Validate precipitation probabilities and ice wording

This commit is contained in:
2026-08-13 00:48:40 +00:00
parent 8e49ba88c7
commit 730929e2ed
10 changed files with 142 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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