Update wind direction and precipitation window presentation
This commit is contained in:
@@ -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 {
|
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)
|
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)
|
data, err := json.Marshal(output.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Marshal narrative forecast: %v", err)
|
t.Fatalf("Marshal narrative forecast: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
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) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("narrative json = %s, want field %s", 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") {
|
if strings.Contains(jsonText, "Tomorrow night") {
|
||||||
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
|
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 {
|
if value.ConditionText != "Partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
|
||||||
t.Fatalf("CurrentConditions = %#v, want current condition facts", value)
|
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)
|
data, err := json.Marshal(output.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Marshal current conditions: %v", err)
|
t.Fatalf("Marshal current conditions: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
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) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("current json = %s, want field %s", 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) {
|
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
|
||||||
@@ -263,6 +275,7 @@ func testModuleContext() ModuleContext {
|
|||||||
narrativeTempF := 81.0
|
narrativeTempF := 81.0
|
||||||
narrativePop := 60.0
|
narrativePop := 60.0
|
||||||
narrativeWind := 12.0
|
narrativeWind := 12.0
|
||||||
|
narrativeWindDirection := 45.0
|
||||||
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
|
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
|
||||||
return ModuleContext{
|
return ModuleContext{
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
@@ -291,6 +304,7 @@ func testModuleContext() ModuleContext {
|
|||||||
TextDescription: "Morning storms, then partly sunny.",
|
TextDescription: "Morning storms, then partly sunny.",
|
||||||
TemperatureF: floatPtr(narrativeTempF),
|
TemperatureF: floatPtr(narrativeTempF),
|
||||||
WindSpeedMph: &narrativeWind,
|
WindSpeedMph: &narrativeWind,
|
||||||
|
WindDirectionDegrees: &narrativeWindDirection,
|
||||||
ProbabilityOfPrecipitationPercent: &narrativePop,
|
ProbabilityOfPrecipitationPercent: &narrativePop,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -336,6 +350,7 @@ func testModuleContext() ModuleContext {
|
|||||||
TextDescription: "Morning storms, then partly sunny.",
|
TextDescription: "Morning storms, then partly sunny.",
|
||||||
TemperatureF: floatPtr(narrativeTempF),
|
TemperatureF: floatPtr(narrativeTempF),
|
||||||
WindSpeedMph: &narrativeWind,
|
WindSpeedMph: &narrativeWind,
|
||||||
|
WindDirectionDegrees: &narrativeWindDirection,
|
||||||
ProbabilityOfPrecipitationPercent: &narrativePop,
|
ProbabilityOfPrecipitationPercent: &narrativePop,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type CurrentConditionsModule struct {
|
|||||||
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||||
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||||
WindSpeedMph *float64 `json:"wind_speed_mph,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) {
|
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
@@ -34,7 +34,7 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
|
|||||||
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
|
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
|
||||||
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
|
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
|
||||||
WindSpeedMph: copyFloat(current.WindSpeedMph),
|
WindSpeedMph: copyFloat(current.WindSpeedMph),
|
||||||
WindDirectionDegrees: copyFloat(current.WindDirectionDegrees),
|
WindDirection: windDirectionLabel(current.WindDirectionDegrees),
|
||||||
}
|
}
|
||||||
if value.isEmpty() {
|
if value.isEmpty() {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -54,5 +54,5 @@ func (v CurrentConditionsModule) isEmpty() bool {
|
|||||||
v.RelativeHumidityPercent == nil &&
|
v.RelativeHumidityPercent == nil &&
|
||||||
v.WindSpeedKmh == nil &&
|
v.WindSpeedKmh == nil &&
|
||||||
v.WindSpeedMph == nil &&
|
v.WindSpeedMph == nil &&
|
||||||
v.WindDirectionDegrees == nil
|
v.WindDirection == ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
|||||||
if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || value.MaxPopWindow != "12 PM-6 PM" {
|
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)
|
t.Fatalf("max precip = %#v %q, want 80 and afternoon window", value.MaxPopPercent, value.MaxPopWindow)
|
||||||
}
|
}
|
||||||
if value.FirstPrecipHour != "8 AM" || value.LastPrecipHour != "1 PM" || !value.ThunderMentioned {
|
if value.FirstPrecipHour != "8 AM" || value.LastPrecipHour != "2 PM" || !value.ThunderMentioned {
|
||||||
t.Fatalf("precip timing = %#v, want morning through afternoon thunder", value)
|
t.Fatalf("precip timing = %#v, want threshold windows from morning through afternoon thunder", value)
|
||||||
}
|
}
|
||||||
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
|
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
|
||||||
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
||||||
@@ -63,8 +63,27 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
|||||||
t.Fatalf("BuildModule(rainy) error = %v", err)
|
t.Fatalf("BuildModule(rainy) error = %v", err)
|
||||||
}
|
}
|
||||||
rainy := moduleValue[PrecipTimingModule](t, output)
|
rainy := moduleValue[PrecipTimingModule](t, output)
|
||||||
if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.FirstPrecipHour != "8 AM" || rainy.LastPrecipHour != "1 PM" || !rainy.ThunderMentioned {
|
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, first/last, thunder", rainy)
|
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)})
|
ctx.Derived.PrecipTiming = forecast.BuildPrecipTiming([]weatherdata.ForecastPeriod{derivedHour("2026-05-29T10:00:00-05:00", "Sunny", 0, 70, nil, 5)})
|
||||||
@@ -73,8 +92,8 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
|||||||
t.Fatalf("BuildModule(dry) error = %v", err)
|
t.Fatalf("BuildModule(dry) error = %v", err)
|
||||||
}
|
}
|
||||||
dry := moduleValue[PrecipTimingModule](t, output)
|
dry := moduleValue[PrecipTimingModule](t, output)
|
||||||
if dry.FirstPrecipHour != "" || dry.LastPrecipHour != "" || dry.ThunderMentioned {
|
if len(dry.PrecipitationWindows) != 0 || dry.ThunderMentioned {
|
||||||
t.Fatalf("dry precip timing = %#v, want no precip hours and no thunder", dry)
|
t.Fatalf("dry precip timing = %#v, want no precip windows and no thunder", dry)
|
||||||
}
|
}
|
||||||
if dry.MaxPopPercent == nil || *dry.MaxPopPercent != 0 {
|
if dry.MaxPopPercent == nil || *dry.MaxPopPercent != 0 {
|
||||||
t.Fatalf("dry MaxPopPercent = %#v, want checked zero", dry.MaxPopPercent)
|
t.Fatalf("dry MaxPopPercent = %#v, want checked zero", dry.MaxPopPercent)
|
||||||
@@ -191,8 +210,10 @@ func derivedModuleContext(id report.ID) ModuleContext {
|
|||||||
hours := []weatherdata.ForecastPeriod{
|
hours := []weatherdata.ForecastPeriod{
|
||||||
derivedHour("2026-05-29T00:00:00-05:00", "Clear and cold", 0, 31, nil, 5),
|
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-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-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-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{
|
narrative := []weatherdata.ForecastPeriod{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package briefing
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
@@ -46,6 +47,19 @@ func roundedInt(value *float64) *int {
|
|||||||
return &rounded
|
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 {
|
func timedClockLabel(value *forecast.TimedValue, timezone string) string {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
29
internal/briefing/module_format_helpers_test.go
Normal file
29
internal/briefing/module_format_helpers_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ type NarrativeForecastPeriod struct {
|
|||||||
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||||
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
|
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
|
||||||
WindGustMph *float64 `json:"wind_gust_mph,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"`
|
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod) []NarrativeF
|
|||||||
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||||
WindGustKmh: copyFloat(period.WindGustKmh),
|
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||||
WindGustMph: copyFloat(period.WindGustMph),
|
WindGustMph: copyFloat(period.WindGustMph),
|
||||||
WindDirectionDegrees: copyFloat(period.WindDirectionDegrees),
|
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
|
||||||
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,23 +8,42 @@ import (
|
|||||||
type PrecipTimingModule struct {
|
type PrecipTimingModule struct {
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
FirstPrecipHour string `json:"first_precip_hour,omitempty"`
|
ProbabilityThreshold float64 `json:"probability_threshold"`
|
||||||
LastPrecipHour string `json:"last_precip_hour,omitempty"`
|
PrecipitationWindows []PrecipitationWindowModule `json:"precipitation_windows,omitempty"`
|
||||||
ThunderMentioned bool `json:"thunder_mentioned"`
|
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) {
|
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
value := precipTimingValue(ctx.Derived.PrecipTiming, ctx.Timezone)
|
value := precipTimingValue(ctx.Derived.PrecipTiming, ctx.Timezone)
|
||||||
return &module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: value}, nil
|
return &module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimingModule {
|
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 {
|
if timing.MaxPrecipitationProbability != nil {
|
||||||
value.MaxPopPercent = roundedInt(&timing.MaxPrecipitationProbability.Value)
|
value.MaxPopPercent = roundedInt(&timing.MaxPrecipitationProbability.Value)
|
||||||
value.MaxPopTime = clockLabel(timing.MaxPrecipitationProbability.Time, timezone)
|
value.MaxPopTime = clockLabel(timing.MaxPrecipitationProbability.Time, timezone)
|
||||||
}
|
}
|
||||||
value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone)
|
for _, window := range timing.PrecipitationWindows {
|
||||||
value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone)
|
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
|
return value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" {
|
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)
|
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 {
|
if !derived.PrecipTiming.ThunderMentioned {
|
||||||
t.Fatal("PrecipTiming.ThunderMentioned = false, want true")
|
t.Fatal("PrecipTiming.ThunderMentioned = false, want true")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ type TimedValue struct {
|
|||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DefaultPrecipWindowProbabilityThreshold = 40
|
||||||
|
|
||||||
type Indicators struct {
|
type Indicators struct {
|
||||||
Snow bool `json:"snow,omitempty"`
|
Snow bool `json:"snow,omitempty"`
|
||||||
Ice bool `json:"ice,omitempty"`
|
Ice bool `json:"ice,omitempty"`
|
||||||
@@ -69,31 +71,98 @@ type PrecipTiming struct {
|
|||||||
MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
||||||
FirstPrecipitation *TimedValue `json:"firstPrecipitation,omitempty"`
|
FirstPrecipitation *TimedValue `json:"firstPrecipitation,omitempty"`
|
||||||
LastPrecipitation *TimedValue `json:"lastPrecipitation,omitempty"`
|
LastPrecipitation *TimedValue `json:"lastPrecipitation,omitempty"`
|
||||||
|
ProbabilityThreshold float64 `json:"probabilityThreshold"`
|
||||||
|
PrecipitationWindows []PrecipitationWindow `json:"precipitationWindows,omitempty"`
|
||||||
ThunderMentioned bool `json:"thunderMentioned,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 {
|
func BuildPrecipTiming(periods []weatherdata.ForecastPeriod) PrecipTiming {
|
||||||
var timing PrecipTiming
|
return buildPrecipTimingWithThreshold(periods, DefaultPrecipWindowProbabilityThreshold)
|
||||||
for _, forecastPeriod := range periods {
|
}
|
||||||
setMaxTimedValue(&timing.MaxPrecipitationProbability, forecastPeriod.ProbabilityOfPrecipitationPercent, forecastPeriod.StartTime)
|
|
||||||
if forecastPeriod.ProbabilityOfPrecipitationPercent != nil && *forecastPeriod.ProbabilityOfPrecipitationPercent > 0 {
|
func buildPrecipTimingWithThreshold(periods []weatherdata.ForecastPeriod, threshold float64) PrecipTiming {
|
||||||
value := TimedValue{
|
timing := PrecipTiming{ProbabilityThreshold: threshold}
|
||||||
Value: *forecastPeriod.ProbabilityOfPrecipitationPercent,
|
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,
|
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) {
|
if mentionsThunder(forecastPeriod.TextDescription) {
|
||||||
timing.ThunderMentioned = true
|
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
|
return timing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
location := time.FixedZone("Test", -5*60*60)
|
||||||
periods := []weatherdata.ForecastPeriod{
|
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-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-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)
|
timing := BuildPrecipTiming(periods)
|
||||||
|
|
||||||
if timing.FirstPrecipitation == nil || timing.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" {
|
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" {
|
if timing.LastPrecipitation == nil || timing.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T13:00:00-05:00" {
|
||||||
t.Fatalf("LastPrecipitation = %#v, want noon thunderstorm", timing.LastPrecipitation)
|
t.Fatalf("LastPrecipitation = %#v, want final closed threshold window end", timing.LastPrecipitation)
|
||||||
}
|
}
|
||||||
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 80 {
|
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 80 {
|
||||||
t.Fatalf("MaxPrecipitationProbability = %#v, want 80", timing.MaxPrecipitationProbability)
|
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 {
|
if !timing.ThunderMentioned {
|
||||||
t.Fatal("ThunderMentioned = false, want true")
|
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) {
|
func TestBuildPrecipTimingHandlesDryForecast(t *testing.T) {
|
||||||
location := time.FixedZone("Test", -5*60*60)
|
location := time.FixedZone("Test", -5*60*60)
|
||||||
periods := []weatherdata.ForecastPeriod{
|
periods := []weatherdata.ForecastPeriod{
|
||||||
@@ -243,9 +306,12 @@ func TestBuildPrecipTimingHandlesDryForecast(t *testing.T) {
|
|||||||
|
|
||||||
timing := BuildPrecipTiming(periods)
|
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)
|
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 {
|
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 0 {
|
||||||
t.Fatalf("dry max precip = %#v, want checked zero chance", timing.MaxPrecipitationProbability)
|
t.Fatalf("dry max precip = %#v, want checked zero chance", timing.MaxPrecipitationProbability)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user