// 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 }