From eefb0681dc43d8fe1f13170da41bdd4d4f950335 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 9 Jun 2026 20:43:53 +0000 Subject: [PATCH] Implement derived forecast modules --- internal/briefing/derived_modules.go | 330 ++++++++++++++++++++++ internal/briefing/derived_modules_test.go | 240 ++++++++++++++++ internal/briefing/modules.go | 7 +- internal/facts/facts.go | 2 + internal/facts/facts_test.go | 6 + internal/forecast/derive.go | 36 +++ internal/forecast/derive_test.go | 41 +++ internal/module/module.go | 1 + 8 files changed, 662 insertions(+), 1 deletion(-) create mode 100644 internal/briefing/derived_modules.go create mode 100644 internal/briefing/derived_modules_test.go diff --git a/internal/briefing/derived_modules.go b/internal/briefing/derived_modules.go new file mode 100644 index 0000000..6f7d207 --- /dev/null +++ b/internal/briefing/derived_modules.go @@ -0,0 +1,330 @@ +package briefing + +import ( + "fmt" + "strings" + "time" + "unicode" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +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"` +} + +type DerivedDaypartSummaryModule struct { + Period timeutil.Period `json:"period"` + TempRangeF string `json:"temp_range_f,omitempty"` + ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"` + MaxPopPercent *int `json:"max_pop_percent,omitempty"` + MaxPopTime string `json:"max_pop_time,omitempty"` + MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` + MaxWindGustTime string `json:"max_wind_gust_time,omitempty"` + DominantCondition string `json:"dominant_condition,omitempty"` + NotableConditions []string `json:"notable_conditions,omitempty"` + Snow bool `json:"snow,omitempty"` + Ice bool `json:"ice,omitempty"` + Fog bool `json:"fog,omitempty"` + Heat bool `json:"heat,omitempty"` + Cold bool `json:"cold,omitempty"` + Wind bool `json:"wind,omitempty"` + RelevantAlertCount int `json:"relevant_alert_count,omitempty"` +} + +type PrecipTimingModule struct { + MaxPopPercent *int `json:"max_pop_percent,omitempty"` + MaxPopTime string `json:"max_pop_time,omitempty"` + FirstPrecipHour string `json:"first_precip_hour,omitempty"` + LastPrecipHour string `json:"last_precip_hour,omitempty"` + ThunderMentioned bool `json:"thunder_mentioned"` +} + +type OutdoorWindowsModule struct { + Best *OutdoorWindowModule `json:"best,omitempty"` + Worst *OutdoorWindowModule `json:"worst,omitempty"` +} + +type OutdoorWindowModule struct { + Daypart string `json:"daypart"` + Start string `json:"start"` + End string `json:"end"` + Reasons []string `json:"reasons,omitempty"` + Score float64 `json:"score"` +} + +type TomorrowPlanningModule struct { + MorningReadiness []string `json:"morning_readiness,omitempty"` + CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"` + OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"` +} + +func buildDerivedDailySummaryModule(ctx ModuleContext, _ any) (*module.Output, error) { + summary := ctx.Derived.FirstDailySummary() + if summary == nil { + return nil, fmt.Errorf("daily summary facts are required") + } + value, err := derivedDailySummaryValue(*summary, ctx.Derived.PrecipTiming, ctx.Timezone) + if err != nil { + return nil, err + } + return &module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: value}, nil +} + +func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) { + if len(ctx.Derived.DaypartSummaries) == 0 { + return nil, fmt.Errorf("daypart summary facts are required") + } + value := map[string]DerivedDaypartSummaryModule{} + prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries) + for _, daypart := range ctx.Derived.DaypartSummaries { + key := daypartKey(daypart, prefixDates) + value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone) + } + return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil +} + +func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) { + value := precipTimingValue(ctx.Derived.PrecipTiming, ctx.Timezone) + return &module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: value}, nil +} + +func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) { + windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries) + value := OutdoorWindowsModule{ + Best: outdoorWindowValue(windows.Best), + Worst: outdoorWindowValue(windows.Worst), + } + return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil +} + +func buildTomorrowPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) { + summary := ctx.Derived.FirstDailySummary() + if summary == nil { + return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: TomorrowPlanningModule{}}, nil + } + planning := buildTomorrowPlanning(summary) + value := TomorrowPlanningModule{} + if planning != nil { + value.MorningReadiness = append([]string(nil), planning.MorningReadiness...) + value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...) + value.OvernightChangeWatch = append([]string(nil), planning.OvernightChangeWatch...) + } + return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: value}, nil +} + +func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.PrecipTiming, timezone string) (DerivedDailySummaryModule, error) { + value := DerivedDailySummaryModule{ + Date: summary.Date, + ThunderMentioned: timing.ThunderMentioned, + } + conditions := map[string]struct{}{} + hazards := map[string]struct{}{} + var temperature forecast.Range + 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(&maxGust, daypart.PeakWindGust) + if daypart.DominantCondition != "" { + conditions[daypart.DominantCondition] = struct{}{} + } + for _, hazard := range hazardsForIndicators(daypart.Indicators) { + hazards[hazard] = struct{}{} + } + } + for _, alert := range summary.AlertOverlaps { + if alert.Event != "" { + hazards[alert.Event] = struct{}{} + } + } + value.HighTempF = roundedInt(temperature.Max) + value.LowTempF = roundedInt(temperature.Min) + value.HeatIndexMaxF = roundedInt(apparent.Max) + if maxPop != nil { + value.MaxPopPercent = roundedInt(&maxPop.Value) + value.MaxPopWindow = periodClockLabel(maxPopWindow, 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.Hazards = sortedSet(hazards) + return value, nil +} + +func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule { + value := DerivedDaypartSummaryModule{ + Period: daypart.Period, + TempRangeF: rangeLabel(daypart.Temperature), + ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature), + DominantCondition: daypart.DominantCondition, + NotableConditions: append([]string(nil), daypart.NotableConditions...), + Snow: daypart.Indicators.Snow, + Ice: daypart.Indicators.Ice, + Fog: daypart.Indicators.Fog, + Heat: daypart.Indicators.Heat, + Cold: daypart.Indicators.Cold, + Wind: daypart.Indicators.Wind, + RelevantAlertCount: len(daypart.AlertOverlaps), + } + if daypart.MaxPrecipitationProbability != nil { + value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value) + value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone) + } + if daypart.PeakWindGust != nil { + value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value) + value.MaxWindGustTime = clockLabel(daypart.PeakWindGust.Time, timezone) + } + return value +} + +func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimingModule { + value := PrecipTimingModule{ThunderMentioned: timing.ThunderMentioned} + if timing.MaxPrecipitationProbability != nil { + value.MaxPopPercent = roundedInt(&timing.MaxPrecipitationProbability.Value) + value.MaxPopTime = clockLabel(timing.MaxPrecipitationProbability.Time, timezone) + } + value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone) + value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone) + return value +} + +func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule { + if window == nil { + return nil + } + return &OutdoorWindowModule{ + Daypart: window.Daypart, + Start: window.Start, + End: window.End, + Reasons: append([]string(nil), window.Reasons...), + Score: window.Score, + } +} + +func multipleSummaryDates(summaries []forecast.DailySummary) bool { + seen := map[string]struct{}{} + for _, summary := range summaries { + seen[summary.Date] = struct{}{} + } + return len(seen) > 1 +} + +func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string { + key := normalizedKey(daypart.Name) + if key == "" { + key = "unnamed" + } + if !prefixDate { + return key + } + return daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key +} + +func normalizedKey(value string) string { + lower := strings.ToLower(strings.TrimSpace(value)) + var out strings.Builder + lastUnderscore := false + for _, r := range lower { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + out.WriteRune(r) + lastUnderscore = false + continue + } + if !lastUnderscore { + out.WriteByte('_') + lastUnderscore = true + } + } + return strings.Trim(out.String(), "_") +} + +func rangeLabel(value forecast.Range) string { + if value.Min == nil && value.Max == nil { + return "" + } + if value.Min != nil && value.Max != nil { + low := roundedInt(value.Min) + high := roundedInt(value.Max) + if low != nil && high != nil && *low == *high { + return fmt.Sprintf("%d", *low) + } + return fmt.Sprintf("%d-%d", *low, *high) + } + if value.Min != nil { + low := roundedInt(value.Min) + return fmt.Sprintf("%d", *low) + } + high := roundedInt(value.Max) + return fmt.Sprintf("%d", *high) +} + +func daypartApparentRangeLabel(value forecast.Range) string { + if value.Min == nil && value.Max == nil { + return "" + } + return rangeLabel(value) +} + +func roundedInt(value *float64) *int { + if value == nil { + return nil + } + rounded := int(*value + 0.5) + if *value < 0 { + rounded = int(*value - 0.5) + } + return &rounded +} + +func timedClockLabel(value *forecast.TimedValue, timezone string) string { + if value == nil { + return "" + } + return clockLabel(value.Time, timezone) +} + +func periodClockLabel(period timeutil.Period, timezone string) string { + if !period.IsValid() { + return "" + } + return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone) +} + +func clockLabel(value time.Time, timezone string) string { + location, err := timeutil.LoadLocation(timezone) + if err != nil { + location = time.UTC + } + label := value.In(location).Format("3 PM") + if label == "12 AM" && value.In(location).Minute() == 0 { + return "12 AM" + } + return label +} diff --git a/internal/briefing/derived_modules_test.go b/internal/briefing/derived_modules_test.go new file mode 100644 index 0000000..e1f0fb7 --- /dev/null +++ b/internal/briefing/derived_modules_test.go @@ -0,0 +1,240 @@ +package briefing + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/facts" + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" +) + +func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := derivedModuleContext(report.DailyToday) + + 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 96/31", 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.FirstPrecipHour != "8 AM" || value.LastPrecipHour != "1 PM" || !value.ThunderMentioned { + t.Fatalf("precip timing = %#v, want morning through afternoon thunder", value) + } + if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 { + t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph) + } + if value.HeatIndexMaxF == nil || *value.HeatIndexMaxF != 101 { + t.Fatalf("HeatIndexMaxF = %#v, want 101", value.HeatIndexMaxF) + } + data, err := json.Marshal(output.Value) + if err != nil { + 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"} { + if !strings.Contains(jsonText, field) { + t.Fatalf("daily json = %s, want field %s", jsonText, field) + } + } + if strings.Contains(jsonText, "qpf") { + t.Fatalf("daily json = %s, want no QPF fields without upstream QPF facts", jsonText) + } +} + +func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := derivedModuleContext(report.DailyToday) + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming}) + if err != nil { + t.Fatalf("BuildModule(rainy) error = %v", err) + } + rainy := moduleValue[PrecipTimingModule](t, output) + if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.FirstPrecipHour != "8 AM" || rainy.LastPrecipHour != "1 PM" || !rainy.ThunderMentioned { + t.Fatalf("rainy precip timing = %#v, want peak, first/last, thunder", rainy) + } + + ctx.Derived.PrecipTiming = forecast.BuildPrecipTiming([]weatherdata.ForecastPeriod{derivedHour("2026-05-29T10:00:00-05:00", "Sunny", 0, 70, nil, 5)}) + output, err = registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming}) + if err != nil { + t.Fatalf("BuildModule(dry) error = %v", err) + } + dry := moduleValue[PrecipTimingModule](t, output) + if dry.FirstPrecipHour != "" || dry.LastPrecipHour != "" || dry.ThunderMentioned { + t.Fatalf("dry precip timing = %#v, want no precip hours and no thunder", dry) + } + if dry.MaxPopPercent == nil || *dry.MaxPopPercent != 0 { + t.Fatalf("dry MaxPopPercent = %#v, want checked zero", dry.MaxPopPercent) + } +} + +func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := derivedModuleContext(report.DailyToday) + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + value := moduleValue[map[string]DerivedDaypartSummaryModule](t, output) + + morning, ok := value["morning"] + if !ok { + t.Fatalf("daypart keys = %#v, want configured morning key", value) + } + if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 { + t.Fatalf("morning = %#v, want temp range and precip peak", morning) + } + afternoon := value["afternoon"] + if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 { + t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon) + } + overnight := value["overnight"] + if !overnight.Cold { + t.Fatalf("overnight = %#v, want cold hazard", overnight) + } + data, err := json.Marshal(output.Value) + if err != nil { + t.Fatalf("marshal daypart summaries: %v", err) + } + jsonText := string(data) + for _, field := range []string{"temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} { + if !strings.Contains(jsonText, field) { + t.Fatalf("daypart json = %s, want field %s", jsonText, field) + } + } +} + +func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := derivedModuleContext(report.DailyTomorrow) + + outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows}) + if err != nil { + t.Fatalf("BuildModule(outdoor windows) error = %v", err) + } + outdoor := moduleValue[OutdoorWindowsModule](t, outdoorOutput) + if outdoor.Best == nil || outdoor.Worst == nil { + t.Fatalf("outdoor windows = %#v, want best and worst", outdoor) + } + if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" { + t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor) + } + + planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning}) + if err != nil { + t.Fatalf("BuildModule(tomorrow planning) error = %v", err) + } + planning := moduleValue[TomorrowPlanningModule](t, planningOutput) + if len(planning.MorningReadiness) == 0 || len(planning.CommuteSchoolWorkdayConcerns) == 0 || len(planning.OvernightChangeWatch) == 0 { + t.Fatalf("tomorrow planning = %#v, want daily planning notes", planning) + } + data, err := json.Marshal(planningOutput.Value) + if err != nil { + t.Fatalf("marshal tomorrow planning: %v", err) + } + if !strings.Contains(string(data), "morning_readiness") || strings.Contains(string(data), "morningReadiness") { + t.Fatalf("tomorrow planning json = %s, want snake_case fields", string(data)) + } +} + +func TestDerivedModulesHandleMissingData(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := derivedModuleContext(report.DailyToday) + ctx.Derived.DailySummaries = nil + ctx.Derived.DaypartSummaries = nil + + if _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}); err == nil { + t.Fatal("BuildModule(derived daily summary) error = nil, want required facts error") + } + if _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries}); err == nil { + t.Fatal("BuildModule(daypart summaries) error = nil, want required facts error") + } + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows}) + if err != nil { + t.Fatalf("BuildModule(outdoor windows) error = %v", err) + } + windows := moduleValue[OutdoorWindowsModule](t, output) + if windows.Best != nil || windows.Worst != nil { + t.Fatalf("outdoor windows = %#v, want empty output with missing dayparts", windows) + } +} + +func derivedModuleContext(id report.ID) ModuleContext { + generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00") + definition := report.DefaultRegistry().MustLookup(id) + summary := forecast.DailySummary{ + Date: "2026-05-29", + Period: timeutil.Period{ + Start: mustParseModuleTime("2026-05-29T00:00:00-05:00"), + End: mustParseModuleTime("2026-05-30T00:00:00-05:00"), + }, + Dayparts: []forecast.DaypartSummary{ + derivedDaypart("overnight", "2026-05-29T00:00:00-05:00", "2026-05-29T06:00:00-05:00", "Clear and cold", 31, nil, 0, 5), + derivedDaypart("morning", "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00", "Showers", 58, nil, 60, 15), + derivedDaypart("afternoon", "2026-05-29T12:00:00-05:00", "2026-05-29T18:00:00-05:00", "Thunderstorms with gusty wind", 96, floatPtr(101), 80, 42), + }, + } + hours := []weatherdata.ForecastPeriod{ + derivedHour("2026-05-29T00:00:00-05:00", "Clear and cold", 0, 31, nil, 5), + derivedHour("2026-05-29T08:00:00-05:00", "Showers", 60, 58, nil, 15), + derivedHour("2026-05-29T12:00:00-05:00", "Thunderstorms with gusty wind", 80, 96, floatPtr(101), 42), + derivedHour("2026-05-29T13:00:00-05:00", "Heavy rain", 70, 82, nil, 30), + } + summary.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}} + return ModuleContext{ + Resolved: report.Resolved{ + Definition: definition, + GeneratedAt: generatedAt, + Timezone: "America/Chicago", + ValidPeriod: summary.Period, + }, + Derived: facts.DerivedFacts{ + ValidPeriodHourlyPeriods: hours, + DailySummaries: []forecast.DailySummary{summary}, + DaypartSummaries: append([]forecast.DaypartSummary(nil), summary.Dayparts...), + PrecipTiming: forecast.BuildPrecipTiming(hours), + }, + Units: "us", + Timezone: "America/Chicago", + } +} + +func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary { + hour := derivedHour(start, text, precip, temperature, apparent, gust) + return forecast.SummarizeDaypart(name, timeutil.Period{ + Start: mustParseModuleTime(start), + End: mustParseModuleTime(end), + }, []weatherdata.ForecastPeriod{hour}) +} + +func derivedHour(start string, text string, precip float64, temperature float64, apparent *float64, gust float64) weatherdata.ForecastPeriod { + startTime := mustParseModuleTime(start) + endTime := startTime.Add(time.Hour) + return weatherdata.ForecastPeriod{ + StartTime: startTime, + EndTime: endTime, + TextDescription: text, + TemperatureF: floatPtr(temperature), + ApparentTemperatureF: apparent, + ProbabilityOfPrecipitationPercent: floatPtr(precip), + WindGustMph: floatPtr(gust), + } +} + +func floatPtr(value float64) *float64 { + return &value +} diff --git a/internal/briefing/modules.go b/internal/briefing/modules.go index 032f1fc..081c1d0 100644 --- a/internal/briefing/modules.go +++ b/internal/briefing/modules.go @@ -194,9 +194,10 @@ func defaultModuleDefinitions() []ModuleDefinition { ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", DefaultOptions: module.DerivedDailySummaryOptions{}, - RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries}, + RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming}, SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow}, MissingData: module.MissingDataError, + Builder: buildDerivedDailySummaryModule, }, { ID: module.DerivedDaypartSummaries, @@ -205,6 +206,7 @@ func defaultModuleDefinitions() []ModuleDefinition { RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries}, SupportedReports: daypartReports, MissingData: module.MissingDataError, + Builder: buildDerivedDaypartSummariesModule, }, { ID: module.HourlyTable, @@ -221,6 +223,7 @@ func defaultModuleDefinitions() []ModuleDefinition { RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries}, SupportedReports: allReports, MissingData: module.MissingDataEmpty, + Builder: buildPrecipTimingModule, }, { ID: module.AlertDigest, @@ -264,6 +267,7 @@ func defaultModuleDefinitions() []ModuleDefinition { RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries}, SupportedReports: daypartReports, MissingData: module.MissingDataEmpty, + Builder: buildOutdoorWindowsModule, }, { ID: module.TomorrowPlanning, @@ -272,6 +276,7 @@ func defaultModuleDefinitions() []ModuleDefinition { RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries}, SupportedReports: []report.ID{report.DailyTomorrow}, MissingData: module.MissingDataEmpty, + Builder: buildTomorrowPlanningModule, }, { ID: module.WeekendPlanning, diff --git a/internal/facts/facts.go b/internal/facts/facts.go index 6c42751..5556611 100644 --- a/internal/facts/facts.go +++ b/internal/facts/facts.go @@ -75,6 +75,7 @@ type DerivedFacts struct { AlertOverlaps []forecast.AlertOverlap DailySummaries []forecast.DailySummary DaypartSummaries []forecast.DaypartSummary + PrecipTiming forecast.PrecipTiming StormWindowSummary *forecast.DaypartSummary } @@ -101,6 +102,7 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) { ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period), AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period), } + derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods) switch req.Resolved.Definition.ID { case report.DailyToday, report.DailyTomorrow: diff --git a/internal/facts/facts_test.go b/internal/facts/facts_test.go index bc489f5..bbd4254 100644 --- a/internal/facts/facts_test.go +++ b/internal/facts/facts_test.go @@ -66,6 +66,12 @@ func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) { if len(derived.DaypartSummaries) != 3 { t.Fatalf("DaypartSummaries length = %d, want 3", len(derived.DaypartSummaries)) } + if derived.PrecipTiming.FirstPrecipitation == nil || derived.PrecipTiming.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" { + t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want valid-period rain start", derived.PrecipTiming.FirstPrecipitation) + } + if !derived.PrecipTiming.ThunderMentioned { + t.Fatal("PrecipTiming.ThunderMentioned = false, want true") + } morning := derived.DailySummaries[0].Dayparts[0] if len(morning.HourlyPeriods) != 1 || morning.MaxPrecipitationProbability == nil || morning.MaxPrecipitationProbability.Value != 60 { t.Fatalf("morning summary = %#v, want sliced hour with precip max", morning) diff --git a/internal/forecast/derive.go b/internal/forecast/derive.go index d9e3d58..c8baa45 100644 --- a/internal/forecast/derive.go +++ b/internal/forecast/derive.go @@ -65,6 +65,38 @@ type AlertOverlap struct { Description string `json:"description,omitempty"` } +type PrecipTiming struct { + MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"` + FirstPrecipitation *TimedValue `json:"firstPrecipitation,omitempty"` + LastPrecipitation *TimedValue `json:"lastPrecipitation,omitempty"` + ThunderMentioned bool `json:"thunderMentioned,omitempty"` +} + +func BuildPrecipTiming(periods []weatherdata.ForecastPeriod) PrecipTiming { + var timing PrecipTiming + for _, forecastPeriod := range periods { + setMaxTimedValue(&timing.MaxPrecipitationProbability, forecastPeriod.ProbabilityOfPrecipitationPercent, forecastPeriod.StartTime) + if forecastPeriod.ProbabilityOfPrecipitationPercent != nil && *forecastPeriod.ProbabilityOfPrecipitationPercent > 0 { + value := TimedValue{ + Value: *forecastPeriod.ProbabilityOfPrecipitationPercent, + Time: forecastPeriod.StartTime, + } + if timing.FirstPrecipitation == nil || value.Time.Before(timing.FirstPrecipitation.Time) { + copied := value + timing.FirstPrecipitation = &copied + } + if timing.LastPrecipitation == nil || value.Time.After(timing.LastPrecipitation.Time) { + copied := value + timing.LastPrecipitation = &copied + } + } + if mentionsThunder(forecastPeriod.TextDescription) { + timing.ThunderMentioned = true + } + } + return timing +} + func BuildDailySummary(bundle *weatherdata.Bundle, date time.Time, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) { if bundle == nil { return nil, fmt.Errorf("forecast bundle is required") @@ -298,6 +330,10 @@ func indicatorsForText(text string) Indicators { } } +func mentionsThunder(text string) bool { + return strings.Contains(strings.ToLower(text), "thunder") +} + func numericIndicators(period weatherdata.ForecastPeriod) Indicators { windGust := firstValue(period.WindGustMph, period.WindGustKmh) windSpeed := firstValue(period.WindSpeedMph, period.WindSpeedKmh) diff --git a/internal/forecast/derive_test.go b/internal/forecast/derive_test.go index c5442d2..886bc33 100644 --- a/internal/forecast/derive_test.go +++ b/internal/forecast/derive_test.go @@ -210,6 +210,47 @@ func TestAlertOverlap(t *testing.T) { } } +func TestBuildPrecipTimingTracksRainAndThunder(t *testing.T) { + location := time.FixedZone("Test", -5*60*60) + periods := []weatherdata.ForecastPeriod{ + hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Cloudy", 70, nil, ptr(0), nil, nil), + hour(location, "2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", "Showers", 70, nil, ptr(30), nil, nil), + hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "Thunderstorms", 70, nil, ptr(80), nil, nil), + hour(location, "2026-05-29T18:00:00-05:00", "2026-05-29T19:00:00-05:00", "Dry", 70, nil, ptr(0), nil, nil), + } + + timing := BuildPrecipTiming(periods) + + if timing.FirstPrecipitation == nil || timing.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" { + t.Fatalf("FirstPrecipitation = %#v, want 9 AM shower", timing.FirstPrecipitation) + } + if timing.LastPrecipitation == nil || timing.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T12:00:00-05:00" { + t.Fatalf("LastPrecipitation = %#v, want noon thunderstorm", timing.LastPrecipitation) + } + if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 80 { + t.Fatalf("MaxPrecipitationProbability = %#v, want 80", timing.MaxPrecipitationProbability) + } + if !timing.ThunderMentioned { + t.Fatal("ThunderMentioned = false, want true") + } +} + +func TestBuildPrecipTimingHandlesDryForecast(t *testing.T) { + location := time.FixedZone("Test", -5*60*60) + periods := []weatherdata.ForecastPeriod{ + hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Sunny", 70, nil, ptr(0), nil, nil), + } + + timing := BuildPrecipTiming(periods) + + if timing.FirstPrecipitation != nil || timing.LastPrecipitation != nil || timing.ThunderMentioned { + t.Fatalf("dry timing = %#v, want no precip timing and no thunder", timing) + } + if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 0 { + t.Fatalf("dry max precip = %#v, want checked zero chance", timing.MaxPrecipitationProbability) + } +} + func TestThresholdHelpers(t *testing.T) { if !DifferenceAtLeast(50, 56, 5) { t.Fatal("DifferenceAtLeast = false, want true") diff --git a/internal/module/module.go b/internal/module/module.go index e8d1a6a..e5a3138 100644 --- a/internal/module/module.go +++ b/internal/module/module.go @@ -117,6 +117,7 @@ const ( RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps" RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries" RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries" + RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing" RequiresDerivedStormWindowSummary FactRequirement = "derived.storm_window_summary" )