diff --git a/internal/briefing/base_modules_test.go b/internal/briefing/base_modules_test.go index fc77b50..41aa93c 100644 --- a/internal/briefing/base_modules_test.go +++ b/internal/briefing/base_modules_test.go @@ -64,16 +64,22 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) { if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 { t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period) } + if period.WindDirection != "NE" { + t.Fatalf("NarrativeForecast period wind direction = %q, want NE", period.WindDirection) + } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("Marshal narrative forecast: %v", err) } jsonText := string(data) - for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_speed_mph", "probability_of_precipitation_percent"} { + for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} { if !strings.Contains(jsonText, field) { t.Fatalf("narrative json = %s, want field %s", jsonText, field) } } + if strings.Contains(jsonText, "wind_direction_degrees") { + t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText) + } if strings.Contains(jsonText, "Tomorrow night") { t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText) } @@ -133,16 +139,22 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) { if value.ConditionText != "Partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 { t.Fatalf("CurrentConditions = %#v, want current condition facts", value) } + if value.WindDirection != "S" { + t.Fatalf("WindDirection = %q, want S", value.WindDirection) + } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("Marshal current conditions: %v", err) } jsonText := string(data) - for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph"} { + for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} { if !strings.Contains(jsonText, field) { t.Fatalf("current json = %s, want field %s", jsonText, field) } } + if strings.Contains(jsonText, "wind_direction_degrees") { + t.Fatalf("current json = %s, want compass wind_direction without degrees field", jsonText) + } } func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) { @@ -263,6 +275,7 @@ func testModuleContext() ModuleContext { narrativeTempF := 81.0 narrativePop := 60.0 narrativeWind := 12.0 + narrativeWindDirection := 45.0 updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00") return ModuleContext{ Resolved: resolved, @@ -291,6 +304,7 @@ func testModuleContext() ModuleContext { TextDescription: "Morning storms, then partly sunny.", TemperatureF: floatPtr(narrativeTempF), WindSpeedMph: &narrativeWind, + WindDirectionDegrees: &narrativeWindDirection, ProbabilityOfPrecipitationPercent: &narrativePop, }, }, @@ -336,6 +350,7 @@ func testModuleContext() ModuleContext { TextDescription: "Morning storms, then partly sunny.", TemperatureF: floatPtr(narrativeTempF), WindSpeedMph: &narrativeWind, + WindDirectionDegrees: &narrativeWindDirection, ProbabilityOfPrecipitationPercent: &narrativePop, }, }, diff --git a/internal/briefing/current_conditions_module.go b/internal/briefing/current_conditions_module.go index cce4a64..e276344 100644 --- a/internal/briefing/current_conditions_module.go +++ b/internal/briefing/current_conditions_module.go @@ -14,7 +14,7 @@ type CurrentConditionsModule struct { RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"` WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"` WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"` - WindDirectionDegrees *float64 `json:"wind_direction_degrees,omitempty"` + WindDirection string `json:"wind_direction,omitempty"` } func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) { @@ -34,7 +34,7 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent), WindSpeedKmh: copyFloat(current.WindSpeedKmh), WindSpeedMph: copyFloat(current.WindSpeedMph), - WindDirectionDegrees: copyFloat(current.WindDirectionDegrees), + WindDirection: windDirectionLabel(current.WindDirectionDegrees), } if value.isEmpty() { return nil, nil @@ -54,5 +54,5 @@ func (v CurrentConditionsModule) isEmpty() bool { v.RelativeHumidityPercent == nil && v.WindSpeedKmh == nil && v.WindSpeedMph == nil && - v.WindDirectionDegrees == nil + v.WindDirection == "" } diff --git a/internal/briefing/derived_modules_test.go b/internal/briefing/derived_modules_test.go index 87bc74c..8bbfcd2 100644 --- a/internal/briefing/derived_modules_test.go +++ b/internal/briefing/derived_modules_test.go @@ -30,8 +30,8 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) { 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.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.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 { t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph) @@ -63,8 +63,27 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) { 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) + if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.MaxPopTime != "12 PM" || rainy.ProbabilityThreshold != forecast.DefaultPrecipWindowProbabilityThreshold || !rainy.ThunderMentioned { + t.Fatalf("rainy precip timing = %#v, want peak, threshold, and thunder", rainy) + } + if len(rainy.PrecipitationWindows) != 2 { + t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows) + } + if rainy.PrecipitationWindows[0].Start != "8 AM" || rainy.PrecipitationWindows[0].End != "9 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 { + t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0]) + } + if rainy.PrecipitationWindows[1].Start != "12 PM" || rainy.PrecipitationWindows[1].End != "2 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 { + t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1]) + } + data, err := json.Marshal(output.Value) + if err != nil { + t.Fatalf("marshal precip timing: %v", err) + } + if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") { + t.Fatalf("precip timing json = %s, want threshold and windows", string(data)) + } + if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") { + t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data)) } ctx.Derived.PrecipTiming = forecast.BuildPrecipTiming([]weatherdata.ForecastPeriod{derivedHour("2026-05-29T10:00:00-05:00", "Sunny", 0, 70, nil, 5)}) @@ -73,8 +92,8 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) { 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 len(dry.PrecipitationWindows) != 0 || dry.ThunderMentioned { + t.Fatalf("dry precip timing = %#v, want no precip windows and no thunder", dry) } if dry.MaxPopPercent == nil || *dry.MaxPopPercent != 0 { t.Fatalf("dry MaxPopPercent = %#v, want checked zero", dry.MaxPopPercent) @@ -191,8 +210,10 @@ func derivedModuleContext(id report.ID) ModuleContext { 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-29T09:00:00-05:00", "Dry break", 20, 62, nil, 10), 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), + derivedHour("2026-05-29T14:00:00-05:00", "Drying out", 20, 78, nil, 12), } narrative := []weatherdata.ForecastPeriod{ { diff --git a/internal/briefing/module_format_helpers.go b/internal/briefing/module_format_helpers.go index 9433137..58be4a6 100644 --- a/internal/briefing/module_format_helpers.go +++ b/internal/briefing/module_format_helpers.go @@ -2,6 +2,7 @@ package briefing import ( "fmt" + "math" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" @@ -46,6 +47,19 @@ func roundedInt(value *float64) *int { return &rounded } +func windDirectionLabel(degrees *float64) string { + if degrees == nil { + return "" + } + labels := []string{"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"} + normalized := math.Mod(*degrees, 360) + if normalized < 0 { + normalized += 360 + } + sector := int(math.Floor((normalized+11.25)/22.5)) % len(labels) + return labels[sector] +} + func timedClockLabel(value *forecast.TimedValue, timezone string) string { if value == nil { return "" diff --git a/internal/briefing/module_format_helpers_test.go b/internal/briefing/module_format_helpers_test.go new file mode 100644 index 0000000..a1f7e7f --- /dev/null +++ b/internal/briefing/module_format_helpers_test.go @@ -0,0 +1,29 @@ +package briefing + +import "testing" + +func TestWindDirectionLabelUsesSixteenPointCompass(t *testing.T) { + tests := []struct { + name string + degrees *float64 + want string + }{ + {name: "nil", degrees: nil, want: ""}, + {name: "north", degrees: floatPtr(0), want: "N"}, + {name: "below first boundary", degrees: floatPtr(11.24), want: "N"}, + {name: "at first boundary", degrees: floatPtr(11.25), want: "NNE"}, + {name: "northeast", degrees: floatPtr(45), want: "NE"}, + {name: "south", degrees: floatPtr(180), want: "S"}, + {name: "wrap to north", degrees: floatPtr(348.75), want: "N"}, + {name: "full rotation", degrees: floatPtr(360), want: "N"}, + {name: "negative normalizes", degrees: floatPtr(-45), want: "NW"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := windDirectionLabel(tt.degrees); got != tt.want { + t.Fatalf("windDirectionLabel(%v) = %q, want %q", tt.degrees, got, tt.want) + } + }) + } +} diff --git a/internal/briefing/narrative_forecast_module.go b/internal/briefing/narrative_forecast_module.go index 411f8c2..07b3213 100644 --- a/internal/briefing/narrative_forecast_module.go +++ b/internal/briefing/narrative_forecast_module.go @@ -32,7 +32,7 @@ type NarrativeForecastPeriod struct { WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"` WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"` WindGustMph *float64 `json:"wind_gust_mph,omitempty"` - WindDirectionDegrees *float64 `json:"wind_direction_degrees,omitempty"` + WindDirection string `json:"wind_direction,omitempty"` ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"` } @@ -74,7 +74,7 @@ func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod) []NarrativeF WindSpeedMph: copyFloat(period.WindSpeedMph), WindGustKmh: copyFloat(period.WindGustKmh), WindGustMph: copyFloat(period.WindGustMph), - WindDirectionDegrees: copyFloat(period.WindDirectionDegrees), + WindDirection: windDirectionLabel(period.WindDirectionDegrees), ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent), }) } diff --git a/internal/briefing/precip_timing_module.go b/internal/briefing/precip_timing_module.go index 2965395..216a432 100644 --- a/internal/briefing/precip_timing_module.go +++ b/internal/briefing/precip_timing_module.go @@ -6,11 +6,18 @@ import ( ) 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"` + MaxPopPercent *int `json:"max_pop_percent,omitempty"` + MaxPopTime string `json:"max_pop_time,omitempty"` + ProbabilityThreshold float64 `json:"probability_threshold"` + PrecipitationWindows []PrecipitationWindowModule `json:"precipitation_windows,omitempty"` + ThunderMentioned bool `json:"thunder_mentioned"` +} + +type PrecipitationWindowModule struct { + Start string `json:"start"` + End string `json:"end,omitempty"` + MaxPopPercent *int `json:"max_pop_percent,omitempty"` + MaxPopTime string `json:"max_pop_time,omitempty"` } func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) { @@ -19,12 +26,24 @@ func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) { } func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimingModule { - value := PrecipTimingModule{ThunderMentioned: timing.ThunderMentioned} + value := PrecipTimingModule{ + ProbabilityThreshold: timing.ProbabilityThreshold, + 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) + for _, window := range timing.PrecipitationWindows { + item := PrecipitationWindowModule{ + Start: clockLabel(window.Start, timezone), + } + if window.End != nil { + item.End = clockLabel(*window.End, timezone) + } + item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value) + item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone) + value.PrecipitationWindows = append(value.PrecipitationWindows, item) + } return value } diff --git a/internal/facts/facts_test.go b/internal/facts/facts_test.go index 27eda20..531e427 100644 --- a/internal/facts/facts_test.go +++ b/internal/facts/facts_test.go @@ -69,6 +69,12 @@ func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) { 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.LastPrecipitation == nil || derived.PrecipTiming.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" { + t.Fatalf("PrecipTiming.LastPrecipitation = %#v, want final closed window end", derived.PrecipTiming.LastPrecipitation) + } + if len(derived.PrecipTiming.PrecipitationWindows) != 2 { + t.Fatalf("PrecipitationWindows = %#v, want two threshold windows", derived.PrecipTiming.PrecipitationWindows) + } if !derived.PrecipTiming.ThunderMentioned { t.Fatal("PrecipTiming.ThunderMentioned = false, want true") } diff --git a/internal/forecast/derive.go b/internal/forecast/derive.go index c8baa45..9ee71d3 100644 --- a/internal/forecast/derive.go +++ b/internal/forecast/derive.go @@ -47,6 +47,8 @@ type TimedValue struct { Time time.Time `json:"time"` } +const DefaultPrecipWindowProbabilityThreshold = 40 + type Indicators struct { Snow bool `json:"snow,omitempty"` Ice bool `json:"ice,omitempty"` @@ -66,34 +68,101 @@ type AlertOverlap struct { } type PrecipTiming struct { - MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"` - FirstPrecipitation *TimedValue `json:"firstPrecipitation,omitempty"` - LastPrecipitation *TimedValue `json:"lastPrecipitation,omitempty"` - ThunderMentioned bool `json:"thunderMentioned,omitempty"` + MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"` + FirstPrecipitation *TimedValue `json:"firstPrecipitation,omitempty"` + LastPrecipitation *TimedValue `json:"lastPrecipitation,omitempty"` + ProbabilityThreshold float64 `json:"probabilityThreshold"` + PrecipitationWindows []PrecipitationWindow `json:"precipitationWindows,omitempty"` + ThunderMentioned bool `json:"thunderMentioned,omitempty"` +} + +type PrecipitationWindow struct { + Start time.Time `json:"start"` + End *time.Time `json:"end,omitempty"` + MaxPrecipitationProbability TimedValue `json:"maxPrecipitationProbability"` + ProbabilityThreshold float64 `json:"probabilityThreshold"` } 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, + return buildPrecipTimingWithThreshold(periods, DefaultPrecipWindowProbabilityThreshold) +} + +func buildPrecipTimingWithThreshold(periods []weatherdata.ForecastPeriod, threshold float64) PrecipTiming { + timing := PrecipTiming{ProbabilityThreshold: threshold} + sorted := append([]weatherdata.ForecastPeriod(nil), periods...) + sort.SliceStable(sorted, func(i int, j int) bool { + return sorted[i].StartTime.Before(sorted[j].StartTime) + }) + + var active *PrecipitationWindow + var activeLastEnd time.Time + closeActive := func() { + if active == nil { + return + } + end := activeLastEnd + active.End = &end + timing.PrecipitationWindows = append(timing.PrecipitationWindows, *active) + active = nil + } + startActive := func(forecastPeriod weatherdata.ForecastPeriod, probability float64) { + active = &PrecipitationWindow{ + Start: forecastPeriod.StartTime, + MaxPrecipitationProbability: TimedValue{ + Value: probability, + Time: forecastPeriod.StartTime, + }, + ProbabilityThreshold: threshold, + } + activeLastEnd = forecastPeriod.EndTime + if timing.FirstPrecipitation == nil { + timing.FirstPrecipitation = &TimedValue{ + Value: probability, 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 + } + } + + for _, forecastPeriod := range sorted { + setMaxTimedValue(&timing.MaxPrecipitationProbability, forecastPeriod.ProbabilityOfPrecipitationPercent, forecastPeriod.StartTime) + if forecastPeriod.ProbabilityOfPrecipitationPercent == nil || *forecastPeriod.ProbabilityOfPrecipitationPercent < threshold { + closeActive() + } else { + probability := *forecastPeriod.ProbabilityOfPrecipitationPercent + if active == nil { + startActive(forecastPeriod, probability) + } else { + if forecastPeriod.StartTime.After(activeLastEnd) { + closeActive() + startActive(forecastPeriod, probability) + } + if probability > active.MaxPrecipitationProbability.Value { + active.MaxPrecipitationProbability = TimedValue{ + Value: probability, + Time: forecastPeriod.StartTime, + } + } + if forecastPeriod.EndTime.After(activeLastEnd) { + activeLastEnd = forecastPeriod.EndTime + } } } if mentionsThunder(forecastPeriod.TextDescription) { timing.ThunderMentioned = true } } + if active != nil { + timing.PrecipitationWindows = append(timing.PrecipitationWindows, *active) + } + if len(timing.PrecipitationWindows) > 0 { + final := timing.PrecipitationWindows[len(timing.PrecipitationWindows)-1] + if final.End != nil { + timing.LastPrecipitation = &TimedValue{ + Value: final.MaxPrecipitationProbability.Value, + Time: *final.End, + } + } + } return timing } diff --git a/internal/forecast/derive_test.go b/internal/forecast/derive_test.go index 886bc33..116dd64 100644 --- a/internal/forecast/derive_test.go +++ b/internal/forecast/derive_test.go @@ -210,31 +210,94 @@ func TestAlertOverlap(t *testing.T) { } } -func TestBuildPrecipTimingTracksRainAndThunder(t *testing.T) { +func TestBuildPrecipTimingBuildsThresholdWindows(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), + hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Brief lull", 70, nil, ptr(39.999), nil, nil), + hour(location, "2026-05-29T13:00:00-05:00", "2026-05-29T14:00:00-05:00", "Unknown rain chance", 70, nil, nil, nil, nil), + hour(location, "2026-05-29T10:00:00-05:00", "2026-05-29T11:00:00-05:00", "Rain likely", 70, nil, ptr(60), nil, nil), + hour(location, "2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", "Showers", 70, nil, ptr(40), 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) + t.Fatalf("FirstPrecipitation = %#v, want first threshold window start", 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.LastPrecipitation == nil || timing.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T13:00:00-05:00" { + t.Fatalf("LastPrecipitation = %#v, want final closed threshold window end", timing.LastPrecipitation) } if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 80 { t.Fatalf("MaxPrecipitationProbability = %#v, want 80", timing.MaxPrecipitationProbability) } + if timing.ProbabilityThreshold != DefaultPrecipWindowProbabilityThreshold { + t.Fatalf("ProbabilityThreshold = %v, want default threshold", timing.ProbabilityThreshold) + } + if len(timing.PrecipitationWindows) != 2 { + t.Fatalf("PrecipitationWindows length = %d, want 2: %#v", len(timing.PrecipitationWindows), timing.PrecipitationWindows) + } + first := timing.PrecipitationWindows[0] + if first.Start.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" || first.End == nil || first.End.Format(time.RFC3339) != "2026-05-29T11:00:00-05:00" { + t.Fatalf("first window = %#v, want 9-11 AM", first) + } + if first.MaxPrecipitationProbability.Value != 60 || first.MaxPrecipitationProbability.Time.Format(time.RFC3339) != "2026-05-29T10:00:00-05:00" { + t.Fatalf("first window max = %#v, want 60 at 10 AM", first.MaxPrecipitationProbability) + } + second := timing.PrecipitationWindows[1] + if second.Start.Format(time.RFC3339) != "2026-05-29T12:00:00-05:00" || second.End == nil || second.End.Format(time.RFC3339) != "2026-05-29T13:00:00-05:00" { + t.Fatalf("second window = %#v, want noon-1 PM", second) + } if !timing.ThunderMentioned { t.Fatal("ThunderMentioned = false, want true") } } +func TestBuildPrecipTimingLeavesFinalWindowOpen(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(45), nil, nil), + hour(location, "2026-05-29T10:00:00-05:00", "2026-05-29T11:00:00-05:00", "Rain likely", 70, nil, ptr(60), nil, nil), + } + + timing := BuildPrecipTiming(periods) + + if len(timing.PrecipitationWindows) != 1 { + t.Fatalf("PrecipitationWindows length = %d, want 1", len(timing.PrecipitationWindows)) + } + if timing.PrecipitationWindows[0].End != nil { + t.Fatalf("open window End = %v, want nil", timing.PrecipitationWindows[0].End) + } + if timing.LastPrecipitation != nil { + t.Fatalf("LastPrecipitation = %#v, want nil for open final window", timing.LastPrecipitation) + } +} + +func TestBuildPrecipTimingSupportsNonDefaultThreshold(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", "Showers", 70, nil, ptr(50), nil, nil), + hour(location, "2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", "Rain likely", 70, nil, ptr(60), nil, nil), + hour(location, "2026-05-29T10:00:00-05:00", "2026-05-29T11:00:00-05:00", "Showers", 70, nil, ptr(55), nil, nil), + hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Drying out", 70, nil, ptr(20), nil, nil), + } + + timing := buildPrecipTimingWithThreshold(periods, 55) + + if timing.ProbabilityThreshold != 55 { + t.Fatalf("ProbabilityThreshold = %v, want 55", timing.ProbabilityThreshold) + } + if len(timing.PrecipitationWindows) != 1 { + t.Fatalf("PrecipitationWindows length = %d, want 1", len(timing.PrecipitationWindows)) + } + window := timing.PrecipitationWindows[0] + if window.Start.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" || window.End == nil || window.End.Format(time.RFC3339) != "2026-05-29T11:00:00-05:00" { + t.Fatalf("window = %#v, want 9-11 AM", window) + } +} + func TestBuildPrecipTimingHandlesDryForecast(t *testing.T) { location := time.FixedZone("Test", -5*60*60) periods := []weatherdata.ForecastPeriod{ @@ -243,9 +306,12 @@ func TestBuildPrecipTimingHandlesDryForecast(t *testing.T) { timing := BuildPrecipTiming(periods) - if timing.FirstPrecipitation != nil || timing.LastPrecipitation != nil || timing.ThunderMentioned { + if timing.FirstPrecipitation != nil || timing.LastPrecipitation != nil || len(timing.PrecipitationWindows) != 0 || timing.ThunderMentioned { t.Fatalf("dry timing = %#v, want no precip timing and no thunder", timing) } + if timing.ProbabilityThreshold != DefaultPrecipWindowProbabilityThreshold { + t.Fatalf("ProbabilityThreshold = %v, want default threshold", timing.ProbabilityThreshold) + } if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 0 { t.Fatalf("dry max precip = %#v, want checked zero chance", timing.MaxPrecipitationProbability) }