446 lines
14 KiB
Go
446 lines
14 KiB
Go
package briefing
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
)
|
|
|
|
type Daily struct {
|
|
BottomLine BottomLine `json:"bottomLine"`
|
|
Dayparts []forecast.DaypartSummary `json:"dayparts"`
|
|
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
|
OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
|
|
Planning *TomorrowPlanning `json:"planning,omitempty"`
|
|
NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"`
|
|
Discussion DiscussionContext `json:"discussion,omitempty"`
|
|
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
|
ForecastSummaryDate string `json:"forecastSummaryDate"`
|
|
}
|
|
|
|
type BottomLine struct {
|
|
Summary string `json:"summary"`
|
|
Hazards []string `json:"hazards,omitempty"`
|
|
Temperature forecast.Range `json:"temperature,omitempty"`
|
|
MaxPrecipProbability *forecast.TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
|
PeakWindGust *forecast.TimedValue `json:"peakWindGust,omitempty"`
|
|
}
|
|
|
|
type OutdoorWindows struct {
|
|
Best *OutdoorWindow `json:"best,omitempty"`
|
|
Worst *OutdoorWindow `json:"worst,omitempty"`
|
|
}
|
|
|
|
type OutdoorWindow struct {
|
|
Daypart string `json:"daypart"`
|
|
Start string `json:"start"`
|
|
End string `json:"end"`
|
|
Reasons []string `json:"reasons,omitempty"`
|
|
Score float64 `json:"score"`
|
|
}
|
|
|
|
type TomorrowPlanning struct {
|
|
MorningReadiness []string `json:"morningReadiness,omitempty"`
|
|
CommuteSchoolWorkdayConcerns []string `json:"commuteSchoolWorkdayConcerns,omitempty"`
|
|
OvernightChangeWatch []string `json:"overnightChangeWatch,omitempty"`
|
|
}
|
|
|
|
type DiscussionContext struct {
|
|
Product string `json:"product,omitempty"`
|
|
KeyMessages []string `json:"keyMessages,omitempty"`
|
|
ShortTerm string `json:"shortTerm,omitempty"`
|
|
LongTerm string `json:"longTerm,omitempty"`
|
|
}
|
|
|
|
type WeatherStoryContext struct {
|
|
Available bool `json:"available"`
|
|
OfficeID string `json:"officeId,omitempty"`
|
|
StartTime time.Time `json:"startTime"`
|
|
EndTime time.Time `json:"endTime"`
|
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
AltText string `json:"altText,omitempty"`
|
|
Priority bool `json:"priority"`
|
|
Order int `json:"order"`
|
|
DownloadURL string `json:"downloadUrl,omitempty"`
|
|
}
|
|
|
|
func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, error) {
|
|
if ctx.Resolved.Definition.ID != report.DailyToday && ctx.Resolved.Definition.ID != report.DailyTomorrow {
|
|
return Package{}, fmt.Errorf("daily briefing requires a daily report definition")
|
|
}
|
|
if summary == nil {
|
|
return Package{}, fmt.Errorf("daily forecast summary is required")
|
|
}
|
|
pkg := buildPackage(ctx)
|
|
pkg.Daily = &Daily{
|
|
BottomLine: buildBottomLine(summary),
|
|
Dayparts: summary.Dayparts,
|
|
RelevantAlerts: summary.AlertOverlaps,
|
|
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
|
|
NarrativePeriods: summary.NarrativePeriods,
|
|
Discussion: buildDiscussion(summary.Discussion),
|
|
WeatherStory: buildWeatherStory(ctx.Bundle),
|
|
ForecastSummaryDate: summary.Date,
|
|
}
|
|
setRelevantAlertCount(&pkg.Metadata, len(summary.AlertOverlaps))
|
|
if ctx.Resolved.Definition.ID == report.DailyTomorrow {
|
|
pkg.Daily.Planning = buildTomorrowPlanning(summary)
|
|
}
|
|
return pkg, nil
|
|
}
|
|
|
|
func buildBottomLine(summary *forecast.DailySummary) BottomLine {
|
|
bottomLine := BottomLine{}
|
|
conditions := map[string]struct{}{}
|
|
hazards := map[string]struct{}{}
|
|
for _, daypart := range summary.Dayparts {
|
|
addRange(&bottomLine.Temperature, daypart.Temperature)
|
|
maxTimedValue(&bottomLine.MaxPrecipProbability, daypart.MaxPrecipitationProbability)
|
|
maxTimedValue(&bottomLine.PeakWindGust, daypart.PeakWindGust)
|
|
if daypart.DominantCondition != "" {
|
|
conditions[daypart.DominantCondition] = struct{}{}
|
|
}
|
|
for _, hazard := range hazardsForIndicators(daypart.Indicators) {
|
|
hazards[hazard] = struct{}{}
|
|
}
|
|
}
|
|
for _, alert := range summary.AlertOverlaps {
|
|
if alert.Event != "" {
|
|
hazards[alert.Event] = struct{}{}
|
|
}
|
|
}
|
|
bottomLine.Hazards = sortedSet(hazards)
|
|
bottomLine.Summary = bottomLineText(sortedSet(conditions), bottomLine.Hazards)
|
|
return bottomLine
|
|
}
|
|
|
|
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 buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
|
planning := &TomorrowPlanning{}
|
|
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 daypart.Name == "overnight" || daypart.Name == "evening" {
|
|
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 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 concernNotes(daypart forecast.DaypartSummary) []string {
|
|
notes := []string{}
|
|
prefix := titleWord(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 {
|
|
for i := range dayparts {
|
|
if strings.EqualFold(dayparts[i].Name, name) {
|
|
return &dayparts[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildDiscussion(discussion *forecast.Discussion) DiscussionContext {
|
|
if discussion == nil {
|
|
return DiscussionContext{}
|
|
}
|
|
ctx := DiscussionContext{
|
|
Product: discussion.Product,
|
|
KeyMessages: discussion.KeyMessages,
|
|
}
|
|
if discussion.ShortTerm != nil {
|
|
ctx.ShortTerm = discussion.ShortTerm.Text
|
|
}
|
|
if discussion.LongTerm != nil {
|
|
ctx.LongTerm = discussion.LongTerm.Text
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext {
|
|
if bundle == nil || bundle.WeatherStory == nil {
|
|
return nil
|
|
}
|
|
story := bundle.WeatherStory
|
|
return &WeatherStoryContext{
|
|
Available: true,
|
|
OfficeID: story.OfficeID,
|
|
StartTime: story.StartTime,
|
|
EndTime: story.EndTime,
|
|
UpdatedAt: copyTime(story.UpdatedAt),
|
|
Title: story.Title,
|
|
Description: story.Description,
|
|
AltText: story.AltText,
|
|
Priority: story.Priority,
|
|
Order: story.Order,
|
|
DownloadURL: story.DownloadURL,
|
|
}
|
|
}
|
|
|
|
func copyTime(value *time.Time) *time.Time {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
copied := *value
|
|
return &copied
|
|
}
|
|
|
|
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 += 25
|
|
if daypart.Indicators.Heat {
|
|
reasons = append(reasons, "heat risk")
|
|
}
|
|
if daypart.Indicators.Cold {
|
|
reasons = append(reasons, "cold risk")
|
|
}
|
|
}
|
|
if len(reasons) == 0 {
|
|
reasons = append(reasons, "quiet weather")
|
|
}
|
|
return OutdoorWindow{
|
|
Daypart: daypart.Name,
|
|
Start: daypart.Period.Start.Format("15:04"),
|
|
End: daypart.Period.End.Format("15:04"),
|
|
Reasons: dedupe(reasons),
|
|
Score: math.Round(score*10) / 10,
|
|
}
|
|
}
|
|
|
|
func bottomLineText(conditions []string, hazards []string) string {
|
|
if len(conditions) == 0 && len(hazards) == 0 {
|
|
return "Quiet weather is expected."
|
|
}
|
|
parts := []string{}
|
|
if len(conditions) > 0 {
|
|
parts = append(parts, "Conditions: "+strings.Join(conditions, "; "))
|
|
}
|
|
if len(hazards) > 0 {
|
|
parts = append(parts, "Watch points: "+strings.Join(hazards, "; "))
|
|
}
|
|
return strings.Join(parts, ". ") + "."
|
|
}
|
|
|
|
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 titleWord(value string) string {
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
return strings.ToUpper(value[:1]) + value[1:]
|
|
}
|