openapi: 3.1.0

info:
  title: meteofeed · external weather API
  version: "v1"
  description: |
    Read-only REST API for current observations, forecasts, historical data,
    sun ephemeris, UV index, marine waves and severe-weather warnings.

    Upstream sources (all CC-BY-4.0 / open-data, full commercial use):
      - **KNMI** (Royal Netherlands Meteorological Institute) — NL
        observations, historicals, radar, warnings, HARMONIE-AROME
        forecast (2.5 km, NL + border).
      - **ECMWF Open Data** — IFS deterministic forecast (0.25° Europe)
        for coords outside the HARMONIE bbox; ECMWF wave model (0.5° EU
        region) for `/v1/marine/forecast`.
      - **Rijkswaterstaat (DDAPI 2.0)** — Dutch water-level observations.
      - **PDOK Locatieserver** — Dutch geocoder.
      - **Compute** — sun position (NOAA SPA), UV index (Madronich/
        McKenzie clear-sky × cloud attenuation), climate norms (CH
        aggregates over the KNMI archive).

    Bearer-token authentication. Enveloped JSON responses. Stripe-style
    nested error objects. Per-key + per-tier rate limits + monthly/daily
    quotas (free tier: 100/day; paid tiers: 10k → 1M per month).

    **Coverage gates** — forecast endpoints (`/v1/forecast`,
    `/v1/forecast/short`, `/v1/uv`, `/v1/solar`) cover NL + Europe;
    `/v1/marine/forecast` covers the EU-region bbox (lat 30..72, lng
    -30..40). Outside these bbox the endpoints return `503
    endpoint_unavailable` — see the response details on each endpoint.
    Two endpoints currently return `503` while their replacement
    upstreams are evaluated: `/v1/water/forecast` (tide curve — pending
    pure-PHP astronomical compute or Copernicus Marine integration) and
    `/v1/historical/forecast` (archived past-model runs — indefinitely
    deferred). All other forecast endpoints serve directly from KNMI
    HARMONIE / ECMWF Open Data IFS / ECMWF wave / Copernicus CAMS.

    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: KNMI weather station registry (76 stations across NL).
  - name: Current
    description: Latest observation per station — live sub-hourly KNMI data.
  - name: Forecast
    description: Hourly forecasts — KNMI HARMONIE-AROME (NL) → ECMWF IFS (Europe). 503 outside Europe.
  - name: Historical
    description: Hourly + daily historical observations from KNMI's 56-year archive.
  - 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: KNMI severe-weather warnings for the Netherlands (48-hour outlook).
  - name: Radar
    description: KNMI precipitation radar PNG tiles (NL, 5-min cadence, ~17 month retention).
  - name: Solar
    description: PV / solar-radiation bundle — live KNMI obs + ECMWF/HARMONIE forecast + computed sun position.
  - name: AirQuality
    description: Hourly air-quality + pollen forecast direct from Copernicus CAMS European regional ensemble. EU coverage only (lat 30..72°, lng -25..45°); outside that the endpoint returns 503.
  - name: Maritime
    description: Maritime observations (KNMI offshore platforms) + wave forecast (ECMWF Open Data wave, EU bbox).
  - name: Water
    description: Rijkswaterstaat water-level observations + tide forecast. Tide forecast is currently 503 — pending tide-source replacement.
  - name: Climate
    description: Monthly climate norms from the KNMI long archive (~56 y) — tier-gated archive depth.
  - name: Geocode
    description: NL forward-geocoder (PDOK Locatieserver) — postal code / address / city / gemeente.

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 all KNMI weather stations
      description: |
        Returns the full registry of 76 KNMI stations (id, WMO ID, name,
        coordinates, elevation, and an `active` flag indicating whether the
        station has reported data in the last 30 days). No pagination.

        **Scope**: `current`
      responses:
        "200":
          description: Station registry.
          headers: { $ref: "#/components/headers/StandardSet" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StationsEnvelope" }
        "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 direct, ECMWF IFS for the rest of Europe
      description: |
        Two-tier coverage waterfall:

          1. **NL path** (default): resolves the nearest KNMI station via
             great-circle distance (haversine); 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 and the NL verbal short
             forecast. `meta.source` is one of `knmi.10min`,
             `knmi.uurgegevens`, `knmi.etmaal`.

          2. **EU fallback**: outside the 60 km KNMI radius but inside
             the ECMWF Open Data IFS bbox (lat 35..72°, lng -15..40°),
             we serve the latest-run step-0 snapshot from
             `weather.forecast_ecmwf` (the "now" hour of the most recent
             4×/day ECMWF run). `meta.source = "ecmwf-ifs"`, station is
             a synthetic grid-point object (`id="ecmwf:LAT:LNG"`).
             Subset of the NL response: rolling-window blocks
             (`temperature`, `grass_temp`, `precipitation`) are null
             since ECMWF has no observation archive. Wind/pressure
             unit-conversions, weather-code decoration, is_day,
             short_forecast and feels-like all work identically.

          3. **Outside Europe**: returns `503 endpoint_unavailable`
             (global coverage pending; the previous OM fallback was
             flag-disabled for licence reasons).

        `?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` for hourly aggregates.
        `?source=` is 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 station
      description: |
        Same shape as `GET /v1/current` but skips the lat/lng resolution and
        queries the given station id directly. Get the list of valid IDs from
        `GET /v1/stations`.

        **Scope**: `current`.
      parameters:
        - in: path
          name: id
          required: true
          description: KNMI station id (e.g. `240` for Schiphol Airport, `260` for De Bilt).
          schema: { type: string, pattern: '^[A-Za-z0-9_-]+$', example: "240" }
        - { $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 → ECMWF IFS)
      description: |
        Hourly + daily forecast served from a two-tier 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. **ECMWF Open Data IFS** (0.25° Europe, ~168 h horizon) for
             coords inside lat 35..72°, lng -15..40°.
          3. Outside both, returns `503 endpoint_unavailable` (global
             coverage pending — see Phase 11 rollout).

        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 the bbox the endpoint
        returns `503 endpoint_unavailable` (global CAMS coverage is
        unscheduled — the Europe ensemble is the highest-resolution
        product CAMS publishes).

        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+)
        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 vs the pre-Phase-11 contract:** `aerosol_optical_depth`,
        `dust` and `uv_index` are no longer surfaced here — those were
        Open-Meteo passthroughs that aren't part of the CAMS Europe
        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`) — thresholds
        are species-specific (grass triggers symptoms at ~5 grains/m³,
        birch at ~10, olive at ~20, per the EAACI / Wörishofen scale).

        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: NL forward-geocoder (postal code / address / city / gemeente / weg)
      description: |
        Free-form forward-geocoding via PDOK Locatieserver (Dutch national
        public-services geocoder). 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.

        NL only. Outside the Dutch postcode grid / address registry the
        result list will be empty (HTTP 200, `count: 0`).

        Use `type` to narrow by PDOK feature class. Without `type` set, PDOK
        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 so cache invalidation is a non-issue.

        **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: type
          in: query
          required: false
          schema:
            type: string
            enum: [postcode, adres, woonplaats, gemeente, weg, provincie]
            example: woonplaats
          description: Optional PDOK 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
        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.

        `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.

        `meta.source` reports both upstreams as a compound label —
        `"knmi.10min + knmi-harmonie"` in NL, `"knmi.10min + ecmwf-ifs"`
        in the rest of Europe.

        **Scope**: `solar`.
      parameters:
        - { $ref: "#/components/parameters/Lat" }
        - { $ref: "#/components/parameters/Lng" }
        - { $ref: "#/components/parameters/Pc" }
        - { $ref: "#/components/parameters/Locatie" }
        - { $ref: "#/components/parameters/Hours" }
      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 — TEMPORARILY UNAVAILABLE
      description: |
        **TEMPORARILY UNAVAILABLE** — returns `503 endpoint_unavailable`.
        The previous tide upstream was flagged off for license-compliance
        reasons. Replacement is pending — likely either Copernicus Marine
        sea-level forecast or pure-PHP astronomical tide compute from
        Rijkswaterstaat harmonic constituents (Phase 11f tide-half).

        Once back online: hourly water-level forecast for the nearest
        coastal/estuarine RWS station. Sea level above MSL in centimetres
        (matches the RWS observation convention so forecast and observed
        can be compared directly), plus a server-detected `extrema[]`
        array marking the high and low tides in the window. Inland queries
        will return `404 out_of_tidal_coverage` with a hint pointing at
        the inland station the query matched.

        `/v1/water/observations` (live RWS readings) and
        `/v1/water/stations` (registry) are NOT affected — those are
        served direct from RWS DDAPI and are live.

        Tier-capped hours via `forecast_max_hours` — same caps as
        `/v1/forecast`.

        **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 from the KNMI long archive (~56y)
      description: |
        Monthly mean / min / max for one metric at the nearest KNMI station,
        aggregated over the last N years of the archive.

        Plays to meteofeed's actual moat: we hold ~56 years of NL daily
        aggregates (`knmi.etmaal`, back to 1970) and ~56 years of hourly
        observations (`knmi.uurgegevens`). One CH GROUP BY toMonth(ts)
        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. Defaults to `knmi.etmaal` (daily aggregates,
            fastest for multi-decade windows). `knmi.uurgegevens` (hourly)
            gives finer-grain min/max but is heavier.
          schema:
            type: string
            enum: [knmi.etmaal, knmi.uurgegevens]
            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 hourly forecast — TEMPORARILY UNAVAILABLE
      description: |
        **TEMPORARILY UNAVAILABLE** — returns `503 endpoint_unavailable`.
        The previous upstream was flagged off for license-compliance
        reasons. This is the painful one to replace (we'd have to
        archive HARMONIE / ECMWF runs ourselves at issue time) and is
        the lowest priority of the Phase 11 rollback; it may stay
        blocked indefinitely.

        Live `/v1/historical/day` is unaffected — that returns what was
        actually MEASURED at the station, served from our 56-year KNMI
        archive. Customers needing forecast verification should compare
        that against their own captured forecasts.

        Once back online: meteoserver-shape
        `historische_uurverwachting.php?dag=YYYYMMDD` parity — returns the
        24 hourly forecast points that the chosen model produced for one
        past calendar date — what the model PREDICTED for that day,
        distinct from `/v1/historical/day` which returns what was actually
        MEASURED at the station.

        Same hourly shape as `/v1/forecast` — `hourly_units` plus a
        column-oriented `hourly` block with temperature, humidity, dew
        point, apparent temperature, precipitation (+ rain/showers/snow
        breakdown), snow depth, pressure (`pressure_msl`), cloud cover
        (total + low/mid/high), visibility, wind speed/direction/gusts
        (m/s), CAPE, UV index, is_day, shortwave radiation, full soil
        profile (3 depths temp + moisture), ET₀, vapour pressure deficit.

        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, …)

        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=` is tier-gated: Free is locked to `best_match` + `gfs`;
            Basic+ unlocks `harmonie` (KNMI HARMONIE-AROME archive).
          - Hard archive floor at `2024-01-01`; hard ceiling at yesterday.

        The previous upstream was an Open-Meteo `historical-forecast-api`
        passthrough, flag-disabled in build 161 for license-compliance.
        A replacement archive (likely self-hosted captures of HARMONIE +
        ECMWF runs at issue time) is unscheduled.
        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 `[2024-01-01, yesterday]`.
        - name: model
          in: query
          required: false
          schema:
            type: string
            enum: [best_match, gfs, harmonie]
            default: best_match
          description: |
            Optional model picker. `best_match` (default, free) blends multiple
            archived models; `gfs` is NOAA GFS archive; `harmonie` is KNMI
            HARMONIE-AROME (~2.5 km NL/Benelux) — paid tiers only.
      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 from the nearest KNMI station
      description: |
        Time-series for one metric over (from, to) at the nearest KNMI station.
        Tier-capped maximum range (Free: 7d, Basic: 90d, Pro: 5y, Business: 50y).
        Bucket size is auto-derived from the requested range unless `bucket` is
        specified.

        Source defaults to `knmi.uurgegevens` (hourly, ~56 yrs deep when fully
        backfilled). Use `?source=knmi.etmaal` for daily aggregates (~75 yrs).

        **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 KNMI precipitation radar tile
      description: |
        Returns the freshest 5-min KNMI radar composite for the Netherlands
        as a 700×765 PNG. We walk back from now (5-min floor) up to 6 frames
        looking for the first one that exists in our cache.

        Response is `image/png` bytes (NOT JSON). Cache-Control:
        `public, max-age=86400, immutable`. The exact timestamp served is
        in the `X-Radar-Ts` header (12-digit `YYYYMMDDHHMM`).

        **Scope**: `radar`.
      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" }
            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 KNMI radar tile
      description: |
        Returns the 700×765 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`, …). Retention is ~17 months on our cache,
        matching KNMI's own rolling window.

        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" }
      responses:
        "200":
          description: PNG image.
          headers:
            X-Radar-Ts: { schema: { type: string, pattern: '^\d{12}$' } }
            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). Coverage is the
        EU-region bbox at 0.5° downsample:

          lat 30..72 °N   (North Atlantic from N.Africa to Svalbard)
          lng -30..40 °E  (W of Ireland to East of Black Sea)

        Outside this bbox the endpoint returns `503 endpoint_unavailable`
        (global ocean coverage pending future Phase 11f work).

        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` (currently 503).

        **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: KNMI severe-weather warnings (48-hour NL outlook)
      description: |
        Returns the latest KNMI severe-weather warnings document for the
        Netherlands. Source dataset: `waarschuwingen_nederland_48h`
        from the KNMI Data Platform; payload is the raw CAP-format XML
        (string, ~2-3 MB typical). Refresh cadence ~5-15 min upstream;
        cached server-side for 5 min.

        Format may switch to structured JSON in a future minor version if
        customer demand surfaces; until then, parse the XML client-side.

        **Scope**: `warnings`.
      parameters:
        - in: query
          name: region
          required: false
          description: Region selector. Currently only `NL` is published.
          schema: { type: string, enum: [NL], 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" }

# ───────────────────────────────────────────────────────────────────────────
# 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 PDOK 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 PDOK Locatieserver 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 PDOK Locatieserver. 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, ECMWF Open Data
        IFS for the rest of Europe, 503 outside). Customers can force a
        specific model if they need predictable behaviour across runs:
          - `best_match` (default, all tiers) — the live coverage waterfall
            described above.
          - `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.
          - `gfs` (all tiers, **currently 503**) — NOAA GFS global. The
            upstream that served this was flag-disabled in build 161 for
            licence reasons; the value is still accepted by the validator
            but currently returns `503 endpoint_unavailable`. Replacement
            pending.

        Requesting a model your tier doesn't have rights to returns
        `403 permission_error / model_not_allowed`.
      schema:
        type: string
        enum: [best_match, gfs, 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: |
        KNMI feed to query. `knmi.10min` (live, sub-hourly — default) or
        `knmi.uurgegevens` (hourly aggregates).
      schema: { type: string, enum: ["knmi.10min", "knmi.uurgegevens"], default: "knmi.10min" }
    SourceHistorical:
      in: query
      name: source
      required: false
      description: |
        KNMI feed for the historical query. `knmi.uurgegevens` (hourly,
        default — ~56 yrs depth when fully backfilled), `knmi.etmaal`
        (daily aggregates, ~75 yrs depth).
      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 }
    RateLimitLimit:
      description: Per-key burst limit (requests per minute).
      schema: { type: integer, example: 60 }
    RateLimitRemaining:
      description: Requests remaining in the current burst window.
      schema: { type: integer, example: 59 }
    RateLimitReset:
      description: Unix timestamp when the burst window resets.
      schema: { type: integer, example: 1779024551 }
    QuotaLimit:
      description: Period quota for your tier (day for Free, month for paid).
      schema: { type: integer, example: 100 }
    QuotaUsed:
      description: Requests used in the current period.
      schema: { type: integer, example: 4 }
    QuotaReset:
      description: Unix timestamp of the next period boundary.
      schema: { type: integer, example: 1779062399 }
    Tier:
      description: Your current tier — one of `free`, `basic`, `pro`, `business`.
      schema: { type: string, enum: [free, basic, pro, business], example: free }
    StandardSet:
      description: Standard rate-limit + quota + tier + request-id headers.
      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 (Stripe-style nested error) ───
    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 }
                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", "ecmwf-ifs"]
              example: "knmi.10min"
              description: |
                Which upstream actually served this response.

                  - `knmi.10min` (default), `knmi.uurgegevens`, `knmi.etmaal`
                    — NL coords within 60km of an active KNMI station.
                  - `ecmwf-ifs` — ECMWF Open Data IFS step-0 fallback for
                    non-NL coords inside the European bbox (lat 35..72°,
                    lng -15..40°). Same envelope shape with a 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. Outside the ECMWF bbox the endpoint
                    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: "One of `knmi-harmonie` (NL) or `ecmwf-ifs` (rest of EU). Empty when the controller emits 503 outside both bboxes." }
                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: "One of `knmi-harmonie` (NL) or `ecmwf-ifs` (rest of EU). Empty when the controller emits 503 outside both bboxes." }
                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 direct). Inside the EU bbox only." }
                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. Thresholds are species-specific:
        grass/mugwort/ragweed trigger symptoms at lower counts than
        alder/birch/olive (see PollenRisk service for exact cuts).
      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: "PDOK `weergavenaam` — human-readable display." }
        type:       { type: string, enum: [postcode, adres, woonplaats, gemeente, weg, provincie], example: woonplaats }
        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: "PDOK relevance score." }

    GeocodeEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            query:   { type: string, example: "Meppel" }
            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, example: pdok-locatieserver }
                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 }
            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 }
            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`)
              additionalProperties: true
        meta:
          allOf:
            - $ref: "#/components/schemas/MetaMinimal"
            - type: object
              properties:
                source:            { type: string, example: "knmi.10min + knmi-harmonie", description: "Compound label naming both the observation source (KNMI 10-min) and the forecast source (`knmi-harmonie` in NL, `ecmwf-ifs` in EU)." }
                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, gfs, harmonie] }
            hours: { type: integer, example: 24 }
            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 } }
                cloud_cover_low:          { type: array, items: { type: integer,                    nullable: true } }
                cloud_cover_mid:          { type: array, items: { type: integer,                    nullable: true } }
                cloud_cover_high:         { type: array, items: { type: integer,                    nullable: true } }
                visibility:               { type: array, items: { type: number, format: float, 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 } }
                uv_index:                 { type: array, items: { type: number, format: float, 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: "model-archive" }
                generation_ms:     { type: number, format: float, nullable: true }
                cache_ttl_seconds: { type: integer, example: 86400 }
                archive_start:     { type: string, format: date, example: "2024-01-01" }
        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 ───
    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, PDOK Locatieserver, ...) 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 EU bbox
          (lat 30..72°, lng -30..40°)
        - `/v1/forecast`, `/v1/forecast/short`, `/v1/uv`, `/v1/solar`
          outside the KNMI HARMONIE bbox (NL+border) AND the ECMWF IFS
          European bbox (lat 35..72°, lng -15..40°)
        - `/v1/water/forecast` — tide curve forecast (pending replacement
          upstream for license-compliance reasons)
        - `/v1/air-quality`, `/v1/pollen` — pending Copernicus CAMS direct
          wiring
        - `/v1/historical/forecast` — pending replacement archive upstream

        These endpoints are coming back online as direct-upstream
        replacements ship (Phase 11). Treat 503 here as a "this corner of
        the API is temporarily flag-disabled", not as an outage of the
        broader service. The `Retry-After` header is NOT set — retrying
        immediately won't help; check meteofeed.com/changelog for the
        feature-rollout state.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
