Aller au contenu principal

Documentation Technique — flood-risk-api

Derniere mise a jour : 2026-03-15 (session 17 — Selection emprise par morphologie batiment)


Table des matières

  1. Vue d'ensemble
  2. Infrastructure Docker
  3. Base de données
  4. API FastAPI
  5. Pipeline ETL
  6. Cache Redis
  7. Frontend
  8. Nginx
  9. Configuration & Variables d'environnement
  10. Opérations courantes
  11. Analyse volumetrique batiments
  12. Multi-tenant claims (sinistres)
  13. Securite & isolation tenant
  14. Analyse visuelle IA (Vision)
  15. 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

ComposantTechnologieVersion
APIPython / FastAPI3.12 / 0.110+
Base de donnéesPostgreSQL + PostGIS15 / 3.4
Cache / ProgressionRedis7.2
Reverse proxyNginxalpine
Worker ETLPython / asyncio3.12
HTTP client ETLhttpxasync
Carte interactiveMapLibre GL JS (CDN)4.x
OrchestrationDocker Composev2

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

VolumeContenu
postgres_dataDonnées PostgreSQL (tables géospatiales)
redis_dataDonné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

ServiceCPU maxMémoire max
api1 CPU512 MB
worker2 CPU3 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

ServiceWORKDIRModule Python
api/appapi.main:app
worker/appworker.main

Important : api/Dockerfile fait COPY . ./api/ (et non COPY . .) pour que le module Python api soit 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 sur geom (intersection spatiale)
  • idx_cadgis_buildings_current : partiel sur is_current = TRUE
  • idx_cadgis_buildings_centroid : GiST sur ST_Centroid(geom) (jointure KNN dans assess_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 → 1 MultiPolygon
  • 1 ligne par polygone (SpwFloodZones2021Etl) : dataset haute résolution → polygones individuels pour exploiter l'index GiST (168 282 lignes). ST_Intersects reste 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 ligne
  • EMSR518_AOI02 — Inondations Belgique juillet 2021, Rochefort (Copernicus EMS) — 1 ligne
  • SPW_NZ_FLOOD_2021 — Zones inondées Wallonie, juillet 2021 (SPW INSPIRE NZ) — 168 282 lignes (résolution parcellaire ~10m, toute la Wallonie)

Autres tables

TableUsage
dem_pointsPoints MNT/altitude (phase 3, non encore utilisé)
geocode_cacheCache géocodage Nominatim (SHA256 → coordonnées)
address_results_cacheCache résultats assess (géré par Redis en priorité)
audit_logLogs 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-Key est present et valide, la reponse inclut tenant_claims_context avec 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 un asyncio.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)

MethodeEndpointDescription
POST/admin/tenantsCreer un tenant {"name":"...","tier":"standard","quota_monthly":10000}
GET/admin/tenantsLister tous les tenants
POST/admin/tenants/{id}/regenerate-keyRegenerer la cle API d'un tenant
DELETE/admin/tenants/{id}Supprimer un tenant (CASCADE sur claims)
GET/admin/tenants/{id}/claimsLister les sinistres d'un tenant
GET/admin/tenants/{id}/claims/statsStatistiques sinistres d'un tenant
POST/admin/tenants/{id}/claimsCreer un sinistre pour un tenant
POST/admin/tenants/{id}/claims/uploadUpload CSV sinistres (max 10 MB)
DELETE/admin/tenants/{id}/claims/{cid}Supprimer un sinistre
GET/admin/claims/templateTelecharger modele CSV

Endpoints claims (tenant) (X-Tenant-Key requis)

MethodeEndpointDescription
GET/claims/meInfos du tenant connecte (validation cle)
POST/claimsCreer un sinistre
POST/claims/uploadImport CSV (multipart/form-data, max 10 MB)
GET/claimsLister sinistres (pagination limit/offset, filtres event_type/date_from/date_to)
GET/claims/statsStatistiques 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, defaut gpt-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

SourceETLProtocoleURL / Fichiers
WallonieWalloniaEtlATOM GeoPackage (téléchargement unique)SPW ATOM feed — GeoPackage ZIP 428 MB
FlandreFlandersEtlGeoTIFF raster (fournis manuellement)data/flanders/*.tif — VMM OGRK metadata.vlaanderen.be
BruxellesBrusselsEtlGML INSPIRE (HTTP GET)app.bruxellesenvironnement.be/carto/inspire/gml/NaturalRiskZones_HazardArea.gml
BâtimentsBuildingsEtlOSM PBFdownload.geofabrik.de/europe/belgium-latest.osm.pbf
Cours d'eauWaterwaysEtlOSM PBFdownload.geofabrik.de/europe/belgium-latest.osm.pbf
CadastreCadastreEtlWFS/ArcGISSPW/AGIV par région
HistoriqueFloodHistoryEtlGeoJSON localdata/flood_history/*.geojson (fournis manuellement)
Wallonie 2021SpwFloodZones2021EtlGML INSPIRE ZIP (téléchargement unique)SPW INSPIRE NZ — NZ.ObservedEvent.SG_ZONES_INONDEES_2021.gml.zip 64 MB
AZI Wallonie 2021WalloniaAziEtlRaster IDW (fourni manuellement)data/wallonia_azi/ — niveaux d'eau observés juillet 2021
CadGIS BuildingsCadgisBuildingsEtlZIP 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 statistiques potentieel_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

ValeurHazard levelReturn period
10ÉlevéT10 (10 ans)
100MoyenT100 (100 ans)
1000FaibleT1000 (1000 ans)
0nodata

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) :

TypeNiveauPolygonesSurf. moy.Surf. totale
FluvialÉlevé231 0371 340 m²309.6 km²
FluvialMoyen1 395 959133 m²185.1 km²
FluvialFaible1 628 884150 m²244.3 km²
PluvialÉlevé1 257 775390 m²490.9 km²
PluvialMoyen6 416 19946 m²296.4 km²
PluvialFaible7 092 19351 m²358.6 km²
CôtierMoyen431 227 190 m²52.8 km²
CôtierFaible2 30663 818 m²147.2 km²
Total18 024 3962 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)

ErreurTentativesBackoff
502/503/50455s → 10s → 20s → 40s → 80s
ReadTimeout/ConnectTimeout51s → 2s → 4s → 8s → 16s
Autres erreurs HTTP1pas 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 :

TentativeDélai avant relance
2/51 min
3/52 min
4/54 min
5/58 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 2021
  • EMSR518_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 :

CoucheRegionBatimentsPlage Object ID
Bpn_ReBu_BRUBruxelles~249 0000 — 999 999
Bpn_ReBu_VLAFlandre~5 600 00010 000 000 — 15 999 999
Bpn_ReBu_WALWallonie~3 800 00020 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.

ModeDéclencheurCommande
DaemonPOST /admin/ingest → file Redispython -m worker.main (démarrage normal)
CLI sourceIngestion manuelle d'une sourcepython -m worker.main --run wallonia_flood_zones
CLI allIngestion de toutes les zones + OSMpython -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éfixeUsageTTL
assess:v4:{lat:.4f}:{lon:.4f}Résultat assess mis en cache24h
geo:{hash}Résultat géocodage Nominatim30 jours
geo:parcel:{lat:.4f}:{lon:.4f}GeoJSON parcelle cadastrale24h
geo:zones:{lat:.4f}:{lon:.4f}:{radius}GeoJSON zones d'aléa locales24h
geo:building:pt:{lat:.4f}:{lon:.4f}GeoJSON bâtiment le plus proche24h
geo:building:osm:{id}GeoJSON bâtiment par osm_id24h
etl:progress:{source}Progression ETL en temps réel2h (auto-expire)
etl:queueFile de jobs ETL (LIST) — API → workerPas 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_cache ETL) utilisent SCAN cursor MATCH count=500 (non-bloquant) au lieu de KEYS (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 : downloadinsertdone | 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 :
    1. Bandeau synthèse : verdict (zone/hors-zone) + phrases auto-générées + score assurance (side-by-side)
    2. Meta row : adresse, coordonnées, confiance, statut cache
    3. Grille KPI (4 cards) : Zone inondable, Bâtiment, Terrain (altitude), Historique
    4. Breakdown chips : facteurs de risque si présents
    5. Historique d'inondations : TOUJOURS visible (pas en accordion), cartes événements avec sévérité
    6. 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 (couches zones-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)
  • 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 en sessionStorage
  • 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 (pas admin) 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 :

SourceDonneeResolutionCout
LiDAR regional (SPW / Vlaanderen)Hauteur = DSM - DTM50cm (Wallonie), 1m (Flandre)Gratuit, illimite
Google Solar APISegmentation toit (pentes, surfaces, annexes)~1m10 000 appels/mois gratuits
OSM buildingsEmprise au sol (polygone)Variable (~5-20%)Gratuit
CadGIS (SPF Finances)Emprise cadastrale officielleCadastrale (~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 :

MorphologieSource empriseRaisonConfiance
detachedCadGIS (si disponible)Emprise officielle cadastrale, corps principal bien defini0.95
terracedOSMMaison en rangee : OSM capture les extensions/annexes que CadGIS ignore0.75
semi_detachedOSMMaison jumelee : meme logique que terraced0.75
Pas de match CadGISOSMFallback quand aucune emprise cadastrale n'est trouvee0.75

Logique (_select_footprint() dans api/services/assess_service.py) :

  • Parametre morphology ajoute (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_m2 pour les maisons mitoyennes (meme raison : le LiDAR ne distingue pas les limites entre maisons adjacentes de meme hauteur)

Coefficients d'habitabilite

TypeCoeffTypeCoeff
house / detached / residential0.82commercial / retail0.87
apartments / dormitory0.77office0.85
industrial / warehouse0.90garage / shed0.95
yes (generique OSM)0.80defaut0.80

Modes de fonctionnement

ModeConditionsPrecision
Combine (LiDAR + Solar)LiDAR OK + Solar OK±15-25%
Solar seulLiDAR echec, Solar OK±25-40%
LiDAR seulSolar 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), migration 05_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)

FacteurSourcePoids
p_zoneZone d'alea (in/out + distance)0.20-0.85
p_eauProximite cours d'eau (type + distance)0-0.30
p_topoMicro-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) et p_eau

Composante S (severite)

FacteurSource
s_depthProfondeur d'eau observee (AZI IDW 2021)
s_hazardNiveau d'alea theorique
s_topoTerrain en cuvette
s_freqFrequence historique

Composante E (exposition)

FacteurSource
e_buildingType de batiment
e_levelsNombre de niveaux
e_areaSurface habitable
e_elevationAltitude du bien

Labels

ScoreLabel
>= 60Critique
>= 35Eleve
>= 15Modere
< 15Faible

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

TableDescription
tenantsAssureurs enregistres (id, name, api_key_hash, tier, quota, usage)
claimsSinistres par tenant (localisation, date, montants, type, cause)
tenant_api_logsJournal detaille des appels API par tenant

Schema claims

  • id UUID PK, tenant_id UUID 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_200m
  • claims_within_500m, claims_within_2km
  • total_damage_eur, avg_damage_eur, max_damage_eur
  • earliest_claim_date, last_claim_date
  • event_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: montantdamage_amount_eur, adresseaddress). 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 si tenant_claims_context present (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

RoleUsageSuperuserRLS
floodriskWorker ETL (COPY, CREATE INDEX, migrations)OuiBypass
floodrisk_appAPI FastAPI (runtime)NonApplique

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

PolitiqueConditionUsage
claims_tenant_isolationtenant_id = current_setting('app.current_tenant')::uuidAcces tenant
claims_admin_accesscurrent_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

  • _tenantKey stocke en localStorage (SPA standard)
  • claimsCardHtml et claimsAccordionHtml rendus UNIQUEMENT si d.tenant_claims_context existe
  • 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

PresetPitchFOVUsage
Vue facade5 deg90 degEtat general facade, menuiseries, soubassement
Vue toiture30 deg100 degToit, gouttieres, cheminee, zinguerie
Vue large10 deg120 degContexte general, proportions, environnement

SYSTEM_PROMPT — instructions cles

  1. 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.

  2. Evaluation : facade (fissures, humidite, joints), menuiseries (fenetres, portes, double vitrage), toiture (tuiles, gouttieres, cheminee), soubassement (humidite ascendante, fissures).

  3. 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).

  4. 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"
}
}
TrendLabel FRCouleurSignification
renovation_recenteRenovation recenteVert #43A047Travaux recents visibles (crepi, fenetres, toiture)
entretenuBien entretenuBleu #29B6F6Bon etat general, pas de degradation visible
vieillissantVieillissantOrange #FB8C00Signes d'usure, pas encore critique
degradeDegradeRouge #E53935Degradation 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

VariableDefautDescription
OPENAI_API_KEY(requis)Cle API OpenAI
OPENAI_VISION_MODELgpt-4oModele OpenAI Vision a utiliser
GOOGLE_MAPS_KEY(requis)Cle Google Maps (Street View Static API)

Frontend

  • Bouton : #btn-sv-analyze dans le panneau Street View
  • Rendu inline : score circle + summary + 4 sections categorie + section evolution (pill couleur)
  • Modale detail : pattern .score-modal-overlay reutilise, 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éFichierDescription
HauteIngestionsLancer wallonia_azi_2021 (données water_height_m pour les zones)
Moyennenginx/nginx.confActiver HTTPS pour la production
Bassepostgres/init/02_functions.sqlcleanup_expired_cache() — planifier en cron
Basseapi/routers/admin.pyEndpoint DELETE /admin/ingest/{id} pour annuler une ingestion en cours
Bassedem_pointsTable MNT altitude (phase 3, source Copernicus DEM 30m)

Corrections appliquees — audit securite 2026-03-07

CategorieFichierCorrection
CRITIQUEdocker-compose.ymlAPI utilise role floodrisk_app (non-superuser) au lieu de floodrisk (superuser). Le RLS etait contourne.
Securitepostgres/init/11_app_role.sqlCreation role floodrisk_app avec permissions granulaires (SELECT/INSERT/UPDATE/DELETE, pas de SUPERUSER)
SecuritePostgreSQLPolitique claims_admin_access recreee (manquante en base)
Securiteapi/routers/assess.pytenant_claims_context enrichi uniquement si X-Tenant-Key valide et tenant actif
Securitefrontend/index.htmlClaims affiches uniquement si tenant_claims_context present (pas de fuite cross-tenant)

Corrections appliquees — audit securite 2026-03-03

CatégorieFichierCorrection
Sécuritéapi/routers/admin.pysecrets.compare_digest() (comparaison clé API en temps constant)
Sécuritéapi/main.pyCORS : allow_credentials=False si allow_origins=["*"]
Sécuritéworker/etl/base.pyALLOWED_TABLES whitelist — empêche injection SQL via nom de table
Sécuritéworker/Dockerfile, api/DockerfileUtilisateur non-root appuser (UID 1000)
Performanceapi/cache.pySCAN au lieu de KEYS pour invalidation Redis
Performanceapi/routers/admin.pySCAN au lieu de KEYS dans clear_geocache, get_etl_progress
Performancepostgres/init/04_assess_point_v2.sqlN+1 fix : water_depth_m calculé une seule fois avant la boucle zones
Performancepostgres/init/04_assess_point_v2.sqlIndex idx_flood_events_geog (GIST((geom::geography)))
Performanceapi/routers/geo.pyKNN bâtiment : filtre 100m max (ST_DWithin)
Robustesseworker/main.pySIGTERM gracieux + semaphore MAX_CONCURRENT_JOBS=2 + grace period 300s
Robustesseworker/main.pyDistinction erreurs fatales vs récupérables
Robustesseapi/services/assess_service.pyTimeout 3s sur Open-Meteo via asyncio.wait_for
Robustesseworker/etl/buildings.pyTimeout 1800s sur executor OSM PBF
Qualitéapi/routers/assess.pyAudit log await (garantit traçabilité RGPD)
Qualitéapi/services/assess_service.pydata_freshness_days calculé (remplace None)
Qualité
Qualitéapi/models/response.pyapi_version: "1.0" dans AssessResponse
Dockerdocker-compose.ymlLimites ressources : api (1 CPU/512 MB), worker (2 CPU/3 GB)
Dockerdocker-compose.ymlHealthcheck worker (Redis ping)
Nginxnginx/nginx.confEn-têtes sécurité globaux (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)

État des ingestions (2026-03-02)

SourceLog IDStatutRecordsDétails
wallonia_flood_zones22✅ Success5 246 320ATOM GeoPackage SPW — durée ~10 min
flanders_flood_zones26✅ Success18 024 396GeoTIFF raster VMM OGRK — durée ~55 min
brussels_flood_zones10✅ Success206GML INSPIRE Bruxelles Environnement
osm_buildings27✅ Success7 473 128Bug asyncio corrigé (séparer thread/main loop) — durée ~16 min
osm_waterways28✅ Success151 615Complété session s5
cadastre29+✅ Success~9 MBPNCAPA bulk GML, 581 communes
flood_history✅ Success2EMSR518 AOI01 (Liège) + AOI02 (Rochefort)
wallonia_flood_zones_2021✅ Success168 282SPW 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.be bloquait à ~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/OGRK retournait les couches potentieel_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 OGRK overstroombaar_gebied_*_noCC.tif.
  • Brussels ETL : l'ancienne implémentation WFS pointait vers le serveur UrbIS (geoservices-urbis.irisnet.be). Corrigé : source GML INSPIRE app.bruxellesenvironnement.be.
  • OSM handlers : _BuildingHandler et _WaterwayHandler n'héritaient pas de osmium.SimpleHandler. Corrigé : wrapper SimpleHandler avec apply_file(path, locations=True).
  • OSM buildings/waterways asyncio : _parse_and_insert créait asyncio.new_event_loop() mais self.pool appartient à la boucle principale → "Task got Future attached to a different loop". Corrigé : séparation en _download_and_parse() (sync, thread pool, retourne list[dict]) + _insert_batch() (async, main loop). asyncio.get_event_loop()asyncio.get_running_loop().
  • POST /admin/ingest : l'API ne disposait pas du module workerModuleNotFoundError. Corrigé : passage par la file Redis etl:queue.
  • APScheduler supprimé : le scheduler cron a été remplacé par un daemon pur (écoute etl:queue via BLPOP). Les ingestions périodiques doivent être déclenchées via POST /admin/ingest ou docker 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.