Data ingestion and API for UK flood monitoring, curated polygons, and time‑series access.
- Read the Data Flow guide to see how data is acquired, curated, and served: docs/data-flow.md
- For deployment, Cloudflare/Railway setup, and cost notes: docs/hosting-options.md
- Services
- lake-worker: runs ingestion and backfill jobs
- lake-api: FastAPI service serving Data Lake endpoints
- Sources
- Environment Agency Flood Monitoring (hydrology, rainfall, flood zones, RSE)
- Data layout
- Raw readings: data/raw/ea/readings/{measure_id}/{YYYY}-{MM}.ndjson.gz
- Discovery outputs: data/raw/ea/stations/.ndjson.gz; data/raw/ea/measures/.ndjson.gz
- Curated polygons: data/curated/ea/*.geojson
Flood Watch (Laravel) already proxies this lake. Folding the lake into Laravel would mostly be an ops / coupling change, not a free performance win. Runtime language is rarely the bottleneck.
Where Laravel would be similar or fine
- Thin JSON proxies (warnings, storm catalogue, health): PHP-FPM vs Uvicorn is noise next to network and file I/O.
- Reading gzipped NDJSON month files and returning aggregates: I/O-bound either way; PHP streams fine.
- Caching and rate limits: Redis (or APCu) in Laravel can match the current in-process Python caches if done properly.
Where Laravel would likely be worse
- LiDAR volume / road strip (
rasterio+ NumPy over large GeoTIFFs): clearest regression. PHP has no first-class equivalent — you would shell out to GDAL/Python, call a sidecar, or precompute. A “Laravel lake” still needs a numeric worker. - Hindcast analogue matching: CPU over many gauge hours. Python is merely adequate today; PHP can do it, but without NumPy-style arrays you fight GC/memory on long series unless results are pre-indexed in Postgres.
- Ingestion / backfill workers: Laravel queues are solid, but EA hydrology and WCS clients would be rewritten, and long-lived workers must stay isolated from the Livewire/LLM web pool. Sharing that pool is the real risk.
Where performance actually comes from (either stack)
- Precompute analogues / volume and store results
- Postgres (+ PostGIS) instead of scanning NDJSON on every request
- Separate worker pool from the user-facing app
- Keep HiPIMS / DEM work out of request threads
Bottom line: merging into Laravel might simplify deploy/auth, but History volume and prediction would still need a Python (or GDAL) sidecar — or accept slower numeric paths. The lake stays separate so backfills and heavy analytics do not contend with Flood Watch; that isolation matters more than Python vs PHP. See also docs/architecture-plan.md.
- Docker Desktop installed (macOS, Apple Silicon supported)
- Start services:
- make up
- Tail logs:
- make logs-worker
- make logs-api
- Restart API:
- make restart-api
- Run collector (month range and region):
- FROM=YYYY-MM TO=YYYY-MM REGION=SOM MAX_STATIONS=1 MAX_MEASURES=1 ./scripts/run-collector.sh
- Makefile shortcut:
- FROM=YYYY-MM TO=YYYY-MM REGION=SOM make collector
- Notes
- Idempotent writes per slice
- Re-run same slice to resume after interruptions
- Base: lake-api (Uvicorn) started by docker compose
- Health:
- GET /healthz
- Polygons:
- GET /v1/polygons?dataset=flood_zones®ion=SOM&inline=true&bbox=w,s,e,n
- GET /v1/polygons/tiles/{dataset}/{z}/{x}/{y}?region=SOM
- Measurements:
- GET /v1/measurements?measure_id={id}&from=YYYY-MM-DDTHH:MM:SSZ&to=YYYY-MM-DDTHH:MM:SSZ&aggregate=raw|hour|day&page=1&limit=500
- Rate limits and caching:
- In‑process TTL cache; per‑IP quotas
graph LR
EA[(Environment Agency APIs)]
Worker[lake-worker]
Raw[data/raw/**]
Curated[data/curated/**]
API[lake-api (FastAPI)]
Clients[Clients]
EA --> Worker
Worker --> Raw
Worker --> Curated
Raw --> API
Curated --> API
Clients --> API
API --> Clients
subgraph API Internals
RL[X-RateLimit headers]
TTL[Cache-Control TTL + ETag]
end
API --> RL
API --> TTL
sequenceDiagram
participant C as Client
participant A as lake-api
participant D as Discovery (stations/measures)
participant FS as File Store (NDJSON)
C->>A: GET /v1/measurements?measure_id=...&bbox=...
A->>D: load_latest_measures_map()
A->>D: load_latest_stations()
A->>A: bbox check (station inside?)
alt outside bbox
A->>C: 200 { series: [], station { name, lat, lng } }
else inside bbox
A->>FS: read_ndjson_gz(month files)
A->>A: aggregate_points(raw|hour|day)
A->>C: 200 series + station
end
Note right of A: Adds ETag, Cache-Control, X-RateLimit headers
flowchart TD
C[Client] -->|GET /v1/polygons| A[lake-api]
A -->|open curated file| Cur[data/curated/ea/...geojson]
A -->|inline=true| Filter[filter by bbox (small only)]
A -->|tiles| Tile[compute tile bbox and filter]
A --> Cache[cache_set + ETag + TTL]
Cache --> C
-
Drivers
- Raw readings NDJSON per measure per month at data/raw/ea/readings/{measure}/{YYYY}-{MM}.ndjson.gz
- Discovery dumps (stations, measures) at data/raw/ea/stations/.ndjson.gz and data/raw/ea/measures/.ndjson.gz
- Curated polygons GeoJSON at data/curated/ea/**
- Logs for worker/API containers
-
Rule‑of‑Thumb
- Hydrology readings: ~0.2 MB per measure per month (15‑min cadence, gzipped)
- Stations snapshot: ~5–20 MB gz
- Measures snapshot: ~3–15 MB gz
- Flood Zones per region (simplified): ~20–80 MB
- RSE per scenario per region: ~10–60 MB
-
Formulas
- Hydrology total ≈ 0.2 MB × measures × months; months = years × 12
- Discovery total ≈ (stations + measures per snapshot) × snapshots retained
- Polygons total ≈ sum(region flood_zones) + sum(region × scenarios RSE)
-
Examples
- 1 region, 500 measures, 5 years → hydrology ≈ 6 GB; polygons ≈ 60–320 MB; discovery ≈ 40–70 MB; total ≈ 6.1–6.3 GB
- 5 regions, 2,000 measures, 10 years → hydrology ≈ 48 GB; polygons ≈ 0.3–1.6 GB; discovery ≈ 0.2–0.5 GB; total ≈ 49–51 GB
- API‑only with curated polygons → ~0.35–1.7 GB
-
Remote Service Footprint
Endpoint / Capability Required Data Current Size Can Omit? /v1/polygonsdata/curated/ea/**~1.8 GB No, if serving polygons /v1/polygons/tilesdata/curated/ea/**~1.8 GB No, if serving tiles /v1/measurementsdata/raw/ea/readings/**plusdata/raw/ea/stations/**anddata/raw/ea/measures/**~13 MB for readings currently; discovery snapshots are additional when present Yes, if you do not need measurements /v1/warningsNo large local dataset required; warnings are fetched/normalized live Minimal Yes, if you do not need warnings Polygon-only hosted service App + curated polygons only ~2 GB class footprint N/A Current full dataset App + curated polygons + raw readings/discovery ~3 GB class footprint N/A -
Current On-Disk Snapshot
data: ~2.9 GBdata/raw: ~1.2 GBdata/curated: ~1.8 GBdata/raw/ea/readings: ~13 MBdata/curated/ea: ~1.8 GB
-
Useful Dataset Scenarios (Somerset-only guide)
Scenario Assumption Estimated Total Footprint Starter useful 1 region, 12 months, ~500 measures, curated polygons ~1.5–3.0 GB Operational useful 1 region, 24 months, ~500 measures, curated polygons ~2.7–4.2 GB Strong historical context 1 region, 5 years, ~500 measures, curated polygons ~6.3–7.8 GB Safer hosted budget Same as above, plus Docker/log/temp headroom ~10 GB recommended -
Practical Recommendation
- For a first hosted version, Somerset-only with 2–3 years of measurements plus curated polygons is likely the best balance of usefulness and size.
- In practice, this is a roughly 3–5 GB class dataset, with 10 GB disk giving safer operating headroom.
-
Control Footprint
- Scope by region and parameters (level, flow)
- Limit backfill windows (FROM/TO)
- Retain minimal discovery snapshots
- Prefer simplified polygons when suitable
- Use MAX_STATIONS and MAX_MEASURES to throttle runs
-
Overview
- Store datasets in Cloudflare R2 (public bucket or behind CDN)
- Serve lake-api as stateless compute (Railway/Fly) reading files over HTTP
-
Configure API to read curated polygons remotely
- Set REMOTE_BASE_URL to the public base that contains curated files
- Example: https://cdn.example.com/ea
- File layout expectation
- Local: data/curated/ea/{region}{scenario_or_fz2_3}{simplified|normalized}.geojson
- Remote: {REMOTE_BASE_URL}/ea/{region}{scenario_or_fz2_3}{simplified|normalized}.geojson
- Behavior
- If local file exists, it is used
- If local file is missing and REMOTE_BASE_URL is set, API fetches via HTTP
- Endpoints: /v1/polygons and /v1/polygons/tiles/… support remote loading
- Set REMOTE_BASE_URL to the public base that contains curated files
-
Notes
- Prefer simplified files for lower latency and CDN cache friendliness
- Keep strong caching via ETag and Cache-Control; put CDN in front for free/cheap egress
- Raw NDJSON can also be hosted the same way; a similar adapter can be added later if needed
- API tests (inside lake-api container):
- make test-api
- Ingestion tests (inside lake-worker container):
- make test
- CI uses Docker targets to run tests on push
- Roadmap: docs/ROADMAP.md
- Changelog: docs/CHANGELOG.md
- OpenAPI: docs/openapi-data-lake.yaml
- Architecture: docs/architecture-plan.md
- Data Flow: docs/data-flow.md
- Hosting Options: docs/hosting-options.md
- AWS Deployment: docs/aws-deploy.md
- Data Sources: docs/data-sources.md
- Project Brief: docs/project-brief.md
- Agents: AGENTS.md
-
Measurements (raw)
curl "http://localhost:8000/v1/measurements?measure_id=TESTMEASURE&from=2026-03-10T00:00:00Z&to=2026-03-10T02:00:00Z&aggregate=raw&limit=100" -
Measurements (hourly)
curl "http://localhost:8000/v1/measurements?measure_id=TESTMEASURE&from=2026-03-10T00:00:00Z&to=2026-03-10T02:00:00Z&aggregate=hour&limit=100" -
Polygons (inline metadata + small bbox)
curl "http://localhost:8000/v1/polygons?dataset=flood_zones®ion=SOM&format=simplified&inline=true&bbox=-3.90,50.90,-2.20,51.40" -
Polygons tiles (Web Mercator)
curl "http://localhost:8000/v1/polygons/tiles/flood_zones/10/511/340?region=SOM&format=simplified" -
Warnings (with geometry; min severity = Flood Alert)
curl "http://localhost:8000/v1/warnings?region=SOM&min_severity=3" -
Warnings (county filter, no region)
curl "http://localhost:8000/v1/warnings?county=Somerset&min_severity=2" -
Warnings (bbox filter)
curl "http://localhost:8000/v1/warnings?bbox=-3.00,50.90,-2.50,51.20&min_severity=3"