Skip to content

Commit 1c7a234

Browse files
authored
Merge pull request #11 from T-DAT-902-Homepedia/feat/score-quartier-iris
feat(duckpipe): maille quartier IRIS — prix et gap qualité-prix infra-communaux
2 parents 30ad7a6 + b83b970 commit 1c7a234

21 files changed

Lines changed: 1080 additions & 52 deletions

ARCHITECTURE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,11 @@ gs://homepedia-data/
127127
│ └── dpe/
128128
├── silver/
129129
│ ├── communes_geom/ (GeoParquet)
130+
│ ├── iris_geom/ (GeoParquet, contours IRIS — maille quartier)
130131
│ ├── dvf_clean/year=2024/
132+
│ ├── dvf_points/year=<annee>/ (mutations géolocalisées des millésimes annexes)
131133
│ ├── commune_agg/year=2024/
134+
│ ├── iris_prix/year=2024/ (agrégat prix par IRIS, fenêtre poolée)
132135
│ ├── transport_commune/
133136
│ ├── climat_commune/
134137
│ ├── proximite_commune/
@@ -143,6 +146,9 @@ gs://homepedia-data/
143146
├── score_territoire/
144147
│ ├── run_date=2025-01-15/score.parquet
145148
│ └── latest/score.parquet ← lu par l'API FastAPI
149+
├── score_quartier/ (gap qualité-prix à la maille IRIS)
150+
│ ├── run_date=2025-01-15/score_quartier.parquet
151+
│ └── latest/score_quartier.parquet
146152
└── dq_reports/
147153
├── silver_2025-01-15.json
148154
└── gold_2025-01-15.json

adr/0015-maille-quartier-iris.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
---
2+
status: accepted
3+
date: 2026-07-09
4+
decision-makers: équipe Homepedia
5+
---
6+
7+
# Maille quartier : agrégats prix et gap qualité-prix à l'IRIS
8+
9+
## Contexte et problème
10+
11+
Le score global (`score_valeur`) et l'écart qualité-prix (`gap`,
12+
`gap_pondere`) ne sont calculés qu'à la maille commune : à l'intérieur d'une
13+
grande ville, la carte est aveugle à la variation entre quartiers — qui est
14+
d'abord une variation de prix. Le DVF est pourtant géolocalisé au grain
15+
mutation (lon/lat conservés en silver `dvf`), seule manque une maille
16+
infra-communale de restitution.
17+
18+
## Facteurs de décision
19+
20+
- Seul le prix est disponible partout à une maille infra-communale (points
21+
DVF) ; sécurité, emploi et risques n'existent pas plus fin que la commune.
22+
- Le seuil de fiabilité (>= 5 ventes) devient difficile à atteindre au grain
23+
quartier sur un seul millésime DVF.
24+
- Déterminisme entre runs (ADR-0008) : pas de simplification de géométries
25+
calculée (ADR-0013), pas de tirage non reproductible.
26+
- Contrat front : `schema_version` reste 1, ajouts additifs uniquement
27+
(ADR-0014) ; budget CDN surveillé (ADR-0013).
28+
29+
## Options envisagées
30+
31+
- **Maille IRIS INSEE** : découpage statistique officiel, contours nationaux
32+
publiés (CONTOURS-IRIS® IGN/INSEE, Licence Ouverte 2.0), quartiers nommés,
33+
et la plupart des sources INSEE (BPE, Filosofi, base logement) existent
34+
déclinées à l'IRIS — seule maille qui permette d'enrichir le score plus tard.
35+
- Sections cadastrales (id_parcelle DVF) : écarté — aucune donnée socio-éco à
36+
cette maille, pas de noms, trop de zones sous le seuil de fiabilité.
37+
- Carreaux INSEE 200 m/1 km : écarté — illisible produit (pas de notion de
38+
quartier), volumétrie carte élevée, pas de correspondance avec les autres
39+
sources.
40+
- Score entièrement recalculé à l'IRIS d'emblée : écarté pour le MVP — exige
41+
4-5 nouvelles ingestions (BPE IRIS, Filosofi IRIS, re-fetch ADEME…) pour un
42+
signal dont l'essentiel (le prix) est déjà disponible. Prévu en phase 2.
43+
44+
## Décision
45+
46+
MVP « gap quartier » à la maille IRIS, qualité héritée de la commune :
47+
48+
- **Contours** : édition FlatGeoBuf 2026 de CONTOURS-IRIS® (Géoplateforme
49+
`data.geopf.fr`), déjà généralisée moyenne échelle — lue par `ST_Read`,
50+
aucune simplification calculée. Les IRIS PLM sont codés par arrondissement
51+
(751xx/6938x/132xx), même convention que le DVF : raccord par equi-join,
52+
et ne JAMAIS filtrer `iris_geom` sur `commune_geom` (Etalab ne connaît que
53+
75056).
54+
- **Prix** : `iris_prix` poole le millésime courant (silver `dvf`) et les
55+
mutations géolocalisées des millésimes annexes (`dvf_points_<annee>`,
56+
seconde sortie de `prix_millesime`), médiane simple sans pondération de
57+
récence. Affectation point→IRIS contrainte à la commune de la mutation
58+
(equi-join puis `ST_Intersects`), point de frontière départagé au plus
59+
petit `code_iris`. Millésime annexe manquant = fenêtre réduite, pas d'échec.
60+
- **Gold `score_quartier`** : `n_prix_iris` normalisé par le même `_norm` que
61+
le communal, sur la population retenue (IRIS fiables de communes scorées) ;
62+
`gap_iris = score_commune − n_prix_iris`,
63+
`gap_pondere_iris = gap_iris × n_access_fin_commune`.
64+
- **Export web** : `choropleth/iris-high/{dept}.geojson`, communes multi-IRIS
65+
uniquement (les mono-IRIS dupliqueraient `communes-high` : ×5,5 d'économie
66+
CDN), clés meta additives `nb_iris`/`nb_iris_scores` pour le feature-gate
67+
front.
68+
69+
## Conséquences
70+
71+
- Mesuré sur le run local de validation : 16 409 IRIS exportés dont 14 491
72+
scorés, 104 fichiers départementaux (max 1,6 Mo brut), appariement
73+
DVF→IRIS 99,95 % (15 communes orphelines, décalage COG 2026 vs DVF —
74+
suivi par `iris_match` dans le rapport DQ silver).
75+
- Le gap quartier d'une commune mono-IRIS mesure surtout l'effet de fenêtre
76+
(prix poolés vs millésime courant), pas un signal quartier — documenté,
77+
et ces IRIS ne sont pas exportés côté web.
78+
- Phase 2 possible sans changement de schéma : recalcul à l'IRIS des
79+
dimensions qui le permettent (transport GPS, BPE `GEO_OBJECT='IRIS'`, DPE
80+
via champ IRIS de l'API ADEME, Filosofi IRIS avec flag secret statistique,
81+
base logement déjà à l'IRIS), les autres restant héritées.
82+
- Repli si l'édition FlatGeoBuf disparaît : GPKG (archive .7z, extracteur à
83+
écrire) ou GeoParquet IGN (encodage GeoArrow non lu par duckdb-spatial à
84+
ce jour).

adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,4 @@ Records).
3131
| [0012](0012-ci-cd-github-actions-wif.md) | CI/CD GitHub Actions avec Workload Identity Federation | accepted | 2026-07-02 |
3232
| [0013](0013-serving-statique-cdn.md) | Serving statique : artefacts web pré-générés sur bucket public | accepted | 2026-07-02 |
3333
| [0014](0014-consolidation-export-web.md) | Consolidation de l'export web : publish-web chemin unique | accepted | 2026-07-03 |
34+
| [0015](0015-maille-quartier-iris.md) | Maille quartier : agrégats prix et gap qualité-prix à l'IRIS | accepted | 2026-07-09 |

duckpipe/deploy/homepedia-pipeline.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ main:
5353
- "geometries_departements_100m"
5454
- "geometries_departements_1000m"
5555
- "geometries_regions_1000m"
56+
- "geometries_iris"
5657
- "transport"
5758
- "revenus"
5859
- "risques"
@@ -88,6 +89,7 @@ main:
8889
value: pipeline
8990
in:
9091
- "dvf"
92+
- "iris_geom"
9193
- "transport"
9294
- "revenus"
9395
- "risques"
@@ -125,6 +127,15 @@ main:
125127
job_name: ${job_name}
126128
cli_args: ${["run", "prix_millesime", "--env", "prod", "--year", string(millesime)]}
127129

130+
# Agrégat prix à la maille IRIS : pool du millésime courant (silver dvf)
131+
# et des mutations géolocalisées des millésimes annexes (dvf_points,
132+
# produits par l'étape millesimes) — d'où le placement séquentiel après.
133+
- iris_prix:
134+
call: run_duckpipe
135+
args:
136+
job_name: ${job_name}
137+
cli_args: ${["run", "iris_prix", "--env", "prod", "--year", string(year), "--run-date", run_date]}
138+
128139
- validate_silver:
129140
call: run_duckpipe
130141
args:
@@ -137,6 +148,14 @@ main:
137148
job_name: ${job_name}
138149
cli_args: ${["run", "score", "--env", "prod", "--year", string(year), "--run-date", run_date]}
139150

151+
# Gap qualité-prix à la maille quartier : hérite du score communal du run,
152+
# d'où le placement après l'étape score.
153+
- score_quartier:
154+
call: run_duckpipe
155+
args:
156+
job_name: ${job_name}
157+
cli_args: ${["run", "score_quartier", "--env", "prod", "--year", string(year), "--run-date", run_date]}
158+
140159
# Agrège le silver avis (produit hors-DAG par le workflow GitHub nlp-avis.yml,
141160
# chemins stables non millésimés) en gold avis_commune. Indépendant du score,
142161
# placé séquentiellement pour garder le DAG simple. Toléré si le silver avis

duckpipe/src/duckpipe/__main__.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from duckpipe.fetch_climat import CLIMAT_BRONZE_PATH, build_stations_csv
2727
from duckpipe.fetch_dpe import DPE_BRONZE_PATH, build_dpe_sample
2828
from duckpipe.pipeline_registry import register_pipelines
29+
from duckpipe.pipelines.iris import make_iris_prix_pipeline
2930
from duckpipe.pipelines.prix_millesime import make_prix_millesime_pipeline
3031

3132
logger = logging.getLogger(__name__)
@@ -61,18 +62,39 @@ def cmd_ingest(args: argparse.Namespace) -> None:
6162
logger.info("[ok] ingest %s", name)
6263

6364

65+
def _uri_exists(uri: str) -> bool:
66+
return fetch.gcs_exists(uri) if fetch.is_gcs_uri(uri) else Path(uri).exists()
67+
68+
69+
def _annees_points_disponibles(env: catalogs.Environment, year: int) -> list[int]:
70+
"""Millésimes annexes dont les dvf_points existent en silver : un millésime
71+
manquant réduit la fenêtre poolée d'iris_prix au lieu d'échouer le run
72+
(même tolérance que l'évolution des fiches dans publish_web)."""
73+
annees: list[int] = []
74+
for annee in catalogs.WEB_MILLESIMES:
75+
if annee == year:
76+
continue
77+
if _uri_exists(catalogs.dvf_points_path(env, annee)):
78+
annees.append(annee)
79+
else:
80+
logger.warning("[warn] dvf_points_%s absent, fenêtre poolée réduite", annee)
81+
return annees
82+
83+
6484
def cmd_run(args: argparse.Namespace) -> None:
6585
env = catalogs.get_environment(args.env, local_root=args.local_root)
6686
catalog = catalogs.build_catalog(env, year=args.year, run_date=args.run_date)
6787

6888
if args.pipeline == "prix_millesime":
6989
pipeline = make_prix_millesime_pipeline(args.year)
90+
elif args.pipeline == "iris_prix":
91+
pipeline = make_iris_prix_pipeline(args.year, _annees_points_disponibles(env, args.year))
7092
else:
7193
pipelines = register_pipelines()
7294
if args.pipeline not in pipelines:
7395
raise SystemExit(
7496
f"pipeline inconnu : {args.pipeline!r} "
75-
f"(disponibles : {', '.join(sorted(pipelines))}, prix_millesime)"
97+
f"(disponibles : {', '.join(sorted(pipelines))}, prix_millesime, iris_prix)"
7698
)
7799
pipeline = pipelines[args.pipeline]
78100

@@ -120,10 +142,7 @@ def cmd_validate_gold(args: argparse.Namespace) -> None:
120142
)
121143
previous_top: list[str] | None = None
122144
latest = catalogs.gold_latest_path(env)
123-
latest_exists = (
124-
fetch.gcs_exists(latest) if fetch.is_gcs_uri(latest) else Path(latest).exists()
125-
)
126-
if latest_exists:
145+
if _uri_exists(latest):
127146
with fetch.local_read_path(latest) as latest_path:
128147
previous_top = [
129148
row[0]
@@ -138,12 +157,26 @@ def cmd_validate_gold(args: argparse.Namespace) -> None:
138157
report_dest=catalogs.dq_report_path(env, "gold", args.run_date),
139158
)
140159

160+
# Contrôles gold du quartier, si l'étape score_quartier a produit la
161+
# table (facultatif : le run doit rester rejouable sans la maille IRIS).
162+
quartier_uri = catalogs.gold_quartier_path(env, args.run_date)
163+
if _uri_exists(quartier_uri):
164+
with fetch.local_read_path(quartier_uri) as quartier_path:
165+
con.execute(
166+
"CREATE TABLE score_quartier AS SELECT * FROM "
167+
f"read_parquet('{quartier_path}')"
168+
)
169+
validation.validate_gold_quartier(
170+
con,
171+
report_dest=catalogs.dq_report_path(env, "gold_quartier", args.run_date),
172+
)
173+
else:
174+
logger.warning("[warn] score_quartier absent, contrôle gold quartier ignoré")
175+
141176
# Contrôles gold des avis, si l'étape NLP a produit la table (facultatif :
142177
# la couverture avis est partielle et le pipeline peut tourner sans).
143178
avis_uri = catalogs.gold_avis_path(env, args.run_date)
144-
avis_exists = (
145-
fetch.gcs_exists(avis_uri) if fetch.is_gcs_uri(avis_uri) else Path(avis_uri).exists()
146-
)
179+
avis_exists = _uri_exists(avis_uri)
147180
if avis_exists:
148181
with fetch.local_read_path(avis_uri) as avis_path:
149182
con.execute(
@@ -165,6 +198,13 @@ def cmd_publish(args: argparse.Namespace) -> None:
165198
validation.publish(
166199
catalogs.gold_score_path(env, args.run_date), catalogs.gold_latest_path(env)
167200
)
201+
# Score quartier : publié s'il existe (toléré absent, comme les avis — les
202+
# runs antérieurs à la maille IRIS restent rejouables).
203+
quartier_uri = catalogs.gold_quartier_path(env, args.run_date)
204+
if _uri_exists(quartier_uri):
205+
validation.publish(quartier_uri, catalogs.gold_quartier_latest_path(env))
206+
else:
207+
logger.warning("[warn] score_quartier absent, publication quartier ignorée")
168208

169209

170210
def cmd_publish_web(args: argparse.Namespace) -> None:

duckpipe/src/duckpipe/catalogs.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ def get_environment(name: str, *, local_root: str = "data") -> Environment:
7575
"dept_geom_100m": "dept_geom/dept_geom_100m.parquet",
7676
"dept_geom_1000m": "dept_geom/dept_geom_1000m.parquet",
7777
"region_geom_1000m": "region_geom/region_geom_1000m.parquet",
78+
# Maille quartier (IRIS) : contours stables, agrégat prix millésimé (la
79+
# fenêtre poolée multi-millésimes glisse avec l'année de run).
80+
"iris_geom": "iris_geom/iris_geom.parquet", # GeoParquet
81+
"iris_prix": "iris_prix/year={year}/iris_prix.parquet",
7882
"dvf": "dvf_clean/year={year}/dvf.parquet",
7983
"commune_agg": "commune_agg/year={year}/commune_agg.parquet",
8084
"commune_agg_type": "commune_agg_type/year={year}/commune_agg_type.parquet",
@@ -111,6 +115,21 @@ def gold_avis_path(env: Environment, run_date: str) -> str:
111115
return f"{env.gold_root}/avis_commune/run_date={run_date}/avis_commune.parquet"
112116

113117

118+
def gold_quartier_path(env: Environment, run_date: str) -> str:
119+
return f"{env.gold_root}/score_quartier/run_date={run_date}/score_quartier.parquet"
120+
121+
122+
def gold_quartier_latest_path(env: Environment) -> str:
123+
return f"{env.gold_root}/score_quartier/latest/score_quartier.parquet"
124+
125+
126+
def dvf_points_path(env: Environment, annee: int) -> str:
127+
"""Chemin silver des mutations géolocalisées d'un millésime annexe (les
128+
points du millésime courant restent dans la table silver `dvf`). Exposé
129+
pour que le CLI vérifie l'existence avant de construire iris_prix."""
130+
return f"{env.silver_root}/dvf_points/year={annee}/dvf_points.parquet"
131+
132+
114133
def dq_report_path(env: Environment, kind: str, run_date: str) -> str:
115134
return f"{env.gold_root}/dq_reports/{kind}_{run_date}.json"
116135

@@ -166,6 +185,10 @@ def build_catalog(env: Environment, *, year: int, run_date: str) -> Catalog:
166185
"regions_1000m_raw",
167186
GeoJsonDataset(f"{bronze}/{SOURCES['geometries_regions_1000m'].bronze_path}"),
168187
)
188+
catalog.add(
189+
"iris_raw",
190+
GeoJsonDataset(f"{bronze}/{SOURCES['geometries_iris'].bronze_path}"),
191+
)
169192
catalog.add("arrets_raw", CsvDataset(f"{bronze}/{SOURCES['transport'].bronze_path}"))
170193
catalog.add(
171194
"revenus_raw",
@@ -219,17 +242,20 @@ def build_catalog(env: Environment, *, year: int, run_date: str) -> Catalog:
219242
catalog.add(name, ParquetDataset(path))
220243
catalog.add("arrets", MemoryDataset()) # intermédiaire transport, jamais persisté
221244
# Millésimes DVF annexes : l'année du run + ceux consommés par l'export web
222-
# (l'évolution des prix des fiches communes, cf. WEB_MILLESIMES).
245+
# (l'évolution des prix des fiches communes, cf. WEB_MILLESIMES) et par
246+
# l'agrégat quartier (mutations géolocalisées poolées, cf. iris_prix).
223247
for annee in {year, *WEB_MILLESIMES}:
224248
catalog.add(
225249
f"commune_prix_{annee}",
226250
ParquetDataset(
227251
f"{env.silver_root}/commune_prix/year={annee}/commune_prix.parquet"
228252
),
229253
)
254+
catalog.add(f"dvf_points_{annee}", ParquetDataset(dvf_points_path(env, annee)))
230255

231256
# --- Gold ----------------------------------------------------------------
232257
catalog.add("score_territoire", ParquetDataset(gold_score_path(env, run_date)))
233258
catalog.add("avis_commune", ParquetDataset(gold_avis_path(env, run_date)))
259+
catalog.add("score_quartier", ParquetDataset(gold_quartier_path(env, run_date)))
234260

235261
return catalog

duckpipe/src/duckpipe/datasets/geojson.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,16 @@
1010

1111

1212
class GeoJsonDataset(Dataset):
13-
"""Dataset GeoJSON, lu via `ST_Read` (extension `spatial` DuckDB).
13+
"""Dataset vectoriel lu via `ST_Read` (extension `spatial` DuckDB) : tout
14+
format géré par les drivers GDAL embarqués — GeoJSON (contours Etalab)
15+
comme FlatGeoBuf (CONTOURS-IRIS® IGN).
1416
1517
`ST_Read` passe par GDAL et non par `httpfs` : contrairement aux CSV et
1618
Parquet, un chemin `gs://` n'est pas lisible directement — on télécharge
1719
alors l'objet vers un fichier temporaire avant lecture.
1820
19-
Écriture non supportée : les GeoJSON de ce pipeline sont uniquement des
20-
sources en entrée (contours communaux/départementaux), jamais des sorties.
21+
Écriture non supportée : ces fichiers sont uniquement des sources en
22+
entrée (contours communaux/départementaux/IRIS), jamais des sorties.
2123
"""
2224

2325
def __init__(self, path: str) -> None:

duckpipe/src/duckpipe/export_web.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,48 @@ def build_choropleth_regions(
310310
return out_table
311311

312312

313+
def build_choropleth_iris(
314+
con: duckdb.DuckDBPyConnection,
315+
iris_geom: str,
316+
score_quartier: str,
317+
*,
318+
out_table: str = "web_choropleth_iris",
319+
) -> str:
320+
"""Table choroplèthe quartier (IRIS) : uniquement les communes multi-IRIS —
321+
un IRIS de commune mono-IRIS duplique le contour communal déjà servi par
322+
communes-high, le front retombe alors sur la maille commune. LEFT JOIN :
323+
un IRIS non fiable (ou de commune non scorée) reste affiché « pas de
324+
donnée », comme les communes. Les properties tiennent lieu de
325+
fiche/tooltip : pas d'artefact fiche séparé au MVP quartier.
326+
"""
327+
con.execute(
328+
f"""
329+
CREATE OR REPLACE TABLE {out_table} AS
330+
SELECT
331+
g.code_iris,
332+
g.code_commune,
333+
g.nom_iris AS nom,
334+
g.nom_commune,
335+
g.type_iris,
336+
{DEPT_EXPR.replace("code_commune", "g.code_commune")} AS code_departement,
337+
CAST(round(q.prix_m2_median) AS INTEGER) AS prix_m2_median,
338+
coalesce(q.nb_transactions, 0) AS nb_transactions,
339+
q.code_iris IS NOT NULL AS fiable,
340+
round(q.n_prix_iris, 3) AS n_prix_iris,
341+
round(q.score_commune, 3) AS score_commune,
342+
round(q.gap_iris, 3) AS gap_iris,
343+
round(q.gap_pondere_iris, 3) AS gap_pondere_iris,
344+
q.annee_min,
345+
q.annee_max,
346+
g.geom
347+
FROM {iris_geom} g
348+
LEFT JOIN {score_quartier} q USING (code_iris)
349+
WHERE g.nb_iris_commune > 1
350+
"""
351+
)
352+
return out_table
353+
354+
313355
def build_evolution(
314356
con: duckdb.DuckDBPyConnection,
315357
commune_agg: str,

0 commit comments

Comments
 (0)