diff --git a/internal/app/app_test.go b/internal/app/app_test.go index cb4dec7..f65a28d 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1356,10 +1356,10 @@ func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Sna precip := 10 snapshot, err := module.NewSnapshot([]module.Output{ {ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]any{ - "date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout), - "low_temp_f": low, - "high_temp_f": high, - "max_pop_percent": precip, + "date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout), + "low_temp_f": low, + "high_temp_f": high, + "daily_precipitation_probability": precip, }}, {ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{ "morning": map[string]any{ diff --git a/internal/briefing/derived_daily_summary_module.go b/internal/briefing/derived_daily_summary_module.go index ee243ec..ee295fb 100644 --- a/internal/briefing/derived_daily_summary_module.go +++ b/internal/briefing/derived_daily_summary_module.go @@ -5,22 +5,20 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" - "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) type DerivedDailySummaryModule struct { - Date string `json:"date,omitempty"` - HighTempF *int `json:"high_temp_f,omitempty"` - LowTempF *int `json:"low_temp_f,omitempty"` - MaxPopPercent *int `json:"max_pop_percent,omitempty"` - MaxPopWindow string `json:"max_pop_window,omitempty"` - FirstPrecipHour string `json:"first_precip_hour,omitempty"` - LastPrecipHour string `json:"last_precip_hour,omitempty"` - ThunderMentioned bool `json:"thunder_mentioned"` - MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` - HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"` - DominantConditions []string `json:"dominant_conditions,omitempty"` - Hazards []string `json:"hazards,omitempty"` + Date string `json:"date,omitempty"` + HighTempF *int `json:"high_temp_f,omitempty"` + LowTempF *int `json:"low_temp_f,omitempty"` + DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"` + 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"` + DominantConditions []string `json:"dominant_conditions,omitempty"` + Hazards []string `json:"hazards,omitempty"` } func buildDerivedDailySummaryModule(ctx ModuleContext, _ any) (*module.Output, error) { @@ -46,17 +44,10 @@ func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.Pre var apparent forecast.Range var maxPop *forecast.TimedValue var maxGust *forecast.TimedValue - var maxPopWindow timeutil.Period for _, daypart := range summary.Dayparts { addRange(&temperature, daypart.Temperature) addRange(&apparent, daypart.ApparentTemperature) - if daypart.MaxPrecipitationProbability != nil { - if maxPop == nil || daypart.MaxPrecipitationProbability.Value > maxPop.Value { - copied := *daypart.MaxPrecipitationProbability - maxPop = &copied - maxPopWindow = daypart.Period - } - } + maxTimedValue(&maxPop, daypart.MaxPrecipitationProbability) maxTimedValue(&maxGust, daypart.PeakWindGust) if daypart.DominantCondition != "" { conditions[daypart.DominantCondition] = struct{}{} @@ -70,19 +61,112 @@ func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.Pre hazards[alert.Event] = struct{}{} } } - value.HighTempF = roundedInt(temperature.Max) - value.LowTempF = roundedInt(temperature.Min) + narrativeTemperature := narrativeTemperatureRange(summary.NarrativePeriods) + if narrativeTemperature.Max != nil { + value.HighTempF = roundedInt(narrativeTemperature.Max) + } else { + value.HighTempF = roundedInt(temperature.Max) + } + if narrativeTemperature.Min != nil { + value.LowTempF = roundedInt(narrativeTemperature.Min) + } else { + value.LowTempF = roundedInt(temperature.Min) + } value.HeatIndexMaxF = roundedInt(apparent.Max) + narrativePrecipitation := narrativeMaxPrecipitation(summary.NarrativePeriods) + if narrativePrecipitation != nil { + value.DailyPrecipitationProbability = roundedInt(&narrativePrecipitation.Value) + } else if maxPop != nil { + value.DailyPrecipitationProbability = roundedInt(&maxPop.Value) + } if maxPop != nil { - value.MaxPopPercent = roundedInt(&maxPop.Value) - value.MaxPopWindow = periodClockLabel(maxPopWindow, timezone) + value.MostLikelyPrecipitationHour = mostLikelyPrecipitationHour(maxPop, timezone) } if maxGust != nil { value.MaxWindGustMph = roundedInt(&maxGust.Value) } - value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone) - value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone) - value.DominantConditions = sortedSet(conditions) + value.DominantConditions = narrativeConditions(summary.NarrativePeriods) + if len(value.DominantConditions) == 0 { + value.DominantConditions = sortedSet(conditions) + } value.Hazards = sortedSet(hazards) return value, nil } + +func narrativeTemperatureRange(periods []weatherdata.ForecastPeriod) forecast.Range { + var out forecast.Range + for _, period := range periods { + addNarrativeHigh(&out, period.TemperatureFMax) + addNarrativeLow(&out, period.TemperatureFMin) + if period.TemperatureF != nil && period.IsDay != nil { + if *period.IsDay { + addNarrativeHigh(&out, period.TemperatureF) + } else { + addNarrativeLow(&out, period.TemperatureF) + } + } + } + return out +} + +func addNarrativeHigh(target *forecast.Range, value *float64) { + if value == nil { + return + } + if target.Max == nil || *value > *target.Max { + copied := *value + target.Max = &copied + } +} + +func addNarrativeLow(target *forecast.Range, value *float64) { + if value == nil { + return + } + if target.Min == nil || *value < *target.Min { + copied := *value + target.Min = &copied + } +} + +func narrativeMaxPrecipitation(periods []weatherdata.ForecastPeriod) *forecast.TimedValue { + var maxPop *forecast.TimedValue + for _, period := range periods { + if period.ProbabilityOfPrecipitationPercent == nil { + continue + } + value := forecast.TimedValue{ + Value: *period.ProbabilityOfPrecipitationPercent, + Time: period.StartTime, + } + maxTimedValue(&maxPop, &value) + } + return maxPop +} + +func narrativeConditions(periods []weatherdata.ForecastPeriod) []string { + seen := map[string]struct{}{} + var out []string + for _, period := range periods { + if period.TextDescription == "" { + continue + } + if _, ok := seen[period.TextDescription]; ok { + continue + } + seen[period.TextDescription] = struct{}{} + out = append(out, period.TextDescription) + } + return out +} + +func mostLikelyPrecipitationHour(maxPop *forecast.TimedValue, timezone string) string { + if maxPop == nil || maxPop.Value <= 0 { + return "" + } + percent := roundedInt(&maxPop.Value) + if percent == nil { + return "" + } + return fmt.Sprintf("%d%% at %s", *percent, clockLabel(maxPop.Time, timezone)) +} diff --git a/internal/briefing/derived_modules_test.go b/internal/briefing/derived_modules_test.go index 8bbfcd2..5e76dc4 100644 --- a/internal/briefing/derived_modules_test.go +++ b/internal/briefing/derived_modules_test.go @@ -24,14 +24,17 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) { } value := moduleValue[DerivedDailySummaryModule](t, output) - if value.HighTempF == nil || *value.HighTempF != 96 || value.LowTempF == nil || *value.LowTempF != 31 { - t.Fatalf("daily temperatures = %#v/%#v, want 96/31", value.HighTempF, value.LowTempF) + if value.HighTempF == nil || *value.HighTempF != 88 || value.LowTempF == nil || *value.LowTempF != 64 { + t.Fatalf("daily temperatures = %#v/%#v, want narrative 88/64", value.HighTempF, value.LowTempF) } - if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || value.MaxPopWindow != "12 PM-6 PM" { - t.Fatalf("max precip = %#v %q, want 80 and afternoon window", value.MaxPopPercent, value.MaxPopWindow) + if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 55 { + t.Fatalf("DailyPrecipitationProbability = %#v, want narrative 55", value.DailyPrecipitationProbability) } - if value.FirstPrecipHour != "8 AM" || value.LastPrecipHour != "2 PM" || !value.ThunderMentioned { - t.Fatalf("precip timing = %#v, want threshold windows from morning through afternoon thunder", value) + if value.MostLikelyPrecipitationHour != "80% at 12 PM" || !value.ThunderMentioned { + t.Fatalf("precip timing = %#v, want most likely hour and thunder", value) + } + if strings.Join(value.DominantConditions, "|") != "Morning storms, then partly sunny.|Clouds linger tonight." { + t.Fatalf("DominantConditions = %#v, want ordered narrative conditions", value.DominantConditions) } if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 { t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph) @@ -44,16 +47,43 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) { t.Fatalf("marshal daily summary: %v", err) } jsonText := string(data) - for _, field := range []string{"high_temp_f", "low_temp_f", "max_pop_percent", "first_precip_hour", "heat_index_max_f"} { + for _, field := range []string{"high_temp_f", "low_temp_f", "daily_precipitation_probability", "most_likely_precipitation_hour", "heat_index_max_f"} { if !strings.Contains(jsonText, field) { t.Fatalf("daily json = %s, want field %s", jsonText, field) } } + 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) + } + } if strings.Contains(jsonText, "qpf") { t.Fatalf("daily json = %s, want no QPF fields without upstream QPF facts", jsonText) } } +func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := derivedModuleContext(report.DailyToday) + ctx.Derived.DailySummaries[0].NarrativePeriods = nil + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + value := moduleValue[DerivedDailySummaryModule](t, output) + + if value.HighTempF == nil || *value.HighTempF != 96 || value.LowTempF == nil || *value.LowTempF != 31 { + t.Fatalf("daily temperatures = %#v/%#v, want fallback 96/31", value.HighTempF, value.LowTempF) + } + if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 80 { + t.Fatalf("DailyPrecipitationProbability = %#v, want hourly fallback 80", value.DailyPrecipitationProbability) + } + if len(value.DominantConditions) == 0 || !containsString(value.DominantConditions, "Thunderstorms with gusty wind") { + t.Fatalf("DominantConditions = %#v, want fallback daypart conditions", value.DominantConditions) + } +} + func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := derivedModuleContext(report.DailyToday) @@ -217,14 +247,26 @@ func derivedModuleContext(id report.ID) ModuleContext { } narrative := []weatherdata.ForecastPeriod{ { - Name: "Today", - StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"), - EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"), - TextDescription: "Morning storms, then partly sunny.", - TemperatureF: floatPtr(81), + Name: "Today", + StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"), + EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"), + IsDay: boolPtr(true), + TextDescription: "Morning storms, then partly sunny.", + TemperatureFMax: floatPtr(88), + ProbabilityOfPrecipitationPercent: floatPtr(55), + }, + { + Name: "Tonight", + StartTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"), + EndTime: mustParseModuleTime("2026-05-30T00:00:00-05:00"), + IsDay: boolPtr(false), + TextDescription: "Clouds linger tonight.", + TemperatureFMin: floatPtr(64), + ProbabilityOfPrecipitationPercent: floatPtr(30), }, } summary.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}} + summary.NarrativePeriods = append([]weatherdata.ForecastPeriod(nil), narrative...) return ModuleContext{ Resolved: report.Resolved{ Definition: definition, @@ -276,3 +318,16 @@ func derivedHour(start string, text string, precip float64, temperature float64, func floatPtr(value float64) *float64 { return &value } + +func boolPtr(value bool) *bool { + return &value +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/internal/changes/daily.go b/internal/changes/daily.go index 897c4d7..30549e3 100644 --- a/internal/changes/daily.go +++ b/internal/changes/daily.go @@ -64,7 +64,7 @@ func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds var changes []Change changes = append(changes, compareTemperatureValues("Low", previousSummary.LowTempF, currentSummary.LowTempF, thresholds.TemperatureDegrees)...) changes = append(changes, compareTemperatureValues("High", previousSummary.HighTempF, currentSummary.HighTempF, thresholds.TemperatureDegrees)...) - changes = append(changes, comparePrecipitationValues(previousSummary.MaxPopPercent, currentSummary.MaxPopPercent, thresholds.PrecipProbabilityPoints, "")...) + changes = append(changes, comparePrecipitationValues(previousSummary.DailyPrecipitationProbability, currentSummary.DailyPrecipitationProbability, thresholds.PrecipProbabilityPoints, "")...) if previousHasTiming && currentHasTiming { changes = append(changes, comparePrecipTiming(previousTiming.MaxPopTime, currentTiming.MaxPopTime, thresholds.PrecipTimingShiftMinutes, "")...) } @@ -76,11 +76,11 @@ func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds } type dailySummaryStanza struct { - Date string `json:"date,omitempty"` - HighTempF *int `json:"high_temp_f,omitempty"` - LowTempF *int `json:"low_temp_f,omitempty"` - MaxPopPercent *int `json:"max_pop_percent,omitempty"` - MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` + Date string `json:"date,omitempty"` + HighTempF *int `json:"high_temp_f,omitempty"` + LowTempF *int `json:"low_temp_f,omitempty"` + DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"` + MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` } type daypartSummaryStanza struct { diff --git a/internal/changes/daily_test.go b/internal/changes/daily_test.go index 5cab971..dfc18e0 100644 --- a/internal/changes/daily_test.go +++ b/internal/changes/daily_test.go @@ -92,10 +92,10 @@ func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime strin } return snapshot(t, module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: dailySummaryStanza{ - Date: "2026-05-29", - HighTempF: &high, - LowTempF: &low, - MaxPopPercent: &precip, + Date: "2026-05-29", + HighTempF: &high, + LowTempF: &low, + DailyPrecipitationProbability: &precip, }}, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{ "morning": {Period: period("2026-05-29T06:00:00Z", "2026-05-29T10:00:00Z"), TempRangeF: "60-70", Snow: snow},