Add Daily briefing JSON generation
This commit is contained in:
278
internal/briefing/daily.go
Normal file
278
internal/briefing/daily.go
Normal file
@@ -0,0 +1,278 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user