// Package changes compares structured briefing snapshots. package changes import ( "fmt" "math" "sort" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" ) 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 briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) { if previous.Daily == nil { return nil, fmt.Errorf("previous daily briefing is required") } if current.Daily == nil { return nil, fmt.Errorf("current daily briefing is required") } var changes []Change changes = append(changes, compareTemperature(previous.Daily.BottomLine.Temperature, current.Daily.BottomLine.Temperature, thresholds.TemperatureDegrees)...) changes = append(changes, comparePrecipitation(previous.Daily.BottomLine.MaxPrecipProbability, current.Daily.BottomLine.MaxPrecipProbability, thresholds)...) changes = append(changes, compareWind(previous.Daily.BottomLine.PeakWindGust, current.Daily.BottomLine.PeakWindGust, float64(thresholds.WindGustMilesPerHour))...) changes = append(changes, compareAlerts(previous.Daily.RelevantAlerts, current.Daily.RelevantAlerts)...) changes = append(changes, compareIndicators(aggregateIndicators(previous.Daily.Dayparts), aggregateIndicators(current.Daily.Dayparts))...) return changes, nil } func compareTemperature(previous forecast.Range, current forecast.Range, threshold float64) []Change { var changes []Change if previous.Min != nil && current.Min != nil && differenceAtLeast(*previous.Min, *current.Min, threshold) { changes = append(changes, Change{ Type: "temperature_shift", Message: fmt.Sprintf("Low temperature changed from %.0f to %.0f.", *previous.Min, *current.Min), Previous: fmt.Sprintf("%.0f", *previous.Min), Current: fmt.Sprintf("%.0f", *current.Min), }) } if previous.Max != nil && current.Max != nil && differenceAtLeast(*previous.Max, *current.Max, threshold) { changes = append(changes, Change{ Type: "temperature_shift", Message: fmt.Sprintf("High temperature changed from %.0f to %.0f.", *previous.Max, *current.Max), Previous: fmt.Sprintf("%.0f", *previous.Max), Current: fmt.Sprintf("%.0f", *current.Max), }) } return changes } func comparePrecipitation(previous *forecast.TimedValue, current *forecast.TimedValue, thresholds Thresholds) []Change { if previous == nil || current == nil { return nil } var changes []Change previousCategory := precipitationCategory(previous.Value) currentCategory := precipitationCategory(current.Value) if previousCategory != currentCategory || differenceAtLeast(previous.Value, current.Value, float64(thresholds.PrecipProbabilityPoints)) { changes = append(changes, Change{ Type: "precip_probability_change", Message: fmt.Sprintf("Peak precipitation chance changed from %.0f%% (%s) to %.0f%% (%s).", previous.Value, previousCategory, current.Value, currentCategory), Previous: fmt.Sprintf("%.0f%% %s", previous.Value, previousCategory), Current: fmt.Sprintf("%.0f%% %s", current.Value, currentCategory), }) } shiftMinutes := int(math.Abs(current.Time.Sub(previous.Time).Minutes())) if thresholds.PrecipTimingShiftMinutes > 0 && shiftMinutes >= thresholds.PrecipTimingShiftMinutes { changes = append(changes, Change{ Type: "precip_timing_shift", Message: fmt.Sprintf("Peak precipitation timing shifted from %s to %s.", clock(previous.Time), clock(current.Time)), Previous: clock(previous.Time), Current: clock(current.Time), }) } return changes } func compareWind(previous *forecast.TimedValue, current *forecast.TimedValue, threshold float64) []Change { if previous == nil || current == nil || !differenceAtLeast(previous.Value, current.Value, threshold) { return nil } return []Change{{ Type: "wind_gust_change", Message: fmt.Sprintf("Peak wind gust changed from %.0f mph to %.0f mph.", previous.Value, current.Value), Previous: fmt.Sprintf("%.0f mph", previous.Value), Current: fmt.Sprintf("%.0f mph", current.Value), }} } func compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOverlap) []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 forecast.Indicators, current forecast.Indicators) []Change { var changes []Change for _, item := range []struct { name string previous bool current bool }{ {name: "thunder", previous: previous.Thunder, current: current.Thunder}, {name: "snow", previous: previous.Snow, current: current.Snow}, {name: "ice", previous: previous.Ice, current: current.Ice}, } { if item.previous == item.current { continue } changeType := 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 []forecast.DaypartSummary) forecast.Indicators { out := forecast.Indicators{} for _, daypart := range dayparts { out.Thunder = out.Thunder || daypart.Indicators.Thunder out.Snow = out.Snow || daypart.Indicators.Snow out.Ice = out.Ice || daypart.Indicators.Ice } return out } func alertSet(alerts []forecast.AlertOverlap) 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 clock(t time.Time) string { return t.Format("15:04") } 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 }) }