Add 3-Day Outlook generation
This commit is contained in:
@@ -100,6 +100,62 @@ func BuildDailySummary(bundle *Bundle, date time.Time, location *time.Location,
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func BuildPeriodDailySummaries(bundle *Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) ([]DailySummary, error) {
|
||||
if !period.IsValid() {
|
||||
return nil, fmt.Errorf("valid forecast period is required")
|
||||
}
|
||||
if location == nil {
|
||||
location = time.UTC
|
||||
}
|
||||
var summaries []DailySummary
|
||||
for day := timeutil.CivilDay(period.Start, location); day.Start.Before(period.End); day = timeutil.CivilDay(day.Start.AddDate(0, 0, 1), location) {
|
||||
overlap, ok := day.Intersection(period)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
summary, err := buildDailySummaryForPeriod(bundle, overlap, location, dayparts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summaries = append(summaries, *summary)
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func buildDailySummaryForPeriod(bundle *Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
|
||||
if bundle == nil {
|
||||
return nil, fmt.Errorf("forecast bundle is required")
|
||||
}
|
||||
if bundle.Hourly == nil || len(bundle.Hourly.Periods) == 0 {
|
||||
return nil, fmt.Errorf("hourly forecast data is required")
|
||||
}
|
||||
windows, err := ResolveDayparts(period.Start, location, dayparts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
alerts := AlertOverlaps(bundle.Alerts, period)
|
||||
summary := &DailySummary{
|
||||
Date: period.Start.In(location).Format(timeutil.DateLayout),
|
||||
Period: period,
|
||||
NarrativePeriods: SelectNarrativePeriods(bundle, period),
|
||||
AlertOverlaps: alerts,
|
||||
Discussion: SelectDiscussion(bundle),
|
||||
SourceWarnings: bundle.Warnings,
|
||||
SourceProvenance: bundle.Sources,
|
||||
}
|
||||
for _, window := range windows {
|
||||
clipped, ok := window.Period.Intersection(period)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
periods := SelectHourlyPeriods(bundle.Hourly, clipped)
|
||||
daypartSummary := SummarizeDaypart(window.Name, clipped, periods)
|
||||
daypartSummary.AlertOverlaps = overlapsWithin(alerts, clipped)
|
||||
summary.Dayparts = append(summary.Dayparts, daypartSummary)
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPeriod {
|
||||
if run == nil {
|
||||
return nil
|
||||
|
||||
@@ -153,6 +153,41 @@ func TestBuildDailySummaryRequiresHourlyData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) {
|
||||
location := time.FixedZone("Test", -5*60*60)
|
||||
bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{
|
||||
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 50, nil, nil, nil, nil),
|
||||
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, nil, ptr(60), nil, nil),
|
||||
hour(location, "2026-05-30T14:00:00-05:00", "2026-05-30T15:00:00-05:00", "Hot", 95, nil, nil, nil, nil),
|
||||
hour(location, "2026-05-31T20:00:00-05:00", "2026-05-31T21:00:00-05:00", "Wind", 70, nil, nil, nil, ptr(35)),
|
||||
}}}
|
||||
period := timeutil.Period{
|
||||
Start: mustParse("2026-05-29T07:00:00-05:00").In(location),
|
||||
End: mustParse("2026-06-01T00:00:00-05:00").In(location),
|
||||
}
|
||||
|
||||
summaries, err := BuildPeriodDailySummaries(bundle, period, location, []DaypartDefinition{
|
||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
||||
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
||||
{Name: "evening", Start: "18:00", End: "24:00"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildPeriodDailySummaries() error = %v", err)
|
||||
}
|
||||
if len(summaries) != 3 {
|
||||
t.Fatalf("summaries length = %d, want 3", len(summaries))
|
||||
}
|
||||
if summaries[0].Period.Start.Format(time.RFC3339) != "2026-05-29T07:00:00-05:00" {
|
||||
t.Fatalf("first period start = %s, want clipped start", summaries[0].Period.Start.Format(time.RFC3339))
|
||||
}
|
||||
if len(summaries[0].Dayparts[0].HourlyPeriods) != 1 || summaries[0].Dayparts[0].HourlyPeriods[0].TextDescription != "Showers" {
|
||||
t.Fatalf("first morning periods = %#v, want only post-start hour", summaries[0].Dayparts[0].HourlyPeriods)
|
||||
}
|
||||
if summaries[2].Dayparts[2].PeakWindGust == nil || summaries[2].Dayparts[2].PeakWindGust.Value != 35 {
|
||||
t.Fatalf("third evening gust = %#v, want 35", summaries[2].Dayparts[2].PeakWindGust)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertOverlap(t *testing.T) {
|
||||
location := time.FixedZone("Test", -5*60*60)
|
||||
raw := json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"}`)
|
||||
|
||||
Reference in New Issue
Block a user