openapi: 3.1.0
info:
  title: Smarter Weather API
  summary: Public REST API for the Smarter Weather developer platform
  description: |
    The public REST API for Smarter Weather (`sw-api`).

    ## Versioning and deprecation

    - Path-based versioning (`/v1/`).
    - Endpoint removal: 6 months notice.
    - Breaking shape changes: 90 days notice.
    - Active deprecations are listed at `GET /.well-known/deprecations`.
    - Non-breaking additions ship without notice.
    - Fields marked `x-stability: experimental` (also flagged in their
      descriptions) are excluded from the 90-day breaking-change
      commitment during Phase 2 closed beta. Stability commitments apply
      to every field at general availability per ADR 001 unless
      explicitly carved out.

    ## Errors

    Error responses (`4xx`, `5xx`) are served with content type
    `application/problem+json` per [RFC 7807](https://datatracker.ietf.org/doc/html/rfc7807).
    Successful responses use `application/json`.

    ## Authentication

    Most endpoints require an API key passed as an HTTP Bearer token in the
    `Authorization` header:

    ```
    Authorization: Bearer sw_live_xxxx...
    ```

    Test-mode keys (`sw_test_*`) return live data but are excluded from
    billing meter events. Get a key from the developer dashboard at
    [https://smarterweather.com/developers/dashboard](https://smarterweather.com/developers/dashboard)
    once general availability ships.

    ## Rate limits

    Authenticated operations return rate-limit state on every response via
    `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset`
    headers. Exceeding the rate limit returns `429 Too Many Requests` with a
    `Retry-After` header. See individual operation responses for header
    definitions.

    ## `/v1/weather` include tokens

    `/v1/weather` returns a composite response. The `include` query parameter
    is a comma-separated list of sections to include. Unknown tokens are
    silently ignored. The token set is:

    | Token       | Default | Stability | Description                                       |
    |-------------|:-------:|-----------|---------------------------------------------------|
    | `current`   | yes     | stable    | Current conditions (RTMA + MRMS).                 |
    | `hourly`    | yes     | stable    | Hourly forecast (1-168 hours, default 48).        |
    | `daily`     | yes     | stable    | Daily forecast (1-14 days, default 10).           |
    | `alerts`    | yes     | stable    | Active NWS alerts for the location.               |
    | `outlooks`  | yes     | stable    | SPC convective outlooks for the location.         |
    | `astro`     | yes     | stable    | Sunrise/sunset/moon phase, etc.                   |
    | `location`  | no      | stable    | Echo of the resolved location (lat/lon/name/tz).  |
    | `gis`       | no      | stable    | Geographic metadata (population, timezone, nearest radar site). |
    | `narrations`| no      | experimental | LLM-generated narrative text for current/short-term conditions. |
    | `guidance`  | no      | experimental | Distilled NWS forecaster text-product data (AFD/HWO/SWO/MCD).   |
    | `uncertainty` | no    | experimental | NBM probabilistic sub-objects on hourly and daily forecasts (CONUS/Alaska/Hawaii only). |
    | `nowcast`     | no    | experimental | MRMS radar precipitation nowcast (60 min @ 5-min steps, CONUS first). |

    `radar` and `products` (wx visual-products manifest) are **not** in the
    v1 token set. They will land in Phase 5 alongside a separate
    `/v1/radar/*` family, once the public CDN authentication design
    (signed-URL minting or equivalent) is accepted in a follow-up ADR.
  version: 0.1.0
  contact:
    name: Smarter Weather Developer Platform
    url: https://github.com/smarterweather/developer

# Canonical public base URL. Adopted by sw-api at the Phase 2 closed-beta
# launch via Track A.3 PR-0 (#7754) per ADR 013, which supersedes ADR 006's
# "interim execute-api hostname through Phase 5" plan.
#
# SDK generators emit https://api.smarterweather.com as the default base URL
# from this point on. Closed-beta design partners onboarded against the raw
# *.execute-api.us-east-1.amazonaws.com URL (per superseded ADR 006) keep
# working -- the API Gateway URL is unaffected by the CloudFront repointing
# in PR-0 -- but should switch to the canonical hostname before GA.
servers:
  - url: https://api.smarterweather.com
    description: Production

tags:
  - name: Weather
    description: Public weather data endpoints.
  - name: Observations
    description: |
      Surface observations and upper-air soundings sampled at or nearest to a
      point.
  - name: Alerts
    description: NWS weather alert lookups.
  - name: Storms
    description: Severe-weather tracking products (storm tracks, etc.).
  - name: Products
    description: |
      Gridded / vector weather products and derived summaries (risk-zone
      population exposure, etc.).
  - name: Geocode
    description: |
      Forward, reverse, and typeahead geocoding for locations within
      the supported NWS coverage domain (CONUS, AK, HI, PR, GU/MP).
  - name: Health
    description: Service health.
  - name: Meta
    description: Cross-cutting platform endpoints (deprecations, etc.).
  - name: Agriculture
    description: |
      Agriculture-derived indices (Growing Degree Units / GDD) computed on
      read from stored daily temperature extremes.

security:
  - bearerAuth: []

paths:
  /v1/health:
    get:
      operationId: getHealth
      summary: Service health probe.
      description: |
        Returns service readiness and downstream connectivity. Does not require
        authentication.
      tags: [Health]
      security: []
      responses:
        '200':
          description: Service is healthy.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Health'

  /v1/weather:
    get:
      operationId: getWeather
      summary: Get blended weather for a location.
      description: |
        Returns a composite blended weather payload for a single location.
        A single call covers current conditions, hourly forecast, daily
        forecast, active alerts, convective outlooks, and astronomical data
        by default; other sections (location echo, geographic metadata,
        narrative text, forecaster guidance) are available via the
        `include` parameter.

        ### Location selector

        Exactly **one** of the following must be provided:

        - `lat` **and** `lon` — latitude/longitude pair.
        - `zip` — 5-digit US ZIP code.
        - `location` — free-text location name (e.g., `"Boston, MA"`,
          `"94043"`, `"Kyiv"`). Resolved by the same upstream geocoder
          that powers the dedicated `/v1/geocode/{search,autosuggest,reverse}`
          endpoints; clients that need geocoding without a weather
          fetch should call those directly.

        Sending more than one selector, or none, returns `400`.

        ### Units

        - `imperial` (default): °F, mph, inHg, mi, in.
        - `metric`: °C, kph, hPa, km, mm.

        SI is not exposed in v1; partners that need SI should convert
        client-side.

        ### Include semantics

        `include` is a comma-separated list. Unknown tokens are silently
        dropped. See the API-level "Include tokens" table in the
        `info.description` for the full token catalog, defaults, and
        stability markers.

        ### Coverage

        Sections that depend on US-only datasets (e.g., `alerts`, some
        `outlooks`, radar-adjacent `gis.nearest_radar` fields) may be
        empty or absent for international locations. Partial-coverage
        scenarios are reported via `meta.warnings[]` with a structured
        code so clients can respond programmatically.
      tags: [Weather]
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/LatQuery'
        - $ref: '#/components/parameters/LonQuery'
        - $ref: '#/components/parameters/ZipQuery'
        - $ref: '#/components/parameters/LocationQuery'
        - $ref: '#/components/parameters/UnitsQuery'
        - $ref: '#/components/parameters/IncludeQuery'
        - $ref: '#/components/parameters/DaysQuery'
        - $ref: '#/components/parameters/HourlyHoursQuery'
      responses:
        '200':
          description: Blended weather data for the requested location.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Weather'
              examples:
                boston:
                  $ref: '#/components/examples/WeatherBoston'
        '400':
          $ref: '#/components/responses/Problem400'
        '401':
          $ref: '#/components/responses/Problem401'
        '403':
          $ref: '#/components/responses/Problem403'
        '404':
          $ref: '#/components/responses/Problem404'
        '429':
          $ref: '#/components/responses/Problem429'
        '500':
          $ref: '#/components/responses/Problem500'
        '502':
          $ref: '#/components/responses/Problem502'
        '503':
          $ref: '#/components/responses/Problem503'
        '504':
          $ref: '#/components/responses/Problem504'

  /v1/weather/batch:
    post:
      operationId: postWeatherBatch
      summary: Get blended weather for up to 25 locations in one call.
      description: |
        Multi-location form of `GET /v1/weather`. Accepts a JSON body with
        an array of up to 25 locations and returns one F2 weather result
        per input, preserving order.

        ### Locations

        Each entry in `locations[]` follows the same selector rules as
        the single-location endpoint: exactly one of `lat`+`lon`, `zip`,
        or `location` must be set. Mixing selectors in a single entry,
        or omitting all of them, fails that row only — the rest of the
        batch still runs.

        ### Per-location failure semantics

        A single location failure does not fail the whole request. The
        corresponding `results[i]` carries `error` (a structured
        `WeatherBatchError`) instead of `weather`. Callers can detect a
        partial result by scanning for non-null `error` fields on
        `results[]`. Section-level partial failures inside a successfully
        resolved location surface as `weather.meta.warnings[]`, exactly
        as for `GET /v1/weather`.

        ### Shared parameters

        `include`, `units`, `days`, and `hourly_hours` are applied to
        every location; per-location overrides are not supported. Defaults
        match the `GET /v1/weather` defaults.
      tags: [Weather]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WeatherBatchRequest'
            examples:
              twoLocations:
                $ref: '#/components/examples/WeatherBatchRequestTwoLocations'
      responses:
        '200':
          description: Batch result, one entry per input location.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WeatherBatchResponse'
        '400':
          $ref: '#/components/responses/Problem400'
        '401':
          $ref: '#/components/responses/Problem401'
        '403':
          $ref: '#/components/responses/Problem403'
        '429':
          $ref: '#/components/responses/Problem429'
        '500':
          $ref: '#/components/responses/Problem500'
        '502':
          $ref: '#/components/responses/Problem502'
        '503':
          $ref: '#/components/responses/Problem503'
        '504':
          $ref: '#/components/responses/Problem504'

  /v1/alerts/{id}:
    get:
      operationId: getAlertById
      summary: Get a single NWS alert by id.
      description: |
        Returns the full alert detail (geometry, spatial metadata,
        affected population, etc.) for a single NWS alert. Accepts
        either a `group_id` (the canonical primary key on the alerts
        table) or a `message_id` (resolved via `nws_alerts_map`);
        callers do not need to disambiguate.

        ### Caching

        Successful responses carry `Cache-Control: public, max-age=60`
        to match the upstream cache policy. CDN edge cache is keyed
        on the URL path so cache invalidation tracks alert
        replacement / cancellation events naturally.

        ### Geometry

        `geometry` is a GeoJSON object embedded in the response.
        Clients should treat it as opaque GeoJSON: forward-compat
        fields the upstream pipeline adds without a sw-api release
        pass through unchanged.

        ### Auth

        Standard sw-api Bearer auth applies. Internal first-party
        keys (`sw_internal_web_*`, `sw_internal_ios_*`,
        `sw_internal_og_*`) are accepted alongside `sw_live_*` /
        `sw_test_*` partner keys; the OG-image lambda authenticates
        with the `og` audience.
      tags: [Alerts]
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: |
            Alert identifier — either `group_id` (canonical primary
            key) or `message_id` (NWS-issued, resolved via
            `nws_alerts_map`). URL-encode embedded dots / colons /
            slashes; the handler `url.PathEscape`s on the wire.
          schema:
            type: string
            minLength: 1
            maxLength: 256
          example: "1.2.3-NWS-WW.SAMPLE.CASE"
      responses:
        '200':
          description: Alert detail for the requested id.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AlertDetail'
        '400':
          $ref: '#/components/responses/Problem400'
        '401':
          $ref: '#/components/responses/Problem401'
        '403':
          $ref: '#/components/responses/Problem403'
        '404':
          $ref: '#/components/responses/Problem404'
        '429':
          $ref: '#/components/responses/Problem429'
        '500':
          $ref: '#/components/responses/Problem500'
        '502':
          $ref: '#/components/responses/Problem502'
        '503':
          $ref: '#/components/responses/Problem503'
        '504':
          $ref: '#/components/responses/Problem504'

  /v1/products/{product_id}/exposure-summary:
    get:
      operationId: getProductExposureSummary
      summary: Cumulative population exposure for a risk-zone product.
      description: |
        Returns the total population inside all risk-band polygons at or above
        a threshold level for a risk-zone product's latest issuance — the data
        behind headlines like "~57M under major heat risk".

        Generic across every product that publishes exclusive risk-band
        polygons (NWS HeatRisk today; SPC categorical, WPC flood, and
        winter/cold outlooks later). Because the bands are spatially exclusive,
        a cumulative `>= min_level` figure is a straight sum with no
        double-counting.

        ### Parameters

        `min_level` is the inclusive cumulative threshold on the ordinal
        `risk_level` (e.g. HeatRisk `3` = Major and above). When omitted the
        upstream default (`1`, any elevated risk) applies. `valid_time` selects
        a specific issuance (RFC3339); omit it for the latest.

        ### Caching

        Successful responses carry `Cache-Control: public, max-age=60,
        stale-while-revalidate=60`. Edge cache key includes the query string
        so each `min_level` / `valid_time` variant gets its own TTL.

        ### Auth

        Standard sw-api Bearer auth applies.
      tags: [Products]
      security:
        - bearerAuth: []
      parameters:
        - name: product_id
          in: path
          required: true
          description: |
            Risk-zone product identifier (lowercase, digits, underscores),
            e.g. `heatrisk_day1_conus`.
          schema:
            type: string
            pattern: '^[a-z0-9_]+$'
            minLength: 1
            maxLength: 128
          example: "heatrisk_day1_conus"
        - name: min_level
          in: query
          required: false
          description: Inclusive cumulative threshold on the ordinal risk_level.
          schema:
            type: integer
            minimum: 0
          example: 3
        - name: valid_time
          in: query
          required: false
          description: RFC3339 issuance (valid_time) to summarize. Defaults to the latest.
          schema:
            type: string
            format: date-time
          example: "2026-06-30T22:00:00Z"
      responses:
        '200':
          description: Cumulative population-exposure summary.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/json:
              schema:
                type: object
                required: [product_id, min_level, population, feature_count]
                properties:
                  product_id:
                    type: string
                    example: "heatrisk_day1_conus"
                  min_level:
                    type: integer
                    example: 3
                  population:
                    type: integer
                    format: int64
                    description: Total population under risk_level >= min_level.
                    example: 57000000
                  feature_count:
                    type: integer
                    description: Number of risk-band polygons contributing to the sum.
                    example: 2
                  valid_time:
                    type: ["string", "null"]
                    format: date-time
                    description: Issuance the summary was computed for (null if no features).
                    example: "2026-06-30T22:00:00Z"
        '400':
          $ref: '#/components/responses/Problem400'
        '401':
          $ref: '#/components/responses/Problem401'
        '403':
          $ref: '#/components/responses/Problem403'
        '429':
          $ref: '#/components/responses/Problem429'
        '500':
          $ref: '#/components/responses/Problem500'
        '502':
          $ref: '#/components/responses/Problem502'
        '503':
          $ref: '#/components/responses/Problem503'
        '504':
          $ref: '#/components/responses/Problem504'

  /v1/storm-tracks:
    get:
      operationId: getStormTracks
      summary: Get active storm-cell tracks as GeoJSON.
      description: |
        Returns a GeoJSON `FeatureCollection` of active storm-cell
        tracks aggregated from NEXRAD radar products. Each feature
        carries cell metadata (id, site, scan / valid times) plus an
        optional impact section listing affected cities and population
        when `detailed=true` is requested.

        ### Site filter

        `site` is an optional NEXRAD radar site code. The storm-track
        pipeline keys on the 3-letter site code (e.g., `LOT`, `TLX`);
        a 4-letter ICAO (e.g., `KLOT`) is also accepted. Empty `site`
        returns one feature per active radar cell across the network;
        setting `site` filters to that radar. Site IDs are uppercased
        server-side.

        ### Detailed mode

        `detailed=true` requests the LATERAL-joined impact payload
        (`properties.impact.affected_cities`,
        `properties.impact.affected_population`). Detailed responses
        are larger and use a 60s reader cache; the all-sites all-cells
        path uses a 30s cache.

        ### Schema stability

        The GeoJSON `type`, `geometry`, and `properties.cell_id` /
        `properties.site_id` / `properties.scan_time` /
        `properties.valid_time` fields are stable. Other property-bag
        keys (`impact.*`, `category`, etc.) are documented as
        forward-compat: the reader pipeline may add new keys without a
        sw-api release, so consumers should treat unknown keys as
        opaque additive data.

        ### Caching

        Successful responses carry `Cache-Control: public, max-age=30,
        stale-while-revalidate=30`. Edge cache key includes the
        full query string (`site` and `detailed`) so each variant
        gets its own TTL.

        ### Auth

        Standard sw-api Bearer auth applies. The legacy CRA storm-tracks
        UI uses `sw_internal_web_*` keys; partners use `sw_live_*`.
      tags: [Storms]
      security:
        - bearerAuth: []
      parameters:
        - name: site
          in: query
          required: false
          description: |
            Optional NEXRAD radar site code. The storm-track pipeline keys
            on the 3-letter site code (e.g., `LOT`); a 4-letter ICAO (e.g.,
            `KLOT`) is also accepted. Empty returns active cells across all
            radar sites.
          schema:
            type: string
            pattern: '^[A-Za-z][A-Za-z0-9]{2,3}$'
            minLength: 3
            maxLength: 4
          example: "LOT"
        - name: detailed
          in: query
          required: false
          description: |
            When `true`, includes the LATERAL-joined impact payload
            (affected cities + populations). When omitted or `false`,
            returns the lightweight cell-only response.
          schema:
            type: boolean
            default: false
      responses:
        '200':
          description: |
            GeoJSON FeatureCollection. Empty `features` array when no
            active cells are tracked (this is a 200, not a 404 — a 200
            with `{"features": []}` is the canonical "no active storms"
            response).
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/geo+json:
              schema:
                $ref: '#/components/schemas/StormTracksFeatureCollection'
        '400':
          $ref: '#/components/responses/Problem400'
        '401':
          $ref: '#/components/responses/Problem401'
        '403':
          $ref: '#/components/responses/Problem403'
        '429':
          $ref: '#/components/responses/Problem429'
        '500':
          $ref: '#/components/responses/Problem500'
        '502':
          $ref: '#/components/responses/Problem502'
        '503':
          $ref: '#/components/responses/Problem503'
        '504':
          $ref: '#/components/responses/Problem504'

  /v1/observations:
    get:
      operationId: getObservations
      summary: Get latest METAR station observations as GeoJSON.
      description: |
        Returns a GeoJSON FeatureCollection of METAR surface observations
        ingested from MADIS. By default returns the latest observation per
        station within the last 2 hours. Optional `station`, `hours`, and
        bounding-box (`min_lon`, `min_lat`, `max_lon`, `max_lat`) filters
        are forwarded to the reader.
      tags: [Observations]
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/LatQuery'
        - $ref: '#/components/parameters/LonQuery'
        - name: station
          in: query
          schema:
            type: string
        - name: hours
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 24
        - name: min_lon
          in: query
          schema:
            type: number
        - name: min_lat
          in: query
          schema:
            type: number
        - name: max_lon
          in: query
          schema:
            type: number
        - name: max_lat
          in: query
          schema:
            type: number
      responses:
        '200':
          description: GeoJSON FeatureCollection of METAR observations.
          content:
            application/geo+json:
              schema:
                type: object
        '502':
          $ref: '#/components/responses/Problem502'

  /v1/observations/nearest:
    get:
      operationId: getObservationsNearest
      summary: Get nearest METAR station observations.
      description: |
        Returns the N nearest METAR stations to a point, deduplicated to
        the latest observation per station, sorted by distance.
      tags: [Observations]
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/LatQuery'
        - $ref: '#/components/parameters/LonQuery'
        - name: n
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 10
            default: 1
      responses:
        '200':
          description: GeoJSON FeatureCollection of nearest METAR observations.
          content:
            application/geo+json:
              schema:
                type: object
        '400':
          $ref: '#/components/responses/Problem400'
        '502':
          $ref: '#/components/responses/Problem502'

  /v1/soundings:
    get:
      operationId: getSoundings
      summary: Get latest RAOB radiosonde soundings as GeoJSON.
      description: |
        Returns a GeoJSON FeatureCollection of RAOB (radiosonde) vertical
        soundings ingested from MADIS. By default returns the latest sounding
        per station within the last 24 hours. Optional `station` and `hours`
        filters are forwarded to the reader.

        Heavy `profile` and `wind_profile` arrays are stripped from bulk
        responses to keep payloads small for map overlays. Use
        `/v1/soundings/nearest` to fetch the full vertical profile for a
        specific site.

        Each feature retains `station_id`, `observed_at`, `elevation_m`,
        `level_count`, `wind_level_count`, and the `derived` indices object
        (see `/v1/soundings/nearest` for field descriptions). The reader
        also injects `valid_time` from the database column.
      tags: [Observations]
      security:
        - bearerAuth: []
      parameters:
        - name: station
          in: query
          schema:
            type: string
        - name: hours
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 24
      responses:
        '200':
          description: GeoJSON FeatureCollection of RAOB soundings (profile stripped).
          content:
            application/geo+json:
              schema:
                type: object
        '502':
          $ref: '#/components/responses/Problem502'
        '504':
          $ref: '#/components/responses/Problem504'

  /v1/soundings/nearest:
    get:
      operationId: getSoundingsNearest
      summary: Get nearest RAOB radiosonde soundings.
      description: |
        Returns the N nearest RAOB (radiosonde) vertical soundings to a
        point, deduplicated to the latest sounding per station, sorted by
        distance. Soundings are launched at 00Z/12Z, so data can be several
        hours old.

        Each feature carries:

        - `profile` — the pressure-indexed thermodynamic sounding (mandatory
          + significant-temperature levels merged, sorted surface-first) with
          aligned arrays `pressure_hpa`, `height_m` (null at
          significant-temperature-only levels), `temperature_c`, `dewpoint_c`,
          `wind_dir`, `wind_speed_kt`, plus `level_count`.
        - `wind_profile` — a height-indexed wind sounding (mandatory +
          significant-wind levels, sorted ascending by height MSL) with aligned
          arrays `height_m`, `wind_dir`, `wind_speed_kt`, plus
          `wind_level_count`. Use this for hodographs and shear.
        - `derived` — server-computed thermodynamic/kinematic indices
          (metric-raw, every field nullable): `sbcape`/`sbcin`,
          `mucape`/`mucin`, `mlcape`/`mlcin` (J/kg); `lifted_index`,
          `k_index`, `total_totals` (°C); `pwat_mm`; `freezing_level_m`;
          `lcl_*`/`lfc_*`/`el_*` pressure and height; `bulk_shear_0_6km_kt`.
        - station metadata: `station_id`, `observed_at`, `elevation_m`.

        The full `properties` bag is forward-compatible: the reader pipeline
        may add keys without a sw-api release, so treat unknown keys as opaque.
      tags: [Observations]
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/LatQuery'
        - $ref: '#/components/parameters/LonQuery'
        - name: n
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 5
            default: 1
      responses:
        '200':
          description: GeoJSON FeatureCollection of nearest RAOB soundings.
          content:
            application/geo+json:
              schema:
                type: object
        '400':
          $ref: '#/components/responses/Problem400'
        '502':
          $ref: '#/components/responses/Problem502'
        '504':
          $ref: '#/components/responses/Problem504'

  /v1/storm-reports:
    get:
      operationId: getStormReports
      summary: Get recent NWS local storm reports as GeoJSON.
      description: |
        Returns a GeoJSON `FeatureCollection` of recent NWS local storm
        reports (LSR) — observed tornadoes, hail, damaging wind, flood,
        etc. Each feature carries the report's geometry plus a
        `properties` bag with at minimum `valid_time` and the
        report's payload fields (event-type-specific magnitude /
        narrative). The full property bag is forward-compat: the reader
        pipeline may add new keys without a sw-api release, so consumers
        should treat unknown keys as opaque additive data.

        ### Filters

        - `hours` — rolling lookback in whole hours (1 ≤ N ≤ 168). Empty
          falls through to the reader's default window.
        - `type` — single-letter NWS report code (e.g. `T` tornado, `H`
          hail, `W` damaging wind, `F` flood). Server uppercases the
          input before forwarding.
        - `wfo` — 3-letter NWS Weather Forecast Office identifier (e.g.
          `LOT`). Server uppercases the input.

        Filters compose: setting all three returns the AND of each
        filter applied to the reader's recent-reports table.

        ### Caching

        Successful responses carry `Cache-Control: public, max-age=30,
        stale-while-revalidate=30`. Edge cache key includes the full
        query string so each filter combination gets its own TTL.

        ### Auth

        Standard sw-api Bearer auth applies.
      tags: [Storms]
      security:
        - bearerAuth: []
      parameters:
        - name: hours
          in: query
          required: false
          description: |
            Rolling lookback window in whole hours (1-168). When empty,
            the reader's default window applies.
          schema:
            type: integer
            minimum: 1
            maximum: 168
          example: 6
        - name: type
          in: query
          required: false
          description: |
            Single-letter NWS report code. Common values include
            `T` (tornado), `H` (hail), `W` (damaging wind), `F` (flood).
          schema:
            type: string
            pattern: '^[A-Za-z]$'
            minLength: 1
            maxLength: 1
          example: "T"
        - name: wfo
          in: query
          required: false
          description: |
            3-letter NWS Weather Forecast Office identifier
            (e.g. `LOT` for Chicago).
          schema:
            type: string
            pattern: '^[A-Za-z]{3}$'
            minLength: 3
            maxLength: 3
          example: "LOT"
      responses:
        '200':
          description: |
            GeoJSON FeatureCollection. Empty `features` array when no
            reports match the filters (200, not 404).
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/geo+json:
              schema:
                $ref: '#/components/schemas/StormReportsFeatureCollection'
        '400':
          $ref: '#/components/responses/Problem400'
        '401':
          $ref: '#/components/responses/Problem401'
        '403':
          $ref: '#/components/responses/Problem403'
        '429':
          $ref: '#/components/responses/Problem429'
        '500':
          $ref: '#/components/responses/Problem500'
        '502':
          $ref: '#/components/responses/Problem502'
        '503':
          $ref: '#/components/responses/Problem503'
        '504':
          $ref: '#/components/responses/Problem504'

  /v1/nexrad/features:
    get:
      operationId: getNexradFeatures
      summary: Get NEXRAD Level III algorithm-detection overlays as GeoJSON.
      description: |
        Returns a GeoJSON `FeatureCollection` of NEXRAD Level III
        algorithm-detection overlays — Hail Index, Mesocyclone, and
        Tornado Vortex Signature (TVS) — for a single radar site. Each
        feature carries `site_id` plus the product-specific detection
        payload (e.g. probability-of-hail / max hail size, mesocyclone
        or TVS strength) in a forward-compat `properties` bag. Treat
        unknown keys as opaque additive data.

        ### Product selector

        `product` is required and selects which detection layer to
        return: `hail_index`, `mesocyclone`, or `tvs`.

        ### Site selector

        `site` is the 3-letter Level III radar site code (e.g. `TLX`);
        a 4-letter ICAO (e.g. `KTLX`) is also accepted and uppercased
        server-side. As an alternative to `site`, pass `lat` + `lon`
        together to resolve the nearest radar server-side.

        ### Caching

        Successful responses carry `Cache-Control: public, max-age=30,
        stale-while-revalidate=30`.

        ### Auth

        Standard sw-api Bearer auth applies.
      tags: [Storms]
      security:
        - bearerAuth: []
      parameters:
        - name: product
          in: query
          required: true
          description: |
            Detection product to return. One of `hail_index`,
            `mesocyclone`, `tvs`.
          schema:
            type: string
            enum: [hail_index, mesocyclone, tvs]
          example: "hail_index"
        - name: site
          in: query
          required: false
          description: |
            NEXRAD Level III radar site code. 3-letter (e.g. `TLX`) or
            4-letter ICAO (e.g. `KTLX`); uppercased server-side. Omit to
            use the `lat`/`lon` nearest-radar fallback.
          schema:
            type: string
            pattern: '^[A-Za-z][A-Za-z0-9]{2,3}$'
            minLength: 3
            maxLength: 4
          example: "TLX"
        - name: lat
          in: query
          required: false
          description: |
            Latitude for nearest-radar resolution. Must be provided
            together with `lon`; ignored when `site` is set.
          schema:
            type: number
            format: double
          example: 35.33
        - name: lon
          in: query
          required: false
          description: |
            Longitude for nearest-radar resolution. Must be provided
            together with `lat`; ignored when `site` is set.
          schema:
            type: number
            format: double
          example: -97.28
      responses:
        '200':
          description: |
            GeoJSON FeatureCollection. Empty `features` array when no
            detections exist for the product/site (200, not 404).
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/geo+json:
              schema:
                $ref: '#/components/schemas/NexradFeaturesFeatureCollection'
        '400':
          $ref: '#/components/responses/Problem400'
        '401':
          $ref: '#/components/responses/Problem401'
        '403':
          $ref: '#/components/responses/Problem403'
        '429':
          $ref: '#/components/responses/Problem429'
        '500':
          $ref: '#/components/responses/Problem500'
        '502':
          $ref: '#/components/responses/Problem502'
        '503':
          $ref: '#/components/responses/Problem503'
        '504':
          $ref: '#/components/responses/Problem504'

  /v1/tropical:
    get:
      operationId: getTropical
      summary: Get the aggregated NHC tropical overlay as GeoJSON.
      description: |
        Returns a single GeoJSON `FeatureCollection` aggregating the
        latest National Hurricane Center tropical overlay: forecast cone,
        track line, forecast points, and coastal watch/warning segments
        for every active storm, plus the 7-day Tropical Weather Outlook
        (TWO) areas. Each feature carries a `kind` property the client
        uses to pick its renderer (`cone`, `track`, `points`,
        `watch_warning`, `two`); the `properties` bag is forward-compat.

        The endpoint takes no parameters.

        ### Caching

        Successful responses carry `Cache-Control: public, max-age=60,
        stale-while-revalidate=60`.

        ### Auth

        Standard sw-api Bearer auth applies.
      tags: [Storms]
      security:
        - bearerAuth: []
      responses:
        '200':
          description: |
            GeoJSON FeatureCollection. Empty `features` array when no
            tropical systems or outlook areas are active (200, not 404).
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/geo+json:
              schema:
                $ref: '#/components/schemas/TropicalFeatureCollection'
        '401':
          $ref: '#/components/responses/Problem401'
        '403':
          $ref: '#/components/responses/Problem403'
        '429':
          $ref: '#/components/responses/Problem429'
        '500':
          $ref: '#/components/responses/Problem500'
        '502':
          $ref: '#/components/responses/Problem502'
        '503':
          $ref: '#/components/responses/Problem503'
        '504':
          $ref: '#/components/responses/Problem504'

  /v1/record-events:
    get:
      operationId: getRecordEvents
      summary: Get NWS climate-record events as GeoJSON.
      description: |
        Returns a GeoJSON `FeatureCollection` of NWS climate-record
        events (record high / low temperatures, daily precipitation
        records, etc.) within the requested time window. Each feature's
        `properties` bag carries at minimum `valid_time` plus the
        event-type-specific payload from the reader pipeline. As with
        `/v1/storm-reports`, additional `properties` keys may appear
        without a sw-api release.

        ### Time window

        Exactly one of three selectors is forwarded to the reader:

        1. `date=YYYY-MM-DD` — single-day window
        2. `start=...&end=...` — explicit RFC3339 window (both required
           together)
        3. `hours=N` — rolling lookback (1 ≤ N ≤ 720, i.e. 30 days)

        When more than one selector is set, the precedence is
        `date` > `start+end` > `hours`. The handler enforces this by
        forwarding all three to libs/wxreader, which picks the
        highest-priority selector.

        ### Filters

        - `wfo` — 3-letter NWS Weather Forecast Office identifier.
        - `type` — alphabetic record-event code (2-16 characters, e.g.
          `high`, `low`, `precip`, `snow`).

        ### Caching

        Successful responses carry `Cache-Control: public, max-age=60,
        stale-while-revalidate=60`. Mirrors the reader's
        recordEventsCacheTTL.

        ### Auth

        Standard sw-api Bearer auth applies.
      tags: [Storms]
      security:
        - bearerAuth: []
      parameters:
        - name: hours
          in: query
          required: false
          description: |
            Rolling lookback window in whole hours (1-720). Lowest
            precedence among the time-window selectors.
          schema:
            type: integer
            minimum: 1
            maximum: 720
          example: 168
        - name: date
          in: query
          required: false
          description: |
            ISO-8601 date (YYYY-MM-DD). Highest-precedence time-window
            selector; trumps `start+end` and `hours` when supplied.
          schema:
            type: string
            pattern: '^\d{4}-\d{2}-\d{2}$'
          example: "2026-05-07"
        - name: start
          in: query
          required: false
          description: |
            RFC3339 timestamp marking the start of the window. Must
            be supplied together with `end`.
          schema:
            type: string
            format: date-time
          example: "2026-05-01T00:00:00Z"
        - name: end
          in: query
          required: false
          description: |
            RFC3339 timestamp marking the end of the window. Must be
            supplied together with `start`.
          schema:
            type: string
            format: date-time
          example: "2026-05-07T00:00:00Z"
        - name: wfo
          in: query
          required: false
          description: 3-letter NWS Weather Forecast Office identifier.
          schema:
            type: string
            pattern: '^[A-Za-z]{3}$'
            minLength: 3
            maxLength: 3
          example: "LOT"
        - name: type
          in: query
          required: false
          description: |
            Alphabetic record-event code (e.g. `high`, `low`,
            `precip`, `snow`). 2-16 characters.
          schema:
            type: string
            pattern: '^[A-Za-z]{2,16}$'
            minLength: 2
            maxLength: 16
          example: "high"
      responses:
        '200':
          description: |
            GeoJSON FeatureCollection. Empty `features` array when no
            record events match the window + filters.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/geo+json:
              schema:
                $ref: '#/components/schemas/RecordEventsFeatureCollection'
        '400':
          $ref: '#/components/responses/Problem400'
        '401':
          $ref: '#/components/responses/Problem401'
        '403':
          $ref: '#/components/responses/Problem403'
        '429':
          $ref: '#/components/responses/Problem429'
        '500':
          $ref: '#/components/responses/Problem500'
        '502':
          $ref: '#/components/responses/Problem502'
        '503':
          $ref: '#/components/responses/Problem503'
        '504':
          $ref: '#/components/responses/Problem504'

  /v1/geocode/reverse:
    get:
      operationId: getGeocodeReverse
      summary: Reverse-geocode a coordinate to a location.
      description: |
        Resolves a `(lat, lon)` coordinate into a city / nearest-city /
        state / county / IANA timezone tuple. Coverage is the supported
        NWS domain (CONUS, AK, HI, PR, GU/MP); coordinates outside the
        domain return a 404 Problem.

        ### Caching

        Successful responses carry `Cache-Control: public,
        max-age=86400`. The underlying place-name <-> timezone mapping
        changes on the order of years; clients should treat this as a
        long-term cache.

        ### Auth

        Standard sw-api Bearer auth applies.
      tags: [Geocode]
      security:
        - bearerAuth: []
      parameters:
        - name: lat
          in: query
          required: true
          description: Latitude in decimal degrees, range [-90, 90].
          schema:
            type: number
            format: double
            minimum: -90
            maximum: 90
          example: 41.8781
        - name: lon
          in: query
          required: true
          description: Longitude in decimal degrees, range [-180, 180].
          schema:
            type: number
            format: double
            minimum: -180
            maximum: 180
          example: -87.6298
      responses:
        '200':
          description: Resolved location metadata.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReverseGeocodeResponse'
        '400':
          $ref: '#/components/responses/Problem400'
        '401':
          $ref: '#/components/responses/Problem401'
        '403':
          $ref: '#/components/responses/Problem403'
        '404':
          $ref: '#/components/responses/Problem404'
        '429':
          $ref: '#/components/responses/Problem429'
        '500':
          $ref: '#/components/responses/Problem500'
        '502':
          $ref: '#/components/responses/Problem502'
        '503':
          $ref: '#/components/responses/Problem503'
        '504':
          $ref: '#/components/responses/Problem504'

  /v1/geocode/autosuggest:
    get:
      operationId: getGeocodeAutosuggest
      summary: Typeahead-style location lookup.
      description: |
        Returns up to `count` location candidates whose names match the
        partial `input` string. Designed for client-side typeahead
        widgets: latency is dominated by the reader's PostGIS prefix
        index; `Cache-Control: public, max-age=300` keeps repeated
        keystrokes off the read replica.

        ### Auth

        Standard sw-api Bearer auth applies.
      tags: [Geocode]
      security:
        - bearerAuth: []
      parameters:
        - name: input
          in: query
          required: true
          description: |
            Partial place-name to expand. 2-128 characters, restricted
            to alphanumerics + spaces + `,`, `.`, `-`, and `'`.
          schema:
            type: string
            minLength: 2
            maxLength: 128
            pattern: "^[A-Za-z0-9 ,.\\-']+$"
          example: "Chic"
        - name: count
          in: query
          required: false
          description: |
            Maximum number of results (1-100). Defaults to 10.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 10
          example: 10
      responses:
        '200':
          description: |
            Match results. Empty `results` array when no candidates
            match the input.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GeocodeResultsResponse'
        '400':
          $ref: '#/components/responses/Problem400'
        '401':
          $ref: '#/components/responses/Problem401'
        '403':
          $ref: '#/components/responses/Problem403'
        '429':
          $ref: '#/components/responses/Problem429'
        '500':
          $ref: '#/components/responses/Problem500'
        '502':
          $ref: '#/components/responses/Problem502'
        '503':
          $ref: '#/components/responses/Problem503'
        '504':
          $ref: '#/components/responses/Problem504'

  /v1/geocode/search:
    get:
      operationId: getGeocodeSearch
      summary: Forward-geocode a place name or US ZIP.
      description: |
        Returns location candidates matching either a 5-digit US `zipcode`
        or a `city` (with optional `state` disambiguator). Exactly one
        of `city` or `zipcode` must be supplied.

        ### Caching

        Successful responses carry `Cache-Control: public,
        max-age=3600`. The underlying place-name <-> ZIP mapping is
        stable on the order of months.

        ### Auth

        Standard sw-api Bearer auth applies.
      tags: [Geocode]
      security:
        - bearerAuth: []
      parameters:
        - name: city
          in: query
          required: false
          description: |
            City name to resolve. Mutually exclusive with `zipcode`.
            Permits the same character set as `autosuggest.input`.
          schema:
            type: string
            minLength: 1
            maxLength: 128
          example: "Chicago"
        - name: state
          in: query
          required: false
          description: |
            Optional 2-letter US state code or full state name to
            disambiguate the city query. Ignored when `zipcode` is set.
          schema:
            type: string
            maxLength: 64
          example: "IL"
        - name: zipcode
          in: query
          required: false
          description: |
            5-digit US ZIP code. Mutually exclusive with `city`.
          schema:
            type: string
            pattern: '^\d{5}$'
            minLength: 5
            maxLength: 5
          example: "60601"
      responses:
        '200':
          description: |
            Match results. Empty `results` array when no candidates
            match the query.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GeocodeResultsResponse'
        '400':
          $ref: '#/components/responses/Problem400'
        '401':
          $ref: '#/components/responses/Problem401'
        '403':
          $ref: '#/components/responses/Problem403'
        '404':
          $ref: '#/components/responses/Problem404'
        '429':
          $ref: '#/components/responses/Problem429'
        '500':
          $ref: '#/components/responses/Problem500'
        '502':
          $ref: '#/components/responses/Problem502'
        '503':
          $ref: '#/components/responses/Problem503'
        '504':
          $ref: '#/components/responses/Problem504'

  /.well-known/deprecations:
    get:
      operationId: getDeprecations
      summary: List active API deprecations.
      description: |
        Returns the current set of active deprecations as required by the
        public-contract policy summarised in this document's Versioning and
        deprecation section. Empty list when no deprecations are active.
      tags: [Meta]
      security: []
      responses:
        '200':
          description: Current deprecations (possibly empty).
          content:
            application/json:
              schema:
                type: object
                required: [deprecations]
                properties:
                  deprecations:
                    type: array
                    description: Active deprecations.
                    items:
                      $ref: '#/components/schemas/Deprecation'

  /v1/agriculture/growing-degree-days:
    get:
      operationId: getGrowingDegreeDays
      summary: Growing Degree Units (GDU / GDD) for a point.
      description: |
        Returns Growing Degree Units (also called Growing Degree Days) for a
        US point (CONUS, Alaska, Hawaii), computed on read from stored daily
        maximum/minimum temperatures. No GDU grids are stored; the values are
        derived per request from the temperature bundles, so a crop's
        base/upper thresholds and the simple/modified method can be selected
        freely without re-running the pipeline.

        **Two modes:**

        - **Forecast (default).** Without `season_start`, the response carries
          a per-day GDU series across the NBM forecast horizon (`days`).
        - **Season cumulative.** With `season_start` (a `YYYY-MM-DD` local
          date within the ~180-day observed window), the response stitches
          RTMA observed daily history + today's partial (hourly on-read
          rollup) + NBM forecast, returns a `cumulative_total`, and adds a
          per-day `cumulative_gdu` running sum. This is the number a grower
          tracks against crop growth-stage milestones.

        **Crops.** Pass a catalog `crop` id (e.g. `corn`, `soybean`,
        `wheat_winter`, `cotton`, `alfalfa`) to use calibrated thresholds, or
        `crop=custom` with `base_temp_c` (and optionally `upper_temp_c` /
        `method`) to supply your own.

        Every response echoes the resolved `method`, `thresholds` (always in
        °C), and a `meta` provenance block (`day_definition`,
        `computation_path`, `source`, `unit`, any `gaps`) so nothing is
        silently assumed.

        Launches behind the `AGRICULTURE_GDD_ENABLED` feature flag: returns
        `404` when the flag is off.
      tags: [Agriculture]
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/LatQuery'
        - $ref: '#/components/parameters/LonQuery'
        - name: crop
          in: query
          required: true
          description: |
            Crop catalog id (e.g. `corn`, `soybean`, `wheat_winter`,
            `cotton`, `alfalfa`) or `custom` to supply thresholds directly.
          schema:
            type: string
          example: corn
        - name: season_start
          in: query
          required: false
          description: |
            Season/planting start as a `YYYY-MM-DD` local date. Presence
            switches the response into cumulative season-to-date mode. Must be
            within the ~180-day observed-history window.
          schema:
            type: string
            format: date
          example: "2026-05-01"
        - name: days
          in: query
          required: false
          description: Forecast horizon in days (default 10).
          schema:
            type: integer
            minimum: 1
            maximum: 30
            default: 10
        - name: day_definition
          in: query
          required: false
          description: |
            Daily boundary: `nws` (default; native NBM MaxT/MinT period
            extremes) or `local_calendar` (midnight-to-midnight local day).
          schema:
            type: string
            enum: [nws, local_calendar]
            default: nws
        - name: unit
          in: query
          required: false
          description: |
            Unit system for GDU + temperatures. `imperial` (default) uses
            °F-days; `metric` uses °C-days. Thresholds are always echoed in
            °C regardless.
          schema:
            type: string
            enum: [imperial, metric]
            default: imperial
        - name: base_temp_c
          in: query
          required: false
          description: Custom base threshold in °C. Required when `crop=custom`.
          schema:
            type: number
            format: double
        - name: upper_temp_c
          in: query
          required: false
          description: |
            Custom upper cutoff in °C (enables the modified method). Optional.
          schema:
            type: number
            format: double
        - name: method
          in: query
          required: false
          description: |
            GDU method for custom crops. Defaults based on whether
            `upper_temp_c` is supplied.
          schema:
            type: string
            enum: [simple, modified]
        - name: include_milestones
          in: query
          required: false
          description: Include the crop's growth-stage GDU milestones.
          schema:
            type: boolean
            default: false
      responses:
        '200':
          description: Growing Degree Units for the requested point.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GrowingDegreeDays'
        '400':
          $ref: '#/components/responses/Problem400'
        '404':
          $ref: '#/components/responses/Problem404'
        '502':
          $ref: '#/components/responses/Problem502'
        '504':
          $ref: '#/components/responses/Problem504'

  /v1/point:
    get:
      operationId: getPointValue
      summary: Curated point value(s) for allowlisted stored data.
      description: |
        Returns the latest value of allowlisted stored data sampled at a point,
        with friendly output (canonical `unit`, an optional `category` label,
        `observed_at`, and a staleness flag).

        This is the one curated wrapper over the internal point-query: it serves
        only the `(product, var)` pairs on a server-side **allowlist**, so the
        public surface stays safe and small while every widget/card is
        onboarded by adding an allowlist entry — no new endpoint. Requesting a
        `(product, var)` that is not allowlisted returns `404` (single form) or
        a per-item `not_allowlisted` status (batch form).

        ### Data kinds (transparent to the caller)

        The allowlist entry's kind selects the internal resolution — a
        **scalar** raster/OM cell, or a vector **zone** (point-in-polygon
        outlook, e.g. SPC categorical / fire-weather). Both collapse to one
        answer at a location and share one envelope: `value`/`unit` are present
        for scalars and ordinal bands, `category`/`label` name the band, and a
        point in no band is a valid answer with `value: null` and
        `category: "none"` (not a 404).

        ### Single vs. batch

        - **Single** — `product` + `var` + `lat` + `lon` returns one
          `PointValue`.
        - **Batch** — `select` (multiple `(product, var)`) and/or `points`
          (multiple locations) returns a `PointBatchResponse` of per-point,
          per-value items, each with a `status`. One request can populate a
          whole family card (several metrics) or a map of locations; partial
          conditions (out of domain, not allowlisted, no data) are reported
          per item and never fail the whole request.

        ### Coverage

        Each allowlisted entry carries a coverage domain; a point outside it
        returns `404` (single) or an `out_of_domain` item (batch) without an
        upstream call. Scalar values are sampled from the nearest grid cell
        (products are snapped to a ~1 km grid), so nearby requests share cache
        entries.

        ### Staleness

        `approximate` is `true` when the value is older than the product's update
        cadence allows (e.g. a SPoRT-LIS daily analysis older than ~36 h, an
        MRMS hourly accumulation older than ~3 h).

        ### Caching

        Successful responses carry `Cache-Control` with a `max-age` keyed to the
        product's cadence (SPoRT-LIS daily: 30 min; MRMS QPE hourly: 5 min). The
        service additionally caches by `(product, var, rounded lat/lon)`.

        ### Auth

        Standard sw-api Bearer auth applies.
      tags: [Products]
      security:
        - bearerAuth: []
      parameters:
        - name: product
          in: query
          required: false
          description: |
            Allowlisted stored product identifier (lowercase, digits,
            underscores), e.g. `sport_rsm_0_2m_pct_conus`,
            `mrms_qpe_24h_pass2_conus`, or the outlook `spc_day1_categorical`.
            Single form: pair with `var` + `lat` + `lon`. Ignored when `select`
            is provided.
          schema:
            type: string
            pattern: '^[a-z0-9_]+$'
            minLength: 1
            maxLength: 128
          example: sport_rsm_0_2m_pct_conus
        - name: var
          in: query
          required: false
          description: |
            Variable identifier within the product, e.g. `rsm_0_2m_pct`,
            `qpe_24h`, or `risk` for zone outlooks. Ignored when `select` is
            provided.
          schema:
            type: string
            pattern: '^[a-z0-9_]+$'
            minLength: 1
            maxLength: 128
          example: rsm_0_2m_pct
        - name: lat
          in: query
          required: false
          description: Latitude of the point (single form). Ignored when `points` is provided.
          schema:
            type: number
            format: double
            minimum: -90
            maximum: 90
          example: 41.9
        - name: lon
          in: query
          required: false
          description: Longitude of the point (single form). Ignored when `points` is provided.
          schema:
            type: number
            format: double
            minimum: -180
            maximum: 180
          example: -93.6
        - name: select
          in: query
          required: false
          description: |
            Batch form. One or more `product:var` selectors (each individually
            allowlisted). Repeat the parameter or comma-join within a single
            value, e.g.
            `select=sport_rsm_0_2m_pct_conus:rsm_0_2m_pct&select=spc_day1_categorical:risk`.
            Presence of `select` (or `points`) switches the response to
            `PointBatchResponse`. Max 25 selectors; points × selectors ≤ 500.
          style: form
          explode: true
          schema:
            type: array
            maxItems: 25
            items:
              type: string
              pattern: '^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$'
              minLength: 3
              maxLength: 1024
          example: [sport_rsm_0_2m_pct_conus:rsm_0_2m_pct, spc_day1_categorical:risk]
        - name: points
          in: query
          required: false
          description: |
            Batch form. One or more `lat,lon` locations. Repeat the parameter
            for multiple points (Go query parsing rejects `;`), e.g.
            `points=41.9,-93.6&points=42.0,-93.4`. Presence of `points` (or
            `select`) switches the response to `PointBatchResponse`. Max 100
            points; points × selectors ≤ 500.
          style: form
          explode: true
          schema:
            type: array
            maxItems: 100
            items:
              type: string
              pattern: '^-?[0-9]+(\.[0-9]+)?,-?[0-9]+(\.[0-9]+)?$'
              minLength: 3
              maxLength: 64
          example: ["41.9,-93.6", "42.0,-93.4"]
      responses:
        '200':
          description: |
            The requested value(s). Single form (`product`+`var`+`lat`+`lon`)
            returns a `PointValue`; batch form (`select` and/or `points`)
            returns a `PointBatchResponse`.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/PointValue'
                  - $ref: '#/components/schemas/PointBatchResponse'
        '400':
          $ref: '#/components/responses/Problem400'
        '404':
          $ref: '#/components/responses/Problem404'
        '502':
          $ref: '#/components/responses/Problem502'
        '504':
          $ref: '#/components/responses/Problem504'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      # `bearerFormat` is informational per OpenAPI 3.1; SDK generators use
      # it as a hint when naming the credential field.
      bearerFormat: sw_live_*|sw_test_*
      description: |
        API key passed as an HTTP Bearer token (RFC 6750):

        ```
        Authorization: Bearer sw_live_xxxx...
        ```

        Keys are issued in two flavors:

        - `sw_live_*` -- production keys; requests are billed and counted
          against rate limits.
        - `sw_test_*` -- test-mode keys; requests return live data but are
          excluded from billing meter events. Useful for CI and local
          development.

        Get a key from the developer dashboard at
        [smarterweather.com/developers/dashboard](https://smarterweather.com/developers/dashboard)
        once general availability ships.

  parameters:
    LatQuery:
      name: lat
      in: query
      description: |
        Latitude. Must be provided together with `lon`. Mutually exclusive
        with `zip` and `location`.
      required: false
      schema:
        type: number
        format: double
        minimum: -90
        maximum: 90
      example: 42.3601
    LonQuery:
      name: lon
      in: query
      description: |
        Longitude. Must be provided together with `lat`. Mutually exclusive
        with `zip` and `location`.
      required: false
      schema:
        type: number
        format: double
        minimum: -180
        maximum: 180
      example: -71.0589
    ZipQuery:
      name: zip
      in: query
      description: |
        5-digit US ZIP code. Mutually exclusive with `lat`/`lon` and
        `location`.
      required: false
      schema:
        type: string
        pattern: '^\d{5}$'
      example: "02108"
    LocationQuery:
      name: location
      in: query
      description: |
        Free-text location name. Examples: `"Boston, MA"`, `"94043"`,
        `"Kyiv"`. Resolved by an internal geocoder. Mutually exclusive with
        `lat`/`lon` and `zip`.
      required: false
      schema:
        type: string
        minLength: 1
        maxLength: 128
      example: "Boston, MA"
    UnitsQuery:
      name: units
      in: query
      description: |
        Unit system for numeric response fields. `imperial` (default) uses
        °F, mph, inHg, mi, in; `metric` uses °C, kph, hPa, km, mm. See the
        `Units` schema returned with every response for the exact label set.
      required: false
      schema:
        type: string
        enum: [imperial, metric]
        default: imperial
    IncludeQuery:
      name: include
      in: query
      description: |
        Comma-separated list of sections to include. See the "Include
        tokens" table in `info.description` for the full catalog, defaults,
        and stability markers. Unknown tokens are silently dropped.
      required: false
      schema:
        type: string
      example: "current,hourly,daily,alerts"
    DaysQuery:
      name: days
      in: query
      description: Number of days for the daily forecast.
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 14
        default: 10
    HourlyHoursQuery:
      name: hourly_hours
      in: query
      description: Number of hours for the hourly forecast.
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 168
        default: 48

  headers:
    RateLimitLimit:
      description: The request-rate limit for this API key in the current window.
      schema:
        type: integer
        minimum: 0
    RateLimitRemaining:
      description: The number of requests remaining in the current window.
      schema:
        type: integer
        minimum: 0
    RateLimitReset:
      description: Unix epoch seconds at which the current window resets.
      schema:
        type: integer
        minimum: 0
    RetryAfter:
      description: Seconds to wait before retrying after a rate-limit rejection.
      schema:
        type: integer
        minimum: 0

  examples:
    WeatherBoston:
      summary: Composite weather response for Boston, MA (default include, imperial).
      value:
        location:
          lat: 42.3601
          lon: -71.0589
          name: "Boston"
          region: "MA"
          country: "US"
          timezone: "America/New_York"
          county: "Suffolk"
          zip: "02108"
        current:
          time: "2026-04-20T15:00:00Z"
          temperature: 58.4
          feels_like: 56.1
          humidity: 62
          dew_point: 45.2
          pressure: 30.05
          wind_speed: 11.3
          wind_gust: 17.8
          wind_direction: 240
          visibility: 10
          cloud_cover: 35
          uv_index: 5
          precip_rate: 0
          precip_type: "none"
          conditions: "Partly Cloudy"
          icon: "partly-cloudy-day"
          severe: false
        hourly:
          - time: "2026-04-20T16:00:00Z"
            temperature: 59.1
            feels_like: 56.8
            humidity: 60
            wind_speed: 12.1
            wind_direction: 245
            precip_probability: 5
            precip_amount: 0
            conditions: "Partly Cloudy"
            icon: "partly-cloudy-day"
          - time: "2026-04-20T17:00:00Z"
            temperature: 59.6
            feels_like: 57.2
            humidity: 58
            wind_speed: 12.4
            wind_direction: 245
            precip_probability: 5
            precip_amount: 0
            conditions: "Partly Cloudy"
            icon: "partly-cloudy-day"
        daily:
          - date: "2026-04-20"
            high: 61.0
            low: 44.5
            humidity_avg: 55
            wind_speed_max: 14.2
            wind_direction: 240
            precip_probability: 10
            precip_total: 0
            snow_total: 0
            conditions: "Partly Cloudy"
            icon: "partly-cloudy-day"
            day:
              temperature: 61.0
              wind_speed: 14.2
              wind_direction: 240
              precip_probability: 10
              precip_total: 0
              conditions: "Partly Cloudy"
              icon: "partly-cloudy-day"
              narrative: "Partly cloudy with a high near 61. West winds 10 to 15 mph."
            night:
              temperature: 44.5
              wind_speed: 6.8
              wind_direction: 260
              precip_probability: 5
              precip_total: 0
              conditions: "Mostly Clear"
              icon: "mostly-clear-night"
              narrative: "Mostly clear with a low around 45. Winds becoming light."
        alerts: []
        outlooks: []
        astro:
          civil_dawn: "2026-04-20T09:37:00Z"
          sunrise: "2026-04-20T10:05:00Z"
          solar_noon: "2026-04-20T16:47:00Z"
          sunset: "2026-04-20T23:29:00Z"
          civil_dusk: "2026-04-20T23:57:00Z"
          day_length: 48240
          daylight_length: 51600
          moonrise: "2026-04-20T11:42:00Z"
          moonset: "2026-04-21T01:18:00Z"
          moon_phase: 0.15
          moon_phase_name: "Waxing Crescent"
          moon_illumination: 0.22
        units:
          temperature: "F"
          wind_speed: "mph"
          pressure: "inHg"
          visibility: "mi"
          precip_amount: "in"
        attribution:
          sources:
            - "NOAA NBM"
            - "HRRR"
            - "RTMA"
            - "MRMS"
            - "NWS"
            - "JPL Ephemeris"
          notice: "Weather data from NOAA/NWS. Astronomical calculations from JPL ephemerides."
        meta:
          schema_version: "2026-04-20"
          generated_at: "2026-04-20T15:00:04Z"
          freshness:
            - source: "rtma"
              product_id: "rtma_conus_2p5km"
              reference_time: "2026-04-20T14:45:00Z"
              latest_valid: "2026-04-20T14:45:00Z"
              age_seconds: 904
            - source: "hrrr"
              product_id: "hrrr_conus_3km"
              reference_time: "2026-04-20T14:00:00Z"
              latest_valid: "2026-04-20T15:00:00Z"
              age_seconds: 3604
          warnings: []

    WeatherBatchRequestTwoLocations:
      summary: Two locations (one by lat/lon, one by ZIP) with shared `include`.
      value:
        locations:
          - lat: 42.3601
            lon: -71.0589
          - zip: "94043"
        include: "current,daily,alerts"
        units: imperial
        days: 7
        hourly_hours: 48

  schemas:
    # -----------------------------------------------------------------------
    # Health / deprecations / error envelope (existing F1 surfaces)
    # -----------------------------------------------------------------------

    Health:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [ok, degraded]
        reader:
          type: string
          enum: [reachable, unreachable]
          description: Reader (internal data plane) connectivity status.

    Problem:
      type: object
      description: |
        RFC 7807 problem-details object served as `application/problem+json`
        on every `4xx` and `5xx` response.
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
          description: A URI reference identifying the problem type.
          example: https://smarterweather.com/errors/invalid-key
        title:
          type: string
          description: Short, human-readable summary of the problem type.
        status:
          type: integer
          description: HTTP status code.
        detail:
          type: string
          description: Human-readable explanation specific to this occurrence.
        instance:
          type: string
          format: uri
          description: A URI reference identifying the specific occurrence.

    Deprecation:
      type: object
      description: |
        A single active deprecation entry. See the Versioning and
        deprecation section in `info.description` for notice-period rules.
      required: [surface, announced_at, sunset_at, migration]
      properties:
        surface:
          type: string
          description: |
            The deprecated surface, expressed as a stable identifier.
            Common forms include an operation path (`/v1/weather`),
            a parameter reference (`/v1/weather?param=foo`), or a
            response-field JSON Pointer (`/v1/weather#/current/feels_like`).
          example: "/v1/weather?param=legacy_include"
        announced_at:
          type: string
          format: date
          description: ISO 8601 date on which the deprecation was announced.
          example: "2026-04-01"
        sunset_at:
          type: string
          format: date
          description: |
            ISO 8601 date on or after which the deprecated surface may be
            removed (minimum 90 days for shape changes, 6 months for
            endpoint removal per ADR 001).
          example: "2026-07-01"
        migration:
          type: string
          description: |
            Short prose pointer to the replacement surface or migration
            guide. Clients should treat this as documentation, not a
            machine-parseable URL.
          example: "Use the `include=narrations` token instead."

    # -----------------------------------------------------------------------
    # Top-level weather response
    # -----------------------------------------------------------------------

    Weather:
      type: object
      description: |
        Composite blended weather payload returned by `GET /v1/weather`.
        Section presence is controlled by the `include` query parameter;
        every section that is present is the complete shape documented
        below.
      required: [meta, attribution]
      properties:
        location:
          $ref: '#/components/schemas/Location'
        current:
          $ref: '#/components/schemas/Current'
        hourly:
          type: array
          description: Hourly forecast, ordered ascending by `time`.
          items:
            $ref: '#/components/schemas/Hourly'
        daily:
          type: array
          description: Daily forecast, ordered ascending by `date`.
          items:
            $ref: '#/components/schemas/Daily'
        alerts:
          type: array
          description: Active NWS alerts for the location.
          items:
            $ref: '#/components/schemas/Alert'
        outlooks:
          type: array
          description: SPC convective outlooks active for the location.
          items:
            $ref: '#/components/schemas/Outlook'
        astro:
          $ref: '#/components/schemas/Astro'
        gis:
          $ref: '#/components/schemas/GIS'
        units:
          $ref: '#/components/schemas/Units'
        attribution:
          $ref: '#/components/schemas/Attribution'
        narrations:
          $ref: '#/components/schemas/Narrations'
        guidance:
          $ref: '#/components/schemas/Guidance'
        nowcast:
          $ref: '#/components/schemas/Nowcast'
        meta:
          $ref: '#/components/schemas/Meta'

    Nowcast:
      type: object
      description: |
        Minute-scale MRMS radar precipitation nowcast for the queried
        location. Populated when `include=nowcast` is requested.
      properties:
        interval_seconds:
          type: integer
          example: 300
        generated_at:
          type: string
          format: date-time
          description: Nowcast reference (anchor) time from the latest radar frame.
        summary:
          $ref: '#/components/schemas/NowcastSummary'
        points:
          type: array
          items:
            $ref: '#/components/schemas/NowcastPoint'

    NowcastSummary:
      type: object
      properties:
        starts_at:
          type: string
          format: date-time
        ends_at:
          type: [string, "null"]
          format: date-time
        duration_minutes:
          type: [integer, "null"]
        type:
          type: string
        peak_intensity:
          type: string
          enum: [none, light, moderate, heavy]
        already_occurring:
          type: boolean

    NowcastPoint:
      type: object
      required: [time, type, intensity_dbz, intensity]
      properties:
        time:
          type: string
          format: date-time
        type:
          type: string
        intensity_dbz:
          type: number
          format: double
        intensity:
          type: string
          enum: [none, light, moderate, heavy]

    # -----------------------------------------------------------------------
    # Location / current / hourly / daily
    # -----------------------------------------------------------------------

    Location:
      type: object
      description: Resolved location.
      required: [lat, lon, name, timezone]
      properties:
        lat:
          type: number
          format: double
          minimum: -90
          maximum: 90
        lon:
          type: number
          format: double
          minimum: -180
          maximum: 180
        name:
          type: string
          description: Human-readable place name (city or nearest city).
          example: "Boston"
        region:
          type: string
          description: State or region abbreviation.
          example: "MA"
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code.
          example: "US"
        timezone:
          type: string
          description: IANA timezone identifier.
          example: "America/New_York"
        county:
          type: string
          description: County name (US only).
          example: "Suffolk"
        zip:
          type: string
          description: ZIP code (US only).
          example: "02108"

    Current:
      type: object
      description: |
        Current weather conditions derived from the RTMA + MRMS blend.
      required: [time, conditions, icon]
      properties:
        time:
          type: string
          format: date-time
          description: Observation time (ISO 8601, UTC).
        temperature:
          type: [number, "null"]
          format: double
          description: Temperature (unit per `units.temperature`).
        feels_like:
          type: [number, "null"]
          format: double
          description: Apparent temperature (wind chill or heat index).
        humidity:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
          description: Relative humidity, percent.
        dew_point:
          type: [number, "null"]
          format: double
        pressure:
          type: [number, "null"]
          format: double
          description: Atmospheric pressure (unit per `units.pressure`).
        wind_speed:
          type: [number, "null"]
          format: double
        wind_gust:
          type: [number, "null"]
          format: double
        wind_direction:
          type: [integer, "null"]
          minimum: 0
          maximum: 360
          description: Wind direction in degrees (0 = N, 90 = E).
        visibility:
          type: [number, "null"]
          format: double
          description: Visibility distance (may be null for international coverage).
        cloud_cover:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
          description: Cloud cover, percent.
        uv_index:
          type: [number, "null"]
          format: double
        aqi:
          type: [integer, "null"]
          description: Air-quality index (US EPA scale, 0-500).
        aqi_category:
          type: string
          description: |
            Human-readable AQI category (e.g., `"Good"`, `"Moderate"`,
            `"Unhealthy for Sensitive Groups"`). Omitted when AQI is
            unavailable.
        pm25:
          type: [number, "null"]
          format: double
          description: PM2.5 concentration (µg/m³).
        ozone:
          type: [number, "null"]
          format: double
          description: Ozone concentration (ppb).
        pm10:
          type: [number, "null"]
          format: double
          description: PM10 concentration (µg/m³).
        precip_rate:
          type: [number, "null"]
          format: double
          description: Current precipitation rate.
        precip_type:
          type: string
          enum: [none, rain, snow, mix, hail, unknown]
          description: |
            Current precipitation type. Omitted when no precipitation data
            is available for the location.
        lightning_density:
          type: [number, "null"]
          format: double
          description: Lightning strike density (strikes per unit area per time window).
        lightning_prob:
          type: [integer, "null"]
          minimum: 0
          maximum: 100
          description: Lightning probability, percent.
        conditions:
          type: string
          description: Human-readable weather description.
          example: "Partly Cloudy"
        icon:
          type: string
          description: Icon slug for UI rendering.
          example: "partly-cloudy-day"
        severe:
          type: boolean
          description: True when severe conditions are present or imminent.
          default: false

    Hourly:
      type: object
      description: Single hourly forecast point.
      required: [time]
      properties:
        time:
          type: string
          format: date-time
          description: Forecast valid time (ISO 8601, UTC).
        temperature:
          type: [number, "null"]
          format: double
        feels_like:
          type: [number, "null"]
          format: double
        humidity:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        wind_speed:
          type: [number, "null"]
          format: double
        wind_direction:
          type: [integer, "null"]
          minimum: 0
          maximum: 360
        precip_probability:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        precip_amount:
          type: [number, "null"]
          format: double
        conditions:
          type: string
        icon:
          type: string
        severe:
          type: boolean
          default: false

        # Tier 1 fields (promoted from internal-only in wx-api).
        wind_gust:
          type: [number, "null"]
          format: double
        dew_point:
          type: [number, "null"]
          format: double
        cloud_cover:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        visibility:
          type: [number, "null"]
          format: double

        # Tier 2 fields.
        pressure:
          type: [number, "null"]
          format: double
        thunderstorm_probability:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        uv_index:
          type: [number, "null"]
          format: double
        aqi:
          type: [number, "null"]
        precip_type:
          type: string
          enum: [none, rain, snow, mix, hail, unknown]
        precip_type_rain_prob:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        precip_type_snow_prob:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        precip_type_freezing_rain_prob:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        precip_type_ice_pellets_prob:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100

        # Tier 3 fields.
        cape:
          type: [number, "null"]
          format: double
          description: Convective available potential energy (J/kg).
        snow_amount:
          type: [number, "null"]
          format: double
        cloud_ceiling:
          type: [number, "null"]
          format: double
        snow_level:
          type: [number, "null"]
          format: double
        thunder_possible:
          type: boolean
          default: false
          description: |
            True when any hourly thunder indicator (`thunderstorm_probability`
            or `cape`) crosses the blender's lightning-risk threshold for this
            hour. This is a distilled flag; use
            `uncertainty.*` for raw probabilistic fields.
        uncertainty:
          description: |
            Optional probabilistic sub-object sourced from the NBM model.
            Populated only when the caller requests `include=uncertainty` and
            NBM coverage is available for the location (CONUS, Alaska, or
            Hawaii).
          oneOf:
            - $ref: '#/components/schemas/HourlyUncertainty'
            - type: "null"

    HourlyUncertainty:
      type: object
      description: |
        NBM probabilistic companion fields for a single hour. Each field is
        optional; the sub-object is omitted when no NBM data is available.
      properties:
        temperature_stddev:
          type: [number, "null"]
          format: double
          description: Ensemble standard deviation of temperature.
        dew_point_stddev:
          type: [number, "null"]
          format: double
        wind_speed_stddev:
          type: [number, "null"]
          format: double
        wind_gust_stddev:
          type: [number, "null"]
          format: double
        visibility_prob_below_1mi:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        visibility_prob_below_2mi:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        visibility_prob_below_3mi:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        visibility_prob_below_5mi:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        ceiling_prob_below_500ft:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        ceiling_prob_below_1000ft:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        ceiling_prob_below_2000ft:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        ceiling_prob_below_3000ft:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        ceiling_prob_below_6600ft:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        wind_speed_p50:
          type: [number, "null"]
          format: double
          description: NBM median 10m wind speed.
        wind_speed_prob_gt_75mph:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100

    Daily:
      type: object
      description: |
        Single daily forecast entry. Per-period weather (temperature, wind,
        precipitation, narrative) lives in `day` / `night`; top-level
        fields are aggregate values for the full calendar day.
      required: [date]
      properties:
        date:
          type: string
          format: date
          description: Forecast date (ISO 8601, local to the location's timezone).
          example: "2026-04-20"
        high:
          type: [number, "null"]
          format: double
        low:
          type: [number, "null"]
          format: double
        conditions:
          type: string
        icon:
          type: string
        severe:
          type: boolean
          default: false
        precip_probability:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        precip_total:
          type: [number, "null"]
          format: double
        snow_total:
          type: [number, "null"]
          format: double
        ice_total:
          type: [number, "null"]
          format: double
        wind_speed_max:
          type: [number, "null"]
          format: double
        wind_gust_max:
          type: [number, "null"]
          format: double
          description: Maximum sustained-gust value across the calendar day.
        wind_direction:
          type: [integer, "null"]
          minimum: 0
          maximum: 360
        humidity_avg:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        cloud_cover_avg:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        uv_index_max:
          type: [number, "null"]
          format: double
        cape_max:
          type: [number, "null"]
          format: double
        dew_point_max:
          type: [number, "null"]
          format: double
        dew_point_min:
          type: [number, "null"]
          format: double
        pressure_avg:
          type: [number, "null"]
          format: double
        visibility_min:
          type: [number, "null"]
          format: double
        cloud_ceiling_min:
          type: [number, "null"]
          format: double
        thunderstorm_probability_max:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        aqi_max:
          type: [number, "null"]
          format: double
        feels_like_high:
          type: [number, "null"]
          format: double
        feels_like_low:
          type: [number, "null"]
          format: double
        solar_radiation_max:
          type: [number, "null"]
          format: double
          description: Peak downward shortwave radiation across the day (W/m^2).
        snow_level:
          type: [number, "null"]
          format: double
        thunder_possible:
          type: boolean
          default: false
          description: Distilled flag; true if any hour in the day crosses the blender's lightning-risk threshold.
        astro:
          $ref: '#/components/schemas/Astro'
        day:
          $ref: '#/components/schemas/DailyPeriod'
        night:
          $ref: '#/components/schemas/DailyPeriod'
        threats:
          type: array
          items:
            $ref: '#/components/schemas/DailyThreat'
          description: Per-day hazard callouts (winter, severe, heat, etc.).
        relative_day:
          type: integer
          description: |
            Signed calendar offset from the local current date at request time
            (-1 = yesterday, 0 = today, 1 = tomorrow). Before 6 AM local an
            extra entry with `relative_day: -1` prepends the in-progress
            overnight row.
        active_period:
          type: [string, "null"]
          enum: [day, night]
          description: |
            Non-null on exactly the row containing the current local time
            (`day` during 06:00–17:59, `night` during 18:00–05:59). Clients
            use this with `relative_day` to label rows Today/Tonight without
            wall-clock heuristics.
        uncertainty:
          description: |
            Optional NBM probabilistic sub-object for this day. Populated only
            when `include=uncertainty` is requested and NBM coverage applies.
          oneOf:
            - $ref: '#/components/schemas/DailyUncertainty'
            - type: "null"

    DailyUncertainty:
      type: object
      description: |
        NBM probabilistic companion fields for a single day. All fields are
        optional; daily uncertainty entries are produced by the NBM daily
        aggregation stage.
      properties:
        temperature_stddev_avg:
          type: [number, "null"]
          format: double
        dew_point_stddev_avg:
          type: [number, "null"]
          format: double
        wind_speed_stddev_avg:
          type: [number, "null"]
          format: double
        wind_gust_stddev_avg:
          type: [number, "null"]
          format: double
        visibility_prob_below_1mi_max:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        ceiling_prob_below_1000ft_max:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        snow_total_p10:
          type: [number, "null"]
          format: double
        snow_total_p50:
          type: [number, "null"]
          format: double
        snow_total_p90:
          type: [number, "null"]
          format: double
        snow_prob_gt_1in:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        snow_prob_gt_2in:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        snow_prob_gt_4in:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100

    DailyPeriod:
      type: object
      description: |
        Forecast data for a single period (day or night) within a
        `Daily` entry.
      properties:
        temperature:
          type: [number, "null"]
          format: double
          description: High for `day`, low for `night`.
        wind_speed:
          type: [number, "null"]
          format: double
        wind_direction:
          type: [integer, "null"]
          minimum: 0
          maximum: 360
        precip_probability:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        precip_total:
          type: [number, "null"]
          format: double
        snow_total:
          type: [number, "null"]
          format: double
        ice_total:
          type: [number, "null"]
          format: double
        humidity:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        cloud_cover:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        wind_gust:
          type: [number, "null"]
          format: double
        dew_point:
          type: [number, "null"]
          format: double
        feels_like:
          type: [number, "null"]
          format: double
          description: High-for-day / low-for-night of hourly apparent temperature.
        visibility:
          type: [number, "null"]
          format: double
        pressure:
          type: [number, "null"]
          format: double
        cloud_ceiling:
          type: [number, "null"]
          format: double
        uv_index:
          type: [number, "null"]
          format: double
          description: Max UV index within the period (day only; ~0 overnight).
        thunderstorm_probability:
          type: [number, "null"]
          format: double
          minimum: 0
          maximum: 100
        cape:
          type: [number, "null"]
          format: double
        aqi:
          type: [number, "null"]
          format: double
        precip_type:
          type: string
          enum: ["", none, rain, snow, mix, sleet, freezing_rain, thunderstorms, unknown]
        thunder_possible:
          type: boolean
          default: false
        conditions:
          type: string
        icon:
          type: string
        severe:
          type: boolean
          default: false
        narrative:
          type: string
          description: Human-readable narrative for this period.

    DailyThreat:
      type: object
      description: Per-day hazard callout.
      required: [type, risk_label]
      properties:
        type:
          type: string
          description: Hazard category (e.g., `severe`, `winter`, `heat`, `fire`).
          example: "severe"
        risk:
          type: string
          description: Raw risk identifier from the upstream source.
        probability:
          type: number
          format: double
          minimum: 0
          maximum: 1
          description: Fractional probability (0-1) when known.
        risk_label:
          type: string
          description: Human-readable risk label (e.g., `"Slight"`, `"Enhanced"`).

    # -----------------------------------------------------------------------
    # Alerts / outlooks / astro
    # -----------------------------------------------------------------------

    Alert:
      type: object
      description: NWS weather alert active for the requested location.
      required:
        - id
        - event
        - severity
        - urgency
        - certainty
        - headline
        - sender
        - status
        - onset
        - ends
        - effective
        - expires
        - expiration
      properties:
        id:
          type: string
          description: Unique alert identifier.
          example: "urn:oid:2.49.0.1.840.0.abc123"
        event:
          type: string
          example: "Wind Advisory"
        severity:
          type: string
          enum: [Extreme, Severe, Moderate, Minor, Unknown]
        urgency:
          type: string
          enum: [Immediate, Expected, Future, Past, Unknown]
        certainty:
          type: string
          enum: [Observed, Likely, Possible, Unlikely, Unknown]
        headline:
          type: string
        sender:
          type: string
          description: Issuing NWS office identifier.
        status:
          type: string
          description: Alert lifecycle status (e.g., `Actual`, `Exercise`, `Test`).
        onset:
          type: string
          format: date-time
        ends:
          type: string
          format: date-time
        effective:
          type: string
          format: date-time
        expires:
          type: string
          format: date-time
        expiration:
          type: string
          format: date-time
          description: Final expiration time used for cache invalidation.
        area_desc:
          type: string
          description: Human-readable affected area.
        highlights:
          type: object
          additionalProperties:
            type: string
          description: |
            Map of salient callouts extracted from the alert text
            (e.g., `{"hazard": "Strong winds", "impact": "Loose debris possible"}`).

    AlertDetail:
      type: object
      description: |
        Full alert representation returned by `GET /v1/alerts/{id}`.
        Supersets `Alert` with full description, instruction, geometry,
        spatial metadata (centroid, recommended zoom, affected
        population), and the upstream NWS VTEC identifier.

        Field shape mirrors `wx-api`'s `GET /v1/alerts/{id}` so iOS,
        legacy CRA, and OG-service consumers swap base URLs without
        parsing changes.
      required:
        - group_id
        - message_id
        - status
        - event
        - severity
        - urgency
        - certainty
        - headline
        - sender
        - effective
        - expires
        - onset
        - ends
        - expiration
      properties:
        group_id:
          type: string
          description: Canonical primary key on the alerts table.
          example: "1.2.3-NWS-WW.SAMPLE.CASE"
        message_id:
          type: string
          description: NWS-issued message identifier (matches `Alert.id`).
          example: "1.2.3-NWS-WW.SAMPLE.CASE"
        status:
          type: string
          description: Alert lifecycle status (e.g., `Actual`, `Exercise`, `Test`).
        event:
          type: string
          example: "Severe Thunderstorm Warning"
        severity:
          type: string
          enum: [Extreme, Severe, Moderate, Minor, Unknown]
        urgency:
          type: string
          enum: [Immediate, Expected, Future, Past, Unknown]
        certainty:
          type: string
          enum: [Observed, Likely, Possible, Unlikely, Unknown]
        headline:
          type: string
        description:
          type: string
          description: |
            Multi-paragraph human-readable forecast / hazard
            description. Line endings vary by NWS office; clients
            should treat as opaque text.
        instruction:
          type: string
          description: Recommended actions (NWS-issued instruction text).
        sender:
          type: string
          description: Issuing NWS office identifier.
        effective:
          type: string
          format: date-time
        expires:
          type: string
          format: date-time
        onset:
          type: string
          format: date-time
        ends:
          type: string
          format: date-time
        expiration:
          type: string
          format: date-time
          description: Final expiration time used for cache invalidation.
        area_desc:
          type: string
          description: Human-readable affected area.
        highlights:
          type: object
          additionalProperties:
            type: string
          description: |
            Map of salient callouts extracted from the alert text
            (e.g., `{"hazard": "Strong winds", "impact": "Loose debris possible"}`).
        geometry:
          type: object
          description: |
            GeoJSON geometry (Polygon / MultiPolygon) for the affected
            area when stored upstream. Treat as opaque GeoJSON;
            forward-compat fields the pipeline adds without a sw-api
            release pass through unchanged.
          additionalProperties: true
        centroid_lat:
          type: number
          format: double
          description: Centroid latitude of the affected geometry.
        centroid_lon:
          type: number
          format: double
          description: Centroid longitude of the affected geometry.
        recommended_zoom:
          type: integer
          description: Suggested map zoom level when rendering the alert detail.
        affected_population:
          type: integer
          description: Estimated population intersecting the alert geometry.
        timezone:
          type: string
          description: IANA timezone of the affected area (e.g., `America/Chicago`).
        vtec_id:
          type: string
          description: |
            Upstream NWS VTEC identifier (when present). Useful for
            joining against external VTEC datasets; `null` for alerts
            without a VTEC code.
        event_ending_time:
          type: string
          description: |
            Optional ending-time hint surfaced by the OG-share image
            renderer. Currently always omitted by the pipeline; clients
            should fall back to `expires`. Reserved for forward-compat.

    Outlook:
      type: object
      description: |
        Single SPC convective outlook (or equivalent product) active for
        the location.
      required: [product_id, type, day, risk_label, valid, expires]
      properties:
        product_id:
          type: string
          description: Upstream product identifier (e.g., `spc_otlk_day1`).
          example: "spc_otlk_day1"
        type:
          type: string
          description: Hazard category (`severe`, `tornado`, `hail`, `wind`, etc.).
          example: "severe"
        day:
          type: integer
          minimum: 1
          maximum: 8
          description: Day number relative to issuance (1 = today).
        risk:
          type: string
          description: Raw risk identifier from the upstream source.
        probability:
          type: number
          format: double
          minimum: 0
          maximum: 1
          description: Fractional probability (0-1) when the source provides one.
        risk_label:
          type: string
          description: Human-readable risk label.
          example: "Slight"
        valid:
          type: string
          format: date-time
        expires:
          type: string
          format: date-time

    StormTracksFeatureCollection:
      type: object
      description: |
        GeoJSON FeatureCollection of active storm-cell tracks. Returned
        as `application/geo+json` from `GET /v1/storm-tracks`.

        The schema below documents the stable subset of the wire
        format. The reader pipeline may add new keys to
        `properties` (e.g. richer `impact.*` sub-objects, additional
        cell-classification fields) without a sw-api release; clients
        should treat unknown keys as opaque additive data and never
        fail-closed on their presence.
      required: [type, features]
      properties:
        type:
          type: string
          enum: [FeatureCollection]
        features:
          type: array
          items:
            $ref: '#/components/schemas/StormTrackFeature'
      additionalProperties: true

    StormReportsFeatureCollection:
      type: object
      description: |
        GeoJSON FeatureCollection of recent NWS local storm reports.
        Returned as `application/geo+json` from `GET /v1/storm-reports`.

        The reader pipeline emits at minimum a `valid_time` field on
        each feature's `properties`; additional event-type-specific
        keys (magnitude, narrative, county, etc.) may appear without a
        sw-api release. Treat unknown keys as opaque additive data.
      required: [type, features]
      properties:
        type:
          type: string
          enum: [FeatureCollection]
        features:
          type: array
          items:
            $ref: '#/components/schemas/GeoJSONReportsFeature'
      additionalProperties: true

    RecordEventsFeatureCollection:
      type: object
      description: |
        GeoJSON FeatureCollection of NWS climate-record events
        (record high / low temperatures, daily precipitation records,
        etc.). Returned as `application/geo+json` from
        `GET /v1/record-events`. Same forward-compat property-bag
        contract as `StormReportsFeatureCollection`.
      required: [type, features]
      properties:
        type:
          type: string
          enum: [FeatureCollection]
        features:
          type: array
          items:
            $ref: '#/components/schemas/GeoJSONReportsFeature'
      additionalProperties: true

    NexradFeaturesFeatureCollection:
      type: object
      description: |
        GeoJSON FeatureCollection of NEXRAD Level III algorithm-detection
        overlays (Hail Index / Mesocyclone / TVS). Returned as
        `application/geo+json` from `GET /v1/nexrad/features`.

        Each feature carries `site_id` plus product-specific detection
        fields in a forward-compat `properties` bag; clients should treat
        unknown keys as opaque additive data and never fail-closed on
        their presence.
      required: [type, features]
      properties:
        type:
          type: string
          enum: [FeatureCollection]
        features:
          type: array
          items:
            $ref: '#/components/schemas/GeoJSONOpaqueFeature'
      additionalProperties: true

    TropicalFeatureCollection:
      type: object
      description: |
        GeoJSON FeatureCollection aggregating the latest NHC tropical
        overlay (cone / track / points / watch-warning per active storm
        plus 7-day TWO areas). Returned as `application/geo+json` from
        `GET /v1/tropical`. Each feature carries a `kind` property the
        client uses to choose its renderer; the property bag is
        forward-compat.
      required: [type, features]
      properties:
        type:
          type: string
          enum: [FeatureCollection]
        features:
          type: array
          items:
            $ref: '#/components/schemas/GeoJSONOpaqueFeature'
      additionalProperties: true

    GeoJSONOpaqueFeature:
      type: object
      description: |
        Generic GeoJSON Feature with opaque geometry and a forward-compat
        property bag. Used by overlays (NEXRAD detections, tropical) whose
        per-feature property schema is owned by the reader pipeline and
        may evolve without a sw-api release. Treat `geometry` and all
        `properties` keys as opaque additive data.
      required: [type, geometry, properties]
      properties:
        type:
          type: string
          enum: [Feature]
        geometry:
          type: object
          description: |
            GeoJSON geometry (Point / LineString / Polygon / Multi*).
            Treat as opaque GeoJSON; pass through to the renderer
            unchanged.
          additionalProperties: true
        properties:
          type: object
          additionalProperties: true
      additionalProperties: true

    GeoJSONReportsFeature:
      type: object
      description: |
        Shared per-feature shape used by `StormReportsFeatureCollection`
        and `RecordEventsFeatureCollection`. The reader emits
        `geometry` as opaque GeoJSON (typically `Point`) and a
        `properties` bag whose only stable key is `valid_time`.
      required: [type, geometry, properties]
      properties:
        type:
          type: string
          enum: [Feature]
        geometry:
          type: object
          description: |
            GeoJSON geometry (typically `Point`). Treat as opaque
            GeoJSON; pass through to the renderer unchanged.
          additionalProperties: true
        properties:
          $ref: '#/components/schemas/GeoJSONReportsProperties'
      additionalProperties: true

    GeoJSONReportsProperties:
      type: object
      description: |
        Per-feature property bag emitted by the storm-reports +
        record-events pipelines. `valid_time` is the only stable key;
        additional keys may appear without a sw-api release
        (forward-compat).
      required: [valid_time]
      properties:
        valid_time:
          type: string
          format: date-time
          description: |
            UTC timestamp the report / record was observed or recorded.
      additionalProperties: true

    ReverseGeocodeResponse:
      type: object
      description: |
        Resolved place metadata for a `(lat, lon)` query against
        `GET /v1/geocode/reverse`. All fields are best-effort: `city`
        is populated when the coordinate falls inside a populated-place
        polygon; `nearest_city` is populated when the coordinate is
        outside any city polygon but within the resolution radius of
        one. `state` carries the canonical NWS region label.

        Forward-compat: additional fields may appear in this object
        without a sw-api release.
      properties:
        city:
          type: string
          description: |
            Populated-place name when the query coordinate falls inside
            a city polygon. Empty when only `nearest_city` is set.
          example: "Chicago"
        nearest_city:
          type: string
          description: |
            Closest populated-place name when the query coordinate is
            outside any city polygon. Empty when `city` is set.
          example: "Naperville"
        state:
          type: string
          description: |
            Canonical NWS state / region label (e.g. "Illinois",
            "Puerto Rico").
          example: "Illinois"
        county:
          type: string
          description: |
            County or equivalent admin-2 area, when available.
          example: "Cook"
        timezone:
          type: string
          description: |
            IANA timezone identifier covering the query coordinate.
          example: "America/Chicago"
      additionalProperties: true

    GeocodeResultsResponse:
      type: object
      description: |
        Envelope returned by `GET /v1/geocode/autosuggest` and
        `GET /v1/geocode/search`. The `results` array is the only
        stable key; additional sibling fields may appear without a
        sw-api release (e.g. server-side annotations).

        An empty array is returned when no candidates match.
      required: [results]
      properties:
        results:
          type: array
          description: Match candidates, ordered by reader-side score.
          items:
            $ref: '#/components/schemas/GeocodeAutosuggestResult'
      additionalProperties: true

    GeocodeAutosuggestResult:
      type: object
      description: |
        Single forward-geocode candidate. Field shape mirrors the
        reader's `AutosuggestResult` (most fields are nullable JSON
        nulls when the underlying row lacks the column). `type` is
        the reader's discriminator (`city`, `zip`, etc.).

        Forward-compat: additional fields may appear without a
        sw-api release.
      properties:
        name:
          type: [string, "null"]
          description: Populated-place name.
          example: "Chicago"
        state:
          type: [string, "null"]
          description: 2-letter US state code.
          example: "IL"
        state_name:
          type: [string, "null"]
          description: Full state / region label.
          example: "Illinois"
        county:
          type: [string, "null"]
          description: County or equivalent admin-2 area.
          example: "Cook"
        zip:
          type: [string, "null"]
          description: Representative ZIP for the place (or query ZIP for ZIP search).
          example: "60601"
        lat:
          type: [number, "null"]
          format: double
          description: Centroid latitude in decimal degrees.
          example: 41.8781
        lon:
          type: [number, "null"]
          format: double
          description: Centroid longitude in decimal degrees.
          example: -87.6298
        population:
          type: [integer, "null"]
          description: Population estimate, when known.
          example: 2746388
        timezone:
          type: [string, "null"]
          description: IANA timezone identifier covering the centroid.
          example: "America/Chicago"
        type:
          type: string
          description: |
            Reader-side discriminator. Currently emitted values
            include `city`, `zip`. Treat as opaque -- new types may
            appear without a sw-api release.
          example: "city"
      additionalProperties: true

    StormTrackFeature:
      type: object
      description: |
        Single storm-cell GeoJSON Feature. `geometry` is the projected
        track polyline; `properties` carries per-cell metadata.
      required: [type, geometry, properties]
      properties:
        type:
          type: string
          enum: [Feature]
        geometry:
          type: object
          description: |
            GeoJSON geometry (typically `LineString` or `MultiLineString`).
            Treat as opaque GeoJSON; pass through to the renderer
            unchanged.
          additionalProperties: true
        properties:
          $ref: '#/components/schemas/StormTrackProperties'
      additionalProperties: true

    StormTrackProperties:
      type: object
      description: |
        Per-cell property bag emitted by the storm-tracks pipeline.
        Stable keys are listed below; additional keys may appear
        without a sw-api release (forward-compat).
      required: [cell_id, site_id]
      properties:
        cell_id:
          type: string
          description: Pipeline-assigned cell identifier (stable across scans).
          example: "A1"
        site_id:
          type: string
          description: 3-letter NEXRAD radar site code that produced the cell.
          example: "TLX"
        scan_time:
          type: string
          format: date-time
          description: Radar scan timestamp the cell was observed at.
        valid_time:
          type: string
          format: date-time
          description: |
            Forecast valid-time (extrapolated). May equal `scan_time`
            for cells without an extrapolation.
        impact:
          type: object
          description: |
            Optional impact metadata. Populated only when the request
            included `detailed=true`. Property bag is forward-compat:
            new fields may appear without a sw-api release.
          additionalProperties: true
      additionalProperties: true

    Astro:
      type: object
      description: Astronomical data for the requested location and date.
      required: [day_length, daylight_length, moon_phase, moon_phase_name, moon_illumination]
      properties:
        civil_dawn:
          type: [string, "null"]
          format: date-time
        sunrise:
          type: [string, "null"]
          format: date-time
        solar_noon:
          type: [string, "null"]
          format: date-time
        sunset:
          type: [string, "null"]
          format: date-time
        civil_dusk:
          type: [string, "null"]
          format: date-time
        day_length:
          type: integer
          minimum: 0
          description: Seconds from sunrise to sunset.
        daylight_length:
          type: integer
          minimum: 0
          description: Seconds from civil dawn to civil dusk.
        moonrise:
          type: [string, "null"]
          format: date-time
          description: Null when no moonrise occurs on this date.
        moonset:
          type: [string, "null"]
          format: date-time
          description: Null when no moonset occurs on this date.
        moon_phase:
          type: number
          format: double
          minimum: 0
          maximum: 1
          description: Moon phase (0 = new, 0.5 = full, 1 = new).
        moon_phase_name:
          type: string
          enum:
            - "New Moon"
            - "Waxing Crescent"
            - "First Quarter"
            - "Waxing Gibbous"
            - "Full Moon"
            - "Waning Gibbous"
            - "Last Quarter"
            - "Waning Crescent"
        moon_illumination:
          type: number
          format: double
          minimum: 0
          maximum: 1

    # -----------------------------------------------------------------------
    # GIS (scoped down), units, attribution, meta
    # -----------------------------------------------------------------------

    GIS:
      type: object
      description: |
        Geographic metadata for the resolved location. Contains coordinate
        data, political boundaries, population, timezone, and nearest
        NEXRAD radar site metadata.

        Radar data URLs (wx-pipeline visual-products manifest, NEXRAD
        ledger URLs) are **not** returned in the Phase 2 v1 response; they
        require a public-CDN authentication model that is still being
        designed. `/v1/radar/*` endpoints will land in Phase 5.
      required: [lat, lon, timezone]
      properties:
        lat:
          type: number
          format: double
          minimum: -90
          maximum: 90
        lon:
          type: number
          format: double
          minimum: -180
          maximum: 180
        city:
          type: string
          description: City name covering the point (if the point lies inside city limits).
        nearest_city:
          type: string
          description: Nearest named city (useful when `city` is not populated).
        state:
          type: string
          description: State or equivalent subdivision (abbreviation).
        county:
          type: string
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code.
        population:
          type: [integer, "null"]
          minimum: 0
          description: Approximate population of `city` or `nearest_city`.
        timezone:
          type: string
          description: IANA timezone identifier.
        nearest_radar:
          $ref: '#/components/schemas/NearestRadar'
        nearest_station:
          $ref: '#/components/schemas/NearestStation'

    NearestStation:
      type: object
      description: Metadata for the nearest METAR observation station.
      required: [id, lat, lon, distance_km]
      properties:
        id:
          type: string
          description: ICAO station identifier (e.g., `KJFK`).
          example: "KJFK"
        name:
          type: string
        state:
          type: string
        lat:
          type: number
          format: double
        lon:
          type: number
          format: double
        distance_km:
          type: number
          format: double
          minimum: 0
        elevation_m:
          type: [number, "null"]

    NearestRadar:
      type: object
      description: |
        Metadata for the nearest NEXRAD radar site. Data URLs are not
        included here; Phase 5 `/v1/radar/*` endpoints will return the
        URL contract.
      required: [id, name, lat, lon, distance_km]
      properties:
        id:
          type: string
          description: Radar site code without ICAO prefix (e.g., `LOT`, `OKX`, `AKC`).
          example: "BOX"
        name:
          type: string
          example: "Boston"
        lat:
          type: number
          format: double
          minimum: -90
          maximum: 90
        lon:
          type: number
          format: double
          minimum: -180
          maximum: 180
        distance_km:
          type: number
          format: double
          minimum: 0

    Units:
      type: object
      description: |
        Unit labels for the numeric fields in this response. The exact
        label set is determined by the `units` request parameter.
      required: [temperature, wind_speed, pressure, visibility, precip_amount]
      properties:
        temperature:
          type: string
          enum: ["F", "C"]
          description: Temperature unit.
        wind_speed:
          type: string
          enum: ["mph", "kph"]
        pressure:
          type: string
          enum: ["inHg", "hPa"]
        visibility:
          type: string
          enum: ["mi", "km"]
        precip_amount:
          type: string
          enum: ["in", "mm"]
        altitude:
          type: string
          enum: ["m", "ft"]
          description: Unit for altitude-dimensioned fields (snow_level, cloud_ceiling).
        solar_radiation:
          type: string
          enum: ["W/m^2"]
          description: Unit for irradiance fields (currently always `W/m^2`).

    Attribution:
      type: object
      description: |
        Attribution for the data sources that contributed to this response.
        `sources` is an explicit list of canonical identifiers suitable
        for compliance checklists; `notice` is a human-readable catch-all
        covering licensing / credit language.
      required: [sources, notice]
      properties:
        sources:
          type: array
          description: Canonical source identifiers that contributed data.
          items:
            type: string
          example:
            - "NOAA NBM"
            - "HRRR"
            - "RTMA"
            - "MRMS"
            - "NWS"
            - "JPL Ephemeris"
        notice:
          type: string
          description: Human-readable attribution notice.
          example: "Weather data from NOAA/NWS. Astronomical calculations from JPL ephemerides."

    Meta:
      type: object
      description: |
        Per-response metadata: response schema version, generation time,
        upstream data freshness, and any non-fatal warnings.
      required: [schema_version, generated_at]
      properties:
        schema_version:
          type: string
          description: |
            Response schema version, emitted as an ISO 8601 date. Increments
            on every non-additive change. SDKs and caches can use this as a
            stable cache key for response shape.
          example: "2026-04-20"
        generated_at:
          type: string
          format: date-time
          description: Server time at which this response body was generated.
        freshness:
          type: array
          description: Freshness metadata for each upstream data source used.
          items:
            $ref: '#/components/schemas/SourceFreshness'
        warnings:
          type: array
          description: |
            Non-fatal warnings generated during request fulfillment. Each
            entry has a stable `code` clients can branch on and a
            human-readable `detail`.
          items:
            $ref: '#/components/schemas/MetaWarning'

    MetaWarning:
      type: object
      required: [code, section]
      properties:
        code:
          type: string
          description: |
            Stable machine-parseable warning code. Clients should branch on
            `code`, not on `detail`. Additive set; new codes may be added
            without a breaking-change announcement.
          enum:
            - partial_fetch
            - upstream_timeout
            - upstream_unavailable
            - coverage_limited
            - geocode_ambiguous
            - stale_response
          example: "partial_fetch"
        section:
          type: string
          description: |
            Response section the warning applies to (e.g., `"alerts"`,
            `"hourly"`, `"outlooks"`). May also be `"_request"` for
            request-level warnings.
          example: "alerts"
        detail:
          type: string
          description: Human-readable explanation.

    SourceFreshness:
      type: object
      description: Freshness metadata for a single upstream data source.
      required: [source, product_id, age_seconds]
      properties:
        source:
          type: string
          description: Source family (e.g., `rtma`, `hrrr`, `nbm`, `mrms`).
          example: "rtma"
        product_id:
          type: string
          description: Specific product identifier.
          example: "rtma_conus_2p5km"
        reference_time:
          type: [string, "null"]
          format: date-time
          description: Model run / observation reference time.
        latest_valid:
          type: [string, "null"]
          format: date-time
          description: Latest valid time represented in the source.
        age_seconds:
          type: integer
          minimum: 0
          description: Seconds elapsed since `reference_time`.

    # -----------------------------------------------------------------------
    # Experimental: narrations + guidance
    # -----------------------------------------------------------------------

    Narrations:
      type: object
      x-stability: experimental
      description: |
        Rule-based narrative text for current and short-term conditions,
        generated deterministically from the blended forecast (not an LLM).

        **Experimental.** Field shapes and prose styling may change during
        Phase 2 closed beta without notice; the 90-day breaking-change
        commitment applies at general availability per ADR 001.
      properties:
        current:
          type: string
          description: |
            Time-of-day-aware summary of current conditions. Weaves in, when
            available, a category- and time-aware alert cue (e.g. "Elevated
            fire-weather conditions through 1 AM."), imminent-precip nowcast, the
            current observation, the next-few-hours trend, and a time-aware
            high/low (today's high during the day; tonight's low and tomorrow's
            high in the evening). Phrasing varies by location and hour for natural
            variety but is stable between refreshes within the hour. Temperatures
            follow the requested `units`. Requires `current` data; `daily` adds
            the high/low, `alerts` the alert cue, and `nowcast` the
            imminent-precip cue.
        short_term:
          type: string
          description: Narrative covering the next few hours.

    Guidance:
      type: object
      x-stability: experimental
      description: |
        Distilled data from NWS forecaster text products (AFD, HWO, SWO,
        MCD). Structured summaries rather than raw text.

        **Experimental.** Field shapes may change during Phase 2 closed
        beta without notice; the 90-day breaking-change commitment applies
        at general availability per ADR 001.
      required: [active_mcds]
      properties:
        afd:
          $ref: '#/components/schemas/AFDGuidance'
        hwo:
          $ref: '#/components/schemas/HWOGuidance'
        active_mcds:
          type: integer
          minimum: 0
          description: Count of active SPC Mesoscale Convective Discussions overlapping the location.
        swo_day1_risk:
          type: string
          description: SPC Day-1 convective outlook categorical risk label (when active).

    AFDGuidance:
      type: object
      x-stability: experimental
      description: Structured Area Forecast Discussion summary.
      properties:
        synopsis:
          type: string
        key_takeaway:
          type: string
        confidence:
          type: string
          description: Forecaster-stated confidence level (free text).
        issued_at:
          type: [string, "null"]
          format: date-time
        wfo:
          type: string
          description: Issuing Weather Forecast Office identifier.

    HWOGuidance:
      type: object
      x-stability: experimental
      description: Structured Hazardous Weather Outlook summary.
      required: [has_active_hazards]
      properties:
        day_one:
          type: string
          description: Day-one hazard summary text.
        has_active_hazards:
          type: boolean
        issued_at:
          type: [string, "null"]
          format: date-time

    # -----------------------------------------------------------------------
    # POST /v1/weather/batch
    # -----------------------------------------------------------------------

    WeatherBatchRequest:
      type: object
      description: |
        Multi-location request body. `locations[]` is required and capped
        at 25 entries. The remaining fields are shared parameters applied
        to every location.
      required: [locations]
      properties:
        locations:
          type: array
          minItems: 1
          maxItems: 25
          description: |
            Locations to fetch in this batch. Order is preserved on the
            corresponding `results[]` array.
          items:
            $ref: '#/components/schemas/WeatherBatchLocation'
        include:
          type: string
          description: |
            Same semantics as the `include` query parameter on
            `GET /v1/weather`. Applied to every location.
          example: "current,daily,alerts"
        units:
          type: string
          enum: [imperial, metric]
          default: imperial
          description: Same semantics as the `units` query parameter on `GET /v1/weather`.
        days:
          type: integer
          minimum: 1
          maximum: 14
          default: 10
          description: Same semantics as the `days` query parameter on `GET /v1/weather`.
        hourly_hours:
          type: integer
          minimum: 1
          maximum: 168
          default: 48
          description: Same semantics as the `hourly_hours` query parameter on `GET /v1/weather`.

    WeatherBatchLocation:
      type: object
      description: |
        A single location selector. Exactly one of (`lat` + `lon`),
        `zip`, or `location` must be provided. Mixing selectors in one
        entry, or omitting all of them, fails that row only.
      properties:
        lat:
          type: number
          format: double
          minimum: -90
          maximum: 90
          description: Latitude. Required together with `lon`.
        lon:
          type: number
          format: double
          minimum: -180
          maximum: 180
          description: Longitude. Required together with `lat`.
        zip:
          type: string
          pattern: '^\d{5}$'
          description: 5-digit US ZIP code.
        location:
          type: string
          minLength: 1
          maxLength: 128
          description: Free-text location name.

    WeatherBatchResponse:
      type: object
      description: |
        Batch response. `results[i]` corresponds to `locations[i]` from
        the request body and carries either `weather` (a full F2 weather
        payload) or `error` (a structured per-location failure).
      required: [results, meta]
      properties:
        results:
          type: array
          description: One entry per input location, in input order.
          items:
            $ref: '#/components/schemas/WeatherBatchResult'
        meta:
          $ref: '#/components/schemas/Meta'

    WeatherBatchResult:
      type: object
      description: |
        A single batch entry. Exactly one of `weather` or `error` is set.
        Section-level partial failures inside a successfully resolved
        location surface as `weather.meta.warnings[]`, exactly as for
        `GET /v1/weather`.
      required: [index]
      properties:
        index:
          type: integer
          minimum: 0
          description: Zero-based index into the original `locations[]` array.
        weather:
          $ref: '#/components/schemas/Weather'
        error:
          $ref: '#/components/schemas/WeatherBatchError'

    WeatherBatchError:
      type: object
      description: Structured per-location error envelope inside a batch response.
      required: [index, code, message]
      properties:
        index:
          type: integer
          minimum: 0
          description: Zero-based index into the original `locations[]` array.
        input:
          $ref: '#/components/schemas/WeatherBatchLocation'
          description: Echo of the failing input row, for client correlation.
        code:
          type: string
          description: Stable machine-parseable error code.
          enum:
            - invalid_input
            - location_not_found
            - upstream_unavailable
            - upstream_timeout
            - internal_error
        message:
          type: string
          description: Human-readable explanation. Stable for logging; clients should branch on `code`.

    # -----------------------------------------------------------------------
    # Agriculture — Growing Degree Units (GDU / GDD)
    # -----------------------------------------------------------------------

    GddThresholds:
      type: object
      description: Resolved GDU thresholds, always expressed in °C.
      required: [base_temp_c]
      properties:
        base_temp_c:
          type: number
          format: double
          description: Base (lower) temperature threshold in °C.
        upper_temp_c:
          type: number
          format: double
          description: Upper cutoff in °C (present only for the modified method).

    GddGap:
      type: object
      description: A run of missing observed days affecting a cumulative total.
      required: [start, end, days, filled]
      properties:
        start:
          type: string
          format: date
          description: First missing day (inclusive).
        end:
          type: string
          format: date
          description: Last missing day (inclusive).
        days:
          type: integer
          description: Number of missing days in the run.
        filled:
          type: string
          description: |
            How the gap was handled, e.g. `interpolated` (short gap filled
            from adjacent days) or `unfilled` (long gap surfaced, not
            silently under-counted).

    GddMeta:
      type: object
      description: Provenance for a GDU response. Nothing is silently assumed.
      required: [day_definition, computation_path, source, unit]
      properties:
        day_definition:
          type: string
          enum: [nws, local_calendar]
          description: The daily boundary used.
        computation_path:
          type: string
          description: |
            How the series was assembled (e.g. forecast-only vs. stitched
            observed + partial + forecast), for grid-vs-point transparency.
        source:
          type: string
          description: Backing product mix (NBM / RTMA) used for this answer.
        freshness:
          type: string
          description: Human-readable data-age hint, when available.
        unit:
          type: string
          enum: [imperial, metric]
          description: Unit system applied to numeric GDU + temperature fields.
        approximate:
          type: boolean
          description: True when gaps were interpolated or coverage was partial.
        gaps:
          type: array
          description: Missing-day runs affecting a cumulative total.
          items:
            $ref: '#/components/schemas/GddGap'

    GddDay:
      type: object
      description: One day of GDU output.
      required: [date, gdu]
      properties:
        date:
          type: string
          format: date
          description: Local calendar date for this day.
        gdu:
          type: number
          format: double
          description: GDU accrued on this day, in the requested unit.
        tmax_c:
          type: number
          format: double
          description: Daily maximum temperature in °C (metric responses).
        tmin_c:
          type: number
          format: double
          description: Daily minimum temperature in °C (metric responses).
        tmax_f:
          type: number
          format: double
          description: Daily maximum temperature in °F (imperial responses).
        tmin_f:
          type: number
          format: double
          description: Daily minimum temperature in °F (imperial responses).
        cumulative_gdu:
          type: number
          format: double
          description: |
            Running season-to-date total through this day. Present only in
            cumulative mode (when `season_start` was supplied).
        missing:
          type: boolean
          description: True when the underlying day had no temperature data.
        interpolated:
          type: boolean
          description: True when this day's value was interpolated across a gap.

    GddMilestone:
      type: object
      description: |
        A crop growth-stage GDU hint from the catalog. Interpretation aid for
        clients; not a server-computed threshold.
      required: [stage, gdu]
      properties:
        stage:
          type: string
          description: Growth-stage label (e.g. `emergence`, `silking`).
        gdu:
          type: number
          format: double
          description: Cumulative GDU (requested unit) at which the stage is reached.

    PointValue:
      type: object
      description: |
        A single allowlisted value sampled at a point, with friendly metadata.
        The single-form wire shape for GET /v1/point (scalar or zone).
      required: [product, var, value, approximate]
      properties:
        product:
          type: string
          description: Echoed product identifier.
          example: sport_rsm_0_2m_pct_conus
        var:
          type: string
          description: Echoed variable identifier.
          example: rsm_0_2m_pct
        value:
          type: [number, "null"]
          format: double
          description: |
            The sampled value in `unit` (scalar), or the ordinal level of the
            containing band (zone). `null` when a zone point is in no band
            (`category: "none"`).
          example: 34.2
        unit:
          type: string
          description: Canonical unit of `value` (clients convert for display).
          example: percentile
        category:
          type: string
          description: |
            Optional human category the value falls into (e.g. soil-moisture
            `dry`/`normal`/`moist`, or an outlook band like `slight`). The
            explicit value `"none"` means a zone point is in no band. Omitted
            for scalars without categories.
          example: dry
        label:
          type: string
          description: |
            Optional human display label for a named band (e.g. `Slight`,
            `Elevated`). Omitted for plain scalars.
          example: Slight
        observed_at:
          type: string
          format: date-time
          description: Valid time of the sampled value / outlook.
          example: "2026-06-30T12:00:00Z"
        approximate:
          type: boolean
          description: |
            True when the value is older than the product's update cadence
            allows (stale).
          example: false
        status:
          type: string
          description: |
            Outcome of the point query. `ok` when a value is present;
            `unavailable` when no sample exists at the location (within domain).
          enum: [ok, unavailable]
          example: ok

    PointValueItem:
      type: object
      description: |
        One resolved value within a PointBatchResponse. Superset of PointValue
        with a per-item `status` so a single unavailable/out-of-domain value
        does not fail the whole request.
      required: [product, var, approximate, status]
      properties:
        product:
          type: string
          description: Echoed product identifier.
          example: spc_day1_categorical
        var:
          type: string
          description: Echoed variable identifier.
          example: risk
        value:
          type: [number, "null"]
          format: double
          description: |
            The sampled value / band ordinal. Omitted for non-`ok` items and
            `null`/omitted for a zone point in no band.
          example: 2
        unit:
          type: string
          description: Canonical unit of `value` (clients convert for display).
          example: mm
        category:
          type: string
          description: |
            Human category (or `"none"` for a zone point in no band). Omitted
            when not applicable.
          example: slight
        label:
          type: string
          description: Human display label for a named band. Omitted otherwise.
          example: Slight
        observed_at:
          type: string
          format: date-time
          description: Valid time of the sampled value / outlook.
          example: "2026-07-01T12:00:00Z"
        approximate:
          type: boolean
          description: True when the value is stale for its cadence.
          example: false
        status:
          type: string
          description: |
            Per-item outcome. `ok` — resolved (a zone `"none"` is still `ok`).
            `not_allowlisted` — the selector is not curated. `out_of_domain` —
            the point is outside the product's coverage. `unavailable` — no data
            at the point.
          enum: [ok, not_allowlisted, out_of_domain, unavailable]
          example: ok

    PointBatchResult:
      type: object
      description: All requested values at one location.
      required: [lat, lon, values]
      properties:
        lat:
          type: number
          format: double
          description: Echoed latitude of the requested point.
          example: 41.9
        lon:
          type: number
          format: double
          description: Echoed longitude of the requested point.
          example: -93.6
        values:
          type: array
          description: One item per requested selector, in request order.
          items:
            $ref: '#/components/schemas/PointValueItem'

    PointBatchResponse:
      type: object
      description: |
        The batch-form wire shape for GET /v1/point: per-point, per-selector
        values. Returned when `select` and/or `points` is provided.
      required: [results]
      properties:
        results:
          type: array
          description: One entry per requested point, in request order.
          items:
            $ref: '#/components/schemas/PointBatchResult'

    GrowingDegreeDays:
      type: object
      description: Growing Degree Units for a point, forecast or season-cumulative.
      required: [crop, catalog_version, method, thresholds, meta, days]
      properties:
        crop:
          type: string
          description: Echoed crop id (`corn`, ..., or `custom`).
        catalog_version:
          type: integer
          description: Version of the crop catalog used to resolve thresholds.
        method:
          type: string
          enum: [simple, modified]
          description: GDU method applied.
        thresholds:
          $ref: '#/components/schemas/GddThresholds'
        meta:
          $ref: '#/components/schemas/GddMeta'
        days:
          type: array
          description: Per-day GDU series (chronological).
          items:
            $ref: '#/components/schemas/GddDay'
        cumulative_total:
          type: number
          format: double
          description: |
            Season-to-date GDU total in the requested unit. Present only in
            cumulative mode (when `season_start` was supplied).
        milestones:
          type: array
          description: |
            Crop growth-stage GDU hints, present when `include_milestones=true`
            and the crop has catalog milestones.
          items:
            $ref: '#/components/schemas/GddMilestone'

  responses:
    Problem400:
      description: Bad request.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    Problem401:
      description: Missing or invalid API key.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    Problem403:
      description: API key lacks required scope.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    Problem404:
      description: |
        Resource not found. For `/v1/weather`, returned when the requested
        location cannot be resolved (unknown ZIP, unrecognised free-text
        location) or when no upstream data covers the location.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    Problem429:
      description: Rate limit exceeded.
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimitReset'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    Problem500:
      description: Internal server error.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    Problem502:
      description: |
        Bad gateway. The service received an invalid response from an
        upstream data source. Transient; clients should retry with backoff.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    Problem503:
      description: |
        Service unavailable. Typically returned when an upstream-reader
        circuit breaker has tripped after repeated failures. Transient;
        clients should retry with backoff and honor any `Retry-After`
        header.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    Problem504:
      description: |
        Gateway timeout. An upstream data source did not respond within
        the service's budget.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
