🗺️ Réglementation / Urbanisme

API GPU Géoportail de l'Urbanisme :
qualifier la faisabilité PLU de vos projets PV en Python

Avant toute étude de faisabilité PV, il faut vérifier que la zone est constructible. Le Géoportail de l'Urbanisme (GPU) expose tous les PLU et PLUi de France via une API WFS gratuite. Ce tutoriel montre comment l'interroger en Python pour qualifier une parcelle en 5 secondes.

📅 2026-02-18 ⏱ 7 min de lecture GPUPLUPLUiWFSPythonUrbanisme

Le Géoportail de l'Urbanisme : une mine pour les développeurs PV

En France, toute installation photovoltaïque au sol ou en toiture dépassant une certaine puissance est soumise à la réglementation d'urbanisme locale — Plan Local d'Urbanisme (PLU), PLUi (intercommunal) ou carte communale. Une erreur de zone au stade de la prospection peut invalider des semaines de travail.

Le GPU, géré par le Ministère chargé de l'Urbanisme, centralise les documents d'urbanisme de 35 000+ communes françaises et les expose via des services WFS standards. Les données sont disponibles sans authentification ni quota.

ℹ️ WFS vs WMS

Le GPU expose deux types de services : WMS (images raster pour l'affichage cartographique) et WFS (données vecteur pour les requêtes attributaires). Pour l'analyse programmatique (identifier la zone d'une parcelle), on utilise exclusivement le WFS.

Comprendre les zones PLU pour le photovoltaïque

Les systèmes PV sont généralement autorisés en zones :

Le règlement graphique et le règlement écrit du PLU précisent les conditions exactes. L'API GPU vous donne la zone — votre équipe juridique vérifie les prescriptions textuelles.

Endpoint WFS GPU et paramètres clés

Python gpu_client.py
import requests
import json
from typing import Optional

# ── Endpoint principal du GPU ─────────────────────────────────────────────────
GPU_WFS_URL = "https://www.geoportail-urbanisme.gouv.fr/api/wfs/metadata"
GPU_WFS_DATA = "https://www.geoportail-urbanisme.gouv.fr/api/wfs"

# Couches disponibles (type names WFS)
COUCHES = {
    "zones_urba":       "GPU:zone_urba",         # Zones d'urbanisme PLU/PLUi
    "prescriptions":   "GPU:prescription_surf",  # Prescriptions surfaciques
    "info_surf":       "GPU:info_surf",          # Informations surfaciques
    "communes":        "GPU:commune",            # Communes avec doc d'urbanisme
}


def recuperer_zone_plu(lat: float, lon: float) -> Optional[dict]:
    """
    Interroge le PLU/PLUi d'un point GPS et retourne la zone d'urbanisme.
    
    Returns:
        dict avec clés : zone_type (str), libelle (str), commune (str), 
                         doc_id (str), constructible (bool, heuristique)
    """
    params = {
        "SERVICE":    "WFS",
        "VERSION":    "2.0.0",
        "REQUEST":    "GetFeature",
        "TYPENAMES":  "GPU:zone_urba",
        "CRS":        "EPSG:4326",
        "count":      5,
        "OUTPUTFORMAT": "application/json",
        # Filtre CQL : intersection avec le point GPS
        "CQL_FILTER": f"INTERSECTS(geom,POINT({lon} {lat}))",
    }

    try:
        resp = requests.get(GPU_WFS_DATA, params=params, timeout=15)
        resp.raise_for_status()
        data = resp.json()
    except requests.RequestException as e:
        return {"erreur": f"Requête GPU échouée: {e}"}

    features = data.get("features", [])
    if not features:
        return {"zone_type": "INCONNU", "message": "Commune sans GPU numérique"}

    # Prendre le premier résultat (zone la plus précise)
    feat = features[0]
    props = feat.get("properties", {})

    libelle  = props.get("libelle", "")     # ex: "UA", "Ub", "AU"
    typezone = props.get("typezone", "")   # ex: "U", "AU", "A", "N"
    liblong  = props.get("libellelong", "") # ex: "Zone urbaine générale"
    doc_id   = props.get("partition", "")    # identifiant du document

    return {
        "zone_type":    typezone,
        "libelle":      libelle,
        "libelle_long": liblong,
        "doc_id":       doc_id,
        "constructible": typezone in ("U", "AU"),   # heuristique simplifiée
        "favorable_pv_sol": typezone == "A",         # heuristique agrivoltaïque
    }

Vérifier si une commune a un document GPU numérique

Toutes les communes ne sont pas encore numérisées dans GPU. Avant d'interroger les zones, vérifiez qu'un document d'urbanisme est disponible pour le code INSEE concerné.

Python gpu_commune_check.py
def commune_a_gpu(code_insee: str) -> dict:
    """
    Vérifie si une commune a un document d'urbanisme dans GPU et retourne
    l'identifiant du document actif (PLU, PLUi, carte communale, RNU).
    """
    params = {
        "SERVICE":    "WFS",
        "VERSION":    "2.0.0",
        "REQUEST":    "GetFeature",
        "TYPENAMES":  "GPU:commune",
        "CQL_FILTER": f"codeinsee='{code_insee}'",
        "OUTPUTFORMAT": "application/json",
    }

    resp = requests.get(GPU_WFS_DATA, params=params, timeout=15)
    features = resp.json().get("features", [])

    if not features:
        return {"gpu_disponible": False, "type_doc": "RNU"}

    props = features[0]["properties"]
    return {
        "gpu_disponible": True,
        "type_doc":       props.get("libelle", ""),   # "PLU", "PLUi", "CC"…
        "date_approb":    props.get("datapprobation", ""),
        "partition":      props.get("partition", ""),
    }


# Exemple : Lyon 7ème (INSEE 69387)
info = commune_a_gpu("69387")
print(info)
# → {'gpu_disponible': True, 'type_doc': 'PLU', 'date_approb': '2022-06-27', ...}

Pipeline de qualification automatique d'un portefeuille de sites

En prospection intensive, un bureau d'études peut avoir plusieurs centaines de sites à qualifier chaque semaine. Voici un pipeline asynchrone pour qualifier un CSV d'adresses en quelques minutes.

Python batch_qualification.py
import asyncio
import aiohttp
import csv

async def qualifier_site_async(session, lat, lon, nom_site):
    """Version asynchrone de la qualification GPU."""
    url = "https://www.geoportail-urbanisme.gouv.fr/api/wfs"
    params = {
        "SERVICE": "WFS", "VERSION": "2.0.0",
        "REQUEST": "GetFeature", "TYPENAMES": "GPU:zone_urba",
        "CQL_FILTER": f"INTERSECTS(geom,POINT({lon} {lat}))",
        "OUTPUTFORMAT": "application/json", "count": 1,
    }
    async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=12)) as resp:
        data = await resp.json(content_type=None)
        features = data.get("features", [])
        zone = features[0]["properties"]["typezone"] if features else "?"
        return {"site": nom_site, "lat": lat, "lon": lon, "zone_plu": zone}


async def qualifier_portefeuille(sites: list) -> list:
    """
    Qualifie tous les sites en parallèle (max 10 requêtes simultanées).
    sites = [{'nom': ..., 'lat': ..., 'lon': ...}, ...]
    """
    semaphore = asyncio.Semaphore(10)

    async def _avec_sem(session, site):
        async with semaphore:
            return await qualifier_site_async(session, site["lat"], site["lon"], site["nom"])

    async with aiohttp.ClientSession() as session:
        tasks = [_avec_sem(session, s) for s in sites]
        return await asyncio.gather(*tasks, return_exceptions=True)

Limites de l'API GPU et alternatives

⚠️ Points d'attention
  • Taux de couverture national : ~80% des communes fin 2025. Les communes rurales sous Règlement National d'Urbanisme (RNU) n'ont pas de données GPU.
  • Délai de mise à jour : Les documents GPU peuvent avoir 6 à 18 mois de décalage avec les délibérations municipales.
  • Interprétation : L'API donne la zone, pas le règlement écrit. Une zone « AU » peut être inconstructible en l'état (AU fermée).

En cas de commune sans GPU, les alternatives sont :

HeliaPV croise GPU, RPG et cadastre simultanément

Sur chaque site analysé, HeliaPV affiche automatiquement la zone PLU, la parcelle cadastrale, le classement RPG et les contraintes GeoRisques — zéro requête API à coder.

Essai gratuit – 50 analyses offertes Voir les fonctionnalités BET
← Article précédent : IGN LiDAR HD Prochain : Enedis Data Connect →