Document outlook v2 behavior

This commit is contained in:
2026-06-12 13:07:17 +00:00
parent beeaeeaecb
commit c7760742e6
9 changed files with 131 additions and 360 deletions

View File

@@ -230,19 +230,22 @@ GET /outlooks/convective/location
```
Returns the latest SPC convective outlook run reconstructed from
weatherfeeder-owned Postgres tables.
weatherfeeder-owned `weather.outlook.v2` Postgres tables.
Route behavior:
- `/outlooks/convective` returns the latest run with all stored outlook
polygons unless user filters are supplied.
- `/outlooks/convective` returns the latest run with stored location-filtered
outlook polygons unless user filters are supplied.
- `/outlooks/convective/active` adds an active-time filter using the server's
current UTC time. Outlooks are active when `validFrom <= now < validTo`.
- `/outlooks/convective/location` adds the same active-time filter and
`containsLocation=true`.
`containsLocation=true`. It remains as an active local-outlook compatibility
route under the v2 weatherfeeder contract.
When no latest run exists, `data` is null. When a run exists but filters match
no outlooks, `data` remains an object and `outlooks` is an empty array.
no outlooks, `data` remains an object and `outlooks` and `discussions` are
empty arrays. Outlook endpoints use latest-run semantics and do not accumulate
historical active outlooks across older runs.
Query parameters:
@@ -253,9 +256,12 @@ Query parameters:
| `outlookType` | all outlook routes | `categorical`, `tornado`, `hail`, or `wind` |
| `containsLocation` | `/outlooks/convective`, `/outlooks/convective/active` | boolean |
`outlookType` values are normalized case-insensitively. `containsLocation` is
rejected on `/outlooks/convective/location` because that route always applies
`containsLocation=true`. `precision` and unknown parameters are rejected.
`outlookType` values are normalized case-insensitively. Weatherfeeder v2
outlooks are already filtered for the configured location, so
`containsLocation` is expected to be true for stored v2 outlooks.
`containsLocation` is rejected on `/outlooks/convective/location` because that
route always applies `containsLocation=true`. `precision` and unknown
parameters are rejected.
Run `data` fields:
@@ -266,6 +272,7 @@ Run `data` fields:
| `asOf` | RFC3339 datetime | required when `data` is not null |
| `issuedAt` | RFC3339 datetime | optional |
| `outlooks` | array | ordered outlook polygons, possibly empty |
| `discussions` | array | ordered day-level discussions, possibly empty |
Outlook fields:
@@ -280,10 +287,20 @@ Outlook fields:
| `containsLocation` | boolean | whether the outlook polygon contains the configured location |
| `geometry` | GeoJSON | stored outlook geometry |
Discussion fields:
| Field | Type | Notes |
| --- | --- | --- |
| `day` | integer | SPC outlook day |
| `headline`, `summary`, `discussion` | string | optional |
| `updatedAt` | RFC3339 datetime | optional |
GeoJSON coordinates use standard GeoJSON coordinate order: longitude, then
latitude. Timezone conversion applies to run `asOf`, run `issuedAt`, and each
outlook's `validFrom`, `validTo`, `issuedAt`, and `expiresAt`. Active filtering
compares instants and is not changed by the presentation timezone.
outlook's `validFrom`, `validTo`, `issuedAt`, and `expiresAt`, and discussion
`updatedAt`. Active filtering compares instants and is not changed by the
presentation timezone. Endpoint filters also filter `discussions` to days
represented by retained outlooks.
Examples:
@@ -328,6 +345,15 @@ Example JSON response:
]
}
}
],
"discussions": [
{
"day": 1,
"headline": "Severe storms possible",
"summary": "Scattered severe storms are possible.",
"discussion": "SPC discussion text.",
"updatedAt": "2026-06-11T17:30:00Z"
}
]
}
}
@@ -354,10 +380,25 @@ Example location-filtered JSON response:
"expiresAt": "2026-06-12T12:00:00Z",
"containsLocation": true,
"geometry": {
"type": "Point",
"coordinates": [-90.2, 38.6]
"type": "Polygon",
"coordinates": [
[
[-90.6, 38.4],
[-90.0, 38.4],
[-90.0, 38.8],
[-90.6, 38.4]
]
]
}
}
],
"discussions": [
{
"day": 1,
"headline": "Tornado risk near the configured location",
"discussion": "SPC tornado outlook discussion text.",
"updatedAt": "2026-06-11T17:30:00Z"
}
]
}
}

View File

@@ -8,10 +8,12 @@
`go.mod` depends on:
- `gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0`
- `gitea.maximumdirect.net/ejr/weatherfeeder v0.11.1-0.20260612044033-4358a7cdce2c`
The repository code also depends on weatherfeeder canonical model types. Table
compatibility must match the SQL in `internal/adapters/outbound/postgres`.
For convective outlooks, `weatherapi` assumes weatherfeeder's
`weather.outlook.v2` table reset has already been applied.
## Boundary
@@ -37,7 +39,7 @@ Postgres owns persistence, backup, restore, and availability.
| Forecast discussion | `forecast_discussions`, `forecast_discussion_key_messages` |
| Weather story run | `weather_story_runs`, `weather_stories` |
| Latest weather story | `weather_stories` |
| Convective outlook run | `outlook_runs`, `outlooks` |
| Convective outlook run | `outlook_runs`, `outlooks`, `outlook_discussions` |
## Latest Row Selection
@@ -68,7 +70,8 @@ Child rows are loaded separately and attached in stored order:
- forecast periods: `period_index ASC`;
- forecast discussion key messages: `message_index ASC`;
- weather stories for a run: `story_index ASC`;
- outlooks for a run: `outlook_index ASC`.
- outlooks for a run: `outlook_index ASC`;
- outlook discussions for a run: `discussion_index ASC`.
## Columns Read
@@ -166,6 +169,11 @@ routes.
`geometry_json` is copied into response GeoJSON without parsing or
reserializing. It must contain valid JSON.
### `outlook_discussions`
`discussion_index`, `day`, `headline`, `summary`, `discussion`, `updated_at`,
and `run_event_id`.
## Nullability and Time Assumptions
The repository scans nullable columns with `sql.Null*` types and maps them to

View File

@@ -144,6 +144,11 @@ application service:
The package variable `outlookNow` exists so endpoint tests can make active and
location filtering deterministic.
The application service returns filtered outlook copies and trims run-level
discussions to days represented by retained outlooks.
`/outlooks/convective/location` is retained for compatibility and active
local-outlook behavior under the weatherfeeder outlook v2 contract.
## Failure Behavior
Binder failures become feedapi invalid-parameter responses. Handler service

View File

@@ -90,7 +90,7 @@ successful responses with `data: null`.
`weather_stories`.
- `LatestWeatherStory`: latest individual row from `weather_stories`.
- `LatestConvectiveOutlookRun`: latest row from `outlook_runs`, then child
`outlooks`.
`outlooks` and `outlook_discussions`.
Latest parent rows are selected by descending weather timestamp and
`event_emitted_at` where that tie-breaker is available in the query.
@@ -105,7 +105,8 @@ Child queries preserve stored order:
- forecast periods by `period_index`;
- discussion key messages by `message_index`;
- weather stories by `story_index`;
- outlooks by `outlook_index`.
- outlooks by `outlook_index`;
- outlook discussions by `discussion_index`.
Alert references are attached after both alert and reference rows are loaded.
References are grouped by alert index and attached to their corresponding alert.

View File

@@ -118,9 +118,11 @@ response envelope so renderers can produce `data: null`.
- Discussions: full or focused payload shapes, section copy, key-message copy,
timezone conversion.
- Weather stories: run/story copy and timezone conversion.
- Convective outlooks: canonical model copy, pointer and geometry copy, and
timezone conversion. `units` is accepted by routes but ignored by the
presenter because outlook fields are not unit-bearing.
- Convective outlooks: canonical model copy, pointer and geometry copy,
run-level discussion copy, and timezone conversion for run, outlook, and
discussion timestamps. Outlook polygon prose is not handled by the presenter;
prose is carried by run-level discussions. `units` is accepted by routes but
ignored by the presenter because outlook fields are not unit-bearing.
## Templates

View File

@@ -91,8 +91,13 @@ build.
## Database Dependency
`weatherapi` expects weatherfeeder-compatible tables for observations, current
conditions aggregation, active alerts, forecasts, forecast discussions, and
weather stories. It only reads those tables.
conditions aggregation, active alerts, forecasts, forecast discussions, weather
stories, and convective outlooks. It only reads those tables.
Convective outlook endpoints require weatherfeeder's `weather.outlook.v2` table
shape, including `outlook_runs`, `outlooks`, and `outlook_discussions`. If
operators reset or recreate outlook tables during a weatherfeeder upgrade,
complete that weatherfeeder-side migration before starting `weatherapi`.
Operational ownership is split:
@@ -113,7 +118,7 @@ template named by each endpoint, with templates stored under `templates.base_dir
The repository includes templates for all implemented endpoint families:
observations, current conditions, active alerts, hourly forecasts, narrative
forecasts, forecast discussions, and weather stories.
forecasts, forecast discussions, weather stories, and convective outlooks.
If text rendering fails or returns an unsupported-format error, verify:

View File

@@ -1,5 +1,12 @@
# Implement Weatherfeeder Outlook V2 Support
## Status
Implementation and current-behavior documentation are complete. This file now
serves as the checklist and verification record for the outlook v2 compatibility
update. Authoritative implemented behavior is documented in `docs/api.md`,
`docs/integrations/weatherfeeder-postgres.md`, and `docs/internal/`.
## Summary
Implement `weatherapi` support for the `weatherfeeder` SPC outlook v2 contract described in `docs/roadmap/outlook.md`.

View File

@@ -1,338 +1,36 @@
# Weatherfeeder Outlook V2 Support
## Summary
Update `weatherapi` to read and serve the new `weatherfeeder` SPC outlook contract introduced by `weather.outlook.v2`.
`weatherfeeder` now emits location-filtered convective outlook runs, removes polygon-level prose fields from outlook polygons, and stores day-level outlook discussions in a new `outlook_discussions` table. `weatherapi` must update its dependency, Postgres read adapter, application filtering, presenters, templates, endpoint tests, and documentation to match that contract.
This roadmap preserves the existing public route family unless a later API roadmap explicitly changes it:
- `GET /outlooks/convective`
- `GET /outlooks/convective/active`
- `GET /outlooks/convective/location`
## Target Behavior
- All outlook endpoints read the latest `weather.outlook.v2` run from weatherfeeder-owned Postgres tables.
- The repository loads `outlook_runs`, `outlooks`, and `outlook_discussions`.
- `outlooks[]` contains only location-relevant polygons written by weatherfeeder.
- `containsLocation` remains in responses and should normally be `true` for every returned outlook.
- `discussions[]` contains run-level SPC day discussions for days represented by returned outlooks.
- If no latest run exists, responses continue returning `{ "data": null }`.
- If a latest run exists but filters remove every outlook, responses return the run with `outlooks: []` and `discussions: []`.
- `/outlooks/convective` returns the latest run with optional user filters.
- `/outlooks/convective/active` filters the latest run to outlooks active at request time.
- `/outlooks/convective/location` remains available for API compatibility and applies the active filter; because v2 storage is already location-filtered, it is effectively the current active local-outlook endpoint.
- Public response timestamps continue honoring `tz` / `TZ` presentation conversion.
- `units=metric|us` remains accepted for consistency and has no payload effect.
- `precision` and unknown query parameters remain rejected.
## Upstream Dependency
Update `go.mod` to the first released `gitea.maximumdirect.net/ejr/weatherfeeder` version that contains:
- `standards.SchemaWeatherOutlookV2`;
- `model.WeatherOutlookRun.Discussions`;
- `model.WeatherOutlookDiscussion`;
- `model.WeatherOutlook` without polygon-level `Headline`, `Summary`, or `Discussion` fields.
Do not commit a local `replace` directive for weatherfeeder. If implementation begins before the upstream release is tagged, stop and tag/release weatherfeeder first or use a temporary local replace only outside the final committed diff.
## Stage 1: Module And Compile Contract
### Changes
- Bump the `weatherfeeder` dependency in `go.mod` to the released version containing outlook v2.
- Run `go mod tidy`.
- Fix compile errors caused by removed `model.WeatherOutlook.Headline`, `Summary`, and `Discussion` fields.
- Update any references to `standards.SchemaWeatherOutlookV1` in outlook-specific code to use `SchemaWeatherOutlookV2` only where schema constants are needed.
### Expected Compile Hotspots
- `internal/adapters/outbound/postgres/outlooks_rows.go`
- `internal/adapters/outbound/postgres/outlooks_mapper.go`
- `internal/adapters/outbound/postgres/outlooks_queries.go`
- `internal/adapters/outbound/postgres/outlooks_read.go`
- `internal/adapters/inbound/httpapi/presenter/outlook.go`
- `internal/app/service.go`
- endpoint and presenter tests that construct `model.WeatherOutlook`
- `docs/integrations/weatherfeeder-postgres.md`
- `docs/api.md`
### Verification
```sh
go test ./internal/app ./internal/adapters/outbound/postgres ./internal/adapters/inbound/httpapi/presenter
```
## Stage 2: Postgres Repository V2 Reads
### Queries And Rows
Update the Postgres adapter to match the v2 table shape.
Parent query:
- Continue selecting latest parent row from `outlook_runs` ordered by `as_of DESC, event_emitted_at DESC`.
- Include `discussion_count` only if useful for tests or sanity checks; the read model does not need to expose it.
Outlook child query:
- Remove columns that no longer exist on `outlooks`:
- `headline`
- `summary`
- `discussion`
- Continue selecting child rows ordered by `outlook_index ASC`.
- Continue validating/copying `geometry_json` as JSON.
- Continue normalizing timestamps to UTC.
Discussion child query:
- Add `queryOutlookDiscussionsForRun` selecting from `outlook_discussions`:
- `discussion_index`
- `day`
- `headline`
- `summary`
- `discussion`
- `updated_at`
- Filter by `run_event_id = $1`.
- Order by `discussion_index ASC`.
Row structs:
- Remove `Headline`, `Summary`, and `Discussion` from `outlookRow`.
- Add `outlookDiscussionRow` with nullable string/time fields.
Read flow:
- `LatestConvectiveOutlookRun` should load parent, outlook rows, and discussion rows.
- Attach `run.Outlooks` and `run.Discussions` before returning.
- Missing latest parent still returns `nil, nil`.
- Child query/scan/iteration errors should include contextual wrapping.
### Mapper Behavior
- `mapOutlookRow` should map only polygon fields present in v2.
- Add `mapOutlookDiscussionRow` returning `model.WeatherOutlookDiscussion`.
- Normalize `updated_at` to UTC when present.
- Preserve nil/zero semantics from the canonical model.
- Preserve geometry byte copy behavior.
### Tests
Update Postgres mapper/read tests to cover:
- parent mapping still normalizes `asOf` and optional `issuedAt` to UTC;
- v2 outlook row maps all polygon fields and no polygon-level prose fields;
- nullable optional outlook fields map to omitted/zero canonical values;
- invalid `geometry_json` returns a mapper error;
- discussion row maps `day`, `headline`, `summary`, `discussion`, and `updatedAt`;
- nullable discussion fields map to omitted/zero values;
- latest run loads outlook children by `outlook_index ASC`;
- latest run loads discussion children by `discussion_index ASC`;
- no parent row returns `nil, nil`;
- query errors and scan errors remain context-wrapped.
### Verification
```sh
go test ./internal/adapters/outbound/postgres
```
## Stage 3: Application Filtering Semantics
### Changes
Update `internal/app` outlook filtering so discussions stay coherent after endpoint filters.
Current filtering should continue to clone the repository-returned run before mutation. Extend cloning and filtering to include `Discussions`:
- Deep-copy `WeatherOutlookRun.Discussions`.
- After filtering `Outlooks`, rebuild `Discussions` to include only days still represented by retained outlooks.
- Preserve discussion order from the repository for retained days.
- If retained outlooks are empty, set `Discussions` to an empty non-nil slice when the original slice was non-nil or when the endpoint needs stable JSON empty-array behavior.
Route implications:
- `/outlooks/convective?day=2` should return only Day 2 outlooks and only the Day 2 discussion.
- `/outlooks/convective?outlookType=tornado` should return discussions only for days with retained tornado outlooks.
- `/outlooks/convective/active` should remove discussions for days with no active retained outlooks.
- `/outlooks/convective/location` should remain active plus local semantics. With v2 data, the explicit `containsLocation=true` filter is redundant but harmless.
- `containsLocation=false` on routes that allow the parameter should return an empty outlook/discussion run with v2 data.
### Query Parameter Policy
Preserve current public query behavior unless endpoint tests reveal a direct conflict:
- `day=1|2|3` accepted on all outlook routes.
- `outlookType=categorical|tornado|hail|wind` accepted case-insensitively on all outlook routes.
- `containsLocation=true|false` accepted on `/outlooks/convective` and `/outlooks/convective/active` for backward-compatible filtering.
- `containsLocation` rejected on `/outlooks/convective/location`.
- `format`, `units`, and `tz` / `TZ` remain supported.
- `precision` and unknown query parameters remain rejected.
### Tests
Update app tests to cover:
- repository delegation still happens once;
- filtering by day also filters discussions to that day;
- filtering by outlook type filters discussions to days with retained outlooks;
- active filtering filters discussions to days with active retained outlooks;
- no matching outlooks returns `outlooks: []` and `discussions: []`;
- clone behavior does not mutate repository-owned `Outlooks`, `Discussions`, severity pointers, geometry bytes, or time pointers.
### Verification
```sh
go test ./internal/app
```
## Stage 4: Presenter, Templates, And HTTP Responses
### Presenter Changes
Update `internal/adapters/inbound/httpapi/presenter/outlook.go`:
- Copy `run.Discussions` into the presented payload.
- Convert `WeatherOutlookDiscussion.UpdatedAt` into the requested timezone.
- Continue converting `run.AsOf`, `run.IssuedAt`, and outlook `validFrom`, `validTo`, `issuedAt`, and `expiresAt`.
- Remove references to polygon-level `Headline`, `Summary`, and `Discussion`.
- Preserve geometry copy behavior.
- Return `nil` for nil input.
### Text Template Changes
Update `templates/outlooks_convective.txt.tmpl`:
- Render run-level discussions, grouped/listed by day.
- Do not reference polygon-level `.Headline`, `.Summary`, or `.Discussion`.
- Keep output useful when `outlooks` is empty but `data` is present.
- Keep no-data text for `data: null`.
### HTTP Tests
Update endpoint tests to cover:
- JSON response includes `discussions` at run level.
- JSON response no longer includes polygon-level `headline`, `summary`, or `discussion`.
- XML response renders run-level discussions without errors.
- Text response renders run-level discussion content.
- `tz` / `TZ` converts `asOf`, `issuedAt`, outlook times, and discussion `updatedAt`.
- `/outlooks/convective`, `/active`, and `/location` still register and route.
- `data: null` remains unchanged when no run exists.
- filtered no-match response returns `outlooks: []` and `discussions: []`.
- query validation behavior remains unchanged for supported/rejected params.
### Presenter Tests
Update presenter tests to cover:
- copied run includes copied discussions;
- discussion `updatedAt` timezone conversion;
- input run is not mutated;
- geometry bytes remain copied;
- nil input returns nil.
### Verification
```sh
go test ./internal/adapters/inbound/httpapi ./internal/adapters/inbound/httpapi/presenter
```
## Stage 5: Documentation Updates
After code behavior is updated, update permanent docs in the same change.
### Public API Docs
Update `docs/api.md`:
- State that outlook endpoints serve weatherfeeder `weather.outlook.v2` data.
- Add `discussions` to run fields.
- Add `WeatherOutlookDiscussion` / discussion field definitions:
- `day`
- `headline`
- `summary`
- `discussion`
- `updatedAt`
- Remove polygon-level `headline`, `summary`, and `discussion` from outlook fields.
- State that v2 outlooks are already location-filtered by weatherfeeder.
- State `containsLocation` is expected to be true for v2 outlooks.
- Clarify that `/outlooks/convective/location` remains active local-outlook behavior and is mostly a compatibility route under v2.
- Document that filters also filter `discussions` to retained outlook days.
- Document latest-run semantics: current endpoints read the latest run and do not accumulate active historical outlook rows from previous runs.
- Update JSON and text examples to include run-level `discussions`.
### Integration And Internal Docs
Update `docs/integrations/weatherfeeder-postgres.md`:
- Update weatherfeeder dependency version.
- Add `outlook_discussions` to the table family.
- Add `discussion_count` to `outlook_runs` if the doc lists columns read or storage assumptions.
- Remove `headline`, `summary`, and `discussion` from `outlooks` columns.
- Add `outlook_discussions` columns and ordering by `discussion_index ASC`.
- State that `weatherapi` expects the v2 table reset/migration to have been applied by operators/weatherfeeder deployment.
Update `docs/internal/postgres-repository.md`:
- State `LatestConvectiveOutlookRun` loads `outlook_runs`, `outlooks`, and `outlook_discussions`.
- State child order for outlook discussions.
Update `docs/internal/presenters.md`:
- State outlook presenter copies and timezone-converts run-level discussions.
Update `docs/internal/http-adapter.md`:
- Clarify that outlook filters also trim run-level discussions to retained days.
Update `README.md` only if its outlook summary implies the old all-polygon behavior.
Update `docs/roadmap/implementation.md` after implementation is complete if this repository continues using that file as the active implementation checklist.
### Documentation Tests
If docs consistency tests exist or are added, assert stable identifiers only:
- `outlook_discussions` appears in `docs/integrations/weatherfeeder-postgres.md`.
- `weather.outlook.v2` appears in `docs/api.md` or the integration docs.
## Stage 6: Full Verification
Run focused tests:
```sh
go test ./internal/app
go test ./internal/adapters/outbound/postgres
go test ./internal/adapters/inbound/httpapi
go test ./internal/adapters/inbound/httpapi/presenter
```
Run the full suite:
```sh
go test ./...
```
Manual verification against a database populated by weatherfeeder v2 outlooks:
- `GET /outlooks/convective` returns latest run with run-level `discussions`.
- `GET /outlooks/convective/active` returns only active outlooks and matching discussions.
- `GET /outlooks/convective/location` works and returns active local outlooks.
- `GET /outlooks/convective?containsLocation=false` returns an empty run for v2 data.
- Text and XML formats render successfully.
## Assumptions
- `weatherfeeder` has been released with outlook v2 before final implementation is committed.
- Weatherfeeder-owned Postgres outlook tables have been reset/recreated according to weatherfeeder's transition documentation.
- `weatherapi` remains read-only and does not create, migrate, or repair weatherfeeder tables.
- The existing outlook route family remains public and should not be removed in this compatibility update.
- Latest-run semantics are the correct public API behavior for current outlook endpoints.
## Open Questions
None. The roadmap preserves current route names and query compatibility while updating storage and response handling to the new weatherfeeder v2 outlook contract.
## Status
Implemented in current behavior docs and code. The authoritative implemented
contracts now live in:
- [`docs/api.md`](../api.md) for public outlook routes, query parameters, and
response fields;
- [`docs/integrations/weatherfeeder-postgres.md`](../integrations/weatherfeeder-postgres.md)
for weatherfeeder-owned table assumptions;
- [`docs/internal/postgres-repository.md`](../internal/postgres-repository.md),
[`docs/internal/http-adapter.md`](../internal/http-adapter.md), and
[`docs/internal/presenters.md`](../internal/presenters.md) for internal
behavior.
`docs/roadmap/implementation.md` remains as the implementation checklist and
verification record for this compatibility update.
## Delivered Behavior
- Existing public routes remain available:
- `GET /outlooks/convective`
- `GET /outlooks/convective/active`
- `GET /outlooks/convective/location`
- The Postgres repository reads weatherfeeder outlook v2 tables:
`outlook_runs`, `outlooks`, and `outlook_discussions`.
- Outlook polygons no longer include prose fields.
- Run-level `discussions` are loaded, copied, presented, and timezone-converted.
- Application filtering trims `discussions` to days represented by retained
outlooks.
- Missing latest data still returns `data: null`.
- Filtered no-match responses return a run object with `outlooks: []` and
`discussions: []`.
- `weatherapi` remains read-only and does not create, migrate, or repair
weatherfeeder tables.

View File

@@ -109,7 +109,7 @@ Likely causes:
- unknown query parameter;
- `precision` outside `0` through `2`, or not an integer;
- `precision` used on alerts, discussions, or weather stories;
- `precision` used on alerts, discussions, weather stories, or outlooks;
- `tz` used on observations, current conditions, or alerts;
- invalid timezone value;
- both `tz` and `TZ` are present with different values.
@@ -187,6 +187,10 @@ Safe fix: verify weatherfeeder is running and writing to the same database that
`weatherapi` uses as its first configured database. For current conditions,
wait for recent observations or inspect weatherfeeder ingestion.
For convective outlooks, also verify that weatherfeeder has applied its
`weather.outlook.v2` table shape and is writing `outlook_runs`, `outlooks`, and
`outlook_discussions`.
## Forecast Day Routes Return Empty `periods`
Symptom: `/forecast/hourly/today`, `/forecast/hourly/tomorrow`,