Phase 6.1: Chance Constraints - die Zusage "mit 95 % Sicherheit"
Neuer Abschnitt im Kapitel Unsicherheit plus Chance_Constraints.py (74. Programm). Das Kapitel hatte Monte-Carlo, Zweistufigkeit und Worst-Case-Robustheit; die Wahrscheinlichkeitszusage war die fehlende vierte Antwort - und die, nach der das Management tatsaechlich fragt. Setzt Paket 4 aus Verbesserungen_02.md um. Beide Wege an DERSELBEN Instanz (Kraftwerkspark, 500 MW gesicherte Zusage): analytisch als Second-Order-Cone-Bedingung (CLARABEL) und szenariobasiert als Big-M-MILP (SciPy/HiGHS) - beides in einem Prozess, ohne ortools- oder highspy-Import. Drei gemessene Befunde: * Der Mittelwertplan haelt 50,08 %. Kein Fehler, sondern die Definition des Erwartungswerts. * Sicherheit ist konvex bepreist: 56.289 EUR je Prozentpunkt auf dem Weg zu 80 %, 253.848 EUR zwischen 95 und 99 % - das 4,5-fache. Das Programm rechnet die Tabelle selbst aus, statt sie zu behaupten. * Die Zusage gilt nur fuer die unterstellte Verteilung: Der 95-%-Plan haelt gemessen 87,44 %, sobald die Testverteilung eine Kaeltewelle mit Dunkelflaute enthaelt, in der alles zugleich einbricht - auch das Gaskraftwerk. In einer reinen Normalwelt liefert derselbe Plan 94,97 %. Zwei eigene Fehlgriffe, beide durch Messen aufgefallen und korrigiert: Die erste Kostentarierung ergab eine entartete Loesung (alles ins Gaskraftwerk), womit die Kovarianzmatrix wirkungslos war - und gerade sie begruendet die Kegelform. Und der erste Kaelteeinbruch traf nur Wind und Sonne; der SOC-Plan hatte die ohnehin herausgehalten und war zufaellig robust, das Argument trug nicht. Ehrlich berichtet statt geglaettet: Die Szenariomethode ueberanpasst. Ueber zwoelf Laeufe (S = 200, 400, 800) lag die tatsaechliche Quote zwischen 93,14 % und 96,71 %, und die Spanne wurde von S = 400 auf 800 wieder breiter. Beide Solver bestaetigen optimal bei identischen Kosten - echte Ueberanpassung, kein Solverartefakt. Steht als Warnkasten im Abschnitt. Die Laufzeitmessung ist aus der Ausgabe entfernt: Eine Wanduhrzeit ist nie byteidentisch reproduzierbar (2,2 s / 2,3 s zwischen zwei Laeufen) und haette Regel 4 dauerhaft gebrochen. Danach drei Laeufe zeichengleich, und die abgedruckte Ausgabe stimmt mit dem Lauf des extrahierten Programms ueberein. Mitgezogen: Kapitelkopf, Lernziele, Uebersichtstabelle (drei -> vier Ansaetze), Selbsttest, Zusammenfassung, Vorwort-Programmverzeichnis, eine neue Uebungsaufgabe und ihre Loesung in Anhang A. pyproject.toml korrigiert: cvxpy lag in [finance], wird aber von 13 Programmen gebraucht, darunter dreien in diesem Kapitel - jetzt in der Grundausstattung. Es zieht kein highspy nach, der Solverkonflikt bleibt auf [large-scale] beschraenkt. Stand: 293 Abschnitte, 712 Querverweise, 327 Indexmarken, 74 Programme (0 Fehler), 33 pytest-Tests, PDF 725 Seiten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
62952ba066
commit
6c67e126dd
23 changed files with 9089 additions and 6419 deletions
|
|
@ -451,6 +451,251 @@
|
|||
" print(\" als sie im Normalfall kostet.\")\n",
|
||||
" print(\"=\" * 88)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Der Fall: ein Kraftwerkspark mit 500 MW Zusage\n",
|
||||
"\n",
|
||||
"`Chance_Constraints.py`\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"#!/usr/bin/env python3\n",
|
||||
"\n",
|
||||
"# Chance_Constraints.py\n",
|
||||
"\"\"\"\n",
|
||||
"Kapitel Unsicherheit: Wahrscheinlichkeitsbeschraenkungen.\n",
|
||||
"\n",
|
||||
"Das Management fragt selten \"was ist im Mittel am besten?\", sondern \"mit\n",
|
||||
"welcher Sicherheit haelt der Plan?\". Genau das formuliert eine Chance\n",
|
||||
"Constraint: P(Versorgung >= Bedarf) >= 1 - alpha.\n",
|
||||
"\n",
|
||||
"Gezeigt werden beide Wege dorthin, an derselben Instanz:\n",
|
||||
" (a) analytisch - unter Normalverteilungsannahme wird daraus eine\n",
|
||||
" Second-Order-Cone-Bedingung, loesbar mit CVXPY\n",
|
||||
" (b) szenariobasiert - Big-M mit Binaervariablen, fuer beliebige\n",
|
||||
" empirische Verteilungen\n",
|
||||
"\n",
|
||||
"Und die beiden Messungen, auf die es ankommt: Was kostet ein Prozentpunkt\n",
|
||||
"Versorgungssicherheit - und haelt die Zusage auch dann, wenn die Verteilung\n",
|
||||
"nicht normal ist?\n",
|
||||
"\n",
|
||||
"Solver: CLARABEL (Kegel) und SciPy/HiGHS (gemischt-ganzzahlig). Weder ortools\n",
|
||||
"noch ein direkter highspy-Import, damit alles in einem Prozess laeuft.\n",
|
||||
"\"\"\"\n",
|
||||
"\n",
|
||||
"import cvxpy as cp\n",
|
||||
"import numpy as np\n",
|
||||
"from scipy.stats import norm\n",
|
||||
"\n",
|
||||
"# --- Der Kraftwerkspark ----------------------------------------------------\n",
|
||||
"# Die Verfuegbarkeit ist der Anteil, den eine installierte MW im Mittel\n",
|
||||
"# wirklich liefert: bei Wind und Sonne klein und stark schwankend, bei Gas und\n",
|
||||
"# Biomasse gross und stabil. Die Ausbaugrenze ist der Standort - Flaeche,\n",
|
||||
"# Genehmigung, Brennstoffversorgung.\n",
|
||||
"TECHNIK = [\"Gaskraftwerk\", \"Windpark\", \"Solarpark\", \"Biomasse\"]\n",
|
||||
"KOSTEN = np.array([65_000.0, 24_000.0, 12_000.0, 60_000.0]) # EUR je MW und Jahr\n",
|
||||
"VERFUEGBAR = np.array([0.92, 0.35, 0.18, 0.85])\n",
|
||||
"STREUUNG = np.array([0.05, 0.16, 0.10, 0.04])\n",
|
||||
"GRENZE = np.array([400.0, 250.0, 600.0, 350.0]) # MW\n",
|
||||
"\n",
|
||||
"# Wind und Sonne sind leicht gegenlaeufig: Ein Tiefdruckgebiet bringt Wind und\n",
|
||||
"# Wolken zugleich. Genau diese Korrelation macht die Bedingung zu einem Kegel\n",
|
||||
"# und nicht zu einer Summe unabhaengiger Einzelzuschlaege.\n",
|
||||
"KORRELATION = np.array([\n",
|
||||
" [1.00, 0.00, 0.00, 0.05],\n",
|
||||
" [0.00, 1.00, -0.25, 0.00],\n",
|
||||
" [0.00, -0.25, 1.00, 0.00],\n",
|
||||
" [0.05, 0.00, 0.00, 1.00],\n",
|
||||
"])\n",
|
||||
"SIGMA = np.diag(STREUUNG) @ KORRELATION @ np.diag(STREUUNG)\n",
|
||||
"WURZEL = np.linalg.cholesky(SIGMA) # L mit L @ L.T == SIGMA\n",
|
||||
"\n",
|
||||
"BEDARF = 500.0 # MW, die gesichert bereitstehen muessen\n",
|
||||
"N = len(TECHNIK)\n",
|
||||
"\n",
|
||||
"# Die Kaeltewelle mit Dunkelflaute: selten, aber sie trifft alles zugleich.\n",
|
||||
"# Wind und Sonne brechen fast vollstaendig weg - und, das ist der Punkt, das\n",
|
||||
"# Gaskraftwerk liefert ebenfalls weniger, weil bei Frost der Netzdruck faellt.\n",
|
||||
"# Eine Kovarianzmatrix mit Korrelationen um null kann das nicht ausdruecken.\n",
|
||||
"P_KAELTEWELLE = 0.08\n",
|
||||
"EINBRUCH = np.array([0.72, 0.10, 0.15, 0.88])\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def mittelwertplan():\n",
|
||||
" \"\"\"Plant mit den Erwartungswerten - ignoriert die Streuung vollstaendig.\"\"\"\n",
|
||||
" x = cp.Variable(N, nonneg=True)\n",
|
||||
" problem = cp.Problem(cp.Minimize(KOSTEN @ x),\n",
|
||||
" [VERFUEGBAR @ x >= BEDARF, x <= GRENZE])\n",
|
||||
" problem.solve(solver=cp.CLARABEL)\n",
|
||||
" return problem.value, x.value\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def chance_constraint_analytisch(alpha):\n",
|
||||
" \"\"\"\n",
|
||||
" P(a^T x >= BEDARF) >= 1 - alpha unter a ~ N(VERFUEGBAR, SIGMA).\n",
|
||||
"\n",
|
||||
" Aequivalent zu: VERFUEGBAR^T x - z * ||L^T x||_2 >= BEDARF\n",
|
||||
" mit z = Phi^-1(1 - alpha). Das ist eine Second-Order-Cone-Bedingung: Der\n",
|
||||
" Sicherheitszuschlag ist eine Norm ueber x, kein fester Aufschlag je Anlage.\n",
|
||||
" Deshalb belohnt sie Mischung - zwei gegenlaeufige Quellen schwanken\n",
|
||||
" gemeinsam weniger als jede fuer sich.\n",
|
||||
" \"\"\"\n",
|
||||
" x = cp.Variable(N, nonneg=True)\n",
|
||||
" z = norm.ppf(1 - alpha)\n",
|
||||
" bedingung = VERFUEGBAR @ x - z * cp.norm(WURZEL.T @ x, 2) >= BEDARF\n",
|
||||
" problem = cp.Problem(cp.Minimize(KOSTEN @ x), [bedingung, x <= GRENZE])\n",
|
||||
" problem.solve(solver=cp.CLARABEL)\n",
|
||||
" return problem.value, x.value\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def chance_constraint_szenarien(a, alpha):\n",
|
||||
" \"\"\"\n",
|
||||
" Dieselbe Zusage ohne Verteilungsannahme: Fuer jedes Szenario s sagt eine\n",
|
||||
" Binaervariable z_s, ob es verletzt werden darf.\n",
|
||||
"\n",
|
||||
" a_s^T x >= BEDARF - M * z_s fuer alle s\n",
|
||||
" sum_s z_s <= alpha * S\n",
|
||||
"\n",
|
||||
" M = BEDARF ist die kleinstmoegliche gueltige Schranke, denn a_s^T x >= 0:\n",
|
||||
" Groesser kann eine Verletzung gar nicht ausfallen. Ein unnoetig grosses M\n",
|
||||
" wuerde die LP-Relaxierung aufweichen und die Suche verlangsamen - die\n",
|
||||
" Big-M-Falle aus dem Kapitel Gemischt-ganzzahlige Optimierung.\n",
|
||||
" \"\"\"\n",
|
||||
" S = len(a)\n",
|
||||
" x = cp.Variable(N, nonneg=True)\n",
|
||||
" z = cp.Variable(S, boolean=True)\n",
|
||||
" problem = cp.Problem(\n",
|
||||
" cp.Minimize(KOSTEN @ x),\n",
|
||||
" [a @ x >= BEDARF - BEDARF * z, cp.sum(z) <= alpha * S, x <= GRENZE])\n",
|
||||
" problem.solve(solver=cp.SCIPY)\n",
|
||||
" return problem.value, x.value\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def ziehe_wetter(anzahl, seed):\n",
|
||||
" \"\"\"Mischverteilung: normales Wetter, mit P_KAELTEWELLE ein Einbruch.\"\"\"\n",
|
||||
" rng = np.random.default_rng(seed)\n",
|
||||
" a = rng.multivariate_normal(VERFUEGBAR, SIGMA, size=anzahl)\n",
|
||||
" getroffen = rng.random(anzahl) < P_KAELTEWELLE\n",
|
||||
" a[getroffen] *= EINBRUCH\n",
|
||||
" return np.clip(a, 0.0, 1.0)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def sicherheit(x, a):\n",
|
||||
" \"\"\"Anteil der Szenarien, in denen der Plan den Bedarf deckt.\"\"\"\n",
|
||||
" return float(np.mean(a @ x >= BEDARF))\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"KOPF = (f\"{'Plan':<20} {'Kosten (EUR)':>13} {'Gas':>4} {'Wind':>4} \"\n",
|
||||
" f\"{'Sol':>4} {'Bio':>4} {'Normalwelt':>11} {'echte Welt':>11}\")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def zeile(name, kosten, x, in_normal, in_echt):\n",
|
||||
" mix = \" \".join(f\"{w:4.0f}\" for w in x)\n",
|
||||
" return (f\"{name:<20} {kosten:>13,.0f} {mix} \"\n",
|
||||
" f\"{in_normal * 100:>9.2f} % {in_echt * 100:>9.2f} %\")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"if __name__ == \"__main__\":\n",
|
||||
" print(\"=\" * 78)\n",
|
||||
" print(\" WAHRSCHEINLICHKEITSBESCHRAENKUNGEN IM KRAFTWERKSPARK\")\n",
|
||||
" print(\"=\" * 78)\n",
|
||||
" print(f\"Gesichert bereitzustellen: {BEDARF:.0f} MW\\n\")\n",
|
||||
" print(f\"{'Technologie':<14} {'EUR/MW':>8} {'verfuegbar':>11} {'Streuung':>9} \"\n",
|
||||
" f\"{'Grenze':>8} {'EUR je erw. MW':>15}\")\n",
|
||||
" print(\"-\" * 78)\n",
|
||||
" for i, name in enumerate(TECHNIK):\n",
|
||||
" print(f\"{name:<14} {KOSTEN[i]:>8,.0f} {VERFUEGBAR[i]:>10.2f} \"\n",
|
||||
" f\"{STREUUNG[i]:>8.2f} {GRENZE[i]:>7.0f} \"\n",
|
||||
" f\"{KOSTEN[i] / VERFUEGBAR[i]:>15,.0f}\")\n",
|
||||
" print(f\"\\nVollausbau liefert im Mittel {VERFUEGBAR @ GRENZE:.0f} MW, \"\n",
|
||||
" f\"in der Kaeltewelle {(VERFUEGBAR * EINBRUCH) @ GRENZE:.0f} MW.\")\n",
|
||||
"\n",
|
||||
" # Zwei grosse, unabhaengige Testmengen. An ihnen wird JEDER Plan gemessen -\n",
|
||||
" # einmal in der unterstellten Normalwelt, einmal in der echten Verteilung.\n",
|
||||
" echt = ziehe_wetter(200_000, seed=771)\n",
|
||||
" normalwelt = np.random.default_rng(4711).multivariate_normal(\n",
|
||||
" VERFUEGBAR, SIGMA, size=200_000)\n",
|
||||
"\n",
|
||||
" print(\"\\n\" + \"=\" * 78)\n",
|
||||
" print(\" (1) Mittelwertplan und Chance Constraints im Vergleich\")\n",
|
||||
" print(\"=\" * 78)\n",
|
||||
" print(KOPF)\n",
|
||||
" print(\"-\" * 78)\n",
|
||||
"\n",
|
||||
" k_mittel, x_mittel = mittelwertplan()\n",
|
||||
" stufen = [(\"Mittelwert\", k_mittel, sicherheit(x_mittel, normalwelt))]\n",
|
||||
" print(zeile(\"Mittelwert\", k_mittel, x_mittel,\n",
|
||||
" sicherheit(x_mittel, normalwelt), sicherheit(x_mittel, echt)))\n",
|
||||
"\n",
|
||||
" plaene = {}\n",
|
||||
" for alpha in (0.20, 0.10, 0.05, 0.01):\n",
|
||||
" kosten, x = chance_constraint_analytisch(alpha)\n",
|
||||
" plaene[alpha] = (kosten, x)\n",
|
||||
" quote_normal = sicherheit(x, normalwelt)\n",
|
||||
" stufen.append((f\"Zusage {(1 - alpha) * 100:.0f} %\", kosten, quote_normal))\n",
|
||||
" print(zeile(f\"Zusage {(1 - alpha) * 100:.0f} %\", kosten, x,\n",
|
||||
" quote_normal, sicherheit(x, echt)))\n",
|
||||
"\n",
|
||||
" print(\"\\n In der Normalwelt trifft jede Zusage ihren Wert - das Verfahren\")\n",
|
||||
" print(\" rechnet richtig. Die Spalte 'echte Welt' kommt in Teil (3).\")\n",
|
||||
"\n",
|
||||
" print(\"\\n\" + \"=\" * 78)\n",
|
||||
" print(\" (2) Was kostet ein Prozentpunkt Versorgungssicherheit?\")\n",
|
||||
" print(\"=\" * 78)\n",
|
||||
" print(f\"{'von -> nach':<26} {'Prozentpunkte':>13} {'Mehrkosten':>13} \"\n",
|
||||
" f\"{'EUR je Punkt':>13}\")\n",
|
||||
" print(\"-\" * 78)\n",
|
||||
" for (n1, k1, q1), (n2, k2, q2) in zip(stufen, stufen[1:]):\n",
|
||||
" d_punkte = (q2 - q1) * 100\n",
|
||||
" d_kosten = k2 - k1\n",
|
||||
" print(f\"{n1 + ' -> ' + n2:<26} {d_punkte:>13.2f} {d_kosten:>13,.0f} \"\n",
|
||||
" f\"{d_kosten / d_punkte:>13,.0f}\")\n",
|
||||
" erst = (stufen[1][1] - stufen[0][1]) / ((stufen[1][2] - stufen[0][2]) * 100)\n",
|
||||
" letzt = (stufen[-1][1] - stufen[-2][1]) / ((stufen[-1][2] - stufen[-2][2]) * 100)\n",
|
||||
" print(f\"\\n Der letzte Prozentpunkt kostet das {letzt / erst:.1f}-fache des ersten.\")\n",
|
||||
" print(\" Sicherheit ist konvex bepreist - genau deshalb muss jemand\")\n",
|
||||
" print(\" entscheiden, wie viel davon das Unternehmen kaufen will.\")\n",
|
||||
"\n",
|
||||
" print(\"\\n\" + \"=\" * 78)\n",
|
||||
" print(\" (3) Und wenn die Verteilung nicht normal ist?\")\n",
|
||||
" print(\"=\" * 78)\n",
|
||||
" k_soc, x_soc = plaene[0.05]\n",
|
||||
" print(f\"Die echte Welt kennt die Kaeltewelle mit Dunkelflaute: in \"\n",
|
||||
" f\"{P_KAELTEWELLE * 100:.0f} % der Faelle liefern\")\n",
|
||||
" print(f\"Wind {EINBRUCH[1] * 100:.0f} %, Sonne {EINBRUCH[2] * 100:.0f} % \"\n",
|
||||
" f\"und - entscheidend - auch das Gaskraftwerk nur \"\n",
|
||||
" f\"{EINBRUCH[0] * 100:.0f} %\")\n",
|
||||
" print(\"des Ueblichen. Alles bricht gleichzeitig ein.\\n\")\n",
|
||||
"\n",
|
||||
" a_bau = ziehe_wetter(400, seed=20260908)\n",
|
||||
" k_sz, x_sz = chance_constraint_szenarien(a_bau, 0.05)\n",
|
||||
"\n",
|
||||
" print(KOPF)\n",
|
||||
" print(\"-\" * 78)\n",
|
||||
" print(zeile(\"SOC, Zusage 95 %\", k_soc, x_soc,\n",
|
||||
" sicherheit(x_soc, normalwelt), sicherheit(x_soc, echt)))\n",
|
||||
" print(zeile(\"Szenarien, 95 %\", k_sz, x_sz,\n",
|
||||
" sicherheit(x_sz, normalwelt), sicherheit(x_sz, echt)))\n",
|
||||
"\n",
|
||||
" print(f\"\\n Der SOC-Plan verspricht 95 % und haelt \"\n",
|
||||
" f\"{sicherheit(x_soc, echt) * 100:.1f} %. Nicht das Verfahren ist\")\n",
|
||||
" print(f\" falsch, sondern die Annahme: In der Normalwelt liefert derselbe\")\n",
|
||||
" print(f\" Plan {sicherheit(x_soc, normalwelt) * 100:.1f} %.\")\n",
|
||||
" print(f\"\\n Der Szenarioplan ({len(a_bau)} Szenarien, {len(a_bau)} \"\n",
|
||||
" f\"Binaervariablen) kommt auf\")\n",
|
||||
" print(f\" {sicherheit(x_sz, echt) * 100:.1f} % - und kostet dafuer \"\n",
|
||||
" f\"{(k_sz / k_soc - 1) * 100:+.1f} %. Er kauft keine Windkraft mehr:\")\n",
|
||||
" print(\" Was im Ernstfall ausfaellt, hilft der Zusage nicht.\")\n",
|
||||
" print(\"=\" * 78)"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue