Skip to main content

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").

Example report map showing color-coded solar utilization and LEG groupings for a Swiss municipality

For a given municipality, the tool:

  1. Extracts addresses, existing PV installations, and roof solar-potential data from national datasets.
  2. 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.
  3. Analyzes the data: splits roof potential across properties, attributes installed PV plants to specific addresses, and computes utilization (installed yield vs. potential) per address.
  4. 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 companion data/ 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 --infographic mode, 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-generates municipalities.csv from (the master list of municipality name/canton/ postcode/BFS-number combinations used to resolve the municipality argument 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_SOURCES marks it manual_extract) since its compression isn't one zipfile can handle automatically — download will 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 enrich stage; 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 administrative COM_NAME/COM_CANTON fields, 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"). extract expands 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_PROVIDERS entry 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: enrich prints a warning and skips LEG discovery entirely; analyze/report still 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) — download will 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

swiss_leg_analyzer-0.1.0.tar.gz (50.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

swiss_leg_analyzer-0.1.0-py3-none-any.whl (62.5 kB view details)

Uploaded Python 3

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

Hashes for swiss_leg_analyzer-0.1.0.tar.gz
Algorithm Hash digest
SHA256 935f6fe3517925613665059b5b75d2bd3a95261c11ec28519b4efd50fac7606f
MD5 0b1bc2962418f387569ac28b2da60457
BLAKE2b-256 7bd240c3a8f0179542c14d0c4c13804280f6fd830f6350fc0c40188c659dbea4

See more details on using hashes here.

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

Hashes for swiss_leg_analyzer-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 66f85470f40d3ac904c3d7e2ce4880695cfcc35a34dd34843a47a9f114beaa63
MD5 a43c480cc920d6d156959f8b8ce982a8
BLAKE2b-256 ff4ed510873c316addbbe9ad2f266857bf8a39d49417d2f0fd26f25042862508

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page