{"openapi":"3.0.1","info":{"title":"radar-airspace-api","description":"Data-serving replacement for the legacy `geoweb-dev.atmosoar.io` install. The service exposes two families of endpoints:\n\n* **Third-party proxies** — server-side passthroughs to OpenWeatherMap, OpenAIP, and RainViewer. The upstream API key is held by the service (never returned to the client) and responses are cached in-process to absorb spikes.\n* **Curated layer endpoints** — Mapbox Vector Tiles and GeoJSON for H3-indexed datasets (GPSJam GPS interference, Kontur population density, NOAA OVATION aurora) backed by the project's PostgreSQL store.\n\n## Error contract\nAll errors follow the unified envelope from `atmosoar-api-libraries/httputils`. Errors always include `httpStatus`, a stable `code`, and a human `message`; field-level problems are listed under `errors[]`. Clients should branch on `code`, not on `message`. Codes you may encounter: `VALIDATION_ERROR`, `INVALID_LOCATION`, `INVALID_PARAMETER`, `MISSING_REQUIRED_FIELD`, `NOT_FOUND`, `INVALID_THRESHOLD`, `INTERNAL_ERROR`, `UPSTREAM_ERROR`, `DATA_SOURCE_UNAVAILABLE`.\n\n## Location parameter\nWhere an endpoint accepts `?location=`, the value is parsed by `atmosoar-api-libraries/location`. Forms accepted (per endpoint — see the individual operation for which subset is valid):\n\n| Form        | Example                      | Meaning                       |\n|-------------|------------------------------|-------------------------------|\n| point       | `52.52,13.40`                | lat,lon                       |\n| point+radius| `52.52,13.40\\|25`            | lat,lon with radius in km     |\n| rectangle   | `70,10\\|50,-10`              | NE\\|SW corners                |\n| bbox        | `bbox:-10,50,10,70`          | west,south,east,north         |\n\n## Authentication\nThe service trusts upstream auth claims forwarded by the gateway. No bearer token is required when calling the service directly from inside the cluster; when calling through `https://api.atmosoar.io`, the gateway enforces auth before the request reaches this service.\n\n## Rate limits \u0026 caching\nEach upstream proxy has its own TTL (see operation-level docs). Cached responses are served with the upstream's original `Content-Type`; cache hits are not currently signaled in headers.","contact":{"name":"Atmosoar Support","url":"https://atmosoar.io"},"version":"1.13.0","x-logo":{"url":"https://atmosoar.io/logo.png","backgroundColor":"#003b48","altText":"atmosoar"}},"paths":{"/radar-airspace/api/aip/tile/{layer}/{z}/{x}/{y}":{"get":{"tags":["openaip"],"summary":"OpenAIP raster tile","description":"Proxies OpenAIP aviation tiles. The service injects the API key server-side; the client never sees it. Cached for `cache.openaip_tile_ttl` (default **30 minutes**).\n\nAvailable layers:\n\n| Layer      | Meaning                                          |\n|------------|--------------------------------------------------|\n| `openaip`  | Standard airfield + airspace overlay             |\n| `hotspots` | Thermal / paragliding hotspots                   |\n\n### Happy path\n```bash\ncurl -s -o aip.png \\\n  \"https://api.atmosoar.io/radar-airspace/api/aip/tile/openaip/8/132/89\"\n```\n\n### Bad path — out-of-range zoom\n```bash\ncurl -s \"https://api.atmosoar.io/radar-airspace/api/aip/tile/openaip/30/0/0\"\n# =\u003e 400 INVALID_PARAMETER  (z must be 0..22)\n```","operationId":"openAIPTile","parameters":[{"name":"layer","in":"path","description":"OpenAIP layer identifier. One of: openaip (standard airfield + airspace overlay) or hotspots (thermal / paragliding hotspots).","required":true,"schema":{"type":"string","description":"OpenAIP layer identifier. One of: openaip (standard airfield + airspace overlay) or hotspots (thermal / paragliding hotspots).","example":"openaip"}},{"name":"x","in":"path","description":"Tile column index. Integer in [0, 2^z) — the valid range narrows with smaller z. Out-of-range values are rejected with 400 INVALID_PARAMETER.","required":true,"schema":{"type":"string","description":"Tile column index. Integer in [0, 2^z) — the valid range narrows with smaller z. Out-of-range values are rejected with 400 INVALID_PARAMETER.","example":"132"}},{"name":"y","in":"path","description":"Tile row index. Integer in [0, 2^z). A trailing .mvt or .png extension is tolerated for compatibility with map clients that always append it. Out-of-range values are rejected with 400 INVALID_PARAMETER.","required":true,"schema":{"type":"string","description":"Tile row index. Integer in [0, 2^z). A trailing .mvt or .png extension is tolerated for compatibility with map clients that always append it. Out-of-range values are rejected with 400 INVALID_PARAMETER.","example":"89"}},{"name":"z","in":"path","description":"Web-Mercator tile zoom level (XYZ scheme). Integer in [0, 22]. Out-of-range values are rejected with 400 INVALID_PARAMETER.","required":true,"schema":{"type":"string","description":"Web-Mercator tile zoom level (XYZ scheme). Integer in [0, 22]. Out-of-range values are rejected with 400 INVALID_PARAMETER.","example":"8"}}],"responses":{"200":{"description":"PNG raster tile."},"400":{"description":"Malformed parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"502":{"description":"Upstream data source temporarily unavailable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"503":{"description":"Upstream API key not configured (operator misconfiguration).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}}}}},"/radar-airspace/api/airspace/point":{"get":{"tags":["airspace"],"summary":"Airspace zones containing a point","description":"Returns every airspace zone whose polygon contains the queried `(lat, lon)`. When `altitude` is supplied, the result is further filtered to zones whose `[altitude_min_ft, altitude_max_ft]` band contains the requested altitude (inclusive on both ends; NULL bounds are treated as surface / no-ceiling).\n\nEmpty `data: []` means the point sits in uncontrolled airspace — never `404` so callers can distinguish that from a lookup failure. Genuine PostGIS / dataset-readiness failures surface as `503`.\n\nResponses include `Cache-Control: public, max-age=\u003cconfigured\u003e` (default 1 hour) since airspace data changes monthly at best.\n\nThe response envelope is `{ \"httpStatus\": 200, \"data\": [ ... ] }` where each `data` element carries `class` (normalized classification), `icao_class` (raw ICAO class A..G when published), `name`, `designator`, `source`, `altitude_min_ft` (lower band in feet MSL; `null` ⇒ surface), and `altitude_max_ft` (upper band in feet MSL; `null` ⇒ no published ceiling).\n\n### Happy path — inside Frankfurt CTR\n```bash\ncurl -s \"https://api.atmosoar.io/radar-airspace/api/airspace/point?location=50.0334,8.5705\"\n```\n\n### Happy path — uncontrolled airspace\n```bash\ncurl -s \"https://api.atmosoar.io/radar-airspace/api/airspace/point?location=0,0\"\n# =\u003e {\"httpStatus\":200,\"data\":[]}\n```\n\n### Happy path — altitude filter (above ceiling)\n```bash\ncurl -s \"https://api.atmosoar.io/radar-airspace/api/airspace/point?location=50.0334,8.5705\u0026altitude=10000ft\"\n```","operationId":"airspacePoint","parameters":[{"name":"altitude","in":"query","description":"Altitude with optional unit. Accepts '5000ft', '1500m', or a bare number (interpreted as feet — aviation default). Filter is inclusive on both band ends; NULL lower bound is treated as surface (0), NULL upper bound as no ceiling.","schema":{"type":"string","description":"Altitude with optional unit. Accepts '5000ft', '1500m', or a bare number (interpreted as feet — aviation default). Filter is inclusive on both band ends; NULL lower bound is treated as surface (0), NULL upper bound as no ceiling.","example":"5000ft"}},{"name":"location","in":"query","description":"Location spec parsed by atmosoar-api-libraries/location. Accepts 'lat,lon' (decimal degrees WGS84) or 'lat,lon|radiusKm' (the center is used). Mutually exclusive with lat/lng.","schema":{"type":"string","description":"Location spec parsed by atmosoar-api-libraries/location. Accepts 'lat,lon' (decimal degrees WGS84) or 'lat,lon|radiusKm' (the center is used). Mutually exclusive with lat/lng.","example":"50.0334,8.5705"}},{"name":"time","in":"query","description":"RFC3339 timestamp. Reserved for future NOTAM/TFR support; in v1 the value is parsed and validated (malformed → 400) but does not filter results.","schema":{"type":"string","description":"RFC3339 timestamp. Reserved for future NOTAM/TFR support; in v1 the value is parsed and validated (malformed → 400) but does not filter results.","example":"2026-05-18T12:00:00Z"}}],"responses":{"200":{"description":"Zero-or-more matching zones (empty array = uncontrolled airspace)."},"400":{"description":"Malformed parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"503":{"description":"Upstream / dataset unavailable (dataset not loaded or readiness check failed).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}}}}},"/radar-airspace/api/airspace/zones":{"get":{"tags":["airspace"],"summary":"Airspace zones intersecting a bbox (with geometry)","description":"Returns every airspace zone whose polygon intersects the queried bounding box, including the polygon geometry as GeoJSON (`Polygon` or `MultiPolygon`).\n\n**Limits:** `west` must be `\u003c` `east` (antimeridian-spanning queries are not supported — `400` `INVALID_PARAMETER`). Each bbox axis must be ≤ the configured cap (default 25°) to bound response size — exceed it and you get `400`.\n\n**Row cap.** The response is capped at `airspace.max_zones_per_response` rows (default 5000) and at `airspace.max_zones_response_bytes` of polygon GeoJSON (default 8 MiB). When either cap is hit, the envelope carries `truncated: true`; clients should query a smaller bbox to see the remaining rows.\n\n**Cache.** `Cache-Control: private, max-age=\u003cconfigured\u003e` for non-truncated responses. Bodies can be large and bbox-keyed; shared CDNs/proxies must not cache them to avoid memory pressure and cross-tenant leakage of bbox-shaped queries. The `/point` endpoint uses `public`. When `truncated: true`, the header is instead `Cache-Control: no-store` — a partial body is non-canonical for the requested bbox, and the caller must re-fetch on the next request so a later `max_zones_per_response` bump surfaces the missing rows immediately instead of being shadowed by a stale browser cache entry.\n\nEmpty `data: []` means no zones intersect (still HTTP 200). Backend failures and dataset-not-loaded surface as `503`.\n\nThe response envelope is `{ \"httpStatus\": 200, \"truncated\": false, \"data\": [ ... ] }` where each `data` element is an airspace zone (see /point) plus a `geometry` field carrying a GeoJSON `Polygon` or `MultiPolygon` in WGS84 (lon, lat).\n\n### Happy path\n```bash\ncurl -s \"https://api.atmosoar.io/radar-airspace/api/airspace/zones?bbox=8.0,49.7,9.0,50.4\"\n```","operationId":"airspaceZones","parameters":[{"name":"bbox","in":"query","description":"Four comma-separated WGS84 degrees: 'west,south,east,north'. west \u003c east, south \u003c north, each axis \u003c= configured max (default 25 degrees).","schema":{"type":"string","description":"Four comma-separated WGS84 degrees: 'west,south,east,north'. west \u003c east, south \u003c north, each axis \u003c= configured max (default 25 degrees).","example":"8.0,49.7,9.0,50.4"}},{"name":"time","in":"query","description":"RFC3339 timestamp. Reserved for future NOTAM/TFR support; in v1 validated but not used.","schema":{"type":"string","description":"RFC3339 timestamp. Reserved for future NOTAM/TFR support; in v1 validated but not used.","example":"2026-05-18T12:00:00Z"}}],"responses":{"200":{"description":"Zero-or-more zones intersecting the bbox, with geometry."},"400":{"description":"Malformed parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"503":{"description":"Upstream / dataset unavailable (dataset not loaded or readiness check failed).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}}}}},"/radar-airspace/api/layers/aurora/latest":{"get":{"tags":["layers"],"summary":"Latest OVATION aurora grid","description":"Returns NOAA SWPC's latest OVATION aurora-probability grid as a GeoJSON `FeatureCollection`. The grid is global at ~1° resolution (~65 000 points max per response). Each feature is a `Point` with `properties.aurora_probability_pct` — the OVATION aurora visibility probability in **percent** (integer, `0`–`100`).\n\n### `?location=` filter\nOptional. Supported forms (others rejected with `INVALID_LOCATION`):\n\n* `bbox:west,south,east,north`\n* rectangle `NE|SW`\n* radius `lat,lon|km`\n\nPoint / polyline / polygon / station forms have no useful filter semantics on a flat point grid and are rejected.\n\n### Happy path — global\n```bash\ncurl -s \"https://api.atmosoar.io/radar-airspace/api/layers/aurora/latest\"\n```\n\n### Happy path — 1000 km around Helsinki\n```bash\ncurl -s \"https://api.atmosoar.io/radar-airspace/api/layers/aurora/latest?location=60.17,24.94|1000\"\n```\n\n### Bad path — point form not supported here\n```bash\ncurl -s \"https://api.atmosoar.io/radar-airspace/api/layers/aurora/latest?location=60.17,24.94\"\n# =\u003e 400 {\"httpStatus\":400,\"code\":\"INVALID_LOCATION\",\"message\":\"point form not supported for aurora grid\"}\n```","operationId":"auroraLatest","parameters":[{"name":"location","in":"query","description":"Optional spatial filter. Supported forms: 'bbox:west,south,east,north', rectangle 'NElat,NElon|SWlat,SWlon', or radius 'lat,lon|km'. Point / polyline / polygon / station forms have no useful filter semantics on a flat point grid and are rejected with 400 INVALID_LOCATION.","schema":{"type":"string","description":"Optional spatial filter. Supported forms: 'bbox:west,south,east,north', rectangle 'NElat,NElon|SWlat,SWlon', or radius 'lat,lon|km'. Point / polyline / polygon / station forms have no useful filter semantics on a flat point grid and are rejected with 400 INVALID_LOCATION.","example":"60.17,24.94|1000"}}],"responses":{"200":{"description":"GeoJSON FeatureCollection of aurora-probability points."},"400":{"description":"Malformed parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"500":{"description":"Unexpected server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}}}}},"/radar-airspace/api/layers/gpsjam/{z}/{x}/{y}":{"get":{"tags":["layers"],"summary":"GPSJam MVT tile (H3 res 4)","description":"Mapbox Vector Tile rendered server-side from the GPSJam GPS-interference dataset, indexed at H3 resolution 4. One layer (`gpsjam`) is included in the tile. Each feature carries:\n\n| Property               | Unit / type                                | Notes |\n|------------------------|--------------------------------------------|-------|\n| `h3index`              | string (H3 cell token)                     | Resolution-4 cell |\n| `count_good_aircraft`  | integer count of aircraft observations     | Per H3 cell, per UTC day |\n| `count_bad_aircraft`   | integer count of aircraft observations     | Per H3 cell, per UTC day |\n| `bad_frac`             | dimensionless ratio in `[0, 1]`            | `bad / (bad + good)` per gpsjam.org |\n| `gps_quality`          | string enum `low` \\| `med` \\| `high`       | Threshold at `bad_frac` 0.02 / 0.10 |\n\nTiles are produced on the fly from PostgreSQL and short-lived cached.\n\n### Happy path\n```bash\ncurl -s -o gpsjam.mvt \\\n  \"https://api.atmosoar.io/radar-airspace/api/layers/gpsjam/3/4/2\"\n```\n\n### Empty tile\nFor tiles with no overlapping H3 cells the response is a `200` with an empty MVT body (zero-length protobuf). This is intentional — Mapbox GL treats it as \"no features here\" rather than an error.\n\n### Bad path — invalid zoom\n```bash\ncurl -s \"https://api.atmosoar.io/radar-airspace/api/layers/gpsjam/30/0/0\"\n# =\u003e 400 INVALID_PARAMETER\n```","operationId":"gpsjamMVT","parameters":[{"name":"x","in":"path","description":"Tile column index. Integer in [0, 2^z) — the valid range narrows with smaller z. Out-of-range values are rejected with 400 INVALID_PARAMETER.","required":true,"schema":{"type":"string","description":"Tile column index. Integer in [0, 2^z) — the valid range narrows with smaller z. Out-of-range values are rejected with 400 INVALID_PARAMETER.","example":"4"}},{"name":"y","in":"path","description":"Tile row index. Integer in [0, 2^z). A trailing .mvt or .png extension is tolerated for compatibility with map clients that always append it. Out-of-range values are rejected with 400 INVALID_PARAMETER.","required":true,"schema":{"type":"string","description":"Tile row index. Integer in [0, 2^z). A trailing .mvt or .png extension is tolerated for compatibility with map clients that always append it. Out-of-range values are rejected with 400 INVALID_PARAMETER.","example":"2"}},{"name":"z","in":"path","description":"Web-Mercator tile zoom level (XYZ scheme). Integer in [0, 22]. Out-of-range values are rejected with 400 INVALID_PARAMETER.","required":true,"schema":{"type":"string","description":"Web-Mercator tile zoom level (XYZ scheme). Integer in [0, 22]. Out-of-range values are rejected with 400 INVALID_PARAMETER.","example":"3"}}],"responses":{"200":{"description":"Mapbox Vector Tile protobuf."},"400":{"description":"Malformed parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"500":{"description":"Unexpected server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}}}}},"/radar-airspace/api/layers/population/{z}/{x}/{y}":{"get":{"tags":["layers"],"summary":"Kontur population MVT tile (H3 pyramid, res 4–8)","description":"Mapbox Vector Tile rendered from the Kontur global population density dataset. The handler picks the H3 resolution by zoom (server-side pyramid: r4 at z≤4 → r8 at z≥11) so low-zoom tiles return a manageable feature count. Each feature carries:\n\n| Property     | Unit / type                                                 |\n|--------------|-------------------------------------------------------------|\n| `h3index`    | string (H3 cell token at the chosen pyramid resolution)     |\n| `population` | integer count of **persons** in the H3 cell (sum across child r8 cells) |\n\nSource: \u003chttps://data.humdata.org/dataset/kontur-population-dataset\u003e.\n\n### Caching\n\nSuccessful responses (including the empty body returned outside the configured zoom range) carry a strong `ETag` and a `Cache-Control: private, max-age=\u003cconfigured\u003e` header. The route is gated by the gateway's claims middleware, so responses are scoped to a single client; `Vary: Authorization, X-Atmosoar-User-Sub` instructs any caching proxy to key entries by those request headers. The ETag incorporates the loaded dataset version, so a fresh load + pod rollout invalidates all downstream caches deterministically. Clients that send a matching `If-None-Match` receive `304 Not Modified` with no body.\n\n### Happy path\n```bash\ncurl -s -o pop.mvt \\\n  \"https://api.atmosoar.io/radar-airspace/api/layers/population/8/137/87\"\n```\n\n### Conditional revalidation\n```bash\ncurl -s -i \\\n  -H 'If-None-Match: \"\u003cetag-from-prior-response\u003e\"' \\\n  \"https://api.atmosoar.io/radar-airspace/api/layers/population/8/137/87\"\n# =\u003e 304 Not Modified (no body)\n```\n\n### Bad path — non-numeric tile coord\n```bash\ncurl -s \"https://api.atmosoar.io/radar-airspace/api/layers/population/8/abc/87\"\n# =\u003e 400 INVALID_PARAMETER\n```","operationId":"populationMVT","parameters":[{"name":"x","in":"path","description":"Tile column index. Integer in [0, 2^z) — the valid range narrows with smaller z. Out-of-range values are rejected with 400 INVALID_PARAMETER.","required":true,"schema":{"type":"string","description":"Tile column index. Integer in [0, 2^z) — the valid range narrows with smaller z. Out-of-range values are rejected with 400 INVALID_PARAMETER.","example":"137"}},{"name":"y","in":"path","description":"Tile row index. Integer in [0, 2^z). A trailing .mvt or .png extension is tolerated for compatibility with map clients that always append it. Out-of-range values are rejected with 400 INVALID_PARAMETER.","required":true,"schema":{"type":"string","description":"Tile row index. Integer in [0, 2^z). A trailing .mvt or .png extension is tolerated for compatibility with map clients that always append it. Out-of-range values are rejected with 400 INVALID_PARAMETER.","example":"87"}},{"name":"z","in":"path","description":"Web-Mercator tile zoom level (XYZ scheme). Integer in [0, 22]. Out-of-range values are rejected with 400 INVALID_PARAMETER.","required":true,"schema":{"type":"string","description":"Web-Mercator tile zoom level (XYZ scheme). Integer in [0, 22]. Out-of-range values are rejected with 400 INVALID_PARAMETER.","example":"8"}},{"name":"If-None-Match","in":"header","description":"Strong ETag from a prior response. A matching value yields 304 Not Modified with no body. Weak (W/'...') validators are accepted per RFC 7232 section 3.2.","schema":{"type":"string","description":"Strong ETag from a prior response. A matching value yields 304 Not Modified with no body. Weak (W/'...') validators are accepted per RFC 7232 section 3.2."}}],"responses":{"200":{"description":"Mapbox Vector Tile protobuf."},"304":{"description":"Cached copy is still fresh — If-None-Match matched the current ETag. Empty body. Weak (W/'...') validators are accepted per RFC 7232 section 3.2."},"400":{"description":"Malformed parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"500":{"description":"Unexpected server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}}}}},"/radar-airspace/api/radar/nowcast/point":{"get":{"tags":["radar"],"summary":"Radar-motion rain nowcast at a point","description":"For a point inside one of the configured site windows, answers how the rain is moving, in how many minutes it reaches the point, and how much falls in the next 30, 60 and 90 minutes.\n\nA scheduled job keeps the last hour of radar frames for a square window around each configured site, estimates one motion vector per window from consecutive frames, and publishes the result in memory. This endpoint back-traces the point along that vector at 5-minute steps, anchored at request time (an older frame is moved further, so the ETA does not drift with frame age). It never calls the radar provider itself.\n\nResponses carry `Cache-Control: no-store`: the result changes with every job cycle and with the request time.\n\n* `404 NOT_FOUND` - the location is outside every configured window (or no sites are configured).\n* `503 DATA_SOURCE_UNAVAILABLE` + `Retry-After` - the newest frame is older than the staleness limit (default 30 min), or the window has no result yet.\n\n### Happy path\n```bash\ncurl -s \"https://api.atmosoar.io/radar-airspace/api/radar/nowcast/point?location=43.93,10.92\"\n```\n\n### Custom threshold\n```bash\ncurl -s \"https://api.atmosoar.io/radar-airspace/api/radar/nowcast/point?location=43.93,10.92\u0026threshold=2\"\n```","operationId":"radarNowcastPoint","parameters":[{"name":"location","in":"query","description":"Point to nowcast, parsed by atmosoar-api-libraries/location: 'lat,lon' (decimal degrees WGS84) or 'lat,lon|radiusKm' (the center is used). Must lie inside a configured site window.","schema":{"type":"string","description":"Point to nowcast, parsed by atmosoar-api-libraries/location: 'lat,lon' (decimal degrees WGS84) or 'lat,lon|radiusKm' (the center is used). Must lie inside a configured site window.","example":"43.93,10.92"}},{"name":"threshold","in":"query","description":"Rain rate in mm/h that counts as rain for eta_status / eta_minutes. Positive number; defaults to the configured default (0.5 mm/h) and is echoed as threshold_mm_per_hour.","schema":{"type":"string","description":"Rain rate in mm/h that counts as rain for eta_status / eta_minutes. Positive number; defaults to the configured default (0.5 mm/h) and is echoed as threshold_mm_per_hour.","example":"0.5"}}],"responses":{"200":{"description":"Nowcast for the point, from the job's last result.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiRadarNowcastEnvelope"}}}},"400":{"description":"Malformed location (INVALID_LOCATION) or threshold (INVALID_THRESHOLD).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"404":{"description":"Location outside every configured nowcast window (NOT_FOUND).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"503":{"description":"Radar nowcast stale or not computed yet (DATA_SOURCE_UNAVAILABLE); carries Retry-After.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}}}}},"/radar-airspace/api/rainviewer/maps":{"get":{"tags":["rainviewer"],"summary":"RainViewer frame index","description":"Passthrough of `https://api.rainviewer.com/public/weather-maps.json`. Returns the latest set of radar/satellite frame paths and their timestamps. Cached for `cache.rainviewer_maps_ttl` (default **1 minute**) because RainViewer publishes new frames every ~10 minutes.\n\nConsumers feed a returned `path` into `/api/rainviewer/tile/{rest}` to fetch the actual tile.\n\n### Happy path\n```bash\ncurl -s https://api.atmosoar.io/radar-airspace/api/rainviewer/maps\n```\n\n### Bad path — RainViewer unreachable\n```bash\n# =\u003e 502 {\"httpStatus\":502,\"code\":\"UPSTREAM_ERROR\",\"message\":\"Data source 'rainviewer' is temporarily unavailable.\"}\n```","operationId":"rainViewerMaps","responses":{"200":{"description":"RainViewer frame index JSON, passed through verbatim."},"502":{"description":"Upstream data source temporarily unavailable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}}}}},"/radar-airspace/api/rainviewer/tile/*rest":{"get":{"tags":["rainviewer"],"summary":"RainViewer tile passthrough","description":"Server-side proxy of `https://tilecache.rainviewer.com/...`. The `{rest}` path fragment is the `path` returned by `/maps`, plus the tile suffix (`/{size}/{z}/{x}/{y}/{color}/{options}.png`).\n\n### Security\nPath traversal (`..`), absolute URLs, and any `{rest}` not matching the RainViewer tile shape are rejected — the endpoint cannot be used as an open relay.\n\n### Happy path\n```bash\n# path returned by /maps was \"/v2/radar/1730476200\"\ncurl -s -o radar.png \\\n  \"https://api.atmosoar.io/radar-airspace/api/rainviewer/tile/v2/radar/1730476200/256/5/16/10/2/1_1.png\"\n```\n\n### Bad path — path traversal attempt\n```bash\ncurl -s \"https://api.atmosoar.io/radar-airspace/api/rainviewer/tile/../etc/passwd\"\n# =\u003e 400 INVALID_PARAMETER\n```","operationId":"rainViewerTile","parameters":[{"name":"rest","in":"path","description":"Tile path fragment. Strict grammar enforced server-side; mismatches yield 400 INVALID_PARAMETER. Concatenation of the 'path' from /maps and the RainViewer tile suffix: /v2/\u003ckind\u003e/\u003cframe\u003e/\u003csize\u003e/\u003cz\u003e/\u003cx\u003e/\u003cy\u003e/\u003ccolor\u003e/\u003coptions\u003e.png. \u003ckind\u003e — 'radar' or 'satellite'. \u003cframe\u003e — opaque frame identifier returned by /maps (any non-empty token without a '/'; historically Unix epoch seconds, now a hex-hash id such as 'd5a3928b77db' — the proxy trusts /maps and forwards it verbatim). \u003csize\u003e — tile edge length in pixels, must be 256 or 512. \u003cz\u003e — Web-Mercator zoom level, integer in [0, 10]. \u003cx\u003e, \u003cy\u003e — tile column/row indices, integers in [0, 2^z). \u003ccolor\u003e — palette identifier, integer in [0, 8] (9 schemes). \u003coptions\u003e — two boolean flags joined by '_' in the form \u003csmooth\u003e_\u003csnow\u003e, each flag 0 or 1.","required":true,"schema":{"type":"string","description":"Tile path fragment. Strict grammar enforced server-side; mismatches yield 400 INVALID_PARAMETER. Concatenation of the 'path' from /maps and the RainViewer tile suffix: /v2/\u003ckind\u003e/\u003cframe\u003e/\u003csize\u003e/\u003cz\u003e/\u003cx\u003e/\u003cy\u003e/\u003ccolor\u003e/\u003coptions\u003e.png. \u003ckind\u003e — 'radar' or 'satellite'. \u003cframe\u003e — opaque frame identifier returned by /maps (any non-empty token without a '/'; historically Unix epoch seconds, now a hex-hash id such as 'd5a3928b77db' — the proxy trusts /maps and forwards it verbatim). \u003csize\u003e — tile edge length in pixels, must be 256 or 512. \u003cz\u003e — Web-Mercator zoom level, integer in [0, 10]. \u003cx\u003e, \u003cy\u003e — tile column/row indices, integers in [0, 2^z). \u003ccolor\u003e — palette identifier, integer in [0, 8] (9 schemes). \u003coptions\u003e — two boolean flags joined by '_' in the form \u003csmooth\u003e_\u003csnow\u003e, each flag 0 or 1.","example":"/v2/radar/d5a3928b77db/256/5/16/10/2/1_1.png"}}],"responses":{"200":{"description":"PNG raster tile."},"400":{"description":"Malformed parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"502":{"description":"Upstream data source temporarily unavailable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}}}}},"/radar-airspace/api/weathermap/airpollution":{"get":{"tags":["openweathermap"],"summary":"Air pollution at a point","description":"Proxies OpenWeatherMap `GET /data/2.5/air_pollution`. Returns the AQI plus per-pollutant concentrations (CO, NO₂, O₃, SO₂, PM2.5, PM10, NH₃). Responses are cached for `cache.owm_pollution_ttl` (default **10 minutes**).\n\nUnits in the upstream JSON (passed through verbatim):\n\n* `list[].main.aqi` — Air Quality Index, integer enum 1=Good, 2=Fair, 3=Moderate, 4=Poor, 5=Very Poor\n* `list[].components.{co, no, no2, o3, so2, pm2_5, pm10, nh3}` — micrograms per cubic meter (μg/m³)\n* `list[].dt` — Unix epoch seconds (UTC)\n\n### Happy path\n```bash\ncurl -s \"https://api.atmosoar.io/radar-airspace/api/weathermap/airpollution?location=52.52,13.40\"\n```\n\n### Bad path — missing coordinates\n```bash\ncurl -s \"https://api.atmosoar.io/radar-airspace/api/weathermap/airpollution\"\n# =\u003e 400 {\"httpStatus\":400,\"code\":\"INVALID_LOCATION\",\"message\":\"location required\"}\n```","operationId":"owmAirPollution","parameters":[{"name":"lat","in":"query","description":"Latitude in decimal degrees, WGS84, range [-90, 90]. Legacy — prefer location. Must be paired with lng.","schema":{"type":"string","description":"Latitude in decimal degrees, WGS84, range [-90, 90]. Legacy — prefer location. Must be paired with lng.","example":"52.52"}},{"name":"lng","in":"query","description":"Longitude in decimal degrees, WGS84, range [-180, 180]. Legacy — prefer location. Must be paired with lat. 'lon' is accepted as an alias.","schema":{"type":"string","description":"Longitude in decimal degrees, WGS84, range [-180, 180]. Legacy — prefer location. Must be paired with lat. 'lon' is accepted as an alias.","example":"13.40"}},{"name":"location","in":"query","description":"Location spec parsed by atmosoar-api-libraries/location. For point endpoints the accepted forms are 'lat,lon' (decimal degrees WGS84, lat in [-90,90], lon in [-180,180]) or 'lat,lon|radiusKm' (the center is used; radius collapses on point endpoints). Mutually exclusive with lat/lng.","schema":{"type":"string","description":"Location spec parsed by atmosoar-api-libraries/location. For point endpoints the accepted forms are 'lat,lon' (decimal degrees WGS84, lat in [-90,90], lon in [-180,180]) or 'lat,lon|radiusKm' (the center is used; radius collapses on point endpoints). Mutually exclusive with lat/lng.","example":"52.52,13.40"}}],"responses":{"200":{"description":"OpenWeatherMap air-pollution JSON, passed through verbatim."},"400":{"description":"Malformed parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"502":{"description":"Upstream data source temporarily unavailable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"503":{"description":"Upstream API key not configured (operator misconfiguration).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}}}}},"/radar-airspace/api/weathermap/current":{"get":{"tags":["openweathermap"],"summary":"Current weather at a point","description":"Proxies OpenWeatherMap `GET /data/2.5/weather` for a single coordinate. The full upstream JSON is returned verbatim (temperature, wind, clouds, etc.). Responses are cached in-process for `cache.owm_current_ttl` (default **5 minutes**) keyed by the rounded coordinate.\n\nUse `?location=` (preferred) or the legacy `?lat=\u0026lng=` pair. Exactly one form must be provided.\n\nThe service always requests `units=metric` upstream, so numeric units in the response are:\n\n* `main.temp`, `main.feels_like`, `main.temp_min`, `main.temp_max` — degrees Celsius (°C)\n* `main.pressure` — hectopascals (hPa)\n* `main.humidity` — percent (%, 0–100)\n* `wind.speed`, `wind.gust` — meters per second (m/s)\n* `wind.deg` — meteorological degrees (0=N, 90=E)\n* `clouds.all` — percent (%, 0–100)\n* `visibility` — meters\n* `dt`, `sys.sunrise`, `sys.sunset` — Unix epoch seconds (UTC)\n\n### Happy path\n```bash\ncurl -s \"https://api.atmosoar.io/radar-airspace/api/weathermap/current?location=52.52,13.40\"\n```\n\n### Bad path — malformed coordinate\n```bash\ncurl -s \"https://api.atmosoar.io/radar-airspace/api/weathermap/current?location=abc\"\n# =\u003e {\"httpStatus\":400,\"code\":\"INVALID_LOCATION\",\"message\":\"could not parse location 'abc'\"}\n```\n\n### Bad path — upstream key missing (operator misconfiguration)\n```bash\n# =\u003e 503 {\"httpStatus\":503,\"code\":\"UPSTREAM_ERROR\",\"message\":\"OWM key not configured\"}\n```","operationId":"owmCurrent","parameters":[{"name":"lat","in":"query","description":"Latitude in decimal degrees, WGS84, range [-90, 90]. Legacy — prefer location. Must be paired with lng.","schema":{"type":"string","description":"Latitude in decimal degrees, WGS84, range [-90, 90]. Legacy — prefer location. Must be paired with lng.","example":"52.52"}},{"name":"lng","in":"query","description":"Longitude in decimal degrees, WGS84, range [-180, 180]. Legacy — prefer location. Must be paired with lat. 'lon' is accepted as an alias.","schema":{"type":"string","description":"Longitude in decimal degrees, WGS84, range [-180, 180]. Legacy — prefer location. Must be paired with lat. 'lon' is accepted as an alias.","example":"13.40"}},{"name":"location","in":"query","description":"Location spec parsed by atmosoar-api-libraries/location. For point endpoints the accepted forms are 'lat,lon' (decimal degrees WGS84, lat in [-90,90], lon in [-180,180]) or 'lat,lon|radiusKm' (the center is used; radius collapses on point endpoints). Mutually exclusive with lat/lng.","schema":{"type":"string","description":"Location spec parsed by atmosoar-api-libraries/location. For point endpoints the accepted forms are 'lat,lon' (decimal degrees WGS84, lat in [-90,90], lon in [-180,180]) or 'lat,lon|radiusKm' (the center is used; radius collapses on point endpoints). Mutually exclusive with lat/lng.","example":"52.52,13.40"}}],"responses":{"200":{"description":"OpenWeatherMap JSON, passed through verbatim (metric units)."},"400":{"description":"Malformed parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"502":{"description":"Upstream data source temporarily unavailable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"503":{"description":"Upstream API key not configured (operator misconfiguration).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}}}}},"/radar-airspace/api/weathermap/tile/{layer}/{z}/{x}/{y}":{"get":{"tags":["openweathermap"],"summary":"OpenWeatherMap raster tile","description":"Proxies OWM raster map tiles (256×256 PNG). Tiles are cached in-process per `(layer, z, x, y)` for `cache.owm_tile_ttl` (default **15 minutes**).\n\n**Zoom range:** OpenWeatherMap publishes raster tiles only for `z` in `[0, 9]`. Higher zooms are rejected with `400 INVALID_PARAMETER` before any upstream request is made.\n\nAvailable layers:\n\n| Layer              | Meaning                                 |\n|--------------------|-----------------------------------------|\n| `clouds_new`       | Cloud cover                             |\n| `precipitation_new`| Precipitation intensity                 |\n| `pressure_new`     | Mean sea-level pressure                 |\n| `wind_new`         | Wind speed                              |\n| `temp_new`         | Air temperature                         |\n\nA trailing `.png` on `{y}` is tolerated for compatibility with map clients that always append the extension.\n\n### Happy path\n```bash\ncurl -s -o tile.png \\\n  \"https://api.atmosoar.io/radar-airspace/api/weathermap/tile/precipitation_new/5/16/10\"\n```\n\n### Bad path — unknown layer\n```bash\ncurl -s \"https://api.atmosoar.io/radar-airspace/api/weathermap/tile/sunshine/5/16/10\"\n# =\u003e 400 INVALID_PARAMETER\n```","operationId":"owmTile","parameters":[{"name":"layer","in":"path","description":"OpenWeatherMap layer identifier. One of: clouds_new (cloud cover), precipitation_new (precipitation intensity), pressure_new (mean sea-level pressure), wind_new (wind speed), temp_new (air temperature).","required":true,"schema":{"type":"string","description":"OpenWeatherMap layer identifier. One of: clouds_new (cloud cover), precipitation_new (precipitation intensity), pressure_new (mean sea-level pressure), wind_new (wind speed), temp_new (air temperature).","example":"precipitation_new"}},{"name":"x","in":"path","description":"Tile column index. Integer in [0, 2^z) — the valid range narrows with smaller z. Out-of-range values are rejected with 400 INVALID_PARAMETER.","required":true,"schema":{"type":"string","description":"Tile column index. Integer in [0, 2^z) — the valid range narrows with smaller z. Out-of-range values are rejected with 400 INVALID_PARAMETER.","example":"16"}},{"name":"y","in":"path","description":"Tile row index. Integer in [0, 2^z). A trailing .mvt or .png extension is tolerated for compatibility with map clients that always append it. Out-of-range values are rejected with 400 INVALID_PARAMETER.","required":true,"schema":{"type":"string","description":"Tile row index. Integer in [0, 2^z). A trailing .mvt or .png extension is tolerated for compatibility with map clients that always append it. Out-of-range values are rejected with 400 INVALID_PARAMETER.","example":"10"}},{"name":"z","in":"path","description":"Web-Mercator tile zoom level (XYZ scheme). Integer in [0, 22]. OpenWeatherMap raster tiles only exist for z in [0, 9] and the proxy rejects higher zooms with 400 INVALID_PARAMETER.","required":true,"schema":{"type":"string","description":"Web-Mercator tile zoom level (XYZ scheme). Integer in [0, 22]. OpenWeatherMap raster tiles only exist for z in [0, 9] and the proxy rejects higher zooms with 400 INVALID_PARAMETER.","example":"5"}}],"responses":{"200":{"description":"PNG raster tile (256×256)."},"400":{"description":"Malformed parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"502":{"description":"Upstream data source temporarily unavailable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"503":{"description":"Upstream API key not configured (operator misconfiguration).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}}}}},"/radar-airspace/healthz":{"get":{"tags":["ops"],"summary":"Liveness probe","description":"Returns `{\"status\":\"ok\"}` when the HTTP server is reachable. This probe does **not** check Postgres or upstream availability — it is intended for Kubernetes liveness only. Use the operation-specific endpoints to verify upstream health.\n\n### Example\n```bash\ncurl -sf https://api.atmosoar.io/radar-airspace/healthz\n# =\u003e {\"status\":\"ok\"}\n```","operationId":"healthz","responses":{"200":{"description":"Service is up; body is {\"status\":\"ok\"}."}}}},"/radar-airspace/metrics":{"get":{"tags":["ops"],"summary":"Prometheus scrape endpoint","description":"Prometheus text exposition. Includes Go runtime metrics plus the service's own counters/histograms registered in `internal/metrics`.\n\n### Example\n```bash\ncurl -s https://api.atmosoar.io/radar-airspace/metrics | head\n```","operationId":"metrics","responses":{"200":{"description":"Prometheus text exposition format."}}}}},"components":{"schemas":{"ApiRadarNowcastAccumulation":{"type":"object","properties":{"30":{"type":"number","description":"mm expected in the next 30 minutes.","format":"double"},"60":{"type":"number","description":"mm expected in the next 60 minutes.","format":"double"},"90":{"type":"number","description":"mm expected in the next 90 minutes.","format":"double"}},"description":"Expected rainfall in mm over the next 30, 60 and 90 minutes (time integral of intensity)."},"ApiRadarNowcastData":{"type":"object","properties":{"accumulation_mm":{"$ref":"#/components/schemas/ApiRadarNowcastAccumulation"},"eta_minutes":{"type":"integer","description":"Minutes until rain at or above threshold: 0 when raining_now, null unless arriving or raining_now.","format":"int32","nullable":true},"eta_status":{"type":"string","description":"raining_now: at or above threshold now; arriving: reaches threshold within the horizon; none_within_horizon: neither.","enum":["raining_now","arriving","none_within_horizon"]},"frame_age_minutes":{"type":"integer","description":"Whole minutes between frame_time and the request.","format":"int32"},"frame_time":{"type":"string","description":"Nominal time (RFC3339 UTC) of the newest radar frame used.","format":"date-time"},"frames_used":{"type":"integer","description":"Radar frames held for the motion estimate.","format":"int32"},"horizon_minutes_covered":{"type":"integer","description":"Minutes of the horizon the intensity curve covers.","format":"int32","example":90},"intensity":{"type":"array","items":{"$ref":"#/components/schemas/ApiRadarNowcastStep"},"description":"Extrapolated rain rate at 5-minute steps up to the horizon (fewer entries when the trajectory leaves the window, see horizon_minutes_covered)."},"motion":{"$ref":"#/components/schemas/ApiRadarNowcastMotion"},"source":{"type":"string","description":"Radar provider of the frames.","example":"rainviewer"},"threshold_mm_per_hour":{"type":"number","description":"Threshold applied (request value or configured default).","format":"double","example":0.5},"warnings":{"type":"array","items":{"type":"string"},"description":"Warning codes, empty when none: MOTION_INCONSISTENT (per-pair estimates disagree), HORIZON_TRUNCATED (trajectory left the window)."},"window":{"type":"string","description":"Name of the configured site window serving the point.","example":"pistoia"}}},"ApiRadarNowcastEnvelope":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ApiRadarNowcastData"},"httpStatus":{"type":"integer","format":"int32","example":200}}},"ApiRadarNowcastMotion":{"type":"object","properties":{"bearing_deg":{"type":"number","description":"Direction the rain moves towards, degrees clockwise from north (0-360); null when there is no motion estimate.","format":"double","example":75,"nullable":true},"confidence":{"type":"string","description":"Quality of the motion estimate.","enum":["high","medium","low","none"]},"speed_kmh":{"type":"number","description":"Rain-field speed in km/h; null when there is no motion estimate.","format":"double","example":32.5,"nullable":true}}},"ApiRadarNowcastStep":{"type":"object","properties":{"mm_per_hour":{"type":"number","description":"Extrapolated rain rate in mm/h.","format":"double","example":1.2},"t_plus_min":{"type":"integer","description":"Minutes after the request.","format":"int32","example":5}}},"HttputilsAPIError":{"type":"object","properties":{"code":{"type":"string"},"errors":{"type":"array","items":{"$ref":"#/components/schemas/HttputilsFieldError"}},"httpStatus":{"type":"integer","format":"int32"},"message":{"type":"string"}}},"HttputilsFieldError":{"type":"object","properties":{"code":{"type":"string"},"field":{"type":"string"},"message":{"type":"string"}}}}},"tags":[{"name":"airspace","description":"Structured airspace lookup endpoints (FEAT-2). Backed by an in-cluster PostGIS table populated by the OpenAIP airspace loader; the API returns normalized classification, altitude bands, and (for the bbox endpoint) polygon geometry. Designed for server-side consumers — for human-eyeball raster overlays, use the OpenAIP raster tile endpoint instead."},{"name":"layers","description":"Server-rendered MVT and GeoJSON for curated H3 datasets stored in the project's PostgreSQL database."},{"name":"openaip","description":"OpenAIP raster tile passthrough. The service holds the OpenAIP API key."},{"name":"openweathermap","description":"OpenWeatherMap passthrough proxies. The service holds the OWM API key and caches each response for the configured TTL."},{"name":"ops","description":"Liveness and Prometheus scrape endpoints."},{"name":"radar","description":"Radar-derived products computed in-process from the radar frames this service already proxies. The nowcast is served from memory; no request reaches the radar provider."},{"name":"rainviewer","description":"RainViewer frame index and tile passthrough. RainViewer is keyless; the proxy exists to keep tile traffic same-origin and to centralize caching."}]}