Skip to main content

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.

Metadata

Release files for swiss-leg-analyzer 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for swiss-leg-analyzer 0.1.0
File Size Uploaded Provenance Actions
swiss_leg_analyzer-0.1.0.tar.gz 50.5 kB Without provenance Details for swiss_leg_analyzer-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for swiss-leg-analyzer 0.1.0
File Interpreter ABI Platform Actions
swiss_leg_analyzer-0.1.0-py3-none-any.whl Python 3 none any Details for swiss_leg_analyzer-0.1.0-py3-none-any.whl

Total release size: 113.0 kB

Release files / swiss_leg_analyzer-0.1.0.tar.gz

Download URL swiss_leg_analyzer-0.1.0.tar.gz
Size 50.5 kB
Tags Source
SHA-256 checksum
How to use checksums
935f6fe3517925613665059b5b75d2bd3a95261c11ec28519b4efd50fac7606f
BLAKE2b-256 checksum
How to use checksums
7bd240c3a8f0179542c14d0c4c13804280f6fd830f6350fc0c40188c659dbea4
Upload date
Uploaded using Trusted Publishing?
What is 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}

Release files / swiss_leg_analyzer-0.1.0-py3-none-any.whl

Download URL swiss_leg_analyzer-0.1.0-py3-none-any.whl
Size 62.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
66f85470f40d3ac904c3d7e2ce4880695cfcc35a34dd34843a47a9f114beaa63
BLAKE2b-256 checksum
How to use checksums
ff4ed510873c316addbbe9ad2f266857bf8a39d49417d2f0fd26f25042862508
Upload date
Uploaded using Trusted Publishing?
What is 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}

Security for swiss-leg-analyzer 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page