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 {
|
||||
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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 == ""
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
||||
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.FirstPrecipHour != "8 AM" || value.LastPrecipHour != "1 PM" || !value.ThunderMentioned {
|
||||
t.Fatalf("precip timing = %#v, want morning through afternoon thunder", value)
|
||||
if value.FirstPrecipHour != "8 AM" || value.LastPrecipHour != "2 PM" || !value.ThunderMentioned {
|
||||
t.Fatalf("precip timing = %#v, want threshold windows from morning through afternoon thunder", value)
|
||||
}
|
||||
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
|
||||
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
||||
@@ -63,8 +63,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 +92,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,8 +210,10 @@ 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{
|
||||
{
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
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"`
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user