Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- .gitattributes +2 -0
- README.md +81 -6
- app.py +150 -0
- data/cartofriches.geojson +3 -0
- data/mapping_communes.csv +0 -0
- data/prix_volumes.csv +3 -0
- data_loader.py +379 -0
- requirements.txt +5 -0
- server.py +941 -0
.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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
-
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|