From ac8d618111b1331bf84efa358a84eeb85a4ccb2b Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 1 Aug 2026 19:24:34 +0000 Subject: [PATCH] Remove dormant forecast comparison policy --- examples/config.yml | 6 - internal/changes/daily.go | 313 --------------------------------- internal/changes/daily_test.go | 138 --------------- internal/config/config.go | 8 - internal/config/config_test.go | 7 + internal/config/defaults.go | 6 - internal/config/load.go | 5 +- 7 files changed, 11 insertions(+), 472 deletions(-) delete mode 100644 internal/changes/daily.go delete mode 100644 internal/changes/daily_test.go diff --git a/examples/config.yml b/examples/config.yml index 8466cd0..8a4a281 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -65,12 +65,6 @@ dayparts: start: "17:00" end: "24:00" -recent_change: - temperature_degrees: 5 - precip_probability_points: 20 - wind_gust_miles_per_hour: 10 - precip_timing_shift_minutes: 120 - reports: daily: distributor: diff --git a/internal/changes/daily.go b/internal/changes/daily.go deleted file mode 100644 index 43735b4..0000000 --- a/internal/changes/daily.go +++ /dev/null @@ -1,313 +0,0 @@ -// Package changes compares structured module snapshots. -package changes - -import ( - "fmt" - "math" - "sort" - "strconv" - "strings" - "time" - - "gitea.maximumdirect.net/eric/weatherreporter/internal/module" -) - -type Thresholds struct { - TemperatureDegrees float64 - PrecipProbabilityPoints int - WindGustMilesPerHour int - PrecipTimingShiftMinutes int -} - -type Change struct { - Type string `json:"type"` - Message string `json:"message"` - Previous string `json:"previous,omitempty"` - Current string `json:"current,omitempty"` -} - -func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) { - previousSummary, err := requiredStanza[dailySummaryStanza](previous, "derived_daily_summary") - if err != nil { - return nil, fmt.Errorf("previous daily summary: %w", err) - } - currentSummary, err := requiredStanza[dailySummaryStanza](current, "derived_daily_summary") - if err != nil { - return nil, fmt.Errorf("current daily summary: %w", err) - } - previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries") - if err != nil { - return nil, fmt.Errorf("previous daypart summaries: %w", err) - } - currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries") - if err != nil { - return nil, fmt.Errorf("current daypart summaries: %w", err) - } - previousAlerts, _, err := module.StanzaValue[alertDigestStanza](previous, "alert_digest") - if err != nil { - return nil, err - } - currentAlerts, _, err := module.StanzaValue[alertDigestStanza](current, "alert_digest") - if err != nil { - return nil, err - } - previousTiming, previousHasTiming, err := module.StanzaValue[precipTimingStanza](previous, "precip_timing") - if err != nil { - return nil, err - } - currentTiming, currentHasTiming, err := module.StanzaValue[precipTimingStanza](current, "precip_timing") - if err != nil { - return nil, err - } - - var changes []Change - changes = append(changes, compareTemperatureValues("Low", previousSummary.LowTempF, currentSummary.LowTempF, thresholds.TemperatureDegrees)...) - changes = append(changes, compareTemperatureValues("High", previousSummary.HighTempF, currentSummary.HighTempF, thresholds.TemperatureDegrees)...) - changes = append(changes, comparePrecipitationValues(previousSummary.DailyPrecipitationProbability, currentSummary.DailyPrecipitationProbability, thresholds.PrecipProbabilityPoints, "")...) - if previousHasTiming && currentHasTiming { - changes = append(changes, comparePrecipTiming(previousTiming.MaxPopTime, currentTiming.MaxPopTime, thresholds.PrecipTimingShiftMinutes, "")...) - } - changes = append(changes, compareWindValues(previousSummary.MaxWindGustMph, currentSummary.MaxWindGustMph, thresholds.WindGustMilesPerHour, "")...) - changes = append(changes, compareAlerts(previousAlerts.Relevant, currentAlerts.Relevant)...) - changes = append(changes, compareIndicators(aggregateIndicators(previousDayparts), aggregateIndicators(currentDayparts), "")...) - sortChanges(changes) - return changes, nil -} - -type dailySummaryStanza struct { - Date string `json:"date,omitempty"` - HighTempF *int `json:"high_temp_f,omitempty"` - LowTempF *int `json:"low_temp_f,omitempty"` - DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"` - MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` -} - -type daypartSummaryStanza struct { - Date string `json:"date,omitempty"` - PeriodBegins string `json:"period_begins,omitempty"` - PeriodEnds string `json:"period_ends,omitempty"` - TempRangeF string `json:"temp_range_f,omitempty"` - MaxPopPercent *int `json:"max_pop_percent,omitempty"` - MaxPopTime string `json:"max_pop_time,omitempty"` - MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` - Snow bool `json:"snow,omitempty"` - Ice bool `json:"ice,omitempty"` -} - -type precipTimingStanza struct { - MaxPopPercent *int `json:"max_pop_percent,omitempty"` - MaxPopTime string `json:"max_pop_time,omitempty"` -} - -type alertDigestStanza struct { - Relevant []alertSummaryStanza `json:"relevant,omitempty"` -} - -type alertSummaryStanza struct { - Event string `json:"event,omitempty"` - Headline string `json:"headline,omitempty"` -} - -type indicators struct { - Snow bool - Ice bool -} - -func requiredStanza[T any](snapshot module.Snapshot, name string) (T, error) { - value, ok, err := module.StanzaValue[T](snapshot, name) - if err != nil { - return value, err - } - if !ok { - return value, fmt.Errorf("stanza %q is required", name) - } - return value, nil -} - -func compareTemperatureValues(label string, previous *int, current *int, threshold float64) []Change { - if previous == nil || current == nil { - return nil - } - if !differenceAtLeast(float64(*previous), float64(*current), threshold) { - return nil - } - return []Change{{ - Type: "temperature_shift", - Message: fmt.Sprintf("%s temperature changed from %d to %d.", label, *previous, *current), - Previous: fmt.Sprintf("%d", *previous), - Current: fmt.Sprintf("%d", *current), - }} -} - -func comparePrecipitationValues(previous *int, current *int, threshold int, prefix string) []Change { - if previous == nil || current == nil { - return nil - } - previousCategory := precipitationCategory(float64(*previous)) - currentCategory := precipitationCategory(float64(*current)) - if previousCategory == currentCategory && !differenceAtLeast(float64(*previous), float64(*current), float64(threshold)) { - return nil - } - changeType := prefix + "precip_probability_change" - return []Change{{ - Type: changeType, - Message: fmt.Sprintf("Peak precipitation chance changed from %d%% (%s) to %d%% (%s).", *previous, previousCategory, *current, currentCategory), - Previous: fmt.Sprintf("%d%% %s", *previous, previousCategory), - Current: fmt.Sprintf("%d%% %s", *current, currentCategory), - }} -} - -func comparePrecipTiming(previous string, current string, thresholdMinutes int, prefix string) []Change { - if thresholdMinutes <= 0 || previous == "" || current == "" || previous == current { - return nil - } - previousTime, previousOK := parseClock(previous) - currentTime, currentOK := parseClock(current) - if !previousOK || !currentOK { - return nil - } - if int(math.Abs(currentTime.Sub(previousTime).Minutes())) < thresholdMinutes { - return nil - } - return []Change{{ - Type: prefix + "precip_timing_shift", - Message: fmt.Sprintf("Peak precipitation timing shifted from %s to %s.", previous, current), - Previous: previous, - Current: current, - }} -} - -func compareWindValues(previous *int, current *int, threshold int, prefix string) []Change { - if previous == nil || current == nil || !differenceAtLeast(float64(*previous), float64(*current), float64(threshold)) { - return nil - } - return []Change{{ - Type: prefix + "wind_gust_change", - Message: fmt.Sprintf("Peak wind gust changed from %d mph to %d mph.", *previous, *current), - Previous: fmt.Sprintf("%d mph", *previous), - Current: fmt.Sprintf("%d mph", *current), - }} -} - -func compareAlerts(previous []alertSummaryStanza, current []alertSummaryStanza) []Change { - previousSet := alertSet(previous) - currentSet := alertSet(current) - var changes []Change - for event := range currentSet { - if _, ok := previousSet[event]; !ok { - changes = append(changes, Change{Type: "alert_added", Message: fmt.Sprintf("Alert added: %s.", event), Current: event}) - } - } - for event := range previousSet { - if _, ok := currentSet[event]; !ok { - changes = append(changes, Change{Type: "alert_removed", Message: fmt.Sprintf("Alert removed: %s.", event), Previous: event}) - } - } - sortChanges(changes) - return changes -} - -func compareIndicators(previous indicators, current indicators, prefix string) []Change { - var changes []Change - for _, item := range []struct { - name string - previous bool - current bool - }{ - {name: "snow", previous: previous.Snow, current: current.Snow}, - {name: "ice", previous: previous.Ice, current: current.Ice}, - } { - if item.previous == item.current { - continue - } - changeType := prefix + item.name + "_risk_change" - if item.current { - changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is now present.", item.name), Current: "present"}) - } else { - changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is no longer present.", item.name), Previous: "present"}) - } - } - return changes -} - -func aggregateIndicators(dayparts map[string]daypartSummaryStanza) indicators { - out := indicators{} - for _, daypart := range dayparts { - out.Snow = out.Snow || daypart.Snow - out.Ice = out.Ice || daypart.Ice - } - return out -} - -func alertSet(alerts []alertSummaryStanza) map[string]struct{} { - out := map[string]struct{}{} - for _, alert := range alerts { - event := alert.Event - if event == "" { - event = alert.Headline - } - if event != "" { - out[event] = struct{}{} - } - } - return out -} - -func precipitationCategory(value float64) string { - switch { - case value >= 70: - return "high" - case value >= 50: - return "likely" - case value >= 20: - return "possible" - default: - return "low" - } -} - -func differenceAtLeast(previous float64, current float64, threshold float64) bool { - if threshold <= 0 { - return previous != current - } - return math.Abs(current-previous) >= threshold -} - -func sortChanges(items []Change) { - sort.SliceStable(items, func(i, j int) bool { - if items[i].Type == items[j].Type { - return items[i].Message < items[j].Message - } - return items[i].Type < items[j].Type - }) -} - -func parseClock(value string) (time.Time, bool) { - value = strings.TrimSpace(value) - for _, layout := range []string{"3 PM", "3:04 PM", "15:04"} { - if parsed, err := time.Parse(layout, value); err == nil { - return parsed, true - } - } - return time.Time{}, false -} - -func parseTempRange(value string) (*int, *int) { - value = strings.TrimSpace(value) - if value == "" { - return nil, nil - } - parts := strings.Split(value, "-") - if len(parts) == 1 { - if parsed, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil { - return &parsed, &parsed - } - return nil, nil - } - minValue, minErr := strconv.Atoi(strings.TrimSpace(parts[0])) - maxValue, maxErr := strconv.Atoi(strings.TrimSpace(parts[len(parts)-1])) - if minErr != nil || maxErr != nil { - return nil, nil - } - return &minValue, &maxValue -} diff --git a/internal/changes/daily_test.go b/internal/changes/daily_test.go deleted file mode 100644 index 438dbb1..0000000 --- a/internal/changes/daily_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package changes - -import ( - "strings" - "testing" - - "gitea.maximumdirect.net/eric/weatherreporter/internal/module" -) - -func TestCompareDailyNoMeaningfulChanges(t *testing.T) { - previous := dailySnapshot(t, 60, 70, 30, "8 AM", nil, false) - current := dailySnapshot(t, 61, 71, 35, "8:30 AM", nil, false) - - changes, err := CompareDaily(previous, current, testThresholds()) - if err != nil { - t.Fatalf("CompareDaily() error = %v", err) - } - if len(changes) != 0 { - t.Fatalf("changes = %#v, want none", changes) - } -} - -func TestCompareDailyTemperatureThreshold(t *testing.T) { - previous := dailySnapshot(t, 50, 70, 10, "8 AM", nil, false) - current := dailySnapshot(t, 58, 79, 10, "8 AM", nil, false) - - changes, err := CompareDaily(previous, current, testThresholds()) - if err != nil { - t.Fatalf("CompareDaily() error = %v", err) - } - if countType(changes, "temperature_shift") != 2 { - t.Fatalf("changes = %#v, want low and high temperature changes", changes) - } -} - -func TestCompareDailyPrecipTimingShift(t *testing.T) { - previous := dailySnapshot(t, 60, 70, 60, "8 AM", nil, false) - current := dailySnapshot(t, 60, 70, 60, "11 AM", nil, false) - - changes, err := CompareDaily(previous, current, testThresholds()) - if err != nil { - t.Fatalf("CompareDaily() error = %v", err) - } - if countType(changes, "precip_timing_shift") != 1 { - t.Fatalf("changes = %#v, want timing shift", changes) - } -} - -func TestCompareDailyAlertAddedAndRemoved(t *testing.T) { - previous := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Wind Advisory"}, false) - current := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Flood Watch"}, false) - - changes, err := CompareDaily(previous, current, testThresholds()) - if err != nil { - t.Fatalf("CompareDaily() error = %v", err) - } - if countType(changes, "alert_added") != 1 || countType(changes, "alert_removed") != 1 { - t.Fatalf("changes = %#v, want one alert added and one removed", changes) - } -} - -func TestCompareDailyIndicatorChange(t *testing.T) { - previous := dailySnapshot(t, 60, 70, 10, "8 AM", nil, false) - current := dailySnapshot(t, 60, 70, 10, "8 AM", nil, true) - - changes, err := CompareDaily(previous, current, testThresholds()) - if err != nil { - t.Fatalf("CompareDaily() error = %v", err) - } - if countType(changes, "snow_risk_change") != 1 { - t.Fatalf("changes = %#v, want snow risk change", changes) - } -} - -func TestCompareDailyRequiresComparisonStanzas(t *testing.T) { - _, err := CompareDaily(snapshot(t), dailySnapshot(t, 60, 70, 10, "8 AM", nil, false), testThresholds()) - if err == nil { - t.Fatal("CompareDaily() error = nil, want missing stanza error") - } - if !strings.Contains(err.Error(), "derived_daily_summary") { - t.Fatalf("error = %q, want derived_daily_summary context", err.Error()) - } -} - -func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime string, alerts []string, snow bool) module.Snapshot { - t.Helper() - relevant := make([]alertSummaryStanza, 0, len(alerts)) - for _, alert := range alerts { - relevant = append(relevant, alertSummaryStanza{Event: alert}) - } - return snapshot(t, - module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: dailySummaryStanza{ - Date: "2026-05-29", - HighTempF: &high, - LowTempF: &low, - DailyPrecipitationProbability: &precip, - }}, - module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{ - "morning": { - Date: "2026-05-29", - PeriodBegins: "2026-05-29 at 6:00 AM", - PeriodEnds: "2026-05-29 at 10:00 AM", - TempRangeF: "60-70", - Snow: snow, - }, - }}, - module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}}, - module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}}, - ) -} - -func snapshot(t *testing.T, outputs ...module.Output) module.Snapshot { - t.Helper() - snapshot, err := module.NewSnapshot(outputs) - if err != nil { - t.Fatalf("NewSnapshot() error = %v", err) - } - return snapshot -} - -func testThresholds() Thresholds { - return Thresholds{ - TemperatureDegrees: 5, - PrecipProbabilityPoints: 20, - WindGustMilesPerHour: 10, - PrecipTimingShiftMinutes: 120, - } -} - -func countType(changes []Change, changeType string) int { - var count int - for _, change := range changes { - if change.Type == changeType { - count++ - } - } - return count -} diff --git a/internal/config/config.go b/internal/config/config.go index 9aeb59d..6b30b46 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,7 +30,6 @@ type Config struct { Promptkit PromptkitConfig `yaml:"promptkit"` Workspace WorkspaceConfig `yaml:"workspace"` Dayparts []DaypartConfig `yaml:"dayparts"` - RecentChange RecentChangeConfig `yaml:"recent_change"` Reports map[string]ReportConfig `yaml:"reports"` } @@ -109,13 +108,6 @@ type DaypartConfig struct { End string `yaml:"end"` } -type RecentChangeConfig struct { - TemperatureDegrees float64 `yaml:"temperature_degrees"` - PrecipProbabilityPoints int `yaml:"precip_probability_points"` - WindGustMilesPerHour int `yaml:"wind_gust_miles_per_hour"` - PrecipTimingShiftMinutes int `yaml:"precip_timing_shift_minutes"` -} - type ReportConfig struct { DeterministicModules []ModuleConfigItem `yaml:"deterministic_modules"` Distributor ReportDistributorConfig `yaml:"distributor"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 39aa591..02815fc 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -165,6 +165,13 @@ func TestLoadRejectsRetiredExecutionConfiguration(t *testing.T) { } } +func TestLoadRejectsRemovedRecentChangeConfiguration(t *testing.T) { + _, err := LoadFile(writeConfig(t, "recent_change:\n temperature_degrees: 5\n")) + if err == nil || !strings.Contains(err.Error(), "recent_change") { + t.Fatalf("LoadFile() error = %v, want removed configuration rejection", err) + } +} + func TestLoadReportModuleOverrides(t *testing.T) { path := writeConfig(t, ` reports: diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 31f0889..36a52c4 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -64,12 +64,6 @@ func Defaults() Config { {Name: "afternoon", Start: "15:00", End: "17:00"}, {Name: "evening", Start: "17:00", End: "24:00"}, }, - RecentChange: RecentChangeConfig{ - TemperatureDegrees: 5, - PrecipProbabilityPoints: 20, - WindGustMilesPerHour: 10, - PrecipTimingShiftMinutes: 120, - }, Reports: map[string]ReportConfig{}, } } diff --git a/internal/config/load.go b/internal/config/load.go index 828d38c..0986bd5 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -1,6 +1,7 @@ package config import ( + "bytes" "errors" "fmt" "os" @@ -62,7 +63,9 @@ func mergeFile(cfg *Config, path string) error { if err := rejectRetiredExecutionConfig(data); err != nil { return fmt.Errorf("parse config %q: %w", path, err) } - if err := yaml.Unmarshal(data, cfg); err != nil { + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(cfg); err != nil { return fmt.Errorf("parse config %q: %w", path, err) } if cfg.MissingSource.Sources == nil {