Skip to content

S6: Price-per-m² Map — Versailles (DVF)

Intermediate CLI + Map

Capabilities: filter calculate classify grid_create spatial_aggregate

Use Case

A real-estate analyst wants to visualize the spatial structure of price per square meter in Versailles. We start from DVF mutations (Demandes de Valeurs Foncières, Etalab open data), filter to residential sales, compute price_per_m2 = valeur_fonciere / surface_reelle_bati, drop outliers (parking spots, garages, data-entry mistakes), then classify the remaining transactions into quintiles with a YlOrRd palette (pale yellow → dark red, ColorBrewer 5 classes).

We then lay a regular 100 m × 100 m fishnet over the sales extent (grid_create, Lambert93 EPSG:2154), aggregate each tile's mean €/m² from the DVF points it contains (spatial_aggregate, predicate contains), and classify the cells into quintiles with the same YlOrRd ramp to paint a clean heatmap — contiguous square tiles that read like a thematic continuous surface, much easier than scattered point dots.

Source: DVF Etalab

SourceContentFeatures (Versailles 2022-2024)Key attributes
geo-dvf EtalabGeolocated real-estate transactions~7,000 raw across 8 communes (Versailles + Le Chesnay-Rocquencourt + Viroflay + Vélizy + Jouy + Buc + Saint-Cyr + Bailly, 2022-2024) → ~5,100 residential after filtervaleur_fonciere, surface_reelle_bati, type_local, nature_mutation, date_mutation

CSV files are published per year + commune: https://files.data.gouv.fr/geo-dvf/latest/csv/{year}/communes/{dept}/{insee}.csv

Versailles = INSEE 78646, département 78.

bash
python examples/prepare_playground_data.py --city versailles
gispulse info examples/datasets/versailles_bdtopo.gpkg --layer dvf_ventes

The script concats 2022+2023+2024, builds Point geometry from longitude/latitude, drops rows without coordinates or prices, then writes the dvf_ventes layer into the GPKG.

Pipeline (8 steps)

dvf_ventes ──► filter (nature_mutation=='Vente' AND type_local in ['Maison','Appartement'])
                │                                                       # residential sales

            calculate → price_per_m2 = valeur_fonciere / surface_reelle_bati


            filter (1500 <= price_per_m2 <= 25000)                      # DVF outlier trim


            classify → price_class (1..5) + price_color (YlOrRd)        # quintiles (points)
                         method: quantile, bins: 5


            grid_create → regular 100 m × 100 m fishnet                  # square tiles
              ref_layer: drop_price_outliers   (envelope of sales)
              cell_size: 100                    (metres)
              crs_meters: EPSG:2154             (Lambert93)
              clip_to_extent: true              (drop tiles outside sales)


            spatial_aggregate                                             # spatial attribute
              ref_layer: drop_price_outliers                              # tile ⊇ DVF points
              predicate: contains
              agg: mean_price_per_m2, max_price_per_m2, tx_count


            filter (tx_count > 0)                                         # keep tiles ≥1 sale


            classify → tile_class (1..5) + tile_color (YlOrRd)            # heatmap choropleth
                         field: mean_price_per_m2, method: quantile, bins: 5

Steps 1–4 (points) — ColorBrewer YlOrRd 5-class palette (#ffffb2, #fecc5c, #fd8d3c, #f03b20, #bd0026) attached per feature in price_color; each quintile holds ~20% of mutations.

Steps 5–8 (tiles)grid_create emits a 100 m × 100 m fishnet in Lambert93 (exact metric) over the filtered-sales extent (~950 non-empty tiles); spatial_aggregate computes mean_price_per_m2, max_price_per_m2, tx_count per tile from the DVF points it contains; empty tiles are dropped; the final classify paints the choropleth as a high-resolution heatmap — fine-grained 100 m cells, easy thematic read, print-ready for QGIS export. Note: at 100 m on the wider S5 extent ~25 % of tiles carry a single transaction (vs ~35 % at the previous 50 m mesh on Versailles centre alone), so quintiles are more statistically stable while still keeping a fine-grained thematic read.

Rules

json
{
  "version": 2,
  "ref_layers": { "dvf_ventes": "dvf_ventes" },
  "steps": [
    {
      "id": "filter_residential_sales",
      "capability": "filter",
      "params": {
        "expression": "nature_mutation == 'Vente' and type_local in ['Maison', 'Appartement']"
      }
    },
    {
      "id": "compute_price_per_m2",
      "capability": "calculate",
      "params": { "expressions": { "price_per_m2": "valeur_fonciere / surface_reelle_bati" } },
      "input": "filter_residential_sales"
    },
    {
      "id": "drop_price_outliers",
      "capability": "filter",
      "params": { "expression": "price_per_m2 >= 1500 and price_per_m2 <= 25000" },
      "input": "compute_price_per_m2"
    },
    {
      "id": "classify_price_quintiles",
      "capability": "classify",
      "params": {
        "field": "price_per_m2",
        "method": "quantile",
        "bins": 5,
        "class_col": "price_class",
        "color_col": "price_color",
        "palette": ["#ffffb2", "#fecc5c", "#fd8d3c", "#f03b20", "#bd0026"]
      },
      "input": "drop_price_outliers"
    },
    {
      "id": "create_price_grid",
      "capability": "grid_create",
      "params": {
        "ref_layer": "drop_price_outliers",
        "cell_size": 100,
        "crs_meters": "EPSG:2154",
        "clip_to_extent": true
      },
      "input": "drop_price_outliers"
    },
    {
      "id": "aggregate_price_to_grid",
      "capability": "spatial_aggregate",
      "params": {
        "ref_layer": "drop_price_outliers",
        "predicate": "contains",
        "agg": {
          "mean_price_per_m2": ["price_per_m2", "mean"],
          "max_price_per_m2": ["price_per_m2", "max"],
          "tx_count": ["price_per_m2", "count"]
        }
      },
      "input": "create_price_grid"
    },
    {
      "id": "keep_cells_with_sales",
      "capability": "filter",
      "params": { "expression": "tx_count > 0" },
      "input": "aggregate_price_to_grid"
    },
    {
      "id": "classify_grid_choropleth",
      "capability": "classify",
      "params": {
        "field": "mean_price_per_m2",
        "method": "quantile",
        "bins": 5,
        "class_col": "tile_class",
        "color_col": "tile_color",
        "palette": ["#ffffb2", "#fecc5c", "#fd8d3c", "#f03b20", "#bd0026"]
      },
      "input": "keep_cells_with_sales"
    }
  ]
}

Execution

bash
gispulse run examples/datasets/versailles_bdtopo.gpkg \
  --layer dvf_ventes \
  --rules playground/scenario-6-rules.json \
  -o output/versailles_price_map.gpkg

gispulse serve output/versailles_price_map.gpkg

Expected Result

Output schema — point layer (step 4)
ColumnTypeSourceDescription
geometryPointsourceDVF parcel centroid
date_mutationdatesourceSale date
nature_mutationstringsource"Vente" after filter
type_localstringsource"Maison" or "Appartement"
valeur_foncierefloatsourceSale price (€)
surface_reelle_batifloatsourceBuilt surface (m²)
price_per_m2floatstep 2 (calculate)Price per square meter
price_classintstep 4 (classify)Quintile 1..5
price_colorstringstep 4 (classify)Hex color (YlOrRd palette)
Output schema — 50 m tile choropleth (step 8)
ColumnTypeSourceDescription
geometryPolygonstep 5 (grid_create)100 m × 100 m square tile in Lambert93
rowintstep 5 (grid_create)Fishnet row index
colintstep 5 (grid_create)Fishnet column index
mean_price_per_m2floatstep 6 (spatial_aggregate)Mean €/m² of contained DVF points
max_price_per_m2floatstep 6 (spatial_aggregate)Max €/m² observed in this tile
tx_countintstep 6 (spatial_aggregate)Number of mutations in tile
tile_classintstep 8 (classify)Quintile 1..5 of mean price
tile_colorstringstep 8 (classify)Hex color of the choropleth

Versailles 2022-2024 quintile edges (typical, after outlier trim)

  • Q1 (< ~€5,200/m²): pale yellow #ffffb2 — peripheral segments, atypical units
  • Q2 (€5,200 → €6,400/m²): light orange #fecc5c
  • Q3 (€6,400 → €7,300/m²): orange #fd8d3c — market median
  • Q4 (€7,300 → €8,500/m²): red-orange #f03b20
  • Q5 (> ~€8,500/m²): dark red #bd0026 — Notre-Dame, Château district

Quintile edges are recomputed dynamically: bins shift if you change the period or the spatial filter.

Full interactive playground

Live 8-step pipeline (requires demo backend).

Points (DVF) — per-mutation gradient

  1. filter_residential_sales (orange) — keep only Maison / Appartement sales
  2. compute_price_per_m2 (cyan) — ratio valeur_fonciere / surface_reelle_bati
  3. drop_price_outliers (orange) — 1500 ≤ price/m² ≤ 25000 €
  4. classify_price_quintiles (red) — quintiles + YlOrRd palette → color gradient on points

Choropleth (tiles) — 50 m heatmap

  1. create_price_grid (teal) — 100 m × 100 m fishnet in Lambert93 clipped to the DVF extent (~950 non-empty tiles)
  2. aggregate_price_to_grid (purple) — spatial_aggregate: per tile, mean price_per_m2 of contained DVF points (+ max, + count)
  3. keep_cells_with_sales (orange) — drop empty tiles (tx_count > 0)
  4. classify_grid_choropleth (red) — quintiles on mean_price_per_m2 + YlOrRdheatmap choropleth

DVF popup: date, type_local, valeur_fonciere, surface_reelle_bati, price_per_m2, price_class. Tile popup: row, col, mean_price_per_m2, max_price_per_m2, tx_count, tile_class. Legend: each quintile ~20% (points, then tiles), same palette, continuous thematic read.

Try it live

Live Demolists available capabilities (filter, calculate, classify) used by the S6 pipeline
GET/capabilities
Live Demolists demo datasets, including versailles_bdtopo with the dvf_ventes layer loaded for this scenario
GET/datasets

Next steps

Published under AGPL-3.0 license.