Metadata-Version: 2.3
Name: swiss-leg-analyzer
Version: 0.1.0
Summary: Generate interactive solar potential maps with LEG overlays for Swiss municipalities
Author: Manuel Stocker
Author-email: Manuel Stocker <mensi@mensi.ch>
License: MIT
Requires-Dist: fiona>=1.10.1
Requires-Dist: geopandas>=1.0.1
Requires-Dist: jinja2>=3.1.6
Requires-Dist: pandas>=2.3.3
Requires-Dist: questionary>=2.1.1
Requires-Dist: requests>=2.32.5
Requires-Dist: rich>=14.3.0
Requires-Dist: scipy>=1.13.1
Requires-Dist: shapely>=2.0.7
Requires-Dist: typer>=0.21.1
Requires-Python: >=3.9
Project-URL: Repository, https://github.com/mensi/swiss-leg-analyzer
Description-Content-Type: text/markdown

# 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](example.png)

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

```bash
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](https://docs.astral.sh/uv/):

```bash
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

```bash
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](#data-sources) below for what
gets downloaded and where.

### 2. Run the pipeline

```bash
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:

```bash
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](#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/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](#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](#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

```bash
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](LICENSE).
