Skip to content

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_Mm3 is 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 reallocation and storage scenarios plot the same deficit bars. Storage is applied to the full gross deficit, so its demand curve is the gross deficit. Only combined differs, 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.