openapi: 3.1.0

info:
  title: meteofeed · external weather API
  version: "v1"
  description: |
    Read-only REST API for current observations, multi-model forecasts,
    severe-weather warnings, precipitation radar, marine waves, air quality +
    pollen, sun / UV / solar-PV, climate normals, multi-decade historical
    archives, worldwide cyclone tracking, space weather + aurora,
    natural-hazard alerts (earthquakes / tsunamis / volcanoes), daily
    sea-surface temperature and push alert webhooks — across 195 countries on
    every inhabited continent (plus 26 inhabited territories), from one bearer token.

    Upstream sources (all CC-BY / open-data, full commercial use):
      - **KNMI** (Royal Netherlands Meteorological Institute) — NL
        observations, historicals, radar, warnings and the HARMONIE-AROME
        forecast (2.5 km, NL + border).
      - **DWD** (Deutscher Wetterdienst) — DE + DACH: ICON-D2 forecast
        (2.2 km), POI observations, CAP warnings, RADOLAN radar and the CDC
        climate archive (back to 1781).
      - **RMI** (Royal Meteorological Institute of Belgium) — BE station
        observations and a synoptic climate archive (back to 1952).
      - **ECMWF Open Data** — IFS deterministic forecast (0.25°, worldwide
        via regional crops) for coords outside the native models; the ECMWF
        wave model for `/v1/marine/forecast`.
      - **NOAA** — METAR airport observations (every country beyond NL / DE /
        BE) and the GHCN-Daily climate + historical archive (worldwide).
      - **Copernicus CAMS** — European ensemble (full pollutants + pollen)
        plus a CAMS-global surface-PM model for worldwide air quality.
      - **EUMETNET OPERA** (European radar composite) and **NASA GIBS IMERG**
        (satellite precipitation, off-grid / non-European regions) — radar.
      - **GDACS** — worldwide tropical-cyclone tracking.
      - **Rijkswaterstaat (DDAPI 2.0)** — Dutch water levels, plus an in-house
        harmonic-regression tide forecast.
      - **Geocoding** — self-hosted BAG (NL) + Nominatim / OpenStreetMap
        (worldwide).
      - **Compute** — sun position (NOAA SPA), UV index (Madronich / McKenzie
        clear-sky × cloud attenuation), the `/v1/solar` PV-yield stack (Erbs ·
        Ineichen-Perez · Hay-Davies · PVWatts) and climate normals.

    Bearer-token authentication. Enveloped JSON responses with a
    nested error object. Per-key limits: a per-tier burst cap (Free 120 /
    Basic 240 / Pro 360 / Business 600 req/min/key) plus monthly
    quotas — Free 500/mo (no card), Basic 7,500/mo, Pro 30,000/mo, Business
    1,500,000/mo. Every response carries `X-RateLimit-{Limit,Remaining,Reset}`
    (the per-minute burst), `X-Quota-{Limit,Used,Reset}` (your billing-period
    quota) and `X-Tier`, plus `Retry-After` on a 429 — self-throttle on those
    headers rather than waiting for the 429.

    **Coverage gates** — forecast, sun / UV / solar, air quality and marine
    waves resolve worldwide (via the regional ECMWF / CAMS / wave crops); a
    coordinate outside a given product's footprint returns `503
    endpoint_unavailable`, documented per endpoint. `/v1/historical/forecast`
    serves OUR self-archived ECMWF day-ahead forecasts (accumulating forward
    from when archiving was switched on) and `/v1/water` is NL-only. The native NL / DE / BE networks give the richest
    coverage; everywhere else rides NOAA METAR + ECMWF + Copernicus CAMS +
    satellite radar.

    See [meteofeed.com/pricing](https://meteofeed.com/pricing) for tier
    details and the [signup flow](https://meteofeed.com/signup) for a free
    API key (no card required).

  contact:
    name: meteofeed support
    email: support@meteofeed.com
    url: https://meteofeed.com
  termsOfService: https://meteofeed.com/terms
  license:
    name: Commercial — see meteofeed.com/terms

servers:
  - url: https://api.meteofeed.com
    description: Production

tags:
  - name: Health
    description: Public, unauthenticated. For load balancers + uptime monitors.
  - name: Account
    description: Inspect the key + tier + usage you're authenticated as.
  - name: Stations
    description: Weather-station registries — KNMI (NL) · DWD (DE) · RMI (BE) · NOAA METAR airports worldwide. Filter with ?country=.
  - name: Current
    description: Latest observation for a coordinate — nearest-station waterfall KNMI → DWD → RMI → NOAA METAR → ECMWF grid snapshot, worldwide.
  - name: Forecast
    description: Hourly + daily forecasts — KNMI HARMONIE-AROME (NL+border) → DWD ICON-D2 (DACH) → ECMWF Open Data IFS (worldwide via regional crops).
  - name: Historical
    description: Historical observations — KNMI (NL, 1970+) · DWD CDC (1781+) · RMI synop (1952+) · NOAA GHCN-Daily (worldwide).
  - name: Sun
    description: Sunrise / sunset / civil-nautical-astronomical twilight (compute, no upstream).
  - name: UV
    description: Hourly UV index — derived in-house from sun elevation + cloud cover (no external UV product).
  - name: Warnings
    description: Severe-weather warnings — KNMI (NL) · DWD CAP (DE) · MeteoAlarm (~34 European countries).
  - name: Radar
    description: Precipitation radar PNG tiles — KNMI (NL) · DWD RADOLAN (DE) · EUMETNET OPERA (~31 European countries) · NASA GIBS satellite (worldwide).
  - name: Solar
    description: PV / solar-radiation bundle — live KNMI obs + ECMWF/HARMONIE forecast + computed sun position.
  - name: AirQuality
    description: Hourly air-quality + pollen — Copernicus CAMS European ensemble (full pollutant set + pollen) plus a CAMS-global surface-PM (PM2.5/PM10) model covering the rest of the world. Outside coverage, the endpoint returns 503.
  - name: Maritime
    description: Maritime observations (KNMI offshore platforms) + wave forecast (ECMWF Open Data wave — EU + the global sea crops).
  - name: Water
    description: Rijkswaterstaat water-level observations + in-house astronomical tide-curve forecast (harmonic regression on RWS observations).
  - name: Climate
    description: Monthly climate normals — KNMI / DWD / RMI archives + NOAA GHCN-Daily worldwide; tier-gated archive depth.
  - name: Geocode
    description: Forward geocoder — self-hosted BAG (NL postcode / address / city) + Nominatim / OpenStreetMap worldwide.
  - name: Typhoons
    description: Active tropical cyclones worldwide (typhoons / hurricanes / cyclones) from GDACS — alert level, Saffir–Simpson category, max wind, position, affected countries.
  - name: Space Weather
    description: Space weather + aurora from NOAA SWPC — planetary Kp index, G/S/R storm scales, solar wind, X-ray flares, and a location-specific OVATION aurora forecast with a darkness-aware viewing verdict.
  - name: Earthquakes
    description: Recent global earthquakes from USGS — magnitude, place, depth, tsunami flag, felt reports, alert level; optionally near a coordinate (lat+lng+radius) with a distance.
  - name: Tsunamis
    description: Current bulletins from the NOAA/NWS tsunami warning centers (NTWC + PTWC) — category, source quake, CAP links; information = no threat (the calm state).
  - name: Volcanoes
    description: USGS-monitored volcanoes at an elevated alert level — advisory/watch/warning + aviation color code, position, synopsis; optionally near a coordinate.
  - name: Avalanche
    description: EUREGIO Alpine avalanche bulletins (Tyrol, South Tyrol, Trentino) from avalanche.report — EAWS danger level 1–5 by elevation band + time of day, plus avalanche problems; a winter product (~Nov–May).

security:
  - bearerAuth: []

# ───────────────────────────────────────────────────────────────────────────
# Paths
# ───────────────────────────────────────────────────────────────────────────
paths:

  /v1/healthz:
    get:
      tags: [Health]
      summary: API liveness
      security: []
      responses:
        "200":
          description: API is up.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: object, properties: { status: { type: string, example: ok }, version: { type: string, example: v1.0.0 } } }
                  meta: { $ref: "#/components/schemas/MetaMinimal" }
                  warnings: { $ref: "#/components/schemas/Warnings" }
              example:
                data: { status: ok, version: v1.0.0 }
                meta: { request_id: "8d998ea3-4bd0-4fd7-bc73-1cb2641a3e6c" }
                warnings: []

  /v1/me:
    get:
      tags: [Account]
      summary: Info about the calling key + your subscription + current usage
      description: |
        Returns the key's id/prefix/scopes/expiry, your user info, and your
        current-period quota usage. Useful for SDKs to display "X of Y
        requests used this month" without a separate billing API call.
      responses:
        "200":
          description: Account snapshot.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MeEnvelope" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "429": { $ref: "#/components/responses/RateLimitError" }

  /v1/stations:
    get:
      tags: [Stations]
      summary: List weather stations (NL by default; DE / BE / METAR networks / combined optional)
      description: |
        Multi-country station registry:

          - **`country=NL`** (default — back-compat): 76 KNMI stations with
            an `active` flag (= has any observation in the last 30 days).
            Source: KNMI Data Platform.
          - **`country=DE`**: 20 curated major-city DWD POI stations
            (Berlin / Hamburg / München-Flughafen / Frankfurt / Köln /
            Stuttgart / Düsseldorf / Nürnberg / Dresden / Leipzig /
            Hannover / Bremen / Karlsruhe / Erfurt / Magdeburg / Essen /
            Lindenberg / Zugspitze). Source: DWD Open Data POI feed.
            `active` always true since the POI feed is fetched live.
          - **`country=BE`**: RMI AWS automatic stations (live WFS feed;
            `id` prefixed `rmi-`).
          - **`country=FR|PH|PL|DK|GB|IE|ES|PT|LU|CH|IT|CZ|AT|SK|SI|HR|HU|RO|BG|GR|LT|LV|EE|FI|SE|NO|IS|MT|CY|RS|ME|BA`**: curated major
            airports backed by NOAA METAR, fetched on demand (`id` prefixed
            `metar-<ICAO>`). e.g. `country=IT` → 22 Italian airports
            (Rome / Milan / Naples / Catania / Cagliari …).
          - **`country=all`**: every network concatenated; each row tagged
            with a `country` field.

        Every row carries a `country` field regardless of filter, plus
        the `id` (with `dwd-`/`rmi-`/`metar-` prefix for DE / BE / airport
        entries to keep the namespace unambiguous).

        **Scope**: `current`
      parameters:
        - in: query
          name: country
          required: false
          description: Country filter — `NL` (default), `DE`, `BE`, `FR`, `PH`, `PL`, `DK`, `GB`, `IE`, `ES`, `PT`, `LU`, `CH`, `IT`, `CZ`, `AT`, `SK`, `SI`, `HR`, `HU`, `RO`, `BG`, `GR`, `LT`, `LV`, `EE`, `FI`, `SE`, `NO`, `IS`, `MT`, `CY`, `RS`, `ME`, `BA`, or `all`.
          schema: { type: string, enum: [NL, DE, BE, FR, PH, PL, DK, GB, IE, ES, PT, LU, CH, IT, CZ, AT, SK, SI, HR, HU, RO, BG, GR, LT, LV, EE, FI, SE, NO, IS, MT, CY, RS, ME, BA, MD, AL, MK, XK, BY, TR, RU, JP, KR, TW, TH, VN, MY, SG, ID, IN, PK, BD, LK, CN, SA, IR, IQ, IL, QA, BH, OM, JO, EG, MA, DZ, TN, LY, ZA, NG, GH, SN, CI, CM, MZ, ET, US, CA, MX, CU, DO, GT, CR, PA, BR, AR, CL, CO, PE, EC, VE, UY, BO, PY, KZ, TM, KG, TJ, GE, AM, AZ, AU, NZ, JM, TT, BS, BB, HT, HN, NI, SV, BZ, GY, MN, FJ, VU, SB, AE, KW, KE, AO, NA, ZM, ZW, UG, BW, MG, RW, MW, GM, GW, GN, SL, LR, TG, BJ, GQ, GA, CG, ML, NE, TD, BF, CF, MR, MM, BN, ST, AG, DM, LC, GD, VC, KN, NR, TV, MV, MU, SC, CV, FM, MH, TO, WS, KI, SY, YE, SZ, BI, PR, VI, GP, MQ, AW, CW, SX, BM, FO, GI, JE, GG, IM, RE, YT, NC, GU, AS, PF, MP, HK, MO, EH, GF, FK, GL, all], default: NL }
      responses:
        "200":
          description: Station registry.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StationsEnvelope" }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }

  /v1/current:
    get:
      tags: [Current]
      summary: Latest observation at the station nearest to (lat, lng) — KNMI / RMI / DWD POI / ECMWF waterfall
      description: |
        4-tier coverage waterfall:

          1. **NL path**: resolves the nearest KNMI station via great-
             circle distance; if within 60 km, returns the full envelope
             from `weather.observations`: 15 core metrics, derived
             wind/pressure unit-conversions, weather-code decoration,
             rolling temperature / grass-temp / precipitation windows,
             Steadman feels-like, NL verbal short forecast.
             `meta.source` ∈ {`knmi.10min`, `knmi.uurgegevens`,
             `knmi.etmaal`}.

             **Stale-station fall-through.** A station can stop being
             published upstream while still being the closest one. When the
             nearest station has no observation newer than 2 hours, the
             response is served from the nearest station that IS reporting
             (searched outward, up to 60 km) and carries a
             `nearest_station_stale` entry in `warnings[]` naming the station
             that was skipped. `station.distance_km` is always the real
             distance to the station actually used, so a client needing the
             strictly-nearest station can detect the substitution from either
             field. NOT applied to `?at=` time-travel, where freshness is
             meaningless and the nearest station may be the only one with data
             for that date. A metric whose own sensor is stale still returns
             `null` — this replaces a DEAD STATION, not a dead sensor.

          2. **BE native preference**: if one of the 14 RMI (Belgium)
             AWS stations is genuinely closer than the nearest KNMI
             station, it wins — so Belgian coords get real Belgian
             observations (Uccle is ~6 km from Brussels) instead of a
             far Dutch border station. Live 10-min WFS proxy.
             `meta.source = "rmi.aws"`, station carries the `rmi-NNNN`
             code + `country: "BE"` + distance_km. (Live queries only;
             `?at=` / `?source=` stay KNMI-only.)

          3. **DE path** (when KNMI too far away): if the caller is
             within 80 km of one of the ~206 curated DWD POI stations
             (Berlin / Hamburg / Munich / Frankfurt / etc.), returns
             live observations from the DWD Open Data POI feed.
             `meta.source = "dwd.poi"`, station carries the `dwd-NNNNN`
             ID + `country: "DE"` + distance_km. Same envelope shape as
             NL with native 1h/3h/6h/12h/24h precipitation aggregates.

          4. **METAR path** (worldwide): outside the native KNMI / DWD / RMI
             radii, the nearest NOAA METAR airport observation is returned —
             every country beyond NL / DE / BE. `meta.source = "metar"`,
             station carries the `metar-ICAO` id + `country` + distance_km.

          5. **Grid fallback**: if no station is near, the latest-run step-0
             snapshot from `weather.forecast_ecmwf` (ECMWF Open Data IFS,
             worldwide via regional crops). `meta.source = "ecmwf-ifs"`,
             station is a synthetic grid-point object (`id="ecmwf:LAT:LNG"`).
             Rolling-window blocks are null (ECMWF has no observation archive).

          6. **No coverage**: a coordinate with no nearby station and outside
             every forecast crop returns `503 endpoint_unavailable`.

        `?at=` time-travel is NL-only — pairing it with out-of-coverage
        coords returns `404 out_of_coverage_at`.

        **Scope**: `current`. Default source `knmi.10min` (sub-hourly live);
        override via `?source=knmi.uurgegevens` / `knmi.etmaal` for coarser
        NL aggregates. **`?source=` selects the KNMI feed granularity (NL
        only) — it does NOT choose the provider/model; the upstream is
        auto-selected by location (see `meta.source`). To pin a forecast
        model use `/v1/forecast?model=`.** Ignored on the ECMWF fallback path.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
        - { $ref: "#/components/parameters/Pc" }
        - { $ref: "#/components/parameters/Locatie" }
        - { $ref: "#/components/parameters/SourceCurrent" }
      responses:
        "200":
          description: Latest observation.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CurrentEnvelope" }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "404": { $ref: "#/components/responses/NotFoundError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "503": { $ref: "#/components/responses/UpstreamDisabled" }

  /v1/current/station/{id}:
    get:
      tags: [Current]
      summary: Latest observation at a specific KNMI, DWD or RMI station
      description: |
        Same envelope shape as `GET /v1/current` but skips the lat/lng
        resolution and queries the given station id directly.

        Three ID namespaces:

          - **KNMI** (NL): bare 3-digit IDs from the registry (e.g. `260`
            for De Bilt, `240` for Schiphol Airport). `meta.source` ∈
            {`knmi.10min`, `knmi.uurgegevens`, `knmi.etmaal`}.
          - **DWD POI** (DE): `dwd-{wmo_id}` prefix (e.g. `dwd-10384` for
            Berlin-Tempelhof). ~206 stations; see
            `GET /v1/stations?country=DE`. `meta.source = "dwd.poi"`.
          - **RMI AWS** (BE): `rmi-{code}` prefix (e.g. `rmi-6447` for
            Uccle, `rmi-6494` for Mont Rigi). 14 stations; see
            `GET /v1/stations?country=BE`. `meta.source = "rmi.aws"`.

        Hyphen separator over colon because colons in URI path segments
        are URL-filtered by some proxies.

        Get the list of valid IDs from `GET /v1/stations` (use
        `?country=DE|BE` for the non-NL entries).

        **Scope**: `current`.
      parameters:
        - in: path
          name: id
          required: true
          description: |
            Station id. KNMI form: 3-digit numeric (e.g. `260`). DWD form:
            `dwd-{wmo_id}` (e.g. `dwd-10384`). RMI form: `rmi-{code}`
            (e.g. `rmi-6447` for Uccle).
          schema: { type: string, pattern: '^([0-9]{1,4}|dwd-[0-9]{4,5}|rmi-[0-9]{4,8})$', example: "rmi-6447" }
        - { $ref: "#/components/parameters/SourceCurrent" }
      responses:
        "200":
          description: Latest observation at the requested station.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CurrentEnvelope" }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "404": { $ref: "#/components/responses/NotFoundError" }
        "429": { $ref: "#/components/responses/RateLimitError" }

  /v1/forecast:
    get:
      tags: [Forecast]
      summary: Hourly forecast for one location (KNMI HARMONIE → DWD ICON-D2 → ECMWF IFS)
      description: |
        Hourly + daily forecast served from a direct-upstream waterfall:

          1. **KNMI HARMONIE-AROME** (~2.5 km, NL + border, ~60 h horizon)
             for coords inside lat 50.6..53.7°, lng 3.1..7.3°.
          2. **DWD ICON-D2** (~2.2 km) for coords inside its Central-European
             domain (DE + DACH + neighbours).
          3. **ECMWF Open Data IFS** (0.25°, ~168 h horizon) worldwide, via
             regional crops covering every inhabited continent.
          4. A coordinate outside every crop returns `503 endpoint_unavailable`.

        Response uses a consistent shape across both upstreams, so the
        same parser handles both. `meta.source` carries the actual
        upstream (`knmi-harmonie` or `ecmwf-ifs`).

        The `hours` parameter is tier-capped (Free: 24, Basic: 48,
        Pro/Business: 168). Requesting more than your tier allows truncates
        the response and adds a `hours_capped` warning rather than erroring.

        The `model` parameter lets paid-tier customers force a specific
        model when in the HARMONIE bbox. Free tier is locked to the
        waterfall default.

        **Scope**: `forecast`. Decoration-style derived arrays
        (`condition[]`, `icon[]`, `wind_compass[]`, unit-conversion
        siblings) are appended server-side.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
        - { $ref: "#/components/parameters/Pc" }
        - { $ref: "#/components/parameters/Locatie" }
        - { $ref: "#/components/parameters/Hours" }
        - { $ref: "#/components/parameters/Model" }
      responses:
        "200":
          description: Forecast.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ForecastEnvelope" }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }
        "503": { $ref: "#/components/responses/UpstreamDisabled" }

  /v1/forecast/short:
    get:
      tags: [Forecast]
      summary: Verkort daily forecast (one object per day, up to 10 days)
      description: |
        Compact daily summary — one object per day with the headline
        metrics customers display on a forecast tile (temp min/max, weather
        condition + icon, dominant wind, total precip + max probability, sun
        coverage, max UV). Each day is expressed as one object in a `days[]`
        array, indexed 0 (today) through N-1.

        The `days` parameter is tier-capped:
          - Free:     up to 3 days
          - Basic:    up to 5 days
          - Pro/Business: up to 10 days

        Requesting more than your tier allows truncates the response and
        adds a `days_capped` warning rather than erroring.

        Same direct-upstream waterfall as `/v1/forecast` (HARMONIE → ECMWF →
        503), but bucketed into daily aggregates per UTC date. Vars not in
        the upstream model (sunshine duration, UV max, precipitation
        probability) come back as null arrays.

        **Scope**: `forecast`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
        - { $ref: "#/components/parameters/Pc" }
        - { $ref: "#/components/parameters/Locatie" }
        - name: days
          in: query
          required: false
          description: |
            Number of daily forecast points. Defaults to the tier maximum
            (free=3, basic=5, pro/business=10). Capped at 10.
          schema: { type: integer, minimum: 1, maximum: 10, example: 7 }
        - { $ref: "#/components/parameters/Model" }
      responses:
        "200":
          description: Daily verkort summary.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ForecastShortEnvelope" }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }
        "503": { $ref: "#/components/responses/UpstreamDisabled" }

  /v1/uv:
    get:
      tags: [UV]
      summary: Hourly UV index forecast (derived from sun elevation + cloud cover)
      description: |
        Hourly UV index derived in-house from sun elevation and cloud cover —
        no external UV product needed. Pipeline:

          1. Cloud-cover forecast pulled from the same direct-upstream
             waterfall as `/v1/forecast` (HARMONIE → ECMWF → 503).
          2. Per hour, compute UV index via:

               UVI_clear   = 12.5 × max(0, sin(elevation_rad))^1.30
               UVI         = UVI_clear × (1 − 0.7 × cloud_fraction)

             Madronich/McKenzie clear-sky simplification (sea-level,
             ~300 DU ozone — accurate to ±0.5 UVI under clear sky for
             European mid-latitudes), times an empirical cloud
             attenuation factor (0% cover → 1.0×, 100% overcast → 0.3×,
             matching observational ~30% UV-through-overcast).

        Response carries the source `cloud_cover[]` array alongside
        `uv_index[]` so customers can see what drove the attenuation, plus
        a `meta.derivation` string describing the formula.

        Same tier-based hours cap as `/v1/forecast`. Returns 0 at night;
        returns clear-sky upper bound when cloud cover is null.

        **Scope**: `uv`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
        - { $ref: "#/components/parameters/Hours" }
      responses:
        "200":
          description: UV forecast.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UvEnvelope" }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "503": { $ref: "#/components/responses/UpstreamDisabled" }

  /v1/air-quality:
    get:
      tags: [AirQuality]
      summary: Hourly air-quality forecast (Copernicus CAMS European regional ensemble)
      description: |
        Hourly pollutant concentrations + European AQI for one location.
        Served direct from Copernicus CAMS European regional ensemble
        (CC-BY-4.0, full commercial use). Coverage gate: EU bbox
        (lat 30..72°, lng -25..45°). Outside Europe the endpoint falls
        through to the CAMS **global** atmospheric-composition model
        (surface PM2.5/PM10 only — the AQI is then computed from the PM
        sub-indices alone; `meta.source` = `cams-global`). Only a
        location outside every CAMS crop returns `503 endpoint_unavailable`.

        Pollutants returned (all in µg/m³):
          - `pm10`, `pm2_5` — particulate matter
          - `no2`, `o3`, `so2`, `co` — reactive gases

        Each hour is decorated with the European AQI (computed in-house
        from the EEA piecewise breakpoint tables — "worst pollutant
        wins" rule) plus a categorical band:
          `good` (0-19) · `fair` (20-39) · `moderate` (40-59) ·
          `poor` (60-79) · `very-poor` (80-99) · `extremely-poor` (100+)

        Sub-indices come from piecewise-linear interpolation over the
        EEA concentration breakpoints — µg/m³ anchors at the AQI band
        edges 0 / 20 / 40 / 60 / 80 / 100:

        | pollutant | 0 | 20 | 40 | 60 | 80 | 100 |
        |---|---|---|---|---|---|---|
        | `pm2_5` | 0 | 10 | 20 | 25 | 50 | 75 |
        | `pm10` | 0 | 20 | 40 | 50 | 100 | 150 |
        | `no2` | 0 | 40 | 90 | 120 | 230 | 340 |
        | `o3` | 0 | 50 | 100 | 130 | 240 | 380 |
        | `so2` | 0 | 100 | 200 | 350 | 500 | 750 |

        Above the 100-edge the interpolation continues past 100 (extreme
        concentrations are not capped at 100), and the overall
        `european_aqi` is the MAX of the available sub-indices.
        The per-pollutant sub-index is also surfaced
        (`european_aqi_pm2_5`, `european_aqi_pm10`, `european_aqi_no2`,
        `european_aqi_o3`, `european_aqi_so2`) so customers can see
        which pollutant is driving the overall score. CO is not part
        of the EEA AQI scale.

        A `current` snapshot block flattens the most-recent past hour's
        values for tile UIs that don't want to walk the hourly array.

        Tier-capped hours (same caps as `/v1/forecast`): free=24, basic=48,
        pro/business=168. CAMS publishes one daily run with up to 96h
        horizon, refreshed at 11:30 UTC daily on our side.

        **Note:** `aerosol_optical_depth`, `dust` and `uv_index` are not
        surfaced here — they aren't part of the CAMS regional ensemble.
        UV index lives at `/v1/uv` (derived in-house); AOD + dust are not
        currently served.

        **Scope**: `air_quality`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
        - { $ref: "#/components/parameters/Hours" }
      responses:
        "200":
          description: Air-quality forecast.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AirQualityEnvelope" }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }
        "503": { $ref: "#/components/responses/UpstreamDisabled" }

  /v1/pollen:
    get:
      tags: [AirQuality]
      summary: Hourly pollen forecast — six species + per-species risk band
      description: |
        Hourly pollen concentrations (grains/m³) for six species:
        alder, birch, grass, mugwort, olive, ragweed. Each species
        hourly array is decorated with a categorical risk band
        (`none`, `low`, `moderate`, `high`, `very-high`) — exact
        per-species thresholds (EAACI / Wörishofen scale) are tabulated
        in the `PollenRiskBand` schema: grass/mugwort/ragweed cut at
        5/20/50 grains/m³, alder/birch at 10/50/200, olive at 20/100/300.

        Same direct CAMS Europe upstream as `/v1/air-quality` — single
        daily fetch covers both endpoints. Coverage gate: EU bbox
        (lat 30..72°, lng -25..45°). Outside, returns `503
        endpoint_unavailable`.

        Field names: `{species}_pollen` for the hourly concentration
        and `{species}_risk` for the parallel band slug (e.g.
        `grass_pollen[]` aligned with `grass_risk[]`).

        Tier-cap on hours (same caps as `/v1/forecast`). Refreshed
        daily at 11:30 UTC.

        **Scope**: `pollen`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
        - { $ref: "#/components/parameters/Hours" }
      responses:
        "200":
          description: Pollen forecast.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PollenEnvelope" }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }
        "503": { $ref: "#/components/responses/UpstreamDisabled" }

  /v1/geocode:
    get:
      tags: [Geocode]
      summary: Forward geocoder (NL via self-hosted BAG · rest of world via Nominatim/OSM)
      description: |
        Country-aware free-form geocoding:

          - **`country=NL`** (default): self-hosted **BAG** geocoder (the
            Dutch national address register, ~9.9M addresses, served from
            ClickHouse). Best-in-class for NL addresses, postcodes, cities,
            gemeenten and provinces.
          - **any other `country`**: Nominatim against OpenStreetMap,
            restricted to that country — every code in the `country` enum
            below. Same field set as NL so client code branches only on
            `country` (BE returns bilingual nl/fr labels, etc.).

        Returns up to `rows` matches sorted by upstream relevance —
        each result carries lat/lng plus the structured admin
        hierarchy (postcode, straat, woonplaats, gemeente, provincie)
        so a single API call powers both autocomplete UIs and address
        parsing.

        Outside the country's coverage the result list is empty
        (HTTP 200, `count: 0`).

        Use `type` to narrow by feature class — same 6-value taxonomy
        across all countries. Without `type`, the upstream ranks across
        all classes and you'll usually get a mix.

        Lookups are cached server-side for 7 days — postcodes and
        centroids are effectively static.

        **Scope**: `geocode`.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string, example: "Meppel" }
          description: Free-form query (city, address, postcode, gemeente, weg name, etc.)
        - name: country
          in: query
          required: false
          schema:
            type: string
            enum: [NL, DE, BE, FR, PH, PL, DK, GB, IE, ES, PT, LU, CH, IT, CZ, AT, SK, SI, HR, HU, RO, BG, GR, LT, LV, EE, FI, SE, NO, IS, MT, CY, RS, ME, BA, UA, MD, AL, MK, XK, BY, TR, RU, AD, MC, LI, SM, VA, JP, KR, TW, TH, VN, MY, SG, ID, IN, PK, BD, LK, NP, CN, SA, IR, IQ, IL, QA, BH, OM, JO, EG, MA, DZ, TN, LY, ZA, NG, GH, SN, CI, CM, MZ, ET, US, CA, MX, CU, DO, GT, CR, PA, BR, AR, CL, CO, PE, EC, VE, UY, BO, PY, KZ, UZ, TM, KG, TJ, GE, AM, AZ, AU, NZ, JM, TT, BS, BB, HT, HN, NI, SV, BZ, GY, SR, MN, AF, FJ, PG, SB, VU, AE, KW, LB, KE, TZ, AO, NA, ZM, ZW, UG, BW, MG, RW, MW, GM, GW, GN, SL, LR, TG, BJ, GQ, GA, CG, CD, ML, NE, TD, BF, CF, MR, SD, SS, SO, ER, DJ, MM, LA, KH, BT, BN, TL, ST, KM, AG, DM, LC, GD, VC, KN, NR, TV, MV, MU, SC, CV, PW, FM, MH, TO, WS, KI, SY, KP, YE, PS, LS, SZ, BI, PR, VI, GP, MQ, AW, CW, SX, BM, FO, GI, JE, GG, IM, RE, YT, NC, GU, AS, PF, MP, HK, MO, EH, GF, FK, GL]
            default: NL
            example: DE
          description: |
            Country to geocode in. NL → self-hosted BAG (default),
            every other country → Nominatim against OpenStreetMap.
        - name: type
          in: query
          required: false
          schema:
            type: string
            enum: [postcode, adres, woonplaats, gemeente, weg, provincie]
            example: woonplaats
          description: Optional feature-class filter. Omit to rank across all classes.
        - name: rows
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 25, default: 5, example: 5 }
          description: Maximum number of results. Capped at 25.
      responses:
        "200":
          description: Geocoding results.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GeocodeEnvelope" }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }

  /v1/solar:
    get:
      tags: [Solar]
      summary: PV / solar-radiation bundle (current obs + hourly forecast)
      description: |
        Solar-focused observation + forecast endpoint for PV yield models,
        tilt calculators, sun-tracker hints and any radiation math.

        `current` block — live KNMI 10-min observation from the nearest
        REPORTING station (if the closest station has gone dark upstream the
        next-nearest reporting one within 60 km is used, and
        `current.station.substituted_for` names the skipped station with its
        distance and reason; it is `null` on the normal path).
        station: temperature, irradiance (both W/m² and J/cm²·h), sunshine
        duration, cloud cover (total, oktas), visibility, precipitation
        intensity. Plus computed **sun position** (elevation_deg /
        azimuth_deg / is_day) at the current moment and the day's
        sunrise / sunset times, plus a `pv` sub-block decomposing the **live**
        irradiance into DNI/DHI/POA (+ `pv_power_w` with `?kwp`), mirroring the
        hourly PV fields.

        `hourly` block — forecast from the same direct-upstream waterfall
        as `/v1/forecast` (HARMONIE → ECMWF → 503), tier-capped hours
        (same caps as `/v1/forecast`). Vars: temp, shortwave_radiation,
        cloud_cover (HARMONIE provides total + low/mid/high layers; ECMWF
        Open Data provides total only), visibility, precipitation, is_day.
        Decorated server-side with per-hour `sun_elevation_deg[]` /
        `sun_azimuth_deg[]` from the NOAA solar-position algorithm, and
        an `irradiance_j_per_cm2_h[]` sibling for the W/m² → J/cm²·h
        unit-conversion that PV yield formulas typically expect.

        **PV-modeling layer** (in-house compute, no extra upstream) — every
        forecast hour also carries `dni_wm2` / `dhi_wm2` (Erbs GHI
        decomposition), `ghi_clear_wm2` + `clearness_index` (Ineichen-Perez
        clear-sky — altitude + Linke turbidity), and `poa_wm2` + `aoi_deg`
        (Hay-Davies plane-of-array
        irradiance for the panel `tilt` / `azimuth`). Pass `?kwp=` to add
        `pv_power_w[]` (PVWatts-style AC power — NOCT cell temp + temperature
        coefficient + system losses) and a `daily[]` roll-up (`ghi_kwh_m2`,
        `poa_kwh_m2`, `peak_sun_hours`, `pv_energy_kwh`). A `pv_system` block
        echoes the parameters in effect.

        `meta.source` reports both upstreams as a compound label —
        `"knmi.10min + knmi-harmonie"` (NL), `"knmi.10min + dwd-icon-d2"` (DE),
        `"knmi.10min + ecmwf-ifs"` (rest of the world).

        **Scope**: `solar`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
        - { $ref: "#/components/parameters/Pc" }
        - { $ref: "#/components/parameters/Locatie" }
        - { $ref: "#/components/parameters/Hours" }
        - name: tilt
          in: query
          required: false
          schema: { type: number, minimum: 0, maximum: 90 }
          description: Panel tilt, degrees (0 = horizontal). Default ≈ |latitude|, capped 60°.
        - name: azimuth
          in: query
          required: false
          schema: { type: number, minimum: 0, maximum: 360 }
          description: Panel azimuth, compass degrees (180 = south, 90 = east). Default equator-facing.
        - name: kwp
          in: query
          required: false
          schema: { type: number, minimum: 0 }
          description: System size in kW-peak. When set, adds `pv_power_w[]` + daily `pv_energy_kwh`.
        - name: losses
          in: query
          required: false
          schema: { type: number, minimum: 0, maximum: 90 }
          description: System loss percentage applied to AC output (default 14, PVWatts-style).
        - name: albedo
          in: query
          required: false
          schema: { type: number, minimum: 0, maximum: 1 }
          description: Ground reflectance for the POA ground-reflected term (default 0.2).
      responses:
        "200":
          description: Current solar obs + hourly forecast bundle.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SolarEnvelope" }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }
        "503": { $ref: "#/components/responses/UpstreamDisabled" }

  /v1/water/observations:
    get:
      tags: [Water]
      summary: Latest Rijkswaterstaat water level at the nearest station
      description: |
        Latest water-level reading from the nearest curated Rijkswaterstaat
        station (~28 stations covering the Dutch coast + major rivers +
        IJsselmeer). Values are in centimetres; for inland and coastal
        stations the reference datum is typically NAP (Normaal Amsterdams
        Peil), but RWS may report `NVT` (not applicable) for some entries.

        Sourced from RWS DDAPI 2.0; proxied on demand with a 5-min cache.
        No historical archive yet — these are point-in-time observations.

        **Scope**: `water`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
      responses:
        "200":
          description: Water-level observation.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WaterObservationEnvelope" }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }

  /v1/water/observations/station/{code}:
    get:
      tags: [Water]
      summary: Latest Rijkswaterstaat water level at one named station
      description: |
        Same response shape as `/v1/water/observations`, but picks the
        station by its RWS DDAPI code (e.g. `lobith.bovenrijn.tolkamer`,
        `hoekvanholland`, `vlissingen`). Codes must be present in the
        curated registry — list them via `/v1/water/stations`.

        **Scope**: `water`.
      parameters:
        - name: code
          in: path
          required: true
          schema: { type: string, example: lobith.bovenrijn.tolkamer }
          description: RWS DDAPI 2.0 station code (lowercase, dot-separated).
      responses:
        "200":
          description: Water-level observation.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WaterObservationEnvelope" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "404": { $ref: "#/components/responses/NotFoundError" }
        "429": { $ref: "#/components/responses/RateLimitError" }

  /v1/water/forecast:
    get:
      tags: [Water]
      summary: Hourly tide-curve forecast (astronomical · harmonic regression)
      description: |
        Hourly astronomical tide-curve forecast for the nearest coastal /
        estuarine RWS station. Built **in-house by harmonic regression**:
        the 7 dominant tidal constituents (M2/S2/N2 semi-diurnal, K1/O1
        diurnal, M4/MS4 overtones) are least-squares-fit to ~35 days of the
        station's OWN observed water level (RWS DDAPI `OphalenWaarnemingen`),
        then the forward curve is synthesised — no external tide upstream
        or harmonic-constant table.

        Sea level in centimetres above the station's vertical reference
        (NAP on the Dutch coast), matching `/v1/water/observations` so
        forecast + observed compare directly. Includes a server-detected
        `extrema[]` array (high/low tides), per-constituent amplitude +
        phase, and `rms_residual_cm` — how much of the observed signal the
        astronomical fit left unexplained (largely meteorological surge).

        **Astronomical tide only** — meteorological surge is NOT included
        (it is not predictable from harmonics). Inland queries return
        `404 out_of_tidal_coverage`. Tier-capped hours via
        `forecast_max_hours`. `meta.source = rws-tidal-harmonic`.

        **Scope**: `water`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
        - { $ref: "#/components/parameters/Pc" }
        - { $ref: "#/components/parameters/Locatie" }
        - { $ref: "#/components/parameters/Hours" }
      responses:
        "200":
          description: Hourly tide forecast + extrema.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WaterForecastEnvelope" }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "404": { $ref: "#/components/responses/NotFoundError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }
        "503": { $ref: "#/components/responses/UpstreamDisabled" }

  /v1/water/stations:
    get:
      tags: [Water]
      summary: Curated Rijkswaterstaat water-level station registry
      description: |
        Lists the ~28 curated RWS water-level stations meteofeed serves.
        Covers Dutch coast (Vlissingen, Hoek van Holland, IJmuiden, Den
        Helder, Harlingen, Delfzijl, ...), Rhine/Waal (Lobith, Nijmegen,
        Tiel, ...), Meuse (Eijsden, Borgharen, Roermond, Lith), IJssel
        (Deventer, Olst, Zutphen, Kampen) and IJsselmeer (Urk).

        RWS publishes ~790 total water-height locations; this registry is
        a hand-picked subset matching what Waterinfo highlights on its
        national overview map. Add a station by code via the path-style
        endpoint even if it's not listed here — though only registry
        codes are accepted today (returns 404 otherwise).

        **Scope**: `water`.
      responses:
        "200":
          description: Station registry.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WaterStationsEnvelope" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }

  /v1/climate:
    get:
      tags: [Climate]
      summary: Monthly climate norms — KNMI (NL ~56y) · RMI (BE 74y) · DWD (DE to 1759)
      description: |
        Monthly mean / min / max for one metric at the nearest station,
        aggregated over the last N years of the archive. Coords resolve to
        the NEAREST station across four networks (closest wins):
          - **NL** → KNMI (≤60 km, `meta.source` =
            `knmi.etmaal`/`knmi.uurgegevens`).
          - **BE** → RMI synop daily archive (≤60 km, `meta.source` =
            `rmi.synop`, back to 1952) — so Brussels resolves to Uccle
            (~6 km) rather than the in-range KNMI border station.
          - **DE** → DWD CDC climate station (≤100 km, `meta.source` =
            `dwd.daily`, back to 1759).
          - **FR / PH / PL / DK / GB / IE** (+ anywhere else) → NOAA GHCN-Daily (≤200 km,
            `meta.source` = `ghcn.daily`, multi-decade depth).
        Station resolution is period-aware — it prefers a station whose
        archive covers the requested year window, falling back to nearest.

        Plays to meteofeed's moat: ~56 years of NL daily aggregates, ~74
        years of Belgian synop (RMI, back to 1952), plus the full DWD German
        daily archive (1,383 stations, the oldest back to **1759**). One CH
        GROUP BY returns the 12-row "what's normal for January here?" answer.

        Tier-gated archive depth via `climate_max_years`:
          - Free        — last 3 years (sampling window for evaluation)
          - Basic       — last 30 years (WMO standard climate normal)
          - Pro / Business — last 56 years (full archive, back to 1970)

        Over-cap `?years=N` truncates and adds a `years_capped` warning
        rather than erroring (same shape as `hours_capped` / `days_capped`).
        The warning carries a structured `details.upgrade_paths` array
        naming the next tier(s) and what each unlocks.

        Cached server-side for 24h — climate norms are slow-changing by
        definition, so daily staleness is invisible to consumers.

        **Scope**: `climate`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
        - { $ref: "#/components/parameters/Pc" }
        - { $ref: "#/components/parameters/Locatie" }
        - name: metric
          in: query
          required: false
          description: |
            Metric to aggregate. Defaults to `temperature_air`. Allowlist:
            `temperature_air`, `temperature_min`, `temperature_max`,
            `precipitation`, `humidity_relative`, `pressure`, `wind_speed`,
            `sunshine_duration`, `cloud_cover`.
          schema:
            type: string
            enum:
              - temperature_air
              - temperature_min
              - temperature_max
              - precipitation
              - humidity_relative
              - pressure
              - wind_speed
              - sunshine_duration
              - cloud_cover
            default: temperature_air
        - name: source
          in: query
          required: false
          description: |
            Long-archive source for NL coords: `knmi.etmaal` (daily
            aggregates, default, fastest for multi-decade windows) or
            `knmi.uurgegevens` (hourly, finer min/max but heavier). Ignored
            for BE / DE / FR / PH / PL / DK / GB / IE coords — the resolved country drives the
            effective source (`rmi.synop` for BE, `dwd.daily` for DE,
            `ghcn.daily` for FR/PH/PL/DK/GB/IE).
          schema:
            type: string
            enum: [knmi.etmaal, knmi.uurgegevens, dwd.daily, rmi.synop, ghcn.daily]
            default: knmi.etmaal
        - name: years
          in: query
          required: false
          description: |
            Number of trailing complete years to include in the aggregate.
            Tier-capped (Free: 3, Basic: 30, Pro / Business: 56). Excess
            requests truncate + add a `years_capped` warning.
          schema: { type: integer, minimum: 1, maximum: 56, default: 30, example: 30 }
      responses:
        "200":
          description: Monthly climate norms.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ClimateEnvelope" }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "404": { $ref: "#/components/responses/NotFoundError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }

  /v1/historical/day:
    get:
      tags: [Historical]
      summary: 24 hourly observations × all metrics for one calendar date
      description: |
        Meteoserver-shape `historie.php?dag=YYYYMMDD` parity: returns the
        full 24-hour picture for one day at the nearest KNMI station in
        ONE response, instead of the 20+ per-metric round-trips you'd
        need on `/v1/historical?metric=X`.

        Each `hours[]` entry carries a `metrics` object keyed by metric
        name (temperature_air, wind_speed, pressure, visibility,
        precipitation, sunshine_duration, dew_point, humidity_relative,
        irradiance_global, cloud_cover, temperature_min, …) plus a
        derived `flags` object: `mist` (visibility < 1000m), `regen`
        (precip > 0 and temp > 0), `sneeuw` (precip > 0 and temp ≤ 0),
        `ijs` (temp ≤ 0), `onweer` (always null — we don't ingest
        lightning data, see CLAUDE.md locked decision).

        Coverage: 1970-01-01 → yesterday. KNMI's hourly archive has a
        ~24-48h QC delay, so today's data isn't queryable yet.

        Tier cap on archive depth: `historical_max_days` per tier
        (Free 7d / Basic 90d / Pro 5y / Business 50y back from today).
        Requesting a date older than your tier allows returns
        `400 date_out_of_tier` with a hint at the upgrade boundary.

        **Scope**: `historical`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
        - { $ref: "#/components/parameters/Pc" }
        - { $ref: "#/components/parameters/Locatie" }
        - name: date
          in: query
          required: true
          schema: { type: string, format: date, example: "1980-01-15" }
          description: UTC calendar date YYYY-MM-DD. Must be in `[1970-01-01, yesterday]`.
        - name: source
          in: query
          required: false
          schema:
            type: string
            enum: [knmi.uurgegevens, knmi.etmaal]
            default: knmi.uurgegevens
          description: Long-archive source. Hourly (`knmi.uurgegevens`, default) or daily aggregates (`knmi.etmaal`).
      responses:
        "200":
          description: 24-hour day-shaped historical observation.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HistoricalDayEnvelope" }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "404": { $ref: "#/components/responses/NotFoundError" }
        "429": { $ref: "#/components/responses/RateLimitError" }

  /v1/historical/forecast:
    get:
      tags: [Historical]
      summary: Archived day-ahead forecast — what the model PREDICTED for a past date
      description: |
        Returns the **day-ahead forecast** our system captured for one past
        calendar date at the requested location — *what the model PREDICTED*
        for that day (distinct from `/v1/historical/day`, which returns what
        the station actually MEASURED).

        Served from our OWN self-archived **ECMWF Open Data** runs
        (`weather.forecast_archive`) — our own upstream, not a third-party
        aggregator. A nightly job snapshots
        each date's forecast as issued by the freshest run *before that day
        began* (typically a 6–30 h lead — see `issued_at` / `lead_hours`) and
        retains 730 days. The archive **accumulates going forward** from when it
        was switched on, so `archive_start` (returned in the response, and in the
        `date_before_archive` error) marks the earliest available date and
        deepens over time.

        Same hourly shape as `/v1/forecast`: `hourly_units` plus a
        column-oriented `hourly` block with temperature, dew point, relative
        humidity, apparent temperature, precipitation, cloud cover, pressure
        (`pressure_msl`), wind speed/direction/gusts (m/s), CAPE, shortwave
        radiation, `is_day`, and `weather_code`.

        Decorated server-side with parallel arrays so customers don't
        maintain their own lookup tables:
          - `condition[]` / `icon[]` / `description[]` from weather_code + is_day
          - `wind_speed_{kmh,knots,bft}[]`, `wind_gusts_{kmh,knots,bft}[]`
          - `pressure_{mmhg,inhg}[]`
          - `wind_compass[]` (Dutch 16-point: N, NNO, NO, ONO, …)

        Top-level `issued_at` (the issuing run, UTC) + `lead_hours` (run → start
        of the forecast day) describe the forecast's lead time — handy for
        verification.

        Use cases: forecast verification, post-mortem analysis of missed
        forecasts, insurance / actuarial modeling.

        Tier rules:
          - `historical_max_days` caps how far back the date can be
            (Free 7d / Basic 90d / Pro 5y / Business 50y).
          - `?model=` currently serves only `best_match` (the global ECMWF
            Open Data canonical); more models are archived over time. Still
            gated by the tier's `forecast_models`.
          - Floor = the dynamic `archive_start` (the archive accumulates
            forward); ceiling = yesterday (the day-ahead snapshot lands the
            day before the date occurs).

        Cache TTL: 24h (archived past forecasts are immutable).

        **Scope**: `historical`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
        - { $ref: "#/components/parameters/Pc" }
        - { $ref: "#/components/parameters/Locatie" }
        - name: date
          in: query
          required: true
          schema: { type: string, format: date, example: "2026-04-15" }
          description: UTC calendar date YYYY-MM-DD. Must be in `[archive_start, yesterday]` — `archive_start` is returned in responses and the `date_before_archive` error (the self-archive deepens going forward).
        - name: model
          in: query
          required: false
          schema:
            type: string
            enum: [best_match]
            default: best_match
          description: |
            Optional model picker. The self-archive currently serves only
            `best_match` (the global ECMWF Open Data canonical); more models
            are archived over time. Still gated by the tier's `forecast_models`.
      responses:
        "200":
          description: 24-hour archived hourly forecast for the given date.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HistoricalForecastEnvelope" }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "404": { $ref: "#/components/responses/NotFoundError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "503": { $ref: "#/components/responses/UpstreamDisabled" }

  /v1/historical:
    get:
      tags: [Historical]
      summary: Bucketed historical observations (NL/KNMI · DE/DWD · BE/RMI synop)
      description: |
        Time-series for one metric over (from, to) at the nearest station.
        Coords resolve by country:
          - **NL** → KNMI (`weather.observations`, bucketed, back to 1970).
          - **DE** → DWD CDC daily archive (`dwd.daily`, one point/day, back
            to 1759). Period-aware station resolution.
          - **BE** → RMI synop daily archive (`rmi.synop`, one point/day,
            back to 1952) when an RMI synop station is closer than the
            nearest KNMI station AND the metric is in the daily vocabulary
            (`temperature_air` + `temperature_air_min`/`max`,
            `temperature_ground_min`, `pressure`, `humidity_relative`,
            `wind_speed`, `wind_gust`, `cloud_cover`, `precipitation`,
            `sunshine_duration`). Other metrics (e.g. `wind_direction`) fall
            through to the nearest KNMI station. Period-aware station
            resolution; archive floor 1952-01-01 (`clamped_to_archive_start`;
            a window entirely pre-1952 → `date_before_archive`). Full archive
            depth — no per-request range cap beyond the tier `historical_max_days`.
          - **FR / PH / PL / DK / GB / IE** (+ anywhere else) → NOAA GHCN-Daily (`ghcn.daily`,
            one point/day, ≤200 km, multi-decade depth). Period-aware resolution.

        Tier-capped maximum range via `historical_max_days` (Free 7d, Basic
        365d, Pro/Business unlimited). Bucket size is auto-derived from the
        requested range unless `bucket` is specified.

        Source defaults to `knmi.uurgegevens` (hourly). Use
        `?source=knmi.etmaal` for daily aggregates. `meta.source` reflects
        the upstream actually used (`knmi.uurgegevens` / `knmi.etmaal` /
        `dwd.daily` / `rmi.synop` / `ghcn.daily`).

        **Scope**: `historical`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
        - { $ref: "#/components/parameters/Metric" }
        - { $ref: "#/components/parameters/From" }
        - { $ref: "#/components/parameters/To" }
        - { $ref: "#/components/parameters/Bucket" }
        - { $ref: "#/components/parameters/SourceHistorical" }
      responses:
        "200":
          description: Time-series of (ts, value) points.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HistoricalEnvelope" }
        "400":
          description: |
            Invalid request. Common codes:
            - `range_too_large` — exceeds your tier's max-days cap; `details.suggested_from` carries a usable narrower bound
            - `invalid_coords` / `missing_required_param` / `invalid_source`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "404": { $ref: "#/components/responses/NotFoundError" }
        "429": { $ref: "#/components/responses/RateLimitError" }

  /v1/sun:
    get:
      tags: [Sun]
      summary: Sunrise / sunset / civil-nautical-astronomical twilight
      description: |
        Pure compute (PHP `date_sun_info`, equivalent to NOAA's solar calculator).
        Zero upstream dependency — works even if every other service is down.
        Polar-day / polar-night returned as `"always_above_horizon"` /
        `"always_below_horizon"` strings instead of timestamps when applicable.

        **Scope**: `sun`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
        - in: query
          name: date
          required: false
          description: ISO 8601 date (YYYY-MM-DD, UTC). Defaults to today.
          schema: { type: string, format: date, example: "2026-05-17" }
      responses:
        "200":
          description: Sun ephemeris for the (lat, lng, date) tuple.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SunEnvelope" }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }

  /v1/radar/precip/latest.png:
    get:
      tags: [Radar]
      summary: Most-recent precipitation radar tile (NL/KNMI · DE/DWD · BE/OPERA)
      description: |
        Returns the freshest 5-min radar composite as a PNG. We walk back
        from now (5-min floor) up to 6 frames looking for the first one in
        our cache.

        `?region=` selects the composite (default `NL`):
          - **NL** — KNMI radar, 700×765 PNG.
          - **DE** — DWD RADOLAN-RY composite, 900×900 PNG on the RADOLAN
            polar-stereographic national grid (north-up). Transparent
            where there's no echo / outside coverage, so it overlays
            cleanly on a base map.
          - **BE** — EUMETNET OPERA reflectivity composite (CC BY 4.0),
            cropped to a Belgium window from the pan-EU 1 km LAEA grid;
            dBZ colourised to the shared precip ramp, transparent where dry.
          - **FR / PL / DK / GB / IE / ES / PT / LU / CH / IT / CZ / AT / SK / SI / HR / HU / RO / BG / GR / LT / LV / EE / FI / SE / NO / IS / MT / RS / ME / BA** — the same EUMETNET OPERA
            composite cropped to a France / Poland / Denmark / UK / Ireland / Spain /
            Portugal / Luxembourg / Switzerland / Italy / Czechia / Austria / Slovakia / Slovenia / Croatia / Hungary / Romania / Bulgaria / Greece / Lithuania / Latvia / Estonia / Finland / Sweden / Norway / Iceland / Malta / Serbia / Montenegro / Bosnia & Herzegovina window.
            (CZ + AT + SK + SI + HR + HU + RO + LT + LV + EE + FI + SE + NO + IS + MT are all 100% covered — each runs its own radars; IT + BG + GR are foreign-radar
            spillover only — IT/BG strong over the north + coasts, sparse inland/south; GR even sparser — only the
            west + Ionian (Italian-radar spillover), with Athens, the Aegean + Crete uncovered — since OPERA open
            data carries no Italian, Bulgarian or Greek national radars. RS (not an OPERA member) is covered by its neighbours' radars (HU/RO/HR) at 14/15 — only the far-S Vranje corner, by non-OPERA N.Macedonia/Bulgaria, is sparse. ME (not a member either) is fully covered by Croatia's Dalmatian-coast radars — 10/10. BA (not a member) is fully covered by its HR + RS neighbours — 12/12.)
          - **PH** — NASA GIBS IMERG satellite precipitation (satellite-
            derived, NOT ground radar), ~30-min cadence.

        Response is `image/png` bytes (NOT JSON). Cache-Control:
        `public, max-age=86400, immutable`. The served frame's timestamp
        is in `X-Radar-Ts` (12-digit UTC `YYYYMMDDHHMM`); the region in
        `X-Radar-Region`.

        **Scope**: `radar`.
      parameters:
        - in: query
          name: region
          required: false
          description: Radar composite — `NL` (KNMI), `DE` (DWD RADOLAN), `BE`/`FR`/`PL`/`DK`/`GB`/`IE`/`ES`/`PT`/`LU`/`CH`/`IT`/`CZ`/`AT`/`SK`/`SI`/`HR`/`HU`/`RO`/`BG`/`GR`/`LT`/`LV`/`EE`/`FI`/`SE`/`NO`/`IS`/`MT`/`RS`/`ME`/`BA` (EUMETNET OPERA) or `PH`/`CY` (NASA GIBS IMERG satellite precipitation — satellite-derived, not ground radar).
          schema: { type: string, enum: [NL, DE, BE, FR, PL, DK, GB, IE, ES, PT, LU, CH, IT, CZ, AT, SK, SI, HR, HU, RO, BG, GR, LT, LV, EE, FI, SE, NO, IS, MT, CY, RS, ME, BA, UA, MD, AL, MK, XK, BY, TR, RU, AD, MC, LI, SM, VA, JP, KR, TW, TH, VN, MY, SG, ID, IN, PK, BD, LK, NP, CN, SA, IR, IQ, IL, QA, BH, OM, JO, EG, MA, DZ, TN, LY, ZA, NG, GH, SN, CI, CM, MZ, ET, US, CA, MX, CU, DO, GT, CR, PA, BR, AR, CL, CO, PE, EC, VE, UY, BO, PY, KZ, UZ, TM, KG, TJ, GE, AM, AZ, AU, NZ, JM, TT, BS, BB, HT, HN, NI, SV, BZ, GY, SR, MN, AF, FJ, PG, SB, VU, AE, KW, LB, KE, TZ, AO, NA, ZM, ZW, UG, BW, MG, RW, MW, GM, GW, GN, SL, LR, TG, BJ, GQ, GA, CG, CD, ML, NE, TD, BF, CF, MR, SD, SS, SO, ER, DJ, MM, LA, KH, BT, BN, TL, ST, KM, AG, DM, LC, GD, VC, KN, NR, TV, MV, MU, SC, CV, PW, FM, MH, TO, WS, KI, SY, KP, YE, PS, LS, SZ, BI, PR, VI, GP, MQ, AW, CW, SX, BM, FO, GI, JE, GG, IM, RE, YT, NC, GU, AS, PF, MP, HK, MO, EH, GF, FK, GL, PH], default: NL }
      responses:
        "200":
          description: PNG image.
          headers:
            X-Radar-Ts:
              description: Resolved 12-digit UTC timestamp (YYYYMMDDHHMM) of the served frame.
              schema: { type: string, pattern: '^\d{12}$', example: "202605171625" }
            X-Radar-Region:
              description: Region of the served composite (NL or DE).
              schema: { type: string, enum: [NL, DE, BE, FR, PL, DK, GB, IE, ES, PT, LU, CH, IT, CZ, AT, SK, SI, HR, HU, RO, BG, GR, LT, LV, EE, FI, SE, NO, IS, MT, CY, RS, ME, BA, UA, MD, AL, MK, XK, BY, TR, RU, AD, MC, LI, SM, VA, JP, KR, TW, TH, VN, MY, SG, ID, IN, PK, BD, LK, NP, CN, SA, IR, IQ, IL, QA, BH, OM, JO, EG, MA, DZ, TN, LY, ZA, NG, GH, SN, CI, CM, MZ, ET, US, CA, MX, CU, DO, GT, CR, PA, BR, AR, CL, CO, PE, EC, VE, UY, BO, PY, KZ, UZ, TM, KG, TJ, GE, AM, AZ, AU, NZ, JM, TT, BS, BB, HT, HN, NI, SV, BZ, GY, SR, MN, AF, FJ, PG, SB, VU, AE, KW, LB, KE, TZ, AO, NA, ZM, ZW, UG, BW, MG, RW, MW, GM, GW, GN, SL, LR, TG, BJ, GQ, GA, CG, CD, ML, NE, TD, BF, CF, MR, SD, SS, SO, ER, DJ, MM, LA, KH, BT, BN, TL, ST, KM, AG, DM, LC, GD, VC, KN, NR, TV, MV, MU, SC, CV, PW, FM, MH, TO, WS, KI, SY, KP, YE, PS, LS, SZ, BI, PR, VI, GP, MQ, AW, CW, SX, BM, FO, GI, JE, GG, IM, RE, YT, NC, GU, AS, PF, MP, HK, MO, EH, GF, FK, GL, PH], example: "DE" }
            Cache-Control: { schema: { type: string, example: "public, max-age=86400, immutable" } }
            X-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            image/png:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "404":
          description: |
            No frames available in the last 30 minutes (upstream catching up
            or radar feed degraded). `error.code = radar_no_recent_frames`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }

  /v1/radar/precip/{ts}.png:
    get:
      tags: [Radar]
      summary: Specific 5-min radar tile (NL/KNMI · DE/DWD · BE/OPERA)
      description: |
        Returns the PNG for the given 5-min frame. `ts` is a 12-digit
        `YYYYMMDDHHMM` UTC timestamp; minute must be a multiple of 5
        (`…00`, `…05`, `…10`, …).

        `?region=` selects the composite (default `NL`): NL → KNMI
        (700×765, ~17-month retention); DE → DWD RADOLAN-RY (900×900
        national grid, ~18-month retention); BE / FR / PL / DK / GB / IE → EUMETNET OPERA
        reflectivity composite cropped to each country (CC BY 4.0, ~18-month
        local retention); PH → NASA GIBS IMERG satellite precipitation.

        Response is `image/png` bytes. Cache-Control:
        `public, max-age=86400, immutable`.

        **Scope**: `radar`.
      parameters:
        - in: path
          name: ts
          required: true
          description: 12-digit UTC timestamp `YYYYMMDDHHMM` on a 5-min boundary.
          schema: { type: string, pattern: '^\d{12}$', example: "202605171625" }
        - in: query
          name: region
          required: false
          description: Radar composite — `NL` (KNMI), `DE` (DWD RADOLAN), `BE`/`FR`/`PL`/`DK`/`GB`/`IE`/`ES`/`PT`/`LU`/`CH`/`IT`/`CZ`/`AT`/`SK`/`SI`/`HR`/`HU`/`RO`/`BG`/`GR`/`LT`/`LV`/`EE`/`FI`/`SE`/`NO`/`IS`/`MT`/`RS`/`ME`/`BA` (EUMETNET OPERA) or `PH`/`CY` (NASA GIBS IMERG satellite precipitation — satellite-derived, not ground radar).
          schema: { type: string, enum: [NL, DE, BE, FR, PL, DK, GB, IE, ES, PT, LU, CH, IT, CZ, AT, SK, SI, HR, HU, RO, BG, GR, LT, LV, EE, FI, SE, NO, IS, MT, CY, RS, ME, BA, UA, MD, AL, MK, XK, BY, TR, RU, AD, MC, LI, SM, VA, JP, KR, TW, TH, VN, MY, SG, ID, IN, PK, BD, LK, NP, CN, SA, IR, IQ, IL, QA, BH, OM, JO, EG, MA, DZ, TN, LY, ZA, NG, GH, SN, CI, CM, MZ, ET, US, CA, MX, CU, DO, GT, CR, PA, BR, AR, CL, CO, PE, EC, VE, UY, BO, PY, KZ, UZ, TM, KG, TJ, GE, AM, AZ, AU, NZ, JM, TT, BS, BB, HT, HN, NI, SV, BZ, GY, SR, MN, AF, FJ, PG, SB, VU, AE, KW, LB, KE, TZ, AO, NA, ZM, ZW, UG, BW, MG, RW, MW, GM, GW, GN, SL, LR, TG, BJ, GQ, GA, CG, CD, ML, NE, TD, BF, CF, MR, SD, SS, SO, ER, DJ, MM, LA, KH, BT, BN, TL, ST, KM, AG, DM, LC, GD, VC, KN, NR, TV, MV, MU, SC, CV, PW, FM, MH, TO, WS, KI, SY, KP, YE, PS, LS, SZ, BI, PR, VI, GP, MQ, AW, CW, SX, BM, FO, GI, JE, GG, IM, RE, YT, NC, GU, AS, PF, MP, HK, MO, EH, GF, FK, GL, PH], default: NL }
      responses:
        "200":
          description: PNG image.
          headers:
            X-Radar-Ts: { schema: { type: string, pattern: '^\d{12}$' } }
            X-Radar-Region: { schema: { type: string, enum: [NL, DE, BE, FR, PL, DK, GB, IE, ES, PT, LU, CH, IT, CZ, AT, SK, SI, HR, HU, RO, BG, GR, LT, LV, EE, FI, SE, NO, IS, MT, CY, RS, ME, BA, UA, MD, AL, MK, XK, BY, TR, RU, AD, MC, LI, SM, VA, JP, KR, TW, TH, VN, MY, SG, ID, IN, PK, BD, LK, NP, CN, SA, IR, IQ, IL, QA, BH, OM, JO, EG, MA, DZ, TN, LY, ZA, NG, GH, SN, CI, CM, MZ, ET, US, CA, MX, CU, DO, GT, CR, PA, BR, AR, CL, CO, PE, EC, VE, UY, BO, PY, KZ, UZ, TM, KG, TJ, GE, AM, AZ, AU, NZ, JM, TT, BS, BB, HT, HN, NI, SV, BZ, GY, SR, MN, AF, FJ, PG, SB, VU, AE, KW, LB, KE, TZ, AO, NA, ZM, ZW, UG, BW, MG, RW, MW, GM, GW, GN, SL, LR, TG, BJ, GQ, GA, CG, CD, ML, NE, TD, BF, CF, MR, SD, SS, SO, ER, DJ, MM, LA, KH, BT, BN, TL, ST, KM, AG, DM, LC, GD, VC, KN, NR, TV, MV, MU, SC, CV, PW, FM, MH, TO, WS, KI, SY, KP, YE, PS, LS, SZ, BI, PR, VI, GP, MQ, AW, CW, SX, BM, FO, GI, JE, GG, IM, RE, YT, NC, GU, AS, PF, MP, HK, MO, EH, GF, FK, GL, PH], example: "DE" } }
            Cache-Control: { schema: { type: string, example: "public, max-age=86400, immutable" } }
            X-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            image/png:
              schema: { type: string, format: binary }
        "400":
          description: |
            `ts` is malformed (not 12 digits) or off-boundary (minute not a
            multiple of 5). `error.code = invalid_timestamp`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "404":
          description: |
            Frame outside our 17-month retention window or never published.
            `error.code = radar_frame_not_found`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }

  /v1/marine/observations:
    get:
      tags: [Maritime]
      summary: Maritime — nearest KNMI offshore platform observation
      description: |
        Picks the nearest KNMI offshore platform (North Sea oil/gas rig or
        coastal measurement tower) to the requested lat/lng and returns its
        current wind, pressure, temperature and humidity readings.

        13 offshore stations are tagged with `is_offshore: true` in the
        registry (`/v1/stations`); a haversine search picks the closest one
        from that subset (not the closest of all KNMI stations).

        **Scope**: `marine`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
        - { $ref: "#/components/parameters/Pc" }
        - { $ref: "#/components/parameters/Locatie" }
      responses:
        "200":
          description: Marine surface observation.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MarineObservationEnvelope" }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }

  /v1/marine/forecast:
    get:
      tags: [Maritime]
      summary: Maritime — wave-height + period + direction forecast (ECMWF Open Data wave model)
      description: |
        Tier-capped hourly wave forecast served direct from the ECMWF Open
        Data wave model (CC-BY-4.0, full commercial use), at 0.5° downsample.
        Coverage spans the EU seas plus regional sea crops worldwide — the
        Mediterranean, the Gulf, the South + East Asian seas, the Caribbean,
        both American coasts, Oceania and more — wherever a coastal country
        in the coverage list has been wired up.

        A coordinate outside every wave crop (mid-ocean, or an uncovered
        coast) returns `503 endpoint_unavailable`.

        Variables returned:
          - `wave_height`           — significant wave height Hm0 (m)
          - `wave_direction`        — mean wave direction (° from)
          - `wave_period`           — mean wave period (s)
          - `wave_peak_period`      — peak wave period from 1D spectrum (s)
          - `wave_mean_zero_period` — mean zero-crossing wave period (s)

        Native step cadence is 3-hourly; consumers should treat the
        `time[]` array as the source of truth for sampling. Land cells
        within the bbox return null per-field — caller should treat as
        "no waves here" rather than zero waves.

        **NOT INCLUDED** (vs the previous upstream): no
        `swell_*`-decomposition, no `sea_surface_temperature`, no
        `ocean_current_*`. ECMWF Open Data wave product doesn't ship the
        wind-wave / swell partitioning or SST.
        `sea_level_height_msl` is unavailable here — see
        `/v1/water/forecast` (in-house astronomical tide).

        **Scope**: `marine`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
        - { $ref: "#/components/parameters/Pc" }
        - { $ref: "#/components/parameters/Locatie" }
        - { $ref: "#/components/parameters/Hours" }
      responses:
        "200":
          description: Hourly marine forecast.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MarineForecastEnvelope" }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }
        "503": { $ref: "#/components/responses/UpstreamDisabled" }
  /v1/warnings:
    get:
      tags: [Warnings]
      summary: Severe-weather warnings (NL · KNMI 48h cube · DE · DWD CAP · BE · MeteoAlarm)
      description: |
        Region-aware severe-weather warnings. All regions return the same
        envelope shape — `report_info`, `active_warnings[]`,
        `by_phenomenon{}` — so client code branches only on `region`.

          - **`region=NL`** (default): KNMI Data Platform
            `waarschuwingen_nederland_48h` dataset. `?format=xml` returns
            the raw 2-3 MB KNMI proprietary cube XML byte-for-byte.
            Cached 5 min.

          - **`region=DE`** (Phase 13e): DWD Open Data CAP-1.2 district-
            level feed (`DISTRICT_EVENT_STAT/...LATEST...zip`). DWD event
            keywords mapped to our standard phenomenon taxonomy
            (LT/FX/IC/RR/VV/TX) where unambiguous (else `phenomenon_id=null`
            with raw `event` preserved). CAP severities → numeric 1..4
            (Minor/Moderate/Severe/Extreme) + color slug. `?format=xml`
            returns 400 unsupported_format. Cached 5 min.

          - **`region=BE`** (Phase 14b): MeteoAlarm (EUMETNET) Belgium
            feed — RMI's warnings republished as Atom-wrapped CAP-1.2.
            Same phenomenon mapping; severity follows MeteoAlarm's
            awareness convention (Minor→green, Moderate→yellow,
            Severe→orange, Extreme→red). Each warning carries an
            `emma_id` (MeteoAlarm area code) in its `areas[]`.
            `?format=xml` returns 400 unsupported_format. Cached 5 min.

        An empty `active_warnings[]` (alert_count 0) is the normal
        "no active warnings" state, not an error.

        **Scope**: `warnings`.
      parameters:
        - in: query
          name: region
          required: false
          description: Country region — `NL` (KNMI), `DE` (DWD), `BE`/`FR`/`PL`/`DK`/`GB`/`IE`/`ES`/`PT`/`LU`/`CH`/`IT`/`CZ`/`AT`/`SK`/`SI`/`HR`/`HU`/`RO`/`BG`/`GR`/`LT`/`LV`/`EE`/`FI`/`SE`/`NO`/`IS`/`MT`/`CY`/`RS`/`ME`/`BA` (MeteoAlarm).
          schema: { type: string, enum: [NL, DE, BE, FR, PL, DK, GB, IE, ES, PT, LU, CH, IT, CZ, AT, SK, SI, HR, HU, RO, BG, GR, LT, LV, EE, FI, SE, NO, IS, MT, CY, RS, ME, BA, MD, AD], default: NL }
      responses:
        "200":
          description: Latest warnings document.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WarningsEnvelope" }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }

  /v1/typhoons:
    get:
      tags: [Typhoons]
      summary: Active tropical cyclones worldwide (typhoons / hurricanes / cyclones · GDACS)
      description: |
        Active tropical cyclones worldwide, sourced from **GDACS** (UN-OCHA
        + EC Joint Research Centre — free reuse). Each entry carries name,
        alert level (green/orange/red), Saffir–Simpson `category`, max
        sustained wind (km/h + kt), current position, affected countries,
        and a `source_url` linking to the GDACS map for the full track +
        forecast cone.

        "Typhoon" is the NW-Pacific term (the Philippines' headline hazard);
        the same feed covers Atlantic hurricanes and Indian-Ocean cyclones,
        so the response lists every active TC globally. An optional
        `?country=` filter (ISO-3166 alpha-2/3 or name substring) narrows to
        storms affecting one country.

        An empty list (`active_count: 0`) is the normal calm-season state,
        not an error. Live proxy, cached 15 min.

        **Scope**: `typhoons`.
      parameters:
        - in: query
          name: country
          required: false
          description: Filter to cyclones affecting this country (ISO-3166 alpha-2/3 or name substring, e.g. `PH`, `PHL`, `Philippines`).
          schema: { type: string }
      responses:
        "200":
          description: Active tropical cyclones (possibly empty).
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TyphoonEnvelope" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }

  /v1/space-weather:
    get:
      tags: [Space Weather]
      summary: Global space-weather state (Kp · storm scales · solar wind · X-ray flares · NOAA SWPC)
      description: |
        Current global space-weather conditions from **NOAA SWPC** (US
        Government, public domain): the planetary **Kp index** (current +
        recent series + a 3-day forecast), the NOAA **G/S/R storm scales**
        (geomagnetic / solar-radiation / radio-blackout), real-time **solar
        wind** (speed, density, Bz, Bt) and the latest **GOES X-ray flare**
        class — plus a plain-English `storm_summary`.

        Global (not location-specific). For aurora visibility at a place use
        `/v1/aurora`. CH-first (swept every 5 min), on-demand SWPC fetch as a
        fallback.

        **Scope**: `space_weather`.
      responses:
        "200":
          description: Current global space-weather state.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      observed_at: { type: string, format: date-time }
                      kp:
                        type: object
                        properties:
                          current: { type: number, nullable: true, example: 5 }
                          state: { type: string, nullable: true, example: minor-storm-G1 }
                          max_24h: { type: number, nullable: true }
                          series: { type: array, items: { type: object } }
                          forecast: { type: array, items: { type: object } }
                      solar_wind:
                        type: object
                        nullable: true
                        properties:
                          speed_kms: { type: number, nullable: true }
                          density_pcc: { type: number, nullable: true }
                          bz_nt: { type: number, nullable: true }
                          bt_nt: { type: number, nullable: true }
                          observed_at: { type: string, format: date-time, nullable: true }
                      xray: { type: object, nullable: true }
                      scales: { type: object }
                      storm_summary: { type: string }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }

  /v1/aurora:
    get:
      tags: [Space Weather]
      summary: Aurora forecast + visibility at a location (NOAA OVATION)
      description: |
        Location-specific aurora forecast from the **NOAA OVATION** model: the
        `probability` (%) of visible aurora **directly overhead** at
        `forecast_time` (~30-40 min ahead), with a `band` label. Because aurora
        is only visible once the sky is dark, the response also carries our own
        solar-elevation compute (`sun_elevation_deg`, `darkness`, `is_dark`) and
        a plain-English `viewing` verdict, plus the current planetary `kp` for
        context.

        Accepts `?lat=&lng=` (or `?pc=` / `?locatie=`). CH-first (the swept
        OVATION 1° grid, refreshed every 5 min), on-demand fetch as a fallback.

        **Scope**: `space_weather`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
      responses:
        "200":
          description: Aurora forecast + visibility verdict for the coordinate.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      lat: { type: number }
                      lng: { type: number }
                      probability: { type: integer, example: 59 }
                      band: { type: string, example: high }
                      forecast_time: { type: string, format: date-time, nullable: true }
                      observation_time: { type: string, format: date-time, nullable: true }
                      kp: { type: number, nullable: true }
                      sun_elevation_deg: { type: number, example: -8.4 }
                      darkness: { type: string, example: nautical_twilight }
                      is_dark: { type: boolean }
                      viewing: { type: string }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }

  /v1/earthquakes:
    get:
      tags: [Earthquakes]
      summary: Recent global earthquakes (USGS), optionally near a location
      description: |
        Recent earthquakes from **USGS** (US Government, public domain) —
        magnitude, place, depth, tsunami flag, felt reports, alert level. The
        snapshot covers **M2.5+ over the past week** plus **significant quakes
        over the past month** (so a major event from >1 week ago still appears).

        Pass `lat`+`lng` (or `?pc=` / `?locatie=`) + `radius` (km) to get the
        nearest quakes, each with a `distance_km`, sorted by distance. Without a
        location you get the most recent globally, sorted by time. `min_mag`
        filters by magnitude; `limit` caps the list.

        CH-first (swept every 5 min), on-demand USGS feed as a fallback.

        **Scope**: `earthquakes`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
        - in: query
          name: radius
          required: false
          description: Search radius in km from lat/lng (default 1000, max 20000). Ignored without lat+lng.
          schema: { type: number, default: 1000, minimum: 1, maximum: 20000 }
        - in: query
          name: min_mag
          required: false
          description: Minimum magnitude (e.g. 4.5 for significant quakes only).
          schema: { type: number }
        - in: query
          name: limit
          required: false
          description: Max number of quakes returned (default 50, max 500).
          schema: { type: integer, default: 50, minimum: 1, maximum: 500 }
      responses:
        "200":
          description: Recent earthquakes (possibly empty).
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      count: { type: integer }
                      earthquakes:
                        type: array
                        items:
                          type: object
                          properties:
                            id: { type: string }
                            time: { type: string, format: date-time }
                            magnitude: { type: number, example: 4.6 }
                            mag_type: { type: string, nullable: true, example: mww }
                            class: { type: string, example: light }
                            place: { type: string, example: "133 km WNW of Fangale'ounga, Tonga" }
                            location:
                              type: object
                              properties:
                                lat: { type: number }
                                lng: { type: number }
                            depth_km: { type: number, nullable: true }
                            tsunami: { type: boolean }
                            felt_reports: { type: integer, nullable: true }
                            max_intensity_mmi: { type: number, nullable: true }
                            alert: { type: string, nullable: true, example: green }
                            significance: { type: integer, nullable: true }
                            url: { type: string }
                            distance_km: { type: number, nullable: true }
                      search: { type: object }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }

  /v1/tsunamis:
    get:
      tags: [Tsunamis]
      summary: Current tsunami bulletins (NOAA/NWS warning centers)
      description: |
        Current bulletins from the two **NOAA/NWS tsunami warning centers**
        (NTWC Palmer AK + PTWC Honolulu, tsunami.gov — US Government, public
        domain). Each bulletin carries a `category` — `information` means an
        earthquake occurred but poses **no tsunami threat** (the normal calm
        state) while `warning` / `advisory` / `watch` are real threats
        (`is_threat: true`) — plus the source quake's position + magnitude and
        CAP/bulletin links. `active_count` counts only actual threats; 0 with
        informational entries is the normal state, not an error.

        CH-first (swept every 5 min), on-demand fetch as a fallback.

        **Scope**: `tsunamis`.
      responses:
        "200":
          description: Current tsunami bulletins (threats counted separately).
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      active_count: { type: integer, example: 0 }
                      alerts:
                        type: array
                        items:
                          type: object
                          properties:
                            id: { type: string }
                            center: { type: string, example: ptwc }
                            category: { type: string, example: information }
                            is_threat: { type: boolean }
                            title: { type: string }
                            issued: { type: string, format: date-time, nullable: true }
                            magnitude: { type: number, nullable: true }
                            source_quake:
                              type: object
                              properties:
                                lat: { type: number, nullable: true }
                                lng: { type: number, nullable: true }
                            summary: { type: string }
                            cap_url: { type: string, nullable: true }
                            bulletin_url: { type: string, nullable: true }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }

  /v1/volcanoes:
    get:
      tags: [Volcanoes]
      summary: Volcanoes at an elevated alert level (USGS), optionally near a location
      description: |
        USGS-monitored volcanoes currently **above NORMAL** — `alert_level`
        (ADVISORY / WATCH / WARNING) + the **aviation color code** (YELLOW /
        ORANGE / RED), with position, region, synopsis and the issuing
        observatory. Coverage: the USGS-monitored volcanoes (Alaska — the most
        aviation-critical arc — Hawaiʻi, Cascades, California, Yellowstone,
        Marianas; honestly labelled US-monitored, not global).

        Pass `lat`+`lng` (or `?pc=` / `?locatie=`) + `radius` (km) to filter to
        nearby volcanoes, each with a `distance_km`. Sorted by alert severity.
        An empty list = nothing elevated. CH-first (swept every 5 min),
        on-demand USGS HANS fetch as a fallback.

        **Scope**: `volcanoes`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
        - in: query
          name: radius
          required: false
          description: Filter radius in km from lat/lng (default 1000, max 20000). Ignored without lat+lng.
          schema: { type: number, default: 1000, minimum: 1, maximum: 20000 }
      responses:
        "200":
          description: Elevated volcanoes (possibly empty).
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      elevated_count: { type: integer, example: 4 }
                      volcanoes:
                        type: array
                        items:
                          type: object
                          properties:
                            vnum: { type: string, example: "311120" }
                            name: { type: string, example: Great Sitkin }
                            location:
                              type: object
                              properties:
                                lat: { type: number, nullable: true }
                                lng: { type: number, nullable: true }
                            region: { type: string, nullable: true }
                            alert_level: { type: string, example: WATCH }
                            aviation_color_code: { type: string, example: ORANGE }
                            synopsis: { type: string, nullable: true }
                            observatory: { type: string, nullable: true }
                            since: { type: string, format: date-time, nullable: true }
                            distance_km: { type: number, nullable: true }
                      search: { type: object }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }

  /v1/avalanche:
    get:
      tags: [Avalanche]
      summary: Avalanche danger at a coordinate — EUREGIO bulletins (avalanche.report)
      description: |
        Avalanche danger for an Alpine coordinate from the **EUREGIO** bulletins —
        **Tyrol** (AT-07), **South Tyrol** (IT-32-BZ) and **Trentino** (IT-32-TN),
        published by avalanche.report under CC BY 4.0. The point is resolved to its
        EAWS micro-region (CC0 geometry) and the current bulletin's **danger rating**
        (EAWS scale **1 low – 5 very high**, by elevation band + time of day) plus
        **avalanche problems** (type, aspects, size, frequency) are returned.

        Coverage is the three EUREGIO regions only (a clean single-licence subset of
        the wider EAWS network); outside it → `404 outside_avalanche_coverage`.
        Avalanche bulletins are a **winter product** (~Nov–May) — off-season a covered
        location returns `current: false`. CH-first (swept every 30 min), on-demand
        avalanche.report fetch as a fallback.

        **Scope**: `avalanche`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
      responses:
        "200":
          description: Avalanche danger (or an off-season / no-bulletin state) for a covered location.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      location:
                        type: object
                        properties:
                          lat: { type: number }
                          lng: { type: number }
                      covered: { type: boolean, example: true }
                      current: { type: boolean, example: true }
                      region: { type: string, example: AT-07 }
                      region_name: { type: string, example: Tyrol }
                      micro_region: { type: string, example: AT-07-29-02 }
                      micro_region_name: { type: string, nullable: true, example: Lienz Dolomites }
                      valid_from: { type: string, format: date-time, nullable: true }
                      valid_to: { type: string, format: date-time, nullable: true }
                      max_danger_level: { type: integer, nullable: true, example: 3 }
                      danger:
                        type: array
                        items:
                          type: object
                          properties:
                            level: { type: integer, nullable: true, example: 3 }
                            rating: { type: string, nullable: true, example: considerable }
                            elevation: { type: string, example: "above treeline" }
                            time_period: { type: string, example: all_day }
                      problems:
                        type: array
                        items:
                          type: object
                          properties:
                            type: { type: string, example: persistent_weak_layers }
                            aspects: { type: array, items: { type: string }, example: [N, NW, NE] }
                            size: { type: integer, nullable: true, example: 2 }
                            frequency: { type: string, nullable: true, example: many }
                            stability: { type: string, nullable: true, example: poor }
                            elevation: { type: string, example: "above treeline" }
                            time_period: { type: string, example: all_day }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "404": { $ref: "#/components/responses/NotFoundError" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }

  /v1/marine/sst:
    get:
      tags: [Maritime]
      summary: Daily global sea-surface temperature + anomaly + sea ice (NOAA OISST)
      description: |
        Daily global **sea-surface temperature** from NOAA OISST v2.1 (0.25°,
        US Government public domain): `sst_c`, the **anomaly vs the 1971-2000
        climatology** (`anomaly_c` — the marine-heatwave signal), and the
        sea-ice concentration where present. The value is the **nearest ocean
        cell** to your coordinate (with a `distance_km`); an inland point with
        no ocean within ~100 km returns **404 `no_sea_nearby`**.

        Recent days are the *preliminary* OISST product (finals lag ~2 weeks).
        Accepts `?lat=&lng=` (or `?pc=` / `?locatie=`).

        **Scope**: `marine`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
      responses:
        "200":
          description: Sea-surface temperature at the nearest ocean cell.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      lat: { type: number }
                      lng: { type: number }
                      date: { type: string, format: date, example: "2026-06-05" }
                      sst_c: { type: number, example: 15.05 }
                      anomaly_c: { type: number, nullable: true, example: 2.81 }
                      climatology_period: { type: string, example: "1971-2000" }
                      sea_ice_pct: { type: number, nullable: true }
                      cell:
                        type: object
                        properties:
                          lat: { type: number }
                          lng: { type: number }
                      distance_km: { type: number, example: 16.3 }
        "400": { $ref: "#/components/responses/InvalidRequestError" }
        "401": { $ref: "#/components/responses/AuthenticationError" }
        "403": { $ref: "#/components/responses/PermissionError" }
        "404":
          description: No ocean cell within ~100 km (inland point).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimitError" }
        "500": { $ref: "#/components/responses/UpstreamError" }

# ───────────────────────────────────────────────────────────────────────────
# Components
# ───────────────────────────────────────────────────────────────────────────
components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: mtfd_<32 base62 chars>
      description: |
        All protected routes require `Authorization: Bearer mtfd_<key>`.
        Keys are 37 characters total (5-char `mtfd_` prefix + 32 base62 chars).
        Live-only — no test variant. Mint via the meteofeed dashboard or via
        `php artisan api-key:create` for ops keys.

  # ────── Parameters (reusable) ──────
  parameters:
    Lat:
      in: query
      name: lat
      required: false
      description: |
        Latitude in decimal degrees (WGS84). Required unless `pc` or
        `locatie` is supplied (NL-only BAG lookup; see those parameters).
      schema: { type: number, format: float, minimum: -90, maximum: 90, example: 52.37 }
    Lng:
      in: query
      name: lng
      required: false
      description: |
        Longitude in decimal degrees (WGS84). Required unless `pc` or
        `locatie` is supplied.
      schema: { type: number, format: float, minimum: -180, maximum: 180, example: 4.90 }
    Pc:
      in: query
      name: pc
      required: false
      description: |
        NL postal code (e.g. `7941BV` — case-insensitive, whitespace tolerated).
        Resolved to lat/lng via the self-hosted BAG geocoder before the controller runs.
        Mutually exclusive with explicit `lat`/`lng` — when both are present,
        `lat`/`lng` win. Outside the Dutch postcode grid returns
        `400 invalid_request_error / geocode_failed`.
      schema: { type: string, pattern: '^\d{4}\s?[A-Za-z]{2}$', example: "7941BV" }
    Locatie:
      in: query
      name: locatie
      required: false
      description: |
        NL city / woonplaats name (e.g. `Meppel`, `Utrecht`). Resolved to the
        woonplaats centroid via the self-hosted BAG geocoder. NL only; same precedence
        as `pc` (explicit `lat`/`lng` win when present).
      schema: { type: string, example: "Meppel" }
    Hours:
      in: query
      name: hours
      required: false
      description: |
        Number of hourly forecast points. Tier-capped:
        Free=24, Basic=48, Pro/Business=168. Requesting more than your tier
        allows truncates the response and adds a `hours_capped` warning.
      schema: { type: integer, minimum: 1, maximum: 168, example: 24 }
    Model:
      in: query
      name: model
      required: false
      description: |
        Forecast model selection. The default `best_match` runs the
        coverage waterfall (KNMI HARMONIE for NL+border → DWD ICON-D2 for
        the DACH/Benelux region → ECMWF Open Data IFS for every other
        served region worldwide). Customers can force a specific model if
        they need predictable behaviour across runs:
          - `best_match` (default, all tiers) — the live coverage waterfall
            described above.
          - `gfs` (all tiers) — NOAA GFS 0.25°, the US National Weather
            Service's global model (public-domain, sourced directly from
            NOAA Open Data). Covers the same served regions as `best_match`.
          - `gem` (all tiers) — Environment Canada GEM-GDPS (~0.30°), the
            Canadian global model (MSC Open Data, commercial-OK). A 3rd global
            model for cross-model comparison; same served regions as `best_match`.
          - `aifs` (all tiers) — ECMWF AIFS (~0.25°), ECMWF's data-driven (AI/ML)
            global model, on the same keyless CC-BY Open Data platform as IFS. A
            distinctive physics-vs-AI comparison; same served regions as
            `best_match`. (No wind-gust or CAPE — AIFS-single ships neither, so
            `wind_gusts_10m` + `cape` come back null for this model.)
          - `arpege` (all tiers) — Météo-France ARPEGE 0.25°, the French
            national weather service's global model (Licence Ouverte 2.0,
            sourced directly from Météo-France open data). Same served
            regions as `best_match`; forecast horizon 102 h.
          - `arome` (all tiers) — Météo-France AROME 0.025° (~2.5 km), the
            French high-resolution limited-area model. France + W-Europe only
            (lat 37.5–55.4 / lng −12–16; outside it returns 503). Hourly to
            51 h — best for short-range detail over France.
          - `arome-om` (all tiers) — Météo-France AROME-OM ~2.5 km, the
            high-resolution overseas model (Licence Ouverte, sourced directly
            from Météo-France open data). Five French-overseas tropical domains:
            Antilles (Guadeloupe/Martinique), French Guiana, Réunion/Mayotte,
            New Caledonia, French Polynesia (outside them returns 503). 3-hourly
            to 48 h — high-res tropical forecast where the global models are coarse.
          - `meps` (all tiers) — MET Norway MEPS ~2.5 km, the Nordic
            high-resolution model (CC-BY 4.0). Scandinavia + North Sea + Baltic
            only (lat 53–72.5 / lng −3–33; outside it returns 503). Hourly to
            51 h — best for short-range detail over the Nordics/North Sea.
          - `dmi` (all tiers) — DMI HARMONIE-DINI ~2 km, the Danish
            high-resolution limited-area model (CC-BY; "Data from DMI"). NW-Europe
            + Nordics + North Sea + UK/Ireland + Iceland (lat 42.5–70 / lng −26–32;
            outside it returns 503). 3-hourly to 60 h.
          - `hrrr` (all tiers) — NOAA HRRR ~3 km, the US High-Resolution Rapid
            Refresh (public-domain, sourced directly from NOAA Open Data).
            Contiguous US only (lat 24–50 / lng −125.5–−66; outside it returns
            503). Hourly to 18 h — best for short-range detail over the US.
          - `hrdps` (all tiers) — Environment Canada HRDPS ~2.5 km, the Canadian
            High-Resolution Deterministic Prediction System (MSC Open Data,
            commercial-OK). Canada + the northern US (lat 27–71 / lng −153–−40;
            outside it returns 503). 3-hourly to 48 h — the high-res model for
            Canada (where HRRR stops at the border).
          - `geosphere` (all tiers) — GeoSphere Austria AROME ~2.5 km, the
            high-resolution Central-Europe / Eastern-Alps model (CC-BY, sourced
            directly from GeoSphere Austria open data). Austria + Slovenia +
            Slovakia + Hungary + Czechia + N-Italy + E-Switzerland (lat 43–51.8 /
            lng 5.5–22.1; outside it returns 503). Hourly to 48 h. (No MSL
            pressure — GeoSphere ships only surface pressure, so `pressure_msl`
            is null for this model.)
          - `harmonie` (basic / pro / business) — pin to KNMI HARMONIE-AROME,
            the Dutch national weather service's high-resolution (~2.5 km)
            NWP. NL + Benelux only; out-of-grid coords return a 500
            upstream error.

        Requesting a model your tier doesn't have rights to returns
        `403 permission_error / model_not_allowed`.
      schema:
        type: string
        enum: [best_match, gfs, gem, aifs, arpege, arome, arome-om, meps, dmi, hrrr, hrdps, geosphere, harmonie]
        default: best_match
        example: harmonie
    Metric:
      in: query
      name: metric
      required: true
      description: |
        Canonical metric name. Common: `temperature_air`, `pressure`,
        `wind_speed`, `wind_direction`, `wind_gust`, `humidity_relative`,
        `precipitation`, `irradiance_global`, `dew_point`, `cloud_cover`,
        `sunshine_duration`. Discover available metrics for a station via
        the response of `GET /v1/current/station/{id}`.
      schema: { type: string, example: temperature_air }
    From:
      in: query
      name: from
      required: false
      description: |
        Window start (ISO 8601 UTC, e.g. `2026-05-10T00:00:00Z`). Defaults to
        `to` minus your tier's max-days cap.
      schema: { type: string, format: date-time, example: "2026-05-10T00:00:00Z" }
    To:
      in: query
      name: to
      required: false
      description: Window end (ISO 8601 UTC). Defaults to now.
      schema: { type: string, format: date-time, example: "2026-05-17T00:00:00Z" }
    Bucket:
      in: query
      name: bucket
      required: false
      description: |
        Bucket size in seconds. If omitted, derived from range:
        ≤1h→60s, ≤6h→5m, ≤24h→15m, ≤7d→1h, ≤30d→6h, ≤1y→1d, else 1w.
      schema: { type: integer, minimum: 1, example: 3600 }
    SourceCurrent:
      in: query
      name: source
      required: false
      description: |
        Which KNMI **feed granularity** to read for NL coordinates:
        `knmi.10min` (live, sub-hourly — default), `knmi.uurgegevens`
        (hourly aggregates) or `knmi.etmaal` (daily aggregates).

        This does NOT choose the weather provider/model — the upstream is
        selected automatically from the location (KNMI in NL, DWD in DE,
        ECMWF elsewhere; returned as `meta.source`). To pin a specific
        forecast model, use `GET /v1/forecast?model=` instead. Ignored for
        non-NL coordinates and on the ECMWF fallback path.
      schema: { type: string, enum: ["knmi.10min", "knmi.uurgegevens", "knmi.etmaal"], default: "knmi.10min" }
    SourceHistorical:
      in: query
      name: source
      required: false
      description: |
        KNMI **feed granularity** for the historical query (NL coordinates
        only): `knmi.uurgegevens` (hourly, default — ~56 yrs depth),
        `knmi.etmaal` (daily aggregates, ~75 yrs depth) or `knmi.10min`
        (raw 10-minute observations — archive from 2020 onward; pair with
        `bucket=600` for the native resolution).

        Like on `/v1/current`, this is a feed selector — NOT a provider
        picker. Outside NL the archive is chosen automatically from the
        location (`dwd.daily` in DE, `rmi.synop` in BE, `ghcn.daily`
        elsewhere) and this parameter is ignored.
      schema: { type: string, enum: ["knmi.uurgegevens", "knmi.etmaal", "knmi.10min"], default: "knmi.uurgegevens" }

  # ────── Headers (reusable) ──────
  headers:
    RequestId:
      description: UUID matching `meta.request_id`. Cite in support tickets.
      schema: { type: string, format: uuid }
    StandardSet:
      description: |
        Protected `/v1` responses carry rate-limit, quota and tier headers so a
        client can self-throttle (present on success and on most errors):

        - `X-RateLimit-Limit` — per-key burst cap, requests per minute, by tier (Free 120 · Basic 240 · Pro 360 · Business 600).
        - `X-RateLimit-Remaining` — requests left in the current minute window.
        - `X-RateLimit-Reset` — Unix epoch (seconds) when the burst window resets.
        - `X-Quota-Limit` — your plan's request cap for the billing period (Free 500/mo · Basic 7,500 · Pro 30,000 · Business 1,500,000).
        - `X-Quota-Used` — requests used so far this period.
        - `X-Quota-Reset` — Unix epoch (seconds) when the period quota resets (end of month).
        - `X-Tier` — your current plan: `free` / `basic` / `pro` / `business`.
        - `X-RateLimit-Grace-Until` — present only while a support-granted onboarding
          grace is active: until this ISO-8601 moment the per-minute burst limit is
          not enforced for your keys (the period quota still applies).

        On a `429` — from either the per-minute burst **or** the period quota — a
        `Retry-After` header gives the seconds to wait. Back off as
        `X-RateLimit-Remaining` nears 0 or `X-Quota-Used` approaches `X-Quota-Limit`,
        rather than waiting for the 429.
      schema: { type: string }

  # ────── Schemas (reusable) ──────
  schemas:

    MetaMinimal:
      type: object
      properties:
        request_id: { type: string, format: uuid }
      required: [request_id]

    Warnings:
      type: array
      items:
        type: object
        properties:
          code:    { type: string, example: hours_capped }
          message: { type: string, example: "Requested 96h forecast; tier 'free' caps at 24h. Truncated." }
          details:
            type: object
            nullable: true
            description: |
              Optional structured context. Some warnings carry machine-readable
              data alongside the human-readable `message` — for tier-cap
              warnings this includes the upgrade path so dashboards can render
              "unlock this for €N/mo" prompts uniformly.

              For `years_capped` (on `/v1/climate`), the shape is:
                requested_years (int), granted_years (int),
                current_tier (string),
                upgrade_paths (array of {tier, price_eur_month, max_years, unlocks}).
            additionalProperties: true

    # ─── Error envelope (nested error object) ───
    ErrorEnvelope:
      type: object
      properties:
        data:     { type: "null" }
        meta:     { $ref: "#/components/schemas/MetaMinimal" }
        warnings: { $ref: "#/components/schemas/Warnings" }
        error:
          type: object
          required: [type, code, message, request_id]
          properties:
            type:       { type: string, enum: [authentication_error, permission_error, invalid_request_error, rate_limit_error, not_found_error, api_error] }
            code:       { type: string, example: missing_authorization, description: "Stable per-endpoint error identifier." }
            message:    { type: string, example: "No Authorization header. Include `Authorization: Bearer mtfd_…`." }
            param:      { type: string, nullable: true, example: lat, description: "Offending request parameter (if applicable)." }
            details:    { type: object, nullable: true, additionalProperties: true, description: "Machine-readable extra context." }
            request_id: { type: string, format: uuid }

    # ─── /v1/me ───
    MeEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            key:
              type: object
              properties:
                id:            { type: integer, example: 1 }
                name:          { type: string,  example: "my dashboard" }
                prefix:        { type: string,  example: "mtfd_a3F" }
                scopes:        { type: array, items: { type: string }, example: ["*"] }
                expires_at:    { type: string, format: date-time, example: "2027-05-17T13:27:39Z" }
                last_used_at:  { type: string, format: date-time, nullable: true }
                request_count: { type: integer, example: 124 }
            user:
              type: object
              properties:
                id:    { type: integer, example: 42 }
                email: { type: string, format: email, example: dev@example.com }
                name:  { type: string,  example: Dev User }
            subscription:
              type: object
              properties:
                tier:      { type: string, enum: [free, basic, pro, business], example: free }
                period:    { type: string, enum: [day, month], example: day }
                limit:     { type: integer, example: 100 }
                max_keys:  { type: integer, example: 1, description: Maximum number of active API keys allowed on this tier. }
                used:      { type: integer, example: 4 }
                remaining: { type: integer, example: 96 }
        meta:     { $ref: "#/components/schemas/MetaMinimal" }
        warnings: { $ref: "#/components/schemas/Warnings" }

    # ─── Station shape ───
    Station:
      type: object
      properties:
        id:          { type: string, example: "240" }
        wmo_id:      { type: string, example: "06240" }
        name:        { type: string, example: Schiphol Airport }
        lat:         { type: number, format: float, example: 52.3172 }
        lng:         { type: number, format: float, example: 4.7897 }
        elevation_m: { type: number, format: float, nullable: true, example: -3.87 }
        active:      { type: boolean, example: true, description: "True if the station has reported data in the last 30 days." }

    StationsEnvelope:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Station" } }
        meta:
          allOf:
            - $ref: "#/components/schemas/MetaMinimal"
            - type: object
              properties:
                count:            { type: integer, example: 76 }
                active_count:     { type: integer, example: 46 }
                active_window_d:  { type: integer, example: 30 }
        warnings: { $ref: "#/components/schemas/Warnings" }

    # ─── Metric reading ───
    MetricReading:
      type: object
      nullable: true
      properties:
        value: { type: number, format: float, example: 6.7 }
        unit:  { type: string, example: "°C" }
        ts:    { type: string, format: date-time, example: "2026-05-17T14:00:00Z" }

    # ─── Derived helper shapes ───
    WindReading:
      type: object
      nullable: true
      description: Wind speed expressed in four common units.
      properties:
        ms:    { type: number, format: float, example: 4.5 }
        kmh:   { type: number, format: float, example: 16.2 }
        knots: { type: number, format: float, example: 8.75 }
        bft:   { type: integer, example: 3, description: "Beaufort scale 0-12." }

    PressureReading:
      type: object
      nullable: true
      description: Pressure expressed in three common units.
      properties:
        hpa:  { type: number, format: float, example: 1013.2 }
        mmhg: { type: number, format: float, example: 759.8 }
        inhg: { type: number, format: float, example: 29.92 }

    PrecipitationWindows:
      type: object
      nullable: true
      description: Rolling precipitation totals (mm) over 1h/6h/12h/24h ending at the observation timestamp.
      properties:
        sum_1h_mm:  { type: number, format: float, nullable: true, example: 0.4 }
        sum_6h_mm:  { type: number, format: float, nullable: true, example: 2.1 }
        sum_12h_mm: { type: number, format: float, nullable: true, example: 3.2 }
        sum_24h_mm: { type: number, format: float, nullable: true, example: 5.7 }

    TempExtremaWindows:
      type: object
      nullable: true
      description: |
        Rolling min/max temperature in °C over 6h/12h/24h windows ending at
        the observation timestamp. Used by both `derived.temperature` (air
        temperature at 1.5m) and `derived.grass_temp` (ground-level temp at
        10cm). Matches meteoserver's `temp_6/12/24` + `temp_min_6/12` and
        `grstemp_6/12` field set (we use 24h symmetrically for both min + max).
      properties:
        min_6h_c:  { type: number, format: float, nullable: true, example: 9.5 }
        min_12h_c: { type: number, format: float, nullable: true, example: 7.8 }
        min_24h_c: { type: number, format: float, nullable: true, example: 6.2 }
        max_6h_c:  { type: number, format: float, nullable: true, example: 14.7 }
        max_12h_c: { type: number, format: float, nullable: true, example: 15.0 }
        max_24h_c: { type: number, format: float, nullable: true, example: 16.4 }

    WeatherDescription:
      type: object
      nullable: true
      description: |
        WMO 4677 weather code plus simplified condition slug, icon hint and
        English description. The condition vocabulary is small (18 slugs) so
        clients can ship a single icon sprite; codes follow the upstream forecast model's
        WMO 4677 subset and the same code space is emitted by /v1/current
        (derived from observation inputs) and /v1/forecast (pass-through).

        When emitted by /v1/forecast/short days[], the block also carries
        `condition_nl` — meteoserver-compatible Dutch `toestand` slug, for
        customers porting integrations from meteoserver.nl literally.
      properties:
        weather_code: { type: integer, example: 3, description: "WMO 4677 code." }
        condition:
          type: string
          example: cloudy
          enum:
            - clear
            - mostly-clear
            - partly-cloudy
            - cloudy
            - fog
            - drizzle
            - freezing-drizzle
            - rain
            - rain-heavy
            - freezing-rain
            - snow
            - snow-heavy
            - snow-grains
            - showers
            - showers-violent
            - snow-showers
            - thunderstorm
            - thunderstorm-hail
        condition_nl:
          type: string
          nullable: true
          example: bewolkt
          description: |
            Dutch `toestand` slug matching meteoserver.nl's Dagverwachting
            vocabulary. Only emitted by /v1/forecast/short.
          enum:
            - zonnig
            - lichtbewolkt
            - halfbewolkt
            - bewolkt
            - buien
            - regen
            - sneeuw
            - buien_wolken_zon
        description: { type: string, example: Overcast }
        icon:        { type: string, example: cloudy-day, description: "`{condition}-{day|night}` for UI sprite selection." }

    CurrentEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            station:
              allOf:
                - $ref: "#/components/schemas/Station"
                - type: object
                  properties:
                    distance_km: { type: number, format: float, nullable: true, example: 9.52, description: "Only set when resolved via lat/lng." }
            source:
              type: string
              enum: ["knmi.10min", "knmi.uurgegevens", "knmi.etmaal", "dwd.poi", "rmi.aws", "metar", "ecmwf-ifs"]
              example: "knmi.10min"
              description: |
                Which upstream actually served this response.

                  - `knmi.10min` (default), `knmi.uurgegevens`, `knmi.etmaal`
                    — NL coords within 60 km of an active KNMI station.
                  - `rmi.aws` — a Belgian RMI AWS station nearer than the
                    nearest KNMI station (live 10-min WFS).
                  - `dwd.poi` — DE leg of the waterfall: coords within 80 km
                    of one of the curated DWD POI stations. Real station
                    observations from DWD Open Data, native 1h/3h/6h/12h/24h
                    precipitation aggregates from POI's cumulative fields.
                  - `metar` — nearest NOAA METAR airport (worldwide; every
                    country beyond NL / DE / BE).
                  - `ecmwf-ifs` — ECMWF Open Data IFS step-0 grid fallback
                    when no station is near (worldwide, via regional crops).
                    Thinner `derived` block (no rolling temperature /
                    precipitation windows — ECMWF has no observation archive).
                    `?at=` time-travel is NL-only; pairing it with
                    out-of-coverage coords returns 404. A coordinate outside
                    every crop returns 503.
            requested_at:  { type: string, format: date-time, nullable: true, example: "2026-05-17T13:50:00Z", description: "Echoed ?at= value, floored to the source cadence; null for live (no ?at=)." }
            metrics:
              type: object
              description: |
                Per-metric latest (or `at`-targeted) reading. Null when no
                data exists for that metric on this station/timestamp.
              properties:
                temperature_air:         { $ref: "#/components/schemas/MetricReading" }
                temperature_ground:      { $ref: "#/components/schemas/MetricReading" }
                dew_point:               { $ref: "#/components/schemas/MetricReading" }
                pressure:                { $ref: "#/components/schemas/MetricReading" }
                pressure_sea_level:      { $ref: "#/components/schemas/MetricReading" }
                wind_speed:              { $ref: "#/components/schemas/MetricReading" }
                wind_direction:          { $ref: "#/components/schemas/MetricReading" }
                wind_gust:               { $ref: "#/components/schemas/MetricReading" }
                humidity_relative:       { $ref: "#/components/schemas/MetricReading" }
                precipitation:           { $ref: "#/components/schemas/MetricReading" }
                precipitation_intensity: { $ref: "#/components/schemas/MetricReading" }
                sunshine_duration:       { $ref: "#/components/schemas/MetricReading" }
                cloud_cover:             { $ref: "#/components/schemas/MetricReading" }
                visibility:              { $ref: "#/components/schemas/MetricReading" }
                irradiance_global:       { $ref: "#/components/schemas/MetricReading" }
            derived:
              type: object
              description: |
                Derived sibling values computed from the raw `metrics` block.
                Additive — clients that ignore this block keep working.
              properties:
                wind_speed:    { $ref: "#/components/schemas/WindReading" }
                wind_gust:     { $ref: "#/components/schemas/WindReading" }
                wind_compass:  { type: string, nullable: true, example: "ZW", description: "16-point Dutch compass (N, NNO, NO, ONO, O, OZO, ZO, ZZO, Z, ZZW, ZW, WZW, W, WNW, NW, NNW)." }
                pressure:      { $ref: "#/components/schemas/PressureReading" }
                feels_like_c:  { type: number, format: float, nullable: true, example: 12.4, description: "Steadman Apparent Temperature in °C." }
                precipitation: { $ref: "#/components/schemas/PrecipitationWindows" }
                temperature:   { $ref: "#/components/schemas/TempExtremaWindows" }
                grass_temp:    { $ref: "#/components/schemas/TempExtremaWindows" }
                weather:       { $ref: "#/components/schemas/WeatherDescription" }
                is_day:        { type: boolean, example: true, description: "True when the observation timestamp is between sunrise and sunset at the station." }
                short_forecast:
                  type: string
                  nullable: true
                  example: "Bewolkt, 8-15°C, ZZW 4Bft."
                  description: |
                    NL verbal day-forecast string matching meteoserver's `verw`
                    — pulled from upstream forecast model daily aggregates for the station
                    coordinates. Format: `{toestand_nl}, {min}-{max}°C, {compass} {bft}Bft.`
                    Returns null when the upstream forecast is unreachable
                    (current obs are still returned — only the forecast string is gated).
            no_data: { type: boolean, example: false, description: "True if every metric is null (station likely inactive)." }
        meta:     { $ref: "#/components/schemas/MetaMinimal" }
        warnings: { $ref: "#/components/schemas/Warnings" }

    # ─── /v1/forecast ───
    ForecastEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            location:
              type: object
              properties:
                lat:         { type: number, format: float }
                lng:         { type: number, format: float }
                elevation_m: { type: number, nullable: true }
                timezone:    { type: string, example: GMT }
            tier:         { type: string, enum: [free, basic, pro, business] }
            hours:        { type: integer, example: 24, description: "Number of hourly points returned (post-cap)." }
            hourly_units: { type: object, additionalProperties: { type: string }, example: { temperature_2m: "°C", wind_speed_10m: "m/s" } }
            daily_units:  { type: object, additionalProperties: { type: string } }
            hourly:
              type: object
              description: |
                upstream forecast model hourly variables. Each value is an array aligned with
                the `time` array. Upstream variables include:
                  - Temperature: `temperature_2m`, `apparent_temperature`, `dew_point_2m`
                  - Wind: `wind_speed_10m` (m/s), `wind_direction_10m` (°), `wind_gusts_10m` (m/s)
                  - Precipitation: `precipitation`, `rain`, `showers`, `snowfall`, `snow_depth`
                  - Sky: `cloud_cover` (total %), `cloud_cover_low`, `cloud_cover_mid`, `cloud_cover_high`, `visibility`
                  - Pressure / atmosphere: `pressure_msl` (hPa), `cape` (J/kg)
                  - Radiation: `shortwave_radiation` (W/m²), `uv_index`
                  - Misc: `weather_code`, `is_day`, `relative_humidity_2m`
                  - Soil profile (build 121, agriculture / irrigation):
                    `soil_temperature_0cm` / `_6cm` / `_18cm` (°C),
                    `soil_moisture_0_to_1cm` / `_3_to_9cm` / `_9_to_27cm` (m³/m³),
                    `et0_fao_evapotranspiration` (mm reference ET, FAO-56),
                    `vapour_pressure_deficit` (kPa, plant-transpiration driver)

                On top of the upstream variables, sibling arrays are appended
                (same length, same alignment as `time`) so clients don't need
                their own conversion or lookup logic:
                  - `condition[]` / `icon[]` / `description[]` — derived from
                    `weather_code[]` + `is_day[]` via the WeatherDescription map
                  - `wind_speed_kmh[]` / `wind_speed_knots[]` / `wind_speed_bft[]`
                    — wind_speed_10m converted to km/h, knots, Beaufort
                  - `wind_gusts_kmh[]` / `wind_gusts_knots[]` / `wind_gusts_bft[]`
                  - `wind_compass[]` — wind_direction_10m as 16-point Dutch
                    compass slugs (N, NNO, NO, ONO, O, OZO, ZO, ZZO, Z, ...)
                  - `pressure_mmhg[]` / `pressure_inhg[]`
              additionalProperties: true
            daily:
              type: object
              description: |
                upstream forecast model daily aggregates, plus parallel `condition[]`,
                `icon[]` and `description[]` arrays derived from
                `weather_code[]` (day-variant icons since daily aggregates
                represent the whole-day characterisation).
              additionalProperties: true
        meta:
          allOf:
            - $ref: "#/components/schemas/MetaMinimal"
            - type: object
              properties:
                source: { type: string, example: knmi-harmonie, description: "The upstream that actually produced this forecast. For `?model=best_match` one of `knmi-harmonie` (NL), `dwd-icon-d2` (DACH) or `ecmwf-ifs`; when a model is pinned it is that model's id (`aifs`, `gfs`, `meps`, …). Empty when the controller emits 503 outside every covered bbox." }
                attribution:
                  type: string
                  example: "ECMWF AIFS (CC-BY-4.0) — 0.25° global data-driven (machine-learning) forecast model"
                  description: |
                    Licence attribution for `source`, injected automatically. Several upstreams
                    (ECMWF, MET Norway, DMI, GeoSphere Austria, Météo-France) are CC-BY or
                    Licence Ouverte, where naming the producer is a licence CONDITION — reproduce
                    this line wherever you display or redistribute the data.
                model_info:
                  type: object
                  nullable: true
                  description: |
                    Provenance of the model behind this response: who produced it and, notably,
                    **how** it forecasts. `method` is machine-readable so you can filter on it.

                    `aifs` is ECMWF's data-driven (machine-learning) model — `method: ai_ml`.
                    Every other model is physics-based numerical weather prediction —
                    `method: physical_nwp`. We redistribute model output; we do not operate the
                    models. If you publish or build on this data, this block is the provenance
                    you need to label it accurately downstream.
                  properties:
                    id:           { type: string, example: aifs }
                    name:         { type: string, example: "ECMWF AIFS" }
                    producer:     { type: string, example: "ECMWF" }
                    method:       { type: string, enum: [ai_ml, physical_nwp], example: ai_ml }
                    method_label: { type: string, example: "Data-driven (machine learning)" }
                    resolution:   { type: string, example: "0.25°" }
                    licence:      { type: string, example: "CC-BY-4.0" }
                generation_ms: { type: number, format: float, nullable: true }
                cache_ttl_seconds: { type: integer, example: 900 }
        warnings: { $ref: "#/components/schemas/Warnings" }

    # ─── /v1/forecast/short ───
    ForecastShortDay:
      type: object
      description: One day of the 3-day verkort summary.
      properties:
        index: { type: integer, example: 0, description: "0 = today, 1 = tomorrow, 2 = day after." }
        date:  { type: string,  example: "2026-05-18", description: "ISO date (UTC, no timezone shift)." }
        weather: { $ref: "#/components/schemas/WeatherDescription" }
        temperature:
          type: object
          properties:
            min_c:            { type: number, format: float, nullable: true, example: 9.5 }
            max_c:            { type: number, format: float, nullable: true, example: 14.7 }
            mean_c:           { type: number, format: float, nullable: true, example: 12.2, description: "Daily mean from the upstream forecast model's `temperature_2m_mean` aggregate. Equivalent to meteoserver's `avg_temp`." }
            feels_like_min_c: { type: number, format: float, nullable: true, example: 7.8 }
            feels_like_max_c: { type: number, format: float, nullable: true, example: 13.4 }
        wind:
          type: object
          properties:
            speed_max:              { $ref: "#/components/schemas/WindReading" }
            speed_mean:             { $ref: "#/components/schemas/WindReading", description: "Daily mean wind speed — matches meteoserver Dagverwachting's `winds`/`windb`/`windkm`/`windknp` field set." }
            gust_max:               { $ref: "#/components/schemas/WindReading" }
            direction_dominant_deg: { type: number, format: float, nullable: true, example: 220.0 }
            compass:                { type: string, nullable: true, example: "ZZW" }
        precipitation:
          type: object
          properties:
            sum_mm:              { type: number,  format: float, nullable: true, example: 4.5 }
            hours:               { type: number,  format: float, nullable: true, example: 6.0 }
            probability_pct_max: { type: integer, nullable: true, example: 75 }
        sun:
          type: object
          properties:
            sunrise:               { type: string, format: date-time, nullable: true, example: "2026-05-18T03:24:00Z" }
            sunset:                { type: string, format: date-time, nullable: true, example: "2026-05-18T19:35:00Z" }
            daylight_duration_s:   { type: integer, nullable: true, example: 58260 }
            sunshine_duration_s:   { type: integer, nullable: true, example: 12420 }
            sunshine_duration_min: { type: integer, nullable: true, example: 207, description: "Sibling of sunshine_duration_s in minutes — matches meteoserver Dagverwachting's `tot_zond` unit." }
            sunshine_pct:          { type: integer, nullable: true, example: 21, description: "100 × sunshine / daylight." }
        uv_index_max: { type: number, format: float, nullable: true, example: 5.2 }

    ForecastShortEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            location:
              type: object
              properties:
                lat:         { type: number, format: float }
                lng:         { type: number, format: float }
                elevation_m: { type: number, nullable: true }
                timezone:    { type: string, example: GMT }
            tier:        { type: string, enum: [free, basic, pro, business] }
            days_count:  { type: integer, example: 7, description: "Number of day objects returned (post-cap)." }
            daily_units: { type: object, additionalProperties: { type: string }, description: "Upstream upstream forecast model daily-unit map (for raw values not unit-wrapped above)." }
            days:
              type: array
              minItems: 1
              maxItems: 10
              items: { $ref: "#/components/schemas/ForecastShortDay" }
        meta:
          allOf:
            - $ref: "#/components/schemas/MetaMinimal"
            - type: object
              properties:
                source: { type: string, example: knmi-harmonie, description: "The upstream that actually produced this forecast. For `?model=best_match` one of `knmi-harmonie` (NL), `dwd-icon-d2` (DACH) or `ecmwf-ifs`; when a model is pinned it is that model's id (`aifs`, `gfs`, `meps`, …). Empty when the controller emits 503 outside every covered bbox." }
                attribution:
                  type: string
                  example: "ECMWF AIFS (CC-BY-4.0) — 0.25° global data-driven (machine-learning) forecast model"
                  description: |
                    Licence attribution for `source`, injected automatically. Several upstreams
                    (ECMWF, MET Norway, DMI, GeoSphere Austria, Météo-France) are CC-BY or
                    Licence Ouverte, where naming the producer is a licence CONDITION — reproduce
                    this line wherever you display or redistribute the data.
                model_info:
                  type: object
                  nullable: true
                  description: |
                    Provenance of the model behind this response: who produced it and, notably,
                    **how** it forecasts. `method` is machine-readable so you can filter on it.

                    `aifs` is ECMWF's data-driven (machine-learning) model — `method: ai_ml`.
                    Every other model is physics-based numerical weather prediction —
                    `method: physical_nwp`. We redistribute model output; we do not operate the
                    models. If you publish or build on this data, this block is the provenance
                    you need to label it accurately downstream.
                  properties:
                    id:           { type: string, example: aifs }
                    name:         { type: string, example: "ECMWF AIFS" }
                    producer:     { type: string, example: "ECMWF" }
                    method:       { type: string, enum: [ai_ml, physical_nwp], example: ai_ml }
                    method_label: { type: string, example: "Data-driven (machine learning)" }
                    resolution:   { type: string, example: "0.25°" }
                    licence:      { type: string, example: "CC-BY-4.0" }
                generation_ms: { type: number, format: float, nullable: true }
                cache_ttl_seconds: { type: integer, example: 900 }
        warnings: { $ref: "#/components/schemas/Warnings" }

    # ─── /v1/uv ───
    UvEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            location: { type: object, properties: { lat: { type: number }, lng: { type: number }, timezone: { type: string } } }
            tier:     { type: string, enum: [free, basic, pro, business] }
            hours:    { type: integer }
            units:
              type: object
              properties:
                uv_index:    { type: string, example: "" }
                is_day:      { type: string, example: "" }
                cloud_cover: { type: string, example: "%" }
            hourly:
              type: object
              properties:
                time:        { type: array, items: { type: string, format: date-time } }
                uv_index:    { type: array, items: { type: number, format: float, nullable: true }, description: "Derived from sun elevation × cloud-cover attenuation. 0 at night; clear-sky upper bound when cloud_cover is null." }
                is_day:      { type: array, items: { type: integer, enum: [0, 1], nullable: true } }
                cloud_cover: { type: array, items: { type: integer, minimum: 0, maximum: 100, nullable: true }, description: "Upstream cloud-cover (%) — surfaced so consumers can see the driver behind the UV attenuation." }
        meta:
          allOf:
            - $ref: "#/components/schemas/MetaMinimal"
            - type: object
              properties:
                source:     { type: string, example: knmi-harmonie, description: "Cloud-cover provider — `knmi-harmonie` (NL), `ecmwf-ifs` (EU)." }
                derivation: { type: string, example: "Sun elevation + cloud-cover attenuation (Madronich/McKenzie clear-sky × 1-0.7·cloud_frac)" }
        warnings: { $ref: "#/components/schemas/Warnings" }

    # ─── /v1/air-quality ───
    AqiBand:
      type: string
      nullable: true
      description: European EEA AQI category band.
      enum: [good, fair, moderate, poor, very-poor, extremely-poor]
      example: fair

    AirQualityEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            location:
              type: object
              properties:
                lat:         { type: number, format: float }
                lng:         { type: number, format: float }
                elevation_m: { type: number, nullable: true }
                timezone:    { type: string, example: GMT }
            tier:         { type: string, enum: [free, basic, pro, business] }
            hours:        { type: integer, example: 24, description: "Number of hourly points returned (post-cap)." }
            hourly_units: { type: object, additionalProperties: { type: string }, example: { pm10: "μg/m³", european_aqi: "European AQI" } }
            current:
              type: object
              nullable: true
              description: |
                Flat snapshot of the most-recent past-hour values. Saves
                clients from walking the hourly array for a "now" view.
              properties:
                time:                  { type: string, format: date-time, nullable: true }
                pm10:                  { type: number, format: float, nullable: true, example: 12.4, description: "µg/m³" }
                pm2_5:                 { type: number, format: float, nullable: true, example: 5.9,  description: "µg/m³" }
                no2:                   { type: number, format: float, nullable: true, example: 8.2,  description: "µg/m³" }
                o3:                    { type: number, format: float, nullable: true, example: 78.6, description: "µg/m³" }
                so2:                   { type: number, format: float, nullable: true, example: 1.1,  description: "µg/m³" }
                co:                    { type: number, format: float, nullable: true, example: 160,  description: "µg/m³ (not part of EEA AQI scale)" }
                european_aqi:          { type: number, format: float, nullable: true, example: 28.4, description: "Overall AQI — max of the 5 sub-indices (worst-pollutant-wins rule)." }
                aqi_band:              { $ref: "#/components/schemas/AqiBand" }
                european_aqi_pm2_5:    { type: number, format: float, nullable: true, example: 24.5 }
                european_aqi_pm10:     { type: number, format: float, nullable: true, example: 18.2 }
                european_aqi_no2:      { type: number, format: float, nullable: true, example: 12.1 }
                european_aqi_o3:       { type: number, format: float, nullable: true, example: 28.4 }
                european_aqi_so2:      { type: number, format: float, nullable: true, example: 2.0 }
            hourly:
              type: object
              description: |
                CAMS European regional ensemble hourly variables. Each
                value is an array aligned with the `time` array. Pollutants
                (all µg/m³): `pm10`, `pm2_5`, `no2`, `o3`, `so2`, `co`.

                AQI fields computed server-side from the EEA piecewise
                breakpoints:
                  - `european_aqi[]` — overall (max of sub-indices)
                  - `european_aqi_pm2_5[]`, `european_aqi_pm10[]`,
                    `european_aqi_no2[]`, `european_aqi_o3[]`,
                    `european_aqi_so2[]` — per-pollutant

                Six parallel band arrays (same length, same alignment) —
                categorical AQI band per hour, see `AqiBand` schema:
                  - `aqi_band[]`
                  - `aqi_band_pm2_5[]`, `aqi_band_pm10[]`, `aqi_band_no2[]`,
                    `aqi_band_o3[]`, `aqi_band_so2[]`
              additionalProperties: true
        meta:
          allOf:
            - $ref: "#/components/schemas/MetaMinimal"
            - type: object
              properties:
                source: { type: string, example: "cams", description: "`cams` (Copernicus CAMS European regional ensemble, inside the EU bbox) or `cams-global` (CAMS global atmospheric-composition model — surface PM2.5/PM10 — elsewhere)." }
                generation_ms: { type: number, format: float, nullable: true }
        warnings: { $ref: "#/components/schemas/Warnings" }

    # ─── /v1/pollen ───
    PollenRiskBand:
      type: string
      nullable: true
      enum: [none, low, moderate, high, very-high]
      description: |
        Per-species pollen risk band (EAACI / Wörishofen scale).
        `none` = exactly 0 grains/m³; `null` = no data. Exact
        thresholds in grains/m³:

        | species | low | moderate | high | very-high |
        |---|---|---|---|---|
        | alder, birch | <10 | 10–49 | 50–199 | ≥200 |
        | grass, mugwort, ragweed | <5 | 5–19 | 20–49 | ≥50 |
        | olive | <20 | 20–99 | 100–299 | ≥300 |
      example: low

    PollenEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            location:
              type: object
              properties:
                lat:         { type: number, format: float }
                lng:         { type: number, format: float }
                elevation_m: { type: number, nullable: true }
                timezone:    { type: string, example: GMT }
            tier:         { type: string, enum: [free, basic, pro, business] }
            hours:        { type: integer, example: 24 }
            hourly_units: { type: object, additionalProperties: { type: string }, example: { grass_pollen: "grains/m³" } }
            species:
              type: array
              items: { type: string }
              example: [alder, birch, grass, mugwort, olive, ragweed]
            current:
              type: object
              nullable: true
              description: Flat snapshot of the most-recent past-hour values.
              properties:
                time:           { type: string, format: date-time, nullable: true }
                alder_pollen:   { type: number, format: float, nullable: true }
                alder_risk:     { $ref: "#/components/schemas/PollenRiskBand" }
                birch_pollen:   { type: number, format: float, nullable: true }
                birch_risk:     { $ref: "#/components/schemas/PollenRiskBand" }
                grass_pollen:   { type: number, format: float, nullable: true }
                grass_risk:     { $ref: "#/components/schemas/PollenRiskBand" }
                mugwort_pollen: { type: number, format: float, nullable: true }
                mugwort_risk:   { $ref: "#/components/schemas/PollenRiskBand" }
                olive_pollen:   { type: number, format: float, nullable: true }
                olive_risk:     { $ref: "#/components/schemas/PollenRiskBand" }
                ragweed_pollen: { type: number, format: float, nullable: true }
                ragweed_risk:   { $ref: "#/components/schemas/PollenRiskBand" }
            hourly:
              type: object
              description: |
                Six pollen concentration series (grains/m³):
                `alder_pollen[]`, `birch_pollen[]`, `grass_pollen[]`,
                `mugwort_pollen[]`, `olive_pollen[]`, `ragweed_pollen[]`.

                Six parallel risk-band arrays (same length, same alignment
                with `time[]`): `{species}_risk[]` for each species. See
                `PollenRiskBand` schema for the enum.

                Coverage is the CAMS Europe ensemble bbox; outside it the
                endpoint returns 503 (not nulls).
              additionalProperties: true
        meta:
          allOf:
            - $ref: "#/components/schemas/MetaMinimal"
            - type: object
              properties:
                source: { type: string, example: "cams", description: "`cams` — Copernicus CAMS European regional ensemble direct." }
                generation_ms: { type: number, format: float, nullable: true }
        warnings: { $ref: "#/components/schemas/Warnings" }

    # ─── /v1/geocode ───
    GeocodeResult:
      type: object
      properties:
        name:       { type: string, example: "Meppel, Meppel, Drenthe", description: "Human-readable display name (BAG-composed / Nominatim `display_name`)." }
        type:       { type: string, enum: [postcode, adres, woonplaats, gemeente, weg, provincie], example: woonplaats, description: "Unified taxonomy across both upstreams — BAG and Nominatim feature classes are normalised to this set." }
        lat:        { type: number, format: float, example: 52.7047 }
        lng:        { type: number, format: float, example: 6.2017 }
        postcode:   { type: string, nullable: true, example: "3511AB" }
        huisnummer: { type: integer, nullable: true, example: 1 }
        straat:     { type: string, nullable: true, example: "Damrak" }
        woonplaats: { type: string, nullable: true, example: "Meppel" }
        gemeente:   { type: string, nullable: true, example: "Meppel" }
        provincie:  { type: string, nullable: true, example: "Drenthe" }
        score:      { type: number, format: float, nullable: true, example: 18.5, description: "Upstream relevance score (BAG numeric / Nominatim `importance`)." }
        country:    { type: string, nullable: true, example: "BE", description: "ISO country code of the result, derived from the result's own address (on Nominatim results); omitted on BAG/NL results for back-compat." }

    GeocodeEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            query:   { type: string, example: "Meppel" }
            country: { type: string, example: NL, description: "Which country was queried (echoed from the `country` param) — any code in the `country` enum above." }
            type:    { type: string, nullable: true, example: woonplaats }
            rows:    { type: integer, example: 5 }
            results:
              type: array
              items: { $ref: "#/components/schemas/GeocodeResult" }
        meta:
          allOf:
            - $ref: "#/components/schemas/MetaMinimal"
            - type: object
              properties:
                source: { type: string, enum: [bag-clickhouse, bag-postcode, bag-woonplaats, nominatim-osm], example: bag-clickhouse, description: "Upstream that served this response. NL → self-hosted BAG, every other country → Nominatim/OSM." }
                count:  { type: integer, example: 1 }
        warnings: { $ref: "#/components/schemas/Warnings" }

    # ─── /v1/solar ───
    SolarSunPosition:
      type: object
      properties:
        elevation_deg: { type: number, format: float, example: 45.2, description: "Sun's angle above the horizon. 0° = horizon, 90° = zenith, <0 = below horizon (night)." }
        azimuth_deg:   { type: number, format: float, example: 180.5, description: "Compass azimuth (0°=N, 90°=E, 180°=S, 270°=W). Computed clockwise from north." }
        is_day:        { type: boolean, example: true }
        sunrise:       { type: string, format: date-time, nullable: true, example: "2026-05-18T03:33:00Z" }
        sunset:        { type: string, format: date-time, nullable: true, example: "2026-05-18T19:29:00Z" }

    SolarEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            location:
              type: object
              properties:
                lat:         { type: number, format: float }
                lng:         { type: number, format: float }
                elevation_m: { type: number, nullable: true }
                timezone:    { type: string, example: UTC }
            tier:  { type: string, enum: [free, basic, pro, business] }
            hours: { type: integer, example: 24 }
            pv_system:
              type: object
              description: PV-modeling parameters in effect (defaults derive from latitude). Echoes ?tilt/?azimuth/?albedo/?kwp/?losses.
              properties:
                tilt_deg:          { type: number, format: float, example: 35 }
                azimuth_deg:       { type: number, format: float, example: 180 }
                albedo:            { type: number, format: float, example: 0.2 }
                kwp:               { type: number, format: float, nullable: true, example: 4.0 }
                system_losses_pct: { type: number, format: float, example: 14 }
                temp_coeff_per_c:  { type: number, format: float, example: -0.004 }
                models:            { type: string, example: "Erbs (DNI/DHI) · Ineichen-Perez (clear-sky) · Hay-Davies (POA) · PVWatts + Faiman wind-temp (power)" }
                note:              { type: string }
            current:
              type: object
              nullable: true
              description: Live KNMI-10min observation from the nearest station + computed sun position.
              properties:
                station:
                  type: object
                  properties:
                    id:          { type: string, example: "273" }
                    name:        { type: string, example: "Marknesse" }
                    lat:         { type: number, format: float }
                    lng:         { type: number, format: float }
                    distance_km: { type: number, format: float, nullable: true }
                ts:      { type: string, format: date-time, nullable: true, example: "2026-05-18T10:00:00Z" }
                ts_unix: { type: integer,                    nullable: true, example: 1779127200, description: "Unix timestamp (seconds since epoch). Drop-in for meteoserver `time`." }
                cet:     { type: string,                     nullable: true, example: "2026-05-18T12:00:00+02:00", description: "Europe/Amsterdam local time, ISO 8601 with offset. Drop-in for meteoserver `cet`." }
                sun: { $ref: "#/components/schemas/SolarSunPosition" }
                metrics:
                  type: object
                  properties:
                    temperature_c:                  { type: number, format: float, nullable: true, example: 14.7 }
                    irradiance_w_per_m2:            { type: number, format: float, nullable: true, example: 678.0 }
                    irradiance_j_per_cm2_h:         { type: number, format: float, nullable: true, example: 244.08, description: "= irradiance_w_per_m2 × 0.36" }
                    sunshine_duration_min:          { type: number, format: float, nullable: true, example: 8.0 }
                    cloud_cover_oktas:              { type: number, format: float, nullable: true, example: 3.0 }
                    cloud_cover_pct:                { type: integer,                nullable: true, example: 38, description: "= round(oktas / 8 × 100). Drop-in for meteoserver `tc`." }
                    visibility_m:                   { type: number, format: float, nullable: true, example: 29200.0 }
                    precipitation_intensity_mm_h:   { type: number, format: float, nullable: true, example: 0.0 }
                pv:
                  type: object
                  nullable: true
                  description: PV decomposition of the LIVE observed irradiance — DNI/DHI/POA + clearness, and pv_power_w when ?kwp is set. Same models as the hourly block. NL-only (needs an irradiance-measuring station).
                  properties:
                    dni_wm2:         { type: integer, nullable: true, example: 758 }
                    dhi_wm2:         { type: integer, nullable: true, example: 149 }
                    ghi_clear_wm2:   { type: integer, nullable: true, example: 719 }
                    clearness_index: { type: number, format: float, nullable: true, example: 0.725 }
                    poa_wm2:         { type: integer, nullable: true, example: 775 }
                    aoi_deg:         { type: number, format: float, nullable: true, example: 36.2 }
                    pv_power_w:      { type: integer, nullable: true, example: 2429, description: "Only when ?kwp set." }
            hourly_units: { type: object, additionalProperties: { type: string } }
            hourly:
              type: object
              description: |
                Hourly forecast block. Upstream upstream forecast model variables aligned
                with `time[]`: `temperature_2m`, `shortwave_radiation`,
                `sunshine_duration` (seconds/h), `cloud_cover` (total %),
                `cloud_cover_low` / `_mid` / `_high`, `visibility`,
                `precipitation`, `is_day`. Plus sibling arrays computed
                server-side:
                  - `time_unix[]`           (unix seconds — drop-in for meteoserver `time`)
                  - `cet[]`                 (Europe/Amsterdam ISO with offset — drop-in for meteoserver `cet`)
                  - `sun_elevation_deg[]`   (NOAA solar-position algorithm — meteoserver `elev`)
                  - `sun_azimuth_deg[]`     (meteoserver `az`)
                  - `irradiance_j_per_cm2_h[]` (= shortwave_radiation × 0.36 — meteoserver `gr`)
                  - `sunshine_duration_min[]`  (= sunshine_duration / 60 — meteoserver `sd`)
                  - `dni_wm2[]` / `dhi_wm2[]`  (Erbs decomposition of GHI into beam + diffuse)
                  - `ghi_clear_wm2[]` / `clearness_index[]` (Ineichen-Perez clear-sky + index)
                  - `poa_wm2[]` / `aoi_deg[]`  (Hay-Davies plane-of-array for ?tilt / ?azimuth)
                  - `pv_power_w[]`             (PVWatts + Faiman wind-cooled AC power — only when ?kwp is set)
              additionalProperties: true
            daily:
              type: array
              description: Per-day PV-energy roll-up (step-weighted, so beyond-Europe 3-hourly steps aggregate correctly).
              items:
                type: object
                properties:
                  date:           { type: string, format: date, example: "2026-06-01" }
                  ghi_kwh_m2:     { type: number, format: float, example: 6.2 }
                  poa_kwh_m2:     { type: number, format: float, example: 7.1 }
                  peak_sun_hours: { type: number, format: float, example: 7.1 }
                  coverage_hours: { type: number, format: float, example: 24 }
                  pv_energy_kwh:  { type: number, format: float, nullable: true, example: 24.3, description: "Only when ?kwp set." }
        meta:
          allOf:
            - $ref: "#/components/schemas/MetaMinimal"
            - type: object
              properties:
                source:            { type: string, example: "knmi.10min + knmi-harmonie", description: "Compound label naming the observation source (KNMI 10-min) + the forecast source (`knmi-harmonie` NL, `dwd-icon-d2` DE, `ecmwf-ifs` rest of world)." }
                generation_ms:     { type: number, format: float, nullable: true }
                cache_ttl_seconds: { type: integer, example: 900 }
        warnings: { $ref: "#/components/schemas/Warnings" }

    # ─── /v1/water/* ───
    WaterStation:
      type: object
      properties:
        code:        { type: string, example: lobith.bovenrijn.tolkamer, description: "RWS DDAPI 2.0 station code." }
        name:        { type: string, example: "Lobith, Bovenrijn (Tolkamer)" }
        lat:         { type: number, format: float, example: 51.8495 }
        lng:         { type: number, format: float, example: 6.1024 }
        waterbody:   { type: string, example: Bovenrijn, description: "Dutch waterbody label (Noordzee, Waddenzee, Bovenrijn, Waal, Maas, IJssel, IJsselmeer, ...)." }
        type:        { type: string, enum: [sea, estuary, river, lake], example: river }
        distance_km: { type: number, format: float, nullable: true, example: 12.4, description: "Haversine distance from the requested lat/lng. Null when station was selected by code." }

    WaterReading:
      type: object
      nullable: true
      description: Most-recent measurement at the station. Null if RWS published no recent value.
      properties:
        value_cm:  { type: number, format: float, example: 816.0, description: "Water height in centimetres." }
        unit:      { type: string, example: cm }
        ts:        { type: string, format: date-time, example: "2026-05-18T15:00:00.000+01:00", description: "RWS-reported timestamp (offset preserved as-published)." }
        quality:   { type: string, example: Ongecontroleerd, description: "RWS Statuswaarde — Ongecontroleerd (raw), Gecontroleerd (QC), etc." }
        reference: { type: string, example: NAP, description: "Reference datum (NAP, NVT, ...) per RWS." }

    TideExtremum:
      type: object
      properties:
        kind:     { type: string, enum: [high, low], example: high }
        time:     { type: string, format: date-time, example: "2026-05-18T02:00" }
        level_cm: { type: integer, example: 116, description: "Level in cm relative to MSL (≈ NAP)." }

    WaterForecastEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            station:  { $ref: "#/components/schemas/WaterStation" }
            tier:     { type: string, enum: [free, basic, pro, business] }
            hours:    { type: integer, example: 48 }
            unit:     { type: string, example: cm }
            datum:    { type: string, example: "MSL (≈ NAP for the Dutch coast)" }
            notes:    { type: string, example: "Total water level including meteorological surge. Astronomical-only tide is not separated." }
            hourly:
              type: object
              properties:
                time:     { type: array, items: { type: string, format: date-time } }
                level_cm: { type: array, items: { type: integer, nullable: true } }
            extrema:
              type: array
              description: |
                Auto-detected local maxima (high tides) and minima (low tides)
                in the hourly curve. Typically 1-2 of each per 24h at NL
                coastal stations (semi-diurnal M2 cycle).
              items: { $ref: "#/components/schemas/TideExtremum" }
        meta:
          allOf:
            - $ref: "#/components/schemas/MetaMinimal"
            - type: object
              properties:
                source:            { type: string, example: marine-model }
                cache_ttl_seconds: { type: integer, example: 900 }
        warnings: { $ref: "#/components/schemas/Warnings" }

    WaterObservationEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            station: { $ref: "#/components/schemas/WaterStation" }
            source:  { type: string, example: rws-ddapi-2.0 }
            metric:  { type: string, example: waterhoogte }
            reading: { $ref: "#/components/schemas/WaterReading" }
            no_data: { type: boolean, example: false }
        meta:     { $ref: "#/components/schemas/MetaMinimal" }
        warnings: { $ref: "#/components/schemas/Warnings" }

    WaterStationsEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            stations:
              type: array
              items: { $ref: "#/components/schemas/WaterStation" }
        meta:
          allOf:
            - $ref: "#/components/schemas/MetaMinimal"
            - type: object
              properties:
                count: { type: integer, example: 27 }
        warnings: { $ref: "#/components/schemas/Warnings" }

    # ─── /v1/climate ───
    ClimateMonthNorm:
      type: object
      description: One row of the 12-month climate-norm aggregate.
      properties:
        month:   { type: integer, minimum: 1, maximum: 12, example: 1, description: "Calendar month (1 = January, 12 = December)." }
        mean:    { type: number, format: float, example: 3.23, description: "Average of `value` across all samples for this month." }
        min:     { type: number, format: float, example: -13.2, description: "Historical minimum observed for this month." }
        max:     { type: number, format: float, example: 14.0, description: "Historical maximum observed for this month." }
        samples: { type: integer, example: 1767, description: "Number of underlying observations contributing to this row." }

    ClimateEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            station: { $ref: "#/components/schemas/Station" }
            metric:  { type: string, example: temperature_air }
            source:  { type: string, enum: [knmi.etmaal, knmi.uurgegevens], example: knmi.etmaal }
            tier:    { type: string, enum: [free, basic, pro, business] }
            window:
              type: object
              properties:
                from_year:  { type: integer, example: 1996 }
                to_year:    { type: integer, example: 2025 }
                year_count: { type: integer, example: 30 }
            period:
              type: string
              enum: [monthly]
              description: Aggregation granularity. Only `monthly` is supported today.
            norms:
              type: array
              minItems: 0
              maxItems: 12
              items: { $ref: "#/components/schemas/ClimateMonthNorm" }
        meta:
          allOf:
            - $ref: "#/components/schemas/MetaMinimal"
            - type: object
              properties:
                source:            { type: string, example: knmi.etmaal }
                cache_ttl_seconds: { type: integer, example: 86400 }
        warnings: { $ref: "#/components/schemas/Warnings" }

    # ─── /v1/marine/* ───
    MarineObservationEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            requested:
              type: object
              properties:
                lat: { type: number, format: float, example: 52.5 }
                lng: { type: number, format: float, example: 4.5 }
            station:
              allOf:
                - $ref: "#/components/schemas/Station"
                - type: object
                  properties:
                    is_offshore: { type: boolean, example: true }
                    distance_km: { type: number, format: float, nullable: true, example: 26.05, description: "Haversine distance from the requested lat/lng to the chosen offshore station." }
            observed_at_utc: { type: string, example: "2026-05-18 18:10:00", description: "Most-recent observation timestamp across the surfaced metrics." }
            metrics:
              type: object
              description: |
                Per-metric live reading. Subset of KNMI 10-min observations
                that offshore platforms reliably publish — wind, pressure,
                temperature, humidity. Each entry is null if the platform
                didn't report that metric in the latest 10-min window.
              properties:
                temperature_air:   { $ref: "#/components/schemas/MetricReading" }
                pressure:          { $ref: "#/components/schemas/MetricReading" }
                wind_speed:        { $ref: "#/components/schemas/MetricReading" }
                wind_direction:    { $ref: "#/components/schemas/MetricReading" }
                wind_gust:         { $ref: "#/components/schemas/MetricReading" }
                humidity_relative: { $ref: "#/components/schemas/MetricReading" }
            no_data: { type: boolean, example: false }
        meta:     { $ref: "#/components/schemas/MetaMinimal" }
        warnings: { $ref: "#/components/schemas/Warnings" }

    MarineForecastEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            location:
              type: object
              properties:
                lat:         { type: number, format: float }
                lng:         { type: number, format: float }
                elevation_m: { type: number, nullable: true }
                timezone:    { type: string, example: GMT }
            tier:         { type: string, enum: [free, basic, pro, business] }
            hours:        { type: integer, example: 48 }
            hourly_units: { type: object, additionalProperties: { type: string } }
            hourly:
              type: object
              description: |
                ECMWF Open Data wave-model variables aligned with `time[]`:
                  - `wave_height`           (Hm0, m)
                  - `wave_direction`        (° from)
                  - `wave_period`           (s, mean)
                  - `wave_peak_period`      (s, peak from 1D spectrum)
                  - `wave_mean_zero_period` (s, mean zero-crossing)

                Step cadence is 3-hourly; `time[]` defines the sampling.
                Not provided (vs the previous upstream): `swell_*`
                decomposition, `sea_surface_temperature`,
                `sea_level_height_msl`, `ocean_current_*`.
              additionalProperties: true
        meta:
          allOf:
            - $ref: "#/components/schemas/MetaMinimal"
            - type: object
              properties:
                source:            { type: string, example: ecmwf-wave }
                tier_cap_hours:    { type: integer, example: 168 }
                generation_ms:     { type: number, format: float, nullable: true }
        warnings: { $ref: "#/components/schemas/Warnings" }

    # ─── /v1/historical/day ───
    HistoricalDayHour:
      type: object
      description: One hour of historical observations + derived weather flags.
      properties:
        hour:     { type: integer, minimum: 0, maximum: 23, example: 11, description: "Hour of day, UTC." }
        datetime: { type: string, format: date-time, example: "1980-01-15T11:00:00Z" }
        metrics:
          type: object
          description: |
            Per-metric reading for this hour, keyed by metric name. Each value
            is `{value: number, unit: string}`. Hours/metrics with no recorded
            value at this station are absent (empty object means no data).
          additionalProperties:
            type: object
            properties:
              value: { type: number, format: float, example: -3.8 }
              unit:  { type: string, example: "°C" }
        flags:
          type: object
          description: |
            Derived boolean weather flags matching meteoserver's `mist`/
            `regen`/`sneeuw`/`ijs`/`onweer` field set. Computed from temp +
            visibility + precipitation. `onweer` is always null since we
            don't ingest lightning data (locked decision per CLAUDE.md).
          properties:
            mist:   { type: boolean, nullable: true, description: "visibility < 1000m" }
            regen:  { type: boolean, nullable: true, description: "precip > 0 AND temp > 0" }
            sneeuw: { type: boolean, nullable: true, description: "precip > 0 AND temp ≤ 0" }
            ijs:    { type: boolean, nullable: true, description: "temp ≤ 0" }
            onweer: { type: boolean, nullable: true, description: "always null — no lightning upstream" }

    HistoricalDayEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            station:
              type: object
              properties:
                id:          { type: string, example: "260" }
                name:        { type: string, example: "DE_BILT" }
                lat:         { type: number, format: float }
                lng:         { type: number, format: float }
                distance_km: { type: number, format: float }
            date:   { type: string, format: date, example: "1980-01-15" }
            source: { type: string, enum: [knmi.uurgegevens, knmi.etmaal], example: "knmi.uurgegevens" }
            tier:   { type: string, enum: [free, basic, pro, business] }
            hours:
              type: array
              minItems: 24
              maxItems: 24
              items: { $ref: "#/components/schemas/HistoricalDayHour" }
        meta:
          allOf:
            - $ref: "#/components/schemas/MetaMinimal"
            - type: object
              properties:
                count_hours:   { type: integer, example: 24 }
                count_samples: { type: integer, example: 187, description: "Total (hour × metric) observations underlying the response." }
        warnings: { $ref: "#/components/schemas/Warnings" }

    # ─── /v1/historical/forecast ───
    HistoricalForecastEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            location:
              type: object
              properties:
                lat:         { type: number, format: float }
                lng:         { type: number, format: float }
                elevation_m: { type: number, format: float, nullable: true }
                timezone:    { type: string, example: "UTC" }
            date:  { type: string, format: date, example: "2026-04-15" }
            tier:  { type: string, enum: [free, basic, pro, business] }
            model: { type: string, enum: [best_match] }
            issued_at:  { type: string, format: date-time, nullable: true, example: "2026-04-14T18:00:00Z", description: "The issuing model run (UTC) — the day-ahead forecast's origin." }
            lead_hours: { type: integer, nullable: true, example: 6, description: "Hours from the issuing run to the start of the forecast day." }
            hours: { type: integer, example: 8, description: "Steps returned — ECMWF Open Data is 3-hourly, so a full day is 8." }
            hourly_units:
              type: object
              additionalProperties: { type: string }
            hourly:
              type: object
              description: |
                Column-oriented hourly block — same shape as `/v1/forecast`,
                so client parsers reuse the same code path for live and
                archived. `time[]` is the canonical 24-entry index; every
                other array is parallel (same length). Sparse upstream
                stays sparse — nulls propagate.
              properties:
                time:                     { type: array, items: { type: string, example: "2026-04-15T13:00" } }
                temperature_2m:           { type: array, items: { type: number, format: float, nullable: true } }
                relative_humidity_2m:     { type: array, items: { type: integer,                    nullable: true } }
                dew_point_2m:             { type: array, items: { type: number, format: float, nullable: true } }
                apparent_temperature:     { type: array, items: { type: number, format: float, nullable: true } }
                precipitation:            { type: array, items: { type: number, format: float, nullable: true } }
                weather_code:             { type: array, items: { type: integer,                    nullable: true } }
                condition:                { type: array, items: { type: string,                     nullable: true } }
                icon:                     { type: array, items: { type: string,                     nullable: true } }
                description:              { type: array, items: { type: string,                     nullable: true } }
                pressure_msl:             { type: array, items: { type: number, format: float, nullable: true } }
                pressure_mmhg:            { type: array, items: { type: number, format: float, nullable: true } }
                pressure_inhg:            { type: array, items: { type: number, format: float, nullable: true } }
                cloud_cover:              { type: array, items: { type: integer,                    nullable: true } }
                cape:                     { type: array, items: { type: integer,                    nullable: true } }
                wind_speed_10m:           { type: array, items: { type: number, format: float, nullable: true } }
                wind_speed_kmh:           { type: array, items: { type: number, format: float, nullable: true } }
                wind_speed_knots:         { type: array, items: { type: number, format: float, nullable: true } }
                wind_speed_bft:           { type: array, items: { type: integer,                    nullable: true } }
                wind_direction_10m:       { type: array, items: { type: number, format: float, nullable: true } }
                wind_compass:             { type: array, items: { type: string,                     nullable: true } }
                wind_gusts_10m:           { type: array, items: { type: number, format: float, nullable: true } }
                wind_gusts_kmh:           { type: array, items: { type: number, format: float, nullable: true } }
                wind_gusts_knots:         { type: array, items: { type: number, format: float, nullable: true } }
                wind_gusts_bft:           { type: array, items: { type: integer,                    nullable: true } }
                is_day:                   { type: array, items: { type: integer,                    nullable: true } }
                shortwave_radiation:      { type: array, items: { type: number, format: float, nullable: true } }
              additionalProperties: true
        meta:
          allOf:
            - $ref: "#/components/schemas/MetaMinimal"
            - type: object
              properties:
                source:            { type: string, example: "self-archive (ECMWF Open Data, day-ahead)" }
                issued_at:         { type: string, format: date-time, nullable: true, example: "2026-04-14T18:00:00Z" }
                lead_hours:        { type: integer, nullable: true, example: 6 }
                generation_ms:     { type: number, format: float, nullable: true }
                cache_ttl_seconds: { type: integer, example: 86400 }
                archive_start:     { type: string, format: date, example: "2026-05-31" }
        warnings: { $ref: "#/components/schemas/Warnings" }

    # ─── /v1/historical ───
    HistoricalPoint:
      type: object
      properties:
        ts:    { type: string, example: "2026-05-14 00:00:00", description: "ISO-like timestamp (UTC); bucket start." }
        value: { type: number, format: float, example: 6.6 }

    HistoricalEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            station: { $ref: "#/components/schemas/Station" }
            metric:         { type: string, example: temperature_air }
            source:         { type: string, example: knmi.uurgegevens }
            tier:           { type: string, enum: [free, basic, pro, business] }
            from:           { type: string, format: date-time }
            to:             { type: string, format: date-time }
            bucket_seconds: { type: integer, example: 3600 }
            points:         { type: array, items: { $ref: "#/components/schemas/HistoricalPoint" } }
        meta:
          allOf:
            - $ref: "#/components/schemas/MetaMinimal"
            - type: object
              properties:
                count:    { type: integer, example: 168 }
                max_days: { type: integer, example: 7, description: "Your tier's max-days cap." }
        warnings: { $ref: "#/components/schemas/Warnings" }

    # ─── /v1/sun ───
    SunEvent:
      type: string
      description: |
        ISO 8601 UTC timestamp OR sentinel string for polar conditions:
        `always_above_horizon` (polar day) / `always_below_horizon` (polar night).
        Null when not computable.
      nullable: true

    SunEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            date:    { type: string, format: date }
            lat:     { type: number }
            lng:     { type: number }
            sunrise: { $ref: "#/components/schemas/SunEvent" }
            sunset:  { $ref: "#/components/schemas/SunEvent" }
            transit: { $ref: "#/components/schemas/SunEvent" }
            twilight:
              type: object
              properties:
                civil_begin:        { $ref: "#/components/schemas/SunEvent" }
                civil_end:          { $ref: "#/components/schemas/SunEvent" }
                nautical_begin:     { $ref: "#/components/schemas/SunEvent" }
                nautical_end:       { $ref: "#/components/schemas/SunEvent" }
                astronomical_begin: { $ref: "#/components/schemas/SunEvent" }
                astronomical_end:   { $ref: "#/components/schemas/SunEvent" }
            day_length_seconds: { type: integer, nullable: true, example: 57040 }
        meta:     { $ref: "#/components/schemas/MetaMinimal" }
        warnings: { $ref: "#/components/schemas/Warnings" }

    # ─── /v1/warnings ───
    TyphoonEnvelope:
      type: object
      description: |
        Active tropical cyclones worldwide (GDACS). `active_count: 0` + an
        empty `cyclones` array is the normal calm-season state, not an error.
      properties:
        data:
          type: object
          properties:
            active_count: { type: integer, example: 0 }
            cyclones:
              type: array
              items:
                type: object
                properties:
                  id:            { type: string, example: "1001090" }
                  episode_id:    { type: integer, nullable: true, example: 24 }
                  name:          { type: string, nullable: true, example: "YAGI" }
                  full_name:     { type: string, nullable: true, example: "Tropical Cyclone YAGI-24" }
                  alert_level:   { type: string, nullable: true, enum: [green, orange, red], example: red }
                  alert_score:   { type: number, nullable: true, example: 3 }
                  category:      { type: string, nullable: true, enum: [tropical-depression, tropical-storm, category-1, category-2, category-3, category-4, category-5], example: category-4 }
                  max_wind_kmh:  { type: number, nullable: true, example: 240 }
                  max_wind_kt:   { type: number, nullable: true, example: 129.6 }
                  severity_text: { type: string, nullable: true, example: "Super Typhoon" }
                  position:
                    type: object
                    properties:
                      lat: { type: number, nullable: true, example: 14.0 }
                      lng: { type: number, nullable: true, example: 125.0 }
                  from:          { type: string, nullable: true, format: date-time }
                  to:            { type: string, nullable: true, format: date-time }
                  is_current:    { type: boolean, example: true }
                  affected_countries: { type: array, items: { type: string }, example: ["Philippines", "China", "Vietnam"] }
                  iso3:          { type: array, items: { type: string }, example: ["PHL", "CHN", "VNM"] }
                  source:        { type: string, nullable: true, example: GDACS }
                  source_url:    { type: string, nullable: true, example: "https://www.gdacs.org/report.aspx?eventid=1001090" }
        meta:     { type: object, additionalProperties: true }
        warnings: { type: array, items: { type: object } }
    WarningsEnvelope:
      type: object
      description: |
        Parsed KNMI severe-weather report (default). With `?format=xml`
        the response body is the raw upstream XML payload (text/xml) and
        this JSON envelope does not apply.

        Note: the KNMI `waarschuwingen_nederland_48h` dataset is NOT
        CAP-format — it's KNMI's proprietary cube schema
        (`<report><data><cube><timeslice><phenomenon><location>`). The
        parsed shape below flattens the 48 × 7 × 15 cube to a list of
        non-GREEN cells.
      properties:
        data:
          type: object
          properties:
            region:          { type: string, example: NL }
            latest_filename: { type: string, example: "knmi_waarschuwingen_202605171159.xml", nullable: true }
            fetched_at:      { type: string, format: date-time }
            report:
              type: object
              description: Parsed warning report (default response when `?format=xml` is NOT passed).
              properties:
                report_info:
                  type: object
                  description: Issue metadata (issued_at, valid_from, valid_to, reporting_agency, ...).
                  additionalProperties: true
                phenomena:
                  type: object
                  description: "Lookup table — phenomenon_id → human name (LT=thunderstorms, FX=wind gusts, IC=snow/ice, RR=heavy rain, VV=visibility/fog, TX=extreme temp, WS=waterspouts)."
                  additionalProperties: { type: string }
                locations:
                  type: object
                  description: Lookup table — location_id (province / region code) → display name.
                  additionalProperties: { type: string }
                colors:
                  type: object
                  description: "Lookup table — color_id → semantic (GREEN, YELLOW, ORANGE, RED, GRAY=no-data, BLACK=extreme)."
                  additionalProperties: { type: string }
                active_warnings:
                  type: array
                  description: Non-GREEN cube cells, flattened. Each entry has phenomenon_id, location_id, color, valid_from, valid_to, optional headline + advisory text.
                  items:
                    type: object
                    additionalProperties: true
                by_phenomenon:
                  type: object
                  description: "Per-phenomenon highest-active-severity, keyed by phenomenon_id. Drop-in for customers porting meteoserver-style flat keys (ltalarmcode, fxalarmcode, ...). Empty object `{}` when no warnings active."
                  additionalProperties: { type: object, additionalProperties: true }
        meta:
          allOf:
            - $ref: "#/components/schemas/MetaMinimal"
            - type: object
              properties:
                source:            { type: string, example: knmi-dataplatform }
                dataset:           { type: string, example: waarschuwingen_nederland_48h }
                dataset_version:   { type: string, example: "1.0" }
                cache_ttl_seconds: { type: integer, example: 300 }
        warnings: { $ref: "#/components/schemas/Warnings" }

  # ────── Reusable responses (error templates) ──────
  responses:
    AuthenticationError:
      description: |
        Bearer missing, malformed, unknown, revoked, expired, or inactive.
        `error.code` differentiates: `missing_authorization`,
        `invalid_authorization_format`, `invalid_key_format`,
        `key_unknown`, `key_revoked`, `key_inactive`, `key_expired`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

    PermissionError:
      description: |
        Bearer is valid but the key's `scopes` doesn't include the called
        endpoint's group. `error.code = scope_missing`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

    InvalidRequestError:
      description: |
        Validation failed: bad coordinates, missing required param, unknown
        metric, malformed date, etc. `error.code` is endpoint-specific
        (`invalid_coords`, `missing_required_param`, `invalid_source`,
        `range_too_large`, `invalid_hours`, …).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

    RateLimitError:
      description: |
        Period quota or burst limit exceeded. `error.code` is one of
        `free_tier_exhausted`, `tier_quota_exhausted`, `rate_limit_exceeded`.
        `Retry-After` header set; `error.details.resets_at` carries the period
        boundary (ISO 8601).
      headers:
        Retry-After:
          schema: { type: integer, example: 47, description: "Seconds until the limit resets." }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

    NotFoundError:
      description: |
        Station id unknown (`station_unknown`), or — for lat/lng resolution —
        the nearest KNMI station is beyond the 60 km coverage cap
        (`out_of_coverage`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

    UpstreamError:
      description: |
        We're up but an upstream we depend on (KNMI Data Platform, ECMWF Open
        Data, Rijkswaterstaat DDAPI, Nominatim, ...) is unreachable
        or returning errors. Safe to retry with backoff.
        `error.code` is `forecast_upstream_failure`, `warnings_upstream_failure`,
        `marine_upstream_failure`, etc.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

    UpstreamDisabled:
      description: |
        The endpoint relies on an upstream that is not currently wired up
        (or is gated behind a feature flag we haven't enabled). `error.code`
        is `endpoint_unavailable` with `error.message` naming which endpoint
        and (where applicable) why. Examples:

        - `/v1/marine/forecast` outside the ECMWF wave model coverage
          (EU seas + the global sea crops)
        - `/v1/forecast`, `/v1/forecast/short`, `/v1/uv`, `/v1/solar`,
          `/v1/air-quality`, `/v1/pollen` for a coordinate outside the
          relevant regional crop
        - `/v1/water` outside the Netherlands

        Treat 503 here as "this coordinate is outside that product's
        footprint", not as an outage of the broader service. The
        `Retry-After` header is NOT set — retrying the same coordinate won't
        help; see the per-endpoint coverage notes.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
