Files
weatherreporter/internal/briefing/daily.go

279 lines
7.8 KiB
Go

package briefing
import (
"fmt"
"math"
"sort"
"strings"
"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"`
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 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"`
Summary string `json:"summary,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 := Package{
Metadata: BuildMetadata(ctx),
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,
},
}
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 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.Narrative
}
if discussion.LongTerm != nil {
ctx.LongTerm = discussion.LongTerm.Narrative
}
return ctx
}
func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext {
if bundle == nil || bundle.WeatherStory == nil || len(bundle.WeatherStory.Raw) == 0 {
return nil
}
return &WeatherStoryContext{Available: true, Summary: string(bundle.WeatherStory.Raw)}
}
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.Thunder {
score += 75
reasons = append(reasons, "thunder risk")
}
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.Thunder {
hazards = append(hazards, "thunder")
}
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
}