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

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