18 KiB
Implement SPC Outlook V2 Location Filtering
Summary
Implement the roadmap in docs/roadmap/outlook.md as a behavior-changing canonical contract update for SPC convective outlooks.
The implementation must make weatherfeeder emit location-relevant SPC outlook snapshots instead of all SPC polygons, and move SPC print-page prose from each polygon to run-level day discussions.
This plan is decision-complete. Do not preserve the old weather.outlook.v1 output behavior in the SPC normalizer. Implement weather.outlook.v2, update the Postgres sink contract, add transition documentation, and update current-behavior docs after the code change lands.
Required Target Behavior
- Raw SPC source behavior remains complete and unfiltered.
- Event kind remains
outlook. - Raw schema remains
raw.spc.convective_outlook.v1. - Canonical SPC outlook output changes from
weather.outlook.v1toweather.outlook.v2. - Canonical outlook runs include only polygons where the configured forecast location is inside or on the boundary.
- Canonical outlook runs are still emitted when no polygons apply locally.
- No local polygons means
outlooks: []anddiscussions: []. - Day discussions are run-level entries in
WeatherOutlookRun.discussions. - Discussions are included only for days represented by at least one retained outlook.
- Multiple retained outlooks for the same day share one run-level discussion entry.
- Polygon-level
headline,summary, anddiscussionfields are removed frommodel.WeatherOutlook. containsLocationremains onWeatherOutlookand must betruefor every v2 emitted outlook.- Empty SPC
GeometryCollectionno-risk placeholders remain skipped and still contribute issue time. - Supersession semantics are documented in permanent docs: latest-run semantics are preferred; historical row supersession uses
provider,product,outlookType,validFrom,validTo, and greatestissuedAt;dayandlabelare not identity fields.
Guardrails
- Do not move point-in-polygon filtering into the source layer.
- Do not fetch additional SPC products, images, shapefiles, or Day 4-8 products.
- Do not make
weatherfeederan archive of national SPC polygons. - Do not add a separate outlook-discussion event kind.
- Do not introduce feedkit changes.
- Do not use column-level compatibility migrations for the outlook table family. Existing SPC outlook history may be discarded, so the transition should drop and recreate outlook tables.
- Do not update non-roadmap current-behavior docs before the corresponding behavior is implemented.
- Keep implementation inside existing architecture boundaries:
model,standards, SPC normalizer/provider helpers, and Postgres sink mapping.
Stage 1: Standards And Canonical Model
Code Changes
- Add
standards.SchemaWeatherOutlookV2 = "weather.outlook.v2"instandards/schema.go. - Keep
standards.SchemaWeatherOutlookV1for historical references and any existing tests that still validate documented constants. - Update
model/outlook.go:- add
WeatherOutlookDiscussion; - add
Discussions []WeatherOutlookDiscussiontoWeatherOutlookRunwith JSON tagjson:"discussions"; - remove
Headline,Summary, andDiscussionfields fromWeatherOutlook.
- add
- Keep all other
WeatherOutlookfields unchanged, includingContainsLocationandGeometry. - Update any code or tests that construct
WeatherOutlookvalues to remove the deleted polygon-level prose fields. - Do not change raw SPC provider structs in this stage.
Tests
- Update model documentation/consumer tests so
WeatherOutlookRun,WeatherOutlook, andWeatherOutlookDiscussionare listed where applicable. - Update standards documentation tests so
weather.outlook.v2is expected. - Add or update a JSON-shape test to verify:
WeatherOutlookRunserializesdiscussions;WeatherOutlookno longer serializes polygon-levelheadline,summary, ordiscussion.
Verification
go test ./model ./standards
Stage 2: SPC Normalizer V2 Output
Code Changes
- Update
internal/normalizers/spc/convective_outlook.gosoNormalizeemitsstandards.SchemaWeatherOutlookV2. - Keep matching raw input schema
standards.SchemaRawSPCConvectiveOutlookV1. - Keep parsing all required print-page discussions before mapping features.
- Keep decoding every configured GeoJSON product.
- Keep skipping empty
GeometryCollectionplaceholders. - Track latest GeoJSON issue time across all real features and empty placeholders, not only retained local polygons.
- For every real feature:
- parse required timestamps and label as today;
- compute
containsLocationusing existinggeo.ContainsPoint; - if
containsLocation == false, do not append a canonical outlook; - if
containsLocation == true, append the canonical outlook withContainsLocation: true.
- Do not attach
headline,summary, ordiscussionto retained outlooks. - After retained outlooks are built, build
WeatherOutlookRun.Discussions:- collect unique days present in retained outlooks;
- include one
WeatherOutlookDiscussionper retained day; - sort discussions by day ascending;
- map
Day,Headline,Summary,Discussion, andUpdatedAtfrom parsed print-page data; - omit discussions for days with no retained outlooks.
- Preserve existing product ordering for retained outlooks: day, outlook type order, then feature order.
- Preserve
asOfand normalized eventeffective_atpolicy:- latest valid GeoJSON issue time across the complete bundle;
- latest print-page update time;
- incoming event
effective_at; - incoming event
emitted_at.
- Preserve
WeatherOutlookRun.IssuedAtas latest valid GeoJSON issue time when available, even whenoutlooksis empty.
Tests
Update internal/normalizers/spc/convective_outlook_test.go:
- Existing sample test should expect
out.Schema == standards.SchemaWeatherOutlookV2. - Existing in-location sample should expect only location-contained outlooks.
- Outside-location sample should normalize successfully with:
len(run.Outlooks) == 0;len(run.Discussions) == 0;- non-zero
run.AsOffrom latest product issue time; - normalized event
EffectiveAt == run.AsOf.
- Add a fixture/test case where only Day 2 contains the location and assert:
- all retained outlooks have
Day == 2; run.Discussionslength is 1;- discussion day is 2;
- Day 1 and Day 3 discussions are absent.
- all retained outlooks have
- Add a fixture/test case where multiple Day 1 outlook types contain the location and assert:
- multiple retained Day 1 outlooks are present;
- exactly one Day 1 discussion is present.
- Assert every retained outlook has
ContainsLocation == true. - Keep the empty
GeometryCollectionregression test and update it for v2 shape. - Keep malformed timestamp, malformed geometry, missing discussion, missing label, and product ordering tests.
- Replace old tests that expected all nine fixture polygons to be emitted with location-filtered expectations.
- Replace tests that asserted polygon-level discussion text with run-level discussion assertions.
Verification
go test ./internal/providers/spc ./internal/normalizers/spc ./internal/geo
Stage 3: Postgres Schema And Mapper
Schema Changes
Update internal/sinks/postgres/schema.go:
- Add table constant
tableOutlookDiscussions = "outlook_discussions". - Add
discussion_count INTEGER NOT NULLtooutlook_runs. - Add
outlook_discussionstable:run_event_id TEXT REFERENCES outlook_runs(event_id) ON DELETE CASCADE NOT NULL;discussion_index INTEGER NOT NULL;as_of TIMESTAMPTZ NOT NULL;day INTEGER NOT NULL;headline TEXT NULL;summary TEXT NULL;discussion TEXT NULL;updated_at TIMESTAMPTZ NULL.
- Set primary key to
(run_event_id, discussion_index). - Set prune column to
as_of. - Add index
idx_wf_outlook_discussions_day_as_ofon(day, as_of). - Add unique index
idx_wf_outlook_discussions_run_dayon(run_event_id, day)usingfksinks.PostgresIndex{Unique: true}. - Remove
headline,summary, anddiscussionfrom the targetoutlooksschema. Do not carry these legacy nullable columns forward.
Mapper Changes
Update internal/sinks/postgres/map.go:
- Route
standards.SchemaWeatherOutlookV2to the outlook mapper. - Remove
standards.SchemaWeatherOutlookV1support from the active outlook mapper unless there is a compile-time reason to keep a clearly documented legacy test. Existing v1 outlook rows are intentionally discarded during the table reset and the SPC normalizer must not produce v1 events. - Decode payload into the updated
model.WeatherOutlookRun. - Write
outlook_runs.discussion_count = len(run.Discussions). - Write one
outlook_discussionsrow for eachrun.Discussions[i]. - Use
discussion_indexas the array position. - Use parent
run.AsOf.UTC()foras_of. - Normalize
UpdatedAtto UTC when present. - Validate discussion entries before writing:
daymust be 1, 2, or 3;- at least one of
headline,summary, ordiscussionmust be non-empty; - duplicate discussion days in one run should fail before hitting the unique index.
- Continue validating outlook required fields:
id,provider,product,day,outlookType,label,validFrom,validTo,issuedAt,expiresAt, andgeometryremain required.
- Add a v2-specific invariant validation: every persisted v2 outlook must have
ContainsLocation == true. If the mapper continues to accept v1, apply this invariant only to v2. - Continue compacting geometry JSON and normalizing all timestamps to UTC.
Tests
Update internal/sinks/postgres/schema_test.go:
- Assert
outlook_runsincludesdiscussion_count. - Assert
outlook_discussionsexists with expected columns, primary key, prune column, day/as-of index, and unique run/day index. - Assert
outlooksdoes not includeheadline,summary, ordiscussion.
Update internal/sinks/postgres/map_test.go:
- Assert v2 outlook run writes:
- one parent row;
- one outlook row per retained outlook;
- one discussion row per run discussion;
- correct
outlook_countanddiscussion_count.
- Assert empty local run writes parent row with
outlook_count = 0anddiscussion_count = 0and no child rows. - Assert discussion rows map day/headline/summary/discussion/updatedAt correctly.
- Assert duplicate discussion days fail with useful context.
- Assert invalid discussion day fails with useful context.
- Assert empty discussion content fails with useful context.
- Assert v2 outlook with
ContainsLocation == falsefails. - Update existing tests that used polygon-level
Headline,Summary, orDiscussion. - Keep compact geometry and UTC normalization tests.
Verification
go test ./internal/sinks/postgres
Stage 4: Normalizer Registry And Cross-Package Consistency
Code Changes
- Confirm SPC normalizer registration remains unchanged except for output schema.
- Update any package-level tests under
internal/normalizersthat assert canonical schema routing or supported schema names. - Update any source/registry documentation consistency tests that expect canonical schema strings.
- Search for stale
SchemaWeatherOutlookV1usage:
rg "SchemaWeatherOutlookV1|weather\.outlook\.v1|headline|summary|discussion" model standards internal docs/consumers docs/integrations docs/internal
- Keep
SchemaWeatherOutlookV1only where intentionally retained for historical docs or standards compatibility. Do not route new SPC normalized events to v1. - Ensure no SPC normalizer test, source test, or current v2 documentation path still claims all polygons are emitted.
Tests
go test ./internal/normalizers ./internal/normalizers/...
go test ./internal/sources ./cmd/weatherfeeder
Stage 5: Transition Documentation
Roadmap Transition Doc
Create docs/roadmap/outlook-schema-transition.md with operator transition guidance.
The document must include:
- Purpose: reset existing Postgres outlook tables from the
weather.outlook.v1storage shape to the v2-compatible storage shape. - Scope: drops only the outlook table family and lets updated
weatherfeederrecreate it. Other weather tables are not affected. - Explicit warning: these commands delete stored SPC outlook history.
- Deployment order:
- Stop
weatherfeeder. - Drop existing outlook tables.
- Deploy updated
weatherfeeder. - Start
weatherfeederso the Postgres sink recreates the v2 tables. - Deploy updated downstream consumers such as
weatherapi.
- Stop
- Transition SQL:
DROP TABLE IF EXISTS outlook_discussions;
DROP TABLE IF EXISTS outlooks;
DROP TABLE IF EXISTS outlook_runs;
- Verification SQL examples:
SELECT table_name
FROM information_schema.tables
WHERE table_name IN ('outlook_runs', 'outlooks', 'outlook_discussions')
ORDER BY table_name;
After the updated daemon has started and recreated tables:
SELECT column_name, is_nullable, data_type
FROM information_schema.columns
WHERE table_name = 'outlook_runs'
AND column_name = 'discussion_count';
SELECT indexname
FROM pg_indexes
WHERE tablename = 'outlook_discussions'
ORDER BY indexname;
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'outlooks'
AND column_name IN ('headline', 'summary', 'discussion');
- State that the final verification query for legacy
outlooksprose columns should return zero rows. - State that v1 outlook rows are intentionally removed and downstream readers should be updated intentionally.
Tests
No code tests are required for the roadmap transition doc, but documentation consistency tests may need updates if they check table names or schema strings.
Stage 6: Permanent Documentation Updates
After the implementation is complete, update permanent current-behavior documentation. Do not leave these as roadmap-only notes.
Required Docs
Update docs/policy/architecture.md:
- Add a core design principle that
weatherfeederis location-focused and 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.
Update docs/integrations/events.md:
- Document
weather.outlook.v2. - Document
WeatherOutlookRun.discussionsandWeatherOutlookDiscussionfields. - Document that
outlooks[]is location-filtered. - Document
containsLocationas an invariant that is true for emitted v2 outlooks. - Document empty local runs with
outlooks: []anddiscussions: []. - Document supersession semantics:
- latest-run semantics preferred;
- historical supersession key is
provider,product,outlookType,validFrom,validTo; - greatest
issuedAtwins; dayandlabelare not identity;- preserve all polygons from the selected latest
issuedAtgroup.
- Keep any v1 notes clearly marked legacy/historical if retained.
Update docs/integrations/spc.md:
- State raw SPC bundles remain complete.
- State canonical output is location-filtered.
- State discussions are day-level and only included for retained outlook days.
- Remove or replace claims that all polygons are emitted or discussion text is attached to every polygon.
- Add supersession guidance or link to the canonical section in
docs/integrations/events.md.
Update docs/integrations/postgres.md:
- Add
outlook_runs.discussion_count. - Add
outlook_discussionstable, columns, keys, indexes, prune column, and mapping source. - Remove
outlooks.headline,outlooks.summary, andoutlooks.discussionfrom the current table contract. - Document v2 reader reconstruction: read
outlook_runs,outlooks, andoutlook_discussionsbyrun_event_idordered by child indexes.
Update docs/internal/postgres-sink.md and internal/sinks/postgres/doc.go:
- Document the new table and mapper validation rules.
Update docs/consumers/pkg-model.md and docs/consumers/api.md:
- Add
WeatherOutlookDiscussion. - Update model examples for run-level discussions.
- Remove polygon-level prose from v2 consumer examples.
Update docs/config.md only if SPC prose still describes all-polygons behavior.
Update docs/operations.md or docs/troubleshooting.md only if operational behavior or recovery instructions need adjustment.
Tests
- Update docs consistency tests for
weather.outlook.v2. - Update docs consistency tests for
outlook_discussionsif such tests exist or are practical. - Ensure no permanent doc still describes v1 all-polygons behavior as current behavior:
rg "All outlook polygons|all polygons|attached to every outlook|weather\.outlook\.v1" docs README.md
Stage 7: Full Verification And Release Readiness
Focused Tests
go test ./model ./standards
go test ./internal/providers/spc ./internal/normalizers/spc ./internal/normalizers ./internal/geo
go test ./internal/sinks/postgres
go test ./internal/sources ./cmd/weatherfeeder
Full Test Suite
go test ./...
Manual Checks
- Confirm
cmd/weatherfeeder/config.ymlstill loads and source registry tests pass. - Confirm no current-behavior docs outside
docs/roadmap/describe unimplemented behavior. - Confirm
docs/roadmap/outlook-schema-transition.mdtransition SQL and verification steps match the final table names andinternal/sinks/postgres/schema.go. - Confirm the SPC normalizer emits
weather.outlook.v2, notweather.outlook.v1. - Confirm a no-local-risk SPC bundle emits a canonical run with empty outlook and discussion arrays.
- Confirm a local-risk SPC bundle emits only containing polygons and only matching day discussions.
- Confirm retained v2 outlooks all have
containsLocation: true.
Weatherapi Coordination Notes
This stage is not implemented in weatherfeeder, but the weatherfeeder release should call it out for downstream work.
- Release
weatherfeederwithweather.outlook.v2before updatingweatherapidependency. - Update
weatherapiPostgres reads to loadoutlook_discussions. - Update weatherapi endpoint semantics to prefer latest-run behavior for current outlook endpoints.
- Remove or revise weatherapi filters that depend on historical
containsLocation=falserows. - Consider keeping
/outlooks/convective/locationas a compatibility alias for latest local outlooks if already exposed.
Open Questions
None. This plan chooses the long-term maintainable options from docs/roadmap/outlook.md: schema v2, location-filtered canonical output, run-level discussions, destructive reset of the outlook table family, and permanent documentation of supersession semantics.