diff --git a/README.md b/README.md index dc547f1..a3bf48d 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ `weatherapi` is a read-only HTTP API for weather data already stored by `weatherfeeder` in PostgreSQL. It exposes the latest observations, current -conditions, alerts, forecasts, discussions, and weather stories as JSON, XML, -or text. +conditions, alerts, forecasts, discussions, weather stories, and convective +outlooks as JSON, XML, or text. ## Quickstart @@ -26,6 +26,11 @@ database credentials through your normal secret-management process. - Hourly and narrative forecasts - Forecast discussions - Weather stories +- Convective outlooks + +Common query parameters include `format`, `units`, and route-specific options +such as forecast `precision`, timezone `tz` / `TZ`, and outlook filters +`day`, `outlookType`, and `containsLocation`. See [`docs/api.md`](docs/api.md) for the HTTP contract. diff --git a/docs/api.md b/docs/api.md index 9885ac0..6c35781 100644 --- a/docs/api.md +++ b/docs/api.md @@ -63,16 +63,17 @@ Unknown query parameters are rejected with `400 Bad Request`. | `format` | `json`, `xml`, `text` | configured default | all endpoints | | `units` | `metric`, `us` | `metric` | all endpoints | | `precision` | integer `0` through `2` | `0` | observations, current conditions, forecasts | -| `tz` or `TZ` | timezone selector | UTC/no conversion | forecasts, discussions, weather stories | +| `tz` or `TZ` | timezone selector | UTC/no conversion | forecasts, discussions, weather stories, outlooks | `units`, `format`, and `precision` values are normalized case-insensitively where applicable. `units=metric` returns metric field names; `units=us` returns US-customary field names for unit-bearing payloads. Alerts, discussions, and weather stories accept `units` but their current payload fields are not -materially changed by it. +materially changed by it. Outlooks also accept `units` without changing payload +values or field names. `precision` controls numeric rounding. The default `0` rounds to whole numbers. -`precision` is rejected on alerts, discussions, and weather stories. +`precision` is rejected on alerts, discussions, weather stories, and outlooks. Timezone selectors accepted by `tz` / `TZ`: @@ -220,6 +221,151 @@ Example: GET /alerts/active?format=text ``` +### Convective Outlooks + +```http +GET /outlooks/convective +GET /outlooks/convective/active +GET /outlooks/convective/location +``` + +Returns the latest SPC convective outlook run reconstructed from +weatherfeeder-owned Postgres tables. + +Route behavior: + +- `/outlooks/convective` returns the latest run with all stored 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`. + +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. + +Query parameters: + +| Parameter | Supported on | Values | +| --- | --- | --- | +| `format`, `units`, `tz` / `TZ` | all outlook routes | shared rules above | +| `day` | all outlook routes | `1`, `2`, or `3` | +| `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. + +Run `data` fields: + +| Field | Type | Notes | +| --- | --- | --- | +| `locationId`, `locationName` | string | optional | +| `latitude`, `longitude` | number | optional | +| `asOf` | RFC3339 datetime | required when `data` is not null | +| `issuedAt` | RFC3339 datetime | optional | +| `outlooks` | array | ordered outlook polygons, possibly empty | + +Outlook fields: + +| Field | Type | Notes | +| --- | --- | --- | +| `id`, `provider`, `product`, `outlookType`, `label` | string | required when an outlook is present | +| `day` | integer | SPC outlook day | +| `labelText`, `forecaster`, `headline`, `summary`, `discussion` | string | optional | +| `severityRank` | integer | optional | +| `validFrom`, `validTo`, `issuedAt`, `expiresAt` | RFC3339 datetime | required when an outlook is present | +| `sourceUrl`, `imageUrl` | string | optional | +| `containsLocation` | boolean | whether the outlook polygon contains the configured location | +| `geometry` | GeoJSON | stored outlook geometry | + +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. + +Examples: + +```http +GET /outlooks/convective?day=1&outlookType=categorical +GET /outlooks/convective/location?format=text&tz=CDT +``` + +Example JSON response: + +```json +{ + "data": { + "locationId": "stl", + "locationName": "St. Louis", + "asOf": "2026-06-11T18:00:00Z", + "issuedAt": "2026-06-11T17:00:00Z", + "outlooks": [ + { + "id": "spc-day1-cat-slight", + "provider": "spc", + "product": "convective", + "day": 1, + "outlookType": "categorical", + "label": "SLGT", + "labelText": "Slight Risk", + "severityRank": 5, + "validFrom": "2026-06-11T18:00:00Z", + "validTo": "2026-06-12T12:00:00Z", + "issuedAt": "2026-06-11T17:00:00Z", + "expiresAt": "2026-06-12T12:00:00Z", + "containsLocation": true, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-91.0, 38.0], + [-90.0, 38.0], + [-90.0, 39.0], + [-91.0, 38.0] + ] + ] + } + } + ] + } +} +``` + +Example location-filtered JSON response: + +```json +{ + "data": { + "locationId": "stl", + "asOf": "2026-06-11T18:00:00Z", + "outlooks": [ + { + "id": "spc-day1-tor-2pct", + "provider": "spc", + "product": "convective", + "day": 1, + "outlookType": "tornado", + "label": "2%", + "validFrom": "2026-06-11T18:00:00Z", + "validTo": "2026-06-12T12:00:00Z", + "issuedAt": "2026-06-11T17:00:00Z", + "expiresAt": "2026-06-12T12:00:00Z", + "containsLocation": true, + "geometry": { + "type": "Point", + "coordinates": [-90.2, 38.6] + } + } + ] + } +} +``` + +Text format uses the shared convective outlook template for all three outlook +routes and renders a no-data message when `data` is null. + ### Forecasts ```http diff --git a/docs/integrations/weatherfeeder-postgres.md b/docs/integrations/weatherfeeder-postgres.md index 9b907a6..210fef5 100644 --- a/docs/integrations/weatherfeeder-postgres.md +++ b/docs/integrations/weatherfeeder-postgres.md @@ -37,6 +37,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` | ## Latest Row Selection @@ -52,6 +53,7 @@ Latest parent resources use these ordering rules: - weather story runs: `as_of DESC, event_emitted_at DESC`; - latest individual weather story: `updated_at DESC, as_of DESC, story_order ASC, story_index ASC`. +- convective outlook runs: `as_of DESC, event_emitted_at DESC`. Current conditions aggregate `observations` rows where `observed_at` is inside the application-provided observation window. @@ -65,7 +67,8 @@ Child rows are loaded separately and attached in stored order: - alert references: `alert_index ASC, reference_index ASC`; - forecast periods: `period_index ASC`; - forecast discussion key messages: `message_index ASC`; -- weather stories for a run: `story_index ASC`. +- weather stories for a run: `story_index ASC`; +- outlooks for a run: `outlook_index ASC`. ## Columns Read @@ -148,6 +151,21 @@ routes. `description`, `alt_text`, `priority`, `story_order`, `download_url`, `run_event_id`, and `as_of`. +### `outlook_runs` + +`event_id`, `location_id`, `location_name`, `latitude`, `longitude`, `as_of`, +`issued_at`, and `event_emitted_at`. + +### `outlooks` + +`outlook_index`, `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`, and `run_event_id`. + +`geometry_json` is copied into response GeoJSON without parsing or +reserializing. It must contain valid JSON. + ## Nullability and Time Assumptions The repository scans nullable columns with `sql.Null*` types and maps them to diff --git a/docs/internal/http-adapter.md b/docs/internal/http-adapter.md index 4b8ee2f..fede6e3 100644 --- a/docs/internal/http-adapter.md +++ b/docs/internal/http-adapter.md @@ -8,7 +8,8 @@ This document describes the internal HTTP adapter under The HTTP adapter turns feedapi route definitions into calls on the application service boundary. It owns route registration, query binding, request validation, -forecast day-slice filtering, response envelopes, and template names. +forecast day-slice filtering, outlook active/location filter construction, +response envelopes, and template names. ## Inputs and Outputs @@ -33,7 +34,8 @@ The adapter may: - bind and validate query parameters; - call the `Service` interface; - choose the presenter function for an endpoint; -- filter forecast copies for `/today` and `/tomorrow`. +- filter forecast copies for `/today` and `/tomorrow`; +- construct outlook active/location filters. The adapter must not: @@ -59,9 +61,9 @@ declared in endpoint definitions, but `templates.base_dir` is loaded by feedapi. ## State -The adapter has no durable state. `forecastNow` is package-level state only to -make forecast day filtering testable. Do not add request caches or cross-request -mutable state here. +The adapter has no durable state. `forecastNow` and `outlookNow` are +package-level state only to make time-dependent endpoint tests deterministic. +Do not add request caches or cross-request mutable state here. ## Route Registry @@ -71,6 +73,7 @@ mutable state here. - active alerts; - current conditions; - weather stories; +- convective outlooks; - forecast discussions; - hourly and narrative forecasts. @@ -80,19 +83,25 @@ middleware, and error normalization after definitions are registered. ## Query Binding -There are three binder shapes: +Binder shapes include: - `bindQuery`: `format` and `units`; - `bindPrecisionQuery`: `format`, `units`, and `precision`; - `bindForecastPrecisionQuery`: `format`, `units`, `precision`, and timezone; - `bindTimezoneQuery`: `format`, `units`, and timezone. +- outlook binders: `format`, `units`, timezone, and outlook filters. All binders use feedapi binding helpers with `RejectUnknown: true`. Supported common query values are lowercased and trimmed before binding where applicable. `precision` defaults to `0` and must be between `0` and `2`. Timezone parsing is -available only through binders used by forecast, discussion, and weather story -routes. +available only through binders used by forecast, discussion, weather story, and +outlook routes. + +Outlook routes accept `day`, `outlookType`, and, except for +`/outlooks/convective/location`, `containsLocation`. The location route always +adds `containsLocation=true` after binding and rejects an explicit +`containsLocation` query value. ## Timezone Parsing @@ -122,6 +131,19 @@ Filtering behavior: The package variable `forecastNow` exists so endpoint tests can make day-slice behavior deterministic. +## Outlook Filters + +Outlook route filters are built at the HTTP boundary and passed to the +application service: + +- `/outlooks/convective` uses only user-supplied filters; +- `/outlooks/convective/active` adds `ActiveAt=outlookNow().UTC()`; +- `/outlooks/convective/location` adds the same active timestamp and + `ContainsLocation=true`. + +The package variable `outlookNow` exists so endpoint tests can make active and +location filtering deterministic. + ## Failure Behavior Binder failures become feedapi invalid-parameter responses. Handler service diff --git a/docs/internal/postgres-repository.md b/docs/internal/postgres-repository.md index 783afdb..159ca7c 100644 --- a/docs/internal/postgres-repository.md +++ b/docs/internal/postgres-repository.md @@ -21,8 +21,8 @@ Inputs: Outputs: -- latest observation, forecast, discussion, weather story, alert, and current - conditions read models; +- latest observation, forecast, discussion, weather story, alert, convective + outlook, and current conditions read models; - nil data with nil error when the latest resource does not exist; - contextual errors for query, scan, iteration, and JSON decode failures. @@ -89,6 +89,8 @@ successful responses with `data: null`. - `LatestWeatherStoryRun`: latest row from `weather_story_runs`, then child `weather_stories`. - `LatestWeatherStory`: latest individual row from `weather_stories`. +- `LatestConvectiveOutlookRun`: latest row from `outlook_runs`, then child + `outlooks`. Latest parent rows are selected by descending weather timestamp and `event_emitted_at` where that tie-breaker is available in the query. @@ -102,7 +104,8 @@ Child queries preserve stored order: - alert references by `alert_index`, then `reference_index`; - forecast periods by `period_index`; - discussion key messages by `message_index`; -- weather stories by `story_index`. +- weather stories by `story_index`; +- outlooks by `outlook_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. @@ -126,6 +129,10 @@ Observation present-weather rows store raw JSON text. Empty or null text maps to an empty present-weather value. Invalid JSON returns a contextual decode error with the weather index. +Outlook rows store `geometry_json` as compact GeoJSON text. The repository +validates and copies the JSON bytes into `json.RawMessage` without parsing or +reserializing the geometry. + ## Failure Behavior Repository methods wrap failures with operation context, for example: diff --git a/docs/internal/presenters.md b/docs/internal/presenters.md index b9c832e..732db06 100644 --- a/docs/internal/presenters.md +++ b/docs/internal/presenters.md @@ -93,9 +93,9 @@ Latitude and longitude are copied but not rounded by forecast presenters. timezone is supplied. Without a timezone, timestamps are preserved as returned by the repository. -Timezone conversion is applied by forecast, discussion, and weather story -presenters. Observations and current conditions do not currently receive -timezone input from their routes. +Timezone conversion is applied by forecast, discussion, weather story, and +outlook presenters. Observations and current conditions do not currently +receive timezone input from their routes. ## Optional Fields and Copy Semantics @@ -118,6 +118,9 @@ 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. ## Templates diff --git a/examples/requests.http b/examples/requests.http index 78a2d67..6eae106 100644 --- a/examples/requests.http +++ b/examples/requests.http @@ -11,6 +11,13 @@ Accept: application/json ### Active alerts as text GET {{baseUrl}}/alerts/active?format=text +### Convective outlook day 1 categorical risk +GET {{baseUrl}}/outlooks/convective?day=1&outlookType=categorical +Accept: application/json + +### Active convective outlooks for the configured location as text +GET {{baseUrl}}/outlooks/convective/location?format=text&tz=CDT + ### Hourly forecast in US units GET {{baseUrl}}/forecast/hourly?units=us&precision=1&tz=Chicago Accept: application/json