Implement derived forecast modules

This commit is contained in:
2026-06-09 20:43:53 +00:00
parent 9501dad1dc
commit eefb0681dc
8 changed files with 662 additions and 1 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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:

View File

@@ -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)

View File

@@ -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)

View File

@@ -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")

View File

@@ -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"
)