202 lines
7.0 KiB
Go
202 lines
7.0 KiB
Go
// 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
|
|
})
|
|
}
|