Generate interactive solar potential maps with LEG overlays for Swiss municipalities
Project description
swiss-leg-analyzer
Generate interactive HTML maps of solar potential for Swiss municipalities, showing which roofs already have PV installed, which are still untapped, and how buildings can be grouped into LEGs ("Lokale Elektrizitätsgemeinschaft").
For a given municipality, the tool:
- Extracts addresses, existing PV installations, and roof solar-potential data from national datasets.
- Enriches that data by querying cantonal/utility APIs to discover which buildings belong to the same net level and can form an LEG. Currently these are implemented for EKZ and WWZ.
- Analyzes the data: splits roof potential across properties, attributes installed PV plants to specific addresses, and computes utilization (installed yield vs. potential) per address.
- Renders an interactive Leaflet-based HTML report with a color-coded map, LEG outlines, and per-LEG CSV exports on top of swisstopo vector tiles.
Installation
pip install swiss-leg-analyzer
This installs the swiss-leg-analyzer command-line tool.
To work on the tool itself, clone the repo and use uv:
git clone https://github.com/mensi/swiss-leg-analyzer.git
cd swiss-leg-analyzer
uv sync
uv run swiss-leg-analyzer --help
Usage
The tool operates on a data directory (input datasets, default data/)
and an output directory (generated per-municipality artifacts, default
output/). Both can be overridden globally with --data-dir / --output-dir.
A municipality can be identified in several ways: by name ("Bonstetten"),
name + canton ("Rüti ZH") when the name is ambiguous, postcode + name
("8106 Adlikon"), or by BFS number (the official Swiss municipality ID,
e.g. "2"). If a name matches multiple municipalities, the tool lists the
candidates so you can add a canton or postcode to disambiguate.
1. Download the datasets
uv run swiss-leg-analyzer download "Bonstetten"
Interactively checks the national datasets (building addresses, solar potential, installed PV plants) for updates, and — if a cantonal AV (property boundary) provider is registered for the municipality's canton — offers to fetch that too. See Data sources below for what gets downloaded and where.
2. Run the pipeline
uv run swiss-leg-analyzer pipeline "Bonstetten"
Runs all four stages end to end. They can also be run individually (each depends on the output of the previous one), which is useful while debugging a specific stage without re-running the (slower) earlier ones:
uv run swiss-leg-analyzer extract "Bonstetten" # -> output/bonstetten/{addresses.csv,plants.csv,solar.gpkg,properties.gpkg}
uv run swiss-leg-analyzer enrich "Bonstetten" # -> output/bonstetten/legs.json
uv run swiss-leg-analyzer analyze "Bonstetten" # -> output/bonstetten/{bonstetten_report.csv,data/addresses.json}
uv run swiss-leg-analyzer report "Bonstetten" # -> output/bonstetten/bonstetten_report.html
report (and pipeline) accept four flags:
--infographic— renders a clean map without symbols, tooltips or the legend, suitable for embedding in a presentation graphic.--singlefile— embeds all data directly in the HTML file instead of writing it to a companiondata/directory, producing a single self-contained file you can share by itself.--no-labels— hides all text labels (street names, town names, etc.) from the map base layer, leaving only road/building/other geometry visible. Can be combined with--infographic.--no-highlights— hides the ⭐/🌙 highlight symbols marking the top 5 installed and top 5 untapped-potential addresses (and the corresponding legend entry). Has no effect in--infographicmode, since that mode already omits all point markers.
The enrich stage discovers LEGs by querying an external API once per new
address (skipping addresses already covered by a previously discovered LEG),
so it can be slow and is capped by a max_requests limit
(EnrichmentPipeline.run, default 120) to bound how many API calls a single
run makes. Re-running enrich resumes from where it left off — already
discovered addresses are cached in legs.json and skipped.
Naming LEGs
LEGs are auto-named from their most common street name (with a secondary
street or numeric suffix appended to disambiguate collisions). If the
swissNAMES3D dataset is present (see Data sources), a LEG is
instead named after a local locality/neighborhood polygon (e.g. Neugasse,
Winzrüti) when one covers its addresses at least as well as its dominant
street does — this often reads better than a street name for a LEG that spans
several streets. Collision disambiguation (secondary street / numeric suffix)
applies the same way regardless of whether a LEG's primary name came from a
street or a swissNAMES3D locality. Without the dataset, naming silently falls
back to street names only. To give a LEG a proper name instead of either,
add a JSON override file — see
data/leg_overrides/README.md.
Data sources
Two datasets are national and shared across all municipalities (see
GLOBAL_SOURCES in src/swiss_leg_analyzer/pipeline/downloader.py for URLs,
and data/README.md for field-level peculiarities):
- Building address register (
amtliches-gebaeudeadressverzeichnis_ch_2056.csv, swisstopo) — every official building address in Switzerland, in LV95 (EPSG:2056) coordinates. This is also the source the tool auto-generatesmunicipalities.csvfrom (the master list of municipality name/canton/ postcode/BFS-number combinations used to resolve themunicipalityargument on every command). - Solar roof potential (
SOLKAT_DACH.gpkg, BFE/opendata.swiss) — modeled annual solar yield per roof polygon, nationwide. This file must be extracted manually after download (GLOBAL_SOURCESmarks itmanual_extract) since its compression isn't onezipfilecan handle automatically —downloadwill tell you when this is needed. - Installed PV plants (
ch.bfe.elektrizitaetsproduktionsanlagen/, BFE) — every registered electricity production plant in Switzerland, including solar. Used to determine which roofs already have PV installed and since when. - swissNAMES3D (
swissnames3d_2026_2056.gpkg, swisstopo) — geographical names of Switzerland, including locality/neighborhood polygons (Ort,Ortsteil,Quartier,Quartierteil,Gebiet). Optional: used only to improve auto-generated LEG names (see Naming LEGs); a large (~450 MB) file, so it isn't fetched by default in the download checklist — select it explicitly if you want the naming enhancement.
Two further datasets are municipality/canton-specific:
- Property boundaries ("Amtliche Vermessung" / AV, per canton) — used to split a roof's potential across the individual properties/addresses under it, where a canton provider is available (see Supported providers). Optional: without it, a roof's potential is still attributed to the addresses within it, just without splitting the polygon along property lines first.
- LEG membership — discovered per-address via a cantonal/utility API by
the
enrichstage; not a bulk downloadable dataset.
How the datasets are joined
Everything is keyed off the address register for a given run:
extract filters it down to the rows matching the target municipality
(canton + name), then uses those addresses' coordinates to compute a bounding
box, which is used to clip the (nationwide) solar-potential and property
files down to just that area.
From there, joining is a mix of spatial and string matching:
- Roofs → addresses: a GIS "point-in-polygon" join (each address point falls within zero or more roof polygons). If potential could not be attributed to any address (no property data to split it further), it's divided evenly across every address under a shared roof; if property data did split it, potential is attributed to whichever address is on the matching parcel.
- PV plants → addresses: plants aren't a spatial dataset with reliable
coordinates, so matching is address-string based first (see
utils/normalization.py), falling back to nearest-neighbor coordinate matching (pipeline/attribution.py, via a KD-tree) when a plant's registered address doesn't resolve cleanly — this handles farms/barns where the PV array sits on a secondary building but the plant's official address is still the main house. - LEG membership → addresses: normalized address strings returned by the provider API are matched against the same normalized addresses used everywhere else.
Because Swiss address data comes from multiple sources with independent
spelling conventions, a single normalization scheme
(normalize_address / normalize_address_to_main / normalize_plant_address
in utils/normalization.py) is used everywhere two datasets need to agree on
what "the same address" means — e.g. expanding "Bahnhofstr." to
"bahnhofstrasse", or collapsing "10a"/"10.1" down to base number "10" for
grouping purposes.
Common issues
- Ambiguous municipality names: several municipalities share a name across cantons (e.g. Rüti ZH vs. Rüti GL). The tool will list all matches and ask you to add a canton or postcode.
- Postcode/municipality mismatch: an address's postcode-derived town can
differ from its administrative municipality near cantonal borders (see the
Rüti/Rapperswil-Jona example in
data/README.md). Extraction filters by the administrativeCOM_NAME/COM_CANTONfields, not the postcode label, so this is usually transparent — but it's worth knowing about if an address you expect seems to be missing. - Combined plant addresses: the PV plant registry sometimes lists two
buildings under one row (e.g.
"Schachenstrasse 10+12").extractexpands these into separate rows, splitting the installed power evenly — this is a heuristic and can occasionally split power incorrectly if the combined addresses aren't actually adjacent numbers on the same street. - Missing property/AV data for a canton: if no
AV_PROVIDERSentry exists for a canton (see below), roof-splitting-by-property is skipped and a warning is printed; the rest of the pipeline still works, potential is just less precisely attributed among addresses sharing a roof. - No LEG provider for a canton:
enrichprints a warning and skips LEG discovery entirely;analyze/reportstill work, addresses are just not colored/grouped by LEG. - SOLKAT_DACH.gpkg extraction: this file needs to be
extracted from its ZIP by hand (e.g. with 7-Zip) —
downloadwill pause and tell you to do this rather than failing silently.
Supported providers
Two kinds of external providers are pluggable, one per canton each:
LEG providers (swiss_leg_analyzer/providers/, registered in
factory.LEG_PROVIDERS) discover which addresses belong to the same local
electricity community:
| Canton | Provider | Notes |
|---|---|---|
| ZH | EKZProvider |
Queries the EKZ ZEV map API for its cities/municipalities reference data once, then caches it as ekz_cities.json / ekz_municipalities.json in the data directory for subsequent runs. |
| ZG | WWZProvider |
Queries the WWZ (Wasserwerke Zug) transformer-zone API; caches results per transformer so buildings sharing one only trigger one extra API call. |
AV (property boundary) providers (registered in factory.AV_PROVIDERS)
download cantonal property/parcel data:
| Canton | Provider | Notes |
|---|---|---|
| ZH | ZurichProvider |
Places an async export order against the cantonal geoservices API and polls until it's ready — this can take a while, so the order state is persisted to disk and resumed on a later download run rather than resubmitted. |
Adding a new provider
To add LEG support for another canton, implement LEGProvider.fetch_leg()
(see providers/base.py for the interface, providers/wwz.py for a
minimal example) and add an entry to LEG_PROVIDERS in
providers/factory.py keyed by the two-letter canton code. To add AV/property
data support, implement BaseCantonalProvider instead and register it in
AV_PROVIDERS. No changes are needed elsewhere — the pipeline looks up
whichever provider is registered for a municipality's canton and skips the
corresponding stage (with a warning) if none is found.
Testing
uv run pytest
License
This codebase was primarily generated with AI tools and thus likely not copyrightable in most jurisdictions. Otherwise, where applicable, the license is MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file swiss_leg_analyzer-0.1.0.tar.gz.
File metadata
- Download URL: swiss_leg_analyzer-0.1.0.tar.gz
- Upload date:
- Size: 50.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.17 {"installer":{"name":"uv","version":"0.9.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
935f6fe3517925613665059b5b75d2bd3a95261c11ec28519b4efd50fac7606f
|
|
| MD5 |
0b1bc2962418f387569ac28b2da60457
|
|
| BLAKE2b-256 |
7bd240c3a8f0179542c14d0c4c13804280f6fd830f6350fc0c40188c659dbea4
|
File details
Details for the file swiss_leg_analyzer-0.1.0-py3-none-any.whl.
File metadata
- Download URL: swiss_leg_analyzer-0.1.0-py3-none-any.whl
- Upload date:
- Size: 62.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.17 {"installer":{"name":"uv","version":"0.9.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
66f85470f40d3ac904c3d7e2ce4880695cfcc35a34dd34843a47a9f114beaa63
|
|
| MD5 |
a43c480cc920d6d156959f8b8ce982a8
|
|
| BLAKE2b-256 |
ff4ed510873c316addbbe9ad2f266857bf8a39d49417d2f0fd26f25042862508
|