426 lines
14 KiB
Go
426 lines
14 KiB
Go
package briefing
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
)
|
|
|
|
type OutdoorWindows struct {
|
|
Best *OutdoorWindow
|
|
Worst *OutdoorWindow
|
|
}
|
|
|
|
type OutdoorWindow struct {
|
|
Daypart string
|
|
Period timeutil.Period
|
|
Reasons []string
|
|
Score float64
|
|
}
|
|
|
|
type TomorrowPlanning struct {
|
|
MorningReadiness []string
|
|
CommuteSchoolWorkdayConcerns []string
|
|
OvernightChangeWatch []string
|
|
}
|
|
|
|
type morningCommuteOvernightPlanning struct {
|
|
MorningReadiness []string
|
|
CommuteSchoolWorkdayConcerns []string
|
|
OvernightChangeWatch []string
|
|
}
|
|
|
|
type TodayPlanning struct {
|
|
MorningReadiness []string
|
|
CommuteSchoolWorkdayConcerns []string
|
|
OutdoorPlanning []string
|
|
LateDayChangeWatch []string
|
|
}
|
|
|
|
const outdoorIndicatorRiskScore = 25
|
|
|
|
const (
|
|
morningDaypartIdentity = "morning"
|
|
afternoonDaypartIdentity = "afternoon"
|
|
eveningDaypartIdentity = "evening"
|
|
overnightDaypartIdentity = "overnight"
|
|
)
|
|
|
|
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
|
var best *OutdoorWindow
|
|
var worst *OutdoorWindow
|
|
for _, daypart := range dayparts {
|
|
if len(daypart.HourlyPeriods) == 0 {
|
|
continue
|
|
}
|
|
window := scoreOutdoorWindow(daypart)
|
|
if best == nil || window.Score < best.Score {
|
|
copied := window
|
|
best = &copied
|
|
}
|
|
if worst == nil || window.Score > worst.Score {
|
|
copied := window
|
|
worst = &copied
|
|
}
|
|
}
|
|
return OutdoorWindows{Best: best, Worst: worst}
|
|
}
|
|
|
|
func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
|
|
planning := &TodayPlanning{}
|
|
morning := daypartNamed(summary.Dayparts, "morning")
|
|
if morning != nil {
|
|
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
|
}
|
|
if len(planning.MorningReadiness) == 0 {
|
|
planning.MorningReadiness = append(planning.MorningReadiness, "Morning weather looks routine based on the available hourly forecast.")
|
|
}
|
|
|
|
for _, daypart := range summary.Dayparts {
|
|
if isOutsideWorkday(daypart) {
|
|
continue
|
|
}
|
|
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
|
|
}
|
|
for _, alert := range summary.AlertOverlaps {
|
|
if alert.Event != "" {
|
|
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, "Active alert to plan around: "+alert.Event+".")
|
|
}
|
|
}
|
|
if len(planning.CommuteSchoolWorkdayConcerns) == 0 {
|
|
planning.CommuteSchoolWorkdayConcerns = append(planning.CommuteSchoolWorkdayConcerns, "No major commute, school, or workday weather concerns stand out in the available forecast.")
|
|
}
|
|
|
|
planning.OutdoorPlanning = append(planning.OutdoorPlanning, outdoorPlanningNotes(summary.Dayparts)...)
|
|
if len(planning.OutdoorPlanning) == 0 {
|
|
planning.OutdoorPlanning = append(planning.OutdoorPlanning, "No standout outdoor weather constraints are evident in the available forecast.")
|
|
}
|
|
|
|
for _, name := range []string{afternoonDaypartIdentity, eveningDaypartIdentity} {
|
|
daypart := daypartNamed(summary.Dayparts, name)
|
|
if daypart != nil {
|
|
planning.LateDayChangeWatch = appendUnique(planning.LateDayChangeWatch, lateDayWatchNotes(*daypart)...)
|
|
}
|
|
}
|
|
if len(planning.LateDayChangeWatch) == 0 {
|
|
planning.LateDayChangeWatch = append(planning.LateDayChangeWatch, "Watch for forecast timing or intensity adjustments later today.")
|
|
}
|
|
|
|
return planning
|
|
}
|
|
|
|
func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
|
base := buildMorningCommuteOvernightPlanning(summary)
|
|
return &TomorrowPlanning{
|
|
MorningReadiness: append([]string(nil), base.MorningReadiness...),
|
|
CommuteSchoolWorkdayConcerns: append([]string(nil), base.CommuteSchoolWorkdayConcerns...),
|
|
OvernightChangeWatch: append([]string(nil), base.OvernightChangeWatch...),
|
|
}
|
|
}
|
|
|
|
func buildMorningCommuteOvernightPlanning(summary *forecast.DailySummary) *morningCommuteOvernightPlanning {
|
|
planning := &morningCommuteOvernightPlanning{}
|
|
morning := daypartNamed(summary.Dayparts, "morning")
|
|
if morning != nil {
|
|
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
|
}
|
|
if len(planning.MorningReadiness) == 0 {
|
|
planning.MorningReadiness = append(planning.MorningReadiness, "Morning weather looks routine based on the available hourly forecast.")
|
|
}
|
|
|
|
for _, daypart := range summary.Dayparts {
|
|
if isOutsideWorkday(daypart) {
|
|
continue
|
|
}
|
|
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
|
|
}
|
|
for _, alert := range summary.AlertOverlaps {
|
|
if alert.Event != "" {
|
|
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, "Active alert to plan around: "+alert.Event+".")
|
|
}
|
|
}
|
|
if len(planning.CommuteSchoolWorkdayConcerns) == 0 {
|
|
planning.CommuteSchoolWorkdayConcerns = append(planning.CommuteSchoolWorkdayConcerns, "No major commute, school, or workday weather concerns stand out in the available forecast.")
|
|
}
|
|
|
|
overnight := daypartNamed(summary.Dayparts, "overnight")
|
|
if overnight != nil {
|
|
planning.OvernightChangeWatch = append(planning.OvernightChangeWatch, overnightWatchNotes(*overnight)...)
|
|
}
|
|
if len(planning.OvernightChangeWatch) == 0 {
|
|
planning.OvernightChangeWatch = append(planning.OvernightChangeWatch, "Watch for forecast timing or intensity adjustments overnight.")
|
|
}
|
|
|
|
return planning
|
|
}
|
|
|
|
func outdoorPlanningNotes(dayparts []forecast.DaypartSummary) []string {
|
|
windows := buildOutdoorWindows(dayparts)
|
|
var notes []string
|
|
if windows.Best != nil {
|
|
notes = append(notes, fmt.Sprintf("Best outdoor window: %s (%s).", capitalizeFirst(windows.Best.Daypart), strings.Join(windows.Best.Reasons, ", ")))
|
|
}
|
|
if windows.Worst != nil && (windows.Best == nil || windows.Worst.Daypart != windows.Best.Daypart) {
|
|
notes = append(notes, fmt.Sprintf("Toughest outdoor window: %s (%s).", capitalizeFirst(windows.Worst.Daypart), strings.Join(windows.Worst.Reasons, ", ")))
|
|
}
|
|
return appendUnique(nil, notes...)
|
|
}
|
|
|
|
func readinessNotes(daypart forecast.DaypartSummary) []string {
|
|
notes := []string{}
|
|
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
|
|
notes = append(notes, fmt.Sprintf("Morning precipitation chance peaks near %.0f%%.", daypart.MaxPrecipitationProbability.Value))
|
|
}
|
|
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
|
notes = append(notes, fmt.Sprintf("Morning gusts may reach %.0f mph.", daypart.PeakWindGust.Value))
|
|
}
|
|
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
|
notes = append(notes, "Morning wintry weather could affect surfaces and travel.")
|
|
}
|
|
if daypart.Indicators.Fog {
|
|
notes = append(notes, "Morning fog could reduce visibility.")
|
|
}
|
|
if daypart.Temperature.Min != nil && *daypart.Temperature.Min <= 32 {
|
|
notes = append(notes, "Morning temperatures may be at or below freezing.")
|
|
}
|
|
return appendUnique(nil, notes...)
|
|
}
|
|
|
|
func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
|
|
notes := []string{}
|
|
prefix := capitalizeFirst(daypart.Name)
|
|
if prefix == "" {
|
|
prefix = "Late-day"
|
|
}
|
|
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
|
notes = append(notes, fmt.Sprintf("%s precipitation timing may shift; current peak is near %.0f%%.", prefix, daypart.MaxPrecipitationProbability.Value))
|
|
}
|
|
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
|
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value))
|
|
}
|
|
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
|
notes = append(notes, prefix+" wintry weather could affect late-day travel.")
|
|
}
|
|
if len(daypart.AlertOverlaps) > 0 {
|
|
notes = append(notes, prefix+" alert timing could affect late-day plans.")
|
|
}
|
|
return appendUnique(nil, notes...)
|
|
}
|
|
|
|
func concernNotes(daypart forecast.DaypartSummary) []string {
|
|
notes := []string{}
|
|
prefix := capitalizeFirst(daypart.Name)
|
|
if prefix == "" {
|
|
prefix = "Daytime"
|
|
}
|
|
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 40 {
|
|
notes = append(notes, fmt.Sprintf("%s precipitation chance reaches %.0f%%.", prefix, daypart.MaxPrecipitationProbability.Value))
|
|
}
|
|
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
|
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value))
|
|
}
|
|
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
|
notes = append(notes, prefix+" wintry weather may affect travel.")
|
|
}
|
|
if daypart.Indicators.Heat {
|
|
notes = append(notes, prefix+" heat may require extra hydration and breaks.")
|
|
}
|
|
if daypart.Indicators.Cold {
|
|
notes = append(notes, prefix+" cold may require extra layers.")
|
|
}
|
|
if len(daypart.AlertOverlaps) > 0 {
|
|
notes = append(notes, prefix+" alert overlap needs attention.")
|
|
}
|
|
return appendUnique(nil, notes...)
|
|
}
|
|
|
|
func overnightWatchNotes(daypart forecast.DaypartSummary) []string {
|
|
notes := []string{}
|
|
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
|
notes = append(notes, fmt.Sprintf("Overnight precipitation timing may shift; current peak is near %.0f%%.", daypart.MaxPrecipitationProbability.Value))
|
|
}
|
|
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
|
notes = append(notes, fmt.Sprintf("Overnight gusts may reach %.0f mph before morning plans begin.", daypart.PeakWindGust.Value))
|
|
}
|
|
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
|
notes = append(notes, "Overnight wintry weather could leave morning travel impacts.")
|
|
}
|
|
if len(daypart.AlertOverlaps) > 0 {
|
|
notes = append(notes, "Overnight alert timing could affect the morning setup.")
|
|
}
|
|
return appendUnique(nil, notes...)
|
|
}
|
|
|
|
func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.DaypartSummary {
|
|
identity := forecast.CanonicalDaypartKey(name)
|
|
for i := range dayparts {
|
|
if forecast.CanonicalDaypartKey(dayparts[i].Name) == identity {
|
|
return &dayparts[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
|
score := 0.0
|
|
reasons := []string{}
|
|
if daypart.MaxPrecipitationProbability != nil {
|
|
score += daypart.MaxPrecipitationProbability.Value
|
|
if daypart.MaxPrecipitationProbability.Value >= 50 {
|
|
reasons = append(reasons, "high precipitation chance")
|
|
}
|
|
}
|
|
if daypart.PeakWindGust != nil {
|
|
score += daypart.PeakWindGust.Value * 1.5
|
|
if daypart.PeakWindGust.Value >= 30 {
|
|
reasons = append(reasons, "gusty wind")
|
|
}
|
|
}
|
|
if len(daypart.AlertOverlaps) > 0 {
|
|
score += float64(len(daypart.AlertOverlaps)) * 100
|
|
reasons = append(reasons, "alert overlap")
|
|
}
|
|
if daypart.Indicators.Heat || daypart.Indicators.Cold {
|
|
score += outdoorIndicatorRiskScore
|
|
if daypart.Indicators.Heat {
|
|
reasons = append(reasons, "heat risk")
|
|
}
|
|
if daypart.Indicators.Cold {
|
|
reasons = append(reasons, "cold risk")
|
|
}
|
|
}
|
|
for _, hazard := range []struct {
|
|
present bool
|
|
reason string
|
|
}{
|
|
{present: daypart.Indicators.Snow, reason: "snow risk"},
|
|
{present: daypart.Indicators.Ice, reason: "ice risk"},
|
|
{present: daypart.Indicators.Fog, reason: "fog risk"},
|
|
} {
|
|
if hazard.present {
|
|
score += outdoorIndicatorRiskScore
|
|
reasons = append(reasons, hazard.reason)
|
|
}
|
|
}
|
|
if len(reasons) == 0 {
|
|
reasons = append(reasons, "quiet weather")
|
|
}
|
|
return OutdoorWindow{
|
|
Daypart: daypart.Name,
|
|
Period: daypart.Period,
|
|
Reasons: dedupe(reasons),
|
|
Score: math.Round(score*10) / 10,
|
|
}
|
|
}
|
|
|
|
func hazardsForIndicators(indicators forecast.Indicators) []string {
|
|
var hazards []string
|
|
if indicators.Snow {
|
|
hazards = append(hazards, "snow")
|
|
}
|
|
if indicators.Ice {
|
|
hazards = append(hazards, "ice")
|
|
}
|
|
if indicators.Fog {
|
|
hazards = append(hazards, "fog")
|
|
}
|
|
if indicators.Heat {
|
|
hazards = append(hazards, "heat")
|
|
}
|
|
if indicators.Cold {
|
|
hazards = append(hazards, "cold")
|
|
}
|
|
if indicators.Wind {
|
|
hazards = append(hazards, "wind")
|
|
}
|
|
return hazards
|
|
}
|
|
|
|
func addRange(target *forecast.Range, value forecast.Range) {
|
|
if value.Min != nil {
|
|
if target.Min == nil || *value.Min < *target.Min {
|
|
copied := *value.Min
|
|
target.Min = &copied
|
|
}
|
|
}
|
|
if value.Max != nil {
|
|
if target.Max == nil || *value.Max > *target.Max {
|
|
copied := *value.Max
|
|
target.Max = &copied
|
|
}
|
|
}
|
|
}
|
|
|
|
func maxTimedValue(target **forecast.TimedValue, value *forecast.TimedValue) {
|
|
if value == nil {
|
|
return
|
|
}
|
|
if *target == nil || value.Value > (*target).Value {
|
|
copied := *value
|
|
*target = &copied
|
|
}
|
|
}
|
|
|
|
func sortedSet(values map[string]struct{}) []string {
|
|
out := make([]string, 0, len(values))
|
|
for value := range values {
|
|
out = append(out, value)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func dedupe(values []string) []string {
|
|
seen := map[string]struct{}{}
|
|
out := []string{}
|
|
for _, value := range values {
|
|
if _, ok := seen[value]; ok {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
out = append(out, value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func appendUnique(values []string, candidates ...string) []string {
|
|
seen := map[string]struct{}{}
|
|
for _, value := range values {
|
|
seen[value] = struct{}{}
|
|
}
|
|
for _, candidate := range candidates {
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[candidate]; ok {
|
|
continue
|
|
}
|
|
seen[candidate] = struct{}{}
|
|
values = append(values, candidate)
|
|
}
|
|
return values
|
|
}
|
|
|
|
func isOutsideWorkday(daypart forecast.DaypartSummary) bool {
|
|
switch forecast.CanonicalDaypartKey(daypart.Name) {
|
|
case overnightDaypartIdentity, eveningDaypartIdentity:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func capitalizeFirst(value string) string {
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
runes := []rune(value)
|
|
runes[0] = unicode.ToUpper(runes[0])
|
|
return string(runes)
|
|
}
|