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
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
curating prompt exports where needed. In particular, source warnings are a
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"`
ThunderMentioned bool `json:"thunder_mentioned"`
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"`
Hazards []string `json:"hazards,omitempty"`
}
@@ -72,7 +72,7 @@ func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.Pre
} else {
value.LowTempF = roundedInt(temperature.Min)
}
value.HeatIndexMaxF = roundedInt(apparent.Max)
value.ApparentTemperatureMaxF = roundedInt(apparent.Max)
narrativePrecipitation := narrativeMaxPrecipitation(summary.NarrativePeriods)
if narrativePrecipitation != nil {
value.DailyPrecipitationProbability = roundedInt(&narrativePrecipitation.Value)

View File

@@ -301,18 +301,8 @@ func celsiusToFahrenheit(value float64) float64 {
}
func temperatureBandIndex(value int) int {
decade := (value / 10) * 10
remainder := value - decade
if remainder < 0 {
remainder = -remainder
}
band := 1
switch {
case remainder <= 3:
band = 0
case remainder >= 7:
band = 2
}
decade, remainder := temperatureBandParts(value)
band := temperatureBandQualifierIndex(remainder)
return decade*3 + band
}
@@ -348,19 +338,47 @@ func temperaturePhraseF(value forecast.Range) string {
}
func temperatureBandPhrase(value int) string {
decade := (value / 10) * 10
remainder := value - decade
if remainder < 0 {
remainder = -remainder
if value < 0 {
decade, remainder := temperatureBandParts(-value)
qualifier := temperatureBandQualifier(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 {
case remainder <= 3:
qualifier = "low"
return 0
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 {

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)

View File

@@ -41,6 +41,8 @@ type TodayPlanning struct {
LateDayChangeWatch []string
}
const outdoorIndicatorRiskScore = 25
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
var best *OutdoorWindow
var worst *OutdoorWindow
@@ -275,7 +277,7 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
reasons = append(reasons, "alert overlap")
}
if daypart.Indicators.Heat || daypart.Indicators.Cold {
score += 25
score += outdoorIndicatorRiskScore
if daypart.Indicators.Heat {
reasons = append(reasons, "heat risk")
}
@@ -283,6 +285,19 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
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 {
reasons = append(reasons, "quiet weather")
}