Add Weekend Outlook generation
This commit is contained in:
@@ -19,6 +19,7 @@ type Package struct {
|
||||
Metadata Metadata `json:"metadata"`
|
||||
Daily *Daily `json:"daily,omitempty"`
|
||||
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
||||
Weekend *Weekend `json:"weekend,omitempty"`
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
|
||||
@@ -44,11 +44,18 @@ func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
},
|
||||
}
|
||||
alerts := map[string]forecast.AlertOverlap{}
|
||||
for _, summary := range summaries {
|
||||
day := buildOutlookDay(summary)
|
||||
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
|
||||
for _, alert := range summary.AlertOverlaps {
|
||||
}
|
||||
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func collectOutlookAlerts(days []OutlookDay) []forecast.AlertOverlap {
|
||||
alerts := map[string]forecast.AlertOverlap{}
|
||||
for _, day := range days {
|
||||
for _, alert := range day.RelevantAlerts {
|
||||
key := alert.Event
|
||||
if key == "" {
|
||||
key = alert.Headline
|
||||
@@ -63,10 +70,11 @@ func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out := make([]forecast.AlertOverlap, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
pkg.ThreeDay.RelevantAlerts = append(pkg.ThreeDay.RelevantAlerts, alerts[key])
|
||||
out = append(out, alerts[key])
|
||||
}
|
||||
return pkg, nil
|
||||
return out
|
||||
}
|
||||
|
||||
func buildOutlookDay(summary forecast.DailySummary) OutlookDay {
|
||||
|
||||
125
internal/briefing/weekend.go
Normal file
125
internal/briefing/weekend.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
type Weekend struct {
|
||||
Days []OutlookDay `json:"days"`
|
||||
Planning WeekendPlanning `json:"planning"`
|
||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
||||
}
|
||||
|
||||
type WeekendPlanning struct {
|
||||
BestOutdoorWindows []OutdoorWindow `json:"bestOutdoorWindows,omitempty"`
|
||||
WorstWeatherWindows []OutdoorWindow `json:"worstWeatherWindows,omitempty"`
|
||||
RainStormTiming []string `json:"rainStormTiming,omitempty"`
|
||||
ComfortConcerns []string `json:"comfortConcerns,omitempty"`
|
||||
UncertaintyInputs []string `json:"uncertaintyInputs,omitempty"`
|
||||
}
|
||||
|
||||
func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package, error) {
|
||||
if ctx.Resolved.Definition.ID != report.Weekend {
|
||||
return Package{}, fmt.Errorf("weekend briefing requires a weekend report definition")
|
||||
}
|
||||
if len(summaries) == 0 {
|
||||
return Package{}, fmt.Errorf("weekend forecast summaries are required")
|
||||
}
|
||||
pkg := Package{
|
||||
Metadata: BuildMetadata(ctx),
|
||||
Weekend: &Weekend{
|
||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
},
|
||||
}
|
||||
for _, summary := range summaries {
|
||||
pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary))
|
||||
}
|
||||
pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days)
|
||||
pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle)
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func buildWeekendPlanning(days []OutlookDay, discussion DiscussionContext, bundle *forecast.Bundle) WeekendPlanning {
|
||||
planning := WeekendPlanning{}
|
||||
for _, day := range days {
|
||||
if day.OutdoorWindows.Best != nil {
|
||||
window := *day.OutdoorWindows.Best
|
||||
window.Daypart = day.Date + " " + window.Daypart
|
||||
planning.BestOutdoorWindows = append(planning.BestOutdoorWindows, window)
|
||||
}
|
||||
if day.OutdoorWindows.Worst != nil {
|
||||
window := *day.OutdoorWindows.Worst
|
||||
window.Daypart = day.Date + " " + window.Daypart
|
||||
planning.WorstWeatherWindows = append(planning.WorstWeatherWindows, window)
|
||||
}
|
||||
for _, daypart := range day.Dayparts {
|
||||
planning.RainStormTiming = appendUnique(planning.RainStormTiming, weekendRainStormNotes(day.Date, daypart)...)
|
||||
planning.ComfortConcerns = appendUnique(planning.ComfortConcerns, weekendComfortNotes(day.Date, daypart)...)
|
||||
}
|
||||
}
|
||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, discussion.KeyMessages...)
|
||||
if discussion.ShortTerm != "" {
|
||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Short-term discussion available for confidence context.")
|
||||
}
|
||||
if discussion.LongTerm != "" {
|
||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Long-term discussion available for uncertainty context.")
|
||||
}
|
||||
if bundle != nil {
|
||||
for _, warning := range bundle.Warnings {
|
||||
if warning.Code != "" {
|
||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Source warning: "+warning.Code+".")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(planning.RainStormTiming) == 0 {
|
||||
planning.RainStormTiming = append(planning.RainStormTiming, "No focused rain or storm timing stands out in the available weekend forecast.")
|
||||
}
|
||||
if len(planning.ComfortConcerns) == 0 {
|
||||
planning.ComfortConcerns = append(planning.ComfortConcerns, "No major heat, cold, or wind comfort concern stands out in the available weekend forecast.")
|
||||
}
|
||||
if len(planning.UncertaintyInputs) == 0 {
|
||||
planning.UncertaintyInputs = append(planning.UncertaintyInputs, "No explicit confidence or uncertainty signal was available from the selected source context.")
|
||||
}
|
||||
return planning
|
||||
}
|
||||
|
||||
func weekendRainStormNotes(date string, daypart forecast.DaypartSummary) []string {
|
||||
notes := []string{}
|
||||
label := weekendWindowLabel(date, daypart.Name)
|
||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
||||
notes = append(notes, fmt.Sprintf("%s precipitation chance peaks near %.0f%%.", label, daypart.MaxPrecipitationProbability.Value))
|
||||
}
|
||||
if daypart.Indicators.Thunder {
|
||||
notes = append(notes, label+" thunder risk is present.")
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
func weekendComfortNotes(date string, daypart forecast.DaypartSummary) []string {
|
||||
notes := []string{}
|
||||
label := weekendWindowLabel(date, daypart.Name)
|
||||
if daypart.Indicators.Heat {
|
||||
notes = append(notes, label+" heat may affect outdoor comfort.")
|
||||
}
|
||||
if daypart.Indicators.Cold {
|
||||
notes = append(notes, label+" cold may affect outdoor comfort.")
|
||||
}
|
||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 25 {
|
||||
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", label, daypart.PeakWindGust.Value))
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
func weekendWindowLabel(date string, daypart string) string {
|
||||
if daypart == "" {
|
||||
return date
|
||||
}
|
||||
return strings.TrimSpace(date + " " + daypart)
|
||||
}
|
||||
88
internal/briefing/weekend_test.go
Normal file
88
internal/briefing/weekend_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved, err := report.Resolve(report.Weekend, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T08:00:00-05:00"),
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve weekend: %v", err)
|
||||
}
|
||||
precip := 70.0
|
||||
gust := 32.0
|
||||
summaries := []forecast.DailySummary{
|
||||
{
|
||||
Date: "2026-05-30",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParse("2026-05-30T00:00:00-05:00"),
|
||||
End: mustParse("2026-05-31T00:00:00-05:00"),
|
||||
},
|
||||
Dayparts: []forecast.DaypartSummary{
|
||||
{
|
||||
Name: "afternoon",
|
||||
DominantCondition: "Showers and thunderstorms",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParse("2026-05-30T12:00:00-05:00"),
|
||||
End: mustParse("2026-05-30T18:00:00-05:00"),
|
||||
},
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: precip,
|
||||
Time: mustParse("2026-05-30T15:00:00-05:00"),
|
||||
},
|
||||
PeakWindGust: &forecast.TimedValue{
|
||||
Value: gust,
|
||||
Time: mustParse("2026-05-30T16:00:00-05:00"),
|
||||
},
|
||||
Indicators: forecast.Indicators{Thunder: true, Wind: true},
|
||||
HourlyPeriods: []forecast.ForecastPeriod{
|
||||
{
|
||||
StartTime: mustParse("2026-05-30T15:00:00-05:00"),
|
||||
EndTime: mustParse("2026-05-30T16:00:00-05:00"),
|
||||
TextDescription: "Showers and thunderstorms",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
||||
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Timing may shift."}},
|
||||
},
|
||||
}
|
||||
|
||||
pkg, err := BuildWeekend(BuildContext{
|
||||
Resolved: resolved,
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
}, summaries)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWeekend() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Metadata.ReportID != report.Weekend {
|
||||
t.Fatalf("ReportID = %q, want weekend", pkg.Metadata.ReportID)
|
||||
}
|
||||
if pkg.Weekend == nil {
|
||||
t.Fatal("Weekend = nil")
|
||||
}
|
||||
if len(pkg.Weekend.Days) != 1 {
|
||||
t.Fatalf("Days length = %d, want 1", len(pkg.Weekend.Days))
|
||||
}
|
||||
if len(pkg.Weekend.Planning.WorstWeatherWindows) == 0 {
|
||||
t.Fatalf("WorstWeatherWindows = %#v, want weather window", pkg.Weekend.Planning.WorstWeatherWindows)
|
||||
}
|
||||
if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "thunder") {
|
||||
t.Fatalf("RainStormTiming = %#v, want thunder timing", pkg.Weekend.Planning.RainStormTiming)
|
||||
}
|
||||
if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 {
|
||||
t.Fatal("UncertaintyInputs length = 0, want discussion context")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user