Add Daily recent changes comparison
This commit is contained in:
201
internal/changes/daily.go
Normal file
201
internal/changes/daily.go
Normal file
@@ -0,0 +1,201 @@
|
||||
// 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
|
||||
})
|
||||
}
|
||||
123
internal/changes/daily_test.go
Normal file
123
internal/changes/daily_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package changes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
)
|
||||
|
||||
func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
|
||||
previous := dailyBriefing(60, 70, 30, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
current := dailyBriefing(61, 71, 35, at("2026-05-29T08:30:00Z"), nil, forecast.Indicators{})
|
||||
|
||||
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 := dailyBriefing(50, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
current := dailyBriefing(58, 79, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
|
||||
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 := dailyBriefing(60, 70, 60, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
current := dailyBriefing(60, 70, 60, at("2026-05-29T11:00:00Z"), nil, forecast.Indicators{})
|
||||
|
||||
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 := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Wind Advisory"}, forecast.Indicators{})
|
||||
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Flood Watch"}, forecast.Indicators{})
|
||||
|
||||
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 := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Thunder: true})
|
||||
|
||||
changes, err := CompareDaily(previous, current, testThresholds())
|
||||
if err != nil {
|
||||
t.Fatalf("CompareDaily() error = %v", err)
|
||||
}
|
||||
if countType(changes, "thunder_risk_change") != 1 {
|
||||
t.Fatalf("changes = %#v, want thunder risk change", changes)
|
||||
}
|
||||
}
|
||||
|
||||
func dailyBriefing(low float64, high float64, precip float64, precipTime time.Time, alerts []string, indicators forecast.Indicators) briefing.Package {
|
||||
alertOverlaps := make([]forecast.AlertOverlap, 0, len(alerts))
|
||||
for _, alert := range alerts {
|
||||
alertOverlaps = append(alertOverlaps, forecast.AlertOverlap{Event: alert})
|
||||
}
|
||||
return briefing.Package{
|
||||
Daily: &briefing.Daily{
|
||||
BottomLine: briefing.BottomLine{
|
||||
Temperature: forecast.Range{Min: &low, Max: &high},
|
||||
MaxPrecipProbability: &forecast.TimedValue{
|
||||
Value: precip,
|
||||
Time: precipTime,
|
||||
},
|
||||
},
|
||||
RelevantAlerts: alertOverlaps,
|
||||
Dayparts: []forecast.DaypartSummary{
|
||||
{Name: "morning", Indicators: indicators},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func at(value string) time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
Reference in New Issue
Block a user