From 3bcccb4a7b3e30f0eafaf5075e7aca49a7d8de6d Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 12 Jun 2026 14:47:42 +0000 Subject: [PATCH] Add SPC convective outlook data contracts --- docs/internal/facts.md | 4 +- docs/internal/module.md | 4 ++ docs/internal/weather-data.md | 3 +- internal/briefing/modules.go | 2 + internal/briefing/modules_test.go | 14 ++++++ internal/facts/facts.go | 65 ++++++++++++------------ internal/facts/facts_test.go | 19 ++++++- internal/module/module.go | 32 +++++++----- internal/module/module_test.go | 18 +++++++ internal/weatherdata/bundle.go | 62 +++++++++++++++++++---- internal/weatherdata/bundle_test.go | 78 +++++++++++++++++++++++++++++ 11 files changed, 242 insertions(+), 59 deletions(-) create mode 100644 internal/weatherdata/bundle_test.go diff --git a/docs/internal/facts.md b/docs/internal/facts.md index 834d477..e68028d 100644 --- a/docs/internal/facts.md +++ b/docs/internal/facts.md @@ -20,7 +20,8 @@ Inputs: Outputs: - `facts.CollectedFacts` with normalized source facts plus separate source - provenance and warnings + 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 @@ -55,6 +56,7 @@ and inspection. derivation error for reports that require daily summaries. - 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. ## Tests diff --git a/docs/internal/module.md b/docs/internal/module.md index 4e02769..0385d83 100644 --- a/docs/internal/module.md +++ b/docs/internal/module.md @@ -45,6 +45,10 @@ 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 `spc_convective_outlooks` and +`spc_convective_discussion` IDs and empty option structs for the collected +contract. They are not registered modules in the briefing registry. + ## Options Most modules use an empty options struct. `area_forecast_discussion` accepts: diff --git a/docs/internal/weather-data.md b/docs/internal/weather-data.md index 48c40e1..594ac95 100644 --- a/docs/internal/weather-data.md +++ b/docs/internal/weather-data.md @@ -21,7 +21,8 @@ Outputs: - `weatherdata.Bundle` with observation, current conditions, hourly forecast, narrative forecast, active alerts, discussion, latest weather story, source - records, and source warnings + records, source warnings, and an optional typed SPC convective outlook field + when that source has been populated - optional saved bundle JSON through app fetch helpers ## Boundaries diff --git a/internal/briefing/modules.go b/internal/briefing/modules.go index 11747f0..77a6a9b 100644 --- a/internal/briefing/modules.go +++ b/internal/briefing/modules.go @@ -162,6 +162,8 @@ func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContex return ctx.Collected.Discussion != nil case module.CollectedWeatherStory: return ctx.Collected.WeatherStory != nil + case module.CollectedSPCConvectiveOutlooks: + return ctx.Collected.SPCConvectiveOutlooks != nil case module.CollectedSourceMetadata: return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0 default: diff --git a/internal/briefing/modules_test.go b/internal/briefing/modules_test.go index 9717895..fd00847 100644 --- a/internal/briefing/modules_test.go +++ b/internal/briefing/modules_test.go @@ -4,8 +4,10 @@ import ( "strings" "testing" + "gitea.maximumdirect.net/eric/weatherreporter/internal/facts" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) { @@ -128,6 +130,18 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) { } } +func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) { + ctx := ModuleContext{} + if collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) { + t.Fatal("collectedFactAvailable() = true, want false without source") + } + + ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}} + if !collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) { + t.Fatal("collectedFactAvailable() = false, want true with checked source") + } +} + 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 5556611..00b1495 100644 --- a/internal/facts/facts.go +++ b/internal/facts/facts.go @@ -12,15 +12,16 @@ import ( ) type CollectedFacts struct { - FetchedAt time.Time - Observation *weatherdata.Observation - Current *weatherdata.Current - Hourly *weatherdata.ForecastRun - Narrative *weatherdata.ForecastRun - Alerts *weatherdata.AlertRun - Discussion *weatherdata.Discussion - Daily *weatherdata.ForecastRun - WeatherStory *weatherdata.WeatherStory + FetchedAt time.Time + Observation *weatherdata.Observation + Current *weatherdata.Current + Hourly *weatherdata.ForecastRun + Narrative *weatherdata.ForecastRun + Alerts *weatherdata.AlertRun + Discussion *weatherdata.Discussion + Daily *weatherdata.ForecastRun + WeatherStory *weatherdata.WeatherStory + SPCConvectiveOutlooks *weatherdata.ConvectiveOutlookRun SourceProvenance []weatherdata.Source SourceWarnings []weatherdata.SourceWarning @@ -31,33 +32,35 @@ func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts { return CollectedFacts{} } return CollectedFacts{ - FetchedAt: bundle.FetchedAt, - Observation: bundle.Observation, - Current: bundle.Current, - Hourly: bundle.Hourly, - Narrative: bundle.Narrative, - Alerts: bundle.Alerts, - Discussion: bundle.Discussion, - Daily: bundle.Daily, - WeatherStory: bundle.WeatherStory, - SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...), - SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...), + FetchedAt: bundle.FetchedAt, + Observation: bundle.Observation, + Current: bundle.Current, + Hourly: bundle.Hourly, + Narrative: bundle.Narrative, + Alerts: bundle.Alerts, + Discussion: bundle.Discussion, + Daily: bundle.Daily, + WeatherStory: bundle.WeatherStory, + SPCConvectiveOutlooks: bundle.SPCConvectiveOutlooks, + SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...), + SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...), } } func (f CollectedFacts) Bundle() *weatherdata.Bundle { return &weatherdata.Bundle{ - FetchedAt: f.FetchedAt, - Observation: f.Observation, - Current: f.Current, - Hourly: f.Hourly, - Narrative: f.Narrative, - Alerts: f.Alerts, - Discussion: f.Discussion, - Daily: f.Daily, - WeatherStory: f.WeatherStory, - Sources: append([]weatherdata.Source(nil), f.SourceProvenance...), - Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...), + FetchedAt: f.FetchedAt, + Observation: f.Observation, + Current: f.Current, + Hourly: f.Hourly, + Narrative: f.Narrative, + Alerts: f.Alerts, + Discussion: f.Discussion, + Daily: f.Daily, + WeatherStory: f.WeatherStory, + SPCConvectiveOutlooks: f.SPCConvectiveOutlooks, + Sources: append([]weatherdata.Source(nil), f.SourceProvenance...), + Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...), } } diff --git a/internal/facts/facts_test.go b/internal/facts/facts_test.go index 531e427..ee9214c 100644 --- a/internal/facts/facts_test.go +++ b/internal/facts/facts_test.go @@ -16,14 +16,24 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) { FetchedAt: fetchedAt, Current: &weatherdata.Current{ConditionText: "Clear"}, Hourly: &weatherdata.ForecastRun{Product: "hourly"}, - Sources: []weatherdata.Source{{Name: "hourly"}}, - Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}}, + SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{ + Product: "convective_outlook", + Outlooks: []weatherdata.ConvectiveOutlook{{ + ID: "day1-categorical-slight", + Label: "SLGT", + }}, + }, + Sources: []weatherdata.Source{{Name: "hourly"}}, + Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}}, } collected := BuildCollected(bundle) if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" { t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected) } + if collected.SPCConvectiveOutlooks == nil || collected.SPCConvectiveOutlooks.Outlooks[0].Label != "SLGT" { + t.Fatalf("SPCConvectiveOutlooks = %#v, want source copied from bundle", collected.SPCConvectiveOutlooks) + } if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" { t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance) } @@ -36,6 +46,11 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) { if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" { t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings) } + + roundTrip := collected.Bundle() + if roundTrip.SPCConvectiveOutlooks == nil || roundTrip.SPCConvectiveOutlooks.Outlooks[0].ID != "day1-categorical-slight" { + t.Fatalf("Bundle().SPCConvectiveOutlooks = %#v, want collected source restored", roundTrip.SPCConvectiveOutlooks) + } } func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) { diff --git a/internal/module/module.go b/internal/module/module.go index f394809..b4a64f6 100644 --- a/internal/module/module.go +++ b/internal/module/module.go @@ -21,6 +21,8 @@ const ( AlertDigest ID = "alert_digest" AreaForecastDiscussion ID = "area_forecast_discussion" WeatherStory ID = "weather_story" + SPCConvectiveOutlooks ID = "spc_convective_outlooks" + SPCConvectiveDiscussion ID = "spc_convective_discussion" OutdoorWindows ID = "outdoor_windows" TomorrowPlanning ID = "tomorrow_planning" ) @@ -105,19 +107,21 @@ func StanzaValue[T any](s Snapshot, name string) (T, bool, error) { type FactRequirement string const ( - CollectedCurrentConditions FactRequirement = "collected.current_conditions" - CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast" - CollectedHourlyForecast FactRequirement = "collected.hourly_forecast" - CollectedAlerts FactRequirement = "collected.alerts" - CollectedDiscussion FactRequirement = "collected.discussion" - CollectedWeatherStory FactRequirement = "collected.weather_story" - CollectedSourceMetadata FactRequirement = "collected.source_metadata" - RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods" - RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods" - RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps" - RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries" - RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries" - RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing" + CollectedCurrentConditions FactRequirement = "collected.current_conditions" + CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast" + CollectedHourlyForecast FactRequirement = "collected.hourly_forecast" + CollectedAlerts FactRequirement = "collected.alerts" + CollectedDiscussion FactRequirement = "collected.discussion" + CollectedWeatherStory FactRequirement = "collected.weather_story" + CollectedSPCConvectiveOutlooks FactRequirement = "collected.spc_convective_outlooks" + CollectedSourceMetadata FactRequirement = "collected.source_metadata" + RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods" + RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods" + RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps" + RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries" + RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries" + RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing" + RequiresDerivedSPCConvectiveOutlooks FactRequirement = "derived.spc_convective_outlooks" ) type MissingDataBehavior string @@ -141,5 +145,7 @@ type AreaForecastDiscussionOptions struct { Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"` } type WeatherStoryOptions struct{} +type SPCConvectiveOutlooksOptions struct{} +type SPCConvectiveDiscussionOptions struct{} type OutdoorWindowsOptions struct{} type TomorrowPlanningOptions struct{} diff --git a/internal/module/module_test.go b/internal/module/module_test.go index b35b1a4..b267065 100644 --- a/internal/module/module_test.go +++ b/internal/module/module_test.go @@ -82,3 +82,21 @@ func TestStanzaValueDecodesTypedOutput(t *testing.T) { t.Fatal("StanzaValue(missing) found = true, want false") } } + +func TestSPCConvectiveModuleContractsAreStable(t *testing.T) { + if SPCConvectiveOutlooks != ID("spc_convective_outlooks") { + t.Fatalf("SPCConvectiveOutlooks = %q, want stable source/module ID", SPCConvectiveOutlooks) + } + if SPCConvectiveDiscussion != ID("spc_convective_discussion") { + t.Fatalf("SPCConvectiveDiscussion = %q, want stable discussion module ID", SPCConvectiveDiscussion) + } + if CollectedSPCConvectiveOutlooks != FactRequirement("collected.spc_convective_outlooks") { + t.Fatalf("CollectedSPCConvectiveOutlooks = %q, want collected requirement", CollectedSPCConvectiveOutlooks) + } + if RequiresDerivedSPCConvectiveOutlooks != FactRequirement("derived.spc_convective_outlooks") { + t.Fatalf("RequiresDerivedSPCConvectiveOutlooks = %q, want derived requirement", RequiresDerivedSPCConvectiveOutlooks) + } + + _ = SPCConvectiveOutlooksOptions{} + _ = SPCConvectiveDiscussionOptions{} +} diff --git a/internal/weatherdata/bundle.go b/internal/weatherdata/bundle.go index 3dcbd09..f00c6ab 100644 --- a/internal/weatherdata/bundle.go +++ b/internal/weatherdata/bundle.go @@ -7,17 +7,18 @@ import ( ) type Bundle struct { - FetchedAt time.Time `json:"fetchedAt"` - Observation *Observation `json:"observation,omitempty"` - Current *Current `json:"current,omitempty"` - Hourly *ForecastRun `json:"hourly,omitempty"` - Narrative *ForecastRun `json:"narrative,omitempty"` - Alerts *AlertRun `json:"alerts,omitempty"` - Discussion *Discussion `json:"discussion,omitempty"` - Daily *ForecastRun `json:"daily,omitempty"` - WeatherStory *WeatherStory `json:"weatherStory,omitempty"` - Sources []Source `json:"sources"` - Warnings []SourceWarning `json:"warnings,omitempty"` + FetchedAt time.Time `json:"fetchedAt"` + Observation *Observation `json:"observation,omitempty"` + Current *Current `json:"current,omitempty"` + Hourly *ForecastRun `json:"hourly,omitempty"` + Narrative *ForecastRun `json:"narrative,omitempty"` + Alerts *AlertRun `json:"alerts,omitempty"` + Discussion *Discussion `json:"discussion,omitempty"` + Daily *ForecastRun `json:"daily,omitempty"` + WeatherStory *WeatherStory `json:"weatherStory,omitempty"` + SPCConvectiveOutlooks *ConvectiveOutlookRun `json:"spcConvectiveOutlooks,omitempty"` + Sources []Source `json:"sources"` + Warnings []SourceWarning `json:"warnings,omitempty"` } type Source struct { @@ -166,3 +167,42 @@ type WeatherStory struct { Order int `json:"order"` DownloadURL string `json:"downloadUrl,omitempty"` } + +type ConvectiveOutlookRun struct { + LocationID string `json:"locationId,omitempty"` + LocationName string `json:"locationName,omitempty"` + AsOf *time.Time `json:"asOf,omitempty"` + IssuedAt *time.Time `json:"issuedAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + Product string `json:"product,omitempty"` + Outlooks []ConvectiveOutlook `json:"outlooks,omitempty"` + Discussions []ConvectiveOutlookDiscussion `json:"discussions,omitempty"` +} + +type ConvectiveOutlook struct { + ID string `json:"id,omitempty"` + Provider string `json:"provider,omitempty"` + Product string `json:"product,omitempty"` + Day int `json:"day,omitempty"` + OutlookType string `json:"outlookType,omitempty"` + Label string `json:"label,omitempty"` + LabelText string `json:"labelText,omitempty"` + Forecaster string `json:"forecaster,omitempty"` + SeverityRank *int `json:"severityRank,omitempty"` + ValidFrom time.Time `json:"validFrom"` + ValidTo time.Time `json:"validTo"` + IssuedAt *time.Time `json:"issuedAt,omitempty"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + SourceURL string `json:"sourceUrl,omitempty"` + ImageURL string `json:"imageUrl,omitempty"` + ContainsLocation bool `json:"containsLocation"` + Geometry json.RawMessage `json:"geometry,omitempty"` +} + +type ConvectiveOutlookDiscussion struct { + Day int `json:"day,omitempty"` + Headline string `json:"headline,omitempty"` + Summary string `json:"summary,omitempty"` + Discussion string `json:"discussion,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} diff --git a/internal/weatherdata/bundle_test.go b/internal/weatherdata/bundle_test.go new file mode 100644 index 0000000..f18440d --- /dev/null +++ b/internal/weatherdata/bundle_test.go @@ -0,0 +1,78 @@ +package weatherdata + +import ( + "encoding/json" + "testing" + "time" +) + +func TestConvectiveOutlookRunJSONPreservesGeometry(t *testing.T) { + severity := 3 + validFrom := mustParseBundleTime("2026-06-12T13:00:00Z") + validTo := mustParseBundleTime("2026-06-13T12:00:00Z") + issuedAt := mustParseBundleTime("2026-06-12T12:30:00Z") + updatedAt := mustParseBundleTime("2026-06-12T12:45:00Z") + run := ConvectiveOutlookRun{ + LocationID: "test-grid", + LocationName: "Brentwood", + AsOf: &updatedAt, + IssuedAt: &issuedAt, + Product: "convective_outlook", + Outlooks: []ConvectiveOutlook{{ + ID: "day1-categorical-slight", + Provider: "spc", + Product: "convective_outlook", + Day: 1, + OutlookType: "categorical", + Label: "SLGT", + LabelText: "Slight Risk", + Forecaster: "Smith", + SeverityRank: &severity, + ValidFrom: validFrom, + ValidTo: validTo, + IssuedAt: &issuedAt, + ExpiresAt: &validTo, + SourceURL: "https://example.test/source", + ImageURL: "https://example.test/image.png", + ContainsLocation: true, + Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-90.1,38.1],[-90.0,38.2],[-90.1,38.1]]]}`), + }}, + Discussions: []ConvectiveOutlookDiscussion{{ + Day: 1, + Headline: "Severe storms possible", + Summary: "Scattered severe storms are possible.", + Discussion: "Discussion text.", + UpdatedAt: &updatedAt, + }}, + } + + data, err := json.Marshal(run) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + var decoded ConvectiveOutlookRun + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(decoded.Outlooks) != 1 || string(decoded.Outlooks[0].Geometry) == "" { + t.Fatalf("decoded outlooks = %#v, want preserved geometry", decoded.Outlooks) + } + if got := string(decoded.Outlooks[0].Geometry); got != `{"type":"Polygon","coordinates":[[[-90.1,38.1],[-90.0,38.2],[-90.1,38.1]]]}` { + t.Fatalf("Geometry = %s, want preserved GeoJSON coordinates", got) + } + if decoded.Outlooks[0].SeverityRank == nil || *decoded.Outlooks[0].SeverityRank != severity { + t.Fatalf("SeverityRank = %#v, want %d", decoded.Outlooks[0].SeverityRank, severity) + } + if len(decoded.Discussions) != 1 || decoded.Discussions[0].Headline != "Severe storms possible" { + t.Fatalf("Discussions = %#v, want decoded discussion", decoded.Discussions) + } +} + +func mustParseBundleTime(value string) time.Time { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + panic(err) + } + return parsed +}