Kapitel 17: Supply-Chain und Energieeinsatz unter Unsicherheit

📌 Kapitel auf einen Blick

Worum geht es? Um einen zusammenhängenden Industriefall, der die Werkzeuge aus Teil II und III zusammenführt: gemischt-ganzzahlige Entscheidungen, Szenarien, Risikomaße — an der Kraftwerkseinsatzplanung, der klassischen Aufgabe der Energiewirtschaft.

Voraussetzungen: Kapitel 6, Kapitel 12 (zweistufige stochastische Programmierung) und Kapitel 20.

Danach können Sie: ein Modell aufsetzen, in dem die erste Stufe ganzzahlig ist und vor dem Zufall feststeht; erklären, warum eine Planung auf den Erwartungswert systematisch zu knapp ausfällt; und den Preis einer Versorgungssicherheitsvorgabe in Euro je vermiedener Megawattstunde ausweisen.

Zeitbedarf: ca. 4 Stunden.

Programme:
Kraftwerkseinsatz.py

Notebook: supplychain.ipynb
In Google Colab öffnen


17.1 In 5 Minuten gelöst

🚀 In 5 Minuten gelöst: Warum „billigstes Kraftwerk zuerst” falsch ist

Zwei Kraftwerke können 120 MW liefern. Der Kernblock produziert für 22 €/MWh, die Gasturbine für 105 € — fünfmal so teuer. Die Merit-Order-Regel sagt: Kernblock.

Sie übersieht die Anfahrkosten: 40 000 € gegen 1 500 €.

# Zwei Kraftwerke, die 120 MW decken sollen - aber wie lange?
#                        Grenzkosten   Anfahrkosten
kernblock  = dict(name="Kernblock",  grenz=22,  anfahrt=40_000)
gasturbine = dict(name="Gasturbine", grenz=105, anfahrt=1_500)
LEISTUNG = 120                                   # MW, die gebraucht werden

kosten = lambda kw, stunden: kw["anfahrt"] + kw["grenz"] * LEISTUNG * stunden

print(f"{'Dauer':>7} {'Kernblock':>12} {'Gasturbine':>12}   guenstiger")
for stunden in (1, 2, 3, 4, 5, 6, 8):
    k, g = kosten(kernblock, stunden), kosten(gasturbine, stunden)
    print(f"{stunden:>5} h {k:>12,.0f} {g:>12,.0f}   "
          f"{kernblock['name'] if k < g else gasturbine['name']}")

wechsel = (kernblock["anfahrt"] - gasturbine["anfahrt"]) / (
    (gasturbine["grenz"] - kernblock["grenz"]) * LEISTUNG)
print(f"\nUmschlagpunkt: {wechsel:.1f} Stunden")

Ausgabe:

  Dauer    Kernblock   Gasturbine   guenstiger
    1 h       42,640       14,100   Gasturbine
    2 h       45,280       26,700   Gasturbine
    3 h       47,920       39,300   Gasturbine
    4 h       50,560       51,900   Kernblock
    5 h       53,200       64,500   Kernblock
    6 h       55,840       77,100   Kernblock
    8 h       61,120      102,300   Kernblock

Umschlagpunkt: 3.9 Stunden

Und jetzt der Punkt. Bis knapp vier Stunden ist die fünfmal teurere Gasturbine die günstigere Wahl. Erst danach hat der Kernblock seine Anfahrkosten wieder eingespielt.

Die Merit-Order — Kraftwerke nach Grenzkosten sortieren und von unten auffüllen — ist damit nur für einen einzelnen Zeitpunkt richtig. Sobald ein Tag geplant wird, hängt jede Stunde an allen anderen: Ein Block, der um 8 Uhr angefahren wird, muss bis mindestens 16 Uhr laufen, und diese Entscheidung fällt am Vorabend.

🎯 Merksatz Anfahrkosten und Mindestlaufzeiten machen aus 24 unabhängigen Stundenentscheidungen ein Problem. Genau deshalb ist die Kraftwerkseinsatzplanung ein MILP und keine Sortierung.

Warum funktioniert das? Der Umschlagpunkt ist eine einfache Rechnung: Die Anfahrkostendifferenz (38 500 €) geteilt durch die Grenzkostendifferenz je Stunde (83 €/MWh × 120 MW = 9 960 €/h) ergibt 3,9 Stunden. Bei zwei Kraftwerken kann man das im Kopf ausrechnen — bei fünf Blöcken und 24 Stunden nicht mehr.


17.2 Lernziele

Nach diesem Kapitel können Sie …

  1. … ein Unit-Commitment-Modell mit Anfahrkosten und Mindestlaufzeiten aufstellen.
  2. … erklären, warum eine binäre erste Stufe das zweistufige Modell aus Kapitel 12 verschärft.
  3. … zeigen, dass eine Planung auf den Erwartungswert nicht nur ungenau, sondern systematisch zu knapp ist.
  4. … eine CVaR-Schranke auf eine physikalische Größe (nicht gedeckte Energie) legen.
  5. … begründen, wann eine Risikoschranke etwas ändert — und wann sie überflüssig ist.

17.3 Die Aufgabe

Fünf Blöcke, 24 Stunden, eine Last zwischen 270 und 804 MW. Dazu Windeinspeisung, die niemand am Vorabend kennt.

Block P_{\min} P_{\max} Grenzkosten Anfahrkosten Mindestlaufzeit
Kernblock 200 600 22 €/MWh 40 000 € 8 h
Braunkohle 100 400 35 €/MWh 18 000 € 6 h
Steinkohle 80 300 48 €/MWh 12 000 € 4 h
Gas GuD 50 250 72 €/MWh 6 000 € 2 h
Gasturbine 20 150 105 €/MWh 1 500 € 1 h

Die Reihenfolge ist typisch: je billiger im Betrieb, desto träger und teurer im Anfahren. Das ist keine Willkür, sondern Physik — ein großer Dampfprozess braucht Stunden, bis er auf Temperatur ist.

Die Zeitstruktur der Entscheidung

Hier geht das Kapitel über Abschnitt 12.5 hinaus:

Stufe Entscheidung Typ wann
1 Welcher Block läuft in welcher Stunde? binär, für alle Szenarien gleich am Vorabend, bevor der Wind bekannt ist
2 Wie viel liefert jeder laufende Block? kontinuierlich, je Szenario verschieden am Tag selbst, laufend anpassbar

Im Kapitel Unsicherheit war die erste Stufe kontinuierlich (Kapazität kaufen, Menge festlegen). Hier ist sie ganzzahlig — und das ändert die Sache grundlegend: Eine Kapazität lässt sich anteilig erhöhen, ein Kraftwerk nicht zu 30 % anfahren.

🔤 Formel-Übersetzer: die Kopplung der beiden Stufen

Mathematik Alltagssprache
u_{k,t} \in \{0,1\} „Läuft Block k in Stunde t?” — eine Antwort für alle Szenarien
p^s_{k,t} \ge P^{\min}_k \, u_{k,t} „Ein laufender Block liefert mindestens seine Mindestleistung.”
p^s_{k,t} \le P^{\max}_k \, u_{k,t} „Ein stehender Block liefert gar nichts.” — dieselbe Big-M-Kopplung wie in Kapitel 6
a_{k,t} \ge u_{k,t} - u_{k,t-1} „Wer eben noch aus war und jetzt an ist, ist angefahren.”
u_{k,\tau} \ge a_{k,t} für \tau = t \dots t{+}L_k Mindestlaufzeit: „Wer anfährt, bleibt L_k Stunden am Netz.”
\sum_k p^s_{k,t} + w^s_t + y^s_t \ge D_t „Erzeugung plus Wind plus Nichtdeckung deckt die Last.”

Die beiden mittleren Zeilen sind der ganze Trick: Sie verbinden eine binäre Größe (u) mit einer kontinuierlichen (p) — und weil u szenarioübergreifend gleich ist, die Szenarien untereinander.


17.4 Das Programm

#!/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)

Erwartete Ausgabe (Laufzeiten hardwareabhängig, die Werte nicht):

========================================================================================
  KRAFTWERKSEINSATZ: DER PLAN STEHT, BEVOR DER WIND WEHT
========================================================================================
5 Bloecke, 24 Stunden, 40 Windszenarien (davon 8 Dunkelflauten).
Last 270 bis 804 MW, Wind im Erwartungswert 30 bis 290 MW.
Nicht gedeckte Last kostet 3,000 EUR je MWh.

  Planungsgrundlage                    Kosten      Kosten Fehlmenge  Fehlmenge    Szen. mit
                                    im Mittel  schlimmst.    Mittel   CVaR 90%       Abwurf
  --------------------------------------------------------------------------------------
  nur Wind-Erwartungswert             874,870   2,504,154     178.7      679.2     28/40
  alle 40 Szenarien                   351,356     415,924       0.0        0.0      0/40
  + Nichtdeckung CVaR 90 % = 0        351,356     415,924       0.0        0.0      0/40

  Rechenzeiten: 0.0s / 1.3s / 1.1s (alle beweisbar optimal: True)

----------------------------------------------------------------------------------------
Was der Erwartungswert-Plan anrichtet

  Er ist auf dem Papier der billigste - gerechnet auf den mittleren
  Wind kostet er weniger als jeder andere. In der Wirklichkeit liegt
  er +149% ueber dem zweistufigen Plan, und sein schlimmster Tag
  kostet 6.0-mal so viel.

  Der Grund steht in der letzten Spalte: In 28 von 40 Szenarien muss
  Last abgeworfen werden. Was fehlt, sind nicht Kraftwerke, sondern
  ANGEFAHRENE Kraftwerke - und ein Block mit acht Stunden
  Mindestlaufzeit laesst sich um 18 Uhr nicht mehr herbeirufen.

  Der Unterschied im Plan ist klein:
    Erwartungswert   111111122222222211111111  (33 Blockstunden)
    zweistufig       111111222222222222222111  (39 Blockstunden)
    mit Sicherheit   111111222222222222222111  (39 Blockstunden)

  Der zweistufige Plan haelt 6 Blockstunden mehr vor - das genuegt,
  um alle 28 Lastabwuerfe zu vermeiden.

----------------------------------------------------------------------------------------
Was Versorgungssicherheit kostet

  Bei 3,000 EUR/MWh kostet die CVaR-Schranke NICHTS: Der
  risikoneutrale Plan haelt sie schon ein. Das ist kein Zufall - bei
  diesem Preis lohnt sich Vorhaltung bereits im Erwartungswert.

  Interessant wird die Schranke dort, wo der Schaden ZU NIEDRIG
  bepreist ist. Dieselbe Rechnung mit einem Marktpreisdeckel von
  300 EUR/MWh statt 3,000:

  Planungsgrundlage                    Kosten      Kosten Fehlmenge  Fehlmenge    Szen. mit
                                    im Mittel  schlimmst.    Mittel   CVaR 90%       Abwurf
  --------------------------------------------------------------------------------------
  risikoneutral, billiger Abwurf      350,512     421,336       3.0       18.4      8/40
  + CVaR 90 % der Fehlmenge = 0       351,356     415,924       0.0        0.0      0/40

  Jetzt greift die Schranke: Der risikoneutrale Plan nimmt in
  8 von 40 Szenarien einen Lastabwurf in Kauf, weil er bei
  300 EUR/MWh billiger ist als das Vorhalten eines Blocks.

  Aufpreis fuer die Sicherheit: 844 EUR je Tag
  Vermiedene Fehlmenge (CVaR 90 %): 18.4 MWh
  -> 46 EUR je vermiedener MWh

  Diese Zahl ist die Entscheidungsgrundlage - genau wie die Spalte
  'EUR je kg' im Kapitel Mehrziel. Sie sagt der Aufsicht, was ihre
  Versorgungssicherheitsvorgabe tatsaechlich kostet, statt darueber
  zu streiten, wie wichtig sie ist.

========================================================================================
  WAS MAN DARAUS MITNIMMT
========================================================================================
1. Die erste Stufe ist binaer und traege. Was am Vorabend nicht
   angefahren wurde, steht am naechsten Tag nicht zur Verfuegung -
   egal, wie hoch der Preis dann steigt.
2. Deshalb ist der Erwartungswert als Planungsgrundlage nicht nur
   ungenau, sondern SYSTEMATISCH zu knapp: Er plant fuer einen Tag,
   der so nie eintritt, und laesst keine Reserve fuer die Haelfte
   der Faelle, in denen es schlechter kommt.
3. Der Ausweg ist keine bessere Windprognose, sondern ein Modell,
   das die Szenarien SIEHT. Die Rechenzeit dafuer liegt hier im
   Sekundenbereich - der Aufwand ist die Modellierung, nicht der
   Solver.
4. Wo der Lastabwurf teuer genug bepreist ist, deckt schon die
   Minimierung der ERWARTETEN Kosten die Extremfaelle mit ab. Die
   Risikoschranke wird genau dann gebraucht, wenn der Schaden ZU
   NIEDRIG bepreist ist - was bei Versorgungssicherheit die Regel
   ist, weil ein Marktpreisdeckel nicht den volkswirtschaftlichen
   Schaden abbildet. Dann liefert sie den Preis der Vorgabe in Euro
   je vermiedener MWh, und darueber laesst sich verhandeln.
========================================================================================

17.5 Der Befund

Planungsgrundlage Kosten im Mittel schlimmster Tag Szenarien mit Lastabwurf
nur Wind-Erwartungswert 874 870 € 2 504 154 € 28 von 40
alle 40 Szenarien 351 356 € 415 924 € 0 von 40

Der Erwartungswert-Plan kostet in der Wirklichkeit 149 % mehr, und sein schlimmster Tag ist sechsmal so teuer.

Wofür genau? Die Summendifferenz von 523 514 € sagt noch nicht, wo sie entsteht. Zerlegt man sie nach Kostenarten, wird aus dem Befund eine Aussage, die man vorlegen kann:

Abb. 17.1: Wofür der Erwartungswert-Plan 149 % mehr bezahlt

Drei Dinge stehen darin, die die Tabelle nicht zeigt. Die Anfahrkosten sind in beiden Plänen gleich (58 000 €) — der Erwartungswert-Plan spart also nicht dort, wo man es vermuten würde. Er spart 12 725 € Brennstoff, weil er weniger Blockstunden vorhält. Und er bezahlt dafür 536 239 € Lastabwurf: das 42-fache seiner Ersparnis.

Genau diese Gegenüberstellung ist es, die eine Investitionsentscheidung trägt. „149 % mehr” ist eine Zahl, über die man streiten kann; „wir sparen 12 725 € und riskieren dafür 536 239 €” ist eine, über die man entscheidet.

📌 Zum Diagramm Es wird von bilder_04/erzeuge_wirkung.py erzeugt, das die Instanz aus Kraftwerkseinsatz.py erneut rechnet statt Zahlen zu übernehmen — und dabei die beiden abgedruckten Summen exakt reproduziert. Die Trennung von Brennstoff- und Abwurfkosten ist der einzige Zusatz; das Buchprogramm braucht sie nicht.

Der Unterschied zwischen den beiden Plänen ist dabei erstaunlich klein:

Erwartungswert   111111122222222211111111  (33 Blockstunden)
zweistufig       111111222222222222222111  (39 Blockstunden)

Sechs zusätzliche Blockstunden verhindern 28 Lastabwürfe. Der zweistufige Plan fährt den zweiten Block eine Stunde früher an und lässt ihn bis 21 Uhr statt bis 16 Uhr laufen.

⚠️ Warum der Erwartungswert hier systematisch versagt

Der Fehler ist nicht, dass die Windprognose ungenau wäre. Er liegt in der Trägheit der ersten Stufe: Was am Vorabend nicht angefahren wurde, steht am nächsten Tag nicht zur Verfügung — egal, wie hoch der Preis dann steigt.

Eine Planung auf den Mittelwert lässt in der Hälfte der Fälle zu wenig Reserve. Bei einer anpassbaren Größe wäre das halb so schlimm, weil man nachsteuern könnte. Bei einem Kernblock mit acht Stunden Mindestlaufzeit gibt es kein Nachsteuern.

Das ist eine Verschärfung des „Fluchs des Durchschnitts” aus Abschnitt 12.3: Dort war der Mittelwert eine schlechte Näherung, hier ist er eine nicht revidierbare schlechte Näherung.


17.6 Was Versorgungssicherheit kostet

Der dritte Plan legt eine CVaR-Schranke auf die nicht gedeckte Energie: Im Mittel der schlechtesten 10 % der Szenarien darf nichts fehlen. Dieselbe Rockafellar-Uryasev-Konstruktion wie in Kapitel 20, nur dass hier Megawattstunden begrenzt werden statt Euro.

Das Ergebnis ist zunächst enttäuschend — und dann lehrreich:

Abwurfpreis risikoneutral mit CVaR-Schranke Aufpreis
3 000 €/MWh 351 356 €, kein Abwurf 351 356 €, kein Abwurf 0 €
300 €/MWh 350 512 €, Abwurf in 8/40 351 356 €, kein Abwurf 844 €

Bei 3 000 €/MWh kostet die Schranke nichts: Der risikoneutrale Plan hält sie längst ein. Bei diesem Preis lohnt sich das Vorhalten eines Blocks schon im Erwartungswert, lange bevor jemand über Risiko spricht.

Erst bei einem Marktpreisdeckel von 300 €/MWh greift sie. Dann nimmt der risikoneutrale Plan in 8 von 40 Szenarien einen Lastabwurf in Kauf, weil er billiger ist als Vorhaltung — und die Schranke kostet 844 € je Tag für 18,4 vermiedene MWh im CVaR:

\frac{844\ \text{€}}{18{,}4\ \text{MWh}} \approx 46\ \text{€ je vermiedener MWh}

🎯 Merksatz Eine Risikoschranke ändert genau dann etwas, wenn der Schaden zu niedrig bepreist ist. Ist er realistisch bewertet, erledigt die Minimierung der erwarteten Kosten die Absicherung von selbst.

Das ist keine akademische Feinheit, sondern die reale Lage der Energiewirtschaft: Ein Marktpreisdeckel bildet den volkswirtschaftlichen Schaden eines Stromausfalls nicht ab. Genau deshalb gibt es regulatorische Vorgaben zur Versorgungssicherheit — und diese Zahl, 46 € je vermiedener MWh, ist das, worüber sich verhandeln lässt. Sie ist dasselbe Instrument wie die Spalte „€ je kg” in Abschnitt 14.8.


17.7 Übungsaufgaben

Lösungen: Abschnitt A.17.

Aufgabe 17.1 ⭐ — Der Umschlagpunkt. Rechnen Sie den Umschlagpunkt aus dem Schnellstart für eine Leistung von 60 MW statt 120 MW nach. In welche Richtung verschiebt er sich, und warum ist das plausibel?

Aufgabe 17.2 ⭐ — Die fehlende Nebenbedingung. Das Modell kennt Mindestlaufzeiten, aber keine Mindeststillstandszeiten. Formulieren Sie die entsprechende Bedingung. Welchen realen Sachverhalt bildet sie ab?

Aufgabe 17.3 ⭐⭐ — Wie viele Szenarien? Rechnen Sie mit 10, 20, 40 und 80 Szenarien. Ab wann ändert sich der Commitment-Plan nicht mehr? Was folgt daraus für die Praxis — und was hat das mit Abschnitt 15.6 zu tun?

Aufgabe 17.4 ⭐⭐ — Der Wert eines Speichers. Ergänzen Sie einen Batteriespeicher (100 MW, 400 MWh, Wirkungsgrad 90 %). Um wie viel sinken die erwarteten Kosten — und was ist der Speicher damit je MWh Kapazität wert?

Aufgabe 17.5 ⭐⭐⭐ — Wenn es größer wird. Erhöhen Sie auf 20 Blöcke und 168 Stunden (eine Woche). Beobachten Sie MIP-Gap und Rechenzeit (Abschnitt 6.8). Ab wann wird das Problem für den exakten Solver zu groß — und welches Verfahren aus Kapitel 9 würden Sie einsetzen?


17.8 Finde den Denkfehler

🐛 „Wir rechnen mit dem P50-Szenario”

Ein Netzbetreiber beschreibt sein Vorgehen:

„Wir verwenden für die Tagesplanung das P50-Szenario der Windprognose — also den > Median. Die Hälfte der Tage fällt besser aus, die Hälfte schlechter, im Mittel gleicht > sich das aus. Für die Extremfälle haben wir zusätzlich eine Reserve von 5 % der Last > vorgehalten, das ist die branchenübliche Größenordnung.”

Das klingt sorgfältig, und beides ist gängige Praxis.

Beide Teile der Aussage enthalten einen Fehler. Welche?

Ein Hinweis zum zweiten Teil: Sehen Sie sich in der Ausgabe des Programms an, welche Größe der Plan verändert hat, um die Lastabwürfe zu vermeiden — und vergleichen Sie das mit dem, was eine prozentuale Leistungsreserve leisten kann.


17.9 Micro-Quiz

❓ Drei Fragen

1. Warum ist die Merit-Order-Regel („billigstes Kraftwerk zuerst”) für die Tagesplanung nicht ausreichend? a) Weil sie die Netzverluste ignoriert. b) Weil Anfahrkosten und Mindestlaufzeiten die Stunden miteinander koppeln — die Entscheidung einer Stunde bindet die folgenden. c) Weil Grenzkosten in der Praxis nicht bekannt sind.

2. Was unterscheidet dieses zweistufige Modell von dem in Kapitel 12? a) Es hat mehr Szenarien. b) Die erste Stufe ist binär und damit nicht anteilig anpassbar. c) Es minimiert den CVaR statt des Erwartungswerts.

3. Bei 3 000 €/MWh kostet die CVaR-Schranke nichts, bei 300 €/MWh dagegen 844 €/Tag. Warum? a) Weil der Solver bei kleineren Zahlen ungenauer rechnet. b) Weil bei realistisch hohem Abwurfpreis schon die Minimierung der erwarteten Kosten Vorhaltung erzwingt; die Schranke greift nur, wenn der Schaden zu niedrig bepreist ist. c) Weil die CVaR-Schranke bei hohen Preisen numerisch unwirksam wird.


17.10 Selbsttest

  1. Erklären Sie in zwei Sätzen, warum ein Kraftwerkspark nicht wie ein Regal aufgefüllt werden kann.
  2. Welche Größe ist in diesem Modell die „here and now”-Entscheidung, welche die „wait and see”-Entscheidung?
  3. Warum ist eine Planung auf den Erwartungswert hier systematisch zu knapp und nicht nur ungenau?
  4. Wann ändert eine Risikoschranke etwas an der Lösung — und wann nicht?
  5. Sie sollen einer Regulierungsbehörde erklären, was ihre Versorgungssicherheitsvorgabe kostet. Welche Zahl nennen Sie, und wie ist sie entstanden?

17.11 Zusammenfassung

  • Anfahrkosten und Mindestlaufzeiten machen aus 24 Stundenentscheidungen ein einziges Problem. Die Merit-Order gilt nur für einen einzelnen Zeitpunkt; im Schnellstart schlägt die fünfmal teurere Gasturbine den Kernblock bis knapp vier Stunden.
  • Das Modell ist zweistufig mit binärer erster Stufe: Das An/Aus steht am Vorabend fest und gilt für alle Szenarien, die Fahrweise passt sich an. Das ist eine Verschärfung gegenüber Kapitel 12, wo die erste Stufe kontinuierlich war.
  • Eine Planung auf den Wind-Erwartungswert kostet hier 149 % mehr und führt in 28 von 40 Szenarien zum Lastabwurf. Der Grund ist nicht die Prognosegüte, sondern die Trägheit: Was nicht angefahren wurde, ist nicht verfügbar.
  • Der zweistufige Plan braucht dafür nur sechs zusätzliche Blockstunden.
  • Eine CVaR-Schranke auf die nicht gedeckte Energie ändert nur dann etwas, wenn der Schaden zu niedrig bepreist ist. Bei realistischen 3 000 €/MWh erledigt die Minimierung der erwarteten Kosten die Absicherung von selbst.
  • Wo sie greift, liefert sie den Preis der Vorgabe: 46 € je vermiedener MWh — eine Zahl, über die man verhandeln kann.