A geospatial analysis of a retail chain's customers that answers the question every multi-site business eventually asks: "Where are my customers, and where should the next store go?" — mapped store catchment areas, quantified how local the business really is, and a gravity (Huff-style) model that ranks candidate cities for expansion.
Objectif : from customer + sales data, map each store's catchment area, measure how much revenue is truly local, and recommend the location of the 5th store.
Geography is real, sales are synthetic but realistic — and 100 % reproducible (fixed seed).
| Source | What | Detail |
|---|---|---|
Real — GeoNames (geonamescache) |
692 French cities | name, population, latitude, longitude |
| Synthetic — spatial gravity model | 12,000 customers · 78,221 orders · €2.93M | demand drawn ∝ population × (0.12 + e^(−dist/18km)) |
A fictional chain, Comptoir & Co, runs 4 stores: Paris, Lyon, Marseille, Lille. Each customer is anchored on a real city; each order carries a basket amount (log-normal, ~€38 median). Because demand is generated with a distance-decay rule, the customer map behaves like a real physical retailer — and the analysis then has to recover that structure from the data.
Why synthetic? There is no open French customer-level dataset at postal-code granularity. Anchoring simulated sales on real geography keeps the maps credible and the method identical to a real engagement.
- A hyper-concentrated footprint. The median customer lives just 7.9 km from their nearest store.
- Paris dominates. Customers whose nearest store is Paris generate 65 % of revenue (Marseille 15 %, Lyon 13 %, Lille 6 %).
- Demand tracks the shopfronts. Colour each customer by nearest store and the four stores light up as dense clusters — with large empty stretches in the West and South-West.
Sort customers by distance to their nearest store, accumulate revenue:
| Within radius | Share of revenue |
|---|---|
| ≤ 10 km | 61.7 % |
| ≤ 20 km | 80.8 % |
| ≤ 30 km | 86.5 % |
| ≤ 100 km | 91.0 % |
80 % of all revenue comes from customers living within 19 km of a store. This is a local business: a store's power is its immediate surroundings, and beyond ~30 km the brand barely exists.
For every city we compute an underserved potential = population × (1 − access),
where access = e^(−distance/18km) is the pull of the nearest store. Then a
Huff-style gravity model scores each candidate city by the demand a new store would
capture — crucially counting only the incremental reach (max(0, new_pull − current_access)), so it never double-counts demand already served by an existing store
(no cannibalisation).
| Rank | Candidate | Population | Extra revenue / yr | People within 40 km |
|---|---|---|---|---|
| 🥇 | Toulouse | 511,684 | +€111,701 | 670,194 |
| 🥈 | Bordeaux | 265,328 | +€104,819 | 709,684 |
| 🥉 | Nice | 342,669 | +€102,121 | 890,316 |
| 4 | Nantes | 325,070 | +€91,458 | 561,540 |
| 5 | Strasbourg | 274,845 | +€66,455 | 407,834 |
Recommendation: open store #5 in Toulouse. The whole South-West is the blind spot — Toulouse and Bordeaux, France's 4th and 9th largest cities, both sit 300+ km from the nearest existing store. Toulouse alone represents ~€112k/yr of currently-unserved demand.
You don't grow a local business by squeezing the zones you already own — 80 % of the revenue is already locked inside a 19 km ring around each store. You grow it by planting a flag where the demand exists and you're simply absent. A map turns "we should expand someday" into "open in Toulouse, here's the number."
.
├── data.py # real cities (GeoNames) + synthetic sales (gravity model)
├── common.py # palette, data loading, France backdrop, haversine
├── analyse.py # EDA + customer map -> carte_clients.png, eda_ventes.png
├── chalandise.py # catchment radius + Huff model -> distance_revenu.png, zones_blanches.png
├── data/ # generated CSVs (not versioned)
├── requirements.txt
└── README.md
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1
pip install -r requirements.txt
python data.py # build the dataset (seeded, reproducible)
python analyse.py # EDA + customer map
python chalandise.py # catchment analysis + next-store recommendation- Drive-time isochrones (road network) instead of straight-line distance.
- Cannibalisation check: revenue an existing store loses when a neighbour opens.
- Enrich with INSEE income / spend data to weight demand by purchasing power.
- An interactive Folium map for exploration.
Python · pandas · numpy · matplotlib · geonamescache
Geography from the open GeoNames database. Sales are synthetic (seeded) and anchored on real cities — same method, real or simulated data.



