Finalize SPC outlook feature addition and clean up implemented roadmap documentation
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
This commit is contained in:
@@ -429,6 +429,8 @@ Indexes:
|
||||
| `run_event_id` | `TEXT REFERENCES outlook_runs(event_id) ON DELETE CASCADE` | no | Parent event ID. |
|
||||
| `outlook_index` | `INTEGER` | no | `payload.outlooks[]` index. |
|
||||
| `as_of` | `TIMESTAMPTZ` | no | Parent `payload.asOf` |
|
||||
| `outlook_id` | `TEXT` | no | `payload.outlooks[].id` |
|
||||
| `provider` | `TEXT` | no | `payload.outlooks[].provider` |
|
||||
| `product` | `TEXT` | no | `payload.outlooks[].product` |
|
||||
| `day` | `INTEGER` | no | `payload.outlooks[].day` |
|
||||
| `outlook_type` | `TEXT` | no | `payload.outlooks[].outlookType` |
|
||||
|
||||
60
docs/roadmap/future.md
Normal file
60
docs/roadmap/future.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Future Work
|
||||
|
||||
## Purpose
|
||||
|
||||
This document is the catch-all roadmap for planned, deferred, aspirational, experimental, or unimplemented weatherfeeder work. Current behavior belongs in the canonical docs outside `docs/roadmap/`.
|
||||
|
||||
## SPC Convective Outlook Follow-Ups
|
||||
|
||||
### Weatherapi Outlook Endpoints
|
||||
|
||||
Expose persisted SPC convective outlooks through `weatherapi` after the weatherfeeder storage contract is stable.
|
||||
|
||||
Likely endpoints:
|
||||
|
||||
- `GET /outlooks/convective`
|
||||
- `GET /outlooks/convective/active`
|
||||
- `GET /outlooks/convective/location`
|
||||
|
||||
Recommended behavior:
|
||||
|
||||
- Return the latest outlook run by default.
|
||||
- Support active outlook filtering by current time and `containsLocation=true`.
|
||||
- Consider optional query filters for `day`, `outlookType`, and `containsLocation`.
|
||||
- Preserve canonical outlook geometry for downstream display and audit use.
|
||||
|
||||
### SPC Day 4-8 Outlooks
|
||||
|
||||
Add SPC Day 4-8 convective outlook support as a schema-compatible extension only after Day 1-3 operation is proven.
|
||||
|
||||
Notes:
|
||||
|
||||
- Day 4-8 products have different semantics from Day 1-3 categorical/tornado/hail/wind products.
|
||||
- Avoid forcing Day 4-8 assumptions into the current Day 1-3 model until the source shapes and consumer needs are reviewed.
|
||||
- Prefer reusing `weather.outlook.v1` if the fields remain accurate; otherwise write a separate roadmap before changing the canonical contract.
|
||||
|
||||
### Degraded SPC Bundle Mode
|
||||
|
||||
Evaluate whether the SPC source should support degraded partial bundles when one required upstream product fails.
|
||||
|
||||
Current behavior should remain atomic: if a required GeoJSON or print-page fetch fails, emit no event for that poll.
|
||||
|
||||
Future degraded mode would need a clear contract for:
|
||||
|
||||
- distinguishing "no risk polygon" from "product missing";
|
||||
- exposing per-product fetch errors without leaking raw provider internals into canonical events;
|
||||
- deciding whether downstream sinks and APIs should store or serve partial snapshots.
|
||||
|
||||
### Richer SPC Page Assets And Tables
|
||||
|
||||
Evaluate whether to parse additional SPC print-page metadata beyond the current discussion text.
|
||||
|
||||
Possible additions:
|
||||
|
||||
- archive GeoJSON/shapefile/KML links;
|
||||
- image URLs;
|
||||
- page risk tables;
|
||||
- city tables;
|
||||
- richer discussion section metadata.
|
||||
|
||||
Keep GeoJSON products authoritative for polygons, validity windows, and point matching unless a future roadmap explicitly changes that contract.
|
||||
@@ -1,103 +0,0 @@
|
||||
# SPC Convective Outlook Remaining Work Plan
|
||||
|
||||
## Summary
|
||||
|
||||
The SPC convective outlook feature is mostly implemented. This roadmap now tracks only the remaining remediation work needed to align the implementation with the intended contract and live SPC print-page behavior.
|
||||
|
||||
Do not use this document as the original full feature implementation plan. It is a focused completion plan for the outstanding gaps below.
|
||||
|
||||
## Remaining Work
|
||||
|
||||
### Postgres Persistence Gap
|
||||
|
||||
Canonical `model.WeatherOutlook` includes required `id` and `provider` fields, but the current Postgres `outlooks` table and mapper do not persist them. This makes Postgres persistence lossy for `weather.outlook.v1`.
|
||||
|
||||
Required changes:
|
||||
|
||||
- Add `outlook_id TEXT NOT NULL` to the `outlooks` table contract.
|
||||
- Add `provider TEXT NOT NULL` to the `outlooks` table contract.
|
||||
- Map `model.WeatherOutlook.ID` to `outlook_id`.
|
||||
- Map `model.WeatherOutlook.Provider` to `provider`.
|
||||
- Keep existing required-field validation for empty `id` and `provider`.
|
||||
- Update Postgres schema, mapper, schema tests, mapper tests, internal Postgres docs, and Postgres integration docs.
|
||||
- Do not include manual database migration commands in this roadmap; keep this plan focused on schema/code/docs expectations.
|
||||
|
||||
### SPC Print-Page Parsing Gap
|
||||
|
||||
The current parser fixtures do not match the live SPC print-page structure closely enough. Live SPC print pages currently place the page-level `Updated:` row outside the `<pre>` product text, and the product text inside `<pre>` begins with an SPC product code line such as `SPC AC 101959` before the human title line.
|
||||
|
||||
Required changes:
|
||||
|
||||
- Update SPC print-page fixtures to match live shape:
|
||||
- page-level `Updated:` appears outside the `<pre>` block
|
||||
- `<pre>` content begins with an `SPC AC ...` product code line
|
||||
- the human outlook title appears on the following line, such as `Day 1 Convective Outlook`
|
||||
- Parse page-level `Updated:` timestamps from the full HTML document, not only from extracted product text.
|
||||
- Keep cleaned product text extraction focused on the `<pre>` block.
|
||||
- Preserve the leading `SPC AC ...` line in canonical `discussion` text.
|
||||
- Update product-title/headline parsing to skip product code lines like `SPC AC 101959` and choose the human outlook title line.
|
||||
- Preserve correction markers such as `CORR 1` in the selected headline/title when present.
|
||||
- Continue stripping scripts and HTML tags from extracted product text.
|
||||
|
||||
### Source Effective-Time Correction
|
||||
|
||||
The source should continue to prefer GeoJSON issue timestamps, but the print-page fallback should use the page-level `Updated:` parser described above.
|
||||
|
||||
Effective-time order should remain:
|
||||
|
||||
1. latest valid GeoJSON `ISSUE_ISO`
|
||||
2. latest valid print-page `Updated:` timestamp parsed from full HTML
|
||||
3. RSS `lastBuildDate`, when optional RSS is configured and parseable
|
||||
4. fetch time
|
||||
|
||||
Required changes:
|
||||
|
||||
- Update source discussion timestamp extraction to parse from full HTML.
|
||||
- Keep RSS optional and supplemental only.
|
||||
- Keep unchanged-response hashing, atomic required fetch behavior, and fetch-time fallback unchanged.
|
||||
|
||||
## Test Plan
|
||||
|
||||
Provider parser tests:
|
||||
|
||||
- Fixture covers live print-page shape with `Updated:` outside `<pre>`.
|
||||
- Fixture product text begins with `SPC AC ...`.
|
||||
- Headline resolves to `Day X Convective Outlook`, not `SPC AC ...`.
|
||||
- Page-level `Updated:` parses to UTC.
|
||||
- Full cleaned discussion text preserves the `SPC AC ...` line.
|
||||
- Script and tag cleanup remains covered.
|
||||
- `CORR 1` remains preserved in headline/title and discussion text.
|
||||
|
||||
Source tests:
|
||||
|
||||
- `effectiveAt` falls back to page-level `Updated:` when GeoJSON issue times are unavailable.
|
||||
- Existing atomic fetch, unchanged-response, required URL failure, and optional RSS tests remain passing.
|
||||
|
||||
Normalizer tests:
|
||||
|
||||
- Canonical `headline` uses the human outlook title.
|
||||
- Canonical `discussion` preserves full cleaned product text.
|
||||
- Existing `CORR 1`, summary, and day-to-discussion mapping tests remain passing.
|
||||
|
||||
Postgres tests:
|
||||
|
||||
- Schema includes `outlook_id` and `provider` on `outlooks`.
|
||||
- Mapper writes `outlook_id` from `WeatherOutlook.ID`.
|
||||
- Mapper writes `provider` from `WeatherOutlook.Provider`.
|
||||
- Required-field validation still rejects empty `id` and `provider`.
|
||||
- Existing compact geometry and `contains_location=false` tests remain passing.
|
||||
|
||||
Verification commands:
|
||||
|
||||
```sh
|
||||
go test ./internal/providers/spc ./internal/sources/spc ./internal/normalizers/spc ./internal/sinks/postgres
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The canonical API shape remains unchanged: `WeatherOutlook.id` and `WeatherOutlook.provider` are already required fields.
|
||||
- Postgres persistence should be lossless for required canonical outlook fields.
|
||||
- Existing database migration commands are intentionally omitted from this roadmap.
|
||||
- RSS remains optional supplemental metadata only.
|
||||
- This remediation plan does not include weatherapi changes.
|
||||
@@ -1,349 +0,0 @@
|
||||
# SPC Convective Outlook Support Roadmap
|
||||
|
||||
## Summary
|
||||
|
||||
Add `weatherfeeder` support for Storm Prediction Center convective outlooks as a new canonical outlook domain. The feature should poll SPC GeoJSON outlook products, enrich them with Day 1-3 print-page discussion text, compute whether the configured forecast point is inside each active outlook polygon, normalize the result into a provider-independent schema, and persist it through existing sinks.
|
||||
|
||||
This is a new domain, not an extension of `weather.alert.v1`. SPC outlooks describe probabilistic/categorical risk areas over a validity window; alerts describe active actionable hazard messages.
|
||||
|
||||
## Public Contract
|
||||
|
||||
Add schema constants:
|
||||
|
||||
- Raw schema: `raw.spc.convective_outlook.v1`
|
||||
- Canonical schema: `weather.outlook.v1`
|
||||
|
||||
Add source driver:
|
||||
|
||||
- `spc_convective_outlook`
|
||||
|
||||
Add event kind:
|
||||
|
||||
- `outlook`
|
||||
|
||||
Add canonical model types:
|
||||
|
||||
- `model.WeatherOutlookRun`
|
||||
- `model.WeatherOutlook`
|
||||
|
||||
Recommended canonical run fields:
|
||||
|
||||
- `locationId`, `locationName`
|
||||
- `latitude`, `longitude`
|
||||
- `asOf`
|
||||
- `issuedAt`
|
||||
- `outlooks`
|
||||
|
||||
Recommended canonical outlook fields:
|
||||
|
||||
- `id`
|
||||
- `provider`
|
||||
- `product`
|
||||
- `day`
|
||||
- `outlookType`
|
||||
- `label`
|
||||
- `labelText`
|
||||
- `severityRank`
|
||||
- `validFrom`
|
||||
- `validTo`
|
||||
- `issuedAt`
|
||||
- `expiresAt`
|
||||
- `forecaster`
|
||||
- `headline`
|
||||
- `summary`
|
||||
- `discussion`
|
||||
- `sourceUrl`
|
||||
- `imageUrl`
|
||||
- `containsLocation`
|
||||
- `geometry`
|
||||
|
||||
Contract defaults:
|
||||
|
||||
- `product` should be `convective`.
|
||||
- `outlookType` should be one of `categorical`, `tornado`, `hail`, `wind`.
|
||||
- `day` should be `1`, `2`, or `3` for this first implementation.
|
||||
- `containsLocation` is computed against configured forecast coordinates.
|
||||
- `geometry` should preserve compact GeoJSON geometry for auditability and future API use.
|
||||
- Day 4-8 outlooks are out of scope for this first implementation. They have different product semantics and should be added in a later schema-compatible extension only after Day 1-3 support is proven.
|
||||
|
||||
## Source Scope
|
||||
|
||||
The source should fetch a bundle of SPC products in one poll cycle and emit one raw event containing fetched GeoJSON products, fetched Day 1-3 print pages, optional RSS metadata, configured point metadata, and per-product fetch metadata. The source may decode minimal timestamps for event identity/effective time, but provider-to-canonical mapping belongs in the normalizer.
|
||||
|
||||
Poll these GeoJSON URLs:
|
||||
|
||||
- `https://www.spc.noaa.gov/products/outlook/day1otlk_cat.nolyr.geojson`
|
||||
- `https://www.spc.noaa.gov/products/outlook/day1otlk_torn.nolyr.geojson`
|
||||
- `https://www.spc.noaa.gov/products/outlook/day1otlk_hail.nolyr.geojson`
|
||||
- `https://www.spc.noaa.gov/products/outlook/day1otlk_wind.nolyr.geojson`
|
||||
- `https://www.spc.noaa.gov/products/outlook/day2otlk_cat.nolyr.geojson`
|
||||
- `https://www.spc.noaa.gov/products/outlook/day2otlk_torn.nolyr.geojson`
|
||||
- `https://www.spc.noaa.gov/products/outlook/day2otlk_hail.nolyr.geojson`
|
||||
- `https://www.spc.noaa.gov/products/outlook/day2otlk_wind.nolyr.geojson`
|
||||
- `https://www.spc.noaa.gov/products/outlook/day3otlk_cat.nolyr.geojson`
|
||||
- `https://www.spc.noaa.gov/products/outlook/day3otlk_torn.nolyr.geojson`
|
||||
- `https://www.spc.noaa.gov/products/outlook/day3otlk_hail.nolyr.geojson`
|
||||
- `https://www.spc.noaa.gov/products/outlook/day3otlk_wind.nolyr.geojson`
|
||||
|
||||
Implementation note: the initial candidate list duplicated Day 3 wind and omitted Day 2 wind. Use the corrected 12-product matrix above.
|
||||
|
||||
Poll these print-page discussion URLs:
|
||||
|
||||
- `https://www.spc.noaa.gov/products/outlook/day1otlk_prt.html`
|
||||
- `https://www.spc.noaa.gov/products/outlook/day2otlk_prt.html`
|
||||
- `https://www.spc.noaa.gov/products/outlook/day3otlk_prt.html`
|
||||
|
||||
Print-page usage:
|
||||
|
||||
- Use print pages as the authoritative source for Day 1-3 outlook discussion text.
|
||||
- Preserve source URL, page title/header where available, page-level `Updated:` timestamp where parseable, and the raw HTML or extracted `<pre>` product block in the raw bundle.
|
||||
- Extract canonical `headline`, `summary`, `discussion`, and discussion metadata in the normalizer or shared SPC provider helper.
|
||||
- Treat the GeoJSON files, not print pages, as authoritative for polygons and validity windows.
|
||||
- Ignore page risk tables and image assets for v1 unless they are needed to support discussion parsing.
|
||||
|
||||
Optionally poll:
|
||||
|
||||
- `https://www.spc.noaa.gov/products/spcacrss.xml`
|
||||
|
||||
RSS usage:
|
||||
|
||||
- Use RSS as supplemental discovery or health metadata, not as the primary discussion or geometry source.
|
||||
- Preserve item title, link, description text, pubDate, and guid where useful.
|
||||
- Do not depend on RSS completeness for Day 1-3 discussion text. The RSS feed can omit current outlook products, including a current Day 2 discussion.
|
||||
- Do not attempt to parse polygons from RSS HTML.
|
||||
|
||||
Polling cadence:
|
||||
|
||||
- Use a default cadence of `30m`, not daily/twice-daily. SPC current outlook files can update several times per day, and a 30-minute poll with HTTP caching is low cost and less likely to miss updates.
|
||||
- Rely on ETag/Last-Modified handling from the HTTP source layer where available.
|
||||
- Keep the cadence configurable via `every`.
|
||||
|
||||
Recommended config shape:
|
||||
|
||||
```yaml
|
||||
- name: SPCConvectiveOutlookSTL
|
||||
mode: poll
|
||||
kinds: ["outlook"]
|
||||
driver: spc_convective_outlook
|
||||
every: 30m
|
||||
params:
|
||||
latitude: 38.6239
|
||||
longitude: -90.3571
|
||||
location_id: "stl"
|
||||
location_name: "St. Louis, MO"
|
||||
user_agent: "HomeOps (eric@maximumdirect.net)"
|
||||
```
|
||||
|
||||
The source should own default SPC URLs, with optional params to override GeoJSON URLs, print-page URLs, and the RSS URL for tests or future provider changes.
|
||||
|
||||
## Implementation Stages
|
||||
|
||||
### Stage 1: Raw Source and Schema
|
||||
|
||||
- Add schema constants.
|
||||
- Add `internal/sources/spc` package.
|
||||
- Implement `spc_convective_outlook` as a poll source.
|
||||
- Fetch all configured GeoJSON products and Day 1-3 print pages during a poll cycle.
|
||||
- Fetch RSS only when configured or retained as an optional supplemental source.
|
||||
- Emit one raw event with kind `outlook` and schema `raw.spc.convective_outlook.v1`.
|
||||
- Use the latest valid `ISSUE_ISO`, print-page `Updated:` timestamp, RSS `lastBuildDate`, or fetch time for `effectiveAt`, in that order.
|
||||
- Preserve partial fetch errors as source errors for the first implementation; do not emit incomplete outlook bundles unless a later explicit degraded-mode policy is added.
|
||||
- Keep source tests deterministic with local test servers and fixtures; do not call live SPC services from unit tests.
|
||||
|
||||
Tests:
|
||||
|
||||
- source driver builds as a `PollSource`
|
||||
- source advertises kind `outlook`
|
||||
- source emits one raw bundled event
|
||||
- source chooses expected effective time
|
||||
- source uses configured location metadata
|
||||
- source fails clearly on missing latitude/longitude
|
||||
- source fails clearly when a configured GeoJSON or print-page fetch fails
|
||||
|
||||
### Stage 2: GeoJSON and Spatial Matching
|
||||
|
||||
- Add an internal geometry helper package, for example `internal/geo`.
|
||||
- Support GeoJSON `Polygon` and `MultiPolygon`.
|
||||
- Implement point-in-polygon with ring support:
|
||||
- first ring is the exterior
|
||||
- subsequent rings are holes
|
||||
- boundary counts as inside
|
||||
- GeoJSON coordinate order is `[longitude, latitude]`
|
||||
- Use planar ray casting for this operational point-in-polygon check.
|
||||
|
||||
Tests:
|
||||
|
||||
- point inside polygon
|
||||
- point outside polygon
|
||||
- point on polygon boundary
|
||||
- point inside a hole is outside
|
||||
- point inside one multipolygon member is inside
|
||||
- longitude/latitude order regression test
|
||||
|
||||
### Stage 3: Normalizer and Canonical Mapping
|
||||
|
||||
- Add `model.WeatherOutlookRun` and `model.WeatherOutlook`.
|
||||
- Add `SPCConvectiveOutlookNormalizer`.
|
||||
- Match only `raw.spc.convective_outlook.v1`.
|
||||
- Decode the raw source bundle.
|
||||
- Map each GeoJSON feature to one `WeatherOutlook`.
|
||||
- Preserve SPC feature order within each product, then order products by day and type.
|
||||
- Map properties:
|
||||
- `VALID_ISO` -> `validFrom`
|
||||
- `EXPIRE_ISO` -> `validTo` and `expiresAt`
|
||||
- `ISSUE_ISO` -> `issuedAt`
|
||||
- `FORECASTER` -> `forecaster`
|
||||
- `LABEL` -> `label`
|
||||
- `LABEL2` -> `labelText`
|
||||
- `DN` -> `severityRank`
|
||||
- Derive:
|
||||
- `day` from product key or URL
|
||||
- `outlookType` from product key or URL
|
||||
- `id` from day, type, label, issuedAt, validFrom, and feature index
|
||||
- `containsLocation` from configured point and GeoJSON geometry
|
||||
- Enrich narrative fields from the matching Day 1-3 print page:
|
||||
- parse the main product text from the `<pre>` block
|
||||
- strip embedded tags and scripts before text parsing
|
||||
- preserve line breaks in discussion text
|
||||
- parse page/title metadata, product title, issued line, valid line, correction markers such as `CORR 1`, forecaster, summary, and full discussion where practical
|
||||
- use print-page text for canonical `headline`, `summary`, and `discussion`
|
||||
- Use RSS only as optional supplemental metadata when present; missing or incomplete RSS must not prevent GeoJSON plus print-page normalization.
|
||||
- Set run `asOf` to the latest valid `issuedAt` across features, falling back to print-page `Updated:` timestamps, RSS `lastBuildDate`, then input event time.
|
||||
- Set run `issuedAt` to the latest valid `issuedAt` across features.
|
||||
- Set output event `effectiveAt` to run `asOf`.
|
||||
|
||||
Tests:
|
||||
|
||||
- normalizer routes only raw SPC schema
|
||||
- categorical and probabilistic products map expected fields
|
||||
- `containsLocation` is true for a known point inside a fixture polygon
|
||||
- `containsLocation` is false outside
|
||||
- print-page discussion text maps to canonical headline, summary, and discussion
|
||||
- correction markers such as `CORR 1` are preserved where present
|
||||
- missing optional RSS still permits GeoJSON and print-page normalization
|
||||
- malformed required GeoJSON timestamps fail with useful context
|
||||
- canonical JSON shape does not expose raw-provider-only bundle internals
|
||||
|
||||
### Stage 4: Postgres Sink
|
||||
|
||||
Add tables:
|
||||
|
||||
- `outlook_runs`
|
||||
- `outlooks`
|
||||
|
||||
Suggested `outlook_runs` columns:
|
||||
|
||||
- event envelope columns
|
||||
- `location_id`
|
||||
- `location_name`
|
||||
- `latitude`
|
||||
- `longitude`
|
||||
- `as_of`
|
||||
- `issued_at`
|
||||
- `outlook_count`
|
||||
|
||||
Suggested `outlooks` columns:
|
||||
|
||||
- `run_event_id`
|
||||
- `outlook_index`
|
||||
- `as_of`
|
||||
- `product`
|
||||
- `day`
|
||||
- `outlook_type`
|
||||
- `label`
|
||||
- `label_text`
|
||||
- `severity_rank`
|
||||
- `valid_from`
|
||||
- `valid_to`
|
||||
- `issued_at`
|
||||
- `expires_at`
|
||||
- `forecaster`
|
||||
- `headline`
|
||||
- `summary`
|
||||
- `discussion`
|
||||
- `source_url`
|
||||
- `image_url`
|
||||
- `contains_location`
|
||||
- `geometry_json`
|
||||
|
||||
Suggested indexes:
|
||||
|
||||
- `outlook_runs(location_id, as_of)`
|
||||
- `outlooks(contains_location, valid_from, valid_to)`
|
||||
- `outlooks(day, outlook_type, label)`
|
||||
- `outlooks(valid_from, valid_to)`
|
||||
|
||||
Mapping rules:
|
||||
|
||||
- Store `geometry` as compact GeoJSON text in `geometry_json`.
|
||||
- Require run `asOf`.
|
||||
- Require outlook `validFrom`, `validTo`, `issuedAt`, `day`, `outlookType`, and `label`.
|
||||
- Preserve all outlook polygons, not only polygons containing the configured point.
|
||||
|
||||
Tests:
|
||||
|
||||
- schema includes outlook tables and indexes
|
||||
- mapper writes one run row plus one row per outlook
|
||||
- mapper stores compact geometry JSON
|
||||
- mapper rejects missing required run/outlook fields
|
||||
|
||||
### Stage 5: Config and Documentation
|
||||
|
||||
- Update sample config with `SPCConvectiveOutlookSTL`.
|
||||
- Add `outlook` to route examples.
|
||||
- Update current-behavior docs in the implementation change, not before behavior exists.
|
||||
- Update `docs/config.md` with source params.
|
||||
- Update `docs/integrations/events.md` with `weather.outlook.v1`.
|
||||
- Update `docs/integrations/postgres.md` with outlook table contract.
|
||||
- Update relevant internal docs under `docs/internal/` if source, normalizer, or sink behavior needs maintainer context.
|
||||
- Update `README.md` only with brief implemented-provider capability and links to canonical docs.
|
||||
|
||||
Docs should clearly state:
|
||||
|
||||
- Print pages are used for Day 1-3 narrative/discussion text.
|
||||
- RSS, if retained, is supplemental and not required for complete discussion coverage.
|
||||
- GeoJSON is used for polygons, validity windows, and point matching.
|
||||
- `containsLocation` is computed by weatherfeeder at ingestion time.
|
||||
- Geometry is stored for downstream audit/display.
|
||||
- Day 4-8 outlooks are not included in the initial implementation.
|
||||
|
||||
### Stage 6: Weatherapi Follow-Up
|
||||
|
||||
Do not include weatherapi changes in the first weatherfeeder implementation unless explicitly requested.
|
||||
|
||||
Likely future weatherapi endpoints:
|
||||
|
||||
- `GET /outlooks/convective`
|
||||
- `GET /outlooks/convective/active`
|
||||
- `GET /outlooks/convective/location`
|
||||
|
||||
Recommended API behavior:
|
||||
|
||||
- latest run by default
|
||||
- active outlooks filtered by current time and `containsLocation=true`
|
||||
- optional filter query params for `day`, `outlookType`, and `containsLocation`
|
||||
|
||||
## Verification Commands
|
||||
|
||||
Run focused tests:
|
||||
|
||||
```sh
|
||||
go test ./internal/sources ./internal/normalizers/... ./internal/sinks/postgres ./model
|
||||
```
|
||||
|
||||
Run full weatherfeeder tests:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- A configured SPC source emits raw outlook bundles.
|
||||
- Normalization produces `weather.outlook.v1` events.
|
||||
- Each outlook indicates whether the configured forecast point is inside its polygon.
|
||||
- All current Day 1-3 categorical/tornado/hail/wind products are represented.
|
||||
- Day 1-3 print-page discussion text is preserved where available.
|
||||
- RSS metadata is optional and supplemental if retained.
|
||||
- Postgres sink persists outlook runs and outlook rows.
|
||||
- Sample config and public docs describe the new kind, driver, schema, and storage contract.
|
||||
@@ -138,11 +138,10 @@ func parseDiscussions(pages []spcprovider.RawDiscussionPage) (map[int]parsedDisc
|
||||
out := map[int]parsedDiscussion{}
|
||||
var latestUpdated time.Time
|
||||
for _, page := range pages {
|
||||
text, err := spcprovider.ExtractProductText(page.Body)
|
||||
parsed, err := spcprovider.ParseDiscussionHTML(page.Body)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, fmt.Errorf("discussion %s: %w", page.Key, err)
|
||||
}
|
||||
parsed := spcprovider.ParseDiscussionText(text)
|
||||
day := page.Day
|
||||
if day == 0 {
|
||||
if meta, ok := spcprovider.DiscussionProductByKey(page.Key); ok {
|
||||
|
||||
@@ -99,6 +99,9 @@ func TestConvectiveOutlookNormalizerProducesCanonicalSchemaAndMapsSample(t *test
|
||||
if !strings.Contains(got.Discussion, "...DISCUSSION...") {
|
||||
t.Fatalf("Discussion missing product text: %q", got.Discussion)
|
||||
}
|
||||
if !strings.HasPrefix(got.Discussion, "SPC AC 111234") {
|
||||
t.Fatalf("Discussion = %q, want SPC product code prefix", got.Discussion)
|
||||
}
|
||||
if got.ID != "spc-convective-day1-categorical-slgt-2026-06-11T12:34:56Z-2026-06-11T13:00:00Z-0" {
|
||||
t.Fatalf("ID = %q", got.ID)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ var (
|
||||
preBlockRE = regexp.MustCompile(`(?is)<pre\b[^>]*>(.*?)</pre>`)
|
||||
tagRE = regexp.MustCompile(`(?is)<[^>]+>`)
|
||||
updatedRE = regexp.MustCompile(`(?im)^\s*Updated:\s*(.+?)\s*$`)
|
||||
pageUpdatedRE = regexp.MustCompile(`(?i)\bUpdated:\s*((?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)|(?:[A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+UTC\s+\d{4})|(?:\d{4}\s+UTC\s+[A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{4})|(?:\d{4}Z\s+[A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{4}))`)
|
||||
productCodeRE = regexp.MustCompile(`(?i)^SPC\s+AC\s+\d+\s*$`)
|
||||
sectionRE = regexp.MustCompile(`^\s*\.\.\.[A-Z0-9 /-]+\.{3}\s*$`)
|
||||
)
|
||||
|
||||
@@ -41,6 +43,20 @@ func ExtractProductText(rawHTML string) (string, error) {
|
||||
return "", fmt.Errorf("no useful pre block found")
|
||||
}
|
||||
|
||||
// ParseDiscussionHTML extracts SPC product text and page-level metadata from a
|
||||
// print-page HTML document.
|
||||
func ParseDiscussionHTML(rawHTML string) (DiscussionText, error) {
|
||||
text, err := ExtractProductText(rawHTML)
|
||||
if err != nil {
|
||||
return DiscussionText{}, err
|
||||
}
|
||||
parsed := ParseDiscussionText(text)
|
||||
if updatedAt := ParsePageUpdatedTimestamp(rawHTML); updatedAt != nil {
|
||||
parsed.UpdatedAt = updatedAt
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// ParseDiscussionText extracts common SPC narrative metadata from cleaned
|
||||
// product text.
|
||||
func ParseDiscussionText(text string) DiscussionText {
|
||||
@@ -55,6 +71,19 @@ func ParseDiscussionText(text string) DiscussionText {
|
||||
}
|
||||
}
|
||||
|
||||
// ParsePageUpdatedTimestamp parses the page-level Updated row from an SPC print
|
||||
// page. SPC currently places this outside the product <pre> block.
|
||||
func ParsePageUpdatedTimestamp(rawHTML string) *time.Time {
|
||||
text := cleanHTMLText(rawHTML)
|
||||
text = strings.ReplaceAll(text, "\u00a0", " ")
|
||||
text = strings.Join(strings.Fields(text), " ")
|
||||
match := pageUpdatedRE.FindStringSubmatch(text)
|
||||
if len(match) != 2 {
|
||||
return nil
|
||||
}
|
||||
return parseUpdatedValue(match[1])
|
||||
}
|
||||
|
||||
// ParseUpdatedTimestamp parses an SPC print-page Updated line when present.
|
||||
func ParseUpdatedTimestamp(text string) *time.Time {
|
||||
match := updatedRE.FindStringSubmatch(normalizeNewlines(text))
|
||||
@@ -68,7 +97,7 @@ func ParseUpdatedTimestamp(text string) *time.Time {
|
||||
func ParseProductTitle(text string) string {
|
||||
for _, line := range strings.Split(normalizeNewlines(text), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "Updated:") {
|
||||
if line == "" || strings.HasPrefix(line, "Updated:") || productCodeRE.MatchString(line) {
|
||||
continue
|
||||
}
|
||||
return line
|
||||
@@ -142,6 +171,7 @@ func parseUpdatedValue(value string) *time.Time {
|
||||
return t
|
||||
}
|
||||
for _, layout := range []string{
|
||||
"Mon Jan 2 15:04:05 UTC 2006",
|
||||
"1504 UTC Mon Jan 2 2006",
|
||||
"1504Z Mon Jan 2 2006",
|
||||
"3:04 PM UTC Mon Jan 2 2006",
|
||||
|
||||
@@ -22,18 +22,19 @@ func TestExtractProductTextCleansPreBlock(t *testing.T) {
|
||||
if !strings.Contains(got, "Day 1 Convective Outlook") {
|
||||
t.Fatalf("ExtractProductText() missing headline: %q", got)
|
||||
}
|
||||
if !strings.HasPrefix(got, "SPC AC 111234") {
|
||||
t.Fatalf("ExtractProductText() = %q, want product code prefix", got)
|
||||
}
|
||||
if strings.HasPrefix(got, "\n") || strings.HasSuffix(got, "\n") {
|
||||
t.Fatalf("ExtractProductText() retained surrounding blank lines: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDiscussionTextExtractsHeadlineSummaryAndUpdated(t *testing.T) {
|
||||
text, err := ExtractProductText(string(readTestFile(t, "day1_prt.html")))
|
||||
func TestParseDiscussionHTMLExtractsHeadlineSummaryAndUpdated(t *testing.T) {
|
||||
got, err := ParseDiscussionHTML(string(readTestFile(t, "day1_prt.html")))
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractProductText() error = %v", err)
|
||||
t.Fatalf("ParseDiscussionHTML() error = %v", err)
|
||||
}
|
||||
|
||||
got := ParseDiscussionText(text)
|
||||
if got.ProductTitle != "Day 1 Convective Outlook" {
|
||||
t.Fatalf("ProductTitle = %q", got.ProductTitle)
|
||||
}
|
||||
@@ -47,27 +48,37 @@ func TestParseDiscussionTextExtractsHeadlineSummaryAndUpdated(t *testing.T) {
|
||||
if !strings.Contains(got.Discussion, "...DISCUSSION...") {
|
||||
t.Fatalf("Discussion missing full text: %q", got.Discussion)
|
||||
}
|
||||
if !strings.HasPrefix(got.Discussion, "SPC AC 111234") {
|
||||
t.Fatalf("Discussion = %q, want product code prefix", got.Discussion)
|
||||
}
|
||||
wantUpdated := time.Date(2026, 6, 11, 12, 45, 0, 0, time.UTC)
|
||||
if got.UpdatedAt == nil || !got.UpdatedAt.Equal(wantUpdated) {
|
||||
t.Fatalf("UpdatedAt = %v, want %s", got.UpdatedAt, wantUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProductTitleSkipsSPCProductCode(t *testing.T) {
|
||||
got := ParseProductTitle("SPC AC 101959\nDay 1 Convective Outlook\nNWS Storm Prediction Center Norman OK")
|
||||
if got != "Day 1 Convective Outlook" {
|
||||
t.Fatalf("ParseProductTitle() = %q, want Day 1 Convective Outlook", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDiscussionTextPreservesCorrectionMarker(t *testing.T) {
|
||||
text, err := ExtractProductText(string(readTestFile(t, "day2_prt_corr.html")))
|
||||
got, err := ParseDiscussionHTML(string(readTestFile(t, "day2_prt_corr.html")))
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractProductText() error = %v", err)
|
||||
t.Fatalf("ParseDiscussionHTML() error = %v", err)
|
||||
}
|
||||
|
||||
got := ParseDiscussionText(text)
|
||||
if !strings.Contains(got.Headline, "CORR 1") {
|
||||
t.Fatalf("Headline = %q, want correction marker", got.Headline)
|
||||
}
|
||||
if !strings.Contains(got.Discussion, "CORR 1") {
|
||||
t.Fatalf("Discussion = %q, want correction marker", got.Discussion)
|
||||
}
|
||||
if got.UpdatedAt != nil {
|
||||
t.Fatalf("UpdatedAt = %v, want nil", got.UpdatedAt)
|
||||
wantUpdated := time.Date(2026, 6, 11, 17, 30, 0, 0, time.UTC)
|
||||
if got.UpdatedAt == nil || !got.UpdatedAt.Equal(wantUpdated) {
|
||||
t.Fatalf("UpdatedAt = %v, want %s", got.UpdatedAt, wantUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,3 +99,11 @@ func TestParseUpdatedTimestampAcceptsSPCUTCFormat(t *testing.T) {
|
||||
t.Fatalf("ParseUpdatedTimestamp() = %v, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePageUpdatedTimestampAcceptsLiveSPCShape(t *testing.T) {
|
||||
got := ParsePageUpdatedTimestamp(string(readTestFile(t, "day3_prt.html")))
|
||||
want := time.Date(2026, 6, 11, 20, 0, 0, 0, time.UTC)
|
||||
if got == nil || !got.Equal(want) {
|
||||
t.Fatalf("ParsePageUpdatedTimestamp() = %v, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
<html>
|
||||
<head><title>Day 1 Convective Outlook</title></head>
|
||||
<body>
|
||||
<table>
|
||||
<tr><td align="center" class="rpttext" nowrap>Updated: Thu Jun 11 12:45:00 UTC 2026 (<a href="archive/day1-geojson.zip">geojson</a>)</td></tr>
|
||||
</table>
|
||||
<pre>
|
||||
<script>window.bad = "<b>ignore me</b>";</script>
|
||||
SPC AC 111234
|
||||
Day 1 Convective Outlook
|
||||
NWS Storm Prediction Center Norman OK
|
||||
Updated: 2026-06-11T12:45:00Z
|
||||
|
||||
...SUMMARY...
|
||||
Severe thunderstorms are possible across parts of the central Plains
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<table>
|
||||
<tr><td class="rpttext">Updated: Thu Jun 11 17:30:00 UTC 2026 </td></tr>
|
||||
</table>
|
||||
<pre>
|
||||
SPC AC 111730
|
||||
Day 2 Convective Outlook CORR 1
|
||||
NWS Storm Prediction Center Norman OK
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<table>
|
||||
<tr><td class="rpttext">Updated: Thu Jun 11 20:00:00 UTC 2026 </td></tr>
|
||||
</table>
|
||||
<pre>
|
||||
SPC AC 112000
|
||||
Day 3 Convective Outlook
|
||||
NWS Storm Prediction Center Norman OK
|
||||
Updated: 2026-06-11T20:00:00Z
|
||||
|
||||
...SUMMARY...
|
||||
A corridor of strong to severe storms may develop near a frontal zone.
|
||||
|
||||
@@ -233,6 +233,8 @@
|
||||
// - run_event_id TEXT -> outlook_runs.event_id / payload.outlooks[i]
|
||||
// - outlook_index INTEGER -> i (array position in payload.outlooks)
|
||||
// - as_of TIMESTAMPTZ -> payload.asOf (copied from parent)
|
||||
// - outlook_id TEXT -> payload.outlooks[i].id
|
||||
// - provider TEXT -> payload.outlooks[i].provider
|
||||
// - product TEXT -> payload.outlooks[i].product
|
||||
// - day INTEGER -> payload.outlooks[i].day
|
||||
// - outlook_type TEXT -> payload.outlooks[i].outlookType
|
||||
|
||||
@@ -404,6 +404,8 @@ func mapOutlookEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
|
||||
"run_event_id": e.ID,
|
||||
"outlook_index": i,
|
||||
"as_of": asOf,
|
||||
"outlook_id": outlook.ID,
|
||||
"provider": outlook.Provider,
|
||||
"product": outlook.Product,
|
||||
"day": outlook.Day,
|
||||
"outlook_type": outlook.OutlookType,
|
||||
|
||||
@@ -312,6 +312,12 @@ func TestMapPostgresEventOutlookStructPayload(t *testing.T) {
|
||||
if got := writes[1].Values["outlook_index"]; got != 0 {
|
||||
t.Fatalf("first outlook index = %#v, want 0", got)
|
||||
}
|
||||
if got := writes[1].Values["outlook_id"]; got != "outlook-1" {
|
||||
t.Fatalf("first outlook_id = %#v, want outlook-1", got)
|
||||
}
|
||||
if got := writes[1].Values["provider"]; got != "spc" {
|
||||
t.Fatalf("first provider = %#v, want spc", got)
|
||||
}
|
||||
if got := writes[1].Values["valid_from"]; got != run.Outlooks[0].ValidFrom.UTC() {
|
||||
t.Fatalf("first valid_from = %#v, want UTC %s", got, run.Outlooks[0].ValidFrom.UTC())
|
||||
}
|
||||
@@ -335,6 +341,57 @@ func TestMapPostgresEventOutlookRejectsMissingAsOf(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapPostgresEventOutlookRejectsMissingIDAndProvider(t *testing.T) {
|
||||
base := model.WeatherOutlook{
|
||||
ID: "outlook-1",
|
||||
Provider: "spc",
|
||||
Product: "convective",
|
||||
Day: 1,
|
||||
OutlookType: "categorical",
|
||||
Label: "SLGT",
|
||||
ValidFrom: time.Date(2026, 6, 11, 13, 0, 0, 0, time.UTC),
|
||||
ValidTo: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
||||
IssuedAt: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
||||
ExpiresAt: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,39],[-91,38]]]}`),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*model.WeatherOutlook)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing id",
|
||||
mutate: func(outlook *model.WeatherOutlook) { outlook.ID = "" },
|
||||
wantErr: "outlooks[0].id is required",
|
||||
},
|
||||
{
|
||||
name: "missing provider",
|
||||
mutate: func(outlook *model.WeatherOutlook) { outlook.Provider = "" },
|
||||
wantErr: "outlooks[0].provider is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
outlook := base
|
||||
tt.mutate(&outlook)
|
||||
run := model.WeatherOutlookRun{
|
||||
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
||||
Outlooks: []model.WeatherOutlook{outlook},
|
||||
}
|
||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV1, "outlook", run))
|
||||
if err == nil {
|
||||
t.Fatalf("mapPostgresEvent() error = nil, want %q", tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapPostgresEventOutlookRejectsMissingRequiredTimes(t *testing.T) {
|
||||
run := model.WeatherOutlookRun{
|
||||
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
||||
|
||||
@@ -329,6 +329,8 @@ func PostgresSchema() fksinks.PostgresSchema {
|
||||
{Name: "run_event_id", Type: "TEXT REFERENCES outlook_runs(event_id) ON DELETE CASCADE", Nullable: false},
|
||||
{Name: "outlook_index", Type: "INTEGER", Nullable: false},
|
||||
{Name: "as_of", Type: "TIMESTAMPTZ", Nullable: false},
|
||||
{Name: "outlook_id", Type: "TEXT", Nullable: false},
|
||||
{Name: "provider", Type: "TEXT", Nullable: false},
|
||||
{Name: "product", Type: "TEXT", Nullable: false},
|
||||
{Name: "day", Type: "INTEGER", Nullable: false},
|
||||
{Name: "outlook_type", Type: "TEXT", Nullable: false},
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestWeatherPostgresSchemaIncludesOutlookTables(t *testing.T) {
|
||||
assertTableIndex(t, tableOutlookRuns, "idx_wf_outlook_run_as_of", []string{"as_of"})
|
||||
|
||||
outlookColumns := columnsForTable(t, tableOutlooks)
|
||||
for _, col := range []string{"run_event_id", "outlook_index", "as_of", "product", "day", "outlook_type", "label", "label_text", "severity_rank", "valid_from", "valid_to", "issued_at", "expires_at", "forecaster", "headline", "summary", "discussion", "source_url", "image_url", "contains_location", "geometry_json"} {
|
||||
for _, col := range []string{"run_event_id", "outlook_index", "as_of", "outlook_id", "provider", "product", "day", "outlook_type", "label", "label_text", "severity_rank", "valid_from", "valid_to", "issued_at", "expires_at", "forecaster", "headline", "summary", "discussion", "source_url", "image_url", "contains_location", "geometry_json"} {
|
||||
if !outlookColumns[col] {
|
||||
t.Fatalf("%s missing %s column", tableOutlooks, col)
|
||||
}
|
||||
|
||||
@@ -377,11 +377,7 @@ func latestIssueTime(raw []byte) time.Time {
|
||||
}
|
||||
|
||||
func discussionUpdatedTime(rawHTML string) time.Time {
|
||||
text, err := spcprovider.ExtractProductText(rawHTML)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
t := spcprovider.ParseUpdatedTimestamp(text)
|
||||
t := spcprovider.ParsePageUpdatedTimestamp(rawHTML)
|
||||
if t == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user