From 8e49ba88c79943e373edb503a281dd117e6ea2d3 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 13 Aug 2026 00:45:08 +0000 Subject: [PATCH] Preserve metric forecast units and overnight alerts --- docs/internal/facts.md | 2 + docs/internal/forecast-derivation.md | 8 ++ internal/facts/facts_test.go | 27 +++++ internal/forecast/derive.go | 71 +++++------- internal/forecast/derive_test.go | 167 ++++++++++++++++++++++++++- 5 files changed, 230 insertions(+), 45 deletions(-) diff --git a/docs/internal/facts.md b/docs/internal/facts.md index d9714d0..eb78270 100644 --- a/docs/internal/facts.md +++ b/docs/internal/facts.md @@ -37,6 +37,8 @@ Report identity controls the summary shape: `DaypartSummaries` is collected from the resulting daily summaries. The detailed grouping, daypart-window, and alert rules are owned by [forecast derivation](forecast-derivation.md). +Daily alert overlaps remain scoped to the civil day, while overnight daypart +summaries retain alerts that overlap their complete next-day window. ## Missing data and failures diff --git a/docs/internal/forecast-derivation.md b/docs/internal/forecast-derivation.md index 74e3901..4413180 100644 --- a/docs/internal/forecast-derivation.md +++ b/docs/internal/forecast-derivation.md @@ -33,9 +33,17 @@ sorts periods, records the maximum and first precipitation, groups contiguous periods at or above its package-owned probability threshold, and records thunder mentions. +Daypart temperature and apparent-temperature ranges are Fahrenheit values, and +timed wind maxima are mph values. When only metric source fields are present, +they are converted to those units before they are stored or evaluated against +indicator thresholds; populated US-customary fields take precedence. + Alert overlap parsing supports the normalized alert payload's available timing fields. Unparseable alerts and invalid intervals are ignored; valid overlaps are clipped to the requested period and ordered by alert start time. +`DailySummary.AlertOverlaps` is limited to the local civil day, while each +daypart evaluates the full alert run against its complete window, including the +next-day portion of an overnight window. ## Missing data and failures diff --git a/internal/facts/facts_test.go b/internal/facts/facts_test.go index 77e23ae..9696e65 100644 --- a/internal/facts/facts_test.go +++ b/internal/facts/facts_test.go @@ -100,6 +100,33 @@ func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) { } } +func TestBuildDerivedDailyIncludesNextDayOvernightAlerts(t *testing.T) { + location := testLocation() + bundle := testBundle(location) + bundle.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{ + json.RawMessage(`{"event":"Overnight Advisory","onset":"2026-05-30T01:00:00-05:00","ends":"2026-05-30T05:00:00-05:00"}`), + }} + derived, err := BuildDerived(BuildDerivedRequest{ + Resolved: resolveForTest(t, report.Daily, mustParse("2026-05-29T08:00:00-05:00"), location), + Timezone: location.String(), + Dayparts: []forecast.DaypartDefinition{ + {Name: "night", Start: "22:00", End: "06:00"}, + }, + Collected: BuildCollected(bundle), + }) + if err != nil { + t.Fatalf("BuildDerived() error = %v", err) + } + + summary := derived.FirstDailySummary() + if summary == nil || len(summary.AlertOverlaps) != 0 { + t.Fatalf("DailySummary = %#v, want no next-day daily alerts", summary) + } + if len(derived.DaypartSummaries) != 1 || len(derived.DaypartSummaries[0].AlertOverlaps) != 1 || derived.DaypartSummaries[0].AlertOverlaps[0].Event != "Overnight Advisory" { + t.Fatalf("DaypartSummaries = %#v, want next-day overnight alert", derived.DaypartSummaries) + } +} + func TestBuildDerivedTomorrow(t *testing.T) { location := testLocation() for _, id := range []report.ID{report.Tomorrow} { diff --git a/internal/forecast/derive.go b/internal/forecast/derive.go index d94320b..64ff819 100644 --- a/internal/forecast/derive.go +++ b/internal/forecast/derive.go @@ -211,7 +211,7 @@ func BuildDailySummary(bundle *weatherdata.Bundle, date time.Time, location *tim for _, window := range windows { periods := SelectHourlyPeriods(bundle.Hourly, window.Period) daypartSummary := SummarizeDaypart(window.Name, window.Period, periods) - daypartSummary.AlertOverlaps = overlapsWithin(alerts, window.Period) + daypartSummary.AlertOverlaps = AlertOverlaps(bundle.Alerts, window.Period) summary.Dayparts = append(summary.Dayparts, daypartSummary) } return summary, nil @@ -258,10 +258,10 @@ func SummarizeDaypart(name string, period timeutil.Period, periods []weatherdata for _, forecastPeriod := range periods { addRangeValue(&summary.Temperature, periodTemperatureValues(forecastPeriod)...) - addRangeValue(&summary.ApparentTemperature, valueFromPointers(forecastPeriod.ApparentTemperatureF, forecastPeriod.ApparentTemperatureC)...) + addRangeValue(&summary.ApparentTemperature, temperatureF(forecastPeriod.ApparentTemperatureF, forecastPeriod.ApparentTemperatureC)) setMaxTimedValue(&summary.MaxPrecipitationProbability, forecastPeriod.ProbabilityOfPrecipitationPercent, forecastPeriod.StartTime) - setMaxTimedValue(&summary.PeakWindSpeed, firstValue(forecastPeriod.WindSpeedMph, forecastPeriod.WindSpeedKmh), forecastPeriod.StartTime) - setMaxTimedValue(&summary.PeakWindGust, firstValue(forecastPeriod.WindGustMph, forecastPeriod.WindGustKmh), forecastPeriod.StartTime) + setMaxTimedValue(&summary.PeakWindSpeed, milesPerHour(forecastPeriod.WindSpeedMph, forecastPeriod.WindSpeedKmh), forecastPeriod.StartTime) + setMaxTimedValue(&summary.PeakWindGust, milesPerHour(forecastPeriod.WindGustMph, forecastPeriod.WindGustKmh), forecastPeriod.StartTime) text := strings.TrimSpace(forecastPeriod.TextDescription) if text != "" { @@ -278,29 +278,33 @@ func SummarizeDaypart(name string, period timeutil.Period, periods []weatherdata } func periodTemperatureValues(period weatherdata.ForecastPeriod) []*float64 { - values := []*float64{} - values = append(values, valueFromPointers(period.TemperatureF, period.TemperatureC)...) - values = append(values, valueFromPointers(period.TemperatureFMin, period.TemperatureCMin)...) - values = append(values, valueFromPointers(period.TemperatureFMax, period.TemperatureCMax)...) - return values + return []*float64{ + temperatureF(period.TemperatureF, period.TemperatureC), + temperatureF(period.TemperatureFMin, period.TemperatureCMin), + temperatureF(period.TemperatureFMax, period.TemperatureCMax), + } } -func valueFromPointers(values ...*float64) []*float64 { - for _, value := range values { - if value != nil { - return []*float64{value} - } +func temperatureF(fahrenheit *float64, celsius *float64) *float64 { + if fahrenheit != nil { + return fahrenheit } - return nil + if celsius == nil { + return nil + } + converted := *celsius*9/5 + 32 + return &converted } -func firstValue(values ...*float64) *float64 { - for _, value := range values { - if value != nil { - return value - } +func milesPerHour(mph *float64, kmh *float64) *float64 { + if mph != nil { + return mph } - return nil + if kmh == nil { + return nil + } + converted := *kmh / 1.609344 + return &converted } func addRangeValue(target *Range, values ...*float64) { @@ -364,15 +368,13 @@ func mentionsThunder(text string) bool { } func numericIndicators(period weatherdata.ForecastPeriod) Indicators { - windGust := firstValue(period.WindGustMph, period.WindGustKmh) - windSpeed := firstValue(period.WindSpeedMph, period.WindSpeedKmh) + windGust := milesPerHour(period.WindGustMph, period.WindGustKmh) + windSpeed := milesPerHour(period.WindSpeedMph, period.WindSpeedKmh) + temperature := temperatureF(period.TemperatureF, period.TemperatureC) indicators := Indicators{} - if period.TemperatureF != nil { - indicators.Heat = *period.TemperatureF >= 95 - indicators.Cold = *period.TemperatureF <= 32 - } else if period.TemperatureC != nil { - indicators.Heat = *period.TemperatureC >= 35 - indicators.Cold = *period.TemperatureC <= 0 + if temperature != nil { + indicators.Heat = *temperature >= 95 + indicators.Cold = *temperature <= 32 } if windGust != nil && *windGust >= 35 || windSpeed != nil && *windSpeed >= 25 { indicators.Wind = true @@ -501,14 +503,3 @@ func intersect(left timeutil.Period, right timeutil.Period) timeutil.Period { } return timeutil.Period{Start: start, End: end} } - -func overlapsWithin(alerts []AlertOverlap, period timeutil.Period) []AlertOverlap { - var out []AlertOverlap - for _, alert := range alerts { - if alert.Period.Overlaps(period) { - alert.Overlap = intersect(alert.Period, period) - out = append(out, alert) - } - } - return out -} diff --git a/internal/forecast/derive_test.go b/internal/forecast/derive_test.go index 39afff0..f526cd6 100644 --- a/internal/forecast/derive_test.go +++ b/internal/forecast/derive_test.go @@ -2,6 +2,7 @@ package forecast import ( "encoding/json" + "math" "os" "path/filepath" "testing" @@ -99,11 +100,17 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) { func TestOvernightGroupingAcrossMidnight(t *testing.T) { location := time.FixedZone("Test", -5*60*60) date := time.Date(2026, 5, 29, 12, 0, 0, 0, location) - bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{ - hour(location, "2026-05-29T23:00:00-05:00", "2026-05-30T00:00:00-05:00", "Snow", 31, nil, nil, nil, nil), - hour(location, "2026-05-30T05:00:00-05:00", "2026-05-30T06:00:00-05:00", "Fog", 30, nil, nil, nil, nil), - hour(location, "2026-05-30T06:00:00-05:00", "2026-05-30T07:00:00-05:00", "Clear", 35, nil, nil, nil, nil), - }}} + bundle := &weatherdata.Bundle{ + Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{ + hour(location, "2026-05-29T23:00:00-05:00", "2026-05-30T00:00:00-05:00", "Snow", 31, nil, nil, nil, nil), + hour(location, "2026-05-30T05:00:00-05:00", "2026-05-30T06:00:00-05:00", "Fog", 30, nil, nil, nil, nil), + hour(location, "2026-05-30T06:00:00-05:00", "2026-05-30T07:00:00-05:00", "Clear", 35, nil, nil, nil, nil), + }}, + Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{ + json.RawMessage(`{"event":"Overnight Advisory","onset":"2026-05-30T01:00:00-05:00","ends":"2026-05-30T05:00:00-05:00"}`), + json.RawMessage(`{"event":"After Night","onset":"2026-05-30T06:00:00-05:00","ends":"2026-05-30T07:00:00-05:00"}`), + }}, + } summary, err := BuildDailySummary(bundle, date, location, []DaypartDefinition{ {Name: "night", Start: "22:00", End: "06:00"}, @@ -118,6 +125,142 @@ func TestOvernightGroupingAcrossMidnight(t *testing.T) { if !night.Indicators.Snow || !night.Indicators.Fog || !night.Indicators.Cold { t.Fatalf("night indicators = %#v, want snow, fog, and cold", night.Indicators) } + if len(summary.AlertOverlaps) != 0 { + t.Fatalf("daily AlertOverlaps = %#v, want next-day alerts excluded", summary.AlertOverlaps) + } + if len(night.AlertOverlaps) != 1 || night.AlertOverlaps[0].Event != "Overnight Advisory" { + t.Fatalf("night AlertOverlaps = %#v, want complete overnight overlap", night.AlertOverlaps) + } + if night.AlertOverlaps[0].Overlap.Start.Format(time.RFC3339) != "2026-05-30T01:00:00-05:00" || night.AlertOverlaps[0].Overlap.End.Format(time.RFC3339) != "2026-05-30T05:00:00-05:00" { + t.Fatalf("night alert overlap = %#v, want source alert clipped to the overnight window", night.AlertOverlaps[0].Overlap) + } +} + +func TestSummarizeDaypartNormalizesMetricFallbacks(t *testing.T) { + period := timeutil.Period{Start: mustParse("2026-05-29T06:00:00-05:00"), End: mustParse("2026-05-29T07:00:00-05:00")} + + for _, tt := range []struct { + name string + temperature float64 + windSpeed float64 + windGust float64 + want Indicators + }{ + {name: "below thresholds", temperature: 94, windSpeed: 24, windGust: 34}, + {name: "at thresholds", temperature: 95, windSpeed: 25, windGust: 35, want: Indicators{Heat: true, Wind: true}}, + {name: "above thresholds", temperature: 96, windSpeed: 26, windGust: 36, want: Indicators{Heat: true, Wind: true}}, + } { + t.Run(tt.name, func(t *testing.T) { + us := weatherdata.ForecastPeriod{TemperatureF: ptr(tt.temperature), WindSpeedMph: ptr(tt.windSpeed), WindGustMph: ptr(tt.windGust)} + metric := weatherdata.ForecastPeriod{TemperatureC: ptr((tt.temperature - 32) * 5 / 9), WindSpeedKmh: ptr(tt.windSpeed * 1.609344), WindGustKmh: ptr(tt.windGust * 1.609344)} + both := metric + both.TemperatureF = ptr(tt.temperature) + both.WindSpeedMph = ptr(tt.windSpeed) + both.WindGustMph = ptr(tt.windGust) + both.TemperatureC = ptr(-40) + both.WindSpeedKmh = ptr(0) + both.WindGustKmh = ptr(0) + + for _, source := range []struct { + name string + period weatherdata.ForecastPeriod + }{ + {name: "us customary", period: us}, + {name: "metric", period: metric}, + {name: "both", period: both}, + } { + t.Run(source.name, func(t *testing.T) { + if got := numericIndicators(source.period); got != tt.want { + t.Fatalf("numericIndicators() = %#v, want %#v", got, tt.want) + } + }) + } + }) + } + + for _, tt := range []struct { + name string + temperature float64 + want Indicators + }{ + {name: "cold above threshold", temperature: 33}, + {name: "cold at threshold", temperature: 32, want: Indicators{Cold: true}}, + {name: "cold below threshold", temperature: 31, want: Indicators{Cold: true}}, + } { + t.Run(tt.name, func(t *testing.T) { + us := weatherdata.ForecastPeriod{TemperatureF: ptr(tt.temperature)} + metric := weatherdata.ForecastPeriod{TemperatureC: ptr((tt.temperature - 32) * 5 / 9)} + both := metric + both.TemperatureF = ptr(tt.temperature) + both.TemperatureC = ptr(40) + + for _, source := range []struct { + name string + period weatherdata.ForecastPeriod + }{ + {name: "us customary", period: us}, + {name: "metric", period: metric}, + {name: "both", period: both}, + } { + t.Run(source.name, func(t *testing.T) { + if got := numericIndicators(source.period); got != tt.want { + t.Fatalf("numericIndicators() = %#v, want %#v", got, tt.want) + } + }) + } + }) + } + + us := weatherdata.ForecastPeriod{ + StartTime: period.Start, + EndTime: period.End, + TemperatureF: ptr(68), + TemperatureFMin: ptr(50), + TemperatureFMax: ptr(86), + ApparentTemperatureF: ptr(69.8), + WindSpeedMph: ptr(31.068559), + WindGustMph: ptr(37.282271), + } + metric := weatherdata.ForecastPeriod{ + StartTime: period.Start, + EndTime: period.End, + TemperatureC: ptr(20), + TemperatureCMin: ptr(10), + TemperatureCMax: ptr(30), + ApparentTemperatureC: ptr(21), + WindSpeedKmh: ptr(50), + WindGustKmh: ptr(60), + } + both := metric + both.TemperatureF = us.TemperatureF + both.TemperatureFMin = us.TemperatureFMin + both.TemperatureFMax = us.TemperatureFMax + both.ApparentTemperatureF = us.ApparentTemperatureF + both.WindSpeedMph = us.WindSpeedMph + both.WindGustMph = us.WindGustMph + both.TemperatureC = ptr(-40) + both.TemperatureCMin = ptr(-40) + both.TemperatureCMax = ptr(-40) + both.ApparentTemperatureC = ptr(-40) + both.WindSpeedKmh = ptr(0) + both.WindGustKmh = ptr(0) + + for _, source := range []struct { + name string + period weatherdata.ForecastPeriod + }{ + {name: "us customary", period: us}, + {name: "metric", period: metric}, + {name: "both", period: both}, + } { + t.Run(source.name, func(t *testing.T) { + summary := SummarizeDaypart("morning", period, []weatherdata.ForecastPeriod{source.period}) + assertRangeClose(t, "temperature", summary.Temperature, 50, 86) + assertRangeClose(t, "apparent temperature", summary.ApparentTemperature, 69.8, 69.8) + assertTimedValueClose(t, "peak wind speed", summary.PeakWindSpeed, 31.068559) + assertTimedValueClose(t, "peak wind gust", summary.PeakWindGust, 37.282271) + }) + } } func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) { @@ -466,3 +609,17 @@ func assertRange(t *testing.T, name string, got Range, wantMin float64, wantMax t.Fatalf("%s = [%v,%v], want [%v,%v]", name, *got.Min, *got.Max, wantMin, wantMax) } } + +func assertRangeClose(t *testing.T, name string, got Range, wantMin float64, wantMax float64) { + t.Helper() + if got.Min == nil || got.Max == nil || math.Abs(*got.Min-wantMin) > 0.000001 || math.Abs(*got.Max-wantMax) > 0.000001 { + t.Fatalf("%s = %#v, want range [%v, %v]", name, got, wantMin, wantMax) + } +} + +func assertTimedValueClose(t *testing.T, name string, got *TimedValue, want float64) { + t.Helper() + if got == nil || math.Abs(got.Value-want) > 0.000001 { + t.Fatalf("%s = %#v, want %v", name, got, want) + } +}