Marktstammdatenplotter

Animated choropleth maps of installed wind & solar capacity in Germany, driven by data scraped from the public Marktstammdatenregister (MaStR).

GitHub Python Data License
⚠ Caution
Research-quality code — designed to run once. Most of it is AI-generated and occasionally very verbose. Nothing has been cleaned up. Good luck.

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:

  1. scrapes the MaStR public JSON API,
  2. decodes the enum-heavy rows into a clean PowerPlant dataclass (parser.py),
  3. joins turbines to German county polygons extracted from OSM,
  4. renders one choropleth PNG per month from 2000 to today, and
  5. assembles the frames into an animated GIF with ffmpeg.

Repository layout

PathPurpose
parser.pyPowerPlant dataclass + JSON-to-record decoder
wind.ipynbEnd-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.mdUser-facing quickstart
CLAUDE.mdConventions 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.

Figure 1 — End-to-end data pipeline.

Module architecture

Figure 2 — Four layers: ingest, parse, analyze, render.

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
pageSize
25 000 rows per page
-P 4
four parallel curl workers
filter
excludes Energieträger codes 2495 + 2496
forExport=true
flat JSON; no HTML scaffolding
Note
The API gets slow under heavy load. Cache JSON on disk and skip files that already exist when re-running.

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.

Figure 3 — MaStR enum codes mapped to human-readable values.

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.

Figure 4 — Per-frame loop and final ffmpeg assembly.

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"
Fun fact
This renaming dance exists because PNGs carry no timestamp metadata. JPGs with EXIF would just work with ffmpeg's glob pattern.

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.

NotebookSourceLive HTMLControls
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
Demo mode
When no 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.

Figure 5 — Installed wind capacity per Kreis, 2026-05-01 (32 107 turbines, 79.7 GW total).
Figure 6 — Cumulative wind capacity in Germany, onshore vs. offshore (real registry).
Figure 7 — PV capacity per Kreis, full open-MaStR registry (~4.86 M plants).
Figure 8 — Cumulative PV capacity over time — full registry (all 5.79 M plants, every size). X-axis capped at 2030 to leave headroom for the planned-MaStR projection.

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.

Figure 8a — Wind installed power by per-turbine size-bin.
Figure 8b — PV installed power by per-plant size-bin (full registry, all sizes).

Installed capacity by Bundesland

Figure 9 — Wind + PV per state, snapshot 2026-05-01 (full registry). Niedersachsen leads wind (12.7 GW); Bayern leads PV (12.0 GW).

21 years of wind growth + YTD 2026

Figure 10 — Yearly frames of installed wind capacity per Kreis, 2005 → May 2026. Fixed Jenks bins. Also available: .mp4 · .gif.

21 years of PV growth + YTD 2026

Figure 11 — Yearly frames of installed PV capacity per Kreis, 2005 → May 2026. Uses the full open-MaStR Zenodo registry (~4.86 M plants, including rooftop and balcony solar — no power threshold). Also available: .mp4 · .gif.

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.

Figure 14 — MaStR energy-type mix, installed capacity at 2026-05-01.

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.

Figure 16 — Renewable build-out per Bundesland, 2000 → May 2026.

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.

Figure 17 — Cumulative PV by installation type + pie of latest share.

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

Figure 20 — PV capacity by panel orientation (pie) and tilt angle (bar).

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.

Figure 21 — German wind: installs vs. decommissions per year, plus turbine-MW upsizing trend.

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.

Cross-reference · German + EU sources
The three-sector split below uses the same boundaries as battery-charts.de (RWTH Aachen · Figgener et al.), the BVES (Bundesverband Energiespeichersysteme) market monitor, the EASE European Storage Market Monitor and the EU SET-Plan flexibility scenarios. Helpful for direct comparison with the figures those reports publish.

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.

Figure 22 — Battery-only sector split at 2026-05-01 (active): HSS 176 411 units · 2.47 GW · 2.54 GWh · CSS 16 655 units · 0.68 GW · 1.20 GWh · LSS 526 units · 3.34 GW · 5.65 GWh. (PSH split off into its own section.)
Figure 23 — Cumulative BESS power + energy by sector since 2010. HSS is the headcount story; LSS is the GW + GWh story.
Figure 24 — Duration distribution per sector (Batterie only). HSS clusters at ~1 h (paired to PV self-consumption); CSS at 1.5-2 h; LSS spreads across 1-4 h.

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.

Figure 25 — Battery installed power (left) and usable energy (right) per per-unit size-bin.

BESS power per Kreis

Figure 26 — Battery storage power per Kreis, snapshot 2026-05-01.

BESS energy per Kreis

Figure 27 — Battery storage energy capacity per Kreis (GWh) — batteries only, PSH split off into its own section.

Duration distribution

Figure 27 — Capacity-weighted duration histogram (Batterie only). Bimodal: 1-h hybrid-PV cluster, 2-4 h grid-services cluster.

Cumulative growth

Figure 28 — Cumulative battery power (purple) + energy (blue dashed) since 2010.

GWh trajectory by sector — historical + planned pipeline

Figure 29 — Cumulative usable storage energy (GWh) per sector (HSS / CSS / LSS), plus the forward trajectory from planned commissioning dates through 2030. Market estimates (battery-charts.de / Figgener et al.) put HSS at ~21 GWh — the gap to registered ~15 GWh is the ~20-40 % of residential batteries that never enter MaStR.

9 years of battery storage growth (2017 → May 2026)

Figure 30 — Yearly BESS power per Kreis, 2017 → May 2026. Also available: .mp4 · .gif.

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

Figure 31 — Pumped-hydro storage energy per Kreis. Thüringen + Bayern + Rheinland-Pfalz lead — these are mountain Kreise with the elevation differential PSH needs.

Distribution + duration

Figure 32 — PSH power (left) + energy (right) per Bundesland, plus duration histogram (far right). Median duration ~ 8 h — Li-ion sits at ~ 1 h for comparison.

Top sites

Figure 33 — Top 15 pumped-hydro sites by power. Annotation: installed energy in GWh + commissioning year.

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.

Figure 18 — Wind capacity density per Kreis (MW per km²), snapshot 2026-05-01.
Figure 19 — PV (≥ 49 kW) capacity density per Kreis (MW per km²).

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

MWOwner / project KreisBundeslandInstalled
200.0BEE Solarpark Schafhöfen GmbH & Co. KGRegensburgBayern
162.3SILUX Solarpark 1 GmbH & Co. KGLeipziger LandSachsen2024
153.1EnBW Solarpark Gottesgabe GmbHMärkisch-OderlandBrandenburg2022
151.0EnBW SunInvest GmbH & Co. KGMärkisch-OderlandBrandenburg2022
144.8SILUX Solarpark 4 GmbH & Co. KGLeipziger LandSachsen2024
141.6ET Solar Gallin-KuppentinParchimMecklenburg-Vorpommern
133.3LEPV Energiepark Bohrau GmbH & Co. KGSpree-NeißeBrandenburg
109.7SILUX Solarpark 2 GmbH & Co. KGLeipziger LandSachsen2023
102.3CEE PVF Klüden GmbH & Co. KGOhrekreisSachsen-Anhalt2025
95.0PV Lako Angern GmbH & Co. KGOder-SpreeBrandenburg

Top 10 wind turbines (single-unit capacity)

MWOwner / project KreisBundeslandInstalled
15.0Nordseecluster A GmbH & Co. KG(offshore)
15.0Nordseecluster A GmbH & Co. KG(offshore)
15.0Nordseecluster B GmbH & Co. KG(offshore)
15.0Nordseecluster A GmbH & Co. KG(offshore)
15.0Nordseecluster B GmbH & Co. KG(offshore)
15.0Windanker GmbH(offshore)
15.0Nordseecluster B GmbH & Co. KG(offshore)
15.0Nordseecluster A GmbH & Co. KG(offshore)
15.0Nordseecluster A GmbH & Co. KG(offshore)
15.0Nordseecluster 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.

Figure 15 — Capacity added during 2024, per Kreis. Top: Leipziger Land (565 MW utility solar), Märkisch-Oderland (258 MW), Rendsburg-Eckernförde (244 MW).

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.

Figure 12 — Top 30 operators · Wind (blue) + PV ≥ 200 kW (orange).

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.

Figure 13 — Top 25 offshore wind operators by installed capacity, colored by sea.

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.

FileFormatRowsUse
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:

FieldTypeSource
idintId
num_panelsint | NoneAnzahlSolarModule (dropped if ratio ≤ 0.1)
powerfloat (kW)Bruttoleistung
inverterfloatNettonennleistung × Leistungsbegrenzung factor
install_datedatetime | NoneInbetriebnahmeDatum
removal_datedatetime | NoneEndgueltigeStilllegungDatum
postal_codestrPlz
is_privateboolAnlagenbetreiberPersonenArt == 518
facingint | strHauptausrichtungSolarModule
tilttuple | int | strHauptneigungswinkelSolarmodule
installation_typestr | NoneArtDerSolaranlageId
building_typestr | NoneNutzungsbereichGebSA
owner_namestrAnlagenbetreiberName
energy_typestrEnergietraegerName
longitude / latitudefloatLaengengrad / Breitengrad (EPSG:4326)
off_shore"Nordsee" | "Ostsee" | NoneWindAnLandOderSeeId + 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. Bruttoleistung is 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 == 889 carry a StandortAnonymisiert string ("Nordsee…" / "Ostsee…") and a real Laengengrad / 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_date returns 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

SymptomLikely causeFix
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