Version 04 als eigenes Repository

Erster Commit des Strangs "Optimierte Entscheidungsfindung mit Python"
(Version 04). Die Historie der 71 Commits bis zur Trennung bleibt im
uebergeordneten Repository OR_mit_Python liegen, das ab jetzt nur noch
Version_03 (eingefroren) verwaltet und Version_04/ ignoriert.

Bewusst kein "git subtree split": Der Pfad Version_04/ existiert erst seit
der Verzeichnistrennung, ein Split braechte daher nur 7 der 41 einschlaegigen
Commits - eine Teilhistorie, die vollstaendig aussieht und es nicht ist.

Stand: 5 Teile, 23 Kapitel, 5 Anhaenge, 292 Abschnitte, 703 Querverweise,
325 Indexmarken, 73 Beispielprogramme, 32 SVGs, 4 Plotly-Figuren,
25 Notebooks, PDF mit 715 Seiten.

Zusaetzlich in diesem Commit:

* pyproject.toml mit Abhaengigkeitsgruppen finance, large-scale, api,
  figures, dev, empfehlungen. Die abgedruckte requirements.txt bleibt
  unveraendert daneben bestehen. ortools steht in der Grundausstattung,
  highspy erst in [large-scale] - so kann der HiGHS-Symbolkonflikt bei der
  schlanken Installation gar nicht erst auftreten.

* Dabei zwei Funde: graphviz wird von erzeuge_architektur_diagramme.py
  importiert, fehlt aber in requirements.txt (jetzt in [figures]); pymoo
  steht in requirements.txt, wird aber von keinem Programm importiert,
  sondern nur im Kapitel Metaheuristiken empfohlen (jetzt in
  [empfehlungen]).

* NEUER_TITEL.md nach Kritik_und_Verbesserungsvorschlaege/ verschoben - es
  ist die Vorlage des Titelblatts, kein Bestandteil des Werks. Die beiden
  Fundstellen in PROGRESS.md und erzeuge_titelseite.py nachgezogen.

* PROGRESS.md nannte noch den Untertitel der ersten Fassung; auf den
  tatsaechlichen aus erzeuge_titelseite.py korrigiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dschlueter 2026-09-08 01:20:09 +02:00
commit b7af2f1d9a
468 changed files with 262141 additions and 0 deletions

View file

@ -0,0 +1,338 @@
#!/usr/bin/env python3
# Kraftwerkseinsatz.py
"""
Kapitel Supply-Chain: Welche Bloecke laufen morgen? Und was, wenn kein Wind weht?
Die Kraftwerkseinsatzplanung (englisch Unit Commitment) ist die Aufgabe, an der
sich in der Energiewirtschaft alles entscheidet: Fuer jede Stunde des naechsten
Tages muss feststehen, welche Bloecke am Netz sind. Ein Kernblock braucht acht
Stunden Mindestlaufzeit und 40.000 Euro Anfahrkosten - wer ihn abschaltet, hat
ihn fuer den Rest des Tages verloren.
Das Besondere liegt in der Zeitstruktur, und es geht ueber das zweistufige
Modell aus dem Kapitel Unsicherheit hinaus:
STUFE 1 Das AN/AUS je Block und Stunde. Binaer, und es steht am Vorabend
fest - bevor irgendjemand weiss, wie viel Wind morgen weht.
STUFE 2 Die Fahrweise: wie viel jeder laufende Block liefert. Das darf
sich stundenweise an die Wirklichkeit anpassen.
Die erste Stufe ist also GANZZAHLIG und szenariouebergreifend gleich, die
zweite kontinuierlich und je Szenario verschieden. Genau diese Kombination
macht das Problem interessant - und sie ist der Grund, warum ein Plan, der auf
den Wind-Erwartungswert gerechnet wurde, in der Wirklichkeit teuer wird.
Das Programm zeigt drei Plaene auf denselben Daten:
1. Deterministisch: gerechnet mit dem Wind-ERWARTUNGSWERT, danach gegen 40
Szenarien ausgewertet.
2. Zweistufig: der Commitment-Plan sieht alle 40 Szenarien.
3. Mit Versorgungssicherheit: die Nichtdeckung in den schlechtesten Faellen
wird begrenzt - und der Preis dafuer in Euro je vermiedener MWh
ausgewiesen.
Benoetigt: numpy, ortools
"""
from __future__ import annotations
import time
import numpy as np
from ortools.linear_solver import pywraplp
# (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 = 24
SZENARIEN = 40
FLAUTENANTEIL = 0.15
ALPHA = 0.90 # die schlechtesten 10 % der Szenarien
LASTABWURF = 3_000.0 # EUR je nicht gedeckter MWh ("value of lost load")
NIEDRIGER_ABWURFPREIS = 300.0 # zum Vergleich: ein Marktpreisdeckel
SAAT = 4
_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 erzeuge_windszenarien(anzahl: int = SZENARIEN, saat: int = SAAT):
"""Windeinspeisung je Szenario und Stunde.
Zwei Zutaten, die zusammen den Unterschied machen: eine breite
lognormale Streuung des Tagesniveaus - und in 15 % der Faelle eine
DUNKELFLAUTE, in der praktisch gar kein Wind weht. Solche seltenen,
extremen Faelle sind der Grund, warum der Erwartungswert als
Planungsgrundlage nicht genuegt.
"""
rng = np.random.default_rng(saat)
tagesniveau = rng.lognormal(0, 0.40, (anzahl, 1))
wind = np.clip(WIND_ERWARTUNG * tagesniveau
+ rng.normal(0, 25, (anzahl, STUNDEN)), 0, 420)
ist_flaute = rng.random(anzahl) < FLAUTENANTEIL
wind[ist_flaute] *= 0.05
return wind, ist_flaute
def plane(wind: np.ndarray, gewichte, cvar_grenze: float | None = None,
abwurfpreis: float = None, zeitlimit: float = 300.0):
"""Commitment (Stufe 1) und Fahrweise je Szenario (Stufe 2) in einem Modell.
'wind' hat die Form (Szenarien, Stunden). Mit einer einzigen Zeile
Windprognose wird daraus die deterministische Planung.
'cvar_grenze' begrenzt den CVaR der Nichtdeckung ueber die Szenarien -
dieselbe Rockafellar-Uryasev-Konstruktion wie im Kapitel CVaR, nur dass
hier keine Verluste in Euro, sondern Megawattstunden begrenzt werden.
"""
abwurfpreis = LASTABWURF if abwurfpreis is None else abwurfpreis
anzahl_szenarien = wind.shape[0]
anzahl_bloecke = len(KRAFTWERKE)
solver = pywraplp.Solver.CreateSolver("SCIP")
solver.SetTimeLimit(int(zeitlimit * 1000))
# --- Stufe 1: binaer, szenariouebergreifend gleich --------------------
laeuft = [[solver.BoolVar(f"laeuft_{k}_{t}") for t in range(STUNDEN)]
for k in range(anzahl_bloecke)]
faehrt_an = [[solver.BoolVar(f"start_{k}_{t}") for t in range(STUNDEN)]
for k in range(anzahl_bloecke)]
# --- Stufe 2: kontinuierlich, je Szenario -----------------------------
leistung = [[[solver.NumVar(0, KRAFTWERKE[k][2], f"p_{j}_{k}_{t}")
for t in range(STUNDEN)] for k in range(anzahl_bloecke)]
for j in range(anzahl_szenarien)]
nichtdeckung = [[solver.NumVar(0, solver.infinity(), f"y_{j}_{t}")
for t in range(STUNDEN)] for j in range(anzahl_szenarien)]
for k, (_, pmin, pmax, _, _, mindestlaufzeit) in enumerate(KRAFTWERKE):
for t in range(STUNDEN):
# Anfahren erkennen: aus im Vortakt, an im aktuellen
vorher = laeuft[k][t - 1] if t > 0 else 0
solver.Add(faehrt_an[k][t] >= laeuft[k][t] - vorher)
# Mindestlaufzeit: wer anfaehrt, laeuft die naechsten Stunden weiter
for spaeter in range(t, min(STUNDEN, t + mindestlaufzeit)):
solver.Add(laeuft[k][spaeter] >= faehrt_an[k][t])
# Ein laufender Block liefert zwischen Mindest- und Nennleistung,
# ein stehender gar nichts. Das koppelt beide Stufen.
for j in range(anzahl_szenarien):
solver.Add(leistung[j][k][t] >= pmin * laeuft[k][t])
solver.Add(leistung[j][k][t] <= pmax * laeuft[k][t])
for j in range(anzahl_szenarien):
for t in range(STUNDEN):
solver.Add(sum(leistung[j][k][t] for k in range(anzahl_bloecke))
+ float(wind[j, t]) + nichtdeckung[j][t] >= LAST[t])
# --- Versorgungssicherheit als CVaR-Schranke --------------------------
if cvar_grenze is not None:
schwelle = solver.NumVar(-solver.infinity(), solver.infinity(), "schwelle")
ueberschuss = [solver.NumVar(0, solver.infinity(), f"u_{j}")
for j in range(anzahl_szenarien)]
for j in range(anzahl_szenarien):
solver.Add(ueberschuss[j] >= sum(nichtdeckung[j][t]
for t in range(STUNDEN)) - schwelle)
solver.Add(schwelle + (1.0 / (anzahl_szenarien * (1 - ALPHA)))
* sum(ueberschuss) <= cvar_grenze)
anfahrkosten = sum(KRAFTWERKE[k][4] * faehrt_an[k][t]
for k in range(anzahl_bloecke) for t in range(STUNDEN))
betriebskosten = sum(
gewichte[j] * sum(KRAFTWERKE[k][3] * leistung[j][k][t]
for k in range(anzahl_bloecke) for t in range(STUNDEN))
for j in range(anzahl_szenarien))
abwurfkosten = sum(gewichte[j] * abwurfpreis
* sum(nichtdeckung[j][t] for t in range(STUNDEN))
for j in range(anzahl_szenarien))
solver.Minimize(anfahrkosten + betriebskosten + abwurfkosten)
beginn = time.perf_counter()
status = solver.Solve()
dauer = time.perf_counter() - beginn
if status not in (pywraplp.Solver.OPTIMAL, pywraplp.Solver.FEASIBLE):
return None, dauer, False
plan = np.array([[laeuft[k][t].solution_value() for t in range(STUNDEN)]
for k in range(anzahl_bloecke)]).round()
return plan, dauer, status == pywraplp.Solver.OPTIMAL
def bewerte(plan: np.ndarray, wind: np.ndarray, abwurfpreis: float = None):
"""Commitment steht fest - nur noch die Fahrweise je Szenario optimieren.
Das ist die ehrliche Bewertung eines Plans: Er wird der Wirklichkeit
ausgesetzt und darf nur noch das anpassen, was sich am Tag selbst
anpassen laesst.
"""
abwurfpreis = LASTABWURF if abwurfpreis is None else abwurfpreis
anzahl_bloecke = len(KRAFTWERKE)
kosten, fehlmengen = [], []
for j in range(wind.shape[0]):
solver = pywraplp.Solver.CreateSolver("GLOP")
leistung = [[solver.NumVar(0, KRAFTWERKE[k][2], f"p_{k}_{t}")
for t in range(STUNDEN)] for k in range(anzahl_bloecke)]
fehlt = [solver.NumVar(0, solver.infinity(), f"y_{t}")
for t in range(STUNDEN)]
for k, (_, pmin, pmax, _, _, _) in enumerate(KRAFTWERKE):
for t in range(STUNDEN):
solver.Add(leistung[k][t] >= pmin * plan[k, t])
solver.Add(leistung[k][t] <= pmax * plan[k, t])
for t in range(STUNDEN):
solver.Add(sum(leistung[k][t] for k in range(anzahl_bloecke))
+ float(wind[j, t]) + fehlt[t] >= LAST[t])
solver.Minimize(
sum(KRAFTWERKE[k][3] * leistung[k][t]
for k in range(anzahl_bloecke) for t in range(STUNDEN))
+ sum(abwurfpreis * fehlt[t] for t in range(STUNDEN)))
solver.Solve()
kosten.append(solver.Objective().Value())
fehlmengen.append(sum(f.solution_value() for f in fehlt))
anfahrten = sum(KRAFTWERKE[k][4]
* max(0.0, plan[k, t] - (plan[k, t - 1] if t > 0 else 0.0))
for k in range(anzahl_bloecke) for t in range(STUNDEN))
return np.array(kosten) + anfahrten, np.array(fehlmengen)
def cvar(werte: np.ndarray, alpha: float = ALPHA) -> float:
"""Mittelwert der schlechtesten (1-alpha) Faelle."""
schwelle = np.quantile(werte, alpha)
schlechteste = werte[werte >= schwelle]
return float(schlechteste.mean()) if len(schlechteste) else 0.0
def zeige(name: str, plan, wind, abwurfpreis: float = None) -> dict:
kosten, fehl = bewerte(plan, wind, abwurfpreis)
kennzahlen = {"mittel": kosten.mean(), "max": kosten.max(),
"fehl_mittel": fehl.mean(), "fehl_cvar": cvar(fehl),
"betroffen": int((fehl > 0.01).sum()),
"blockstunden": int(plan.sum())}
print(f" {name:<32} {kennzahlen['mittel']:>10,.0f} {kennzahlen['max']:>11,.0f} "
f"{kennzahlen['fehl_mittel']:>9.1f} {kennzahlen['fehl_cvar']:>10.1f} "
f"{kennzahlen['betroffen']:>6}/{len(fehl)}")
return kennzahlen
if __name__ == "__main__":
wind, ist_flaute = erzeuge_windszenarien()
print("=" * 88)
print(" KRAFTWERKSEINSATZ: DER PLAN STEHT, BEVOR DER WIND WEHT")
print("=" * 88)
print(f"{len(KRAFTWERKE)} Bloecke, {STUNDEN} Stunden, {SZENARIEN} Windszenarien "
f"(davon {int(ist_flaute.sum())} Dunkelflauten).")
print(f"Last {LAST.min():.0f} bis {LAST.max():.0f} MW, "
f"Wind im Erwartungswert {WIND_ERWARTUNG.min():.0f} bis "
f"{WIND_ERWARTUNG.max():.0f} MW.")
print(f"Nicht gedeckte Last kostet {LASTABWURF:,.0f} EUR je MWh.\n")
print(f" {'Planungsgrundlage':<32} {'Kosten':>10} {'Kosten':>11} "
f"{'Fehlmenge':>9} {'Fehlmenge':>10} {'Szen. mit':>12}")
print(f" {'':<32} {'im Mittel':>10} {'schlimmst.':>11} "
f"{'Mittel':>9} {'CVaR 90%':>10} {'Abwurf':>12}")
print(" " + "-" * 86)
# --- 1. Deterministisch ----------------------------------------------
plan_det, dauer_det, _ = plane(WIND_ERWARTUNG.reshape(1, -1), [1.0])
det = zeige("nur Wind-Erwartungswert", plan_det, wind)
# --- 2. Zweistufig, risikoneutral ------------------------------------
gleich = [1.0 / SZENARIEN] * SZENARIEN
plan_sto, dauer_sto, optimal_sto = plane(wind, gleich)
sto = zeige("alle 40 Szenarien", plan_sto, wind)
# --- 3. Mit Versorgungssicherheit ------------------------------------
plan_sicher, dauer_sicher, optimal_sicher = plane(wind, gleich, cvar_grenze=0.0)
sicher = zeige("+ Nichtdeckung CVaR 90 % = 0", plan_sicher, wind)
print(f"\n Rechenzeiten: {dauer_det:.1f}s / {dauer_sto:.1f}s / {dauer_sicher:.1f}s "
f"(alle beweisbar optimal: {optimal_sto and optimal_sicher})")
# --- Was die Zeilen bedeuten -----------------------------------------
print("\n" + "-" * 88)
print("Was der Erwartungswert-Plan anrichtet\n")
print(f" Er ist auf dem Papier der billigste - gerechnet auf den mittleren")
print(f" Wind kostet er weniger als jeder andere. In der Wirklichkeit liegt")
print(f" er {det['mittel'] / sto['mittel'] - 1:+.0%} ueber dem zweistufigen Plan, und sein "
f"schlimmster Tag")
print(f" kostet {det['max'] / sto['max']:.1f}-mal so viel.")
print(f"\n Der Grund steht in der letzten Spalte: In {det['betroffen']} von {SZENARIEN} "
f"Szenarien muss")
print(f" Last abgeworfen werden. Was fehlt, sind nicht Kraftwerke, sondern")
print(f" ANGEFAHRENE Kraftwerke - und ein Block mit acht Stunden")
print(f" Mindestlaufzeit laesst sich um 18 Uhr nicht mehr herbeirufen.")
print(f"\n Der Unterschied im Plan ist klein:")
for name, plan in [("Erwartungswert", plan_det), ("zweistufig", plan_sto),
("mit Sicherheit", plan_sicher)]:
laufend = plan.sum(axis=0).astype(int)
print(f" {name:<16} {''.join(str(x) for x in laufend)} "
f"({int(plan.sum())} Blockstunden)")
print(f"\n Der zweistufige Plan haelt {sto['blockstunden'] - det['blockstunden']} "
f"Blockstunden mehr vor - das genuegt,")
print(f" um alle {det['betroffen']} Lastabwuerfe zu vermeiden.")
# --- Der Preis der Sicherheit ----------------------------------------
print("\n" + "-" * 88)
print("Was Versorgungssicherheit kostet\n")
print(f" Bei {LASTABWURF:,.0f} EUR/MWh kostet die CVaR-Schranke NICHTS: Der")
print(" risikoneutrale Plan haelt sie schon ein. Das ist kein Zufall - bei")
print(" diesem Preis lohnt sich Vorhaltung bereits im Erwartungswert.")
print("\n Interessant wird die Schranke dort, wo der Schaden ZU NIEDRIG")
print(" bepreist ist. Dieselbe Rechnung mit einem Marktpreisdeckel von")
print(f" {NIEDRIGER_ABWURFPREIS:,.0f} EUR/MWh statt {LASTABWURF:,.0f}:\n")
print(f" {'Planungsgrundlage':<32} {'Kosten':>10} {'Kosten':>11} "
f"{'Fehlmenge':>9} {'Fehlmenge':>10} {'Szen. mit':>12}")
print(f" {'':<32} {'im Mittel':>10} {'schlimmst.':>11} "
f"{'Mittel':>9} {'CVaR 90%':>10} {'Abwurf':>12}")
print(" " + "-" * 86)
plan_billig, _, _ = plane(wind, gleich, abwurfpreis=NIEDRIGER_ABWURFPREIS)
billig = zeige("risikoneutral, billiger Abwurf", plan_billig, wind,
NIEDRIGER_ABWURFPREIS)
plan_billig_sicher, _, _ = plane(wind, gleich, cvar_grenze=0.0,
abwurfpreis=NIEDRIGER_ABWURFPREIS)
billig_sicher = zeige("+ CVaR 90 % der Fehlmenge = 0", plan_billig_sicher, wind,
NIEDRIGER_ABWURFPREIS)
aufpreis = billig_sicher["mittel"] - billig["mittel"]
vermieden = billig["fehl_cvar"] - billig_sicher["fehl_cvar"]
print(f"\n Jetzt greift die Schranke: Der risikoneutrale Plan nimmt in")
print(f" {billig['betroffen']} von {SZENARIEN} Szenarien einen Lastabwurf in Kauf, weil er bei")
print(f" {NIEDRIGER_ABWURFPREIS:,.0f} EUR/MWh billiger ist als das Vorhalten eines Blocks.")
print(f"\n Aufpreis fuer die Sicherheit: {aufpreis:,.0f} EUR je Tag")
if vermieden > 0.01:
print(f" Vermiedene Fehlmenge (CVaR 90 %): {vermieden:.1f} MWh")
print(f" -> {aufpreis / vermieden:,.0f} EUR je vermiedener MWh")
print(f"\n Diese Zahl ist die Entscheidungsgrundlage - genau wie die Spalte")
print(f" 'EUR je kg' im Kapitel Mehrziel. Sie sagt der Aufsicht, was ihre")
print(f" Versorgungssicherheitsvorgabe tatsaechlich kostet, statt darueber")
print(f" zu streiten, wie wichtig sie ist.")
print("\n" + "=" * 88)
print(" WAS MAN DARAUS MITNIMMT")
print("=" * 88)
print("1. Die erste Stufe ist binaer und traege. Was am Vorabend nicht")
print(" angefahren wurde, steht am naechsten Tag nicht zur Verfuegung -")
print(" egal, wie hoch der Preis dann steigt.")
print("2. Deshalb ist der Erwartungswert als Planungsgrundlage nicht nur")
print(" ungenau, sondern SYSTEMATISCH zu knapp: Er plant fuer einen Tag,")
print(" der so nie eintritt, und laesst keine Reserve fuer die Haelfte")
print(" der Faelle, in denen es schlechter kommt.")
print("3. Der Ausweg ist keine bessere Windprognose, sondern ein Modell,")
print(" das die Szenarien SIEHT. Die Rechenzeit dafuer liegt hier im")
print(" Sekundenbereich - der Aufwand ist die Modellierung, nicht der")
print(" Solver.")
print("4. Wo der Lastabwurf teuer genug bepreist ist, deckt schon die")
print(" Minimierung der ERWARTETEN Kosten die Extremfaelle mit ab. Die")
print(" Risikoschranke wird genau dann gebraucht, wenn der Schaden ZU")
print(" NIEDRIG bepreist ist - was bei Versorgungssicherheit die Regel")
print(" ist, weil ein Marktpreisdeckel nicht den volkswirtschaftlichen")
print(" Schaden abbildet. Dann liefert sie den Preis der Vorgabe in Euro")
print(" je vermiedener MWh, und darueber laesst sich verhandeln.")
print("=" * 88)