diff --git a/docs/internal/facts.md b/docs/internal/facts.md index eb78270..ac313c1 100644 --- a/docs/internal/facts.md +++ b/docs/internal/facts.md @@ -26,6 +26,8 @@ uses half-open period overlap to select hourly, narrative, daily, and alert data; it also derives precipitation timing. Convective outlooks are retained only when their valid interval overlaps the report period, with discussions kept for represented outlook days. Both collections are sorted deterministically. +It rejects collected hourly data with a precipitation probability outside the +finite 0 through 100 percentage domain before constructing derived facts. Report identity controls the summary shape: diff --git a/docs/internal/forecast-derivation.md b/docs/internal/forecast-derivation.md index 4413180..90ebf82 100644 --- a/docs/internal/forecast-derivation.md +++ b/docs/internal/forecast-derivation.md @@ -26,12 +26,18 @@ its selected hourly periods and derives temperature and apparent-temperature ranges, timed precipitation and wind maxima, dominant and notable conditions, and weather indicators. +Hourly precipitation probabilities must be finite percentages from 0 through +100. Daily-summary construction rejects invalid values before they can affect +timed maxima or precipitation windows. + Indicators are deterministic checks over normalized values and condition text: heat, cold, and wind use package-owned numeric cutoffs; snow, ice, fog, and wind text are detected from the forecast description. `BuildPrecipTiming` sorts periods, records the maximum and first precipitation, groups contiguous periods at or above its package-owned probability threshold, and records thunder mentions. +Ice detection uses the bounded condition vocabulary `ice`, `icy`, `freezing`, +and `sleet` as whole words. Daypart temperature and apparent-temperature ranges are Fahrenheit values, and timed wind maxima are mph values. When only metric source fields are present, diff --git a/internal/adapters/weatherapi/client.go b/internal/adapters/weatherapi/client.go index 28461bc..9f4c8bc 100644 --- a/internal/adapters/weatherapi/client.go +++ b/internal/adapters/weatherapi/client.go @@ -228,6 +228,9 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error { if !period.HasUsableTimeBounds() { return fmt.Errorf("hourly forecast from %s has unusable time bounds for period %d", source.Endpoint, i+1) } + if !period.HasValidPrecipitationProbability() { + return fmt.Errorf("hourly forecast from %s has invalid precipitation probability for period %d", source.Endpoint, i+1) + } } source.IssuedAt = &hourly.IssuedAt source.UpdatedAt = hourly.UpdatedAt diff --git a/internal/adapters/weatherapi/client_test.go b/internal/adapters/weatherapi/client_test.go index fda6401..751537c 100644 --- a/internal/adapters/weatherapi/client_test.go +++ b/internal/adapters/weatherapi/client_test.go @@ -112,6 +112,22 @@ func TestFetchBundleFromFixtures(t *testing.T) { } } +func TestFetchBundleRejectsInvalidHourlyPrecipitationProbability(t *testing.T) { + for _, probability := range []string{"-1", "101"} { + t.Run(probability, func(t *testing.T) { + server := fixtureServer(t, map[string]handlerOverride{ + "/forecast/hourly": {status: http.StatusOK, body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T14:00:00Z","probabilityOfPrecipitationPercent":` + probability + `}]}}`}, + }, nil) + client := newTestClient(t, server.URL+"/", nil) + + _, err := client.FetchBundle(context.Background()) + if err == nil || !strings.Contains(err.Error(), "invalid precipitation probability") { + t.Fatalf("FetchBundle() error = %v, want invalid precipitation probability", err) + } + }) + } +} + func TestFetchBundleBuildsExpectedQueries(t *testing.T) { var requested []string server := fixtureServer(t, nil, &requested) diff --git a/internal/facts/facts.go b/internal/facts/facts.go index 0977014..dfd5b89 100644 --- a/internal/facts/facts.go +++ b/internal/facts/facts.go @@ -101,6 +101,11 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) { } bundle := req.Collected.Bundle() period := req.Resolved.ValidPeriod + if req.Collected.Hourly != nil { + if err := forecast.ValidatePrecipitationProbabilities(req.Collected.Hourly.Periods); err != nil { + return DerivedFacts{}, err + } + } spcOutlooks, spcDiscussions := selectSPCConvectiveOutlooks(req.Collected.SPCConvectiveOutlooks, period) derived := DerivedFacts{ ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period), diff --git a/internal/facts/facts_test.go b/internal/facts/facts_test.go index 9696e65..81c3dba 100644 --- a/internal/facts/facts_test.go +++ b/internal/facts/facts_test.go @@ -127,6 +127,21 @@ func TestBuildDerivedDailyIncludesNextDayOvernightAlerts(t *testing.T) { } } +func TestBuildDerivedRejectsInvalidPrecipitationProbability(t *testing.T) { + location := testLocation() + bundle := testBundle(location) + invalid := -1.0 + bundle.Hourly.Periods[0].ProbabilityOfPrecipitationPercent = &invalid + _, err := BuildDerived(BuildDerivedRequest{ + Resolved: resolveForTest(t, report.Hourly, mustParse("2026-05-29T08:00:00-05:00"), location), + Timezone: location.String(), + Collected: BuildCollected(bundle), + }) + if err == nil || !strings.Contains(err.Error(), "invalid precipitation probability") { + t.Fatalf("BuildDerived() error = %v, want invalid precipitation probability", err) + } +} + func TestBuildDerivedTomorrow(t *testing.T) { location := testLocation() for _, id := range []report.ID{report.Tomorrow} { diff --git a/internal/forecast/derive.go b/internal/forecast/derive.go index 64ff819..9be4a23 100644 --- a/internal/forecast/derive.go +++ b/internal/forecast/derive.go @@ -192,6 +192,9 @@ func BuildDailySummary(bundle *weatherdata.Bundle, date time.Time, location *tim if bundle.Hourly == nil || len(bundle.Hourly.Periods) == 0 { return nil, fmt.Errorf("hourly forecast data is required") } + if err := ValidatePrecipitationProbabilities(bundle.Hourly.Periods); err != nil { + return nil, err + } day := timeutil.CivilDay(date, location) windows, err := ResolveDayparts(date, location, dayparts) if err != nil { @@ -247,6 +250,15 @@ func SelectDiscussion(bundle *weatherdata.Bundle) *weatherdata.Discussion { return bundle.Discussion } +func ValidatePrecipitationProbabilities(periods []weatherdata.ForecastPeriod) error { + for i, period := range periods { + if !period.HasValidPrecipitationProbability() { + return fmt.Errorf("hourly forecast period %d has an invalid precipitation probability", i+1) + } + } + return nil +} + func SummarizeDaypart(name string, period timeutil.Period, periods []weatherdata.ForecastPeriod) DaypartSummary { summary := DaypartSummary{ Name: name, @@ -357,12 +369,24 @@ func indicatorsForText(text string) Indicators { lower := strings.ToLower(text) return Indicators{ Snow: strings.Contains(lower, "snow"), - Ice: strings.Contains(lower, "ice") || strings.Contains(lower, "freezing") || strings.Contains(lower, "sleet"), + Ice: mentionsIce(lower), Fog: strings.Contains(lower, "fog"), Wind: strings.Contains(lower, "wind") || strings.Contains(lower, "gust"), } } +func mentionsIce(text string) bool { + for _, token := range strings.FieldsFunc(text, func(r rune) bool { + return !('a' <= r && r <= 'z') + }) { + switch token { + case "ice", "icy", "freezing", "sleet": + return true + } + } + return false +} + func mentionsThunder(text string) bool { return strings.Contains(strings.ToLower(text), "thunder") } diff --git a/internal/forecast/derive_test.go b/internal/forecast/derive_test.go index f526cd6..433eb80 100644 --- a/internal/forecast/derive_test.go +++ b/internal/forecast/derive_test.go @@ -297,6 +297,41 @@ func TestBuildDailySummaryRequiresHourlyData(t *testing.T) { } } +func TestBuildDailySummaryRejectsInvalidPrecipitationProbability(t *testing.T) { + location := time.FixedZone("Test", -5*60*60) + for _, probability := range []float64{-1, 101, math.NaN()} { + bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{ + hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Showers", 70, nil, ptr(probability), nil, nil), + }}} + _, err := BuildDailySummary(bundle, mustParse("2026-05-29T12:00:00-05:00"), location, []DaypartDefinition{{Name: "morning", Start: "06:00", End: "12:00"}}) + if err == nil { + t.Fatalf("BuildDailySummary(%v) error = nil, want invalid precipitation probability", probability) + } + } +} + +func TestIndicatorsForTextClassifyIceVocabulary(t *testing.T) { + for _, tt := range []struct { + name string + text string + want Indicators + }{ + {name: "ice", text: "Ice likely", want: Indicators{Ice: true}}, + {name: "icy mixed case", text: "ICY roads", want: Indicators{Ice: true}}, + {name: "freezing punctuation", text: "Freezing-rain possible", want: Indicators{Ice: true}}, + {name: "sleet punctuation", text: "Sleet, then rain", want: Indicators{Ice: true}}, + {name: "unrelated nice", text: "Nice weather"}, + {name: "unrelated spicy", text: "Spicy conditions"}, + {name: "other conditions unchanged", text: "Snow and fog with gusts", want: Indicators{Snow: true, Fog: true, Wind: true}}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := indicatorsForText(tt.text); got != tt.want { + t.Fatalf("indicatorsForText(%q) = %#v, want %#v", tt.text, got, tt.want) + } + }) + } +} + func TestAlertOverlap(t *testing.T) { location := time.FixedZone("Test", -5*60*60) raw := json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"}`) diff --git a/internal/weatherdata/bundle.go b/internal/weatherdata/bundle.go index 3b8d1b3..4cd23e9 100644 --- a/internal/weatherdata/bundle.go +++ b/internal/weatherdata/bundle.go @@ -3,6 +3,7 @@ package weatherdata import ( "encoding/json" + "math" "time" ) @@ -137,6 +138,12 @@ func (p ForecastPeriod) HasUsableTimeBounds() bool { return !p.StartTime.IsZero() && !p.EndTime.IsZero() && p.EndTime.After(p.StartTime) } +// HasValidPrecipitationProbability reports whether an optional probability is a finite percentage. +func (p ForecastPeriod) HasValidPrecipitationProbability() bool { + value := p.ProbabilityOfPrecipitationPercent + return value == nil || !math.IsNaN(*value) && !math.IsInf(*value, 0) && *value >= 0 && *value <= 100 +} + type AlertRun struct { AsOf *time.Time `json:"asOf,omitempty"` Alerts []json.RawMessage `json:"alerts,omitempty"` diff --git a/internal/weatherdata/bundle_test.go b/internal/weatherdata/bundle_test.go index e589984..bed0af6 100644 --- a/internal/weatherdata/bundle_test.go +++ b/internal/weatherdata/bundle_test.go @@ -2,6 +2,7 @@ package weatherdata import ( "encoding/json" + "math" "testing" "time" ) @@ -93,6 +94,29 @@ func TestForecastPeriodHasUsableTimeBounds(t *testing.T) { } } +func TestForecastPeriodHasValidPrecipitationProbability(t *testing.T) { + for _, tt := range []struct { + name string + value *float64 + want bool + }{ + {name: "missing", want: true}, + {name: "zero", value: ptrProbability(0), want: true}, + {name: "one hundred", value: ptrProbability(100), want: true}, + {name: "negative", value: ptrProbability(-0.1)}, + {name: "above one hundred", value: ptrProbability(100.1)}, + {name: "not a number", value: ptrProbability(math.NaN())}, + {name: "infinite", value: ptrProbability(math.Inf(1))}, + } { + t.Run(tt.name, func(t *testing.T) { + period := ForecastPeriod{ProbabilityOfPrecipitationPercent: tt.value} + if got := period.HasValidPrecipitationProbability(); got != tt.want { + t.Fatalf("HasValidPrecipitationProbability() = %t, want %t", got, tt.want) + } + }) + } +} + func mustParseBundleTime(value string) time.Time { parsed, err := time.Parse(time.RFC3339, value) if err != nil { @@ -100,3 +124,7 @@ func mustParseBundleTime(value string) time.Time { } return parsed } + +func ptrProbability(value float64) *float64 { + return &value +}