Skip to main content

REX 06079

Contexte

Affichage des IRIS de Mandelieu-la-Napoule (06079) depuis PostGIS Alteris, avec population graduéegraduée, étiquettes et étiquettesfond orthophoto IGN Géoplateforme — entièrement piloté depuis Claude Code via le MCP QGIS, sans interaction manuelle dans QGIS.


Données utilisées

Table Schéma Contenu
contours_iris_2025 insee_raw Géométries IRIS France entière
iris_2025_population insee_raw Population 2022 par IRIS (recensement)
Orthophoto IGN Géoplateforme (WMTS) HR.ORTHOIMAGERY.ORTHOPHOTOS — sans clé API

Colonnes clés — pièges à retenir

contours_iris_2025 : colonnes standard sans espace — code_insee, code_iris, nom_iris, type_iris, geom

iris_2025_population : colonnes avec espace traînant (import INSEE brut) :

  • Clé IRIS : "IRIS " (avec espace)
  • Commune : "COM " (avec espace)
  • Population totale 2022 : "P22_POP " (avec espace)
  • Toutes les colonnes sont de type text — caster en ::numeric avant usage

Résultats

9 IRIS pour 06079, population 2022 :

code_iris nom_iris population
060790101 Zone d'activités 24
060790102 IRIS 2 2 701
060790103 IRIS 3 2 000
060790104 IRIS 4 3 058
060790105 IRIS 5 2 197
060790106 IRIS 6 2 432
060790107 IRIS 7 3 188
060790108 IRIS 8 2 944
060790109 IRIS 9 2 656

Population min : 24 hab. (zone d'activités/naturelle) — max : 3 188 hab.


Procédure complète

Prérequis

Tunnel SSH paramiko actif (voir page 229 — Option 2). À relancer à chaque redémarrage de QGIS :

import sys
sys.path.insert(0, r'C:\Users\eliob\AppData\Roaming\Python\Python312\site-packages')
import paramiko
#import socket, threading, select

class SSHTunnel(threading.Thread):
    def __init__(self, ssh_host, ssh_user, ssh_password,
                 remote_port, local_port=5433, remote_host='127.0.0.1'):
        super().__init__(daemon=True)
        self.ssh_host = ssh_host; self.ssh_user = ssh_user
        self.ssh_password = ssh_password; self.remote_host = remote_host
        self.remote_port = remote_port; self.local_port = local_port
        self._stop = threading.Event(); self.transport = None; self.server_sock = None

    def run(self):
        client = paramiko.SSHClient()
        client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        client.connect(self.ssh_host, username=self.ssh_user, password=self.ssh_password)
        self.transport = client.get_transport()
        self.server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.server_sock.bind(('127.0.0.1', self.local_port))
        self.server_sock.listen(5); self.server_sock.settimeout(1.0)
        while not self._stop.is_set():
            try:
                conn, _ = self.server_sock.accept()
                threading.Thread(target=self._forward, args=(conn,), daemon=True)..start()
            lancerexcept SSHTunnelsocket.timeout:
                verscontinue
        localhost:5433self.server_sock.close(); client.close()

    def _forward(self, local_conn):
        try:
            chan = self.transport.open_channel('direct-tcpip',
                (self.remote_host, self.remote_port), local_conn.getpeername())
        except Exception:
            local_conn.close(); return
        while True:
            r, _, _ = select.select([local_conn, chan], [], [], 5)
            if local_conn in r:
                data = local_conn.recv(4096)
                if not data: break
                chan.send(data)
            if chan in r:
                data = chan.recv(4096)
                if not data: break
                local_conn.send(data)
        chan.close(); local_conn.close()

    def stop(self):
        self._stop.set()

tunnel = SSHTunnel('79.137.14.202', 'debian', 'RAW+NEXTE!', remote_port=5432, local_port=5433)
tunnel.start()

1. IdentifierCharger l'orthophoto IGN (fond de carte)

Bug QGIS 4.0.3 : addMapLayer déclenche autoSelectAddedLayer → identifyMapTool → access violation quand une couche raster est ajoutée. Deux contournements obligatoires :

    Passer sur l'outil Pan avant d'ajouter la couche Utiliser addMapLayer(layer, False) + root.insertLayer() (sans auto-sélection)

    Format URI : le provider WMTS natif de QGIS (crs=...&layers=...&url=...) échoue sur la Géoplateforme (pas de capabilities). Solution : encoder l'URL WMTS en XYZ tiles avec {z}/{y}/{x}.

    from qgis.core import QgsProject, QgsRasterLayer
    from qgis.gui import QgsMapToolPan
    from qgis.utils import iface
    import urllib.parse
    
    # 1. Passer sur Pan AVANT d'ajouter la couche raster (évite le crash identifyMapTool)
    pan_tool = QgsMapToolPan(iface.mapCanvas())
    iface.mapCanvas().setMapTool(pan_tool)
    
    # 2. URI XYZ (le provider WMTS natif échoue sur data.geopf.fr)
    base_url = (
        "https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile"
        "&VERSION=1.0.0&LAYER=HR.ORTHOIMAGERY.ORTHOPHOTOS"
        "&STYLE=normal&FORMAT=image/jpeg"
        "&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}"
    )
    encoded = urllib.parse.quote(base_url, safe='')
    uri = f"type=xyz&url={encoded}&zmin=0&zmax=19"
    
    layer_ortho = QgsRasterLayer(uri, "Orthophoto IGN", "wms")
    # 3. addToLegend=False + insertLayer manuel (évite autoSelectAddedLayer)
    QgsProject.instance().addMapLayer(layer_ortho, False)
    root = QgsProject.instance().layerTreeRoot()
    root.insertLayer(-1, layer_ortho)  # en bas de la pile
    

    2. Créer la vue PostGIS et charger les tablesIRIS

    import psycopg2
    conn = psycopg2.connect(host='localhost', port=5433, dbname='alteris_geo',
                            user='alteris_admin', password='Alteris2026')
    cur = conn.cursor()
    cur.execute("SELECT table_schema, table_name FROM information_schema.tables WHERE table_name ILIKE '%iris%' ORDER BY 1,2")
    print(cur.fetchall())
    

    2. Créer une vue PostgreSQL (contournement du bug URI)

    Problème rencontré : charger une sous-requête SQL directement dans l'URI QGIS échoue quand la requête contient des guillemets doubles ("P22_POP "). QgsDataSourceUri.setDataSource() sur-échappe les guillemets (\\\") → couche invalide.

    Solution : créer une vue PostgreSQL et charger la vue comme une table normale.

    cur.execute("""
        CREATE OR REPLACE VIEW insee_raw.v_iris_mandelieu_pop AS
        SELECT c.fid, c.geom, c.nom_iris, c.code_iris,
               ROUND(p."P22_POP "::numeric, 0)::integer AS population
        FROM insee_raw.contours_iris_2025 c
        LEFT JOIN insee_raw.iris_2025_population p ON p."IRIS " = c.code_iris
        WHERE c.code_insee = '06079'
    """)
    conn.commit(); conn.close()
    
    

    3. Charger la vue comme couche QGIS

    from qgis.core import QgsProject, QgsVectorLayer
    uri = (
        'host=localhost port=5433 dbname=alteris_geo '
        'user=alteris_admin password=Alteris2026 sslmode=disable '
        'table="insee_raw"."v_iris_mandelieu_pop" (geom) key=fid'
    )
    layer = QgsVectorLayer(uri, "IRIS Mandelieu-la-Napoule", "postgres")
    # → Valide: True — Entités: 9
    

    4.3. Graduation par population (semi-transparent)

    from qgis.core import QgsGraduatedSymbolRenderer, QgsRendererRange, QgsFillSymbol
    
    colors = ['#EFF3FF', '#BDD7E7', '#6BAED6', '#2171B5', '#084594']  # blanc → bleu foncé
    pmin, pmax = 24, 3188
    step = (pmax - pmin) / 5
    bounds = [pmin + i * step for i in range(6)]
    
    ranges_def = []
    for i in range(5):
        lo, hi = bounds[i], bounds[i+1]
        sym = QgsFillSymbol.createSimple({
            'color': colors[i], 'outline_color': '#555555', 'outline_width': '0.3'
        })
        labelsym.setOpacity(0.6)  =# semi-transparent pour laisser voir l'ortho
        ranges_def.append(QgsRendererRange(lo, hi, sym,
            f"{int(lo):,}–{int(hi):,} hab.".replace(',', ' ')
        ranges_def.append(QgsRendererRange(lo, hi, sym, label)))
    
    renderer = layer.setRenderer(QgsGraduatedSymbolRenderer('population', ranges_def)
    layer.setRenderer(renderer))
    

    5.4. Étiquettes (nom IRIS + population)ajout dans le bon ordre

    from qgis.core import (
        QgsPalLayerSettings, QgsTextFormat,
        QgsTextBufferSettings, QgsVectorLayerSimpleLabeling
    )QgsVectorLayerSimpleLabeling)
    from qgis.PyQt.QtGui import QColor, QFont
    
    pal = QgsPalLayerSettings()
    pal.fieldName = "concat(nom_iris, '\n', population, ' hab.')"
    pal.isExpression = True
    pal.placement = QgsPalLayerSettings.Placement.OverPoint
    fmt = QgsTextFormat()
    fmt.setFont(QFont("Arial", 8)); fmt.setSize(8); fmt.setColor(QColor('#1a1a1a'))
    buf = QgsTextBufferSettings()
    buf.setEnabled(True); buf.setSize(1); buf.setColor(QColor('white'))
    fmt.setBuffer(buf); pal.setFormat(fmt)
    layer.setLabelsEnabled(True)
    layer.setLabeling(QgsVectorLayerSimpleLabeling(pal))
    
    # IRIS en haut de la pile (index 0), ortho déjà en bas
    QgsProject.instance().addMapLayer(layer, False)
    root = QgsProject.instance().layerTreeRoot()
    root.insertLayer(0, layer)
    

    5. Zoomer sur Mandelieu

    from qgis.core import QgsRectangle, QgsCoordinateReferenceSystem, QgsCoordinateTransform
    
    src_crs = QgsCoordinateReferenceSystem("EPSG:4326")
    dst_crs = QgsCoordinateReferenceSystem("EPSG:3857")
    transform = QgsCoordinateTransform(src_crs, dst_crs, QgsProject.instance())
    pt_min = transform.transform(6.87, 43.51)
    pt_max = transform.transform(6.99, 43.60)
    extent = QgsRectangle(pt_min.x(), pt_min.y(), pt_max.x(), pt_max.y())
    iface.mapCanvas().setExtent(extent)
    iface.mapCanvas().refresh()
    

    Points clés et pièges

    Sujet Constat
    Bug QGIS 4.0.3 — raster + identifyMapTool
    addMapLayer sur une couche raster → access violation si l'outil Identifier est actif. Fix : passer sur Pan + addMapLayer(layer, False) + insertLayer() Ne pas cliquer sur la couche raster dans le panneau Même après ajout réussi, cliquer sur la couche raster dans le panneau des couches déclenche onActiveLayerChanged → crash. Rester sur l'outil Pan. Provider WMTS natif QGIS invalide crs=...&layers=...&url=https://data.geopf.fr/wmts → couche invalide. Fix : encoder en XYZ avec type=xyz&url=...{z}/{y}/{x} Canvas au zoom mondial au démarrage Les tuiles ne se chargent pas. Toujours zoomer sur la zone cible après ajout. Colonnes INSEE avec espace Toutes les colonnes de iris_2025_population ont un espace traînant — utiliser "IRIS ", "COM ", "P22_POP " — espace traînant partout dans iris_2025_population Types text Toutes les valeurs numériques INSEE sont stockées en text — caster avec ::numeric Sous-requête URI QGIS QgsDataSourceUri.setDataSource() sur-échappe les guillemets doubles → couche invalide. ContournementFix : créer une vue PostgreSQL sys.executable QGIS Pointe sur qgis-bin.exe, pas python.exesubprocess/pip inutilisablevia directementsubprocess inutilisable. Fix : pip._internal.cli.main Importsys.path manquantparamiko QGIS n'inclut pas le dossier utilisateur Python. Ajouter QgsVectorLayerSimpleLabelingsys.path.insert(0, ...) doità êtrechaque importé explicitement depuis qgis.coresession

    Généralisation à d'autres communes

    Remplacer

    code_insee = '06079'  # remplacer par le code INSEE cible
    dans la vue :
    
    cur.execute(f"""
        CREATE OR REPLACE VIEW insee_raw.v_iris_pop AS
        SELECT c.fid, c.geom, c.nom_iris, c.code_iris, c.nom_commune,
               ROUND(p."P22_POP "::numeric, 0)::integer AS population
        FROM insee_raw.contours_iris_2025 c
        LEFT JOIN insee_raw.iris_2025_population p ON p."IRIS " = c.code_iris
        WHERE c.code_insee = '{code_insee}'
    """)