Qdonnars commited on
Commit
dff63a3
·
verified ·
1 Parent(s): 1eb145f

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ data/cartofriches.geojson filter=lfs diff=lfs merge=lfs -text
37
+ data/prix_volumes.csv filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,88 @@
1
  ---
2
  title: MCP Data Foncier
3
- emoji: 🦀
4
- colorFrom: blue
5
- colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.5.1
8
  app_file: app.py
9
  pinned: false
10
- short_description: Accès aux données DV3F et Cartofriches
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: MCP Data Foncier
3
+ emoji: 🏗️
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 5.29.0
8
  app_file: app.py
9
  pinned: false
10
+ tags:
11
+ - mcp-server-track
12
  ---
13
 
14
+ # 🏗️ MCP Data Foncier Données foncières & friches pour la transition écologique
15
+
16
+ Serveur MCP (Model Context Protocol) exposant les données du CEREMA pour la plateforme **Sofia V2** (ADEME / CEREMA / CGDD).
17
+
18
+ Accès aux données DV3F (transactions immobilières) et Cartofriches (inventaire national des friches).
19
+
20
+ ## 🛠️ Tools MCP disponibles
21
+
22
+ | Tool | Description |
23
+ |---|---|
24
+ | `rechercher_friches` | Recherche de friches par commune, département, type, surface, statut |
25
+ | `statistiques_prix_foncier` | Prix et volumes de transactions immobilières par territoire et année |
26
+ | `evolution_prix` | Évolution temporelle des prix de 2010 à 2024 |
27
+ | `statistiques_friches` | Statistiques agrégées multi-échelle (commune → national) |
28
+ | `diagnostic_foncier_territoire` | Diagnostic complet croisant friches + marché foncier (pour le ZAN) |
29
+
30
+ ## 📊 Données exposées
31
+
32
+ ### Cartofriches
33
+ Inventaire national des friches (28 373 sites géolocalisés) :
34
+ - Type, surface, statut, pollution, zonage urbanisme
35
+
36
+ ### DV3F (Demande de Valeurs Foncières)
37
+ Statistiques de transactions immobilières (2010–2024) :
38
+ - Prix médians, prix au m², volumes, par type de bien et période de construction
39
+ - Multi-échelle : commune, EPCI, département, région
40
+
41
+ ## 🔌 Connexion MCP
42
+
43
+ ### Claude Desktop
44
+
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "data-foncier": {
49
+ "url": "https://qdonnars-mcp-data-foncier.hf.space/gradio_api/mcp/sse"
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ ### Cursor
56
+
57
+ ```json
58
+ {
59
+ "data-foncier": {
60
+ "url": "https://qdonnars-mcp-data-foncier.hf.space/gradio_api/mcp/sse"
61
+ }
62
+ }
63
+ ```
64
+
65
+ ## 🌱 Cas d'usage transition écologique
66
+
67
+ - **Zéro Artificialisation Nette (ZAN)** : identifier les friches mobilisables
68
+ - **Planification territoriale** : contextualiser le marché foncier local (PLU, SCOT)
69
+ - **Rénovation énergétique** : distinguer le parc ancien du neuf via les segments DV3F
70
+ - **Croisement multi-MCP** : combiner avec indicateurs TE et Base Carbone
71
+
72
+ ## 📁 Structure
73
+
74
+ ```
75
+ ├── app.py # Point d'entrée Gradio + MCP
76
+ ├── server.py # Fonctions-tools MCP
77
+ ├── data_loader.py # Chargement optimisé des données
78
+ ├── requirements.txt
79
+ └── data/
80
+ ├── prix_volumes.csv
81
+ ├── cartofriches.geojson
82
+ └── mapping_communes.csv
83
+ ```
84
+
85
+ ## Sources
86
+
87
+ - **Cartofriches** : https://cartofriches.cerema.fr — CEREMA, licence ouverte
88
+ - **DV3F** : https://datafoncier.cerema.fr — CEREMA/DGFiP, données ouvertes DVF
app.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MCP Server CEREMA — Données foncières et friches pour la transition écologique.
3
+
4
+ Point d'entrée Gradio exposant les tools MCP pour Sofia V2.
5
+ Les données (DV3F + Cartofriches) sont chargées en mémoire au démarrage.
6
+ Les friches sont pré-agrégées à toutes les mailles (commune, EPCI, département, région, national).
7
+
8
+ Endpoint MCP : http://localhost:7860/gradio_api/mcp/sse
9
+ Schema : http://localhost:7860/gradio_api/mcp/schema
10
+ """
11
+
12
+ import logging
13
+ import gradio as gr
14
+
15
+ from data_loader import init_all
16
+ from server import (
17
+ rechercher_friches,
18
+ statistiques_prix_foncier,
19
+ evolution_prix,
20
+ statistiques_friches,
21
+ diagnostic_foncier_territoire,
22
+ )
23
+
24
+ # Configuration du logging
25
+ logging.basicConfig(
26
+ level=logging.INFO,
27
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
28
+ )
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # --- Chargement des données au démarrage ---
32
+ logger.info("Démarrage du serveur MCP CEREMA...")
33
+ init_all()
34
+ logger.info("Données chargées. Construction de l'interface Gradio...")
35
+
36
+ # --- Interfaces Gradio pour chaque tool ---
37
+
38
+ # Tool 1 : Recherche de friches
39
+ iface_friches = gr.Interface(
40
+ fn=rechercher_friches,
41
+ inputs=[
42
+ gr.Textbox(label="Code INSEE commune", placeholder="ex: 13055, 59350, 75056"),
43
+ gr.Textbox(label="Code département", placeholder="ex: 13, 59, 75"),
44
+ gr.Dropdown(
45
+ label="Type de friche",
46
+ choices=["", "industrielle", "habitat", "commerciale", "ferroviaire",
47
+ "militaire", "hospitalière", "logistique", "agro-industrielle",
48
+ "équipement public", "carrière ou mine"],
49
+ value="",
50
+ ),
51
+ gr.Number(label="Surface minimale (m²)", value=0),
52
+ gr.Dropdown(
53
+ label="Statut",
54
+ choices=["", "sans projet", "avec projet", "potentielle", "reconvertie"],
55
+ value="",
56
+ ),
57
+ ],
58
+ outputs=gr.Markdown(label="Résultats"),
59
+ title="Recherche de friches",
60
+ description="Recherche des friches disponibles sur un territoire (base Cartofriches du CEREMA).",
61
+ api_name="rechercher_friches",
62
+ )
63
+
64
+ # Tool 2 : Statistiques de prix foncier
65
+ iface_prix = gr.Interface(
66
+ fn=statistiques_prix_foncier,
67
+ inputs=[
68
+ gr.Textbox(label="Code INSEE commune", placeholder="ex: 13055, 59350"),
69
+ gr.Textbox(label="Code département", placeholder="ex: 13, 59"),
70
+ gr.Dropdown(
71
+ label="Type de bien",
72
+ choices=["tous", "maison", "appartement"],
73
+ value="tous",
74
+ ),
75
+ gr.Textbox(label="Année", value="2024", placeholder="2010 à 2024"),
76
+ ],
77
+ outputs=gr.Markdown(label="Résultats"),
78
+ title="Prix foncier",
79
+ description="Statistiques de prix et volumes de transactions immobilières (DV3F, CEREMA/DGFiP).",
80
+ api_name="statistiques_prix_foncier",
81
+ )
82
+
83
+ # Tool 3 : Évolution des prix
84
+ iface_evolution = gr.Interface(
85
+ fn=evolution_prix,
86
+ inputs=[
87
+ gr.Textbox(label="Code INSEE commune", placeholder="ex: 13055"),
88
+ gr.Textbox(label="Code département", placeholder="ex: 13"),
89
+ gr.Dropdown(
90
+ label="Type de bien",
91
+ choices=["maison", "appartement"],
92
+ value="maison",
93
+ ),
94
+ ],
95
+ outputs=gr.Markdown(label="Résultats"),
96
+ title="Évolution des prix",
97
+ description="Évolution temporelle des prix fonciers de 2010 à 2024 (DV3F, CEREMA/DGFiP).",
98
+ api_name="evolution_prix",
99
+ )
100
+
101
+ # Tool 4 : Statistiques agrégées de friches (multi-échelle)
102
+ iface_stats_friches = gr.Interface(
103
+ fn=statistiques_friches,
104
+ inputs=[
105
+ gr.Textbox(label="Code INSEE commune", placeholder="ex: 13055 — retourne aussi EPCI, département, région"),
106
+ gr.Textbox(label="Code SIREN EPCI", placeholder="ex: 200054807 (Aix-Marseille-Provence)"),
107
+ gr.Textbox(label="Code département", placeholder="ex: 13, 59"),
108
+ gr.Textbox(label="Code région", placeholder="ex: 93 (PACA), 32 (Hauts-de-France)"),
109
+ gr.Dropdown(
110
+ label="Échelle",
111
+ choices=["", "commune", "epci", "departement", "region", "national"],
112
+ value="",
113
+ ),
114
+ ],
115
+ outputs=gr.Markdown(label="Résultats"),
116
+ title="Statistiques friches (multi-échelle)",
117
+ description="Statistiques agrégées des friches : commune, EPCI, département, région ou national. "
118
+ "Si une commune est fournie, les stats aux échelles supérieures sont aussi affichées.",
119
+ api_name="statistiques_friches",
120
+ )
121
+
122
+ # Tool 5 : Diagnostic foncier territorial (multi-échelle)
123
+ iface_diagnostic = gr.Interface(
124
+ fn=diagnostic_foncier_territoire,
125
+ inputs=[
126
+ gr.Textbox(label="Code INSEE commune", placeholder="ex: 13055"),
127
+ gr.Textbox(label="Code SIREN EPCI", placeholder="ex: 200054807"),
128
+ gr.Textbox(label="Code département", placeholder="ex: 13"),
129
+ gr.Textbox(label="Code région", placeholder="ex: 93 (PACA)"),
130
+ ],
131
+ outputs=gr.Markdown(label="Résultats"),
132
+ title="Diagnostic foncier",
133
+ description="Diagnostic complet croisant friches et marché foncier à toutes les échelles (Cartofriches + DV3F).",
134
+ api_name="diagnostic_foncier_territoire",
135
+ )
136
+
137
+ # --- Application Gradio combinée ---
138
+ demo = gr.TabbedInterface(
139
+ [iface_friches, iface_prix, iface_evolution, iface_stats_friches, iface_diagnostic],
140
+ tab_names=["🏗️ Friches", "💰 Prix foncier", "📈 Évolution prix",
141
+ "📊 Stats friches", "🔍 Diagnostic territorial"],
142
+ title="MCP CEREMA — Données foncières & friches pour la transition écologique",
143
+ )
144
+
145
+ if __name__ == "__main__":
146
+ demo.launch(
147
+ mcp_server=True,
148
+ server_name="0.0.0.0",
149
+ server_port=7860,
150
+ )
data/cartofriches.geojson ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:508be66a72d3ebdc7040a252237dc80264ef73a9d8d070a6c7645092e560785a
3
+ size 18848774
data/mapping_communes.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/prix_volumes.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:38647dfa09a7f7ccce319b675de17a17ed65e171817ad3ba2e12873de7394f6e
3
+ size 15502944
data_loader.py ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chargement et indexation des données CEREMA pour le serveur MCP.
3
+
4
+ Optimisations mémoire :
5
+ - DV3F : seules les ~60 colonnes utiles sont chargées (sur 619), avec types optimisés
6
+ - Cartofriches : chargement GeoPandas standard (112 Mo en RAM)
7
+
8
+ Les données sont chargées une seule fois au démarrage et indexées pour des requêtes rapides.
9
+ Les friches sont pré-agrégées à chaque maille territoriale (commune, EPCI, département,
10
+ région, national) au chargement pour des réponses instantanées.
11
+ """
12
+
13
+ import os
14
+ import pandas as pd
15
+ import geopandas as gpd
16
+ import numpy as np
17
+ import logging
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # --- Répertoire des données ---
22
+ DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
23
+
24
+ # --- Colonnes DV3F à charger ---
25
+ # Base
26
+ BASE_COLS = ["annee", "echelle", "code", "libelle"]
27
+
28
+ # Transactions et prix globaux
29
+ GLOBAL_COLS = [
30
+ "nbtrans_cod1", # Total mutations
31
+ "nbtrans_cod11", # Biens bâtis
32
+ "nbtrans_cod111", # Maisons
33
+ "nbtrans_cod121", # Appartements
34
+ "nbtrans_cod2", # Non bâti (terrains)
35
+ "valeurfonc_sum_cod1", # Valeur foncière totale
36
+ "valeurfonc_sum_cod111", # Valeur foncière maisons
37
+ "valeurfonc_sum_cod121", # Valeur foncière appartements
38
+ ]
39
+
40
+ # Maisons - statistiques détaillées
41
+ MAISON_COLS = [
42
+ "valeurfonc_median_cod111", "valeurfonc_q25_cod111", "valeurfonc_q75_cod111",
43
+ "pxm2_median_cod111", "pxm2_q25_cod111", "pxm2_q75_cod111",
44
+ "sbati_median_cod111", "sbati_sum_cod111",
45
+ ]
46
+
47
+ # Appartements - statistiques détaillées
48
+ APPART_COLS = [
49
+ "valeurfonc_median_cod121", "valeurfonc_q25_cod121", "valeurfonc_q75_cod121",
50
+ "pxm2_median_cod121", "pxm2_q25_cod121", "pxm2_q75_cod121",
51
+ "sbati_median_cod121", "sbati_sum_cod121",
52
+ ]
53
+
54
+ # Maisons par période de construction
55
+ MAISON_PERIODE_COLS = []
56
+ for p in ["mp1", "mp2", "mp3", "mp4", "mp5", "mpx"]:
57
+ MAISON_PERIODE_COLS.extend([
58
+ f"nbtrans_{p}", f"valeurfonc_median_{p}", f"pxm2_median_{p}", f"sbati_median_{p}"
59
+ ])
60
+
61
+ # Appartements par période de construction
62
+ APPART_PERIODE_COLS = []
63
+ for p in ["ap1", "ap2", "ap3", "ap4", "ap5", "apx"]:
64
+ APPART_PERIODE_COLS.extend([
65
+ f"nbtrans_{p}", f"valeurfonc_median_{p}", f"pxm2_median_{p}", f"sbati_median_{p}"
66
+ ])
67
+
68
+ ALL_DV3F_COLS = BASE_COLS + GLOBAL_COLS + MAISON_COLS + APPART_COLS + MAISON_PERIODE_COLS + APPART_PERIODE_COLS
69
+
70
+ # --- Nomenclature des périodes de construction ---
71
+ PERIODES_CONSTRUCTION = {
72
+ "mp1": "avant 1914", "mp2": "1914–1947", "mp3": "1948–1969",
73
+ "mp4": "1970–1989", "mp5": "1990 et après", "mpx": "période inconnue",
74
+ "ap1": "avant 1914", "ap2": "1914–1947", "ap3": "1948–1969",
75
+ "ap4": "1970–1989", "ap5": "1990 et après", "apx": "période inconnue",
76
+ }
77
+
78
+ # --- Noms des régions ---
79
+ REG_NAMES = {
80
+ "01": "Guadeloupe", "02": "Martinique", "03": "Guyane",
81
+ "04": "La Réunion", "06": "Mayotte",
82
+ "11": "Île-de-France", "24": "Centre-Val de Loire",
83
+ "27": "Bourgogne-Franche-Comté", "28": "Normandie",
84
+ "32": "Hauts-de-France", "44": "Grand Est",
85
+ "52": "Pays de la Loire", "53": "Bretagne",
86
+ "75": "Nouvelle-Aquitaine", "76": "Occitanie",
87
+ "84": "Auvergne-Rhône-Alpes",
88
+ "93": "Provence-Alpes-Côte d'Azur", "94": "Corse",
89
+ }
90
+
91
+ # --- Échelles territoriales ---
92
+ ECHELLES = ["commune", "epci", "departement", "region", "national"]
93
+
94
+ # --- Variables globales pour les données chargées ---
95
+ _dv3f: pd.DataFrame | None = None
96
+ _friches: gpd.GeoDataFrame | None = None
97
+ _mapping: pd.DataFrame | None = None
98
+ _friches_agg: dict[str, pd.DataFrame] = {} # Agrégations pré-calculées par échelle
99
+
100
+
101
+ def load_mapping() -> pd.DataFrame:
102
+ """Charge la table de correspondance commune → EPCI → département → région (INSEE)."""
103
+ global _mapping
104
+ if _mapping is not None:
105
+ return _mapping
106
+
107
+ filepath = os.path.join(DATA_DIR, "mapping_communes.csv")
108
+ logger.info(f"Chargement du mapping territorial depuis {filepath}...")
109
+ _mapping = pd.read_csv(filepath, dtype=str)
110
+ _mapping["reg_nom"] = _mapping["reg_nom"].fillna("")
111
+ _mapping["epci_nom"] = _mapping["epci_nom"].fillna("")
112
+ logger.info(f"Mapping chargé : {len(_mapping)} communes, "
113
+ f"{_mapping['epci'].nunique()} EPCI, "
114
+ f"{_mapping['dep'].nunique()} départements, "
115
+ f"{_mapping['reg'].nunique()} régions")
116
+ return _mapping
117
+
118
+
119
+ def load_dv3f() -> pd.DataFrame:
120
+ """Charge les données DV3F avec optimisation mémoire."""
121
+ global _dv3f
122
+ if _dv3f is not None:
123
+ return _dv3f
124
+
125
+ filepath = os.path.join(DATA_DIR, "prix_volumes.csv")
126
+ logger.info(f"Chargement de DV3F depuis {filepath}...")
127
+
128
+ _dv3f = pd.read_csv(
129
+ filepath,
130
+ sep=";",
131
+ usecols=ALL_DV3F_COLS,
132
+ dtype={
133
+ "annee": "int16",
134
+ "echelle": "category",
135
+ "code": "str",
136
+ "libelle": "str",
137
+ },
138
+ low_memory=False,
139
+ )
140
+
141
+ # Convertir les colonnes numériques en float32 pour économiser la mémoire
142
+ numeric_cols = [c for c in _dv3f.columns if c not in BASE_COLS]
143
+ for col in numeric_cols:
144
+ _dv3f[col] = pd.to_numeric(_dv3f[col], errors="coerce").astype("float32")
145
+
146
+ # Créer un index pour des lookups rapides
147
+ _dv3f.set_index(["echelle", "code", "annee"], inplace=True)
148
+ _dv3f.sort_index(inplace=True)
149
+
150
+ mem_mb = _dv3f.memory_usage(deep=True).sum() / 1e6
151
+ logger.info(f"DV3F chargé : {len(_dv3f)} lignes, {mem_mb:.0f} Mo en mémoire")
152
+ return _dv3f
153
+
154
+
155
+ def load_friches() -> gpd.GeoDataFrame:
156
+ """Charge les données Cartofriches et enrichit avec le mapping territorial."""
157
+ global _friches
158
+ if _friches is not None:
159
+ return _friches
160
+
161
+ filepath = os.path.join(DATA_DIR, "cartofriches.geojson")
162
+ logger.info(f"Chargement de Cartofriches depuis {filepath}...")
163
+
164
+ _friches = gpd.read_file(filepath)
165
+
166
+ # Nettoyer les colonnes utiles
167
+ _friches["site_surface_num"] = pd.to_numeric(
168
+ _friches["site_surface"], errors="coerce"
169
+ )
170
+ _friches["site_surface_ha"] = _friches["site_surface_num"] / 10000
171
+
172
+ # Enrichir avec le mapping territorial (EPCI, région)
173
+ mapping = load_mapping()
174
+ _friches = _friches.merge(
175
+ mapping[["comm_insee", "epci", "epci_nom", "reg", "reg_nom"]],
176
+ on="comm_insee",
177
+ how="left",
178
+ suffixes=("", "_map"),
179
+ )
180
+
181
+ mem_mb = _friches.memory_usage(deep=True).sum() / 1e6
182
+ logger.info(f"Cartofriches chargé et enrichi : {len(_friches)} friches, {mem_mb:.0f} Mo en mémoire")
183
+ return _friches
184
+
185
+
186
+ def _aggregate_friches_for_group(group: pd.DataFrame) -> dict:
187
+ """Calcule les statistiques agrégées pour un groupe de friches."""
188
+ surfaces = group["site_surface_num"].dropna()
189
+ statuts = group["site_statut"].value_counts()
190
+ types = group["site_type"].value_counts()
191
+
192
+ # Pollution avérée ou supposée
193
+ pollution = group["sol_pollution_existe"].fillna("inconnu")
194
+ nb_polluees = pollution.str.contains("avérée|supposée", case=False, na=False).sum()
195
+
196
+ # En zone U
197
+ nb_zone_u = (group["urba_zone_type"] == "U").sum()
198
+
199
+ # Mobilisables (sans projet + potentielles)
200
+ mobilisables = group[group["site_statut"].isin(["friche sans projet", "friche potentielle"])]
201
+ surface_mobilisable = mobilisables["site_surface_num"].sum() / 10000
202
+
203
+ return {
204
+ "nb_friches": len(group),
205
+ "surface_totale_ha": surfaces.sum() / 10000,
206
+ "surface_mediane_ha": surfaces.median() / 10000 if len(surfaces) > 0 else 0,
207
+ "surface_moyenne_ha": surfaces.mean() / 10000 if len(surfaces) > 0 else 0,
208
+ "surface_min_ha": surfaces.min() / 10000 if len(surfaces) > 0 else 0,
209
+ "surface_max_ha": surfaces.max() / 10000 if len(surfaces) > 0 else 0,
210
+ "nb_sans_projet": int(statuts.get("friche sans projet", 0)),
211
+ "nb_avec_projet": int(statuts.get("friche avec projet", 0)),
212
+ "nb_potentielle": int(statuts.get("friche potentielle", 0)),
213
+ "nb_reconvertie": int(statuts.get("friche reconvertie", 0)),
214
+ "nb_mobilisables": len(mobilisables),
215
+ "surface_mobilisable_ha": surface_mobilisable,
216
+ "nb_polluees": int(nb_polluees),
217
+ "nb_zone_u": int(nb_zone_u),
218
+ "pct_zone_u": round(100 * nb_zone_u / len(group), 1) if len(group) > 0 else 0,
219
+ "top_types": types.head(5).to_dict(),
220
+ "nb_communes": group["comm_insee"].nunique() if "comm_insee" in group.columns else 0,
221
+ }
222
+
223
+
224
+ def precompute_friches_aggregations():
225
+ """Pré-calcule les agrégations de friches à chaque maille territoriale."""
226
+ global _friches_agg
227
+ gdf = load_friches()
228
+
229
+ logger.info("Pré-agrégation des friches par commune...")
230
+ commune_groups = gdf.groupby("comm_insee")
231
+ agg_commune = {}
232
+ for code, group in commune_groups:
233
+ stats = _aggregate_friches_for_group(group)
234
+ stats["libelle"] = group.iloc[0]["comm_nom"] if len(group) > 0 else ""
235
+ agg_commune[code] = stats
236
+ _friches_agg["commune"] = pd.DataFrame(agg_commune).T
237
+ logger.info(f" Communes : {len(agg_commune)} territoires")
238
+
239
+ logger.info("Pré-agrégation des friches par EPCI...")
240
+ epci_groups = gdf[gdf["epci"].notna() & (gdf["epci"] != "ZZZZZZZZZ")].groupby("epci")
241
+ agg_epci = {}
242
+ for code, group in epci_groups:
243
+ stats = _aggregate_friches_for_group(group)
244
+ stats["libelle"] = group.iloc[0].get("epci_nom", "") or ""
245
+ agg_epci[code] = stats
246
+ _friches_agg["epci"] = pd.DataFrame(agg_epci).T
247
+ logger.info(f" EPCI : {len(agg_epci)} territoires")
248
+
249
+ logger.info("Pré-agrégation des friches par département...")
250
+ dep_groups = gdf[gdf["dep"].notna()].groupby("dep")
251
+ agg_dep = {}
252
+ for code, group in dep_groups:
253
+ stats = _aggregate_friches_for_group(group)
254
+ # Trouver le nom du département dans DV3F
255
+ stats["libelle"] = ""
256
+ agg_dep[code] = stats
257
+ _friches_agg["departement"] = pd.DataFrame(agg_dep).T
258
+ logger.info(f" Départements : {len(agg_dep)} territoires")
259
+
260
+ logger.info("Pré-agrégation des friches par région...")
261
+ reg_groups = gdf[gdf["reg"].notna()].groupby("reg")
262
+ agg_reg = {}
263
+ for code, group in reg_groups:
264
+ stats = _aggregate_friches_for_group(group)
265
+ stats["libelle"] = REG_NAMES.get(code, "")
266
+ agg_reg[code] = stats
267
+ _friches_agg["region"] = pd.DataFrame(agg_reg).T
268
+ logger.info(f" Régions : {len(agg_reg)} territoires")
269
+
270
+ logger.info("Pré-agrégation des friches au niveau national...")
271
+ national_stats = _aggregate_friches_for_group(gdf)
272
+ national_stats["libelle"] = "France entière"
273
+ _friches_agg["national"] = pd.DataFrame({"france": national_stats}).T
274
+ logger.info(f" National : 1 territoire")
275
+
276
+ logger.info("Pré-agrégation terminée.")
277
+
278
+
279
+ def get_friches_agg(echelle: str, code: str = "") -> dict | None:
280
+ """Récupère les statistiques agrégées de friches pour un territoire donné.
281
+
282
+ Args:
283
+ echelle: "commune", "epci", "departement", "region" ou "national"
284
+ code: Code du territoire (INSEE, SIREN EPCI, etc.). Ignoré pour "national".
285
+
286
+ Returns:
287
+ Dict avec les statistiques agrégées, ou None si non trouvé.
288
+ """
289
+ if echelle not in _friches_agg:
290
+ return None
291
+ if echelle == "national":
292
+ return _friches_agg["national"].iloc[0].to_dict()
293
+ if code in _friches_agg[echelle].index:
294
+ return _friches_agg[echelle].loc[code].to_dict()
295
+ return None
296
+
297
+
298
+ def get_epci_for_commune(code_commune: str) -> tuple[str, str] | None:
299
+ """Retourne (code_epci, nom_epci) pour une commune donnée."""
300
+ mapping = load_mapping()
301
+ row = mapping[mapping["comm_insee"] == code_commune]
302
+ if len(row) == 0:
303
+ return None
304
+ epci_code = row.iloc[0]["epci"]
305
+ epci_nom = row.iloc[0]["epci_nom"]
306
+ if epci_code == "ZZZZZZZZZ" or pd.isna(epci_code):
307
+ return None
308
+ return (epci_code, epci_nom if pd.notna(epci_nom) else "")
309
+
310
+
311
+ def get_region_for_commune(code_commune: str) -> tuple[str, str] | None:
312
+ """Retourne (code_region, nom_region) pour une commune donnée."""
313
+ mapping = load_mapping()
314
+ row = mapping[mapping["comm_insee"] == code_commune]
315
+ if len(row) == 0:
316
+ return None
317
+ reg = row.iloc[0]["reg"]
318
+ reg_nom = row.iloc[0]["reg_nom"]
319
+ return (reg, reg_nom if pd.notna(reg_nom) else REG_NAMES.get(reg, ""))
320
+
321
+
322
+ def get_region_for_departement(code_dep: str) -> tuple[str, str] | None:
323
+ """Retourne (code_region, nom_region) pour un département donné."""
324
+ mapping = load_mapping()
325
+ dep_rows = mapping[mapping["dep"] == code_dep]
326
+ if len(dep_rows) == 0:
327
+ return None
328
+ reg = dep_rows.iloc[0]["reg"]
329
+ reg_nom = dep_rows.iloc[0]["reg_nom"]
330
+ return (reg, reg_nom if pd.notna(reg_nom) else REG_NAMES.get(reg, ""))
331
+
332
+
333
+ def get_departement_from_commune(code_commune: str) -> str:
334
+ """Extrait le code département à partir d'un code INSEE de commune."""
335
+ if code_commune.startswith("97") or code_commune.startswith("98"):
336
+ return code_commune[:3] # DOM-TOM
337
+ return code_commune[:2]
338
+
339
+
340
+ def get_all_echelles_for_commune(code_commune: str) -> dict:
341
+ """Retourne tous les codes territoriaux pour une commune.
342
+
343
+ Returns:
344
+ Dict avec commune, epci, departement, region et leurs libellés.
345
+ """
346
+ result = {"commune": code_commune, "commune_nom": ""}
347
+ mapping = load_mapping()
348
+ row = mapping[mapping["comm_insee"] == code_commune]
349
+ if len(row) > 0:
350
+ r = row.iloc[0]
351
+ result["commune_nom"] = r.get("comm_nom", "")
352
+ result["epci"] = r.get("epci", "")
353
+ result["epci_nom"] = r.get("epci_nom", "")
354
+ result["departement"] = r.get("dep", "")
355
+ result["region"] = r.get("reg", "")
356
+ result["region_nom"] = r.get("reg_nom", REG_NAMES.get(r.get("reg", ""), ""))
357
+ else:
358
+ dep = get_departement_from_commune(code_commune)
359
+ result["departement"] = dep
360
+ result["epci"] = ""
361
+ result["epci_nom"] = ""
362
+ reg_info = get_region_for_departement(dep)
363
+ if reg_info:
364
+ result["region"] = reg_info[0]
365
+ result["region_nom"] = reg_info[1]
366
+ else:
367
+ result["region"] = ""
368
+ result["region_nom"] = ""
369
+ return result
370
+
371
+
372
+ def init_all():
373
+ """Charge toutes les données au démarrage et pré-calcule les agrégations."""
374
+ logger.info("Initialisation des données...")
375
+ load_mapping()
376
+ load_dv3f()
377
+ load_friches()
378
+ precompute_friches_aggregations()
379
+ logger.info("Toutes les données sont chargées et pré-agrégées.")
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio[mcp]>=5.27.1,<6.0.0
2
+ pandas>=2.0
3
+ geopandas>=0.14
4
+ pyogrio
5
+ numpy
server.py ADDED
@@ -0,0 +1,941 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Serveur MCP CEREMA — Tools pour les données foncières et friches.
3
+
4
+ Ce module définit les 5 fonctions-tools exposées via Gradio MCP :
5
+ 1. rechercher_friches : recherche de friches sur un territoire
6
+ 2. statistiques_prix_foncier : prix et volumes de transactions immobilières
7
+ 3. evolution_prix : évolution temporelle des prix fonciers
8
+ 4. statistiques_friches : statistiques agrégées de friches multi-échelle
9
+ 5. diagnostic_foncier_territoire : vision combinée friches + marché foncier
10
+ """
11
+
12
+ import pandas as pd
13
+ from data_loader import (
14
+ load_dv3f,
15
+ load_friches,
16
+ get_departement_from_commune,
17
+ get_friches_agg,
18
+ get_all_echelles_for_commune,
19
+ get_epci_for_commune,
20
+ get_region_for_commune,
21
+ get_region_for_departement,
22
+ REG_NAMES,
23
+ PERIODES_CONSTRUCTION,
24
+ )
25
+
26
+
27
+ # =============================================================================
28
+ # Helpers
29
+ # =============================================================================
30
+
31
+ def _format_prix(val: float | None) -> str:
32
+ """Formate un prix en euros lisible."""
33
+ if val is None or pd.isna(val):
34
+ return "non disponible"
35
+ if val >= 1_000_000:
36
+ return f"{val/1_000_000:,.2f} M€".replace(",", " ")
37
+ if val >= 1_000:
38
+ return f"{val:,.0f} €".replace(",", " ")
39
+ return f"{val:.0f} €"
40
+
41
+
42
+ def _format_pxm2(val: float | None) -> str:
43
+ """Formate un prix au m²."""
44
+ if val is None or pd.isna(val):
45
+ return "non disponible"
46
+ return f"{val:,.0f} €/m²".replace(",", " ")
47
+
48
+
49
+ def _format_surface(val: float | None, unite: str = "m²") -> str:
50
+ """Formate une surface."""
51
+ if val is None or pd.isna(val):
52
+ return "non disponible"
53
+ return f"{val:,.0f} {unite}".replace(",", " ")
54
+
55
+
56
+ def _safe_int(val) -> int | None:
57
+ """Convertit en int si possible, None sinon."""
58
+ try:
59
+ if pd.isna(val):
60
+ return None
61
+ return int(val)
62
+ except (ValueError, TypeError):
63
+ return None
64
+
65
+
66
+ def _safe_float(val) -> float | None:
67
+ """Convertit en float si possible, None sinon."""
68
+ try:
69
+ if pd.isna(val):
70
+ return None
71
+ return float(val)
72
+ except (ValueError, TypeError):
73
+ return None
74
+
75
+
76
+ def _get_dv3f_row(echelle: str, code: str, annee: int) -> pd.Series | None:
77
+ """Récupère une ligne DV3F par index."""
78
+ df = load_dv3f()
79
+ try:
80
+ row = df.loc[(echelle, code, annee)]
81
+ if isinstance(row, pd.DataFrame):
82
+ row = row.iloc[0]
83
+ return row
84
+ except KeyError:
85
+ return None
86
+
87
+
88
+ def _get_dv3f_series(echelle: str, code: str) -> pd.DataFrame | None:
89
+ """Récupère toutes les années pour un territoire."""
90
+ df = load_dv3f()
91
+ try:
92
+ result = df.loc[(echelle, code)]
93
+ if isinstance(result, pd.Series):
94
+ result = result.to_frame().T
95
+ return result
96
+ except KeyError:
97
+ return None
98
+
99
+
100
+ def _find_best_echelle(commune: str = "", departement: str = "", annee: int = 2024):
101
+ """Trouve la meilleure échelle disponible avec fallback.
102
+
103
+ Retourne (echelle, code, libelle, row) ou None.
104
+ """
105
+ df = load_dv3f()
106
+
107
+ # 1. Essayer la commune
108
+ if commune:
109
+ row = _get_dv3f_row("communes", commune, annee)
110
+ if row is not None:
111
+ nb = _safe_int(row.get("nbtrans_cod111"))
112
+ if nb is not None and nb >= 5:
113
+ return ("communes", commune, row.get("libelle", commune), row)
114
+ # Commune trouvée mais pas assez de transactions pour les prix
115
+ # On retourne quand même la commune mais on signalera le fallback si besoin
116
+ dep = departement or get_departement_from_commune(commune)
117
+ row_dep = _get_dv3f_row("departements", dep, annee)
118
+ if row_dep is not None:
119
+ return ("departements", dep, row_dep.get("libelle", dep), row_dep,
120
+ "communes", commune, row)
121
+ else:
122
+ # Commune non trouvée, essayer le département
123
+ dep = departement or get_departement_from_commune(commune)
124
+ row_dep = _get_dv3f_row("departements", dep, annee)
125
+ if row_dep is not None:
126
+ return ("departements", dep, row_dep.get("libelle", dep), row_dep)
127
+
128
+ # 2. Essayer le département
129
+ if departement:
130
+ row = _get_dv3f_row("departements", departement, annee)
131
+ if row is not None:
132
+ return ("departements", departement, row.get("libelle", departement), row)
133
+
134
+ return None
135
+
136
+
137
+ # =============================================================================
138
+ # Tool 1 : Recherche de friches
139
+ # =============================================================================
140
+
141
+ def rechercher_friches(
142
+ commune: str = "",
143
+ departement: str = "",
144
+ type_friche: str = "",
145
+ surface_min: float = 0,
146
+ statut: str = "",
147
+ ) -> str:
148
+ """Recherche des friches disponibles sur un territoire donné.
149
+
150
+ Interroge la base Cartofriches du CEREMA (inventaire national des friches) pour
151
+ trouver les friches correspondant aux critères. Utile pour la politique de Zéro
152
+ Artificialisation Nette (ZAN) : identifier les terrains déjà artificialisés qui
153
+ peuvent être réhabilités plutôt que de consommer de nouveaux espaces naturels.
154
+
155
+ Args:
156
+ commune: Code INSEE de la commune (ex: "13055" pour Marseille, "75056" pour Paris). Optionnel.
157
+ departement: Code du département (ex: "13", "75", "59"). Optionnel.
158
+ type_friche: Type de friche à rechercher. Valeurs possibles : "industrielle", "habitat",
159
+ "commerciale", "ferroviaire", "militaire", "hospitalière", "logistique",
160
+ "agro-industrielle", "équipement public", "carrière ou mine". Optionnel.
161
+ surface_min: Surface minimale de la friche en mètres carrés (ex: 5000 pour 0.5 ha). Défaut : 0.
162
+ statut: Statut de la friche. Valeurs possibles : "sans projet", "avec projet",
163
+ "potentielle", "reconvertie". Optionnel.
164
+
165
+ Returns:
166
+ Texte structuré décrivant les friches trouvées, avec pour chacune : nom, type, surface,
167
+ statut, pollution, zonage urbanisme, et commune. Si aucun résultat, un message explicite.
168
+ """
169
+ gdf = load_friches()
170
+ mask = pd.Series([True] * len(gdf), index=gdf.index)
171
+
172
+ # Filtres
173
+ if commune:
174
+ mask &= gdf["comm_insee"] == commune
175
+ if departement:
176
+ mask &= gdf["dep"] == departement
177
+ if type_friche:
178
+ type_mapping = {
179
+ "industrielle": "friche industrielle",
180
+ "habitat": "friche d'habitat",
181
+ "commerciale": "friche commerciale",
182
+ "ferroviaire": "friche ferroviaire",
183
+ "militaire": "friche militaire",
184
+ "hospitalière": "friche hospitalière",
185
+ "logistique": "friche logistique",
186
+ "agro-industrielle": "friche agro-industrielle",
187
+ "équipement public": "friche d'équipement public",
188
+ "carrière ou mine": "friche carrière ou mine",
189
+ }
190
+ type_val = type_mapping.get(type_friche.lower(), type_friche)
191
+ mask &= gdf["site_type"].str.lower() == type_val.lower()
192
+ if surface_min > 0:
193
+ mask &= pd.to_numeric(gdf["site_surface"], errors="coerce") >= surface_min
194
+ if statut:
195
+ statut_mapping = {
196
+ "sans projet": "friche sans projet",
197
+ "avec projet": "friche avec projet",
198
+ "potentielle": "friche potentielle",
199
+ "reconvertie": "friche reconvertie",
200
+ }
201
+ statut_val = statut_mapping.get(statut.lower(), statut)
202
+ mask &= gdf["site_statut"].str.lower() == statut_val.lower()
203
+
204
+ results = gdf[mask]
205
+
206
+ if len(results) == 0:
207
+ filters_desc = []
208
+ if commune:
209
+ filters_desc.append(f"commune {commune}")
210
+ if departement:
211
+ filters_desc.append(f"département {departement}")
212
+ if type_friche:
213
+ filters_desc.append(f"type '{type_friche}'")
214
+ if surface_min > 0:
215
+ filters_desc.append(f"surface ≥ {surface_min:,.0f} m²")
216
+ if statut:
217
+ filters_desc.append(f"statut '{statut}'")
218
+ return f"Aucune friche trouvée avec les critères : {', '.join(filters_desc)}. Essayez d'élargir la recherche (retirer un filtre ou chercher au niveau département)."
219
+
220
+ # Construire la réponse
221
+ total = len(results)
222
+ lines = []
223
+ territory = ""
224
+ if commune:
225
+ commune_name = results.iloc[0]["comm_nom"] if len(results) > 0 else commune
226
+ territory = f"la commune de {commune_name} ({commune})"
227
+ elif departement:
228
+ territory = f"le département {departement}"
229
+ else:
230
+ territory = "le territoire recherché"
231
+
232
+ lines.append(f"## Friches trouvées sur {territory}")
233
+ lines.append(f"**{total} friche(s) trouvée(s)**" + (f" (les 30 premières sont affichées)" if total > 30 else ""))
234
+ lines.append("")
235
+
236
+ # Statistiques résumées (sur TOUS les résultats, pas seulement les 30 premiers)
237
+ surfaces = pd.to_numeric(results["site_surface"], errors="coerce")
238
+ lines.append(f"**Surface totale** : {surfaces.sum()/10000:,.1f} hectares")
239
+ lines.append(f"**Surface médiane** : {surfaces.median()/10000:,.2f} hectares")
240
+ lines.append("")
241
+
242
+ # Répartition par statut (sur TOUS les résultats)
243
+ statut_counts = results["site_statut"].value_counts()
244
+ lines.append("**Répartition par statut** :")
245
+ for s, c in statut_counts.items():
246
+ lines.append(f"- {s} : {c}")
247
+ lines.append("")
248
+
249
+ # Limiter à 30 résultats pour le détail
250
+ results_display = results.head(30)
251
+
252
+ # Liste détaillée
253
+ lines.append("### Détail des friches")
254
+ lines.append("")
255
+
256
+ for _, row in results_display.iterrows():
257
+ surface_val = _safe_float(row.get("site_surface"))
258
+ surface_ha = f"{surface_val/10000:,.2f} ha" if surface_val else "non renseignée"
259
+ surface_m2 = f"{surface_val:,.0f} m²" if surface_val else ""
260
+
261
+ pollution = row.get("sol_pollution_existe", "inconnu")
262
+ if pd.isna(pollution):
263
+ pollution = "inconnu"
264
+
265
+ zonage = row.get("urba_zone_type", "non renseigné")
266
+ if pd.isna(zonage):
267
+ zonage = "non renseigné"
268
+
269
+ bati_etat = row.get("bati_etat", "inconnu")
270
+ if pd.isna(bati_etat):
271
+ bati_etat = "inconnu"
272
+
273
+ site_type = row.get("site_type", "inconnu")
274
+ if pd.isna(site_type):
275
+ site_type = "inconnu"
276
+
277
+ lines.append(f"**{row.get('site_nom', 'Sans nom')}**")
278
+ lines.append(f"- Commune : {row.get('comm_nom', '?')} ({row.get('comm_insee', '?')})")
279
+ lines.append(f"- Type : {site_type}")
280
+ lines.append(f"- Surface : {surface_ha} ({surface_m2})")
281
+ lines.append(f"- Statut : {row.get('site_statut', '?')}")
282
+ lines.append(f"- Pollution sol : {pollution}")
283
+ lines.append(f"- Zonage urbanisme : {zonage}")
284
+ lines.append(f"- État du bâti : {bati_etat}")
285
+ lines.append("")
286
+
287
+ return "\n".join(lines)
288
+
289
+
290
+ # =============================================================================
291
+ # Tool 2 : Statistiques de prix foncier
292
+ # =============================================================================
293
+
294
+ def statistiques_prix_foncier(
295
+ commune: str = "",
296
+ departement: str = "",
297
+ type_bien: str = "tous",
298
+ annee: str = "2024",
299
+ ) -> str:
300
+ """Récupère les statistiques de prix foncier (transactions immobilières) sur un territoire.
301
+
302
+ Utilise les données DV3F du CEREMA (Demande de Valeurs Foncières) qui compilent
303
+ l'ensemble des transactions immobilières en France depuis 2010. Fournit les prix
304
+ médians, prix au m², et volumes de transactions pour les maisons et appartements.
305
+
306
+ Si la commune est trop petite (moins de 5 transactions), les statistiques du département
307
+ sont automatiquement fournies en complément.
308
+
309
+ Args:
310
+ commune: Code INSEE de la commune (ex: "13055" pour Marseille). Optionnel.
311
+ departement: Code du département (ex: "13", "75"). Optionnel. Si ni commune ni département
312
+ ne sont fournis, un message d'erreur est retourné.
313
+ type_bien: Type de bien immobilier. Valeurs : "tous" (défaut), "maison", "appartement".
314
+ annee: Année des statistiques (de 2010 à 2024). Défaut : "2024".
315
+
316
+ Returns:
317
+ Texte structuré avec le nombre de transactions, le prix médian, le prix au m²
318
+ (quartiles Q25, médian, Q75), et la surface médiane. Inclut aussi la ventilation
319
+ par période de construction (avant 1914, 1914-1947, etc.) pour identifier le
320
+ potentiel de rénovation énergétique (biens anciens vs neufs).
321
+ """
322
+ if not commune and not departement:
323
+ return "Veuillez fournir au moins un code INSEE de commune ou un code département pour obtenir des statistiques de prix foncier."
324
+
325
+ try:
326
+ annee_int = int(annee)
327
+ except ValueError:
328
+ return f"L'année '{annee}' n'est pas valide. Veuillez fournir une année entre 2010 et 2024."
329
+
330
+ if annee_int < 2010 or annee_int > 2024:
331
+ return f"L'année {annee_int} est hors de la période couverte (2010–2024)."
332
+
333
+ result = _find_best_echelle(commune, departement, annee_int)
334
+ if result is None:
335
+ return f"Aucune donnée trouvée pour le territoire demandé (commune={commune}, département={departement}, année={annee})."
336
+
337
+ # Gérer le cas du fallback avec données commune
338
+ commune_row = None
339
+ commune_code = None
340
+ if len(result) == 7:
341
+ echelle, code, libelle, row, _, commune_code, commune_row = result
342
+ fallback = True
343
+ else:
344
+ echelle, code, libelle, row = result
345
+ fallback = echelle == "departements" and commune != ""
346
+
347
+ lines = []
348
+
349
+ # Titre
350
+ if echelle == "communes":
351
+ lines.append(f"## Prix foncier à {libelle} ({code}) — {annee_int}")
352
+ elif echelle == "departements":
353
+ lines.append(f"## Prix foncier dans le département {libelle} ({code}) — {annee_int}")
354
+ if commune and commune_row is not None:
355
+ nb_trans = _safe_int(commune_row.get("nbtrans_cod1"))
356
+ lines.append(f"\n> **Note** : La commune {commune} a seulement {nb_trans or 0} transaction(s) en {annee_int}, ce qui est insuffisant pour des statistiques de prix fiables. Les données du département sont présentées en complément.")
357
+ elif commune:
358
+ lines.append(f"\n> **Note** : La commune {commune} n'a pas été trouvée dans les données. Les données du département sont présentées.")
359
+ else:
360
+ lines.append(f"## Prix foncier — {libelle} ({code}) — {annee_int}")
361
+
362
+ lines.append("")
363
+
364
+ # Nombre total de transactions
365
+ nb_total = _safe_int(row.get("nbtrans_cod1"))
366
+ nb_maisons = _safe_int(row.get("nbtrans_cod111"))
367
+ nb_apparts = _safe_int(row.get("nbtrans_cod121"))
368
+ nb_terrains = _safe_int(row.get("nbtrans_cod2"))
369
+
370
+ lines.append("### Volume de transactions")
371
+ lines.append(f"- Total mutations : **{nb_total:,}**".replace(",", " ") if nb_total else "- Total mutations : non disponible")
372
+ lines.append(f"- Maisons : **{nb_maisons:,}**".replace(",", " ") if nb_maisons else "- Maisons : 0")
373
+ lines.append(f"- Appartements : **{nb_apparts:,}**".replace(",", " ") if nb_apparts else "- Appartements : 0")
374
+ lines.append(f"- Terrains (non bâti) : **{nb_terrains:,}**".replace(",", " ") if nb_terrains else "- Terrains : 0")
375
+ lines.append("")
376
+
377
+ # Maisons
378
+ if type_bien in ("tous", "maison"):
379
+ lines.append("### Maisons")
380
+ prix_med = _safe_float(row.get("valeurfonc_median_cod111"))
381
+ prix_q25 = _safe_float(row.get("valeurfonc_q25_cod111"))
382
+ prix_q75 = _safe_float(row.get("valeurfonc_q75_cod111"))
383
+ pxm2_med = _safe_float(row.get("pxm2_median_cod111"))
384
+ pxm2_q25 = _safe_float(row.get("pxm2_q25_cod111"))
385
+ pxm2_q75 = _safe_float(row.get("pxm2_q75_cod111"))
386
+ surf_med = _safe_float(row.get("sbati_median_cod111"))
387
+
388
+ lines.append(f"- Prix médian : **{_format_prix(prix_med)}** (Q25: {_format_prix(prix_q25)}, Q75: {_format_prix(prix_q75)})")
389
+ lines.append(f"- Prix au m² médian : **{_format_pxm2(pxm2_med)}** (Q25: {_format_pxm2(pxm2_q25)}, Q75: {_format_pxm2(pxm2_q75)})")
390
+ lines.append(f"- Surface médiane : **{_format_surface(surf_med)}**")
391
+ lines.append("")
392
+
393
+ # Par période
394
+ lines.append("**Transactions par période de construction** :")
395
+ for p in ["mp1", "mp2", "mp3", "mp4", "mp5", "mpx"]:
396
+ nb = _safe_int(row.get(f"nbtrans_{p}"))
397
+ if nb and nb > 0:
398
+ prix = _safe_float(row.get(f"valeurfonc_median_{p}"))
399
+ pxm2 = _safe_float(row.get(f"pxm2_median_{p}"))
400
+ periode_label = PERIODES_CONSTRUCTION[p]
401
+ lines.append(f"- {periode_label} : {nb} transactions, prix médian {_format_prix(prix)}, {_format_pxm2(pxm2)}")
402
+ lines.append("")
403
+
404
+ # Appartements
405
+ if type_bien in ("tous", "appartement"):
406
+ lines.append("### Appartements")
407
+ prix_med = _safe_float(row.get("valeurfonc_median_cod121"))
408
+ prix_q25 = _safe_float(row.get("valeurfonc_q25_cod121"))
409
+ prix_q75 = _safe_float(row.get("valeurfonc_q75_cod121"))
410
+ pxm2_med = _safe_float(row.get("pxm2_median_cod121"))
411
+ pxm2_q25 = _safe_float(row.get("pxm2_q25_cod121"))
412
+ pxm2_q75 = _safe_float(row.get("pxm2_q75_cod121"))
413
+ surf_med = _safe_float(row.get("sbati_median_cod121"))
414
+
415
+ if nb_apparts and nb_apparts > 0:
416
+ lines.append(f"- Prix médian : **{_format_prix(prix_med)}** (Q25: {_format_prix(prix_q25)}, Q75: {_format_prix(prix_q75)})")
417
+ lines.append(f"- Prix au m² médian : **{_format_pxm2(pxm2_med)}** (Q25: {_format_pxm2(pxm2_q25)}, Q75: {_format_pxm2(pxm2_q75)})")
418
+ lines.append(f"- Surface médiane : **{_format_surface(surf_med)}**")
419
+ lines.append("")
420
+
421
+ # Par période
422
+ lines.append("**Transactions par période de construction** :")
423
+ for p in ["ap1", "ap2", "ap3", "ap4", "ap5", "apx"]:
424
+ nb = _safe_int(row.get(f"nbtrans_{p}"))
425
+ if nb and nb > 0:
426
+ prix = _safe_float(row.get(f"valeurfonc_median_{p}"))
427
+ pxm2 = _safe_float(row.get(f"pxm2_median_{p}"))
428
+ periode_label = PERIODES_CONSTRUCTION[p]
429
+ lines.append(f"- {periode_label} : {nb} transactions, prix médian {_format_prix(prix)}, {_format_pxm2(pxm2)}")
430
+ else:
431
+ lines.append("Pas de transactions d'appartements sur ce territoire pour cette année.")
432
+ lines.append("")
433
+
434
+ # Source
435
+ lines.append("---")
436
+ lines.append(f"*Source : DV3F (CEREMA/DGFiP), échelle {echelle}, année {annee_int}.*")
437
+
438
+ return "\n".join(lines)
439
+
440
+
441
+ # =============================================================================
442
+ # Tool 3 : Évolution des prix
443
+ # =============================================================================
444
+
445
+ def evolution_prix(
446
+ commune: str = "",
447
+ departement: str = "",
448
+ type_bien: str = "maison",
449
+ ) -> str:
450
+ """Retourne l'évolution des prix fonciers sur un territoire de 2010 à 2024.
451
+
452
+ Permet de visualiser la tendance du marché immobilier sur 15 ans : évolution du prix
453
+ médian, du prix au m², et du volume de transactions. Utile pour les études de marché
454
+ et l'analyse des dynamiques territoriales liées à l'artificialisation.
455
+
456
+ Args:
457
+ commune: Code INSEE de la commune (ex: "13055"). Optionnel.
458
+ departement: Code du département (ex: "13"). Optionnel. Au moins un des deux est requis.
459
+ type_bien: Type de bien : "maison" (défaut) ou "appartement".
460
+
461
+ Returns:
462
+ Tableau année par année avec le nombre de transactions, le prix médian, et le
463
+ prix au m² médian. Inclut le calcul de l'évolution entre la première et la dernière
464
+ année disponible.
465
+ """
466
+ if not commune and not departement:
467
+ return "Veuillez fournir au moins un code INSEE de commune ou un code département."
468
+
469
+ # Déterminer l'échelle
470
+ echelle = "communes" if commune else "departements"
471
+ code = commune if commune else departement
472
+
473
+ data = _get_dv3f_series(echelle, code)
474
+ if data is None:
475
+ if commune:
476
+ dep = departement or get_departement_from_commune(commune)
477
+ data = _get_dv3f_series("departements", dep)
478
+ if data is None:
479
+ return f"Aucune donnée trouvée pour la commune {commune} ni le département {dep}."
480
+ echelle = "departements"
481
+ code = dep
482
+ else:
483
+ return f"Aucune donnée trouvée pour le département {departement}."
484
+
485
+ # Récupérer le libellé
486
+ libelle = data.iloc[0].get("libelle", code) if "libelle" in data.columns else code
487
+
488
+ # Colonnes selon le type de bien
489
+ if type_bien.lower() == "appartement":
490
+ col_nb = "nbtrans_cod121"
491
+ col_prix = "valeurfonc_median_cod121"
492
+ col_pxm2 = "pxm2_median_cod121"
493
+ bien_label = "Appartements"
494
+ else:
495
+ col_nb = "nbtrans_cod111"
496
+ col_prix = "valeurfonc_median_cod111"
497
+ col_pxm2 = "pxm2_median_cod111"
498
+ bien_label = "Maisons"
499
+
500
+ lines = []
501
+ if echelle == "communes":
502
+ lines.append(f"## Évolution des prix — {bien_label} à {libelle} ({code})")
503
+ else:
504
+ lines.append(f"## Évolution des prix — {bien_label} dans le département {libelle} ({code})")
505
+ lines.append("")
506
+
507
+ # Construire le tableau
508
+ lines.append("| Année | Nb transactions | Prix médian | Prix/m² médian |")
509
+ lines.append("|-------|-----------------|-------------|----------------|")
510
+
511
+ first_prix = None
512
+ last_prix = None
513
+ first_pxm2 = None
514
+ last_pxm2 = None
515
+
516
+ # data index is annee
517
+ for annee_val in sorted(data.index):
518
+ row = data.loc[annee_val]
519
+ if isinstance(row, pd.DataFrame):
520
+ row = row.iloc[0]
521
+
522
+ nb = _safe_int(row.get(col_nb))
523
+ prix = _safe_float(row.get(col_prix))
524
+ pxm2 = _safe_float(row.get(col_pxm2))
525
+
526
+ if prix is not None and first_prix is None:
527
+ first_prix = prix
528
+ first_pxm2 = pxm2
529
+ if prix is not None:
530
+ last_prix = prix
531
+ last_pxm2 = pxm2
532
+
533
+ nb_str = f"{nb:,}".replace(",", " ") if nb is not None else "-"
534
+ prix_str = _format_prix(prix) if prix else "-"
535
+ pxm2_str = _format_pxm2(pxm2) if pxm2 else "-"
536
+
537
+ lines.append(f"| {annee_val} | {nb_str} | {prix_str} | {pxm2_str} |")
538
+
539
+ lines.append("")
540
+
541
+ # Évolution
542
+ if first_prix and last_prix:
543
+ evol_prix = ((last_prix - first_prix) / first_prix) * 100
544
+ lines.append(f"**Évolution du prix médian** : {evol_prix:+.1f}% sur la période")
545
+ if first_pxm2 and last_pxm2:
546
+ evol_pxm2 = ((last_pxm2 - first_pxm2) / first_pxm2) * 100
547
+ lines.append(f"**Évolution du prix/m²** : {evol_pxm2:+.1f}% sur la période")
548
+
549
+ lines.append("")
550
+ lines.append("---")
551
+ lines.append(f"*Source : DV3F (CEREMA/DGFiP), échelle {echelle}.*")
552
+
553
+ return "\n".join(lines)
554
+
555
+
556
+ # =============================================================================
557
+ # Tool 4 : Statistiques agrégées de friches (multi-échelle)
558
+ # =============================================================================
559
+
560
+ def _format_friches_agg(stats: dict, echelle_label: str, territoire_label: str) -> str:
561
+ """Formate les statistiques agrégées de friches en texte lisible."""
562
+ lines = []
563
+ lines.append(f"## Statistiques des friches — {territoire_label}")
564
+ lines.append(f"*Échelle : {echelle_label}*")
565
+ lines.append("")
566
+
567
+ nb = int(stats.get("nb_friches", 0))
568
+ if nb == 0:
569
+ lines.append("**Aucune friche recensée** dans la base Cartofriches pour ce territoire.")
570
+ return "\n".join(lines)
571
+
572
+ # Chiffres clés
573
+ lines.append("### Chiffres clés")
574
+ lines.append(f"- **Nombre de friches** : {nb:,}".replace(",", " "))
575
+ nb_communes = stats.get("nb_communes", 0)
576
+ if isinstance(nb_communes, (int, float)) and nb_communes > 1:
577
+ lines.append(f"- **Communes concernées** : {int(nb_communes):,}".replace(",", " "))
578
+ surf_tot = stats.get("surface_totale_ha", 0)
579
+ lines.append(f"- **Surface totale** : {surf_tot:,.1f} hectares ({surf_tot/100:,.2f} km²)".replace(",", " "))
580
+ lines.append(f"- **Surface médiane** : {stats.get('surface_mediane_ha', 0):,.2f} hectares".replace(",", " "))
581
+ lines.append(f"- **Surface moyenne** : {stats.get('surface_moyenne_ha', 0):,.2f} hectares".replace(",", " "))
582
+ lines.append("")
583
+
584
+ # Statuts
585
+ lines.append("### Répartition par statut")
586
+ for statut_key, statut_label in [
587
+ ("nb_potentielle", "Friche potentielle"),
588
+ ("nb_sans_projet", "Friche sans projet"),
589
+ ("nb_avec_projet", "Friche avec projet"),
590
+ ("nb_reconvertie", "Friche reconvertie"),
591
+ ]:
592
+ count = int(stats.get(statut_key, 0))
593
+ pct = round(100 * count / nb, 1) if nb > 0 else 0
594
+ lines.append(f"- {statut_label} : **{count}** ({pct}%)")
595
+ lines.append("")
596
+
597
+ # Mobilisables
598
+ nb_mob = int(stats.get("nb_mobilisables", 0))
599
+ surf_mob = stats.get("surface_mobilisable_ha", 0)
600
+ lines.append("### Potentiel mobilisable (ZAN)")
601
+ lines.append(f"- **Friches mobilisables** (sans projet + potentielles) : **{nb_mob}** friches")
602
+ lines.append(f"- **Surface mobilisable** : **{surf_mob:,.1f} hectares**".replace(",", " "))
603
+ lines.append("")
604
+
605
+ # Qualité
606
+ nb_poll = int(stats.get("nb_polluees", 0))
607
+ nb_u = int(stats.get("nb_zone_u", 0))
608
+ pct_u = stats.get("pct_zone_u", 0)
609
+ lines.append("### Caractéristiques")
610
+ lines.append(f"- Pollution avérée ou supposée : {nb_poll} ({round(100*nb_poll/nb, 1) if nb else 0}%)")
611
+ lines.append(f"- En zone urbanisée (U) : {nb_u} ({pct_u}%) — requalifiables sans artificialisation")
612
+ lines.append("")
613
+
614
+ # Types principaux
615
+ top_types = stats.get("top_types", {})
616
+ if top_types and isinstance(top_types, dict):
617
+ lines.append("### Principaux types de friches")
618
+ for type_name, count in sorted(top_types.items(), key=lambda x: -x[1]):
619
+ lines.append(f"- {type_name} : {int(count)}")
620
+ lines.append("")
621
+
622
+ lines.append("---")
623
+ lines.append("*Source : Cartofriches (CEREMA), statistiques pré-agrégées.*")
624
+ return "\n".join(lines)
625
+
626
+
627
+ def statistiques_friches(
628
+ commune: str = "",
629
+ epci: str = "",
630
+ departement: str = "",
631
+ region: str = "",
632
+ echelle: str = "",
633
+ ) -> str:
634
+ """Fournit les statistiques agrégées des friches à différentes échelles territoriales.
635
+
636
+ Retourne le nombre de friches, la surface totale et mobilisable, la répartition par
637
+ statut et type, le taux de pollution et le pourcentage en zone urbanisée. Les données
638
+ sont pré-agrégées pour des réponses instantanées.
639
+
640
+ Fonctionne à 5 échelles : commune, EPCI (intercommunalité), département, région, national.
641
+ Si une commune est fournie, l'outil retourne aussi automatiquement les statistiques
642
+ aux échelles supérieures (EPCI, département, région) pour permettre la comparaison.
643
+
644
+ Args:
645
+ commune: Code INSEE de la commune (ex: "13055" pour Marseille). Optionnel.
646
+ epci: Code SIREN de l'EPCI / intercommunalité (ex: "200054807" pour Aix-Marseille-Provence).
647
+ Optionnel.
648
+ departement: Code du département (ex: "13", "59"). Optionnel.
649
+ region: Code de la région (ex: "93" pour PACA, "32" pour Hauts-de-France). Optionnel.
650
+ echelle: Forcer une échelle spécifique : "commune", "epci", "departement", "region",
651
+ "national". Si vide, l'échelle est déduite des paramètres fournis. Mettre
652
+ "national" pour les statistiques France entière.
653
+
654
+ Returns:
655
+ Statistiques structurées : nombre de friches, surface totale et mobilisable,
656
+ répartition par statut/type, pollution, zone urbanisée. Si une commune est fournie,
657
+ inclut aussi les statistiques EPCI, département et région pour comparaison.
658
+ """
659
+ # Cas national
660
+ if echelle == "national" or (not commune and not epci and not departement and not region):
661
+ if not echelle:
662
+ return ("Veuillez fournir au moins un territoire (commune, EPCI, département, région) "
663
+ "ou préciser echelle='national' pour les statistiques France entière.")
664
+ stats = get_friches_agg("national")
665
+ if stats:
666
+ return _format_friches_agg(stats, "National", "France entière")
667
+ return "Erreur : données nationales non disponibles."
668
+
669
+ # Si commune fournie → retourner multi-échelle pour comparaison
670
+ if commune:
671
+ infos = get_all_echelles_for_commune(commune)
672
+ lines_parts = []
673
+
674
+ # Commune
675
+ stats_comm = get_friches_agg("commune", commune)
676
+ if stats_comm:
677
+ lines_parts.append(_format_friches_agg(
678
+ stats_comm, "Commune",
679
+ f"{infos.get('commune_nom', '')} ({commune})"
680
+ ))
681
+ else:
682
+ lines_parts.append(f"## Friches — {infos.get('commune_nom', commune)} ({commune})\n\nAucune friche recensée dans cette commune.")
683
+
684
+ # EPCI
685
+ epci_code = infos.get("epci", "")
686
+ if epci_code and epci_code != "ZZZZZZZZZ":
687
+ stats_epci = get_friches_agg("epci", epci_code)
688
+ if stats_epci:
689
+ lines_parts.append(_format_friches_agg(
690
+ stats_epci, "EPCI (intercommunalité)",
691
+ f"{infos.get('epci_nom', epci_code)}"
692
+ ))
693
+
694
+ # Département
695
+ dep = infos.get("departement", "")
696
+ if dep:
697
+ stats_dep = get_friches_agg("departement", dep)
698
+ if stats_dep:
699
+ lines_parts.append(_format_friches_agg(
700
+ stats_dep, "Département",
701
+ f"Département {dep}"
702
+ ))
703
+
704
+ # Région
705
+ reg = infos.get("region", "")
706
+ if reg:
707
+ stats_reg = get_friches_agg("region", reg)
708
+ if stats_reg:
709
+ lines_parts.append(_format_friches_agg(
710
+ stats_reg, "Région",
711
+ f"{infos.get('region_nom', reg)}"
712
+ ))
713
+
714
+ return "\n\n---\n\n".join(lines_parts)
715
+
716
+ # EPCI seul
717
+ if epci:
718
+ stats = get_friches_agg("epci", epci)
719
+ if stats:
720
+ return _format_friches_agg(stats, "EPCI (intercommunalité)",
721
+ stats.get("libelle", epci))
722
+ return f"Aucune donnée de friches pour l'EPCI {epci}."
723
+
724
+ # Département seul
725
+ if departement:
726
+ stats = get_friches_agg("departement", departement)
727
+ if stats:
728
+ return _format_friches_agg(stats, "Département",
729
+ f"Département {departement}")
730
+ return f"Aucune donnée de friches pour le département {departement}."
731
+
732
+ # Région seule
733
+ if region:
734
+ stats = get_friches_agg("region", region)
735
+ if stats:
736
+ reg_nom = REG_NAMES.get(region, region)
737
+ return _format_friches_agg(stats, "Région", reg_nom)
738
+ return f"Aucune donnée de friches pour la région {region}."
739
+
740
+ return "Paramètres insuffisants. Fournissez un code commune, EPCI, département ou région."
741
+
742
+
743
+ # =============================================================================
744
+ # Tool 5 : Diagnostic foncier territorial (multi-échelle)
745
+ # =============================================================================
746
+
747
+ def diagnostic_foncier_territoire(
748
+ commune: str = "",
749
+ epci: str = "",
750
+ departement: str = "",
751
+ region: str = "",
752
+ ) -> str:
753
+ """Fournit un diagnostic foncier complet d'un territoire en croisant friches et marché immobilier.
754
+
755
+ Outil de synthèse qui combine les données Cartofriches (inventaire des friches) et
756
+ DV3F (transactions immobilières) pour donner une vision globale de la situation
757
+ foncière d'un territoire. Particulièrement utile dans le cadre du Zéro Artificialisation
758
+ Nette (ZAN) pour identifier le potentiel de requalification et contextualiser avec
759
+ le marché local.
760
+
761
+ Fonctionne à toutes les échelles : commune, EPCI, département, région.
762
+
763
+ Args:
764
+ commune: Code INSEE de la commune (ex: "13055" pour Marseille). Optionnel.
765
+ epci: Code SIREN de l'EPCI (ex: "200054807"). Optionnel.
766
+ departement: Code du département (ex: "13"). Optionnel.
767
+ region: Code de la région (ex: "93" pour PACA). Optionnel.
768
+
769
+ Returns:
770
+ Diagnostic structuré en 3 parties :
771
+ 1. Inventaire des friches (nombre, surface, types, statuts)
772
+ 2. Marché foncier local (prix, volumes, tendance)
773
+ 3. Analyse croisée (contextualisation friches / marché)
774
+ """
775
+ if not commune and not epci and not departement and not region:
776
+ return "Veuillez fournir au moins un territoire (commune, EPCI, département ou région)."
777
+
778
+ lines = []
779
+
780
+ # --- Déterminer l'échelle et le territoire ---
781
+ if commune:
782
+ friches_echelle, friches_code = "commune", commune
783
+ dv3f_echelle, dv3f_code = "communes", commune
784
+ infos = get_all_echelles_for_commune(commune)
785
+ nom_territoire = f"{infos.get('commune_nom', commune)} ({commune})"
786
+ dep_code = infos.get("departement", get_departement_from_commune(commune))
787
+ elif epci:
788
+ friches_echelle, friches_code = "epci", epci
789
+ dv3f_echelle, dv3f_code = "epci", epci
790
+ stats_temp = get_friches_agg("epci", epci)
791
+ nom_territoire = stats_temp.get("libelle", epci) if stats_temp else epci
792
+ dep_code = ""
793
+ elif departement:
794
+ friches_echelle, friches_code = "departement", departement
795
+ dv3f_echelle, dv3f_code = "departements", departement
796
+ nom_territoire = f"Département {departement}"
797
+ dep_code = departement
798
+ else: # region
799
+ friches_echelle, friches_code = "region", region
800
+ dv3f_echelle, dv3f_code = "regions", region
801
+ nom_territoire = REG_NAMES.get(region, f"Région {region}")
802
+ dep_code = ""
803
+
804
+ lines.append(f"## Diagnostic foncier — {nom_territoire}")
805
+ lines.append("")
806
+
807
+ # --- Partie 1 : Friches (depuis les agrégations pré-calculées) ---
808
+ lines.append("### 1. Inventaire des friches (Cartofriches)")
809
+ lines.append("")
810
+
811
+ stats = get_friches_agg(friches_echelle, friches_code)
812
+
813
+ if stats is None or int(stats.get("nb_friches", 0)) == 0:
814
+ lines.append("**Aucune friche recensée** dans la base Cartofriches pour ce territoire.")
815
+ lines.append("> Cela ne signifie pas qu'il n'y a pas de friches, mais qu'aucune n'a été inventoriée.")
816
+ nb_friches = 0
817
+ else:
818
+ nb_friches = int(stats["nb_friches"])
819
+ nb_communes = stats.get("nb_communes", 0)
820
+ lines.append(f"- **Nombre de friches** : {nb_friches:,}".replace(",", " "))
821
+ if isinstance(nb_communes, (int, float)) and nb_communes > 1:
822
+ lines.append(f"- **Communes concernées** : {int(nb_communes):,}".replace(",", " "))
823
+ lines.append(f"- **Surface totale** : {stats['surface_totale_ha']:,.1f} hectares".replace(",", " "))
824
+ lines.append(f"- **Surface médiane** : {stats['surface_mediane_ha']:,.2f} hectares".replace(",", " "))
825
+ lines.append("")
826
+
827
+ # Par statut
828
+ lines.append("**Par statut** :")
829
+ for key, label in [("nb_potentielle", "friche potentielle"), ("nb_sans_projet", "friche sans projet"),
830
+ ("nb_avec_projet", "friche avec projet"), ("nb_reconvertie", "friche reconvertie")]:
831
+ count = int(stats.get(key, 0))
832
+ if count > 0:
833
+ pct = round(100 * count / nb_friches, 0)
834
+ lines.append(f"- {label} : {count} ({pct:.0f}%)")
835
+ lines.append("")
836
+
837
+ # Types
838
+ top_types = stats.get("top_types", {})
839
+ if top_types and isinstance(top_types, dict):
840
+ lines.append("**Principaux types** :")
841
+ for t, c in sorted(top_types.items(), key=lambda x: -x[1])[:5]:
842
+ lines.append(f"- {t} : {int(c)}")
843
+ lines.append("")
844
+
845
+ # Pollution et zone U
846
+ nb_poll = int(stats.get("nb_polluees", 0))
847
+ nb_u = int(stats.get("nb_zone_u", 0))
848
+ pct_u = stats.get("pct_zone_u", 0)
849
+ lines.append(f"**Pollution** : {nb_poll} friche(s) avec pollution avérée ou supposée ({round(100*nb_poll/nb_friches)}%)")
850
+ lines.append(f"**En zone urbanisée (U)** : {nb_u} ({pct_u}%) — requalifiables sans artificialisation")
851
+
852
+ lines.append("")
853
+
854
+ # --- Partie 2 : Marché foncier ---
855
+ lines.append("### 2. Marché foncier (DV3F)")
856
+ lines.append("")
857
+
858
+ annee = 2024
859
+ pxm2_maisons = None
860
+
861
+ if commune:
862
+ result = _find_best_echelle(commune, dep_code, annee)
863
+ elif departement:
864
+ result = _find_best_echelle("", departement, annee)
865
+ elif epci:
866
+ row = _get_dv3f_row("epci", epci, annee)
867
+ result = ("epci", epci, nom_territoire, row) if row is not None else None
868
+ elif region:
869
+ row = _get_dv3f_row("regions", region, annee)
870
+ result = ("regions", region, nom_territoire, row) if row is not None else None
871
+ else:
872
+ result = None
873
+
874
+ if result is None:
875
+ lines.append("Aucune donnée de marché foncier disponible pour ce territoire.")
876
+ else:
877
+ if len(result) == 7:
878
+ ech, ech_code, ech_libelle, row, _, _, _ = result
879
+ else:
880
+ ech, ech_code, ech_libelle, row = result
881
+
882
+ if ech == "departements" and commune:
883
+ lines.append(f"> Données au niveau département ({ech_libelle}) — commune trop petite pour des statistiques fiables.")
884
+ lines.append("")
885
+
886
+ nb_total = _safe_int(row.get("nbtrans_cod1"))
887
+ nb_maisons = _safe_int(row.get("nbtrans_cod111"))
888
+ nb_apparts = _safe_int(row.get("nbtrans_cod121"))
889
+
890
+ lines.append(f"**Année {annee}** :")
891
+ lines.append(f"- Transactions totales : {nb_total:,}".replace(",", " ") if nb_total else "- Transactions totales : non disponible")
892
+ lines.append("")
893
+
894
+ pxm2_maisons = _safe_float(row.get("pxm2_median_cod111"))
895
+ prix_maisons = _safe_float(row.get("valeurfonc_median_cod111"))
896
+ if nb_maisons and nb_maisons > 0:
897
+ lines.append(f"**Maisons** ({nb_maisons:,} transactions) :".replace(",", " "))
898
+ lines.append(f"- Prix médian : {_format_prix(prix_maisons)}")
899
+ lines.append(f"- Prix/m² médian : {_format_pxm2(pxm2_maisons)}")
900
+
901
+ pxm2_apparts = _safe_float(row.get("pxm2_median_cod121"))
902
+ prix_apparts = _safe_float(row.get("valeurfonc_median_cod121"))
903
+ if nb_apparts and nb_apparts > 0:
904
+ lines.append(f"\n**Appartements** ({nb_apparts:,} transactions) :".replace(",", " "))
905
+ lines.append(f"- Prix médian : {_format_prix(prix_apparts)}")
906
+ lines.append(f"- Prix/m² médian : {_format_pxm2(pxm2_apparts)}")
907
+
908
+ # Tendance
909
+ row_2020 = _get_dv3f_row(ech, ech_code, 2020)
910
+ if row_2020 is not None:
911
+ pxm2_2020 = _safe_float(row_2020.get("pxm2_median_cod111"))
912
+ if pxm2_2020 and pxm2_maisons:
913
+ evol = ((pxm2_maisons - pxm2_2020) / pxm2_2020) * 100
914
+ lines.append(f"\n**Tendance** : prix/m² maisons {evol:+.1f}% entre 2020 et 2024")
915
+
916
+ lines.append("")
917
+
918
+ # --- Partie 3 : Analyse croisée ---
919
+ lines.append("### 3. Analyse croisée pour le ZAN")
920
+ lines.append("")
921
+
922
+ if stats and nb_friches > 0 and result is not None:
923
+ nb_mob = int(stats.get("nb_mobilisables", 0))
924
+ surf_mob = stats.get("surface_mobilisable_ha", 0)
925
+ lines.append(f"- **Gisement de friches mobilisables** (sans projet + potentielles) : {nb_mob} friches, {surf_mob:,.1f} ha".replace(",", " "))
926
+ if nb_mob > 0:
927
+ if pxm2_maisons:
928
+ lines.append(f"- **Contexte marché** : le prix/m² local ({_format_pxm2(pxm2_maisons)} pour les maisons) permet de contextualiser le coût de réhabilitation")
929
+ lines.append(f"- **Potentiel ZAN** : ces {nb_mob} friches représentent du foncier déjà artificialisé, mobilisable pour éviter la consommation de nouveaux espaces naturels ou agricoles")
930
+ else:
931
+ lines.append("- Toutes les friches du territoire ont déjà un projet ou ont été reconverties.")
932
+ elif nb_friches == 0:
933
+ lines.append("Pas de friches inventoriées sur ce territoire — le diagnostic ZAN nécessiterait un inventaire local complémentaire.")
934
+ else:
935
+ lines.append("Données de marché foncier indisponibles — le croisement friches/marché n'est pas possible.")
936
+
937
+ lines.append("")
938
+ lines.append("---")
939
+ lines.append("*Sources : Cartofriches (CEREMA) + DV3F (CEREMA/DGFiP).*")
940
+
941
+ return "\n".join(lines)