Compare commits
24 Commits
819ac24aed
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b88be4dd2 | |||
| 456a46e01b | |||
| 3740c779eb | |||
| 6943a5ebc9 | |||
| 9a09454621 | |||
| b8c6708439 | |||
| a62cb87b78 | |||
| 0b5eaf46f4 | |||
| 882059014c | |||
| 0a3e52d0e5 | |||
| 2b19a121fa | |||
| 29c65971eb | |||
| 3ecf4c5b7f | |||
| f402e27542 | |||
| f720b6cdc0 | |||
| 50215d2105 | |||
| 74411e3f54 | |||
| 4358a7cdce | |||
| dcea5261ab | |||
| 97141c7a9b | |||
| 2e2d36024e | |||
| 4d2cddf801 | |||
| 21a35a5205 | |||
| 435d1ade07 |
@@ -63,7 +63,7 @@ func Decode(payload []byte) (any, error) {
|
||||
case standards.SchemaWeatherAlertV1:
|
||||
var out model.WeatherAlertRun
|
||||
return &out, json.Unmarshal(evt.Payload, &out)
|
||||
case standards.SchemaWeatherOutlookV1:
|
||||
case standards.SchemaWeatherOutlookV2:
|
||||
var out model.WeatherOutlookRun
|
||||
return &out, json.Unmarshal(evt.Payload, &out)
|
||||
default:
|
||||
|
||||
@@ -9,8 +9,8 @@ import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||
## Purpose
|
||||
|
||||
Package `model` defines `weatherfeeder`'s canonical weather payload structs.
|
||||
These structs are emitted as the `payload` of canonical `weather.*.v1` events
|
||||
and are also the domain types consumed by downstream applications such as
|
||||
These structs are emitted as the `payload` of canonical `weather.*` events and
|
||||
are also the domain types consumed by downstream applications such as
|
||||
`weatherapi`.
|
||||
|
||||
The JSON field tags on these structs are part of the wire contract. For the full
|
||||
@@ -27,7 +27,7 @@ Current canonical schema families map to these public types:
|
||||
| `weather.forecast_discussion.v1` | `WeatherForecastDiscussion` |
|
||||
| `weather.weather_story.v1` | `WeatherStoryRun` |
|
||||
| `weather.alert.v1` | `WeatherAlertRun` |
|
||||
| `weather.outlook.v1` | `WeatherOutlookRun` |
|
||||
| `weather.outlook.v2` | `WeatherOutlookRun` |
|
||||
|
||||
Related child types include:
|
||||
|
||||
@@ -38,6 +38,7 @@ Related child types include:
|
||||
- `WeatherAlert`
|
||||
- `WeatherAlertReference`
|
||||
- `WeatherOutlook`
|
||||
- `WeatherOutlookDiscussion`
|
||||
- `WMOCode`
|
||||
|
||||
## Wire And Compatibility Rules
|
||||
|
||||
@@ -41,6 +41,12 @@ Canonical schemas emitted after normalization:
|
||||
| `SchemaWeatherForecastDiscussionV1` | `weather.forecast_discussion.v1` |
|
||||
| `SchemaWeatherStoryV1` | `weather.weather_story.v1` |
|
||||
| `SchemaWeatherAlertV1` | `weather.alert.v1` |
|
||||
| `SchemaWeatherOutlookV2` | `weather.outlook.v2` |
|
||||
|
||||
Historical canonical schema constant:
|
||||
|
||||
| Constant | Value |
|
||||
|---|---|
|
||||
| `SchemaWeatherOutlookV1` | `weather.outlook.v1` |
|
||||
|
||||
## Raw Schema Constants
|
||||
|
||||
@@ -37,7 +37,7 @@ Canonical schemas emitted after normalization:
|
||||
| `forecast_discussion` | `weather.forecast_discussion.v1` | `WeatherForecastDiscussion` |
|
||||
| `weather_story` | `weather.weather_story.v1` | `WeatherStoryRun` |
|
||||
| `alert` | `weather.alert.v1` | `WeatherAlertRun` |
|
||||
| `outlook` | `weather.outlook.v1` | `WeatherOutlookRun` |
|
||||
| `outlook` | `weather.outlook.v2` | `WeatherOutlookRun` |
|
||||
|
||||
Raw upstream schemas emitted by current sources:
|
||||
|
||||
@@ -211,8 +211,9 @@ Payload type: `WeatherAlertRun`.
|
||||
| `instruction` | string | no | Alert instruction. |
|
||||
| `sent` | timestamp | no | Provider sent time. |
|
||||
| `effective` | timestamp | no | Effective time. |
|
||||
| `onset` | timestamp | no | Onset time. |
|
||||
| `expires` | timestamp | no | Expiration time. |
|
||||
| `onset` | timestamp | no | Alert period start. |
|
||||
| `ends` | timestamp | no | Alert period end. |
|
||||
| `expires` | timestamp | no | Provider expiration metadata; not necessarily the alert period end. |
|
||||
| `areaDescription` | string | no | Affected area description. |
|
||||
| `senderName` | string | no | Provider sender name. |
|
||||
| `references` | array | no | Related alerts. |
|
||||
@@ -220,13 +221,15 @@ Payload type: `WeatherAlertRun`.
|
||||
`references[]` entries contain optional `id`, `identifier`, `sender`, and
|
||||
`sent` fields.
|
||||
|
||||
## `weather.outlook.v1`
|
||||
## `weather.outlook.v2`
|
||||
|
||||
Payload type: `WeatherOutlookRun`.
|
||||
|
||||
The current producer is the SPC convective outlook normalizer. It emits Day 1-3
|
||||
convective outlook polygons for categorical, tornado, hail, and wind products.
|
||||
All timestamps are UTC.
|
||||
convective outlook snapshots for categorical, tornado, hail, and wind products
|
||||
that apply to the configured forecast point. Raw SPC bundles remain complete;
|
||||
canonical outlook payloads are filtered to local polygons. All timestamps are
|
||||
UTC.
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|:---:|---|
|
||||
@@ -236,7 +239,8 @@ All timestamps are UTC.
|
||||
| `longitude` | number | no | Configured point longitude in decimal degrees. |
|
||||
| `asOf` | timestamp | yes | Snapshot time. For SPC, this is the latest outlook issue time when available. |
|
||||
| `issuedAt` | timestamp | no | Latest issue time across outlook features when any feature exists. |
|
||||
| `outlooks` | array | yes | Ordered outlook polygons. |
|
||||
| `outlooks` | array | yes | Ordered outlook polygons containing the configured point. |
|
||||
| `discussions` | array | yes | Run-level day discussions for retained outlook days. |
|
||||
|
||||
`outlooks[]` entries:
|
||||
|
||||
@@ -255,19 +259,53 @@ All timestamps are UTC.
|
||||
| `issuedAt` | timestamp | yes | Feature issue time. |
|
||||
| `expiresAt` | timestamp | yes | Expiration time; currently equal to `validTo`. |
|
||||
| `forecaster` | string | no | SPC forecaster text, when present. |
|
||||
| `headline` | string | no | Matching Day 1-3 print-page product title. |
|
||||
| `summary` | string | no | Text from the print-page `...SUMMARY...` section. |
|
||||
| `discussion` | string | no | Cleaned full print-page product text. |
|
||||
| `sourceUrl` | string | no | GeoJSON product URL for this outlook feature. |
|
||||
| `imageUrl` | string | no | Reserved for provider image URLs; currently empty. |
|
||||
| `containsLocation` | boolean | yes | Whether the configured point is inside or on the boundary of the polygon. |
|
||||
| `containsLocation` | boolean | yes | Always `true` for emitted v2 outlooks. |
|
||||
| `geometry` | object | yes | Compact GeoJSON `Polygon` or `MultiPolygon` geometry. |
|
||||
|
||||
`geometry` preserves the SPC feature geometry as compact GeoJSON using
|
||||
`[longitude, latitude]` coordinate order. `containsLocation` is computed with
|
||||
that geometry and the configured source `latitude`/`longitude`; boundary points
|
||||
count as contained. All outlook polygons are emitted, including polygons that do
|
||||
not contain the configured point.
|
||||
count as contained. Polygons that do not contain the configured point are not
|
||||
included in canonical v2 payloads.
|
||||
|
||||
`discussions[]` entries:
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|:---:|---|
|
||||
| `day` | integer | yes | SPC outlook day, currently `1`, `2`, or `3`. |
|
||||
| `headline` | string | no | Matching Day 1-3 print-page product title. |
|
||||
| `summary` | string | no | Text from the print-page `...SUMMARY...` section. |
|
||||
| `discussion` | string | no | Cleaned full print-page product text. |
|
||||
| `updatedAt` | timestamp | no | Print-page update time, when present. |
|
||||
|
||||
When no SPC polygons apply locally, the run is still emitted with `outlooks: []`
|
||||
and `discussions: []`. Discussions are included only for days represented by at
|
||||
least one retained outlook, and multiple retained outlook types for the same day
|
||||
share one discussion entry.
|
||||
|
||||
### SPC Outlook Supersession
|
||||
|
||||
Consumers should prefer latest-run semantics for current conditions: read the
|
||||
latest `WeatherOutlookRun` for the configured location and use its `outlooks`
|
||||
and `discussions` arrays together.
|
||||
|
||||
Historical SQL consumers that collapse older rows should identify superseded
|
||||
outlooks by `provider`, `product`, `outlookType`, `validFrom`, and `validTo`,
|
||||
then keep rows with the greatest `issuedAt`. `day` and `label` are not identity
|
||||
fields. When multiple retained polygons share that latest `issuedAt`, preserve
|
||||
the full group.
|
||||
|
||||
## Legacy `weather.outlook.v1`
|
||||
|
||||
`weather.outlook.v1` is a historical canonical schema retained as a standards
|
||||
constant for older data and consumers. Current SPC normalization emits
|
||||
`weather.outlook.v2`.
|
||||
|
||||
The v1 payload used `WeatherOutlookRun` and placed `headline`, `summary`, and
|
||||
`discussion` on each `outlooks[]` polygon. It also represented the pre-v2 SPC
|
||||
canonical behavior, where national polygons were preserved in canonical output.
|
||||
|
||||
## Compact Example
|
||||
|
||||
|
||||
@@ -44,6 +44,9 @@ normalizer uses fields under `properties` such as `stationId`, `stationName`,
|
||||
`nws_alerts` expects an alerts FeatureCollection. The normalizer uses the
|
||||
collection `updated` timestamp, `title`, each feature ID, alert classification
|
||||
fields, narrative fields, timing fields, sender fields, and references.
|
||||
`properties.onset` and `properties.ends` map to the canonical alert period
|
||||
start and end. `properties.expires` maps only to canonical `expires` provider
|
||||
metadata and is not treated as the alert period end.
|
||||
|
||||
`nws_forecast_hourly` and `nws_forecast_narrative` expect gridpoint forecast
|
||||
GeoJSON with `properties.generatedAt`, `properties.updateTime`, elevation,
|
||||
@@ -98,8 +101,9 @@ unset. Forecast temperatures are converted to Celsius when NWS supplies
|
||||
Fahrenheit, and wind speed strings are converted to kilometers per hour.
|
||||
|
||||
Alert timing fields are parsed best-effort. Invalid per-alert timestamps are
|
||||
left unset rather than failing the whole alert run. Missing alert IDs are
|
||||
synthesized from the run snapshot time and array position.
|
||||
left unset rather than failing the whole alert run. NWS `ends` is preserved
|
||||
separately from `expires`; `expires` does not fall back to `ends`. Missing alert
|
||||
IDs are synthesized from the run snapshot time and array position.
|
||||
|
||||
Forecast discussion parsing requires an issue time. Weather story entries require
|
||||
start time, end time, and update time.
|
||||
|
||||
@@ -23,7 +23,7 @@ Events are mapped only for canonical weather schemas:
|
||||
- `weather.forecast_discussion.v1`
|
||||
- `weather.weather_story.v1`
|
||||
- `weather.alert.v1`
|
||||
- `weather.outlook.v1`
|
||||
- `weather.outlook.v2`
|
||||
|
||||
Unsupported schemas produce no writes for this sink. Mapped events are inserted
|
||||
transactionally. Inserts use ordinary `INSERT`; duplicate primary keys fail the
|
||||
@@ -59,6 +59,7 @@ Parent tables store the feed event envelope:
|
||||
| `alert_references` | `run_event_id`, `alert_index`, `reference_index` | `as_of` |
|
||||
| `outlook_runs` | `event_id` | `as_of` |
|
||||
| `outlooks` | `run_event_id`, `outlook_index` | `as_of` |
|
||||
| `outlook_discussions` | `run_event_id`, `discussion_index` | `as_of` |
|
||||
|
||||
## Table Contract
|
||||
|
||||
@@ -352,6 +353,7 @@ Indexes:
|
||||
| `sent` | `TIMESTAMPTZ` | yes | `payload.alerts[].sent` |
|
||||
| `effective` | `TIMESTAMPTZ` | yes | `payload.alerts[].effective` |
|
||||
| `onset` | `TIMESTAMPTZ` | yes | `payload.alerts[].onset` |
|
||||
| `ends` | `TIMESTAMPTZ` | yes | `payload.alerts[].ends` |
|
||||
| `expires` | `TIMESTAMPTZ` | yes | `payload.alerts[].expires` |
|
||||
| `area_description` | `TEXT` | yes | `payload.alerts[].areaDescription` |
|
||||
| `sender_name` | `TEXT` | yes | `payload.alerts[].senderName` |
|
||||
@@ -408,6 +410,7 @@ Indexes:
|
||||
| `as_of` | `TIMESTAMPTZ` | no | `payload.asOf` |
|
||||
| `issued_at` | `TIMESTAMPTZ` | yes | `payload.issuedAt` |
|
||||
| `outlook_count` | `INTEGER` | no | `len(payload.outlooks)` |
|
||||
| `discussion_count` | `INTEGER` | no | `len(payload.discussions)` |
|
||||
|
||||
### `outlooks`
|
||||
|
||||
@@ -442,14 +445,36 @@ Indexes:
|
||||
| `issued_at` | `TIMESTAMPTZ` | no | `payload.outlooks[].issuedAt` |
|
||||
| `expires_at` | `TIMESTAMPTZ` | no | `payload.outlooks[].expiresAt` |
|
||||
| `forecaster` | `TEXT` | yes | `payload.outlooks[].forecaster` |
|
||||
| `headline` | `TEXT` | yes | `payload.outlooks[].headline` |
|
||||
| `summary` | `TEXT` | yes | `payload.outlooks[].summary` |
|
||||
| `discussion` | `TEXT` | yes | `payload.outlooks[].discussion` |
|
||||
| `source_url` | `TEXT` | yes | `payload.outlooks[].sourceUrl` |
|
||||
| `image_url` | `TEXT` | yes | `payload.outlooks[].imageUrl` |
|
||||
| `contains_location` | `BOOLEAN` | no | `payload.outlooks[].containsLocation` |
|
||||
| `geometry_json` | `TEXT` | no | Compact JSON from `payload.outlooks[].geometry` |
|
||||
|
||||
### `outlook_discussions`
|
||||
|
||||
Primary key: `run_event_id`, `discussion_index`
|
||||
|
||||
Prune column: `as_of`
|
||||
|
||||
Foreign key: `run_event_id` references `outlook_runs(event_id)` with cascade
|
||||
delete.
|
||||
|
||||
Indexes:
|
||||
|
||||
- `idx_wf_outlook_discussions_day_as_of` on `day`, `as_of`
|
||||
- unique `idx_wf_outlook_discussions_run_day` on `run_event_id`, `day`
|
||||
|
||||
| Column | Type | Null | Source |
|
||||
|---|---|:---:|---|
|
||||
| `run_event_id` | `TEXT REFERENCES outlook_runs(event_id) ON DELETE CASCADE` | no | Parent event ID. |
|
||||
| `discussion_index` | `INTEGER` | no | `payload.discussions[]` index. |
|
||||
| `as_of` | `TIMESTAMPTZ` | no | Parent `payload.asOf` |
|
||||
| `day` | `INTEGER` | no | `payload.discussions[].day` |
|
||||
| `headline` | `TEXT` | yes | `payload.discussions[].headline` |
|
||||
| `summary` | `TEXT` | yes | `payload.discussions[].summary` |
|
||||
| `discussion` | `TEXT` | yes | `payload.discussions[].discussion` |
|
||||
| `updated_at` | `TIMESTAMPTZ` | yes | `payload.discussions[].updatedAt` |
|
||||
|
||||
## Retention
|
||||
|
||||
When sink param `prune` is set, every successful write transaction deletes rows
|
||||
@@ -472,5 +497,6 @@ binary does not provide CLI commands for them.
|
||||
- `WeatherAlertRun`: read `alert_runs`, join `alerts` by `run_event_id` ordered
|
||||
by `alert_index`, then join `alert_references` by `run_event_id` and
|
||||
`alert_index` ordered by `reference_index`.
|
||||
- `WeatherOutlookRun`: read `outlook_runs`, then join `outlooks` by
|
||||
`run_event_id` ordered by `outlook_index`.
|
||||
- `WeatherOutlookRun`: read `outlook_runs`, join `outlooks` by `run_event_id`
|
||||
ordered by `outlook_index`, then join `outlook_discussions` by
|
||||
`run_event_id` ordered by `discussion_index`.
|
||||
|
||||
@@ -13,7 +13,7 @@ events are documented in [event wire contract](events.md).
|
||||
|
||||
| Driver | Kind | Raw schema | Canonical schema |
|
||||
| --- | --- | --- | --- |
|
||||
| `spc_convective_outlook` | `outlook` | `raw.spc.convective_outlook.v1` | `weather.outlook.v1` |
|
||||
| `spc_convective_outlook` | `outlook` | `raw.spc.convective_outlook.v1` | `weather.outlook.v2` |
|
||||
|
||||
## Config Requirements
|
||||
|
||||
@@ -52,8 +52,8 @@ It also fetches three required print pages:
|
||||
- Day 3 convective outlook print page
|
||||
|
||||
GeoJSON products are authoritative for outlook polygons, valid windows, issue
|
||||
times, labels, and severity rank. Print pages are authoritative for headline,
|
||||
summary, and discussion text.
|
||||
times, labels, and severity rank. Print pages are authoritative for run-level
|
||||
day discussion headline, summary, and discussion text.
|
||||
|
||||
## Accept Headers
|
||||
|
||||
@@ -99,23 +99,33 @@ Raw source `effective_at` prefers:
|
||||
4. fetch time.
|
||||
|
||||
The normalizer sets canonical `asOf` and normalized event `effective_at` from
|
||||
the latest valid outlook feature `issuedAt`, with fallback to print-page update
|
||||
time and then the incoming event envelope.
|
||||
the latest valid GeoJSON issue time across the complete raw bundle, including
|
||||
empty no-risk placeholders, with fallback to print-page update time and then the
|
||||
incoming event envelope.
|
||||
|
||||
## Mapping Notes
|
||||
|
||||
Each GeoJSON feature becomes one canonical outlook. Products are ordered by day,
|
||||
then categorical, tornado, hail, and wind. Feature order is preserved within
|
||||
each product.
|
||||
The raw source fetches and envelopes the complete SPC bundle. The normalizer
|
||||
decodes every configured GeoJSON product, skips empty no-risk
|
||||
`GeometryCollection` placeholders, and emits canonical outlooks only when the
|
||||
configured point is inside or on the boundary of a real feature. Products are
|
||||
ordered by day, then categorical, tornado, hail, and wind. Retained feature order
|
||||
is preserved within each product.
|
||||
|
||||
The normalizer computes `containsLocation` with the configured latitude and
|
||||
longitude against compact GeoJSON `Polygon` or `MultiPolygon` geometry.
|
||||
Coordinates use GeoJSON order, `[longitude, latitude]`, and boundary points
|
||||
count as contained.
|
||||
|
||||
All outlook polygons are preserved, including polygons that do not contain the
|
||||
configured point. Matching print-page headline, summary, and discussion text is
|
||||
attached to every outlook for the same day.
|
||||
Canonical outlook runs are emitted even when no polygons apply locally. In that
|
||||
case the payload contains empty `outlooks` and `discussions` arrays.
|
||||
|
||||
Print-page prose is represented as run-level day discussions. Discussions are
|
||||
included only for days represented by at least one retained outlook. Multiple
|
||||
retained outlook types for the same day share one discussion entry.
|
||||
|
||||
For downstream current-state and historical supersession guidance, see the
|
||||
[event wire contract](events.md#spc-outlook-supersession).
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ Current mappings:
|
||||
| `raw.nws.forecast_discussion.v1` | `weather.forecast_discussion.v1` |
|
||||
| `raw.nws.weatherstories.v1` | `weather.weather_story.v1` |
|
||||
| `raw.nws.alerts.v1` | `weather.alert.v1` |
|
||||
| `raw.spc.convective_outlook.v1` | `weather.outlook.v1` |
|
||||
| `raw.spc.convective_outlook.v1` | `weather.outlook.v2` |
|
||||
|
||||
## Boundaries
|
||||
|
||||
@@ -59,13 +59,13 @@ Weatherfeeder registers normalizers in a stable order:
|
||||
|
||||
The current normalizers avoid ambiguous matches by using schema equality.
|
||||
|
||||
The SPC outlook normalizer decodes the raw multi-document bundle, maps each
|
||||
GeoJSON feature to a canonical outlook, and enriches all outlooks for a day with
|
||||
the matching print-page headline, summary, and discussion. It preserves compact
|
||||
The SPC outlook normalizer decodes the raw multi-document bundle, maps
|
||||
location-containing GeoJSON features to canonical outlooks, and adds one
|
||||
run-level print-page discussion per retained outlook day. It preserves compact
|
||||
GeoJSON feature geometry and computes `containsLocation` with
|
||||
`internal/geo.ContainsPoint` using the source-configured point. Boundary points
|
||||
count as contained, and all polygons are preserved whether or not they contain
|
||||
the point.
|
||||
count as contained. Polygons that do not contain the point are omitted from the
|
||||
canonical run.
|
||||
|
||||
## State
|
||||
|
||||
|
||||
@@ -19,11 +19,16 @@ Inputs are canonical feed events. The mapper currently handles these schemas:
|
||||
- `weather.forecast_discussion.v1`
|
||||
- `weather.weather_story.v1`
|
||||
- `weather.alert.v1`
|
||||
- `weather.outlook.v1`
|
||||
- `weather.outlook.v2`
|
||||
|
||||
Outputs are feedkit `PostgresWrite` values for weatherfeeder-owned tables.
|
||||
Unsupported schemas produce no writes and no error.
|
||||
|
||||
Outlook runs are written to `outlook_runs`, retained local polygons are written
|
||||
to `outlooks`, and run-level day discussions are written to
|
||||
`outlook_discussions`. The parent run row stores `outlook_count` and
|
||||
`discussion_count`.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Weatherfeeder owns table definitions in `schema.go`.
|
||||
@@ -82,6 +87,7 @@ Child rows use positional indexes to preserve canonical array order:
|
||||
- `alert_index`
|
||||
- `reference_index`
|
||||
- `outlook_index`
|
||||
- `discussion_index`
|
||||
|
||||
Required canonical fields are validated before writes are returned:
|
||||
|
||||
@@ -94,7 +100,10 @@ Required canonical fields are validated before writes are returned:
|
||||
- alert runs require `asOf`, and each alert requires `id`;
|
||||
- outlook runs require `asOf`, and each outlook requires `id`, `provider`,
|
||||
`product`, `day`, `outlookType`, `label`, `validFrom`, `validTo`, `issuedAt`,
|
||||
`expiresAt`, and `geometry`.
|
||||
`expiresAt`, `containsLocation: true`, and `geometry`;
|
||||
- outlook discussions require day `1`, `2`, or `3`; at least one of
|
||||
`headline`, `summary`, or `discussion`; and no duplicate discussion day in the
|
||||
same run.
|
||||
|
||||
Nullable canonical values are converted to SQL nulls by mapper helpers.
|
||||
Observation present-weather raw values and outlook geometry values are stored as
|
||||
|
||||
@@ -82,7 +82,7 @@ document bodies as the previous successful poll.
|
||||
|
||||
Every event passes through normalization first and dedupe second.
|
||||
|
||||
Normalizers match raw source schemas and produce canonical `weather.*.v1`
|
||||
Normalizers match raw source schemas and produce versioned canonical `weather.*`
|
||||
payloads. If an event has no matching normalizer, the normalize processor passes
|
||||
it through unchanged.
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ The implementation style is:
|
||||
|
||||
- Hexagonal boundaries: provider APIs, config loading, scheduling, dispatch, and sinks are external mechanisms around the weather domain model and normalization logic.
|
||||
- Raw-to-canonical flow: sources should fetch and envelope raw provider payloads; normalizers should own provider-to-canonical mapping.
|
||||
- Location-focused canonical data: canonical weather events represent data relevant to the configured forecast location or configured provider object; `weatherfeeder` is not a national provider-data archive.
|
||||
- Schema-based routing: normalizers match on event schema, not source name or event kind.
|
||||
- Composable registries: source drivers, normalizers, processors, and sinks are assembled explicitly through registries.
|
||||
- Bounded concurrency: scheduling and sink fanout are concurrent, but the application should keep queues, goroutine ownership, logging, and cancellation behavior visible.
|
||||
|
||||
@@ -4,6 +4,45 @@
|
||||
|
||||
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/`.
|
||||
|
||||
## NWS AFD Parsing Resilience
|
||||
|
||||
The current parser handles the concrete RAH, LWX, and MFR variants that
|
||||
motivated these ideas. Future work should keep those extension points
|
||||
maintainable as additional evidence appears.
|
||||
|
||||
### Generalize Wrapper-Scoped Embedded Sections
|
||||
|
||||
The scanner currently permits undotted nested headings only inside `PREV
|
||||
DISCUSSION`. If other wrapper identities are observed, replace the single
|
||||
wrapper check with a small explicit provider-local registry and add a fixture
|
||||
for each wrapper family. Do not make the leading dot globally optional: wrapper
|
||||
scope is the safeguard against classifying uppercase prose as a section.
|
||||
|
||||
### Extend Conservative Preamble Classification
|
||||
|
||||
Leading key-message metadata currently supports validated `Issued at`, `Updated
|
||||
at`, and `As of <clock> <weekday>...` forms. Add future wording variants as
|
||||
small, ordered classifiers with strict label boundaries and value grammars.
|
||||
Every addition should include collision tests proving that similar message prose
|
||||
and malformed metadata remain canonical content.
|
||||
|
||||
### Keep List-Marker Recognition Extensible
|
||||
|
||||
The marker parser currently supports hyphens, asterisks, `N)`, `N.`, `(N)`, and
|
||||
composite forms such as `- (N)`. If new decorators appear, evolve the helper
|
||||
toward an explicit marker grammar or typed classification result rather than a
|
||||
broad punctuation heuristic. Preserve positive-number and whitespace-boundary
|
||||
checks so ordinary prose is not stripped.
|
||||
|
||||
### Maintain a Cross-Office Fixture Corpus
|
||||
|
||||
The compact RAH, LWX, and current MFR fixtures seed regression coverage for the
|
||||
observed layouts. Future parser changes should add concise, deterministic HTML
|
||||
fixtures for materially distinct office formats and exercise them through both
|
||||
the provider parser and normalizer. Fixture comments should identify the format
|
||||
family and state that edited prose is not an archived product; tests must remain
|
||||
offline and assert both intended extraction and adjacent-section isolation.
|
||||
|
||||
## SPC Convective Outlook Follow-Ups
|
||||
|
||||
### Weatherapi Outlook Endpoints
|
||||
@@ -31,7 +70,7 @@ 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.
|
||||
- Prefer reusing `weather.outlook.v2` if the fields remain accurate; otherwise write a separate roadmap before changing the canonical contract.
|
||||
|
||||
### Degraded SPC Bundle Mode
|
||||
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
# 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.v1` to `weather.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: []` and `discussions: []`.
|
||||
- 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`, and `discussion` fields are removed from `model.WeatherOutlook`.
|
||||
- `containsLocation` remains on `WeatherOutlook` and must be `true` for every v2 emitted outlook.
|
||||
- Empty SPC `GeometryCollection` no-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 greatest `issuedAt`; `day` and `label` are 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 `weatherfeeder` an 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"` in `standards/schema.go`.
|
||||
- Keep `standards.SchemaWeatherOutlookV1` for historical references and any existing tests that still validate documented constants.
|
||||
- Update `model/outlook.go`:
|
||||
- add `WeatherOutlookDiscussion`;
|
||||
- add `Discussions []WeatherOutlookDiscussion` to `WeatherOutlookRun` with JSON tag `json:"discussions"`;
|
||||
- remove `Headline`, `Summary`, and `Discussion` fields from `WeatherOutlook`.
|
||||
- Keep all other `WeatherOutlook` fields unchanged, including `ContainsLocation` and `Geometry`.
|
||||
- Update any code or tests that construct `WeatherOutlook` values 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`, and `WeatherOutlookDiscussion` are listed where applicable.
|
||||
- Update standards documentation tests so `weather.outlook.v2` is expected.
|
||||
- Add or update a JSON-shape test to verify:
|
||||
- `WeatherOutlookRun` serializes `discussions`;
|
||||
- `WeatherOutlook` no longer serializes polygon-level `headline`, `summary`, or `discussion`.
|
||||
|
||||
### Verification
|
||||
|
||||
```sh
|
||||
go test ./model ./standards
|
||||
```
|
||||
|
||||
## Stage 2: SPC Normalizer V2 Output
|
||||
|
||||
### Code Changes
|
||||
|
||||
- Update `internal/normalizers/spc/convective_outlook.go` so `Normalize` emits `standards.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 `GeometryCollection` placeholders.
|
||||
- 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 `containsLocation` using existing `geo.ContainsPoint`;
|
||||
- if `containsLocation == false`, do not append a canonical outlook;
|
||||
- if `containsLocation == true`, append the canonical outlook with `ContainsLocation: true`.
|
||||
- Do not attach `headline`, `summary`, or `discussion` to retained outlooks.
|
||||
- After retained outlooks are built, build `WeatherOutlookRun.Discussions`:
|
||||
- collect unique days present in retained outlooks;
|
||||
- include one `WeatherOutlookDiscussion` per retained day;
|
||||
- sort discussions by day ascending;
|
||||
- map `Day`, `Headline`, `Summary`, `Discussion`, and `UpdatedAt` from 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 `asOf` and normalized event `effective_at` policy:
|
||||
- latest valid GeoJSON issue time across the complete bundle;
|
||||
- latest print-page update time;
|
||||
- incoming event `effective_at`;
|
||||
- incoming event `emitted_at`.
|
||||
- Preserve `WeatherOutlookRun.IssuedAt` as latest valid GeoJSON issue time when available, even when `outlooks` is 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.AsOf` from 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.Discussions` length is 1;
|
||||
- discussion day is 2;
|
||||
- Day 1 and Day 3 discussions are absent.
|
||||
- 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 `GeometryCollection` regression 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
|
||||
|
||||
```sh
|
||||
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 NULL` to `outlook_runs`.
|
||||
- Add `outlook_discussions` table:
|
||||
- `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_of` on `(day, as_of)`.
|
||||
- Add unique index `idx_wf_outlook_discussions_run_day` on `(run_event_id, day)` using `fksinks.PostgresIndex{Unique: true}`.
|
||||
- Remove `headline`, `summary`, and `discussion` from the target `outlooks` schema. Do not carry these legacy nullable columns forward.
|
||||
|
||||
### Mapper Changes
|
||||
|
||||
Update `internal/sinks/postgres/map.go`:
|
||||
|
||||
- Route `standards.SchemaWeatherOutlookV2` to the outlook mapper.
|
||||
- Remove `standards.SchemaWeatherOutlookV1` support 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_discussions` row for each `run.Discussions[i]`.
|
||||
- Use `discussion_index` as the array position.
|
||||
- Use parent `run.AsOf.UTC()` for `as_of`.
|
||||
- Normalize `UpdatedAt` to UTC when present.
|
||||
- Validate discussion entries before writing:
|
||||
- `day` must be 1, 2, or 3;
|
||||
- at least one of `headline`, `summary`, or `discussion` must 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`, and `geometry` remain 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_runs` includes `discussion_count`.
|
||||
- Assert `outlook_discussions` exists with expected columns, primary key, prune column, day/as-of index, and unique run/day index.
|
||||
- Assert `outlooks` does not include `headline`, `summary`, or `discussion`.
|
||||
|
||||
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_count` and `discussion_count`.
|
||||
- Assert empty local run writes parent row with `outlook_count = 0` and `discussion_count = 0` and 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 == false` fails.
|
||||
- Update existing tests that used polygon-level `Headline`, `Summary`, or `Discussion`.
|
||||
- Keep compact geometry and UTC normalization tests.
|
||||
|
||||
### Verification
|
||||
|
||||
```sh
|
||||
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/normalizers` that assert canonical schema routing or supported schema names.
|
||||
- Update any source/registry documentation consistency tests that expect canonical schema strings.
|
||||
- Search for stale `SchemaWeatherOutlookV1` usage:
|
||||
|
||||
```sh
|
||||
rg "SchemaWeatherOutlookV1|weather\.outlook\.v1|headline|summary|discussion" model standards internal docs/consumers docs/integrations docs/internal
|
||||
```
|
||||
|
||||
- Keep `SchemaWeatherOutlookV1` only 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
|
||||
|
||||
```sh
|
||||
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.v1` storage shape to the v2-compatible storage shape.
|
||||
- Scope: drops only the outlook table family and lets updated `weatherfeeder` recreate it. Other weather tables are not affected.
|
||||
- Explicit warning: these commands delete stored SPC outlook history.
|
||||
- Deployment order:
|
||||
1. Stop `weatherfeeder`.
|
||||
2. Drop existing outlook tables.
|
||||
3. Deploy updated `weatherfeeder`.
|
||||
4. Start `weatherfeeder` so the Postgres sink recreates the v2 tables.
|
||||
5. Deploy updated downstream consumers such as `weatherapi`.
|
||||
- Transition SQL:
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS outlook_discussions;
|
||||
DROP TABLE IF EXISTS outlooks;
|
||||
DROP TABLE IF EXISTS outlook_runs;
|
||||
```
|
||||
|
||||
- Verification SQL examples:
|
||||
|
||||
```sql
|
||||
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:
|
||||
|
||||
```sql
|
||||
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 `outlooks` prose 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 `weatherfeeder` is 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.discussions` and `WeatherOutlookDiscussion` fields.
|
||||
- Document that `outlooks[]` is location-filtered.
|
||||
- Document `containsLocation` as an invariant that is true for emitted v2 outlooks.
|
||||
- Document empty local runs with `outlooks: []` and `discussions: []`.
|
||||
- Document supersession semantics:
|
||||
- latest-run semantics preferred;
|
||||
- historical supersession key is `provider`, `product`, `outlookType`, `validFrom`, `validTo`;
|
||||
- greatest `issuedAt` wins;
|
||||
- `day` and `label` are not identity;
|
||||
- preserve all polygons from the selected latest `issuedAt` group.
|
||||
- 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_discussions` table, columns, keys, indexes, prune column, and mapping source.
|
||||
- Remove `outlooks.headline`, `outlooks.summary`, and `outlooks.discussion` from the current table contract.
|
||||
- Document v2 reader reconstruction: read `outlook_runs`, `outlooks`, and `outlook_discussions` by `run_event_id` ordered 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_discussions` if such tests exist or are practical.
|
||||
- Ensure no permanent doc still describes v1 all-polygons behavior as current behavior:
|
||||
|
||||
```sh
|
||||
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
|
||||
|
||||
```sh
|
||||
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
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
```
|
||||
|
||||
### Manual Checks
|
||||
|
||||
- Confirm `cmd/weatherfeeder/config.yml` still loads and source registry tests pass.
|
||||
- Confirm no current-behavior docs outside `docs/roadmap/` describe unimplemented behavior.
|
||||
- Confirm `docs/roadmap/outlook-schema-transition.md` transition SQL and verification steps match the final table names and `internal/sinks/postgres/schema.go`.
|
||||
- Confirm the SPC normalizer emits `weather.outlook.v2`, not `weather.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 `weatherfeeder` with `weather.outlook.v2` before updating `weatherapi` dependency.
|
||||
- Update `weatherapi` Postgres reads to load `outlook_discussions`.
|
||||
- Update weatherapi endpoint semantics to prefer latest-run behavior for current outlook endpoints.
|
||||
- Remove or revise weatherapi filters that depend on historical `containsLocation=false` rows.
|
||||
- Consider keeping `/outlooks/convective/location` as 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.
|
||||
87
docs/roadmap/outlook-schema-transition.md
Normal file
87
docs/roadmap/outlook-schema-transition.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# SPC Outlook Postgres Schema Transition
|
||||
|
||||
## Purpose
|
||||
|
||||
This document describes how to reset existing Postgres outlook tables from the
|
||||
`weather.outlook.v1` storage shape to the `weather.outlook.v2` compatible
|
||||
storage shape.
|
||||
|
||||
Updated `weatherfeeder` versions create outlook tables with run-level
|
||||
discussion storage. Existing databases that already contain the old outlook
|
||||
table family need a manual reset because the Postgres sink creates tables with
|
||||
`CREATE TABLE IF NOT EXISTS`.
|
||||
|
||||
## Scope
|
||||
|
||||
This reset drops only the outlook table family and lets updated `weatherfeeder`
|
||||
recreate it:
|
||||
|
||||
- `outlook_discussions`
|
||||
- `outlooks`
|
||||
- `outlook_runs`
|
||||
|
||||
Other weather tables are not affected.
|
||||
|
||||
## Warning
|
||||
|
||||
These commands delete stored SPC outlook history. Existing `weather.outlook.v1`
|
||||
outlook rows are intentionally removed. Downstream readers should be updated
|
||||
intentionally for the new outlook shape.
|
||||
|
||||
## Deployment Order
|
||||
|
||||
1. Stop `weatherfeeder`.
|
||||
2. Drop the existing outlook tables.
|
||||
3. Deploy updated `weatherfeeder`.
|
||||
4. Start `weatherfeeder` so the Postgres sink recreates the new outlook tables.
|
||||
5. Deploy updated downstream consumers such as `weatherapi`.
|
||||
|
||||
## Reset SQL
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS outlook_discussions;
|
||||
DROP TABLE IF EXISTS outlooks;
|
||||
DROP TABLE IF EXISTS outlook_runs;
|
||||
```
|
||||
|
||||
## Verification SQL
|
||||
|
||||
Before or after the updated daemon starts, this query shows which outlook tables
|
||||
exist:
|
||||
|
||||
```sql
|
||||
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 the tables, verify the new
|
||||
run column:
|
||||
|
||||
```sql
|
||||
SELECT column_name, is_nullable, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'outlook_runs'
|
||||
AND column_name = 'discussion_count';
|
||||
```
|
||||
|
||||
Verify the discussion table indexes:
|
||||
|
||||
```sql
|
||||
SELECT indexname
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'outlook_discussions'
|
||||
ORDER BY indexname;
|
||||
```
|
||||
|
||||
Verify that legacy polygon-level prose columns are gone from `outlooks`:
|
||||
|
||||
```sql
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'outlooks'
|
||||
AND column_name IN ('headline', 'summary', 'discussion');
|
||||
```
|
||||
|
||||
The final query should return zero rows.
|
||||
@@ -1,355 +0,0 @@
|
||||
# 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
|
||||
|
||||
- `weatherfeeder` is 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: []` and `discussions: []`.
|
||||
- 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 `GeometryCollection` no-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.v1` events from the SPC normalizer after the transition.
|
||||
- Keep `weather.outlook.v1` documentation as historical behavior if needed for consumers of already-stored events.
|
||||
|
||||
### Canonical Model
|
||||
|
||||
Update `model.WeatherOutlookRun` to include day-level discussions:
|
||||
|
||||
```go
|
||||
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`:
|
||||
|
||||
```go
|
||||
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 `containsLocation` for compatibility and invariant visibility, but every emitted canonical outlook should have `containsLocation: 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:
|
||||
|
||||
1. Decode the complete raw SPC bundle.
|
||||
2. Parse all required print-page discussions so missing or malformed required pages still fail normalization.
|
||||
3. Decode all configured GeoJSON products.
|
||||
4. Skip empty `GeometryCollection` no-risk placeholders.
|
||||
5. Compute point-in-polygon for every real `Polygon` or `MultiPolygon` feature.
|
||||
6. Append only outlooks where the configured forecast point is inside or on the boundary of the polygon.
|
||||
7. Track latest valid GeoJSON `ISSUE_ISO` across all decoded features, including skipped empty placeholders, so `asOf` remains a snapshot of the checked upstream products.
|
||||
8. Build `discussions` from the set of days represented by retained outlooks.
|
||||
9. Preserve product ordering by day, then categorical, tornado, hail, and wind, and preserve feature order within each product.
|
||||
10. Emit a canonical run even when no outlooks apply.
|
||||
|
||||
`asOf` and normalized event `effective_at` should continue using:
|
||||
|
||||
1. latest valid GeoJSON issue time across the complete bundle;
|
||||
2. latest print-page update time;
|
||||
3. incoming event `effective_at`;
|
||||
4. 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 `WeatherOutlookRun` is a point-in-time provider snapshot for the configured
|
||||
forecast location as of `asOf`.
|
||||
- Consumers that need current local outlooks should prefer latest-run semantics:
|
||||
read the latest run and use that run's `outlooks` and `discussions` rather
|
||||
than accumulating active outlook rows from older runs.
|
||||
- `WeatherOutlook.issuedAt` is 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`, and `validTo`.
|
||||
- `day` is 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.
|
||||
- `label` is 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
|
||||
`issuedAt` group 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.md`
|
||||
- `docs/integrations/spc.md`
|
||||
- `docs/integrations/postgres.md`
|
||||
- `docs/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 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`
|
||||
|
||||
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`, and `discussion` from 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:
|
||||
|
||||
```sql
|
||||
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`:
|
||||
|
||||
- `weatherfeeder` is 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 Principles` as 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.md`
|
||||
- `docs/integrations/spc.md`
|
||||
- `docs/integrations/postgres.md`
|
||||
- `docs/internal/postgres-sink.md`
|
||||
- `docs/consumers/pkg-model.md`
|
||||
- `docs/consumers/api.md`
|
||||
- `docs/config.md` if source/operator behavior descriptions mention all polygons
|
||||
- `docs/operations.md` and `docs/troubleshooting.md` only 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 []WeatherOutlookDiscussion` to `model.WeatherOutlookRun` with JSON field `discussions`.
|
||||
- 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 `containsLocation` for each real geometry.
|
||||
- Append only `containsLocation == true` outlooks.
|
||||
- Set retained outlooks' `ContainsLocation` to true.
|
||||
- Build run `Discussions` by 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: []` and `discussions: []`.
|
||||
- Continue using all products and print pages for timestamp fallback and validation.
|
||||
|
||||
Tests:
|
||||
|
||||
- Outside-location fixture normalizes successfully with `outlooks: []` and `discussions: []`.
|
||||
- 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 `GeometryCollection` placeholders 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_discussions` schema definition.
|
||||
- Map `WeatherOutlookRun.Discussions` to `outlook_discussions` rows.
|
||||
- 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:
|
||||
- `day` is required and must be 1, 2, or 3.
|
||||
- At least one of `headline`, `summary`, or `discussion` should be present for a discussion row.
|
||||
- `updatedAt` remains optional.
|
||||
- Preserve required outlook validation for retained polygons.
|
||||
|
||||
Tests:
|
||||
|
||||
- Schema includes `discussion_count` and `outlook_discussions`.
|
||||
- Mapper writes one discussion row per run discussion.
|
||||
- Mapper writes `discussion_count == len(payload.discussions)`.
|
||||
- Mapper writes empty runs with `outlook_count = 0` and `discussion_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.md` with the SQL commands from this roadmap, adjusted to match the final implemented schema.
|
||||
- Clearly state deployment order:
|
||||
1. Stop `weatherfeeder`.
|
||||
2. Drop existing outlook tables.
|
||||
3. Deploy updated `weatherfeeder`.
|
||||
4. Start `weatherfeeder` so it recreates the outlook tables.
|
||||
5. Deploy updated downstream consumers such as `weatherapi`.
|
||||
- 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`: document `weather.outlook.v2`, `discussions[]`, and location-filtered `outlooks[]`.
|
||||
- `docs/integrations/spc.md`: update mapping notes to say raw bundles are complete but canonical outlooks are location-filtered.
|
||||
- `docs/integrations/postgres.md`: document `outlook_discussions` and `discussion_count`.
|
||||
- `docs/internal/postgres-sink.md`: update mapper contract.
|
||||
- `docs/consumers/pkg-model.md` and `docs/consumers/api.md`: update model examples.
|
||||
- Keep any `weather.outlook.v1` notes 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 `weatherapi` after `weatherfeeder` has a released version containing `weather.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=false` rows existed.
|
||||
- `/outlooks/convective/location` may 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:
|
||||
|
||||
```sh
|
||||
go test ./model ./standards
|
||||
go test ./internal/providers/spc ./internal/normalizers/spc ./internal/sources/spc ./internal/geo
|
||||
go test ./internal/sinks/postgres
|
||||
```
|
||||
|
||||
Full verification:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Manual operational validation with a live SPC no-risk scenario should confirm:
|
||||
|
||||
- poll succeeds;
|
||||
- normalization succeeds;
|
||||
- a canonical outlook run is emitted;
|
||||
- `outlooks` is empty when no polygon contains the configured point;
|
||||
- `discussions` is empty when no local outlook applies;
|
||||
- `asOf` still 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.
|
||||
@@ -93,12 +93,8 @@ func buildAlerts(parsed nwsAlertsResponse, fallbackAsOf time.Time) (model.Weathe
|
||||
sent := nwscommon.ParseTimePtr(p.Sent)
|
||||
effective := nwscommon.ParseTimePtr(p.Effective)
|
||||
onset := nwscommon.ParseTimePtr(p.Onset)
|
||||
|
||||
// Expires: prefer "expires"; fall back to "ends" if present.
|
||||
ends := nwscommon.ParseTimePtr(p.Ends)
|
||||
expires := nwscommon.ParseTimePtr(p.Expires)
|
||||
if expires == nil {
|
||||
expires = nwscommon.ParseTimePtr(p.Ends)
|
||||
}
|
||||
|
||||
refs := parseNWSAlertReferences(p.References)
|
||||
|
||||
@@ -123,6 +119,7 @@ func buildAlerts(parsed nwsAlertsResponse, fallbackAsOf time.Time) (model.Weathe
|
||||
Sent: sent,
|
||||
Effective: effective,
|
||||
Onset: onset,
|
||||
Ends: ends,
|
||||
Expires: expires,
|
||||
|
||||
AreaDescription: strings.TrimSpace(p.AreaDesc),
|
||||
|
||||
136
internal/normalizers/nws/alerts_test.go
Normal file
136
internal/normalizers/nws/alerts_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package nws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/feedkit/event"
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||
)
|
||||
|
||||
func TestAlertsNormalizerMapsEndsSeparatelyFromExpires(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"updated":"2026-06-16T10:00:00+00:00",
|
||||
"title":"Current watches, warnings, and advisories for St. Louis",
|
||||
"features":[{
|
||||
"id":"https://api.weather.gov/alerts/alert-1",
|
||||
"properties":{
|
||||
"event":"Flood Warning",
|
||||
"headline":"Flood Warning issued",
|
||||
"sent":"2026-06-16T09:55:00+00:00",
|
||||
"effective":"2026-06-16T10:00:00+00:00",
|
||||
"onset":"2026-06-16T10:15:00+00:00",
|
||||
"ends":"2026-06-16T14:00:00+00:00",
|
||||
"expires":"2026-06-16T11:00:00+00:00"
|
||||
}
|
||||
}]
|
||||
}`)
|
||||
|
||||
out, err := AlertsNormalizer{}.Normalize(context.Background(), alertRawEvent(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
|
||||
run := decodeAlertRun(t, out)
|
||||
if len(run.Alerts) != 1 {
|
||||
t.Fatalf("expected 1 alert, got %d", len(run.Alerts))
|
||||
}
|
||||
alert := run.Alerts[0]
|
||||
wantEnds := time.Date(2026, 6, 16, 14, 0, 0, 0, time.UTC)
|
||||
wantExpires := time.Date(2026, 6, 16, 11, 0, 0, 0, time.UTC)
|
||||
if alert.Ends == nil || !alert.Ends.Equal(wantEnds) {
|
||||
t.Fatalf("ends = %v, want %s", alert.Ends, wantEnds)
|
||||
}
|
||||
if alert.Expires == nil || !alert.Expires.Equal(wantExpires) {
|
||||
t.Fatalf("expires = %v, want %s", alert.Expires, wantExpires)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertsNormalizerDoesNotFallbackExpiresToEnds(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"updated":"2026-06-16T10:00:00+00:00",
|
||||
"features":[{
|
||||
"id":"alert-ends-only",
|
||||
"properties":{
|
||||
"event":"Heat Advisory",
|
||||
"ends":"2026-06-16T22:00:00+00:00"
|
||||
}
|
||||
}]
|
||||
}`)
|
||||
|
||||
out, err := AlertsNormalizer{}.Normalize(context.Background(), alertRawEvent(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
|
||||
run := decodeAlertRun(t, out)
|
||||
alert := run.Alerts[0]
|
||||
if alert.Ends == nil {
|
||||
t.Fatal("expected ends to be populated")
|
||||
}
|
||||
if alert.Expires != nil {
|
||||
t.Fatalf("expected expires nil when upstream expires is absent, got %v", alert.Expires)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertsNormalizerIgnoresInvalidEnds(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"updated":"2026-06-16T10:00:00+00:00",
|
||||
"features":[{
|
||||
"id":"alert-invalid-ends",
|
||||
"properties":{
|
||||
"event":"Special Weather Statement",
|
||||
"ends":"not-a-time",
|
||||
"expires":"2026-06-16T11:00:00+00:00"
|
||||
}
|
||||
}]
|
||||
}`)
|
||||
|
||||
out, err := AlertsNormalizer{}.Normalize(context.Background(), alertRawEvent(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
|
||||
run := decodeAlertRun(t, out)
|
||||
alert := run.Alerts[0]
|
||||
if alert.Ends != nil {
|
||||
t.Fatalf("expected invalid ends to map nil, got %v", alert.Ends)
|
||||
}
|
||||
if alert.Expires == nil {
|
||||
t.Fatal("expected expires to remain populated")
|
||||
}
|
||||
}
|
||||
|
||||
func alertRawEvent(raw []byte) event.Event {
|
||||
emittedAt := time.Date(2026, 6, 16, 10, 5, 0, 0, time.UTC)
|
||||
return event.Event{
|
||||
ID: "raw-alerts",
|
||||
Kind: event.Kind(standards.KindAlert),
|
||||
Source: "NWSAlerts",
|
||||
Schema: standards.SchemaRawNWSAlertsV1,
|
||||
EmittedAt: emittedAt,
|
||||
Payload: json.RawMessage(raw),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAlertRun(t *testing.T, e *event.Event) model.WeatherAlertRun {
|
||||
t.Helper()
|
||||
if e == nil {
|
||||
t.Fatal("expected normalized event")
|
||||
}
|
||||
if e.Schema != standards.SchemaWeatherAlertV1 {
|
||||
t.Fatalf("schema = %q, want %q", e.Schema, standards.SchemaWeatherAlertV1)
|
||||
}
|
||||
var run model.WeatherAlertRun
|
||||
raw, err := json.Marshal(e.Payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal alert payload: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatalf("decode alert payload: %v", err)
|
||||
}
|
||||
return run
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -71,6 +72,277 @@ func TestForecastDiscussionNormalizerProducesCanonicalSchema(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestForecastDiscussionNormalizerSupportsMixedHeadingFormats(t *testing.T) {
|
||||
out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
||||
ID: "evt-discussion-mixed-format",
|
||||
Kind: event.Kind(standards.KindForecastDiscussion),
|
||||
Source: "nws-discussion-test",
|
||||
EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC),
|
||||
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
||||
Payload: loadMixedFormatForecastDiscussionSampleHTML(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatalf("Normalize() returned nil output")
|
||||
}
|
||||
if out.Kind != event.Kind(standards.KindForecastDiscussion) {
|
||||
t.Fatalf("Kind = %q, want forecast_discussion", out.Kind)
|
||||
}
|
||||
if out.Schema != standards.SchemaWeatherForecastDiscussionV1 {
|
||||
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastDiscussionV1)
|
||||
}
|
||||
wantEffectiveAt := time.Date(2026, 3, 28, 19, 24, 0, 0, time.UTC)
|
||||
if out.EffectiveAt == nil || !out.EffectiveAt.Equal(wantEffectiveAt) {
|
||||
t.Fatalf("EffectiveAt = %v, want %s", out.EffectiveAt, wantEffectiveAt.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
payload, ok := out.Payload.(model.WeatherForecastDiscussion)
|
||||
if !ok {
|
||||
t.Fatalf("Payload type = %T, want model.WeatherForecastDiscussion", out.Payload)
|
||||
}
|
||||
if payload.ShortTerm == nil || payload.LongTerm == nil {
|
||||
t.Fatalf("ShortTerm=%v LongTerm=%v, want both populated", payload.ShortTerm, payload.LongTerm)
|
||||
}
|
||||
if payload.ShortTerm.Qualifier != "Through Late Sunday Night" {
|
||||
t.Fatalf("ShortTerm.Qualifier = %q", payload.ShortTerm.Qualifier)
|
||||
}
|
||||
if !strings.Contains(payload.ShortTerm.Text, "After a chilly morning") {
|
||||
t.Fatalf("ShortTerm.Text missing expected prose: %q", payload.ShortTerm.Text)
|
||||
}
|
||||
if payload.LongTerm.Qualifier != "Monday through Next Saturday" {
|
||||
t.Fatalf("LongTerm.Qualifier = %q", payload.LongTerm.Qualifier)
|
||||
}
|
||||
if !strings.Contains(payload.LongTerm.Text, "The peak of the warmth arrives Monday and Tuesday") {
|
||||
t.Fatalf("LongTerm.Text missing expected prose: %q", payload.LongTerm.Text)
|
||||
}
|
||||
if strings.Contains(payload.LongTerm.Text, "AVIATION") || strings.Contains(payload.LongTerm.Text, "VFR conditions are expected") {
|
||||
t.Fatalf("LongTerm.Text includes aviation content: %q", payload.LongTerm.Text)
|
||||
}
|
||||
|
||||
b, err := json.Marshal(out.Payload)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(payload) error = %v", err)
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal(b, &fields); err != nil {
|
||||
t.Fatalf("json.Unmarshal(payload) error = %v", err)
|
||||
}
|
||||
for _, key := range []string{"aviation", "sections"} {
|
||||
if _, ok := fields[key]; ok {
|
||||
t.Fatalf("unexpected key %q in canonical payload", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForecastDiscussionNormalizerSupportsCrossOfficeLayout(t *testing.T) {
|
||||
in := event.Event{
|
||||
ID: "evt-discussion-bou",
|
||||
Kind: event.Kind(standards.KindForecastDiscussion),
|
||||
Source: "nws-discussion-bou-test",
|
||||
EmittedAt: time.Date(2026, 4, 7, 19, 1, 0, 0, time.UTC),
|
||||
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
||||
Payload: loadForecastDiscussionBOUSampleHTML(t),
|
||||
}
|
||||
|
||||
out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, in)
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatalf("Normalize() returned nil output")
|
||||
}
|
||||
if out.ID != in.ID || out.Source != in.Source || !out.EmittedAt.Equal(in.EmittedAt) {
|
||||
t.Fatalf("envelope = %#v, want ID/source/emittedAt from input", out)
|
||||
}
|
||||
if out.Kind != event.Kind(standards.KindForecastDiscussion) {
|
||||
t.Fatalf("Kind = %q, want forecast_discussion", out.Kind)
|
||||
}
|
||||
if out.Schema != standards.SchemaWeatherForecastDiscussionV1 {
|
||||
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastDiscussionV1)
|
||||
}
|
||||
wantEffectiveAt := time.Date(2026, 4, 7, 19, 0, 0, 0, time.UTC)
|
||||
if out.EffectiveAt == nil || !out.EffectiveAt.Equal(wantEffectiveAt) {
|
||||
t.Fatalf("EffectiveAt = %v, want %s", out.EffectiveAt, wantEffectiveAt.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
payload, ok := out.Payload.(model.WeatherForecastDiscussion)
|
||||
if !ok {
|
||||
t.Fatalf("Payload type = %T, want model.WeatherForecastDiscussion", out.Payload)
|
||||
}
|
||||
if payload.OfficeID != "BOU" || payload.OfficeName != "National Weather Service Denver CO" {
|
||||
t.Fatalf("OfficeID=%q OfficeName=%q", payload.OfficeID, payload.OfficeName)
|
||||
}
|
||||
wantMessages := []string{
|
||||
"Strong winds are expected along the Front Range this evening.",
|
||||
"Cooler temperatures arrive on Wednesday.",
|
||||
}
|
||||
if len(payload.KeyMessages) != len(wantMessages) {
|
||||
t.Fatalf("KeyMessages = %#v, want %#v", payload.KeyMessages, wantMessages)
|
||||
}
|
||||
for i := range wantMessages {
|
||||
if payload.KeyMessages[i] != wantMessages[i] {
|
||||
t.Fatalf("KeyMessages[%d] = %q, want %q", i, payload.KeyMessages[i], wantMessages[i])
|
||||
}
|
||||
}
|
||||
if payload.ShortTerm == nil || payload.LongTerm == nil {
|
||||
t.Fatalf("ShortTerm=%v LongTerm=%v, want both populated", payload.ShortTerm, payload.LongTerm)
|
||||
}
|
||||
if payload.ShortTerm.Qualifier != "(Tonight through Wednesday)" || payload.ShortTerm.Text != "Gusty west winds will continue through the evening before decreasing overnight." {
|
||||
t.Fatalf("ShortTerm = %#v", payload.ShortTerm)
|
||||
}
|
||||
if payload.LongTerm.Qualifier != "(Thursday through Saturday)" || payload.LongTerm.Text != "Warmer and drier conditions return Thursday, followed by a chance of showers Friday." {
|
||||
t.Fatalf("LongTerm = %#v", payload.LongTerm)
|
||||
}
|
||||
if payload.ShortTerm.IssuedAt == nil || payload.LongTerm.IssuedAt == nil ||
|
||||
!payload.ShortTerm.IssuedAt.Equal(wantEffectiveAt) || !payload.LongTerm.IssuedAt.Equal(wantEffectiveAt) {
|
||||
t.Fatalf("section issue times = short %v long %v, want %s", payload.ShortTerm.IssuedAt, payload.LongTerm.IssuedAt, wantEffectiveAt.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
b, err := json.Marshal(out.Payload)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(payload) error = %v", err)
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal(b, &fields); err != nil {
|
||||
t.Fatalf("json.Unmarshal(payload) error = %v", err)
|
||||
}
|
||||
for _, key := range []string{"aviation", "discussion", "sections"} {
|
||||
if _, ok := fields[key]; ok {
|
||||
t.Fatalf("unexpected key %q in canonical payload", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForecastDiscussionNormalizerSupportsCrossOfficeKeyMessageFixtures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
id string
|
||||
source string
|
||||
emittedAt time.Time
|
||||
effectiveAt time.Time
|
||||
messages []string
|
||||
}{
|
||||
{
|
||||
name: "numbered key messages",
|
||||
filename: "forecast_discussion_bgm_numbered_sample.html",
|
||||
id: "evt-discussion-bgm",
|
||||
source: "nws-discussion-bgm-test",
|
||||
emittedAt: time.Date(2026, 4, 10, 17, 31, 0, 0, time.UTC),
|
||||
effectiveAt: time.Date(2026, 4, 10, 17, 30, 0, 0, time.UTC),
|
||||
messages: []string{
|
||||
"Periods of rain are expected through Saturday, with locally heavier amounts possible.",
|
||||
"Cooler temperatures return late this weekend.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "key points alias",
|
||||
filename: "forecast_discussion_mfr_key_points_sample.html",
|
||||
id: "evt-discussion-mfr",
|
||||
source: "nws-discussion-mfr-test",
|
||||
emittedAt: time.Date(2026, 4, 10, 19, 46, 0, 0, time.UTC),
|
||||
effectiveAt: time.Date(2026, 4, 10, 19, 45, 0, 0, time.UTC),
|
||||
messages: []string{
|
||||
"Gusty winds will develop over exposed ridges, especially during the afternoon.",
|
||||
"Inland valleys remain dry through Saturday.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "as of preamble",
|
||||
filename: "forecast_discussion_rah_as_of_sample.html",
|
||||
id: "evt-discussion-rah",
|
||||
source: "nws-discussion-rah-test",
|
||||
emittedAt: time.Date(2026, 8, 2, 16, 36, 0, 0, time.UTC),
|
||||
effectiveAt: time.Date(2026, 8, 2, 16, 35, 0, 0, time.UTC),
|
||||
messages: []string{
|
||||
"Scattered storms may produce locally heavy rain this afternoon.",
|
||||
"Drier weather arrives Monday.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "parenthesized numeric markers",
|
||||
filename: "forecast_discussion_lwx_parenthesized_number_sample.html",
|
||||
id: "evt-discussion-lwx",
|
||||
source: "nws-discussion-lwx-test",
|
||||
emittedAt: time.Date(2026, 8, 2, 18, 1, 0, 0, time.UTC),
|
||||
effectiveAt: time.Date(2026, 8, 2, 18, 0, 0, 0, time.UTC),
|
||||
messages: []string{
|
||||
"Thunderstorms remain possible near the Blue Ridge this evening.",
|
||||
"Seasonably warm conditions continue Monday.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "embedded key messages in previous discussion",
|
||||
filename: "forecast_discussion_mfr_prev_discussion_sample.html",
|
||||
id: "evt-discussion-mfr-previous",
|
||||
source: "nws-discussion-mfr-previous-test",
|
||||
emittedAt: time.Date(2026, 8, 2, 22, 20, 0, 0, time.UTC),
|
||||
effectiveAt: time.Date(2026, 8, 2, 22, 19, 0, 0, time.UTC),
|
||||
messages: []string{
|
||||
"Heat returns to inland valleys Monday.",
|
||||
"Gusty afternoon winds develop east of the Cascades.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
in := event.Event{
|
||||
ID: tt.id,
|
||||
Kind: event.Kind(standards.KindForecastDiscussion),
|
||||
Source: tt.source,
|
||||
EmittedAt: tt.emittedAt,
|
||||
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
||||
Payload: loadForecastDiscussionFixtureHTML(t, tt.filename),
|
||||
}
|
||||
|
||||
out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, in)
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatalf("Normalize() returned nil output")
|
||||
}
|
||||
if out.ID != in.ID || out.Source != in.Source || !out.EmittedAt.Equal(in.EmittedAt) {
|
||||
t.Fatalf("envelope = %#v, want ID/source/emittedAt from input", out)
|
||||
}
|
||||
if out.Kind != event.Kind(standards.KindForecastDiscussion) {
|
||||
t.Fatalf("Kind = %q, want forecast_discussion", out.Kind)
|
||||
}
|
||||
if out.Schema != standards.SchemaWeatherForecastDiscussionV1 {
|
||||
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastDiscussionV1)
|
||||
}
|
||||
if out.EffectiveAt == nil || !out.EffectiveAt.Equal(tt.effectiveAt) {
|
||||
t.Fatalf("EffectiveAt = %v, want %s", out.EffectiveAt, tt.effectiveAt.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
payload, ok := out.Payload.(model.WeatherForecastDiscussion)
|
||||
if !ok {
|
||||
t.Fatalf("Payload type = %T, want model.WeatherForecastDiscussion", out.Payload)
|
||||
}
|
||||
if !reflect.DeepEqual(payload.KeyMessages, tt.messages) {
|
||||
t.Fatalf("KeyMessages = %#v, want %#v", payload.KeyMessages, tt.messages)
|
||||
}
|
||||
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(payload) error = %v", err)
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal(b, &fields); err != nil {
|
||||
t.Fatalf("json.Unmarshal(payload) error = %v", err)
|
||||
}
|
||||
for _, key := range []string{"nearTerm", "discussion", "aviation", "sections"} {
|
||||
if _, ok := fields[key]; ok {
|
||||
t.Fatalf("unexpected key %q in canonical payload", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestForecastDiscussionNormalizerRejectsMissingIssueTime(t *testing.T) {
|
||||
_, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
||||
ID: "evt-discussion-bad",
|
||||
@@ -128,3 +400,56 @@ func loadForecastDiscussionSampleHTML(t *testing.T) string {
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func loadForecastDiscussionBOUSampleHTML(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join("..", "..", "providers", "nws", "testdata", "forecast_discussion_bou_sample.html")
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("os.ReadFile(%q) error = %v", path, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func loadForecastDiscussionFixtureHTML(t *testing.T, filename string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join("..", "..", "providers", "nws", "testdata", filename)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("os.ReadFile(%q) error = %v", path, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func loadMixedFormatForecastDiscussionSampleHTML(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
raw := loadForecastDiscussionSampleHTML(t)
|
||||
replacements := []struct {
|
||||
original string
|
||||
replacement string
|
||||
}{
|
||||
{
|
||||
original: ".SHORT TERM... (Through Late Sunday Night)",
|
||||
replacement: ".SHORT TERM /Through Late Sunday Night/...",
|
||||
},
|
||||
{
|
||||
original: ".LONG TERM... (Monday through Next Saturday)",
|
||||
replacement: ".LONG TERM /Monday through Next Saturday/...",
|
||||
},
|
||||
{
|
||||
original: ".AVIATION... (For the 18z TAFs through 18z Sunday Afternoon)",
|
||||
replacement: ".AVIATION /For the 18z TAFs through 18z Sunday Afternoon/...",
|
||||
},
|
||||
}
|
||||
for _, replacement := range replacements {
|
||||
if !strings.Contains(raw, replacement.original) {
|
||||
t.Fatalf("fixture missing heading %q", replacement.original)
|
||||
}
|
||||
raw = strings.Replace(raw, replacement.original, replacement.replacement, 1)
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
@@ -29,10 +29,10 @@ var idTokenRE = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// ConvectiveOutlookNormalizer converts:
|
||||
//
|
||||
// standards.SchemaRawSPCConvectiveOutlookV1 -> standards.SchemaWeatherOutlookV1
|
||||
// standards.SchemaRawSPCConvectiveOutlookV1 -> standards.SchemaWeatherOutlookV2
|
||||
//
|
||||
// It maps SPC GeoJSON outlook features into canonical outlook polygons and
|
||||
// enriches each day with the matching required print-page discussion.
|
||||
// It maps SPC GeoJSON outlook features containing the configured location into
|
||||
// canonical outlook polygons and adds matching day-level print-page discussions.
|
||||
type ConvectiveOutlookNormalizer struct{}
|
||||
|
||||
func (ConvectiveOutlookNormalizer) Match(e event.Event) bool {
|
||||
@@ -50,7 +50,7 @@ func (ConvectiveOutlookNormalizer) Normalize(ctx context.Context, in event.Event
|
||||
return normcommon.NormalizeJSON(
|
||||
in,
|
||||
outlookNormalizer,
|
||||
standards.SchemaWeatherOutlookV1,
|
||||
standards.SchemaWeatherOutlookV2,
|
||||
func(parsed spcprovider.RawConvectiveOutlookBundle) (model.WeatherOutlookRun, time.Time, error) {
|
||||
return buildConvectiveOutlook(parsed, fallbackAsOf)
|
||||
},
|
||||
@@ -76,8 +76,7 @@ func buildConvectiveOutlook(bundle spcprovider.RawConvectiveOutlookBundle, fallb
|
||||
if err := validateProductMetadata(product); err != nil {
|
||||
return model.WeatherOutlookRun{}, time.Time{}, err
|
||||
}
|
||||
discussion, ok := discussions[product.Day]
|
||||
if !ok {
|
||||
if _, ok := discussions[product.Day]; !ok {
|
||||
return model.WeatherOutlookRun{}, time.Time{}, fmt.Errorf("product %s: discussion for day %d is required", product.Key, product.Day)
|
||||
}
|
||||
|
||||
@@ -98,17 +97,25 @@ func buildConvectiveOutlook(bundle spcprovider.RawConvectiveOutlookBundle, fallb
|
||||
continue
|
||||
}
|
||||
|
||||
outlook, err := mapFeature(product, feature, i, point, discussion)
|
||||
outlook, err := mapFeature(product, feature, i, point)
|
||||
if err != nil {
|
||||
return model.WeatherOutlookRun{}, time.Time{}, err
|
||||
}
|
||||
if latestIssue.IsZero() || outlook.IssuedAt.After(latestIssue) {
|
||||
latestIssue = outlook.IssuedAt
|
||||
}
|
||||
if !outlook.ContainsLocation {
|
||||
continue
|
||||
}
|
||||
outlooks = append(outlooks, outlook)
|
||||
}
|
||||
}
|
||||
|
||||
runDiscussions, err := buildOutlookDiscussions(outlooks, discussions)
|
||||
if err != nil {
|
||||
return model.WeatherOutlookRun{}, time.Time{}, err
|
||||
}
|
||||
|
||||
asOf := latestIssue
|
||||
if asOf.IsZero() {
|
||||
asOf = latestDiscussionUpdated
|
||||
@@ -133,6 +140,7 @@ func buildConvectiveOutlook(bundle spcprovider.RawConvectiveOutlookBundle, fallb
|
||||
AsOf: asOf.UTC(),
|
||||
IssuedAt: issuedAt,
|
||||
Outlooks: outlooks,
|
||||
Discussions: runDiscussions,
|
||||
}
|
||||
return run, run.AsOf, nil
|
||||
}
|
||||
@@ -175,6 +183,43 @@ func parseDiscussions(pages []spcprovider.RawDiscussionPage) (map[int]parsedDisc
|
||||
return out, latestUpdated, nil
|
||||
}
|
||||
|
||||
func buildOutlookDiscussions(outlooks []model.WeatherOutlook, discussions map[int]parsedDiscussion) ([]model.WeatherOutlookDiscussion, error) {
|
||||
daysWithOutlooks := map[int]bool{}
|
||||
for _, outlook := range outlooks {
|
||||
daysWithOutlooks[outlook.Day] = true
|
||||
}
|
||||
|
||||
days := make([]int, 0, len(daysWithOutlooks))
|
||||
for day := range daysWithOutlooks {
|
||||
days = append(days, day)
|
||||
}
|
||||
sort.Ints(days)
|
||||
|
||||
out := make([]model.WeatherOutlookDiscussion, 0, len(days))
|
||||
for _, day := range days {
|
||||
disc, ok := discussions[day]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("discussion for retained day %d is required", day)
|
||||
}
|
||||
out = append(out, model.WeatherOutlookDiscussion{
|
||||
Day: day,
|
||||
Headline: disc.Headline,
|
||||
Summary: disc.Summary,
|
||||
Discussion: disc.Discussion,
|
||||
UpdatedAt: utcTimePtr(disc.UpdatedAt),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func utcTimePtr(t *time.Time) *time.Time {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
tt := t.UTC()
|
||||
return &tt
|
||||
}
|
||||
|
||||
func orderedProducts(products []spcprovider.RawOutlookProduct) []spcprovider.RawOutlookProduct {
|
||||
out := make([]spcprovider.RawOutlookProduct, len(products))
|
||||
copy(out, products)
|
||||
@@ -219,7 +264,7 @@ func validateProductMetadata(product spcprovider.RawOutlookProduct) error {
|
||||
}
|
||||
}
|
||||
|
||||
func mapFeature(product spcprovider.RawOutlookProduct, feature spcprovider.GeoJSONFeature, index int, point geo.Point, discussion parsedDiscussion) (model.WeatherOutlook, error) {
|
||||
func mapFeature(product spcprovider.RawOutlookProduct, feature spcprovider.GeoJSONFeature, index int, point geo.Point) (model.WeatherOutlook, error) {
|
||||
fieldPrefix := fmt.Sprintf("product %s feature %d", product.Key, index)
|
||||
props := feature.Properties
|
||||
|
||||
@@ -264,9 +309,6 @@ func mapFeature(product spcprovider.RawOutlookProduct, feature spcprovider.GeoJS
|
||||
IssuedAt: issuedAt,
|
||||
ExpiresAt: validTo,
|
||||
Forecaster: strings.TrimSpace(props.Forecaster),
|
||||
Headline: discussion.Headline,
|
||||
Summary: discussion.Summary,
|
||||
Discussion: discussion.Discussion,
|
||||
SourceURL: strings.TrimSpace(product.URL),
|
||||
ImageURL: "",
|
||||
ContainsLocation: containsLocation,
|
||||
|
||||
@@ -27,8 +27,8 @@ func TestConvectiveOutlookNormalizerProducesCanonicalSchemaAndMapsSample(t *test
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
if out.Schema != standards.SchemaWeatherOutlookV1 {
|
||||
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherOutlookV1)
|
||||
if out.Schema != standards.SchemaWeatherOutlookV2 {
|
||||
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherOutlookV2)
|
||||
}
|
||||
if out.Kind != event.Kind(standards.KindOutlook) {
|
||||
t.Fatalf("Kind = %q, want outlook", out.Kind)
|
||||
@@ -54,8 +54,23 @@ func TestConvectiveOutlookNormalizerProducesCanonicalSchemaAndMapsSample(t *test
|
||||
if run.Latitude == nil || *run.Latitude != 38.5 || run.Longitude == nil || *run.Longitude != -90.5 {
|
||||
t.Fatalf("coordinates = %v,%v", run.Latitude, run.Longitude)
|
||||
}
|
||||
if len(run.Outlooks) != 9 {
|
||||
t.Fatalf("Outlooks length = %d, want 9", len(run.Outlooks))
|
||||
if len(run.Outlooks) != 4 {
|
||||
t.Fatalf("Outlooks length = %d, want 4", len(run.Outlooks))
|
||||
}
|
||||
assertAllOutlooksContainLocation(t, run.Outlooks)
|
||||
assertDiscussionDays(t, run.Discussions, 1)
|
||||
day1Discussion := run.Discussions[0]
|
||||
if day1Discussion.Headline != "Day 1 Convective Outlook" {
|
||||
t.Fatalf("day 1 Headline = %q", day1Discussion.Headline)
|
||||
}
|
||||
if !strings.Contains(day1Discussion.Summary, "central Plains") {
|
||||
t.Fatalf("day 1 Summary = %q", day1Discussion.Summary)
|
||||
}
|
||||
if !strings.Contains(day1Discussion.Discussion, "...DISCUSSION...") {
|
||||
t.Fatalf("day 1 Discussion missing product text: %q", day1Discussion.Discussion)
|
||||
}
|
||||
if !strings.HasPrefix(day1Discussion.Discussion, "SPC AC 111234") {
|
||||
t.Fatalf("day 1 Discussion = %q, want SPC product code prefix", day1Discussion.Discussion)
|
||||
}
|
||||
|
||||
got := run.Outlooks[0]
|
||||
@@ -88,18 +103,6 @@ func TestConvectiveOutlookNormalizerProducesCanonicalSchemaAndMapsSample(t *test
|
||||
if !got.ContainsLocation {
|
||||
t.Fatalf("ContainsLocation = false, want true")
|
||||
}
|
||||
if got.Headline != "Day 1 Convective Outlook" {
|
||||
t.Fatalf("Headline = %q", got.Headline)
|
||||
}
|
||||
if !strings.Contains(got.Summary, "central Plains") {
|
||||
t.Fatalf("Summary = %q", got.Summary)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -115,13 +118,39 @@ func TestConvectiveOutlookNormalizerAcceptsTypedSourcePayload(t *testing.T) {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
run := out.Payload.(model.WeatherOutlookRun)
|
||||
if len(run.Outlooks) != 9 {
|
||||
t.Fatalf("Outlooks length = %d, want 9", len(run.Outlooks))
|
||||
if len(run.Outlooks) != 4 {
|
||||
t.Fatalf("Outlooks length = %d, want 4", len(run.Outlooks))
|
||||
}
|
||||
assertAllOutlooksContainLocation(t, run.Outlooks)
|
||||
assertDiscussionDays(t, run.Discussions, 1)
|
||||
}
|
||||
|
||||
func TestConvectiveOutlookNormalizerEmitsEmptyLocalRunOutsidePolygons(t *testing.T) {
|
||||
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 0, 0)))
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
run := out.Payload.(model.WeatherOutlookRun)
|
||||
if len(run.Outlooks) != 0 {
|
||||
t.Fatalf("Outlooks length = %d, want 0", len(run.Outlooks))
|
||||
}
|
||||
if len(run.Discussions) != 0 {
|
||||
t.Fatalf("Discussions length = %d, want 0", len(run.Discussions))
|
||||
}
|
||||
wantAsOf := time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC)
|
||||
if !run.AsOf.Equal(wantAsOf) {
|
||||
t.Fatalf("AsOf = %s, want latest product issue time %s", run.AsOf, wantAsOf)
|
||||
}
|
||||
if run.IssuedAt == nil || !run.IssuedAt.Equal(wantAsOf) {
|
||||
t.Fatalf("IssuedAt = %v, want latest product issue time %s", run.IssuedAt, wantAsOf)
|
||||
}
|
||||
if out.EffectiveAt == nil || !out.EffectiveAt.Equal(run.AsOf) {
|
||||
t.Fatalf("EffectiveAt = %v, want run AsOf %s", out.EffectiveAt, run.AsOf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvectiveOutlookNormalizerOrdersProductsByDayAndType(t *testing.T) {
|
||||
bundle := spcBundle(t, 0, 0)
|
||||
bundle := spcBundle(t, 38.5, -90.5)
|
||||
for i, j := 0, len(bundle.Products)-1; i < j; i, j = i+1, j-1 {
|
||||
bundle.Products[i], bundle.Products[j] = bundle.Products[j], bundle.Products[i]
|
||||
}
|
||||
@@ -151,11 +180,12 @@ func TestConvectiveOutlookNormalizerOrdersProductsByDayAndType(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConvectiveOutlookNormalizerMapsProbabilisticOutlookTypes(t *testing.T) {
|
||||
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 0, 0)))
|
||||
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 38.5, -90.5)))
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
run := out.Payload.(model.WeatherOutlookRun)
|
||||
assertAllOutlooksContainLocation(t, run.Outlooks)
|
||||
for _, outlookType := range []string{
|
||||
spcprovider.OutlookTypeTornado,
|
||||
spcprovider.OutlookTypeHail,
|
||||
@@ -168,7 +198,7 @@ func TestConvectiveOutlookNormalizerMapsProbabilisticOutlookTypes(t *testing.T)
|
||||
}
|
||||
|
||||
func TestConvectiveOutlookNormalizerSkipsEmptyGeometryCollectionPlaceholder(t *testing.T) {
|
||||
bundle := spcBundle(t, 0, 0)
|
||||
bundle := spcBundle(t, 36, -99)
|
||||
replaced := false
|
||||
for i := range bundle.Products {
|
||||
if bundle.Products[i].Day == 2 && bundle.Products[i].OutlookType == spcprovider.OutlookTypeTornado {
|
||||
@@ -185,9 +215,11 @@ func TestConvectiveOutlookNormalizerSkipsEmptyGeometryCollectionPlaceholder(t *t
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
run := out.Payload.(model.WeatherOutlookRun)
|
||||
if len(run.Outlooks) != 8 {
|
||||
t.Fatalf("Outlooks length = %d, want 8", len(run.Outlooks))
|
||||
if len(run.Outlooks) != 3 {
|
||||
t.Fatalf("Outlooks length = %d, want 3", len(run.Outlooks))
|
||||
}
|
||||
assertAllOutlooksContainLocation(t, run.Outlooks)
|
||||
assertDiscussionDays(t, run.Discussions, 2)
|
||||
if got := findOutlook(run.Outlooks, 2, spcprovider.OutlookTypeTornado); got != nil {
|
||||
t.Fatalf("day 2 tornado outlook = %+v, want nil placeholder skipped", *got)
|
||||
}
|
||||
@@ -200,19 +232,40 @@ func TestConvectiveOutlookNormalizerSkipsEmptyGeometryCollectionPlaceholder(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvectiveOutlookNormalizerContainsLocationFalseOutsidePolygon(t *testing.T) {
|
||||
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 0, 0)))
|
||||
func TestConvectiveOutlookNormalizerIncludesOnlyDayWithContainingPolygons(t *testing.T) {
|
||||
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 36, -99)))
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
run := out.Payload.(model.WeatherOutlookRun)
|
||||
if run.Outlooks[0].ContainsLocation {
|
||||
t.Fatalf("ContainsLocation = true, want false")
|
||||
if len(run.Outlooks) == 0 {
|
||||
t.Fatalf("Outlooks length = 0, want retained day 2 outlooks")
|
||||
}
|
||||
assertAllOutlooksContainLocation(t, run.Outlooks)
|
||||
for i, outlook := range run.Outlooks {
|
||||
if outlook.Day != 2 {
|
||||
t.Fatalf("outlook[%d].Day = %d, want 2", i, outlook.Day)
|
||||
}
|
||||
}
|
||||
assertDiscussionDays(t, run.Discussions, 2)
|
||||
}
|
||||
|
||||
func TestConvectiveOutlookNormalizerUsesOneDiscussionForMultipleSameDayOutlooks(t *testing.T) {
|
||||
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 38.5, -90.5)))
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
run := out.Payload.(model.WeatherOutlookRun)
|
||||
if got := countOutlooksByDay(run.Outlooks, 1); got < 2 {
|
||||
t.Fatalf("day 1 outlook count = %d, want multiple", got)
|
||||
}
|
||||
assertAllOutlooksContainLocation(t, run.Outlooks)
|
||||
assertDiscussionDays(t, run.Discussions, 1)
|
||||
}
|
||||
|
||||
func TestConvectiveOutlookNormalizerPreservesCorrectionMarker(t *testing.T) {
|
||||
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 0, 0)))
|
||||
bundle := spcBundle(t, 36, -99)
|
||||
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, bundle))
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
@@ -221,11 +274,12 @@ func TestConvectiveOutlookNormalizerPreservesCorrectionMarker(t *testing.T) {
|
||||
if got == nil {
|
||||
t.Fatalf("missing day 2 tornado outlook")
|
||||
}
|
||||
if !strings.Contains(got.Headline, "CORR 1") {
|
||||
t.Fatalf("Headline = %q, want correction marker", got.Headline)
|
||||
assertDiscussionDays(t, run.Discussions, 2)
|
||||
if !strings.Contains(run.Discussions[0].Headline, "CORR 1") {
|
||||
t.Fatalf("day 2 headline = %q, want correction marker", run.Discussions[0].Headline)
|
||||
}
|
||||
if !strings.Contains(got.Discussion, "CORR 1") {
|
||||
t.Fatalf("Discussion = %q, want correction marker", got.Discussion)
|
||||
if !strings.Contains(run.Discussions[0].Discussion, "CORR 1") {
|
||||
t.Fatalf("day 2 discussion = %q, want correction marker", run.Discussions[0].Discussion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +345,19 @@ func TestConvectiveOutlookNormalizerRejectsMissingLabel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvectiveOutlookNormalizerRejectsMissingDiscussion(t *testing.T) {
|
||||
bundle := spcBundle(t, 38.5, -90.5)
|
||||
bundle.Discussions = bundle.Discussions[1:]
|
||||
|
||||
_, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, bundle))
|
||||
if err == nil {
|
||||
t.Fatalf("Normalize() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "product day1_categorical: discussion for day 1 is required") {
|
||||
t.Fatalf("error = %q, want missing discussion context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvectiveOutlookNormalizerOutputJSONShape(t *testing.T) {
|
||||
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 38.5, -90.5)))
|
||||
if err != nil {
|
||||
@@ -301,12 +368,23 @@ func TestConvectiveOutlookNormalizerOutputJSONShape(t *testing.T) {
|
||||
t.Fatalf("Marshal(payload) error = %v", err)
|
||||
}
|
||||
got := string(raw)
|
||||
for _, want := range []string{`"asOf"`, `"outlooks"`, `"containsLocation"`, `"geometry"`} {
|
||||
for _, want := range []string{`"asOf"`, `"outlooks"`, `"discussions"`, `"containsLocation"`, `"geometry"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("payload JSON missing %s: %s", want, got)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{`"products"`, `"discussions"`, `"fetchedAt"`, `"body"`} {
|
||||
outlookStart := strings.Index(got, `"outlooks"`)
|
||||
discussionStart := strings.Index(got, `"discussions"`)
|
||||
if outlookStart == -1 || discussionStart == -1 || discussionStart <= outlookStart {
|
||||
t.Fatalf("payload JSON has unexpected outlook/discussion order: %s", got)
|
||||
}
|
||||
outlookJSON := got[outlookStart:discussionStart]
|
||||
for _, unwanted := range []string{`"headline"`, `"summary"`, `"discussion"`} {
|
||||
if strings.Contains(outlookJSON, unwanted) {
|
||||
t.Fatalf("outlook JSON exposed polygon-level prose key %s: %s", unwanted, got)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{`"products"`, `"fetchedAt"`, `"body"`} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("payload JSON exposed raw key %s: %s", unwanted, got)
|
||||
}
|
||||
@@ -408,6 +486,37 @@ func findOutlook(outlooks []model.WeatherOutlook, day int, outlookType string) *
|
||||
return nil
|
||||
}
|
||||
|
||||
func countOutlooksByDay(outlooks []model.WeatherOutlook, day int) int {
|
||||
count := 0
|
||||
for _, outlook := range outlooks {
|
||||
if outlook.Day == day {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func assertAllOutlooksContainLocation(t *testing.T, outlooks []model.WeatherOutlook) {
|
||||
t.Helper()
|
||||
for i, outlook := range outlooks {
|
||||
if !outlook.ContainsLocation {
|
||||
t.Fatalf("outlook[%d].ContainsLocation = false, want true", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertDiscussionDays(t *testing.T, discussions []model.WeatherOutlookDiscussion, want ...int) {
|
||||
t.Helper()
|
||||
if len(discussions) != len(want) {
|
||||
t.Fatalf("Discussions length = %d, want %d", len(discussions), len(want))
|
||||
}
|
||||
for i, day := range want {
|
||||
if discussions[i].Day != day {
|
||||
t.Fatalf("Discussions[%d].Day = %d, want %d", i, discussions[i].Day, day)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertTime(t *testing.T, name string, got time.Time, year int, month time.Month, day int, hour int, minute int, second int) {
|
||||
t.Helper()
|
||||
want := time.Date(year, month, day, hour, minute, second, 0, time.UTC)
|
||||
|
||||
@@ -27,11 +27,34 @@ type ForecastDiscussionSection struct {
|
||||
Text string
|
||||
}
|
||||
|
||||
type forecastDiscussionSectionRole uint8
|
||||
|
||||
const (
|
||||
forecastDiscussionSectionRoleKeyMessages forecastDiscussionSectionRole = iota
|
||||
forecastDiscussionSectionRoleShortTerm
|
||||
forecastDiscussionSectionRoleLongTerm
|
||||
)
|
||||
|
||||
type forecastDiscussionSectionHeading struct {
|
||||
section string
|
||||
qualifier string
|
||||
}
|
||||
|
||||
type forecastDiscussionSectionBlock struct {
|
||||
heading forecastDiscussionSectionHeading
|
||||
body []string
|
||||
}
|
||||
|
||||
var (
|
||||
forecastDiscussionHeaderRE = regexp.MustCompile(`^\.(KEY MESSAGES|SHORT TERM|LONG TERM|AVIATION)\.\.\.(.*)$`)
|
||||
forecastDiscussionAFDRE = regexp.MustCompile(`^AFD([A-Z]{3})$`)
|
||||
forecastDiscussionWMORE = regexp.MustCompile(`\bK([A-Z]{3})\b`)
|
||||
forecastDiscussionSigRE = regexp.MustCompile(`^[A-Z]{2,6}$`)
|
||||
forecastDiscussionSectionRoles = map[string]forecastDiscussionSectionRole{
|
||||
"KEY MESSAGES": forecastDiscussionSectionRoleKeyMessages,
|
||||
"KEY POINTS": forecastDiscussionSectionRoleKeyMessages,
|
||||
"SHORT TERM": forecastDiscussionSectionRoleShortTerm,
|
||||
"LONG TERM": forecastDiscussionSectionRoleLongTerm,
|
||||
}
|
||||
forecastDiscussionAFDRE = regexp.MustCompile(`^AFD([A-Z]{3})$`)
|
||||
forecastDiscussionWMORE = regexp.MustCompile(`\bK([A-Z]{3})\b`)
|
||||
forecastDiscussionSigRE = regexp.MustCompile(`^[A-Z]{2,6}$`)
|
||||
)
|
||||
|
||||
func ParseForecastDiscussionHTML(raw string) (ForecastDiscussion, error) {
|
||||
@@ -99,22 +122,30 @@ func ParseForecastDiscussionText(text string) (ForecastDiscussion, error) {
|
||||
IssuedAt: issuedAt.UTC(),
|
||||
}
|
||||
|
||||
if block, ok := extractForecastDiscussionSection(lines, "KEY MESSAGES"); ok {
|
||||
out.KeyMessages = parseForecastDiscussionKeyMessages(block)
|
||||
}
|
||||
if block, ok := extractForecastDiscussionSection(lines, "SHORT TERM"); ok {
|
||||
section, err := parseForecastDiscussionTextSection(block)
|
||||
if err != nil {
|
||||
return ForecastDiscussion{}, fmt.Errorf("parse SHORT TERM: %w", err)
|
||||
seenRoles := make(map[forecastDiscussionSectionRole]bool, len(forecastDiscussionSectionRoles))
|
||||
for _, block := range parseForecastDiscussionSectionBlocks(lines) {
|
||||
role, ok := forecastDiscussionSectionRoles[block.heading.section]
|
||||
if !ok || seenRoles[role] {
|
||||
continue
|
||||
}
|
||||
out.ShortTerm = §ion
|
||||
}
|
||||
if block, ok := extractForecastDiscussionSection(lines, "LONG TERM"); ok {
|
||||
section, err := parseForecastDiscussionTextSection(block)
|
||||
if err != nil {
|
||||
return ForecastDiscussion{}, fmt.Errorf("parse LONG TERM: %w", err)
|
||||
seenRoles[role] = true
|
||||
|
||||
switch role {
|
||||
case forecastDiscussionSectionRoleKeyMessages:
|
||||
out.KeyMessages = parseForecastDiscussionKeyMessages(block.body)
|
||||
case forecastDiscussionSectionRoleShortTerm:
|
||||
section, err := parseForecastDiscussionTextSection(block)
|
||||
if err != nil {
|
||||
return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err)
|
||||
}
|
||||
out.ShortTerm = §ion
|
||||
case forecastDiscussionSectionRoleLongTerm:
|
||||
section, err := parseForecastDiscussionTextSection(block)
|
||||
if err != nil {
|
||||
return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err)
|
||||
}
|
||||
out.LongTerm = §ion
|
||||
}
|
||||
out.LongTerm = §ion
|
||||
}
|
||||
|
||||
return out, nil
|
||||
@@ -285,8 +316,9 @@ func parseForecastDiscussionHeader(lines []string) (string, time.Time, error) {
|
||||
|
||||
func parseForecastDiscussionIssueTime(line string) (time.Time, error) {
|
||||
line = strings.TrimSpace(line)
|
||||
line = strings.TrimPrefix(line, "Issued at ")
|
||||
line = strings.TrimSpace(line)
|
||||
if isForecastDiscussionIssuedAtLine(line) {
|
||||
line = strings.TrimSpace(line[len("Issued at"):])
|
||||
}
|
||||
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) != 7 {
|
||||
@@ -386,40 +418,217 @@ func forecastDiscussionLocation(abbrev string) (*time.Location, error) {
|
||||
return time.FixedZone(abbr, offset), nil
|
||||
}
|
||||
|
||||
func extractForecastDiscussionSection(lines []string, section string) ([]string, bool) {
|
||||
target := "." + section + "..."
|
||||
for i, raw := range lines {
|
||||
func parseForecastDiscussionSectionHeading(line string) (forecastDiscussionSectionHeading, bool) {
|
||||
line = strings.TrimSpace(line)
|
||||
if len(line) < 2 || line[0] != '.' {
|
||||
return forecastDiscussionSectionHeading{}, false
|
||||
}
|
||||
|
||||
if strings.HasSuffix(line, "/...") {
|
||||
return parseForecastDiscussionSlashQualifiedHeading(line)
|
||||
}
|
||||
if strings.HasSuffix(line, "...") {
|
||||
if heading, ok := parseForecastDiscussionParenthesizedTerminalHeading(line); ok {
|
||||
return heading, true
|
||||
}
|
||||
}
|
||||
return parseForecastDiscussionEllipsisHeading(line)
|
||||
}
|
||||
|
||||
func parseForecastDiscussionSlashQualifiedHeading(line string) (forecastDiscussionSectionHeading, bool) {
|
||||
content := strings.TrimSuffix(line[1:], "/...")
|
||||
separator := -1
|
||||
for i := 1; i < len(content); i++ {
|
||||
if content[i] == '/' && isForecastDiscussionHorizontalWhitespace(content[i-1]) {
|
||||
separator = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if separator < 0 {
|
||||
return forecastDiscussionSectionHeading{}, false
|
||||
}
|
||||
|
||||
section, ok := normalizeForecastDiscussionSectionIdentity(content[:separator])
|
||||
if !ok {
|
||||
return forecastDiscussionSectionHeading{}, false
|
||||
}
|
||||
qualifier := strings.TrimSpace(content[separator+1:])
|
||||
if qualifier == "" {
|
||||
return forecastDiscussionSectionHeading{}, false
|
||||
}
|
||||
return forecastDiscussionSectionHeading{section: section, qualifier: qualifier}, true
|
||||
}
|
||||
|
||||
func parseForecastDiscussionParenthesizedTerminalHeading(line string) (forecastDiscussionSectionHeading, bool) {
|
||||
if len(line) < 4 || line[0] != '.' || !strings.HasSuffix(line, "...") {
|
||||
return forecastDiscussionSectionHeading{}, false
|
||||
}
|
||||
|
||||
content := strings.TrimRight(line[1:len(line)-3], " \t")
|
||||
if !strings.HasSuffix(content, ")") {
|
||||
return forecastDiscussionSectionHeading{}, false
|
||||
}
|
||||
|
||||
separator := -1
|
||||
for i := 1; i < len(content); i++ {
|
||||
if content[i] == '(' && isForecastDiscussionHorizontalWhitespace(content[i-1]) {
|
||||
separator = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if separator < 0 {
|
||||
return forecastDiscussionSectionHeading{}, false
|
||||
}
|
||||
|
||||
section, ok := normalizeForecastDiscussionSectionIdentity(content[:separator])
|
||||
if !ok {
|
||||
return forecastDiscussionSectionHeading{}, false
|
||||
}
|
||||
qualifier := content[separator:]
|
||||
if len(qualifier) <= 2 || strings.TrimSpace(qualifier[1:len(qualifier)-1]) == "" {
|
||||
return forecastDiscussionSectionHeading{}, false
|
||||
}
|
||||
return forecastDiscussionSectionHeading{section: section, qualifier: qualifier}, true
|
||||
}
|
||||
|
||||
func parseForecastDiscussionEllipsisHeading(line string) (forecastDiscussionSectionHeading, bool) {
|
||||
content := line[1:]
|
||||
delimiter := strings.Index(content, "...")
|
||||
if delimiter < 0 {
|
||||
return forecastDiscussionSectionHeading{}, false
|
||||
}
|
||||
|
||||
section, ok := normalizeForecastDiscussionSectionIdentity(content[:delimiter])
|
||||
if !ok {
|
||||
return forecastDiscussionSectionHeading{}, false
|
||||
}
|
||||
return forecastDiscussionSectionHeading{
|
||||
section: section,
|
||||
qualifier: strings.TrimSpace(content[delimiter+3:]),
|
||||
}, true
|
||||
}
|
||||
|
||||
func normalizeForecastDiscussionSectionIdentity(raw string) (string, bool) {
|
||||
var normalized strings.Builder
|
||||
pendingSpace := false
|
||||
hasLetterOrDigit := false
|
||||
|
||||
for i := 0; i < len(raw); i++ {
|
||||
b := raw[i]
|
||||
switch {
|
||||
case isForecastDiscussionIdentityLetterOrDigit(b):
|
||||
hasLetterOrDigit = true
|
||||
case b == ' ' || b == '\t':
|
||||
pendingSpace = normalized.Len() > 0
|
||||
continue
|
||||
case b == '/' && i > 0 && isForecastDiscussionHorizontalWhitespace(raw[i-1]):
|
||||
return "", false
|
||||
case b != '/' && b != '&' && b != '\'' && b != '-':
|
||||
return "", false
|
||||
}
|
||||
|
||||
if pendingSpace {
|
||||
normalized.WriteByte(' ')
|
||||
pendingSpace = false
|
||||
}
|
||||
normalized.WriteByte(b)
|
||||
}
|
||||
if !hasLetterOrDigit {
|
||||
return "", false
|
||||
}
|
||||
return normalized.String(), true
|
||||
}
|
||||
|
||||
func isForecastDiscussionIdentityLetterOrDigit(b byte) bool {
|
||||
return b >= 'A' && b <= 'Z' || b >= '0' && b <= '9'
|
||||
}
|
||||
|
||||
func isForecastDiscussionHorizontalWhitespace(b byte) bool {
|
||||
return b == ' ' || b == '\t'
|
||||
}
|
||||
|
||||
func parseForecastDiscussionSectionBlocks(lines []string) []forecastDiscussionSectionBlock {
|
||||
var blocks []forecastDiscussionSectionBlock
|
||||
var active *forecastDiscussionSectionBlock
|
||||
embeddedHeadings := false
|
||||
|
||||
finish := func() {
|
||||
if active == nil {
|
||||
return
|
||||
}
|
||||
blocks = append(blocks, *active)
|
||||
active = nil
|
||||
}
|
||||
|
||||
for _, raw := range lines {
|
||||
line := strings.TrimSpace(raw)
|
||||
if !strings.HasPrefix(line, target) {
|
||||
if line == "$$" {
|
||||
finish()
|
||||
break
|
||||
}
|
||||
if line == "&&" || strings.Contains(line, "WATCHES/WARNINGS/ADVISORIES") {
|
||||
finish()
|
||||
embeddedHeadings = false
|
||||
continue
|
||||
}
|
||||
|
||||
out := []string{line}
|
||||
for j := i + 1; j < len(lines); j++ {
|
||||
next := strings.TrimSpace(lines[j])
|
||||
if next == "&&" || next == "$$" || strings.Contains(next, "WATCHES/WARNINGS/ADVISORIES") {
|
||||
break
|
||||
}
|
||||
if j > i+1 && isForecastDiscussionSectionHeader(next) {
|
||||
break
|
||||
}
|
||||
out = append(out, lines[j])
|
||||
heading, ok := parseForecastDiscussionSectionHeading(raw)
|
||||
if ok {
|
||||
finish()
|
||||
active = &forecastDiscussionSectionBlock{heading: heading}
|
||||
embeddedHeadings = isForecastDiscussionEmbeddedSectionWrapper(heading.section)
|
||||
continue
|
||||
}
|
||||
if embeddedHeadings {
|
||||
heading, ok = parseForecastDiscussionEmbeddedSectionHeading(raw)
|
||||
if ok {
|
||||
finish()
|
||||
active = &forecastDiscussionSectionBlock{heading: heading}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if active != nil {
|
||||
active.body = append(active.body, raw)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
return nil, false
|
||||
finish()
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
func isForecastDiscussionSectionHeader(line string) bool {
|
||||
return forecastDiscussionHeaderRE.MatchString(strings.TrimSpace(line))
|
||||
func isForecastDiscussionEmbeddedSectionWrapper(section string) bool {
|
||||
return section == "PREV DISCUSSION"
|
||||
}
|
||||
|
||||
func parseForecastDiscussionKeyMessages(block []string) []string {
|
||||
if len(block) <= 1 {
|
||||
func parseForecastDiscussionEmbeddedSectionHeading(line string) (forecastDiscussionSectionHeading, bool) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || line[0] == '.' {
|
||||
return forecastDiscussionSectionHeading{}, false
|
||||
}
|
||||
return parseForecastDiscussionSectionHeading("." + line)
|
||||
}
|
||||
|
||||
func parseForecastDiscussionKeyMessages(body []string) []string {
|
||||
body = removeForecastDiscussionPresentationMarkers(body)
|
||||
body = trimBlankLines(body)
|
||||
if len(body) > 0 && isForecastDiscussionKeyMessageMetadataLine(body[0]) {
|
||||
body = trimBlankLines(body[1:])
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return nil
|
||||
}
|
||||
hasMarkers := false
|
||||
for _, raw := range body {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := stripForecastDiscussionKeyMessageMarker(line); ok {
|
||||
hasMarkers = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
body := trimBlankLines(block[1:])
|
||||
var messages []string
|
||||
var current strings.Builder
|
||||
|
||||
@@ -431,15 +640,21 @@ func parseForecastDiscussionKeyMessages(block []string) []string {
|
||||
current.Reset()
|
||||
}
|
||||
|
||||
seenMarker := false
|
||||
for _, raw := range body {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
if !hasMarkers || !seenMarker {
|
||||
flush()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "-") {
|
||||
if stripped, ok := stripForecastDiscussionKeyMessageMarker(line); ok {
|
||||
flush()
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "-"))
|
||||
current.WriteString(line)
|
||||
seenMarker = true
|
||||
line = stripped
|
||||
}
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if current.Len() > 0 {
|
||||
@@ -452,25 +667,24 @@ func parseForecastDiscussionKeyMessages(block []string) []string {
|
||||
return messages
|
||||
}
|
||||
|
||||
func parseForecastDiscussionTextSection(block []string) (ForecastDiscussionSection, error) {
|
||||
if len(block) == 0 {
|
||||
return ForecastDiscussionSection{}, fmt.Errorf("empty section")
|
||||
}
|
||||
|
||||
func parseForecastDiscussionTextSection(block forecastDiscussionSectionBlock) (ForecastDiscussionSection, error) {
|
||||
section := ForecastDiscussionSection{
|
||||
Qualifier: parseForecastDiscussionQualifier(strings.TrimSpace(block[0])),
|
||||
Qualifier: block.heading.qualifier,
|
||||
}
|
||||
|
||||
body := trimBlankLines(block[1:])
|
||||
body := trimBlankLines(removeForecastDiscussionPresentationMarkers(block.body))
|
||||
if section.Qualifier == "" && len(body) > 0 && isForecastDiscussionStandaloneParenthetical(body[0]) {
|
||||
section.Qualifier = strings.TrimSpace(body[0])
|
||||
body = trimBlankLines(body[1:])
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return section, nil
|
||||
}
|
||||
|
||||
first := strings.TrimSpace(body[0])
|
||||
if strings.HasPrefix(first, "Issued at ") {
|
||||
issuedAt, err := parseForecastDiscussionIssueTime(first)
|
||||
if isForecastDiscussionIssuedAtLine(body[0]) {
|
||||
issuedAt, err := parseForecastDiscussionIssueTime(body[0])
|
||||
if err != nil {
|
||||
return ForecastDiscussionSection{}, fmt.Errorf("parse section issuedAt %q: %w", first, err)
|
||||
return ForecastDiscussionSection{}, fmt.Errorf("parse section issuedAt %q: %w", strings.TrimSpace(body[0]), err)
|
||||
}
|
||||
tt := issuedAt.UTC()
|
||||
section.IssuedAt = &tt
|
||||
@@ -482,12 +696,147 @@ func parseForecastDiscussionTextSection(block []string) (ForecastDiscussionSecti
|
||||
return section, nil
|
||||
}
|
||||
|
||||
func parseForecastDiscussionQualifier(header string) string {
|
||||
m := forecastDiscussionHeaderRE.FindStringSubmatch(header)
|
||||
if len(m) != 3 {
|
||||
return ""
|
||||
func isForecastDiscussionPresentationMarker(line string) bool {
|
||||
switch {
|
||||
case strings.EqualFold(strings.TrimSpace(line), "-- Changed Discussion --"):
|
||||
return true
|
||||
case strings.EqualFold(strings.TrimSpace(line), "-- End Changed Discussion --"):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(m[2])
|
||||
}
|
||||
|
||||
func removeForecastDiscussionPresentationMarkers(lines []string) []string {
|
||||
body := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if !isForecastDiscussionPresentationMarker(line) {
|
||||
body = append(body, line)
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func isForecastDiscussionStandaloneParenthetical(line string) bool {
|
||||
line = strings.TrimSpace(line)
|
||||
return len(line) > 2 && line[0] == '(' && line[len(line)-1] == ')' && strings.TrimSpace(line[1:len(line)-1]) != ""
|
||||
}
|
||||
|
||||
func isForecastDiscussionIssuedAtLine(line string) bool {
|
||||
line = strings.TrimSpace(line)
|
||||
return len(line) > len("Issued at") &&
|
||||
strings.EqualFold(line[:len("Issued at")], "Issued at") &&
|
||||
isForecastDiscussionHorizontalWhitespace(line[len("Issued at")])
|
||||
}
|
||||
|
||||
func isForecastDiscussionKeyMessageMetadataLine(line string) bool {
|
||||
line = strings.TrimSpace(line)
|
||||
for _, label := range []string{"Issued at", "Updated at"} {
|
||||
if !hasForecastDiscussionASCIIPrefix(line, label) || len(line) == len(label) || !isForecastDiscussionHorizontalWhitespace(line[len(label)]) {
|
||||
continue
|
||||
}
|
||||
if _, err := parseForecastDiscussionIssueTime(strings.TrimSpace(line[len(label):])); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return isForecastDiscussionKeyMessageAsOfLine(line)
|
||||
}
|
||||
|
||||
func isForecastDiscussionKeyMessageAsOfLine(line string) bool {
|
||||
const label = "As of"
|
||||
|
||||
if !hasForecastDiscussionASCIIPrefix(line, label) || len(line) == len(label) || !isForecastDiscussionHorizontalWhitespace(line[len(label)]) {
|
||||
return false
|
||||
}
|
||||
remainder := strings.TrimSpace(line[len(label):])
|
||||
if !strings.HasSuffix(remainder, "...") {
|
||||
return false
|
||||
}
|
||||
fields := strings.Fields(strings.TrimSpace(strings.TrimSuffix(remainder, "...")))
|
||||
if len(fields) != 3 {
|
||||
return false
|
||||
}
|
||||
if _, _, err := parseForecastDiscussionClock(fields[0], fields[1]); err != nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(fields[2]) {
|
||||
case "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func stripForecastDiscussionKeyMessageMarker(line string) (string, bool) {
|
||||
if line == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if line[0] == '-' || line[0] == '*' {
|
||||
content := line[1:]
|
||||
hadWhitespace := len(content) > 0 && isForecastDiscussionHorizontalWhitespace(content[0])
|
||||
content = strings.TrimLeft(content, " \t")
|
||||
if hadWhitespace {
|
||||
if stripped, ok := stripForecastDiscussionKeyMessageNumericMarker(content); ok {
|
||||
content = stripped
|
||||
}
|
||||
}
|
||||
return content, true
|
||||
}
|
||||
|
||||
return stripForecastDiscussionKeyMessageNumericMarker(line)
|
||||
}
|
||||
|
||||
func stripForecastDiscussionKeyMessageNumericMarker(line string) (string, bool) {
|
||||
digitStart := 0
|
||||
digitEnd := 0
|
||||
parenthesized := len(line) > 0 && line[0] == '('
|
||||
if parenthesized {
|
||||
digitStart = 1
|
||||
digitEnd = 1
|
||||
}
|
||||
for digitEnd < len(line) && line[digitEnd] >= '0' && line[digitEnd] <= '9' {
|
||||
digitEnd++
|
||||
}
|
||||
if digitEnd == digitStart || digitEnd == len(line) {
|
||||
return "", false
|
||||
}
|
||||
if parenthesized && line[digitEnd] != ')' {
|
||||
return "", false
|
||||
}
|
||||
if !parenthesized && line[digitEnd] != ')' && line[digitEnd] != '.' {
|
||||
return "", false
|
||||
}
|
||||
|
||||
markerEnd := digitEnd + 1
|
||||
if markerEnd < len(line) && !isForecastDiscussionHorizontalWhitespace(line[markerEnd]) {
|
||||
return "", false
|
||||
}
|
||||
value, err := strconv.ParseUint(line[digitStart:digitEnd], 10, 0)
|
||||
if err != nil || value == 0 {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimLeft(line[markerEnd:], " \t"), true
|
||||
}
|
||||
|
||||
func hasForecastDiscussionASCIIPrefix(line, prefix string) bool {
|
||||
if len(line) < len(prefix) {
|
||||
return false
|
||||
}
|
||||
for i := range prefix {
|
||||
actual := line[i]
|
||||
if actual >= 'A' && actual <= 'Z' {
|
||||
actual += 'a' - 'A'
|
||||
}
|
||||
expected := prefix[i]
|
||||
if expected >= 'A' && expected <= 'Z' {
|
||||
expected += 'a' - 'A'
|
||||
}
|
||||
if actual != expected {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func trimBlankLines(lines []string) []string {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
26
internal/providers/nws/testdata/forecast_discussion_bgm_numbered_sample.html
vendored
Normal file
26
internal/providers/nws/testdata/forecast_discussion_bgm_numbered_sample.html
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Representative BGM/CTP-style layout; prose is concise edited test data, not an archived product. -->
|
||||
<html>
|
||||
<body>
|
||||
<pre class="glossaryProduct">
|
||||
FXUS61 KBGM 101730
|
||||
AFDBGM
|
||||
|
||||
Area Forecast Discussion
|
||||
National Weather Service Binghamton NY
|
||||
130 PM EDT Fri Apr 10 2026
|
||||
|
||||
.KEY MESSAGES...
|
||||
1) Periods of rain are expected through Saturday,
|
||||
with locally heavier amounts possible.
|
||||
2. Cooler temperatures return late this weekend.
|
||||
|
||||
.DISCUSSION...
|
||||
Discussion details remain boundary-only content.
|
||||
|
||||
$$
|
||||
|
||||
WFO BGM
|
||||
</pre>
|
||||
</body>
|
||||
</html>
|
||||
48
internal/providers/nws/testdata/forecast_discussion_bou_sample.html
vendored
Normal file
48
internal/providers/nws/testdata/forecast_discussion_bou_sample.html
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<pre class="glossaryProduct">
|
||||
FXUS65 KBOU 071900
|
||||
AFDBOU
|
||||
|
||||
Area Forecast Discussion
|
||||
National Weather Service Denver CO
|
||||
100 PM MDT Tue Apr 7 2026
|
||||
|
||||
.KEY MESSAGES...
|
||||
-- Changed Discussion --
|
||||
Updated at 100 PM MDT Tue Apr 7 2026
|
||||
- Strong winds are expected along the Front Range this evening.
|
||||
- Cooler temperatures arrive on Wednesday.
|
||||
-- End Changed Discussion --
|
||||
|
||||
&&
|
||||
|
||||
.SHORT TERM...
|
||||
(Tonight through Wednesday)
|
||||
Issued at 100 PM MDT Tue Apr 7 2026
|
||||
|
||||
Gusty west winds will continue through the evening before decreasing overnight.
|
||||
|
||||
&&
|
||||
|
||||
.LONG TERM...
|
||||
(Thursday through Saturday)
|
||||
ISSUED AT 100 PM MDT Tue Apr 7 2026
|
||||
|
||||
Warmer and drier conditions return Thursday, followed by a chance of showers Friday.
|
||||
|
||||
&&
|
||||
|
||||
.AVIATION...
|
||||
|
||||
VFR conditions are expected at Denver-area terminals through Wednesday morning.
|
||||
|
||||
&&
|
||||
|
||||
$$
|
||||
|
||||
WFO BOU
|
||||
</pre>
|
||||
</body>
|
||||
</html>
|
||||
27
internal/providers/nws/testdata/forecast_discussion_lwx_parenthesized_number_sample.html
vendored
Normal file
27
internal/providers/nws/testdata/forecast_discussion_lwx_parenthesized_number_sample.html
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Representative LWX-style layout; prose is concise edited test data, not an archived product. -->
|
||||
<html>
|
||||
<body>
|
||||
<pre class="glossaryProduct">
|
||||
FXUS61 KLWX 021800
|
||||
AFDLWX
|
||||
|
||||
Area Forecast Discussion
|
||||
National Weather Service Baltimore MD/Washington DC
|
||||
200 PM EDT Sun Aug 2 2026
|
||||
|
||||
.KEY MESSAGES...
|
||||
- (1) Thunderstorms remain possible near the Blue Ridge this evening.
|
||||
- (2) Seasonably warm conditions continue Monday.
|
||||
|
||||
&&
|
||||
|
||||
.AVIATION...
|
||||
Aviation details remain boundary-only content.
|
||||
|
||||
$$
|
||||
|
||||
WFO LWX
|
||||
</pre>
|
||||
</body>
|
||||
</html>
|
||||
25
internal/providers/nws/testdata/forecast_discussion_mfr_key_points_sample.html
vendored
Normal file
25
internal/providers/nws/testdata/forecast_discussion_mfr_key_points_sample.html
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Representative MFR-style layout; prose is concise edited test data, not an archived product. -->
|
||||
<html>
|
||||
<body>
|
||||
<pre class="glossaryProduct">
|
||||
FXUS66 KMFR 101945
|
||||
AFDMFR
|
||||
|
||||
Area Forecast Discussion
|
||||
National Weather Service Medford OR
|
||||
1245 PM PDT Fri Apr 10 2026
|
||||
|
||||
.KEY POINTS...
|
||||
* Gusty winds will develop over exposed ridges,
|
||||
especially during the afternoon.
|
||||
* Inland valleys remain dry through Saturday.
|
||||
.DISCUSSION (Today through Thursday)...
|
||||
Discussion details must not be included with key points.
|
||||
|
||||
$$
|
||||
|
||||
WFO MFR
|
||||
</pre>
|
||||
</body>
|
||||
</html>
|
||||
33
internal/providers/nws/testdata/forecast_discussion_mfr_prev_discussion_sample.html
vendored
Normal file
33
internal/providers/nws/testdata/forecast_discussion_mfr_prev_discussion_sample.html
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Representative current MFR previous-discussion wrapper layout; prose is concise edited test data, not an archived product. -->
|
||||
<html>
|
||||
<body>
|
||||
<pre class="glossaryProduct">
|
||||
FXUS66 KMFR 022219
|
||||
AFDMFR
|
||||
|
||||
Area Forecast Discussion
|
||||
National Weather Service Medford OR
|
||||
319 PM PDT Sun Aug 2 2026
|
||||
|
||||
.PREV DISCUSSION... /Issued 319 PM PDT Sun Aug 2 2026/
|
||||
|
||||
KEY MESSAGES...
|
||||
|
||||
* Heat returns to inland valleys Monday.
|
||||
* Gusty afternoon winds develop east of the Cascades.
|
||||
|
||||
DISCUSSION...
|
||||
Discussion details must not be included with key messages.
|
||||
|
||||
&&
|
||||
|
||||
.MFR WATCHES/WARNINGS/ADVISORIES...
|
||||
None.
|
||||
|
||||
$$
|
||||
|
||||
WFO MFR
|
||||
</pre>
|
||||
</body>
|
||||
</html>
|
||||
29
internal/providers/nws/testdata/forecast_discussion_rah_as_of_sample.html
vendored
Normal file
29
internal/providers/nws/testdata/forecast_discussion_rah_as_of_sample.html
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Representative RAH-style layout; prose is concise edited test data, not an archived product. -->
|
||||
<html>
|
||||
<body>
|
||||
<pre class="glossaryProduct">
|
||||
FXUS62 KRAH 021635
|
||||
AFDRAH
|
||||
|
||||
Area Forecast Discussion
|
||||
National Weather Service Raleigh NC
|
||||
1235 PM EDT Sun Aug 2 2026
|
||||
|
||||
.KEY MESSAGES...
|
||||
As of 1235 PM Sunday...
|
||||
|
||||
1) Scattered storms may produce locally heavy rain this afternoon.
|
||||
2) Drier weather arrives Monday.
|
||||
|
||||
&&
|
||||
|
||||
.DISCUSSION...
|
||||
Discussion details remain boundary-only content.
|
||||
|
||||
$$
|
||||
|
||||
WFO RAH
|
||||
</pre>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,7 +11,7 @@
|
||||
// - weather.forecast_discussion.v1 -> model.WeatherForecastDiscussion
|
||||
// - weather.weather_story.v1 -> model.WeatherStoryRun
|
||||
// - weather.alert.v1 -> model.WeatherAlertRun
|
||||
// - weather.outlook.v1 -> model.WeatherOutlookRun
|
||||
// - weather.outlook.v2 -> model.WeatherOutlookRun
|
||||
//
|
||||
// Parent/child relationships:
|
||||
// - observations.event_id -> observation_present_weather.event_id
|
||||
@@ -21,6 +21,7 @@
|
||||
// - alert_runs.event_id -> alerts.run_event_id
|
||||
// - alerts.(run_event_id, alert_index) -> alert_references.(run_event_id, alert_index)
|
||||
// - outlook_runs.event_id -> outlooks.run_event_id
|
||||
// - outlook_runs.event_id -> outlook_discussions.run_event_id
|
||||
//
|
||||
// Dedupe and retention behavior:
|
||||
// - Parent primary keys (event_id): observations, forecasts, alert_runs, outlook_runs.
|
||||
@@ -39,6 +40,7 @@
|
||||
// - alert_references.as_of
|
||||
// - outlook_runs.as_of
|
||||
// - outlooks.as_of
|
||||
// - outlook_discussions.as_of
|
||||
//
|
||||
// Envelope field mapping (shared parent columns)
|
||||
//
|
||||
@@ -196,6 +198,7 @@
|
||||
// - sent TIMESTAMPTZ NULL -> payload.alerts[i].sent
|
||||
// - effective TIMESTAMPTZ NULL -> payload.alerts[i].effective
|
||||
// - onset TIMESTAMPTZ NULL -> payload.alerts[i].onset
|
||||
// - ends TIMESTAMPTZ NULL -> payload.alerts[i].ends
|
||||
// - expires TIMESTAMPTZ NULL -> payload.alerts[i].expires
|
||||
// - area_description TEXT NULL -> payload.alerts[i].areaDescription
|
||||
// - sender_name TEXT NULL -> payload.alerts[i].senderName
|
||||
@@ -227,6 +230,7 @@
|
||||
// - as_of TIMESTAMPTZ -> payload.asOf
|
||||
// - issued_at TIMESTAMPTZ NULL -> payload.issuedAt
|
||||
// - outlook_count INTEGER -> len(payload.outlooks)
|
||||
// - discussion_count INTEGER -> len(payload.discussions)
|
||||
//
|
||||
// 11. outlooks (PK: run_event_id, outlook_index)
|
||||
//
|
||||
@@ -246,14 +250,22 @@
|
||||
// - issued_at TIMESTAMPTZ -> payload.outlooks[i].issuedAt
|
||||
// - expires_at TIMESTAMPTZ -> payload.outlooks[i].expiresAt
|
||||
// - forecaster TEXT NULL -> payload.outlooks[i].forecaster
|
||||
// - headline TEXT NULL -> payload.outlooks[i].headline
|
||||
// - summary TEXT NULL -> payload.outlooks[i].summary
|
||||
// - discussion TEXT NULL -> payload.outlooks[i].discussion
|
||||
// - source_url TEXT NULL -> payload.outlooks[i].sourceUrl
|
||||
// - image_url TEXT NULL -> payload.outlooks[i].imageUrl
|
||||
// - contains_location BOOLEAN -> payload.outlooks[i].containsLocation
|
||||
// - geometry_json TEXT -> compact JSON payload.outlooks[i].geometry
|
||||
//
|
||||
// 12. outlook_discussions (PK: run_event_id, discussion_index)
|
||||
//
|
||||
// - run_event_id TEXT -> outlook_runs.event_id / payload.discussions[i]
|
||||
// - discussion_index INTEGER -> i (array position in payload.discussions)
|
||||
// - as_of TIMESTAMPTZ -> payload.asOf (copied from parent)
|
||||
// - day INTEGER -> payload.discussions[i].day
|
||||
// - headline TEXT NULL -> payload.discussions[i].headline
|
||||
// - summary TEXT NULL -> payload.discussions[i].summary
|
||||
// - discussion TEXT NULL -> payload.discussions[i].discussion
|
||||
// - updated_at TIMESTAMPTZ NULL -> payload.discussions[i].updatedAt
|
||||
//
|
||||
// Reconstructing canonical JSON payloads
|
||||
//
|
||||
// - WeatherObservation:
|
||||
@@ -274,6 +286,7 @@
|
||||
// ordered by reference_index to rebuild references per alert.
|
||||
//
|
||||
// - WeatherOutlookRun:
|
||||
// read one row from outlook_runs, then join outlooks by run_event_id ordered
|
||||
// by outlook_index to rebuild outlooks.
|
||||
// read one row from outlook_runs, join outlooks by run_event_id ordered by
|
||||
// outlook_index to rebuild outlooks, then join outlook_discussions by
|
||||
// run_event_id ordered by discussion_index to rebuild discussions.
|
||||
package postgres
|
||||
|
||||
33
internal/sinks/postgres/docs_test.go
Normal file
33
internal/sinks/postgres/docs_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDocumentedOutlookDiscussionStorage(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"../../../docs/integrations/postgres.md",
|
||||
"../../../docs/internal/postgres-sink.md",
|
||||
} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", path, err)
|
||||
}
|
||||
doc := string(raw)
|
||||
|
||||
for _, want := range []string{
|
||||
tableOutlookDiscussions,
|
||||
"discussion_count",
|
||||
"discussion_index",
|
||||
"weather.outlook.v2",
|
||||
} {
|
||||
if !strings.Contains(doc, want) {
|
||||
t.Fatalf("%s missing %q", path, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func mapPostgresEvent(_ context.Context, e fkevent.Event) ([]fksinks.PostgresWri
|
||||
return mapWeatherStoryEvent(e)
|
||||
case standards.SchemaWeatherAlertV1:
|
||||
return mapAlertEvent(e)
|
||||
case standards.SchemaWeatherOutlookV1:
|
||||
case standards.SchemaWeatherOutlookV2:
|
||||
return mapOutlookEvent(e)
|
||||
default:
|
||||
return nil, nil
|
||||
@@ -302,6 +302,7 @@ func mapAlertEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
|
||||
"sent": nullableTime(a.Sent),
|
||||
"effective": nullableTime(a.Effective),
|
||||
"onset": nullableTime(a.Onset),
|
||||
"ends": nullableTime(a.Ends),
|
||||
"expires": nullableTime(a.Expires),
|
||||
"area_description": nullableString(a.AreaDescription),
|
||||
"sender_name": nullableString(a.SenderName),
|
||||
@@ -339,17 +340,22 @@ func mapOutlookEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
|
||||
}
|
||||
|
||||
asOf := run.AsOf.UTC()
|
||||
writes := make([]fksinks.PostgresWrite, 0, 1+len(run.Outlooks))
|
||||
if err := validateOutlookDiscussions(run.Discussions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
writes := make([]fksinks.PostgresWrite, 0, 1+len(run.Outlooks)+len(run.Discussions))
|
||||
writes = append(writes, fksinks.PostgresWrite{
|
||||
Table: tableOutlookRuns,
|
||||
Values: parentEventValues(e, map[string]any{
|
||||
"location_id": nullableString(run.LocationID),
|
||||
"location_name": nullableString(run.LocationName),
|
||||
"latitude": nullableFloat64(run.Latitude),
|
||||
"longitude": nullableFloat64(run.Longitude),
|
||||
"as_of": asOf,
|
||||
"issued_at": nullableTime(run.IssuedAt),
|
||||
"outlook_count": len(run.Outlooks),
|
||||
"location_id": nullableString(run.LocationID),
|
||||
"location_name": nullableString(run.LocationName),
|
||||
"latitude": nullableFloat64(run.Latitude),
|
||||
"longitude": nullableFloat64(run.Longitude),
|
||||
"as_of": asOf,
|
||||
"issued_at": nullableTime(run.IssuedAt),
|
||||
"outlook_count": len(run.Outlooks),
|
||||
"discussion_count": len(run.Discussions),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -381,9 +387,6 @@ func mapOutlookEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
|
||||
"issued_at": outlook.IssuedAt.UTC(),
|
||||
"expires_at": outlook.ExpiresAt.UTC(),
|
||||
"forecaster": nullableString(outlook.Forecaster),
|
||||
"headline": nullableString(outlook.Headline),
|
||||
"summary": nullableString(outlook.Summary),
|
||||
"discussion": nullableString(outlook.Discussion),
|
||||
"source_url": nullableString(outlook.SourceURL),
|
||||
"image_url": nullableString(outlook.ImageURL),
|
||||
"contains_location": outlook.ContainsLocation,
|
||||
@@ -392,6 +395,22 @@ func mapOutlookEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
|
||||
})
|
||||
}
|
||||
|
||||
for i, discussion := range run.Discussions {
|
||||
writes = append(writes, fksinks.PostgresWrite{
|
||||
Table: tableOutlookDiscussions,
|
||||
Values: map[string]any{
|
||||
"run_event_id": e.ID,
|
||||
"discussion_index": i,
|
||||
"as_of": asOf,
|
||||
"day": discussion.Day,
|
||||
"headline": nullableString(discussion.Headline),
|
||||
"summary": nullableString(discussion.Summary),
|
||||
"discussion": nullableString(discussion.Discussion),
|
||||
"updated_at": nullableTime(discussion.UpdatedAt),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return writes, nil
|
||||
}
|
||||
|
||||
@@ -423,6 +442,28 @@ func validateOutlook(outlook model.WeatherOutlook, index int) error {
|
||||
if len(outlook.Geometry) == 0 {
|
||||
return fmt.Errorf("decode outlook payload: outlooks[%d].geometry is required", index)
|
||||
}
|
||||
if !outlook.ContainsLocation {
|
||||
return fmt.Errorf("decode outlook payload: outlooks[%d].containsLocation must be true", index)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOutlookDiscussions(discussions []model.WeatherOutlookDiscussion) error {
|
||||
seenDays := map[int]int{}
|
||||
for i, discussion := range discussions {
|
||||
if discussion.Day < 1 || discussion.Day > 3 {
|
||||
return fmt.Errorf("decode outlook payload: discussions[%d].day must be 1, 2, or 3", i)
|
||||
}
|
||||
if strings.TrimSpace(discussion.Headline) == "" &&
|
||||
strings.TrimSpace(discussion.Summary) == "" &&
|
||||
strings.TrimSpace(discussion.Discussion) == "" {
|
||||
return fmt.Errorf("decode outlook payload: discussions[%d] headline, summary, or discussion is required", i)
|
||||
}
|
||||
if first, ok := seenDays[discussion.Day]; ok {
|
||||
return fmt.Errorf("decode outlook payload: discussions[%d].day duplicates discussions[%d].day %d", i, first, discussion.Day)
|
||||
}
|
||||
seenDays[discussion.Day] = i
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,8 @@ func TestMapPostgresEventForecastStructPayload(t *testing.T) {
|
||||
|
||||
func TestMapPostgresEventAlertStructPayload(t *testing.T) {
|
||||
sent := time.Date(2026, 3, 16, 17, 0, 0, 0, time.UTC)
|
||||
ends := time.Date(2026, 3, 16, 20, 0, 0, 0, time.UTC)
|
||||
expires := time.Date(2026, 3, 16, 18, 30, 0, 0, time.UTC)
|
||||
run := model.WeatherAlertRun{
|
||||
AsOf: time.Date(2026, 3, 16, 18, 0, 0, 0, time.UTC),
|
||||
Alerts: []model.WeatherAlert{
|
||||
@@ -110,6 +112,8 @@ func TestMapPostgresEventAlertStructPayload(t *testing.T) {
|
||||
ID: "urn:alert:1",
|
||||
Headline: "Winter Weather Advisory",
|
||||
Severity: "Moderate",
|
||||
Ends: &ends,
|
||||
Expires: &expires,
|
||||
References: []model.AlertReference{
|
||||
{ID: "urn:ref:1", Sent: &sent},
|
||||
{Identifier: "ref-two"},
|
||||
@@ -145,6 +149,20 @@ func TestMapPostgresEventAlertStructPayload(t *testing.T) {
|
||||
if got := firstAlert.Values["reference_count"]; got != 2 {
|
||||
t.Fatalf("alerts reference_count = %#v, want 2", got)
|
||||
}
|
||||
if got := firstAlert.Values["ends"]; got != ends {
|
||||
t.Fatalf("alerts ends = %#v, want %#v", got, ends)
|
||||
}
|
||||
if got := firstAlert.Values["expires"]; got != expires {
|
||||
t.Fatalf("alerts expires = %#v, want %#v", got, expires)
|
||||
}
|
||||
|
||||
alertWrites := writesForTable(writes, tableAlerts)
|
||||
if len(alertWrites) != 2 {
|
||||
t.Fatalf("alert writes len = %d, want 2", len(alertWrites))
|
||||
}
|
||||
if got := alertWrites[1].Values["ends"]; got != nil {
|
||||
t.Fatalf("second alert ends = %#v, want nil", got)
|
||||
}
|
||||
|
||||
assertAllWritesIncludeAllColumns(t, writes)
|
||||
}
|
||||
@@ -243,6 +261,7 @@ func TestMapPostgresEventOutlookStructPayload(t *testing.T) {
|
||||
lat := 38.6239
|
||||
lon := -90.3571
|
||||
issuedAt := time.Date(2026, 6, 11, 19, 45, 0, 0, time.FixedZone("UTC-5", -5*60*60))
|
||||
updatedAt := time.Date(2026, 6, 11, 21, 15, 0, 0, time.FixedZone("UTC-5", -5*60*60))
|
||||
severity := 3
|
||||
run := model.WeatherOutlookRun{
|
||||
LocationID: "stl",
|
||||
@@ -266,9 +285,6 @@ func TestMapPostgresEventOutlookStructPayload(t *testing.T) {
|
||||
IssuedAt: issuedAt,
|
||||
ExpiresAt: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
||||
Forecaster: "SMITH",
|
||||
Headline: "Day 1 Convective Outlook",
|
||||
Summary: "Severe thunderstorms are possible.",
|
||||
Discussion: "Full discussion text.",
|
||||
SourceURL: "https://example.invalid/day1.geojson",
|
||||
ContainsLocation: true,
|
||||
Geometry: json.RawMessage(`{ "type" : "Polygon", "coordinates" : [ [ [ -91.0, 38.0 ], [ -90.0, 38.0 ], [ -90.0, 39.0 ], [ -91.0, 39.0 ], [ -91.0, 38.0 ] ] ] }`),
|
||||
@@ -284,18 +300,27 @@ func TestMapPostgresEventOutlookStructPayload(t *testing.T) {
|
||||
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),
|
||||
ContainsLocation: false,
|
||||
ContainsLocation: true,
|
||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-100,35],[-98,35],[-98,37],[-100,37],[-100,35]]]}`),
|
||||
},
|
||||
},
|
||||
Discussions: []model.WeatherOutlookDiscussion{
|
||||
{
|
||||
Day: 1,
|
||||
Headline: "Day 1 Convective Outlook",
|
||||
Summary: "Severe thunderstorms are possible.",
|
||||
Discussion: "Full discussion text.",
|
||||
UpdatedAt: &updatedAt,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV1, standards.KindOutlook, run))
|
||||
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, run))
|
||||
if err != nil {
|
||||
t.Fatalf("mapPostgresEvent() error = %v", err)
|
||||
}
|
||||
if len(writes) != 3 {
|
||||
t.Fatalf("mapPostgresEvent() writes len = %d, want 3", len(writes))
|
||||
if len(writes) != 4 {
|
||||
t.Fatalf("mapPostgresEvent() writes len = %d, want 4", len(writes))
|
||||
}
|
||||
if writes[0].Table != tableOutlookRuns {
|
||||
t.Fatalf("writes[0].Table = %q, want %q", writes[0].Table, tableOutlookRuns)
|
||||
@@ -303,6 +328,9 @@ func TestMapPostgresEventOutlookStructPayload(t *testing.T) {
|
||||
if got := writes[0].Values["outlook_count"]; got != 2 {
|
||||
t.Fatalf("outlook_runs outlook_count = %#v, want 2", got)
|
||||
}
|
||||
if got := writes[0].Values["discussion_count"]; got != 1 {
|
||||
t.Fatalf("outlook_runs discussion_count = %#v, want 1", got)
|
||||
}
|
||||
if got := writes[0].Values["issued_at"]; got != issuedAt.UTC() {
|
||||
t.Fatalf("outlook_runs issued_at = %#v, want UTC %s", got, issuedAt.UTC())
|
||||
}
|
||||
@@ -324,15 +352,64 @@ func TestMapPostgresEventOutlookStructPayload(t *testing.T) {
|
||||
if got := writes[1].Values["geometry_json"]; got != `{"type":"Polygon","coordinates":[[[-91.0,38.0],[-90.0,38.0],[-90.0,39.0],[-91.0,39.0],[-91.0,38.0]]]}` {
|
||||
t.Fatalf("first geometry_json = %#v", got)
|
||||
}
|
||||
if got := writes[2].Values["contains_location"]; got != false {
|
||||
t.Fatalf("second contains_location = %#v, want false", got)
|
||||
if got := writes[2].Values["contains_location"]; got != true {
|
||||
t.Fatalf("second contains_location = %#v, want true", got)
|
||||
}
|
||||
if writes[3].Table != tableOutlookDiscussions {
|
||||
t.Fatalf("writes[3].Table = %q, want %q", writes[3].Table, tableOutlookDiscussions)
|
||||
}
|
||||
if got := writes[3].Values["discussion_index"]; got != 0 {
|
||||
t.Fatalf("discussion_index = %#v, want 0", got)
|
||||
}
|
||||
if got := writes[3].Values["as_of"]; got != run.AsOf.UTC() {
|
||||
t.Fatalf("discussion as_of = %#v, want %s", got, run.AsOf.UTC())
|
||||
}
|
||||
if got := writes[3].Values["day"]; got != 1 {
|
||||
t.Fatalf("discussion day = %#v, want 1", got)
|
||||
}
|
||||
if got := writes[3].Values["headline"]; got != "Day 1 Convective Outlook" {
|
||||
t.Fatalf("discussion headline = %#v", got)
|
||||
}
|
||||
if got := writes[3].Values["summary"]; got != "Severe thunderstorms are possible." {
|
||||
t.Fatalf("discussion summary = %#v", got)
|
||||
}
|
||||
if got := writes[3].Values["discussion"]; got != "Full discussion text." {
|
||||
t.Fatalf("discussion text = %#v", got)
|
||||
}
|
||||
if got := writes[3].Values["updated_at"]; got != updatedAt.UTC() {
|
||||
t.Fatalf("discussion updated_at = %#v, want UTC %s", got, updatedAt.UTC())
|
||||
}
|
||||
|
||||
assertAllWritesIncludeAllColumns(t, writes)
|
||||
}
|
||||
|
||||
func TestMapPostgresEventOutlookEmptyLocalRun(t *testing.T) {
|
||||
run := model.WeatherOutlookRun{
|
||||
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, run))
|
||||
if err != nil {
|
||||
t.Fatalf("mapPostgresEvent() error = %v", err)
|
||||
}
|
||||
if len(writes) != 1 {
|
||||
t.Fatalf("mapPostgresEvent() writes len = %d, want 1", len(writes))
|
||||
}
|
||||
if writes[0].Table != tableOutlookRuns {
|
||||
t.Fatalf("writes[0].Table = %q, want %q", writes[0].Table, tableOutlookRuns)
|
||||
}
|
||||
if got := writes[0].Values["outlook_count"]; got != 0 {
|
||||
t.Fatalf("outlook_runs outlook_count = %#v, want 0", got)
|
||||
}
|
||||
if got := writes[0].Values["discussion_count"]; got != 0 {
|
||||
t.Fatalf("outlook_runs discussion_count = %#v, want 0", got)
|
||||
}
|
||||
|
||||
assertAllWritesIncludeAllColumns(t, writes)
|
||||
}
|
||||
|
||||
func TestMapPostgresEventOutlookRejectsMissingAsOf(t *testing.T) {
|
||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV1, standards.KindOutlook, model.WeatherOutlookRun{}))
|
||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, model.WeatherOutlookRun{}))
|
||||
if err == nil {
|
||||
t.Fatalf("mapPostgresEvent() error = nil, want missing asOf error")
|
||||
}
|
||||
@@ -343,17 +420,18 @@ 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]]]}`),
|
||||
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),
|
||||
ContainsLocation: true,
|
||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,39],[-91,38]]]}`),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -381,7 +459,7 @@ func TestMapPostgresEventOutlookRejectsMissingIDAndProvider(t *testing.T) {
|
||||
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
||||
Outlooks: []model.WeatherOutlook{outlook},
|
||||
}
|
||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV1, standards.KindOutlook, run))
|
||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, run))
|
||||
if err == nil {
|
||||
t.Fatalf("mapPostgresEvent() error = nil, want %q", tt.wantErr)
|
||||
}
|
||||
@@ -396,16 +474,17 @@ func TestMapPostgresEventOutlookRejectsMissingRequiredTimes(t *testing.T) {
|
||||
run := model.WeatherOutlookRun{
|
||||
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
||||
Outlooks: []model.WeatherOutlook{{
|
||||
ID: "outlook-1",
|
||||
Provider: "spc",
|
||||
Product: "convective",
|
||||
Day: 1,
|
||||
OutlookType: "categorical",
|
||||
Label: "SLGT",
|
||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,39],[-91,38]]]}`),
|
||||
ID: "outlook-1",
|
||||
Provider: "spc",
|
||||
Product: "convective",
|
||||
Day: 1,
|
||||
OutlookType: "categorical",
|
||||
Label: "SLGT",
|
||||
ContainsLocation: true,
|
||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,39],[-91,38]]]}`),
|
||||
}},
|
||||
}
|
||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV1, standards.KindOutlook, run))
|
||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, run))
|
||||
if err == nil {
|
||||
t.Fatalf("mapPostgresEvent() error = nil, want missing time error")
|
||||
}
|
||||
@@ -418,19 +497,20 @@ func TestMapPostgresEventOutlookRejectsEmptyGeometry(t *testing.T) {
|
||||
run := model.WeatherOutlookRun{
|
||||
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
||||
Outlooks: []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),
|
||||
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),
|
||||
ContainsLocation: true,
|
||||
}},
|
||||
}
|
||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV1, standards.KindOutlook, run))
|
||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, run))
|
||||
if err == nil {
|
||||
t.Fatalf("mapPostgresEvent() error = nil, want geometry error")
|
||||
}
|
||||
@@ -439,6 +519,67 @@ func TestMapPostgresEventOutlookRejectsEmptyGeometry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapPostgresEventOutlookRejectsDuplicateDiscussionDay(t *testing.T) {
|
||||
run := model.WeatherOutlookRun{
|
||||
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
||||
Discussions: []model.WeatherOutlookDiscussion{
|
||||
{Day: 1, Discussion: "First day one discussion."},
|
||||
{Day: 1, Discussion: "Duplicate day one discussion."},
|
||||
},
|
||||
}
|
||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, run))
|
||||
if err == nil {
|
||||
t.Fatalf("mapPostgresEvent() error = nil, want duplicate discussion day error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "discussions[1].day duplicates discussions[0].day 1") {
|
||||
t.Fatalf("error = %q, want duplicate discussion day context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapPostgresEventOutlookRejectsInvalidDiscussionDay(t *testing.T) {
|
||||
run := model.WeatherOutlookRun{
|
||||
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
||||
Discussions: []model.WeatherOutlookDiscussion{{Day: 4, Discussion: "Invalid day."}},
|
||||
}
|
||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, run))
|
||||
if err == nil {
|
||||
t.Fatalf("mapPostgresEvent() error = nil, want invalid discussion day error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "discussions[0].day must be 1, 2, or 3") {
|
||||
t.Fatalf("error = %q, want invalid discussion day context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapPostgresEventOutlookRejectsEmptyDiscussionContent(t *testing.T) {
|
||||
run := model.WeatherOutlookRun{
|
||||
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
||||
Discussions: []model.WeatherOutlookDiscussion{{Day: 1}},
|
||||
}
|
||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, run))
|
||||
if err == nil {
|
||||
t.Fatalf("mapPostgresEvent() error = nil, want empty discussion content error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "discussions[0] headline, summary, or discussion is required") {
|
||||
t.Fatalf("error = %q, want empty discussion content context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapPostgresEventOutlookRejectsContainsLocationFalse(t *testing.T) {
|
||||
run := model.WeatherOutlookRun{
|
||||
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
||||
Outlooks: []model.WeatherOutlook{validTestOutlook()},
|
||||
}
|
||||
run.Outlooks[0].ContainsLocation = false
|
||||
|
||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, run))
|
||||
if err == nil {
|
||||
t.Fatalf("mapPostgresEvent() error = nil, want containsLocation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "outlooks[0].containsLocation must be true") {
|
||||
t.Fatalf("error = %q, want containsLocation context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapPostgresEventWeatherStoryRejectsMissingAsOf(t *testing.T) {
|
||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherStoryV1, standards.KindWeatherStory, model.WeatherStoryRun{}))
|
||||
if err == nil {
|
||||
@@ -508,6 +649,17 @@ func TestMapPostgresEventUnknownSchemaNoOp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapPostgresEventLegacyOutlookSchemaNoOp(t *testing.T) {
|
||||
run := model.WeatherOutlookRun{AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC)}
|
||||
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV1, standards.KindOutlook, run))
|
||||
if err != nil {
|
||||
t.Fatalf("mapPostgresEvent() error = %v", err)
|
||||
}
|
||||
if len(writes) != 0 {
|
||||
t.Fatalf("mapPostgresEvent() writes len = %d, want 0", len(writes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapPostgresEventMalformedPayload(t *testing.T) {
|
||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherForecastV1, standards.KindForecast, "bad"))
|
||||
if err == nil {
|
||||
@@ -624,6 +776,16 @@ func firstWriteForTable(writes []fksinks.PostgresWrite, table string) (fksinks.P
|
||||
return fksinks.PostgresWrite{}, false
|
||||
}
|
||||
|
||||
func writesForTable(writes []fksinks.PostgresWrite, table string) []fksinks.PostgresWrite {
|
||||
out := make([]fksinks.PostgresWrite, 0)
|
||||
for _, w := range writes {
|
||||
if w.Table == table {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertAllWritesIncludeAllColumns(t *testing.T, writes []fksinks.PostgresWrite) {
|
||||
t.Helper()
|
||||
colCounts := tableColumnCounts()
|
||||
@@ -647,6 +809,23 @@ func tableColumnCounts() map[string]int {
|
||||
return m
|
||||
}
|
||||
|
||||
func validTestOutlook() model.WeatherOutlook {
|
||||
return 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),
|
||||
ContainsLocation: true,
|
||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,39],[-91,38]]]}`),
|
||||
}
|
||||
}
|
||||
|
||||
func wmoCodePtr(v model.WMOCode) *model.WMOCode {
|
||||
out := v
|
||||
return &out
|
||||
|
||||
@@ -18,6 +18,7 @@ const (
|
||||
tableAlertReferences = "alert_references"
|
||||
tableOutlookRuns = "outlook_runs"
|
||||
tableOutlooks = "outlooks"
|
||||
tableOutlookDiscussions = "outlook_discussions"
|
||||
)
|
||||
|
||||
// PostgresSchema returns weatherfeeder's Postgres schema definition.
|
||||
@@ -237,6 +238,7 @@ func PostgresSchema() fksinks.PostgresSchema {
|
||||
{Name: "sent", Type: "TIMESTAMPTZ", Nullable: true},
|
||||
{Name: "effective", Type: "TIMESTAMPTZ", Nullable: true},
|
||||
{Name: "onset", Type: "TIMESTAMPTZ", Nullable: true},
|
||||
{Name: "ends", Type: "TIMESTAMPTZ", Nullable: true},
|
||||
{Name: "expires", Type: "TIMESTAMPTZ", Nullable: true},
|
||||
{Name: "area_description", Type: "TEXT", Nullable: true},
|
||||
{Name: "sender_name", Type: "TEXT", Nullable: true},
|
||||
@@ -279,6 +281,7 @@ func PostgresSchema() fksinks.PostgresSchema {
|
||||
{Name: "as_of", Type: "TIMESTAMPTZ", Nullable: false},
|
||||
{Name: "issued_at", Type: "TIMESTAMPTZ", Nullable: true},
|
||||
{Name: "outlook_count", Type: "INTEGER", Nullable: false},
|
||||
{Name: "discussion_count", Type: "INTEGER", Nullable: false},
|
||||
}...),
|
||||
PrimaryKey: []string{"event_id"},
|
||||
PruneColumn: "as_of",
|
||||
@@ -306,9 +309,6 @@ func PostgresSchema() fksinks.PostgresSchema {
|
||||
{Name: "issued_at", Type: "TIMESTAMPTZ", Nullable: false},
|
||||
{Name: "expires_at", Type: "TIMESTAMPTZ", Nullable: false},
|
||||
{Name: "forecaster", Type: "TEXT", Nullable: true},
|
||||
{Name: "headline", Type: "TEXT", Nullable: true},
|
||||
{Name: "summary", Type: "TEXT", Nullable: true},
|
||||
{Name: "discussion", Type: "TEXT", Nullable: true},
|
||||
{Name: "source_url", Type: "TEXT", Nullable: true},
|
||||
{Name: "image_url", Type: "TEXT", Nullable: true},
|
||||
{Name: "contains_location", Type: "BOOLEAN", Nullable: false},
|
||||
@@ -322,6 +322,25 @@ func PostgresSchema() fksinks.PostgresSchema {
|
||||
{Name: "idx_wf_outlooks_valid", Columns: []string{"valid_from", "valid_to"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: tableOutlookDiscussions,
|
||||
Columns: []fksinks.PostgresColumn{
|
||||
{Name: "run_event_id", Type: "TEXT REFERENCES outlook_runs(event_id) ON DELETE CASCADE", Nullable: false},
|
||||
{Name: "discussion_index", Type: "INTEGER", Nullable: false},
|
||||
{Name: "as_of", Type: "TIMESTAMPTZ", Nullable: false},
|
||||
{Name: "day", Type: "INTEGER", Nullable: false},
|
||||
{Name: "headline", Type: "TEXT", Nullable: true},
|
||||
{Name: "summary", Type: "TEXT", Nullable: true},
|
||||
{Name: "discussion", Type: "TEXT", Nullable: true},
|
||||
{Name: "updated_at", Type: "TIMESTAMPTZ", Nullable: true},
|
||||
},
|
||||
PrimaryKey: []string{"run_event_id", "discussion_index"},
|
||||
PruneColumn: "as_of",
|
||||
Indexes: []fksinks.PostgresIndex{
|
||||
{Name: "idx_wf_outlook_discussions_day_as_of", Columns: []string{"day", "as_of"}},
|
||||
{Name: "idx_wf_outlook_discussions_run_day", Columns: []string{"run_event_id", "day"}, Unique: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
MapEvent: mapPostgresEvent,
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ func TestWeatherPostgresSchemaShape(t *testing.T) {
|
||||
tableAlertReferences: true,
|
||||
tableOutlookRuns: true,
|
||||
tableOutlooks: true,
|
||||
tableOutlookDiscussions: true,
|
||||
}
|
||||
|
||||
if len(s.Tables) != len(wantTables) {
|
||||
@@ -53,7 +54,7 @@ func TestWeatherPostgresSchemaShape(t *testing.T) {
|
||||
|
||||
func TestWeatherPostgresSchemaIncludesOutlookTables(t *testing.T) {
|
||||
runColumns := columnsForTable(t, tableOutlookRuns)
|
||||
for _, col := range []string{"event_id", "event_kind", "event_source", "event_schema", "event_emitted_at", "event_effective_at", "location_id", "location_name", "latitude", "longitude", "as_of", "issued_at", "outlook_count"} {
|
||||
for _, col := range []string{"event_id", "event_kind", "event_source", "event_schema", "event_emitted_at", "event_effective_at", "location_id", "location_name", "latitude", "longitude", "as_of", "issued_at", "outlook_count", "discussion_count"} {
|
||||
if !runColumns[col] {
|
||||
t.Fatalf("%s missing %s column", tableOutlookRuns, col)
|
||||
}
|
||||
@@ -63,15 +64,40 @@ 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", "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"} {
|
||||
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", "source_url", "image_url", "contains_location", "geometry_json"} {
|
||||
if !outlookColumns[col] {
|
||||
t.Fatalf("%s missing %s column", tableOutlooks, col)
|
||||
}
|
||||
}
|
||||
for _, col := range []string{"headline", "summary", "discussion"} {
|
||||
if outlookColumns[col] {
|
||||
t.Fatalf("%s still includes legacy %s column", tableOutlooks, col)
|
||||
}
|
||||
}
|
||||
assertTablePrimaryKey(t, tableOutlooks, []string{"run_event_id", "outlook_index"})
|
||||
assertTableIndex(t, tableOutlooks, "idx_wf_outlooks_contains_valid", []string{"contains_location", "valid_from", "valid_to"})
|
||||
assertTableIndex(t, tableOutlooks, "idx_wf_outlooks_day_type_label", []string{"day", "outlook_type", "label"})
|
||||
assertTableIndex(t, tableOutlooks, "idx_wf_outlooks_valid", []string{"valid_from", "valid_to"})
|
||||
|
||||
discussionColumns := columnsForTable(t, tableOutlookDiscussions)
|
||||
for _, col := range []string{"run_event_id", "discussion_index", "as_of", "day", "headline", "summary", "discussion", "updated_at"} {
|
||||
if !discussionColumns[col] {
|
||||
t.Fatalf("%s missing %s column", tableOutlookDiscussions, col)
|
||||
}
|
||||
}
|
||||
assertTablePrimaryKey(t, tableOutlookDiscussions, []string{"run_event_id", "discussion_index"})
|
||||
assertTablePruneColumn(t, tableOutlookDiscussions, "as_of")
|
||||
assertTableIndex(t, tableOutlookDiscussions, "idx_wf_outlook_discussions_day_as_of", []string{"day", "as_of"})
|
||||
assertTableUniqueIndex(t, tableOutlookDiscussions, "idx_wf_outlook_discussions_run_day", []string{"run_event_id", "day"})
|
||||
}
|
||||
|
||||
func TestWeatherPostgresSchemaIncludesAlertEndsColumn(t *testing.T) {
|
||||
alertColumns := columnsForTable(t, tableAlerts)
|
||||
for _, col := range []string{"run_event_id", "alert_index", "as_of", "alert_id", "onset", "ends", "expires"} {
|
||||
if !alertColumns[col] {
|
||||
t.Fatalf("%s missing %s column", tableAlerts, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeatherPostgresSchemaIncludesWeatherStoryColumns(t *testing.T) {
|
||||
@@ -115,35 +141,56 @@ func TestWeatherPostgresSchemaParentTablesStartWithEnvelopeColumns(t *testing.T)
|
||||
|
||||
func assertTablePrimaryKey(t *testing.T, table string, want []string) {
|
||||
t.Helper()
|
||||
for _, tbl := range PostgresSchema().Tables {
|
||||
if tbl.Name != table {
|
||||
continue
|
||||
}
|
||||
if strings.Join(tbl.PrimaryKey, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("%s primary key = %#v, want %#v", table, tbl.PrimaryKey, want)
|
||||
}
|
||||
return
|
||||
tbl := tableByName(t, table)
|
||||
if strings.Join(tbl.PrimaryKey, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("%s primary key = %#v, want %#v", table, tbl.PrimaryKey, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertTablePruneColumn(t *testing.T, table string, want string) {
|
||||
t.Helper()
|
||||
tbl := tableByName(t, table)
|
||||
if tbl.PruneColumn != want {
|
||||
t.Fatalf("%s prune column = %q, want %q", table, tbl.PruneColumn, want)
|
||||
}
|
||||
t.Fatalf("missing table %q", table)
|
||||
}
|
||||
|
||||
func assertTableIndex(t *testing.T, table string, name string, want []string) {
|
||||
t.Helper()
|
||||
for _, tbl := range PostgresSchema().Tables {
|
||||
if tbl.Name != table {
|
||||
continue
|
||||
}
|
||||
for _, idx := range tbl.Indexes {
|
||||
if idx.Name == name {
|
||||
if strings.Join(idx.Columns, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("%s index %s columns = %#v, want %#v", table, name, idx.Columns, want)
|
||||
}
|
||||
return
|
||||
assertTableIndexWithUnique(t, table, name, want, false)
|
||||
}
|
||||
|
||||
func assertTableUniqueIndex(t *testing.T, table string, name string, want []string) {
|
||||
t.Helper()
|
||||
assertTableIndexWithUnique(t, table, name, want, true)
|
||||
}
|
||||
|
||||
func assertTableIndexWithUnique(t *testing.T, table string, name string, want []string, unique bool) {
|
||||
t.Helper()
|
||||
tbl := tableByName(t, table)
|
||||
for _, idx := range tbl.Indexes {
|
||||
if idx.Name == name {
|
||||
if strings.Join(idx.Columns, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("%s index %s columns = %#v, want %#v", table, name, idx.Columns, want)
|
||||
}
|
||||
if idx.Unique != unique {
|
||||
t.Fatalf("%s index %s unique = %v, want %v", table, name, idx.Unique, unique)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s missing index %s", table, name)
|
||||
}
|
||||
|
||||
func tableByName(t *testing.T, table string) fksinks.PostgresTable {
|
||||
t.Helper()
|
||||
for _, tbl := range PostgresSchema().Tables {
|
||||
if tbl.Name == table {
|
||||
return tbl
|
||||
}
|
||||
t.Fatalf("%s missing index %s", table, name)
|
||||
}
|
||||
t.Fatalf("missing table %q", table)
|
||||
return fksinks.PostgresTable{}
|
||||
}
|
||||
|
||||
func orderedColumnsForTable(t *testing.T, table string) []fksinks.PostgresColumn {
|
||||
|
||||
@@ -55,9 +55,11 @@ type WeatherAlert struct {
|
||||
Instruction string `json:"instruction,omitempty"`
|
||||
|
||||
// Timing (all optional; provider-dependent).
|
||||
// Onset and Ends describe the alert period. Expires is provider expiration metadata.
|
||||
Sent *time.Time `json:"sent,omitempty"`
|
||||
Effective *time.Time `json:"effective,omitempty"`
|
||||
Onset *time.Time `json:"onset,omitempty"`
|
||||
Ends *time.Time `json:"ends,omitempty"`
|
||||
Expires *time.Time `json:"expires,omitempty"`
|
||||
|
||||
// Scope / affected area.
|
||||
|
||||
@@ -26,6 +26,7 @@ func TestDocumentedConsumerModelTypes(t *testing.T) {
|
||||
"WeatherAlert",
|
||||
"WeatherAlertReference",
|
||||
"WeatherOutlookRun",
|
||||
"WeatherOutlookDiscussion",
|
||||
"WeatherOutlook",
|
||||
"WMOCode",
|
||||
}
|
||||
|
||||
@@ -8,13 +8,23 @@ import (
|
||||
// WeatherOutlookRun is a snapshot of convective outlook polygons for a
|
||||
// configured location as-of a provider issue time.
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// WeatherOutlookDiscussion is run-level SPC outlook prose for one outlook day.
|
||||
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"`
|
||||
}
|
||||
|
||||
// WeatherOutlook is a canonical representation of one outlook polygon.
|
||||
@@ -32,9 +42,6 @@ type WeatherOutlook struct {
|
||||
IssuedAt time.Time `json:"issuedAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Forecaster string `json:"forecaster,omitempty"`
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Discussion string `json:"discussion,omitempty"`
|
||||
SourceURL string `json:"sourceUrl,omitempty"`
|
||||
ImageURL string `json:"imageUrl,omitempty"`
|
||||
ContainsLocation bool `json:"containsLocation"`
|
||||
|
||||
60
model/outlook_test.go
Normal file
60
model/outlook_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWeatherOutlookJSONShape(t *testing.T) {
|
||||
updatedAt := time.Date(2026, 6, 11, 16, 30, 0, 0, time.UTC)
|
||||
run := WeatherOutlookRun{
|
||||
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
||||
Outlooks: []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, 12, 34, 56, 0, time.UTC),
|
||||
ExpiresAt: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
||||
ContainsLocation: true,
|
||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,39],[-91,38]]]}`),
|
||||
}},
|
||||
Discussions: []WeatherOutlookDiscussion{{
|
||||
Day: 1,
|
||||
Headline: "Day 1 Convective Outlook",
|
||||
Summary: "Severe thunderstorms are possible.",
|
||||
Discussion: "Full discussion text.",
|
||||
UpdatedAt: &updatedAt,
|
||||
}},
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(run)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal(WeatherOutlookRun) error = %v", err)
|
||||
}
|
||||
got := string(raw)
|
||||
|
||||
for _, want := range []string{`"outlooks"`, `"discussions"`, `"headline"`, `"summary"`, `"discussion"`, `"updatedAt"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("WeatherOutlookRun JSON missing %s: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
outlookStart := strings.Index(got, `"outlooks"`)
|
||||
discussionStart := strings.Index(got, `"discussions"`)
|
||||
if outlookStart == -1 || discussionStart == -1 || discussionStart <= outlookStart {
|
||||
t.Fatalf("WeatherOutlookRun JSON has unexpected outlook/discussion order: %s", got)
|
||||
}
|
||||
outlookJSON := got[outlookStart:discussionStart]
|
||||
for _, unwanted := range []string{`"headline"`, `"summary"`, `"discussion"`} {
|
||||
if strings.Contains(outlookJSON, unwanted) {
|
||||
t.Fatalf("WeatherOutlook JSON contains polygon-level prose key %s: %s", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,5 +97,10 @@ func stringConstantsFromFile(t *testing.T, path string, prefix string, skip func
|
||||
}
|
||||
|
||||
func schemaConstantNotInCurrentContract(name string) bool {
|
||||
return name == "SchemaRawOpenWeatherHourlyForecastV1"
|
||||
switch name {
|
||||
case "SchemaRawOpenWeatherHourlyForecastV1":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,4 +33,5 @@ const (
|
||||
SchemaWeatherStoryV1 = "weather.weather_story.v1"
|
||||
SchemaWeatherAlertV1 = "weather.alert.v1"
|
||||
SchemaWeatherOutlookV1 = "weather.outlook.v1"
|
||||
SchemaWeatherOutlookV2 = "weather.outlook.v2"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user