Skip to content

Live-data docs site, marimo notebooks, animated GIFs + weekly CI refresh#6

Open
MaykThewessen wants to merge 98 commits into
emmericp:mainfrom
MaykThewessen:main
Open

Live-data docs site, marimo notebooks, animated GIFs + weekly CI refresh#6
MaykThewessen wants to merge 98 commits into
emmericp:mainfrom
MaykThewessen:main

Conversation

@MaykThewessen

Copy link
Copy Markdown

A larger-than-typical PR — splits cleanly into 7 commits if you'd prefer to
cherry-pick a subset. Live preview at
https://maykthewessen.github.io/marktstammdatenplotter/.

What's in here

Commit Adds
d45260a RTD-styled GitHub Pages site at /docs, four SVG pipeline diagrams, CLAUDE.md, mastr_plot.py helper module, two marimo reactive notebooks (pv.py, wind.py) with controls for date / installation type / on-offshore / colormap / bin count. Static HTML exports check in as docs/pv.html + docs/wind.html.
b8e424b First real-data re-render of all sample SVGs.
3cef76c PV scrape switched from "first 125 k by default sort" to "top 50 k sorted by Bruttoleistung-desc". Same number of rows, ~20× the signal.
ac4801e First multi-year wind animation GIF (21 yearly frames 2005 → 2025, fixed Jenks bins so color stays comparable). Bundesland-stacked-bar chart. pixi.toml for reproducible env across macOS / Linux / Windows. germany_kreise.gpkg is now built from isellsoap/deutschlandGeoJSON (~1 MB) instead of the 3.5 GB OSM extract, and carries a bundesland column for state aggregations.
318974d Sortable, filterable per-Kreis HTML table (all 434 Kreise; docs/assets/kreise.json + kreise-table.js). New `scale=jenks|linear|log` picker on the marimo maps. GitHub Actions workflow (`.github/workflows/refresh-docs.yml`) runs Sundays 03:17 UTC: re-scrapes, re-renders, re-commits docs back to main. Helper scripts in `scripts/`.
cca0c0f Top-30-operators chart + top-25-offshore-wind chart. Open Graph + Twitter Card meta tags. SVG favicon.
54e4d64 PV animation GIF (21 yearly frames, 474 KB). PV scrape extended to 8 pages (top 200 k = ≈ 58 GW active 2025-01-01). GIF renderer generalised: `scripts/render_wind_gif.py {wind|pv|both}`.

Why a single PR

The pieces share infrastructure: the docs site reads the same SVG output dir
that the marimo notebooks write to, the CI workflow re-runs those same
notebooks, and the operator / offshore / Bundesland charts all consume the
unified DataFrame from mastr_plot.load_records(). Splitting them would
double the diff churn. Happy to break into smaller PRs if you'd rather
review them in isolation.

Things I did NOT change

  • parser.py is untouched — all six MaStR enum decoders preserved.
  • The original wind.ipynb Jupyter notebook is preserved as-is; the new
    reactive notebooks live alongside as wind.py + pv.py.
  • The Fish-shell ffmpeg block in the README still works; the new Python
    helpers are additive.

Caveats

  • "All PV" interpreted as "top 200 000 plants by capacity" — covers ~91 %
    of national capacity. True 6 M-plant scrape is ~24 GB JSON and impractical
    for a GitHub Pages-hosted demo. CLI flag lets you go bigger.
  • No LICENSE added — the upstream repo carries no license file, so I
    didn't unilaterally apply one. Happy to add MIT / CC-BY-SA / Apache-2.0
    on request.
  • All charts and GIFs are rendered from a live May 2026 scrape committed to
    the branch. The CI workflow keeps them current; running manually is one
    `pixi run docs-build` away.

Live preview

Cheers.

MaykThewessen and others added 13 commits May 2, 2026 00:21
Replaces the slow, fragile curl-based MaStR API scrape with the curated
Zenodo dataset (DOI 10.5281/zenodo.18697247, version 2026-02-19), which
provides corrected coordinates and metadata for 40,455 wind turbines.

Changes:
- wind.ipynb: replace parser.py/JSON loading cell with direct Zenodo CSV load;
  maps Zenodo columns (nettonennleistung, datum_inbetriebnahme, lon_x/lat_y,
  bundesland) to the field names expected by the rest of the notebook
- build_kreise_gpkg.py: build germany_kreise.gpkg from Eurostat GISCO
  NUTS-3 boundaries instead of requiring a 4 GB OSM planet file + osmfilter;
  city-states (Berlin, Hamburg, Bremen) sourced from NUTS-1 as admin_level=4
- run_notebook.py: standalone runnable Python equivalent of wind.ipynb
- export_capacity_per_region.py: export current installed wind capacity
  (MW) per Landkreis and offshore sea area to a named CSV
- .gitignore: exclude data files, generated outputs, and Python cache

Closes emmericp#2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…egion

- fetch_mastr.py: new script to fetch wind/solar units from MaStR API
  - incremental mode (default): sorts descending by install date, stops
    at cutoff — retrieves only turbines newer than Zenodo snapshot in ~1 page
  - full mode: downloads all entries (wind ~2 pages; warns for solar ~237 pages)
  - supports --energy wind|solar via Energieträger code (wind=2497, solar=2495)

- export_capacity_per_region.py: merge Zenodo base + API incremental files
  - merges Zenodo dataset with any incremental API JSON files
  - spatial join via germany_kreise.gpkg for API entries missing Landkreis
  - fills missing Bundesland from Zenodo landkreis→bundesland lookup
  - offshore fallback: coordinate bounding-box (Nordsee lon 3–9 / Ostsee 9–15)
  - historical capacity columns: capacity_MW_2013, _2024, _2025, _latest
  - output validated: 313 regions, no duplicates, 2013=28.8 GW → latest=78.6 GW

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- pv.py + wind.py: reactive marimo explorers (date slider, type filter,
  bin count, colormap picker, summary stats, growth trend).
- mastr_plot.py: shared helpers (load_records, load_admin_units,
  aggregate_by_unit, jenks_bins, plot_choropleth). Falls back to a
  synthetic demo dataset when no scrape or kreise polygons are present.
- docs/: Read-the-Docs-styled single-page site (sticky sidebar, search
  filter, scroll-spy, 404, .nojekyll) with embedded SVG diagrams and
  rendered HTML exports of both notebooks. Served from /docs on main.
- fig/: pipeline + architecture + enum-decoding + timeline SVGs, plus
  sample PV/wind choropleths and growth charts rendered from demo data.
- fig/.gitignore: allowlist *.svg so diagrams ship with the repo.
- .gitignore: ignore macOS junk, __pycache__, __marimo__, regenerable
  scrape/OSM artifacts.
- CLAUDE.md: conventions for Claude Code agents working in this tree.
- README.md: rewritten with badges, embedded SVGs, notebook section.
- Live scrape of 7 non-PV pages (169 174 rows: wind, biomass, gas, hydro)
  and 5 PV pages (125 000 of 6.1 M registry plants).
- Wind map now shows the full registry slice at 2025-01-01: 31 404
  active turbines, 63 GW total. Real Kreis polygons (434 units from
  isellsoap/deutschlandGeoJSON) drive the choropleth.
- PV map shows a 125 k-plant sample — captures the spatial pattern
  even though it covers ~0.7 % of installed capacity.
- mastr_plot.load_records: merge every default data dir (data/, data-pv/,
  non-pv-data/) so a single load covers wind + PV when both have been
  scraped.
- README: document the PV-scrape command + the MaStR filter quirk
  (silent drop of second clause when ANDed across fields).
- docs/index.html: figure captions updated, "demo data" labels removed.
- docs/{pv,wind}.html: re-exported from notebooks reading real data
  (~520 KB each, up from ~160 KB demo).
…lice

Switched the PV scrape from "first 125 000 rows by default sort" to
"top 50 000 by Bruttoleistung-desc". Every plant in the new slice is
≥ 200 kW; combined capacity is ~68.8 GW — essentially every commercial
and utility-scale PV plant in Germany.

Trade-off: the long tail (6 M rooftop and balcony installs below 200 kW)
is excluded, but the spatial choropleth signal is dramatically stronger
since utility parks dominate per-Kreis aggregates anyway.

- README.md: update PV scrape command (sort=Bruttoleistung-desc, 2 pages)
  and the sample-render caption.
- docs/index.html: figure captions updated, "0.7%" / "125k-plant" wording
  replaced.
- fig/sample-pv-*.svg + docs/assets/sample-pv-*.svg: re-rendered.
- docs/pv.html + docs/wind.html: re-exported from notebooks reading the
  refreshed slice.
Three additions wired into both README and the docs site:

- fig/wind-2005-2025.gif (300 KB, 21 yearly frames 2005→2025).
  Built from the live registry, fixed Jenks bins so colors stay
  comparable across years. Drops onto the README as the marquee
  image.
- fig/sample-by-bundesland.svg — horizontal stacked bar of wind +
  utility-scale PV by state. Niedersachsen leads wind (12.7 GW);
  Bayern leads PV (12.0 GW). Live-data spatial join through the
  rebuilt GeoPackage.
- germany_kreise.gpkg now carries a `bundesland` column (NAME_1
  from deutschlandGeoJSON), enabling per-state aggregations
  without re-running the OSM extract.
- pixi.toml: reproducible env spec + tasks (scrape-non-pv,
  scrape-pv-top, fetch-kreise, pv-edit, wind-edit, docs-build).
- fig/.gitignore: allow wind-*.gif alongside SVGs.
- .gitignore: skip fig/frames/ (per-PNG dump regenerable from
  the notebooks).
Three follow-ups landed together because they share infrastructure
(the new scripts/ directory).

Top-Kreise table (docs)
- scripts/build_kreise_json.py produces docs/assets/kreise.json
  with one row per Kreis (434 total): wind GW + count, PV ≥200 kW
  GW + count, combined total. Snapshot is 2025-01-01.
- docs/assets/kreise-table.js renders a sortable, filterable HTML
  table mounted at #kreise-table. Click columns to re-sort,
  type in the filter box to search, drop-down for row count.
- docs/index.html: new "Top Kreise by installed capacity" section
  plus sidebar link.

Linear / log color-scale picker
- mastr_plot.plot_choropleth() grows a `scale=` argument
  (jenks | linear | log) selecting BoundaryNorm vs. Normalize vs.
  SymLogNorm respectively.
- pv.py + wind.py add an mo.ui.radio for the scale and pass it
  through to plot_choropleth, then re-export to docs/pv.html and
  docs/wind.html.

GitHub Actions weekly refresh
- .github/workflows/refresh-docs.yml runs Sun 03:17 UTC (or
  manually via workflow_dispatch). Scrapes 7 non-PV pages + 2
  top-sorted PV pages, regenerates the polygons, runs the three
  helper scripts, re-exports the notebooks, commits anything that
  changed back to main. Uses prefix-dev/setup-pixi@v0.8.10 for
  reproducible env.
- scripts/render_samples.py renders all 5 sample SVGs.
- scripts/render_wind_gif.py renders 21 frames + 9 hold + ffmpeg
  assemble.
- scripts/build_kreise_json.py rebuilds the per-Kreis dataset.

Bonus
- mastr_plot.find_data_dirs() now returns every dir that has
  *.json scrapes, so wind + PV scrapes are merged automatically
  when both have been pulled. find_data_dir() retained for
  single-dir callers.
- fig/sample-top-operators.svg + docs section "operators": top 30
  owners by combined wind + PV. Offshore project vehicles dominate
  the very top (every farm is its own LLC).
- fig/sample-offshore-windparks.svg + docs section "offshore":
  top 25 offshore wind operators, colored by sea. 1 637 turbines /
  9.2 GW total (Nordsee 7.4 + Ostsee 1.8).
- docs/index.html: full Open Graph + Twitter Card meta tags so
  link previews show the wind animation. Theme color set. Canonical
  URL added. New SVG favicon (turbine + sun).
- scripts/render_samples.py: now also re-renders the two new charts
  on every CI run.
- README: new sections for both charts.
The repo's marquee was wind-only — now PV gets the same treatment.
True "all solar PV" needs ~6 M plants (~24 GB JSON), so the practical
approximation is "all plants ≥ 49 kW" = top 200 000 sorted by capacity.
That covers ~58 GW active 2025-01-01: every utility park, every
commercial rooftop, every parking-lot canopy. The long tail of
sub-50 kW residential and balcony installs is excluded.

- fig/pv-2005-2025.gif (474 KB, 21 yearly frames). Sits next to the
  wind GIF in the README as a side-by-side comparison.
- scripts/render_wind_gif.py: generalised with PRESETS for "wind" and
  "pv" plus a CLI arg ("wind" | "pv" | "both", default "wind").
- pixi.toml: new tasks (render-samples, render-gifs, render-wind,
  render-pv, build-kreise) + scrape-pv-top now fetches 8 pages.
- .github/workflows/refresh-docs.yml: defaults bumped to 8 PV pages,
  GIF step runs `both`, commit-back add-list extended for pv-*.gif.
- docs/index.html: new section + figure for the PV animation.
  Figure numbers renumbered to avoid the collision the new section
  introduced.
- .gitignore + fig/.gitignore: allow pv-*.gif, ignore the new
  frames-pv/ scratch dir.
…CITATION

Five additions that share infrastructure (the scripts/ directory).

Energy-type mix chart
- fig/sample-energy-mix.svg + docs section "energy-mix": national
  installed capacity per Energieträger. Coal (31 GW) still rivals
  utility-scale PV (58 GW). Adds context — until now the docs only
  showed Wind + PV.

Year-over-year additions choropleth
- fig/sample-2024-additions.svg + docs section "yoy-additions": new
  plants registered during 2024, aggregated per Kreis. Top: Leipziger
  Land (565 MW utility solar in the former-lignite belt).

Bulk downloads
- scripts/build_downloads.py: produces docs/data/mastr-snapshot.parquet
  (8.4 MB, zstd), docs/data/mastr-snapshot.csv.gz (9.8 MB), and a
  per-Kreis × energy_type roll-up. Each row is joined to its Kreis +
  Bundesland via spatial point-in-polygon.
- docs section "downloads" with a table of formats + recommended use.

Parser unit tests
- tests/test_parser.py: 52 pytest cases covering parse_dotnet_date and
  every enum decoder in PowerPlant.from_json. Runs in 0.54 s.
- pixi tasks: `pixi run test`.
- CI runs the suite as part of the weekly refresh.

CITATION.cff
- Standard citation metadata for the software + upstream MaStR data
  + OSM polygons. Picked up automatically by GitHub's "Cite this
  repository" button.

Wiring
- pixi.toml: added pyarrow + pytest deps; new tasks (build-downloads,
  test, tests).
- .github/workflows/refresh-docs.yml: runs build_downloads.py and the
  pytest suite, then includes docs/data/ artefacts in the commit-back.
- scripts/render_samples.py: now also re-renders the energy mix +
  2024-additions charts.
Two new charts complete the time-series narrative on the docs site.

Per-Bundesland build-out ramp
- fig/sample-state-ramp.svg: monthly cumulative wind + PV ≥ 49 kW
  per state, 2000 → 2025. Bayern (22 GW), Niedersachsen (19 GW),
  Brandenburg (18 GW) lead. Sharp inflection around 2010 = PV boom.
- Spatial join attaches each plant to its Bundesland once, then
  pivots monthly to a wide table for matplotlib's stackplot.

PV by installation type
- fig/sample-pv-by-type.svg: cumulative GW per ArtDerSolaranlage
  bucket (building / free / parking_lot / water / balkonkraftwerk),
  plus a pie of the latest share. 16 k free-standing plants hold
  ~ 43 GW; 184 k building rooftops hold ~ 30 GW.

Wiring
- scripts/render_samples.py: two new render_* functions, both
  invoked from main() so the weekly CI keeps them fresh.
- docs/index.html: new sidebar entries + sections.
- README.md: figures inlined under the existing data narrative.
@MaykThewessen

Copy link
Copy Markdown
Author

Update: pushed three more commits onto the PR head since the original
opening — all on the same MaykThewessen:main branch, so they're already
visible above.

Commit Adds
f62fbc9 Energy-mix chart, 2024-additions choropleth, bulk-data downloads (Parquet + CSV.gz), 52 pytest cases for parser.py, CITATION.cff.
8844769 Per-Bundesland stacked-area ramp (2000 → 2025) and PV-by-installation-type breakdown.

Live preview rebuilt at https://maykthewessen.github.io/marktstammdatenplotter/.

Happy to split into smaller PRs or rebase if any of this is out of scope —
each commit is self-contained.

… templates

Density (MW/km²) reshuffles the absolute-capacity ranking
- fig/sample-wind-density.svg + fig/sample-pv-density.svg.
  Wind: Dithmarschen (1.64 MW/km²), Nordfriesland (1.14),
  Paderborn (1.05). PV: Straubing Städte (1.49), Amberg
  Städte (1.20), Leipziger Land (0.94).
- units.area_km2 is computed via ETRS89 / UTM32N
  (epsg=25832), the standard equal-area projection for
  Germany.

Top 10 largest individual plants
- docs/assets/largest-plants.json — JSON dataset for
  potential future client-side rendering.
- docs/index.html: two inline HTML tables (top-10 PV +
  top-10 wind by single-unit Bruttoleistung). PV list
  reveals the Leipziger Land / Märkisch-Oderland utility-
  solar cluster. Wind top-10 is dominated by the new 15 MW
  offshore turbines (Nordseecluster A+B, Windanker).

README badges
- Added: Refresh-CI status, parser-tests badge (52 passing),
  pixi badge, marimo badge. Existing badges (docs, Python,
  data source) restyled with logos.

.github templates
- bug_report.md and feature_request.md issue templates plus
  a PULL_REQUEST_TEMPLATE.md with the standard test-plan
  checklist (parser tests + render-samples + docs-build).

Wiring
- scripts/render_samples.py: two new render_density_map
  functions + export_largest_plants(). Both called from
  main() so CI keeps them fresh.
PV orientation analysis
- fig/sample-pv-orientation.svg: capacity-weighted distribution of
  panel facing (pie) + tilt angle (horizontal bar). South dominates
  at 62 % of installed PV; east-west flat-mount is second at 14 %.
  Trackers rare (0.5 GW). Most plants tilt 0-19° (flat, utility-park
  footprints).

Wind fleet age + repowering signal
- fig/sample-wind-age.svg: two-panel chart. Top: installs vs.
  decommissions per year, decommission wave starting 2019 as the
  first EEG cohort hits its 20-year boundary. Bottom: mean rotor
  MW by commission year — fleet has upsized from ~0.5 MW in the
  late 90s to ~7 MW in 2025 (15× growth at the unit level, driven
  by new 15 MW offshore class).

Project hygiene
- CHANGELOG.md: Keep-a-Changelog format, 0.1.0 → 0.5.0 plus an
  Unreleased bucket. Captures every commit in the public history.
- CONTRIBUTING.md: quickstart, repo layout, "how to add a chart"
  + "how to add a parser enum" recipes, style + commit rules.

Wiring
- scripts/render_samples.py: two new render_* functions wired into
  main(). Both honour the same dual-write pattern (fig/ + docs/assets/).
The registry already carries 4 981 plants with install_date in 2026
(354 wind / 2.3 GW + 4 148 PV / 3.5 GW); the latest install_date is
2026-05-15. Adding a final May-2026 snapshot to both animations
captures the year-to-date build-out without waiting for year-end.

- scripts/render_wind_gif.py: introduced snapshot_dates() helper plus
  a FINAL_FRAME constant (date(2026, 5, 1)). Frame list is now the
  21 yearly Jan-1 dates plus the final cap. Trivial to extend to
  finer cadence later (monthly, quarterly).
- fig/wind-2005-may2026.gif (302 KB, was 300 KB / 21 frames →
  now 31 frames including the 9-frame hold) and
  fig/pv-2005-may2026.gif (509 KB).
- Old GIFs removed: fig/wind-2005-2025.gif, fig/pv-2005-2025.gif,
  docs/assets/ mirrors.
- README + docs/index.html + Open Graph meta tags updated to point
  at the new filenames.
- CHANGELOG.md: Unreleased bucket records the change.
Density choropleths now match the final frame of the animation GIFs.
The same bump propagates through every snapshot-derived artefact so
the docs site reads as one consistent point-in-time.

Headline numbers at 2026-05-01:
- Wind: 32 107 turbines active, 79.7 GW (was 31 404 / 63 GW @ 2025-01-01)
- PV (≥ 49 kW): 195 591 plants active, 72.8 GW (was 174 934 / 58 GW)
- Offshore: 1 732 turbines / 10.4 GW (Nordsee 8.6 + Ostsee 1.8)

Code changes
- scripts/render_samples.py: SNAP → 2026-05-01.
- scripts/build_kreise_json.py: SNAP → 2026-05-01.
- scripts/build_downloads.py: SNAP → 2026-05-01; active column
  renamed `active_2025_01_01` → `active_at_snap`.

Regenerated artefacts (all on the new snapshot)
- fig/sample-*.svg + docs/assets/sample-*.svg (all 14 charts).
- docs/assets/kreise.json (434 rows).
- docs/data/mastr-snapshot.parquet + .csv.gz + mastr-by-kreis.csv.gz.

Caption fixes
- README.md and docs/index.html: every "2025-01-01" replaced with
  "2026-05-01"; stale absolute figures (63 GW, 58 GW, 31 404 turbines,
  1 637 offshore) updated to match the new snapshot.

CHANGELOG.md: entry expanded under Unreleased / Changed.
…ization claim

Three bugs found by a coverage audit:

A. Choropleth titles used the per-Kreis aggregated sum
   `agg["power_gw"].sum()`, which silently excludes plants that fall
   outside every Kreis polygon. For wind that's 3 098 turbines /
   11.3 GW of offshore + slightly-misaligned coastal capacity. Titles
   now use the active-set total `active["power"].sum() / 1e6` and add
   a "X GW offshore / out-of-Kreis" footnote when the gap > 1 GW.
   Fixed in: scripts/render_samples.py (map + density), scripts/
   render_wind_gif.py (yearly + YTD frames).

B. `active_at_snap` in build_downloads.py used
   `install_date.isna() | install_date <= snap`, which silently
   includes NaT-install plants (~15 k rows / 71 GW of legacy
   conventional). Every other filter in the codebase excludes them.
   The parquet + CSV downloads were inconsistent with the rendered
   charts. Now matches `mastr_plot.aggregate_by_unit`.

C. README, CLAUDE.md, and docs/index.html previously claimed
   offshore-turbine coordinates are anonymised. Reality: all 1 909
   offshore rows carry real Laengengrad / Breitengrad (verified
   against the raw scrape — EnBW Baltic 1 at 54.605, 12.63 etc.).
   Only the StandortAnonymisiert string is a "Nordsee…" / "Ostsee…"
   label. The original notebook's synthetic-sea-rectangle hack was
   never required. Docs corrected.

README additions
- New "What this method does not track" table enumerating every
  systematically-excluded category (sub-49 kW PV, NaT install,
  NaN coords, offshore in choropleths, battery storage, heat-only,
  pre-commissioning, behind-the-meter, decommissioned).

CHANGELOG.md: entries under Unreleased / Fixed.

Re-renders
- All 16 sample SVGs (titles now match the active-set totals).
- Both animation GIFs (wind, pv) regenerated with corrected titles.
- Parquet + CSV downloads regenerated with the consistent flag.
- 52 parser tests still green (0.53 s).
scripts/render_wind_gif.py prepended the snapshot date to its title,
then mastr_plot.plot_choropleth appended it again. Net result: the
date showed up twice in every frame ("Installed wind capacity in
Germany — 2026-05-01 / 32,107 turbines · 79.7 GW / 2026-05-01").

plot_choropleth is responsible for the date suffix; callers shouldn't
duplicate it. Stripped the leading "— {snap.isoformat()}" from the
GIF script's title template.

Re-rendered both animations (wind: 310 KB, pv: 502 KB) — date now
appears exactly once per frame.
User feedback: prefer the date on row 1 (next to the chart name)
rather than as a trailing line under the stats.

- mastr_plot.plot_choropleth no longer auto-appends the date.
  Callers control title formatting verbatim.
- scripts/render_wind_gif.py: keeps date in row 1
  ("Installed wind capacity in Germany — 2026-05-01\n…").
- scripts/render_samples.py render_map: date now lives on row 1
  ("Wind capacity — 2026-05-01\n32,107 plants · 79.7 GW").
- pv.py + wind.py marimo notebooks: same pattern; the date_slider
  value is interpolated into the first row.

Re-rendered all 16 sample SVGs, both animation GIFs, and re-exported
both marimo HTML pages.
Source PNG dpi 110 → 150 so we render at higher pixel density
(946×1105 → 1290×1505 per frame), then ffmpeg downscales via
Lanczos to 1200 px height (was 900 px). Palette grows 128 → 192
colors for smoother Jenks-bin transitions.

Result
- wind-2005-may2026.gif: 320 KB → 576 KB (1027×1200)
- pv-2005-may2026.gif:   576 KB → 876 KB (1027×1200)

Both still well under LinkedIn's 5 MB GIF cap. Choropleth edges +
text are noticeably sharper at 1027×1200 — good for embed in
LinkedIn/Slack/Twitter previews where the platform itself doesn't
upscale.
Source PNG dpi 150 → 180, ffmpeg target height 1200 → 1440, palette
192 → 224 colors. The animations are the only raster outputs in the
repo (every chart, diagram, and favicon is already SVG) so the GIFs
are where resolution bumps actually matter.

Result
- wind-2005-may2026.gif: 576 KB → 688 KB
- pv-2005-may2026.gif:   876 KB → 1.1 MB

Both still comfortably under LinkedIn / Slack / X 5 MB caps.

Note on PNG → SVG: no PNG files exist in the repo's published outputs.
fig/frames*/ holds intermediate raster PNGs but those are gitignored.
GIF is a raster format by definition, so the animations themselves
can't be SVG without switching to animated SVG (SMIL/CSS) or
WebM/MP4 — both lose the universal-autoplay property that makes
GIFs work in README badges.
WebP intermediates (lossless)
- scripts/render_wind_gif.py renders frames as lossless WebP via
  matplotlib + Pillow (pil_kwargs={"lossless": True, "method": 4}).
  ffmpeg reads them natively via the webp decoder.
- Smaller than PNG at the same dpi, identical pixels into ffmpeg.

Modern output: H.264 MP4 next to the GIF
- New assemble_mp4() emits libx264 yuv420p MP4 with crf=18, preset=slow,
  faststart, fps=10. Same 1233×1440 px target as the GIF; ~30 % smaller
  file at notably sharper color reproduction (h264 isn't capped by GIF's
  256-color palette).
- LinkedIn, Slack, X all autoplay MP4 inline. Major win for sharing.
- docs/index.html embeds via <video autoplay loop muted playsinline>
  with the GIF as <img> fallback + poster. Both formats also linked
  in the figure captions.

Sizes
| Output | Wind | PV |
|--------|------|----|
| GIF    | 688K | 1.1M |
| MP4    | 476K | 916K |

Wiring
- fig/.gitignore: allows wind-*.mp4 + pv-*.mp4 alongside the GIFs.
- .github/workflows/refresh-docs.yml: commit-back add-list extended
  for fig/*.mp4 + docs/assets/*.mp4.
- README + CHANGELOG entries.
MaStR carries 2.5 M storage records under Energieträger=2496 (Speicher)
on the same Stromerzeugung endpoint we already scrape. Top 200 000 by
Bruttoleistung captures every grid-scale + commercial unit down to
~10 kW home batteries.

Snapshot 2026-05-01 (commissioned only)
- 193 678 units · 7.86 GW · 98.9 GWh active
- Power split ~50/50 between Pumpspeicher (6.48 GW, 41 sites) and
  Batterie (6.49 GW, 193 599 units). Energy ~100× asymmetric — Li-ion
  sits in the 1-2 h duration band, PSH is multi-hour.

Pipeline (incl. planned-and-permitted)
- 25 GW / 2 565 GWh by ~2028. Headlines: LEAG GigaBattery Jänschwalde
  1 GW / 4 GWh, GigaBattery Boxberg 400 MW, ECO POWER 1-6 (300 MW each).

What landed
- parser.py: new BatteryUnit dataclass + parser.load_bess(). Energy
  capacity, storage tech, planned commissioning, voltage level, status
  preserved. Kreis + Bundesland are pre-joined in source JSON so no
  spatial join needed inside the parser layer.
- mastr_plot.py: load_bess(), aggregate_bess_by_unit() with optional
  include_planned=True for pipeline view.
- scripts/render_samples.py: render_bess_charts() emits 5 SVGs in one
  pass (power map, energy map, duration histogram, tech mix, growth).
- scripts/render_wind_gif.py: PRESETS gains a "bess" entry; CLI takes
  `bess` and `all` arguments. Same WebP/GIF/MP4 pipeline.
- tests/test_parser.py: 5 new BatteryUnit cases. 57 / 57 green.
- pixi.toml: new tasks scrape-bess + render-bess; render-gifs upgraded
  from `both` → `all`.
- .github/workflows/refresh-docs.yml: BESS scrape step, commit-back
  add-list extended for bess-*.gif/.mp4.
- docs/index.html + README.md: new "Battery + electricity storage
  (BESS)" section with 5 charts + animation video.
- fig/.gitignore + .gitignore: allow bess-*.gif/mp4, ignore data-bess/
  + fig/frames-bess/.
- CHANGELOG.md: extensive entry under Unreleased / Added.
…tion)

The German + EU storage-reporting standard splits BESS into three
sectors by usable energy capacity:

| Sector | Threshold | Source convention |
|--------|-----------|-------------------|
| HSS Heimspeicher    | < 30 kWh        | battery-charts.de, BVES |
| CSS Gewerblich      | 30 kWh – 1 MWh  | battery-charts.de, BVES |
| LSS Großspeicher    | ≥ 1 MWh         | battery-charts.de, EASE, EU SET-Plan |

Snapshot 2026-05-01 (active only)
- HSS  176 441 units · 2.47 GW · 2.54 GWh  (1-h residential PV+battery)
- CSS   16 659 units · 0.68 GW · 1.20 GWh
- LSS      569 units · 9.81 GW · 933.15 GWh  (Pumpspeicher dominates)

Pipeline (planned-and-permitted)
- + 8.2 GW LSS Li-ion by ~ 2028 (Jänschwalde 1 GW, Boxberg 400 MW,
  ECO POWER 1-6 = 6 × 300 MW, Westfalen 538 MW, …)

What landed
- mastr_plot.bess_sector() classifier + BESS_SECTORS constant.
- mastr_plot.load_bess() decorates each row with a `sector` column.
- scripts/render_samples.py: new render_bess_sector_charts() emits
  three SVGs in one pass (summary bars, stacked-area cumulative
  growth, per-sector duration histogram). Wired into main().
- tests/test_parser.py: 4 new TestBessSector cases (HSS/CSS/LSS
  bounds, unknown for None/0/negative). 61 / 61 tests green.
- README + docs/index.html: new sector subsection cross-referencing
  battery-charts.de (RWTH Aachen), BVES, EASE, EU SET-Plan.

Why this matters
The headline "12.97 GW BESS" hides three very different stories:
176k households doing 1-h self-consumption, ~17k commercial sites,
and 569 grid-services + Pumpspeicher units carrying most of the
energy capacity. Sector reporting lets the docs site speak the same
language as the German + EU storage-market trackers.
Seven log-spaced bins on a shared axis across all three techs:
0-10 kW · 10-100 kW · 100 kW-1 MW · 1-10 MW · 10-100 MW · 100-1000 MW · 1 GW+

BESS gets two panels (GW + GWh per bin) because storage carries both
power and energy. Wind + PV charts are single-panel (GW only).

What it reveals (snapshot 2026-05-01)

  Wind
  - 75.4 GW of the 79.7 GW total sits in the 1-10 MW bin
    (26 174 turbines). 10-100 MW = 118 of the new 14-15 MW
    offshore class (1.5 GW). Everything else is rounding.

  PV (top-200k slice)
  - 100 kW-1 MW: 73 569 plants / 22.7 GW (commercial rooftop)
  - 1-10 MW:      7 765 plants / 24.9 GW (utility ground-mount)
  - 10-100 MW:      787 plants / 15.6 GW (large utility)
  - 100-1000 MW:      6 plants /  0.8 GW (mega-parks)
  - Sub-49 kW residential excluded by the scrape's sort floor.

  BESS
  - 10-100 kW:   192 075 units · 2.9 GW · 3.5 GWh  (residential)
  - 1-10 MW:        356 units · 1.1 GW · 2.1 GWh  (grid-scale Li-ion)
  - 10-100 MW:       94 units · 2.4 GW · 165 GWh  (utility Li-ion +
                                                    smaller Pumpspeicher)
  - 100-1000 MW:     30 units · 6.2 GW · 765 GWh  (Pumpspeicher +
                                                    biggest Li-ion)
  - 1 GW+:            0 units (Jänschwalde still In Planung)

Code
- scripts/render_samples.py: SIZE_BIN_EDGES_KW + SIZE_BIN_LABELS,
  _size_bin_stats(), _bar_with_labels(), render_bess_by_size(),
  render_generation_by_size(). Numpy import was missing — fixed.
- Three new sample SVGs (one per tech).
- Embedded in docs/index.html (under wind/PV section + BESS section)
  and README.md.

CHANGELOG entry under Unreleased / Added.
Three concurrent changes shipped together because they share the
size-bin pipeline.

1. Pumped-hydro storage (PSH) is now its own section
   - PSH is reported separately from batteries everywhere serious
     (battery-charts.de / BVES / EASE / EU SET-Plan): different
     technology, different lifecycle (50+ yr vs 10-15), different
     grid-services profile.
   - mastr_plot.split_bess_storage() returns three disjoint slices
     (batteries / psh / other).
   - scripts/render_samples.py: new render_psh_charts() emits a map,
     per-Bundesland + duration summary, and top-15 sites bar chart.
   - Battery-only numbers in the BESS section now drop from the
     combined 12.97 GW / 936.9 GWh to the true Li-ion-and-friends
     number: 6.49 GW / 9.4 GWh.
   - PSH numbers: 41 sites · 6.48 GW · 927.5 GWh, median duration
     ~8 h vs ~1 h for residential Li-ion.

2. Wind size-bin chart now aggregates per project, not per turbine
   - render_generation_by_size() grows an aggregate_by="owner_name"
     parameter. For wind, project ≈ Windpark SPV, so summing per
     owner before binning surfaces the real distribution: offshore
     farms now appear in the 100-1000 MW bin (was hidden because
     individual turbines are 6-15 MW each).

3. Size-bin bars no longer print unit-count annotations
   - Dropped count_series from _bar_with_labels; value is enough.

Cleanup
- Removed the now-stale BESS tech-mix subchart + sample-bess-tech-mix.svg
  (post-split it'd collapse to a single 'Batterie' bar).
- README + docs/index.html: BESS section rewritten with batteries-only
  numbers; new dedicated PSH section + sidebar entry; figure numbers
  renumbered to keep continuity.
- 61 / 61 parser tests still green.
…outputs

Reads the complete open-MaStR bulk export (Zenodo 14783581) for all three
technologies, selects key columns, parses dates, and saves as snappy-compressed
parquet. Includes stats on capacity by Bundesland, year-over-year additions,
battery chemistry breakdown, and usable storage capacity joined from the
storage_units sub-table.

Solar.parquet excluded from git (126 MB > GitHub 100 MB limit); regenerate
locally via process_mastr_zenodo.py.
MaykThewessen and others added 30 commits May 19, 2026 21:27
Regenerated 31 WebP frames via render_wind_gif.py pv — now driven by
open-MaStR bulk parquet (load_records prefer_bulk=True), covering
the full ~4.86 M PV registry including residential and balcony plants,
not just the previous top-200k ≥49 kW slice.
Remove shapely simplify(0.005) from load_admin_units. Re-render all
sample SVGs at full vertex count (~88,869 vertices, ~2.47MB per map).
Restore previously deleted _gradient.svg variants.
- render_wind_gif.py: add agg_unit/legend_label GWp to PV preset
- render_samples.py: add unit param to render_map, render_growth,
  render_density_map, render_generation_by_size (default "GW");
  pass unit="GWp" at all PV call sites. Directly update PV-only
  functions: render_pv_by_type, render_pv_orientation,
  render_pv_orientation_polar, render_pv_orientation_polar_snapshot
- Re-render all sample SVGs with GWp labels at full polygon resolution
  (no geometry.simplify — reverted in previous commit)
- Physical 6.3 GB SQLite now lives at data/mastr/open-mastr.db (gitignored)
- Symlink at ~/.open-MaStR/data/sqlite/open-mastr.db preserves open-mastr's
  default refresh path, so `pixi run db-mastr-core` writes to the repo copy
- mastr_db.DB_PATH prefers the in-repo file, falls back to home path
- .gitignore: exclude data/mastr/
- CLAUDE.md: document the path + symlink
- mastr_plot.py: add KREIS_NAME_TO_AGS5 lookup (23 city/Landkreis pairs) and
  comma-suffix regex for BKG VG2500 names; resolves Passau/München/etc.
  collisions when aggregating anonymised MaStR rows via municipality_key prefix
- scripts/render_wind_gif.py: minor pipeline tweak
- pixi.toml: trivial whitespace
- docs/assets + fig: re-render all sample SVGs and per-tech animations
  (bess, pv, wind 2005-may2026 GIF/MP4 + extra 2010/2018 starts)
Drop docs/assets/sample-pv-density_gradient.svg and
sample-wind-density_gradient.svg (orphans from removed render path).
Add planning note for open-mastr integration.
- mastr_db.load_for_pipeline(tech) returns DataFrame matching the
  Zenodo-parquet column contract that mastr_plot.load_from_bulk consumes:
    * common: mastr_id, einheit_name, gross_capacity_kw, commissioning_date,
      decommissioning_date, longitude, latitude, landkreis, bundesland,
      municipality_key, owner_name (via JOIN to market_actors.Firmenname)
    * wind: + location_type (WindAnLandOderAufSee), seelage, wind_park
    * pv:   + location_type (mapped ArtDerSolaranlage), usage_type (translated
            Nutzungsbereich), orientation, tilt (mapped Neigungswinkel tuple)
    * bess: + usable_capacity_kwh, planned_commissioning_date, storage_technology
- Strict mode: _ensure_market_actors() raises RuntimeError when the
  market_actors table is empty so callers get a clear diagnosis rather
  than silently NULL owner_name across all rows
- pixi.toml: add db-mastr-market task; db-mastr-core now also pulls market
- tests/test_open_mastr_parity.py: 19 tests covering schema contract,
  row-count floors, capacity sum bounds, enum value translation, and the
  strict-mode raise path; all skip cleanly when DB is absent
load_from_bulk(tech, bulk_dir=None, source="auto") gains a new selector:
  - "auto"   prefers SQLite when data/mastr/open-mastr.db is populated,
             falls back to the Zenodo parquet dir otherwise (default)
  - "sqlite" forces open-mastr via mastr_db.load_for_pipeline
  - "zenodo" forces the legacy parquet path

A new helper, _resolve_bulk_source(), encapsulates the path/DB discovery so
the main function body stays linear. The downstream rename + decoration
block at lines 477+ is unchanged: both backends produce Zenodo-style column
names so the same translation logic applies.

Backward compatibility: existing callers (no source= argument) inherit the
"auto" behavior. When the SQLite DB is absent, behavior is identical to
pre-change. When present, the SQLite path returns the newer registry data
(e.g. wind: 42,500 vs Zenodo 40,815 rows).
- scripts/build_full_bess.py: collapse the Zenodo-base + JSON-API delta merge
  into a single mastr_db.load_for_pipeline("bess") call. Output parquet path
  unchanged for backward compatibility with CI artefact uploads.
- mastr_db: BESS adapter now LEFT JOINs storage_units for
  NutzbareSpeicherkapazitaet — open-mastr stores it there, not in
  storage_extended where the column is always NULL. Falls back gracefully
  when storage_units is unpopulated.
- mastr_plot.load_records / load_bess: accept new `source` parameter and
  rely on load_from_bulk's source resolution. Drops the explicit
  find_bulk_dir() probe — load_from_bulk handles SQLite-vs-parquet auto.
  Notebooks (wind.py, pv.py) call load_records() with no args and now
  transparently get the daily-fresh SQLite data when present.
- pixi: add db-mastr-storage-units task; db-mastr-core extended to fetch
  it alongside wind/solar/storage/market so energy capacity is populated.

Verified end-to-end: 2.53 M BESS rows, 36.83 GW + 751.25 GWh active. 19/19
parity tests green.
- README: new Data sources table summarising primary (SQLite) vs fallback
  (Zenodo parquet, JSON API top-N) paths and showing the source= switch
- CHANGELOG: document the SQLite addition, mastr_db.load_for_pipeline,
  source= switch in load_from_bulk, 19 parity tests, 7 new pixi tasks,
  and the build_full_bess.py rewrite
- .github/workflows/refresh-docs.yml: add a "Refresh open-mastr SQLite
  snapshot" step that runs pixi run db-mastr-core before the render
  stages, with SQLITE_DATABASE_PATH env pointed at repo path so mastr_db
  resolves to _REPO_DB. ZIP cleanup after parse keeps runner disk under
  9 GB.

All 80 tests green (19 parity + 61 parser).
- All sample SVGs regenerated using mastr_plot.load_records / load_bess →
  open-mastr SQLite (wind 42.5k, PV 6.13M, BESS 2.53M)
- scripts/render_samples.py: BESS load now uses the default prefer_bulk=True
  path so BESS charts pull the full 2.5M-unit registry via SQLite instead
  of the 200k JSON-scrape slice. Falls back to JSON if SQLite + parquet
  are both absent.

AGS5 disambiguation note (already in place since 3158604):
  KREIS_NAME_TO_AGS5 dict (46 entries / 23 city-Landkreis pairs) maps
  BKG VG2500 name → Destatis 5-digit Kreisschlüssel. Both
  aggregate_by_unit (mastr_plot.py:875) and aggregate_bess_by_unit
  (mastr_plot.py:791) use municipality_key[:5] via combine_first as the
  canonical override of the landkreis_norm name-fallback for anonymised
  rows.
Risk 1 (BESS energy 3x gap): open-mastr's XML drops ~85 % of pumped-hydro
reservoir energy (e.g. Goldisthal 9.6 GWh per turbine → NULL). Added
_backfill_psh_energy_from_zenodo() that maps mastr_id → usable_capacity_kwh
from BNetzA_MaStR/storage.parquet for rows where SQLite returned NULL.
load_for_pipeline("bess", psh_backfill=True) wires it in by default;
psh_backfill=False yields pure open-mastr output. Result: 751 → 2,586 GWh
total (within 1.4 % of Zenodo's 2,551 GWh baseline; matches all 115 PSH
units now non-null vs 17/115 before).

Risk 2 (render_samples GWh trajectory stale parquet read): replaced the
direct pd.read_parquet(bulk_dir / 'storage.parquet') call at line 1605
with mastr_plot.load_bess() so the chart pulls from SQLite (2.5 M BESS
units) instead of the 1.75 M Zenodo snapshot. Column references switched
to the post-rename schema (energy_kwh, install_date, planned_date,
removal_date — all tz-naive). New chart numbers: 29.0 GWh historical +
18.1 GWh planned, up from 19.2 + 4.9.

Risk 3 (PV installation_type lossy): documented in mastr_db.py comment.
0.002 % of registry (~126 rows) — Großparkplatz / Gewässer collapsed
into "Bauliche Anlagen (Sonstige)". Use source="zenodo" if granularity
matters.

Risk 4 (full-storage.parquet consumer audit): no breakage. All removed
columns either rename-resilient (storage_technology, usable_capacity_kwh,
commissioning_date are preserved by load_from_bulk) or unused downstream
(status, voltage_level, feed_in_mode, net_capacity_kw, battery_technology_code).

New: cadence-aware GIF rendering (--cadence yearly|halfyear|quarterly|monthly).
22 → 43 → 85 → 253 frames over 2005-2026 with proportionally faster framerate
so wall-clock playback stays ~8–12 s. Six new pixi tasks render-{wind,pv,bess}-
{halfyear,monthly} expose the smoother variants without changing the default.
Smoother frame-to-frame growth using the new --cadence monthly option.
GIF 1.3 MB / MP4 886 KB at 24 fps display rate. fig/ + docs/assets/.
- fig/bess-2018-may2026.{gif,mp4}: monthly cadence render (106 frames)
- docs/assets: replace stale bess-2017-* with bess-2018-* (BESS start_year=2018)
- docs/index.html: bump poster + source URLs to match new filename
- snapshot_dates(): walk up to FINAL_FRAME instead of END_YEAR/Dec, then
  ensure FINAL_FRAME is the closing frame regardless of step alignment.
  Monthly cadence now includes Jan/Feb/Mar/Apr 2026 + May 2026 YTD instead
  of jumping from Dec 2025 straight to May 2026.
- PRESETS[bess]: start_year 2018 -> 2020; basename bess-2018-may2026 ->
  bess-2020-may2026 (battery growth before 2020 is negligible)
- docs/index.html: update poster + source URLs accordingly
- wind 2005→may2026: 266 frames (was 262; +4 for 2026 Jan-Apr)
- pv   2010→may2026: 206 frames; rename from pv-2005-* to pv-2010-* to
  match PRESETS[pv].start_year=2010 (older years were noise — flat at zero)
- bess 2020→may2026: 86  frames; start year 2018 → 2020 per user request
- Drop stale bess-2017-*, bess-2018-*, pv-2005-* artefacts
- docs/index.html + README.md: update gif/mp4 hrefs to new filenames
astype(str) coerces NaN to the literal 'nan', which can never match an AGS5
key, so the AGS5 override was silently skipped for any anonymised row whose
municipality_key was also missing. Use .str[:5] directly so NaN stays NaN
and combine_first falls through to the name-based join cleanly.

Applies to both aggregate_by_unit (PV/Wind) and aggregate_bess_by_unit
(BESS). All 19 parity tests still pass; wind 2026-05 aggregate unchanged
at 69.22 GW.
Hardcoded date(2026, 5, 1) becomes a moving target. Use date.today() by
default; override with env MASTR_FINAL_FRAME=YYYY-MM-DD for reproducible
renders. Monthly cadence now adds today's date as the closing frame on top
of the regular month-start sequence.
Aggregates installed battery power/energy to the 16 German federal states
at a configurable snapshot date (default 2026-02-28) and applies a
€/MW/yr revenue benchmark (default 130 k€, near Modo Energy index median)
for an order-of-magnitude annual-income estimate per state.

Bundesland polygons fetched from the geoBoundaries ADM1 API and cached
under data/geo/ (gitignored); used to spatially recover the few rows
where MaStR has Bundesland NULL but coordinates are present.
~400 unique Kreis names vs millions of MaStR rows — apply() re-runs the
regex chain per row. New normalise_kreis_series() normalises only on
unique values and maps back. Behaviour-identical incl. NaN handling.
Refresh open-mastr.db to Gesamtdatenexport_20260614 (all techs) and
re-render wind/pv/bess monthly choropleth animations on current data.
Latest commissioning: wind 2026-06-11, solar/storage 2026-06-14.
From claude/german-battery-income-analysis. Clean merge onto current main
(base 9ed7a12); adds the income-by-Bundesland BESS snapshot script.
… sources

Add BESS animation to the marquee (was wind+pv only); refresh caption to
monthly/2026-06. New At-a-glance table with verified live totals (wind 80.3 GW,
PV 124.3 GWp, storage 29.0 GW @ 2026-06-14). Rework Data sources table with
Retrieval-mode + Freshness columns (bulk full-snapshot vs JSON incremental
delta vs Zenodo frozen). Fix BKG-VG2500-vs-OSM contradiction.
process_mastr_zenodo.py and convert_storage_to_parquet.py only read the
bnetza_mastr_*_raw.csv bulk dump, now deleted. The DB path replaces them:
load_from_bulk(source="auto") resolves to sqlite and reads wind/pv/bess
straight from open-mastr.db via mastr_db.load_for_pipeline; build_full_bess
rebuilds full-storage.parquet from the same DB. Verified all three techs
load clean (42,617 / 6,204,610 / 2,580,464 rows).
run_notebook.py and wind.ipynb both switch the wind loader from the frozen
Zenodo CSV (record 18697247, v2026_02_19) to mastr_db.load("wind", ...),
which reads the daily-refreshable open-mastr SQLite snapshot. off_shore now
comes straight from the decoded Seelage column (Nordsee / Ostsee / None),
replacing the bundesland-string apply() heuristic with a vectorised assign.
open-mastr's two bulk-download paths report progress without a usable
transfer rate: the subset path (db-mastr-core etc.) drives a per-file
tqdm bar whose rate and ETA swing wildly because file sizes differ by
orders of magnitude, and the full-download path labels its 1 MiB-chunk
bar with unit="" so the rate prints as a bare /s.

Add scripts/mastr_download.py, a thin wrapper that patches both paths
before invoking Mastr().download:

- Subset path: re-drive the bar by bytes (summing each member's
  compressed size from the remote zip's central directory) for a real
  MB/s rate and a byte-accurate ETA. Extraction logic mirrors upstream.
- Full path: relabel the chunk bar's unit to MB (each update already
  consumes one 1 MiB chunk).

Repoint all db-mastr-* pixi tasks to the wrapper. Task names are
unchanged, so docs and README stay valid. Keeping the patch in-repo
makes it durable across pixi reinstalls instead of editing site-packages.
render_wind_gif.py extends the snapshot loop past today to PLANNED_HORIZON
(default 2028-12-31, env MASTR_PLANNED_HORIZON) and folds In-Planung units
into those future frames only, via effective_date. Future frames carry an
"incl. planned pipeline" title note; historical frames stay commissioned-only
and unchanged. aggregate_by_unit gains an include_planned flag (mirrors the
BESS aggregator); wind + PV load_for_pipeline now select
GeplantesInbetriebnahmedatum.

load_from_bulk derives wind off_shore from the authoritative MaStR seelage
column (Nordsee / Ostsee) when present, instead of a longitude split; the
column was already fetched by load_for_pipeline but left unused.

mastr_db.load() / load_for_pipeline() convert only the date columns actually
present in the result, replacing read_sql(parse_dates=...) which silently
ignored names absent from a column whitelist.

Rename animations *-may2026 to *-2028-planned and re-render all three at
monthly cadence. Update README, docs/index.html, CHANGELOG.
…alues

open-mastr's replace_mastr_katalogeintraege casts catalog columns with
.astype("float"); WeitereBrennstoffe can hold comma-separated codes
(e.g. "2442, 2442") that crash the cast and silently drop the entire
combustion table (gas/coal/oil) to 0 rows, logged as ERROR only. Re-drive
the catalog casts through pd.to_numeric(errors="coerce") so the
unparseable multi-code value (unused downstream) is dropped and the table
parses. Restores ~93.6k combustion units. Verified via full db-mastr-all.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants