2 Commits

14 changed files with 475 additions and 97 deletions

View File

@@ -1356,10 +1356,10 @@ func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Sna
precip := 10
snapshot, err := module.NewSnapshot([]module.Output{
{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]any{
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
"low_temp_f": low,
"high_temp_f": high,
"max_pop_percent": precip,
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
"low_temp_f": low,
"high_temp_f": high,
"daily_precipitation_probability": precip,
}},
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
"morning": map[string]any{

View File

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

View File

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

View File

@@ -5,22 +5,20 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type DerivedDailySummaryModule struct {
Date string `json:"date,omitempty"`
HighTempF *int `json:"high_temp_f,omitempty"`
LowTempF *int `json:"low_temp_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopWindow string `json:"max_pop_window,omitempty"`
FirstPrecipHour string `json:"first_precip_hour,omitempty"`
LastPrecipHour string `json:"last_precip_hour,omitempty"`
ThunderMentioned bool `json:"thunder_mentioned"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"`
DominantConditions []string `json:"dominant_conditions,omitempty"`
Hazards []string `json:"hazards,omitempty"`
Date string `json:"date,omitempty"`
HighTempF *int `json:"high_temp_f,omitempty"`
LowTempF *int `json:"low_temp_f,omitempty"`
DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"`
MostLikelyPrecipitationHour string `json:"most_likely_precipitation_hour,omitempty"`
ThunderMentioned bool `json:"thunder_mentioned"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"`
DominantConditions []string `json:"dominant_conditions,omitempty"`
Hazards []string `json:"hazards,omitempty"`
}
func buildDerivedDailySummaryModule(ctx ModuleContext, _ any) (*module.Output, error) {
@@ -46,17 +44,10 @@ func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.Pre
var apparent forecast.Range
var maxPop *forecast.TimedValue
var maxGust *forecast.TimedValue
var maxPopWindow timeutil.Period
for _, daypart := range summary.Dayparts {
addRange(&temperature, daypart.Temperature)
addRange(&apparent, daypart.ApparentTemperature)
if daypart.MaxPrecipitationProbability != nil {
if maxPop == nil || daypart.MaxPrecipitationProbability.Value > maxPop.Value {
copied := *daypart.MaxPrecipitationProbability
maxPop = &copied
maxPopWindow = daypart.Period
}
}
maxTimedValue(&maxPop, daypart.MaxPrecipitationProbability)
maxTimedValue(&maxGust, daypart.PeakWindGust)
if daypart.DominantCondition != "" {
conditions[daypart.DominantCondition] = struct{}{}
@@ -70,19 +61,112 @@ func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.Pre
hazards[alert.Event] = struct{}{}
}
}
value.HighTempF = roundedInt(temperature.Max)
value.LowTempF = roundedInt(temperature.Min)
narrativeTemperature := narrativeTemperatureRange(summary.NarrativePeriods)
if narrativeTemperature.Max != nil {
value.HighTempF = roundedInt(narrativeTemperature.Max)
} else {
value.HighTempF = roundedInt(temperature.Max)
}
if narrativeTemperature.Min != nil {
value.LowTempF = roundedInt(narrativeTemperature.Min)
} else {
value.LowTempF = roundedInt(temperature.Min)
}
value.HeatIndexMaxF = roundedInt(apparent.Max)
narrativePrecipitation := narrativeMaxPrecipitation(summary.NarrativePeriods)
if narrativePrecipitation != nil {
value.DailyPrecipitationProbability = roundedInt(&narrativePrecipitation.Value)
} else if maxPop != nil {
value.DailyPrecipitationProbability = roundedInt(&maxPop.Value)
}
if maxPop != nil {
value.MaxPopPercent = roundedInt(&maxPop.Value)
value.MaxPopWindow = periodClockLabel(maxPopWindow, timezone)
value.MostLikelyPrecipitationHour = mostLikelyPrecipitationHour(maxPop, timezone)
}
if maxGust != nil {
value.MaxWindGustMph = roundedInt(&maxGust.Value)
}
value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone)
value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone)
value.DominantConditions = sortedSet(conditions)
value.DominantConditions = narrativeConditions(summary.NarrativePeriods)
if len(value.DominantConditions) == 0 {
value.DominantConditions = sortedSet(conditions)
}
value.Hazards = sortedSet(hazards)
return value, nil
}
func narrativeTemperatureRange(periods []weatherdata.ForecastPeriod) forecast.Range {
var out forecast.Range
for _, period := range periods {
addNarrativeHigh(&out, period.TemperatureFMax)
addNarrativeLow(&out, period.TemperatureFMin)
if period.TemperatureF != nil && period.IsDay != nil {
if *period.IsDay {
addNarrativeHigh(&out, period.TemperatureF)
} else {
addNarrativeLow(&out, period.TemperatureF)
}
}
}
return out
}
func addNarrativeHigh(target *forecast.Range, value *float64) {
if value == nil {
return
}
if target.Max == nil || *value > *target.Max {
copied := *value
target.Max = &copied
}
}
func addNarrativeLow(target *forecast.Range, value *float64) {
if value == nil {
return
}
if target.Min == nil || *value < *target.Min {
copied := *value
target.Min = &copied
}
}
func narrativeMaxPrecipitation(periods []weatherdata.ForecastPeriod) *forecast.TimedValue {
var maxPop *forecast.TimedValue
for _, period := range periods {
if period.ProbabilityOfPrecipitationPercent == nil {
continue
}
value := forecast.TimedValue{
Value: *period.ProbabilityOfPrecipitationPercent,
Time: period.StartTime,
}
maxTimedValue(&maxPop, &value)
}
return maxPop
}
func narrativeConditions(periods []weatherdata.ForecastPeriod) []string {
seen := map[string]struct{}{}
var out []string
for _, period := range periods {
if period.TextDescription == "" {
continue
}
if _, ok := seen[period.TextDescription]; ok {
continue
}
seen[period.TextDescription] = struct{}{}
out = append(out, period.TextDescription)
}
return out
}
func mostLikelyPrecipitationHour(maxPop *forecast.TimedValue, timezone string) string {
if maxPop == nil || maxPop.Value <= 0 {
return ""
}
percent := roundedInt(&maxPop.Value)
if percent == nil {
return ""
}
return fmt.Sprintf("%d%% at %s", *percent, clockLabel(maxPop.Time, timezone))
}

View File

@@ -24,14 +24,17 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
}
value := moduleValue[DerivedDailySummaryModule](t, output)
if value.HighTempF == nil || *value.HighTempF != 96 || value.LowTempF == nil || *value.LowTempF != 31 {
t.Fatalf("daily temperatures = %#v/%#v, want 96/31", value.HighTempF, value.LowTempF)
if value.HighTempF == nil || *value.HighTempF != 88 || value.LowTempF == nil || *value.LowTempF != 64 {
t.Fatalf("daily temperatures = %#v/%#v, want narrative 88/64", value.HighTempF, value.LowTempF)
}
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.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 55 {
t.Fatalf("DailyPrecipitationProbability = %#v, want narrative 55", value.DailyPrecipitationProbability)
}
if value.FirstPrecipHour != "8 AM" || value.LastPrecipHour != "1 PM" || !value.ThunderMentioned {
t.Fatalf("precip timing = %#v, want morning through afternoon thunder", value)
if value.MostLikelyPrecipitationHour != "80% at 12 PM" || !value.ThunderMentioned {
t.Fatalf("precip timing = %#v, want most likely hour and thunder", value)
}
if strings.Join(value.DominantConditions, "|") != "Morning storms, then partly sunny.|Clouds linger tonight." {
t.Fatalf("DominantConditions = %#v, want ordered narrative conditions", value.DominantConditions)
}
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
@@ -44,16 +47,43 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
t.Fatalf("marshal daily summary: %v", err)
}
jsonText := string(data)
for _, field := range []string{"high_temp_f", "low_temp_f", "max_pop_percent", "first_precip_hour", "heat_index_max_f"} {
for _, field := range []string{"high_temp_f", "low_temp_f", "daily_precipitation_probability", "most_likely_precipitation_hour", "heat_index_max_f"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("daily json = %s, want field %s", jsonText, field)
}
}
for _, removed := range []string{"max_pop_percent", "max_pop_window", "first_precip_hour", "last_precip_hour"} {
if strings.Contains(jsonText, removed) {
t.Fatalf("daily json = %s, want removed field %s omitted", jsonText, removed)
}
}
if strings.Contains(jsonText, "qpf") {
t.Fatalf("daily json = %s, want no QPF fields without upstream QPF facts", jsonText)
}
}
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday)
ctx.Derived.DailySummaries[0].NarrativePeriods = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[DerivedDailySummaryModule](t, output)
if value.HighTempF == nil || *value.HighTempF != 96 || value.LowTempF == nil || *value.LowTempF != 31 {
t.Fatalf("daily temperatures = %#v/%#v, want fallback 96/31", value.HighTempF, value.LowTempF)
}
if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 80 {
t.Fatalf("DailyPrecipitationProbability = %#v, want hourly fallback 80", value.DailyPrecipitationProbability)
}
if len(value.DominantConditions) == 0 || !containsString(value.DominantConditions, "Thunderstorms with gusty wind") {
t.Fatalf("DominantConditions = %#v, want fallback daypart conditions", value.DominantConditions)
}
}
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday)
@@ -63,8 +93,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 +122,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,19 +240,33 @@ 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{
{
Name: "Today",
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
TextDescription: "Morning storms, then partly sunny.",
TemperatureF: floatPtr(81),
Name: "Today",
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
IsDay: boolPtr(true),
TextDescription: "Morning storms, then partly sunny.",
TemperatureFMax: floatPtr(88),
ProbabilityOfPrecipitationPercent: floatPtr(55),
},
{
Name: "Tonight",
StartTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
IsDay: boolPtr(false),
TextDescription: "Clouds linger tonight.",
TemperatureFMin: floatPtr(64),
ProbabilityOfPrecipitationPercent: floatPtr(30),
},
}
summary.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}}
summary.NarrativePeriods = append([]weatherdata.ForecastPeriod(nil), narrative...)
return ModuleContext{
Resolved: report.Resolved{
Definition: definition,
@@ -255,3 +318,16 @@ func derivedHour(start string, text string, precip float64, temperature float64,
func floatPtr(value float64) *float64 {
return &value
}
func boolPtr(value bool) *bool {
return &value
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -64,7 +64,7 @@ func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds
var changes []Change
changes = append(changes, compareTemperatureValues("Low", previousSummary.LowTempF, currentSummary.LowTempF, thresholds.TemperatureDegrees)...)
changes = append(changes, compareTemperatureValues("High", previousSummary.HighTempF, currentSummary.HighTempF, thresholds.TemperatureDegrees)...)
changes = append(changes, comparePrecipitationValues(previousSummary.MaxPopPercent, currentSummary.MaxPopPercent, thresholds.PrecipProbabilityPoints, "")...)
changes = append(changes, comparePrecipitationValues(previousSummary.DailyPrecipitationProbability, currentSummary.DailyPrecipitationProbability, thresholds.PrecipProbabilityPoints, "")...)
if previousHasTiming && currentHasTiming {
changes = append(changes, comparePrecipTiming(previousTiming.MaxPopTime, currentTiming.MaxPopTime, thresholds.PrecipTimingShiftMinutes, "")...)
}
@@ -76,11 +76,11 @@ func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds
}
type dailySummaryStanza struct {
Date string `json:"date,omitempty"`
HighTempF *int `json:"high_temp_f,omitempty"`
LowTempF *int `json:"low_temp_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
Date string `json:"date,omitempty"`
HighTempF *int `json:"high_temp_f,omitempty"`
LowTempF *int `json:"low_temp_f,omitempty"`
DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
}
type daypartSummaryStanza struct {

View File

@@ -92,10 +92,10 @@ func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime strin
}
return snapshot(t,
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: dailySummaryStanza{
Date: "2026-05-29",
HighTempF: &high,
LowTempF: &low,
MaxPopPercent: &precip,
Date: "2026-05-29",
HighTempF: &high,
LowTempF: &low,
DailyPrecipitationProbability: &precip,
}},
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
"morning": {Period: period("2026-05-29T06:00:00Z", "2026-05-29T10:00:00Z"), TempRangeF: "60-70", Snow: snow},

View File

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

View File

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

View File

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