Compare recent changes from module snapshots
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
// Package changes compares structured briefing snapshots.
|
||||
// Package changes compares structured module snapshots.
|
||||
package changes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type Thresholds struct {
|
||||
@@ -25,83 +27,168 @@ type Change struct {
|
||||
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")
|
||||
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)
|
||||
}
|
||||
if current.Daily == nil {
|
||||
return nil, fmt.Errorf("current daily briefing is required")
|
||||
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, 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))...)
|
||||
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.MaxPopPercent, currentSummary.MaxPopPercent, 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
|
||||
}
|
||||
|
||||
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
|
||||
type dailySummaryStanza struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
HighTempF *int `json:"high_temp_f,omitempty"`
|
||||
LowTempF *int `json:"low_temp_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
}
|
||||
|
||||
func comparePrecipitation(previous *forecast.TimedValue, current *forecast.TimedValue, thresholds Thresholds) []Change {
|
||||
type daypartSummaryStanza struct {
|
||||
Period timeutil.Period `json:"period"`
|
||||
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
|
||||
}
|
||||
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) {
|
||||
if !differenceAtLeast(float64(*previous), float64(*current), 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),
|
||||
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 compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOverlap) []Change {
|
||||
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
|
||||
@@ -119,7 +206,7 @@ func compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOve
|
||||
return changes
|
||||
}
|
||||
|
||||
func compareIndicators(previous forecast.Indicators, current forecast.Indicators) []Change {
|
||||
func compareIndicators(previous indicators, current indicators, prefix string) []Change {
|
||||
var changes []Change
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
@@ -132,7 +219,7 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
||||
if item.previous == item.current {
|
||||
continue
|
||||
}
|
||||
changeType := item.name + "_risk_change"
|
||||
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 {
|
||||
@@ -142,16 +229,16 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
||||
return changes
|
||||
}
|
||||
|
||||
func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators {
|
||||
out := forecast.Indicators{}
|
||||
func aggregateIndicators(dayparts map[string]daypartSummaryStanza) indicators {
|
||||
out := indicators{}
|
||||
for _, daypart := range dayparts {
|
||||
out.Snow = out.Snow || daypart.Indicators.Snow
|
||||
out.Ice = out.Ice || daypart.Indicators.Ice
|
||||
out.Snow = out.Snow || daypart.Snow
|
||||
out.Ice = out.Ice || daypart.Ice
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func alertSet(alerts []forecast.AlertOverlap) map[string]struct{} {
|
||||
func alertSet(alerts []alertSummaryStanza) map[string]struct{} {
|
||||
out := map[string]struct{}{}
|
||||
for _, alert := range alerts {
|
||||
event := alert.Event
|
||||
@@ -185,10 +272,6 @@ func differenceAtLeast(previous float64, current float64, threshold float64) boo
|
||||
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 {
|
||||
@@ -197,3 +280,33 @@ func sortChanges(items []Change) {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user