Case Study // POSTGIS · FASTAPI · MAPLIBRE

California Wildfire Risk API & Explorer

A production-grade geospatial service that turns 22,800+ CAL FIRE perimeters into a queryable spatial API and a live, client-side risk explorer — the full pipeline from raw geodata to a deployable REST endpoint.

FastAPIPostGISPostgreSQLSQLAlchemyGeoPandasShapelyRasterioMapLibre GLDockerGitHub Actionspytest
22,810
CAL FIRE PERIMETERS
11
API ENDPOINTS
97%
TEST COVERAGE
1878–2024
HISTORICAL RECORD
// Context

Why this exists

I lived in Santa Monica during the January 2025 Los Angeles wildfires. This project is both a personal response to that experience and a portfolio piece built toward geospatial software-engineering work — it covers the entire pipeline, from raw CAL FIRE and NASA FIRMS geodata through spatial analysis notebooks to a deployable, tested spatial API.

The dataset spans 22,810 CAL FIRE wildfire perimeters back to 1878 plus NASA FIRMS VIIRS satellite fire detections from the 2025 LA fires. Everything is stored in its native EPSG:3310 (California Albers) projection for accurate metric math and transformed to EPSG:4326 (WGS84) on the way out, so GeoJSON responses render correctly on any standard web map.

// Architecture

Three small modules, one request path

The application is deliberately split into three thin modules so each has a single responsibility. Routes stay declarative, all SQL lives in one place, and database configuration is isolated behind environment variables.

app/main.py
FastAPI routes
Each route is thin — it validates inputs, calls a query function, and shapes the response.
app/queries.py
Spatial SQL layer
All SQL lives here, returning pandas / GeoPandas results ready for serialization.
PostgreSQL + PostGIS
Spatial database
fire_perimeters (22,810) and firms_fires (NASA FIRMS), queried with PostGIS spatial predicates.
app/database.py builds the SQLAlchemy engine from environment variables — no credentials in code.
// The risk model

A transparent 0–100 score

The /risk endpoint combines three spatial factors, each normalized to 0–100 and then weighted. It always returns the full factor breakdown alongside the headline score, so the result is inspectable rather than a black box.

FactorWeightWhat it measuresSub-score
Proximity0.5Distance to the nearest historical fire (ST_Distance).0 km → 100, ≥ 50 km → 0
Density0.3Count of fires within 20 km (ST_DWithin + COUNT).saturates at 20 fires → 100
Recency0.2Years since the most recent nearby fire.this year → 100, ≥ 50 yrs → 0

An honest note on the weights. These are a heuristic, not trained values — they encode the domain assumption that recent, close fire activity is the strongest risk signal. The principled next step is to gather ground-truth burn outcomes and fit the weights with a logistic regression so the data determines them. Because the scoring logic is a pure function decoupled from the database queries, swapping in trained weights is a localized change — and it's unit-tested with no database required.

Two implementations, one model
The canonical scoring logic lives in app/scoring.py as a tested Python function. The live explorer is a static site — its score is a hand-written JavaScript port of the same formula, computed entirely client-side against a bundled GeoJSON snapshot, so the map works instantly with no server round-trip.
// Spatial API

Queries, GeoJSON, and a routing gotcha

The API exposes 11 endpoints over the fire data: proximity search with ST_DWithin, per-year aggregates, name lookups, NASA FIRMS satellite detections near a point, and the risk score. Geometry-returning routes emit standards-compliant GeoJSON FeatureCollections in WGS84, ready to drop straight into Leaflet, Mapbox, QGIS, or geojson.io.

Implementation note — route ordering
Specific literal paths (/fires/summary, /fires/geojson/nearby) are declared before the catch-all wildcards (/fires/{fire_name}) so FastAPI matches the literal routes first instead of swallowing them into the wildcard.
// Testing & CI

A two-layer suite, Dockerized and gated

The test suite is split by dependency. The risk-scoring tests exercise the pure-math scoring function and run with no PostGIS required — covering the high-risk case, the low-risk case, the factor-breakdown structure, and score-bounds clamping. The API-route tests run against FastAPI's TestClient.

CI runs on GitHub Actions: the workflow spins up a postgis/postgis service container, seeds it with fixture data, and runs pytest on every push and pull request. The whole stack — API plus PostGIS — is reproducible locally through a single docker-compose up.

CI PASSINGSEEDED POSTGIS FIXTURESDOCKER-COMPOSE97% COVERAGE
// Foundations

Six analysis notebooks underneath

The API sits on top of a series of geospatial notebooks that build the underlying skills in order — from CRS transformations and buffer/intersection analysis to raster elevation and slope (including writing a Cloud-Optimized GeoTIFF), interactive Leafmap visualization of the 2025 LA fires, and the final PostGIS integration that loads the data and builds the spatial queries the API depends on.

CRS transformations
Reprojections + centroid distances to CA cities
Buffers & intersections
Buffering perimeters against populated places
Rasterio elevation
Slope analysis + Cloud-Optimized GeoTIFF output
Leafmap — 2025 LA fires
Interactive HTML map of the January fires
PostGIS integration
Loading fire + FIRMS data, building spatial queries
Fire perimeter exploration
Choropleth mapping, top-10 largest fires
Explore it yourself
Click anywhere on the live map to get a risk score, or read the full source.
Back to all work