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
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user