{
  "openapi": "3.0.1",
  "info": {
    "title": "NIMBUS Weather API",
    "version": "0.1.0-layer3-nimbus",
    "description": "Public weather and climate API for NIMBUS by WeatherPlus.\n\nProducts: weather radar, numerical forecast rainfall (GFS/ICON/ECMWF and others), analytical rainfall, geostationary satellite imagery, 10 m wind fields, GFS temperature / thunderstorm potential / heat / cold layers, lightning strikes and tropical cyclone tracks and forecasts.\n\n### Tiles\nAll tile endpoints serve XYZ web-mercator (EPSG:3857) tiles, 256x256, and send `Access-Control-Allow-Origin: *`. Run timestamps are UTC; heat/cold frame dates are Vietnam calendar days (UTC+7). Other tile path timestamps are UTC.\n\nRadar, rainfall and forecast tiles are **encoded data, not pictures**: the red channel carries the normalised value under a gamma curve and the alpha channel is a coverage mask (alpha 0 means *no data*, which is not the same as zero rain). Decode with `value = pow(R/255, gamma) * range`, reading `gamma` and `range` from the `x-radar-meta` / `x-rainfall-meta` response header (base64url-encoded JSON), defaulting to `gamma = 2.0` and `range = 100` mm/h.\n\nSatellite tiles are ordinary true-colour WebP images (zoom 3-6). Wind tiles (`/tiles/webp-v2/`) are RGB with `u = (R/255*2-1)*64`, `v = (G/255*2-1)*64` and `speed = B/255*64`, all in m/s; the deprecated `/tiles/webp/` route keeps the old ±32 / 36 m/s ranges.\n\n### Authentication\nThe endpoints in this document are public and require no credentials. If an API key is issued to you, send it as `X-API-Key`; it is accepted and ignored where authentication is not enforced.\n\nGFS temperature is global: lat -85 to 85, all longitudes (antimeridian-safe), 0.25°. Thunderstorm potential (K-index), daily heat and cold thresholds stay restricted to lat 6-26 / lon 100-112 (Vietnam). Lossless WebP RG16: value=(256*R+G)/100-100; B=category; A=0 missing, A=255 valid. Read x-weather-meta (base64url UTF-8 JSON). Decode before filtering/colourisation. Daily frames require all 24 hourly samples from the same run, midnight to midnight UTC+7, windowEnd exclusive. These derived layers are not calibrated lightning probabilities or official heat/cold warnings.\n\n### About this document\nGenerated from the running service (`dotnet run -- --export-openapi`): routes, methods and parameters come straight from the controllers; descriptions and response schemas are curated in `docs/api/openapi-overlay.json`.",
    "contact": {
      "name": "NIMBUS",
      "url": "https://resources.nimbus.vn"
    }
  },
  "paths": {
    "/api/cold/noaa-gfs/timeline": {
      "get": {
        "tags": [
          "Cold"
        ],
        "operationId": "Cold_GetTimeline",
        "parameters": [
          {
            "name": "run",
            "in": "query",
            "schema": {
              "type": "string",
              "pattern": "^\\d{8}(00|06|12|18)$"
            },
            "description": "UTC run YYYYMMDDHH, cycles 00/06/12/18."
          }
        ],
        "responses": {
          "200": {
            "description": "Timeline with metadata; cached 60 seconds.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WeatherTimeline"
                }
              }
            }
          },
          "400": {
            "description": "Invalid run, frame or XYZ coordinates."
          },
          "404": {
            "description": "Run/frame not held by the server (use a run and frame listed by the timeline — the server never fetches upstream on demand) or daily window incomplete."
          },
          "503": {
            "description": "Warming up or source/decode error. Not cached.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "string",
                  "example": "60"
                },
                "description": "Retry after 60 seconds."
              }
            }
          }
        },
        "summary": "Cold available frames",
        "description": "Daily mean of 24 hourly TMP2m samples (UTC+7), same run. Classes in degC: 0:>15, 1:>13 to <=15, 2:<=13. Temperature thresholds only; not an official cold warning. Omitting run selects the latest run with actual frames for this product; daily may lag hourly. Use current and frames[].tileUrl; do not synthesize frames."
      }
    },
    "/api/cold/noaa-gfs/tiles/webp/{run}/{frame}/{z}/{x}/{y}.webp": {
      "get": {
        "tags": [
          "Cold"
        ],
        "operationId": "Cold_GetTile",
        "parameters": [
          {
            "name": "run",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^\\d{8}(00|06|12|18)$"
            }
          },
          {
            "name": "frame",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^\\d{8}$"
            },
            "description": "Vietnam date yyyyMMdd, complete local day only."
          },
          {
            "name": "z",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 12
            },
            "description": "Zoom 0..12."
          },
          {
            "name": "x",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0
            },
            "description": "Must be less than 2^z; no wrapping."
          },
          {
            "name": "y",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0
            },
            "description": "Must be less than 2^z; no wrapping."
          },
          {
            "in": "header",
            "description": "ETag revalidation",
            "schema": {
              "type": "string"
            },
            "required": false,
            "name": "If-None-Match"
          }
        ],
        "responses": {
          "200": {
            "description": "Encoded scalar data, not a display image.",
            "headers": {
              "x-weather-meta": {
                "description": "Base64url UTF-8 JSON, schema WeatherTileMetadata. Includes product encoding, legend, frame and coverage.",
                "schema": {
                  "type": "string"
                }
              },
              "ETag": {
                "schema": {
                  "type": "string"
                }
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "Complete input: public, max-age=86400. Partial input: no-store."
              }
            },
            "content": {
              "image/webp": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "304": {
            "description": "ETag unchanged.",
            "headers": {
              "x-weather-meta": {
                "description": "Base64url UTF-8 JSON, schema WeatherTileMetadata. Includes product encoding, legend, frame and coverage.",
                "schema": {
                  "type": "string"
                }
              },
              "ETag": {
                "schema": {
                  "type": "string"
                }
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "Complete input: public, max-age=86400. Partial input: no-store."
              }
            }
          },
          "400": {
            "description": "Invalid run, frame or XYZ coordinates."
          },
          "404": {
            "description": "Run/frame not held by the server (use a run and frame listed by the timeline — the server never fetches upstream on demand) or daily window incomplete."
          },
          "503": {
            "description": "Warming up or source/decode error. Not cached.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "string",
                  "example": "60"
                },
                "description": "Retry after 60 seconds."
              }
            }
          }
        },
        "summary": "Cold encoded RG16 tile",
        "description": "Daily mean of 24 hourly TMP2m samples (UTC+7), same run. Classes in degC: 0:>15, 1:>13 to <=15, 2:<=13. Temperature thresholds only; not an official cold warning. Lossless 256x256 WebP, value=(256*R+G)/100-100, B=class, A=0 missing/255 valid. Zero and negative values are valid. Outside bbox returns transparent WebP. Decode bytes before filtering; derived categories use nearest interpolation."
      }
    },
    "/api/forecast-grid/sources": {
      "get": {
        "tags": [
          "Forecast grid"
        ],
        "operationId": "ForecastGrid_GetSources",
        "responses": {
          "200": {
            "description": "Sources",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ForecastGridSources"
                }
              }
            }
          },
          "503": {
            "description": "Temporarily unavailable — retry after the `Retry-After` header (60 s)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "List forecast grid sources and the bbox area limit",
        "description": "Sources accepted by `GET /api/forecast-grid/{source}`, each with provider, unit and `domain` (`latMin`, `latMax`, `lonMin`, `lonMax`, `step`). An NWP source's `domain` is omitted until NIMBUS has loaded a run of it. `maxBboxAreaDeg2` is the largest bbox area accepted per request."
      }
    },
    "/api/forecast-grid/{source}": {
      "get": {
        "tags": [
          "Forecast grid"
        ],
        "operationId": "ForecastGrid_GetGrid",
        "parameters": [
          {
            "name": "source",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "noaa-gfs-temperature"
            },
            "description": "Source id from `/api/forecast-grid/sources`: `gfs`, `icon`, `ecmwf`, `nimbus`, `coms`, `wrf84h` (WeatherPlus NWP rainfall, mm per step), `noaa-gfs-temperature` (degC) or `noaa-gfs-precipitation` (mm/h)."
          },
          {
            "name": "bbox",
            "in": "query",
            "schema": {
              "type": "string",
              "example": "100,6,106,16"
            },
            "description": "Required. `minLon,minLat,maxLon,maxLat`, longitude first. Area at most 60 deg²; crossing the 180° meridian is not supported."
          },
          {
            "name": "from",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Earliest valid time, ISO-8601 UTC (inclusive). Default: first step of the latest run."
          },
          {
            "name": "to",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Latest valid time, ISO-8601 UTC (inclusive). Default: last step of the latest run."
          }
        ],
        "responses": {
          "200": {
            "description": "Grid values",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ForecastGrid"
                }
              }
            }
          },
          "400": {
            "description": "bbox missing, malformed, over 60 deg² or containing no grid point; from/to malformed; from later than to; window needs more than 20 on-demand frames",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Unknown source, or no step of the latest run inside from/to",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "No run loaded yet — retry after the `Retry-After` header (60 s)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "Forecast values inside a bbox",
        "description": "Every grid point inside `bbox` (edges inclusive, clipped to the source domain) for the **latest run**, filtered to valid times in [`from`, `to`]. No `run` parameter. Column-shaped: shared `lats`/`lons` axes, and `values[t]` holds exactly `rows × columns` numbers, `index = row * columns + col`, rows south→north, columns west→east. `null` means no data. NWP sources store only wet cells, so a dry cell is `0`. NOAA GFS sources always return an evenly spaced hourly axis f000-f168 (f001-f168 for precipitation). NOAA only publishes hourly to f120; the hours in between the 3-hourly frames after that are derived — temperature by linear interpolation, precipitation by carrying the containing 3-hour average, so hourly sums stay correct. Derived hours are not flagged and every step reports `stepHours: 1`; to keep only published hours, take those <= f120 or divisible by 3. Published frames past f084 are decoded from disk on demand, at most 20 per request (a wider window returns 400). Tiles and timelines never derive anything. bbox area `(maxLon−minLon)×(maxLat−minLat)` must not exceed 60 deg² (the Vietnam box 100–112°E, 6–26°N divided by 4): Vietnam is `100,6,106,16`, `106,6,112,16`, `100,16,106,26`, `106,16,112,26` — shared edges appear in both neighbouring responses."
      }
    },
    "/api/forecast-grid/{source}/point": {
      "get": {
        "tags": [
          "Forecast grid"
        ],
        "operationId": "ForecastGrid_GetPoint",
        "parameters": [
          {
            "name": "source",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "noaa-gfs-temperature"
            },
            "description": "Source id from `/api/forecast-grid/sources`: `gfs`, `icon`, `ecmwf`, `nimbus`, `coms`, `wrf84h` (WeatherPlus NWP rainfall, mm per step), `noaa-gfs-temperature` (degC) or `noaa-gfs-precipitation` (mm/h)."
          },
          {
            "name": "lat",
            "in": "query",
            "schema": {
              "type": "number",
              "format": "double",
              "example": 21.02
            },
            "description": "Required. Latitude, -90..90.",
            "required": true
          },
          {
            "name": "lon",
            "in": "query",
            "schema": {
              "type": "number",
              "format": "double",
              "example": 105.83
            },
            "description": "Required. Longitude, -180..180.",
            "required": true
          },
          {
            "name": "from",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Earliest valid time, ISO-8601 UTC (inclusive). Default: first step of the latest run."
          },
          {
            "name": "to",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Latest valid time, ISO-8601 UTC (inclusive). Default: last step of the latest run for NWP sources; f084 for NOAA GFS sources when neither `from` nor `to` is given."
          }
        ],
        "responses": {
          "200": {
            "description": "Series at the nearest grid point",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ForecastGridPoint"
                }
              }
            }
          },
          "400": {
            "description": "lat/lon missing, malformed or out of range; point more than half a grid cell outside the source domain; from/to malformed; from later than to; window needs more than 20 on-demand frames",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Unknown source, or no step of the latest run inside from/to",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "No run loaded yet — retry after the `Retry-After` header (60 s)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "Forecast series at a lat/lon point",
        "description": "Forecast values of the **latest run** at the grid point nearest to (`lat`, `lon`) — no interpolation; `gridPoint` says which point was read. A point up to half a grid cell outside the source domain snaps to the edge; farther out returns 400. Same sources, time axis and values as `GET /api/forecast-grid/{source}`: the point and a bbox covering the same grid point return identical numbers. `null` means no data; NWP sources store only wet cells, so a dry cell is `0`. **Default window:** NWP sources return the whole run; the two NOAA GFS sources stop at f084 (the in-memory part, about 3.5 days) because the whole run needs more than the 20 on-demand frames allowed per request. Pass `from`/`to` for later hours — past f084 at most 20 published frames per request."
      }
    },
    "/api/forecast-model/models": {
      "get": {
        "tags": [
          "Forecast model"
        ],
        "operationId": "ForecastModel_GetModels",
        "responses": {
          "200": {
            "description": "Model registry",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ModelList"
                }
              }
            }
          }
        },
        "summary": "List available forecast models"
      }
    },
    "/api/forecast-model/{model}/runs": {
      "get": {
        "tags": [
          "Forecast model"
        ],
        "operationId": "ForecastModel_GetRuns",
        "parameters": [
          {
            "name": "model",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "gfs"
            },
            "description": "Model slug, e.g. `gfs`."
          },
          {
            "name": "date",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Restrict to a date, yyyyMMdd."
          },
          {
            "name": "count",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 8
            },
            "description": "Maximum runs to return."
          }
        ],
        "responses": {
          "200": {
            "description": "Run list",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ModelRuns"
                }
              }
            }
          },
          "404": {
            "description": "Unknown model",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "List recent runs of a model"
      }
    },
    "/api/forecast-model/{model}/timeline": {
      "get": {
        "tags": [
          "Forecast model"
        ],
        "operationId": "ForecastModel_GetTimeline",
        "parameters": [
          {
            "name": "model",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "gfs"
            }
          },
          {
            "name": "run",
            "in": "query",
            "schema": {
              "type": "string",
              "example": "2026090400"
            },
            "description": "Run key, yyyyMMddHH. Defaults to the latest run."
          }
        ],
        "responses": {
          "200": {
            "description": "Run timeline",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ModelTimeline"
                }
              }
            }
          },
          "404": {
            "description": "Unknown model or run",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "Forecast steps for a run",
        "description": "Returns every forecast hour of the run together with its valid time. Defaults to the latest run."
      }
    },
    "/api/forecast-model/{model}/tiles/{run}/{valid}/{z}/{x}/{filename}": {
      "get": {
        "tags": [
          "Forecast model"
        ],
        "operationId": "ForecastModel_GetTile",
        "parameters": [
          {
            "name": "model",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "gfs"
            }
          },
          {
            "name": "run",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "2026090400"
            },
            "description": "Run key, yyyyMMddHH."
          },
          {
            "name": "valid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "2026090412"
            },
            "description": "Valid time, yyyyMMddHH."
          },
          {
            "name": "z",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "Zoom level."
          },
          {
            "name": "x",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "Tile column."
          },
          {
            "name": "filename",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Tile row followed by the extension, i.e. `{y}.webp` — for example `14.webp`."
          }
        ],
        "responses": {
          "200": {
            "description": "Encoded tile",
            "content": {
              "image/webp": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Outside coverage or step unavailable"
          }
        },
        "summary": "Forecast rainfall tile (encoded WebP)",
        "description": "Zoom 3-9. `run` and `valid` are both `yyyyMMddHH` in UTC. Encoding matches the rainfall scheme."
      }
    },
    "/health": {
      "get": {
        "tags": [
          "System"
        ],
        "operationId": "Health_GetHealth",
        "responses": {
          "200": {
            "description": "Service is healthy",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                }
              }
            }
          }
        },
        "summary": "Service health"
      }
    },
    "/api/heat/noaa-gfs/timeline": {
      "get": {
        "tags": [
          "Heat"
        ],
        "operationId": "Heat_GetTimeline",
        "parameters": [
          {
            "name": "run",
            "in": "query",
            "schema": {
              "type": "string",
              "pattern": "^\\d{8}(00|06|12|18)$"
            },
            "description": "UTC run YYYYMMDDHH, cycles 00/06/12/18."
          }
        ],
        "responses": {
          "200": {
            "description": "Timeline with metadata; cached 60 seconds.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WeatherTimeline"
                }
              }
            }
          },
          "400": {
            "description": "Invalid run, frame or XYZ coordinates."
          },
          "404": {
            "description": "Run/frame not held by the server (use a run and frame listed by the timeline — the server never fetches upstream on demand) or daily window incomplete."
          },
          "503": {
            "description": "Warming up or source/decode error. Not cached.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "string",
                  "example": "60"
                },
                "description": "Retry after 60 seconds."
              }
            }
          }
        },
        "summary": "Heat available frames",
        "description": "Daily max of 24 hourly TMP2m samples (UTC+7), same run. Classes in degC: 0:<35, 1:35 to <37, 2:37 to <39, 3:>=39. Sampled maximum can miss between-hour peaks. Omitting run selects the latest run with actual frames for this product; daily may lag hourly. Use current and frames[].tileUrl; do not synthesize frames."
      }
    },
    "/api/heat/noaa-gfs/tiles/webp/{run}/{frame}/{z}/{x}/{y}.webp": {
      "get": {
        "tags": [
          "Heat"
        ],
        "operationId": "Heat_GetTile",
        "parameters": [
          {
            "name": "run",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^\\d{8}(00|06|12|18)$"
            }
          },
          {
            "name": "frame",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^\\d{8}$"
            },
            "description": "Vietnam date yyyyMMdd, complete local day only."
          },
          {
            "name": "z",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 12
            },
            "description": "Zoom 0..12."
          },
          {
            "name": "x",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0
            },
            "description": "Must be less than 2^z; no wrapping."
          },
          {
            "name": "y",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0
            },
            "description": "Must be less than 2^z; no wrapping."
          },
          {
            "in": "header",
            "description": "ETag revalidation",
            "schema": {
              "type": "string"
            },
            "required": false,
            "name": "If-None-Match"
          }
        ],
        "responses": {
          "200": {
            "description": "Encoded scalar data, not a display image.",
            "headers": {
              "x-weather-meta": {
                "description": "Base64url UTF-8 JSON, schema WeatherTileMetadata. Includes product encoding, legend, frame and coverage.",
                "schema": {
                  "type": "string"
                }
              },
              "ETag": {
                "schema": {
                  "type": "string"
                }
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "Complete input: public, max-age=86400. Partial input: no-store."
              }
            },
            "content": {
              "image/webp": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "304": {
            "description": "ETag unchanged.",
            "headers": {
              "x-weather-meta": {
                "description": "Base64url UTF-8 JSON, schema WeatherTileMetadata. Includes product encoding, legend, frame and coverage.",
                "schema": {
                  "type": "string"
                }
              },
              "ETag": {
                "schema": {
                  "type": "string"
                }
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "Complete input: public, max-age=86400. Partial input: no-store."
              }
            }
          },
          "400": {
            "description": "Invalid run, frame or XYZ coordinates."
          },
          "404": {
            "description": "Run/frame not held by the server (use a run and frame listed by the timeline — the server never fetches upstream on demand) or daily window incomplete."
          },
          "503": {
            "description": "Warming up or source/decode error. Not cached.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "string",
                  "example": "60"
                },
                "description": "Retry after 60 seconds."
              }
            }
          }
        },
        "summary": "Heat encoded RG16 tile",
        "description": "Daily max of 24 hourly TMP2m samples (UTC+7), same run. Classes in degC: 0:<35, 1:35 to <37, 2:37 to <39, 3:>=39. Sampled maximum can miss between-hour peaks. Lossless 256x256 WebP, value=(256*R+G)/100-100, B=class, A=0 missing/255 valid. Zero and negative values are valid. Outside bbox returns transparent WebP. Decode bytes before filtering; derived categories use nearest interpolation."
      }
    },
    "/api/lightning/strikes": {
      "get": {
        "tags": [
          "Lightning"
        ],
        "operationId": "Lightning_GetStrikes",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Start of window, ISO-8601 UTC (inclusive). Default: `to` − 1 hour."
          },
          {
            "name": "to",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "End of window, ISO-8601 UTC (inclusive). Default: now."
          },
          {
            "name": "bbox",
            "in": "query",
            "schema": {
              "type": "string",
              "example": "100,6,112,26"
            },
            "description": "`minLon,minLat,maxLon,maxLat` — longitude first (GeoJSON/Leaflet order). Omit for the whole coverage area."
          },
          {
            "name": "strokeType",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "cg",
                "0",
                "ic",
                "1"
              ]
            },
            "description": "`cg` or `0` = cloud-to-ground; `ic` or `1` = intracloud. Omit for both."
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 50000
            },
            "description": "Maximum number of features returned (default 50000)."
          }
        ],
        "responses": {
          "200": {
            "description": "Strikes in the window",
            "content": {
              "application/geo+json": {
                "schema": {
                  "$ref": "#/components/schemas/LightningStrikes"
                }
              }
            }
          },
          "400": {
            "description": "Window longer than 1 hour, malformed bbox/time, or unknown strokeType",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Lightning data temporarily unavailable — retry after the `Retry-After` header (60 s)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "Lightning strikes as GeoJSON points",
        "description": "Discrete strike fixes from the WeatherPlus lightning network, newest first, as a GeoJSON `FeatureCollection` (`application/geo+json`). The `from`–`to` window may not exceed 1 hour — a longer window returns `400`, never a silently truncated result. Only the last 12 hours are held in cache; older windows return an empty `features` array. `truncated: true` in `properties` means the result hit `limit` — narrow the bbox or window."
      }
    },
    "/api/lightning/freshness": {
      "get": {
        "tags": [
          "Lightning"
        ],
        "operationId": "Lightning_GetFreshness",
        "responses": {
          "200": {
            "description": "Freshness report",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LightningFreshness"
                }
              }
            }
          },
          "503": {
            "description": "Freshness unavailable",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "Lightning feed freshness and cache state",
        "description": "Health of the lightning feed. Read `fileAgeMinutes` — the 5-minute source file arrives even with zero strikes, so a value above ~15 minutes means collection is behind. `ageMinutes` is the time since the last strike and is large whenever there simply are no thunderstorms; do not surface it as a health signal."
      }
    },
    "/api/precipitation/noaa-gfs/timeline": {
      "get": {
        "tags": [
          "Precipitation"
        ],
        "operationId": "Precipitation_GetTimeline",
        "parameters": [
          {
            "name": "run",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/precipitation/noaa-gfs/tiles/webp/{run}/{frame}/{z}/{x}/{y}.webp": {
      "get": {
        "tags": [
          "Precipitation"
        ],
        "operationId": "Precipitation_GetTile",
        "parameters": [
          {
            "name": "run",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "frame",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "z",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "x",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "y",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/radar/timeline": {
      "get": {
        "tags": [
          "Radar"
        ],
        "operationId": "Radar_GetTimeline",
        "parameters": [
          {
            "name": "hours",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 6
            },
            "description": "Look-back window in hours."
          },
          {
            "name": "nowcast",
            "in": "query",
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "Append the nowcast frames after `latest`. Off by default so clients that treat every frame as an observation keep working."
          }
        ],
        "responses": {
          "200": {
            "description": "Radar timeline",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RadarTimeline"
                }
              }
            }
          }
        },
        "summary": "List available radar frames",
        "description": "Frames are 10 minutes apart, newest last. Cached for 60 seconds. When the upstream data service is unreachable the list still contains every frame this service holds, with `stale: true`. With `nowcast=true`, up to six forecast frames (`nowcast: true`, up to +60 minutes) follow the newest observed frame, extrapolated from the motion of the last three frames and rebuilt with every new frame; `latest` stays the newest observation. Without it the response lists observations only."
      }
    },
    "/api/radar/coverage": {
      "get": {
        "tags": [
          "Radar"
        ],
        "operationId": "Radar_GetCoverage",
        "responses": {
          "200": {
            "description": "Coverage polygon (EPSG:4326, counter-clockwise outer rings)",
            "content": {
              "application/geo+json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "type": {
                      "type": "string",
                      "example": "FeatureCollection"
                    },
                    "features": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "summary": "Area scanned by the radar network",
        "description": "GeoJSON FeatureCollection with one MultiPolygon feature: the union of the 300 km scan ranges around the weather radars in the composite (Vietnam's national network; positions from the hydro-meteorological observation centre). `properties.stations` lists each radar (`code`, `name`, `lat`, `lon`, `rangeKm`). Outside the polygon an empty radar pixel means *no radar*, not *no rain* — dim that area or label it \"No radar data\". Static; cached for a day."
      }
    },
    "/api/radar/motion": {
      "get": {
        "tags": [
          "Radar"
        ],
        "operationId": "Radar_GetMotion",
        "parameters": [
          {
            "name": "timestamp",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "ISO 8601 UTC of the first frame of the pair, on the 10-minute frame grid with zero seconds (e.g. `2026-09-25T10:10:00Z`) — pass a `timestamp` from `/api/radar/timeline`. Anything else is rejected with 400."
          },
          {
            "name": "run",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "`nowcastRun` of the second frame when it is a forecast frame."
          }
        ],
        "responses": {
          "200": {
            "description": "Motion field",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RadarMotion"
                }
              }
            }
          },
          "400": {
            "description": "Missing or malformed timestamp, or not on the 10-minute frame grid",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "One of the two frames is not available",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "Rain motion between two frames",
        "description": "Motion field from the frame at `timestamp` to the next frame (+10 minutes), for animating the transition: shift the first frame forward by t times the field and the second back by (1 - t) times, then blend, and rain moves instead of fading in place. Pass `run` (the `nowcastRun` of the second frame) when that frame is a forecast to get the motion the nowcast used; without it the pair is two observations. Observed pairs are cached for a day, nowcast pairs for 5 minutes."
      }
    },
    "/api/radar/point": {
      "get": {
        "tags": [
          "Radar"
        ],
        "operationId": "Radar_GetPointValue",
        "parameters": [
          {
            "name": "lat",
            "in": "query",
            "schema": {
              "type": "number",
              "format": "double",
              "example": 21.02
            }
          },
          {
            "name": "lon",
            "in": "query",
            "schema": {
              "type": "number",
              "format": "double",
              "example": 105.83
            }
          },
          {
            "name": "timestamp",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "ISO 8601 UTC time of a frame on the 10-minute grid with zero seconds (e.g. `2026-09-25T10:10:00Z`) — pass a `timestamp` from `/api/radar/timeline`. Anything else is rejected with 400. Defaults to the latest available frame."
          },
          {
            "name": "run",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "`nowcastRun` of a forecast frame: read the nowcast. Without it the observation is returned, falling back to the nowcast (flagged `nowcast: true`) only when no observation exists yet."
          }
        ],
        "responses": {
          "200": {
            "description": "Value in mm/h",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PointValue"
                }
              }
            }
          },
          "400": {
            "description": "Malformed timestamp, or not on the frame grid (10 minutes for radar, the hour for rainfall)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No data for that timestamp",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "Radar rain rate at a point"
      }
    },
    "/api/radar/tiles/{year}/{month}/{day}/{hour}/{minute}/{z}/{x}/{filename}": {
      "get": {
        "tags": [
          "Radar"
        ],
        "operationId": "Radar_GetTile",
        "parameters": [
          {
            "name": "year",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "2026"
            }
          },
          {
            "name": "month",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "09"
            }
          },
          {
            "name": "day",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "04"
            }
          },
          {
            "name": "hour",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "04"
            }
          },
          {
            "name": "minute",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "10"
            }
          },
          {
            "name": "z",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "Zoom level."
          },
          {
            "name": "x",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "Tile column."
          },
          {
            "name": "filename",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Tile row followed by the extension, i.e. `{y}.webp` — for example `14.webp`."
          },
          {
            "name": "run",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "`nowcastRun` of a forecast frame from the timeline. Required for nowcast tiles; omit for observations."
          }
        ],
        "responses": {
          "200": {
            "description": "Encoded tile",
            "headers": {
              "x-radar-meta": {
                "description": "Base64url JSON with bbox and encoding parameters.",
                "schema": {
                  "type": "string"
                }
              },
              "x-radar-range": {
                "description": "Encoding range in mm/h.",
                "schema": {
                  "type": "string"
                }
              },
              "x-radar-encoding": {
                "description": "Pixel encoding, e.g. `webp-rgba8`.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "image/webp": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Outside coverage or frame unavailable"
          }
        },
        "summary": "Radar tile (encoded WebP)",
        "description": "Zoom 5-13. The red channel carries the value and alpha is the coverage mask; decode using the `x-radar-meta` header. A 404 means the tile lies outside coverage and should be treated as an empty cell, not an error. Nowcast slots (timeline frames with `nowcast: true`) are served from memory with `Cache-Control: public, max-age=600` instead of `immutable`, and `x-radar-meta` carries `nowcast: true` plus `nowcastRun`; request them as `...webp?run={nowcastRun}`. Without `run` the route serves observations only, so an observed tile and a forecast for the same slot never share a URL."
      }
    },
    "/api/rainfall/timeline": {
      "get": {
        "tags": [
          "Rainfall"
        ],
        "operationId": "Rainfall_GetTimeline",
        "parameters": [
          {
            "name": "date",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Restrict to a date, yyyy-MM-dd."
          },
          {
            "name": "hours",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 24
            },
            "description": "Look-back window in hours."
          }
        ],
        "responses": {
          "200": {
            "description": "Rainfall timeline",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RainfallTimeline"
                }
              }
            }
          }
        },
        "summary": "List available analytical rainfall frames",
        "description": "Frames are 1 hour apart, newest last. Cached for 60 seconds. When the upstream data service is unreachable the list still contains every frame this service holds, with `stale: true`."
      }
    },
    "/api/rainfall/point": {
      "get": {
        "tags": [
          "Rainfall"
        ],
        "operationId": "Rainfall_GetPointValue",
        "parameters": [
          {
            "name": "lat",
            "in": "query",
            "schema": {
              "type": "number",
              "format": "double"
            }
          },
          {
            "name": "lon",
            "in": "query",
            "schema": {
              "type": "number",
              "format": "double"
            }
          },
          {
            "name": "timestamp",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "ISO 8601 UTC time of an hourly frame, on the hour with zero seconds (e.g. `2026-09-25T10:00:00Z`) — pass a `timestamp` from `/api/rainfall/timeline`. Anything else is rejected with 400. Defaults to the latest frame."
          }
        ],
        "responses": {
          "200": {
            "description": "Value in mm",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PointValue"
                }
              }
            }
          },
          "400": {
            "description": "Malformed timestamp, or not on the frame grid (10 minutes for radar, the hour for rainfall)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No data for that timestamp",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "Accumulated rainfall at a point"
      }
    },
    "/api/rainfall/tiles/{year}/{month}/{day}/{hour}/{z}/{x}/{filename}": {
      "get": {
        "tags": [
          "Rainfall"
        ],
        "operationId": "Rainfall_GetTile",
        "parameters": [
          {
            "name": "year",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "2026"
            }
          },
          {
            "name": "month",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "09"
            }
          },
          {
            "name": "day",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "04"
            }
          },
          {
            "name": "hour",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "04"
            }
          },
          {
            "name": "z",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "Zoom level."
          },
          {
            "name": "x",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "Tile column."
          },
          {
            "name": "filename",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Tile row followed by the extension, i.e. `{y}.webp` — for example `14.webp`."
          }
        ],
        "responses": {
          "200": {
            "description": "Encoded tile",
            "headers": {
              "x-rainfall-meta": {
                "description": "Base64url JSON with bbox and encoding parameters.",
                "schema": {
                  "type": "string"
                }
              },
              "x-rainfall-range": {
                "description": "Encoding range in mm.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "image/webp": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Outside coverage or frame unavailable"
          }
        },
        "summary": "Analytical rainfall tile (encoded WebP)",
        "description": "Zoom 3-9. Same encoding scheme as radar, read from `x-rainfall-meta`. Note there is **no minute segment** in this path, unlike radar."
      }
    },
    "/api/satellite/timeline": {
      "get": {
        "tags": [
          "Satellite"
        ],
        "operationId": "SatelliteV2_GetMergedTimeline",
        "parameters": [
          {
            "name": "hours",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 12
            },
            "description": "Look-back window, 1-48."
          }
        ],
        "responses": {
          "200": {
            "description": "Merged satellite timeline",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SatelliteMergedTimeline"
                }
              }
            }
          },
          "400": {
            "description": "`hours` outside 1-48",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "Merged satellite timeline (all satellites)",
        "description": "10-minute frame grid for the global composite of Himawari-9, Meteosat-9 IODC, Meteosat-12 (MTG), GOES-18 and GOES-19. For each frame, `sources` gives the image slot to draw per satellite (null when that satellite has nothing close enough). `latest` is the newest frame with a Himawari-9 image; later frames may report `available: false`. `satellites` lists each satellite's bounds, cadence, zoom range and tile template."
      }
    },
    "/api/satellite/{satellite}/timeline": {
      "get": {
        "tags": [
          "Satellite"
        ],
        "operationId": "SatelliteV2_GetSatelliteTimeline",
        "parameters": [
          {
            "name": "satellite",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "himawari",
                "msg-iodc",
                "goes-west",
                "goes-east",
                "mtg-zero"
              ]
            }
          },
          {
            "name": "hours",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 12
            },
            "description": "Look-back window, 1-48."
          }
        ],
        "responses": {
          "200": {
            "description": "Satellite timeline",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SatelliteTimeline"
                }
              }
            }
          },
          "404": {
            "description": "Unknown satellite",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "Frames for one satellite"
      }
    },
    "/api/satellite/{satellite}/{yyyy}/{mo}/{dd}/{hh}/{mi}/{z}/{x}/{y}.webp": {
      "get": {
        "tags": [
          "Satellite"
        ],
        "operationId": "SatelliteV2_GetTileWebp",
        "parameters": [
          {
            "name": "satellite",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "himawari",
                "msg-iodc",
                "goes-west",
                "goes-east",
                "mtg-zero"
              ]
            }
          },
          {
            "name": "yyyy",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Frame year (UTC)."
          },
          {
            "name": "mo",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Frame month, 2 digits."
          },
          {
            "name": "dd",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Frame day, 2 digits."
          },
          {
            "name": "hh",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Frame hour (UTC), 2 digits."
          },
          {
            "name": "mi",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Frame minute, 2 digits."
          },
          {
            "name": "z",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            },
            "description": "Zoom level, 3-6."
          },
          {
            "name": "x",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            },
            "description": "Tile column."
          },
          {
            "name": "y",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            },
            "description": "Tile row (path segment is `{y}.webp`)."
          }
        ],
        "responses": {
          "200": {
            "description": "WebP tile",
            "headers": {
              "x-satellite-meta": {
                "description": "Base64url JSON describing the frame and tile.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "image/webp": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "400": {
            "description": "Invalid date, zoom or tile index"
          },
          "404": {
            "description": "Unknown satellite, outside its footprint, or frame not available"
          }
        },
        "summary": "Satellite tile (true-colour WebP)",
        "description": "Zoom 3-6, day true colour / night infrared composite, processed by NIMBUS from the satellite operators' own data. Ordinary images: use directly as a raster source.\n\nCoverage (`[west, south, east, north]`) and cadence:\n- `himawari` `[84.375, -66.513, 185.625, 66.513]`, 10 min\n- `msg-iodc` `[33.75, -66.513, 90, 66.513]`, 15 min\n- `goes-west` `[-180, -66.513, -129.375, 66.513]`, 20 min\n- `goes-east` `[-135, -66.513, -11.25, 66.513]`, 20 min\n- `mtg-zero` `[-16.875, -66.513, 39.375, 66.513]`, 10 min\n\nA minute off the satellite's cadence is floored to the previous slot. Longitudes above 180 cover the wrap past the antimeridian. Credit the satellite's `attribution` from the timeline."
      }
    },
    "/api/satellite/{satellite}/{yyyy}/{mo}/{dd}/{hh}/{mi}/{z}/{x}/{y}.jpg": {
      "get": {
        "tags": [
          "Satellite"
        ],
        "operationId": "SatelliteV2_GetTileJpg",
        "parameters": [
          {
            "name": "satellite",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "yyyy",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "mo",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "dd",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "hh",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "mi",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "z",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "x",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "y",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Tile image"
          },
          "404": {
            "description": "Unknown satellite, outside its footprint, or frame not available"
          }
        },
        "summary": "Satellite tile (legacy .jpg path)",
        "description": "Same tile as the `.webp` path, kept for older clients; the body is the stored image (WebP for current frames)."
      }
    },
    "/api/storms/active": {
      "get": {
        "tags": [
          "Storms"
        ],
        "operationId": "Storms_GetActive",
        "parameters": [
          {
            "name": "basin",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Restrict to one basin, e.g. `wp` (West Pacific)."
          },
          {
            "name": "refresh",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Bypass the server's ATCF caches before answering (`1`, `true` or `yes`). Honoured at most once every 5 minutes across all clients; otherwise the cached data is served. The response header `x-storm-refresh` says `applied` or `throttled`. Leave unset for normal polling."
          },
          {
            "name": "force",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Alias of `refresh`."
          },
          {
            "name": "maxAgeHours",
            "in": "query",
            "schema": {
              "type": "integer"
            },
            "description": "Drop systems whose latest fix is older than this many hours."
          },
          {
            "name": "includeRecent",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Include recently-ended systems (`1`, `true` or `yes`)."
          },
          {
            "name": "recent",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Alias of `includeRecent`."
          }
        ],
        "responses": {
          "200": {
            "description": "Active storms",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StormList"
                }
              }
            }
          },
          "502": {
            "description": "Upstream ATCF source unavailable",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "Currently active systems only",
        "description": "Storms with a recent fix, from the NOAA/ATCF source (`x-storm-source: noaa-atcf`). Not cacheable (`Cache-Control: no-store`): the position data changes with every bulletin."
      }
    },
    "/api/storms": {
      "get": {
        "tags": [
          "Storms"
        ],
        "operationId": "Storms_GetAll",
        "responses": {
          "200": {
            "description": "Storm list",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StormList"
                }
              }
            }
          }
        },
        "summary": "List every tracked tropical cyclone (summary)",
        "description": "Compact `{ id, name, title, active }` summary of the storms currently active, from the NOAA/ATCF source (`x-storm-source: noaa-atcf`) — the same set as `/api/storms/active` with default parameters. Ids look like `wp252026` (basin, number, year). The response header `x-storm-fetched-at` carries the time the newest record was built from the source. Poll no more often than every 60–120 seconds. Filtering (`basin`, `maxAgeHours`, `includeRecent`) and the storm geometry live on `/api/storms/active`."
      }
    },
    "/api/storms/latest": {
      "get": {
        "tags": [
          "Storms"
        ],
        "operationId": "Storms_GetLatest",
        "responses": {
          "200": {
            "description": "Storm list",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StormList"
                }
              }
            }
          }
        },
        "summary": "Most recent storm snapshot",
        "description": "Same storms as `/api/storms`, each as the full record (including `track`) used by `/api/storms/active`, without the combined GeoJSON. Headers as for `/api/storms`."
      }
    },
    "/api/storms/{id}": {
      "get": {
        "tags": [
          "Storms"
        ],
        "operationId": "Storms_GetById",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Storm id as listed by `/api/storms` or `/api/storms/active`, e.g. `wp252026`."
          },
          {
            "name": "refresh",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Bypass the upstream caches first (`1`, `true` or `yes`). Shares the `/api/storms/active` limit of one forced refresh every 5 minutes across all clients; the response header `x-storm-refresh` says `applied` or `throttled`."
          },
          {
            "name": "force",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Alias of `refresh`."
          }
        ],
        "responses": {
          "200": {
            "description": "`{ source, product, storm, geojson }` — the storm record (including track points) and its GeoJSON (track, forecast cone)"
          },
          "400": {
            "description": "Malformed storm id",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Unknown storm id",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "Full record for one storm"
      }
    },
    "/api/storms/{id}/track": {
      "get": {
        "tags": [
          "Storms"
        ],
        "operationId": "Storms_GetTrack",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "wp252026"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "GeoJSON FeatureCollection",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StormTrack"
                }
              }
            }
          },
          "400": {
            "description": "Malformed storm id",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Unknown storm",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "Storm track as GeoJSON",
        "description": "The track of `/api/storms/{id}` as GeoJSON points with `{ date, wind, pressure, forecast, code }` (wind in knots, pressure in hPa). `forecast` is false for the observed track and true for the forecast track — render them as separate styles."
      }
    },
    "/api/storm-tc/centers": {
      "get": {
        "tags": [
          "Storm TC"
        ],
        "operationId": "StormTc_GetCenters",
        "responses": {
          "200": {
            "description": "Centres",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StormTcCenters"
                }
              }
            }
          }
        },
        "summary": "Forecast centres",
        "description": "The centres that issue bulletins. Use `code` (e.g. `jma`, `vnhma`) as the `center` parameter; `center_cid` is an internal id that may change upstream. Cacheable for 5 minutes."
      }
    },
    "/api/storm-tc/active": {
      "get": {
        "tags": [
          "Storm TC"
        ],
        "operationId": "StormTc_GetActive",
        "responses": {
          "200": {
            "description": "Active storms",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StormTcActive"
                }
              }
            }
          }
        },
        "summary": "Storms with recent official bulletins",
        "description": "Official tropical-cyclone forecasts from nine meteorological centres including VNHMA (Vietnam). This is a different source from `/api/storms/*` (public ATCF data); the two are not merged — use `/api/storms/unified` to see both side by side. Identify storms by `wps_id` (e.g. `WP2644`), never by name: centres name the same storm differently and names change. `names[]` lists what each centre calls the storm so you can choose which to display."
      }
    },
    "/api/storm-tc/{wpsId}/forecast": {
      "get": {
        "tags": [
          "Storm TC"
        ],
        "operationId": "StormTc_GetForecast",
        "parameters": [
          {
            "name": "wpsId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Storm id, e.g. `WP2644`."
          },
          {
            "name": "center",
            "in": "query",
            "schema": {
              "type": "string",
              "example": "jma"
            },
            "description": "Restrict to one centre by `code` from `/api/storm-tc/centers`."
          }
        ],
        "responses": {
          "200": {
            "description": "Forecast points grouped by centre",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StormTcPoints"
                }
              }
            }
          },
          "404": {
            "description": "No forecast for that wpsId",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "Latest bulletin from each centre",
        "description": "Official tropical-cyclone forecasts from nine meteorological centres including VNHMA (Vietnam). This is a different source from `/api/storms/*` (public ATCF data); the two are not merged — use `/api/storms/unified` to see both side by side. Identify storms by `wps_id` (e.g. `WP2644`), never by name: centres name the same storm differently and names change. Returns only the latest bulletin per centre — the set to draw on a map. `forecast_hour = 0` is the current position at bulletin time, not a forecast."
      }
    },
    "/api/storm-tc/{wpsId}/track": {
      "get": {
        "tags": [
          "Storm TC"
        ],
        "operationId": "StormTc_GetTrack",
        "parameters": [
          {
            "name": "wpsId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Storm id, e.g. `WP2644`."
          },
          {
            "name": "center",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Restrict to one centre by `code`."
          }
        ],
        "responses": {
          "200": {
            "description": "Track points grouped by centre",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StormTcPoints"
                }
              }
            }
          },
          "404": {
            "description": "No track for that wpsId",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "Every recorded point, all centres and bulletins",
        "description": "Official tropical-cyclone forecasts from nine meteorological centres including VNHMA (Vietnam). This is a different source from `/api/storms/*` (public ATCF data); the two are not merged — use `/api/storms/unified` to see both side by side. Identify storms by `wps_id` (e.g. `WP2644`), never by name: centres name the same storm differently and names change. Can run to thousands of points; past data never changes, so cache it aggressively (served with `Cache-Control: max-age=300`)."
      }
    },
    "/api/storms/unified": {
      "get": {
        "tags": [
          "Storms"
        ],
        "operationId": "StormUnified_GetUnified",
        "parameters": [
          {
            "name": "basin",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Basin filter, passed through to the ATCF source."
          },
          {
            "name": "maxAgeHours",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 36
            },
            "description": "How recent a fix must be to count as active (default 36)."
          },
          {
            "name": "includeRecent",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Include recently dissipated storms (`1` or `true`)."
          }
        ],
        "responses": {
          "200": {
            "description": "Unified storm list",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StormsUnified"
                }
              }
            }
          },
          "502": {
            "description": "One of the sources is unavailable",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "One storm, both sources side by side",
        "description": "Presents each storm with its ATCF record (`/api/storms/*`) and its official multi-centre forecasts (`/api/storm-tc/*`) in `sources[]`, without mixing numbers between them. Storms present in only one source are still listed. Records from the two sources are paired by space–time proximity (300 km / ±3 h by default), so every pair carries `match.confidence` and `match.method` — treat the pairing as NIMBUS's inference, not a statement from either source. Header `x-storm-sources` names the inputs."
      }
    },
    "/api/temperature/noaa-gfs/timeline": {
      "get": {
        "tags": [
          "Temperature"
        ],
        "operationId": "Temperature_GetTimeline",
        "parameters": [
          {
            "name": "run",
            "in": "query",
            "schema": {
              "type": "string",
              "pattern": "^\\d{8}(00|06|12|18)$"
            },
            "description": "UTC run YYYYMMDDHH, cycles 00/06/12/18."
          }
        ],
        "responses": {
          "200": {
            "description": "Timeline with metadata; cached 60 seconds.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WeatherTimeline"
                }
              }
            }
          },
          "400": {
            "description": "Invalid run, frame or XYZ coordinates."
          },
          "404": {
            "description": "Run/frame not held by the server (use a run and frame listed by the timeline — the server never fetches upstream on demand) or daily window incomplete."
          },
          "503": {
            "description": "Warming up or source/decode error. Not cached.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "string",
                  "example": "60"
                },
                "description": "Retry after 60 seconds."
              }
            }
          }
        },
        "summary": "Temperature available frames",
        "description": "TMP at 2 m minus 273.15; hourly f000..f168; degC. Global domain: lat -85 to 85, all longitudes (no antimeridian gap), 0.25°. Temperature tiles use bilinear source-grid interpolation. Omitting run selects the latest run with actual frames for this product; daily may lag hourly. Use current and frames[].tileUrl; do not synthesize frames. NOAA phát GFS hằng giờ tới f120, sau đó mỗi 3 giờ; NIMBUS phục vụ 7 ngày: f000-f120 hằng giờ rồi f123, f126 ... f168. f121/f122 không tồn tại và trả 400."
      }
    },
    "/api/temperature/noaa-gfs/tiles/webp/{run}/{frame}/{z}/{x}/{y}.webp": {
      "get": {
        "tags": [
          "Temperature"
        ],
        "operationId": "Temperature_GetTile",
        "parameters": [
          {
            "name": "run",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^\\d{8}(00|06|12|18)$"
            }
          },
          {
            "name": "frame",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^f\\d{1,3}$"
            },
            "description": "f0..f84 (canonical f000..f168)."
          },
          {
            "name": "z",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 12
            },
            "description": "Zoom 0..12."
          },
          {
            "name": "x",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0
            },
            "description": "Must be less than 2^z; no wrapping."
          },
          {
            "name": "y",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0
            },
            "description": "Must be less than 2^z; no wrapping."
          },
          {
            "in": "header",
            "description": "ETag revalidation",
            "schema": {
              "type": "string"
            },
            "required": false,
            "name": "If-None-Match"
          }
        ],
        "responses": {
          "200": {
            "description": "Encoded scalar data, not a display image.",
            "headers": {
              "x-weather-meta": {
                "description": "Base64url UTF-8 JSON, schema WeatherTileMetadata. Includes product encoding, legend, frame and coverage.",
                "schema": {
                  "type": "string"
                }
              },
              "ETag": {
                "schema": {
                  "type": "string"
                }
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "Complete input: public, max-age=86400. Partial input: no-store."
              }
            },
            "content": {
              "image/webp": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "304": {
            "description": "ETag unchanged.",
            "headers": {
              "x-weather-meta": {
                "description": "Base64url UTF-8 JSON, schema WeatherTileMetadata. Includes product encoding, legend, frame and coverage.",
                "schema": {
                  "type": "string"
                }
              },
              "ETag": {
                "schema": {
                  "type": "string"
                }
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "Complete input: public, max-age=86400. Partial input: no-store."
              }
            }
          },
          "400": {
            "description": "Invalid run, frame or XYZ coordinates."
          },
          "404": {
            "description": "Run/frame not held by the server (use a run and frame listed by the timeline — the server never fetches upstream on demand) or daily window incomplete."
          },
          "503": {
            "description": "Warming up or source/decode error. Not cached.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "string",
                  "example": "60"
                },
                "description": "Retry after 60 seconds."
              }
            }
          }
        },
        "summary": "Temperature encoded RG16 tile",
        "description": "TMP at 2 m minus 273.15; hourly f000..f168; degC. Global domain: lat -85 to 85, all longitudes (no antimeridian gap), 0.25°. Temperature tiles use bilinear source-grid interpolation. Lossless 256x256 WebP, value=(256*R+G)/100-100, B=class, A=0 missing/255 valid. Zero and negative values are valid. Outside the served latitude band (poleward of ±85°) returns transparent WebP. Decode bytes before filtering; derived categories use nearest interpolation. NOAA phát GFS hằng giờ tới f120, sau đó mỗi 3 giờ; NIMBUS phục vụ 7 ngày: f000-f120 hằng giờ rồi f123, f126 ... f168. f121/f122 không tồn tại và trả 400."
      }
    },
    "/api/thunderstorm/noaa-gfs/timeline": {
      "get": {
        "tags": [
          "Thunderstorm"
        ],
        "operationId": "Thunderstorm_GetTimeline",
        "parameters": [
          {
            "name": "run",
            "in": "query",
            "schema": {
              "type": "string",
              "pattern": "^\\d{8}(00|06|12|18)$"
            },
            "description": "UTC run YYYYMMDDHH, cycles 00/06/12/18."
          }
        ],
        "responses": {
          "200": {
            "description": "Timeline with metadata; cached 60 seconds.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WeatherTimeline"
                }
              }
            }
          },
          "400": {
            "description": "Invalid run, frame or XYZ coordinates."
          },
          "404": {
            "description": "Run/frame not held by the server (use a run and frame listed by the timeline — the server never fetches upstream on demand) or daily window incomplete."
          },
          "503": {
            "description": "Warming up or source/decode error. Not cached.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "string",
                  "example": "60"
                },
                "description": "Retry after 60 seconds."
              }
            }
          }
        },
        "summary": "Thunderstorm available frames",
        "description": "Hourly K-index: (T850-T500)+Td850-(T700-Td700), dewpoint from T/RH via Bolton. Surface pressure <=85000 Pa or invalid inputs are missing. Classes 0:<20, 1:20 to <25, 2:25 to <30, 3:30 to <35, 4:>=35. Environmental thunderstorm potential, not lightning probability/density. Omitting run selects the latest run with actual frames for this product; daily may lag hourly. Use current and frames[].tileUrl; do not synthesize frames."
      }
    },
    "/api/thunderstorm/noaa-gfs/tiles/webp/{run}/{frame}/{z}/{x}/{y}.webp": {
      "get": {
        "tags": [
          "Thunderstorm"
        ],
        "operationId": "Thunderstorm_GetTile",
        "parameters": [
          {
            "name": "run",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^\\d{8}(00|06|12|18)$"
            }
          },
          {
            "name": "frame",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^f\\d{1,3}$"
            },
            "description": "f0..f84 (canonical f000..f084)."
          },
          {
            "name": "z",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 12
            },
            "description": "Zoom 0..12."
          },
          {
            "name": "x",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0
            },
            "description": "Must be less than 2^z; no wrapping."
          },
          {
            "name": "y",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0
            },
            "description": "Must be less than 2^z; no wrapping."
          },
          {
            "in": "header",
            "description": "ETag revalidation",
            "schema": {
              "type": "string"
            },
            "required": false,
            "name": "If-None-Match"
          }
        ],
        "responses": {
          "200": {
            "description": "Encoded scalar data, not a display image.",
            "headers": {
              "x-weather-meta": {
                "description": "Base64url UTF-8 JSON, schema WeatherTileMetadata. Includes product encoding, legend, frame and coverage.",
                "schema": {
                  "type": "string"
                }
              },
              "ETag": {
                "schema": {
                  "type": "string"
                }
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "Complete input: public, max-age=86400. Partial input: no-store."
              }
            },
            "content": {
              "image/webp": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "304": {
            "description": "ETag unchanged.",
            "headers": {
              "x-weather-meta": {
                "description": "Base64url UTF-8 JSON, schema WeatherTileMetadata. Includes product encoding, legend, frame and coverage.",
                "schema": {
                  "type": "string"
                }
              },
              "ETag": {
                "schema": {
                  "type": "string"
                }
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "Complete input: public, max-age=86400. Partial input: no-store."
              }
            }
          },
          "400": {
            "description": "Invalid run, frame or XYZ coordinates."
          },
          "404": {
            "description": "Run/frame not held by the server (use a run and frame listed by the timeline — the server never fetches upstream on demand) or daily window incomplete."
          },
          "503": {
            "description": "Warming up or source/decode error. Not cached.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "string",
                  "example": "60"
                },
                "description": "Retry after 60 seconds."
              }
            }
          }
        },
        "summary": "Thunderstorm encoded RG16 tile",
        "description": "Hourly K-index: (T850-T500)+Td850-(T700-Td700), dewpoint from T/RH via Bolton. Surface pressure <=85000 Pa or invalid inputs are missing. Classes 0:<20, 1:20 to <25, 2:25 to <30, 3:30 to <35, 4:>=35. Environmental thunderstorm potential, not lightning probability/density. Lossless 256x256 WebP, value=(256*R+G)/100-100, B=class, A=0 missing/255 valid. Zero and negative values are valid. Outside bbox returns transparent WebP. Decode bytes before filtering; derived categories use nearest interpolation."
      }
    },
    "/api/wind/noaa-gfs/timeline": {
      "get": {
        "tags": [
          "Wind"
        ],
        "operationId": "Wind_GetTimeline",
        "parameters": [
          {
            "name": "run",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Run key, yyyyMMddHH. Defaults to the run the service is serving. Only runs kept on the server are served — the run the timeline serves, the current cycle or the one before it: any other run returns 404, a malformed key 400."
          }
        ],
        "responses": {
          "200": {
            "description": "Wind timeline",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WindTimeline"
                }
              }
            }
          },
          "400": {
            "description": "Malformed `run` (expected yyyyMMddHH)"
          },
          "404": {
            "description": "`run` is not kept on the server (see the `run` parameter)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "Available GFS wind frames",
        "description": "Frames of the newest fully prepared GFS run, f000 to f168 (7 days): hourly to f120, then every 3 hours (f123, f126 … f168) because NOAA does not publish the hours in between. `current` names the frame the service considers live. Drive playback from `frames`, never from a +1 hour loop. The run rolls over every 6 hours and the service switches to a new run only once all of its frames are ready (a few minutes after the cycle is due, later if NOAA publishes late) and only runs kept on the server have tiles, so a long-lived client should refetch the timeline about every 10 minutes (and when a hidden tab becomes visible again)."
      }
    },
    "/api/wind/noaa-gfs/tiles/webp/10m/{date}/{cycle}/{fhSegment}/{z}/{x}/{filename}": {
      "get": {
        "tags": [
          "Wind"
        ],
        "operationId": "Wind_GetWindTile",
        "parameters": [
          {
            "name": "date",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "20260904"
            },
            "description": "Run date, yyyyMMdd. Together with `cycle` it must name a run the timeline lists — one of the runs kept on the server — the run the timeline serves, the current cycle or the one before it; any other run returns 404."
          },
          {
            "name": "cycle",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "0000"
            },
            "description": "Run cycle `00`, `06`, `12` or `18` (`0000` form accepted)."
          },
          {
            "name": "fhSegment",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Forecast hour segment `f{HHH}` (three digits), for example `f012` for the 12 h step. Must be a frame listed by the timeline: f000–f120 hourly, then every 3 hours to f168. Other hours (f121, f122, negative, past f168) return 400."
          },
          {
            "name": "z",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "Zoom level, 0–9. Anything else returns 400."
          },
          {
            "name": "x",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "Tile column, 0 to 2^z − 1 (wrap it client-side across the antimeridian). Out of range returns 400."
          },
          {
            "name": "filename",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Tile row followed by the extension, i.e. `{y}.webp` — for example `14.webp`. The row must be 0 to 2^z − 1."
          }
        ],
        "responses": {
          "200": {
            "description": "Encoded tile",
            "content": {
              "image/webp": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "304": {
            "description": "Not modified — `If-None-Match` matches the tile `ETag`. Only for tiles rendered from the requested run (`Cache-Control: immutable`); fallback tiles from an older run always return 200."
          },
          "404": {
            "description": "Run not served (only runs kept on the server are), or frame unavailable",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "400": {
            "description": "Malformed segment, tile outside the XYZ grid or above zoom 9, or a forecast hour NIMBUS does not serve (f121, f122, negative, past f168)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "10 m wind vector tile, v1 ±32 m/s (deprecated)",
        "deprecated": true,
        "description": "**Deprecated — use `/tiles/webp-v2/...`.** Same path and parameters, but values are clipped: `u = (R/255*2-1)*32`, `v = (G/255*2-1)*32`, `speed = B/255*36`, all m/s, so winds above 32 m/s (Beaufort 12 and stronger) saturate. Kept byte-for-byte for existing clients; responses carry `Deprecation` and a `Link: <…webp-v2…>; rel=\"successor-version\"` header. Zoom up to 9. Alpha is unused, so these tiles are opaque everywhere and carry no coverage mask."
      }
    },
    "/api/wind/noaa-gfs/tiles/webp-v2/10m/{date}/{cycle}/{fhSegment}/{z}/{x}/{filename}": {
      "get": {
        "tags": [
          "Wind"
        ],
        "operationId": "Wind_GetWindTileV2",
        "parameters": [
          {
            "name": "date",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "20260904"
            },
            "description": "Run date, yyyyMMdd. Together with `cycle` it must name a run the timeline lists — one of the runs kept on the server — the run the timeline serves, the current cycle or the one before it; any other run returns 404."
          },
          {
            "name": "cycle",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "example": "0000"
            },
            "description": "Run cycle `00`, `06`, `12` or `18` (`0000` form accepted)."
          },
          {
            "name": "fhSegment",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Forecast hour segment `f{HHH}` (three digits), for example `f012` for the 12 h step. Must be a frame listed by the timeline: f000–f120 hourly, then every 3 hours to f168. Other hours (f121, f122, negative, past f168) return 400."
          },
          {
            "name": "z",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "Zoom level, 0–9. Anything else returns 400."
          },
          {
            "name": "x",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "Tile column, 0 to 2^z − 1 (wrap it client-side across the antimeridian). Out of range returns 400."
          },
          {
            "name": "filename",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Tile row followed by the extension, i.e. `{y}.webp` — for example `14.webp`. The row must be 0 to 2^z − 1."
          }
        ],
        "responses": {
          "200": {
            "description": "Encoded tile",
            "content": {
              "image/webp": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "304": {
            "description": "Not modified — `If-None-Match` matches the tile `ETag`. Only for tiles rendered from the requested run (`Cache-Control: immutable`); fallback tiles from an older run always return 200."
          },
          "404": {
            "description": "Run not served (only runs kept on the server are), or frame unavailable",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "400": {
            "description": "Malformed segment, tile outside the XYZ grid or above zoom 9, or a forecast hour NIMBUS does not serve (f121, f122, negative, past f168)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "summary": "10 m wind vector tile (encoded WebP, ±64 m/s)",
        "description": "Zoom up to 9. RGB encodes the vector field with a ±64 m/s range: `u = (R/255*2-1)*64`, `v = (G/255*2-1)*64`, `speed = B/255*64`, all m/s — enough for the core of a super typhoon (Beaufort 17). Quantisation step is 0.5 m/s for u and v, 0.25 m/s for speed. Alpha is unused, so these tiles are opaque everywhere and carry no coverage mask. The `x-wind-meta` header repeats the ranges in `encoding.vectorRange` / `encoding.speedRange` (`encoding.version` = `wind-rgb8-v2`)."
      }
    }
  },
  "components": {
    "schemas": {
      "Error": {
        "type": "object",
        "properties": {
          "status": {
            "type": "string",
            "example": "error"
          },
          "message": {
            "type": "string"
          }
        }
      },
      "TimelineFrame": {
        "type": "object",
        "properties": {
          "timestamp": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-04T04:10:00Z"
          },
          "date": {
            "type": "string",
            "example": "2026-09-04"
          },
          "hour": {
            "type": "string",
            "example": "04"
          },
          "minute": {
            "type": "string",
            "example": "10"
          },
          "available": {
            "type": "boolean",
            "description": "False when the slot exists but the image has not been produced yet."
          }
        }
      },
      "RadarTimeline": {
        "type": "object",
        "properties": {
          "source": {
            "type": "string",
            "example": "weatherplus-radar"
          },
          "layer": {
            "type": "string",
            "example": "radar"
          },
          "resolution": {
            "type": "string",
            "example": "1km"
          },
          "unit": {
            "type": "string",
            "example": "mm/h"
          },
          "intervalMinutes": {
            "type": "integer",
            "example": 10
          },
          "stale": {
            "type": "boolean",
            "example": false,
            "description": "True when the upstream data service did not answer: `frames` then lists only the frames this service already holds and can still serve, so the newest frames may be missing. Keep showing them and tell the user the data is not current."
          },
          "frames": {
            "type": "array",
            "description": "Observed frames, oldest first; with `nowcast=true`, followed by the forecast frames (`nowcast: true`) that lie after `latest`.",
            "items": {
              "$ref": "#/components/schemas/RadarTimelineFrame"
            }
          },
          "latest": {
            "description": "Newest OBSERVED frame — never a nowcast frame. Use it as \"now\" and as the initial selection.",
            "allOf": [
              {
                "$ref": "#/components/schemas/TimelineFrame"
              }
            ]
          },
          "nowcast": {
            "type": "object",
            "nullable": true,
            "description": "Present when `nowcast=true` was asked and forecast frames follow `latest`; null otherwise.",
            "properties": {
              "run": {
                "type": "string",
                "format": "date-time",
                "description": "Observed frame the nowcast was extrapolated from. Usually `latest.timestamp`; up to 20 minutes older for the few seconds a nowcast from a new frame is being built — only its frames after `latest` are listed then."
              },
              "leadMinutes": {
                "type": "integer",
                "example": 60
              },
              "stepMinutes": {
                "type": "integer",
                "example": 10
              },
              "method": {
                "type": "string",
                "example": "lagrangian-persistence",
                "description": "Rain is advected along the motion measured from the last three frames; intensity is kept, so growth and decay are not forecast."
              }
            }
          }
        }
      },
      "RadarTimelineFrame": {
        "allOf": [
          {
            "$ref": "#/components/schemas/TimelineFrame"
          },
          {
            "type": "object",
            "properties": {
              "nowcast": {
                "type": "boolean",
                "description": "True for a forecast frame after the newest observation. Absent on observed frames."
              },
              "nowcastRun": {
                "type": "string",
                "format": "date-time",
                "description": "Nowcast frames only. Append it to tile URLs as `?run=` — the content of a nowcast slot changes every 10 minutes."
              }
            }
          }
        ]
      },
      "RadarMotion": {
        "type": "object",
        "description": "Rain motion between two consecutive frames, sampled on a regular grid. Point (i, j) sits at (lat0 + i*dLat, lon0 + j*dLon); row 0 is the southern edge; value k = i*cols + j.",
        "properties": {
          "from": {
            "type": "string",
            "format": "date-time"
          },
          "to": {
            "type": "string",
            "format": "date-time",
            "description": "`from` + `stepMinutes`."
          },
          "stepMinutes": {
            "type": "integer",
            "example": 10
          },
          "nowcast": {
            "type": "boolean",
            "description": "True when the pair belongs to the nowcast (the motion the nowcast was extrapolated with)."
          },
          "measuredBlocks": {
            "type": "integer",
            "description": "Blocks with enough echo to measure; 0 means no rain and a zero field."
          },
          "rows": {
            "type": "integer",
            "example": 40
          },
          "cols": {
            "type": "integer",
            "example": 40
          },
          "lat0": {
            "type": "number"
          },
          "lon0": {
            "type": "number"
          },
          "dLat": {
            "type": "number"
          },
          "dLon": {
            "type": "number"
          },
          "u": {
            "type": "array",
            "description": "Eastward displacement per step, degrees of longitude.",
            "items": {
              "type": "number"
            }
          },
          "v": {
            "type": "array",
            "description": "Northward displacement per step, degrees of latitude.",
            "items": {
              "type": "number"
            }
          }
        }
      },
      "RainfallTimeline": {
        "type": "object",
        "properties": {
          "source": {
            "type": "string",
            "example": "weatherplus-rainfall"
          },
          "layer": {
            "type": "string",
            "example": "rainfall"
          },
          "unit": {
            "type": "string",
            "example": "mm"
          },
          "intervalHours": {
            "type": "integer",
            "example": 1
          },
          "stale": {
            "type": "boolean",
            "example": false,
            "description": "True when the upstream data service did not answer: `frames` then lists only the frames this service already holds and can still serve, so the newest frames may be missing. Keep showing them and tell the user the data is not current."
          },
          "frames": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TimelineFrame"
            }
          },
          "latest": {
            "$ref": "#/components/schemas/TimelineFrame"
          }
        }
      },
      "PointValue": {
        "type": "object",
        "properties": {
          "status": {
            "type": "string",
            "example": "success"
          },
          "data": {
            "type": "object",
            "properties": {
              "lat": {
                "type": "number",
                "format": "double",
                "example": 21.02
              },
              "lon": {
                "type": "number",
                "format": "double",
                "example": 105.83
              },
              "timestamp": {
                "type": "string",
                "format": "date-time"
              },
              "value": {
                "type": "number",
                "format": "float",
                "description": "Null-equivalent values are returned as 0 where the grid has no coverage."
              },
              "unit": {
                "type": "string",
                "example": "mm/h"
              },
              "nowcast": {
                "type": "boolean",
                "description": "Radar only: true when the value comes from the nowcast (asked with `run`, or no observation exists yet for `timestamp`)."
              }
            }
          }
        }
      },
      "ModelList": {
        "type": "object",
        "properties": {
          "source": {
            "type": "string",
            "example": "weatherplus-nwp"
          },
          "layer": {
            "type": "string",
            "example": "forecast-model"
          },
          "models": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "slug": {
                  "type": "string",
                  "example": "gfs"
                },
                "key": {
                  "type": "string",
                  "example": "GFS"
                },
                "displayName": {
                  "type": "string",
                  "example": "GFS (Global Forecast System)"
                },
                "unit": {
                  "type": "string",
                  "example": "mm"
                },
                "resolution": {
                  "type": "string",
                  "example": "5 km"
                },
                "intervalHours": {
                  "type": "integer",
                  "example": 1
                }
              }
            }
          }
        }
      },
      "ModelRuns": {
        "type": "object",
        "properties": {
          "source": {
            "type": "string"
          },
          "layer": {
            "type": "string"
          },
          "model": {
            "type": "string",
            "example": "gfs"
          },
          "runs": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "run": {
                  "type": "string",
                  "description": "Run key, yyyyMMddHH.",
                  "example": "2026090400"
                },
                "timestamp": {
                  "type": "string",
                  "format": "date-time"
                }
              }
            }
          }
        }
      },
      "ModelTimeline": {
        "type": "object",
        "properties": {
          "source": {
            "type": "string"
          },
          "layer": {
            "type": "string"
          },
          "model": {
            "type": "string",
            "example": "gfs"
          },
          "displayName": {
            "type": "string"
          },
          "resolution": {
            "type": "string",
            "example": "5km"
          },
          "unit": {
            "type": "string",
            "example": "mm"
          },
          "intervalHours": {
            "type": "integer",
            "example": 1
          },
          "run": {
            "type": "string",
            "example": "2026090300"
          },
          "date": {
            "type": "string",
            "example": "20260903"
          },
          "cycle": {
            "type": "string",
            "example": "0000"
          },
          "runTimestamp": {
            "type": "string",
            "format": "date-time"
          },
          "totalFrames": {
            "type": "integer",
            "example": 84
          },
          "frames": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "run": {
                  "type": "string"
                },
                "date": {
                  "type": "string"
                },
                "cycle": {
                  "type": "string"
                },
                "forecastHour": {
                  "type": "integer"
                },
                "validTime": {
                  "type": "string",
                  "format": "date-time"
                }
              }
            }
          }
        }
      },
      "SatelliteTimeline": {
        "type": "object",
        "properties": {
          "source": {
            "type": "string",
            "example": "self-processed"
          },
          "satellite": {
            "type": "string",
            "example": "himawari"
          },
          "displayName": {
            "type": "string",
            "example": "Himawari-9"
          },
          "attribution": {
            "type": "string",
            "example": "Himawari-9 TCR © JMA/NOAA/NESDIS · CSU/CIRA"
          },
          "product": {
            "type": "string",
            "example": "geocolor"
          },
          "tileFormat": {
            "type": "string",
            "example": "webp"
          },
          "intervalMinutes": {
            "type": "integer",
            "example": 10
          },
          "validMinutes": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "example": [
              0,
              10,
              20,
              30,
              40,
              50
            ]
          },
          "bounds": {
            "type": "array",
            "description": "[west, south, east, north]; east above 180 wraps past the antimeridian.",
            "items": {
              "type": "number"
            },
            "example": [
              84.375,
              -66.51326,
              185.625,
              66.51326
            ]
          },
          "minzoom": {
            "type": "integer",
            "example": 3
          },
          "maxzoom": {
            "type": "integer",
            "example": 6
          },
          "tileTemplate": {
            "type": "string",
            "example": "/api/satellite/himawari/{yyyy}/{MM}/{dd}/{HH}/{mm}/{z}/{x}/{y}.webp"
          },
          "frames": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TimelineFrame"
            }
          },
          "latest": {
            "$ref": "#/components/schemas/TimelineFrame"
          }
        }
      },
      "SatelliteMergedTimeline": {
        "type": "object",
        "description": "Merged 10-minute grid over all satellites. Each frame's `sources` maps a satellite slug to the image slot to draw for it (ISO UTC), or null when that satellite has no image close enough.",
        "properties": {
          "primary": {
            "type": "string",
            "example": "himawari"
          },
          "attribution": {
            "type": "string"
          },
          "tileTemplate": {
            "type": "string",
            "example": "/api/satellite/{satellite}/{yyyy}/{MM}/{dd}/{HH}/{mm}/{z}/{x}/{y}.webp"
          },
          "satellites": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SatelliteTimeline"
            }
          },
          "frames": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TimelineFrame"
            }
          },
          "latest": {
            "$ref": "#/components/schemas/TimelineFrame"
          },
          "degraded": {
            "type": "boolean",
            "description": "True when the primary satellite (Himawari-9) has no frame in the window; frames then come from the other satellites."
          }
        }
      },
      "WindTimeline": {
        "type": "object",
        "properties": {
          "source": {
            "type": "string",
            "example": "noaa-gfs"
          },
          "model": {
            "type": "string",
            "example": "gfs-0p25"
          },
          "height": {
            "type": "string",
            "example": "10m"
          },
          "frames": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "run": {
                  "type": "string",
                  "example": "2026090400"
                },
                "date": {
                  "type": "string",
                  "example": "20260904"
                },
                "cycle": {
                  "type": "string",
                  "example": "0000"
                },
                "forecastHour": {
                  "type": "integer",
                  "example": 3
                },
                "validTime": {
                  "type": "string",
                  "format": "date-time"
                }
              }
            }
          },
          "current": {
            "type": "object",
            "properties": {
              "run": {
                "type": "string"
              },
              "forecastHour": {
                "type": "integer"
              }
            }
          }
        }
      },
      "StormList": {
        "type": "object",
        "properties": {
          "count": {
            "type": "integer",
            "example": 6
          },
          "storms": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "example": "wp252026"
                },
                "name": {
                  "type": "string",
                  "example": "SURIGAE"
                },
                "title": {
                  "type": "string",
                  "example": "Tropical Storm SURIGAE"
                },
                "active": {
                  "type": "boolean"
                }
              }
            }
          }
        }
      },
      "StormTrack": {
        "type": "object",
        "description": "GeoJSON FeatureCollection. Each point feature carries a `forecast` flag separating the observed track from the forecast track.",
        "properties": {
          "type": {
            "type": "string",
            "example": "FeatureCollection"
          },
          "features": {
            "type": "array",
            "items": {
              "type": "object"
            }
          }
        }
      },
      "Health": {
        "type": "object",
        "properties": {
          "status": {
            "type": "string",
            "example": "ok"
          },
          "service": {
            "type": "string",
            "example": "nimbus"
          },
          "layer": {
            "type": "integer",
            "example": 3
          },
          "version": {
            "type": "string",
            "example": "0.1.0-layer3-nimbus"
          },
          "server_time_utc": {
            "type": "string",
            "format": "date-time"
          },
          "satellite_services": {
            "type": "integer"
          },
          "platform_configured": {
            "type": "boolean"
          }
        }
      },
      "WeatherDomain": {
        "type": "object",
        "properties": {
          "latMin": {
            "type": "number",
            "example": 6
          },
          "latMax": {
            "type": "number",
            "example": 26
          },
          "lonMin": {
            "type": "number",
            "example": 100
          },
          "lonMax": {
            "type": "number",
            "example": 112
          },
          "step": {
            "type": "number",
            "example": 0.25
          },
          "rows": {
            "type": "number",
            "example": 81
          },
          "columns": {
            "type": "number",
            "example": 49
          }
        },
        "required": [
          "latMin",
          "latMax",
          "lonMin",
          "lonMax",
          "step",
          "rows",
          "columns"
        ]
      },
      "WeatherEncoding": {
        "type": "object",
        "properties": {
          "version": {
            "type": "string",
            "enum": [
              "scalar-rg16-v1"
            ]
          },
          "type": {
            "type": "string",
            "enum": [
              "webp-rg16"
            ]
          },
          "formula": {
            "type": "string",
            "example": "value=(256*R+G)/100-100; B=class; A=0 missing,255 valid"
          },
          "offset": {
            "type": "number",
            "enum": [
              -100
            ]
          },
          "scale": {
            "type": "number",
            "enum": [
              0.01
            ]
          },
          "interpolation": {
            "type": "string",
            "enum": [
              "bilinear",
              "nearest"
            ]
          }
        },
        "required": [
          "version",
          "type",
          "formula",
          "offset",
          "scale",
          "interpolation"
        ]
      },
      "WeatherFrame": {
        "type": "object",
        "properties": {
          "run": {
            "type": "string",
            "pattern": "^\\d{8}(00|06|12|18)$",
            "example": "2026090700"
          },
          "frame": {
            "type": "string",
            "description": "Hourly f000..f084; daily yyyyMMdd in UTC+7.",
            "example": "f000"
          },
          "forecastHour": {
            "type": "integer",
            "minimum": 0,
            "maximum": 84,
            "nullable": true,
            "description": "Hourly only; omitted for daily frames."
          },
          "validTime": {
            "type": "string",
            "format": "date-time",
            "description": "Instantaneous valid time, or start of a daily window, in UTC."
          },
          "localDate": {
            "type": "string",
            "format": "date",
            "nullable": true,
            "description": "Daily only, Vietnam calendar date."
          },
          "timezone": {
            "type": "string",
            "enum": [
              "Asia/Ho_Chi_Minh"
            ]
          },
          "windowStart": {
            "type": "string",
            "format": "date-time"
          },
          "windowEnd": {
            "type": "string",
            "format": "date-time",
            "description": "Exclusive for daily frames. Equal to windowStart for instantaneous frames."
          },
          "sampleCount": {
            "type": "integer",
            "enum": [
              1,
              24
            ]
          },
          "aggregation": {
            "type": "string",
            "enum": [
              "instantaneous",
              "hourly_sample_max",
              "hourly_sample_mean"
            ]
          },
          "tileUrl": {
            "type": "string",
            "example": "/api/temperature/noaa-gfs/tiles/webp/2026090700/f000/{z}/{x}/{y}.webp"
          }
        },
        "required": [
          "run",
          "frame",
          "validTime",
          "timezone",
          "windowStart",
          "windowEnd",
          "sampleCount",
          "aggregation",
          "tileUrl"
        ]
      },
      "WeatherLegendEntry": {
        "type": "object",
        "properties": {
          "code": {
            "type": "integer"
          },
          "label": {
            "type": "string"
          },
          "interval": {
            "type": "string"
          }
        },
        "required": [
          "code",
          "label",
          "interval"
        ]
      },
      "WeatherTimeline": {
        "type": "object",
        "properties": {
          "source": {
            "type": "string",
            "enum": [
              "noaa-gfs"
            ]
          },
          "model": {
            "type": "string",
            "enum": [
              "gfs-0p25"
            ]
          },
          "layer": {
            "type": "string",
            "enum": [
              "temperature",
              "thunderstorm",
              "heat",
              "cold"
            ]
          },
          "run": {
            "type": "string"
          },
          "temporalResolution": {
            "type": "string",
            "enum": [
              "hourly",
              "daily"
            ]
          },
          "algorithmVersion": {
            "type": "string",
            "enum": [
              "gfs-vn-v1"
            ]
          },
          "unit": {
            "type": "string",
            "enum": [
              "degC",
              "K-index"
            ]
          },
          "method": {
            "type": "string"
          },
          "interpretation": {
            "type": "string"
          },
          "domain": {
            "$ref": "#/components/schemas/WeatherDomain"
          },
          "encoding": {
            "$ref": "#/components/schemas/WeatherEncoding"
          },
          "legend": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/WeatherLegendEntry"
            }
          },
          "frames": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/WeatherFrame"
            }
          },
          "current": {
            "allOf": [
              {
                "$ref": "#/components/schemas/WeatherFrame"
              }
            ],
            "nullable": true
          }
        },
        "required": [
          "source",
          "model",
          "layer",
          "run",
          "temporalResolution",
          "algorithmVersion",
          "unit",
          "method",
          "interpretation",
          "domain",
          "encoding",
          "legend",
          "frames",
          "current"
        ]
      },
      "WeatherTileMetadata": {
        "type": "object",
        "properties": {
          "product": {
            "$ref": "#/components/schemas/WeatherTimeline"
          },
          "frame": {
            "$ref": "#/components/schemas/WeatherFrame"
          },
          "z": {
            "type": "integer"
          },
          "x": {
            "type": "integer"
          },
          "y": {
            "type": "integer"
          },
          "tileSize": {
            "type": "integer",
            "enum": [
              256
            ]
          },
          "filledCount": {
            "type": "integer",
            "minimum": 0,
            "maximum": 65536
          },
          "filledRatio": {
            "type": "number",
            "minimum": 0,
            "maximum": 1
          }
        },
        "required": [
          "product",
          "frame",
          "z",
          "x",
          "y",
          "tileSize",
          "filledCount",
          "filledRatio"
        ],
        "description": "Base64url UTF-8 JSON in x-weather-meta. product.frames is empty; use the timeline endpoint for available frames."
      },
      "LightningStrike": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "Feature"
            ]
          },
          "geometry": {
            "type": "object",
            "properties": {
              "type": {
                "type": "string",
                "enum": [
                  "Point"
                ]
              },
              "coordinates": {
                "type": "array",
                "items": {
                  "type": "number"
                },
                "minItems": 2,
                "maxItems": 2,
                "description": "`[lon, lat]`"
              }
            }
          },
          "properties": {
            "type": "object",
            "properties": {
              "occurredAt": {
                "type": "string",
                "format": "date-time",
                "description": "UTC, precise to the second"
              },
              "nanosecond": {
                "type": "integer",
                "description": "Sub-second part, 0–999999999"
              },
              "peakCurrentKa": {
                "type": "number",
                "description": "Peak current in kA; negative means negative polarity (normal, not an error)"
              },
              "strokeType": {
                "type": "integer",
                "description": "0 = cloud-to-ground, 1 = intracloud"
              },
              "strokeTypeLabel": {
                "type": "string"
              },
              "multiplicity": {
                "type": "integer"
              },
              "sensorCount": {
                "type": "integer"
              },
              "semiMajorAxisKm": {
                "type": "number",
                "description": "Positional error ellipse, km"
              },
              "semiMinorAxisKm": {
                "type": "number"
              },
              "chiSquare": {
                "type": "number",
                "description": "Solution quality; lower is better"
              },
              "domain": {
                "type": "string"
              }
            }
          }
        }
      },
      "LightningStrikes": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "FeatureCollection"
            ]
          },
          "features": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/LightningStrike"
            }
          },
          "properties": {
            "type": "object",
            "properties": {
              "source": {
                "type": "string"
              },
              "layer": {
                "type": "string"
              },
              "from": {
                "type": "string",
                "format": "date-time"
              },
              "to": {
                "type": "string",
                "format": "date-time"
              },
              "count": {
                "type": "integer"
              },
              "limit": {
                "type": "integer"
              },
              "truncated": {
                "type": "boolean"
              },
              "bbox": {
                "type": "string",
                "nullable": true
              },
              "strokeType": {
                "type": "string",
                "nullable": true
              },
              "cacheWindowStart": {
                "type": "string",
                "format": "date-time"
              },
              "cacheWindowEnd": {
                "type": "string",
                "format": "date-time"
              }
            }
          }
        }
      },
      "LightningFreshness": {
        "type": "object",
        "properties": {
          "source": {
            "type": "string"
          },
          "latestStrikeAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "ageMinutes": {
            "type": "number",
            "description": "Minutes since the last strike — large during quiet weather"
          },
          "latestFileAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "fileAgeMinutes": {
            "type": "number",
            "description": "Collection health: above ~15 means the feed is behind"
          },
          "serverTime": {
            "type": "string",
            "format": "date-time"
          },
          "cache": {
            "type": "object",
            "properties": {
              "strikeCount": {
                "type": "integer"
              },
              "windowStart": {
                "type": "string",
                "format": "date-time"
              },
              "windowEnd": {
                "type": "string",
                "format": "date-time"
              },
              "retentionHours": {
                "type": "integer"
              },
              "lastSyncAt": {
                "type": "string",
                "format": "date-time"
              }
            }
          },
          "interpretation": {
            "type": "string"
          }
        }
      },
      "StormTcCenters": {
        "type": "object",
        "properties": {
          "source": {
            "type": "string"
          },
          "count": {
            "type": "integer"
          },
          "centers": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "center_cid": {
                  "type": "integer",
                  "description": "Internal id — do not hardcode"
                },
                "code": {
                  "type": "string",
                  "description": "Use as the `center` parameter"
                },
                "name": {
                  "type": "string"
                },
                "country": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "StormTcActive": {
        "type": "object",
        "properties": {
          "source": {
            "type": "string"
          },
          "count": {
            "type": "integer"
          },
          "storms": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "wps_id": {
                  "type": "string"
                },
                "names": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "centerCode": {
                        "type": "string"
                      },
                      "tcName": {
                        "type": "string"
                      }
                    }
                  }
                },
                "latestBulletinAt": {
                  "type": "string",
                  "format": "date-time"
                }
              }
            }
          }
        }
      },
      "StormTcPoint": {
        "type": "object",
        "properties": {
          "center_cid": {
            "type": "integer"
          },
          "wps_id": {
            "type": "string"
          },
          "tc_name": {
            "type": "string"
          },
          "tc_name_vn": {
            "type": "string",
            "nullable": true,
            "description": "Vietnamese bulletin title (VNHMA only), not a translation"
          },
          "bulletin_date": {
            "type": "string",
            "format": "date"
          },
          "bulletin_hhmm": {
            "type": "integer",
            "description": "Hour+minute without leading zeros: 600 = 06:00, 0 = 00:00"
          },
          "forecast_hour": {
            "type": "integer",
            "description": "0 = current position, >0 = forecast lead time in hours"
          },
          "longitude": {
            "type": "number"
          },
          "latitude": {
            "type": "number"
          },
          "min_pressure_hpa": {
            "type": "number",
            "nullable": true
          },
          "max_wind_kt": {
            "type": "number",
            "nullable": true
          },
          "gust_kt": {
            "type": "number",
            "nullable": true
          },
          "move_dir": {
            "type": "string",
            "nullable": true,
            "description": "e.g. `NNE`; `ALMOST` = nearly stationary; null = undetermined"
          },
          "move_speed_kt": {
            "type": "number",
            "nullable": true,
            "description": "Unit under confirmation with the source"
          },
          "radius_30kt_km": {
            "type": "number",
            "nullable": true
          },
          "radius_50kt_km": {
            "type": "number",
            "nullable": true
          },
          "position_error_km": {
            "type": "number",
            "nullable": true,
            "description": "Meaningful only at forecast_hour = 0"
          },
          "source_updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "StormTcPoints": {
        "type": "object",
        "properties": {
          "source": {
            "type": "string"
          },
          "product": {
            "type": "string",
            "enum": [
              "forecast",
              "track"
            ]
          },
          "wps_id": {
            "type": "string"
          },
          "centers": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "centerCid": {
                  "type": "integer"
                },
                "centerCode": {
                  "type": "string"
                },
                "tcName": {
                  "type": "string"
                },
                "points": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/StormTcPoint"
                  }
                }
              }
            }
          },
          "count": {
            "type": "integer"
          },
          "notes": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          }
        }
      },
      "StormsUnified": {
        "type": "object",
        "properties": {
          "sources": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "count": {
            "type": "integer"
          },
          "storms": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "sources": {
                  "type": "array",
                  "description": "One entry per source that knows this storm; numbers are never mixed between entries",
                  "items": {
                    "type": "object",
                    "additionalProperties": true
                  }
                },
                "match": {
                  "type": "object",
                  "nullable": true,
                  "description": "null when only one source has the storm",
                  "properties": {
                    "confidence": {
                      "type": "number",
                      "minimum": 0,
                      "maximum": 1
                    },
                    "method": {
                      "type": "string"
                    },
                    "distanceKm": {
                      "type": "number"
                    },
                    "timeDeltaHours": {
                      "type": "number"
                    }
                  }
                }
              }
            }
          }
        }
      },
      "ForecastGridSources": {
        "type": "object",
        "properties": {
          "maxBboxAreaDeg2": {
            "type": "number",
            "example": 60
          },
          "bboxFormat": {
            "type": "string",
            "example": "minLon,minLat,maxLon,maxLat"
          },
          "sources": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "source": {
                  "type": "string"
                },
                "displayName": {
                  "type": "string"
                },
                "provider": {
                  "type": "string",
                  "enum": [
                    "weatherplus-nwp",
                    "noaa-gfs"
                  ]
                },
                "unit": {
                  "type": "string"
                },
                "interpretation": {
                  "type": "string"
                },
                "domain": {
                  "type": "object",
                  "properties": {
                    "latMin": {
                      "type": "number"
                    },
                    "latMax": {
                      "type": "number"
                    },
                    "lonMin": {
                      "type": "number"
                    },
                    "lonMax": {
                      "type": "number"
                    },
                    "step": {
                      "type": "number"
                    }
                  }
                }
              }
            }
          }
        }
      },
      "ForecastGrid": {
        "type": "object",
        "properties": {
          "source": {
            "type": "string"
          },
          "displayName": {
            "type": "string"
          },
          "provider": {
            "type": "string"
          },
          "unit": {
            "type": "string"
          },
          "run": {
            "type": "string",
            "example": "2026091500"
          },
          "runTime": {
            "type": "string",
            "format": "date-time"
          },
          "bbox": {
            "type": "object",
            "properties": {
              "minLon": {
                "type": "number"
              },
              "minLat": {
                "type": "number"
              },
              "maxLon": {
                "type": "number"
              },
              "maxLat": {
                "type": "number"
              }
            }
          },
          "grid": {
            "type": "object",
            "properties": {
              "latMin": {
                "type": "number"
              },
              "latMax": {
                "type": "number"
              },
              "lonMin": {
                "type": "number"
              },
              "lonMax": {
                "type": "number"
              },
              "step": {
                "type": "number"
              },
              "rows": {
                "type": "integer"
              },
              "columns": {
                "type": "integer"
              },
              "order": {
                "type": "string"
              }
            }
          },
          "lats": {
            "type": "array",
            "items": {
              "type": "number"
            }
          },
          "lons": {
            "type": "array",
            "items": {
              "type": "number"
            }
          },
          "validTimes": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "date-time"
            }
          },
          "forecastHours": {
            "type": "array",
            "items": {
              "type": "integer"
            }
          },
          "values": {
            "type": "array",
            "description": "One array per step, rows × columns entries each; null = no data",
            "items": {
              "type": "array",
              "items": {
                "type": "number",
                "nullable": true
              }
            }
          }
        }
      },
      "ForecastGridPoint": {
        "type": "object",
        "properties": {
          "source": {
            "type": "string"
          },
          "displayName": {
            "type": "string"
          },
          "provider": {
            "type": "string"
          },
          "unit": {
            "type": "string"
          },
          "run": {
            "type": "string",
            "example": "2026092400"
          },
          "runTime": {
            "type": "string",
            "format": "date-time"
          },
          "point": {
            "type": "object",
            "properties": {
              "lat": {
                "type": "number"
              },
              "lon": {
                "type": "number"
              }
            },
            "description": "Coordinates as sent by the client"
          },
          "gridPoint": {
            "type": "object",
            "properties": {
              "lat": {
                "type": "number"
              },
              "lon": {
                "type": "number"
              }
            },
            "description": "Grid point the values were read from — the one nearest to `point`"
          },
          "gridStep": {
            "type": "number",
            "example": 0.25
          },
          "validTimes": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "date-time"
            }
          },
          "forecastHours": {
            "type": "array",
            "items": {
              "type": "integer"
            }
          },
          "stepHours": {
            "type": "array",
            "description": "Hours covered by each step, parallel to `forecastHours`",
            "items": {
              "type": "integer"
            }
          },
          "values": {
            "type": "array",
            "description": "One value per step, parallel to `validTimes`; null = no data",
            "items": {
              "type": "number",
              "nullable": true
            }
          }
        }
      }
    },
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "Optional. Not enforced on the public endpoints documented here."
      }
    }
  },
  "servers": [
    {
      "url": "https://resources.nimbus.vn",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "Radar",
      "description": "Weather radar composite, 1 km, 10-minute cadence"
    },
    {
      "name": "Rainfall",
      "description": "Analytical (observed) accumulated rainfall, hourly"
    },
    {
      "name": "Forecast model",
      "description": "Numerical weather prediction rainfall fields"
    },
    {
      "name": "Satellite",
      "description": "Geostationary satellite imagery (geocolor)"
    },
    {
      "name": "Wind",
      "description": "NOAA GFS 10 m wind vector field"
    },
    {
      "name": "Storms",
      "description": "Tropical cyclone tracks and forecasts"
    },
    {
      "name": "System",
      "description": "Service health"
    },
    {
      "name": "Temperature",
      "description": "Regional NOAA GFS 0.25 deg; 6-26 deg N, 100-112 deg E. TMP at 2 m minus 273.15; hourly f000..f084; degC. Temperature tiles use bilinear source-grid interpolation."
    },
    {
      "name": "Thunderstorm",
      "description": "Regional NOAA GFS 0.25 deg; 6-26 deg N, 100-112 deg E. Hourly K-index: (T850-T500)+Td850-(T700-Td700), dewpoint from T/RH via Bolton. Surface pressure <=85000 Pa or invalid inputs are missing. Classes 0:<20, 1:20 to <25, 2:25 to <30, 3:30 to <35, 4:>=35. Environmental thunderstorm potential, not lightning probability/density."
    },
    {
      "name": "Heat",
      "description": "Regional NOAA GFS 0.25 deg; 6-26 deg N, 100-112 deg E. Daily max of 24 hourly TMP2m samples (UTC+7), same run. Classes in degC: 0:<35, 1:35 to <37, 2:37 to <39, 3:>=39. Sampled maximum can miss between-hour peaks."
    },
    {
      "name": "Cold",
      "description": "Regional NOAA GFS 0.25 deg; 6-26 deg N, 100-112 deg E. Daily mean of 24 hourly TMP2m samples (UTC+7), same run. Classes in degC: 0:>15, 1:>13 to <=15, 2:<=13. Temperature thresholds only; not an official cold warning."
    },
    {
      "name": "Lightning",
      "description": "Lightning strike fixes as GeoJSON points (WeatherPlus network), last 12 hours"
    },
    {
      "name": "Storm TC",
      "description": "Official tropical-cyclone forecasts from nine centres including VNHMA — a separate source from ATCF `Storms`"
    },
    {
      "name": "Forecast grid",
      "description": "Raw numeric forecast values for every grid point inside a bbox (at most 60 deg², so Vietnam takes 4 requests), from the latest run, filtered by valid time. Sources: WeatherPlus NWP 5 km rainfall and NOAA GFS 0.25 deg temperature / precipitation."
    }
  ]
}
