314 lines
10 KiB
Go
314 lines
10 KiB
Go
// 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
|
|
}
|