API reference¶
The application registers 47 routes, of which 34 are under /api/. This page
documents that /api/ surface: what each endpoint returns, what it accepts, and
who may call it. The remaining 13 are the pages, the static and documentation
file handlers, and POST /auth/verify, which exchanges a Firebase ID token for
a session.
Everything is read-only except the admin upload endpoint. No endpoint recomputes
hydrology: the application loads the pre-computed tables under app/data/ into
memory at boot and slices them per request.
Access levels¶
Every /api/ route carries an explicit access marker in the source, and
app/tests/test_rbac.py fails the build if a new one is added without one.
| Level | Routes | Who gets through |
|---|---|---|
| Public | 5 | Anyone, no session. These feed the unauthenticated About page map. |
| Requires login | 23 | Any signed-in user, role viewer or admin. |
| Admin only | 6 | Role admin. See Data management. |
Failure responses differ by caller:
| Situation | Response |
|---|---|
| Not signed in, route requires login | 302 to /login?next=... |
| Not signed in, route requires admin | 401 {"error": "Authentication required"} |
| Signed in as viewer, route requires admin | 403 {"error": "Administrator access required"} |
Setting BYPASS_AUTH=1 disables both gates for the whole process. It is a
local-development setting and must never be set on a deployment.
Conventions¶
Path parameters. <wrua> is the WRUA name exactly as it appears in
/api/wruas, URL-encoded, so Lake Ol Bolossat becomes
Lake%20Ol%20Bolossat. <band_id> is the integer sub-watershed identifier from
/api/subwatersheds/<wrua>. An unknown WRUA returns 404; a non-integer
band_id returns 400.
?month= takes 1 to 12 and selects one calendar month. The tables are a
12-month climatology with no year dimension, so there is no year parameter. A
missing, empty or out-of-range value means "no filter", which gives the annual
view rather than an error.
?scenario= takes reallocation, storage or combined. Only combined
reads a different source table; reallocation and storage both read the
storage sheet. An unrecognised value falls back to the endpoint's default rather
than erroring. Defaults differ per endpoint and are stated below.
?band= on /api/balance and /api/simulation narrows the result to a
single sub-watershed. An unparseable value falls back to the WRUA rollup.
Units. Volumes are Mm³ everywhere except /api/compare/<wrua>, which is
entirely in m³ and says so in its own units field. Cropland areas are hectares;
pond and watershed areas are m².
Nulls. A percentage over a zero denominator is null, never 0. A
sub-watershed with no deficit has no "share of deficit resolved", and 0% would
read as a failed intervention rather than as nothing to do.
Reference lists¶
GET /api/wruas¶
Requires login. Every modelled WRUA, as a flat array. This is the canonical list
of names accepted by every <wrua> path parameter.
[
{
"name": "Burguret",
"sro": "5BC",
"subbasin": "5BC",
"area_km2": 156.21,
"centroid": [37.031336, -0.0847],
"elev_min": 1775,
"elev_max": 2375,
"num_bands": 14
}
]
GET /api/basin/summary¶
Requires login. The header stat tiles: counts and extents across every modelled WRUA. No parameters.
{
"scope": "basin",
"wrua_count": 116,
"sub_watershed_count": 1782,
"area_km2": 18210.72,
"elev_min": 325,
"elev_max": 3875,
"total_deficit_Mm3": 956.22,
"total_surplus_Mm3": 225.89
}
GET /api/subwatersheds/<wrua>¶
Requires login. The sub-watershed picker list for one WRUA, ordered from the top
of the watershed down. excluded_below_min_area counts units dropped for being
smaller than 0.25 km², the delineation-fragment threshold. It is 0 for every
WRUA in the current dataset, whose smallest sub-watershed is exactly 0.25 km².
{
"wrua": "Isiolo",
"count": 220,
"excluded_below_min_area": 0,
"subwatersheds": [
{"band_id": 95, "label": "WS 95", "area_km2": 1.8918, "elev_min": 3350.0, "elev_max": 3375.0}
]
}
Map layers¶
All six return GeoJSON FeatureCollection objects. Responses are memoised per
worker process, keyed by WRUA, month and scenario.
| Method | Path | Access | Returns |
|---|---|---|---|
| GET | /api/map/wruas |
Public | WRUA outline polygons that have boundary geometry, with per-WRUA area, sub-region code and annual surplus and deficit totals |
| GET | /api/map/other-wruas |
Public | The remaining WRUA outlines in the study area, each carrying has_cropland |
| GET | /api/map/crop-zones |
Public | One dissolved polygon per predominant production type |
| GET | /api/map/bands |
Requires login | Every modelled sub-watershed polygon, basin-wide, with the full choropleth attribute set |
| GET | /api/map/bands/<wrua> |
Requires login | The same for one WRUA |
| GET | /api/map/flow/<wrua> |
Requires login | Reallocation routing lines for one WRUA, upstream centroid to downstream centroid |
The two WRUA outline layers together cover the study area. Three outlines carry
has_cropland: false: no cropland was found there, so they have no modelled
sub-watersheds and are drawn as context only. The front end renders both layers
identically, so a reader sees one WRUA layer rather than two.
/api/map/bands and /api/map/bands/<wrua> accept ?month= and ?scenario=.
Each feature's properties carries the identity fields (band_id, label,
elev_min, elev_max, elev_mid, area_m2, area_km2, and wrua_name on the
basin-wide layer) plus every field the map can colour by:
| Group | Properties |
|---|---|
| Water balance | total_surplus_Mm3, total_deficit_Mm3, deficit_area_ha |
| Reallocation | pct_resolved, pct_remaining, realloc_class, reallocation_score |
| Storage | NP, NP_needed, NP_built, pond_depth_m, SA, storage_sufficiency_ratio, reliability_supply, land_footprint_storage_pct, max_deficit_area, max_deficit_m3, annual_retained_frac, suitability_score, priority_category |
| Flags | below_min_area |
deficit_area_ha follows the month selector: it is that month's cropland area in
deficit when ?month= is given, and the mean across the twelve months when it is
not. max_deficit_area is the annual peak and does not move with the selector.
They are separate colour modes because they answer different questions.
The storage properties come from the table ?scenario= names, so the map and the
detail panel cannot show different numbers for the same sub-watershed.
/api/map/flow/<wrua> accepts ?month=. Each LineString feature carries
WRUA_NAME, band_id, next_id and flow_Mm3, the surplus that sub-watershed
passes downstream for the selected month, or the annual sum with no month. Every
edge is returned, including those with flow_Mm3 of 0; the front end animates
only the non-zero ones.
Water balance¶
GET /api/balance/<wrua> and GET /api/basin/balance¶
Requires login. The 12-month surplus and deficit series behind the water balance
chart. Parameters: ?month= (both), ?scenario= (both, default
reallocation), ?band= (per-WRUA only).
{
"wrua": "Isiolo",
"band_id": null,
"scenario": "reallocation",
"deficit_source": "reallocation",
"months": [
{"month": 3, "surplus_Mm3": 0.0, "deficit_Mm3": 4.1481, "deficit_met_Mm3": 0.0, "unmet_Mm3": 4.1481}
]
}
deficit_source names the table the deficit was read from, so a consumer can see
which sheet is behind the bars. Two properties of the model matter when reading
this series:
deficit_Mm3is the gross deficit, the volume that has to be abstracted at the intake, not the net crop water requirement. Gross is net divided by the overall irrigation efficiency, so it is materially larger. Comparing it against a net figure from elsewhere looks like a discrepancy and is not one.- The
reallocationandstoragescenarios plot the same deficit bars. Storage is applied to the full gross deficit, so its demand curve is the gross deficit. Onlycombineddiffers, because reallocation has already absorbed part of the deficit before the ponds see it.
surplus_Mm3 is supply-side and is identical across all three scenarios.
GET /api/heatmap/<wrua> and GET /api/basin/heatmap¶
Requires login. The month by sub-watershed deficit grid. No parameters.
The per-WRUA endpoint returns every sub-watershed in the WRUA, ordered by
elevation with the highest first. The basin endpoint returns the 12
sub-watersheds with the largest peak monthly deficit anywhere in the study area,
labelling each WS <id> · <wrua> because band_id is unique only within a WRUA.
{
"wrua": "Burguret",
"months": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
"bands": [
{"band_id": 1473, "label": "WS 1473", "elev_min": 2000, "elev_max": 2025, "elev_mid": 2012,
"values": [0.0068, 0.0624, 0.0651, 0.0313, 0.0248, 0.03,
0.0875, 0.1291, 0.0749, 0.0488, 0.0521, 0.0134]}
]
}
values is Mm³ per month, index 0 being January. The basin variant adds
wrua_name and replaces wrua with "scope": "basin".
GET /api/areas/<wrua> and GET /api/basin/areas¶
Public. Cropland split into deficit, optimum and surplus area. Parameter:
?month=.
{
"wrua": "Isiolo",
"month": 3,
"total_area_km2": 467.1,
"crop_area_ha": 26599.08,
"deficit_ha": 6630.73,
"optimum_ha": 19968.36,
"surplus_ha": 0.0
}
With ?month= the figures are that month's. Without it they are the mean across
the twelve months, not the sum: an annual sum would report twelve times the
cropland that exists.
GET /api/volumes/<wrua> and GET /api/basin/volumes¶
Requires login. Volume aggregates and the reallocation outcome. Parameter:
?month=. Volumes are additive in time, so the annual figures here are true
sums.
{
"wrua": "Isiolo",
"month": null,
"vol_surplus_Mm3": 0.049549256,
"vol_deficit_gross_Mm3": 89.425054734,
"inflow_surplus_Mm3": 0.080115177,
"total_available_Mm3": 0.080115177,
"deficit_met_gross_Mm3": 0.015755087,
"unmet_deficit_gross_Mm3": 89.409299647,
"surplus_transferred_down_Mm3": 0.113909346,
"pct_deficit_resolved": 0.0176,
"pct_deficit_remaining": 99.9824
}
Both percentages are null when there is no deficit in scope.
Sub-watershed detail¶
GET /api/subwatershed/<wrua>/<band_id>¶
Requires login. One sub-watershed's full metric set, with no cross-band
aggregation. Parameters: ?month=, ?scenario= (default reallocation).
The routing columns (inflow_surplus_Mm3, total_available_Mm3,
surplus_transferred_down_Mm3) describe water passing along a cascade. Summing
them across sub-watersheds counts the same water more than once, which is why
they are served one unit at a time and never rolled up.
{
"wrua": "Burguret",
"band_id": 1471,
"label": "WS 1471",
"month": 8,
"scenario": "combined",
"area_km2": 15.0479,
"reallocation": {
"vol_surplus_Mm3": 0.0,
"vol_deficit_gross_Mm3": 0.549575039,
"inflow_surplus_Mm3": 0.0,
"total_available_Mm3": 0.0,
"deficit_met_gross_Mm3": 0.0,
"unmet_deficit_gross_Mm3": 0.549575039,
"surplus_transferred_down_Mm3": 0.0,
"pct_deficit_resolved": 0.0,
"pct_deficit_remaining": 100.0,
"area_deficit_ha": 576.9944,
"area_optimal_ha": 332.8704,
"area_surplus_ha": 0.0,
"realloc_class": "Low",
"reallocation_score": 0.0478,
"local_realloc_ok": 0,
"water_from_upstream": "none"
},
"storage": {
"NP": 66.0,
"NP_needed": 550.0,
"NP_built": 66.0,
"max_deficit_m3": 549575.0394,
"gross_max_deficit_m3": 549575.0394,
"max_deficit_area": 576.9944,
"pond_depth_m": 1.1,
"pond_area_m2": 60000.0,
"suitability_score": 0.1192,
"footprint_score": 0.868,
"priority_category": "Low",
"annual_retained_frac": 1.0,
"storage_sufficiency_ratio": 0.12,
"land_footprint_storage_pct": 0.3987,
"reliability_supply": 0.0
},
"topology": {
"next_band_id": 1475,
"is_outlet": false,
"upstream_band_ids": []
}
}
topology is the static routing chain, not a monthly flow. It is what lets the
panel say where an inflow or outflow tile points, so a reading of zero is legible
rather than ambiguous. gross_max_deficit_m3 appears only under
?scenario=combined, where max_deficit_m3 is the residual after reallocation
and the gross figure is needed beside it.
Storage and interventions¶
GET /api/storage/<wrua> and GET /api/basin/storage¶
Requires login. The storage snapshot. Parameter: ?scenario= (default
storage; only combined changes the source table).
{
"wrua": "Burguret",
"scenario": "storage",
"NP": 804.0,
"NP_needed": 3268.0,
"NP_built": 546.0,
"mean_pond_depth_m": 1.0786,
"total_pond_area_m2": 502272.73,
"watershed_area_m2": 156210938.9,
"pond_to_watershed_ratio": 0.003215,
"fraction_months_meeting_demand": 0.4824,
"max_deficit_area_ha": 3626.45,
"max_deficit_m3": 3260967.7175,
"footprint_score": 0.8828,
"suitability_score": 0.3808,
"priority_category": "Low",
"buildable_vs_needed": 0.1671
}
NP_needed is what the peak monthly deficit asks for. NP_built is what
survives the basin-wide cap on retained runoff. The gap between them is the point
of the tile, so both are always served. pond_to_watershed_ratio and
buildable_vs_needed are ratios on a scale of 1, not percentages, and are null
rather than 0 when their denominator is absent.
GET /api/interventions/<wrua> and GET /api/basin/interventions¶
Requires login. The priority sites table: the 15 sub-watersheds with the
largest peak monthly deficit that still need at least one pond. Parameter:
?scenario= (default sat, which reads the same table as storage).
{
"wrua": "Burguret",
"scenario": "sat",
"total_ponds": 546,
"total_storage_m3": 546000,
"interventions": [
{
"band_id": 1467,
"band": "WS 1467",
"elev_min": 1850,
"elev_max": 1875,
"elev_mid": 1862,
"deficit_m3": 1159666.0,
"ponds_needed": 1160,
"ponds_built": 75,
"priority": "high",
"type": "Farm Pond"
}
]
}
priority buckets deficit_m3: above 5,000 m³ is high, above 1,000 m³ is
medium, otherwise low. It is a label only and alters no volume. total_ponds
counts NP_built across the whole scope, not just the 15 rows shown, and
total_storage_m3 is that count times the 1,000 m³ unit pond. The basin variant
adds wrua_name and puts the WRUA name in band.
GET /api/simulation/<wrua>¶
Requires login. The 48-step farm-pond simulation series, averaged across the
WRUA's sub-watersheds. Parameters: ?scenario= (default sat; combined
switches table), ?band=.
{
"wrua": "Burguret",
"scenario": "sat",
"basin_retained_frac": null,
"timeseries": [
{"month": 1, "H": 0.0, "overflow": 0.0, "R": 0.0,
"balance": 0.0, "Deficit": 0.0, "retained_frac": 0.0}
]
}
month is the step index from 1 to 48, not a calendar month. The simulation runs
one climatology four times starting from an empty pond; the first year is spin-up
and steps 37 to 48 are the converged year that every derived figure is read from.
Scenario comparison¶
GET /api/compare/<wrua>¶
Requires login. The three scenarios side by side. No parameters.
Every volume in this payload is m³, not Mm³. The units field says so and
basis states where each half comes from.
{
"wrua": "Burguret",
"units": "m3",
"basis": "Reallocation is read from the reallocation table. Storage and Reallocation + Storage are computed from the 48-step pond simulation, read at its converged year (steps 37-48). All volumes are m3.",
"reallocation": {
"total_deficit": 15840083.7,
"deficit_met": 394859.8,
"unmet": 15445223.9,
"ponds": 0,
"storage_m3": 0,
"pct_addressed": 2.5,
"monthly": [191035.459, 1328642.706, 1503819.682, 581554.985,
588374.001, 1106300.641, 2545188.615, 3117105.285,
1568822.299, 1171947.25, 1446970.588, 295462.425]
},
"storage": {
"total_deficit": 15840083.7,
"deficit_met": 2730221.5,
"unmet": 13109862.2,
"ponds": 546,
"storage_m3": 546000,
"pct_addressed": 17.2,
"monthly": []
},
"combined": {
"total_deficit": 15840083.7,
"deficit_met": 3060978.8,
"unmet": 12779104.9,
"ponds": 546,
"storage_m3": 546000,
"pct_addressed": 19.3,
"monthly": []
}
}
total_deficit is deliberately the same in all three blocks: the gross annual
deficit is the single denominator, so the three pct_addressed figures are
comparable. monthly is the unmet residual per calendar month, January
first, which is why it does not sum to deficit_met. pct_addressed is null
for a WRUA with no deficit at all. The monthly arrays are shown empty above for
brevity; each is twelve numbers.
Recommendations¶
GET /api/recommendation/<wrua>/<band_id>¶
Requires login. The precomputed recommendation card for one sub-watershed.
Parameters: ?scenario= (default combined), ?month=.
Without ?month= the endpoint serves the peak deficit month, not an annual
figure. There is no annual reallocation efficiency in the source: it is a ratio
of two monthly volumes, and the peak month is the same basis the pond sizing
uses, so both halves of the card stay consistent. The month field always states
which month the figures describe, and peak_month names the worst month for
reference. A sub-watershed with no deficit in any month has no peak month, and
peak_month is null.
headline, recommendation_text and the metric set come from the intervention
table; the crop fields are annual context and do not move with ?scenario= or
?month=.
| Field | Meaning |
|---|---|
available |
false when this sub-watershed has no scored recommendation |
headline, recommendation_text |
The card title and narrative, generated offline |
month, peak_month |
The month the figures describe, and the worst month of the year |
efficiency, reduction_Mm3, gross_deficit_Mm3, residual_dependence |
Reallocation outcome for that month |
suitability_class, suitability_score, sufficiency_ratio, reliability_supply, land_footprint_pct, annual_retained_frac, np_needed, np_built, gross_ratio, gross_class |
Storage outcome |
months_with_deficit, mean_monthly_deficit_Mm3 |
How often this unit is in deficit and by how much, from the scenario's own monthly volumes |
deficit_months, optimal_months, surplus_months |
Months in which that class dominates the cropland, from the crop-matching profile |
predominant_crop, predominant_aez, predominant_crop_frac |
Production type context |
months_with_deficit and deficit_months count different things and routinely
disagree: a unit can carry a deficit volume every month while deficit cropland
dominates in only one. Both are legitimate, and they are named apart on purpose.
The narrative is never composed at request time. There is one generator, the offline data-prep step, and this endpoint only selects the right precomputed row.
GET /api/recommendation/<wrua>¶
Requires login. How the headlines split across a WRUA's sub-watersheds. Parameters as above.
{
"wrua": "Isiolo",
"scenario": "combined",
"month": null,
"available": true,
"count": 220,
"headlines": {
"NO INTERVENTION NEEDED": 3,
"REALLOCATION + PARTIAL STORAGE": 79,
"RESIDUAL GAP - DEMAND-SIDE MEASURES NEEDED": 88,
"STORAGE-LED - REALLOCATION OPTIONAL": 50
},
"dominant_headline": "RESIDUAL GAP - DEMAND-SIDE MEASURES NEEDED",
"unscored": 0,
"top_crops": {
"Ranching Zone": 54,
"Livestock - Sorghum Zone": 53,
"Livestock - Millet Zone": 52
}
}
count is the number of scored sub-watersheds; unscored counts the rest.
Exports and reports¶
GET /api/export/<wrua>¶
Requires login. Returns text/csv, not JSON: every reallocation row for the
WRUA, one per sub-watershed per month, with the source column names unchanged.
The filename is SWAG_DSS_<wrua>_data.csv. See
Exporting data for the columns.
GET /api/report/<wrua>¶
Requires login. Returns a multi-page PDF. Parameter: ?band=<band_id> scopes
every figure to one sub-watershed of that WRUA; anything else, including an id
belonging to a different WRUA, leaves the report at whole-WRUA scope.
The report is annual and covers all three scenarios. ?scenario= and ?month=
are accepted and ignored: the document's structure is inherently annual and
comparative, so filtering to one month would empty the monthly table and
filtering to one scenario would gut the comparison. The cover states the real
basis rather than the arguments passed.
Every number in the PDF is computed by the same code that serves
/api/compare/<wrua>, so the report and the dashboard cannot drift apart.
Admin¶
All six require the admin role. See Data management for
what the screen built on them can and cannot do today.
| Method | Path | Returns |
|---|---|---|
| GET | /api/admin/whoami |
The caller's username and role, runtime_dir, and bypass_active with bypass_from_dotenv, so the page can say plainly when the gate it sits behind is switched off |
| GET | /api/admin/inventory |
served (one row per served file: name, size, modified time, live row count), sources (the source dataset contracts), totals, and upload_enabled with upload_blocked_reason |
| GET | /api/admin/download/<name> |
One served file as an attachment. <name> is matched against the inventory allowlist, so an unknown name returns 404 rather than reaching the filesystem. Writes a data.download audit entry |
| GET | /api/admin/download-all |
Every served file as swag-dss-data.zip, built in memory. Writes a data.download_all audit entry |
| POST | /api/admin/upload/<source_id> |
Validates and stages a replacement workbook. Does not publish it. See below |
| GET | /api/admin/audit |
The 100 most recent audit entries, newest first: at, who, role, action, detail |
POST /api/admin/upload/<source_id>¶
source_id is one of the ids in the inventory's sources array. The body is
multipart/form-data with the file in the file field.
Checks are applied in this order:
| Check | Failure |
|---|---|
source_id is a known dataset |
404 |
| The audit log records a download on this deployment | 409 |
| A file is attached | 400 |
The filename ends .xlsx |
400 |
| The file is at most 100 MB | 413 |
| The workbook has all three required sheets, with the required columns on the expected header row, and data rows below them | 400, with errors listing every problem found, not just the first |
On success it returns 200 with "staged": true and "published": false. The
file is stored under the runtime directory and nothing further happens to it:
{
"ok": true,
"staged": true,
"published": false,
"message": "The workbook is valid and has been stored. It is NOT live yet: processing it into the served files still has to be switched on.",
"summary": {
"size_bytes": 4194304,
"sheets_found": ["Both_Ec85_Ea70_req", "Reallocat_Ec85_Ea70", "SAT_Ec85_Ea70_req"],
"sheets": {"Reallocat_Ec85_Ea70": {"rows": 21384, "columns": 40}},
"wruas": 116
}
}
A rejected file is deleted from staging rather than left behind. The admin page does not offer this endpoint: its Replace buttons are disabled. See Data management.