From 96ce0edd4645c8dba7f260b13f2556a31f3e5209 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 12 Jun 2026 15:05:42 +0000 Subject: [PATCH] Add SPC convective discussion briefing module --- docs/internal/briefing.md | 5 +- docs/internal/module.md | 5 +- internal/briefing/modules.go | 10 ++ .../spc_convective_discussion_module.go | 71 ++++++++ .../spc_convective_discussion_module_test.go | 155 ++++++++++++++++++ 5 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 internal/briefing/spc_convective_discussion_module.go create mode 100644 internal/briefing/spc_convective_discussion_module_test.go diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index 380232e..6fda4dd 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -29,7 +29,8 @@ Outputs: - `module.Output` values for source-oriented stanzas: `metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`, `alert_digest`, `spc_convective_outlooks`, - `area_forecast_discussion`, and `weather_story` + `area_forecast_discussion`, `spc_convective_discussion`, and + `weather_story` - `module.Output` values for derived stanzas: `derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`, `outdoor_windows`, and `tomorrow_planning` @@ -90,6 +91,8 @@ return an error for invalid required inputs. - SPC convective outlook output distinguishes checked empty outlook data from missing outlook source data and omits GeoJSON geometry from prompt-facing fields. +- SPC convective discussion output is omitted unless a retained outlook meets + the internal severity threshold and matching discussion text is available. ## Tests diff --git a/docs/internal/module.md b/docs/internal/module.md index 5492298..6dbb9d9 100644 --- a/docs/internal/module.md +++ b/docs/internal/module.md @@ -39,6 +39,7 @@ The registry recognizes these IDs: - `alert_digest` - `spc_convective_outlooks` - `area_forecast_discussion` +- `spc_convective_discussion` - `weather_story` - `outdoor_windows` - `tomorrow_planning` @@ -46,10 +47,6 @@ The registry recognizes these IDs: Every registered module has a builder. Report composition entries that refer to unknown or unimplemented module IDs fail validation instead of being skipped. -The package also defines the `spc_convective_discussion` ID and empty option -struct for the collected contract. It is not a registered module in the -briefing registry until its builder exists. - ## Options Most modules use an empty options struct. `area_forecast_discussion` accepts: diff --git a/internal/briefing/modules.go b/internal/briefing/modules.go index 50f5f52..3d543fd 100644 --- a/internal/briefing/modules.go +++ b/internal/briefing/modules.go @@ -346,6 +346,16 @@ func defaultModuleDefinitions() []ModuleDefinition { MissingData: module.MissingDataOmit, Builder: buildAreaForecastDiscussionModule, }, + { + ID: module.SPCConvectiveDiscussion, + StanzaName: string(module.SPCConvectiveDiscussion), + DefaultOptions: module.SPCConvectiveDiscussionOptions{}, + RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks}, + RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks}, + SupportedReports: allReports, + MissingData: module.MissingDataOmit, + Builder: buildSPCConvectiveDiscussionModule, + }, { ID: module.WeatherStory, StanzaName: "weather_story", diff --git a/internal/briefing/spc_convective_discussion_module.go b/internal/briefing/spc_convective_discussion_module.go new file mode 100644 index 0000000..3b1b4dd --- /dev/null +++ b/internal/briefing/spc_convective_discussion_module.go @@ -0,0 +1,71 @@ +package briefing + +import ( + "fmt" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" +) + +const defaultSPCConvectiveDiscussionMinimumSeverityRank = 3 + +type SPCConvectiveDiscussionModule struct { + IncludedBecause string `json:"included_because"` + Discussions []SPCConvectiveDiscussionRecord `json:"discussions"` +} + +type SPCConvectiveDiscussionRecord struct { + Day int `json:"day,omitempty"` + Headline string `json:"headline,omitempty"` + Summary string `json:"summary,omitempty"` + Discussion string `json:"discussion,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +func buildSPCConvectiveDiscussionModule(ctx ModuleContext, _ any) (*module.Output, error) { + if !hasSPCConvectiveDiscussionThreshold(ctx.Derived.SPCConvectiveOutlooks) { + return nil, nil + } + records := spcConvectiveDiscussionRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Derived.SPCConvectiveDiscussions, ctx.Timezone) + if len(records) == 0 { + return nil, nil + } + value := SPCConvectiveDiscussionModule{ + IncludedBecause: fmt.Sprintf("severity_rank >= %d", defaultSPCConvectiveDiscussionMinimumSeverityRank), + Discussions: records, + } + return &module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: value}, nil +} + +func hasSPCConvectiveDiscussionThreshold(outlooks []weatherdata.ConvectiveOutlook) bool { + for _, outlook := range outlooks { + if outlook.SeverityRank != nil && *outlook.SeverityRank >= defaultSPCConvectiveDiscussionMinimumSeverityRank { + return true + } + } + return false +} + +func spcConvectiveDiscussionRecords(outlooks []weatherdata.ConvectiveOutlook, discussions []weatherdata.ConvectiveOutlookDiscussion, timezone string) []SPCConvectiveDiscussionRecord { + retainedDays := map[int]struct{}{} + for _, outlook := range outlooks { + retainedDays[outlook.Day] = struct{}{} + } + records := make([]SPCConvectiveDiscussionRecord, 0, len(discussions)) + for _, discussion := range discussions { + if _, ok := retainedDays[discussion.Day]; !ok { + continue + } + if discussion.Discussion == "" { + continue + } + records = append(records, SPCConvectiveDiscussionRecord{ + Day: discussion.Day, + Headline: discussion.Headline, + Summary: discussion.Summary, + Discussion: discussion.Discussion, + UpdatedAt: friendlyOptionalTime(discussion.UpdatedAt, timezone), + }) + } + return records +} diff --git a/internal/briefing/spc_convective_discussion_module_test.go b/internal/briefing/spc_convective_discussion_module_test.go new file mode 100644 index 0000000..0c50c2a --- /dev/null +++ b/internal/briefing/spc_convective_discussion_module_test.go @@ -0,0 +1,155 @@ +package briefing + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" +) + +func TestSPCConvectiveDiscussionModuleOmitsBelowThresholdButOutlookRemains(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := spcConvectiveDiscussionContext(2, []weatherdata.ConvectiveOutlookDiscussion{ + spcDiscussion(1, "Lower risk", "General thunderstorms.", "No organized severe weather is expected.", "2026-05-29T08:30:00-05:00"), + }) + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion}) + if err != nil { + t.Fatalf("BuildModule(discussion) error = %v", err) + } + if output != nil { + t.Fatalf("discussion output = %#v, want omitted below threshold", output) + } + + outlookOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks}) + if err != nil { + t.Fatalf("BuildModule(outlooks) error = %v", err) + } + outlookValue := moduleValue[SPCConvectiveOutlooksModule](t, outlookOutput) + if !outlookValue.Checked || outlookValue.OutlookCount != 1 { + t.Fatalf("outlook value = %#v, want lower-risk outlook still emitted", outlookValue) + } +} + +func TestSPCConvectiveDiscussionModuleIncludesEqualThresholdDiscussion(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{ + spcDiscussion(1, "Severe storms possible", "Scattered severe storms are possible.", "A few storms may become severe during the afternoon.", "2026-05-29T08:30:00-05:00"), + }) + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + if output == nil || output.ID != module.SPCConvectiveDiscussion || output.StanzaName != "spc_convective_discussion" { + t.Fatalf("output = %#v, want spc convective discussion stanza", output) + } + value := moduleValue[SPCConvectiveDiscussionModule](t, output) + if value.IncludedBecause != "severity_rank >= 3" || len(value.Discussions) != 1 { + t.Fatalf("value = %#v, want threshold reason and one discussion", value) + } + discussion := value.Discussions[0] + if discussion.Day != 1 || discussion.Headline != "Severe storms possible" || discussion.Summary == "" || discussion.Discussion == "" { + t.Fatalf("discussion = %#v, want prompt-facing discussion fields", discussion) + } + if discussion.UpdatedAt != "2026-05-29 at 8:30 AM" { + t.Fatalf("UpdatedAt = %q, want friendly local time", discussion.UpdatedAt) + } + data, err := json.Marshal(output.Value) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + text := string(data) + for _, field := range []string{"included_because", "discussions", "headline", "summary", "discussion", "updated_at"} { + if !strings.Contains(text, field) { + t.Fatalf("json = %s, want field %s", text, field) + } + } +} + +func TestSPCConvectiveDiscussionModuleIncludesAboveThresholdDiscussion(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := spcConvectiveDiscussionContext(4, []weatherdata.ConvectiveOutlookDiscussion{ + spcDiscussion(1, "Enhanced severe risk", "Numerous severe storms are possible.", "Severe storms may produce damaging winds.", "2026-05-29T09:15:00-05:00"), + }) + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + value := moduleValue[SPCConvectiveDiscussionModule](t, output) + if len(value.Discussions) != 1 || value.Discussions[0].Headline != "Enhanced severe risk" { + t.Fatalf("value = %#v, want above-threshold discussion", value) + } +} + +func TestSPCConvectiveDiscussionModuleOmitsMissingDiscussionText(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{ + {Day: 1, Headline: "Severe storms possible", Summary: "Scattered severe storms are possible.", UpdatedAt: ptrModuleTime("2026-05-29T08:30:00-05:00")}, + }) + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + if output != nil { + t.Fatalf("output = %#v, want omitted when discussion text is missing", output) + } +} + +func TestSPCConvectiveDiscussionModuleOmitsMissingSource(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := testModuleContext() + ctx.Collected.SPCConvectiveOutlooks = nil + ctx.Derived.SPCConvectiveOutlooks = nil + ctx.Derived.SPCConvectiveDiscussions = nil + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + if output != nil { + t.Fatalf("output = %#v, want omitted for missing optional source", output) + } +} + +func spcConvectiveDiscussionContext(rank int, discussions []weatherdata.ConvectiveOutlookDiscussion) ModuleContext { + ctx := testModuleContext() + outlook := weatherdata.ConvectiveOutlook{ + ID: "day1-categorical", + Day: 1, + OutlookType: "categorical", + Label: "SLGT", + LabelText: "Slight Risk", + SeverityRank: &rank, + ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"), + ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"), + } + ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{ + AsOf: ptrModuleTime("2026-05-29T08:00:00-05:00"), + IssuedAt: ptrModuleTime("2026-05-29T07:45:00-05:00"), + Outlooks: []weatherdata.ConvectiveOutlook{outlook}, + } + ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook} + ctx.Derived.SPCConvectiveDiscussions = discussions + return ctx +} + +func spcDiscussion(day int, headline string, summary string, discussion string, updatedAt string) weatherdata.ConvectiveOutlookDiscussion { + return weatherdata.ConvectiveOutlookDiscussion{ + Day: day, + Headline: headline, + Summary: summary, + Discussion: discussion, + UpdatedAt: ptrModuleTime(updatedAt), + } +} + +func ptrModuleTime(value string) *time.Time { + parsed := mustParseModuleTime(value) + return &parsed +}