Preserve metric forecast units and overnight alerts

This commit is contained in:
2026-08-13 00:45:08 +00:00
parent 8fafacf921
commit 8e49ba88c7
5 changed files with 230 additions and 45 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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} {

View File

@@ -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
}

View File

@@ -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)
}
}