Correct derived briefing weather semantics

This commit is contained in:
2026-08-13 00:58:04 +00:00
parent 9b4e53702b
commit daf0c7efd7
5 changed files with 215 additions and 26 deletions

View File

@@ -42,19 +42,22 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
}
if value.HeatIndexMaxF == nil || *value.HeatIndexMaxF != 101 {
t.Fatalf("HeatIndexMaxF = %#v, want 101", value.HeatIndexMaxF)
if value.ApparentTemperatureMaxF == nil || *value.ApparentTemperatureMaxF != 101 {
t.Fatalf("ApparentTemperatureMaxF = %#v, want 101", value.ApparentTemperatureMaxF)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("marshal daily summary: %v", err)
}
jsonText := string(data)
for _, field := range []string{"high_temp_f", "low_temp_f", "daily_precipitation_probability", "most_likely_precipitation_hour", "heat_index_max_f"} {
for _, field := range []string{"high_temp_f", "low_temp_f", "daily_precipitation_probability", "most_likely_precipitation_hour", "apparent_temperature_max_f"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("daily json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "heat_index") {
t.Fatalf("daily json = %s, want no heat-index label for generic apparent temperature", jsonText)
}
for _, removed := range []string{"max_pop_percent", "max_pop_window", "first_precip_hour", "last_precip_hour"} {
if strings.Contains(jsonText, removed) {
t.Fatalf("daily json = %s, want removed field %s omitted", jsonText, removed)
@@ -65,6 +68,54 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
}
}
func TestDerivedDailySummaryPreservesApparentTemperatureMeaning(t *testing.T) {
for _, tt := range []struct {
name string
temperature float64
want int
}{
{name: "hot", temperature: 101, want: 101},
{name: "mild", temperature: 63, want: 63},
{name: "below freezing", temperature: -12, want: -12},
} {
t.Run(tt.name, func(t *testing.T) {
value, err := derivedDailySummaryValue(forecast.DailySummary{
Date: "2026-05-29",
Dayparts: []forecast.DaypartSummary{{
ApparentTemperature: forecast.Range{Max: floatPtr(tt.temperature)},
}},
}, forecast.PrecipTiming{}, "America/Chicago")
if err != nil {
t.Fatalf("derivedDailySummaryValue() error = %v", err)
}
if value.ApparentTemperatureMaxF == nil || *value.ApparentTemperatureMaxF != tt.want {
t.Fatalf("ApparentTemperatureMaxF = %#v, want %d", value.ApparentTemperatureMaxF, tt.want)
}
})
}
}
func TestDerivedDailySummaryLabelsMetricApparentTemperature(t *testing.T) {
start := mustParseModuleTime("2026-05-29T12:00:00-05:00")
period := timeutil.Period{Start: start, End: start.Add(time.Hour)}
daypart := forecast.SummarizeDaypart("afternoon", period, []weatherdata.ForecastPeriod{{
StartTime: period.Start,
EndTime: period.End,
TemperatureC: floatPtr(20),
ApparentTemperatureC: floatPtr(20),
}})
value, err := derivedDailySummaryValue(forecast.DailySummary{
Date: "2026-05-29",
Dayparts: []forecast.DaypartSummary{daypart},
}, forecast.PrecipTiming{}, "America/Chicago")
if err != nil {
t.Fatalf("derivedDailySummaryValue() error = %v", err)
}
if value.ApparentTemperatureMaxF == nil || *value.ApparentTemperatureMaxF != 68 {
t.Fatalf("ApparentTemperatureMaxF = %#v, want converted 68", value.ApparentTemperatureMaxF)
}
}
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Daily)
@@ -390,6 +441,20 @@ func TestDerivedDaypartPromptExportTemperatureTrends(t *testing.T) {
wantTrend: "steady",
wantSteady: "upper 70s",
},
{
name: "rising across zero",
temps: []float64{-5, 5},
wantTrend: "rising",
wantStart: "mid single digits below zero",
wantEnd: "mid 0s",
},
{
name: "falling across zero",
temps: []float64{5, -5},
wantTrend: "falling",
wantStart: "mid 0s",
wantEnd: "mid single digits below zero",
},
}
for _, test := range tests {
@@ -460,6 +525,20 @@ func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) {
wantTrend: "steady",
wantSteady: "upper 70s",
},
{
name: "rising across zero",
temps: []float64{-5, 5},
wantTrend: "rising",
wantStart: "mid single digits below zero",
wantEnd: "mid 0s",
},
{
name: "falling across zero",
temps: []float64{5, -5},
wantTrend: "falling",
wantStart: "mid 0s",
wantEnd: "mid single digits below zero",
},
}
for _, test := range tests {
@@ -506,6 +585,11 @@ func TestTemperaturePhraseF(t *testing.T) {
value: forecast.Range{Max: floatPtr(84)},
want: "mid 80s",
},
{
name: "below zero range",
value: forecast.Range{Min: floatPtr(-9), Max: floatPtr(-1)},
want: "upper single digits below zero to low single digits below zero",
},
{
name: "empty",
value: forecast.Range{},
@@ -521,6 +605,71 @@ func TestTemperaturePhraseF(t *testing.T) {
}
}
func TestTemperatureBandIndexPreservesSignedOrder(t *testing.T) {
values := []int{-11, -10, -9, -5, -1, 0, 1, 9}
previous := temperatureBandIndex(values[0])
for _, value := range values[1:] {
current := temperatureBandIndex(value)
if current < previous {
t.Fatalf("temperatureBandIndex(%d) = %d, want at least %d", value, current, previous)
}
previous = current
}
for _, tt := range []struct {
value int
want string
}{
{value: -11, want: "low 10s below zero"},
{value: -10, want: "low 10s below zero"},
{value: -9, want: "upper single digits below zero"},
{value: -5, want: "mid single digits below zero"},
{value: -1, want: "low single digits below zero"},
{value: 0, want: "low 0s"},
{value: 1, want: "low 0s"},
{value: 9, want: "upper 0s"},
} {
if got := temperatureBandPhrase(tt.value); got != tt.want {
t.Fatalf("temperatureBandPhrase(%d) = %q, want %q", tt.value, got, tt.want)
}
}
}
func TestOutdoorWindowsScoreSnowIceAndFog(t *testing.T) {
for _, tt := range []struct {
name string
indicators forecast.Indicators
reason string
}{
{name: "snow", indicators: forecast.Indicators{Snow: true}, reason: "snow risk"},
{name: "ice", indicators: forecast.Indicators{Ice: true}, reason: "ice risk"},
{name: "fog", indicators: forecast.Indicators{Fog: true}, reason: "fog risk"},
} {
t.Run(tt.name, func(t *testing.T) {
window := scoreOutdoorWindow(forecast.DaypartSummary{Name: tt.name, Indicators: tt.indicators})
if window.Score != outdoorIndicatorRiskScore || !containsString(window.Reasons, tt.reason) || containsString(window.Reasons, "quiet weather") {
t.Fatalf("outdoor window = %#v, want indicator risk without quiet weather", window)
}
})
}
dayparts := []forecast.DaypartSummary{
{Name: "snow", HourlyPeriods: []weatherdata.ForecastPeriod{{}}, Indicators: forecast.Indicators{Snow: true}},
{Name: "ice and fog", HourlyPeriods: []weatherdata.ForecastPeriod{{}}, Indicators: forecast.Indicators{Ice: true, Fog: true}},
}
windows := buildOutdoorWindows(dayparts)
if windows.Best == nil || windows.Best.Daypart != "snow" || windows.Worst == nil || windows.Worst.Daypart != "ice and fog" {
t.Fatalf("outdoor windows = %#v, want mixed hazards ranked by accumulated risk", windows)
}
tied := buildOutdoorWindows([]forecast.DaypartSummary{
{Name: "first", HourlyPeriods: []weatherdata.ForecastPeriod{{}}, Indicators: forecast.Indicators{Snow: true}},
{Name: "second", HourlyPeriods: []weatherdata.ForecastPeriod{{}}, Indicators: forecast.Indicators{Ice: true}},
})
if tied.Best == nil || tied.Best.Daypart != "first" || tied.Worst == nil || tied.Worst.Daypart != "first" {
t.Fatalf("tied outdoor windows = %#v, want input-order tie behavior", tied)
}
}
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Tomorrow)