Preserve metric forecast units and overnight alerts
This commit is contained in:
@@ -37,6 +37,8 @@ Report identity controls the summary shape:
|
||||
`DaypartSummaries` is collected from the resulting daily summaries.
|
||||
The detailed grouping, daypart-window, and alert rules are owned by
|
||||
[forecast derivation](forecast-derivation.md).
|
||||
Daily alert overlaps remain scoped to the civil day, while overnight daypart
|
||||
summaries retain alerts that overlap their complete next-day window.
|
||||
|
||||
## Missing data and failures
|
||||
|
||||
|
||||
@@ -33,9 +33,17 @@ sorts periods, records the maximum and first precipitation, groups contiguous
|
||||
periods at or above its package-owned probability threshold, and records
|
||||
thunder mentions.
|
||||
|
||||
Daypart temperature and apparent-temperature ranges are Fahrenheit values, and
|
||||
timed wind maxima are mph values. When only metric source fields are present,
|
||||
they are converted to those units before they are stored or evaluated against
|
||||
indicator thresholds; populated US-customary fields take precedence.
|
||||
|
||||
Alert overlap parsing supports the normalized alert payload's available timing
|
||||
fields. Unparseable alerts and invalid intervals are ignored; valid overlaps
|
||||
are clipped to the requested period and ordered by alert start time.
|
||||
`DailySummary.AlertOverlaps` is limited to the local civil day, while each
|
||||
daypart evaluates the full alert run against its complete window, including the
|
||||
next-day portion of an overnight window.
|
||||
|
||||
## Missing data and failures
|
||||
|
||||
|
||||
@@ -100,6 +100,33 @@ func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDerivedDailyIncludesNextDayOvernightAlerts(t *testing.T) {
|
||||
location := testLocation()
|
||||
bundle := testBundle(location)
|
||||
bundle.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||
json.RawMessage(`{"event":"Overnight Advisory","onset":"2026-05-30T01:00:00-05:00","ends":"2026-05-30T05:00:00-05:00"}`),
|
||||
}}
|
||||
derived, err := BuildDerived(BuildDerivedRequest{
|
||||
Resolved: resolveForTest(t, report.Daily, mustParse("2026-05-29T08:00:00-05:00"), location),
|
||||
Timezone: location.String(),
|
||||
Dayparts: []forecast.DaypartDefinition{
|
||||
{Name: "night", Start: "22:00", End: "06:00"},
|
||||
},
|
||||
Collected: BuildCollected(bundle),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDerived() error = %v", err)
|
||||
}
|
||||
|
||||
summary := derived.FirstDailySummary()
|
||||
if summary == nil || len(summary.AlertOverlaps) != 0 {
|
||||
t.Fatalf("DailySummary = %#v, want no next-day daily alerts", summary)
|
||||
}
|
||||
if len(derived.DaypartSummaries) != 1 || len(derived.DaypartSummaries[0].AlertOverlaps) != 1 || derived.DaypartSummaries[0].AlertOverlaps[0].Event != "Overnight Advisory" {
|
||||
t.Fatalf("DaypartSummaries = %#v, want next-day overnight alert", derived.DaypartSummaries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDerivedTomorrow(t *testing.T) {
|
||||
location := testLocation()
|
||||
for _, id := range []report.ID{report.Tomorrow} {
|
||||
|
||||
@@ -211,7 +211,7 @@ func BuildDailySummary(bundle *weatherdata.Bundle, date time.Time, location *tim
|
||||
for _, window := range windows {
|
||||
periods := SelectHourlyPeriods(bundle.Hourly, window.Period)
|
||||
daypartSummary := SummarizeDaypart(window.Name, window.Period, periods)
|
||||
daypartSummary.AlertOverlaps = overlapsWithin(alerts, window.Period)
|
||||
daypartSummary.AlertOverlaps = AlertOverlaps(bundle.Alerts, window.Period)
|
||||
summary.Dayparts = append(summary.Dayparts, daypartSummary)
|
||||
}
|
||||
return summary, nil
|
||||
@@ -258,10 +258,10 @@ func SummarizeDaypart(name string, period timeutil.Period, periods []weatherdata
|
||||
|
||||
for _, forecastPeriod := range periods {
|
||||
addRangeValue(&summary.Temperature, periodTemperatureValues(forecastPeriod)...)
|
||||
addRangeValue(&summary.ApparentTemperature, valueFromPointers(forecastPeriod.ApparentTemperatureF, forecastPeriod.ApparentTemperatureC)...)
|
||||
addRangeValue(&summary.ApparentTemperature, temperatureF(forecastPeriod.ApparentTemperatureF, forecastPeriod.ApparentTemperatureC))
|
||||
setMaxTimedValue(&summary.MaxPrecipitationProbability, forecastPeriod.ProbabilityOfPrecipitationPercent, forecastPeriod.StartTime)
|
||||
setMaxTimedValue(&summary.PeakWindSpeed, firstValue(forecastPeriod.WindSpeedMph, forecastPeriod.WindSpeedKmh), forecastPeriod.StartTime)
|
||||
setMaxTimedValue(&summary.PeakWindGust, firstValue(forecastPeriod.WindGustMph, forecastPeriod.WindGustKmh), forecastPeriod.StartTime)
|
||||
setMaxTimedValue(&summary.PeakWindSpeed, milesPerHour(forecastPeriod.WindSpeedMph, forecastPeriod.WindSpeedKmh), forecastPeriod.StartTime)
|
||||
setMaxTimedValue(&summary.PeakWindGust, milesPerHour(forecastPeriod.WindGustMph, forecastPeriod.WindGustKmh), forecastPeriod.StartTime)
|
||||
|
||||
text := strings.TrimSpace(forecastPeriod.TextDescription)
|
||||
if text != "" {
|
||||
@@ -278,29 +278,33 @@ func SummarizeDaypart(name string, period timeutil.Period, periods []weatherdata
|
||||
}
|
||||
|
||||
func periodTemperatureValues(period weatherdata.ForecastPeriod) []*float64 {
|
||||
values := []*float64{}
|
||||
values = append(values, valueFromPointers(period.TemperatureF, period.TemperatureC)...)
|
||||
values = append(values, valueFromPointers(period.TemperatureFMin, period.TemperatureCMin)...)
|
||||
values = append(values, valueFromPointers(period.TemperatureFMax, period.TemperatureCMax)...)
|
||||
return values
|
||||
return []*float64{
|
||||
temperatureF(period.TemperatureF, period.TemperatureC),
|
||||
temperatureF(period.TemperatureFMin, period.TemperatureCMin),
|
||||
temperatureF(period.TemperatureFMax, period.TemperatureCMax),
|
||||
}
|
||||
}
|
||||
|
||||
func valueFromPointers(values ...*float64) []*float64 {
|
||||
for _, value := range values {
|
||||
if value != nil {
|
||||
return []*float64{value}
|
||||
}
|
||||
func temperatureF(fahrenheit *float64, celsius *float64) *float64 {
|
||||
if fahrenheit != nil {
|
||||
return fahrenheit
|
||||
}
|
||||
return nil
|
||||
if celsius == nil {
|
||||
return nil
|
||||
}
|
||||
converted := *celsius*9/5 + 32
|
||||
return &converted
|
||||
}
|
||||
|
||||
func firstValue(values ...*float64) *float64 {
|
||||
for _, value := range values {
|
||||
if value != nil {
|
||||
return value
|
||||
}
|
||||
func milesPerHour(mph *float64, kmh *float64) *float64 {
|
||||
if mph != nil {
|
||||
return mph
|
||||
}
|
||||
return nil
|
||||
if kmh == nil {
|
||||
return nil
|
||||
}
|
||||
converted := *kmh / 1.609344
|
||||
return &converted
|
||||
}
|
||||
|
||||
func addRangeValue(target *Range, values ...*float64) {
|
||||
@@ -364,15 +368,13 @@ func mentionsThunder(text string) bool {
|
||||
}
|
||||
|
||||
func numericIndicators(period weatherdata.ForecastPeriod) Indicators {
|
||||
windGust := firstValue(period.WindGustMph, period.WindGustKmh)
|
||||
windSpeed := firstValue(period.WindSpeedMph, period.WindSpeedKmh)
|
||||
windGust := milesPerHour(period.WindGustMph, period.WindGustKmh)
|
||||
windSpeed := milesPerHour(period.WindSpeedMph, period.WindSpeedKmh)
|
||||
temperature := temperatureF(period.TemperatureF, period.TemperatureC)
|
||||
indicators := Indicators{}
|
||||
if period.TemperatureF != nil {
|
||||
indicators.Heat = *period.TemperatureF >= 95
|
||||
indicators.Cold = *period.TemperatureF <= 32
|
||||
} else if period.TemperatureC != nil {
|
||||
indicators.Heat = *period.TemperatureC >= 35
|
||||
indicators.Cold = *period.TemperatureC <= 0
|
||||
if temperature != nil {
|
||||
indicators.Heat = *temperature >= 95
|
||||
indicators.Cold = *temperature <= 32
|
||||
}
|
||||
if windGust != nil && *windGust >= 35 || windSpeed != nil && *windSpeed >= 25 {
|
||||
indicators.Wind = true
|
||||
@@ -501,14 +503,3 @@ func intersect(left timeutil.Period, right timeutil.Period) timeutil.Period {
|
||||
}
|
||||
return timeutil.Period{Start: start, End: end}
|
||||
}
|
||||
|
||||
func overlapsWithin(alerts []AlertOverlap, period timeutil.Period) []AlertOverlap {
|
||||
var out []AlertOverlap
|
||||
for _, alert := range alerts {
|
||||
if alert.Period.Overlaps(period) {
|
||||
alert.Overlap = intersect(alert.Period, period)
|
||||
out = append(out, alert)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package forecast
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -99,11 +100,17 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
|
||||
func TestOvernightGroupingAcrossMidnight(t *testing.T) {
|
||||
location := time.FixedZone("Test", -5*60*60)
|
||||
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location)
|
||||
bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||
hour(location, "2026-05-29T23:00:00-05:00", "2026-05-30T00:00:00-05:00", "Snow", 31, nil, nil, nil, nil),
|
||||
hour(location, "2026-05-30T05:00:00-05:00", "2026-05-30T06:00:00-05:00", "Fog", 30, nil, nil, nil, nil),
|
||||
hour(location, "2026-05-30T06:00:00-05:00", "2026-05-30T07:00:00-05:00", "Clear", 35, nil, nil, nil, nil),
|
||||
}}}
|
||||
bundle := &weatherdata.Bundle{
|
||||
Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||
hour(location, "2026-05-29T23:00:00-05:00", "2026-05-30T00:00:00-05:00", "Snow", 31, nil, nil, nil, nil),
|
||||
hour(location, "2026-05-30T05:00:00-05:00", "2026-05-30T06:00:00-05:00", "Fog", 30, nil, nil, nil, nil),
|
||||
hour(location, "2026-05-30T06:00:00-05:00", "2026-05-30T07:00:00-05:00", "Clear", 35, nil, nil, nil, nil),
|
||||
}},
|
||||
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||
json.RawMessage(`{"event":"Overnight Advisory","onset":"2026-05-30T01:00:00-05:00","ends":"2026-05-30T05:00:00-05:00"}`),
|
||||
json.RawMessage(`{"event":"After Night","onset":"2026-05-30T06:00:00-05:00","ends":"2026-05-30T07:00:00-05:00"}`),
|
||||
}},
|
||||
}
|
||||
|
||||
summary, err := BuildDailySummary(bundle, date, location, []DaypartDefinition{
|
||||
{Name: "night", Start: "22:00", End: "06:00"},
|
||||
@@ -118,6 +125,142 @@ func TestOvernightGroupingAcrossMidnight(t *testing.T) {
|
||||
if !night.Indicators.Snow || !night.Indicators.Fog || !night.Indicators.Cold {
|
||||
t.Fatalf("night indicators = %#v, want snow, fog, and cold", night.Indicators)
|
||||
}
|
||||
if len(summary.AlertOverlaps) != 0 {
|
||||
t.Fatalf("daily AlertOverlaps = %#v, want next-day alerts excluded", summary.AlertOverlaps)
|
||||
}
|
||||
if len(night.AlertOverlaps) != 1 || night.AlertOverlaps[0].Event != "Overnight Advisory" {
|
||||
t.Fatalf("night AlertOverlaps = %#v, want complete overnight overlap", night.AlertOverlaps)
|
||||
}
|
||||
if night.AlertOverlaps[0].Overlap.Start.Format(time.RFC3339) != "2026-05-30T01:00:00-05:00" || night.AlertOverlaps[0].Overlap.End.Format(time.RFC3339) != "2026-05-30T05:00:00-05:00" {
|
||||
t.Fatalf("night alert overlap = %#v, want source alert clipped to the overnight window", night.AlertOverlaps[0].Overlap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeDaypartNormalizesMetricFallbacks(t *testing.T) {
|
||||
period := timeutil.Period{Start: mustParse("2026-05-29T06:00:00-05:00"), End: mustParse("2026-05-29T07:00:00-05:00")}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
temperature float64
|
||||
windSpeed float64
|
||||
windGust float64
|
||||
want Indicators
|
||||
}{
|
||||
{name: "below thresholds", temperature: 94, windSpeed: 24, windGust: 34},
|
||||
{name: "at thresholds", temperature: 95, windSpeed: 25, windGust: 35, want: Indicators{Heat: true, Wind: true}},
|
||||
{name: "above thresholds", temperature: 96, windSpeed: 26, windGust: 36, want: Indicators{Heat: true, Wind: true}},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
us := weatherdata.ForecastPeriod{TemperatureF: ptr(tt.temperature), WindSpeedMph: ptr(tt.windSpeed), WindGustMph: ptr(tt.windGust)}
|
||||
metric := weatherdata.ForecastPeriod{TemperatureC: ptr((tt.temperature - 32) * 5 / 9), WindSpeedKmh: ptr(tt.windSpeed * 1.609344), WindGustKmh: ptr(tt.windGust * 1.609344)}
|
||||
both := metric
|
||||
both.TemperatureF = ptr(tt.temperature)
|
||||
both.WindSpeedMph = ptr(tt.windSpeed)
|
||||
both.WindGustMph = ptr(tt.windGust)
|
||||
both.TemperatureC = ptr(-40)
|
||||
both.WindSpeedKmh = ptr(0)
|
||||
both.WindGustKmh = ptr(0)
|
||||
|
||||
for _, source := range []struct {
|
||||
name string
|
||||
period weatherdata.ForecastPeriod
|
||||
}{
|
||||
{name: "us customary", period: us},
|
||||
{name: "metric", period: metric},
|
||||
{name: "both", period: both},
|
||||
} {
|
||||
t.Run(source.name, func(t *testing.T) {
|
||||
if got := numericIndicators(source.period); got != tt.want {
|
||||
t.Fatalf("numericIndicators() = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
temperature float64
|
||||
want Indicators
|
||||
}{
|
||||
{name: "cold above threshold", temperature: 33},
|
||||
{name: "cold at threshold", temperature: 32, want: Indicators{Cold: true}},
|
||||
{name: "cold below threshold", temperature: 31, want: Indicators{Cold: true}},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
us := weatherdata.ForecastPeriod{TemperatureF: ptr(tt.temperature)}
|
||||
metric := weatherdata.ForecastPeriod{TemperatureC: ptr((tt.temperature - 32) * 5 / 9)}
|
||||
both := metric
|
||||
both.TemperatureF = ptr(tt.temperature)
|
||||
both.TemperatureC = ptr(40)
|
||||
|
||||
for _, source := range []struct {
|
||||
name string
|
||||
period weatherdata.ForecastPeriod
|
||||
}{
|
||||
{name: "us customary", period: us},
|
||||
{name: "metric", period: metric},
|
||||
{name: "both", period: both},
|
||||
} {
|
||||
t.Run(source.name, func(t *testing.T) {
|
||||
if got := numericIndicators(source.period); got != tt.want {
|
||||
t.Fatalf("numericIndicators() = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
us := weatherdata.ForecastPeriod{
|
||||
StartTime: period.Start,
|
||||
EndTime: period.End,
|
||||
TemperatureF: ptr(68),
|
||||
TemperatureFMin: ptr(50),
|
||||
TemperatureFMax: ptr(86),
|
||||
ApparentTemperatureF: ptr(69.8),
|
||||
WindSpeedMph: ptr(31.068559),
|
||||
WindGustMph: ptr(37.282271),
|
||||
}
|
||||
metric := weatherdata.ForecastPeriod{
|
||||
StartTime: period.Start,
|
||||
EndTime: period.End,
|
||||
TemperatureC: ptr(20),
|
||||
TemperatureCMin: ptr(10),
|
||||
TemperatureCMax: ptr(30),
|
||||
ApparentTemperatureC: ptr(21),
|
||||
WindSpeedKmh: ptr(50),
|
||||
WindGustKmh: ptr(60),
|
||||
}
|
||||
both := metric
|
||||
both.TemperatureF = us.TemperatureF
|
||||
both.TemperatureFMin = us.TemperatureFMin
|
||||
both.TemperatureFMax = us.TemperatureFMax
|
||||
both.ApparentTemperatureF = us.ApparentTemperatureF
|
||||
both.WindSpeedMph = us.WindSpeedMph
|
||||
both.WindGustMph = us.WindGustMph
|
||||
both.TemperatureC = ptr(-40)
|
||||
both.TemperatureCMin = ptr(-40)
|
||||
both.TemperatureCMax = ptr(-40)
|
||||
both.ApparentTemperatureC = ptr(-40)
|
||||
both.WindSpeedKmh = ptr(0)
|
||||
both.WindGustKmh = ptr(0)
|
||||
|
||||
for _, source := range []struct {
|
||||
name string
|
||||
period weatherdata.ForecastPeriod
|
||||
}{
|
||||
{name: "us customary", period: us},
|
||||
{name: "metric", period: metric},
|
||||
{name: "both", period: both},
|
||||
} {
|
||||
t.Run(source.name, func(t *testing.T) {
|
||||
summary := SummarizeDaypart("morning", period, []weatherdata.ForecastPeriod{source.period})
|
||||
assertRangeClose(t, "temperature", summary.Temperature, 50, 86)
|
||||
assertRangeClose(t, "apparent temperature", summary.ApparentTemperature, 69.8, 69.8)
|
||||
assertTimedValueClose(t, "peak wind speed", summary.PeakWindSpeed, 31.068559)
|
||||
assertTimedValueClose(t, "peak wind gust", summary.PeakWindGust, 37.282271)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) {
|
||||
@@ -466,3 +609,17 @@ func assertRange(t *testing.T, name string, got Range, wantMin float64, wantMax
|
||||
t.Fatalf("%s = [%v,%v], want [%v,%v]", name, *got.Min, *got.Max, wantMin, wantMax)
|
||||
}
|
||||
}
|
||||
|
||||
func assertRangeClose(t *testing.T, name string, got Range, wantMin float64, wantMax float64) {
|
||||
t.Helper()
|
||||
if got.Min == nil || got.Max == nil || math.Abs(*got.Min-wantMin) > 0.000001 || math.Abs(*got.Max-wantMax) > 0.000001 {
|
||||
t.Fatalf("%s = %#v, want range [%v, %v]", name, got, wantMin, wantMax)
|
||||
}
|
||||
}
|
||||
|
||||
func assertTimedValueClose(t *testing.T, name string, got *TimedValue, want float64) {
|
||||
t.Helper()
|
||||
if got == nil || math.Abs(got.Value-want) > 0.000001 {
|
||||
t.Fatalf("%s = %#v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user