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

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