operations_research/bilder_04/erzeuge_wirkung.py
dschlueter 360912dbfa Aufgeraeumt: 33 PNG-Zweitfassungen, Bau-Ueberbleibsel, Ausgabepfade
Drei Aufraeumarbeiten - und zwei Funde, die dabei auffielen.

1. DIE PNG-ZWEITFASSUNGEN SIND WEG. Jedes Diagramm lag doppelt vor, als SVG
und als PNG, und kein einziges src=/href= in der Website zeigte je auf ein
PNG. Der Build kopierte sie trotzdem mit: 3,3 MB im Repository plus 3,3 MB,
die bei jeder Veroeffentlichung auf den Webserver gingen. Die 15 Generatoren
schreiben jetzt nur noch SVG, die Docstrings sind mitgezogen. Vor dem
Loeschen geprueft: Jedes PNG hatte sein gleichnamiges SVG, alle 33 waren
versioniert.

FUND 1: erzeuge_kap06_gantt.py folgte als einziger Generator nicht der
Konvention - weder svg.hashsalt noch metadata={"Date": None}. Sein SVG trug
einen echten Zeitstempel und bei jedem Lauf andere clip-path-IDs, war also
nie byteidentisch reproduzierbar, obwohl CLAUDE.md genau das fuer alle
Generatoren festhaelt. Aufgefallen nur, weil nach der PNG-Umstellung 32 von
33 SVGs bitgleich blieben und eines nicht. Jetzt byteidentisch ueber zwei
Laeufe.

FUND 2: spiegle_bilder() legte leere Verzeichnisse auf dem Webserver an. Der
Dateifilter arbeitete korrekt, aber os.walk durchlief auch __pycache__/, und
os.makedirs() erzeugte es am Ziel. Die Verzeichnisliste wird jetzt vorher
gefiltert. Gegengetestet.

2. BAU-UEBERBLEIBSEL entfernt (alle ignoriert und neu erzeugbar):
svg-inkscape/, build_v04.log, .pytest_cache/, Programme/output/, vier
__pycache__/ und die Excel-Mappen. Arbeitsbaum 36 -> 33 MB, danach null
ignorierte Ueberbleibsel.

3. Excel_Bruecke.py SCHREIBT NEBEN DAS SKRIPT statt ins
Arbeitsverzeichnis. Es benutzte blanke relative Namen; wer es aus der
Repository-Wurzel startete, verstreute dort produktionsmix.xlsx und
produktionsmix_ergebnis.xlsx. Jetzt wie die vier anderen schreibenden
Programme ueber os.path.dirname(os.path.abspath(__file__)). Nachgemessen:
Lauf aus der Wurzel legt dort null Dateien ab.

Ausserdem git gc: 653 lose Objekte gepackt, .git von 71 MB auf 28 MB -
reines Repacken, kein Inhalt beruehrt.

Geprueft: alle 33 im Buch referenzierten SVGs vorhanden, in Quelle und
Website; 16 Generatoren fehlerfrei; keine fehlenden Bilder im LaTeX-Lauf;
33 pytest-Tests; PDF unveraendert 760 Seiten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:22:26 +02:00

252 lines
11 KiB
Python

#!/usr/bin/env python3
# erzeuge_wirkung.py
"""
Erzeugt das Wasserfalldiagramm zum Kapitel Supply-Chain und Energieeinsatz:
bilder_04/kap_supplychain_wirkung.svg statisch, fuer das PDF
Die Frage, die es beantwortet, ist die des Managements: WOFUER genau kostet
der Erwartungswert-Plan 149 % mehr? Die Tabelle im Kapitel nennt nur die
Summen (874.870 EUR gegen 351.356 EUR). Der Wasserfall zerlegt die
Differenz - und die Zerlegung ist der eigentliche Befund:
* Die ANFAHRKOSTEN sind in beiden Plaenen gleich (58.000 EUR). Der
Erwartungswert-Plan spart also nicht dort, wo man es vermuten wuerde.
* Er spart 12.725 EUR Brennstoff, weil er weniger Blockstunden vorhaelt.
* Und zahlt dafuer 536.239 EUR Lastabwurf.
Das ist das 42-fache dessen, was er einspart. Genau diese Gegenueberstellung
laesst sich in eine Vorlage uebernehmen, eine Summendifferenz nicht.
Verfahren und Instanz sind wortgleich zu Kraftwerkseinsatz.py, und gerechnet
wird hier ERNEUT - so koennen Diagramm und Buchtext nicht auseinanderlaufen.
Der Nachbau reproduziert die im Kapitel abgedruckten Summen exakt; das ist
die Probe darauf, dass beide dasselbe Modell meinen. Zusaetzlich trennt er
Brennstoff- und Abwurfkosten, was bewerte() im Buchprogramm nicht tut, weil
es dort nicht gebraucht wird.
Aufruf (aus dem Repository-Wurzelverzeichnis):
python3 bilder_04/erzeuge_wirkung.py
Benoetigt: numpy, matplotlib, ortools
"""
from __future__ import annotations
import os
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from ortools.linear_solver import pywraplp
# Reproduzierbare SVG-Ausgabe (siehe erzeuge_titelseite.py)
plt.rcParams["svg.hashsalt"] = "or-mit-python-v04"
BASIS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BILDER = os.path.join(BASIS, "bilder_04")
INDIGO, CYAN, AMBER, GRUEN = "#4338ca", "#0891b2", "#b45309", "#059669"
GRAU = "#64748b"
# --- Instanz: wortgleich zu Kraftwerkseinsatz.py ----------------------------
# (Name, Mindestleistung, Nennleistung, Grenzkosten, Anfahrkosten, Mindestlaufzeit)
KRAFTWERKE = [
("Kernblock", 200, 600, 22, 40_000, 8),
("Braunkohle", 100, 400, 35, 18_000, 6),
("Steinkohle", 80, 300, 48, 12_000, 4),
("Gas GuD", 50, 250, 72, 6_000, 2),
("Gasturbine", 20, 150, 105, 1_500, 1),
]
STUNDEN, SZENARIEN, FLAUTENANTEIL, SAAT = 24, 40, 0.15, 4
LASTABWURF = 3_000.0 # EUR je nicht gedeckter MWh
_t = np.arange(STUNDEN)
LAST = 620 + 260 * np.sin((_t - 7) / 24 * 2 * np.pi) + 90 * np.sin((_t - 4) / 12 * 2 * np.pi)
WIND_ERWARTUNG = np.maximum(0.0, 160 + 130 * np.sin((_t - 14) / 24 * 2 * np.pi))
def euro(betrag: float, vorzeichen: bool = False) -> str:
"""Deutsche Tausenderpunkte. Nur auf ZAHLEN anwenden, nie auf ganze Saetze."""
text = f"{betrag:+,.0f}" if vorzeichen else f"{betrag:,.0f}"
return text.replace(",", ".") + ""
def windszenarien() -> tuple[np.ndarray, np.ndarray]:
rng = np.random.default_rng(SAAT)
niveau = rng.lognormal(0, 0.40, (SZENARIEN, 1))
wind = np.clip(WIND_ERWARTUNG * niveau
+ rng.normal(0, 25, (SZENARIEN, STUNDEN)), 0, 420)
flaute = rng.random(SZENARIEN) < FLAUTENANTEIL
wind[flaute] *= 0.05
return wind, flaute
def plane(wind: np.ndarray, gewichte: np.ndarray) -> np.ndarray:
"""Stufe 1 (An/Aus, szenariouebergreifend) und Stufe 2 (Fahrweise je
Szenario) in einem Modell - wie plane() in Kraftwerkseinsatz.py."""
n_s, n_b = wind.shape[0], len(KRAFTWERKE)
s = pywraplp.Solver.CreateSolver("SCIP")
s.SetTimeLimit(300_000)
laeuft = [[s.BoolVar(f"l{k}_{t}") for t in range(STUNDEN)] for k in range(n_b)]
start = [[s.BoolVar(f"s{k}_{t}") for t in range(STUNDEN)] for k in range(n_b)]
p = [[[s.NumVar(0, KRAFTWERKE[k][2], f"p{j}_{k}_{t}") for t in range(STUNDEN)]
for k in range(n_b)] for j in range(n_s)]
y = [[s.NumVar(0, s.infinity(), f"y{j}_{t}") for t in range(STUNDEN)]
for j in range(n_s)]
for k, (_, pmin, pmax, _, _, mindestlaufzeit) in enumerate(KRAFTWERKE):
for t in range(STUNDEN):
vorher = laeuft[k][t - 1] if t > 0 else 0
s.Add(start[k][t] >= laeuft[k][t] - vorher)
for spaeter in range(t, min(STUNDEN, t + mindestlaufzeit)):
s.Add(laeuft[k][spaeter] >= start[k][t])
for j in range(n_s):
s.Add(p[j][k][t] >= pmin * laeuft[k][t])
s.Add(p[j][k][t] <= pmax * laeuft[k][t])
for j in range(n_s):
for t in range(STUNDEN):
s.Add(sum(p[j][k][t] for k in range(n_b))
+ float(wind[j, t]) + y[j][t] >= LAST[t])
s.Minimize(
sum(KRAFTWERKE[k][4] * start[k][t]
for k in range(n_b) for t in range(STUNDEN))
+ sum(gewichte[j] * sum(KRAFTWERKE[k][3] * p[j][k][t]
for k in range(n_b) for t in range(STUNDEN))
for j in range(n_s))
+ sum(gewichte[j] * LASTABWURF * sum(y[j][t] for t in range(STUNDEN))
for j in range(n_s)))
s.Solve()
return np.array([[laeuft[k][t].solution_value() for t in range(STUNDEN)]
for k in range(n_b)]).round()
def zerlege(plan: np.ndarray, wind: np.ndarray) -> dict:
"""Setzt einen fertigen Plan der Wirklichkeit aus und trennt dabei die
Kostenarten - das ist der Zusatz gegenueber bewerte() im Buchprogramm."""
n_b = len(KRAFTWERKE)
brennstoff, abwurf, fehlmenge = [], [], []
for j in range(wind.shape[0]):
s = pywraplp.Solver.CreateSolver("GLOP")
p = [[s.NumVar(0, KRAFTWERKE[k][2], f"p{k}_{t}") for t in range(STUNDEN)]
for k in range(n_b)]
y = [s.NumVar(0, s.infinity(), f"y{t}") for t in range(STUNDEN)]
for k, (_, pmin, pmax, _, _, _) in enumerate(KRAFTWERKE):
for t in range(STUNDEN):
s.Add(p[k][t] >= pmin * plan[k, t])
s.Add(p[k][t] <= pmax * plan[k, t])
for t in range(STUNDEN):
s.Add(sum(p[k][t] for k in range(n_b))
+ float(wind[j, t]) + y[t] >= LAST[t])
s.Minimize(sum(KRAFTWERKE[k][3] * p[k][t]
for k in range(n_b) for t in range(STUNDEN))
+ sum(LASTABWURF * y[t] for t in range(STUNDEN)))
s.Solve()
brennstoff.append(sum(KRAFTWERKE[k][3] * p[k][t].solution_value()
for k in range(n_b) for t in range(STUNDEN)))
fehlt = sum(v.solution_value() for v in y)
fehlmenge.append(fehlt)
abwurf.append(LASTABWURF * fehlt)
anfahrt = sum(KRAFTWERKE[k][4]
* max(0.0, plan[k, t] - (plan[k, t - 1] if t > 0 else 0.0))
for k in range(n_b) for t in range(STUNDEN))
fehlmenge = np.array(fehlmenge)
return {"anfahrt": float(anfahrt),
"brennstoff": float(np.mean(brennstoff)),
"abwurf": float(np.mean(abwurf)),
"fehlmenge": float(fehlmenge.mean()),
"szenarien_mit_abwurf": int((fehlmenge > 1e-6).sum()),
"gesamt": float(anfahrt + np.mean(brennstoff) + np.mean(abwurf))}
def zeichne(zwei: dict, erw: dict) -> None:
"""Wasserfall vom guten zum schlechten Plan: Was wird gespart, was kostet es."""
d_anfahrt = erw["anfahrt"] - zwei["anfahrt"]
d_brennstoff = erw["brennstoff"] - zwei["brennstoff"]
d_abwurf = erw["abwurf"] - zwei["abwurf"]
stufen = [("Zweistufiger\nPlan", zwei["gesamt"], None),
("Brennstoff", d_brennstoff, "delta"),
("Anfahren", d_anfahrt, "delta"),
("Lastabwurf", d_abwurf, "delta"),
("Erwartungswert-\nPlan", erw["gesamt"], None)]
figur, achse = plt.subplots(figsize=(10.2, 5.6))
unten = 0.0
for i, (name, wert, art) in enumerate(stufen):
if art is None:
achse.bar(i, wert, 0.62, color=INDIGO, zorder=3)
achse.text(i, wert + 18_000, euro(wert),
ha="center", va="bottom", fontsize=11, fontweight="bold",
color=INDIGO)
unten = wert
continue
farbe = AMBER if wert > 0 else GRUEN
start = unten if wert > 0 else unten + wert
achse.bar(i, abs(wert), 0.62, bottom=start, color=farbe, zorder=3)
# Nullbalken (hier: Anfahren) sichtbar machen, sonst fehlt die Aussage
if abs(wert) < 5_000:
achse.plot([i - 0.31, i + 0.31], [start, start], color=GRAU,
linewidth=2.2, zorder=4)
achse.text(i, max(start + abs(wert), unten) + 18_000,
euro(wert, vorzeichen=True),
ha="center", va="bottom", fontsize=10.5, color=farbe,
fontweight="bold")
unten = unten + wert
if i < len(stufen) - 1:
achse.plot([i - 0.31, i + 0.69], [unten, unten], color=GRAU,
linewidth=0.9, linestyle=":", zorder=2)
achse.set_xticks(range(len(stufen)))
achse.set_xticklabels([n for n, _, _ in stufen], fontsize=10.5)
achse.set_ylabel("Erwartete Tageskosten")
achse.set_ylim(0, erw["gesamt"] * 1.18)
achse.yaxis.set_major_formatter(
plt.FuncFormatter(lambda v, _: f"{v/1000:,.0f}k €".replace(",", ".")))
achse.grid(axis="y", linestyle=":", alpha=0.45, zorder=0)
for rand in ("top", "right"):
achse.spines[rand].set_visible(False)
verhaeltnis = abs(d_abwurf / d_brennstoff) if d_brennstoff else float("inf")
# Zahlen einzeln setzen: Ein .replace(",", ".") auf den ganzen Satz wuerde
# auch das Satzkomma treffen.
achse.set_title(
"Was der Erwartungswert-Plan spart — und was er dafür bezahlt\n"
f"Ersparnis {euro(abs(d_brennstoff))} Brennstoff, Preis "
f"{euro(d_abwurf)} Lastabwurf: das {verhaeltnis:.0f}-fache",
fontsize=12.5, pad=14)
achse.text(0.5, -0.155,
f"Lastabwurf in {erw['szenarien_mit_abwurf']} von {SZENARIEN} "
f"Szenarien statt in {zwei['szenarien_mit_abwurf']}; "
f"Anfahrkosten in beiden Plänen gleich.",
transform=achse.transAxes, ha="center", fontsize=10, color=GRAU)
figur.tight_layout()
os.makedirs(BILDER, exist_ok=True)
for endung in ("svg",):
pfad = os.path.join(BILDER, f"kap_supplychain_wirkung.{endung}")
figur.savefig(pfad, format=endung, dpi=160,
metadata={"Date": None} if endung == "svg" else None)
print(f"geschrieben: {pfad}")
plt.close(figur)
if __name__ == "__main__":
wind, flaute = windszenarien()
gleich = np.full(SZENARIEN, 1.0 / SZENARIEN)
print(f"{SZENARIEN} Windszenarien, davon {int(flaute.sum())} Dunkelflauten")
erw = zerlege(plane(WIND_ERWARTUNG.reshape(1, -1), np.array([1.0])), wind)
zwei = zerlege(plane(wind, gleich), wind)
for name, w in (("Erwartungswert", erw), ("zweistufig", zwei)):
print(f" {name:<16} Anfahrt {w['anfahrt']:>9,.0f} "
f"Brennstoff {w['brennstoff']:>9,.0f} "
f"Abwurf {w['abwurf']:>9,.0f} = {w['gesamt']:>9,.0f}")
print(f" Aufschlag des Erwartungswert-Plans: "
f"{erw['gesamt'] / zwei['gesamt'] - 1:+.0%}")
zeichne(zwei, erw)