Live demo: https://georisk-agent.vercel.app (click the map; real Sentinel-2 imagery, ~3 s per site)
Satellite-derived physical-risk screening for a US site or outline: pulls a recent Sentinel-2 scene, the Copernicus elevation model and the high-voltage grid map; returns calibrated flood and wildfire probabilities with their track record and the factors that drive them, an experimental solar-siting energy lens, and a plain-English analyst memo built only from the numbers.
It is a compact, honest GeoAI system: every number is validated on held-out places against public records (FEMA, USGS), weaknesses are written next to the result, and each part fails alone.
flowchart TD
A[lat/lon or GeoJSON outline] --> B[AOI + tiles]
B --> P{{read in parallel}}
P --> I[Imagery chain<br/>Earth Search -> Planetary -> stale cache<br/>20 s budget per source, circuit breakers]
P --> D[DEM context<br/>terrain + river height + topography]
P --> G[Grid proximity<br/>HIFLD lines >= 100 kV]
I --> F[Imagery features + heuristic scores]
I -- no clear scene --> X[Imagery-free results only]
D --> M
G --> M
F --> M[Models: flood, fire, solar<br/>blend / ensemble, recalibrated]
X --> M
M --> E[Evidence: track record + drivers + caveat]
E --> N[Analyst memo<br/>number-checked]
N --> O[FastAPI / demo / area GeoJSON / CLI]
C[(result cache<br/>single-flight)] -.-> O
Flood and wildfire scores for a site usually come from a vendor model that cannot be audited. This repository shows a small GeoAI pipeline where every probability is calibrated and checked on held-out places against public records (FEMA, USGS), every degraded input is reported in the output instead of hidden, and the written memo is built only from the computed numbers.
Every external dependency and every hazard sits behind its own boundary, so a failure degrades the answer instead of destroying it — and the response says what degraded.
| If this fails... | ...this happens | Visible in output |
|---|---|---|
| Earth Search is down | Retries, then Planetary Computer serves the scene | provenance.imagery_trail, confidence -0.05 |
| Both imagery sources are down | Expired cache entry is served | meta.cache = "stale", confidence -0.15 |
| Everything is down and no cache | HTTP 503 with the full per-source trail (or synthetic data only if GEORISK_ALLOW_SYNTHETIC=1, always flagged, confidence 0) |
error, trail |
| A source keeps failing | Circuit opens: later requests skip it instantly, half-open after cooldown | /health/deep shows circuit state |
| DEM / terrain unavailable | Flood and wildfire score from imagery only | hazard status: degraded, confidence -0.10, warning |
| River-height (DEM context) read fails | Flood probability uses typical values for it | warning: river-height data unavailable |
| No clear satellite scene (e.g. weeks of cloud) | Results that need no imagery (solar) are returned; flood and fire are withheld | warning: imagery unavailable, provenance.imagery: false, confidence low |
| Grid service is down | Solar is withheld, not guessed from a median | warning: grid unavailable |
| An imagery source hangs | After a 20 s budget (GEORISK_PROVIDER_BUDGET_S) it stops retrying and fails over |
provenance.imagery_trail |
| One hazard raises | That hazard is failed; the others still score |
hazard status: failed, warning |
| A cache file is corrupt | Discarded and refetched, never fatal | logged |
| LLM unavailable / no key | Deterministic template narrative | narrative_source: template |
| One location in a batch fails | Only that item is marked failed | per-item ok flag |
Other guarantees: GDAL is isolated from the host (public buckets are read unsigned, so local AWS
credentials cannot break a read; every read has timeouts and bounded retries), cache writes are
atomic, and liveness (/health) never touches an external service.
docker build -t georisk-agent . && docker run -p 8000:8000 georisk-agent # or:
pip install -e ".[dev,research]" # what CI installs; ".[dev]" alone skips the 2 scikit-learn tests
# live imagery (no API key needed)
georisk --lat 39.9612 --lon -82.9988 --radius 800 --no-llm
# fully offline, deterministic synthetic scene
georisk --lat 34.0 --lon -118.5 --offline wildland_urban --no-llm
# service
uvicorn georisk.api:app --reload # POST /assess, GET /healthOptional LLM narrative: pip install -e ".[llm]" and set ANTHROPIC_API_KEY. The model is only
given the computed scores and factors and is instructed not to invent anything; if the key or call
fails, a deterministic template is used, so the service never depends on an LLM.
| Location | Scene | Flood | Wildfire | Urban | Overall | Confidence |
|---|---|---|---|---|---|---|
| Columbus, OH | 2026-09-25 | 12 | 23 | 22 | 28 moderate | 1.00 |
| Houston, TX | 2026-08-30 | 14 | 39 | 41 | 49 moderate | 0.85 |
| Griffith Park, CA (341 m relief, 18 deg mean slope) | 2026-09-29 | 0 | 32 | 1 | 32 moderate | 1.00 |
| Chatsworth, CA (valley floor) | 2026-09-29 | 19 | 34 | 19 | 40 moderate | 1.00 |
800 m half-side AOI, 128x128 imagery grid, ~2 s per assessment (band reads run concurrently).
Labels are public ground truth, not synthetic: FEMA National Flood Hazard Layer (Special Flood Hazard
Area) for flood and USGS/NIFC WFIGS historical fire perimeters (>=100 acres, 2005-2018) for wildfire.
Each label is joined to Sentinel-2 imagery + Copernicus DEM features for the same point
(python -m georisk.validation.run, then python -m georisk.validation.calibrate).
Everything below is leave-region-out: the model is trained on every region except one and scored on the held-out region, so it is judged on places it has never seen. AUC 0.5 = coin flip.
Flood (876 points, 448 in an SFHA, 16 US regions incl. six Ohio cities; 14 features: imagery, terrain and river height)
| Model | AUC | 95% CI | Brier (baseline 0.250) |
|---|---|---|---|
| Original hand-tuned heuristic | 0.607 | 0.57–0.64 | n/a |
| Logistic regression (inputs capped at 2 SD) | 0.859 | 0.84–0.88 | 0.152 |
| Bagged random forest (numpy) | 0.821 | 0.79–0.85 | 0.178 |
| k-nearest neighbours | 0.800 | 0.77–0.83 | 0.186 |
| Average of logistic + forest + kNN | 0.850 | 0.83–0.87 | 0.159 |
| Random forest (scikit-learn-trained, exported to numpy) | 0.852 | 0.82–0.88 | 0.183 |
| Blend: logistic + scikit-learn forest (served) | 0.866 | 0.84–0.89 | 0.158 |
Ohio case study (Columbus, Cincinnati, Cleveland, Dayton, Toledo, Akron; 292 points). Trained on non-Ohio regions only, tested in Ohio: blend model AUC 0.856 (0.81–0.90); logistic 0.851, forest 0.764, kNN 0.768. The original heuristic scored 0.519 there (no better than chance).
What moved the flood numbers, in order (held-out regions): the heuristic scored ~0.58; fitted models on imagery + terrain 0.845; river-height features (hydrology) 0.856 (Ohio holdout 0.821 -> 0.854, the biggest single gain); capping extreme inputs for the linear member 0.866 (it extrapolated badly on steep sites). Each step was kept only because it improved held-out regions.
Flood zones are river phenomena, so src/georisk/hydrology.py derives them from the same free 30 m elevation data: fill pits, route
water downhill (D8), accumulate flow, mark streams (>= 0.09 km2 upstream), and compute HAND, the height above the nearest drainage, plus
distance to and size of the stream the site drains to. Pure numpy; about 0.1 s of compute per site (the network read dominates).
Verified on synthetic landscapes with known answers (a V-valley's HAND equals its wall height; pits are filled; accumulation counts cells exactly).
Spot check against FEMA: the Houston bayou site went from 43% to 73% (it is in a flood zone); the Scioto River site scores 84% (in one).
Known miss: a steep chaparral hillside east of Santa Rosa, CA scores 88% but FEMA says it is outside a flood zone. In training, steep sites that sit near a sizeable stream are often canyon-bottom flood zones; this site resembles them. Held-out accuracy in that probability band is about 85%, so roughly one in seven such predictions is wrong, which is exactly what the track-record table below says.
Weakest regions for flood (the served model, held out): New Orleans (0.54), Toledo (0.58), Miami (0.68). The API, memo and demo warn automatically for sites in these regions.
Wildfire (292 points, 149 inside a perimeter; western US only; 13 features incl. topography)
| Model | AUC | 95% CI | Brier (baseline 0.250) |
|---|---|---|---|
| Original hand-tuned heuristic | 0.576 | 0.51–0.64 | n/a |
| Logistic regression (inputs capped at 2 SD) | 0.762 | 0.71–0.82 | 0.198 |
| Bagged random forest (numpy) | 0.736 | 0.68–0.80 | 0.204 |
| k-nearest neighbours | 0.758 | 0.70–0.81 | 0.205 |
| Average of logistic + forest + kNN (served) | 0.777 | 0.72–0.83 | 0.190 |
| Random forest (scikit-learn-trained, exported to numpy) | 0.781 | 0.73–0.83 | 0.202 |
| Blend: logistic + scikit-learn forest | 0.780 | 0.73–0.83 | 0.191 |
Topography from the same elevation window (elevation, ridge/valley position, roughness, and whether the slope faces south) made the fire probabilities better behaved rather than ranking much better: raw calibration error fell from 0.124 to 0.065 and Brier from 0.200 to 0.190, with AUC about the same (0.78). Weakest regions (also warned automatically at serving time): central Arizona (0.58), the Oregon Cascades (0.66), the Colorado Front Range (0.67). Known miss: a site inside the 2018 Camp Fire perimeter (Paradise, CA) scores only 36%; the label is "has burned before", so this is a screening signal, not a forecast.
Which model serves. The product is a probability, so among candidates whose AUC is within 0.01 of the best (a ranking tie), the served model is the one
with the best Brier score after the recalibration gate (flood: blend; fire: ensemble). Selection rule, scores and the recalibration decision are in validation_data/report.json.
How to read this honestly:
- The original heuristic scores were statistically indistinguishable from chance against ground truth. That is why the service also returns a calibrated probability from a fitted model, and warns when a hazard's heuristic score has no validated model at the site's location.
- The served model for each task is chosen by the rule above (best post-recalibration Brier among the near-best-AUC candidates). The candidate
set was fixed in advance with no per-model hyper-parameter search, but with a few hundred samples the top
candidates' intervals overlap, so the choice is not a proven win over the runner-up. A wider
scikit-learn benchmark (
python -m georisk.validation.benchmark: gradient boosting, extra trees, SVM) showed the same picture on the 876-point flood set: every model lands between 0.81 and 0.84 AUC, inside each other's confidence intervals. - Serving needs numpy only: scikit-learn (the
researchextra) trains the forest, which is exported to plain JSON trees. PyTorch was deliberately not used: with ~10^2-10^3 tabular rows a neural net does not beat tree ensembles, and it would break the serverless size limit. - A model ships only if leave-region-out AUC >= 0.6, its CI lower bound > 0.5 and Brier beats the base rate.
- Skill is modest, not deployment-grade for insurance or lending decisions. Weak regions: flood in New Orleans (AUC 0.54, pumped/leveed terrain), Toledo (0.58) and Miami (0.68); fire in central Arizona (0.58), the Oregon Cascades (0.66) and the Colorado Front Range (0.67).
- Labels have known noise: FEMA maps lag the ground, fire perimeters are coarse, and the fire label asks "has burned before", which is a proxy for exposure, not a forecast.
- Fire probabilities are withheld outside the western US (the training domain) instead of extrapolating; flood is served across the sampled US range.
I tested IBM/NASA's Prithvi-EO-1.0-100M (a 100-million-parameter model pre-trained on satellite imagery; Apache-2.0) against
the lightweight models above, under the same leave-region-out rules. For each labelled site I fetched a 6-band, 224 x 224
Sentinel-2 chip (30 m pixels, about 6.7 km square), ran Prithvi's encoder, and used the 768-number embedding (PCA-reduced inside each
training fold, so nothing leaks from the held-out region) on its own and combined with the tabular features. Code: src/georisk/foundation/.
The tables use the current feature set (14 flood, 13 fire features) and fixed seeds: python -m georisk.foundation.evaluate on the committed
embeddings reproduces validation_data/foundation_report.json exactly.
Flood (863 sites)
| Model | AUC | 95% CI | Brier |
|---|---|---|---|
| features: blend (logistic + forest) | 0.872 | 0.85–0.90 | 0.155 |
| features: forest | 0.857 | 0.83–0.88 | 0.181 |
| features + prithvi PCA16: forest | 0.854 | 0.83–0.88 | 0.191 |
| features: logistic | 0.853 | 0.83–0.88 | 0.157 |
| features + prithvi PCA16: logistic | 0.844 | 0.82–0.87 | 0.160 |
| average: feature blend + prithvi logistic | 0.838 | 0.81–0.86 | 0.178 |
| prithvi only: forest on PCA32 | 0.668 | 0.64–0.70 | 0.235 |
| prithvi only: logistic on PCA32 | 0.661 | 0.62–0.70 | 0.237 |
Ohio holdout (trained on other states): feature blend 0.873, Prithvi alone 0.624, features + Prithvi 0.827.
Wildfire (286 sites)
| Model | AUC | 95% CI | Brier |
|---|---|---|---|
| features + prithvi PCA16: forest | 0.789 | 0.73–0.84 | 0.202 |
| average: feature blend + prithvi logistic | 0.781 | 0.73–0.83 | 0.189 |
| features: forest | 0.775 | 0.71–0.83 | 0.203 |
| features: blend (logistic + forest) | 0.771 | 0.70–0.82 | 0.194 |
| features + prithvi PCA16: logistic | 0.769 | 0.71–0.82 | 0.194 |
| features: logistic | 0.750 | 0.69–0.81 | 0.203 |
| prithvi only: logistic on PCA32 | 0.714 | 0.65–0.77 | 0.220 |
| prithvi only: forest on PCA32 | 0.714 | 0.65–0.77 | 0.226 |
Result: the big model does not earn its cost here.
- For flood, Prithvi alone is far worse (0.66 vs 0.87), and adding it to the tabular features lowers accuracy. Flood-zone status depends on fine terrain and water detail that a 30 m, 6.7 km chip blurs away.
- For wildfire, the best combination is marginally higher (0.789 vs 0.771), but the confidence intervals overlap almost completely, so it is a tie.
- Cost: 443 MB of weights, PyTorch, and about 1 s per chip on a laptop GPU, versus milliseconds and numpy only for the served models. It also cannot run on the free serverless deployment.
- Caveats: one encoder, one image date per chip, mean-pooled embeddings and no fine-tuning. A fine-tuned model or finer-resolution input might do better; this measures the cheap "frozen embedding" route.
So the served models stay the small ones. This is the same answer DeepSeek/Qwen-style work keeps pointing to: a small, well-matched model beats a big general one when the task is narrow and the data is clean.
Ranking sites correctly (AUC) is not the same as being honest about how likely. I checked, on held-out regions, what actually happened to sites at each predicted probability. The averaged models were compressed toward 50%: when flood said about 26%, only 11% of those sites were really in a flood zone; when it said 65%, 81% were (expected calibration error 0.099 flood and 0.124 fire at the time of the audit).
- Fix: a two-parameter correction (Platt scaling) fitted on held-out predictions, evaluated by correcting each region using only the
other regions. Flood (current model): calibration error 0.091 -> 0.035 and Brier 0.1580 -> 0.1517, so it ships.
Fire (current model): error 0.065 -> 0.116 and Brier 0.1903 -> 0.2040 (both worse), so it does not ship; a correction must improve both.
tests/test_calibration_regression.pyretrains both on the committed data in CI and fails if flood's corrected error exceeds 0.05 or fire's gate decision flips. - Track record shown with every probability. For each prediction the API (
evidence) and the demo show what happened to similar held-out sites, with a 95% Wilson interval, e.g. "22% of 120 sites scored 20-30% were actually in a flood zone (16-31%)".
Flood (shown probability vs what happened in held-out regions):
| Predicted band | Sites | Actually positive | 95% range |
|---|---|---|---|
| 0–10% | 139 | 2% | 1–6% |
| 10–20% | 83 | 18% | 11–28% |
| 20–30% | 56 | 20% | 11–32% |
| 30–40% | 70 | 53% | 41–64% |
| 40–50% | 77 | 39% | 29–50% |
| 50–60% | 68 | 56% | 44–67% |
| 60–70% | 61 | 67% | 55–78% |
| 70–80% | 68 | 75% | 64–84% |
| 80–90% | 105 | 86% | 78–91% |
| 90–100% | 149 | 89% | 82–93% |
Wildfire (not recalibrated, so this table is the honest reading of the raw score):
| Predicted band | Sites | Actually positive | 95% range |
|---|---|---|---|
| 0–30% | 47 | 6% | 2–17% |
| 30–50% | 61 | 30% | 20–42% |
| 50–60% | 46 | 63% | 49–76% |
| 60–70% | 44 | 66% | 51–78% |
| 70–80% | 54 | 72% | 59–82% |
| 80–100% | 40 | 78% | 63–88% |
A check that did not pan out, and so was not built: I tested whether "this site looks unlike the training data" predicts worse accuracy. It does not; unusual sites (extreme terrain, obvious rivers) were predicted better, and the hard cases are ordinary mid-range sites. So there is no "unusual site" warning; the evidence table above is the uncertainty signal that the data supports.
- Geodesic AOI construction; validated inputs.
- Failover imagery chain (Earth Search -> Planetary Computer) with retries, per-provider circuit breakers, atomic disk cache with stale-if-error, opt-in synthetic last resort.
- Windowed COG reads (a 1 km box costs a few hundred KB), SCL cloud/shadow/cirrus/snow masking, processing-baseline offset handling.
- Terrain: Copernicus 30 m DEM, mosaicked across tile boundaries, median-filtered, giving slope and relative elevation that modulate flood and wildfire scores.
- Spectral indices (NDVI, NDWI, NDBI, NBR) with safe division and masked statistics.
- Pluggable hazards (flood, wildfire, urban density), each isolated, each returning its factors.
- Confidence score with explicit reasons (cloud, staleness, missing terrain, fallback used).
- FastAPI:
/assess,/assess/batch(per-item isolation, bounded),/health,/health/deep. - Dockerfile (with
libexpat1) and a liveness healthcheck; configuration via environment variables. - Calibrated probabilities (
calibratedfield) from numpy-only models (logistic, bagged forest, kNN) fitted on FEMA/WFIGS labels; withheld outside their training domain. - Median compositing (
composite=N) of the N clearest scenes, with failover and single-scene degradation. - Hardened API: optional
X-API-Key(GEORISK_API_KEY), per-client rate limit (GEORISK_RATE_PER_MIN; per instance, so best-effort on serverless), bounded in-flight work (GEORISK_MAX_INFLIGHT, 429 when saturated), security headers, strict input validation without echoing raw input, no internal error text in responses, LLM narrative off unlessGEORISK_ALLOW_LLM=1, CSP on the demo page. - Observability: structured JSON logs with request IDs (no coordinates or IPs), Prometheus-format
/metricswith bounded labels. - Area mode (
POST /assess/area, or the Area button in the demo): send a GeoJSON outline, it is cut into tiles (default 1 km, up to 16), each tile is assessed independently and in parallel, and the result is a GeoJSON FeatureCollection with a summary (mean / highest tile / tiles above 50%). A failed tile greys out only itself. Rate limit is charged per tile; outlines over 400 km² are refused. - Analyst memo: every assessment includes a plain-English risk note (headline probabilities, what drives them, suggested use, imagery date, warnings) built only from the assessment's numbers. An optional LLM rewrite (
use_llm+GEORISK_ALLOW_LLM=1) is accepted only if every number it writes appears in the facts it was given; otherwise the template is used. - Live demo at
/: click-to-assess satellite map with place search (OpenStreetMap geocoder, US only), shareable links (/#lat,lon) and GeoJSON/CSV export of area results (CSV cells are neutralised against spreadsheet formula injection). Leads with the validated probabilities and shows which factors drive them (what-if effect of each factor versus a typical training site); the unvalidated heuristic index is collapsed and labelled. - River height, topography and grid proximity features (
hydrology.py,grid.py) read in parallel with imagery; a failure of any one only costs its own features. - Result cache (bounded, expiring, single-flight) and a per-provider time budget with failover.
- Docker image verified in CI: it must import the real imagery stack (it once shipped without
libexpat, so live imagery returned a 500 while offline tests passed). - 231 offline tests, 84% coverage enforced at 80% in CI (fault injection, API security, rate limiting, metrics, calibration, a held-out calibration-error regression guard, hydrology, the real imagery path with fake catalogs and a real GeoTIFF, the model-training tools including a "noise must not ship" gate test) + live smoke tests.
GeoRisk's energy lens asks "how much does this site's terrain and grid access resemble where US utility-scale solar is actually built?". Labels are the USGS/Berkeley Lab US Large-Scale Solar Photovoltaic Database (v4.0, April 2026; 6,611 plants of 1 MW or more, 67 in Ohio). Positives are 1500 plant locations; negatives are an equal number of nearby sites (3-25 km away, never within 2 km of any plant), so the model has to separate solar land from its neighbours rather than learn "sunny state". Only elevation-derived features (slope, flat share, aspect, ridge/valley position, river height) and distance to the high-voltage grid (HIFLD transmission lines of 100 kV or more: nearest line, nearest 230 kV+ line, lines within 10 km, highest voltage nearby) are used. Satellite imagery is deliberately excluded because the panels themselves would leak the label. Everything is held out by state.
| Model (leave-state-out, 3000 sites) | AUC | 95% CI | Brier (baseline 0.250) |
|---|---|---|---|
| logistic | 0.673 | 0.66–0.69 | 0.226 |
| forest | 0.664 | 0.65–0.68 | 0.231 |
| knn | 0.623 | 0.60–0.64 | 0.248 |
| ensemble | 0.670 | 0.65–0.69 | 0.227 |
| sk_forest | 0.673 | 0.65–0.69 | 0.231 |
| blend | 0.682 | 0.67–0.70 | 0.226 |
Terrain alone gave AUC 0.625; adding grid proximity lifted every model (served blend: 0.682, 0.67-0.70). Track record of the shown probability on held-out states:
| Predicted band | Sites | Real solar sites | 95% range |
|---|---|---|---|
| 0–20% | 167 | 13% | 9–19% |
| 20–30% | 267 | 27% | 22–33% |
| 30–40% | 382 | 34% | 30–39% |
| 40–50% | 521 | 48% | 43–52% |
| 50–60% | 681 | 58% | 54–62% |
| 60–70% | 721 | 61% | 58–65% |
| 70–100% | 261 | 73% | 68–78% |
This is a rough screen, and it is weakest exactly where you might use it first.
- Nationally the skill is modest. In flat farmland states it is close to chance: Ohio held-out AUC 0.63 on only 26 sites, and a spot check of 10 real Ohio plants (not used in training) found the plant scored higher than ordinary land 6 km away in 5 of 10 pairs (average 53% vs 50%).
- Why: in flat farmland a solar field and its neighbours look the same to terrain and grid distance. What decides siting there is parcel size, land use, substation capacity, zoning and economics, none of which are in this free pipeline.
- Weakest states in testing (held out, from
validation_data/report.json): MO (0.45, 16 sites), MI (0.57, 18 sites), IL (0.60, 100 sites). - The caveat is stored in the model artifact, so the API, the memo and the demo always show it next to the number ("experimental").
Each result is delivered when its own inputs exist. If no clear satellite scene can be found (for example 60 days of cloud), flood and wildfire need imagery and are withheld, but the solar lens (elevation and grid only) is still returned, with a warning saying what is missing. Only when nothing at all can be computed does the API return 503. If the grid service is down, solar is withheld rather than guessed from a median.
| Variable | Default | What it does |
|---|---|---|
GEORISK_API_KEY |
unset | If set, /assess, /assess/area, /assess/batch and /metrics require X-API-Key |
GEORISK_RATE_PER_MIN |
20 | Per-client sliding-window limit (batch and area are charged per item/tile); per instance on serverless |
GEORISK_MAX_INFLIGHT |
8 (4 on Vercel) | Concurrent assessments before requests get a clean 429 |
GEORISK_MAX_AREA_TILES |
16 | Largest area job; larger outlines get a 422 asking for bigger tiles |
GEORISK_RESULT_TTL_S |
1800 | How long a finished assessment is reused (bounded LRU, 256 places; failures are never cached) |
GEORISK_TRUST_PROXY |
0 (1 on Vercel) | Honour X-Forwarded-For for client identity (only behind a proxy you control) |
GEORISK_ALLOW_LLM / ANTHROPIC_API_KEY |
off | Optional LLM rewrite of the memo, accepted only if every number it writes is in the facts |
GEORISK_PROVIDERS, GEORISK_CACHE_DIR, GEORISK_CACHE_TTL_S, GEORISK_ALLOW_SYNTHETIC |
see config.py |
Imagery provider order, disk scene cache, opt-in synthetic last resort |
- Health:
/health(liveness, never touches external services) and/health/deep(probes each imagery provider and reports circuit state). - Metrics:
/metricsin Prometheus text format: request counts and latency histograms by route template and status class, plus result-cache hits and misses. Labels are bounded; no coordinates or IPs ever appear in logs or metrics. - Load test:
python scripts/loadtest.py http://localhost:8765 --requests 400 --threads 32drives synthetic scenes plus bad input and fails if it sees any 5xx. On an Apple M2 Pro (10 cores, 16 GB), runs of 400 requests at 32 threads gave 0 server errors, overload shed as clean 429s and bad input as 422s. Only the 8 requests admitted by the in-flight cap succeed, and they wait behind the rejected burst, so their p50 ranged from 151 ms to 1.4 s across four runs; a single offline request on its own takes about 3 ms. - Latency: a new place takes about 2-6 s (imagery and the 6 km DEM window are fetched in parallel); repeating it is served from cache in about 2 ms.
vercel deploy --prod works as-is: app.py is the entrypoint and vercel.json sets a 60 s function limit.
Vercel's Python runtime lacks libexpat, which rasterio's GDAL needs, so vendor/libexpat.so.1 (unmodified
Ubuntu build, MIT) is bundled and preloaded on Linux. Set GEORISK_API_KEY in the project settings to require
X-API-Key; the per-client rate limit is per instance, so treat it as best-effort on serverless.
- Probabilities are modest-skill screening, not loss estimates or a regulated floodplain determination. Flood AUC about 0.87 and fire about 0.78 on held-out places, each with a track-record table above. Confirm with an official determination or inspection before relying on one.
- The 0-100 heuristic scores are descriptive only (validated as near chance, shown collapsed and labelled unvalidated).
- Flood is weakest in Toledo (Ohio), New Orleans (pumped, leveed) and Miami; sites there carry an automatic caution, and can flag steep creek-side hillsides (a Santa Rosa hillside scores 88%; FEMA says outside a flood zone). FEMA maps lag the ground and exclude unmapped areas, so labels are noisy.
- Wildfire labels mean "has burned before" (a perimeter of 100+ acres, 2005-2018), a proxy for exposure and not a forecast. Western US only (probability withheld elsewhere); weakest in central Arizona, the Oregon Cascades and the Colorado Front Range; a site inside the 2018 Camp Fire perimeter scores only 36%.
- Solar siting is experimental: AUC 0.68 across states, close to chance in flat farmland such as Ohio. It compares terrain and grid distance only; irradiance, land price, parcels, substation capacity and permitting are not assessed.
- 10-30 m pixels: cannot resolve buildings, defensible space or individual parcels. The Copernicus DEM is a surface model (a 3x3 median removes spikes, but dense high-rise areas read rough).
- One scene, not a time series: seasonal effects (dormancy, snow, irrigation) and change over time are not modelled.
- Rate limits are per instance on serverless, so they are best-effort there. The demo calls a public geocoder from the browser.
- Training sets are a few hundred to a few thousand points (flood 876, fire 292, solar 3,000): intervals are reported, and numbers drop when data grows, which is why only the larger-sample figures are quoted.
- Land use and parcel context for flat farmland, where terrain and grid distance cannot separate solar sites from neighbouring fields (the experimental lens's main gap); substation capacity when a stable open source is available.
- Time series: change detection and seasonality instead of one scene.
- Fine-tune a foundation model (Prithvi/Clay) end to end, or try finer-resolution input; the frozen-embedding route tested above did not help.
- Larger label sets (more regions, MTBS burn severity) and isotonic recalibration once n > 1000.
src/georisk/
agent.py orchestration: parallel reads, graceful degradation, models, memo
api.py FastAPI: /assess, /assess/area, /assess/batch, /health*, /metrics, demo page
area.py outline -> tiles -> independent assessments (GeoJSON)
analyst.py number-checked analyst memo
calibration.py served models: recalibration, evidence, drivers, domain guard
resultcache.py bounded single-flight cache
providers/ imagery chain (Earth Search -> Planetary), disk cache, retry + circuit breaker
terrain.py DEM -> slope, river-height and topography inputs
hydrology.py pits, D8 flow, accumulation, streams, HAND, topography
grid.py high-voltage line proximity (HIFLD)
hazards/ flood, wildfire, urban heuristics + isolated runner
confidence.py trust score with reasons
validation/ labels, sampling, collectors, calibrate.py (train + gate), benchmark, ensemble, metrics
foundation/ Prithvi experiment (research; needs the `deep` extra)
web/index.html single-file demo (map, drivers, area, search, export)
data/calibration.json the served models
tests/ 231 offline tests
scripts/loadtest.py load test
validation_data/ labelled points, collected features, reports (reproducible)
MIT

