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

@@ -32,6 +32,13 @@ discussion, and weather story. Derived builders shape daily and daypart
summaries, precipitation timing, outdoor windows, and the report-specific summaries, precipitation timing, outdoor windows, and the report-specific
Daily, Today, and Tomorrow planning values. Daily, Today, and Tomorrow planning values.
The daily summary preserves generic feels-like values as
`apparent_temperature_max_f`; it does not label them as a heat index. Daypart
temperature phrases retain below-zero meaning, including through temperature
trends that cross zero. Outdoor windows add a 25-point risk penalty and an
explicit reason for each snow, ice, or fog indicator. Equal scores retain input
order for both best and worst windows.
The module registry preserves rich values for templates and snapshots while The module registry preserves rich values for templates and snapshots while
curating prompt exports where needed. In particular, source warnings are a curating prompt exports where needed. In particular, source warnings are a
metadata summary, checked-empty alerts and SPC outlooks remain distinct from metadata summary, checked-empty alerts and SPC outlooks remain distinct from

View File

@@ -16,7 +16,7 @@ type DerivedDailySummaryModule struct {
MostLikelyPrecipitationHour string `json:"most_likely_precipitation_hour,omitempty"` MostLikelyPrecipitationHour string `json:"most_likely_precipitation_hour,omitempty"`
ThunderMentioned bool `json:"thunder_mentioned"` ThunderMentioned bool `json:"thunder_mentioned"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"` ApparentTemperatureMaxF *int `json:"apparent_temperature_max_f,omitempty"`
DominantConditions []string `json:"dominant_conditions,omitempty"` DominantConditions []string `json:"dominant_conditions,omitempty"`
Hazards []string `json:"hazards,omitempty"` Hazards []string `json:"hazards,omitempty"`
} }
@@ -72,7 +72,7 @@ func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.Pre
} else { } else {
value.LowTempF = roundedInt(temperature.Min) value.LowTempF = roundedInt(temperature.Min)
} }
value.HeatIndexMaxF = roundedInt(apparent.Max) value.ApparentTemperatureMaxF = roundedInt(apparent.Max)
narrativePrecipitation := narrativeMaxPrecipitation(summary.NarrativePeriods) narrativePrecipitation := narrativeMaxPrecipitation(summary.NarrativePeriods)
if narrativePrecipitation != nil { if narrativePrecipitation != nil {
value.DailyPrecipitationProbability = roundedInt(&narrativePrecipitation.Value) value.DailyPrecipitationProbability = roundedInt(&narrativePrecipitation.Value)

View File

@@ -301,18 +301,8 @@ func celsiusToFahrenheit(value float64) float64 {
} }
func temperatureBandIndex(value int) int { func temperatureBandIndex(value int) int {
decade := (value / 10) * 10 decade, remainder := temperatureBandParts(value)
remainder := value - decade band := temperatureBandQualifierIndex(remainder)
if remainder < 0 {
remainder = -remainder
}
band := 1
switch {
case remainder <= 3:
band = 0
case remainder >= 7:
band = 2
}
return decade*3 + band return decade*3 + band
} }
@@ -348,19 +338,47 @@ func temperaturePhraseF(value forecast.Range) string {
} }
func temperatureBandPhrase(value int) string { func temperatureBandPhrase(value int) string {
decade := (value / 10) * 10 if value < 0 {
remainder := value - decade decade, remainder := temperatureBandParts(-value)
if remainder < 0 { qualifier := temperatureBandQualifier(remainder)
remainder = -remainder if decade == 0 {
return fmt.Sprintf("%s single digits below zero", qualifier)
}
return fmt.Sprintf("%s %ds below zero", qualifier, decade)
} }
qualifier := "mid" decade, remainder := temperatureBandParts(value)
qualifier := temperatureBandQualifier(remainder)
return fmt.Sprintf("%s %ds", qualifier, decade)
}
func temperatureBandParts(value int) (int, int) {
decade := value / 10
if value < 0 && value%10 != 0 {
decade--
}
return decade * 10, value - decade*10
}
func temperatureBandQualifierIndex(remainder int) int {
switch { switch {
case remainder <= 3: case remainder <= 3:
qualifier = "low" return 0
case remainder >= 7: case remainder >= 7:
qualifier = "upper" return 2
default:
return 1
}
}
func temperatureBandQualifier(remainder int) string {
switch temperatureBandQualifierIndex(remainder) {
case 0:
return "low"
case 2:
return "upper"
default:
return "mid"
} }
return fmt.Sprintf("%s %ds", qualifier, decade)
} }
func sentenceCase(value string) string { func sentenceCase(value string) string {

View File

@@ -42,19 +42,22 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 { if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph) t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
} }
if value.HeatIndexMaxF == nil || *value.HeatIndexMaxF != 101 { if value.ApparentTemperatureMaxF == nil || *value.ApparentTemperatureMaxF != 101 {
t.Fatalf("HeatIndexMaxF = %#v, want 101", value.HeatIndexMaxF) t.Fatalf("ApparentTemperatureMaxF = %#v, want 101", value.ApparentTemperatureMaxF)
} }
data, err := json.Marshal(output.Value) data, err := json.Marshal(output.Value)
if err != nil { if err != nil {
t.Fatalf("marshal daily summary: %v", err) t.Fatalf("marshal daily summary: %v", err)
} }
jsonText := string(data) 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) { if !strings.Contains(jsonText, field) {
t.Fatalf("daily json = %s, want field %s", 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"} { for _, removed := range []string{"max_pop_percent", "max_pop_window", "first_precip_hour", "last_precip_hour"} {
if strings.Contains(jsonText, removed) { if strings.Contains(jsonText, removed) {
t.Fatalf("daily json = %s, want removed field %s omitted", 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) { func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Daily) ctx := derivedModuleContext(report.Daily)
@@ -390,6 +441,20 @@ func TestDerivedDaypartPromptExportTemperatureTrends(t *testing.T) {
wantTrend: "steady", wantTrend: "steady",
wantSteady: "upper 70s", 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 { for _, test := range tests {
@@ -460,6 +525,20 @@ func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) {
wantTrend: "steady", wantTrend: "steady",
wantSteady: "upper 70s", 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 { for _, test := range tests {
@@ -506,6 +585,11 @@ func TestTemperaturePhraseF(t *testing.T) {
value: forecast.Range{Max: floatPtr(84)}, value: forecast.Range{Max: floatPtr(84)},
want: "mid 80s", 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", name: "empty",
value: forecast.Range{}, 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) { func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Tomorrow) ctx := derivedModuleContext(report.Tomorrow)

View File

@@ -41,6 +41,8 @@ type TodayPlanning struct {
LateDayChangeWatch []string LateDayChangeWatch []string
} }
const outdoorIndicatorRiskScore = 25
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows { func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
var best *OutdoorWindow var best *OutdoorWindow
var worst *OutdoorWindow var worst *OutdoorWindow
@@ -275,7 +277,7 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
reasons = append(reasons, "alert overlap") reasons = append(reasons, "alert overlap")
} }
if daypart.Indicators.Heat || daypart.Indicators.Cold { if daypart.Indicators.Heat || daypart.Indicators.Cold {
score += 25 score += outdoorIndicatorRiskScore
if daypart.Indicators.Heat { if daypart.Indicators.Heat {
reasons = append(reasons, "heat risk") reasons = append(reasons, "heat risk")
} }
@@ -283,6 +285,19 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
reasons = append(reasons, "cold risk") reasons = append(reasons, "cold risk")
} }
} }
for _, hazard := range []struct {
present bool
reason string
}{
{present: daypart.Indicators.Snow, reason: "snow risk"},
{present: daypart.Indicators.Ice, reason: "ice risk"},
{present: daypart.Indicators.Fog, reason: "fog risk"},
} {
if hazard.present {
score += outdoorIndicatorRiskScore
reasons = append(reasons, hazard.reason)
}
}
if len(reasons) == 0 { if len(reasons) == 0 {
reasons = append(reasons, "quiet weather") reasons = append(reasons, "quiet weather")
} }