Refactored the derived_daily_summary module to utilize narrative forecast data where available

This commit is contained in:
2026-06-10 09:35:39 -05:00
parent d1d0df11a8
commit ef044327c6
5 changed files with 193 additions and 54 deletions

View File

@@ -1356,10 +1356,10 @@ func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Sna
precip := 10 precip := 10
snapshot, err := module.NewSnapshot([]module.Output{ snapshot, err := module.NewSnapshot([]module.Output{
{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]any{ {ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]any{
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout), "date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
"low_temp_f": low, "low_temp_f": low,
"high_temp_f": high, "high_temp_f": high,
"max_pop_percent": precip, "daily_precipitation_probability": precip,
}}, }},
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{ {ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
"morning": map[string]any{ "morning": map[string]any{

View File

@@ -5,22 +5,20 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
type DerivedDailySummaryModule struct { type DerivedDailySummaryModule struct {
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
HighTempF *int `json:"high_temp_f,omitempty"` HighTempF *int `json:"high_temp_f,omitempty"`
LowTempF *int `json:"low_temp_f,omitempty"` LowTempF *int `json:"low_temp_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"` DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"`
MaxPopWindow string `json:"max_pop_window,omitempty"` MostLikelyPrecipitationHour string `json:"most_likely_precipitation_hour,omitempty"`
FirstPrecipHour string `json:"first_precip_hour,omitempty"` ThunderMentioned bool `json:"thunder_mentioned"`
LastPrecipHour string `json:"last_precip_hour,omitempty"` MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
ThunderMentioned bool `json:"thunder_mentioned"` HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` DominantConditions []string `json:"dominant_conditions,omitempty"`
HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"` Hazards []string `json:"hazards,omitempty"`
DominantConditions []string `json:"dominant_conditions,omitempty"`
Hazards []string `json:"hazards,omitempty"`
} }
func buildDerivedDailySummaryModule(ctx ModuleContext, _ any) (*module.Output, error) { 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 apparent forecast.Range
var maxPop *forecast.TimedValue var maxPop *forecast.TimedValue
var maxGust *forecast.TimedValue var maxGust *forecast.TimedValue
var maxPopWindow timeutil.Period
for _, daypart := range summary.Dayparts { for _, daypart := range summary.Dayparts {
addRange(&temperature, daypart.Temperature) addRange(&temperature, daypart.Temperature)
addRange(&apparent, daypart.ApparentTemperature) addRange(&apparent, daypart.ApparentTemperature)
if daypart.MaxPrecipitationProbability != nil { maxTimedValue(&maxPop, daypart.MaxPrecipitationProbability)
if maxPop == nil || daypart.MaxPrecipitationProbability.Value > maxPop.Value {
copied := *daypart.MaxPrecipitationProbability
maxPop = &copied
maxPopWindow = daypart.Period
}
}
maxTimedValue(&maxGust, daypart.PeakWindGust) maxTimedValue(&maxGust, daypart.PeakWindGust)
if daypart.DominantCondition != "" { if daypart.DominantCondition != "" {
conditions[daypart.DominantCondition] = struct{}{} conditions[daypart.DominantCondition] = struct{}{}
@@ -70,19 +61,112 @@ func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.Pre
hazards[alert.Event] = struct{}{} hazards[alert.Event] = struct{}{}
} }
} }
value.HighTempF = roundedInt(temperature.Max) narrativeTemperature := narrativeTemperatureRange(summary.NarrativePeriods)
value.LowTempF = roundedInt(temperature.Min) 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) 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 { if maxPop != nil {
value.MaxPopPercent = roundedInt(&maxPop.Value) value.MostLikelyPrecipitationHour = mostLikelyPrecipitationHour(maxPop, timezone)
value.MaxPopWindow = periodClockLabel(maxPopWindow, timezone)
} }
if maxGust != nil { if maxGust != nil {
value.MaxWindGustMph = roundedInt(&maxGust.Value) value.MaxWindGustMph = roundedInt(&maxGust.Value)
} }
value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone) value.DominantConditions = narrativeConditions(summary.NarrativePeriods)
value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone) if len(value.DominantConditions) == 0 {
value.DominantConditions = sortedSet(conditions) value.DominantConditions = sortedSet(conditions)
}
value.Hazards = sortedSet(hazards) value.Hazards = sortedSet(hazards)
return value, nil 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))
}

View File

@@ -24,14 +24,17 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
} }
value := moduleValue[DerivedDailySummaryModule](t, output) value := moduleValue[DerivedDailySummaryModule](t, output)
if value.HighTempF == nil || *value.HighTempF != 96 || value.LowTempF == nil || *value.LowTempF != 31 { if value.HighTempF == nil || *value.HighTempF != 88 || value.LowTempF == nil || *value.LowTempF != 64 {
t.Fatalf("daily temperatures = %#v/%#v, want 96/31", value.HighTempF, value.LowTempF) 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" { if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 55 {
t.Fatalf("max precip = %#v %q, want 80 and afternoon window", value.MaxPopPercent, value.MaxPopWindow) t.Fatalf("DailyPrecipitationProbability = %#v, want narrative 55", value.DailyPrecipitationProbability)
} }
if value.FirstPrecipHour != "8 AM" || value.LastPrecipHour != "2 PM" || !value.ThunderMentioned { if value.MostLikelyPrecipitationHour != "80% at 12 PM" || !value.ThunderMentioned {
t.Fatalf("precip timing = %#v, want threshold windows from morning through afternoon thunder", value) 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 { if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph) t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
@@ -44,16 +47,43 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
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", "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) { 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)
} }
} }
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") { if strings.Contains(jsonText, "qpf") {
t.Fatalf("daily json = %s, want no QPF fields without upstream QPF facts", jsonText) 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) { func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday) ctx := derivedModuleContext(report.DailyToday)
@@ -217,14 +247,26 @@ func derivedModuleContext(id report.ID) ModuleContext {
} }
narrative := []weatherdata.ForecastPeriod{ narrative := []weatherdata.ForecastPeriod{
{ {
Name: "Today", Name: "Today",
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"), StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"), EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
TextDescription: "Morning storms, then partly sunny.", IsDay: boolPtr(true),
TemperatureF: floatPtr(81), 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.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}}
summary.NarrativePeriods = append([]weatherdata.ForecastPeriod(nil), narrative...)
return ModuleContext{ return ModuleContext{
Resolved: report.Resolved{ Resolved: report.Resolved{
Definition: definition, Definition: definition,
@@ -276,3 +318,16 @@ func derivedHour(start string, text string, precip float64, temperature float64,
func floatPtr(value float64) *float64 { func floatPtr(value float64) *float64 {
return &value 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
}

View File

@@ -64,7 +64,7 @@ func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds
var changes []Change var changes []Change
changes = append(changes, compareTemperatureValues("Low", previousSummary.LowTempF, currentSummary.LowTempF, thresholds.TemperatureDegrees)...) 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, 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 { if previousHasTiming && currentHasTiming {
changes = append(changes, comparePrecipTiming(previousTiming.MaxPopTime, currentTiming.MaxPopTime, thresholds.PrecipTimingShiftMinutes, "")...) 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 { type dailySummaryStanza struct {
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
HighTempF *int `json:"high_temp_f,omitempty"` HighTempF *int `json:"high_temp_f,omitempty"`
LowTempF *int `json:"low_temp_f,omitempty"` LowTempF *int `json:"low_temp_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"` DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
} }
type daypartSummaryStanza struct { type daypartSummaryStanza struct {

View File

@@ -92,10 +92,10 @@ func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime strin
} }
return snapshot(t, return snapshot(t,
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: dailySummaryStanza{ module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: dailySummaryStanza{
Date: "2026-05-29", Date: "2026-05-29",
HighTempF: &high, HighTempF: &high,
LowTempF: &low, LowTempF: &low,
MaxPopPercent: &precip, DailyPrecipitationProbability: &precip,
}}, }},
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{ 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}, "morning": {Period: period("2026-05-29T06:00:00Z", "2026-05-29T10:00:00Z"), TempRangeF: "60-70", Snow: snow},