{"openapi":"3.0.1","info":{"title":"Elevation API","description":"# Elevation API\n\nReturns elevation in **metres above mean sea level (EGM2008 geoid)** for any geographic coordinate on Earth.\n\n---\n\n## Data Sources \u0026 Resolution\n\nThe API selects a DEM tier per request based on the **requested sampling density**, so wide-area queries don't pay 30 m memory costs at continental zoom:\n\n| Source | Resolution | Coverage | Vertical Datum |\n|--------|-----------|----------|----------------|\n| **Copernicus GLO-30** | ~30 m | 60° S – 90° N | EGM2008 |\n| **Copernicus GLO-90** | ~90 m | 60° S – 90° N | EGM2008 |\n| **FABDEM** | ~30 m | Global | EGM2008 |\n| **GTOPO30x** (legacy) | ~926 m | Global | EGM96 (assumed; USGS readme states only \"mean sea level\") |\n| Regional high-res (3DEP, LiDAR) | 1–10 m | Country-specific | Varies |\n\nBoth Copernicus tiers are **auto-downloaded** from AWS Open Data on first request (~30–50 MB per 1° GLO-30 tile, ~4 MB per GLO-90 tile, cached to disk). No manual data setup needed.\n\n### Tier routing\n\n- **Point / point-list / polyline / WMO / shortcut** → always the finest tier available (explicit positions get the best data).\n- **Cluster** with fewer than 50 points → finest tier.\n- **Bbox / rectangle / radius / large cluster** → the **coarsest tier whose native resolution is still finer than or equal to the request's per-sample resolution** (per-sample = √(area / point-count)). A 100° × 30° bbox at 10 000 points routes to GLO-90; a 0.01° bbox at 10 000 points stays on GLO-30.\n\nThe tier that actually served the request is reported in the response `source` field (e.g. `\"copernicus-glo30\"`, `\"copernicus-glo90\"`).\n\n---\n\n## Interpolation\n\n| Source type | Method | Grid size | Notes |\n|------------|--------|-----------|-------|\n| GeoTIFF (GLO-30, FABDEM, etc.) | **Catmull-Rom bicubic** | 4×4 (16 pixels) | ~2× effective resolution vs bilinear |\n| GTOPO30x (legacy) | Bilinear | 2×2 (4 pixels) | |\n\nBicubic interpolation produces **smooth terrain profiles** without the staircase artifacts of bilinear. Falls back to bilinear 2×2 if any of the 16 surrounding cells is nodata (coastlines, data voids). Cross-tile reads are handled transparently — no accuracy loss at tile boundaries.\n\n---\n\n## Location Formats\n\nThe unified `/elevation/api/elevation` endpoint accepts all location formats from the atmosoar forecast API:\n\n| Format | Syntax | Example |\n|--------|--------|---------|\n| Single Point | `lat,lng` | `47.37,8.55` |\n| Point List | `lat,lng\\|lat,lng\\|...` | `47.37,8.55\\|52.52,13.40` |\n| Polyline | `startLat,startLng,endLat,endLng,N` | `50,8,52,13,5` |\n| Rectangle | `neLat,neLng\\|swLat,swLng` | `52.52,13.40\\|50.11,8.68` |\n| Bounding Box | `bbox:W,S,E,N` | `bbox:5,47,15,55` |\n| Radius | `lat,lng\\|km` | `47.37,8.55\\|100` |\n| WMO Station | `WMO{id}` | `WMO10501` *(needs gateway-supplied station map)* |\n| Shortcut | name | `Germany` *(needs gateway-supplied shortcut map)* |\n\nRectangle, BBox, and Radius generate **adaptive grids** with automatic step-size selection. The location parser is supplied by `atmosoar.io/atmosoar-api-libraries/location`, so the grammar matches every other Atmosoar service byte-for-byte.\n\n---\n\n## Example requests\n\n```sh\n# Unified endpoint — single point\ncurl 'https://api.atmosoar.io/elevation/api/elevation?location=47.37,8.55'\n\n# Polyline — 5 points from Frankfurt to Berlin\ncurl 'https://api.atmosoar.io/elevation/api/elevation?location=50,8,52,13,5'\n\n# Bounding box — adaptive grid over Germany\ncurl 'https://api.atmosoar.io/elevation/api/elevation?location=bbox:5,47,15,55'\n\n# Radius — 100 km disc around Zurich (use --data-urlencode for the pipe)\ncurl --get 'https://api.atmosoar.io/elevation/api/elevation' \\\n  --data-urlencode 'location=47.37,8.55|100'\n\n# Cluster — array form with optional id\ncurl -X POST 'https://api.atmosoar.io/elevation/api/elevation/cluster' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"locations\":[[47.37,8.55,\"a\"],[52.52,13.40,\"b\"]]}'\n\n# Cluster — object form\ncurl -X POST 'https://api.atmosoar.io/elevation/api/elevation/cluster' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"locations\":[{\"lat\":47.37,\"lng\":8.55,\"id\":\"a\"},{\"lat\":52.52,\"lng\":13.40}]}'\n```\n\n---\n\n## Error responses\n\nAll error responses use the shared Atmosoar `APIError` envelope from `atmosoar.io/atmosoar-api-libraries/httputils`:\n\n```json\n{\n  \"httpStatus\": 400,\n  \"code\": \"INVALID_LOCATION\",\n  \"message\": \"invalid location \\\"WMO99999\\\": ...\",\n  \"errors\": [\n    { \"code\": \"INVALID_LOCATION\", \"field\": \"location\", \"message\": \"...\" }\n  ]\n}\n```\n\nTop-level codes you may see: `MISSING_REQUIRED_FIELD`, `VALIDATION_ERROR`, `INVALID_LOCATION`, `UPSTREAM_ERROR` (502 from the DEM source), `INTERNAL_ERROR` (500).\n\n---\n\n## Performance\n\n- **Batch tile sorting**: cluster and location queries are evaluated in tile-sorted order for cache locality\n- **HTTP caching**: complete `200` GET responses return `Cache-Control: private, max-age=86400` + `ETag` (`If-None-Match` revalidates with `304`); errors, partial results with `UNAVAILABLE` points and `OUT_OF_COVERAGE` answers the DEM source did not confirm (HTTP 403 or a tile missing from a local directory) return `Cache-Control: no-store`\n- **Lazy tile loading**: tiles are downloaded and opened on first access, then kept in a bounded LRU cache (open files, decoded blocks and on-disk tiles each have their own budget)\n\n---\n\n## Observability\n\nProvided by `atmosoar.io/atmosoar-api-libraries/observability` — same logger preset, metric names, and span attributes as every other Atmosoar Go service.\n\n- `GET /elevation/healthz` — liveness check\n- `GET /elevation/metrics` — Prometheus metrics (Go runtime, process, `http_requests_total`, `http_request_duration_seconds`, `http_requests_in_flight`)\n- OpenTelemetry tracing via OTLP **gRPC** — enabled when `OTEL_EXPORTER_OTLP_ENDPOINT` is set (e.g. `host:4317`); unset means tracing is off (no default endpoint)\n- Structured JSON logging (zap, sugared)","contact":{"name":"Atmosoar Support","url":"https://atmosoar.io"},"version":"1.10.0","x-logo":{"url":"https://atmosoar.io/logo.png","backgroundColor":"#003b48","altText":"atmosoar"}},"paths":{"/elevation/api/elevation":{"get":{"tags":["elevation"],"summary":"Query elevation using any location format","description":"Unified elevation endpoint that accepts the **full Atmosoar location grammar**. Use this when you want a single endpoint that works for points, lines, areas, and named locations — the parser figures out the kind and produces an appropriate set of sample points (with adaptive grid sizing for areas).\n\n### Supported location formats\n\n| Format        | Syntax                                | Example                               |\n|---------------|---------------------------------------|---------------------------------------|\n| Single point  | `lat,lng`                             | `47.37,8.55`                          |\n| Point list    | `lat,lng\\|lat,lng\\|...`               | `47.37,8.55\\|52.52,13.40`             |\n| Polyline      | `startLat,startLng,endLat,endLng,N`   | `50,8,52,13,5` (5 evenly-spaced pts)  |\n| Rectangle     | `neLat,neLng\\|swLat,swLng`            | `52.52,13.40\\|50.11,8.68`             |\n| Bounding box  | `bbox:W,S,E,N`                        | `bbox:5,47,15,55`                     |\n| Radius        | `lat,lng\\|km`                         | `47.37,8.55\\|100`                     |\n| WMO station   | `WMO{id}`                             | `WMO10501` *(needs station map)*      |\n| Shortcut      | name                                  | `Germany` *(needs shortcut map)*      |\n\nRectangle, bbox, and radius generate **adaptive grids** with automatic step-size selection, capped at `ELEVATION_MAX_REQUEST_POINTS` (default 10000) sample points.\n\n\u003e Tip: the `|` (pipe) character must be URL-encoded (`%7C`) or passed via `curl --get --data-urlencode`. Shells will otherwise interpret it as a pipeline.\n\n### Happy path — single point\n```sh\ncurl 'https://api.atmosoar.io/elevation/api/elevation?location=47.37,8.55'\n```\n```json\n{\n  \"location\": {\n    \"kind\": \"point\",\n    \"bbox\": { \"west\": 8.55, \"south\": 47.37, \"east\": 8.55, \"north\": 47.37 }\n  },\n  \"points\": [ { \"lat\": 47.37, \"lng\": 8.55, \"elevation\": 408.5 } ],\n  \"source\": \"copernicus-glo30\"\n}\n```\n\n### Happy path — polyline (Frankfurt → Berlin, 5 points)\n```sh\ncurl 'https://api.atmosoar.io/elevation/api/elevation?location=50,8,52,13,5'\n```\n\n### Happy path — bounding box (adaptive grid)\nKeep the span within the tile cap of the tier that serves it (in production 8 tiles on GLO-30 and 32 on GLO-90, which serves wide boxes). `bbox:6,47,10,51` touches 25 1° tiles; a wider box such as `bbox:5,47,15,55` (99 tiles) returns a 400 reduce-area.\n```sh\ncurl 'https://api.atmosoar.io/elevation/api/elevation?location=bbox:6,47,10,51'\n```\n\n### Happy path — 100 km radius around Zurich (pipe URL-encoded)\n```sh\ncurl --get 'https://api.atmosoar.io/elevation/api/elevation' \\\n  --data-urlencode 'location=47.37,8.55|100'\n```\n\n### Bad paths\n- **400 `MISSING_REQUIRED_FIELD`** — `location` query parameter omitted or empty.\n- **400 `INVALID_LOCATION`** — the parser cannot interpret the spec (e.g. `location=foo`, malformed bbox, fewer than 2 polyline endpoints, a polyline over 2000 points, a radius over 200 km, out-of-range coordinates, unknown shortcut/WMO id when no map is configured).\n- **400 `VALIDATION_ERROR`** — generated grid would exceed `ELEVATION_MAX_REQUEST_POINTS`, or the area spans more than the tile cap of the serving source (tiered mode: the tier's own cap, clamped by a global ceiling — in production 8 on GLO-30, 32 on GLO-90; single-source mode: `ELEVATION_MAX_REQUEST_TILES`, default 64): `\"location spans N DEM tiles (max M …); reduce the area or zoom in\"`. Reduce the area or zoom in.\n- **504 `UPSTREAM_ERROR`** — a within-cap request could not be assembled within `ELEVATION_REQUEST_TIMEOUT` (default 25s). The DEM source is healthy; the request was too large for the time budget — reduce the area or retry once tiles are cached. Always a structured body, never a bare/empty 502.\n- **502 `UPSTREAM_ERROR`** — *every* sample point failed and the DEM source itself failed (`UNAVAILABLE`). An area that is entirely out of coverage (e.g. open ocean, where Copernicus publishes no tiles) is a 200 with per-point `OUT_OF_COVERAGE` errors. A partial failure returns 200 with per-point `error` objects — see partial success below.\n- **500 `INTERNAL_ERROR`** — unexpected server error.\n\n### Partial success\nArea/list queries that straddle the edge of coverage return **200**: points that resolve carry an `elevation`; points that don't carry no `elevation` and an `error` object (`code` ∈ `OUT_OF_COVERAGE` | `NO_DATA` | `UNAVAILABLE`). Only when *all* points fail and the source is unavailable does the request return 502.\n\n### Example error response (`INVALID_LOCATION`)\n```json\n{\n  \"httpStatus\": 400,\n  \"code\": \"INVALID_LOCATION\",\n  \"message\": \"invalid location \\\"WMO99999\\\": no station map configured\",\n  \"errors\": [\n    { \"code\": \"INVALID_LOCATION\", \"field\": \"location\", \"message\": \"no station map configured\" }\n  ]\n}\n```","operationId":"queryElevationByLocation","parameters":[{"name":"location","in":"query","description":"Location spec — point, list, polyline, rectangle, bbox or radius (WMO id and shortcut name are planned via the gateway). Required: a 400 MISSING_REQUIRED_FIELD is returned when omitted or empty.","schema":{"type":"string","description":"Location spec — point, list, polyline, rectangle, bbox or radius (WMO id and shortcut name are planned via the gateway). Required: a 400 MISSING_REQUIRED_FIELD is returned when omitted or empty.","example":"47.37,8.55"}}],"responses":{"200":{"description":"Sampled elevations plus parsed location metadata."},"400":{"description":"Missing, malformed, or oversized location (point or tile cap).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"502":{"description":"DEM source unavailable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"504":{"description":"Request exceeded the time budget; reduce the area or retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}}}}},"/elevation/api/elevation/cluster":{"post":{"tags":["elevation"],"summary":"Get elevations for a batch of locations","description":"Batch elevation lookup for an explicit list of points. Use this when you have a pre-computed set of coordinates (e.g. waypoints, sensor sites, flight plan fixes) and want a single round-trip rather than N point requests.\n\nEach entry may be supplied in either of two equivalent forms:\n- **Array form:** `[lat, lng]` or `[lat, lng, id]`\n- **Object form:** `{ \"lat\": ..., \"lng\": ... }` or `{ \"lat\": ..., \"lng\": ..., \"id\": \"...\" }`\n\nForms can be mixed within a single request. Optional `id` is echoed back per result so callers can correlate without relying on input order. Responses preserve input order regardless of internal tile-sorting.\n\nRequests are chunked internally in groups of `ELEVATION_MAX_CLUSTER_SIZE` (default **500**) to bound per-chunk memory; clients do not need to split. The hard per-request cap is `ELEVATION_MAX_REQUEST_POINTS` (default **10000**).\n\n### Happy path — array form\n```sh\ncurl -X POST 'https://api.atmosoar.io/elevation/api/elevation/cluster' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"locations\":[[47.37,8.55,\"zurich\"],[52.52,13.40,\"berlin\"]]}'\n```\n```json\n{\n  \"elevations\": [\n    { \"lat\": 47.37, \"lng\": 8.55,  \"elevation\": 408.5, \"id\": \"zurich\" },\n    { \"lat\": 52.52, \"lng\": 13.40, \"elevation\": 34.0,  \"id\": \"berlin\" }\n  ]\n}\n```\n\n### Happy path — object form (mixed, no id)\n```sh\ncurl -X POST 'https://api.atmosoar.io/elevation/api/elevation/cluster' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"locations\":[{\"lat\":47.37,\"lng\":8.55,\"id\":\"a\"},{\"lat\":52.52,\"lng\":13.40}]}'\n```\n\n### Bad paths\n- **400 `VALIDATION_ERROR`** — body is not valid JSON, or `locations[i]` is malformed (e.g. array with \u003c2 entries, missing `lat`/`lng`, non-numeric coordinate).\n- **400 `MISSING_REQUIRED_FIELD`** — body is `{}` or omits the `locations` field.\n- **400 `VALIDATION_ERROR`** — batch exceeds the hard cap `ELEVATION_MAX_REQUEST_POINTS` (default 10000): `\"cluster size exceeds maximum of 10000\"`.\n- **400 `INVALID_LOCATION`** — at least one entry has out-of-range coordinates; the `errors` array points to `locations[i]`.\n- **400 `VALIDATION_ERROR`** — the points span more than the per-source tile cap (tiered mode: the tier's cap clamped by a global ceiling, in production 8 on GLO-30 and 32 on GLO-90; single-source mode: `ELEVATION_MAX_REQUEST_TILES`, default 64): `\"request spans N DEM tiles (max M …); reduce the area\"`.\n- **504 `UPSTREAM_ERROR`** — the batch could not be assembled within `ELEVATION_REQUEST_TIMEOUT` (default 25s). Structured body, never a bare 502 — reduce the batch span or retry once tiles are cached.\n- **502 `UPSTREAM_ERROR`** — *every* point failed to resolve and the DEM source itself failed (`UNAVAILABLE`). All points out of coverage (e.g. open ocean) is a 200 with per-point `OUT_OF_COVERAGE` errors. A partial failure does NOT fail the request — see partial success below.\n\n### Partial success\nIf some points resolve and others don't, the response is **200**: each failed entry carries no `elevation` and an `error` object instead (`code` ∈ `OUT_OF_COVERAGE` | `NO_DATA` | `UNAVAILABLE`), while resolved entries carry an `elevation`. Only when *all* points fail and the source is unavailable does the request return 502.\n```json\n{ \"elevations\": [\n  { \"lat\": 45, \"lng\": -5, \"error\": { \"code\": \"OUT_OF_COVERAGE\", \"message\": \"no elevation data covers this coordinate\" } },\n  { \"lat\": 45, \"lng\": 5,  \"elevation\": 100 }\n] }\n```\n\n### Example error response\n```json\n{\n  \"httpStatus\": 400,\n  \"code\": \"INVALID_LOCATION\",\n  \"message\": \"location[2]: latitude 91.0 out of range\",\n  \"errors\": [\n    { \"code\": \"INVALID_LOCATION\", \"field\": \"locations[2]\", \"message\": \"latitude 91.0 out of range\" }\n  ]\n}\n```","operationId":"clusterElevation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClusterElevationInput"}}}},"responses":{"200":{"description":"Elevations in input order."},"400":{"description":"Invalid payload, oversized batch, malformed location, or tile cap exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"413":{"description":"Request body exceeds ELEVATION_MAX_BODY_BYTES (default 2 MiB).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"502":{"description":"DEM source unavailable for one or more tiles.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}},"504":{"description":"Batch exceeded the time budget; reduce the span or retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HttputilsAPIError"}}}}}}},"/elevation/healthz":{"get":{"tags":["ops"],"summary":"Health check","operationId":"healthz","responses":{"200":{"description":"Service is healthy; body is {\"status\":\"ok\"}."}}}}},"components":{"schemas":{"ClusterElevationInput":{"type":"object","properties":{"locations":{"type":"array","items":{},"description":"Array of locations (max ELEVATION_MAX_REQUEST_POINTS, default 10000; chunked internally in groups of ELEVATION_MAX_CLUSTER_SIZE, default 500). Each entry is either the array form [lat,lng] or [lat,lng,id], or the object form {\"lat\":...,\"lng\":...,\"id\":\"...\"}. Forms may be mixed."}}},"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":"elevation","description":"Elevation lookups."},{"name":"ops","description":"Operational endpoints (health)."}]}