Refactored the derived_daily_summary module to utilize narrative forecast data where available
This commit is contained in:
@@ -1359,7 +1359,7 @@ func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Sna
|
|||||||
"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{
|
||||||
|
|||||||
@@ -5,17 +5,15 @@ 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"`
|
|
||||||
LastPrecipHour string `json:"last_precip_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"`
|
HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"`
|
||||||
@@ -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{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
narrativeTemperature := narrativeTemperatureRange(summary.NarrativePeriods)
|
||||||
|
if narrativeTemperature.Max != nil {
|
||||||
|
value.HighTempF = roundedInt(narrativeTemperature.Max)
|
||||||
|
} else {
|
||||||
value.HighTempF = roundedInt(temperature.Max)
|
value.HighTempF = roundedInt(temperature.Max)
|
||||||
|
}
|
||||||
|
if narrativeTemperature.Min != nil {
|
||||||
|
value.LowTempF = roundedInt(narrativeTemperature.Min)
|
||||||
|
} else {
|
||||||
value.LowTempF = roundedInt(temperature.Min)
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -220,11 +250,23 @@ func derivedModuleContext(id report.ID) ModuleContext {
|
|||||||
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"),
|
||||||
|
IsDay: boolPtr(true),
|
||||||
TextDescription: "Morning storms, then partly sunny.",
|
TextDescription: "Morning storms, then partly sunny.",
|
||||||
TemperatureF: floatPtr(81),
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, "")...)
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime strin
|
|||||||
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},
|
||||||
|
|||||||
Reference in New Issue
Block a user