Validate precipitation probabilities and ice wording
This commit is contained in:
@@ -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
|
data; it also derives precipitation timing. Convective outlooks are retained
|
||||||
only when their valid interval overlaps the report period, with discussions
|
only when their valid interval overlaps the report period, with discussions
|
||||||
kept for represented outlook days. Both collections are sorted deterministically.
|
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:
|
Report identity controls the summary shape:
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,18 @@ its selected hourly periods and derives temperature and apparent-temperature
|
|||||||
ranges, timed precipitation and wind maxima, dominant and notable conditions,
|
ranges, timed precipitation and wind maxima, dominant and notable conditions,
|
||||||
and weather indicators.
|
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:
|
Indicators are deterministic checks over normalized values and condition text:
|
||||||
heat, cold, and wind use package-owned numeric cutoffs; snow, ice, fog, and
|
heat, cold, and wind use package-owned numeric cutoffs; snow, ice, fog, and
|
||||||
wind text are detected from the forecast description. `BuildPrecipTiming`
|
wind text are detected from the forecast description. `BuildPrecipTiming`
|
||||||
sorts periods, records the maximum and first precipitation, groups contiguous
|
sorts periods, records the maximum and first precipitation, groups contiguous
|
||||||
periods at or above its package-owned probability threshold, and records
|
periods at or above its package-owned probability threshold, and records
|
||||||
thunder mentions.
|
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
|
Daypart temperature and apparent-temperature ranges are Fahrenheit values, and
|
||||||
timed wind maxima are mph values. When only metric source fields are present,
|
timed wind maxima are mph values. When only metric source fields are present,
|
||||||
|
|||||||
@@ -228,6 +228,9 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
|||||||
if !period.HasUsableTimeBounds() {
|
if !period.HasUsableTimeBounds() {
|
||||||
return fmt.Errorf("hourly forecast from %s has unusable time bounds for period %d", source.Endpoint, i+1)
|
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.IssuedAt = &hourly.IssuedAt
|
||||||
source.UpdatedAt = hourly.UpdatedAt
|
source.UpdatedAt = hourly.UpdatedAt
|
||||||
|
|||||||
@@ -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) {
|
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||||
var requested []string
|
var requested []string
|
||||||
server := fixtureServer(t, nil, &requested)
|
server := fixtureServer(t, nil, &requested)
|
||||||
|
|||||||
@@ -101,6 +101,11 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
|
|||||||
}
|
}
|
||||||
bundle := req.Collected.Bundle()
|
bundle := req.Collected.Bundle()
|
||||||
period := req.Resolved.ValidPeriod
|
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)
|
spcOutlooks, spcDiscussions := selectSPCConvectiveOutlooks(req.Collected.SPCConvectiveOutlooks, period)
|
||||||
derived := DerivedFacts{
|
derived := DerivedFacts{
|
||||||
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
|
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
|
||||||
|
|||||||
@@ -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) {
|
func TestBuildDerivedTomorrow(t *testing.T) {
|
||||||
location := testLocation()
|
location := testLocation()
|
||||||
for _, id := range []report.ID{report.Tomorrow} {
|
for _, id := range []report.ID{report.Tomorrow} {
|
||||||
|
|||||||
@@ -192,6 +192,9 @@ func BuildDailySummary(bundle *weatherdata.Bundle, date time.Time, location *tim
|
|||||||
if bundle.Hourly == nil || len(bundle.Hourly.Periods) == 0 {
|
if bundle.Hourly == nil || len(bundle.Hourly.Periods) == 0 {
|
||||||
return nil, fmt.Errorf("hourly forecast data is required")
|
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)
|
day := timeutil.CivilDay(date, location)
|
||||||
windows, err := ResolveDayparts(date, location, dayparts)
|
windows, err := ResolveDayparts(date, location, dayparts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -247,6 +250,15 @@ func SelectDiscussion(bundle *weatherdata.Bundle) *weatherdata.Discussion {
|
|||||||
return bundle.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 {
|
func SummarizeDaypart(name string, period timeutil.Period, periods []weatherdata.ForecastPeriod) DaypartSummary {
|
||||||
summary := DaypartSummary{
|
summary := DaypartSummary{
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -357,12 +369,24 @@ func indicatorsForText(text string) Indicators {
|
|||||||
lower := strings.ToLower(text)
|
lower := strings.ToLower(text)
|
||||||
return Indicators{
|
return Indicators{
|
||||||
Snow: strings.Contains(lower, "snow"),
|
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"),
|
Fog: strings.Contains(lower, "fog"),
|
||||||
Wind: strings.Contains(lower, "wind") || strings.Contains(lower, "gust"),
|
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 {
|
func mentionsThunder(text string) bool {
|
||||||
return strings.Contains(strings.ToLower(text), "thunder")
|
return strings.Contains(strings.ToLower(text), "thunder")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestAlertOverlap(t *testing.T) {
|
||||||
location := time.FixedZone("Test", -5*60*60)
|
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"}`)
|
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"}`)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package weatherdata
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"math"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -137,6 +138,12 @@ func (p ForecastPeriod) HasUsableTimeBounds() bool {
|
|||||||
return !p.StartTime.IsZero() && !p.EndTime.IsZero() && p.EndTime.After(p.StartTime)
|
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 {
|
type AlertRun struct {
|
||||||
AsOf *time.Time `json:"asOf,omitempty"`
|
AsOf *time.Time `json:"asOf,omitempty"`
|
||||||
Alerts []json.RawMessage `json:"alerts,omitempty"`
|
Alerts []json.RawMessage `json:"alerts,omitempty"`
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package weatherdata
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"math"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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 {
|
func mustParseBundleTime(value string) time.Time {
|
||||||
parsed, err := time.Parse(time.RFC3339, value)
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -100,3 +124,7 @@ func mustParseBundleTime(value string) time.Time {
|
|||||||
}
|
}
|
||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ptrProbability(value float64) *float64 {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user