Documentation Technique — flood-risk-api
Derniere mise a jour : 2026-03-15 (session 17 — Selection emprise par morphologie batiment)
Table des matières
- Vue d'ensemble
- Infrastructure Docker
- Base de données
- API FastAPI
- Pipeline ETL
- Cache Redis
- Frontend
- Nginx
- Configuration & Variables d'environnement
- Opérations courantes
- Analyse volumetrique batiments
- Multi-tenant claims (sinistres)
- Securite & isolation tenant
- Analyse visuelle IA (Vision)
- TODOs & Points ouverts
1. Vue d'ensemble
flood-risk-api est une API REST géospatiale qui évalue le risque d'inondation pour n'importe quelle adresse belge.
Flux principal
Adresse / Coordonnées GPS
│
▼
[Géocodage Nominatim] ← cache Redis geo: (TTL 30 jours)
│
▼
[PostGIS assess_point()]
├── flood_zones (intersections ST_Intersects + water_depth_m)
├── flood_events (historique DISTINCT, ST_Intersects)
├── flood_events (water_depth_m MAX — 1 requête, partagée entre zones)
├── buildings (bâtiment le plus proche, 50m KNN) + cadgis_buildings (LEFT JOIN centroide 10m)
├── parcels (parcelle cadastrale)
├── waterways (cours d'eau le plus proche, 2000m KNN)
└── nearest_flood_zone (zone inondable la plus proche, 2000m KNN)
│
▼
[Score de risque]
│
▼
Réponse JSON ← cache Redis assess: (TTL 24h)
Stack technique
| Composant | Technologie | Version |
|---|---|---|
| API | Python / FastAPI | 3.12 / 0.110+ |
| Base de données | PostgreSQL + PostGIS | 15 / 3.4 |
| Cache / Progression | Redis | 7.2 |
| Reverse proxy | Nginx | alpine |
| Worker ETL | Python / asyncio | 3.12 |
| HTTP client ETL | httpx | async |
| Carte interactive | MapLibre GL JS (CDN) | 4.x |
| Orchestration | Docker Compose | v2 |
2. Infrastructure Docker
Services (docker-compose.yml)
flood-risk-api/
├── postgres → postgis/postgis:15-3.4-alpine (port 5432)
├── redis → redis:7.2-alpine (port 6379)
├── api → ./api/Dockerfile (port 8000, exposé via Nginx)
├── worker → ./worker/Dockerfile (pas de port exposé)
└── nginx → nginx:alpine (port 80 → public)
Volumes persistants
| Volume | Contenu |
|---|---|
postgres_data | Données PostgreSQL (tables géospatiales) |
redis_data | Données Redis (persistance AOF) |
Dépendances de démarrage
postgres (healthy) ──┬──▶ api
redis (healthy) ──┘
└──▶ worker
nginx dépend de api
Healthchecks
- postgres :
pg_isready -U floodrisk - redis :
redis-cli ping - api :
curl -f http://localhost:8000/health - worker :
python -c "import asyncio, redis.asyncio as r; asyncio.run(r.from_url('redis://redis:6379').ping())"(intervalle 30s, grace 60s)
Sécurité containers
Les containers api et worker s'exécutent en tant qu'utilisateur non-root appuser (UID 1000) :
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
Limites de ressources
| Service | CPU max | Mémoire max |
|---|---|---|
| api | 1 CPU | 512 MB |
| worker | 2 CPU | 3 GB |
Ces limites évitent qu'un job ETL mémoire-intensif (Flandre GeoTIFF, ~3 GB RAM) ne prive l'API de ressources.
Répertoire de travail dans les containers
| Service | WORKDIR | Module Python |
|---|---|---|
| api | /app | api.main:app |
| worker | /app | worker.main |
Important :
api/DockerfilefaitCOPY . ./api/(et nonCOPY . .) pour que le module Pythonapisoit accessible sous/app/api/. Le volume dev est./api:/app/api:ro.
3. Base de données
Schéma (postgres/init/01_schema.sql)
flood_zones — Zones d'aléa d'inondation
id BIGSERIAL PRIMARY KEY
region TEXT -- 'wallonia' | 'flanders' | 'brussels'
source TEXT -- 'wallonia_flood_zones' | ...
layer_name TEXT -- nom de la couche source
hazard_level TEXT -- 'Élevé' | 'Moyen' | 'Faible'
hazard_type TEXT -- 'fluvial' | 'pluvial' | ...
hazard_code TEXT -- code interne source
return_period INTEGER -- période de retour (ans) : 25 | 100 | 500
geom GEOMETRY(MultiPolygon,4326) -- INDEX GiST
source_date DATE
is_current BOOLEAN DEFAULT TRUE -- FALSE = données archivées
ingested_at TIMESTAMPTZ DEFAULT NOW()
buildings — Bâtiments OSM
id BIGSERIAL PRIMARY KEY
osm_id BIGINT UNIQUE
building TEXT -- 'yes' | 'house' | 'apartments' | ...
levels INTEGER
area_m2 FLOAT
geom GEOMETRY(MultiPolygon,4326) -- INDEX GiST
is_current BOOLEAN DEFAULT TRUE
ingested_at TIMESTAMPTZ DEFAULT NOW()
parcels — Parcelles cadastrales
id BIGSERIAL PRIMARY KEY
capakey TEXT UNIQUE -- référence cadastrale belge (CAPAKEY)
region TEXT
area_m2 FLOAT
geom GEOMETRY(MultiPolygon,4326) -- INDEX GiST
is_current BOOLEAN DEFAULT TRUE
ingested_at TIMESTAMPTZ DEFAULT NOW()
waterways — Cours d'eau OSM
id BIGSERIAL PRIMARY KEY
osm_id BIGINT UNIQUE
name TEXT
waterway TEXT -- 'river'|'stream'|'canal'|'ditch'|'drain'
geom GEOMETRY(MultiLineString,4326) -- INDEX GiST
is_current BOOLEAN DEFAULT TRUE
ingested_at TIMESTAMPTZ DEFAULT NOW()
cadgis_buildings — Emprises cadastrales officielles (postgres/init/16_cadgis_buildings.sql)
object_id BIGINT PRIMARY KEY -- ID fixe par region (BRU: 0-999K, VLA: 10M-16M, WAL: 20M-24M)
global_id TEXT -- identifiant global source
building_type TEXT -- type de batiment source
status SMALLINT -- statut fiscal
area_m2 FLOAT -- surface au sol officielle (cadastre)
geom GEOMETRY(MultiPolygon,4326) -- INDEX GiST
is_current BOOLEAN DEFAULT TRUE -- FALSE = donnees archivees
ingested_at TIMESTAMPTZ DEFAULT NOW()
Indexes :
idx_cadgis_buildings_geom: GiST surgeom(intersection spatiale)idx_cadgis_buildings_current: partiel suris_current = TRUEidx_cadgis_buildings_centroid: GiST surST_Centroid(geom)(jointure KNN dansassess_point())
Source : SPF Finances (CadGIS) — emprises de construction officielles belges (pas les parcelles). 3 couches regionales : Bpn_ReBu_BRU (Bruxelles), Bpn_ReBu_VLA (Flandre), Bpn_ReBu_WAL (Wallonie). ~9.6M batiments au total.
Strategie Object ID : des plages fixes par region evitent les collisions entre les 3 couches du shapefile :
- Bruxelles : 0 — 999 999
- Flandre : 10 000 000 — 15 999 999
- Wallonie : 20 000 000 — 23 999 999
data_ingestion_log — Journal ETL
id BIGSERIAL PRIMARY KEY
source TEXT NOT NULL
region TEXT
started_at TIMESTAMPTZ DEFAULT NOW()
finished_at TIMESTAMPTZ
status TEXT CHECK IN ('running','success','failed')
records_count INTEGER
error_message TEXT
flood_events — Événements d'inondation historiques (postgres/init/03_flood_events.sql)
id BIGSERIAL PRIMARY KEY
event_name TEXT -- nom lisible de l'événement
event_date DATE -- date de début
event_end DATE -- date de fin (optionnel)
region TEXT -- 'wallonia' | 'flanders' | 'brussels' | 'belgium'
source TEXT -- 'copernicus_ems' | 'spw' | ...
source_id TEXT -- identifiant activation (ex: EMSR518_AOI01)
severity TEXT -- 'minor' | 'moderate' | 'major' | 'catastrophic'
geom GEOMETRY(MultiPolygon,4326) -- INDEX GiST (emprise de l'événement)
ingested_at TIMESTAMPTZ DEFAULT NOW()
Deux stratégies de stockage coexistent :
- 1 ligne par fichier (
FloodHistoryEtl) : chaque fichier GeoJSON →unary_union→ 1MultiPolygon - 1 ligne par polygone (
SpwFloodZones2021Etl) : dataset haute résolution → polygones individuels pour exploiter l'index GiST (168 282 lignes).ST_Intersectsreste performant car chaque bbox est petite.
La fonction assess_point() utilise DISTINCT ON (source_id) pour retourner 1 seule entrée par événement quelle que soit la stratégie de stockage.
Données actuellement chargées :
EMSR518_AOI01— Inondations Belgique juillet 2021, Liège (Copernicus EMS) — 1 ligneEMSR518_AOI02— Inondations Belgique juillet 2021, Rochefort (Copernicus EMS) — 1 ligneSPW_NZ_FLOOD_2021— Zones inondées Wallonie, juillet 2021 (SPW INSPIRE NZ) — 168 282 lignes (résolution parcellaire ~10m, toute la Wallonie)
Autres tables
| Table | Usage |
|---|---|
dem_points | Points MNT/altitude (phase 3, non encore utilisé) |
geocode_cache | Cache géocodage Nominatim (SHA256 → coordonnées) |
address_results_cache | Cache résultats assess (géré par Redis en priorité) |
audit_log | Logs des appels API (endpoint, IP, durée, status) |
Fonctions PostGIS (postgres/init/02_functions.sql)
assess_point(lat FLOAT, lon FLOAT) → JSONB
Fonction principale. Exécutée à chaque appel /assess (hors cache). Version courante : v6 (postgres/init/16_cadgis_buildings.sql), remplace 04_assess_point_v2.sql.
-- Retourne :
{
"in_flood_zone": true/false,
"zones": [...], -- zones intersectant le point, chaque zone inclut "water_depth_m"
"flood_history": [...], -- événements historiques (DISTINCT ON source_id)
"building": {...}, -- bâtiment dans 50m (KNN) + footprint_source + cadgis_area_m2
"parcel": {...}, -- parcelle cadastrale (CAPAKEY)
"nearest_waterway": {...}, -- cours d'eau dans 2000m (KNN) + closest_lat/lon
"nearest_flood_zone": {...} -- zone inondable dans 2000m (KNN) + closest_lat/lon
}
Utilise ST_Intersects (index GiST), ST_DWithin + opérateur KNN <->.
Optimisation N+1 (migration 04) : water_depth_m (profondeur max observée, source AZI IDW 2021) est calculé une seule fois dans une variable v_water_depth avant la boucle de zones, puis réutilisé dans chaque zone. Évite N appels ST_Intersects sur flood_events (un par zone).
Index géographie : idx_flood_events_geog (GIST((geom::geography))) accélère les requêtes ST_DWithin(geom::geography, ...) sur flood_events.
CadGIS v6 (migration 16) : CTE closest_bldg isole la recherche KNN du batiment OSM le plus proche, puis un LEFT JOIN cadgis_buildings par proximite de centroide (ST_DWithin 10m) enrichit le resultat avec l'emprise cadastrale officielle. Nouveaux champs retournes : footprint_source ('cadgis' si match, 'osm' sinon) et cadgis_area_m2 (surface officielle du cadastre). La confiance sur l'emprise passe de 0.75 (OSM) a 0.95 (CadGIS) dans le calcul de surface habitable.
Selection emprise par morphologie (v15) : _select_footprint() dans assess_service.py choisit la source d'emprise en fonction de la morphologie du batiment. Pour les maisons mitoyennes (terraced, semi_detached), l'emprise OSM est utilisee directement (retour footprint_source = "osm", footprint_reason = "morphology_row_house"). Raison : CadGIS = emprise cadastrale officielle (corps principal uniquement), OSM = emprise tracee par la communaute (inclut extensions et annexes). Pour les maisons en rangee, OSM est plus fidele car il capture les extensions que CadGIS ignore. Pour les batiments detaches, CadGIS reste la source prioritaire.
cleanup_expired_cache() → INTEGER
Purge address_results_cache dont expires_at < NOW().
dataset_summary() → TABLE
Vue synthétique des dernières ingestions réussies par source.
Pattern de mise à jour des données (stage & swap)
1. UPDATE flood_zones SET is_current=FALSE WHERE region=$1 AND is_current=TRUE
2. INSERT nouveaux records (is_current=TRUE) par batches de 5000
3. (atomique dans une transaction)
Les anciennes données restent en base (is_current=FALSE) pour audit/rollback.
4. API FastAPI
Structure des fichiers
api/
├── main.py # App FastAPI, montage des routers, CORS, lifespan
├── config.py # Settings (pydantic-settings, .env)
├── database.py # Pool asyncpg (get_pool, fetch_all, fetch_row)
├── cache.py # Redis async (get_redis, invalidate_all_assess)
├── models/
│ ├── request.py # IngestRequest, AssessRequest
│ ├── response.py # AssessResponse, IngestResponse, HealthResponse...
│ └── claims.py # ClaimCreate, ClaimResponse, ClaimListResponse...
├── routers/
│ ├── assess.py # POST /assess (+ enrichissement tenant_claims_context)
│ ├── claims.py # CRUD /claims (X-Tenant-Key requis)
│ ├── datasets.py # GET /datasets
│ ├── monitoring.py # GET /health, GET /metrics
│ ├── admin.py # POST /admin/ingest, GET /admin/*, tenants, claims admin
│ └── vision.py # POST /assess/vision (analyse visuelle IA)
├── middleware/
│ ├── tenant_auth.py # Dependance FastAPI get_current_tenant (X-Tenant-Key)
│ ├── tenant_logger.py # Middleware logging API calls tenant dans tenant_api_logs
│ └── call_tracker.py # Tracker sub-calls pour logs detailles
└── services/
├── assess_service.py # Logique metier assess (geocodage + PostGIS + scoring)
├── claims_service.py # CRUD sinistres, import CSV, stats, claims_context
├── tenant_service.py # Creation/auth/quota tenants (SHA-256 hash)
├── insurance_score.py # Score actuariel P*S*E (actuarial_v2)
├── vision_service.py # Analyse visuelle Street View + OpenAI Vision
└── building_height_service.py # Analyse volumetrique (Google Solar + LiDAR)
Endpoints
POST /assess
Body: { "address": "Rue de la Loi 1, Bruxelles" }
ou { "lat": 50.8503, "lon": 4.3517 }
Réponse:
{
"api_version": "1.0",
"address": "...",
"lat": 50.8503,
"lon": 4.3517,
"risk_score": 8.5, // 0-10
"risk_level": "Élevé", // Faible | Moyen | Élevé
"in_flood_zone": true,
"zones": [
{
"hazard_level": "Élevé",
"hazard_type": "fluvial",
"return_period": 100,
"region": "wallonia",
"source": "wallonia_flood_zones",
"water_depth_m": 1.45 // profondeur max observée (AZI IDW 2021) — null si aucun événement
}
],
"building": { "osm_id": 123, "building": "house", "levels": 2, "area_m2": 120.5, "dist_m": 3.2, "footprint_source": "cadgis", "cadgis_area_m2": 118.3 },
"parcel": { "capakey": "62099B0001/00M000", "area_m2": 250.0, "region": "wallonia" },
"nearest_waterway": { "name": "Vesdre", "waterway": "river", "dist_m": 45.2 },
"flood_history": [
{
"event_name": "Inondations Belgique juillet 2021 — Liège (AOI01)",
"event_date": "2021-07-14",
"event_end": "2021-07-16",
"region": "wallonia",
"source": "copernicus_ems",
"source_id": "EMSR518_AOI01",
"severity": "catastrophic",
"water_height_m": 1.45
}
],
"data_freshness_days": 3, // calculé depuis data_ingestion_log (MAX finished_at)
"cache_hit": false,
"computed_at": "2026-03-03T10:00:00Z"
}
Cache Redis : cle assess:v6:{lat:.4f}:{lon:.4f}, TTL 24h. Le prefixe v6 assure l'invalidation automatique des caches stales lors d'un changement de schema de reponse.
Enrichissement tenant : si le header
X-Tenant-Keyest present et valide, la reponse incluttenant_claims_contextavec les sinistres du tenant a proximite (200m/500m/2km). Ce champ n'est PAS mis en cache (enrichi apres le cache).
GET /datasets
Liste les sources de données disponibles avec statut d'ingestion.
GET /health
{ "status": "ok", "postgres": "ok", "redis": "ok", "version": "1.0.0" }
GET /metrics
Métriques Prometheus (format texte).
POST /admin/ingest (X-API-Key requis)
Body: { "source": "wallonia_flood_zones", "force": false }
Réponse 202: { "task_id": "abc123", "source": "...", "status": "queued" }
Sources valides : wallonia_flood_zones, flanders_flood_zones, brussels_flood_zones, osm_buildings, osm_waterways, cadastre, flood_history, wallonia_flood_zones_2021, wallonia_azi_2021, cadgis_buildings
Implémentation : l'API pousse un job dans la file Redis
etl:queue(RPUSH). Le worker écoute cette file via BLPOP (queue_listener) et exécute le job dans unasyncio.Task. Ceci évite que l'API importe les modules ETL (non disponibles dans son container).Sécurité : la clé API est vérifiée via
secrets.compare_digest()(comparaison en temps constant) pour prévenir les attaques par timing.
GET /admin/progress (X-API-Key requis)
Retourne la progression en temps réel des ingestions ETL actives (depuis Redis).
[{
"source": "wallonia_flood_zones",
"phase": "download", // download | insert | done | failed
"current": 200000,
"total": 4327194,
"pct": 4.6,
"elapsed_s": 407,
"eta_s": 8440,
"pages_done": 200,
"pages_total": 4328,
"status": "running",
"updated_at": "2026-03-01T21:34:42Z"
}]
GET /admin/logs?limit=30 (X-API-Key requis)
Dernières entrées de data_ingestion_log.
GET /admin/stats (X-API-Key requis)
Nombre d'enregistrements actuels par table (is_current=TRUE vs total).
POST /admin/geocache/clear (X-API-Key requis)
Vide les clés geo:* dans Redis.
POST /admin/cache/clear (X-API-Key requis)
Vide les cles assess:* dans Redis.
Endpoints tenants (admin) (X-API-Key requis)
| Methode | Endpoint | Description |
|---|---|---|
POST | /admin/tenants | Creer un tenant {"name":"...","tier":"standard","quota_monthly":10000} |
GET | /admin/tenants | Lister tous les tenants |
POST | /admin/tenants/{id}/regenerate-key | Regenerer la cle API d'un tenant |
DELETE | /admin/tenants/{id} | Supprimer un tenant (CASCADE sur claims) |
GET | /admin/tenants/{id}/claims | Lister les sinistres d'un tenant |
GET | /admin/tenants/{id}/claims/stats | Statistiques sinistres d'un tenant |
POST | /admin/tenants/{id}/claims | Creer un sinistre pour un tenant |
POST | /admin/tenants/{id}/claims/upload | Upload CSV sinistres (max 10 MB) |
DELETE | /admin/tenants/{id}/claims/{cid} | Supprimer un sinistre |
GET | /admin/claims/template | Telecharger modele CSV |
Endpoints claims (tenant) (X-Tenant-Key requis)
| Methode | Endpoint | Description |
|---|---|---|
GET | /claims/me | Infos du tenant connecte (validation cle) |
POST | /claims | Creer un sinistre |
POST | /claims/upload | Import CSV (multipart/form-data, max 10 MB) |
GET | /claims | Lister sinistres (pagination limit/offset, filtres event_type/date_from/date_to) |
GET | /claims/stats | Statistiques agregees |
GET | /claims/context?lat=&lon= | Contexte sinistres autour d'un point (200m/500m/2km) |
GET | /claims/{id} | Detail d'un sinistre |
DELETE | /claims/{id} | Supprimer un sinistre |
DELETE | /claims/batch/{batch_id} | Supprimer un batch CSV complet |
POST /assess/vision
Analyse visuelle IA du batiment via Google Street View + OpenAI Vision.
Body: {
"lat": 50.6449, "lon": 5.5693,
"heading": 180.0,
"panos": [
{"pano_id": "abc123...", "date": "2010-06"},
{"pano_id": "xyz789...", "date": "2024-09"}
]
}
Reponse:
{
"score": 72,
"analysis": {
"score": 72,
"summary": "Batiment en bon etat general",
"facade": "...",
"menuiseries": "...",
"toiture": "...",
"soubassement": "...",
"evolution": {
"trend": "entretenu",
"estimated_age": "Renove il y a 5-10 ans",
"changes": "Fenetres PVC recentes...",
"details": {"facade": "...", "menuiseries": "...", "toiture": null}
}
},
"images_analyzed": 4,
"dates": ["2010-06", "2024-09"],
"model": "gpt-4.1",
"captures": [
{"label": "Vue facade (2024-09)", "data_url": "data:image/jpeg;base64,..."},
{"label": "Vue facade ancienne (2010-06)", "data_url": "data:image/jpeg;base64,..."}
]
}
- Capture : 3 angles actuels (facade pitch=5/fov=90, toiture pitch=30/fov=100, large pitch=10/fov=120) + 1 facade ancienne si pano historique disponible
- Modele : OpenAI Vision (configurable via
OPENAI_VISION_MODEL, defautgpt-4o) - Cache Redis : cle
vision:{lat:.5f}:{lon:.5f}:{heading:.0f}:{pano_suffix}, TTL 7 jours - Champ
panos: optionnel,[oldest, newest]depuis le Time Machine frontend. Permet la comparaison historique - Evolution : trend parmi
renovation_recente|entretenu|vieillissant|degrade. L'IA ignore les differences de qualite/luminosite entre epoques
GET /flood-events/geojson
Retourne tous les événements d'inondation historiques sous forme de GeoJSON FeatureCollection. Les géométries sont agrégées par source_id (ST_Collect), puis simplifiées (ST_SimplifyPreserveTopology, tolérance 0.003°, ~300 m) pour un usage cartographique. Ceci gère transparentement les sources multi-lignes (ex: SPW 2021 à 168 282 lignes).
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"source_id": "EMSR518_AOI01",
"event_name": "Inondations Belgique juillet 2021 — Liège (AOI01)",
"event_date": "2021-07-14",
"event_end": "2021-07-16",
"region": "wallonia",
"source": "copernicus_ems",
"severity": "catastrophic"
},
"geometry": { "type": "MultiPolygon", "coordinates": [...] }
}
]
}
Utilisé par le frontend pour afficher les emprises historiques sur la carte Leaflet.
5. Pipeline ETL
Architecture
worker/
├── main.py # Entry point : daemon Redis queue + CLI manuel
└── etl/
├── base.py # EtlBase (classe abstraite, logique commune)
├── wallonia.py # WalloniaEtl
├── flanders.py # FlandersEtl
├── brussels.py # BrusselsEtl
├── buildings.py # BuildingsEtl (OSM)
├── waterways.py # WaterwaysEtl (OSM)
├── cadastre.py # CadastreEtl
├── flood_history.py # FloodHistoryEtl (GeoJSON locaux)
├── spw_flood_zones_2021.py # SpwFloodZones2021Etl
├── wallonia_azi_2021.py # WalloniaAziEtl
└── cadgis_buildings.py # CadgisBuildingsEtl (emprises cadastrales SPF Finances)
Cycle ETL (EtlBase.run())
_log_start()
│
▼
_set_progress("download", 0, 0)
│
▼
download() ← méthode abstraite (implémentée par chaque ETL)
│
▼
validate() ← vérifie count, types, etc.
│
▼
_stage_and_swap() ← transaction atomique (désactive anciens + insère nouveaux)
│
▼
_log_finish("success")
_set_progress("done")
│
▼
_invalidate_cache() ← purge toutes les clés assess:* Redis
Sources de données
| Source | ETL | Protocole | URL / Fichiers |
|---|---|---|---|
| Wallonie | WalloniaEtl | ATOM GeoPackage (téléchargement unique) | SPW ATOM feed — GeoPackage ZIP 428 MB |
| Flandre | FlandersEtl | GeoTIFF raster (fournis manuellement) | data/flanders/*.tif — VMM OGRK metadata.vlaanderen.be |
| Bruxelles | BrusselsEtl | GML INSPIRE (HTTP GET) | app.bruxellesenvironnement.be/carto/inspire/gml/NaturalRiskZones_HazardArea.gml |
| Bâtiments | BuildingsEtl | OSM PBF | download.geofabrik.de/europe/belgium-latest.osm.pbf |
| Cours d'eau | WaterwaysEtl | OSM PBF | download.geofabrik.de/europe/belgium-latest.osm.pbf |
| Cadastre | CadastreEtl | WFS/ArcGIS | SPW/AGIV par région |
| Historique | FloodHistoryEtl | GeoJSON local | data/flood_history/*.geojson (fournis manuellement) |
| Wallonie 2021 | SpwFloodZones2021Etl | GML INSPIRE ZIP (téléchargement unique) | SPW INSPIRE NZ — NZ.ObservedEvent.SG_ZONES_INONDEES_2021.gml.zip 64 MB |
| AZI Wallonie 2021 | WalloniaAziEtl | Raster IDW (fourni manuellement) | data/wallonia_azi/ — niveaux d'eau observés juillet 2021 |
| CadGIS Buildings | CadgisBuildingsEtl | ZIP Shapefile (fourni manuellement) | data/cadgis_buildings/PP-FiscSit_*.zip — SPF Finances ~2.5 GB |
Wallonie — ATOM GeoPackage (WalloniaEtl)
Remplace la pagination ArcGIS REST (4 328 pages, rate-limiting à ~offset 257 000).
1. Téléchargement streaming du ZIP GeoPackage (428 MB) depuis le ATOM feed SPW
URL : geoservices.wallonie.be/geotraitement/spwdatadownload/results/{UUID}/ALEA_GEOPACKAGE_31370.zip
Cache local : /tmp/etl/wallonia_gpkg/alea.zip (réutilisé si déjà présent)
2. Extraction du .gpkg depuis le ZIP (en thread, synchrone)
3. Lecture du GeoPackage via SQLite3 (en thread)
- gpkg_geometry_columns → liste des tables géométriques
- Pour chaque ligne : parse blob GeoPackage (header GP + WKB) → shapely → GeoJSON
- Reprojection EPSG:31370 → EPSG:4326 (pyproj)
- Décodage CLASSEMENT → hazard_level / hazard_type
4. Nettoyage des fichiers temporaires
Dépendances : shapely (déjà installé), sqlite3 (built-in Python), zipfile (built-in).
Avantages : 1 seule requête HTTP (vs 4 328 pages), aucun rate-limiting, données identiques à l'ArcGIS.
Format en-tête GeoPackage (ISO 13249-3)
magic[2] = 0x47 0x50 | version[1] | flags[1] | srs_id[4] | [envelope optionnel]
flags bits 1-3 : type d'enveloppe (0=aucune, 1=XY→4 doubles, 2=XYZ→6, 3=XYM→6, 4=XYZM→8)
Après l'enveloppe : WKB standard → shapely.from_wkb()
Flandre — GeoTIFF raster OGRK (FlandersEtl)
Remplace successivement :
- La pagination ArcGIS REST OG (16 704 620 micro-polygones, 503 après 3h)
- Le WFS OGRK (
geoservice.waterinfo.be) — retournait les sections statistiquespotentieel_getroffen_inwoners(~12 km² en moyenne, pas des zones géométriques)
Approche : vectorisation raster → vecteur (rasterio + numpy).
Fichiers source (téléchargés manuellement depuis metadata.vlaanderen.be — VMM OGRK) :
data/flanders/
fluvial_current.tif ← overstroombaar_gebied_FLU_noCC.tif (2m, 116 946×45 485 px)
pluvial_current.tif ← overstroombaar_gebied_PLU_noCC.tif (2m, 118 445×45 490 px)
coastal_current.tif ← overstroombaar_gebied_KUST_noCC.tif (20m, 3 228×2 610 px)
Montés en lecture seule dans le container worker via ./data/flanders:/data/flanders:ro.
Encodage pixel : valeur pixel = période de retour en années
| Valeur | Hazard level | Return period |
|---|---|---|
| 10 | Élevé | T10 (10 ans) |
| 100 | Moyen | T100 (100 ans) |
| 1000 | Faible | T1000 (1000 ans) |
| 0 | nodata | — |
Note : la couche Côtier ne contient que T100 et T1000 (pas de T10).
Traitement :
Pour chaque raster (FLU / PLU / KUST) :
1. Lecture par blocs de 2 000 lignes (mémoire ~467 MB/bloc)
2. Pour chaque valeur de pixel (10, 100, 1000) :
- Masque binaire uint8
- rasterio.features.shapes() → polygones GeoJSON (CRS natif EPSG:31370)
- Filtrage des micro-artefacts < 4 m²
- shapely.simplify(tolerance=5m, preserve_topology=True)
- Reprojection EPSG:31370 → WGS84 (pyproj Transformer)
3. Insertion en base via _stage_and_swap() (batches 5 000)
Résultats (log ID 26, 2026-03-02, ~55 min) :
| Type | Niveau | Polygones | Surf. moy. | Surf. totale |
|---|---|---|---|---|
| Fluvial | Élevé | 231 037 | 1 340 m² | 309.6 km² |
| Fluvial | Moyen | 1 395 959 | 133 m² | 185.1 km² |
| Fluvial | Faible | 1 628 884 | 150 m² | 244.3 km² |
| Pluvial | Élevé | 1 257 775 | 390 m² | 490.9 km² |
| Pluvial | Moyen | 6 416 199 | 46 m² | 296.4 km² |
| Pluvial | Faible | 7 092 193 | 51 m² | 358.6 km² |
| Côtier | Moyen | 43 | 1 227 190 m² | 52.8 km² |
| Côtier | Faible | 2 306 | 63 818 m² | 147.2 km² |
| Total | 18 024 396 | 2 085 km² |
Dépendances : rasterio==1.4.3, numpy==2.2.3 (ajoutés à worker/requirements.txt).
Scénario noCC : situation actuelle (sans changement climatique). Scénario hCC (2050) disponible séparément sur metadata.vlaanderen.be.
Pagination ArcGIS (EtlBase.paginate_arcgis)
Toujours utilisé pour Bruxelles (GML INSPIRE remplacé par ArcGIS REST si disponible).
1. GET ?returnCountOnly=true → total features
2. Scan /tmp/etl/{source}/layer{id}/ → charge les pages déjà en checkpoint gzip
3. Téléchargement parallèle des pages manquantes (asyncio.Semaphore(concurrency=3))
4. Sauvegarde chaque page en checkpoint gzip (reprise si crash)
5. Mise à jour Redis toutes les 10 pages (~10 000 records)
6. Reconstitution dans l'ordre
7. Nettoyage des checkpoints en cas de succès
Checkpoints gzip (reprise après crash)
Chaque page téléchargée est sauvegardée dans :
/tmp/etl/{source_name}/layer{layer_id}/page_{offset:010d}.json.gz
Format du fichier :
{ "source": "wallonia_flood_zones", "total": 4327194, "offset": 15000, "features": [...] }
Au redémarrage d'une ingestion, les pages déjà présentes sont rechargées et seules les pages manquantes sont téléchargées. Le répertoire est supprimé en cas de succès.
Retry HTTP (EtlBase.fetch_json)
| Erreur | Tentatives | Backoff |
|---|---|---|
| 502/503/504 | 5 | 5s → 10s → 20s → 40s → 80s |
| ReadTimeout/ConnectTimeout | 5 | 1s → 2s → 4s → 8s → 16s |
| Autres erreurs HTTP | 1 | pas de retry |
Auto-retry ingestion (run_source)
En cas d'échec d'une ingestion, run_source relance automatiquement jusqu'à 5 fois avec les délais suivants :
| Tentative | Délai avant relance |
|---|---|
| 2/5 | 1 min |
| 3/5 | 2 min |
| 4/5 | 4 min |
| 5/5 | 8 min |
Les checkpoints gzip sont conservés entre les tentatives. La reprise reprend là où le téléchargement s'était arrêté.
ETL Historique d'inondations (FloodHistoryEtl)
Contrairement aux autres ETL, FloodHistoryEtl n'hérite pas d'EtlBase (pas de pagination, pas de stage/swap). Les données sont fournies manuellement sous forme de fichiers GeoJSON dans data/flood_history/.
Convention de nommage :
{source_id}_{YYYY-MM-DD}_{region}.geojson ← données géométriques
{source_id}_{YYYY-MM-DD}_{region}.meta.json ← métadonnées (optionnel, prioritaire)
Traitement :
1. Scan data/flood_history/*.geojson
2. Lecture des métadonnées (.meta.json ou dérivé du nom de fichier)
3. shapely.ops.unary_union() → fusionne toutes les features en 1 MultiPolygon
4. INSERT INTO flood_events (1 ligne par fichier .geojson)
Déclenchement :
# Ingestion initiale
curl -X POST http://localhost/admin/ingest \
-H "X-API-Key: <ADMIN_API_KEY>" \
-d '{"source": "flood_history", "force": false}'
# Avec force=true : TRUNCATE flood_events puis rechargement complet
curl -X POST http://localhost/admin/ingest \
-H "X-API-Key: <ADMIN_API_KEY>" \
-d '{"source": "flood_history", "force": true}'
Sources disponibles dans data/flood_history/ :
EMSR518_AOI01_2021-07-14_wallonia.geojson— Copernicus EMS, Liège, juillet 2021EMSR518_AOI02_2021-07-14_wallonia.geojson— Copernicus EMS, Rochefort, juillet 2021
Pour ajouter de nouveaux événements : déposer le fichier GeoJSON dans data/flood_history/ et relancer l'ingestion. Voir data/flood_history/README.md pour le format complet.
ETL Zones inondées Wallonie 2021 (SpwFloodZones2021Etl)
Source officielle SPW : cartographie des zones inondées lors des crues de juillet 2021, couvrant toute la Wallonie (201 640 polygones à résolution parcellaire ~10m). Corrige la lacune des Copernicus EMS AOIs qui ne couvraient pas la Vesdre orientale (Pepinster, Trooz, Verviers).
Format source : GML 3.2 INSPIRE NaturalRiskZones (ObservedEvent) — ZIP 64 MB, GML décompressé ~517 MB, EPSG:3812 (Belgian Lambert 2008).
Stratégie de stockage : contrairement à FloodHistoryEtl (1 ligne/événement), ce dataset est stocké en 1 ligne par polygone (168 282 lignes après unary_union) pour que l'index GiST de PostGIS reste efficace. Un seul MultiPolygon de 168K parties causerait un timeout (ST_Intersects O(n) sans sous-index).
Traitement :
1. Téléchargement streaming ZIP (64 MB) → /tmp/etl/spw_flood_zones_2021/NZ_ZONES_INONDEES_2021.zip
2. Extraction du GML depuis le ZIP → NZ_ZONES_INONDEES_2021.gml (517 MB)
3. Parsing iterparse : gml:Surface/gml:patches/gml:PolygonPatch → Polygon shapely
Reprojection EPSG:3812 → EPSG:4326 via pyproj (always_xy=True)
4. shapely.ops.unary_union() : 201 640 → 168 282 polygones (fusion des adjacents)
5. INSERT individuel par lots de 500 dans flood_events (source_id='SPW_NZ_FLOOD_2021')
Déclenchement :
curl -X POST http://localhost/admin/ingest \
-H "X-API-Key: <ADMIN_API_KEY>" \
-H "Content-Type: application/json" \
-d '{"source": "wallonia_flood_zones_2021", "force": false}'
Le ZIP et le GML sont conservés dans /tmp/etl/spw_flood_zones_2021/ (checkpoint). Une relance sans force saute le téléchargement.
CadGIS Buildings — ZIP Shapefile (CadgisBuildingsEtl)
Emprises de construction officielles du cadastre belge (SPF Finances / CadGIS). Remplace les polygones OSM (contributeurs benevoles, precision ~5-20%) par des emprises officielles (precision cadastrale).
Source : ZIP Shapefile PP-FiscSit_20250101_shp_3812_01000_Belgium.zip (~2.5 GB), place dans data/cadgis_buildings/. Monte en lecture seule dans le container worker via ./data/cadgis_buildings:/data/cadgis_buildings:ro.
3 couches regionales :
| Couche | Region | Batiments | Plage Object ID |
|---|---|---|---|
Bpn_ReBu_BRU | Bruxelles | ~249 000 | 0 — 999 999 |
Bpn_ReBu_VLA | Flandre | ~5 600 000 | 10 000 000 — 15 999 999 |
Bpn_ReBu_WAL | Wallonie | ~3 800 000 | 20 000 000 — 23 999 999 |
| Total | ~9 600 000 |
CRS : EPSG:3812 (Belgian Lambert 2008) → reprojete en EPSG:4326 (WGS84) via pyproj.
Architecture streaming (memoire ~50 MB max) :
Thread producteur (sync) asyncio.Queue(maxsize=4) Consommateur async
| | |
pyshp iterShapeRecords() ──batch 5000──► INSERT batches PostgreSQL
+ pyproj reprojection (stage & swap)
+ shapely MultiPolygon
Le producteur lit le shapefile record par record via iterShapeRecords() (pas de chargement en memoire), reprojette chaque geometrie avec pyproj et shapely, et pousse des batches de 5 000 enregistrements dans une asyncio.Queue(maxsize=4). Le consommateur async insere les batches en base. La back-pressure de la queue limite la memoire a ~50 MB quel que soit la taille du dataset.
Dependances : pyshp (lecture shapefile), pyproj (reprojection), shapely (geometries).
Declenchement :
curl -X POST http://localhost/admin/ingest \
-H "X-API-Key: <ADMIN_API_KEY>" \
-H "Content-Type: application/json" \
-d '{"source": "cadgis_buildings"}'
Integration dans assess_point() : la migration 16_cadgis_buildings.sql met a jour assess_point() v6 pour joindre automatiquement l'emprise CadGIS au batiment OSM le plus proche (par proximite de centroide, 10m max). Si un match est trouve, footprint_source = 'cadgis' et cadgis_area_m2 contient la surface officielle. Le service building_height_service.py utilise cette surface avec une confiance de 0.95 (au lieu de 0.75 pour OSM). Exception morphologie : pour les maisons mitoyennes (terraced, semi_detached), _select_footprint() force l'utilisation de l'emprise OSM car CadGIS ne contient que le corps principal et ignore les extensions/annexes. Les corrections Solar et LiDAR sont egalement desactivees pour ces morphologies (impossibilite de distinguer les maisons adjacentes de meme hauteur).
Déclenchement des ingestions (worker/main.py)
Le worker n'utilise pas APScheduler. Il s'exécute en mode daemon (écoute permanente de la file Redis etl:queue) et supporte un mode CLI pour les ingestions manuelles.
| Mode | Déclencheur | Commande |
|---|---|---|
| Daemon | POST /admin/ingest → file Redis | python -m worker.main (démarrage normal) |
| CLI source | Ingestion manuelle d'une source | python -m worker.main --run wallonia_flood_zones |
| CLI all | Ingestion de toutes les zones + OSM | python -m worker.main --run-all |
Concurrence : max MAX_CONCURRENT_JOBS = 2 jobs simultanés (contrôlé par asyncio.Semaphore).
Arrêt gracieux : sur SIGTERM/SIGINT, le worker arrête d'accepter de nouveaux jobs et attend la fin des jobs actifs (grace period 300s) avant de terminer.
File Redis etl:queue
L'API pousse des jobs via RPUSH. Le worker écoute via BLPOP dans queue_listener :
{ "source": "flanders_flood_zones", "force": false, "task_id": "a1b2c3d4" }
Lancer une ingestion manuelle
# Via l'interface admin (recommandé) :
# → admin.html → bouton "Déclencher ingestion" → source → OK
# Via l'API directement :
curl -X POST http://localhost/admin/ingest \
-H "X-API-Key: <ADMIN_API_KEY>" \
-H "Content-Type: application/json" \
-d '{"source": "wallonia_flood_zones"}'
# Via docker exec (en détaché, survit à la fermeture du terminal) :
docker exec -d flood-risk-api-worker-1 python -m worker.main --run wallonia_flood_zones
# Sources disponibles :
# wallonia_flood_zones | flanders_flood_zones | brussels_flood_zones
# osm_buildings | osm_waterways | cadastre | cadgis_buildings
# flood_history | wallonia_flood_zones_2021 | wallonia_azi_2021
6. Cache Redis
Clés et TTL
| Préfixe | Usage | TTL |
|---|---|---|
assess:v4:{lat:.4f}:{lon:.4f} | Résultat assess mis en cache | 24h |
geo:{hash} | Résultat géocodage Nominatim | 30 jours |
geo:parcel:{lat:.4f}:{lon:.4f} | GeoJSON parcelle cadastrale | 24h |
geo:zones:{lat:.4f}:{lon:.4f}:{radius} | GeoJSON zones d'aléa locales | 24h |
geo:building:pt:{lat:.4f}:{lon:.4f} | GeoJSON bâtiment le plus proche | 24h |
geo:building:osm:{id} | GeoJSON bâtiment par osm_id | 24h |
etl:progress:{source} | Progression ETL en temps réel | 2h (auto-expire) |
etl:queue | File de jobs ETL (LIST) — API → worker | Pas de TTL (BLPOP) |
**> Versionnage : le préfixe dans les clés assess assure l'invalidation automatique des caches stales lors d'un changement de schéma de réponse. Incrémenter dans lors de tout changement de modèle.
SCAN vs KEYS : toutes les opérations de suppression en masse (
clear_geocache,invalidate_all_assess,_invalidate_cacheETL) utilisentSCAN cursor MATCH count=500(non-bloquant) au lieu deKEYS(O(N) bloquant, interdit en production).
Structure clé etl:progress:{source}
{
"source": "wallonia_flood_zones",
"region": "wallonia",
"log_id": 6,
"phase": "download",
"current": 200000,
"total": 4327194,
"pct": 4.6,
"elapsed_s": 407,
"eta_s": 8440,
"layer_id": 2,
"pages_done": 200,
"pages_total": 4328,
"status": "running",
"updated_at": "2026-03-01T21:34:42Z"
}
Phases : download → insert → done | failed
7. Frontend
frontend/
├── index.html # Interface utilisateur principale (formulaire assess + carte)
├── admin.html # Interface d'administration (auth API key requise)
└── logs.html # Journal ETL dédié (auth API key requise)
Servi par Nginx comme fichiers statiques depuis /usr/share/nginx/html.
index.html — Fonctionnalités
- Formulaire : saisie adresse OU lat/lon →
POST /assess - UX cible : gestionnaires production assurance (progressive disclosure)
- Layout résultats :
- Bandeau synthèse : verdict (zone/hors-zone) + phrases auto-générées + score assurance (side-by-side)
- Meta row : adresse, coordonnées, confiance, statut cache
- Grille KPI (4 cards) : Zone inondable, Bâtiment, Terrain (altitude), Historique
- Breakdown chips : facteurs de risque si présents
- Historique d'inondations : TOUJOURS visible (pas en accordion), cartes événements avec sévérité
- Accordions : Zones d'aléa, Distance zone inondable par période (table), Bâtiment & surface habitable, Micro-topographie, Parcelle & hydrographie, Sources & confiance
- Carte interactive (MapLibre GL JS v4) :
- Fond OpenFreeMap
liberty - Marqueur au point évalué avec popup lat/lon
- Zones d'aléa locales depuis
/geo/zones(coucheszones-fill+zones-outline, match par hazard_level) - Emprises historiques depuis
/flood-events/geojson— rechargées à chaque assess - Vue 3D (extrusion bâtiments)
- Google Street View (heading calculé vers le bâtiment cible)
- Fond OpenFreeMap
- Export PDF : jsPDF, capture carte + Street View + toutes les sections
admin.html — Fonctionnalités
- Auth : saisie clé API → validée via
GET /admin/stats→ stockée ensessionStorage - Progression en temps réel : poll adaptatif (2.5s si ingestion active, 30s si idle)
- Barre de progression animée (bleu=download, vert=insert/done, rouge=failed)
- Point clignotant si en cours, ETA, pages done/total
- Santé services : chips PostgreSQL / Redis / API
- Volumes par table : barres visuelles (flood_zones, buildings, waterways, parcels)
- Journal ETL : 30 dernières opérations
- Actions : déclencher ingestion, vider caches
- Lien : bouton "📋 Logs ETL" en en-tête vers
logs.html
logs.html — Fonctionnalités
- Auth : même mécanisme que
admin.html - Cards synthèse : total / succès / échecs / en cours / durée moyenne / dernière activité
- Grille par source : statut coloré (vert/rouge/orange) avec dernière date et count
- Filtres : par source et par statut
- Table complète : #, source, région, statut, records, démarré, durée (barre visuelle), erreur (expandable)
- Auto-refresh : 5s si ingestion active, 30s sinon (avec compte à rebours)
- Pagination : chargement 50 entrées par page (max 200)
8. Nginx
Fichier : nginx/nginx.conf
Routage
# Requêtes API → FastAPI (port 8000)
location ~ ^/(assess|metrics|datasets|flood-events|admin/|docs|redoc|openapi\.json) {
proxy_pass http://api:8000;
}
# Frontend statique
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
Important :
admin/avec slash final (pasadmin) pour ne pas intercepter/admin.html.
En-têtes de sécurité
Appliqués globalement sur le server block HTTP :
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
Les routes API (/assess, /admin/) ajoutent en plus X-XSS-Protection et surchargeant X-Frame-Options: DENY.
HTTPS
Configuré en commentaire dans nginx.conf. À activer pour la production avec Let's Encrypt ou certificat personnalisé. HSTS (Strict-Transport-Security) est également commenté — à activer uniquement sur HTTPS.
9. Configuration & Variables d'environnement
Fichier .env (à la racine du projet)
POSTGRES_USER=floodrisk
POSTGRES_PASSWORD=<secret>
POSTGRES_DB=floodrisk
REDIS_URL=redis://redis:6379/0
ADMIN_API_KEY=<secret>
CACHE_TTL_SECONDS=86400
LOG_LEVEL=INFO
ETL_SCHEDULE_CRON=0 2 * * 1
NOMINATIM_USER_AGENT=ProjetZoneInondable/1.0
api/config.py (pydantic-settings)
Les settings sont lus depuis les variables d'environnement et accessibles via get_settings() (LRU cache).
10. Opérations courantes
Démarrage complet
cd flood-risk-api
docker compose up -d
docker compose logs -f
Rebuild d'un service
docker compose build api && docker compose up -d api
docker compose build worker && docker compose up -d worker
Vérifier l'état
# Santé API
curl http://localhost:8000/health
# Progression ETL
curl http://localhost:8000/admin/progress -H "X-API-Key: <ADMIN_API_KEY>"
# Logs d'ingestion
curl "http://localhost:8000/admin/logs?limit=5" -H "X-API-Key: <ADMIN_API_KEY>"
# Stats tables
curl http://localhost:8000/admin/stats -H "X-API-Key: <ADMIN_API_KEY>"
# Logs worker en live
docker logs -f flood-risk-api-worker-1
Tester une évaluation
curl -X POST http://localhost:8000/assess \
-H "Content-Type: application/json" \
-d '{"address": "Place Saint-Lambert 1, Liège"}'
Corriger une ingestion orpheline (status=running bloqué)
docker exec flood-risk-api-postgres-1 psql -U floodrisk -d floodrisk \
-c "UPDATE data_ingestion_log SET status='failed', finished_at=NOW(),
error_message='Orphaned: worker container restarted' WHERE id=<ID>;"
Vider le cache Redis
# Cache assess seulement
curl -X POST http://localhost:8000/admin/cache/clear -H "X-API-Key: <ADMIN_API_KEY>"
# Cache géocodage seulement
curl -X POST http://localhost:8000/admin/geocache/clear -H "X-API-Key: <ADMIN_API_KEY>"
Empêcher la mise en veille Windows (pendant une ingestion longue)
Add-Type -Name Win32 -Namespace "" -MemberDefinition '[DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint esFlags);'
[Win32]::SetThreadExecutionState(0x80000001)
while ($true) { Start-Sleep -Seconds 60; Write-Host "$(Get-Date -Format 'HH:mm') - PC actif" }
11. Analyse volumetrique batiments
Documentation detaillee : docs/building-living-area.md
Vue d'ensemble
Le service api/services/building_height_service.py estime la surface habitable d'un
batiment en combinant 3 sources de donnees en parallele :
| Source | Donnee | Resolution | Cout |
|---|---|---|---|
| LiDAR regional (SPW / Vlaanderen) | Hauteur = DSM - DTM | 50cm (Wallonie), 1m (Flandre) | Gratuit, illimite |
| Google Solar API | Segmentation toit (pentes, surfaces, annexes) | ~1m | 10 000 appels/mois gratuits |
| OSM buildings | Emprise au sol (polygone) | Variable (~5-20%) | Gratuit |
| CadGIS (SPF Finances) | Emprise cadastrale officielle | Cadastrale (~1%) | Gratuit |
Formule
Surface habitable = Emprise_corrigee × (Niveaux + Facteur_combles) × Coefficient
Pipeline de calcul (9 etapes)
1. Extraire segments Google Solar, convertir ASL → AGL (via DTM LiDAR)
2. Separation gap-based : principal vs structures basses (gap > 3m)
3. Classification segments bas : terrain (<2m) | garage (2-3.5m) | extension (>3.5m)
4. Correction emprise OSM : retirer la proportion "terrain" du footprint
5. Type de toit : plat (<10°) ou pente (≥10°)
6. Detection mono-niveau : forcer 1 etage pour garage, shed, industrial, etc.
7. Estimation niveaux : hauteur_egout / 3m + combles dynamiques (≥1.8m belge)
8. Override OSM : si building:levels disponible, priorite sur l'estimation
9. Surface = emprise × (niveaux + combles) × coefficient d'habitabilite
Selection de la source d'emprise (_select_footprint)
Le choix de la source d'emprise (CadGIS vs OSM) depend de la morphologie du batiment :
| Morphologie | Source emprise | Raison | Confiance |
|---|---|---|---|
detached | CadGIS (si disponible) | Emprise officielle cadastrale, corps principal bien defini | 0.95 |
terraced | OSM | Maison en rangee : OSM capture les extensions/annexes que CadGIS ignore | 0.75 |
semi_detached | OSM | Maison jumelee : meme logique que terraced | 0.75 |
| Pas de match CadGIS | OSM | Fallback quand aucune emprise cadastrale n'est trouvee | 0.75 |
Logique (_select_footprint() dans api/services/assess_service.py) :
- Parametre
morphologyajoute (provient de la classification du batiment) - Retour anticipe pour
terraced/semi_detached:(osm_area, "osm", "morphology_row_house") - Pour les batiments detaches : CadGIS prioritaire si match disponible
Corrections Solar et LiDAR desactivees pour maisons mitoyennes :
_parse_combined()ne corrige plus le ratio Solar pour les maisons en rangee (la segmentation toit ne peut pas distinguer les maisons adjacentes ayant la meme hauteur)- La grille LiDAR 2D ne remplace plus
main_area_m2pour les maisons mitoyennes (meme raison : le LiDAR ne distingue pas les limites entre maisons adjacentes de meme hauteur)
Coefficients d'habitabilite
| Type | Coeff | Type | Coeff |
|---|---|---|---|
| house / detached / residential | 0.82 | commercial / retail | 0.87 |
| apartments / dormitory | 0.77 | office | 0.85 |
| industrial / warehouse | 0.90 | garage / shed | 0.95 |
| yes (generique OSM) | 0.80 | defaut | 0.80 |
Modes de fonctionnement
| Mode | Conditions | Precision |
|---|---|---|
| Combine (LiDAR + Solar) | LiDAR OK + Solar OK | ±15-25% |
| Solar seul | LiDAR echec, Solar OK | ±25-40% |
| LiDAR seul | Solar echec/404, LiDAR OK | ±20-30% |
Cache et persistance
- Premiere requete : appels LiDAR + Google (~1-2s) → resultat stocke dans
building_lidar - Requetes suivantes :
assess_point()fait LEFT JOIN → reponse instantanee (0ms) - Table :
building_lidar(osm_id PK, 14 colonnes), migration05_building_lidar.sql - Cache Redis : version
v15(bump a chaque changement de schema — v14→v15 pour selection emprise par morphologie)
Champs retournes (BuildingInfo)
building_height_m float Hauteur faitAge (LiDAR DSM-DTM)
roof_type str "flat" | "pitched"
annex_detected bool Annexe detectee par gap-based
main_area_m2 float Emprise corrigee (sans terrain)
annex_area_m2 float Emprise annexe
estimated_levels int Niveaux estimes
has_attic bool Combles habitables (≥15% surface ≥1.8m)
estimated_living_area_m2 float Surface habitable estimee
area_coefficient float Coefficient utilise
levels_source str "osm" | "google_solar+lidar" | "lidar"
footprint_source str "cadgis" | "osm" (source de l'emprise au sol)
footprint_reason str "morphology_row_house" | null (raison du choix OSM pour mitoyennes)
cadgis_area_m2 float Surface officielle cadastre (null si pas de match CadGIS)
Frontend
Un bouton ? a cote de "Surface habitable est." ouvre un tooltip
qui decompose le calcul complet : emprise, correction, niveaux, combles,
coefficient, et formule finale.
Score d'assurabilite (actuarial_v2)
Fichier : api/services/insurance_score.py
Modele actuariel : Score = P x S x E (probabilite x severite x exposition), transforme via raw^0.45 * 100 sur echelle 0-100.
Composante P (probabilite)
| Facteur | Source | Poids |
|---|---|---|
p_zone | Zone d'alea (in/out + distance) | 0.20-0.85 |
p_eau | Proximite cours d'eau (type + distance) | 0-0.30 |
p_topo | Micro-topographie (pente, cuvette) | 0-0.15 |
Multiplicateur d'elevation (_elevation_multiplier) :
- Bien au-dessus de la source d'eau :
max(0.05, exp(-h/5))-- +5m = x0.37, +10m = x0.14 - Bien en contrebas :
min(2.0, 1 + |h|/5)-- lineaire, plafond x2.0 - Zone morte +/-1m (marge erreur DEM GLO-30)
- Applique a
p_zone(hors zone) etp_eau
Composante S (severite)
| Facteur | Source |
|---|---|
s_depth | Profondeur d'eau observee (AZI IDW 2021) |
s_hazard | Niveau d'alea theorique |
s_topo | Terrain en cuvette |
s_freq | Frequence historique |
Composante E (exposition)
| Facteur | Source |
|---|---|
e_building | Type de batiment |
e_levels | Nombre de niveaux |
e_area | Surface habitable |
e_elevation | Altitude du bien |
Labels
| Score | Label |
|---|---|
| >= 60 | Critique |
| >= 35 | Eleve |
| >= 15 | Modere |
| < 15 | Faible |
Altitude relative (elevation-aware)
Fichier : postgres/init/07_relative_elevation.sql
assess_point() v4 utilise ST_ClosestPoint pour retourner les coordonnees du point le plus proche sur la geometrie du cours d'eau et de la zone inondable. Ces coordonnees sont ensuite utilisees pour obtenir l'altitude via Open-Meteo (batch API) et calculer la difference d'altitude avec le bien cible.
waterway_elevation_diff_m: difference altitude bien - cours d'eau (via topo_service)fz_elevation_diff: difference altitude bien - zone inondable la plus proche (via assess_service)- Batch Open-Meteo : cible + zone inondable en 1 appel HTTP (pas d'appel supplementaire)
12. Multi-tenant claims (sinistres)
Architecture
Systeme multi-tenant permettant aux assureurs d'uploader et gerer leurs sinistres inondation/catastrophes naturelles.
Tables PostgreSQL
| Table | Description |
|---|---|
tenants | Assureurs enregistres (id, name, api_key_hash, tier, quota, usage) |
claims | Sinistres par tenant (localisation, date, montants, type, cause) |
tenant_api_logs | Journal detaille des appels API par tenant |
Schema claims
idUUID PK,tenant_idUUID FK → tenants(id) ON DELETE CASCADE- Localisation :
address,lat,lon,geom(GEOGRAPHY auto-genere) - Evenement :
event_date,event_type,cause - Financier :
damage_amount_eur,deductible_eur,payout_eur,insured_value_eur - Propriete :
property_type,floor_level,has_basement,building_year - Meta :
external_ref,source(manual/csv/api_push),batch_id,imported_at
Fonction SQL claims_context()
Appelee lors de /assess avec X-Tenant-Key. Retourne :
claims_within_200m,avg_damage_200m,avg_payout_200mclaims_within_500m,claims_within_2kmtotal_damage_eur,avg_damage_eur,max_damage_eurearliest_claim_date,last_claim_dateevent_types[],causes[]
Filtre : WHERE c.tenant_id = p_tenant_id AND ST_DWithin(c.geom, point, 2000).
Import CSV
Mapping automatique FR/EN des colonnes (ex: montant → damage_amount_eur, adresse → address). Validation : lat/lon obligatoires, date non-future, coordonnees valides, NaN/Infinity rejetes. Support Excel via SheetJS (conversion client-side dans admin.html).
Authentification tenant
- Cle API format
tk_{secrets.token_urlsafe(32)}(~43 caracteres) - Stockage : SHA-256 hash en base (
api_key_hash), jamais en clair - Header :
X-Tenant-Key: tk_... - Quota mensuel auto-reset (
usage_reset_at)
Frontend
- Admin (
admin.html) : page "Sinistres" avec selection tenant, liste claims, stats KPI, modale creation, upload CSV/Excel, suppression - Principal (
index.html) : carte bleue "Sinistres declares a proximite" dans Historique + accordion "Historique voisinage" dans Analyse detaillee. Affiche uniquement sitenant_claims_contextpresent (securite)
13. Securite & isolation tenant
Architecture de defense en profondeur (5 couches)
┌─────────────────────────────────────────────────────────┐
│ COUCHE 1 — Authentification (tenant_auth.py) │
│ Header X-Tenant-Key → SHA-256 → lookup DB │
│ Absent → 401 | Invalide → 401 | Inactif → 401 │
├─────────────────────────────────────────────────────────┤
│ COUCHE 2 — Quota (check_and_increment_quota) │
│ Depassement → 429 │
├─────────────────────────────────────────────────────────┤
│ COUCHE 3 — WHERE applicatif (claims_service.py) │
│ Toutes les requetes : WHERE tenant_id = $1 │
│ claims_context() SQL : WHERE c.tenant_id = p_tenant_id │
├─────────────────────────────────────────────────────────┤
│ COUCHE 4 — SET app.current_tenant (acquire_tenant_conn) │
│ SET LOCAL dans chaque transaction PostgreSQL │
├─────────────────────────────────────────────────────────┤
│ COUCHE 5 — RLS PostgreSQL (FORCE ROW LEVEL SECURITY) │
│ Policy claims_tenant_isolation : tenant_id = setting │
│ Role floodrisk_app (non-superuser) → RLS applique │
└─────────────────────────────────────────────────────────┘
Role applicatif PostgreSQL
| Role | Usage | Superuser | RLS |
|---|---|---|---|
floodrisk | Worker ETL (COPY, CREATE INDEX, migrations) | Oui | Bypass |
floodrisk_app | API FastAPI (runtime) | Non | Applique |
Migration : postgres/init/11_app_role.sql. Le docker-compose.yml configure l'API avec floodrisk_app.
CRITIQUE : un superuser PostgreSQL bypass TOUJOURS le RLS, meme avec
FORCE ROW LEVEL SECURITY. C'est pourquoi l'API doit utiliser un role non-superuser.
Politiques RLS
| Politique | Condition | Usage |
|---|---|---|
claims_tenant_isolation | tenant_id = current_setting('app.current_tenant')::uuid | Acces tenant |
claims_admin_access | current_setting('app.is_admin') = 'true' | Acces admin |
Cache et isolation
Le cache Redis /assess ne contient PAS tenant_claims_context. L'enrichissement tenant est effectue APRES la lecture du cache, dans assess.py (lignes 64-72). Ainsi :
- Le cache est partage entre tous les appelants (performance)
- Chaque tenant recoit uniquement ses propres donnees claims (securite)
Frontend
_tenantKeystocke enlocalStorage(SPA standard)claimsCardHtmletclaimsAccordionHtmlrendus UNIQUEMENT sid.tenant_claims_contextexiste- Deconnexion tenant → suppression
localStorage+ reset UI
14. Analyse visuelle IA (Vision)
Architecture
Frontend (streetview.js)
|
+-- Bouton "Analyser le batiment"
+-- Collecte : lat, lon, heading, panos (oldest+newest)
|
v
POST /assess/vision (api/routers/vision.py)
|
+-- Cache Redis check (TTL 7 jours)
|
v
vision_service.capture_and_analyze() (api/services/vision_service.py)
|
+-- Google Street View Static API
| +-- 3 angles actuels (facade, toiture, large)
| +-- 1 facade ancienne (si pano historique dispo)
|
+-- OpenAI Vision API (GPT-4.1)
| +-- SYSTEM_PROMPT (expert inspection, focus centre, prudence historique)
| +-- Images base64 haute resolution + labels
|
+-- Reponse JSON
+-- score (0-100)
+-- analysis {facade, menuiseries, toiture, soubassement, evolution}
+-- captures [{label, data_url}] (images base64 pour le frontend)
+-- dates, model, images_analyzed
Presets camera
| Preset | Pitch | FOV | Usage |
|---|---|---|---|
| Vue facade | 5 deg | 90 deg | Etat general facade, menuiseries, soubassement |
| Vue toiture | 30 deg | 100 deg | Toit, gouttieres, cheminee, zinguerie |
| Vue large | 10 deg | 120 deg | Contexte general, proportions, environnement |
SYSTEM_PROMPT — instructions cles
-
Focus batiment central : "Concentre-toi UNIQUEMENT sur le batiment situe au centre de l'image, directement face a la camera. Ignore completement les batiments adjacents, voisins ou en arriere-plan." Critique en rues etroites ou les facades voisines sont partiellement visibles.
-
Evaluation : facade (fissures, humidite, joints), menuiseries (fenetres, portes, double vitrage), toiture (tuiles, gouttieres, cheminee), soubassement (humidite ascendante, fissures).
-
Evolution prudente : si photo ancienne fournie, detecter UNIQUEMENT changements MAJEURS et STRUCTURELS (ravalement, remplacement toiture, nouvelles fenetres, ajout etage). IGNORER les differences de qualite d'image, luminosite, saison, compression entre epoques (cameras Street View de generations differentes).
-
Indices de renovation : detecter sur les photos actuelles des signes de renovation recente (crepi neuf, raccords, couleurs fraiches, materiaux modernes) ou degradation ancienne (mousses, decoloration, fissures patinees).
Format evolution
{
"trend": "renovation_recente|entretenu|vieillissant|degrade",
"estimated_age": "Renove il y a 5-10 ans",
"changes": "2-4 phrases decrivant les indices d'evolution",
"details": {
"facade": "indice d'evolution ou null",
"menuiseries": "indice d'evolution ou null",
"toiture": "indice d'evolution ou null"
}
}
Trends et couleurs
| Trend | Label FR | Couleur | Signification |
|---|---|---|---|
renovation_recente | Renovation recente | Vert #43A047 | Travaux recents visibles (crepi, fenetres, toiture) |
entretenu | Bien entretenu | Bleu #29B6F6 | Bon etat general, pas de degradation visible |
vieillissant | Vieillissant | Orange #FB8C00 | Signes d'usure, pas encore critique |
degrade | Degrade | Rouge #E53935 | Degradation avancee, travaux necessaires |
Cache
- Cle :
vision:{lat:.5f}:{lon:.5f}:{heading:.0f}:{pano_id_oldest[:8]} - TTL : 7 jours (604 800 secondes)
- Le suffixe pano ID dans la cle assure qu'un changement de couverture historique invalide le cache
Variables d'environnement
| Variable | Defaut | Description |
|---|---|---|
OPENAI_API_KEY | (requis) | Cle API OpenAI |
OPENAI_VISION_MODEL | gpt-4o | Modele OpenAI Vision a utiliser |
GOOGLE_MAPS_KEY | (requis) | Cle Google Maps (Street View Static API) |
Frontend
- Bouton :
#btn-sv-analyzedans le panneau Street View - Rendu inline : score circle + summary + 4 sections categorie + section evolution (pill couleur)
- Modale detail : pattern
.score-modal-overlayreutilise, grid images, ponderation par categorie (facade 30%, menuiseries 25%, toiture 30%, soubassement 15%), evolution avec badge + estimated_age + details par categorie + comparaison avant/apres cote a cote - CSS :
.sv-info-btn,.sv-modal-grid,.sv-modal-img,.sv-evo-pill,.sv-evo-badge,.sv-evo-compare,.sv-evo-img,.sv-evo-arrow
15. TODOs & Points ouverts
| Priorité | Fichier | Description |
|---|---|---|
| Haute | Ingestions | Lancer wallonia_azi_2021 (données water_height_m pour les zones) |
| Moyenne | nginx/nginx.conf | Activer HTTPS pour la production |
| Basse | postgres/init/02_functions.sql | cleanup_expired_cache() — planifier en cron |
| Basse | api/routers/admin.py | Endpoint DELETE /admin/ingest/{id} pour annuler une ingestion en cours |
| Basse | dem_points | Table MNT altitude (phase 3, source Copernicus DEM 30m) |
Corrections appliquees — audit securite 2026-03-07
| Categorie | Fichier | Correction |
|---|---|---|
| CRITIQUE | docker-compose.yml | API utilise role floodrisk_app (non-superuser) au lieu de floodrisk (superuser). Le RLS etait contourne. |
| Securite | postgres/init/11_app_role.sql | Creation role floodrisk_app avec permissions granulaires (SELECT/INSERT/UPDATE/DELETE, pas de SUPERUSER) |
| Securite | PostgreSQL | Politique claims_admin_access recreee (manquante en base) |
| Securite | api/routers/assess.py | tenant_claims_context enrichi uniquement si X-Tenant-Key valide et tenant actif |
| Securite | frontend/index.html | Claims affiches uniquement si tenant_claims_context present (pas de fuite cross-tenant) |
Corrections appliquees — audit securite 2026-03-03
| Catégorie | Fichier | Correction |
|---|---|---|
| Sécurité | api/routers/admin.py | secrets.compare_digest() (comparaison clé API en temps constant) |
| Sécurité | api/main.py | CORS : allow_credentials=False si allow_origins=["*"] |
| Sécurité | worker/etl/base.py | ALLOWED_TABLES whitelist — empêche injection SQL via nom de table |
| Sécurité | worker/Dockerfile, api/Dockerfile | Utilisateur non-root appuser (UID 1000) |
| Performance | api/cache.py | SCAN au lieu de KEYS pour invalidation Redis |
| Performance | api/routers/admin.py | SCAN au lieu de KEYS dans clear_geocache, get_etl_progress |
| Performance | postgres/init/04_assess_point_v2.sql | N+1 fix : water_depth_m calculé une seule fois avant la boucle zones |
| Performance | postgres/init/04_assess_point_v2.sql | Index idx_flood_events_geog (GIST((geom::geography))) |
| Performance | api/routers/geo.py | KNN bâtiment : filtre 100m max (ST_DWithin) |
| Robustesse | worker/main.py | SIGTERM gracieux + semaphore MAX_CONCURRENT_JOBS=2 + grace period 300s |
| Robustesse | worker/main.py | Distinction erreurs fatales vs récupérables |
| Robustesse | api/services/assess_service.py | Timeout 3s sur Open-Meteo via asyncio.wait_for |
| Robustesse | worker/etl/buildings.py | Timeout 1800s sur executor OSM PBF |
| Qualité | api/routers/assess.py | Audit log await (garantit traçabilité RGPD) |
| Qualité | api/services/assess_service.py | data_freshness_days calculé (remplace None) |
| Qualité | ||
| Qualité | api/models/response.py | api_version: "1.0" dans AssessResponse |
| Docker | docker-compose.yml | Limites ressources : api (1 CPU/512 MB), worker (2 CPU/3 GB) |
| Docker | docker-compose.yml | Healthcheck worker (Redis ping) |
| Nginx | nginx/nginx.conf | En-têtes sécurité globaux (X-Content-Type-Options, X-Frame-Options, Referrer-Policy) |
État des ingestions (2026-03-02)
| Source | Log ID | Statut | Records | Détails |
|---|---|---|---|---|
wallonia_flood_zones | 22 | ✅ Success | 5 246 320 | ATOM GeoPackage SPW — durée ~10 min |
flanders_flood_zones | 26 | ✅ Success | 18 024 396 | GeoTIFF raster VMM OGRK — durée ~55 min |
brussels_flood_zones | 10 | ✅ Success | 206 | GML INSPIRE Bruxelles Environnement |
osm_buildings | 27 | ✅ Success | 7 473 128 | Bug asyncio corrigé (séparer thread/main loop) — durée ~16 min |
osm_waterways | 28 | ✅ Success | 151 615 | Complété session s5 |
cadastre | 29+ | ✅ Success | ~9 M | BPNCAPA bulk GML, 581 communes |
flood_history | — | ✅ Success | 2 | EMSR518 AOI01 (Liège) + AOI02 (Rochefort) |
wallonia_flood_zones_2021 | — | ✅ Success | 168 282 | SPW INSPIRE NZ, zones inondées juillet 2021, toute la Wallonie |
Problèmes connus et correctifs appliqués
- Ingestion orpheline : si le worker est redémarré pendant une ingestion, le statut DB reste
running. Corriger manuellement :UPDATE data_ingestion_log SET status='failed', finished_at=NOW(),error_message='Orphaned' WHERE id=<ID>; - Wallonie ArcGIS rate-limiting : le serveur
geoservices.wallonie.bebloquait à ~offset 257 000 (502/504). Corrigé : remplacement par l'ATOM GeoPackage (1 seule requête HTTP). - Flandre ArcGIS rate-limiting : le serveur VMM donnait 503 après ~3h pour les 16,7M micro-polygones OG. Corrigé : remplacement par les GeoTIFF rasters OGRK (vectorisation rasterio, 18 024 396 polygones précis à 2m).
- Flandre WFS OGRK sections statistiques : le WFS
geoservice.waterinfo.be/OGRKretournait les couchespotentieel_getroffen_inwoners(sections statistiques ~12 km² en moyenne, total 110 000 km² = 8× la Flandre). Pas des zones géométriques d'inondation. Corrigé : remplacement par les GeoTIFF rasters OGRKoverstroombaar_gebied_*_noCC.tif. - Brussels ETL : l'ancienne implémentation WFS pointait vers le serveur UrbIS (
geoservices-urbis.irisnet.be). Corrigé : source GML INSPIREapp.bruxellesenvironnement.be. - OSM handlers :
_BuildingHandleret_WaterwayHandlern'héritaient pas deosmium.SimpleHandler. Corrigé : wrapperSimpleHandleravecapply_file(path, locations=True). - OSM buildings/waterways asyncio :
_parse_and_insertcréaitasyncio.new_event_loop()maisself.poolappartient à la boucle principale → "Task got Future attached to a different loop". Corrigé : séparation en_download_and_parse()(sync, thread pool, retournelist[dict]) +_insert_batch()(async, main loop).asyncio.get_event_loop()→asyncio.get_running_loop(). POST /admin/ingest: l'API ne disposait pas du moduleworker→ModuleNotFoundError. Corrigé : passage par la file Redisetl:queue.- APScheduler supprimé : le scheduler cron a été remplacé par un daemon pur (écoute
etl:queuevia BLPOP). Les ingestions périodiques doivent être déclenchées viaPOST /admin/ingestoudocker exec ... python -m worker.main --run <source>. - Temps d'ingestion : Wallonie ~10 min (GeoPackage 428 MB), Flandre GeoTIFF OGRK ~55 min (vectorisation 5,3 Gpx + insertion 18 M records), Brussels ~2s, OSM ~30–60 min selon taille du PBF.