From c67504501313d432a3c614835e18d27c278f4fa9 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 17 Jan 2026 17:39:18 -0600 Subject: [PATCH] Updated documentation and added API.md to document the stable external wire format. --- API.md | 332 +++++++++++++++++++++++++++++++++++ README.md | 33 +++- cmd/weatherfeeder/config.yml | 14 +- internal/model/forecast.go | 2 +- 4 files changed, 372 insertions(+), 9 deletions(-) create mode 100644 API.md diff --git a/API.md b/API.md new file mode 100644 index 0000000..ce66234 --- /dev/null +++ b/API.md @@ -0,0 +1,332 @@ +# weatherfeeder API (Wire Contract) + +This document defines the stable, consumer-facing JSON contract emitted by weatherfeeder sinks. + +weatherfeeder emits **events** encoded as JSON. Each event has: +- an **envelope** (metadata + schema identifier), and +- a **payload** whose shape is determined by `schema`. + +Downstream consumers should: +1. parse the event envelope, +2. switch on `schema`, then +3. decode `payload` into the matching schema. + +--- + +## Event envelope + +All events are JSON objects with these fields: + +| Field | Type | Required | Notes | +|---|---:|:---:|---| +| `id` | string | yes | Stable event identifier. Treat as opaque. | +| `schema` | string | yes | Schema identifier (e.g. `weather.observation.v1`). | +| `source` | string | yes | Provider/source identifier (stable within configuration). | +| `effectiveAt` | string (timestamp) | yes | RFC3339Nano timestamp indicating when this event is effective. | +| `payload` | object | yes | Schema-specific payload (see below). | + +### Timestamp format + +All timestamps are encoded as JSON strings using Go’s `time.Time` JSON encoding (RFC3339Nano). + +Examples: +- `"2026-01-17T14:27:00Z"` +- `"2026-01-17T08:27:00-06:00"` + +--- + +## Canonical schemas + +weatherfeeder emits three canonical domain schemas: + +- `weather.observation.v1` +- `weather.forecast.v1` +- `weather.alert.v1` + +Each payload is described below using the JSON field names as the contract. + +--- + +## Shared conventions + +### Optional fields + +Most non-identity measurements are optional. Optional fields are omitted when unknown (i.e., not present). + +### Units + +Canonical payloads are normalized to metric units: + +- Temperature: `*C` (Celsius) +- Wind speed/gust: `*Kmh` (kilometers/hour) +- Pressure: `*Pa` (Pascals) +- Visibility / distance / elevation: `*Meters` (meters) +- Precipitation amount / snowfall depth: `*Mm` (millimeters) +- Humidity / cloud cover / PoP: `*Percent` (0–100) + +### Float rounding + +For readability and stability, weatherfeeder rounds floating-point values in canonical payloads to +**2 digits after the decimal** during normalization finalization. + +### WMO condition codes + +`conditionCode` uses the WMO weather interpretation code vocabulary. + +- Type: integer +- Unknown/unmappable: `-1` + +Downstream consumers should treat unknown codes as “unknown conditions” rather than failing decoding. + +--- + +## Schema: `weather.observation.v1` + +Payload type: `WeatherObservation` + +A `WeatherObservation` represents a point-in-time observation for a station/location. + +### Fields + +| Field | Type | Required | Units / Notes | +|---|---:|:---:|---| +| `stationId` | string | no | Provider station/location identifier | +| `stationName` | string | no | Human name, if available | +| `timestamp` | string (timestamp) | yes | Observation time | +| `conditionCode` | int | yes | WMO code (`-1` for unknown) | +| `conditionText` | string | no | Canonical short text (often derived from WMO code) | +| `isDay` | bool | no | Day/night hint when available | +| `providerRawDescription` | string | no | Provider-specific “evidence” text | +| `textDescription` | string | no | Legacy/transitional human text | +| `iconUrl` | string | no | Legacy/transitional icon URL | +| `temperatureC` | number | no | °C | +| `dewpointC` | number | no | °C | +| `windDirectionDegrees` | number | no | Degrees (meteorological) | +| `windSpeedKmh` | number | no | km/h | +| `windGustKmh` | number | no | km/h | +| `barometricPressurePa` | number | no | Pa | +| `seaLevelPressurePa` | number | no | Pa | +| `visibilityMeters` | number | no | meters | +| `relativeHumidityPercent` | number | no | percent (0–100) | +| `apparentTemperatureC` | number | no | °C | +| `elevationMeters` | number | no | meters | +| `rawMessage` | string | no | Provider raw message (e.g. METAR), if available | +| `presentWeather` | array | no | Provider-specific structured fragments | +| `cloudLayers` | array | no | Cloud layers (base + amount) | + +### Nested: `cloudLayers[]` + +Each `cloudLayers[]` element: + +| Field | Type | Required | Notes | +|---|---:|:---:|---| +| `baseMeters` | number | no | Cloud base altitude in meters | +| `amount` | string | no | Provider string (e.g. FEW/SCT/BKN/OVC) | + +### Nested: `presentWeather[]` + +Each `presentWeather[]` element: + +| Field | Type | Required | Notes | +|---|---:|:---:|---| +| `raw` | object | no | Provider-specific JSON object | + +--- + +## Schema: `weather.forecast.v1` + +Payload type: `WeatherForecastRun` + +A `WeatherForecastRun` is a single issued forecast snapshot for a location and a specific product +(hourly / narrative / daily). The run contains an ordered list of forecast periods. + +### `product` values + +`product` is one of: + +- `"hourly"` +- `"narrative"` +- `"daily"` + +### Fields + +| Field | Type | Required | Notes | +|---|---:|:---:|---| +| `locationId` | string | no | Provider location identifier | +| `locationName` | string | no | Human name, if available | +| `issuedAt` | string (timestamp) | yes | When this run was generated/issued | +| `updatedAt` | string (timestamp) | no | Optional later update time | +| `product` | string | yes | `"hourly"`, `"narrative"`, or `"daily"` | +| `latitude` | number | no | Degrees | +| `longitude` | number | no | Degrees | +| `elevationMeters` | number | no | meters | +| `periods` | array | yes | Chronological forecast periods | + +### Nested: `periods[]` (`WeatherForecastPeriod`) + +A `WeatherForecastPeriod` is valid for `[startTime, endTime)`. + +| Field | Type | Required | Units / Notes | +|---|---:|:---:|---| +| `startTime` | string (timestamp) | yes | Period start | +| `endTime` | string (timestamp) | yes | Period end | +| `name` | string | no | Human label (often empty for hourly) | +| `isDay` | bool | no | Day/night hint | +| `conditionCode` | int | yes | WMO code (`-1` for unknown) | +| `conditionText` | string | no | Canonical short text | +| `providerRawDescription` | string | no | Provider-specific “evidence” text | +| `textDescription` | string | no | Human-facing short phrase | +| `detailedText` | string | no | Longer narrative | +| `iconUrl` | string | no | Legacy/transitional | +| `temperatureC` | number | no | °C | +| `temperatureCMin` | number | no | °C (aggregated products) | +| `temperatureCMax` | number | no | °C (aggregated products) | +| `dewpointC` | number | no | °C | +| `relativeHumidityPercent` | number | no | percent | +| `windDirectionDegrees` | number | no | degrees | +| `windSpeedKmh` | number | no | km/h | +| `windGustKmh` | number | no | km/h | +| `barometricPressurePa` | number | no | Pa | +| `visibilityMeters` | number | no | meters | +| `apparentTemperatureC` | number | no | °C | +| `cloudCoverPercent` | number | no | percent | +| `probabilityOfPrecipitationPercent` | number | no | percent | +| `precipitationAmountMm` | number | no | mm (liquid equivalent) | +| `snowfallDepthMm` | number | no | mm | +| `uvIndex` | number | no | unitless index | + +--- + +## Schema: `weather.alert.v1` + +Payload type: `WeatherAlertRun` + +A `WeatherAlertRun` is a snapshot of *active* alerts for a location as-of a point in time. +A run may contain zero, one, or many alerts. + +### Fields + +| Field | Type | Required | Notes | +|---|---:|:---:|---| +| `locationId` | string | no | Provider location identifier | +| `locationName` | string | no | Human name, if available | +| `asOf` | string (timestamp) | yes | When the provider asserted this snapshot is current | +| `latitude` | number | no | Degrees | +| `longitude` | number | no | Degrees | +| `alerts` | array | yes | Active alerts (order provider-dependent) | + +### Nested: `alerts[]` (`WeatherAlert`) + +| Field | Type | Required | Notes | +|---|---:|:---:|---| +| `id` | string | yes | Provider-stable identifier (often a URL/URI) | +| `event` | string | no | Classification | +| `headline` | string | no | Short headline | +| `severity` | string | no | e.g. Extreme/Severe/Moderate/Minor/Unknown | +| `urgency` | string | no | e.g. Immediate/Expected/Future/Past/Unknown | +| `certainty` | string | no | e.g. Observed/Likely/Possible/Unlikely/Unknown | +| `status` | string | no | e.g. Actual/Exercise/Test/System/Unknown | +| `messageType` | string | no | e.g. Alert/Update/Cancel | +| `category` | string | no | e.g. Met/Geo/Safety/... | +| `response` | string | no | e.g. Shelter/Evacuate/Prepare/... | +| `description` | string | no | Narrative | +| `instruction` | string | no | What to do | +| `sent` | string (timestamp) | no | Provider-dependent | +| `effective` | string (timestamp) | no | Provider-dependent | +| `onset` | string (timestamp) | no | Provider-dependent | +| `expires` | string (timestamp) | no | Provider-dependent | +| `areaDescription` | string | no | Often a provider string | +| `senderName` | string | no | Provenance | +| `references` | array | no | Related alert references | + +### Nested: `references[]` (`AlertReference`) + +| Field | Type | Required | Notes | +|---|---:|:---:|---| +| `id` | string | no | Provider reference ID/URI | +| `identifier` | string | no | Provider identifier string, if distinct | +| `sender` | string | no | Sender | +| `sent` | string (timestamp) | no | Timestamp | + +--- + +## Compatibility rules + +- Consumers **must** ignore unknown fields. +- Producers (weatherfeeder) prefer **additive changes** within a schema version. +- Renames/removals/semantic breaks require a **schema version bump** (`weather.*.v2`). + +--- + +## Examples + +### Observation event (`weather.observation.v1`) + +```json +{ + "id": "nws:KSTL:2026-01-17T14:00:00Z", + "schema": "weather.observation.v1", + "source": "nws_observation", + "effectiveAt": "2026-01-17T14:00:00Z", + "payload": { + "stationId": "KSTL", + "timestamp": "2026-01-17T14:00:00Z", + "conditionCode": 1, + "conditionText": "Mainly Sunny", + "temperatureC": 3.25, + "windSpeedKmh": 18.5 + } +} +``` + +### Forecast event (`weather.forecast.v1`) + +```json +{ + "id": "openmeteo:38.63,-90.20:2026-01-17T13:00:00Z", + "schema": "weather.forecast.v1", + "source": "openmeteo_forecast", + "effectiveAt": "2026-01-17T13:00:00Z", + "payload": { + "locationName": "St. Louis, MO", + "issuedAt": "2026-01-17T13:00:00Z", + "product": "hourly", + "latitude": 38.63, + "longitude": -90.2, + "periods": [ + { + "startTime": "2026-01-17T14:00:00Z", + "endTime": "2026-01-17T15:00:00Z", + "conditionCode": 2, + "conditionText": "Partly Cloudy", + "temperatureC": 3.5, + "probabilityOfPrecipitationPercent": 10 + } + ] + } +} +``` + +### Alert event (`weather.alert.v1`) + +```json +{ + "id": "nws:alerts:2026-01-17T14:10:00Z", + "schema": "weather.alert.v1", + "source": "nws_alerts", + "effectiveAt": "2026-01-17T14:10:00Z", + "payload": { + "asOf": "2026-01-17T14:05:00Z", + "alerts": [ + { + "id": "https://api.weather.gov/alerts/abc123", + "event": "Winter Weather Advisory", + "headline": "Winter Weather Advisory issued January 17 at 8:05AM CST", + "severity": "Moderate", + "description": "Mixed precipitation expected...", + "expires": "2026-01-18T06:00:00Z" + } + ] + } +} +``` diff --git a/README.md b/README.md index 7f27002..81ce516 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,34 @@ # weatherfeeder -A small daemon to poll weather observations, alerts, and forecasts from a variety of sources. \ No newline at end of file +weatherfeeder is a small daemon that polls weather observations, forecasts, and alerts from multiple upstream +providers, normalizes them into a provider-independent format, and emits them to a sink. + +Today, the only implemented sink is `stdout`, which prints JSON-encoded events. + +## What weatherfeeder emits + +weatherfeeder emits **feed events** encoded as JSON. Each event includes a schema identifier and a payload. +Downstream consumers should key off the `schema` value and decode the `payload` accordingly. + +Canonical domain schemas emitted after normalization: + +- `weather.observation.v1` → `WeatherObservation` +- `weather.forecast.v1` → `WeatherForecastRun` +- `weather.alert.v1` → `WeatherAlertRun` + +For the complete wire contract (event envelope + payload schemas, fields, units, and compatibility rules), see: + +- **API.md** + +## Upstream providers (current MVP) + +- NWS: observations, hourly forecasts, alerts +- Open-Meteo: observations, hourly forecasts +- OpenWeather: observations + +## Versioning & compatibility + +The JSON field names on canonical payload types are treated as part of the wire contract. +Additive changes are preferred. Renames/removals require a schema version bump. + +See **API.md** for details. diff --git a/cmd/weatherfeeder/config.yml b/cmd/weatherfeeder/config.yml index 71c2fec..4fc86cc 100644 --- a/cmd/weatherfeeder/config.yml +++ b/cmd/weatherfeeder/config.yml @@ -1,12 +1,12 @@ --- sources: -# - name: NWSObservationKSTL -# kind: observation -# driver: nws_observation -# every: 10m -# params: -# url: "https://api.weather.gov/stations/KSTL/observations/latest" -# user_agent: "HomeOps (eric@maximumdirect.net)" + - name: NWSObservationKSTL + kind: observation + driver: nws_observation + every: 10m + params: + url: "https://api.weather.gov/stations/KSTL/observations/latest" + user_agent: "HomeOps (eric@maximumdirect.net)" # - name: OpenMeteoObservation # kind: observation diff --git a/internal/model/forecast.go b/internal/model/forecast.go index 1580c6c..61d62ab 100644 --- a/internal/model/forecast.go +++ b/internal/model/forecast.go @@ -111,7 +111,7 @@ type WeatherForecastPeriod struct { // Quantitative precip is not universally available, but OpenWeather/Open-Meteo often supply it. // Use liquid-equivalent mm for interoperability. PrecipitationAmountMm *float64 `json:"precipitationAmountMm,omitempty"` - SnowfallDepthMM *float64 `json:"SnowfallDepthMM,omitempty"` + SnowfallDepthMM *float64 `json:"snowfallDepthMm,omitempty"` // Optional extras that some providers supply and downstream might care about. UVIndex *float64 `json:"uvIndex,omitempty"`