17 KiB
SPC Outlook Location Filtering And Discussion Refactor
Summary
Revise SPC convective outlook normalization so weatherfeeder emits only outlook data relevant to the configured forecast location, and move SPC discussion text from each outlook polygon to day-level run discussions.
This is a canonical contract change. The current weather.outlook.v1 contract preserves all SPC polygons and duplicates day discussion text on each polygon. The target contract should use a new canonical schema, weather.outlook.v2, so downstream consumers can distinguish the released all-polygons shape from the location-filtered shape.
Locked Decisions
weatherfeederis a location-focused weather daemon, not a national weather archive.- SPC sources should continue fetching complete upstream bundles for correctness and effective-time calculation.
- SPC raw events should remain complete and unfiltered.
- SPC normalizers should own location relevance policy.
- Canonical outlook runs should include only polygons that contain the configured forecast point.
- A canonical run should still be emitted when no polygons contain the configured point.
- When no polygons apply, the canonical run should contain
outlooks: []anddiscussions: []. - SPC discussion text should be modeled once per applicable outlook day, not duplicated on each polygon.
- If only Day 2 polygons apply, include only the Day 2 discussion.
- If multiple outlook types apply for the same day, include that day discussion once.
- Keep point-in-polygon boundary semantics unchanged: points on a polygon boundary count as contained.
- Keep empty SPC
GeometryCollectionno-risk placeholders skipped as non-polygons.
Target Public Contract
Event Kind And Schemas
- Event kind remains
outlook. - Raw schema remains
raw.spc.convective_outlook.v1. - Add canonical schema
weather.outlook.v2. - Stop producing new
weather.outlook.v1events from the SPC normalizer after the transition. - Keep
weather.outlook.v1documentation as historical behavior if needed for consumers of already-stored events.
Canonical Model
Update model.WeatherOutlookRun to include day-level discussions:
type WeatherOutlookRun struct {
LocationID string `json:"locationId,omitempty"`
LocationName string `json:"locationName,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
AsOf time.Time `json:"asOf"`
IssuedAt *time.Time `json:"issuedAt,omitempty"`
Outlooks []WeatherOutlook `json:"outlooks"`
Discussions []WeatherOutlookDiscussion `json:"discussions"`
}
Add model.WeatherOutlookDiscussion:
type WeatherOutlookDiscussion struct {
Day int `json:"day"`
Headline string `json:"headline,omitempty"`
Summary string `json:"summary,omitempty"`
Discussion string `json:"discussion,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
Update model.WeatherOutlook:
- Remove or stop populating polygon-level
headline. - Remove or stop populating polygon-level
summary. - Remove or stop populating polygon-level
discussion. - Keep
containsLocationfor compatibility and invariant visibility, but every emitted canonical outlook should havecontainsLocation: true.
Recommended implementation choice: remove the three polygon-level prose fields from the Go model and document them as weather.outlook.v1 fields only. This is cleaner and consistent with the schema bump.
Normalization Behavior
The SPC normalizer should:
- Decode the complete raw SPC bundle.
- Parse all required print-page discussions so missing or malformed required pages still fail normalization.
- Decode all configured GeoJSON products.
- Skip empty
GeometryCollectionno-risk placeholders. - Compute point-in-polygon for every real
PolygonorMultiPolygonfeature. - Append only outlooks where the configured forecast point is inside or on the boundary of the polygon.
- Track latest valid GeoJSON
ISSUE_ISOacross all decoded features, including skipped empty placeholders, soasOfremains a snapshot of the checked upstream products. - Build
discussionsfrom the set of days represented by retained outlooks. - Preserve product ordering by day, then categorical, tornado, hail, and wind, and preserve feature order within each product.
- Emit a canonical run even when no outlooks apply.
asOf and normalized event effective_at should continue using:
- latest valid GeoJSON issue time across the complete bundle;
- latest print-page update time;
- incoming event
effective_at; - incoming event
emitted_at.
issuedAt on the run should continue representing the latest valid GeoJSON issue time when one exists, even if no polygons apply locally.
Supersession Semantics
The permanent event and integration documentation must define SPC outlook
supersession semantics when weather.outlook.v2 is implemented.
Required contract language:
- A
WeatherOutlookRunis a point-in-time provider snapshot for the configured forecast location as ofasOf. - Consumers that need current local outlooks should prefer latest-run semantics:
read the latest run and use that run's
outlooksanddiscussionsrather than accumulating active outlook rows from older runs. WeatherOutlook.issuedAtis the authoritative version timestamp for an outlook polygon.- If a consumer intentionally queries across historical rows, later outlooks
supersede earlier outlooks with the same
provider,product,outlookType,validFrom, andvalidTo. dayis an issuance-relative classification, not a stable supersession key. A Day 3 outlook can become the Day 2 outlook and then the Day 1 outlook for the same valid period.labelis outlook content, not identity. A newer outlook with the same valid period and type can legitimately change risk label.- When multiple polygons from the same latest issuance share the same
supersession key, consumers must preserve all polygons from that latest
issuedAtgroup rather than collapsing to one row.
This should be documented in the permanent non-roadmap docs as part of the v2 implementation, especially:
docs/integrations/events.mddocs/integrations/spc.mddocs/integrations/postgres.mddocs/consumers/api.md
The roadmap should not update those current-behavior docs before the behavior is implemented.
Postgres Contract Changes
The Postgres sink needs a schema transition because discussion text moves from outlooks rows to day-level rows.
Target Tables
Keep outlook_runs and outlooks.
Add a new child table, recommended name outlook_discussions:
run_event_id TEXT REFERENCES outlook_runs(event_id) ON DELETE CASCADE NOT NULLdiscussion_index INTEGER NOT NULLas_of TIMESTAMPTZ NOT NULLday INTEGER NOT NULLheadline TEXT NULLsummary TEXT NULLdiscussion TEXT NULLupdated_at TIMESTAMPTZ NULL
Recommended keys and indexes:
- Primary key:
(run_event_id, discussion_index) - Index:
(day, as_of) - Optional uniqueness:
(run_event_id, day)if mapper guarantees one discussion per day and tests cover it.
Update outlook_runs:
- Keep
outlook_count. - Add
discussion_count INTEGER NOT NULL.
Update outlooks:
- Remove
headline,summary, anddiscussionfrom the target v2 table contract. - Keep
contains_location BOOLEAN NOT NULL; for v2 records this should always be true.
Recommended implementation choice: do not carry legacy nullable prose columns forward. Existing SPC outlook history does not need to be retained, so the transition should drop and recreate the outlook tables rather than preserving v1-only columns.
Database Transition Document
Create a dedicated transitional document during implementation:
docs/roadmap/outlook-schema-transition.md
That document should include operator-facing SQL for existing deployments. Because feedkit's Postgres sink uses CREATE TABLE IF NOT EXISTS, existing databases need manual intervention before the new schema can be created.
Recommended transition commands:
DROP TABLE IF EXISTS outlook_discussions;
DROP TABLE IF EXISTS outlooks;
DROP TABLE IF EXISTS outlook_runs;
After those tables are dropped, deploy the updated weatherfeeder and let the Postgres sink create the v2 outlook tables from internal/sinks/postgres/schema.go.
The transition document must state clearly that these commands delete stored SPC outlook history. That loss is acceptable for this feature because weatherfeeder is a location-focused current weather daemon, not an archive.
Documentation Policy Update
A small architecture-policy revision is warranted. Add an explicit domain-scope invariant to docs/policy/architecture.md:
weatherfeederis location-focused. Canonical weather events should represent weather data relevant to the configured forecast location or configured provider object. It is not intended to archive all provider data for all places.
Suggested placement:
- Add this under
Core Design Principlesas a new bullet. - Mention SPC outlooks as an example only if the implementation has shipped; otherwise keep the statement general.
Also update current-behavior docs only when implementation changes land:
docs/integrations/events.mddocs/integrations/spc.mddocs/integrations/postgres.mddocs/internal/postgres-sink.mddocs/consumers/pkg-model.mddocs/consumers/api.mddocs/config.mdif source/operator behavior descriptions mention all polygonsdocs/operations.mdanddocs/troubleshooting.mdonly if operational behavior changes need explicit mention
Implementation Stages
Stage 1: Standards And Model Contract
- Add
standards.SchemaWeatherOutlookV2 = "weather.outlook.v2". - Update the SPC normalizer to emit
weather.outlook.v2. - Add
model.WeatherOutlookDiscussion. - Add
Discussions []WeatherOutlookDiscussiontomodel.WeatherOutlookRunwith JSON fielddiscussions. - Remove polygon-level prose fields from
model.WeatherOutlook, or stop populating them if a softer source-compatible model transition is preferred. - Update model documentation tests and consumer docs references.
Tests:
- Model JSON shape includes
discussions. - Model JSON shape no longer emits polygon-level prose fields if removed.
- Standards docs tests include
weather.outlook.v2.
Stage 2: Normalizer Filtering And Discussion Mapping
- Keep raw bundle decode complete and unfiltered.
- Compute
containsLocationfor each real geometry. - Append only
containsLocation == trueoutlooks. - Set retained outlooks'
ContainsLocationto true. - Build run
Discussionsby collecting unique days from retained outlooks. - Preserve discussion ordering by day ascending.
- Do not include discussions for days without retained outlooks.
- Ensure empty applicable outlook results produce
outlooks: []anddiscussions: []. - Continue using all products and print pages for timestamp fallback and validation.
Tests:
- Outside-location fixture normalizes successfully with
outlooks: []anddiscussions: []. - Day 2-only applicable polygon includes exactly one Day 2 discussion.
- Multiple Day 1 applicable outlook types include one Day 1 discussion.
- Retained outlooks always have
containsLocation == true. - Empty
GeometryCollectionplaceholders remain skipped and still contribute issue time. - Existing malformed timestamp, malformed geometry, missing discussion, and product ordering tests remain meaningful.
Stage 3: Postgres Sink V2 Mapping
- Update schema definition for
outlook_runs.discussion_count. - Add
outlook_discussionsschema definition. - Map
WeatherOutlookRun.Discussionstooutlook_discussionsrows. - Stop writing polygon-level prose fields for v2 outlook rows.
- Preserve event envelope fields and UTC normalization.
- Preserve compact GeoJSON storage.
- Validate required discussion fields:
dayis required and must be 1, 2, or 3.- At least one of
headline,summary, ordiscussionshould be present for a discussion row. updatedAtremains optional.
- Preserve required outlook validation for retained polygons.
Tests:
- Schema includes
discussion_countandoutlook_discussions. - Mapper writes one discussion row per run discussion.
- Mapper writes
discussion_count == len(payload.discussions). - Mapper writes empty runs with
outlook_count = 0anddiscussion_count = 0. - Mapper rejects invalid discussion day.
- Mapper preserves compact geometry and UTC timestamps.
- Existing mapper tests for outlook IDs, provider, geometry, and required fields still pass.
Stage 4: Transition Documentation
- Add
docs/roadmap/outlook-schema-transition.mdwith the SQL commands from this roadmap, adjusted to match the final implemented schema. - Clearly state deployment order:
- Stop
weatherfeeder. - Drop existing outlook tables.
- Deploy updated
weatherfeeder. - Start
weatherfeederso it recreates the outlook tables. - Deploy updated downstream consumers such as
weatherapi.
- Stop
- State that existing v1 outlook rows are intentionally deleted by this transition.
- State that new v2 rows store discussion text in
outlook_discussions. - Do not include
ALTER TABLE ... DROP COLUMN; the chosen path is to drop and recreate the outlook table family.
Stage 5: Current-Behavior Documentation Updates
After implementation lands, update current-behavior docs to describe v2 behavior:
docs/policy/architecture.md: add the location-focused domain boundary invariant.docs/integrations/events.md: documentweather.outlook.v2,discussions[], and location-filteredoutlooks[].docs/integrations/spc.md: update mapping notes to say raw bundles are complete but canonical outlooks are location-filtered.docs/integrations/postgres.md: documentoutlook_discussionsanddiscussion_count.docs/internal/postgres-sink.md: update mapper contract.docs/consumers/pkg-model.mdanddocs/consumers/api.md: update model examples.- Keep any
weather.outlook.v1notes explicitly marked as legacy/historical if retained.
Tests:
- Documentation consistency tests include
weather.outlook.v2. - Postgres docs mention
outlook_discussions. - SPC docs no longer claim all polygons are emitted or discussion text is attached to every polygon.
Stage 6: Downstream Consumer Coordination
- Update
weatherapiafterweatherfeederhas a released version containingweather.outlook.v2. - Update weatherapi Postgres reads to load
outlook_discussions. - Update weatherapi response docs to expose run-level
discussions. - Remove or adapt weatherapi filters that assumed all polygons were persisted and
containsLocation=falserows existed. /outlooks/convective/locationmay become redundant if weatherapi only consumes v2 rows; keep it as an alias/filter endpoint only if useful for API stability.
Verification
Focused tests during implementation:
go test ./model ./standards
go test ./internal/providers/spc ./internal/normalizers/spc ./internal/sources/spc ./internal/geo
go test ./internal/sinks/postgres
Full verification:
go test ./...
Manual operational validation with a live SPC no-risk scenario should confirm:
- poll succeeds;
- normalization succeeds;
- a canonical outlook run is emitted;
outlooksis empty when no polygon contains the configured point;discussionsis empty when no local outlook applies;asOfstill reflects the latest checked SPC product issue time.
Risks And Mitigations
| Risk | Mitigation |
|---|---|
Downstream consumers expect weather.outlook.v1 shape. |
Use weather.outlook.v2 and coordinate downstream updates. |
| Existing databases contain v1 outlook tables. | Stop the daemon, drop the outlook table family, deploy the v2 binary, and let the sink recreate the tables. |
| Empty local runs look like missing data. | Preserve asOf, issuedAt, location metadata, and empty arrays to indicate a successful checked snapshot. |
Removing polygon-level discussion fields breaks code that reads model.WeatherOutlook. |
Schema bump and release notes; update weatherapi immediately after weatherfeeder release. |
| Filtering hides useful national context. | Raw events remain complete for debugging; canonical events intentionally remain location-scoped. |
Non-Goals
- Do not change SPC source polling URLs in this roadmap.
- Do not fetch SPC image assets or shapefiles.
- Do not add Day 4-8 outlooks.
- Do not make weatherfeeder a national SPC outlook archive.
- Do not add a separate outlook-discussion event kind unless a future roadmap establishes independent consumer value.
- Do not move point-in-polygon filtering into the source layer.