Marktstammdatenplotter
Animated choropleth maps of installed wind & solar capacity in Germany, driven by data scraped from the public Marktstammdatenregister (MaStR).
What it does
The MaStR registry contains every grid-connected electricity-generating unit in Germany — millions of solar panels, tens of thousands of wind turbines, plus hydro, biomass, gas and more. Each record carries an install date, capacity in kW, geographic coordinates, and a pile of enum-encoded metadata.
This repo:
- scrapes the MaStR public JSON API,
- decodes the enum-heavy rows into a clean
PowerPlantdataclass (parser.py), - joins turbines to German county polygons extracted from OSM,
- renders one choropleth PNG per month from 2000 to today, and
- assembles the frames into an animated GIF with
ffmpeg.
Repository layout
| Path | Purpose |
|---|---|
parser.py | PowerPlant dataclass + JSON-to-record decoder |
wind.ipynb | End-to-end notebook: load → join → plot → save frames |
fig/ | Rendered PNG/GIF outputs (gitignored) plus pipeline SVGs |
docs/ | This site (served from GitHub Pages) |
README.md | User-facing quickstart |
CLAUDE.md | Conventions for Claude Code agents |
Pipeline overview
Three external sources flow into a single rendering loop. Cached
artifacts (data-*.json, germany_kreise.gpkg) sit between scrape
and analyze so the slow steps are run-once.
Module architecture
Scraping MaStR
The registry offers a full XML export, but the API filters server-side and returns JSON instead of XML, so this repo scrapes that instead:
seq 7 | xargs -P 4 -I{} curl --get \
'https://www.marktstammdatenregister.de/MaStR/Einheit/EinheitJson/GetErweiterteOeffentlicheEinheitStromerzeugung' \
--data-urlencode 'sort=' \
--data-urlencode 'page={}' \
--data-urlencode 'pageSize=25000' \
--data-urlencode 'group=' \
--data-urlencode 'filter=Energieträger~neq~\'2495\'~and~Energieträger~neq~\'2496\'' \
--data-urlencode 'forExport=true' -o data-{}.json
OSM boundaries
County boundaries come from a Germany OSM extract (e.g. geofabrik.de). Filter to admin levels 4 (state) and 6 (Kreis), then export to GeoPackage:
osmfilter germany-latest.o5m \
--keep-nodes="boundary=administrative and ( admin_level=6 or admin_level=4 )" \
--keep-ways="boundary=administrative and ( admin_level=6 or admin_level=4 )" \
--keep-relations="boundary=administrative and ( admin_level=6 or admin_level=4 )" \
--drop-version --drop-author \
-o=germany_admin_levels_4_6.osm
ogr2ogr -f GPKG germany_kreise.gpkg germany_admin_levels_4_6.osm \
-sql "SELECT name, admin_level, boundary FROM multipolygons \
WHERE boundary = 'administrative' \
AND (admin_level = '6' \
OR (admin_level = '4' AND name IN ('Berlin','Hamburg','Bremen')))" \
-nlt MULTIPOLYGON -overwrite -nln multipolygons
Kreise are admin_level=6. The three city-states
(Berlin, Hamburg, Bremen) sit at admin_level=4 and get
pulled in by name.
Parser & enums
MaStR fields are numeric enum codes. parser.py decodes the
six enums that matter for plotting, plus the .NET-style
/Date(ms)/ timestamps.
Unknown codes resolve to None rather than raising — the
registry adds codes over time, so the decoder must tolerate them.
Notebook flow
wind.ipynb loads cached JSON, builds a GeoDataFrame of
turbines, joins to germany_kreise.gpkg, aggregates by
county, and writes one PNG per month from 2000-01-01 to today.
Bins are pre-computed once on a representative date so the legend stays stable across all frames — otherwise the same color would mean different megawatts in January 2005 versus May 2026.
Animation
PNGs land in fig/ with names like
wind-2007-04.png. ffmpeg insists on
frame%03d.png, so the assembly script renames frames in a
tmpdir, repeats the final frame ~120 times for a 4-second hold, and
runs palettegen + paletteuse for a clean
palette:
set -l file wind
set -l frames_to_repeat 120
mktemp -d | read -l temp_dir
and cp "$file"-*.png $temp_dir
and begin
set -l i 1
set -l last_frame_path ""
for f in (ls "$temp_dir/$file"*.png | sort)
mv $f (printf "%s/frame%03d.png" $temp_dir $i)
set last_frame_path (printf "%s/frame%03d.png" $temp_dir $i)
set i (math $i + 1)
end
set -l current_duplicate_index $i
for j in (seq 1 $frames_to_repeat)
cp "$last_frame_path" (printf "%s/frame%03d.png" $temp_dir $current_duplicate_index)
set current_duplicate_index (math $current_duplicate_index + 1)
end
end
and ffmpeg -framerate 30 -i "$temp_dir/frame%03d.png" \
-vf "scale=-1:1200:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse=dither=none" \
-loop 0 -y "$file".gif
and rm -rf "$temp_dir"
Interactive notebooks
Two marimo reactive notebooks ship with
this repo. They render directly to standalone HTML, so the published
docs site below contains a fully self-contained interactive map for
each technology. Edit the source .py files locally to add
your own controls.
| Notebook | Source | Live HTML | Controls |
|---|---|---|---|
| PV explorer | pv.py |
pv.html | date · installation type · bin count · colormap |
| Wind explorer | wind.py |
wind.html | date · onshore/offshore · bin count · colormap |
Run locally
python -m marimo edit pv.py # reactive editor
python -m marimo edit wind.py
python -m marimo run pv.py # read-only app
python -m marimo export html pv.py -o docs/pv.html
data-*.json or germany_kreise.gpkg are
present, the notebooks fall back to a synthetic demo dataset (~4 600
plants on a 6×6 grid over Germany) so they still render. A banner at the
top of each notebook tells you which mode is active.
Sample renders (real MaStR data)
Generated from the open-MaStR Zenodo full registry dump (cutoff 2025-02-09) merged with the MaStR API delta through May 2026. Wind: full registry (~42 k turbines, every Kreis covered). PV: full registry (~4.86 M plants, including all rooftop and balcony solar — no power threshold). BESS: top 200 k storage units by installed power.
Per-unit size-bin distribution
Same size-bin axis as the BESS chart above. Wind sits almost entirely in the 1-10 MW bin (75 GW of the 79.7 GW total). PV is spread across 10 kW – 100 MW — utility ground-mount (1-10 MW + 10-100 MW) carries roughly equal weight, with ≥ 100 MW reserved for a handful of mega-parks.
Installed capacity by Bundesland
21 years of wind growth + YTD 2026
21 years of PV growth + YTD 2026
Energy-type mix
National installed capacity per Energietraeger,
snapshot 2026-05-01. Numbers reflect the open-MaStR Zenodo full
registry merged with the API delta through May 2026 — Wind, PV,
and storage all use the complete registry, no power threshold.
Build-out per Bundesland
Monthly cumulative wind + PV per state, 2000 → May 2026. Bayern (22 GW), Niedersachsen (19 GW) and Brandenburg (18 GW) lead. Sharp inflection around 2010 = PV boom; the post-2020 wedge is mostly utility-scale solar in the former-lignite belt.
PV by installation type
Which sub-type of plant carries the GW? Top 200 k PV plants split by
ArtDerSolaranlageId. Free-standing utility parks (16 k
plants) hold ~ 43 GW; building-mounted commercial rooftops (184 k
plants) hold ~ 30 GW. The residential rooftop and balcony long tail
under 49 kW is excluded — that's where the count is, but not the GW.
PV orientation
Capacity-weighted distribution of panel facing + tilt across the top 200 k PV plants. South-facing dominates at ~ 62 % of installed capacity; east-west flat-mount (a utility-park staple) is the strong second at 14 %. Trackers stay rare — only 0.5 GW of the captured fleet. Most plants tilt 0–19° (flat, utility-park footprints).
Wind fleet age + repowering signal
Top panel: installed MW per commission year (blue) vs. decommissioned MW per removal year (red, plotted below the axis). The decommission wave kicks in around 2019 as the first big EEG cohort reaches its 20-year payback boundary — ~ 0.6 → 0.75 GW removed each year since 2023.
Bottom panel: mean per-turbine rotor MW by commission year. The fleet has upsized from ~ 0.5 MW per turbine in the late 90s to ~ 7 MW in 2026 — a 15× growth at the unit level, driven by the new offshore 15 MW class.
Battery storage (BESS)
The German electricity registry tracks storage units under
Energieträger=2496 (Speicher) — a separate slice from
generation. The numbers below cover batteries only
(Stromspeichertechnologie = "Batterie"). Pumped-hydro
(PSH) is reported in its own section below.
Scraped the top 200 000 units by Bruttoleistung;
that captures everything from grid-scale Li-ion projects down to
~10 kW home batteries.
Snapshot 2026-05-01 (batteries only, active): 193 599 units · 6.49 GW · 9.4 GWh.
HSS / CSS / LSS — three-sector view
Heimspeicher (HSS, < 30 kWh) — residential PV+battery, 5-15 kWh LFP units typical. Gewerblicher Speicher (CSS, 30 kWh – 1 MWh) — C&I PV+battery, UPS, peak-shaving. Großspeicher (LSS, ≥ 1 MWh) — grid-services + standalone utility BESS + Pumpspeicher.
Battery sub-sector breakdown (active, 2026-05-01):
- HSS < 30 kWh: 176 411 units · 2.47 GW · 2.54 GWh
- CSS 30 kWh – 1 MWh: 16 655 units · 0.68 GW · 1.20 GWh
- LSS ≥ 1 MWh: 526 units · 3.34 GW · 5.65 GWh
Pipeline: another ~ 8 GW of LSS Li-ion under construction or permitted (LEAG GigaBattery Jänschwalde 1 GW, Boxberg 400 MW, ECO POWER 1–6 = 6 × 300 MW, Westfalen 538 MW…). Combined with PSH the German storage stack reaches ~ 25 GW by ~ 2028.
Size-bin breakdown — GW + GWh per per-unit size class
Battery-only view. Residential (10-100 kW) carries ~ 192 k units but only 2.9 GW / 3.5 GWh. The MW-scale grid-services bins (10-100 MW + 1-10 MW) carry most of the GW + GWh.
BESS power per Kreis
BESS energy per Kreis
Duration distribution
Cumulative growth
GWh trajectory by sector — historical + planned pipeline
9 years of battery storage growth (2017 → May 2026)
Pumped-hydro storage (PSH)
PSH is reported separately from batteries everywhere serious (battery-charts.de, BVES, EASE, EU SET-Plan) because it's a fundamentally different technology — multi-decade lifecycle, multi-hour duration, mechanical-hydro chemistry instead of electrochemical. Lumping it with Li-ion BESS hides both stories.
Snapshot 2026-05-01 (active): 41 sites · 6.48 GW · 927.5 GWh. Median site duration ~ 8 h — orders of magnitude longer than the 1-h Li-ion median. Goldisthal alone (4 PSS units × 265 MW each / 9.64 GWh each) accounts for 1.06 GW + 38.5 GWh.
PSH energy per Kreis
Distribution + duration
Top sites
The "other electricity storage" tail (Wasserstoffspeicher 26 units, Druckluft 10, Schwungrad 1) sums to 0.0 GW / 0.006 GWh combined — pilot installations, not yet material.
Capacity density (MW/km²)
Absolute capacity makes big Kreise look impressive even when their per-km² intensity is low. Normalising by Kreis area reshuffles the ranking: Dithmarschen and Nordfriesland top wind at 1.1–1.6 MW/km²; the city-Kreise of Straubing, Amberg and Memmingen top PV because they host utility parks on small footprints.
Largest individual plants
Top 10 by single-unit capacity (Bruttoleistung) in this
scrape, snapshot 2026-05-01. Wind list shows the new
Nordseecluster + Iberdrola Windanker 15 MW turbines —
all offshore, so Kreis is empty. PV list captures every solar park
≥ 100 MW: Schafhöfen (200 MW, Bayern), SILUX 1/2/4 +
EnBW Gottesgabe + SunInvest all clustered in the
former-lignite belt around Leipzig + the Oder-Spree border.
Top 10 PV plants
| MW | Owner / project | Kreis | Bundesland | Installed |
|---|---|---|---|---|
| 200.0 | BEE Solarpark Schafhöfen GmbH & Co. KG | Regensburg | Bayern | — |
| 162.3 | SILUX Solarpark 1 GmbH & Co. KG | Leipziger Land | Sachsen | 2024 |
| 153.1 | EnBW Solarpark Gottesgabe GmbH | Märkisch-Oderland | Brandenburg | 2022 |
| 151.0 | EnBW SunInvest GmbH & Co. KG | Märkisch-Oderland | Brandenburg | 2022 |
| 144.8 | SILUX Solarpark 4 GmbH & Co. KG | Leipziger Land | Sachsen | 2024 |
| 141.6 | ET Solar Gallin-Kuppentin | Parchim | Mecklenburg-Vorpommern | — |
| 133.3 | LEPV Energiepark Bohrau GmbH & Co. KG | Spree-Neiße | Brandenburg | — |
| 109.7 | SILUX Solarpark 2 GmbH & Co. KG | Leipziger Land | Sachsen | 2023 |
| 102.3 | CEE PVF Klüden GmbH & Co. KG | Ohrekreis | Sachsen-Anhalt | 2025 |
| 95.0 | PV Lako Angern GmbH & Co. KG | Oder-Spree | Brandenburg | — |
Top 10 wind turbines (single-unit capacity)
| MW | Owner / project | Kreis | Bundesland | Installed |
|---|---|---|---|---|
| 15.0 | Nordseecluster A GmbH & Co. KG | — | (offshore) | — |
| 15.0 | Nordseecluster A GmbH & Co. KG | — | (offshore) | — |
| 15.0 | Nordseecluster B GmbH & Co. KG | — | (offshore) | — |
| 15.0 | Nordseecluster A GmbH & Co. KG | — | (offshore) | — |
| 15.0 | Nordseecluster B GmbH & Co. KG | — | (offshore) | — |
| 15.0 | Windanker GmbH | — | (offshore) | — |
| 15.0 | Nordseecluster B GmbH & Co. KG | — | (offshore) | — |
| 15.0 | Nordseecluster A GmbH & Co. KG | — | (offshore) | — |
| 15.0 | Nordseecluster A GmbH & Co. KG | — | (offshore) | — |
| 15.0 | Nordseecluster A GmbH & Co. KG | — | (offshore) | — |
Raw data: largest-plants.json.
Capacity added during 2024
New plants registered between 2024-01-01 and 2024-12-31, aggregated per Kreis (Wind + PV ≥ 49 kW from this scrape). Total in this slice: ~13.4 GW — close to the public BNetzA tally of 16 GW once you add back the long tail of sub-49 kW rooftops.
Top operators
Top 30 owners by combined wind + utility-scale PV capacity, snapshot 2026-05-01. Names are normalised (legal-form suffixes stripped) but not consolidated across parent companies — most offshore wind farms appear as standalone project vehicles, which is why the top of the chart is dominated by single-farm LLCs rather than utilities.
Offshore wind
Germany's offshore wind fleet at 2026-05-01: 1 732 turbines, 10.4 GW
total — 8.6 GW in the Nordsee, 1.8 GW in the Ostsee. The
StandortAnonymisiert field carries a sea label
("Nordsee…" / "Ostsee…"), but the actual
Laengengrad / Breitengrad coordinates
are real (verified across all 1 909 offshore rows). Offshore
points still fall outside every Kreis polygon, so they don't
contribute to per-Kreis aggregations — we group by operator
instead.
Top Kreise by installed capacity
Sortable, filterable table of all 434 German Kreise (snapshot 2026-05-01), driven by the same MaStR scrape behind the maps above. Click any column to sort; type in the filter box to search by Kreis name or Bundesland. PV column uses the top-50 000 utility-scale plants (≥ 200 kW); Wind # and PV # are turbine / plant counts active at the snapshot date.
Bulk downloads
Cleaned, joined-to-Kreis snapshots are regenerated by the weekly
CI workflow. parquet is recommended for analysis
(zstd-compressed, types preserved); csv.gz for tools
without parquet support.
| File | Format | Rows | Use |
|---|---|---|---|
mastr-snapshot.parquet |
Parquet (zstd) | ~370 k | Per-plant table joined to Kreis + Bundesland. Recommended. |
mastr-snapshot.csv.gz |
CSV (gzip) | ~370 k | Same data as the parquet, for non-Python consumers. |
mastr-by-kreis.csv.gz |
CSV (gzip) | ~3 k | Per-Kreis × energy_type roll-up at the snapshot date. |
Data © Bundesnetzagentur (MaStR) and OpenStreetMap contributors (Kreis polygons, ODbL-1.0). The compressed snapshot omits owner contact details — names only, no PII.
PowerPlant dataclass
Defined in parser.py. Fields after decoding:
| Field | Type | Source |
|---|---|---|
id | int | Id |
num_panels | int | None | AnzahlSolarModule (dropped if ratio ≤ 0.1) |
power | float (kW) | Bruttoleistung |
inverter | float | Nettonennleistung × Leistungsbegrenzung factor |
install_date | datetime | None | InbetriebnahmeDatum |
removal_date | datetime | None | EndgueltigeStilllegungDatum |
postal_code | str | Plz |
is_private | bool | AnlagenbetreiberPersonenArt == 518 |
facing | int | str | HauptausrichtungSolarModule |
tilt | tuple | int | str | HauptneigungswinkelSolarmodule |
installation_type | str | None | ArtDerSolaranlageId |
building_type | str | None | NutzungsbereichGebSA |
owner_name | str | AnlagenbetreiberName |
energy_type | str | EnergietraegerName |
longitude / latitude | float | Laengengrad / Breitengrad (EPSG:4326) |
off_shore | "Nordsee" | "Ostsee" | None | WindAnLandOderSeeId + StandortAnonymisiert |
Gotchas
- Hamburg's geometry contains a national park as a phantom part. The notebook removes "Nationalpark Hamburgisches Wattenmeer" by part-id 2. Verify the part-id still matches if you regenerate the GeoPackage from a newer OSM extract.
-
Capacity units.
Bruttoleistungis in kW; the notebook divides by 1 000 000 to express county totals in GW for the legend. -
Offshore coordinates are real, despite the label.
Records flagged
WindAnLandOderSeeId == 889carry aStandortAnonymisiertstring ("Nordsee…" / "Ostsee…") and a realLaengengrad/Breitengrad. The original notebook draws them in synthetic sea rectangles anyway — that workaround was based on a wrong assumption about anonymisation; you can drop it if you want point-accurate offshore rendering. Spatial join to Kreis polygons still excludes them (no Kreis covers open sea). -
Timezone discipline.
parse_dotnet_datereturns tz-aware UTC datetimes. The notebook strips tz before comparing to date-only filters — keep that conversion local. Do not propagate tz-naive values back into other code.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Empty DataFrame after load_data |
No data-*.json files in the given directory, or the path is wrong relative to the notebook's CWD |
Re-run the scrape, or pass an absolute path |
All facings are None after a data refresh |
MaStR introduced new enum codes | Add the new cases in PowerPlant.from_json |
| Hamburg shows a wedge in the North Sea | OSM extract changed and the Wattenmeer is no longer part-id 2 | Inspect the MultiPolygon parts, update the index |
| GIF flickers between frames | Bins were re-computed per frame | Pass the precomputed BINS list to every call |