Refactored the derived_daily_summary module to utilize narrative forecast data where available
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user