253 lines
11 KiB
Python
253 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
|
||
|
|
bilder_04/kap_supplychain_wirkung.png Rasterfassung
|
||
|
|
|
||
|
|
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", "png"):
|
||
|
|
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)
|