Add 3-Day Outlook generation
This commit is contained in:
@@ -16,8 +16,9 @@ import (
|
||||
const SchemaVersion = "weatherreporter.briefing.v1"
|
||||
|
||||
type Package struct {
|
||||
Metadata Metadata `json:"metadata"`
|
||||
Daily *Daily `json:"daily,omitempty"`
|
||||
Metadata Metadata `json:"metadata"`
|
||||
Daily *Daily `json:"daily,omitempty"`
|
||||
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
|
||||
121
internal/briefing/three_day.go
Normal file
121
internal/briefing/three_day.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type ThreeDay struct {
|
||||
Days []OutlookDay `json:"days"`
|
||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
||||
}
|
||||
|
||||
type OutlookDay struct {
|
||||
Date string `json:"date"`
|
||||
Period timeutil.Period `json:"period"`
|
||||
OverallCharacter string `json:"overallCharacter"`
|
||||
Temperature forecast.Range `json:"temperature,omitempty"`
|
||||
MaxPrecipitationProbability *forecast.TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
||||
PeakWindGust *forecast.TimedValue `json:"peakWindGust,omitempty"`
|
||||
Risks []string `json:"risks,omitempty"`
|
||||
OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
|
||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
||||
Dayparts []forecast.DaypartSummary `json:"dayparts"`
|
||||
}
|
||||
|
||||
func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package, error) {
|
||||
if ctx.Resolved.Definition.ID != report.ThreeDay {
|
||||
return Package{}, fmt.Errorf("3-day briefing requires a 3-day report definition")
|
||||
}
|
||||
if len(summaries) == 0 {
|
||||
return Package{}, fmt.Errorf("3-day forecast summaries are required")
|
||||
}
|
||||
pkg := Package{
|
||||
Metadata: BuildMetadata(ctx),
|
||||
ThreeDay: &ThreeDay{
|
||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||
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 {
|
||||
key := alert.Event
|
||||
if key == "" {
|
||||
key = alert.Headline
|
||||
}
|
||||
if key != "" {
|
||||
alerts[key] = alert
|
||||
}
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(alerts))
|
||||
for key := range alerts {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
pkg.ThreeDay.RelevantAlerts = append(pkg.ThreeDay.RelevantAlerts, alerts[key])
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func buildOutlookDay(summary forecast.DailySummary) OutlookDay {
|
||||
day := OutlookDay{
|
||||
Date: summary.Date,
|
||||
Period: summary.Period,
|
||||
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
|
||||
RelevantAlerts: summary.AlertOverlaps,
|
||||
Dayparts: summary.Dayparts,
|
||||
}
|
||||
conditions := map[string]struct{}{}
|
||||
risks := map[string]struct{}{}
|
||||
for _, daypart := range summary.Dayparts {
|
||||
addRange(&day.Temperature, daypart.Temperature)
|
||||
maxTimedValue(&day.MaxPrecipitationProbability, daypart.MaxPrecipitationProbability)
|
||||
maxTimedValue(&day.PeakWindGust, daypart.PeakWindGust)
|
||||
if daypart.DominantCondition != "" {
|
||||
conditions[daypart.DominantCondition] = struct{}{}
|
||||
}
|
||||
for _, risk := range hazardsForIndicators(daypart.Indicators) {
|
||||
risks[risk] = struct{}{}
|
||||
}
|
||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
|
||||
risks["precipitation"] = struct{}{}
|
||||
}
|
||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||
risks["wind"] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, alert := range summary.AlertOverlaps {
|
||||
if alert.Event != "" {
|
||||
risks[alert.Event] = struct{}{}
|
||||
}
|
||||
}
|
||||
day.Risks = sortedSet(risks)
|
||||
day.OverallCharacter = outlookCharacter(sortedSet(conditions), day.Risks)
|
||||
return day
|
||||
}
|
||||
|
||||
func outlookCharacter(conditions []string, risks []string) string {
|
||||
if len(conditions) == 0 && len(risks) == 0 {
|
||||
return "Quiet weather is expected."
|
||||
}
|
||||
parts := []string{}
|
||||
if len(conditions) > 0 {
|
||||
parts = append(parts, strings.Join(conditions, "; "))
|
||||
}
|
||||
if len(risks) > 0 {
|
||||
parts = append(parts, "risks: "+strings.Join(risks, "; "))
|
||||
}
|
||||
return strings.Join(parts, ". ") + "."
|
||||
}
|
||||
83
internal/briefing/three_day_test.go
Normal file
83
internal/briefing/three_day_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
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 TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved, err := report.Resolve(report.ThreeDay, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T06:00:00-05:00"),
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve 3-day: %v", err)
|
||||
}
|
||||
precip := 70.0
|
||||
gust := 35.0
|
||||
summaries := []forecast.DailySummary{
|
||||
{
|
||||
Date: "2026-05-29",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParse("2026-05-29T06:00:00-05:00"),
|
||||
End: mustParse("2026-05-30T00:00:00-05:00"),
|
||||
},
|
||||
Dayparts: []forecast.DaypartSummary{
|
||||
{
|
||||
Name: "morning",
|
||||
DominantCondition: "Showers and thunderstorms",
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: precip,
|
||||
Time: mustParse("2026-05-29T09:00:00-05:00"),
|
||||
},
|
||||
PeakWindGust: &forecast.TimedValue{
|
||||
Value: gust,
|
||||
Time: mustParse("2026-05-29T10:00:00-05:00"),
|
||||
},
|
||||
Indicators: forecast.Indicators{Thunder: true, Wind: true},
|
||||
},
|
||||
},
|
||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
||||
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Unsettled stretch."}},
|
||||
},
|
||||
{
|
||||
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: "Clear"}},
|
||||
},
|
||||
}
|
||||
|
||||
pkg, err := BuildThreeDay(BuildContext{
|
||||
Resolved: resolved,
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
}, summaries)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildThreeDay() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Metadata.ReportID != report.ThreeDay {
|
||||
t.Fatalf("ReportID = %q, want three_day", pkg.Metadata.ReportID)
|
||||
}
|
||||
if pkg.ThreeDay == nil {
|
||||
t.Fatal("ThreeDay = nil")
|
||||
}
|
||||
if len(pkg.ThreeDay.Days) != 2 {
|
||||
t.Fatalf("Days length = %d, want 2", len(pkg.ThreeDay.Days))
|
||||
}
|
||||
first := pkg.ThreeDay.Days[0]
|
||||
if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "thunder") {
|
||||
t.Fatalf("first day = %#v, want conditions and risks", first)
|
||||
}
|
||||
if len(pkg.ThreeDay.RelevantAlerts) != 1 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user