From 3389d4fa93e838ad44708934460288e8d73d5ae0 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 12 Jun 2026 14:56:17 +0000 Subject: [PATCH] Derive report-period SPC convective outlooks --- docs/internal/facts.md | 10 +- internal/briefing/modules.go | 2 + internal/briefing/modules_test.go | 12 +++ internal/facts/facts.go | 84 ++++++++++++++++ internal/facts/facts_test.go | 162 ++++++++++++++++++++++++++++++ 5 files changed, 269 insertions(+), 1 deletion(-) diff --git a/docs/internal/facts.md b/docs/internal/facts.md index e68028d..99b0426 100644 --- a/docs/internal/facts.md +++ b/docs/internal/facts.md @@ -23,12 +23,16 @@ Outputs: provenance and warnings. SPC convective outlook source data is carried through when present in the bundle. - `facts.DerivedFacts` with valid-period forecast slices, alert overlaps, - daily summaries, daypart summaries, and Storm Report window summary + report-period SPC convective outlooks and discussions, daily summaries, + daypart summaries, and Storm Report window summary ## Boundaries - This package owns fact assembly and reusable deterministic derivation for a report run. +- SPC convective outlook derivation selects already-collected outlooks whose + half-open valid intervals overlap the resolved report period and retains + discussions for represented outlook days. - It does not fetch upstream data, build prompt wording, compare prior snapshots, write workflow state, invoke Scriptorium, or define modules. @@ -57,6 +61,8 @@ and inspection. - Missing optional narrative, alert, discussion, daily, or weather story data produces empty or nil derived fields. - Missing optional SPC convective outlook data produces a nil collected field. +- A present SPC convective outlook source with no report-period matches + produces non-nil empty derived outlook and discussion slices. ## Tests @@ -69,6 +75,8 @@ Inspect: - Collected facts are built once from a fetched bundle. - Derived facts are scoped to one resolved report. +- SPC convective outlook selection uses the resolved report period, not + server-current active filtering. - Source provenance and warnings stay separate from ordinary fact fields. - Prompt-specific wording and one-off presentation decisions stay outside this package. diff --git a/internal/briefing/modules.go b/internal/briefing/modules.go index 77a6a9b..bf0f740 100644 --- a/internal/briefing/modules.go +++ b/internal/briefing/modules.go @@ -185,6 +185,8 @@ func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) return len(ctx.Derived.DaypartSummaries) > 0 case module.RequiresDerivedPrecipTiming: return true + case module.RequiresDerivedSPCConvectiveOutlooks: + return ctx.Derived.SPCConvectiveOutlooks != nil default: return false } diff --git a/internal/briefing/modules_test.go b/internal/briefing/modules_test.go index fd00847..350d21b 100644 --- a/internal/briefing/modules_test.go +++ b/internal/briefing/modules_test.go @@ -142,6 +142,18 @@ func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) { } } +func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) { + ctx := ModuleContext{} + if derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) { + t.Fatal("derivedFactAvailable() = true, want false without derived outlooks") + } + + ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}} + if !derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) { + t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks") + } +} + func noopModuleBuilder(ModuleContext, any) (*module.Output, error) { return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil } diff --git a/internal/facts/facts.go b/internal/facts/facts.go index 00b1495..7d51dde 100644 --- a/internal/facts/facts.go +++ b/internal/facts/facts.go @@ -3,6 +3,7 @@ package facts import ( "fmt" + "sort" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" @@ -76,6 +77,8 @@ type DerivedFacts struct { ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod ValidPeriodDailyPeriods []weatherdata.ForecastPeriod AlertOverlaps []forecast.AlertOverlap + SPCConvectiveOutlooks []weatherdata.ConvectiveOutlook + SPCConvectiveDiscussions []weatherdata.ConvectiveOutlookDiscussion DailySummaries []forecast.DailySummary DaypartSummaries []forecast.DaypartSummary PrecipTiming forecast.PrecipTiming @@ -99,11 +102,14 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) { } bundle := req.Collected.Bundle() period := req.Resolved.ValidPeriod + spcOutlooks, spcDiscussions := selectSPCConvectiveOutlooks(req.Collected.SPCConvectiveOutlooks, period) derived := DerivedFacts{ ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period), ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period), ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period), AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period), + SPCConvectiveOutlooks: spcOutlooks, + SPCConvectiveDiscussions: spcDiscussions, } derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods) @@ -142,3 +148,81 @@ func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary { } return out } + +func selectSPCConvectiveOutlooks(run *weatherdata.ConvectiveOutlookRun, period timeutil.Period) ([]weatherdata.ConvectiveOutlook, []weatherdata.ConvectiveOutlookDiscussion) { + if run == nil { + return nil, nil + } + + outlooks := make([]weatherdata.ConvectiveOutlook, 0, len(run.Outlooks)) + days := map[int]struct{}{} + for _, outlook := range run.Outlooks { + outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo} + if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(period) { + continue + } + outlooks = append(outlooks, outlook) + days[outlook.Day] = struct{}{} + } + sort.SliceStable(outlooks, func(i, j int) bool { + left := outlooks[i] + right := outlooks[j] + if left.Day != right.Day { + return left.Day < right.Day + } + if left.OutlookType != right.OutlookType { + return left.OutlookType < right.OutlookType + } + leftRank, leftRankOK := severityRank(left) + rightRank, rightRankOK := severityRank(right) + if leftRankOK != rightRankOK { + return leftRankOK + } + if leftRankOK && leftRank != rightRank { + return leftRank > rightRank + } + if !left.ValidFrom.Equal(right.ValidFrom) { + return left.ValidFrom.Before(right.ValidFrom) + } + if left.Label != right.Label { + return left.Label < right.Label + } + return left.ID < right.ID + }) + + discussions := make([]weatherdata.ConvectiveOutlookDiscussion, 0, len(run.Discussions)) + for _, discussion := range run.Discussions { + if _, ok := days[discussion.Day]; ok { + discussions = append(discussions, discussion) + } + } + sort.SliceStable(discussions, func(i, j int) bool { + left := discussions[i] + right := discussions[j] + if left.Day != right.Day { + return left.Day < right.Day + } + if left.UpdatedAt != nil && right.UpdatedAt != nil && !left.UpdatedAt.Equal(*right.UpdatedAt) { + return left.UpdatedAt.Before(*right.UpdatedAt) + } + if (left.UpdatedAt != nil) != (right.UpdatedAt != nil) { + return left.UpdatedAt != nil + } + if left.Headline != right.Headline { + return left.Headline < right.Headline + } + if left.Summary != right.Summary { + return left.Summary < right.Summary + } + return left.Discussion < right.Discussion + }) + + return outlooks, discussions +} + +func severityRank(outlook weatherdata.ConvectiveOutlook) (int, bool) { + if outlook.SeverityRank == nil { + return 0, false + } + return *outlook.SeverityRank, true +} diff --git a/internal/facts/facts_test.go b/internal/facts/facts_test.go index ee9214c..7e3a04c 100644 --- a/internal/facts/facts_test.go +++ b/internal/facts/facts_test.go @@ -2,6 +2,7 @@ package facts import ( "encoding/json" + "strings" "testing" "time" @@ -181,6 +182,108 @@ func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) { } } +func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) { + location := testLocation() + now := mustParse("2026-05-29T08:00:00-05:00") + bundle := testBundle(location) + bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location) + + tests := []struct { + name string + resolved report.Resolved + wantOutlookIDs []string + wantDiscussion []string + }{ + { + name: "daily today", + resolved: resolveForTest(t, report.DailyToday, now, location), + wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"}, + wantDiscussion: []string{"day1 early", "day1 late"}, + }, + { + name: "daily tomorrow", + resolved: resolveForTest(t, report.DailyTomorrow, now, location), + wantOutlookIDs: []string{"sat-enhanced"}, + wantDiscussion: []string{"day2"}, + }, + { + name: "three day", + resolved: resolveForTest(t, report.ThreeDay, now, location), + wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic", "sat-enhanced", "sun-slight"}, + wantDiscussion: []string{"day1 early", "day1 late", "day2", "day3"}, + }, + { + name: "weekend", + resolved: resolveForTest(t, report.Weekend, now, location), + wantOutlookIDs: []string{"sat-enhanced", "sun-slight"}, + wantDiscussion: []string{"day2", "day3"}, + }, + { + name: "storm", + resolved: resolveStormForTest(t, location), + wantOutlookIDs: []string{"fri-storm", "fri-low"}, + wantDiscussion: []string{"day1 early", "day1 late"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + derived, err := BuildDerived(BuildDerivedRequest{ + Resolved: tt.resolved, + Timezone: location.String(), + Dayparts: testDayparts(), + Collected: BuildCollected(bundle), + }) + if err != nil { + t.Fatalf("BuildDerived() error = %v", err) + } + if got := outlookIDs(derived.SPCConvectiveOutlooks); strings.Join(got, ",") != strings.Join(tt.wantOutlookIDs, ",") { + t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, tt.wantOutlookIDs) + } + if got := discussionHeadlines(derived.SPCConvectiveDiscussions); strings.Join(got, ",") != strings.Join(tt.wantDiscussion, ",") { + t.Fatalf("SPCConvectiveDiscussions = %#v, want %#v", got, tt.wantDiscussion) + } + }) + } +} + +func TestBuildDerivedSPCConvectiveOutlooksDistinguishesMissingAndCheckedEmpty(t *testing.T) { + location := testLocation() + resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location) + bundle := testBundle(location) + bundle.SPCConvectiveOutlooks = nil + + derived, err := BuildDerived(BuildDerivedRequest{ + Resolved: resolved, + Timezone: location.String(), + Dayparts: testDayparts(), + Collected: BuildCollected(bundle), + }) + if err != nil { + t.Fatalf("BuildDerived(missing source) error = %v", err) + } + if derived.SPCConvectiveOutlooks != nil || derived.SPCConvectiveDiscussions != nil { + t.Fatalf("missing source derived outlooks=%#v discussions=%#v, want nil slices", derived.SPCConvectiveOutlooks, derived.SPCConvectiveDiscussions) + } + + bundle.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{Outlooks: []weatherdata.ConvectiveOutlook{}, Discussions: []weatherdata.ConvectiveOutlookDiscussion{}} + derived, err = BuildDerived(BuildDerivedRequest{ + Resolved: resolved, + Timezone: location.String(), + Dayparts: testDayparts(), + Collected: BuildCollected(bundle), + }) + if err != nil { + t.Fatalf("BuildDerived(checked empty source) error = %v", err) + } + if derived.SPCConvectiveOutlooks == nil || len(derived.SPCConvectiveOutlooks) != 0 { + t.Fatalf("checked empty outlooks = %#v, want non-nil empty slice", derived.SPCConvectiveOutlooks) + } + if derived.SPCConvectiveDiscussions == nil || len(derived.SPCConvectiveDiscussions) != 0 { + t.Fatalf("checked empty discussions = %#v, want non-nil empty slice", derived.SPCConvectiveDiscussions) + } +} + func testBundle(location *time.Location) *weatherdata.Bundle { return &weatherdata.Bundle{ FetchedAt: mustParse("2026-05-29T10:00:00Z"), @@ -208,6 +311,65 @@ func testBundle(location *time.Location) *weatherdata.Bundle { } } +func testConvectiveOutlookRun(location *time.Location) *weatherdata.ConvectiveOutlookRun { + rank1 := 1 + rank2 := 2 + rank3 := 3 + rank4 := 4 + rank5 := 5 + return &weatherdata.ConvectiveOutlookRun{ + Outlooks: []weatherdata.ConvectiveOutlook{ + convectiveOutlook(location, "mon-outside", 4, "categorical", "MDT", &rank5, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00"), + convectiveOutlook(location, "sun-slight", 3, "categorical", "SLGT", &rank3, "2026-05-31T06:00:00-05:00", "2026-06-01T00:00:00-05:00"), + convectiveOutlook(location, "fri-low", 1, "categorical", "MRGL", &rank1, "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00"), + convectiveOutlook(location, "fri-missing-rank", 1, "categorical", "GEN", nil, "2026-05-29T08:00:00-05:00", "2026-05-29T10:00:00-05:00"), + convectiveOutlook(location, "sat-enhanced", 2, "categorical", "ENH", &rank4, "2026-05-30T01:00:00-05:00", "2026-05-30T12:00:00-05:00"), + convectiveOutlook(location, "fri-high", 1, "categorical", "SLGT", &rank3, "2026-05-29T07:00:00-05:00", "2026-05-29T11:00:00-05:00"), + convectiveOutlook(location, "fri-probabilistic", 1, "probabilistic", "5%", &rank2, "2026-05-29T05:00:00-05:00", "2026-05-29T10:00:00-05:00"), + convectiveOutlook(location, "fri-storm", 1, "categorical", "SLGT", &rank2, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00"), + }, + Discussions: []weatherdata.ConvectiveOutlookDiscussion{ + {Day: 4, Headline: "day4", UpdatedAt: ptrTime(mustParse("2026-05-31T10:00:00-05:00"))}, + {Day: 1, Headline: "day1 late", UpdatedAt: ptrTime(mustParse("2026-05-29T09:00:00-05:00"))}, + {Day: 3, Headline: "day3", UpdatedAt: ptrTime(mustParse("2026-05-31T08:00:00-05:00"))}, + {Day: 1, Headline: "day1 early", UpdatedAt: ptrTime(mustParse("2026-05-29T08:00:00-05:00"))}, + {Day: 2, Headline: "day2", UpdatedAt: ptrTime(mustParse("2026-05-30T08:00:00-05:00"))}, + }, + } +} + +func convectiveOutlook(location *time.Location, id string, day int, outlookType string, label string, rank *int, validFrom string, validTo string) weatherdata.ConvectiveOutlook { + return weatherdata.ConvectiveOutlook{ + ID: id, + Day: day, + OutlookType: outlookType, + Label: label, + SeverityRank: rank, + ValidFrom: mustParse(validFrom).In(location), + ValidTo: mustParse(validTo).In(location), + } +} + +func outlookIDs(outlooks []weatherdata.ConvectiveOutlook) []string { + out := make([]string, 0, len(outlooks)) + for _, outlook := range outlooks { + out = append(out, outlook.ID) + } + return out +} + +func discussionHeadlines(discussions []weatherdata.ConvectiveOutlookDiscussion) []string { + out := make([]string, 0, len(discussions)) + for _, discussion := range discussions { + out = append(out, discussion.Headline) + } + return out +} + +func ptrTime(value time.Time) *time.Time { + return &value +} + func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod { temperature := 70.0 return weatherdata.ForecastPeriod{