Compare commits
2 Commits
c3da3af2f4
...
ef044327c6
| Author | SHA1 | Date | |
|---|---|---|---|
| ef044327c6 | |||
| d1d0df11a8 |
@@ -1359,7 +1359,7 @@ func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Sna
|
|||||||
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
|
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
|
||||||
"low_temp_f": low,
|
"low_temp_f": low,
|
||||||
"high_temp_f": high,
|
"high_temp_f": high,
|
||||||
"max_pop_percent": precip,
|
"daily_precipitation_probability": precip,
|
||||||
}},
|
}},
|
||||||
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
|
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
|
||||||
"morning": map[string]any{
|
"morning": map[string]any{
|
||||||
|
|||||||
@@ -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 == ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,15 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DerivedDailySummaryModule struct {
|
type DerivedDailySummaryModule struct {
|
||||||
Date string `json:"date,omitempty"`
|
Date string `json:"date,omitempty"`
|
||||||
HighTempF *int `json:"high_temp_f,omitempty"`
|
HighTempF *int `json:"high_temp_f,omitempty"`
|
||||||
LowTempF *int `json:"low_temp_f,omitempty"`
|
LowTempF *int `json:"low_temp_f,omitempty"`
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"`
|
||||||
MaxPopWindow string `json:"max_pop_window,omitempty"`
|
MostLikelyPrecipitationHour string `json:"most_likely_precipitation_hour,omitempty"`
|
||||||
FirstPrecipHour string `json:"first_precip_hour,omitempty"`
|
|
||||||
LastPrecipHour string `json:"last_precip_hour,omitempty"`
|
|
||||||
ThunderMentioned bool `json:"thunder_mentioned"`
|
ThunderMentioned bool `json:"thunder_mentioned"`
|
||||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||||
HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"`
|
HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"`
|
||||||
@@ -46,17 +44,10 @@ func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.Pre
|
|||||||
var apparent forecast.Range
|
var apparent forecast.Range
|
||||||
var maxPop *forecast.TimedValue
|
var maxPop *forecast.TimedValue
|
||||||
var maxGust *forecast.TimedValue
|
var maxGust *forecast.TimedValue
|
||||||
var maxPopWindow timeutil.Period
|
|
||||||
for _, daypart := range summary.Dayparts {
|
for _, daypart := range summary.Dayparts {
|
||||||
addRange(&temperature, daypart.Temperature)
|
addRange(&temperature, daypart.Temperature)
|
||||||
addRange(&apparent, daypart.ApparentTemperature)
|
addRange(&apparent, daypart.ApparentTemperature)
|
||||||
if daypart.MaxPrecipitationProbability != nil {
|
maxTimedValue(&maxPop, daypart.MaxPrecipitationProbability)
|
||||||
if maxPop == nil || daypart.MaxPrecipitationProbability.Value > maxPop.Value {
|
|
||||||
copied := *daypart.MaxPrecipitationProbability
|
|
||||||
maxPop = &copied
|
|
||||||
maxPopWindow = daypart.Period
|
|
||||||
}
|
|
||||||
}
|
|
||||||
maxTimedValue(&maxGust, daypart.PeakWindGust)
|
maxTimedValue(&maxGust, daypart.PeakWindGust)
|
||||||
if daypart.DominantCondition != "" {
|
if daypart.DominantCondition != "" {
|
||||||
conditions[daypart.DominantCondition] = struct{}{}
|
conditions[daypart.DominantCondition] = struct{}{}
|
||||||
@@ -70,19 +61,112 @@ func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.Pre
|
|||||||
hazards[alert.Event] = struct{}{}
|
hazards[alert.Event] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
narrativeTemperature := narrativeTemperatureRange(summary.NarrativePeriods)
|
||||||
|
if narrativeTemperature.Max != nil {
|
||||||
|
value.HighTempF = roundedInt(narrativeTemperature.Max)
|
||||||
|
} else {
|
||||||
value.HighTempF = roundedInt(temperature.Max)
|
value.HighTempF = roundedInt(temperature.Max)
|
||||||
|
}
|
||||||
|
if narrativeTemperature.Min != nil {
|
||||||
|
value.LowTempF = roundedInt(narrativeTemperature.Min)
|
||||||
|
} else {
|
||||||
value.LowTempF = roundedInt(temperature.Min)
|
value.LowTempF = roundedInt(temperature.Min)
|
||||||
|
}
|
||||||
value.HeatIndexMaxF = roundedInt(apparent.Max)
|
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 {
|
if maxPop != nil {
|
||||||
value.MaxPopPercent = roundedInt(&maxPop.Value)
|
value.MostLikelyPrecipitationHour = mostLikelyPrecipitationHour(maxPop, timezone)
|
||||||
value.MaxPopWindow = periodClockLabel(maxPopWindow, timezone)
|
|
||||||
}
|
}
|
||||||
if maxGust != nil {
|
if maxGust != nil {
|
||||||
value.MaxWindGustMph = roundedInt(&maxGust.Value)
|
value.MaxWindGustMph = roundedInt(&maxGust.Value)
|
||||||
}
|
}
|
||||||
value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone)
|
value.DominantConditions = narrativeConditions(summary.NarrativePeriods)
|
||||||
value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone)
|
if len(value.DominantConditions) == 0 {
|
||||||
value.DominantConditions = sortedSet(conditions)
|
value.DominantConditions = sortedSet(conditions)
|
||||||
|
}
|
||||||
value.Hazards = sortedSet(hazards)
|
value.Hazards = sortedSet(hazards)
|
||||||
return value, nil
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,14 +24,17 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
|||||||
}
|
}
|
||||||
value := moduleValue[DerivedDailySummaryModule](t, output)
|
value := moduleValue[DerivedDailySummaryModule](t, output)
|
||||||
|
|
||||||
if value.HighTempF == nil || *value.HighTempF != 96 || value.LowTempF == nil || *value.LowTempF != 31 {
|
if value.HighTempF == nil || *value.HighTempF != 88 || value.LowTempF == nil || *value.LowTempF != 64 {
|
||||||
t.Fatalf("daily temperatures = %#v/%#v, want 96/31", value.HighTempF, value.LowTempF)
|
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" {
|
if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 55 {
|
||||||
t.Fatalf("max precip = %#v %q, want 80 and afternoon window", value.MaxPopPercent, value.MaxPopWindow)
|
t.Fatalf("DailyPrecipitationProbability = %#v, want narrative 55", value.DailyPrecipitationProbability)
|
||||||
}
|
}
|
||||||
if value.FirstPrecipHour != "8 AM" || value.LastPrecipHour != "1 PM" || !value.ThunderMentioned {
|
if value.MostLikelyPrecipitationHour != "80% at 12 PM" || !value.ThunderMentioned {
|
||||||
t.Fatalf("precip timing = %#v, want morning through afternoon thunder", value)
|
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 {
|
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
|
||||||
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
||||||
@@ -44,16 +47,43 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
|||||||
t.Fatalf("marshal daily summary: %v", err)
|
t.Fatalf("marshal daily summary: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
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) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("daily json = %s, want field %s", 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") {
|
if strings.Contains(jsonText, "qpf") {
|
||||||
t.Fatalf("daily json = %s, want no QPF fields without upstream QPF facts", jsonText)
|
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) {
|
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.DailyToday)
|
||||||
@@ -63,8 +93,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 +122,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,19 +240,33 @@ 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{
|
||||||
{
|
{
|
||||||
Name: "Today",
|
Name: "Today",
|
||||||
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||||
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||||
|
IsDay: boolPtr(true),
|
||||||
TextDescription: "Morning storms, then partly sunny.",
|
TextDescription: "Morning storms, then partly sunny.",
|
||||||
TemperatureF: floatPtr(81),
|
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.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}}
|
||||||
|
summary.NarrativePeriods = append([]weatherdata.ForecastPeriod(nil), narrative...)
|
||||||
return ModuleContext{
|
return ModuleContext{
|
||||||
Resolved: report.Resolved{
|
Resolved: report.Resolved{
|
||||||
Definition: definition,
|
Definition: definition,
|
||||||
@@ -255,3 +318,16 @@ func derivedHour(start string, text string, precip float64, temperature float64,
|
|||||||
func floatPtr(value float64) *float64 {
|
func floatPtr(value float64) *float64 {
|
||||||
return &value
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds
|
|||||||
var changes []Change
|
var changes []Change
|
||||||
changes = append(changes, compareTemperatureValues("Low", previousSummary.LowTempF, currentSummary.LowTempF, thresholds.TemperatureDegrees)...)
|
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, 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 {
|
if previousHasTiming && currentHasTiming {
|
||||||
changes = append(changes, comparePrecipTiming(previousTiming.MaxPopTime, currentTiming.MaxPopTime, thresholds.PrecipTimingShiftMinutes, "")...)
|
changes = append(changes, comparePrecipTiming(previousTiming.MaxPopTime, currentTiming.MaxPopTime, thresholds.PrecipTimingShiftMinutes, "")...)
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ type dailySummaryStanza struct {
|
|||||||
Date string `json:"date,omitempty"`
|
Date string `json:"date,omitempty"`
|
||||||
HighTempF *int `json:"high_temp_f,omitempty"`
|
HighTempF *int `json:"high_temp_f,omitempty"`
|
||||||
LowTempF *int `json:"low_temp_f,omitempty"`
|
LowTempF *int `json:"low_temp_f,omitempty"`
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"`
|
||||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime strin
|
|||||||
Date: "2026-05-29",
|
Date: "2026-05-29",
|
||||||
HighTempF: &high,
|
HighTempF: &high,
|
||||||
LowTempF: &low,
|
LowTempF: &low,
|
||||||
MaxPopPercent: &precip,
|
DailyPrecipitationProbability: &precip,
|
||||||
}},
|
}},
|
||||||
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
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},
|
"morning": {Period: period("2026-05-29T06:00:00Z", "2026-05-29T10:00:00Z"), TempRangeF: "60-70", Snow: snow},
|
||||||
|
|||||||
@@ -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