# Hydrata - ANUGA Flood Simulation

> Run ANUGA flood simulations, track progress, and retrieve results on Hydrata Cloud.

- name: com.hydrata/hydrata-mcp-server
- version: 0.2.0
- connection class: R0 (autonomous)
- trust: 97/100
- quality: 73/100 (needs work)
- owner: not claimed
- descriptor: https://api.protogrid.dev/v1/servers/com.hydrata%2Fhydrata-mcp-server
- tools: https://api.protogrid.dev/v1/servers/com.hydrata%2Fhydrata-mcp-server/tools

**An agent can connect right now, no human step.** Remote endpoint, no authentication, reachable on the last probe.

## Connection (mcpServers)

```json
{
  "mcpServers": {
    "hydrata-mcp-server": {
      "type": "http",
      "url": "https://hydrata.com/mcp/"
    }
  }
}
```


## Trust

- hygiene: 100
- liveness: 100
- freshness: 100
- provenance: 90
- drivers: +repository +namespace-matches-repo +dns-namespace +reachable +uptime-100% +updated-29d-ago
- Derived from observable signals (official registry feed and our own credential-free probes); no code audit performed.

## Quality 73/100 (needs work)

### protocol: 88 (weight 25)

- pass `protocol.modern`: supports 2026-07-28 (server/discover)
- pass `protocol.stateless`: answers without a session
- pass `protocol.transport`: streamable HTTP
- warn `protocol.list_ttl`: tools/list declares ttlMs 0, so clients re-fetch it on every use

### authorization: n/a (weight 15)

- n/a `auth.prm`, `auth.as_metadata`, `auth.cimd`: no remote uses OAuth
- n/a `auth.secret_in_url`: no templated URL

### tool hygiene: 86 (weight 30)

- pass `tools.descriptions`: every tool has a description
- warn `tools.description_length`: 7 tools have descriptions over 1,024 characters, which crowds the context
- pass `tools.schemas`: every tool has an object input schema
- pass `tools.annotations`: 28 of 28 tools declare readOnlyHint or destructiveHint
- pass `tools.directory_hints`: every tool declares readOnlyHint, destructiveHint, idempotentHint and openWorldHint
- warn `tools.token_cost`: about 10,950 tokens to load every tool
- pass `tools.api_dump`: tools are not a one-to-one API dump

### stability: 25 (weight 15)

- warn `stability.changes`: 39 tool changes in 30 days
- fail `stability.rug_pull`: 2 tools kept their name but changed most of their description in 30 days; review before trusting them

### dependencies: n/a (weight 15)

- n/a `deps.known_vulns`, `deps.mcp_sdk_version`, `deps.resolvable`: no npm or PyPI package

- tools: 28, about 10,950 tokens to load them all
- tool set hash: f9c4ddfde2550a2ab19a1f3929312372
- tools last changed: 2026-10-19T07:04:32.401Z
- badge: [![protogrid quality](https://protogrid.dev/badge/com.hydrata/hydrata-mcp-server.svg)](https://protogrid.dev/servers/com.hydrata/hydrata-mcp-server)
- Checks run on what our credential-free, read-only probes observe; tools are never called and no code audit is performed.

### Tool changes

- 2026-10-19 `set_comparison_tolerance` added
  - Save a project's default tolerance for comparing runs with flood observations. Needs Editor (or above). Every later get_observation_comparison (and the map's Compare with observations tab) uses it unless a tolerance is passed. Only save it when the user asks to change the project's default; to show the figures at another tolerance, pass `tolerance` to get_observation_comparison.
- 2026-10-19 `set_observation_exclusion` added
  - Exclude a flood observation from ONE run's comparison with observations, or include it again. Needs Editor (or above) on the project. The exclusion is logged with the user and reason (nothing is overwritten) and keyed on (run, layer, fid), so it survives a recompute. The figures change at once: `headline_after` is the run's headline with the change applied. To exclude, the user must give the reason: if they have not, ASK them; do not make one up.
- 2026-10-19 `get_observation_comparison` added
  - Compare a run with the project's flood observations: how well does it match them? Returns `headline` (the figures to tell the user, e.g. "47 observations · 31 within ±0.30 m (66%) · bias -0.12 m · RMSE 0.35 m · 1 excluded"), `headline_numbers` (the same as numbers), `tolerance_m` (used) and `default_tolerance_m` (the project's saved one), `statistics` (the API's summary: n, within, within_pct, bias_m, rmse_m, weighted_rmse_m, mae_m, max_abs_m, excluded, not_scored by reason, gauges), `gauges` (Gauge records: peak error, timing error, NSE), `datum_checks` (warn the user only when `warning` is true: both datums known and different; `not_recorded` = the terrain has no recorded datum, stated in `note`, not a warning) and one row per observation: observed_m / modelled_m / residual_m (modelled - observed, + = model high), within, scored / not_scored_reason, match (`outside_run_window` = not this flood), excluded + exclusion {reason, by, at}. `sampling` counts how the modelled values were read: `centroid` (the peak of the mesh triangle the observation sits in), `nearest_node_fallback` (an older run without that peak: the nearest mesh node's value, tell the user) and `nearest_node` (just outside the mesh); each row's `sample.method` says which. There is no pass or fail: report the figures. A run that is not historic has none (`has_comparison` false) — do not retry it.
- 2026-10-19 `add_observation_photo_link` added
  - Attach a photo to a flood observation as a reference LINK (never a file). MCP never uploads photo bytes. A local path, file:// URL or data: URI is refused: to upload a photo file, open the observation in the map and use the photo strip on its form (JPEG, PNG or HEIC, 25 MB, 10 per observation). The link counts toward the 10.
- 2026-10-19 `delete_observation` added
  - Delete one flood observation and its photos (cannot be undone). DELETEs /projects/<id>/flood-observations/<layer>/observations/<fid>/; a missing fid is an error (404), not a silent success.
- 2026-10-19 `update_observation` added
  - Edit one flood observation (PATCH; only the fields given change). The API checks the stored observation merged with `changes` and refuses (400, every bad field named) rather than store a half-valid one. Photos are not fields: attach a link with add_observation_photo_link.
- 2026-10-19 `add_observations` added
  - Add flood observations (Flood mark, Timed level, Gauge record, Dry point) to a layer. POSTs each observation to /projects/<id>/flood-observations/<layer>/observations/ in order. The API validates every field: a row it refuses (400) is listed in `rejected` with its `problems` and the rest still land; a refusal that is not about the row (403 not an Editor, 404, 409) stops the batch and is reported in `stopped`, with `not_sent_count`. Stages are on the LAYER's vertical datum. Photos are not part of an observation: attach a link with add_observation_photo_link.
- 2026-10-19 `list_observations` added
  - List a project's flood observation layers, or one layer's observations. Without layer_id: every layer (layer_id, title, dataset_alternate, vertical_datum, datum_offset_m, has_features). With layer_id: {layer, count, results} where each observation carries its `fid` (the key for update_observation / delete_observation), subtype + subtype_label, stage/depth, sigma, bound, the observed window in UTC, `datum` status (an `unknown` datum is not scored), `photo_count` and `problems` (fields drawn in the map that fail validation, reported, never fixed).
- 2026-10-19 `create_observation_layer` added
  - Create a flood observation layer in a project and wait for its map layer. A flood observation layer holds real flood evidence (Flood marks, Timed levels, Gauge records, Dry points) that every historic run is compared with (Compare with observations). It is project-level: it is never a scenario input. POSTs /projects/<id>/flood-observations/, then polls the layer until its GeoServer layer exists (`ready`), usually a few seconds; until then add_observations refuses. On `timed_out`, call list_observations later.
- 2026-10-15 `attach_input_layer` changed (description)
  - before: Attach a GeoJSON you uploaded to GeoNode as the project's boundary, friction, inflow, rainfall, building, mesh_region or culvert layer. The agent moves the bytes: first upload the GeoJSON yourself, with the same credential, to GeoNode's upload endpoint at the site origin: curl -sS -u <user>:<password> -F "base_file=@/path/rainfall.geojson" https://<site>/api/v2/uploads/upload/ → JSON with `execution_id`. Then call this tool with it. The tool polls GET /api/v2/resource-service/execution-status/<execution_id> (bounded by timeout_seconds; statuses ready → running → finished | failed), reads the new dataset's pk, and PATCHes it onto the project's DEFAULT row of that kind ('Boundary 01' … 'MeshRegion 01' — the six rows the terrain import seeds ~30 s after get_terrain reports ready; if the list is still empty the tool says so: run finalize_terrain_upload / wait for get_terrain first). kind `culvert` (Hydraulic Structures; one two-point line per culvert, Inlet first, then Outlet): no culvert row is seeded, so the tool creates 'Culvert 01' when the project has none and waits for its layer. It reads your culverts back and maps your attribute names onto the culvert fields — name, shape (box|circular), width_m, height_m, diameter_m, upstream_invert_m, downstream_invert_m, barrels (1-20), wall_thickness_m, entrance_type, material (concrete|corrugated_metal), manning_n, blockage_pct (Reduced-area blockage, percent), length_override_m, description — accepting common alternatives (width, diameter, label, us_invert, ds_invert, num_barrels, manning, blockage, length, shape 'pipe' = circular, …) and filling the defaults (barrels 1, wall 0.20 m, square_edge_headwall, concrete, manning_n from the material, blockage 0). Other attributes are kept. If anything changed it re-uploads the mapped culverts and attaches THAT layer (`mapped`: renamed, filled, source_dataset_pk). `culvert_problems` lists what the build will still refuse (typically missing inverts) — the user fixes those in the map. The culvert engine conveys flow through each culvert in a run (GPU lanes and localhost; the multi-core CPU lanes refuse a culvert scenario). `outcome` is exactly one of: `attached` (row_id, gn_layer, dataset_pk, dataset_alternate — the WFS typename — and every row id seen); `timed_out` (still importing or still creating the culvert layer: call again with the same arguments, except pass `retry_execution_id` as execution_id when the result carries one); `upload_failed` (the execution record, incl. its log, is returned verbatim — fix the file and upload again); `no_default_row`; `no_dataset` (finished but no resource was recorded). The PATCH is refused with a 400 if the dataset is not owned by you. Refused, with no request made: kind `breakline` — no REST create path exists for it at HEAD (TASK-3040 AC6), so attaching one would only pretend. Also refused without a request: an execution_id that is not the UUID the upload returned. One GeoJSON per kind: all of a kind's features travel in that one file. A rainfall polygon names its gauge in its `data` property — create that gauge with create_time_series under the exact same name.
  - after: Attach a GeoJSON you uploaded to GeoNode as the project's boundary, friction, inflow, rainfall, building, mesh_region or culvert layer. The agent moves the bytes: first upload the GeoJSON yourself, with the same credential, to GeoNode's upload endpoint at the site origin: curl -sS -u <user>:<password> -F "base_file=@/path/rainfall.geojson" https://<site>/api/v2/uploads/upload/ → JSON with `execution_id`. Then call this tool with it. The tool polls GET /api/v2/resource-service/execution-status/<execution_id> (bounded by timeout_seconds; statuses ready → running → finished | failed), reads the new dataset's pk, and PATCHes it onto the project's DEFAULT row of that kind ('Boundary 01' … 'MeshRegion 01' — the six rows the terrain import seeds ~30 s after get_terrain reports ready; if the list is still empty the tool says so: run finalize_terrain_upload / wait for get_terrain first). kind `culvert` (Hydraulic Structures; one two-point line per culvert, Inlet first, then Outlet): no culvert row is seeded, so the tool creates 'Culvert 01' when the project has none and waits for its layer. It reads your culverts back and maps your attribute names onto the culvert fields — name, shape (box|circular), width_m, height_m, diameter_m, upstream_invert_m, downstream_invert_m, barrels (1-20), wall_thickness_m, entrance_type, material (concrete|corrugated_metal), manning_n, blockage_pct (Reduced-area blockage, percent), length_override_m, description — accepting common alternatives (width, diameter, label, us_invert, ds_invert, num_barrels, manning, blockage, length, shape 'pipe' = circular, …) and filling the defaults (barrels 1, wall 0.20 m, square_edge_headwall, concrete, manning_n from the material, blockage 0). Other attributes are kept. If anything changed it re-uploads the mapped culverts and attaches THAT layer (`mapped`: renamed, filled, source_dataset_pk). `culvert_problems` lists what the build will still refuse (typically missing inverts) — the user fixes those in the map. `leftover_datasets` lists the datasets the attach left unreferenced (the raw upload when it was re-uploaded; the empty factory layer of a 'Culvert 01' created here), each {dataset_pk, name, reason}, with a `leftover_hint`; the tool deletes nothing — the user may delete them. The culvert engine conveys flow through each culvert in a run (GPU lanes and localhost; the multi-core CPU lanes refuse a culvert scenario). `outcome` is exactly one of: `attached` (row_id, gn_layer, dataset_pk, dataset_alternate — the WFS typename — and every row id seen); `timed_out` (still importing or still creating the culvert layer: call again with the same arguments, except pass `retry_execution_id` as execution_id when the result carries one); `upload_failed` (the execution record, incl. its log, is returned verbatim — fix the file and upload again); `no_default_row`; `no_dataset` (finished but no resource was recorded). The PATCH is refused with a 400 if the dataset is not owned by you. Refused, with no request made: kind `breakline` — no REST create path exists for it at HEAD (TASK-3040 AC6), so attaching one would only pretend. Also refused without a request: an execution_id that is not the UUID the upload returned. One GeoJSON per kind: all of a kind's features travel in that one file. A rainfall polygon names its gauge in its `data` property — create that gauge with create_time_series under the exact same name.

Full history: https://api.protogrid.dev/v1/servers/com.hydrata%2Fhydrata-mcp-server/changes

## Remotes

- https://hydrata.com/mcp/ (streamable-http, auth none, reachable true, uptime30d 1)

## Packages

_none_

## Tools (28)

- `add_observation_photo_link`: Attach a photo to a flood observation as a reference LINK (never a file).

MCP never uploads photo bytes. A local path, file:// URL or data: URI is
refused: to upload a photo file, open the observation in the map and use the
photo strip on its form (JPEG, PNG or HEIC, 25 MB, 10 per observation).
The link counts toward the 10.
- `add_observations`: Add flood observations (Flood mark, Timed level, Gauge record, Dry point) to a layer.

POSTs each observation to /projects/<id>/flood-observations/<layer>/observations/
in order. The API validates every field: a row it refuses (400) is listed in
`rejected` with its `problems` and the rest still land; a refusal that is not
about the row (403 not an Editor, 404, 409) stops the batch and is reported in
`stopped`, with `not_sent_count`. Stages are on the LAYER's vertical datum.
Photos are not part of an observation: attach a link with add_observation_photo_link.
- `attach_input_layer`: Attach a GeoJSON you uploaded to GeoNode as the project's boundary, friction, inflow, rainfall, building, mesh_region or culvert layer.

The agent moves the bytes: first upload the GeoJSON yourself, with the
same credential, to GeoNode's upload endpoint at the site origin:

    curl -sS -u <user>:<password> -F "base_file=@/path/rainfall.geojson" https://<site>/api/v2/uploads/upload/

→ JSON with `execution_id`. Then call this tool with it. The tool polls
GET /api/v2/resource-service/execution-status/<execution_id> (bounded by
timeout_seconds; statuses ready → running → finished | failed), reads the
new dataset's pk, and PATCHes it onto the project's DEFAULT row of that
kind ('Boundary 01' … 'MeshRegion 01' — the six rows the terrain import
seeds ~30 s after get_terrain reports ready; if the list is still empty
the tool says so: run finalize_terrain_upload / wait for get_terrain first).

kind `culvert` (Hydraulic Structures; one two-point line per culvert,
Inlet first, then Outlet): no culvert row is seeded, so the tool creates
'Culvert 01' when the project has none and waits for its layer. It reads
your culverts back and maps your attribute names onto the culvert fields
— name, shape (box|circular), width_m, height_m, diameter_m,
upstream_invert_m, downstream_invert_m, barrels (1-20), wall_thickness_m,
entrance_type, material (concrete|corrugated_metal), manning_n,
blockage_pct (Reduced-area blockage, percent), length_override_m,
description — accepting common alternatives (width, diameter, label,
us_invert, ds_invert, num_barrels, manning, blockage, length, shape
'pipe' = circular, …) and filling the defaults (barrels 1, wall 0.20 m,
square_edge_headwall, concrete, manning_n from the material, blockage
0). Other attributes are kept. If anything changed it re-uploads the
mapped culverts and attaches THAT layer (`mapped`: renamed, filled,
source_dataset_pk). `culvert_problems` lists what the build will still
refuse (typically missing inverts) — the user fixes those in the map.
`leftover_datasets` lists the datasets the attach left unreferenced (the
raw upload when it was re-uploaded; the empty factory layer of a
'Culvert 01' created here), each {dataset_pk, name, reason}, with a
`leftover_hint`; the tool deletes nothing — the user may delete them.
The culvert engine conveys flow through each culvert in a run (GPU
lanes and localhost; the multi-core CPU lanes refuse a culvert scenario).

`outcome` is exactly one of: `attached` (row_id, gn_layer, dataset_pk,
dataset_alternate — the WFS typename — and every row id seen);
`timed_out` (still importing or still creating the culvert layer: call
again with the same arguments, except pass `retry_execution_id` as
execution_id when the result carries one); `upload_failed` (the
execution record, incl. its log, is returned verbatim — fix the file and
upload again); `no_default_row`; `no_dataset` (finished but no resource
was recorded). The PATCH is refused with a 400 if the dataset is not
owned by you.

Refused, with no request made: kind `breakline` — no REST create path
exists for it at HEAD (TASK-3040 AC6), so attaching one would only
pretend. Also refused without a request: an execution_id that is not the
UUID the upload returned.
One GeoJSON per kind: all of a kind's features travel in that one file. A
rainfall polygon names its gauge in its `data` property — create that
gauge with create_time_series under the exact same name.
- `build_scenario`: Build a scenario's package (mesh + inputs) after showing what it will cost, and poll until built.

Order of operations, so the number is shown before anything is spent:

1. GET the scenario detail. A record with no `boundary`/`computed_status`
   keys is a non-member's read of a public project → refused: a build
   needs the EDITOR role.
2. Re-call checks on `latest_run` (a re-POST is NOT deduplicated after
   `built`/`complete` — it would dispatch a duplicate build): no run →
   proceed; run `created`/`building` → resume polling, no POST; run
   `built`/`queued`/`computing`/`processing`/`complete` with
   `latest_run_is_valid` not false → return that state, no POST unless
   rebuild=true; run `error`/`cancelled`, or `latest_run_is_valid` false
   (the scenario was edited since the build) → proceed.
3. Spend gates, each a refusal with `outcome: "refused"` and no POST: the
   scenario has no boundary or its boundary row has no features (the
   server would admit the build and fail it in make_package); the
   estimate is None (resolution is 0/unset); the estimate is above
   100,000 triangles and confirm is not true — the refusal states the
   number; re-call with confirm=true to proceed. An estimate of 0 over a
   boundary WITH features is buildable and is reported, not refused.
4. POST /projects/<id>/scenarios/<pk>/build/. Every 4xx comes back
   verbatim as a normal result with `http_status`: 422 MESH_TOO_LARGE
   (`error_code`, `estimate`, `ceiling`, `detail` — the server's hard
   ceiling; coarsen `resolution` or shrink the boundary; no run was
   created), 409 COMPUTE_TARGET_UNAVAILABLE, 400/403/404. A 409 WITHOUT
   an error_code is the dedup body ({status, run_id, detail}: a build is
   already in flight) — the tool resumes polling that run.
5. On 202 poll the detail's `computed_status` (created → building →
   built | error), bounded by timeout_seconds.

Returns a COMPACT state, never the whole detail: `outcome` (built, error,
cancelled, complete/queued/computing/processing for a run past the build,
timed_out, or refused), `computed_status`, `mesh_triangle_count_estimate`,
`posted`, the POST's `build` body + `http_status` when one was made, and
the latest run's `run_id`, `run_status`, `error_message`, `user_message`,
`mesh_triangle_count`. `timed_out` is normal (make_package re-downloads
the terrain from S3 every build; minutes): call again with the same
arguments — step 2 resumes polling the same run, it never POSTs twice —
or cancel_run(run_id) if the run is stuck in `created` with no worker.
`error` is terminal: `error_message` says why (fix the inputs, then call
again — an errored latest run is rebuilt).

Units: `resolution` — on the SCENARIO and on every MeshRegion FEATURE — is
a LENGTH in metres; ANUGA maximum_triangle_area = resolution²/2 (FloatField
default 100, not nullable; 0 makes the estimate None), and the estimate
prices both the same way (TASK-3186). The smallest value becomes the raster
cell size. A MeshRegion feature must carry `resolution_units = m` (features
drawn in the map are marked automatically); an unmarked one makes the build
answer 422 MESH_REGION_UNITS_UNMARKED, naming the conversion an operator runs.
- `cancel_run`: Cancel an in-flight simulation run.

Works on runs in built, queued, or computing status. Cleans up
compute resources (terminates EC2 instance, Celery task, or Batch job).

Returns 409 if the run is already in a terminal state
(complete, cancelled, or error).
- `create_observation_layer`: Create a flood observation layer in a project and wait for its map layer.

A flood observation layer holds real flood evidence (Flood marks, Timed
levels, Gauge records, Dry points) that every historic run is compared
with (Compare with observations). It is project-level: it is never a scenario input.
POSTs /projects/<id>/flood-observations/, then polls the layer until its
GeoServer layer exists (`ready`), usually a few seconds; until then
add_observations refuses. On `timed_out`, call list_observations later.
- `create_project`: Create a new ANUGA project. Returns the project record including its id, base_map and ui_url.

The caller's own account becomes the owner. `projection` is the projected
CRS every scenario in the project is meshed and run in — pick the UTM zone
covering the site. The project's map is created alongside it; `ui_url`
opens it — give it to the user. Next step for a new project:
presign_terrain_upload.
- `create_scenario`: Create a DRAFT scenario (no build, no run) and report its mesh-triangle estimate.

POSTs /projects/<id>/scenarios/ with the write fields, then GETs the
scenario detail — the create response carries NO estimate; the detail's
`mesh_triangle_count_estimate` (+ `_breakdown`) does. Returns a compact
record (the same fields get_scenario answers with): id, name, the FK ids
as stored, resolution, duration, `computed_status` (`created` = no run
yet), the estimate and its breakdown, the price (`compute_cost_estimate`
in USD, `vcpu_hours_estimate`), `inflow_anchor_mismatch`, and
http_status. Next step: build_scenario (which asks for
confirm=true above 100,000 triangles). Nothing is meshed or queued here.

Units: `resolution` — on the SCENARIO and on every MeshRegion FEATURE — is
a LENGTH in metres; ANUGA maximum_triangle_area = resolution²/2 (FloatField
default 100, not nullable; 0 makes the estimate None), and the estimate
prices both the same way (TASK-3186). The smallest value becomes the raster
cell size. A MeshRegion feature must carry `resolution_units = m` (features
drawn in the map are marked automatically); an unmarked one makes the build
answer 422 MESH_REGION_UNITS_UNMARKED, naming the conversion an operator runs.

The server does NOT validate that the FK ids belong to `project_id` —
pass the row ids attach_input_layer / get_terrain returned for THIS
project. Every FK is nullable at create time, and the build fails later
on a scenario with no terrain, or with neither an inflow nor a rainfall.
A `boundary` whose row has no features (the default 'Boundary 01' before
attach_input_layer) is accepted here and refused by build_scenario. The
estimate is None when resolution is 0; an estimate of 0 with a real
boundary is a very coarse mesh, not an error.
- `create_time_series`: Create a time series (rain gauge, hydrograph, tide/stage) in a project.

POSTs /projects/<id>/time-series/ with `series_type` and `units` as
top-level fields. `series_type` is checked against the four choices and
`data` against the {"rowData": [{timestamp, value}, ...]} shape before any
request is made — the API answers 500 (not 400) to a malformed row.
Returns a compact record — id, name, series_type, units, timezone,
row_count, http_status — not the echoed rows; fetch the full row with the
REST API (GET /projects/<id>/time-series/<id>/) if you need to round-trip it.
A rainfall polygon references its gauge by the series NAME, so create the
gauges with the exact names the rainfall GeoJSON's features carry.
- `delete_observation`: Delete one flood observation and its photos (cannot be undone).

DELETEs /projects/<id>/flood-observations/<layer>/observations/<fid>/; a
missing fid is an error (404), not a silent success.
- `finalize_terrain_upload`: Step 2 of a terrain import: register the PUT GeoTIFF as a Terrain and start the import.

Call this only after the presigned PUT returned 200. Creates the Terrain row
(status `creating`) and queues the import chain — reproject to UTM, publish
the layer + hillshade, style — which also seeds the project's six default
boundary, friction, inflow, rainfall, building and mesh-region rows.
Returns 202 with the terrain record; keep its `id` for get_terrain. A 400 UPLOAD_NOT_FOUND
means no object is at `staging_key`: the PUT did not land (check its status
code and Content-Type) — do not retry finalize until it has.
- `get_observation_comparison`: Compare a run with the project's flood observations: how well does it match them?

Returns `headline` (the figures to tell the user, e.g. "47 observations · 31
within ±0.30 m (66%) · bias -0.12 m · RMSE 0.35 m · 1 excluded"),
`headline_numbers` (the same as numbers), `tolerance_m` (used) and
`default_tolerance_m` (the project's saved one), `statistics`
(the API's summary: n, within, within_pct, bias_m, rmse_m, weighted_rmse_m, mae_m, max_abs_m,
excluded, not_scored by reason, gauges), `gauges` (Gauge records: peak error,
timing error, NSE), `datum_checks` (warn the user only when `warning` is true:
both datums known and different; `not_recorded` = the terrain has no
recorded datum, stated in `note`, not a warning)
and one row per observation: observed_m / modelled_m / residual_m (modelled -
observed, + = model high), within, scored / not_scored_reason, match
(`outside_run_window` = not this flood), excluded + exclusion {reason, by, at}.
`sampling` counts how the modelled values were read: `centroid` (the peak
of the mesh triangle the observation sits in), `nearest_node_fallback` (an
older run without that peak: the nearest mesh node's value, tell the user)
and `nearest_node` (just outside the mesh); each row's `sample.method` says
which.
There is no pass or fail: report the figures. A run that is not historic has
none (`has_comparison` false) — do not retry it.
- `get_project`: Get one ANUGA project: id, name, projection, visibility, your role, its base map — and the link to it.

Returns the project record (`id`, `name`, `projection` (EPSG code),
`simple_view_config`, `visibility`, `owner_username`, `my_role`,
`base_map`) plus `ui_url`: the link that opens the project's map, for the
user (`<site>/catalogue/#/map/<base_map>`; null while a new project's map
is still being created). Scenarios are NOT listed here — use get_scenario
with a scenario id.
- `get_run`: Get full details of a simulation run including timing and results.

Returns the complete run record: status, progress, timing (start/end
timestamps, duration), compute details (backend, instance type, cost),
mesh info, error messages, and result log. Use get_run_status for
lightweight polling; use this for final results.

The presigned `s3_*_url` download links are ELIDED — each is a 6-hour
capability and no tool relays one. The results stay viewable through the
`gn_layer_*` entries (published WMS layers on the project's map).
- `get_run_status`: Lightweight status check for a simulation run (fast, <50ms).

Use this for polling instead of get_run. Returns only: id, status,
`phase`, progress_pct (0-100), eta_seconds, error_message, and
compute_backend.

`phase` is never empty: it is `status`, except `preparing_inputs` while a
`computing` run has no progress yet (it is downloading its input package
and building the domain — minutes on a big model; this is NOT a stall).
Poll every 5-10 seconds. Terminal states: complete, error, cancelled.
- `get_scenario`: Get a scenario's COMPACT state: its `computed_status`, inputs, estimate and price.

Returns a compact record, never the raw ~50 KB detail: `scenario_id`,
`computed_status`, `mesh_triangle_count_estimate` and its `_breakdown`,
`latest_run_is_valid`, the latest run's `run_id`, `run_status`,
`error_message`, `user_message`, `status_detail` and `mesh_triangle_count`
— plus what the scenario IS and what it will cost: `name`, `description`,
`project`, `resolution`, `duration`, the input row ids (`terrain`,
`boundary`, `friction`, `inflow`, `rainfall`, `building`, `mesh_region`, `culvert`),
`compute_cost_estimate` (USD), `vcpu_hours_estimate`, and
`inflow_anchor_mismatch` (null unless an inflow series starts after the
model does, whose first value is then held backwards).

The `computed_status` field (there is NO `status` key on a scenario) is
derived from the latest run and will be one of: created, building, built,
queued, computing, processing, complete, error, or cancelled — `created`
also means no run exists yet. A scenario must be `built` before it can be
run. `latest_run_is_valid` is false after any edit since the last build.
A non-member reading a public project's scenario gets a reduced record:
the fields the server withholds (the input ids, the price,
`inflow_anchor_mismatch`) are ABSENT here rather than reported as null —
"withheld" is not "no terrain, no cost" — while the run-state keys,
`computed_status` among them, read null.

No presigned S3 URL is returned by any read tool (each is a 6-hour
capability): results stay viewable through a run's `gn_layer_*` WMS
entries — use get_run for the full run record.
- `get_terrain`: Poll a terrain's import until it is `ready` (or `error`), bounded by timeout_seconds.

Returns `outcome` — exactly one of `ready`, `error`, `timed_out`, `not_found`
(the project has no terrain yet; an unknown terrain_id is an API 404 error
instead) — plus the last `status` seen (creating → styling → ready | error)
and `phase` (that status, or `not_found`; never empty),
`polls`, `elapsed_seconds` and the full `terrain` record (its `gn_layer` is
the published elevation dataset pk once ready). An import takes minutes to
tens of minutes (the SAME 32 MB 1 m DEM measured 4.5 min once and 26 min
once — the worker's S3 download speed dominates), so `timed_out` is normal
and NOT a failure: keep calling with the same arguments while `status` is
still `creating`/`styling`; several calls in a row is expected. `error` is
terminal — the import failed and no default input rows were seeded; upload
a corrected GeoTIFF as a new terrain.

`ready` is written by the import task; the project's default input rows
(Boundary 01, Friction 01, Inflow 01, Rainfall 01, Building 01,
MeshRegion 01, one GeoNode layer each) are seeded by the NEXT task in the
chain and appear over the following ~30 s. Poll the input-layer list until
it is non-empty before attaching your own layer to a default row.
- `list_inputs`: List every input layer row of a project, per kind, with whether it has features.

For each of boundary, friction, inflow, rainfall, building, mesh_region and culvert:
the rows (`row_id`, `title`, `gn_layer` — the dataset pk — and
`dataset_alternate`, the `workspace:name` WFS typename) and `has_features`.
`has_features` comes from the API for boundary, rainfall, mesh_region and culvert;
for friction, inflow and building (whose records do not carry it) it is a
WFS feature count, at most 24 lookups per call — past that, or when a
lookup fails, it is null (unknown, not false). A row with no `gn_layer`
has no layer yet (`has_features` false). Use the row ids with
create_scenario; this is the listing primitive — no need to probe files.
- `list_observations`: List a project's flood observation layers, or one layer's observations.

Without layer_id: every layer (layer_id, title, dataset_alternate, vertical_datum,
datum_offset_m, has_features). With layer_id: {layer, count, results} where each
observation carries its `fid` (the key for update_observation / delete_observation),
subtype + subtype_label, stage/depth, sigma, bound, the observed window in UTC,
`datum` status (an `unknown` datum is not scored), `photo_count` and `problems`
(fields drawn in the map that fail validation, reported, never fixed).
- `list_projects`: List ANUGA simulation projects accessible to the authenticated user.

Returns a paginated list of projects with their names, projections,
and base map references.
- `list_runs`: List all simulation runs across all scenarios in a project.

Returns a paginated list of runs. Optionally filter by status
to find active, completed, or failed runs. Each row's presigned
`s3_*_url` download links are elided (6-hour capabilities).
- `list_time_series`: List a project's time series (rain gauges, hydrographs, tides) — never their rows.

Each series: `id`, `name` (a rainfall polygon binds to its gauge by this
exact name), `series_type`, `units`, `timezone`, `row_count`,
`first_timestamp` and `last_timestamp`. The data rows themselves are
dropped (a project's list is megabytes); fetch one series with the REST API
(GET /projects/<id>/time-series/<id>/) if you need its values.
- `presign_terrain_upload`: Step 1 of a terrain import: get a presigned S3 PUT URL for a GeoTIFF DEM.

The agent moves the bytes itself — no tool accepts file contents. After this
call, PUT the file straight to `upload_url`, sending the SAME Content-Type
you passed here (it is part of the signature; a mismatch is a 403
SignatureDoesNotMatch before it is anything else):

    curl -sS -X PUT -H "Content-Type: image/tiff" --upload-file /path/dem.tif "$UPLOAD_URL"

then call finalize_terrain_upload with the returned staging_key and
process_id. Nothing exists in Hydrata until finalize; the URL expires after
`expires_in` seconds (3600).

Platform guidance: keep the terrain extent within 5° across and 40,000 km²
(200 × 200 km). The API enforces that cap on its bbox-fetch path; an
uploaded GeoTIFF is only rejected above 5 GiB, but a larger DEM will not
mesh or run well. The GeoTIFF must carry a CRS; the import reprojects it to
the site's UTM zone and builds a hillshade. Fallback when a presigned PUT is
impossible: multipart-POST the file with the same credential to
/api/v2/anuga/projects/<id>/terrain/upload/ (form field `file`, optional
`title`), which creates the terrain in one step — then get_terrain.
- `retry_run`: Retry a failed simulation run.

Resets an errored run back to 'created' status and triggers a new
package build. The same run ID is reused. Only valid when
status is 'error'. Returns 409 for any other state.
- `set_comparison_tolerance`: Save a project's default tolerance for comparing runs with flood observations.

Needs Editor (or above). Every later get_observation_comparison (and the map's
Compare with observations tab) uses it unless a tolerance is passed. Only
save it when the user asks to change the project's default; to show the
figures at another tolerance, pass `tolerance` to get_observation_comparison.
- `set_observation_exclusion`: Exclude a flood observation from ONE run's comparison with observations, or include it again.

Needs Editor (or above) on the project. The exclusion is logged with the user
and reason (nothing is overwritten) and keyed on (run, layer, fid), so it
survives a recompute. The figures change at once: `headline_after` is the
run's headline with the change applied. To exclude, the user must give the
reason: if they have not, ASK them; do not make one up.
- `start_simulation`: Start a flood simulation run for a built scenario.

The scenario must be in 'built' status. Returns 202 with the new run.
The run transitions through: built → queued → computing → processing → complete.

After starting, poll get_run_status to track progress. Returns 409
if the scenario is not in the correct state. The returned run record has
its presigned `s3_*_url` links elided (6-hour capabilities).
- `update_observation`: Edit one flood observation (PATCH; only the fields given change).

The API checks the stored observation merged with `changes` and refuses
(400, every bad field named) rather than store a half-valid one. Photos are
not fields: attach a link with add_observation_photo_link.
