Correct derived briefing weather semantics
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user