diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index 69de6ed..df380ac 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -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 diff --git a/internal/briefing/derived_daily_summary_module.go b/internal/briefing/derived_daily_summary_module.go index a27f75d..62f5346 100644 --- a/internal/briefing/derived_daily_summary_module.go +++ b/internal/briefing/derived_daily_summary_module.go @@ -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) diff --git a/internal/briefing/derived_daypart_summaries_module.go b/internal/briefing/derived_daypart_summaries_module.go index 224d0fd..3e3223d 100644 --- a/internal/briefing/derived_daypart_summaries_module.go +++ b/internal/briefing/derived_daypart_summaries_module.go @@ -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 { diff --git a/internal/briefing/derived_modules_test.go b/internal/briefing/derived_modules_test.go index 397c04e..eba8c14 100644 --- a/internal/briefing/derived_modules_test.go +++ b/internal/briefing/derived_modules_test.go @@ -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) diff --git a/internal/briefing/summary_helpers.go b/internal/briefing/summary_helpers.go index 49af3d9..a0f6fe7 100644 --- a/internal/briefing/summary_helpers.go +++ b/internal/briefing/summary_helpers.go @@ -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") }