diff --git a/Notebooks_04/cpsat.ipynb b/Notebooks_04/cpsat.ipynb index 1ed1cb0..d7ebcb5 100644 --- a/Notebooks_04/cpsat.ipynb +++ b/Notebooks_04/cpsat.ipynb @@ -496,6 +496,218 @@ " loese_jobshop()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Parallele Suche: was `num_workers` wirklich bewirkt\n", + "\n", + "`Parallele_Suche.py`\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "#!/usr/bin/env python3\n", + "\n", + "# Parallele_Suche.py\n", + "\"\"\"\n", + "Kapitel CP-SAT: Was mehrere Arbeiter wirklich bringen - und was sie kosten.\n", + "\n", + "Das Buch setzt an mehreren Stellen 'num_workers = 1', damit die abgedruckte\n", + "Ausgabe reproduzierbar ist. Behauptet wird dabei zweierlei: dass parallele\n", + "Suche schneller ist, und dass sie von Lauf zu Lauf verschiedene, gleich gute\n", + "Loesungen findet. Beides wird hier gemessen statt geglaubt.\n", + "\n", + "Gerechnet wird auf einem Job-Shop wie in JobShop_Intervalle.py, nur gross\n", + "genug, dass die Suche wirklich arbeitet: 12 Auftraege auf 10 Maschinen,\n", + "jeder Auftrag besucht jede Maschine.\n", + "\n", + "Zwei Messungen:\n", + " 1. Dieselbe Aufgabe mit 1, 2, 4 und 8 Arbeitern - Laufzeit und Ergebnis.\n", + " 2. Dieselbe Konfiguration mehrfach, bei FESTEM random_seed - wie oft kommt\n", + " derselbe Plan heraus?\n", + "\n", + "Die zweite ist die wichtigere: Sie entscheidet, wie man Tests schreibt.\n", + "\n", + "Achtung, Laufzeiten sind hardwareabhaengig. Die ZAHL der verschiedenen\n", + "Plaene ist es auch - und genau das ist die Aussage.\n", + "\n", + "Benoetigt: numpy, ortools\n", + "\"\"\"\n", + "\n", + "from __future__ import annotations\n", + "\n", + "import collections\n", + "import time\n", + "\n", + "import numpy as np\n", + "from ortools.sat.python import cp_model\n", + "\n", + "AUFTRAEGE = 12\n", + "MASCHINEN = 10\n", + "INSTANZ_SEED = 20260908\n", + "SOLVER_SEED = 1 # bleibt ueber ALLE Laeufe gleich - das ist der Punkt\n", + "ARBEITERZAHLEN = (1, 2, 4, 8)\n", + "WIEDERHOLUNGEN = 4\n", + "ZEITLIMIT = 120.0\n", + "\n", + "\n", + "def baue_instanz() -> list[list[tuple[int, int]]]:\n", + " \"\"\"Klassischer Job-Shop: Jeder Auftrag besucht jede Maschine genau einmal,\n", + " in einer eigenen zufaelligen Reihenfolge.\"\"\"\n", + " rng = np.random.default_rng(INSTANZ_SEED)\n", + " return [[(int(m), int(rng.integers(2, 20))) for m in rng.permutation(MASCHINEN)]\n", + " for _ in range(AUFTRAEGE)]\n", + "\n", + "\n", + "def loese(auftraege, arbeiter: int):\n", + " \"\"\"Minimiert den Makespan. Gibt Status, Zielwert, Laufzeit und den Plan\n", + " zurueck - den Plan als Tupel aller Startzeiten, damit sich zwei Laeufe\n", + " vergleichen lassen.\"\"\"\n", + " horizont = sum(dauer for auftrag in auftraege for _, dauer in auftrag)\n", + " modell = cp_model.CpModel()\n", + " Gang = collections.namedtuple(\"Gang\", \"start ende intervall\")\n", + " plaene: dict[tuple[int, int], Gang] = {}\n", + " je_maschine = collections.defaultdict(list)\n", + "\n", + " for a, auftrag in enumerate(auftraege):\n", + " for g, (maschine, dauer) in enumerate(auftrag):\n", + " start = modell.NewIntVar(0, horizont, f\"start_{a}_{g}\")\n", + " ende = modell.NewIntVar(0, horizont, f\"ende_{a}_{g}\")\n", + " intervall = modell.NewIntervalVar(start, dauer, ende, f\"iv_{a}_{g}\")\n", + " plaene[a, g] = Gang(start, ende, intervall)\n", + " je_maschine[maschine].append(intervall)\n", + "\n", + " for maschine in range(MASCHINEN):\n", + " modell.AddNoOverlap(je_maschine[maschine])\n", + " for a, auftrag in enumerate(auftraege):\n", + " for g in range(len(auftrag) - 1):\n", + " modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende)\n", + "\n", + " makespan = modell.NewIntVar(0, horizont, \"makespan\")\n", + " modell.AddMaxEquality(\n", + " makespan, [plaene[a, len(auftrag) - 1].ende\n", + " for a, auftrag in enumerate(auftraege)])\n", + " modell.Minimize(makespan)\n", + "\n", + " loeser = cp_model.CpSolver()\n", + " loeser.parameters.num_workers = arbeiter\n", + " loeser.parameters.random_seed = SOLVER_SEED\n", + " loeser.parameters.max_time_in_seconds = ZEITLIMIT\n", + "\n", + " beginn = time.perf_counter()\n", + " status = loeser.Solve(modell)\n", + " dauer = time.perf_counter() - beginn\n", + "\n", + " if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):\n", + " return loeser.StatusName(status), None, dauer, None\n", + " plan = tuple(loeser.Value(plaene[a, g].start)\n", + " for a, auftrag in enumerate(auftraege)\n", + " for g in range(len(auftrag)))\n", + " return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer, plan\n", + "\n", + "\n", + "if __name__ == \"__main__\":\n", + " auftraege = baue_instanz()\n", + " gaenge = sum(len(a) for a in auftraege)\n", + "\n", + " print(\"=\" * 78)\n", + " print(\" WAS MEHRERE ARBEITER WIRKLICH BRINGEN\")\n", + " print(\"=\" * 78)\n", + " print(f\"Job-Shop: {AUFTRAEGE} Auftraege, {MASCHINEN} Maschinen, \"\n", + " f\"{gaenge} Arbeitsgaenge.\")\n", + " print(f\"random_seed = {SOLVER_SEED} in JEDEM Lauf, \"\n", + " f\"{WIEDERHOLUNGEN} Wiederholungen je Arbeiterzahl.\\n\")\n", + "\n", + " # Jede Konfiguration mehrfach - ein einzelner Zeitwert sagt nichts.\n", + " ergebnisse: dict[int, list[tuple]] = {}\n", + " for arbeiter in ARBEITERZAHLEN:\n", + " ergebnisse[arbeiter] = [loese(auftraege, arbeiter)\n", + " for _ in range(WIEDERHOLUNGEN)]\n", + "\n", + " print(\"=\" * 78)\n", + " print(\" (1) Laufzeit: was die Arbeiter bringen\")\n", + " print(\"=\" * 78)\n", + " print(f\"{'Arbeiter':>9} {'Status':>9} {'Makespan':>9} \"\n", + " f\"{'schnellste':>11} {'langsamste':>11} {'Beschleunigung':>15}\")\n", + " print(\"-\" * 78)\n", + " basis_zeit = min(d for _, _, d, _ in ergebnisse[1])\n", + " for arbeiter in ARBEITERZAHLEN:\n", + " laeufe = ergebnisse[arbeiter]\n", + " zeiten = [d for _, _, d, _ in laeufe]\n", + " ziele = {o for _, o, _, _ in laeufe}\n", + " status = {s for s, _, _, _ in laeufe}\n", + " print(f\"{arbeiter:>9} {'/'.join(sorted(status)):>9} \"\n", + " f\"{'/'.join(str(z) for z in sorted(ziele)):>9} \"\n", + " f\"{min(zeiten):>10.2f}s {max(zeiten):>10.2f}s \"\n", + " f\"{basis_zeit / min(zeiten):>14.1f}x\")\n", + "\n", + " beste = min(ARBEITERZAHLEN, key=lambda w: min(d for _, _, d, _ in ergebnisse[w]))\n", + " faktor = basis_zeit / min(d for _, _, d, _ in ergebnisse[beste])\n", + " print(f\"\\n Am schnellsten sind {beste} Arbeiter: Faktor {faktor:.1f} gegenueber\")\n", + " print(f\" einem einzigen - also MEHR, als {beste} Kerne hergeben sollten.\")\n", + " print(f\" Das ist kein Messfehler. CP-SAT laesst nicht {beste}-mal dieselbe Suche\")\n", + " print(\" laufen, sondern verschiedene Strategien nebeneinander, die einander\")\n", + " print(\" ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der\")\n", + " print(\" 'zweite Kern', sondern ein anderes Verfahren.\")\n", + " letzte = ARBEITERZAHLEN[-1]\n", + " if beste != letzte:\n", + " zeit_letzte = min(d for _, _, d, _ in ergebnisse[letzte])\n", + " print(f\"\\n Und mehr ist nicht immer besser: {letzte} Arbeiter brauchen \"\n", + " f\"{zeit_letzte:.2f}s\")\n", + " print(f\" gegenueber {min(d for _, _, d, _ in ergebnisse[beste]):.2f}s bei \"\n", + " f\"{beste}. Ab einer gewissen Zahl kosten Abstimmung\")\n", + " print(\" und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt.\")\n", + "\n", + " print(\"\\n\" + \"=\" * 78)\n", + " print(\" (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?\")\n", + " print(\"=\" * 78)\n", + " print(f\"{'Arbeiter':>9} {'Laeufe':>7} {'verschiedene Plaene':>21} \"\n", + " f\"{'Makespan':>10}\")\n", + " print(\"-\" * 78)\n", + " for arbeiter in ARBEITERZAHLEN:\n", + " plaene = {p for _, _, _, p in ergebnisse[arbeiter] if p is not None}\n", + " ziele = {o for _, o, _, _ in ergebnisse[arbeiter]}\n", + " print(f\"{arbeiter:>9} {WIEDERHOLUNGEN:>7} {len(plaene):>21} \"\n", + " f\"{'/'.join(str(z) for z in sorted(ziele)):>10}\")\n", + "\n", + " einer = len({p for _, _, _, p in ergebnisse[1]})\n", + " # Ab welcher Arbeiterzahl bricht die Reproduzierbarkeit? Das ist die\n", + " # eigentliche Zahl - nicht, was acht Arbeiter anrichten.\n", + " ab = next((w for w in ARBEITERZAHLEN\n", + " if len({p for _, _, _, p in ergebnisse[w]}) > 1), None)\n", + " print(f\"\\n Mit einem Arbeiter: {einer} Plan aus {WIEDERHOLUNGEN} Laeufen.\")\n", + " if ab is not None:\n", + " viele = len({p for _, _, _, p in ergebnisse[ab]})\n", + " print(f\" Schon mit {ab} Arbeitern: {viele} verschiedene Plaene aus \"\n", + " f\"{WIEDERHOLUNGEN} Laeufen -\")\n", + " print(\" bei identischem random_seed und identischem Zielwert.\")\n", + " print(f\" Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit\")\n", + " print(f\" zu verlieren. {ab} genuegen.\")\n", + " print(\"\\n Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt\")\n", + " print(\" fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von\")\n", + " print(\" mehreren zuerst fertig wird. Das entscheidet die Uhr.\")\n", + "\n", + " print(\"\\n\" + \"=\" * 78)\n", + " print(\" (3) Was daraus fuer Tests folgt\")\n", + " print(\"=\" * 78)\n", + " print(\" Ein Test der Form assert plan == erwarteter_plan besteht mal und\")\n", + " print(\" scheitert mal, ohne dass sich am Code etwas geaendert haette.\")\n", + " print(\" Zu pruefen sind stattdessen:\")\n", + " print(\" * der ZIELWERT (hier in allen Laeufen gleich),\")\n", + " print(\" * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),\")\n", + " print(\" * und der Status - nicht die Gestalt der Loesung.\")\n", + " print(\"\\n Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte\")\n", + " print(\" Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.\")\n", + " print(f\" Das kostet hier den Faktor {faktor:.1f} an Laufzeit und ist genau\")\n", + " print(\" deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.\")\n", + " print(\"=\" * 78)" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/Notebooks_04/milp.ipynb b/Notebooks_04/milp.ipynb index 6ee1d17..a9bb7ca 100644 --- a/Notebooks_04/milp.ipynb +++ b/Notebooks_04/milp.ipynb @@ -757,7 +757,8 @@ "\n", " loeser = cp_model.CpSolver()\n", " loeser.parameters.max_time_in_seconds = zeitlimit\n", - " # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist.\n", + " # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist -\n", + " # der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming).\n", " # Im Produktivbetrieb laesst man beides auf den Standardwerten.\n", " loeser.parameters.num_workers = 1\n", " loeser.parameters.random_seed = 1\n", diff --git a/OR_HTML_04/Notebooks_04/cpsat.ipynb b/OR_HTML_04/Notebooks_04/cpsat.ipynb index 1ed1cb0..d7ebcb5 100644 --- a/OR_HTML_04/Notebooks_04/cpsat.ipynb +++ b/OR_HTML_04/Notebooks_04/cpsat.ipynb @@ -496,6 +496,218 @@ " loese_jobshop()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Parallele Suche: was `num_workers` wirklich bewirkt\n", + "\n", + "`Parallele_Suche.py`\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "#!/usr/bin/env python3\n", + "\n", + "# Parallele_Suche.py\n", + "\"\"\"\n", + "Kapitel CP-SAT: Was mehrere Arbeiter wirklich bringen - und was sie kosten.\n", + "\n", + "Das Buch setzt an mehreren Stellen 'num_workers = 1', damit die abgedruckte\n", + "Ausgabe reproduzierbar ist. Behauptet wird dabei zweierlei: dass parallele\n", + "Suche schneller ist, und dass sie von Lauf zu Lauf verschiedene, gleich gute\n", + "Loesungen findet. Beides wird hier gemessen statt geglaubt.\n", + "\n", + "Gerechnet wird auf einem Job-Shop wie in JobShop_Intervalle.py, nur gross\n", + "genug, dass die Suche wirklich arbeitet: 12 Auftraege auf 10 Maschinen,\n", + "jeder Auftrag besucht jede Maschine.\n", + "\n", + "Zwei Messungen:\n", + " 1. Dieselbe Aufgabe mit 1, 2, 4 und 8 Arbeitern - Laufzeit und Ergebnis.\n", + " 2. Dieselbe Konfiguration mehrfach, bei FESTEM random_seed - wie oft kommt\n", + " derselbe Plan heraus?\n", + "\n", + "Die zweite ist die wichtigere: Sie entscheidet, wie man Tests schreibt.\n", + "\n", + "Achtung, Laufzeiten sind hardwareabhaengig. Die ZAHL der verschiedenen\n", + "Plaene ist es auch - und genau das ist die Aussage.\n", + "\n", + "Benoetigt: numpy, ortools\n", + "\"\"\"\n", + "\n", + "from __future__ import annotations\n", + "\n", + "import collections\n", + "import time\n", + "\n", + "import numpy as np\n", + "from ortools.sat.python import cp_model\n", + "\n", + "AUFTRAEGE = 12\n", + "MASCHINEN = 10\n", + "INSTANZ_SEED = 20260908\n", + "SOLVER_SEED = 1 # bleibt ueber ALLE Laeufe gleich - das ist der Punkt\n", + "ARBEITERZAHLEN = (1, 2, 4, 8)\n", + "WIEDERHOLUNGEN = 4\n", + "ZEITLIMIT = 120.0\n", + "\n", + "\n", + "def baue_instanz() -> list[list[tuple[int, int]]]:\n", + " \"\"\"Klassischer Job-Shop: Jeder Auftrag besucht jede Maschine genau einmal,\n", + " in einer eigenen zufaelligen Reihenfolge.\"\"\"\n", + " rng = np.random.default_rng(INSTANZ_SEED)\n", + " return [[(int(m), int(rng.integers(2, 20))) for m in rng.permutation(MASCHINEN)]\n", + " for _ in range(AUFTRAEGE)]\n", + "\n", + "\n", + "def loese(auftraege, arbeiter: int):\n", + " \"\"\"Minimiert den Makespan. Gibt Status, Zielwert, Laufzeit und den Plan\n", + " zurueck - den Plan als Tupel aller Startzeiten, damit sich zwei Laeufe\n", + " vergleichen lassen.\"\"\"\n", + " horizont = sum(dauer for auftrag in auftraege for _, dauer in auftrag)\n", + " modell = cp_model.CpModel()\n", + " Gang = collections.namedtuple(\"Gang\", \"start ende intervall\")\n", + " plaene: dict[tuple[int, int], Gang] = {}\n", + " je_maschine = collections.defaultdict(list)\n", + "\n", + " for a, auftrag in enumerate(auftraege):\n", + " for g, (maschine, dauer) in enumerate(auftrag):\n", + " start = modell.NewIntVar(0, horizont, f\"start_{a}_{g}\")\n", + " ende = modell.NewIntVar(0, horizont, f\"ende_{a}_{g}\")\n", + " intervall = modell.NewIntervalVar(start, dauer, ende, f\"iv_{a}_{g}\")\n", + " plaene[a, g] = Gang(start, ende, intervall)\n", + " je_maschine[maschine].append(intervall)\n", + "\n", + " for maschine in range(MASCHINEN):\n", + " modell.AddNoOverlap(je_maschine[maschine])\n", + " for a, auftrag in enumerate(auftraege):\n", + " for g in range(len(auftrag) - 1):\n", + " modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende)\n", + "\n", + " makespan = modell.NewIntVar(0, horizont, \"makespan\")\n", + " modell.AddMaxEquality(\n", + " makespan, [plaene[a, len(auftrag) - 1].ende\n", + " for a, auftrag in enumerate(auftraege)])\n", + " modell.Minimize(makespan)\n", + "\n", + " loeser = cp_model.CpSolver()\n", + " loeser.parameters.num_workers = arbeiter\n", + " loeser.parameters.random_seed = SOLVER_SEED\n", + " loeser.parameters.max_time_in_seconds = ZEITLIMIT\n", + "\n", + " beginn = time.perf_counter()\n", + " status = loeser.Solve(modell)\n", + " dauer = time.perf_counter() - beginn\n", + "\n", + " if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):\n", + " return loeser.StatusName(status), None, dauer, None\n", + " plan = tuple(loeser.Value(plaene[a, g].start)\n", + " for a, auftrag in enumerate(auftraege)\n", + " for g in range(len(auftrag)))\n", + " return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer, plan\n", + "\n", + "\n", + "if __name__ == \"__main__\":\n", + " auftraege = baue_instanz()\n", + " gaenge = sum(len(a) for a in auftraege)\n", + "\n", + " print(\"=\" * 78)\n", + " print(\" WAS MEHRERE ARBEITER WIRKLICH BRINGEN\")\n", + " print(\"=\" * 78)\n", + " print(f\"Job-Shop: {AUFTRAEGE} Auftraege, {MASCHINEN} Maschinen, \"\n", + " f\"{gaenge} Arbeitsgaenge.\")\n", + " print(f\"random_seed = {SOLVER_SEED} in JEDEM Lauf, \"\n", + " f\"{WIEDERHOLUNGEN} Wiederholungen je Arbeiterzahl.\\n\")\n", + "\n", + " # Jede Konfiguration mehrfach - ein einzelner Zeitwert sagt nichts.\n", + " ergebnisse: dict[int, list[tuple]] = {}\n", + " for arbeiter in ARBEITERZAHLEN:\n", + " ergebnisse[arbeiter] = [loese(auftraege, arbeiter)\n", + " for _ in range(WIEDERHOLUNGEN)]\n", + "\n", + " print(\"=\" * 78)\n", + " print(\" (1) Laufzeit: was die Arbeiter bringen\")\n", + " print(\"=\" * 78)\n", + " print(f\"{'Arbeiter':>9} {'Status':>9} {'Makespan':>9} \"\n", + " f\"{'schnellste':>11} {'langsamste':>11} {'Beschleunigung':>15}\")\n", + " print(\"-\" * 78)\n", + " basis_zeit = min(d for _, _, d, _ in ergebnisse[1])\n", + " for arbeiter in ARBEITERZAHLEN:\n", + " laeufe = ergebnisse[arbeiter]\n", + " zeiten = [d for _, _, d, _ in laeufe]\n", + " ziele = {o for _, o, _, _ in laeufe}\n", + " status = {s for s, _, _, _ in laeufe}\n", + " print(f\"{arbeiter:>9} {'/'.join(sorted(status)):>9} \"\n", + " f\"{'/'.join(str(z) for z in sorted(ziele)):>9} \"\n", + " f\"{min(zeiten):>10.2f}s {max(zeiten):>10.2f}s \"\n", + " f\"{basis_zeit / min(zeiten):>14.1f}x\")\n", + "\n", + " beste = min(ARBEITERZAHLEN, key=lambda w: min(d for _, _, d, _ in ergebnisse[w]))\n", + " faktor = basis_zeit / min(d for _, _, d, _ in ergebnisse[beste])\n", + " print(f\"\\n Am schnellsten sind {beste} Arbeiter: Faktor {faktor:.1f} gegenueber\")\n", + " print(f\" einem einzigen - also MEHR, als {beste} Kerne hergeben sollten.\")\n", + " print(f\" Das ist kein Messfehler. CP-SAT laesst nicht {beste}-mal dieselbe Suche\")\n", + " print(\" laufen, sondern verschiedene Strategien nebeneinander, die einander\")\n", + " print(\" ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der\")\n", + " print(\" 'zweite Kern', sondern ein anderes Verfahren.\")\n", + " letzte = ARBEITERZAHLEN[-1]\n", + " if beste != letzte:\n", + " zeit_letzte = min(d for _, _, d, _ in ergebnisse[letzte])\n", + " print(f\"\\n Und mehr ist nicht immer besser: {letzte} Arbeiter brauchen \"\n", + " f\"{zeit_letzte:.2f}s\")\n", + " print(f\" gegenueber {min(d for _, _, d, _ in ergebnisse[beste]):.2f}s bei \"\n", + " f\"{beste}. Ab einer gewissen Zahl kosten Abstimmung\")\n", + " print(\" und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt.\")\n", + "\n", + " print(\"\\n\" + \"=\" * 78)\n", + " print(\" (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?\")\n", + " print(\"=\" * 78)\n", + " print(f\"{'Arbeiter':>9} {'Laeufe':>7} {'verschiedene Plaene':>21} \"\n", + " f\"{'Makespan':>10}\")\n", + " print(\"-\" * 78)\n", + " for arbeiter in ARBEITERZAHLEN:\n", + " plaene = {p for _, _, _, p in ergebnisse[arbeiter] if p is not None}\n", + " ziele = {o for _, o, _, _ in ergebnisse[arbeiter]}\n", + " print(f\"{arbeiter:>9} {WIEDERHOLUNGEN:>7} {len(plaene):>21} \"\n", + " f\"{'/'.join(str(z) for z in sorted(ziele)):>10}\")\n", + "\n", + " einer = len({p for _, _, _, p in ergebnisse[1]})\n", + " # Ab welcher Arbeiterzahl bricht die Reproduzierbarkeit? Das ist die\n", + " # eigentliche Zahl - nicht, was acht Arbeiter anrichten.\n", + " ab = next((w for w in ARBEITERZAHLEN\n", + " if len({p for _, _, _, p in ergebnisse[w]}) > 1), None)\n", + " print(f\"\\n Mit einem Arbeiter: {einer} Plan aus {WIEDERHOLUNGEN} Laeufen.\")\n", + " if ab is not None:\n", + " viele = len({p for _, _, _, p in ergebnisse[ab]})\n", + " print(f\" Schon mit {ab} Arbeitern: {viele} verschiedene Plaene aus \"\n", + " f\"{WIEDERHOLUNGEN} Laeufen -\")\n", + " print(\" bei identischem random_seed und identischem Zielwert.\")\n", + " print(f\" Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit\")\n", + " print(f\" zu verlieren. {ab} genuegen.\")\n", + " print(\"\\n Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt\")\n", + " print(\" fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von\")\n", + " print(\" mehreren zuerst fertig wird. Das entscheidet die Uhr.\")\n", + "\n", + " print(\"\\n\" + \"=\" * 78)\n", + " print(\" (3) Was daraus fuer Tests folgt\")\n", + " print(\"=\" * 78)\n", + " print(\" Ein Test der Form assert plan == erwarteter_plan besteht mal und\")\n", + " print(\" scheitert mal, ohne dass sich am Code etwas geaendert haette.\")\n", + " print(\" Zu pruefen sind stattdessen:\")\n", + " print(\" * der ZIELWERT (hier in allen Laeufen gleich),\")\n", + " print(\" * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),\")\n", + " print(\" * und der Status - nicht die Gestalt der Loesung.\")\n", + " print(\"\\n Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte\")\n", + " print(\" Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.\")\n", + " print(f\" Das kostet hier den Faktor {faktor:.1f} an Laufzeit und ist genau\")\n", + " print(\" deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.\")\n", + " print(\"=\" * 78)" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/OR_HTML_04/Notebooks_04/milp.ipynb b/OR_HTML_04/Notebooks_04/milp.ipynb index 6ee1d17..a9bb7ca 100644 --- a/OR_HTML_04/Notebooks_04/milp.ipynb +++ b/OR_HTML_04/Notebooks_04/milp.ipynb @@ -757,7 +757,8 @@ "\n", " loeser = cp_model.CpSolver()\n", " loeser.parameters.max_time_in_seconds = zeitlimit\n", - " # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist.\n", + " # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist -\n", + " # der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming).\n", " # Im Produktivbetrieb laesst man beides auf den Standardwerten.\n", " loeser.parameters.num_workers = 1\n", " loeser.parameters.random_seed = 1\n", diff --git a/OR_HTML_04/Operations_Research_mit_Python_Version_04.pdf b/OR_HTML_04/Operations_Research_mit_Python_Version_04.pdf index 4e50397..408f1e1 100644 Binary files a/OR_HTML_04/Operations_Research_mit_Python_Version_04.pdf and b/OR_HTML_04/Operations_Research_mit_Python_Version_04.pdf differ diff --git a/OR_HTML_04/anhang-loesungen.html b/OR_HTML_04/anhang-loesungen.html index d7f775e..13e7cc0 100644 --- a/OR_HTML_04/anhang-loesungen.html +++ b/OR_HTML_04/anhang-loesungen.html @@ -484,8 +484,31 @@ for j in range(9): if vorgabe[i][j]: m.Add(x[i][j] == vorgabe[i][j])

Etwa 10 Zeilen Modellcode — und der Solver löst jedes Sudoku in Millisekunden. Das ist die Stärke globaler Constraints.

-

7.6 — Job-Shop erweitern. (a) Rüstzeiten: AddNoOverlap durch paarweise Disjunktionen mit Übergangszeit ersetzen, oder AddCircuit je Maschine mit Übergangsmatrix. (b) Verspätung: tardiness = MaxEquality(0, ende - faellig), in die Zielfunktion. (c) AddCumulative(intervalle, [1]*n, 2) statt AddNoOverlap für die Doppelmaschine.

-

7.7 — Wochendienstplan. Siehe Projekt P2. Kernpunkte: Nachtschicht-Folgeregel über AddImplication; „höchstens 5 Tage in Folge“ über gleitende Fenster (sum(x[p,t..t+5]) <= 5).

+

7.6 — Die Grenze der Parallelität finden. (a) Der Gewinn flacht ab und kehrt sich um. Auf einem Rechner mit 24 Kernen gemessen (Faktor gegenüber einem Arbeiter): 8 Arbeiter 10,2, 16 Arbeiter 12,3, 24 Arbeiter 9,0. Wer alle Kerne belegt, ist langsamer als mit zwei Dritteln — dem Betriebssystem, dem Speicherbus und den Suchsträngen untereinander bleibt zu wenig Luft. Die Faustregel „so viele Arbeiter wie Kerne” ist also falsch; die richtige Zahl ist eine Messung, keine Einstellung. (b) Fünf verschiedene Pläne aus fünf Seeds — bei num_workers = 1 und identischem Zielwert 183. Das ist die andere Hälfte der Antwort: Der Seed wirkt, aber nur auf den einzelnen Suchstrang. Es sichert also keiner der beiden Parameter allein die Reproduzierbarkeit:

+ + + + + + + + + + + + + + + + + + + + +
Seed festSeed variabel
1 Arbeiterreproduzierbar5 Pläne aus 5 Läufen
mehrere Arbeiter3 Pläne aus 4 Läufenerst recht nicht
+

Nur die Kombination trägt. (c) Bei 15 Aufträgen (60 s Zeitlimit): Ein Arbeiter läuft ins Limit und meldet FEASIBLE mit Makespan 200; acht Arbeiter melden OPTIMAL mit demselben Makespan 200 nach 26,4 s. Bei 13 Aufträgen sind es 11,2 s gegen 2,8 s, bei 14 schon 21,4 s gegen 3,7 s. Bemerkenswert ist, dass beide denselben Wert finden — der Unterschied liegt nicht in der Lösung, sondern im Beweis, dass es keine bessere gibt. Genau diese Arbeit teilen sich die parallelen Stränge auf.

+

7.7 — Job-Shop erweitern. (a) Rüstzeiten: AddNoOverlap durch paarweise Disjunktionen mit Übergangszeit ersetzen, oder AddCircuit je Maschine mit Übergangsmatrix. (b) Verspätung: tardiness = MaxEquality(0, ende - faellig), in die Zielfunktion. (c) AddCumulative(intervalle, [1]*n, 2) statt AddNoOverlap für die Doppelmaschine.

+

7.8 — Wochendienstplan. Siehe Projekt P2. Kernpunkte: Nachtschicht-Folgeregel über AddImplication; „höchstens 5 Tage in Folge“ über gleitende Fenster (sum(x[p,t..t+5]) <= 5).

Finde den Denkfehler — Die Betriebsvereinbarung, die niemanden interessiert

  1. Warum die Regel verletzt wird. Weil sie einen Preis hat. Eine weiche Nebenbedingung verbietet nichts — sie verteuert. Der Solver vergleicht in jedem Schritt, was ihn eine Verletzung kostet und was sie ihm einbringt. Bei einem Gewicht von 1 gegen 10 lohnt sich eine Drei-Tage-Serie, sobald sie auch nur ein Zehntel eines Wunsches rettet. Der Solver hat genau das getan, was im Modell steht — er hat nur nicht getan, was gemeint war.

  2. @@ -563,6 +586,8 @@
  3. Eine Variable mit Start, Dauer und Ende; sie garantiert automatisch start + dauer == ende.
  4. Weil jede zusätzliche harte Bedingung das Risiko von INFEASIBLE erhöht — und eine Fehlermeldung dem Anwender nicht hilft.
  5. Weil Anwender die Frage „Warum ich?“ stellen. Ohne Antwort wird das System umgangen.
  6. +
  7. Weil der Seed nur festlegt, wie ein einzelner Suchstrang würfelt — nicht, welcher von mehreren parallelen zuerst fertig wird. Das entscheidet die Ausführungsreihenfolge, also die Uhr. Abhilfe: zusätzlich num_workers = 1. Gemessen: schon zwei Arbeiter liefern bei identischem Seed drei verschiedene Pläne zum selben Zielwert.
  8. +
  9. Weil CP-SAT nicht dieselbe Suche vervielfacht, sondern verschiedene Strategien nebeneinander laufen lässt (LP-gestützt, andere Verzweigungsregeln, lokale Suche), die einander ihre Schranken mitteilen. Ein zusätzlicher Arbeiter ist deshalb nicht ein weiterer Kern für dieselbe Arbeit, sondern ein anderes Verfahren — und wenn eines davon früh eine gute Schranke findet, profitieren alle.

A.8 Lösungen zu Kapitel „Graphen, Flüsse und Touren — Min-Cost-Flow, Matching und VRP“

diff --git a/OR_HTML_04/assets/programme.js b/OR_HTML_04/assets/programme.js index ed78974..1d8d3b6 100644 --- a/OR_HTML_04/assets/programme.js +++ b/OR_HTML_04/assets/programme.js @@ -1 +1 @@ -window.OR_PROGRAMME = ["Backtest_Fallen.py", "Bausteine_Vorlage.py", "Bellman_Minimalbeispiel.py", "Benchmark_Skalierung.py", "Betriebsueberwachung.py", "Big_M_Falle.py", "Bot_Allokation.py", "Brute_Force_Vergleich.py", "CP_SAT_Statusfaelle.py", "CP_SAT_Vertretungssystem.py", "CVaR_Portfolio.py", "Chance_Constraints.py", "Constraint_Attribution.py", "Data_Snooping.py", "Diversifikation_Demo.py", "Dualitaet_Nachweis.py", "Ein_System_Vier_Ansaetze.py", "Entropie_Maximierte_Allokation.py", "Erklaerbarkeit.py", "Excel_Bruecke.py", "Finanzdaten_Ledoit_Wolf.py", "Fluch_des_Durchschnitts.py", "Infeasibility_Diagnose.py", "Installationstest.py", "JobShop_Intervalle.py", "KKT_Nachweis.py", "Konfliktsuche.py", "Konvexitaet_Demo.py", "Kovarianz_Falle.py", "Kraftwerkseinsatz.py", "Large_Neighborhood_Search.py", "Lokale_Optima_Multistart.py", "MILP_Portfolio_Fixgebuehren.py", "Markowitz_CVXPY.py", "Matrixform.py", "Mehrperiodige_Order_Execution.py", "Mehrziel_Pareto.py", "Metaheuristik_vs_Exakt.py", "Min_Cost_Flow.py", "Modellierungsschichten.py", "Monte_Carlo.py", "Mutationstest.py", "Optimierungsdienst.py", "Predict_then_Optimize.py", "Propagation_Demo.py", "QP_Grundlagen.py", "QuantitativeTradingEngine.py", "Renditen_Vergleich.py", "Renditeschaetzung_Falle.py", "Robuste_Optimierung.py", "Rucksack.py", "Runden_Gegenbeispiel.py", "Schaetzrauschen_Demo.py", "Sensitivitaetsanalyse.py", "Simplex_Tableau_LP.py", "Simulated_Annealing.py", "Skalierung_Kondition.py", "Solver_Wahl.py", "Solverstatus_und_Gap.py", "Solverwechsel_CPSAT_HiGHS.py", "Spaltengenerierung.py", "Stochastische_Optimierung.py", "Strafgewichte.py", "Strukturbruecke.py", "Toleranzen_und_Entartung.py", "VRP_Flotten_Routing.py", "VRP_Kapazitaetsfalle.py", "VaR_CVaR_Demo.py", "Vektorisierte_Modellgenerierung.py", "Visualisierung_Loesungsraum.py", "Vom_Wunsch_zum_Modell.py", "Warmstart_Effekt.py", "Zuordnung_Ungarisch.py", "or_kern.py", "test_or_kern.py"]; +window.OR_PROGRAMME = ["Backtest_Fallen.py", "Bausteine_Vorlage.py", "Bellman_Minimalbeispiel.py", "Benchmark_Skalierung.py", "Betriebsueberwachung.py", "Big_M_Falle.py", "Bot_Allokation.py", "Brute_Force_Vergleich.py", "CP_SAT_Statusfaelle.py", "CP_SAT_Vertretungssystem.py", "CVaR_Portfolio.py", "Chance_Constraints.py", "Constraint_Attribution.py", "Data_Snooping.py", "Diversifikation_Demo.py", "Dualitaet_Nachweis.py", "Ein_System_Vier_Ansaetze.py", "Entropie_Maximierte_Allokation.py", "Erklaerbarkeit.py", "Excel_Bruecke.py", "Finanzdaten_Ledoit_Wolf.py", "Fluch_des_Durchschnitts.py", "Infeasibility_Diagnose.py", "Installationstest.py", "JobShop_Intervalle.py", "KKT_Nachweis.py", "Konfliktsuche.py", "Konvexitaet_Demo.py", "Kovarianz_Falle.py", "Kraftwerkseinsatz.py", "Large_Neighborhood_Search.py", "Lokale_Optima_Multistart.py", "MILP_Portfolio_Fixgebuehren.py", "Markowitz_CVXPY.py", "Matrixform.py", "Mehrperiodige_Order_Execution.py", "Mehrziel_Pareto.py", "Metaheuristik_vs_Exakt.py", "Min_Cost_Flow.py", "Modellierungsschichten.py", "Monte_Carlo.py", "Mutationstest.py", "Optimierungsdienst.py", "Parallele_Suche.py", "Predict_then_Optimize.py", "Propagation_Demo.py", "QP_Grundlagen.py", "QuantitativeTradingEngine.py", "Renditen_Vergleich.py", "Renditeschaetzung_Falle.py", "Robuste_Optimierung.py", "Rucksack.py", "Runden_Gegenbeispiel.py", "Schaetzrauschen_Demo.py", "Sensitivitaetsanalyse.py", "Simplex_Tableau_LP.py", "Simulated_Annealing.py", "Skalierung_Kondition.py", "Solver_Wahl.py", "Solverstatus_und_Gap.py", "Solverwechsel_CPSAT_HiGHS.py", "Spaltengenerierung.py", "Stochastische_Optimierung.py", "Strafgewichte.py", "Strukturbruecke.py", "Toleranzen_und_Entartung.py", "VRP_Flotten_Routing.py", "VRP_Kapazitaetsfalle.py", "VaR_CVaR_Demo.py", "Vektorisierte_Modellgenerierung.py", "Visualisierung_Loesungsraum.py", "Vom_Wunsch_zum_Modell.py", "Warmstart_Effekt.py", "Zuordnung_Ungarisch.py", "or_kern.py", "test_or_kern.py"]; diff --git a/OR_HTML_04/assets/search-index.js b/OR_HTML_04/assets/search-index.js index f2b493e..8028c8d 100644 --- a/OR_HTML_04/assets/search-index.js +++ b/OR_HTML_04/assets/search-index.js @@ -1 +1 @@ -window.OR_SEARCH_INDEX = [{"seite":"vorwort.html","titel":"Vorwort & Lesehilfe","anker":null,"text":""},{"seite":"notation.html","titel":"Notation & Abkürzungen","anker":null,"text":""},{"seite":"notation.html","titel":"1. Die Symbole der Optimierung","anker":"die-symbole-der-optimierung","text":""},{"seite":"notation.html","titel":"1.1 Grundgrößen jedes Modells","anker":"grundgrößen-jedes-modells","text":""},{"seite":"notation.html","titel":"1.2 Mengen und Zahlbereiche","anker":"mengen-und-zahlbereiche","text":""},{"seite":"notation.html","titel":"1.3 Operatoren und Schreibweisen","anker":"operatoren-und-schreibweisen","text":""},{"seite":"notation.html","titel":"1.4 Symbole der Finanzkapitel (Teil IV)","anker":"symbole-der-finanzkapitel-teil-iv","text":""},{"seite":"notation.html","titel":"1.5 Symbole der Verfahrenskapitel (Teil II und III)","anker":"symbole-der-verfahrenskapitel-teil-ii-und-iii","text":"Diese Zeichen kommen erst in den Kapiteln vor, die über das klassische LP hinausgehen."},{"seite":"notation.html","titel":"2. Abkürzungen — vollständig ausgeschrieben","anker":"abkürzungen-vollständig-ausgeschrieben","text":""},{"seite":"notation.html","titel":"2.1 Problemklassen und Verfahren","anker":"problemklassen-und-verfahren","text":""},{"seite":"notation.html","titel":"2.2 Probleme mit Eigennamen","anker":"probleme-mit-eigennamen","text":""},{"seite":"notation.html","titel":"2.3 Finanz- und Risikokennzahlen","anker":"finanz--und-risikokennzahlen","text":""},{"seite":"notation.html","titel":"2.4 Software, Solver und Schnittstellen","anker":"software-solver-und-schnittstellen","text":""},{"seite":"notation.html","titel":"2.5 Begriffe aus dem Solver-Alltag","anker":"begriffe-aus-dem-solver-alltag","text":""},{"seite":"notation.html","titel":"3. Konventionen in diesem Buch","anker":"konventionen-in-diesem-buch","text":"Vektoren sind grundsätzlich Spaltenvektoren und werden fett gesetzt: \\mathbf{x}. Ein transponierter Vektor \\mathbf{x}^\\top ist eine Zeile. Skalare bleiben mager: n, Z."},{"seite":"einfuehrung.html","titel":"Kapitel 1: Einführung in Operations Research — Vom Ursprung zur mathematischen Entscheidungsfindung","anker":null,"text":""},{"seite":"einfuehrung.html","titel":"1.1 In 5 Minuten gelöst","anker":"sec:einfuehrung-schnellstart","text":"Bevor wir über Theorie sprechen, lösen Sie ein echtes Problem. Kopieren Sie den folgenden Block, führen Sie ihn aus, sehen Sie das Ergebnis — die Erklärung kommt danach."},{"seite":"einfuehrung.html","titel":"1.2 Lernziele","anker":"sec:einfuehrung-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"einfuehrung.html","titel":"1.3 Was ist Operations Research wirklich?","anker":"sec:einfuehrung-was-ist-operations-research-wirklich","text":"Im Alltag lösen wir Probleme intuitiv: durch Ausprobieren, Daumenregeln, Erfahrung. Das funktioniert erstaunlich gut — bis eine bestimmte Schwelle überschritten wird. Dann bricht d"},{"seite":"einfuehrung.html","titel":"Die drei Stufen der Analytik","anker":"die-drei-stufen-der-analytik","text":"OR beantwortet nicht die beschreibende Frage „Was ist passiert?“ und nicht die vorhersagende Frage „Was wird passieren?“, sondern die vorschreibende (präskriptive) Kernfrage:"},{"seite":"einfuehrung.html","titel":"1.4 Warum Ausprobieren scheitert — mit eigener Rechnung","anker":"sec:einfuehrung-warum-ausprobieren-scheitert-mit-eigener-rechnung","text":"Die Behauptung „vollständiges Durchprobieren ist unmöglich“ wird oft aufgestellt und selten belegt. Rechnen wir sie einmal selbst nach — das Ergebnis prägt sich besser ein als jede"},{"seite":"einfuehrung.html","titel":"1.5 Historischer Kontext und Evolution","anker":"sec:einfuehrung-historischer-kontext-und-evolution","text":"Der Begriff entstand im Vorfeld des Zweiten Weltkriegs in Großbritannien. Militärische Führungsstäbe standen vor neuartigen logistischen und strategischen Fragen: Wie platziert man"},{"seite":"einfuehrung.html","titel":"1.6 Die vier universellen Bausteine jedes OR-Problems","anker":"sec:einfuehrung-die-vier-universellen-bausteine-jedes-or","text":"Dies ist der wichtigste Abschnitt des Kapitels. Jedes Optimierungsproblem der Welt lässt sich — unabhängig von der Branche — auf vier Elemente reduzieren. Wenn Sie später vor einem"},{"seite":"einfuehrung.html","titel":"Baustein 1 — Entscheidungsvariablen (x)","anker":"baustein-1-entscheidungsvariablen-x","text":"Die Stellschrauben des Modells: Werte, die der Algorithmus aktiv festlegen darf."},{"seite":"einfuehrung.html","titel":"Baustein 2 — Parameter / Eingabedaten (c, A, b)","anker":"baustein-2-parameter-eingabedaten-c-a-b","text":"Die unveränderlichen Fakten der Realität: historische Renditen, Maschinenstundensätze, Lagerkapazitäten, Steuerquoten, Entfernungen, Qualifikationen."},{"seite":"einfuehrung.html","titel":"Baustein 3 — Zielfunktion (f(x))","anker":"baustein-3-zielfunktion-fx","text":"Ein mathematischer Ausdruck, der die Güte einer Lösung zu einer einzigen Zahl verdichtet:"},{"seite":"einfuehrung.html","titel":"Baustein 4 — Nebenbedingungen (Constraints)","anker":"baustein-4-nebenbedingungen-constraints","text":"Regeln, die den Raum der erlaubten Lösungen einschränken:"},{"seite":"einfuehrung.html","titel":"Die vier Bausteine als eine Formel","anker":"die-vier-bausteine-als-eine-formel","text":"Setzt man die vier Bausteine zusammen, entsteht die Standardform, in der Ihnen jedes Optimierungsmodell dieses Buches begegnen wird. Sie sieht auf den ersten Blick sperrig aus — de"},{"seite":"einfuehrung.html","titel":"Die Bausteine in der Praxis: eine Vorlage","anker":"die-bausteine-in-der-praxis-eine-vorlage","text":"Damit die Zerlegung zur Gewohnheit wird, hier eine Vorlage, die Sie bei jedem neuen Problem ausfüllen können — vor der ersten Codezeile."},{"seite":"einfuehrung.html","titel":"1.7 Erstes Python-Vollbeispiel: Ressourcenallokation im Rechenzentrum","anker":"sec:einfuehrung-erstes-python-vollbeispiel-ressourcenallokation-im-rechenzentrum","text":""},{"seite":"einfuehrung.html","titel":"Szenario","anker":"szenario","text":"Ein quantitatives Handelshaus betreibt Serverkapazitäten für zwei Algorithmentypen:"},{"seite":"einfuehrung.html","titel":"Mathematische Formulierung","anker":"mathematische-formulierung","text":"Entscheidungsvariablen:"},{"seite":"einfuehrung.html","titel":"Umsetzung mit Google OR-Tools","anker":"umsetzung-mit-google-or-tools","text":""},{"seite":"einfuehrung.html","titel":"1.8 Von Excel zu Python: Ihre Daten liegen schon da","anker":"sec:einfuehrung-excel-bruecke","text":"In den allermeisten Betrieben liegen die Zahlen, die ein Optimierungsmodell braucht, nicht in einer Datenbank, sondern in einer Tabellenkalkulation. Und viele Leserinnen und Leser "},{"seite":"einfuehrung.html","titel":"1.9 Übungsaufgaben","anker":"sec:einfuehrung-uebungsaufgaben","text":""},{"seite":"einfuehrung.html","titel":"1.10 Finde den Denkfehler","anker":"sec:einfuehrung-denkfehler","text":"Ein Programm, das abstürzt, ist harmlos — Sie sehen sofort, dass etwas nicht stimmt. Gefährlich sind Programme, die fehlerfrei durchlaufen, einen Status OPTIMAL melden und eine Zah"},{"seite":"einfuehrung.html","titel":"1.11 Micro-Quiz","anker":"sec:einfuehrung-quiz","text":""},{"seite":"einfuehrung.html","titel":"1.12 Selbsttest","anker":"sec:einfuehrung-selbsttest","text":""},{"seite":"einfuehrung.html","titel":"1.13 Zusammenfassung","anker":"sec:einfuehrung-zusammenfassung","text":""},{"seite":"fundament.html","titel":"Kapitel 2: Das mathematische Fundament — Vektoren, Matrizen, Konvexität","anker":null,"text":""},{"seite":"fundament.html","titel":"2.1 In 5 Minuten gelöst","anker":"sec:fundament-schnellstart","text":"Dieses Kapitel handelt von Geometrie. Bevor wir Vektoren und Matrizen einführen, sehen Sie die zentrale Einsicht in Aktion — ganz ohne Solver, mit acht Zeilen NumPy."},{"seite":"fundament.html","titel":"2.2 Lernziele","anker":"sec:fundament-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"fundament.html","titel":"2.3 Warum überhaupt Vektoren und Matrizen?","anker":"sec:fundament-warum-ueberhaupt-vektoren-und-matrizen","text":"In Kapitel 1 hatten wir zwei Variablen und drei Nebenbedingungen. Das ließ sich bequem ausschreiben. Reale Modelle haben hunderte bis hunderttausende Variablen — dort wäre Ausschre"},{"seite":"fundament.html","titel":"Die Bausteine","anker":"die-bausteine","text":"Sei n die Anzahl der Entscheidungsvariablen und m die Anzahl der Nebenbedingungen."},{"seite":"fundament.html","titel":"Die kanonische Standardform","anker":"die-kanonische-standardform","text":"Damit lässt sich jedes lineare Programm in drei Zeilen schreiben:"},{"seite":"fundament.html","titel":"Dasselbe in Python","anker":"dasselbe-in-python","text":""},{"seite":"fundament.html","titel":"2.4 Der zulässige Lösungsraum und das Polyeder","anker":"sec:fundament-der-zulaessige-loesungsraum-und-das-polyeder","text":"Jede lineare Ungleichung a_{i1}x_1 + a_{i2}x_2 \\le b_i definiert geometrisch eine Halbebene (im \\mathbb{R}^2) bzw. einen Halbraum (im \\mathbb{R}^n): Die zugehörige Gleichung ist ei"},{"seite":"fundament.html","titel":"Der Fundamentalsatz der linearen Optimierung","anker":"der-fundamentalsatz-der-linearen-optimierung","text":""},{"seite":"fundament.html","titel":"2.5 Konvexität: die Grenze zwischen leicht und schwer","anker":"sec:fundament-konvexitaet-die-grenze-zwischen-leicht-und","text":"Hier kommt die vielleicht wichtigste Einsicht des ganzen Buches. In der Optimierungstheorie verläuft die Trennlinie zwischen „zuverlässig lösbar“ und „im Allgemeinen hoffnungslos“ "},{"seite":"fundament.html","titel":"Konvexe Menge","anker":"konvexe-menge","text":"Eine Menge \\mathcal{C} \\subseteq \\mathbb{R}^n heißt konvex, wenn für alle Punkte \\mathbf{x}, \\mathbf{y} \\in \\mathcal{C} und jedes \\theta \\in [0,1] gilt:"},{"seite":"fundament.html","titel":"Konvexe Funktion","anker":"konvexe-funktion","text":"Eine Funktion f: \\mathcal{C} \\to \\mathbb{R} heißt konvex, wenn für alle \\mathbf{x}, \\mathbf{y} \\in \\mathcal{C} und \\theta \\in [0,1] gilt:"},{"seite":"fundament.html","titel":"Der zentrale Satz","anker":"der-zentrale-satz","text":""},{"seite":"fundament.html","titel":"Was ist konvex, was nicht?","anker":"was-ist-konvex-was-nicht","text":""},{"seite":"fundament.html","titel":"Konvexität sichtbar machen","anker":"konvexität-sichtbar-machen","text":""},{"seite":"fundament.html","titel":"2.6 Geometrische Visualisierung des Lösungsraums","anker":"sec:fundament-geometrische-visualisierung-des-loesungsraums","text":"Wir zeichnen nun den Lösungsraum aus Kapitel 1 und lesen das Optimum ab. Zweidimensionale Bilder sind der schnellste Weg, Intuition für höhere Dimensionen aufzubauen — auch wenn ma"},{"seite":"fundament.html","titel":"2.7 Wenn die Zahlen nicht zusammenpassen: Kondition und Skalierung","anker":"sec:fundament-kondition","text":"Bis hierher war die lineare Algebra exakt. Ein Rechner rechnet aber nicht exakt, sondern mit rund 16 signifikanten Stellen — und Ihre Betriebsdaten sind ohnehin nur auf wenige Stel"},{"seite":"fundament.html","titel":"Die geometrische Vorstellung: zwei fast parallele Linien","anker":"die-geometrische-vorstellung-zwei-fast-parallele-linien","text":"Zwei Nebenbedingungen legen im Zweidimensionalen einen Schnittpunkt fest. Stehen die zugehörigen Geraden kreuzweise aufeinander, ist der Schnittpunkt robust: Verschiebt man eine Ge"},{"seite":"fundament.html","titel":"Woher schlechte Kondition im Alltag kommt","anker":"woher-schlechte-kondition-im-alltag-kommt","text":"Fast nie aus exotischer Mathematik — fast immer aus Einheiten:"},{"seite":"fundament.html","titel":"Ruiz-Equilibrierung: das Modell gesundrechnen","anker":"ruiz-equilibrierung-das-modell-gesundrechnen","text":"Die Gegenmaßnahme ist erstaunlich schlicht. Man multipliziert jede Zeile und jede Spalte der Matrix mit einem Faktor, sodass die Beträge überall in derselben Größenordnung landen:"},{"seite":"fundament.html","titel":"Das Experiment","anker":"das-experiment","text":"Das folgende Programm führt beides vor — die Fehlerverstärkung und ihre Behebung — und schließt mit der praktischen Konsequenz, die Sie in Kapitel 6 wieder brauchen werden: warum e"},{"seite":"fundament.html","titel":"2.8 Übungsaufgaben","anker":"sec:fundament-uebungsaufgaben","text":""},{"seite":"fundament.html","titel":"2.9 Finde den Denkfehler","anker":"sec:fundament-denkfehler","text":""},{"seite":"fundament.html","titel":"2.10 Micro-Quiz","anker":"sec:fundament-quiz","text":""},{"seite":"fundament.html","titel":"2.11 Selbsttest","anker":"sec:fundament-selbsttest","text":""},{"seite":"fundament.html","titel":"2.12 Zusammenfassung","anker":"sec:fundament-zusammenfassung","text":""},{"seite":"oekosystem.html","titel":"Kapitel 3: Das Python-Ökosystem für OR — Solver, Bindings und Modellierungsschichten","anker":null,"text":""},{"seite":"oekosystem.html","titel":"3.1 In 5 Minuten gelöst","anker":"sec:oekosystem-schnellstart","text":""},{"seite":"oekosystem.html","titel":"3.2 Lernziele","anker":"sec:oekosystem-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"oekosystem.html","titel":"3.3 Die Zwei-Schichten-Architektur","anker":"sec:oekosystem-die-zwei-schichten-architektur","text":"Im modernen Operations Research schreibt man Optimierungsalgorithmen nicht selbst. Man nutzt eine zweischichtige Architektur:"},{"seite":"oekosystem.html","titel":"3.4 Die Werkzeuge im Vergleich","anker":"sec:oekosystem-die-werkzeuge-im-vergleich","text":""},{"seite":"oekosystem.html","titel":"Die Entscheidung in drei Fragen","anker":"die-entscheidung-in-drei-fragen","text":"Statt die Tabelle auswendig zu lernen, beantworten Sie drei Fragen:"},{"seite":"oekosystem.html","titel":"3.5 Ein System — vier Programmieransätze","anker":"sec:oekosystem-ein-system-vier-programmieransaetze","text":"Nichts macht die Unterschiede so deutlich wie dasselbe Problem, viermal gelöst. Wir nehmen:"},{"seite":"oekosystem.html","titel":"Was das CSR-Format bedeutet","anker":"was-das-csr-format-bedeutet","text":"highspy erwartet die Nebenbedingungsmatrix im CSR-Format (Compressed Sparse Row, komprimierte Zeilendarstellung). Statt der vollen Matrix speichert man nur die Einträge ungleich nu"},{"seite":"oekosystem.html","titel":"3.6 Wann lohnt sich welche Ebene?","anker":"sec:oekosystem-wann-lohnt-sich-welche-ebene","text":""},{"seite":"oekosystem.html","titel":"3.7 Modellierungsschichten für große Modelle: Pyomo und Linopy","anker":"sec:oekosystem-pyomo-linopy","text":"Die vier Bibliotheken aus dem Vierfach-Vergleich decken den Alltag weitgehend ab. Sobald Modelle industrielle Größe erreichen — zehntausende Variablen, Daten aus mehreren Systemen,"},{"seite":"oekosystem.html","titel":"Pyomo: die algebraische Denkweise","anker":"pyomo-die-algebraische-denkweise","text":"Pyomo ist im deutschsprachigen Raum der De-facto-Standard für große LP- und MILP-Modelle in Energiewirtschaft, Chemie und Logistik. Sein Kennzeichen: Es denkt in Mengen und Indizes"},{"seite":"oekosystem.html","titel":"Linopy: eine Zeile, zehntausend Nebenbedingungen","anker":"linopy-eine-zeile-zehntausend-nebenbedingungen","text":"Linopy verfolgt einen anderen Ansatz: Variablen sind beschriftete Arrays (xarray), keine indizierten Einzelobjekte. Damit wird aus"},{"seite":"oekosystem.html","titel":"Die Schichten im Überblick","anker":"die-schichten-im-überblick","text":""},{"seite":"oekosystem.html","titel":"3.8 Wo die Zeit wirklich hingeht: vektorisierte Modellgenerierung","anker":"sec:oekosystem-vektorisierung","text":"Eine der hartnäckigsten Fehlannahmen in Optimierungsprojekten lautet: „Wenn es zu langsam ist, brauchen wir einen besseren Solver.“ Messen Sie erst — oft stimmt das nicht."},{"seite":"oekosystem.html","titel":"Vier Stufen an einem Transportproblem","anker":"vier-stufen-an-einem-transportproblem","text":"Wir bauen dasselbe Transportproblem (m Werke, n Kunden, m \\cdot n Variablen) auf vier Arten auf:"},{"seite":"oekosystem.html","titel":"3.9 Übungsaufgaben","anker":"sec:oekosystem-uebungsaufgaben","text":""},{"seite":"oekosystem.html","titel":"3.10 Finde den Denkfehler","anker":"sec:oekosystem-denkfehler","text":""},{"seite":"oekosystem.html","titel":"3.11 Micro-Quiz","anker":"sec:oekosystem-quiz","text":""},{"seite":"oekosystem.html","titel":"3.12 Selbsttest","anker":"sec:oekosystem-selbsttest","text":""},{"seite":"oekosystem.html","titel":"3.13 Zusammenfassung","anker":"sec:oekosystem-zusammenfassung","text":""},{"seite":"modellierung.html","titel":"Kapitel 4: Vom Management-Wunsch zum Modell","anker":null,"text":""},{"seite":"modellierung.html","titel":"4.1 In 5 Minuten gelöst","anker":"sec:modellierung-schnellstart","text":""},{"seite":"modellierung.html","titel":"4.2 Lernziele","anker":"sec:modellierung-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"modellierung.html","titel":"4.3 Ein Satz ist noch kein Modell","anker":"sec:modellierung-vier-fragen","text":"Was aus einer Besprechung mitkommt, klingt meist so:"},{"seite":"modellierung.html","titel":"4.4 Derselbe Datensatz, fünf Modelle","anker":"sec:modellierung-fuenf-modelle","text":"Das Programm dieses Kapitels arbeitet die vier Fragen an einer Auftragsannahme ab: 14 Anfragen verlangen 800 Maschinenstunden, verfügbar sind 300."},{"seite":"modellierung.html","titel":"Teil 1: Drei Ziele, drei Pläne","anker":"teil-1-drei-ziele-drei-pläne","text":""},{"seite":"modellierung.html","titel":"Teil 2: Ein Satz, drei Lesarten","anker":"teil-2-ein-satz-drei-lesarten","text":"„Stammkunden dürfen wir nicht verlieren.” Jeder im Raum nickt. Niemand merkt, dass drei verschiedene Modelle gemeint sein können:"},{"seite":"modellierung.html","titel":"Teil 3: Hart oder weich","anker":"teil-3-hart-oder-weich","text":"Dieselbe Regel als Strafkosten je abgelehntem Stammkundenauftrag:"},{"seite":"modellierung.html","titel":"Teil 4: Ein Quartal später","anker":"teil-4-ein-quartal-später","text":"Die harte Lesart A wurde eingebaut, alles lief drei Monate lang. Dann fragt Weber einen Auftrag über 320 Maschinenstunden an — mehr, als der ganze Zeitraum hergibt."},{"seite":"modellierung.html","titel":"4.5 Der Gesprächsleitfaden","anker":"sec:modellierung-leitfaden","text":"Was aus den vier Teilen als Handwerkszeug bleibt — Fragen, die man in der ersten Besprechung stellt, bevor eine Zeile Code entsteht:"},{"seite":"modellierung.html","titel":"4.6 Übungsaufgaben","anker":"sec:modellierung-uebungsaufgaben","text":""},{"seite":"modellierung.html","titel":"4.7 Finde den Denkfehler","anker":"sec:modellierung-denkfehler","text":""},{"seite":"modellierung.html","titel":"4.8 Micro-Quiz","anker":"sec:modellierung-quiz","text":""},{"seite":"modellierung.html","titel":"4.9 Selbsttest","anker":"sec:modellierung-selbsttest","text":""},{"seite":"modellierung.html","titel":"4.10 Zusammenfassung","anker":"sec:modellierung-zusammenfassung","text":""},{"seite":"lp.html","titel":"Kapitel 5: Lineare Programmierung — Simplex, Dualität und Schattenpreise","anker":null,"text":""},{"seite":"lp.html","titel":"5.1 In 5 Minuten gelöst","anker":"sec:lp-schnellstart","text":"Bisher hat ein Solver Ihnen gesagt, was Sie tun sollen. Jetzt sagt er Ihnen zusätzlich, was eine zusätzliche Stunde wert wäre — und das ist die Zahl, für die Sie im Betrieb gehört "},{"seite":"lp.html","titel":"5.2 Lernziele","anker":"sec:lp-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"lp.html","titel":"5.3 Die Standardform und Schlupfvariablen","anker":"sec:lp-die-standardform-und-schlupfvariablen","text":"Lineare Programme zeichnen sich dadurch aus, dass Zielfunktion und alle Nebenbedingungen strikt linear sind — keine Produkte von Variablen, keine Quadrate, keine Logarithmen."},{"seite":"lp.html","titel":"5.4 Der Simplex-Algorithmus Schritt für Schritt","anker":"sec:lp-der-simplex-algorithmus-schritt-fuer-schritt","text":"Der von George Dantzig 1947 formulierte Simplex-Algorithmus nutzt den Fundamentalsatz aus Kapitel 2: Das Optimum liegt in einer Ecke. Statt alle Ecken aufzuzählen, wandert er von E"},{"seite":"lp.html","titel":"Der Ablauf","anker":"der-ablauf","text":""},{"seite":"lp.html","titel":"5.5 Das Simplex-Tableau in Python","anker":"sec:lp-das-simplex-tableau-in-python","text":""},{"seite":"lp.html","titel":"5.6 Dualität und Schattenpreise","anker":"sec:lp-dualitaet-und-schattenpreise","text":"Zu jedem linearen Optimierungsproblem (dem primalen Problem) existiert ein spiegelbildliches Zwillingsproblem: das duale Problem."},{"seite":"lp.html","titel":"Der starke Dualitätssatz","anker":"der-starke-dualitätssatz","text":""},{"seite":"lp.html","titel":"Der Schattenpreis als Ableitung","anker":"der-schattenpreis-als-ableitung","text":"Der Dualwert y_i^* ist die Grenzproduktivität der Ressource i:"},{"seite":"lp.html","titel":"5.7 Die Vorzeichenfalle bei Schattenpreisen","anker":"sec:lp-die-vorzeichenfalle-bei-schattenpreisen","text":"Diese Falle sollten Sie sich merken, weil sie in jedem Projekt wieder auftaucht."},{"seite":"lp.html","titel":"5.8 Praxisfall: Sensitivitätsanalyse mit korrekten Schattenpreisen","anker":"sec:lp-praxisfall-sensitivitaetsanalyse-mit-korrekten-schattenpreisen","text":"Szenario. Ein Fertigungsbetrieb stellt drei Produkte her und will wissen:"},{"seite":"lp.html","titel":"5.9 Wann Schattenpreise lügen: Entartung und Toleranzen","anker":"sec:lp-entartung","text":"Der vorige Abschnitt hat gezeigt, wie wertvoll Schattenpreise sind. Dieser zeigt die zwei Fälle, in denen sie eine Entscheidung nicht tragen — und wie man beide erkennt, bevor jema"},{"seite":"lp.html","titel":"Fall 1: Entartung","anker":"fall-1-entartung","text":"Erinnern Sie sich an Abschnitt 2.4: In zwei Dimensionen legen zwei sich schneidende Geraden eine Ecke fest, in drei Dimensionen drei Ebenen, allgemein n Restriktionen bei n Variabl"},{"seite":"lp.html","titel":"Fall 2: Toleranzen","anker":"fall-2-toleranzen","text":"Der zweite Fall ist unscheinbarer und deshalb häufiger. Wie stellt man fest, ob eine Ressource ausgelastet ist? Naheliegend wäre if schlupf == 0. Genau das ist falsch."},{"seite":"lp.html","titel":"Die ehrliche Auskunft: Schattenpreis-Spannen","anker":"die-ehrliche-auskunft-schattenpreis-spannen","text":"Was tut man also, wenn das Modell entartet ist? Man meldet keinen Einzelwert, sondern die Spanne. Die Menge aller optimalen Dualvektoren ist selbst ein Polyeder:"},{"seite":"lp.html","titel":"5.10 Übungsaufgaben","anker":"sec:lp-uebungsaufgaben","text":""},{"seite":"lp.html","titel":"5.11 Finde den Denkfehler","anker":"sec:lp-denkfehler","text":""},{"seite":"lp.html","titel":"5.12 Micro-Quiz","anker":"sec:lp-quiz","text":""},{"seite":"lp.html","titel":"5.13 Selbsttest","anker":"sec:lp-selbsttest","text":""},{"seite":"lp.html","titel":"5.14 Zusammenfassung","anker":"sec:lp-zusammenfassung","text":""},{"seite":"milp.html","titel":"Kapitel 6: Gemischt-ganzzahlige Optimierung — Diskrete Entscheidungen und Branch-and-Bound","anker":null,"text":""},{"seite":"milp.html","titel":"6.1 In 5 Minuten gelöst","anker":"sec:milp-schnellstart","text":""},{"seite":"milp.html","titel":"6.2 Lernziele","anker":"sec:milp-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"milp.html","titel":"6.3 Warum Runden fundamental scheitert","anker":"sec:milp-warum-runden-fundamental-scheitert","text":"In der realen Welt sind viele Entscheidungen nicht teilbar. Man kann nicht 0,47 Flugzeuge kaufen, keine halbe Lagerhalle bauen, keinen Mitarbeiter zu 38 % einstellen. Noch wichtige"},{"seite":"milp.html","titel":"6.4 Branch-and-Bound","anker":"sec:milp-branch-and-bound","text":"MILP-Probleme sind NP-schwer. Der Standardansatz moderner Solver (HiGHS, SCIP, Gurobi) heißt Branch-and-Cut — Branch-and-Bound plus Schnittebenen."},{"seite":"milp.html","titel":"Die drei Phasen","anker":"die-drei-phasen","text":""},{"seite":"milp.html","titel":"6.5 Modellierungstricks: Big-M und logische Bedingungen","anker":"sec:milp-modellierungstricks-big-m-und-logische-bedingungen","text":"Hier kommt das Handwerkszeug, das MILP so mächtig macht: Man übersetzt Geschäftsregeln in lineare Ungleichungen — mithilfe binärer Hilfsvariablen y \\in \\{0,1\\} und einer hinreichen"},{"seite":"milp.html","titel":"Muster 1 — Fixkosten / Aktivierungsschalter","anker":"muster-1-fixkosten-aktivierungsschalter","text":"Regel: Wird Maschine j genutzt (x_j > 0), fallen Rüstkosten F_j an."},{"seite":"milp.html","titel":"Muster 2 — Entweder-Oder (disjunctive constraints)","anker":"muster-2-entweder-oder-disjunctive-constraints","text":"Regel: Es muss entweder f(\\mathbf{x}) \\le b_1 oder g(\\mathbf{x}) \\le b_2 gelten."},{"seite":"milp.html","titel":"Muster 3 — Wenn-Dann (Implikation)","anker":"muster-3-wenn-dann-implikation","text":"Regel: Wenn y_1 = 1, dann muss auch y_2 = 1 sein."},{"seite":"milp.html","titel":"Muster 4 — Kardinalität („höchstens K aus N“)","anker":"muster-4-kardinalität-höchstens-k-aus-n","text":"\\sum_{j=1}^N y_j \\le K"},{"seite":"milp.html","titel":"Muster 5 — Semikontinuierlich („entweder 0 oder mindestens L“)","anker":"muster-5-semikontinuierlich-entweder-0-oder-mindestens-l","text":"Regel: Eine Position ist entweder gar nicht besetzt oder mit mindestens L Euro."},{"seite":"milp.html","titel":"6.6 Beispiel: Das Rucksackproblem","anker":"sec:milp-beispiel-das-rucksackproblem","text":"Bevor wir zum Portfolio kommen, das klassische Einstiegsproblem — kurz, verständlich und überall wiederzuerkennen."},{"seite":"milp.html","titel":"6.7 Praxisfall: Portfolio mit Ordergebühren und Kardinalitätsgrenze","anker":"sec:milp-praxisfall-portfolio-mit-ordergebuehren-und-kardinalitaetsgrenze","text":"Szenario. Ein Investor verteilt 100\\,000\\,\\text{€} auf sechs Anlageklassen."},{"seite":"milp.html","titel":"6.8 Wenn der Solver nicht fertig wird: Gap, Zeitlimit und Warm-Start","anker":"sec:milp-gap","text":"Bei einem LP gibt es zwei Ausgänge: eine optimale Lösung oder eine klare Absage. Bei einem MILP gibt es einen dritten, und im Betrieb ist er der häufigste:"},{"seite":"milp.html","titel":"Die zwei Zahlen, die zählen","anker":"die-zwei-zahlen-die-zählen","text":"Branch-and-Bound führt zu jedem Zeitpunkt zwei Werte mit:"},{"seite":"milp.html","titel":"Alle Statusfälle behandeln","anker":"alle-statusfälle-behandeln","text":"Der zweite Teil der Professionalität ist unspektakulär, aber entscheidend: jeden Rückgabewert auswerten, nicht nur OPTIMAL."},{"seite":"milp.html","titel":"Warm-Starts: was sie können und was nicht","anker":"warm-starts-was-sie-können-und-was-nicht","text":"Ein Warm-Start gibt dem Solver eine bekannte Lösung als Startpunkt mit. Die Idee ist verlockend: Wer schon eine brauchbare Lösung hat, muss nicht bei null anfangen."},{"seite":"milp.html","titel":"6.9 Übungsaufgaben","anker":"sec:milp-uebungsaufgaben","text":""},{"seite":"milp.html","titel":"6.10 Finde den Denkfehler","anker":"sec:milp-denkfehler","text":"Die Warnung „M so klein wie möglich“ wird meist mit der Laufzeit begründet: schwache Relaxation, mehr Knoten. Das stimmt — und ist die harmlosere Hälfte der Wahrheit."},{"seite":"milp.html","titel":"6.11 Micro-Quiz","anker":"sec:milp-quiz","text":""},{"seite":"milp.html","titel":"6.12 Selbsttest","anker":"sec:milp-selbsttest","text":""},{"seite":"milp.html","titel":"6.13 Zusammenfassung","anker":"sec:milp-zusammenfassung","text":""},{"seite":"cpsat.html","titel":"Kapitel 7: Constraint Programming mit CP-SAT — Logik, Scheduling und Zuweisung","anker":null,"text":""},{"seite":"cpsat.html","titel":"7.1 In 5 Minuten gelöst","anker":"sec:cpsat-schnellstart","text":""},{"seite":"cpsat.html","titel":"7.2 Lernziele","anker":"sec:cpsat-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"cpsat.html","titel":"7.3 Ein anderes Denkmodell","anker":"sec:cpsat-ein-anderes-denkmodell","text":"Während LP und MILP auf algebraischen Gleichungen und Simplex-Matrizen beruhen, kommt Constraint Programming (CP) aus der künstlichen Intelligenz:"},{"seite":"cpsat.html","titel":"Was Propagation leistet","anker":"was-propagation-leistet","text":""},{"seite":"cpsat.html","titel":"7.4 Globale Constraints — die Bausteine von CP-SAT","anker":"sec:cpsat-globale-constraints-die-bausteine-von-cp","text":"CP-SAT stellt mächtige Makros bereit, die in MILP hunderte Big-M-Ungleichungen erfordern würden. Jedes davon hat einen spezialisierten, hocheffizienten Propagator."},{"seite":"cpsat.html","titel":"7.5 Praxisfall: Dynamisches Vertretungssystem","anker":"sec:cpsat-praxisfall-dynamisches-vertretungssystem","text":"Problemstellung. Eine Schule muss nach spontanen Ausfällen vier Unterrichtsstunden nachbesetzen."},{"seite":"cpsat.html","titel":"7.6 Intervallvariablen: Job-Shop-Scheduling","anker":"sec:cpsat-intervallvariablen-job-shop-scheduling","text":"Die Königsdisziplin von CP-SAT sind Zeitplanungsprobleme. Intervallvariablen sind dabei das zentrale Werkzeug."},{"seite":"cpsat.html","titel":"7.7 Die fünf Antworten von CP-SAT","anker":"sec:cpsat-statusfaelle","text":"Abschnitt 6.8 hat gezeigt, warum OPTIMAL bei ganzzahligen Problemen nicht der Normalfall ist. Bei CP-SAT kommt eine Besonderheit hinzu: Es gibt fünf verschiedene Antworten, und dre"},{"seite":"cpsat.html","titel":"7.8 Übungsaufgaben","anker":"sec:cpsat-uebungsaufgaben","text":""},{"seite":"cpsat.html","titel":"7.9 Finde den Denkfehler","anker":"sec:cpsat-denkfehler","text":""},{"seite":"cpsat.html","titel":"7.10 Micro-Quiz","anker":"sec:cpsat-quiz","text":""},{"seite":"cpsat.html","titel":"7.11 Selbsttest","anker":"sec:cpsat-selbsttest","text":""},{"seite":"cpsat.html","titel":"7.12 Zusammenfassung","anker":"sec:cpsat-zusammenfassung","text":""},{"seite":"graphen.html","titel":"Kapitel 8: Graphen, Flüsse und Touren — Min-Cost-Flow, Matching und VRP","anker":null,"text":""},{"seite":"graphen.html","titel":"8.1 In 5 Minuten gelöst","anker":"sec:graphen-schnellstart","text":""},{"seite":"graphen.html","titel":"8.2 Lernziele","anker":"sec:graphen-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"graphen.html","titel":"8.3 Graphen als Modellsprache","anker":"sec:graphen-graphen-als-modellsprache","text":"Viele reale Probleme in Logistik, Kommunikation und Finanzströmen sind keine flachen Ungleichungssysteme, sondern Graphen G = (V, E):"},{"seite":"graphen.html","titel":"Das Minimum-Cost-Flow-Problem (MCNFP)","anker":"das-minimum-cost-flow-problem-mcnfp","text":"Minimum-Cost Network Flow Problem — deutsch: kostenminimales Flussproblem. Es ist das mathematische Fundament für Transportketten, Liquiditätsrouting und Datenverteilung."},{"seite":"graphen.html","titel":"8.4 Bipartites Matching: das Zuordnungsproblem","anker":"sec:graphen-bipartites-matching-das-zuordnungsproblem","text":"Wenn N Aufgaben auf N Ressourcen eins zu eins verteilt werden — Orders auf Broker, Schichten auf Mitarbeitende, Aufträge auf Maschinen — spricht man von bipartitem Matching."},{"seite":"graphen.html","titel":"Der Satz von Birkhoff und von Neumann","anker":"der-satz-von-birkhoff-und-von-neumann","text":""},{"seite":"graphen.html","titel":"8.5 Das Vehicle Routing Problem mit Zeitfenstern","anker":"sec:graphen-das-vehicle-routing-problem-mit-zeitfenstern","text":"Das Traveling Salesperson Problem (TSP), deutsch Problem des Handlungsreisenden, fragt nach der kürzesten Rundreise durch N Städte. Das Capacitated Vehicle Routing Problem with Tim"},{"seite":"graphen.html","titel":"Kurzzyklen verhindern","anker":"kurzzyklen-verhindern","text":"Ein naives Modell erlaubt Subtouren: isolierte Kreise, die das Depot nie anfahren. Die klassische Gegenmaßnahme ist die MTZ-Formulierung nach Miller, Tucker und Zemlin. Man führt R"},{"seite":"graphen.html","titel":"Praxisbeispiel: Flotten-Routing","anker":"praxisbeispiel-flotten-routing","text":""},{"seite":"graphen.html","titel":"8.6 Übungsaufgaben","anker":"sec:graphen-uebungsaufgaben","text":""},{"seite":"graphen.html","titel":"8.7 Finde den Denkfehler","anker":"sec:graphen-denkfehler","text":""},{"seite":"graphen.html","titel":"8.8 Micro-Quiz","anker":"sec:graphen-quiz","text":""},{"seite":"graphen.html","titel":"8.9 Selbsttest","anker":"sec:graphen-selbsttest","text":""},{"seite":"graphen.html","titel":"8.10 Zusammenfassung","anker":"sec:graphen-zusammenfassung","text":""},{"seite":"metaheuristiken.html","titel":"Kapitel 9: Metaheuristiken — wenn der exakte Solver aussteigt","anker":null,"text":""},{"seite":"metaheuristiken.html","titel":"9.1 In 5 Minuten gelöst","anker":"sec:metaheuristiken-schnellstart","text":""},{"seite":"metaheuristiken.html","titel":"9.2 Lernziele","anker":"sec:metaheuristiken-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"metaheuristiken.html","titel":"9.3 Die Aufgabe: Rüstzeiten an der Lackieranlage","anker":"sec:metaheuristiken-aufgabe","text":"Der Schnellstart war ein Spielzeug. Die Aufgabe dieses Kapitels ist dieselbe, nur in Betriebsgröße: n Aufträge in eine Reihenfolge bringen, so dass die Summe der Rüstzeiten minimal"},{"seite":"metaheuristiken.html","titel":"Die Instanz","anker":"die-instanz","text":"Alle Programme dieses Kapitels benutzen dieselbe Aufgabe. Die Rüstzeit setzt sich aus zwei Teilen zusammen: einem unregelmäßigen Reinigungsaufwand zwischen den Produktfamilien und "},{"seite":"metaheuristiken.html","titel":"9.4 Lokale Suche: der Zug und seine Kosten","anker":"sec:metaheuristiken-lokale-suche","text":"Eine lokale Suche braucht drei Dinge: eine Startlösung, einen Zug und eine Regel, welche Züge übernommen werden."},{"seite":"metaheuristiken.html","titel":"9.5 Simulated Annealing","anker":"sec:metaheuristiken-annealing","text":"Reines Bergsteigen — nur Verbesserungen annehmen — bleibt im ersten lokalen Optimum stehen. Simulated Annealing nimmt Verschlechterungen mit einer Wahrscheinlichkeit an, die von de"},{"seite":"metaheuristiken.html","titel":"Was die Messung zeigt — und was sie nicht zeigt","anker":"was-die-messung-zeigt-und-was-sie-nicht-zeigt","text":"Erstens: Der Beitrag des Annealings ist bescheiden. Bergsteigen kommt auf 917 Minuten, Annealing auf 907 — das simple Verfahren holt 93 % des Ertrags. Das ist kein Argument gegen A"},{"seite":"metaheuristiken.html","titel":"9.6 Ab wann lohnt es sich?","anker":"sec:metaheuristiken-umschlagpunkt","text":"„Kleine Probleme exakt, große mit Metaheuristiken“ ist richtig und nutzlos, solange niemand sagt, wo groß anfängt. Diese Zahl lässt sich messen."},{"seite":"metaheuristiken.html","titel":"Die Spalte, die man nicht weglassen darf","anker":"die-spalte-die-man-nicht-weglassen-darf","text":"Bei 500 Aufträgen haben wir die CP-SAT-Lösung verworfen. Seine Schranke ist trotzdem das Wertvollste in der ganzen Tabelle."},{"seite":"metaheuristiken.html","titel":"9.7 Large Neighborhood Search","anker":"sec:metaheuristiken-lns","text":"Die Tabelle oben endet mit einem Widerspruch. Bei 500 Aufträgen scheitert der exakte Solver an der Größe; die Metaheuristik scheitert an der Kleinheit ihrer Züge — sie verschiebt j"},{"seite":"metaheuristiken.html","titel":"Das Fenster ist die eine Stellschraube","anker":"das-fenster-ist-die-eine-stellschraube","text":""},{"seite":"metaheuristiken.html","titel":"Das Gesamtbild","anker":"das-gesamtbild","text":""},{"seite":"metaheuristiken.html","titel":"9.8 Was dieses Kapitel nicht behandelt","anker":"sec:metaheuristiken-abgrenzung","text":"Damit die Landkarte vollständig ist — drei Verfahren, die in dieselbe Familie gehören und hier bewusst nur benannt werden:"},{"seite":"metaheuristiken.html","titel":"9.9 Übungsaufgaben","anker":"sec:metaheuristiken-uebungsaufgaben","text":""},{"seite":"metaheuristiken.html","titel":"9.10 Finde den Denkfehler","anker":"sec:metaheuristiken-denkfehler","text":""},{"seite":"metaheuristiken.html","titel":"9.11 Micro-Quiz","anker":"sec:metaheuristiken-quiz","text":""},{"seite":"metaheuristiken.html","titel":"9.12 Selbsttest","anker":"sec:metaheuristiken-selbsttest","text":""},{"seite":"metaheuristiken.html","titel":"9.13 Zusammenfassung","anker":"sec:metaheuristiken-zusammenfassung","text":""},{"seite":"dekomposition.html","titel":"Kapitel 10: Spaltengenerierung — das Modell umbauen statt die Lösung raten","anker":null,"text":""},{"seite":"dekomposition.html","titel":"10.1 In 5 Minuten gelöst","anker":"sec:dekomposition-schnellstart","text":""},{"seite":"dekomposition.html","titel":"10.2 Lernziele","anker":"sec:dekomposition-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"dekomposition.html","titel":"10.3 Warum das naheliegende Modell scheitert","anker":"sec:dekomposition-symmetrie","text":"Der erste Entwurf eines Zuschnittmodells sieht fast immer so aus: eine Binärvariable z_{jr} für „Stück j liegt auf Rolle r“, dazu y_r für „Rolle r wird benutzt”."},{"seite":"dekomposition.html","titel":"10.4 Rechnen mit Mustern, ohne sie aufzuschreiben","anker":"sec:dekomposition-schleife","text":"Die Idee ist alt (Gilmore und Gomory, 1961) und in einem Satz gesagt:"},{"seite":"dekomposition.html","titel":"10.5 Das Programm","anker":"sec:dekomposition-programm","text":""},{"seite":"dekomposition.html","titel":"10.6 Was die Zahlen zeigen","anker":"sec:dekomposition-befund","text":""},{"seite":"dekomposition.html","titel":"Die Musterzahl explodiert, der Bedarf nicht","anker":"die-musterzahl-explodiert-der-bedarf-nicht","text":""},{"seite":"dekomposition.html","titel":"Der unbequeme Teil: Es lohnt sich nicht immer","anker":"der-unbequeme-teil-es-lohnt-sich-nicht-immer","text":""},{"seite":"dekomposition.html","titel":"10.7 Übungsaufgaben","anker":"sec:dekomposition-uebungsaufgaben","text":""},{"seite":"dekomposition.html","titel":"10.8 Finde den Denkfehler","anker":"sec:dekomposition-denkfehler","text":""},{"seite":"dekomposition.html","titel":"10.9 Micro-Quiz","anker":"sec:dekomposition-quiz","text":""},{"seite":"dekomposition.html","titel":"10.10 Selbsttest","anker":"sec:dekomposition-selbsttest","text":""},{"seite":"dekomposition.html","titel":"10.11 Zusammenfassung","anker":"sec:dekomposition-zusammenfassung","text":""},{"seite":"qp-nlp.html","titel":"Kapitel 11: Quadratische und nichtlineare Optimierung — KKT, Lagrange, Konvexität","anker":null,"text":""},{"seite":"qp-nlp.html","titel":"11.1 In 5 Minuten gelöst","anker":"sec:qp-nlp-schnellstart","text":""},{"seite":"qp-nlp.html","titel":"11.2 Lernziele","anker":"sec:qp-nlp-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"qp-nlp.html","titel":"Auffrischung: Gradient in einer Minute","anker":"auffrischung-gradient-in-einer-minute","text":"Der Gradient \\nabla f(\\mathbf{x}) ist der Vektor aller partiellen Ableitungen:"},{"seite":"qp-nlp.html","titel":"11.3 Das quadratische Programm (QP)","anker":"sec:qp-nlp-das-quadratische-programm-qp","text":"Ein quadratisches Programm hat eine quadratische Zielfunktion und lineare Nebenbedingungen:"},{"seite":"qp-nlp.html","titel":"Konvexität: die präzise Aussage","anker":"konvexität-die-präzise-aussage","text":"Eine verbreitete, aber zu stark formulierte Aussage lautet: „Wenn \\mathbf{P} positiv semi-definit ist, ist das QP streng konvex; es existiert ein eindeutiges globales Minimum.“ Kor"},{"seite":"qp-nlp.html","titel":"11.4 Die Karush-Kuhn-Tucker-Bedingungen","anker":"sec:qp-nlp-die-karush-kuhn-tucker-bedingungen","text":"Die KKT-Bedingungen verallgemeinern den Lagrange-Ansatz auf Probleme mit Ungleichungen. Sie sind notwendig für ein Optimum — und bei konvexen Problemen auch hinreichend."},{"seite":"qp-nlp.html","titel":"Die vier KKT-Bedingungen","anker":"die-vier-kkt-bedingungen","text":"Am Optimum (\\mathbf{x}^*, \\boldsymbol{\\lambda}^*, \\boldsymbol{\\nu}^*) gilt:"},{"seite":"qp-nlp.html","titel":"11.5 Nichtlineare Optimierung mit scipy.optimize.minimize","anker":"sec:qp-nlp-nichtlineare-optimierung-mit-scipy-optimize-minimize","text":"Wenn Zielfunktion oder Nebenbedingungen weder linear noch quadratisch sind — Logarithmen, Wurzeln, Exponentialfunktionen — nutzt man gradientenbasierte Verfahren wie SLSQP (Sequent"},{"seite":"qp-nlp.html","titel":"Praxisfall: Entropie-maximierte Kapitalallokation","anker":"praxisfall-entropie-maximierte-kapitalallokation","text":"Wir optimieren ein Portfolio nicht nur nach Varianz, sondern maximieren zusätzlich die Shannon-Entropie der Gewichte, um Klumpenrisiken glatt zu bestrafen:"},{"seite":"qp-nlp.html","titel":"11.6 Jenseits der Konvexität: lokale Optima, Multistart und MINLP","anker":"sec:qp-nlp-nichtkonvex","text":"Alles bisher in diesem Kapitel stand unter einer Bedingung: Konvexität. Sie ist der Grund, warum CVXPY eine Optimalitätsgarantie geben kann und warum die KKT-Bedingungen nicht nur "},{"seite":"qp-nlp.html","titel":"Woher Nicht-Konvexität im Alltag kommt","anker":"woher-nicht-konvexität-im-alltag-kommt","text":"Nicht aus exotischer Mathematik, sondern aus ganz gewöhnlichen betriebswirtschaftlichen Effekten:"},{"seite":"qp-nlp.html","titel":"Was ein lokales Optimum praktisch bedeutet","anker":"was-ein-lokales-optimum-praktisch-bedeutet","text":"scipy.optimize.minimize verweigert nichts. Es rechnet, meldet success: True und liefert eine Zahl. Diese Meldung heißt aber nicht „das ist das Optimum“, sondern nur:"},{"seite":"qp-nlp.html","titel":"Die Zahlen im Klartext","anker":"die-zahlen-im-klartext","text":""},{"seite":"qp-nlp.html","titel":"Ausblick: MINLP und globale Solver","anker":"ausblick-minlp-und-globale-solver","text":"Wird zusätzlich noch ganzzahlig entschieden — „welche Anlage läuft überhaupt“ und „wie stark“ —, entsteht ein MINLP (Mixed-Integer Nonlinear Program), die schwierigste der in diese"},{"seite":"qp-nlp.html","titel":"11.7 Übungsaufgaben","anker":"sec:qp-nlp-uebungsaufgaben","text":""},{"seite":"qp-nlp.html","titel":"11.8 Finde den Denkfehler","anker":"sec:qp-nlp-denkfehler","text":""},{"seite":"qp-nlp.html","titel":"11.9 Micro-Quiz","anker":"sec:qp-nlp-quiz","text":""},{"seite":"qp-nlp.html","titel":"11.10 Selbsttest","anker":"sec:qp-nlp-selbsttest","text":""},{"seite":"qp-nlp.html","titel":"11.11 Zusammenfassung","anker":"sec:qp-nlp-zusammenfassung","text":""},{"seite":"unsicherheit.html","titel":"Kapitel 12: Optimierung unter Unsicherheit — Monte-Carlo, Stochastik, Robustheit","anker":null,"text":""},{"seite":"unsicherheit.html","titel":"12.1 In 5 Minuten gelöst","anker":"sec:unsicherheit-schnellstart","text":""},{"seite":"unsicherheit.html","titel":"12.2 Lernziele","anker":"sec:unsicherheit-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"unsicherheit.html","titel":"12.3 Der Fluch des Durchschnitts","anker":"sec:unsicherheit-der-fluch-des-durchschnitts","text":"Der naheliegende Umgang mit Unsicherheit lautet: „Wir setzen den Erwartungswert ein und rechnen deterministisch.“ Das ist fast immer falsch — und zwar nicht ungenau, sondern system"},{"seite":"unsicherheit.html","titel":"Die vier Ansätze im Überblick","anker":"die-vier-ansätze-im-überblick","text":""},{"seite":"unsicherheit.html","titel":"12.4 Monte-Carlo-Simulation","anker":"sec:unsicherheit-monte-carlo-simulation","text":"Monte-Carlo heißt schlicht: Ziehe sehr viele Zufallsszenarien, werte für jedes den Plan aus, und betrachte die Verteilung der Ergebnisse statt eines einzigen Werts."},{"seite":"unsicherheit.html","titel":"12.5 Zweistufige stochastische Programmierung","anker":"sec:unsicherheit-zweistufige-stochastische-programmierung","text":"Das zweistufige Modell mit Recourse bildet ab, dass Entscheidungen zeitlich gestaffelt sind:"},{"seite":"unsicherheit.html","titel":"12.6 Robuste Optimierung: gegen den Worst Case absichern","anker":"sec:unsicherheit-robuste-optimierung-gegen-den-worst-case","text":"Wenn Wahrscheinlichkeiten unbekannt oder instabil sind — Marktcrashs, Lieferkettenabrisse, Pandemien —, hilft die robuste Optimierung. Sie fragt nicht nach dem Mittel, sondern nach"},{"seite":"unsicherheit.html","titel":"12.7 Wahrscheinlichkeitsbeschränkungen: „mit 95 % Sicherheit“","anker":"sec:unsicherheit-chance-constraints","text":"Die drei bisherigen Ansätze beantworten Fragen, die im Sitzungssaal selten so gestellt werden. „Was ist im Mittel am besten?“ hört ein Vorstand als Ausflucht; „was hält auch im sch"},{"seite":"unsicherheit.html","titel":"Weg 1: Normalverteilung — die Bedingung wird ein Kegel","anker":"weg-1-normalverteilung-die-bedingung-wird-ein-kegel","text":"Ist \\mathbf{a} \\sim \\mathcal{N}(\\hat{\\mathbf{a}}, \\boldsymbol{\\Sigma}), dann ist \\mathbf{a}^\\top\\mathbf{x} für festes \\mathbf{x} selbst normalverteilt, mit Erwartungswert \\hat{\\mat"},{"seite":"unsicherheit.html","titel":"Weg 2: Szenarien — Big-M ohne Verteilungsannahme","anker":"weg-2-szenarien-big-m-ohne-verteilungsannahme","text":"Liegen statt einer Verteilung nur S beobachtete Szenarien vor, bekommt jedes eine Binärvariable z_s, die sagt, ob es verletzt werden darf:"},{"seite":"unsicherheit.html","titel":"Der Fall: ein Kraftwerkspark mit 500 MW Zusage","anker":"der-fall-ein-kraftwerkspark-mit-500-mw-zusage","text":""},{"seite":"unsicherheit.html","titel":"12.8 Übungsaufgaben","anker":"sec:unsicherheit-uebungsaufgaben","text":""},{"seite":"unsicherheit.html","titel":"12.9 Finde den Denkfehler","anker":"sec:unsicherheit-denkfehler","text":""},{"seite":"unsicherheit.html","titel":"12.10 Micro-Quiz","anker":"sec:unsicherheit-quiz","text":""},{"seite":"unsicherheit.html","titel":"12.11 Selbsttest","anker":"sec:unsicherheit-selbsttest","text":""},{"seite":"unsicherheit.html","titel":"12.12 Zusammenfassung","anker":"sec:unsicherheit-zusammenfassung","text":""},{"seite":"dynamische-programmierung.html","titel":"Kapitel 13: Dynamische Programmierung — Die Bellman-Gleichung und Order-Execution","anker":null,"text":""},{"seite":"dynamische-programmierung.html","titel":"13.1 In 5 Minuten gelöst","anker":"sec:dynamische-programmierung-schnellstart","text":""},{"seite":"dynamische-programmierung.html","titel":"13.2 Lernziele","anker":"sec:dynamische-programmierung-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"dynamische-programmierung.html","titel":"13.3 Das Bellmansche Optimalitätsprinzip","anker":"sec:dynamische-programmierung-das-bellmansche-optimalitaetsprinzip","text":"Richard Bellman formulierte 1957 das Grundprinzip der Dynamischen Programmierung (DP):"},{"seite":"dynamische-programmierung.html","titel":"Die vier Bausteine eines DP-Modells","anker":"die-vier-bausteine-eines-dp-modells","text":""},{"seite":"dynamische-programmierung.html","titel":"Die Bellman-Gleichung","anker":"die-bellman-gleichung","text":"V_t(S_t) = \\min_{a_t\\in\\mathcal{A}(S_t)}\\Big\\{\\,C(S_t,a_t) \\;+\\; \\gamma\\cdot\\mathbb{E}\\big[V_{t+1}(S_{t+1})\\mid S_t,a_t\\big]\\Big\\}"},{"seite":"dynamische-programmierung.html","titel":"13.4 Das Almgren-Chriss-Problem: optimale Orderausführung","anker":"sec:dynamische-programmierung-das-almgren-chriss-problem-optimale-orderausfuehrung","text":"Ein klassisches OR-Problem an Börsen: Ein institutioneller Händler muss X_0 = 100\\,000 Aktien innerhalb von T Handelsperioden verkaufen."},{"seite":"dynamische-programmierung.html","titel":"13.5 Der Fluch der Dimensionalität","anker":"sec:dynamische-programmierung-der-fluch-der-dimensionalitaet","text":"DP ist mächtig, aber es hat eine harte Grenze. Der Aufwand der Rückwärtsinduktion ist"},{"seite":"dynamische-programmierung.html","titel":"13.6 Übungsaufgaben","anker":"sec:dynamische-programmierung-uebungsaufgaben","text":""},{"seite":"dynamische-programmierung.html","titel":"13.7 Finde den Denkfehler","anker":"sec:dynamische-programmierung-denkfehler","text":"Bei dynamischer Programmierung entscheidet eine einzige Frage über Erfolg oder Misserfolg: Was gehört in den Zustand? Ist er zu klein, rechnet das Verfahren völlig korrekt — nur eb"},{"seite":"dynamische-programmierung.html","titel":"13.8 Micro-Quiz","anker":"sec:dynamische-programmierung-quiz","text":""},{"seite":"dynamische-programmierung.html","titel":"13.9 Selbsttest","anker":"sec:dynamische-programmierung-selbsttest","text":""},{"seite":"dynamische-programmierung.html","titel":"13.10 Zusammenfassung","anker":"sec:dynamische-programmierung-zusammenfassung","text":""},{"seite":"mehrziel.html","titel":"Kapitel 14: Mehrere Ziele — Pareto-Fronten statt Gewichte","anker":null,"text":""},{"seite":"mehrziel.html","titel":"14.1 In 5 Minuten gelöst","anker":"sec:mehrziel-schnellstart","text":""},{"seite":"mehrziel.html","titel":"14.2 Lernziele","anker":"sec:mehrziel-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"mehrziel.html","titel":"14.3 Wann ein Kompromiss gut ist","anker":"sec:mehrziel-dominanz","text":"Bei einem Ziel ist „besser” eindeutig. Bei zwei Zielen nicht mehr: Ein Plan kann billiger und schmutziger sein als ein anderer, und dann ist keiner von beiden besser."},{"seite":"mehrziel.html","titel":"14.4 Der Reflex: alles in ein Ziel rühren","anker":"sec:mehrziel-skalarisierung","text":"Der naheliegende Weg, zwei Ziele zu einem zu machen, ist die gewichtete Summe:"},{"seite":"mehrziel.html","titel":"14.5 Das Programm","anker":"sec:mehrziel-programm","text":"Zwölf Sendungen, drei Verkehrsträger, fünf freie Bahntrassen. Der Zielkonflikt entsteht nicht zwischen den Trägern — die Bahn ist immer billiger und sauberer als der LKW —, sondern"},{"seite":"mehrziel.html","titel":"14.6 Was die gewichtete Summe nicht sieht","anker":"sec:mehrziel-luecke","text":"Die Front hat zehn Punkte. Ein Raster aus 1 401 Gewichten findet sechs davon. Vier Kompromisse sind für kein Gewicht erreichbar — und das ist keine Frage der Rasterweite."},{"seite":"mehrziel.html","titel":"Das Gewicht ist keine Feineinstellung","anker":"das-gewicht-ist-keine-feineinstellung","text":"Die zweite Beobachtung aus derselben Tabelle: 1 401 Gewichte erzeugen sechs verschiedene Pläne. Weite Gewichtsbereiche liefern dasselbe Ergebnis, und dazwischen springt es."},{"seite":"mehrziel.html","titel":"14.7 Das ε-Constraint-Verfahren","anker":"sec:mehrziel-epsilon","text":"Der Ausweg ist unspektakulär: Man macht aus dem zweiten Ziel eine Nebenbedingung."},{"seite":"mehrziel.html","titel":"Wenn es eine klare Rangfolge gibt: lexikografisch","anker":"wenn-es-eine-klare-rangfolge-gibt-lexikografisch","text":"Manchmal ist ein Ziel wirklich vorrangig. Dann optimiert man lexikografisch: erst das erste Ziel, dann das zweite unter der Bedingung, dass das erste (fast) erhalten bleibt."},{"seite":"mehrziel.html","titel":"14.8 Wie man die Front vorlegt","anker":"sec:mehrziel-vorlegen","text":"Eine Front mit zehn Punkten ist kein Ergebnis, sondern eine Speisekarte. Damit sie entscheidbar wird, gehört eine Spalte dazu, die im Programm EUR je kg heißt:"},{"seite":"mehrziel.html","titel":"14.9 Übungsaufgaben","anker":"sec:mehrziel-uebungsaufgaben","text":""},{"seite":"mehrziel.html","titel":"14.10 Finde den Denkfehler","anker":"sec:mehrziel-denkfehler","text":""},{"seite":"mehrziel.html","titel":"14.11 Micro-Quiz","anker":"sec:mehrziel-quiz","text":""},{"seite":"mehrziel.html","titel":"14.12 Selbsttest","anker":"sec:mehrziel-selbsttest","text":""},{"seite":"mehrziel.html","titel":"14.13 Zusammenfassung","anker":"sec:mehrziel-zusammenfassung","text":""},{"seite":"prognose.html","titel":"Kapitel 15: Predict-then-Optimize — die bessere Prognose, die schlechtere Entscheidung","anker":null,"text":""},{"seite":"prognose.html","titel":"15.1 In 5 Minuten gelöst","anker":"sec:prognose-schnellstart","text":""},{"seite":"prognose.html","titel":"15.2 Lernziele","anker":"sec:prognose-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"prognose.html","titel":"15.3 Die Naht zwischen zwei Abteilungen","anker":"sec:prognose-naht","text":"In fast jedem Unternehmen sieht der Ablauf so aus:"},{"seite":"prognose.html","titel":"15.4 Das Programm","anker":"sec:prognose-programm","text":"Dieselbe Bäckerei, aber mit Merkmalen: Wochentag, Temperatur, Aktionstage. Die Nachfrage muss jetzt modelliert werden."},{"seite":"prognose.html","titel":"15.5 Der Befund","anker":"sec:prognose-befund","text":""},{"seite":"prognose.html","titel":"Warum ein pauschaler Zuschlag zu kurz greift","anker":"warum-ein-pauschaler-zuschlag-zu-kurz-greift","text":"Die zweite Zeile der Tabelle ist die verbreitete Praxis: Punktprognose plus Sicherheitsbestand. Sie hilft (−7,3 %), bleibt aber hinter der Quantilregression zurück. Das Programm re"},{"seite":"prognose.html","titel":"15.6 Eine Messfalle, in die der Autor selbst getappt ist","anker":"sec:prognose-messfalle","text":"Der erste Entwurf dieses Programms bewertete auf 230 Testtagen — ein realistischer Zeitraum, gut sieben Monate. Dort hatte die Quantilregression den besseren MSE, und die ganze Aus"},{"seite":"prognose.html","titel":"15.7 Wie weit das Verfahren reicht","anker":"sec:prognose-ausblick","text":"Die dritte Zeile der Ergebnistabelle — „Zuschlag auf Kosten trainiert” — ist die einfachste denkbare Form einer Idee, die derzeit erforscht wird: das Prognosemodell direkt auf die "},{"seite":"prognose.html","titel":"15.8 Übungsaufgaben","anker":"sec:prognose-uebungsaufgaben","text":""},{"seite":"prognose.html","titel":"15.9 Finde den Denkfehler","anker":"sec:prognose-denkfehler","text":""},{"seite":"prognose.html","titel":"15.10 Micro-Quiz","anker":"sec:prognose-quiz","text":""},{"seite":"prognose.html","titel":"15.11 Selbsttest","anker":"sec:prognose-selbsttest","text":""},{"seite":"prognose.html","titel":"15.12 Zusammenfassung","anker":"sec:prognose-zusammenfassung","text":""},{"seite":"bruecke.html","titel":"Kapitel 16: Die Strukturbrücke — dieselbe Mathematik, zwei Welten","anker":null,"text":""},{"seite":"bruecke.html","titel":"16.1 In 5 Minuten gelöst","anker":"sec:bruecke-schnellstart","text":""},{"seite":"bruecke.html","titel":"16.2 Lernziele","anker":"sec:bruecke-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"bruecke.html","titel":"16.3 Das Programm","anker":"sec:bruecke-programm","text":""},{"seite":"bruecke.html","titel":"16.4 Die zwei Brücken im Einzelnen","anker":"sec:bruecke-einzelnen","text":""},{"seite":"bruecke.html","titel":"Erste Brücke: Allokation","anker":"erste-brücke-allokation","text":""},{"seite":"bruecke.html","titel":"Zweite Brücke: Absicherung gegen den schlechtesten Fall","anker":"zweite-brücke-absicherung-gegen-den-schlechtesten-fall","text":"Hier ist die Übereinstimmung noch enger, weil sie nicht nur die Struktur betrifft, sondern die Formel. Die Rockafellar-Uryasev-Formulierung aus Kapitel 20"},{"seite":"bruecke.html","titel":"Wo die Lösungen sich unterscheiden","anker":"wo-die-lösungen-sich-unterscheiden","text":"Der Vergleich fördert nebenbei einen Unterschied zutage, den man nicht erwartet: Die Streuungsgrenze von 40 % bindet nur im Einkauf, nicht im Depot."},{"seite":"bruecke.html","titel":"16.5 Wo die Brücke endet","anker":"sec:bruecke-grenzen","text":"Eine Analogie ist nur so nützlich wie die Grenzen, die man ihr zieht. Drei Dinge wandern nicht mit:"},{"seite":"bruecke.html","titel":"16.6 Übungsaufgaben","anker":"sec:bruecke-uebungsaufgaben","text":""},{"seite":"bruecke.html","titel":"16.7 Finde den Denkfehler","anker":"sec:bruecke-denkfehler","text":""},{"seite":"bruecke.html","titel":"16.8 Micro-Quiz","anker":"sec:bruecke-quiz","text":""},{"seite":"bruecke.html","titel":"16.9 Selbsttest","anker":"sec:bruecke-selbsttest","text":""},{"seite":"bruecke.html","titel":"16.10 Zusammenfassung","anker":"sec:bruecke-zusammenfassung","text":""},{"seite":"supplychain.html","titel":"Kapitel 17: Supply-Chain und Energieeinsatz unter Unsicherheit","anker":null,"text":""},{"seite":"supplychain.html","titel":"17.1 In 5 Minuten gelöst","anker":"sec:supplychain-schnellstart","text":""},{"seite":"supplychain.html","titel":"17.2 Lernziele","anker":"sec:supplychain-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"supplychain.html","titel":"17.3 Die Aufgabe","anker":"sec:supplychain-aufgabe","text":"Fünf Blöcke, 24 Stunden, eine Last zwischen 270 und 804 MW. Dazu Windeinspeisung, die niemand am Vorabend kennt."},{"seite":"supplychain.html","titel":"Die Zeitstruktur der Entscheidung","anker":"die-zeitstruktur-der-entscheidung","text":"Hier geht das Kapitel über Abschnitt 12.5 hinaus:"},{"seite":"supplychain.html","titel":"17.4 Das Programm","anker":"sec:supplychain-programm","text":""},{"seite":"supplychain.html","titel":"17.5 Der Befund","anker":"sec:supplychain-befund","text":""},{"seite":"supplychain.html","titel":"17.6 Was Versorgungssicherheit kostet","anker":"sec:supplychain-sicherheit","text":"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"},{"seite":"supplychain.html","titel":"17.7 Übungsaufgaben","anker":"sec:supplychain-uebungsaufgaben","text":""},{"seite":"supplychain.html","titel":"17.8 Finde den Denkfehler","anker":"sec:supplychain-denkfehler","text":""},{"seite":"supplychain.html","titel":"17.9 Micro-Quiz","anker":"sec:supplychain-quiz","text":""},{"seite":"supplychain.html","titel":"17.10 Selbsttest","anker":"sec:supplychain-selbsttest","text":""},{"seite":"supplychain.html","titel":"17.11 Zusammenfassung","anker":"sec:supplychain-zusammenfassung","text":""},{"seite":"finanzdaten.html","titel":"Kapitel 18: Finanzdaten-Modellierung — Renditen, Kovarianz und Shrinkage","anker":null,"text":""},{"seite":"finanzdaten.html","titel":"18.1 In 5 Minuten gelöst","anker":"sec:finanzdaten-schnellstart","text":""},{"seite":"finanzdaten.html","titel":"18.2 Lernziele","anker":"sec:finanzdaten-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"finanzdaten.html","titel":"18.3 Diskrete und logarithmische Renditen","anker":"sec:finanzdaten-diskrete-und-logarithmische-renditen","text":"Sei P_{i,t} der bereinigte Schlusskurs (adjusted close) von Titel i zum Zeitpunkt t. „Bereinigt“ heißt: um Dividenden und Aktiensplits korrigiert — sonst erscheint jede Dividendenz"},{"seite":"finanzdaten.html","titel":"Diskrete Rendite","anker":"diskrete-rendite","text":"R_{i,t} = \\frac{P_{i,t} - P_{i,t-1}}{P_{i,t-1}} = \\frac{P_{i,t}}{P_{i,t-1}} - 1"},{"seite":"finanzdaten.html","titel":"Logarithmische Rendite","anker":"logarithmische-rendite","text":"r_{i,t} = \\ln\\!\\left(\\frac{P_{i,t}}{P_{i,t-1}}\\right) = \\ln P_{i,t} - \\ln P_{i,t-1}"},{"seite":"finanzdaten.html","titel":"18.4 Das Schätzfehler-Problem","anker":"sec:finanzdaten-das-schaetzfehler-problem","text":"Sei \\mathbf{X}\\in\\mathbb{R}^{T\\times N} die Matrix zentrierter Renditen von N Titeln über T Handelstage. Die Stichproben-Kovarianzmatrix lautet:"},{"seite":"finanzdaten.html","titel":"18.5 Ledoit-Wolf-Shrinkage","anker":"sec:finanzdaten-ledoit-wolf-shrinkage","text":"Olivier Ledoit und Michael Wolf lösten das Dilemma durch lineare Schrumpfung:"},{"seite":"finanzdaten.html","titel":"Welches Ziel \\mathbf{F}?","anker":"welches-ziel-mathbff","text":"Ein häufiges Missverständnis: Es gibt nämlich mehrere Ledoit-Wolf-Varianten, und Text und Code müssen sich konsistent auf dieselbe beziehen:"},{"seite":"finanzdaten.html","titel":"18.6 Praxis: Datenpipeline mit korrekter Spaltenreihenfolge","anker":"sec:finanzdaten-praxis-datenpipeline-mit-korrekter-spaltenreihenfolge","text":""},{"seite":"finanzdaten.html","titel":"18.7 Übungsaufgaben","anker":"sec:finanzdaten-uebungsaufgaben","text":""},{"seite":"finanzdaten.html","titel":"18.8 Finde den Denkfehler","anker":"sec:finanzdaten-denkfehler","text":""},{"seite":"finanzdaten.html","titel":"18.9 Micro-Quiz","anker":"sec:finanzdaten-quiz","text":""},{"seite":"finanzdaten.html","titel":"18.10 Selbsttest","anker":"sec:finanzdaten-selbsttest","text":""},{"seite":"finanzdaten.html","titel":"18.11 Zusammenfassung","anker":"sec:finanzdaten-zusammenfassung","text":""},{"seite":"markowitz.html","titel":"Kapitel 19: Die moderne Portfoliotheorie nach Markowitz","anker":null,"text":""},{"seite":"markowitz.html","titel":"19.1 In 5 Minuten gelöst","anker":"sec:markowitz-schnellstart","text":""},{"seite":"markowitz.html","titel":"19.2 Lernziele","anker":"sec:markowitz-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"markowitz.html","titel":"19.3 Warum Diversifikation funktioniert","anker":"sec:markowitz-warum-diversifikation-funktioniert","text":"Harry Markowitz’ zentrale Einsicht von 1952: Das Risiko eines Portfolios ist nicht die Summe der Einzelrisiken. Es hängt davon ab, wie die Titel zusammenspielen."},{"seite":"markowitz.html","titel":"19.4 Das Mean-Variance-Modell","anker":"sec:markowitz-das-mean-variance-modell","text":"Sei \\mathbf{w} = (w_1,\\dots,w_n)^\\top der Vektor der Portfoliogewichte:"},{"seite":"markowitz.html","titel":"Die Effizienzgrenze und das Tangentialportfolio","anker":"die-effizienzgrenze-und-das-tangentialportfolio","text":"Die Effizienzgrenze (efficient frontier) ist die Kurve aller Portfolios, die zu jedem Risikoniveau die höchstmögliche Rendite liefern."},{"seite":"markowitz.html","titel":"Die Korn-Transformation","anker":"die-korn-transformation","text":"Die Sharpe Ratio ist ein Bruch — und damit nicht konvex. CVXPY würde sie ablehnen. Der Ausweg ist ein eleganter Trick, der die Homogenität des Problems ausnutzt:"},{"seite":"markowitz.html","titel":"19.5 Institutionelle Nebenbedingungen","anker":"sec:markowitz-institutionelle-nebenbedingungen","text":"In der Praxis gelten regulatorische und interne Regeln:"},{"seite":"markowitz.html","titel":"Was diese Regeln kosten","anker":"was-diese-regeln-kosten","text":"Jede Regel schneidet etwas vom Erreichbaren ab. Wie viel, lässt sich ausrechnen — man legt die Effizienzlinie ohne und mit Beschränkungen übereinander:"},{"seite":"markowitz.html","titel":"19.6 Vollimplementierung mit CVXPY","anker":"sec:markowitz-vollimplementierung-mit-cvxpy","text":""},{"seite":"markowitz.html","titel":"19.7 Übungsaufgaben","anker":"sec:markowitz-uebungsaufgaben","text":""},{"seite":"markowitz.html","titel":"19.8 Finde den Denkfehler","anker":"sec:markowitz-denkfehler","text":"Kapitel 18 hat gezeigt, was eine schlecht geschätzte Kovarianzmatrix anrichtet. Bei den erwarteten Renditen ist es schlimmer — und weil sie in der Zielfunktion stehen statt in der "},{"seite":"markowitz.html","titel":"19.9 Micro-Quiz","anker":"sec:markowitz-quiz","text":""},{"seite":"markowitz.html","titel":"19.10 Selbsttest","anker":"sec:markowitz-selbsttest","text":""},{"seite":"markowitz.html","titel":"19.11 Zusammenfassung","anker":"sec:markowitz-zusammenfassung","text":""},{"seite":"cvar.html","titel":"Kapitel 20: Tail-Risiko, CVaR und Transaktionskosten","anker":null,"text":""},{"seite":"cvar.html","titel":"20.1 In 5 Minuten gelöst","anker":"sec:cvar-schnellstart","text":""},{"seite":"cvar.html","titel":"20.2 Lernziele","anker":"sec:cvar-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"cvar.html","titel":"20.3 Die zwei Schwächen des Markowitz-Modells","anker":"sec:cvar-die-zwei-schwaechen-des-markowitz-modells","text":"Erstens: die Normalverteilungs-Illusion. Die Varianz behandelt Aufwärts- und Abwärtsschwankungen gleich und unterstellt implizit symmetrische, dünn auslaufende Verteilungen. Reale "},{"seite":"cvar.html","titel":"20.4 Value at Risk und Conditional Value at Risk","anker":"sec:cvar-value-at-risk-und-conditional-value","text":""},{"seite":"cvar.html","titel":"Das Rockafellar-Uryasev-Theorem (2000)","anker":"das-rockafellar-uryasev-theorem-2000","text":"Rockafellar und Uryasev zeigten, dass sich der CVaR über S Szenarien exakt als lineares Programm formulieren lässt:"},{"seite":"cvar.html","titel":"20.5 Transaktionskosten über die L_1-Norm","anker":"sec:cvar-transaktionskosten-ueber-die-l-1-norm","text":"Sei \\mathbf{w}_{\\text{alt}} das bestehende Portfolio und \\mathbf{w} das neue Ziel. Der Umschlag (turnover) ist:"},{"seite":"cvar.html","titel":"20.6 Implementierung: CVaR-Portfolio mit Reibung","anker":"sec:cvar-implementierung-cvar-portfolio-mit-reibung","text":""},{"seite":"cvar.html","titel":"20.7 Übungsaufgaben","anker":"sec:cvar-uebungsaufgaben","text":""},{"seite":"cvar.html","titel":"20.8 Finde den Denkfehler","anker":"sec:cvar-denkfehler","text":""},{"seite":"cvar.html","titel":"20.9 Micro-Quiz","anker":"sec:cvar-quiz","text":""},{"seite":"cvar.html","titel":"20.10 Selbsttest","anker":"sec:cvar-selbsttest","text":""},{"seite":"cvar.html","titel":"20.11 Zusammenfassung","anker":"sec:cvar-zusammenfassung","text":""},{"seite":"handelsmaschine.html","titel":"Kapitel 21: Die vollständige quantitative Handelsmaschine","anker":null,"text":""},{"seite":"handelsmaschine.html","titel":"21.1 In 5 Minuten gelöst","anker":"sec:handelsmaschine-schnellstart","text":""},{"seite":"handelsmaschine.html","titel":"21.2 Lernziele","anker":"sec:handelsmaschine-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"handelsmaschine.html","titel":"21.3 Die Architektur","anker":"sec:handelsmaschine-die-architektur","text":""},{"seite":"handelsmaschine.html","titel":"21.4 Rebalancing-Termine richtig bestimmen","anker":"sec:handelsmaschine-rebalancing-termine-richtig-bestimmen","text":""},{"seite":"handelsmaschine.html","titel":"21.5 Die Engine","anker":"sec:handelsmaschine-die-engine","text":""},{"seite":"handelsmaschine.html","titel":"21.6 Die fünf Selbsttäuschungen des Backtestens","anker":"sec:handelsmaschine-die-fuenf-selbsttaeuschungen-des-backtestens","text":""},{"seite":"handelsmaschine.html","titel":"21.7 Vom Backtest zum Betrieb","anker":"sec:handelsmaschine-vom-backtest-zum-betrieb","text":"Ein guter Backtest ist eine notwendige, keine hinreichende Bedingung. Was zwischen Backtest und echtem Einsatz liegt:"},{"seite":"handelsmaschine.html","titel":"21.8 Übungsaufgaben","anker":"sec:handelsmaschine-uebungsaufgaben","text":""},{"seite":"handelsmaschine.html","titel":"21.9 Finde den Denkfehler","anker":"sec:handelsmaschine-denkfehler","text":"Backtest_Fallen.py zeigt drei Fehler, die im Backtest stecken: Lookahead, Survivorship, vergessene Kosten. Alle drei kann man einem Programm ansehen, wenn man es liest."},{"seite":"handelsmaschine.html","titel":"21.10 Micro-Quiz","anker":"sec:handelsmaschine-quiz","text":""},{"seite":"handelsmaschine.html","titel":"21.11 Selbsttest","anker":"sec:handelsmaschine-selbsttest","text":""},{"seite":"handelsmaschine.html","titel":"21.12 Zusammenfassung","anker":"sec:handelsmaschine-zusammenfassung","text":""},{"seite":"praxisfallen.html","titel":"Kapitel 22: Praxisfallen und der Weg zum produktiven Einsatz","anker":null,"text":""},{"seite":"praxisfallen.html","titel":"22.1 In 5 Minuten gelöst","anker":"sec:praxisfallen-schnellstart","text":"Die vorangegangenen vierzehn Kapitel enden fast alle mit derselben Empfehlung: Prüfen Sie das Ergebnis gegen die Wirklichkeit. Dieses Kapitel beginnt damit, diese Prüfung als wiede"},{"seite":"praxisfallen.html","titel":"22.2 Lernziele","anker":"sec:praxisfallen-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"praxisfallen.html","titel":"22.3 Die fünf typischen Praxisfallen","anker":"sec:praxisfallen-die-fuenf-typischen-praxisfallen","text":""},{"seite":"praxisfallen.html","titel":"Falle 1 — Infeasibility","anker":"falle-1-infeasibility","text":"Wenn Parameter unglücklich zusammentreffen — drei Mitarbeitende gleichzeitig krank, kein qualifizierter Ersatz —, meldet der Solver schlicht INFEASIBLE."},{"seite":"praxisfallen.html","titel":"Falle 2 — Der Black-Box-Effekt","anker":"falle-2-der-black-box-effekt","text":"Ein Planer oder Trader lehnt ein System ab, wenn er die Zuweisungen nicht nachvollziehen kann. Akzeptanz ist keine Nebensache, sondern Voraussetzung für den Betrieb."},{"seite":"praxisfallen.html","titel":"Falle 3 — Regimewechsel und Schätzfehler","anker":"falle-3-regimewechsel-und-schätzfehler","text":"Ein Modell, das auf einer mehrjährigen Aufwärtsphase kalibriert wurde, kennt keine Liquiditätskrise und keine Zinswende."},{"seite":"praxisfallen.html","titel":"Falle 4 — Lookahead- und Survivorship-Bias","anker":"falle-4-lookahead--und-survivorship-bias","text":"Ausführlich in Kapitel 21 behandelt. Die Kurzfassung: Beide Fehler machen Backtests systematisch zu gut, ohne eine Fehlermeldung zu erzeugen. Der Lookahead-Selbsttest aus Abschnitt"},{"seite":"praxisfallen.html","titel":"Falle 5 — Laufzeitexplosion","anker":"falle-5-laufzeitexplosion","text":"Exakte Optimalität kann bei MILP oder CP-SAT Minuten bis Stunden dauern — und die Laufzeit wächst nicht linear mit der Problemgröße."},{"seite":"praxisfallen.html","titel":"22.4 Constraint Attribution: welche Bedingung kostet wie viel?","anker":"sec:praxisfallen-attribution","text":"Der Constraint-Trace beantwortet „warum diese Zuweisung?“. Der Deletion Filter in Anhang C beantwortet „warum geht es gar nicht?“. Der häufigste Fall in der Praxis liegt dazwischen"},{"seite":"praxisfallen.html","titel":"Drei Befunde, und keiner steht im Solverergebnis","anker":"drei-befunde-und-keiner-steht-im-solverergebnis","text":"Erstens: bindend heißt nicht teuer. Fünf Bedingungen binden, aber „Lackierbudget Schicht 2“ tut es mit einem Schattenpreis von 0,00 €. Sie berührt den Plan, ohne ihn zu verteuern —"},{"seite":"praxisfallen.html","titel":"Vom Zahlenwerk zur Vorlage","anker":"vom-zahlenwerk-zur-vorlage","text":"Alle drei Antworten stehen bis hierhin in Tabellen. Tabellen liest niemand in einer Abteilungsleiterrunde. Teil (4) des Programms baut aus denselben Zahlen einen Text — und das ist"},{"seite":"praxisfallen.html","titel":"22.5 Architektur einer produktionsreifen OR-Plattform","anker":"sec:praxisfallen-architektur-einer-produktionsreifen-or-plattform","text":""},{"seite":"praxisfallen.html","titel":"22.6 Der gemeinsame Unterbau: or_kern.py","anker":"sec:praxisfallen-or-kern","text":"Die fünf Prinzipien oben beschreiben eine Plattform. Der Weg dorthin beginnt aber viel kleiner — mit der Frage, welche Teile eines Optimierungsprogramms immer dieselben sind."},{"seite":"praxisfallen.html","titel":"Die vier Schichten","anker":"die-vier-schichten","text":"\\underbrace{\\text{Rohdaten}}_{\\text{Excel, CSV, ERP}}\n\\;\\longrightarrow\\;\n\\underbrace{\\text{Domänenmodell}}_{\\text{geprüft, solverfrei}}\n\\;\\longrightarrow\\;\n\\underbrace{\\text{Model"},{"seite":"praxisfallen.html","titel":"Ein Enum für fünf Bibliotheken","anker":"ein-enum-für-fünf-bibliotheken","text":"Der unscheinbarste, aber wirksamste Teil ist die Statusübersetzung. Dieselbe Aussage heißt in den fünf im Buch verwendeten Bibliotheken:"},{"seite":"praxisfallen.html","titel":"22.7 Der Solverwechsel in der Praxis","anker":"sec:praxisfallen-solverwechsel","text":"Der vorige Abschnitt endet mit einer Behauptung: Ein Solverwechsel betreffe genau einen Baustein. Das ist die Art Satz, die in jedem Architekturvortrag vorkommt und die man deshalb"},{"seite":"praxisfallen.html","titel":"22.8 Eine Checkliste vor dem Produktivgang","anker":"sec:praxisfallen-eine-checkliste-vor-dem-produktivgang","text":"Arbeiten Sie diese Liste ab, bevor ein OR-System in Betrieb geht:"},{"seite":"praxisfallen.html","titel":"22.9 Weiterführende Literatur und Roadmap","anker":"sec:praxisfallen-weiterfuehrende-literatur-und-roadmap","text":"Grundlagen und lineare/ganzzahlige Optimierung * Bertsimas & Tsitsiklis: Introduction to Linear Optimization — Standardwerk zu Simplex, Dualität, Polyedertheorie. * Wolsey: Int"},{"seite":"praxisfallen.html","titel":"22.10 Übungsaufgaben","anker":"sec:praxisfallen-uebungsaufgaben","text":""},{"seite":"praxisfallen.html","titel":"22.11 Finde den Denkfehler","anker":"sec:praxisfallen-denkfehler","text":"Falle 5 — die Laufzeitexplosion — zeigt sich im Betrieb selten als Absturz. Sie zeigt sich als gar nichts."},{"seite":"praxisfallen.html","titel":"22.12 Micro-Quiz","anker":"sec:praxisfallen-quiz","text":""},{"seite":"praxisfallen.html","titel":"22.13 Selbsttest","anker":"sec:praxisfallen-selbsttest","text":""},{"seite":"praxisfallen.html","titel":"22.14 Zusammenfassung","anker":"sec:praxisfallen-zusammenfassung","text":""},{"seite":"testing.html","titel":"Kapitel 23: Testen, Messen, Ausliefern","anker":null,"text":""},{"seite":"testing.html","titel":"23.1 In 5 Minuten gelöst","anker":"sec:testing-schnellstart","text":""},{"seite":"testing.html","titel":"23.2 Lernziele","anker":"sec:testing-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"testing.html","titel":"23.3 Warum Optimierungsmodelle schwer zu testen sind","anker":"sec:testing-warum-schwer","text":"Bei einer gewöhnlichen Funktion schreibt man den erwarteten Wert hin:"},{"seite":"testing.html","titel":"23.4 Die Testsuite","anker":"sec:testing-suite","text":""},{"seite":"testing.html","titel":"23.5 Wer testet die Tests?","anker":"sec:testing-mutation","text":"33 grüne Tests. Das ist noch kein Ergebnis."},{"seite":"testing.html","titel":"Der erste Lauf fand zwei Lücken","anker":"der-erste-lauf-fand-zwei-lücken","text":"Die Suite hatte zunächst 31 Tests. Der Mutationstest sagte dazu:"},{"seite":"testing.html","titel":"23.6 Ein Vergleich, dem man glauben kann","anker":"sec:testing-benchmark","text":"Solververgleiche stehen in jedem Blog, und die meisten sind wertlos. Nicht, weil falsch gemessen wurde, sondern weil zu wenig dazugesagt wird."},{"seite":"testing.html","titel":"Die interessanteste Spalte heißt „Anteil”","anker":"die-interessanteste-spalte-heißt-anteil","text":"Sie sagt, wie viel der Zeit in den Aufbau des Modells geht statt ins Lösen — und die Zahlen sind unbequem:"},{"seite":"testing.html","titel":"23.7 Das Modell als Dienst","anker":"sec:testing-dienst","text":"Ein Modell hinter eine HTTP-Schnittstelle zu hängen sieht nach einer Fingerübung aus. Es gibt aber einen Unterschied, der die ganze Bauform bestimmt:"},{"seite":"testing.html","titel":"Threads oder Prozesse? Eine Messung, keine Meinung","anker":"threads-oder-prozesse-eine-messung-keine-meinung","text":"Punkt 6 des Selbsttests misst, ob vier Solverläufe in Threads sich überlappen. Das Ergebnis ist ein Faktor von rund 3,5 bei vier Threads: OR-Tools rechnet in C++ und gibt den GIL w"},{"seite":"testing.html","titel":"Was im echten Betrieb dazukommt","anker":"was-im-echten-betrieb-dazukommt","text":"Das Beispiel hält das Auftragsbuch in einem dict und rechnet in Threads desselben Prozesses. Für einen einzelnen Rechner reicht das; darüber hinaus nicht:"},{"seite":"testing.html","titel":"23.8 Übungsaufgaben","anker":"sec:testing-uebungsaufgaben","text":""},{"seite":"testing.html","titel":"23.9 Finde den Denkfehler","anker":"sec:testing-denkfehler","text":""},{"seite":"testing.html","titel":"23.10 Micro-Quiz","anker":"sec:testing-quiz","text":""},{"seite":"testing.html","titel":"23.11 Selbsttest","anker":"sec:testing-selbsttest","text":""},{"seite":"testing.html","titel":"23.12 Zusammenfassung","anker":"sec:testing-zusammenfassung","text":""},{"seite":"projektwerkstatt.html","titel":"Projektwerkstatt","anker":null,"text":""},{"seite":"projektwerkstatt.html","titel":"Wie Sie ein Projekt bearbeiten","anker":"wie-sie-ein-projekt-bearbeiten","text":"Halten Sie sich an diese Reihenfolge — sie ist aus vielen gescheiterten und einigen gelungenen Projekten destilliert:"},{"seite":"projektwerkstatt.html","titel":"P1 — Vertretungsplaner für eine Schule","anker":"p1-vertretungsplaner-für-eine-schule","text":""},{"seite":"projektwerkstatt.html","titel":"P2 — Schichtplanung für ein Pflegeteam","anker":"p2-schichtplanung-für-ein-pflegeteam","text":""},{"seite":"projektwerkstatt.html","titel":"P3 — Tourenplanung für einen Lieferdienst","anker":"p3-tourenplanung-für-einen-lieferdienst","text":""},{"seite":"projektwerkstatt.html","titel":"P4 — Standort- und Lagernetzplanung","anker":"p4-standort--und-lagernetzplanung","text":""},{"seite":"projektwerkstatt.html","titel":"P5 — Produktionsplanung mit Rüstkosten","anker":"p5-produktionsplanung-mit-rüstkosten","text":""},{"seite":"projektwerkstatt.html","titel":"P6 — Portfolio-Rebalancer für ein Privatdepot","anker":"p6-portfolio-rebalancer-für-ein-privatdepot","text":""},{"seite":"projektwerkstatt.html","titel":"P7 — Risikoreport mit CVaR und Stresstests","anker":"p7-risikoreport-mit-cvar-und-stresstests","text":""},{"seite":"projektwerkstatt.html","titel":"P9 — Wenn der Solver aussteigt: Tourenplanung in Echtgröße","anker":"p9-wenn-der-solver-aussteigt-tourenplanung-in-echtgröße","text":""},{"seite":"projektwerkstatt.html","titel":"P10 — Zwei Ziele, eine Entscheidung: Kosten gegen CO₂","anker":"p10-zwei-ziele-eine-entscheidung-kosten-gegen-co₂","text":""},{"seite":"projektwerkstatt.html","titel":"P11 — Vom Skript zum Dienst: das Modell übergeben","anker":"p11-vom-skript-zum-dienst-das-modell-übergeben","text":""},{"seite":"projektwerkstatt.html","titel":"P8 — Freies Projekt aus Ihrem eigenen Umfeld","anker":"p8-freies-projekt-aus-ihrem-eigenen-umfeld","text":""},{"seite":"projektwerkstatt.html","titel":"Eine Bitte zum Schluss","anker":"eine-bitte-zum-schluss","text":"Wenn Sie ein Projekt fertig haben, machen Sie zwei Dinge:"},{"seite":"anhang-loesungen.html","titel":"Anhang A: Lösungen zu allen Übungsaufgaben","anker":null,"text":""},{"seite":"anhang-loesungen.html","titel":"A.1 Lösungen zu Kapitel „Einführung in Operations Research — Vom Ursprung zur mathematischen Entscheidungsfindung“","anker":"sec:loesungen-einfuehrung","text":"1.1 — Analytik-Stufen. (a) deskriptiv (Vergangenheit beschreiben). (b) prädiktiv (Prognose). (c) präskriptiv (Handlungsanweisung unter Restriktionen). (d) prädiktiv (Klassifikation"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Die Schreinerei verdoppelt ihren Gewinn","anker":"finde-den-denkfehler-die-schreinerei-verdoppelt-ihren-gewinn","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-einfuehrung","text":"1 — (c) präskriptiv. Die Frage lautet „was tun“, und es gibt eine Restriktion (das Restbudget). Dass man für die Bewertung eine Prognose braucht, macht die Frage nicht prädiktiv — "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-einfuehrung","text":""},{"seite":"anhang-loesungen.html","titel":"A.2 Lösungen zu Kapitel „Das mathematische Fundament — Vektoren, Matrizen, Konvexität“","anker":"sec:loesungen-fundament","text":"2.1 — Matrixform lesen. \\max 4x_1 + x_2 + 6x_3 u. d. N. x_1 + 2x_2 \\le 10, x_2 + 3x_3 \\le 12, x \\ge 0. 3 Variablen, 2 Nebenbedingungen (plus Nichtnegativität)."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Die unauffällige Transposition","anker":"finde-den-denkfehler-die-unauffällige-transposition","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-fundament","text":"1 — (b). Der Fundamentalsatz sagt nur, dass ein Optimum in einer Ecke angenommen wird. (a) ist falsch, weil es mehrere optimale Ecken geben kann (wenn die Zielfunktion parallel zu "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-fundament","text":""},{"seite":"anhang-loesungen.html","titel":"A.3 Lösungen zu Kapitel „Das Python-Ökosystem für OR — Solver, Bindings und Modellierungsschichten“","anker":"sec:loesungen-oekosystem","text":"3.1 — Solverwahl. (a) CP-SAT (diskrete Zuordnung mit Zeitfenstern). (b) scipy.optimize.linprog (klassisches Mischungs-LP, klein). (c) CVXPY (konvexes QP). (d) MIQP — CVXPY mit Binä"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Der Solver, der angeblich dreimal schneller ist","anker":"finde-den-denkfehler-der-solver-der-angeblich-dreimal-schneller-ist","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-oekosystem","text":"1 — (b) CP-SAT. Es geht um diskrete Zuweisung mit logischen Regeln („nicht gleichzeitig“) — CP-SATs Kerngebiet, und Regeln dieser Art formuliert man dort direkt statt über Big-M-Um"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-oekosystem","text":""},{"seite":"anhang-loesungen.html","titel":"A.4 Lösungen zu Kapitel „Vom Management-Wunsch zum Modell“","anker":"sec:loesungen-modellierung","text":"4.1 — Die fehlende Lesart. Eine dritte Lesart: „Der Umsatzanteil der Stammkunden darf nicht unter 30 % fallen.” Sie zielt weder auf einzelne Aufträge noch auf einzelne Kunden, sond"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Die Engpassmaschine soll zu mindestens 92 % ausgelastet sein“","anker":"denkfehler-loesung-modellierung","text":"Fehler 1: Auslastung ist ein Ergebnis, keine Anforderung."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-modellierung","text":"1. b) Die Zielfunktion ist eine vollständige Aussage darüber, was zählt. Was nicht darin steht, ist für das Modell nicht „weniger wichtig”, sondern wertlos. (a) und (c) sind frei e"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-modellierung","text":""},{"seite":"anhang-loesungen.html","titel":"A.5 Lösungen zu Kapitel „Lineare Programmierung — Simplex, Dualität und Schattenpreise“","anker":"sec:loesungen-lp","text":"5.1 — Schlupf deuten. (a) Ressourcen 1 und 3 (Schlupf 0). (b) Ja: Überall ist s_i \\cdot y_i = 0. (c) In Ressource 3 — der höchste Schattenpreis (9,8) bedeutet den größten Grenznutz"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Die 380 000-Euro-Maschine","anker":"finde-den-denkfehler-die-380-000-euro-maschine","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-lp","text":"1 — (b). Genau der Satz vom komplementären Schlupf: s_i \\cdot y_i = 0. Ist Schlupf vorhanden, muss der Dualwert null sein. (a) hat die Aussage in ihr Gegenteil verkehrt; (c) verwec"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-lp","text":""},{"seite":"anhang-loesungen.html","titel":"A.6 Lösungen zu Kapitel „Gemischt-ganzzahlige Optimierung — Diskrete Entscheidungen und Branch-and-Bound“","anker":"sec:loesungen-milp","text":"6.1 — Runden widerlegen. Beispiel: \\max x_1 + x_2 u. d. N. 10x_1 + 10x_2 \\le 15, ganzzahlig. LP-Optimum Z = 1{,}5; Abrunden ergibt (0,0) mit Z = 0 — 100 % Verlust. Das ganzzahlige "},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Elf Lager, die keine Fixkosten kosten","anker":"finde-den-denkfehler-elf-lager-die-keine-fixkosten-kosten","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-milp","text":"1 — (b). Der Gap (48\\,200 - 47\\,100)/48\\,200 = 2{,}3\\,\\% ist eine Garantie, keine Schätzung: Besser als 47 100 € kann es nachweislich nicht werden, also ist der vorliegende Plan hö"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-milp","text":""},{"seite":"anhang-loesungen.html","titel":"A.7 Lösungen zu Kapitel „Constraint Programming mit CP-SAT — Logik, Scheduling und Zuweisung“","anker":"sec:loesungen-cpsat","text":"7.1 — Propagation. Aus x_1 + x_2 = 8 und x_1 < x_2 folgt x_1 < 4, also x_1 \\in \\{2,3\\} (denn x_2 = 8-x_1 \\le 6 verlangt x_1 \\ge 2) und entsprechend x_2 \\in \\{5,6\\}. Aus 36 Ko"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Die Betriebsvereinbarung, die niemanden interessiert","anker":"finde-den-denkfehler-die-betriebsvereinbarung-die-niemanden-interessiert","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-cpsat","text":"1 — (b) wirkungslos. INFEASIBLE ist kein Abbruch, sondern ein Beweis: CP-SAT hat gezeigt, dass keine zulässige Lösung existiert. Mehr Zeit (a) oder mehr Arbeiter (c) ändern daran n"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-cpsat","text":""},{"seite":"anhang-loesungen.html","titel":"A.8 Lösungen zu Kapitel „Graphen, Flüsse und Touren — Min-Cost-Flow, Matching und VRP“","anker":"sec:loesungen-graphen","text":"8.1 — Flusserhaltung. b_i = (15+3) - (12+8) = -2 → Senke (Nettobedarf 2)."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Die vergessene Dimension","anker":"finde-den-denkfehler-die-vergessene-dimension","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-graphen","text":"1 — (b) totale Unimodularität. Jede quadratische Teilmatrix hat Determinante 0, +1 oder -1; bei ganzzahliger rechter Seite sind deshalb alle Ecken des zulässigen Bereichs ganzzahli"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-graphen","text":""},{"seite":"anhang-loesungen.html","titel":"A.9 Lösungen zu Kapitel „Metaheuristiken — wenn der exakte Solver aussteigt“","anker":"sec:loesungen-metaheuristiken","text":"9.1 — Die Kurzsichtigkeit von Hand. Ab Schwarz wählt die Faustregel: Schwarz → Dunkelrot (26) → Rot (5) → … und muss dann in die helle Gruppe: Rot → Weiß (50) → Elfenbein (2) → Bei"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Unsere Heuristik ist 6 % besser“","anker":"denkfehler-loesung-metaheuristiken","text":"Es fehlt die Schranke."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-metaheuristiken","text":"1. b) Eine lokale Suche lebt von der Zahl geprüfter Züge. Die volle Summe kostet O(n) statt O(1) — bei 500 Aufträgen zwei Größenordnungen weniger Züge im selben Zeitbudget. Numeris"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-metaheuristiken","text":""},{"seite":"anhang-loesungen.html","titel":"A.10 Lösungen zu Kapitel „Spaltengenerierung“","anker":"sec:loesungen-dekomposition","text":"10.1 — Das Abbruchkriterium. Die Eins ist der Zielfunktionskoeffizient eines Musters: Jedes geschnittene Muster verbraucht genau eine Mutterrolle, und die Zielfunktion lautet \\min "},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Die LP-Lösung sagt 72,92 — also runden wir auf“","anker":"denkfehler-loesung-dekomposition","text":"Das Aufrunden kostet sechs Rollen — acht Prozent."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-dekomposition","text":"1. b) Das Mustermodell kennt keine einzelnen Rollen und damit keine austauschbaren Objekte — die Symmetrie verschwindet. (a) trifft zufällig auch zu, ist aber nicht der Grund; (c) "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-dekomposition","text":""},{"seite":"anhang-loesungen.html","titel":"A.11 Lösungen zu Kapitel „Quadratische und nichtlineare Optimierung — KKT, Lagrange, Konvexität“","anker":"sec:loesungen-qp-nlp","text":"11.1 — Konvexität einordnen. PSD (alle \\ge 0) → konvex, aber nicht streng konvex; die Lösung ist nicht notwendig eindeutig. Der Eigenwert 0 bedeutet eine flache Richtung: Entlang d"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Die Kovarianzmatrix aus dem Controlling","anker":"finde-den-denkfehler-die-kovarianzmatrix-aus-dem-controlling","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-qp-nlp","text":"1 — (b). Der DCPError ist eine inhaltliche Aussage: Für diese Formulierung kann es keine Optimalitätsgarantie geben. Auf scipy auszuweichen (a) beseitigt die Meldung, nicht die Urs"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-qp-nlp","text":""},{"seite":"anhang-loesungen.html","titel":"A.12 Lösungen zu Kapitel „Optimierung unter Unsicherheit — Monte-Carlo, Stochastik, Robustheit“","anker":"sec:loesungen-unsicherheit","text":"12.1 — Fluch des Durchschnitts. Weil sich Verzögerungen fortpflanzen und die Verteilung rechtsschief ist: Ein Gewerk, das früher fertig wird, beschleunigt selten den Gesamtablauf ("},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Warum jedes Projekt zu spät fertig wird","anker":"finde-den-denkfehler-warum-jedes-projekt-zu-spät-fertig-wird","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-unsicherheit","text":"1 — (b). Das kritische Verhältnis 1400/(1400+120) = 0{,}921 gibt an, wie weit man sich auf die günstigere Fehlerseite stellen soll: Bestellt wird das 92,1-%-Quantil des Bedarfs, hi"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-unsicherheit","text":""},{"seite":"anhang-loesungen.html","titel":"A.13 Lösungen zu Kapitel „Dynamische Programmierung — Die Bellman-Gleichung und Order-Execution“","anker":"sec:loesungen-dynamische-programmierung","text":"13.1 — Bausteine. Stufe = Tag 1–5; Zustand = verbleibende Distanz (ggf. plus Erschöpfungsgrad); Aktion = heutige Tagesetappe; Wertfunktion = minimale Restanstrengung. Nicht in den "},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Der Zustand, der zu wenig weiß","anker":"finde-den-denkfehler-der-zustand-der-zu-wenig-weiß","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-dynamische-programmierung","text":"1 — (b). V_t setzt V_{t+1} voraus: Eine Entscheidung lässt sich erst bewerten, wenn feststeht, was sie für die Zukunft bedeutet. Am Ende ist dieser Wert bekannt (V_T = g), deshalb "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-dynamische-programmierung","text":""},{"seite":"anhang-loesungen.html","titel":"A.14 Lösungen zu Kapitel „Mehrere Ziele — Pareto-Fronten statt Gewichte“","anker":"sec:loesungen-mehrziel","text":"14.1 — Dominanz prüfen. P (14 000 €, 6 000 kg) und Q (13 800 €, 6 100 kg): keiner dominiert den anderen. Q ist billiger, P ist sauberer — beide sind pareto-optimal, die Wahl ist ei"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Wir haben die Gewichte sauber kalibriert“","anker":"denkfehler-loesung-mehrziel","text":"Der Lösungsraum wurde nicht vollständig abgetastet — und zwar aus prinzipiellen Gründen."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-mehrziel","text":"1. b) Geometrisch heißt „gewichtete Summe minimieren“: eine Gerade von links unten an die Punktwolke schieben. Sie berührt immer einen Eckpunkt der unteren konvexen Hülle; Punkte d"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-mehrziel","text":""},{"seite":"anhang-loesungen.html","titel":"A.15 Lösungen zu Kapitel „Predict-then-Optimize“","anker":"sec:loesungen-prognose","text":"15.1 — Andere Preise. Der Überhang kostet nur noch 1,50 €, die Fehlmenge weiterhin 6 €. Das kritische Verhältnis steigt von 6/9 = 0{,}667 auf 6/7{,}5 = 0{,}80. Die Bestellmenge ver"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Wir haben die Prognose um 18 % verbessert“","anker":"denkfehler-loesung-prognose","text":"Der Sicherheitszuschlag stammt aus den Residuen des alten Modells."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-prognose","text":"1. b) Die Kostenasymmetrie verschiebt das Optimum vom Erwartungswert zum kritischen Quantil. (a) und (c) sind reale Probleme, aber nicht dieses: Selbst bei perfekt bekannter, norma"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-prognose","text":""},{"seite":"anhang-loesungen.html","titel":"A.16 Lösungen zu Kapitel „Die Strukturbrücke — dieselbe Mathematik, zwei Welten“","anker":"sec:loesungen-bruecke","text":"16.1 — Die dritte Ressource. „Liquidität” — wie schnell sich eine Position ohne Kursabschlag verkaufen lässt — entspricht in der Werkstatt der Umrüstbarkeit oder der Vorlaufzeit: W"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Das ist doch dasselbe Problem“","anker":"denkfehler-loesung-bruecke","text":"Fehler 1: Varianz ist hier das falsche Risikomaß — genau der Fall, für den es CVaR gibt."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-bruecke","text":"1. b) Beide Probleme haben dieselbe Struktur: knappe Größen auf konkurrierende Verwendungen verteilen. (a) ist falsch — Produktionsproblem ist streng typisiert und validiert beim E"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-bruecke","text":""},{"seite":"anhang-loesungen.html","titel":"A.17 Lösungen zu Kapitel „Supply-Chain und Energieeinsatz unter Unsicherheit“","anker":"sec:loesungen-supplychain","text":"17.1 — Der Umschlagpunkt. Bei 60 MW statt 120 MW halbiert sich die Grenzkostendifferenz je Stunde (83 €/MWh × 60 MW = 4 980 €/h). Der Umschlagpunkt verdoppelt sich auf 7,7 Stunden."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Wir rechnen mit dem P50-Szenario“","anker":"denkfehler-loesung-supplychain","text":"Fehler 1: Der Median ist keine Absicherung, sondern eine Wette auf die Hälfte der Fälle."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-supplychain","text":"1. b) Anfahrkosten und Mindestlaufzeiten koppeln die Stunden. Die Merit-Order ist eine Sortierung und kennt keine Kopplung über die Zeit; sie ist für einen einzelnen Zeitpunkt rich"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-supplychain","text":""},{"seite":"anhang-loesungen.html","titel":"A.18 Lösungen zu Kapitel „Finanzdaten-Modellierung — Renditen, Kovarianz und Shrinkage“","anker":"sec:loesungen-finanzdaten","text":"18.1 — Renditeart. (a) diskret. (b) logarithmisch. (c) logarithmisch (bzw. diskret kumuliert über Produkt). (d) logarithmisch (statistische Eigenschaften)."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Das risikofreie Portfolio","anker":"finde-den-denkfehler-das-risikofreie-portfolio","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-finanzdaten","text":"1 — (b) bei 75 %. 1{,}0 \\cdot 0{,}5 \\cdot 1{,}5 = 0{,}75. Prozentuale Änderungen verknüpfen sich multiplikativ; ein Verlust wiegt schwerer als ein gleich großer Gewinn, weil er von"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-finanzdaten","text":""},{"seite":"anhang-loesungen.html","titel":"A.19 Lösungen zu Kapitel „Die moderne Portfoliotheorie nach Markowitz“","anker":"sec:loesungen-markowitz","text":"19.1 — Diversifikationseffekt."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Zwölf gleiche Anlagen, ein sehr ungleiches Portfolio","anker":"finde-den-denkfehler-zwölf-gleiche-anlagen-ein-sehr-ungleiches-portfolio","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-markowitz","text":"1 — (b). Das renditestärkste Portfolio auf der Effizienzlinie hat notwendig das schlechteste Rendite-Risiko-Verhältnis (im Kapitelbeispiel 0,44 gegenüber 0,75 beim Optimum) und bes"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-markowitz","text":""},{"seite":"anhang-loesungen.html","titel":"A.20 Lösungen zu Kapitel „Tail-Risiko, CVaR und Transaktionskosten“","anker":"sec:loesungen-cvar","text":"20.1 — VaR und CVaR von Hand. Sortierte Verluste: -2{,}0; -1{,}2; -0{,}8; -0{,}4; 0{,}3; 0{,}6; 1{,}1; 2{,}1; 5{,}4; 8{,}9. Das 80 %-Quantil ist der 8. Wert: \\text{VaR}_{80\\%} = 2{"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Das Risikobudget, das durch Diversifikation stieg","anker":"finde-den-denkfehler-das-risikobudget-das-durch-diversifikation-stieg","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-cvar","text":"1 — (b) nichts. Der VaR ist ein Quantil: Er markiert die Schwelle und sagt nichts über den Bereich dahinter. Genau das zeigt der Schnellstart dieses Kapitels — zwei Anlagen mit ide"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-cvar","text":""},{"seite":"anhang-loesungen.html","titel":"A.21 Lösungen zu Kapitel „Die vollständige quantitative Handelsmaschine“","anker":"sec:loesungen-handelsmaschine","text":"21.1 — Lookahead erkennen. (a) sauber (:heute). (b) Lookahead — gesamter Zeitraum. (c) Lookahead — Volatilität über den ganzen Zeitraum enthält Zukunft. (d) sauber — Gewichte aus D"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Die Strategie mit dem hochsignifikanten Ergebnis","anker":"finde-den-denkfehler-die-strategie-mit-dem-hochsignifikanten-ergebnis","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-handelsmaschine","text":"1 — (b) nichts. Beide Kennzahlen sind Mittelwerte über die Zeit und blind für die Reihenfolge. Der Schnellstart dieses Kapitels zeigt es an denselben Tagesrenditen, nur anders ange"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-handelsmaschine","text":""},{"seite":"anhang-loesungen.html","titel":"A.22 Lösungen zu Kapitel „Praxisfallen und der Weg zum produktiven Einsatz“","anker":"sec:loesungen-praxisfallen","text":"22.1 — Hart oder weich, revisited. (a) hart: gesetzliche Ruhezeit, Qualifikationspflicht. (b) weich: individuelle Wunschtage, Vermeidung von Freistunden. (c) diskutabel: Höchstzahl"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Das Modell, das seit einem Jahr nicht mehr optimiert","anker":"finde-den-denkfehler-das-modell-das-seit-einem-jahr-nicht-mehr-optimiert","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-praxisfallen","text":"1 — (b) das Zeitlimit greift. Eine Laufzeit, die exakt dem Limit entspricht und über Monate konstant bleibt, ist kein Stabilitätsbeleg, sondern zeigt, dass der Solver jedes Mal abg"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-praxisfallen","text":""},{"seite":"anhang-loesungen.html","titel":"A.23 Lösungen zu Kapitel „Testen, Messen, Ausliefern“","anker":"sec:loesungen-testing","text":"23.1 — Den Schnellstart reparieren. Zwei Wege: (a) Abrunden statt runden — np.floor statt np.round. Das ist immer zulässig, weil weniger produzieren nie eine Kapazität sprengt, kos"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Die Suite ist grün, das Modell stimmt”","anker":"denkfehler-loesung-testing","text":"Alle Prüfungen benutzen dieselbe falsche Zahl."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-testing","text":"1. b) Es gibt keine unabhängige Quelle für die richtige Antwort — sonst bräuchte man den Solver nicht. (a) ist bei festgelegtem Seed und Zeitlimit meist beherrschbar; (c) löst man "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-testing","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"Anhang B: Katalog der Modellierungsmuster","anker":null,"text":""},{"seite":"anhang-modellierungsmuster.html","titel":"Übersicht","anker":"übersicht","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"Logische Schalter","anker":"logische-schalter","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B1 — Aktivierungsschalter (Fixkosten)","anker":"b1-aktivierungsschalter-fixkosten","text":"Regel. „Wenn überhaupt etwas produziert wird, fallen Rüstkosten F an.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B2 — Semikontinuierliche Variable","anker":"b2-semikontinuierliche-variable","text":"Regel. „Entweder gar nicht oder mindestens L (und höchstens U).“"},{"seite":"anhang-modellierungsmuster.html","titel":"B3 — Implikation","anker":"b3-implikation","text":"Regel. „Wenn A gewählt wird, muss auch B gewählt werden.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B4 — Entweder-Oder (disjunktive Bedingung)","anker":"b4-entweder-oder-disjunktive-bedingung","text":"Regel. „Es muss f(\\mathbf{x}) \\le b_1 oder g(\\mathbf{x}) \\le b_2 gelten.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B5 — Exklusiv-Oder","anker":"b5-exklusiv-oder","text":"Regel. „Genau eine der Optionen wird gewählt.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B6 — Kardinalität","anker":"b6-kardinalität","text":"Regel. „Höchstens (mindestens, genau) K von N.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B7 — Bedingte Kopplung (Konjunktion)","anker":"b7-bedingte-kopplung-konjunktion","text":"Regel. „Wenn A und B, dann auch C.“"},{"seite":"anhang-modellierungsmuster.html","titel":"Mengen und Grenzen","anker":"mengen-und-grenzen","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B8 — Weiche Grenze mit Strafkosten","anker":"b8-weiche-grenze-mit-strafkosten","text":"Regel. „Möglichst nicht über b — wenn doch, kostet es.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B9 — Gestaffelte Preise (stückweise linear)","anker":"b9-gestaffelte-preise-stückweise-linear","text":"Regel. „Die ersten 100 Stück kosten 5 €, danach 8 €.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B10 — Absolutbetrag / Abweichung","anker":"b10-absolutbetrag-abweichung","text":"Regel. „Die Abweichung vom Zielwert soll klein sein, egal in welche Richtung.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B11 — Min/Max in der Zielfunktion","anker":"b11-minmax-in-der-zielfunktion","text":"Regel. „Der am schlechtesten gestellte Beteiligte soll möglichst gut dastehen“ (Maximin/Fairness)."},{"seite":"anhang-modellierungsmuster.html","titel":"B12 — Verhältnis-Bedingung","anker":"b12-verhältnis-bedingung","text":"Regel. „Der Anteil von Gruppe G soll mindestens 30 % betragen.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B25 — Mindestabnahmemenge über einen Zeitraum","anker":"b25-mindestabnahmemenge-über-einen-zeitraum","text":"Regel. „Entweder wir arbeiten mit diesem Lieferanten gar nicht, oder wir nehmen ihm im Jahr mindestens 500 Einheiten ab.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B27 — Budgetlimit","anker":"b27-budgetlimit","text":"Regel. „Alle Maßnahmen zusammen dürfen 2 Mio. € nicht überschreiten.“"},{"seite":"anhang-modellierungsmuster.html","titel":"Zeit und Reihenfolge","anker":"zeit-und-reihenfolge","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B13 — Vorrangbeziehung","anker":"b13-vorrangbeziehung","text":"Regel. „Arbeitsgang B darf erst beginnen, wenn A fertig ist.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B14 — Nichtüberlappung","anker":"b14-nichtüberlappung","text":"Regel. „Eine Maschine bearbeitet nur einen Job gleichzeitig.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B15 — Kumulative Ressource","anker":"b15-kumulative-ressource","text":"Regel. „Zu keinem Zeitpunkt dürfen mehr als 3 Arbeiten gleichzeitig laufen“ bzw. „die Stromlast darf 500 kW nie übersteigen“."},{"seite":"anhang-modellierungsmuster.html","titel":"B16 — Gleitendes Fenster","anker":"b16-gleitendes-fenster","text":"Regel. „Höchstens 5 Arbeitstage in Folge“ / „mindestens 2 freie Tage je Woche“."},{"seite":"anhang-modellierungsmuster.html","titel":"B17 — Umrüst- bzw. Wechselkosten","anker":"b17-umrüst--bzw.-wechselkosten","text":"Regel. „Ein Produktwechsel kostet Rüstzeit.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B26 — Rüstzeit als Kapazitätsverbrauch","anker":"b26-rüstzeit-als-kapazitätsverbrauch","text":"Regel. „Das Umrüsten kostet nicht nur Geld, es kostet Maschinenstunden — und die fehlen dann für die Produktion.“"},{"seite":"anhang-modellierungsmuster.html","titel":"Robustheit und Diagnose","anker":"robustheit-und-diagnose","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B18 — Schlupfvariablen gegen Unlösbarkeit","anker":"b18-schlupfvariablen-gegen-unlösbarkeit","text":"Das wichtigste Muster für den Produktivbetrieb."},{"seite":"anhang-modellierungsmuster.html","titel":"B19 — Hierarchische Ziele (lexikografisch)","anker":"b19-hierarchische-ziele-lexikografisch","text":"Regel. „Erst die Besetzung sicherstellen, dann die Fairness optimieren.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B20 — Symmetriebrechung","anker":"b20-symmetriebrechung","text":"Regel. „Drei identische Maschinen — der Solver soll nicht alle Vertauschungen durchprobieren.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B21 — Worst-Case-Abzug (robuste Formulierung)","anker":"b21-worst-case-abzug-robuste-formulierung","text":"Regel. „Rechne nicht mit dem geschätzten Wert, sondern mit dem ungünstigsten plausiblen.“"},{"seite":"anhang-modellierungsmuster.html","titel":"Netzwerke","anker":"netzwerke","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B22 — Flusserhaltung","anker":"b22-flusserhaltung","text":"\\sum_{j:(i,j)\\in E} x_{ij} - \\sum_{k:(k,i)\\in E} x_{ki} = b_i \\qquad \\forall i"},{"seite":"anhang-modellierungsmuster.html","titel":"B23 — Zuordnung 1:1","anker":"b23-zuordnung-11","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B24 — Subtour-Eliminierung","anker":"b24-subtour-eliminierung","text":"MTZ (einfach, aber schwach): u_i - u_j + C x_{ij} \\le C - d_j \\qquad \\forall i\\ne j"},{"seite":"anhang-modellierungsmuster.html","titel":"Ein Wort zur Auswahl","anker":"ein-wort-zur-auswahl","text":"Wenn mehrere Muster passen, entscheiden Sie nach dieser Reihenfolge:"},{"seite":"anhang-fehlerdiagnose.html","titel":"Anhang C: Fehlerdiagnose-Handbuch","anker":null,"text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"Symptom-Schnellübersicht","anker":"symptom-schnellübersicht","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"C1 — INFEASIBLE","anker":"c1-infeasible","text":"Bedeutung. Es gibt keinen einzigen Punkt, der alle Bedingungen gleichzeitig erfüllt. Das ist eine Aussage über Ihr Modell, nicht über den Solver."},{"seite":"anhang-fehlerdiagnose.html","titel":"Diagnose in fünf Schritten","anker":"diagnose-in-fünf-schritten","text":"Schritt 1 — Trivialprüfungen von Hand. Rechnen Sie die offensichtlichen Bilanzen nach, bevor Sie den Solver befragen:"},{"seite":"anhang-fehlerdiagnose.html","titel":"Wenn die fünf Schritte nicht reichen: den Konflikt einkreisen","anker":"wenn-die-fünf-schritte-nicht-reichen-den-konflikt-einkreisen","text":"Warum Schritt 2 so oft ins Leere läuft. Bedingungen einzeln abzuschalten funktioniert nur, solange es einen Schuldigen gibt. Überlagern sich zwei unabhängige Widersprüche, bleibt d"},{"seite":"anhang-fehlerdiagnose.html","titel":"C2 — UNBOUNDED","anker":"c2-unbounded","text":"Bedeutung. Die Zielfunktion lässt sich unbegrenzt verbessern."},{"seite":"anhang-fehlerdiagnose.html","titel":"C3 — Zu langsam","anker":"c3-zu-langsam","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"Diagnosereihenfolge","anker":"diagnosereihenfolge","text":"1. Ist es überhaupt das Lösen?"},{"seite":"anhang-fehlerdiagnose.html","titel":"C4 — Unsinniges Ergebnis","anker":"c4-unsinniges-ergebnis","text":"Der Solver hat immer recht — bezogen auf das Modell, das Sie ihm gegeben haben. Wenn das Ergebnis unsinnig ist, beschreibt Ihr Modell nicht das Problem, das Sie meinen."},{"seite":"anhang-fehlerdiagnose.html","titel":"Checkliste","anker":"checkliste","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"Die wirksamste Gegenmaßnahme","anker":"die-wirksamste-gegenmaßnahme","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"C5 — Instabile Lösung","anker":"c5-instabile-lösung","text":"Symptom. Ein zusätzlicher Handelstag, und das Portfolio sieht völlig anders aus."},{"seite":"anhang-fehlerdiagnose.html","titel":"Derselbe Fehler außerhalb der Finanzwelt","anker":"derselbe-fehler-außerhalb-der-finanzwelt","text":"Instabilität ist kein Portfolioproblem, sondern ein Skalierungsproblem — und es trifft jedes Modell, in dem Größen sehr verschiedener Ordnung nebeneinanderstehen. Euro-Beträge (10^"},{"seite":"anhang-fehlerdiagnose.html","titel":"C6 — Falsche Dualwerte","anker":"c6-falsche-dualwerte","text":"Symptom. Schattenpreise sind negativ, wo sie positiv sein sollten — oder null bei einer offensichtlich knappen Ressource."},{"seite":"anhang-fehlerdiagnose.html","titel":"Die drei Prüfungen","anker":"die-drei-prüfungen","text":"1. Vorzeichenkonvention. Haben Sie zur Maximierung negiert?"},{"seite":"anhang-fehlerdiagnose.html","titel":"C7 — Widersprüchliche Solver","anker":"c7-widersprüchliche-solver","text":"Symptom. linprog sagt 530, CVXPY sagt 529,8."},{"seite":"anhang-fehlerdiagnose.html","titel":"C8 — DCPError","anker":"c8-dcperror","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"C9 — Importfehler","anker":"c9-importfehler","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"C10 — Verdächtig guter Backtest","anker":"c10-verdächtig-guter-backtest","text":"Faustregel: Eine Sharpe Ratio über 2 bei einer einfachen Strategie ist fast immer ein Fehler, kein Fund."},{"seite":"anhang-fehlerdiagnose.html","titel":"Prüfreihenfolge","anker":"prüfreihenfolge","text":"1. Lookahead-Selbsttest."},{"seite":"anhang-fehlerdiagnose.html","titel":"C11 — Vertauschte Spalten","anker":"c11-vertauschte-spalten","text":"Symptom. Ergebnisse sind plausibel, aber falsch beschriftet — oder eine Sektorgrenze greift auf die falschen Titel."},{"seite":"anhang-fehlerdiagnose.html","titel":"Die goldene Diagnoseregel","anker":"die-goldene-diagnoseregel","text":""},{"seite":"anhang-spickzettel.html","titel":"Anhang D: Spickzettel der Solver","anker":null,"text":""},{"seite":"anhang-spickzettel.html","titel":"D1 — SciPy: linprog und milp","anker":"d1-scipy-linprog-und-milp","text":"Wofür. Die Einstiegsschicht: keine zusätzliche Installation, HiGHS als Unterbau, ideal für lineare und gemischt-ganzzahlige Probleme in Matrixform. Wofür nicht: alles, was sich nic"},{"seite":"anhang-spickzettel.html","titel":"Lineares Programm","anker":"lineares-programm","text":""},{"seite":"anhang-spickzettel.html","titel":"Ganzzahlig: milp","anker":"ganzzahlig-milp","text":""},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine","text":""},{"seite":"anhang-spickzettel.html","titel":"D2 — HiGHS über highspy","anker":"d2-highs-über-highspy","text":"Wofür. Derselbe Solver wie unter SciPy, aber direkt gesteuert: Optionen, Warm-Starts, inkrementelles Ändern eines bestehenden Modells. Wofür nicht: schnelles Hinschreiben — die CSR"},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-1","text":""},{"seite":"anhang-spickzettel.html","titel":"D3 — OR-Tools: pywraplp","anker":"d3-or-tools-pywraplp","text":"Wofür. Bequeme algebraische Schreibweise für LP und MILP mit umschaltbarem Backend (GLOP, SCIP, CBC, SAT). Wofür nicht: Scheduling und kombinatorische Bedingungen — dafür ist CP-SA"},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-2","text":""},{"seite":"anhang-spickzettel.html","titel":"D4 — CP-SAT: cp_model","anker":"d4-cp-sat-cp_model","text":"Wofür. Scheduling, Zuordnung, Reihenfolgen, alles Kombinatorische mit globalen Bedingungen. Wofür nicht: stetige Größen — CP-SAT rechnet ausschließlich ganzzahlig. Wer Nachkommaste"},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-3","text":""},{"seite":"anhang-spickzettel.html","titel":"D5 — CVXPY","anker":"d5-cvxpy","text":"Wofür. Konvexe Probleme: quadratische Ziele, Normen, CVaR, alles mit Regularisierungstermen. Wofür nicht: große kombinatorische Modelle — der Aufbau der Ausdrücke wird dann selbst "},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-4","text":""},{"seite":"anhang-spickzettel.html","titel":"D6 — Modellierungssprachen: Pyomo und Linopy","anker":"d6-modellierungssprachen-pyomo-und-linopy","text":"Wofür. Beide trennen Modell von Solver: dasselbe Modell läuft ohne Änderung unter HiGHS, CBC, Gurobi. Pyomo denkt in Mengen und Indizes wie eine mathematische Formulierung; Linopy "},{"seite":"anhang-spickzettel.html","titel":"Pyomo","anker":"pyomo","text":""},{"seite":"anhang-spickzettel.html","titel":"Linopy","anker":"linopy","text":""},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-5","text":""},{"seite":"anhang-spickzettel.html","titel":"Die gemeinsame Regel","anker":"die-gemeinsame-regel","text":"Alle sechs Seiten haben denselben längsten Abschnitt: die Statusauswertung. Das ist kein Zufall. Ein Solveraufruf hat nie zwei Ausgänge, sondern mindestens fünf — optimal, zulässig"},{"seite":"anhang-glossar-literatur.html","titel":"Anhang E: Glossar und Literatur","anker":null,"text":""},{"seite":"anhang-glossar-literatur.html","titel":"Mathematische Optimierung — Grundlagen","anker":"mathematische-optimierung-grundlagen","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Konvexe, nichtlineare und robuste Optimierung","anker":"konvexe-nichtlineare-und-robuste-optimierung","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Constraint Programming und Scheduling","anker":"constraint-programming-und-scheduling","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Quantitative Finanzmathematik","anker":"quantitative-finanzmathematik","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Software und Dokumentation","anker":"software-und-dokumentation","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Verbände und Normen","anker":"verbände-und-normen","text":""}]; +window.OR_SEARCH_INDEX = [{"seite":"vorwort.html","titel":"Vorwort & Lesehilfe","anker":null,"text":""},{"seite":"notation.html","titel":"Notation & Abkürzungen","anker":null,"text":""},{"seite":"notation.html","titel":"1. Die Symbole der Optimierung","anker":"die-symbole-der-optimierung","text":""},{"seite":"notation.html","titel":"1.1 Grundgrößen jedes Modells","anker":"grundgrößen-jedes-modells","text":""},{"seite":"notation.html","titel":"1.2 Mengen und Zahlbereiche","anker":"mengen-und-zahlbereiche","text":""},{"seite":"notation.html","titel":"1.3 Operatoren und Schreibweisen","anker":"operatoren-und-schreibweisen","text":""},{"seite":"notation.html","titel":"1.4 Symbole der Finanzkapitel (Teil IV)","anker":"symbole-der-finanzkapitel-teil-iv","text":""},{"seite":"notation.html","titel":"1.5 Symbole der Verfahrenskapitel (Teil II und III)","anker":"symbole-der-verfahrenskapitel-teil-ii-und-iii","text":"Diese Zeichen kommen erst in den Kapiteln vor, die über das klassische LP hinausgehen."},{"seite":"notation.html","titel":"2. Abkürzungen — vollständig ausgeschrieben","anker":"abkürzungen-vollständig-ausgeschrieben","text":""},{"seite":"notation.html","titel":"2.1 Problemklassen und Verfahren","anker":"problemklassen-und-verfahren","text":""},{"seite":"notation.html","titel":"2.2 Probleme mit Eigennamen","anker":"probleme-mit-eigennamen","text":""},{"seite":"notation.html","titel":"2.3 Finanz- und Risikokennzahlen","anker":"finanz--und-risikokennzahlen","text":""},{"seite":"notation.html","titel":"2.4 Software, Solver und Schnittstellen","anker":"software-solver-und-schnittstellen","text":""},{"seite":"notation.html","titel":"2.5 Begriffe aus dem Solver-Alltag","anker":"begriffe-aus-dem-solver-alltag","text":""},{"seite":"notation.html","titel":"3. Konventionen in diesem Buch","anker":"konventionen-in-diesem-buch","text":"Vektoren sind grundsätzlich Spaltenvektoren und werden fett gesetzt: \\mathbf{x}. Ein transponierter Vektor \\mathbf{x}^\\top ist eine Zeile. Skalare bleiben mager: n, Z."},{"seite":"einfuehrung.html","titel":"Kapitel 1: Einführung in Operations Research — Vom Ursprung zur mathematischen Entscheidungsfindung","anker":null,"text":""},{"seite":"einfuehrung.html","titel":"1.1 In 5 Minuten gelöst","anker":"sec:einfuehrung-schnellstart","text":"Bevor wir über Theorie sprechen, lösen Sie ein echtes Problem. Kopieren Sie den folgenden Block, führen Sie ihn aus, sehen Sie das Ergebnis — die Erklärung kommt danach."},{"seite":"einfuehrung.html","titel":"1.2 Lernziele","anker":"sec:einfuehrung-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"einfuehrung.html","titel":"1.3 Was ist Operations Research wirklich?","anker":"sec:einfuehrung-was-ist-operations-research-wirklich","text":"Im Alltag lösen wir Probleme intuitiv: durch Ausprobieren, Daumenregeln, Erfahrung. Das funktioniert erstaunlich gut — bis eine bestimmte Schwelle überschritten wird. Dann bricht d"},{"seite":"einfuehrung.html","titel":"Die drei Stufen der Analytik","anker":"die-drei-stufen-der-analytik","text":"OR beantwortet nicht die beschreibende Frage „Was ist passiert?“ und nicht die vorhersagende Frage „Was wird passieren?“, sondern die vorschreibende (präskriptive) Kernfrage:"},{"seite":"einfuehrung.html","titel":"1.4 Warum Ausprobieren scheitert — mit eigener Rechnung","anker":"sec:einfuehrung-warum-ausprobieren-scheitert-mit-eigener-rechnung","text":"Die Behauptung „vollständiges Durchprobieren ist unmöglich“ wird oft aufgestellt und selten belegt. Rechnen wir sie einmal selbst nach — das Ergebnis prägt sich besser ein als jede"},{"seite":"einfuehrung.html","titel":"1.5 Historischer Kontext und Evolution","anker":"sec:einfuehrung-historischer-kontext-und-evolution","text":"Der Begriff entstand im Vorfeld des Zweiten Weltkriegs in Großbritannien. Militärische Führungsstäbe standen vor neuartigen logistischen und strategischen Fragen: Wie platziert man"},{"seite":"einfuehrung.html","titel":"1.6 Die vier universellen Bausteine jedes OR-Problems","anker":"sec:einfuehrung-die-vier-universellen-bausteine-jedes-or","text":"Dies ist der wichtigste Abschnitt des Kapitels. Jedes Optimierungsproblem der Welt lässt sich — unabhängig von der Branche — auf vier Elemente reduzieren. Wenn Sie später vor einem"},{"seite":"einfuehrung.html","titel":"Baustein 1 — Entscheidungsvariablen (x)","anker":"baustein-1-entscheidungsvariablen-x","text":"Die Stellschrauben des Modells: Werte, die der Algorithmus aktiv festlegen darf."},{"seite":"einfuehrung.html","titel":"Baustein 2 — Parameter / Eingabedaten (c, A, b)","anker":"baustein-2-parameter-eingabedaten-c-a-b","text":"Die unveränderlichen Fakten der Realität: historische Renditen, Maschinenstundensätze, Lagerkapazitäten, Steuerquoten, Entfernungen, Qualifikationen."},{"seite":"einfuehrung.html","titel":"Baustein 3 — Zielfunktion (f(x))","anker":"baustein-3-zielfunktion-fx","text":"Ein mathematischer Ausdruck, der die Güte einer Lösung zu einer einzigen Zahl verdichtet:"},{"seite":"einfuehrung.html","titel":"Baustein 4 — Nebenbedingungen (Constraints)","anker":"baustein-4-nebenbedingungen-constraints","text":"Regeln, die den Raum der erlaubten Lösungen einschränken:"},{"seite":"einfuehrung.html","titel":"Die vier Bausteine als eine Formel","anker":"die-vier-bausteine-als-eine-formel","text":"Setzt man die vier Bausteine zusammen, entsteht die Standardform, in der Ihnen jedes Optimierungsmodell dieses Buches begegnen wird. Sie sieht auf den ersten Blick sperrig aus — de"},{"seite":"einfuehrung.html","titel":"Die Bausteine in der Praxis: eine Vorlage","anker":"die-bausteine-in-der-praxis-eine-vorlage","text":"Damit die Zerlegung zur Gewohnheit wird, hier eine Vorlage, die Sie bei jedem neuen Problem ausfüllen können — vor der ersten Codezeile."},{"seite":"einfuehrung.html","titel":"1.7 Erstes Python-Vollbeispiel: Ressourcenallokation im Rechenzentrum","anker":"sec:einfuehrung-erstes-python-vollbeispiel-ressourcenallokation-im-rechenzentrum","text":""},{"seite":"einfuehrung.html","titel":"Szenario","anker":"szenario","text":"Ein quantitatives Handelshaus betreibt Serverkapazitäten für zwei Algorithmentypen:"},{"seite":"einfuehrung.html","titel":"Mathematische Formulierung","anker":"mathematische-formulierung","text":"Entscheidungsvariablen:"},{"seite":"einfuehrung.html","titel":"Umsetzung mit Google OR-Tools","anker":"umsetzung-mit-google-or-tools","text":""},{"seite":"einfuehrung.html","titel":"1.8 Von Excel zu Python: Ihre Daten liegen schon da","anker":"sec:einfuehrung-excel-bruecke","text":"In den allermeisten Betrieben liegen die Zahlen, die ein Optimierungsmodell braucht, nicht in einer Datenbank, sondern in einer Tabellenkalkulation. Und viele Leserinnen und Leser "},{"seite":"einfuehrung.html","titel":"1.9 Übungsaufgaben","anker":"sec:einfuehrung-uebungsaufgaben","text":""},{"seite":"einfuehrung.html","titel":"1.10 Finde den Denkfehler","anker":"sec:einfuehrung-denkfehler","text":"Ein Programm, das abstürzt, ist harmlos — Sie sehen sofort, dass etwas nicht stimmt. Gefährlich sind Programme, die fehlerfrei durchlaufen, einen Status OPTIMAL melden und eine Zah"},{"seite":"einfuehrung.html","titel":"1.11 Micro-Quiz","anker":"sec:einfuehrung-quiz","text":""},{"seite":"einfuehrung.html","titel":"1.12 Selbsttest","anker":"sec:einfuehrung-selbsttest","text":""},{"seite":"einfuehrung.html","titel":"1.13 Zusammenfassung","anker":"sec:einfuehrung-zusammenfassung","text":""},{"seite":"fundament.html","titel":"Kapitel 2: Das mathematische Fundament — Vektoren, Matrizen, Konvexität","anker":null,"text":""},{"seite":"fundament.html","titel":"2.1 In 5 Minuten gelöst","anker":"sec:fundament-schnellstart","text":"Dieses Kapitel handelt von Geometrie. Bevor wir Vektoren und Matrizen einführen, sehen Sie die zentrale Einsicht in Aktion — ganz ohne Solver, mit acht Zeilen NumPy."},{"seite":"fundament.html","titel":"2.2 Lernziele","anker":"sec:fundament-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"fundament.html","titel":"2.3 Warum überhaupt Vektoren und Matrizen?","anker":"sec:fundament-warum-ueberhaupt-vektoren-und-matrizen","text":"In Kapitel 1 hatten wir zwei Variablen und drei Nebenbedingungen. Das ließ sich bequem ausschreiben. Reale Modelle haben hunderte bis hunderttausende Variablen — dort wäre Ausschre"},{"seite":"fundament.html","titel":"Die Bausteine","anker":"die-bausteine","text":"Sei n die Anzahl der Entscheidungsvariablen und m die Anzahl der Nebenbedingungen."},{"seite":"fundament.html","titel":"Die kanonische Standardform","anker":"die-kanonische-standardform","text":"Damit lässt sich jedes lineare Programm in drei Zeilen schreiben:"},{"seite":"fundament.html","titel":"Dasselbe in Python","anker":"dasselbe-in-python","text":""},{"seite":"fundament.html","titel":"2.4 Der zulässige Lösungsraum und das Polyeder","anker":"sec:fundament-der-zulaessige-loesungsraum-und-das-polyeder","text":"Jede lineare Ungleichung a_{i1}x_1 + a_{i2}x_2 \\le b_i definiert geometrisch eine Halbebene (im \\mathbb{R}^2) bzw. einen Halbraum (im \\mathbb{R}^n): Die zugehörige Gleichung ist ei"},{"seite":"fundament.html","titel":"Der Fundamentalsatz der linearen Optimierung","anker":"der-fundamentalsatz-der-linearen-optimierung","text":""},{"seite":"fundament.html","titel":"2.5 Konvexität: die Grenze zwischen leicht und schwer","anker":"sec:fundament-konvexitaet-die-grenze-zwischen-leicht-und","text":"Hier kommt die vielleicht wichtigste Einsicht des ganzen Buches. In der Optimierungstheorie verläuft die Trennlinie zwischen „zuverlässig lösbar“ und „im Allgemeinen hoffnungslos“ "},{"seite":"fundament.html","titel":"Konvexe Menge","anker":"konvexe-menge","text":"Eine Menge \\mathcal{C} \\subseteq \\mathbb{R}^n heißt konvex, wenn für alle Punkte \\mathbf{x}, \\mathbf{y} \\in \\mathcal{C} und jedes \\theta \\in [0,1] gilt:"},{"seite":"fundament.html","titel":"Konvexe Funktion","anker":"konvexe-funktion","text":"Eine Funktion f: \\mathcal{C} \\to \\mathbb{R} heißt konvex, wenn für alle \\mathbf{x}, \\mathbf{y} \\in \\mathcal{C} und \\theta \\in [0,1] gilt:"},{"seite":"fundament.html","titel":"Der zentrale Satz","anker":"der-zentrale-satz","text":""},{"seite":"fundament.html","titel":"Was ist konvex, was nicht?","anker":"was-ist-konvex-was-nicht","text":""},{"seite":"fundament.html","titel":"Konvexität sichtbar machen","anker":"konvexität-sichtbar-machen","text":""},{"seite":"fundament.html","titel":"2.6 Geometrische Visualisierung des Lösungsraums","anker":"sec:fundament-geometrische-visualisierung-des-loesungsraums","text":"Wir zeichnen nun den Lösungsraum aus Kapitel 1 und lesen das Optimum ab. Zweidimensionale Bilder sind der schnellste Weg, Intuition für höhere Dimensionen aufzubauen — auch wenn ma"},{"seite":"fundament.html","titel":"2.7 Wenn die Zahlen nicht zusammenpassen: Kondition und Skalierung","anker":"sec:fundament-kondition","text":"Bis hierher war die lineare Algebra exakt. Ein Rechner rechnet aber nicht exakt, sondern mit rund 16 signifikanten Stellen — und Ihre Betriebsdaten sind ohnehin nur auf wenige Stel"},{"seite":"fundament.html","titel":"Die geometrische Vorstellung: zwei fast parallele Linien","anker":"die-geometrische-vorstellung-zwei-fast-parallele-linien","text":"Zwei Nebenbedingungen legen im Zweidimensionalen einen Schnittpunkt fest. Stehen die zugehörigen Geraden kreuzweise aufeinander, ist der Schnittpunkt robust: Verschiebt man eine Ge"},{"seite":"fundament.html","titel":"Woher schlechte Kondition im Alltag kommt","anker":"woher-schlechte-kondition-im-alltag-kommt","text":"Fast nie aus exotischer Mathematik — fast immer aus Einheiten:"},{"seite":"fundament.html","titel":"Ruiz-Equilibrierung: das Modell gesundrechnen","anker":"ruiz-equilibrierung-das-modell-gesundrechnen","text":"Die Gegenmaßnahme ist erstaunlich schlicht. Man multipliziert jede Zeile und jede Spalte der Matrix mit einem Faktor, sodass die Beträge überall in derselben Größenordnung landen:"},{"seite":"fundament.html","titel":"Das Experiment","anker":"das-experiment","text":"Das folgende Programm führt beides vor — die Fehlerverstärkung und ihre Behebung — und schließt mit der praktischen Konsequenz, die Sie in Kapitel 6 wieder brauchen werden: warum e"},{"seite":"fundament.html","titel":"2.8 Übungsaufgaben","anker":"sec:fundament-uebungsaufgaben","text":""},{"seite":"fundament.html","titel":"2.9 Finde den Denkfehler","anker":"sec:fundament-denkfehler","text":""},{"seite":"fundament.html","titel":"2.10 Micro-Quiz","anker":"sec:fundament-quiz","text":""},{"seite":"fundament.html","titel":"2.11 Selbsttest","anker":"sec:fundament-selbsttest","text":""},{"seite":"fundament.html","titel":"2.12 Zusammenfassung","anker":"sec:fundament-zusammenfassung","text":""},{"seite":"oekosystem.html","titel":"Kapitel 3: Das Python-Ökosystem für OR — Solver, Bindings und Modellierungsschichten","anker":null,"text":""},{"seite":"oekosystem.html","titel":"3.1 In 5 Minuten gelöst","anker":"sec:oekosystem-schnellstart","text":""},{"seite":"oekosystem.html","titel":"3.2 Lernziele","anker":"sec:oekosystem-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"oekosystem.html","titel":"3.3 Die Zwei-Schichten-Architektur","anker":"sec:oekosystem-die-zwei-schichten-architektur","text":"Im modernen Operations Research schreibt man Optimierungsalgorithmen nicht selbst. Man nutzt eine zweischichtige Architektur:"},{"seite":"oekosystem.html","titel":"3.4 Die Werkzeuge im Vergleich","anker":"sec:oekosystem-die-werkzeuge-im-vergleich","text":""},{"seite":"oekosystem.html","titel":"Die Entscheidung in drei Fragen","anker":"die-entscheidung-in-drei-fragen","text":"Statt die Tabelle auswendig zu lernen, beantworten Sie drei Fragen:"},{"seite":"oekosystem.html","titel":"3.5 Ein System — vier Programmieransätze","anker":"sec:oekosystem-ein-system-vier-programmieransaetze","text":"Nichts macht die Unterschiede so deutlich wie dasselbe Problem, viermal gelöst. Wir nehmen:"},{"seite":"oekosystem.html","titel":"Was das CSR-Format bedeutet","anker":"was-das-csr-format-bedeutet","text":"highspy erwartet die Nebenbedingungsmatrix im CSR-Format (Compressed Sparse Row, komprimierte Zeilendarstellung). Statt der vollen Matrix speichert man nur die Einträge ungleich nu"},{"seite":"oekosystem.html","titel":"3.6 Wann lohnt sich welche Ebene?","anker":"sec:oekosystem-wann-lohnt-sich-welche-ebene","text":""},{"seite":"oekosystem.html","titel":"3.7 Modellierungsschichten für große Modelle: Pyomo und Linopy","anker":"sec:oekosystem-pyomo-linopy","text":"Die vier Bibliotheken aus dem Vierfach-Vergleich decken den Alltag weitgehend ab. Sobald Modelle industrielle Größe erreichen — zehntausende Variablen, Daten aus mehreren Systemen,"},{"seite":"oekosystem.html","titel":"Pyomo: die algebraische Denkweise","anker":"pyomo-die-algebraische-denkweise","text":"Pyomo ist im deutschsprachigen Raum der De-facto-Standard für große LP- und MILP-Modelle in Energiewirtschaft, Chemie und Logistik. Sein Kennzeichen: Es denkt in Mengen und Indizes"},{"seite":"oekosystem.html","titel":"Linopy: eine Zeile, zehntausend Nebenbedingungen","anker":"linopy-eine-zeile-zehntausend-nebenbedingungen","text":"Linopy verfolgt einen anderen Ansatz: Variablen sind beschriftete Arrays (xarray), keine indizierten Einzelobjekte. Damit wird aus"},{"seite":"oekosystem.html","titel":"Die Schichten im Überblick","anker":"die-schichten-im-überblick","text":""},{"seite":"oekosystem.html","titel":"3.8 Wo die Zeit wirklich hingeht: vektorisierte Modellgenerierung","anker":"sec:oekosystem-vektorisierung","text":"Eine der hartnäckigsten Fehlannahmen in Optimierungsprojekten lautet: „Wenn es zu langsam ist, brauchen wir einen besseren Solver.“ Messen Sie erst — oft stimmt das nicht."},{"seite":"oekosystem.html","titel":"Vier Stufen an einem Transportproblem","anker":"vier-stufen-an-einem-transportproblem","text":"Wir bauen dasselbe Transportproblem (m Werke, n Kunden, m \\cdot n Variablen) auf vier Arten auf:"},{"seite":"oekosystem.html","titel":"3.9 Übungsaufgaben","anker":"sec:oekosystem-uebungsaufgaben","text":""},{"seite":"oekosystem.html","titel":"3.10 Finde den Denkfehler","anker":"sec:oekosystem-denkfehler","text":""},{"seite":"oekosystem.html","titel":"3.11 Micro-Quiz","anker":"sec:oekosystem-quiz","text":""},{"seite":"oekosystem.html","titel":"3.12 Selbsttest","anker":"sec:oekosystem-selbsttest","text":""},{"seite":"oekosystem.html","titel":"3.13 Zusammenfassung","anker":"sec:oekosystem-zusammenfassung","text":""},{"seite":"modellierung.html","titel":"Kapitel 4: Vom Management-Wunsch zum Modell","anker":null,"text":""},{"seite":"modellierung.html","titel":"4.1 In 5 Minuten gelöst","anker":"sec:modellierung-schnellstart","text":""},{"seite":"modellierung.html","titel":"4.2 Lernziele","anker":"sec:modellierung-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"modellierung.html","titel":"4.3 Ein Satz ist noch kein Modell","anker":"sec:modellierung-vier-fragen","text":"Was aus einer Besprechung mitkommt, klingt meist so:"},{"seite":"modellierung.html","titel":"4.4 Derselbe Datensatz, fünf Modelle","anker":"sec:modellierung-fuenf-modelle","text":"Das Programm dieses Kapitels arbeitet die vier Fragen an einer Auftragsannahme ab: 14 Anfragen verlangen 800 Maschinenstunden, verfügbar sind 300."},{"seite":"modellierung.html","titel":"Teil 1: Drei Ziele, drei Pläne","anker":"teil-1-drei-ziele-drei-pläne","text":""},{"seite":"modellierung.html","titel":"Teil 2: Ein Satz, drei Lesarten","anker":"teil-2-ein-satz-drei-lesarten","text":"„Stammkunden dürfen wir nicht verlieren.” Jeder im Raum nickt. Niemand merkt, dass drei verschiedene Modelle gemeint sein können:"},{"seite":"modellierung.html","titel":"Teil 3: Hart oder weich","anker":"teil-3-hart-oder-weich","text":"Dieselbe Regel als Strafkosten je abgelehntem Stammkundenauftrag:"},{"seite":"modellierung.html","titel":"Teil 4: Ein Quartal später","anker":"teil-4-ein-quartal-später","text":"Die harte Lesart A wurde eingebaut, alles lief drei Monate lang. Dann fragt Weber einen Auftrag über 320 Maschinenstunden an — mehr, als der ganze Zeitraum hergibt."},{"seite":"modellierung.html","titel":"4.5 Der Gesprächsleitfaden","anker":"sec:modellierung-leitfaden","text":"Was aus den vier Teilen als Handwerkszeug bleibt — Fragen, die man in der ersten Besprechung stellt, bevor eine Zeile Code entsteht:"},{"seite":"modellierung.html","titel":"4.6 Übungsaufgaben","anker":"sec:modellierung-uebungsaufgaben","text":""},{"seite":"modellierung.html","titel":"4.7 Finde den Denkfehler","anker":"sec:modellierung-denkfehler","text":""},{"seite":"modellierung.html","titel":"4.8 Micro-Quiz","anker":"sec:modellierung-quiz","text":""},{"seite":"modellierung.html","titel":"4.9 Selbsttest","anker":"sec:modellierung-selbsttest","text":""},{"seite":"modellierung.html","titel":"4.10 Zusammenfassung","anker":"sec:modellierung-zusammenfassung","text":""},{"seite":"lp.html","titel":"Kapitel 5: Lineare Programmierung — Simplex, Dualität und Schattenpreise","anker":null,"text":""},{"seite":"lp.html","titel":"5.1 In 5 Minuten gelöst","anker":"sec:lp-schnellstart","text":"Bisher hat ein Solver Ihnen gesagt, was Sie tun sollen. Jetzt sagt er Ihnen zusätzlich, was eine zusätzliche Stunde wert wäre — und das ist die Zahl, für die Sie im Betrieb gehört "},{"seite":"lp.html","titel":"5.2 Lernziele","anker":"sec:lp-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"lp.html","titel":"5.3 Die Standardform und Schlupfvariablen","anker":"sec:lp-die-standardform-und-schlupfvariablen","text":"Lineare Programme zeichnen sich dadurch aus, dass Zielfunktion und alle Nebenbedingungen strikt linear sind — keine Produkte von Variablen, keine Quadrate, keine Logarithmen."},{"seite":"lp.html","titel":"5.4 Der Simplex-Algorithmus Schritt für Schritt","anker":"sec:lp-der-simplex-algorithmus-schritt-fuer-schritt","text":"Der von George Dantzig 1947 formulierte Simplex-Algorithmus nutzt den Fundamentalsatz aus Kapitel 2: Das Optimum liegt in einer Ecke. Statt alle Ecken aufzuzählen, wandert er von E"},{"seite":"lp.html","titel":"Der Ablauf","anker":"der-ablauf","text":""},{"seite":"lp.html","titel":"5.5 Das Simplex-Tableau in Python","anker":"sec:lp-das-simplex-tableau-in-python","text":""},{"seite":"lp.html","titel":"5.6 Dualität und Schattenpreise","anker":"sec:lp-dualitaet-und-schattenpreise","text":"Zu jedem linearen Optimierungsproblem (dem primalen Problem) existiert ein spiegelbildliches Zwillingsproblem: das duale Problem."},{"seite":"lp.html","titel":"Der starke Dualitätssatz","anker":"der-starke-dualitätssatz","text":""},{"seite":"lp.html","titel":"Der Schattenpreis als Ableitung","anker":"der-schattenpreis-als-ableitung","text":"Der Dualwert y_i^* ist die Grenzproduktivität der Ressource i:"},{"seite":"lp.html","titel":"5.7 Die Vorzeichenfalle bei Schattenpreisen","anker":"sec:lp-die-vorzeichenfalle-bei-schattenpreisen","text":"Diese Falle sollten Sie sich merken, weil sie in jedem Projekt wieder auftaucht."},{"seite":"lp.html","titel":"5.8 Praxisfall: Sensitivitätsanalyse mit korrekten Schattenpreisen","anker":"sec:lp-praxisfall-sensitivitaetsanalyse-mit-korrekten-schattenpreisen","text":"Szenario. Ein Fertigungsbetrieb stellt drei Produkte her und will wissen:"},{"seite":"lp.html","titel":"5.9 Wann Schattenpreise lügen: Entartung und Toleranzen","anker":"sec:lp-entartung","text":"Der vorige Abschnitt hat gezeigt, wie wertvoll Schattenpreise sind. Dieser zeigt die zwei Fälle, in denen sie eine Entscheidung nicht tragen — und wie man beide erkennt, bevor jema"},{"seite":"lp.html","titel":"Fall 1: Entartung","anker":"fall-1-entartung","text":"Erinnern Sie sich an Abschnitt 2.4: In zwei Dimensionen legen zwei sich schneidende Geraden eine Ecke fest, in drei Dimensionen drei Ebenen, allgemein n Restriktionen bei n Variabl"},{"seite":"lp.html","titel":"Fall 2: Toleranzen","anker":"fall-2-toleranzen","text":"Der zweite Fall ist unscheinbarer und deshalb häufiger. Wie stellt man fest, ob eine Ressource ausgelastet ist? Naheliegend wäre if schlupf == 0. Genau das ist falsch."},{"seite":"lp.html","titel":"Die ehrliche Auskunft: Schattenpreis-Spannen","anker":"die-ehrliche-auskunft-schattenpreis-spannen","text":"Was tut man also, wenn das Modell entartet ist? Man meldet keinen Einzelwert, sondern die Spanne. Die Menge aller optimalen Dualvektoren ist selbst ein Polyeder:"},{"seite":"lp.html","titel":"5.10 Übungsaufgaben","anker":"sec:lp-uebungsaufgaben","text":""},{"seite":"lp.html","titel":"5.11 Finde den Denkfehler","anker":"sec:lp-denkfehler","text":""},{"seite":"lp.html","titel":"5.12 Micro-Quiz","anker":"sec:lp-quiz","text":""},{"seite":"lp.html","titel":"5.13 Selbsttest","anker":"sec:lp-selbsttest","text":""},{"seite":"lp.html","titel":"5.14 Zusammenfassung","anker":"sec:lp-zusammenfassung","text":""},{"seite":"milp.html","titel":"Kapitel 6: Gemischt-ganzzahlige Optimierung — Diskrete Entscheidungen und Branch-and-Bound","anker":null,"text":""},{"seite":"milp.html","titel":"6.1 In 5 Minuten gelöst","anker":"sec:milp-schnellstart","text":""},{"seite":"milp.html","titel":"6.2 Lernziele","anker":"sec:milp-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"milp.html","titel":"6.3 Warum Runden fundamental scheitert","anker":"sec:milp-warum-runden-fundamental-scheitert","text":"In der realen Welt sind viele Entscheidungen nicht teilbar. Man kann nicht 0,47 Flugzeuge kaufen, keine halbe Lagerhalle bauen, keinen Mitarbeiter zu 38 % einstellen. Noch wichtige"},{"seite":"milp.html","titel":"6.4 Branch-and-Bound","anker":"sec:milp-branch-and-bound","text":"MILP-Probleme sind NP-schwer. Der Standardansatz moderner Solver (HiGHS, SCIP, Gurobi) heißt Branch-and-Cut — Branch-and-Bound plus Schnittebenen."},{"seite":"milp.html","titel":"Die drei Phasen","anker":"die-drei-phasen","text":""},{"seite":"milp.html","titel":"6.5 Modellierungstricks: Big-M und logische Bedingungen","anker":"sec:milp-modellierungstricks-big-m-und-logische-bedingungen","text":"Hier kommt das Handwerkszeug, das MILP so mächtig macht: Man übersetzt Geschäftsregeln in lineare Ungleichungen — mithilfe binärer Hilfsvariablen y \\in \\{0,1\\} und einer hinreichen"},{"seite":"milp.html","titel":"Muster 1 — Fixkosten / Aktivierungsschalter","anker":"muster-1-fixkosten-aktivierungsschalter","text":"Regel: Wird Maschine j genutzt (x_j > 0), fallen Rüstkosten F_j an."},{"seite":"milp.html","titel":"Muster 2 — Entweder-Oder (disjunctive constraints)","anker":"muster-2-entweder-oder-disjunctive-constraints","text":"Regel: Es muss entweder f(\\mathbf{x}) \\le b_1 oder g(\\mathbf{x}) \\le b_2 gelten."},{"seite":"milp.html","titel":"Muster 3 — Wenn-Dann (Implikation)","anker":"muster-3-wenn-dann-implikation","text":"Regel: Wenn y_1 = 1, dann muss auch y_2 = 1 sein."},{"seite":"milp.html","titel":"Muster 4 — Kardinalität („höchstens K aus N“)","anker":"muster-4-kardinalität-höchstens-k-aus-n","text":"\\sum_{j=1}^N y_j \\le K"},{"seite":"milp.html","titel":"Muster 5 — Semikontinuierlich („entweder 0 oder mindestens L“)","anker":"muster-5-semikontinuierlich-entweder-0-oder-mindestens-l","text":"Regel: Eine Position ist entweder gar nicht besetzt oder mit mindestens L Euro."},{"seite":"milp.html","titel":"6.6 Beispiel: Das Rucksackproblem","anker":"sec:milp-beispiel-das-rucksackproblem","text":"Bevor wir zum Portfolio kommen, das klassische Einstiegsproblem — kurz, verständlich und überall wiederzuerkennen."},{"seite":"milp.html","titel":"6.7 Praxisfall: Portfolio mit Ordergebühren und Kardinalitätsgrenze","anker":"sec:milp-praxisfall-portfolio-mit-ordergebuehren-und-kardinalitaetsgrenze","text":"Szenario. Ein Investor verteilt 100\\,000\\,\\text{€} auf sechs Anlageklassen."},{"seite":"milp.html","titel":"6.8 Wenn der Solver nicht fertig wird: Gap, Zeitlimit und Warm-Start","anker":"sec:milp-gap","text":"Bei einem LP gibt es zwei Ausgänge: eine optimale Lösung oder eine klare Absage. Bei einem MILP gibt es einen dritten, und im Betrieb ist er der häufigste:"},{"seite":"milp.html","titel":"Die zwei Zahlen, die zählen","anker":"die-zwei-zahlen-die-zählen","text":"Branch-and-Bound führt zu jedem Zeitpunkt zwei Werte mit:"},{"seite":"milp.html","titel":"Alle Statusfälle behandeln","anker":"alle-statusfälle-behandeln","text":"Der zweite Teil der Professionalität ist unspektakulär, aber entscheidend: jeden Rückgabewert auswerten, nicht nur OPTIMAL."},{"seite":"milp.html","titel":"Warm-Starts: was sie können und was nicht","anker":"warm-starts-was-sie-können-und-was-nicht","text":"Ein Warm-Start gibt dem Solver eine bekannte Lösung als Startpunkt mit. Die Idee ist verlockend: Wer schon eine brauchbare Lösung hat, muss nicht bei null anfangen."},{"seite":"milp.html","titel":"6.9 Übungsaufgaben","anker":"sec:milp-uebungsaufgaben","text":""},{"seite":"milp.html","titel":"6.10 Finde den Denkfehler","anker":"sec:milp-denkfehler","text":"Die Warnung „M so klein wie möglich“ wird meist mit der Laufzeit begründet: schwache Relaxation, mehr Knoten. Das stimmt — und ist die harmlosere Hälfte der Wahrheit."},{"seite":"milp.html","titel":"6.11 Micro-Quiz","anker":"sec:milp-quiz","text":""},{"seite":"milp.html","titel":"6.12 Selbsttest","anker":"sec:milp-selbsttest","text":""},{"seite":"milp.html","titel":"6.13 Zusammenfassung","anker":"sec:milp-zusammenfassung","text":""},{"seite":"cpsat.html","titel":"Kapitel 7: Constraint Programming mit CP-SAT — Logik, Scheduling und Zuweisung","anker":null,"text":""},{"seite":"cpsat.html","titel":"7.1 In 5 Minuten gelöst","anker":"sec:cpsat-schnellstart","text":""},{"seite":"cpsat.html","titel":"7.2 Lernziele","anker":"sec:cpsat-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"cpsat.html","titel":"7.3 Ein anderes Denkmodell","anker":"sec:cpsat-ein-anderes-denkmodell","text":"Während LP und MILP auf algebraischen Gleichungen und Simplex-Matrizen beruhen, kommt Constraint Programming (CP) aus der künstlichen Intelligenz:"},{"seite":"cpsat.html","titel":"Was Propagation leistet","anker":"was-propagation-leistet","text":""},{"seite":"cpsat.html","titel":"7.4 Globale Constraints — die Bausteine von CP-SAT","anker":"sec:cpsat-globale-constraints-die-bausteine-von-cp","text":"CP-SAT stellt mächtige Makros bereit, die in MILP hunderte Big-M-Ungleichungen erfordern würden. Jedes davon hat einen spezialisierten, hocheffizienten Propagator."},{"seite":"cpsat.html","titel":"7.5 Praxisfall: Dynamisches Vertretungssystem","anker":"sec:cpsat-praxisfall-dynamisches-vertretungssystem","text":"Problemstellung. Eine Schule muss nach spontanen Ausfällen vier Unterrichtsstunden nachbesetzen."},{"seite":"cpsat.html","titel":"7.6 Intervallvariablen: Job-Shop-Scheduling","anker":"sec:cpsat-intervallvariablen-job-shop-scheduling","text":"Die Königsdisziplin von CP-SAT sind Zeitplanungsprobleme. Intervallvariablen sind dabei das zentrale Werkzeug."},{"seite":"cpsat.html","titel":"7.7 Parallele Suche: was num_workers wirklich bewirkt","anker":"sec:cpsat-parallele-suche","text":"An mehreren Stellen dieses Buchs steht loeser.parameters.num_workers = 1, mit dem Kommentar „für eine reproduzierbare Ausgabe“. Darin stecken zwei Behauptungen: dass parallele Such"},{"seite":"cpsat.html","titel":"Was die Messung zeigt","anker":"was-die-messung-zeigt","text":"Erstens: Die Beschleunigung ist überlinear. Acht Arbeiter sind im abgedruckten Lauf nicht achtmal, sondern gut zwölfmal schneller als einer. Das sieht nach Messfehler aus, ist aber"},{"seite":"cpsat.html","titel":"Was daraus für Tests folgt","anker":"was-daraus-für-tests-folgt","text":"Der Zielwert war in allen sechzehn Läufen derselbe: 183. Die Gestalt der Lösung war es nicht. Genau daran entscheidet sich, was ein Test prüfen darf:"},{"seite":"cpsat.html","titel":"7.8 Die fünf Antworten von CP-SAT","anker":"sec:cpsat-statusfaelle","text":"Abschnitt 6.8 hat gezeigt, warum OPTIMAL bei ganzzahligen Problemen nicht der Normalfall ist. Bei CP-SAT kommt eine Besonderheit hinzu: Es gibt fünf verschiedene Antworten, und dre"},{"seite":"cpsat.html","titel":"7.9 Übungsaufgaben","anker":"sec:cpsat-uebungsaufgaben","text":""},{"seite":"cpsat.html","titel":"7.10 Finde den Denkfehler","anker":"sec:cpsat-denkfehler","text":""},{"seite":"cpsat.html","titel":"7.11 Micro-Quiz","anker":"sec:cpsat-quiz","text":""},{"seite":"cpsat.html","titel":"7.12 Selbsttest","anker":"sec:cpsat-selbsttest","text":""},{"seite":"cpsat.html","titel":"7.13 Zusammenfassung","anker":"sec:cpsat-zusammenfassung","text":""},{"seite":"graphen.html","titel":"Kapitel 8: Graphen, Flüsse und Touren — Min-Cost-Flow, Matching und VRP","anker":null,"text":""},{"seite":"graphen.html","titel":"8.1 In 5 Minuten gelöst","anker":"sec:graphen-schnellstart","text":""},{"seite":"graphen.html","titel":"8.2 Lernziele","anker":"sec:graphen-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"graphen.html","titel":"8.3 Graphen als Modellsprache","anker":"sec:graphen-graphen-als-modellsprache","text":"Viele reale Probleme in Logistik, Kommunikation und Finanzströmen sind keine flachen Ungleichungssysteme, sondern Graphen G = (V, E):"},{"seite":"graphen.html","titel":"Das Minimum-Cost-Flow-Problem (MCNFP)","anker":"das-minimum-cost-flow-problem-mcnfp","text":"Minimum-Cost Network Flow Problem — deutsch: kostenminimales Flussproblem. Es ist das mathematische Fundament für Transportketten, Liquiditätsrouting und Datenverteilung."},{"seite":"graphen.html","titel":"8.4 Bipartites Matching: das Zuordnungsproblem","anker":"sec:graphen-bipartites-matching-das-zuordnungsproblem","text":"Wenn N Aufgaben auf N Ressourcen eins zu eins verteilt werden — Orders auf Broker, Schichten auf Mitarbeitende, Aufträge auf Maschinen — spricht man von bipartitem Matching."},{"seite":"graphen.html","titel":"Der Satz von Birkhoff und von Neumann","anker":"der-satz-von-birkhoff-und-von-neumann","text":""},{"seite":"graphen.html","titel":"8.5 Das Vehicle Routing Problem mit Zeitfenstern","anker":"sec:graphen-das-vehicle-routing-problem-mit-zeitfenstern","text":"Das Traveling Salesperson Problem (TSP), deutsch Problem des Handlungsreisenden, fragt nach der kürzesten Rundreise durch N Städte. Das Capacitated Vehicle Routing Problem with Tim"},{"seite":"graphen.html","titel":"Kurzzyklen verhindern","anker":"kurzzyklen-verhindern","text":"Ein naives Modell erlaubt Subtouren: isolierte Kreise, die das Depot nie anfahren. Die klassische Gegenmaßnahme ist die MTZ-Formulierung nach Miller, Tucker und Zemlin. Man führt R"},{"seite":"graphen.html","titel":"Praxisbeispiel: Flotten-Routing","anker":"praxisbeispiel-flotten-routing","text":""},{"seite":"graphen.html","titel":"8.6 Übungsaufgaben","anker":"sec:graphen-uebungsaufgaben","text":""},{"seite":"graphen.html","titel":"8.7 Finde den Denkfehler","anker":"sec:graphen-denkfehler","text":""},{"seite":"graphen.html","titel":"8.8 Micro-Quiz","anker":"sec:graphen-quiz","text":""},{"seite":"graphen.html","titel":"8.9 Selbsttest","anker":"sec:graphen-selbsttest","text":""},{"seite":"graphen.html","titel":"8.10 Zusammenfassung","anker":"sec:graphen-zusammenfassung","text":""},{"seite":"metaheuristiken.html","titel":"Kapitel 9: Metaheuristiken — wenn der exakte Solver aussteigt","anker":null,"text":""},{"seite":"metaheuristiken.html","titel":"9.1 In 5 Minuten gelöst","anker":"sec:metaheuristiken-schnellstart","text":""},{"seite":"metaheuristiken.html","titel":"9.2 Lernziele","anker":"sec:metaheuristiken-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"metaheuristiken.html","titel":"9.3 Die Aufgabe: Rüstzeiten an der Lackieranlage","anker":"sec:metaheuristiken-aufgabe","text":"Der Schnellstart war ein Spielzeug. Die Aufgabe dieses Kapitels ist dieselbe, nur in Betriebsgröße: n Aufträge in eine Reihenfolge bringen, so dass die Summe der Rüstzeiten minimal"},{"seite":"metaheuristiken.html","titel":"Die Instanz","anker":"die-instanz","text":"Alle Programme dieses Kapitels benutzen dieselbe Aufgabe. Die Rüstzeit setzt sich aus zwei Teilen zusammen: einem unregelmäßigen Reinigungsaufwand zwischen den Produktfamilien und "},{"seite":"metaheuristiken.html","titel":"9.4 Lokale Suche: der Zug und seine Kosten","anker":"sec:metaheuristiken-lokale-suche","text":"Eine lokale Suche braucht drei Dinge: eine Startlösung, einen Zug und eine Regel, welche Züge übernommen werden."},{"seite":"metaheuristiken.html","titel":"9.5 Simulated Annealing","anker":"sec:metaheuristiken-annealing","text":"Reines Bergsteigen — nur Verbesserungen annehmen — bleibt im ersten lokalen Optimum stehen. Simulated Annealing nimmt Verschlechterungen mit einer Wahrscheinlichkeit an, die von de"},{"seite":"metaheuristiken.html","titel":"Was die Messung zeigt — und was sie nicht zeigt","anker":"was-die-messung-zeigt-und-was-sie-nicht-zeigt","text":"Erstens: Der Beitrag des Annealings ist bescheiden. Bergsteigen kommt auf 917 Minuten, Annealing auf 907 — das simple Verfahren holt 93 % des Ertrags. Das ist kein Argument gegen A"},{"seite":"metaheuristiken.html","titel":"9.6 Ab wann lohnt es sich?","anker":"sec:metaheuristiken-umschlagpunkt","text":"„Kleine Probleme exakt, große mit Metaheuristiken“ ist richtig und nutzlos, solange niemand sagt, wo groß anfängt. Diese Zahl lässt sich messen."},{"seite":"metaheuristiken.html","titel":"Die Spalte, die man nicht weglassen darf","anker":"die-spalte-die-man-nicht-weglassen-darf","text":"Bei 500 Aufträgen haben wir die CP-SAT-Lösung verworfen. Seine Schranke ist trotzdem das Wertvollste in der ganzen Tabelle."},{"seite":"metaheuristiken.html","titel":"9.7 Large Neighborhood Search","anker":"sec:metaheuristiken-lns","text":"Die Tabelle oben endet mit einem Widerspruch. Bei 500 Aufträgen scheitert der exakte Solver an der Größe; die Metaheuristik scheitert an der Kleinheit ihrer Züge — sie verschiebt j"},{"seite":"metaheuristiken.html","titel":"Das Fenster ist die eine Stellschraube","anker":"das-fenster-ist-die-eine-stellschraube","text":""},{"seite":"metaheuristiken.html","titel":"Das Gesamtbild","anker":"das-gesamtbild","text":""},{"seite":"metaheuristiken.html","titel":"9.8 Was dieses Kapitel nicht behandelt","anker":"sec:metaheuristiken-abgrenzung","text":"Damit die Landkarte vollständig ist — drei Verfahren, die in dieselbe Familie gehören und hier bewusst nur benannt werden:"},{"seite":"metaheuristiken.html","titel":"9.9 Übungsaufgaben","anker":"sec:metaheuristiken-uebungsaufgaben","text":""},{"seite":"metaheuristiken.html","titel":"9.10 Finde den Denkfehler","anker":"sec:metaheuristiken-denkfehler","text":""},{"seite":"metaheuristiken.html","titel":"9.11 Micro-Quiz","anker":"sec:metaheuristiken-quiz","text":""},{"seite":"metaheuristiken.html","titel":"9.12 Selbsttest","anker":"sec:metaheuristiken-selbsttest","text":""},{"seite":"metaheuristiken.html","titel":"9.13 Zusammenfassung","anker":"sec:metaheuristiken-zusammenfassung","text":""},{"seite":"dekomposition.html","titel":"Kapitel 10: Spaltengenerierung — das Modell umbauen statt die Lösung raten","anker":null,"text":""},{"seite":"dekomposition.html","titel":"10.1 In 5 Minuten gelöst","anker":"sec:dekomposition-schnellstart","text":""},{"seite":"dekomposition.html","titel":"10.2 Lernziele","anker":"sec:dekomposition-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"dekomposition.html","titel":"10.3 Warum das naheliegende Modell scheitert","anker":"sec:dekomposition-symmetrie","text":"Der erste Entwurf eines Zuschnittmodells sieht fast immer so aus: eine Binärvariable z_{jr} für „Stück j liegt auf Rolle r“, dazu y_r für „Rolle r wird benutzt”."},{"seite":"dekomposition.html","titel":"10.4 Rechnen mit Mustern, ohne sie aufzuschreiben","anker":"sec:dekomposition-schleife","text":"Die Idee ist alt (Gilmore und Gomory, 1961) und in einem Satz gesagt:"},{"seite":"dekomposition.html","titel":"10.5 Das Programm","anker":"sec:dekomposition-programm","text":""},{"seite":"dekomposition.html","titel":"10.6 Was die Zahlen zeigen","anker":"sec:dekomposition-befund","text":""},{"seite":"dekomposition.html","titel":"Die Musterzahl explodiert, der Bedarf nicht","anker":"die-musterzahl-explodiert-der-bedarf-nicht","text":""},{"seite":"dekomposition.html","titel":"Der unbequeme Teil: Es lohnt sich nicht immer","anker":"der-unbequeme-teil-es-lohnt-sich-nicht-immer","text":""},{"seite":"dekomposition.html","titel":"10.7 Übungsaufgaben","anker":"sec:dekomposition-uebungsaufgaben","text":""},{"seite":"dekomposition.html","titel":"10.8 Finde den Denkfehler","anker":"sec:dekomposition-denkfehler","text":""},{"seite":"dekomposition.html","titel":"10.9 Micro-Quiz","anker":"sec:dekomposition-quiz","text":""},{"seite":"dekomposition.html","titel":"10.10 Selbsttest","anker":"sec:dekomposition-selbsttest","text":""},{"seite":"dekomposition.html","titel":"10.11 Zusammenfassung","anker":"sec:dekomposition-zusammenfassung","text":""},{"seite":"qp-nlp.html","titel":"Kapitel 11: Quadratische und nichtlineare Optimierung — KKT, Lagrange, Konvexität","anker":null,"text":""},{"seite":"qp-nlp.html","titel":"11.1 In 5 Minuten gelöst","anker":"sec:qp-nlp-schnellstart","text":""},{"seite":"qp-nlp.html","titel":"11.2 Lernziele","anker":"sec:qp-nlp-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"qp-nlp.html","titel":"Auffrischung: Gradient in einer Minute","anker":"auffrischung-gradient-in-einer-minute","text":"Der Gradient \\nabla f(\\mathbf{x}) ist der Vektor aller partiellen Ableitungen:"},{"seite":"qp-nlp.html","titel":"11.3 Das quadratische Programm (QP)","anker":"sec:qp-nlp-das-quadratische-programm-qp","text":"Ein quadratisches Programm hat eine quadratische Zielfunktion und lineare Nebenbedingungen:"},{"seite":"qp-nlp.html","titel":"Konvexität: die präzise Aussage","anker":"konvexität-die-präzise-aussage","text":"Eine verbreitete, aber zu stark formulierte Aussage lautet: „Wenn \\mathbf{P} positiv semi-definit ist, ist das QP streng konvex; es existiert ein eindeutiges globales Minimum.“ Kor"},{"seite":"qp-nlp.html","titel":"11.4 Die Karush-Kuhn-Tucker-Bedingungen","anker":"sec:qp-nlp-die-karush-kuhn-tucker-bedingungen","text":"Die KKT-Bedingungen verallgemeinern den Lagrange-Ansatz auf Probleme mit Ungleichungen. Sie sind notwendig für ein Optimum — und bei konvexen Problemen auch hinreichend."},{"seite":"qp-nlp.html","titel":"Die vier KKT-Bedingungen","anker":"die-vier-kkt-bedingungen","text":"Am Optimum (\\mathbf{x}^*, \\boldsymbol{\\lambda}^*, \\boldsymbol{\\nu}^*) gilt:"},{"seite":"qp-nlp.html","titel":"11.5 Nichtlineare Optimierung mit scipy.optimize.minimize","anker":"sec:qp-nlp-nichtlineare-optimierung-mit-scipy-optimize-minimize","text":"Wenn Zielfunktion oder Nebenbedingungen weder linear noch quadratisch sind — Logarithmen, Wurzeln, Exponentialfunktionen — nutzt man gradientenbasierte Verfahren wie SLSQP (Sequent"},{"seite":"qp-nlp.html","titel":"Praxisfall: Entropie-maximierte Kapitalallokation","anker":"praxisfall-entropie-maximierte-kapitalallokation","text":"Wir optimieren ein Portfolio nicht nur nach Varianz, sondern maximieren zusätzlich die Shannon-Entropie der Gewichte, um Klumpenrisiken glatt zu bestrafen:"},{"seite":"qp-nlp.html","titel":"11.6 Jenseits der Konvexität: lokale Optima, Multistart und MINLP","anker":"sec:qp-nlp-nichtkonvex","text":"Alles bisher in diesem Kapitel stand unter einer Bedingung: Konvexität. Sie ist der Grund, warum CVXPY eine Optimalitätsgarantie geben kann und warum die KKT-Bedingungen nicht nur "},{"seite":"qp-nlp.html","titel":"Woher Nicht-Konvexität im Alltag kommt","anker":"woher-nicht-konvexität-im-alltag-kommt","text":"Nicht aus exotischer Mathematik, sondern aus ganz gewöhnlichen betriebswirtschaftlichen Effekten:"},{"seite":"qp-nlp.html","titel":"Was ein lokales Optimum praktisch bedeutet","anker":"was-ein-lokales-optimum-praktisch-bedeutet","text":"scipy.optimize.minimize verweigert nichts. Es rechnet, meldet success: True und liefert eine Zahl. Diese Meldung heißt aber nicht „das ist das Optimum“, sondern nur:"},{"seite":"qp-nlp.html","titel":"Die Zahlen im Klartext","anker":"die-zahlen-im-klartext","text":""},{"seite":"qp-nlp.html","titel":"Ausblick: MINLP und globale Solver","anker":"ausblick-minlp-und-globale-solver","text":"Wird zusätzlich noch ganzzahlig entschieden — „welche Anlage läuft überhaupt“ und „wie stark“ —, entsteht ein MINLP (Mixed-Integer Nonlinear Program), die schwierigste der in diese"},{"seite":"qp-nlp.html","titel":"11.7 Übungsaufgaben","anker":"sec:qp-nlp-uebungsaufgaben","text":""},{"seite":"qp-nlp.html","titel":"11.8 Finde den Denkfehler","anker":"sec:qp-nlp-denkfehler","text":""},{"seite":"qp-nlp.html","titel":"11.9 Micro-Quiz","anker":"sec:qp-nlp-quiz","text":""},{"seite":"qp-nlp.html","titel":"11.10 Selbsttest","anker":"sec:qp-nlp-selbsttest","text":""},{"seite":"qp-nlp.html","titel":"11.11 Zusammenfassung","anker":"sec:qp-nlp-zusammenfassung","text":""},{"seite":"unsicherheit.html","titel":"Kapitel 12: Optimierung unter Unsicherheit — Monte-Carlo, Stochastik, Robustheit","anker":null,"text":""},{"seite":"unsicherheit.html","titel":"12.1 In 5 Minuten gelöst","anker":"sec:unsicherheit-schnellstart","text":""},{"seite":"unsicherheit.html","titel":"12.2 Lernziele","anker":"sec:unsicherheit-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"unsicherheit.html","titel":"12.3 Der Fluch des Durchschnitts","anker":"sec:unsicherheit-der-fluch-des-durchschnitts","text":"Der naheliegende Umgang mit Unsicherheit lautet: „Wir setzen den Erwartungswert ein und rechnen deterministisch.“ Das ist fast immer falsch — und zwar nicht ungenau, sondern system"},{"seite":"unsicherheit.html","titel":"Die vier Ansätze im Überblick","anker":"die-vier-ansätze-im-überblick","text":""},{"seite":"unsicherheit.html","titel":"12.4 Monte-Carlo-Simulation","anker":"sec:unsicherheit-monte-carlo-simulation","text":"Monte-Carlo heißt schlicht: Ziehe sehr viele Zufallsszenarien, werte für jedes den Plan aus, und betrachte die Verteilung der Ergebnisse statt eines einzigen Werts."},{"seite":"unsicherheit.html","titel":"12.5 Zweistufige stochastische Programmierung","anker":"sec:unsicherheit-zweistufige-stochastische-programmierung","text":"Das zweistufige Modell mit Recourse bildet ab, dass Entscheidungen zeitlich gestaffelt sind:"},{"seite":"unsicherheit.html","titel":"12.6 Robuste Optimierung: gegen den Worst Case absichern","anker":"sec:unsicherheit-robuste-optimierung-gegen-den-worst-case","text":"Wenn Wahrscheinlichkeiten unbekannt oder instabil sind — Marktcrashs, Lieferkettenabrisse, Pandemien —, hilft die robuste Optimierung. Sie fragt nicht nach dem Mittel, sondern nach"},{"seite":"unsicherheit.html","titel":"12.7 Wahrscheinlichkeitsbeschränkungen: „mit 95 % Sicherheit“","anker":"sec:unsicherheit-chance-constraints","text":"Die drei bisherigen Ansätze beantworten Fragen, die im Sitzungssaal selten so gestellt werden. „Was ist im Mittel am besten?“ hört ein Vorstand als Ausflucht; „was hält auch im sch"},{"seite":"unsicherheit.html","titel":"Weg 1: Normalverteilung — die Bedingung wird ein Kegel","anker":"weg-1-normalverteilung-die-bedingung-wird-ein-kegel","text":"Ist \\mathbf{a} \\sim \\mathcal{N}(\\hat{\\mathbf{a}}, \\boldsymbol{\\Sigma}), dann ist \\mathbf{a}^\\top\\mathbf{x} für festes \\mathbf{x} selbst normalverteilt, mit Erwartungswert \\hat{\\mat"},{"seite":"unsicherheit.html","titel":"Weg 2: Szenarien — Big-M ohne Verteilungsannahme","anker":"weg-2-szenarien-big-m-ohne-verteilungsannahme","text":"Liegen statt einer Verteilung nur S beobachtete Szenarien vor, bekommt jedes eine Binärvariable z_s, die sagt, ob es verletzt werden darf:"},{"seite":"unsicherheit.html","titel":"Der Fall: ein Kraftwerkspark mit 500 MW Zusage","anker":"der-fall-ein-kraftwerkspark-mit-500-mw-zusage","text":""},{"seite":"unsicherheit.html","titel":"12.8 Übungsaufgaben","anker":"sec:unsicherheit-uebungsaufgaben","text":""},{"seite":"unsicherheit.html","titel":"12.9 Finde den Denkfehler","anker":"sec:unsicherheit-denkfehler","text":""},{"seite":"unsicherheit.html","titel":"12.10 Micro-Quiz","anker":"sec:unsicherheit-quiz","text":""},{"seite":"unsicherheit.html","titel":"12.11 Selbsttest","anker":"sec:unsicherheit-selbsttest","text":""},{"seite":"unsicherheit.html","titel":"12.12 Zusammenfassung","anker":"sec:unsicherheit-zusammenfassung","text":""},{"seite":"dynamische-programmierung.html","titel":"Kapitel 13: Dynamische Programmierung — Die Bellman-Gleichung und Order-Execution","anker":null,"text":""},{"seite":"dynamische-programmierung.html","titel":"13.1 In 5 Minuten gelöst","anker":"sec:dynamische-programmierung-schnellstart","text":""},{"seite":"dynamische-programmierung.html","titel":"13.2 Lernziele","anker":"sec:dynamische-programmierung-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"dynamische-programmierung.html","titel":"13.3 Das Bellmansche Optimalitätsprinzip","anker":"sec:dynamische-programmierung-das-bellmansche-optimalitaetsprinzip","text":"Richard Bellman formulierte 1957 das Grundprinzip der Dynamischen Programmierung (DP):"},{"seite":"dynamische-programmierung.html","titel":"Die vier Bausteine eines DP-Modells","anker":"die-vier-bausteine-eines-dp-modells","text":""},{"seite":"dynamische-programmierung.html","titel":"Die Bellman-Gleichung","anker":"die-bellman-gleichung","text":"V_t(S_t) = \\min_{a_t\\in\\mathcal{A}(S_t)}\\Big\\{\\,C(S_t,a_t) \\;+\\; \\gamma\\cdot\\mathbb{E}\\big[V_{t+1}(S_{t+1})\\mid S_t,a_t\\big]\\Big\\}"},{"seite":"dynamische-programmierung.html","titel":"13.4 Das Almgren-Chriss-Problem: optimale Orderausführung","anker":"sec:dynamische-programmierung-das-almgren-chriss-problem-optimale-orderausfuehrung","text":"Ein klassisches OR-Problem an Börsen: Ein institutioneller Händler muss X_0 = 100\\,000 Aktien innerhalb von T Handelsperioden verkaufen."},{"seite":"dynamische-programmierung.html","titel":"13.5 Der Fluch der Dimensionalität","anker":"sec:dynamische-programmierung-der-fluch-der-dimensionalitaet","text":"DP ist mächtig, aber es hat eine harte Grenze. Der Aufwand der Rückwärtsinduktion ist"},{"seite":"dynamische-programmierung.html","titel":"13.6 Übungsaufgaben","anker":"sec:dynamische-programmierung-uebungsaufgaben","text":""},{"seite":"dynamische-programmierung.html","titel":"13.7 Finde den Denkfehler","anker":"sec:dynamische-programmierung-denkfehler","text":"Bei dynamischer Programmierung entscheidet eine einzige Frage über Erfolg oder Misserfolg: Was gehört in den Zustand? Ist er zu klein, rechnet das Verfahren völlig korrekt — nur eb"},{"seite":"dynamische-programmierung.html","titel":"13.8 Micro-Quiz","anker":"sec:dynamische-programmierung-quiz","text":""},{"seite":"dynamische-programmierung.html","titel":"13.9 Selbsttest","anker":"sec:dynamische-programmierung-selbsttest","text":""},{"seite":"dynamische-programmierung.html","titel":"13.10 Zusammenfassung","anker":"sec:dynamische-programmierung-zusammenfassung","text":""},{"seite":"mehrziel.html","titel":"Kapitel 14: Mehrere Ziele — Pareto-Fronten statt Gewichte","anker":null,"text":""},{"seite":"mehrziel.html","titel":"14.1 In 5 Minuten gelöst","anker":"sec:mehrziel-schnellstart","text":""},{"seite":"mehrziel.html","titel":"14.2 Lernziele","anker":"sec:mehrziel-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"mehrziel.html","titel":"14.3 Wann ein Kompromiss gut ist","anker":"sec:mehrziel-dominanz","text":"Bei einem Ziel ist „besser” eindeutig. Bei zwei Zielen nicht mehr: Ein Plan kann billiger und schmutziger sein als ein anderer, und dann ist keiner von beiden besser."},{"seite":"mehrziel.html","titel":"14.4 Der Reflex: alles in ein Ziel rühren","anker":"sec:mehrziel-skalarisierung","text":"Der naheliegende Weg, zwei Ziele zu einem zu machen, ist die gewichtete Summe:"},{"seite":"mehrziel.html","titel":"14.5 Das Programm","anker":"sec:mehrziel-programm","text":"Zwölf Sendungen, drei Verkehrsträger, fünf freie Bahntrassen. Der Zielkonflikt entsteht nicht zwischen den Trägern — die Bahn ist immer billiger und sauberer als der LKW —, sondern"},{"seite":"mehrziel.html","titel":"14.6 Was die gewichtete Summe nicht sieht","anker":"sec:mehrziel-luecke","text":"Die Front hat zehn Punkte. Ein Raster aus 1 401 Gewichten findet sechs davon. Vier Kompromisse sind für kein Gewicht erreichbar — und das ist keine Frage der Rasterweite."},{"seite":"mehrziel.html","titel":"Das Gewicht ist keine Feineinstellung","anker":"das-gewicht-ist-keine-feineinstellung","text":"Die zweite Beobachtung aus derselben Tabelle: 1 401 Gewichte erzeugen sechs verschiedene Pläne. Weite Gewichtsbereiche liefern dasselbe Ergebnis, und dazwischen springt es."},{"seite":"mehrziel.html","titel":"14.7 Das ε-Constraint-Verfahren","anker":"sec:mehrziel-epsilon","text":"Der Ausweg ist unspektakulär: Man macht aus dem zweiten Ziel eine Nebenbedingung."},{"seite":"mehrziel.html","titel":"Wenn es eine klare Rangfolge gibt: lexikografisch","anker":"wenn-es-eine-klare-rangfolge-gibt-lexikografisch","text":"Manchmal ist ein Ziel wirklich vorrangig. Dann optimiert man lexikografisch: erst das erste Ziel, dann das zweite unter der Bedingung, dass das erste (fast) erhalten bleibt."},{"seite":"mehrziel.html","titel":"14.8 Wie man die Front vorlegt","anker":"sec:mehrziel-vorlegen","text":"Eine Front mit zehn Punkten ist kein Ergebnis, sondern eine Speisekarte. Damit sie entscheidbar wird, gehört eine Spalte dazu, die im Programm EUR je kg heißt:"},{"seite":"mehrziel.html","titel":"14.9 Übungsaufgaben","anker":"sec:mehrziel-uebungsaufgaben","text":""},{"seite":"mehrziel.html","titel":"14.10 Finde den Denkfehler","anker":"sec:mehrziel-denkfehler","text":""},{"seite":"mehrziel.html","titel":"14.11 Micro-Quiz","anker":"sec:mehrziel-quiz","text":""},{"seite":"mehrziel.html","titel":"14.12 Selbsttest","anker":"sec:mehrziel-selbsttest","text":""},{"seite":"mehrziel.html","titel":"14.13 Zusammenfassung","anker":"sec:mehrziel-zusammenfassung","text":""},{"seite":"prognose.html","titel":"Kapitel 15: Predict-then-Optimize — die bessere Prognose, die schlechtere Entscheidung","anker":null,"text":""},{"seite":"prognose.html","titel":"15.1 In 5 Minuten gelöst","anker":"sec:prognose-schnellstart","text":""},{"seite":"prognose.html","titel":"15.2 Lernziele","anker":"sec:prognose-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"prognose.html","titel":"15.3 Die Naht zwischen zwei Abteilungen","anker":"sec:prognose-naht","text":"In fast jedem Unternehmen sieht der Ablauf so aus:"},{"seite":"prognose.html","titel":"15.4 Das Programm","anker":"sec:prognose-programm","text":"Dieselbe Bäckerei, aber mit Merkmalen: Wochentag, Temperatur, Aktionstage. Die Nachfrage muss jetzt modelliert werden."},{"seite":"prognose.html","titel":"15.5 Der Befund","anker":"sec:prognose-befund","text":""},{"seite":"prognose.html","titel":"Warum ein pauschaler Zuschlag zu kurz greift","anker":"warum-ein-pauschaler-zuschlag-zu-kurz-greift","text":"Die zweite Zeile der Tabelle ist die verbreitete Praxis: Punktprognose plus Sicherheitsbestand. Sie hilft (−7,3 %), bleibt aber hinter der Quantilregression zurück. Das Programm re"},{"seite":"prognose.html","titel":"15.6 Eine Messfalle, in die der Autor selbst getappt ist","anker":"sec:prognose-messfalle","text":"Der erste Entwurf dieses Programms bewertete auf 230 Testtagen — ein realistischer Zeitraum, gut sieben Monate. Dort hatte die Quantilregression den besseren MSE, und die ganze Aus"},{"seite":"prognose.html","titel":"15.7 Wie weit das Verfahren reicht","anker":"sec:prognose-ausblick","text":"Die dritte Zeile der Ergebnistabelle — „Zuschlag auf Kosten trainiert” — ist die einfachste denkbare Form einer Idee, die derzeit erforscht wird: das Prognosemodell direkt auf die "},{"seite":"prognose.html","titel":"15.8 Übungsaufgaben","anker":"sec:prognose-uebungsaufgaben","text":""},{"seite":"prognose.html","titel":"15.9 Finde den Denkfehler","anker":"sec:prognose-denkfehler","text":""},{"seite":"prognose.html","titel":"15.10 Micro-Quiz","anker":"sec:prognose-quiz","text":""},{"seite":"prognose.html","titel":"15.11 Selbsttest","anker":"sec:prognose-selbsttest","text":""},{"seite":"prognose.html","titel":"15.12 Zusammenfassung","anker":"sec:prognose-zusammenfassung","text":""},{"seite":"bruecke.html","titel":"Kapitel 16: Die Strukturbrücke — dieselbe Mathematik, zwei Welten","anker":null,"text":""},{"seite":"bruecke.html","titel":"16.1 In 5 Minuten gelöst","anker":"sec:bruecke-schnellstart","text":""},{"seite":"bruecke.html","titel":"16.2 Lernziele","anker":"sec:bruecke-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"bruecke.html","titel":"16.3 Das Programm","anker":"sec:bruecke-programm","text":""},{"seite":"bruecke.html","titel":"16.4 Die zwei Brücken im Einzelnen","anker":"sec:bruecke-einzelnen","text":""},{"seite":"bruecke.html","titel":"Erste Brücke: Allokation","anker":"erste-brücke-allokation","text":""},{"seite":"bruecke.html","titel":"Zweite Brücke: Absicherung gegen den schlechtesten Fall","anker":"zweite-brücke-absicherung-gegen-den-schlechtesten-fall","text":"Hier ist die Übereinstimmung noch enger, weil sie nicht nur die Struktur betrifft, sondern die Formel. Die Rockafellar-Uryasev-Formulierung aus Kapitel 20"},{"seite":"bruecke.html","titel":"Wo die Lösungen sich unterscheiden","anker":"wo-die-lösungen-sich-unterscheiden","text":"Der Vergleich fördert nebenbei einen Unterschied zutage, den man nicht erwartet: Die Streuungsgrenze von 40 % bindet nur im Einkauf, nicht im Depot."},{"seite":"bruecke.html","titel":"16.5 Wo die Brücke endet","anker":"sec:bruecke-grenzen","text":"Eine Analogie ist nur so nützlich wie die Grenzen, die man ihr zieht. Drei Dinge wandern nicht mit:"},{"seite":"bruecke.html","titel":"16.6 Übungsaufgaben","anker":"sec:bruecke-uebungsaufgaben","text":""},{"seite":"bruecke.html","titel":"16.7 Finde den Denkfehler","anker":"sec:bruecke-denkfehler","text":""},{"seite":"bruecke.html","titel":"16.8 Micro-Quiz","anker":"sec:bruecke-quiz","text":""},{"seite":"bruecke.html","titel":"16.9 Selbsttest","anker":"sec:bruecke-selbsttest","text":""},{"seite":"bruecke.html","titel":"16.10 Zusammenfassung","anker":"sec:bruecke-zusammenfassung","text":""},{"seite":"supplychain.html","titel":"Kapitel 17: Supply-Chain und Energieeinsatz unter Unsicherheit","anker":null,"text":""},{"seite":"supplychain.html","titel":"17.1 In 5 Minuten gelöst","anker":"sec:supplychain-schnellstart","text":""},{"seite":"supplychain.html","titel":"17.2 Lernziele","anker":"sec:supplychain-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"supplychain.html","titel":"17.3 Die Aufgabe","anker":"sec:supplychain-aufgabe","text":"Fünf Blöcke, 24 Stunden, eine Last zwischen 270 und 804 MW. Dazu Windeinspeisung, die niemand am Vorabend kennt."},{"seite":"supplychain.html","titel":"Die Zeitstruktur der Entscheidung","anker":"die-zeitstruktur-der-entscheidung","text":"Hier geht das Kapitel über Abschnitt 12.5 hinaus:"},{"seite":"supplychain.html","titel":"17.4 Das Programm","anker":"sec:supplychain-programm","text":""},{"seite":"supplychain.html","titel":"17.5 Der Befund","anker":"sec:supplychain-befund","text":""},{"seite":"supplychain.html","titel":"17.6 Was Versorgungssicherheit kostet","anker":"sec:supplychain-sicherheit","text":"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"},{"seite":"supplychain.html","titel":"17.7 Übungsaufgaben","anker":"sec:supplychain-uebungsaufgaben","text":""},{"seite":"supplychain.html","titel":"17.8 Finde den Denkfehler","anker":"sec:supplychain-denkfehler","text":""},{"seite":"supplychain.html","titel":"17.9 Micro-Quiz","anker":"sec:supplychain-quiz","text":""},{"seite":"supplychain.html","titel":"17.10 Selbsttest","anker":"sec:supplychain-selbsttest","text":""},{"seite":"supplychain.html","titel":"17.11 Zusammenfassung","anker":"sec:supplychain-zusammenfassung","text":""},{"seite":"finanzdaten.html","titel":"Kapitel 18: Finanzdaten-Modellierung — Renditen, Kovarianz und Shrinkage","anker":null,"text":""},{"seite":"finanzdaten.html","titel":"18.1 In 5 Minuten gelöst","anker":"sec:finanzdaten-schnellstart","text":""},{"seite":"finanzdaten.html","titel":"18.2 Lernziele","anker":"sec:finanzdaten-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"finanzdaten.html","titel":"18.3 Diskrete und logarithmische Renditen","anker":"sec:finanzdaten-diskrete-und-logarithmische-renditen","text":"Sei P_{i,t} der bereinigte Schlusskurs (adjusted close) von Titel i zum Zeitpunkt t. „Bereinigt“ heißt: um Dividenden und Aktiensplits korrigiert — sonst erscheint jede Dividendenz"},{"seite":"finanzdaten.html","titel":"Diskrete Rendite","anker":"diskrete-rendite","text":"R_{i,t} = \\frac{P_{i,t} - P_{i,t-1}}{P_{i,t-1}} = \\frac{P_{i,t}}{P_{i,t-1}} - 1"},{"seite":"finanzdaten.html","titel":"Logarithmische Rendite","anker":"logarithmische-rendite","text":"r_{i,t} = \\ln\\!\\left(\\frac{P_{i,t}}{P_{i,t-1}}\\right) = \\ln P_{i,t} - \\ln P_{i,t-1}"},{"seite":"finanzdaten.html","titel":"18.4 Das Schätzfehler-Problem","anker":"sec:finanzdaten-das-schaetzfehler-problem","text":"Sei \\mathbf{X}\\in\\mathbb{R}^{T\\times N} die Matrix zentrierter Renditen von N Titeln über T Handelstage. Die Stichproben-Kovarianzmatrix lautet:"},{"seite":"finanzdaten.html","titel":"18.5 Ledoit-Wolf-Shrinkage","anker":"sec:finanzdaten-ledoit-wolf-shrinkage","text":"Olivier Ledoit und Michael Wolf lösten das Dilemma durch lineare Schrumpfung:"},{"seite":"finanzdaten.html","titel":"Welches Ziel \\mathbf{F}?","anker":"welches-ziel-mathbff","text":"Ein häufiges Missverständnis: Es gibt nämlich mehrere Ledoit-Wolf-Varianten, und Text und Code müssen sich konsistent auf dieselbe beziehen:"},{"seite":"finanzdaten.html","titel":"18.6 Praxis: Datenpipeline mit korrekter Spaltenreihenfolge","anker":"sec:finanzdaten-praxis-datenpipeline-mit-korrekter-spaltenreihenfolge","text":""},{"seite":"finanzdaten.html","titel":"18.7 Übungsaufgaben","anker":"sec:finanzdaten-uebungsaufgaben","text":""},{"seite":"finanzdaten.html","titel":"18.8 Finde den Denkfehler","anker":"sec:finanzdaten-denkfehler","text":""},{"seite":"finanzdaten.html","titel":"18.9 Micro-Quiz","anker":"sec:finanzdaten-quiz","text":""},{"seite":"finanzdaten.html","titel":"18.10 Selbsttest","anker":"sec:finanzdaten-selbsttest","text":""},{"seite":"finanzdaten.html","titel":"18.11 Zusammenfassung","anker":"sec:finanzdaten-zusammenfassung","text":""},{"seite":"markowitz.html","titel":"Kapitel 19: Die moderne Portfoliotheorie nach Markowitz","anker":null,"text":""},{"seite":"markowitz.html","titel":"19.1 In 5 Minuten gelöst","anker":"sec:markowitz-schnellstart","text":""},{"seite":"markowitz.html","titel":"19.2 Lernziele","anker":"sec:markowitz-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"markowitz.html","titel":"19.3 Warum Diversifikation funktioniert","anker":"sec:markowitz-warum-diversifikation-funktioniert","text":"Harry Markowitz’ zentrale Einsicht von 1952: Das Risiko eines Portfolios ist nicht die Summe der Einzelrisiken. Es hängt davon ab, wie die Titel zusammenspielen."},{"seite":"markowitz.html","titel":"19.4 Das Mean-Variance-Modell","anker":"sec:markowitz-das-mean-variance-modell","text":"Sei \\mathbf{w} = (w_1,\\dots,w_n)^\\top der Vektor der Portfoliogewichte:"},{"seite":"markowitz.html","titel":"Die Effizienzgrenze und das Tangentialportfolio","anker":"die-effizienzgrenze-und-das-tangentialportfolio","text":"Die Effizienzgrenze (efficient frontier) ist die Kurve aller Portfolios, die zu jedem Risikoniveau die höchstmögliche Rendite liefern."},{"seite":"markowitz.html","titel":"Die Korn-Transformation","anker":"die-korn-transformation","text":"Die Sharpe Ratio ist ein Bruch — und damit nicht konvex. CVXPY würde sie ablehnen. Der Ausweg ist ein eleganter Trick, der die Homogenität des Problems ausnutzt:"},{"seite":"markowitz.html","titel":"19.5 Institutionelle Nebenbedingungen","anker":"sec:markowitz-institutionelle-nebenbedingungen","text":"In der Praxis gelten regulatorische und interne Regeln:"},{"seite":"markowitz.html","titel":"Was diese Regeln kosten","anker":"was-diese-regeln-kosten","text":"Jede Regel schneidet etwas vom Erreichbaren ab. Wie viel, lässt sich ausrechnen — man legt die Effizienzlinie ohne und mit Beschränkungen übereinander:"},{"seite":"markowitz.html","titel":"19.6 Vollimplementierung mit CVXPY","anker":"sec:markowitz-vollimplementierung-mit-cvxpy","text":""},{"seite":"markowitz.html","titel":"19.7 Übungsaufgaben","anker":"sec:markowitz-uebungsaufgaben","text":""},{"seite":"markowitz.html","titel":"19.8 Finde den Denkfehler","anker":"sec:markowitz-denkfehler","text":"Kapitel 18 hat gezeigt, was eine schlecht geschätzte Kovarianzmatrix anrichtet. Bei den erwarteten Renditen ist es schlimmer — und weil sie in der Zielfunktion stehen statt in der "},{"seite":"markowitz.html","titel":"19.9 Micro-Quiz","anker":"sec:markowitz-quiz","text":""},{"seite":"markowitz.html","titel":"19.10 Selbsttest","anker":"sec:markowitz-selbsttest","text":""},{"seite":"markowitz.html","titel":"19.11 Zusammenfassung","anker":"sec:markowitz-zusammenfassung","text":""},{"seite":"cvar.html","titel":"Kapitel 20: Tail-Risiko, CVaR und Transaktionskosten","anker":null,"text":""},{"seite":"cvar.html","titel":"20.1 In 5 Minuten gelöst","anker":"sec:cvar-schnellstart","text":""},{"seite":"cvar.html","titel":"20.2 Lernziele","anker":"sec:cvar-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"cvar.html","titel":"20.3 Die zwei Schwächen des Markowitz-Modells","anker":"sec:cvar-die-zwei-schwaechen-des-markowitz-modells","text":"Erstens: die Normalverteilungs-Illusion. Die Varianz behandelt Aufwärts- und Abwärtsschwankungen gleich und unterstellt implizit symmetrische, dünn auslaufende Verteilungen. Reale "},{"seite":"cvar.html","titel":"20.4 Value at Risk und Conditional Value at Risk","anker":"sec:cvar-value-at-risk-und-conditional-value","text":""},{"seite":"cvar.html","titel":"Das Rockafellar-Uryasev-Theorem (2000)","anker":"das-rockafellar-uryasev-theorem-2000","text":"Rockafellar und Uryasev zeigten, dass sich der CVaR über S Szenarien exakt als lineares Programm formulieren lässt:"},{"seite":"cvar.html","titel":"20.5 Transaktionskosten über die L_1-Norm","anker":"sec:cvar-transaktionskosten-ueber-die-l-1-norm","text":"Sei \\mathbf{w}_{\\text{alt}} das bestehende Portfolio und \\mathbf{w} das neue Ziel. Der Umschlag (turnover) ist:"},{"seite":"cvar.html","titel":"20.6 Implementierung: CVaR-Portfolio mit Reibung","anker":"sec:cvar-implementierung-cvar-portfolio-mit-reibung","text":""},{"seite":"cvar.html","titel":"20.7 Übungsaufgaben","anker":"sec:cvar-uebungsaufgaben","text":""},{"seite":"cvar.html","titel":"20.8 Finde den Denkfehler","anker":"sec:cvar-denkfehler","text":""},{"seite":"cvar.html","titel":"20.9 Micro-Quiz","anker":"sec:cvar-quiz","text":""},{"seite":"cvar.html","titel":"20.10 Selbsttest","anker":"sec:cvar-selbsttest","text":""},{"seite":"cvar.html","titel":"20.11 Zusammenfassung","anker":"sec:cvar-zusammenfassung","text":""},{"seite":"handelsmaschine.html","titel":"Kapitel 21: Die vollständige quantitative Handelsmaschine","anker":null,"text":""},{"seite":"handelsmaschine.html","titel":"21.1 In 5 Minuten gelöst","anker":"sec:handelsmaschine-schnellstart","text":""},{"seite":"handelsmaschine.html","titel":"21.2 Lernziele","anker":"sec:handelsmaschine-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"handelsmaschine.html","titel":"21.3 Die Architektur","anker":"sec:handelsmaschine-die-architektur","text":""},{"seite":"handelsmaschine.html","titel":"21.4 Rebalancing-Termine richtig bestimmen","anker":"sec:handelsmaschine-rebalancing-termine-richtig-bestimmen","text":""},{"seite":"handelsmaschine.html","titel":"21.5 Die Engine","anker":"sec:handelsmaschine-die-engine","text":""},{"seite":"handelsmaschine.html","titel":"21.6 Die fünf Selbsttäuschungen des Backtestens","anker":"sec:handelsmaschine-die-fuenf-selbsttaeuschungen-des-backtestens","text":""},{"seite":"handelsmaschine.html","titel":"21.7 Vom Backtest zum Betrieb","anker":"sec:handelsmaschine-vom-backtest-zum-betrieb","text":"Ein guter Backtest ist eine notwendige, keine hinreichende Bedingung. Was zwischen Backtest und echtem Einsatz liegt:"},{"seite":"handelsmaschine.html","titel":"21.8 Übungsaufgaben","anker":"sec:handelsmaschine-uebungsaufgaben","text":""},{"seite":"handelsmaschine.html","titel":"21.9 Finde den Denkfehler","anker":"sec:handelsmaschine-denkfehler","text":"Backtest_Fallen.py zeigt drei Fehler, die im Backtest stecken: Lookahead, Survivorship, vergessene Kosten. Alle drei kann man einem Programm ansehen, wenn man es liest."},{"seite":"handelsmaschine.html","titel":"21.10 Micro-Quiz","anker":"sec:handelsmaschine-quiz","text":""},{"seite":"handelsmaschine.html","titel":"21.11 Selbsttest","anker":"sec:handelsmaschine-selbsttest","text":""},{"seite":"handelsmaschine.html","titel":"21.12 Zusammenfassung","anker":"sec:handelsmaschine-zusammenfassung","text":""},{"seite":"praxisfallen.html","titel":"Kapitel 22: Praxisfallen und der Weg zum produktiven Einsatz","anker":null,"text":""},{"seite":"praxisfallen.html","titel":"22.1 In 5 Minuten gelöst","anker":"sec:praxisfallen-schnellstart","text":"Die vorangegangenen vierzehn Kapitel enden fast alle mit derselben Empfehlung: Prüfen Sie das Ergebnis gegen die Wirklichkeit. Dieses Kapitel beginnt damit, diese Prüfung als wiede"},{"seite":"praxisfallen.html","titel":"22.2 Lernziele","anker":"sec:praxisfallen-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"praxisfallen.html","titel":"22.3 Die fünf typischen Praxisfallen","anker":"sec:praxisfallen-die-fuenf-typischen-praxisfallen","text":""},{"seite":"praxisfallen.html","titel":"Falle 1 — Infeasibility","anker":"falle-1-infeasibility","text":"Wenn Parameter unglücklich zusammentreffen — drei Mitarbeitende gleichzeitig krank, kein qualifizierter Ersatz —, meldet der Solver schlicht INFEASIBLE."},{"seite":"praxisfallen.html","titel":"Falle 2 — Der Black-Box-Effekt","anker":"falle-2-der-black-box-effekt","text":"Ein Planer oder Trader lehnt ein System ab, wenn er die Zuweisungen nicht nachvollziehen kann. Akzeptanz ist keine Nebensache, sondern Voraussetzung für den Betrieb."},{"seite":"praxisfallen.html","titel":"Falle 3 — Regimewechsel und Schätzfehler","anker":"falle-3-regimewechsel-und-schätzfehler","text":"Ein Modell, das auf einer mehrjährigen Aufwärtsphase kalibriert wurde, kennt keine Liquiditätskrise und keine Zinswende."},{"seite":"praxisfallen.html","titel":"Falle 4 — Lookahead- und Survivorship-Bias","anker":"falle-4-lookahead--und-survivorship-bias","text":"Ausführlich in Kapitel 21 behandelt. Die Kurzfassung: Beide Fehler machen Backtests systematisch zu gut, ohne eine Fehlermeldung zu erzeugen. Der Lookahead-Selbsttest aus Abschnitt"},{"seite":"praxisfallen.html","titel":"Falle 5 — Laufzeitexplosion","anker":"falle-5-laufzeitexplosion","text":"Exakte Optimalität kann bei MILP oder CP-SAT Minuten bis Stunden dauern — und die Laufzeit wächst nicht linear mit der Problemgröße."},{"seite":"praxisfallen.html","titel":"22.4 Constraint Attribution: welche Bedingung kostet wie viel?","anker":"sec:praxisfallen-attribution","text":"Der Constraint-Trace beantwortet „warum diese Zuweisung?“. Der Deletion Filter in Anhang C beantwortet „warum geht es gar nicht?“. Der häufigste Fall in der Praxis liegt dazwischen"},{"seite":"praxisfallen.html","titel":"Drei Befunde, und keiner steht im Solverergebnis","anker":"drei-befunde-und-keiner-steht-im-solverergebnis","text":"Erstens: bindend heißt nicht teuer. Fünf Bedingungen binden, aber „Lackierbudget Schicht 2“ tut es mit einem Schattenpreis von 0,00 €. Sie berührt den Plan, ohne ihn zu verteuern —"},{"seite":"praxisfallen.html","titel":"Vom Zahlenwerk zur Vorlage","anker":"vom-zahlenwerk-zur-vorlage","text":"Alle drei Antworten stehen bis hierhin in Tabellen. Tabellen liest niemand in einer Abteilungsleiterrunde. Teil (4) des Programms baut aus denselben Zahlen einen Text — und das ist"},{"seite":"praxisfallen.html","titel":"22.5 Architektur einer produktionsreifen OR-Plattform","anker":"sec:praxisfallen-architektur-einer-produktionsreifen-or-plattform","text":""},{"seite":"praxisfallen.html","titel":"22.6 Der gemeinsame Unterbau: or_kern.py","anker":"sec:praxisfallen-or-kern","text":"Die fünf Prinzipien oben beschreiben eine Plattform. Der Weg dorthin beginnt aber viel kleiner — mit der Frage, welche Teile eines Optimierungsprogramms immer dieselben sind."},{"seite":"praxisfallen.html","titel":"Die vier Schichten","anker":"die-vier-schichten","text":"\\underbrace{\\text{Rohdaten}}_{\\text{Excel, CSV, ERP}}\n\\;\\longrightarrow\\;\n\\underbrace{\\text{Domänenmodell}}_{\\text{geprüft, solverfrei}}\n\\;\\longrightarrow\\;\n\\underbrace{\\text{Model"},{"seite":"praxisfallen.html","titel":"Ein Enum für fünf Bibliotheken","anker":"ein-enum-für-fünf-bibliotheken","text":"Der unscheinbarste, aber wirksamste Teil ist die Statusübersetzung. Dieselbe Aussage heißt in den fünf im Buch verwendeten Bibliotheken:"},{"seite":"praxisfallen.html","titel":"22.7 Der Solverwechsel in der Praxis","anker":"sec:praxisfallen-solverwechsel","text":"Der vorige Abschnitt endet mit einer Behauptung: Ein Solverwechsel betreffe genau einen Baustein. Das ist die Art Satz, die in jedem Architekturvortrag vorkommt und die man deshalb"},{"seite":"praxisfallen.html","titel":"22.8 Eine Checkliste vor dem Produktivgang","anker":"sec:praxisfallen-eine-checkliste-vor-dem-produktivgang","text":"Arbeiten Sie diese Liste ab, bevor ein OR-System in Betrieb geht:"},{"seite":"praxisfallen.html","titel":"22.9 Weiterführende Literatur und Roadmap","anker":"sec:praxisfallen-weiterfuehrende-literatur-und-roadmap","text":"Grundlagen und lineare/ganzzahlige Optimierung * Bertsimas & Tsitsiklis: Introduction to Linear Optimization — Standardwerk zu Simplex, Dualität, Polyedertheorie. * Wolsey: Int"},{"seite":"praxisfallen.html","titel":"22.10 Übungsaufgaben","anker":"sec:praxisfallen-uebungsaufgaben","text":""},{"seite":"praxisfallen.html","titel":"22.11 Finde den Denkfehler","anker":"sec:praxisfallen-denkfehler","text":"Falle 5 — die Laufzeitexplosion — zeigt sich im Betrieb selten als Absturz. Sie zeigt sich als gar nichts."},{"seite":"praxisfallen.html","titel":"22.12 Micro-Quiz","anker":"sec:praxisfallen-quiz","text":""},{"seite":"praxisfallen.html","titel":"22.13 Selbsttest","anker":"sec:praxisfallen-selbsttest","text":""},{"seite":"praxisfallen.html","titel":"22.14 Zusammenfassung","anker":"sec:praxisfallen-zusammenfassung","text":""},{"seite":"testing.html","titel":"Kapitel 23: Testen, Messen, Ausliefern","anker":null,"text":""},{"seite":"testing.html","titel":"23.1 In 5 Minuten gelöst","anker":"sec:testing-schnellstart","text":""},{"seite":"testing.html","titel":"23.2 Lernziele","anker":"sec:testing-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"testing.html","titel":"23.3 Warum Optimierungsmodelle schwer zu testen sind","anker":"sec:testing-warum-schwer","text":"Bei einer gewöhnlichen Funktion schreibt man den erwarteten Wert hin:"},{"seite":"testing.html","titel":"23.4 Die Testsuite","anker":"sec:testing-suite","text":""},{"seite":"testing.html","titel":"23.5 Wer testet die Tests?","anker":"sec:testing-mutation","text":"33 grüne Tests. Das ist noch kein Ergebnis."},{"seite":"testing.html","titel":"Der erste Lauf fand zwei Lücken","anker":"der-erste-lauf-fand-zwei-lücken","text":"Die Suite hatte zunächst 31 Tests. Der Mutationstest sagte dazu:"},{"seite":"testing.html","titel":"23.6 Ein Vergleich, dem man glauben kann","anker":"sec:testing-benchmark","text":"Solververgleiche stehen in jedem Blog, und die meisten sind wertlos. Nicht, weil falsch gemessen wurde, sondern weil zu wenig dazugesagt wird."},{"seite":"testing.html","titel":"Die interessanteste Spalte heißt „Anteil”","anker":"die-interessanteste-spalte-heißt-anteil","text":"Sie sagt, wie viel der Zeit in den Aufbau des Modells geht statt ins Lösen — und die Zahlen sind unbequem:"},{"seite":"testing.html","titel":"23.7 Das Modell als Dienst","anker":"sec:testing-dienst","text":"Ein Modell hinter eine HTTP-Schnittstelle zu hängen sieht nach einer Fingerübung aus. Es gibt aber einen Unterschied, der die ganze Bauform bestimmt:"},{"seite":"testing.html","titel":"Threads oder Prozesse? Eine Messung, keine Meinung","anker":"threads-oder-prozesse-eine-messung-keine-meinung","text":"Punkt 6 des Selbsttests misst, ob vier Solverläufe in Threads sich überlappen. Das Ergebnis ist ein Faktor von rund 3,5 bei vier Threads: OR-Tools rechnet in C++ und gibt den GIL w"},{"seite":"testing.html","titel":"Was im echten Betrieb dazukommt","anker":"was-im-echten-betrieb-dazukommt","text":"Das Beispiel hält das Auftragsbuch in einem dict und rechnet in Threads desselben Prozesses. Für einen einzelnen Rechner reicht das; darüber hinaus nicht:"},{"seite":"testing.html","titel":"23.8 Übungsaufgaben","anker":"sec:testing-uebungsaufgaben","text":""},{"seite":"testing.html","titel":"23.9 Finde den Denkfehler","anker":"sec:testing-denkfehler","text":""},{"seite":"testing.html","titel":"23.10 Micro-Quiz","anker":"sec:testing-quiz","text":""},{"seite":"testing.html","titel":"23.11 Selbsttest","anker":"sec:testing-selbsttest","text":""},{"seite":"testing.html","titel":"23.12 Zusammenfassung","anker":"sec:testing-zusammenfassung","text":""},{"seite":"projektwerkstatt.html","titel":"Projektwerkstatt","anker":null,"text":""},{"seite":"projektwerkstatt.html","titel":"Wie Sie ein Projekt bearbeiten","anker":"wie-sie-ein-projekt-bearbeiten","text":"Halten Sie sich an diese Reihenfolge — sie ist aus vielen gescheiterten und einigen gelungenen Projekten destilliert:"},{"seite":"projektwerkstatt.html","titel":"P1 — Vertretungsplaner für eine Schule","anker":"p1-vertretungsplaner-für-eine-schule","text":""},{"seite":"projektwerkstatt.html","titel":"P2 — Schichtplanung für ein Pflegeteam","anker":"p2-schichtplanung-für-ein-pflegeteam","text":""},{"seite":"projektwerkstatt.html","titel":"P3 — Tourenplanung für einen Lieferdienst","anker":"p3-tourenplanung-für-einen-lieferdienst","text":""},{"seite":"projektwerkstatt.html","titel":"P4 — Standort- und Lagernetzplanung","anker":"p4-standort--und-lagernetzplanung","text":""},{"seite":"projektwerkstatt.html","titel":"P5 — Produktionsplanung mit Rüstkosten","anker":"p5-produktionsplanung-mit-rüstkosten","text":""},{"seite":"projektwerkstatt.html","titel":"P6 — Portfolio-Rebalancer für ein Privatdepot","anker":"p6-portfolio-rebalancer-für-ein-privatdepot","text":""},{"seite":"projektwerkstatt.html","titel":"P7 — Risikoreport mit CVaR und Stresstests","anker":"p7-risikoreport-mit-cvar-und-stresstests","text":""},{"seite":"projektwerkstatt.html","titel":"P9 — Wenn der Solver aussteigt: Tourenplanung in Echtgröße","anker":"p9-wenn-der-solver-aussteigt-tourenplanung-in-echtgröße","text":""},{"seite":"projektwerkstatt.html","titel":"P10 — Zwei Ziele, eine Entscheidung: Kosten gegen CO₂","anker":"p10-zwei-ziele-eine-entscheidung-kosten-gegen-co₂","text":""},{"seite":"projektwerkstatt.html","titel":"P11 — Vom Skript zum Dienst: das Modell übergeben","anker":"p11-vom-skript-zum-dienst-das-modell-übergeben","text":""},{"seite":"projektwerkstatt.html","titel":"P8 — Freies Projekt aus Ihrem eigenen Umfeld","anker":"p8-freies-projekt-aus-ihrem-eigenen-umfeld","text":""},{"seite":"projektwerkstatt.html","titel":"Eine Bitte zum Schluss","anker":"eine-bitte-zum-schluss","text":"Wenn Sie ein Projekt fertig haben, machen Sie zwei Dinge:"},{"seite":"anhang-loesungen.html","titel":"Anhang A: Lösungen zu allen Übungsaufgaben","anker":null,"text":""},{"seite":"anhang-loesungen.html","titel":"A.1 Lösungen zu Kapitel „Einführung in Operations Research — Vom Ursprung zur mathematischen Entscheidungsfindung“","anker":"sec:loesungen-einfuehrung","text":"1.1 — Analytik-Stufen. (a) deskriptiv (Vergangenheit beschreiben). (b) prädiktiv (Prognose). (c) präskriptiv (Handlungsanweisung unter Restriktionen). (d) prädiktiv (Klassifikation"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Die Schreinerei verdoppelt ihren Gewinn","anker":"finde-den-denkfehler-die-schreinerei-verdoppelt-ihren-gewinn","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-einfuehrung","text":"1 — (c) präskriptiv. Die Frage lautet „was tun“, und es gibt eine Restriktion (das Restbudget). Dass man für die Bewertung eine Prognose braucht, macht die Frage nicht prädiktiv — "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-einfuehrung","text":""},{"seite":"anhang-loesungen.html","titel":"A.2 Lösungen zu Kapitel „Das mathematische Fundament — Vektoren, Matrizen, Konvexität“","anker":"sec:loesungen-fundament","text":"2.1 — Matrixform lesen. \\max 4x_1 + x_2 + 6x_3 u. d. N. x_1 + 2x_2 \\le 10, x_2 + 3x_3 \\le 12, x \\ge 0. 3 Variablen, 2 Nebenbedingungen (plus Nichtnegativität)."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Die unauffällige Transposition","anker":"finde-den-denkfehler-die-unauffällige-transposition","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-fundament","text":"1 — (b). Der Fundamentalsatz sagt nur, dass ein Optimum in einer Ecke angenommen wird. (a) ist falsch, weil es mehrere optimale Ecken geben kann (wenn die Zielfunktion parallel zu "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-fundament","text":""},{"seite":"anhang-loesungen.html","titel":"A.3 Lösungen zu Kapitel „Das Python-Ökosystem für OR — Solver, Bindings und Modellierungsschichten“","anker":"sec:loesungen-oekosystem","text":"3.1 — Solverwahl. (a) CP-SAT (diskrete Zuordnung mit Zeitfenstern). (b) scipy.optimize.linprog (klassisches Mischungs-LP, klein). (c) CVXPY (konvexes QP). (d) MIQP — CVXPY mit Binä"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Der Solver, der angeblich dreimal schneller ist","anker":"finde-den-denkfehler-der-solver-der-angeblich-dreimal-schneller-ist","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-oekosystem","text":"1 — (b) CP-SAT. Es geht um diskrete Zuweisung mit logischen Regeln („nicht gleichzeitig“) — CP-SATs Kerngebiet, und Regeln dieser Art formuliert man dort direkt statt über Big-M-Um"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-oekosystem","text":""},{"seite":"anhang-loesungen.html","titel":"A.4 Lösungen zu Kapitel „Vom Management-Wunsch zum Modell“","anker":"sec:loesungen-modellierung","text":"4.1 — Die fehlende Lesart. Eine dritte Lesart: „Der Umsatzanteil der Stammkunden darf nicht unter 30 % fallen.” Sie zielt weder auf einzelne Aufträge noch auf einzelne Kunden, sond"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Die Engpassmaschine soll zu mindestens 92 % ausgelastet sein“","anker":"denkfehler-loesung-modellierung","text":"Fehler 1: Auslastung ist ein Ergebnis, keine Anforderung."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-modellierung","text":"1. b) Die Zielfunktion ist eine vollständige Aussage darüber, was zählt. Was nicht darin steht, ist für das Modell nicht „weniger wichtig”, sondern wertlos. (a) und (c) sind frei e"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-modellierung","text":""},{"seite":"anhang-loesungen.html","titel":"A.5 Lösungen zu Kapitel „Lineare Programmierung — Simplex, Dualität und Schattenpreise“","anker":"sec:loesungen-lp","text":"5.1 — Schlupf deuten. (a) Ressourcen 1 und 3 (Schlupf 0). (b) Ja: Überall ist s_i \\cdot y_i = 0. (c) In Ressource 3 — der höchste Schattenpreis (9,8) bedeutet den größten Grenznutz"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Die 380 000-Euro-Maschine","anker":"finde-den-denkfehler-die-380-000-euro-maschine","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-lp","text":"1 — (b). Genau der Satz vom komplementären Schlupf: s_i \\cdot y_i = 0. Ist Schlupf vorhanden, muss der Dualwert null sein. (a) hat die Aussage in ihr Gegenteil verkehrt; (c) verwec"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-lp","text":""},{"seite":"anhang-loesungen.html","titel":"A.6 Lösungen zu Kapitel „Gemischt-ganzzahlige Optimierung — Diskrete Entscheidungen und Branch-and-Bound“","anker":"sec:loesungen-milp","text":"6.1 — Runden widerlegen. Beispiel: \\max x_1 + x_2 u. d. N. 10x_1 + 10x_2 \\le 15, ganzzahlig. LP-Optimum Z = 1{,}5; Abrunden ergibt (0,0) mit Z = 0 — 100 % Verlust. Das ganzzahlige "},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Elf Lager, die keine Fixkosten kosten","anker":"finde-den-denkfehler-elf-lager-die-keine-fixkosten-kosten","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-milp","text":"1 — (b). Der Gap (48\\,200 - 47\\,100)/48\\,200 = 2{,}3\\,\\% ist eine Garantie, keine Schätzung: Besser als 47 100 € kann es nachweislich nicht werden, also ist der vorliegende Plan hö"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-milp","text":""},{"seite":"anhang-loesungen.html","titel":"A.7 Lösungen zu Kapitel „Constraint Programming mit CP-SAT — Logik, Scheduling und Zuweisung“","anker":"sec:loesungen-cpsat","text":"7.1 — Propagation. Aus x_1 + x_2 = 8 und x_1 < x_2 folgt x_1 < 4, also x_1 \\in \\{2,3\\} (denn x_2 = 8-x_1 \\le 6 verlangt x_1 \\ge 2) und entsprechend x_2 \\in \\{5,6\\}. Aus 36 Ko"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Die Betriebsvereinbarung, die niemanden interessiert","anker":"finde-den-denkfehler-die-betriebsvereinbarung-die-niemanden-interessiert","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-cpsat","text":"1 — (b) wirkungslos. INFEASIBLE ist kein Abbruch, sondern ein Beweis: CP-SAT hat gezeigt, dass keine zulässige Lösung existiert. Mehr Zeit (a) oder mehr Arbeiter (c) ändern daran n"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-cpsat","text":""},{"seite":"anhang-loesungen.html","titel":"A.8 Lösungen zu Kapitel „Graphen, Flüsse und Touren — Min-Cost-Flow, Matching und VRP“","anker":"sec:loesungen-graphen","text":"8.1 — Flusserhaltung. b_i = (15+3) - (12+8) = -2 → Senke (Nettobedarf 2)."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Die vergessene Dimension","anker":"finde-den-denkfehler-die-vergessene-dimension","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-graphen","text":"1 — (b) totale Unimodularität. Jede quadratische Teilmatrix hat Determinante 0, +1 oder -1; bei ganzzahliger rechter Seite sind deshalb alle Ecken des zulässigen Bereichs ganzzahli"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-graphen","text":""},{"seite":"anhang-loesungen.html","titel":"A.9 Lösungen zu Kapitel „Metaheuristiken — wenn der exakte Solver aussteigt“","anker":"sec:loesungen-metaheuristiken","text":"9.1 — Die Kurzsichtigkeit von Hand. Ab Schwarz wählt die Faustregel: Schwarz → Dunkelrot (26) → Rot (5) → … und muss dann in die helle Gruppe: Rot → Weiß (50) → Elfenbein (2) → Bei"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Unsere Heuristik ist 6 % besser“","anker":"denkfehler-loesung-metaheuristiken","text":"Es fehlt die Schranke."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-metaheuristiken","text":"1. b) Eine lokale Suche lebt von der Zahl geprüfter Züge. Die volle Summe kostet O(n) statt O(1) — bei 500 Aufträgen zwei Größenordnungen weniger Züge im selben Zeitbudget. Numeris"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-metaheuristiken","text":""},{"seite":"anhang-loesungen.html","titel":"A.10 Lösungen zu Kapitel „Spaltengenerierung“","anker":"sec:loesungen-dekomposition","text":"10.1 — Das Abbruchkriterium. Die Eins ist der Zielfunktionskoeffizient eines Musters: Jedes geschnittene Muster verbraucht genau eine Mutterrolle, und die Zielfunktion lautet \\min "},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Die LP-Lösung sagt 72,92 — also runden wir auf“","anker":"denkfehler-loesung-dekomposition","text":"Das Aufrunden kostet sechs Rollen — acht Prozent."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-dekomposition","text":"1. b) Das Mustermodell kennt keine einzelnen Rollen und damit keine austauschbaren Objekte — die Symmetrie verschwindet. (a) trifft zufällig auch zu, ist aber nicht der Grund; (c) "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-dekomposition","text":""},{"seite":"anhang-loesungen.html","titel":"A.11 Lösungen zu Kapitel „Quadratische und nichtlineare Optimierung — KKT, Lagrange, Konvexität“","anker":"sec:loesungen-qp-nlp","text":"11.1 — Konvexität einordnen. PSD (alle \\ge 0) → konvex, aber nicht streng konvex; die Lösung ist nicht notwendig eindeutig. Der Eigenwert 0 bedeutet eine flache Richtung: Entlang d"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Die Kovarianzmatrix aus dem Controlling","anker":"finde-den-denkfehler-die-kovarianzmatrix-aus-dem-controlling","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-qp-nlp","text":"1 — (b). Der DCPError ist eine inhaltliche Aussage: Für diese Formulierung kann es keine Optimalitätsgarantie geben. Auf scipy auszuweichen (a) beseitigt die Meldung, nicht die Urs"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-qp-nlp","text":""},{"seite":"anhang-loesungen.html","titel":"A.12 Lösungen zu Kapitel „Optimierung unter Unsicherheit — Monte-Carlo, Stochastik, Robustheit“","anker":"sec:loesungen-unsicherheit","text":"12.1 — Fluch des Durchschnitts. Weil sich Verzögerungen fortpflanzen und die Verteilung rechtsschief ist: Ein Gewerk, das früher fertig wird, beschleunigt selten den Gesamtablauf ("},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Warum jedes Projekt zu spät fertig wird","anker":"finde-den-denkfehler-warum-jedes-projekt-zu-spät-fertig-wird","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-unsicherheit","text":"1 — (b). Das kritische Verhältnis 1400/(1400+120) = 0{,}921 gibt an, wie weit man sich auf die günstigere Fehlerseite stellen soll: Bestellt wird das 92,1-%-Quantil des Bedarfs, hi"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-unsicherheit","text":""},{"seite":"anhang-loesungen.html","titel":"A.13 Lösungen zu Kapitel „Dynamische Programmierung — Die Bellman-Gleichung und Order-Execution“","anker":"sec:loesungen-dynamische-programmierung","text":"13.1 — Bausteine. Stufe = Tag 1–5; Zustand = verbleibende Distanz (ggf. plus Erschöpfungsgrad); Aktion = heutige Tagesetappe; Wertfunktion = minimale Restanstrengung. Nicht in den "},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Der Zustand, der zu wenig weiß","anker":"finde-den-denkfehler-der-zustand-der-zu-wenig-weiß","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-dynamische-programmierung","text":"1 — (b). V_t setzt V_{t+1} voraus: Eine Entscheidung lässt sich erst bewerten, wenn feststeht, was sie für die Zukunft bedeutet. Am Ende ist dieser Wert bekannt (V_T = g), deshalb "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-dynamische-programmierung","text":""},{"seite":"anhang-loesungen.html","titel":"A.14 Lösungen zu Kapitel „Mehrere Ziele — Pareto-Fronten statt Gewichte“","anker":"sec:loesungen-mehrziel","text":"14.1 — Dominanz prüfen. P (14 000 €, 6 000 kg) und Q (13 800 €, 6 100 kg): keiner dominiert den anderen. Q ist billiger, P ist sauberer — beide sind pareto-optimal, die Wahl ist ei"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Wir haben die Gewichte sauber kalibriert“","anker":"denkfehler-loesung-mehrziel","text":"Der Lösungsraum wurde nicht vollständig abgetastet — und zwar aus prinzipiellen Gründen."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-mehrziel","text":"1. b) Geometrisch heißt „gewichtete Summe minimieren“: eine Gerade von links unten an die Punktwolke schieben. Sie berührt immer einen Eckpunkt der unteren konvexen Hülle; Punkte d"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-mehrziel","text":""},{"seite":"anhang-loesungen.html","titel":"A.15 Lösungen zu Kapitel „Predict-then-Optimize“","anker":"sec:loesungen-prognose","text":"15.1 — Andere Preise. Der Überhang kostet nur noch 1,50 €, die Fehlmenge weiterhin 6 €. Das kritische Verhältnis steigt von 6/9 = 0{,}667 auf 6/7{,}5 = 0{,}80. Die Bestellmenge ver"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Wir haben die Prognose um 18 % verbessert“","anker":"denkfehler-loesung-prognose","text":"Der Sicherheitszuschlag stammt aus den Residuen des alten Modells."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-prognose","text":"1. b) Die Kostenasymmetrie verschiebt das Optimum vom Erwartungswert zum kritischen Quantil. (a) und (c) sind reale Probleme, aber nicht dieses: Selbst bei perfekt bekannter, norma"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-prognose","text":""},{"seite":"anhang-loesungen.html","titel":"A.16 Lösungen zu Kapitel „Die Strukturbrücke — dieselbe Mathematik, zwei Welten“","anker":"sec:loesungen-bruecke","text":"16.1 — Die dritte Ressource. „Liquidität” — wie schnell sich eine Position ohne Kursabschlag verkaufen lässt — entspricht in der Werkstatt der Umrüstbarkeit oder der Vorlaufzeit: W"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Das ist doch dasselbe Problem“","anker":"denkfehler-loesung-bruecke","text":"Fehler 1: Varianz ist hier das falsche Risikomaß — genau der Fall, für den es CVaR gibt."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-bruecke","text":"1. b) Beide Probleme haben dieselbe Struktur: knappe Größen auf konkurrierende Verwendungen verteilen. (a) ist falsch — Produktionsproblem ist streng typisiert und validiert beim E"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-bruecke","text":""},{"seite":"anhang-loesungen.html","titel":"A.17 Lösungen zu Kapitel „Supply-Chain und Energieeinsatz unter Unsicherheit“","anker":"sec:loesungen-supplychain","text":"17.1 — Der Umschlagpunkt. Bei 60 MW statt 120 MW halbiert sich die Grenzkostendifferenz je Stunde (83 €/MWh × 60 MW = 4 980 €/h). Der Umschlagpunkt verdoppelt sich auf 7,7 Stunden."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Wir rechnen mit dem P50-Szenario“","anker":"denkfehler-loesung-supplychain","text":"Fehler 1: Der Median ist keine Absicherung, sondern eine Wette auf die Hälfte der Fälle."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-supplychain","text":"1. b) Anfahrkosten und Mindestlaufzeiten koppeln die Stunden. Die Merit-Order ist eine Sortierung und kennt keine Kopplung über die Zeit; sie ist für einen einzelnen Zeitpunkt rich"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-supplychain","text":""},{"seite":"anhang-loesungen.html","titel":"A.18 Lösungen zu Kapitel „Finanzdaten-Modellierung — Renditen, Kovarianz und Shrinkage“","anker":"sec:loesungen-finanzdaten","text":"18.1 — Renditeart. (a) diskret. (b) logarithmisch. (c) logarithmisch (bzw. diskret kumuliert über Produkt). (d) logarithmisch (statistische Eigenschaften)."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Das risikofreie Portfolio","anker":"finde-den-denkfehler-das-risikofreie-portfolio","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-finanzdaten","text":"1 — (b) bei 75 %. 1{,}0 \\cdot 0{,}5 \\cdot 1{,}5 = 0{,}75. Prozentuale Änderungen verknüpfen sich multiplikativ; ein Verlust wiegt schwerer als ein gleich großer Gewinn, weil er von"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-finanzdaten","text":""},{"seite":"anhang-loesungen.html","titel":"A.19 Lösungen zu Kapitel „Die moderne Portfoliotheorie nach Markowitz“","anker":"sec:loesungen-markowitz","text":"19.1 — Diversifikationseffekt."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Zwölf gleiche Anlagen, ein sehr ungleiches Portfolio","anker":"finde-den-denkfehler-zwölf-gleiche-anlagen-ein-sehr-ungleiches-portfolio","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-markowitz","text":"1 — (b). Das renditestärkste Portfolio auf der Effizienzlinie hat notwendig das schlechteste Rendite-Risiko-Verhältnis (im Kapitelbeispiel 0,44 gegenüber 0,75 beim Optimum) und bes"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-markowitz","text":""},{"seite":"anhang-loesungen.html","titel":"A.20 Lösungen zu Kapitel „Tail-Risiko, CVaR und Transaktionskosten“","anker":"sec:loesungen-cvar","text":"20.1 — VaR und CVaR von Hand. Sortierte Verluste: -2{,}0; -1{,}2; -0{,}8; -0{,}4; 0{,}3; 0{,}6; 1{,}1; 2{,}1; 5{,}4; 8{,}9. Das 80 %-Quantil ist der 8. Wert: \\text{VaR}_{80\\%} = 2{"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Das Risikobudget, das durch Diversifikation stieg","anker":"finde-den-denkfehler-das-risikobudget-das-durch-diversifikation-stieg","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-cvar","text":"1 — (b) nichts. Der VaR ist ein Quantil: Er markiert die Schwelle und sagt nichts über den Bereich dahinter. Genau das zeigt der Schnellstart dieses Kapitels — zwei Anlagen mit ide"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-cvar","text":""},{"seite":"anhang-loesungen.html","titel":"A.21 Lösungen zu Kapitel „Die vollständige quantitative Handelsmaschine“","anker":"sec:loesungen-handelsmaschine","text":"21.1 — Lookahead erkennen. (a) sauber (:heute). (b) Lookahead — gesamter Zeitraum. (c) Lookahead — Volatilität über den ganzen Zeitraum enthält Zukunft. (d) sauber — Gewichte aus D"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Die Strategie mit dem hochsignifikanten Ergebnis","anker":"finde-den-denkfehler-die-strategie-mit-dem-hochsignifikanten-ergebnis","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-handelsmaschine","text":"1 — (b) nichts. Beide Kennzahlen sind Mittelwerte über die Zeit und blind für die Reihenfolge. Der Schnellstart dieses Kapitels zeigt es an denselben Tagesrenditen, nur anders ange"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-handelsmaschine","text":""},{"seite":"anhang-loesungen.html","titel":"A.22 Lösungen zu Kapitel „Praxisfallen und der Weg zum produktiven Einsatz“","anker":"sec:loesungen-praxisfallen","text":"22.1 — Hart oder weich, revisited. (a) hart: gesetzliche Ruhezeit, Qualifikationspflicht. (b) weich: individuelle Wunschtage, Vermeidung von Freistunden. (c) diskutabel: Höchstzahl"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — Das Modell, das seit einem Jahr nicht mehr optimiert","anker":"finde-den-denkfehler-das-modell-das-seit-einem-jahr-nicht-mehr-optimiert","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-praxisfallen","text":"1 — (b) das Zeitlimit greift. Eine Laufzeit, die exakt dem Limit entspricht und über Monate konstant bleibt, ist kein Stabilitätsbeleg, sondern zeigt, dass der Solver jedes Mal abg"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-praxisfallen","text":""},{"seite":"anhang-loesungen.html","titel":"A.23 Lösungen zu Kapitel „Testen, Messen, Ausliefern“","anker":"sec:loesungen-testing","text":"23.1 — Den Schnellstart reparieren. Zwei Wege: (a) Abrunden statt runden — np.floor statt np.round. Das ist immer zulässig, weil weniger produzieren nie eine Kapazität sprengt, kos"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Die Suite ist grün, das Modell stimmt”","anker":"denkfehler-loesung-testing","text":"Alle Prüfungen benutzen dieselbe falsche Zahl."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-testing","text":"1. b) Es gibt keine unabhängige Quelle für die richtige Antwort — sonst bräuchte man den Solver nicht. (a) ist bei festgelegtem Seed und Zeitlimit meist beherrschbar; (c) löst man "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-testing","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"Anhang B: Katalog der Modellierungsmuster","anker":null,"text":""},{"seite":"anhang-modellierungsmuster.html","titel":"Übersicht","anker":"übersicht","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"Logische Schalter","anker":"logische-schalter","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B1 — Aktivierungsschalter (Fixkosten)","anker":"b1-aktivierungsschalter-fixkosten","text":"Regel. „Wenn überhaupt etwas produziert wird, fallen Rüstkosten F an.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B2 — Semikontinuierliche Variable","anker":"b2-semikontinuierliche-variable","text":"Regel. „Entweder gar nicht oder mindestens L (und höchstens U).“"},{"seite":"anhang-modellierungsmuster.html","titel":"B3 — Implikation","anker":"b3-implikation","text":"Regel. „Wenn A gewählt wird, muss auch B gewählt werden.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B4 — Entweder-Oder (disjunktive Bedingung)","anker":"b4-entweder-oder-disjunktive-bedingung","text":"Regel. „Es muss f(\\mathbf{x}) \\le b_1 oder g(\\mathbf{x}) \\le b_2 gelten.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B5 — Exklusiv-Oder","anker":"b5-exklusiv-oder","text":"Regel. „Genau eine der Optionen wird gewählt.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B6 — Kardinalität","anker":"b6-kardinalität","text":"Regel. „Höchstens (mindestens, genau) K von N.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B7 — Bedingte Kopplung (Konjunktion)","anker":"b7-bedingte-kopplung-konjunktion","text":"Regel. „Wenn A und B, dann auch C.“"},{"seite":"anhang-modellierungsmuster.html","titel":"Mengen und Grenzen","anker":"mengen-und-grenzen","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B8 — Weiche Grenze mit Strafkosten","anker":"b8-weiche-grenze-mit-strafkosten","text":"Regel. „Möglichst nicht über b — wenn doch, kostet es.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B9 — Gestaffelte Preise (stückweise linear)","anker":"b9-gestaffelte-preise-stückweise-linear","text":"Regel. „Die ersten 100 Stück kosten 5 €, danach 8 €.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B10 — Absolutbetrag / Abweichung","anker":"b10-absolutbetrag-abweichung","text":"Regel. „Die Abweichung vom Zielwert soll klein sein, egal in welche Richtung.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B11 — Min/Max in der Zielfunktion","anker":"b11-minmax-in-der-zielfunktion","text":"Regel. „Der am schlechtesten gestellte Beteiligte soll möglichst gut dastehen“ (Maximin/Fairness)."},{"seite":"anhang-modellierungsmuster.html","titel":"B12 — Verhältnis-Bedingung","anker":"b12-verhältnis-bedingung","text":"Regel. „Der Anteil von Gruppe G soll mindestens 30 % betragen.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B25 — Mindestabnahmemenge über einen Zeitraum","anker":"b25-mindestabnahmemenge-über-einen-zeitraum","text":"Regel. „Entweder wir arbeiten mit diesem Lieferanten gar nicht, oder wir nehmen ihm im Jahr mindestens 500 Einheiten ab.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B27 — Budgetlimit","anker":"b27-budgetlimit","text":"Regel. „Alle Maßnahmen zusammen dürfen 2 Mio. € nicht überschreiten.“"},{"seite":"anhang-modellierungsmuster.html","titel":"Zeit und Reihenfolge","anker":"zeit-und-reihenfolge","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B13 — Vorrangbeziehung","anker":"b13-vorrangbeziehung","text":"Regel. „Arbeitsgang B darf erst beginnen, wenn A fertig ist.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B14 — Nichtüberlappung","anker":"b14-nichtüberlappung","text":"Regel. „Eine Maschine bearbeitet nur einen Job gleichzeitig.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B15 — Kumulative Ressource","anker":"b15-kumulative-ressource","text":"Regel. „Zu keinem Zeitpunkt dürfen mehr als 3 Arbeiten gleichzeitig laufen“ bzw. „die Stromlast darf 500 kW nie übersteigen“."},{"seite":"anhang-modellierungsmuster.html","titel":"B16 — Gleitendes Fenster","anker":"b16-gleitendes-fenster","text":"Regel. „Höchstens 5 Arbeitstage in Folge“ / „mindestens 2 freie Tage je Woche“."},{"seite":"anhang-modellierungsmuster.html","titel":"B17 — Umrüst- bzw. Wechselkosten","anker":"b17-umrüst--bzw.-wechselkosten","text":"Regel. „Ein Produktwechsel kostet Rüstzeit.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B26 — Rüstzeit als Kapazitätsverbrauch","anker":"b26-rüstzeit-als-kapazitätsverbrauch","text":"Regel. „Das Umrüsten kostet nicht nur Geld, es kostet Maschinenstunden — und die fehlen dann für die Produktion.“"},{"seite":"anhang-modellierungsmuster.html","titel":"Robustheit und Diagnose","anker":"robustheit-und-diagnose","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B18 — Schlupfvariablen gegen Unlösbarkeit","anker":"b18-schlupfvariablen-gegen-unlösbarkeit","text":"Das wichtigste Muster für den Produktivbetrieb."},{"seite":"anhang-modellierungsmuster.html","titel":"B19 — Hierarchische Ziele (lexikografisch)","anker":"b19-hierarchische-ziele-lexikografisch","text":"Regel. „Erst die Besetzung sicherstellen, dann die Fairness optimieren.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B20 — Symmetriebrechung","anker":"b20-symmetriebrechung","text":"Regel. „Drei identische Maschinen — der Solver soll nicht alle Vertauschungen durchprobieren.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B21 — Worst-Case-Abzug (robuste Formulierung)","anker":"b21-worst-case-abzug-robuste-formulierung","text":"Regel. „Rechne nicht mit dem geschätzten Wert, sondern mit dem ungünstigsten plausiblen.“"},{"seite":"anhang-modellierungsmuster.html","titel":"Netzwerke","anker":"netzwerke","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B22 — Flusserhaltung","anker":"b22-flusserhaltung","text":"\\sum_{j:(i,j)\\in E} x_{ij} - \\sum_{k:(k,i)\\in E} x_{ki} = b_i \\qquad \\forall i"},{"seite":"anhang-modellierungsmuster.html","titel":"B23 — Zuordnung 1:1","anker":"b23-zuordnung-11","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B24 — Subtour-Eliminierung","anker":"b24-subtour-eliminierung","text":"MTZ (einfach, aber schwach): u_i - u_j + C x_{ij} \\le C - d_j \\qquad \\forall i\\ne j"},{"seite":"anhang-modellierungsmuster.html","titel":"Ein Wort zur Auswahl","anker":"ein-wort-zur-auswahl","text":"Wenn mehrere Muster passen, entscheiden Sie nach dieser Reihenfolge:"},{"seite":"anhang-fehlerdiagnose.html","titel":"Anhang C: Fehlerdiagnose-Handbuch","anker":null,"text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"Symptom-Schnellübersicht","anker":"symptom-schnellübersicht","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"C1 — INFEASIBLE","anker":"c1-infeasible","text":"Bedeutung. Es gibt keinen einzigen Punkt, der alle Bedingungen gleichzeitig erfüllt. Das ist eine Aussage über Ihr Modell, nicht über den Solver."},{"seite":"anhang-fehlerdiagnose.html","titel":"Diagnose in fünf Schritten","anker":"diagnose-in-fünf-schritten","text":"Schritt 1 — Trivialprüfungen von Hand. Rechnen Sie die offensichtlichen Bilanzen nach, bevor Sie den Solver befragen:"},{"seite":"anhang-fehlerdiagnose.html","titel":"Wenn die fünf Schritte nicht reichen: den Konflikt einkreisen","anker":"wenn-die-fünf-schritte-nicht-reichen-den-konflikt-einkreisen","text":"Warum Schritt 2 so oft ins Leere läuft. Bedingungen einzeln abzuschalten funktioniert nur, solange es einen Schuldigen gibt. Überlagern sich zwei unabhängige Widersprüche, bleibt d"},{"seite":"anhang-fehlerdiagnose.html","titel":"C2 — UNBOUNDED","anker":"c2-unbounded","text":"Bedeutung. Die Zielfunktion lässt sich unbegrenzt verbessern."},{"seite":"anhang-fehlerdiagnose.html","titel":"C3 — Zu langsam","anker":"c3-zu-langsam","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"Diagnosereihenfolge","anker":"diagnosereihenfolge","text":"1. Ist es überhaupt das Lösen?"},{"seite":"anhang-fehlerdiagnose.html","titel":"C4 — Unsinniges Ergebnis","anker":"c4-unsinniges-ergebnis","text":"Der Solver hat immer recht — bezogen auf das Modell, das Sie ihm gegeben haben. Wenn das Ergebnis unsinnig ist, beschreibt Ihr Modell nicht das Problem, das Sie meinen."},{"seite":"anhang-fehlerdiagnose.html","titel":"Checkliste","anker":"checkliste","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"Die wirksamste Gegenmaßnahme","anker":"die-wirksamste-gegenmaßnahme","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"C5 — Instabile Lösung","anker":"c5-instabile-lösung","text":"Symptom. Ein zusätzlicher Handelstag, und das Portfolio sieht völlig anders aus."},{"seite":"anhang-fehlerdiagnose.html","titel":"Derselbe Fehler außerhalb der Finanzwelt","anker":"derselbe-fehler-außerhalb-der-finanzwelt","text":"Instabilität ist kein Portfolioproblem, sondern ein Skalierungsproblem — und es trifft jedes Modell, in dem Größen sehr verschiedener Ordnung nebeneinanderstehen. Euro-Beträge (10^"},{"seite":"anhang-fehlerdiagnose.html","titel":"C6 — Falsche Dualwerte","anker":"c6-falsche-dualwerte","text":"Symptom. Schattenpreise sind negativ, wo sie positiv sein sollten — oder null bei einer offensichtlich knappen Ressource."},{"seite":"anhang-fehlerdiagnose.html","titel":"Die drei Prüfungen","anker":"die-drei-prüfungen","text":"1. Vorzeichenkonvention. Haben Sie zur Maximierung negiert?"},{"seite":"anhang-fehlerdiagnose.html","titel":"C7 — Widersprüchliche Solver","anker":"c7-widersprüchliche-solver","text":"Symptom. linprog sagt 530, CVXPY sagt 529,8."},{"seite":"anhang-fehlerdiagnose.html","titel":"C8 — DCPError","anker":"c8-dcperror","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"C9 — Importfehler","anker":"c9-importfehler","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"C10 — Verdächtig guter Backtest","anker":"c10-verdächtig-guter-backtest","text":"Faustregel: Eine Sharpe Ratio über 2 bei einer einfachen Strategie ist fast immer ein Fehler, kein Fund."},{"seite":"anhang-fehlerdiagnose.html","titel":"Prüfreihenfolge","anker":"prüfreihenfolge","text":"1. Lookahead-Selbsttest."},{"seite":"anhang-fehlerdiagnose.html","titel":"C11 — Vertauschte Spalten","anker":"c11-vertauschte-spalten","text":"Symptom. Ergebnisse sind plausibel, aber falsch beschriftet — oder eine Sektorgrenze greift auf die falschen Titel."},{"seite":"anhang-fehlerdiagnose.html","titel":"Die goldene Diagnoseregel","anker":"die-goldene-diagnoseregel","text":""},{"seite":"anhang-spickzettel.html","titel":"Anhang D: Spickzettel der Solver","anker":null,"text":""},{"seite":"anhang-spickzettel.html","titel":"D1 — SciPy: linprog und milp","anker":"d1-scipy-linprog-und-milp","text":"Wofür. Die Einstiegsschicht: keine zusätzliche Installation, HiGHS als Unterbau, ideal für lineare und gemischt-ganzzahlige Probleme in Matrixform. Wofür nicht: alles, was sich nic"},{"seite":"anhang-spickzettel.html","titel":"Lineares Programm","anker":"lineares-programm","text":""},{"seite":"anhang-spickzettel.html","titel":"Ganzzahlig: milp","anker":"ganzzahlig-milp","text":""},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine","text":""},{"seite":"anhang-spickzettel.html","titel":"D2 — HiGHS über highspy","anker":"d2-highs-über-highspy","text":"Wofür. Derselbe Solver wie unter SciPy, aber direkt gesteuert: Optionen, Warm-Starts, inkrementelles Ändern eines bestehenden Modells. Wofür nicht: schnelles Hinschreiben — die CSR"},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-1","text":""},{"seite":"anhang-spickzettel.html","titel":"D3 — OR-Tools: pywraplp","anker":"d3-or-tools-pywraplp","text":"Wofür. Bequeme algebraische Schreibweise für LP und MILP mit umschaltbarem Backend (GLOP, SCIP, CBC, SAT). Wofür nicht: Scheduling und kombinatorische Bedingungen — dafür ist CP-SA"},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-2","text":""},{"seite":"anhang-spickzettel.html","titel":"D4 — CP-SAT: cp_model","anker":"d4-cp-sat-cp_model","text":"Wofür. Scheduling, Zuordnung, Reihenfolgen, alles Kombinatorische mit globalen Bedingungen. Wofür nicht: stetige Größen — CP-SAT rechnet ausschließlich ganzzahlig. Wer Nachkommaste"},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-3","text":""},{"seite":"anhang-spickzettel.html","titel":"D5 — CVXPY","anker":"d5-cvxpy","text":"Wofür. Konvexe Probleme: quadratische Ziele, Normen, CVaR, alles mit Regularisierungstermen. Wofür nicht: große kombinatorische Modelle — der Aufbau der Ausdrücke wird dann selbst "},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-4","text":""},{"seite":"anhang-spickzettel.html","titel":"D6 — Modellierungssprachen: Pyomo und Linopy","anker":"d6-modellierungssprachen-pyomo-und-linopy","text":"Wofür. Beide trennen Modell von Solver: dasselbe Modell läuft ohne Änderung unter HiGHS, CBC, Gurobi. Pyomo denkt in Mengen und Indizes wie eine mathematische Formulierung; Linopy "},{"seite":"anhang-spickzettel.html","titel":"Pyomo","anker":"pyomo","text":""},{"seite":"anhang-spickzettel.html","titel":"Linopy","anker":"linopy","text":""},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-5","text":""},{"seite":"anhang-spickzettel.html","titel":"Die gemeinsame Regel","anker":"die-gemeinsame-regel","text":"Alle sechs Seiten haben denselben längsten Abschnitt: die Statusauswertung. Das ist kein Zufall. Ein Solveraufruf hat nie zwei Ausgänge, sondern mindestens fünf — optimal, zulässig"},{"seite":"anhang-glossar-literatur.html","titel":"Anhang E: Glossar und Literatur","anker":null,"text":""},{"seite":"anhang-glossar-literatur.html","titel":"Mathematische Optimierung — Grundlagen","anker":"mathematische-optimierung-grundlagen","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Konvexe, nichtlineare und robuste Optimierung","anker":"konvexe-nichtlineare-und-robuste-optimierung","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Constraint Programming und Scheduling","anker":"constraint-programming-und-scheduling","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Quantitative Finanzmathematik","anker":"quantitative-finanzmathematik","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Software und Dokumentation","anker":"software-und-dokumentation","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Verbände und Normen","anker":"verbände-und-normen","text":""}]; diff --git a/OR_HTML_04/cpsat.html b/OR_HTML_04/cpsat.html index 74c78ff..7db8d1e 100644 --- a/OR_HTML_04/cpsat.html +++ b/OR_HTML_04/cpsat.html @@ -111,6 +111,7 @@ Propagation_Demo.py
CP_SAT_Vertretungssystem.py
JobShop_Intervalle.py
+Parallele_Suche.py
CP_SAT_Statusfaelle.py
Strafgewichte.py

Notebook: cpsat.ipynb
@@ -155,7 +156,7 @@ Fr -> Abel

Und jetzt sehen Sie sich das Ergebnis genau an. Cor bekommt keinen einzigen Dienst. Dane arbeitet Montag und Dienstag hintereinander. Ist das ein Fehler?

Nein — und das ist die wichtigste Lektion dieses Kapitels. Der Plan erfüllt jede Regel, die wir aufgeschrieben haben. Wir haben nur nie nach Fairness gefragt, und wir haben nie gesagt, dass Dienste nicht aufeinanderfolgen sollen. Ein Solver liefert exakt das Bestellte — nicht das Gemeinte.

-

🎯 Merksatz Ein Modell, das durchläuft, ist noch kein richtiges Modell. Die eigentliche Arbeit im Constraint Programming ist nicht das Lösen, sondern das vollständige Aufschreiben dessen, was „ein guter Plan“ überhaupt heißt. Wie man Wünsche wie Fairness als weiche Bedingungen mit Strafkosten formuliert, zeigt Abschnitt 7.5 — und Abschnitt 7.9 zeigt, was passiert, wenn man ihr Gewicht falsch wählt.

+

🎯 Merksatz Ein Modell, das durchläuft, ist noch kein richtiges Modell. Die eigentliche Arbeit im Constraint Programming ist nicht das Lösen, sondern das vollständige Aufschreiben dessen, was „ein guter Plan“ überhaupt heißt. Wie man Wünsche wie Fairness als weiche Bedingungen mit Strafkosten formuliert, zeigt Abschnitt 7.5 — und Abschnitt 7.10 zeigt, was passiert, wenn man ihr Gewicht falsch wählt.


7.2 Lernziele

@@ -167,6 +168,7 @@ Fr -> Abel
  • … mit Intervallvariablen ein Maschinenbelegungsproblem modellieren.
  • … harte Regeln und weiche Ziele in einem Modell kombinieren und gewichten.
  • … die fünf CP-SAT-Statusfälle unterscheiden — insbesondere INFEASIBLE (es gibt keine Lösung) von UNKNOWN (es wurde keine gefunden) und MODEL_INVALID (Ihr Code ist fehlerhaft).
  • +
  • … begründen, warum num_workers = 1 für einen reproduzierbaren Lauf nötig ist und ein fester random_seed allein nicht genügt — und was ein Test deshalb prüfen darf.
  • … begründen, warum ein Strafgewicht ein Wechselkurs ist, und welche Regeln deshalb niemals in die Zielfunktion gehören.

  • @@ -880,6 +882,7 @@ Erreichter Makespan: 11

    ⚠️ Mehrere gleich gute Pläne — und was das für Ihre Tests bedeutet

    Der kürzeste Makespan beträgt 11, aber es gibt mehrere verschiedene Pläne, die ihn erreichen. Welchen CP-SAT findet, hängt normalerweise davon ab, welcher seiner parallelen Suchstränge zuerst fertig wird — dasselbe Programm liefert dann von Lauf zu Lauf einen anderen (gleich guten) Plan.

    Deshalb setzt das Programm oben num_workers = 1 und random_seed = 1: nur so ist die abgedruckte Ausgabe reproduzierbar. Im Produktivbetrieb lässt man beides weg — mehrere Arbeiter sind deutlich schneller.

    +

    Wie viel schneller, und warum der Seed allein nicht genügt, misst Abschnitt 7.7 an genau diesem Modell.

    Die Lehre für Ihre eigenen Tests: Prüfen Sie bei mehrdeutigen Optima nie auf einen bestimmten Plan, sondern immer auf den Zielwert und auf die Einhaltung aller Regeln. Ein Test wie assert plan == erwarteter_plan besteht sonst mal und scheitert mal, ohne dass sich am Code etwas geändert hätte.

    Bemerkenswert ist die letzte Zeile: Die untere Schranke liegt bei 10, erreicht wurden 11. Die Schranke ist also nicht erreichbar — kein Plan kann 10 schaffen, obwohl weder ein einzelner Auftrag noch eine einzelne Maschine mehr als 10 Zeiteinheiten braucht. Der Grund sind die Wechselwirkungen. Das ist typisch für Scheduling: Einfache Schranken sind selten scharf. Trotzdem sind sie nützlich — sie sagten uns hier, dass wir höchstens 10 % vom theoretischen Ideal entfernt sein können, noch bevor der Solver fertig war.

    @@ -888,7 +891,305 @@ Erreichter Makespan: 11

    Eine Intervallvariable bündelt drei Größen (Start, Dauer, Ende) und garantiert intern start + dauer == ende. Der eigentliche Gewinn ist aber AddNoOverlap: Diese eine Zeile ersetzt für k Gänge auf einer Maschine \binom{k}{2} Entweder-Oder-Konstruktionen mit je zwei Big-M-Ungleichungen und einer Binärvariablen. Bei 10 Gängen sind das 45 Disjunktionen — in MILP ein zäher Block, in CP-SAT eine Zeile mit einem spezialisierten Propagator.


    -

    7.7 Die fünf Antworten von CP-SAT

    +

    7.7 Parallele Suche: was num_workers wirklich bewirkt

    +

    An mehreren Stellen dieses Buchs steht loeser.parameters.num_workers = 1, mit dem Kommentar „für eine reproduzierbare Ausgabe“. Darin stecken zwei Behauptungen: dass parallele Suche schneller ist, und dass sie von Lauf zu Lauf andere, gleich gute Lösungen liefert. Beides lässt sich messen — und beim Messen kommt mehr heraus als erwartet.

    +

    Gerechnet wird auf demselben Modell wie Abschnitt 7.6, nur groß genug, dass die Suche wirklich arbeitet: 12 Aufträge auf 10 Maschinen, jeder Auftrag besucht jede Maschine. Entscheidend ist, dass random_seed in jedem Lauf denselben Wert hat.

    +
    #!/usr/bin/env python3
    +
    +# Parallele_Suche.py
    +"""
    +Kapitel CP-SAT: Was mehrere Arbeiter wirklich bringen - und was sie kosten.
    +
    +Das Buch setzt an mehreren Stellen 'num_workers = 1', damit die abgedruckte
    +Ausgabe reproduzierbar ist. Behauptet wird dabei zweierlei: dass parallele
    +Suche schneller ist, und dass sie von Lauf zu Lauf verschiedene, gleich gute
    +Loesungen findet. Beides wird hier gemessen statt geglaubt.
    +
    +Gerechnet wird auf einem Job-Shop wie in JobShop_Intervalle.py, nur gross
    +genug, dass die Suche wirklich arbeitet: 12 Auftraege auf 10 Maschinen,
    +jeder Auftrag besucht jede Maschine.
    +
    +Zwei Messungen:
    +  1. Dieselbe Aufgabe mit 1, 2, 4 und 8 Arbeitern - Laufzeit und Ergebnis.
    +  2. Dieselbe Konfiguration mehrfach, bei FESTEM random_seed - wie oft kommt
    +     derselbe Plan heraus?
    +
    +Die zweite ist die wichtigere: Sie entscheidet, wie man Tests schreibt.
    +
    +Achtung, Laufzeiten sind hardwareabhaengig. Die ZAHL der verschiedenen
    +Plaene ist es auch - und genau das ist die Aussage.
    +
    +Benoetigt: numpy, ortools
    +"""
    +
    +from __future__ import annotations
    +
    +import collections
    +import time
    +
    +import numpy as np
    +from ortools.sat.python import cp_model
    +
    +AUFTRAEGE = 12
    +MASCHINEN = 10
    +INSTANZ_SEED = 20260908
    +SOLVER_SEED = 1          # bleibt ueber ALLE Laeufe gleich - das ist der Punkt
    +ARBEITERZAHLEN = (1, 2, 4, 8)
    +WIEDERHOLUNGEN = 4
    +ZEITLIMIT = 120.0
    +
    +
    +def baue_instanz() -> list[list[tuple[int, int]]]:
    +    """Klassischer Job-Shop: Jeder Auftrag besucht jede Maschine genau einmal,
    +    in einer eigenen zufaelligen Reihenfolge."""
    +    rng = np.random.default_rng(INSTANZ_SEED)
    +    return [[(int(m), int(rng.integers(2, 20))) for m in rng.permutation(MASCHINEN)]
    +            for _ in range(AUFTRAEGE)]
    +
    +
    +def loese(auftraege, arbeiter: int):
    +    """Minimiert den Makespan. Gibt Status, Zielwert, Laufzeit und den Plan
    +    zurueck - den Plan als Tupel aller Startzeiten, damit sich zwei Laeufe
    +    vergleichen lassen."""
    +    horizont = sum(dauer for auftrag in auftraege for _, dauer in auftrag)
    +    modell = cp_model.CpModel()
    +    Gang = collections.namedtuple("Gang", "start ende intervall")
    +    plaene: dict[tuple[int, int], Gang] = {}
    +    je_maschine = collections.defaultdict(list)
    +
    +    for a, auftrag in enumerate(auftraege):
    +        for g, (maschine, dauer) in enumerate(auftrag):
    +            start = modell.NewIntVar(0, horizont, f"start_{a}_{g}")
    +            ende = modell.NewIntVar(0, horizont, f"ende_{a}_{g}")
    +            intervall = modell.NewIntervalVar(start, dauer, ende, f"iv_{a}_{g}")
    +            plaene[a, g] = Gang(start, ende, intervall)
    +            je_maschine[maschine].append(intervall)
    +
    +    for maschine in range(MASCHINEN):
    +        modell.AddNoOverlap(je_maschine[maschine])
    +    for a, auftrag in enumerate(auftraege):
    +        for g in range(len(auftrag) - 1):
    +            modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende)
    +
    +    makespan = modell.NewIntVar(0, horizont, "makespan")
    +    modell.AddMaxEquality(
    +        makespan, [plaene[a, len(auftrag) - 1].ende
    +                   for a, auftrag in enumerate(auftraege)])
    +    modell.Minimize(makespan)
    +
    +    loeser = cp_model.CpSolver()
    +    loeser.parameters.num_workers = arbeiter
    +    loeser.parameters.random_seed = SOLVER_SEED
    +    loeser.parameters.max_time_in_seconds = ZEITLIMIT
    +
    +    beginn = time.perf_counter()
    +    status = loeser.Solve(modell)
    +    dauer = time.perf_counter() - beginn
    +
    +    if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
    +        return loeser.StatusName(status), None, dauer, None
    +    plan = tuple(loeser.Value(plaene[a, g].start)
    +                 for a, auftrag in enumerate(auftraege)
    +                 for g in range(len(auftrag)))
    +    return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer, plan
    +
    +
    +if __name__ == "__main__":
    +    auftraege = baue_instanz()
    +    gaenge = sum(len(a) for a in auftraege)
    +
    +    print("=" * 78)
    +    print("  WAS MEHRERE ARBEITER WIRKLICH BRINGEN")
    +    print("=" * 78)
    +    print(f"Job-Shop: {AUFTRAEGE} Auftraege, {MASCHINEN} Maschinen, "
    +          f"{gaenge} Arbeitsgaenge.")
    +    print(f"random_seed = {SOLVER_SEED} in JEDEM Lauf, "
    +          f"{WIEDERHOLUNGEN} Wiederholungen je Arbeiterzahl.\n")
    +
    +    # Jede Konfiguration mehrfach - ein einzelner Zeitwert sagt nichts.
    +    ergebnisse: dict[int, list[tuple]] = {}
    +    for arbeiter in ARBEITERZAHLEN:
    +        ergebnisse[arbeiter] = [loese(auftraege, arbeiter)
    +                                for _ in range(WIEDERHOLUNGEN)]
    +
    +    print("=" * 78)
    +    print("  (1) Laufzeit: was die Arbeiter bringen")
    +    print("=" * 78)
    +    print(f"{'Arbeiter':>9} {'Status':>9} {'Makespan':>9} "
    +          f"{'schnellste':>11} {'langsamste':>11} {'Beschleunigung':>15}")
    +    print("-" * 78)
    +    basis_zeit = min(d for _, _, d, _ in ergebnisse[1])
    +    for arbeiter in ARBEITERZAHLEN:
    +        laeufe = ergebnisse[arbeiter]
    +        zeiten = [d for _, _, d, _ in laeufe]
    +        ziele = {o for _, o, _, _ in laeufe}
    +        status = {s for s, _, _, _ in laeufe}
    +        print(f"{arbeiter:>9} {'/'.join(sorted(status)):>9} "
    +              f"{'/'.join(str(z) for z in sorted(ziele)):>9} "
    +              f"{min(zeiten):>10.2f}s {max(zeiten):>10.2f}s "
    +              f"{basis_zeit / min(zeiten):>14.1f}x")
    +
    +    beste = min(ARBEITERZAHLEN, key=lambda w: min(d for _, _, d, _ in ergebnisse[w]))
    +    faktor = basis_zeit / min(d for _, _, d, _ in ergebnisse[beste])
    +    print(f"\n  Am schnellsten sind {beste} Arbeiter: Faktor {faktor:.1f} gegenueber")
    +    print(f"  einem einzigen - also MEHR, als {beste} Kerne hergeben sollten.")
    +    print(f"  Das ist kein Messfehler. CP-SAT laesst nicht {beste}-mal dieselbe Suche")
    +    print("  laufen, sondern verschiedene Strategien nebeneinander, die einander")
    +    print("  ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der")
    +    print("  'zweite Kern', sondern ein anderes Verfahren.")
    +    letzte = ARBEITERZAHLEN[-1]
    +    if beste != letzte:
    +        zeit_letzte = min(d for _, _, d, _ in ergebnisse[letzte])
    +        print(f"\n  Und mehr ist nicht immer besser: {letzte} Arbeiter brauchen "
    +              f"{zeit_letzte:.2f}s")
    +        print(f"  gegenueber {min(d for _, _, d, _ in ergebnisse[beste]):.2f}s bei "
    +              f"{beste}. Ab einer gewissen Zahl kosten Abstimmung")
    +        print("  und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt.")
    +
    +    print("\n" + "=" * 78)
    +    print("  (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?")
    +    print("=" * 78)
    +    print(f"{'Arbeiter':>9} {'Laeufe':>7} {'verschiedene Plaene':>21} "
    +          f"{'Makespan':>10}")
    +    print("-" * 78)
    +    for arbeiter in ARBEITERZAHLEN:
    +        plaene = {p for _, _, _, p in ergebnisse[arbeiter] if p is not None}
    +        ziele = {o for _, o, _, _ in ergebnisse[arbeiter]}
    +        print(f"{arbeiter:>9} {WIEDERHOLUNGEN:>7} {len(plaene):>21} "
    +              f"{'/'.join(str(z) for z in sorted(ziele)):>10}")
    +
    +    einer = len({p for _, _, _, p in ergebnisse[1]})
    +    # Ab welcher Arbeiterzahl bricht die Reproduzierbarkeit? Das ist die
    +    # eigentliche Zahl - nicht, was acht Arbeiter anrichten.
    +    ab = next((w for w in ARBEITERZAHLEN
    +               if len({p for _, _, _, p in ergebnisse[w]}) > 1), None)
    +    print(f"\n  Mit einem Arbeiter: {einer} Plan aus {WIEDERHOLUNGEN} Laeufen.")
    +    if ab is not None:
    +        viele = len({p for _, _, _, p in ergebnisse[ab]})
    +        print(f"  Schon mit {ab} Arbeitern: {viele} verschiedene Plaene aus "
    +              f"{WIEDERHOLUNGEN} Laeufen -")
    +        print("  bei identischem random_seed und identischem Zielwert.")
    +        print(f"  Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit")
    +        print(f"  zu verlieren. {ab} genuegen.")
    +    print("\n  Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt")
    +    print("  fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von")
    +    print("  mehreren zuerst fertig wird. Das entscheidet die Uhr.")
    +
    +    print("\n" + "=" * 78)
    +    print("  (3) Was daraus fuer Tests folgt")
    +    print("=" * 78)
    +    print("  Ein Test der Form  assert plan == erwarteter_plan  besteht mal und")
    +    print("  scheitert mal, ohne dass sich am Code etwas geaendert haette.")
    +    print("  Zu pruefen sind stattdessen:")
    +    print("    * der ZIELWERT (hier in allen Laeufen gleich),")
    +    print("    * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),")
    +    print("    * und der Status - nicht die Gestalt der Loesung.")
    +    print("\n  Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte")
    +    print("  Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.")
    +    print(f"  Das kostet hier den Faktor {faktor:.1f} an Laufzeit und ist genau")
    +    print("  deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.")
    +    print("=" * 78)
    +

    Erwartete Ausgabe (Laufzeiten und die Zahl der verschiedenen Pläne sind hardwareabhängig — genau das ist die Aussage; der Zielwert ist es nicht):

    +
    ==============================================================================
    +  WAS MEHRERE ARBEITER WIRKLICH BRINGEN
    +==============================================================================
    +Job-Shop: 12 Auftraege, 10 Maschinen, 120 Arbeitsgaenge.
    +random_seed = 1 in JEDEM Lauf, 4 Wiederholungen je Arbeiterzahl.
    +
    +==============================================================================
    +  (1) Laufzeit: was die Arbeiter bringen
    +==============================================================================
    + Arbeiter    Status  Makespan  schnellste  langsamste  Beschleunigung
    +------------------------------------------------------------------------------
    +        1   OPTIMAL       183       5.62s       5.82s            1.0x
    +        2   OPTIMAL       183       1.43s       2.04s            3.9x
    +        4   OPTIMAL       183       0.60s       0.66s            9.4x
    +        8   OPTIMAL       183       0.46s       0.65s           12.3x
    +
    +  Am schnellsten sind 8 Arbeiter: Faktor 12.3 gegenueber
    +  einem einzigen - also MEHR, als 8 Kerne hergeben sollten.
    +  Das ist kein Messfehler. CP-SAT laesst nicht 8-mal dieselbe Suche
    +  laufen, sondern verschiedene Strategien nebeneinander, die einander
    +  ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der
    +  'zweite Kern', sondern ein anderes Verfahren.
    +
    +==============================================================================
    +  (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?
    +==============================================================================
    + Arbeiter  Laeufe   verschiedene Plaene   Makespan
    +------------------------------------------------------------------------------
    +        1       4                     1        183
    +        2       4                     3        183
    +        4       4                     3        183
    +        8       4                     4        183
    +
    +  Mit einem Arbeiter: 1 Plan aus 4 Laeufen.
    +  Schon mit 2 Arbeitern: 3 verschiedene Plaene aus 4 Laeufen -
    +  bei identischem random_seed und identischem Zielwert.
    +  Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit
    +  zu verlieren. 2 genuegen.
    +
    +  Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt
    +  fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von
    +  mehreren zuerst fertig wird. Das entscheidet die Uhr.
    +
    +==============================================================================
    +  (3) Was daraus fuer Tests folgt
    +==============================================================================
    +  Ein Test der Form  assert plan == erwarteter_plan  besteht mal und
    +  scheitert mal, ohne dass sich am Code etwas geaendert haette.
    +  Zu pruefen sind stattdessen:
    +    * der ZIELWERT (hier in allen Laeufen gleich),
    +    * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),
    +    * und der Status - nicht die Gestalt der Loesung.
    +
    +  Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte
    +  Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.
    +  Das kostet hier den Faktor 12.3 an Laufzeit und ist genau
    +  deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.
    +==============================================================================
    +

    Was die Messung zeigt

    +

    Erstens: Die Beschleunigung ist überlinear. Acht Arbeiter sind im abgedruckten Lauf nicht achtmal, sondern gut zwölfmal schneller als einer. Das sieht nach Messfehler aus, ist aber die korrekte Beschreibung dessen, was CP-SAT tut: Es lässt nicht achtmal dieselbe Suche auf verschiedenen Kernen laufen, sondern verschiedene Strategien nebeneinander — Suche mit LP-Relaxation, Suche mit anderen Verzweigungsregeln, lokale Suche —, und die teilen einander ihre Schranken mit. Der zweite Arbeiter ist deshalb nicht „der zweite Kern“, sondern ein anderes Verfahren, das zufällig auch rechnet.

    +

    Daraus folgt zweierlei. Ein Speedup über der Kernzahl ist bei CP-SAT normal und kein Grund zum Misstrauen. Und der Zugewinn flacht ab: Zwischen vier und acht Arbeitern lag in unseren Wiederholungen mal das eine, mal das andere vorn — ab einer gewissen Zahl kosten Abstimmung und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt. Auch hier gilt: messen, nicht schätzen.

    +

    Zweitens — und das ist der Befund, der die Testpraxis bestimmt: Der Seed genügt nicht. Bei einem Arbeiter liefern vier Läufe einen Plan. Schon bei zwei Arbeitern liefern vier Läufe drei verschiedene — bei identischem random_seed und identischem Zielwert 183.

    +

    Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit zu verlieren. Zwei genügen. Der Grund ist einfach, sobald man ihn ausspricht: Der Seed legt fest, wie ein einzelner Suchstrang würfelt. Er legt nicht fest, welcher von mehreren zuerst fertig wird — das entscheidet die Uhr, und die ist bei jedem Lauf anders.

    +
    +

    ⚠️ random_seed allein macht nichts reproduzierbar Ein häufiger Irrtum: „Ich habe doch einen Seed gesetzt.“ Für einen Zufallsgenerator stimmt das. Für einen parallelen Solver nicht — dort ist die zweite Zufallsquelle die Ausführungsreihenfolge, und die lässt sich nicht mit einem Parameter festnageln. Reproduzierbar wird der Lauf erst mit num_workers = 1 und Seed. Wer nur eines von beidem setzt, hat einen Test, der gelegentlich grundlos rot wird.

    +
    +

    Was daraus für Tests folgt

    +

    Der Zielwert war in allen sechzehn Läufen derselbe: 183. Die Gestalt der Lösung war es nicht. Genau daran entscheidet sich, was ein Test prüfen darf:

    + ++++ + + + + + + + + + + + + + + + + + + + + +
    PrüfenNicht prüfen
    den Zielwert — er ist eindeutig, wenn das Modell optimal gelöst wurdedie Gestalt des Plans (assert plan == erwarteter_plan)
    die Einhaltung aller Regeln über eine Abnahmeprüfung (Abschnitt 22.6)die Reihenfolge, in der gleichwertige Aufträge zugewiesen wurden
    den Status (OPTIMAL, FEASIBLE, INFEASIBLE)die Laufzeit als feste Zahl
    +

    Wer doch einen bestimmten Plan braucht — für eine abgedruckte Ausgabe, für einen Regressionstest —, setzt num_workers = 1. Das kostet hier den Faktor 12 an Laufzeit, und genau deshalb ist es eine Entscheidung für den Test, nicht für den Betrieb. In Produktion lässt man beide Parameter auf ihren Standardwerten.

    +
    +

    🎯 Merksatz Ein paralleler Solver hat zwei Zufallsquellen: den Seed und die Uhr. Nur die erste lässt sich setzen. Wer Reproduzierbarkeit braucht, muss die zweite abschalten — und das heißt num_workers = 1.

    +
    +
    +

    7.8 Die fünf Antworten von CP-SAT

    Abschnitt 6.8 hat gezeigt, warum OPTIMAL bei ganzzahligen Problemen nicht der Normalfall ist. Bei CP-SAT kommt eine Besonderheit hinzu: Es gibt fünf verschiedene Antworten, und drei davon werden regelmäßig verwechselt.

    @@ -936,229 +1237,229 @@ Erreichter Makespan: 11

    Und MODEL_INVALID ist von beiden nochmals verschieden: Es ist immer ein Programmierfehler, kein fachliches Problem. Der klassische Fall ist eine Variable mit leerem Wertebereich — NewIntVar(mindestbesetzung, kapazitaet, ...), wobei die Daten vertauscht ankamen und die untere Schranke über der oberen liegt.

    Das folgende Programm erzeugt jeden Fall absichtlich und zeigt die passende Auswertung.

    -
    #!/usr/bin/env python3
    -
    -# CP_SAT_Statusfaelle.py
    -"""
    -Kapitel CP-SAT: Alle fuenf Antworten von CP-SAT - und was jede bedeutet.
    -
    -Der haeufigste Fehler beim Einsatz von CP-SAT im Betrieb ist eine Zeile wie
    -
    -    if status == cp_model.OPTIMAL:
    -        ...verwerte die Loesung...
    -
    -Sie wirft in drei von fuenf Faellen etwas weg, das man haette gebrauchen
    -koennen, und verschweigt in einem weiteren Fall einen Modellierungsfehler.
    -
    -Dieses Programm erzeugt jeden Statusfall absichtlich und zeigt die passende
    -Reaktion:
    -
    -    OPTIMAL        beweisbar bestmoeglich          -> ausfuehren
    -    FEASIBLE       zulaessig, Zeit war um          -> Gap pruefen, dann entscheiden
    -    INFEASIBLE     kein Plan existiert             -> harte Regeln lockern
    -    MODEL_INVALID  das Modell ist fehlerhaft       -> Programmierfehler beheben
    -    UNKNOWN        nichts gefunden, Zeit war um    -> mehr Zeit oder Heuristik
    -
    -Benoetigt: numpy, ortools
    -"""
    -
    -from __future__ import annotations
    -
    -from dataclasses import dataclass
    -
    -import numpy as np
    -from ortools.sat.python import cp_model
    -
    -
    -@dataclass
    -class Auswertung:
    -    """Was nach einem CP-SAT-Lauf tatsaechlich feststeht."""
    -    status: str
    -    hat_loesung: bool
    -    beweisbar_optimal: bool
    -    zielwert: float | None
    -    schranke: float | None
    -    gap: float | None
    -    empfehlung: str
    -
    -
    -def werte_aus(loeser: cp_model.CpSolver, status: int,
    -              mit_zielfunktion: bool) -> Auswertung:
    -    """Uebersetzt einen CP-SAT-Status in eine Handlungsempfehlung.
    -
    -    Genau diese Funktion fehlt in den meisten Projekten. Sie ist kurz, aber
    -    sie ist der Unterschied zwischen einem Prototyp und einem System, das
    -    nachts ohne Aufsicht laeuft.
    -    """
    -    name = loeser.StatusName(status)
    -    hat_loesung = status in (cp_model.OPTIMAL, cp_model.FEASIBLE)
    -
    -    zielwert = schranke = gap = None
    -    if hat_loesung and mit_zielfunktion:
    -        zielwert = loeser.ObjectiveValue()
    -        schranke = loeser.BestObjectiveBound()
    -        nenner = max(abs(zielwert), 1e-9)
    -        gap = abs(zielwert - schranke) / nenner
    -
    -    if status == cp_model.OPTIMAL:
    -        empfehlung = "Ausfuehren. Besser geht es nachweislich nicht."
    -    elif status == cp_model.FEASIBLE:
    -        empfehlung = (f"Brauchbar. Der Plan ist hoechstens {gap * 100:.2f} % "
    -                      f"schlechter als das theoretisch Bestmoegliche."
    -                      if gap is not None else
    -                      "Brauchbar, aber nicht als optimal bewiesen.")
    -    elif status == cp_model.INFEASIBLE:
    -        empfehlung = ("KEIN Plan existiert. Das ist eine Aussage ueber Ihre "
    -                      "harten Regeln, nicht ueber die Rechenzeit: Auch "
    -                      "unendlich viel Zeit wuerde nichts aendern. Regeln "
    -                      "lockern oder weich machen (Anhang C).")
    -    elif status == cp_model.MODEL_INVALID:
    -        empfehlung = ("Das MODELL ist fehlerhaft, nicht das Problem. "
    -                      "Typisch: eine Variable mit leerem Wertebereich. "
    -                      "model.Validate() nennt die Ursache.")
    -    else:                                    # UNKNOWN
    -        empfehlung = ("Nichts gefunden - aber auch nicht widerlegt. Mehr Zeit "
    -                      "geben, das Modell vereinfachen oder mit einer "
    -                      "Heuristik vorbelegen (AddHint).")
    -
    -    return Auswertung(
    -        status=name,
    -        hat_loesung=hat_loesung,
    -        beweisbar_optimal=status == cp_model.OPTIMAL,
    -        zielwert=zielwert, schranke=schranke, gap=gap,
    -        empfehlung=empfehlung,
    -    )
    -
    -
    -def loese(baue, zeitlimit: float, mit_zielfunktion: bool) -> Auswertung:
    -    modell = cp_model.CpModel()
    -    baue(modell)
    -    loeser = cp_model.CpSolver()
    -    loeser.parameters.max_time_in_seconds = zeitlimit
    -    # Fuer reproduzierbare Buchausgaben; im Betrieb weglassen.
    -    loeser.parameters.num_workers = 1
    -    loeser.parameters.random_seed = 1
    -    status = loeser.Solve(modell)
    -    return werte_aus(loeser, status, mit_zielfunktion)
    -
    -
    -# --- Die fuenf Faelle --------------------------------------------------------
    -
    -def dienstplan_loesbar(modell: cp_model.CpModel) -> None:
    -    """Vier Personen, fuenf Dienste, hoechstens zwei je Person - loesbar."""
    -    personen, dienste = 4, 5
    -    x = [[modell.NewBoolVar(f"x_{i}_{j}") for j in range(dienste)]
    -         for i in range(personen)]
    -    for j in range(dienste):
    -        modell.AddExactlyOne(x[i][j] for i in range(personen))
    -    for i in range(personen):
    -        modell.Add(sum(x[i]) <= 2)
    -
    -
    -def dienstplan_unloesbar(modell: cp_model.CpModel) -> None:
    -    """Dieselben fuenf Dienste, aber hoechstens EIN Dienst je Person.
    -
    -    Vier Personen koennen zusammen hoechstens vier Dienste uebernehmen - fuer
    -    fuenf Dienste reicht das nicht. Kein Solver der Welt findet hier etwas.
    -    """
    -    personen, dienste = 4, 5
    -    x = [[modell.NewBoolVar(f"x_{i}_{j}") for j in range(dienste)]
    -         for i in range(personen)]
    -    for j in range(dienste):
    -        modell.AddExactlyOne(x[i][j] for i in range(personen))
    -    for i in range(personen):
    -        modell.Add(sum(x[i]) <= 1)
    -
    -
    -def modell_fehlerhaft(modell: cp_model.CpModel) -> None:
    -    """Eine Variable mit leerem Wertebereich: untere Schranke > obere.
    -
    -    Im Alltag entsteht das durch einen Rechenfehler in den Grenzen, etwa
    -    NewIntVar(mindestbesetzung, kapazitaet, ...) bei falsch sortierten Daten.
    -    """
    -    kaputt = modell.NewIntVar(5, 2, "leerer_bereich")
    -    modell.Add(kaputt >= 0)
    -
    -
    -def lastverteilung(modell: cp_model.CpModel) -> None:
    -    """120 Auftraege auf 11 Maschinen - gross genug, dass der Optimalitaets-
    -    beweis laenger dauert als das Finden einer sehr guten Loesung."""
    -    rng = np.random.default_rng(3)
    -    dauer = rng.integers(100, 9000, 120)
    -    n, maschinen = len(dauer), 11
    -    obergrenze = int(dauer.sum())
    -
    -    x = [[modell.NewBoolVar(f"x_{i}_{k}") for k in range(maschinen)]
    -         for i in range(n)]
    -    for i in range(n):
    -        modell.AddExactlyOne(x[i])
    -    belegung = [modell.NewIntVar(0, obergrenze, f"l_{k}") for k in range(maschinen)]
    -    for k in range(maschinen):
    -        modell.Add(belegung[k] == sum(int(dauer[i]) * x[i][k] for i in range(n)))
    -    makespan = modell.NewIntVar(0, obergrenze, "makespan")
    -    modell.AddMaxEquality(makespan, belegung)
    -    modell.Minimize(makespan)
    -
    -
    -def zahlpartition(modell: cp_model.CpModel) -> None:
    -    """46 grosse Zahlen exakt in zwei gleich schwere Haelften teilen.
    -
    -    Ein beruehmt schweres Problem: Es gibt keine Zielfunktion, entweder es
    -    geht auf oder nicht - und beides zu entscheiden dauert lange.
    -    """
    -    rng = np.random.default_rng(1)
    -    gewichte = rng.integers(10**6, 2 * 10**6, 46)
    -    x = [modell.NewBoolVar(f"x_{i}") for i in range(len(gewichte))]
    -    modell.Add(sum(int(gewichte[i]) * x[i] for i in range(len(gewichte)))
    -               == int(gewichte.sum() // 2))
    -
    -
    -def zeige(titel: str, a: Auswertung) -> None:
    -    print(f"\n{titel}")
    -    print(f"  Status               {a.status}")
    -    print(f"  Loesung vorhanden    {'ja' if a.hat_loesung else 'nein'}")
    -    if a.zielwert is not None:
    -        print(f"  Zielwert / Schranke  {a.zielwert:,.0f} / {a.schranke:,.0f}"
    -              f"   (Gap {a.gap * 100:.3f} %)")
    -    print(f"  -> {a.empfehlung}")
    -
    -
    -if __name__ == "__main__":
    -    print("=" * 78)
    -    print("  DIE FUENF ANTWORTEN VON CP-SAT")
    -    print("=" * 78)
    -
    -    zeige("[1] Dienstplan, hoechstens 2 Dienste je Person",
    -          loese(dienstplan_loesbar, 10.0, mit_zielfunktion=False))
    -
    -    zeige("[2] Lastverteilung 120 Auftraege / 11 Maschinen, 0,5 s Limit",
    -          loese(lastverteilung, 0.5, mit_zielfunktion=True))
    -
    -    zeige("[3] Derselbe Fall mit 10 s Limit",
    -          loese(lastverteilung, 10.0, mit_zielfunktion=True))
    -
    -    zeige("[4] Dienstplan, hoechstens 1 Dienst je Person",
    -          loese(dienstplan_unloesbar, 10.0, mit_zielfunktion=False))
    -
    -    zeige("[5] Variable mit leerem Wertebereich",
    -          loese(modell_fehlerhaft, 10.0, mit_zielfunktion=False))
    -
    -    zeige("[6] Zahlpartition mit 46 grossen Zahlen, 2 s Limit",
    -          loese(zahlpartition, 2.0, mit_zielfunktion=False))
    -
    -    print("\n" + "=" * 78)
    -    print("  DIE DREI, DIE MAN NICHT VERWECHSELN DARF")
    -    print("=" * 78)
    -    print("INFEASIBLE  Es gibt keine Loesung. Eine Aussage ueber Ihr MODELL.")
    -    print("            Mehr Rechenzeit aendert daran nichts.")
    -    print("UNKNOWN     Es wurde keine gefunden. Eine Aussage ueber die ZEIT.")
    -    print("            Ob es eine gibt, ist offen.")
    -    print("MODEL_INVALID  Das Modell ist gar kein gueltiges Modell. Eine")
    -    print("            Aussage ueber Ihren CODE - immer ein Programmierfehler.")
    -    print()
    -    print("Wer diese drei in einem 'else: return None' zusammenfasst, verliert")
    -    print("genau die Information, die zur Fehlersuche noetig waere.")
    -    print("=" * 78)
    +
    #!/usr/bin/env python3
    +
    +# CP_SAT_Statusfaelle.py
    +"""
    +Kapitel CP-SAT: Alle fuenf Antworten von CP-SAT - und was jede bedeutet.
    +
    +Der haeufigste Fehler beim Einsatz von CP-SAT im Betrieb ist eine Zeile wie
    +
    +    if status == cp_model.OPTIMAL:
    +        ...verwerte die Loesung...
    +
    +Sie wirft in drei von fuenf Faellen etwas weg, das man haette gebrauchen
    +koennen, und verschweigt in einem weiteren Fall einen Modellierungsfehler.
    +
    +Dieses Programm erzeugt jeden Statusfall absichtlich und zeigt die passende
    +Reaktion:
    +
    +    OPTIMAL        beweisbar bestmoeglich          -> ausfuehren
    +    FEASIBLE       zulaessig, Zeit war um          -> Gap pruefen, dann entscheiden
    +    INFEASIBLE     kein Plan existiert             -> harte Regeln lockern
    +    MODEL_INVALID  das Modell ist fehlerhaft       -> Programmierfehler beheben
    +    UNKNOWN        nichts gefunden, Zeit war um    -> mehr Zeit oder Heuristik
    +
    +Benoetigt: numpy, ortools
    +"""
    +
    +from __future__ import annotations
    +
    +from dataclasses import dataclass
    +
    +import numpy as np
    +from ortools.sat.python import cp_model
    +
    +
    +@dataclass
    +class Auswertung:
    +    """Was nach einem CP-SAT-Lauf tatsaechlich feststeht."""
    +    status: str
    +    hat_loesung: bool
    +    beweisbar_optimal: bool
    +    zielwert: float | None
    +    schranke: float | None
    +    gap: float | None
    +    empfehlung: str
    +
    +
    +def werte_aus(loeser: cp_model.CpSolver, status: int,
    +              mit_zielfunktion: bool) -> Auswertung:
    +    """Uebersetzt einen CP-SAT-Status in eine Handlungsempfehlung.
    +
    +    Genau diese Funktion fehlt in den meisten Projekten. Sie ist kurz, aber
    +    sie ist der Unterschied zwischen einem Prototyp und einem System, das
    +    nachts ohne Aufsicht laeuft.
    +    """
    +    name = loeser.StatusName(status)
    +    hat_loesung = status in (cp_model.OPTIMAL, cp_model.FEASIBLE)
    +
    +    zielwert = schranke = gap = None
    +    if hat_loesung and mit_zielfunktion:
    +        zielwert = loeser.ObjectiveValue()
    +        schranke = loeser.BestObjectiveBound()
    +        nenner = max(abs(zielwert), 1e-9)
    +        gap = abs(zielwert - schranke) / nenner
    +
    +    if status == cp_model.OPTIMAL:
    +        empfehlung = "Ausfuehren. Besser geht es nachweislich nicht."
    +    elif status == cp_model.FEASIBLE:
    +        empfehlung = (f"Brauchbar. Der Plan ist hoechstens {gap * 100:.2f} % "
    +                      f"schlechter als das theoretisch Bestmoegliche."
    +                      if gap is not None else
    +                      "Brauchbar, aber nicht als optimal bewiesen.")
    +    elif status == cp_model.INFEASIBLE:
    +        empfehlung = ("KEIN Plan existiert. Das ist eine Aussage ueber Ihre "
    +                      "harten Regeln, nicht ueber die Rechenzeit: Auch "
    +                      "unendlich viel Zeit wuerde nichts aendern. Regeln "
    +                      "lockern oder weich machen (Anhang C).")
    +    elif status == cp_model.MODEL_INVALID:
    +        empfehlung = ("Das MODELL ist fehlerhaft, nicht das Problem. "
    +                      "Typisch: eine Variable mit leerem Wertebereich. "
    +                      "model.Validate() nennt die Ursache.")
    +    else:                                    # UNKNOWN
    +        empfehlung = ("Nichts gefunden - aber auch nicht widerlegt. Mehr Zeit "
    +                      "geben, das Modell vereinfachen oder mit einer "
    +                      "Heuristik vorbelegen (AddHint).")
    +
    +    return Auswertung(
    +        status=name,
    +        hat_loesung=hat_loesung,
    +        beweisbar_optimal=status == cp_model.OPTIMAL,
    +        zielwert=zielwert, schranke=schranke, gap=gap,
    +        empfehlung=empfehlung,
    +    )
    +
    +
    +def loese(baue, zeitlimit: float, mit_zielfunktion: bool) -> Auswertung:
    +    modell = cp_model.CpModel()
    +    baue(modell)
    +    loeser = cp_model.CpSolver()
    +    loeser.parameters.max_time_in_seconds = zeitlimit
    +    # Fuer reproduzierbare Buchausgaben; im Betrieb weglassen.
    +    loeser.parameters.num_workers = 1
    +    loeser.parameters.random_seed = 1
    +    status = loeser.Solve(modell)
    +    return werte_aus(loeser, status, mit_zielfunktion)
    +
    +
    +# --- Die fuenf Faelle --------------------------------------------------------
    +
    +def dienstplan_loesbar(modell: cp_model.CpModel) -> None:
    +    """Vier Personen, fuenf Dienste, hoechstens zwei je Person - loesbar."""
    +    personen, dienste = 4, 5
    +    x = [[modell.NewBoolVar(f"x_{i}_{j}") for j in range(dienste)]
    +         for i in range(personen)]
    +    for j in range(dienste):
    +        modell.AddExactlyOne(x[i][j] for i in range(personen))
    +    for i in range(personen):
    +        modell.Add(sum(x[i]) <= 2)
    +
    +
    +def dienstplan_unloesbar(modell: cp_model.CpModel) -> None:
    +    """Dieselben fuenf Dienste, aber hoechstens EIN Dienst je Person.
    +
    +    Vier Personen koennen zusammen hoechstens vier Dienste uebernehmen - fuer
    +    fuenf Dienste reicht das nicht. Kein Solver der Welt findet hier etwas.
    +    """
    +    personen, dienste = 4, 5
    +    x = [[modell.NewBoolVar(f"x_{i}_{j}") for j in range(dienste)]
    +         for i in range(personen)]
    +    for j in range(dienste):
    +        modell.AddExactlyOne(x[i][j] for i in range(personen))
    +    for i in range(personen):
    +        modell.Add(sum(x[i]) <= 1)
    +
    +
    +def modell_fehlerhaft(modell: cp_model.CpModel) -> None:
    +    """Eine Variable mit leerem Wertebereich: untere Schranke > obere.
    +
    +    Im Alltag entsteht das durch einen Rechenfehler in den Grenzen, etwa
    +    NewIntVar(mindestbesetzung, kapazitaet, ...) bei falsch sortierten Daten.
    +    """
    +    kaputt = modell.NewIntVar(5, 2, "leerer_bereich")
    +    modell.Add(kaputt >= 0)
    +
    +
    +def lastverteilung(modell: cp_model.CpModel) -> None:
    +    """120 Auftraege auf 11 Maschinen - gross genug, dass der Optimalitaets-
    +    beweis laenger dauert als das Finden einer sehr guten Loesung."""
    +    rng = np.random.default_rng(3)
    +    dauer = rng.integers(100, 9000, 120)
    +    n, maschinen = len(dauer), 11
    +    obergrenze = int(dauer.sum())
    +
    +    x = [[modell.NewBoolVar(f"x_{i}_{k}") for k in range(maschinen)]
    +         for i in range(n)]
    +    for i in range(n):
    +        modell.AddExactlyOne(x[i])
    +    belegung = [modell.NewIntVar(0, obergrenze, f"l_{k}") for k in range(maschinen)]
    +    for k in range(maschinen):
    +        modell.Add(belegung[k] == sum(int(dauer[i]) * x[i][k] for i in range(n)))
    +    makespan = modell.NewIntVar(0, obergrenze, "makespan")
    +    modell.AddMaxEquality(makespan, belegung)
    +    modell.Minimize(makespan)
    +
    +
    +def zahlpartition(modell: cp_model.CpModel) -> None:
    +    """46 grosse Zahlen exakt in zwei gleich schwere Haelften teilen.
    +
    +    Ein beruehmt schweres Problem: Es gibt keine Zielfunktion, entweder es
    +    geht auf oder nicht - und beides zu entscheiden dauert lange.
    +    """
    +    rng = np.random.default_rng(1)
    +    gewichte = rng.integers(10**6, 2 * 10**6, 46)
    +    x = [modell.NewBoolVar(f"x_{i}") for i in range(len(gewichte))]
    +    modell.Add(sum(int(gewichte[i]) * x[i] for i in range(len(gewichte)))
    +               == int(gewichte.sum() // 2))
    +
    +
    +def zeige(titel: str, a: Auswertung) -> None:
    +    print(f"\n{titel}")
    +    print(f"  Status               {a.status}")
    +    print(f"  Loesung vorhanden    {'ja' if a.hat_loesung else 'nein'}")
    +    if a.zielwert is not None:
    +        print(f"  Zielwert / Schranke  {a.zielwert:,.0f} / {a.schranke:,.0f}"
    +              f"   (Gap {a.gap * 100:.3f} %)")
    +    print(f"  -> {a.empfehlung}")
    +
    +
    +if __name__ == "__main__":
    +    print("=" * 78)
    +    print("  DIE FUENF ANTWORTEN VON CP-SAT")
    +    print("=" * 78)
    +
    +    zeige("[1] Dienstplan, hoechstens 2 Dienste je Person",
    +          loese(dienstplan_loesbar, 10.0, mit_zielfunktion=False))
    +
    +    zeige("[2] Lastverteilung 120 Auftraege / 11 Maschinen, 0,5 s Limit",
    +          loese(lastverteilung, 0.5, mit_zielfunktion=True))
    +
    +    zeige("[3] Derselbe Fall mit 10 s Limit",
    +          loese(lastverteilung, 10.0, mit_zielfunktion=True))
    +
    +    zeige("[4] Dienstplan, hoechstens 1 Dienst je Person",
    +          loese(dienstplan_unloesbar, 10.0, mit_zielfunktion=False))
    +
    +    zeige("[5] Variable mit leerem Wertebereich",
    +          loese(modell_fehlerhaft, 10.0, mit_zielfunktion=False))
    +
    +    zeige("[6] Zahlpartition mit 46 grossen Zahlen, 2 s Limit",
    +          loese(zahlpartition, 2.0, mit_zielfunktion=False))
    +
    +    print("\n" + "=" * 78)
    +    print("  DIE DREI, DIE MAN NICHT VERWECHSELN DARF")
    +    print("=" * 78)
    +    print("INFEASIBLE  Es gibt keine Loesung. Eine Aussage ueber Ihr MODELL.")
    +    print("            Mehr Rechenzeit aendert daran nichts.")
    +    print("UNKNOWN     Es wurde keine gefunden. Eine Aussage ueber die ZEIT.")
    +    print("            Ob es eine gibt, ist offen.")
    +    print("MODEL_INVALID  Das Modell ist gar kein gueltiges Modell. Eine")
    +    print("            Aussage ueber Ihren CODE - immer ein Programmierfehler.")
    +    print()
    +    print("Wer diese drei in einem 'else: return None' zusammenfasst, verliert")
    +    print("genau die Information, die zur Fehlersuche noetig waere.")
    +    print("=" * 78)

    Erwartete Ausgabe (Zielwerte in Lauf [2] hardwareabhängig):

    ==============================================================================
       DIE FUENF ANTWORTEN VON CP-SAT
    @@ -1263,7 +1564,7 @@ genau die Information, die zur Fehlersuche noetig waere.
     
     
     
    -

    7.8 Übungsaufgaben

    +

    7.9 Übungsaufgaben

    Lösungen: Abschnitt A.7.

    @@ -1272,10 +1573,11 @@ genau die Information, die zur Fehlersuche noetig waere.

    Aufgabe 7.3 ⭐⭐ — Regeln ergänzen. Erweitern Sie das Vertretungssystem um: (a) Frau Albrecht ist ab Slot 3 nicht mehr verfügbar. (b) Herr Bauer und Frau Koch sollen nicht beide am selben Tag eingesetzt werden. (c) Wer Slot 1 übernimmt, soll bevorzugt auch Slot 2 bekommen (Belohnung statt Strafe).

    Aufgabe 7.4 ⭐⭐ — Infeasibility erzeugen und diagnostizieren. Setzen Sie MAX_VERTRETUNGEN = 0. Was meldet das Programm? Bauen Sie anschließend eine Schlupfvariable je Slot ein („Stunde fällt aus“, Strafe 10 000) und beobachten Sie, wie der Solver statt einer Fehlermeldung einen Notfallplan liefert. Welche Stunde lässt er ausfallen und warum?

    Aufgabe 7.5 ⭐⭐ — Sudoku. Lösen Sie ein 9×9-Sudoku mit CP-SAT. Sie brauchen nur NewIntVar(1, 9, ...) und AddAllDifferent für Zeilen, Spalten und die neun 3×3-Blöcke. Wie viele Zeilen Modellcode benötigen Sie?

    -

    Aufgabe 7.6 ⭐⭐⭐ — Job-Shop erweitern. Ergänzen Sie JobShop_Intervalle.py um: (a) Rüstzeiten von 1 Zeiteinheit zwischen zwei Aufträgen auf derselben Maschine. (b) Fälligkeitstermine je Auftrag mit Strafkosten für Verspätung. (c) Eine zweite Fräse (Kapazität 2 statt 1) mit AddCumulative.

    -

    Aufgabe 7.7 ⭐⭐⭐ — Eigener Dienstplan. Modellieren Sie einen Wochendienstplan: 7 Tage, 3 Schichten (früh/spät/nacht), 8 Mitarbeitende. Regeln: Jede Schicht 2 Personen; nach Nachtschicht mindestens 2 Tage frei; höchstens 5 Arbeitstage in Folge; jede Person mindestens 2 freie Tage pro Woche; Wünsche als weiche Ziele. Geben Sie den Plan als Wochentabelle aus.

    +

    Aufgabe 7.6 ⭐⭐ — Die Grenze der Parallelität finden. Parallele_Suche.py misst 1, 2, 4 und 8 Arbeiter. (a) Ergänzen Sie 16 und 24 (bzw. die Kernzahl Ihres Rechners). Ab wo bringt ein weiterer Arbeiter nichts mehr — und wird es irgendwann wieder schlechter? (b) Setzen Sie random_seed bei jedem Lauf auf einen anderen Wert, aber num_workers = 1. Wie viele verschiedene Pläne kommen jetzt heraus? Was folgt daraus für die Frage, welcher der beiden Parameter die Reproduzierbarkeit sichert? (c) Vergrößern Sie die Instanz auf 15 Aufträge. Ab welcher Größe erreicht der Lauf mit einem Arbeiter das Zeitlimit, während acht noch OPTIMAL melden?

    +

    Aufgabe 7.7 ⭐⭐⭐ — Job-Shop erweitern. Ergänzen Sie JobShop_Intervalle.py um: (a) Rüstzeiten von 1 Zeiteinheit zwischen zwei Aufträgen auf derselben Maschine. (b) Fälligkeitstermine je Auftrag mit Strafkosten für Verspätung. (c) Eine zweite Fräse (Kapazität 2 statt 1) mit AddCumulative.

    +

    Aufgabe 7.8 ⭐⭐⭐ — Eigener Dienstplan. Modellieren Sie einen Wochendienstplan: 7 Tage, 3 Schichten (früh/spät/nacht), 8 Mitarbeitende. Regeln: Jede Schicht 2 Personen; nach Nachtschicht mindestens 2 Tage frei; höchstens 5 Arbeitstage in Folge; jede Person mindestens 2 freie Tage pro Woche; Wünsche als weiche Ziele. Geben Sie den Plan als Wochentabelle aus.


    -

    7.9 Finde den Denkfehler

    +

    7.10 Finde den Denkfehler

    🐛 Finde den Denkfehler: Die Betriebsvereinbarung, die niemanden interessiert

    @@ -1291,138 +1593,138 @@ genau die Information, die zur Fehlersuche noetig waere.

    Das folgende Programm rechnet den Plan für sechs verschiedene Gewichte durch. Es ist damit zugleich die Antwort auf (c) — und die Tabelle, die man in so einem Fall dem Auftraggeber vorlegt, statt ein Gewicht zu raten.

    -
    #!/usr/bin/env python3
    -
    -# Strafgewichte.py
    -"""
    -Kapitel CP-SAT: Warum ein Strafgewicht keine Wichtigkeit ausdrueckt, sondern
    -einen Wechselkurs.
    -
    -Weiche Regeln kommen als Strafterm in die Zielfunktion. Die Frage, die dabei
    -regelmaessig falsch beantwortet wird, lautet: "Wie gross muss die Strafe sein?"
    -Die uebliche Antwort - "je wichtiger, desto groesser" - klingt vernuenftig und
    -fuehrt zuverlaessig zu Plaenen, die niemand unterschreiben will.
    -
    -Der Grund: Zwei Strafgewichte legen nicht Wichtigkeiten fest, sondern einen
    -UMRECHNUNGSKURS. Steht die Serienregel bei 1 und der Wunschfrei-Tag bei 10,
    -dann hat man dem Solver woertlich gesagt: "Zehn Verstoesse gegen die
    -Serienregel sind so schlimm wie ein abgelehnter Wunsch." Er nimmt das
    -ernst - und handelt danach.
    -
    -Szenario: Schichtplan, 6 Personen, 14 Tage, 4 Besetzungen pro Tag.
    -  HART   jeder Tag genau 4 Personen; hoechstens 11 Dienste je Person
    -  WEICH  keine drei Dienste in Folge (Betriebsvereinbarung)
    -  WEICH  Wunschfrei-Tage einhalten (26 Antraege)
    -
    -Benoetigt: numpy, ortools
    -"""
    -
    -from __future__ import annotations
    -
    -import numpy as np
    -from ortools.sat.python import cp_model
    -
    -PERSONEN, TAGE, PRO_TAG = 6, 14, 4
    -MAX_DIENSTE = 11
    -STRAFE_WUNSCH = 10                    # bleibt fest - variiert wird die Serienstrafe
    -
    -RNG = np.random.default_rng(2)
    -WUNSCHFREI = sorted({(p, t) for p in range(PERSONEN) for t in range(TAGE)
    -                     if RNG.random() < 0.35})
    -
    -
    -def plane(strafe_serie: int, zeitlimit: float = 25.0) -> dict:
    -    """Baut den Schichtplan mit dem angegebenen Gewicht fuer die Serienregel."""
    -    modell = cp_model.CpModel()
    -    x = {(p, t): modell.NewBoolVar(f"x_{p}_{t}")
    -         for p in range(PERSONEN) for t in range(TAGE)}
    -
    -    # --- harte Regeln --------------------------------------------------
    -    for t in range(TAGE):
    -        modell.Add(sum(x[p, t] for p in range(PERSONEN)) == PRO_TAG)
    -    for p in range(PERSONEN):
    -        modell.Add(sum(x[p, t] for t in range(TAGE)) <= MAX_DIENSTE)
    -
    -    # --- weiche Regel 1: keine drei Dienste in Folge --------------------
    -    # serie[p, t] = 1  <=>  Person p arbeitet an t, t+1 UND t+2.
    -    # Die Ungleichung erzwingt das nur in eine Richtung; das genuegt, weil
    -    # der Solver serie minimiert - er wuerde die Variable nie freiwillig
    -    # auf 1 setzen.
    -    strafterme = []
    -    serien = []
    -    for p in range(PERSONEN):
    -        for t in range(TAGE - 2):
    -            serie = modell.NewBoolVar(f"serie_{p}_{t}")
    -            modell.Add(x[p, t] + x[p, t + 1] + x[p, t + 2] <= 2 + serie)
    -            serien.append(serie)
    -            strafterme.append(serie * strafe_serie)
    -
    -    # --- weiche Regel 2: Wunschfrei ------------------------------------
    -    wunschverstoesse = []
    -    for p, t in WUNSCHFREI:
    -        wunschverstoesse.append(x[p, t])
    -        strafterme.append(x[p, t] * STRAFE_WUNSCH)
    -
    -    modell.Minimize(sum(strafterme))
    -
    -    loeser = cp_model.CpSolver()
    -    loeser.parameters.max_time_in_seconds = zeitlimit
    -    loeser.parameters.num_workers = 1          # reproduzierbare Buchausgabe
    -    loeser.parameters.random_seed = 1
    -    status = loeser.Solve(modell)
    -    if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
    -        raise RuntimeError(f"Kein Plan: {loeser.StatusName(status)}")
    -
    -    return {
    -        "status": loeser.StatusName(status),
    -        "zielwert": loeser.ObjectiveValue(),
    -        "serien": sum(loeser.Value(v) for v in serien),
    -        "wunsch_verletzt": sum(loeser.Value(v) for v in wunschverstoesse),
    -    }
    -
    -
    -if __name__ == "__main__":
    -    print("=" * 78)
    -    print("  STRAFGEWICHTE SIND WECHSELKURSE, KEINE WICHTIGKEITEN")
    -    print("=" * 78)
    -    print(f"{PERSONEN} Personen, {TAGE} Tage, {PRO_TAG} Besetzungen pro Tag "
    -          f"= {TAGE * PRO_TAG} Dienste")
    -    print(f"Das sind {TAGE * PRO_TAG / PERSONEN:.1f} Dienste je Person - der Plan ist eng.")
    -    print(f"{len(WUNSCHFREI)} Wunschfrei-Antraege, Strafe je abgelehntem Wunsch: "
    -          f"{STRAFE_WUNSCH}\n")
    -
    -    print(f"{'Strafe je 3er-Serie':>20} {'3er-Serien':>12} {'Wunsch verletzt':>17} "
    -          f"{'Zielwert':>10}")
    -    print("-" * 78)
    -    ergebnisse = {}
    -    for strafe in (1, 2, 5, 10, 30, 100):
    -        e = plane(strafe)
    -        ergebnisse[strafe] = e
    -        print(f"{strafe:>20} {e['serien']:>12} {e['wunsch_verletzt']:>17} "
    -              f"{e['zielwert']:>10.0f}")
    -    print("-" * 78)
    -
    -    niedrig, hoch = ergebnisse[1], ergebnisse[30]
    -    print(f"\nBei Strafe 1 baut der Solver {niedrig['serien']} Drei-Tage-Serien ein.")
    -    print("Die Betriebsvereinbarung steht im Modell - und wird trotzdem")
    -    print("systematisch verletzt. Das ist kein Fehler des Solvers: Bei einem")
    -    print(f"Gewicht von 1 gegen {STRAFE_WUNSCH} lohnt sich jede Serie, solange sie")
    -    print("auch nur einen Zehntel-Wunsch rettet.")
    -    print(f"\nBei Strafe 30 sind es {hoch['serien']} Serien - dafuer werden")
    -    print(f"{hoch['wunsch_verletzt']} statt {niedrig['wunsch_verletzt']} Wuensche abgelehnt.")
    -    print("Beide Plaene sind optimal. Sie beantworten nur verschiedene Fragen.")
    -    print()
    -    print("Die Frage lautet also nie 'wie wichtig ist mir diese Regel?', sondern:")
    -    print("  'Wie viele abgelehnte Wuensche bin ich bereit zu akzeptieren,")
    -    print("   um eine Drei-Tage-Serie zu vermeiden?'")
    -    print("Wer darauf keine Zahl nennen kann, hat das Problem noch nicht")
    -    print("verstanden - und sollte diese Tabelle dem Auftraggeber vorlegen,")
    -    print("statt ein Gewicht zu raten.")
    -    print()
    -    print("Und die wichtigste Konsequenz: Eine Regel, die NIE gebrochen werden")
    -    print("darf, gehoert nicht in die Zielfunktion, sondern unter die harten")
    -    print("Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.")
    -    print("=" * 78)
    +
    #!/usr/bin/env python3
    +
    +# Strafgewichte.py
    +"""
    +Kapitel CP-SAT: Warum ein Strafgewicht keine Wichtigkeit ausdrueckt, sondern
    +einen Wechselkurs.
    +
    +Weiche Regeln kommen als Strafterm in die Zielfunktion. Die Frage, die dabei
    +regelmaessig falsch beantwortet wird, lautet: "Wie gross muss die Strafe sein?"
    +Die uebliche Antwort - "je wichtiger, desto groesser" - klingt vernuenftig und
    +fuehrt zuverlaessig zu Plaenen, die niemand unterschreiben will.
    +
    +Der Grund: Zwei Strafgewichte legen nicht Wichtigkeiten fest, sondern einen
    +UMRECHNUNGSKURS. Steht die Serienregel bei 1 und der Wunschfrei-Tag bei 10,
    +dann hat man dem Solver woertlich gesagt: "Zehn Verstoesse gegen die
    +Serienregel sind so schlimm wie ein abgelehnter Wunsch." Er nimmt das
    +ernst - und handelt danach.
    +
    +Szenario: Schichtplan, 6 Personen, 14 Tage, 4 Besetzungen pro Tag.
    +  HART   jeder Tag genau 4 Personen; hoechstens 11 Dienste je Person
    +  WEICH  keine drei Dienste in Folge (Betriebsvereinbarung)
    +  WEICH  Wunschfrei-Tage einhalten (26 Antraege)
    +
    +Benoetigt: numpy, ortools
    +"""
    +
    +from __future__ import annotations
    +
    +import numpy as np
    +from ortools.sat.python import cp_model
    +
    +PERSONEN, TAGE, PRO_TAG = 6, 14, 4
    +MAX_DIENSTE = 11
    +STRAFE_WUNSCH = 10                    # bleibt fest - variiert wird die Serienstrafe
    +
    +RNG = np.random.default_rng(2)
    +WUNSCHFREI = sorted({(p, t) for p in range(PERSONEN) for t in range(TAGE)
    +                     if RNG.random() < 0.35})
    +
    +
    +def plane(strafe_serie: int, zeitlimit: float = 25.0) -> dict:
    +    """Baut den Schichtplan mit dem angegebenen Gewicht fuer die Serienregel."""
    +    modell = cp_model.CpModel()
    +    x = {(p, t): modell.NewBoolVar(f"x_{p}_{t}")
    +         for p in range(PERSONEN) for t in range(TAGE)}
    +
    +    # --- harte Regeln --------------------------------------------------
    +    for t in range(TAGE):
    +        modell.Add(sum(x[p, t] for p in range(PERSONEN)) == PRO_TAG)
    +    for p in range(PERSONEN):
    +        modell.Add(sum(x[p, t] for t in range(TAGE)) <= MAX_DIENSTE)
    +
    +    # --- weiche Regel 1: keine drei Dienste in Folge --------------------
    +    # serie[p, t] = 1  <=>  Person p arbeitet an t, t+1 UND t+2.
    +    # Die Ungleichung erzwingt das nur in eine Richtung; das genuegt, weil
    +    # der Solver serie minimiert - er wuerde die Variable nie freiwillig
    +    # auf 1 setzen.
    +    strafterme = []
    +    serien = []
    +    for p in range(PERSONEN):
    +        for t in range(TAGE - 2):
    +            serie = modell.NewBoolVar(f"serie_{p}_{t}")
    +            modell.Add(x[p, t] + x[p, t + 1] + x[p, t + 2] <= 2 + serie)
    +            serien.append(serie)
    +            strafterme.append(serie * strafe_serie)
    +
    +    # --- weiche Regel 2: Wunschfrei ------------------------------------
    +    wunschverstoesse = []
    +    for p, t in WUNSCHFREI:
    +        wunschverstoesse.append(x[p, t])
    +        strafterme.append(x[p, t] * STRAFE_WUNSCH)
    +
    +    modell.Minimize(sum(strafterme))
    +
    +    loeser = cp_model.CpSolver()
    +    loeser.parameters.max_time_in_seconds = zeitlimit
    +    loeser.parameters.num_workers = 1          # reproduzierbare Buchausgabe
    +    loeser.parameters.random_seed = 1
    +    status = loeser.Solve(modell)
    +    if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
    +        raise RuntimeError(f"Kein Plan: {loeser.StatusName(status)}")
    +
    +    return {
    +        "status": loeser.StatusName(status),
    +        "zielwert": loeser.ObjectiveValue(),
    +        "serien": sum(loeser.Value(v) for v in serien),
    +        "wunsch_verletzt": sum(loeser.Value(v) for v in wunschverstoesse),
    +    }
    +
    +
    +if __name__ == "__main__":
    +    print("=" * 78)
    +    print("  STRAFGEWICHTE SIND WECHSELKURSE, KEINE WICHTIGKEITEN")
    +    print("=" * 78)
    +    print(f"{PERSONEN} Personen, {TAGE} Tage, {PRO_TAG} Besetzungen pro Tag "
    +          f"= {TAGE * PRO_TAG} Dienste")
    +    print(f"Das sind {TAGE * PRO_TAG / PERSONEN:.1f} Dienste je Person - der Plan ist eng.")
    +    print(f"{len(WUNSCHFREI)} Wunschfrei-Antraege, Strafe je abgelehntem Wunsch: "
    +          f"{STRAFE_WUNSCH}\n")
    +
    +    print(f"{'Strafe je 3er-Serie':>20} {'3er-Serien':>12} {'Wunsch verletzt':>17} "
    +          f"{'Zielwert':>10}")
    +    print("-" * 78)
    +    ergebnisse = {}
    +    for strafe in (1, 2, 5, 10, 30, 100):
    +        e = plane(strafe)
    +        ergebnisse[strafe] = e
    +        print(f"{strafe:>20} {e['serien']:>12} {e['wunsch_verletzt']:>17} "
    +              f"{e['zielwert']:>10.0f}")
    +    print("-" * 78)
    +
    +    niedrig, hoch = ergebnisse[1], ergebnisse[30]
    +    print(f"\nBei Strafe 1 baut der Solver {niedrig['serien']} Drei-Tage-Serien ein.")
    +    print("Die Betriebsvereinbarung steht im Modell - und wird trotzdem")
    +    print("systematisch verletzt. Das ist kein Fehler des Solvers: Bei einem")
    +    print(f"Gewicht von 1 gegen {STRAFE_WUNSCH} lohnt sich jede Serie, solange sie")
    +    print("auch nur einen Zehntel-Wunsch rettet.")
    +    print(f"\nBei Strafe 30 sind es {hoch['serien']} Serien - dafuer werden")
    +    print(f"{hoch['wunsch_verletzt']} statt {niedrig['wunsch_verletzt']} Wuensche abgelehnt.")
    +    print("Beide Plaene sind optimal. Sie beantworten nur verschiedene Fragen.")
    +    print()
    +    print("Die Frage lautet also nie 'wie wichtig ist mir diese Regel?', sondern:")
    +    print("  'Wie viele abgelehnte Wuensche bin ich bereit zu akzeptieren,")
    +    print("   um eine Drei-Tage-Serie zu vermeiden?'")
    +    print("Wer darauf keine Zahl nennen kann, hat das Problem noch nicht")
    +    print("verstanden - und sollte diese Tabelle dem Auftraggeber vorlegen,")
    +    print("statt ein Gewicht zu raten.")
    +    print()
    +    print("Und die wichtigste Konsequenz: Eine Regel, die NIE gebrochen werden")
    +    print("darf, gehoert nicht in die Zielfunktion, sondern unter die harten")
    +    print("Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.")
    +    print("=" * 78)

    Erwartete Ausgabe:

    ==============================================================================
       STRAFGEWICHTE SIND WECHSELKURSE, KEINE WICHTIGKEITEN
    @@ -1475,7 +1777,7 @@ Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.
     
     
     
    -

    7.10 Micro-Quiz

    +

    7.11 Micro-Quiz

    ❓ Micro-Quiz 7: Drei Fragen zum Selbstcheck

    @@ -1486,7 +1788,7 @@ Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.

    -

    7.11 Selbsttest

    +

    7.12 Selbsttest

    Antworten: Anhang A.

    @@ -1495,21 +1797,24 @@ Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.
  • Welchen Vorteil hat AddAllDifferent(x) gegenüber \binom{n}{2} Einzelbedingungen?
  • Was ist eine Intervallvariable, und welche Bedingung garantiert sie automatisch?
  • Warum sollten möglichst wenige Bedingungen hart formuliert werden?
  • -
  • Warum ist die Aufschlüsselung der Strafkosten in der Praxis wichtiger als der Zielwert selbst?
  • +
  • Warum ist die Aufschlüsselung der Strafkosten in der Praxis wichtiger als der Zielwert
  • +
  • Sie setzen random_seed = 1, bekommen aber trotzdem bei jedem Lauf einen anderen Plan. Woran liegt das, und was hilft?
  • +
  • Warum kann ein CP-SAT-Lauf mit acht Arbeitern mehr als achtmal schneller sein als mit einem? selbst?

  • -

    7.12 Zusammenfassung

    +

    7.13 Zusammenfassung

      -
    • CP denkt in Wertebereichen und Regeln, nicht in Ungleichungen und Schranken.
    • -
    • Propagation streicht unmögliche Werte, bevor gesucht wird; Conflict Learning verhindert, dieselbe Sackgasse zweimal zu betreten.
    • -
    • Globale Constraints wie , und ersetzen ganze Blöcke von Big-M-Ungleichungen und propagieren stärker.
    • -
    • Intervallvariablen sind das Werkzeug für Zeitplanung; AddNoOverlap erledigt in einer Zeile, was in MILP dutzende Disjunktionen kostet.
    • -
    • Weiche Ziele mit Strafkosten machen Modelle robust gegen INFEASIBLE — und die Kostenzerlegung macht sie erklärbar.
    • -
    • Strafgewichte sind Wechselkurse, keine Wichtigkeiten. Rechnen Sie mehrere Gewichte durch und lassen Sie den Auftraggeber eine Zeile der Tabelle wählen. Und was nie gebrochen werden darf, gehört unter die harten Nebenbedingungen: Alles, was einen Preis hat, wird irgendwann gekauft.
    • -
    • Fünf Antworten, nicht zwei. INFEASIBLE (es gibt keine Lösung), UNKNOWN (es wurde keine gefunden) und MODEL_INVALID (Ihr Code ist fehlerhaft) bedeuten Grundverschiedenes. Wer sie in ein else zusammenfasst, verliert die Information zur Fehlersuche.
    • -
    • Bei mehrdeutigen Optima nie auf einen bestimmten Plan testen — nur auf den Zielwert und die Einhaltung aller Regeln.
    • +
    • CP denkt in Wertebereichen und Regeln, nicht in Ungleichungen und Schranken.

    • +
    • Propagation streicht unmögliche Werte, bevor gesucht wird; Conflict Learning verhindert, dieselbe Sackgasse zweimal zu betreten.

    • +
    • Globale Constraints wie , und ersetzen ganze Blöcke von Big-M-Ungleichungen und propagieren stärker.

    • +
    • Intervallvariablen sind das Werkzeug für Zeitplanung; AddNoOverlap erledigt in einer Zeile, was in MILP dutzende Disjunktionen kostet.

    • +
    • Weiche Ziele mit Strafkosten machen Modelle robust gegen INFEASIBLE — und die Kostenzerlegung macht sie erklärbar.

    • +
    • Strafgewichte sind Wechselkurse, keine Wichtigkeiten. Rechnen Sie mehrere Gewichte durch und lassen Sie den Auftraggeber eine Zeile der Tabelle wählen. Und was nie gebrochen werden darf, gehört unter die harten Nebenbedingungen: Alles, was einen Preis hat, wird irgendwann gekauft.

    • +
    • Fünf Antworten, nicht zwei. INFEASIBLE (es gibt keine Lösung), UNKNOWN (es wurde keine gefunden) und MODEL_INVALID (Ihr Code ist fehlerhaft) bedeuten Grundverschiedenes. Wer sie in ein else zusammenfasst, verliert die Information zur Fehlersuche.

    • +
    • Bei mehrdeutigen Optima nie auf einen bestimmten Plan testen — nur auf den Zielwert und die Einhaltung aller Regeln.

    • +
    • Ein paralleler Solver hat zwei Zufallsquellen: den Seed und die Uhr. Nur die erste lässt sich setzen. Reproduzierbar wird ein Lauf erst mit num_workers = 1 und Seed — gemessen in Abschnitt 7.7: schon zwei Arbeiter liefern bei identischem Seed drei verschiedene Pläne zum selben Zielwert.

    • +
    • Parallele Suche kann überlinear beschleunigen, weil CP-SAT nicht dieselbe Suche vervielfacht, sondern verschiedene Strategien nebeneinander laufen lässt, die einander ihre Schranken mitteilen. Ausblick. Kapitel 8 behandelt Probleme, die sich am besten als Graph begreifen lassen: Flüsse durch Netzwerke, Zuordnungen und die Tourenplanung mit Fahrzeugen und Zeitfenstern.

    -

    Ausblick. Kapitel 8 behandelt Probleme, die sich am besten als Graph begreifen lassen: Flüsse durch Netzwerke, Zuordnungen und die Tourenplanung mit Fahrzeugen und Zeitfenstern.

    diff --git a/OR_HTML_04/gesamtdokument.html b/OR_HTML_04/gesamtdokument.html index 4267787..8def754 100644 --- a/OR_HTML_04/gesamtdokument.html +++ b/OR_HTML_04/gesamtdokument.html @@ -626,12 +626,17 @@
  • 7.4 Globale Constraints — die Bausteine von CP-SAT
  • 7.5 Praxisfall: Dynamisches Vertretungssystem
  • 7.6 Intervallvariablen: Job-Shop-Scheduling
  • -
  • 7.7 Die fünf Antworten von CP-SAT
  • -
  • 7.8 Übungsaufgaben
  • -
  • 7.9 Finde den Denkfehler
  • -
  • 7.10 Micro-Quiz
  • -
  • 7.11 Selbsttest
  • -
  • 7.12 Zusammenfassung
  • +
  • 7.7 Parallele Suche: was num_workers wirklich bewirkt +
  • +
  • 7.8 Die fünf Antworten von CP-SAT
  • +
  • 7.9 Übungsaufgaben
  • +
  • 7.10 Finde den Denkfehler
  • +
  • 7.11 Micro-Quiz
  • +
  • 7.12 Selbsttest
  • +
  • 7.13 Zusammenfassung
  • Kapitel 8: Graphen, Flüsse und Touren — Min-Cost-Flow, Matching und VRP
      @@ -1676,241 +1681,246 @@ Alles bereit — Sie können mit dem ersten Kapitel beginnen.
  • + + + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -7360,7 +7370,7 @@ Drei Dinge, die deshalb vor dem ersten Modell geklaert gehoeren:
    Kapitel 7
    Parallele_Suche.pynum_workers: Tempo gegen ReproduzierbarkeitKapitel 7
    CP_SAT_Statusfaelle.py Die fünf Solver-Antworten Kapitel 7
    Strafgewichte.py Gewichte als Wechselkurse Kapitel 7
    Min_Cost_Flow.py Netzwerkfluss Kapitel 8
    Zuordnung_Ungarisch.py Zuordnung, Unimodularität Kapitel 8
    VRP_Flotten_Routing.py CVRPTW Kapitel 8
    VRP_Kapazitaetsfalle.py Vergessene Dimension im Routing Kapitel 8
    Simulated_Annealing.py Lokale Suche, Temperatur kalibrieren Kapitel 9
    Metaheuristik_vs_Exakt.py Der Umschlagpunkt, und die Schranke Kapitel 9
    Large_Neighborhood_Search.py Zerstören und exakt reparieren Kapitel 9
    Spaltengenerierung.py Muster statt Stücke, Master und Pricing Kapitel 10
    QP_Grundlagen.py Konvexität, DCP-Check Kapitel 11
    KKT_Nachweis.py KKT numerisch prüfen Kapitel 11
    Entropie_Maximierte_Allokation.py NLP mit Entropie Kapitel 11
    Lokale_Optima_Multistart.py Lokale Optima, Multistart Kapitel 11
    Fluch_des_Durchschnitts.py Optimum ≠ Mittelwert Kapitel 12
    Monte_Carlo.py Monte-Carlo-Bewertung Kapitel 12
    Stochastische_Optimierung.py Two-Stage mit Recourse Kapitel 12
    Robuste_Optimierung.py Worst-Case-Absicherung Kapitel 12
    Chance_Constraints.py Zusage „mit 95 % Sicherheit“, SOC und Big-M Kapitel 12
    Bellman_Minimalbeispiel.py Rückwärtsinduktion Kapitel 13
    Mehrziel_Pareto.py Pareto-Front, ε-Constraint, Gewichtslücke Kapitel 14
    Predict_then_Optimize.py MSE gegen Entscheidungskosten Kapitel 15
    Strukturbruecke.py Derselbe Code über Werkstatt und Depot Kapitel 16
    Kraftwerkseinsatz.py Unit Commitment unter Windunsicherheit Kapitel 17
    Mehrperiodige_Order_Execution.py Almgren-Chriss Kapitel 13
    Renditen_Vergleich.py Diskret vs. logarithmisch Kapitel 18
    Schaetzrauschen_Demo.py Error-Maximizer messen Kapitel 18
    Finanzdaten_Ledoit_Wolf.py Datenpipeline, Shrinkage Kapitel 18
    Kovarianz_Falle.py Singuläre Kovarianz, Error-Maximizer Kapitel 18
    Markowitz_CVXPY.py GMV, Max Sharpe, Frontier Kapitel 19
    Diversifikation_Demo.py Korrelation und Portfoliorisiko Kapitel 19
    Renditeschaetzung_Falle.py Schätzfehler in erwarteten Renditen Kapitel 19
    VaR_CVaR_Demo.py Fat Tails, Subadditivität Kapitel 20
    CVaR_Portfolio.py CVaR mit Reibung Kapitel 20
    QuantitativeTradingEngine.py Walk-Forward-Backtest Kapitel 21
    Backtest_Fallen.py Fünf Selbsttäuschungen Kapitel 21
    Data_Snooping.py Bestes aus N Versuchen auf Rauschen Kapitel 21
    Infeasibility_Diagnose.py Notfallplan statt Fehler Kapitel 22
    Erklaerbarkeit.py Constraint-Trace, Was-wäre-wenn Kapitel 22
    Constraint_Attribution.py Was kostet welche Bedingung? Managementbericht Kapitel 22
    Betriebsueberwachung.py Status, Gap und Zeitausschöpfung Kapitel 22
    or_kern.py Gemeinsamer Unterbau: Domäne, Status, Prüfung Kapitel 22
    Solverwechsel_CPSAT_HiGHS.py Derselbe Fall in zwei Solvern Kapitel 22
    test_or_kern.py Testsuite für ein Optimierungsmodell Kapitel 23
    Mutationstest.py Findet die Lücken der eigenen Tests Kapitel 23
    Benchmark_Skalierung.py Aufbau gegen Lösen, vier Bibliotheken Kapitel 23
    Optimierungsdienst.py Das Modell als HTTP-Dienst Kapitel 23
    Konfliktsuche.py Deletion Filter: den Widerspruch einkreisen Anhang C

    Bei 3 000 € ändert sich nichts: Die Strafe liegt unter dem Deckungsbeitrag, den die Stammkundenaufträge verdrängen würden. Bei 10 000 € kommt genau Lesart A heraus.

    -

    Daraus folgt eine Einsicht, die das Modellieren erleichtert: Eine harte Regel ist eine weiche mit hinreichend großer Strafe. Man kann also immer weich anfangen und die Strafe hochdrehen, bis die Regel greift — und sieht dabei, was sie kostet. Was ein Strafgewicht darüber hinaus bedeutet und warum zwei Gewichte einen Wechselkurs festlegen und keine Wichtigkeiten, steht in Abschnitt 7.9.

    +

    Daraus folgt eine Einsicht, die das Modellieren erleichtert: Eine harte Regel ist eine weiche mit hinreichend großer Strafe. Man kann also immer weich anfangen und die Strafe hochdrehen, bis die Regel greift — und sieht dabei, was sie kostet. Was ein Strafgewicht darüber hinaus bedeutet und warum zwei Gewichte einen Wechselkurs festlegen und keine Wichtigkeiten, steht in Abschnitt 7.10.

    Teil 4: Ein Quartal später

    Die harte Lesart A wurde eingebaut, alles lief drei Monate lang. Dann fragt Weber einen Auftrag über 320 Maschinenstunden an — mehr, als der ganze Zeitraum hergibt.

    Lesart A (hart):  UNZULAESSIG - der Nachtlauf bricht ab
    @@ -10143,68 +10153,69 @@ mit Warm-Start                 8.44s       150     69,134.14
    loeser = cp_model.CpSolver() loeser.parameters.max_time_in_seconds = zeitlimit - # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist. - # Im Produktivbetrieb laesst man beides auf den Standardwerten. - loeser.parameters.num_workers = 1 - loeser.parameters.random_seed = 1 - - t0 = time.perf_counter() - status = loeser.Solve(modell) - dauer_s = time.perf_counter() - t0 - - if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE): - raise RuntimeError(f"Kein Plan gefunden: {loeser.StatusName(status)}") - return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer_s - + # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist - + # der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming). + # Im Produktivbetrieb laesst man beides auf den Standardwerten. + loeser.parameters.num_workers = 1 + loeser.parameters.random_seed = 1 + + t0 = time.perf_counter() + status = loeser.Solve(modell) + dauer_s = time.perf_counter() - t0 + + if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE): + raise RuntimeError(f"Kein Plan gefunden: {loeser.StatusName(status)}") + return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer_s -if __name__ == "__main__": - print("=" * 78) - print(" WARM-START: WENN DIE HEURISTIK MEHR WEISS ALS DER SOLVER") - print("=" * 78) - print("Lastverteilung: Auftraege auf gleichartige Maschinen verteilen,") - print("sodass die letzte Maschine so frueh wie moeglich fertig wird.\n") - - print(f"{'Instanz':<22} {'Variante':<22} {'Makespan':>9} {'Zeit':>9} " - f"{'Faktor':>8}") - print("-" * 78) - - for n_auftraege, n_maschinen in [(60, 7), (80, 9)]: - dauer = RNG.integers(10, 90, n_auftraege) - start, lpt_wert = lpt_heuristik(dauer, n_maschinen) - untere_schranke = dauer.sum() / n_maschinen - - instanz = f"{n_auftraege} Auftr., {n_maschinen} Masch." - print(f"{instanz:<22} {'LPT-Heuristik':<22} {lpt_wert:>9} " - f"{'< 0.001s':>9} {'':>8}") - - _, ziel_kalt, zeit_kalt = loese(dauer, n_maschinen, 60.0) - print(f"{'':<22} {'CP-SAT kalt':<22} {ziel_kalt:>9} " - f"{zeit_kalt:>8.3f}s {'1,0x':>8}") - - _, ziel_warm, zeit_warm = loese(dauer, n_maschinen, 60.0, hinweis=start) - print(f"{'':<22} {'CP-SAT + LPT-Hinweis':<22} {ziel_warm:>9} " - f"{zeit_warm:>8.3f}s {zeit_kalt / zeit_warm:>7.1f}x") - - assert ziel_kalt == ziel_warm, \ - "Der Hinweis darf das Optimum nicht veraendern - nur den Weg dorthin!" - print(f"{'':<22} {'untere Schranke':<22} {untere_schranke:>9.1f}") - print("-" * 78) - - print("\nDrei Beobachtungen:") - print("1. Der Hinweis aendert das ERGEBNIS nicht - beide Laeufe finden") - print(" dasselbe Optimum. Er aendert nur, wie lange der Beweis dauert.") - print(" Genau deshalb ist ein Warm-Start ungefaehrlich: Ein schlechter") - print(" Hinweis kostet Zeit, er verfaelscht aber nie die Loesung.") - print("2. Die LPT-Heuristik liegt schon sehr nah am Optimum. Ihr Wert fuer") - print(" den Solver liegt weniger in der Qualitaet als darin, dass sie") - print(" SOFORT da ist - der Solver kann von Beginn an alles verwerfen,") - print(" was schlechter ist.") - print("3. Der Faktor schwankt von Instanz zu Instanz - oben 2,2x und 1,4x -") - print(" und laesst sich NICHT aus der Problemgroesse ableiten. Er haengt") - print(" davon ab, wie schnell der Solver von allein eine vergleichbar gute") - print(" Loesung findet. Das ist die eigentliche Lehre: Ein Warm-Start ist") - print(" eine Messung wert, keine Glaubensfrage.") - print("=" * 78)
    + +if __name__ == "__main__": + print("=" * 78) + print(" WARM-START: WENN DIE HEURISTIK MEHR WEISS ALS DER SOLVER") + print("=" * 78) + print("Lastverteilung: Auftraege auf gleichartige Maschinen verteilen,") + print("sodass die letzte Maschine so frueh wie moeglich fertig wird.\n") + + print(f"{'Instanz':<22} {'Variante':<22} {'Makespan':>9} {'Zeit':>9} " + f"{'Faktor':>8}") + print("-" * 78) + + for n_auftraege, n_maschinen in [(60, 7), (80, 9)]: + dauer = RNG.integers(10, 90, n_auftraege) + start, lpt_wert = lpt_heuristik(dauer, n_maschinen) + untere_schranke = dauer.sum() / n_maschinen + + instanz = f"{n_auftraege} Auftr., {n_maschinen} Masch." + print(f"{instanz:<22} {'LPT-Heuristik':<22} {lpt_wert:>9} " + f"{'< 0.001s':>9} {'':>8}") + + _, ziel_kalt, zeit_kalt = loese(dauer, n_maschinen, 60.0) + print(f"{'':<22} {'CP-SAT kalt':<22} {ziel_kalt:>9} " + f"{zeit_kalt:>8.3f}s {'1,0x':>8}") + + _, ziel_warm, zeit_warm = loese(dauer, n_maschinen, 60.0, hinweis=start) + print(f"{'':<22} {'CP-SAT + LPT-Hinweis':<22} {ziel_warm:>9} " + f"{zeit_warm:>8.3f}s {zeit_kalt / zeit_warm:>7.1f}x") + + assert ziel_kalt == ziel_warm, \ + "Der Hinweis darf das Optimum nicht veraendern - nur den Weg dorthin!" + print(f"{'':<22} {'untere Schranke':<22} {untere_schranke:>9.1f}") + print("-" * 78) + + print("\nDrei Beobachtungen:") + print("1. Der Hinweis aendert das ERGEBNIS nicht - beide Laeufe finden") + print(" dasselbe Optimum. Er aendert nur, wie lange der Beweis dauert.") + print(" Genau deshalb ist ein Warm-Start ungefaehrlich: Ein schlechter") + print(" Hinweis kostet Zeit, er verfaelscht aber nie die Loesung.") + print("2. Die LPT-Heuristik liegt schon sehr nah am Optimum. Ihr Wert fuer") + print(" den Solver liegt weniger in der Qualitaet als darin, dass sie") + print(" SOFORT da ist - der Solver kann von Beginn an alles verwerfen,") + print(" was schlechter ist.") + print("3. Der Faktor schwankt von Instanz zu Instanz - oben 2,2x und 1,4x -") + print(" und laesst sich NICHT aus der Problemgroesse ableiten. Er haengt") + print(" davon ab, wie schnell der Solver von allein eine vergleichbar gute") + print(" Loesung findet. Das ist die eigentliche Lehre: Ein Warm-Start ist") + print(" eine Messung wert, keine Glaubensfrage.") + print("=" * 78)

    Erwartete Ausgabe (Zeiten hardwareabhängig):

    ==============================================================================
       WARM-START: WENN DIE HEURISTIK MEHR WEISS ALS DER SOLVER
    @@ -10589,6 +10600,7 @@ funktioniert immer.
     Propagation_Demo.py
    CP_SAT_Vertretungssystem.py
    JobShop_Intervalle.py
    +Parallele_Suche.py
    CP_SAT_Statusfaelle.py
    Strafgewichte.py

    Notebook: Notebooks_04/cpsat.ipynb
    @@ -10630,7 +10642,7 @@ Fr -> Abel

    Und jetzt sehen Sie sich das Ergebnis genau an. Cor bekommt keinen einzigen Dienst. Dane arbeitet Montag und Dienstag hintereinander. Ist das ein Fehler?

    Nein — und das ist die wichtigste Lektion dieses Kapitels. Der Plan erfüllt jede Regel, die wir aufgeschrieben haben. Wir haben nur nie nach Fairness gefragt, und wir haben nie gesagt, dass Dienste nicht aufeinanderfolgen sollen. Ein Solver liefert exakt das Bestellte — nicht das Gemeinte.

    -

    🎯 Merksatz Ein Modell, das durchläuft, ist noch kein richtiges Modell. Die eigentliche Arbeit im Constraint Programming ist nicht das Lösen, sondern das vollständige Aufschreiben dessen, was „ein guter Plan“ überhaupt heißt. Wie man Wünsche wie Fairness als weiche Bedingungen mit Strafkosten formuliert, zeigt Abschnitt 7.5 — und Abschnitt 7.9 zeigt, was passiert, wenn man ihr Gewicht falsch wählt.

    +

    🎯 Merksatz Ein Modell, das durchläuft, ist noch kein richtiges Modell. Die eigentliche Arbeit im Constraint Programming ist nicht das Lösen, sondern das vollständige Aufschreiben dessen, was „ein guter Plan“ überhaupt heißt. Wie man Wünsche wie Fairness als weiche Bedingungen mit Strafkosten formuliert, zeigt Abschnitt 7.5 — und Abschnitt 7.10 zeigt, was passiert, wenn man ihr Gewicht falsch wählt.


    7.2 Lernziele

    @@ -10642,6 +10654,7 @@ Fr -> Abel
  • … mit Intervallvariablen ein Maschinenbelegungsproblem modellieren.
  • … harte Regeln und weiche Ziele in einem Modell kombinieren und gewichten.
  • … die fünf CP-SAT-Statusfälle unterscheiden — insbesondere INFEASIBLE (es gibt keine Lösung) von UNKNOWN (es wurde keine gefunden) und MODEL_INVALID (Ihr Code ist fehlerhaft).
  • +
  • … begründen, warum num_workers = 1 für einen reproduzierbaren Lauf nötig ist und ein fester random_seed allein nicht genügt — und was ein Test deshalb prüfen darf.
  • … begründen, warum ein Strafgewicht ein Wechselkurs ist, und welche Regeln deshalb niemals in die Zielfunktion gehören.

  • @@ -11351,6 +11364,7 @@ Erreichter Makespan: 11

    ⚠️ Mehrere gleich gute Pläne — und was das für Ihre Tests bedeutet

    Der kürzeste Makespan beträgt 11, aber es gibt mehrere verschiedene Pläne, die ihn erreichen. Welchen CP-SAT findet, hängt normalerweise davon ab, welcher seiner parallelen Suchstränge zuerst fertig wird — dasselbe Programm liefert dann von Lauf zu Lauf einen anderen (gleich guten) Plan.

    Deshalb setzt das Programm oben num_workers = 1 und random_seed = 1: nur so ist die abgedruckte Ausgabe reproduzierbar. Im Produktivbetrieb lässt man beides weg — mehrere Arbeiter sind deutlich schneller.

    +

    Wie viel schneller, und warum der Seed allein nicht genügt, misst Abschnitt 7.7 an genau diesem Modell.

    Die Lehre für Ihre eigenen Tests: Prüfen Sie bei mehrdeutigen Optima nie auf einen bestimmten Plan, sondern immer auf den Zielwert und auf die Einhaltung aller Regeln. Ein Test wie assert plan == erwarteter_plan besteht sonst mal und scheitert mal, ohne dass sich am Code etwas geändert hätte.

    Bemerkenswert ist die letzte Zeile: Die untere Schranke liegt bei 10, erreicht wurden 11. Die Schranke ist also nicht erreichbar — kein Plan kann 10 schaffen, obwohl weder ein einzelner Auftrag noch eine einzelne Maschine mehr als 10 Zeiteinheiten braucht. Der Grund sind die Wechselwirkungen. Das ist typisch für Scheduling: Einfache Schranken sind selten scharf. Trotzdem sind sie nützlich — sie sagten uns hier, dass wir höchstens 10 % vom theoretischen Ideal entfernt sein können, noch bevor der Solver fertig war.

    @@ -11359,7 +11373,305 @@ Erreichter Makespan: 11

    Eine Intervallvariable bündelt drei Größen (Start, Dauer, Ende) und garantiert intern start + dauer == ende. Der eigentliche Gewinn ist aber AddNoOverlap: Diese eine Zeile ersetzt für k Gänge auf einer Maschine \binom{k}{2} Entweder-Oder-Konstruktionen mit je zwei Big-M-Ungleichungen und einer Binärvariablen. Bei 10 Gängen sind das 45 Disjunktionen — in MILP ein zäher Block, in CP-SAT eine Zeile mit einem spezialisierten Propagator.


    -

    7.7 Die fünf Antworten von CP-SAT

    +

    7.7 Parallele Suche: was num_workers wirklich bewirkt

    +

    An mehreren Stellen dieses Buchs steht loeser.parameters.num_workers = 1, mit dem Kommentar „für eine reproduzierbare Ausgabe“. Darin stecken zwei Behauptungen: dass parallele Suche schneller ist, und dass sie von Lauf zu Lauf andere, gleich gute Lösungen liefert. Beides lässt sich messen — und beim Messen kommt mehr heraus als erwartet.

    +

    Gerechnet wird auf demselben Modell wie Abschnitt 7.6, nur groß genug, dass die Suche wirklich arbeitet: 12 Aufträge auf 10 Maschinen, jeder Auftrag besucht jede Maschine. Entscheidend ist, dass random_seed in jedem Lauf denselben Wert hat.

    +
    #!/usr/bin/env python3
    +
    +# Parallele_Suche.py
    +"""
    +Kapitel CP-SAT: Was mehrere Arbeiter wirklich bringen - und was sie kosten.
    +
    +Das Buch setzt an mehreren Stellen 'num_workers = 1', damit die abgedruckte
    +Ausgabe reproduzierbar ist. Behauptet wird dabei zweierlei: dass parallele
    +Suche schneller ist, und dass sie von Lauf zu Lauf verschiedene, gleich gute
    +Loesungen findet. Beides wird hier gemessen statt geglaubt.
    +
    +Gerechnet wird auf einem Job-Shop wie in JobShop_Intervalle.py, nur gross
    +genug, dass die Suche wirklich arbeitet: 12 Auftraege auf 10 Maschinen,
    +jeder Auftrag besucht jede Maschine.
    +
    +Zwei Messungen:
    +  1. Dieselbe Aufgabe mit 1, 2, 4 und 8 Arbeitern - Laufzeit und Ergebnis.
    +  2. Dieselbe Konfiguration mehrfach, bei FESTEM random_seed - wie oft kommt
    +     derselbe Plan heraus?
    +
    +Die zweite ist die wichtigere: Sie entscheidet, wie man Tests schreibt.
    +
    +Achtung, Laufzeiten sind hardwareabhaengig. Die ZAHL der verschiedenen
    +Plaene ist es auch - und genau das ist die Aussage.
    +
    +Benoetigt: numpy, ortools
    +"""
    +
    +from __future__ import annotations
    +
    +import collections
    +import time
    +
    +import numpy as np
    +from ortools.sat.python import cp_model
    +
    +AUFTRAEGE = 12
    +MASCHINEN = 10
    +INSTANZ_SEED = 20260908
    +SOLVER_SEED = 1          # bleibt ueber ALLE Laeufe gleich - das ist der Punkt
    +ARBEITERZAHLEN = (1, 2, 4, 8)
    +WIEDERHOLUNGEN = 4
    +ZEITLIMIT = 120.0
    +
    +
    +def baue_instanz() -> list[list[tuple[int, int]]]:
    +    """Klassischer Job-Shop: Jeder Auftrag besucht jede Maschine genau einmal,
    +    in einer eigenen zufaelligen Reihenfolge."""
    +    rng = np.random.default_rng(INSTANZ_SEED)
    +    return [[(int(m), int(rng.integers(2, 20))) for m in rng.permutation(MASCHINEN)]
    +            for _ in range(AUFTRAEGE)]
    +
    +
    +def loese(auftraege, arbeiter: int):
    +    """Minimiert den Makespan. Gibt Status, Zielwert, Laufzeit und den Plan
    +    zurueck - den Plan als Tupel aller Startzeiten, damit sich zwei Laeufe
    +    vergleichen lassen."""
    +    horizont = sum(dauer for auftrag in auftraege for _, dauer in auftrag)
    +    modell = cp_model.CpModel()
    +    Gang = collections.namedtuple("Gang", "start ende intervall")
    +    plaene: dict[tuple[int, int], Gang] = {}
    +    je_maschine = collections.defaultdict(list)
    +
    +    for a, auftrag in enumerate(auftraege):
    +        for g, (maschine, dauer) in enumerate(auftrag):
    +            start = modell.NewIntVar(0, horizont, f"start_{a}_{g}")
    +            ende = modell.NewIntVar(0, horizont, f"ende_{a}_{g}")
    +            intervall = modell.NewIntervalVar(start, dauer, ende, f"iv_{a}_{g}")
    +            plaene[a, g] = Gang(start, ende, intervall)
    +            je_maschine[maschine].append(intervall)
    +
    +    for maschine in range(MASCHINEN):
    +        modell.AddNoOverlap(je_maschine[maschine])
    +    for a, auftrag in enumerate(auftraege):
    +        for g in range(len(auftrag) - 1):
    +            modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende)
    +
    +    makespan = modell.NewIntVar(0, horizont, "makespan")
    +    modell.AddMaxEquality(
    +        makespan, [plaene[a, len(auftrag) - 1].ende
    +                   for a, auftrag in enumerate(auftraege)])
    +    modell.Minimize(makespan)
    +
    +    loeser = cp_model.CpSolver()
    +    loeser.parameters.num_workers = arbeiter
    +    loeser.parameters.random_seed = SOLVER_SEED
    +    loeser.parameters.max_time_in_seconds = ZEITLIMIT
    +
    +    beginn = time.perf_counter()
    +    status = loeser.Solve(modell)
    +    dauer = time.perf_counter() - beginn
    +
    +    if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
    +        return loeser.StatusName(status), None, dauer, None
    +    plan = tuple(loeser.Value(plaene[a, g].start)
    +                 for a, auftrag in enumerate(auftraege)
    +                 for g in range(len(auftrag)))
    +    return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer, plan
    +
    +
    +if __name__ == "__main__":
    +    auftraege = baue_instanz()
    +    gaenge = sum(len(a) for a in auftraege)
    +
    +    print("=" * 78)
    +    print("  WAS MEHRERE ARBEITER WIRKLICH BRINGEN")
    +    print("=" * 78)
    +    print(f"Job-Shop: {AUFTRAEGE} Auftraege, {MASCHINEN} Maschinen, "
    +          f"{gaenge} Arbeitsgaenge.")
    +    print(f"random_seed = {SOLVER_SEED} in JEDEM Lauf, "
    +          f"{WIEDERHOLUNGEN} Wiederholungen je Arbeiterzahl.\n")
    +
    +    # Jede Konfiguration mehrfach - ein einzelner Zeitwert sagt nichts.
    +    ergebnisse: dict[int, list[tuple]] = {}
    +    for arbeiter in ARBEITERZAHLEN:
    +        ergebnisse[arbeiter] = [loese(auftraege, arbeiter)
    +                                for _ in range(WIEDERHOLUNGEN)]
    +
    +    print("=" * 78)
    +    print("  (1) Laufzeit: was die Arbeiter bringen")
    +    print("=" * 78)
    +    print(f"{'Arbeiter':>9} {'Status':>9} {'Makespan':>9} "
    +          f"{'schnellste':>11} {'langsamste':>11} {'Beschleunigung':>15}")
    +    print("-" * 78)
    +    basis_zeit = min(d for _, _, d, _ in ergebnisse[1])
    +    for arbeiter in ARBEITERZAHLEN:
    +        laeufe = ergebnisse[arbeiter]
    +        zeiten = [d for _, _, d, _ in laeufe]
    +        ziele = {o for _, o, _, _ in laeufe}
    +        status = {s for s, _, _, _ in laeufe}
    +        print(f"{arbeiter:>9} {'/'.join(sorted(status)):>9} "
    +              f"{'/'.join(str(z) for z in sorted(ziele)):>9} "
    +              f"{min(zeiten):>10.2f}s {max(zeiten):>10.2f}s "
    +              f"{basis_zeit / min(zeiten):>14.1f}x")
    +
    +    beste = min(ARBEITERZAHLEN, key=lambda w: min(d for _, _, d, _ in ergebnisse[w]))
    +    faktor = basis_zeit / min(d for _, _, d, _ in ergebnisse[beste])
    +    print(f"\n  Am schnellsten sind {beste} Arbeiter: Faktor {faktor:.1f} gegenueber")
    +    print(f"  einem einzigen - also MEHR, als {beste} Kerne hergeben sollten.")
    +    print(f"  Das ist kein Messfehler. CP-SAT laesst nicht {beste}-mal dieselbe Suche")
    +    print("  laufen, sondern verschiedene Strategien nebeneinander, die einander")
    +    print("  ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der")
    +    print("  'zweite Kern', sondern ein anderes Verfahren.")
    +    letzte = ARBEITERZAHLEN[-1]
    +    if beste != letzte:
    +        zeit_letzte = min(d for _, _, d, _ in ergebnisse[letzte])
    +        print(f"\n  Und mehr ist nicht immer besser: {letzte} Arbeiter brauchen "
    +              f"{zeit_letzte:.2f}s")
    +        print(f"  gegenueber {min(d for _, _, d, _ in ergebnisse[beste]):.2f}s bei "
    +              f"{beste}. Ab einer gewissen Zahl kosten Abstimmung")
    +        print("  und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt.")
    +
    +    print("\n" + "=" * 78)
    +    print("  (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?")
    +    print("=" * 78)
    +    print(f"{'Arbeiter':>9} {'Laeufe':>7} {'verschiedene Plaene':>21} "
    +          f"{'Makespan':>10}")
    +    print("-" * 78)
    +    for arbeiter in ARBEITERZAHLEN:
    +        plaene = {p for _, _, _, p in ergebnisse[arbeiter] if p is not None}
    +        ziele = {o for _, o, _, _ in ergebnisse[arbeiter]}
    +        print(f"{arbeiter:>9} {WIEDERHOLUNGEN:>7} {len(plaene):>21} "
    +              f"{'/'.join(str(z) for z in sorted(ziele)):>10}")
    +
    +    einer = len({p for _, _, _, p in ergebnisse[1]})
    +    # Ab welcher Arbeiterzahl bricht die Reproduzierbarkeit? Das ist die
    +    # eigentliche Zahl - nicht, was acht Arbeiter anrichten.
    +    ab = next((w for w in ARBEITERZAHLEN
    +               if len({p for _, _, _, p in ergebnisse[w]}) > 1), None)
    +    print(f"\n  Mit einem Arbeiter: {einer} Plan aus {WIEDERHOLUNGEN} Laeufen.")
    +    if ab is not None:
    +        viele = len({p for _, _, _, p in ergebnisse[ab]})
    +        print(f"  Schon mit {ab} Arbeitern: {viele} verschiedene Plaene aus "
    +              f"{WIEDERHOLUNGEN} Laeufen -")
    +        print("  bei identischem random_seed und identischem Zielwert.")
    +        print(f"  Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit")
    +        print(f"  zu verlieren. {ab} genuegen.")
    +    print("\n  Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt")
    +    print("  fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von")
    +    print("  mehreren zuerst fertig wird. Das entscheidet die Uhr.")
    +
    +    print("\n" + "=" * 78)
    +    print("  (3) Was daraus fuer Tests folgt")
    +    print("=" * 78)
    +    print("  Ein Test der Form  assert plan == erwarteter_plan  besteht mal und")
    +    print("  scheitert mal, ohne dass sich am Code etwas geaendert haette.")
    +    print("  Zu pruefen sind stattdessen:")
    +    print("    * der ZIELWERT (hier in allen Laeufen gleich),")
    +    print("    * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),")
    +    print("    * und der Status - nicht die Gestalt der Loesung.")
    +    print("\n  Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte")
    +    print("  Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.")
    +    print(f"  Das kostet hier den Faktor {faktor:.1f} an Laufzeit und ist genau")
    +    print("  deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.")
    +    print("=" * 78)
    +

    Erwartete Ausgabe (Laufzeiten und die Zahl der verschiedenen Pläne sind hardwareabhängig — genau das ist die Aussage; der Zielwert ist es nicht):

    +
    ==============================================================================
    +  WAS MEHRERE ARBEITER WIRKLICH BRINGEN
    +==============================================================================
    +Job-Shop: 12 Auftraege, 10 Maschinen, 120 Arbeitsgaenge.
    +random_seed = 1 in JEDEM Lauf, 4 Wiederholungen je Arbeiterzahl.
    +
    +==============================================================================
    +  (1) Laufzeit: was die Arbeiter bringen
    +==============================================================================
    + Arbeiter    Status  Makespan  schnellste  langsamste  Beschleunigung
    +------------------------------------------------------------------------------
    +        1   OPTIMAL       183       5.62s       5.82s            1.0x
    +        2   OPTIMAL       183       1.43s       2.04s            3.9x
    +        4   OPTIMAL       183       0.60s       0.66s            9.4x
    +        8   OPTIMAL       183       0.46s       0.65s           12.3x
    +
    +  Am schnellsten sind 8 Arbeiter: Faktor 12.3 gegenueber
    +  einem einzigen - also MEHR, als 8 Kerne hergeben sollten.
    +  Das ist kein Messfehler. CP-SAT laesst nicht 8-mal dieselbe Suche
    +  laufen, sondern verschiedene Strategien nebeneinander, die einander
    +  ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der
    +  'zweite Kern', sondern ein anderes Verfahren.
    +
    +==============================================================================
    +  (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?
    +==============================================================================
    + Arbeiter  Laeufe   verschiedene Plaene   Makespan
    +------------------------------------------------------------------------------
    +        1       4                     1        183
    +        2       4                     3        183
    +        4       4                     3        183
    +        8       4                     4        183
    +
    +  Mit einem Arbeiter: 1 Plan aus 4 Laeufen.
    +  Schon mit 2 Arbeitern: 3 verschiedene Plaene aus 4 Laeufen -
    +  bei identischem random_seed und identischem Zielwert.
    +  Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit
    +  zu verlieren. 2 genuegen.
    +
    +  Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt
    +  fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von
    +  mehreren zuerst fertig wird. Das entscheidet die Uhr.
    +
    +==============================================================================
    +  (3) Was daraus fuer Tests folgt
    +==============================================================================
    +  Ein Test der Form  assert plan == erwarteter_plan  besteht mal und
    +  scheitert mal, ohne dass sich am Code etwas geaendert haette.
    +  Zu pruefen sind stattdessen:
    +    * der ZIELWERT (hier in allen Laeufen gleich),
    +    * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),
    +    * und der Status - nicht die Gestalt der Loesung.
    +
    +  Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte
    +  Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.
    +  Das kostet hier den Faktor 12.3 an Laufzeit und ist genau
    +  deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.
    +==============================================================================
    +

    Was die Messung zeigt

    +

    Erstens: Die Beschleunigung ist überlinear. Acht Arbeiter sind im abgedruckten Lauf nicht achtmal, sondern gut zwölfmal schneller als einer. Das sieht nach Messfehler aus, ist aber die korrekte Beschreibung dessen, was CP-SAT tut: Es lässt nicht achtmal dieselbe Suche auf verschiedenen Kernen laufen, sondern verschiedene Strategien nebeneinander — Suche mit LP-Relaxation, Suche mit anderen Verzweigungsregeln, lokale Suche —, und die teilen einander ihre Schranken mit. Der zweite Arbeiter ist deshalb nicht „der zweite Kern“, sondern ein anderes Verfahren, das zufällig auch rechnet.

    +

    Daraus folgt zweierlei. Ein Speedup über der Kernzahl ist bei CP-SAT normal und kein Grund zum Misstrauen. Und der Zugewinn flacht ab: Zwischen vier und acht Arbeitern lag in unseren Wiederholungen mal das eine, mal das andere vorn — ab einer gewissen Zahl kosten Abstimmung und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt. Auch hier gilt: messen, nicht schätzen.

    +

    Zweitens — und das ist der Befund, der die Testpraxis bestimmt: Der Seed genügt nicht. Bei einem Arbeiter liefern vier Läufe einen Plan. Schon bei zwei Arbeitern liefern vier Läufe drei verschiedene — bei identischem random_seed und identischem Zielwert 183.

    +

    Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit zu verlieren. Zwei genügen. Der Grund ist einfach, sobald man ihn ausspricht: Der Seed legt fest, wie ein einzelner Suchstrang würfelt. Er legt nicht fest, welcher von mehreren zuerst fertig wird — das entscheidet die Uhr, und die ist bei jedem Lauf anders.

    +
    +

    ⚠️ random_seed allein macht nichts reproduzierbar Ein häufiger Irrtum: „Ich habe doch einen Seed gesetzt.“ Für einen Zufallsgenerator stimmt das. Für einen parallelen Solver nicht — dort ist die zweite Zufallsquelle die Ausführungsreihenfolge, und die lässt sich nicht mit einem Parameter festnageln. Reproduzierbar wird der Lauf erst mit num_workers = 1 und Seed. Wer nur eines von beidem setzt, hat einen Test, der gelegentlich grundlos rot wird.

    +
    +

    Was daraus für Tests folgt

    +

    Der Zielwert war in allen sechzehn Läufen derselbe: 183. Die Gestalt der Lösung war es nicht. Genau daran entscheidet sich, was ein Test prüfen darf:

    + ++++ + + + + + + + + + + + + + + + + + + + + +
    PrüfenNicht prüfen
    den Zielwert — er ist eindeutig, wenn das Modell optimal gelöst wurdedie Gestalt des Plans (assert plan == erwarteter_plan)
    die Einhaltung aller Regeln über eine Abnahmeprüfung (Abschnitt 22.6)die Reihenfolge, in der gleichwertige Aufträge zugewiesen wurden
    den Status (OPTIMAL, FEASIBLE, INFEASIBLE)die Laufzeit als feste Zahl
    +

    Wer doch einen bestimmten Plan braucht — für eine abgedruckte Ausgabe, für einen Regressionstest —, setzt num_workers = 1. Das kostet hier den Faktor 12 an Laufzeit, und genau deshalb ist es eine Entscheidung für den Test, nicht für den Betrieb. In Produktion lässt man beide Parameter auf ihren Standardwerten.

    +
    +

    🎯 Merksatz Ein paralleler Solver hat zwei Zufallsquellen: den Seed und die Uhr. Nur die erste lässt sich setzen. Wer Reproduzierbarkeit braucht, muss die zweite abschalten — und das heißt num_workers = 1.

    +
    +
    +

    7.8 Die fünf Antworten von CP-SAT

    Abschnitt 6.8 hat gezeigt, warum OPTIMAL bei ganzzahligen Problemen nicht der Normalfall ist. Bei CP-SAT kommt eine Besonderheit hinzu: Es gibt fünf verschiedene Antworten, und drei davon werden regelmäßig verwechselt.

    @@ -11407,229 +11719,229 @@ Erreichter Makespan: 11

    Und MODEL_INVALID ist von beiden nochmals verschieden: Es ist immer ein Programmierfehler, kein fachliches Problem. Der klassische Fall ist eine Variable mit leerem Wertebereich — NewIntVar(mindestbesetzung, kapazitaet, ...), wobei die Daten vertauscht ankamen und die untere Schranke über der oberen liegt.

    Das folgende Programm erzeugt jeden Fall absichtlich und zeigt die passende Auswertung.

    -
    #!/usr/bin/env python3
    -
    -# CP_SAT_Statusfaelle.py
    -"""
    -Kapitel CP-SAT: Alle fuenf Antworten von CP-SAT - und was jede bedeutet.
    -
    -Der haeufigste Fehler beim Einsatz von CP-SAT im Betrieb ist eine Zeile wie
    -
    -    if status == cp_model.OPTIMAL:
    -        ...verwerte die Loesung...
    -
    -Sie wirft in drei von fuenf Faellen etwas weg, das man haette gebrauchen
    -koennen, und verschweigt in einem weiteren Fall einen Modellierungsfehler.
    -
    -Dieses Programm erzeugt jeden Statusfall absichtlich und zeigt die passende
    -Reaktion:
    -
    -    OPTIMAL        beweisbar bestmoeglich          -> ausfuehren
    -    FEASIBLE       zulaessig, Zeit war um          -> Gap pruefen, dann entscheiden
    -    INFEASIBLE     kein Plan existiert             -> harte Regeln lockern
    -    MODEL_INVALID  das Modell ist fehlerhaft       -> Programmierfehler beheben
    -    UNKNOWN        nichts gefunden, Zeit war um    -> mehr Zeit oder Heuristik
    -
    -Benoetigt: numpy, ortools
    -"""
    -
    -from __future__ import annotations
    -
    -from dataclasses import dataclass
    -
    -import numpy as np
    -from ortools.sat.python import cp_model
    -
    -
    -@dataclass
    -class Auswertung:
    -    """Was nach einem CP-SAT-Lauf tatsaechlich feststeht."""
    -    status: str
    -    hat_loesung: bool
    -    beweisbar_optimal: bool
    -    zielwert: float | None
    -    schranke: float | None
    -    gap: float | None
    -    empfehlung: str
    -
    -
    -def werte_aus(loeser: cp_model.CpSolver, status: int,
    -              mit_zielfunktion: bool) -> Auswertung:
    -    """Uebersetzt einen CP-SAT-Status in eine Handlungsempfehlung.
    -
    -    Genau diese Funktion fehlt in den meisten Projekten. Sie ist kurz, aber
    -    sie ist der Unterschied zwischen einem Prototyp und einem System, das
    -    nachts ohne Aufsicht laeuft.
    -    """
    -    name = loeser.StatusName(status)
    -    hat_loesung = status in (cp_model.OPTIMAL, cp_model.FEASIBLE)
    -
    -    zielwert = schranke = gap = None
    -    if hat_loesung and mit_zielfunktion:
    -        zielwert = loeser.ObjectiveValue()
    -        schranke = loeser.BestObjectiveBound()
    -        nenner = max(abs(zielwert), 1e-9)
    -        gap = abs(zielwert - schranke) / nenner
    -
    -    if status == cp_model.OPTIMAL:
    -        empfehlung = "Ausfuehren. Besser geht es nachweislich nicht."
    -    elif status == cp_model.FEASIBLE:
    -        empfehlung = (f"Brauchbar. Der Plan ist hoechstens {gap * 100:.2f} % "
    -                      f"schlechter als das theoretisch Bestmoegliche."
    -                      if gap is not None else
    -                      "Brauchbar, aber nicht als optimal bewiesen.")
    -    elif status == cp_model.INFEASIBLE:
    -        empfehlung = ("KEIN Plan existiert. Das ist eine Aussage ueber Ihre "
    -                      "harten Regeln, nicht ueber die Rechenzeit: Auch "
    -                      "unendlich viel Zeit wuerde nichts aendern. Regeln "
    -                      "lockern oder weich machen (Anhang C).")
    -    elif status == cp_model.MODEL_INVALID:
    -        empfehlung = ("Das MODELL ist fehlerhaft, nicht das Problem. "
    -                      "Typisch: eine Variable mit leerem Wertebereich. "
    -                      "model.Validate() nennt die Ursache.")
    -    else:                                    # UNKNOWN
    -        empfehlung = ("Nichts gefunden - aber auch nicht widerlegt. Mehr Zeit "
    -                      "geben, das Modell vereinfachen oder mit einer "
    -                      "Heuristik vorbelegen (AddHint).")
    -
    -    return Auswertung(
    -        status=name,
    -        hat_loesung=hat_loesung,
    -        beweisbar_optimal=status == cp_model.OPTIMAL,
    -        zielwert=zielwert, schranke=schranke, gap=gap,
    -        empfehlung=empfehlung,
    -    )
    -
    -
    -def loese(baue, zeitlimit: float, mit_zielfunktion: bool) -> Auswertung:
    -    modell = cp_model.CpModel()
    -    baue(modell)
    -    loeser = cp_model.CpSolver()
    -    loeser.parameters.max_time_in_seconds = zeitlimit
    -    # Fuer reproduzierbare Buchausgaben; im Betrieb weglassen.
    -    loeser.parameters.num_workers = 1
    -    loeser.parameters.random_seed = 1
    -    status = loeser.Solve(modell)
    -    return werte_aus(loeser, status, mit_zielfunktion)
    -
    -
    -# --- Die fuenf Faelle --------------------------------------------------------
    -
    -def dienstplan_loesbar(modell: cp_model.CpModel) -> None:
    -    """Vier Personen, fuenf Dienste, hoechstens zwei je Person - loesbar."""
    -    personen, dienste = 4, 5
    -    x = [[modell.NewBoolVar(f"x_{i}_{j}") for j in range(dienste)]
    -         for i in range(personen)]
    -    for j in range(dienste):
    -        modell.AddExactlyOne(x[i][j] for i in range(personen))
    -    for i in range(personen):
    -        modell.Add(sum(x[i]) <= 2)
    -
    -
    -def dienstplan_unloesbar(modell: cp_model.CpModel) -> None:
    -    """Dieselben fuenf Dienste, aber hoechstens EIN Dienst je Person.
    -
    -    Vier Personen koennen zusammen hoechstens vier Dienste uebernehmen - fuer
    -    fuenf Dienste reicht das nicht. Kein Solver der Welt findet hier etwas.
    -    """
    -    personen, dienste = 4, 5
    -    x = [[modell.NewBoolVar(f"x_{i}_{j}") for j in range(dienste)]
    -         for i in range(personen)]
    -    for j in range(dienste):
    -        modell.AddExactlyOne(x[i][j] for i in range(personen))
    -    for i in range(personen):
    -        modell.Add(sum(x[i]) <= 1)
    -
    -
    -def modell_fehlerhaft(modell: cp_model.CpModel) -> None:
    -    """Eine Variable mit leerem Wertebereich: untere Schranke > obere.
    -
    -    Im Alltag entsteht das durch einen Rechenfehler in den Grenzen, etwa
    -    NewIntVar(mindestbesetzung, kapazitaet, ...) bei falsch sortierten Daten.
    -    """
    -    kaputt = modell.NewIntVar(5, 2, "leerer_bereich")
    -    modell.Add(kaputt >= 0)
    -
    -
    -def lastverteilung(modell: cp_model.CpModel) -> None:
    -    """120 Auftraege auf 11 Maschinen - gross genug, dass der Optimalitaets-
    -    beweis laenger dauert als das Finden einer sehr guten Loesung."""
    -    rng = np.random.default_rng(3)
    -    dauer = rng.integers(100, 9000, 120)
    -    n, maschinen = len(dauer), 11
    -    obergrenze = int(dauer.sum())
    -
    -    x = [[modell.NewBoolVar(f"x_{i}_{k}") for k in range(maschinen)]
    -         for i in range(n)]
    -    for i in range(n):
    -        modell.AddExactlyOne(x[i])
    -    belegung = [modell.NewIntVar(0, obergrenze, f"l_{k}") for k in range(maschinen)]
    -    for k in range(maschinen):
    -        modell.Add(belegung[k] == sum(int(dauer[i]) * x[i][k] for i in range(n)))
    -    makespan = modell.NewIntVar(0, obergrenze, "makespan")
    -    modell.AddMaxEquality(makespan, belegung)
    -    modell.Minimize(makespan)
    -
    -
    -def zahlpartition(modell: cp_model.CpModel) -> None:
    -    """46 grosse Zahlen exakt in zwei gleich schwere Haelften teilen.
    -
    -    Ein beruehmt schweres Problem: Es gibt keine Zielfunktion, entweder es
    -    geht auf oder nicht - und beides zu entscheiden dauert lange.
    -    """
    -    rng = np.random.default_rng(1)
    -    gewichte = rng.integers(10**6, 2 * 10**6, 46)
    -    x = [modell.NewBoolVar(f"x_{i}") for i in range(len(gewichte))]
    -    modell.Add(sum(int(gewichte[i]) * x[i] for i in range(len(gewichte)))
    -               == int(gewichte.sum() // 2))
    -
    -
    -def zeige(titel: str, a: Auswertung) -> None:
    -    print(f"\n{titel}")
    -    print(f"  Status               {a.status}")
    -    print(f"  Loesung vorhanden    {'ja' if a.hat_loesung else 'nein'}")
    -    if a.zielwert is not None:
    -        print(f"  Zielwert / Schranke  {a.zielwert:,.0f} / {a.schranke:,.0f}"
    -              f"   (Gap {a.gap * 100:.3f} %)")
    -    print(f"  -> {a.empfehlung}")
    -
    -
    -if __name__ == "__main__":
    -    print("=" * 78)
    -    print("  DIE FUENF ANTWORTEN VON CP-SAT")
    -    print("=" * 78)
    -
    -    zeige("[1] Dienstplan, hoechstens 2 Dienste je Person",
    -          loese(dienstplan_loesbar, 10.0, mit_zielfunktion=False))
    -
    -    zeige("[2] Lastverteilung 120 Auftraege / 11 Maschinen, 0,5 s Limit",
    -          loese(lastverteilung, 0.5, mit_zielfunktion=True))
    -
    -    zeige("[3] Derselbe Fall mit 10 s Limit",
    -          loese(lastverteilung, 10.0, mit_zielfunktion=True))
    -
    -    zeige("[4] Dienstplan, hoechstens 1 Dienst je Person",
    -          loese(dienstplan_unloesbar, 10.0, mit_zielfunktion=False))
    -
    -    zeige("[5] Variable mit leerem Wertebereich",
    -          loese(modell_fehlerhaft, 10.0, mit_zielfunktion=False))
    -
    -    zeige("[6] Zahlpartition mit 46 grossen Zahlen, 2 s Limit",
    -          loese(zahlpartition, 2.0, mit_zielfunktion=False))
    -
    -    print("\n" + "=" * 78)
    -    print("  DIE DREI, DIE MAN NICHT VERWECHSELN DARF")
    -    print("=" * 78)
    -    print("INFEASIBLE  Es gibt keine Loesung. Eine Aussage ueber Ihr MODELL.")
    -    print("            Mehr Rechenzeit aendert daran nichts.")
    -    print("UNKNOWN     Es wurde keine gefunden. Eine Aussage ueber die ZEIT.")
    -    print("            Ob es eine gibt, ist offen.")
    -    print("MODEL_INVALID  Das Modell ist gar kein gueltiges Modell. Eine")
    -    print("            Aussage ueber Ihren CODE - immer ein Programmierfehler.")
    -    print()
    -    print("Wer diese drei in einem 'else: return None' zusammenfasst, verliert")
    -    print("genau die Information, die zur Fehlersuche noetig waere.")
    -    print("=" * 78)
    +
    #!/usr/bin/env python3
    +
    +# CP_SAT_Statusfaelle.py
    +"""
    +Kapitel CP-SAT: Alle fuenf Antworten von CP-SAT - und was jede bedeutet.
    +
    +Der haeufigste Fehler beim Einsatz von CP-SAT im Betrieb ist eine Zeile wie
    +
    +    if status == cp_model.OPTIMAL:
    +        ...verwerte die Loesung...
    +
    +Sie wirft in drei von fuenf Faellen etwas weg, das man haette gebrauchen
    +koennen, und verschweigt in einem weiteren Fall einen Modellierungsfehler.
    +
    +Dieses Programm erzeugt jeden Statusfall absichtlich und zeigt die passende
    +Reaktion:
    +
    +    OPTIMAL        beweisbar bestmoeglich          -> ausfuehren
    +    FEASIBLE       zulaessig, Zeit war um          -> Gap pruefen, dann entscheiden
    +    INFEASIBLE     kein Plan existiert             -> harte Regeln lockern
    +    MODEL_INVALID  das Modell ist fehlerhaft       -> Programmierfehler beheben
    +    UNKNOWN        nichts gefunden, Zeit war um    -> mehr Zeit oder Heuristik
    +
    +Benoetigt: numpy, ortools
    +"""
    +
    +from __future__ import annotations
    +
    +from dataclasses import dataclass
    +
    +import numpy as np
    +from ortools.sat.python import cp_model
    +
    +
    +@dataclass
    +class Auswertung:
    +    """Was nach einem CP-SAT-Lauf tatsaechlich feststeht."""
    +    status: str
    +    hat_loesung: bool
    +    beweisbar_optimal: bool
    +    zielwert: float | None
    +    schranke: float | None
    +    gap: float | None
    +    empfehlung: str
    +
    +
    +def werte_aus(loeser: cp_model.CpSolver, status: int,
    +              mit_zielfunktion: bool) -> Auswertung:
    +    """Uebersetzt einen CP-SAT-Status in eine Handlungsempfehlung.
    +
    +    Genau diese Funktion fehlt in den meisten Projekten. Sie ist kurz, aber
    +    sie ist der Unterschied zwischen einem Prototyp und einem System, das
    +    nachts ohne Aufsicht laeuft.
    +    """
    +    name = loeser.StatusName(status)
    +    hat_loesung = status in (cp_model.OPTIMAL, cp_model.FEASIBLE)
    +
    +    zielwert = schranke = gap = None
    +    if hat_loesung and mit_zielfunktion:
    +        zielwert = loeser.ObjectiveValue()
    +        schranke = loeser.BestObjectiveBound()
    +        nenner = max(abs(zielwert), 1e-9)
    +        gap = abs(zielwert - schranke) / nenner
    +
    +    if status == cp_model.OPTIMAL:
    +        empfehlung = "Ausfuehren. Besser geht es nachweislich nicht."
    +    elif status == cp_model.FEASIBLE:
    +        empfehlung = (f"Brauchbar. Der Plan ist hoechstens {gap * 100:.2f} % "
    +                      f"schlechter als das theoretisch Bestmoegliche."
    +                      if gap is not None else
    +                      "Brauchbar, aber nicht als optimal bewiesen.")
    +    elif status == cp_model.INFEASIBLE:
    +        empfehlung = ("KEIN Plan existiert. Das ist eine Aussage ueber Ihre "
    +                      "harten Regeln, nicht ueber die Rechenzeit: Auch "
    +                      "unendlich viel Zeit wuerde nichts aendern. Regeln "
    +                      "lockern oder weich machen (Anhang C).")
    +    elif status == cp_model.MODEL_INVALID:
    +        empfehlung = ("Das MODELL ist fehlerhaft, nicht das Problem. "
    +                      "Typisch: eine Variable mit leerem Wertebereich. "
    +                      "model.Validate() nennt die Ursache.")
    +    else:                                    # UNKNOWN
    +        empfehlung = ("Nichts gefunden - aber auch nicht widerlegt. Mehr Zeit "
    +                      "geben, das Modell vereinfachen oder mit einer "
    +                      "Heuristik vorbelegen (AddHint).")
    +
    +    return Auswertung(
    +        status=name,
    +        hat_loesung=hat_loesung,
    +        beweisbar_optimal=status == cp_model.OPTIMAL,
    +        zielwert=zielwert, schranke=schranke, gap=gap,
    +        empfehlung=empfehlung,
    +    )
    +
    +
    +def loese(baue, zeitlimit: float, mit_zielfunktion: bool) -> Auswertung:
    +    modell = cp_model.CpModel()
    +    baue(modell)
    +    loeser = cp_model.CpSolver()
    +    loeser.parameters.max_time_in_seconds = zeitlimit
    +    # Fuer reproduzierbare Buchausgaben; im Betrieb weglassen.
    +    loeser.parameters.num_workers = 1
    +    loeser.parameters.random_seed = 1
    +    status = loeser.Solve(modell)
    +    return werte_aus(loeser, status, mit_zielfunktion)
    +
    +
    +# --- Die fuenf Faelle --------------------------------------------------------
    +
    +def dienstplan_loesbar(modell: cp_model.CpModel) -> None:
    +    """Vier Personen, fuenf Dienste, hoechstens zwei je Person - loesbar."""
    +    personen, dienste = 4, 5
    +    x = [[modell.NewBoolVar(f"x_{i}_{j}") for j in range(dienste)]
    +         for i in range(personen)]
    +    for j in range(dienste):
    +        modell.AddExactlyOne(x[i][j] for i in range(personen))
    +    for i in range(personen):
    +        modell.Add(sum(x[i]) <= 2)
    +
    +
    +def dienstplan_unloesbar(modell: cp_model.CpModel) -> None:
    +    """Dieselben fuenf Dienste, aber hoechstens EIN Dienst je Person.
    +
    +    Vier Personen koennen zusammen hoechstens vier Dienste uebernehmen - fuer
    +    fuenf Dienste reicht das nicht. Kein Solver der Welt findet hier etwas.
    +    """
    +    personen, dienste = 4, 5
    +    x = [[modell.NewBoolVar(f"x_{i}_{j}") for j in range(dienste)]
    +         for i in range(personen)]
    +    for j in range(dienste):
    +        modell.AddExactlyOne(x[i][j] for i in range(personen))
    +    for i in range(personen):
    +        modell.Add(sum(x[i]) <= 1)
    +
    +
    +def modell_fehlerhaft(modell: cp_model.CpModel) -> None:
    +    """Eine Variable mit leerem Wertebereich: untere Schranke > obere.
    +
    +    Im Alltag entsteht das durch einen Rechenfehler in den Grenzen, etwa
    +    NewIntVar(mindestbesetzung, kapazitaet, ...) bei falsch sortierten Daten.
    +    """
    +    kaputt = modell.NewIntVar(5, 2, "leerer_bereich")
    +    modell.Add(kaputt >= 0)
    +
    +
    +def lastverteilung(modell: cp_model.CpModel) -> None:
    +    """120 Auftraege auf 11 Maschinen - gross genug, dass der Optimalitaets-
    +    beweis laenger dauert als das Finden einer sehr guten Loesung."""
    +    rng = np.random.default_rng(3)
    +    dauer = rng.integers(100, 9000, 120)
    +    n, maschinen = len(dauer), 11
    +    obergrenze = int(dauer.sum())
    +
    +    x = [[modell.NewBoolVar(f"x_{i}_{k}") for k in range(maschinen)]
    +         for i in range(n)]
    +    for i in range(n):
    +        modell.AddExactlyOne(x[i])
    +    belegung = [modell.NewIntVar(0, obergrenze, f"l_{k}") for k in range(maschinen)]
    +    for k in range(maschinen):
    +        modell.Add(belegung[k] == sum(int(dauer[i]) * x[i][k] for i in range(n)))
    +    makespan = modell.NewIntVar(0, obergrenze, "makespan")
    +    modell.AddMaxEquality(makespan, belegung)
    +    modell.Minimize(makespan)
    +
    +
    +def zahlpartition(modell: cp_model.CpModel) -> None:
    +    """46 grosse Zahlen exakt in zwei gleich schwere Haelften teilen.
    +
    +    Ein beruehmt schweres Problem: Es gibt keine Zielfunktion, entweder es
    +    geht auf oder nicht - und beides zu entscheiden dauert lange.
    +    """
    +    rng = np.random.default_rng(1)
    +    gewichte = rng.integers(10**6, 2 * 10**6, 46)
    +    x = [modell.NewBoolVar(f"x_{i}") for i in range(len(gewichte))]
    +    modell.Add(sum(int(gewichte[i]) * x[i] for i in range(len(gewichte)))
    +               == int(gewichte.sum() // 2))
    +
    +
    +def zeige(titel: str, a: Auswertung) -> None:
    +    print(f"\n{titel}")
    +    print(f"  Status               {a.status}")
    +    print(f"  Loesung vorhanden    {'ja' if a.hat_loesung else 'nein'}")
    +    if a.zielwert is not None:
    +        print(f"  Zielwert / Schranke  {a.zielwert:,.0f} / {a.schranke:,.0f}"
    +              f"   (Gap {a.gap * 100:.3f} %)")
    +    print(f"  -> {a.empfehlung}")
    +
    +
    +if __name__ == "__main__":
    +    print("=" * 78)
    +    print("  DIE FUENF ANTWORTEN VON CP-SAT")
    +    print("=" * 78)
    +
    +    zeige("[1] Dienstplan, hoechstens 2 Dienste je Person",
    +          loese(dienstplan_loesbar, 10.0, mit_zielfunktion=False))
    +
    +    zeige("[2] Lastverteilung 120 Auftraege / 11 Maschinen, 0,5 s Limit",
    +          loese(lastverteilung, 0.5, mit_zielfunktion=True))
    +
    +    zeige("[3] Derselbe Fall mit 10 s Limit",
    +          loese(lastverteilung, 10.0, mit_zielfunktion=True))
    +
    +    zeige("[4] Dienstplan, hoechstens 1 Dienst je Person",
    +          loese(dienstplan_unloesbar, 10.0, mit_zielfunktion=False))
    +
    +    zeige("[5] Variable mit leerem Wertebereich",
    +          loese(modell_fehlerhaft, 10.0, mit_zielfunktion=False))
    +
    +    zeige("[6] Zahlpartition mit 46 grossen Zahlen, 2 s Limit",
    +          loese(zahlpartition, 2.0, mit_zielfunktion=False))
    +
    +    print("\n" + "=" * 78)
    +    print("  DIE DREI, DIE MAN NICHT VERWECHSELN DARF")
    +    print("=" * 78)
    +    print("INFEASIBLE  Es gibt keine Loesung. Eine Aussage ueber Ihr MODELL.")
    +    print("            Mehr Rechenzeit aendert daran nichts.")
    +    print("UNKNOWN     Es wurde keine gefunden. Eine Aussage ueber die ZEIT.")
    +    print("            Ob es eine gibt, ist offen.")
    +    print("MODEL_INVALID  Das Modell ist gar kein gueltiges Modell. Eine")
    +    print("            Aussage ueber Ihren CODE - immer ein Programmierfehler.")
    +    print()
    +    print("Wer diese drei in einem 'else: return None' zusammenfasst, verliert")
    +    print("genau die Information, die zur Fehlersuche noetig waere.")
    +    print("=" * 78)

    Erwartete Ausgabe (Zielwerte in Lauf [2] hardwareabhängig):

    ==============================================================================
       DIE FUENF ANTWORTEN VON CP-SAT
    @@ -11734,7 +12046,7 @@ genau die Information, die zur Fehlersuche noetig waere.
     
     
     
    -

    7.8 Übungsaufgaben

    +

    7.9 Übungsaufgaben

    Lösungen: Abschnitt A.7.

    @@ -11743,10 +12055,11 @@ genau die Information, die zur Fehlersuche noetig waere.

    Aufgabe 7.3 ⭐⭐ — Regeln ergänzen. Erweitern Sie das Vertretungssystem um: (a) Frau Albrecht ist ab Slot 3 nicht mehr verfügbar. (b) Herr Bauer und Frau Koch sollen nicht beide am selben Tag eingesetzt werden. (c) Wer Slot 1 übernimmt, soll bevorzugt auch Slot 2 bekommen (Belohnung statt Strafe).

    Aufgabe 7.4 ⭐⭐ — Infeasibility erzeugen und diagnostizieren. Setzen Sie MAX_VERTRETUNGEN = 0. Was meldet das Programm? Bauen Sie anschließend eine Schlupfvariable je Slot ein („Stunde fällt aus“, Strafe 10 000) und beobachten Sie, wie der Solver statt einer Fehlermeldung einen Notfallplan liefert. Welche Stunde lässt er ausfallen und warum?

    Aufgabe 7.5 ⭐⭐ — Sudoku. Lösen Sie ein 9×9-Sudoku mit CP-SAT. Sie brauchen nur NewIntVar(1, 9, ...) und AddAllDifferent für Zeilen, Spalten und die neun 3×3-Blöcke. Wie viele Zeilen Modellcode benötigen Sie?

    -

    Aufgabe 7.6 ⭐⭐⭐ — Job-Shop erweitern. Ergänzen Sie JobShop_Intervalle.py um: (a) Rüstzeiten von 1 Zeiteinheit zwischen zwei Aufträgen auf derselben Maschine. (b) Fälligkeitstermine je Auftrag mit Strafkosten für Verspätung. (c) Eine zweite Fräse (Kapazität 2 statt 1) mit AddCumulative.

    -

    Aufgabe 7.7 ⭐⭐⭐ — Eigener Dienstplan. Modellieren Sie einen Wochendienstplan: 7 Tage, 3 Schichten (früh/spät/nacht), 8 Mitarbeitende. Regeln: Jede Schicht 2 Personen; nach Nachtschicht mindestens 2 Tage frei; höchstens 5 Arbeitstage in Folge; jede Person mindestens 2 freie Tage pro Woche; Wünsche als weiche Ziele. Geben Sie den Plan als Wochentabelle aus.

    +

    Aufgabe 7.6 ⭐⭐ — Die Grenze der Parallelität finden. Parallele_Suche.py misst 1, 2, 4 und 8 Arbeiter. (a) Ergänzen Sie 16 und 24 (bzw. die Kernzahl Ihres Rechners). Ab wo bringt ein weiterer Arbeiter nichts mehr — und wird es irgendwann wieder schlechter? (b) Setzen Sie random_seed bei jedem Lauf auf einen anderen Wert, aber num_workers = 1. Wie viele verschiedene Pläne kommen jetzt heraus? Was folgt daraus für die Frage, welcher der beiden Parameter die Reproduzierbarkeit sichert? (c) Vergrößern Sie die Instanz auf 15 Aufträge. Ab welcher Größe erreicht der Lauf mit einem Arbeiter das Zeitlimit, während acht noch OPTIMAL melden?

    +

    Aufgabe 7.7 ⭐⭐⭐ — Job-Shop erweitern. Ergänzen Sie JobShop_Intervalle.py um: (a) Rüstzeiten von 1 Zeiteinheit zwischen zwei Aufträgen auf derselben Maschine. (b) Fälligkeitstermine je Auftrag mit Strafkosten für Verspätung. (c) Eine zweite Fräse (Kapazität 2 statt 1) mit AddCumulative.

    +

    Aufgabe 7.8 ⭐⭐⭐ — Eigener Dienstplan. Modellieren Sie einen Wochendienstplan: 7 Tage, 3 Schichten (früh/spät/nacht), 8 Mitarbeitende. Regeln: Jede Schicht 2 Personen; nach Nachtschicht mindestens 2 Tage frei; höchstens 5 Arbeitstage in Folge; jede Person mindestens 2 freie Tage pro Woche; Wünsche als weiche Ziele. Geben Sie den Plan als Wochentabelle aus.


    -

    7.9 Finde den Denkfehler

    +

    7.10 Finde den Denkfehler

    🐛 Finde den Denkfehler: Die Betriebsvereinbarung, die niemanden interessiert

    Ein Klinikteam lässt seinen Schichtplan optimieren. Zwei Wünsche kommen aus dem Betrieb:

    @@ -11760,138 +12073,138 @@ genau die Information, die zur Fehlersuche noetig waere.

    Auflösung: Abschnitt A.7.

    Das folgende Programm rechnet den Plan für sechs verschiedene Gewichte durch. Es ist damit zugleich die Antwort auf (c) — und die Tabelle, die man in so einem Fall dem Auftraggeber vorlegt, statt ein Gewicht zu raten.

    -
    #!/usr/bin/env python3
    -
    -# Strafgewichte.py
    -"""
    -Kapitel CP-SAT: Warum ein Strafgewicht keine Wichtigkeit ausdrueckt, sondern
    -einen Wechselkurs.
    -
    -Weiche Regeln kommen als Strafterm in die Zielfunktion. Die Frage, die dabei
    -regelmaessig falsch beantwortet wird, lautet: "Wie gross muss die Strafe sein?"
    -Die uebliche Antwort - "je wichtiger, desto groesser" - klingt vernuenftig und
    -fuehrt zuverlaessig zu Plaenen, die niemand unterschreiben will.
    -
    -Der Grund: Zwei Strafgewichte legen nicht Wichtigkeiten fest, sondern einen
    -UMRECHNUNGSKURS. Steht die Serienregel bei 1 und der Wunschfrei-Tag bei 10,
    -dann hat man dem Solver woertlich gesagt: "Zehn Verstoesse gegen die
    -Serienregel sind so schlimm wie ein abgelehnter Wunsch." Er nimmt das
    -ernst - und handelt danach.
    -
    -Szenario: Schichtplan, 6 Personen, 14 Tage, 4 Besetzungen pro Tag.
    -  HART   jeder Tag genau 4 Personen; hoechstens 11 Dienste je Person
    -  WEICH  keine drei Dienste in Folge (Betriebsvereinbarung)
    -  WEICH  Wunschfrei-Tage einhalten (26 Antraege)
    -
    -Benoetigt: numpy, ortools
    -"""
    -
    -from __future__ import annotations
    -
    -import numpy as np
    -from ortools.sat.python import cp_model
    -
    -PERSONEN, TAGE, PRO_TAG = 6, 14, 4
    -MAX_DIENSTE = 11
    -STRAFE_WUNSCH = 10                    # bleibt fest - variiert wird die Serienstrafe
    -
    -RNG = np.random.default_rng(2)
    -WUNSCHFREI = sorted({(p, t) for p in range(PERSONEN) for t in range(TAGE)
    -                     if RNG.random() < 0.35})
    -
    -
    -def plane(strafe_serie: int, zeitlimit: float = 25.0) -> dict:
    -    """Baut den Schichtplan mit dem angegebenen Gewicht fuer die Serienregel."""
    -    modell = cp_model.CpModel()
    -    x = {(p, t): modell.NewBoolVar(f"x_{p}_{t}")
    -         for p in range(PERSONEN) for t in range(TAGE)}
    -
    -    # --- harte Regeln --------------------------------------------------
    -    for t in range(TAGE):
    -        modell.Add(sum(x[p, t] for p in range(PERSONEN)) == PRO_TAG)
    -    for p in range(PERSONEN):
    -        modell.Add(sum(x[p, t] for t in range(TAGE)) <= MAX_DIENSTE)
    -
    -    # --- weiche Regel 1: keine drei Dienste in Folge --------------------
    -    # serie[p, t] = 1  <=>  Person p arbeitet an t, t+1 UND t+2.
    -    # Die Ungleichung erzwingt das nur in eine Richtung; das genuegt, weil
    -    # der Solver serie minimiert - er wuerde die Variable nie freiwillig
    -    # auf 1 setzen.
    -    strafterme = []
    -    serien = []
    -    for p in range(PERSONEN):
    -        for t in range(TAGE - 2):
    -            serie = modell.NewBoolVar(f"serie_{p}_{t}")
    -            modell.Add(x[p, t] + x[p, t + 1] + x[p, t + 2] <= 2 + serie)
    -            serien.append(serie)
    -            strafterme.append(serie * strafe_serie)
    -
    -    # --- weiche Regel 2: Wunschfrei ------------------------------------
    -    wunschverstoesse = []
    -    for p, t in WUNSCHFREI:
    -        wunschverstoesse.append(x[p, t])
    -        strafterme.append(x[p, t] * STRAFE_WUNSCH)
    -
    -    modell.Minimize(sum(strafterme))
    -
    -    loeser = cp_model.CpSolver()
    -    loeser.parameters.max_time_in_seconds = zeitlimit
    -    loeser.parameters.num_workers = 1          # reproduzierbare Buchausgabe
    -    loeser.parameters.random_seed = 1
    -    status = loeser.Solve(modell)
    -    if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
    -        raise RuntimeError(f"Kein Plan: {loeser.StatusName(status)}")
    -
    -    return {
    -        "status": loeser.StatusName(status),
    -        "zielwert": loeser.ObjectiveValue(),
    -        "serien": sum(loeser.Value(v) for v in serien),
    -        "wunsch_verletzt": sum(loeser.Value(v) for v in wunschverstoesse),
    -    }
    -
    -
    -if __name__ == "__main__":
    -    print("=" * 78)
    -    print("  STRAFGEWICHTE SIND WECHSELKURSE, KEINE WICHTIGKEITEN")
    -    print("=" * 78)
    -    print(f"{PERSONEN} Personen, {TAGE} Tage, {PRO_TAG} Besetzungen pro Tag "
    -          f"= {TAGE * PRO_TAG} Dienste")
    -    print(f"Das sind {TAGE * PRO_TAG / PERSONEN:.1f} Dienste je Person - der Plan ist eng.")
    -    print(f"{len(WUNSCHFREI)} Wunschfrei-Antraege, Strafe je abgelehntem Wunsch: "
    -          f"{STRAFE_WUNSCH}\n")
    -
    -    print(f"{'Strafe je 3er-Serie':>20} {'3er-Serien':>12} {'Wunsch verletzt':>17} "
    -          f"{'Zielwert':>10}")
    -    print("-" * 78)
    -    ergebnisse = {}
    -    for strafe in (1, 2, 5, 10, 30, 100):
    -        e = plane(strafe)
    -        ergebnisse[strafe] = e
    -        print(f"{strafe:>20} {e['serien']:>12} {e['wunsch_verletzt']:>17} "
    -              f"{e['zielwert']:>10.0f}")
    -    print("-" * 78)
    -
    -    niedrig, hoch = ergebnisse[1], ergebnisse[30]
    -    print(f"\nBei Strafe 1 baut der Solver {niedrig['serien']} Drei-Tage-Serien ein.")
    -    print("Die Betriebsvereinbarung steht im Modell - und wird trotzdem")
    -    print("systematisch verletzt. Das ist kein Fehler des Solvers: Bei einem")
    -    print(f"Gewicht von 1 gegen {STRAFE_WUNSCH} lohnt sich jede Serie, solange sie")
    -    print("auch nur einen Zehntel-Wunsch rettet.")
    -    print(f"\nBei Strafe 30 sind es {hoch['serien']} Serien - dafuer werden")
    -    print(f"{hoch['wunsch_verletzt']} statt {niedrig['wunsch_verletzt']} Wuensche abgelehnt.")
    -    print("Beide Plaene sind optimal. Sie beantworten nur verschiedene Fragen.")
    -    print()
    -    print("Die Frage lautet also nie 'wie wichtig ist mir diese Regel?', sondern:")
    -    print("  'Wie viele abgelehnte Wuensche bin ich bereit zu akzeptieren,")
    -    print("   um eine Drei-Tage-Serie zu vermeiden?'")
    -    print("Wer darauf keine Zahl nennen kann, hat das Problem noch nicht")
    -    print("verstanden - und sollte diese Tabelle dem Auftraggeber vorlegen,")
    -    print("statt ein Gewicht zu raten.")
    -    print()
    -    print("Und die wichtigste Konsequenz: Eine Regel, die NIE gebrochen werden")
    -    print("darf, gehoert nicht in die Zielfunktion, sondern unter die harten")
    -    print("Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.")
    -    print("=" * 78)
    +
    #!/usr/bin/env python3
    +
    +# Strafgewichte.py
    +"""
    +Kapitel CP-SAT: Warum ein Strafgewicht keine Wichtigkeit ausdrueckt, sondern
    +einen Wechselkurs.
    +
    +Weiche Regeln kommen als Strafterm in die Zielfunktion. Die Frage, die dabei
    +regelmaessig falsch beantwortet wird, lautet: "Wie gross muss die Strafe sein?"
    +Die uebliche Antwort - "je wichtiger, desto groesser" - klingt vernuenftig und
    +fuehrt zuverlaessig zu Plaenen, die niemand unterschreiben will.
    +
    +Der Grund: Zwei Strafgewichte legen nicht Wichtigkeiten fest, sondern einen
    +UMRECHNUNGSKURS. Steht die Serienregel bei 1 und der Wunschfrei-Tag bei 10,
    +dann hat man dem Solver woertlich gesagt: "Zehn Verstoesse gegen die
    +Serienregel sind so schlimm wie ein abgelehnter Wunsch." Er nimmt das
    +ernst - und handelt danach.
    +
    +Szenario: Schichtplan, 6 Personen, 14 Tage, 4 Besetzungen pro Tag.
    +  HART   jeder Tag genau 4 Personen; hoechstens 11 Dienste je Person
    +  WEICH  keine drei Dienste in Folge (Betriebsvereinbarung)
    +  WEICH  Wunschfrei-Tage einhalten (26 Antraege)
    +
    +Benoetigt: numpy, ortools
    +"""
    +
    +from __future__ import annotations
    +
    +import numpy as np
    +from ortools.sat.python import cp_model
    +
    +PERSONEN, TAGE, PRO_TAG = 6, 14, 4
    +MAX_DIENSTE = 11
    +STRAFE_WUNSCH = 10                    # bleibt fest - variiert wird die Serienstrafe
    +
    +RNG = np.random.default_rng(2)
    +WUNSCHFREI = sorted({(p, t) for p in range(PERSONEN) for t in range(TAGE)
    +                     if RNG.random() < 0.35})
    +
    +
    +def plane(strafe_serie: int, zeitlimit: float = 25.0) -> dict:
    +    """Baut den Schichtplan mit dem angegebenen Gewicht fuer die Serienregel."""
    +    modell = cp_model.CpModel()
    +    x = {(p, t): modell.NewBoolVar(f"x_{p}_{t}")
    +         for p in range(PERSONEN) for t in range(TAGE)}
    +
    +    # --- harte Regeln --------------------------------------------------
    +    for t in range(TAGE):
    +        modell.Add(sum(x[p, t] for p in range(PERSONEN)) == PRO_TAG)
    +    for p in range(PERSONEN):
    +        modell.Add(sum(x[p, t] for t in range(TAGE)) <= MAX_DIENSTE)
    +
    +    # --- weiche Regel 1: keine drei Dienste in Folge --------------------
    +    # serie[p, t] = 1  <=>  Person p arbeitet an t, t+1 UND t+2.
    +    # Die Ungleichung erzwingt das nur in eine Richtung; das genuegt, weil
    +    # der Solver serie minimiert - er wuerde die Variable nie freiwillig
    +    # auf 1 setzen.
    +    strafterme = []
    +    serien = []
    +    for p in range(PERSONEN):
    +        for t in range(TAGE - 2):
    +            serie = modell.NewBoolVar(f"serie_{p}_{t}")
    +            modell.Add(x[p, t] + x[p, t + 1] + x[p, t + 2] <= 2 + serie)
    +            serien.append(serie)
    +            strafterme.append(serie * strafe_serie)
    +
    +    # --- weiche Regel 2: Wunschfrei ------------------------------------
    +    wunschverstoesse = []
    +    for p, t in WUNSCHFREI:
    +        wunschverstoesse.append(x[p, t])
    +        strafterme.append(x[p, t] * STRAFE_WUNSCH)
    +
    +    modell.Minimize(sum(strafterme))
    +
    +    loeser = cp_model.CpSolver()
    +    loeser.parameters.max_time_in_seconds = zeitlimit
    +    loeser.parameters.num_workers = 1          # reproduzierbare Buchausgabe
    +    loeser.parameters.random_seed = 1
    +    status = loeser.Solve(modell)
    +    if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
    +        raise RuntimeError(f"Kein Plan: {loeser.StatusName(status)}")
    +
    +    return {
    +        "status": loeser.StatusName(status),
    +        "zielwert": loeser.ObjectiveValue(),
    +        "serien": sum(loeser.Value(v) for v in serien),
    +        "wunsch_verletzt": sum(loeser.Value(v) for v in wunschverstoesse),
    +    }
    +
    +
    +if __name__ == "__main__":
    +    print("=" * 78)
    +    print("  STRAFGEWICHTE SIND WECHSELKURSE, KEINE WICHTIGKEITEN")
    +    print("=" * 78)
    +    print(f"{PERSONEN} Personen, {TAGE} Tage, {PRO_TAG} Besetzungen pro Tag "
    +          f"= {TAGE * PRO_TAG} Dienste")
    +    print(f"Das sind {TAGE * PRO_TAG / PERSONEN:.1f} Dienste je Person - der Plan ist eng.")
    +    print(f"{len(WUNSCHFREI)} Wunschfrei-Antraege, Strafe je abgelehntem Wunsch: "
    +          f"{STRAFE_WUNSCH}\n")
    +
    +    print(f"{'Strafe je 3er-Serie':>20} {'3er-Serien':>12} {'Wunsch verletzt':>17} "
    +          f"{'Zielwert':>10}")
    +    print("-" * 78)
    +    ergebnisse = {}
    +    for strafe in (1, 2, 5, 10, 30, 100):
    +        e = plane(strafe)
    +        ergebnisse[strafe] = e
    +        print(f"{strafe:>20} {e['serien']:>12} {e['wunsch_verletzt']:>17} "
    +              f"{e['zielwert']:>10.0f}")
    +    print("-" * 78)
    +
    +    niedrig, hoch = ergebnisse[1], ergebnisse[30]
    +    print(f"\nBei Strafe 1 baut der Solver {niedrig['serien']} Drei-Tage-Serien ein.")
    +    print("Die Betriebsvereinbarung steht im Modell - und wird trotzdem")
    +    print("systematisch verletzt. Das ist kein Fehler des Solvers: Bei einem")
    +    print(f"Gewicht von 1 gegen {STRAFE_WUNSCH} lohnt sich jede Serie, solange sie")
    +    print("auch nur einen Zehntel-Wunsch rettet.")
    +    print(f"\nBei Strafe 30 sind es {hoch['serien']} Serien - dafuer werden")
    +    print(f"{hoch['wunsch_verletzt']} statt {niedrig['wunsch_verletzt']} Wuensche abgelehnt.")
    +    print("Beide Plaene sind optimal. Sie beantworten nur verschiedene Fragen.")
    +    print()
    +    print("Die Frage lautet also nie 'wie wichtig ist mir diese Regel?', sondern:")
    +    print("  'Wie viele abgelehnte Wuensche bin ich bereit zu akzeptieren,")
    +    print("   um eine Drei-Tage-Serie zu vermeiden?'")
    +    print("Wer darauf keine Zahl nennen kann, hat das Problem noch nicht")
    +    print("verstanden - und sollte diese Tabelle dem Auftraggeber vorlegen,")
    +    print("statt ein Gewicht zu raten.")
    +    print()
    +    print("Und die wichtigste Konsequenz: Eine Regel, die NIE gebrochen werden")
    +    print("darf, gehoert nicht in die Zielfunktion, sondern unter die harten")
    +    print("Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.")
    +    print("=" * 78)

    Erwartete Ausgabe:

    ==============================================================================
       STRAFGEWICHTE SIND WECHSELKURSE, KEINE WICHTIGKEITEN
    @@ -11944,7 +12257,7 @@ Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.
     
     
     
    -

    7.10 Micro-Quiz

    +

    7.11 Micro-Quiz

    ❓ Micro-Quiz 7: Drei Fragen zum Selbstcheck

    Genau eine Antwort ist jeweils richtig. Auflösung in Anhang A.

    @@ -11953,7 +12266,7 @@ Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.

    3. Zwei Läufe desselben Job-Shop-Programms liefern verschiedene Pläne, beide mit Makespan 11. Was folgt daraus für Ihre Tests? (a) Das Programm ist fehlerhaft; ein korrektes Modell hat genau eine Lösung. (b) Es gibt mehrere optimale Pläne. Tests dürfen deshalb nicht auf einen bestimmten Plan prüfen, sondern auf den Zielwert und die Einhaltung aller Regeln. (c) Der Solver arbeitet ungenau; man sollte die Toleranzen verschärfen.


    -

    7.11 Selbsttest

    +

    7.12 Selbsttest

    Antworten: Anhang A.

    @@ -11962,21 +12275,24 @@ Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.
  • Welchen Vorteil hat AddAllDifferent(x) gegenüber \binom{n}{2} Einzelbedingungen?
  • Was ist eine Intervallvariable, und welche Bedingung garantiert sie automatisch?
  • Warum sollten möglichst wenige Bedingungen hart formuliert werden?
  • -
  • Warum ist die Aufschlüsselung der Strafkosten in der Praxis wichtiger als der Zielwert selbst?
  • +
  • Warum ist die Aufschlüsselung der Strafkosten in der Praxis wichtiger als der Zielwert
  • +
  • Sie setzen random_seed = 1, bekommen aber trotzdem bei jedem Lauf einen anderen Plan. Woran liegt das, und was hilft?
  • +
  • Warum kann ein CP-SAT-Lauf mit acht Arbeitern mehr als achtmal schneller sein als mit einem? selbst?

  • -

    7.12 Zusammenfassung

    +

    7.13 Zusammenfassung

      -
    • CP denkt in Wertebereichen und Regeln, nicht in Ungleichungen und Schranken.
    • -
    • Propagation streicht unmögliche Werte, bevor gesucht wird; Conflict Learning verhindert, dieselbe Sackgasse zweimal zu betreten.
    • -
    • Globale Constraints wie , und ersetzen ganze Blöcke von Big-M-Ungleichungen und propagieren stärker.
    • -
    • Intervallvariablen sind das Werkzeug für Zeitplanung; AddNoOverlap erledigt in einer Zeile, was in MILP dutzende Disjunktionen kostet.
    • -
    • Weiche Ziele mit Strafkosten machen Modelle robust gegen INFEASIBLE — und die Kostenzerlegung macht sie erklärbar.
    • -
    • Strafgewichte sind Wechselkurse, keine Wichtigkeiten. Rechnen Sie mehrere Gewichte durch und lassen Sie den Auftraggeber eine Zeile der Tabelle wählen. Und was nie gebrochen werden darf, gehört unter die harten Nebenbedingungen: Alles, was einen Preis hat, wird irgendwann gekauft.
    • -
    • Fünf Antworten, nicht zwei. INFEASIBLE (es gibt keine Lösung), UNKNOWN (es wurde keine gefunden) und MODEL_INVALID (Ihr Code ist fehlerhaft) bedeuten Grundverschiedenes. Wer sie in ein else zusammenfasst, verliert die Information zur Fehlersuche.
    • -
    • Bei mehrdeutigen Optima nie auf einen bestimmten Plan testen — nur auf den Zielwert und die Einhaltung aller Regeln.
    • +
    • CP denkt in Wertebereichen und Regeln, nicht in Ungleichungen und Schranken.

    • +
    • Propagation streicht unmögliche Werte, bevor gesucht wird; Conflict Learning verhindert, dieselbe Sackgasse zweimal zu betreten.

    • +
    • Globale Constraints wie , und ersetzen ganze Blöcke von Big-M-Ungleichungen und propagieren stärker.

    • +
    • Intervallvariablen sind das Werkzeug für Zeitplanung; AddNoOverlap erledigt in einer Zeile, was in MILP dutzende Disjunktionen kostet.

    • +
    • Weiche Ziele mit Strafkosten machen Modelle robust gegen INFEASIBLE — und die Kostenzerlegung macht sie erklärbar.

    • +
    • Strafgewichte sind Wechselkurse, keine Wichtigkeiten. Rechnen Sie mehrere Gewichte durch und lassen Sie den Auftraggeber eine Zeile der Tabelle wählen. Und was nie gebrochen werden darf, gehört unter die harten Nebenbedingungen: Alles, was einen Preis hat, wird irgendwann gekauft.

    • +
    • Fünf Antworten, nicht zwei. INFEASIBLE (es gibt keine Lösung), UNKNOWN (es wurde keine gefunden) und MODEL_INVALID (Ihr Code ist fehlerhaft) bedeuten Grundverschiedenes. Wer sie in ein else zusammenfasst, verliert die Information zur Fehlersuche.

    • +
    • Bei mehrdeutigen Optima nie auf einen bestimmten Plan testen — nur auf den Zielwert und die Einhaltung aller Regeln.

    • +
    • Ein paralleler Solver hat zwei Zufallsquellen: den Seed und die Uhr. Nur die erste lässt sich setzen. Reproduzierbar wird ein Lauf erst mit num_workers = 1 und Seed — gemessen in Abschnitt 7.7: schon zwei Arbeiter liefern bei identischem Seed drei verschiedene Pläne zum selben Zielwert.

    • +
    • Parallele Suche kann überlinear beschleunigen, weil CP-SAT nicht dieselbe Suche vervielfacht, sondern verschiedene Strategien nebeneinander laufen lässt, die einander ihre Schranken mitteilen. Ausblick. Kapitel 8 behandelt Probleme, die sich am besten als Graph begreifen lassen: Flüsse durch Netzwerke, Zuordnungen und die Tourenplanung mit Fahrzeugen und Zeitfenstern.

    -

    Ausblick. Kapitel 8 behandelt Probleme, die sich am besten als Graph begreifen lassen: Flüsse durch Netzwerke, Zuordnungen und die Tourenplanung mit Fahrzeugen und Zeitfenstern.


    Kapitel 8: Graphen, Flüsse und Touren — Min-Cost-Flow, Matching und VRP

    @@ -12039,20 +12355,20 @@ Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.
    -
    import numpy as np
    -from scipy.optimize import linear_sum_assignment
    -
    -kosten = np.array([[21, 45, 33, 60],      # Bauer
    -                   [18, 52, 29, 47],      # Cakir
    -                   [40, 24, 55, 38],      # Diaz
    -                   [35, 31, 26, 44]])     # Engel
    -monteur = ["Bauer", "Cakir", "Diaz", "Engel"]
    -ort = ["Nord", "Ost", "Sued", "West"]
    -
    -zeile, spalte = linear_sum_assignment(kosten)
    -for z, s in zip(zeile, spalte):
    -    print(f"{monteur[z]:6} -> {ort[s]:5} ({kosten[z, s]} min)")
    -print("Gesamtfahrzeit:", kosten[zeile, spalte].sum(), "min")
    +
    import numpy as np
    +from scipy.optimize import linear_sum_assignment
    +
    +kosten = np.array([[21, 45, 33, 60],      # Bauer
    +                   [18, 52, 29, 47],      # Cakir
    +                   [40, 24, 55, 38],      # Diaz
    +                   [35, 31, 26, 44]])     # Engel
    +monteur = ["Bauer", "Cakir", "Diaz", "Engel"]
    +ort = ["Nord", "Ost", "Sued", "West"]
    +
    +zeile, spalte = linear_sum_assignment(kosten)
    +for z, s in zip(zeile, spalte):
    +    print(f"{monteur[z]:6} -> {ort[s]:5} ({kosten[z, s]} min)")
    +print("Gesamtfahrzeit:", kosten[zeile, spalte].sum(), "min")

    Ausgabe:

    Bauer  -> Nord  (21 min)
     Cakir  -> Sued  (29 min)
    @@ -12165,103 +12481,103 @@ Gesamtfahrzeit: 118 min

    Ohne Formel gesagt: Das ist die Kirchhoffsche Knotenregel aus der Elektrotechnik — nichts geht verloren, nichts entsteht aus dem Nichts. Für einen Umladeknoten heißt das wörtlich: „Was ankommt, fährt auch wieder weg.“

    Wichtig: Damit das Problem lösbar ist, muss \sum_i b_i = 0 gelten — das Gesamtangebot muss dem Gesamtbedarf entsprechen.

    -
    #!/usr/bin/env python3
    -
    -# Min_Cost_Flow.py
    -"""
    -Kapitel Graphen: Kostenminimaler Fluss durch ein Netzwerk.
    -
    -Loest dasselbe Problem zweimal:
    -  (1) als allgemeines LP mit scipy  -> zeigt die Modellstruktur
    -  (2) mit dem spezialisierten Netzwerk-Solver von OR-Tools -> zeigt den
    -      Geschwindigkeitsvorteil eines Verfahrens, das die Struktur ausnutzt
    -
    -Beide laufen in eigenen Prozessen nicht noetig: scipy und ortools vertragen
    -sich (nur ortools + highspy kollidieren, siehe Kapitel Oekosystem).
    -"""
    -
    -import numpy as np
    -from scipy.optimize import linprog
    -
    -# --- Netzwerk definieren ---------------------------------------------------
    -KNOTEN = ["Werk_A", "Werk_B", "Umschlag", "Kunde_1", "Kunde_2"]
    -# (von, nach, Kosten je Einheit, Kapazitaet)
    -KANTEN = [
    -    ("Werk_A",   "Umschlag", 2.0, 15),
    -    ("Werk_A",   "Kunde_1",  5.0, 10),
    -    ("Werk_B",   "Umschlag", 4.0, 10),
    -    ("Werk_B",   "Kunde_2",  6.0, 10),
    -    ("Umschlag", "Kunde_1",  1.0, 20),
    -    ("Umschlag", "Kunde_2",  3.0, 10),
    -]
    -# Angebot (+) bzw. Bedarf (-) je Knoten
    -SALDO = {"Werk_A": 20, "Werk_B": 10, "Umschlag": 0, "Kunde_1": -15, "Kunde_2": -15}
    -
    -
    -def loese_als_lp():
    -    """Flussproblem als allgemeines lineares Programm."""
    -    n_kanten = len(KANTEN)
    -    knoten_index = {k: i for i, k in enumerate(KNOTEN)}
    -
    -    # Zielfunktion: Summe der Transportkosten
    -    kosten = np.array([k[2] for k in KANTEN])
    -
    -    # Flusserhaltung als Gleichungssystem: A_eq @ x = b_eq
    -    A_eq = np.zeros((len(KNOTEN), n_kanten))
    -    for e, (von, nach, _, _) in enumerate(KANTEN):
    -        A_eq[knoten_index[von], e] = +1.0      # fliesst hinaus
    -        A_eq[knoten_index[nach], e] = -1.0     # fliesst hinein
    -    b_eq = np.array([SALDO[k] for k in KNOTEN], dtype=float)
    -
    -    schranken = [(0, k[3]) for k in KANTEN]    # 0 <= x_ij <= u_ij
    -
    -    ergebnis = linprog(c=kosten, A_eq=A_eq, b_eq=b_eq, bounds=schranken, method="highs")
    -    if not ergebnis.success:
    -        raise SystemExit(f"Nicht loesbar: {ergebnis.message}")
    -    return ergebnis.fun, ergebnis.x, ergebnis.eqlin.marginals
    -
    -
    -if __name__ == "__main__":
    -    # Vorabpruefung: Angebot muss Bedarf entsprechen
    -    gesamt = sum(SALDO.values())
    -    print("=" * 78)
    -    print("  KOSTENMINIMALER FLUSS DURCH EIN TRANSPORTNETZ")
    -    print("=" * 78)
    -    print(f"Angebot gesamt: {sum(v for v in SALDO.values() if v > 0)} | "
    -          f"Bedarf gesamt: {-sum(v for v in SALDO.values() if v < 0)} | "
    -          f"Saldo: {gesamt}")
    -    if gesamt != 0:
    -        raise SystemExit("Angebot und Bedarf stimmen nicht ueberein - unloesbar!")
    -
    -    kosten_gesamt, fluss, knotenpreise = loese_als_lp()
    -
    -    print(f"\nMinimale Transportkosten: {kosten_gesamt:,.2f} EUR\n")
    -    print(f"{'Kante':<24} {'Fluss':>7} {'Kapazitaet':>11} {'Kosten/E':>9} {'Kosten':>9}")
    -    print("-" * 78)
    -    for e, (von, nach, c, u) in enumerate(KANTEN):
    -        menge = fluss[e] + 0.0 if abs(fluss[e]) > 1e-9 else 0.0   # vermeidet "-0.0"
    -        ausgelastet = " (VOLL)" if abs(menge - u) < 1e-6 else ""
    -        print(f"{von + ' -> ' + nach:<24} {menge:>7.1f} {u:>11} "
    -              f"{c:>9.2f} {menge * c:>9.2f}{ausgelastet}")
    -
    -    # --- Flusserhaltung nachpruefen --------------------------------------
    -    print("\n--- Pruefung der Flusserhaltung je Knoten ---")
    -    for k in KNOTEN:
    -        hinaus = sum(fluss[e] for e, (v, n, _, _) in enumerate(KANTEN) if v == k)
    -        hinein = sum(fluss[e] for e, (v, n, _, _) in enumerate(KANTEN) if n == k)
    -        netto = hinaus - hinein
    -        art = "Quelle" if SALDO[k] > 0 else ("Senke" if SALDO[k] < 0 else "Umschlag")
    -        print(f"  {k:<10} ({art:<8}): hinaus {hinaus:5.1f} - hinein {hinein:5.1f} "
    -              f"= {netto:+6.1f}  (gefordert: {SALDO[k]:+d})")
    -        assert abs(netto - SALDO[k]) < 1e-6, f"Flusserhaltung verletzt bei {k}!"
    -
    -    # --- Knotenpreise (Dualwerte) interpretieren -------------------------
    -    print("\n--- Knotenpreise (Dualwerte der Flusserhaltung) ---")
    -    print("  Differenz zweier Knotenpreise = Grenzkosten einer zusaetzlichen Einheit")
    -    print("  auf dem guenstigsten Weg zwischen ihnen.")
    -    for k, preis in zip(KNOTEN, knotenpreise):
    -        print(f"  {k:<10}: {preis:7.2f}")
    -    print("=" * 78)
    +
    #!/usr/bin/env python3
    +
    +# Min_Cost_Flow.py
    +"""
    +Kapitel Graphen: Kostenminimaler Fluss durch ein Netzwerk.
    +
    +Loest dasselbe Problem zweimal:
    +  (1) als allgemeines LP mit scipy  -> zeigt die Modellstruktur
    +  (2) mit dem spezialisierten Netzwerk-Solver von OR-Tools -> zeigt den
    +      Geschwindigkeitsvorteil eines Verfahrens, das die Struktur ausnutzt
    +
    +Beide laufen in eigenen Prozessen nicht noetig: scipy und ortools vertragen
    +sich (nur ortools + highspy kollidieren, siehe Kapitel Oekosystem).
    +"""
    +
    +import numpy as np
    +from scipy.optimize import linprog
    +
    +# --- Netzwerk definieren ---------------------------------------------------
    +KNOTEN = ["Werk_A", "Werk_B", "Umschlag", "Kunde_1", "Kunde_2"]
    +# (von, nach, Kosten je Einheit, Kapazitaet)
    +KANTEN = [
    +    ("Werk_A",   "Umschlag", 2.0, 15),
    +    ("Werk_A",   "Kunde_1",  5.0, 10),
    +    ("Werk_B",   "Umschlag", 4.0, 10),
    +    ("Werk_B",   "Kunde_2",  6.0, 10),
    +    ("Umschlag", "Kunde_1",  1.0, 20),
    +    ("Umschlag", "Kunde_2",  3.0, 10),
    +]
    +# Angebot (+) bzw. Bedarf (-) je Knoten
    +SALDO = {"Werk_A": 20, "Werk_B": 10, "Umschlag": 0, "Kunde_1": -15, "Kunde_2": -15}
    +
    +
    +def loese_als_lp():
    +    """Flussproblem als allgemeines lineares Programm."""
    +    n_kanten = len(KANTEN)
    +    knoten_index = {k: i for i, k in enumerate(KNOTEN)}
    +
    +    # Zielfunktion: Summe der Transportkosten
    +    kosten = np.array([k[2] for k in KANTEN])
    +
    +    # Flusserhaltung als Gleichungssystem: A_eq @ x = b_eq
    +    A_eq = np.zeros((len(KNOTEN), n_kanten))
    +    for e, (von, nach, _, _) in enumerate(KANTEN):
    +        A_eq[knoten_index[von], e] = +1.0      # fliesst hinaus
    +        A_eq[knoten_index[nach], e] = -1.0     # fliesst hinein
    +    b_eq = np.array([SALDO[k] for k in KNOTEN], dtype=float)
    +
    +    schranken = [(0, k[3]) for k in KANTEN]    # 0 <= x_ij <= u_ij
    +
    +    ergebnis = linprog(c=kosten, A_eq=A_eq, b_eq=b_eq, bounds=schranken, method="highs")
    +    if not ergebnis.success:
    +        raise SystemExit(f"Nicht loesbar: {ergebnis.message}")
    +    return ergebnis.fun, ergebnis.x, ergebnis.eqlin.marginals
    +
    +
    +if __name__ == "__main__":
    +    # Vorabpruefung: Angebot muss Bedarf entsprechen
    +    gesamt = sum(SALDO.values())
    +    print("=" * 78)
    +    print("  KOSTENMINIMALER FLUSS DURCH EIN TRANSPORTNETZ")
    +    print("=" * 78)
    +    print(f"Angebot gesamt: {sum(v for v in SALDO.values() if v > 0)} | "
    +          f"Bedarf gesamt: {-sum(v for v in SALDO.values() if v < 0)} | "
    +          f"Saldo: {gesamt}")
    +    if gesamt != 0:
    +        raise SystemExit("Angebot und Bedarf stimmen nicht ueberein - unloesbar!")
    +
    +    kosten_gesamt, fluss, knotenpreise = loese_als_lp()
    +
    +    print(f"\nMinimale Transportkosten: {kosten_gesamt:,.2f} EUR\n")
    +    print(f"{'Kante':<24} {'Fluss':>7} {'Kapazitaet':>11} {'Kosten/E':>9} {'Kosten':>9}")
    +    print("-" * 78)
    +    for e, (von, nach, c, u) in enumerate(KANTEN):
    +        menge = fluss[e] + 0.0 if abs(fluss[e]) > 1e-9 else 0.0   # vermeidet "-0.0"
    +        ausgelastet = " (VOLL)" if abs(menge - u) < 1e-6 else ""
    +        print(f"{von + ' -> ' + nach:<24} {menge:>7.1f} {u:>11} "
    +              f"{c:>9.2f} {menge * c:>9.2f}{ausgelastet}")
    +
    +    # --- Flusserhaltung nachpruefen --------------------------------------
    +    print("\n--- Pruefung der Flusserhaltung je Knoten ---")
    +    for k in KNOTEN:
    +        hinaus = sum(fluss[e] for e, (v, n, _, _) in enumerate(KANTEN) if v == k)
    +        hinein = sum(fluss[e] for e, (v, n, _, _) in enumerate(KANTEN) if n == k)
    +        netto = hinaus - hinein
    +        art = "Quelle" if SALDO[k] > 0 else ("Senke" if SALDO[k] < 0 else "Umschlag")
    +        print(f"  {k:<10} ({art:<8}): hinaus {hinaus:5.1f} - hinein {hinein:5.1f} "
    +              f"= {netto:+6.1f}  (gefordert: {SALDO[k]:+d})")
    +        assert abs(netto - SALDO[k]) < 1e-6, f"Flusserhaltung verletzt bei {k}!"
    +
    +    # --- Knotenpreise (Dualwerte) interpretieren -------------------------
    +    print("\n--- Knotenpreise (Dualwerte der Flusserhaltung) ---")
    +    print("  Differenz zweier Knotenpreise = Grenzkosten einer zusaetzlichen Einheit")
    +    print("  auf dem guenstigsten Weg zwischen ihnen.")
    +    for k, preis in zip(KNOTEN, knotenpreise):
    +        print(f"  {k:<10}: {preis:7.2f}")
    +    print("=" * 78)

    Erwartete Ausgabe:

    ==============================================================================
       KOSTENMINIMALER FLUSS DURCH EIN TRANSPORTNETZ
    @@ -12336,111 +12652,111 @@ Umschlag -> Kunde_2          5.0          10      3.00     15.00
     

    Die praktische Folge in einem Satz: Bei Zuordnungs- und Flussproblemen dürfen Sie die Ganzzahligkeit weglassen und trotzdem ganzzahlige Lösungen erwarten — Sie sparen sich die NP-Schwere von Branch-and-Bound vollständig.

    Und die Warnung dazu: Diese Eigenschaft ist zerbrechlich. Eine einzige zusätzliche Nebenbedingung, die nicht in das Schema passt — „höchstens drei Fahrzeuge insgesamt“, eine Fixkostenkopplung, eine Mindestabnahmemenge — zerstört die totale Unimodularität. Dann liefert die Relaxation wieder Brüche, und Sie brauchen doch ein MILP. Prüfen Sie das, bevor Sie sich auf die Struktur verlassen.

    -
    #!/usr/bin/env python3
    -
    -# Zuordnung_Ungarisch.py
    -"""
    -Kapitel Graphen: Das Zuordnungsproblem, dreifach geloest.
    -
    -  (1) Ungarischer Algorithmus (scipy.optimize.linear_sum_assignment) - O(n^3)
    -  (2) als LP OHNE Ganzzahligkeitsforderung -> liefert trotzdem 0/1 (Birkhoff)
    -  (3) als MILP MIT Ganzzahligkeitsforderung -> gleiches Ergebnis, mehr Aufwand
    -
    -Zeigt damit die praktische Bedeutung der totalen Unimodularitaet.
    -"""
    -
    -import time
    -
    -import numpy as np
    -from scipy.optimize import linear_sum_assignment, linprog
    -
    -
    -def erzeuge_kosten(n, seed=11):
    -    rng = np.random.default_rng(seed)
    -    return rng.integers(10, 99, size=(n, n)).astype(float)
    -
    -
    -def loese_ungarisch(kosten):
    -    zeilen, spalten = linear_sum_assignment(kosten)
    -    return kosten[zeilen, spalten].sum(), spalten
    -
    -
    -def baue_lp(kosten):
    -    """Gemeinsame LP-Struktur fuer Variante 2 und 3."""
    -    n = len(kosten)
    -    c = kosten.flatten()                       # x_ij in Zeilenreihenfolge
    -    A_eq = np.zeros((2 * n, n * n))
    -    for i in range(n):                         # jede Person genau eine Aufgabe
    -        A_eq[i, i * n:(i + 1) * n] = 1.0
    -    for j in range(n):                         # jede Aufgabe genau einer Person
    -        A_eq[n + j, j::n] = 1.0
    -    b_eq = np.ones(2 * n)
    -    return c, A_eq, b_eq
    -
    -
    -def loese_lp(kosten, ganzzahlig):
    -    n = len(kosten)
    -    c, A_eq, b_eq = baue_lp(kosten)
    -    ergebnis = linprog(c=c, A_eq=A_eq, b_eq=b_eq, bounds=[(0, 1)] * (n * n),
    -                       integrality=np.ones(n * n) if ganzzahlig else None,
    -                       method="highs")
    -    x = ergebnis.x.reshape(n, n)
    -    return ergebnis.fun, x
    -
    -
    -if __name__ == "__main__":
    -    print("=" * 84)
    -    print("  ZUORDNUNGSPROBLEM: DREI WEGE ZUM SELBEN ERGEBNIS")
    -    print("=" * 84)
    -
    -    # --- Kleines Beispiel zum Nachvollziehen ------------------------------
    -    kosten = np.array([[82., 83., 69., 92.],
    -                       [77., 37., 49., 92.],
    -                       [11., 69., 5., 86.],
    -                       [8., 9., 98., 23.]])
    -    namen = ["Anna", "Ben", "Carla", "David"]
    -    aufgaben = ["Auftrag W", "Auftrag X", "Auftrag Y", "Auftrag Z"]
    -
    -    print("\nKostenmatrix (wer bearbeitet was zu welchen Kosten?):")
    -    print(f"{'':<8}" + "".join(f"{a:>12}" for a in aufgaben))
    -    for i, name in enumerate(namen):
    -        print(f"{name:<8}" + "".join(f"{kosten[i, j]:>12.0f}" for j in range(4)))
    -
    -    wert, zuordnung = loese_ungarisch(kosten)
    -    print(f"\nOptimale Zuordnung (Gesamtkosten {wert:.0f}):")
    -    for i, j in enumerate(zuordnung):
    -        print(f"  {namen[i]:<8} -> {aufgaben[j]:<12} ({kosten[i, j]:.0f} EUR)")
    -
    -    # --- Nachweis: LP ohne Ganzzahligkeit liefert trotzdem 0/1 -----------
    -    wert_lp, x_lp = loese_lp(kosten, ganzzahlig=False)
    -    ist_binaer = np.all((np.abs(x_lp) < 1e-9) | (np.abs(x_lp - 1) < 1e-9))
    -    print(f"\nLP OHNE Ganzzahligkeitsforderung: Kosten {wert_lp:.0f}, "
    -          f"Loesung ist {'0/1-wertig' if ist_binaer else 'GEBROCHEN'}")
    -    print("  -> Satz von Birkhoff/von Neumann bestaetigt: Die Ecken sind Permutationen.")
    -
    -    # --- Laufzeitvergleich bei wachsender Groesse ------------------------
    -    print("\n" + "-" * 84)
    -    print(f"{'n':>4} | {'Ungarisch':>12} | {'LP (kontinuierlich)':>21} | "
    -          f"{'MILP (ganzzahlig)':>19} | {'gleich?':>8}")
    -    print("-" * 84)
    -    for n in [10, 25, 50, 100]:
    -        k = erzeuge_kosten(n)
    -
    -        t0 = time.perf_counter(); w1, _ = loese_ungarisch(k); t1 = time.perf_counter() - t0
    -        t0 = time.perf_counter(); w2, _ = loese_lp(k, False);  t2 = time.perf_counter() - t0
    -        if n <= 50:
    -            t0 = time.perf_counter(); w3, _ = loese_lp(k, True); t3 = time.perf_counter() - t0
    -            t3_text, gleich = f"{t3*1000:>16.1f} ms", abs(w1 - w3) < 1e-6
    -        else:
    -            t3_text, gleich = f"{'uebersprungen':>19}", abs(w1 - w2) < 1e-6
    -
    -        print(f"{n:>4} | {t1*1000:>9.1f} ms | {t2*1000:>18.1f} ms | {t3_text} | "
    -              f"{'ja' if gleich else 'NEIN':>8}")
    -
    -    print("-" * 84)
    -    print("Fazit: Der spezialisierte Ungarische Algorithmus ist um Groessenordnungen")
    -    print("schneller. Nutzen Sie fuer reine Zuordnungen NIE einen MILP-Solver.")
    -    print("=" * 84)
    +
    #!/usr/bin/env python3
    +
    +# Zuordnung_Ungarisch.py
    +"""
    +Kapitel Graphen: Das Zuordnungsproblem, dreifach geloest.
    +
    +  (1) Ungarischer Algorithmus (scipy.optimize.linear_sum_assignment) - O(n^3)
    +  (2) als LP OHNE Ganzzahligkeitsforderung -> liefert trotzdem 0/1 (Birkhoff)
    +  (3) als MILP MIT Ganzzahligkeitsforderung -> gleiches Ergebnis, mehr Aufwand
    +
    +Zeigt damit die praktische Bedeutung der totalen Unimodularitaet.
    +"""
    +
    +import time
    +
    +import numpy as np
    +from scipy.optimize import linear_sum_assignment, linprog
    +
    +
    +def erzeuge_kosten(n, seed=11):
    +    rng = np.random.default_rng(seed)
    +    return rng.integers(10, 99, size=(n, n)).astype(float)
    +
    +
    +def loese_ungarisch(kosten):
    +    zeilen, spalten = linear_sum_assignment(kosten)
    +    return kosten[zeilen, spalten].sum(), spalten
    +
    +
    +def baue_lp(kosten):
    +    """Gemeinsame LP-Struktur fuer Variante 2 und 3."""
    +    n = len(kosten)
    +    c = kosten.flatten()                       # x_ij in Zeilenreihenfolge
    +    A_eq = np.zeros((2 * n, n * n))
    +    for i in range(n):                         # jede Person genau eine Aufgabe
    +        A_eq[i, i * n:(i + 1) * n] = 1.0
    +    for j in range(n):                         # jede Aufgabe genau einer Person
    +        A_eq[n + j, j::n] = 1.0
    +    b_eq = np.ones(2 * n)
    +    return c, A_eq, b_eq
    +
    +
    +def loese_lp(kosten, ganzzahlig):
    +    n = len(kosten)
    +    c, A_eq, b_eq = baue_lp(kosten)
    +    ergebnis = linprog(c=c, A_eq=A_eq, b_eq=b_eq, bounds=[(0, 1)] * (n * n),
    +                       integrality=np.ones(n * n) if ganzzahlig else None,
    +                       method="highs")
    +    x = ergebnis.x.reshape(n, n)
    +    return ergebnis.fun, x
    +
    +
    +if __name__ == "__main__":
    +    print("=" * 84)
    +    print("  ZUORDNUNGSPROBLEM: DREI WEGE ZUM SELBEN ERGEBNIS")
    +    print("=" * 84)
    +
    +    # --- Kleines Beispiel zum Nachvollziehen ------------------------------
    +    kosten = np.array([[82., 83., 69., 92.],
    +                       [77., 37., 49., 92.],
    +                       [11., 69., 5., 86.],
    +                       [8., 9., 98., 23.]])
    +    namen = ["Anna", "Ben", "Carla", "David"]
    +    aufgaben = ["Auftrag W", "Auftrag X", "Auftrag Y", "Auftrag Z"]
    +
    +    print("\nKostenmatrix (wer bearbeitet was zu welchen Kosten?):")
    +    print(f"{'':<8}" + "".join(f"{a:>12}" for a in aufgaben))
    +    for i, name in enumerate(namen):
    +        print(f"{name:<8}" + "".join(f"{kosten[i, j]:>12.0f}" for j in range(4)))
    +
    +    wert, zuordnung = loese_ungarisch(kosten)
    +    print(f"\nOptimale Zuordnung (Gesamtkosten {wert:.0f}):")
    +    for i, j in enumerate(zuordnung):
    +        print(f"  {namen[i]:<8} -> {aufgaben[j]:<12} ({kosten[i, j]:.0f} EUR)")
    +
    +    # --- Nachweis: LP ohne Ganzzahligkeit liefert trotzdem 0/1 -----------
    +    wert_lp, x_lp = loese_lp(kosten, ganzzahlig=False)
    +    ist_binaer = np.all((np.abs(x_lp) < 1e-9) | (np.abs(x_lp - 1) < 1e-9))
    +    print(f"\nLP OHNE Ganzzahligkeitsforderung: Kosten {wert_lp:.0f}, "
    +          f"Loesung ist {'0/1-wertig' if ist_binaer else 'GEBROCHEN'}")
    +    print("  -> Satz von Birkhoff/von Neumann bestaetigt: Die Ecken sind Permutationen.")
    +
    +    # --- Laufzeitvergleich bei wachsender Groesse ------------------------
    +    print("\n" + "-" * 84)
    +    print(f"{'n':>4} | {'Ungarisch':>12} | {'LP (kontinuierlich)':>21} | "
    +          f"{'MILP (ganzzahlig)':>19} | {'gleich?':>8}")
    +    print("-" * 84)
    +    for n in [10, 25, 50, 100]:
    +        k = erzeuge_kosten(n)
    +
    +        t0 = time.perf_counter(); w1, _ = loese_ungarisch(k); t1 = time.perf_counter() - t0
    +        t0 = time.perf_counter(); w2, _ = loese_lp(k, False);  t2 = time.perf_counter() - t0
    +        if n <= 50:
    +            t0 = time.perf_counter(); w3, _ = loese_lp(k, True); t3 = time.perf_counter() - t0
    +            t3_text, gleich = f"{t3*1000:>16.1f} ms", abs(w1 - w3) < 1e-6
    +        else:
    +            t3_text, gleich = f"{'uebersprungen':>19}", abs(w1 - w2) < 1e-6
    +
    +        print(f"{n:>4} | {t1*1000:>9.1f} ms | {t2*1000:>18.1f} ms | {t3_text} | "
    +              f"{'ja' if gleich else 'NEIN':>8}")
    +
    +    print("-" * 84)
    +    print("Fazit: Der spezialisierte Ungarische Algorithmus ist um Groessenordnungen")
    +    print("schneller. Nutzen Sie fuer reine Zuordnungen NIE einen MILP-Solver.")
    +    print("=" * 84)

    💻 Code-Durchgang: die Indexakrobatik

    Die Variable x_{ij} wird zu einem flachen Vektor der Länge n^2 aufgerollt: Position von x_{ij} ist i \cdot n + j. * A_eq[i, i*n:(i+1)*n] = 1 — Zeile i: alle Aufgaben einer Person (ein zusammenhängender Block). * A_eq[n+j, j::n] = 1 — Zeile n+j: alle Personen einer Aufgabe (jedes n-te Element, deshalb die Schrittweite ::n).

    @@ -12466,171 +12782,171 @@ u_i - u_j + C \cdot x_{ij} \le C - d_j \qquad \forall i \ne j

    💡 In der Praxis: nicht selbst modellieren Die MTZ-Formulierung ist didaktisch wertvoll, aber für reale Instanzen zu schwach — die LP-Relaxation ist sehr locker, und Branch-and-Bound braucht sehr lange. Professionelle Solver verwenden stattdessen dynamisch erzeugte Subtour-Eliminationsschnitte oder, wie OR-Tools, spezialisierte Metaheuristiken. Nutzen Sie für Routing die Routing-Bibliothek, nicht ein selbstgebautes MILP.

    Praxisbeispiel: Flotten-Routing

    -
    #!/usr/bin/env python3
    -
    -# VRP_Flotten_Routing.py
    -"""
    -Kapitel Graphen: Capacitated Vehicle Routing Problem with Time Windows (CVRPTW)
    -mit der Routing-Bibliothek von Google OR-Tools.
    -
    -Eigenschaften:
    -  * Eingabedaten werden vorab auf Plausibilitaet geprueft (Kapazitaet
    -    ausreichend? Zeitfenster erreichbar?)
    -  * Fahrzeit und Servicezeit werden getrennt ausgewiesen
    -  * Ausgabe als lesbarer Tourenplan mit Ankunftszeiten
    -  * Kennzahlen: Auslastung, Leerfahrten, Wartezeit
    -"""
    -
    -import numpy as np
    -from ortools.constraint_solver import pywrapcp, routing_enums_pb2
    -
    -SERVICEZEIT = 10          # Minuten je Kundenstopp
    -WARTEZEIT_MAX = 60        # zulaessige Wartezeit bei zu frueher Ankunft
    -SCHICHTLAENGE = 600       # Minuten
    -
    -
    -def erzeuge_daten(seed: int = 42):
    -    """Synthetische, aber reproduzierbare Instanz: 1 Depot + 16 Kunden."""
    -    anzahl_orte = 17
    -    rng = np.random.default_rng(seed)
    -    koordinaten = rng.random((anzahl_orte, 2)) * 100      # 100 x 100 km Raster
    -
    -    distanz = np.zeros((anzahl_orte, anzahl_orte), dtype=int)
    -    for i in range(anzahl_orte):
    -        for j in range(anzahl_orte):
    -            distanz[i][j] = int(np.linalg.norm(koordinaten[i] - koordinaten[j]))
    -
    -    return {
    -        "distanzmatrix": distanz.tolist(),
    -        "zeitfenster": [
    -            (0, SCHICHTLAENGE),                                   # 0: Depot
    -            (30, 120),  (60, 180),  (100, 240), (150, 300),       # Kunden 1-4
    -            (60, 180),  (120, 240), (200, 360), (300, 450),       # Kunden 5-8
    -            (180, 300), (240, 360), (300, 480), (360, 500),       # Kunden 9-12
    -            (60, 200),  (120, 300), (240, 400), (300, 550),       # Kunden 13-16
    -        ],
    -        "bedarfe": [0, 2, 3, 1, 4, 2, 2, 3, 1, 2, 4, 3, 2, 1, 2, 3, 2],
    -        "kapazitaeten": [10, 10, 10, 10],
    -        "anzahl_fahrzeuge": 4,
    -        "depot": 0,
    -    }
    -
    -
    -def pruefe_daten(daten) -> None:
    -    """Vorabdiagnose - fangt die haeufigsten Ursachen fuer 'keine Loesung' ab."""
    -    gesamtbedarf = sum(daten["bedarfe"])
    -    gesamtkapazitaet = sum(daten["kapazitaeten"])
    -    print(f"Gesamtbedarf {gesamtbedarf} Einheiten | "
    -          f"Flottenkapazitaet {gesamtkapazitaet} Einheiten | "
    -          f"Auslastung {gesamtbedarf / gesamtkapazitaet * 100:.0f} %")
    -    if gesamtbedarf > gesamtkapazitaet:
    -        raise SystemExit("UNLOESBAR: Der Bedarf uebersteigt die Flottenkapazitaet.")
    -
    -    d = daten["distanzmatrix"]
    -    for kunde, (fruehestens, spaetestens) in enumerate(daten["zeitfenster"]):
    -        if kunde == 0:
    -            continue
    -        direktfahrt = d[0][kunde]
    -        if direktfahrt > spaetestens:
    -            raise SystemExit(
    -                f"UNLOESBAR: Kunde {kunde} ist erst nach {direktfahrt} min erreichbar, "
    -                f"sein Zeitfenster endet aber bei {spaetestens} min.")
    -    print("Vorabpruefung bestanden: Kapazitaet und Zeitfenster sind grundsaetzlich machbar.")
    -
    -
    -def loese_cvrptw(zeitlimit_s: int = 5):
    -    daten = erzeuge_daten()
    -    pruefe_daten(daten)
    -
    -    manager = pywrapcp.RoutingIndexManager(
    -        len(daten["distanzmatrix"]), daten["anzahl_fahrzeuge"], daten["depot"])
    -    routing = pywrapcp.RoutingModel(manager)
    -
    -    # --- Fahrzeit + Servicezeit als Kantengewicht ------------------------
    -    def zeit_callback(von_index, nach_index):
    -        von = manager.IndexToNode(von_index)
    -        nach = manager.IndexToNode(nach_index)
    -        service = SERVICEZEIT if von != daten["depot"] else 0
    -        return daten["distanzmatrix"][von][nach] + service
    -
    -    zeit_index = routing.RegisterTransitCallback(zeit_callback)
    -    routing.SetArcCostEvaluatorOfAllVehicles(zeit_index)
    -
    -    # --- Kapazitaetsdimension ---------------------------------------------
    -    def bedarf_callback(von_index):
    -        return daten["bedarfe"][manager.IndexToNode(von_index)]
    -
    -    bedarf_index = routing.RegisterUnaryTransitCallback(bedarf_callback)
    -    routing.AddDimensionWithVehicleCapacity(
    -        bedarf_index, 0, daten["kapazitaeten"], True, "Kapazitaet")
    -
    -    # --- Zeitdimension mit Zeitfenstern -----------------------------------
    -    routing.AddDimension(zeit_index, WARTEZEIT_MAX, SCHICHTLAENGE, False, "Zeit")
    -    zeit_dimension = routing.GetDimensionOrDie("Zeit")
    -    for ort, (fruehestens, spaetestens) in enumerate(daten["zeitfenster"]):
    -        zeit_dimension.CumulVar(manager.NodeToIndex(ort)).SetRange(fruehestens, spaetestens)
    -
    -    # --- Suchparameter -----------------------------------------------------
    -    parameter = pywrapcp.DefaultRoutingSearchParameters()
    -    parameter.first_solution_strategy = (
    -        routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC)
    -    parameter.local_search_metaheuristic = (
    -        routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH)
    -    parameter.time_limit.seconds = zeitlimit_s
    -
    -    loesung = routing.SolveWithParameters(parameter)
    -    if not loesung:
    -        print("Keine zulaessige Routenfuehrung gefunden.")
    -        return
    -
    -    # --- Auswertung --------------------------------------------------------
    -    print("\n" + "=" * 84)
    -    print("         OPTIMIERTER TOURENPLAN (CVRPTW)")
    -    print("=" * 84)
    -
    -    gesamtzeit = gesamtfracht = gesamtdistanz = 0
    -    kapazitaet = daten["kapazitaeten"]
    -
    -    for fahrzeug in range(daten["anzahl_fahrzeuge"]):
    -        index = routing.Start(fahrzeug)
    -        if routing.IsEnd(loesung.Value(routing.NextVar(index))):
    -            print(f"\nFahrzeug {fahrzeug + 1}: nicht eingesetzt")
    -            continue
    -
    -        stationen, fracht, distanz = [], 0, 0
    -        while not routing.IsEnd(index):
    -            knoten = manager.IndexToNode(index)
    -            ankunft = loesung.Min(zeit_dimension.CumulVar(index))
    -            fracht += daten["bedarfe"][knoten]
    -            bezeichnung = "Depot" if knoten == 0 else f"K{knoten}"
    -            stationen.append(f"{bezeichnung}@{ankunft}")
    -            naechster = loesung.Value(routing.NextVar(index))
    -            distanz += daten["distanzmatrix"][knoten][manager.IndexToNode(naechster)]
    -            index = naechster
    -
    -        endzeit = loesung.Min(zeit_dimension.CumulVar(index))
    -        stationen.append(f"Depot@{endzeit}")
    -        gesamtzeit += endzeit
    -        gesamtfracht += fracht
    -        gesamtdistanz += distanz
    -
    -        print(f"\nFahrzeug {fahrzeug + 1}:")
    -        print("  " + " -> ".join(stationen))
    -        print(f"  Schichtzeit {endzeit} min | Fahrstrecke {distanz} km | "
    -              f"Fracht {fracht}/{kapazitaet[fahrzeug]} "
    -              f"({fracht / kapazitaet[fahrzeug] * 100:.0f} % Auslastung)")
    -
    -    print("\n" + "-" * 84)
    -    print(f"Summe Schichtzeiten:   {gesamtzeit} min")
    -    print(f"Summe Fahrstrecken:    {gesamtdistanz} km")
    -    print(f"Transportierte Fracht: {gesamtfracht} von {sum(daten['bedarfe'])} Einheiten")
    -    assert gesamtfracht == sum(daten["bedarfe"]), "Nicht alle Kunden wurden beliefert!"
    -    print("Alle Kunden wurden innerhalb ihrer Zeitfenster beliefert.")
    -    print("=" * 84)
    -
    -
    -if __name__ == "__main__":
    -    loese_cvrptw()
    +
    #!/usr/bin/env python3
    +
    +# VRP_Flotten_Routing.py
    +"""
    +Kapitel Graphen: Capacitated Vehicle Routing Problem with Time Windows (CVRPTW)
    +mit der Routing-Bibliothek von Google OR-Tools.
    +
    +Eigenschaften:
    +  * Eingabedaten werden vorab auf Plausibilitaet geprueft (Kapazitaet
    +    ausreichend? Zeitfenster erreichbar?)
    +  * Fahrzeit und Servicezeit werden getrennt ausgewiesen
    +  * Ausgabe als lesbarer Tourenplan mit Ankunftszeiten
    +  * Kennzahlen: Auslastung, Leerfahrten, Wartezeit
    +"""
    +
    +import numpy as np
    +from ortools.constraint_solver import pywrapcp, routing_enums_pb2
    +
    +SERVICEZEIT = 10          # Minuten je Kundenstopp
    +WARTEZEIT_MAX = 60        # zulaessige Wartezeit bei zu frueher Ankunft
    +SCHICHTLAENGE = 600       # Minuten
    +
    +
    +def erzeuge_daten(seed: int = 42):
    +    """Synthetische, aber reproduzierbare Instanz: 1 Depot + 16 Kunden."""
    +    anzahl_orte = 17
    +    rng = np.random.default_rng(seed)
    +    koordinaten = rng.random((anzahl_orte, 2)) * 100      # 100 x 100 km Raster
    +
    +    distanz = np.zeros((anzahl_orte, anzahl_orte), dtype=int)
    +    for i in range(anzahl_orte):
    +        for j in range(anzahl_orte):
    +            distanz[i][j] = int(np.linalg.norm(koordinaten[i] - koordinaten[j]))
    +
    +    return {
    +        "distanzmatrix": distanz.tolist(),
    +        "zeitfenster": [
    +            (0, SCHICHTLAENGE),                                   # 0: Depot
    +            (30, 120),  (60, 180),  (100, 240), (150, 300),       # Kunden 1-4
    +            (60, 180),  (120, 240), (200, 360), (300, 450),       # Kunden 5-8
    +            (180, 300), (240, 360), (300, 480), (360, 500),       # Kunden 9-12
    +            (60, 200),  (120, 300), (240, 400), (300, 550),       # Kunden 13-16
    +        ],
    +        "bedarfe": [0, 2, 3, 1, 4, 2, 2, 3, 1, 2, 4, 3, 2, 1, 2, 3, 2],
    +        "kapazitaeten": [10, 10, 10, 10],
    +        "anzahl_fahrzeuge": 4,
    +        "depot": 0,
    +    }
    +
    +
    +def pruefe_daten(daten) -> None:
    +    """Vorabdiagnose - fangt die haeufigsten Ursachen fuer 'keine Loesung' ab."""
    +    gesamtbedarf = sum(daten["bedarfe"])
    +    gesamtkapazitaet = sum(daten["kapazitaeten"])
    +    print(f"Gesamtbedarf {gesamtbedarf} Einheiten | "
    +          f"Flottenkapazitaet {gesamtkapazitaet} Einheiten | "
    +          f"Auslastung {gesamtbedarf / gesamtkapazitaet * 100:.0f} %")
    +    if gesamtbedarf > gesamtkapazitaet:
    +        raise SystemExit("UNLOESBAR: Der Bedarf uebersteigt die Flottenkapazitaet.")
    +
    +    d = daten["distanzmatrix"]
    +    for kunde, (fruehestens, spaetestens) in enumerate(daten["zeitfenster"]):
    +        if kunde == 0:
    +            continue
    +        direktfahrt = d[0][kunde]
    +        if direktfahrt > spaetestens:
    +            raise SystemExit(
    +                f"UNLOESBAR: Kunde {kunde} ist erst nach {direktfahrt} min erreichbar, "
    +                f"sein Zeitfenster endet aber bei {spaetestens} min.")
    +    print("Vorabpruefung bestanden: Kapazitaet und Zeitfenster sind grundsaetzlich machbar.")
    +
    +
    +def loese_cvrptw(zeitlimit_s: int = 5):
    +    daten = erzeuge_daten()
    +    pruefe_daten(daten)
    +
    +    manager = pywrapcp.RoutingIndexManager(
    +        len(daten["distanzmatrix"]), daten["anzahl_fahrzeuge"], daten["depot"])
    +    routing = pywrapcp.RoutingModel(manager)
    +
    +    # --- Fahrzeit + Servicezeit als Kantengewicht ------------------------
    +    def zeit_callback(von_index, nach_index):
    +        von = manager.IndexToNode(von_index)
    +        nach = manager.IndexToNode(nach_index)
    +        service = SERVICEZEIT if von != daten["depot"] else 0
    +        return daten["distanzmatrix"][von][nach] + service
    +
    +    zeit_index = routing.RegisterTransitCallback(zeit_callback)
    +    routing.SetArcCostEvaluatorOfAllVehicles(zeit_index)
    +
    +    # --- Kapazitaetsdimension ---------------------------------------------
    +    def bedarf_callback(von_index):
    +        return daten["bedarfe"][manager.IndexToNode(von_index)]
    +
    +    bedarf_index = routing.RegisterUnaryTransitCallback(bedarf_callback)
    +    routing.AddDimensionWithVehicleCapacity(
    +        bedarf_index, 0, daten["kapazitaeten"], True, "Kapazitaet")
    +
    +    # --- Zeitdimension mit Zeitfenstern -----------------------------------
    +    routing.AddDimension(zeit_index, WARTEZEIT_MAX, SCHICHTLAENGE, False, "Zeit")
    +    zeit_dimension = routing.GetDimensionOrDie("Zeit")
    +    for ort, (fruehestens, spaetestens) in enumerate(daten["zeitfenster"]):
    +        zeit_dimension.CumulVar(manager.NodeToIndex(ort)).SetRange(fruehestens, spaetestens)
    +
    +    # --- Suchparameter -----------------------------------------------------
    +    parameter = pywrapcp.DefaultRoutingSearchParameters()
    +    parameter.first_solution_strategy = (
    +        routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC)
    +    parameter.local_search_metaheuristic = (
    +        routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH)
    +    parameter.time_limit.seconds = zeitlimit_s
    +
    +    loesung = routing.SolveWithParameters(parameter)
    +    if not loesung:
    +        print("Keine zulaessige Routenfuehrung gefunden.")
    +        return
    +
    +    # --- Auswertung --------------------------------------------------------
    +    print("\n" + "=" * 84)
    +    print("         OPTIMIERTER TOURENPLAN (CVRPTW)")
    +    print("=" * 84)
    +
    +    gesamtzeit = gesamtfracht = gesamtdistanz = 0
    +    kapazitaet = daten["kapazitaeten"]
    +
    +    for fahrzeug in range(daten["anzahl_fahrzeuge"]):
    +        index = routing.Start(fahrzeug)
    +        if routing.IsEnd(loesung.Value(routing.NextVar(index))):
    +            print(f"\nFahrzeug {fahrzeug + 1}: nicht eingesetzt")
    +            continue
    +
    +        stationen, fracht, distanz = [], 0, 0
    +        while not routing.IsEnd(index):
    +            knoten = manager.IndexToNode(index)
    +            ankunft = loesung.Min(zeit_dimension.CumulVar(index))
    +            fracht += daten["bedarfe"][knoten]
    +            bezeichnung = "Depot" if knoten == 0 else f"K{knoten}"
    +            stationen.append(f"{bezeichnung}@{ankunft}")
    +            naechster = loesung.Value(routing.NextVar(index))
    +            distanz += daten["distanzmatrix"][knoten][manager.IndexToNode(naechster)]
    +            index = naechster
    +
    +        endzeit = loesung.Min(zeit_dimension.CumulVar(index))
    +        stationen.append(f"Depot@{endzeit}")
    +        gesamtzeit += endzeit
    +        gesamtfracht += fracht
    +        gesamtdistanz += distanz
    +
    +        print(f"\nFahrzeug {fahrzeug + 1}:")
    +        print("  " + " -> ".join(stationen))
    +        print(f"  Schichtzeit {endzeit} min | Fahrstrecke {distanz} km | "
    +              f"Fracht {fracht}/{kapazitaet[fahrzeug]} "
    +              f"({fracht / kapazitaet[fahrzeug] * 100:.0f} % Auslastung)")
    +
    +    print("\n" + "-" * 84)
    +    print(f"Summe Schichtzeiten:   {gesamtzeit} min")
    +    print(f"Summe Fahrstrecken:    {gesamtdistanz} km")
    +    print(f"Transportierte Fracht: {gesamtfracht} von {sum(daten['bedarfe'])} Einheiten")
    +    assert gesamtfracht == sum(daten["bedarfe"]), "Nicht alle Kunden wurden beliefert!"
    +    print("Alle Kunden wurden innerhalb ihrer Zeitfenster beliefert.")
    +    print("=" * 84)
    +
    +
    +if __name__ == "__main__":
    +    loese_cvrptw()

    ⚠️ Typische Fehler beim VRP

      @@ -12658,186 +12974,186 @@ u_i - u_j + C \cdot x_{ij} \le C - d_j \qquad \forall i \ne j

      🐛 Finde den Denkfehler: Die vergessene Dimension

      Eine Spedition lässt ihre Tagestouren optimieren: 16 Kunden, 4 Fahrzeuge zu je 10 Paletten, Gesamtbedarf 37 Paletten. Das erste Ergebnis begeistert alle — 326 km. Nach einem Hinweis aus dem Fuhrpark wird das Modell überarbeitet; jetzt kommen 572 km heraus, 75 % mehr. Der Auftraggeber ist verärgert: „Ihre erste Version war doch viel besser.“

      Der Unterschied zwischen beiden Fassungen ist ein einziger Codeblock:

      -
      def bedarf(index):
      -    return BEDARFE[manager.IndexToNode(index)]
      -
      -bedarf_id = routing.RegisterUnaryTransitCallback(bedarf)
      -routing.AddDimensionWithVehicleCapacity(
      -    bedarf_id, 0, KAPAZITAETEN, True, "Ladung")
      +
      def bedarf(index):
      +    return BEDARFE[manager.IndexToNode(index)]
      +
      +bedarf_id = routing.RegisterUnaryTransitCallback(bedarf)
      +routing.AddDimensionWithVehicleCapacity(
      +    bedarf_id, 0, KAPAZITAETEN, True, "Ladung")

      Ihre Aufgabe: (a) Sehen Sie sich unten die Tourenübersicht des ersten Laufs an. Was tun die Fahrzeuge 1 bis 3, und wie viel lädt Fahrzeug 4? (b) Warum hat die Routing-Bibliothek das nicht von allein verhindert — die Kapazitäten standen doch in den Daten? (c) Warum ist ausgerechnet ein besser aussehendes Ergebnis hier das gefährliche? (d) Welche Prüfung hätte den Fehler sofort sichtbar gemacht — und warum darf sie nicht dieselben Bausteine benutzen wie das Modell?

      Auflösung: Abschnitt A.8.

    -
    #!/usr/bin/env python3
    -
    -# VRP_Kapazitaetsfalle.py
    -"""
    -Kapitel Graphen: Die vergessene Dimension.
    -
    -Die Routing-Bibliothek von OR-Tools kennt keine "Kapazitaet" von sich aus.
    -Sie kennt nur DIMENSIONEN - benannte Groessen, die sich entlang einer Tour
    -aufsummieren und begrenzt werden koennen. Distanz ist eine, Zeit ist eine,
    -Ladung ist eine. Wer eine davon nicht anlegt, bekommt trotzdem eine Loesung:
    -eine schoene, kurze, guenstige - und unfahrbare.
    -
    -Dieses Programm loest dieselbe Instanz zweimal und prueft beide Ergebnisse
    -gegen die tatsaechlichen Lademengen.
    -
    -Instanz: 1 Depot, 16 Kunden, 4 Fahrzeuge zu je 10 Paletten.
    -Gesamtbedarf 37 Paletten bei 40 Paletten Flottenkapazitaet - es ist also
    -knapp, aber machbar.
    -
    -Benoetigt: numpy, ortools
    -"""
    -
    -from __future__ import annotations
    -
    -import numpy as np
    -from ortools.constraint_solver import pywrapcp, routing_enums_pb2
    -
    -# Dieselbe Instanz wie VRP_Flotten_Routing.py
    -BEDARFE = [0, 2, 3, 1, 4, 2, 2, 3, 1, 2, 4, 3, 2, 1, 2, 3, 2]
    -KAPAZITAETEN = [10, 10, 10, 10]
    -ANZAHL_FAHRZEUGE = 4
    -DEPOT = 0
    -
    -
    -def distanzmatrix(seed: int = 42) -> list[list[int]]:
    -    rng = np.random.default_rng(seed)
    -    koordinaten = rng.random((len(BEDARFE), 2)) * 100         # 100 x 100 km
    -    n = len(BEDARFE)
    -    return [[int(np.linalg.norm(koordinaten[i] - koordinaten[j]))
    -             for j in range(n)] for i in range(n)]
    -
    -
    -def plane(mit_kapazitaet: bool, zeitlimit: int = 5) -> dict:
    -    """Loest die Tourenplanung - wahlweise mit oder ohne Ladungsdimension."""
    -    distanz = distanzmatrix()
    -    manager = pywrapcp.RoutingIndexManager(len(distanz), ANZAHL_FAHRZEUGE, DEPOT)
    -    routing = pywrapcp.RoutingModel(manager)
    -
    -    def entfernung(von_index, nach_index):
    -        return distanz[manager.IndexToNode(von_index)][manager.IndexToNode(nach_index)]
    -
    -    kosten_id = routing.RegisterTransitCallback(entfernung)
    -    routing.SetArcCostEvaluatorOfAllVehicles(kosten_id)
    -
    -    # DIE entscheidende Stelle. Ohne diesen Block existiert im Modell keine
    -    # Ladung - die Fahrzeuge sind dann unendlich gross.
    -    if mit_kapazitaet:
    -        def bedarf(index):
    -            return BEDARFE[manager.IndexToNode(index)]
    -
    -        bedarf_id = routing.RegisterUnaryTransitCallback(bedarf)
    -        routing.AddDimensionWithVehicleCapacity(
    -            bedarf_id,
    -            0,                      # kein Zwischenpuffer
    -            KAPAZITAETEN,           # Obergrenze je Fahrzeug
    -            True,                   # Ladung startet bei 0
    -            "Ladung")
    -
    -    parameter = pywrapcp.DefaultRoutingSearchParameters()
    -    parameter.first_solution_strategy = (
    -        routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC)
    -    parameter.local_search_metaheuristic = (
    -        routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH)
    -    parameter.time_limit.FromSeconds(zeitlimit)
    -
    -    loesung = routing.SolveWithParameters(parameter)
    -    if loesung is None:
    -        raise RuntimeError("Keine Loesung gefunden")
    -
    -    touren, strecken, ladungen = [], [], []
    -    for fahrzeug in range(ANZAHL_FAHRZEUGE):
    -        index = routing.Start(fahrzeug)
    -        tour, strecke, ladung = [], 0, 0
    -        while not routing.IsEnd(index):
    -            knoten = manager.IndexToNode(index)
    -            tour.append(knoten)
    -            ladung += BEDARFE[knoten]
    -            vorher = index
    -            index = loesung.Value(routing.NextVar(index))
    -            strecke += routing.GetArcCostForVehicle(vorher, index, fahrzeug)
    -        tour.append(manager.IndexToNode(index))
    -        touren.append(tour)
    -        strecken.append(strecke)
    -        ladungen.append(ladung)
    -
    -    return {"touren": touren, "strecken": strecken, "ladungen": ladungen,
    -            "gesamtstrecke": sum(strecken)}
    -
    -
    -def pruefe(ergebnis: dict) -> list[str]:
    -    """Prueft den Plan gegen die Wirklichkeit - unabhaengig vom Modell.
    -
    -    Genau diese Trennung ist der Punkt: Die Pruefung darf nicht dieselben
    -    Annahmen benutzen wie das Modell, sonst prueft sie nichts.
    -    """
    -    beanstandungen = []
    -    for fahrzeug, (ladung, kapazitaet) in enumerate(
    -            zip(ergebnis["ladungen"], KAPAZITAETEN)):
    -        if ladung > kapazitaet:
    -            beanstandungen.append(
    -                f"Fahrzeug {fahrzeug + 1}: {ladung} Paletten geladen, "
    -                f"Kapazitaet {kapazitaet} ({ladung - kapazitaet} zu viel)")
    -
    -    beliefert = sorted(k for tour in ergebnis["touren"] for k in tour[1:-1])
    -    erwartet = list(range(1, len(BEDARFE)))
    -    if beliefert != erwartet:
    -        fehlend = set(erwartet) - set(beliefert)
    -        if fehlend:
    -            beanstandungen.append(f"nicht beliefert: {sorted(fehlend)}")
    -    return beanstandungen
    -
    -
    -def zeige(titel: str, ergebnis: dict) -> None:
    -    print(f"\n{titel}")
    -    print(f"  Gesamtstrecke {ergebnis['gesamtstrecke']} km")
    -    print(f"  {'Fahrzeug':<10} {'Stopps':>7} {'Strecke':>9} {'Ladung':>8} "
    -          f"{'Kapazitaet':>11}")
    -    for i, (tour, strecke, ladung) in enumerate(
    -            zip(ergebnis["touren"], ergebnis["strecken"], ergebnis["ladungen"])):
    -        markierung = "  <-- ueberladen" if ladung > KAPAZITAETEN[i] else ""
    -        print(f"  {i + 1:<10} {len(tour) - 2:>7} {strecke:>8} km {ladung:>8} "
    -              f"{KAPAZITAETEN[i]:>11}{markierung}")
    -
    -    beanstandungen = pruefe(ergebnis)
    -    if beanstandungen:
    -        print("  PRUEFUNG: DURCHGEFALLEN")
    -        for text in beanstandungen:
    -            print(f"    - {text}")
    -    else:
    -        print("  PRUEFUNG: bestanden")
    -
    -
    -if __name__ == "__main__":
    -    print("=" * 78)
    -    print("  DIE VERGESSENE DIMENSION")
    -    print("=" * 78)
    -    print(f"16 Kunden, Gesamtbedarf {sum(BEDARFE)} Paletten, "
    -          f"{ANZAHL_FAHRZEUGE} Fahrzeuge zu je {KAPAZITAETEN[0]} "
    -          f"= {sum(KAPAZITAETEN)} Paletten Flottenkapazitaet.")
    -
    -    ohne = plane(mit_kapazitaet=False)
    -    zeige("[1] Ohne Ladungsdimension", ohne)
    -
    -    mit = plane(mit_kapazitaet=True)
    -    zeige("[2] Mit AddDimensionWithVehicleCapacity", mit)
    -
    -    print("\n" + "=" * 78)
    -    mehr = mit["gesamtstrecke"] - ohne["gesamtstrecke"]
    -    print(f"Der korrekte Plan ist {mehr} km laenger "
    -          f"({mehr / ohne['gesamtstrecke'] * 100:.1f} %).")
    -    print()
    -    print("Und genau darin liegt die Gefahr: Lauf [1] sieht BESSER aus. Wer")
    -    print("beide Zahlen nebeneinander legt, ohne die Ladung zu pruefen, haelt")
    -    print("die unfahrbare Loesung fuer die bessere Optimierung - und den")
    -    print("korrekten Plan fuer schlechte Arbeit.")
    -    print()
    -    print("Die Routing-Bibliothek kennt keine 'Kapazitaet'. Sie kennt nur")
    -    print("Dimensionen, die man ihr anlegt. Was nicht als Dimension existiert,")
    -    print("wird nicht begrenzt - und faellt niemandem auf, weil das Ergebnis")
    -    print("plausibel aussieht.")
    -    print("=" * 78)
    +
    #!/usr/bin/env python3
    +
    +# VRP_Kapazitaetsfalle.py
    +"""
    +Kapitel Graphen: Die vergessene Dimension.
    +
    +Die Routing-Bibliothek von OR-Tools kennt keine "Kapazitaet" von sich aus.
    +Sie kennt nur DIMENSIONEN - benannte Groessen, die sich entlang einer Tour
    +aufsummieren und begrenzt werden koennen. Distanz ist eine, Zeit ist eine,
    +Ladung ist eine. Wer eine davon nicht anlegt, bekommt trotzdem eine Loesung:
    +eine schoene, kurze, guenstige - und unfahrbare.
    +
    +Dieses Programm loest dieselbe Instanz zweimal und prueft beide Ergebnisse
    +gegen die tatsaechlichen Lademengen.
    +
    +Instanz: 1 Depot, 16 Kunden, 4 Fahrzeuge zu je 10 Paletten.
    +Gesamtbedarf 37 Paletten bei 40 Paletten Flottenkapazitaet - es ist also
    +knapp, aber machbar.
    +
    +Benoetigt: numpy, ortools
    +"""
    +
    +from __future__ import annotations
    +
    +import numpy as np
    +from ortools.constraint_solver import pywrapcp, routing_enums_pb2
    +
    +# Dieselbe Instanz wie VRP_Flotten_Routing.py
    +BEDARFE = [0, 2, 3, 1, 4, 2, 2, 3, 1, 2, 4, 3, 2, 1, 2, 3, 2]
    +KAPAZITAETEN = [10, 10, 10, 10]
    +ANZAHL_FAHRZEUGE = 4
    +DEPOT = 0
    +
    +
    +def distanzmatrix(seed: int = 42) -> list[list[int]]:
    +    rng = np.random.default_rng(seed)
    +    koordinaten = rng.random((len(BEDARFE), 2)) * 100         # 100 x 100 km
    +    n = len(BEDARFE)
    +    return [[int(np.linalg.norm(koordinaten[i] - koordinaten[j]))
    +             for j in range(n)] for i in range(n)]
    +
    +
    +def plane(mit_kapazitaet: bool, zeitlimit: int = 5) -> dict:
    +    """Loest die Tourenplanung - wahlweise mit oder ohne Ladungsdimension."""
    +    distanz = distanzmatrix()
    +    manager = pywrapcp.RoutingIndexManager(len(distanz), ANZAHL_FAHRZEUGE, DEPOT)
    +    routing = pywrapcp.RoutingModel(manager)
    +
    +    def entfernung(von_index, nach_index):
    +        return distanz[manager.IndexToNode(von_index)][manager.IndexToNode(nach_index)]
    +
    +    kosten_id = routing.RegisterTransitCallback(entfernung)
    +    routing.SetArcCostEvaluatorOfAllVehicles(kosten_id)
    +
    +    # DIE entscheidende Stelle. Ohne diesen Block existiert im Modell keine
    +    # Ladung - die Fahrzeuge sind dann unendlich gross.
    +    if mit_kapazitaet:
    +        def bedarf(index):
    +            return BEDARFE[manager.IndexToNode(index)]
    +
    +        bedarf_id = routing.RegisterUnaryTransitCallback(bedarf)
    +        routing.AddDimensionWithVehicleCapacity(
    +            bedarf_id,
    +            0,                      # kein Zwischenpuffer
    +            KAPAZITAETEN,           # Obergrenze je Fahrzeug
    +            True,                   # Ladung startet bei 0
    +            "Ladung")
    +
    +    parameter = pywrapcp.DefaultRoutingSearchParameters()
    +    parameter.first_solution_strategy = (
    +        routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC)
    +    parameter.local_search_metaheuristic = (
    +        routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH)
    +    parameter.time_limit.FromSeconds(zeitlimit)
    +
    +    loesung = routing.SolveWithParameters(parameter)
    +    if loesung is None:
    +        raise RuntimeError("Keine Loesung gefunden")
    +
    +    touren, strecken, ladungen = [], [], []
    +    for fahrzeug in range(ANZAHL_FAHRZEUGE):
    +        index = routing.Start(fahrzeug)
    +        tour, strecke, ladung = [], 0, 0
    +        while not routing.IsEnd(index):
    +            knoten = manager.IndexToNode(index)
    +            tour.append(knoten)
    +            ladung += BEDARFE[knoten]
    +            vorher = index
    +            index = loesung.Value(routing.NextVar(index))
    +            strecke += routing.GetArcCostForVehicle(vorher, index, fahrzeug)
    +        tour.append(manager.IndexToNode(index))
    +        touren.append(tour)
    +        strecken.append(strecke)
    +        ladungen.append(ladung)
    +
    +    return {"touren": touren, "strecken": strecken, "ladungen": ladungen,
    +            "gesamtstrecke": sum(strecken)}
    +
    +
    +def pruefe(ergebnis: dict) -> list[str]:
    +    """Prueft den Plan gegen die Wirklichkeit - unabhaengig vom Modell.
    +
    +    Genau diese Trennung ist der Punkt: Die Pruefung darf nicht dieselben
    +    Annahmen benutzen wie das Modell, sonst prueft sie nichts.
    +    """
    +    beanstandungen = []
    +    for fahrzeug, (ladung, kapazitaet) in enumerate(
    +            zip(ergebnis["ladungen"], KAPAZITAETEN)):
    +        if ladung > kapazitaet:
    +            beanstandungen.append(
    +                f"Fahrzeug {fahrzeug + 1}: {ladung} Paletten geladen, "
    +                f"Kapazitaet {kapazitaet} ({ladung - kapazitaet} zu viel)")
    +
    +    beliefert = sorted(k for tour in ergebnis["touren"] for k in tour[1:-1])
    +    erwartet = list(range(1, len(BEDARFE)))
    +    if beliefert != erwartet:
    +        fehlend = set(erwartet) - set(beliefert)
    +        if fehlend:
    +            beanstandungen.append(f"nicht beliefert: {sorted(fehlend)}")
    +    return beanstandungen
    +
    +
    +def zeige(titel: str, ergebnis: dict) -> None:
    +    print(f"\n{titel}")
    +    print(f"  Gesamtstrecke {ergebnis['gesamtstrecke']} km")
    +    print(f"  {'Fahrzeug':<10} {'Stopps':>7} {'Strecke':>9} {'Ladung':>8} "
    +          f"{'Kapazitaet':>11}")
    +    for i, (tour, strecke, ladung) in enumerate(
    +            zip(ergebnis["touren"], ergebnis["strecken"], ergebnis["ladungen"])):
    +        markierung = "  <-- ueberladen" if ladung > KAPAZITAETEN[i] else ""
    +        print(f"  {i + 1:<10} {len(tour) - 2:>7} {strecke:>8} km {ladung:>8} "
    +              f"{KAPAZITAETEN[i]:>11}{markierung}")
    +
    +    beanstandungen = pruefe(ergebnis)
    +    if beanstandungen:
    +        print("  PRUEFUNG: DURCHGEFALLEN")
    +        for text in beanstandungen:
    +            print(f"    - {text}")
    +    else:
    +        print("  PRUEFUNG: bestanden")
    +
    +
    +if __name__ == "__main__":
    +    print("=" * 78)
    +    print("  DIE VERGESSENE DIMENSION")
    +    print("=" * 78)
    +    print(f"16 Kunden, Gesamtbedarf {sum(BEDARFE)} Paletten, "
    +          f"{ANZAHL_FAHRZEUGE} Fahrzeuge zu je {KAPAZITAETEN[0]} "
    +          f"= {sum(KAPAZITAETEN)} Paletten Flottenkapazitaet.")
    +
    +    ohne = plane(mit_kapazitaet=False)
    +    zeige("[1] Ohne Ladungsdimension", ohne)
    +
    +    mit = plane(mit_kapazitaet=True)
    +    zeige("[2] Mit AddDimensionWithVehicleCapacity", mit)
    +
    +    print("\n" + "=" * 78)
    +    mehr = mit["gesamtstrecke"] - ohne["gesamtstrecke"]
    +    print(f"Der korrekte Plan ist {mehr} km laenger "
    +          f"({mehr / ohne['gesamtstrecke'] * 100:.1f} %).")
    +    print()
    +    print("Und genau darin liegt die Gefahr: Lauf [1] sieht BESSER aus. Wer")
    +    print("beide Zahlen nebeneinander legt, ohne die Ladung zu pruefen, haelt")
    +    print("die unfahrbare Loesung fuer die bessere Optimierung - und den")
    +    print("korrekten Plan fuer schlechte Arbeit.")
    +    print()
    +    print("Die Routing-Bibliothek kennt keine 'Kapazitaet'. Sie kennt nur")
    +    print("Dimensionen, die man ihr anlegt. Was nicht als Dimension existiert,")
    +    print("wird nicht begrenzt - und faellt niemandem auf, weil das Ergebnis")
    +    print("plausibel aussieht.")
    +    print("=" * 78)

    Erwartete Ausgabe:

    ==============================================================================
       DIE VERGESSENE DIMENSION
    @@ -13011,22 +13327,22 @@ plausibel aussieht.
     
     
     
    -
    from itertools import permutations
    -> lack = ["Weiss", "Elfenbein", "Rot", "Dunkelrot", "Schwarz", "Beige"]
    -ruest = [[0, 2, 49, 49, 49, 2],      # Ruestzeit in Minuten von Zeile nach Spalte
    -         [2, 0, 49, 49, 49, 2],
    -         [50, 50, 0, 2, 2, 50],
    -         [53, 53, 5, 0, 2, 50],
    -         [77, 77, 29, 26, 0, 74],
    -         [5, 5, 52, 49, 49, 0]]
    -> dauer = lambda p: sum(ruest[p[k]][p[k + 1]] for k in range(len(p) - 1))
    -> offen, plan = set(range(1, 6)), [0]                  # Faustregel: billigster naechster
    -while offen:
    -    naechster = min(offen, key=lambda j: ruest[plan[-1]][j])
    -    plan.append(naechster); offen.discard(naechster)
    -> best = min(permutations(range(1, 6)), key=lambda p: dauer((0,) + p))
    -print("Faustregel:", " -> ".join(lack[i] for i in plan), f"= {dauer(plan)} min")
    -print("Optimal   :", " -> ".join(lack[i] for i in (0,) + best), f"= {dauer((0,) + best)} min")
    +
    from itertools import permutations
    +> lack = ["Weiss", "Elfenbein", "Rot", "Dunkelrot", "Schwarz", "Beige"]
    +ruest = [[0, 2, 49, 49, 49, 2],      # Ruestzeit in Minuten von Zeile nach Spalte
    +         [2, 0, 49, 49, 49, 2],
    +         [50, 50, 0, 2, 2, 50],
    +         [53, 53, 5, 0, 2, 50],
    +         [77, 77, 29, 26, 0, 74],
    +         [5, 5, 52, 49, 49, 0]]
    +> dauer = lambda p: sum(ruest[p[k]][p[k + 1]] for k in range(len(p) - 1))
    +> offen, plan = set(range(1, 6)), [0]                  # Faustregel: billigster naechster
    +while offen:
    +    naechster = min(offen, key=lambda j: ruest[plan[-1]][j])
    +    plan.append(naechster); offen.discard(naechster)
    +> best = min(permutations(range(1, 6)), key=lambda p: dauer((0,) + p))
    +print("Faustregel:", " -> ".join(lack[i] for i in plan), f"= {dauer(plan)} min")
    +print("Optimal   :", " -> ".join(lack[i] for i in (0,) + best), f"= {dauer((0,) + best)} min")

    Ausgabe:

    Faustregel: Weiss -> Elfenbein -> Beige -> Dunkelrot -> Schwarz -> Rot = 84 min
     Optimal   : Weiss -> Elfenbein -> Beige -> Rot -> Dunkelrot -> Schwarz = 60 min
    @@ -13113,7 +13429,7 @@ Optimal : Weiss -> Elfenbein -> Beige -> Rot -> Dunkelrot -> Sc

    ⚠️ Die Stelle, an der Metaheuristiken scheitern

    Es ist verlockend, die Kosten eines Zugs so zu bewerten:

    -
    neue_kosten = gesamtruestzeit(neue_reihe, matrix)      # falsch gedacht
    +
    neue_kosten = gesamtruestzeit(neue_reihe, matrix)      # falsch gedacht

    Das ist korrekt und trotzdem der Fehler, der ein Projekt scheitern lässt. Die volle Summe kostet O(n) je Zug. Herausnehmen und Einsetzen verändert aber nur drei Kanten — die Kostenänderung steht in höchstens sechs Matrixeinträgen und ist in O(1) zu haben.

    Bei 500 Aufträgen ist das der Unterschied zwischen einigen tausend und einigen hunderttausend geprüften Zügen im selben Zeitbudget. Eine Metaheuristik lebt von der Zahl der Züge; wer sie durch eine bequeme Bewertungsfunktion um zwei Größenordnungen drückt, misst am Ende nicht das Verfahren, sondern seine eigene Implementierung.

    @@ -13170,305 +13486,305 @@ e^{-\Delta / T} & \Delta > 0

    Die letzte Zeile ist die praktisch wichtigste: T und \Delta werden in derselben Einheit gemessen. Wer die Temperatur wählt, ohne die typische Zuggröße zu kennen, wählt blind.

    -
    #!/usr/bin/env python3
    -
    -# Simulated_Annealing.py
    -"""
    -Kapitel Metaheuristiken: Simulated Annealing an der Lackieranlage.
    -
    -Aufgabe: n Auftraege in eine Reihenfolge bringen. Zwischen zwei Auftraegen
    -faellt eine Ruestzeit an - die Anlage muss gereinigt werden. Wie lange das
    -dauert, haengt von BEIDEN Auftraegen ab: Ein Wechsel innerhalb derselben
    -Produktfamilie kostet fast nichts, ein Wechsel von Dunkel nach Hell ist teuer,
    -und zwischen manchen Familien ist der Reinigungsaufwand schlicht hoch. Gesucht
    -ist die Reihenfolge mit der kleinsten Summe der Ruestzeiten.
    -
    -Das Programm zeigt drei Dinge:
    -
    -  1. Warum die Faustregel "immer der billigste naechste Auftrag" in eine
    -     Sackgasse laeuft - und wie weit man sie mit lokaler Suche verbessert.
    -  2. Wie gross der Beitrag der Verschlechterungen WIRKLICH ist. Die Antwort
    -     faellt bescheidener aus als das Lehrbuch verspricht, und genau das ist
    -     der Grund, den Vergleich immer mitzurechnen.
    -  3. Dass die Starttemperatur kein Beiwerk ist: zu heiss macht die Suche
    -     nutzlos, und man sieht es an einer einzigen Kennzahl kommen.
    -
    -Zum Budget: Der Abkuehlplan laeuft ueber eine feste ZUGZAHL, nicht ueber eine
    -Sekundenzahl. Im Betrieb hat man zwar ein Zeitbudget - fuer einen VERGLEICH ist
    -die Zugzahl aber das ehrlichere Mass: Sie ist auf jeder Maschine dieselbe, und
    -die abgedruckten Zahlen unten lassen sich damit nachrechnen. Die gemessene
    -Laufzeit steht trotzdem dabei.
    -
    -Benoetigt: numpy
    -"""
    -
    -from __future__ import annotations
    -
    -import math
    -import time
    -
    -import numpy as np
    -
    -ZUEGE = 400_000           # Zugbudget je Lauf (statt Sekunden: reproduzierbar)
    -SAAT = 11                 # feste Saat -> reproduzierbare Instanz
    -
    -
    -# --- 1. Die Instanz ---------------------------------------------------------
    -
    -def erzeuge_ruestmatrix(n: int, saat: int = SAAT) -> np.ndarray:
    -    """Ruestzeiten in Minuten zwischen je zwei Auftraegen.
    -
    -    Die Zahl der Produktfamilien waechst mit der Auftragszahl (eine Familie je
    -    zehn Auftraege). Sonst wuerde das Problem mit wachsendem n LEICHTER: Bei
    -    fester Familienzahl haette der Plan irgendwann nur noch ein paar grosse
    -    Bloecke, und jede Faustregel faende ihn.
    -    """
    -    rng = np.random.default_rng(saat)
    -    familien = max(6, n // 10)
    -    # Reinigungsaufwand zwischen den Familien - unregelmaessig und asymmetrisch,
    -    # so wie im Betrieb: Von Klarlack auf Rot ist etwas anderes als umgekehrt.
    -    zwischen = rng.integers(8, 60, (familien, familien))
    -    np.fill_diagonal(zwischen, 2)
    -
    -    familie = rng.integers(0, familien, n)
    -    farbe = rng.integers(0, 10, n)          # 0 = weiss ... 9 = schwarz
    -
    -    matrix = np.zeros((n, n), dtype=np.int64)
    -    for i in range(n):
    -        for j in range(n):
    -            if i != j:
    -                # Dunkel -> hell kostet zusaetzlich: die Anlage muss heller
    -                # werden, das braucht mehr Spuelgaenge.
    -                matrix[i, j] = (zwischen[familie[i], familie[j]]
    -                                + 3 * max(0, farbe[i] - farbe[j]))
    -    return matrix
    -
    -
    -def gesamtruestzeit(reihe: list[int], matrix: np.ndarray) -> int:
    -    """Summe der Ruestzeiten einer Reihenfolge (offene Kette, keine Rundreise)."""
    -    return int(sum(matrix[reihe[k], reihe[k + 1]] for k in range(len(reihe) - 1)))
    -
    -
    -def faustregel(matrix: np.ndarray) -> list[int]:
    -    """Immer der billigste noch offene Auftrag - 'naechster Nachbar'.
    -
    -    So plant ein Meister von Hand, und es ist keine schlechte Regel. Ihr Fehler
    -    ist die Kurzsichtigkeit: Sie spart am Anfang und laesst die teuren Wechsel
    -    fuer das Ende uebrig, wo keine Wahl mehr bleibt.
    -    """
    -    n = len(matrix)
    -    offen = set(range(1, n))
    -    reihe = [0]
    -    while offen:
    -        naechster = min(offen, key=lambda j: matrix[reihe[-1], j])
    -        reihe.append(naechster)
    -        offen.discard(naechster)
    -    return reihe
    -
    -
    -# --- 2. Der Zug und seine Kostenaenderung -----------------------------------
    -
    -def delta_verschieben(reihe: list[int], matrix: np.ndarray,
    -                      von: int, nach: int):
    -    """Auftrag von Position 'von' an Position 'nach' versetzen.
    -
    -    Liefert (Kostenaenderung, Restliste, Einfuegeposition) oder None, wenn der
    -    Zug nichts aendert.
    -
    -    DAS IST DIE WICHTIGSTE FUNKTION DES PROGRAMMS. Sie berechnet die
    -    Kostenaenderung aus HOECHSTENS SECHS Matrixeintraegen, statt die
    -    Reihenfolge neu durchzusummieren. Der Unterschied ist nicht kosmetisch:
    -    Die volle Summe kostet O(n) je Zug, diese Rechnung O(1). Bei 500 Auftraegen
    -    sind das rund 500-mal mehr geprueste Zuege im selben Zeitbudget - und eine
    -    Metaheuristik lebt von der Zahl der Zuege.
    -
    -    Herausgerissen wird der Auftrag aus zwei Kanten, seine Nachbarn ruecken
    -    zusammen (eine neue Kante). Eingefuegt wird er zwischen zwei andere
    -    Nachbarn, deren bisherige Kante dadurch verschwindet.
    -    """
    -    n = len(reihe)
    -    if von == nach or nach == von + 1:
    -        return None
    -
    -    heraus = 0
    -    if von > 0:
    -        heraus += matrix[reihe[von - 1], reihe[von]]
    -    if von < n - 1:
    -        heraus += matrix[reihe[von], reihe[von + 1]]
    -    if 0 < von < n - 1:                       # die Nachbarn ruecken zusammen
    -        heraus -= matrix[reihe[von - 1], reihe[von + 1]]
    -
    -    rest = reihe[:von] + reihe[von + 1:]
    -    stelle = nach if nach < von else nach - 1
    -
    -    hinein = 0
    -    if stelle > 0:
    -        hinein += matrix[rest[stelle - 1], reihe[von]]
    -    if stelle < len(rest):
    -        hinein += matrix[reihe[von], rest[stelle]]
    -    if 0 < stelle < len(rest):                # die aufgetrennte Kante faellt weg
    -        hinein -= matrix[rest[stelle - 1], rest[stelle]]
    -
    -    return int(hinein - heraus), rest, stelle
    -
    -
    -# --- 3. Die Suche -----------------------------------------------------------
    -
    -class Verlauf:
    -    """Was ein Lauf ausser dem Ergebnis noch verraet."""
    -
    -    def __init__(self) -> None:
    -        self.schritte = 0
    -        self.verschlechterungen_geprueft = 0
    -        self.verschlechterungen_genommen = 0
    -        self.schlechtester_zustand = 0      # wie weit die Suche abgedriftet ist
    -        self.sekunden = 0.0                 # nur zur Information, nicht zum Vergleich
    -
    -    @property
    -    def annahmequote(self) -> float:
    -        if not self.verschlechterungen_geprueft:
    -            return 0.0
    -        return self.verschlechterungen_genommen / self.verschlechterungen_geprueft
    -
    -
    -def suche(matrix: np.ndarray, zuege: int, start_temperatur: float,
    -          end_temperatur: float = 0.05, saat: int = 1,
    -          bergsteigen: bool = False) -> tuple[list[int], int, Verlauf]:
    -    """Simulated Annealing (oder reines Bergsteigen, wenn bergsteigen=True).
    -
    -    Der einzige Unterschied zwischen beiden steht in der Annahmezeile: Das
    -    Bergsteigen nimmt nur Verbesserungen, SA nimmt Verschlechterungen mit einer
    -    Wahrscheinlichkeit an, die mit der Zeit gegen null geht.
    -    """
    -    rng = np.random.default_rng(saat)
    -    n = len(matrix)
    -    reihe = faustregel(matrix)
    -    kosten = gesamtruestzeit(reihe, matrix)
    -    beste, beste_kosten = reihe[:], kosten
    -    verlauf = Verlauf()
    -    verlauf.schlechtester_zustand = kosten
    -
    -    start = time.perf_counter()
    -    for zug in range(zuege):
    -        verlauf.schritte += 1
    -        # Geometrischer Abkuehlplan ueber das Zugbudget.
    -        temperatur = start_temperatur * (end_temperatur / start_temperatur) \
    -            ** (zug / zuege)
    -
    -        von = int(rng.integers(0, n))
    -        nach = int(rng.integers(0, n + 1))
    -        ergebnis = delta_verschieben(reihe, matrix, von, nach)
    -        if ergebnis is None:
    -            continue
    -        aenderung, rest, stelle = ergebnis
    -
    -        if aenderung <= 0:
    -            annehmen = True
    -        else:
    -            verlauf.verschlechterungen_geprueft += 1
    -            # Metropolis-Kriterium: je groesser die Verschlechterung und je
    -            # kaelter es ist, desto unwahrscheinlicher.
    -            annehmen = (not bergsteigen
    -                        and rng.random() < math.exp(-aenderung / temperatur))
    -            verlauf.verschlechterungen_genommen += annehmen
    -
    -        if annehmen:
    -            reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]
    -            kosten += aenderung
    -            verlauf.schlechtester_zustand = max(verlauf.schlechtester_zustand,
    -                                                kosten)
    -            if kosten < beste_kosten:
    -                beste, beste_kosten = reihe[:], kosten
    -
    -    verlauf.sekunden = time.perf_counter() - start
    -    return beste, beste_kosten, verlauf
    -
    -
    -if __name__ == "__main__":
    -    N = 200
    -    matrix = erzeuge_ruestmatrix(N)
    -    start_reihe = faustregel(matrix)
    -    start_kosten = gesamtruestzeit(start_reihe, matrix)
    -
    -    print("=" * 78)
    -    print("  SIMULATED ANNEALING AN DER LACKIERANLAGE")
    -    print("=" * 78)
    -    print(f"{N} Auftraege, {max(6, N // 10)} Produktfamilien, "
    -          f"Zugbudget {ZUEGE:,} je Lauf.\n")
    -    print(f"Faustregel 'billigster naechster Auftrag': "
    -          f"{start_kosten:,} Minuten Ruestzeit")
    -
    -    # --- Wie gross sind die Zuege ueberhaupt? ---------------------------
    -    # Ohne diese Zahl kann man die Temperatur nicht waehlen: Das
    -    # Metropolis-Kriterium vergleicht die Verschlechterung MIT der Temperatur.
    -    rng = np.random.default_rng(0)
    -    stichprobe = []
    -    for _ in range(5000):
    -        e = delta_verschieben(start_reihe, matrix,
    -                              int(rng.integers(0, N)), int(rng.integers(0, N + 1)))
    -        if e:
    -            stichprobe.append(abs(e[0]))
    -    print(f"Typische Zuggroesse |Delta|: Median {np.median(stichprobe):.0f}, "
    -          f"90 %-Quantil {np.quantile(stichprobe, 0.9):.0f} Minuten")
    -
    -    # --- Bergsteigen gegen Annealing -------------------------------------
    -    print("\n" + "-" * 78)
    -    print("Nur Verbesserungen annehmen (Bergsteigen) gegen Annealing:\n")
    -    _, berg, berg_verlauf = suche(matrix, ZUEGE, 1.0, bergsteigen=True)
    -    _, gluehen, gluehen_verlauf = suche(matrix, ZUEGE, 1.0)
    -    print(f"  {'Faustregel (Start)':<34} {start_kosten:>7,} Minuten")
    -    print(f"  {'Bergsteigen':<34} {berg:>7,} Minuten  "
    -          f"({(start_kosten - berg) / start_kosten * 100:5.1f} % besser)")
    -    print(f"  {'Simulated Annealing':<34} {gluehen:>7,} Minuten  "
    -          f"({(start_kosten - gluehen) / start_kosten * 100:5.1f} % besser)")
    -    print(f"\n  Beide pruefen genau {berg_verlauf.schritte:,} Zuege "
    -          f"({berg_verlauf.sekunden:.1f} bzw. {gluehen_verlauf.sekunden:.1f} Sekunden).")
    -    print(f"  Annealing nimmt davon {gluehen_verlauf.annahmequote * 100:.2f} % der "
    -          f"VERSCHLECHTERUNGEN an, Bergsteigen keine.")
    -    print("\n  Bemerkenswert ist, wie klein der Abstand ist: Das simple")
    -    print(f"  Bergsteigen holt hier bereits {(start_kosten - berg) / (start_kosten - gluehen) * 100:.0f} % dessen, was Annealing")
    -    print("  schafft. Wer eine Metaheuristik einsetzt, sollte diesen Vergleich")
    -    print("  IMMER mitrechnen - sonst schreibt man einer aufwendigen Methode")
    -    print("  gut, was schon die einfache geliefert haette.")
    -
    -    # --- Die Temperatur ist kein Beiwerk ---------------------------------
    -    print("\n" + "-" * 78)
    -    print("Und warum die Starttemperatur ausgemessen gehoert:\n")
    -    print(f"  {'Start-T':>8} {'Ruestzeit':>11} {'gg. Faustregel':>15} "
    -          f"{'angenommene':>12} {'schlechtester':>14}")
    -    print(f"  {'':>8} {'':>11} {'':>15} {'Verschlecht.':>12} {'Zwischenwert':>14}")
    -    print("  " + "-" * 64)
    -    ergebnisse, verlaeufe = {}, {}
    -    for temperatur in (0.5, 1.0, 2.0, 4.0, 8.0):
    -        _, wert, verlauf = suche(matrix, ZUEGE, temperatur)
    -        ergebnisse[temperatur] = wert
    -        verlaeufe[temperatur] = verlauf
    -        gewinn = (start_kosten - wert) / start_kosten * 100
    -        print(f"  {temperatur:>8.1f} {wert:>11,} {gewinn:>13.1f} % "
    -              f"{verlauf.annahmequote * 100:>11.2f} % "
    -              f"{verlauf.schlechtester_zustand:>14,}")
    -
    -    beste_temperatur = min(ergebnisse, key=ergebnisse.get)
    -    heiss = verlaeufe[8.0]
    -    print(f"\n  Bester Wert bei T0 = {beste_temperatur}.")
    -    print("\n  Die letzten beiden Spalten erklaeren, warum es nach oben kippt.")
    -    print(f"  Bei T0 = 8 gehen nur {heiss.annahmequote * 100:.2f} % der Verschlechterungen")
    -    print("  durch - wenig genug, dass man es fuer harmlos halten koennte. Es")
    -    print(f"  genuegt aber: Die Suche driftet bis auf {heiss.schlechtester_zustand:,} Minuten ab, das")
    -    print(f"  {heiss.schlechtester_zustand / start_kosten:.1f}-fache der Startloesung, und findet im Zugbudget nicht")
    -    print("  mehr zurueck. Vom Ergebnis bleibt fast nichts uebrig: Die Suche hat")
    -    print(f"  in {ZUEGE:,} Zuegen nie etwas GESEHEN, das mehr als")
    -    print(f"  {(start_kosten - ergebnisse[8.0]) / start_kosten * 100:.1f} % unter der Startloesung lag.")
    -    print("\n  Bemerkenswert ist die andere Richtung: Die besten Werte entstehen")
    -    print("  bei Annahmequoten nahe null. Die Lehrbuchregel 'anfangs sollen")
    -    print("  20 bis 50 Prozent der Verschlechterungen durchgehen' fuehrt hier in")
    -    print("  die Irre - sie stammt aus Problemen mit sehr kleinen Zuggroessen.")
    -    print("  Bei Ruestzeiten in Minuten ist ein einziger schlechter Zug teuer.")
    -    print("\n  Was stattdessen traegt: Den SCHLECHTESTEN Zwischenwert protokollieren")
    -    print("  und T0 so waehlen, dass er die Startloesung nicht wesentlich")
    -    print("  ueberschreitet. Diese eine Zahl haette hier auf Anhieb gezeigt, dass")
    -    print("  T0 = 8 unbrauchbar ist - ohne dass man das Endergebnis abwarten muss.")
    -    print("=" * 78)
    +
    #!/usr/bin/env python3
    +
    +# Simulated_Annealing.py
    +"""
    +Kapitel Metaheuristiken: Simulated Annealing an der Lackieranlage.
    +
    +Aufgabe: n Auftraege in eine Reihenfolge bringen. Zwischen zwei Auftraegen
    +faellt eine Ruestzeit an - die Anlage muss gereinigt werden. Wie lange das
    +dauert, haengt von BEIDEN Auftraegen ab: Ein Wechsel innerhalb derselben
    +Produktfamilie kostet fast nichts, ein Wechsel von Dunkel nach Hell ist teuer,
    +und zwischen manchen Familien ist der Reinigungsaufwand schlicht hoch. Gesucht
    +ist die Reihenfolge mit der kleinsten Summe der Ruestzeiten.
    +
    +Das Programm zeigt drei Dinge:
    +
    +  1. Warum die Faustregel "immer der billigste naechste Auftrag" in eine
    +     Sackgasse laeuft - und wie weit man sie mit lokaler Suche verbessert.
    +  2. Wie gross der Beitrag der Verschlechterungen WIRKLICH ist. Die Antwort
    +     faellt bescheidener aus als das Lehrbuch verspricht, und genau das ist
    +     der Grund, den Vergleich immer mitzurechnen.
    +  3. Dass die Starttemperatur kein Beiwerk ist: zu heiss macht die Suche
    +     nutzlos, und man sieht es an einer einzigen Kennzahl kommen.
    +
    +Zum Budget: Der Abkuehlplan laeuft ueber eine feste ZUGZAHL, nicht ueber eine
    +Sekundenzahl. Im Betrieb hat man zwar ein Zeitbudget - fuer einen VERGLEICH ist
    +die Zugzahl aber das ehrlichere Mass: Sie ist auf jeder Maschine dieselbe, und
    +die abgedruckten Zahlen unten lassen sich damit nachrechnen. Die gemessene
    +Laufzeit steht trotzdem dabei.
    +
    +Benoetigt: numpy
    +"""
    +
    +from __future__ import annotations
    +
    +import math
    +import time
    +
    +import numpy as np
    +
    +ZUEGE = 400_000           # Zugbudget je Lauf (statt Sekunden: reproduzierbar)
    +SAAT = 11                 # feste Saat -> reproduzierbare Instanz
    +
    +
    +# --- 1. Die Instanz ---------------------------------------------------------
    +
    +def erzeuge_ruestmatrix(n: int, saat: int = SAAT) -> np.ndarray:
    +    """Ruestzeiten in Minuten zwischen je zwei Auftraegen.
    +
    +    Die Zahl der Produktfamilien waechst mit der Auftragszahl (eine Familie je
    +    zehn Auftraege). Sonst wuerde das Problem mit wachsendem n LEICHTER: Bei
    +    fester Familienzahl haette der Plan irgendwann nur noch ein paar grosse
    +    Bloecke, und jede Faustregel faende ihn.
    +    """
    +    rng = np.random.default_rng(saat)
    +    familien = max(6, n // 10)
    +    # Reinigungsaufwand zwischen den Familien - unregelmaessig und asymmetrisch,
    +    # so wie im Betrieb: Von Klarlack auf Rot ist etwas anderes als umgekehrt.
    +    zwischen = rng.integers(8, 60, (familien, familien))
    +    np.fill_diagonal(zwischen, 2)
    +
    +    familie = rng.integers(0, familien, n)
    +    farbe = rng.integers(0, 10, n)          # 0 = weiss ... 9 = schwarz
    +
    +    matrix = np.zeros((n, n), dtype=np.int64)
    +    for i in range(n):
    +        for j in range(n):
    +            if i != j:
    +                # Dunkel -> hell kostet zusaetzlich: die Anlage muss heller
    +                # werden, das braucht mehr Spuelgaenge.
    +                matrix[i, j] = (zwischen[familie[i], familie[j]]
    +                                + 3 * max(0, farbe[i] - farbe[j]))
    +    return matrix
    +
    +
    +def gesamtruestzeit(reihe: list[int], matrix: np.ndarray) -> int:
    +    """Summe der Ruestzeiten einer Reihenfolge (offene Kette, keine Rundreise)."""
    +    return int(sum(matrix[reihe[k], reihe[k + 1]] for k in range(len(reihe) - 1)))
    +
    +
    +def faustregel(matrix: np.ndarray) -> list[int]:
    +    """Immer der billigste noch offene Auftrag - 'naechster Nachbar'.
    +
    +    So plant ein Meister von Hand, und es ist keine schlechte Regel. Ihr Fehler
    +    ist die Kurzsichtigkeit: Sie spart am Anfang und laesst die teuren Wechsel
    +    fuer das Ende uebrig, wo keine Wahl mehr bleibt.
    +    """
    +    n = len(matrix)
    +    offen = set(range(1, n))
    +    reihe = [0]
    +    while offen:
    +        naechster = min(offen, key=lambda j: matrix[reihe[-1], j])
    +        reihe.append(naechster)
    +        offen.discard(naechster)
    +    return reihe
    +
    +
    +# --- 2. Der Zug und seine Kostenaenderung -----------------------------------
    +
    +def delta_verschieben(reihe: list[int], matrix: np.ndarray,
    +                      von: int, nach: int):
    +    """Auftrag von Position 'von' an Position 'nach' versetzen.
    +
    +    Liefert (Kostenaenderung, Restliste, Einfuegeposition) oder None, wenn der
    +    Zug nichts aendert.
    +
    +    DAS IST DIE WICHTIGSTE FUNKTION DES PROGRAMMS. Sie berechnet die
    +    Kostenaenderung aus HOECHSTENS SECHS Matrixeintraegen, statt die
    +    Reihenfolge neu durchzusummieren. Der Unterschied ist nicht kosmetisch:
    +    Die volle Summe kostet O(n) je Zug, diese Rechnung O(1). Bei 500 Auftraegen
    +    sind das rund 500-mal mehr geprueste Zuege im selben Zeitbudget - und eine
    +    Metaheuristik lebt von der Zahl der Zuege.
    +
    +    Herausgerissen wird der Auftrag aus zwei Kanten, seine Nachbarn ruecken
    +    zusammen (eine neue Kante). Eingefuegt wird er zwischen zwei andere
    +    Nachbarn, deren bisherige Kante dadurch verschwindet.
    +    """
    +    n = len(reihe)
    +    if von == nach or nach == von + 1:
    +        return None
    +
    +    heraus = 0
    +    if von > 0:
    +        heraus += matrix[reihe[von - 1], reihe[von]]
    +    if von < n - 1:
    +        heraus += matrix[reihe[von], reihe[von + 1]]
    +    if 0 < von < n - 1:                       # die Nachbarn ruecken zusammen
    +        heraus -= matrix[reihe[von - 1], reihe[von + 1]]
    +
    +    rest = reihe[:von] + reihe[von + 1:]
    +    stelle = nach if nach < von else nach - 1
    +
    +    hinein = 0
    +    if stelle > 0:
    +        hinein += matrix[rest[stelle - 1], reihe[von]]
    +    if stelle < len(rest):
    +        hinein += matrix[reihe[von], rest[stelle]]
    +    if 0 < stelle < len(rest):                # die aufgetrennte Kante faellt weg
    +        hinein -= matrix[rest[stelle - 1], rest[stelle]]
    +
    +    return int(hinein - heraus), rest, stelle
    +
    +
    +# --- 3. Die Suche -----------------------------------------------------------
    +
    +class Verlauf:
    +    """Was ein Lauf ausser dem Ergebnis noch verraet."""
    +
    +    def __init__(self) -> None:
    +        self.schritte = 0
    +        self.verschlechterungen_geprueft = 0
    +        self.verschlechterungen_genommen = 0
    +        self.schlechtester_zustand = 0      # wie weit die Suche abgedriftet ist
    +        self.sekunden = 0.0                 # nur zur Information, nicht zum Vergleich
    +
    +    @property
    +    def annahmequote(self) -> float:
    +        if not self.verschlechterungen_geprueft:
    +            return 0.0
    +        return self.verschlechterungen_genommen / self.verschlechterungen_geprueft
    +
    +
    +def suche(matrix: np.ndarray, zuege: int, start_temperatur: float,
    +          end_temperatur: float = 0.05, saat: int = 1,
    +          bergsteigen: bool = False) -> tuple[list[int], int, Verlauf]:
    +    """Simulated Annealing (oder reines Bergsteigen, wenn bergsteigen=True).
    +
    +    Der einzige Unterschied zwischen beiden steht in der Annahmezeile: Das
    +    Bergsteigen nimmt nur Verbesserungen, SA nimmt Verschlechterungen mit einer
    +    Wahrscheinlichkeit an, die mit der Zeit gegen null geht.
    +    """
    +    rng = np.random.default_rng(saat)
    +    n = len(matrix)
    +    reihe = faustregel(matrix)
    +    kosten = gesamtruestzeit(reihe, matrix)
    +    beste, beste_kosten = reihe[:], kosten
    +    verlauf = Verlauf()
    +    verlauf.schlechtester_zustand = kosten
    +
    +    start = time.perf_counter()
    +    for zug in range(zuege):
    +        verlauf.schritte += 1
    +        # Geometrischer Abkuehlplan ueber das Zugbudget.
    +        temperatur = start_temperatur * (end_temperatur / start_temperatur) \
    +            ** (zug / zuege)
    +
    +        von = int(rng.integers(0, n))
    +        nach = int(rng.integers(0, n + 1))
    +        ergebnis = delta_verschieben(reihe, matrix, von, nach)
    +        if ergebnis is None:
    +            continue
    +        aenderung, rest, stelle = ergebnis
    +
    +        if aenderung <= 0:
    +            annehmen = True
    +        else:
    +            verlauf.verschlechterungen_geprueft += 1
    +            # Metropolis-Kriterium: je groesser die Verschlechterung und je
    +            # kaelter es ist, desto unwahrscheinlicher.
    +            annehmen = (not bergsteigen
    +                        and rng.random() < math.exp(-aenderung / temperatur))
    +            verlauf.verschlechterungen_genommen += annehmen
    +
    +        if annehmen:
    +            reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]
    +            kosten += aenderung
    +            verlauf.schlechtester_zustand = max(verlauf.schlechtester_zustand,
    +                                                kosten)
    +            if kosten < beste_kosten:
    +                beste, beste_kosten = reihe[:], kosten
    +
    +    verlauf.sekunden = time.perf_counter() - start
    +    return beste, beste_kosten, verlauf
    +
    +
    +if __name__ == "__main__":
    +    N = 200
    +    matrix = erzeuge_ruestmatrix(N)
    +    start_reihe = faustregel(matrix)
    +    start_kosten = gesamtruestzeit(start_reihe, matrix)
    +
    +    print("=" * 78)
    +    print("  SIMULATED ANNEALING AN DER LACKIERANLAGE")
    +    print("=" * 78)
    +    print(f"{N} Auftraege, {max(6, N // 10)} Produktfamilien, "
    +          f"Zugbudget {ZUEGE:,} je Lauf.\n")
    +    print(f"Faustregel 'billigster naechster Auftrag': "
    +          f"{start_kosten:,} Minuten Ruestzeit")
    +
    +    # --- Wie gross sind die Zuege ueberhaupt? ---------------------------
    +    # Ohne diese Zahl kann man die Temperatur nicht waehlen: Das
    +    # Metropolis-Kriterium vergleicht die Verschlechterung MIT der Temperatur.
    +    rng = np.random.default_rng(0)
    +    stichprobe = []
    +    for _ in range(5000):
    +        e = delta_verschieben(start_reihe, matrix,
    +                              int(rng.integers(0, N)), int(rng.integers(0, N + 1)))
    +        if e:
    +            stichprobe.append(abs(e[0]))
    +    print(f"Typische Zuggroesse |Delta|: Median {np.median(stichprobe):.0f}, "
    +          f"90 %-Quantil {np.quantile(stichprobe, 0.9):.0f} Minuten")
    +
    +    # --- Bergsteigen gegen Annealing -------------------------------------
    +    print("\n" + "-" * 78)
    +    print("Nur Verbesserungen annehmen (Bergsteigen) gegen Annealing:\n")
    +    _, berg, berg_verlauf = suche(matrix, ZUEGE, 1.0, bergsteigen=True)
    +    _, gluehen, gluehen_verlauf = suche(matrix, ZUEGE, 1.0)
    +    print(f"  {'Faustregel (Start)':<34} {start_kosten:>7,} Minuten")
    +    print(f"  {'Bergsteigen':<34} {berg:>7,} Minuten  "
    +          f"({(start_kosten - berg) / start_kosten * 100:5.1f} % besser)")
    +    print(f"  {'Simulated Annealing':<34} {gluehen:>7,} Minuten  "
    +          f"({(start_kosten - gluehen) / start_kosten * 100:5.1f} % besser)")
    +    print(f"\n  Beide pruefen genau {berg_verlauf.schritte:,} Zuege "
    +          f"({berg_verlauf.sekunden:.1f} bzw. {gluehen_verlauf.sekunden:.1f} Sekunden).")
    +    print(f"  Annealing nimmt davon {gluehen_verlauf.annahmequote * 100:.2f} % der "
    +          f"VERSCHLECHTERUNGEN an, Bergsteigen keine.")
    +    print("\n  Bemerkenswert ist, wie klein der Abstand ist: Das simple")
    +    print(f"  Bergsteigen holt hier bereits {(start_kosten - berg) / (start_kosten - gluehen) * 100:.0f} % dessen, was Annealing")
    +    print("  schafft. Wer eine Metaheuristik einsetzt, sollte diesen Vergleich")
    +    print("  IMMER mitrechnen - sonst schreibt man einer aufwendigen Methode")
    +    print("  gut, was schon die einfache geliefert haette.")
    +
    +    # --- Die Temperatur ist kein Beiwerk ---------------------------------
    +    print("\n" + "-" * 78)
    +    print("Und warum die Starttemperatur ausgemessen gehoert:\n")
    +    print(f"  {'Start-T':>8} {'Ruestzeit':>11} {'gg. Faustregel':>15} "
    +          f"{'angenommene':>12} {'schlechtester':>14}")
    +    print(f"  {'':>8} {'':>11} {'':>15} {'Verschlecht.':>12} {'Zwischenwert':>14}")
    +    print("  " + "-" * 64)
    +    ergebnisse, verlaeufe = {}, {}
    +    for temperatur in (0.5, 1.0, 2.0, 4.0, 8.0):
    +        _, wert, verlauf = suche(matrix, ZUEGE, temperatur)
    +        ergebnisse[temperatur] = wert
    +        verlaeufe[temperatur] = verlauf
    +        gewinn = (start_kosten - wert) / start_kosten * 100
    +        print(f"  {temperatur:>8.1f} {wert:>11,} {gewinn:>13.1f} % "
    +              f"{verlauf.annahmequote * 100:>11.2f} % "
    +              f"{verlauf.schlechtester_zustand:>14,}")
    +
    +    beste_temperatur = min(ergebnisse, key=ergebnisse.get)
    +    heiss = verlaeufe[8.0]
    +    print(f"\n  Bester Wert bei T0 = {beste_temperatur}.")
    +    print("\n  Die letzten beiden Spalten erklaeren, warum es nach oben kippt.")
    +    print(f"  Bei T0 = 8 gehen nur {heiss.annahmequote * 100:.2f} % der Verschlechterungen")
    +    print("  durch - wenig genug, dass man es fuer harmlos halten koennte. Es")
    +    print(f"  genuegt aber: Die Suche driftet bis auf {heiss.schlechtester_zustand:,} Minuten ab, das")
    +    print(f"  {heiss.schlechtester_zustand / start_kosten:.1f}-fache der Startloesung, und findet im Zugbudget nicht")
    +    print("  mehr zurueck. Vom Ergebnis bleibt fast nichts uebrig: Die Suche hat")
    +    print(f"  in {ZUEGE:,} Zuegen nie etwas GESEHEN, das mehr als")
    +    print(f"  {(start_kosten - ergebnisse[8.0]) / start_kosten * 100:.1f} % unter der Startloesung lag.")
    +    print("\n  Bemerkenswert ist die andere Richtung: Die besten Werte entstehen")
    +    print("  bei Annahmequoten nahe null. Die Lehrbuchregel 'anfangs sollen")
    +    print("  20 bis 50 Prozent der Verschlechterungen durchgehen' fuehrt hier in")
    +    print("  die Irre - sie stammt aus Problemen mit sehr kleinen Zuggroessen.")
    +    print("  Bei Ruestzeiten in Minuten ist ein einziger schlechter Zug teuer.")
    +    print("\n  Was stattdessen traegt: Den SCHLECHTESTEN Zwischenwert protokollieren")
    +    print("  und T0 so waehlen, dass er die Startloesung nicht wesentlich")
    +    print("  ueberschreitet. Diese eine Zahl haette hier auf Anhieb gezeigt, dass")
    +    print("  T0 = 8 unbrauchbar ist - ohne dass man das Endergebnis abwarten muss.")
    +    print("=" * 78)

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

    ==============================================================================
       SIMULATED ANNEALING AN DER LACKIERANLAGE
    @@ -13635,238 +13951,238 @@ Und warum die Starttemperatur ausgemessen gehoert:
     

    9.6 Ab wann lohnt es sich?

    „Kleine Probleme exakt, große mit Metaheuristiken“ ist richtig und nutzlos, solange niemand sagt, wo groß anfängt. Diese Zahl lässt sich messen.

    -
    #!/usr/bin/env python3
    -
    -# Metaheuristik_vs_Exakt.py
    -"""
    -Kapitel Metaheuristiken: Ab wann lohnt sich eine Metaheuristik? Die Antwort ist eine Zahl.
    -
    -Die verbreitete Regel lautet: "Kleine Probleme exakt, grosse mit
    -Metaheuristiken." Sie ist richtig - aber voellig nutzlos, solange niemand sagt,
    -wo 'gross' anfaengt. Dieses Programm misst es fuer die Ruestzeitenaufgabe aus
    -Simulated_Annealing.py nach, und das Ergebnis ueberrascht in beide Richtungen:
    -
    -  * Bei 60 Auftraegen loest CP-SAT BEWEISBAR optimal, in wenigen Sekunden.
    -    Die Metaheuristik ist hier schlicht die schlechtere Wahl - sie liefert ein
    -    schwaecheres Ergebnis und weiss nicht einmal, wie schwach.
    -  * Bei 200 Auftraegen liegt CP-SAT ohne Optimalitaetsbeweis vorne.
    -  * Erst bei 500 Auftraegen kippt es: Dort gibt CP-SAT im Zeitlimit eine
    -    Loesung aus, die SCHLECHTER ist als die Faustregel eines Meisters.
    -
    -Der Punkt des Kapitels steht in der letzten Spalte: Die Schranke. Nur der
    -exakte Solver sagt, wie gut die Loesung sein KANN. Eine Metaheuristik allein
    -liefert eine Zahl ohne Massstab - man weiss nie, ob man 2 % oder 40 % neben dem
    -Optimum liegt.
    -
    -ZUR REPRODUZIERBARKEIT: Die Spalte 'Annealing' ist auf jeder Maschine gleich -
    -sie laeuft ueber ein festes ZUGbudget. Die CP-SAT-Spalten sind es nicht: Ein
    -Zeitlimit ist ein Wanduhr-Limit, und wie weit der Solver in 30 Sekunden kommt,
    -haengt von der Maschine ab. CP-SAT kennt zwar ein deterministisches Zeitmass
    -(max_deterministic_time), das aber in einer Groessenordnung rechnet, die fuer
    -ein Buchbeispiel unbrauchbar langsam ist. Die Zahlen unten sind also
    -hardwareabhaengig - die AUSSAGE der Tabelle ist es nicht, sie ist auf
    -verschiedenen Groessen und in mehreren Laeufen stabil.
    -
    -WICHTIG: Hier wird nur ortools importiert, nicht highspy (Kapitel Oekosystem).
    -
    -Benoetigt: numpy, ortools
    -"""
    -
    -from __future__ import annotations
    -
    -import time
    -
    -import numpy as np
    -from ortools.sat.python import cp_model
    -
    -ZUEGE = 400_000           # Zugbudget der Metaheuristik (wie Simulated_Annealing.py)
    -ZEITLIMIT = 30.0          # Zeitbudget des exakten Solvers
    -GROESSEN = (60, 200, 500)
    -
    -
    -# --- Instanz, Faustregel und Suche: identisch zu Simulated_Annealing.py -----
    -
    -def erzeuge_ruestmatrix(n: int, saat: int = 11) -> np.ndarray:
    -    rng = np.random.default_rng(saat)
    -    familien = max(6, n // 10)
    -    zwischen = rng.integers(8, 60, (familien, familien))
    -    np.fill_diagonal(zwischen, 2)
    -    familie = rng.integers(0, familien, n)
    -    farbe = rng.integers(0, 10, n)
    -    matrix = np.zeros((n, n), dtype=np.int64)
    -    for i in range(n):
    -        for j in range(n):
    -            if i != j:
    -                matrix[i, j] = (zwischen[familie[i], familie[j]]
    -                                + 3 * max(0, farbe[i] - farbe[j]))
    -    return matrix
    -
    -
    -def gesamtruestzeit(reihe: list[int], matrix: np.ndarray) -> int:
    -    return int(sum(matrix[reihe[k], reihe[k + 1]] for k in range(len(reihe) - 1)))
    -
    -
    -def faustregel(matrix: np.ndarray) -> list[int]:
    -    n = len(matrix)
    -    offen = set(range(1, n))
    -    reihe = [0]
    -    while offen:
    -        naechster = min(offen, key=lambda j: matrix[reihe[-1], j])
    -        reihe.append(naechster)
    -        offen.discard(naechster)
    -    return reihe
    -
    -
    -def delta_verschieben(reihe, matrix, von, nach):
    -    n = len(reihe)
    -    if von == nach or nach == von + 1:
    -        return None
    -    heraus = 0
    -    if von > 0:
    -        heraus += matrix[reihe[von - 1], reihe[von]]
    -    if von < n - 1:
    -        heraus += matrix[reihe[von], reihe[von + 1]]
    -    if 0 < von < n - 1:
    -        heraus -= matrix[reihe[von - 1], reihe[von + 1]]
    -    rest = reihe[:von] + reihe[von + 1:]
    -    stelle = nach if nach < von else nach - 1
    -    hinein = 0
    -    if stelle > 0:
    -        hinein += matrix[rest[stelle - 1], reihe[von]]
    -    if stelle < len(rest):
    -        hinein += matrix[reihe[von], rest[stelle]]
    -    if 0 < stelle < len(rest):
    -        hinein -= matrix[rest[stelle - 1], rest[stelle]]
    -    return int(hinein - heraus), rest, stelle
    -
    -
    -def annealing(matrix: np.ndarray, zuege: int = ZUEGE,
    -              start_temperatur: float = 1.0, saat: int = 1) -> tuple[int, float]:
    -    """Dieselbe Suche wie in Simulated_Annealing.py, hier nur als Vergleichswert."""
    -    import math
    -
    -    rng = np.random.default_rng(saat)
    -    n = len(matrix)
    -    reihe = faustregel(matrix)
    -    kosten = gesamtruestzeit(reihe, matrix)
    -    beste_kosten = kosten
    -
    -    t0 = time.perf_counter()
    -    for zug in range(zuege):
    -        temperatur = start_temperatur * (0.05 / start_temperatur) ** (zug / zuege)
    -        von = int(rng.integers(0, n))
    -        nach = int(rng.integers(0, n + 1))
    -        ergebnis = delta_verschieben(reihe, matrix, von, nach)
    -        if ergebnis is None:
    -            continue
    -        aenderung, rest, stelle = ergebnis
    -        if aenderung <= 0 or rng.random() < math.exp(-aenderung / temperatur):
    -            reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]
    -            kosten += aenderung
    -            beste_kosten = min(beste_kosten, kosten)
    -    return beste_kosten, time.perf_counter() - t0
    -
    -
    -# --- Der exakte Weg: CP-SAT mit AddCircuit ---------------------------------
    -
    -def exakt(matrix: np.ndarray, zeitlimit: float = ZEITLIMIT):
    -    """Reihenfolge als Rundreise mit einem kostenlosen Hilfsknoten.
    -
    -    AddCircuit verlangt einen geschlossenen Kreis. Unsere Aufgabe ist aber eine
    -    offene Kette: Der erste Auftrag hat keinen Vorgaenger, der letzte keinen
    -    Nachfolger. Der uebliche Kniff ist ein zusaetzlicher Hilfsknoten n, dessen
    -    Kanten nichts kosten. Der Kreis laeuft dann Hilfsknoten -> Kette ->
    -    Hilfsknoten, und die Kosten des Kreises sind genau die der Kette.
    -
    -    AddCircuit ist der Grund, warum CP-SAT hier so lange mithaelt: Die
    -    Nebenbedingung sorgt selbst dafuer, dass keine Teilkreise entstehen. Wer
    -    dasselbe als MILP formuliert, braucht dafuer entweder exponentiell viele
    -    Ungleichungen oder die schwache MTZ-Formulierung (Kapitel Graphen).
    -    """
    -    n = len(matrix)
    -    modell = cp_model.CpModel()
    -    kanten, ziel = [], []
    -    for i in range(n + 1):
    -        for j in range(n + 1):
    -            if i == j:
    -                continue
    -            aktiv = modell.NewBoolVar(f"kante_{i}_{j}")
    -            kanten.append((i, j, aktiv))
    -            if i < n and j < n:                  # Hilfsknoten kostet nichts
    -                ziel.append(int(matrix[i, j]) * aktiv)
    -    modell.AddCircuit(kanten)
    -    modell.Minimize(sum(ziel))
    -
    -    loeser = cp_model.CpSolver()
    -    loeser.parameters.max_time_in_seconds = zeitlimit
    -    loeser.parameters.num_workers = 8
    -    loeser.parameters.random_seed = 1
    -    t0 = time.perf_counter()
    -    status = loeser.Solve(modell)
    -    dauer = time.perf_counter() - t0
    -    return (loeser.StatusName(status), int(loeser.ObjectiveValue()),
    -            int(loeser.BestObjectiveBound()), dauer)
    -
    -
    -if __name__ == "__main__":
    -    print("=" * 88)
    -    print("  AB WANN LOHNT SICH DIE METAHEURISTIK?")
    -    print("=" * 88)
    -    print(f"Dieselbe Aufgabe in drei Groessen. Metaheuristik: {ZUEGE:,} Zuege. "
    -          f"Exakt: {ZEITLIMIT:.0f} s Zeitlimit.\n")
    -
    -    print(f"{'Auftraege':>10} {'Faustregel':>11} {'Annealing':>10} "
    -          f"{'CP-SAT':>9} {'Status':>10} {'Schranke':>9} {'CP-Zeit':>9}")
    -    print("-" * 88)
    -
    -    zeilen = []
    -    for n in GROESSEN:
    -        matrix = erzeuge_ruestmatrix(n)
    -        start = gesamtruestzeit(faustregel(matrix), matrix)
    -        heuristisch, _ = annealing(matrix)
    -        status, wert, schranke, dauer = exakt(matrix)
    -        zeilen.append((n, start, heuristisch, wert, status, schranke, dauer))
    -        print(f"{n:>10} {start:>11,} {heuristisch:>10,} {wert:>9,} "
    -              f"{status:>10} {schranke:>9,} {dauer:>8.1f}s")
    -
    -    print("-" * 88)
    -    print("\nWas in jeder Zeile steht:\n")
    -    for n, start, heur, wert, status, schranke, _ in zeilen:
    -        if status == "OPTIMAL":
    -            urteil = (f"CP-SAT beweist das Optimum. Annealing liegt "
    -                      f"{(heur - wert) / wert * 100:.1f} % daneben - und haette")
    -            zweite = "     das ohne den exakten Lauf nicht erfahren."
    -        elif wert <= heur:
    -            urteil = (f"CP-SAT liegt {(heur - wert) / heur * 100:.1f} % vor Annealing, "
    -                      f"ohne Beweis (Gap {(wert - schranke) / wert * 100:.1f} %).")
    -            zweite = "     Auch ohne Optimalitaetsbeweis ist es die bessere Wahl."
    -        else:
    -            urteil = (f"UMSCHLAGPUNKT: CP-SAT ist {(wert - heur) / heur * 100:.1f} % "
    -                      f"SCHLECHTER als Annealing")
    -            zweite = (f"     und sogar {(wert - start) / start * 100:.1f} % schlechter "
    -                      f"als die Faustregel des Meisters.")
    -        print(f"  {n:>4} Auftraege: {urteil}")
    -        print(zweite)
    -
    -    n, start, heur, wert, status, schranke, _ = zeilen[-1]
    -    print("\n" + "=" * 88)
    -    print("  DIE SPALTE, DIE MAN NICHT WEGLASSEN DARF")
    -    print("=" * 88)
    -    print("Die Schranke ist der eigentliche Ertrag des exakten Solvers - auch dann,")
    -    print("wenn seine LOESUNG unbrauchbar ist.")
    -    print()
    -    print(f"Bei {n} Auftraegen liefert Annealing {heur:,} Minuten. Klingt gut: "
    -          f"{(start - heur) / start * 100:.1f} %")
    -    print("besser als die Faustregel. Die Schranke aus demselben CP-SAT-Lauf, dessen")
    -    print(f"Loesung wir gerade verworfen haben, sagt aber: Unter {schranke:,} Minuten geht")
    -    print(f"es nicht. Zwischen {schranke:,} und {heur:,} liegen {(heur - schranke) / heur * 100:.0f} % - so viel Luft")
    -    print("kann noch nach oben sein.")
    -    print()
    -    print("Das ist der Unterschied zwischen 'besser als vorher' und 'gut'. Eine")
    -    print("Metaheuristik allein kann nur das Erste. Deshalb gehoert auch dann ein")
    -    print("exakter Lauf dazu, wenn man am Ende die heuristische Loesung einsetzt:")
    -    print("nicht wegen seiner Loesung, sondern wegen seiner Schranke.")
    -    print("=" * 88)
    +
    #!/usr/bin/env python3
    +
    +# Metaheuristik_vs_Exakt.py
    +"""
    +Kapitel Metaheuristiken: Ab wann lohnt sich eine Metaheuristik? Die Antwort ist eine Zahl.
    +
    +Die verbreitete Regel lautet: "Kleine Probleme exakt, grosse mit
    +Metaheuristiken." Sie ist richtig - aber voellig nutzlos, solange niemand sagt,
    +wo 'gross' anfaengt. Dieses Programm misst es fuer die Ruestzeitenaufgabe aus
    +Simulated_Annealing.py nach, und das Ergebnis ueberrascht in beide Richtungen:
    +
    +  * Bei 60 Auftraegen loest CP-SAT BEWEISBAR optimal, in wenigen Sekunden.
    +    Die Metaheuristik ist hier schlicht die schlechtere Wahl - sie liefert ein
    +    schwaecheres Ergebnis und weiss nicht einmal, wie schwach.
    +  * Bei 200 Auftraegen liegt CP-SAT ohne Optimalitaetsbeweis vorne.
    +  * Erst bei 500 Auftraegen kippt es: Dort gibt CP-SAT im Zeitlimit eine
    +    Loesung aus, die SCHLECHTER ist als die Faustregel eines Meisters.
    +
    +Der Punkt des Kapitels steht in der letzten Spalte: Die Schranke. Nur der
    +exakte Solver sagt, wie gut die Loesung sein KANN. Eine Metaheuristik allein
    +liefert eine Zahl ohne Massstab - man weiss nie, ob man 2 % oder 40 % neben dem
    +Optimum liegt.
    +
    +ZUR REPRODUZIERBARKEIT: Die Spalte 'Annealing' ist auf jeder Maschine gleich -
    +sie laeuft ueber ein festes ZUGbudget. Die CP-SAT-Spalten sind es nicht: Ein
    +Zeitlimit ist ein Wanduhr-Limit, und wie weit der Solver in 30 Sekunden kommt,
    +haengt von der Maschine ab. CP-SAT kennt zwar ein deterministisches Zeitmass
    +(max_deterministic_time), das aber in einer Groessenordnung rechnet, die fuer
    +ein Buchbeispiel unbrauchbar langsam ist. Die Zahlen unten sind also
    +hardwareabhaengig - die AUSSAGE der Tabelle ist es nicht, sie ist auf
    +verschiedenen Groessen und in mehreren Laeufen stabil.
    +
    +WICHTIG: Hier wird nur ortools importiert, nicht highspy (Kapitel Oekosystem).
    +
    +Benoetigt: numpy, ortools
    +"""
    +
    +from __future__ import annotations
    +
    +import time
    +
    +import numpy as np
    +from ortools.sat.python import cp_model
    +
    +ZUEGE = 400_000           # Zugbudget der Metaheuristik (wie Simulated_Annealing.py)
    +ZEITLIMIT = 30.0          # Zeitbudget des exakten Solvers
    +GROESSEN = (60, 200, 500)
    +
    +
    +# --- Instanz, Faustregel und Suche: identisch zu Simulated_Annealing.py -----
    +
    +def erzeuge_ruestmatrix(n: int, saat: int = 11) -> np.ndarray:
    +    rng = np.random.default_rng(saat)
    +    familien = max(6, n // 10)
    +    zwischen = rng.integers(8, 60, (familien, familien))
    +    np.fill_diagonal(zwischen, 2)
    +    familie = rng.integers(0, familien, n)
    +    farbe = rng.integers(0, 10, n)
    +    matrix = np.zeros((n, n), dtype=np.int64)
    +    for i in range(n):
    +        for j in range(n):
    +            if i != j:
    +                matrix[i, j] = (zwischen[familie[i], familie[j]]
    +                                + 3 * max(0, farbe[i] - farbe[j]))
    +    return matrix
    +
    +
    +def gesamtruestzeit(reihe: list[int], matrix: np.ndarray) -> int:
    +    return int(sum(matrix[reihe[k], reihe[k + 1]] for k in range(len(reihe) - 1)))
    +
    +
    +def faustregel(matrix: np.ndarray) -> list[int]:
    +    n = len(matrix)
    +    offen = set(range(1, n))
    +    reihe = [0]
    +    while offen:
    +        naechster = min(offen, key=lambda j: matrix[reihe[-1], j])
    +        reihe.append(naechster)
    +        offen.discard(naechster)
    +    return reihe
    +
    +
    +def delta_verschieben(reihe, matrix, von, nach):
    +    n = len(reihe)
    +    if von == nach or nach == von + 1:
    +        return None
    +    heraus = 0
    +    if von > 0:
    +        heraus += matrix[reihe[von - 1], reihe[von]]
    +    if von < n - 1:
    +        heraus += matrix[reihe[von], reihe[von + 1]]
    +    if 0 < von < n - 1:
    +        heraus -= matrix[reihe[von - 1], reihe[von + 1]]
    +    rest = reihe[:von] + reihe[von + 1:]
    +    stelle = nach if nach < von else nach - 1
    +    hinein = 0
    +    if stelle > 0:
    +        hinein += matrix[rest[stelle - 1], reihe[von]]
    +    if stelle < len(rest):
    +        hinein += matrix[reihe[von], rest[stelle]]
    +    if 0 < stelle < len(rest):
    +        hinein -= matrix[rest[stelle - 1], rest[stelle]]
    +    return int(hinein - heraus), rest, stelle
    +
    +
    +def annealing(matrix: np.ndarray, zuege: int = ZUEGE,
    +              start_temperatur: float = 1.0, saat: int = 1) -> tuple[int, float]:
    +    """Dieselbe Suche wie in Simulated_Annealing.py, hier nur als Vergleichswert."""
    +    import math
    +
    +    rng = np.random.default_rng(saat)
    +    n = len(matrix)
    +    reihe = faustregel(matrix)
    +    kosten = gesamtruestzeit(reihe, matrix)
    +    beste_kosten = kosten
    +
    +    t0 = time.perf_counter()
    +    for zug in range(zuege):
    +        temperatur = start_temperatur * (0.05 / start_temperatur) ** (zug / zuege)
    +        von = int(rng.integers(0, n))
    +        nach = int(rng.integers(0, n + 1))
    +        ergebnis = delta_verschieben(reihe, matrix, von, nach)
    +        if ergebnis is None:
    +            continue
    +        aenderung, rest, stelle = ergebnis
    +        if aenderung <= 0 or rng.random() < math.exp(-aenderung / temperatur):
    +            reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]
    +            kosten += aenderung
    +            beste_kosten = min(beste_kosten, kosten)
    +    return beste_kosten, time.perf_counter() - t0
    +
    +
    +# --- Der exakte Weg: CP-SAT mit AddCircuit ---------------------------------
    +
    +def exakt(matrix: np.ndarray, zeitlimit: float = ZEITLIMIT):
    +    """Reihenfolge als Rundreise mit einem kostenlosen Hilfsknoten.
    +
    +    AddCircuit verlangt einen geschlossenen Kreis. Unsere Aufgabe ist aber eine
    +    offene Kette: Der erste Auftrag hat keinen Vorgaenger, der letzte keinen
    +    Nachfolger. Der uebliche Kniff ist ein zusaetzlicher Hilfsknoten n, dessen
    +    Kanten nichts kosten. Der Kreis laeuft dann Hilfsknoten -> Kette ->
    +    Hilfsknoten, und die Kosten des Kreises sind genau die der Kette.
    +
    +    AddCircuit ist der Grund, warum CP-SAT hier so lange mithaelt: Die
    +    Nebenbedingung sorgt selbst dafuer, dass keine Teilkreise entstehen. Wer
    +    dasselbe als MILP formuliert, braucht dafuer entweder exponentiell viele
    +    Ungleichungen oder die schwache MTZ-Formulierung (Kapitel Graphen).
    +    """
    +    n = len(matrix)
    +    modell = cp_model.CpModel()
    +    kanten, ziel = [], []
    +    for i in range(n + 1):
    +        for j in range(n + 1):
    +            if i == j:
    +                continue
    +            aktiv = modell.NewBoolVar(f"kante_{i}_{j}")
    +            kanten.append((i, j, aktiv))
    +            if i < n and j < n:                  # Hilfsknoten kostet nichts
    +                ziel.append(int(matrix[i, j]) * aktiv)
    +    modell.AddCircuit(kanten)
    +    modell.Minimize(sum(ziel))
    +
    +    loeser = cp_model.CpSolver()
    +    loeser.parameters.max_time_in_seconds = zeitlimit
    +    loeser.parameters.num_workers = 8
    +    loeser.parameters.random_seed = 1
    +    t0 = time.perf_counter()
    +    status = loeser.Solve(modell)
    +    dauer = time.perf_counter() - t0
    +    return (loeser.StatusName(status), int(loeser.ObjectiveValue()),
    +            int(loeser.BestObjectiveBound()), dauer)
    +
    +
    +if __name__ == "__main__":
    +    print("=" * 88)
    +    print("  AB WANN LOHNT SICH DIE METAHEURISTIK?")
    +    print("=" * 88)
    +    print(f"Dieselbe Aufgabe in drei Groessen. Metaheuristik: {ZUEGE:,} Zuege. "
    +          f"Exakt: {ZEITLIMIT:.0f} s Zeitlimit.\n")
    +
    +    print(f"{'Auftraege':>10} {'Faustregel':>11} {'Annealing':>10} "
    +          f"{'CP-SAT':>9} {'Status':>10} {'Schranke':>9} {'CP-Zeit':>9}")
    +    print("-" * 88)
    +
    +    zeilen = []
    +    for n in GROESSEN:
    +        matrix = erzeuge_ruestmatrix(n)
    +        start = gesamtruestzeit(faustregel(matrix), matrix)
    +        heuristisch, _ = annealing(matrix)
    +        status, wert, schranke, dauer = exakt(matrix)
    +        zeilen.append((n, start, heuristisch, wert, status, schranke, dauer))
    +        print(f"{n:>10} {start:>11,} {heuristisch:>10,} {wert:>9,} "
    +              f"{status:>10} {schranke:>9,} {dauer:>8.1f}s")
    +
    +    print("-" * 88)
    +    print("\nWas in jeder Zeile steht:\n")
    +    for n, start, heur, wert, status, schranke, _ in zeilen:
    +        if status == "OPTIMAL":
    +            urteil = (f"CP-SAT beweist das Optimum. Annealing liegt "
    +                      f"{(heur - wert) / wert * 100:.1f} % daneben - und haette")
    +            zweite = "     das ohne den exakten Lauf nicht erfahren."
    +        elif wert <= heur:
    +            urteil = (f"CP-SAT liegt {(heur - wert) / heur * 100:.1f} % vor Annealing, "
    +                      f"ohne Beweis (Gap {(wert - schranke) / wert * 100:.1f} %).")
    +            zweite = "     Auch ohne Optimalitaetsbeweis ist es die bessere Wahl."
    +        else:
    +            urteil = (f"UMSCHLAGPUNKT: CP-SAT ist {(wert - heur) / heur * 100:.1f} % "
    +                      f"SCHLECHTER als Annealing")
    +            zweite = (f"     und sogar {(wert - start) / start * 100:.1f} % schlechter "
    +                      f"als die Faustregel des Meisters.")
    +        print(f"  {n:>4} Auftraege: {urteil}")
    +        print(zweite)
    +
    +    n, start, heur, wert, status, schranke, _ = zeilen[-1]
    +    print("\n" + "=" * 88)
    +    print("  DIE SPALTE, DIE MAN NICHT WEGLASSEN DARF")
    +    print("=" * 88)
    +    print("Die Schranke ist der eigentliche Ertrag des exakten Solvers - auch dann,")
    +    print("wenn seine LOESUNG unbrauchbar ist.")
    +    print()
    +    print(f"Bei {n} Auftraegen liefert Annealing {heur:,} Minuten. Klingt gut: "
    +          f"{(start - heur) / start * 100:.1f} %")
    +    print("besser als die Faustregel. Die Schranke aus demselben CP-SAT-Lauf, dessen")
    +    print(f"Loesung wir gerade verworfen haben, sagt aber: Unter {schranke:,} Minuten geht")
    +    print(f"es nicht. Zwischen {schranke:,} und {heur:,} liegen {(heur - schranke) / heur * 100:.0f} % - so viel Luft")
    +    print("kann noch nach oben sein.")
    +    print()
    +    print("Das ist der Unterschied zwischen 'besser als vorher' und 'gut'. Eine")
    +    print("Metaheuristik allein kann nur das Erste. Deshalb gehoert auch dann ein")
    +    print("exakter Lauf dazu, wenn man am Ende die heuristische Loesung einsetzt:")
    +    print("nicht wegen seiner Loesung, sondern wegen seiner Schranke.")
    +    print("=" * 88)

    Erwartete Ausgabe (die CP-SAT-Spalten sind hardwareabhängig, siehe Programmkopf):

    ========================================================================================
       AB WANN LOHNT SICH DIE METAHEURISTIK?
    @@ -13971,292 +14287,292 @@ nicht wegen seiner Loesung, sondern wegen seiner Schranke.
     
     
     

    Der Solver bekommt also nicht mehr das ganze Problem, sondern immer wieder ein kleines.

    -
    #!/usr/bin/env python3
    -
    -# Large_Neighborhood_Search.py
    -"""
    -Kapitel Metaheuristiken: Zerstoeren und exakt reparieren - der Solver im Dienst der Heuristik.
    -
    -Metaheuristik_vs_Exakt.py endet mit einem Widerspruch. Bei 500 Auftraegen gilt:
    -
    -  * Der exakte Solver scheitert an der GROESSE - seine Loesung nach 30 Sekunden
    -    ist schlechter als die Faustregel eines Meisters.
    -  * Die Metaheuristik kommt weiter, laesst aber laut Schranke noch rund ein
    -    Viertel liegen. Ihre Zuege sind zu kleinteilig: Sie verschiebt jeweils
    -    EINEN Auftrag und kann eine ganze Passage nicht auf einmal umbauen.
    -
    -Large Neighborhood Search loest den Widerspruch, indem sie beide einsetzt -
    -jeden fuer das, was er kann:
    -
    -    ZERSTOEREN   Ein Stueck des Plans herausbrechen (hier: ein zusammen-
    -                 haengendes Fenster von 20 Auftraegen).
    -    REPARIEREN   Genau dieses Stueck EXAKT neu optimieren. 20 Auftraege sind
    -                 fuer CP-SAT eine Kleinigkeit, 500 sind es nicht.
    -    UEBERNEHMEN  Nur behalten, wenn der Gesamtplan besser wurde.
    -
    -Der Solver bekommt also nicht mehr das ganze Problem, sondern immer wieder ein
    -kleines. Das ist der ganze Trick, und er ist in der Praxis der wichtigste
    -Baustein dieses Kapitels.
    -
    -ZUR REPRODUZIERBARKEIT: Alle Laeufe haben eine feste RUNDENzahl, nicht ein
    -Zeitbudget - die Ergebniswerte sind damit auf jeder Maschine gleich. Die
    -Laufzeiten daneben sind hardwareabhaengig.
    -
    -Benoetigt: numpy, ortools
    -"""
    -
    -from __future__ import annotations
    -
    -import math
    -import time
    -
    -import numpy as np
    -from ortools.sat.python import cp_model
    -
    -FENSTER = 20              # so viele Auftraege werden je Runde herausgebrochen
    -RUNDEN = 120
    -ZUEGE = 400_000           # Zugbudget des Annealings (wie Simulated_Annealing.py)
    -N = 500
    -
    -
    -# --- Instanz und Faustregel (wie in Simulated_Annealing.py) ----------------
    -
    -def erzeuge_ruestmatrix(n: int, saat: int = 11) -> np.ndarray:
    -    rng = np.random.default_rng(saat)
    -    familien = max(6, n // 10)
    -    zwischen = rng.integers(8, 60, (familien, familien))
    -    np.fill_diagonal(zwischen, 2)
    -    familie = rng.integers(0, familien, n)
    -    farbe = rng.integers(0, 10, n)
    -    matrix = np.zeros((n, n), dtype=np.int64)
    -    for i in range(n):
    -        for j in range(n):
    -            if i != j:
    -                matrix[i, j] = (zwischen[familie[i], familie[j]]
    -                                + 3 * max(0, farbe[i] - farbe[j]))
    -    return matrix
    -
    -
    -def gesamtruestzeit(reihe: list[int], matrix: np.ndarray) -> int:
    -    return int(sum(matrix[reihe[k], reihe[k + 1]] for k in range(len(reihe) - 1)))
    -
    -
    -def faustregel(matrix: np.ndarray) -> list[int]:
    -    n = len(matrix)
    -    offen = set(range(1, n))
    -    reihe = [0]
    -    while offen:
    -        naechster = min(offen, key=lambda j: matrix[reihe[-1], j])
    -        reihe.append(naechster)
    -        offen.discard(naechster)
    -    return reihe
    -
    -
    -def delta_verschieben(reihe, matrix, von, nach):
    -    n = len(reihe)
    -    if von == nach or nach == von + 1:
    -        return None
    -    heraus = 0
    -    if von > 0:
    -        heraus += matrix[reihe[von - 1], reihe[von]]
    -    if von < n - 1:
    -        heraus += matrix[reihe[von], reihe[von + 1]]
    -    if 0 < von < n - 1:
    -        heraus -= matrix[reihe[von - 1], reihe[von + 1]]
    -    rest = reihe[:von] + reihe[von + 1:]
    -    stelle = nach if nach < von else nach - 1
    -    hinein = 0
    -    if stelle > 0:
    -        hinein += matrix[rest[stelle - 1], reihe[von]]
    -    if stelle < len(rest):
    -        hinein += matrix[reihe[von], rest[stelle]]
    -    if 0 < stelle < len(rest):
    -        hinein -= matrix[rest[stelle - 1], rest[stelle]]
    -    return int(hinein - heraus), rest, stelle
    -
    -
    -def annealing(matrix: np.ndarray, zuege: int = ZUEGE,
    -              start_temperatur: float = 1.0, saat: int = 1) -> list[int]:
    -    rng = np.random.default_rng(saat)
    -    n = len(matrix)
    -    reihe = faustregel(matrix)
    -    kosten = gesamtruestzeit(reihe, matrix)
    -    beste, beste_kosten = reihe[:], kosten
    -    for zug in range(zuege):
    -        temperatur = start_temperatur * (0.05 / start_temperatur) ** (zug / zuege)
    -        von, nach = int(rng.integers(0, n)), int(rng.integers(0, n + 1))
    -        ergebnis = delta_verschieben(reihe, matrix, von, nach)
    -        if ergebnis is None:
    -            continue
    -        aenderung, rest, stelle = ergebnis
    -        if aenderung <= 0 or rng.random() < math.exp(-aenderung / temperatur):
    -            reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]
    -            kosten += aenderung
    -            if kosten < beste_kosten:
    -                beste, beste_kosten = reihe[:], kosten
    -    return beste
    -
    -
    -# --- Der Reparaturschritt: ein kleines Problem, exakt geloest --------------
    -
    -def repariere_fenster(reihe: list[int], matrix: np.ndarray,
    -                      a: int, b: int) -> list[int] | None:
    -    """Ordnet reihe[a:b] optimal neu; die beiden Raender bleiben, wo sie sind.
    -
    -    Die Raender festzuhalten ist entscheidend. Ohne sie waere das Teilproblem
    -    ein anderes als der Ausschnitt aus dem Gesamtplan: Der Uebergang vom
    -    Vorgaenger in das Fenster und aus dem Fenster in den Nachfolger gehoert
    -    zu den Kosten dazu. Genau dafuer steht der Hilfsknoten k - er vertritt
    -    beide Raender in einem.
    -    """
    -    innen = reihe[a:b]
    -    k = len(innen)
    -    vorgaenger = reihe[a - 1] if a > 0 else None
    -    nachfolger_rand = reihe[b] if b < len(reihe) else None
    -
    -    modell = cp_model.CpModel()
    -    kanten, ziel = [], []
    -    for i in range(k + 1):
    -        for j in range(k + 1):
    -            if i == j:
    -                continue
    -            aktiv = modell.NewBoolVar(f"kante_{i}_{j}")
    -            kanten.append((i, j, aktiv))
    -            if i < k and j < k:
    -                ziel.append(int(matrix[innen[i], innen[j]]) * aktiv)
    -            elif i == k and j < k and vorgaenger is not None:
    -                ziel.append(int(matrix[vorgaenger, innen[j]]) * aktiv)
    -            elif j == k and i < k and nachfolger_rand is not None:
    -                ziel.append(int(matrix[innen[i], nachfolger_rand]) * aktiv)
    -    modell.AddCircuit(kanten)
    -    modell.Minimize(sum(ziel))
    -
    -    loeser = cp_model.CpSolver()
    -    loeser.parameters.num_workers = 1
    -    loeser.parameters.random_seed = 1
    -    loeser.parameters.max_time_in_seconds = 10.0
    -    status = loeser.Solve(modell)
    -    if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
    -        # Auch das gehoert dazu: Wenn das Teilproblem nicht loest, bleibt der
    -        # Plan, wie er war. Eine LNS-Runde darf scheitern.
    -        return None
    -
    -    nachfolger = {i: j for i, j, aktiv in kanten if loeser.Value(aktiv)}
    -    neue_folge, aktuell = [], nachfolger[k]
    -    while aktuell != k:
    -        neue_folge.append(innen[aktuell])
    -        aktuell = nachfolger[aktuell]
    -    return reihe[:a] + neue_folge + reihe[b:]
    -
    -
    -def lns(reihe: list[int], matrix: np.ndarray, runden: int = RUNDEN,
    -        fenster: int = FENSTER, saat: int = 3):
    -    """Zerstoeren, exakt reparieren, uebernehmen - so lange das Budget reicht."""
    -    rng = np.random.default_rng(saat)
    -    kosten = gesamtruestzeit(reihe, matrix)
    -    verbesserungen = 0
    -    start = time.perf_counter()
    -    for _ in range(runden):
    -        a = int(rng.integers(0, len(reihe) - fenster))
    -        neu = repariere_fenster(reihe, matrix, a, a + fenster)
    -        if neu is None:
    -            continue
    -        neue_kosten = gesamtruestzeit(neu, matrix)
    -        if neue_kosten < kosten:
    -            reihe, kosten = neu, neue_kosten
    -            verbesserungen += 1
    -    return reihe, kosten, verbesserungen, time.perf_counter() - start
    -
    -
    -if __name__ == "__main__":
    -    matrix = erzeuge_ruestmatrix(N)
    -    start_reihe = faustregel(matrix)
    -    start_kosten = gesamtruestzeit(start_reihe, matrix)
    -    SCHRANKE = 1768        # aus dem CP-SAT-Lauf in Metaheuristik_vs_Exakt.py
    -
    -    print("=" * 84)
    -    print("  ZERSTOEREN UND EXAKT REPARIEREN")
    -    print("=" * 84)
    -    print(f"{N} Auftraege. Je Runde werden {FENSTER} aufeinanderfolgende Auftraege")
    -    print(f"herausgebrochen und exakt neu geordnet, {RUNDEN} Runden lang.\n")
    -
    -    print("Erst die Metaheuristik allein, dann LNS auf ihrem Ergebnis:\n")
    -    sa_reihe = annealing(matrix)
    -    sa_kosten = gesamtruestzeit(sa_reihe, matrix)
    -
    -    lns_ab_faustregel = lns(start_reihe[:], matrix)
    -    lns_ab_annealing = lns(sa_reihe[:], matrix)
    -
    -    print(f"  {'Verfahren':<34} {'Ruestzeit':>10} {'ueber Schranke':>15}")
    -    print("  " + "-" * 62)
    -    for name, wert in [
    -            ("Faustregel (ohne Solver)", start_kosten),
    -            ("CP-SAT allein, 30 s", 2628),
    -            ("Simulated Annealing", sa_kosten),
    -            ("LNS ab Faustregel", lns_ab_faustregel[1]),
    -            ("Annealing, dann LNS", lns_ab_annealing[1])]:
    -        print(f"  {name:<34} {wert:>10,} {(wert - SCHRANKE) / SCHRANKE * 100:>13.1f} %")
    -    print(f"  {'untere Schranke (CP-SAT)':<34} {SCHRANKE:>10,} {0.0:>13.1f} %")
    -
    -    print(f"\n  LNS ab Faustregel: {lns_ab_faustregel[2]} von {RUNDEN} Runden brachten "
    -          f"eine Verbesserung ({lns_ab_faustregel[3]:.0f} s).")
    -    print(f"  LNS ab Annealing:  {lns_ab_annealing[2]} von {RUNDEN} Runden "
    -          f"({lns_ab_annealing[3]:.0f} s).")
    -
    -    # --- Die Fenstergroesse ----------------------------------------------
    -    # Verglichen wird bei ANNAEHERND GLEICHER ZEIT, nicht bei gleicher
    -    # Rundenzahl: Eine Runde mit Fenster 30 kostet ein Vielfaches einer Runde
    -    # mit Fenster 10. Wer nach Runden vergleicht, misst nur, dass groessere
    -    # Teilprobleme mehr finden - und uebersieht, was sie kosten. Die
    -    # Rundenzahlen unten sind so gewaehlt, dass alle drei Laeufe in derselben
    -    # Groessenordnung liegen.
    -    print("\n" + "-" * 84)
    -    print("Die Fenstergroesse ist die eine Stellschraube - und sie hat ein")
    -    print("Optimum in der Mitte. Gleiche Zeit, verschiedene Fenster:\n")
    -    print(f"  {'Fenster':>8} {'Runden':>8} {'Ruestzeit':>11} {'Verbesser-':>12} {'Zeit':>8}")
    -    print(f"  {'':>8} {'':>8} {'':>11} {'ungen':>12} {'':>8}")
    -    print("  " + "-" * 52)
    -    fenster_ergebnis = {}
    -    for fenster, runden in ((10, 4000), (20, 150), (30, 20)):
    -        _, wert, verbesserungen, dauer = lns(sa_reihe[:], matrix,
    -                                             runden=runden, fenster=fenster)
    -        fenster_ergebnis[fenster] = wert
    -        print(f"  {fenster:>8} {runden:>8,} {wert:>11,} {verbesserungen:>12} "
    -              f"{dauer:>7.0f}s")
    -
    -    print("\n  Nach unten ist die Grenze nicht die Zeit, sondern die SAETTIGUNG:")
    -    print(f"  Mit Fenster 10 bleiben von {4000:,} Runden nur eine Handvoll")
    -    print("  Verbesserungen uebrig. Zwanzig Auftraege lassen sich in einem Zug")
    -    print("  umbauen, zehn nicht - und was ein Fenster nicht umbauen kann, findet")
    -    print("  es auch in beliebig vielen Runden nicht.")
    -    print("\n  Nach oben ist die Grenze die Rechenzeit: Fenster 30 findet pro Runde")
    -    print("  mehr, kommt in derselben Zeit aber nur auf einen Bruchteil der Runden")
    -    print(f"  und landet bei {fenster_ergebnis[30]:,} statt {fenster_ergebnis[20]:,}. Eine Reparatur mit 30 Auftraegen")
    -    print("  ist eben genau das Problem, an dem der exakte Solver im Grossen")
    -    print("  scheitert - nur eine Nummer kleiner.")
    -    print("\n  Diese Tabelle gehoert an den Anfang jedes LNS-Projekts. Sie zu raten")
    -    print("  statt zu messen ist der haeufigste Fehler beim Einsatz des Verfahrens.")
    -
    -    print("\n" + "=" * 84)
    -    print("  WAS DAS HEISST")
    -    print("=" * 84)
    -    bester = lns_ab_annealing[1]
    -    print(f"Der beste Plan kommt aus der Kombination: {bester:,} Minuten,")
    -    print(f"{(start_kosten - bester) / start_kosten * 100:.1f} % unter der Faustregel und "
    -          f"{(sa_kosten - bester) / sa_kosten * 100:.1f} % unter dem reinen Annealing.")
    -    print()
    -    print("Weder der exakte Solver noch die Metaheuristik allein kommen dorthin.")
    -    print("Der Solver scheitert an der Groesse, die Metaheuristik an der Kleinheit")
    -    print("ihrer Zuege. LNS gibt dem Solver Teilprobleme in einer Groesse, die er")
    -    print("beherrscht, und der Metaheuristik die Umbauten, die sie nicht kann.")
    -    print()
    -    print(f"Und die Ehrlichkeit zum Schluss: Auch {bester:,} liegt noch "
    -          f"{(bester - SCHRANKE) / SCHRANKE * 100:.0f} % ueber der")
    -    print("Schranke. Ob dort wirklich noch so viel Luft ist oder ob die Schranke")
    -    print("nur schwach ist, weiss man nicht - das ist die Lage, in der man mit")
    -    print("einer Metaheuristik arbeitet. Man kennt seine Loesung, nicht ihren")
    -    print("Abstand zum Optimum.")
    -    print("=" * 84)
    +
    #!/usr/bin/env python3
    +
    +# Large_Neighborhood_Search.py
    +"""
    +Kapitel Metaheuristiken: Zerstoeren und exakt reparieren - der Solver im Dienst der Heuristik.
    +
    +Metaheuristik_vs_Exakt.py endet mit einem Widerspruch. Bei 500 Auftraegen gilt:
    +
    +  * Der exakte Solver scheitert an der GROESSE - seine Loesung nach 30 Sekunden
    +    ist schlechter als die Faustregel eines Meisters.
    +  * Die Metaheuristik kommt weiter, laesst aber laut Schranke noch rund ein
    +    Viertel liegen. Ihre Zuege sind zu kleinteilig: Sie verschiebt jeweils
    +    EINEN Auftrag und kann eine ganze Passage nicht auf einmal umbauen.
    +
    +Large Neighborhood Search loest den Widerspruch, indem sie beide einsetzt -
    +jeden fuer das, was er kann:
    +
    +    ZERSTOEREN   Ein Stueck des Plans herausbrechen (hier: ein zusammen-
    +                 haengendes Fenster von 20 Auftraegen).
    +    REPARIEREN   Genau dieses Stueck EXAKT neu optimieren. 20 Auftraege sind
    +                 fuer CP-SAT eine Kleinigkeit, 500 sind es nicht.
    +    UEBERNEHMEN  Nur behalten, wenn der Gesamtplan besser wurde.
    +
    +Der Solver bekommt also nicht mehr das ganze Problem, sondern immer wieder ein
    +kleines. Das ist der ganze Trick, und er ist in der Praxis der wichtigste
    +Baustein dieses Kapitels.
    +
    +ZUR REPRODUZIERBARKEIT: Alle Laeufe haben eine feste RUNDENzahl, nicht ein
    +Zeitbudget - die Ergebniswerte sind damit auf jeder Maschine gleich. Die
    +Laufzeiten daneben sind hardwareabhaengig.
    +
    +Benoetigt: numpy, ortools
    +"""
    +
    +from __future__ import annotations
    +
    +import math
    +import time
    +
    +import numpy as np
    +from ortools.sat.python import cp_model
    +
    +FENSTER = 20              # so viele Auftraege werden je Runde herausgebrochen
    +RUNDEN = 120
    +ZUEGE = 400_000           # Zugbudget des Annealings (wie Simulated_Annealing.py)
    +N = 500
    +
    +
    +# --- Instanz und Faustregel (wie in Simulated_Annealing.py) ----------------
    +
    +def erzeuge_ruestmatrix(n: int, saat: int = 11) -> np.ndarray:
    +    rng = np.random.default_rng(saat)
    +    familien = max(6, n // 10)
    +    zwischen = rng.integers(8, 60, (familien, familien))
    +    np.fill_diagonal(zwischen, 2)
    +    familie = rng.integers(0, familien, n)
    +    farbe = rng.integers(0, 10, n)
    +    matrix = np.zeros((n, n), dtype=np.int64)
    +    for i in range(n):
    +        for j in range(n):
    +            if i != j:
    +                matrix[i, j] = (zwischen[familie[i], familie[j]]
    +                                + 3 * max(0, farbe[i] - farbe[j]))
    +    return matrix
    +
    +
    +def gesamtruestzeit(reihe: list[int], matrix: np.ndarray) -> int:
    +    return int(sum(matrix[reihe[k], reihe[k + 1]] for k in range(len(reihe) - 1)))
    +
    +
    +def faustregel(matrix: np.ndarray) -> list[int]:
    +    n = len(matrix)
    +    offen = set(range(1, n))
    +    reihe = [0]
    +    while offen:
    +        naechster = min(offen, key=lambda j: matrix[reihe[-1], j])
    +        reihe.append(naechster)
    +        offen.discard(naechster)
    +    return reihe
    +
    +
    +def delta_verschieben(reihe, matrix, von, nach):
    +    n = len(reihe)
    +    if von == nach or nach == von + 1:
    +        return None
    +    heraus = 0
    +    if von > 0:
    +        heraus += matrix[reihe[von - 1], reihe[von]]
    +    if von < n - 1:
    +        heraus += matrix[reihe[von], reihe[von + 1]]
    +    if 0 < von < n - 1:
    +        heraus -= matrix[reihe[von - 1], reihe[von + 1]]
    +    rest = reihe[:von] + reihe[von + 1:]
    +    stelle = nach if nach < von else nach - 1
    +    hinein = 0
    +    if stelle > 0:
    +        hinein += matrix[rest[stelle - 1], reihe[von]]
    +    if stelle < len(rest):
    +        hinein += matrix[reihe[von], rest[stelle]]
    +    if 0 < stelle < len(rest):
    +        hinein -= matrix[rest[stelle - 1], rest[stelle]]
    +    return int(hinein - heraus), rest, stelle
    +
    +
    +def annealing(matrix: np.ndarray, zuege: int = ZUEGE,
    +              start_temperatur: float = 1.0, saat: int = 1) -> list[int]:
    +    rng = np.random.default_rng(saat)
    +    n = len(matrix)
    +    reihe = faustregel(matrix)
    +    kosten = gesamtruestzeit(reihe, matrix)
    +    beste, beste_kosten = reihe[:], kosten
    +    for zug in range(zuege):
    +        temperatur = start_temperatur * (0.05 / start_temperatur) ** (zug / zuege)
    +        von, nach = int(rng.integers(0, n)), int(rng.integers(0, n + 1))
    +        ergebnis = delta_verschieben(reihe, matrix, von, nach)
    +        if ergebnis is None:
    +            continue
    +        aenderung, rest, stelle = ergebnis
    +        if aenderung <= 0 or rng.random() < math.exp(-aenderung / temperatur):
    +            reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]
    +            kosten += aenderung
    +            if kosten < beste_kosten:
    +                beste, beste_kosten = reihe[:], kosten
    +    return beste
    +
    +
    +# --- Der Reparaturschritt: ein kleines Problem, exakt geloest --------------
    +
    +def repariere_fenster(reihe: list[int], matrix: np.ndarray,
    +                      a: int, b: int) -> list[int] | None:
    +    """Ordnet reihe[a:b] optimal neu; die beiden Raender bleiben, wo sie sind.
    +
    +    Die Raender festzuhalten ist entscheidend. Ohne sie waere das Teilproblem
    +    ein anderes als der Ausschnitt aus dem Gesamtplan: Der Uebergang vom
    +    Vorgaenger in das Fenster und aus dem Fenster in den Nachfolger gehoert
    +    zu den Kosten dazu. Genau dafuer steht der Hilfsknoten k - er vertritt
    +    beide Raender in einem.
    +    """
    +    innen = reihe[a:b]
    +    k = len(innen)
    +    vorgaenger = reihe[a - 1] if a > 0 else None
    +    nachfolger_rand = reihe[b] if b < len(reihe) else None
    +
    +    modell = cp_model.CpModel()
    +    kanten, ziel = [], []
    +    for i in range(k + 1):
    +        for j in range(k + 1):
    +            if i == j:
    +                continue
    +            aktiv = modell.NewBoolVar(f"kante_{i}_{j}")
    +            kanten.append((i, j, aktiv))
    +            if i < k and j < k:
    +                ziel.append(int(matrix[innen[i], innen[j]]) * aktiv)
    +            elif i == k and j < k and vorgaenger is not None:
    +                ziel.append(int(matrix[vorgaenger, innen[j]]) * aktiv)
    +            elif j == k and i < k and nachfolger_rand is not None:
    +                ziel.append(int(matrix[innen[i], nachfolger_rand]) * aktiv)
    +    modell.AddCircuit(kanten)
    +    modell.Minimize(sum(ziel))
    +
    +    loeser = cp_model.CpSolver()
    +    loeser.parameters.num_workers = 1
    +    loeser.parameters.random_seed = 1
    +    loeser.parameters.max_time_in_seconds = 10.0
    +    status = loeser.Solve(modell)
    +    if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
    +        # Auch das gehoert dazu: Wenn das Teilproblem nicht loest, bleibt der
    +        # Plan, wie er war. Eine LNS-Runde darf scheitern.
    +        return None
    +
    +    nachfolger = {i: j for i, j, aktiv in kanten if loeser.Value(aktiv)}
    +    neue_folge, aktuell = [], nachfolger[k]
    +    while aktuell != k:
    +        neue_folge.append(innen[aktuell])
    +        aktuell = nachfolger[aktuell]
    +    return reihe[:a] + neue_folge + reihe[b:]
    +
    +
    +def lns(reihe: list[int], matrix: np.ndarray, runden: int = RUNDEN,
    +        fenster: int = FENSTER, saat: int = 3):
    +    """Zerstoeren, exakt reparieren, uebernehmen - so lange das Budget reicht."""
    +    rng = np.random.default_rng(saat)
    +    kosten = gesamtruestzeit(reihe, matrix)
    +    verbesserungen = 0
    +    start = time.perf_counter()
    +    for _ in range(runden):
    +        a = int(rng.integers(0, len(reihe) - fenster))
    +        neu = repariere_fenster(reihe, matrix, a, a + fenster)
    +        if neu is None:
    +            continue
    +        neue_kosten = gesamtruestzeit(neu, matrix)
    +        if neue_kosten < kosten:
    +            reihe, kosten = neu, neue_kosten
    +            verbesserungen += 1
    +    return reihe, kosten, verbesserungen, time.perf_counter() - start
    +
    +
    +if __name__ == "__main__":
    +    matrix = erzeuge_ruestmatrix(N)
    +    start_reihe = faustregel(matrix)
    +    start_kosten = gesamtruestzeit(start_reihe, matrix)
    +    SCHRANKE = 1768        # aus dem CP-SAT-Lauf in Metaheuristik_vs_Exakt.py
    +
    +    print("=" * 84)
    +    print("  ZERSTOEREN UND EXAKT REPARIEREN")
    +    print("=" * 84)
    +    print(f"{N} Auftraege. Je Runde werden {FENSTER} aufeinanderfolgende Auftraege")
    +    print(f"herausgebrochen und exakt neu geordnet, {RUNDEN} Runden lang.\n")
    +
    +    print("Erst die Metaheuristik allein, dann LNS auf ihrem Ergebnis:\n")
    +    sa_reihe = annealing(matrix)
    +    sa_kosten = gesamtruestzeit(sa_reihe, matrix)
    +
    +    lns_ab_faustregel = lns(start_reihe[:], matrix)
    +    lns_ab_annealing = lns(sa_reihe[:], matrix)
    +
    +    print(f"  {'Verfahren':<34} {'Ruestzeit':>10} {'ueber Schranke':>15}")
    +    print("  " + "-" * 62)
    +    for name, wert in [
    +            ("Faustregel (ohne Solver)", start_kosten),
    +            ("CP-SAT allein, 30 s", 2628),
    +            ("Simulated Annealing", sa_kosten),
    +            ("LNS ab Faustregel", lns_ab_faustregel[1]),
    +            ("Annealing, dann LNS", lns_ab_annealing[1])]:
    +        print(f"  {name:<34} {wert:>10,} {(wert - SCHRANKE) / SCHRANKE * 100:>13.1f} %")
    +    print(f"  {'untere Schranke (CP-SAT)':<34} {SCHRANKE:>10,} {0.0:>13.1f} %")
    +
    +    print(f"\n  LNS ab Faustregel: {lns_ab_faustregel[2]} von {RUNDEN} Runden brachten "
    +          f"eine Verbesserung ({lns_ab_faustregel[3]:.0f} s).")
    +    print(f"  LNS ab Annealing:  {lns_ab_annealing[2]} von {RUNDEN} Runden "
    +          f"({lns_ab_annealing[3]:.0f} s).")
    +
    +    # --- Die Fenstergroesse ----------------------------------------------
    +    # Verglichen wird bei ANNAEHERND GLEICHER ZEIT, nicht bei gleicher
    +    # Rundenzahl: Eine Runde mit Fenster 30 kostet ein Vielfaches einer Runde
    +    # mit Fenster 10. Wer nach Runden vergleicht, misst nur, dass groessere
    +    # Teilprobleme mehr finden - und uebersieht, was sie kosten. Die
    +    # Rundenzahlen unten sind so gewaehlt, dass alle drei Laeufe in derselben
    +    # Groessenordnung liegen.
    +    print("\n" + "-" * 84)
    +    print("Die Fenstergroesse ist die eine Stellschraube - und sie hat ein")
    +    print("Optimum in der Mitte. Gleiche Zeit, verschiedene Fenster:\n")
    +    print(f"  {'Fenster':>8} {'Runden':>8} {'Ruestzeit':>11} {'Verbesser-':>12} {'Zeit':>8}")
    +    print(f"  {'':>8} {'':>8} {'':>11} {'ungen':>12} {'':>8}")
    +    print("  " + "-" * 52)
    +    fenster_ergebnis = {}
    +    for fenster, runden in ((10, 4000), (20, 150), (30, 20)):
    +        _, wert, verbesserungen, dauer = lns(sa_reihe[:], matrix,
    +                                             runden=runden, fenster=fenster)
    +        fenster_ergebnis[fenster] = wert
    +        print(f"  {fenster:>8} {runden:>8,} {wert:>11,} {verbesserungen:>12} "
    +              f"{dauer:>7.0f}s")
    +
    +    print("\n  Nach unten ist die Grenze nicht die Zeit, sondern die SAETTIGUNG:")
    +    print(f"  Mit Fenster 10 bleiben von {4000:,} Runden nur eine Handvoll")
    +    print("  Verbesserungen uebrig. Zwanzig Auftraege lassen sich in einem Zug")
    +    print("  umbauen, zehn nicht - und was ein Fenster nicht umbauen kann, findet")
    +    print("  es auch in beliebig vielen Runden nicht.")
    +    print("\n  Nach oben ist die Grenze die Rechenzeit: Fenster 30 findet pro Runde")
    +    print("  mehr, kommt in derselben Zeit aber nur auf einen Bruchteil der Runden")
    +    print(f"  und landet bei {fenster_ergebnis[30]:,} statt {fenster_ergebnis[20]:,}. Eine Reparatur mit 30 Auftraegen")
    +    print("  ist eben genau das Problem, an dem der exakte Solver im Grossen")
    +    print("  scheitert - nur eine Nummer kleiner.")
    +    print("\n  Diese Tabelle gehoert an den Anfang jedes LNS-Projekts. Sie zu raten")
    +    print("  statt zu messen ist der haeufigste Fehler beim Einsatz des Verfahrens.")
    +
    +    print("\n" + "=" * 84)
    +    print("  WAS DAS HEISST")
    +    print("=" * 84)
    +    bester = lns_ab_annealing[1]
    +    print(f"Der beste Plan kommt aus der Kombination: {bester:,} Minuten,")
    +    print(f"{(start_kosten - bester) / start_kosten * 100:.1f} % unter der Faustregel und "
    +          f"{(sa_kosten - bester) / sa_kosten * 100:.1f} % unter dem reinen Annealing.")
    +    print()
    +    print("Weder der exakte Solver noch die Metaheuristik allein kommen dorthin.")
    +    print("Der Solver scheitert an der Groesse, die Metaheuristik an der Kleinheit")
    +    print("ihrer Zuege. LNS gibt dem Solver Teilprobleme in einer Groesse, die er")
    +    print("beherrscht, und der Metaheuristik die Umbauten, die sie nicht kann.")
    +    print()
    +    print(f"Und die Ehrlichkeit zum Schluss: Auch {bester:,} liegt noch "
    +          f"{(bester - SCHRANKE) / SCHRANKE * 100:.0f} % ueber der")
    +    print("Schranke. Ob dort wirklich noch so viel Luft ist oder ob die Schranke")
    +    print("nur schwach ist, weiss man nicht - das ist die Lage, in der man mit")
    +    print("einer Metaheuristik arbeitet. Man kennt seine Loesung, nicht ihren")
    +    print("Abstand zum Optimum.")
    +    print("=" * 84)

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

    ====================================================================================
       ZERSTOEREN UND EXAKT REPARIEREN
    @@ -14514,28 +14830,28 @@ Abstand zum Optimum.
     

    🚀 In 5 Minuten gelöst: Denken Sie in Mustern, nicht in Stücken

    Eine Rolle ist 1 000 mm breit. Bestellt sind 9 Stück à 420 mm, 12 à 310 mm und 14 à 250 mm. Wie viele Rollen braucht man?

    Die naheliegende Frage — „welches Stück kommt auf welche Rolle?“ — führt zu 35 Einzelentscheidungen. Die bessere Frage lautet: „Wie oft schneide ich welches Muster?“

    -
    import itertools
    -import numpy as np
    -from scipy.optimize import linprog
    -
    -ROLLE = 1000                                  # mm Mutterrolle
    -breiten, bedarf = [420, 310, 250], [9, 12, 14]
    -
    -# Alle Schnittmuster aufzaehlen, die in die Rolle passen
    -muster = [m for m in itertools.product(*[range(ROLLE // b + 1) for b in breiten])
    -          if sum(a * b for a, b in zip(m, breiten)) <= ROLLE and sum(m) > 0]
    -
    -# Wie oft schneide ich welches Muster?  min sum(x)  u.d.N.  A x >= bedarf
    -loesung = linprog(np.ones(len(muster)), A_ub=-np.array(muster, float).T,
    -                  b_ub=-np.array(bedarf, float), bounds=(0, None),
    -                  integrality=1, method="highs")
    -
    -print(f"{len(muster)} zulaessige Muster, {int(round(loesung.fun))} Rollen noetig:")
    -for anzahl, m in zip(np.round(loesung.x).astype(int), muster):
    -    if anzahl:
    -        rest = ROLLE - sum(a * b for a, b in zip(m, breiten))
    -        print(f"  {anzahl:2d} x  " + "  ".join(f"{a}x{b}mm" for a, b in zip(m, breiten))
    -              + f"   Verschnitt {rest} mm")
    +
    import itertools
    +import numpy as np
    +from scipy.optimize import linprog
    +
    +ROLLE = 1000                                  # mm Mutterrolle
    +breiten, bedarf = [420, 310, 250], [9, 12, 14]
    +
    +# Alle Schnittmuster aufzaehlen, die in die Rolle passen
    +muster = [m for m in itertools.product(*[range(ROLLE // b + 1) for b in breiten])
    +          if sum(a * b for a, b in zip(m, breiten)) <= ROLLE and sum(m) > 0]
    +
    +# Wie oft schneide ich welches Muster?  min sum(x)  u.d.N.  A x >= bedarf
    +loesung = linprog(np.ones(len(muster)), A_ub=-np.array(muster, float).T,
    +                  b_ub=-np.array(bedarf, float), bounds=(0, None),
    +                  integrality=1, method="highs")
    +
    +print(f"{len(muster)} zulaessige Muster, {int(round(loesung.fun))} Rollen noetig:")
    +for anzahl, m in zip(np.round(loesung.x).astype(int), muster):
    +    if anzahl:
    +        rest = ROLLE - sum(a * b for a, b in zip(m, breiten))
    +        print(f"  {anzahl:2d} x  " + "  ".join(f"{a}x{b}mm" for a, b in zip(m, breiten))
    +              + f"   Verschnitt {rest} mm")

    Ausgabe:

    16 zulaessige Muster, 12 Rollen noetig:
        2 x  0x420mm  0x310mm  4x250mm   Verschnitt 0 mm
    @@ -14688,298 +15004,298 @@ Abstand zum Optimum.
     

    Das Teilproblem ist also ein Verfahren, das die Leser dieses Buchs schon kennen. Neu ist allein die Schleife.


    10.5 Das Programm

    -
    #!/usr/bin/env python3
    -
    -# Spaltengenerierung.py
    -"""
    -Kapitel Dekomposition: Das Modell umbauen, statt die Loesung zu raten.
    -
    -Eine Papierfabrik liefert Rollen von 5.600 mm Breite. Die Kunden bestellen
    -schmalere Breiten, und aus jeder Mutterrolle werden mehrere davon geschnitten.
    -Gesucht ist der Schnittplan, der mit den wenigsten Mutterrollen auskommt.
    -
    -Der naheliegende Modellansatz - "welches Stueck kommt auf welche Rolle?" -
    -fuehrt in eine Sackgasse: Er braucht eine Binaervariable je Stueck-und-Rolle,
    -und weil alle Rollen gleich sind, ist er hochgradig symmetrisch. Der Solver
    -probiert dieselbe Loesung in tausend Umbenennungen durch.
    -
    -Die Spaltengenerierung dreht die Frage um:
    -
    -    Nicht "welches Stueck auf welche Rolle",
    -    sondern "wie oft schneide ich welches MUSTER".
    -
    -Damit verschwindet die Symmetrie - aber es entsteht ein neues Problem: Die Zahl
    -der moeglichen Muster waechst kombinatorisch. Man kann sie nicht aufschreiben.
    -Der Trick besteht darin, sie auch nicht aufzuschreiben, sondern nur die wenigen
    -zu erzeugen, die tatsaechlich gebraucht werden - und zwar mit Hilfe der
    -Schattenpreise aus dem Kapitel LP.
    -
    -Das Programm zeigt vier Dinge:
    -
    -  1. Die beiden Formulierungen desselben Problems und ihre Groesse.
    -  2. Wie schnell die Musterzahl waechst - und wie wenige davon genuegen.
    -  3. Die Schleife selbst: Master-LP, Dualwerte, Rucksack, neue Spalte.
    -  4. Wann sich der Aufwand lohnt und wann nicht. Die Antwort haengt an einer
    -     einzigen Kennzahl der Instanz, und sie erklaert nebenbei, warum eine
    -     Faustregel bei manchen Zuschnittproblemen schon optimal ist.
    -
    -Benoetigt: numpy, scipy
    -"""
    -
    -from __future__ import annotations
    -
    -import numpy as np
    -from scipy.optimize import linprog
    -
    -ROLLENBREITE = 5600
    -
    -# Zwei Instanzen mit demselben Bedarf, aber verschiedenen Breiten. Der
    -# Unterschied ist die eine Kennzahl, um die es in Teil 4 geht.
    -BEDARF = [22, 25, 12, 14, 18, 18, 20, 10, 12, 14, 16, 18, 20]
    -BREIT = [1380, 1520, 1560, 1710, 1820, 1880, 1930, 2000, 2050, 2100, 2140,
    -         2150, 2200]
    -SCHMAL = [380, 520, 560, 710, 820, 880, 930, 1000, 1050, 1100, 1140,
    -          1150, 1200]
    -
    -
    -# --- Die Musterzahl ---------------------------------------------------------
    -
    -def zaehle_muster(breiten, rollenbreite: int = ROLLENBREITE) -> int:
    -    """Wie viele zulaessige Schnittmuster gibt es ueberhaupt?
    -
    -    Nur zum Zeigen - in einem produktiven Programm wuerde man das nie
    -    ausrechnen, weil die Zahl bei realistischen Instanzen jede Vorstellung
    -    sprengt. Genau das ist der Punkt.
    -    """
    -    speicher: dict[tuple[int, int], int] = {}
    -
    -    def ab(i: int, rest: int) -> int:
    -        if i == len(breiten):
    -            return 1
    -        if (i, rest) in speicher:
    -            return speicher[(i, rest)]
    -        summe = sum(ab(i + 1, rest - anzahl * breiten[i])
    -                    for anzahl in range(rest // breiten[i] + 1))
    -        speicher[(i, rest)] = summe
    -        return summe
    -
    -    return ab(0, rollenbreite)
    -
    -
    -# --- Die beiden Bausteine der Schleife --------------------------------------
    -
    -def loese_master(muster, bedarf, ganzzahlig: bool = False):
    -    """Restringiertes Master: Wie oft schneide ich jedes bekannte Muster?
    -
    -        min  sum_p x_p           unter   sum_p a_ip * x_p >= bedarf_i
    -
    -    Als LP gerechnet liefert es zusaetzlich die DUALWERTE - und die sind der
    -    eigentliche Ertrag: Der Dualwert zu Breite i sagt, was ein zusaetzliches
    -    Stueck dieser Breite an Rollen kostet. Es ist derselbe Schattenpreis wie
    -    im Kapitel LP, nur dass er hier nicht berichtet, sondern weiterverarbeitet
    -    wird.
    -    """
    -    matrix = -np.array(muster, dtype=float).T        # >= wird zu <= mit Minus
    -    ergebnis = linprog(np.ones(len(muster)),
    -                       A_ub=matrix, b_ub=-np.array(bedarf, dtype=float),
    -                       bounds=(0, None),
    -                       integrality=(1 if ganzzahlig else 0), method="highs")
    -    if not ergebnis.success:
    -        raise SystemExit(f"Master nicht loesbar: {ergebnis.message}")
    -    dual = None if ganzzahlig else -ergebnis.ineqlin.marginals
    -    return ergebnis.fun, ergebnis.x, dual
    -
    -
    -def bestes_neues_muster(dual, breiten, rollenbreite: int = ROLLENBREITE):
    -    """Pricing: Gibt es ein Muster, das sich noch lohnt?
    -
    -    Gesucht ist das Muster mit dem groessten Gesamtwert zu den aktuellen
    -    Schattenpreisen - unter der Bedingung, dass es in die Rolle passt. Das ist
    -    ein RUCKSACKPROBLEM (Kapitel MILP, Rucksack.py): Preise sind der Nutzen,
    -    Breiten das Gewicht, die Rollenbreite die Kapazitaet. Hier mit dynamischer
    -    Programmierung geloest, weil die Breiten ganzzahlig sind.
    -
    -    Bei Gleichstand wird bewusst der KLEINSTE Index bevorzugt (>-Vergleich mit
    -    Toleranz statt >=). Sonst haengt das erzeugte Muster von der Reihenfolge
    -    der Gleitkommaoperationen ab, und das Programm liefert von Lauf zu Lauf
    -    verschiedene Ausgaben.
    -    """
    -    wert = np.zeros(rollenbreite + 1)
    -    herkunft = [-1] * (rollenbreite + 1)
    -    for platz in range(rollenbreite + 1):
    -        for i, breite in enumerate(breiten):
    -            if breite <= platz and wert[platz - breite] + dual[i] > wert[platz] + 1e-9:
    -                wert[platz] = wert[platz - breite] + dual[i]
    -                herkunft[platz] = i
    -
    -    muster = [0] * len(breiten)
    -    platz = rollenbreite
    -    while herkunft[platz] >= 0:
    -        i = herkunft[platz]
    -        muster[i] += 1
    -        platz -= breiten[i]
    -    return float(wert[rollenbreite]), muster
    -
    -
    -def spaltengenerierung(breiten, bedarf, rollenbreite: int = ROLLENBREITE):
    -    """Die Schleife. Startbasis: je ein Muster mit nur einer Breite.
    -
    -    Abbruch, wenn kein Muster mehr einen Wert ueber 1 hat: Eine zusaetzliche
    -    Rolle kostet 1, also lohnt sich ein neues Muster nur, wenn es zu den
    -    aktuellen Preisen mehr als 1 wert ist. Das ist das Kriterium der
    -    reduzierten Kosten.
    -    """
    -    anzahl = len(breiten)
    -    muster = [[0] * anzahl for _ in range(anzahl)]
    -    for i in range(anzahl):
    -        muster[i][i] = rollenbreite // breiten[i]
    -
    -    runden = 0
    -    while True:
    -        zielwert, _, dual = loese_master(muster, bedarf)
    -        wert, neu = bestes_neues_muster(dual, breiten, rollenbreite)
    -        runden += 1
    -        if wert <= 1 + 1e-6:
    -            return muster, zielwert, runden
    -        muster.append(neu)
    -
    -
    -def first_fit(breiten, bedarf, rollenbreite: int = ROLLENBREITE) -> int:
    -    """Die Faustregel: groesstes Stueck zuerst, auf die erste passende Rolle."""
    -    stuecke = sorted([b for b, menge in zip(breiten, bedarf)
    -                      for _ in range(menge)], reverse=True)
    -    rollen: list[list[int]] = []
    -    for stueck in stuecke:
    -        for rolle in rollen:
    -            if sum(rolle) + stueck <= rollenbreite:
    -                rolle.append(stueck)
    -                break
    -        else:
    -            rollen.append([stueck])
    -    return len(rollen)
    -
    -
    -def auswerten(name: str, breiten, bedarf) -> dict:
    -    muster, schranke, runden = spaltengenerierung(breiten, bedarf)
    -    ganz, _, _ = loese_master(muster, bedarf, ganzzahlig=True)
    -    return {"name": name, "muster_gesamt": zaehle_muster(breiten),
    -            "muster_erzeugt": len(muster), "runden": runden,
    -            "schranke": schranke, "ganzzahlig": int(round(ganz)),
    -            "first_fit": first_fit(breiten, bedarf),
    -            "stuecke_je_rolle": ROLLENBREITE / np.mean(breiten)}
    -
    -
    -if __name__ == "__main__":
    -    print("=" * 84)
    -    print("  SPALTENGENERIERUNG: NUR DIE MUSTER ERZEUGEN, DIE MAN BRAUCHT")
    -    print("=" * 84)
    -    print(f"Mutterrolle {ROLLENBREITE:,} mm, {len(BREIT)} bestellte Breiten, "
    -          f"{sum(BEDARF)} Zuschnitte.\n")
    -
    -    # --- 1. Zwei Formulierungen ------------------------------------------
    -    stuecke = sum(BEDARF)
    -    # Wie viele Rollen braucht man hoechstens? Die Faustregel liefert eine
    -    # brauchbare Obergrenze - mehr Rollen als das wird niemand benoetigen.
    -    rollen_obergrenze = first_fit(BREIT, BEDARF)
    -    print("1. Zwei Modelle fuer dieselbe Aufgabe\n")
    -    print(f"  {'Formulierung':<34} {'Binaervariablen':>16} {'symmetrisch?':>16}")
    -    print("  " + "-" * 70)
    -    print(f"  {'Stueck -> Rolle (naheliegend)':<34} "
    -          f"{stuecke * rollen_obergrenze:>16,} {'ja':>16}")
    -    print(f"  {'wie oft welches Muster':<34} "
    -          f"{'eine je Muster':>16} {'nein':>16}")
    -    print(f"\n  Der naheliegende Ansatz braucht eine Variable je Stueck und Rolle:")
    -    print(f"  {stuecke} Zuschnitte x {rollen_obergrenze} Rollen (Obergrenze aus der Faustregel)")
    -    print(f"  = {stuecke * rollen_obergrenze:,} Binaervariablen.")
    -    print("\n  Schlimmer als die Zahl ist die Symmetrie: Alle Mutterrollen sind")
    -    print("  gleich, also beschreibt jede Loesung dieselbe Schnittvorschrift in")
    -    print("  unzaehligen Umbenennungen. Branch-and-Bound probiert sie einzeln")
    -    print("  durch und kommt nicht voran - der Suchbaum waechst, ohne dass sich")
    -    print("  die Schranke bewegt.")
    -    print("\n  Das Mustermodell hat je Muster genau eine Variable und kennt keine")
    -    print("  einzelnen Rollen mehr. Damit ist die Symmetrie weg. Sein Problem ist")
    -    print("  ein anderes - und zwar das folgende.")
    -
    -    # --- 2. Die Musterzahl ------------------------------------------------
    -    print("\n" + "-" * 84)
    -    print("2. Warum man die Muster nicht aufschreiben kann\n")
    -    gross = auswerten("breite Zuschnitte (1.380-2.200 mm)", BREIT, BEDARF)
    -    schmal = auswerten("schmale Zuschnitte (380-1.200 mm)", SCHMAL, BEDARF)
    -
    -    print(f"  {'Instanz':<36} {'Muster':>12} {'davon erzeugt':>14} {'Anteil':>9}")
    -    print("  " + "-" * 76)
    -    for fall in (gross, schmal):
    -        anteil = fall["muster_erzeugt"] / fall["muster_gesamt"]
    -        print(f"  {fall['name']:<36} {fall['muster_gesamt']:>12,} "
    -              f"{fall['muster_erzeugt']:>14} {anteil:>8.1%}")
    -
    -    print(f"\n  Schon das Halbieren der Breiten laesst die Musterzahl von "
    -          f"{gross['muster_gesamt']:,} auf")
    -    print(f"  {schmal['muster_gesamt']:,} springen - Faktor "
    -          f"{schmal['muster_gesamt'] / gross['muster_gesamt']:.0f}. Bei einer echten "
    -          f"Papierfabrik mit")
    -    print("  vierzig Breiten und Millimeterschritten sind es mehr, als sich")
    -    print("  speichern liesse.")
    -    print(f"\n  Gebraucht werden davon {schmal['muster_erzeugt']} - "
    -          f"{schmal['muster_erzeugt'] / schmal['muster_gesamt']:.2%} der Gesamtzahl.")
    -
    -    # --- 3. Die Schleife --------------------------------------------------
    -    print("\n" + "-" * 84)
    -    print("3. Die Schleife an der breiten Instanz\n")
    -    print(f"  Startbasis: {len(BREIT)} triviale Muster (je Rolle nur eine Breite)")
    -    print(f"  Runden bis kein Muster mehr lohnt: {gross['runden']}")
    -    print(f"  Muster am Ende: {gross['muster_erzeugt']}")
    -    print(f"  LP-Schranke: {gross['schranke']:.4f} Rollen")
    -    print(f"\n  Die LP-Schranke ist eine ZUSAGE: Weniger als "
    -          f"{np.ceil(gross['schranke'] - 1e-9):.0f} Rollen sind")
    -    print("  nicht moeglich - unabhaengig davon, wie clever man weiterschneidet.")
    -    print("  Genau diese Aussage fehlt einer Heuristik (Kapitel Metaheuristiken).")
    -
    -    # --- 4. Wann es sich lohnt --------------------------------------------
    -    print("\n" + "-" * 84)
    -    print("4. Wann sich der Aufwand lohnt - und wann nicht\n")
    -    print(f"  {'Instanz':<36} {'Faustregel':>11} {'exakt':>8} {'Schranke':>10} "
    -          f"{'Ersparnis':>11}")
    -    print("  " + "-" * 80)
    -    for fall in (gross, schmal):
    -        ersparnis = (fall["first_fit"] - fall["ganzzahlig"]) / fall["first_fit"]
    -        print(f"  {fall['name']:<36} {fall['first_fit']:>11} "
    -              f"{fall['ganzzahlig']:>8} {fall['schranke']:>10.2f} "
    -              f"{ersparnis:>10.0%}")
    -
    -    print(f"\n  Bei den breiten Zuschnitten spart die Spaltengenerierung "
    -          f"{gross['first_fit'] - gross['ganzzahlig']} von")
    -    print(f"  {gross['first_fit']} Rollen. Bei den schmalen spart sie NICHTS - dort ist die")
    -    print("  Faustregel bereits optimal.")
    -    print(f"\n  Der Unterschied haengt an einer einzigen Kennzahl:\n")
    -    print(f"  {'Instanz':<36} {'Stuecke je Rolle (etwa)':>24}")
    -    print("  " + "-" * 62)
    -    for fall in (gross, schmal):
    -        print(f"  {fall['name']:<36} {fall['stuecke_je_rolle']:>24.1f}")
    -
    -    print("\n  Passen nur zwei bis drei Stuecke auf eine Rolle, entscheidet jede")
    -    print("  einzelne Zuordnung viel, und eine kurzsichtige Regel verschenkt")
    -    print("  ganze Rollen. Passen sechs oder mehr darauf, gleichen sich die")
    -    print("  Fehler aus - die Reste sind klein gegen die Rollenbreite, und die")
    -    print("  Faustregel trifft es fast immer.")
    -
    -    print("\n" + "=" * 84)
    -    print("  WAS MAN DARAUS MITNIMMT")
    -    print("=" * 84)
    -    print("1. Der Perspektivwechsel ist die eigentliche Arbeit: nicht 'welches")
    -    print("   Stueck wohin', sondern 'wie oft welches Muster'. Damit verschwindet")
    -    print("   die Symmetrie, die das naheliegende Modell unloesbar macht.")
    -    print("2. Der Preis dafuer ist eine unaufschreibbare Zahl von Variablen. Die")
    -    print("   Spaltengenerierung zahlt ihn nicht, sondern erzeugt nur die wenigen")
    -    print("   Spalten, die die Dualwerte als lohnend ausweisen.")
    -    print("3. Das Teilproblem ist ein Rucksack - ein Verfahren, das die Leser")
    -    print("   dieses Buchs schon kennen. Neu ist allein die Schleife.")
    -    print("4. Und die unbequeme Erkenntnis: Ob sich das alles lohnt, entscheidet")
    -    print("   die Instanz, nicht die Methode. Bei sechs Stuecken je Rolle ist die")
    -    print("   Faustregel so gut wie das Optimum - und der Ertrag der")
    -    print("   Spaltengenerierung liegt dann allein in der SCHRANKE, die beweist,")
    -    print("   dass man aufhoeren kann zu suchen.")
    -    print("=" * 84)
    +
    #!/usr/bin/env python3
    +
    +# Spaltengenerierung.py
    +"""
    +Kapitel Dekomposition: Das Modell umbauen, statt die Loesung zu raten.
    +
    +Eine Papierfabrik liefert Rollen von 5.600 mm Breite. Die Kunden bestellen
    +schmalere Breiten, und aus jeder Mutterrolle werden mehrere davon geschnitten.
    +Gesucht ist der Schnittplan, der mit den wenigsten Mutterrollen auskommt.
    +
    +Der naheliegende Modellansatz - "welches Stueck kommt auf welche Rolle?" -
    +fuehrt in eine Sackgasse: Er braucht eine Binaervariable je Stueck-und-Rolle,
    +und weil alle Rollen gleich sind, ist er hochgradig symmetrisch. Der Solver
    +probiert dieselbe Loesung in tausend Umbenennungen durch.
    +
    +Die Spaltengenerierung dreht die Frage um:
    +
    +    Nicht "welches Stueck auf welche Rolle",
    +    sondern "wie oft schneide ich welches MUSTER".
    +
    +Damit verschwindet die Symmetrie - aber es entsteht ein neues Problem: Die Zahl
    +der moeglichen Muster waechst kombinatorisch. Man kann sie nicht aufschreiben.
    +Der Trick besteht darin, sie auch nicht aufzuschreiben, sondern nur die wenigen
    +zu erzeugen, die tatsaechlich gebraucht werden - und zwar mit Hilfe der
    +Schattenpreise aus dem Kapitel LP.
    +
    +Das Programm zeigt vier Dinge:
    +
    +  1. Die beiden Formulierungen desselben Problems und ihre Groesse.
    +  2. Wie schnell die Musterzahl waechst - und wie wenige davon genuegen.
    +  3. Die Schleife selbst: Master-LP, Dualwerte, Rucksack, neue Spalte.
    +  4. Wann sich der Aufwand lohnt und wann nicht. Die Antwort haengt an einer
    +     einzigen Kennzahl der Instanz, und sie erklaert nebenbei, warum eine
    +     Faustregel bei manchen Zuschnittproblemen schon optimal ist.
    +
    +Benoetigt: numpy, scipy
    +"""
    +
    +from __future__ import annotations
    +
    +import numpy as np
    +from scipy.optimize import linprog
    +
    +ROLLENBREITE = 5600
    +
    +# Zwei Instanzen mit demselben Bedarf, aber verschiedenen Breiten. Der
    +# Unterschied ist die eine Kennzahl, um die es in Teil 4 geht.
    +BEDARF = [22, 25, 12, 14, 18, 18, 20, 10, 12, 14, 16, 18, 20]
    +BREIT = [1380, 1520, 1560, 1710, 1820, 1880, 1930, 2000, 2050, 2100, 2140,
    +         2150, 2200]
    +SCHMAL = [380, 520, 560, 710, 820, 880, 930, 1000, 1050, 1100, 1140,
    +          1150, 1200]
    +
    +
    +# --- Die Musterzahl ---------------------------------------------------------
    +
    +def zaehle_muster(breiten, rollenbreite: int = ROLLENBREITE) -> int:
    +    """Wie viele zulaessige Schnittmuster gibt es ueberhaupt?
    +
    +    Nur zum Zeigen - in einem produktiven Programm wuerde man das nie
    +    ausrechnen, weil die Zahl bei realistischen Instanzen jede Vorstellung
    +    sprengt. Genau das ist der Punkt.
    +    """
    +    speicher: dict[tuple[int, int], int] = {}
    +
    +    def ab(i: int, rest: int) -> int:
    +        if i == len(breiten):
    +            return 1
    +        if (i, rest) in speicher:
    +            return speicher[(i, rest)]
    +        summe = sum(ab(i + 1, rest - anzahl * breiten[i])
    +                    for anzahl in range(rest // breiten[i] + 1))
    +        speicher[(i, rest)] = summe
    +        return summe
    +
    +    return ab(0, rollenbreite)
    +
    +
    +# --- Die beiden Bausteine der Schleife --------------------------------------
    +
    +def loese_master(muster, bedarf, ganzzahlig: bool = False):
    +    """Restringiertes Master: Wie oft schneide ich jedes bekannte Muster?
    +
    +        min  sum_p x_p           unter   sum_p a_ip * x_p >= bedarf_i
    +
    +    Als LP gerechnet liefert es zusaetzlich die DUALWERTE - und die sind der
    +    eigentliche Ertrag: Der Dualwert zu Breite i sagt, was ein zusaetzliches
    +    Stueck dieser Breite an Rollen kostet. Es ist derselbe Schattenpreis wie
    +    im Kapitel LP, nur dass er hier nicht berichtet, sondern weiterverarbeitet
    +    wird.
    +    """
    +    matrix = -np.array(muster, dtype=float).T        # >= wird zu <= mit Minus
    +    ergebnis = linprog(np.ones(len(muster)),
    +                       A_ub=matrix, b_ub=-np.array(bedarf, dtype=float),
    +                       bounds=(0, None),
    +                       integrality=(1 if ganzzahlig else 0), method="highs")
    +    if not ergebnis.success:
    +        raise SystemExit(f"Master nicht loesbar: {ergebnis.message}")
    +    dual = None if ganzzahlig else -ergebnis.ineqlin.marginals
    +    return ergebnis.fun, ergebnis.x, dual
    +
    +
    +def bestes_neues_muster(dual, breiten, rollenbreite: int = ROLLENBREITE):
    +    """Pricing: Gibt es ein Muster, das sich noch lohnt?
    +
    +    Gesucht ist das Muster mit dem groessten Gesamtwert zu den aktuellen
    +    Schattenpreisen - unter der Bedingung, dass es in die Rolle passt. Das ist
    +    ein RUCKSACKPROBLEM (Kapitel MILP, Rucksack.py): Preise sind der Nutzen,
    +    Breiten das Gewicht, die Rollenbreite die Kapazitaet. Hier mit dynamischer
    +    Programmierung geloest, weil die Breiten ganzzahlig sind.
    +
    +    Bei Gleichstand wird bewusst der KLEINSTE Index bevorzugt (>-Vergleich mit
    +    Toleranz statt >=). Sonst haengt das erzeugte Muster von der Reihenfolge
    +    der Gleitkommaoperationen ab, und das Programm liefert von Lauf zu Lauf
    +    verschiedene Ausgaben.
    +    """
    +    wert = np.zeros(rollenbreite + 1)
    +    herkunft = [-1] * (rollenbreite + 1)
    +    for platz in range(rollenbreite + 1):
    +        for i, breite in enumerate(breiten):
    +            if breite <= platz and wert[platz - breite] + dual[i] > wert[platz] + 1e-9:
    +                wert[platz] = wert[platz - breite] + dual[i]
    +                herkunft[platz] = i
    +
    +    muster = [0] * len(breiten)
    +    platz = rollenbreite
    +    while herkunft[platz] >= 0:
    +        i = herkunft[platz]
    +        muster[i] += 1
    +        platz -= breiten[i]
    +    return float(wert[rollenbreite]), muster
    +
    +
    +def spaltengenerierung(breiten, bedarf, rollenbreite: int = ROLLENBREITE):
    +    """Die Schleife. Startbasis: je ein Muster mit nur einer Breite.
    +
    +    Abbruch, wenn kein Muster mehr einen Wert ueber 1 hat: Eine zusaetzliche
    +    Rolle kostet 1, also lohnt sich ein neues Muster nur, wenn es zu den
    +    aktuellen Preisen mehr als 1 wert ist. Das ist das Kriterium der
    +    reduzierten Kosten.
    +    """
    +    anzahl = len(breiten)
    +    muster = [[0] * anzahl for _ in range(anzahl)]
    +    for i in range(anzahl):
    +        muster[i][i] = rollenbreite // breiten[i]
    +
    +    runden = 0
    +    while True:
    +        zielwert, _, dual = loese_master(muster, bedarf)
    +        wert, neu = bestes_neues_muster(dual, breiten, rollenbreite)
    +        runden += 1
    +        if wert <= 1 + 1e-6:
    +            return muster, zielwert, runden
    +        muster.append(neu)
    +
    +
    +def first_fit(breiten, bedarf, rollenbreite: int = ROLLENBREITE) -> int:
    +    """Die Faustregel: groesstes Stueck zuerst, auf die erste passende Rolle."""
    +    stuecke = sorted([b for b, menge in zip(breiten, bedarf)
    +                      for _ in range(menge)], reverse=True)
    +    rollen: list[list[int]] = []
    +    for stueck in stuecke:
    +        for rolle in rollen:
    +            if sum(rolle) + stueck <= rollenbreite:
    +                rolle.append(stueck)
    +                break
    +        else:
    +            rollen.append([stueck])
    +    return len(rollen)
    +
    +
    +def auswerten(name: str, breiten, bedarf) -> dict:
    +    muster, schranke, runden = spaltengenerierung(breiten, bedarf)
    +    ganz, _, _ = loese_master(muster, bedarf, ganzzahlig=True)
    +    return {"name": name, "muster_gesamt": zaehle_muster(breiten),
    +            "muster_erzeugt": len(muster), "runden": runden,
    +            "schranke": schranke, "ganzzahlig": int(round(ganz)),
    +            "first_fit": first_fit(breiten, bedarf),
    +            "stuecke_je_rolle": ROLLENBREITE / np.mean(breiten)}
    +
    +
    +if __name__ == "__main__":
    +    print("=" * 84)
    +    print("  SPALTENGENERIERUNG: NUR DIE MUSTER ERZEUGEN, DIE MAN BRAUCHT")
    +    print("=" * 84)
    +    print(f"Mutterrolle {ROLLENBREITE:,} mm, {len(BREIT)} bestellte Breiten, "
    +          f"{sum(BEDARF)} Zuschnitte.\n")
    +
    +    # --- 1. Zwei Formulierungen ------------------------------------------
    +    stuecke = sum(BEDARF)
    +    # Wie viele Rollen braucht man hoechstens? Die Faustregel liefert eine
    +    # brauchbare Obergrenze - mehr Rollen als das wird niemand benoetigen.
    +    rollen_obergrenze = first_fit(BREIT, BEDARF)
    +    print("1. Zwei Modelle fuer dieselbe Aufgabe\n")
    +    print(f"  {'Formulierung':<34} {'Binaervariablen':>16} {'symmetrisch?':>16}")
    +    print("  " + "-" * 70)
    +    print(f"  {'Stueck -> Rolle (naheliegend)':<34} "
    +          f"{stuecke * rollen_obergrenze:>16,} {'ja':>16}")
    +    print(f"  {'wie oft welches Muster':<34} "
    +          f"{'eine je Muster':>16} {'nein':>16}")
    +    print(f"\n  Der naheliegende Ansatz braucht eine Variable je Stueck und Rolle:")
    +    print(f"  {stuecke} Zuschnitte x {rollen_obergrenze} Rollen (Obergrenze aus der Faustregel)")
    +    print(f"  = {stuecke * rollen_obergrenze:,} Binaervariablen.")
    +    print("\n  Schlimmer als die Zahl ist die Symmetrie: Alle Mutterrollen sind")
    +    print("  gleich, also beschreibt jede Loesung dieselbe Schnittvorschrift in")
    +    print("  unzaehligen Umbenennungen. Branch-and-Bound probiert sie einzeln")
    +    print("  durch und kommt nicht voran - der Suchbaum waechst, ohne dass sich")
    +    print("  die Schranke bewegt.")
    +    print("\n  Das Mustermodell hat je Muster genau eine Variable und kennt keine")
    +    print("  einzelnen Rollen mehr. Damit ist die Symmetrie weg. Sein Problem ist")
    +    print("  ein anderes - und zwar das folgende.")
    +
    +    # --- 2. Die Musterzahl ------------------------------------------------
    +    print("\n" + "-" * 84)
    +    print("2. Warum man die Muster nicht aufschreiben kann\n")
    +    gross = auswerten("breite Zuschnitte (1.380-2.200 mm)", BREIT, BEDARF)
    +    schmal = auswerten("schmale Zuschnitte (380-1.200 mm)", SCHMAL, BEDARF)
    +
    +    print(f"  {'Instanz':<36} {'Muster':>12} {'davon erzeugt':>14} {'Anteil':>9}")
    +    print("  " + "-" * 76)
    +    for fall in (gross, schmal):
    +        anteil = fall["muster_erzeugt"] / fall["muster_gesamt"]
    +        print(f"  {fall['name']:<36} {fall['muster_gesamt']:>12,} "
    +              f"{fall['muster_erzeugt']:>14} {anteil:>8.1%}")
    +
    +    print(f"\n  Schon das Halbieren der Breiten laesst die Musterzahl von "
    +          f"{gross['muster_gesamt']:,} auf")
    +    print(f"  {schmal['muster_gesamt']:,} springen - Faktor "
    +          f"{schmal['muster_gesamt'] / gross['muster_gesamt']:.0f}. Bei einer echten "
    +          f"Papierfabrik mit")
    +    print("  vierzig Breiten und Millimeterschritten sind es mehr, als sich")
    +    print("  speichern liesse.")
    +    print(f"\n  Gebraucht werden davon {schmal['muster_erzeugt']} - "
    +          f"{schmal['muster_erzeugt'] / schmal['muster_gesamt']:.2%} der Gesamtzahl.")
    +
    +    # --- 3. Die Schleife --------------------------------------------------
    +    print("\n" + "-" * 84)
    +    print("3. Die Schleife an der breiten Instanz\n")
    +    print(f"  Startbasis: {len(BREIT)} triviale Muster (je Rolle nur eine Breite)")
    +    print(f"  Runden bis kein Muster mehr lohnt: {gross['runden']}")
    +    print(f"  Muster am Ende: {gross['muster_erzeugt']}")
    +    print(f"  LP-Schranke: {gross['schranke']:.4f} Rollen")
    +    print(f"\n  Die LP-Schranke ist eine ZUSAGE: Weniger als "
    +          f"{np.ceil(gross['schranke'] - 1e-9):.0f} Rollen sind")
    +    print("  nicht moeglich - unabhaengig davon, wie clever man weiterschneidet.")
    +    print("  Genau diese Aussage fehlt einer Heuristik (Kapitel Metaheuristiken).")
    +
    +    # --- 4. Wann es sich lohnt --------------------------------------------
    +    print("\n" + "-" * 84)
    +    print("4. Wann sich der Aufwand lohnt - und wann nicht\n")
    +    print(f"  {'Instanz':<36} {'Faustregel':>11} {'exakt':>8} {'Schranke':>10} "
    +          f"{'Ersparnis':>11}")
    +    print("  " + "-" * 80)
    +    for fall in (gross, schmal):
    +        ersparnis = (fall["first_fit"] - fall["ganzzahlig"]) / fall["first_fit"]
    +        print(f"  {fall['name']:<36} {fall['first_fit']:>11} "
    +              f"{fall['ganzzahlig']:>8} {fall['schranke']:>10.2f} "
    +              f"{ersparnis:>10.0%}")
    +
    +    print(f"\n  Bei den breiten Zuschnitten spart die Spaltengenerierung "
    +          f"{gross['first_fit'] - gross['ganzzahlig']} von")
    +    print(f"  {gross['first_fit']} Rollen. Bei den schmalen spart sie NICHTS - dort ist die")
    +    print("  Faustregel bereits optimal.")
    +    print(f"\n  Der Unterschied haengt an einer einzigen Kennzahl:\n")
    +    print(f"  {'Instanz':<36} {'Stuecke je Rolle (etwa)':>24}")
    +    print("  " + "-" * 62)
    +    for fall in (gross, schmal):
    +        print(f"  {fall['name']:<36} {fall['stuecke_je_rolle']:>24.1f}")
    +
    +    print("\n  Passen nur zwei bis drei Stuecke auf eine Rolle, entscheidet jede")
    +    print("  einzelne Zuordnung viel, und eine kurzsichtige Regel verschenkt")
    +    print("  ganze Rollen. Passen sechs oder mehr darauf, gleichen sich die")
    +    print("  Fehler aus - die Reste sind klein gegen die Rollenbreite, und die")
    +    print("  Faustregel trifft es fast immer.")
    +
    +    print("\n" + "=" * 84)
    +    print("  WAS MAN DARAUS MITNIMMT")
    +    print("=" * 84)
    +    print("1. Der Perspektivwechsel ist die eigentliche Arbeit: nicht 'welches")
    +    print("   Stueck wohin', sondern 'wie oft welches Muster'. Damit verschwindet")
    +    print("   die Symmetrie, die das naheliegende Modell unloesbar macht.")
    +    print("2. Der Preis dafuer ist eine unaufschreibbare Zahl von Variablen. Die")
    +    print("   Spaltengenerierung zahlt ihn nicht, sondern erzeugt nur die wenigen")
    +    print("   Spalten, die die Dualwerte als lohnend ausweisen.")
    +    print("3. Das Teilproblem ist ein Rucksack - ein Verfahren, das die Leser")
    +    print("   dieses Buchs schon kennen. Neu ist allein die Schleife.")
    +    print("4. Und die unbequeme Erkenntnis: Ob sich das alles lohnt, entscheidet")
    +    print("   die Instanz, nicht die Methode. Bei sechs Stuecken je Rolle ist die")
    +    print("   Faustregel so gut wie das Optimum - und der Ertrag der")
    +    print("   Spaltengenerierung liegt dann allein in der SCHRANKE, die beweist,")
    +    print("   dass man aufhoeren kann zu suchen.")
    +    print("=" * 84)

    Erwartete Ausgabe:

    ====================================================================================
       SPALTENGENERIERUNG: NUR DIE MUSTER ERZEUGEN, DIE MAN BRAUCHT
    @@ -15260,20 +15576,20 @@ Mutterrolle 5,600 mm, 13 bestellte Breiten, 219 Zuschnitte.
     
     
     

    Die beiden bewegen sich gegenläufig: Korrelation -0{,}40. Wie viel soll in jede?

    -
    import numpy as np
    -import cvxpy as cp
    -
    -sigma = np.array([0.12, 0.28])                    # Schwankung je Anlage
    -rho = -0.40                                       # Korrelation
    -S = np.array([[sigma[0]**2,            rho * sigma[0] * sigma[1]],
    -              [rho * sigma[0] * sigma[1], sigma[1]**2]])
    -
    -w = cp.Variable(2, nonneg=True)                   # Anteile, keine Leerverkäufe
    -problem = cp.Problem(cp.Minimize(cp.quad_form(w, S)), [cp.sum(w) == 1])
    -problem.solve()
    -
    -print("Anteile:", np.round(w.value, 3))
    -print(f"Risiko der Mischung: {np.sqrt(problem.value) * 100:.2f} %")
    +
    import numpy as np
    +import cvxpy as cp
    +
    +sigma = np.array([0.12, 0.28])                    # Schwankung je Anlage
    +rho = -0.40                                       # Korrelation
    +S = np.array([[sigma[0]**2,            rho * sigma[0] * sigma[1]],
    +              [rho * sigma[0] * sigma[1], sigma[1]**2]])
    +
    +w = cp.Variable(2, nonneg=True)                   # Anteile, keine Leerverkäufe
    +problem = cp.Problem(cp.Minimize(cp.quad_form(w, S)), [cp.sum(w) == 1])
    +problem.solve()
    +
    +print("Anteile:", np.round(w.value, 3))
    +print(f"Risiko der Mischung: {np.sqrt(problem.value) * 100:.2f} %")

    Ausgabe:

    Anteile: [0.767 0.233]
     Risiko der Mischung: 8.90 %
    @@ -15411,61 +15727,61 @@ Risiko der Mischung: 8.90 %

    Warum das praktisch zählt: Eine Stichproben-Kovarianzmatrix mit mehr Titeln als Beobachtungen (N > T) ist singulär — also nur semidefinit. Dann gibt es unendlich viele Portfolios mit exakt demselben minimalen Risiko, und der Solver liefert eines davon, scheinbar willkürlich. Kleine Datenänderungen führen zu völlig anderen Gewichten. Genau dieses Problem behebt die Shrinkage aus Kapitel 18.

    Alle drei Zeilen der Tabelle lassen sich an einem winzigen Zwei-Variablen-QP direkt beobachten — inklusive der Stelle, an der CVXPY ein nicht konvexes Problem verweigert, bevor überhaupt ein Solver aufgerufen wird:

    -
    #!/usr/bin/env python3
    -
    -# QP_Grundlagen.py
    -"""
    -Kapitel QP/NLP: Die drei Faelle aus der Konvexitaets-Tabelle (Abschnitt
    -'Das quadratische Programm') an
    -einem Mini-QP demonstriert: P positiv definit, P (singulaer) semidefinit,
    -P mit negativem Eigenwert.
    -"""
    -
    -import numpy as np
    -import cvxpy as cp
    -
    -
    -def loese_qp(P, q, name):
    -    n = len(q)
    -    w = cp.Variable(n)
    -    ziel = cp.Minimize(0.5 * cp.quad_form(w, P) + q @ w)
    -    bedingungen = [cp.sum(w) == 1, w >= 0]
    -    problem = cp.Problem(ziel, bedingungen)
    -
    -    print(f"\n--- {name} ---")
    -    eigenwerte = np.linalg.eigvalsh(P)
    -    print(f"Eigenwerte von P: {np.round(eigenwerte, 4)}")
    -    print(f"DCP-konvex (CVXPY-Pruefung)? {problem.is_dcp()}")
    -
    -    if not problem.is_dcp():
    -        print("-> CVXPY lehnt das Problem ab, BEVOR ueberhaupt ein Solver laeuft.")
    -        return
    -
    -    problem.solve()
    -    print(f"Status: {problem.status}")
    -    print(f"w* = {np.round(w.value, 4)}")
    -    print(f"Zielwert = {problem.value:.6f}")
    -
    -
    -if __name__ == "__main__":
    -    q = np.zeros(2)
    -
    -    # Fall 1: P positiv definit -> eindeutiges Minimum
    -    P_definit = np.array([[2.0, 0.5], [0.5, 1.0]])
    -    loese_qp(P_definit, q, "P positiv definit")
    -
    -    # Fall 2: P singulaer/semidefinit (zwei "identische" Assets) -> unendlich viele Minima
    -    P_semidefinit = np.array([[1.0, 1.0], [1.0, 1.0]])
    -    loese_qp(P_semidefinit, q, "P positiv semidefinit (singulaer)")
    -
    -    # Fall 3: P mit negativem Eigenwert -> nicht konvex
    -    P_indefinit = np.array([[1.0, 2.0], [2.0, 1.0]])
    -    loese_qp(P_indefinit, q, "P indefinit (negativer Eigenwert)")
    -
    -    print("\n--- Nachweis: 'unendlich viele Minima' im semidefiniten Fall ---")
    -    for punkt in [np.array([1.0, 0.0]), np.array([0.0, 1.0]), np.array([0.3, 0.7])]:
    -        wert = 0.5 * punkt @ P_semidefinit @ punkt
    -        print(f"  w = {punkt} -> Zielwert = {wert:.4f}  (identisch, obwohl w verschieden)")
    +
    #!/usr/bin/env python3
    +
    +# QP_Grundlagen.py
    +"""
    +Kapitel QP/NLP: Die drei Faelle aus der Konvexitaets-Tabelle (Abschnitt
    +'Das quadratische Programm') an
    +einem Mini-QP demonstriert: P positiv definit, P (singulaer) semidefinit,
    +P mit negativem Eigenwert.
    +"""
    +
    +import numpy as np
    +import cvxpy as cp
    +
    +
    +def loese_qp(P, q, name):
    +    n = len(q)
    +    w = cp.Variable(n)
    +    ziel = cp.Minimize(0.5 * cp.quad_form(w, P) + q @ w)
    +    bedingungen = [cp.sum(w) == 1, w >= 0]
    +    problem = cp.Problem(ziel, bedingungen)
    +
    +    print(f"\n--- {name} ---")
    +    eigenwerte = np.linalg.eigvalsh(P)
    +    print(f"Eigenwerte von P: {np.round(eigenwerte, 4)}")
    +    print(f"DCP-konvex (CVXPY-Pruefung)? {problem.is_dcp()}")
    +
    +    if not problem.is_dcp():
    +        print("-> CVXPY lehnt das Problem ab, BEVOR ueberhaupt ein Solver laeuft.")
    +        return
    +
    +    problem.solve()
    +    print(f"Status: {problem.status}")
    +    print(f"w* = {np.round(w.value, 4)}")
    +    print(f"Zielwert = {problem.value:.6f}")
    +
    +
    +if __name__ == "__main__":
    +    q = np.zeros(2)
    +
    +    # Fall 1: P positiv definit -> eindeutiges Minimum
    +    P_definit = np.array([[2.0, 0.5], [0.5, 1.0]])
    +    loese_qp(P_definit, q, "P positiv definit")
    +
    +    # Fall 2: P singulaer/semidefinit (zwei "identische" Assets) -> unendlich viele Minima
    +    P_semidefinit = np.array([[1.0, 1.0], [1.0, 1.0]])
    +    loese_qp(P_semidefinit, q, "P positiv semidefinit (singulaer)")
    +
    +    # Fall 3: P mit negativem Eigenwert -> nicht konvex
    +    P_indefinit = np.array([[1.0, 2.0], [2.0, 1.0]])
    +    loese_qp(P_indefinit, q, "P indefinit (negativer Eigenwert)")
    +
    +    print("\n--- Nachweis: 'unendlich viele Minima' im semidefiniten Fall ---")
    +    for punkt in [np.array([1.0, 0.0]), np.array([0.0, 1.0]), np.array([0.3, 0.7])]:
    +        wert = 0.5 * punkt @ P_semidefinit @ punkt
    +        print(f"  w = {punkt} -> Zielwert = {wert:.4f}  (identisch, obwohl w verschieden)")

    Erwartete Ausgabe:

    --- P positiv definit ---
     Eigenwerte von P: [0.7929 2.2071]
    @@ -15493,9 +15809,9 @@ DCP-konvex (CVXPY-Pruefung)? False
     

    Der letzte Block ist der eigentliche Beweis: Drei völlig verschiedene Portfolios (1,0), (0,1) und (0{,}3;\,0{,}7) liefern exakt denselben Zielwert — die „Rinne” aus der Tabelle ist keine Metapher, sie ist hier eine echte Gerade im Lösungsraum.

    ⚠️ Prüfen Sie jede Matrix, bevor Sie sie verwenden

    -
    eigenwerte = np.linalg.eigvalsh(Sigma)     # 'h' = fuer symmetrische Matrizen
    -assert np.all(eigenwerte >= -1e-10), "Sigma ist nicht positiv semidefinit!"
    -print(f"Konditionszahl: {eigenwerte.max() / eigenwerte.min():.1f}")
    +
    eigenwerte = np.linalg.eigvalsh(Sigma)     # 'h' = fuer symmetrische Matrizen
    +assert np.all(eigenwerte >= -1e-10), "Sigma ist nicht positiv semidefinit!"
    +print(f"Konditionszahl: {eigenwerte.max() / eigenwerte.min():.1f}")

    Eine einzige Zeile — und sie deckt eine ungültige Kovarianzmatrix sofort auf.


    @@ -15581,116 +15897,116 @@ DCP-konvex (CVXPY-Pruefung)? False

    Lösung: x^* = 3, \lambda^* = 4, f(x^*) = 4.

    Und was bedeutet \lambda^* = 4? Es ist der Schattenpreis: Dürfte x bis 3,1 gehen, sänke f um etwa 4 \times 0{,}1 = 0{,}4. Probe: f(3{,}1) = (3{,}1-5)^2 = 3{,}61, tatsächlicher Rückgang 0{,}39 ✓ — die kleine Abweichung ist die Krümmung.

    -
    #!/usr/bin/env python3
    -
    -# KKT_Nachweis.py
    -"""
    -Kapitel QP/NLP: Die KKT-Bedingungen numerisch nachpruefen.
    -
    -Loest ein QP mit CVXPY, liest die Dualwerte aus und prueft alle vier
    -KKT-Bedingungen einzeln nach. Das ist zugleich eine Vorlage fuer die
    -Qualitaetssicherung eigener Modelle.
    -"""
    -
    -import numpy as np
    -import cvxpy as cp
    -
    -
    -def baue_gueltige_kovarianz(vola, korrelationen, seed=0):
    -    """
    -    Baut Sigma = D * C * D aus Volatilitaeten und einer Korrelationsmatrix.
    -    Dieses Vorgehen ist konstruktionsbedingt positiv semidefinit - im
    -    Gegensatz zum nachtraeglichen Ueberschreiben der Diagonalen, das die
    -    positive Semidefinitheit zerstoeren kann.
    -    """
    -    C = np.array(korrelationen, dtype=float)
    -    assert np.allclose(C, C.T), "Korrelationsmatrix muss symmetrisch sein."
    -    assert np.allclose(np.diag(C), 1.0), "Diagonale der Korrelationsmatrix muss 1 sein."
    -    eigen = np.linalg.eigvalsh(C)
    -    assert eigen.min() > -1e-10, (
    -        f"Korrelationsmatrix ist nicht positiv semidefinit "
    -        f"(kleinster Eigenwert {eigen.min():.4f}). Solche Korrelationen sind unmoeglich.")
    -    D = np.diag(vola)
    -    return D @ C @ D
    -
    -
    -if __name__ == "__main__":
    -    # --- Ein kleines Portfolio-QP ----------------------------------------
    -    vola = np.array([0.20, 0.14, 0.30])                 # Volatilitaeten
    -    korr = [[1.00, 0.30, 0.10],
    -            [0.30, 1.00, 0.25],
    -            [0.10, 0.25, 1.00]]
    -    Sigma = baue_gueltige_kovarianz(vola, korr)
    -    mu = np.array([0.09, 0.05, 0.13])                   # erwartete Renditen
    -    lam = 3.0                                           # Risikoaversion
    -
    -    eigenwerte = np.linalg.eigvalsh(Sigma)
    -    print("=" * 74)
    -    print("  KKT-BEDINGUNGEN AM PORTFOLIO-QP")
    -    print("=" * 74)
    -    print(f"Eigenwerte von Sigma: {np.round(eigenwerte, 6)}")
    -    print(f"  -> positiv definit: {bool(eigenwerte.min() > 0)}  "
    -          f"(Problem ist streng konvex, Loesung eindeutig)")
    -    print(f"  -> Konditionszahl:  {eigenwerte.max() / eigenwerte.min():.2f}")
    -
    -    # --- Modell: min  lam/2 * w'Sigma w - mu'w   u.d.N. sum(w)=1, w>=0 ----
    -    n = len(mu)
    -    w = cp.Variable(n)
    -    ziel = cp.Minimize(0.5 * lam * cp.quad_form(w, Sigma) - mu @ w)
    -    budget = cp.sum(w) == 1
    -    nichtnegativ = w >= 0
    -    problem = cp.Problem(ziel, [budget, nichtnegativ])
    -    problem.solve()
    -
    -    w_opt = w.value
    -    nu = budget.dual_value                    # Multiplikator der Gleichung
    -    lam_i = nichtnegativ.dual_value           # Multiplikatoren der Ungleichungen
    -
    -    print(f"\nStatus: {problem.status}")
    -    print(f"Optimale Gewichte: {np.round(w_opt, 6)}")
    -    print(f"Zielwert:          {problem.value:.6f}")
    -    print(f"Multiplikator der Budgetgleichung (nu): {nu:.6f}")
    -    print(f"Multiplikatoren der w>=0-Bedingungen:   {np.round(lam_i, 6)}")
    -
    -    # --- KKT-Bedingungen einzeln pruefen ---------------------------------
    -    print("\n--- Pruefung der vier KKT-Bedingungen ---")
    -
    -    # 1. Stationaritaet:  grad f - lambda + nu*1 = 0
    -    #    f(w) = lam/2 w'Sigma w - mu'w   ->   grad f = lam*Sigma w - mu
    -    #    g_i(w) = -w_i <= 0              ->   grad g_i = -e_i
    -    #    h(w)   = sum(w) - 1 = 0         ->   grad h   = 1
    -    grad_f = lam * (Sigma @ w_opt) - mu
    -    stationaritaet = grad_f - lam_i + nu * np.ones(n)
    -    print(f"1. Stationaritaet   : max|Residuum| = {np.abs(stationaritaet).max():.2e}")
    -
    -    # 2. Primale Zulaessigkeit
    -    print(f"2. Primal zulaessig : sum(w)-1 = {w_opt.sum()-1:.2e}, "
    -          f"min(w) = {w_opt.min():.2e}")
    -
    -    # 3. Duale Zulaessigkeit
    -    print(f"3. Dual zulaessig   : min(lambda) = {lam_i.min():.2e}  (muss >= 0 sein)")
    -
    -    # 4. Komplementaerer Schlupf: lambda_i * w_i = 0
    -    print(f"4. Kompl. Schlupf   : max|lambda_i * w_i| = "
    -          f"{np.abs(lam_i * w_opt).max():.2e}")
    -
    -    alle_ok = (np.abs(stationaritaet).max() < 1e-6
    -               and abs(w_opt.sum() - 1) < 1e-8
    -               and w_opt.min() > -1e-8
    -               and lam_i.min() > -1e-8
    -               and np.abs(lam_i * w_opt).max() < 1e-6)
    -    print(f"\nAlle vier KKT-Bedingungen erfuellt: {alle_ok}")
    -
    -    # --- Interpretation von nu -------------------------------------------
    -    print("\n--- Was bedeutet nu? ---")
    -    print("nu ist der Schattenpreis des Budgets: Um so viel aendert sich der")
    -    print("Zielwert, wenn man statt 100 % nur 99 % investieren duerfte.")
    -    problem2 = cp.Problem(cp.Minimize(0.5 * lam * cp.quad_form(w, Sigma) - mu @ w),
    -                          [cp.sum(w) == 1.01, w >= 0])
    -    problem2.solve()
    -    print(f"  Vorhergesagt (nu * 0.01): {nu * 0.01:+.6f}")
    -    print(f"  Tatsaechlich gemessen:    {problem2.value - problem.value:+.6f}")
    -    print("=" * 74)
    +
    #!/usr/bin/env python3
    +
    +# KKT_Nachweis.py
    +"""
    +Kapitel QP/NLP: Die KKT-Bedingungen numerisch nachpruefen.
    +
    +Loest ein QP mit CVXPY, liest die Dualwerte aus und prueft alle vier
    +KKT-Bedingungen einzeln nach. Das ist zugleich eine Vorlage fuer die
    +Qualitaetssicherung eigener Modelle.
    +"""
    +
    +import numpy as np
    +import cvxpy as cp
    +
    +
    +def baue_gueltige_kovarianz(vola, korrelationen, seed=0):
    +    """
    +    Baut Sigma = D * C * D aus Volatilitaeten und einer Korrelationsmatrix.
    +    Dieses Vorgehen ist konstruktionsbedingt positiv semidefinit - im
    +    Gegensatz zum nachtraeglichen Ueberschreiben der Diagonalen, das die
    +    positive Semidefinitheit zerstoeren kann.
    +    """
    +    C = np.array(korrelationen, dtype=float)
    +    assert np.allclose(C, C.T), "Korrelationsmatrix muss symmetrisch sein."
    +    assert np.allclose(np.diag(C), 1.0), "Diagonale der Korrelationsmatrix muss 1 sein."
    +    eigen = np.linalg.eigvalsh(C)
    +    assert eigen.min() > -1e-10, (
    +        f"Korrelationsmatrix ist nicht positiv semidefinit "
    +        f"(kleinster Eigenwert {eigen.min():.4f}). Solche Korrelationen sind unmoeglich.")
    +    D = np.diag(vola)
    +    return D @ C @ D
    +
    +
    +if __name__ == "__main__":
    +    # --- Ein kleines Portfolio-QP ----------------------------------------
    +    vola = np.array([0.20, 0.14, 0.30])                 # Volatilitaeten
    +    korr = [[1.00, 0.30, 0.10],
    +            [0.30, 1.00, 0.25],
    +            [0.10, 0.25, 1.00]]
    +    Sigma = baue_gueltige_kovarianz(vola, korr)
    +    mu = np.array([0.09, 0.05, 0.13])                   # erwartete Renditen
    +    lam = 3.0                                           # Risikoaversion
    +
    +    eigenwerte = np.linalg.eigvalsh(Sigma)
    +    print("=" * 74)
    +    print("  KKT-BEDINGUNGEN AM PORTFOLIO-QP")
    +    print("=" * 74)
    +    print(f"Eigenwerte von Sigma: {np.round(eigenwerte, 6)}")
    +    print(f"  -> positiv definit: {bool(eigenwerte.min() > 0)}  "
    +          f"(Problem ist streng konvex, Loesung eindeutig)")
    +    print(f"  -> Konditionszahl:  {eigenwerte.max() / eigenwerte.min():.2f}")
    +
    +    # --- Modell: min  lam/2 * w'Sigma w - mu'w   u.d.N. sum(w)=1, w>=0 ----
    +    n = len(mu)
    +    w = cp.Variable(n)
    +    ziel = cp.Minimize(0.5 * lam * cp.quad_form(w, Sigma) - mu @ w)
    +    budget = cp.sum(w) == 1
    +    nichtnegativ = w >= 0
    +    problem = cp.Problem(ziel, [budget, nichtnegativ])
    +    problem.solve()
    +
    +    w_opt = w.value
    +    nu = budget.dual_value                    # Multiplikator der Gleichung
    +    lam_i = nichtnegativ.dual_value           # Multiplikatoren der Ungleichungen
    +
    +    print(f"\nStatus: {problem.status}")
    +    print(f"Optimale Gewichte: {np.round(w_opt, 6)}")
    +    print(f"Zielwert:          {problem.value:.6f}")
    +    print(f"Multiplikator der Budgetgleichung (nu): {nu:.6f}")
    +    print(f"Multiplikatoren der w>=0-Bedingungen:   {np.round(lam_i, 6)}")
    +
    +    # --- KKT-Bedingungen einzeln pruefen ---------------------------------
    +    print("\n--- Pruefung der vier KKT-Bedingungen ---")
    +
    +    # 1. Stationaritaet:  grad f - lambda + nu*1 = 0
    +    #    f(w) = lam/2 w'Sigma w - mu'w   ->   grad f = lam*Sigma w - mu
    +    #    g_i(w) = -w_i <= 0              ->   grad g_i = -e_i
    +    #    h(w)   = sum(w) - 1 = 0         ->   grad h   = 1
    +    grad_f = lam * (Sigma @ w_opt) - mu
    +    stationaritaet = grad_f - lam_i + nu * np.ones(n)
    +    print(f"1. Stationaritaet   : max|Residuum| = {np.abs(stationaritaet).max():.2e}")
    +
    +    # 2. Primale Zulaessigkeit
    +    print(f"2. Primal zulaessig : sum(w)-1 = {w_opt.sum()-1:.2e}, "
    +          f"min(w) = {w_opt.min():.2e}")
    +
    +    # 3. Duale Zulaessigkeit
    +    print(f"3. Dual zulaessig   : min(lambda) = {lam_i.min():.2e}  (muss >= 0 sein)")
    +
    +    # 4. Komplementaerer Schlupf: lambda_i * w_i = 0
    +    print(f"4. Kompl. Schlupf   : max|lambda_i * w_i| = "
    +          f"{np.abs(lam_i * w_opt).max():.2e}")
    +
    +    alle_ok = (np.abs(stationaritaet).max() < 1e-6
    +               and abs(w_opt.sum() - 1) < 1e-8
    +               and w_opt.min() > -1e-8
    +               and lam_i.min() > -1e-8
    +               and np.abs(lam_i * w_opt).max() < 1e-6)
    +    print(f"\nAlle vier KKT-Bedingungen erfuellt: {alle_ok}")
    +
    +    # --- Interpretation von nu -------------------------------------------
    +    print("\n--- Was bedeutet nu? ---")
    +    print("nu ist der Schattenpreis des Budgets: Um so viel aendert sich der")
    +    print("Zielwert, wenn man statt 100 % nur 99 % investieren duerfte.")
    +    problem2 = cp.Problem(cp.Minimize(0.5 * lam * cp.quad_form(w, Sigma) - mu @ w),
    +                          [cp.sum(w) == 1.01, w >= 0])
    +    problem2.solve()
    +    print(f"  Vorhergesagt (nu * 0.01): {nu * 0.01:+.6f}")
    +    print(f"  Tatsaechlich gemessen:    {problem2.value - problem.value:+.6f}")
    +    print("=" * 74)

    11.5 Nichtlineare Optimierung mit scipy.optimize.minimize

    Wenn Zielfunktion oder Nebenbedingungen weder linear noch quadratisch sind — Logarithmen, Wurzeln, Exponentialfunktionen — nutzt man gradientenbasierte Verfahren wie SLSQP (Sequential Least Squares Programming).

    @@ -15708,144 +16024,144 @@ DCP-konvex (CVXPY-Pruefung)? False

    📐 Formel-Lesehilfe zur Entropie H(\mathbf{w}) = -\sum_i w_i \ln w_i ist ein Streuungsmaß: * Alles in einem Titel (w = (1,0,0,0)): H = 0 — minimale Streuung. * Gleichverteilt (w_i = 1/4): H = \ln 4 \approx 1{,}386 — maximale Streuung.

    Ohne Formel gesagt: Die Entropie misst, wie „breit verteilt“ das Portfolio ist. Weil sie mit einem Minuszeichen in die zu minimierende Zielfunktion eingeht, wird breite Streuung belohnt — und zwar mathematisch glatt, ohne harte Obergrenzen. Der Vorteil gegenüber einer Schranke wie w_i \le 0{,}2: Der Übergang ist weich, das Ergebnis reagiert nicht sprunghaft auf kleine Datenänderungen.

    -
    #!/usr/bin/env python3
    -
    -# Entropie_Maximierte_Allokation.py
    -"""
    -Kapitel QP/NLP: Nichtlineare Optimierung (NLP) mit scipy SLSQP.
    -Modell: Mean-Variance-Portfolio mit Shannon-Entropie-Diversifikation.
    -
    -Achtung, haeufiger Fehler: Sigma per A@A.T zu erzeugen und anschliessend die
    -Diagonale zu ueberschreiben zerstoert die positive Semidefinitheit - die
    -Matrix kann dadurch einen negativen Eigenwert bekommen und unmoegliche
    -Korrelationen (> 1) implizieren. Dieses Programm baut Sigma stattdessen als
    -D * C * D aus Volatilitaeten und einer echten Korrelationsmatrix -
    -konstruktionsbedingt immer PSD.
    -
    -Zusaetzlich: Multistart, weil SLSQP nur lokale Optima findet.
    -"""
    -
    -import numpy as np
    -import pandas as pd
    -from scipy.optimize import minimize
    -
    -ASSETS = ["Tech-Aktien", "Rohstoffe", "US-Treasuries", "Krypto"]
    -MU = np.array([0.14, 0.07, 0.03, 0.22])          # erwartete Jahresrenditen
    -VOLA = np.array([0.20, 0.14, 0.07, 0.40])        # Volatilitaeten p.a.
    -KORRELATION = np.array([
    -    [1.00, 0.25, -0.10, 0.55],
    -    [0.25, 1.00, 0.05, 0.20],
    -    [-0.10, 0.05, 1.00, -0.15],
    -    [0.55, 0.20, -0.15, 1.00],
    -])
    -
    -ALPHA = 1.0        # Gewicht des Ertrags
    -BETA = 0.015       # Gewicht der Entropie-Diversifikation
    -UNTERGRENZE = 0.001
    -OBERGRENZE = 0.80
    -
    -
    -def baue_kovarianz() -> np.ndarray:
    -    """Sigma = D * C * D. Immer PSD, wenn C eine gueltige Korrelationsmatrix ist."""
    -    eig_c = np.linalg.eigvalsh(KORRELATION)
    -    if eig_c.min() < -1e-10:
    -        raise ValueError(f"Korrelationsmatrix unmoeglich (Eigenwert {eig_c.min():.4f}).")
    -    D = np.diag(VOLA)
    -    Sigma = D @ KORRELATION @ D
    -    eig_s = np.linalg.eigvalsh(Sigma)
    -    assert eig_s.min() > 0, "Sigma nicht positiv definit!"
    -    return Sigma
    -
    -
    -SIGMA = baue_kovarianz()
    -
    -
    -def zielfunktion(w, alpha=ALPHA, beta=BETA):
    -    """Risiko - Ertrag - Entropiepraemie (wird minimiert)."""
    -    varianz = 0.5 * w @ SIGMA @ w
    -    ertrag = MU @ w
    -    entropie = -np.sum(w * np.log(w + 1e-12))
    -    return varianz - alpha * ertrag - beta * entropie
    -
    -
    -def gradient(w, alpha=ALPHA, beta=BETA):
    -    """Exakter analytischer Gradient - beschleunigt und stabilisiert SLSQP."""
    -    grad_varianz = SIGMA @ w                       # d/dw von 0.5 w'Sigma w
    -    grad_ertrag = -alpha * MU
    -    grad_entropie = beta * (np.log(w + 1e-12) + 1.0)
    -    return grad_varianz + grad_ertrag + grad_entropie
    -
    -
    -def optimiere(startpunkt, alpha=ALPHA, beta=BETA):
    -    """alpha und beta werden durchgereicht - so bleibt die Funktion seiteneffektfrei."""
    -    return minimize(
    -        zielfunktion, startpunkt, args=(alpha, beta), jac=gradient,
    -        method="SLSQP", bounds=[(UNTERGRENZE, OBERGRENZE)] * len(MU),
    -        constraints=({"type": "eq",
    -                      "fun": lambda w: np.sum(w) - 1.0,
    -                      "jac": lambda w: np.ones(len(MU))}),
    -        options={"ftol": 1e-12, "maxiter": 300})
    -
    -
    -if __name__ == "__main__":
    -    n = len(MU)
    -    eig = np.linalg.eigvalsh(SIGMA)
    -
    -    print("=" * 74)
    -    print("   NICHTLINEARE ENTROPIE-OPTIMIERTE ASSET-ALLOKATION")
    -    print("=" * 74)
    -    print("Pruefung der Kovarianzmatrix:")
    -    print(f"  Eigenwerte:          {np.round(eig, 6)}")
    -    print(f"  Positiv definit:     {bool(eig.min() > 0)}")
    -    print(f"  Groesste Korrelation ausserhalb der Diagonale: "
    -          f"{np.abs(KORRELATION - np.eye(n)).max():.2f}  (muss <= 1 sein)")
    -
    -    # --- Multistart: SLSQP findet nur lokale Optima -----------------------
    -    rng = np.random.default_rng(7)
    -    startpunkte = [np.ones(n) / n]                                  # Gleichgewichtung
    -    for _ in range(9):
    -        z = rng.random(n) + 0.05
    -        startpunkte.append(z / z.sum())
    -
    -    ergebnisse = [optimiere(s) for s in startpunkte]
    -    erfolgreich = [r for r in ergebnisse if r.success]
    -    bestes = min(erfolgreich, key=lambda r: r.fun)
    -    zielwerte = np.array([r.fun for r in erfolgreich])
    -
    -    print(f"\nMultistart mit {len(startpunkte)} Startpunkten:")
    -    print(f"  Erfolgreich konvergiert: {len(erfolgreich)}")
    -    print(f"  Spannweite der Zielwerte: {zielwerte.max() - zielwerte.min():.2e}")
    -    print("  -> " + ("alle Startpunkte fuehren zum selben Optimum (Indiz fuer Konvexitaet)"
    -                     if zielwerte.max() - zielwerte.min() < 1e-6
    -                     else "ACHTUNG: verschiedene lokale Optima gefunden!"))
    -
    -    w_opt = bestes.x
    -    rendite = MU @ w_opt
    -    volatilitaet = np.sqrt(w_opt @ SIGMA @ w_opt)
    -    entropie = -np.sum(w_opt * np.log(w_opt))
    -
    -    print(f"\nKonvergenz:                {bestes.message}")
    -    print(f"Iterationen:               {bestes.nit}")
    -    print(f"Erwartete Jahresrendite:   {rendite * 100:6.2f} %")
    -    print(f"Erwartete Volatilitaet:    {volatilitaet * 100:6.2f} %")
    -    print(f"Diversifikations-Entropie: {entropie:.4f} "
    -          f"(Maximum bei Gleichgewichtung: {np.log(n):.4f})")
    -
    -    print("\n" + pd.DataFrame({
    -        "Asset": ASSETS,
    -        "Erw. Rendite": [f"{r*100:.1f} %" for r in MU],
    -        "Volatilitaet": [f"{v*100:.1f} %" for v in VOLA],
    -        "Gewicht": [f"{w*100:6.2f} %" for w in w_opt],
    -    }).to_string(index=False))
    -
    -    # --- Vergleich: was passiert ohne Entropieterm? ----------------------
    -    ohne = optimiere(np.ones(n) / n, beta=0.0)
    -    print("\n--- Wirkung des Entropieterms ---")
    -    print(f"  Mit Entropie  (beta={BETA}): Gewichte {np.round(w_opt * 100, 1)}")
    -    print(f"  Ohne Entropie (beta=0):     Gewichte {np.round(ohne.x * 100, 1)}")
    -    print("  Der Entropieterm zieht Kapital aus der Spitzenposition heraus,")
    -    print("  ohne dass eine harte Obergrenze noetig waere.")
    -    print("=" * 74)
    +
    #!/usr/bin/env python3
    +
    +# Entropie_Maximierte_Allokation.py
    +"""
    +Kapitel QP/NLP: Nichtlineare Optimierung (NLP) mit scipy SLSQP.
    +Modell: Mean-Variance-Portfolio mit Shannon-Entropie-Diversifikation.
    +
    +Achtung, haeufiger Fehler: Sigma per A@A.T zu erzeugen und anschliessend die
    +Diagonale zu ueberschreiben zerstoert die positive Semidefinitheit - die
    +Matrix kann dadurch einen negativen Eigenwert bekommen und unmoegliche
    +Korrelationen (> 1) implizieren. Dieses Programm baut Sigma stattdessen als
    +D * C * D aus Volatilitaeten und einer echten Korrelationsmatrix -
    +konstruktionsbedingt immer PSD.
    +
    +Zusaetzlich: Multistart, weil SLSQP nur lokale Optima findet.
    +"""
    +
    +import numpy as np
    +import pandas as pd
    +from scipy.optimize import minimize
    +
    +ASSETS = ["Tech-Aktien", "Rohstoffe", "US-Treasuries", "Krypto"]
    +MU = np.array([0.14, 0.07, 0.03, 0.22])          # erwartete Jahresrenditen
    +VOLA = np.array([0.20, 0.14, 0.07, 0.40])        # Volatilitaeten p.a.
    +KORRELATION = np.array([
    +    [1.00, 0.25, -0.10, 0.55],
    +    [0.25, 1.00, 0.05, 0.20],
    +    [-0.10, 0.05, 1.00, -0.15],
    +    [0.55, 0.20, -0.15, 1.00],
    +])
    +
    +ALPHA = 1.0        # Gewicht des Ertrags
    +BETA = 0.015       # Gewicht der Entropie-Diversifikation
    +UNTERGRENZE = 0.001
    +OBERGRENZE = 0.80
    +
    +
    +def baue_kovarianz() -> np.ndarray:
    +    """Sigma = D * C * D. Immer PSD, wenn C eine gueltige Korrelationsmatrix ist."""
    +    eig_c = np.linalg.eigvalsh(KORRELATION)
    +    if eig_c.min() < -1e-10:
    +        raise ValueError(f"Korrelationsmatrix unmoeglich (Eigenwert {eig_c.min():.4f}).")
    +    D = np.diag(VOLA)
    +    Sigma = D @ KORRELATION @ D
    +    eig_s = np.linalg.eigvalsh(Sigma)
    +    assert eig_s.min() > 0, "Sigma nicht positiv definit!"
    +    return Sigma
    +
    +
    +SIGMA = baue_kovarianz()
    +
    +
    +def zielfunktion(w, alpha=ALPHA, beta=BETA):
    +    """Risiko - Ertrag - Entropiepraemie (wird minimiert)."""
    +    varianz = 0.5 * w @ SIGMA @ w
    +    ertrag = MU @ w
    +    entropie = -np.sum(w * np.log(w + 1e-12))
    +    return varianz - alpha * ertrag - beta * entropie
    +
    +
    +def gradient(w, alpha=ALPHA, beta=BETA):
    +    """Exakter analytischer Gradient - beschleunigt und stabilisiert SLSQP."""
    +    grad_varianz = SIGMA @ w                       # d/dw von 0.5 w'Sigma w
    +    grad_ertrag = -alpha * MU
    +    grad_entropie = beta * (np.log(w + 1e-12) + 1.0)
    +    return grad_varianz + grad_ertrag + grad_entropie
    +
    +
    +def optimiere(startpunkt, alpha=ALPHA, beta=BETA):
    +    """alpha und beta werden durchgereicht - so bleibt die Funktion seiteneffektfrei."""
    +    return minimize(
    +        zielfunktion, startpunkt, args=(alpha, beta), jac=gradient,
    +        method="SLSQP", bounds=[(UNTERGRENZE, OBERGRENZE)] * len(MU),
    +        constraints=({"type": "eq",
    +                      "fun": lambda w: np.sum(w) - 1.0,
    +                      "jac": lambda w: np.ones(len(MU))}),
    +        options={"ftol": 1e-12, "maxiter": 300})
    +
    +
    +if __name__ == "__main__":
    +    n = len(MU)
    +    eig = np.linalg.eigvalsh(SIGMA)
    +
    +    print("=" * 74)
    +    print("   NICHTLINEARE ENTROPIE-OPTIMIERTE ASSET-ALLOKATION")
    +    print("=" * 74)
    +    print("Pruefung der Kovarianzmatrix:")
    +    print(f"  Eigenwerte:          {np.round(eig, 6)}")
    +    print(f"  Positiv definit:     {bool(eig.min() > 0)}")
    +    print(f"  Groesste Korrelation ausserhalb der Diagonale: "
    +          f"{np.abs(KORRELATION - np.eye(n)).max():.2f}  (muss <= 1 sein)")
    +
    +    # --- Multistart: SLSQP findet nur lokale Optima -----------------------
    +    rng = np.random.default_rng(7)
    +    startpunkte = [np.ones(n) / n]                                  # Gleichgewichtung
    +    for _ in range(9):
    +        z = rng.random(n) + 0.05
    +        startpunkte.append(z / z.sum())
    +
    +    ergebnisse = [optimiere(s) for s in startpunkte]
    +    erfolgreich = [r for r in ergebnisse if r.success]
    +    bestes = min(erfolgreich, key=lambda r: r.fun)
    +    zielwerte = np.array([r.fun for r in erfolgreich])
    +
    +    print(f"\nMultistart mit {len(startpunkte)} Startpunkten:")
    +    print(f"  Erfolgreich konvergiert: {len(erfolgreich)}")
    +    print(f"  Spannweite der Zielwerte: {zielwerte.max() - zielwerte.min():.2e}")
    +    print("  -> " + ("alle Startpunkte fuehren zum selben Optimum (Indiz fuer Konvexitaet)"
    +                     if zielwerte.max() - zielwerte.min() < 1e-6
    +                     else "ACHTUNG: verschiedene lokale Optima gefunden!"))
    +
    +    w_opt = bestes.x
    +    rendite = MU @ w_opt
    +    volatilitaet = np.sqrt(w_opt @ SIGMA @ w_opt)
    +    entropie = -np.sum(w_opt * np.log(w_opt))
    +
    +    print(f"\nKonvergenz:                {bestes.message}")
    +    print(f"Iterationen:               {bestes.nit}")
    +    print(f"Erwartete Jahresrendite:   {rendite * 100:6.2f} %")
    +    print(f"Erwartete Volatilitaet:    {volatilitaet * 100:6.2f} %")
    +    print(f"Diversifikations-Entropie: {entropie:.4f} "
    +          f"(Maximum bei Gleichgewichtung: {np.log(n):.4f})")
    +
    +    print("\n" + pd.DataFrame({
    +        "Asset": ASSETS,
    +        "Erw. Rendite": [f"{r*100:.1f} %" for r in MU],
    +        "Volatilitaet": [f"{v*100:.1f} %" for v in VOLA],
    +        "Gewicht": [f"{w*100:6.2f} %" for w in w_opt],
    +    }).to_string(index=False))
    +
    +    # --- Vergleich: was passiert ohne Entropieterm? ----------------------
    +    ohne = optimiere(np.ones(n) / n, beta=0.0)
    +    print("\n--- Wirkung des Entropieterms ---")
    +    print(f"  Mit Entropie  (beta={BETA}): Gewichte {np.round(w_opt * 100, 1)}")
    +    print(f"  Ohne Entropie (beta=0):     Gewichte {np.round(ohne.x * 100, 1)}")
    +    print("  Der Entropieterm zieht Kapital aus der Spitzenposition heraus,")
    +    print("  ohne dass eine harte Obergrenze noetig waere.")
    +    print("=" * 74)

    Erwartete Ausgabe:

    ==========================================================================
        NICHTLINEARE ENTROPIE-OPTIMIERTE ASSET-ALLOKATION
    @@ -15945,174 +16261,174 @@ US-Treasuries        3.0 %        7.0 %   0.45 %
     

    „Ich bin an einer Stelle angekommen, an der es in keine Richtung mehr bergab geht.“

    Bei einem konvexen Problem ist das dasselbe. Bei einem nicht-konvexen sind es zwei völlig verschiedene Aussagen. Das folgende Programm macht den Unterschied sichtbar.

    -
    #!/usr/bin/env python3
    -
    -# Lokale_Optima_Multistart.py
    -"""
    -Kapitel QP/NLP: Was passiert, wenn die Konvexitaet fehlt.
    -
    -CVXPY verweigert nicht-konvexe Probleme - das ist sein Schutzmechanismus.
    -scipy.optimize.minimize verweigert nichts. Es rechnet, meldet 'success: True'
    -und liefert ein Ergebnis. Nur ist das Ergebnis dann kein Optimum, sondern
    -irgendein lokales Minimum, das vom Startpunkt abhaengt.
    -
    -Beispiel aus dem Einkauf: 700 Tonnen Rohstoff werden auf vier Lieferanten
    -verteilt. Jeder gewaehrt einen MENGENRABATT - der Stueckpreis faellt, je mehr
    -man bei ihm bestellt:
    -
    -    preis_i(q) = basis_i * (1 - rabatt_i * (1 - exp(-q / skala_i)))
    -
    -Genau das macht die Zielfunktion nicht-konvex: Grosse Bestellungen lohnen
    -sich ueberproportional, es gibt also mehrere sinnvolle "Cluster"-Loesungen -
    -und dazwischen schlechtere Taeler.
    -
    -Das Programm zeigt drei Dinge:
    -  1. Ein einzelner Lauf liefert ein plausibles Ergebnis - ohne jede Warnung.
    -  2. 200 Startpunkte foerdern mehrere verschiedene lokale Optima zutage.
    -  3. Der Unterschied zwischen bestem und schlechtestem betraegt hier 8 %.
    -
    -Benoetigt: numpy, scipy
    -"""
    -
    -from __future__ import annotations
    -
    -import numpy as np
    -from scipy.optimize import minimize
    -
    -# Vier Lieferanten
    -NAMEN = ["Nord AG", "Ost GmbH", "Sued KG", "West SE"]
    -BASISPREIS = np.array([50.0, 47.0, 53.0, 45.0])     # EUR je Tonne ohne Rabatt
    -MAX_RABATT = np.array([0.30, 0.18, 0.35, 0.12])     # hoechstmoeglicher Rabatt
    -RABATT_SKALA = np.array([120.0, 260.0, 90.0, 300.0])  # wie schnell er greift
    -KAPAZITAET = np.array([400.0, 400.0, 400.0, 400.0])
    -BEDARF = 700.0
    -
    -RNG = np.random.default_rng(0)
    -
    -
    -def gesamtkosten(menge: np.ndarray) -> float:
    -    """Einkaufskosten bei mengenabhaengigem Stueckpreis.
    -
    -    Der Rabatt waechst mit der Bestellmenge und laeuft gegen MAX_RABATT.
    -    Dadurch ist der Stueckpreis fallend - und die Gesamtkostenfunktion
    -    nicht mehr konvex.
    -    """
    -    stueckpreis = BASISPREIS * (1 - MAX_RABATT * (1 - np.exp(-menge / RABATT_SKALA)))
    -    return float(stueckpreis @ menge)
    -
    -
    -NEBENBEDINGUNGEN = [{"type": "eq", "fun": lambda q: q.sum() - BEDARF}]
    -GRENZEN = [(0.0, k) for k in KAPAZITAET]
    -
    -
    -def optimiere_von(startpunkt: np.ndarray):
    -    """Ein Lauf von einem gegebenen Startpunkt aus."""
    -    return minimize(gesamtkosten, startpunkt, method="SLSQP",
    -                    bounds=GRENZEN, constraints=NEBENBEDINGUNGEN)
    -
    -
    -def zufaelliger_start() -> np.ndarray:
    -    """Zufaellige Aufteilung, die den Bedarf bereits erfuellt."""
    -    anteil = RNG.random(len(NAMEN))
    -    return anteil / anteil.sum() * BEDARF
    -
    -
    -def zeige_plan(titel: str, menge: np.ndarray, kosten: float) -> None:
    -    print(f"\n{titel}")
    -    for name, m in zip(NAMEN, menge):
    -        anteil = m / BEDARF * 100
    -        print(f"    {name:<10} {m:7.1f} t  ({anteil:4.1f} %)")
    -    print(f"    {'Gesamtkosten':<10} {kosten:9,.2f} EUR")
    -
    -
    -if __name__ == "__main__":
    -    print("=" * 78)
    -    print("  NICHT-KONVEX: DERSELBE CODE, VERSCHIEDENE ERGEBNISSE")
    -    print("=" * 78)
    -    print(f"{BEDARF:.0f} t Rohstoff auf {len(NAMEN)} Lieferanten mit Mengenrabatt.")
    -
    -    # --- 1. Ein einziger Lauf, so wie man es zuerst schreibt --------------
    -    erster = optimiere_von(np.full(len(NAMEN), BEDARF / len(NAMEN)))
    -    print(f"\n[1] EIN Lauf, Startpunkt 'gleichmaessig verteilt'")
    -    print(f"    scipy meldet: success={erster.success}, "
    -          f"'{erster.message}'")
    -    zeige_plan("    Ergebnis:", erster.x, erster.fun)
    -    print("\n    Nichts an dieser Ausgabe deutet darauf hin, dass etwas fehlt.")
    -
    -    # --- 1b. Der kaufmaennisch naheliegende Startpunkt --------------------
    -    # "Kaufe bei den beiden Lieferanten mit dem guenstigsten Basispreis" -
    -    # West SE (45) und Ost GmbH (47), jeweils bis zur Kapazitaetsgrenze.
    -    guenstigste = np.argsort(BASISPREIS)[:2]
    -    kaufmaennisch = np.zeros(len(NAMEN))
    -    rest = BEDARF
    -    for i in guenstigste:
    -        kaufmaennisch[i] = min(KAPAZITAET[i], rest)
    -        rest -= kaufmaennisch[i]
    -    zweiter = optimiere_von(kaufmaennisch)
    -    print(f"\n[1b] EIN Lauf, Startpunkt 'die zwei mit dem guenstigsten Basispreis'")
    -    print(f"    scipy meldet: success={zweiter.success}")
    -    zeige_plan("    Ergebnis:", zweiter.x, zweiter.fun)
    -    print(f"\n    Dasselbe Programm, derselbe Aufruf, ein anderer Startpunkt -")
    -    print(f"    und {zweiter.fun - erster.fun:,.2f} EUR Unterschied "
    -          f"({(zweiter.fun / erster.fun - 1) * 100:.1f} %).")
    -
    -    # --- 2. Multistart: dasselbe Problem, viele Startpunkte ---------------
    -    laeufe = []
    -    for _ in range(200):
    -        ergebnis = optimiere_von(zufaelliger_start())
    -        if ergebnis.success:
    -            laeufe.append((float(ergebnis.fun), ergebnis.x))
    -
    -    # Ergebnisse, die sich um weniger als 1 Cent unterscheiden, sind dasselbe
    -    # lokale Optimum - zusammenfassen, sonst zaehlt man Rundungsrauschen.
    -    optima: list[tuple[float, np.ndarray]] = []
    -    for wert, plan in sorted(laeufe, key=lambda t: t[0]):
    -        if not optima or abs(wert - optima[-1][0]) > 0.01:
    -            optima.append((wert, plan))
    -
    -    print("\n" + "=" * 78)
    -    print(f"[2] 200 zufaellige Startpunkte -> {len(laeufe)} erfolgreiche Laeufe")
    -    print(f"    darunter {len(optima)} VERSCHIEDENE lokale Optima:")
    -    print()
    -    print(f"    {'Rang':>5} {'Kosten':>13} {'Abstand zum besten':>20}   Aufteilung (t)")
    -    print("    " + "-" * 70)
    -    bester = optima[0][0]
    -    for rang, (wert, plan) in enumerate(optima, start=1):
    -        abstand = (wert / bester - 1) * 100
    -        aufteilung = " ".join(f"{m:5.0f}" for m in plan)
    -        print(f"    {rang:>5} {wert:>13,.2f} {abstand:>19.2f} %   {aufteilung}")
    -
    -    # --- 3. Was das kostet ------------------------------------------------
    -    schlechtester = optima[-1]
    -    print("\n" + "=" * 78)
    -    print("  WAS AUF DEM SPIEL STEHT")
    -    print("=" * 78)
    -    zeige_plan("Bester gefundener Plan:", optima[0][1], optima[0][0])
    -    zeige_plan("Schlechtestes lokales Optimum:", schlechtester[1], schlechtester[0])
    -    unterschied = schlechtester[0] - optima[0][0]
    -    print(f"\n  Unterschied: {unterschied:,.2f} EUR "
    -          f"({unterschied / optima[0][0] * 100:.1f} %)")
    -    print(f"\n  Lauf [1]  (gleichmaessiger Start):     {erster.fun:>10,.2f} EUR")
    -    print(f"  Lauf [1b] (kaufmaennischer Start):    {zweiter.fun:>10,.2f} EUR"
    -          f"   <- {(zweiter.fun / optima[0][0] - 1) * 100:.1f} % ueber dem besten")
    -    print()
    -    print("  Bemerkenswert: Der kaufmaennisch NAHELIEGENDE Startpunkt fuehrt in")
    -    print("  das schlechteste Ergebnis von allen - schlechter als jedes der 200")
    -    print("  zufaellig gefundenen lokalen Optima. Wer beim guenstigsten")
    -    print("  Basispreis anfaengt, uebersieht, dass hier der Mengenrabatt")
    -    print("  entscheidet und nicht der Listenpreis.")
    -
    -    print()
    -    print("Drei Konsequenzen fuer die Praxis:")
    -    print("  1. 'success: True' heisst bei nicht-konvexen Problemen NICHT 'optimal'.")
    -    print("     Es heisst nur: 'Ich bin an einer Stelle angekommen, an der es in")
    -    print("     keine Richtung mehr bergab geht.'")
    -    print("  2. Ein einzelner Lauf ist wertlos. Nehmen Sie viele Startpunkte und")
    -    print("     berichten Sie die STREUUNG mit - sie ist Ihre einzige Auskunft")
    -    print("     darueber, wie zerklueftet die Landschaft ist.")
    -    print("  3. Auch Multistart liefert KEINE Garantie. Dass hier nichts unter")
    -    print(f"     {bester:,.2f} EUR gefunden wurde, beweist nicht, dass es nichts gibt.")
    -    print("=" * 78)
    +
    #!/usr/bin/env python3
    +
    +# Lokale_Optima_Multistart.py
    +"""
    +Kapitel QP/NLP: Was passiert, wenn die Konvexitaet fehlt.
    +
    +CVXPY verweigert nicht-konvexe Probleme - das ist sein Schutzmechanismus.
    +scipy.optimize.minimize verweigert nichts. Es rechnet, meldet 'success: True'
    +und liefert ein Ergebnis. Nur ist das Ergebnis dann kein Optimum, sondern
    +irgendein lokales Minimum, das vom Startpunkt abhaengt.
    +
    +Beispiel aus dem Einkauf: 700 Tonnen Rohstoff werden auf vier Lieferanten
    +verteilt. Jeder gewaehrt einen MENGENRABATT - der Stueckpreis faellt, je mehr
    +man bei ihm bestellt:
    +
    +    preis_i(q) = basis_i * (1 - rabatt_i * (1 - exp(-q / skala_i)))
    +
    +Genau das macht die Zielfunktion nicht-konvex: Grosse Bestellungen lohnen
    +sich ueberproportional, es gibt also mehrere sinnvolle "Cluster"-Loesungen -
    +und dazwischen schlechtere Taeler.
    +
    +Das Programm zeigt drei Dinge:
    +  1. Ein einzelner Lauf liefert ein plausibles Ergebnis - ohne jede Warnung.
    +  2. 200 Startpunkte foerdern mehrere verschiedene lokale Optima zutage.
    +  3. Der Unterschied zwischen bestem und schlechtestem betraegt hier 8 %.
    +
    +Benoetigt: numpy, scipy
    +"""
    +
    +from __future__ import annotations
    +
    +import numpy as np
    +from scipy.optimize import minimize
    +
    +# Vier Lieferanten
    +NAMEN = ["Nord AG", "Ost GmbH", "Sued KG", "West SE"]
    +BASISPREIS = np.array([50.0, 47.0, 53.0, 45.0])     # EUR je Tonne ohne Rabatt
    +MAX_RABATT = np.array([0.30, 0.18, 0.35, 0.12])     # hoechstmoeglicher Rabatt
    +RABATT_SKALA = np.array([120.0, 260.0, 90.0, 300.0])  # wie schnell er greift
    +KAPAZITAET = np.array([400.0, 400.0, 400.0, 400.0])
    +BEDARF = 700.0
    +
    +RNG = np.random.default_rng(0)
    +
    +
    +def gesamtkosten(menge: np.ndarray) -> float:
    +    """Einkaufskosten bei mengenabhaengigem Stueckpreis.
    +
    +    Der Rabatt waechst mit der Bestellmenge und laeuft gegen MAX_RABATT.
    +    Dadurch ist der Stueckpreis fallend - und die Gesamtkostenfunktion
    +    nicht mehr konvex.
    +    """
    +    stueckpreis = BASISPREIS * (1 - MAX_RABATT * (1 - np.exp(-menge / RABATT_SKALA)))
    +    return float(stueckpreis @ menge)
    +
    +
    +NEBENBEDINGUNGEN = [{"type": "eq", "fun": lambda q: q.sum() - BEDARF}]
    +GRENZEN = [(0.0, k) for k in KAPAZITAET]
    +
    +
    +def optimiere_von(startpunkt: np.ndarray):
    +    """Ein Lauf von einem gegebenen Startpunkt aus."""
    +    return minimize(gesamtkosten, startpunkt, method="SLSQP",
    +                    bounds=GRENZEN, constraints=NEBENBEDINGUNGEN)
    +
    +
    +def zufaelliger_start() -> np.ndarray:
    +    """Zufaellige Aufteilung, die den Bedarf bereits erfuellt."""
    +    anteil = RNG.random(len(NAMEN))
    +    return anteil / anteil.sum() * BEDARF
    +
    +
    +def zeige_plan(titel: str, menge: np.ndarray, kosten: float) -> None:
    +    print(f"\n{titel}")
    +    for name, m in zip(NAMEN, menge):
    +        anteil = m / BEDARF * 100
    +        print(f"    {name:<10} {m:7.1f} t  ({anteil:4.1f} %)")
    +    print(f"    {'Gesamtkosten':<10} {kosten:9,.2f} EUR")
    +
    +
    +if __name__ == "__main__":
    +    print("=" * 78)
    +    print("  NICHT-KONVEX: DERSELBE CODE, VERSCHIEDENE ERGEBNISSE")
    +    print("=" * 78)
    +    print(f"{BEDARF:.0f} t Rohstoff auf {len(NAMEN)} Lieferanten mit Mengenrabatt.")
    +
    +    # --- 1. Ein einziger Lauf, so wie man es zuerst schreibt --------------
    +    erster = optimiere_von(np.full(len(NAMEN), BEDARF / len(NAMEN)))
    +    print(f"\n[1] EIN Lauf, Startpunkt 'gleichmaessig verteilt'")
    +    print(f"    scipy meldet: success={erster.success}, "
    +          f"'{erster.message}'")
    +    zeige_plan("    Ergebnis:", erster.x, erster.fun)
    +    print("\n    Nichts an dieser Ausgabe deutet darauf hin, dass etwas fehlt.")
    +
    +    # --- 1b. Der kaufmaennisch naheliegende Startpunkt --------------------
    +    # "Kaufe bei den beiden Lieferanten mit dem guenstigsten Basispreis" -
    +    # West SE (45) und Ost GmbH (47), jeweils bis zur Kapazitaetsgrenze.
    +    guenstigste = np.argsort(BASISPREIS)[:2]
    +    kaufmaennisch = np.zeros(len(NAMEN))
    +    rest = BEDARF
    +    for i in guenstigste:
    +        kaufmaennisch[i] = min(KAPAZITAET[i], rest)
    +        rest -= kaufmaennisch[i]
    +    zweiter = optimiere_von(kaufmaennisch)
    +    print(f"\n[1b] EIN Lauf, Startpunkt 'die zwei mit dem guenstigsten Basispreis'")
    +    print(f"    scipy meldet: success={zweiter.success}")
    +    zeige_plan("    Ergebnis:", zweiter.x, zweiter.fun)
    +    print(f"\n    Dasselbe Programm, derselbe Aufruf, ein anderer Startpunkt -")
    +    print(f"    und {zweiter.fun - erster.fun:,.2f} EUR Unterschied "
    +          f"({(zweiter.fun / erster.fun - 1) * 100:.1f} %).")
    +
    +    # --- 2. Multistart: dasselbe Problem, viele Startpunkte ---------------
    +    laeufe = []
    +    for _ in range(200):
    +        ergebnis = optimiere_von(zufaelliger_start())
    +        if ergebnis.success:
    +            laeufe.append((float(ergebnis.fun), ergebnis.x))
    +
    +    # Ergebnisse, die sich um weniger als 1 Cent unterscheiden, sind dasselbe
    +    # lokale Optimum - zusammenfassen, sonst zaehlt man Rundungsrauschen.
    +    optima: list[tuple[float, np.ndarray]] = []
    +    for wert, plan in sorted(laeufe, key=lambda t: t[0]):
    +        if not optima or abs(wert - optima[-1][0]) > 0.01:
    +            optima.append((wert, plan))
    +
    +    print("\n" + "=" * 78)
    +    print(f"[2] 200 zufaellige Startpunkte -> {len(laeufe)} erfolgreiche Laeufe")
    +    print(f"    darunter {len(optima)} VERSCHIEDENE lokale Optima:")
    +    print()
    +    print(f"    {'Rang':>5} {'Kosten':>13} {'Abstand zum besten':>20}   Aufteilung (t)")
    +    print("    " + "-" * 70)
    +    bester = optima[0][0]
    +    for rang, (wert, plan) in enumerate(optima, start=1):
    +        abstand = (wert / bester - 1) * 100
    +        aufteilung = " ".join(f"{m:5.0f}" for m in plan)
    +        print(f"    {rang:>5} {wert:>13,.2f} {abstand:>19.2f} %   {aufteilung}")
    +
    +    # --- 3. Was das kostet ------------------------------------------------
    +    schlechtester = optima[-1]
    +    print("\n" + "=" * 78)
    +    print("  WAS AUF DEM SPIEL STEHT")
    +    print("=" * 78)
    +    zeige_plan("Bester gefundener Plan:", optima[0][1], optima[0][0])
    +    zeige_plan("Schlechtestes lokales Optimum:", schlechtester[1], schlechtester[0])
    +    unterschied = schlechtester[0] - optima[0][0]
    +    print(f"\n  Unterschied: {unterschied:,.2f} EUR "
    +          f"({unterschied / optima[0][0] * 100:.1f} %)")
    +    print(f"\n  Lauf [1]  (gleichmaessiger Start):     {erster.fun:>10,.2f} EUR")
    +    print(f"  Lauf [1b] (kaufmaennischer Start):    {zweiter.fun:>10,.2f} EUR"
    +          f"   <- {(zweiter.fun / optima[0][0] - 1) * 100:.1f} % ueber dem besten")
    +    print()
    +    print("  Bemerkenswert: Der kaufmaennisch NAHELIEGENDE Startpunkt fuehrt in")
    +    print("  das schlechteste Ergebnis von allen - schlechter als jedes der 200")
    +    print("  zufaellig gefundenen lokalen Optima. Wer beim guenstigsten")
    +    print("  Basispreis anfaengt, uebersieht, dass hier der Mengenrabatt")
    +    print("  entscheidet und nicht der Listenpreis.")
    +
    +    print()
    +    print("Drei Konsequenzen fuer die Praxis:")
    +    print("  1. 'success: True' heisst bei nicht-konvexen Problemen NICHT 'optimal'.")
    +    print("     Es heisst nur: 'Ich bin an einer Stelle angekommen, an der es in")
    +    print("     keine Richtung mehr bergab geht.'")
    +    print("  2. Ein einzelner Lauf ist wertlos. Nehmen Sie viele Startpunkte und")
    +    print("     berichten Sie die STREUUNG mit - sie ist Ihre einzige Auskunft")
    +    print("     darueber, wie zerklueftet die Landschaft ist.")
    +    print("  3. Auch Multistart liefert KEINE Garantie. Dass hier nichts unter")
    +    print(f"     {bester:,.2f} EUR gefunden wurde, beweist nicht, dass es nichts gibt.")
    +    print("=" * 78)

    Erwartete Ausgabe:

    ==============================================================================
       NICHT-KONVEX: DERSELBE CODE, VERSCHIEDENE ERGEBNISSE
    @@ -16291,25 +16607,25 @@ Drei Konsequenzen fuer die Praxis:
     

    ⚠️ Zu diesem Abschnitt gehört kein lauffähiges Programm. Ipopt, Bonmin und Couenne sind C++-Pakete, die über die Python-Installation hinaus systemweit eingerichtet werden müssen (conda install -c conda-forge ipopt cyipopt oder eine Distributionspaketquelle). Anders als bei allen übrigen Programmen dieses Buches konnten wir den Code deshalb nicht auf jedem Zielsystem ausführen — und drucken hier bewusst keine Ausgabe ab, die wir nicht selbst erzeugt haben. Der folgende Ausschnitt zeigt die Anbindung; prüfen Sie das Ergebnis auf Ihrem System selbst nach.

    -
    # Ipopt ueber Pyomo - Anbindungsmuster, NICHT ausgefuehrt.
    -# Voraussetzung:  conda install -c conda-forge ipopt
    -import pyomo.environ as pyo
    -
    -modell = pyo.ConcreteModel()
    -modell.q = pyo.Var(range(4), domain=pyo.NonNegativeReals, bounds=(0, 400))
    -modell.bedarf = pyo.Constraint(expr=sum(modell.q[i] for i in range(4)) == 700)
    -modell.ziel = pyo.Objective(
    -    expr=sum(BASISPREIS[i] * (1 - MAX_RABATT[i]
    -                              * (1 - pyo.exp(-modell.q[i] / RABATT_SKALA[i])))
    -             * modell.q[i] for i in range(4)),
    -    sense=pyo.minimize)
    -
    -loeser = pyo.SolverFactory("ipopt")
    -if loeser.available(exception_flag=False):
    -    ergebnis = loeser.solve(modell)
    -    print(ergebnis.solver.termination_condition)
    -else:
    -    print("Ipopt ist nicht installiert - siehe Hinweis oben.")
    +
    # Ipopt ueber Pyomo - Anbindungsmuster, NICHT ausgefuehrt.
    +# Voraussetzung:  conda install -c conda-forge ipopt
    +import pyomo.environ as pyo
    +
    +modell = pyo.ConcreteModel()
    +modell.q = pyo.Var(range(4), domain=pyo.NonNegativeReals, bounds=(0, 400))
    +modell.bedarf = pyo.Constraint(expr=sum(modell.q[i] for i in range(4)) == 700)
    +modell.ziel = pyo.Objective(
    +    expr=sum(BASISPREIS[i] * (1 - MAX_RABATT[i]
    +                              * (1 - pyo.exp(-modell.q[i] / RABATT_SKALA[i])))
    +             * modell.q[i] for i in range(4)),
    +    sense=pyo.minimize)
    +
    +loeser = pyo.SolverFactory("ipopt")
    +if loeser.available(exception_flag=False):
    +    ergebnis = loeser.solve(modell)
    +    print(ergebnis.solver.termination_condition)
    +else:
    +    print("Ipopt ist nicht installiert - siehe Hinweis oben.")

    Die Strategie bleibt in jedem Fall dieselbe: Ein lokaler Solver wie Ipopt löst schneller und größer als scipy — er löst aber nicht globaler. Multistart bleibt nötig. Erst ein globaler Solver (SCIP, Couenne, BARON) ersetzt ihn, und den bezahlt man mit Rechenzeit, die um Größenordnungen höher liegt.

    🎯 Merksatz Bei nicht-konvexen Problemen ist die ehrliche Berichterstattung wichtiger als die letzte Nachkommastelle: „Bester gefundener Wert 24 724 € aus 200 Startpunkten; die Streuung reicht bis 26 690 €.“ Das ist eine belastbare Aussage. „Das Optimum liegt bei 24 724,19 €“ ist es nicht.

    @@ -16340,19 +16656,19 @@ Drei Konsequenzen fuer die Praxis:
    DCPError: Problem does not follow DCP rules.
     The objective is not DCP. Its following subexpressions are not: QuadForm(...)

    Er hält das für eine Einschränkung der Bibliothek und weicht auf scipy aus:

    -
    import numpy as np
    -from scipy.optimize import minimize
    -
    -R = np.array([[ 1.0,  0.9, -0.9],
    -              [ 0.9,  1.0,  0.9],
    -              [-0.9,  0.9,  1.0]])
    -S = 0.20**2 * R
    -
    -varianz = lambda w: float(w @ S @ w)
    -ergebnis = minimize(varianz, np.array([0.4, 0.3, 0.3]),
    -                    constraints=[{"type": "eq", "fun": lambda w: w.sum() - 1}],
    -                    bounds=[(-2, 3)] * 3)
    -print(np.round(ergebnis.x, 3), round(ergebnis.fun, 4), ergebnis.success)
    +
    import numpy as np
    +from scipy.optimize import minimize
    +
    +R = np.array([[ 1.0,  0.9, -0.9],
    +              [ 0.9,  1.0,  0.9],
    +              [-0.9,  0.9,  1.0]])
    +S = 0.20**2 * R
    +
    +varianz = lambda w: float(w @ S @ w)
    +ergebnis = minimize(varianz, np.array([0.4, 0.3, 0.3]),
    +                    constraints=[{"type": "eq", "fun": lambda w: w.sum() - 1}],
    +                    bounds=[(-2, 3)] * 3)
    +print(np.round(ergebnis.x, 3), round(ergebnis.fun, 4), ergebnis.success)

    Ausgabe:

    [ 1.5 -2.   1.5] -0.254 True

    Ein Portfolio mit einer Varianz von -0{,}254. Der Analyst notiert erfreut ein „risikofreies Portfolio mit negativer Schwankung“.

    @@ -16441,20 +16757,20 @@ The objective is not DCP. Its following subexpressions are not: QuadForm(...)

    Wie viele bestellen Sie?

    -
    import numpy as np
    -
    -KOSTEN_ZUVIEL, KOSTEN_ZUWENIG = 120.0, 1400.0
    -bedarf = np.random.default_rng(7).poisson(30, 200_000)     # 200.000 Winter
    -
    -def kosten(menge):
    -    zuviel = np.maximum(menge - bedarf, 0)
    -    zuwenig = np.maximum(bedarf - menge, 0)
    -    return (KOSTEN_ZUVIEL * zuviel + KOSTEN_ZUWENIG * zuwenig).mean()
    -
    -kandidaten = np.arange(20, 61)
    -werte = np.array([kosten(m) for m in kandidaten])
    -print("Optimum:", kandidaten[werte.argmin()], "Stueck,", f"{werte.min():,.0f} EUR")
    -print("Mittlerer Bedarf 30:", f"{kosten(30):,.0f} EUR")
    +
    import numpy as np
    +
    +KOSTEN_ZUVIEL, KOSTEN_ZUWENIG = 120.0, 1400.0
    +bedarf = np.random.default_rng(7).poisson(30, 200_000)     # 200.000 Winter
    +
    +def kosten(menge):
    +    zuviel = np.maximum(menge - bedarf, 0)
    +    zuwenig = np.maximum(bedarf - menge, 0)
    +    return (KOSTEN_ZUVIEL * zuviel + KOSTEN_ZUWENIG * zuwenig).mean()
    +
    +kandidaten = np.arange(20, 61)
    +werte = np.array([kosten(m) for m in kandidaten])
    +print("Optimum:", kandidaten[werte.argmin()], "Stueck,", f"{werte.min():,.0f} EUR")
    +print("Mittlerer Bedarf 30:", f"{kosten(30):,.0f} EUR")

    Ausgabe:

    Optimum: 38 Stueck, 1,270 EUR
     Mittlerer Bedarf 30: 3,306 EUR
    @@ -16557,59 +16873,59 @@ q^{*} = \frac{c_{\text{zu wenig}}}{c_{\text{zu wenig}} + c_{\text{zu viel}}}

    Die naive Planung kostet 437,50 € mehr — rund 2,7 % Aufschlag für das Einsetzen eines Mittelwerts, den es als Szenario gar nicht gibt. Beachten Sie: Der Aufschlag wirkt klein, weil bei nur drei Szenarien 225 und 250 nahe beieinanderliegen. Entscheidend ist die Richtung: Das Optimum liegt systematisch über dem Mittelwert, und zwar bei genau jenem Szenariowert, an dem die Kostenbalance kippt.

    Dass x = 250 tatsächlich das Minimum ist und nicht nur eine plausible Vermutung, zeigt ein vollständiger Scan über alle möglichen Kapazitäten:

    -
    #!/usr/bin/env python3
    -
    -# Fluch_des_Durchschnitts.py
    -"""
    -Kapitel Unsicherheit: Der Fluch des Durchschnitts (die Handrechnung dazu) als Scan ueber alle
    -moeglichen Kapazitaeten - zeigt, dass das Optimum nicht beim Mittelwert liegt.
    -"""
    -
    -import numpy as np
    -
    -SZENARIEN = np.array([100, 250, 500])
    -WAHRSCHEINLICHKEITEN = np.array([0.5, 0.3, 0.2])
    -PREIS_VORAB = 40.0
    -PREIS_ZUKAUF = 120.0
    -PREIS_VERWALTUNG = 5.0
    -
    -ERWARTUNGSWERT = float(SZENARIEN @ WAHRSCHEINLICHKEITEN)
    -
    -
    -def erwartete_kosten(x):
    -    kosten = np.where(
    -        SZENARIEN >= x,
    -        PREIS_VORAB * x + PREIS_ZUKAUF * (SZENARIEN - x),
    -        PREIS_VORAB * x + PREIS_VERWALTUNG * (x - SZENARIEN),
    -    )
    -    return float(kosten @ WAHRSCHEINLICHKEITEN)
    -
    -
    -if __name__ == "__main__":
    -    print("=" * 70)
    -    print("  DER FLUCH DES DURCHSCHNITTS")
    -    print("=" * 70)
    -    print(f"Erwarteter Bedarf (Mittelwert): {ERWARTUNGSWERT:.1f}")
    -    print(f"Kosten bei naiver Planung x={ERWARTUNGSWERT:.0f}: "
    -          f"{erwartete_kosten(ERWARTUNGSWERT):,.2f} EUR\n")
    -
    -    x_werte = np.arange(0, 501, 1)
    -    kosten_werte = np.array([erwartete_kosten(x) for x in x_werte])
    -    x_optimal = x_werte[np.argmin(kosten_werte)]
    -    kosten_optimal = kosten_werte.min()
    -
    -    print(f"Optimales x (durch Scan gefunden): {x_optimal}")
    -    print(f"Kosten beim Optimum:                {kosten_optimal:,.2f} EUR")
    -    print(f"Ersparnis gegenueber naiver Planung: "
    -          f"{erwartete_kosten(ERWARTUNGSWERT) - kosten_optimal:,.2f} EUR\n")
    -
    -    print("Kosten fuer ausgewaehlte x zum Vergleich:")
    -    for x in [100, 225, 250, 300, 500]:
    -        markierung = "  <- Mittelwert" if x == 225 else ("  <- Optimum" if x == 250 else "")
    -        print(f"  x={x:4d}: {erwartete_kosten(x):>12,.2f} EUR{markierung}")
    -
    -    print("\nDas Optimum liegt exakt auf einem Szenariowert (250 = 'Volatil'),")
    -    print("nicht beim Mittelwert 225 - typisch fuer asymmetrische Kostenfunktionen.")
    +
    #!/usr/bin/env python3
    +
    +# Fluch_des_Durchschnitts.py
    +"""
    +Kapitel Unsicherheit: Der Fluch des Durchschnitts (die Handrechnung dazu) als Scan ueber alle
    +moeglichen Kapazitaeten - zeigt, dass das Optimum nicht beim Mittelwert liegt.
    +"""
    +
    +import numpy as np
    +
    +SZENARIEN = np.array([100, 250, 500])
    +WAHRSCHEINLICHKEITEN = np.array([0.5, 0.3, 0.2])
    +PREIS_VORAB = 40.0
    +PREIS_ZUKAUF = 120.0
    +PREIS_VERWALTUNG = 5.0
    +
    +ERWARTUNGSWERT = float(SZENARIEN @ WAHRSCHEINLICHKEITEN)
    +
    +
    +def erwartete_kosten(x):
    +    kosten = np.where(
    +        SZENARIEN >= x,
    +        PREIS_VORAB * x + PREIS_ZUKAUF * (SZENARIEN - x),
    +        PREIS_VORAB * x + PREIS_VERWALTUNG * (x - SZENARIEN),
    +    )
    +    return float(kosten @ WAHRSCHEINLICHKEITEN)
    +
    +
    +if __name__ == "__main__":
    +    print("=" * 70)
    +    print("  DER FLUCH DES DURCHSCHNITTS")
    +    print("=" * 70)
    +    print(f"Erwarteter Bedarf (Mittelwert): {ERWARTUNGSWERT:.1f}")
    +    print(f"Kosten bei naiver Planung x={ERWARTUNGSWERT:.0f}: "
    +          f"{erwartete_kosten(ERWARTUNGSWERT):,.2f} EUR\n")
    +
    +    x_werte = np.arange(0, 501, 1)
    +    kosten_werte = np.array([erwartete_kosten(x) for x in x_werte])
    +    x_optimal = x_werte[np.argmin(kosten_werte)]
    +    kosten_optimal = kosten_werte.min()
    +
    +    print(f"Optimales x (durch Scan gefunden): {x_optimal}")
    +    print(f"Kosten beim Optimum:                {kosten_optimal:,.2f} EUR")
    +    print(f"Ersparnis gegenueber naiver Planung: "
    +          f"{erwartete_kosten(ERWARTUNGSWERT) - kosten_optimal:,.2f} EUR\n")
    +
    +    print("Kosten fuer ausgewaehlte x zum Vergleich:")
    +    for x in [100, 225, 250, 300, 500]:
    +        markierung = "  <- Mittelwert" if x == 225 else ("  <- Optimum" if x == 250 else "")
    +        print(f"  x={x:4d}: {erwartete_kosten(x):>12,.2f} EUR{markierung}")
    +
    +    print("\nDas Optimum liegt exakt auf einem Szenariowert (250 = 'Volatil'),")
    +    print("nicht beim Mittelwert 225 - typisch fuer asymmetrische Kostenfunktionen.")

    Erwartete Ausgabe:

    ======================================================================
       DER FLUCH DES DURCHSCHNITTS
    @@ -16706,98 +17022,98 @@ nicht beim Mittelwert 225 - typisch fuer asymmetrische Kostenfunktionen.<
     

    12.4 Monte-Carlo-Simulation

    Monte-Carlo heißt schlicht: Ziehe sehr viele Zufallsszenarien, werte für jedes den Plan aus, und betrachte die Verteilung der Ergebnisse statt eines einzigen Werts.

    -
    #!/usr/bin/env python3
    -
    -# Monte_Carlo.py
    -"""
    -Kapitel Unsicherheit: Monte-Carlo-Bewertung von Kapazitaetsplaenen.
    -
    -Monte Carlo OPTIMIERT nicht - es BEWERTET. Der Nutzen liegt darin, dass man
    -beliebige Kennzahlen ablesen kann: Erwartungswert, Quantile, Ausfallwahr-
    -scheinlichkeit, Worst Case. Genau diese Groessen braucht man, um zwischen
    -Plaenen zu entscheiden.
    -"""
    -
    -import numpy as np
    -
    -KOSTEN_VORAB = 40.0        # EUR je Einheit, im Voraus gekauft
    -KOSTEN_SPOT = 120.0        # EUR je Einheit, kurzfristig zugekauft
    -KOSTEN_LEERLAUF = 5.0      # EUR je ungenutzter Einheit
    -
    -ANZAHL_ZIEHUNGEN = 100_000
    -
    -
    -def ziehe_bedarf(rng, ziehungen):
    -    """
    -    Bedarfsmodell: Mischverteilung aus Normalbetrieb und seltenen Lastspitzen.
    -    Realistischer als eine reine Normalverteilung - Krisen sind selten,
    -    aber extrem (fat tail).
    -    """
    -    normal = rng.normal(loc=150, scale=40, size=ziehungen)
    -    spitze = rng.normal(loc=450, scale=80, size=ziehungen)
    -    ist_spitze = rng.random(ziehungen) < 0.15            # 15 % Lastspitzen
    -    return np.maximum(np.where(ist_spitze, spitze, normal), 0.0)
    -
    -
    -def kosten_fuer(kapazitaet, bedarf):
    -    """Gesamtkosten je Szenario fuer eine gegebene Vorabkapazitaet."""
    -    unterdeckung = np.maximum(bedarf - kapazitaet, 0.0)
    -    ueberdeckung = np.maximum(kapazitaet - bedarf, 0.0)
    -    return (KOSTEN_VORAB * kapazitaet
    -            + KOSTEN_SPOT * unterdeckung
    -            + KOSTEN_LEERLAUF * ueberdeckung)
    -
    -
    -if __name__ == "__main__":
    -    rng = np.random.default_rng(2026)
    -    bedarf = ziehe_bedarf(rng, ANZAHL_ZIEHUNGEN)
    -
    -    print("=" * 88)
    -    print(f"  MONTE-CARLO-BEWERTUNG ({ANZAHL_ZIEHUNGEN:,} Szenarien)")
    -    print("=" * 88)
    -    print(f"Bedarfsverteilung: Mittelwert {bedarf.mean():.1f} | "
    -          f"Median {np.median(bedarf):.1f} | "
    -          f"95%-Quantil {np.percentile(bedarf, 95):.1f} | "
    -          f"Maximum {bedarf.max():.1f}")
    -    print("Der Median liegt deutlich unter dem Mittelwert - die Verteilung ist")
    -    print("rechtsschief. Genau hier fuehrt Planung mit dem Mittelwert in die Irre.\n")
    -
    -    print(f"{'Kapazitaet':>10} | {'Erw. Kosten':>12} | {'Median':>10} | "
    -          f"{'95%-Quantil':>12} | {'Unterdeckung':>12}")
    -    print("-" * 88)
    -
    -    kandidaten = [150, 200, 225, 250, 300, 350, 400]
    -    ergebnisse = []
    -    for kapazitaet in kandidaten:
    -        kosten = kosten_fuer(kapazitaet, bedarf)
    -        p_unterdeckung = float(np.mean(bedarf > kapazitaet))
    -        ergebnisse.append((kapazitaet, kosten.mean(), p_unterdeckung))
    -        print(f"{kapazitaet:>10} | {kosten.mean():>12,.0f} | "
    -              f"{np.median(kosten):>10,.0f} | {np.percentile(kosten, 95):>12,.0f} | "
    -              f"{p_unterdeckung*100:>11.1f} %")
    -
    -    beste = min(ergebnisse, key=lambda t: t[1])
    -    print("-" * 88)
    -    print(f"Bester Kandidat: Kapazitaet {beste[0]} mit erwarteten Kosten "
    -          f"{beste[1]:,.0f} EUR")
    -
    -    # --- Feinsuche ueber ein Raster ---------------------------------------
    -    raster = np.arange(100, 500, 5)
    -    erwartete = np.array([kosten_fuer(k, bedarf).mean() for k in raster])
    -    optimum = raster[int(np.argmin(erwartete))]
    -    print(f"Feinsuche (Raster 100..500): Optimum bei Kapazitaet {optimum}, "
    -          f"Kosten {erwartete.min():,.0f} EUR")
    -
    -    # --- Vergleich mit der naiven Mittelwertplanung ----------------------
    -    naiv = int(round(bedarf.mean()))
    -    kosten_naiv = kosten_fuer(naiv, bedarf).mean()
    -    kosten_opt = kosten_fuer(optimum, bedarf).mean()
    -    print("\n--- Fluch des Durchschnitts, gemessen ---")
    -    print(f"  Planung mit Mittelwert ({naiv}):  {kosten_naiv:,.0f} EUR")
    -    print(f"  Monte-Carlo-Optimum    ({optimum}):  {kosten_opt:,.0f} EUR")
    -    print(f"  Mehrkosten der naiven Planung:  {kosten_naiv - kosten_opt:,.0f} EUR "
    -          f"({(kosten_naiv/kosten_opt - 1)*100:.1f} %)")
    -    print("=" * 88)
    +
    #!/usr/bin/env python3
    +
    +# Monte_Carlo.py
    +"""
    +Kapitel Unsicherheit: Monte-Carlo-Bewertung von Kapazitaetsplaenen.
    +
    +Monte Carlo OPTIMIERT nicht - es BEWERTET. Der Nutzen liegt darin, dass man
    +beliebige Kennzahlen ablesen kann: Erwartungswert, Quantile, Ausfallwahr-
    +scheinlichkeit, Worst Case. Genau diese Groessen braucht man, um zwischen
    +Plaenen zu entscheiden.
    +"""
    +
    +import numpy as np
    +
    +KOSTEN_VORAB = 40.0        # EUR je Einheit, im Voraus gekauft
    +KOSTEN_SPOT = 120.0        # EUR je Einheit, kurzfristig zugekauft
    +KOSTEN_LEERLAUF = 5.0      # EUR je ungenutzter Einheit
    +
    +ANZAHL_ZIEHUNGEN = 100_000
    +
    +
    +def ziehe_bedarf(rng, ziehungen):
    +    """
    +    Bedarfsmodell: Mischverteilung aus Normalbetrieb und seltenen Lastspitzen.
    +    Realistischer als eine reine Normalverteilung - Krisen sind selten,
    +    aber extrem (fat tail).
    +    """
    +    normal = rng.normal(loc=150, scale=40, size=ziehungen)
    +    spitze = rng.normal(loc=450, scale=80, size=ziehungen)
    +    ist_spitze = rng.random(ziehungen) < 0.15            # 15 % Lastspitzen
    +    return np.maximum(np.where(ist_spitze, spitze, normal), 0.0)
    +
    +
    +def kosten_fuer(kapazitaet, bedarf):
    +    """Gesamtkosten je Szenario fuer eine gegebene Vorabkapazitaet."""
    +    unterdeckung = np.maximum(bedarf - kapazitaet, 0.0)
    +    ueberdeckung = np.maximum(kapazitaet - bedarf, 0.0)
    +    return (KOSTEN_VORAB * kapazitaet
    +            + KOSTEN_SPOT * unterdeckung
    +            + KOSTEN_LEERLAUF * ueberdeckung)
    +
    +
    +if __name__ == "__main__":
    +    rng = np.random.default_rng(2026)
    +    bedarf = ziehe_bedarf(rng, ANZAHL_ZIEHUNGEN)
    +
    +    print("=" * 88)
    +    print(f"  MONTE-CARLO-BEWERTUNG ({ANZAHL_ZIEHUNGEN:,} Szenarien)")
    +    print("=" * 88)
    +    print(f"Bedarfsverteilung: Mittelwert {bedarf.mean():.1f} | "
    +          f"Median {np.median(bedarf):.1f} | "
    +          f"95%-Quantil {np.percentile(bedarf, 95):.1f} | "
    +          f"Maximum {bedarf.max():.1f}")
    +    print("Der Median liegt deutlich unter dem Mittelwert - die Verteilung ist")
    +    print("rechtsschief. Genau hier fuehrt Planung mit dem Mittelwert in die Irre.\n")
    +
    +    print(f"{'Kapazitaet':>10} | {'Erw. Kosten':>12} | {'Median':>10} | "
    +          f"{'95%-Quantil':>12} | {'Unterdeckung':>12}")
    +    print("-" * 88)
    +
    +    kandidaten = [150, 200, 225, 250, 300, 350, 400]
    +    ergebnisse = []
    +    for kapazitaet in kandidaten:
    +        kosten = kosten_fuer(kapazitaet, bedarf)
    +        p_unterdeckung = float(np.mean(bedarf > kapazitaet))
    +        ergebnisse.append((kapazitaet, kosten.mean(), p_unterdeckung))
    +        print(f"{kapazitaet:>10} | {kosten.mean():>12,.0f} | "
    +              f"{np.median(kosten):>10,.0f} | {np.percentile(kosten, 95):>12,.0f} | "
    +              f"{p_unterdeckung*100:>11.1f} %")
    +
    +    beste = min(ergebnisse, key=lambda t: t[1])
    +    print("-" * 88)
    +    print(f"Bester Kandidat: Kapazitaet {beste[0]} mit erwarteten Kosten "
    +          f"{beste[1]:,.0f} EUR")
    +
    +    # --- Feinsuche ueber ein Raster ---------------------------------------
    +    raster = np.arange(100, 500, 5)
    +    erwartete = np.array([kosten_fuer(k, bedarf).mean() for k in raster])
    +    optimum = raster[int(np.argmin(erwartete))]
    +    print(f"Feinsuche (Raster 100..500): Optimum bei Kapazitaet {optimum}, "
    +          f"Kosten {erwartete.min():,.0f} EUR")
    +
    +    # --- Vergleich mit der naiven Mittelwertplanung ----------------------
    +    naiv = int(round(bedarf.mean()))
    +    kosten_naiv = kosten_fuer(naiv, bedarf).mean()
    +    kosten_opt = kosten_fuer(optimum, bedarf).mean()
    +    print("\n--- Fluch des Durchschnitts, gemessen ---")
    +    print(f"  Planung mit Mittelwert ({naiv}):  {kosten_naiv:,.0f} EUR")
    +    print(f"  Monte-Carlo-Optimum    ({optimum}):  {kosten_opt:,.0f} EUR")
    +    print(f"  Mehrkosten der naiven Planung:  {kosten_naiv - kosten_opt:,.0f} EUR "
    +          f"({(kosten_naiv/kosten_opt - 1)*100:.1f} %)")
    +    print("=" * 88)

    Erwartete Ausgabe (gekürzt):

    ========================================================================================
       MONTE-CARLO-BEWERTUNG (100,000 Szenarien)
    @@ -16856,125 +17172,125 @@ Feinsuche (Raster 100..500): Optimum bei Kapazitaet 180, Kosten 12,810 EUR
     

    Ohne Formel gesagt: „Entscheide jetzt so, dass die Summe aus heutigen Kosten und durchschnittlich zu erwartenden Nachbesserungskosten minimal wird.“

    Der entscheidende Punkt: Es gibt ein \mathbf{x}, aber je Szenario ein eigenes \mathbf{y}_s. Das ist keine Rechentrickserei, sondern bildet die Realität ab: Man legt sich einmal fest und reagiert dann unterschiedlich.

    -
    #!/usr/bin/env python3
    -
    -# Stochastische_Optimierung.py
    -"""
    -Kapitel Unsicherheit: Two-Stage Stochastic Programming mit CVXPY.
    -
    -Eigenschaften:
    -  * Die Analyse am Ende wird BERECHNET statt fest verdrahtet
    -  * Vergleich gegen drei Alternativen: Mittelwert, Worst Case, perfekte Voraussicht
    -  * Kennzahl EVPI (Wert perfekter Information) wird ausgewiesen
    -"""
    -
    -import cvxpy as cp
    -import numpy as np
    -import pandas as pd
    -
    -SZENARIEN = ["Ruhig", "Volatil", "Crash"]
    -WAHRSCHEINLICHKEIT = np.array([0.50, 0.30, 0.20])
    -BEDARF = np.array([100.0, 250.0, 500.0])
    -
    -KOSTEN_VORAB = 40.0
    -KOSTEN_SPOT = 120.0
    -KOSTEN_LEERLAUF = 5.0
    -
    -S = len(SZENARIEN)
    -
    -
    -def loese_stochastisch():
    -    """Zweistufiges Modell: eine Vorabentscheidung, szenarioabhaengige Korrektur."""
    -    x = cp.Variable(nonneg=True, name="Basiskapazitaet")        # Stufe 1
    -    y_spot = cp.Variable(S, nonneg=True, name="Spot_Zukauf")    # Stufe 2
    -    y_leer = cp.Variable(S, nonneg=True, name="Leerlauf")       # Stufe 2
    -
    -    # Kopplung: Basis + Zukauf - Leerlauf == Bedarf (je Szenario)
    -    nebenbedingungen = [x + y_spot[s] - y_leer[s] == BEDARF[s] for s in range(S)]
    -
    -    erwartete_korrektur = sum(
    -        WAHRSCHEINLICHKEIT[s] * (KOSTEN_SPOT * y_spot[s] + KOSTEN_LEERLAUF * y_leer[s])
    -        for s in range(S))
    -
    -    problem = cp.Problem(cp.Minimize(KOSTEN_VORAB * x + erwartete_korrektur),
    -                         nebenbedingungen)
    -    problem.solve()
    -    return problem, x, y_spot, y_leer
    -
    -
    -def kosten_bei(kapazitaet):
    -    """Erwartete Gesamtkosten fuer eine fest vorgegebene Kapazitaet."""
    -    unter = np.maximum(BEDARF - kapazitaet, 0.0)
    -    ueber = np.maximum(kapazitaet - BEDARF, 0.0)
    -    je_szenario = KOSTEN_VORAB * kapazitaet + KOSTEN_SPOT * unter + KOSTEN_LEERLAUF * ueber
    -    return float(WAHRSCHEINLICHKEIT @ je_szenario)
    -
    -
    -if __name__ == "__main__":
    -    problem, x, y_spot, y_leer = loese_stochastisch()
    -    kapazitaet = float(x.value)
    -    mittelwert = float(WAHRSCHEINLICHKEIT @ BEDARF)
    -
    -    print("=" * 82)
    -    print("     STOCHASTISCHE TWO-STAGE OPTIMIERUNG (KAPAZITAETSPLANUNG)")
    -    print("=" * 82)
    -    print(f"Status: {problem.status}")
    -    print(f"Erwarteter Bedarf (Mittelwert):   {mittelwert:.1f} Einheiten")
    -    print(f"Optimale Stufe-1-Kapazitaet x*:   {kapazitaet:.1f} Einheiten")
    -    print(f"Minimale erwartete Gesamtkosten:  {problem.value:,.2f} EUR\n")
    -
    -    tabelle = pd.DataFrame({
    -        "Szenario": SZENARIEN,
    -        "Wahrsch.": [f"{p*100:.0f} %" for p in WAHRSCHEINLICHKEIT],
    -        "Bedarf": BEDARF,
    -        "Basis genutzt": [min(kapazitaet, b) for b in BEDARF],
    -        "Spot-Zukauf": np.round(y_spot.value, 1),
    -        "Leerlauf": np.round(y_leer.value, 1),
    -        "Kosten (EUR)": [f"{KOSTEN_VORAB*kapazitaet + KOSTEN_SPOT*y_spot.value[s] + KOSTEN_LEERLAUF*y_leer.value[s]:,.0f}"
    -                         for s in range(S)],
    -    })
    -    print(tabelle.to_string(index=False))
    -
    -    # --- Vergleich mit Alternativstrategien (berechnet, nicht behauptet) --
    -    print("\n" + "-" * 82)
    -    print("Vergleich verschiedener Planungsstrategien:")
    -    print(f"{'Strategie':<34} {'Kapazitaet':>11} {'Erw. Kosten':>14} {'Mehrkosten':>13}")
    -    print("-" * 82)
    -
    -    optimal = problem.value
    -    strategien = [
    -        ("Stochastisch optimal", kapazitaet),
    -        ("Naiv: Mittelwert einsetzen", mittelwert),
    -        ("Vorsichtig: Worst Case abdecken", float(BEDARF.max())),
    -        ("Optimistisch: Bestfall", float(BEDARF.min())),
    -    ]
    -    for name, kap in strategien:
    -        kosten = kosten_bei(kap)
    -        print(f"{name:<34} {kap:>11.1f} {kosten:>14,.0f} "
    -              f"{kosten - optimal:>+13,.0f}")
    -
    -    # --- EVPI: Was waere perfekte Voraussicht wert? ----------------------
    -    # Bei perfekter Information wuerde man je Szenario genau den Bedarf kaufen.
    -    kosten_perfekt = float(WAHRSCHEINLICHKEIT @ (KOSTEN_VORAB * BEDARF))
    -    evpi = optimal - kosten_perfekt
    -    print("-" * 82)
    -    print(f"Kosten bei perfekter Voraussicht:      {kosten_perfekt:>10,.0f} EUR")
    -    print(f"Wert perfekter Information (EVPI):     {evpi:>10,.0f} EUR "
    -          f"({evpi/optimal*100:.1f} % der Kosten)")
    -    print("  -> So viel duerfte eine perfekte Bedarfsprognose hoechstens kosten.")
    -
    -    # --- Automatische Interpretation --------------------------------------
    -    print("-" * 82)
    -    if kapazitaet > mittelwert + 1e-6:
    -        print(f"Analyse: Der Solver waehlt {kapazitaet:.0f} Einheiten und damit MEHR als")
    -        print(f"den Mittelwert ({mittelwert:.0f}), weil Unterdeckung ({KOSTEN_SPOT:.0f} EUR)")
    -        print(f"deutlich teurer ist als Leerlauf ({KOSTEN_LEERLAUF:.0f} EUR).")
    -    elif kapazitaet < mittelwert - 1e-6:
    -        print(f"Analyse: Der Solver waehlt {kapazitaet:.0f} und damit WENIGER als den")
    -        print(f"Mittelwert ({mittelwert:.0f}) - Leerlauf ist hier teurer als Zukauf.")
    -    else:
    -        print("Analyse: Kapazitaet entspricht dem Mittelwert (symmetrische Kosten).")
    -    print("=" * 82)
    +
    #!/usr/bin/env python3
    +
    +# Stochastische_Optimierung.py
    +"""
    +Kapitel Unsicherheit: Two-Stage Stochastic Programming mit CVXPY.
    +
    +Eigenschaften:
    +  * Die Analyse am Ende wird BERECHNET statt fest verdrahtet
    +  * Vergleich gegen drei Alternativen: Mittelwert, Worst Case, perfekte Voraussicht
    +  * Kennzahl EVPI (Wert perfekter Information) wird ausgewiesen
    +"""
    +
    +import cvxpy as cp
    +import numpy as np
    +import pandas as pd
    +
    +SZENARIEN = ["Ruhig", "Volatil", "Crash"]
    +WAHRSCHEINLICHKEIT = np.array([0.50, 0.30, 0.20])
    +BEDARF = np.array([100.0, 250.0, 500.0])
    +
    +KOSTEN_VORAB = 40.0
    +KOSTEN_SPOT = 120.0
    +KOSTEN_LEERLAUF = 5.0
    +
    +S = len(SZENARIEN)
    +
    +
    +def loese_stochastisch():
    +    """Zweistufiges Modell: eine Vorabentscheidung, szenarioabhaengige Korrektur."""
    +    x = cp.Variable(nonneg=True, name="Basiskapazitaet")        # Stufe 1
    +    y_spot = cp.Variable(S, nonneg=True, name="Spot_Zukauf")    # Stufe 2
    +    y_leer = cp.Variable(S, nonneg=True, name="Leerlauf")       # Stufe 2
    +
    +    # Kopplung: Basis + Zukauf - Leerlauf == Bedarf (je Szenario)
    +    nebenbedingungen = [x + y_spot[s] - y_leer[s] == BEDARF[s] for s in range(S)]
    +
    +    erwartete_korrektur = sum(
    +        WAHRSCHEINLICHKEIT[s] * (KOSTEN_SPOT * y_spot[s] + KOSTEN_LEERLAUF * y_leer[s])
    +        for s in range(S))
    +
    +    problem = cp.Problem(cp.Minimize(KOSTEN_VORAB * x + erwartete_korrektur),
    +                         nebenbedingungen)
    +    problem.solve()
    +    return problem, x, y_spot, y_leer
    +
    +
    +def kosten_bei(kapazitaet):
    +    """Erwartete Gesamtkosten fuer eine fest vorgegebene Kapazitaet."""
    +    unter = np.maximum(BEDARF - kapazitaet, 0.0)
    +    ueber = np.maximum(kapazitaet - BEDARF, 0.0)
    +    je_szenario = KOSTEN_VORAB * kapazitaet + KOSTEN_SPOT * unter + KOSTEN_LEERLAUF * ueber
    +    return float(WAHRSCHEINLICHKEIT @ je_szenario)
    +
    +
    +if __name__ == "__main__":
    +    problem, x, y_spot, y_leer = loese_stochastisch()
    +    kapazitaet = float(x.value)
    +    mittelwert = float(WAHRSCHEINLICHKEIT @ BEDARF)
    +
    +    print("=" * 82)
    +    print("     STOCHASTISCHE TWO-STAGE OPTIMIERUNG (KAPAZITAETSPLANUNG)")
    +    print("=" * 82)
    +    print(f"Status: {problem.status}")
    +    print(f"Erwarteter Bedarf (Mittelwert):   {mittelwert:.1f} Einheiten")
    +    print(f"Optimale Stufe-1-Kapazitaet x*:   {kapazitaet:.1f} Einheiten")
    +    print(f"Minimale erwartete Gesamtkosten:  {problem.value:,.2f} EUR\n")
    +
    +    tabelle = pd.DataFrame({
    +        "Szenario": SZENARIEN,
    +        "Wahrsch.": [f"{p*100:.0f} %" for p in WAHRSCHEINLICHKEIT],
    +        "Bedarf": BEDARF,
    +        "Basis genutzt": [min(kapazitaet, b) for b in BEDARF],
    +        "Spot-Zukauf": np.round(y_spot.value, 1),
    +        "Leerlauf": np.round(y_leer.value, 1),
    +        "Kosten (EUR)": [f"{KOSTEN_VORAB*kapazitaet + KOSTEN_SPOT*y_spot.value[s] + KOSTEN_LEERLAUF*y_leer.value[s]:,.0f}"
    +                         for s in range(S)],
    +    })
    +    print(tabelle.to_string(index=False))
    +
    +    # --- Vergleich mit Alternativstrategien (berechnet, nicht behauptet) --
    +    print("\n" + "-" * 82)
    +    print("Vergleich verschiedener Planungsstrategien:")
    +    print(f"{'Strategie':<34} {'Kapazitaet':>11} {'Erw. Kosten':>14} {'Mehrkosten':>13}")
    +    print("-" * 82)
    +
    +    optimal = problem.value
    +    strategien = [
    +        ("Stochastisch optimal", kapazitaet),
    +        ("Naiv: Mittelwert einsetzen", mittelwert),
    +        ("Vorsichtig: Worst Case abdecken", float(BEDARF.max())),
    +        ("Optimistisch: Bestfall", float(BEDARF.min())),
    +    ]
    +    for name, kap in strategien:
    +        kosten = kosten_bei(kap)
    +        print(f"{name:<34} {kap:>11.1f} {kosten:>14,.0f} "
    +              f"{kosten - optimal:>+13,.0f}")
    +
    +    # --- EVPI: Was waere perfekte Voraussicht wert? ----------------------
    +    # Bei perfekter Information wuerde man je Szenario genau den Bedarf kaufen.
    +    kosten_perfekt = float(WAHRSCHEINLICHKEIT @ (KOSTEN_VORAB * BEDARF))
    +    evpi = optimal - kosten_perfekt
    +    print("-" * 82)
    +    print(f"Kosten bei perfekter Voraussicht:      {kosten_perfekt:>10,.0f} EUR")
    +    print(f"Wert perfekter Information (EVPI):     {evpi:>10,.0f} EUR "
    +          f"({evpi/optimal*100:.1f} % der Kosten)")
    +    print("  -> So viel duerfte eine perfekte Bedarfsprognose hoechstens kosten.")
    +
    +    # --- Automatische Interpretation --------------------------------------
    +    print("-" * 82)
    +    if kapazitaet > mittelwert + 1e-6:
    +        print(f"Analyse: Der Solver waehlt {kapazitaet:.0f} Einheiten und damit MEHR als")
    +        print(f"den Mittelwert ({mittelwert:.0f}), weil Unterdeckung ({KOSTEN_SPOT:.0f} EUR)")
    +        print(f"deutlich teurer ist als Leerlauf ({KOSTEN_LEERLAUF:.0f} EUR).")
    +    elif kapazitaet < mittelwert - 1e-6:
    +        print(f"Analyse: Der Solver waehlt {kapazitaet:.0f} und damit WENIGER als den")
    +        print(f"Mittelwert ({mittelwert:.0f}) - Leerlauf ist hier teurer als Zukauf.")
    +    else:
    +        print("Analyse: Kapazitaet entspricht dem Mittelwert (symmetrische Kosten).")
    +    print("=" * 82)

    Erwartete Ausgabe:

    ==================================================================================
          STOCHASTISCHE TWO-STAGE OPTIMIERUNG (KAPAZITAETSPLANUNG)
    @@ -17028,105 +17344,105 @@ deutlich teurer ist als Leerlauf (5 EUR).
     = \hat{\boldsymbol{\mu}}^\top\mathbf{w} - \boldsymbol{\delta}^\top\mathbf{w}
     

    Das Min-Max-Problem wird damit zu einem gewöhnlichen Optimierungsproblem mit einem Abzugsterm. Genau das macht robuste Optimierung praktikabel.

    -
    #!/usr/bin/env python3
    -
    -# Robuste_Optimierung.py
    -"""
    -Kapitel Unsicherheit: Robuste Portfolio-Optimierung.
    -
    -Vergleicht drei Haltungen zur Unsicherheit:
    -  (a) nominal   - vertraut den Punktschaetzungen blind
    -  (b) robust    - sichert gegen Box-Unsicherheit ab (Worst Case)
    -  (c) stochastisch - optimiert den Erwartungswert ueber Szenarien
    -
    -und misst den "Preis der Robustheit": Wie viel Ertrag kostet die Absicherung
    -im Normalfall - und wie viel Verlust erspart sie im Ernstfall?
    -"""
    -
    -import cvxpy as cp
    -import numpy as np
    -
    -ASSETS = ["Aktien Welt", "Anleihen", "Rohstoffe", "Immobilien"]
    -MU_SCHAETZUNG = np.array([0.085, 0.030, 0.055, 0.060])   # Punktschaetzung
    -UNSICHERHEIT = np.array([0.040, 0.008, 0.045, 0.025])    # +/- delta je Titel
    -VOLA = np.array([0.17, 0.05, 0.22, 0.12])
    -KORR = np.array([
    -    [1.00, -0.15, 0.35, 0.55],
    -    [-0.15, 1.00, -0.05, 0.10],
    -    [0.35, -0.05, 1.00, 0.25],
    -    [0.55, 0.10, 0.25, 1.00],
    -])
    -SIGMA = np.diag(VOLA) @ KORR @ np.diag(VOLA)
    -LAMBDA = 4.0        # Risikoaversion
    -N = len(ASSETS)
    -
    -
    -def optimiere(mu_effektiv):
    -    """Standard-Mean-Variance mit vorgegebenem Renditevektor."""
    -    w = cp.Variable(N, nonneg=True)
    -    ziel = cp.Maximize(mu_effektiv @ w - 0.5 * LAMBDA * cp.quad_form(w, SIGMA))
    -    problem = cp.Problem(ziel, [cp.sum(w) == 1])
    -    problem.solve()
    -    return w.value
    -
    -
    -def kennzahlen(w, mu):
    -    ertrag = float(mu @ w)
    -    risiko = float(np.sqrt(w @ SIGMA @ w))
    -    return ertrag, risiko
    -
    -
    -if __name__ == "__main__":
    -    print("=" * 88)
    -    print("  ROBUSTE vs. NOMINALE PORTFOLIO-OPTIMIERUNG")
    -    print("=" * 88)
    -    print(f"{'Asset':<14} {'Erw. Rendite':>14} {'Unsicherheit':>14} "
    -          f"{'Worst Case':>12} {'Volatilitaet':>13}")
    -    print("-" * 88)
    -    for i, name in enumerate(ASSETS):
    -        print(f"{name:<14} {MU_SCHAETZUNG[i]*100:>13.1f} % "
    -              f"{'+/- ' + format(UNSICHERHEIT[i]*100, '.1f') + ' %':>14} "
    -              f"{(MU_SCHAETZUNG[i]-UNSICHERHEIT[i])*100:>11.1f} % "
    -              f"{VOLA[i]*100:>12.1f} %")
    -
    -    # (a) nominal: vertraut den Schaetzungen
    -    w_nominal = optimiere(MU_SCHAETZUNG)
    -    # (b) robust: rechnet mit dem Worst Case der Box-Unsicherheitsmenge
    -    w_robust = optimiere(MU_SCHAETZUNG - UNSICHERHEIT)
    -
    -    print("\n" + "-" * 88)
    -    print(f"{'':<14} {'nominal':>22} {'robust':>22}")
    -    print("-" * 88)
    -    for i, name in enumerate(ASSETS):
    -        print(f"{name:<14} {w_nominal[i]*100:>21.1f} % {w_robust[i]*100:>21.1f} %")
    -
    -    # --- Bewertung in beiden Welten --------------------------------------
    -    mu_worst = MU_SCHAETZUNG - UNSICHERHEIT
    -    e_nom_gut, r_nom = kennzahlen(w_nominal, MU_SCHAETZUNG)
    -    e_rob_gut, r_rob = kennzahlen(w_robust, MU_SCHAETZUNG)
    -    e_nom_schlecht, _ = kennzahlen(w_nominal, mu_worst)
    -    e_rob_schlecht, _ = kennzahlen(w_robust, mu_worst)
    -
    -    print("\n" + "-" * 88)
    -    print(f"{'Bewertung':<34} {'nominales Portfolio':>22} {'robustes Portfolio':>22}")
    -    print("-" * 88)
    -    print(f"{'Ertrag, wenn Schaetzung stimmt':<34} {e_nom_gut*100:>21.2f} % "
    -          f"{e_rob_gut*100:>21.2f} %")
    -    print(f"{'Ertrag im Worst Case':<34} {e_nom_schlecht*100:>21.2f} % "
    -          f"{e_rob_schlecht*100:>21.2f} %")
    -    print(f"{'Volatilitaet':<34} {r_nom*100:>21.2f} % {r_rob*100:>21.2f} %")
    -
    -    print("\n" + "-" * 88)
    -    print(f"Preis der Robustheit (Ertragsverzicht im Normalfall): "
    -          f"{(e_nom_gut - e_rob_gut)*100:+.2f} Prozentpunkte")
    -    print(f"Nutzen der Robustheit (Vorteil im Worst Case):        "
    -          f"{(e_rob_schlecht - e_nom_schlecht)*100:+.2f} Prozentpunkte")
    -    verhaeltnis = ((e_rob_schlecht - e_nom_schlecht)
    -                   / max(e_nom_gut - e_rob_gut, 1e-9))
    -    print(f"Verhaeltnis Nutzen/Preis: {verhaeltnis:.2f}")
    -    print("  -> Werte > 1 bedeuten: Die Absicherung bringt im Ernstfall mehr,")
    -    print("     als sie im Normalfall kostet.")
    -    print("=" * 88)
    +
    #!/usr/bin/env python3
    +
    +# Robuste_Optimierung.py
    +"""
    +Kapitel Unsicherheit: Robuste Portfolio-Optimierung.
    +
    +Vergleicht drei Haltungen zur Unsicherheit:
    +  (a) nominal   - vertraut den Punktschaetzungen blind
    +  (b) robust    - sichert gegen Box-Unsicherheit ab (Worst Case)
    +  (c) stochastisch - optimiert den Erwartungswert ueber Szenarien
    +
    +und misst den "Preis der Robustheit": Wie viel Ertrag kostet die Absicherung
    +im Normalfall - und wie viel Verlust erspart sie im Ernstfall?
    +"""
    +
    +import cvxpy as cp
    +import numpy as np
    +
    +ASSETS = ["Aktien Welt", "Anleihen", "Rohstoffe", "Immobilien"]
    +MU_SCHAETZUNG = np.array([0.085, 0.030, 0.055, 0.060])   # Punktschaetzung
    +UNSICHERHEIT = np.array([0.040, 0.008, 0.045, 0.025])    # +/- delta je Titel
    +VOLA = np.array([0.17, 0.05, 0.22, 0.12])
    +KORR = np.array([
    +    [1.00, -0.15, 0.35, 0.55],
    +    [-0.15, 1.00, -0.05, 0.10],
    +    [0.35, -0.05, 1.00, 0.25],
    +    [0.55, 0.10, 0.25, 1.00],
    +])
    +SIGMA = np.diag(VOLA) @ KORR @ np.diag(VOLA)
    +LAMBDA = 4.0        # Risikoaversion
    +N = len(ASSETS)
    +
    +
    +def optimiere(mu_effektiv):
    +    """Standard-Mean-Variance mit vorgegebenem Renditevektor."""
    +    w = cp.Variable(N, nonneg=True)
    +    ziel = cp.Maximize(mu_effektiv @ w - 0.5 * LAMBDA * cp.quad_form(w, SIGMA))
    +    problem = cp.Problem(ziel, [cp.sum(w) == 1])
    +    problem.solve()
    +    return w.value
    +
    +
    +def kennzahlen(w, mu):
    +    ertrag = float(mu @ w)
    +    risiko = float(np.sqrt(w @ SIGMA @ w))
    +    return ertrag, risiko
    +
    +
    +if __name__ == "__main__":
    +    print("=" * 88)
    +    print("  ROBUSTE vs. NOMINALE PORTFOLIO-OPTIMIERUNG")
    +    print("=" * 88)
    +    print(f"{'Asset':<14} {'Erw. Rendite':>14} {'Unsicherheit':>14} "
    +          f"{'Worst Case':>12} {'Volatilitaet':>13}")
    +    print("-" * 88)
    +    for i, name in enumerate(ASSETS):
    +        print(f"{name:<14} {MU_SCHAETZUNG[i]*100:>13.1f} % "
    +              f"{'+/- ' + format(UNSICHERHEIT[i]*100, '.1f') + ' %':>14} "
    +              f"{(MU_SCHAETZUNG[i]-UNSICHERHEIT[i])*100:>11.1f} % "
    +              f"{VOLA[i]*100:>12.1f} %")
    +
    +    # (a) nominal: vertraut den Schaetzungen
    +    w_nominal = optimiere(MU_SCHAETZUNG)
    +    # (b) robust: rechnet mit dem Worst Case der Box-Unsicherheitsmenge
    +    w_robust = optimiere(MU_SCHAETZUNG - UNSICHERHEIT)
    +
    +    print("\n" + "-" * 88)
    +    print(f"{'':<14} {'nominal':>22} {'robust':>22}")
    +    print("-" * 88)
    +    for i, name in enumerate(ASSETS):
    +        print(f"{name:<14} {w_nominal[i]*100:>21.1f} % {w_robust[i]*100:>21.1f} %")
    +
    +    # --- Bewertung in beiden Welten --------------------------------------
    +    mu_worst = MU_SCHAETZUNG - UNSICHERHEIT
    +    e_nom_gut, r_nom = kennzahlen(w_nominal, MU_SCHAETZUNG)
    +    e_rob_gut, r_rob = kennzahlen(w_robust, MU_SCHAETZUNG)
    +    e_nom_schlecht, _ = kennzahlen(w_nominal, mu_worst)
    +    e_rob_schlecht, _ = kennzahlen(w_robust, mu_worst)
    +
    +    print("\n" + "-" * 88)
    +    print(f"{'Bewertung':<34} {'nominales Portfolio':>22} {'robustes Portfolio':>22}")
    +    print("-" * 88)
    +    print(f"{'Ertrag, wenn Schaetzung stimmt':<34} {e_nom_gut*100:>21.2f} % "
    +          f"{e_rob_gut*100:>21.2f} %")
    +    print(f"{'Ertrag im Worst Case':<34} {e_nom_schlecht*100:>21.2f} % "
    +          f"{e_rob_schlecht*100:>21.2f} %")
    +    print(f"{'Volatilitaet':<34} {r_nom*100:>21.2f} % {r_rob*100:>21.2f} %")
    +
    +    print("\n" + "-" * 88)
    +    print(f"Preis der Robustheit (Ertragsverzicht im Normalfall): "
    +          f"{(e_nom_gut - e_rob_gut)*100:+.2f} Prozentpunkte")
    +    print(f"Nutzen der Robustheit (Vorteil im Worst Case):        "
    +          f"{(e_rob_schlecht - e_nom_schlecht)*100:+.2f} Prozentpunkte")
    +    verhaeltnis = ((e_rob_schlecht - e_nom_schlecht)
    +                   / max(e_nom_gut - e_rob_gut, 1e-9))
    +    print(f"Verhaeltnis Nutzen/Preis: {verhaeltnis:.2f}")
    +    print("  -> Werte > 1 bedeuten: Die Absicherung bringt im Ernstfall mehr,")
    +    print("     als sie im Normalfall kostet.")
    +    print("=" * 88)

    ⚠️ Der Preis der Robustheit Robuste Modelle sind konservativ. Wer gegen den absoluten Worst Case absichert, zahlt im Normalfall drauf — oft erheblich. Drei Gegenmittel: 1. Unsicherheitsmenge realistisch wählen. Nicht „alles kann passieren“, sondern „Abweichungen bis zu einer Standardabweichung“. 2. Budgeted Uncertainty (Bertsimas/Sim): Man nimmt an, dass höchstens \Gamma von n Parametern gleichzeitig ihren Worst Case annehmen. \Gamma steuert die Vorsicht stufenlos. 3. Nutzen und Preis immer beziffern — genau wie im Programm oben. Ohne diese zwei Zahlen ist die Diskussion „robust oder nicht“ Glaubenssache.

    @@ -17166,234 +17482,234 @@ z_s &\in \{0,1\}

    Aus dem Kegelproblem wird ein MILP mit S Binärvariablen. Für M gilt, was Kapitel 6 über die Big-M-Falle sagt: so klein wie möglich. Hier ist M = b die kleinste gültige Schranke, denn \mathbf{a}_s^\top\mathbf{x} \ge 0 — größer kann eine Verletzung gar nicht ausfallen.

    Der Fall: ein Kraftwerkspark mit 500 MW Zusage

    -
    #!/usr/bin/env python3
    -
    -# Chance_Constraints.py
    -"""
    -Kapitel Unsicherheit: Wahrscheinlichkeitsbeschraenkungen.
    -
    -Das Management fragt selten "was ist im Mittel am besten?", sondern "mit
    -welcher Sicherheit haelt der Plan?". Genau das formuliert eine Chance
    -Constraint:  P(Versorgung >= Bedarf) >= 1 - alpha.
    -
    -Gezeigt werden beide Wege dorthin, an derselben Instanz:
    -  (a) analytisch      - unter Normalverteilungsannahme wird daraus eine
    -                        Second-Order-Cone-Bedingung, loesbar mit CVXPY
    -  (b) szenariobasiert - Big-M mit Binaervariablen, fuer beliebige
    -                        empirische Verteilungen
    -
    -Und die beiden Messungen, auf die es ankommt: Was kostet ein Prozentpunkt
    -Versorgungssicherheit - und haelt die Zusage auch dann, wenn die Verteilung
    -nicht normal ist?
    -
    -Solver: CLARABEL (Kegel) und SciPy/HiGHS (gemischt-ganzzahlig). Weder ortools
    -noch ein direkter highspy-Import, damit alles in einem Prozess laeuft.
    -"""
    -
    -import cvxpy as cp
    -import numpy as np
    -from scipy.stats import norm
    -
    -# --- Der Kraftwerkspark ----------------------------------------------------
    -# Die Verfuegbarkeit ist der Anteil, den eine installierte MW im Mittel
    -# wirklich liefert: bei Wind und Sonne klein und stark schwankend, bei Gas und
    -# Biomasse gross und stabil. Die Ausbaugrenze ist der Standort - Flaeche,
    -# Genehmigung, Brennstoffversorgung.
    -TECHNIK = ["Gaskraftwerk", "Windpark", "Solarpark", "Biomasse"]
    -KOSTEN = np.array([65_000.0, 24_000.0, 12_000.0, 60_000.0])   # EUR je MW und Jahr
    -VERFUEGBAR = np.array([0.92, 0.35, 0.18, 0.85])
    -STREUUNG = np.array([0.05, 0.16, 0.10, 0.04])
    -GRENZE = np.array([400.0, 250.0, 600.0, 350.0])               # MW
    -
    -# Wind und Sonne sind leicht gegenlaeufig: Ein Tiefdruckgebiet bringt Wind und
    -# Wolken zugleich. Genau diese Korrelation macht die Bedingung zu einem Kegel
    -# und nicht zu einer Summe unabhaengiger Einzelzuschlaege.
    -KORRELATION = np.array([
    -    [1.00,  0.00,  0.00, 0.05],
    -    [0.00,  1.00, -0.25, 0.00],
    -    [0.00, -0.25,  1.00, 0.00],
    -    [0.05,  0.00,  0.00, 1.00],
    -])
    -SIGMA = np.diag(STREUUNG) @ KORRELATION @ np.diag(STREUUNG)
    -WURZEL = np.linalg.cholesky(SIGMA)          # L mit L @ L.T == SIGMA
    -
    -BEDARF = 500.0          # MW, die gesichert bereitstehen muessen
    -N = len(TECHNIK)
    -
    -# Die Kaeltewelle mit Dunkelflaute: selten, aber sie trifft alles zugleich.
    -# Wind und Sonne brechen fast vollstaendig weg - und, das ist der Punkt, das
    -# Gaskraftwerk liefert ebenfalls weniger, weil bei Frost der Netzdruck faellt.
    -# Eine Kovarianzmatrix mit Korrelationen um null kann das nicht ausdruecken.
    -P_KAELTEWELLE = 0.08
    -EINBRUCH = np.array([0.72, 0.10, 0.15, 0.88])
    -
    -
    -def mittelwertplan():
    -    """Plant mit den Erwartungswerten - ignoriert die Streuung vollstaendig."""
    -    x = cp.Variable(N, nonneg=True)
    -    problem = cp.Problem(cp.Minimize(KOSTEN @ x),
    -                         [VERFUEGBAR @ x >= BEDARF, x <= GRENZE])
    -    problem.solve(solver=cp.CLARABEL)
    -    return problem.value, x.value
    -
    -
    -def chance_constraint_analytisch(alpha):
    -    """
    -    P(a^T x >= BEDARF) >= 1 - alpha  unter a ~ N(VERFUEGBAR, SIGMA).
    -
    -    Aequivalent zu:  VERFUEGBAR^T x - z * ||L^T x||_2 >= BEDARF
    -    mit z = Phi^-1(1 - alpha). Das ist eine Second-Order-Cone-Bedingung: Der
    -    Sicherheitszuschlag ist eine Norm ueber x, kein fester Aufschlag je Anlage.
    -    Deshalb belohnt sie Mischung - zwei gegenlaeufige Quellen schwanken
    -    gemeinsam weniger als jede fuer sich.
    -    """
    -    x = cp.Variable(N, nonneg=True)
    -    z = norm.ppf(1 - alpha)
    -    bedingung = VERFUEGBAR @ x - z * cp.norm(WURZEL.T @ x, 2) >= BEDARF
    -    problem = cp.Problem(cp.Minimize(KOSTEN @ x), [bedingung, x <= GRENZE])
    -    problem.solve(solver=cp.CLARABEL)
    -    return problem.value, x.value
    -
    -
    -def chance_constraint_szenarien(a, alpha):
    -    """
    -    Dieselbe Zusage ohne Verteilungsannahme: Fuer jedes Szenario s sagt eine
    -    Binaervariable z_s, ob es verletzt werden darf.
    -
    -        a_s^T x >= BEDARF - M * z_s        fuer alle s
    -        sum_s z_s <= alpha * S
    -
    -    M = BEDARF ist die kleinstmoegliche gueltige Schranke, denn a_s^T x >= 0:
    -    Groesser kann eine Verletzung gar nicht ausfallen. Ein unnoetig grosses M
    -    wuerde die LP-Relaxierung aufweichen und die Suche verlangsamen - die
    -    Big-M-Falle aus dem Kapitel Gemischt-ganzzahlige Optimierung.
    -    """
    -    S = len(a)
    -    x = cp.Variable(N, nonneg=True)
    -    z = cp.Variable(S, boolean=True)
    -    problem = cp.Problem(
    -        cp.Minimize(KOSTEN @ x),
    -        [a @ x >= BEDARF - BEDARF * z, cp.sum(z) <= alpha * S, x <= GRENZE])
    -    problem.solve(solver=cp.SCIPY)
    -    return problem.value, x.value
    -
    -
    -def ziehe_wetter(anzahl, seed):
    -    """Mischverteilung: normales Wetter, mit P_KAELTEWELLE ein Einbruch."""
    -    rng = np.random.default_rng(seed)
    -    a = rng.multivariate_normal(VERFUEGBAR, SIGMA, size=anzahl)
    -    getroffen = rng.random(anzahl) < P_KAELTEWELLE
    -    a[getroffen] *= EINBRUCH
    -    return np.clip(a, 0.0, 1.0)
    -
    -
    -def sicherheit(x, a):
    -    """Anteil der Szenarien, in denen der Plan den Bedarf deckt."""
    -    return float(np.mean(a @ x >= BEDARF))
    -
    -
    -KOPF = (f"{'Plan':<20} {'Kosten (EUR)':>13}  {'Gas':>4} {'Wind':>4} "
    -        f"{'Sol':>4} {'Bio':>4}  {'Normalwelt':>11} {'echte Welt':>11}")
    -
    -
    -def zeile(name, kosten, x, in_normal, in_echt):
    -    mix = " ".join(f"{w:4.0f}" for w in x)
    -    return (f"{name:<20} {kosten:>13,.0f}  {mix}  "
    -            f"{in_normal * 100:>9.2f} % {in_echt * 100:>9.2f} %")
    -
    -
    -if __name__ == "__main__":
    -    print("=" * 78)
    -    print("  WAHRSCHEINLICHKEITSBESCHRAENKUNGEN IM KRAFTWERKSPARK")
    -    print("=" * 78)
    -    print(f"Gesichert bereitzustellen: {BEDARF:.0f} MW\n")
    -    print(f"{'Technologie':<14} {'EUR/MW':>8} {'verfuegbar':>11} {'Streuung':>9} "
    -          f"{'Grenze':>8} {'EUR je erw. MW':>15}")
    -    print("-" * 78)
    -    for i, name in enumerate(TECHNIK):
    -        print(f"{name:<14} {KOSTEN[i]:>8,.0f} {VERFUEGBAR[i]:>10.2f}  "
    -              f"{STREUUNG[i]:>8.2f} {GRENZE[i]:>7.0f} "
    -              f"{KOSTEN[i] / VERFUEGBAR[i]:>15,.0f}")
    -    print(f"\nVollausbau liefert im Mittel {VERFUEGBAR @ GRENZE:.0f} MW, "
    -          f"in der Kaeltewelle {(VERFUEGBAR * EINBRUCH) @ GRENZE:.0f} MW.")
    -
    -    # Zwei grosse, unabhaengige Testmengen. An ihnen wird JEDER Plan gemessen -
    -    # einmal in der unterstellten Normalwelt, einmal in der echten Verteilung.
    -    echt = ziehe_wetter(200_000, seed=771)
    -    normalwelt = np.random.default_rng(4711).multivariate_normal(
    -        VERFUEGBAR, SIGMA, size=200_000)
    -
    -    print("\n" + "=" * 78)
    -    print("  (1) Mittelwertplan und Chance Constraints im Vergleich")
    -    print("=" * 78)
    -    print(KOPF)
    -    print("-" * 78)
    -
    -    k_mittel, x_mittel = mittelwertplan()
    -    stufen = [("Mittelwert", k_mittel, sicherheit(x_mittel, normalwelt))]
    -    print(zeile("Mittelwert", k_mittel, x_mittel,
    -                sicherheit(x_mittel, normalwelt), sicherheit(x_mittel, echt)))
    -
    -    plaene = {}
    -    for alpha in (0.20, 0.10, 0.05, 0.01):
    -        kosten, x = chance_constraint_analytisch(alpha)
    -        plaene[alpha] = (kosten, x)
    -        quote_normal = sicherheit(x, normalwelt)
    -        stufen.append((f"Zusage {(1 - alpha) * 100:.0f} %", kosten, quote_normal))
    -        print(zeile(f"Zusage {(1 - alpha) * 100:.0f} %", kosten, x,
    -                    quote_normal, sicherheit(x, echt)))
    -
    -    print("\n  In der Normalwelt trifft jede Zusage ihren Wert - das Verfahren")
    -    print("  rechnet richtig. Die Spalte 'echte Welt' kommt in Teil (3).")
    -
    -    print("\n" + "=" * 78)
    -    print("  (2) Was kostet ein Prozentpunkt Versorgungssicherheit?")
    -    print("=" * 78)
    -    print(f"{'von -> nach':<26} {'Prozentpunkte':>13} {'Mehrkosten':>13} "
    -          f"{'EUR je Punkt':>13}")
    -    print("-" * 78)
    -    for (n1, k1, q1), (n2, k2, q2) in zip(stufen, stufen[1:]):
    -        d_punkte = (q2 - q1) * 100
    -        d_kosten = k2 - k1
    -        print(f"{n1 + ' -> ' + n2:<26} {d_punkte:>13.2f} {d_kosten:>13,.0f} "
    -              f"{d_kosten / d_punkte:>13,.0f}")
    -    erst = (stufen[1][1] - stufen[0][1]) / ((stufen[1][2] - stufen[0][2]) * 100)
    -    letzt = (stufen[-1][1] - stufen[-2][1]) / ((stufen[-1][2] - stufen[-2][2]) * 100)
    -    print(f"\n  Der letzte Prozentpunkt kostet das {letzt / erst:.1f}-fache des ersten.")
    -    print("  Sicherheit ist konvex bepreist - genau deshalb muss jemand")
    -    print("  entscheiden, wie viel davon das Unternehmen kaufen will.")
    -
    -    print("\n" + "=" * 78)
    -    print("  (3) Und wenn die Verteilung nicht normal ist?")
    -    print("=" * 78)
    -    k_soc, x_soc = plaene[0.05]
    -    print(f"Die echte Welt kennt die Kaeltewelle mit Dunkelflaute: in "
    -          f"{P_KAELTEWELLE * 100:.0f} % der Faelle liefern")
    -    print(f"Wind {EINBRUCH[1] * 100:.0f} %, Sonne {EINBRUCH[2] * 100:.0f} % "
    -          f"und - entscheidend - auch das Gaskraftwerk nur "
    -          f"{EINBRUCH[0] * 100:.0f} %")
    -    print("des Ueblichen. Alles bricht gleichzeitig ein.\n")
    -
    -    a_bau = ziehe_wetter(400, seed=20260908)
    -    k_sz, x_sz = chance_constraint_szenarien(a_bau, 0.05)
    -
    -    print(KOPF)
    -    print("-" * 78)
    -    print(zeile("SOC, Zusage 95 %", k_soc, x_soc,
    -                sicherheit(x_soc, normalwelt), sicherheit(x_soc, echt)))
    -    print(zeile("Szenarien, 95 %", k_sz, x_sz,
    -                sicherheit(x_sz, normalwelt), sicherheit(x_sz, echt)))
    -
    -    print(f"\n  Der SOC-Plan verspricht 95 % und haelt "
    -          f"{sicherheit(x_soc, echt) * 100:.1f} %. Nicht das Verfahren ist")
    -    print(f"  falsch, sondern die Annahme: In der Normalwelt liefert derselbe")
    -    print(f"  Plan {sicherheit(x_soc, normalwelt) * 100:.1f} %.")
    -    print(f"\n  Der Szenarioplan ({len(a_bau)} Szenarien, {len(a_bau)} "
    -          f"Binaervariablen) kommt auf")
    -    print(f"  {sicherheit(x_sz, echt) * 100:.1f} % - und kostet dafuer "
    -          f"{(k_sz / k_soc - 1) * 100:+.1f} %. Er kauft keine Windkraft mehr:")
    -    print("  Was im Ernstfall ausfaellt, hilft der Zusage nicht.")
    -    print("=" * 78)
    +
    #!/usr/bin/env python3
    +
    +# Chance_Constraints.py
    +"""
    +Kapitel Unsicherheit: Wahrscheinlichkeitsbeschraenkungen.
    +
    +Das Management fragt selten "was ist im Mittel am besten?", sondern "mit
    +welcher Sicherheit haelt der Plan?". Genau das formuliert eine Chance
    +Constraint:  P(Versorgung >= Bedarf) >= 1 - alpha.
    +
    +Gezeigt werden beide Wege dorthin, an derselben Instanz:
    +  (a) analytisch      - unter Normalverteilungsannahme wird daraus eine
    +                        Second-Order-Cone-Bedingung, loesbar mit CVXPY
    +  (b) szenariobasiert - Big-M mit Binaervariablen, fuer beliebige
    +                        empirische Verteilungen
    +
    +Und die beiden Messungen, auf die es ankommt: Was kostet ein Prozentpunkt
    +Versorgungssicherheit - und haelt die Zusage auch dann, wenn die Verteilung
    +nicht normal ist?
    +
    +Solver: CLARABEL (Kegel) und SciPy/HiGHS (gemischt-ganzzahlig). Weder ortools
    +noch ein direkter highspy-Import, damit alles in einem Prozess laeuft.
    +"""
    +
    +import cvxpy as cp
    +import numpy as np
    +from scipy.stats import norm
    +
    +# --- Der Kraftwerkspark ----------------------------------------------------
    +# Die Verfuegbarkeit ist der Anteil, den eine installierte MW im Mittel
    +# wirklich liefert: bei Wind und Sonne klein und stark schwankend, bei Gas und
    +# Biomasse gross und stabil. Die Ausbaugrenze ist der Standort - Flaeche,
    +# Genehmigung, Brennstoffversorgung.
    +TECHNIK = ["Gaskraftwerk", "Windpark", "Solarpark", "Biomasse"]
    +KOSTEN = np.array([65_000.0, 24_000.0, 12_000.0, 60_000.0])   # EUR je MW und Jahr
    +VERFUEGBAR = np.array([0.92, 0.35, 0.18, 0.85])
    +STREUUNG = np.array([0.05, 0.16, 0.10, 0.04])
    +GRENZE = np.array([400.0, 250.0, 600.0, 350.0])               # MW
    +
    +# Wind und Sonne sind leicht gegenlaeufig: Ein Tiefdruckgebiet bringt Wind und
    +# Wolken zugleich. Genau diese Korrelation macht die Bedingung zu einem Kegel
    +# und nicht zu einer Summe unabhaengiger Einzelzuschlaege.
    +KORRELATION = np.array([
    +    [1.00,  0.00,  0.00, 0.05],
    +    [0.00,  1.00, -0.25, 0.00],
    +    [0.00, -0.25,  1.00, 0.00],
    +    [0.05,  0.00,  0.00, 1.00],
    +])
    +SIGMA = np.diag(STREUUNG) @ KORRELATION @ np.diag(STREUUNG)
    +WURZEL = np.linalg.cholesky(SIGMA)          # L mit L @ L.T == SIGMA
    +
    +BEDARF = 500.0          # MW, die gesichert bereitstehen muessen
    +N = len(TECHNIK)
    +
    +# Die Kaeltewelle mit Dunkelflaute: selten, aber sie trifft alles zugleich.
    +# Wind und Sonne brechen fast vollstaendig weg - und, das ist der Punkt, das
    +# Gaskraftwerk liefert ebenfalls weniger, weil bei Frost der Netzdruck faellt.
    +# Eine Kovarianzmatrix mit Korrelationen um null kann das nicht ausdruecken.
    +P_KAELTEWELLE = 0.08
    +EINBRUCH = np.array([0.72, 0.10, 0.15, 0.88])
    +
    +
    +def mittelwertplan():
    +    """Plant mit den Erwartungswerten - ignoriert die Streuung vollstaendig."""
    +    x = cp.Variable(N, nonneg=True)
    +    problem = cp.Problem(cp.Minimize(KOSTEN @ x),
    +                         [VERFUEGBAR @ x >= BEDARF, x <= GRENZE])
    +    problem.solve(solver=cp.CLARABEL)
    +    return problem.value, x.value
    +
    +
    +def chance_constraint_analytisch(alpha):
    +    """
    +    P(a^T x >= BEDARF) >= 1 - alpha  unter a ~ N(VERFUEGBAR, SIGMA).
    +
    +    Aequivalent zu:  VERFUEGBAR^T x - z * ||L^T x||_2 >= BEDARF
    +    mit z = Phi^-1(1 - alpha). Das ist eine Second-Order-Cone-Bedingung: Der
    +    Sicherheitszuschlag ist eine Norm ueber x, kein fester Aufschlag je Anlage.
    +    Deshalb belohnt sie Mischung - zwei gegenlaeufige Quellen schwanken
    +    gemeinsam weniger als jede fuer sich.
    +    """
    +    x = cp.Variable(N, nonneg=True)
    +    z = norm.ppf(1 - alpha)
    +    bedingung = VERFUEGBAR @ x - z * cp.norm(WURZEL.T @ x, 2) >= BEDARF
    +    problem = cp.Problem(cp.Minimize(KOSTEN @ x), [bedingung, x <= GRENZE])
    +    problem.solve(solver=cp.CLARABEL)
    +    return problem.value, x.value
    +
    +
    +def chance_constraint_szenarien(a, alpha):
    +    """
    +    Dieselbe Zusage ohne Verteilungsannahme: Fuer jedes Szenario s sagt eine
    +    Binaervariable z_s, ob es verletzt werden darf.
    +
    +        a_s^T x >= BEDARF - M * z_s        fuer alle s
    +        sum_s z_s <= alpha * S
    +
    +    M = BEDARF ist die kleinstmoegliche gueltige Schranke, denn a_s^T x >= 0:
    +    Groesser kann eine Verletzung gar nicht ausfallen. Ein unnoetig grosses M
    +    wuerde die LP-Relaxierung aufweichen und die Suche verlangsamen - die
    +    Big-M-Falle aus dem Kapitel Gemischt-ganzzahlige Optimierung.
    +    """
    +    S = len(a)
    +    x = cp.Variable(N, nonneg=True)
    +    z = cp.Variable(S, boolean=True)
    +    problem = cp.Problem(
    +        cp.Minimize(KOSTEN @ x),
    +        [a @ x >= BEDARF - BEDARF * z, cp.sum(z) <= alpha * S, x <= GRENZE])
    +    problem.solve(solver=cp.SCIPY)
    +    return problem.value, x.value
    +
    +
    +def ziehe_wetter(anzahl, seed):
    +    """Mischverteilung: normales Wetter, mit P_KAELTEWELLE ein Einbruch."""
    +    rng = np.random.default_rng(seed)
    +    a = rng.multivariate_normal(VERFUEGBAR, SIGMA, size=anzahl)
    +    getroffen = rng.random(anzahl) < P_KAELTEWELLE
    +    a[getroffen] *= EINBRUCH
    +    return np.clip(a, 0.0, 1.0)
    +
    +
    +def sicherheit(x, a):
    +    """Anteil der Szenarien, in denen der Plan den Bedarf deckt."""
    +    return float(np.mean(a @ x >= BEDARF))
    +
    +
    +KOPF = (f"{'Plan':<20} {'Kosten (EUR)':>13}  {'Gas':>4} {'Wind':>4} "
    +        f"{'Sol':>4} {'Bio':>4}  {'Normalwelt':>11} {'echte Welt':>11}")
    +
    +
    +def zeile(name, kosten, x, in_normal, in_echt):
    +    mix = " ".join(f"{w:4.0f}" for w in x)
    +    return (f"{name:<20} {kosten:>13,.0f}  {mix}  "
    +            f"{in_normal * 100:>9.2f} % {in_echt * 100:>9.2f} %")
    +
    +
    +if __name__ == "__main__":
    +    print("=" * 78)
    +    print("  WAHRSCHEINLICHKEITSBESCHRAENKUNGEN IM KRAFTWERKSPARK")
    +    print("=" * 78)
    +    print(f"Gesichert bereitzustellen: {BEDARF:.0f} MW\n")
    +    print(f"{'Technologie':<14} {'EUR/MW':>8} {'verfuegbar':>11} {'Streuung':>9} "
    +          f"{'Grenze':>8} {'EUR je erw. MW':>15}")
    +    print("-" * 78)
    +    for i, name in enumerate(TECHNIK):
    +        print(f"{name:<14} {KOSTEN[i]:>8,.0f} {VERFUEGBAR[i]:>10.2f}  "
    +              f"{STREUUNG[i]:>8.2f} {GRENZE[i]:>7.0f} "
    +              f"{KOSTEN[i] / VERFUEGBAR[i]:>15,.0f}")
    +    print(f"\nVollausbau liefert im Mittel {VERFUEGBAR @ GRENZE:.0f} MW, "
    +          f"in der Kaeltewelle {(VERFUEGBAR * EINBRUCH) @ GRENZE:.0f} MW.")
    +
    +    # Zwei grosse, unabhaengige Testmengen. An ihnen wird JEDER Plan gemessen -
    +    # einmal in der unterstellten Normalwelt, einmal in der echten Verteilung.
    +    echt = ziehe_wetter(200_000, seed=771)
    +    normalwelt = np.random.default_rng(4711).multivariate_normal(
    +        VERFUEGBAR, SIGMA, size=200_000)
    +
    +    print("\n" + "=" * 78)
    +    print("  (1) Mittelwertplan und Chance Constraints im Vergleich")
    +    print("=" * 78)
    +    print(KOPF)
    +    print("-" * 78)
    +
    +    k_mittel, x_mittel = mittelwertplan()
    +    stufen = [("Mittelwert", k_mittel, sicherheit(x_mittel, normalwelt))]
    +    print(zeile("Mittelwert", k_mittel, x_mittel,
    +                sicherheit(x_mittel, normalwelt), sicherheit(x_mittel, echt)))
    +
    +    plaene = {}
    +    for alpha in (0.20, 0.10, 0.05, 0.01):
    +        kosten, x = chance_constraint_analytisch(alpha)
    +        plaene[alpha] = (kosten, x)
    +        quote_normal = sicherheit(x, normalwelt)
    +        stufen.append((f"Zusage {(1 - alpha) * 100:.0f} %", kosten, quote_normal))
    +        print(zeile(f"Zusage {(1 - alpha) * 100:.0f} %", kosten, x,
    +                    quote_normal, sicherheit(x, echt)))
    +
    +    print("\n  In der Normalwelt trifft jede Zusage ihren Wert - das Verfahren")
    +    print("  rechnet richtig. Die Spalte 'echte Welt' kommt in Teil (3).")
    +
    +    print("\n" + "=" * 78)
    +    print("  (2) Was kostet ein Prozentpunkt Versorgungssicherheit?")
    +    print("=" * 78)
    +    print(f"{'von -> nach':<26} {'Prozentpunkte':>13} {'Mehrkosten':>13} "
    +          f"{'EUR je Punkt':>13}")
    +    print("-" * 78)
    +    for (n1, k1, q1), (n2, k2, q2) in zip(stufen, stufen[1:]):
    +        d_punkte = (q2 - q1) * 100
    +        d_kosten = k2 - k1
    +        print(f"{n1 + ' -> ' + n2:<26} {d_punkte:>13.2f} {d_kosten:>13,.0f} "
    +              f"{d_kosten / d_punkte:>13,.0f}")
    +    erst = (stufen[1][1] - stufen[0][1]) / ((stufen[1][2] - stufen[0][2]) * 100)
    +    letzt = (stufen[-1][1] - stufen[-2][1]) / ((stufen[-1][2] - stufen[-2][2]) * 100)
    +    print(f"\n  Der letzte Prozentpunkt kostet das {letzt / erst:.1f}-fache des ersten.")
    +    print("  Sicherheit ist konvex bepreist - genau deshalb muss jemand")
    +    print("  entscheiden, wie viel davon das Unternehmen kaufen will.")
    +
    +    print("\n" + "=" * 78)
    +    print("  (3) Und wenn die Verteilung nicht normal ist?")
    +    print("=" * 78)
    +    k_soc, x_soc = plaene[0.05]
    +    print(f"Die echte Welt kennt die Kaeltewelle mit Dunkelflaute: in "
    +          f"{P_KAELTEWELLE * 100:.0f} % der Faelle liefern")
    +    print(f"Wind {EINBRUCH[1] * 100:.0f} %, Sonne {EINBRUCH[2] * 100:.0f} % "
    +          f"und - entscheidend - auch das Gaskraftwerk nur "
    +          f"{EINBRUCH[0] * 100:.0f} %")
    +    print("des Ueblichen. Alles bricht gleichzeitig ein.\n")
    +
    +    a_bau = ziehe_wetter(400, seed=20260908)
    +    k_sz, x_sz = chance_constraint_szenarien(a_bau, 0.05)
    +
    +    print(KOPF)
    +    print("-" * 78)
    +    print(zeile("SOC, Zusage 95 %", k_soc, x_soc,
    +                sicherheit(x_soc, normalwelt), sicherheit(x_soc, echt)))
    +    print(zeile("Szenarien, 95 %", k_sz, x_sz,
    +                sicherheit(x_sz, normalwelt), sicherheit(x_sz, echt)))
    +
    +    print(f"\n  Der SOC-Plan verspricht 95 % und haelt "
    +          f"{sicherheit(x_soc, echt) * 100:.1f} %. Nicht das Verfahren ist")
    +    print(f"  falsch, sondern die Annahme: In der Normalwelt liefert derselbe")
    +    print(f"  Plan {sicherheit(x_soc, normalwelt) * 100:.1f} %.")
    +    print(f"\n  Der Szenarioplan ({len(a_bau)} Szenarien, {len(a_bau)} "
    +          f"Binaervariablen) kommt auf")
    +    print(f"  {sicherheit(x_sz, echt) * 100:.1f} % - und kostet dafuer "
    +          f"{(k_sz / k_soc - 1) * 100:+.1f} %. Er kauft keine Windkraft mehr:")
    +    print("  Was im Ernstfall ausfaellt, hilft der Zusage nicht.")
    +    print("=" * 78)

    Ausgabe:

    ==============================================================================
       WAHRSCHEINLICHKEITSBESCHRAENKUNGEN IM KRAFTWERKSPARK
    @@ -17489,16 +17805,16 @@ Szenarien, 95 %         43,760,709   350    0    0  350     100.00 %     94.84 %
     

    Ein Projektleiter plant eine Umbaumaßnahme. Fünf Gewerke arbeiten parallel und unabhängig voneinander; fertig ist der Umbau, wenn das letzte fertig ist. Für jedes Gewerk liegt eine Schätzung vor: mindestens 6 Tage, wahrscheinlich 10, im schlechtesten Fall 18.

    Er rechnet: „Jedes Gewerk braucht im Mittel (6 + 10 + 18)/3 = 11{,}3 Tage. Alle laufen gleichzeitig. Also ist der Umbau nach 11,3 Tagen fertig — ich plane großzügig 12 ein.“

    Nachgerechnet mit 200 000 simulierten Umbauten:

    -
    import numpy as np
    -
    -rng = np.random.default_rng(3)
    -dauer = rng.triangular(6, 10, 18, size=(200_000, 5))    # 5 Gewerke, je eine Schaetzung
    -projekt = dauer.max(axis=1)                             # fertig, wenn das LETZTE fertig ist
    -
    -print(f"Mittelwert einer Einzeldauer:  {dauer.mean():.2f} Tage")
    -print(f"Tatsaechliche Projektdauer:    {projekt.mean():.2f} Tage")
    -print(f"90-%-Quantil:                  {np.quantile(projekt, 0.9):.2f} Tage")
    -print(f"Anteil ueber 11,34 Tage:       {(projekt > 11.34).mean() * 100:.1f} %")
    +
    import numpy as np
    +
    +rng = np.random.default_rng(3)
    +dauer = rng.triangular(6, 10, 18, size=(200_000, 5))    # 5 Gewerke, je eine Schaetzung
    +projekt = dauer.max(axis=1)                             # fertig, wenn das LETZTE fertig ist
    +
    +print(f"Mittelwert einer Einzeldauer:  {dauer.mean():.2f} Tage")
    +print(f"Tatsaechliche Projektdauer:    {projekt.mean():.2f} Tage")
    +print(f"90-%-Quantil:                  {np.quantile(projekt, 0.9):.2f} Tage")
    +print(f"Anteil ueber 11,34 Tage:       {(projekt > 11.34).mean() * 100:.1f} %")

    Ausgabe:

    Mittelwert einer Einzeldauer:  11.34 Tage
     Tatsaechliche Projektdauer:    14.38 Tage
    @@ -17618,25 +17934,25 @@ Anteil ueber 11,34 Tage:       95.5 %
    -
    kanten = {
    -    "Start": {"Nord": 4, "Sued": 6},
    -    "Nord":  {"Ost": 14, "West": 9},
    -    "Sued":  {"Ost": 3,  "West": 7},
    -    "Ost":   {"Ziel": 5},
    -    "West":  {"Ziel": 8},
    -    "Ziel":  {},
    -}
    -
    -wert, plan = {"Ziel": 0}, {}                       # Restkosten ab jedem Ort
    -for ort in ["Ost", "West", "Nord", "Sued", "Start"]:      # RUECKWAERTS!
    -    wert[ort], plan[ort] = min((k + wert[z], z) for z, k in kanten[ort].items())
    -
    -weg, ort = ["Start"], "Start"
    -while ort != "Ziel":
    -    ort = plan[ort]
    -    weg.append(ort)
    -print(" -> ".join(weg), "=", wert["Start"], "min")
    -print("Restkosten je Ort:", wert)
    +
    kanten = {
    +    "Start": {"Nord": 4, "Sued": 6},
    +    "Nord":  {"Ost": 14, "West": 9},
    +    "Sued":  {"Ost": 3,  "West": 7},
    +    "Ost":   {"Ziel": 5},
    +    "West":  {"Ziel": 8},
    +    "Ziel":  {},
    +}
    +
    +wert, plan = {"Ziel": 0}, {}                       # Restkosten ab jedem Ort
    +for ort in ["Ost", "West", "Nord", "Sued", "Start"]:      # RUECKWAERTS!
    +    wert[ort], plan[ort] = min((k + wert[z], z) for z, k in kanten[ort].items())
    +
    +weg, ort = ["Start"], "Start"
    +while ort != "Ziel":
    +    ort = plan[ort]
    +    weg.append(ort)
    +print(" -> ".join(weg), "=", wert["Start"], "min")
    +print("Restkosten je Ort:", wert)

    Ausgabe:

    Start -> Sued -> Ost -> Ziel = 14 min
     Restkosten je Ort: {'Ziel': 0, 'Ost': 5, 'West': 8, 'Nord': 17, 'Sued': 8, 'Start': 14}
    @@ -17900,91 +18216,91 @@ V_t(S_t) = \min_{a_t\in\mathcal{A}(S_t)}\Big\{\,C(S_t,a_t) \;+\; \gamma\cdot\mat

    Ergebnis: V_0(3) = 5, erreicht durch n_0 = 1 (dann n_1 = 2) oder n_0 = 2 (dann n_1 = 1). Zwei gleichwertige Optima.

    Die Lehre: Alles auf einmal zu verkaufen kostet 9, gleichmäßiges Aufteilen nur 5. Das ist der Kern des Ausführungsproblems: Bei überproportionalen Kosten lohnt sich Stückelung. Und beachten Sie, dass wir nur 4 + 4 = 8 Kombinationen bewertet haben statt aller Verkaufspfade — bei größeren Problemen ist dieser Unterschied dramatisch.

    -
    #!/usr/bin/env python3
    -
    -# Bellman_Minimalbeispiel.py
    -"""
    -Kapitel Dynamische Programmierung: Die Handrechnung zur Rueckwaertsinduktion als Code.
    -Zeigt die Wertfunktionstabelle und die optimale Politik Schritt fuer Schritt.
    -"""
    -
    -import numpy as np
    -
    -GESAMT = 3          # zu verkaufende Einheiten
    -PERIODEN = 2        # Anzahl Verkaufsperioden
    -
    -
    -def kosten(menge: int) -> float:
    -    """Ueberproportionale Marktauswirkung: doppelte Menge kostet vierfach."""
    -    return float(menge ** 2)
    -
    -
    -def loese_rueckwaerts():
    -    # V[t, x] = minimale Restkosten, wenn zu Beginn von Periode t noch x Stueck offen sind
    -    V = np.full((PERIODEN + 1, GESAMT + 1), np.inf)
    -    politik = np.zeros((PERIODEN, GESAMT + 1), dtype=int)
    -
    -    # Endbedingung: nach der letzten Periode darf nichts mehr offen sein
    -    V[PERIODEN, 0] = 0.0
    -
    -    print("=" * 70)
    -    print("  RUECKWAERTSINDUKTION SCHRITT FUER SCHRITT")
    -    print("=" * 70)
    -
    -    for t in range(PERIODEN - 1, -1, -1):
    -        letzte_periode = (t == PERIODEN - 1)
    -        print(f"\nStufe t = {t}" + ("  (letzte Periode: alles muss weg)" if letzte_periode
    -                                    else "  (freie Wahl der Menge)"))
    -        print(f"  {'Zustand x':>10} | {'beste Aktion':>12} | {'Sofortkosten':>13} | "
    -              f"{'V[t+1]':>9} | {'V[t]':>8}")
    -        print("  " + "-" * 62)
    -
    -        for x in range(GESAMT + 1):
    -            aktionen = [x] if letzte_periode else range(x + 1)
    -            bester_wert, beste_aktion, beste_teile = np.inf, 0, (0.0, 0.0)
    -
    -            for n in aktionen:
    -                rest = x - n
    -                sofort = kosten(n)
    -                zukunft = V[t + 1, rest]
    -                gesamt = sofort + zukunft
    -                if gesamt < bester_wert:
    -                    bester_wert, beste_aktion = gesamt, n
    -                    beste_teile = (sofort, zukunft)
    -
    -            V[t, x] = bester_wert
    -            politik[t, x] = beste_aktion
    -            print(f"  {x:>10} | {beste_aktion:>12} | {beste_teile[0]:>13.1f} | "
    -                  f"{beste_teile[1]:>9.1f} | {bester_wert:>8.1f}")
    -
    -    return V, politik
    -
    -
    -if __name__ == "__main__":
    -    V, politik = loese_rueckwaerts()
    -
    -    # --- Vorwaertspfad: der optimalen Politik folgen ---------------------
    -    print("\n" + "=" * 70)
    -    print("  OPTIMALER PFAD (Vorwaertssimulation)")
    -    print("=" * 70)
    -    bestand = GESAMT
    -    gesamtkosten = 0.0
    -    for t in range(PERIODEN):
    -        aktion = politik[t, bestand]
    -        gesamtkosten += kosten(aktion)
    -        print(f"  Periode {t}: Bestand {bestand} -> verkaufe {aktion} "
    -              f"(Kosten {kosten(aktion):.1f}) -> Rest {bestand - aktion}")
    -        bestand -= aktion
    -
    -    print(f"\n  Gesamtkosten: {gesamtkosten:.1f}  (V[0, {GESAMT}] = {V[0, GESAMT]:.1f})")
    -    assert abs(gesamtkosten - V[0, GESAMT]) < 1e-9, "Pfadkosten != Wertfunktion!"
    -
    -    # --- Vergleich mit naiven Strategien ---------------------------------
    -    alles_sofort = kosten(GESAMT)
    -    print(f"\n  Zum Vergleich - alles in Periode 0 verkaufen: {alles_sofort:.1f}")
    -    print(f"  Ersparnis durch Stueckelung: {alles_sofort - gesamtkosten:.1f} "
    -          f"({(1 - gesamtkosten/alles_sofort)*100:.0f} %)")
    -    print("=" * 70)
    +
    #!/usr/bin/env python3
    +
    +# Bellman_Minimalbeispiel.py
    +"""
    +Kapitel Dynamische Programmierung: Die Handrechnung zur Rueckwaertsinduktion als Code.
    +Zeigt die Wertfunktionstabelle und die optimale Politik Schritt fuer Schritt.
    +"""
    +
    +import numpy as np
    +
    +GESAMT = 3          # zu verkaufende Einheiten
    +PERIODEN = 2        # Anzahl Verkaufsperioden
    +
    +
    +def kosten(menge: int) -> float:
    +    """Ueberproportionale Marktauswirkung: doppelte Menge kostet vierfach."""
    +    return float(menge ** 2)
    +
    +
    +def loese_rueckwaerts():
    +    # V[t, x] = minimale Restkosten, wenn zu Beginn von Periode t noch x Stueck offen sind
    +    V = np.full((PERIODEN + 1, GESAMT + 1), np.inf)
    +    politik = np.zeros((PERIODEN, GESAMT + 1), dtype=int)
    +
    +    # Endbedingung: nach der letzten Periode darf nichts mehr offen sein
    +    V[PERIODEN, 0] = 0.0
    +
    +    print("=" * 70)
    +    print("  RUECKWAERTSINDUKTION SCHRITT FUER SCHRITT")
    +    print("=" * 70)
    +
    +    for t in range(PERIODEN - 1, -1, -1):
    +        letzte_periode = (t == PERIODEN - 1)
    +        print(f"\nStufe t = {t}" + ("  (letzte Periode: alles muss weg)" if letzte_periode
    +                                    else "  (freie Wahl der Menge)"))
    +        print(f"  {'Zustand x':>10} | {'beste Aktion':>12} | {'Sofortkosten':>13} | "
    +              f"{'V[t+1]':>9} | {'V[t]':>8}")
    +        print("  " + "-" * 62)
    +
    +        for x in range(GESAMT + 1):
    +            aktionen = [x] if letzte_periode else range(x + 1)
    +            bester_wert, beste_aktion, beste_teile = np.inf, 0, (0.0, 0.0)
    +
    +            for n in aktionen:
    +                rest = x - n
    +                sofort = kosten(n)
    +                zukunft = V[t + 1, rest]
    +                gesamt = sofort + zukunft
    +                if gesamt < bester_wert:
    +                    bester_wert, beste_aktion = gesamt, n
    +                    beste_teile = (sofort, zukunft)
    +
    +            V[t, x] = bester_wert
    +            politik[t, x] = beste_aktion
    +            print(f"  {x:>10} | {beste_aktion:>12} | {beste_teile[0]:>13.1f} | "
    +                  f"{beste_teile[1]:>9.1f} | {bester_wert:>8.1f}")
    +
    +    return V, politik
    +
    +
    +if __name__ == "__main__":
    +    V, politik = loese_rueckwaerts()
    +
    +    # --- Vorwaertspfad: der optimalen Politik folgen ---------------------
    +    print("\n" + "=" * 70)
    +    print("  OPTIMALER PFAD (Vorwaertssimulation)")
    +    print("=" * 70)
    +    bestand = GESAMT
    +    gesamtkosten = 0.0
    +    for t in range(PERIODEN):
    +        aktion = politik[t, bestand]
    +        gesamtkosten += kosten(aktion)
    +        print(f"  Periode {t}: Bestand {bestand} -> verkaufe {aktion} "
    +              f"(Kosten {kosten(aktion):.1f}) -> Rest {bestand - aktion}")
    +        bestand -= aktion
    +
    +    print(f"\n  Gesamtkosten: {gesamtkosten:.1f}  (V[0, {GESAMT}] = {V[0, GESAMT]:.1f})")
    +    assert abs(gesamtkosten - V[0, GESAMT]) < 1e-9, "Pfadkosten != Wertfunktion!"
    +
    +    # --- Vergleich mit naiven Strategien ---------------------------------
    +    alles_sofort = kosten(GESAMT)
    +    print(f"\n  Zum Vergleich - alles in Periode 0 verkaufen: {alles_sofort:.1f}")
    +    print(f"  Ersparnis durch Stueckelung: {alles_sofort - gesamtkosten:.1f} "
    +          f"({(1 - gesamtkosten/alles_sofort)*100:.0f} %)")
    +    print("=" * 70)

    13.4 Das Almgren-Chriss-Problem: optimale Orderausführung

    Ein klassisches OR-Problem an Börsen: Ein institutioneller Händler muss X_0 = 100\,000 Aktien innerhalb von T Handelsperioden verkaufen.

    @@ -18005,222 +18321,222 @@ C(n_t, X_t) = \underbrace{\eta\,n_t^2}_{\text{Marktauswirkung}} \;+\; \underbrac

    ⚠️ Zur Kalibrierung des Risikoparameters

    Ein häufiger Fehler ist ein undokumentierter Skalierungsfaktor im Risikoterm, etwa:

    -
    holding_risk = risk_aversion * (sigma_period ** 2) * (remaining ** 2) * 1e5
    +
    holding_risk = risk_aversion * (sigma_period ** 2) * (remaining ** 2) * 1e5

    Woher eine solche Zahl kommt, bleibt dann unklar. Faktisch wirkt sie als versteckte Erhöhung der Risikoaversion um fünf Größenordnungen — ein Parameter wie risk_aversion = 1e-6 würde dann in Wahrheit 0{,}1 bedeuten. Solche „magischen Zahlen“ machen ein Modell unkalibrierbar: Niemand kann sagen, ob 1e-6 viel oder wenig ist.

    Der Parameter lässt sich stattdessen herleiten. Beide Kostenterme werden in Euro gerechnet: * Marktauswirkung: \eta \cdot n_t^2 mit \eta in €/Stück². * Risiko: \tfrac{\lambda}{2}\cdot \sigma_{\text{Periode}}^2 \cdot P_0^2 \cdot X_t^2 — die Varianz des Werts der offenen Position, multipliziert mit der Risikoaversion.

    Durch die Multiplikation mit P_0^2 (dem Quadrat des Aktienkurses) stimmen die Einheiten, und \lambda wird interpretierbar: Es ist der Preis, den man je Einheit Wertvarianz zu zahlen bereit ist.

    -
    #!/usr/bin/env python3
    -
    -# Mehrperiodige_Order_Execution.py
    -"""
    -Kapitel Dynamische Programmierung: Dynamische Programmierung fuer optimale Orderausfuehrung
    -(Almgren-Chriss-Rahmen, geloest per Rueckwaertsinduktion).
    -
    -Eigenschaften:
    -  * Risikoterm sauber hergeleitet ueber den Aktienkurs P0 (Einheiten: EUR),
    -    ohne undokumentierte Skalierungsfaktoren
    -  * Vergleich mit der analytischen Almgren-Chriss-Loesung
    -  * Vergleich mit naiven Strategien (alles sofort / gleichmaessig)
    -  * Sensitivitaet gegenueber der Risikoaversion
    -"""
    -
    -import os
    -
    -import numpy as np
    -import pandas as pd
    -import matplotlib
    -matplotlib.use("Agg")
    -import matplotlib.pyplot as plt
    -
    -OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
    -os.makedirs(OUTPUT_DIR, exist_ok=True)
    -
    -# --- Parameter -------------------------------------------------------------
    -GESAMTBESTAND = 100_000        # X_0, zu verkaufende Aktien
    -PERIODEN = 5                   # T Handelsperioden
    -KURS = 50.0                    # P_0 in EUR, zur Skalierung des Risikoterms
    -ETA = 2.5e-6                   # EUR je Stueck^2 (Slippage-Koeffizient)
    -VOLA_JAHR = 0.30               # 30 % p.a.
    -HANDELSSTUNDEN_JAHR = 252 * 6.5
    -RISIKOAVERSION = 1e-5          # 1/EUR; kalibriert, siehe Kommentar unten
    -SCHRITTWEITE = 1000            # Diskretisierung des Zustandsraums
    -
    -# Kalibrierungshinweis: Die dimensionslose Kennzahl des Modells ist
    -#     kappa_tilde^2 = lambda * sigma_periode^2 * P0^2 / eta
    -# Sie entscheidet ueber den Charakter der Loesung:
    -#     << 1  -> praktisch gleichmaessige Aufteilung (Risiko spielt keine Rolle)
    -#     ~  1  -> ausgewogener Kompromiss  <- hier: 0.55
    -#     >> 1  -> fast alles sofort verkaufen
    -# Genau diese Interpretierbarkeit geht mit einem undokumentierten
    -# Skalierungsfaktor verloren.
    -
    -VOLA_PERIODE = VOLA_JAHR / np.sqrt(HANDELSSTUNDEN_JAHR)
    -
    -
    -def periodenkosten(verkauf: float, restbestand: float) -> float:
    -    """
    -    Sofortkosten einer Periode in EUR:
    -      (1) Marktauswirkung: eta * n^2
    -      (2) Risiko des Restbestands: lambda/2 * sigma^2 * P0^2 * X^2
    -          -> P0^2 macht aus "Stueck^2" einen EUR^2-Wert; lambda hat damit
    -             die Einheit 1/EUR und ist interpretierbar.
    -    """
    -    marktauswirkung = ETA * verkauf ** 2
    -    wertvarianz = (VOLA_PERIODE ** 2) * (KURS ** 2) * (restbestand ** 2)
    -    risiko = 0.5 * RISIKOAVERSION * wertvarianz
    -    return marktauswirkung + risiko
    -
    -
    -def loese_dp():
    -    """Rueckwaertsinduktion ueber den diskretisierten Zustandsraum."""
    -    zustaende = np.arange(0, GESAMTBESTAND + SCHRITTWEITE, SCHRITTWEITE)
    -    anzahl = len(zustaende)
    -
    -    V = np.full((PERIODEN + 1, anzahl), np.inf)
    -    politik = np.zeros((PERIODEN, anzahl), dtype=int)
    -    V[PERIODEN, 0] = 0.0                     # am Ende muss alles verkauft sein
    -
    -    for t in range(PERIODEN - 1, -1, -1):
    -        for idx, bestand in enumerate(zustaende):
    -            if t == PERIODEN - 1:
    -                moegliche = [bestand]        # letzte Periode: Rest muss weg
    -            else:
    -                moegliche = zustaende[zustaende <= bestand]
    -
    -            bester_wert, beste_aktion = np.inf, 0
    -            for verkauf in moegliche:
    -                rest = bestand - verkauf
    -                rest_idx = int(round(rest / SCHRITTWEITE))
    -                gesamt = periodenkosten(verkauf, rest) + V[t + 1, rest_idx]
    -                if gesamt < bester_wert:
    -                    bester_wert, beste_aktion = gesamt, int(verkauf)
    -
    -            V[t, idx] = bester_wert
    -            politik[t, idx] = beste_aktion
    -
    -    return zustaende, V, politik
    -
    -
    -def analytische_loesung():
    -    """
    -    Geschlossene Almgren-Chriss-Loesung fuer den kontinuierlichen Fall.
    -    Der optimale Pfad ist X_t = X_0 * sinh(kappa*(T-t)) / sinh(kappa*T)
    -    mit kappa = arccosh(tilde_kappa^2/2 + 1), tilde_kappa^2 = lambda*sigma^2*P0^2/eta.
    -    Dient hier als unabhaengige Kontrolle des DP-Ergebnisses.
    -    """
    -    kappa_tilde_quadrat = (RISIKOAVERSION * (VOLA_PERIODE ** 2) * (KURS ** 2)) / ETA
    -    kappa = np.arccosh(kappa_tilde_quadrat / 2.0 + 1.0)
    -    if kappa < 1e-12:                        # Grenzfall: risikoneutral -> linear
    -        return np.linspace(GESAMTBESTAND, 0, PERIODEN + 1)
    -    t = np.arange(PERIODEN + 1)
    -    return GESAMTBESTAND * np.sinh(kappa * (PERIODEN - t)) / np.sinh(kappa * PERIODEN)
    -
    -
    -def bewerte_pfad(bestaende):
    -    """Gesamtkosten eines beliebigen Bestandspfades."""
    -    summe = 0.0
    -    for t in range(len(bestaende) - 1):
    -        verkauf = bestaende[t] - bestaende[t + 1]
    -        summe += periodenkosten(verkauf, bestaende[t + 1])
    -    return summe
    -
    -
    -if __name__ == "__main__":
    -    zustaende, V, politik = loese_dp()
    -
    -    # --- Vorwaertspfad der optimalen Politik -----------------------------
    -    bestand = GESAMTBESTAND
    -    verlauf = [bestand]
    -    verkaeufe = []
    -    for t in range(PERIODEN):
    -        idx = int(round(bestand / SCHRITTWEITE))
    -        verkauf = politik[t, idx]
    -        verkaeufe.append(verkauf)
    -        bestand -= verkauf
    -        verlauf.append(bestand)
    -
    -    print("=" * 84)
    -    print("   OPTIMALE MEHRPERIODIGE ORDER-EXECUTION (BELLMAN DP)")
    -    print("=" * 84)
    -    print(f"Gesamtvolumen:       {GESAMTBESTAND:,} Stueck zu {KURS:.2f} EUR "
    -          f"= {GESAMTBESTAND*KURS:,.0f} EUR Positionswert")
    -    print(f"Zeithorizont:        {PERIODEN} Handelsperioden")
    -    print(f"Volatilitaet:        {VOLA_JAHR*100:.0f} % p.a. "
    -          f"= {VOLA_PERIODE*100:.3f} % je Periode")
    -    print(f"Slippage eta:        {ETA:.2e} EUR/Stueck^2")
    -    print(f"Risikoaversion:      {RISIKOAVERSION:.2e} 1/EUR")
    -    print(f"Erwartete Gesamtreibung: {V[0, -1]:,.2f} EUR "
    -          f"({V[0, -1]/(GESAMTBESTAND*KURS)*10000:.1f} Basispunkte)\n")
    -
    -    plan = pd.DataFrame([{
    -        "Periode": f"t = {t} -> {t+1}",
    -        "Startbestand": f"{verlauf[t]:,}",
    -        "Verkauf n_t": f"{verkaeufe[t]:,}",
    -        "Restbestand": f"{verlauf[t+1]:,}",
    -        "Anteil": f"{verkaeufe[t]/GESAMTBESTAND*100:5.1f} %",
    -        "Kosten (EUR)": f"{periodenkosten(verkaeufe[t], verlauf[t+1]):,.0f}",
    -    } for t in range(PERIODEN)])
    -    print(plan.to_string(index=False))
    -
    -    # --- Vergleich mit Alternativen und der analytischen Loesung ---------
    -    sofort = [GESAMTBESTAND] + [0] * PERIODEN
    -    gleichmaessig = [GESAMTBESTAND * (1 - t / PERIODEN) for t in range(PERIODEN + 1)]
    -    analytisch = analytische_loesung()
    -
    -    print("\n" + "-" * 84)
    -    print(f"{'Strategie':<34} {'Kosten (EUR)':>15} {'Basispunkte':>13} "
    -          f"{'ggue. Optimum':>16}")
    -    print("-" * 84)
    -    optimum = V[0, -1]
    -    for name, pfad in [("DP-Optimum", verlauf),
    -                       ("Analytisch (Almgren-Chriss)", list(analytisch)),
    -                       ("Gleichmaessig (TWAP)", gleichmaessig),
    -                       ("Alles sofort", sofort)]:
    -        kosten = bewerte_pfad(pfad)
    -        bp = kosten / (GESAMTBESTAND * KURS) * 10000
    -        print(f"{name:<34} {kosten:>15,.0f} {bp:>12.1f} "
    -              f"{kosten - optimum:>+15,.0f}")
    -
    -    print("-" * 84)
    -    abweichung = abs(bewerte_pfad(list(analytisch)) - optimum) / optimum
    -    print(f"Abweichung DP zur analytischen Loesung: {abweichung*100:.3f} % "
    -          f"(Diskretisierung: {SCHRITTWEITE} Stueck)")
    -
    -    # --- Sensitivitaet gegenueber der Risikoaversion ---------------------
    -    print("\n--- Wie wirkt die Risikoaversion? ---")
    -    print(f"{'lambda':>10} | {'Verkauf in Periode 0':>22} | {'Charakter':<28}")
    -    print("-" * 70)
    -    for lam in [1e-7, 1e-6, 1e-5, 1e-4, 1e-3]:
    -        globals()["RISIKOAVERSION"] = lam
    -        _, V_l, pol_l = loese_dp()
    -        erste = pol_l[0, -1]
    -        anteil = erste / GESAMTBESTAND * 100
    -        charakter = ("nahezu gleichmaessig" if anteil < 25 else
    -                     "front-loaded" if anteil < 60 else "fast alles sofort")
    -        print(f"{lam:>10.0e} | {erste:>13,} ({anteil:5.1f} %) | {charakter:<28}")
    -    globals()["RISIKOAVERSION"] = 1e-5       # zuruecksetzen
    -
    -    # --- Diagramm ---------------------------------------------------------
    -    plt.figure(figsize=(10, 5.5))
    -    plt.plot(range(PERIODEN + 1), verlauf, "o-", lw=2.5, label="DP-Optimum")
    -    plt.plot(range(PERIODEN + 1), analytisch, "s--", lw=1.8, alpha=0.8,
    -             label="Analytisch (Almgren-Chriss)")
    -    plt.plot(range(PERIODEN + 1), gleichmaessig, "^:", lw=1.8, alpha=0.8,
    -             label="Gleichmaessig (TWAP)")
    -    plt.bar(range(PERIODEN), verkaeufe, alpha=0.25, color="orange", width=0.45,
    -            label="Verkaufstranche $n_t$")
    -    plt.title("Optimaler Liquidationspfad ueber diskrete Perioden", fontsize=12)
    -    plt.xlabel("Handelsperiode $t$")
    -    plt.ylabel("Verbleibender Bestand $X_t$")
    -    plt.xticks(range(PERIODEN + 1))
    -    plt.grid(True, linestyle=":", alpha=0.6)
    -    plt.legend()
    -    plt.tight_layout()
    -    ziel = os.path.join(OUTPUT_DIR, "optimal_execution_dp.png")
    -    plt.savefig(ziel, dpi=150)
    -    print(f"\nDiagramm gespeichert unter '{ziel}'")
    -    print("=" * 84)
    +
    #!/usr/bin/env python3
    +
    +# Mehrperiodige_Order_Execution.py
    +"""
    +Kapitel Dynamische Programmierung: Dynamische Programmierung fuer optimale Orderausfuehrung
    +(Almgren-Chriss-Rahmen, geloest per Rueckwaertsinduktion).
    +
    +Eigenschaften:
    +  * Risikoterm sauber hergeleitet ueber den Aktienkurs P0 (Einheiten: EUR),
    +    ohne undokumentierte Skalierungsfaktoren
    +  * Vergleich mit der analytischen Almgren-Chriss-Loesung
    +  * Vergleich mit naiven Strategien (alles sofort / gleichmaessig)
    +  * Sensitivitaet gegenueber der Risikoaversion
    +"""
    +
    +import os
    +
    +import numpy as np
    +import pandas as pd
    +import matplotlib
    +matplotlib.use("Agg")
    +import matplotlib.pyplot as plt
    +
    +OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
    +os.makedirs(OUTPUT_DIR, exist_ok=True)
    +
    +# --- Parameter -------------------------------------------------------------
    +GESAMTBESTAND = 100_000        # X_0, zu verkaufende Aktien
    +PERIODEN = 5                   # T Handelsperioden
    +KURS = 50.0                    # P_0 in EUR, zur Skalierung des Risikoterms
    +ETA = 2.5e-6                   # EUR je Stueck^2 (Slippage-Koeffizient)
    +VOLA_JAHR = 0.30               # 30 % p.a.
    +HANDELSSTUNDEN_JAHR = 252 * 6.5
    +RISIKOAVERSION = 1e-5          # 1/EUR; kalibriert, siehe Kommentar unten
    +SCHRITTWEITE = 1000            # Diskretisierung des Zustandsraums
    +
    +# Kalibrierungshinweis: Die dimensionslose Kennzahl des Modells ist
    +#     kappa_tilde^2 = lambda * sigma_periode^2 * P0^2 / eta
    +# Sie entscheidet ueber den Charakter der Loesung:
    +#     << 1  -> praktisch gleichmaessige Aufteilung (Risiko spielt keine Rolle)
    +#     ~  1  -> ausgewogener Kompromiss  <- hier: 0.55
    +#     >> 1  -> fast alles sofort verkaufen
    +# Genau diese Interpretierbarkeit geht mit einem undokumentierten
    +# Skalierungsfaktor verloren.
    +
    +VOLA_PERIODE = VOLA_JAHR / np.sqrt(HANDELSSTUNDEN_JAHR)
    +
    +
    +def periodenkosten(verkauf: float, restbestand: float) -> float:
    +    """
    +    Sofortkosten einer Periode in EUR:
    +      (1) Marktauswirkung: eta * n^2
    +      (2) Risiko des Restbestands: lambda/2 * sigma^2 * P0^2 * X^2
    +          -> P0^2 macht aus "Stueck^2" einen EUR^2-Wert; lambda hat damit
    +             die Einheit 1/EUR und ist interpretierbar.
    +    """
    +    marktauswirkung = ETA * verkauf ** 2
    +    wertvarianz = (VOLA_PERIODE ** 2) * (KURS ** 2) * (restbestand ** 2)
    +    risiko = 0.5 * RISIKOAVERSION * wertvarianz
    +    return marktauswirkung + risiko
    +
    +
    +def loese_dp():
    +    """Rueckwaertsinduktion ueber den diskretisierten Zustandsraum."""
    +    zustaende = np.arange(0, GESAMTBESTAND + SCHRITTWEITE, SCHRITTWEITE)
    +    anzahl = len(zustaende)
    +
    +    V = np.full((PERIODEN + 1, anzahl), np.inf)
    +    politik = np.zeros((PERIODEN, anzahl), dtype=int)
    +    V[PERIODEN, 0] = 0.0                     # am Ende muss alles verkauft sein
    +
    +    for t in range(PERIODEN - 1, -1, -1):
    +        for idx, bestand in enumerate(zustaende):
    +            if t == PERIODEN - 1:
    +                moegliche = [bestand]        # letzte Periode: Rest muss weg
    +            else:
    +                moegliche = zustaende[zustaende <= bestand]
    +
    +            bester_wert, beste_aktion = np.inf, 0
    +            for verkauf in moegliche:
    +                rest = bestand - verkauf
    +                rest_idx = int(round(rest / SCHRITTWEITE))
    +                gesamt = periodenkosten(verkauf, rest) + V[t + 1, rest_idx]
    +                if gesamt < bester_wert:
    +                    bester_wert, beste_aktion = gesamt, int(verkauf)
    +
    +            V[t, idx] = bester_wert
    +            politik[t, idx] = beste_aktion
    +
    +    return zustaende, V, politik
    +
    +
    +def analytische_loesung():
    +    """
    +    Geschlossene Almgren-Chriss-Loesung fuer den kontinuierlichen Fall.
    +    Der optimale Pfad ist X_t = X_0 * sinh(kappa*(T-t)) / sinh(kappa*T)
    +    mit kappa = arccosh(tilde_kappa^2/2 + 1), tilde_kappa^2 = lambda*sigma^2*P0^2/eta.
    +    Dient hier als unabhaengige Kontrolle des DP-Ergebnisses.
    +    """
    +    kappa_tilde_quadrat = (RISIKOAVERSION * (VOLA_PERIODE ** 2) * (KURS ** 2)) / ETA
    +    kappa = np.arccosh(kappa_tilde_quadrat / 2.0 + 1.0)
    +    if kappa < 1e-12:                        # Grenzfall: risikoneutral -> linear
    +        return np.linspace(GESAMTBESTAND, 0, PERIODEN + 1)
    +    t = np.arange(PERIODEN + 1)
    +    return GESAMTBESTAND * np.sinh(kappa * (PERIODEN - t)) / np.sinh(kappa * PERIODEN)
    +
    +
    +def bewerte_pfad(bestaende):
    +    """Gesamtkosten eines beliebigen Bestandspfades."""
    +    summe = 0.0
    +    for t in range(len(bestaende) - 1):
    +        verkauf = bestaende[t] - bestaende[t + 1]
    +        summe += periodenkosten(verkauf, bestaende[t + 1])
    +    return summe
    +
    +
    +if __name__ == "__main__":
    +    zustaende, V, politik = loese_dp()
    +
    +    # --- Vorwaertspfad der optimalen Politik -----------------------------
    +    bestand = GESAMTBESTAND
    +    verlauf = [bestand]
    +    verkaeufe = []
    +    for t in range(PERIODEN):
    +        idx = int(round(bestand / SCHRITTWEITE))
    +        verkauf = politik[t, idx]
    +        verkaeufe.append(verkauf)
    +        bestand -= verkauf
    +        verlauf.append(bestand)
    +
    +    print("=" * 84)
    +    print("   OPTIMALE MEHRPERIODIGE ORDER-EXECUTION (BELLMAN DP)")
    +    print("=" * 84)
    +    print(f"Gesamtvolumen:       {GESAMTBESTAND:,} Stueck zu {KURS:.2f} EUR "
    +          f"= {GESAMTBESTAND*KURS:,.0f} EUR Positionswert")
    +    print(f"Zeithorizont:        {PERIODEN} Handelsperioden")
    +    print(f"Volatilitaet:        {VOLA_JAHR*100:.0f} % p.a. "
    +          f"= {VOLA_PERIODE*100:.3f} % je Periode")
    +    print(f"Slippage eta:        {ETA:.2e} EUR/Stueck^2")
    +    print(f"Risikoaversion:      {RISIKOAVERSION:.2e} 1/EUR")
    +    print(f"Erwartete Gesamtreibung: {V[0, -1]:,.2f} EUR "
    +          f"({V[0, -1]/(GESAMTBESTAND*KURS)*10000:.1f} Basispunkte)\n")
    +
    +    plan = pd.DataFrame([{
    +        "Periode": f"t = {t} -> {t+1}",
    +        "Startbestand": f"{verlauf[t]:,}",
    +        "Verkauf n_t": f"{verkaeufe[t]:,}",
    +        "Restbestand": f"{verlauf[t+1]:,}",
    +        "Anteil": f"{verkaeufe[t]/GESAMTBESTAND*100:5.1f} %",
    +        "Kosten (EUR)": f"{periodenkosten(verkaeufe[t], verlauf[t+1]):,.0f}",
    +    } for t in range(PERIODEN)])
    +    print(plan.to_string(index=False))
    +
    +    # --- Vergleich mit Alternativen und der analytischen Loesung ---------
    +    sofort = [GESAMTBESTAND] + [0] * PERIODEN
    +    gleichmaessig = [GESAMTBESTAND * (1 - t / PERIODEN) for t in range(PERIODEN + 1)]
    +    analytisch = analytische_loesung()
    +
    +    print("\n" + "-" * 84)
    +    print(f"{'Strategie':<34} {'Kosten (EUR)':>15} {'Basispunkte':>13} "
    +          f"{'ggue. Optimum':>16}")
    +    print("-" * 84)
    +    optimum = V[0, -1]
    +    for name, pfad in [("DP-Optimum", verlauf),
    +                       ("Analytisch (Almgren-Chriss)", list(analytisch)),
    +                       ("Gleichmaessig (TWAP)", gleichmaessig),
    +                       ("Alles sofort", sofort)]:
    +        kosten = bewerte_pfad(pfad)
    +        bp = kosten / (GESAMTBESTAND * KURS) * 10000
    +        print(f"{name:<34} {kosten:>15,.0f} {bp:>12.1f} "
    +              f"{kosten - optimum:>+15,.0f}")
    +
    +    print("-" * 84)
    +    abweichung = abs(bewerte_pfad(list(analytisch)) - optimum) / optimum
    +    print(f"Abweichung DP zur analytischen Loesung: {abweichung*100:.3f} % "
    +          f"(Diskretisierung: {SCHRITTWEITE} Stueck)")
    +
    +    # --- Sensitivitaet gegenueber der Risikoaversion ---------------------
    +    print("\n--- Wie wirkt die Risikoaversion? ---")
    +    print(f"{'lambda':>10} | {'Verkauf in Periode 0':>22} | {'Charakter':<28}")
    +    print("-" * 70)
    +    for lam in [1e-7, 1e-6, 1e-5, 1e-4, 1e-3]:
    +        globals()["RISIKOAVERSION"] = lam
    +        _, V_l, pol_l = loese_dp()
    +        erste = pol_l[0, -1]
    +        anteil = erste / GESAMTBESTAND * 100
    +        charakter = ("nahezu gleichmaessig" if anteil < 25 else
    +                     "front-loaded" if anteil < 60 else "fast alles sofort")
    +        print(f"{lam:>10.0e} | {erste:>13,} ({anteil:5.1f} %) | {charakter:<28}")
    +    globals()["RISIKOAVERSION"] = 1e-5       # zuruecksetzen
    +
    +    # --- Diagramm ---------------------------------------------------------
    +    plt.figure(figsize=(10, 5.5))
    +    plt.plot(range(PERIODEN + 1), verlauf, "o-", lw=2.5, label="DP-Optimum")
    +    plt.plot(range(PERIODEN + 1), analytisch, "s--", lw=1.8, alpha=0.8,
    +             label="Analytisch (Almgren-Chriss)")
    +    plt.plot(range(PERIODEN + 1), gleichmaessig, "^:", lw=1.8, alpha=0.8,
    +             label="Gleichmaessig (TWAP)")
    +    plt.bar(range(PERIODEN), verkaeufe, alpha=0.25, color="orange", width=0.45,
    +            label="Verkaufstranche $n_t$")
    +    plt.title("Optimaler Liquidationspfad ueber diskrete Perioden", fontsize=12)
    +    plt.xlabel("Handelsperiode $t$")
    +    plt.ylabel("Verbleibender Bestand $X_t$")
    +    plt.xticks(range(PERIODEN + 1))
    +    plt.grid(True, linestyle=":", alpha=0.6)
    +    plt.legend()
    +    plt.tight_layout()
    +    ziel = os.path.join(OUTPUT_DIR, "optimal_execution_dp.png")
    +    plt.savefig(ziel, dpi=150)
    +    print(f"\nDiagramm gespeichert unter '{ziel}'")
    +    print("=" * 84)

    Erwartete Ausgabe:

    ====================================================================================
        OPTIMALE MEHRPERIODIGE ORDER-EXECUTION (BELLMAN DP)
    @@ -18361,22 +18677,22 @@ Abweichung DP zur analytischen Loesung: 5.788 % (Diskretisierung: 1000 Stueck)
     
     
     

    Der Entwickler überlegt: „Für die Zukunft zählt nur, wie viel ich auf Lager habe. Also ist der Lagerbestand mein Zustand.“

    -
    import functools
    -
    -@functools.lru_cache(None)
    -def V(t, lager):
    -    if t == 4:
    -        return (0.0, ())
    -    best = (float("inf"), ())
    -    for p in range(KAP + 1):
    -        neu = lager + p - BEDARF[t]
    -        if neu < 0 or neu > LAGER_MAX:
    -            continue
    -        kosten = (RUESTKOSTEN if p > 0 else 0) + STUECKKOSTEN * p + LAGERKOSTEN * neu
    -        rest, plan = V(t + 1, neu)
    -        if kosten + rest < best[0]:
    -            best = (kosten + rest, (p,) + plan)
    -    return best
    +
    import functools
    +
    +@functools.lru_cache(None)
    +def V(t, lager):
    +    if t == 4:
    +        return (0.0, ())
    +    best = (float("inf"), ())
    +    for p in range(KAP + 1):
    +        neu = lager + p - BEDARF[t]
    +        if neu < 0 or neu > LAGER_MAX:
    +            continue
    +        kosten = (RUESTKOSTEN if p > 0 else 0) + STUECKKOSTEN * p + LAGERKOSTEN * neu
    +        rest, plan = V(t + 1, neu)
    +        if kosten + rest < best[0]:
    +            best = (kosten + rest, (p,) + plan)
    +    return best

    Ergebnis: Plan (5, 0, 5, 0) für 110 €. Nachgerechnet stimmt der Betrag sogar — dieser Plan kostet tatsächlich 110 €. Das wahre Optimum lautet aber (3, 1, 4, 2) und kostet 70 €.

    Ihre Aufgabe: (a) Sehen Sie sich die Zeile mit den Rüstkosten an. Welche Information bräuchte sie, die im Zustand (t, lager) nicht enthalten ist? (b) Welchen Vorteil des Plans (3,1,4,2) kann dieses Modell prinzipiell nicht erkennen? (c) Wie lautet der korrigierte Zustand, und um welchen Faktor wächst der Zustandsraum dadurch? (d) Warum ist es besonders tückisch, dass der ausgegebene Kostenbetrag richtig war?

    Auflösung: Abschnitt A.13.

    @@ -18437,30 +18753,30 @@ Abweichung DP zur analytischen Loesung: 5.788 % (Diskretisierung: 1000 Stueck)

    🚀 In 5 Minuten gelöst: Was kostet ein Kilogramm CO₂?

    Eine Spedition vergibt fünf Sendungen an LKW, Bahn oder Kombinierten Verkehr. Der LKW ist billig und schmutzig, die Bahn teuer und sauber — und es sind nur zwei Trassen frei.

    Statt zu fragen „wie wichtig ist uns CO₂?” fragen wir: Was bekommen wir für zwei Prozent mehr Geld?

    -
    import numpy as np
    -from scipy.optimize import linprog
    -
    -#                       LKW   Bahn  Kombi     LKW billig+schmutzig, Bahn teuer+sauber
    -kosten = np.array([[  900, 1150, 1020], [ 620,  790,  700], [1300, 1660, 1470],
    -                   [  480,  610,  545], [1050, 1340, 1180]], dtype=float)
    -co2    = np.array([[ 1400,  310,  760], [ 980,  220,  530], [2000,  440, 1080],
    -                   [  760,  170,  410], [1620,  360,  880]], dtype=float)
    -eine_pro_sendung = np.repeat(np.eye(5), 3, axis=1)
    -bahn_trassen     = [[1.0 if j == 1 else 0.0 for _ in range(5) for j in range(3)]]
    -
    -def plane(ziel, kostenbudget=None):
    -    A, b = list(bahn_trassen), [2.0]                       # nur zwei Trassen frei
    -    if kostenbudget is not None:
    -        A.append(kosten.reshape(-1)); b.append(kostenbudget)
    -    x = linprog(ziel.reshape(-1), A_ub=A, b_ub=b, A_eq=eine_pro_sendung,
    -                b_eq=np.ones(5), bounds=(0, 1), integrality=1, method="highs").x
    -    return kosten.reshape(-1) @ x, co2.reshape(-1) @ x
    -
    -k0, c0 = plane(kosten)                                     # nur Kosten minimieren
    -k1, c1 = plane(co2, kostenbudget=k0 * 1.02)                # 2 % mehr ausgeben
    -print(f"guenstigster Plan:  {k0:>7,.0f} EUR   {c0:>6,.0f} kg CO2")
    -print(f"mit 2 % Aufpreis:   {k1:>7,.0f} EUR   {c1:>6,.0f} kg CO2   "
    -      f"({(c0-c1)/c0:.0%} weniger, {(k1-k0)/(c0-c1):.2f} EUR je kg)")
    +
    import numpy as np
    +from scipy.optimize import linprog
    +
    +#                       LKW   Bahn  Kombi     LKW billig+schmutzig, Bahn teuer+sauber
    +kosten = np.array([[  900, 1150, 1020], [ 620,  790,  700], [1300, 1660, 1470],
    +                   [  480,  610,  545], [1050, 1340, 1180]], dtype=float)
    +co2    = np.array([[ 1400,  310,  760], [ 980,  220,  530], [2000,  440, 1080],
    +                   [  760,  170,  410], [1620,  360,  880]], dtype=float)
    +eine_pro_sendung = np.repeat(np.eye(5), 3, axis=1)
    +bahn_trassen     = [[1.0 if j == 1 else 0.0 for _ in range(5) for j in range(3)]]
    +
    +def plane(ziel, kostenbudget=None):
    +    A, b = list(bahn_trassen), [2.0]                       # nur zwei Trassen frei
    +    if kostenbudget is not None:
    +        A.append(kosten.reshape(-1)); b.append(kostenbudget)
    +    x = linprog(ziel.reshape(-1), A_ub=A, b_ub=b, A_eq=eine_pro_sendung,
    +                b_eq=np.ones(5), bounds=(0, 1), integrality=1, method="highs").x
    +    return kosten.reshape(-1) @ x, co2.reshape(-1) @ x
    +
    +k0, c0 = plane(kosten)                                     # nur Kosten minimieren
    +k1, c1 = plane(co2, kostenbudget=k0 * 1.02)                # 2 % mehr ausgeben
    +print(f"guenstigster Plan:  {k0:>7,.0f} EUR   {c0:>6,.0f} kg CO2")
    +print(f"mit 2 % Aufpreis:   {k1:>7,.0f} EUR   {c1:>6,.0f} kg CO2   "
    +      f"({(c0-c1)/c0:.0%} weniger, {(k1-k0)/(c0-c1):.2f} EUR je kg)")

    Ausgabe:

    guenstigster Plan:    4,350 EUR    6,760 kg CO2
     mit 2 % Aufpreis:     4,430 EUR    6,310 kg CO2   (7% weniger, 0.18 EUR je kg)
    @@ -18565,7 +18881,7 @@ f_j(x) < f_j(y)\ \text{ für mindestens ein } j w -„So viele Euro ist mir ein Kilogramm CO₂ wert.“ Ein Wechselkurs, kein Wichtigkeitsregler (Abschnitt 7.9). +„So viele Euro ist mir ein Kilogramm CO₂ wert.“ Ein Wechselkurs, kein Wichtigkeitsregler (Abschnitt 7.10). \min_{x \in X} @@ -18579,266 +18895,266 @@ f_j(x) < f_j(y)\ \text{ für mindestens ein } j

    14.5 Das Programm

    Zwölf Sendungen, drei Verkehrsträger, fünf freie Bahntrassen. Der Zielkonflikt entsteht nicht zwischen den Trägern — die Bahn ist immer billiger und sauberer als der LKW —, sondern durch die Knappheit der Trassen. Genau so sieht es in der Praxis aus.

    -
    #!/usr/bin/env python3
    -
    -# Mehrziel_Pareto.py
    -"""
    -Kapitel Mehrziel: Kosten gegen CO2 - und warum Gewichte nicht genuegen.
    -
    -Eine Spedition vergibt zwoelf Sendungen an drei Verkehrstraeger: LKW (schnell,
    -teuer, schmutzig), Bahn (billig und sauber, aber nur fuenf Trassen frei) und
    -Kombinierten Verkehr (dazwischen). Zwei Ziele stehen gegeneinander:
    -Transportkosten und CO2-Ausstoss.
    -
    -Der uebliche Reflex ist, beide Ziele zu einem zusammenzuruehren:
    -
    -    minimiere   Kosten + w * CO2
    -
    -Das ist bequem, liefert zulaessige Loesungen - und ist unvollstaendig. Bei
    -ganzzahligen Entscheidungen gibt es Kompromisse, die auf diesem Weg
    -GRUNDSAETZLICH nicht erreichbar sind, egal welches w man waehlt. Nicht "schwer
    -zu finden", sondern beweisbar unerreichbar.
    -
    -Das Programm zeigt in vier Teilen:
    -
    -  1. Die beiden Extreme - was jedes Ziel allein kostet.
    -  2. Die lineare Skalarisierung ueber ein feines Gewichtsraster.
    -  3. Die vollstaendige Pareto-Front ueber das eps-Constraint-Verfahren.
    -  4. Den Nachweis, dass die Luecke keine Frage des Rasters ist, sondern
    -     Geometrie: Die fehlenden Punkte liegen strikt oberhalb der konvexen
    -     Huelle und koennen deshalb von keiner Geraden gestuetzt werden.
    -
    -Benoetigt: numpy, scipy
    -"""
    -
    -from __future__ import annotations
    -
    -import numpy as np
    -from scipy.optimize import linprog
    -
    -TRAEGER = ["LKW", "Bahn", "Kombiniert"]
    -BAHN_TRASSEN = 5          # so viele Sendungen passen hoechstens auf die Bahn
    -SAAT = 5
    -
    -
    -def erzeuge_sendungen(anzahl: int = 12, saat: int = SAAT):
    -    """Kosten und CO2 je Sendung und Verkehrstraeger.
    -
    -    Die Bahn ist immer billiger und sauberer als der LKW - der Zielkonflikt
    -    entsteht nicht zwischen den Traegern, sondern durch die KNAPPHEIT der
    -    Trassen. Genau so sieht es in der Praxis aus.
    -    """
    -    rng = np.random.default_rng(saat)
    -    kosten = np.zeros((anzahl, 3))
    -    co2 = np.zeros((anzahl, 3))
    -    for i in range(anzahl):
    -        grund_kosten = rng.integers(600, 2400)
    -        grund_co2 = rng.integers(400, 1800)
    -        kosten[i] = [grund_kosten,
    -                     grund_kosten * rng.uniform(0.55, 0.85),
    -                     grund_kosten * rng.uniform(0.70, 1.00)]
    -        co2[i] = [grund_co2,
    -                  grund_co2 * rng.uniform(0.15, 0.35),
    -                  grund_co2 * rng.uniform(0.40, 0.70)]
    -    return np.round(kosten).astype(int), np.round(co2).astype(int)
    -
    -
    -KOSTEN, CO2 = erzeuge_sendungen()
    -N = len(KOSTEN)
    -
    -
    -def plane(ziel: np.ndarray, co2_grenze: float | None = None,
    -          kosten_grenze: float | None = None):
    -    """Jede Sendung genau einem Traeger zuordnen; Trassen sind knapp.
    -
    -    'ziel' ist die zu minimierende Matrix (Kosten, CO2 oder eine Mischung).
    -    Die beiden Grenzen sind das Werkzeug fuer eps-Constraint und
    -    lexikografische Optimierung - sie machen aus einem Ziel eine Schranke.
    -    """
    -    gleichungen = np.zeros((N, N * 3))
    -    for i in range(N):
    -        gleichungen[i, i * 3:(i + 1) * 3] = 1.0        # genau ein Traeger
    -
    -    ungleichungen = [[1.0 if j == 1 else 0.0 for _ in range(N) for j in range(3)]]
    -    grenzen = [float(BAHN_TRASSEN)]
    -    if co2_grenze is not None:
    -        ungleichungen.append(CO2.reshape(-1).astype(float))
    -        grenzen.append(float(co2_grenze))
    -    if kosten_grenze is not None:
    -        ungleichungen.append(KOSTEN.reshape(-1).astype(float))
    -        grenzen.append(float(kosten_grenze))
    -
    -    ergebnis = linprog(ziel.reshape(-1).astype(float),
    -                       A_ub=ungleichungen, b_ub=grenzen,
    -                       A_eq=gleichungen, b_eq=np.ones(N),
    -                       bounds=(0, 1), integrality=1, method="highs")
    -    if not ergebnis.success:
    -        return None
    -    plan = np.round(ergebnis.x).astype(int)
    -    return (int(KOSTEN.reshape(-1) @ plan), int(CO2.reshape(-1) @ plan), plan)
    -
    -
    -def pareto_front():
    -    """Vollstaendige Front ueber das eps-Constraint-Verfahren.
    -
    -    Der Ablauf ist der eigentliche Inhalt dieser Funktion: Erst das
    -    Kostenminimum bestimmen (der eine Rand der Front), dann die CO2-Schranke
    -    schrittweise um genau ein Kilogramm unter den zuletzt erreichten Wert
    -    druecken. Jeder Lauf liefert den naechsten Punkt - und wenn keiner mehr
    -    zulaessig ist, ist die Front vollstaendig.
    -
    -    Das braucht so viele Solveraufrufe, wie die Front Punkte hat. Ein Raster
    -    ueber alle moeglichen CO2-Werte braeuchte hier fast tausend.
    -    """
    -    front = []
    -    start = plane(KOSTEN)
    -    grenze = start[1]
    -    while True:
    -        ergebnis = plane(KOSTEN, co2_grenze=grenze)
    -        if ergebnis is None:
    -            break
    -        front.append((ergebnis[0], ergebnis[1]))
    -        grenze = ergebnis[1] - 1
    -    return front
    -
    -
    -def skalarisierung(gewichte):
    -    """Was 'Kosten + w * CO2' fuer viele w hergibt - als Menge von Punkten."""
    -    gefunden = {}
    -    for w in gewichte:
    -        ergebnis = plane(KOSTEN + w * CO2)
    -        if ergebnis:
    -            gefunden.setdefault((ergebnis[0], ergebnis[1]), []).append(w)
    -    return gefunden
    -
    -
    -def untere_huelle(punkte):
    -    """Untere linke konvexe Huelle - genau die Punkte, die eine Gerade stuetzt.
    -
    -    Der Zusammenhang, um den es geht: 'Kosten + w*CO2 minimieren' heisst
    -    geometrisch, eine Gerade der Steigung -1/w von links unten an die
    -    Punktwolke zu schieben. Sie beruehrt immer einen Eckpunkt der konvexen
    -    Huelle. Punkte, die oberhalb liegen, werden nie beruehrt - fuer kein w.
    -    """
    -    huelle = []
    -    for punkt in sorted(punkte):
    -        while len(huelle) >= 2:
    -            (x1, y1), (x2, y2) = huelle[-2], huelle[-1]
    -            if (x2 - x1) * (punkt[1] - y1) - (y2 - y1) * (punkt[0] - x1) <= 0:
    -                huelle.pop()
    -            else:
    -                break
    -        huelle.append(punkt)
    -    return huelle
    -
    -
    -if __name__ == "__main__":
    -    print("=" * 80)
    -    print("  KOSTEN GEGEN CO2 - UND WARUM GEWICHTE NICHT GENUEGEN")
    -    print("=" * 80)
    -    print(f"{N} Sendungen, {len(TRAEGER)} Verkehrstraeger, "
    -          f"{BAHN_TRASSEN} freie Bahntrassen.\n")
    -
    -    # --- 1. Die beiden Extreme -------------------------------------------
    -    guenstigst = plane(KOSTEN)
    -    saubersten = plane(CO2)
    -    print("1. Was jedes Ziel allein ergibt\n")
    -    print(f"  {'':<22} {'Kosten':>10} {'CO2':>10}")
    -    print("  " + "-" * 44)
    -    print(f"  {'nur Kosten minimal':<22} {guenstigst[0]:>10,} {guenstigst[1]:>9,} kg")
    -    print(f"  {'nur CO2 minimal':<22} {saubersten[0]:>10,} {saubersten[1]:>9,} kg")
    -    print(f"\n  Der Zielkonflikt ist echt, aber klein: "
    -          f"{saubersten[0] - guenstigst[0]:,} EUR mehr")
    -    print(f"  ({(saubersten[0] - guenstigst[0]) / guenstigst[0] * 100:.1f} %) sparen "
    -          f"{guenstigst[1] - saubersten[1]:,} kg CO2 "
    -          f"({(guenstigst[1] - saubersten[1]) / guenstigst[1] * 100:.1f} %).")
    -    print("  Genau solche Zahlen will die Geschaeftsfuehrung sehen - nicht ein")
    -    print("  Gewicht, das niemand interpretieren kann.")
    -
    -    # --- 2. Die lineare Skalarisierung -----------------------------------
    -    gewichte = np.concatenate([np.linspace(0.0, 3.0, 1201),
    -                               np.geomspace(3.0, 1000.0, 200)])
    -    gefunden = skalarisierung(gewichte)
    -    print("\n" + "-" * 80)
    -    print(f"2. Lineare Skalarisierung: 'Kosten + w * CO2' fuer "
    -          f"{len(gewichte):,} Gewichte\n")
    -    print(f"  {'Kosten':>10} {'CO2':>10}   {'gefunden bei w':>16}")
    -    print("  " + "-" * 46)
    -    for (k, c), ws in sorted(gefunden.items()):
    -        print(f"  {k:>10,} {c:>9,} kg   {min(ws):>7.3f} bis {max(ws):>7.3f}")
    -    print(f"\n  {len(gefunden)} verschiedene Plaene - fuer {len(gewichte):,} Gewichte.")
    -    print("  Das Gewicht ist also gar keine Feineinstellung: Weite Bereiche")
    -    print("  liefern dasselbe Ergebnis, und dazwischen springt es.")
    -
    -    # --- 3. Die vollstaendige Front --------------------------------------
    -    front = pareto_front()
    -    print("\n" + "-" * 80)
    -    print(f"3. Die vollstaendige Pareto-Front ueber eps-Constraint "
    -          f"({len(front)} Solverlaeufe)\n")
    -    print(f"  {'Kosten':>10} {'CO2':>10}   {'Aufpreis':>9} {'CO2-Ersparnis':>14} "
    -          f"{'EUR je kg':>10}")
    -    print("  " + "-" * 60)
    -    for k, c in front:
    -        auf = k - guenstigst[0]
    -        ersparnis = guenstigst[1] - c
    -        preis = auf / ersparnis if ersparnis else 0.0
    -        print(f"  {k:>10,} {c:>9,} kg   {auf:>8,} {ersparnis:>13,} "
    -              f"{preis:>10.2f}")
    -
    -    # --- 4. Der Nachweis --------------------------------------------------
    -    huelle = untere_huelle(front)
    -    unerreichbar = [p for p in front if p not in huelle]
    -    print("\n" + "-" * 80)
    -    print("4. Was die Skalarisierung nicht findet\n")
    -    erreicht = [p for p in front if p in gefunden]
    -    print(f"  Pareto-Punkte insgesamt:              {len(front)}")
    -    print(f"  davon von der Skalarisierung gefunden: {len(erreicht)}")
    -    print(f"  nie gefunden:                          {len(front) - len(erreicht)}")
    -    print()
    -    print("  Diese Kompromisse sind fuer KEIN Gewicht erreichbar:\n")
    -    print(f"  {'Kosten':>10} {'CO2':>10}   {'Aufpreis':>9} {'CO2-Ersparnis':>14}")
    -    print("  " + "-" * 50)
    -    for k, c in unerreichbar:
    -        print(f"  {k:>10,} {c:>9,} kg   {k - guenstigst[0]:>8,} "
    -              f"{guenstigst[1] - c:>13,}")
    -
    -    stimmt = sorted(unerreichbar) == sorted(p for p in front if p not in gefunden)
    -    print(f"\n  Gegenprobe ueber die Geometrie: {'bestanden' if stimmt else 'ABWEICHUNG'}")
    -    print("  Genau die Punkte, die das Gewichtsraster verfehlt, liegen strikt")
    -    print("  oberhalb der unteren konvexen Huelle. Das ist kein Rasterproblem -")
    -    print("  eine Gerade, die von links unten an die Wolke geschoben wird,")
    -    print("  beruehrt immer einen Eckpunkt der Huelle und nie einen Punkt")
    -    print("  darueber. Ein feineres Raster aendert daran nichts.")
    -
    -    # --- 5. Lexikografisch -------------------------------------------------
    -    print("\n" + "-" * 80)
    -    print("5. Lexikografisch: erst Kosten, dann CO2 im Rahmen eines Budgets\n")
    -    print(f"  {'Kostenbudget':>14} {'Kosten':>10} {'CO2':>10} {'gegenueber Minimum':>20}")
    -    print("  " + "-" * 58)
    -    for aufschlag in (0.00, 0.01, 0.02, 0.05, 0.10):
    -        budget = guenstigst[0] * (1 + aufschlag)
    -        ergebnis = plane(CO2, kosten_grenze=budget)
    -        if ergebnis is None:
    -            print(f"  {aufschlag:>13.0%} unzulaessig")
    -            continue
    -        print(f"  {aufschlag:>13.0%} {ergebnis[0]:>10,} {ergebnis[1]:>9,} kg "
    -              f"{guenstigst[1] - ergebnis[1]:>15,} kg weniger")
    -
    -    print("\n  Das ist die Form, die im Betrieb am ehesten trifft: Nicht 'wie")
    -    print("  wichtig ist CO2?', sondern 'wir geben zwei Prozent mehr aus - was")
    -    print("  bringt das?'. Die Frage kann ein Kaufmann beantworten.")
    -
    -    print("\n" + "=" * 80)
    -    print("  WAS MAN DARAUS MITNIMMT")
    -    print("=" * 80)
    -    print("Ein Gewicht zu setzen heisst, die Entscheidung heimlich zu treffen -")
    -    print("und dabei einen Teil der Moeglichkeiten gar nicht erst zu sehen.")
    -    print()
    -    print("Die Pareto-Front ist die ehrlichere Antwort: Sie legt dem Betrieb")
    -    print("alle sinnvollen Kompromisse vor und ueberlaesst ihm die Wahl. Die")
    -    print("Spalte 'EUR je kg' macht sie entscheidbar - man vergleicht sie mit")
    -    print("dem CO2-Preis, den das Unternehmen ohnehin ansetzt.")
    -    print("=" * 80)
    +
    #!/usr/bin/env python3
    +
    +# Mehrziel_Pareto.py
    +"""
    +Kapitel Mehrziel: Kosten gegen CO2 - und warum Gewichte nicht genuegen.
    +
    +Eine Spedition vergibt zwoelf Sendungen an drei Verkehrstraeger: LKW (schnell,
    +teuer, schmutzig), Bahn (billig und sauber, aber nur fuenf Trassen frei) und
    +Kombinierten Verkehr (dazwischen). Zwei Ziele stehen gegeneinander:
    +Transportkosten und CO2-Ausstoss.
    +
    +Der uebliche Reflex ist, beide Ziele zu einem zusammenzuruehren:
    +
    +    minimiere   Kosten + w * CO2
    +
    +Das ist bequem, liefert zulaessige Loesungen - und ist unvollstaendig. Bei
    +ganzzahligen Entscheidungen gibt es Kompromisse, die auf diesem Weg
    +GRUNDSAETZLICH nicht erreichbar sind, egal welches w man waehlt. Nicht "schwer
    +zu finden", sondern beweisbar unerreichbar.
    +
    +Das Programm zeigt in vier Teilen:
    +
    +  1. Die beiden Extreme - was jedes Ziel allein kostet.
    +  2. Die lineare Skalarisierung ueber ein feines Gewichtsraster.
    +  3. Die vollstaendige Pareto-Front ueber das eps-Constraint-Verfahren.
    +  4. Den Nachweis, dass die Luecke keine Frage des Rasters ist, sondern
    +     Geometrie: Die fehlenden Punkte liegen strikt oberhalb der konvexen
    +     Huelle und koennen deshalb von keiner Geraden gestuetzt werden.
    +
    +Benoetigt: numpy, scipy
    +"""
    +
    +from __future__ import annotations
    +
    +import numpy as np
    +from scipy.optimize import linprog
    +
    +TRAEGER = ["LKW", "Bahn", "Kombiniert"]
    +BAHN_TRASSEN = 5          # so viele Sendungen passen hoechstens auf die Bahn
    +SAAT = 5
    +
    +
    +def erzeuge_sendungen(anzahl: int = 12, saat: int = SAAT):
    +    """Kosten und CO2 je Sendung und Verkehrstraeger.
    +
    +    Die Bahn ist immer billiger und sauberer als der LKW - der Zielkonflikt
    +    entsteht nicht zwischen den Traegern, sondern durch die KNAPPHEIT der
    +    Trassen. Genau so sieht es in der Praxis aus.
    +    """
    +    rng = np.random.default_rng(saat)
    +    kosten = np.zeros((anzahl, 3))
    +    co2 = np.zeros((anzahl, 3))
    +    for i in range(anzahl):
    +        grund_kosten = rng.integers(600, 2400)
    +        grund_co2 = rng.integers(400, 1800)
    +        kosten[i] = [grund_kosten,
    +                     grund_kosten * rng.uniform(0.55, 0.85),
    +                     grund_kosten * rng.uniform(0.70, 1.00)]
    +        co2[i] = [grund_co2,
    +                  grund_co2 * rng.uniform(0.15, 0.35),
    +                  grund_co2 * rng.uniform(0.40, 0.70)]
    +    return np.round(kosten).astype(int), np.round(co2).astype(int)
    +
    +
    +KOSTEN, CO2 = erzeuge_sendungen()
    +N = len(KOSTEN)
    +
    +
    +def plane(ziel: np.ndarray, co2_grenze: float | None = None,
    +          kosten_grenze: float | None = None):
    +    """Jede Sendung genau einem Traeger zuordnen; Trassen sind knapp.
    +
    +    'ziel' ist die zu minimierende Matrix (Kosten, CO2 oder eine Mischung).
    +    Die beiden Grenzen sind das Werkzeug fuer eps-Constraint und
    +    lexikografische Optimierung - sie machen aus einem Ziel eine Schranke.
    +    """
    +    gleichungen = np.zeros((N, N * 3))
    +    for i in range(N):
    +        gleichungen[i, i * 3:(i + 1) * 3] = 1.0        # genau ein Traeger
    +
    +    ungleichungen = [[1.0 if j == 1 else 0.0 for _ in range(N) for j in range(3)]]
    +    grenzen = [float(BAHN_TRASSEN)]
    +    if co2_grenze is not None:
    +        ungleichungen.append(CO2.reshape(-1).astype(float))
    +        grenzen.append(float(co2_grenze))
    +    if kosten_grenze is not None:
    +        ungleichungen.append(KOSTEN.reshape(-1).astype(float))
    +        grenzen.append(float(kosten_grenze))
    +
    +    ergebnis = linprog(ziel.reshape(-1).astype(float),
    +                       A_ub=ungleichungen, b_ub=grenzen,
    +                       A_eq=gleichungen, b_eq=np.ones(N),
    +                       bounds=(0, 1), integrality=1, method="highs")
    +    if not ergebnis.success:
    +        return None
    +    plan = np.round(ergebnis.x).astype(int)
    +    return (int(KOSTEN.reshape(-1) @ plan), int(CO2.reshape(-1) @ plan), plan)
    +
    +
    +def pareto_front():
    +    """Vollstaendige Front ueber das eps-Constraint-Verfahren.
    +
    +    Der Ablauf ist der eigentliche Inhalt dieser Funktion: Erst das
    +    Kostenminimum bestimmen (der eine Rand der Front), dann die CO2-Schranke
    +    schrittweise um genau ein Kilogramm unter den zuletzt erreichten Wert
    +    druecken. Jeder Lauf liefert den naechsten Punkt - und wenn keiner mehr
    +    zulaessig ist, ist die Front vollstaendig.
    +
    +    Das braucht so viele Solveraufrufe, wie die Front Punkte hat. Ein Raster
    +    ueber alle moeglichen CO2-Werte braeuchte hier fast tausend.
    +    """
    +    front = []
    +    start = plane(KOSTEN)
    +    grenze = start[1]
    +    while True:
    +        ergebnis = plane(KOSTEN, co2_grenze=grenze)
    +        if ergebnis is None:
    +            break
    +        front.append((ergebnis[0], ergebnis[1]))
    +        grenze = ergebnis[1] - 1
    +    return front
    +
    +
    +def skalarisierung(gewichte):
    +    """Was 'Kosten + w * CO2' fuer viele w hergibt - als Menge von Punkten."""
    +    gefunden = {}
    +    for w in gewichte:
    +        ergebnis = plane(KOSTEN + w * CO2)
    +        if ergebnis:
    +            gefunden.setdefault((ergebnis[0], ergebnis[1]), []).append(w)
    +    return gefunden
    +
    +
    +def untere_huelle(punkte):
    +    """Untere linke konvexe Huelle - genau die Punkte, die eine Gerade stuetzt.
    +
    +    Der Zusammenhang, um den es geht: 'Kosten + w*CO2 minimieren' heisst
    +    geometrisch, eine Gerade der Steigung -1/w von links unten an die
    +    Punktwolke zu schieben. Sie beruehrt immer einen Eckpunkt der konvexen
    +    Huelle. Punkte, die oberhalb liegen, werden nie beruehrt - fuer kein w.
    +    """
    +    huelle = []
    +    for punkt in sorted(punkte):
    +        while len(huelle) >= 2:
    +            (x1, y1), (x2, y2) = huelle[-2], huelle[-1]
    +            if (x2 - x1) * (punkt[1] - y1) - (y2 - y1) * (punkt[0] - x1) <= 0:
    +                huelle.pop()
    +            else:
    +                break
    +        huelle.append(punkt)
    +    return huelle
    +
    +
    +if __name__ == "__main__":
    +    print("=" * 80)
    +    print("  KOSTEN GEGEN CO2 - UND WARUM GEWICHTE NICHT GENUEGEN")
    +    print("=" * 80)
    +    print(f"{N} Sendungen, {len(TRAEGER)} Verkehrstraeger, "
    +          f"{BAHN_TRASSEN} freie Bahntrassen.\n")
    +
    +    # --- 1. Die beiden Extreme -------------------------------------------
    +    guenstigst = plane(KOSTEN)
    +    saubersten = plane(CO2)
    +    print("1. Was jedes Ziel allein ergibt\n")
    +    print(f"  {'':<22} {'Kosten':>10} {'CO2':>10}")
    +    print("  " + "-" * 44)
    +    print(f"  {'nur Kosten minimal':<22} {guenstigst[0]:>10,} {guenstigst[1]:>9,} kg")
    +    print(f"  {'nur CO2 minimal':<22} {saubersten[0]:>10,} {saubersten[1]:>9,} kg")
    +    print(f"\n  Der Zielkonflikt ist echt, aber klein: "
    +          f"{saubersten[0] - guenstigst[0]:,} EUR mehr")
    +    print(f"  ({(saubersten[0] - guenstigst[0]) / guenstigst[0] * 100:.1f} %) sparen "
    +          f"{guenstigst[1] - saubersten[1]:,} kg CO2 "
    +          f"({(guenstigst[1] - saubersten[1]) / guenstigst[1] * 100:.1f} %).")
    +    print("  Genau solche Zahlen will die Geschaeftsfuehrung sehen - nicht ein")
    +    print("  Gewicht, das niemand interpretieren kann.")
    +
    +    # --- 2. Die lineare Skalarisierung -----------------------------------
    +    gewichte = np.concatenate([np.linspace(0.0, 3.0, 1201),
    +                               np.geomspace(3.0, 1000.0, 200)])
    +    gefunden = skalarisierung(gewichte)
    +    print("\n" + "-" * 80)
    +    print(f"2. Lineare Skalarisierung: 'Kosten + w * CO2' fuer "
    +          f"{len(gewichte):,} Gewichte\n")
    +    print(f"  {'Kosten':>10} {'CO2':>10}   {'gefunden bei w':>16}")
    +    print("  " + "-" * 46)
    +    for (k, c), ws in sorted(gefunden.items()):
    +        print(f"  {k:>10,} {c:>9,} kg   {min(ws):>7.3f} bis {max(ws):>7.3f}")
    +    print(f"\n  {len(gefunden)} verschiedene Plaene - fuer {len(gewichte):,} Gewichte.")
    +    print("  Das Gewicht ist also gar keine Feineinstellung: Weite Bereiche")
    +    print("  liefern dasselbe Ergebnis, und dazwischen springt es.")
    +
    +    # --- 3. Die vollstaendige Front --------------------------------------
    +    front = pareto_front()
    +    print("\n" + "-" * 80)
    +    print(f"3. Die vollstaendige Pareto-Front ueber eps-Constraint "
    +          f"({len(front)} Solverlaeufe)\n")
    +    print(f"  {'Kosten':>10} {'CO2':>10}   {'Aufpreis':>9} {'CO2-Ersparnis':>14} "
    +          f"{'EUR je kg':>10}")
    +    print("  " + "-" * 60)
    +    for k, c in front:
    +        auf = k - guenstigst[0]
    +        ersparnis = guenstigst[1] - c
    +        preis = auf / ersparnis if ersparnis else 0.0
    +        print(f"  {k:>10,} {c:>9,} kg   {auf:>8,} {ersparnis:>13,} "
    +              f"{preis:>10.2f}")
    +
    +    # --- 4. Der Nachweis --------------------------------------------------
    +    huelle = untere_huelle(front)
    +    unerreichbar = [p for p in front if p not in huelle]
    +    print("\n" + "-" * 80)
    +    print("4. Was die Skalarisierung nicht findet\n")
    +    erreicht = [p for p in front if p in gefunden]
    +    print(f"  Pareto-Punkte insgesamt:              {len(front)}")
    +    print(f"  davon von der Skalarisierung gefunden: {len(erreicht)}")
    +    print(f"  nie gefunden:                          {len(front) - len(erreicht)}")
    +    print()
    +    print("  Diese Kompromisse sind fuer KEIN Gewicht erreichbar:\n")
    +    print(f"  {'Kosten':>10} {'CO2':>10}   {'Aufpreis':>9} {'CO2-Ersparnis':>14}")
    +    print("  " + "-" * 50)
    +    for k, c in unerreichbar:
    +        print(f"  {k:>10,} {c:>9,} kg   {k - guenstigst[0]:>8,} "
    +              f"{guenstigst[1] - c:>13,}")
    +
    +    stimmt = sorted(unerreichbar) == sorted(p for p in front if p not in gefunden)
    +    print(f"\n  Gegenprobe ueber die Geometrie: {'bestanden' if stimmt else 'ABWEICHUNG'}")
    +    print("  Genau die Punkte, die das Gewichtsraster verfehlt, liegen strikt")
    +    print("  oberhalb der unteren konvexen Huelle. Das ist kein Rasterproblem -")
    +    print("  eine Gerade, die von links unten an die Wolke geschoben wird,")
    +    print("  beruehrt immer einen Eckpunkt der Huelle und nie einen Punkt")
    +    print("  darueber. Ein feineres Raster aendert daran nichts.")
    +
    +    # --- 5. Lexikografisch -------------------------------------------------
    +    print("\n" + "-" * 80)
    +    print("5. Lexikografisch: erst Kosten, dann CO2 im Rahmen eines Budgets\n")
    +    print(f"  {'Kostenbudget':>14} {'Kosten':>10} {'CO2':>10} {'gegenueber Minimum':>20}")
    +    print("  " + "-" * 58)
    +    for aufschlag in (0.00, 0.01, 0.02, 0.05, 0.10):
    +        budget = guenstigst[0] * (1 + aufschlag)
    +        ergebnis = plane(CO2, kosten_grenze=budget)
    +        if ergebnis is None:
    +            print(f"  {aufschlag:>13.0%} unzulaessig")
    +            continue
    +        print(f"  {aufschlag:>13.0%} {ergebnis[0]:>10,} {ergebnis[1]:>9,} kg "
    +              f"{guenstigst[1] - ergebnis[1]:>15,} kg weniger")
    +
    +    print("\n  Das ist die Form, die im Betrieb am ehesten trifft: Nicht 'wie")
    +    print("  wichtig ist CO2?', sondern 'wir geben zwei Prozent mehr aus - was")
    +    print("  bringt das?'. Die Frage kann ein Kaufmann beantworten.")
    +
    +    print("\n" + "=" * 80)
    +    print("  WAS MAN DARAUS MITNIMMT")
    +    print("=" * 80)
    +    print("Ein Gewicht zu setzen heisst, die Entscheidung heimlich zu treffen -")
    +    print("und dabei einen Teil der Moeglichkeiten gar nicht erst zu sehen.")
    +    print()
    +    print("Die Pareto-Front ist die ehrlichere Antwort: Sie legt dem Betrieb")
    +    print("alle sinnvollen Kompromisse vor und ueberlaesst ihm die Wahl. Die")
    +    print("Spalte 'EUR je kg' macht sie entscheidbar - man vergleicht sie mit")
    +    print("dem CO2-Preis, den das Unternehmen ohnehin ansetzt.")
    +    print("=" * 80)

    Erwartete Ausgabe:

    ================================================================================
       KOSTEN GEGEN CO2 - UND WARUM GEWICHTE NICHT GENUEGEN
    @@ -19126,22 +19442,22 @@ e(x) \le \varepsilon
     

    🚀 In 5 Minuten gelöst: Der Durchschnitt ist die falsche Zahl

    Eine Bäckerei kennt die Nachfrage der letzten 20 Tage. Ein Brot bringt 6 € Marge, ein übriges kostet 3 € Einkauf. Wie viele soll sie ansetzen?

    Die naheliegende Antwort — den Durchschnitt — ist nachweislich falsch:

    -
    import numpy as np
    -
    -nachfrage = np.array([104, 138, 96, 151, 118, 127, 143, 109, 162, 121,   # 20 Tage
    -                      133, 115, 148, 102, 129, 156, 111, 140, 124, 135])
    -preis, einkauf = 9.0, 3.0
    -fehl, ueber = preis - einkauf, einkauf          # 6 EUR zu wenig, 3 EUR zu viel
    -
    -kosten = lambda menge: (fehl * np.maximum(0, nachfrage - menge)
    -                        + ueber * np.maximum(0, menge - nachfrage)).mean()
    -
    -erwartungswert = round(nachfrage.mean())
    -kritisch = round(np.quantile(nachfrage, fehl / (fehl + ueber)))
    -print(f"Erwartungswert bestellen: {erwartungswert} Stueck -> {kosten(erwartungswert):5.2f} EUR/Tag")
    -print(f"kritisches Quantil      : {kritisch} Stueck -> {kosten(kritisch):5.2f} EUR/Tag")
    -print(f"bestmoegliche feste Menge: {int(np.argmin([kosten(m) for m in range(200)]))} Stueck"
    -      f" -> {min(kosten(m) for m in range(200)):5.2f} EUR/Tag")
    +
    import numpy as np
    +
    +nachfrage = np.array([104, 138, 96, 151, 118, 127, 143, 109, 162, 121,   # 20 Tage
    +                      133, 115, 148, 102, 129, 156, 111, 140, 124, 135])
    +preis, einkauf = 9.0, 3.0
    +fehl, ueber = preis - einkauf, einkauf          # 6 EUR zu wenig, 3 EUR zu viel
    +
    +kosten = lambda menge: (fehl * np.maximum(0, nachfrage - menge)
    +                        + ueber * np.maximum(0, menge - nachfrage)).mean()
    +
    +erwartungswert = round(nachfrage.mean())
    +kritisch = round(np.quantile(nachfrage, fehl / (fehl + ueber)))
    +print(f"Erwartungswert bestellen: {erwartungswert} Stueck -> {kosten(erwartungswert):5.2f} EUR/Tag")
    +print(f"kritisches Quantil      : {kritisch} Stueck -> {kosten(kritisch):5.2f} EUR/Tag")
    +print(f"bestmoegliche feste Menge: {int(np.argmin([kosten(m) for m in range(200)]))} Stueck"
    +      f" -> {min(kosten(m) for m in range(200)):5.2f} EUR/Tag")

    Ausgabe:

    Erwartungswert bestellen: 128 Stueck -> 69.45 EUR/Tag
     kritisches Quantil      : 137 Stueck -> 62.25 EUR/Tag
    @@ -19185,236 +19501,236 @@ bestmoegliche feste Menge: 138 Stueck -> 62.10 EUR/Tag

    15.4 Das Programm

    Dieselbe Bäckerei, aber mit Merkmalen: Wochentag, Temperatur, Aktionstage. Die Nachfrage muss jetzt modelliert werden.

    Eine Eigenschaft der Daten ist dabei entscheidend und in der Praxis der Normalfall: An Aktionstagen ist die Nachfrage nicht nur höher, sondern auch viel unsicherer (heteroskedastisch).

    -
    #!/usr/bin/env python3
    -
    -# Predict_then_Optimize.py
    -"""
    -Kapitel Prognose: Die bessere Prognose trifft die schlechtere Entscheidung.
    -
    -Eine Baeckerei muss jeden Abend entscheiden, wie viel sie fuer den naechsten Tag
    -ansetzt. Zu wenig kostet die Marge des entgangenen Verkaufs, zu viel kostet den
    -Einkaufspreis der Retoure. Das ist das Newsvendor-Problem aus dem Kapitel
    -Unsicherheit - nur dass die Nachfrage diesmal nicht aus einer Verteilung kommt,
    -sondern PROGNOSTIZIERT werden muss: aus Wochentag, Temperatur und Aktionstagen.
    -
    -Damit zerfaellt die Aufgabe in zwei Schritte, und genau an der Naht entsteht der
    -Fehler, um den es hier geht:
    -
    -    PREDICT     ein Modell schaetzt die Nachfrage
    -    OPTIMIZE    daraus wird eine Bestellmenge
    -
    -Der Prognostiker optimiert seinen Modellfehler, meist den MSE. Der Planer traegt
    -die Kosten. Beide messen etwas anderes - und die beiden Masse widersprechen
    -einander. Das Programm zeigt:
    -
    -  1. Vier Verfahren, verglichen nach MSE UND nach Entscheidungskosten. Das
    -     Verfahren mit dem BESTEN MSE hat die HOECHSTEN Kosten.
    -  2. Warum ein pauschaler Sicherheitszuschlag zu kurz greift - die Streuung der
    -     Nachfrage haengt selbst von den Merkmalen ab.
    -  3. Eine Messfalle, in die der Autor dieses Programms zuerst selbst getappt
    -     ist: Bei kurzen Testzeitraeumen ist der MSE-Vergleich nicht stabil.
    -
    -Benoetigt: numpy, scipy, scikit-learn
    -"""
    -
    -from __future__ import annotations
    -
    -import numpy as np
    -from scipy.stats import norm
    -from sklearn.linear_model import LinearRegression, QuantileRegressor
    -
    -VERKAUFSPREIS = 9.0
    -EINKAUFSPREIS = 3.0
    -KOSTEN_FEHLMENGE = VERKAUFSPREIS - EINKAUFSPREIS      # entgangene Marge: 6 EUR
    -KOSTEN_UEBERHANG = EINKAUFSPREIS                      # Retoure:           3 EUR
    -KRITISCHES_VERHAELTNIS = KOSTEN_FEHLMENGE / (KOSTEN_FEHLMENGE + KOSTEN_UEBERHANG)
    -
    -TAGE = 5000               # Simulation, siehe Hinweis unten
    -TRAINING = 1000
    -SAAT = 11
    -
    -
    -def erzeuge_daten(tage: int = TAGE, saat: int = SAAT):
    -    """Taegliche Nachfrage mit Wochentag, Temperatur und Aktionstagen.
    -
    -    Die entscheidende Eigenschaft steckt in 'streuung': An Aktionstagen ist die
    -    Nachfrage nicht nur hoeher, sondern auch viel UNSICHERER. Solche
    -    heteroskedastischen Daten sind der Normalfall - und der Grund, warum ein
    -    pauschaler Sicherheitszuschlag nicht genuegt.
    -    """
    -    rng = np.random.default_rng(saat)
    -    wochentag = np.arange(tage) % 7
    -    temperatur = (12 + 10 * np.sin(2 * np.pi * np.arange(tage) / 365)
    -                  + rng.normal(0, 3, tage))
    -    aktion = (rng.random(tage) < 0.15).astype(float)
    -
    -    merkmale = np.column_stack([np.eye(7)[wochentag][:, 1:], temperatur, aktion])
    -    erwartung = (120
    -                 + np.eye(7)[wochentag] @ np.array([0, 10, 12, 14, 18, 35, -40])
    -                 + 1.8 * temperatur + 45 * aktion)
    -    streuung = 8 + 22 * aktion
    -    nachfrage = np.maximum(0.0, erwartung + rng.normal(0, 1, tage) * streuung)
    -    return merkmale, nachfrage, aktion
    -
    -
    -def tageskosten(bestellung: np.ndarray, nachfrage: np.ndarray) -> float:
    -    """Die Zahl, auf die es ankommt - und die kein Prognosemass kennt."""
    -    fehlmenge = np.maximum(0.0, nachfrage - bestellung)
    -    ueberhang = np.maximum(0.0, bestellung - nachfrage)
    -    return float((KOSTEN_FEHLMENGE * fehlmenge
    -                  + KOSTEN_UEBERHANG * ueberhang).mean())
    -
    -
    -if __name__ == "__main__":
    -    merkmale, nachfrage, aktion = erzeuge_daten()
    -    lernen = slice(0, TRAINING)
    -    pruefen = slice(TRAINING, TAGE)
    -
    -    print("=" * 84)
    -    print("  DIE BESSERE PROGNOSE TRIFFT DIE SCHLECHTERE ENTSCHEIDUNG")
    -    print("=" * 84)
    -    print(f"Verkaufspreis {VERKAUFSPREIS:.0f} EUR, Einkauf {EINKAUFSPREIS:.0f} EUR.")
    -    print(f"Fehlmenge kostet {KOSTEN_FEHLMENGE:.0f} EUR, Ueberhang "
    -          f"{KOSTEN_UEBERHANG:.0f} EUR je Stueck.")
    -    print(f"Kritisches Verhaeltnis: {KRITISCHES_VERHAELTNIS:.3f} - der Planer sollte "
    -          f"also das")
    -    print(f"{KRITISCHES_VERHAELTNIS:.1%}-Quantil der Nachfrage bestellen, nicht ihren "
    -          f"Erwartungswert.")
    -    print(f"\nTraining: Tag 1 bis {TRAINING}. Bewertung: die restlichen "
    -          f"{TAGE - TRAINING} Tage.\n")
    -
    -    # --- Die vier Verfahren ----------------------------------------------
    -    kleinste_quadrate = LinearRegression().fit(merkmale[lernen], nachfrage[lernen])
    -    punktprognose = kleinste_quadrate.predict(merkmale[pruefen])
    -
    -    restfehler = nachfrage[lernen] - kleinste_quadrate.predict(merkmale[lernen])
    -    pauschalzuschlag = norm.ppf(KRITISCHES_VERHAELTNIS) * restfehler.std()
    -
    -    # Ein Zuschlag, der nicht aus der Normalverteilung kommt, sondern direkt
    -    # auf den Trainingsdaten die Kosten minimiert.
    -    kandidaten = np.linspace(-10.0, 30.0, 401)
    -    trainingsprognose = kleinste_quadrate.predict(merkmale[lernen])
    -    kostenzuschlag = float(kandidaten[np.argmin(
    -        [tageskosten(trainingsprognose + z, nachfrage[lernen]) for z in kandidaten])])
    -
    -    # Und das Verfahren, das von vornherein das richtige Quantil schaetzt.
    -    quantilmodell = QuantileRegressor(quantile=KRITISCHES_VERHAELTNIS,
    -                                      alpha=0.0, solver="highs")
    -    quantilmodell.fit(merkmale[lernen], nachfrage[lernen])
    -    quantilprognose = quantilmodell.predict(merkmale[pruefen])
    -
    -    verfahren = [
    -        ("bestelle die Punktprognose", punktprognose, punktprognose),
    -        ("+ Zuschlag aus der Normalverteilung",
    -         punktprognose, punktprognose + pauschalzuschlag),
    -        ("+ Zuschlag auf Kosten trainiert",
    -         punktprognose, punktprognose + kostenzuschlag),
    -        ("Quantilregression aufs kritische Quantil",
    -         quantilprognose, quantilprognose),
    -    ]
    -
    -    print(f"  {'Verfahren':<42} {'MSE':>9} {'Kosten/Tag':>12} {'gegen Zeile 1':>14}")
    -    print("  " + "-" * 80)
    -    ergebnisse = {}
    -    for name, prognose, bestellung in verfahren:
    -        mse = float(((prognose - nachfrage[pruefen]) ** 2).mean())
    -        kosten = tageskosten(bestellung, nachfrage[pruefen])
    -        ergebnisse[name] = (mse, kosten)
    -        basis = ergebnisse[verfahren[0][0]][1]
    -        vergleich = "" if name == verfahren[0][0] else f"{(kosten - basis) / basis:+13.1%}"
    -        print(f"  {name:<42} {mse:>9.1f} {kosten:>10.2f} EUR {vergleich:>14}")
    -
    -    bester_mse = min(ergebnisse, key=lambda k: ergebnisse[k][0])
    -    beste_kosten = min(ergebnisse, key=lambda k: ergebnisse[k][1])
    -    print(f"\n  bester MSE:      {bester_mse}")
    -    print(f"  beste Kosten:    {beste_kosten}")
    -    print(f"\n  Das Verfahren mit dem besten MSE hat die HOECHSTEN Kosten, und das")
    -    print(f"  Verfahren mit den besten Kosten hat einen um "
    -          f"{(ergebnisse[beste_kosten][0] / ergebnisse[bester_mse][0] - 1):.0%} SCHLECHTEREN MSE.")
    -    print("  Wer Prognosemodelle nach MSE auswaehlt, waehlt hier das falsche.")
    -
    -    # --- Warum der pauschale Zuschlag zu kurz greift ---------------------
    -    print("\n" + "-" * 84)
    -    print("Warum ein pauschaler Zuschlag nicht genuegt\n")
    -    ist_aktion = aktion[pruefen] > 0.5
    -    print(f"  {'Verfahren':<42} {'normale Tage':>14} {'Aktionstage':>14}")
    -    print("  " + "-" * 74)
    -    for name, _, bestellung in verfahren:
    -        normal = tageskosten(bestellung[~ist_aktion], nachfrage[pruefen][~ist_aktion])
    -        aktionstag = tageskosten(bestellung[ist_aktion], nachfrage[pruefen][ist_aktion])
    -        print(f"  {name:<42} {normal:>10.2f} EUR {aktionstag:>10.2f} EUR")
    -
    -    # Nachgerechnet statt behauptet: Welcher Zuschlag waere je Tagesart richtig?
    -    aktion_training = aktion[lernen] > 0.5
    -    z = norm.ppf(KRITISCHES_VERHAELTNIS)
    -    richtig_normal = z * restfehler[~aktion_training].std()
    -    richtig_aktion = z * restfehler[aktion_training].std()
    -    print(f"\n  Der pauschale Zuschlag betraegt {pauschalzuschlag:.1f} Stueck. Aus den")
    -    print(f"  Trainingsresten getrennt nach Tagesart waere richtig:")
    -    print(f"    normale Tage : {richtig_normal:5.1f} Stueck")
    -    print(f"    Aktionstage  : {richtig_aktion:5.1f} Stueck")
    -    print(f"  Ein Zuschlag fuer alle Tage kann nur einen Mittelweg treffen - hier")
    -    print(f"  ist er an normalen Tagen {pauschalzuschlag / richtig_normal:.1f}-mal zu gross und an")
    -    print(f"  Aktionstagen nur {pauschalzuschlag / richtig_aktion:.0%} dessen, was noetig waere.")
    -    print(f"\n  Die Quantilregression schaetzt das {KRITISCHES_VERHAELTNIS:.1%}-Quantil "
    -          f"direkt aus den")
    -    print(f"  Merkmalen und darf deshalb an verschiedenen Tagen verschieden weit")
    -    print(f"  ueber dem Erwartungswert liegen. Genau das ist der Unterschied")
    -    print(f"  zwischen 'ein Modell und danach eine Formel' und 'ein Modell, das")
    -    print(f"  weiss, wofuer es gebraucht wird'.")
    -
    -    # --- Die Messfalle ---------------------------------------------------
    -    print("\n" + "-" * 84)
    -    print("Eine Messfalle, in die der Autor zuerst selbst getappt ist\n")
    -    print("  Der erste Entwurf dieses Programms bewertete auf 230 Testtagen - ein")
    -    print("  realistischer Zeitraum. Dort hatte die Quantilregression den BESSEREN")
    -    print("  MSE, und die ganze Aussage des Kapitels stand auf dem Kopf.")
    -    print("\n  Wie oft das passiert, laesst sich ausmessen:\n")
    -    rng = np.random.default_rng(0)
    -    print(f"  {'Testfenster':>14} {'QR sieht MSE-besser aus':>26}")
    -    print("  " + "-" * 42)
    -    for fenster in (180, 365, 730, 2000):
    -        treffer = 0
    -        versuche = 400
    -        for _ in range(versuche):
    -            start = int(rng.integers(TRAINING, TAGE - fenster))
    -            ausschnitt = slice(start, start + fenster)
    -            mse_punkt = ((kleinste_quadrate.predict(merkmale[ausschnitt])
    -                          - nachfrage[ausschnitt]) ** 2).mean()
    -            mse_quantil = ((quantilmodell.predict(merkmale[ausschnitt])
    -                            - nachfrage[ausschnitt]) ** 2).mean()
    -            treffer += mse_quantil < mse_punkt
    -        print(f"  {fenster:>10} Tage {treffer / versuche:>24.1%}")
    -
    -    print("\n  Bei einem halben Jahr Testdaten sieht das schlechtere Modell in gut")
    -    print("  jedem zehnten Fall besser aus. Das ist keine grosse Zahl - aber wer")
    -    print("  EINMAL misst, hat genau eine Ziehung aus dieser Verteilung.")
    -    print("\n  Die Lehre ist nicht 'nimm 4.000 Testtage' - die hat niemand. Sie")
    -    print("  lautet: Ein Kennzahlenvergleich ohne Angabe seiner Streuung ist keine")
    -    print("  Aussage. Bei kurzen Zeitraeumen gehoert eine Kreuzvalidierung dazu.")
    -
    -    print("\n" + "=" * 84)
    -    print("  WAS MAN DARAUS MITNIMMT")
    -    print("=" * 84)
    -    print("Der Prognostiker optimiert den MSE, der Planer traegt die Kosten - und")
    -    print("die beiden Masse zeigen hier in verschiedene Richtungen. Drei Saetze:")
    -    print()
    -    print("  1. Sagen Sie nicht den Erwartungswert vorher, sondern die Groesse, die")
    -    print("     in die Entscheidung eingeht. Beim Newsvendor ist das das kritische")
    -    print("     Quantil - und das kann man direkt schaetzen.")
    -    print("  2. Bewerten Sie Prognosemodelle an den ENTSCHEIDUNGSKOSTEN. Die sind")
    -    print("     in Euro und damit vergleichbar; ein MSE ist es nicht.")
    -    print("  3. Ein pauschaler Sicherheitszuschlag ist besser als nichts und")
    -    print("     schlechter als ein Modell, das die Unsicherheit selbst aus den")
    -    print("     Merkmalen liest.")
    -    print()
    -    print("Der naechste Schritt - Prognosemodelle so zu trainieren, dass sie die")
    -    print("Entscheidungskosten direkt minimieren (Smart Predict-then-Optimize,")
    -    print("differenzierbare Optimierungsschichten) - ist Forschungsstand und")
    -    print("erfordert Bibliotheken wie cvxpylayers. Die dritte Zeile der Tabelle")
    -    print("oben ist seine einfachste denkbare Form: ein einziger Parameter, auf")
    -    print("Kosten statt auf Fehler trainiert.")
    -    print("=" * 84)
    +
    #!/usr/bin/env python3
    +
    +# Predict_then_Optimize.py
    +"""
    +Kapitel Prognose: Die bessere Prognose trifft die schlechtere Entscheidung.
    +
    +Eine Baeckerei muss jeden Abend entscheiden, wie viel sie fuer den naechsten Tag
    +ansetzt. Zu wenig kostet die Marge des entgangenen Verkaufs, zu viel kostet den
    +Einkaufspreis der Retoure. Das ist das Newsvendor-Problem aus dem Kapitel
    +Unsicherheit - nur dass die Nachfrage diesmal nicht aus einer Verteilung kommt,
    +sondern PROGNOSTIZIERT werden muss: aus Wochentag, Temperatur und Aktionstagen.
    +
    +Damit zerfaellt die Aufgabe in zwei Schritte, und genau an der Naht entsteht der
    +Fehler, um den es hier geht:
    +
    +    PREDICT     ein Modell schaetzt die Nachfrage
    +    OPTIMIZE    daraus wird eine Bestellmenge
    +
    +Der Prognostiker optimiert seinen Modellfehler, meist den MSE. Der Planer traegt
    +die Kosten. Beide messen etwas anderes - und die beiden Masse widersprechen
    +einander. Das Programm zeigt:
    +
    +  1. Vier Verfahren, verglichen nach MSE UND nach Entscheidungskosten. Das
    +     Verfahren mit dem BESTEN MSE hat die HOECHSTEN Kosten.
    +  2. Warum ein pauschaler Sicherheitszuschlag zu kurz greift - die Streuung der
    +     Nachfrage haengt selbst von den Merkmalen ab.
    +  3. Eine Messfalle, in die der Autor dieses Programms zuerst selbst getappt
    +     ist: Bei kurzen Testzeitraeumen ist der MSE-Vergleich nicht stabil.
    +
    +Benoetigt: numpy, scipy, scikit-learn
    +"""
    +
    +from __future__ import annotations
    +
    +import numpy as np
    +from scipy.stats import norm
    +from sklearn.linear_model import LinearRegression, QuantileRegressor
    +
    +VERKAUFSPREIS = 9.0
    +EINKAUFSPREIS = 3.0
    +KOSTEN_FEHLMENGE = VERKAUFSPREIS - EINKAUFSPREIS      # entgangene Marge: 6 EUR
    +KOSTEN_UEBERHANG = EINKAUFSPREIS                      # Retoure:           3 EUR
    +KRITISCHES_VERHAELTNIS = KOSTEN_FEHLMENGE / (KOSTEN_FEHLMENGE + KOSTEN_UEBERHANG)
    +
    +TAGE = 5000               # Simulation, siehe Hinweis unten
    +TRAINING = 1000
    +SAAT = 11
    +
    +
    +def erzeuge_daten(tage: int = TAGE, saat: int = SAAT):
    +    """Taegliche Nachfrage mit Wochentag, Temperatur und Aktionstagen.
    +
    +    Die entscheidende Eigenschaft steckt in 'streuung': An Aktionstagen ist die
    +    Nachfrage nicht nur hoeher, sondern auch viel UNSICHERER. Solche
    +    heteroskedastischen Daten sind der Normalfall - und der Grund, warum ein
    +    pauschaler Sicherheitszuschlag nicht genuegt.
    +    """
    +    rng = np.random.default_rng(saat)
    +    wochentag = np.arange(tage) % 7
    +    temperatur = (12 + 10 * np.sin(2 * np.pi * np.arange(tage) / 365)
    +                  + rng.normal(0, 3, tage))
    +    aktion = (rng.random(tage) < 0.15).astype(float)
    +
    +    merkmale = np.column_stack([np.eye(7)[wochentag][:, 1:], temperatur, aktion])
    +    erwartung = (120
    +                 + np.eye(7)[wochentag] @ np.array([0, 10, 12, 14, 18, 35, -40])
    +                 + 1.8 * temperatur + 45 * aktion)
    +    streuung = 8 + 22 * aktion
    +    nachfrage = np.maximum(0.0, erwartung + rng.normal(0, 1, tage) * streuung)
    +    return merkmale, nachfrage, aktion
    +
    +
    +def tageskosten(bestellung: np.ndarray, nachfrage: np.ndarray) -> float:
    +    """Die Zahl, auf die es ankommt - und die kein Prognosemass kennt."""
    +    fehlmenge = np.maximum(0.0, nachfrage - bestellung)
    +    ueberhang = np.maximum(0.0, bestellung - nachfrage)
    +    return float((KOSTEN_FEHLMENGE * fehlmenge
    +                  + KOSTEN_UEBERHANG * ueberhang).mean())
    +
    +
    +if __name__ == "__main__":
    +    merkmale, nachfrage, aktion = erzeuge_daten()
    +    lernen = slice(0, TRAINING)
    +    pruefen = slice(TRAINING, TAGE)
    +
    +    print("=" * 84)
    +    print("  DIE BESSERE PROGNOSE TRIFFT DIE SCHLECHTERE ENTSCHEIDUNG")
    +    print("=" * 84)
    +    print(f"Verkaufspreis {VERKAUFSPREIS:.0f} EUR, Einkauf {EINKAUFSPREIS:.0f} EUR.")
    +    print(f"Fehlmenge kostet {KOSTEN_FEHLMENGE:.0f} EUR, Ueberhang "
    +          f"{KOSTEN_UEBERHANG:.0f} EUR je Stueck.")
    +    print(f"Kritisches Verhaeltnis: {KRITISCHES_VERHAELTNIS:.3f} - der Planer sollte "
    +          f"also das")
    +    print(f"{KRITISCHES_VERHAELTNIS:.1%}-Quantil der Nachfrage bestellen, nicht ihren "
    +          f"Erwartungswert.")
    +    print(f"\nTraining: Tag 1 bis {TRAINING}. Bewertung: die restlichen "
    +          f"{TAGE - TRAINING} Tage.\n")
    +
    +    # --- Die vier Verfahren ----------------------------------------------
    +    kleinste_quadrate = LinearRegression().fit(merkmale[lernen], nachfrage[lernen])
    +    punktprognose = kleinste_quadrate.predict(merkmale[pruefen])
    +
    +    restfehler = nachfrage[lernen] - kleinste_quadrate.predict(merkmale[lernen])
    +    pauschalzuschlag = norm.ppf(KRITISCHES_VERHAELTNIS) * restfehler.std()
    +
    +    # Ein Zuschlag, der nicht aus der Normalverteilung kommt, sondern direkt
    +    # auf den Trainingsdaten die Kosten minimiert.
    +    kandidaten = np.linspace(-10.0, 30.0, 401)
    +    trainingsprognose = kleinste_quadrate.predict(merkmale[lernen])
    +    kostenzuschlag = float(kandidaten[np.argmin(
    +        [tageskosten(trainingsprognose + z, nachfrage[lernen]) for z in kandidaten])])
    +
    +    # Und das Verfahren, das von vornherein das richtige Quantil schaetzt.
    +    quantilmodell = QuantileRegressor(quantile=KRITISCHES_VERHAELTNIS,
    +                                      alpha=0.0, solver="highs")
    +    quantilmodell.fit(merkmale[lernen], nachfrage[lernen])
    +    quantilprognose = quantilmodell.predict(merkmale[pruefen])
    +
    +    verfahren = [
    +        ("bestelle die Punktprognose", punktprognose, punktprognose),
    +        ("+ Zuschlag aus der Normalverteilung",
    +         punktprognose, punktprognose + pauschalzuschlag),
    +        ("+ Zuschlag auf Kosten trainiert",
    +         punktprognose, punktprognose + kostenzuschlag),
    +        ("Quantilregression aufs kritische Quantil",
    +         quantilprognose, quantilprognose),
    +    ]
    +
    +    print(f"  {'Verfahren':<42} {'MSE':>9} {'Kosten/Tag':>12} {'gegen Zeile 1':>14}")
    +    print("  " + "-" * 80)
    +    ergebnisse = {}
    +    for name, prognose, bestellung in verfahren:
    +        mse = float(((prognose - nachfrage[pruefen]) ** 2).mean())
    +        kosten = tageskosten(bestellung, nachfrage[pruefen])
    +        ergebnisse[name] = (mse, kosten)
    +        basis = ergebnisse[verfahren[0][0]][1]
    +        vergleich = "" if name == verfahren[0][0] else f"{(kosten - basis) / basis:+13.1%}"
    +        print(f"  {name:<42} {mse:>9.1f} {kosten:>10.2f} EUR {vergleich:>14}")
    +
    +    bester_mse = min(ergebnisse, key=lambda k: ergebnisse[k][0])
    +    beste_kosten = min(ergebnisse, key=lambda k: ergebnisse[k][1])
    +    print(f"\n  bester MSE:      {bester_mse}")
    +    print(f"  beste Kosten:    {beste_kosten}")
    +    print(f"\n  Das Verfahren mit dem besten MSE hat die HOECHSTEN Kosten, und das")
    +    print(f"  Verfahren mit den besten Kosten hat einen um "
    +          f"{(ergebnisse[beste_kosten][0] / ergebnisse[bester_mse][0] - 1):.0%} SCHLECHTEREN MSE.")
    +    print("  Wer Prognosemodelle nach MSE auswaehlt, waehlt hier das falsche.")
    +
    +    # --- Warum der pauschale Zuschlag zu kurz greift ---------------------
    +    print("\n" + "-" * 84)
    +    print("Warum ein pauschaler Zuschlag nicht genuegt\n")
    +    ist_aktion = aktion[pruefen] > 0.5
    +    print(f"  {'Verfahren':<42} {'normale Tage':>14} {'Aktionstage':>14}")
    +    print("  " + "-" * 74)
    +    for name, _, bestellung in verfahren:
    +        normal = tageskosten(bestellung[~ist_aktion], nachfrage[pruefen][~ist_aktion])
    +        aktionstag = tageskosten(bestellung[ist_aktion], nachfrage[pruefen][ist_aktion])
    +        print(f"  {name:<42} {normal:>10.2f} EUR {aktionstag:>10.2f} EUR")
    +
    +    # Nachgerechnet statt behauptet: Welcher Zuschlag waere je Tagesart richtig?
    +    aktion_training = aktion[lernen] > 0.5
    +    z = norm.ppf(KRITISCHES_VERHAELTNIS)
    +    richtig_normal = z * restfehler[~aktion_training].std()
    +    richtig_aktion = z * restfehler[aktion_training].std()
    +    print(f"\n  Der pauschale Zuschlag betraegt {pauschalzuschlag:.1f} Stueck. Aus den")
    +    print(f"  Trainingsresten getrennt nach Tagesart waere richtig:")
    +    print(f"    normale Tage : {richtig_normal:5.1f} Stueck")
    +    print(f"    Aktionstage  : {richtig_aktion:5.1f} Stueck")
    +    print(f"  Ein Zuschlag fuer alle Tage kann nur einen Mittelweg treffen - hier")
    +    print(f"  ist er an normalen Tagen {pauschalzuschlag / richtig_normal:.1f}-mal zu gross und an")
    +    print(f"  Aktionstagen nur {pauschalzuschlag / richtig_aktion:.0%} dessen, was noetig waere.")
    +    print(f"\n  Die Quantilregression schaetzt das {KRITISCHES_VERHAELTNIS:.1%}-Quantil "
    +          f"direkt aus den")
    +    print(f"  Merkmalen und darf deshalb an verschiedenen Tagen verschieden weit")
    +    print(f"  ueber dem Erwartungswert liegen. Genau das ist der Unterschied")
    +    print(f"  zwischen 'ein Modell und danach eine Formel' und 'ein Modell, das")
    +    print(f"  weiss, wofuer es gebraucht wird'.")
    +
    +    # --- Die Messfalle ---------------------------------------------------
    +    print("\n" + "-" * 84)
    +    print("Eine Messfalle, in die der Autor zuerst selbst getappt ist\n")
    +    print("  Der erste Entwurf dieses Programms bewertete auf 230 Testtagen - ein")
    +    print("  realistischer Zeitraum. Dort hatte die Quantilregression den BESSEREN")
    +    print("  MSE, und die ganze Aussage des Kapitels stand auf dem Kopf.")
    +    print("\n  Wie oft das passiert, laesst sich ausmessen:\n")
    +    rng = np.random.default_rng(0)
    +    print(f"  {'Testfenster':>14} {'QR sieht MSE-besser aus':>26}")
    +    print("  " + "-" * 42)
    +    for fenster in (180, 365, 730, 2000):
    +        treffer = 0
    +        versuche = 400
    +        for _ in range(versuche):
    +            start = int(rng.integers(TRAINING, TAGE - fenster))
    +            ausschnitt = slice(start, start + fenster)
    +            mse_punkt = ((kleinste_quadrate.predict(merkmale[ausschnitt])
    +                          - nachfrage[ausschnitt]) ** 2).mean()
    +            mse_quantil = ((quantilmodell.predict(merkmale[ausschnitt])
    +                            - nachfrage[ausschnitt]) ** 2).mean()
    +            treffer += mse_quantil < mse_punkt
    +        print(f"  {fenster:>10} Tage {treffer / versuche:>24.1%}")
    +
    +    print("\n  Bei einem halben Jahr Testdaten sieht das schlechtere Modell in gut")
    +    print("  jedem zehnten Fall besser aus. Das ist keine grosse Zahl - aber wer")
    +    print("  EINMAL misst, hat genau eine Ziehung aus dieser Verteilung.")
    +    print("\n  Die Lehre ist nicht 'nimm 4.000 Testtage' - die hat niemand. Sie")
    +    print("  lautet: Ein Kennzahlenvergleich ohne Angabe seiner Streuung ist keine")
    +    print("  Aussage. Bei kurzen Zeitraeumen gehoert eine Kreuzvalidierung dazu.")
    +
    +    print("\n" + "=" * 84)
    +    print("  WAS MAN DARAUS MITNIMMT")
    +    print("=" * 84)
    +    print("Der Prognostiker optimiert den MSE, der Planer traegt die Kosten - und")
    +    print("die beiden Masse zeigen hier in verschiedene Richtungen. Drei Saetze:")
    +    print()
    +    print("  1. Sagen Sie nicht den Erwartungswert vorher, sondern die Groesse, die")
    +    print("     in die Entscheidung eingeht. Beim Newsvendor ist das das kritische")
    +    print("     Quantil - und das kann man direkt schaetzen.")
    +    print("  2. Bewerten Sie Prognosemodelle an den ENTSCHEIDUNGSKOSTEN. Die sind")
    +    print("     in Euro und damit vergleichbar; ein MSE ist es nicht.")
    +    print("  3. Ein pauschaler Sicherheitszuschlag ist besser als nichts und")
    +    print("     schlechter als ein Modell, das die Unsicherheit selbst aus den")
    +    print("     Merkmalen liest.")
    +    print()
    +    print("Der naechste Schritt - Prognosemodelle so zu trainieren, dass sie die")
    +    print("Entscheidungskosten direkt minimieren (Smart Predict-then-Optimize,")
    +    print("differenzierbare Optimierungsschichten) - ist Forschungsstand und")
    +    print("erfordert Bibliotheken wie cvxpylayers. Die dritte Zeile der Tabelle")
    +    print("oben ist seine einfachste denkbare Form: ein einziger Parameter, auf")
    +    print("Kosten statt auf Fehler trainiert.")
    +    print("=" * 84)

    Erwartete Ausgabe:

    ====================================================================================
       DIE BESSERE PROGNOSE TRIFFT DIE SCHLECHTERE ENTSCHEIDUNG
    @@ -19810,22 +20126,22 @@ Kosten statt auf Fehler trainiert.
     
     

    Lesen Sie die Tabelle noch einmal — und ersetzen Sie „Kapital” durch „Montagestunden” und „Risikobudget” durch „Plattenmaterial”. Es ist die Schreinerei aus Kapitel 1, Wort für Wort.

    Deshalb genügt der vorhandene Code:

    -
    from or_kern import Produkt, Produktionsproblem, loese_mit_scipy
    -
    -depot = Produktionsproblem(
    -    produkte=[
    -        Produkt(name="Aktien Welt", deckungsbeitrag=75.0,
    -                verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 1.00}),
    -        Produkt(name="Anleihen", deckungsbeitrag=28.0,
    -                verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.22}),
    -        Produkt(name="Immobilienfonds", deckungsbeitrag=46.0,
    -                verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.55}),
    -    ],
    -    kapazitaeten={"Kapital (Tsd. EUR)": 150.0, "Risikobudget": 90.0})
    -
    -loesung = loese_mit_scipy(depot)
    -print(loesung.als_bericht())
    -print(loesung.schattenpreise)
    +
    from or_kern import Produkt, Produktionsproblem, loese_mit_scipy
    +
    +depot = Produktionsproblem(
    +    produkte=[
    +        Produkt(name="Aktien Welt", deckungsbeitrag=75.0,
    +                verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 1.00}),
    +        Produkt(name="Anleihen", deckungsbeitrag=28.0,
    +                verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.22}),
    +        Produkt(name="Immobilienfonds", deckungsbeitrag=46.0,
    +                verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.55}),
    +    ],
    +    kapazitaeten={"Kapital (Tsd. EUR)": 150.0, "Risikobudget": 90.0})
    +
    +loesung = loese_mit_scipy(depot)
    +print(loesung.als_bericht())
    +print(loesung.schattenpreise)

    Ausgabe:

    Status: optimal | Zielwert: 7,634.62 | Gap: 0.00% | Zeit: 0.00s
     {'Kapital (Tsd. EUR)': 14.74, 'Risikobudget': 60.26}
    @@ -19847,281 +20163,281 @@ Kosten statt auf Fehler trainiert.

    16.3 Das Programm

    -
    #!/usr/bin/env python3
    -
    -# Strukturbruecke.py
    -"""
    -Kapitel Bruecke: Derselbe Code, zwei Welten - der Beweis statt der Behauptung.
    -
    -Der Teil-Auftakt stellt eine Tabelle auf: "Ressourcen auf Produkte verteilen"
    -entspreche "Kapital auf Anlagen verteilen", "gegen den Worst Case absichern"
    -entspreche "Absicherung gegen Kursabstuerze". Solche Tabellen stehen in vielen
    -Buechern. Sie sind billig - und man kann sie pruefen.
    -
    -Dieses Programm prueft sie. Es fuettert zweimal DENSELBEN Code mit Daten aus
    -zwei Welten und zeigt die Ergebnisse nebeneinander:
    -
    -  1. Die Allokation als LP. Das Domaenenmodell aus or_kern.py, einmal mit einer
    -     Schreinerei (Montagestunden, Plattenmaterial) und einmal mit einem Depot
    -     (Kapital, Risikobudget). Gleiche Klasse, gleicher Modellbauer, gleiche
    -     Abnahmepruefung - und der Schattenpreis heisst in der einen Welt
    -     "Wert einer zusaetzlichen Montagestunde" und in der anderen "Preis des
    -     Risikos".
    -  2. Die Absicherung gegen den schlechtesten Fall als CVaR. EINE Funktion,
    -     einmal mit Kursrenditen und einmal mit Lieferverzuegen.
    -  3. Was NICHT hinueberreicht. Der ehrliche Teil: Drei Unterschiede, die die
    -     Analogie begrenzen - und die man kennen muss, bevor man Methoden aus
    -     der einen Welt in die andere traegt.
    -
    -ZUR SOLVERWAHL: Teil 1 benutzt 'loese_mit_scipy' und nicht 'loese_mit_glop'.
    -Das ist kein Zufall - CVXPY laedt fuer Teil 2 highspy, und ortools vertraegt
    -sich damit nicht im selben Prozess (Kapitel Oekosystem). Wer hier GLOP nimmt,
    -bekommt beim cvxpy-Import eine Fehlermeldung ueber ein 'undefined symbol'.
    -Genau deshalb laedt or_kern.py seine Solver erst beim Aufruf.
    -
    -Benoetigt: numpy, cvxpy, scipy und pydantic (ueber or_kern)
    -"""
    -
    -from __future__ import annotations
    -
    -import numpy as np
    -
    -from or_kern import (Produkt, Produktionsproblem, loese_mit_scipy,
    -                     pruefe_loesung)
    -
    -SAAT = 7
    -SZENARIEN = 500
    -ALPHA = 0.95              # die schlechtesten 5 % der Faelle
    -MAX_ANTEIL = 0.40         # Streuungsgebot in beiden Welten
    -
    -
    -# --- Teil 1: Dieselbe Klasse, zwei Welten ----------------------------------
    -
    -def schreinerei() -> Produktionsproblem:
    -    """Montagestunden und Plattenmaterial auf Tische und Stuehle verteilen."""
    -    return Produktionsproblem(
    -        produkte=[
    -            Produkt(name="Tisch", deckungsbeitrag=240.0,
    -                    verbrauch={"Montagestunden": 3.0, "Plattenmaterial": 6.0}),
    -            Produkt(name="Stuhl", deckungsbeitrag=60.0,
    -                    verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0}),
    -            Produkt(name="Regal", deckungsbeitrag=130.0,
    -                    verbrauch={"Montagestunden": 2.0, "Plattenmaterial": 4.0}),
    -        ],
    -        kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0})
    -
    -
    -def depot() -> Produktionsproblem:
    -    """Kapital und Risikobudget auf Anlageklassen verteilen.
    -
    -    Eine "Einheit" ist hier 1.000 EUR Anlagesumme. Der Deckungsbeitrag ist der
    -    erwartete Jahresertrag dieser Einheit, der Verbrauch die beanspruchte
    -    Kapital- und Risikomenge. Das ist keine Analogie, sondern buchstaeblich
    -    dasselbe Modell - deshalb passt es in dieselbe Klasse.
    -    """
    -    return Produktionsproblem(
    -        produkte=[
    -            Produkt(name="Aktien Welt", deckungsbeitrag=75.0,
    -                    verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 1.00}),
    -            Produkt(name="Anleihen", deckungsbeitrag=28.0,
    -                    verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.22}),
    -            Produkt(name="Immobilienfonds", deckungsbeitrag=46.0,
    -                    verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.55}),
    -        ],
    -        kapazitaeten={"Kapital (Tsd. EUR)": 150.0, "Risikobudget": 90.0})
    -
    -
    -def berichte_allokation(titel: str, problem: Produktionsproblem,
    -                        einheit: str, ertragsname: str) -> None:
    -    """Ein Bericht fuer beide Welten - nur die Beschriftung wechselt."""
    -    loesung = loese_mit_scipy(problem)
    -    beanstandungen = pruefe_loesung(problem, loesung)
    -
    -    print(f"  {titel}")
    -    print(f"    {loesung.als_bericht()}")
    -    for produkt in problem.produkte:
    -        print(f"      {produkt.name:<18} {loesung.werte[produkt.name]:8.2f} {einheit}")
    -    print(f"      {ertragsname:<18} {loesung.zielwert:8.2f} EUR")
    -    for ressource, preis in loesung.schattenpreise.items():
    -        print(f"      Schattenpreis {ressource:<24} {preis:7.2f} EUR")
    -    print(f"      Abnahmepruefung: "
    -          f"{'bestanden' if not beanstandungen else beanstandungen}")
    -
    -
    -# --- Teil 2: Eine CVaR-Funktion, zwei Welten -------------------------------
    -
    -def optimiere_cvar(verluste: np.ndarray, ertrag: np.ndarray,
    -                   mindestertrag: float, alpha: float = ALPHA,
    -                   max_anteil: float = MAX_ANTEIL):
    -    """Minimiert den CVaR der Verluste unter einer Mindestertragsbedingung.
    -
    -    'verluste' hat die Form (Szenarien x Optionen) und enthaelt, was in
    -    Szenario s passiert, wenn eine Einheit in Option j steckt. Was ein
    -    "Verlust" ist, entscheidet allein die Einheit der Matrix: Prozentpunkte
    -    Kursverlust oder Tage Lieferverzug - die Formel sieht keinen Unterschied.
    -
    -    Das ist die Rockafellar-Uryasev-Formulierung aus dem Kapitel CVaR, hier
    -    ohne jede Aenderung wiederverwendet.
    -    """
    -    import cvxpy as cp
    -
    -    anzahl_szenarien, anzahl_optionen = verluste.shape
    -    anteil = cp.Variable(anzahl_optionen, nonneg=True)
    -    schwelle = cp.Variable()                       # wird im Optimum zum VaR
    -    ueberschuss = cp.Variable(anzahl_szenarien, nonneg=True)
    -
    -    cvar = schwelle + (1.0 / (anzahl_szenarien * (1 - alpha))) * cp.sum(ueberschuss)
    -    problem = cp.Problem(
    -        cp.Minimize(cvar),
    -        [ueberschuss >= verluste @ anteil - schwelle,
    -         cp.sum(anteil) == 1,
    -         anteil <= max_anteil,
    -         ertrag @ anteil >= mindestertrag])
    -    problem.solve()
    -    if problem.status not in ("optimal", "optimal_inaccurate"):
    -        raise SystemExit(f"CVaR-Problem nicht loesbar: {problem.status}")
    -    return anteil.value, float(cvar.value), float(schwelle.value)
    -
    -
    -def kursszenarien(rng) -> tuple[np.ndarray, np.ndarray, list[str]]:
    -    """Taegliche Verluste (negative Renditen) von sechs Anlageklassen."""
    -    namen = ["Aktien Welt", "Aktien EU", "Schwellenlaender",
    -             "Staatsanleihen", "Unternehmensanl.", "Rohstoffe"]
    -    rendite_pa = np.array([0.080, 0.065, 0.110, 0.025, 0.045, 0.070])
    -    schwankung = np.array([0.180, 0.160, 0.260, 0.040, 0.075, 0.210])
    -    taeglich = rng.normal(rendite_pa / 252, schwankung / np.sqrt(252),
    -                          (SZENARIEN, len(namen)))
    -    return -taeglich * 100.0, rendite_pa * 100.0, namen
    -
    -
    -def lieferszenarien(rng) -> tuple[np.ndarray, np.ndarray, list[str]]:
    -    """Lieferverzug in Tagen bei sechs Lieferanten.
    -
    -    Der Aufbau ist bewusst anders als bei den Kursen: Hier gibt es einen
    -    normalen Verzug UND seltene Totalausfaelle, die 14 Tage kosten. Das ist
    -    genau die Art fetter Raender, wegen der man in beiden Welten CVaR statt
    -    Standardabweichung benutzt.
    -    """
    -    namen = ["Nordwerk", "Sued-Metall", "Fernost A", "Lokalzulieferer",
    -             "Fernost B", "Osteuropa"]
    -    zuverlaessigkeit = np.array([0.94, 0.97, 0.90, 0.99, 0.92, 0.95])
    -    verzugsstreuung = np.array([3.5, 1.8, 5.0, 0.9, 4.2, 2.8])
    -    normaler_verzug = np.maximum(0.0, rng.normal(0.5, 1.0, (SZENARIEN, len(namen)))
    -                                 * verzugsstreuung)
    -    totalausfall = (rng.random((SZENARIEN, len(namen))) > zuverlaessigkeit) * 14.0
    -    return normaler_verzug + totalausfall, zuverlaessigkeit, namen
    -
    -
    -def berichte_cvar(titel: str, verluste, ertrag, namen, mindestertrag,
    -                  einheit: str, ertragsname: str):
    -    anteile, cvar, var = optimiere_cvar(verluste, ertrag, mindestertrag)
    -    mittel = float((verluste @ anteile).mean())
    -    print(f"  {titel}")
    -    print(f"    VaR  {ALPHA:.0%}: {var:8.3f} {einheit}")
    -    print(f"    CVaR {ALPHA:.0%}: {cvar:8.3f} {einheit}      "
    -          f"(Mittelwert ueber alle Szenarien: {mittel:.3f})")
    -    print(f"    {ertragsname}: {float(ertrag @ anteile):.3f}")
    -    print("    Aufteilung:")
    -    for name, anteil in zip(namen, anteile):
    -        balken = "#" * int(round(anteil * 40))
    -        grenze = "  <- an der Streuungsgrenze" if anteil > MAX_ANTEIL - 1e-4 else ""
    -        print(f"      {name:<18} {anteil:6.1%}  {balken}{grenze}")
    -    return anteile
    -
    -
    -if __name__ == "__main__":
    -    rng = np.random.default_rng(SAAT)
    -
    -    print("=" * 80)
    -    print("  DIESELBE STRUKTUR, ZWEI WELTEN")
    -    print("=" * 80)
    -
    -    # --- Teil 1 ----------------------------------------------------------
    -    print("\n1. Allokation als LP - EINE Klasse, EIN Modellbauer\n")
    -    berichte_allokation("Werkstatt: Produktionsprogramm", schreinerei(),
    -                        "Stueck", "Deckungsbeitrag")
    -    print()
    -    berichte_allokation("Depot: Anlageaufteilung", depot(),
    -                        "Tsd.  ", "Erwarteter Ertrag")
    -
    -    print("\n  Beide Ausgaben stammen aus derselben Funktion "
    -          "'berichte_allokation'.")
    -    print("  Ausgetauscht wurden nur die Daten und die Beschriftungen -")
    -    print("  keine Zeile Modellcode.")
    -    print()
    -    print("  Lesen Sie die Schattenpreise nebeneinander: In der Werkstatt sagt")
    -    print("  er, was eine zusaetzliche Montagestunde wert waere. Im Depot sagt")
    -    print("  dieselbe Zahl, was eine zusaetzliche Einheit Risikobudget wert")
    -    print("  waere - der PREIS DES RISIKOS. Das ist kein Sprachbild, sondern")
    -    print("  derselbe Dualwert derselben Nebenbedingung.")
    -
    -    # --- Teil 2 ----------------------------------------------------------
    -    print("\n" + "-" * 80)
    -    print("2. Absicherung gegen den schlechtesten Fall - EINE CVaR-Funktion\n")
    -
    -    kurse, rendite, anlagen = kursszenarien(rng)
    -    anteile_depot = berichte_cvar(
    -                  "Depot: die schlechtesten 5 % der Handelstage",
    -                  kurse, rendite, anlagen, 5.5,
    -                  "% je Tag    ", "Erwartete Jahresrendite (%)")
    -    print()
    -    lieferung, zuverlaessig, lieferanten = lieferszenarien(rng)
    -    anteile_einkauf = berichte_cvar("Einkauf: die schlechtesten 5 % der Bestellungen",
    -                  lieferung, zuverlaessig, lieferanten, 0.945,
    -                  "Tage Verzug  ", "Mittlere Zuverlaessigkeit")
    -
    -    print("\n  Auch hier: eine Funktion, zwei Aufrufe. Die Zielfunktion")
    -    print("  interessiert sich nicht dafuer, ob in der Matrix Prozentpunkte")
    -    print("  oder Tage stehen.")
    -    print()
    -    print(f"  Interessant ist, WO die Streuungsgrenze von {MAX_ANTEIL:.0%} bindet:")
    -    print(f"    Depot   : {(anteile_depot > MAX_ANTEIL - 1e-4).sum()} von "
    -          f"{len(anteile_depot)} Posten am Anschlag")
    -    print(f"    Einkauf : {(anteile_einkauf > MAX_ANTEIL - 1e-4).sum()} von "
    -          f"{len(anteile_einkauf)} Posten am Anschlag")
    -    print()
    -    print("  Im Einkauf zieht es die Loesung an den sicheren Lokalzulieferer,")
    -    print("  bis die Grenze sie stoppt. Im Depot nicht - dort verhindert die")
    -    print("  Mindestrendite, dass alles in Anleihen wandert. Zwei verschiedene")
    -    print("  Bremsen also, und sie stehen an verschiedenen Stellen des Modells:")
    -    print("  einmal in einer Nebenbedingung ueber die Anteile, einmal in einer")
    -    print("  ueber den Ertrag. Wer eine Struktur uebertraegt, uebertraegt eben")
    -    print("  nicht automatisch mit, WELCHE Bedingung am Ende bindet.")
    -
    -    # --- Teil 3: Der ehrliche Teil ---------------------------------------
    -    print("\n" + "=" * 80)
    -    print("  WAS NICHT HINUEBERREICHT")
    -    print("=" * 80)
    -    print("Die Struktur traegt. Drei Unterschiede tragen NICHT mit, und wer sie")
    -    print("uebersieht, macht aus einer nuetzlichen Analogie einen Fehler:")
    -    print()
    -    print("1. WOHER DIE ZAHLEN KOMMEN.")
    -    print("   In der Werkstatt ist der Verbrauch je Tisch gemessen - drei")
    -    print("   Montagestunden sind drei Montagestunden. Im Depot ist die")
    -    print("   erwartete Rendite GESCHAETZT, und zwar mit einem Fehler, der")
    -    print("   groesser sein kann als die Unterschiede zwischen den Anlagen")
    -    print("   (Kapitel Markowitz, Renditeschaetzung_Falle.py). Dieselbe")
    -    print("   Optimierung ist im einen Fall Planung und im anderen")
    -    print("   Fehlerverstaerkung.")
    -    print()
    -    print("2. OB DIE VERGANGENHEIT ETWAS UEBER DIE ZUKUNFT SAGT.")
    -    print("   Lieferzeiten haben physikalische Ursachen: Entfernung, Zoll,")
    -    print("   Kapazitaet. Sie aendern sich langsam und nachvollziehbar.")
    -    print("   Kursrenditen entstehen aus dem Verhalten von Marktteilnehmern,")
    -    print("   die selbst auf Modelle reagieren - dort verschwindet ein")
    -    print("   erkanntes Muster oft genau deshalb, weil es erkannt wurde")
    -    print("   (Kapitel Handelsmaschine, Data_Snooping.py).")
    -    print()
    -    print("3. OB TEILBARKEIT ERLAUBT IST.")
    -    print("   37,4 % eines Aktienfonds sind ein normaler Auftrag. 37,4 % eines")
    -    print("   Lieferanten sind es nicht - Vertraege, Mindestabnahmen und")
    -    print("   Ruestzeiten machen Einkaufsentscheidungen ganzzahlig. Genau")
    -    print("   deshalb ist Teil II voller MILP und Teil V fast frei davon.")
    -    print()
    -    print("Die Bruecke traegt also die MODELLE, nicht die Annahmen. Wer sie")
    -    print("benutzt, spart sich das Lernen der Methoden - nicht das Nachdenken")
    -    print("ueber die Daten.")
    -    print("=" * 80)
    +
    #!/usr/bin/env python3
    +
    +# Strukturbruecke.py
    +"""
    +Kapitel Bruecke: Derselbe Code, zwei Welten - der Beweis statt der Behauptung.
    +
    +Der Teil-Auftakt stellt eine Tabelle auf: "Ressourcen auf Produkte verteilen"
    +entspreche "Kapital auf Anlagen verteilen", "gegen den Worst Case absichern"
    +entspreche "Absicherung gegen Kursabstuerze". Solche Tabellen stehen in vielen
    +Buechern. Sie sind billig - und man kann sie pruefen.
    +
    +Dieses Programm prueft sie. Es fuettert zweimal DENSELBEN Code mit Daten aus
    +zwei Welten und zeigt die Ergebnisse nebeneinander:
    +
    +  1. Die Allokation als LP. Das Domaenenmodell aus or_kern.py, einmal mit einer
    +     Schreinerei (Montagestunden, Plattenmaterial) und einmal mit einem Depot
    +     (Kapital, Risikobudget). Gleiche Klasse, gleicher Modellbauer, gleiche
    +     Abnahmepruefung - und der Schattenpreis heisst in der einen Welt
    +     "Wert einer zusaetzlichen Montagestunde" und in der anderen "Preis des
    +     Risikos".
    +  2. Die Absicherung gegen den schlechtesten Fall als CVaR. EINE Funktion,
    +     einmal mit Kursrenditen und einmal mit Lieferverzuegen.
    +  3. Was NICHT hinueberreicht. Der ehrliche Teil: Drei Unterschiede, die die
    +     Analogie begrenzen - und die man kennen muss, bevor man Methoden aus
    +     der einen Welt in die andere traegt.
    +
    +ZUR SOLVERWAHL: Teil 1 benutzt 'loese_mit_scipy' und nicht 'loese_mit_glop'.
    +Das ist kein Zufall - CVXPY laedt fuer Teil 2 highspy, und ortools vertraegt
    +sich damit nicht im selben Prozess (Kapitel Oekosystem). Wer hier GLOP nimmt,
    +bekommt beim cvxpy-Import eine Fehlermeldung ueber ein 'undefined symbol'.
    +Genau deshalb laedt or_kern.py seine Solver erst beim Aufruf.
    +
    +Benoetigt: numpy, cvxpy, scipy und pydantic (ueber or_kern)
    +"""
    +
    +from __future__ import annotations
    +
    +import numpy as np
    +
    +from or_kern import (Produkt, Produktionsproblem, loese_mit_scipy,
    +                     pruefe_loesung)
    +
    +SAAT = 7
    +SZENARIEN = 500
    +ALPHA = 0.95              # die schlechtesten 5 % der Faelle
    +MAX_ANTEIL = 0.40         # Streuungsgebot in beiden Welten
    +
    +
    +# --- Teil 1: Dieselbe Klasse, zwei Welten ----------------------------------
    +
    +def schreinerei() -> Produktionsproblem:
    +    """Montagestunden und Plattenmaterial auf Tische und Stuehle verteilen."""
    +    return Produktionsproblem(
    +        produkte=[
    +            Produkt(name="Tisch", deckungsbeitrag=240.0,
    +                    verbrauch={"Montagestunden": 3.0, "Plattenmaterial": 6.0}),
    +            Produkt(name="Stuhl", deckungsbeitrag=60.0,
    +                    verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0}),
    +            Produkt(name="Regal", deckungsbeitrag=130.0,
    +                    verbrauch={"Montagestunden": 2.0, "Plattenmaterial": 4.0}),
    +        ],
    +        kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0})
    +
    +
    +def depot() -> Produktionsproblem:
    +    """Kapital und Risikobudget auf Anlageklassen verteilen.
    +
    +    Eine "Einheit" ist hier 1.000 EUR Anlagesumme. Der Deckungsbeitrag ist der
    +    erwartete Jahresertrag dieser Einheit, der Verbrauch die beanspruchte
    +    Kapital- und Risikomenge. Das ist keine Analogie, sondern buchstaeblich
    +    dasselbe Modell - deshalb passt es in dieselbe Klasse.
    +    """
    +    return Produktionsproblem(
    +        produkte=[
    +            Produkt(name="Aktien Welt", deckungsbeitrag=75.0,
    +                    verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 1.00}),
    +            Produkt(name="Anleihen", deckungsbeitrag=28.0,
    +                    verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.22}),
    +            Produkt(name="Immobilienfonds", deckungsbeitrag=46.0,
    +                    verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.55}),
    +        ],
    +        kapazitaeten={"Kapital (Tsd. EUR)": 150.0, "Risikobudget": 90.0})
    +
    +
    +def berichte_allokation(titel: str, problem: Produktionsproblem,
    +                        einheit: str, ertragsname: str) -> None:
    +    """Ein Bericht fuer beide Welten - nur die Beschriftung wechselt."""
    +    loesung = loese_mit_scipy(problem)
    +    beanstandungen = pruefe_loesung(problem, loesung)
    +
    +    print(f"  {titel}")
    +    print(f"    {loesung.als_bericht()}")
    +    for produkt in problem.produkte:
    +        print(f"      {produkt.name:<18} {loesung.werte[produkt.name]:8.2f} {einheit}")
    +    print(f"      {ertragsname:<18} {loesung.zielwert:8.2f} EUR")
    +    for ressource, preis in loesung.schattenpreise.items():
    +        print(f"      Schattenpreis {ressource:<24} {preis:7.2f} EUR")
    +    print(f"      Abnahmepruefung: "
    +          f"{'bestanden' if not beanstandungen else beanstandungen}")
    +
    +
    +# --- Teil 2: Eine CVaR-Funktion, zwei Welten -------------------------------
    +
    +def optimiere_cvar(verluste: np.ndarray, ertrag: np.ndarray,
    +                   mindestertrag: float, alpha: float = ALPHA,
    +                   max_anteil: float = MAX_ANTEIL):
    +    """Minimiert den CVaR der Verluste unter einer Mindestertragsbedingung.
    +
    +    'verluste' hat die Form (Szenarien x Optionen) und enthaelt, was in
    +    Szenario s passiert, wenn eine Einheit in Option j steckt. Was ein
    +    "Verlust" ist, entscheidet allein die Einheit der Matrix: Prozentpunkte
    +    Kursverlust oder Tage Lieferverzug - die Formel sieht keinen Unterschied.
    +
    +    Das ist die Rockafellar-Uryasev-Formulierung aus dem Kapitel CVaR, hier
    +    ohne jede Aenderung wiederverwendet.
    +    """
    +    import cvxpy as cp
    +
    +    anzahl_szenarien, anzahl_optionen = verluste.shape
    +    anteil = cp.Variable(anzahl_optionen, nonneg=True)
    +    schwelle = cp.Variable()                       # wird im Optimum zum VaR
    +    ueberschuss = cp.Variable(anzahl_szenarien, nonneg=True)
    +
    +    cvar = schwelle + (1.0 / (anzahl_szenarien * (1 - alpha))) * cp.sum(ueberschuss)
    +    problem = cp.Problem(
    +        cp.Minimize(cvar),
    +        [ueberschuss >= verluste @ anteil - schwelle,
    +         cp.sum(anteil) == 1,
    +         anteil <= max_anteil,
    +         ertrag @ anteil >= mindestertrag])
    +    problem.solve()
    +    if problem.status not in ("optimal", "optimal_inaccurate"):
    +        raise SystemExit(f"CVaR-Problem nicht loesbar: {problem.status}")
    +    return anteil.value, float(cvar.value), float(schwelle.value)
    +
    +
    +def kursszenarien(rng) -> tuple[np.ndarray, np.ndarray, list[str]]:
    +    """Taegliche Verluste (negative Renditen) von sechs Anlageklassen."""
    +    namen = ["Aktien Welt", "Aktien EU", "Schwellenlaender",
    +             "Staatsanleihen", "Unternehmensanl.", "Rohstoffe"]
    +    rendite_pa = np.array([0.080, 0.065, 0.110, 0.025, 0.045, 0.070])
    +    schwankung = np.array([0.180, 0.160, 0.260, 0.040, 0.075, 0.210])
    +    taeglich = rng.normal(rendite_pa / 252, schwankung / np.sqrt(252),
    +                          (SZENARIEN, len(namen)))
    +    return -taeglich * 100.0, rendite_pa * 100.0, namen
    +
    +
    +def lieferszenarien(rng) -> tuple[np.ndarray, np.ndarray, list[str]]:
    +    """Lieferverzug in Tagen bei sechs Lieferanten.
    +
    +    Der Aufbau ist bewusst anders als bei den Kursen: Hier gibt es einen
    +    normalen Verzug UND seltene Totalausfaelle, die 14 Tage kosten. Das ist
    +    genau die Art fetter Raender, wegen der man in beiden Welten CVaR statt
    +    Standardabweichung benutzt.
    +    """
    +    namen = ["Nordwerk", "Sued-Metall", "Fernost A", "Lokalzulieferer",
    +             "Fernost B", "Osteuropa"]
    +    zuverlaessigkeit = np.array([0.94, 0.97, 0.90, 0.99, 0.92, 0.95])
    +    verzugsstreuung = np.array([3.5, 1.8, 5.0, 0.9, 4.2, 2.8])
    +    normaler_verzug = np.maximum(0.0, rng.normal(0.5, 1.0, (SZENARIEN, len(namen)))
    +                                 * verzugsstreuung)
    +    totalausfall = (rng.random((SZENARIEN, len(namen))) > zuverlaessigkeit) * 14.0
    +    return normaler_verzug + totalausfall, zuverlaessigkeit, namen
    +
    +
    +def berichte_cvar(titel: str, verluste, ertrag, namen, mindestertrag,
    +                  einheit: str, ertragsname: str):
    +    anteile, cvar, var = optimiere_cvar(verluste, ertrag, mindestertrag)
    +    mittel = float((verluste @ anteile).mean())
    +    print(f"  {titel}")
    +    print(f"    VaR  {ALPHA:.0%}: {var:8.3f} {einheit}")
    +    print(f"    CVaR {ALPHA:.0%}: {cvar:8.3f} {einheit}      "
    +          f"(Mittelwert ueber alle Szenarien: {mittel:.3f})")
    +    print(f"    {ertragsname}: {float(ertrag @ anteile):.3f}")
    +    print("    Aufteilung:")
    +    for name, anteil in zip(namen, anteile):
    +        balken = "#" * int(round(anteil * 40))
    +        grenze = "  <- an der Streuungsgrenze" if anteil > MAX_ANTEIL - 1e-4 else ""
    +        print(f"      {name:<18} {anteil:6.1%}  {balken}{grenze}")
    +    return anteile
    +
    +
    +if __name__ == "__main__":
    +    rng = np.random.default_rng(SAAT)
    +
    +    print("=" * 80)
    +    print("  DIESELBE STRUKTUR, ZWEI WELTEN")
    +    print("=" * 80)
    +
    +    # --- Teil 1 ----------------------------------------------------------
    +    print("\n1. Allokation als LP - EINE Klasse, EIN Modellbauer\n")
    +    berichte_allokation("Werkstatt: Produktionsprogramm", schreinerei(),
    +                        "Stueck", "Deckungsbeitrag")
    +    print()
    +    berichte_allokation("Depot: Anlageaufteilung", depot(),
    +                        "Tsd.  ", "Erwarteter Ertrag")
    +
    +    print("\n  Beide Ausgaben stammen aus derselben Funktion "
    +          "'berichte_allokation'.")
    +    print("  Ausgetauscht wurden nur die Daten und die Beschriftungen -")
    +    print("  keine Zeile Modellcode.")
    +    print()
    +    print("  Lesen Sie die Schattenpreise nebeneinander: In der Werkstatt sagt")
    +    print("  er, was eine zusaetzliche Montagestunde wert waere. Im Depot sagt")
    +    print("  dieselbe Zahl, was eine zusaetzliche Einheit Risikobudget wert")
    +    print("  waere - der PREIS DES RISIKOS. Das ist kein Sprachbild, sondern")
    +    print("  derselbe Dualwert derselben Nebenbedingung.")
    +
    +    # --- Teil 2 ----------------------------------------------------------
    +    print("\n" + "-" * 80)
    +    print("2. Absicherung gegen den schlechtesten Fall - EINE CVaR-Funktion\n")
    +
    +    kurse, rendite, anlagen = kursszenarien(rng)
    +    anteile_depot = berichte_cvar(
    +                  "Depot: die schlechtesten 5 % der Handelstage",
    +                  kurse, rendite, anlagen, 5.5,
    +                  "% je Tag    ", "Erwartete Jahresrendite (%)")
    +    print()
    +    lieferung, zuverlaessig, lieferanten = lieferszenarien(rng)
    +    anteile_einkauf = berichte_cvar("Einkauf: die schlechtesten 5 % der Bestellungen",
    +                  lieferung, zuverlaessig, lieferanten, 0.945,
    +                  "Tage Verzug  ", "Mittlere Zuverlaessigkeit")
    +
    +    print("\n  Auch hier: eine Funktion, zwei Aufrufe. Die Zielfunktion")
    +    print("  interessiert sich nicht dafuer, ob in der Matrix Prozentpunkte")
    +    print("  oder Tage stehen.")
    +    print()
    +    print(f"  Interessant ist, WO die Streuungsgrenze von {MAX_ANTEIL:.0%} bindet:")
    +    print(f"    Depot   : {(anteile_depot > MAX_ANTEIL - 1e-4).sum()} von "
    +          f"{len(anteile_depot)} Posten am Anschlag")
    +    print(f"    Einkauf : {(anteile_einkauf > MAX_ANTEIL - 1e-4).sum()} von "
    +          f"{len(anteile_einkauf)} Posten am Anschlag")
    +    print()
    +    print("  Im Einkauf zieht es die Loesung an den sicheren Lokalzulieferer,")
    +    print("  bis die Grenze sie stoppt. Im Depot nicht - dort verhindert die")
    +    print("  Mindestrendite, dass alles in Anleihen wandert. Zwei verschiedene")
    +    print("  Bremsen also, und sie stehen an verschiedenen Stellen des Modells:")
    +    print("  einmal in einer Nebenbedingung ueber die Anteile, einmal in einer")
    +    print("  ueber den Ertrag. Wer eine Struktur uebertraegt, uebertraegt eben")
    +    print("  nicht automatisch mit, WELCHE Bedingung am Ende bindet.")
    +
    +    # --- Teil 3: Der ehrliche Teil ---------------------------------------
    +    print("\n" + "=" * 80)
    +    print("  WAS NICHT HINUEBERREICHT")
    +    print("=" * 80)
    +    print("Die Struktur traegt. Drei Unterschiede tragen NICHT mit, und wer sie")
    +    print("uebersieht, macht aus einer nuetzlichen Analogie einen Fehler:")
    +    print()
    +    print("1. WOHER DIE ZAHLEN KOMMEN.")
    +    print("   In der Werkstatt ist der Verbrauch je Tisch gemessen - drei")
    +    print("   Montagestunden sind drei Montagestunden. Im Depot ist die")
    +    print("   erwartete Rendite GESCHAETZT, und zwar mit einem Fehler, der")
    +    print("   groesser sein kann als die Unterschiede zwischen den Anlagen")
    +    print("   (Kapitel Markowitz, Renditeschaetzung_Falle.py). Dieselbe")
    +    print("   Optimierung ist im einen Fall Planung und im anderen")
    +    print("   Fehlerverstaerkung.")
    +    print()
    +    print("2. OB DIE VERGANGENHEIT ETWAS UEBER DIE ZUKUNFT SAGT.")
    +    print("   Lieferzeiten haben physikalische Ursachen: Entfernung, Zoll,")
    +    print("   Kapazitaet. Sie aendern sich langsam und nachvollziehbar.")
    +    print("   Kursrenditen entstehen aus dem Verhalten von Marktteilnehmern,")
    +    print("   die selbst auf Modelle reagieren - dort verschwindet ein")
    +    print("   erkanntes Muster oft genau deshalb, weil es erkannt wurde")
    +    print("   (Kapitel Handelsmaschine, Data_Snooping.py).")
    +    print()
    +    print("3. OB TEILBARKEIT ERLAUBT IST.")
    +    print("   37,4 % eines Aktienfonds sind ein normaler Auftrag. 37,4 % eines")
    +    print("   Lieferanten sind es nicht - Vertraege, Mindestabnahmen und")
    +    print("   Ruestzeiten machen Einkaufsentscheidungen ganzzahlig. Genau")
    +    print("   deshalb ist Teil II voller MILP und Teil V fast frei davon.")
    +    print()
    +    print("Die Bruecke traegt also die MODELLE, nicht die Annahmen. Wer sie")
    +    print("benutzt, spart sich das Lernen der Methoden - nicht das Nachdenken")
    +    print("ueber die Daten.")
    +    print("=" * 80)

    Erwartete Ausgabe:

    ================================================================================
       DIESELBE STRUKTUR, ZWEI WELTEN
    @@ -20444,23 +20760,23 @@ u_s \ge \ell_s^\top w - \gamma, \quad u_s \ge 0
     

    🚀 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")
    +
    # 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
    @@ -20632,344 +20948,344 @@ Umschlagpunkt: 3.9 Stunden

    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)
    +
    #!/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
    @@ -21217,18 +21533,18 @@ zweistufig       111111222222222222222111  (39 Blockstunden)

    🚀 In 5 Minuten gelöst: Die durchschnittliche Rendite, die es nicht gibt

    Eine Anlage wurde über vier Perioden beobachtet. Der Kurs stand bei 100, dann bei 50, 75, 150 und schließlich bei 120.

    -
    import numpy as np
    -
    -kurse = np.array([100.0, 50.0, 75.0, 150.0, 120.0])
    -diskret = kurse[1:] / kurse[:-1] - 1          # (neu - alt) / alt
    -log = np.log(kurse[1:] / kurse[:-1])          # ln(neu / alt)
    -
    -print("diskret:", np.round(diskret * 100, 2), "%")
    -print("log    :", np.round(log * 100, 2), "%")
    -print(f"Mittelwert diskret {diskret.mean()*100:5.2f} % -> hochgerechnet "
    -      f"{100 * (1 + diskret.mean())**4:6.2f}")
    -print(f"Mittelwert log     {log.mean()*100:5.2f} % -> hochgerechnet "
    -      f"{100 * np.exp(log.mean() * 4):6.2f}")
    +
    import numpy as np
    +
    +kurse = np.array([100.0, 50.0, 75.0, 150.0, 120.0])
    +diskret = kurse[1:] / kurse[:-1] - 1          # (neu - alt) / alt
    +log = np.log(kurse[1:] / kurse[:-1])          # ln(neu / alt)
    +
    +print("diskret:", np.round(diskret * 100, 2), "%")
    +print("log    :", np.round(log * 100, 2), "%")
    +print(f"Mittelwert diskret {diskret.mean()*100:5.2f} % -> hochgerechnet "
    +      f"{100 * (1 + diskret.mean())**4:6.2f}")
    +print(f"Mittelwert log     {log.mean()*100:5.2f} % -> hochgerechnet "
    +      f"{100 * np.exp(log.mean() * 4):6.2f}")

    Ausgabe:

    diskret: [-50.  50. 100. -20.] %
     log    : [-69.31  40.55  69.31 -22.31] %
    @@ -21353,74 +21669,74 @@ r_{i,0\to T} = \sum_{t=1}^T r_{i,t}
     

    Die diskreten Renditen suggerieren einen Gewinn von 16,7 %, obwohl der Kurs exakt dort steht, wo er begann. Die Log-Renditen heben sich korrekt auf.

    Deshalb ist die Aussage „im Mittel 5 % Rendite“ mehrdeutig. Das arithmetische Mittel diskreter Renditen überschätzt systematisch, was ein Anleger tatsächlich verdient hätte. Für Wachstumsaussagen nimmt man das geometrische Mittel — oder rechnet gleich in Log-Renditen.

    -
    #!/usr/bin/env python3
    -
    -# Renditen_Vergleich.py
    -"""
    -Kapitel Finanzdaten: Diskrete vs. logarithmische Renditen.
    -Zeigt an einem simulierten Kursverlauf, welche Eigenschaft wo gilt -
    -und warum das arithmetische Mittel diskreter Renditen in die Irre fuehrt.
    -"""
    -
    -import numpy as np
    -import pandas as pd
    -
    -if __name__ == "__main__":
    -    kurse = np.array([100.0, 150.0, 100.0, 120.0, 90.0, 135.0])
    -    tage = [f"t{i}" for i in range(len(kurse))]
    -
    -    diskret = kurse[1:] / kurse[:-1] - 1.0
    -    logarithmisch = np.log(kurse[1:] / kurse[:-1])
    -
    -    print("=" * 74)
    -    print("  DISKRETE UND LOGARITHMISCHE RENDITEN IM VERGLEICH")
    -    print("=" * 74)
    -    tabelle = pd.DataFrame({
    -        "Periode": [f"{tage[i]} -> {tage[i+1]}" for i in range(len(diskret))],
    -        "Kurs von": kurse[:-1],
    -        "Kurs bis": kurse[1:],
    -        "diskret R": [f"{r*100:+7.2f} %" for r in diskret],
    -        "log r": [f"{r:+8.4f}" for r in logarithmisch],
    -    })
    -    print(tabelle.to_string(index=False))
    -
    -    # --- Zeitliche Aggregation ------------------------------------------
    -    gesamt_wahr = kurse[-1] / kurse[0] - 1.0
    -    summe_log = logarithmisch.sum()
    -    aus_log_zurueck = np.exp(summe_log) - 1.0
    -    summe_diskret = diskret.sum()
    -
    -    print("\n--- Aggregation ueber die Zeit ---")
    -    print(f"  Tatsaechliche Gesamtrendite:        {gesamt_wahr*100:+8.2f} %")
    -    print(f"  Summe der Log-Renditen -> exp()-1:  {aus_log_zurueck*100:+8.2f} %  "
    -          f"{'KORREKT' if abs(aus_log_zurueck - gesamt_wahr) < 1e-9 else 'falsch'}")
    -    print(f"  Summe der diskreten Renditen:       {summe_diskret*100:+8.2f} %  FALSCH")
    -
    -    # --- Mittelwerte ------------------------------------------------------
    -    arithmetisch = diskret.mean()
    -    geometrisch = np.prod(1 + diskret) ** (1 / len(diskret)) - 1
    -    aus_log = np.exp(logarithmisch.mean()) - 1
    -
    -    print("\n--- Welcher Mittelwert ist der richtige? ---")
    -    print(f"  Arithmetisches Mittel (diskret):    {arithmetisch*100:+8.2f} %  "
    -          f"-> ueberschaetzt")
    -    print(f"  Geometrisches Mittel:               {geometrisch*100:+8.2f} %  -> korrekt")
    -    print(f"  exp(Mittel der Log-Renditen) - 1:   {aus_log*100:+8.2f} %  "
    -          f"-> identisch zum geometrischen")
    -
    -    # Probe: Endkapital mit dem jeweiligen Mittelwert hochgerechnet
    -    n = len(diskret)
    -    print(f"\n  Probe - Startkapital 100 EUR ueber {n} Perioden:")
    -    print(f"    tatsaechlich:                     {kurse[-1]:8.2f} EUR")
    -    print(f"    mit arithm. Mittel hochgerechnet: {100*(1+arithmetisch)**n:8.2f} EUR")
    -    print(f"    mit geom.  Mittel hochgerechnet:  {100*(1+geometrisch)**n:8.2f} EUR")
    -
    -    print("\n--- Annualisierung (252 Handelstage) ---")
    -    print(f"  Volatilitaet aus Log-Renditen: "
    -          f"{logarithmisch.std(ddof=1)*np.sqrt(252)*100:.2f} % p.a.")
    -    print("  (Die Wurzel-Zeit-Regel gilt nur bei unabhaengigen Renditen -")
    -    print("   fuer reale Maerkte ist sie eine Naeherung.)")
    -    print("=" * 74)
    +
    #!/usr/bin/env python3
    +
    +# Renditen_Vergleich.py
    +"""
    +Kapitel Finanzdaten: Diskrete vs. logarithmische Renditen.
    +Zeigt an einem simulierten Kursverlauf, welche Eigenschaft wo gilt -
    +und warum das arithmetische Mittel diskreter Renditen in die Irre fuehrt.
    +"""
    +
    +import numpy as np
    +import pandas as pd
    +
    +if __name__ == "__main__":
    +    kurse = np.array([100.0, 150.0, 100.0, 120.0, 90.0, 135.0])
    +    tage = [f"t{i}" for i in range(len(kurse))]
    +
    +    diskret = kurse[1:] / kurse[:-1] - 1.0
    +    logarithmisch = np.log(kurse[1:] / kurse[:-1])
    +
    +    print("=" * 74)
    +    print("  DISKRETE UND LOGARITHMISCHE RENDITEN IM VERGLEICH")
    +    print("=" * 74)
    +    tabelle = pd.DataFrame({
    +        "Periode": [f"{tage[i]} -> {tage[i+1]}" for i in range(len(diskret))],
    +        "Kurs von": kurse[:-1],
    +        "Kurs bis": kurse[1:],
    +        "diskret R": [f"{r*100:+7.2f} %" for r in diskret],
    +        "log r": [f"{r:+8.4f}" for r in logarithmisch],
    +    })
    +    print(tabelle.to_string(index=False))
    +
    +    # --- Zeitliche Aggregation ------------------------------------------
    +    gesamt_wahr = kurse[-1] / kurse[0] - 1.0
    +    summe_log = logarithmisch.sum()
    +    aus_log_zurueck = np.exp(summe_log) - 1.0
    +    summe_diskret = diskret.sum()
    +
    +    print("\n--- Aggregation ueber die Zeit ---")
    +    print(f"  Tatsaechliche Gesamtrendite:        {gesamt_wahr*100:+8.2f} %")
    +    print(f"  Summe der Log-Renditen -> exp()-1:  {aus_log_zurueck*100:+8.2f} %  "
    +          f"{'KORREKT' if abs(aus_log_zurueck - gesamt_wahr) < 1e-9 else 'falsch'}")
    +    print(f"  Summe der diskreten Renditen:       {summe_diskret*100:+8.2f} %  FALSCH")
    +
    +    # --- Mittelwerte ------------------------------------------------------
    +    arithmetisch = diskret.mean()
    +    geometrisch = np.prod(1 + diskret) ** (1 / len(diskret)) - 1
    +    aus_log = np.exp(logarithmisch.mean()) - 1
    +
    +    print("\n--- Welcher Mittelwert ist der richtige? ---")
    +    print(f"  Arithmetisches Mittel (diskret):    {arithmetisch*100:+8.2f} %  "
    +          f"-> ueberschaetzt")
    +    print(f"  Geometrisches Mittel:               {geometrisch*100:+8.2f} %  -> korrekt")
    +    print(f"  exp(Mittel der Log-Renditen) - 1:   {aus_log*100:+8.2f} %  "
    +          f"-> identisch zum geometrischen")
    +
    +    # Probe: Endkapital mit dem jeweiligen Mittelwert hochgerechnet
    +    n = len(diskret)
    +    print(f"\n  Probe - Startkapital 100 EUR ueber {n} Perioden:")
    +    print(f"    tatsaechlich:                     {kurse[-1]:8.2f} EUR")
    +    print(f"    mit arithm. Mittel hochgerechnet: {100*(1+arithmetisch)**n:8.2f} EUR")
    +    print(f"    mit geom.  Mittel hochgerechnet:  {100*(1+geometrisch)**n:8.2f} EUR")
    +
    +    print("\n--- Annualisierung (252 Handelstage) ---")
    +    print(f"  Volatilitaet aus Log-Renditen: "
    +          f"{logarithmisch.std(ddof=1)*np.sqrt(252)*100:.2f} % p.a.")
    +    print("  (Die Wurzel-Zeit-Regel gilt nur bei unabhaengigen Renditen -")
    +    print("   fuer reale Maerkte ist sie eine Naeherung.)")
    +    print("=" * 74)

    18.4 Das Schätzfehler-Problem

    Sei \mathbf{X}\in\mathbb{R}^{T\times N} die Matrix zentrierter Renditen von N Titeln über T Handelstage. Die Stichproben-Kovarianzmatrix lautet:

    @@ -21462,102 +21778,102 @@ r_{i,0\to T} = \sum_{t=1}^T r_{i,t}

    🎯 Der Error-Maximizer-Effekt Ein Risikominimierer sucht die Richtungen mit der kleinsten Varianz — also genau jene Eigenrichtungen, deren Eigenwerte am stärksten nach unten verzerrt sind. Die Optimierung greift damit zielsicher in das Schätzrauschen hinein. Markowitz-Optimierung ist deshalb kein Fehlerdämpfer, sondern ein Fehlerverstärker.

    -
    #!/usr/bin/env python3
    -
    -# Schaetzrauschen_Demo.py
    -"""
    -Kapitel Finanzdaten: Der Error-Maximizer-Effekt im Experiment.
    -
    -Aufbau: Wir KENNEN die wahre Kovarianzmatrix, weil wir die Daten selbst
    -erzeugen. Dann schaetzen wir sie aus endlich vielen Beobachtungen und
    -vergleichen, wie gut das daraus optimierte Portfolio in der WAHREN Welt
    -abschneidet - mit und ohne Shrinkage.
    -
    -Das ist der einzige saubere Weg, Schaetzfehler zu messen: Man braucht eine
    -Wahrheit zum Vergleich, und die gibt es nur in der Simulation.
    -"""
    -
    -import numpy as np
    -from sklearn.covariance import LedoitWolf
    -
    -
    -def erzeuge_wahre_kovarianz(n, rng):
    -    """Ein-Faktor-Modell: gemeinsamer Marktfaktor plus titelspezifisches Rauschen."""
    -    beta = rng.uniform(0.6, 1.4, size=n)             # Marktsensitivitaeten
    -    var_markt = 0.04                                  # Marktvarianz p.a.
    -    var_spezifisch = rng.uniform(0.01, 0.09, size=n)  # idiosynkratische Varianz
    -    return np.outer(beta, beta) * var_markt + np.diag(var_spezifisch)
    -
    -
    -def minimum_varianz_gewichte(sigma):
    -    """Analytische GMV-Loesung: w = Sigma^-1 1 / (1' Sigma^-1 1). Ohne Restriktionen."""
    -    eins = np.ones(len(sigma))
    -    try:
    -        loesung = np.linalg.solve(sigma, eins)
    -    except np.linalg.LinAlgError:
    -        loesung = np.linalg.pinv(sigma) @ eins        # falls singulaer
    -    return loesung / loesung.sum()
    -
    -
    -def portfoliovolatilitaet(w, sigma):
    -    return float(np.sqrt(w @ sigma @ w))
    -
    -
    -if __name__ == "__main__":
    -    rng = np.random.default_rng(2026)
    -    N = 40                                            # Anzahl Titel
    -    WIEDERHOLUNGEN = 200
    -
    -    sigma_wahr = erzeuge_wahre_kovarianz(N, rng)
    -    w_ideal = minimum_varianz_gewichte(sigma_wahr)
    -    vola_ideal = portfoliovolatilitaet(w_ideal, sigma_wahr)
    -
    -    print("=" * 92)
    -    print("  ERROR-MAXIMIZER: WIE TEUER IST SCHAETZRAUSCHEN?")
    -    print("=" * 92)
    -    print(f"Aufbau: {N} Titel, wahre Kovarianz bekannt (Ein-Faktor-Modell).")
    -    print(f"Bestmoegliche Volatilitaet bei perfektem Wissen: "
    -          f"{vola_ideal*100:.2f} % p.a.\n")
    -
    -    print(f"{'T (Tage)':>9} | {'T/N':>5} | {'Stichprobe':>22} | {'Ledoit-Wolf':>22} | "
    -          f"{'Shrink':>7}")
    -    print(f"{'':>9} | {'':>5} | {'Vola     Aufschlag':>22} | "
    -          f"{'Vola     Aufschlag':>22} | {'delta':>7}")
    -    print("-" * 92)
    -
    -    for T in [60, 120, 252, 504, 1260]:
    -        vola_stichprobe, vola_lw, deltas = [], [], []
    -
    -        for _ in range(WIEDERHOLUNGEN):
    -            # Daten aus der WAHREN Verteilung ziehen (taeglich)
    -            daten = rng.multivariate_normal(np.zeros(N), sigma_wahr / 252, size=T)
    -
    -            # (a) Stichproben-Kovarianz
    -            s_stich = np.cov(daten, rowvar=False, ddof=1) * 252
    -            w_stich = minimum_varianz_gewichte(s_stich)
    -
    -            # (b) Ledoit-Wolf-Shrinkage
    -            lw = LedoitWolf().fit(daten)
    -            s_lw = lw.covariance_ * 252
    -            w_lw = minimum_varianz_gewichte(s_lw)
    -            deltas.append(lw.shrinkage_)
    -
    -            # Bewertung IMMER mit der wahren Kovarianz - das ist der Punkt
    -            vola_stichprobe.append(portfoliovolatilitaet(w_stich, sigma_wahr))
    -            vola_lw.append(portfoliovolatilitaet(w_lw, sigma_wahr))
    -
    -        m_stich, m_lw = np.mean(vola_stichprobe), np.mean(vola_lw)
    -        print(f"{T:>9} | {T/N:>5.1f} | {m_stich*100:>8.2f} % "
    -              f"{(m_stich/vola_ideal-1)*100:>+9.1f} % | "
    -              f"{m_lw*100:>8.2f} % {(m_lw/vola_ideal-1)*100:>+9.1f} % | "
    -              f"{np.mean(deltas):>7.3f}")
    -
    -    print("-" * 92)
    -    print("'Aufschlag' = wie viel mehr Risiko das Portfolio in der WAHREN Welt traegt,")
    -    print("verglichen mit dem Portfolio bei perfektem Wissen.")
    -    print("Lesart: Je kleiner T/N, desto teurer das Schaetzrauschen - und desto mehr")
    -    print("schrumpft Ledoit-Wolf (delta steigt).")
    -    print("=" * 92)
    +
    #!/usr/bin/env python3
    +
    +# Schaetzrauschen_Demo.py
    +"""
    +Kapitel Finanzdaten: Der Error-Maximizer-Effekt im Experiment.
    +
    +Aufbau: Wir KENNEN die wahre Kovarianzmatrix, weil wir die Daten selbst
    +erzeugen. Dann schaetzen wir sie aus endlich vielen Beobachtungen und
    +vergleichen, wie gut das daraus optimierte Portfolio in der WAHREN Welt
    +abschneidet - mit und ohne Shrinkage.
    +
    +Das ist der einzige saubere Weg, Schaetzfehler zu messen: Man braucht eine
    +Wahrheit zum Vergleich, und die gibt es nur in der Simulation.
    +"""
    +
    +import numpy as np
    +from sklearn.covariance import LedoitWolf
    +
    +
    +def erzeuge_wahre_kovarianz(n, rng):
    +    """Ein-Faktor-Modell: gemeinsamer Marktfaktor plus titelspezifisches Rauschen."""
    +    beta = rng.uniform(0.6, 1.4, size=n)             # Marktsensitivitaeten
    +    var_markt = 0.04                                  # Marktvarianz p.a.
    +    var_spezifisch = rng.uniform(0.01, 0.09, size=n)  # idiosynkratische Varianz
    +    return np.outer(beta, beta) * var_markt + np.diag(var_spezifisch)
    +
    +
    +def minimum_varianz_gewichte(sigma):
    +    """Analytische GMV-Loesung: w = Sigma^-1 1 / (1' Sigma^-1 1). Ohne Restriktionen."""
    +    eins = np.ones(len(sigma))
    +    try:
    +        loesung = np.linalg.solve(sigma, eins)
    +    except np.linalg.LinAlgError:
    +        loesung = np.linalg.pinv(sigma) @ eins        # falls singulaer
    +    return loesung / loesung.sum()
    +
    +
    +def portfoliovolatilitaet(w, sigma):
    +    return float(np.sqrt(w @ sigma @ w))
    +
    +
    +if __name__ == "__main__":
    +    rng = np.random.default_rng(2026)
    +    N = 40                                            # Anzahl Titel
    +    WIEDERHOLUNGEN = 200
    +
    +    sigma_wahr = erzeuge_wahre_kovarianz(N, rng)
    +    w_ideal = minimum_varianz_gewichte(sigma_wahr)
    +    vola_ideal = portfoliovolatilitaet(w_ideal, sigma_wahr)
    +
    +    print("=" * 92)
    +    print("  ERROR-MAXIMIZER: WIE TEUER IST SCHAETZRAUSCHEN?")
    +    print("=" * 92)
    +    print(f"Aufbau: {N} Titel, wahre Kovarianz bekannt (Ein-Faktor-Modell).")
    +    print(f"Bestmoegliche Volatilitaet bei perfektem Wissen: "
    +          f"{vola_ideal*100:.2f} % p.a.\n")
    +
    +    print(f"{'T (Tage)':>9} | {'T/N':>5} | {'Stichprobe':>22} | {'Ledoit-Wolf':>22} | "
    +          f"{'Shrink':>7}")
    +    print(f"{'':>9} | {'':>5} | {'Vola     Aufschlag':>22} | "
    +          f"{'Vola     Aufschlag':>22} | {'delta':>7}")
    +    print("-" * 92)
    +
    +    for T in [60, 120, 252, 504, 1260]:
    +        vola_stichprobe, vola_lw, deltas = [], [], []
    +
    +        for _ in range(WIEDERHOLUNGEN):
    +            # Daten aus der WAHREN Verteilung ziehen (taeglich)
    +            daten = rng.multivariate_normal(np.zeros(N), sigma_wahr / 252, size=T)
    +
    +            # (a) Stichproben-Kovarianz
    +            s_stich = np.cov(daten, rowvar=False, ddof=1) * 252
    +            w_stich = minimum_varianz_gewichte(s_stich)
    +
    +            # (b) Ledoit-Wolf-Shrinkage
    +            lw = LedoitWolf().fit(daten)
    +            s_lw = lw.covariance_ * 252
    +            w_lw = minimum_varianz_gewichte(s_lw)
    +            deltas.append(lw.shrinkage_)
    +
    +            # Bewertung IMMER mit der wahren Kovarianz - das ist der Punkt
    +            vola_stichprobe.append(portfoliovolatilitaet(w_stich, sigma_wahr))
    +            vola_lw.append(portfoliovolatilitaet(w_lw, sigma_wahr))
    +
    +        m_stich, m_lw = np.mean(vola_stichprobe), np.mean(vola_lw)
    +        print(f"{T:>9} | {T/N:>5.1f} | {m_stich*100:>8.2f} % "
    +              f"{(m_stich/vola_ideal-1)*100:>+9.1f} % | "
    +              f"{m_lw*100:>8.2f} % {(m_lw/vola_ideal-1)*100:>+9.1f} % | "
    +              f"{np.mean(deltas):>7.3f}")
    +
    +    print("-" * 92)
    +    print("'Aufschlag' = wie viel mehr Risiko das Portfolio in der WAHREN Welt traegt,")
    +    print("verglichen mit dem Portfolio bei perfektem Wissen.")
    +    print("Lesart: Je kleiner T/N, desto teurer das Schaetzrauschen - und desto mehr")
    +    print("schrumpft Ledoit-Wolf (delta steigt).")
    +    print("=" * 92)

    Erwartete Ausgabe:

    ============================================================================================
    @@ -21645,156 +21961,156 @@ Bestmoegliche Volatilitaet bei perfektem Wissen: 11.84 % p.a.
     
    Ihre Liste:      ['AAPL', 'MSFT', 'NVDA', 'AMZN', 'JNJ', 'PFE', 'JPM', 'GS', 'XOM', 'CVX']
     prices.columns:  ['AAPL', 'AMZN', 'CVX', 'GS', 'JNJ', 'JPM', 'MSFT', 'NVDA', 'PFE', 'XOM']

    Wer danach mit Positionsindizes arbeitet, beschriftet Ergebnisse falsch und beschränkt falsche Sektoren. Die Abhilfe ist eine Zeile:

    -
    prices = raw["Close"][tickers].dropna()      # erzwingt die eigene Reihenfolge
    -assert list(prices.columns) == tickers
    +
    prices = raw["Close"][tickers].dropna()      # erzwingt die eigene Reihenfolge
    +assert list(prices.columns) == tickers
    -
    #!/usr/bin/env python3
    -
    -# Finanzdaten_Ledoit_Wolf.py
    -"""
    -Kapitel Finanzdaten: Automatisierte Finanzdaten-Pipeline und Kovarianz-Shrinkage.
    -
    -Eigenschaften:
    -  * Spaltenreihenfolge wird erzwungen und per assert geprueft
    -  * beide Shrinkage-Ziele werden berechnet und verglichen
    -  * Datenqualitaetspruefungen (Luecken, Ausreisser, Mindestlaenge)
    -  * Eigenwertspektrum wird ausgewiesen, nicht nur die Konditionszahl
    -"""
    -
    -import numpy as np
    -import pandas as pd
    -import yfinance as yf
    -from sklearn.covariance import LedoitWolf
    -
    -HANDELSTAGE = 252
    -
    -
    -def lade_kurse(tickers: list[str], start, ende) -> pd.DataFrame:
    -    """Laedt bereinigte Schlusskurse und garantiert die Spaltenreihenfolge."""
    -    print(f"Lade {len(tickers)} Ticker von {start} bis {ende} ...")
    -    roh = yf.download(tickers, start=start, end=ende, auto_adjust=True, progress=False)
    -    if roh.empty:
    -        raise SystemExit("Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).")
    -
    -    if isinstance(roh.columns, pd.MultiIndex):
    -        # [tickers] erzwingt die gewuenschte Spaltenreihenfolge
    -        kurse = roh["Close"][tickers].dropna()
    -    else:
    -        kurse = roh[["Close"]].dropna()
    -        kurse.columns = tickers
    -
    -    assert list(kurse.columns) == tickers, (
    -        f"Spaltenreihenfolge weicht ab!\n  erwartet: {tickers}\n"
    -        f"  erhalten: {list(kurse.columns)}")
    -    return kurse
    -
    -
    -def pruefe_datenqualitaet(kurse: pd.DataFrame, mindest_tage: int = 200) -> None:
    -    """Faengt die haeufigsten Datenprobleme ab, bevor sie ins Modell gelangen."""
    -    T, N = kurse.shape
    -    print(f"\nDatenqualitaet: {T} Handelstage, {N} Titel (T/N = {T/N:.1f})")
    -    if T < mindest_tage:
    -        raise SystemExit(f"Zu wenige Beobachtungen ({T} < {mindest_tage}).")
    -    if T < N:
    -        print("  WARNUNG: T < N - die Stichprobenkovarianz ist singulaer!")
    -    elif T < 3 * N:
    -        print("  WARNUNG: T < 3N - erhebliches Schaetzrauschen zu erwarten.")
    -
    -    renditen = kurse.pct_change().dropna()
    -    extreme = (renditen.abs() > 0.25).sum().sum()
    -    if extreme:
    -        print(f"  Hinweis: {extreme} Tagesrenditen ueber 25 % "
    -              f"(Splits, Sondersituationen oder Datenfehler pruefen).")
    -    luecken = kurse.isna().sum().sum()
    -    print(f"  Fehlende Werte nach dropna: {luecken}")
    -
    -
    -def ziel_konstante_korrelation(renditen: np.ndarray) -> np.ndarray:
    -    """
    -    Shrinkage-Ziel nach Ledoit/Wolf 2003: Einzelvarianzen behalten,
    -    alle Korrelationen durch ihren Mittelwert ersetzen.
    -    (sklearn nutzt stattdessen die skalierte Einheitsmatrix - siehe oben.)
    -    """
    -    S = np.cov(renditen, rowvar=False, ddof=1)
    -    d = np.sqrt(np.diag(S))
    -    korrelation = S / np.outer(d, d)
    -    n = len(S)
    -    r_quer = (korrelation.sum() - n) / (n * (n - 1))       # Mittel ohne Diagonale
    -    F = r_quer * np.outer(d, d)
    -    np.fill_diagonal(F, np.diag(S))
    -    return F
    -
    -
    -def analysiere(kurse: pd.DataFrame):
    -    renditen = kurse.pct_change().dropna()
    -    T, N = renditen.shape
    -
    -    mu = renditen.mean().values * HANDELSTAGE
    -    sigma_stichprobe = renditen.cov().values * HANDELSTAGE
    -
    -    lw = LedoitWolf(assume_centered=False).fit(renditen.values)
    -    sigma_lw = lw.covariance_ * HANDELSTAGE
    -    delta = lw.shrinkage_
    -
    -    print("\n" + "=" * 84)
    -    print("         FINANZDATEN-PIPELINE UND MATRIX-KONDITIONIERUNG")
    -    print("=" * 84)
    -    print(f"Beobachtungen T:            {T}")
    -    print(f"Titel N:                    {N}")
    -    print(f"Optimales Shrinkage delta:  {delta:.4f} "
    -          f"({delta*100:.1f} % Gewicht auf dem strukturierten Ziel)")
    -
    -    eig_stich = np.linalg.eigvalsh(sigma_stichprobe)
    -    eig_lw = np.linalg.eigvalsh(sigma_lw)
    -
    -    print("\n--- Eigenwertspektrum (annualisiert) ---")
    -    print(f"{'':<14} {'kleinster':>12} {'Median':>12} {'groesster':>12} "
    -          f"{'Kondition':>12}")
    -    for name, eig in [("Stichprobe", eig_stich), ("Ledoit-Wolf", eig_lw)]:
    -        kondition = eig.max() / max(eig.min(), 1e-12)
    -        print(f"{name:<14} {eig.min():>12.6f} {np.median(eig):>12.6f} "
    -              f"{eig.max():>12.6f} {kondition:>12.1f}")
    -
    -    print("\nDie Konditionszahl misst, wie stark sich kleine Datenaenderungen auf")
    -    print("die Inverse auswirken. Je kleiner, desto stabiler die Portfoliogewichte.")
    -
    -    # --- Vergleich der beiden Shrinkage-Ziele ----------------------------
    -    S_taeglich = np.cov(renditen.values, rowvar=False, ddof=1)
    -    F_identitaet = np.eye(N) * np.trace(S_taeglich) / N
    -    F_korrelation = ziel_konstante_korrelation(renditen.values)
    -
    -    print("\n--- Die beiden Shrinkage-Ziele im Vergleich ---")
    -    for name, F in [("skalierte Einheitsmatrix (sklearn)", F_identitaet),
    -                    ("konstante Korrelation (LW 2003)", F_korrelation)]:
    -        gemischt = ((1 - delta) * S_taeglich + delta * F) * HANDELSTAGE
    -        eig = np.linalg.eigvalsh(gemischt)
    -        print(f"  {name:<36}: Kondition {eig.max()/max(eig.min(),1e-12):8.1f}, "
    -              f"kleinster EW {eig.min():.6f}")
    -
    -    print("\n--- Erwartete Renditen (annualisiert) ---")
    -    uebersicht = pd.DataFrame({
    -        "Ticker": kurse.columns,
    -        "Rendite p.a.": [f"{r*100:+7.2f} %" for r in mu],
    -        "Volatilitaet p.a.": [f"{np.sqrt(sigma_lw[i, i])*100:6.2f} %" for i in range(N)],
    -    })
    -    print(uebersicht.to_string(index=False))
    -    print("=" * 84)
    -    return renditen, mu, sigma_stichprobe, sigma_lw
    -
    -
    -if __name__ == "__main__":
    -    UNIVERSUM = ["AAPL", "MSFT", "NVDA", "AMZN",   # Technologie
    -                 "JNJ", "PFE",                      # Gesundheit
    -                 "JPM", "GS",                       # Banken
    -                 "XOM", "CVX"]                      # Energie
    -
    -    # Relatives 2-Jahres-Fenster, damit das Beispiel nicht "altert"
    -    ende = pd.Timestamp.today().normalize()
    -    start = ende - pd.DateOffset(years=2)
    -
    -    kurse = lade_kurse(UNIVERSUM, start.strftime("%Y-%m-%d"), ende.strftime("%Y-%m-%d"))
    -    pruefe_datenqualitaet(kurse)
    -    analysiere(kurse)
    +
    #!/usr/bin/env python3
    +
    +# Finanzdaten_Ledoit_Wolf.py
    +"""
    +Kapitel Finanzdaten: Automatisierte Finanzdaten-Pipeline und Kovarianz-Shrinkage.
    +
    +Eigenschaften:
    +  * Spaltenreihenfolge wird erzwungen und per assert geprueft
    +  * beide Shrinkage-Ziele werden berechnet und verglichen
    +  * Datenqualitaetspruefungen (Luecken, Ausreisser, Mindestlaenge)
    +  * Eigenwertspektrum wird ausgewiesen, nicht nur die Konditionszahl
    +"""
    +
    +import numpy as np
    +import pandas as pd
    +import yfinance as yf
    +from sklearn.covariance import LedoitWolf
    +
    +HANDELSTAGE = 252
    +
    +
    +def lade_kurse(tickers: list[str], start, ende) -> pd.DataFrame:
    +    """Laedt bereinigte Schlusskurse und garantiert die Spaltenreihenfolge."""
    +    print(f"Lade {len(tickers)} Ticker von {start} bis {ende} ...")
    +    roh = yf.download(tickers, start=start, end=ende, auto_adjust=True, progress=False)
    +    if roh.empty:
    +        raise SystemExit("Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).")
    +
    +    if isinstance(roh.columns, pd.MultiIndex):
    +        # [tickers] erzwingt die gewuenschte Spaltenreihenfolge
    +        kurse = roh["Close"][tickers].dropna()
    +    else:
    +        kurse = roh[["Close"]].dropna()
    +        kurse.columns = tickers
    +
    +    assert list(kurse.columns) == tickers, (
    +        f"Spaltenreihenfolge weicht ab!\n  erwartet: {tickers}\n"
    +        f"  erhalten: {list(kurse.columns)}")
    +    return kurse
    +
    +
    +def pruefe_datenqualitaet(kurse: pd.DataFrame, mindest_tage: int = 200) -> None:
    +    """Faengt die haeufigsten Datenprobleme ab, bevor sie ins Modell gelangen."""
    +    T, N = kurse.shape
    +    print(f"\nDatenqualitaet: {T} Handelstage, {N} Titel (T/N = {T/N:.1f})")
    +    if T < mindest_tage:
    +        raise SystemExit(f"Zu wenige Beobachtungen ({T} < {mindest_tage}).")
    +    if T < N:
    +        print("  WARNUNG: T < N - die Stichprobenkovarianz ist singulaer!")
    +    elif T < 3 * N:
    +        print("  WARNUNG: T < 3N - erhebliches Schaetzrauschen zu erwarten.")
    +
    +    renditen = kurse.pct_change().dropna()
    +    extreme = (renditen.abs() > 0.25).sum().sum()
    +    if extreme:
    +        print(f"  Hinweis: {extreme} Tagesrenditen ueber 25 % "
    +              f"(Splits, Sondersituationen oder Datenfehler pruefen).")
    +    luecken = kurse.isna().sum().sum()
    +    print(f"  Fehlende Werte nach dropna: {luecken}")
    +
    +
    +def ziel_konstante_korrelation(renditen: np.ndarray) -> np.ndarray:
    +    """
    +    Shrinkage-Ziel nach Ledoit/Wolf 2003: Einzelvarianzen behalten,
    +    alle Korrelationen durch ihren Mittelwert ersetzen.
    +    (sklearn nutzt stattdessen die skalierte Einheitsmatrix - siehe oben.)
    +    """
    +    S = np.cov(renditen, rowvar=False, ddof=1)
    +    d = np.sqrt(np.diag(S))
    +    korrelation = S / np.outer(d, d)
    +    n = len(S)
    +    r_quer = (korrelation.sum() - n) / (n * (n - 1))       # Mittel ohne Diagonale
    +    F = r_quer * np.outer(d, d)
    +    np.fill_diagonal(F, np.diag(S))
    +    return F
    +
    +
    +def analysiere(kurse: pd.DataFrame):
    +    renditen = kurse.pct_change().dropna()
    +    T, N = renditen.shape
    +
    +    mu = renditen.mean().values * HANDELSTAGE
    +    sigma_stichprobe = renditen.cov().values * HANDELSTAGE
    +
    +    lw = LedoitWolf(assume_centered=False).fit(renditen.values)
    +    sigma_lw = lw.covariance_ * HANDELSTAGE
    +    delta = lw.shrinkage_
    +
    +    print("\n" + "=" * 84)
    +    print("         FINANZDATEN-PIPELINE UND MATRIX-KONDITIONIERUNG")
    +    print("=" * 84)
    +    print(f"Beobachtungen T:            {T}")
    +    print(f"Titel N:                    {N}")
    +    print(f"Optimales Shrinkage delta:  {delta:.4f} "
    +          f"({delta*100:.1f} % Gewicht auf dem strukturierten Ziel)")
    +
    +    eig_stich = np.linalg.eigvalsh(sigma_stichprobe)
    +    eig_lw = np.linalg.eigvalsh(sigma_lw)
    +
    +    print("\n--- Eigenwertspektrum (annualisiert) ---")
    +    print(f"{'':<14} {'kleinster':>12} {'Median':>12} {'groesster':>12} "
    +          f"{'Kondition':>12}")
    +    for name, eig in [("Stichprobe", eig_stich), ("Ledoit-Wolf", eig_lw)]:
    +        kondition = eig.max() / max(eig.min(), 1e-12)
    +        print(f"{name:<14} {eig.min():>12.6f} {np.median(eig):>12.6f} "
    +              f"{eig.max():>12.6f} {kondition:>12.1f}")
    +
    +    print("\nDie Konditionszahl misst, wie stark sich kleine Datenaenderungen auf")
    +    print("die Inverse auswirken. Je kleiner, desto stabiler die Portfoliogewichte.")
    +
    +    # --- Vergleich der beiden Shrinkage-Ziele ----------------------------
    +    S_taeglich = np.cov(renditen.values, rowvar=False, ddof=1)
    +    F_identitaet = np.eye(N) * np.trace(S_taeglich) / N
    +    F_korrelation = ziel_konstante_korrelation(renditen.values)
    +
    +    print("\n--- Die beiden Shrinkage-Ziele im Vergleich ---")
    +    for name, F in [("skalierte Einheitsmatrix (sklearn)", F_identitaet),
    +                    ("konstante Korrelation (LW 2003)", F_korrelation)]:
    +        gemischt = ((1 - delta) * S_taeglich + delta * F) * HANDELSTAGE
    +        eig = np.linalg.eigvalsh(gemischt)
    +        print(f"  {name:<36}: Kondition {eig.max()/max(eig.min(),1e-12):8.1f}, "
    +              f"kleinster EW {eig.min():.6f}")
    +
    +    print("\n--- Erwartete Renditen (annualisiert) ---")
    +    uebersicht = pd.DataFrame({
    +        "Ticker": kurse.columns,
    +        "Rendite p.a.": [f"{r*100:+7.2f} %" for r in mu],
    +        "Volatilitaet p.a.": [f"{np.sqrt(sigma_lw[i, i])*100:6.2f} %" for i in range(N)],
    +    })
    +    print(uebersicht.to_string(index=False))
    +    print("=" * 84)
    +    return renditen, mu, sigma_stichprobe, sigma_lw
    +
    +
    +if __name__ == "__main__":
    +    UNIVERSUM = ["AAPL", "MSFT", "NVDA", "AMZN",   # Technologie
    +                 "JNJ", "PFE",                      # Gesundheit
    +                 "JPM", "GS",                       # Banken
    +                 "XOM", "CVX"]                      # Energie
    +
    +    # Relatives 2-Jahres-Fenster, damit das Beispiel nicht "altert"
    +    ende = pd.Timestamp.today().normalize()
    +    start = ende - pd.DateOffset(years=2)
    +
    +    kurse = lade_kurse(UNIVERSUM, start.strftime("%Y-%m-%d"), ende.strftime("%Y-%m-%d"))
    +    pruefe_datenqualitaet(kurse)
    +    analysiere(kurse)

    ⚠️ Zur Reproduzierbarkeit Dieses Programm lädt Live-Daten. Ihre Zahlen werden von den hier abgedruckten abweichen — je nach Abrufdatum. Das ist beabsichtigt und selbst eine Lektion: Wenn sich das optimale Portfolio zwischen zwei Abrufen deutlich ändert, obwohl nur ein paar Tage vergangen sind, sehen Sie den Schätzfehler direkt. Prüfen Sie das ruhig einmal.

    @@ -21819,130 +22135,130 @@ prices.columns: ['AAPL', 'AMZN', 'CVX', 'GS', &

    Auflösung: Abschnitt A.18.

    Das folgende Programm führt den Fall vor. Es arbeitet mit synthetischen Daten, damit das Ergebnis reproduzierbar ist und kein Internetzugang nötig wird — mit echten Kursdaten sieht es genauso aus.

    -
    #!/usr/bin/env python3
    -
    -# Kovarianz_Falle.py
    -"""
    -Kapitel Finanzdaten: Warum die historische Kovarianzmatrix den Optimierer belaeugt.
    -
    -Ein Portfolio aus 30 Anlagen soll das Risiko minimieren. Geschaetzt wird die
    -Kovarianzmatrix aus 20 Beobachtungen - in der Praxis ein voellig ueblicher
    -Fall (30 Titel, ein Quartal Wochendaten).
    -
    -Das Ergebnis ist ein Portfolio mit einem gemessenen Risiko von 0,000 %. Der
    -Optimierer haelt es fuer risikofrei. Ausserhalb des Schaetzzeitraums ist es
    -das schlechteste der drei untersuchten Portfolios - schlechter sogar als
    -blosse Gleichgewichtung.
    -
    -Der Grund ist nicht Zufall, sondern Struktur: Bei N Anlagen und T < N
    -Beobachtungen hat die Stichproben-Kovarianzmatrix hoechstens Rang T-1. Es gibt
    -dann ganze Richtungen im Gewichtsraum, in denen sie exakt null Varianz misst -
    -Richtungen, die es in Wirklichkeit nicht gibt. Der Optimierer findet sie
    -zuverlaessig, denn er sucht ja genau danach.
    -
    -Die Daten sind synthetisch (ein Marktfaktor plus Eigenrauschen), damit das
    -Ergebnis reproduzierbar ist und kein Internetzugang noetig wird. Mit echten
    -Kursdaten sieht es genauso aus.
    -
    -Benoetigt: numpy, cvxpy, scikit-learn
    -"""
    -
    -from __future__ import annotations
    -
    -import numpy as np
    -import cvxpy as cp
    -from sklearn.covariance import LedoitWolf
    -
    -ANZAHL_ANLAGEN = 30
    -BEOBACHTUNGEN_SCHAETZUNG = 20        # weniger als Anlagen - genau darum geht es
    -BEOBACHTUNGEN_TEST = 2000            # der "spaetere Verlauf"
    -
    -RNG = np.random.default_rng(11)
    -# Jede Anlage reagiert unterschiedlich stark auf den Gesamtmarkt.
    -BETA = RNG.uniform(0.6, 1.4, ANZAHL_ANLAGEN)
    -
    -
    -def erzeuge_renditen(perioden: int) -> np.ndarray:
    -    """Ein Marktfaktor, auf den alle reagieren, plus Eigenrauschen je Anlage."""
    -    markt = RNG.normal(0.0, 0.010, perioden)
    -    eigen = RNG.normal(0.0, 0.012, (perioden, ANZAHL_ANLAGEN))
    -    return np.outer(markt, BETA) + eigen
    -
    -
    -def minimales_risiko(kovarianz: np.ndarray) -> tuple[np.ndarray, float]:
    -    """Minimum-Varianz-Portfolio: Gewichte summieren sich zu 1, sonst frei.
    -
    -    cp.psd_wrap() sagt CVXPY: 'Ich weiss, dass diese Matrix numerisch nicht
    -    exakt positiv semidefinit ist - rechne trotzdem.' Das ist hier bewusst so
    -    gewaehlt, denn genau diese Eigenschaft wollen wir vorfuehren. Im
    -    Produktivbetrieb waere die DCP-Fehlermeldung das Warnsignal, dem man
    -    nachgehen muss (siehe den Denkfehler im Kapitel Quadratische und
    -    nichtlineare Optimierung).
    -    """
    -    gewichte = cp.Variable(ANZAHL_ANLAGEN)
    -    problem = cp.Problem(cp.Minimize(cp.quad_form(gewichte, cp.psd_wrap(kovarianz))),
    -                         [cp.sum(gewichte) == 1])
    -    problem.solve()
    -    return np.array(gewichte.value).ravel(), max(problem.value, 0.0)
    -
    -
    -if __name__ == "__main__":
    -    schaetzzeitraum = erzeuge_renditen(BEOBACHTUNGEN_SCHAETZUNG)
    -    spaeterer_verlauf = erzeuge_renditen(BEOBACHTUNGEN_TEST)
    -
    -    stichprobe = np.cov(schaetzzeitraum, rowvar=False)
    -    ledoit_wolf = LedoitWolf().fit(schaetzzeitraum)
    -
    -    print("=" * 78)
    -    print("  DIE KOVARIANZ-FALLE")
    -    print("=" * 78)
    -    print(f"{ANZAHL_ANLAGEN} Anlagen, geschaetzt aus {BEOBACHTUNGEN_SCHAETZUNG} "
    -          f"Beobachtungen.")
    -    print(f"Rang der Stichprobenmatrix: "
    -          f"{np.linalg.matrix_rank(stichprobe)} - noetig waeren {ANZAHL_ANLAGEN}.")
    -    print(f"Kleinster Eigenwert: {np.linalg.eigvalsh(stichprobe).min():.2e}")
    -    print()
    -    print("Es gibt also Richtungen, in denen diese Matrix EXAKT null Varianz")
    -    print("misst. Genau dort sucht ein Minimum-Varianz-Optimierer.")
    -    print()
    -
    -    print(f"{'Schaetzer':<26} {'Risiko im Zeitraum':>19} "
    -          f"{'Risiko spaeter':>16} {'Hebel':>7}")
    -    print("-" * 78)
    -
    -    ergebnisse = {}
    -    for name, matrix in [
    -            ("Stichprobe (roh)", stichprobe),
    -            (f"Ledoit-Wolf (d={ledoit_wolf.shrinkage_:.2f})", ledoit_wolf.covariance_)]:
    -        gewichte, varianz_intern = minimales_risiko(matrix)
    -        risiko_spaeter = float(np.std(spaeterer_verlauf @ gewichte))
    -        hebel = float(np.abs(gewichte).sum())
    -        ergebnisse[name] = (risiko_spaeter, gewichte)
    -        print(f"{name:<26} {np.sqrt(varianz_intern) * 100:>18.3f} % "
    -              f"{risiko_spaeter * 100:>15.3f} % {hebel:>7.1f}")
    -
    -    gleich = np.full(ANZAHL_ANLAGEN, 1.0 / ANZAHL_ANLAGEN)
    -    print(f"{'Gleichgewichtung 1/N':<26} "
    -          f"{float(np.std(schaetzzeitraum @ gleich)) * 100:>18.3f} % "
    -          f"{float(np.std(spaeterer_verlauf @ gleich)) * 100:>15.3f} % {1.0:>7.1f}")
    -    print("-" * 78)
    -
    -    print("\nDrei Beobachtungen:")
    -    print("1. Die rohe Schaetzung meldet 0,000 % Risiko - und liefert spaeter das")
    -    print("   SCHLECHTESTE Ergebnis der drei. Sie hat kein Portfolio optimiert,")
    -    print("   sondern eine Luecke in den eigenen Daten gefunden.")
    -    print("2. Die Gleichgewichtung, die gar nicht optimiert, schlaegt den rohen")
    -    print("   Optimierer deutlich. Das ist kein Zufallsbefund, sondern in der")
    -    print("   Literatur breit belegt.")
    -    print("3. Ledoit-Wolf ist im Schaetzzeitraum EHRLICHER (0,441 statt 0,000 %)")
    -    print("   und spaeter am besten. Der Hebel faellt von 3,8 auf 1,8 - das")
    -    print("   Portfolio wird auch praktisch handelbarer.")
    -    print()
    -    print("Merksatz: Ein Optimierer glaubt seinen Eingabedaten vollstaendig.")
    -    print("Je mehr Freiheit Sie ihm geben, desto gruendlicher findet er deren")
    -    print("Fehler. Das gilt fuer Kovarianzmatrizen wie fuer Lieferzeiten,")
    -    print("Ausfallraten und jede andere geschaetzte Groesse.")
    -    print("=" * 78)
    +
    #!/usr/bin/env python3
    +
    +# Kovarianz_Falle.py
    +"""
    +Kapitel Finanzdaten: Warum die historische Kovarianzmatrix den Optimierer belaeugt.
    +
    +Ein Portfolio aus 30 Anlagen soll das Risiko minimieren. Geschaetzt wird die
    +Kovarianzmatrix aus 20 Beobachtungen - in der Praxis ein voellig ueblicher
    +Fall (30 Titel, ein Quartal Wochendaten).
    +
    +Das Ergebnis ist ein Portfolio mit einem gemessenen Risiko von 0,000 %. Der
    +Optimierer haelt es fuer risikofrei. Ausserhalb des Schaetzzeitraums ist es
    +das schlechteste der drei untersuchten Portfolios - schlechter sogar als
    +blosse Gleichgewichtung.
    +
    +Der Grund ist nicht Zufall, sondern Struktur: Bei N Anlagen und T < N
    +Beobachtungen hat die Stichproben-Kovarianzmatrix hoechstens Rang T-1. Es gibt
    +dann ganze Richtungen im Gewichtsraum, in denen sie exakt null Varianz misst -
    +Richtungen, die es in Wirklichkeit nicht gibt. Der Optimierer findet sie
    +zuverlaessig, denn er sucht ja genau danach.
    +
    +Die Daten sind synthetisch (ein Marktfaktor plus Eigenrauschen), damit das
    +Ergebnis reproduzierbar ist und kein Internetzugang noetig wird. Mit echten
    +Kursdaten sieht es genauso aus.
    +
    +Benoetigt: numpy, cvxpy, scikit-learn
    +"""
    +
    +from __future__ import annotations
    +
    +import numpy as np
    +import cvxpy as cp
    +from sklearn.covariance import LedoitWolf
    +
    +ANZAHL_ANLAGEN = 30
    +BEOBACHTUNGEN_SCHAETZUNG = 20        # weniger als Anlagen - genau darum geht es
    +BEOBACHTUNGEN_TEST = 2000            # der "spaetere Verlauf"
    +
    +RNG = np.random.default_rng(11)
    +# Jede Anlage reagiert unterschiedlich stark auf den Gesamtmarkt.
    +BETA = RNG.uniform(0.6, 1.4, ANZAHL_ANLAGEN)
    +
    +
    +def erzeuge_renditen(perioden: int) -> np.ndarray:
    +    """Ein Marktfaktor, auf den alle reagieren, plus Eigenrauschen je Anlage."""
    +    markt = RNG.normal(0.0, 0.010, perioden)
    +    eigen = RNG.normal(0.0, 0.012, (perioden, ANZAHL_ANLAGEN))
    +    return np.outer(markt, BETA) + eigen
    +
    +
    +def minimales_risiko(kovarianz: np.ndarray) -> tuple[np.ndarray, float]:
    +    """Minimum-Varianz-Portfolio: Gewichte summieren sich zu 1, sonst frei.
    +
    +    cp.psd_wrap() sagt CVXPY: 'Ich weiss, dass diese Matrix numerisch nicht
    +    exakt positiv semidefinit ist - rechne trotzdem.' Das ist hier bewusst so
    +    gewaehlt, denn genau diese Eigenschaft wollen wir vorfuehren. Im
    +    Produktivbetrieb waere die DCP-Fehlermeldung das Warnsignal, dem man
    +    nachgehen muss (siehe den Denkfehler im Kapitel Quadratische und
    +    nichtlineare Optimierung).
    +    """
    +    gewichte = cp.Variable(ANZAHL_ANLAGEN)
    +    problem = cp.Problem(cp.Minimize(cp.quad_form(gewichte, cp.psd_wrap(kovarianz))),
    +                         [cp.sum(gewichte) == 1])
    +    problem.solve()
    +    return np.array(gewichte.value).ravel(), max(problem.value, 0.0)
    +
    +
    +if __name__ == "__main__":
    +    schaetzzeitraum = erzeuge_renditen(BEOBACHTUNGEN_SCHAETZUNG)
    +    spaeterer_verlauf = erzeuge_renditen(BEOBACHTUNGEN_TEST)
    +
    +    stichprobe = np.cov(schaetzzeitraum, rowvar=False)
    +    ledoit_wolf = LedoitWolf().fit(schaetzzeitraum)
    +
    +    print("=" * 78)
    +    print("  DIE KOVARIANZ-FALLE")
    +    print("=" * 78)
    +    print(f"{ANZAHL_ANLAGEN} Anlagen, geschaetzt aus {BEOBACHTUNGEN_SCHAETZUNG} "
    +          f"Beobachtungen.")
    +    print(f"Rang der Stichprobenmatrix: "
    +          f"{np.linalg.matrix_rank(stichprobe)} - noetig waeren {ANZAHL_ANLAGEN}.")
    +    print(f"Kleinster Eigenwert: {np.linalg.eigvalsh(stichprobe).min():.2e}")
    +    print()
    +    print("Es gibt also Richtungen, in denen diese Matrix EXAKT null Varianz")
    +    print("misst. Genau dort sucht ein Minimum-Varianz-Optimierer.")
    +    print()
    +
    +    print(f"{'Schaetzer':<26} {'Risiko im Zeitraum':>19} "
    +          f"{'Risiko spaeter':>16} {'Hebel':>7}")
    +    print("-" * 78)
    +
    +    ergebnisse = {}
    +    for name, matrix in [
    +            ("Stichprobe (roh)", stichprobe),
    +            (f"Ledoit-Wolf (d={ledoit_wolf.shrinkage_:.2f})", ledoit_wolf.covariance_)]:
    +        gewichte, varianz_intern = minimales_risiko(matrix)
    +        risiko_spaeter = float(np.std(spaeterer_verlauf @ gewichte))
    +        hebel = float(np.abs(gewichte).sum())
    +        ergebnisse[name] = (risiko_spaeter, gewichte)
    +        print(f"{name:<26} {np.sqrt(varianz_intern) * 100:>18.3f} % "
    +              f"{risiko_spaeter * 100:>15.3f} % {hebel:>7.1f}")
    +
    +    gleich = np.full(ANZAHL_ANLAGEN, 1.0 / ANZAHL_ANLAGEN)
    +    print(f"{'Gleichgewichtung 1/N':<26} "
    +          f"{float(np.std(schaetzzeitraum @ gleich)) * 100:>18.3f} % "
    +          f"{float(np.std(spaeterer_verlauf @ gleich)) * 100:>15.3f} % {1.0:>7.1f}")
    +    print("-" * 78)
    +
    +    print("\nDrei Beobachtungen:")
    +    print("1. Die rohe Schaetzung meldet 0,000 % Risiko - und liefert spaeter das")
    +    print("   SCHLECHTESTE Ergebnis der drei. Sie hat kein Portfolio optimiert,")
    +    print("   sondern eine Luecke in den eigenen Daten gefunden.")
    +    print("2. Die Gleichgewichtung, die gar nicht optimiert, schlaegt den rohen")
    +    print("   Optimierer deutlich. Das ist kein Zufallsbefund, sondern in der")
    +    print("   Literatur breit belegt.")
    +    print("3. Ledoit-Wolf ist im Schaetzzeitraum EHRLICHER (0,441 statt 0,000 %)")
    +    print("   und spaeter am besten. Der Hebel faellt von 3,8 auf 1,8 - das")
    +    print("   Portfolio wird auch praktisch handelbarer.")
    +    print()
    +    print("Merksatz: Ein Optimierer glaubt seinen Eingabedaten vollstaendig.")
    +    print("Je mehr Freiheit Sie ihm geben, desto gruendlicher findet er deren")
    +    print("Fehler. Das gilt fuer Kovarianzmatrizen wie fuer Lieferzeiten,")
    +    print("Ausfallraten und jede andere geschaetzte Groesse.")
    +    print("=" * 78)

    Erwartete Ausgabe:

    ==============================================================================
       DIE KOVARIANZ-FALLE
    @@ -22097,24 +22413,24 @@ Ausfallraten und jede andere geschaetzte Groesse.
     
     
     

    Statt einer Antwort rechnen wir gleich die ganze Auswahl durch — für jede Zielrendite das Portfolio mit dem geringsten Risiko:

    -
    import numpy as np
    -import cvxpy as cp
    -
    -mu = np.array([0.03, 0.08, 0.055])                    # erwartete Rendite
    -sig = np.array([0.05, 0.18, 0.11])                    # Schwankung
    -R = np.array([[1.0, 0.1, 0.2], [0.1, 1.0, 0.5], [0.2, 0.5, 1.0]])
    -S = np.outer(sig, sig) * R                            # Kovarianzmatrix
    -
    -w, ziel = cp.Variable(3, nonneg=True), cp.Parameter(nonneg=True)
    -problem = cp.Problem(cp.Minimize(cp.quad_form(w, S)),
    -                     [cp.sum(w) == 1, mu @ w >= ziel])
    -
    -for z in [0.03, 0.04, 0.05, 0.06, 0.07, 0.08]:
    -    ziel.value = z
    -    problem.solve()
    -    risiko = np.sqrt(problem.value)
    -    print(f"{z*100:5.1f}% {risiko*100:7.2f}% {(mu @ w.value)/risiko:8.2f}   "
    -          f"{np.round(w.value, 3)}")
    +
    import numpy as np
    +import cvxpy as cp
    +
    +mu = np.array([0.03, 0.08, 0.055])                    # erwartete Rendite
    +sig = np.array([0.05, 0.18, 0.11])                    # Schwankung
    +R = np.array([[1.0, 0.1, 0.2], [0.1, 1.0, 0.5], [0.2, 0.5, 1.0]])
    +S = np.outer(sig, sig) * R                            # Kovarianzmatrix
    +
    +w, ziel = cp.Variable(3, nonneg=True), cp.Parameter(nonneg=True)
    +problem = cp.Problem(cp.Minimize(cp.quad_form(w, S)),
    +                     [cp.sum(w) == 1, mu @ w >= ziel])
    +
    +for z in [0.03, 0.04, 0.05, 0.06, 0.07, 0.08]:
    +    ziel.value = z
    +    problem.solve()
    +    risiko = np.sqrt(problem.value)
    +    print(f"{z*100:5.1f}% {risiko*100:7.2f}% {(mu @ w.value)/risiko:8.2f}   "
    +          f"{np.round(w.value, 3)}")

    Ausgabe (Zielrendite, Risiko, Rendite-je-Risiko, Gewichte):

      3.0%    4.83%     0.69   [0.884 0.017 0.098]
       4.0%    5.35%     0.75   [0.711 0.111 0.178]
    @@ -22202,53 +22518,53 @@ Ausfallraten und jede andere geschaetzte Groesse.
     

    Die Kehrseite: In Krisen steigen Korrelationen. Ein Portfolio, dessen Diversifikation auf \rho = 0{,}2 beruht, kann bei \rho \to 0{,}9 plötzlich fast unkorrelierte Absicherung verlieren — genau dann, wenn man sie braucht. Kapitel 20 und Kapitel 22 kommen darauf zurück.

    Die Tabelle aus der Handrechnung Das Gratis-Mittagessen in Zahlen lässt sich auf zwei unabhängigen Wegen prüfen: über die analytische Formel und über eine Simulation korrelierter Renditepaare, die von der Formel nichts weiß.

    -
    #!/usr/bin/env python3
    -
    -# Diversifikation_Demo.py
    -"""
    -Kapitel Markowitz: Portfoliorisiko in Abhaengigkeit von der Korrelation - die
    -Handrechnung zum Gratis-Mittagessen, geprueft ueber die analytische Formel und
    -ueber simulierte, korrelierte Renditen.
    -"""
    -
    -import numpy as np
    -
    -MU = 0.08
    -SIGMA = 0.20
    -
    -
    -def sigma_portfolio(rho: float, w1: float = 0.5) -> float:
    -    """Analytische Formel: sigma_p^2 = w1^2 s1^2 + w2^2 s2^2 + 2 w1 w2 rho s1 s2."""
    -    w2 = 1 - w1
    -    varianz = w1**2 * SIGMA**2 + w2**2 * SIGMA**2 + 2 * w1 * w2 * rho * SIGMA**2
    -    return float(np.sqrt(varianz))
    -
    -
    -def simuliere(rho: float, n: int = 500_000, seed: int = 7) -> float:
    -    """Erzeugt n korrelierte Renditepaare und misst die Portfolio-Volatilitaet empirisch."""
    -    rng = np.random.default_rng(seed)
    -    kovarianz = np.array([[SIGMA**2, rho * SIGMA**2],
    -                           [rho * SIGMA**2, SIGMA**2]])
    -    renditen = rng.multivariate_normal([MU, MU], kovarianz, size=n)
    -    portfolio = 0.5 * renditen[:, 0] + 0.5 * renditen[:, 1]
    -    return float(portfolio.std(ddof=1))
    -
    -
    -if __name__ == "__main__":
    -    print("=" * 78)
    -    print("  PORTFOLIORISIKO IN ABHAENGIGKEIT VON DER KORRELATION")
    -    print("=" * 78)
    -    print(f"{'rho':>6} | {'sigma_p (Formel)':>18} | {'sigma_p (Simulation)':>20} | {'Risikoreduktion':>16}")
    -    print("-" * 78)
    -    for rho in [1.0, 0.5, 0.0, -0.5, -1.0]:
    -        formel = sigma_portfolio(rho)
    -        sim = simuliere(rho)
    -        reduktion = (1 - formel / SIGMA) * 100
    -        print(f"{rho:6.1f} | {formel*100:16.2f} % | {sim*100:18.2f} % | {reduktion:14.0f} %")
    -
    -    print("\nDie simulierten Werte (500.000 gezogene Renditepaare je rho) bestaetigen")
    -    print("die Formel aus der Handrechnung bis auf statistisches Rauschen - und das,")
    -    print("obwohl Formel und Simulation nichts voneinander wissen.")
    +
    #!/usr/bin/env python3
    +
    +# Diversifikation_Demo.py
    +"""
    +Kapitel Markowitz: Portfoliorisiko in Abhaengigkeit von der Korrelation - die
    +Handrechnung zum Gratis-Mittagessen, geprueft ueber die analytische Formel und
    +ueber simulierte, korrelierte Renditen.
    +"""
    +
    +import numpy as np
    +
    +MU = 0.08
    +SIGMA = 0.20
    +
    +
    +def sigma_portfolio(rho: float, w1: float = 0.5) -> float:
    +    """Analytische Formel: sigma_p^2 = w1^2 s1^2 + w2^2 s2^2 + 2 w1 w2 rho s1 s2."""
    +    w2 = 1 - w1
    +    varianz = w1**2 * SIGMA**2 + w2**2 * SIGMA**2 + 2 * w1 * w2 * rho * SIGMA**2
    +    return float(np.sqrt(varianz))
    +
    +
    +def simuliere(rho: float, n: int = 500_000, seed: int = 7) -> float:
    +    """Erzeugt n korrelierte Renditepaare und misst die Portfolio-Volatilitaet empirisch."""
    +    rng = np.random.default_rng(seed)
    +    kovarianz = np.array([[SIGMA**2, rho * SIGMA**2],
    +                           [rho * SIGMA**2, SIGMA**2]])
    +    renditen = rng.multivariate_normal([MU, MU], kovarianz, size=n)
    +    portfolio = 0.5 * renditen[:, 0] + 0.5 * renditen[:, 1]
    +    return float(portfolio.std(ddof=1))
    +
    +
    +if __name__ == "__main__":
    +    print("=" * 78)
    +    print("  PORTFOLIORISIKO IN ABHAENGIGKEIT VON DER KORRELATION")
    +    print("=" * 78)
    +    print(f"{'rho':>6} | {'sigma_p (Formel)':>18} | {'sigma_p (Simulation)':>20} | {'Risikoreduktion':>16}")
    +    print("-" * 78)
    +    for rho in [1.0, 0.5, 0.0, -0.5, -1.0]:
    +        formel = sigma_portfolio(rho)
    +        sim = simuliere(rho)
    +        reduktion = (1 - formel / SIGMA) * 100
    +        print(f"{rho:6.1f} | {formel*100:16.2f} % | {sim*100:18.2f} % | {reduktion:14.0f} %")
    +
    +    print("\nDie simulierten Werte (500.000 gezogene Renditepaare je rho) bestaetigen")
    +    print("die Formel aus der Handrechnung bis auf statistisches Rauschen - und das,")
    +    print("obwohl Formel und Simulation nichts voneinander wissen.")

    Erwartete Ausgabe:

    ==============================================================================
       PORTFOLIORISIKO IN ABHAENGIGKEIT VON DER KORRELATION
    @@ -22469,279 +22785,279 @@ obwohl Formel und Simulation nichts voneinander wissen.

    ⚠️ Sektoren niemals über Positionsindizes definieren

    Ein naiver, aber gefährlicher Ansatz definiert den Technologiesektor so:

    -
    tickers = ["AAPL", "MSFT", "NVDA", "AMZN", "JNJ", ...]
    -tech_indices = [0, 1, 2, 3]        # gemeint: AAPL, MSFT, NVDA, AMZN
    +
    tickers = ["AAPL", "MSFT", "NVDA", "AMZN", "JNJ", ...]
    +tech_indices = [0, 1, 2, 3]        # gemeint: AAPL, MSFT, NVDA, AMZN

    Weil yfinance alphabetisch sortiert, trifft das tatsächlich auf AAPL, AMZN, CVX (Chevron) und GS (Goldman Sachs) zu — Microsoft und NVIDIA blieben unbeschränkt.

    Robust ist die Definition über Namen:

    -
    SEKTOREN = {"Technologie": ["AAPL", "MSFT", "NVDA", "AMZN"], ...}
    -idx = [tickers.index(t) for t in SEKTOREN["Technologie"]]
    +
    SEKTOREN = {"Technologie": ["AAPL", "MSFT", "NVDA", "AMZN"], ...}
    +idx = [tickers.index(t) for t in SEKTOREN["Technologie"]]

    Diese Schreibweise ist nicht nur sicher, sondern auch lesbar — und sie bricht laut ab, wenn ein Ticker fehlt, statt still das Falsche zu tun.


    19.6 Vollimplementierung mit CVXPY

    -
    #!/usr/bin/env python3
    -
    -# Markowitz_CVXPY.py
    -"""
    -Kapitel Markowitz: Markowitz-Mean-Variance-Optimierung mit CVXPY.
    -Enthaelt GMV, Maximum Sharpe (Korn-Transformation), Effizienzgrenze
    -und institutionelle Restriktionen.
    -
    -Eigenschaften:
    -  * Spaltenreihenfolge erzwungen; Sektoren ueber NAMEN statt Indizes
    -  * Effizienzgrenze bis zur TATSAECHLICH erreichbaren Maximalrendite
    -    (nicht etwa max(mu)*0.95 - das waere unter Restriktionen oft
    -     unerreichbar und die Kurve wuerde stillschweigend vorzeitig abbrechen)
    -  * Pruefung aller Restriktionen nach dem Loesen
    -  * Vergleich mit Gleichgewichtung als Realitaetscheck
    -"""
    -
    -import os
    -
    -import cvxpy as cp
    -import numpy as np
    -import pandas as pd
    -import yfinance as yf
    -from sklearn.covariance import LedoitWolf
    -import matplotlib
    -matplotlib.use("Agg")
    -import matplotlib.pyplot as plt
    -
    -OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
    -os.makedirs(OUTPUT_DIR, exist_ok=True)
    -
    -TICKER = ["AAPL", "MSFT", "NVDA", "AMZN", "JNJ", "PFE", "JPM", "GS", "XOM", "CVX"]
    -
    -# Sektoren ueber Namen definiert, nicht ueber Positionen
    -SEKTOREN = {
    -    "Technologie": ["AAPL", "MSFT", "NVDA", "AMZN"],
    -    "Gesundheit":  ["JNJ", "PFE"],
    -    "Finanzen":    ["JPM", "GS"],
    -    "Energie":     ["XOM", "CVX"],
    -}
    -SEKTORGRENZEN = {"Technologie": 0.35, "Gesundheit": 0.40,
    -                 "Finanzen": 0.40, "Energie": 0.40}
    -
    -MAX_GEWICHT = 0.20      # hoechstens 20 % je Einzeltitel
    -RISIKOFREI = 0.03       # 3 % p.a.
    -HANDELSTAGE = 252
    -
    -
    -def lade_daten():
    -    """Laedt Kurse und garantiert die Spaltenreihenfolge."""
    -    ende = pd.Timestamp.today().normalize()
    -    start = ende - pd.DateOffset(years=2)
    -    roh = yf.download(TICKER, start=start, end=ende, auto_adjust=True, progress=False)
    -    if roh.empty:
    -        raise SystemExit("Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).")
    -
    -    if isinstance(roh.columns, pd.MultiIndex):
    -        kurse = roh["Close"][TICKER].dropna()      # [TICKER] erzwingt die Reihenfolge
    -    else:
    -        kurse = roh[["Close"]].dropna()
    -        kurse.columns = TICKER
    -
    -    assert list(kurse.columns) == TICKER, "Spaltenreihenfolge weicht ab!"
    -    renditen = kurse.pct_change().dropna()
    -    mu = renditen.mean().values * HANDELSTAGE
    -    sigma = LedoitWolf().fit(renditen.values).covariance_ * HANDELSTAGE
    -    return kurse, renditen, mu, sigma
    -
    -
    -def sektor_indizes():
    -    """Uebersetzt Sektornamen einmalig in Positionsindizes - mit Pruefung."""
    -    ergebnis = {}
    -    for sektor, titel in SEKTOREN.items():
    -        fehlend = [t for t in titel if t not in TICKER]
    -        if fehlend:
    -            raise ValueError(f"Sektor '{sektor}': Ticker {fehlend} nicht im Universum.")
    -        ergebnis[sektor] = [TICKER.index(t) for t in titel]
    -    return ergebnis
    -
    -
    -def basis_restriktionen(w, skala=None):
    -    """
    -    Standardrestriktionen. Bei der Korn-Transformation muessen ALLE Grenzen
    -    mit kappa mitskaliert werden - dafuer dient der Parameter 'skala'.
    -    """
    -    eins = 1.0 if skala is None else skala
    -    idx = sektor_indizes()
    -    bedingungen = [w >= 0, w <= MAX_GEWICHT * eins]
    -    for sektor, positionen in idx.items():
    -        bedingungen.append(cp.sum(w[positionen]) <= SEKTORGRENZEN[sektor] * eins)
    -    return bedingungen
    -
    -
    -def loese_gmv(sigma):
    -    """Global Minimum Variance: minimiere Risiko, ignoriere Rendite."""
    -    w = cp.Variable(len(sigma))
    -    problem = cp.Problem(cp.Minimize(0.5 * cp.quad_form(w, sigma)),
    -                         [cp.sum(w) == 1] + basis_restriktionen(w))
    -    problem.solve()
    -    if problem.status not in ("optimal", "optimal_inaccurate"):
    -        raise SystemExit(f"GMV nicht loesbar: {problem.status}")
    -    return w.value
    -
    -
    -def loese_max_sharpe(mu, sigma, r_f):
    -    """Maximum Sharpe Ratio ueber die Korn-Transformation."""
    -    n = len(mu)
    -    ueberrendite = mu - r_f
    -    if np.all(ueberrendite <= 0):
    -        raise SystemExit("Kein Titel schlaegt den risikofreien Zins - "
    -                         "Max-Sharpe-Portfolio existiert nicht.")
    -
    -    y = cp.Variable(n)
    -    kappa = cp.Variable(nonneg=True)
    -    bedingungen = ([ueberrendite @ y == 1, cp.sum(y) == kappa]
    -                   + basis_restriktionen(y, skala=kappa))
    -    problem = cp.Problem(cp.Minimize(0.5 * cp.quad_form(y, sigma)), bedingungen)
    -    problem.solve()
    -    if problem.status not in ("optimal", "optimal_inaccurate"):
    -        raise SystemExit(f"Max-Sharpe nicht loesbar: {problem.status}")
    -    return y.value / kappa.value
    -
    -
    -def max_erreichbare_rendite(mu, sigma):
    -    """
    -    Achtung: max(mu)*0.95 als obere Grenze der Frontier anzunehmen, ist unter
    -    Positions- und Sektorgrenzen oft unerreichbar; die Kurve wuerde dann
    -    stillschweigend abbrechen. Hier wird die tatsaechliche Obergrenze berechnet.
    -    """
    -    w = cp.Variable(len(mu))
    -    problem = cp.Problem(cp.Maximize(mu @ w), [cp.sum(w) == 1] + basis_restriktionen(w))
    -    problem.solve()
    -    return float(problem.value)
    -
    -
    -def berechne_frontier(mu, sigma, ret_min, ret_max, punkte=40):
    -    """Effizienzgrenze durch Variation der Mindestrendite."""
    -    n = len(mu)
    -    w = cp.Variable(n)
    -    ziel_rendite = cp.Parameter()
    -    problem = cp.Problem(
    -        cp.Minimize(0.5 * cp.quad_form(w, sigma)),
    -        [cp.sum(w) == 1, mu @ w >= ziel_rendite] + basis_restriktionen(w))
    -
    -    volas, renditen, uebersprungen = [], [], 0
    -    for ziel in np.linspace(ret_min, ret_max, punkte):
    -        ziel_rendite.value = ziel
    -        problem.solve()
    -        if problem.status in ("optimal", "optimal_inaccurate"):
    -            volas.append(float(np.sqrt(w.value @ sigma @ w.value)))
    -            renditen.append(float(mu @ w.value))
    -        else:
    -            uebersprungen += 1
    -    if uebersprungen:
    -        print(f"  Hinweis: {uebersprungen} Zielrenditen waren nicht erreichbar.")
    -    return np.array(volas), np.array(renditen)
    -
    -
    -def pruefe_restriktionen(w, bezeichnung):
    -    """Nach dem Loesen: haelt die Loesung wirklich alle Regeln ein?"""
    -    assert abs(w.sum() - 1) < 1e-6, f"{bezeichnung}: Summe != 1"
    -    assert w.min() > -1e-6, f"{bezeichnung}: negatives Gewicht"
    -    assert w.max() < MAX_GEWICHT + 1e-6, f"{bezeichnung}: Positionsgrenze verletzt"
    -    for sektor, positionen in sektor_indizes().items():
    -        anteil = w[positionen].sum()
    -        assert anteil < SEKTORGRENZEN[sektor] + 1e-6, \
    -            f"{bezeichnung}: Sektor {sektor} bei {anteil:.3f} ueber Grenze"
    -
    -
    -def kennzahlen(w, mu, sigma, r_f):
    -    rendite = float(mu @ w)
    -    vola = float(np.sqrt(w @ sigma @ w))
    -    return rendite, vola, (rendite - r_f) / vola
    -
    -
    -if __name__ == "__main__":
    -    kurse, renditen, mu, sigma = lade_daten()
    -    n = len(TICKER)
    -
    -    w_gmv = loese_gmv(sigma)
    -    w_sharpe = loese_max_sharpe(mu, sigma, RISIKOFREI)
    -    w_gleich = np.ones(n) / n
    -
    -    pruefe_restriktionen(w_gmv, "GMV")
    -    pruefe_restriktionen(w_sharpe, "Max Sharpe")
    -
    -    print("=" * 88)
    -    print("        ERGEBNISSE DER MEAN-VARIANCE-OPTIMIERUNG")
    -    print("=" * 88)
    -    print(f"Datenbasis: {len(renditen)} Handelstage, {n} Titel, "
    -          f"risikofreier Zins {RISIKOFREI*100:.1f} %")
    -    print(f"Restriktionen: max. {MAX_GEWICHT*100:.0f} % je Titel, "
    -          f"Sektorgrenzen {SEKTORGRENZEN}\n")
    -
    -    print(f"{'Portfolio':<26} {'Rendite':>10} {'Volatilitaet':>13} {'Sharpe':>9}")
    -    print("-" * 88)
    -    for name, w in [("Global Minimum Variance", w_gmv),
    -                    ("Maximum Sharpe Ratio", w_sharpe),
    -                    ("Gleichgewichtung (1/N)", w_gleich)]:
    -        r, v, sr = kennzahlen(w, mu, sigma, RISIKOFREI)
    -        print(f"{name:<26} {r*100:>9.2f} % {v*100:>12.2f} % {sr:>9.2f}")
    -
    -    # --- Gewichte je Titel ------------------------------------------------
    -    print("\n--- Optimierte Portfoliogewichte ---")
    -    sektor_je_titel = {t: s for s, titel in SEKTOREN.items() for t in titel}
    -    tabelle = pd.DataFrame({
    -        "Ticker": TICKER,
    -        "Sektor": [sektor_je_titel[t] for t in TICKER],
    -        "Rendite p.a.": [f"{r*100:+6.1f} %" for r in mu],
    -        "Vola p.a.": [f"{np.sqrt(sigma[i, i])*100:5.1f} %" for i in range(n)],
    -        "GMV": [f"{w*100:5.1f} %" for w in w_gmv],
    -        "Max Sharpe": [f"{w*100:5.1f} %" for w in w_sharpe],
    -    })
    -    print(tabelle.to_string(index=False))
    -
    -    print("\n--- Sektoraufteilung (Kontrolle) ---")
    -    print(f"{'Sektor':<14} {'Grenze':>8} {'GMV':>9} {'Max Sharpe':>12}")
    -    for sektor, positionen in sektor_indizes().items():
    -        print(f"{sektor:<14} {SEKTORGRENZEN[sektor]*100:>7.0f} % "
    -              f"{w_gmv[positionen].sum()*100:>8.1f} % "
    -              f"{w_sharpe[positionen].sum()*100:>11.1f} %")
    -
    -    # --- Effizienzgrenze --------------------------------------------------
    -    ret_gmv = float(mu @ w_gmv)
    -    ret_max = max_erreichbare_rendite(mu, sigma)
    -    print(f"\nEffizienzgrenze von {ret_gmv*100:.2f} % bis {ret_max*100:.2f} % "
    -          f"(unter Restriktionen tatsaechlich erreichbar)")
    -    print(f"  Zum Vergleich: bester Einzeltitel {mu.max()*100:.2f} % - "
    -          f"durch die Grenzen nicht erreichbar.")
    -    volas, rets = berechne_frontier(mu, sigma, ret_gmv, ret_max)
    -
    -    # --- Diagramm ---------------------------------------------------------
    -    plt.figure(figsize=(11, 6.5))
    -    plt.plot(volas * 100, rets * 100, "b-", lw=2.5, label="Effizienzgrenze (restringiert)")
    -
    -    for w, farbe, marker, groesse, name in [
    -            (w_gmv, "green", "o", 150, "GMV"),
    -            (w_sharpe, "red", "*", 260, "Max Sharpe"),
    -            (w_gleich, "purple", "D", 110, "Gleichgewichtung")]:
    -        r, v, sr = kennzahlen(w, mu, sigma, RISIKOFREI)
    -        plt.scatter([v * 100], [r * 100], color=farbe, marker=marker, s=groesse,
    -                    zorder=5, label=f"{name} (SR={sr:.2f})")
    -
    -    # Kapitalmarktlinie durch den risikofreien Zins und das Tangentialportfolio
    -    r_s, v_s, _ = kennzahlen(w_sharpe, mu, sigma, RISIKOFREI)
    -    x_linie = np.array([0, v_s * 1.25])
    -    plt.plot(x_linie * 100, (RISIKOFREI + (r_s - RISIKOFREI) / v_s * x_linie) * 100,
    -             color="orange", ls=":", lw=2, label="Kapitalmarktlinie")
    -
    -    for i, t in enumerate(TICKER):
    -        plt.scatter(np.sqrt(sigma[i, i]) * 100, mu[i] * 100, color="gray", alpha=0.5, s=40)
    -        plt.annotate(t, (np.sqrt(sigma[i, i]) * 100 + 0.4, mu[i] * 100), fontsize=8)
    -
    -    plt.title("Markowitz-Effizienzgrenze mit Sektor- und Positionsgrenzen", fontsize=12)
    -    plt.xlabel("Annualisierte Volatilitaet [%]")
    -    plt.ylabel("Annualisierte erwartete Rendite [%]")
    -    plt.grid(True, linestyle=":", alpha=0.6)
    -    plt.legend(loc="best")
    -    plt.tight_layout()
    -    ziel = os.path.join(OUTPUT_DIR, "markowitz_efficient_frontier.png")
    -    plt.savefig(ziel, dpi=150)
    -    print(f"\nDiagramm gespeichert unter '{ziel}'")
    -    print("=" * 88)
    +
    #!/usr/bin/env python3
    +
    +# Markowitz_CVXPY.py
    +"""
    +Kapitel Markowitz: Markowitz-Mean-Variance-Optimierung mit CVXPY.
    +Enthaelt GMV, Maximum Sharpe (Korn-Transformation), Effizienzgrenze
    +und institutionelle Restriktionen.
    +
    +Eigenschaften:
    +  * Spaltenreihenfolge erzwungen; Sektoren ueber NAMEN statt Indizes
    +  * Effizienzgrenze bis zur TATSAECHLICH erreichbaren Maximalrendite
    +    (nicht etwa max(mu)*0.95 - das waere unter Restriktionen oft
    +     unerreichbar und die Kurve wuerde stillschweigend vorzeitig abbrechen)
    +  * Pruefung aller Restriktionen nach dem Loesen
    +  * Vergleich mit Gleichgewichtung als Realitaetscheck
    +"""
    +
    +import os
    +
    +import cvxpy as cp
    +import numpy as np
    +import pandas as pd
    +import yfinance as yf
    +from sklearn.covariance import LedoitWolf
    +import matplotlib
    +matplotlib.use("Agg")
    +import matplotlib.pyplot as plt
    +
    +OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
    +os.makedirs(OUTPUT_DIR, exist_ok=True)
    +
    +TICKER = ["AAPL", "MSFT", "NVDA", "AMZN", "JNJ", "PFE", "JPM", "GS", "XOM", "CVX"]
    +
    +# Sektoren ueber Namen definiert, nicht ueber Positionen
    +SEKTOREN = {
    +    "Technologie": ["AAPL", "MSFT", "NVDA", "AMZN"],
    +    "Gesundheit":  ["JNJ", "PFE"],
    +    "Finanzen":    ["JPM", "GS"],
    +    "Energie":     ["XOM", "CVX"],
    +}
    +SEKTORGRENZEN = {"Technologie": 0.35, "Gesundheit": 0.40,
    +                 "Finanzen": 0.40, "Energie": 0.40}
    +
    +MAX_GEWICHT = 0.20      # hoechstens 20 % je Einzeltitel
    +RISIKOFREI = 0.03       # 3 % p.a.
    +HANDELSTAGE = 252
    +
    +
    +def lade_daten():
    +    """Laedt Kurse und garantiert die Spaltenreihenfolge."""
    +    ende = pd.Timestamp.today().normalize()
    +    start = ende - pd.DateOffset(years=2)
    +    roh = yf.download(TICKER, start=start, end=ende, auto_adjust=True, progress=False)
    +    if roh.empty:
    +        raise SystemExit("Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).")
    +
    +    if isinstance(roh.columns, pd.MultiIndex):
    +        kurse = roh["Close"][TICKER].dropna()      # [TICKER] erzwingt die Reihenfolge
    +    else:
    +        kurse = roh[["Close"]].dropna()
    +        kurse.columns = TICKER
    +
    +    assert list(kurse.columns) == TICKER, "Spaltenreihenfolge weicht ab!"
    +    renditen = kurse.pct_change().dropna()
    +    mu = renditen.mean().values * HANDELSTAGE
    +    sigma = LedoitWolf().fit(renditen.values).covariance_ * HANDELSTAGE
    +    return kurse, renditen, mu, sigma
    +
    +
    +def sektor_indizes():
    +    """Uebersetzt Sektornamen einmalig in Positionsindizes - mit Pruefung."""
    +    ergebnis = {}
    +    for sektor, titel in SEKTOREN.items():
    +        fehlend = [t for t in titel if t not in TICKER]
    +        if fehlend:
    +            raise ValueError(f"Sektor '{sektor}': Ticker {fehlend} nicht im Universum.")
    +        ergebnis[sektor] = [TICKER.index(t) for t in titel]
    +    return ergebnis
    +
    +
    +def basis_restriktionen(w, skala=None):
    +    """
    +    Standardrestriktionen. Bei der Korn-Transformation muessen ALLE Grenzen
    +    mit kappa mitskaliert werden - dafuer dient der Parameter 'skala'.
    +    """
    +    eins = 1.0 if skala is None else skala
    +    idx = sektor_indizes()
    +    bedingungen = [w >= 0, w <= MAX_GEWICHT * eins]
    +    for sektor, positionen in idx.items():
    +        bedingungen.append(cp.sum(w[positionen]) <= SEKTORGRENZEN[sektor] * eins)
    +    return bedingungen
    +
    +
    +def loese_gmv(sigma):
    +    """Global Minimum Variance: minimiere Risiko, ignoriere Rendite."""
    +    w = cp.Variable(len(sigma))
    +    problem = cp.Problem(cp.Minimize(0.5 * cp.quad_form(w, sigma)),
    +                         [cp.sum(w) == 1] + basis_restriktionen(w))
    +    problem.solve()
    +    if problem.status not in ("optimal", "optimal_inaccurate"):
    +        raise SystemExit(f"GMV nicht loesbar: {problem.status}")
    +    return w.value
    +
    +
    +def loese_max_sharpe(mu, sigma, r_f):
    +    """Maximum Sharpe Ratio ueber die Korn-Transformation."""
    +    n = len(mu)
    +    ueberrendite = mu - r_f
    +    if np.all(ueberrendite <= 0):
    +        raise SystemExit("Kein Titel schlaegt den risikofreien Zins - "
    +                         "Max-Sharpe-Portfolio existiert nicht.")
    +
    +    y = cp.Variable(n)
    +    kappa = cp.Variable(nonneg=True)
    +    bedingungen = ([ueberrendite @ y == 1, cp.sum(y) == kappa]
    +                   + basis_restriktionen(y, skala=kappa))
    +    problem = cp.Problem(cp.Minimize(0.5 * cp.quad_form(y, sigma)), bedingungen)
    +    problem.solve()
    +    if problem.status not in ("optimal", "optimal_inaccurate"):
    +        raise SystemExit(f"Max-Sharpe nicht loesbar: {problem.status}")
    +    return y.value / kappa.value
    +
    +
    +def max_erreichbare_rendite(mu, sigma):
    +    """
    +    Achtung: max(mu)*0.95 als obere Grenze der Frontier anzunehmen, ist unter
    +    Positions- und Sektorgrenzen oft unerreichbar; die Kurve wuerde dann
    +    stillschweigend abbrechen. Hier wird die tatsaechliche Obergrenze berechnet.
    +    """
    +    w = cp.Variable(len(mu))
    +    problem = cp.Problem(cp.Maximize(mu @ w), [cp.sum(w) == 1] + basis_restriktionen(w))
    +    problem.solve()
    +    return float(problem.value)
    +
    +
    +def berechne_frontier(mu, sigma, ret_min, ret_max, punkte=40):
    +    """Effizienzgrenze durch Variation der Mindestrendite."""
    +    n = len(mu)
    +    w = cp.Variable(n)
    +    ziel_rendite = cp.Parameter()
    +    problem = cp.Problem(
    +        cp.Minimize(0.5 * cp.quad_form(w, sigma)),
    +        [cp.sum(w) == 1, mu @ w >= ziel_rendite] + basis_restriktionen(w))
    +
    +    volas, renditen, uebersprungen = [], [], 0
    +    for ziel in np.linspace(ret_min, ret_max, punkte):
    +        ziel_rendite.value = ziel
    +        problem.solve()
    +        if problem.status in ("optimal", "optimal_inaccurate"):
    +            volas.append(float(np.sqrt(w.value @ sigma @ w.value)))
    +            renditen.append(float(mu @ w.value))
    +        else:
    +            uebersprungen += 1
    +    if uebersprungen:
    +        print(f"  Hinweis: {uebersprungen} Zielrenditen waren nicht erreichbar.")
    +    return np.array(volas), np.array(renditen)
    +
    +
    +def pruefe_restriktionen(w, bezeichnung):
    +    """Nach dem Loesen: haelt die Loesung wirklich alle Regeln ein?"""
    +    assert abs(w.sum() - 1) < 1e-6, f"{bezeichnung}: Summe != 1"
    +    assert w.min() > -1e-6, f"{bezeichnung}: negatives Gewicht"
    +    assert w.max() < MAX_GEWICHT + 1e-6, f"{bezeichnung}: Positionsgrenze verletzt"
    +    for sektor, positionen in sektor_indizes().items():
    +        anteil = w[positionen].sum()
    +        assert anteil < SEKTORGRENZEN[sektor] + 1e-6, \
    +            f"{bezeichnung}: Sektor {sektor} bei {anteil:.3f} ueber Grenze"
    +
    +
    +def kennzahlen(w, mu, sigma, r_f):
    +    rendite = float(mu @ w)
    +    vola = float(np.sqrt(w @ sigma @ w))
    +    return rendite, vola, (rendite - r_f) / vola
    +
    +
    +if __name__ == "__main__":
    +    kurse, renditen, mu, sigma = lade_daten()
    +    n = len(TICKER)
    +
    +    w_gmv = loese_gmv(sigma)
    +    w_sharpe = loese_max_sharpe(mu, sigma, RISIKOFREI)
    +    w_gleich = np.ones(n) / n
    +
    +    pruefe_restriktionen(w_gmv, "GMV")
    +    pruefe_restriktionen(w_sharpe, "Max Sharpe")
    +
    +    print("=" * 88)
    +    print("        ERGEBNISSE DER MEAN-VARIANCE-OPTIMIERUNG")
    +    print("=" * 88)
    +    print(f"Datenbasis: {len(renditen)} Handelstage, {n} Titel, "
    +          f"risikofreier Zins {RISIKOFREI*100:.1f} %")
    +    print(f"Restriktionen: max. {MAX_GEWICHT*100:.0f} % je Titel, "
    +          f"Sektorgrenzen {SEKTORGRENZEN}\n")
    +
    +    print(f"{'Portfolio':<26} {'Rendite':>10} {'Volatilitaet':>13} {'Sharpe':>9}")
    +    print("-" * 88)
    +    for name, w in [("Global Minimum Variance", w_gmv),
    +                    ("Maximum Sharpe Ratio", w_sharpe),
    +                    ("Gleichgewichtung (1/N)", w_gleich)]:
    +        r, v, sr = kennzahlen(w, mu, sigma, RISIKOFREI)
    +        print(f"{name:<26} {r*100:>9.2f} % {v*100:>12.2f} % {sr:>9.2f}")
    +
    +    # --- Gewichte je Titel ------------------------------------------------
    +    print("\n--- Optimierte Portfoliogewichte ---")
    +    sektor_je_titel = {t: s for s, titel in SEKTOREN.items() for t in titel}
    +    tabelle = pd.DataFrame({
    +        "Ticker": TICKER,
    +        "Sektor": [sektor_je_titel[t] for t in TICKER],
    +        "Rendite p.a.": [f"{r*100:+6.1f} %" for r in mu],
    +        "Vola p.a.": [f"{np.sqrt(sigma[i, i])*100:5.1f} %" for i in range(n)],
    +        "GMV": [f"{w*100:5.1f} %" for w in w_gmv],
    +        "Max Sharpe": [f"{w*100:5.1f} %" for w in w_sharpe],
    +    })
    +    print(tabelle.to_string(index=False))
    +
    +    print("\n--- Sektoraufteilung (Kontrolle) ---")
    +    print(f"{'Sektor':<14} {'Grenze':>8} {'GMV':>9} {'Max Sharpe':>12}")
    +    for sektor, positionen in sektor_indizes().items():
    +        print(f"{sektor:<14} {SEKTORGRENZEN[sektor]*100:>7.0f} % "
    +              f"{w_gmv[positionen].sum()*100:>8.1f} % "
    +              f"{w_sharpe[positionen].sum()*100:>11.1f} %")
    +
    +    # --- Effizienzgrenze --------------------------------------------------
    +    ret_gmv = float(mu @ w_gmv)
    +    ret_max = max_erreichbare_rendite(mu, sigma)
    +    print(f"\nEffizienzgrenze von {ret_gmv*100:.2f} % bis {ret_max*100:.2f} % "
    +          f"(unter Restriktionen tatsaechlich erreichbar)")
    +    print(f"  Zum Vergleich: bester Einzeltitel {mu.max()*100:.2f} % - "
    +          f"durch die Grenzen nicht erreichbar.")
    +    volas, rets = berechne_frontier(mu, sigma, ret_gmv, ret_max)
    +
    +    # --- Diagramm ---------------------------------------------------------
    +    plt.figure(figsize=(11, 6.5))
    +    plt.plot(volas * 100, rets * 100, "b-", lw=2.5, label="Effizienzgrenze (restringiert)")
    +
    +    for w, farbe, marker, groesse, name in [
    +            (w_gmv, "green", "o", 150, "GMV"),
    +            (w_sharpe, "red", "*", 260, "Max Sharpe"),
    +            (w_gleich, "purple", "D", 110, "Gleichgewichtung")]:
    +        r, v, sr = kennzahlen(w, mu, sigma, RISIKOFREI)
    +        plt.scatter([v * 100], [r * 100], color=farbe, marker=marker, s=groesse,
    +                    zorder=5, label=f"{name} (SR={sr:.2f})")
    +
    +    # Kapitalmarktlinie durch den risikofreien Zins und das Tangentialportfolio
    +    r_s, v_s, _ = kennzahlen(w_sharpe, mu, sigma, RISIKOFREI)
    +    x_linie = np.array([0, v_s * 1.25])
    +    plt.plot(x_linie * 100, (RISIKOFREI + (r_s - RISIKOFREI) / v_s * x_linie) * 100,
    +             color="orange", ls=":", lw=2, label="Kapitalmarktlinie")
    +
    +    for i, t in enumerate(TICKER):
    +        plt.scatter(np.sqrt(sigma[i, i]) * 100, mu[i] * 100, color="gray", alpha=0.5, s=40)
    +        plt.annotate(t, (np.sqrt(sigma[i, i]) * 100 + 0.4, mu[i] * 100), fontsize=8)
    +
    +    plt.title("Markowitz-Effizienzgrenze mit Sektor- und Positionsgrenzen", fontsize=12)
    +    plt.xlabel("Annualisierte Volatilitaet [%]")
    +    plt.ylabel("Annualisierte erwartete Rendite [%]")
    +    plt.grid(True, linestyle=":", alpha=0.6)
    +    plt.legend(loc="best")
    +    plt.tight_layout()
    +    ziel = os.path.join(OUTPUT_DIR, "markowitz_efficient_frontier.png")
    +    plt.savefig(ziel, dpi=150)
    +    print(f"\nDiagramm gespeichert unter '{ziel}'")
    +    print("=" * 88)

    💻 Code-Durchgang: vier typische Fehlerquellen und ihre Absicherung

    @@ -22811,149 +23127,149 @@ obwohl Formel und Simulation nichts voneinander wissen.

    Ihre Aufgabe: (a) Woher kommt die Spanne von 0,24 Prozentpunkten zwischen der höchsten und der niedrigsten geschätzten Rendite, wenn doch alle Anlagen identisch sind? (b) Rechnen Sie nach, wie viele Beobachtungen nötig wären, um eine Rendite von 0,04 % bei 1,2 % Tagesschwankung überhaupt von null zu unterscheiden. (c) Warum wirkt sich derselbe Schätzfehler bei Renditen stärker aus als bei Kovarianzen? (d) Nennen Sie drei Gegenmittel — und begründen Sie, warum das erste davon das wirksamste ist.

    Auflösung: Abschnitt A.19.

    -
    #!/usr/bin/env python3
    -
    -# Renditeschaetzung_Falle.py
    -"""
    -Kapitel Markowitz: Warum geschaetzte Renditen noch gefaehrlicher sind als geschaetzte
    -Kovarianzen.
    -
    -Das Kapitel Finanzdaten hat gezeigt, was eine schlecht geschaetzte Kovarianzmatrix
    -anrichtet. Bei den erwarteten RENDITEN ist es schlimmer - aus einem einfachen
    -statistischen Grund: Um eine Rendite von 0,04 % pro Tag von null zu
    -unterscheiden, braucht man bei 1,2 % Tagesschwankung ueber tausend
    -Beobachtungen. Kovarianzen konvergieren dagegen deutlich schneller.
    -
    -Das Experiment ist bewusst so gebaut, dass die Antwort feststeht: ALLE zwoelf
    -Anlagen haben exakt dieselbe wahre Rendite und dieselbe Schwankung. Ein
    -korrektes Ergebnis waere also Gleichgewichtung. Was der Optimierer stattdessen
    -tut, zeigt der Lauf.
    -
    -Wiederholt wird 200 mal, damit das Ergebnis nicht von einer Zufallsstichprobe
    -abhaengt.
    -
    -Benoetigt: numpy, cvxpy
    -"""
    -
    -from __future__ import annotations
    -
    -import numpy as np
    -import cvxpy as cp
    -
    -ANZAHL_ANLAGEN = 12
    -BEOBACHTUNGEN = 250              # ein Jahr Tagesdaten
    -BEOBACHTUNGEN_SPAETER = 2000     # der "spaetere Verlauf"
    -WIEDERHOLUNGEN = 200
    -
    -# Die Wahrheit, die der Optimierer nicht kennt: alle Anlagen sind gleich.
    -WAHRE_RENDITE = 0.0004           # 0,04 % pro Tag
    -WAHRE_SCHWANKUNG = 0.012         # 1,2 % pro Tag
    -RISIKOAVERSION = 10.0
    -
    -RNG = np.random.default_rng(5)
    -
    -
    -def baue_optimierer() -> tuple[cp.Problem, cp.Variable, cp.Parameter, cp.Parameter]:
    -    """Baut das Markowitz-Problem EINMAL mit Parametern.
    -
    -    Bei 200 Wiederholungen ist das der Unterschied zwischen Sekunden und
    -    Minuten: cp.Parameter erlaubt es, nur die Daten zu tauschen, statt den
    -    Ausdrucksbaum jedes Mal neu zu kompilieren (siehe Kapitel Oekosystem).
    -    """
    -    gewichte = cp.Variable(ANZAHL_ANLAGEN, nonneg=True)
    -    renditen = cp.Parameter(ANZAHL_ANLAGEN)
    -    kovarianz = cp.Parameter((ANZAHL_ANLAGEN, ANZAHL_ANLAGEN), PSD=True)
    -    problem = cp.Problem(
    -        cp.Maximize(renditen @ gewichte
    -                    - RISIKOAVERSION * cp.quad_form(gewichte, kovarianz)),
    -        [cp.sum(gewichte) == 1])
    -    return problem, gewichte, renditen, kovarianz
    -
    -
    -def erzeuge(perioden: int) -> np.ndarray:
    -    """Renditen, bei denen alle Anlagen identisch verteilt und unabhaengig sind."""
    -    return RNG.normal(WAHRE_RENDITE, WAHRE_SCHWANKUNG,
    -                      (perioden, ANZAHL_ANLAGEN))
    -
    -
    -if __name__ == "__main__":
    -    problem, gewichte, renditen, kovarianz = baue_optimierer()
    -
    -    im_zeitraum, spaeter, spaeter_gleich = [], [], []
    -    groesstes_gewicht, schaetzspanne = [], []
    -
    -    for _ in range(WIEDERHOLUNGEN):
    -        schaetzdaten = erzeuge(BEOBACHTUNGEN)
    -        spaetere_daten = erzeuge(BEOBACHTUNGEN_SPAETER)
    -
    -        geschaetzte_rendite = schaetzdaten.mean(axis=0)
    -        geschaetzte_kovarianz = np.cov(schaetzdaten, rowvar=False)
    -
    -        renditen.value = geschaetzte_rendite
    -        # Symmetrisieren und minimal anheben: numerisches Rauschen kann die
    -        # Matrix sonst knapp unter die PSD-Grenze druecken (Kapitel Fundament).
    -        kovarianz.value = ((geschaetzte_kovarianz + geschaetzte_kovarianz.T) / 2
    -                           + 1e-10 * np.eye(ANZAHL_ANLAGEN))
    -        problem.solve()
    -
    -        w = np.array(gewichte.value).ravel()
    -        im_zeitraum.append(float((schaetzdaten @ w).mean()))
    -        spaeter.append(float((spaetere_daten @ w).mean()))
    -        spaeter_gleich.append(float(spaetere_daten.mean()))
    -        groesstes_gewicht.append(float(w.max()))
    -        schaetzspanne.append(float(geschaetzte_rendite.max()
    -                                   - geschaetzte_rendite.min()))
    -
    -    prozent = lambda werte: float(np.mean(werte)) * 100
    -
    -    print("=" * 78)
    -    print("  WENN DER OPTIMIERER GESCHAETZTE RENDITEN GLAUBT")
    -    print("=" * 78)
    -    print(f"{ANZAHL_ANLAGEN} Anlagen, {WIEDERHOLUNGEN} Wiederholungen, "
    -          f"je {BEOBACHTUNGEN} Beobachtungen zur Schaetzung.")
    -    print(f"In Wahrheit haben ALLE dieselbe Tagesrendite von "
    -          f"{WAHRE_RENDITE * 100:.4f} % und dieselbe Schwankung.")
    -    print("Die richtige Antwort waere also: gleichgewichten.")
    -    print()
    -    print(f"Mittlere Spanne der geschaetzten Renditen: "
    -          f"{prozent(schaetzspanne):.4f} Prozentpunkte")
    -    print(f"  -> Das ist das {prozent(schaetzspanne) / (WAHRE_RENDITE * 100):.1f}-Fache "
    -          f"des wahren Werts. Aus reinem Rauschen.")
    -    print(f"Mittleres groesstes Einzelgewicht: "
    -          f"{prozent(groesstes_gewicht):.1f} % "
    -          f"(bei Gleichgewichtung waeren es {100 / ANZAHL_ANLAGEN:.1f} %)")
    -    print()
    -    print(f"{'':<38} {'Tagesrendite':>14}")
    -    print("-" * 78)
    -    print(f"{'Wahrheit (alle Anlagen)':<38} {WAHRE_RENDITE * 100:>13.4f} %")
    -    print(f"{'Optimierer IM Schaetzzeitraum':<38} {prozent(im_zeitraum):>13.4f} %"
    -          f"   <- Illusion")
    -    print(f"{'Optimierer AUSSERHALB':<38} {prozent(spaeter):>13.4f} %")
    -    print(f"{'Gleichgewichtung AUSSERHALB':<38} {prozent(spaeter_gleich):>13.4f} %")
    -    print("-" * 78)
    -
    -    faktor = prozent(im_zeitraum) / (WAHRE_RENDITE * 100)
    -    vorsprung = prozent(spaeter) - prozent(spaeter_gleich)
    -    print(f"\nIm Schaetzzeitraum verspricht der Optimierer das "
    -          f"{faktor:.1f}-Fache der wahren Rendite.")
    -    print(f"Ausserhalb bleibt davon nichts: Er liegt {abs(vorsprung):.4f} "
    -          f"Prozentpunkte")
    -    print(f"{'schlechter' if vorsprung < 0 else 'besser'} als blosse Gleichgewichtung.")
    -    print()
    -    print("Der Grund ist statistisch, nicht finanzwirtschaftlich: Um eine")
    -    print(f"Rendite von {WAHRE_RENDITE*100:.2f} % taeglich von null zu unterscheiden,")
    -    print(f"braucht man bei {WAHRE_SCHWANKUNG*100:.1f} % Schwankung ueber tausend")
    -    print("Beobachtungen. Mit 250 misst man ueberwiegend Rauschen - und der")
    -    print("Optimierer nimmt jedes Rauschen fuer bare Muenze.")
    -    print()
    -    print("Drei praktische Konsequenzen:")
    -    print("  1. Verzichten Sie auf Renditeschaetzungen, wo es geht. Das")
    -    print("     Minimum-Varianz-Portfolio braucht gar keine.")
    -    print("  2. Wenn Sie welche brauchen: schrumpfen Sie sie stark zur Mitte")
    -    print("     (James-Stein, Black-Litterman) - viel staerker als Kovarianzen.")
    -    print("  3. Begrenzen Sie Einzelgewichte. Was der Optimierer nicht darf,")
    -    print("     kann er auch nicht auf Rauschen setzen.")
    -    print("=" * 78)
    +
    #!/usr/bin/env python3
    +
    +# Renditeschaetzung_Falle.py
    +"""
    +Kapitel Markowitz: Warum geschaetzte Renditen noch gefaehrlicher sind als geschaetzte
    +Kovarianzen.
    +
    +Das Kapitel Finanzdaten hat gezeigt, was eine schlecht geschaetzte Kovarianzmatrix
    +anrichtet. Bei den erwarteten RENDITEN ist es schlimmer - aus einem einfachen
    +statistischen Grund: Um eine Rendite von 0,04 % pro Tag von null zu
    +unterscheiden, braucht man bei 1,2 % Tagesschwankung ueber tausend
    +Beobachtungen. Kovarianzen konvergieren dagegen deutlich schneller.
    +
    +Das Experiment ist bewusst so gebaut, dass die Antwort feststeht: ALLE zwoelf
    +Anlagen haben exakt dieselbe wahre Rendite und dieselbe Schwankung. Ein
    +korrektes Ergebnis waere also Gleichgewichtung. Was der Optimierer stattdessen
    +tut, zeigt der Lauf.
    +
    +Wiederholt wird 200 mal, damit das Ergebnis nicht von einer Zufallsstichprobe
    +abhaengt.
    +
    +Benoetigt: numpy, cvxpy
    +"""
    +
    +from __future__ import annotations
    +
    +import numpy as np
    +import cvxpy as cp
    +
    +ANZAHL_ANLAGEN = 12
    +BEOBACHTUNGEN = 250              # ein Jahr Tagesdaten
    +BEOBACHTUNGEN_SPAETER = 2000     # der "spaetere Verlauf"
    +WIEDERHOLUNGEN = 200
    +
    +# Die Wahrheit, die der Optimierer nicht kennt: alle Anlagen sind gleich.
    +WAHRE_RENDITE = 0.0004           # 0,04 % pro Tag
    +WAHRE_SCHWANKUNG = 0.012         # 1,2 % pro Tag
    +RISIKOAVERSION = 10.0
    +
    +RNG = np.random.default_rng(5)
    +
    +
    +def baue_optimierer() -> tuple[cp.Problem, cp.Variable, cp.Parameter, cp.Parameter]:
    +    """Baut das Markowitz-Problem EINMAL mit Parametern.
    +
    +    Bei 200 Wiederholungen ist das der Unterschied zwischen Sekunden und
    +    Minuten: cp.Parameter erlaubt es, nur die Daten zu tauschen, statt den
    +    Ausdrucksbaum jedes Mal neu zu kompilieren (siehe Kapitel Oekosystem).
    +    """
    +    gewichte = cp.Variable(ANZAHL_ANLAGEN, nonneg=True)
    +    renditen = cp.Parameter(ANZAHL_ANLAGEN)
    +    kovarianz = cp.Parameter((ANZAHL_ANLAGEN, ANZAHL_ANLAGEN), PSD=True)
    +    problem = cp.Problem(
    +        cp.Maximize(renditen @ gewichte
    +                    - RISIKOAVERSION * cp.quad_form(gewichte, kovarianz)),
    +        [cp.sum(gewichte) == 1])
    +    return problem, gewichte, renditen, kovarianz
    +
    +
    +def erzeuge(perioden: int) -> np.ndarray:
    +    """Renditen, bei denen alle Anlagen identisch verteilt und unabhaengig sind."""
    +    return RNG.normal(WAHRE_RENDITE, WAHRE_SCHWANKUNG,
    +                      (perioden, ANZAHL_ANLAGEN))
    +
    +
    +if __name__ == "__main__":
    +    problem, gewichte, renditen, kovarianz = baue_optimierer()
    +
    +    im_zeitraum, spaeter, spaeter_gleich = [], [], []
    +    groesstes_gewicht, schaetzspanne = [], []
    +
    +    for _ in range(WIEDERHOLUNGEN):
    +        schaetzdaten = erzeuge(BEOBACHTUNGEN)
    +        spaetere_daten = erzeuge(BEOBACHTUNGEN_SPAETER)
    +
    +        geschaetzte_rendite = schaetzdaten.mean(axis=0)
    +        geschaetzte_kovarianz = np.cov(schaetzdaten, rowvar=False)
    +
    +        renditen.value = geschaetzte_rendite
    +        # Symmetrisieren und minimal anheben: numerisches Rauschen kann die
    +        # Matrix sonst knapp unter die PSD-Grenze druecken (Kapitel Fundament).
    +        kovarianz.value = ((geschaetzte_kovarianz + geschaetzte_kovarianz.T) / 2
    +                           + 1e-10 * np.eye(ANZAHL_ANLAGEN))
    +        problem.solve()
    +
    +        w = np.array(gewichte.value).ravel()
    +        im_zeitraum.append(float((schaetzdaten @ w).mean()))
    +        spaeter.append(float((spaetere_daten @ w).mean()))
    +        spaeter_gleich.append(float(spaetere_daten.mean()))
    +        groesstes_gewicht.append(float(w.max()))
    +        schaetzspanne.append(float(geschaetzte_rendite.max()
    +                                   - geschaetzte_rendite.min()))
    +
    +    prozent = lambda werte: float(np.mean(werte)) * 100
    +
    +    print("=" * 78)
    +    print("  WENN DER OPTIMIERER GESCHAETZTE RENDITEN GLAUBT")
    +    print("=" * 78)
    +    print(f"{ANZAHL_ANLAGEN} Anlagen, {WIEDERHOLUNGEN} Wiederholungen, "
    +          f"je {BEOBACHTUNGEN} Beobachtungen zur Schaetzung.")
    +    print(f"In Wahrheit haben ALLE dieselbe Tagesrendite von "
    +          f"{WAHRE_RENDITE * 100:.4f} % und dieselbe Schwankung.")
    +    print("Die richtige Antwort waere also: gleichgewichten.")
    +    print()
    +    print(f"Mittlere Spanne der geschaetzten Renditen: "
    +          f"{prozent(schaetzspanne):.4f} Prozentpunkte")
    +    print(f"  -> Das ist das {prozent(schaetzspanne) / (WAHRE_RENDITE * 100):.1f}-Fache "
    +          f"des wahren Werts. Aus reinem Rauschen.")
    +    print(f"Mittleres groesstes Einzelgewicht: "
    +          f"{prozent(groesstes_gewicht):.1f} % "
    +          f"(bei Gleichgewichtung waeren es {100 / ANZAHL_ANLAGEN:.1f} %)")
    +    print()
    +    print(f"{'':<38} {'Tagesrendite':>14}")
    +    print("-" * 78)
    +    print(f"{'Wahrheit (alle Anlagen)':<38} {WAHRE_RENDITE * 100:>13.4f} %")
    +    print(f"{'Optimierer IM Schaetzzeitraum':<38} {prozent(im_zeitraum):>13.4f} %"
    +          f"   <- Illusion")
    +    print(f"{'Optimierer AUSSERHALB':<38} {prozent(spaeter):>13.4f} %")
    +    print(f"{'Gleichgewichtung AUSSERHALB':<38} {prozent(spaeter_gleich):>13.4f} %")
    +    print("-" * 78)
    +
    +    faktor = prozent(im_zeitraum) / (WAHRE_RENDITE * 100)
    +    vorsprung = prozent(spaeter) - prozent(spaeter_gleich)
    +    print(f"\nIm Schaetzzeitraum verspricht der Optimierer das "
    +          f"{faktor:.1f}-Fache der wahren Rendite.")
    +    print(f"Ausserhalb bleibt davon nichts: Er liegt {abs(vorsprung):.4f} "
    +          f"Prozentpunkte")
    +    print(f"{'schlechter' if vorsprung < 0 else 'besser'} als blosse Gleichgewichtung.")
    +    print()
    +    print("Der Grund ist statistisch, nicht finanzwirtschaftlich: Um eine")
    +    print(f"Rendite von {WAHRE_RENDITE*100:.2f} % taeglich von null zu unterscheiden,")
    +    print(f"braucht man bei {WAHRE_SCHWANKUNG*100:.1f} % Schwankung ueber tausend")
    +    print("Beobachtungen. Mit 250 misst man ueberwiegend Rauschen - und der")
    +    print("Optimierer nimmt jedes Rauschen fuer bare Muenze.")
    +    print()
    +    print("Drei praktische Konsequenzen:")
    +    print("  1. Verzichten Sie auf Renditeschaetzungen, wo es geht. Das")
    +    print("     Minimum-Varianz-Portfolio braucht gar keine.")
    +    print("  2. Wenn Sie welche brauchen: schrumpfen Sie sie stark zur Mitte")
    +    print("     (James-Stein, Black-Litterman) - viel staerker als Kovarianzen.")
    +    print("  3. Begrenzen Sie Einzelgewichte. Was der Optimierer nicht darf,")
    +    print("     kann er auch nicht auf Rauschen setzen.")
    +    print("=" * 78)

    Erwartete Ausgabe:

    ==============================================================================
       WENN DER OPTIMIERER GESCHAETZTE RENDITEN GLAUBT
    @@ -23052,17 +23368,17 @@ Drei praktische Konsequenzen:
     
  • Anlage A: sechsmal −3 %.
  • Anlage B: fünfmal −3 % — und einmal −40 %.
  • -
    import numpy as np
    -
    -gut = np.full(94, 0.5)
    -A = np.concatenate([gut, np.full(6, -3.0)])
    -B = np.concatenate([gut, np.full(5, -3.0), [-40.0]])
    -
    -for name, r in [("Anlage A", A), ("Anlage B", B)]:
    -    schwelle = np.percentile(r, 5)               # 5-%-Quantil
    -    var = -schwelle                              # Value at Risk
    -    cvar = -r[r <= schwelle].mean()              # Mittel der schlimmsten 5 %
    -    print(f"{name}: VaR(95) {var:5.2f} %   CVaR(95) {cvar:5.2f} %")
    +
    import numpy as np
    +
    +gut = np.full(94, 0.5)
    +A = np.concatenate([gut, np.full(6, -3.0)])
    +B = np.concatenate([gut, np.full(5, -3.0), [-40.0]])
    +
    +for name, r in [("Anlage A", A), ("Anlage B", B)]:
    +    schwelle = np.percentile(r, 5)               # 5-%-Quantil
    +    var = -schwelle                              # Value at Risk
    +    cvar = -r[r <= schwelle].mean()              # Mittel der schlimmsten 5 %
    +    print(f"{name}: VaR(95) {var:5.2f} %   CVaR(95) {cvar:5.2f} %")

    Ausgabe:

    Anlage A: VaR(95)  3.00 %   CVaR(95)  3.00 %
     Anlage B: VaR(95)  3.00 %   CVaR(95)  9.17 %
    @@ -23117,111 +23433,111 @@ Anlage B: VaR(95) 3.00 % CVaR(95) 9.17 %

    20.3 Die zwei Schwächen des Markowitz-Modells

    Erstens: die Normalverteilungs-Illusion. Die Varianz behandelt Aufwärts- und Abwärtsschwankungen gleich und unterstellt implizit symmetrische, dünn auslaufende Verteilungen. Reale Marktrenditen haben aber fette Ränder (fat tails) und negative Schiefe: Extreme Verluste treten deutlich häufiger auf, als eine Normalverteilung vorhersagt.

    Zweitens: Reibungsblindheit. Ein ungedämpftes Mean-Variance-Modell schichtet bei minimalen Schätzänderungen das gesamte Portfolio um. Ohne Berücksichtigung von Gebühren, Spreads und Steuern frisst der Umschlag (turnover) den theoretischen Mehrertrag auf.

    -
    #!/usr/bin/env python3
    -
    -# VaR_CVaR_Demo.py
    -"""
    -Kapitel CVaR: Fat Tails, VaR und CVaR anschaulich.
    -
    -Teil 1: Wie oft treten "unmoegliche" Tage wirklich auf?
    -Teil 2: Warum ist der VaR nicht subadditiv - ein Gegenbeispiel zum Nachrechnen.
    -"""
    -
    -import numpy as np
    -from scipy import stats
    -
    -
    -def var_quantil(verluste: np.ndarray, alpha: float = 0.95) -> float:
    -    """VaR = Quantil der Verlustverteilung (Verluste positiv, Gewinne negativ)."""
    -    return float(np.quantile(verluste, alpha))
    -
    -
    -def cvar_rockafellar(verluste: np.ndarray, alpha: float = 0.95) -> float:
    -    """
    -    CVaR ueber die Rockafellar-Uryasev-Formel:
    -        CVaR = min_gamma { gamma + 1/(1-alpha) * E[max(Verlust - gamma, 0)] }
    -
    -    WICHTIG: Der naheliegende Weg "Mittelwert aller Werte >= VaR" ist FALSCH,
    -    sobald die Verteilung Atome hat (z. B. genau zwei moegliche Verluste).
    -    Dann liegt der VaR selbst auf einem Atom, und der Vergleich '>=' erfasst
    -    zu viel Wahrscheinlichkeitsmasse. Die Formel unten behandelt das korrekt -
    -    und ist zugleich genau der Ausdruck, den wir im Abschnitt 'Value at Risk
    -    und Conditional Value at Risk' optimieren.
    -    """
    -    kandidaten = np.unique(verluste)          # Optimum liegt immer auf einem Datenpunkt
    -    return float(min(g + np.mean(np.maximum(verluste - g, 0.0)) / (1.0 - alpha)
    -                     for g in kandidaten))
    -
    -
    -if __name__ == "__main__":
    -    rng = np.random.default_rng(2026)
    -
    -    # --- Teil 1: Fat Tails (analytisch, nicht simuliert) ------------------
    -    print("=" * 88)
    -    print("  TEIL 1: WIE OFT TRITT DAS 'UNMOEGLICHE' EIN?")
    -    print("=" * 88)
    -    print("Vergleich: Normalverteilung gegen t-Verteilung mit 3 Freiheitsgraden")
    -    print("(beide auf Standardabweichung 1 normiert).\n")
    -
    -    t_verteilung = stats.t(df=3)
    -    skalierung = t_verteilung.std()            # auf Varianz 1 bringen
    -
    -    print(f"{'Ereignis':<22} {'Normal':>14} {'t (df=3)':>14} {'Faktor':>11} "
    -          f"{'Normal: 1 Tag in':>18}")
    -    print("-" * 88)
    -    for k in [3, 4, 5, 6]:
    -        p_normal = 2 * stats.norm.sf(k)                     # beidseitig
    -        p_t = 2 * t_verteilung.sf(k * skalierung)
    -        print(f"Abweichung > {k} Sigma  {p_normal*100:>13.6f} % {p_t*100:>13.6f} % "
    -              f"{p_t/p_normal:>10.1f}x {1/p_normal/252:>15,.0f} Jahre")
    -
    -    print("\nDeutung: Ein 5-Sigma-Tag ist unter Normalverteilung ein Ereignis von")
    -    print("etwa einmal in 6.900 Jahren. Reale Aktienmaerkte liefern solche Tage")
    -    print("mehrfach pro Jahrzehnt. Wer allein mit Varianz steuert, plant fuer")
    -    print("eine Welt, in der Crashs praktisch nicht vorkommen.")
    -
    -    # --- Teil 2: VaR ist nicht subadditiv ---------------------------------
    -    print("\n" + "=" * 88)
    -    print("  TEIL 2: WARUM DER VaR KEIN KOHAERENTES RISIKOMASS IST")
    -    print("=" * 88)
    -    print("Zwei unabhaengige Anleihen, je 100 EUR Nominal.")
    -    print("Jede faellt mit 4 % Wahrscheinlichkeit aus (Verlust 100),")
    -    print("sonst zahlt sie 2 EUR Kupon (Verlust -2).\n")
    -
    -    ziehungen = 2_000_000
    -    verlust_a = np.where(rng.random(ziehungen) < 0.04, 100.0, -2.0)
    -    verlust_b = np.where(rng.random(ziehungen) < 0.04, 100.0, -2.0)
    -    verlust_ab = verlust_a + verlust_b
    -
    -    print(f"{'':<28} {'VaR 95%':>12} {'CVaR 95%':>12}")
    -    print("-" * 88)
    -    werte = {}
    -    for name, v in [("Anleihe A allein", verlust_a),
    -                    ("Anleihe B allein", verlust_b),
    -                    ("Portfolio A+B", verlust_ab)]:
    -        werte[name] = (var_quantil(v), cvar_rockafellar(v))
    -        print(f"{name:<28} {werte[name][0]:>12.2f} {werte[name][1]:>12.2f}")
    -
    -    var_summe = werte["Anleihe A allein"][0] + werte["Anleihe B allein"][0]
    -    cvar_summe = werte["Anleihe A allein"][1] + werte["Anleihe B allein"][1]
    -    print(f"{'Summe der Einzelwerte':<28} {var_summe:>12.2f} {cvar_summe:>12.2f}")
    -
    -    var_port, cvar_port = werte["Portfolio A+B"]
    -    print("-" * 88)
    -    print(f"VaR:  Portfolio {var_port:7.2f}  vs. Summe {var_summe:7.2f}  -> "
    -          f"{'VERLETZT die Subadditivitaet!' if var_port > var_summe else 'subadditiv'}")
    -    print(f"CVaR: Portfolio {cvar_port:7.2f}  vs. Summe {cvar_summe:7.2f}  -> "
    -          f"{'subadditiv (kohaerent)' if cvar_port <= cvar_summe + 1e-6 else 'verletzt'}")
    -
    -    print("\nDeutung: Einzeln betrachtet meldet der VaR fuer jede Anleihe einen")
    -    print("GEWINN von 2 EUR - denn mit 96 % Wahrscheinlichkeit passiert nichts,")
    -    print("und 4 % liegen unterhalb der 5-%-Schwelle. Im Portfolio steigt die")
    -    print("Wahrscheinlichkeit mindestens eines Ausfalls auf 7,8 % und damit UEBER")
    -    print("die Schwelle - der VaR springt auf 98. Er behauptet also, Streuung")
    -    print("habe das Risiko erhoeht. Das ist oekonomisch unsinnig und der Grund,")
    -    print("warum die Bankenaufsicht mit Basel III auf den Expected Shortfall")
    -    print("umgestellt hat.")
    -    print("=" * 88)
    +
    #!/usr/bin/env python3
    +
    +# VaR_CVaR_Demo.py
    +"""
    +Kapitel CVaR: Fat Tails, VaR und CVaR anschaulich.
    +
    +Teil 1: Wie oft treten "unmoegliche" Tage wirklich auf?
    +Teil 2: Warum ist der VaR nicht subadditiv - ein Gegenbeispiel zum Nachrechnen.
    +"""
    +
    +import numpy as np
    +from scipy import stats
    +
    +
    +def var_quantil(verluste: np.ndarray, alpha: float = 0.95) -> float:
    +    """VaR = Quantil der Verlustverteilung (Verluste positiv, Gewinne negativ)."""
    +    return float(np.quantile(verluste, alpha))
    +
    +
    +def cvar_rockafellar(verluste: np.ndarray, alpha: float = 0.95) -> float:
    +    """
    +    CVaR ueber die Rockafellar-Uryasev-Formel:
    +        CVaR = min_gamma { gamma + 1/(1-alpha) * E[max(Verlust - gamma, 0)] }
    +
    +    WICHTIG: Der naheliegende Weg "Mittelwert aller Werte >= VaR" ist FALSCH,
    +    sobald die Verteilung Atome hat (z. B. genau zwei moegliche Verluste).
    +    Dann liegt der VaR selbst auf einem Atom, und der Vergleich '>=' erfasst
    +    zu viel Wahrscheinlichkeitsmasse. Die Formel unten behandelt das korrekt -
    +    und ist zugleich genau der Ausdruck, den wir im Abschnitt 'Value at Risk
    +    und Conditional Value at Risk' optimieren.
    +    """
    +    kandidaten = np.unique(verluste)          # Optimum liegt immer auf einem Datenpunkt
    +    return float(min(g + np.mean(np.maximum(verluste - g, 0.0)) / (1.0 - alpha)
    +                     for g in kandidaten))
    +
    +
    +if __name__ == "__main__":
    +    rng = np.random.default_rng(2026)
    +
    +    # --- Teil 1: Fat Tails (analytisch, nicht simuliert) ------------------
    +    print("=" * 88)
    +    print("  TEIL 1: WIE OFT TRITT DAS 'UNMOEGLICHE' EIN?")
    +    print("=" * 88)
    +    print("Vergleich: Normalverteilung gegen t-Verteilung mit 3 Freiheitsgraden")
    +    print("(beide auf Standardabweichung 1 normiert).\n")
    +
    +    t_verteilung = stats.t(df=3)
    +    skalierung = t_verteilung.std()            # auf Varianz 1 bringen
    +
    +    print(f"{'Ereignis':<22} {'Normal':>14} {'t (df=3)':>14} {'Faktor':>11} "
    +          f"{'Normal: 1 Tag in':>18}")
    +    print("-" * 88)
    +    for k in [3, 4, 5, 6]:
    +        p_normal = 2 * stats.norm.sf(k)                     # beidseitig
    +        p_t = 2 * t_verteilung.sf(k * skalierung)
    +        print(f"Abweichung > {k} Sigma  {p_normal*100:>13.6f} % {p_t*100:>13.6f} % "
    +              f"{p_t/p_normal:>10.1f}x {1/p_normal/252:>15,.0f} Jahre")
    +
    +    print("\nDeutung: Ein 5-Sigma-Tag ist unter Normalverteilung ein Ereignis von")
    +    print("etwa einmal in 6.900 Jahren. Reale Aktienmaerkte liefern solche Tage")
    +    print("mehrfach pro Jahrzehnt. Wer allein mit Varianz steuert, plant fuer")
    +    print("eine Welt, in der Crashs praktisch nicht vorkommen.")
    +
    +    # --- Teil 2: VaR ist nicht subadditiv ---------------------------------
    +    print("\n" + "=" * 88)
    +    print("  TEIL 2: WARUM DER VaR KEIN KOHAERENTES RISIKOMASS IST")
    +    print("=" * 88)
    +    print("Zwei unabhaengige Anleihen, je 100 EUR Nominal.")
    +    print("Jede faellt mit 4 % Wahrscheinlichkeit aus (Verlust 100),")
    +    print("sonst zahlt sie 2 EUR Kupon (Verlust -2).\n")
    +
    +    ziehungen = 2_000_000
    +    verlust_a = np.where(rng.random(ziehungen) < 0.04, 100.0, -2.0)
    +    verlust_b = np.where(rng.random(ziehungen) < 0.04, 100.0, -2.0)
    +    verlust_ab = verlust_a + verlust_b
    +
    +    print(f"{'':<28} {'VaR 95%':>12} {'CVaR 95%':>12}")
    +    print("-" * 88)
    +    werte = {}
    +    for name, v in [("Anleihe A allein", verlust_a),
    +                    ("Anleihe B allein", verlust_b),
    +                    ("Portfolio A+B", verlust_ab)]:
    +        werte[name] = (var_quantil(v), cvar_rockafellar(v))
    +        print(f"{name:<28} {werte[name][0]:>12.2f} {werte[name][1]:>12.2f}")
    +
    +    var_summe = werte["Anleihe A allein"][0] + werte["Anleihe B allein"][0]
    +    cvar_summe = werte["Anleihe A allein"][1] + werte["Anleihe B allein"][1]
    +    print(f"{'Summe der Einzelwerte':<28} {var_summe:>12.2f} {cvar_summe:>12.2f}")
    +
    +    var_port, cvar_port = werte["Portfolio A+B"]
    +    print("-" * 88)
    +    print(f"VaR:  Portfolio {var_port:7.2f}  vs. Summe {var_summe:7.2f}  -> "
    +          f"{'VERLETZT die Subadditivitaet!' if var_port > var_summe else 'subadditiv'}")
    +    print(f"CVaR: Portfolio {cvar_port:7.2f}  vs. Summe {cvar_summe:7.2f}  -> "
    +          f"{'subadditiv (kohaerent)' if cvar_port <= cvar_summe + 1e-6 else 'verletzt'}")
    +
    +    print("\nDeutung: Einzeln betrachtet meldet der VaR fuer jede Anleihe einen")
    +    print("GEWINN von 2 EUR - denn mit 96 % Wahrscheinlichkeit passiert nichts,")
    +    print("und 4 % liegen unterhalb der 5-%-Schwelle. Im Portfolio steigt die")
    +    print("Wahrscheinlichkeit mindestens eines Ausfalls auf 7,8 % und damit UEBER")
    +    print("die Schwelle - der VaR springt auf 98. Er behauptet also, Streuung")
    +    print("habe das Risiko erhoeht. Das ist oekonomisch unsinnig und der Grund,")
    +    print("warum die Bankenaufsicht mit Basel III auf den Expected Shortfall")
    +    print("umgestellt hat.")
    +    print("=" * 88)

    Erwartete Ausgabe (gekürzt):

    ========================================================================================
       TEIL 1: WIE OFT TRITT DAS 'UNMOEGLICHE' EIN?
    @@ -23396,215 +23712,215 @@ CVaR: Portfolio  101.33  vs. Summe  159.24  -> subadditiv (kohaerent)
     

    ⚠️ Einheiten konsistent halten

    Ein häufiger Fehler verrechnet eine annualisierte Rendite gegen einen täglichen CVaR:

    -
    mu = returns_df.mean().values * 252              # ANNUALISIERT
    -cvar = gamma + (1/(S*(1-alpha))) * cp.sum(u)     # TAEGLICH
    -objective = cp.Maximize(mu @ w - lambda_risk * cvar - trans_costs)
    +
    mu = returns_df.mean().values * 252              # ANNUALISIERT
    +cvar = gamma + (1/(S*(1-alpha))) * cp.sum(u)     # TAEGLICH
    +objective = cp.Maximize(mu @ w - lambda_risk * cvar - trans_costs)

    Der Risikoterm ist dadurch faktisch um Faktor 252 zu schwach gewichtet: lambda_risk = 1.5 bedeutet dann effektiv eine Risikoaversion von 1{,}5/252 \approx 0{,}006. Das Modell ist nicht falsch im Sinne von unlösbar — aber der Parameter bedeutet nicht, was er zu bedeuten scheint, und lässt sich daher nicht sinnvoll einstellen.

    Dieses Programm rechnet deshalb durchgehend auf Tagesbasis und annualisiert erst in der Ausgabe.

    -
    #!/usr/bin/env python3
    -
    -# CVaR_Portfolio.py
    -"""
    -Kapitel CVaR: CVaR-Optimierung mit L1-Transaktionskosten via CVXPY.
    -
    -Eigenschaften:
    -  * Spaltenreihenfolge erzwungen
    -  * Einheiten konsistent (alles taeglich, Annualisierung nur in der Ausgabe)
    -  * Szenario-Nebenbedingungen VEKTORISIERT statt in einer Python-Schleife
    -    (eine Matrixbedingung statt S einzelner Constraints - deutlich schneller)
    -  * Vergleich CVaR- gegen Varianz-Optimierung
    -  * Nachrechnung von VaR/CVaR aus den realisierten Szenarien
    -"""
    -
    -import time
    -
    -import cvxpy as cp
    -import numpy as np
    -import pandas as pd
    -import yfinance as yf
    -
    -TICKER = ["AAPL", "MSFT", "NVDA", "AMZN", "JNJ", "PFE", "JPM", "GS", "XOM", "CVX"]
    -ALPHA = 0.95            # Konfidenzniveau: schlechteste 5 % der Tage
    -MAX_GEWICHT = 0.25
    -GEBUEHRENSATZ = 0.002   # 0,2 % je Einheit Turnover (Spread + Brokerage)
    -RISIKOAVERSION = 1.5    # bezogen auf TAEGLICHE Groessen
    -HANDELSTAGE = 252
    -
    -
    -def lade_renditen():
    -    ende = pd.Timestamp.today().normalize()
    -    start = ende - pd.DateOffset(years=2)
    -    roh = yf.download(TICKER, start=start, end=ende, auto_adjust=True, progress=False)
    -    if roh.empty:
    -        raise SystemExit("Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).")
    -
    -    if isinstance(roh.columns, pd.MultiIndex):
    -        kurse = roh["Close"][TICKER].dropna()        # erzwingt eigene Spaltenreihenfolge
    -    else:
    -        kurse = roh[["Close"]].dropna()
    -        kurse.columns = TICKER
    -    assert list(kurse.columns) == TICKER, "Spaltenreihenfolge weicht ab!"
    -    return kurse.pct_change().dropna()
    -
    -
    -def optimiere_cvar(R, w_alt, vektorisiert=True):
    -    """
    -    Maximiere:  taegliche Rendite - lambda * CVaR - Transaktionskosten
    -    Alle Groessen TAEGLICH.
    -    """
    -    S, N = R.shape
    -    mu_taeglich = R.mean(axis=0)
    -
    -    w = cp.Variable(N, nonneg=True)
    -    gamma = cp.Variable()                  # wird im Optimum zum VaR
    -    u = cp.Variable(S, nonneg=True)        # Ueberschuss ueber die Schwelle
    -
    -    cvar = gamma + (1.0 / (S * (1.0 - ALPHA))) * cp.sum(u)
    -    turnover = cp.norm1(w - w_alt)
    -    kosten = GEBUEHRENSATZ * turnover
    -
    -    ziel = cp.Maximize(mu_taeglich @ w - RISIKOAVERSION * cvar - kosten)
    -
    -    bedingungen = [cp.sum(w) == 1, w <= MAX_GEWICHT]
    -    if vektorisiert:
    -        # EINE Matrixbedingung statt S einzelner - deutlich schneller
    -        bedingungen.append(u >= -(R @ w) - gamma)
    -    else:
    -        for s in range(S):                 # Alternative: S einzelne Constraints (langsamer)
    -            bedingungen.append(u[s] >= -R[s] @ w - gamma)
    -
    -    problem = cp.Problem(ziel, bedingungen)
    -    problem.solve()
    -    if problem.status not in ("optimal", "optimal_inaccurate"):
    -        raise SystemExit(f"CVaR-Problem nicht loesbar: {problem.status}")
    -    return w.value, float(gamma.value), float(cvar.value), float(turnover.value)
    -
    -
    -def optimiere_varianz(R, w_alt):
    -    """Klassisches Mean-Variance zum Vergleich - ebenfalls taeglich gerechnet.
    -
    -    ACHTUNG, DCP-Falle: Die Standardabweichung ist hier NICHT als
    -    cp.sqrt(cp.quad_form(w, sigma)) formulierbar. cp.sqrt ist konkav und
    -    verlangt ein konkaves Argument; quad_form ist konvex - CVXPY lehnt den
    -    Ausdruck mit einem DCPError ab, und zwar voellig zu Recht (Anhang
    -    Fehlerdiagnose). cp.psd_wrap() hilft dagegen nicht: Es behebt eine
    -    NUMERISCHE Beanstandung an sigma, keine Regelverletzung im Aufbau.
    -
    -    Der Standardweg ist die Cholesky-Zerlegung sigma = L L^T. Damit gilt
    -    w' sigma w = ||L^T w||^2, also ist die Standardabweichung die 2-Norm
    -    eines AFFINEN Ausdrucks - konvex und damit regelkonform.
    -    """
    -    N = R.shape[1]
    -    mu_taeglich = R.mean(axis=0)
    -    sigma = np.cov(R, rowvar=False, ddof=1)
    -    # Der winzige Diagonalzuschlag faengt den Fall ab, dass sigma numerisch
    -    # nur halbdefinit ist (mehr Titel als Handelstage, doppelte Spalten).
    -    L = np.linalg.cholesky(sigma + 1e-12 * np.eye(N))
    -
    -    w = cp.Variable(N, nonneg=True)
    -    ziel = cp.Maximize(mu_taeglich @ w
    -                       - RISIKOAVERSION * cp.norm2(L.T @ w)
    -                       - GEBUEHRENSATZ * cp.norm1(w - w_alt))
    -    problem = cp.Problem(ziel, [cp.sum(w) == 1, w <= MAX_GEWICHT])
    -    problem.solve()
    -    if problem.status not in ("optimal", "optimal_inaccurate"):
    -        raise SystemExit(f"Varianz-Problem nicht loesbar: {problem.status}")
    -    return w.value
    -
    -
    -def realisierte_kennzahlen(w, R):
    -    """VaR und CVaR direkt aus den Szenarien - unabhaengige Gegenprobe."""
    -    portfoliorenditen = R @ w
    -    verluste = -portfoliorenditen
    -    var = float(np.quantile(verluste, ALPHA))
    -    cvar = float(verluste[verluste >= var].mean())
    -    return var, cvar, float(portfoliorenditen.mean()), float(portfoliorenditen.std(ddof=1))
    -
    -
    -if __name__ == "__main__":
    -    renditen = lade_renditen()
    -    R = renditen.values
    -    S, N = R.shape
    -    w_alt = np.ones(N) / N                 # Ausgangslage: Gleichgewichtung
    -
    -    t0 = time.perf_counter()
    -    w_cvar, var_modell, cvar_modell, turnover = optimiere_cvar(R, w_alt, vektorisiert=True)
    -    dauer_vektor = time.perf_counter() - t0
    -
    -    w_var = optimiere_varianz(R, w_alt)
    -
    -    print("=" * 90)
    -    print("         CVaR-PORTFOLIO-OPTIMIERUNG MIT TRANSAKTIONSKOSTEN")
    -    print("=" * 90)
    -    print(f"Datenbasis: {S} Handelstage, {N} Titel | Konfidenzniveau "
    -          f"{ALPHA*100:.0f} % | Loesungszeit {dauer_vektor:.2f} s\n")
    -
    -    # --- Gegenprobe: Modellwerte gegen realisierte Szenariowerte ---------
    -    var_real, cvar_real, mu_real, sd_real = realisierte_kennzahlen(w_cvar, R)
    -    print("--- Gegenprobe: stimmen Modell und Szenarien ueberein? ---")
    -    print(f"  VaR  aus dem Modell (gamma): {var_modell*100:7.4f} %  |  "
    -          f"aus den Szenarien: {var_real*100:7.4f} %")
    -    print(f"  CVaR aus dem Modell:         {cvar_modell*100:7.4f} %  |  "
    -          f"aus den Szenarien: {cvar_real*100:7.4f} %")
    -    assert abs(cvar_modell - cvar_real) < 1e-4, "CVaR stimmt nicht mit den Szenarien!"
    -    print("  -> Der Rockafellar-Uryasev-Trick liefert exakt den empirischen CVaR.")
    -
    -    # --- Kennzahlen beider Portfolios ------------------------------------
    -    print(f"\n{'Portfolio':<24} {'Rendite p.a.':>13} {'Vola p.a.':>11} "
    -          f"{'VaR 95% (Tag)':>15} {'CVaR 95% (Tag)':>16} {'Turnover':>10}")
    -    print("-" * 90)
    -    for name, w in [("CVaR-optimiert", w_cvar), ("Varianz-optimiert", w_var),
    -                    ("Gleichgewichtung", w_alt)]:
    -        v, c, m, s = realisierte_kennzahlen(w, R)
    -        to = float(np.abs(w - w_alt).sum())
    -        print(f"{name:<24} {m*HANDELSTAGE*100:>12.2f} % "
    -              f"{s*np.sqrt(HANDELSTAGE)*100:>10.2f} % {v*100:>14.3f} % "
    -              f"{c*100:>15.3f} % {to*100:>9.1f} %")
    -
    -    print("\nHinweis zur Annualisierung: Renditen werden mit 252 skaliert,")
    -    print("Volatilitaeten mit sqrt(252). Fuer VaR/CVaR ist eine solche Skalierung")
    -    print("nur unter starken Annahmen (Unabhaengigkeit, kein Drift) zulaessig -")
    -    print("sie werden hier deshalb bewusst als TAGESwerte ausgewiesen.")
    -
    -    # --- Allokationstabelle ------------------------------------------------
    -    print("\n--- Allokation ---")
    -    tabelle = pd.DataFrame({
    -        "Ticker": TICKER,
    -        "vorher": [f"{v*100:5.1f} %" for v in w_alt],
    -        "CVaR-opt.": [f"{v*100:5.1f} %" for v in w_cvar],
    -        "Handel": [f"{(w_cvar[i]-w_alt[i])*100:+6.1f} %" for i in range(N)],
    -        "Varianz-opt.": [f"{v*100:5.1f} %" for v in w_var],
    -    })
    -    print(tabelle.to_string(index=False))
    -    print(f"\nTurnover {turnover*100:.1f} % -> Transaktionskosten "
    -          f"{GEBUEHRENSATZ*turnover*100:.3f} % des Portfoliowerts")
    -
    -    # --- Laufzeitvergleich vektorisiert vs. Schleife ----------------------
    -    if S <= 600:                     # bei sehr vielen Szenarien zu langsam
    -        t0 = time.perf_counter()
    -        optimiere_cvar(R, w_alt, vektorisiert=False)
    -        dauer_schleife = time.perf_counter() - t0
    -        print(f"\n--- Laufzeit: {S} Nebenbedingungen aufbauen ---")
    -        print(f"  vektorisiert (u >= -(R @ w) - gamma): {dauer_vektor:6.2f} s")
    -        print(f"  Schleife ueber Szenarien (V01):        {dauer_schleife:6.2f} s "
    -              f"({dauer_schleife/dauer_vektor:.1f}x langsamer)")
    -    print("=" * 90)
    +
    #!/usr/bin/env python3
    +
    +# CVaR_Portfolio.py
    +"""
    +Kapitel CVaR: CVaR-Optimierung mit L1-Transaktionskosten via CVXPY.
    +
    +Eigenschaften:
    +  * Spaltenreihenfolge erzwungen
    +  * Einheiten konsistent (alles taeglich, Annualisierung nur in der Ausgabe)
    +  * Szenario-Nebenbedingungen VEKTORISIERT statt in einer Python-Schleife
    +    (eine Matrixbedingung statt S einzelner Constraints - deutlich schneller)
    +  * Vergleich CVaR- gegen Varianz-Optimierung
    +  * Nachrechnung von VaR/CVaR aus den realisierten Szenarien
    +"""
    +
    +import time
    +
    +import cvxpy as cp
    +import numpy as np
    +import pandas as pd
    +import yfinance as yf
    +
    +TICKER = ["AAPL", "MSFT", "NVDA", "AMZN", "JNJ", "PFE", "JPM", "GS", "XOM", "CVX"]
    +ALPHA = 0.95            # Konfidenzniveau: schlechteste 5 % der Tage
    +MAX_GEWICHT = 0.25
    +GEBUEHRENSATZ = 0.002   # 0,2 % je Einheit Turnover (Spread + Brokerage)
    +RISIKOAVERSION = 1.5    # bezogen auf TAEGLICHE Groessen
    +HANDELSTAGE = 252
    +
    +
    +def lade_renditen():
    +    ende = pd.Timestamp.today().normalize()
    +    start = ende - pd.DateOffset(years=2)
    +    roh = yf.download(TICKER, start=start, end=ende, auto_adjust=True, progress=False)
    +    if roh.empty:
    +        raise SystemExit("Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).")
    +
    +    if isinstance(roh.columns, pd.MultiIndex):
    +        kurse = roh["Close"][TICKER].dropna()        # erzwingt eigene Spaltenreihenfolge
    +    else:
    +        kurse = roh[["Close"]].dropna()
    +        kurse.columns = TICKER
    +    assert list(kurse.columns) == TICKER, "Spaltenreihenfolge weicht ab!"
    +    return kurse.pct_change().dropna()
    +
    +
    +def optimiere_cvar(R, w_alt, vektorisiert=True):
    +    """
    +    Maximiere:  taegliche Rendite - lambda * CVaR - Transaktionskosten
    +    Alle Groessen TAEGLICH.
    +    """
    +    S, N = R.shape
    +    mu_taeglich = R.mean(axis=0)
    +
    +    w = cp.Variable(N, nonneg=True)
    +    gamma = cp.Variable()                  # wird im Optimum zum VaR
    +    u = cp.Variable(S, nonneg=True)        # Ueberschuss ueber die Schwelle
    +
    +    cvar = gamma + (1.0 / (S * (1.0 - ALPHA))) * cp.sum(u)
    +    turnover = cp.norm1(w - w_alt)
    +    kosten = GEBUEHRENSATZ * turnover
    +
    +    ziel = cp.Maximize(mu_taeglich @ w - RISIKOAVERSION * cvar - kosten)
    +
    +    bedingungen = [cp.sum(w) == 1, w <= MAX_GEWICHT]
    +    if vektorisiert:
    +        # EINE Matrixbedingung statt S einzelner - deutlich schneller
    +        bedingungen.append(u >= -(R @ w) - gamma)
    +    else:
    +        for s in range(S):                 # Alternative: S einzelne Constraints (langsamer)
    +            bedingungen.append(u[s] >= -R[s] @ w - gamma)
    +
    +    problem = cp.Problem(ziel, bedingungen)
    +    problem.solve()
    +    if problem.status not in ("optimal", "optimal_inaccurate"):
    +        raise SystemExit(f"CVaR-Problem nicht loesbar: {problem.status}")
    +    return w.value, float(gamma.value), float(cvar.value), float(turnover.value)
    +
    +
    +def optimiere_varianz(R, w_alt):
    +    """Klassisches Mean-Variance zum Vergleich - ebenfalls taeglich gerechnet.
    +
    +    ACHTUNG, DCP-Falle: Die Standardabweichung ist hier NICHT als
    +    cp.sqrt(cp.quad_form(w, sigma)) formulierbar. cp.sqrt ist konkav und
    +    verlangt ein konkaves Argument; quad_form ist konvex - CVXPY lehnt den
    +    Ausdruck mit einem DCPError ab, und zwar voellig zu Recht (Anhang
    +    Fehlerdiagnose). cp.psd_wrap() hilft dagegen nicht: Es behebt eine
    +    NUMERISCHE Beanstandung an sigma, keine Regelverletzung im Aufbau.
    +
    +    Der Standardweg ist die Cholesky-Zerlegung sigma = L L^T. Damit gilt
    +    w' sigma w = ||L^T w||^2, also ist die Standardabweichung die 2-Norm
    +    eines AFFINEN Ausdrucks - konvex und damit regelkonform.
    +    """
    +    N = R.shape[1]
    +    mu_taeglich = R.mean(axis=0)
    +    sigma = np.cov(R, rowvar=False, ddof=1)
    +    # Der winzige Diagonalzuschlag faengt den Fall ab, dass sigma numerisch
    +    # nur halbdefinit ist (mehr Titel als Handelstage, doppelte Spalten).
    +    L = np.linalg.cholesky(sigma + 1e-12 * np.eye(N))
    +
    +    w = cp.Variable(N, nonneg=True)
    +    ziel = cp.Maximize(mu_taeglich @ w
    +                       - RISIKOAVERSION * cp.norm2(L.T @ w)
    +                       - GEBUEHRENSATZ * cp.norm1(w - w_alt))
    +    problem = cp.Problem(ziel, [cp.sum(w) == 1, w <= MAX_GEWICHT])
    +    problem.solve()
    +    if problem.status not in ("optimal", "optimal_inaccurate"):
    +        raise SystemExit(f"Varianz-Problem nicht loesbar: {problem.status}")
    +    return w.value
    +
    +
    +def realisierte_kennzahlen(w, R):
    +    """VaR und CVaR direkt aus den Szenarien - unabhaengige Gegenprobe."""
    +    portfoliorenditen = R @ w
    +    verluste = -portfoliorenditen
    +    var = float(np.quantile(verluste, ALPHA))
    +    cvar = float(verluste[verluste >= var].mean())
    +    return var, cvar, float(portfoliorenditen.mean()), float(portfoliorenditen.std(ddof=1))
    +
    +
    +if __name__ == "__main__":
    +    renditen = lade_renditen()
    +    R = renditen.values
    +    S, N = R.shape
    +    w_alt = np.ones(N) / N                 # Ausgangslage: Gleichgewichtung
    +
    +    t0 = time.perf_counter()
    +    w_cvar, var_modell, cvar_modell, turnover = optimiere_cvar(R, w_alt, vektorisiert=True)
    +    dauer_vektor = time.perf_counter() - t0
    +
    +    w_var = optimiere_varianz(R, w_alt)
    +
    +    print("=" * 90)
    +    print("         CVaR-PORTFOLIO-OPTIMIERUNG MIT TRANSAKTIONSKOSTEN")
    +    print("=" * 90)
    +    print(f"Datenbasis: {S} Handelstage, {N} Titel | Konfidenzniveau "
    +          f"{ALPHA*100:.0f} % | Loesungszeit {dauer_vektor:.2f} s\n")
    +
    +    # --- Gegenprobe: Modellwerte gegen realisierte Szenariowerte ---------
    +    var_real, cvar_real, mu_real, sd_real = realisierte_kennzahlen(w_cvar, R)
    +    print("--- Gegenprobe: stimmen Modell und Szenarien ueberein? ---")
    +    print(f"  VaR  aus dem Modell (gamma): {var_modell*100:7.4f} %  |  "
    +          f"aus den Szenarien: {var_real*100:7.4f} %")
    +    print(f"  CVaR aus dem Modell:         {cvar_modell*100:7.4f} %  |  "
    +          f"aus den Szenarien: {cvar_real*100:7.4f} %")
    +    assert abs(cvar_modell - cvar_real) < 1e-4, "CVaR stimmt nicht mit den Szenarien!"
    +    print("  -> Der Rockafellar-Uryasev-Trick liefert exakt den empirischen CVaR.")
    +
    +    # --- Kennzahlen beider Portfolios ------------------------------------
    +    print(f"\n{'Portfolio':<24} {'Rendite p.a.':>13} {'Vola p.a.':>11} "
    +          f"{'VaR 95% (Tag)':>15} {'CVaR 95% (Tag)':>16} {'Turnover':>10}")
    +    print("-" * 90)
    +    for name, w in [("CVaR-optimiert", w_cvar), ("Varianz-optimiert", w_var),
    +                    ("Gleichgewichtung", w_alt)]:
    +        v, c, m, s = realisierte_kennzahlen(w, R)
    +        to = float(np.abs(w - w_alt).sum())
    +        print(f"{name:<24} {m*HANDELSTAGE*100:>12.2f} % "
    +              f"{s*np.sqrt(HANDELSTAGE)*100:>10.2f} % {v*100:>14.3f} % "
    +              f"{c*100:>15.3f} % {to*100:>9.1f} %")
    +
    +    print("\nHinweis zur Annualisierung: Renditen werden mit 252 skaliert,")
    +    print("Volatilitaeten mit sqrt(252). Fuer VaR/CVaR ist eine solche Skalierung")
    +    print("nur unter starken Annahmen (Unabhaengigkeit, kein Drift) zulaessig -")
    +    print("sie werden hier deshalb bewusst als TAGESwerte ausgewiesen.")
    +
    +    # --- Allokationstabelle ------------------------------------------------
    +    print("\n--- Allokation ---")
    +    tabelle = pd.DataFrame({
    +        "Ticker": TICKER,
    +        "vorher": [f"{v*100:5.1f} %" for v in w_alt],
    +        "CVaR-opt.": [f"{v*100:5.1f} %" for v in w_cvar],
    +        "Handel": [f"{(w_cvar[i]-w_alt[i])*100:+6.1f} %" for i in range(N)],
    +        "Varianz-opt.": [f"{v*100:5.1f} %" for v in w_var],
    +    })
    +    print(tabelle.to_string(index=False))
    +    print(f"\nTurnover {turnover*100:.1f} % -> Transaktionskosten "
    +          f"{GEBUEHRENSATZ*turnover*100:.3f} % des Portfoliowerts")
    +
    +    # --- Laufzeitvergleich vektorisiert vs. Schleife ----------------------
    +    if S <= 600:                     # bei sehr vielen Szenarien zu langsam
    +        t0 = time.perf_counter()
    +        optimiere_cvar(R, w_alt, vektorisiert=False)
    +        dauer_schleife = time.perf_counter() - t0
    +        print(f"\n--- Laufzeit: {S} Nebenbedingungen aufbauen ---")
    +        print(f"  vektorisiert (u >= -(R @ w) - gamma): {dauer_vektor:6.2f} s")
    +        print(f"  Schleife ueber Szenarien (V01):        {dauer_schleife:6.2f} s "
    +              f"({dauer_schleife/dauer_vektor:.1f}x langsamer)")
    +    print("=" * 90)

    💻 Code-Durchgang: Vektorisierung

    Die entscheidende Zeile ist

    -
    bedingungen.append(u >= -(R @ w) - gamma)     # EINE Matrixbedingung
    +
    bedingungen.append(u >= -(R @ w) - gamma)     # EINE Matrixbedingung

    statt

    -
    for s in range(S):
    -    bedingungen.append(u[s] >= -R[s] @ w - gamma)    # S einzelne Bedingungen
    +
    for s in range(S):
    +    bedingungen.append(u[s] >= -R[s] @ w - gamma)    # S einzelne Bedingungen

    Beide beschreiben dasselbe Modell. Der Unterschied liegt im Aufbau: CVXPY muss im zweiten Fall 500 einzelne Ausdrucksbäume erzeugen, prüfen und zusammensetzen. Bei einem Backtest mit 60 Rebalancings (Kapitel 21) summiert sich das erheblich.

    Die allgemeine Regel: Wenn Sie in einer Modellierungssprache eine Python-Schleife über Datenzeilen schreiben, prüfen Sie, ob sich dasselbe als Matrixoperation ausdrücken lässt. Der Solver rechnet ohnehin mit Matrizen — der Umweg über einzelne Ausdrücke kostet nur Aufbauzeit.

    ⚠️ Die Standardabweichung ist in CVXPY keine Wurzel

    Im Vergleichsmodell optimiere_varianz() steht die Standardabweichung als

    -
    L = np.linalg.cholesky(sigma + 1e-12 * np.eye(N))
    -risiko = cp.norm2(L.T @ w)                    # = sqrt(w' sigma w)
    +
    L = np.linalg.cholesky(sigma + 1e-12 * np.eye(N))
    +risiko = cp.norm2(L.T @ w)                    # = sqrt(w' sigma w)

    und nicht als das Naheliegende, cp.sqrt(cp.quad_form(w, sigma)). Der naheliegende Ausdruck ist nicht DCP und wird von CVXPY mit einem DCPError abgelehnt: cp.sqrt ist konkav und verlangt deshalb ein konkaves Argument, quad_form ist aber konvex. Die Regel wird verletzt, obwohl die Funktion als ganze mathematisch völlig harmlos ist — \sqrt{w^\top \Sigma w} ist konvex, DCP kann es nur nicht sehen.

    Wichtig ist die Fehlerdiagnose dahinter, weil sie leicht in die Irre geht: cp.psd_wrap(sigma) sieht nach der Lösung aus und ist keine. Es unterdrückt die numerische Beanstandung, dass \Sigma nicht als positiv semidefinit erkannt wird — gegen die strukturelle Regelverletzung im Aufbau hilft es nicht.

    Der Ausweg ist immer derselbe: Zerlegen Sie \Sigma = LL^\top (Cholesky). Dann ist w^\top \Sigma w = \lVert L^\top w\rVert_2^2, und die Standardabweichung wird zur 2-Norm eines affinen Ausdrucks — konvex, regelkonform und für den Solver sogar die bessere Formulierung, weil sie direkt ein Kegelproblem ist. Der winzige Diagonalzuschlag 10^{-12} fängt den Fall ab, dass \Sigma numerisch nur halbdefinit ist (mehr Titel als Handelstage, duplizierte Spalten). Die vollständige Fehlertabelle steht in Anhang C.

    @@ -23705,28 +24021,28 @@ CVaR: Portfolio 101.33 vs. Summe 159.24 -> subadditiv (kohaerent)

    🚀 In 5 Minuten gelöst: Dieselben Tage, ein anderes Schicksal

    Zwei Strategien über zehn Jahre. Sie bestehen aus exakt denselben Tagesrenditen — nur in anderer Reihenfolge. Bei der einen sind die 250 schlechtesten Tage über den ganzen Zeitraum verstreut, bei der anderen liegen sie am Stück.

    -
    import numpy as np
    -
    -rng = np.random.default_rng(1)
    -tage = rng.normal(0.0005, 0.010, 2520)          # zehn Jahre Tagesrenditen
    -
    -gestreut = tage.copy()
    -rng.shuffle(gestreut)
    -
    -sortiert = np.sort(tage)                         # dieselben Tage, neu geordnet:
    -schlecht, rest = sortiert[:250], sortiert[250:]  # die 250 schlechtesten ...
    -rng.shuffle(rest)
    -geballt = np.concatenate([rest[:1000], schlecht, rest[1000:]])   # ... am Stueck
    -
    -def kennzahlen(r):
    -    verlauf = np.cumprod(1 + r)
    -    rueckschlag = (verlauf / np.maximum.accumulate(verlauf) - 1).min()
    -    return verlauf[-1]**(252/len(r)) - 1, r.mean()/r.std()*np.sqrt(252), rueckschlag
    -
    -for name, r in [("gestreut", gestreut), ("geballt", geballt)]:
    -    jahr, sharpe, rueckschlag = kennzahlen(r)
    -    print(f"{name:9} Jahresrendite {jahr*100:5.2f} %  Sharpe {sharpe:4.2f}  "
    -          f"max. Rueckschlag {rueckschlag*100:6.1f} %")
    +
    import numpy as np
    +
    +rng = np.random.default_rng(1)
    +tage = rng.normal(0.0005, 0.010, 2520)          # zehn Jahre Tagesrenditen
    +
    +gestreut = tage.copy()
    +rng.shuffle(gestreut)
    +
    +sortiert = np.sort(tage)                         # dieselben Tage, neu geordnet:
    +schlecht, rest = sortiert[:250], sortiert[250:]  # die 250 schlechtesten ...
    +rng.shuffle(rest)
    +geballt = np.concatenate([rest[:1000], schlecht, rest[1000:]])   # ... am Stueck
    +
    +def kennzahlen(r):
    +    verlauf = np.cumprod(1 + r)
    +    rueckschlag = (verlauf / np.maximum.accumulate(verlauf) - 1).min()
    +    return verlauf[-1]**(252/len(r)) - 1, r.mean()/r.std()*np.sqrt(252), rueckschlag
    +
    +for name, r in [("gestreut", gestreut), ("geballt", geballt)]:
    +    jahr, sharpe, rueckschlag = kennzahlen(r)
    +    print(f"{name:9} Jahresrendite {jahr*100:5.2f} %  Sharpe {sharpe:4.2f}  "
    +          f"max. Rueckschlag {rueckschlag*100:6.1f} %")

    Ausgabe:

    gestreut  Jahresrendite  8.88 %  Sharpe 0.61  max. Rueckschlag  -19.0 %
     geballt   Jahresrendite  8.88 %  Sharpe 0.61  max. Rueckschlag  -98.7 %
    @@ -23817,9 +24133,9 @@ geballt Jahresrendite 8.88 % Sharpe 0.61 max. Rueckschlag -98.7 %
    21.4 Rebalancing-Termine richtig bestimmen

    ⚠️ Ein stiller, häufiger Fehler

    -
    self.rebalance_dates = self.prices.resample("MS").first().index
    -...
    -if current_date in self.rebalance_dates:
    +
    self.rebalance_dates = self.prices.resample("MS").first().index
    +...
    +if current_date in self.rebalance_dates:

    resample("MS") erzeugt Kalender-Monatsanfänge — den 1. Januar, den 1. Februar und so fort. Der Kursindex enthält aber nur Handelstage. Fällt der Monatserste auf ein Wochenende oder einen Feiertag, ist er kein Element des Index, die Bedingung wird nie wahr, und das Rebalancing entfällt kommentarlos.

    Messung auf einem reinen Wochentagskalender über fünf Jahre:

    @@ -23846,9 +24162,9 @@ geballt Jahresrendite 8.88 % Sharpe 0.61 max. Rueckschlag -98.7 %

    Mit echten Börsenfeiertagen fallen weitere Termine aus. Der Backtest testete also eine andere Strategie als die beschriebene — und lieferte trotzdem plausibel aussehende Kennzahlen.

    Die Korrektur:

    -
    monat = self.kurse.index.to_period("M")
    -self.rebalancing_termine = self.kurse.index[~monat.duplicated()]   # erster HANDELStag
    -assert self.rebalancing_termine.isin(self.kurse.index).all()
    +
    monat = self.kurse.index.to_period("M")
    +self.rebalancing_termine = self.kurse.index[~monat.duplicated()]   # erster HANDELStag
    +assert self.rebalancing_termine.isin(self.kurse.index).all()

    ~monat.duplicated() markiert den jeweils ersten vorhandenen Tag jedes Monats — also garantiert einen Handelstag.

    @@ -23856,325 +24172,325 @@ geballt Jahresrendite 8.88 % Sharpe 0.61 max. Rueckschlag -98.7 %

    21.5 Die Engine

    -
    #!/usr/bin/env python3
    -
    -# QuantitativeTradingEngine.py
    -"""
    -Kapitel Handelsmaschine: Vollstaendige quantitative Handelsmaschine mit
    -Walk-Forward-Backtest und OR-Optimierungsschicht.
    -
    -Eigenschaften:
    -  * Rebalancing-Termine sind garantiert Handelstage; die Zahl der
    -    tatsaechlich ausgefuehrten Umschichtungen wird ausgewiesen
    -  * Spaltenreihenfolge erzwungen
    -  * Einheiten konsistent (taegliche Groessen im Modell)
    -  * Szenario-Nebenbedingungen vektorisiert
    -  * Lookahead-Selbsttest eingebaut
    -  * Zusaetzliche Kennzahlen: Calmar, Trefferquote, Turnover, Kostenanteil
    -"""
    -
    -import os
    -
    -import cvxpy as cp
    -import numpy as np
    -import pandas as pd
    -import yfinance as yf
    -from sklearn.covariance import LedoitWolf
    -import matplotlib
    -matplotlib.use("Agg")
    -import matplotlib.pyplot as plt
    -
    -OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
    -os.makedirs(OUTPUT_DIR, exist_ok=True)
    -
    -HANDELSTAGE = 252
    -
    -
    -class QuantitativeHandelsmaschine:
    -    """Walk-Forward-Backtest mit CVaR-Optimierung und Transaktionskosten."""
    -
    -    def __init__(self, universum, benchmark="SPY", jahre=5,
    -                 startkapital=100_000.0, gebuehrensatz=0.0015,
    -                 max_gewicht=0.20, alpha=0.95, risikoaversion=1.2,
    -                 warmup=HANDELSTAGE):
    -        self.universum = list(universum)
    -        self.benchmark = benchmark
    -        self.jahre = jahre
    -        self.startkapital = startkapital
    -        self.gebuehrensatz = gebuehrensatz
    -        self.max_gewicht = max_gewicht
    -        self.alpha = alpha
    -        self.risikoaversion = risikoaversion
    -        self.warmup = warmup
    -
    -        self.kurse = None
    -        self.benchmark_kurse = None
    -        self.renditen = None
    -        self.rebalancing_termine = None
    -
    -    # -- 1. Datenpipeline ------------------------------------------------
    -    def lade_daten(self):
    -        ende = pd.Timestamp.today().normalize()
    -        start = ende - pd.DateOffset(years=self.jahre)
    -        alle = self.universum + [self.benchmark]
    -
    -        print(f"Lade {len(self.universum)} Titel + Benchmark ({self.benchmark}) "
    -              f"ueber {self.jahre} Jahre ...")
    -        roh = yf.download(alle, start=start, end=ende, auto_adjust=True, progress=False)
    -        if roh.empty:
    -            raise SystemExit("Download fehlgeschlagen (Netz/Ticker/Rate-Limit pruefen).")
    -
    -        if isinstance(roh.columns, pd.MultiIndex):
    -            daten = roh["Close"][alle].dropna()      # erzwingt eigene Spaltenreihenfolge
    -        else:
    -            daten = roh[["Close"]].dropna()
    -            daten.columns = alle
    -        assert list(daten.columns) == alle, "Spaltenreihenfolge weicht ab!"
    -
    -        self.kurse = daten[self.universum]
    -        self.benchmark_kurse = daten[self.benchmark]
    -        self.renditen = self.kurse.pct_change().dropna()
    -
    -        # --- erster HANDELStag je Monat, nicht Kalendermonatsanfang -----
    -        monat = self.kurse.index.to_period("M")
    -        self.rebalancing_termine = self.kurse.index[~monat.duplicated()]
    -        assert self.rebalancing_termine.isin(self.kurse.index).all(), \
    -            "Rebalancing-Termine sind keine Handelstage!"
    -
    -        print(f"  {len(self.kurse)} Handelstage, "
    -              f"{len(self.rebalancing_termine)} Rebalancing-Termine "
    -              f"(alle sind Handelstage)")
    -
    -    # -- 2. Signal --------------------------------------------------------
    -    def alpha_signal(self, stichtag, rueckblick=HANDELSTAGE):
    -        """
    -        12-1-Momentum: Kursentwicklung der letzten 12 Monate unter Auslassung
    -        des letzten Monats (kurzfristige Umkehr herausrechnen).
    -        Rueckgabe: TAEGLICHE Renditeerwartung.
    -        """
    -        historie = self.kurse.loc[:stichtag]
    -        if len(historie) < rueckblick:
    -            return np.zeros(len(self.universum))
    -
    -        p_alt = historie.iloc[-rueckblick]
    -        p_neu = historie.iloc[-21]                    # vor ca. einem Monat
    -        momentum = (p_neu / p_alt - 1.0).values
    -
    -        # Z-Score, damit das Signal skalenfrei wird
    -        streuung = momentum.std()
    -        score = (momentum - momentum.mean()) / (streuung + 1e-9)
    -
    -        # In eine taegliche Renditeerwartung uebersetzen:
    -        # Basis 4 % p.a. plus 6 % p.a. je Standardabweichung Momentum.
    -        mu_jaehrlich = np.maximum(0.04 + 0.06 * score, 0.0)
    -        return mu_jaehrlich / HANDELSTAGE
    -
    -    # -- 3./4. Risikomodell und Optimierung -------------------------------
    -    def optimiere(self, stichtag, w_alt, rueckblick=HANDELSTAGE):
    -        historie = self.renditen.loc[:stichtag].iloc[-rueckblick:]
    -        if len(historie) < 100:
    -            return w_alt, False
    -
    -        R = historie.values
    -        S, N = R.shape
    -        mu_taeglich = self.alpha_signal(stichtag, rueckblick)
    -
    -        w = cp.Variable(N, nonneg=True)
    -        gamma = cp.Variable()
    -        u = cp.Variable(S, nonneg=True)
    -
    -        cvar = gamma + (1.0 / (S * (1.0 - self.alpha))) * cp.sum(u)
    -        kosten = self.gebuehrensatz * cp.norm1(w - w_alt)
    -        ziel = cp.Maximize(mu_taeglich @ w - self.risikoaversion * cvar - kosten)
    -
    -        bedingungen = [cp.sum(w) == 1,
    -                       w <= self.max_gewicht,
    -                       u >= -(R @ w) - gamma]          # vektorisiert
    -        problem = cp.Problem(ziel, bedingungen)
    -        try:
    -            problem.solve(solver=cp.CLARABEL)
    -        except cp.error.SolverError:
    -            return w_alt, False
    -
    -        if problem.status not in ("optimal", "optimal_inaccurate") or w.value is None:
    -            return w_alt, False
    -
    -        gewichte = np.maximum(w.value, 0.0)
    -        return gewichte / gewichte.sum(), True
    -
    -    # -- 5. Backtest -------------------------------------------------------
    -    def backtest(self):
    -        print("Starte Walk-Forward-Backtest ...")
    -        N = len(self.universum)
    -        gewichte = np.ones(N) / N
    -
    -        werte = [self.startkapital]
    -        datumsliste = [self.renditen.index[self.warmup]]
    -        gewichtshistorie = []
    -        geplant = versucht = ausgefuehrt = 0
    -        kosten_gesamt = 0.0
    -        turnover_gesamt = 0.0
    -
    -        for t in range(self.warmup, len(self.renditen) - 1):
    -            heute = self.renditen.index[t]
    -            morgen = self.renditen.index[t + 1]
    -
    -            ist_termin = heute in self.rebalancing_termine or t == self.warmup
    -            if ist_termin:
    -                geplant += 1
    -                versucht += 1
    -                neue_gewichte, erfolg = self.optimiere(heute, gewichte)
    -                if erfolg:
    -                    ausgefuehrt += 1
    -                    turnover = float(np.abs(neue_gewichte - gewichte).sum())
    -                    gebuehr = werte[-1] * turnover * self.gebuehrensatz
    -                    werte[-1] -= gebuehr
    -                    kosten_gesamt += gebuehr
    -                    turnover_gesamt += turnover
    -                    gewichte = neue_gewichte
    -                    gewichtshistorie.append((heute, gewichte.copy()))
    -
    -            # Rendite von MORGEN auf die HEUTE festgelegten Gewichte anwenden
    -            tagesrenditen = self.renditen.iloc[t + 1].values
    -            portfoliorendite = float(gewichte @ tagesrenditen)
    -            werte.append(werte[-1] * (1.0 + portfoliorendite))
    -            datumsliste.append(morgen)
    -
    -            # Gewichte driften mit den Kursen weiter
    -            gedriftet = gewichte * (1.0 + tagesrenditen)
    -            gewichte = gedriftet / gedriftet.sum()
    -
    -        print(f"  Rebalancing: {geplant} geplant, {ausgefuehrt} ausgefuehrt "
    -              f"({ausgefuehrt/max(geplant,1)*100:.0f} %)")
    -        if ausgefuehrt < geplant:
    -            print(f"  WARNUNG: {geplant - ausgefuehrt} Optimierungen sind "
    -                  f"fehlgeschlagen - Ursache pruefen!")
    -        print(f"  Kumulierter Turnover: {turnover_gesamt*100:.0f} % | "
    -              f"Transaktionskosten insgesamt: {kosten_gesamt:,.2f} EUR")
    -
    -        ergebnis = pd.DataFrame({"Portfolio": werte}, index=datumsliste)
    -        benchmark = self.benchmark_kurse.loc[datumsliste]
    -        ergebnis["Benchmark"] = benchmark / benchmark.iloc[0] * self.startkapital
    -        return ergebnis, gewichtshistorie, kosten_gesamt
    -
    -    # -- 6. Auswertung -----------------------------------------------------
    -    @staticmethod
    -    def kennzahlen(reihe, risikofrei=0.02):
    -        renditen = reihe.pct_change().dropna()
    -        jahre = (reihe.index[-1] - reihe.index[0]).days / 365.25
    -        cagr = (reihe.iloc[-1] / reihe.iloc[0]) ** (1 / jahre) - 1
    -        vola = renditen.std() * np.sqrt(HANDELSTAGE)
    -        sharpe = (cagr - risikofrei) / vola if vola > 0 else np.nan
    -        drawdown = ((reihe - reihe.cummax()) / reihe.cummax()).min()
    -        calmar = cagr / abs(drawdown) if drawdown < 0 else np.nan
    -        trefferquote = float((renditen > 0).mean())
    -        return {"CAGR": cagr, "Volatilitaet": vola, "Sharpe": sharpe,
    -                "Max Drawdown": drawdown, "Calmar": calmar,
    -                "Trefferquote": trefferquote, "Jahre": jahre}
    -
    -    def bericht(self, ergebnis, kosten_gesamt):
    -        k_port = self.kennzahlen(ergebnis["Portfolio"])
    -        k_bench = self.kennzahlen(ergebnis["Benchmark"])
    -
    -        print("\n" + "=" * 84)
    -        print("          LEISTUNGSREPORT DER QUANTITATIVEN ENGINE")
    -        print("=" * 84)
    -        print(f"Zeitraum: {ergebnis.index[0]:%Y-%m-%d} bis {ergebnis.index[-1]:%Y-%m-%d} "
    -              f"({k_port['Jahre']:.1f} Jahre) | Startkapital "
    -              f"{self.startkapital:,.0f} EUR\n")
    -
    -        zeilen = [
    -            ("Endkapital", f"{ergebnis['Portfolio'].iloc[-1]:,.0f} EUR",
    -             f"{ergebnis['Benchmark'].iloc[-1]:,.0f} EUR"),
    -            ("CAGR (Jahresrendite)", f"{k_port['CAGR']*100:6.2f} %",
    -             f"{k_bench['CAGR']*100:6.2f} %"),
    -            ("Volatilitaet p.a.", f"{k_port['Volatilitaet']*100:6.2f} %",
    -             f"{k_bench['Volatilitaet']*100:6.2f} %"),
    -            ("Sharpe Ratio (rf=2 %)", f"{k_port['Sharpe']:6.2f}",
    -             f"{k_bench['Sharpe']:6.2f}"),
    -            ("Maximum Drawdown", f"{k_port['Max Drawdown']*100:6.2f} %",
    -             f"{k_bench['Max Drawdown']*100:6.2f} %"),
    -            ("Calmar Ratio", f"{k_port['Calmar']:6.2f}", f"{k_bench['Calmar']:6.2f}"),
    -            ("Trefferquote (Tage)", f"{k_port['Trefferquote']*100:6.1f} %",
    -             f"{k_bench['Trefferquote']*100:6.1f} %"),
    -        ]
    -        print(pd.DataFrame(zeilen, columns=["Kennzahl", "OR-Strategie", "Benchmark"])
    -              .to_string(index=False))
    -
    -        anteil = kosten_gesamt / self.startkapital
    -        print(f"\nTransaktionskosten: {kosten_gesamt:,.0f} EUR "
    -              f"= {anteil*100:.2f} % des Startkapitals "
    -              f"= {anteil/k_port['Jahre']*100:.2f} % p.a.")
    -        print("Ohne diese Kosten waere die ausgewiesene Rendite entsprechend hoeher -")
    -        print("genau deshalb gehoeren sie in den Backtest und nicht daneben.")
    -        print("=" * 84)
    -        return k_port, k_bench
    -
    -    def zeichne(self, ergebnis):
    -        plt.figure(figsize=(11, 6))
    -        plt.plot(ergebnis.index, ergebnis["Portfolio"], "b-", lw=2.2,
    -                 label="OR-Handelsmaschine")
    -        plt.plot(ergebnis.index, ergebnis["Benchmark"], "k--", lw=1.5, alpha=0.75,
    -                 label=f"Benchmark ({self.benchmark})")
    -        plt.title("Walk-Forward-Backtest: OR-Strategie gegen Benchmark", fontsize=12)
    -        plt.xlabel("Datum")
    -        plt.ylabel("Depotwert in EUR")
    -        plt.grid(True, linestyle=":", alpha=0.6)
    -        plt.legend(loc="upper left")
    -        plt.tight_layout()
    -        ziel = os.path.join(OUTPUT_DIR, "trading_engine_backtest.png")
    -        plt.savefig(ziel, dpi=150)
    -        print(f"Chart gespeichert unter '{ziel}'")
    -
    -
    -def lookahead_selbsttest(maschine):
    -    """
    -    Prueft, dass das Signal zum Zeitpunkt t NUR Daten bis t verwendet.
    -    Methode: kuenstlich die Zukunft veraendern und pruefen, ob sich das
    -    Signal von heute dadurch aendert. Tut es das, liegt Lookahead vor.
    -    """
    -    stichtag = maschine.kurse.index[len(maschine.kurse) // 2]
    -    signal_original = maschine.alpha_signal(stichtag)
    -
    -    original_kurse = maschine.kurse.copy()
    -    zukunft = maschine.kurse.index > stichtag
    -    maschine.kurse.loc[zukunft] *= 3.0            # Zukunft massiv veraendern
    -    signal_manipuliert = maschine.alpha_signal(stichtag)
    -    maschine.kurse = original_kurse               # zuruecksetzen
    -
    -    identisch = np.allclose(signal_original, signal_manipuliert)
    -    print(f"Lookahead-Selbsttest: Signal bleibt bei manipulierter Zukunft "
    -          f"{'UNVERAENDERT (gut)' if identisch else 'VERAENDERT - LOOKAHEAD-BIAS!'}")
    -    assert identisch, "Das Signal verwendet Zukunftsdaten!"
    -
    -
    -if __name__ == "__main__":
    -    UNIVERSUM = ["AAPL", "MSFT", "NVDA", "GOOGL",
    -                 "JNJ", "UNH", "PFE",
    -                 "JPM", "BAC", "GS",
    -                 "XOM", "CVX",
    -                 "PG", "KO", "COST"]
    -
    -    maschine = QuantitativeHandelsmaschine(UNIVERSUM, benchmark="SPY", jahre=5)
    -    maschine.lade_daten()
    -    lookahead_selbsttest(maschine)
    -    ergebnis, gewichte, kosten = maschine.backtest()
    -    maschine.bericht(ergebnis, kosten)
    -    maschine.zeichne(ergebnis)
    +
    #!/usr/bin/env python3
    +
    +# QuantitativeTradingEngine.py
    +"""
    +Kapitel Handelsmaschine: Vollstaendige quantitative Handelsmaschine mit
    +Walk-Forward-Backtest und OR-Optimierungsschicht.
    +
    +Eigenschaften:
    +  * Rebalancing-Termine sind garantiert Handelstage; die Zahl der
    +    tatsaechlich ausgefuehrten Umschichtungen wird ausgewiesen
    +  * Spaltenreihenfolge erzwungen
    +  * Einheiten konsistent (taegliche Groessen im Modell)
    +  * Szenario-Nebenbedingungen vektorisiert
    +  * Lookahead-Selbsttest eingebaut
    +  * Zusaetzliche Kennzahlen: Calmar, Trefferquote, Turnover, Kostenanteil
    +"""
    +
    +import os
    +
    +import cvxpy as cp
    +import numpy as np
    +import pandas as pd
    +import yfinance as yf
    +from sklearn.covariance import LedoitWolf
    +import matplotlib
    +matplotlib.use("Agg")
    +import matplotlib.pyplot as plt
    +
    +OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
    +os.makedirs(OUTPUT_DIR, exist_ok=True)
    +
    +HANDELSTAGE = 252
    +
    +
    +class QuantitativeHandelsmaschine:
    +    """Walk-Forward-Backtest mit CVaR-Optimierung und Transaktionskosten."""
    +
    +    def __init__(self, universum, benchmark="SPY", jahre=5,
    +                 startkapital=100_000.0, gebuehrensatz=0.0015,
    +                 max_gewicht=0.20, alpha=0.95, risikoaversion=1.2,
    +                 warmup=HANDELSTAGE):
    +        self.universum = list(universum)
    +        self.benchmark = benchmark
    +        self.jahre = jahre
    +        self.startkapital = startkapital
    +        self.gebuehrensatz = gebuehrensatz
    +        self.max_gewicht = max_gewicht
    +        self.alpha = alpha
    +        self.risikoaversion = risikoaversion
    +        self.warmup = warmup
    +
    +        self.kurse = None
    +        self.benchmark_kurse = None
    +        self.renditen = None
    +        self.rebalancing_termine = None
    +
    +    # -- 1. Datenpipeline ------------------------------------------------
    +    def lade_daten(self):
    +        ende = pd.Timestamp.today().normalize()
    +        start = ende - pd.DateOffset(years=self.jahre)
    +        alle = self.universum + [self.benchmark]
    +
    +        print(f"Lade {len(self.universum)} Titel + Benchmark ({self.benchmark}) "
    +              f"ueber {self.jahre} Jahre ...")
    +        roh = yf.download(alle, start=start, end=ende, auto_adjust=True, progress=False)
    +        if roh.empty:
    +            raise SystemExit("Download fehlgeschlagen (Netz/Ticker/Rate-Limit pruefen).")
    +
    +        if isinstance(roh.columns, pd.MultiIndex):
    +            daten = roh["Close"][alle].dropna()      # erzwingt eigene Spaltenreihenfolge
    +        else:
    +            daten = roh[["Close"]].dropna()
    +            daten.columns = alle
    +        assert list(daten.columns) == alle, "Spaltenreihenfolge weicht ab!"
    +
    +        self.kurse = daten[self.universum]
    +        self.benchmark_kurse = daten[self.benchmark]
    +        self.renditen = self.kurse.pct_change().dropna()
    +
    +        # --- erster HANDELStag je Monat, nicht Kalendermonatsanfang -----
    +        monat = self.kurse.index.to_period("M")
    +        self.rebalancing_termine = self.kurse.index[~monat.duplicated()]
    +        assert self.rebalancing_termine.isin(self.kurse.index).all(), \
    +            "Rebalancing-Termine sind keine Handelstage!"
    +
    +        print(f"  {len(self.kurse)} Handelstage, "
    +              f"{len(self.rebalancing_termine)} Rebalancing-Termine "
    +              f"(alle sind Handelstage)")
    +
    +    # -- 2. Signal --------------------------------------------------------
    +    def alpha_signal(self, stichtag, rueckblick=HANDELSTAGE):
    +        """
    +        12-1-Momentum: Kursentwicklung der letzten 12 Monate unter Auslassung
    +        des letzten Monats (kurzfristige Umkehr herausrechnen).
    +        Rueckgabe: TAEGLICHE Renditeerwartung.
    +        """
    +        historie = self.kurse.loc[:stichtag]
    +        if len(historie) < rueckblick:
    +            return np.zeros(len(self.universum))
    +
    +        p_alt = historie.iloc[-rueckblick]
    +        p_neu = historie.iloc[-21]                    # vor ca. einem Monat
    +        momentum = (p_neu / p_alt - 1.0).values
    +
    +        # Z-Score, damit das Signal skalenfrei wird
    +        streuung = momentum.std()
    +        score = (momentum - momentum.mean()) / (streuung + 1e-9)
    +
    +        # In eine taegliche Renditeerwartung uebersetzen:
    +        # Basis 4 % p.a. plus 6 % p.a. je Standardabweichung Momentum.
    +        mu_jaehrlich = np.maximum(0.04 + 0.06 * score, 0.0)
    +        return mu_jaehrlich / HANDELSTAGE
    +
    +    # -- 3./4. Risikomodell und Optimierung -------------------------------
    +    def optimiere(self, stichtag, w_alt, rueckblick=HANDELSTAGE):
    +        historie = self.renditen.loc[:stichtag].iloc[-rueckblick:]
    +        if len(historie) < 100:
    +            return w_alt, False
    +
    +        R = historie.values
    +        S, N = R.shape
    +        mu_taeglich = self.alpha_signal(stichtag, rueckblick)
    +
    +        w = cp.Variable(N, nonneg=True)
    +        gamma = cp.Variable()
    +        u = cp.Variable(S, nonneg=True)
    +
    +        cvar = gamma + (1.0 / (S * (1.0 - self.alpha))) * cp.sum(u)
    +        kosten = self.gebuehrensatz * cp.norm1(w - w_alt)
    +        ziel = cp.Maximize(mu_taeglich @ w - self.risikoaversion * cvar - kosten)
    +
    +        bedingungen = [cp.sum(w) == 1,
    +                       w <= self.max_gewicht,
    +                       u >= -(R @ w) - gamma]          # vektorisiert
    +        problem = cp.Problem(ziel, bedingungen)
    +        try:
    +            problem.solve(solver=cp.CLARABEL)
    +        except cp.error.SolverError:
    +            return w_alt, False
    +
    +        if problem.status not in ("optimal", "optimal_inaccurate") or w.value is None:
    +            return w_alt, False
    +
    +        gewichte = np.maximum(w.value, 0.0)
    +        return gewichte / gewichte.sum(), True
    +
    +    # -- 5. Backtest -------------------------------------------------------
    +    def backtest(self):
    +        print("Starte Walk-Forward-Backtest ...")
    +        N = len(self.universum)
    +        gewichte = np.ones(N) / N
    +
    +        werte = [self.startkapital]
    +        datumsliste = [self.renditen.index[self.warmup]]
    +        gewichtshistorie = []
    +        geplant = versucht = ausgefuehrt = 0
    +        kosten_gesamt = 0.0
    +        turnover_gesamt = 0.0
    +
    +        for t in range(self.warmup, len(self.renditen) - 1):
    +            heute = self.renditen.index[t]
    +            morgen = self.renditen.index[t + 1]
    +
    +            ist_termin = heute in self.rebalancing_termine or t == self.warmup
    +            if ist_termin:
    +                geplant += 1
    +                versucht += 1
    +                neue_gewichte, erfolg = self.optimiere(heute, gewichte)
    +                if erfolg:
    +                    ausgefuehrt += 1
    +                    turnover = float(np.abs(neue_gewichte - gewichte).sum())
    +                    gebuehr = werte[-1] * turnover * self.gebuehrensatz
    +                    werte[-1] -= gebuehr
    +                    kosten_gesamt += gebuehr
    +                    turnover_gesamt += turnover
    +                    gewichte = neue_gewichte
    +                    gewichtshistorie.append((heute, gewichte.copy()))
    +
    +            # Rendite von MORGEN auf die HEUTE festgelegten Gewichte anwenden
    +            tagesrenditen = self.renditen.iloc[t + 1].values
    +            portfoliorendite = float(gewichte @ tagesrenditen)
    +            werte.append(werte[-1] * (1.0 + portfoliorendite))
    +            datumsliste.append(morgen)
    +
    +            # Gewichte driften mit den Kursen weiter
    +            gedriftet = gewichte * (1.0 + tagesrenditen)
    +            gewichte = gedriftet / gedriftet.sum()
    +
    +        print(f"  Rebalancing: {geplant} geplant, {ausgefuehrt} ausgefuehrt "
    +              f"({ausgefuehrt/max(geplant,1)*100:.0f} %)")
    +        if ausgefuehrt < geplant:
    +            print(f"  WARNUNG: {geplant - ausgefuehrt} Optimierungen sind "
    +                  f"fehlgeschlagen - Ursache pruefen!")
    +        print(f"  Kumulierter Turnover: {turnover_gesamt*100:.0f} % | "
    +              f"Transaktionskosten insgesamt: {kosten_gesamt:,.2f} EUR")
    +
    +        ergebnis = pd.DataFrame({"Portfolio": werte}, index=datumsliste)
    +        benchmark = self.benchmark_kurse.loc[datumsliste]
    +        ergebnis["Benchmark"] = benchmark / benchmark.iloc[0] * self.startkapital
    +        return ergebnis, gewichtshistorie, kosten_gesamt
    +
    +    # -- 6. Auswertung -----------------------------------------------------
    +    @staticmethod
    +    def kennzahlen(reihe, risikofrei=0.02):
    +        renditen = reihe.pct_change().dropna()
    +        jahre = (reihe.index[-1] - reihe.index[0]).days / 365.25
    +        cagr = (reihe.iloc[-1] / reihe.iloc[0]) ** (1 / jahre) - 1
    +        vola = renditen.std() * np.sqrt(HANDELSTAGE)
    +        sharpe = (cagr - risikofrei) / vola if vola > 0 else np.nan
    +        drawdown = ((reihe - reihe.cummax()) / reihe.cummax()).min()
    +        calmar = cagr / abs(drawdown) if drawdown < 0 else np.nan
    +        trefferquote = float((renditen > 0).mean())
    +        return {"CAGR": cagr, "Volatilitaet": vola, "Sharpe": sharpe,
    +                "Max Drawdown": drawdown, "Calmar": calmar,
    +                "Trefferquote": trefferquote, "Jahre": jahre}
    +
    +    def bericht(self, ergebnis, kosten_gesamt):
    +        k_port = self.kennzahlen(ergebnis["Portfolio"])
    +        k_bench = self.kennzahlen(ergebnis["Benchmark"])
    +
    +        print("\n" + "=" * 84)
    +        print("          LEISTUNGSREPORT DER QUANTITATIVEN ENGINE")
    +        print("=" * 84)
    +        print(f"Zeitraum: {ergebnis.index[0]:%Y-%m-%d} bis {ergebnis.index[-1]:%Y-%m-%d} "
    +              f"({k_port['Jahre']:.1f} Jahre) | Startkapital "
    +              f"{self.startkapital:,.0f} EUR\n")
    +
    +        zeilen = [
    +            ("Endkapital", f"{ergebnis['Portfolio'].iloc[-1]:,.0f} EUR",
    +             f"{ergebnis['Benchmark'].iloc[-1]:,.0f} EUR"),
    +            ("CAGR (Jahresrendite)", f"{k_port['CAGR']*100:6.2f} %",
    +             f"{k_bench['CAGR']*100:6.2f} %"),
    +            ("Volatilitaet p.a.", f"{k_port['Volatilitaet']*100:6.2f} %",
    +             f"{k_bench['Volatilitaet']*100:6.2f} %"),
    +            ("Sharpe Ratio (rf=2 %)", f"{k_port['Sharpe']:6.2f}",
    +             f"{k_bench['Sharpe']:6.2f}"),
    +            ("Maximum Drawdown", f"{k_port['Max Drawdown']*100:6.2f} %",
    +             f"{k_bench['Max Drawdown']*100:6.2f} %"),
    +            ("Calmar Ratio", f"{k_port['Calmar']:6.2f}", f"{k_bench['Calmar']:6.2f}"),
    +            ("Trefferquote (Tage)", f"{k_port['Trefferquote']*100:6.1f} %",
    +             f"{k_bench['Trefferquote']*100:6.1f} %"),
    +        ]
    +        print(pd.DataFrame(zeilen, columns=["Kennzahl", "OR-Strategie", "Benchmark"])
    +              .to_string(index=False))
    +
    +        anteil = kosten_gesamt / self.startkapital
    +        print(f"\nTransaktionskosten: {kosten_gesamt:,.0f} EUR "
    +              f"= {anteil*100:.2f} % des Startkapitals "
    +              f"= {anteil/k_port['Jahre']*100:.2f} % p.a.")
    +        print("Ohne diese Kosten waere die ausgewiesene Rendite entsprechend hoeher -")
    +        print("genau deshalb gehoeren sie in den Backtest und nicht daneben.")
    +        print("=" * 84)
    +        return k_port, k_bench
    +
    +    def zeichne(self, ergebnis):
    +        plt.figure(figsize=(11, 6))
    +        plt.plot(ergebnis.index, ergebnis["Portfolio"], "b-", lw=2.2,
    +                 label="OR-Handelsmaschine")
    +        plt.plot(ergebnis.index, ergebnis["Benchmark"], "k--", lw=1.5, alpha=0.75,
    +                 label=f"Benchmark ({self.benchmark})")
    +        plt.title("Walk-Forward-Backtest: OR-Strategie gegen Benchmark", fontsize=12)
    +        plt.xlabel("Datum")
    +        plt.ylabel("Depotwert in EUR")
    +        plt.grid(True, linestyle=":", alpha=0.6)
    +        plt.legend(loc="upper left")
    +        plt.tight_layout()
    +        ziel = os.path.join(OUTPUT_DIR, "trading_engine_backtest.png")
    +        plt.savefig(ziel, dpi=150)
    +        print(f"Chart gespeichert unter '{ziel}'")
    +
    +
    +def lookahead_selbsttest(maschine):
    +    """
    +    Prueft, dass das Signal zum Zeitpunkt t NUR Daten bis t verwendet.
    +    Methode: kuenstlich die Zukunft veraendern und pruefen, ob sich das
    +    Signal von heute dadurch aendert. Tut es das, liegt Lookahead vor.
    +    """
    +    stichtag = maschine.kurse.index[len(maschine.kurse) // 2]
    +    signal_original = maschine.alpha_signal(stichtag)
    +
    +    original_kurse = maschine.kurse.copy()
    +    zukunft = maschine.kurse.index > stichtag
    +    maschine.kurse.loc[zukunft] *= 3.0            # Zukunft massiv veraendern
    +    signal_manipuliert = maschine.alpha_signal(stichtag)
    +    maschine.kurse = original_kurse               # zuruecksetzen
    +
    +    identisch = np.allclose(signal_original, signal_manipuliert)
    +    print(f"Lookahead-Selbsttest: Signal bleibt bei manipulierter Zukunft "
    +          f"{'UNVERAENDERT (gut)' if identisch else 'VERAENDERT - LOOKAHEAD-BIAS!'}")
    +    assert identisch, "Das Signal verwendet Zukunftsdaten!"
    +
    +
    +if __name__ == "__main__":
    +    UNIVERSUM = ["AAPL", "MSFT", "NVDA", "GOOGL",
    +                 "JNJ", "UNH", "PFE",
    +                 "JPM", "BAC", "GS",
    +                 "XOM", "CVX",
    +                 "PG", "KO", "COST"]
    +
    +    maschine = QuantitativeHandelsmaschine(UNIVERSUM, benchmark="SPY", jahre=5)
    +    maschine.lade_daten()
    +    lookahead_selbsttest(maschine)
    +    ergebnis, gewichte, kosten = maschine.backtest()
    +    maschine.bericht(ergebnis, kosten)
    +    maschine.zeichne(ergebnis)

    💻 Code-Durchgang: die vier kritischen Stellen

    1. Rebalancing-Kalender.

    -
    monat = self.kurse.index.to_period("M")
    -self.rebalancing_termine = self.kurse.index[~monat.duplicated()]
    +
    monat = self.kurse.index.to_period("M")
    +self.rebalancing_termine = self.kurse.index[~monat.duplicated()]

    to_period("M") bildet jeden Handelstag auf seinen Monat ab; ~duplicated() behält jeweils den ersten — also garantiert einen realen Handelstag.

    2. Die Zeitachse im Backtest.

    -
    tagesrenditen = self.renditen.iloc[t + 1].values      # MORGEN
    -portfoliorendite = gewichte @ tagesrenditen            # HEUTE festgelegt
    +
    tagesrenditen = self.renditen.iloc[t + 1].values      # MORGEN
    +portfoliorendite = gewichte @ tagesrenditen            # HEUTE festgelegt

    Die Gewichte werden am Tag t aus Daten bis t bestimmt und auf die Rendite von t+1 angewendet. Diese eine Indexverschiebung ist der Unterschied zwischen einem ehrlichen und einem geschönten Backtest.

    3. Der Lookahead-Selbsttest. Er verdreifacht künstlich alle Kurse nach dem Stichtag und prüft, ob sich das Signal von vor dem Stichtag dadurch ändert. Ändert es sich, greift der Code auf die Zukunft zu. Dieser Test kostet zehn Zeilen und findet eine Fehlerklasse, die sonst praktisch unsichtbar bleibt. Übernehmen Sie ihn in jedes Backtest-Projekt.

    4. Gewichtsdrift.

    -
    gedriftet = gewichte * (1.0 + tagesrenditen)
    -gewichte = gedriftet / gedriftet.sum()
    +
    gedriftet = gewichte * (1.0 + tagesrenditen)
    +gewichte = gedriftet / gedriftet.sum()

    Zwischen zwei Rebalancings verändern sich die Gewichte von selbst — gestiegene Titel bekommen mehr Gewicht. Wer das weglässt, unterstellt implizit tägliches kostenloses Rebalancing und überschätzt die Strategie.

    @@ -24182,102 +24498,102 @@ geballt Jahresrendite 8.88 % Sharpe 0.61 max. Rueckschlag -98.7 %

    21.6 Die fünf Selbsttäuschungen des Backtestens

    -
    #!/usr/bin/env python3
    -
    -# Backtest_Fallen.py
    -"""
    -Kapitel Handelsmaschine: Wie leicht man sich selbst betruegt.
    -
    -Demonstriert an SIMULIERTEN Daten (also ohne jede echte Prognosekraft),
    -wie gross die scheinbare Ueberrendite durch typische Backtest-Fehler wird.
    -Weil die Daten reines Rauschen sind, ist JEDE positive Ueberrendite ein Artefakt.
    -"""
    -
    -import numpy as np
    -import pandas as pd
    -
    -
    -def erzeuge_zufallsmarkt(tage=1260, titel=15, seed=7):
    -    """Reines Rauschen: kein Titel hat echte Prognosekraft."""
    -    rng = np.random.default_rng(seed)
    -    renditen = rng.normal(0.0003, 0.015, size=(tage, titel))
    -    index = pd.bdate_range("2020-01-01", periods=tage)
    -    return pd.DataFrame(renditen, index=index,
    -                        columns=[f"T{i:02d}" for i in range(titel)])
    -
    -
    -def strategie(renditen, lookahead=False, survivorship=False,
    -              ohne_kosten=False, gebuehr=0.0015):
    -    """Einfache Momentum-Strategie mit optional eingebauten Fehlern."""
    -    fenster = 60
    -    kapital = [100.0]
    -    gewichte = np.ones(renditen.shape[1]) / renditen.shape[1]
    -
    -    daten = renditen
    -    if survivorship:
    -        # FEHLER: nur die im Nachhinein besten Titel ins Universum lassen
    -        beste = renditen.sum().nlargest(renditen.shape[1] // 2).index
    -        daten = renditen[beste]
    -        gewichte = np.ones(len(beste)) / len(beste)
    -
    -    for t in range(fenster, len(daten) - 1):
    -        if t % 21 == 0:                                  # monatlich
    -            if lookahead:
    -                # FEHLER: Signal nutzt die Rendite von MORGEN
    -                signal = daten.iloc[t + 1].values
    -            else:
    -                signal = daten.iloc[t - fenster:t].mean().values
    -
    -            neue = (signal > np.median(signal)).astype(float)
    -            neue = neue / neue.sum() if neue.sum() > 0 else gewichte
    -            if not ohne_kosten:
    -                kapital[-1] *= (1 - np.abs(neue - gewichte).sum() * gebuehr)
    -            gewichte = neue
    -
    -        kapital.append(kapital[-1] * (1 + float(gewichte @ daten.iloc[t + 1].values)))
    -    return np.array(kapital)
    -
    -
    -def cagr(verlauf, jahre):
    -    return (verlauf[-1] / verlauf[0]) ** (1 / jahre) - 1
    -
    -
    -if __name__ == "__main__":
    -    renditen = erzeuge_zufallsmarkt()
    -    jahre = len(renditen) / 252
    -    markt = 100 * (1 + renditen.mean(axis=1)).cumprod().values
    -
    -    print("=" * 84)
    -    print("  DIE FUENF SELBSTTAEUSCHUNGEN - GEMESSEN AN REINEM RAUSCHEN")
    -    print("=" * 84)
    -    print("Die Daten sind zufaellig erzeugt. Es gibt KEINE echte Prognosekraft.")
    -    print("Jede Ueberrendite unten ist daher ein reines Artefakt.\n")
    -    print(f"{'Variante':<44} {'CAGR':>9} {'ggue. Markt':>13}")
    -    print("-" * 84)
    -
    -    basis = cagr(markt, jahre)
    -    print(f"{'Markt (Gleichgewichtung, Referenz)':<44} {basis*100:>8.2f} % "
    -          f"{0.0:>12.2f} %")
    -
    -    varianten = [
    -        ("Ehrlich: Signal aus Vergangenheit, mit Kosten", {}),
    -        ("FEHLER 1: Lookahead (Signal kennt morgen)", {"lookahead": True}),
    -        ("FEHLER 2: Survivorship (nur Gewinner im Universum)", {"survivorship": True}),
    -        ("FEHLER 3: Transaktionskosten ignoriert", {"ohne_kosten": True}),
    -        ("FEHLER 1+2+3 kombiniert", {"lookahead": True, "survivorship": True,
    -                                     "ohne_kosten": True}),
    -    ]
    -    for name, argumente in varianten:
    -        verlauf = strategie(renditen, **argumente)
    -        wert = cagr(verlauf, jahre)
    -        print(f"{name:<44} {wert*100:>8.2f} % {(wert-basis)*100:>+12.2f} %")
    -
    -    print("-" * 84)
    -    print("FEHLER 4 (Overfitting) und FEHLER 5 (Data Snooping) lassen sich so nicht")
    -    print("zeigen - sie entstehen erst durch WIEDERHOLTES Probieren. Faustregel:")
    -    print("Wer 20 Strategien testet, findet auch in reinem Rauschen eine mit")
    -    print("Signifikanz auf dem 5-%-Niveau. Genau das ist der Punkt.")
    -    print("=" * 84)
    +
    #!/usr/bin/env python3
    +
    +# Backtest_Fallen.py
    +"""
    +Kapitel Handelsmaschine: Wie leicht man sich selbst betruegt.
    +
    +Demonstriert an SIMULIERTEN Daten (also ohne jede echte Prognosekraft),
    +wie gross die scheinbare Ueberrendite durch typische Backtest-Fehler wird.
    +Weil die Daten reines Rauschen sind, ist JEDE positive Ueberrendite ein Artefakt.
    +"""
    +
    +import numpy as np
    +import pandas as pd
    +
    +
    +def erzeuge_zufallsmarkt(tage=1260, titel=15, seed=7):
    +    """Reines Rauschen: kein Titel hat echte Prognosekraft."""
    +    rng = np.random.default_rng(seed)
    +    renditen = rng.normal(0.0003, 0.015, size=(tage, titel))
    +    index = pd.bdate_range("2020-01-01", periods=tage)
    +    return pd.DataFrame(renditen, index=index,
    +                        columns=[f"T{i:02d}" for i in range(titel)])
    +
    +
    +def strategie(renditen, lookahead=False, survivorship=False,
    +              ohne_kosten=False, gebuehr=0.0015):
    +    """Einfache Momentum-Strategie mit optional eingebauten Fehlern."""
    +    fenster = 60
    +    kapital = [100.0]
    +    gewichte = np.ones(renditen.shape[1]) / renditen.shape[1]
    +
    +    daten = renditen
    +    if survivorship:
    +        # FEHLER: nur die im Nachhinein besten Titel ins Universum lassen
    +        beste = renditen.sum().nlargest(renditen.shape[1] // 2).index
    +        daten = renditen[beste]
    +        gewichte = np.ones(len(beste)) / len(beste)
    +
    +    for t in range(fenster, len(daten) - 1):
    +        if t % 21 == 0:                                  # monatlich
    +            if lookahead:
    +                # FEHLER: Signal nutzt die Rendite von MORGEN
    +                signal = daten.iloc[t + 1].values
    +            else:
    +                signal = daten.iloc[t - fenster:t].mean().values
    +
    +            neue = (signal > np.median(signal)).astype(float)
    +            neue = neue / neue.sum() if neue.sum() > 0 else gewichte
    +            if not ohne_kosten:
    +                kapital[-1] *= (1 - np.abs(neue - gewichte).sum() * gebuehr)
    +            gewichte = neue
    +
    +        kapital.append(kapital[-1] * (1 + float(gewichte @ daten.iloc[t + 1].values)))
    +    return np.array(kapital)
    +
    +
    +def cagr(verlauf, jahre):
    +    return (verlauf[-1] / verlauf[0]) ** (1 / jahre) - 1
    +
    +
    +if __name__ == "__main__":
    +    renditen = erzeuge_zufallsmarkt()
    +    jahre = len(renditen) / 252
    +    markt = 100 * (1 + renditen.mean(axis=1)).cumprod().values
    +
    +    print("=" * 84)
    +    print("  DIE FUENF SELBSTTAEUSCHUNGEN - GEMESSEN AN REINEM RAUSCHEN")
    +    print("=" * 84)
    +    print("Die Daten sind zufaellig erzeugt. Es gibt KEINE echte Prognosekraft.")
    +    print("Jede Ueberrendite unten ist daher ein reines Artefakt.\n")
    +    print(f"{'Variante':<44} {'CAGR':>9} {'ggue. Markt':>13}")
    +    print("-" * 84)
    +
    +    basis = cagr(markt, jahre)
    +    print(f"{'Markt (Gleichgewichtung, Referenz)':<44} {basis*100:>8.2f} % "
    +          f"{0.0:>12.2f} %")
    +
    +    varianten = [
    +        ("Ehrlich: Signal aus Vergangenheit, mit Kosten", {}),
    +        ("FEHLER 1: Lookahead (Signal kennt morgen)", {"lookahead": True}),
    +        ("FEHLER 2: Survivorship (nur Gewinner im Universum)", {"survivorship": True}),
    +        ("FEHLER 3: Transaktionskosten ignoriert", {"ohne_kosten": True}),
    +        ("FEHLER 1+2+3 kombiniert", {"lookahead": True, "survivorship": True,
    +                                     "ohne_kosten": True}),
    +    ]
    +    for name, argumente in varianten:
    +        verlauf = strategie(renditen, **argumente)
    +        wert = cagr(verlauf, jahre)
    +        print(f"{name:<44} {wert*100:>8.2f} % {(wert-basis)*100:>+12.2f} %")
    +
    +    print("-" * 84)
    +    print("FEHLER 4 (Overfitting) und FEHLER 5 (Data Snooping) lassen sich so nicht")
    +    print("zeigen - sie entstehen erst durch WIEDERHOLTES Probieren. Faustregel:")
    +    print("Wer 20 Strategien testet, findet auch in reinem Rauschen eine mit")
    +    print("Signifikanz auf dem 5-%-Niveau. Genau das ist der Punkt.")
    +    print("=" * 84)

    Erwartete Ausgabe:

    ====================================================================================
       DIE FUENF SELBSTTAEUSCHUNGEN - GEMESSEN AN REINEM RAUSCHEN
    @@ -24423,139 +24739,139 @@ FEHLER 1+2+3 kombiniert                          25.41 %       +19.80 %
     

    Auflösung: Abschnitt A.21.

    Das folgende Programm macht den Effekt messbar. Es testet Strategien, die nachweislich keinerlei Vorteil haben — ihre Tagesergebnisse sind Zufallszahlen mit Erwartungswert exakt null — und zeigt, wie gut die jeweils beste davon aussieht, allein als Funktion der Anzahl der Versuche.

    -
    #!/usr/bin/env python3
    -
    -# Data_Snooping.py
    -"""
    -Kapitel Handelsmaschine: Die Selbsttaeuschung, die man einem Backtest nicht ansieht.
    -
    -Backtest_Fallen.py zeigt drei Fehler, die IM Backtest stecken: Lookahead,
    -Survivorship, vergessene Kosten. Alle drei kann man einem Programm ansehen,
    -wenn man es liest.
    -
    -Die beiden anderen - Overfitting und Data Snooping - entstehen nicht im
    -Backtest, sondern in der ARBEITSWEISE davor. Sie hinterlassen im Code keine
    -Spur. Ein Backtest, der zwanzigmal angepasst wurde, sieht am Ende genauso
    -sauber aus wie einer, der beim ersten Versuch funktioniert hat.
    -
    -Dieses Programm macht den Effekt messbar. Es testet Strategien, die
    -NACHWEISLICH keinerlei Prognosekraft haben (ihre taeglichen Ergebnisse sind
    -Zufallszahlen mit Erwartungswert exakt null), und zeigt, wie gut die jeweils
    -BESTE davon aussieht - allein als Funktion der Anzahl der Versuche.
    -
    -Benoetigt: numpy, scipy
    -"""
    -
    -from __future__ import annotations
    -
    -import numpy as np
    -from scipy import stats
    -
    -HANDELSTAGE = 1260               # fuenf Jahre
    -TAGESSCHWANKUNG = 0.010          # 1 % taeglich
    -WIEDERHOLUNGEN = 300             # damit die Tabelle nicht vom Zufall abhaengt
    -NIVEAU = 0.05
    -
    -RNG = np.random.default_rng(42)
    -
    -
    -def bewerte(ergebnisse: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    -    """Sharpe-Kennzahl und einseitiger p-Wert je Strategie.
    -
    -    ergebnisse hat die Form (Strategien, Handelstage). Der p-Wert prueft die
    -    Nullhypothese 'mittleres Tagesergebnis ist null' - also genau die Frage,
    -    die ein Backtest beantworten soll.
    -    """
    -    mittel = ergebnisse.mean(axis=1)
    -    streuung = ergebnisse.std(axis=1, ddof=1)
    -    sharpe = mittel / streuung * np.sqrt(252)
    -    t_wert = mittel / (streuung / np.sqrt(ergebnisse.shape[1]))
    -    return sharpe, stats.t.sf(t_wert, ergebnisse.shape[1] - 1)
    -
    -
    -def probiere(anzahl_strategien: int) -> tuple[float, float, float, float]:
    -    """Liefert (beste Sharpe, ihr p-Wert, Anteil scheinbar signifikanter,
    -    Anteil, der auch die Bonferroni-Huerde nimmt) - gemittelt ueber
    -    WIEDERHOLUNGEN unabhaengige Durchgaenge."""
    -    beste_sharpe, beste_p, anteil_signifikant, ueberlebt_bonferroni = [], [], [], []
    -
    -    for _ in range(WIEDERHOLUNGEN):
    -        # Der springende Punkt: Erwartungswert exakt 0. Keine dieser
    -        # Strategien hat einen echten Vorteil - per Konstruktion.
    -        ergebnisse = RNG.normal(0.0, TAGESSCHWANKUNG,
    -                                (anzahl_strategien, HANDELSTAGE))
    -        sharpe, p_werte = bewerte(ergebnisse)
    -
    -        beste = int(sharpe.argmax())
    -        beste_sharpe.append(float(sharpe[beste]))
    -        beste_p.append(float(p_werte[beste]))
    -        anteil_signifikant.append(float((p_werte < NIVEAU).mean()))
    -        # Bonferroni: Wer n Tests macht, muss die Huerde durch n teilen.
    -        ueberlebt_bonferroni.append(
    -            float((p_werte < NIVEAU / anzahl_strategien).mean()))
    -
    -    return (float(np.mean(beste_sharpe)), float(np.mean(beste_p)),
    -            float(np.mean(anteil_signifikant)),
    -            float(np.mean(ueberlebt_bonferroni)))
    -
    -
    -if __name__ == "__main__":
    -    print("=" * 78)
    -    print("  WIE GUT SIEHT DIE BESTE VON N STRATEGIEN AUS,")
    -    print("  WENN KEINE EINZIGE ETWAS TAUGT?")
    -    print("=" * 78)
    -    print(f"{HANDELSTAGE} Handelstage, taegliche Ergebnisse mit Erwartungswert "
    -          f"EXAKT null.")
    -    print(f"Jede Zeile ist der Mittelwert aus {WIEDERHOLUNGEN} unabhaengigen "
    -          f"Durchgaengen.\n")
    -
    -    print(f"{'getestet':>9} {'beste Sharpe':>14} {'ihr p-Wert':>12} "
    -          f"{'scheinbar sign.':>16}")
    -    print("-" * 78)
    -    ergebnisse = {}
    -    for anzahl in (1, 10, 100, 1000):
    -        sharpe, p_wert, anteil, _ = probiere(anzahl)
    -        ergebnisse[anzahl] = (sharpe, p_wert, anteil)
    -        print(f"{anzahl:9d} {sharpe:14.2f} {p_wert:12.4f} {anteil * 100:15.1f} %")
    -    print("-" * 78)
    -
    -    sharpe_100, p_100, _ = ergebnisse[100]
    -    sharpe_1000, p_1000, _ = ergebnisse[1000]
    -
    -    print("\nLesen Sie die dritte Spalte von unten nach oben:")
    -    print(f"  Wer {1000} Varianten durchprobiert, findet eine mit p = {p_1000:.4f} -")
    -    print("  ein Wert, den jede Zeitschrift als 'hochsignifikant' druckt.")
    -    print(f"  Wer {100} probiert, findet p = {p_100:.4f} und eine Sharpe-Kennzahl")
    -    print(f"  von {sharpe_100:.2f} - damit geht man zum Vorgesetzten.")
    -    print("  Und keine dieser Strategien hat auch nur den Hauch eines Vorteils.")
    -    print()
    -    print("Die LETZTE Spalte bleibt dagegen konstant bei rund 5 %. Das ist kein")
    -    print("Widerspruch, sondern die Definition: Ein Test zum 5-%-Niveau irrt in")
    -    print("5 % der Faelle. Was sich aendert, ist nicht der Anteil, sondern die")
    -    print("ANZAHL - und weil man nur die beste Strategie praesentiert, sieht man")
    -    print("von allen Fehlversuchen genau einen: den erfolgreichsten.")
    -
    -    print("\n" + "=" * 78)
    -    print("  WAS MAN DAGEGEN TUN KANN")
    -    print("=" * 78)
    -    print("1. ZAEHLEN. Notieren Sie, wie viele Varianten Sie ausprobiert haben -")
    -    print("   auch die verworfenen, auch die 'nur mal schnell'. Ohne diese Zahl")
    -    print("   ist kein p-Wert und keine Sharpe-Kennzahl interpretierbar.")
    -    print()
    -    print("2. KORRIGIEREN. Die Bonferroni-Korrektur teilt die Huerde durch die")
    -    print("   Zahl der Versuche:")
    -    for anzahl in (10, 100, 1000):
    -        print(f"     bei {anzahl:5d} Versuchen: p < {NIVEAU / anzahl:.5f} statt "
    -              f"p < {NIVEAU:.2f}")
    -    print("   Sie ist konservativ, aber sie ist ehrlich. Fuer Handelsstrategien")
    -    print("   gibt es verfeinerte Varianten (Deflated Sharpe Ratio nach Bailey")
    -    print("   und Lopez de Prado), die dasselbe Prinzip verfolgen.")
    -    print()
    -    print("3. ZURUECKHALTEN. Legen Sie einen Zeitraum beiseite, BEVOR Sie")
    -    print("   anfangen, und ruehren Sie ihn bis zum Schluss nicht an. Er ist")
    -    print("   der einzige Test, den Sie nicht durch Probieren verderben koennen -")
    -    print("   und er ist genau einmal verwendbar.")
    -    print("=" * 78)
    +
    #!/usr/bin/env python3
    +
    +# Data_Snooping.py
    +"""
    +Kapitel Handelsmaschine: Die Selbsttaeuschung, die man einem Backtest nicht ansieht.
    +
    +Backtest_Fallen.py zeigt drei Fehler, die IM Backtest stecken: Lookahead,
    +Survivorship, vergessene Kosten. Alle drei kann man einem Programm ansehen,
    +wenn man es liest.
    +
    +Die beiden anderen - Overfitting und Data Snooping - entstehen nicht im
    +Backtest, sondern in der ARBEITSWEISE davor. Sie hinterlassen im Code keine
    +Spur. Ein Backtest, der zwanzigmal angepasst wurde, sieht am Ende genauso
    +sauber aus wie einer, der beim ersten Versuch funktioniert hat.
    +
    +Dieses Programm macht den Effekt messbar. Es testet Strategien, die
    +NACHWEISLICH keinerlei Prognosekraft haben (ihre taeglichen Ergebnisse sind
    +Zufallszahlen mit Erwartungswert exakt null), und zeigt, wie gut die jeweils
    +BESTE davon aussieht - allein als Funktion der Anzahl der Versuche.
    +
    +Benoetigt: numpy, scipy
    +"""
    +
    +from __future__ import annotations
    +
    +import numpy as np
    +from scipy import stats
    +
    +HANDELSTAGE = 1260               # fuenf Jahre
    +TAGESSCHWANKUNG = 0.010          # 1 % taeglich
    +WIEDERHOLUNGEN = 300             # damit die Tabelle nicht vom Zufall abhaengt
    +NIVEAU = 0.05
    +
    +RNG = np.random.default_rng(42)
    +
    +
    +def bewerte(ergebnisse: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    +    """Sharpe-Kennzahl und einseitiger p-Wert je Strategie.
    +
    +    ergebnisse hat die Form (Strategien, Handelstage). Der p-Wert prueft die
    +    Nullhypothese 'mittleres Tagesergebnis ist null' - also genau die Frage,
    +    die ein Backtest beantworten soll.
    +    """
    +    mittel = ergebnisse.mean(axis=1)
    +    streuung = ergebnisse.std(axis=1, ddof=1)
    +    sharpe = mittel / streuung * np.sqrt(252)
    +    t_wert = mittel / (streuung / np.sqrt(ergebnisse.shape[1]))
    +    return sharpe, stats.t.sf(t_wert, ergebnisse.shape[1] - 1)
    +
    +
    +def probiere(anzahl_strategien: int) -> tuple[float, float, float, float]:
    +    """Liefert (beste Sharpe, ihr p-Wert, Anteil scheinbar signifikanter,
    +    Anteil, der auch die Bonferroni-Huerde nimmt) - gemittelt ueber
    +    WIEDERHOLUNGEN unabhaengige Durchgaenge."""
    +    beste_sharpe, beste_p, anteil_signifikant, ueberlebt_bonferroni = [], [], [], []
    +
    +    for _ in range(WIEDERHOLUNGEN):
    +        # Der springende Punkt: Erwartungswert exakt 0. Keine dieser
    +        # Strategien hat einen echten Vorteil - per Konstruktion.
    +        ergebnisse = RNG.normal(0.0, TAGESSCHWANKUNG,
    +                                (anzahl_strategien, HANDELSTAGE))
    +        sharpe, p_werte = bewerte(ergebnisse)
    +
    +        beste = int(sharpe.argmax())
    +        beste_sharpe.append(float(sharpe[beste]))
    +        beste_p.append(float(p_werte[beste]))
    +        anteil_signifikant.append(float((p_werte < NIVEAU).mean()))
    +        # Bonferroni: Wer n Tests macht, muss die Huerde durch n teilen.
    +        ueberlebt_bonferroni.append(
    +            float((p_werte < NIVEAU / anzahl_strategien).mean()))
    +
    +    return (float(np.mean(beste_sharpe)), float(np.mean(beste_p)),
    +            float(np.mean(anteil_signifikant)),
    +            float(np.mean(ueberlebt_bonferroni)))
    +
    +
    +if __name__ == "__main__":
    +    print("=" * 78)
    +    print("  WIE GUT SIEHT DIE BESTE VON N STRATEGIEN AUS,")
    +    print("  WENN KEINE EINZIGE ETWAS TAUGT?")
    +    print("=" * 78)
    +    print(f"{HANDELSTAGE} Handelstage, taegliche Ergebnisse mit Erwartungswert "
    +          f"EXAKT null.")
    +    print(f"Jede Zeile ist der Mittelwert aus {WIEDERHOLUNGEN} unabhaengigen "
    +          f"Durchgaengen.\n")
    +
    +    print(f"{'getestet':>9} {'beste Sharpe':>14} {'ihr p-Wert':>12} "
    +          f"{'scheinbar sign.':>16}")
    +    print("-" * 78)
    +    ergebnisse = {}
    +    for anzahl in (1, 10, 100, 1000):
    +        sharpe, p_wert, anteil, _ = probiere(anzahl)
    +        ergebnisse[anzahl] = (sharpe, p_wert, anteil)
    +        print(f"{anzahl:9d} {sharpe:14.2f} {p_wert:12.4f} {anteil * 100:15.1f} %")
    +    print("-" * 78)
    +
    +    sharpe_100, p_100, _ = ergebnisse[100]
    +    sharpe_1000, p_1000, _ = ergebnisse[1000]
    +
    +    print("\nLesen Sie die dritte Spalte von unten nach oben:")
    +    print(f"  Wer {1000} Varianten durchprobiert, findet eine mit p = {p_1000:.4f} -")
    +    print("  ein Wert, den jede Zeitschrift als 'hochsignifikant' druckt.")
    +    print(f"  Wer {100} probiert, findet p = {p_100:.4f} und eine Sharpe-Kennzahl")
    +    print(f"  von {sharpe_100:.2f} - damit geht man zum Vorgesetzten.")
    +    print("  Und keine dieser Strategien hat auch nur den Hauch eines Vorteils.")
    +    print()
    +    print("Die LETZTE Spalte bleibt dagegen konstant bei rund 5 %. Das ist kein")
    +    print("Widerspruch, sondern die Definition: Ein Test zum 5-%-Niveau irrt in")
    +    print("5 % der Faelle. Was sich aendert, ist nicht der Anteil, sondern die")
    +    print("ANZAHL - und weil man nur die beste Strategie praesentiert, sieht man")
    +    print("von allen Fehlversuchen genau einen: den erfolgreichsten.")
    +
    +    print("\n" + "=" * 78)
    +    print("  WAS MAN DAGEGEN TUN KANN")
    +    print("=" * 78)
    +    print("1. ZAEHLEN. Notieren Sie, wie viele Varianten Sie ausprobiert haben -")
    +    print("   auch die verworfenen, auch die 'nur mal schnell'. Ohne diese Zahl")
    +    print("   ist kein p-Wert und keine Sharpe-Kennzahl interpretierbar.")
    +    print()
    +    print("2. KORRIGIEREN. Die Bonferroni-Korrektur teilt die Huerde durch die")
    +    print("   Zahl der Versuche:")
    +    for anzahl in (10, 100, 1000):
    +        print(f"     bei {anzahl:5d} Versuchen: p < {NIVEAU / anzahl:.5f} statt "
    +              f"p < {NIVEAU:.2f}")
    +    print("   Sie ist konservativ, aber sie ist ehrlich. Fuer Handelsstrategien")
    +    print("   gibt es verfeinerte Varianten (Deflated Sharpe Ratio nach Bailey")
    +    print("   und Lopez de Prado), die dasselbe Prinzip verfolgen.")
    +    print()
    +    print("3. ZURUECKHALTEN. Legen Sie einen Zeitraum beiseite, BEVOR Sie")
    +    print("   anfangen, und ruehren Sie ihn bis zum Schluss nicht an. Er ist")
    +    print("   der einzige Test, den Sie nicht durch Probieren verderben koennen -")
    +    print("   und er ist genau einmal verwendbar.")
    +    print("=" * 78)

    Erwartete Ausgabe:

    ==============================================================================
       WIE GUT SIEHT DIE BESTE VON N STRATEGIEN AUS,
    @@ -24668,25 +24984,25 @@ von allen Fehlversuchen genau einen: den erfolgreichsten.
     

    Die vorangegangenen vierzehn Kapitel enden fast alle mit derselben Empfehlung: Prüfen Sie das Ergebnis gegen die Wirklichkeit. Dieses Kapitel beginnt damit, diese Prüfung als wiederverwendbaren Baustein hinzuschreiben.

    🚀 In 5 Minuten gelöst: Die Abnahmeprüfung, die in jedes Modell gehört

    -
    def pruefe_loesung(x, A, b, kosten, zielwert, ganzzahlig=(), toleranz=1e-6):
    -    """Prueft eine Loesung gegen die Anforderungen - OHNE den Solver zu fragen."""
    -    beanstandungen = []
    -
    -    verbrauch = A @ x                                   # 1. Werden Grenzen eingehalten?
    -    for i, (ist, grenze) in enumerate(zip(verbrauch, b)):
    -        if ist > grenze + toleranz:
    -            beanstandungen.append(f"Bedingung {i}: {ist:.4f} > {grenze:.4f}")
    -
    -    for j in ganzzahlig:                                # 2. Ist ganzzahlig auch ganzzahlig?
    -        if abs(x[j] - round(x[j])) > toleranz:
    -            beanstandungen.append(f"x[{j}] = {x[j]!r} ist nicht ganzzahlig")
    -
    -    nachgerechnet = float(kosten @ x)                   # 3. Stimmt der Zielwert?
    -    if abs(nachgerechnet - zielwert) > toleranz * max(1.0, abs(nachgerechnet)):
    -        beanstandungen.append(
    -            f"Zielwert {zielwert:.4f} passt nicht zu den Daten ({nachgerechnet:.4f})")
    -
    -    return beanstandungen
    +
    def pruefe_loesung(x, A, b, kosten, zielwert, ganzzahlig=(), toleranz=1e-6):
    +    """Prueft eine Loesung gegen die Anforderungen - OHNE den Solver zu fragen."""
    +    beanstandungen = []
    +
    +    verbrauch = A @ x                                   # 1. Werden Grenzen eingehalten?
    +    for i, (ist, grenze) in enumerate(zip(verbrauch, b)):
    +        if ist > grenze + toleranz:
    +            beanstandungen.append(f"Bedingung {i}: {ist:.4f} > {grenze:.4f}")
    +
    +    for j in ganzzahlig:                                # 2. Ist ganzzahlig auch ganzzahlig?
    +        if abs(x[j] - round(x[j])) > toleranz:
    +            beanstandungen.append(f"x[{j}] = {x[j]!r} ist nicht ganzzahlig")
    +
    +    nachgerechnet = float(kosten @ x)                   # 3. Stimmt der Zielwert?
    +    if abs(nachgerechnet - zielwert) > toleranz * max(1.0, abs(nachgerechnet)):
    +        beanstandungen.append(
    +            f"Zielwert {zielwert:.4f} passt nicht zu den Daten ({nachgerechnet:.4f})")
    +
    +    return beanstandungen

    Zwölf Zeilen, drei Prüfungen — und jede davon hätte einen der Denkfehler dieses Buches gefunden:

    @@ -24756,118 +25072,118 @@ von allen Fehlversuchen genau einen: den erfolgreichsten.
  • Falsch: Das System stürzt ab oder zeigt „Fehler“. Der Disponent steht ohne Plan da.
  • Richtig: Hierarchische Relaxation. Jede harte Bedingung, die im Notfall gebrochen werden darf, erhält eine teure Schlupfvariable. So liefert der Solver immer einen Plan — und zeigt zugleich präzise, wo es klemmt.
  • -
    #!/usr/bin/env python3
    -
    -# Infeasibility_Diagnose.py
    -"""
    -Kapitel Praxisfallen: Aus INFEASIBLE einen Notfallplan machen.
    -
    -Demonstriert die hierarchische Relaxation an einem Dienstplan, der in der
    -harten Fassung unloesbar ist, und zeigt, wie der Solver die Ursache benennt.
    -"""
    -
    -from ortools.sat.python import cp_model
    -
    -SLOTS = ["Mo frueh", "Mo spaet", "Di frueh", "Di spaet"]
    -FAECHER = ["Mathematik", "Physik", "Mathematik", "Chemie"]   # Chemie kann niemand!
    -PERSONAL = ["Alice", "Bob", "Carla"]
    -QUALIFIKATION = {
    -    "Alice": {"Mathematik", "Physik"},
    -    "Bob":   {"Mathematik"},
    -    "Carla": {"Physik", "Mathematik"},
    -}
    -MAX_PRO_PERSON = 2
    -STRAFE_UNBESETZT = 10_000          # sehr teuer, aber nicht unmoeglich
    -STRAFE_UEBERLAST = 500             # teuer, aber billiger als Ausfall
    -
    -
    -def plane(harte_fassung: bool):
    -    """harte_fassung=True: klassisch (kann INFEASIBLE werden).
    -       harte_fassung=False: mit Schlupfvariablen (immer loesbar)."""
    -    modell = cp_model.CpModel()
    -    x = {(p, s): modell.NewBoolVar(f"x_{p}_{s}")
    -         for p in PERSONAL for s in range(len(SLOTS))}
    -
    -    strafen = []
    -    unbesetzt = {}
    -    ueberlast = {}
    -
    -    for s in range(len(SLOTS)):
    -        if harte_fassung:
    -            modell.AddExactlyOne(x[p, s] for p in PERSONAL)
    -        else:
    -            # Schlupfvariable: der Slot DARF unbesetzt bleiben - gegen hohe Strafe
    -            unbesetzt[s] = modell.NewBoolVar(f"unbesetzt_{s}")
    -            modell.AddExactlyOne([x[p, s] for p in PERSONAL] + [unbesetzt[s]])
    -            strafen.append(unbesetzt[s] * STRAFE_UNBESETZT)
    -
    -    # Qualifikation bleibt IMMER hart - fachfremder Unterricht ist keine Option
    -    for s, fach in enumerate(FAECHER):
    -        for p in PERSONAL:
    -            if fach not in QUALIFIKATION[p]:
    -                modell.Add(x[p, s] == 0)
    -
    -    for p in PERSONAL:
    -        last = sum(x[p, s] for s in range(len(SLOTS)))
    -        if harte_fassung:
    -            modell.Add(last <= MAX_PRO_PERSON)
    -        else:
    -            # Ueberlast erlaubt - aber teuer
    -            ueberlast[p] = modell.NewIntVar(0, len(SLOTS), f"ueberlast_{p}")
    -            modell.Add(last <= MAX_PRO_PERSON + ueberlast[p])
    -            strafen.append(ueberlast[p] * STRAFE_UEBERLAST)
    -
    -    if strafen:
    -        modell.Minimize(sum(strafen))
    -
    -    loeser = cp_model.CpSolver()
    -    loeser.parameters.max_time_in_seconds = 5.0
    -    status = loeser.Solve(modell)
    -    return loeser, status, x, unbesetzt, ueberlast
    -
    -
    -if __name__ == "__main__":
    -    print("=" * 78)
    -    print("  VARIANTE A: KLASSISCH MIT LAUTER HARTEN BEDINGUNGEN")
    -    print("=" * 78)
    -    loeser, status, *_ = plane(harte_fassung=True)
    -    print(f"Solver-Status: {loeser.StatusName(status)}")
    -    if status == cp_model.INFEASIBLE:
    -        print("Das System kann keinen Plan liefern. Der Anwender erfaehrt NICHT,")
    -        print("welche Regel das Problem verursacht - nur, dass es nicht geht.\n")
    -
    -    print("=" * 78)
    -    print("  VARIANTE B: MIT HIERARCHISCHER RELAXATION")
    -    print("=" * 78)
    -    loeser, status, x, unbesetzt, ueberlast = plane(harte_fassung=False)
    -    print(f"Solver-Status: {loeser.StatusName(status)} | "
    -          f"Strafkosten: {loeser.ObjectiveValue():.0f}\n")
    -
    -    print(f"{'Slot':<12} {'Fach':<12} {'Zuweisung':<18} {'Bemerkung'}")
    -    print("-" * 78)
    -    for s, slot in enumerate(SLOTS):
    -        if loeser.Value(unbesetzt[s]):
    -            print(f"{slot:<12} {FAECHER[s]:<12} {'-- UNBESETZT --':<18} "
    -                  f"Kein qualifiziertes Personal verfuegbar")
    -        else:
    -            person = next(p for p in PERSONAL if loeser.Value(x[p, s]))
    -            print(f"{slot:<12} {FAECHER[s]:<12} {person:<18}")
    -
    -    print("\n--- Diagnose ---")
    -    for s, slot in enumerate(SLOTS):
    -        if loeser.Value(unbesetzt[s]):
    -            fach = FAECHER[s]
    -            qualifiziert = [p for p in PERSONAL if fach in QUALIFIKATION[p]]
    -            print(f"  '{slot}' ({fach}): {len(qualifiziert)} qualifizierte Personen "
    -                  f"{qualifiziert if qualifiziert else '-> URSACHE: niemand kann dieses Fach'}")
    -    for p in PERSONAL:
    -        if loeser.Value(ueberlast[p]):
    -            print(f"  {p} arbeitet {loeser.Value(ueberlast[p])} Stunden ueber der Grenze.")
    -
    -    print("\nDer Anwender bekommt jetzt einen Plan PLUS eine konkrete Ursache -")
    -    print("und kann handeln: Vertretung von aussen holen, Stunde verlegen,")
    -    print("oder die Klasse zusammenlegen.")
    -    print("=" * 78)
    +
    #!/usr/bin/env python3
    +
    +# Infeasibility_Diagnose.py
    +"""
    +Kapitel Praxisfallen: Aus INFEASIBLE einen Notfallplan machen.
    +
    +Demonstriert die hierarchische Relaxation an einem Dienstplan, der in der
    +harten Fassung unloesbar ist, und zeigt, wie der Solver die Ursache benennt.
    +"""
    +
    +from ortools.sat.python import cp_model
    +
    +SLOTS = ["Mo frueh", "Mo spaet", "Di frueh", "Di spaet"]
    +FAECHER = ["Mathematik", "Physik", "Mathematik", "Chemie"]   # Chemie kann niemand!
    +PERSONAL = ["Alice", "Bob", "Carla"]
    +QUALIFIKATION = {
    +    "Alice": {"Mathematik", "Physik"},
    +    "Bob":   {"Mathematik"},
    +    "Carla": {"Physik", "Mathematik"},
    +}
    +MAX_PRO_PERSON = 2
    +STRAFE_UNBESETZT = 10_000          # sehr teuer, aber nicht unmoeglich
    +STRAFE_UEBERLAST = 500             # teuer, aber billiger als Ausfall
    +
    +
    +def plane(harte_fassung: bool):
    +    """harte_fassung=True: klassisch (kann INFEASIBLE werden).
    +       harte_fassung=False: mit Schlupfvariablen (immer loesbar)."""
    +    modell = cp_model.CpModel()
    +    x = {(p, s): modell.NewBoolVar(f"x_{p}_{s}")
    +         for p in PERSONAL for s in range(len(SLOTS))}
    +
    +    strafen = []
    +    unbesetzt = {}
    +    ueberlast = {}
    +
    +    for s in range(len(SLOTS)):
    +        if harte_fassung:
    +            modell.AddExactlyOne(x[p, s] for p in PERSONAL)
    +        else:
    +            # Schlupfvariable: der Slot DARF unbesetzt bleiben - gegen hohe Strafe
    +            unbesetzt[s] = modell.NewBoolVar(f"unbesetzt_{s}")
    +            modell.AddExactlyOne([x[p, s] for p in PERSONAL] + [unbesetzt[s]])
    +            strafen.append(unbesetzt[s] * STRAFE_UNBESETZT)
    +
    +    # Qualifikation bleibt IMMER hart - fachfremder Unterricht ist keine Option
    +    for s, fach in enumerate(FAECHER):
    +        for p in PERSONAL:
    +            if fach not in QUALIFIKATION[p]:
    +                modell.Add(x[p, s] == 0)
    +
    +    for p in PERSONAL:
    +        last = sum(x[p, s] for s in range(len(SLOTS)))
    +        if harte_fassung:
    +            modell.Add(last <= MAX_PRO_PERSON)
    +        else:
    +            # Ueberlast erlaubt - aber teuer
    +            ueberlast[p] = modell.NewIntVar(0, len(SLOTS), f"ueberlast_{p}")
    +            modell.Add(last <= MAX_PRO_PERSON + ueberlast[p])
    +            strafen.append(ueberlast[p] * STRAFE_UEBERLAST)
    +
    +    if strafen:
    +        modell.Minimize(sum(strafen))
    +
    +    loeser = cp_model.CpSolver()
    +    loeser.parameters.max_time_in_seconds = 5.0
    +    status = loeser.Solve(modell)
    +    return loeser, status, x, unbesetzt, ueberlast
    +
    +
    +if __name__ == "__main__":
    +    print("=" * 78)
    +    print("  VARIANTE A: KLASSISCH MIT LAUTER HARTEN BEDINGUNGEN")
    +    print("=" * 78)
    +    loeser, status, *_ = plane(harte_fassung=True)
    +    print(f"Solver-Status: {loeser.StatusName(status)}")
    +    if status == cp_model.INFEASIBLE:
    +        print("Das System kann keinen Plan liefern. Der Anwender erfaehrt NICHT,")
    +        print("welche Regel das Problem verursacht - nur, dass es nicht geht.\n")
    +
    +    print("=" * 78)
    +    print("  VARIANTE B: MIT HIERARCHISCHER RELAXATION")
    +    print("=" * 78)
    +    loeser, status, x, unbesetzt, ueberlast = plane(harte_fassung=False)
    +    print(f"Solver-Status: {loeser.StatusName(status)} | "
    +          f"Strafkosten: {loeser.ObjectiveValue():.0f}\n")
    +
    +    print(f"{'Slot':<12} {'Fach':<12} {'Zuweisung':<18} {'Bemerkung'}")
    +    print("-" * 78)
    +    for s, slot in enumerate(SLOTS):
    +        if loeser.Value(unbesetzt[s]):
    +            print(f"{slot:<12} {FAECHER[s]:<12} {'-- UNBESETZT --':<18} "
    +                  f"Kein qualifiziertes Personal verfuegbar")
    +        else:
    +            person = next(p for p in PERSONAL if loeser.Value(x[p, s]))
    +            print(f"{slot:<12} {FAECHER[s]:<12} {person:<18}")
    +
    +    print("\n--- Diagnose ---")
    +    for s, slot in enumerate(SLOTS):
    +        if loeser.Value(unbesetzt[s]):
    +            fach = FAECHER[s]
    +            qualifiziert = [p for p in PERSONAL if fach in QUALIFIKATION[p]]
    +            print(f"  '{slot}' ({fach}): {len(qualifiziert)} qualifizierte Personen "
    +                  f"{qualifiziert if qualifiziert else '-> URSACHE: niemand kann dieses Fach'}")
    +    for p in PERSONAL:
    +        if loeser.Value(ueberlast[p]):
    +            print(f"  {p} arbeitet {loeser.Value(ueberlast[p])} Stunden ueber der Grenze.")
    +
    +    print("\nDer Anwender bekommt jetzt einen Plan PLUS eine konkrete Ursache -")
    +    print("und kann handeln: Vertretung von aussen holen, Stunde verlegen,")
    +    print("oder die Klasse zusammenlegen.")
    +    print("=" * 78)

    Falle 2 — Der Black-Box-Effekt

    Ein Planer oder Trader lehnt ein System ab, wenn er die Zuweisungen nicht nachvollziehen kann. Akzeptanz ist keine Nebensache, sondern Voraussetzung für den Betrieb.

    Jede Entscheidung braucht einen Constraint-Trace:

    @@ -24880,91 +25196,91 @@ von allen Fehlversuchen genau einen: den erfolgreichsten.

    Kapitel 5 liefert für Frage 3 das Werkzeug. Nutzen Sie es — Schattenpreise sind in Managementgesprächen oft wertvoller als die Lösung selbst.

    Ein vollständiger Constraint-Trace beantwortet alle drei Fragen der Faustregel für eine einzelne Zuweisung — inklusive einer Unterscheidung, die in der Praxis oft übersehen wird: eine Bedingung kann blockieren, ohne etwas zu kosten.

    -
    #!/usr/bin/env python3
    -
    -# Erklaerbarkeit.py
    -"""
    -Kapitel Praxisfallen: Ein Constraint-Trace, der die drei Fragen der Faustregel fuer
    -Erklaerbarkeit (Abschnitt 'Die fuenf typischen Praxisfallen') fuer eine
    -einzelne Zuweisung beantwortet:
    -Warum diese Loesung? Warum nicht die Alternative? Was wuerde sie verbessern?
    -"""
    -
    -from ortools.sat.python import cp_model
    -
    -PERSONAL = ["Anna", "Ben", "Clara"]
    -BEREITS_STUNDEN = {"Anna": 1, "Ben": 2, "Clara": 0}   # diese Woche schon geleistet
    -MAX_STUNDEN = 2                                        # gesetzliche Obergrenze
    -STRAFE_VORBELASTUNG = 50                                # pro bereits geleisteter Stunde
    -STRAFKOSTEN_WUNSCH = {"Anna": 0, "Ben": 30, "Clara": 100}
    -
    -
    -def loese(max_stunden: int):
    -    modell = cp_model.CpModel()
    -    x = {p: modell.NewBoolVar(f"x_{p}") for p in PERSONAL}
    -    modell.AddExactlyOne(x.values())
    -
    -    kosten = []
    -    for p in PERSONAL:
    -        if BEREITS_STUNDEN[p] + 1 > max_stunden:
    -            modell.Add(x[p] == 0)                       # hartes Ausschlusskriterium
    -        strafe = BEREITS_STUNDEN[p] * STRAFE_VORBELASTUNG + STRAFKOSTEN_WUNSCH[p]
    -        kosten.append(strafe * x[p])
    -
    -    modell.Minimize(sum(kosten))
    -    loeser = cp_model.CpSolver()
    -    status = loeser.Solve(modell)
    -    if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
    -        return None
    -    gewinner = next(p for p in PERSONAL if loeser.Value(x[p]))
    -    return gewinner, loeser.ObjectiveValue()
    -
    -
    -def gesamtkosten(p: str) -> int:
    -    return BEREITS_STUNDEN[p] * STRAFE_VORBELASTUNG + STRAFKOSTEN_WUNSCH[p]
    -
    -
    -if __name__ == "__main__":
    -    gewinner, kosten_opt = loese(MAX_STUNDEN)
    -
    -    print("=" * 78)
    -    print("  CONSTRAINT-TRACE: Warum bekommt", gewinner, "die Vertretungsstunde?")
    -    print("=" * 78)
    -
    -    print("\n1. WARUM DIESE LOESUNG?")
    -    v, w = BEREITS_STUNDEN[gewinner] * STRAFE_VORBELASTUNG, STRAFKOSTEN_WUNSCH[gewinner]
    -    print(f"   {gewinner}: Vorbelastung {v} Strafpunkte + Wunsch {w} Strafpunkte "
    -          f"= {v + w} Strafpunkte insgesamt (Minimum).")
    -
    -    print("\n2. WARUM NICHT DIE NAHELIEGENDEN ALTERNATIVEN?")
    -    for p in PERSONAL:
    -        if p == gewinner:
    -            continue
    -        if BEREITS_STUNDEN[p] + 1 > MAX_STUNDEN:
    -            print(f"   {p}: AUSGESCHLOSSEN - hat bereits {BEREITS_STUNDEN[p]} Stunden, "
    -                  f"eine dritte würde die Obergrenze von {MAX_STUNDEN} Stunden verletzen "
    -                  f"(harte Bedingung, nicht verhandelbar).")
    -        else:
    -            differenz = gesamtkosten(p) - kosten_opt
    -            print(f"   {p}: zulässig, aber {differenz:.0f} Strafpunkte teurer als "
    -                  f"{gewinner} ({gesamtkosten(p):.0f} statt {kosten_opt:.0f}).")
    -
    -    print("\n3. WAS WUERDE DIE LOESUNG VERBESSERN?")
    -    gewinner_gelockert, kosten_gelockert = loese(MAX_STUNDEN + 1)
    -    if kosten_gelockert < kosten_opt:
    -        print(f"   Obergrenze auf {MAX_STUNDEN + 1} Stunden gelockert: {gewinner_gelockert} "
    -              f"uebernimmt jetzt zu {kosten_gelockert:.0f} Strafpunkten "
    -              f"({kosten_opt - kosten_gelockert:.0f} weniger als heute).")
    -        print("   -> Diese Bedingung hat tatsaechlich einen Preis.")
    -    else:
    -        print(f"   Obergrenze auf {MAX_STUNDEN + 1} Stunden gelockert: Ergebnis bleibt bei "
    -              f"{gewinner} mit {kosten_gelockert:.0f} Strafpunkten (unveraendert).")
    -        blockiert = ", ".join(p for p in PERSONAL
    -                               if BEREITS_STUNDEN[p] + 1 > MAX_STUNDEN and p != gewinner)
    -        print(f"   -> Die Obergrenze blockiert zwar {blockiert}, kostet aber gerade NICHTS:")
    -        print("   Selbst ohne sie waere die Loesung dieselbe.")
    -        print("   Blockierend und kostenrelevant sind zwei verschiedene Dinge.")
    -    print("=" * 78)
    +
    #!/usr/bin/env python3
    +
    +# Erklaerbarkeit.py
    +"""
    +Kapitel Praxisfallen: Ein Constraint-Trace, der die drei Fragen der Faustregel fuer
    +Erklaerbarkeit (Abschnitt 'Die fuenf typischen Praxisfallen') fuer eine
    +einzelne Zuweisung beantwortet:
    +Warum diese Loesung? Warum nicht die Alternative? Was wuerde sie verbessern?
    +"""
    +
    +from ortools.sat.python import cp_model
    +
    +PERSONAL = ["Anna", "Ben", "Clara"]
    +BEREITS_STUNDEN = {"Anna": 1, "Ben": 2, "Clara": 0}   # diese Woche schon geleistet
    +MAX_STUNDEN = 2                                        # gesetzliche Obergrenze
    +STRAFE_VORBELASTUNG = 50                                # pro bereits geleisteter Stunde
    +STRAFKOSTEN_WUNSCH = {"Anna": 0, "Ben": 30, "Clara": 100}
    +
    +
    +def loese(max_stunden: int):
    +    modell = cp_model.CpModel()
    +    x = {p: modell.NewBoolVar(f"x_{p}") for p in PERSONAL}
    +    modell.AddExactlyOne(x.values())
    +
    +    kosten = []
    +    for p in PERSONAL:
    +        if BEREITS_STUNDEN[p] + 1 > max_stunden:
    +            modell.Add(x[p] == 0)                       # hartes Ausschlusskriterium
    +        strafe = BEREITS_STUNDEN[p] * STRAFE_VORBELASTUNG + STRAFKOSTEN_WUNSCH[p]
    +        kosten.append(strafe * x[p])
    +
    +    modell.Minimize(sum(kosten))
    +    loeser = cp_model.CpSolver()
    +    status = loeser.Solve(modell)
    +    if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
    +        return None
    +    gewinner = next(p for p in PERSONAL if loeser.Value(x[p]))
    +    return gewinner, loeser.ObjectiveValue()
    +
    +
    +def gesamtkosten(p: str) -> int:
    +    return BEREITS_STUNDEN[p] * STRAFE_VORBELASTUNG + STRAFKOSTEN_WUNSCH[p]
    +
    +
    +if __name__ == "__main__":
    +    gewinner, kosten_opt = loese(MAX_STUNDEN)
    +
    +    print("=" * 78)
    +    print("  CONSTRAINT-TRACE: Warum bekommt", gewinner, "die Vertretungsstunde?")
    +    print("=" * 78)
    +
    +    print("\n1. WARUM DIESE LOESUNG?")
    +    v, w = BEREITS_STUNDEN[gewinner] * STRAFE_VORBELASTUNG, STRAFKOSTEN_WUNSCH[gewinner]
    +    print(f"   {gewinner}: Vorbelastung {v} Strafpunkte + Wunsch {w} Strafpunkte "
    +          f"= {v + w} Strafpunkte insgesamt (Minimum).")
    +
    +    print("\n2. WARUM NICHT DIE NAHELIEGENDEN ALTERNATIVEN?")
    +    for p in PERSONAL:
    +        if p == gewinner:
    +            continue
    +        if BEREITS_STUNDEN[p] + 1 > MAX_STUNDEN:
    +            print(f"   {p}: AUSGESCHLOSSEN - hat bereits {BEREITS_STUNDEN[p]} Stunden, "
    +                  f"eine dritte würde die Obergrenze von {MAX_STUNDEN} Stunden verletzen "
    +                  f"(harte Bedingung, nicht verhandelbar).")
    +        else:
    +            differenz = gesamtkosten(p) - kosten_opt
    +            print(f"   {p}: zulässig, aber {differenz:.0f} Strafpunkte teurer als "
    +                  f"{gewinner} ({gesamtkosten(p):.0f} statt {kosten_opt:.0f}).")
    +
    +    print("\n3. WAS WUERDE DIE LOESUNG VERBESSERN?")
    +    gewinner_gelockert, kosten_gelockert = loese(MAX_STUNDEN + 1)
    +    if kosten_gelockert < kosten_opt:
    +        print(f"   Obergrenze auf {MAX_STUNDEN + 1} Stunden gelockert: {gewinner_gelockert} "
    +              f"uebernimmt jetzt zu {kosten_gelockert:.0f} Strafpunkten "
    +              f"({kosten_opt - kosten_gelockert:.0f} weniger als heute).")
    +        print("   -> Diese Bedingung hat tatsaechlich einen Preis.")
    +    else:
    +        print(f"   Obergrenze auf {MAX_STUNDEN + 1} Stunden gelockert: Ergebnis bleibt bei "
    +              f"{gewinner} mit {kosten_gelockert:.0f} Strafpunkten (unveraendert).")
    +        blockiert = ", ".join(p for p in PERSONAL
    +                               if BEREITS_STUNDEN[p] + 1 > MAX_STUNDEN and p != gewinner)
    +        print(f"   -> Die Obergrenze blockiert zwar {blockiert}, kostet aber gerade NICHTS:")
    +        print("   Selbst ohne sie waere die Loesung dieselbe.")
    +        print("   Blockierend und kostenrelevant sind zwei verschiedene Dinge.")
    +    print("=" * 78)

    Erwartete Ausgabe:

    ==============================================================================
       CONSTRAINT-TRACE: Warum bekommt Anna die Vertretungsstunde?
    @@ -25039,311 +25355,311 @@ von allen Fehlversuchen genau einen: den erfolgreichsten.
     
     

    Die Antwort heißt Constraint Attribution: Jede Bedingungsgruppe wird einzeln gelockert, und gemessen wird, was sich am Zielwert ändert. Das ist dasselbe Verfahren wie der Deletion Filter — nur mit Kosten statt Zulässigkeit als Kriterium.

    Gerechnet wird auf derselben Fabrik wie die Konfliktsuche in Anhang C, aber nach der Reparatur: Dort war die Lackierkapazität von 150 Stunden der Kern des Widerspruchs (2 \cdot 40 + 3 \cdot 30 = 170 > 150). Eine zweite Schicht hat sie auf 210 gebracht, der Mindestumsatz wurde auf 10 000 € gesenkt. Jetzt ist das Modell lösbar — und die Frage lautet nicht mehr „warum nicht?“, sondern „was kostet uns was?“.

    -
    #!/usr/bin/env python3
    -
    -# Constraint_Attribution.py
    -"""
    -Kapitel Praxisfallen: Welche Bedingung kostet wie viel - und was sagt man
    -dem Management?
    -
    -Zwischen den beiden bekannten Faellen klafft eine Luecke:
    -
    -  * Erklaerbarkeit.py beantwortet "warum diese eine Zuweisung?"
    -  * Konfliktsuche.py (Anhang Fehlerdiagnose) beantwortet "warum geht es gar
    -    nicht?"
    -
    -Der haeufigste Fall in der Praxis liegt dazwischen: Das Modell rechnet, der
    -Plan ist zulaessig - und trotzdem unbefriedigend. Niemand weiss, WORAN es
    -liegt. Genau das beantwortet Constraint Attribution.
    -
    -Gerechnet wird auf derselben Fabrik wie in Konfliktsuche.py, nur nach der
    -Reparatur: Dort war die Lackierkapazitaet von 150 Stunden der Kern des
    -Widerspruchs (2*40 + 3*30 = 170 > 150). Eine zweite Schicht hat sie auf 210
    -gebracht, und der Mindestumsatz wurde auf 10.000 EUR gesenkt. Jetzt ist das
    -Modell loesbar - und die Frage lautet nicht mehr "warum nicht?", sondern
    -"was kostet uns was?".
    -
    -Gezeigt werden vier Dinge:
    -  1. Welche Bedingungen binden - und dass "bindend" nicht "teuer" heisst.
    -  2. Dass der Schattenpreis eine MOMENTAUFNAHME ist: hochgerechnet auf eine
    -     realistische Sonderschicht verspricht er hier das Doppelte des
    -     tatsaechlichen Nutzens.
    -  3. Dass ein Wunsch aus dem Vertrieb nicht teuer, sondern unmoeglich sein
    -     kann - und welche Bedingungen ihn gemeinsam blockieren.
    -  4. Wie aus alledem ein Bericht in Alltagssprache wird.
    -
    -Benoetigt: numpy, scipy
    -"""
    -
    -from __future__ import annotations
    -
    -import textwrap
    -
    -import numpy as np
    -from scipy.optimize import linprog
    -
    -PRODUKTE = ["Rahmen", "Gehaeuse", "Deckel", "Traeger", "Halter"]
    -DECKUNGSBEITRAG = np.array([35.0, 28.0, 9.0, 22.0, 6.0])   # EUR je Stueck
    -
    -# Dieselben Bedingungen wie in Konfliktsuche.py, mit den beiden Reparaturen.
    -# Der sprechende Name ist keine Kosmetik: Ohne ihn ist kein Bericht moeglich,
    -# der jemandem ausserhalb der Modellierung etwas sagt.
    -BEDINGUNGEN: list[tuple[str, list[float], str, float]] = [
    -    ("Kapazitaet Montage",       [3, 2, 1, 4, 2],      "<=",   400),
    -    ("Kapazitaet Lackieren",     [2, 3, 0, 1, 0],      "<=",   210),
    -    ("Kapazitaet Pruefung",      [1, 1, 1, 1, 1],      "<=",   300),
    -    ("Liefervertrag Rahmen",     [1, 0, 0, 0, 0],      ">=",    40),
    -    ("Liefervertrag Gehaeuse",   [0, 1, 0, 0, 0],      ">=",    30),
    -    ("Liefervertrag Deckel",     [0, 0, 1, 0, 0],      ">=",    20),
    -    ("Liefervertrag Traeger",    [0, 0, 0, 1, 0],      ">=",    25),
    -    ("Marktgrenze Rahmen",       [1, 0, 0, 0, 0],      "<=",   120),
    -    ("Marktgrenze Gehaeuse",     [0, 1, 0, 0, 0],      "<=",    90),
    -    ("Marktgrenze Deckel",       [0, 0, 1, 0, 0],      "<=",   150),
    -    ("Marktgrenze Halter",       [0, 0, 0, 0, 1],      "<=",   200),
    -    ("Mindestumsatz",            [90, 70, 30, 60, 20], ">=", 10000),
    -    ("Sortimentsbreite",         [0, 0, 1, 1, 1],      ">=",   100),
    -    ("Lackierbudget Schicht 2",  [0, 1, 0, 1, 0],      "<=",    55),
    -]
    -
    -# Was liesse sich im Ernstfall wirklich bewegen - und um wie viel? Nicht "eine
    -# Einheit", sondern der Schritt, den ein Planer tatsaechlich gehen kann: eine
    -# Sonderschicht, ein nachverhandelter Vertrag. Genau an dieser Groesse
    -# scheitert das blosse Hochrechnen des Schattenpreises.
    -STELLSCHRAUBEN: dict[str, tuple[float, str]] = {
    -    "Kapazitaet Montage":      (+50, "eine Sonderschicht Montage"),
    -    "Kapazitaet Lackieren":    (+30, "eine Sonderschicht Lackieren"),
    -    "Kapazitaet Pruefung":     (+50, "eine Sonderschicht Pruefung"),
    -    "Lackierbudget Schicht 2": (+10, "10 Stunden mehr Budget in Schicht 2"),
    -    "Liefervertrag Traeger":   (-10, "10 Stueck weniger Abnahmepflicht Traeger"),
    -    "Liefervertrag Gehaeuse":  (-10, "10 Stueck weniger Abnahmepflicht Gehaeuse"),
    -}
    -
    -WUNSCH_MENGE = 55.0      # der Vertrieb haette gern mehr Rahmen
    -WUNSCH_PRODUKT = "Rahmen"
    -
    -
    -def loese(bedingungen: list[tuple[str, list[float], str, float]]):
    -    """Maximiert den Deckungsbeitrag. linprog minimiert, daher das Vorzeichen."""
    -    matrix, rechte_seite = [], []
    -    for _, koeffizienten, richtung, grenze in bedingungen:
    -        zeile = np.array(koeffizienten, dtype=float)
    -        matrix.append(zeile if richtung == "<=" else -zeile)
    -        rechte_seite.append(grenze if richtung == "<=" else -grenze)
    -    return linprog(-DECKUNGSBEITRAG,
    -                   A_ub=np.array(matrix), b_ub=np.array(rechte_seite),
    -                   bounds=[(0, None)] * len(PRODUKTE), method="highs")
    -
    -
    -def geaendert(name: str, delta: float,
    -              basis=None) -> list[tuple[str, list[float], str, float]]:
    -    """Dieselben Bedingungen, eine davon um delta verschoben."""
    -    return [(n, k, r, g + delta if n == name else g)
    -            for n, k, r, g in (basis or BEDINGUNGEN)]
    -
    -
    -def auswertung(ergebnis, bedingungen=None):
    -    """Schlupf und Schattenpreis je Bedingung."""
    -    x = ergebnis.x
    -    dual = ergebnis.ineqlin.marginals
    -    zeilen = []
    -    for i, (name, koeff, richtung, grenze) in enumerate(bedingungen or BEDINGUNGEN):
    -        links = float(np.array(koeff, dtype=float) @ x)
    -        schlupf = (grenze - links) if richtung == "<=" else (links - grenze)
    -        zeilen.append((name, links, richtung, grenze, schlupf, abs(dual[i])))
    -    return zeilen
    -
    -
    -def blockierer(bedingungen, zusatz) -> list[str]:
    -    """Welche Bedingungen verhindern 'zusatz' - einzeln weggelassen.
    -
    -    Dasselbe Vorgehen wie der Deletion Filter in Konfliktsuche.py, nur mit
    -    umgekehrtem Vorzeichen: Dort wird gesucht, was den Widerspruch AUSMACHT,
    -    hier, was einen Wunsch VERHINDERT. Wird das Modell ohne eine Bedingung
    -    loesbar, gehoert sie zum Konflikt.
    -    """
    -    gefunden = []
    -    for i, (name, _, _, _) in enumerate(bedingungen):
    -        rest = [c for j, c in enumerate(bedingungen) if j != i] + [zusatz]
    -        if loese(rest).success:
    -            gefunden.append(name)
    -    return gefunden
    -
    -
    -def euro(betrag: float) -> str:
    -    return f"{betrag:,.2f} EUR"
    -
    -
    -if __name__ == "__main__":
    -    basis = loese(BEDINGUNGEN)
    -    db_basis = -basis.fun
    -    plan = dict(zip(PRODUKTE, basis.x))
    -
    -    print("=" * 78)
    -    print("  CONSTRAINT ATTRIBUTION: Was kostet uns welche Bedingung?")
    -    print("=" * 78)
    -    print(f"Status: {basis.message.split('(')[0].strip()}")
    -    print(f"Deckungsbeitrag des Plans: {euro(db_basis)}\n")
    -    print("  " + "  ".join(f"{p}: {m:.1f}" for p, m in plan.items()))
    -
    -    # --- 1. Wer begrenzt den Plan? -----------------------------------------
    -    print("\n" + "=" * 78)
    -    print("  (1) Welche Bedingungen binden - und was kostet die naechste Einheit?")
    -    print("=" * 78)
    -    print(f"{'Bedingung':<24} {'genutzt':>9} {'Grenze':>7} {'Schlupf':>8} "
    -          f"{'Schattenpreis':>11}")
    -    print("-" * 78)
    -    bindend = []
    -    for name, links, richtung, grenze, schlupf, dual in auswertung(basis):
    -        marke = ""
    -        if abs(schlupf) < 1e-6:
    -            bindend.append((name, dual))
    -            marke = "  <-- bindend"
    -        print(f"{name:<24} {links:>9.1f} {grenze:>7.0f} {schlupf:>8.1f} "
    -              f"{dual:>11.2f}{marke}")
    -
    -    ohne_preis = [n for n, d in bindend if d < 1e-9]
    -    print(f"\n  {len(bindend)} Bedingungen binden. Aber: "
    -          f"{', '.join(ohne_preis)} bindet")
    -    print("  mit einem Schattenpreis von 0,00 EUR - sie beruehrt den Plan, ohne ihn")
    -    print("  zu verteuern. Bindend und kostenrelevant sind zwei verschiedene Dinge.")
    -
    -    # --- 2. Der Schattenpreis als Momentaufnahme ---------------------------
    -    print("\n" + "=" * 78)
    -    print("  (2) Was der Schattenpreis verschweigt")
    -    print("=" * 78)
    -    print("Der Schattenpreis gilt fuer die NAECHSTE Einheit. Ein Planer kauft aber")
    -    print("keine Einheit, sondern eine ganze Schicht. Beides gegenuebergestellt:\n")
    -    preise = {name: dual for name, *_, dual in auswertung(basis)}
    -    print(f"{'Stellschraube':<24} {'Preis':>9} {'Schritt':>7} "
    -          f"{'hochgerechnet':>13} {'gemessen':>9} {'Abw.':>9}")
    -    print("-" * 78)
    -    wirkung = {}
    -    for name, (delta, _) in STELLSCHRAUBEN.items():
    -        ergebnis = loese(geaendert(name, delta))
    -        echt = (-ergebnis.fun) - db_basis if ergebnis.success else 0.0
    -        linear = preise[name] * abs(delta)
    -        wirkung[name] = echt
    -        print(f"{name:<24} {preise[name]:>9.2f} {delta:>+7.0f} "
    -              f"{linear:>13.2f} {echt:>9.2f} {echt - linear:>+9.2f}")
    -
    -    # Wo endet der Gueltigkeitsbereich? Schrittweise nachfahren.
    -    abweichler = max(STELLSCHRAUBEN,
    -                     key=lambda n: preise[n] * abs(STELLSCHRAUBEN[n][0]) - wirkung[n])
    -    delta_max = STELLSCHRAUBEN[abweichler][0]
    -    grenze_gueltig = 0.0
    -    schritt = delta_max / 60.0
    -    vorher = db_basis
    -    for i in range(1, 61):
    -        d = schritt * i
    -        e = loese(geaendert(abweichler, d))
    -        db = -e.fun if e.success else vorher
    -        if db - vorher > 1e-9:
    -            grenze_gueltig = d
    -        vorher = db
    -    print(f"\n  '{abweichler}': hochgerechnet "
    -          f"{preise[abweichler] * abs(delta_max):.2f} EUR, tatsaechlich "
    -          f"{wirkung[abweichler]:.2f} EUR.")
    -    print(f"  Der Preis von {preise[abweichler]:.2f} EUR gilt nur bis "
    -          f"+{grenze_gueltig:.0f} Stunden. Jede weitere Stunde")
    -    print("  dieser Sonderschicht bringt exakt nichts - der Engpass ist dann ein anderer.")
    -
    -    nach_preis = sorted(STELLSCHRAUBEN, key=lambda n: -preise[n])[0]
    -    nach_wirkung = max(wirkung, key=wirkung.get)
    -    print(f"\n  Und die Ranglisten drehen sich: Nach Schattenpreis fuehrt")
    -    print(f"  '{nach_preis}' ({preise[nach_preis]:.2f} EUR je Einheit),")
    -    print(f"  nach tatsaechlicher Wirkung '{nach_wirkung}' "
    -          f"({wirkung[nach_wirkung]:.2f} EUR).")
    -    print("  Der Schattenpreis sagt, was eine Einheit wert ist - nicht, wie viele")
    -    print("  Einheiten zu haben sind.")
    -
    -    # --- 3. Der Wunsch aus dem Vertrieb ------------------------------------
    -    print("\n" + "=" * 78)
    -    print(f"  (3) Was-waere-wenn: mindestens {WUNSCH_MENGE:.0f} "
    -          f"{WUNSCH_PRODUKT} statt {plan[WUNSCH_PRODUKT]:.1f}")
    -    print("=" * 78)
    -    wunsch = (f"Wunsch Vertrieb {WUNSCH_PRODUKT}",
    -              [1 if p == WUNSCH_PRODUKT else 0 for p in PRODUKTE], ">=", WUNSCH_MENGE)
    -    mit_wunsch = loese(BEDINGUNGEN + [wunsch])
    -    schuldige: list[str] = []
    -    engpass = noetig = None
    -    wunsch_gratis = False
    -
    -    if mit_wunsch.success:
    -        print(f"Erfuellbar, kostet {euro(db_basis - (-mit_wunsch.fun))}.")
    -    else:
    -        schuldige = blockierer(BEDINGUNGEN, wunsch)
    -        print("Der Wunsch ist nicht teuer - er ist UNMOEGLICH.\n")
    -        print(f"Blockiert wird er von {len(schuldige)} Bedingungen gemeinsam. "
    -              f"Faellt eine davon")
    -        print("weg, ist er erfuellbar; keine allein ist 'der' Grund:")
    -        for name in schuldige:
    -            e = loese([c for c in BEDINGUNGEN if c[0] != name] + [wunsch])
    -            print(f"   ohne '{name}': moeglich, Deckungsbeitrag {euro(-e.fun)}")
    -
    -        engpass = next((n for n in schuldige if n in STELLSCHRAUBEN), schuldige[0])
    -        for schritt in range(1, 61):
    -            if loese(geaendert(engpass, schritt) + [wunsch]).success:
    -                noetig = schritt
    -                break
    -        if noetig is not None:
    -            e = loese(geaendert(engpass, noetig) + [wunsch])
    -            ohne = loese(geaendert(engpass, noetig))
    -            wunsch_gratis = abs((-e.fun) - (-ohne.fun)) < 1e-6
    -            print(f"\n   Mit +{noetig} bei '{engpass}' wird der Wunsch erfuellbar:")
    -            print(f"   Deckungsbeitrag {euro(-e.fun)} "
    -                  f"({-e.fun - db_basis:+,.2f} gegenueber heute).")
    -            if wunsch_gratis:
    -                print(f"   Und ohne den Wunsch ergaebe dieselbe Lockerung "
    -                      f"{euro(-ohne.fun)} bei")
    -                print(f"   {ohne.x[PRODUKTE.index(WUNSCH_PRODUKT)]:.0f} "
    -                      f"{WUNSCH_PRODUKT} - der Wunsch kostet also NICHTS.")
    -                print("   Er war nie das Problem. Der Engpass war es.")
    -
    -    # --- 4. Der Bericht ----------------------------------------------------
    -    # Aus denselben Zahlen, die oben in Tabellen stehen, wird hier Prosa. Kein
    -    # Satz enthaelt eine Zahl, die nicht vorher gerechnet wurde - das ist der
    -    # ganze Trick und zugleich die Bedingung dafuer, dass der Bericht nicht
    -    # irgendwann leise falsch wird.
    -    print("\n" + "=" * 78)
    -    print("  (4) Und so steht es in der Vorlage fuer die Abteilungsleiterrunde")
    -    print("=" * 78)
    -
    -    teuerste = sorted(((n, d) for n, d in bindend if d > 0), key=lambda t: -t[1])
    -    gross = STELLSCHRAUBEN[nach_wirkung][1]
    -    klein = STELLSCHRAUBEN[abweichler][1]
    -
    -    absaetze = [
    -        f"Der Plan bringt {euro(db_basis)} Deckungsbeitrag. Begrenzt wird er von "
    -        f"{len(bindend)} Bedingungen, von denen {len(teuerste)} tatsaechlich Geld "
    -        f"kosten; \"{ohne_preis[0]}\" beruehrt den Plan, ohne ihn zu verteuern.",
    -
    -        f"Teuerste Bindung ist \"{teuerste[0][0]}\" mit "
    -        f"{teuerste[0][1]:.2f} EUR je Einheit, gefolgt von \"{teuerste[1][0]}\" "
    -        f"mit {teuerste[1][1]:.2f} EUR.",
    -
    -        f"Die groesste einzelne Verbesserung bringt {gross}: "
    -        f"{euro(wirkung[nach_wirkung])} mehr Deckungsbeitrag. {klein[0].upper()}{klein[1:]} "
    -        f"dagegen bringt nur {euro(wirkung[abweichler])} statt der rechnerischen "
    -        f"{euro(preise[abweichler] * abs(delta_max))} - ab +{grenze_gueltig:.0f} "
    -        f"Stunden begrenzt uns etwas anderes. Wer die ganze Schicht bezahlt, "
    -        f"bezahlt die zweite Haelfte umsonst.",
    -    ]
    -
    -    if schuldige and noetig is not None:
    -        namen = ", ".join(f'"{s}"' for s in schuldige)
    -        satz = (f"Zum Wunsch des Vertriebs ({WUNSCH_MENGE:.0f} {WUNSCH_PRODUKT} statt "
    -                f"{plan[WUNSCH_PRODUKT]:.1f}): Er ist mit der heutigen Ausstattung "
    -                f"nicht erfuellbar - nicht aus Kostengruenden, sondern weil {namen} "
    -                f"ihn gemeinsam blockieren. Mit +{noetig} Stunden bei "
    -                f'"{engpass}" wird er moeglich')
    -        satz += (", und er kostet dann nichts: Der Plan waehlt diese Menge von "
    -                 "sich aus." if wunsch_gratis else ".")
    -        absaetze.append(satz)
    -
    -    for absatz in absaetze:
    -        print()
    -        print(textwrap.fill(absatz, width=76))
    -    print("\n" + "=" * 78)
    +
    #!/usr/bin/env python3
    +
    +# Constraint_Attribution.py
    +"""
    +Kapitel Praxisfallen: Welche Bedingung kostet wie viel - und was sagt man
    +dem Management?
    +
    +Zwischen den beiden bekannten Faellen klafft eine Luecke:
    +
    +  * Erklaerbarkeit.py beantwortet "warum diese eine Zuweisung?"
    +  * Konfliktsuche.py (Anhang Fehlerdiagnose) beantwortet "warum geht es gar
    +    nicht?"
    +
    +Der haeufigste Fall in der Praxis liegt dazwischen: Das Modell rechnet, der
    +Plan ist zulaessig - und trotzdem unbefriedigend. Niemand weiss, WORAN es
    +liegt. Genau das beantwortet Constraint Attribution.
    +
    +Gerechnet wird auf derselben Fabrik wie in Konfliktsuche.py, nur nach der
    +Reparatur: Dort war die Lackierkapazitaet von 150 Stunden der Kern des
    +Widerspruchs (2*40 + 3*30 = 170 > 150). Eine zweite Schicht hat sie auf 210
    +gebracht, und der Mindestumsatz wurde auf 10.000 EUR gesenkt. Jetzt ist das
    +Modell loesbar - und die Frage lautet nicht mehr "warum nicht?", sondern
    +"was kostet uns was?".
    +
    +Gezeigt werden vier Dinge:
    +  1. Welche Bedingungen binden - und dass "bindend" nicht "teuer" heisst.
    +  2. Dass der Schattenpreis eine MOMENTAUFNAHME ist: hochgerechnet auf eine
    +     realistische Sonderschicht verspricht er hier das Doppelte des
    +     tatsaechlichen Nutzens.
    +  3. Dass ein Wunsch aus dem Vertrieb nicht teuer, sondern unmoeglich sein
    +     kann - und welche Bedingungen ihn gemeinsam blockieren.
    +  4. Wie aus alledem ein Bericht in Alltagssprache wird.
    +
    +Benoetigt: numpy, scipy
    +"""
    +
    +from __future__ import annotations
    +
    +import textwrap
    +
    +import numpy as np
    +from scipy.optimize import linprog
    +
    +PRODUKTE = ["Rahmen", "Gehaeuse", "Deckel", "Traeger", "Halter"]
    +DECKUNGSBEITRAG = np.array([35.0, 28.0, 9.0, 22.0, 6.0])   # EUR je Stueck
    +
    +# Dieselben Bedingungen wie in Konfliktsuche.py, mit den beiden Reparaturen.
    +# Der sprechende Name ist keine Kosmetik: Ohne ihn ist kein Bericht moeglich,
    +# der jemandem ausserhalb der Modellierung etwas sagt.
    +BEDINGUNGEN: list[tuple[str, list[float], str, float]] = [
    +    ("Kapazitaet Montage",       [3, 2, 1, 4, 2],      "<=",   400),
    +    ("Kapazitaet Lackieren",     [2, 3, 0, 1, 0],      "<=",   210),
    +    ("Kapazitaet Pruefung",      [1, 1, 1, 1, 1],      "<=",   300),
    +    ("Liefervertrag Rahmen",     [1, 0, 0, 0, 0],      ">=",    40),
    +    ("Liefervertrag Gehaeuse",   [0, 1, 0, 0, 0],      ">=",    30),
    +    ("Liefervertrag Deckel",     [0, 0, 1, 0, 0],      ">=",    20),
    +    ("Liefervertrag Traeger",    [0, 0, 0, 1, 0],      ">=",    25),
    +    ("Marktgrenze Rahmen",       [1, 0, 0, 0, 0],      "<=",   120),
    +    ("Marktgrenze Gehaeuse",     [0, 1, 0, 0, 0],      "<=",    90),
    +    ("Marktgrenze Deckel",       [0, 0, 1, 0, 0],      "<=",   150),
    +    ("Marktgrenze Halter",       [0, 0, 0, 0, 1],      "<=",   200),
    +    ("Mindestumsatz",            [90, 70, 30, 60, 20], ">=", 10000),
    +    ("Sortimentsbreite",         [0, 0, 1, 1, 1],      ">=",   100),
    +    ("Lackierbudget Schicht 2",  [0, 1, 0, 1, 0],      "<=",    55),
    +]
    +
    +# Was liesse sich im Ernstfall wirklich bewegen - und um wie viel? Nicht "eine
    +# Einheit", sondern der Schritt, den ein Planer tatsaechlich gehen kann: eine
    +# Sonderschicht, ein nachverhandelter Vertrag. Genau an dieser Groesse
    +# scheitert das blosse Hochrechnen des Schattenpreises.
    +STELLSCHRAUBEN: dict[str, tuple[float, str]] = {
    +    "Kapazitaet Montage":      (+50, "eine Sonderschicht Montage"),
    +    "Kapazitaet Lackieren":    (+30, "eine Sonderschicht Lackieren"),
    +    "Kapazitaet Pruefung":     (+50, "eine Sonderschicht Pruefung"),
    +    "Lackierbudget Schicht 2": (+10, "10 Stunden mehr Budget in Schicht 2"),
    +    "Liefervertrag Traeger":   (-10, "10 Stueck weniger Abnahmepflicht Traeger"),
    +    "Liefervertrag Gehaeuse":  (-10, "10 Stueck weniger Abnahmepflicht Gehaeuse"),
    +}
    +
    +WUNSCH_MENGE = 55.0      # der Vertrieb haette gern mehr Rahmen
    +WUNSCH_PRODUKT = "Rahmen"
    +
    +
    +def loese(bedingungen: list[tuple[str, list[float], str, float]]):
    +    """Maximiert den Deckungsbeitrag. linprog minimiert, daher das Vorzeichen."""
    +    matrix, rechte_seite = [], []
    +    for _, koeffizienten, richtung, grenze in bedingungen:
    +        zeile = np.array(koeffizienten, dtype=float)
    +        matrix.append(zeile if richtung == "<=" else -zeile)
    +        rechte_seite.append(grenze if richtung == "<=" else -grenze)
    +    return linprog(-DECKUNGSBEITRAG,
    +                   A_ub=np.array(matrix), b_ub=np.array(rechte_seite),
    +                   bounds=[(0, None)] * len(PRODUKTE), method="highs")
    +
    +
    +def geaendert(name: str, delta: float,
    +              basis=None) -> list[tuple[str, list[float], str, float]]:
    +    """Dieselben Bedingungen, eine davon um delta verschoben."""
    +    return [(n, k, r, g + delta if n == name else g)
    +            for n, k, r, g in (basis or BEDINGUNGEN)]
    +
    +
    +def auswertung(ergebnis, bedingungen=None):
    +    """Schlupf und Schattenpreis je Bedingung."""
    +    x = ergebnis.x
    +    dual = ergebnis.ineqlin.marginals
    +    zeilen = []
    +    for i, (name, koeff, richtung, grenze) in enumerate(bedingungen or BEDINGUNGEN):
    +        links = float(np.array(koeff, dtype=float) @ x)
    +        schlupf = (grenze - links) if richtung == "<=" else (links - grenze)
    +        zeilen.append((name, links, richtung, grenze, schlupf, abs(dual[i])))
    +    return zeilen
    +
    +
    +def blockierer(bedingungen, zusatz) -> list[str]:
    +    """Welche Bedingungen verhindern 'zusatz' - einzeln weggelassen.
    +
    +    Dasselbe Vorgehen wie der Deletion Filter in Konfliktsuche.py, nur mit
    +    umgekehrtem Vorzeichen: Dort wird gesucht, was den Widerspruch AUSMACHT,
    +    hier, was einen Wunsch VERHINDERT. Wird das Modell ohne eine Bedingung
    +    loesbar, gehoert sie zum Konflikt.
    +    """
    +    gefunden = []
    +    for i, (name, _, _, _) in enumerate(bedingungen):
    +        rest = [c for j, c in enumerate(bedingungen) if j != i] + [zusatz]
    +        if loese(rest).success:
    +            gefunden.append(name)
    +    return gefunden
    +
    +
    +def euro(betrag: float) -> str:
    +    return f"{betrag:,.2f} EUR"
    +
    +
    +if __name__ == "__main__":
    +    basis = loese(BEDINGUNGEN)
    +    db_basis = -basis.fun
    +    plan = dict(zip(PRODUKTE, basis.x))
    +
    +    print("=" * 78)
    +    print("  CONSTRAINT ATTRIBUTION: Was kostet uns welche Bedingung?")
    +    print("=" * 78)
    +    print(f"Status: {basis.message.split('(')[0].strip()}")
    +    print(f"Deckungsbeitrag des Plans: {euro(db_basis)}\n")
    +    print("  " + "  ".join(f"{p}: {m:.1f}" for p, m in plan.items()))
    +
    +    # --- 1. Wer begrenzt den Plan? -----------------------------------------
    +    print("\n" + "=" * 78)
    +    print("  (1) Welche Bedingungen binden - und was kostet die naechste Einheit?")
    +    print("=" * 78)
    +    print(f"{'Bedingung':<24} {'genutzt':>9} {'Grenze':>7} {'Schlupf':>8} "
    +          f"{'Schattenpreis':>11}")
    +    print("-" * 78)
    +    bindend = []
    +    for name, links, richtung, grenze, schlupf, dual in auswertung(basis):
    +        marke = ""
    +        if abs(schlupf) < 1e-6:
    +            bindend.append((name, dual))
    +            marke = "  <-- bindend"
    +        print(f"{name:<24} {links:>9.1f} {grenze:>7.0f} {schlupf:>8.1f} "
    +              f"{dual:>11.2f}{marke}")
    +
    +    ohne_preis = [n for n, d in bindend if d < 1e-9]
    +    print(f"\n  {len(bindend)} Bedingungen binden. Aber: "
    +          f"{', '.join(ohne_preis)} bindet")
    +    print("  mit einem Schattenpreis von 0,00 EUR - sie beruehrt den Plan, ohne ihn")
    +    print("  zu verteuern. Bindend und kostenrelevant sind zwei verschiedene Dinge.")
    +
    +    # --- 2. Der Schattenpreis als Momentaufnahme ---------------------------
    +    print("\n" + "=" * 78)
    +    print("  (2) Was der Schattenpreis verschweigt")
    +    print("=" * 78)
    +    print("Der Schattenpreis gilt fuer die NAECHSTE Einheit. Ein Planer kauft aber")
    +    print("keine Einheit, sondern eine ganze Schicht. Beides gegenuebergestellt:\n")
    +    preise = {name: dual for name, *_, dual in auswertung(basis)}
    +    print(f"{'Stellschraube':<24} {'Preis':>9} {'Schritt':>7} "
    +          f"{'hochgerechnet':>13} {'gemessen':>9} {'Abw.':>9}")
    +    print("-" * 78)
    +    wirkung = {}
    +    for name, (delta, _) in STELLSCHRAUBEN.items():
    +        ergebnis = loese(geaendert(name, delta))
    +        echt = (-ergebnis.fun) - db_basis if ergebnis.success else 0.0
    +        linear = preise[name] * abs(delta)
    +        wirkung[name] = echt
    +        print(f"{name:<24} {preise[name]:>9.2f} {delta:>+7.0f} "
    +              f"{linear:>13.2f} {echt:>9.2f} {echt - linear:>+9.2f}")
    +
    +    # Wo endet der Gueltigkeitsbereich? Schrittweise nachfahren.
    +    abweichler = max(STELLSCHRAUBEN,
    +                     key=lambda n: preise[n] * abs(STELLSCHRAUBEN[n][0]) - wirkung[n])
    +    delta_max = STELLSCHRAUBEN[abweichler][0]
    +    grenze_gueltig = 0.0
    +    schritt = delta_max / 60.0
    +    vorher = db_basis
    +    for i in range(1, 61):
    +        d = schritt * i
    +        e = loese(geaendert(abweichler, d))
    +        db = -e.fun if e.success else vorher
    +        if db - vorher > 1e-9:
    +            grenze_gueltig = d
    +        vorher = db
    +    print(f"\n  '{abweichler}': hochgerechnet "
    +          f"{preise[abweichler] * abs(delta_max):.2f} EUR, tatsaechlich "
    +          f"{wirkung[abweichler]:.2f} EUR.")
    +    print(f"  Der Preis von {preise[abweichler]:.2f} EUR gilt nur bis "
    +          f"+{grenze_gueltig:.0f} Stunden. Jede weitere Stunde")
    +    print("  dieser Sonderschicht bringt exakt nichts - der Engpass ist dann ein anderer.")
    +
    +    nach_preis = sorted(STELLSCHRAUBEN, key=lambda n: -preise[n])[0]
    +    nach_wirkung = max(wirkung, key=wirkung.get)
    +    print(f"\n  Und die Ranglisten drehen sich: Nach Schattenpreis fuehrt")
    +    print(f"  '{nach_preis}' ({preise[nach_preis]:.2f} EUR je Einheit),")
    +    print(f"  nach tatsaechlicher Wirkung '{nach_wirkung}' "
    +          f"({wirkung[nach_wirkung]:.2f} EUR).")
    +    print("  Der Schattenpreis sagt, was eine Einheit wert ist - nicht, wie viele")
    +    print("  Einheiten zu haben sind.")
    +
    +    # --- 3. Der Wunsch aus dem Vertrieb ------------------------------------
    +    print("\n" + "=" * 78)
    +    print(f"  (3) Was-waere-wenn: mindestens {WUNSCH_MENGE:.0f} "
    +          f"{WUNSCH_PRODUKT} statt {plan[WUNSCH_PRODUKT]:.1f}")
    +    print("=" * 78)
    +    wunsch = (f"Wunsch Vertrieb {WUNSCH_PRODUKT}",
    +              [1 if p == WUNSCH_PRODUKT else 0 for p in PRODUKTE], ">=", WUNSCH_MENGE)
    +    mit_wunsch = loese(BEDINGUNGEN + [wunsch])
    +    schuldige: list[str] = []
    +    engpass = noetig = None
    +    wunsch_gratis = False
    +
    +    if mit_wunsch.success:
    +        print(f"Erfuellbar, kostet {euro(db_basis - (-mit_wunsch.fun))}.")
    +    else:
    +        schuldige = blockierer(BEDINGUNGEN, wunsch)
    +        print("Der Wunsch ist nicht teuer - er ist UNMOEGLICH.\n")
    +        print(f"Blockiert wird er von {len(schuldige)} Bedingungen gemeinsam. "
    +              f"Faellt eine davon")
    +        print("weg, ist er erfuellbar; keine allein ist 'der' Grund:")
    +        for name in schuldige:
    +            e = loese([c for c in BEDINGUNGEN if c[0] != name] + [wunsch])
    +            print(f"   ohne '{name}': moeglich, Deckungsbeitrag {euro(-e.fun)}")
    +
    +        engpass = next((n for n in schuldige if n in STELLSCHRAUBEN), schuldige[0])
    +        for schritt in range(1, 61):
    +            if loese(geaendert(engpass, schritt) + [wunsch]).success:
    +                noetig = schritt
    +                break
    +        if noetig is not None:
    +            e = loese(geaendert(engpass, noetig) + [wunsch])
    +            ohne = loese(geaendert(engpass, noetig))
    +            wunsch_gratis = abs((-e.fun) - (-ohne.fun)) < 1e-6
    +            print(f"\n   Mit +{noetig} bei '{engpass}' wird der Wunsch erfuellbar:")
    +            print(f"   Deckungsbeitrag {euro(-e.fun)} "
    +                  f"({-e.fun - db_basis:+,.2f} gegenueber heute).")
    +            if wunsch_gratis:
    +                print(f"   Und ohne den Wunsch ergaebe dieselbe Lockerung "
    +                      f"{euro(-ohne.fun)} bei")
    +                print(f"   {ohne.x[PRODUKTE.index(WUNSCH_PRODUKT)]:.0f} "
    +                      f"{WUNSCH_PRODUKT} - der Wunsch kostet also NICHTS.")
    +                print("   Er war nie das Problem. Der Engpass war es.")
    +
    +    # --- 4. Der Bericht ----------------------------------------------------
    +    # Aus denselben Zahlen, die oben in Tabellen stehen, wird hier Prosa. Kein
    +    # Satz enthaelt eine Zahl, die nicht vorher gerechnet wurde - das ist der
    +    # ganze Trick und zugleich die Bedingung dafuer, dass der Bericht nicht
    +    # irgendwann leise falsch wird.
    +    print("\n" + "=" * 78)
    +    print("  (4) Und so steht es in der Vorlage fuer die Abteilungsleiterrunde")
    +    print("=" * 78)
    +
    +    teuerste = sorted(((n, d) for n, d in bindend if d > 0), key=lambda t: -t[1])
    +    gross = STELLSCHRAUBEN[nach_wirkung][1]
    +    klein = STELLSCHRAUBEN[abweichler][1]
    +
    +    absaetze = [
    +        f"Der Plan bringt {euro(db_basis)} Deckungsbeitrag. Begrenzt wird er von "
    +        f"{len(bindend)} Bedingungen, von denen {len(teuerste)} tatsaechlich Geld "
    +        f"kosten; \"{ohne_preis[0]}\" beruehrt den Plan, ohne ihn zu verteuern.",
    +
    +        f"Teuerste Bindung ist \"{teuerste[0][0]}\" mit "
    +        f"{teuerste[0][1]:.2f} EUR je Einheit, gefolgt von \"{teuerste[1][0]}\" "
    +        f"mit {teuerste[1][1]:.2f} EUR.",
    +
    +        f"Die groesste einzelne Verbesserung bringt {gross}: "
    +        f"{euro(wirkung[nach_wirkung])} mehr Deckungsbeitrag. {klein[0].upper()}{klein[1:]} "
    +        f"dagegen bringt nur {euro(wirkung[abweichler])} statt der rechnerischen "
    +        f"{euro(preise[abweichler] * abs(delta_max))} - ab +{grenze_gueltig:.0f} "
    +        f"Stunden begrenzt uns etwas anderes. Wer die ganze Schicht bezahlt, "
    +        f"bezahlt die zweite Haelfte umsonst.",
    +    ]
    +
    +    if schuldige and noetig is not None:
    +        namen = ", ".join(f'"{s}"' for s in schuldige)
    +        satz = (f"Zum Wunsch des Vertriebs ({WUNSCH_MENGE:.0f} {WUNSCH_PRODUKT} statt "
    +                f"{plan[WUNSCH_PRODUKT]:.1f}): Er ist mit der heutigen Ausstattung "
    +                f"nicht erfuellbar - nicht aus Kostengruenden, sondern weil {namen} "
    +                f"ihn gemeinsam blockieren. Mit +{noetig} Stunden bei "
    +                f'"{engpass}" wird er moeglich')
    +        satz += (", und er kostet dann nichts: Der Plan waehlt diese Menge von "
    +                 "sich aus." if wunsch_gratis else ".")
    +        absaetze.append(satz)
    +
    +    for absatz in absaetze:
    +        print()
    +        print(textwrap.fill(absatz, width=76))
    +    print("\n" + "=" * 78)

    Ausgabe:

    ==============================================================================
       CONSTRAINT ATTRIBUTION: Was kostet uns welche Bedingung?
    @@ -25644,510 +25960,510 @@ aus.
     

    ⚠️ Eine Besonderheit beim Import

    or_kern.py importiert von sich aus keine Solverbibliothek. Die Statusübersetzer laden ihre Bibliothek erst beim Aufruf. Das ist kein Stilmittel, sondern notwendig: ortools und highspy vertragen sich nicht im selben Prozess (Abschnitt 3.5). Ein Modul, das beide importierte, wäre mit keinem von beiden benutzbar.

    -
    #!/usr/bin/env python3
    -
    -# or_kern.py
    -"""
    -Kapitel Praxisfallen: Der gemeinsame Unterbau fuer produktionsreife OR-Modelle.
    -
    -Alle Beispiele dieses Buchs loesen dieselben vier Aufgaben immer wieder:
    -Daten einlesen und pruefen, ein Modell bauen, den Solverstatus auswerten, die
    -Loesung gegen die Wirklichkeit kontrollieren. Dieses Modul zieht diese vier
    -Aufgaben aus den Einzelprogrammen heraus.
    -
    -    Rohdaten (Excel/CSV)
    -        -> Domaenenmodell (Pydantic, geprueft)
    -            -> Modellbauer (solverabhaengig)
    -                -> Loesung (DTO, solverunabhaengig)
    -                    -> Abnahmepruefung
    -
    -Der Sinn dieser Trennung: Die Domaenenschicht weiss nichts von Solvern, die
    -Loesungsschicht weiss nichts vom Modellaufbau. Wer den Solver wechselt, tauscht
    -genau EINEN Baustein aus - der Rest bleibt.
    -
    -WICHTIG ZUM IMPORT: Dieses Modul importiert von sich aus KEINE Solver-
    -bibliothek. Der Grund steht im Kapitel Oekosystem: ortools und highspy vertragen sich
    -nicht im selben Prozess. Die Statusuebersetzer laden ihre Bibliothek erst,
    -wenn sie aufgerufen werden - so bleibt or_kern.py mit jedem Solver benutzbar.
    -
    -Benoetigt: pydantic (v2), numpy; optional pandas und openpyxl fuer Excel.
    -"""
    -
    -from __future__ import annotations
    -
    -from enum import Enum
    -from typing import Annotated, Any, Sequence
    -
    -import numpy as np
    -from pydantic import BaseModel, Field, model_validator
    -
    -# Wiederverwendbare Feldtypen. Sie tragen die Pruefung im Typ, nicht im Code -
    -# damit gilt sie ueberall, wo der Typ verwendet wird.
    -PositiveZahl = Annotated[float, Field(gt=0)]
    -NichtNegativ = Annotated[float, Field(ge=0)]
    -
    -
    -# --- 1. Solverstatus: eine Sprache fuer fuenf Bibliotheken -------------------
    -
    -class SolverStatus(str, Enum):
    -    """Was ein Solverlauf ergeben hat - unabhaengig davon, wer gerechnet hat.
    -
    -    Die fuenf im Buch verwendeten Bibliotheken benennen dasselbe Ergebnis
    -    unterschiedlich: 'Optimal', 'OPTIMAL', 2, 'optimal'. Wer darauf direkt
    -    prueft, bindet seinen Auswertungscode an eine Bibliothek.
    -    """
    -    OPTIMAL = "optimal"              # beweisbar bestmoeglich
    -    ZULAESSIG = "zulaessig"          # brauchbare Loesung, Beweis fehlt
    -    UNZULAESSIG = "unzulaessig"      # es gibt keine Loesung (Aussage ueber das Modell)
    -    UNBESCHRAENKT = "unbeschraenkt"  # Ziel waechst ins Unendliche
    -    ZEITLIMIT = "zeitlimit"          # abgebrochen, nichts Brauchbares gefunden
    -    FEHLERHAFT = "fehlerhaft"        # das Modell ist kein gueltiges Modell
    -    UNBEKANNT = "unbekannt"          # alles Uebrige
    -
    -    @property
    -    def brauchbar(self) -> bool:
    -        """Habe ich etwas in der Hand, das ich ausfuehren kann?"""
    -        return self in (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG)
    -
    -    @property
    -    def modellfehler(self) -> bool:
    -        """Liegt die Ursache im Modell (und nicht in der Rechenzeit)?"""
    -        return self in (SolverStatus.UNZULAESSIG, SolverStatus.UNBESCHRAENKT,
    -                        SolverStatus.FEHLERHAFT)
    -
    -
    -def status_von_pywraplp(rohstatus: int) -> SolverStatus:
    -    """OR-Tools linear_solver (GLOP, SCIP, CBC)."""
    -    from ortools.linear_solver import pywraplp
    -    zuordnung = {
    -        pywraplp.Solver.OPTIMAL: SolverStatus.OPTIMAL,
    -        pywraplp.Solver.FEASIBLE: SolverStatus.ZULAESSIG,
    -        pywraplp.Solver.INFEASIBLE: SolverStatus.UNZULAESSIG,
    -        pywraplp.Solver.UNBOUNDED: SolverStatus.UNBESCHRAENKT,
    -        pywraplp.Solver.ABNORMAL: SolverStatus.FEHLERHAFT,
    -        pywraplp.Solver.NOT_SOLVED: SolverStatus.ZEITLIMIT,
    -    }
    -    return zuordnung.get(rohstatus, SolverStatus.UNBEKANNT)
    -
    -
    -def status_von_cpsat(rohstatus: int) -> SolverStatus:
    -    """OR-Tools CP-SAT (siehe Kapitel CP-SAT, alle fuenf Faelle)."""
    -    from ortools.sat.python import cp_model
    -    zuordnung = {
    -        cp_model.OPTIMAL: SolverStatus.OPTIMAL,
    -        cp_model.FEASIBLE: SolverStatus.ZULAESSIG,
    -        cp_model.INFEASIBLE: SolverStatus.UNZULAESSIG,
    -        cp_model.MODEL_INVALID: SolverStatus.FEHLERHAFT,
    -        cp_model.UNKNOWN: SolverStatus.ZEITLIMIT,
    -    }
    -    return zuordnung.get(rohstatus, SolverStatus.UNBEKANNT)
    -
    -
    -def status_von_highs(statustext: str) -> SolverStatus:
    -    """HiGHS ueber highspy - hier kommt der Status als Text."""
    -    zuordnung = {
    -        "Optimal": SolverStatus.OPTIMAL,
    -        "Time limit reached": SolverStatus.ZULAESSIG,   # meist mit Loesung
    -        "Solution limit reached": SolverStatus.ZULAESSIG,
    -        "Infeasible": SolverStatus.UNZULAESSIG,
    -        "Unbounded": SolverStatus.UNBESCHRAENKT,
    -        "Primal infeasible or unbounded": SolverStatus.UNZULAESSIG,
    -        "Model error": SolverStatus.FEHLERHAFT,
    -    }
    -    return zuordnung.get(statustext, SolverStatus.UNBEKANNT)
    -
    -
    -def status_von_scipy(ergebnis: Any) -> SolverStatus:
    -    """scipy.optimize.linprog und milp liefern ein Ergebnisobjekt mit .status."""
    -    zuordnung = {
    -        0: SolverStatus.OPTIMAL,
    -        1: SolverStatus.ZEITLIMIT,        # Iterations-/Zeitgrenze
    -        2: SolverStatus.UNZULAESSIG,
    -        3: SolverStatus.UNBESCHRAENKT,
    -        4: SolverStatus.FEHLERHAFT,       # numerische Schwierigkeiten
    -    }
    -    return zuordnung.get(int(ergebnis.status), SolverStatus.UNBEKANNT)
    -
    -
    -def status_von_cvxpy(statustext: str) -> SolverStatus:
    -    """CVXPY - Statuszeichenketten wie 'optimal' oder 'infeasible'."""
    -    zuordnung = {
    -        "optimal": SolverStatus.OPTIMAL,
    -        "optimal_inaccurate": SolverStatus.ZULAESSIG,
    -        "infeasible": SolverStatus.UNZULAESSIG,
    -        "infeasible_inaccurate": SolverStatus.UNZULAESSIG,
    -        "unbounded": SolverStatus.UNBESCHRAENKT,
    -        "unbounded_inaccurate": SolverStatus.UNBESCHRAENKT,
    -    }
    -    return zuordnung.get(statustext, SolverStatus.UNBEKANNT)
    -
    -
    -# --- 2. Die Loesung als solverunabhaengiges Datenobjekt ----------------------
    -
    -class Loesung(BaseModel):
    -    """Das Ergebnis eines Solverlaufs, so wie es weiterverarbeitet wird.
    -
    -    Bewusst OHNE Referenz auf Solver, Modell oder Variablen: Ein Bericht, ein
    -    Test oder eine Weiterverarbeitung soll nicht wissen muessen, womit
    -    gerechnet wurde.
    -    """
    -    status: SolverStatus
    -    werte: dict[str, float] = Field(default_factory=dict)
    -    zielwert: float | None = None
    -    schranke: float | None = None
    -    laufzeit: float | None = None
    -    # Schattenpreise je Nebenbedingung, sofern der Solver sie liefert
    -    # (nur bei kontinuierlichen Problemen, siehe Kapitel LP). Auch das ist
    -    # ein solverunabhaengiger Begriff und gehoert deshalb hierher.
    -    schattenpreise: dict[str, float] = Field(default_factory=dict)
    -
    -    @property
    -    def gap(self) -> float | None:
    -        """Relativer Abstand zwischen gefundener Loesung und bewiesener Schranke.
    -
    -        Das ist die Zahl fuer den Bericht: keine Schaetzung, sondern eine
    -        Zusage - schlechter als das kann die Loesung nicht sein.
    -        """
    -        if self.zielwert is None or self.schranke is None:
    -            return None
    -        nenner = max(abs(self.zielwert), 1e-9)
    -        return abs(self.zielwert - self.schranke) / nenner
    -
    -    def als_bericht(self) -> str:
    -        """Eine Zeile fuers Betriebsprotokoll - siehe Betriebsueberwachung.py."""
    -        teile = [f"Status: {self.status.value}"]
    -        if self.zielwert is not None:
    -            teile.append(f"Zielwert: {self.zielwert:,.2f}")
    -        if self.gap is not None:
    -            teile.append(f"Gap: {self.gap:.2%}")
    -        if self.laufzeit is not None:
    -            teile.append(f"Zeit: {self.laufzeit:.2f}s")
    -        return " | ".join(teile)
    -
    -
    -# --- 3. Domaenenmodell: die Fakten, bevor ein Solver sie sieht ---------------
    -
    -class Produkt(BaseModel):
    -    """Ein Produkt mit Deckungsbeitrag und Ressourcenverbrauch."""
    -    name: str = Field(min_length=1)
    -    deckungsbeitrag: float
    -    verbrauch: dict[str, NichtNegativ]
    -
    -    model_config = {"frozen": True}      # Stammdaten aendern sich nicht im Lauf
    -
    -
    -class Produktionsproblem(BaseModel):
    -    """Produktionsprogrammplanung - das Modell aus Kapitel Einfuehrung.
    -
    -    Die Pruefungen stehen hier und nicht im Solvercode. Damit gelten sie
    -    unabhaengig davon, mit welcher Bibliothek spaeter gerechnet wird - und sie
    -    schlagen beim EINLESEN zu, wo der Fehler noch zuzuordnen ist.
    -    """
    -    produkte: list[Produkt] = Field(min_length=1)
    -    kapazitaeten: dict[str, PositiveZahl]
    -
    -    @model_validator(mode="after")
    -    def pruefe_ressourcen(self) -> "Produktionsproblem":
    -        namen = [p.name for p in self.produkte]
    -        if len(set(namen)) != len(namen):
    -            doppelt = sorted({n for n in namen if namen.count(n) > 1})
    -            raise ValueError(f"Produktnamen kommen mehrfach vor: {doppelt}")
    -
    -        for produkt in self.produkte:
    -            unbekannt = set(produkt.verbrauch) - set(self.kapazitaeten)
    -            if unbekannt:
    -                raise ValueError(
    -                    f"Produkt '{produkt.name}' verbraucht Ressourcen ohne "
    -                    f"Kapazitaetsangabe: {sorted(unbekannt)}")
    -        return self
    -
    -    @property
    -    def ressourcen(self) -> list[str]:
    -        """Feste Reihenfolge - siehe die Spaltenfalle in Kapitel Finanzdaten."""
    -        return sorted(self.kapazitaeten)
    -
    -    def verbrauchsmatrix(self) -> np.ndarray:
    -        """Zeilen = Ressourcen, Spalten = Produkte (Matrixform, Kapitel Fundament)."""
    -        return np.array([[p.verbrauch.get(r, 0.0) for p in self.produkte]
    -                         for r in self.ressourcen])
    -
    -    def kapazitaetsvektor(self) -> np.ndarray:
    -        return np.array([self.kapazitaeten[r] for r in self.ressourcen])
    -
    -    def deckungsbeitragsvektor(self) -> np.ndarray:
    -        return np.array([p.deckungsbeitrag for p in self.produkte])
    -
    -
    -# --- 4. Abnahmepruefung: Loesung gegen Anforderung, ohne Solver --------------
    -
    -def pruefe_loesung(problem: Produktionsproblem, loesung: Loesung,
    -                   ganzzahlig: Sequence[str] = (),
    -                   toleranz: float = 1e-6) -> list[str]:
    -    """Prueft eine Loesung gegen die Anforderungen - OHNE den Solver zu fragen.
    -
    -    Diese Funktion darf ausdruecklich KEIN Solverobjekt und keine
    -    Modellvariable benutzen. Eine Pruefung aus denselben Bausteinen wie das
    -    Modell prueft das Modell gegen sich selbst und findet nichts (siehe die
    -    Denkfehler in den Kapiteln Graphen und MILP).
    -
    -    Liefert eine Liste von Beanstandungen; leer heisst bestanden.
    -    """
    -    beanstandungen: list[str] = []
    -
    -    if not loesung.status.brauchbar:
    -        return [f"Kein verwertbares Ergebnis (Status: {loesung.status.value})"]
    -
    -    fehlend = [p.name for p in problem.produkte if p.name not in loesung.werte]
    -    if fehlend:
    -        return [f"Loesung enthaelt keine Werte fuer: {fehlend}"]
    -
    -    mengen = np.array([loesung.werte[p.name] for p in problem.produkte])
    -
    -    if (mengen < -toleranz).any():
    -        negativ = [p.name for p, m in zip(problem.produkte, mengen) if m < -toleranz]
    -        beanstandungen.append(f"negative Mengen bei: {negativ}")
    -
    -    verbrauch = problem.verbrauchsmatrix() @ mengen
    -    for ressource, ist, grenze in zip(problem.ressourcen, verbrauch,
    -                                      problem.kapazitaetsvektor()):
    -        if ist > grenze + toleranz:
    -            beanstandungen.append(
    -                f"{ressource}: Verbrauch {ist:,.3f} ueber Kapazitaet {grenze:,.3f}")
    -
    -    for name in ganzzahlig:
    -        wert = loesung.werte[name]
    -        if abs(wert - round(wert)) > toleranz:
    -            beanstandungen.append(f"'{name}' = {wert!r} ist nicht ganzzahlig")
    -
    -    if loesung.zielwert is not None:
    -        nachgerechnet = float(problem.deckungsbeitragsvektor() @ mengen)
    -        if abs(nachgerechnet - loesung.zielwert) > toleranz * max(1.0, abs(nachgerechnet)):
    -            beanstandungen.append(
    -                f"Zielwert {loesung.zielwert:,.4f} passt nicht zu den Mengen "
    -                f"(nachgerechnet {nachgerechnet:,.4f})")
    -
    -    return beanstandungen
    -
    -
    -# --- 5. Ein Modellbauer je Solver - austauschbar ----------------------------
    -
    -def loese_mit_glop(problem: Produktionsproblem) -> Loesung:
    -    """Modellbauer fuer OR-Tools GLOP (kontinuierlich)."""
    -    import time
    -    from ortools.linear_solver import pywraplp
    -
    -    solver = pywraplp.Solver.CreateSolver("GLOP")
    -    menge = {p.name: solver.NumVar(0, solver.infinity(), p.name)
    -             for p in problem.produkte}
    -    # Die Nebenbedingung wird gemerkt - ohne die Referenz gibt es spaeter
    -    # keinen Schattenpreis (siehe Kapitel LP).
    -    bedingung = {}
    -    for ressource in problem.ressourcen:
    -        bedingung[ressource] = solver.Add(
    -            sum(menge[p.name] * p.verbrauch.get(ressource, 0.0)
    -                for p in problem.produkte)
    -            <= problem.kapazitaeten[ressource], name=ressource)
    -    solver.Maximize(sum(menge[p.name] * p.deckungsbeitrag for p in problem.produkte))
    -
    -    t0 = time.perf_counter()
    -    rohstatus = solver.Solve()
    -    laufzeit = time.perf_counter() - t0
    -
    -    status = status_von_pywraplp(rohstatus)
    -    if not status.brauchbar:
    -        return Loesung(status=status, laufzeit=laufzeit)
    -    return Loesung(status=status,
    -                   werte={name: v.solution_value() for name, v in menge.items()},
    -                   zielwert=solver.Objective().Value(),
    -                   schranke=solver.Objective().Value(),
    -                   schattenpreise={r: b.dual_value() for r, b in bedingung.items()},
    -                   laufzeit=laufzeit)
    -
    -
    -def loese_mit_scipy(problem: Produktionsproblem) -> Loesung:
    -    """Derselbe Fall mit scipy.optimize.linprog - anderer Solver, gleiche Loesung.
    -
    -    Beachten Sie, wie wenig hier steht: Die Daten kommen fertig geprueft aus
    -    dem Domaenenmodell, die Auswertung geht ans gemeinsame Loesung-Objekt.
    -    Genau das ist der Ertrag der Trennung.
    -    """
    -    import time
    -    from scipy.optimize import linprog
    -
    -    t0 = time.perf_counter()
    -    ergebnis = linprog(c=-problem.deckungsbeitragsvektor(),      # linprog minimiert
    -                       A_ub=problem.verbrauchsmatrix(),
    -                       b_ub=problem.kapazitaetsvektor(),
    -                       bounds=(0, None), method="highs")
    -    laufzeit = time.perf_counter() - t0
    -
    -    status = status_von_scipy(ergebnis)
    -    if not status.brauchbar:
    -        return Loesung(status=status, laufzeit=laufzeit)
    -    # linprog rechnet minimierend mit negierten Kosten - die Dualwerte
    -    # muessen entsprechend zurueckgedreht werden.
    -    return Loesung(status=status,
    -                   werte={p.name: float(x)
    -                          for p, x in zip(problem.produkte, ergebnis.x)},
    -                   zielwert=float(-ergebnis.fun),
    -                   schranke=float(-ergebnis.fun),
    -                   schattenpreise={r: float(-m) for r, m in
    -                                   zip(problem.ressourcen,
    -                                       ergebnis.ineqlin.marginals)},
    -                   laufzeit=laufzeit)
    -
    -
    -# --- 6. Ein- und Ausgabe: Tabellen sind die Wirklichkeit ---------------------
    -#
    -# In den meisten Betrieben liegen die Zahlen in einer Tabellenkalkulation
    -# (Kapitel Einfuehrung). Diese beiden Funktionen sind die einzige Stelle im
    -# Modul, die das weiss - alles andere arbeitet mit dem Domaenenmodell.
    -#
    -# pandas und openpyxl werden bewusst erst hier importiert: Wer or_kern nur
    -# fuer Statusauswertung und Pruefung benutzt, soll sie nicht installieren
    -# muessen.
    -
    -STAMMSPALTEN = ("Produkt", "Deckungsbeitrag")
    -
    -
    -def lade_produktionsproblem(pfad: str, blatt_produkte: str = "Produkte",
    -                            blatt_kapazitaeten: str = "Kapazitaeten"
    -                            ) -> Produktionsproblem:
    -    """Liest eine Excel-Mappe und liefert ein geprueftes Domaenenmodell.
    -
    -    Alle Spalten ausser 'Produkt' und 'Deckungsbeitrag' gelten als
    -    Verbrauchsspalten - eine neue Ressource ist damit eine neue SPALTE in der
    -    Mappe und erfordert keine Codeaenderung.
    -
    -    Die eigentliche Pruefung passiert nicht hier, sondern im Konstruktor von
    -    Produktionsproblem. Diese Funktion uebersetzt nur Tabelle in Objekt; was
    -    ein gueltiges Problem ist, steht an genau einer Stelle.
    -    """
    -    import pandas as pd
    -
    -    produkte_tabelle = pd.read_excel(pfad, sheet_name=blatt_produkte)
    -    kapazitaeten_tabelle = pd.read_excel(pfad, sheet_name=blatt_kapazitaeten)
    -
    -    fehlende = [s for s in STAMMSPALTEN if s not in produkte_tabelle.columns]
    -    if fehlende:
    -        raise ValueError(f"Blatt '{blatt_produkte}': Spalten fehlen: {fehlende}")
    -    if produkte_tabelle.isna().any().any():
    -        zeilen = produkte_tabelle[produkte_tabelle.isna().any(axis=1)].index.tolist()
    -        raise ValueError(f"Blatt '{blatt_produkte}': leere Zellen in Zeile(n) {zeilen}")
    -
    -    ressourcen = [s for s in produkte_tabelle.columns if s not in STAMMSPALTEN]
    -    produkte = [
    -        Produkt(name=str(zeile.Produkt),
    -                deckungsbeitrag=float(zeile.Deckungsbeitrag),
    -                verbrauch={r: float(getattr(zeile, r)) for r in ressourcen})
    -        for zeile in produkte_tabelle.itertuples()
    -    ]
    -    kapazitaeten = {str(z.Ressource): float(z.Verfuegbar)
    -                    for z in kapazitaeten_tabelle.itertuples()}
    -
    -    return Produktionsproblem(produkte=produkte, kapazitaeten=kapazitaeten)
    -
    -
    -def schreibe_ergebnis(pfad: str, problem: Produktionsproblem,
    -                      loesung: Loesung) -> None:
    -    """Schreibt Plan und Kennzahlen als zwei Blaetter zurueck nach Excel.
    -
    -    Die Fachabteilung bekommt das Ergebnis im Format, das sie ohnehin benutzt.
    -    Akzeptanz entscheidet ueber Projekterfolg (Kapitel Praxisfallen).
    -    """
    -    import pandas as pd
    -
    -    if not loesung.status.brauchbar:
    -        raise ValueError(f"Nichts zu schreiben - Status: {loesung.status.value}")
    -
    -    mengen = np.array([loesung.werte[p.name] for p in problem.produkte])
    -    plan = pd.DataFrame({
    -        "Produkt": [p.name for p in problem.produkte],
    -        "Menge": mengen,
    -        "Deckungsbeitrag": mengen * problem.deckungsbeitragsvektor(),
    -    })
    -
    -    verbrauch = problem.verbrauchsmatrix() @ mengen
    -    kennzahlen: dict[str, float | str] = {
    -        "Status": loesung.status.value,
    -        "Gesamtdeckungsbeitrag": loesung.zielwert or 0.0,
    -    }
    -    for ressource, ist, grenze in zip(problem.ressourcen, verbrauch,
    -                                      problem.kapazitaetsvektor()):
    -        kennzahlen[f"{ressource}: Verbrauch"] = float(ist)
    -        kennzahlen[f"{ressource}: Auslastung %"] = float(100.0 * ist / grenze)
    -        if ressource in loesung.schattenpreise:
    -            kennzahlen[f"{ressource}: Schattenpreis"] = \
    -                loesung.schattenpreise[ressource]
    -
    -    kennzahlen_tabelle = pd.DataFrame({
    -        "Kennzahl": list(kennzahlen),
    -        "Wert": [round(w, 2) if isinstance(w, float) else w
    -                 for w in kennzahlen.values()],
    -    })
    -
    -    with pd.ExcelWriter(pfad, engine="openpyxl") as mappe:
    -        plan.round(2).to_excel(mappe, sheet_name="Plan", index=False)
    -        kennzahlen_tabelle.to_excel(mappe, sheet_name="Kennzahlen", index=False)
    -
    -
    -if __name__ == "__main__":
    -    # Die Schreinerei aus Kapitel Einfuehrung - jetzt als Domaenenmodell.
    -    schreinerei = Produktionsproblem(
    -        produkte=[
    -            Produkt(name="Tisch", deckungsbeitrag=240.0,
    -                    verbrauch={"Montagestunden": 3.0, "Plattenmaterial": 6.0}),
    -            Produkt(name="Stuhl", deckungsbeitrag=60.0,
    -                    verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0}),
    -        ],
    -        kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0},
    -    )
    -
    -    print("=" * 78)
    -    print("  DERSELBE FALL, ZWEI SOLVER, EIN AUSWERTUNGSCODE")
    -    print("=" * 78)
    -
    -    for name, bauer in [("OR-Tools GLOP", loese_mit_glop),
    -                        ("scipy / HiGHS", loese_mit_scipy)]:
    -        loesung = bauer(schreinerei)
    -        beanstandungen = pruefe_loesung(schreinerei, loesung)
    -        print(f"\n{name}")
    -        print(f"  {loesung.als_bericht()}")
    -        for produkt, menge in loesung.werte.items():
    -            print(f"    {produkt:<10} {menge:8.2f}")
    -        print(f"  Abnahmepruefung: "
    -              f"{'bestanden' if not beanstandungen else beanstandungen}")
    -
    -    print("\n" + "=" * 78)
    -    print("  WAS DIE PRUEFUNGEN ABFANGEN")
    -    print("=" * 78)
    -
    -    faelle = [
    -        ("Kapazitaet 0", dict(
    -            produkte=schreinerei.produkte,
    -            kapazitaeten={"Montagestunden": 0.0, "Plattenmaterial": 240.0})),
    -        ("Ressource ohne Kapazitaet", dict(
    -            produkte=[Produkt(name="Regal", deckungsbeitrag=130.0,
    -                              verbrauch={"Lackieren": 2.0})],
    -            kapazitaeten={"Montagestunden": 150.0})),
    -        ("doppelter Produktname", dict(
    -            produkte=[schreinerei.produkte[0], schreinerei.produkte[0]],
    -            kapazitaeten=schreinerei.kapazitaeten)),
    -    ]
    -    for beschreibung, daten in faelle:
    -        try:
    -            Produktionsproblem(**daten)
    -            print(f"  {beschreibung:<28} NICHT erkannt (!)")
    -        except Exception as fehler:
    -            meldung = str(fehler).splitlines()
    -            kern = next((z.strip() for z in meldung
    -                         if "Value error" in z or "greater than" in z), meldung[-1])
    -            print(f"  {beschreibung:<28} abgefangen: {kern[:44]}")
    -
    -    print("\nAlle drei scheitern beim EINLESEN - nicht erst beim Loesen und")
    -    print("schon gar nicht erst im Bericht. Das ist der ganze Zweck der")
    -    print("Domaenenschicht.")
    -    print("=" * 78)
    +
    #!/usr/bin/env python3
    +
    +# or_kern.py
    +"""
    +Kapitel Praxisfallen: Der gemeinsame Unterbau fuer produktionsreife OR-Modelle.
    +
    +Alle Beispiele dieses Buchs loesen dieselben vier Aufgaben immer wieder:
    +Daten einlesen und pruefen, ein Modell bauen, den Solverstatus auswerten, die
    +Loesung gegen die Wirklichkeit kontrollieren. Dieses Modul zieht diese vier
    +Aufgaben aus den Einzelprogrammen heraus.
    +
    +    Rohdaten (Excel/CSV)
    +        -> Domaenenmodell (Pydantic, geprueft)
    +            -> Modellbauer (solverabhaengig)
    +                -> Loesung (DTO, solverunabhaengig)
    +                    -> Abnahmepruefung
    +
    +Der Sinn dieser Trennung: Die Domaenenschicht weiss nichts von Solvern, die
    +Loesungsschicht weiss nichts vom Modellaufbau. Wer den Solver wechselt, tauscht
    +genau EINEN Baustein aus - der Rest bleibt.
    +
    +WICHTIG ZUM IMPORT: Dieses Modul importiert von sich aus KEINE Solver-
    +bibliothek. Der Grund steht im Kapitel Oekosystem: ortools und highspy vertragen sich
    +nicht im selben Prozess. Die Statusuebersetzer laden ihre Bibliothek erst,
    +wenn sie aufgerufen werden - so bleibt or_kern.py mit jedem Solver benutzbar.
    +
    +Benoetigt: pydantic (v2), numpy; optional pandas und openpyxl fuer Excel.
    +"""
    +
    +from __future__ import annotations
    +
    +from enum import Enum
    +from typing import Annotated, Any, Sequence
    +
    +import numpy as np
    +from pydantic import BaseModel, Field, model_validator
    +
    +# Wiederverwendbare Feldtypen. Sie tragen die Pruefung im Typ, nicht im Code -
    +# damit gilt sie ueberall, wo der Typ verwendet wird.
    +PositiveZahl = Annotated[float, Field(gt=0)]
    +NichtNegativ = Annotated[float, Field(ge=0)]
    +
    +
    +# --- 1. Solverstatus: eine Sprache fuer fuenf Bibliotheken -------------------
    +
    +class SolverStatus(str, Enum):
    +    """Was ein Solverlauf ergeben hat - unabhaengig davon, wer gerechnet hat.
    +
    +    Die fuenf im Buch verwendeten Bibliotheken benennen dasselbe Ergebnis
    +    unterschiedlich: 'Optimal', 'OPTIMAL', 2, 'optimal'. Wer darauf direkt
    +    prueft, bindet seinen Auswertungscode an eine Bibliothek.
    +    """
    +    OPTIMAL = "optimal"              # beweisbar bestmoeglich
    +    ZULAESSIG = "zulaessig"          # brauchbare Loesung, Beweis fehlt
    +    UNZULAESSIG = "unzulaessig"      # es gibt keine Loesung (Aussage ueber das Modell)
    +    UNBESCHRAENKT = "unbeschraenkt"  # Ziel waechst ins Unendliche
    +    ZEITLIMIT = "zeitlimit"          # abgebrochen, nichts Brauchbares gefunden
    +    FEHLERHAFT = "fehlerhaft"        # das Modell ist kein gueltiges Modell
    +    UNBEKANNT = "unbekannt"          # alles Uebrige
    +
    +    @property
    +    def brauchbar(self) -> bool:
    +        """Habe ich etwas in der Hand, das ich ausfuehren kann?"""
    +        return self in (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG)
    +
    +    @property
    +    def modellfehler(self) -> bool:
    +        """Liegt die Ursache im Modell (und nicht in der Rechenzeit)?"""
    +        return self in (SolverStatus.UNZULAESSIG, SolverStatus.UNBESCHRAENKT,
    +                        SolverStatus.FEHLERHAFT)
    +
    +
    +def status_von_pywraplp(rohstatus: int) -> SolverStatus:
    +    """OR-Tools linear_solver (GLOP, SCIP, CBC)."""
    +    from ortools.linear_solver import pywraplp
    +    zuordnung = {
    +        pywraplp.Solver.OPTIMAL: SolverStatus.OPTIMAL,
    +        pywraplp.Solver.FEASIBLE: SolverStatus.ZULAESSIG,
    +        pywraplp.Solver.INFEASIBLE: SolverStatus.UNZULAESSIG,
    +        pywraplp.Solver.UNBOUNDED: SolverStatus.UNBESCHRAENKT,
    +        pywraplp.Solver.ABNORMAL: SolverStatus.FEHLERHAFT,
    +        pywraplp.Solver.NOT_SOLVED: SolverStatus.ZEITLIMIT,
    +    }
    +    return zuordnung.get(rohstatus, SolverStatus.UNBEKANNT)
    +
    +
    +def status_von_cpsat(rohstatus: int) -> SolverStatus:
    +    """OR-Tools CP-SAT (siehe Kapitel CP-SAT, alle fuenf Faelle)."""
    +    from ortools.sat.python import cp_model
    +    zuordnung = {
    +        cp_model.OPTIMAL: SolverStatus.OPTIMAL,
    +        cp_model.FEASIBLE: SolverStatus.ZULAESSIG,
    +        cp_model.INFEASIBLE: SolverStatus.UNZULAESSIG,
    +        cp_model.MODEL_INVALID: SolverStatus.FEHLERHAFT,
    +        cp_model.UNKNOWN: SolverStatus.ZEITLIMIT,
    +    }
    +    return zuordnung.get(rohstatus, SolverStatus.UNBEKANNT)
    +
    +
    +def status_von_highs(statustext: str) -> SolverStatus:
    +    """HiGHS ueber highspy - hier kommt der Status als Text."""
    +    zuordnung = {
    +        "Optimal": SolverStatus.OPTIMAL,
    +        "Time limit reached": SolverStatus.ZULAESSIG,   # meist mit Loesung
    +        "Solution limit reached": SolverStatus.ZULAESSIG,
    +        "Infeasible": SolverStatus.UNZULAESSIG,
    +        "Unbounded": SolverStatus.UNBESCHRAENKT,
    +        "Primal infeasible or unbounded": SolverStatus.UNZULAESSIG,
    +        "Model error": SolverStatus.FEHLERHAFT,
    +    }
    +    return zuordnung.get(statustext, SolverStatus.UNBEKANNT)
    +
    +
    +def status_von_scipy(ergebnis: Any) -> SolverStatus:
    +    """scipy.optimize.linprog und milp liefern ein Ergebnisobjekt mit .status."""
    +    zuordnung = {
    +        0: SolverStatus.OPTIMAL,
    +        1: SolverStatus.ZEITLIMIT,        # Iterations-/Zeitgrenze
    +        2: SolverStatus.UNZULAESSIG,
    +        3: SolverStatus.UNBESCHRAENKT,
    +        4: SolverStatus.FEHLERHAFT,       # numerische Schwierigkeiten
    +    }
    +    return zuordnung.get(int(ergebnis.status), SolverStatus.UNBEKANNT)
    +
    +
    +def status_von_cvxpy(statustext: str) -> SolverStatus:
    +    """CVXPY - Statuszeichenketten wie 'optimal' oder 'infeasible'."""
    +    zuordnung = {
    +        "optimal": SolverStatus.OPTIMAL,
    +        "optimal_inaccurate": SolverStatus.ZULAESSIG,
    +        "infeasible": SolverStatus.UNZULAESSIG,
    +        "infeasible_inaccurate": SolverStatus.UNZULAESSIG,
    +        "unbounded": SolverStatus.UNBESCHRAENKT,
    +        "unbounded_inaccurate": SolverStatus.UNBESCHRAENKT,
    +    }
    +    return zuordnung.get(statustext, SolverStatus.UNBEKANNT)
    +
    +
    +# --- 2. Die Loesung als solverunabhaengiges Datenobjekt ----------------------
    +
    +class Loesung(BaseModel):
    +    """Das Ergebnis eines Solverlaufs, so wie es weiterverarbeitet wird.
    +
    +    Bewusst OHNE Referenz auf Solver, Modell oder Variablen: Ein Bericht, ein
    +    Test oder eine Weiterverarbeitung soll nicht wissen muessen, womit
    +    gerechnet wurde.
    +    """
    +    status: SolverStatus
    +    werte: dict[str, float] = Field(default_factory=dict)
    +    zielwert: float | None = None
    +    schranke: float | None = None
    +    laufzeit: float | None = None
    +    # Schattenpreise je Nebenbedingung, sofern der Solver sie liefert
    +    # (nur bei kontinuierlichen Problemen, siehe Kapitel LP). Auch das ist
    +    # ein solverunabhaengiger Begriff und gehoert deshalb hierher.
    +    schattenpreise: dict[str, float] = Field(default_factory=dict)
    +
    +    @property
    +    def gap(self) -> float | None:
    +        """Relativer Abstand zwischen gefundener Loesung und bewiesener Schranke.
    +
    +        Das ist die Zahl fuer den Bericht: keine Schaetzung, sondern eine
    +        Zusage - schlechter als das kann die Loesung nicht sein.
    +        """
    +        if self.zielwert is None or self.schranke is None:
    +            return None
    +        nenner = max(abs(self.zielwert), 1e-9)
    +        return abs(self.zielwert - self.schranke) / nenner
    +
    +    def als_bericht(self) -> str:
    +        """Eine Zeile fuers Betriebsprotokoll - siehe Betriebsueberwachung.py."""
    +        teile = [f"Status: {self.status.value}"]
    +        if self.zielwert is not None:
    +            teile.append(f"Zielwert: {self.zielwert:,.2f}")
    +        if self.gap is not None:
    +            teile.append(f"Gap: {self.gap:.2%}")
    +        if self.laufzeit is not None:
    +            teile.append(f"Zeit: {self.laufzeit:.2f}s")
    +        return " | ".join(teile)
    +
    +
    +# --- 3. Domaenenmodell: die Fakten, bevor ein Solver sie sieht ---------------
    +
    +class Produkt(BaseModel):
    +    """Ein Produkt mit Deckungsbeitrag und Ressourcenverbrauch."""
    +    name: str = Field(min_length=1)
    +    deckungsbeitrag: float
    +    verbrauch: dict[str, NichtNegativ]
    +
    +    model_config = {"frozen": True}      # Stammdaten aendern sich nicht im Lauf
    +
    +
    +class Produktionsproblem(BaseModel):
    +    """Produktionsprogrammplanung - das Modell aus Kapitel Einfuehrung.
    +
    +    Die Pruefungen stehen hier und nicht im Solvercode. Damit gelten sie
    +    unabhaengig davon, mit welcher Bibliothek spaeter gerechnet wird - und sie
    +    schlagen beim EINLESEN zu, wo der Fehler noch zuzuordnen ist.
    +    """
    +    produkte: list[Produkt] = Field(min_length=1)
    +    kapazitaeten: dict[str, PositiveZahl]
    +
    +    @model_validator(mode="after")
    +    def pruefe_ressourcen(self) -> "Produktionsproblem":
    +        namen = [p.name for p in self.produkte]
    +        if len(set(namen)) != len(namen):
    +            doppelt = sorted({n for n in namen if namen.count(n) > 1})
    +            raise ValueError(f"Produktnamen kommen mehrfach vor: {doppelt}")
    +
    +        for produkt in self.produkte:
    +            unbekannt = set(produkt.verbrauch) - set(self.kapazitaeten)
    +            if unbekannt:
    +                raise ValueError(
    +                    f"Produkt '{produkt.name}' verbraucht Ressourcen ohne "
    +                    f"Kapazitaetsangabe: {sorted(unbekannt)}")
    +        return self
    +
    +    @property
    +    def ressourcen(self) -> list[str]:
    +        """Feste Reihenfolge - siehe die Spaltenfalle in Kapitel Finanzdaten."""
    +        return sorted(self.kapazitaeten)
    +
    +    def verbrauchsmatrix(self) -> np.ndarray:
    +        """Zeilen = Ressourcen, Spalten = Produkte (Matrixform, Kapitel Fundament)."""
    +        return np.array([[p.verbrauch.get(r, 0.0) for p in self.produkte]
    +                         for r in self.ressourcen])
    +
    +    def kapazitaetsvektor(self) -> np.ndarray:
    +        return np.array([self.kapazitaeten[r] for r in self.ressourcen])
    +
    +    def deckungsbeitragsvektor(self) -> np.ndarray:
    +        return np.array([p.deckungsbeitrag for p in self.produkte])
    +
    +
    +# --- 4. Abnahmepruefung: Loesung gegen Anforderung, ohne Solver --------------
    +
    +def pruefe_loesung(problem: Produktionsproblem, loesung: Loesung,
    +                   ganzzahlig: Sequence[str] = (),
    +                   toleranz: float = 1e-6) -> list[str]:
    +    """Prueft eine Loesung gegen die Anforderungen - OHNE den Solver zu fragen.
    +
    +    Diese Funktion darf ausdruecklich KEIN Solverobjekt und keine
    +    Modellvariable benutzen. Eine Pruefung aus denselben Bausteinen wie das
    +    Modell prueft das Modell gegen sich selbst und findet nichts (siehe die
    +    Denkfehler in den Kapiteln Graphen und MILP).
    +
    +    Liefert eine Liste von Beanstandungen; leer heisst bestanden.
    +    """
    +    beanstandungen: list[str] = []
    +
    +    if not loesung.status.brauchbar:
    +        return [f"Kein verwertbares Ergebnis (Status: {loesung.status.value})"]
    +
    +    fehlend = [p.name for p in problem.produkte if p.name not in loesung.werte]
    +    if fehlend:
    +        return [f"Loesung enthaelt keine Werte fuer: {fehlend}"]
    +
    +    mengen = np.array([loesung.werte[p.name] for p in problem.produkte])
    +
    +    if (mengen < -toleranz).any():
    +        negativ = [p.name for p, m in zip(problem.produkte, mengen) if m < -toleranz]
    +        beanstandungen.append(f"negative Mengen bei: {negativ}")
    +
    +    verbrauch = problem.verbrauchsmatrix() @ mengen
    +    for ressource, ist, grenze in zip(problem.ressourcen, verbrauch,
    +                                      problem.kapazitaetsvektor()):
    +        if ist > grenze + toleranz:
    +            beanstandungen.append(
    +                f"{ressource}: Verbrauch {ist:,.3f} ueber Kapazitaet {grenze:,.3f}")
    +
    +    for name in ganzzahlig:
    +        wert = loesung.werte[name]
    +        if abs(wert - round(wert)) > toleranz:
    +            beanstandungen.append(f"'{name}' = {wert!r} ist nicht ganzzahlig")
    +
    +    if loesung.zielwert is not None:
    +        nachgerechnet = float(problem.deckungsbeitragsvektor() @ mengen)
    +        if abs(nachgerechnet - loesung.zielwert) > toleranz * max(1.0, abs(nachgerechnet)):
    +            beanstandungen.append(
    +                f"Zielwert {loesung.zielwert:,.4f} passt nicht zu den Mengen "
    +                f"(nachgerechnet {nachgerechnet:,.4f})")
    +
    +    return beanstandungen
    +
    +
    +# --- 5. Ein Modellbauer je Solver - austauschbar ----------------------------
    +
    +def loese_mit_glop(problem: Produktionsproblem) -> Loesung:
    +    """Modellbauer fuer OR-Tools GLOP (kontinuierlich)."""
    +    import time
    +    from ortools.linear_solver import pywraplp
    +
    +    solver = pywraplp.Solver.CreateSolver("GLOP")
    +    menge = {p.name: solver.NumVar(0, solver.infinity(), p.name)
    +             for p in problem.produkte}
    +    # Die Nebenbedingung wird gemerkt - ohne die Referenz gibt es spaeter
    +    # keinen Schattenpreis (siehe Kapitel LP).
    +    bedingung = {}
    +    for ressource in problem.ressourcen:
    +        bedingung[ressource] = solver.Add(
    +            sum(menge[p.name] * p.verbrauch.get(ressource, 0.0)
    +                for p in problem.produkte)
    +            <= problem.kapazitaeten[ressource], name=ressource)
    +    solver.Maximize(sum(menge[p.name] * p.deckungsbeitrag for p in problem.produkte))
    +
    +    t0 = time.perf_counter()
    +    rohstatus = solver.Solve()
    +    laufzeit = time.perf_counter() - t0
    +
    +    status = status_von_pywraplp(rohstatus)
    +    if not status.brauchbar:
    +        return Loesung(status=status, laufzeit=laufzeit)
    +    return Loesung(status=status,
    +                   werte={name: v.solution_value() for name, v in menge.items()},
    +                   zielwert=solver.Objective().Value(),
    +                   schranke=solver.Objective().Value(),
    +                   schattenpreise={r: b.dual_value() for r, b in bedingung.items()},
    +                   laufzeit=laufzeit)
    +
    +
    +def loese_mit_scipy(problem: Produktionsproblem) -> Loesung:
    +    """Derselbe Fall mit scipy.optimize.linprog - anderer Solver, gleiche Loesung.
    +
    +    Beachten Sie, wie wenig hier steht: Die Daten kommen fertig geprueft aus
    +    dem Domaenenmodell, die Auswertung geht ans gemeinsame Loesung-Objekt.
    +    Genau das ist der Ertrag der Trennung.
    +    """
    +    import time
    +    from scipy.optimize import linprog
    +
    +    t0 = time.perf_counter()
    +    ergebnis = linprog(c=-problem.deckungsbeitragsvektor(),      # linprog minimiert
    +                       A_ub=problem.verbrauchsmatrix(),
    +                       b_ub=problem.kapazitaetsvektor(),
    +                       bounds=(0, None), method="highs")
    +    laufzeit = time.perf_counter() - t0
    +
    +    status = status_von_scipy(ergebnis)
    +    if not status.brauchbar:
    +        return Loesung(status=status, laufzeit=laufzeit)
    +    # linprog rechnet minimierend mit negierten Kosten - die Dualwerte
    +    # muessen entsprechend zurueckgedreht werden.
    +    return Loesung(status=status,
    +                   werte={p.name: float(x)
    +                          for p, x in zip(problem.produkte, ergebnis.x)},
    +                   zielwert=float(-ergebnis.fun),
    +                   schranke=float(-ergebnis.fun),
    +                   schattenpreise={r: float(-m) for r, m in
    +                                   zip(problem.ressourcen,
    +                                       ergebnis.ineqlin.marginals)},
    +                   laufzeit=laufzeit)
    +
    +
    +# --- 6. Ein- und Ausgabe: Tabellen sind die Wirklichkeit ---------------------
    +#
    +# In den meisten Betrieben liegen die Zahlen in einer Tabellenkalkulation
    +# (Kapitel Einfuehrung). Diese beiden Funktionen sind die einzige Stelle im
    +# Modul, die das weiss - alles andere arbeitet mit dem Domaenenmodell.
    +#
    +# pandas und openpyxl werden bewusst erst hier importiert: Wer or_kern nur
    +# fuer Statusauswertung und Pruefung benutzt, soll sie nicht installieren
    +# muessen.
    +
    +STAMMSPALTEN = ("Produkt", "Deckungsbeitrag")
    +
    +
    +def lade_produktionsproblem(pfad: str, blatt_produkte: str = "Produkte",
    +                            blatt_kapazitaeten: str = "Kapazitaeten"
    +                            ) -> Produktionsproblem:
    +    """Liest eine Excel-Mappe und liefert ein geprueftes Domaenenmodell.
    +
    +    Alle Spalten ausser 'Produkt' und 'Deckungsbeitrag' gelten als
    +    Verbrauchsspalten - eine neue Ressource ist damit eine neue SPALTE in der
    +    Mappe und erfordert keine Codeaenderung.
    +
    +    Die eigentliche Pruefung passiert nicht hier, sondern im Konstruktor von
    +    Produktionsproblem. Diese Funktion uebersetzt nur Tabelle in Objekt; was
    +    ein gueltiges Problem ist, steht an genau einer Stelle.
    +    """
    +    import pandas as pd
    +
    +    produkte_tabelle = pd.read_excel(pfad, sheet_name=blatt_produkte)
    +    kapazitaeten_tabelle = pd.read_excel(pfad, sheet_name=blatt_kapazitaeten)
    +
    +    fehlende = [s for s in STAMMSPALTEN if s not in produkte_tabelle.columns]
    +    if fehlende:
    +        raise ValueError(f"Blatt '{blatt_produkte}': Spalten fehlen: {fehlende}")
    +    if produkte_tabelle.isna().any().any():
    +        zeilen = produkte_tabelle[produkte_tabelle.isna().any(axis=1)].index.tolist()
    +        raise ValueError(f"Blatt '{blatt_produkte}': leere Zellen in Zeile(n) {zeilen}")
    +
    +    ressourcen = [s for s in produkte_tabelle.columns if s not in STAMMSPALTEN]
    +    produkte = [
    +        Produkt(name=str(zeile.Produkt),
    +                deckungsbeitrag=float(zeile.Deckungsbeitrag),
    +                verbrauch={r: float(getattr(zeile, r)) for r in ressourcen})
    +        for zeile in produkte_tabelle.itertuples()
    +    ]
    +    kapazitaeten = {str(z.Ressource): float(z.Verfuegbar)
    +                    for z in kapazitaeten_tabelle.itertuples()}
    +
    +    return Produktionsproblem(produkte=produkte, kapazitaeten=kapazitaeten)
    +
    +
    +def schreibe_ergebnis(pfad: str, problem: Produktionsproblem,
    +                      loesung: Loesung) -> None:
    +    """Schreibt Plan und Kennzahlen als zwei Blaetter zurueck nach Excel.
    +
    +    Die Fachabteilung bekommt das Ergebnis im Format, das sie ohnehin benutzt.
    +    Akzeptanz entscheidet ueber Projekterfolg (Kapitel Praxisfallen).
    +    """
    +    import pandas as pd
    +
    +    if not loesung.status.brauchbar:
    +        raise ValueError(f"Nichts zu schreiben - Status: {loesung.status.value}")
    +
    +    mengen = np.array([loesung.werte[p.name] for p in problem.produkte])
    +    plan = pd.DataFrame({
    +        "Produkt": [p.name for p in problem.produkte],
    +        "Menge": mengen,
    +        "Deckungsbeitrag": mengen * problem.deckungsbeitragsvektor(),
    +    })
    +
    +    verbrauch = problem.verbrauchsmatrix() @ mengen
    +    kennzahlen: dict[str, float | str] = {
    +        "Status": loesung.status.value,
    +        "Gesamtdeckungsbeitrag": loesung.zielwert or 0.0,
    +    }
    +    for ressource, ist, grenze in zip(problem.ressourcen, verbrauch,
    +                                      problem.kapazitaetsvektor()):
    +        kennzahlen[f"{ressource}: Verbrauch"] = float(ist)
    +        kennzahlen[f"{ressource}: Auslastung %"] = float(100.0 * ist / grenze)
    +        if ressource in loesung.schattenpreise:
    +            kennzahlen[f"{ressource}: Schattenpreis"] = \
    +                loesung.schattenpreise[ressource]
    +
    +    kennzahlen_tabelle = pd.DataFrame({
    +        "Kennzahl": list(kennzahlen),
    +        "Wert": [round(w, 2) if isinstance(w, float) else w
    +                 for w in kennzahlen.values()],
    +    })
    +
    +    with pd.ExcelWriter(pfad, engine="openpyxl") as mappe:
    +        plan.round(2).to_excel(mappe, sheet_name="Plan", index=False)
    +        kennzahlen_tabelle.to_excel(mappe, sheet_name="Kennzahlen", index=False)
    +
    +
    +if __name__ == "__main__":
    +    # Die Schreinerei aus Kapitel Einfuehrung - jetzt als Domaenenmodell.
    +    schreinerei = Produktionsproblem(
    +        produkte=[
    +            Produkt(name="Tisch", deckungsbeitrag=240.0,
    +                    verbrauch={"Montagestunden": 3.0, "Plattenmaterial": 6.0}),
    +            Produkt(name="Stuhl", deckungsbeitrag=60.0,
    +                    verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0}),
    +        ],
    +        kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0},
    +    )
    +
    +    print("=" * 78)
    +    print("  DERSELBE FALL, ZWEI SOLVER, EIN AUSWERTUNGSCODE")
    +    print("=" * 78)
    +
    +    for name, bauer in [("OR-Tools GLOP", loese_mit_glop),
    +                        ("scipy / HiGHS", loese_mit_scipy)]:
    +        loesung = bauer(schreinerei)
    +        beanstandungen = pruefe_loesung(schreinerei, loesung)
    +        print(f"\n{name}")
    +        print(f"  {loesung.als_bericht()}")
    +        for produkt, menge in loesung.werte.items():
    +            print(f"    {produkt:<10} {menge:8.2f}")
    +        print(f"  Abnahmepruefung: "
    +              f"{'bestanden' if not beanstandungen else beanstandungen}")
    +
    +    print("\n" + "=" * 78)
    +    print("  WAS DIE PRUEFUNGEN ABFANGEN")
    +    print("=" * 78)
    +
    +    faelle = [
    +        ("Kapazitaet 0", dict(
    +            produkte=schreinerei.produkte,
    +            kapazitaeten={"Montagestunden": 0.0, "Plattenmaterial": 240.0})),
    +        ("Ressource ohne Kapazitaet", dict(
    +            produkte=[Produkt(name="Regal", deckungsbeitrag=130.0,
    +                              verbrauch={"Lackieren": 2.0})],
    +            kapazitaeten={"Montagestunden": 150.0})),
    +        ("doppelter Produktname", dict(
    +            produkte=[schreinerei.produkte[0], schreinerei.produkte[0]],
    +            kapazitaeten=schreinerei.kapazitaeten)),
    +    ]
    +    for beschreibung, daten in faelle:
    +        try:
    +            Produktionsproblem(**daten)
    +            print(f"  {beschreibung:<28} NICHT erkannt (!)")
    +        except Exception as fehler:
    +            meldung = str(fehler).splitlines()
    +            kern = next((z.strip() for z in meldung
    +                         if "Value error" in z or "greater than" in z), meldung[-1])
    +            print(f"  {beschreibung:<28} abgefangen: {kern[:44]}")
    +
    +    print("\nAlle drei scheitern beim EINLESEN - nicht erst beim Loesen und")
    +    print("schon gar nicht erst im Bericht. Das ist der ganze Zweck der")
    +    print("Domaenenschicht.")
    +    print("=" * 78)

    Erwartete Ausgabe:

    ==============================================================================
       DERSELBE FALL, ZWEI SOLVER, EIN AUSWERTUNGSCODE
    @@ -26205,7 +26521,7 @@ Domaenenschicht.
     
    - + @@ -26289,330 +26605,330 @@ Domaenenschicht.

    📎 Warum zwei Prozesse?

    ortools und highspy bringen beide eine eigene HiGHS-Kopie mit und kollidieren beim gemeinsamen Import (Kapitel 3). Das Programm startet sich deshalb für jeden Solver selbst noch einmal als Kindprozess und lässt sich das Ergebnis als JSON zurückgeben:

    -
    print(MODELLBAUER[sys.argv[1]](problem).model_dump_json())   # im Kindprozess
    -Loesung.model_validate_json(...)                             # im Hauptprozess
    +
    print(MODELLBAUER[sys.argv[1]](problem).model_dump_json())   # im Kindprozess
    +Loesung.model_validate_json(...)                             # im Hauptprozess

    Das ist der zweite, weniger offensichtliche Ertrag des DTOs: Ein Lösungsobjekt ohne Solverbezug ist nicht nur eine Sprachregelung, sondern ein Datenformat. Es überlebt eine Prozessgrenze, passt in eine Warteschlange und lässt sich protokollieren. Ein pywraplp.Solver-Objekt kann das alles nicht.

    -
    #!/usr/bin/env python3
    -
    -# Solverwechsel_CPSAT_HiGHS.py
    -"""
    -Kapitel Praxisfallen: Denselben Fall einmal mit CP-SAT und einmal mit HiGHS rechnen.
    -
    -Die Trennung aus or_kern.py behauptet, ein Solverwechsel koste genau EINEN
    -Baustein. Dieses Programm loest diese Behauptung ein. Aufgabe ist eine
    -Standortplanung: Welche Lager oeffnen wir, und wer beliefert welchen Kunden?
    -
    -    Standortproblem  (Pydantic, geprueft)   <- gemeinsam
    -        baue_und_loese_mit_cpsat()          <- der EINE Baustein
    -        baue_und_loese_mit_highs()          <- ... in zwei Ausfuehrungen
    -    Loesung          (DTO)                  <- gemeinsam
    -    pruefe_zuordnung()                      <- gemeinsam
    -    berichte()                              <- gemeinsam
    -
    -Die beiden Modellbauer sind 39 und 52 Zeilen lang - sie sind der gesamte
    -solverabhaengige Teil des Programms. Alles andere wird zweimal benutzt und
    -einmal geschrieben.
    -
    -WARUM ZWEI PROZESSE? ortools und highspy bringen beide eine eigene
    -HiGHS-Kopie mit und lassen sich auf vielen Systemen nicht gemeinsam
    -importieren (Kapitel Oekosystem). Das Hauptprogramm startet deshalb fuer jeden
    -Solver einen eigenen Python-Prozess und laesst sich die Loesung als JSON
    -zurueckgeben - das DTO ist nicht nur eine Sprachregelung, sondern ein
    -Datenformat, das eine Prozessgrenze ueberlebt.
    -
    -Aufruf:
    -    python3 Solverwechsel_CPSAT_HiGHS.py            # beide, mit Vergleich
    -    python3 Solverwechsel_CPSAT_HiGHS.py cpsat      # nur der Kindprozess
    -    python3 Solverwechsel_CPSAT_HiGHS.py highs
    -
    -Benoetigt: numpy, pydantic, ortools, highspy (jeweils im eigenen Prozess)
    -"""
    -
    -from __future__ import annotations
    -
    -import subprocess
    -import sys
    -import time
    -
    -import numpy as np
    -from pydantic import BaseModel, Field, model_validator
    -
    -from or_kern import Loesung, SolverStatus, status_von_cpsat, status_von_highs
    -
    -ZEITLIMIT = 30.0
    -
    -
    -# --- 1. Domaenenmodell: dieselben Fakten fuer beide Solver -------------------
    -
    -class Standortproblem(BaseModel):
    -    """Kapazitierte Standortplanung mit Einzelbelieferung.
    -
    -    Nach dem Muster von Produktionsproblem in or_kern.py: Die Pruefungen
    -    stehen im Konstruktor, nicht im Solvercode - sie gelten damit fuer beide
    -    Solver, und sie schlagen beim Einlesen zu.
    -
    -    Alle Kosten sind ganzzahlig (Euro). Das ist keine Bequemlichkeit, sondern
    -    Voraussetzung: CP-SAT rechnet ausschliesslich ganzzahlig.
    -    """
    -    lager: list[str] = Field(min_length=1)
    -    kunden: list[str] = Field(min_length=1)
    -    fixkosten: list[int]        # je Lager, faellt bei Eroeffnung an
    -    kapazitaet: list[int]       # je Lager, in Paletten
    -    bedarf: list[int]           # je Kunde, in Paletten
    -    transport: list[list[int]]  # [Lager][Kunde], Kosten der Belieferung
    -
    -    @model_validator(mode="after")
    -    def pruefe_masse(self) -> "Standortproblem":
    -        n, m = len(self.lager), len(self.kunden)
    -        if len(self.fixkosten) != n or len(self.kapazitaet) != n:
    -            raise ValueError(f"fixkosten/kapazitaet muessen {n} Eintraege haben")
    -        if len(self.bedarf) != m:
    -            raise ValueError(f"bedarf muss {m} Eintraege haben")
    -        if len(self.transport) != n or any(len(z) != m for z in self.transport):
    -            raise ValueError(f"transport muss {n} x {m} sein")
    -        if sum(self.kapazitaet) < sum(self.bedarf):
    -            raise ValueError(f"Gesamtkapazitaet {sum(self.kapazitaet)} deckt den "
    -                             f"Gesamtbedarf {sum(self.bedarf)} nicht")
    -        return self
    -
    -    def schluessel(self, i: int, j: int) -> str:
    -        """Variablenname im Loesung-DTO - beide Modellbauer benutzen ihn."""
    -        return f"{self.lager[i]}->{self.kunden[j]}"
    -
    -
    -def beispielproblem(saat: int = 11) -> Standortproblem:
    -    """Sechs moegliche Lager, zwoelf Kunden - klein genug fuer beide Solver."""
    -    rng = np.random.default_rng(saat)
    -    lager = [f"Lager_{k}" for k in "ABCDEF"]
    -    kunden = [f"Kunde_{k:02d}" for k in range(1, 13)]
    -    bedarf = rng.integers(10, 60, len(kunden))
    -    return Standortproblem(
    -        lager=lager,
    -        kunden=kunden,
    -        fixkosten=rng.integers(3000, 9000, len(lager)).tolist(),
    -        kapazitaet=[int(bedarf.sum() * 0.45)] * len(lager),
    -        bedarf=bedarf.tolist(),
    -        transport=rng.integers(200, 1800, (len(lager), len(kunden))).tolist(),
    -    )
    -
    -
    -# --- 2. Der eine Baustein, der sich aendert: der Modellbauer -----------------
    -
    -def baue_und_loese_mit_cpsat(problem: Standortproblem) -> Loesung:
    -    """CP-SAT: Bool-Variablen, ganzzahlige Koeffizienten, Minimize."""
    -    from ortools.sat.python import cp_model
    -
    -    n, m = len(problem.lager), len(problem.kunden)
    -    modell = cp_model.CpModel()
    -    y = [modell.NewBoolVar(f"offen_{i}") for i in range(n)]
    -    x = {(i, j): modell.NewBoolVar(f"liefert_{i}_{j}")
    -         for i in range(n) for j in range(m)}
    -
    -    for j in range(m):                                   # jeder Kunde genau einmal
    -        modell.AddExactlyOne(x[i, j] for i in range(n))
    -    for i in range(n):
    -        for j in range(m):                               # nur aus offenen Lagern
    -            modell.AddImplication(x[i, j], y[i])
    -        modell.Add(sum(problem.bedarf[j] * x[i, j] for j in range(m))
    -                   <= problem.kapazitaet[i] * y[i])      # Kapazitaet
    -
    -    modell.Minimize(
    -        sum(problem.fixkosten[i] * y[i] for i in range(n))
    -        + sum(problem.transport[i][j] * x[i, j] for i in range(n) for j in range(m)))
    -
    -    loeser = cp_model.CpSolver()
    -    loeser.parameters.max_time_in_seconds = ZEITLIMIT
    -    loeser.parameters.num_workers = 1
    -    loeser.parameters.random_seed = 1
    -    status = status_von_cpsat(loeser.Solve(modell))
    -    if not status.brauchbar:
    -        return Loesung(status=status, laufzeit=loeser.WallTime())
    -
    -    return Loesung(
    -        status=status,
    -        werte={problem.schluessel(i, j): float(loeser.Value(x[i, j]))
    -               for i in range(n) for j in range(m)},
    -        zielwert=loeser.ObjectiveValue(),
    -        schranke=loeser.BestObjectiveBound(),
    -        laufzeit=loeser.WallTime())
    -
    -
    -def baue_und_loese_mit_highs(problem: Standortproblem) -> Loesung:
    -    """HiGHS: dieselben Restriktionen als Ungleichungszeilen einer Matrix."""
    -    import highspy
    -
    -    n, m = len(problem.lager), len(problem.kunden)
    -    anzahl_x = n * m                                     # Spalten 0..n*m-1
    -    spalte_y = lambda i: anzahl_x + i                    # danach die y_i  # noqa: E731
    -
    -    modell = highspy.Highs()
    -    modell.setOptionValue("output_flag", False)
    -    modell.setOptionValue("time_limit", ZEITLIMIT)
    -    modell.addVars(anzahl_x + n, np.zeros(anzahl_x + n), np.ones(anzahl_x + n))
    -    for spalte in range(anzahl_x + n):
    -        modell.changeColIntegrality(spalte, highspy.HighsVarType.kInteger)
    -    for i in range(n):
    -        modell.changeColCost(spalte_y(i), float(problem.fixkosten[i]))
    -        for j in range(m):
    -            modell.changeColCost(i * m + j, float(problem.transport[i][j]))
    -
    -    for j in range(m):                                   # jeder Kunde genau einmal
    -        index = np.array([i * m + j for i in range(n)], dtype=np.int32)
    -        modell.addRow(1.0, 1.0, n, index, np.ones(n))
    -    for i in range(n):
    -        for j in range(m):                               # x_ij - y_i <= 0
    -            modell.addRow(-highspy.kHighsInf, 0.0, 2,
    -                          np.array([i * m + j, spalte_y(i)], dtype=np.int32),
    -                          np.array([1.0, -1.0]))
    -        index = np.array([i * m + j for j in range(m)] + [spalte_y(i)],
    -                         dtype=np.int32)                 # Kapazitaet
    -        werte = np.concatenate([np.array(problem.bedarf, dtype=float),
    -                                [-float(problem.kapazitaet[i])]])
    -        modell.addRow(-highspy.kHighsInf, 0.0, m + 1, index, werte)
    -
    -    t0 = time.perf_counter()
    -    modell.run()
    -    laufzeit = time.perf_counter() - t0
    -
    -    status = status_von_highs(modell.modelStatusToString(modell.getModelStatus()))
    -    if not status.brauchbar:
    -        return Loesung(status=status, laufzeit=laufzeit)
    -
    -    loesungswerte = modell.getSolution().col_value
    -    info = modell.getInfo()
    -    return Loesung(
    -        status=status,
    -        werte={problem.schluessel(i, j): float(loesungswerte[i * m + j])
    -               for i in range(n) for j in range(m)},
    -        zielwert=info.objective_function_value,
    -        schranke=info.mip_dual_bound,
    -        laufzeit=laufzeit)
    -
    -
    -MODELLBAUER = {"cpsat": baue_und_loese_mit_cpsat, "highs": baue_und_loese_mit_highs}
    -
    -
    -# --- 3. Alles Weitere ist wieder gemeinsam -----------------------------------
    -
    -def pruefe_zuordnung(problem: Standortproblem, loesung: Loesung,
    -                     toleranz: float = 1e-6) -> list[str]:
    -    """Prueft die Loesung gegen die Anforderung - ohne Solver, ohne Modell."""
    -    if not loesung.status.brauchbar:
    -        return [f"kein verwertbares Ergebnis ({loesung.status.value})"]
    -
    -    n, m = len(problem.lager), len(problem.kunden)
    -    zuordnung = np.array([[loesung.werte[problem.schluessel(i, j)]
    -                           for j in range(m)] for i in range(n)])
    -    beanstandungen: list[str] = []
    -
    -    if (np.abs(zuordnung - np.round(zuordnung)) > toleranz).any():
    -        beanstandungen.append("Zuordnungen sind nicht 0/1")
    -    zuordnung = np.round(zuordnung)
    -
    -    for j, kunde in enumerate(problem.kunden):
    -        if abs(zuordnung[:, j].sum() - 1.0) > toleranz:
    -            beanstandungen.append(f"{kunde} wird {zuordnung[:, j].sum():.0f}-mal beliefert")
    -
    -    beliefert = zuordnung @ np.array(problem.bedarf, dtype=float)
    -    for i, lagername in enumerate(problem.lager):
    -        if beliefert[i] > problem.kapazitaet[i] + toleranz:
    -            beanstandungen.append(f"{lagername}: {beliefert[i]:.0f} Paletten ueber "
    -                                  f"Kapazitaet {problem.kapazitaet[i]}")
    -
    -    if loesung.zielwert is not None:
    -        offen = beliefert > toleranz
    -        nachgerechnet = (np.array(problem.fixkosten, dtype=float) @ offen
    -                         + (np.array(problem.transport, dtype=float) * zuordnung).sum())
    -        if abs(nachgerechnet - loesung.zielwert) > 0.5:
    -            beanstandungen.append(f"Zielwert {loesung.zielwert:,.0f} passt nicht zur "
    -                                  f"Zuordnung (nachgerechnet {nachgerechnet:,.0f})")
    -    return beanstandungen
    -
    -
    -def geoeffnete_lager(problem: Standortproblem, loesung: Loesung) -> list[str]:
    -    m = len(problem.kunden)
    -    return [name for i, name in enumerate(problem.lager)
    -            if any(loesung.werte[problem.schluessel(i, j)] > 0.5 for j in range(m))]
    -
    -
    -def loese_in_eigenem_prozess(name: str) -> Loesung:
    -    """Startet dieses Programm noch einmal - mit genau einem Solverimport."""
    -    ergebnis = subprocess.run([sys.executable, __file__, name],
    -                              capture_output=True, text=True, timeout=300)
    -    if ergebnis.returncode != 0:
    -        raise RuntimeError(ergebnis.stderr.strip().splitlines()[-1])
    -    # Das DTO als JSON - genau dafuer ist ein Datenobjekt ohne Solverbezug gut.
    -    return Loesung.model_validate_json(ergebnis.stdout.strip().splitlines()[-1])
    -
    -
    -if __name__ == "__main__":
    -    problem = beispielproblem()
    -
    -    # --- Kindprozess: rechnen und das DTO als JSON ausgeben ---------------
    -    if len(sys.argv) > 1:
    -        print(MODELLBAUER[sys.argv[1]](problem).model_dump_json())
    -        sys.exit(0)
    -
    -    # --- Hauptprozess: beide Solver anstossen und vergleichen -------------
    -    print("=" * 82)
    -    print("  DERSELBE FALL, ZWEI SOLVER - UND EIN AUSWERTUNGSCODE")
    -    print("=" * 82)
    -    print(f"Standortplanung: {len(problem.lager)} moegliche Lager, "
    -          f"{len(problem.kunden)} Kunden, {sum(problem.bedarf)} Paletten Bedarf.")
    -    print(f"Kapazitaet je Lager: {problem.kapazitaet[0]} Paletten "
    -          f"-> mindestens 3 Lager noetig.\n")
    -
    -    loesungen: dict[str, Loesung] = {}
    -    for name, beschriftung in [("cpsat", "OR-Tools CP-SAT"),
    -                               ("highs", "HiGHS (highspy)")]:
    -        loesung = loesungen[name] = loese_in_eigenem_prozess(name)
    -        beanstandungen = pruefe_zuordnung(problem, loesung)
    -
    -        print(f"{beschriftung}")
    -        print(f"  {loesung.als_bericht()}")
    -        print(f"  eroeffnete Lager: {', '.join(geoeffnete_lager(problem, loesung))}")
    -        print(f"  Abnahmepruefung:  "
    -              f"{'bestanden' if not beanstandungen else beanstandungen}")
    -
    -    # --- Was der Vergleich zeigt -----------------------------------------
    -    zielwerte = [loesung.zielwert for loesung in loesungen.values()]
    -    print("-" * 82)
    -    print(f"Zielwertdifferenz: {abs(zielwerte[0] - zielwerte[1]):.6f} EUR")
    -
    -    gleich_belegt = all(
    -        round(loesungen["cpsat"].werte[s]) == round(loesungen["highs"].werte[s])
    -        for s in loesungen["cpsat"].werte)
    -    print(f"Identische Zuordnung: {'ja' if gleich_belegt else 'nein'}")
    -
    -    assert abs(zielwerte[0] - zielwerte[1]) < 0.5, "Die Solver widersprechen sich!"
    -    assert all(l.status is SolverStatus.OPTIMAL for l in loesungen.values())
    -
    -    print("\n" + "=" * 82)
    -    print("  WAS DER WECHSEL GEKOSTET HAT")
    -    print("=" * 82)
    -    print("Ausgetauscht wurde EINE Funktion. Domaenenmodell, Abnahmepruefung und")
    -    print("Bericht sind woertlich dieselben - sie sehen den Solver nie.")
    -    print()
    -    print("Nicht umsonst ist der Wechsel trotzdem:")
    -    print("  * CP-SAT rechnet ausschliesslich GANZZAHLIG. Alle Kosten sind hier")
    -    print("    deshalb int. Wer in Euro und Cent rechnet, skaliert vorher auf Cent -")
    -    print("    und muss das im Bericht wieder zuruecknehmen.")
    -    print("  * HiGHS braucht die Restriktionen als Matrixzeilen, CP-SAT nimmt sie")
    -    print("    als Ausdruecke. Das ist der Grund, warum der HiGHS-Modellbauer")
    -    print("    laenger ist, obwohl er dasselbe Modell beschreibt.")
    -    print("  * Beide Bibliotheken bringen eine eigene HiGHS-Kopie mit und lassen")
    -    print("    sich nicht gemeinsam importieren - daher die zwei Prozesse.")
    -    print()
    -    print("Der Ertrag: Beide beweisen denselben optimalen Zielwert, und die")
    -    print("Entscheidung zwischen ihnen ist eine Frage der Laufzeit geworden -")
    -    print("nicht eine Frage, wie viel Code man neu schreiben muss.")
    -    print()
    -    print("Verglichen wird deshalb der ZIELWERT, nicht der Plan: Gibt es mehrere")
    -    print("gleich teure Loesungen, darf jeder Solver eine andere davon liefern.")
    -    print("Hier stimmen sie zufaellig ueberein - darauf zu testen waere trotzdem")
    -    print("ein unzuverlaessiger Test (siehe JobShop_Intervalle.py).")
    -    print("=" * 82)
    +
    #!/usr/bin/env python3
    +
    +# Solverwechsel_CPSAT_HiGHS.py
    +"""
    +Kapitel Praxisfallen: Denselben Fall einmal mit CP-SAT und einmal mit HiGHS rechnen.
    +
    +Die Trennung aus or_kern.py behauptet, ein Solverwechsel koste genau EINEN
    +Baustein. Dieses Programm loest diese Behauptung ein. Aufgabe ist eine
    +Standortplanung: Welche Lager oeffnen wir, und wer beliefert welchen Kunden?
    +
    +    Standortproblem  (Pydantic, geprueft)   <- gemeinsam
    +        baue_und_loese_mit_cpsat()          <- der EINE Baustein
    +        baue_und_loese_mit_highs()          <- ... in zwei Ausfuehrungen
    +    Loesung          (DTO)                  <- gemeinsam
    +    pruefe_zuordnung()                      <- gemeinsam
    +    berichte()                              <- gemeinsam
    +
    +Die beiden Modellbauer sind 39 und 52 Zeilen lang - sie sind der gesamte
    +solverabhaengige Teil des Programms. Alles andere wird zweimal benutzt und
    +einmal geschrieben.
    +
    +WARUM ZWEI PROZESSE? ortools und highspy bringen beide eine eigene
    +HiGHS-Kopie mit und lassen sich auf vielen Systemen nicht gemeinsam
    +importieren (Kapitel Oekosystem). Das Hauptprogramm startet deshalb fuer jeden
    +Solver einen eigenen Python-Prozess und laesst sich die Loesung als JSON
    +zurueckgeben - das DTO ist nicht nur eine Sprachregelung, sondern ein
    +Datenformat, das eine Prozessgrenze ueberlebt.
    +
    +Aufruf:
    +    python3 Solverwechsel_CPSAT_HiGHS.py            # beide, mit Vergleich
    +    python3 Solverwechsel_CPSAT_HiGHS.py cpsat      # nur der Kindprozess
    +    python3 Solverwechsel_CPSAT_HiGHS.py highs
    +
    +Benoetigt: numpy, pydantic, ortools, highspy (jeweils im eigenen Prozess)
    +"""
    +
    +from __future__ import annotations
    +
    +import subprocess
    +import sys
    +import time
    +
    +import numpy as np
    +from pydantic import BaseModel, Field, model_validator
    +
    +from or_kern import Loesung, SolverStatus, status_von_cpsat, status_von_highs
    +
    +ZEITLIMIT = 30.0
    +
    +
    +# --- 1. Domaenenmodell: dieselben Fakten fuer beide Solver -------------------
    +
    +class Standortproblem(BaseModel):
    +    """Kapazitierte Standortplanung mit Einzelbelieferung.
    +
    +    Nach dem Muster von Produktionsproblem in or_kern.py: Die Pruefungen
    +    stehen im Konstruktor, nicht im Solvercode - sie gelten damit fuer beide
    +    Solver, und sie schlagen beim Einlesen zu.
    +
    +    Alle Kosten sind ganzzahlig (Euro). Das ist keine Bequemlichkeit, sondern
    +    Voraussetzung: CP-SAT rechnet ausschliesslich ganzzahlig.
    +    """
    +    lager: list[str] = Field(min_length=1)
    +    kunden: list[str] = Field(min_length=1)
    +    fixkosten: list[int]        # je Lager, faellt bei Eroeffnung an
    +    kapazitaet: list[int]       # je Lager, in Paletten
    +    bedarf: list[int]           # je Kunde, in Paletten
    +    transport: list[list[int]]  # [Lager][Kunde], Kosten der Belieferung
    +
    +    @model_validator(mode="after")
    +    def pruefe_masse(self) -> "Standortproblem":
    +        n, m = len(self.lager), len(self.kunden)
    +        if len(self.fixkosten) != n or len(self.kapazitaet) != n:
    +            raise ValueError(f"fixkosten/kapazitaet muessen {n} Eintraege haben")
    +        if len(self.bedarf) != m:
    +            raise ValueError(f"bedarf muss {m} Eintraege haben")
    +        if len(self.transport) != n or any(len(z) != m for z in self.transport):
    +            raise ValueError(f"transport muss {n} x {m} sein")
    +        if sum(self.kapazitaet) < sum(self.bedarf):
    +            raise ValueError(f"Gesamtkapazitaet {sum(self.kapazitaet)} deckt den "
    +                             f"Gesamtbedarf {sum(self.bedarf)} nicht")
    +        return self
    +
    +    def schluessel(self, i: int, j: int) -> str:
    +        """Variablenname im Loesung-DTO - beide Modellbauer benutzen ihn."""
    +        return f"{self.lager[i]}->{self.kunden[j]}"
    +
    +
    +def beispielproblem(saat: int = 11) -> Standortproblem:
    +    """Sechs moegliche Lager, zwoelf Kunden - klein genug fuer beide Solver."""
    +    rng = np.random.default_rng(saat)
    +    lager = [f"Lager_{k}" for k in "ABCDEF"]
    +    kunden = [f"Kunde_{k:02d}" for k in range(1, 13)]
    +    bedarf = rng.integers(10, 60, len(kunden))
    +    return Standortproblem(
    +        lager=lager,
    +        kunden=kunden,
    +        fixkosten=rng.integers(3000, 9000, len(lager)).tolist(),
    +        kapazitaet=[int(bedarf.sum() * 0.45)] * len(lager),
    +        bedarf=bedarf.tolist(),
    +        transport=rng.integers(200, 1800, (len(lager), len(kunden))).tolist(),
    +    )
    +
    +
    +# --- 2. Der eine Baustein, der sich aendert: der Modellbauer -----------------
    +
    +def baue_und_loese_mit_cpsat(problem: Standortproblem) -> Loesung:
    +    """CP-SAT: Bool-Variablen, ganzzahlige Koeffizienten, Minimize."""
    +    from ortools.sat.python import cp_model
    +
    +    n, m = len(problem.lager), len(problem.kunden)
    +    modell = cp_model.CpModel()
    +    y = [modell.NewBoolVar(f"offen_{i}") for i in range(n)]
    +    x = {(i, j): modell.NewBoolVar(f"liefert_{i}_{j}")
    +         for i in range(n) for j in range(m)}
    +
    +    for j in range(m):                                   # jeder Kunde genau einmal
    +        modell.AddExactlyOne(x[i, j] for i in range(n))
    +    for i in range(n):
    +        for j in range(m):                               # nur aus offenen Lagern
    +            modell.AddImplication(x[i, j], y[i])
    +        modell.Add(sum(problem.bedarf[j] * x[i, j] for j in range(m))
    +                   <= problem.kapazitaet[i] * y[i])      # Kapazitaet
    +
    +    modell.Minimize(
    +        sum(problem.fixkosten[i] * y[i] for i in range(n))
    +        + sum(problem.transport[i][j] * x[i, j] for i in range(n) for j in range(m)))
    +
    +    loeser = cp_model.CpSolver()
    +    loeser.parameters.max_time_in_seconds = ZEITLIMIT
    +    loeser.parameters.num_workers = 1
    +    loeser.parameters.random_seed = 1
    +    status = status_von_cpsat(loeser.Solve(modell))
    +    if not status.brauchbar:
    +        return Loesung(status=status, laufzeit=loeser.WallTime())
    +
    +    return Loesung(
    +        status=status,
    +        werte={problem.schluessel(i, j): float(loeser.Value(x[i, j]))
    +               for i in range(n) for j in range(m)},
    +        zielwert=loeser.ObjectiveValue(),
    +        schranke=loeser.BestObjectiveBound(),
    +        laufzeit=loeser.WallTime())
    +
    +
    +def baue_und_loese_mit_highs(problem: Standortproblem) -> Loesung:
    +    """HiGHS: dieselben Restriktionen als Ungleichungszeilen einer Matrix."""
    +    import highspy
    +
    +    n, m = len(problem.lager), len(problem.kunden)
    +    anzahl_x = n * m                                     # Spalten 0..n*m-1
    +    spalte_y = lambda i: anzahl_x + i                    # danach die y_i  # noqa: E731
    +
    +    modell = highspy.Highs()
    +    modell.setOptionValue("output_flag", False)
    +    modell.setOptionValue("time_limit", ZEITLIMIT)
    +    modell.addVars(anzahl_x + n, np.zeros(anzahl_x + n), np.ones(anzahl_x + n))
    +    for spalte in range(anzahl_x + n):
    +        modell.changeColIntegrality(spalte, highspy.HighsVarType.kInteger)
    +    for i in range(n):
    +        modell.changeColCost(spalte_y(i), float(problem.fixkosten[i]))
    +        for j in range(m):
    +            modell.changeColCost(i * m + j, float(problem.transport[i][j]))
    +
    +    for j in range(m):                                   # jeder Kunde genau einmal
    +        index = np.array([i * m + j for i in range(n)], dtype=np.int32)
    +        modell.addRow(1.0, 1.0, n, index, np.ones(n))
    +    for i in range(n):
    +        for j in range(m):                               # x_ij - y_i <= 0
    +            modell.addRow(-highspy.kHighsInf, 0.0, 2,
    +                          np.array([i * m + j, spalte_y(i)], dtype=np.int32),
    +                          np.array([1.0, -1.0]))
    +        index = np.array([i * m + j for j in range(m)] + [spalte_y(i)],
    +                         dtype=np.int32)                 # Kapazitaet
    +        werte = np.concatenate([np.array(problem.bedarf, dtype=float),
    +                                [-float(problem.kapazitaet[i])]])
    +        modell.addRow(-highspy.kHighsInf, 0.0, m + 1, index, werte)
    +
    +    t0 = time.perf_counter()
    +    modell.run()
    +    laufzeit = time.perf_counter() - t0
    +
    +    status = status_von_highs(modell.modelStatusToString(modell.getModelStatus()))
    +    if not status.brauchbar:
    +        return Loesung(status=status, laufzeit=laufzeit)
    +
    +    loesungswerte = modell.getSolution().col_value
    +    info = modell.getInfo()
    +    return Loesung(
    +        status=status,
    +        werte={problem.schluessel(i, j): float(loesungswerte[i * m + j])
    +               for i in range(n) for j in range(m)},
    +        zielwert=info.objective_function_value,
    +        schranke=info.mip_dual_bound,
    +        laufzeit=laufzeit)
    +
    +
    +MODELLBAUER = {"cpsat": baue_und_loese_mit_cpsat, "highs": baue_und_loese_mit_highs}
    +
    +
    +# --- 3. Alles Weitere ist wieder gemeinsam -----------------------------------
    +
    +def pruefe_zuordnung(problem: Standortproblem, loesung: Loesung,
    +                     toleranz: float = 1e-6) -> list[str]:
    +    """Prueft die Loesung gegen die Anforderung - ohne Solver, ohne Modell."""
    +    if not loesung.status.brauchbar:
    +        return [f"kein verwertbares Ergebnis ({loesung.status.value})"]
    +
    +    n, m = len(problem.lager), len(problem.kunden)
    +    zuordnung = np.array([[loesung.werte[problem.schluessel(i, j)]
    +                           for j in range(m)] for i in range(n)])
    +    beanstandungen: list[str] = []
    +
    +    if (np.abs(zuordnung - np.round(zuordnung)) > toleranz).any():
    +        beanstandungen.append("Zuordnungen sind nicht 0/1")
    +    zuordnung = np.round(zuordnung)
    +
    +    for j, kunde in enumerate(problem.kunden):
    +        if abs(zuordnung[:, j].sum() - 1.0) > toleranz:
    +            beanstandungen.append(f"{kunde} wird {zuordnung[:, j].sum():.0f}-mal beliefert")
    +
    +    beliefert = zuordnung @ np.array(problem.bedarf, dtype=float)
    +    for i, lagername in enumerate(problem.lager):
    +        if beliefert[i] > problem.kapazitaet[i] + toleranz:
    +            beanstandungen.append(f"{lagername}: {beliefert[i]:.0f} Paletten ueber "
    +                                  f"Kapazitaet {problem.kapazitaet[i]}")
    +
    +    if loesung.zielwert is not None:
    +        offen = beliefert > toleranz
    +        nachgerechnet = (np.array(problem.fixkosten, dtype=float) @ offen
    +                         + (np.array(problem.transport, dtype=float) * zuordnung).sum())
    +        if abs(nachgerechnet - loesung.zielwert) > 0.5:
    +            beanstandungen.append(f"Zielwert {loesung.zielwert:,.0f} passt nicht zur "
    +                                  f"Zuordnung (nachgerechnet {nachgerechnet:,.0f})")
    +    return beanstandungen
    +
    +
    +def geoeffnete_lager(problem: Standortproblem, loesung: Loesung) -> list[str]:
    +    m = len(problem.kunden)
    +    return [name for i, name in enumerate(problem.lager)
    +            if any(loesung.werte[problem.schluessel(i, j)] > 0.5 for j in range(m))]
    +
    +
    +def loese_in_eigenem_prozess(name: str) -> Loesung:
    +    """Startet dieses Programm noch einmal - mit genau einem Solverimport."""
    +    ergebnis = subprocess.run([sys.executable, __file__, name],
    +                              capture_output=True, text=True, timeout=300)
    +    if ergebnis.returncode != 0:
    +        raise RuntimeError(ergebnis.stderr.strip().splitlines()[-1])
    +    # Das DTO als JSON - genau dafuer ist ein Datenobjekt ohne Solverbezug gut.
    +    return Loesung.model_validate_json(ergebnis.stdout.strip().splitlines()[-1])
    +
    +
    +if __name__ == "__main__":
    +    problem = beispielproblem()
    +
    +    # --- Kindprozess: rechnen und das DTO als JSON ausgeben ---------------
    +    if len(sys.argv) > 1:
    +        print(MODELLBAUER[sys.argv[1]](problem).model_dump_json())
    +        sys.exit(0)
    +
    +    # --- Hauptprozess: beide Solver anstossen und vergleichen -------------
    +    print("=" * 82)
    +    print("  DERSELBE FALL, ZWEI SOLVER - UND EIN AUSWERTUNGSCODE")
    +    print("=" * 82)
    +    print(f"Standortplanung: {len(problem.lager)} moegliche Lager, "
    +          f"{len(problem.kunden)} Kunden, {sum(problem.bedarf)} Paletten Bedarf.")
    +    print(f"Kapazitaet je Lager: {problem.kapazitaet[0]} Paletten "
    +          f"-> mindestens 3 Lager noetig.\n")
    +
    +    loesungen: dict[str, Loesung] = {}
    +    for name, beschriftung in [("cpsat", "OR-Tools CP-SAT"),
    +                               ("highs", "HiGHS (highspy)")]:
    +        loesung = loesungen[name] = loese_in_eigenem_prozess(name)
    +        beanstandungen = pruefe_zuordnung(problem, loesung)
    +
    +        print(f"{beschriftung}")
    +        print(f"  {loesung.als_bericht()}")
    +        print(f"  eroeffnete Lager: {', '.join(geoeffnete_lager(problem, loesung))}")
    +        print(f"  Abnahmepruefung:  "
    +              f"{'bestanden' if not beanstandungen else beanstandungen}")
    +
    +    # --- Was der Vergleich zeigt -----------------------------------------
    +    zielwerte = [loesung.zielwert for loesung in loesungen.values()]
    +    print("-" * 82)
    +    print(f"Zielwertdifferenz: {abs(zielwerte[0] - zielwerte[1]):.6f} EUR")
    +
    +    gleich_belegt = all(
    +        round(loesungen["cpsat"].werte[s]) == round(loesungen["highs"].werte[s])
    +        for s in loesungen["cpsat"].werte)
    +    print(f"Identische Zuordnung: {'ja' if gleich_belegt else 'nein'}")
    +
    +    assert abs(zielwerte[0] - zielwerte[1]) < 0.5, "Die Solver widersprechen sich!"
    +    assert all(l.status is SolverStatus.OPTIMAL for l in loesungen.values())
    +
    +    print("\n" + "=" * 82)
    +    print("  WAS DER WECHSEL GEKOSTET HAT")
    +    print("=" * 82)
    +    print("Ausgetauscht wurde EINE Funktion. Domaenenmodell, Abnahmepruefung und")
    +    print("Bericht sind woertlich dieselben - sie sehen den Solver nie.")
    +    print()
    +    print("Nicht umsonst ist der Wechsel trotzdem:")
    +    print("  * CP-SAT rechnet ausschliesslich GANZZAHLIG. Alle Kosten sind hier")
    +    print("    deshalb int. Wer in Euro und Cent rechnet, skaliert vorher auf Cent -")
    +    print("    und muss das im Bericht wieder zuruecknehmen.")
    +    print("  * HiGHS braucht die Restriktionen als Matrixzeilen, CP-SAT nimmt sie")
    +    print("    als Ausdruecke. Das ist der Grund, warum der HiGHS-Modellbauer")
    +    print("    laenger ist, obwohl er dasselbe Modell beschreibt.")
    +    print("  * Beide Bibliotheken bringen eine eigene HiGHS-Kopie mit und lassen")
    +    print("    sich nicht gemeinsam importieren - daher die zwei Prozesse.")
    +    print()
    +    print("Der Ertrag: Beide beweisen denselben optimalen Zielwert, und die")
    +    print("Entscheidung zwischen ihnen ist eine Frage der Laufzeit geworden -")
    +    print("nicht eine Frage, wie viel Code man neu schreiben muss.")
    +    print()
    +    print("Verglichen wird deshalb der ZIELWERT, nicht der Plan: Gibt es mehrere")
    +    print("gleich teure Loesungen, darf jeder Solver eine andere davon liefern.")
    +    print("Hier stimmen sie zufaellig ueberein - darauf zu testen waere trotzdem")
    +    print("ein unzuverlaessiger Test (siehe JobShop_Intervalle.py).")
    +    print("=" * 82)

    Erwartete Ausgabe (Laufzeiten hardwareabhängig):

    ==================================================================================
       DERSELBE FALL, ZWEI SOLVER - UND EIN AUSWERTUNGSCODE
    @@ -26781,186 +27097,186 @@ ein unzuverlaessiger Test (siehe JobShop_Intervalle.py).
     

    Ihre Aufgabe: (a) Warum ist die gleichbleibende Laufzeit hier kein Zeichen von Stabilität, sondern das Symptom? (b) Welche drei Größen fehlen im Protokoll? (c) Welche der drei warnt am frühesten — und warum ist gerade sie so wertvoll? (d) Das Team bemerkt den Fehler 2026. Wie hoch waren die Mehrkosten, und warum lässt sich das nachträglich nicht mehr genau beziffern?

    Auflösung: Abschnitt A.22.

    -
    #!/usr/bin/env python3
    -
    -# Betriebsueberwachung.py
    -"""
    -Kapitel Praxisfallen: Das Modell, das still aufgehoert hat zu optimieren.
    -
    -Ein Optimierungsmodell geht in Betrieb und laeuft jede Nacht. Es liefert
    -zuverlaessig eine Zahl, die Job-Dauer bleibt konstant, es gibt keine
    -Fehlermeldung. Alles sieht gut aus.
    -
    -Waehrenddessen waechst das Unternehmen. Aus 15 Lagern werden 40, dann 80. Das
    -Zeitlimit von drei Sekunden - vor zwei Jahren grosszuegig bemessen - reicht
    -irgendwann nicht mehr. Der Solver bricht ab und liefert die beste Loesung, die
    -er bis dahin gefunden hat.
    -
    -Das ist nicht falsch. Es ist sogar richtig so. Aber es ist etwas anderes als
    -das, was das Modell einmal geleistet hat - und niemand merkt es, weil im
    -Protokoll nur die Kosten stehen.
    -
    -Dieses Programm zeigt die Verschlechterung und die Ueberwachung, die sie
    -sichtbar macht.
    -
    -WICHTIG: ortools wird hier nicht importiert (Konflikt mit highspy, siehe
    -Kapitel Oekosystem).
    -
    -Benoetigt: numpy, highspy
    -"""
    -
    -from __future__ import annotations
    -
    -import time
    -from dataclasses import dataclass
    -
    -import numpy as np
    -import highspy
    -
    -ZEITLIMIT = 3.0                  # das Budget, das der Nachtjob hat
    -GAP_WARNSCHWELLE = 0.02          # ab 2 % Gap wollen wir es wissen
    -ZEITAUSLASTUNG_WARNSCHWELLE = 0.9
    -
    -
    -@dataclass
    -class Laufprotokoll:
    -    """Was nach JEDEM Produktivlauf protokolliert gehoert.
    -
    -    Die meisten Systeme schreiben nur 'kosten'. Genau deshalb faellt eine
    -    schleichende Verschlechterung ueber Monate nicht auf.
    -    """
    -    zeitpunkt: str
    -    lager: int
    -    kunden: int
    -    status: str
    -    kosten: float
    -    gap: float
    -    laufzeit: float
    -    knoten: int
    -
    -    @property
    -    def warnungen(self) -> list[str]:
    -        meldungen = []
    -        if self.status != "Optimal":
    -            meldungen.append(f"nicht beweisbar optimal ({self.status})")
    -        if self.gap > GAP_WARNSCHWELLE:
    -            meldungen.append(f"Gap {self.gap:.1%} ueber Schwelle "
    -                             f"{GAP_WARNSCHWELLE:.0%}")
    -        if self.laufzeit > ZEITAUSLASTUNG_WARNSCHWELLE * ZEITLIMIT:
    -            meldungen.append(f"Zeitbudget zu {self.laufzeit / ZEITLIMIT:.0%} "
    -                             f"ausgeschoepft")
    -        return meldungen
    -
    -
    -def plane_netzwerk(n_lager: int, n_kunden: int, zeitlimit: float,
    -                   saat: int = 7) -> tuple[str, float, float, float, int]:
    -    """Standortplanung wie in Kapitel MILP, nur mit wachsender Groesse."""
    -    rng = np.random.default_rng(saat)
    -    fixkosten = rng.uniform(3000, 9000, n_lager)
    -    transport = rng.uniform(5, 60, (n_lager, n_kunden))
    -    bedarf = rng.uniform(10, 60, n_kunden)
    -    kapazitaet = np.full(n_lager, bedarf.sum() * 0.22)
    -
    -    modell = highspy.Highs()
    -    modell.setOptionValue("output_flag", False)
    -    modell.setOptionValue("time_limit", zeitlimit)
    -
    -    anzahl_x = n_lager * n_kunden
    -    unendlich = highspy.kHighsInf
    -    modell.addVars(anzahl_x, np.zeros(anzahl_x), np.full(anzahl_x, unendlich))
    -    modell.addVars(n_lager, np.zeros(n_lager), np.ones(n_lager))
    -    for i in range(n_lager):
    -        modell.changeColIntegrality(anzahl_x + i, highspy.HighsVarType.kInteger)
    -        modell.changeColCost(anzahl_x + i, fixkosten[i])
    -        for j in range(n_kunden):
    -            modell.changeColCost(i * n_kunden + j, transport[i, j])
    -
    -    for j in range(n_kunden):
    -        index = np.array([i * n_kunden + j for i in range(n_lager)], dtype=np.int32)
    -        modell.addRow(bedarf[j], bedarf[j], len(index), index, np.ones(len(index)))
    -    for i in range(n_lager):
    -        index = np.array([i * n_kunden + j for j in range(n_kunden)] + [anzahl_x + i],
    -                         dtype=np.int32)
    -        werte = np.concatenate([np.ones(n_kunden), [-kapazitaet[i]]])
    -        modell.addRow(-unendlich, 0.0, len(index), index, werte)
    -
    -    t0 = time.perf_counter()
    -    modell.run()
    -    laufzeit = time.perf_counter() - t0
    -    info = modell.getInfo()
    -    return (modell.modelStatusToString(modell.getModelStatus()),
    -            info.objective_function_value, info.mip_gap, laufzeit,
    -            info.mip_node_count)
    -
    -
    -if __name__ == "__main__":
    -    # So ist das Unternehmen ueber zwei Jahre gewachsen.
    -    entwicklung = [("2024-Q1", 15, 40), ("2024-Q3", 25, 70),
    -                   ("2025-Q1", 40, 110), ("2025-Q3", 60, 160),
    -                   ("2026-Q1", 80, 220)]
    -
    -    print("=" * 84)
    -    print("  WAS DAS PROTOKOLL ZEIGT - UND WAS ES ZEIGEN SOLLTE")
    -    print("=" * 84)
    -    print(f"Derselbe Nachtjob, unveraendert, mit {ZEITLIMIT:.0f} Sekunden Zeitlimit.\n")
    -
    -    protokolle = []
    -    for zeitpunkt, lager, kunden in entwicklung:
    -        status, kosten, gap, laufzeit, knoten = plane_netzwerk(
    -            lager, kunden, ZEITLIMIT)
    -        protokolle.append(Laufprotokoll(zeitpunkt, lager, kunden, status,
    -                                        kosten, gap, laufzeit, knoten))
    -
    -    print("So sieht das ueblich gefuehrte Protokoll aus:\n")
    -    print(f"  {'Zeitpunkt':<10} {'Kosten':>12}")
    -    print("  " + "-" * 24)
    -    for p in protokolle:
    -        print(f"  {p.zeitpunkt:<10} {p.kosten:>12,.0f}")
    -    print("\n  -> Die Kosten steigen. Das Unternehmen waechst ja auch.")
    -    print("     Nichts an dieser Tabelle deutet auf ein Problem hin.")
    -
    -    print("\n" + "-" * 84)
    -    print("So sieht ein vollstaendiges Protokoll aus:\n")
    -    print(f"  {'Zeitpunkt':<10} {'Groesse':>10} {'Status':>20} {'Kosten':>11} "
    -          f"{'Gap':>8} {'Zeit':>7}")
    -    print("  " + "-" * 80)
    -    for p in protokolle:
    -        groesse = f"{p.lager}x{p.kunden}"
    -        print(f"  {p.zeitpunkt:<10} {groesse:>10} {p.status:>20} "
    -              f"{p.kosten:>11,.0f} {p.gap * 100:7.2f} % {p.laufzeit:6.2f}s")
    -
    -    print("\n" + "-" * 84)
    -    print("Und so sehen die Warnungen aus, die daraus folgen:\n")
    -    for p in protokolle:
    -        if p.warnungen:
    -            print(f"  {p.zeitpunkt}:  " + "; ".join(p.warnungen))
    -        else:
    -            print(f"  {p.zeitpunkt}:  in Ordnung")
    -
    -    letzte = protokolle[-1]
    -    erste_warnung = next(p for p in protokolle if p.warnungen)
    -
    -    print("\n" + "=" * 84)
    -    print("  WAS DA PASSIERT IST")
    -    print("=" * 84)
    -    print(f"Seit {erste_warnung.zeitpunkt} erreicht der Job das Zeitlimit und liefert")
    -    print("nicht mehr das Optimum, sondern die beste bis dahin gefundene Loesung.")
    -    print(f"Im letzten Lauf betraegt der Abstand zum Bestmoeglichen "
    -          f"{letzte.gap:.1%}.")
    -    print()
    -    print("Der Job ist nicht abgestuerzt. Er hat keine Fehlermeldung erzeugt.")
    -    print("Die Laufzeit ist sogar bemerkenswert STABIL geblieben - genau deshalb,")
    -    print("weil das Zeitlimit greift. Ein Ueberwachungssystem, das auf Abstuerze")
    -    print("und Laufzeitspitzen achtet, sieht hier nichts.")
    -    print()
    -    print("Drei Zahlen gehoeren deshalb in jedes Protokoll eines Optimierungsjobs:")
    -    print("  * der STATUS   - 'Optimal' oder etwas anderes?")
    -    print("  * der GAP      - wie weit ist die Loesung vom Bestmoeglichen entfernt?")
    -    print("  * die LAUFZEIT im Verhaeltnis zum Limit - wie nah am Anschlag?")
    -    print()
    -    print("Die dritte ist die frueheste Warnung: Sie steigt, lange bevor der Gap")
    -    print("sichtbar wird, und gibt Zeit zum Handeln.")
    -    print("=" * 84)
    +
    #!/usr/bin/env python3
    +
    +# Betriebsueberwachung.py
    +"""
    +Kapitel Praxisfallen: Das Modell, das still aufgehoert hat zu optimieren.
    +
    +Ein Optimierungsmodell geht in Betrieb und laeuft jede Nacht. Es liefert
    +zuverlaessig eine Zahl, die Job-Dauer bleibt konstant, es gibt keine
    +Fehlermeldung. Alles sieht gut aus.
    +
    +Waehrenddessen waechst das Unternehmen. Aus 15 Lagern werden 40, dann 80. Das
    +Zeitlimit von drei Sekunden - vor zwei Jahren grosszuegig bemessen - reicht
    +irgendwann nicht mehr. Der Solver bricht ab und liefert die beste Loesung, die
    +er bis dahin gefunden hat.
    +
    +Das ist nicht falsch. Es ist sogar richtig so. Aber es ist etwas anderes als
    +das, was das Modell einmal geleistet hat - und niemand merkt es, weil im
    +Protokoll nur die Kosten stehen.
    +
    +Dieses Programm zeigt die Verschlechterung und die Ueberwachung, die sie
    +sichtbar macht.
    +
    +WICHTIG: ortools wird hier nicht importiert (Konflikt mit highspy, siehe
    +Kapitel Oekosystem).
    +
    +Benoetigt: numpy, highspy
    +"""
    +
    +from __future__ import annotations
    +
    +import time
    +from dataclasses import dataclass
    +
    +import numpy as np
    +import highspy
    +
    +ZEITLIMIT = 3.0                  # das Budget, das der Nachtjob hat
    +GAP_WARNSCHWELLE = 0.02          # ab 2 % Gap wollen wir es wissen
    +ZEITAUSLASTUNG_WARNSCHWELLE = 0.9
    +
    +
    +@dataclass
    +class Laufprotokoll:
    +    """Was nach JEDEM Produktivlauf protokolliert gehoert.
    +
    +    Die meisten Systeme schreiben nur 'kosten'. Genau deshalb faellt eine
    +    schleichende Verschlechterung ueber Monate nicht auf.
    +    """
    +    zeitpunkt: str
    +    lager: int
    +    kunden: int
    +    status: str
    +    kosten: float
    +    gap: float
    +    laufzeit: float
    +    knoten: int
    +
    +    @property
    +    def warnungen(self) -> list[str]:
    +        meldungen = []
    +        if self.status != "Optimal":
    +            meldungen.append(f"nicht beweisbar optimal ({self.status})")
    +        if self.gap > GAP_WARNSCHWELLE:
    +            meldungen.append(f"Gap {self.gap:.1%} ueber Schwelle "
    +                             f"{GAP_WARNSCHWELLE:.0%}")
    +        if self.laufzeit > ZEITAUSLASTUNG_WARNSCHWELLE * ZEITLIMIT:
    +            meldungen.append(f"Zeitbudget zu {self.laufzeit / ZEITLIMIT:.0%} "
    +                             f"ausgeschoepft")
    +        return meldungen
    +
    +
    +def plane_netzwerk(n_lager: int, n_kunden: int, zeitlimit: float,
    +                   saat: int = 7) -> tuple[str, float, float, float, int]:
    +    """Standortplanung wie in Kapitel MILP, nur mit wachsender Groesse."""
    +    rng = np.random.default_rng(saat)
    +    fixkosten = rng.uniform(3000, 9000, n_lager)
    +    transport = rng.uniform(5, 60, (n_lager, n_kunden))
    +    bedarf = rng.uniform(10, 60, n_kunden)
    +    kapazitaet = np.full(n_lager, bedarf.sum() * 0.22)
    +
    +    modell = highspy.Highs()
    +    modell.setOptionValue("output_flag", False)
    +    modell.setOptionValue("time_limit", zeitlimit)
    +
    +    anzahl_x = n_lager * n_kunden
    +    unendlich = highspy.kHighsInf
    +    modell.addVars(anzahl_x, np.zeros(anzahl_x), np.full(anzahl_x, unendlich))
    +    modell.addVars(n_lager, np.zeros(n_lager), np.ones(n_lager))
    +    for i in range(n_lager):
    +        modell.changeColIntegrality(anzahl_x + i, highspy.HighsVarType.kInteger)
    +        modell.changeColCost(anzahl_x + i, fixkosten[i])
    +        for j in range(n_kunden):
    +            modell.changeColCost(i * n_kunden + j, transport[i, j])
    +
    +    for j in range(n_kunden):
    +        index = np.array([i * n_kunden + j for i in range(n_lager)], dtype=np.int32)
    +        modell.addRow(bedarf[j], bedarf[j], len(index), index, np.ones(len(index)))
    +    for i in range(n_lager):
    +        index = np.array([i * n_kunden + j for j in range(n_kunden)] + [anzahl_x + i],
    +                         dtype=np.int32)
    +        werte = np.concatenate([np.ones(n_kunden), [-kapazitaet[i]]])
    +        modell.addRow(-unendlich, 0.0, len(index), index, werte)
    +
    +    t0 = time.perf_counter()
    +    modell.run()
    +    laufzeit = time.perf_counter() - t0
    +    info = modell.getInfo()
    +    return (modell.modelStatusToString(modell.getModelStatus()),
    +            info.objective_function_value, info.mip_gap, laufzeit,
    +            info.mip_node_count)
    +
    +
    +if __name__ == "__main__":
    +    # So ist das Unternehmen ueber zwei Jahre gewachsen.
    +    entwicklung = [("2024-Q1", 15, 40), ("2024-Q3", 25, 70),
    +                   ("2025-Q1", 40, 110), ("2025-Q3", 60, 160),
    +                   ("2026-Q1", 80, 220)]
    +
    +    print("=" * 84)
    +    print("  WAS DAS PROTOKOLL ZEIGT - UND WAS ES ZEIGEN SOLLTE")
    +    print("=" * 84)
    +    print(f"Derselbe Nachtjob, unveraendert, mit {ZEITLIMIT:.0f} Sekunden Zeitlimit.\n")
    +
    +    protokolle = []
    +    for zeitpunkt, lager, kunden in entwicklung:
    +        status, kosten, gap, laufzeit, knoten = plane_netzwerk(
    +            lager, kunden, ZEITLIMIT)
    +        protokolle.append(Laufprotokoll(zeitpunkt, lager, kunden, status,
    +                                        kosten, gap, laufzeit, knoten))
    +
    +    print("So sieht das ueblich gefuehrte Protokoll aus:\n")
    +    print(f"  {'Zeitpunkt':<10} {'Kosten':>12}")
    +    print("  " + "-" * 24)
    +    for p in protokolle:
    +        print(f"  {p.zeitpunkt:<10} {p.kosten:>12,.0f}")
    +    print("\n  -> Die Kosten steigen. Das Unternehmen waechst ja auch.")
    +    print("     Nichts an dieser Tabelle deutet auf ein Problem hin.")
    +
    +    print("\n" + "-" * 84)
    +    print("So sieht ein vollstaendiges Protokoll aus:\n")
    +    print(f"  {'Zeitpunkt':<10} {'Groesse':>10} {'Status':>20} {'Kosten':>11} "
    +          f"{'Gap':>8} {'Zeit':>7}")
    +    print("  " + "-" * 80)
    +    for p in protokolle:
    +        groesse = f"{p.lager}x{p.kunden}"
    +        print(f"  {p.zeitpunkt:<10} {groesse:>10} {p.status:>20} "
    +              f"{p.kosten:>11,.0f} {p.gap * 100:7.2f} % {p.laufzeit:6.2f}s")
    +
    +    print("\n" + "-" * 84)
    +    print("Und so sehen die Warnungen aus, die daraus folgen:\n")
    +    for p in protokolle:
    +        if p.warnungen:
    +            print(f"  {p.zeitpunkt}:  " + "; ".join(p.warnungen))
    +        else:
    +            print(f"  {p.zeitpunkt}:  in Ordnung")
    +
    +    letzte = protokolle[-1]
    +    erste_warnung = next(p for p in protokolle if p.warnungen)
    +
    +    print("\n" + "=" * 84)
    +    print("  WAS DA PASSIERT IST")
    +    print("=" * 84)
    +    print(f"Seit {erste_warnung.zeitpunkt} erreicht der Job das Zeitlimit und liefert")
    +    print("nicht mehr das Optimum, sondern die beste bis dahin gefundene Loesung.")
    +    print(f"Im letzten Lauf betraegt der Abstand zum Bestmoeglichen "
    +          f"{letzte.gap:.1%}.")
    +    print()
    +    print("Der Job ist nicht abgestuerzt. Er hat keine Fehlermeldung erzeugt.")
    +    print("Die Laufzeit ist sogar bemerkenswert STABIL geblieben - genau deshalb,")
    +    print("weil das Zeitlimit greift. Ein Ueberwachungssystem, das auf Abstuerze")
    +    print("und Laufzeitspitzen achtet, sieht hier nichts.")
    +    print()
    +    print("Drei Zahlen gehoeren deshalb in jedes Protokoll eines Optimierungsjobs:")
    +    print("  * der STATUS   - 'Optimal' oder etwas anderes?")
    +    print("  * der GAP      - wie weit ist die Loesung vom Bestmoeglichen entfernt?")
    +    print("  * die LAUFZEIT im Verhaeltnis zum Limit - wie nah am Anschlag?")
    +    print()
    +    print("Die dritte ist die frueheste Warnung: Sie steigt, lange bevor der Gap")
    +    print("sichtbar wird, und gibt Zeit zum Handeln.")
    +    print("=" * 84)

    Erwartete Ausgabe (Zeiten hardwareabhängig):

    ====================================================================================
       WAS DAS PROTOKOLL ZEIGT - UND WAS ES ZEIGEN SOLLTE
    @@ -27084,22 +27400,22 @@ sichtbar wird, und gibt Zeit zum Handeln.
     

    🚀 In 5 Minuten gelöst: drei Zeilen, die einen echten Fehler fangen

    Die Schreinerei aus Kapitel 1, nur mit etwas knapperen Kapazitäten. Der Solver liefert einen Plan, halbe Tische gibt es nicht — also wird gerundet.

    -
    import numpy as np
    -from scipy.optimize import linprog
    -
    -verbrauch_je_stueck = np.array([[3.0, 1.0],    # Montagestunden je Tisch/Stuhl
    -                                [6.0, 1.0]])   # Plattenmaterial je Tisch/Stuhl
    -kapazitaet = np.array([140.0, 232.0])
    -deckungsbeitrag = np.array([240.0, 60.0])
    -
    -loesung = linprog(-deckungsbeitrag, A_ub=verbrauch_je_stueck, b_ub=kapazitaet,
    -                  bounds=(0, None), method="highs")
    -plan = np.round(loesung.x)                     # "Halbe Tische gibt es nicht"
    -
    -verbrauch = verbrauch_je_stueck @ plan         # DER TEST
    -assert (verbrauch <= kapazitaet + 1e-6).all(), \
    -    f"Plan {plan} braucht {verbrauch}, verfuegbar sind {kapazitaet}"
    -print("Abnahme bestanden:", plan)
    +
    import numpy as np
    +from scipy.optimize import linprog
    +
    +verbrauch_je_stueck = np.array([[3.0, 1.0],    # Montagestunden je Tisch/Stuhl
    +                                [6.0, 1.0]])   # Plattenmaterial je Tisch/Stuhl
    +kapazitaet = np.array([140.0, 232.0])
    +deckungsbeitrag = np.array([240.0, 60.0])
    +
    +loesung = linprog(-deckungsbeitrag, A_ub=verbrauch_je_stueck, b_ub=kapazitaet,
    +                  bounds=(0, None), method="highs")
    +plan = np.round(loesung.x)                     # "Halbe Tische gibt es nicht"
    +
    +verbrauch = verbrauch_je_stueck @ plan         # DER TEST
    +assert (verbrauch <= kapazitaet + 1e-6).all(), \
    +    f"Plan {plan} braucht {verbrauch}, verfuegbar sind {kapazitaet}"
    +print("Abnahme bestanden:", plan)

    Ausgabe:

    Traceback (most recent call last):
       File "schnellstart.py", line 14, in <module>
    @@ -27126,7 +27442,7 @@ AssertionError: Plan [31. 48.] braucht [141. 234.], verfuegbar sind [140. 232.]<
     

    23.3 Warum Optimierungsmodelle schwer zu testen sind

    Bei einer gewöhnlichen Funktion schreibt man den erwarteten Wert hin:

    -
    assert steuer(50_000) == 12_345.60
    +
    assert steuer(50_000) == 12_345.60

    Bei „der beste Produktionsplan für 400 Aufträge” gibt es niemanden, der die Antwort unabhängig ausrechnen könnte. Gäbe es ihn, bräuchte man den Solver nicht. Damit fällt die naheliegende Testform aus — und mit ihr die Illusion, man könne Optimierungscode wie Geschäftslogik testen.

    Was bleibt, sind vier Ersatzformen:

    SolverStatus.modellfehler trennt Modell- von ZeitproblemenGenau die Unterscheidung, die Abschnitt 7.7 als häufigste Verwechslung benennt.Genau die Unterscheidung, die Abschnitt 7.8 als häufigste Verwechslung benennt.
    model_validator(mode="after")
    @@ -27173,315 +27489,315 @@ AssertionError: Plan [31. 48.] braucht [141. 234.], verfuegbar sind [140. 232.]<

    23.4 Die Testsuite

    -
    #!/usr/bin/env python3
    -
    -# test_or_kern.py
    -"""
    -Kapitel Testen: Eine Testsuite fuer ein Optimierungsmodell.
    -
    -Das Grundproblem beim Testen von Optimierungsmodellen: Man kennt die richtige
    -Antwort nicht. Bei einer Funktion `steuer(brutto)` schreibt man den erwarteten
    -Wert hin. Bei "der beste Produktionsplan fuer 400 Auftraege" gibt es niemanden,
    -der ihn unabhaengig ausrechnen koennte - sonst braeuchte man den Solver nicht.
    -
    -Deshalb testet man nicht den WERT, sondern vier andere Dinge:
    -
    -  1. EIGENSCHAFTEN  Die Loesung haelt jede Nebenbedingung ein, und der
    -                    ausgewiesene Zielwert passt zu den Mengen.
    -  2. INVARIANTEN    Was das Ergebnis NICHT aendern darf: die Reihenfolge der
    -                    Produkte, die Waehrungseinheit, ein zusaetzliches Produkt
    -                    mit Deckungsbeitrag 0.
    -  3. REGRESSION     Eine kleine Instanz mit von Hand nachgerechnetem Optimum.
    -  4. FEHLERFAELLE   Unsinnige Eingaben werden abgewiesen, unloesbare Modelle
    -                    als unloesbar erkannt - und die Abnahmepruefung schlaegt
    -                    tatsaechlich an, wenn man ihr eine kaputte Loesung gibt.
    -
    -Der vierte Punkt ist der wichtigste und wird am haeufigsten vergessen: Eine
    -Pruefung, die noch nie etwas gefunden hat, ist keine Pruefung, sondern eine
    -Vermutung. Die Tests unten fuettern `pruefe_loesung()` deshalb absichtlich mit
    -falschen Loesungen und verlangen, dass sie anschlaegt.
    -
    -Aufruf:
    -    pytest test_or_kern.py -v
    -    python3 test_or_kern.py          # ruft pytest selbst auf
    -
    -Benoetigt: pytest, numpy, pydantic, ortools, scipy (ueber or_kern)
    -"""
    -
    -from __future__ import annotations
    -
    -import pytest
    -
    -from or_kern import (Loesung, Produkt, Produktionsproblem, SolverStatus,
    -                     loese_mit_glop, loese_mit_scipy, pruefe_loesung,
    -                     status_von_scipy)
    -
    -
    -# --- Die Instanz, gegen die getestet wird ----------------------------------
    -
    -def schreinerei() -> Produktionsproblem:
    -    """Der Fall aus Kapitel Einfuehrung - klein genug fuer die Handrechnung.
    -
    -    Optimum: 30 Tische, 60 Stuehle, Deckungsbeitrag 10.800 EUR.
    -    Beide Ressourcen sind voll ausgelastet (3*30 + 1*60 = 150,
    -    6*30 + 1*60 = 240).
    -    """
    -    return Produktionsproblem(
    -        produkte=[
    -            Produkt(name="Tisch", deckungsbeitrag=240.0,
    -                    verbrauch={"Montagestunden": 3.0, "Plattenmaterial": 6.0}),
    -            Produkt(name="Stuhl", deckungsbeitrag=60.0,
    -                    verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0}),
    -        ],
    -        kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0},
    -    )
    -
    -
    -# Beide Modellbauer durchlaufen JEDEN Test. Ein Test, der nur mit einem Solver
    -# laeuft, prueft die Bibliothek mit - nicht das Modell.
    -MODELLBAUER = [loese_mit_glop, loese_mit_scipy]
    -NAMEN = ["glop", "scipy"]
    -
    -
    -@pytest.fixture(params=MODELLBAUER, ids=NAMEN)
    -def bauer(request):
    -    return request.param
    -
    -
    -# --- 1. Eigenschaften ------------------------------------------------------
    -
    -def test_loesung_haelt_alle_nebenbedingungen_ein(bauer):
    -    """Die Abnahmepruefung darf nichts zu beanstanden haben."""
    -    problem = schreinerei()
    -    loesung = bauer(problem)
    -    assert loesung.status is SolverStatus.OPTIMAL
    -    assert pruefe_loesung(problem, loesung) == []
    -
    -
    -def test_zielwert_passt_zu_den_mengen(bauer):
    -    """Der ausgewiesene Zielwert wird unabhaengig nachgerechnet.
    -
    -    Klingt trivial, ist es nicht: Wer die Zielfunktion im Modell anders
    -    zusammensetzt als im Bericht (etwa Gebuehren einmal abgezogen, einmal
    -    nicht), merkt es sonst nie.
    -    """
    -    problem = schreinerei()
    -    loesung = bauer(problem)
    -    nachgerechnet = sum(p.deckungsbeitrag * loesung.werte[p.name]
    -                        for p in problem.produkte)
    -    assert loesung.zielwert == pytest.approx(nachgerechnet, abs=1e-6)
    -
    -
    -def test_beide_solver_liefern_dasselbe():
    -    """Der Kern der Architektur aus Kapitel Praxisfallen, als Test."""
    -    problem = schreinerei()
    -    glop, scipy_ = loese_mit_glop(problem), loese_mit_scipy(problem)
    -    assert glop.zielwert == pytest.approx(scipy_.zielwert, abs=1e-6)
    -    for ressource in problem.ressourcen:
    -        assert glop.schattenpreise[ressource] == pytest.approx(
    -            scipy_.schattenpreise[ressource], abs=1e-6)
    -
    -
    -# --- 2. Invarianten --------------------------------------------------------
    -
    -def test_produktreihenfolge_aendert_nichts(bauer):
    -    """Dieselben Daten in anderer Zeilenreihenfolge - gleiches Ergebnis.
    -
    -    Das faengt die Spaltenvertauschungsfalle aus Kapitel Finanzdaten ab: ein
    -    Modell, das Positionen statt Namen benutzt, faellt hier durch.
    -    """
    -    problem = schreinerei()
    -    gedreht = Produktionsproblem(produkte=list(reversed(problem.produkte)),
    -                                 kapazitaeten=problem.kapazitaeten)
    -    assert bauer(problem).zielwert == pytest.approx(bauer(gedreht).zielwert)
    -
    -
    -def test_waehrungseinheit_skaliert_linear(bauer):
    -    """Deckungsbeitraege in Cent statt Euro: Zielwert mal 100, Mengen gleich.
    -
    -    Der Test prueft nicht die Mathematik - die ist offensichtlich -, sondern
    -    die NUMERIK. Wer schlecht skalierte Modelle baut (Kapitel Fundament),
    -    bekommt hier Abweichungen weit ueber der Toleranz.
    -    """
    -    problem = schreinerei()
    -    in_cent = Produktionsproblem(
    -        produkte=[Produkt(name=p.name, deckungsbeitrag=p.deckungsbeitrag * 100,
    -                          verbrauch=p.verbrauch) for p in problem.produkte],
    -        kapazitaeten=problem.kapazitaeten)
    -    basis, skaliert = bauer(problem), bauer(in_cent)
    -    assert skaliert.zielwert == pytest.approx(basis.zielwert * 100, rel=1e-9)
    -    for p in problem.produkte:
    -        assert skaliert.werte[p.name] == pytest.approx(basis.werte[p.name],
    -                                                       abs=1e-6)
    -
    -
    -def test_produkt_ohne_deckungsbeitrag_aendert_das_optimum_nicht(bauer):
    -    """Ein Produkt, das nichts einbringt, darf den Zielwert nicht heben."""
    -    problem = schreinerei()
    -    mit_nullprodukt = Produktionsproblem(
    -        produkte=problem.produkte + [
    -            Produkt(name="Muster", deckungsbeitrag=0.0,
    -                    verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0})],
    -        kapazitaeten=problem.kapazitaeten)
    -    assert bauer(mit_nullprodukt).zielwert <= bauer(problem).zielwert + 1e-6
    -
    -
    -# --- 3. Regression ---------------------------------------------------------
    -
    -def test_bekanntes_optimum(bauer):
    -    """Von Hand nachgerechnet (Kapitel Einfuehrung, Handrechnung).
    -
    -    Das ist der einzige Test, der einen ZAHLENWERT festschreibt - und er darf
    -    es, weil dieser Wert von Hand belegt ist. Fuer grosse Instanzen gibt es
    -    diesen Test nicht; dort tragen die Eigenschaften und Invarianten.
    -    """
    -    loesung = bauer(schreinerei())
    -    assert loesung.zielwert == pytest.approx(10_800.0, abs=1e-6)
    -    assert loesung.werte["Tisch"] == pytest.approx(30.0, abs=1e-6)
    -    assert loesung.werte["Stuhl"] == pytest.approx(60.0, abs=1e-6)
    -
    -
    -def test_schattenpreise_bekannt(bauer):
    -    """40 EUR je Montagestunde, 20 EUR je Einheit Plattenmaterial."""
    -    loesung = bauer(schreinerei())
    -    assert loesung.schattenpreise["Montagestunden"] == pytest.approx(40.0, abs=1e-6)
    -    assert loesung.schattenpreise["Plattenmaterial"] == pytest.approx(20.0, abs=1e-6)
    -
    -
    -# --- 4. Fehlerfaelle: schlaegt die Pruefung ueberhaupt an? -----------------
    -
    -def test_pruefung_findet_kapazitaetsverletzung():
    -    """Eine Loesung, die zu viel verbraucht, MUSS beanstandet werden."""
    -    problem = schreinerei()
    -    kaputt = Loesung(status=SolverStatus.OPTIMAL,
    -                     werte={"Tisch": 50.0, "Stuhl": 60.0},   # 3*50+60 = 210 > 150
    -                     zielwert=50 * 240.0 + 60 * 60.0)
    -    beanstandungen = pruefe_loesung(problem, kaputt)
    -    assert any("Montagestunden" in b for b in beanstandungen)
    -
    -
    -def test_pruefung_findet_falschen_zielwert():
    -    """Mengen und Zielwert passen nicht zusammen - der haeufigste stille Fehler."""
    -    problem = schreinerei()
    -    kaputt = Loesung(status=SolverStatus.OPTIMAL,
    -                     werte={"Tisch": 30.0, "Stuhl": 60.0},
    -                     zielwert=99_999.0)
    -    assert any("Zielwert" in b for b in pruefe_loesung(problem, kaputt))
    -
    -
    -def test_pruefung_findet_negative_mengen():
    -    problem = schreinerei()
    -    kaputt = Loesung(status=SolverStatus.OPTIMAL,
    -                     werte={"Tisch": -5.0, "Stuhl": 60.0},
    -                     zielwert=-5 * 240.0 + 60 * 60.0)
    -    assert any("negativ" in b for b in pruefe_loesung(problem, kaputt))
    -
    -
    -def test_pruefung_findet_gebrochene_ganzzahligkeit():
    -    """Die Falle aus Kapitel MILP: 0,99999998 ist nicht 1."""
    -    problem = schreinerei()
    -    fast_ganz = Loesung(status=SolverStatus.OPTIMAL,
    -                        werte={"Tisch": 29.4, "Stuhl": 61.8},
    -                        zielwert=29.4 * 240.0 + 61.8 * 60.0)
    -    beanstandungen = pruefe_loesung(problem, fast_ganz, ganzzahlig=["Tisch"])
    -    assert any("ganzzahlig" in b for b in beanstandungen)
    -
    -
    -def test_pruefung_verweigert_unbrauchbaren_status():
    -    """Ohne verwertbares Ergebnis wird gar nicht erst gerechnet."""
    -    problem = schreinerei()
    -    ohne = Loesung(status=SolverStatus.UNZULAESSIG)
    -    beanstandungen = pruefe_loesung(problem, ohne)
    -    assert len(beanstandungen) == 1 and "unzulaessig" in beanstandungen[0]
    -
    -
    -@pytest.mark.parametrize("daten, stichwort", [
    -    (dict(produkte=[Produkt(name="Tisch", deckungsbeitrag=240.0,
    -                            verbrauch={"Montagestunden": 3.0})],
    -          kapazitaeten={"Montagestunden": 0.0}), "greater than"),
    -    (dict(produkte=[Produkt(name="Regal", deckungsbeitrag=130.0,
    -                            verbrauch={"Lackieren": 2.0})],
    -          kapazitaeten={"Montagestunden": 150.0}), "Kapazitaetsangabe"),
    -    (dict(produkte=[], kapazitaeten={"Montagestunden": 150.0}), "at least 1"),
    -])
    -def test_unsinnige_eingaben_werden_beim_einlesen_abgewiesen(daten, stichwort):
    -    """Drei Fehler, die NICHT erst beim Loesen auffallen duerfen."""
    -    with pytest.raises(Exception) as fehler:
    -        Produktionsproblem(**daten)
    -    assert stichwort in str(fehler.value)
    -
    -
    -def test_doppelter_produktname_wird_abgewiesen():
    -    produkt = Produkt(name="Tisch", deckungsbeitrag=240.0,
    -                      verbrauch={"Montagestunden": 3.0})
    -    with pytest.raises(Exception) as fehler:
    -        Produktionsproblem(produkte=[produkt, produkt],
    -                           kapazitaeten={"Montagestunden": 150.0})
    -    assert "mehrfach" in str(fehler.value)
    -
    -
    -@pytest.mark.parametrize("rohstatus, erwartet", [
    -    (0, SolverStatus.OPTIMAL),
    -    (1, SolverStatus.ZEITLIMIT),
    -    (2, SolverStatus.UNZULAESSIG),
    -    (3, SolverStatus.UNBESCHRAENKT),
    -    (4, SolverStatus.FEHLERHAFT),
    -    (99, SolverStatus.UNBEKANNT),
    -])
    -def test_statusuebersetzung_scipy(rohstatus, erwartet):
    -    """Die Uebersetzertabelle selbst - inklusive des unbekannten Falls."""
    -    class Ergebnis:
    -        status = rohstatus
    -    assert status_von_scipy(Ergebnis()) is erwartet
    -
    -
    -def test_status_eigenschaften_sind_konsistent():
    -    """brauchbar und modellfehler duerfen sich nie ueberschneiden."""
    -    for status in SolverStatus:
    -        assert not (status.brauchbar and status.modellfehler)
    -
    -
    -# Die beiden folgenden Tests gab es zuerst nicht. Sie sind entstanden, weil
    -# Mutationstest.py zwei eingebaute Fehler UEBERLEBEN liess - siehe den
    -# Abschnitt "Wer testet die Tests?".
    -
    -def test_nur_optimal_und_zulaessig_gelten_als_brauchbar():
    -    """ZEITLIMIT ist NICHT brauchbar - der Solver hat nichts gefunden.
    -
    -    Der Unterschied entscheidet, ob ein Nachtjob einen Plan ausliefert oder
    -    Alarm schlaegt. Ein aufgeweichtes 'brauchbar' faellt sonst nirgends auf.
    -    """
    -    assert SolverStatus.OPTIMAL.brauchbar
    -    assert SolverStatus.ZULAESSIG.brauchbar
    -    for status in (SolverStatus.ZEITLIMIT, SolverStatus.UNZULAESSIG,
    -                   SolverStatus.UNBESCHRAENKT, SolverStatus.FEHLERHAFT,
    -                   SolverStatus.UNBEKANNT):
    -        assert not status.brauchbar, f"{status.value} darf nicht brauchbar sein"
    -
    -
    -def test_ressourcenreihenfolge_haengt_nicht_an_der_eingabe():
    -    """Zwei Mappings mit gleichem Inhalt, andere Einfuegereihenfolge.
    -
    -    Die Verbrauchsmatrix muss zeilenweise identisch sein. Ohne diese
    -    Zusicherung passt die Matrix irgendwann nicht mehr zum
    -    Kapazitaetsvektor - die Vertauschungsfalle aus Kapitel Finanzdaten,
    -    nur eine Ebene tiefer.
    -    """
    -    produkte = schreinerei().produkte
    -    vorwaerts = Produktionsproblem(
    -        produkte=produkte,
    -        kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0})
    -    rueckwaerts = Produktionsproblem(
    -        produkte=produkte,
    -        kapazitaeten={"Plattenmaterial": 240.0, "Montagestunden": 150.0})
    -    assert vorwaerts.ressourcen == rueckwaerts.ressourcen
    -    assert (vorwaerts.verbrauchsmatrix() == rueckwaerts.verbrauchsmatrix()).all()
    -    assert (vorwaerts.kapazitaetsvektor() == rueckwaerts.kapazitaetsvektor()).all()
    -
    -
    -if __name__ == "__main__":
    -    import sys
    -    sys.exit(pytest.main([__file__, "-v", "--tb=short", "-p", "no:cacheprovider"]))
    +
    #!/usr/bin/env python3
    +
    +# test_or_kern.py
    +"""
    +Kapitel Testen: Eine Testsuite fuer ein Optimierungsmodell.
    +
    +Das Grundproblem beim Testen von Optimierungsmodellen: Man kennt die richtige
    +Antwort nicht. Bei einer Funktion `steuer(brutto)` schreibt man den erwarteten
    +Wert hin. Bei "der beste Produktionsplan fuer 400 Auftraege" gibt es niemanden,
    +der ihn unabhaengig ausrechnen koennte - sonst braeuchte man den Solver nicht.
    +
    +Deshalb testet man nicht den WERT, sondern vier andere Dinge:
    +
    +  1. EIGENSCHAFTEN  Die Loesung haelt jede Nebenbedingung ein, und der
    +                    ausgewiesene Zielwert passt zu den Mengen.
    +  2. INVARIANTEN    Was das Ergebnis NICHT aendern darf: die Reihenfolge der
    +                    Produkte, die Waehrungseinheit, ein zusaetzliches Produkt
    +                    mit Deckungsbeitrag 0.
    +  3. REGRESSION     Eine kleine Instanz mit von Hand nachgerechnetem Optimum.
    +  4. FEHLERFAELLE   Unsinnige Eingaben werden abgewiesen, unloesbare Modelle
    +                    als unloesbar erkannt - und die Abnahmepruefung schlaegt
    +                    tatsaechlich an, wenn man ihr eine kaputte Loesung gibt.
    +
    +Der vierte Punkt ist der wichtigste und wird am haeufigsten vergessen: Eine
    +Pruefung, die noch nie etwas gefunden hat, ist keine Pruefung, sondern eine
    +Vermutung. Die Tests unten fuettern `pruefe_loesung()` deshalb absichtlich mit
    +falschen Loesungen und verlangen, dass sie anschlaegt.
    +
    +Aufruf:
    +    pytest test_or_kern.py -v
    +    python3 test_or_kern.py          # ruft pytest selbst auf
    +
    +Benoetigt: pytest, numpy, pydantic, ortools, scipy (ueber or_kern)
    +"""
    +
    +from __future__ import annotations
    +
    +import pytest
    +
    +from or_kern import (Loesung, Produkt, Produktionsproblem, SolverStatus,
    +                     loese_mit_glop, loese_mit_scipy, pruefe_loesung,
    +                     status_von_scipy)
    +
    +
    +# --- Die Instanz, gegen die getestet wird ----------------------------------
    +
    +def schreinerei() -> Produktionsproblem:
    +    """Der Fall aus Kapitel Einfuehrung - klein genug fuer die Handrechnung.
    +
    +    Optimum: 30 Tische, 60 Stuehle, Deckungsbeitrag 10.800 EUR.
    +    Beide Ressourcen sind voll ausgelastet (3*30 + 1*60 = 150,
    +    6*30 + 1*60 = 240).
    +    """
    +    return Produktionsproblem(
    +        produkte=[
    +            Produkt(name="Tisch", deckungsbeitrag=240.0,
    +                    verbrauch={"Montagestunden": 3.0, "Plattenmaterial": 6.0}),
    +            Produkt(name="Stuhl", deckungsbeitrag=60.0,
    +                    verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0}),
    +        ],
    +        kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0},
    +    )
    +
    +
    +# Beide Modellbauer durchlaufen JEDEN Test. Ein Test, der nur mit einem Solver
    +# laeuft, prueft die Bibliothek mit - nicht das Modell.
    +MODELLBAUER = [loese_mit_glop, loese_mit_scipy]
    +NAMEN = ["glop", "scipy"]
    +
    +
    +@pytest.fixture(params=MODELLBAUER, ids=NAMEN)
    +def bauer(request):
    +    return request.param
    +
    +
    +# --- 1. Eigenschaften ------------------------------------------------------
    +
    +def test_loesung_haelt_alle_nebenbedingungen_ein(bauer):
    +    """Die Abnahmepruefung darf nichts zu beanstanden haben."""
    +    problem = schreinerei()
    +    loesung = bauer(problem)
    +    assert loesung.status is SolverStatus.OPTIMAL
    +    assert pruefe_loesung(problem, loesung) == []
    +
    +
    +def test_zielwert_passt_zu_den_mengen(bauer):
    +    """Der ausgewiesene Zielwert wird unabhaengig nachgerechnet.
    +
    +    Klingt trivial, ist es nicht: Wer die Zielfunktion im Modell anders
    +    zusammensetzt als im Bericht (etwa Gebuehren einmal abgezogen, einmal
    +    nicht), merkt es sonst nie.
    +    """
    +    problem = schreinerei()
    +    loesung = bauer(problem)
    +    nachgerechnet = sum(p.deckungsbeitrag * loesung.werte[p.name]
    +                        for p in problem.produkte)
    +    assert loesung.zielwert == pytest.approx(nachgerechnet, abs=1e-6)
    +
    +
    +def test_beide_solver_liefern_dasselbe():
    +    """Der Kern der Architektur aus Kapitel Praxisfallen, als Test."""
    +    problem = schreinerei()
    +    glop, scipy_ = loese_mit_glop(problem), loese_mit_scipy(problem)
    +    assert glop.zielwert == pytest.approx(scipy_.zielwert, abs=1e-6)
    +    for ressource in problem.ressourcen:
    +        assert glop.schattenpreise[ressource] == pytest.approx(
    +            scipy_.schattenpreise[ressource], abs=1e-6)
    +
    +
    +# --- 2. Invarianten --------------------------------------------------------
    +
    +def test_produktreihenfolge_aendert_nichts(bauer):
    +    """Dieselben Daten in anderer Zeilenreihenfolge - gleiches Ergebnis.
    +
    +    Das faengt die Spaltenvertauschungsfalle aus Kapitel Finanzdaten ab: ein
    +    Modell, das Positionen statt Namen benutzt, faellt hier durch.
    +    """
    +    problem = schreinerei()
    +    gedreht = Produktionsproblem(produkte=list(reversed(problem.produkte)),
    +                                 kapazitaeten=problem.kapazitaeten)
    +    assert bauer(problem).zielwert == pytest.approx(bauer(gedreht).zielwert)
    +
    +
    +def test_waehrungseinheit_skaliert_linear(bauer):
    +    """Deckungsbeitraege in Cent statt Euro: Zielwert mal 100, Mengen gleich.
    +
    +    Der Test prueft nicht die Mathematik - die ist offensichtlich -, sondern
    +    die NUMERIK. Wer schlecht skalierte Modelle baut (Kapitel Fundament),
    +    bekommt hier Abweichungen weit ueber der Toleranz.
    +    """
    +    problem = schreinerei()
    +    in_cent = Produktionsproblem(
    +        produkte=[Produkt(name=p.name, deckungsbeitrag=p.deckungsbeitrag * 100,
    +                          verbrauch=p.verbrauch) for p in problem.produkte],
    +        kapazitaeten=problem.kapazitaeten)
    +    basis, skaliert = bauer(problem), bauer(in_cent)
    +    assert skaliert.zielwert == pytest.approx(basis.zielwert * 100, rel=1e-9)
    +    for p in problem.produkte:
    +        assert skaliert.werte[p.name] == pytest.approx(basis.werte[p.name],
    +                                                       abs=1e-6)
    +
    +
    +def test_produkt_ohne_deckungsbeitrag_aendert_das_optimum_nicht(bauer):
    +    """Ein Produkt, das nichts einbringt, darf den Zielwert nicht heben."""
    +    problem = schreinerei()
    +    mit_nullprodukt = Produktionsproblem(
    +        produkte=problem.produkte + [
    +            Produkt(name="Muster", deckungsbeitrag=0.0,
    +                    verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0})],
    +        kapazitaeten=problem.kapazitaeten)
    +    assert bauer(mit_nullprodukt).zielwert <= bauer(problem).zielwert + 1e-6
    +
    +
    +# --- 3. Regression ---------------------------------------------------------
    +
    +def test_bekanntes_optimum(bauer):
    +    """Von Hand nachgerechnet (Kapitel Einfuehrung, Handrechnung).
    +
    +    Das ist der einzige Test, der einen ZAHLENWERT festschreibt - und er darf
    +    es, weil dieser Wert von Hand belegt ist. Fuer grosse Instanzen gibt es
    +    diesen Test nicht; dort tragen die Eigenschaften und Invarianten.
    +    """
    +    loesung = bauer(schreinerei())
    +    assert loesung.zielwert == pytest.approx(10_800.0, abs=1e-6)
    +    assert loesung.werte["Tisch"] == pytest.approx(30.0, abs=1e-6)
    +    assert loesung.werte["Stuhl"] == pytest.approx(60.0, abs=1e-6)
    +
    +
    +def test_schattenpreise_bekannt(bauer):
    +    """40 EUR je Montagestunde, 20 EUR je Einheit Plattenmaterial."""
    +    loesung = bauer(schreinerei())
    +    assert loesung.schattenpreise["Montagestunden"] == pytest.approx(40.0, abs=1e-6)
    +    assert loesung.schattenpreise["Plattenmaterial"] == pytest.approx(20.0, abs=1e-6)
    +
    +
    +# --- 4. Fehlerfaelle: schlaegt die Pruefung ueberhaupt an? -----------------
    +
    +def test_pruefung_findet_kapazitaetsverletzung():
    +    """Eine Loesung, die zu viel verbraucht, MUSS beanstandet werden."""
    +    problem = schreinerei()
    +    kaputt = Loesung(status=SolverStatus.OPTIMAL,
    +                     werte={"Tisch": 50.0, "Stuhl": 60.0},   # 3*50+60 = 210 > 150
    +                     zielwert=50 * 240.0 + 60 * 60.0)
    +    beanstandungen = pruefe_loesung(problem, kaputt)
    +    assert any("Montagestunden" in b for b in beanstandungen)
    +
    +
    +def test_pruefung_findet_falschen_zielwert():
    +    """Mengen und Zielwert passen nicht zusammen - der haeufigste stille Fehler."""
    +    problem = schreinerei()
    +    kaputt = Loesung(status=SolverStatus.OPTIMAL,
    +                     werte={"Tisch": 30.0, "Stuhl": 60.0},
    +                     zielwert=99_999.0)
    +    assert any("Zielwert" in b for b in pruefe_loesung(problem, kaputt))
    +
    +
    +def test_pruefung_findet_negative_mengen():
    +    problem = schreinerei()
    +    kaputt = Loesung(status=SolverStatus.OPTIMAL,
    +                     werte={"Tisch": -5.0, "Stuhl": 60.0},
    +                     zielwert=-5 * 240.0 + 60 * 60.0)
    +    assert any("negativ" in b for b in pruefe_loesung(problem, kaputt))
    +
    +
    +def test_pruefung_findet_gebrochene_ganzzahligkeit():
    +    """Die Falle aus Kapitel MILP: 0,99999998 ist nicht 1."""
    +    problem = schreinerei()
    +    fast_ganz = Loesung(status=SolverStatus.OPTIMAL,
    +                        werte={"Tisch": 29.4, "Stuhl": 61.8},
    +                        zielwert=29.4 * 240.0 + 61.8 * 60.0)
    +    beanstandungen = pruefe_loesung(problem, fast_ganz, ganzzahlig=["Tisch"])
    +    assert any("ganzzahlig" in b for b in beanstandungen)
    +
    +
    +def test_pruefung_verweigert_unbrauchbaren_status():
    +    """Ohne verwertbares Ergebnis wird gar nicht erst gerechnet."""
    +    problem = schreinerei()
    +    ohne = Loesung(status=SolverStatus.UNZULAESSIG)
    +    beanstandungen = pruefe_loesung(problem, ohne)
    +    assert len(beanstandungen) == 1 and "unzulaessig" in beanstandungen[0]
    +
    +
    +@pytest.mark.parametrize("daten, stichwort", [
    +    (dict(produkte=[Produkt(name="Tisch", deckungsbeitrag=240.0,
    +                            verbrauch={"Montagestunden": 3.0})],
    +          kapazitaeten={"Montagestunden": 0.0}), "greater than"),
    +    (dict(produkte=[Produkt(name="Regal", deckungsbeitrag=130.0,
    +                            verbrauch={"Lackieren": 2.0})],
    +          kapazitaeten={"Montagestunden": 150.0}), "Kapazitaetsangabe"),
    +    (dict(produkte=[], kapazitaeten={"Montagestunden": 150.0}), "at least 1"),
    +])
    +def test_unsinnige_eingaben_werden_beim_einlesen_abgewiesen(daten, stichwort):
    +    """Drei Fehler, die NICHT erst beim Loesen auffallen duerfen."""
    +    with pytest.raises(Exception) as fehler:
    +        Produktionsproblem(**daten)
    +    assert stichwort in str(fehler.value)
    +
    +
    +def test_doppelter_produktname_wird_abgewiesen():
    +    produkt = Produkt(name="Tisch", deckungsbeitrag=240.0,
    +                      verbrauch={"Montagestunden": 3.0})
    +    with pytest.raises(Exception) as fehler:
    +        Produktionsproblem(produkte=[produkt, produkt],
    +                           kapazitaeten={"Montagestunden": 150.0})
    +    assert "mehrfach" in str(fehler.value)
    +
    +
    +@pytest.mark.parametrize("rohstatus, erwartet", [
    +    (0, SolverStatus.OPTIMAL),
    +    (1, SolverStatus.ZEITLIMIT),
    +    (2, SolverStatus.UNZULAESSIG),
    +    (3, SolverStatus.UNBESCHRAENKT),
    +    (4, SolverStatus.FEHLERHAFT),
    +    (99, SolverStatus.UNBEKANNT),
    +])
    +def test_statusuebersetzung_scipy(rohstatus, erwartet):
    +    """Die Uebersetzertabelle selbst - inklusive des unbekannten Falls."""
    +    class Ergebnis:
    +        status = rohstatus
    +    assert status_von_scipy(Ergebnis()) is erwartet
    +
    +
    +def test_status_eigenschaften_sind_konsistent():
    +    """brauchbar und modellfehler duerfen sich nie ueberschneiden."""
    +    for status in SolverStatus:
    +        assert not (status.brauchbar and status.modellfehler)
    +
    +
    +# Die beiden folgenden Tests gab es zuerst nicht. Sie sind entstanden, weil
    +# Mutationstest.py zwei eingebaute Fehler UEBERLEBEN liess - siehe den
    +# Abschnitt "Wer testet die Tests?".
    +
    +def test_nur_optimal_und_zulaessig_gelten_als_brauchbar():
    +    """ZEITLIMIT ist NICHT brauchbar - der Solver hat nichts gefunden.
    +
    +    Der Unterschied entscheidet, ob ein Nachtjob einen Plan ausliefert oder
    +    Alarm schlaegt. Ein aufgeweichtes 'brauchbar' faellt sonst nirgends auf.
    +    """
    +    assert SolverStatus.OPTIMAL.brauchbar
    +    assert SolverStatus.ZULAESSIG.brauchbar
    +    for status in (SolverStatus.ZEITLIMIT, SolverStatus.UNZULAESSIG,
    +                   SolverStatus.UNBESCHRAENKT, SolverStatus.FEHLERHAFT,
    +                   SolverStatus.UNBEKANNT):
    +        assert not status.brauchbar, f"{status.value} darf nicht brauchbar sein"
    +
    +
    +def test_ressourcenreihenfolge_haengt_nicht_an_der_eingabe():
    +    """Zwei Mappings mit gleichem Inhalt, andere Einfuegereihenfolge.
    +
    +    Die Verbrauchsmatrix muss zeilenweise identisch sein. Ohne diese
    +    Zusicherung passt die Matrix irgendwann nicht mehr zum
    +    Kapazitaetsvektor - die Vertauschungsfalle aus Kapitel Finanzdaten,
    +    nur eine Ebene tiefer.
    +    """
    +    produkte = schreinerei().produkte
    +    vorwaerts = Produktionsproblem(
    +        produkte=produkte,
    +        kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0})
    +    rueckwaerts = Produktionsproblem(
    +        produkte=produkte,
    +        kapazitaeten={"Plattenmaterial": 240.0, "Montagestunden": 150.0})
    +    assert vorwaerts.ressourcen == rueckwaerts.ressourcen
    +    assert (vorwaerts.verbrauchsmatrix() == rueckwaerts.verbrauchsmatrix()).all()
    +    assert (vorwaerts.kapazitaetsvektor() == rueckwaerts.kapazitaetsvektor()).all()
    +
    +
    +if __name__ == "__main__":
    +    import sys
    +    sys.exit(pytest.main([__file__, "-v", "--tb=short", "-p", "no:cacheprovider"]))

    💻 Code-Durchgang

    @@ -27558,185 +27874,185 @@ AssertionError: Plan [31. 48.] braucht [141. 234.], verfuegbar sind [140. 232.]<
    -
    #!/usr/bin/env python3
    -
    -# Mutationstest.py
    -"""
    -Kapitel Testen: Wer testet die Tests?
    -
    -`test_or_kern.py` besteht aus 31 Tests, und alle sind gruen. Das ist noch kein
    -Ergebnis. Eine Testsuite, die nie rot war, ist keine Pruefung, sondern eine
    -Vermutung - sie koennte aus lauter `assert True` bestehen und saehe genauso aus.
    -
    -Dieses Programm prueft die Pruefung. Es baut nacheinander KLEINE, gezielte
    -Fehler in `or_kern.py` ein - jeder einzelne ist ein Fehler, den man beim
    -Programmieren tatsaechlich macht - und laesst die Testsuite darauf los. Fuer
    -jede Mutation gibt es genau zwei moegliche Ausgaenge:
    -
    -    GETOETET    Mindestens ein Test wird rot. Die Suite haette diesen Fehler
    -                in der Praxis gefunden.
    -    UEBERLEBT   Alle Tests bleiben gruen. Die Suite haette diesen Fehler
    -                DURCHGEHEN LASSEN - hier fehlt ein Test.
    -
    -Ueberlebende Mutationen sind das eigentliche Ergebnis: Sie zeigen die Luecken,
    -und zwar ohne dass man sie erraten muss.
    -
    -Das Verfahren heisst Mutationstest und ist alt; neu ist nur, wie gut es zu
    -Optimierungsmodellen passt. Dort kann man den Zielwert meist nicht gegen einen
    -bekannten Sollwert pruefen - also weiss man ohne so ein Experiment nicht, ob
    -die Ersatzpruefungen (Eigenschaften, Invarianten) ueberhaupt greifen.
    -
    -WICHTIG: Es wird nichts veraendert. Jede Mutation laeuft in einem eigenen
    -temporaeren Verzeichnis mit einer Kopie von or_kern.py.
    -
    -Benoetigt: pytest; im selben Verzeichnis or_kern.py und test_or_kern.py
    -"""
    -
    -from __future__ import annotations
    -
    -import os
    -import shutil
    -import subprocess
    -import sys
    -import tempfile
    -
    -# (Name, gesuchter Text, Ersatz) - jede Zeile ein realistischer Fehler.
    -MUTATIONEN = [
    -    ("Nebenbedingung umgedreht (<= wird >=)",
    -     "<= problem.kapazitaeten[ressource], name=ressource)",
    -     ">= problem.kapazitaeten[ressource], name=ressource)"),
    -
    -    ("Maximierung wird Minimierung",
    -     "solver.Maximize(sum(menge[p.name] * p.deckungsbeitrag",
    -     "solver.Minimize(sum(menge[p.name] * p.deckungsbeitrag"),
    -
    -    ("Vorzeichen der Dualwerte vergessen",
    -     "schattenpreise={r: float(-m) for r, m in",
    -     "schattenpreise={r: float(m) for r, m in"),
    -
    -    ("Abnahmepruefung aufgeweicht (Toleranz 1e-6 -> 1.0)",
    -     "toleranz: float = 1e-6) -> list[str]:",
    -     "toleranz: float = 1.0) -> list[str]:"),
    -
    -    ("Kapazitaetspruefung uebersprungen",
    -     "if ist > grenze + toleranz:",
    -     "if False:"),
    -
    -    ("Pruefung auf doppelte Produktnamen entfernt",
    -     "if len(set(namen)) != len(namen):",
    -     "if False:"),
    -
    -    ("Ganzzahligkeitspruefung entfernt",
    -     "if abs(wert - round(wert)) > toleranz:",
    -     "if False:"),
    -
    -    ("Zielwertabgleich entfernt",
    -     "if abs(nachgerechnet - loesung.zielwert) > toleranz * max(1.0, abs(nachgerechnet)):",
    -     "if False:"),
    -
    -    ("ZEITLIMIT gilt faelschlich als brauchbar",
    -     "return self in (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG)",
    -     "return self in (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG,\n"
    -     "                        SolverStatus.ZEITLIMIT)"),
    -
    -    ("Ressourcenreihenfolge nicht mehr stabil",
    -     "return sorted(self.kapazitaeten)",
    -     "return list(self.kapazitaeten)"),
    -]
    -
    -
    -def fuehre_suite_aus(verzeichnis: str) -> tuple[int, int, str]:
    -    """Laesst pytest im Verzeichnis laufen; liefert (bestanden, fehlgeschlagen)."""
    -    ergebnis = subprocess.run(
    -        [sys.executable, "-m", "pytest", "test_or_kern.py", "-q",
    -         "--tb=no", "-p", "no:cacheprovider"],
    -        cwd=verzeichnis, capture_output=True, text=True, timeout=600)
    -    letzte = [z for z in ergebnis.stdout.strip().splitlines() if z.strip()]
    -    zeile = letzte[-1] if letzte else ""
    -    bestanden = fehlgeschlagen = 0
    -    for teil in zeile.replace("=", " ").split(","):
    -        for wort in teil.split():
    -            if wort.isdigit():
    -                zahl = int(wort)
    -            elif wort.startswith("passed"):
    -                bestanden = zahl
    -            elif wort.startswith("failed") or wort.startswith("error"):
    -                fehlgeschlagen += zahl
    -    return bestanden, fehlgeschlagen, zeile
    -
    -
    -def mutiere_und_pruefe(quelle: str, ziel: str, suche: str,
    -                       ersatz: str) -> tuple[int, int, str] | None:
    -    """Legt eine mutierte Kopie an und laesst die Suite darauf laufen."""
    -    with tempfile.TemporaryDirectory() as verzeichnis:
    -        text = open(quelle, encoding="utf-8").read()
    -        if text.count(suche) != 1:
    -            return None                      # Muster passt nicht (mehr)
    -        open(os.path.join(verzeichnis, "or_kern.py"), "w",
    -             encoding="utf-8").write(text.replace(suche, ersatz))
    -        shutil.copy(ziel, verzeichnis)
    -        return fuehre_suite_aus(verzeichnis)
    -
    -
    -if __name__ == "__main__":
    -    hier = os.path.dirname(os.path.abspath(__file__))
    -    quelle = os.path.join(hier, "or_kern.py")
    -    suite = os.path.join(hier, "test_or_kern.py")
    -    for pfad in (quelle, suite):
    -        if not os.path.exists(pfad):
    -            raise SystemExit(f"Nicht gefunden: {pfad}\n"
    -                             f"Beide Dateien muessen im selben Verzeichnis liegen.")
    -
    -    print("=" * 84)
    -    print("  MUTATIONSTEST: WUERDE DIE SUITE DIESE FEHLER FINDEN?")
    -    print("=" * 84)
    -
    -    with tempfile.TemporaryDirectory() as v:
    -        shutil.copy(quelle, v)
    -        shutil.copy(suite, v)
    -        bestanden, fehlgeschlagen, _ = fuehre_suite_aus(v)
    -    print(f"Ausgangslage: {bestanden} Tests, {fehlgeschlagen} rot.\n")
    -    if fehlgeschlagen:
    -        raise SystemExit("Die Suite ist schon ohne Mutation rot - erst das reparieren.")
    -
    -    print(f"{'eingebauter Fehler':<48} {'rote Tests':>11} {'Urteil':>12}")
    -    print("-" * 84)
    -
    -    getoetet, ueberlebt = [], []
    -    for name, suche, ersatz in MUTATIONEN:
    -        ergebnis = mutiere_und_pruefe(quelle, suite, suche, ersatz)
    -        if ergebnis is None:
    -            print(f"{name:<48} {'-':>11} {'nicht anwendbar':>12}")
    -            continue
    -        _, rot, _ = ergebnis
    -        urteil = "GETOETET" if rot else "UEBERLEBT"
    -        (getoetet if rot else ueberlebt).append(name)
    -        print(f"{name:<48} {rot:>11} {urteil:>12}")
    -
    -    print("-" * 84)
    -    quote = len(getoetet) / max(1, len(getoetet) + len(ueberlebt)) * 100
    -    print(f"Getoetet: {len(getoetet)} von {len(getoetet) + len(ueberlebt)} "
    -          f"({quote:.0f} %)")
    -
    -    print("\n" + "=" * 84)
    -    print("  WAS DAS ERGEBNIS BEDEUTET")
    -    print("=" * 84)
    -    if ueberlebt:
    -        print("Diese Fehler haette die Suite DURCHGEHEN LASSEN:\n")
    -        for name in ueberlebt:
    -            print(f"  * {name}")
    -        print("\nJeder ueberlebende Eintrag ist eine Testluecke - und zwar eine")
    -        print("gefundene, keine vermutete. Das ist der ganze Zweck des Verfahrens:")
    -        print("Es sagt einem, welchen Test man als naechstes schreiben sollte,")
    -        print("statt dass man raten muss.")
    -    else:
    -        print("Alle eingebauten Fehler wurden gefunden. Das heisst nicht, dass die")
    -        print("Suite vollstaendig ist - nur, dass sie diese zehn Fehler faengt.")
    -    print()
    -    print("Die Quote selbst ist keine Kennzahl fuer ein Dashboard. Zehn von Hand")
    -    print("gewaehlte Mutationen sind keine Stichprobe aus der Menge aller")
    -    print("moeglichen Fehler. Was zaehlt, ist die LISTE der Ueberlebenden.")
    -    print("=" * 84)
    +
    #!/usr/bin/env python3
    +
    +# Mutationstest.py
    +"""
    +Kapitel Testen: Wer testet die Tests?
    +
    +`test_or_kern.py` besteht aus 31 Tests, und alle sind gruen. Das ist noch kein
    +Ergebnis. Eine Testsuite, die nie rot war, ist keine Pruefung, sondern eine
    +Vermutung - sie koennte aus lauter `assert True` bestehen und saehe genauso aus.
    +
    +Dieses Programm prueft die Pruefung. Es baut nacheinander KLEINE, gezielte
    +Fehler in `or_kern.py` ein - jeder einzelne ist ein Fehler, den man beim
    +Programmieren tatsaechlich macht - und laesst die Testsuite darauf los. Fuer
    +jede Mutation gibt es genau zwei moegliche Ausgaenge:
    +
    +    GETOETET    Mindestens ein Test wird rot. Die Suite haette diesen Fehler
    +                in der Praxis gefunden.
    +    UEBERLEBT   Alle Tests bleiben gruen. Die Suite haette diesen Fehler
    +                DURCHGEHEN LASSEN - hier fehlt ein Test.
    +
    +Ueberlebende Mutationen sind das eigentliche Ergebnis: Sie zeigen die Luecken,
    +und zwar ohne dass man sie erraten muss.
    +
    +Das Verfahren heisst Mutationstest und ist alt; neu ist nur, wie gut es zu
    +Optimierungsmodellen passt. Dort kann man den Zielwert meist nicht gegen einen
    +bekannten Sollwert pruefen - also weiss man ohne so ein Experiment nicht, ob
    +die Ersatzpruefungen (Eigenschaften, Invarianten) ueberhaupt greifen.
    +
    +WICHTIG: Es wird nichts veraendert. Jede Mutation laeuft in einem eigenen
    +temporaeren Verzeichnis mit einer Kopie von or_kern.py.
    +
    +Benoetigt: pytest; im selben Verzeichnis or_kern.py und test_or_kern.py
    +"""
    +
    +from __future__ import annotations
    +
    +import os
    +import shutil
    +import subprocess
    +import sys
    +import tempfile
    +
    +# (Name, gesuchter Text, Ersatz) - jede Zeile ein realistischer Fehler.
    +MUTATIONEN = [
    +    ("Nebenbedingung umgedreht (<= wird >=)",
    +     "<= problem.kapazitaeten[ressource], name=ressource)",
    +     ">= problem.kapazitaeten[ressource], name=ressource)"),
    +
    +    ("Maximierung wird Minimierung",
    +     "solver.Maximize(sum(menge[p.name] * p.deckungsbeitrag",
    +     "solver.Minimize(sum(menge[p.name] * p.deckungsbeitrag"),
    +
    +    ("Vorzeichen der Dualwerte vergessen",
    +     "schattenpreise={r: float(-m) for r, m in",
    +     "schattenpreise={r: float(m) for r, m in"),
    +
    +    ("Abnahmepruefung aufgeweicht (Toleranz 1e-6 -> 1.0)",
    +     "toleranz: float = 1e-6) -> list[str]:",
    +     "toleranz: float = 1.0) -> list[str]:"),
    +
    +    ("Kapazitaetspruefung uebersprungen",
    +     "if ist > grenze + toleranz:",
    +     "if False:"),
    +
    +    ("Pruefung auf doppelte Produktnamen entfernt",
    +     "if len(set(namen)) != len(namen):",
    +     "if False:"),
    +
    +    ("Ganzzahligkeitspruefung entfernt",
    +     "if abs(wert - round(wert)) > toleranz:",
    +     "if False:"),
    +
    +    ("Zielwertabgleich entfernt",
    +     "if abs(nachgerechnet - loesung.zielwert) > toleranz * max(1.0, abs(nachgerechnet)):",
    +     "if False:"),
    +
    +    ("ZEITLIMIT gilt faelschlich als brauchbar",
    +     "return self in (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG)",
    +     "return self in (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG,\n"
    +     "                        SolverStatus.ZEITLIMIT)"),
    +
    +    ("Ressourcenreihenfolge nicht mehr stabil",
    +     "return sorted(self.kapazitaeten)",
    +     "return list(self.kapazitaeten)"),
    +]
    +
    +
    +def fuehre_suite_aus(verzeichnis: str) -> tuple[int, int, str]:
    +    """Laesst pytest im Verzeichnis laufen; liefert (bestanden, fehlgeschlagen)."""
    +    ergebnis = subprocess.run(
    +        [sys.executable, "-m", "pytest", "test_or_kern.py", "-q",
    +         "--tb=no", "-p", "no:cacheprovider"],
    +        cwd=verzeichnis, capture_output=True, text=True, timeout=600)
    +    letzte = [z for z in ergebnis.stdout.strip().splitlines() if z.strip()]
    +    zeile = letzte[-1] if letzte else ""
    +    bestanden = fehlgeschlagen = 0
    +    for teil in zeile.replace("=", " ").split(","):
    +        for wort in teil.split():
    +            if wort.isdigit():
    +                zahl = int(wort)
    +            elif wort.startswith("passed"):
    +                bestanden = zahl
    +            elif wort.startswith("failed") or wort.startswith("error"):
    +                fehlgeschlagen += zahl
    +    return bestanden, fehlgeschlagen, zeile
    +
    +
    +def mutiere_und_pruefe(quelle: str, ziel: str, suche: str,
    +                       ersatz: str) -> tuple[int, int, str] | None:
    +    """Legt eine mutierte Kopie an und laesst die Suite darauf laufen."""
    +    with tempfile.TemporaryDirectory() as verzeichnis:
    +        text = open(quelle, encoding="utf-8").read()
    +        if text.count(suche) != 1:
    +            return None                      # Muster passt nicht (mehr)
    +        open(os.path.join(verzeichnis, "or_kern.py"), "w",
    +             encoding="utf-8").write(text.replace(suche, ersatz))
    +        shutil.copy(ziel, verzeichnis)
    +        return fuehre_suite_aus(verzeichnis)
    +
    +
    +if __name__ == "__main__":
    +    hier = os.path.dirname(os.path.abspath(__file__))
    +    quelle = os.path.join(hier, "or_kern.py")
    +    suite = os.path.join(hier, "test_or_kern.py")
    +    for pfad in (quelle, suite):
    +        if not os.path.exists(pfad):
    +            raise SystemExit(f"Nicht gefunden: {pfad}\n"
    +                             f"Beide Dateien muessen im selben Verzeichnis liegen.")
    +
    +    print("=" * 84)
    +    print("  MUTATIONSTEST: WUERDE DIE SUITE DIESE FEHLER FINDEN?")
    +    print("=" * 84)
    +
    +    with tempfile.TemporaryDirectory() as v:
    +        shutil.copy(quelle, v)
    +        shutil.copy(suite, v)
    +        bestanden, fehlgeschlagen, _ = fuehre_suite_aus(v)
    +    print(f"Ausgangslage: {bestanden} Tests, {fehlgeschlagen} rot.\n")
    +    if fehlgeschlagen:
    +        raise SystemExit("Die Suite ist schon ohne Mutation rot - erst das reparieren.")
    +
    +    print(f"{'eingebauter Fehler':<48} {'rote Tests':>11} {'Urteil':>12}")
    +    print("-" * 84)
    +
    +    getoetet, ueberlebt = [], []
    +    for name, suche, ersatz in MUTATIONEN:
    +        ergebnis = mutiere_und_pruefe(quelle, suite, suche, ersatz)
    +        if ergebnis is None:
    +            print(f"{name:<48} {'-':>11} {'nicht anwendbar':>12}")
    +            continue
    +        _, rot, _ = ergebnis
    +        urteil = "GETOETET" if rot else "UEBERLEBT"
    +        (getoetet if rot else ueberlebt).append(name)
    +        print(f"{name:<48} {rot:>11} {urteil:>12}")
    +
    +    print("-" * 84)
    +    quote = len(getoetet) / max(1, len(getoetet) + len(ueberlebt)) * 100
    +    print(f"Getoetet: {len(getoetet)} von {len(getoetet) + len(ueberlebt)} "
    +          f"({quote:.0f} %)")
    +
    +    print("\n" + "=" * 84)
    +    print("  WAS DAS ERGEBNIS BEDEUTET")
    +    print("=" * 84)
    +    if ueberlebt:
    +        print("Diese Fehler haette die Suite DURCHGEHEN LASSEN:\n")
    +        for name in ueberlebt:
    +            print(f"  * {name}")
    +        print("\nJeder ueberlebende Eintrag ist eine Testluecke - und zwar eine")
    +        print("gefundene, keine vermutete. Das ist der ganze Zweck des Verfahrens:")
    +        print("Es sagt einem, welchen Test man als naechstes schreiben sollte,")
    +        print("statt dass man raten muss.")
    +    else:
    +        print("Alle eingebauten Fehler wurden gefunden. Das heisst nicht, dass die")
    +        print("Suite vollstaendig ist - nur, dass sie diese zehn Fehler faengt.")
    +    print()
    +    print("Die Quote selbst ist keine Kennzahl fuer ein Dashboard. Zehn von Hand")
    +    print("gewaehlte Mutationen sind keine Stichprobe aus der Menge aller")
    +    print("moeglichen Fehler. Was zaehlt, ist die LISTE der Ueberlebenden.")
    +    print("=" * 84)

    Der erste Lauf fand zwei Lücken

    Die Suite hatte zunächst 31 Tests. Der Mutationstest sagte dazu:

    Nebenbedingung umgedreht (<= wird >=)                          7     GETOETET
    @@ -27791,208 +28107,208 @@ moeglichen Fehler. Was zaehlt, ist die LISTE der Ueberlebenden.
     

    23.6 Ein Vergleich, dem man glauben kann

    Solververgleiche stehen in jedem Blog, und die meisten sind wertlos. Nicht, weil falsch gemessen wurde, sondern weil zu wenig dazugesagt wird.

    -
    #!/usr/bin/env python3
    -
    -# Benchmark_Skalierung.py
    -"""
    -Kapitel Testen: Ein Vergleich, dem man glauben kann.
    -
    -Solververgleiche stehen in jedem Blog, und die meisten sind wertlos - nicht
    -weil falsch gemessen wurde, sondern weil zu wenig dazugesagt wird. Dieses
    -Programm misst dasselbe Transportproblem in drei Groessen mit vier
    -Bibliotheken und haelt sich dabei an fuenf Regeln, die den Unterschied machen:
    -
    -  1. EIGENER PROZESS je Bibliothek. Nicht nur wegen des Importkonflikts
    -     zwischen ortools und highspy (Kapitel Oekosystem) - auch, damit der
    -     Speicherverbrauch der einen nicht in der Messung der anderen auftaucht.
    -  2. AUFBAU UND LOESEN GETRENNT messen. Bei grossen Instanzen ist der Aufbau
    -     des Modells in Python regelmaessig teurer als das Loesen. Wer nur die
    -     Gesamtzeit misst, optimiert am Ende die falsche Haelfte.
    -  3. ZIELWERTE GEGENEINANDER PRUEFEN. Eine Bibliothek, die schneller ist und
    -     etwas anderes ausrechnet, hat den Vergleich nicht gewonnen. Diese Pruefung
    -     ist der wichtigste Teil des Programms.
    -  4. SPEICHER MITMESSEN. Bei 10.000 Variablen entscheidet oft er und nicht die
    -     Zeit darueber, was auf einer Maschine noch laeuft.
    -  5. DIESELBE INSTANZ fuer alle. Feste Saat, kein Neuwuerfeln zwischendurch.
    -
    -Und die Einschraenkung, die dazugehoert: Gemessen wird EIN Problemtyp in EINER
    -Formulierung auf EINER Maschine. Das Ergebnis ist keine Rangliste der Solver,
    -sondern eine Entscheidungshilfe fuer genau diesen Fall. Wer es verallgemeinert,
    -macht denselben Fehler wie jemand, der aus einem Backtest auf die Zukunft
    -schliesst (Kapitel Handelsmaschine).
    -
    -Benoetigt: numpy; in den Kindprozessen scipy, highspy, ortools, cvxpy
    -"""
    -
    -from __future__ import annotations
    -
    -import json
    -import subprocess
    -import sys
    -import textwrap
    -
    -GROESSEN = [(10, 10), (32, 32), (100, 100)]   # (Lager, Kunden) -> 100 / 1.024 / 10.000 Variablen
    -
    -
    -# Jeder Eintrag ist ein eigenstaendiges Programm: Instanz aufbauen, loesen,
    -# Ergebnis als JSON ausgeben. Die Instanz wird in jedem Kindprozess aus
    -# derselben Saat neu erzeugt - so reist nichts ueber die Prozessgrenze,
    -# was das Ergebnis verfaelschen koennte.
    -VORSPANN = """
    -import json, time, resource
    -import numpy as np
    -
    -def instanz(m, n):
    -    rng = np.random.default_rng(20)
    -    kosten = rng.integers(5, 95, (m, n)).astype(float)
    -    angebot = rng.integers(50, 150, m).astype(float)
    -    bedarf = angebot.sum() * rng.dirichlet(np.ones(n))
    -    return kosten, angebot, bedarf
    -
    -def speicher_mb():
    -    # ru_maxrss ist unter Linux in Kilobyte
    -    return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
    -
    -M, N = {m}, {n}
    -kosten, angebot, bedarf = instanz(M, N)
    -"""
    -
    -ANSAETZE = {
    -    "scipy.linprog": """
    -        from scipy.optimize import linprog
    -        t0 = time.perf_counter()
    -        c = kosten.reshape(-1)
    -        A_ub = np.zeros((M, M * N)); A_eq = np.zeros((N, M * N))
    -        for i in range(M):
    -            A_ub[i, i * N:(i + 1) * N] = 1.0
    -        for j in range(N):
    -            A_eq[j, j::N] = 1.0
    -        aufbau = time.perf_counter() - t0
    -        t0 = time.perf_counter()
    -        r = linprog(c=c, A_ub=A_ub, b_ub=angebot, A_eq=A_eq, b_eq=bedarf,
    -                    bounds=(0, None), method="highs")
    -        loesen = time.perf_counter() - t0
    -        ausgabe = (float(r.fun), aufbau, loesen, speicher_mb())
    -    """,
    -
    -    "highspy": """
    -        import highspy
    -        t0 = time.perf_counter()
    -        h = highspy.Highs(); h.setOptionValue("output_flag", False)
    -        h.addVars(M * N, np.zeros(M * N), np.full(M * N, highspy.kHighsInf))
    -        for k in range(M * N):
    -            h.changeColCost(k, float(kosten.reshape(-1)[k]))
    -        for i in range(M):
    -            idx = np.arange(i * N, (i + 1) * N, dtype=np.int32)
    -            h.addRow(-highspy.kHighsInf, float(angebot[i]), N, idx, np.ones(N))
    -        for j in range(N):
    -            idx = np.arange(j, M * N, N, dtype=np.int32)
    -            h.addRow(float(bedarf[j]), float(bedarf[j]), M, idx, np.ones(M))
    -        aufbau = time.perf_counter() - t0
    -        t0 = time.perf_counter(); h.run(); loesen = time.perf_counter() - t0
    -        ausgabe = (h.getInfo().objective_function_value, aufbau, loesen, speicher_mb())
    -    """,
    -
    -    "ortools/GLOP": """
    -        from ortools.linear_solver import pywraplp
    -        t0 = time.perf_counter()
    -        s = pywraplp.Solver.CreateSolver("GLOP")
    -        x = [[s.NumVar(0, s.infinity(), f"x{i}_{j}") for j in range(N)]
    -             for i in range(M)]
    -        for i in range(M):
    -            s.Add(sum(x[i]) <= float(angebot[i]))
    -        for j in range(N):
    -            s.Add(sum(x[i][j] for i in range(M)) == float(bedarf[j]))
    -        s.Minimize(sum(float(kosten[i, j]) * x[i][j]
    -                       for i in range(M) for j in range(N)))
    -        aufbau = time.perf_counter() - t0
    -        t0 = time.perf_counter(); s.Solve(); loesen = time.perf_counter() - t0
    -        ausgabe = (s.Objective().Value(), aufbau, loesen, speicher_mb())
    -    """,
    -
    -    "cvxpy": """
    -        import cvxpy as cp
    -        t0 = time.perf_counter()
    -        x = cp.Variable((M, N), nonneg=True)
    -        problem = cp.Problem(cp.Minimize(cp.sum(cp.multiply(kosten, x))),
    -                             [cp.sum(x, axis=1) <= angebot,
    -                              cp.sum(x, axis=0) == bedarf])
    -        aufbau = time.perf_counter() - t0
    -        t0 = time.perf_counter(); problem.solve(); loesen = time.perf_counter() - t0
    -        ausgabe = (float(problem.value), aufbau, loesen, speicher_mb())
    -    """,
    -}
    -
    -
    -def messe(name: str, quelltext: str, m: int, n: int):
    -    """Fuehrt einen Ansatz in einem eigenen Prozess aus."""
    -    programm = (VORSPANN.format(m=m, n=n) + textwrap.dedent(quelltext)
    -                + "\nprint(json.dumps(ausgabe))\n")
    -    ergebnis = subprocess.run([sys.executable, "-c", programm],
    -                              capture_output=True, text=True, timeout=600)
    -    if ergebnis.returncode != 0:
    -        return None, ergebnis.stderr.strip().splitlines()[-1][:60]
    -    return json.loads(ergebnis.stdout.strip().splitlines()[-1]), None
    -
    -
    -if __name__ == "__main__":
    -    print("=" * 92)
    -    print("  SKALIERUNGSVERGLEICH: TRANSPORTPROBLEM, VIER BIBLIOTHEKEN")
    -    print("=" * 92)
    -    print("Jede Zeile ein eigener Prozess. Zeiten und Speicher sind "
    -          "hardwareabhaengig,")
    -    print("die Zielwerte und ihr Verhaeltnis zueinander nicht.\n")
    -
    -    for m, n in GROESSEN:
    -        kopf = f"--- {m} Lager x {n} Kunden = {m * n:,} Variablen "
    -        print(kopf + "-" * max(3, 92 - len(kopf)))
    -        print(f"  {'Bibliothek':<16} {'Zielwert':>14} {'Aufbau':>9} "
    -              f"{'Loesen':>9} {'Anteil':>8} {'Speicher':>10}")
    -        print("  " + "-" * 72)
    -        zielwerte = {}
    -        for name, quelltext in ANSAETZE.items():
    -            werte, fehler = messe(name, quelltext, m, n)
    -            if werte is None:
    -                print(f"  {name:<16} nicht verfuegbar: {fehler}")
    -                continue
    -            ziel, aufbau, loesen, speicher = werte
    -            zielwerte[name] = ziel
    -            anteil = aufbau / (aufbau + loesen) * 100
    -            print(f"  {name:<16} {ziel:>14,.2f} {aufbau:>8.3f}s "
    -                  f"{loesen:>8.3f}s {anteil:>7.0f}% {speicher:>9.0f} MB")
    -
    -        # Die wichtigste Zeile: Rechnen alle dasselbe aus?
    -        spanne = max(zielwerte.values()) - min(zielwerte.values())
    -        bezug = max(abs(v) for v in zielwerte.values())
    -        print(f"  {'':16} Spannweite der Zielwerte: {spanne:.2e} "
    -              f"(relativ {spanne / bezug:.1e})")
    -        if spanne / bezug > 1e-6:
    -            print("  ACHTUNG: Die Bibliotheken widersprechen sich - "
    -                  "der Zeitvergleich ist wertlos.")
    -        print()
    -
    -    print("=" * 92)
    -    print("  WAS MAN AUS SO EINER TABELLE ABLESEN DARF - UND WAS NICHT")
    -    print("=" * 92)
    -    print("DARF man ablesen:")
    -    print("  * Die Spalte 'Anteil' - wie viel der Zeit in den AUFBAU geht statt")
    -    print("    ins Loesen. Wenn dort 80 % stehen, ist ein schnellerer Solver die")
    -    print("    falsche Antwort; dann gehoert das Modell vektorisiert aufgebaut")
    -    print("    (Kapitel Oekosystem).")
    -    print("  * Die Groessenordnung des Speicherbedarfs. Sie entscheidet, was auf")
    -    print("    einer bestimmten Maschine ueberhaupt laeuft.")
    -    print("  * Wie sich beides mit der Groesse ENTWICKELT. Der Trend ist")
    -    print("    uebertragbarer als der Absolutwert.")
    -    print()
    -    print("NICHT ablesen darf man:")
    -    print("  * 'Bibliothek X ist schneller als Y.' Gemessen wurde EIN")
    -    print("    Problemtyp in EINER Formulierung. Ein MILP, ein QP oder eine")
    -    print("    andere Modellierung desselben Problems koennen die Reihenfolge")
    -    print("    umdrehen.")
    -    print("  * Etwas ueber Ihre Maschine. Diese Zahlen stammen von einer")
    -    print("    anderen. Der Sinn des Programms ist, dass Sie es auf Ihrer")
    -    print("    laufen lassen.")
    -    print("=" * 92)
    +
    #!/usr/bin/env python3
    +
    +# Benchmark_Skalierung.py
    +"""
    +Kapitel Testen: Ein Vergleich, dem man glauben kann.
    +
    +Solververgleiche stehen in jedem Blog, und die meisten sind wertlos - nicht
    +weil falsch gemessen wurde, sondern weil zu wenig dazugesagt wird. Dieses
    +Programm misst dasselbe Transportproblem in drei Groessen mit vier
    +Bibliotheken und haelt sich dabei an fuenf Regeln, die den Unterschied machen:
    +
    +  1. EIGENER PROZESS je Bibliothek. Nicht nur wegen des Importkonflikts
    +     zwischen ortools und highspy (Kapitel Oekosystem) - auch, damit der
    +     Speicherverbrauch der einen nicht in der Messung der anderen auftaucht.
    +  2. AUFBAU UND LOESEN GETRENNT messen. Bei grossen Instanzen ist der Aufbau
    +     des Modells in Python regelmaessig teurer als das Loesen. Wer nur die
    +     Gesamtzeit misst, optimiert am Ende die falsche Haelfte.
    +  3. ZIELWERTE GEGENEINANDER PRUEFEN. Eine Bibliothek, die schneller ist und
    +     etwas anderes ausrechnet, hat den Vergleich nicht gewonnen. Diese Pruefung
    +     ist der wichtigste Teil des Programms.
    +  4. SPEICHER MITMESSEN. Bei 10.000 Variablen entscheidet oft er und nicht die
    +     Zeit darueber, was auf einer Maschine noch laeuft.
    +  5. DIESELBE INSTANZ fuer alle. Feste Saat, kein Neuwuerfeln zwischendurch.
    +
    +Und die Einschraenkung, die dazugehoert: Gemessen wird EIN Problemtyp in EINER
    +Formulierung auf EINER Maschine. Das Ergebnis ist keine Rangliste der Solver,
    +sondern eine Entscheidungshilfe fuer genau diesen Fall. Wer es verallgemeinert,
    +macht denselben Fehler wie jemand, der aus einem Backtest auf die Zukunft
    +schliesst (Kapitel Handelsmaschine).
    +
    +Benoetigt: numpy; in den Kindprozessen scipy, highspy, ortools, cvxpy
    +"""
    +
    +from __future__ import annotations
    +
    +import json
    +import subprocess
    +import sys
    +import textwrap
    +
    +GROESSEN = [(10, 10), (32, 32), (100, 100)]   # (Lager, Kunden) -> 100 / 1.024 / 10.000 Variablen
    +
    +
    +# Jeder Eintrag ist ein eigenstaendiges Programm: Instanz aufbauen, loesen,
    +# Ergebnis als JSON ausgeben. Die Instanz wird in jedem Kindprozess aus
    +# derselben Saat neu erzeugt - so reist nichts ueber die Prozessgrenze,
    +# was das Ergebnis verfaelschen koennte.
    +VORSPANN = """
    +import json, time, resource
    +import numpy as np
    +
    +def instanz(m, n):
    +    rng = np.random.default_rng(20)
    +    kosten = rng.integers(5, 95, (m, n)).astype(float)
    +    angebot = rng.integers(50, 150, m).astype(float)
    +    bedarf = angebot.sum() * rng.dirichlet(np.ones(n))
    +    return kosten, angebot, bedarf
    +
    +def speicher_mb():
    +    # ru_maxrss ist unter Linux in Kilobyte
    +    return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
    +
    +M, N = {m}, {n}
    +kosten, angebot, bedarf = instanz(M, N)
    +"""
    +
    +ANSAETZE = {
    +    "scipy.linprog": """
    +        from scipy.optimize import linprog
    +        t0 = time.perf_counter()
    +        c = kosten.reshape(-1)
    +        A_ub = np.zeros((M, M * N)); A_eq = np.zeros((N, M * N))
    +        for i in range(M):
    +            A_ub[i, i * N:(i + 1) * N] = 1.0
    +        for j in range(N):
    +            A_eq[j, j::N] = 1.0
    +        aufbau = time.perf_counter() - t0
    +        t0 = time.perf_counter()
    +        r = linprog(c=c, A_ub=A_ub, b_ub=angebot, A_eq=A_eq, b_eq=bedarf,
    +                    bounds=(0, None), method="highs")
    +        loesen = time.perf_counter() - t0
    +        ausgabe = (float(r.fun), aufbau, loesen, speicher_mb())
    +    """,
    +
    +    "highspy": """
    +        import highspy
    +        t0 = time.perf_counter()
    +        h = highspy.Highs(); h.setOptionValue("output_flag", False)
    +        h.addVars(M * N, np.zeros(M * N), np.full(M * N, highspy.kHighsInf))
    +        for k in range(M * N):
    +            h.changeColCost(k, float(kosten.reshape(-1)[k]))
    +        for i in range(M):
    +            idx = np.arange(i * N, (i + 1) * N, dtype=np.int32)
    +            h.addRow(-highspy.kHighsInf, float(angebot[i]), N, idx, np.ones(N))
    +        for j in range(N):
    +            idx = np.arange(j, M * N, N, dtype=np.int32)
    +            h.addRow(float(bedarf[j]), float(bedarf[j]), M, idx, np.ones(M))
    +        aufbau = time.perf_counter() - t0
    +        t0 = time.perf_counter(); h.run(); loesen = time.perf_counter() - t0
    +        ausgabe = (h.getInfo().objective_function_value, aufbau, loesen, speicher_mb())
    +    """,
    +
    +    "ortools/GLOP": """
    +        from ortools.linear_solver import pywraplp
    +        t0 = time.perf_counter()
    +        s = pywraplp.Solver.CreateSolver("GLOP")
    +        x = [[s.NumVar(0, s.infinity(), f"x{i}_{j}") for j in range(N)]
    +             for i in range(M)]
    +        for i in range(M):
    +            s.Add(sum(x[i]) <= float(angebot[i]))
    +        for j in range(N):
    +            s.Add(sum(x[i][j] for i in range(M)) == float(bedarf[j]))
    +        s.Minimize(sum(float(kosten[i, j]) * x[i][j]
    +                       for i in range(M) for j in range(N)))
    +        aufbau = time.perf_counter() - t0
    +        t0 = time.perf_counter(); s.Solve(); loesen = time.perf_counter() - t0
    +        ausgabe = (s.Objective().Value(), aufbau, loesen, speicher_mb())
    +    """,
    +
    +    "cvxpy": """
    +        import cvxpy as cp
    +        t0 = time.perf_counter()
    +        x = cp.Variable((M, N), nonneg=True)
    +        problem = cp.Problem(cp.Minimize(cp.sum(cp.multiply(kosten, x))),
    +                             [cp.sum(x, axis=1) <= angebot,
    +                              cp.sum(x, axis=0) == bedarf])
    +        aufbau = time.perf_counter() - t0
    +        t0 = time.perf_counter(); problem.solve(); loesen = time.perf_counter() - t0
    +        ausgabe = (float(problem.value), aufbau, loesen, speicher_mb())
    +    """,
    +}
    +
    +
    +def messe(name: str, quelltext: str, m: int, n: int):
    +    """Fuehrt einen Ansatz in einem eigenen Prozess aus."""
    +    programm = (VORSPANN.format(m=m, n=n) + textwrap.dedent(quelltext)
    +                + "\nprint(json.dumps(ausgabe))\n")
    +    ergebnis = subprocess.run([sys.executable, "-c", programm],
    +                              capture_output=True, text=True, timeout=600)
    +    if ergebnis.returncode != 0:
    +        return None, ergebnis.stderr.strip().splitlines()[-1][:60]
    +    return json.loads(ergebnis.stdout.strip().splitlines()[-1]), None
    +
    +
    +if __name__ == "__main__":
    +    print("=" * 92)
    +    print("  SKALIERUNGSVERGLEICH: TRANSPORTPROBLEM, VIER BIBLIOTHEKEN")
    +    print("=" * 92)
    +    print("Jede Zeile ein eigener Prozess. Zeiten und Speicher sind "
    +          "hardwareabhaengig,")
    +    print("die Zielwerte und ihr Verhaeltnis zueinander nicht.\n")
    +
    +    for m, n in GROESSEN:
    +        kopf = f"--- {m} Lager x {n} Kunden = {m * n:,} Variablen "
    +        print(kopf + "-" * max(3, 92 - len(kopf)))
    +        print(f"  {'Bibliothek':<16} {'Zielwert':>14} {'Aufbau':>9} "
    +              f"{'Loesen':>9} {'Anteil':>8} {'Speicher':>10}")
    +        print("  " + "-" * 72)
    +        zielwerte = {}
    +        for name, quelltext in ANSAETZE.items():
    +            werte, fehler = messe(name, quelltext, m, n)
    +            if werte is None:
    +                print(f"  {name:<16} nicht verfuegbar: {fehler}")
    +                continue
    +            ziel, aufbau, loesen, speicher = werte
    +            zielwerte[name] = ziel
    +            anteil = aufbau / (aufbau + loesen) * 100
    +            print(f"  {name:<16} {ziel:>14,.2f} {aufbau:>8.3f}s "
    +                  f"{loesen:>8.3f}s {anteil:>7.0f}% {speicher:>9.0f} MB")
    +
    +        # Die wichtigste Zeile: Rechnen alle dasselbe aus?
    +        spanne = max(zielwerte.values()) - min(zielwerte.values())
    +        bezug = max(abs(v) for v in zielwerte.values())
    +        print(f"  {'':16} Spannweite der Zielwerte: {spanne:.2e} "
    +              f"(relativ {spanne / bezug:.1e})")
    +        if spanne / bezug > 1e-6:
    +            print("  ACHTUNG: Die Bibliotheken widersprechen sich - "
    +                  "der Zeitvergleich ist wertlos.")
    +        print()
    +
    +    print("=" * 92)
    +    print("  WAS MAN AUS SO EINER TABELLE ABLESEN DARF - UND WAS NICHT")
    +    print("=" * 92)
    +    print("DARF man ablesen:")
    +    print("  * Die Spalte 'Anteil' - wie viel der Zeit in den AUFBAU geht statt")
    +    print("    ins Loesen. Wenn dort 80 % stehen, ist ein schnellerer Solver die")
    +    print("    falsche Antwort; dann gehoert das Modell vektorisiert aufgebaut")
    +    print("    (Kapitel Oekosystem).")
    +    print("  * Die Groessenordnung des Speicherbedarfs. Sie entscheidet, was auf")
    +    print("    einer bestimmten Maschine ueberhaupt laeuft.")
    +    print("  * Wie sich beides mit der Groesse ENTWICKELT. Der Trend ist")
    +    print("    uebertragbarer als der Absolutwert.")
    +    print()
    +    print("NICHT ablesen darf man:")
    +    print("  * 'Bibliothek X ist schneller als Y.' Gemessen wurde EIN")
    +    print("    Problemtyp in EINER Formulierung. Ein MILP, ein QP oder eine")
    +    print("    andere Modellierung desselben Problems koennen die Reihenfolge")
    +    print("    umdrehen.")
    +    print("  * Etwas ueber Ihre Maschine. Diese Zahlen stammen von einer")
    +    print("    anderen. Der Sinn des Programms ist, dass Sie es auf Ihrer")
    +    print("    laufen lassen.")
    +    print("=" * 92)

    Erwartete Ausgabe (Zeiten und Speicher hardwareabhängig, die Zielwerte nicht):

    ============================================================================================
       SKALIERUNGSVERGLEICH: TRANSPORTPROBLEM, VIER BIBLIOTHEKEN
    @@ -28119,321 +28435,321 @@ NICHT ablesen darf man:
     
     
     
    -
    #!/usr/bin/env python3
    -
    -# Optimierungsdienst.py
    -"""
    -Kapitel Testen: Das Modell als Dienst - und warum es kein normaler Endpunkt ist.
    -
    -Ein Optimierungsmodell hinter eine HTTP-Schnittstelle zu haengen sieht nach
    -einer Fingeruebung aus. Es gibt aber einen Unterschied, der alles bestimmt:
    -
    -    Eine gewoehnliche Anfrage dauert Millisekunden.
    -    Eine Optimierung dauert Sekunden bis Minuten - und manchmal laenger,
    -    als jemand warten will.
    -
    -Damit scheidet die naheliegende Bauform aus. Wer den Solver direkt im
    -Request-Handler aufruft, baut einen Dienst, der bei der ersten grossen Instanz
    -in einen Timeout des Reverse Proxy laeuft, und bei zehn gleichzeitigen Anfragen
    -alle Arbeiter blockiert.
    -
    -Die tragfaehige Bauform ist deshalb ZWEISTUFIG:
    -
    -    POST /plaene         nimmt den Auftrag an, prueft ihn, gibt sofort
    -                         202 Accepted und eine Auftragsnummer zurueck
    -    GET  /plaene/{id}    sagt, wie weit es ist - und liefert am Ende die
    -                         Loesung samt Status und Gap
    -
    -Drei Dinge, die dabei leicht untergehen und hier bewusst sichtbar sind:
    -
    -  1. Das Domaenenmodell aus or_kern.py ist zugleich das API-Schema. Eine
    -     unsinnige Anfrage wird von FastAPI mit 422 abgewiesen, bevor irgendein
    -     Solver startet - dieselbe Pruefung wie beim Excel-Import.
    -  2. Die Antwort enthaelt STATUS und GAP, nicht nur Zahlen. Ein Aufrufer, der
    -     nur die Mengen bekommt, kann nicht unterscheiden, ob er ein bewiesenes
    -     Optimum oder einen Zeitlimit-Abbruch in der Hand haelt.
    -  3. Jeder Auftrag hat ein ZEITLIMIT. Ohne das belegt eine einzige unguenstige
    -     Instanz einen Arbeiter auf unbestimmte Zeit.
    -
    -Dieses Programm laeuft ohne Server: Der Selbsttest unten benutzt den
    -TestClient von FastAPI und spricht die Anwendung direkt an. Fuer den echten
    -Betrieb steht am Ende, was sich aendert.
    -
    -Aufruf:
    -    python3 Optimierungsdienst.py          # Selbsttest, kein Server noetig
    -    uvicorn Optimierungsdienst:app         # echter Server auf Port 8000
    -
    -Benoetigt: fastapi, httpx (fuer den Selbsttest), pydantic, ortools (ueber or_kern)
    -"""
    -
    -from __future__ import annotations
    -
    -import time
    -import uuid
    -from concurrent.futures import ThreadPoolExecutor
    -from enum import Enum
    -
    -from fastapi import FastAPI, HTTPException
    -from pydantic import BaseModel, Field
    -
    -from or_kern import (Loesung, Produktionsproblem, loese_mit_glop,
    -                     pruefe_loesung)
    -
    -ZEITLIMIT_SEKUNDEN = 30.0
    -ARBEITER = 2
    -
    -
    -class Auftragsstand(str, Enum):
    -    WARTET = "wartet"
    -    LAEUFT = "laeuft"
    -    FERTIG = "fertig"
    -    GESCHEITERT = "gescheitert"
    -
    -
    -class Auftragsantwort(BaseModel):
    -    """Was der Aufrufer beim Abholen bekommt.
    -
    -    Bewusst NICHT nur die Mengen: 'stand' und die Felder aus 'loesung'
    -    (Status, Gap) sind der Unterschied zwischen einer Zahl und einer
    -    belastbaren Auskunft.
    -    """
    -    id: str
    -    stand: Auftragsstand
    -    eingegangen: float
    -    laufzeit: float | None = None
    -    loesung: Loesung | None = None
    -    beanstandungen: list[str] = Field(default_factory=list)
    -    fehler: str | None = None
    -
    -
    -app = FastAPI(title="Optimierungsdienst",
    -              summary="Produktionsplanung als Auftrag, nicht als Abfrage")
    -
    -# Fuer das Buchbeispiel: Auftragsbuch im Speicher, Arbeiter im selben Prozess.
    -# Was daran im echten Betrieb nicht reicht, steht unten.
    -AUFTRAEGE: dict[str, Auftragsantwort] = {}
    -POOL = ThreadPoolExecutor(max_workers=ARBEITER)
    -
    -
    -def rechne(auftrags_id: str, problem: Produktionsproblem) -> None:
    -    """Laeuft im Arbeiterthread - nie im Request-Handler."""
    -    antwort = AUFTRAEGE[auftrags_id]
    -    antwort.stand = Auftragsstand.LAEUFT
    -    start = time.perf_counter()
    -    try:
    -        loesung = loese_mit_glop(problem)
    -        antwort.loesung = loesung
    -        # Dieselbe Abnahmepruefung wie ueberall sonst. Ein Dienst, der sie
    -        # weglaesst, liefert Fehler schneller aus als ein Mensch sie faende.
    -        antwort.beanstandungen = pruefe_loesung(problem, loesung)
    -        antwort.stand = (Auftragsstand.FERTIG if loesung.status.brauchbar
    -                         and not antwort.beanstandungen
    -                         else Auftragsstand.GESCHEITERT)
    -        if not loesung.status.brauchbar:
    -            antwort.fehler = f"Solverstatus: {loesung.status.value}"
    -        elif antwort.beanstandungen:
    -            antwort.fehler = "Abnahmepruefung fehlgeschlagen"
    -    except Exception as fehler:                      # noqa: BLE001
    -        antwort.stand = Auftragsstand.GESCHEITERT
    -        antwort.fehler = f"{type(fehler).__name__}: {fehler}"
    -    finally:
    -        antwort.laufzeit = time.perf_counter() - start
    -
    -
    -@app.post("/plaene", status_code=202, response_model=Auftragsantwort)
    -def auftrag_annehmen(problem: Produktionsproblem) -> Auftragsantwort:
    -    """Nimmt an, prueft, gibt sofort zurueck.
    -
    -    Der Typ 'Produktionsproblem' im Parameter ist der ganze Trick: FastAPI
    -    validiert die Anfrage damit gegen das Domaenenmodell und antwortet bei
    -    Unsinn mit 422, ohne dass hier eine Zeile Pruefcode steht.
    -    """
    -    auftrags_id = str(uuid.uuid4())
    -    AUFTRAEGE[auftrags_id] = Auftragsantwort(
    -        id=auftrags_id, stand=Auftragsstand.WARTET, eingegangen=time.time())
    -    POOL.submit(rechne, auftrags_id, problem)
    -    return AUFTRAEGE[auftrags_id]
    -
    -
    -@app.get("/plaene/{auftrags_id}", response_model=Auftragsantwort)
    -def auftrag_abholen(auftrags_id: str) -> Auftragsantwort:
    -    if auftrags_id not in AUFTRAEGE:
    -        raise HTTPException(status_code=404, detail="Unbekannter Auftrag")
    -    return AUFTRAEGE[auftrags_id]
    -
    -
    -@app.get("/gesundheit")
    -def gesundheit() -> dict[str, object]:
    -    """Was ein Ueberwachungssystem abfragt - siehe Betriebsueberwachung.py."""
    -    offen = sum(1 for a in AUFTRAEGE.values()
    -                if a.stand in (Auftragsstand.WARTET, Auftragsstand.LAEUFT))
    -    return {"zustand": "bereit", "auftraege_gesamt": len(AUFTRAEGE),
    -            "offen": offen, "arbeiter": ARBEITER}
    -
    -
    -# --- Selbsttest ohne Server -------------------------------------------------
    -
    -SCHREINEREI = {
    -    "produkte": [
    -        {"name": "Tisch", "deckungsbeitrag": 240.0,
    -         "verbrauch": {"Montagestunden": 3.0, "Plattenmaterial": 6.0}},
    -        {"name": "Stuhl", "deckungsbeitrag": 60.0,
    -         "verbrauch": {"Montagestunden": 1.0, "Plattenmaterial": 1.0}},
    -    ],
    -    "kapazitaeten": {"Montagestunden": 150.0, "Plattenmaterial": 240.0},
    -}
    -
    -DOCKERFILE = """\
    -FROM python:3.12-slim
    -WORKDIR /app
    -COPY requirements.txt .
    -RUN pip install --no-cache-dir -r requirements.txt
    -COPY or_kern.py Optimierungsdienst.py .
    -# Ein Arbeiter je CPU-Kern - der Solver rechnet selbst schon parallel,
    -# mehr Prozesse machen ihn nicht schneller, sondern langsamer.
    -CMD ["uvicorn", "Optimierungsdienst:app", "--host", "0.0.0.0", "--port", "8000"]
    -"""
    -
    -
    -def warte_auf_ergebnis(klient, auftrags_id: str, grenze: float = 30.0) -> dict:
    -    ende = time.time() + grenze
    -    while time.time() < ende:
    -        antwort = klient.get(f"/plaene/{auftrags_id}").json()
    -        if antwort["stand"] in ("fertig", "gescheitert"):
    -            return antwort
    -        time.sleep(0.02)
    -    raise TimeoutError("Auftrag wurde nicht fertig")
    -
    -
    -if __name__ == "__main__":
    -    from fastapi.testclient import TestClient
    -
    -    print("=" * 80)
    -    print("  DAS MODELL ALS DIENST")
    -    print("=" * 80)
    -
    -    with TestClient(app) as klient:
    -        # 1. Gueltiger Auftrag
    -        print("\n1. Auftrag einreichen")
    -        angenommen = klient.post("/plaene", json=SCHREINEREI)
    -        auftrags_id = angenommen.json()["id"]
    -        print(f"   POST /plaene            -> {angenommen.status_code} "
    -              f"{'Accepted' if angenommen.status_code == 202 else ''}")
    -        print(f"   Auftragsnummer          -> UUID, {len(auftrags_id)} Zeichen")
    -        # Der entscheidende Punkt, und er ist pruefbar: Die Antwort ist da,
    -        # BEVOR es eine Loesung gibt. Genau dafuer ist 202 gedacht.
    -        print(f"   Loesung schon dabei?    -> "
    -              f"{'ja' if angenommen.json()['loesung'] else 'nein - genau so soll es sein'}")
    -
    -        fertig = warte_auf_ergebnis(klient, auftrags_id)
    -        loesung = fertig["loesung"]
    -        print("\n2. Ergebnis abholen")
    -        print(f"   Stand                   -> {fertig['stand']}")
    -        print(f"   Solverstatus            -> {loesung['status']}")
    -        print(f"   Zielwert                -> {loesung['zielwert']:,.2f} EUR")
    -        print(f"   Mengen                  -> " + ", ".join(
    -            f"{name} {wert:.0f}" for name, wert in loesung["werte"].items()))
    -        print(f"   Schattenpreise          -> " + ", ".join(
    -            f"{name} {wert:.0f}" for name, wert in loesung["schattenpreise"].items()))
    -        print(f"   Abnahmepruefung         -> "
    -              f"{'bestanden' if not fertig['beanstandungen'] else fertig['beanstandungen']}")
    -
    -        # 2. Unsinnige Anfrage - muss abgewiesen werden, BEVOR gerechnet wird
    -        print("\n3. Unsinnige Anfragen (der Solver startet gar nicht erst)")
    -        for beschreibung, aenderung in [
    -                ("Kapazitaet 0", {"kapazitaeten": {"Montagestunden": 0.0}}),
    -                ("keine Produkte", {"produkte": []}),
    -                ("Ressource ohne Kapazitaet",
    -                 {"produkte": [{"name": "Regal", "deckungsbeitrag": 130.0,
    -                                "verbrauch": {"Lackieren": 2.0}}]})]:
    -            anfrage = {**SCHREINEREI, **aenderung}
    -            antwort = klient.post("/plaene", json=anfrage)
    -            print(f"   {beschreibung:<24} -> {antwort.status_code} "
    -                  f"{'Unprocessable Content' if antwort.status_code == 422 else ''}")
    -
    -        print("\n4. Unbekannter Auftrag")
    -        print(f"   GET /plaene/gibtsnicht  -> "
    -              f"{klient.get('/plaene/gibtsnicht').status_code} Not Found")
    -
    -        print("\n5. Gesundheitsabfrage")
    -        print(f"   GET /gesundheit         -> {klient.get('/gesundheit').json()}")
    -
    -    # --- Warum ein Thread je Auftrag genuegt -----------------------------
    -    print("\n" + "-" * 80)
    -    print("6. Warum Threads hier reichen (und wann nicht)\n")
    -    from concurrent.futures import ThreadPoolExecutor as Pool
    -
    -    # Wichtig: eine Rechnung mit ECHTER Last. Ein Modell, das in zehn
    -    # Millisekunden fertig ist, misst nur den Aufwand fuers Threadstarten -
    -    # der erste Entwurf dieser Messung ist genau daran gescheitert und zeigte
    -    # eine Verlangsamung, wo in Wirklichkeit eine Beschleunigung steht.
    -    def eine_rechnung() -> None:
    -        import numpy as np
    -        from ortools.linear_solver import pywraplp
    -        rng = np.random.default_rng(3)
    -        n = 260
    -        solver = pywraplp.Solver.CreateSolver("SCIP")
    -        x = [solver.IntVar(0, 1, f"x{i}") for i in range(n)]
    -        gewicht = rng.integers(10, 60, n)
    -        wert = rng.integers(10, 60, n)
    -        solver.Add(sum(int(gewicht[i]) * x[i] for i in range(n))
    -                   <= int(gewicht.sum() * 0.5))
    -        for _ in range(30):
    -            auswahl = rng.choice(n, 40, replace=False)
    -            solver.Add(sum(x[int(i)] for i in auswahl) <= 12)
    -        solver.Maximize(sum(int(wert[i]) * x[i] for i in range(n)))
    -        solver.Solve()
    -
    -    t0 = time.perf_counter()
    -    for _ in range(4):
    -        eine_rechnung()
    -    seriell = time.perf_counter() - t0
    -    t0 = time.perf_counter()
    -    with Pool(max_workers=4) as p:
    -        list(p.map(lambda _: eine_rechnung(), range(4)))
    -    parallel = time.perf_counter() - t0
    -
    -    print(f"   vier Rechnungen nacheinander -> {seriell:6.2f} s")
    -    print(f"   vier Rechnungen in Threads   -> {parallel:6.2f} s "
    -          f"(Faktor {seriell / parallel:.1f})")
    -    print()
    -    if parallel < seriell * 0.75:
    -        print("   Die Laeufe ueberlappen sich. ortools rechnet in C++ und gibt")
    -        print("   den GIL waehrend Solve() frei - deshalb genuegt hier ein")
    -        print("   Threadpool, es braucht keine eigenen Prozesse.")
    -    else:
    -        print("   Keine Ueberlappung - hier waeren Prozesse noetig.")
    -    print()
    -    print("   ACHTUNG, das gilt nicht allgemein: Eine in reinem Python")
    -    print("   geschriebene Heuristik (Kapitel Metaheuristiken) haelt den GIL")
    -    print("   die ganze Zeit. Fuer sie braucht derselbe Dienst einen")
    -    print("   ProcessPoolExecutor statt eines Threadpools.")
    -
    -    print("\n" + "=" * 80)
    -    print("  WAS SICH IM ECHTEN BETRIEB AENDERT")
    -    print("=" * 80)
    -    print("Dieses Beispiel haelt das Auftragsbuch im Speicher und rechnet in")
    -    print("Threads desselben Prozesses. Das reicht zum Zeigen und fuer einen")
    -    print("einzelnen Rechner - nicht darueber hinaus:")
    -    print()
    -    print("  * NEUSTART LOESCHT ALLES. Auftragsbuch in eine Datenbank oder eine")
    -    print("    Warteschlange (Redis, RabbitMQ), nicht in ein dict.")
    -    print("  * ZWEI INSTANZEN KENNEN EINANDER NICHT. Sobald der Dienst mehr als")
    -    print("    einmal laeuft, muss die Warteschlange ausserhalb liegen -")
    -    print("    typischerweise Celery mit Redis als Vermittler.")
    -    print("  * DIE WAHL THREADS/PROZESSE HAENGT AM SOLVER - siehe die Messung")
    -    print("    unter Punkt 6. Sie gehoert gemessen, nicht angenommen.")
    -    print("  * OHNE ZEITLIMIT KEIN DIENST. Jede Instanz bekommt eines, und der")
    -    print("    Aufrufer erfaehrt im Status, ob es gegriffen hat.")
    -    print()
    -    print("Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:")
    -    print()
    -    for zeile in DOCKERFILE.splitlines():
    -        print(f"    {zeile}")
    -    print()
    -    print("(Es wird hier nicht gebaut - das Buch setzt keine laufende")
    -    print(" Docker-Installation voraus.)")
    -    print("=" * 80)
    +
    #!/usr/bin/env python3
    +
    +# Optimierungsdienst.py
    +"""
    +Kapitel Testen: Das Modell als Dienst - und warum es kein normaler Endpunkt ist.
    +
    +Ein Optimierungsmodell hinter eine HTTP-Schnittstelle zu haengen sieht nach
    +einer Fingeruebung aus. Es gibt aber einen Unterschied, der alles bestimmt:
    +
    +    Eine gewoehnliche Anfrage dauert Millisekunden.
    +    Eine Optimierung dauert Sekunden bis Minuten - und manchmal laenger,
    +    als jemand warten will.
    +
    +Damit scheidet die naheliegende Bauform aus. Wer den Solver direkt im
    +Request-Handler aufruft, baut einen Dienst, der bei der ersten grossen Instanz
    +in einen Timeout des Reverse Proxy laeuft, und bei zehn gleichzeitigen Anfragen
    +alle Arbeiter blockiert.
    +
    +Die tragfaehige Bauform ist deshalb ZWEISTUFIG:
    +
    +    POST /plaene         nimmt den Auftrag an, prueft ihn, gibt sofort
    +                         202 Accepted und eine Auftragsnummer zurueck
    +    GET  /plaene/{id}    sagt, wie weit es ist - und liefert am Ende die
    +                         Loesung samt Status und Gap
    +
    +Drei Dinge, die dabei leicht untergehen und hier bewusst sichtbar sind:
    +
    +  1. Das Domaenenmodell aus or_kern.py ist zugleich das API-Schema. Eine
    +     unsinnige Anfrage wird von FastAPI mit 422 abgewiesen, bevor irgendein
    +     Solver startet - dieselbe Pruefung wie beim Excel-Import.
    +  2. Die Antwort enthaelt STATUS und GAP, nicht nur Zahlen. Ein Aufrufer, der
    +     nur die Mengen bekommt, kann nicht unterscheiden, ob er ein bewiesenes
    +     Optimum oder einen Zeitlimit-Abbruch in der Hand haelt.
    +  3. Jeder Auftrag hat ein ZEITLIMIT. Ohne das belegt eine einzige unguenstige
    +     Instanz einen Arbeiter auf unbestimmte Zeit.
    +
    +Dieses Programm laeuft ohne Server: Der Selbsttest unten benutzt den
    +TestClient von FastAPI und spricht die Anwendung direkt an. Fuer den echten
    +Betrieb steht am Ende, was sich aendert.
    +
    +Aufruf:
    +    python3 Optimierungsdienst.py          # Selbsttest, kein Server noetig
    +    uvicorn Optimierungsdienst:app         # echter Server auf Port 8000
    +
    +Benoetigt: fastapi, httpx (fuer den Selbsttest), pydantic, ortools (ueber or_kern)
    +"""
    +
    +from __future__ import annotations
    +
    +import time
    +import uuid
    +from concurrent.futures import ThreadPoolExecutor
    +from enum import Enum
    +
    +from fastapi import FastAPI, HTTPException
    +from pydantic import BaseModel, Field
    +
    +from or_kern import (Loesung, Produktionsproblem, loese_mit_glop,
    +                     pruefe_loesung)
    +
    +ZEITLIMIT_SEKUNDEN = 30.0
    +ARBEITER = 2
    +
    +
    +class Auftragsstand(str, Enum):
    +    WARTET = "wartet"
    +    LAEUFT = "laeuft"
    +    FERTIG = "fertig"
    +    GESCHEITERT = "gescheitert"
    +
    +
    +class Auftragsantwort(BaseModel):
    +    """Was der Aufrufer beim Abholen bekommt.
    +
    +    Bewusst NICHT nur die Mengen: 'stand' und die Felder aus 'loesung'
    +    (Status, Gap) sind der Unterschied zwischen einer Zahl und einer
    +    belastbaren Auskunft.
    +    """
    +    id: str
    +    stand: Auftragsstand
    +    eingegangen: float
    +    laufzeit: float | None = None
    +    loesung: Loesung | None = None
    +    beanstandungen: list[str] = Field(default_factory=list)
    +    fehler: str | None = None
    +
    +
    +app = FastAPI(title="Optimierungsdienst",
    +              summary="Produktionsplanung als Auftrag, nicht als Abfrage")
    +
    +# Fuer das Buchbeispiel: Auftragsbuch im Speicher, Arbeiter im selben Prozess.
    +# Was daran im echten Betrieb nicht reicht, steht unten.
    +AUFTRAEGE: dict[str, Auftragsantwort] = {}
    +POOL = ThreadPoolExecutor(max_workers=ARBEITER)
    +
    +
    +def rechne(auftrags_id: str, problem: Produktionsproblem) -> None:
    +    """Laeuft im Arbeiterthread - nie im Request-Handler."""
    +    antwort = AUFTRAEGE[auftrags_id]
    +    antwort.stand = Auftragsstand.LAEUFT
    +    start = time.perf_counter()
    +    try:
    +        loesung = loese_mit_glop(problem)
    +        antwort.loesung = loesung
    +        # Dieselbe Abnahmepruefung wie ueberall sonst. Ein Dienst, der sie
    +        # weglaesst, liefert Fehler schneller aus als ein Mensch sie faende.
    +        antwort.beanstandungen = pruefe_loesung(problem, loesung)
    +        antwort.stand = (Auftragsstand.FERTIG if loesung.status.brauchbar
    +                         and not antwort.beanstandungen
    +                         else Auftragsstand.GESCHEITERT)
    +        if not loesung.status.brauchbar:
    +            antwort.fehler = f"Solverstatus: {loesung.status.value}"
    +        elif antwort.beanstandungen:
    +            antwort.fehler = "Abnahmepruefung fehlgeschlagen"
    +    except Exception as fehler:                      # noqa: BLE001
    +        antwort.stand = Auftragsstand.GESCHEITERT
    +        antwort.fehler = f"{type(fehler).__name__}: {fehler}"
    +    finally:
    +        antwort.laufzeit = time.perf_counter() - start
    +
    +
    +@app.post("/plaene", status_code=202, response_model=Auftragsantwort)
    +def auftrag_annehmen(problem: Produktionsproblem) -> Auftragsantwort:
    +    """Nimmt an, prueft, gibt sofort zurueck.
    +
    +    Der Typ 'Produktionsproblem' im Parameter ist der ganze Trick: FastAPI
    +    validiert die Anfrage damit gegen das Domaenenmodell und antwortet bei
    +    Unsinn mit 422, ohne dass hier eine Zeile Pruefcode steht.
    +    """
    +    auftrags_id = str(uuid.uuid4())
    +    AUFTRAEGE[auftrags_id] = Auftragsantwort(
    +        id=auftrags_id, stand=Auftragsstand.WARTET, eingegangen=time.time())
    +    POOL.submit(rechne, auftrags_id, problem)
    +    return AUFTRAEGE[auftrags_id]
    +
    +
    +@app.get("/plaene/{auftrags_id}", response_model=Auftragsantwort)
    +def auftrag_abholen(auftrags_id: str) -> Auftragsantwort:
    +    if auftrags_id not in AUFTRAEGE:
    +        raise HTTPException(status_code=404, detail="Unbekannter Auftrag")
    +    return AUFTRAEGE[auftrags_id]
    +
    +
    +@app.get("/gesundheit")
    +def gesundheit() -> dict[str, object]:
    +    """Was ein Ueberwachungssystem abfragt - siehe Betriebsueberwachung.py."""
    +    offen = sum(1 for a in AUFTRAEGE.values()
    +                if a.stand in (Auftragsstand.WARTET, Auftragsstand.LAEUFT))
    +    return {"zustand": "bereit", "auftraege_gesamt": len(AUFTRAEGE),
    +            "offen": offen, "arbeiter": ARBEITER}
    +
    +
    +# --- Selbsttest ohne Server -------------------------------------------------
    +
    +SCHREINEREI = {
    +    "produkte": [
    +        {"name": "Tisch", "deckungsbeitrag": 240.0,
    +         "verbrauch": {"Montagestunden": 3.0, "Plattenmaterial": 6.0}},
    +        {"name": "Stuhl", "deckungsbeitrag": 60.0,
    +         "verbrauch": {"Montagestunden": 1.0, "Plattenmaterial": 1.0}},
    +    ],
    +    "kapazitaeten": {"Montagestunden": 150.0, "Plattenmaterial": 240.0},
    +}
    +
    +DOCKERFILE = """\
    +FROM python:3.12-slim
    +WORKDIR /app
    +COPY requirements.txt .
    +RUN pip install --no-cache-dir -r requirements.txt
    +COPY or_kern.py Optimierungsdienst.py .
    +# Ein Arbeiter je CPU-Kern - der Solver rechnet selbst schon parallel,
    +# mehr Prozesse machen ihn nicht schneller, sondern langsamer.
    +CMD ["uvicorn", "Optimierungsdienst:app", "--host", "0.0.0.0", "--port", "8000"]
    +"""
    +
    +
    +def warte_auf_ergebnis(klient, auftrags_id: str, grenze: float = 30.0) -> dict:
    +    ende = time.time() + grenze
    +    while time.time() < ende:
    +        antwort = klient.get(f"/plaene/{auftrags_id}").json()
    +        if antwort["stand"] in ("fertig", "gescheitert"):
    +            return antwort
    +        time.sleep(0.02)
    +    raise TimeoutError("Auftrag wurde nicht fertig")
    +
    +
    +if __name__ == "__main__":
    +    from fastapi.testclient import TestClient
    +
    +    print("=" * 80)
    +    print("  DAS MODELL ALS DIENST")
    +    print("=" * 80)
    +
    +    with TestClient(app) as klient:
    +        # 1. Gueltiger Auftrag
    +        print("\n1. Auftrag einreichen")
    +        angenommen = klient.post("/plaene", json=SCHREINEREI)
    +        auftrags_id = angenommen.json()["id"]
    +        print(f"   POST /plaene            -> {angenommen.status_code} "
    +              f"{'Accepted' if angenommen.status_code == 202 else ''}")
    +        print(f"   Auftragsnummer          -> UUID, {len(auftrags_id)} Zeichen")
    +        # Der entscheidende Punkt, und er ist pruefbar: Die Antwort ist da,
    +        # BEVOR es eine Loesung gibt. Genau dafuer ist 202 gedacht.
    +        print(f"   Loesung schon dabei?    -> "
    +              f"{'ja' if angenommen.json()['loesung'] else 'nein - genau so soll es sein'}")
    +
    +        fertig = warte_auf_ergebnis(klient, auftrags_id)
    +        loesung = fertig["loesung"]
    +        print("\n2. Ergebnis abholen")
    +        print(f"   Stand                   -> {fertig['stand']}")
    +        print(f"   Solverstatus            -> {loesung['status']}")
    +        print(f"   Zielwert                -> {loesung['zielwert']:,.2f} EUR")
    +        print(f"   Mengen                  -> " + ", ".join(
    +            f"{name} {wert:.0f}" for name, wert in loesung["werte"].items()))
    +        print(f"   Schattenpreise          -> " + ", ".join(
    +            f"{name} {wert:.0f}" for name, wert in loesung["schattenpreise"].items()))
    +        print(f"   Abnahmepruefung         -> "
    +              f"{'bestanden' if not fertig['beanstandungen'] else fertig['beanstandungen']}")
    +
    +        # 2. Unsinnige Anfrage - muss abgewiesen werden, BEVOR gerechnet wird
    +        print("\n3. Unsinnige Anfragen (der Solver startet gar nicht erst)")
    +        for beschreibung, aenderung in [
    +                ("Kapazitaet 0", {"kapazitaeten": {"Montagestunden": 0.0}}),
    +                ("keine Produkte", {"produkte": []}),
    +                ("Ressource ohne Kapazitaet",
    +                 {"produkte": [{"name": "Regal", "deckungsbeitrag": 130.0,
    +                                "verbrauch": {"Lackieren": 2.0}}]})]:
    +            anfrage = {**SCHREINEREI, **aenderung}
    +            antwort = klient.post("/plaene", json=anfrage)
    +            print(f"   {beschreibung:<24} -> {antwort.status_code} "
    +                  f"{'Unprocessable Content' if antwort.status_code == 422 else ''}")
    +
    +        print("\n4. Unbekannter Auftrag")
    +        print(f"   GET /plaene/gibtsnicht  -> "
    +              f"{klient.get('/plaene/gibtsnicht').status_code} Not Found")
    +
    +        print("\n5. Gesundheitsabfrage")
    +        print(f"   GET /gesundheit         -> {klient.get('/gesundheit').json()}")
    +
    +    # --- Warum ein Thread je Auftrag genuegt -----------------------------
    +    print("\n" + "-" * 80)
    +    print("6. Warum Threads hier reichen (und wann nicht)\n")
    +    from concurrent.futures import ThreadPoolExecutor as Pool
    +
    +    # Wichtig: eine Rechnung mit ECHTER Last. Ein Modell, das in zehn
    +    # Millisekunden fertig ist, misst nur den Aufwand fuers Threadstarten -
    +    # der erste Entwurf dieser Messung ist genau daran gescheitert und zeigte
    +    # eine Verlangsamung, wo in Wirklichkeit eine Beschleunigung steht.
    +    def eine_rechnung() -> None:
    +        import numpy as np
    +        from ortools.linear_solver import pywraplp
    +        rng = np.random.default_rng(3)
    +        n = 260
    +        solver = pywraplp.Solver.CreateSolver("SCIP")
    +        x = [solver.IntVar(0, 1, f"x{i}") for i in range(n)]
    +        gewicht = rng.integers(10, 60, n)
    +        wert = rng.integers(10, 60, n)
    +        solver.Add(sum(int(gewicht[i]) * x[i] for i in range(n))
    +                   <= int(gewicht.sum() * 0.5))
    +        for _ in range(30):
    +            auswahl = rng.choice(n, 40, replace=False)
    +            solver.Add(sum(x[int(i)] for i in auswahl) <= 12)
    +        solver.Maximize(sum(int(wert[i]) * x[i] for i in range(n)))
    +        solver.Solve()
    +
    +    t0 = time.perf_counter()
    +    for _ in range(4):
    +        eine_rechnung()
    +    seriell = time.perf_counter() - t0
    +    t0 = time.perf_counter()
    +    with Pool(max_workers=4) as p:
    +        list(p.map(lambda _: eine_rechnung(), range(4)))
    +    parallel = time.perf_counter() - t0
    +
    +    print(f"   vier Rechnungen nacheinander -> {seriell:6.2f} s")
    +    print(f"   vier Rechnungen in Threads   -> {parallel:6.2f} s "
    +          f"(Faktor {seriell / parallel:.1f})")
    +    print()
    +    if parallel < seriell * 0.75:
    +        print("   Die Laeufe ueberlappen sich. ortools rechnet in C++ und gibt")
    +        print("   den GIL waehrend Solve() frei - deshalb genuegt hier ein")
    +        print("   Threadpool, es braucht keine eigenen Prozesse.")
    +    else:
    +        print("   Keine Ueberlappung - hier waeren Prozesse noetig.")
    +    print()
    +    print("   ACHTUNG, das gilt nicht allgemein: Eine in reinem Python")
    +    print("   geschriebene Heuristik (Kapitel Metaheuristiken) haelt den GIL")
    +    print("   die ganze Zeit. Fuer sie braucht derselbe Dienst einen")
    +    print("   ProcessPoolExecutor statt eines Threadpools.")
    +
    +    print("\n" + "=" * 80)
    +    print("  WAS SICH IM ECHTEN BETRIEB AENDERT")
    +    print("=" * 80)
    +    print("Dieses Beispiel haelt das Auftragsbuch im Speicher und rechnet in")
    +    print("Threads desselben Prozesses. Das reicht zum Zeigen und fuer einen")
    +    print("einzelnen Rechner - nicht darueber hinaus:")
    +    print()
    +    print("  * NEUSTART LOESCHT ALLES. Auftragsbuch in eine Datenbank oder eine")
    +    print("    Warteschlange (Redis, RabbitMQ), nicht in ein dict.")
    +    print("  * ZWEI INSTANZEN KENNEN EINANDER NICHT. Sobald der Dienst mehr als")
    +    print("    einmal laeuft, muss die Warteschlange ausserhalb liegen -")
    +    print("    typischerweise Celery mit Redis als Vermittler.")
    +    print("  * DIE WAHL THREADS/PROZESSE HAENGT AM SOLVER - siehe die Messung")
    +    print("    unter Punkt 6. Sie gehoert gemessen, nicht angenommen.")
    +    print("  * OHNE ZEITLIMIT KEIN DIENST. Jede Instanz bekommt eines, und der")
    +    print("    Aufrufer erfaehrt im Status, ob es gegriffen hat.")
    +    print()
    +    print("Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:")
    +    print()
    +    for zeile in DOCKERFILE.splitlines():
    +        print(f"    {zeile}")
    +    print()
    +    print("(Es wird hier nicht gebaut - das Buch setzt keine laufende")
    +    print(" Docker-Installation voraus.)")
    +    print("=" * 80)

    Erwartete Ausgabe (Laufzeiten hardwareabhängig):

    ================================================================================
       DAS MODELL ALS DIENST
    @@ -28850,15 +29166,15 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
     

    1.3 — Kombinatorik. (a) 12! = 479\,001\,600. (b) 479\,001\,600 / 5\cdot10^6 \approx 95{,}8 Sekunden \approx 1{,}6 Minuten. (c) 13! = 6\,227\,020\,800; das sind 1245 s \approx 20{,}8 Minuten — Faktor 13. Jeder weitere Auftrag multipliziert die Zeit mit der neuen Anzahl. (d) Eine Stunde = 3600 s \to 1{,}8\cdot10^{10} Prüfungen. 13! = 6{,}2\cdot10^9 ✓, 14! = 8{,}7\cdot10^{10} ✗. Also 13 Aufträge.

    1.4 — Modell lesen. (a) Variablen x_1, x_2 \ge 0; Parameter (3,5) und die Kapazitäten (4,12,18); Zielfunktion \max 3x_1+5x_2; vier Nebenbedingungen inkl. Nichtnegativität. (b) (2,6): 2\le4 ✓, 12\le12 ✓, 6+12=18\le18 ✓ → zulässig, Z=36. (4,3): 4\le4 ✓, 6\le12 ✓, 12+6=18\le18 ✓ → zulässig, Z=27. (c) Beste ganzzahlige Lösung ist (2,6) mit Z=36 — sie ist hier bereits ganzzahlig, weil die Ecke des Polyeders zufällig auf Gitterpunkten liegt.

    1.5 — Bäckerei.

    -
    from ortools.sat.python import cp_model
    -m = cp_model.CpModel()
    -x1 = m.NewIntVar(0, 1000, "Brote")
    -x2 = m.NewIntVar(0, 1000, "Broetchen_10er")
    -m.Add(5 * x1 + 6 * x2 <= 900)        # Mehl in 100-g-Einheiten (0,5 kg -> 5)
    -m.Add(4 * x1 + 3 * x2 <= 600)        # Ofenminuten
    -m.Add(x1 >= 40)                       # Vertrag
    -m.Maximize(250 * x1 + 300 * x2)       # Deckungsbeitrag in Cent
    -s = cp_model.CpSolver(); s.Solve(m)
    +
    from ortools.sat.python import cp_model
    +m = cp_model.CpModel()
    +x1 = m.NewIntVar(0, 1000, "Brote")
    +x2 = m.NewIntVar(0, 1000, "Broetchen_10er")
    +m.Add(5 * x1 + 6 * x2 <= 900)        # Mehl in 100-g-Einheiten (0,5 kg -> 5)
    +m.Add(4 * x1 + 3 * x2 <= 600)        # Ofenminuten
    +m.Add(x1 >= 40)                       # Vertrag
    +m.Maximize(250 * x1 + 300 * x2)       # Deckungsbeitrag in Cent
    +s = cp_model.CpSolver(); s.Solve(m)

    Ergebnis: x_1 = 96 Brote, x_2 = 70 Zehnerpackungen, Deckungsbeitrag 450,00 €.

    Probe: Mehl 0{,}5\cdot96 + 0{,}6\cdot70 = 48 + 42 = 90 kg ✓ (voll ausgelastet); Ofen 4\cdot96 + 3\cdot70 = 384+210 = 594 \le 600 ✓; Vertrag 96 \ge 40 ✓; Z = 2{,}5\cdot96 + 3\cdot70 = 240 + 210 = 450 ✓.

    Zwei lehrreiche Beobachtungen: 1. Die Vertragsbedingung x_1 \ge 40 ist nicht bindend — der Solver backt freiwillig 96 Brote. Wer aus der Aufgabenstellung schließt, die Mindestmenge werde „gerade so“ erfüllt, liegt falsch. 2. Das kontinuierliche Optimum (40;\ 116{,}67) liefert denselben Zielwert 450. Das Problem hat also mehrere optimale Lösungen — die Zielfunktion verläuft parallel zur Mehlrestriktion (2{,}5/0{,}5 = 5 = 3{,}0/0{,}6). Prüfen Sie das nach: Genau deshalb ist hier auch die ganzzahlige Lösung ohne Verlust erreichbar.

    @@ -28873,14 +29189,14 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
    1. Korrektur. Die Summe über alle Produkte gehört in die Bedingung, die Schleife läuft über die Ressourcen, nicht über die Produkte:
    -
    for r, index in [("Montagestunden", 1), ("Plattenmaterial", 2)]:
    -    s.Add(sum(x[p] * produkt[p][index] for p in produkt) <= vorrat[r])
    +
    for r, index in [("Montagestunden", 1), ("Plattenmaterial", 2)]:
    +    s.Add(sum(x[p] * produkt[p][index] for p in produkt) <= vorrat[r])
    1. Automatische Absicherung. Nach jedem Lösen den tatsächlichen Verbrauch gegen den Vorrat prüfen — mit kleiner Toleranz, weil Solver mit endlicher Genauigkeit rechnen (siehe Kapitel 3):
    -
    for r, index in [("Montagestunden", 1), ("Plattenmaterial", 2)]:
    -    verbrauch = sum(x[p].solution_value() * produkt[p][index] for p in produkt)
    -    assert verbrauch <= vorrat[r] + 1e-6, f"{r} ueberzogen: {verbrauch} > {vorrat[r]}"
    +
    for r, index in [("Montagestunden", 1), ("Plattenmaterial", 2)]:
    +    verbrauch = sum(x[p].solution_value() * produkt[p][index] for p in produkt)
    +    assert verbrauch <= vorrat[r] + 1e-6, f"{r} ueberzogen: {verbrauch} > {vorrat[r]}"

    Diese vier Zeilen sind das wichtigste Werkzeug des Kapitels: Sie prüfen die Lösung gegen die Wirklichkeit, nicht gegen das Modell. Ein Modellierungsfehler kann sich vor dem Solver verstecken, vor dieser Prüfung nicht.

    Micro-Quiz

    1 — (c) präskriptiv. Die Frage lautet „was tun“, und es gibt eine Restriktion (das Restbudget). Dass man für die Bewertung eine Prognose braucht, macht die Frage nicht prädiktiv — die Prognose ist hier Eingabeparameter, nicht Ergebnis.

    @@ -28903,9 +29219,9 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:

    2.5 — Positive Semidefinitheit. \mathbf{P}_1: Eigenwerte \approx (3{,}8;\ 9{,}2) → PSD ✓, Korrelation 1/\sqrt{4\cdot9} = 0{,}167 ✓. \mathbf{P}_2: Eigenwerte \approx (-0{,}6;\ 13{,}6)nicht PSD; implizierte Korrelation 7/\sqrt{36} = 1{,}167 > 1 — unmöglich. \mathbf{P}_3: Eigenwerte (0{,}5;\ 0{,}5;\ 2{,}0) → PSD ✓.

    2.6 — Bäckerei visualisieren. Ecken: (40,0), (150,0), (40,116{,}67) und der Schnittpunkt von Mehl- und Ofengrenze. Beste Ecke ist (40;\ 116{,}67) mit Z = 450 (im kontinuierlichen Fall).

    2.7 — Eigenwert-Clipping.

    -
    def repariere(P):
    -    lam, V = np.linalg.eigh(P)
    -    return V @ np.diag(np.maximum(lam, 0.0)) @ V.T
    +
    def repariere(P):
    +    lam, V = np.linalg.eigh(P)
    +    return V @ np.diag(np.maximum(lam, 0.0)) @ V.T

    Für \mathbf{P}_2 ergibt sich eine PSD-Matrix mit Korrelation exakt 1{,}0 — das Verfahren zieht die unmögliche Korrelation auf den nächstgelegenen zulässigen Wert. Für Kovarianzmatrizen setzt man in der Praxis auf einen kleinen positiven Wert statt auf 0 (np.maximum(lam, 1e-10)), damit die Matrix invertierbar bleibt.

    Finde den Denkfehler — Die unauffällige Transposition

      @@ -28918,8 +29234,8 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
    1. Warum „zu klein“ schlimmer ist als „zu groß“. Ein unerwartet hoher Zielwert weckt Misstrauen — man rechnet nach (siehe Abschnitt 1.10). Ein leicht niedrigerer Wert wirkt dagegen wie ein normales, etwas enttäuschendes Ergebnis: Niemand prüft nach, warum die Optimierung „nur“ 20,80 € statt der erhofften 22 € bringt. Fehler, die sich als Bescheidenheit tarnen, überleben am längsten.

    2. Zwei Zeilen, die es aufgedeckt hätten. Erstens beim Einlesen die Form gegen die Bedeutung prüfen, zweitens nach dem Lösen den Verbrauch gegen den Vorrat:

    -
    assert A.shape == (len(b), len(c)), "A: Zeilen = Ressourcen, Spalten = Variablen"
    -assert np.all(A @ r.x <= b + 1e-9), f"Plan verletzt Restriktionen: {A @ r.x} > {b}"
    +
    assert A.shape == (len(b), len(c)), "A: Zeilen = Ressourcen, Spalten = Variablen"
    +assert np.all(A @ r.x <= b + 1e-9), f"Plan verletzt Restriktionen: {A @ r.x} > {b}"

    Die erste Zeile hilft nur bei rechteckigem \mathbf{A} — die zweite immer. Sie ist die wichtigste Zeile in jedem Optimierungsskript: Sie prüft die Lösung nicht gegen das Modell, sondern gegen die Wirklichkeit, die das Modell abbilden sollte.

    Vorbeugend hilft außerdem, Matrizen nie positionsweise abzutippen, sondern über benannte Spalten aus einer Tabelle zu erzeugen — genau das tut Excel_Bruecke.py aus Kapitel 1.

    Micro-Quiz

    @@ -28955,24 +29271,24 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
  • Warum das bei 60 Wiederholungen kippt. Import und Kompilierung fallen einmal an, das Lösen sechzigmal. Genau dafür hat CVXPY cp.Parameter: Man baut das Problem einmal, tauscht nur die Daten und löst erneut, ohne neu zu kompilieren. Gemessen wurde also ausgerechnet der Teil, der in der Zielanwendung fast nicht mehr vorkommt — die Entscheidung beruht auf der unwichtigsten Zahl.

  • Eine Messung, die trägt. Drei Regeln:

  • -
    # 1. Importe VOR die Messung
    -import cvxpy as cp
    -from scipy.optimize import linprog
    -
    -# 2. Aufwaermlauf: einmal alles durchlaufen lassen, Ergebnis verwerfen
    -loese_klein()
    -
    -# 3. Aufbau und Loesen GETRENNT messen, ueber mehrere Wiederholungen
    -t0 = time.perf_counter()
    -problem = cp.Problem(cp.Minimize(kosten @ x), [A @ x == b])   # Aufbau
    -t_aufbau = time.perf_counter() - t0
    -
    -zeiten = []
    -for _ in range(10):
    -    t0 = time.perf_counter()
    -    problem.solve()
    -    zeiten.append(time.perf_counter() - t0)
    -print(f"Aufbau {t_aufbau:.3f}s, Loesen Median {statistics.median(zeiten):.3f}s")
    +
    # 1. Importe VOR die Messung
    +import cvxpy as cp
    +from scipy.optimize import linprog
    +
    +# 2. Aufwaermlauf: einmal alles durchlaufen lassen, Ergebnis verwerfen
    +loese_klein()
    +
    +# 3. Aufbau und Loesen GETRENNT messen, ueber mehrere Wiederholungen
    +t0 = time.perf_counter()
    +problem = cp.Problem(cp.Minimize(kosten @ x), [A @ x == b])   # Aufbau
    +t_aufbau = time.perf_counter() - t0
    +
    +zeiten = []
    +for _ in range(10):
    +    t0 = time.perf_counter()
    +    problem.solve()
    +    zeiten.append(time.perf_counter() - t0)
    +print(f"Aufbau {t_aufbau:.3f}s, Loesen Median {statistics.median(zeiten):.3f}s")

    Und die vierte, wichtigste Regel: die Größe messen, die später auch läuft. Ein Vergleich bei 200 Variablen sagt nichts über das Verhalten bei 200 000 — die Kurven schneiden sich oft. Genau diese Trennung führt Vektorisierte_Modellgenerierung.py vor, und Kapitel 22 baut daraus eine vollständige Benchmark-Pipeline.

    Micro-Quiz

    1 — (b) CP-SAT. Es geht um diskrete Zuweisung mit logischen Regeln („nicht gleichzeitig“) — CP-SATs Kerngebiet, und Regeln dieser Art formuliert man dort direkt statt über Big-M-Umwege. (a) scheidet aus, weil CVXPY keine sinnvolle Ganzzahligkeit in dieser Größenordnung bietet; (c) findet bei einem diskreten Problem bestenfalls ein lokales Optimum und hat keinerlei Handhabe für „entweder–oder“-Regeln.

    @@ -28993,10 +29309,10 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:

    4.2 — Frage 5 anwenden. Wird die durchschnittliche Wartezeit minimiert, lohnt es sich, viele leichte Fälle schnell abzuarbeiten und die aufwendigen nach hinten zu schieben: Ein Fall mit vier Stunden Wartezeit zählt genauso viel wie vier Fälle mit einer Stunde. Der optimale Plan behandelt also bevorzugt Bagatellen und lässt schwere Fälle warten — medizinisch das Gegenteil des Gewollten.

    Das ist genau der Ertrag von Frage 5: Der Plan lässt sich ablehnen, und aus der Ablehnung folgt die richtige Zielgröße — etwa die maximale Wartezeit je Dringlichkeitsstufe, oder die Einhaltung von Zielzeiten je Triage-Kategorie.

    4.3 — Der Kompromiss dazwischen. Je Stammkunde eine Nebenbedingung: Die Summe der zugeteilten Stunden dieses Kunden muss mindestens die Hälfte seiner angefragten Stunden betragen.

    -
    for kunde in STAMMKUNDEN:
    -    stunden_kunde = [a[3] if a[1] == kunde else 0 for a in AUFTRAEGE]
    -    je_kunde_ub.append([-s for s in stunden_kunde])
    -    je_kunde_b.append(-0.5 * sum(stunden_kunde))
    +
    for kunde in STAMMKUNDEN:
    +    stunden_kunde = [a[3] if a[1] == kunde else 0 for a in AUFTRAEGE]
    +    je_kunde_ub.append([-s for s in stunden_kunde])
    +    je_kunde_b.append(-0.5 * sum(stunden_kunde))

    Erwartung: Das Ergebnis liegt zwischen Lesart A und B — die Regel bindet stärker als „ein Auftrag genügt”, aber schwächer als „alle Aufträge”. Der Lehrpunkt ist nicht die Zahl, sondern dass sich zwischen zwei Lesarten beliebig fein abstufen lässt, sobald man sie einmal als Ungleichung geschrieben hat.

    4.4 — Zwei Ziele gleichzeitig. Bei \lambda = 0 ergibt sich der DB-Plan (78 500 € DB), bei großem \lambda der Umsatzplan (66 000 € DB). Dazwischen kippt es an genau einer Stelle — und zwar sprunghaft, nicht allmählich: Weil die Entscheidungsvariablen binär sind, gibt es nur endlich viele Pläne, und der Wechsel erfolgt, sobald ein anderer Plan den höheren gewichteten Wert hat.

    Der Kippwert sagt dem Betrieb etwas Konkretes: „Erst wenn Ihnen ein Euro Umsatz mehr wert ist als λ Euro Deckungsbeitrag, ändert sich Ihr Plan.” Das ist eine Frage, die ein Kaufmann beantworten kann — anders als „welche Gewichtung hätten Sie gern?“.

    @@ -29141,10 +29457,10 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
  • Zwei fehlende Prüfungen. Erstens der Entartungstest: Sind mehr Nebenbedingungen aktiv als Variablen vorhanden, ist der Wert 50 € nur einer von vielen möglichen und hätte gar nicht als Einzelwert berichtet werden dürfen (siehe Toleranzen_und_Entartung.py). Zweitens der Gültigkeitsbereich: Bis zu welcher Kapazitätserhöhung bleibt dieser Schattenpreis überhaupt bestehen? Ohne diese Spanne ist die Zahl nicht interpretierbar.

  • Nicht extrapolieren — nachrechnen. Der zusätzliche Nutzen ergibt sich aus einem zweiten Solverlauf, nicht aus einer Multiplikation:

  • -
    z_basis = -linprog(c, A_ub=A, b_ub=b, bounds=(0, None)).fun
    -b_neu = b.copy(); b_neu[0] += 2000                 # zweiter Lackierofen
    -z_neu = -linprog(c, A_ub=A, b_ub=b_neu, bounds=(0, None)).fun
    -print(f"Tatsaechlicher Zusatznutzen: {z_neu - z_basis:,.0f} EUR")   # 3.000 EUR
    +
    z_basis = -linprog(c, A_ub=A, b_ub=b, bounds=(0, None)).fun
    +b_neu = b.copy(); b_neu[0] += 2000                 # zweiter Lackierofen
    +z_neu = -linprog(c, A_ub=A, b_ub=b_neu, bounds=(0, None)).fun
    +print(f"Tatsaechlicher Zusatznutzen: {z_neu - z_basis:,.0f} EUR")   # 3.000 EUR

    Das ist die allgemeine Regel für jede Sensitivitätsaussage über mehr als eine Grenzeinheit: Der Schattenpreis zeigt die Richtung, die Neuberechnung liefert den Betrag. Bei einer Investitionsentscheidung kostet ein zusätzlicher Solverlauf Millisekunden — und hier hätte er 380 000 € gespart.

    Micro-Quiz

    1 — (b). Genau der Satz vom komplementären Schlupf: s_i \cdot y_i = 0. Ist Schlupf vorhanden, muss der Dualwert null sein. (a) hat die Aussage in ihr Gegenteil verkehrt; (c) verwechselt Schlupf mit Auslastung — s_i = 12 heißt gerade, dass 12 Einheiten übrig sind.

    @@ -29182,17 +29498,17 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
    1. Die Prüfung. Drei Tests, zusammen keine zwanzig Zeilen, die in jedes MILP-Auswertungsskript gehören:
    -
    # 1. Sind die Binaervariablen wirklich binaer?
    -assert np.abs(y - np.round(y)).max() <= 1e-6, "y ist nicht ganzzahlig"
    -
    -# 2. Widerspricht sich die Loesung selbst?
    -liefert = x.sum(axis=1) > 1e-6
    -assert not (liefert & (y < 0.5)).any(), "Lager liefert, gilt aber als geschlossen"
    -
    -# 3. Stimmt der Zielwert mit den Kosten der Loesung ueberein?
    -echte_kosten = FIXKOSTEN[liefert].sum() + (TRANSPORT * x).sum()
    -assert abs(echte_kosten - zielwert) < 1e-4 * echte_kosten, \
    -    f"Zielwert {zielwert} passt nicht zu den echten Kosten {echte_kosten}"
    +
    # 1. Sind die Binaervariablen wirklich binaer?
    +assert np.abs(y - np.round(y)).max() <= 1e-6, "y ist nicht ganzzahlig"
    +
    +# 2. Widerspricht sich die Loesung selbst?
    +liefert = x.sum(axis=1) > 1e-6
    +assert not (liefert & (y < 0.5)).any(), "Lager liefert, gilt aber als geschlossen"
    +
    +# 3. Stimmt der Zielwert mit den Kosten der Loesung ueberein?
    +echte_kosten = FIXKOSTEN[liefert].sum() + (TRANSPORT * x).sum()
    +assert abs(echte_kosten - zielwert) < 1e-4 * echte_kosten, \
    +    f"Zielwert {zielwert} passt nicht zu den echten Kosten {echte_kosten}"

    Test 3 ist der wichtigste. Er vergleicht nicht Modell mit Modell, sondern Modell mit Wirklichkeit — er rechnet die Kosten aus der ausgegebenen Lösung neu aus, ganz ohne Solver. Damit fängt er nicht nur den Big-M-Fehler, sondern jeden Fehler, bei dem die Zielfunktion etwas anderes bilanziert als der Plan tatsächlich kostet.

    Vorbeugend gilt weiter die Regel aus dem Kapitel: M so klein wie möglich, hergeleitet aus einer echten Kapazität. Hier wäre das schlicht die Lagerkapazität — mehr kann ein Lager ohnehin nicht ausliefern.

    Micro-Quiz

    @@ -29214,18 +29530,41 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:

    7.3 — Regeln ergänzen. (a) for s in (2,3): modell.Add(x["Frau_Albrecht", s] == 0) (b) Hilfsvariablen arbeitet_bauer, arbeitet_koch per AddMaxEquality an die Zuweisungssummen koppeln, dann modell.Add(arbeitet_bauer + arbeitet_koch <= 1). (c) Belohnung = negative Strafe: strafterme.append(-30 * folge_var), wobei folge_var per AddBoolAnd/OnlyEnforceIf an x_{p,0} \wedge x_{p,1} gekoppelt wird.

    7.4 — Infeasibility. Mit MAX_VERTRETUNGEN = 0 meldet der Solver INFEASIBLE. Nach Einbau der Schlupfvariablen lässt der Solver alle vier Stunden ausfallen (4 × 10 000 = 40 000 Strafpunkte) — es geht nicht anders. Interessanter wird es bei MAX_VERTRETUNGEN = 1: Dann fällt genau die Stunde aus, für die am wenigsten qualifiziertes Personal zur Verfügung steht.

    7.5 — Sudoku.

    -
    x = [[m.NewIntVar(1, 9, f"x{i}{j}") for j in range(9)] for i in range(9)]
    -for i in range(9): m.AddAllDifferent(x[i])                       # Zeilen
    -for j in range(9): m.AddAllDifferent([x[i][j] for i in range(9)]) # Spalten
    -for bi in range(3):
    -    for bj in range(3):
    -        m.AddAllDifferent([x[3*bi+i][3*bj+j] for i in range(3) for j in range(3)])
    -for i in range(9):
    -    for j in range(9):
    -        if vorgabe[i][j]: m.Add(x[i][j] == vorgabe[i][j])
    +
    x = [[m.NewIntVar(1, 9, f"x{i}{j}") for j in range(9)] for i in range(9)]
    +for i in range(9): m.AddAllDifferent(x[i])                       # Zeilen
    +for j in range(9): m.AddAllDifferent([x[i][j] for i in range(9)]) # Spalten
    +for bi in range(3):
    +    for bj in range(3):
    +        m.AddAllDifferent([x[3*bi+i][3*bj+j] for i in range(3) for j in range(3)])
    +for i in range(9):
    +    for j in range(9):
    +        if vorgabe[i][j]: m.Add(x[i][j] == vorgabe[i][j])

    Etwa 10 Zeilen Modellcode — und der Solver löst jedes Sudoku in Millisekunden. Das ist die Stärke globaler Constraints.

    -

    7.6 — Job-Shop erweitern. (a) Rüstzeiten: AddNoOverlap durch paarweise Disjunktionen mit Übergangszeit ersetzen, oder AddCircuit je Maschine mit Übergangsmatrix. (b) Verspätung: tardiness = MaxEquality(0, ende - faellig), in die Zielfunktion. (c) AddCumulative(intervalle, [1]*n, 2) statt AddNoOverlap für die Doppelmaschine.

    -

    7.7 — Wochendienstplan. Siehe Projekt P2. Kernpunkte: Nachtschicht-Folgeregel über AddImplication; „höchstens 5 Tage in Folge“ über gleitende Fenster (sum(x[p,t..t+5]) <= 5).

    +

    7.6 — Die Grenze der Parallelität finden. (a) Der Gewinn flacht ab und kehrt sich um. Auf einem Rechner mit 24 Kernen gemessen (Faktor gegenüber einem Arbeiter): 8 Arbeiter 10,2, 16 Arbeiter 12,3, 24 Arbeiter 9,0. Wer alle Kerne belegt, ist langsamer als mit zwei Dritteln — dem Betriebssystem, dem Speicherbus und den Suchsträngen untereinander bleibt zu wenig Luft. Die Faustregel „so viele Arbeiter wie Kerne” ist also falsch; die richtige Zahl ist eine Messung, keine Einstellung. (b) Fünf verschiedene Pläne aus fünf Seeds — bei num_workers = 1 und identischem Zielwert 183. Das ist die andere Hälfte der Antwort: Der Seed wirkt, aber nur auf den einzelnen Suchstrang. Es sichert also keiner der beiden Parameter allein die Reproduzierbarkeit:

    + + + + + + + + + + + + + + + + + + + + +
    Seed festSeed variabel
    1 Arbeiterreproduzierbar5 Pläne aus 5 Läufen
    mehrere Arbeiter3 Pläne aus 4 Läufenerst recht nicht
    +

    Nur die Kombination trägt. (c) Bei 15 Aufträgen (60 s Zeitlimit): Ein Arbeiter läuft ins Limit und meldet FEASIBLE mit Makespan 200; acht Arbeiter melden OPTIMAL mit demselben Makespan 200 nach 26,4 s. Bei 13 Aufträgen sind es 11,2 s gegen 2,8 s, bei 14 schon 21,4 s gegen 3,7 s. Bemerkenswert ist, dass beide denselben Wert finden — der Unterschied liegt nicht in der Lösung, sondern im Beweis, dass es keine bessere gibt. Genau diese Arbeit teilen sich die parallelen Stränge auf.

    +

    7.7 — Job-Shop erweitern. (a) Rüstzeiten: AddNoOverlap durch paarweise Disjunktionen mit Übergangszeit ersetzen, oder AddCircuit je Maschine mit Übergangsmatrix. (b) Verspätung: tardiness = MaxEquality(0, ende - faellig), in die Zielfunktion. (c) AddCumulative(intervalle, [1]*n, 2) statt AddNoOverlap für die Doppelmaschine.

    +

    7.8 — Wochendienstplan. Siehe Projekt P2. Kernpunkte: Nachtschicht-Folgeregel über AddImplication; „höchstens 5 Tage in Folge“ über gleitende Fenster (sum(x[p,t..t+5]) <= 5).

    Finde den Denkfehler — Die Betriebsvereinbarung, die niemanden interessiert

    1. Warum die Regel verletzt wird. Weil sie einen Preis hat. Eine weiche Nebenbedingung verbietet nichts — sie verteuert. Der Solver vergleicht in jedem Schritt, was ihn eine Verletzung kostet und was sie ihm einbringt. Bei einem Gewicht von 1 gegen 10 lohnt sich eine Drei-Tage-Serie, sobald sie auch nur ein Zehntel eines Wunsches rettet. Der Solver hat genau das getan, was im Modell steht — er hat nur nicht getan, was gemeint war.

    2. @@ -29303,6 +29642,8 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
    3. Eine Variable mit Start, Dauer und Ende; sie garantiert automatisch start + dauer == ende.
    4. Weil jede zusätzliche harte Bedingung das Risiko von INFEASIBLE erhöht — und eine Fehlermeldung dem Anwender nicht hilft.
    5. Weil Anwender die Frage „Warum ich?“ stellen. Ohne Antwort wird das System umgangen.
    6. +
    7. Weil der Seed nur festlegt, wie ein einzelner Suchstrang würfelt — nicht, welcher von mehreren parallelen zuerst fertig wird. Das entscheidet die Ausführungsreihenfolge, also die Uhr. Abhilfe: zusätzlich num_workers = 1. Gemessen: schon zwei Arbeiter liefern bei identischem Seed drei verschiedene Pläne zum selben Zielwert.
    8. +
    9. Weil CP-SAT nicht dieselbe Suche vervielfacht, sondern verschiedene Strategien nebeneinander laufen lässt (LP-gestützt, andere Verzweigungsregeln, lokale Suche), die einander ihre Schranken mitteilen. Ein zusätzlicher Arbeiter ist deshalb nicht ein weiterer Kern für dieselbe Arbeit, sondern ein anderes Verfahren — und wenn eines davon früh eine gute Schranke findet, profitieren alle.

    A.8 Lösungen zu Kapitel „Graphen, Flüsse und Touren — Min-Cost-Flow, Matching und VRP“

    @@ -29376,12 +29717,12 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
    1. Die Prüfung — und warum sie unabhängig sein muss.
    -
    for fahrzeug, (ladung, kapazitaet) in enumerate(zip(ladungen, KAPAZITAETEN)):
    -    assert ladung <= kapazitaet, \
    -        f"Fahrzeug {fahrzeug + 1}: {ladung} Paletten bei Kapazitaet {kapazitaet}"
    +
    for fahrzeug, (ladung, kapazitaet) in enumerate(zip(ladungen, KAPAZITAETEN)):
    +    assert ladung <= kapazitaet, \
    +        f"Fahrzeug {fahrzeug + 1}: {ladung} Paletten bei Kapazitaet {kapazitaet}"

    Dabei wird ladung aus der ausgegebenen Tour aufsummiert — also aus der Liste der angefahrenen Kunden und deren Bedarfen. Entscheidend ist, was man dafür nicht benutzt:

    -
    # FALSCH als Pruefung:
    -ladung = loesung.Value(routing.GetDimensionOrDie("Ladung").CumulVar(index))
    +
    # FALSCH als Pruefung:
    +ladung = loesung.Value(routing.GetDimensionOrDie("Ladung").CumulVar(index))

    Diese Zeile fragt das Modell, ob das Modell sich an sich selbst hält. Existiert die Dimension „Ladung“ gar nicht, stürzt sie ab — und existiert sie, kann sie per Konstruktion nie verletzt sein. Sie prüft also entweder nichts oder gar nichts.

    Die allgemeine Regel: Eine Prüfung, die dieselben Bausteine verwendet wie das Modell, prüft das Modell gegen sich selbst. Nützlich ist nur eine Prüfung, die von der ausgegebenen Lösung ausgeht und die Anforderungen der Wirklichkeit unabhängig nachrechnet — genau wie die Kostengegenrechnung in Abschnitt 6.10 und die Verbrauchsprüfung in Abschnitt 1.10.

    Micro-Quiz

    @@ -29507,9 +29848,9 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
  • Die Eigenwerte von \mathbf{R} lauten (-0{,}8;\ 1{,}9;\ 1{,}9). Ein negativer Eigenwert bedeutet: Die Matrix ist nicht positiv semidefinit. Es gibt eine Richtung im Gewichtsraum — hier ungefähr (0{,}58;\ -0{,}58;\ 0{,}58), der Eigenvektor zu -0{,}8 —, entlang derer die quadratische Form negativ wird. Genau diese Richtung hat scipy gefunden: Das Ergebnis (1{,}5;\ -2{,}0;\ 1{,}5) ist ein Vielfaches davon.
  • Der Test dafür ist eine Zeile und gehört vor jede Risikorechnung:

    -
    eigenwerte = np.linalg.eigvalsh(S)
    -assert eigenwerte.min() >= -1e-10, \
    -    f"Kovarianzmatrix nicht positiv semidefinit: kleinster Eigenwert {eigenwerte.min():.3f}"
    +
    eigenwerte = np.linalg.eigvalsh(S)
    +assert eigenwerte.min() >= -1e-10, \
    +    f"Kovarianzmatrix nicht positiv semidefinit: kleinster Eigenwert {eigenwerte.min():.3f}"
    1. Warum das kein Rechenfehler ist. scipy hat exakt die Aufgabe gelöst, die man ihm gestellt hat: „minimiere \mathbf{w}^\top\mathbf{S}\mathbf{w}“. Dass dieser Ausdruck bei nicht positiv semidefinitem \mathbf{S} beliebig negativ werden kann, ist eine Eigenschaft der Eingabe, nicht des Verfahrens. success: True heißt hier — wie in Abschnitt 11.6 — nur: „Ich bin an einer Stelle angekommen, an der es nicht weiter bergab geht.“ Dass es überhaupt bis -0{,}254 bergab ging, lag allein an den Schranken \pm 2; ohne sie liefe das Verfahren gegen -\infty.
    @@ -29547,8 +29888,8 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:

    12.5 — Monte-Carlo erweitern. (a) Servicelevel 95 %: Kapazität = 95 %-Quantil des Bedarfs (ca. 485). Die Zusatzkosten gegenüber dem Kostenoptimum (180) betragen ein Vielfaches — Servicelevel ist teuer, und genau diese Zahl braucht die Geschäftsleitung für die Entscheidung. (b) cvar = kosten[kosten >= np.quantile(kosten, 0.95)].mean(). (c) Erwartetes Bild: Bei kleiner Kapazität ist die Kostenverteilung stark rechtsschief (seltene, teure Notzukäufe); bei großer Kapazität schmal und nach rechts verschoben.

    12.6 — Den Preis der Zusage selbst bestimmen. (a) Die Kosten je Prozentpunkt steigen weiter steil an; der Mix verschiebt sich dabei fast nur noch zugunsten des Gaskraftwerks, weil Biomasse längst an ihrer Ausbaugrenze liegt. Wind und Sonne bleiben bei einem kleinen Rest — sie senken über ihre Gegenläufigkeit die Norm, tragen zur gesicherten Leistung aber kaum bei. (b) Der Unterschied ist nicht numerisch, sondern inhaltlich. Bei 200 MW Biomasse liefert selbst der Vollausbau in der Kältewelle nur rund 440 MW — 7,5 % der Szenarien sind schlicht nicht bedienbar, egal wie viel Geld man ausgibt. Das Szenariomodell sieht diese Fälle in den Daten und meldet korrekt infeasible, sobald die Zusage über 92 % steigt. Das analytische Modell kennt sie nicht: Für eine Normalverteilung ist jede Zusage unter 100 % erfüllbar, wenn man nur genug Streuung wegkauft. Es meldet optimal — und der Plan hält gemessen 87,4 %. Glauben sollte man dem Szenariomodell: Ein infeasible ist hier die richtige Antwort. Es sagt, dass die Zusage nicht am Budget scheitert, sondern am Kraftwerkspark, und dass nicht mehr Geld hilft, sondern nur eine andere Anlage oder eine kleinere Zusage. (c) Sinngemäß: „99,99 % kosten nicht 5 % mehr als 99 %, sondern ein Vielfaches — der letzte Prozentpunkt ist bereits 4,5-mal so teuer wie der erste. Sagen Sie mir, was ein Ausfalltag das Unternehmen kostet, dann rechne ich aus, welche Quote sich lohnt. Und die Zusage gilt nur für die Wetterlagen, die wir modelliert haben — die Kältewelle gehört hinein.“

    12.7 — Budgeted Uncertainty.

    -
    abzug = cp.sum_largest(cp.multiply(UNSICHERHEIT, w), Gamma)
    -ziel = cp.Maximize(MU_SCHAETZUNG @ w - abzug - 0.5*LAMBDA*cp.quad_form(w, SIGMA))
    +
    abzug = cp.sum_largest(cp.multiply(UNSICHERHEIT, w), Gamma)
    +ziel = cp.Maximize(MU_SCHAETZUNG @ w - abzug - 0.5*LAMBDA*cp.quad_form(w, SIGMA))

    \Gamma = 0 entspricht dem nominalen Fall, \Gamma = n dem vollen Worst Case. Dazwischen steuert \Gamma die Vorsicht stufenlos — der praktisch nützlichste Bereich liegt meist bei \Gamma \approx \sqrt{n}.

    Finde den Denkfehler — Warum jedes Projekt zu spät fertig wird

      @@ -29579,8 +29920,8 @@ F(11{,}33) = 1 - \frac{(18-11{,}33)^2}{(18-6)(18-10)} = 1 - \frac{44{,}4}{96} =
      1. Die Zahl für den Projektplan. Bei einer geforderten Sicherheit von 90 % gehört das 90-%-Quantil in den Plan: 16,6 Tage, aufgerundet 17. Das sind 47 % mehr als die ursprünglich geplanten 11,3 Tage.
      -
      puffer = np.quantile(projekt, 0.90)
      -print(f"Termin mit 90 % Sicherheit: {np.ceil(puffer):.0f} Tage")
      +
      puffer = np.quantile(projekt, 0.90)
      +print(f"Termin mit 90 % Sicherheit: {np.ceil(puffer):.0f} Tage")

      Und die Formulierung gegenüber dem Auftraggeber lautet nicht „der Umbau dauert 17 Tage“, sondern: „In 9 von 10 Fällen sind wir nach 17 Tagen fertig; im Mittel nach 14,4.“ Diese zwei Zahlen sind eine belastbare Zusage — eine einzelne ist es nie.

      Micro-Quiz

      1 — (b). Das kritische Verhältnis 1400/(1400+120) = 0{,}921 gibt an, wie weit man sich auf die günstigere Fehlerseite stellen soll: Bestellt wird das 92,1-%-Quantil des Bedarfs, hier 38 Stück. (a) ignoriert die Kostenasymmetrie und kostet im Kapitelbeispiel 160 % mehr. (c) verwechselt die Fragestellung: Die Kapitalbindung ist bereits in den 120 € je überzähligem Stück enthalten — sie rechtfertigt keine zusätzliche Kürzung.

      @@ -29602,12 +29943,12 @@ F(11{,}33) = 1 - \frac{(18-11{,}33)^2}{(18-6)(18-10)} = 1 - \frac{44{,}4}{96} =

      13.2 — Optimalitätsprinzip. Weil der Wert eines Zustands nur von den künftigen Entscheidungen abhängt, kann man ihn ab dem Ende rekursiv berechnen. Hängen die Kosten zusätzlich von früheren Aktionen ab, ist die Markov-Eigenschaft verletzt — die Lösung: die relevante Vergangenheit in den Zustand aufnehmen (dann wächst allerdings der Zustandsraum).

      13.3 — Rückwärtsinduktion. Mit C(n) = n^2 + 2n und 5 Einheiten in 3 Perioden ergibt sich als optimaler Pfad 2 \to 2 \to 1 (oder eine Permutation davon) mit Gesamtkosten 8 + 8 + 3 = 19. Alles auf einmal kostet 25 + 10 = 35.

      13.4 — Rucksack als DP.

      -
      V = np.zeros((n + 1, kapazitaet + 1))
      -for i in range(1, n + 1):
      -    for c in range(kapazitaet + 1):
      -        V[i][c] = V[i-1][c]
      -        if gewicht[i-1] <= c:
      -            V[i][c] = max(V[i][c], V[i-1][c - gewicht[i-1]] + nutzen[i-1])
      +
      V = np.zeros((n + 1, kapazitaet + 1))
      +for i in range(1, n + 1):
      +    for c in range(kapazitaet + 1):
      +        V[i][c] = V[i-1][c]
      +        if gewicht[i-1] <= c:
      +            V[i][c] = max(V[i][c], V[i-1][c - gewicht[i-1]] + nutzen[i-1])

      Ergebnis identisch zum MILP (110). Laufzeit O(n \cdot C) — bei kleinen ganzzahligen Kapazitäten schneller als Branch-and-Bound, bei großen oder nicht-ganzzahligen Kapazitäten schlechter (dann ist das MILP überlegen). Man nennt das pseudopolynomiell.

      13.5 — Risikoaversion kalibrieren. (a) Zwischen \lambda = 10^{-5} (41 %) und 10^{-4} (78 %); genauer etwa bei \lambda \approx 3\cdot10^{-5}. (b) Höheres \lambda → höhere Gesamtkosten (man kauft Sicherheit mit Slippage). (c) Iterativ: \lambda so wählen, dass der Anteil der Marktauswirkungskosten an den Gesamtkosten bei 20 % liegt — im Programm ausrechnen und per Bisektion einstellen.

      13.6 — Zustandsraum erweitern. Der Zustand wird zu (Restbestand, Orderbuchtiefe); der Zustandsraum verdreifacht sich, die Rechenzeit ebenfalls. Die Strategie wird zustandsabhängig: Bei tiefem Buch wird mehr verkauft, bei dünnem gewartet. Genau diese Adaptivität ist der Mehrwert von DP gegenüber einem festen Pfad.

      @@ -29615,7 +29956,7 @@ F(11{,}33) = 1 - \frac{(18-11{,}33)^2}{(18-6)(18-10)} = 1 - \frac{44{,}4}{96} =
      1. Die fehlende Information. Die Zeile
      -
      kosten = (RUESTKOSTEN if p > 0 else 0) + ...
      +
      kosten = (RUESTKOSTEN if p > 0 else 0) + ...

      berechnet die Rüstkosten allein daraus, ob in dieser Periode produziert wird. Die tatsächliche Regel lautet aber: Rüstkosten fallen an, wenn produziert wird und in der Vorperiode nicht. Ob in der Vorperiode produziert wurde, steht nirgends im Zustand (t, lager) — die Funktion kann es gar nicht wissen.

      Das Modell nimmt deshalb an, dass jede Produktionsperiode eine Rüstung kostet. Es bestraft laufende Produktion, obwohl sie in Wirklichkeit gratis weiterläuft.

        @@ -29653,11 +29994,11 @@ F(11{,}33) = 1 - \frac{(18-11{,}33)^2}{(18-6)(18-10)} = 1 - \frac{44{,}4}{96} =
        1. Der korrigierte Zustand lautet (t, lager, lief_vorher) mit einem zusätzlichen Wahrheitswert:
        -
        @functools.lru_cache(None)
        -def V(t, lager, lief_vorher):
        -    ...
        -    kosten = (RUESTKOSTEN if (p > 0 and not lief_vorher) else 0) + ...
        -    rest, plan = V(t + 1, neu, p > 0)
        +
        @functools.lru_cache(None)
        +def V(t, lager, lief_vorher):
        +    ...
        +    kosten = (RUESTKOSTEN if (p > 0 and not lief_vorher) else 0) + ...
        +    rest, plan = V(t + 1, neu, p > 0)

        Damit findet die Rückwärtsinduktion (3, 1, 4, 2) für 70 € — das nachgerechnete Optimum. Der Zustandsraum verdoppelt sich (je Lagerstand zwei Varianten, „lief“ und „lief nicht“). Das ist der übliche Preis: Vollständigkeit des Zustands kostet Größe, und genau hier beginnt der Fluch der Dimensionalität.

        1. Warum der richtige Betrag das Tückische ist. Man würde erwarten, dass ein falsches Modell auch einen falschen Kostenbetrag ausgibt — dann fiele es beim Nachrechnen auf. Hier nicht: Für den von ihm gewählten Plan (5,0,5,0) stimmt die Rechnung zufällig, weil in diesem Plan tatsächlich jede Produktionsperiode auf eine Pause folgt. Modellannahme und Wirklichkeit fallen für genau diese eine Lösung zusammen.
        2. @@ -30159,19 +30500,19 @@ T \ge \left(\frac{2\sigma}{\mu}\right)^2 = \left(\frac{2 \cdot 0{,}012}{0{,}0004

          23.1 — Den Schnellstart reparieren. Zwei Wege: (a) Abrunden statt rundennp.floor statt np.round. Das ist immer zulässig, weil weniger produzieren nie eine Kapazität sprengt, kostet aber Deckungsbeitrag und ist im Allgemeinen nicht die optimale ganzzahlige Lösung. (b) Ganzzahlig modellierenlinprog(..., integrality=1) bzw. IntVar. Das ist der richtige Weg.

          Warum das ein eigenes Kapitel wert ist (Kapitel 6): Runden ist nicht nur ungenau, sondern kann beliebig danebenliegen. Es gibt Instanzen, bei denen die gerundete LP-Lösung nicht nur suboptimal, sondern unzulässig ist — genau das zeigt der Schnellstart — und andere, bei denen zwischen gerundetem LP und echtem Optimum Welten liegen (Runden_Gegenbeispiel.py).

          23.2 — Eine Invariante mehr.

          -
          def test_kapazitaeten_verdoppeln(bauer):
          -    problem = schreinerei()
          -    doppelt = Produktionsproblem(
          -        produkte=problem.produkte,
          -        kapazitaeten={r: 2 * k for r, k in problem.kapazitaeten.items()})
          -    basis, gross = bauer(problem), bauer(doppelt)
          -    assert gross.zielwert == pytest.approx(2 * basis.zielwert, rel=1e-9)
          +
          def test_kapazitaeten_verdoppeln(bauer):
          +    problem = schreinerei()
          +    doppelt = Produktionsproblem(
          +        produkte=problem.produkte,
          +        kapazitaeten={r: 2 * k for r, k in problem.kapazitaeten.items()})
          +    basis, gross = bauer(problem), bauer(doppelt)
          +    assert gross.zielwert == pytest.approx(2 * basis.zielwert, rel=1e-9)

          Bei einem LP gilt das exakt: Der zulässige Bereich wird um den Faktor 2 gestreckt, und weil die Zielfunktion linear ist, skaliert das Optimum mit. Bei einem MILP gilt es nicht: Die Ganzzahligkeitsbedingung skaliert nicht mit. Aus 30,67 wird beim Verdoppeln 61,33 — und ob dazwischen eine bessere ganzzahlige Lösung liegt, hängt vom Einzelfall ab. Der Test wäre dort also falsch.

          23.3 — Eine eigene Mutation. Zwei lohnende Kandidaten:

          -
          ("Kapazitaetspruefung mit falschem Vergleich",
          - "if ist > grenze + toleranz:", "if ist < grenze - toleranz:"),
          -("Toleranz mit falschem Vorzeichen",
          - "if (mengen < -toleranz).any():", "if (mengen < toleranz).any():"),
          +
          ("Kapazitaetspruefung mit falschem Vergleich",
          + "if ist > grenze + toleranz:", "if ist < grenze - toleranz:"),
          +("Toleranz mit falschem Vorzeichen",
          + "if (mengen < -toleranz).any():", "if (mengen < toleranz).any():"),

          Die erste wird getötet (test_pruefung_findet_kapazitaetsverletzung). Die zweite ist interessanter: Sie macht die Prüfung strenger statt schwächer — eine Lösung mit einer Menge von exakt 0 würde als negativ beanstandet. Ob sie überlebt, hängt daran, ob die Suite eine Instanz enthält, in der ein Produkt mit Menge 0 vorkommt. In der Schreinerei ist das nicht der Fall — die Mutation überlebt also und zeigt eine echte Lücke: Es fehlt eine Testinstanz, in der ein Produkt nicht produziert wird.

          23.4 — Der Benchmark mit MILP. Zu erwarten ist, dass sich die Reihenfolge ändert. Beim reinen LP entscheidet vor allem der Modellaufbau in Python; beim MILP verschiebt sich das Gewicht zum Lösen, und dort spielen die Branch-and-Bound-Heuristiken der Bibliotheken gegeneinander. Die Spalte „Anteil” sollte bei allen deutlich fallen — nicht weil der Aufbau schneller würde, sondern weil das Lösen langsamer wird. Genau deshalb steht im Kapitel, dass die Tabelle nichts über MILPs sagt.

          23.5 — Der Dienst mit Zeitlimit. Der Modellbauer in or_kern.py nimmt bisher kein Zeitlimit entgegen — das ist der erste Schritt (bei GLOP solver.SetTimeLimit(millisekunden)). Danach im Arbeiter durchreichen und den Status auswerten: Liefert der Solver ZEITLIMIT, ist stand weiterhin gescheitert, aber mit einer anderen Begründung als bei UNZULAESSIG — der Unterschied zwischen „rechne länger” und „ändere das Modell” (Kapitel 6).

          @@ -30428,8 +30769,8 @@ T \ge \left(\frac{2\sigma}{\mu}\right)^2 = \left(\frac{2 \cdot 0{,}012}{0{,}0004

          B1 — Aktivierungsschalter (Fixkosten)

          Regel. „Wenn überhaupt etwas produziert wird, fallen Rüstkosten F an.“

          x \le M\,y,\qquad y\in\{0,1\},\ x\ge0 Zielfunktion: \dots + F\,y

          -
          modell.Add(x <= M * y)              # CP-SAT
          -# LP/MILP:  Zeile  x - M*y <= 0
          +
          modell.Add(x <= M * y)              # CP-SAT
          +# LP/MILP:  Zeile  x - M*y <= 0

          ⚠️ M so klein wie möglich — idealerweise die ohnehin vorhandene Kapazitätsgrenze von x. Zu großes M macht die LP-Relaxation wertlos (Abschnitt 6.5).

          @@ -30440,18 +30781,18 @@ T \ge \left(\frac{2\sigma}{\mu}\right)^2 = \left(\frac{2 \cdot 0{,}012}{0{,}0004

          B3 — Implikation

          Regel. „Wenn A gewählt wird, muss auch B gewählt werden.“

          y_A \le y_B

          -
          modell.AddImplication(y_a, y_b)     # CP-SAT, gleichwertig und lesbarer
          +
          modell.AddImplication(y_a, y_b)     # CP-SAT, gleichwertig und lesbarer

          Für die Umkehrung („nur wenn“) einfach vertauschen. Für Äquivalenz: y_A = y_B.

          B4 — Entweder-Oder (disjunktive Bedingung)

          Regel. „Es muss f(\mathbf{x}) \le b_1 oder g(\mathbf{x}) \le b_2 gelten.“

          f(\mathbf{x}) \le b_1 + M(1-y),\qquad g(\mathbf{x}) \le b_2 + M y

          -
          # CP-SAT: viel eleganter ueber Reifizierung
          -modell.Add(f_ausdruck <= b1).OnlyEnforceIf(y)
          -modell.Add(g_ausdruck <= b2).OnlyEnforceIf(y.Not())
          +
          # CP-SAT: viel eleganter ueber Reifizierung
          +modell.Add(f_ausdruck <= b1).OnlyEnforceIf(y)
          +modell.Add(g_ausdruck <= b2).OnlyEnforceIf(y.Not())

          B5 — Exklusiv-Oder

          Regel. „Genau eine der Optionen wird gewählt.“

          \sum_{j=1}^N y_j = 1

          -
          modell.AddExactlyOne(y)             # bzw. AddAtMostOne / AddBoolOr
          +
          modell.AddExactlyOne(y)             # bzw. AddAtMostOne / AddBoolOr

          B6 — Kardinalität

          Regel. „Höchstens (mindestens, genau) K von N.“

          \sum_j y_j \le K \qquad(\ \ge K,\ = K\ )

          @@ -30476,14 +30817,14 @@ T \ge \left(\frac{2\sigma}{\mu}\right)^2 = \left(\frac{2 \cdot 0{,}012}{0{,}0004

          Regel. „Die Abweichung vom Zielwert soll klein sein, egal in welche Richtung.“

          |x - z| \le d \quad\Longleftrightarrow\quad x - z \le d\ \ \text{und}\ \ z - x \le d

          In der Zielfunktion (Minimierung von |x-z|) genügt: \min d \quad\text{u. d. N.}\quad x-z \le d,\quad z-x \le d

          -
          turnover = cp.norm1(w - w_alt)      # CVXPY macht das automatisch
          +
          turnover = cp.norm1(w - w_alt)      # CVXPY macht das automatisch

          B11 — Min/Max in der Zielfunktion

          Regel. „Der am schlechtesten gestellte Beteiligte soll möglichst gut dastehen“ (Maximin/Fairness).

          \max t \quad\text{u. d. N.}\quad t \le f_i(\mathbf{x})\ \ \forall i

          -
          modell.AddMaxEquality(max_var, liste)   # CP-SAT
          -modell.AddMinEquality(min_var, liste)
          -# Fairness ueber die Spannweite:
          -modell.Add(spannweite == max_var - min_var)   # dann minimieren
          +
          modell.AddMaxEquality(max_var, liste)   # CP-SAT
          +modell.AddMinEquality(min_var, liste)
          +# Fairness ueber die Spannweite:
          +modell.Add(spannweite == max_var - min_var)   # dann minimieren

          B12 — Verhältnis-Bedingung

          Regel. „Der Anteil von Gruppe G soll mindestens 30 % betragen.“

          \frac{\sum_{i\in G} x_i}{\sum_i x_i} \ge 0{,}3 \quad\Longleftrightarrow\quad \sum_{i\in G} x_i \ge 0{,}3\sum_i x_i

          @@ -30494,12 +30835,12 @@ T \ge \left(\frac{2\sigma}{\mu}\right)^2 = \left(\frac{2 \cdot 0{,}012}{0{,}0004

          Regel. „Entweder wir arbeiten mit diesem Lieferanten gar nicht, oder wir nehmen ihm im Jahr mindestens 500 Einheiten ab.“

          Ein Schalter für den ganzen Zeitraum, nicht je Periode:

          Q\,y \le \sum_{t} x_{t} \le U\,y, \qquad y \in \{0,1\},\ x_t \ge 0

          -
          # y_s ist EINE Variable je Lieferant - nicht eine je Lieferant UND Periode
          -y = {s: modell.NewBoolVar(f"vertrag_{s}") for s in lieferanten}
          -for s in lieferanten:
          -    jahresmenge = sum(x[s, t] for t in perioden)
          -    modell.Add(jahresmenge >= MINDESTMENGE[s] * y[s])
          -    modell.Add(jahresmenge <= JAHRESKAPAZITAET[s] * y[s])   # koppelt x an y
          +
          # y_s ist EINE Variable je Lieferant - nicht eine je Lieferant UND Periode
          +y = {s: modell.NewBoolVar(f"vertrag_{s}") for s in lieferanten}
          +for s in lieferanten:
          +    jahresmenge = sum(x[s, t] for t in perioden)
          +    modell.Add(jahresmenge >= MINDESTMENGE[s] * y[s])
          +    modell.Add(jahresmenge <= JAHRESKAPAZITAET[s] * y[s])   # koppelt x an y

          ⚠️ Die häufigste Verwechslung: L\,y_t \le x_t je Periode (B2) ist ein anderes Modell — es verlangt in jeder einzelnen Periode eine Mindestmenge und ist erheblich strenger. Wer den Jahresvertrag so formuliert, erzeugt ein unlösbares Modell und sucht den Fehler dann in den Daten.

          @@ -30510,11 +30851,11 @@ T \ge \left(\frac{2\sigma}{\mu}\right)^2 = \left(\frac{2 \cdot 0{,}012}{0{,}0004

          Regel. „Alle Maßnahmen zusammen dürfen 2 Mio. € nicht überschreiten.“

          \sum_i c_i x_i \le B

          Mit x_i \in \{0,1\} ist das ein Rucksackproblem — dasselbe Muster, das Kapitel 6 an Rucksack.py vorrechnet und Kapitel 10 als Pricing-Teilproblem wiederverwendet.

          -
          modell.Add(sum(KOSTEN[i] * x[i] for i in massnahmen) <= BUDGET)
          -# Mehrere Toepfe: je Topf eine Zeile - NICHT die Summe ueber alle Toepfe
          -for topf, grenze in BUDGETS.items():
          -    modell.Add(sum(KOSTEN[i] * x[i] for i in massnahmen
          -                   if TOPF[i] == topf) <= grenze)
          +
          modell.Add(sum(KOSTEN[i] * x[i] for i in massnahmen) <= BUDGET)
          +# Mehrere Toepfe: je Topf eine Zeile - NICHT die Summe ueber alle Toepfe
          +for topf, grenze in BUDGETS.items():
          +    modell.Add(sum(KOSTEN[i] * x[i] for i in massnahmen
          +                   if TOPF[i] == topf) <= grenze)

          💡 Der Schattenpreis des Budgets ist die Zahl, nach der die Geschäftsführung fragt: „Was bringt der nächste Euro?“ Bei einem LP ist er direkt ablesbar. Bei Ganzzahligkeit gibt es ihn nicht — die Dualwerte der Relaxation sind keine gültige Antwort. Rechnen Sie stattdessen mit erhöhtem Budget neu und vergleichen Sie die Zielwerte (Abschnitt 5.9).

          @@ -30529,16 +30870,16 @@ T \ge \left(\frac{2\sigma}{\mu}\right)^2 = \left(\frac{2 \cdot 0{,}012}{0{,}0004

          Mit Mindestwartezeit w: \text{start}_B \ge \text{ende}_A + w.

          B14 — Nichtüberlappung

          Regel. „Eine Maschine bearbeitet nur einen Job gleichzeitig.“

          -
          modell.AddNoOverlap([intervall_1, intervall_2, ...])
          +
          modell.AddNoOverlap([intervall_1, intervall_2, ...])

          In MILP bräuchte man je Paar eine Disjunktion (B4) — bei k Jobs sind das \binom{k}{2} Konstruktionen. Nehmen Sie hier CP-SAT.

          B15 — Kumulative Ressource

          Regel. „Zu keinem Zeitpunkt dürfen mehr als 3 Arbeiten gleichzeitig laufen“ bzw. „die Stromlast darf 500 kW nie übersteigen“.

          -
          modell.AddCumulative(intervalle, bedarfe, kapazitaet)
          +
          modell.AddCumulative(intervalle, bedarfe, kapazitaet)

          B16 — Gleitendes Fenster

          Regel. „Höchstens 5 Arbeitstage in Folge“ / „mindestens 2 freie Tage je Woche“.

          \sum_{u=t}^{t+5} x_u \le 5 \qquad \forall t

          -
          for t in range(len(tage) - 5):
          -    modell.Add(sum(x[p, u] for u in range(t, t + 6)) <= 5)
          +
          for t in range(len(tage) - 5):
          +    modell.Add(sum(x[p, u] for u in range(t, t + 6)) <= 5)

          B17 — Umrüst- bzw. Wechselkosten

          Regel. „Ein Produktwechsel kostet Rüstzeit.“

          Hilfsvariable z_t \in \{0,1\} = „in Periode t wird gewechselt“:

          @@ -30549,9 +30890,9 @@ T \ge \left(\frac{2\sigma}{\mu}\right)^2 = \left(\frac{2 \cdot 0{,}012}{0{,}0004

          B17 verbucht den Wechsel in der Zielfunktion. Sobald die Maschine ausgelastet ist, gehört er zusätzlich in die Kapazitätszeile:

          \sum_j a_j\,x_{j,t} \;+\; \sum_j r_j\,y_{j,t} \;\le\; C_t \qquad \forall t

          mit a_j Stückzeit, r_j Rüstzeit, y_{j,t}\in\{0,1\} = „Produkt j läuft in Periode t“ (gekoppelt über B1: x_{j,t} \le M\,y_{j,t}).

          -
          for t in perioden:
          -    modell.Add(sum(STUECKZEIT[j] * x[j, t] for j in produkte)
          -               + sum(RUESTZEIT[j] * y[j, t] for j in produkte) <= KAPAZITAET[t])
          +
          for t in perioden:
          +    modell.Add(sum(STUECKZEIT[j] * x[j, t] for j in produkte)
          +               + sum(RUESTZEIT[j] * y[j, t] for j in produkte) <= KAPAZITAET[t])

          ⚠️ Wer die Rüstzeit nur als Kosten führt, erhält Pläne, die in der Halle nicht laufen. Das Modell verteilt die Produktion dann auf viele kleine Lose, weil ein zusätzlicher Wechsel zwar etwas kostet, aber keine Zeit verbraucht — die Rechnung geht auf dem Papier auf und in der Schicht nicht.

          @@ -30567,15 +30908,15 @@ T \ge \left(\frac{2\sigma}{\mu}\right)^2 = \left(\frac{2 \cdot 0{,}012}{0{,}0004

          Regel. „Erst die Besetzung sicherstellen, dann die Fairness optimieren.“

          Variante A — Gewichtung: Strafen um Größenordnungen staffeln (10\,000 \gg 100 \gg 1). Einfach, aber bei extremen Skalenunterschieden numerisch heikel.

          Variante B — Zweistufig lösen (sauberer):

          -
          # Stufe 1: nur das Hauptziel
          -modell.Minimize(unbesetzte_schichten)
          -loeser.Solve(modell)
          -bestwert = loeser.ObjectiveValue()
          -
          -# Stufe 2: Hauptziel fixieren, Nebenziel optimieren
          -modell.Add(unbesetzte_schichten <= int(bestwert))
          -modell.Minimize(unfairness)
          -loeser.Solve(modell)
          +
          # Stufe 1: nur das Hauptziel
          +modell.Minimize(unbesetzte_schichten)
          +loeser.Solve(modell)
          +bestwert = loeser.ObjectiveValue()
          +
          +# Stufe 2: Hauptziel fixieren, Nebenziel optimieren
          +modell.Add(unbesetzte_schichten <= int(bestwert))
          +modell.Minimize(unfairness)
          +loeser.Solve(modell)

          B20 — Symmetriebrechung

          Regel. „Drei identische Maschinen — der Solver soll nicht alle Vertauschungen durchprobieren.“

          \text{start}_1 \le \text{start}_2 \le \text{start}_3 \qquad\text{bzw.}\qquad \sum_j x_{1j} \ge \sum_j x_{2j}

          @@ -30584,7 +30925,7 @@ T \ge \left(\frac{2\sigma}{\mu}\right)^2 = \left(\frac{2 \cdot 0{,}012}{0{,}0004

          Regel. „Rechne nicht mit dem geschätzten Wert, sondern mit dem ungünstigsten plausiblen.“

          Bei Box-Unsicherheit \mu_i \in [\hat\mu_i - \delta_i, \hat\mu_i + \delta_i] und w \ge 0: \hat{\boldsymbol{\mu}}^\top\mathbf{w} \;\longrightarrow\; \hat{\boldsymbol{\mu}}^\top\mathbf{w} - \boldsymbol{\delta}^\top\mathbf{w}

          Abgestuft (Bertsimas/Sim): nur die \Gamma ungünstigsten gleichzeitig:

          -
          abzug = cp.sum_largest(cp.multiply(delta, w), Gamma)
          +
          abzug = cp.sum_largest(cp.multiply(delta, w), Gamma)

          Netzwerke

          B22 — Flusserhaltung

          @@ -30684,22 +31025,22 @@ T \ge \left(\frac{2\sigma}{\mu}\right)^2 = \left(\frac{2 \cdot 0{,}012}{0{,}0004

          Bedeutung. Es gibt keinen einzigen Punkt, der alle Bedingungen gleichzeitig erfüllt. Das ist eine Aussage über Ihr Modell, nicht über den Solver.

          Diagnose in fünf Schritten

          Schritt 1 — Trivialprüfungen von Hand. Rechnen Sie die offensichtlichen Bilanzen nach, bevor Sie den Solver befragen:

          -
          assert summe_angebot >= summe_bedarf, "Angebot deckt Bedarf nicht"
          -assert anzahl_personal * max_schichten >= anzahl_schichten, "zu wenig Personal"
          -for slot, fach in enumerate(faecher):
          -    qualifiziert = [p for p in personal if fach in qualifikation[p]]
          -    assert qualifiziert, f"Fuer Slot {slot} ({fach}) gibt es niemanden"
          +
          assert summe_angebot >= summe_bedarf, "Angebot deckt Bedarf nicht"
          +assert anzahl_personal * max_schichten >= anzahl_schichten, "zu wenig Personal"
          +for slot, fach in enumerate(faecher):
          +    qualifiziert = [p for p in personal if fach in qualifikation[p]]
          +    assert qualifiziert, f"Fuer Slot {slot} ({fach}) gibt es niemanden"

          Erfahrungsgemäß findet dieser Schritt die Mehrzahl aller Fälle.

          Schritt 2 — Bedingungen einzeln abschalten. Kommentieren Sie Bedingungsgruppen nacheinander aus. Wird das Modell lösbar, ist die zuletzt entfernte Gruppe (mit-)verantwortlich.

          Schritt 3 — Schlupfvariablen einbauen (das wirksamste Mittel).

          -
          # statt:  modell.Add(bedingung)
          -schlupf = modell.NewIntVar(0, obergrenze, f"schlupf_{name}")
          -modell.Add(linke_seite <= rechte_seite + schlupf)
          -strafen.append(10_000 * schlupf)
          +
          # statt:  modell.Add(bedingung)
          +schlupf = modell.NewIntVar(0, obergrenze, f"schlupf_{name}")
          +modell.Add(linke_seite <= rechte_seite + schlupf)
          +strafen.append(10_000 * schlupf)

          Nach dem Lösen zeigen die Schlupfvariablen mit Wert > 0 präzise, welche Bedingung wo und um wie viel verletzt werden musste. Das ist zugleich die produktionstaugliche Lösung (Muster B18).

          Schritt 4 — Zeitfenster und Erreichbarkeit prüfen. Bei Scheduling und Routing: Ist jeder Termin überhaupt physisch erreichbar?

          -
          for kunde, (frueh, spaet) in enumerate(zeitfenster):
          -    assert distanz[depot][kunde] <= spaet, f"Kunde {kunde} nicht rechtzeitig erreichbar"
          +
          for kunde, (frueh, spaet) in enumerate(zeitfenster):
          +    assert distanz[depot][kunde] <= spaet, f"Kunde {kunde} nicht rechtzeitig erreichbar"

          Schritt 5 — Rundungs- und Einheitenfehler. Sind Kapazitäten in Stunden, Verbräuche aber in Minuten? Rechnen CP-SAT-Modelle mit ganzen Zahlen, wo Sie Nachkommastellen brauchen? Ein klassischer Fall: sum(w) == 1 mit Gleitkommazahlen — nutzen Sie eine Toleranz oder rechnen Sie in Ganzzahlen (Promille).

          Wenn die fünf Schritte nicht reichen: den Konflikt einkreisen

          Warum Schritt 2 so oft ins Leere läuft. Bedingungen einzeln abzuschalten funktioniert nur, solange es einen Schuldigen gibt. Überlagern sich zwei unabhängige Widersprüche, bleibt das Modell nach jeder einzelnen Abschaltung unlösbar — die Suche endet mit null Treffern, obwohl beide Widersprüche unverändert im Modell stehen. Genau dann steht man vor einem Modell mit tausenden Restriktionen und einem Statuscode.

          @@ -30708,231 +31049,231 @@ T \ge \left(\frac{2\sigma}{\mu}\right)^2 = \left(\frac{2 \cdot 0{,}012}{0{,}0004

          Nimm eine Bedingung versuchsweise heraus. Bleibt der Rest unlösbar, wurde sie nicht gebraucht: weg damit, endgültig. Wird der Rest lösbar, war sie beteiligt: sie bleibt.

    Nach genau einem Durchlauf über alle n Bedingungen ist die übrig gebliebene Menge unreduzierbar — entfernt man aus ihr irgendeine Bedingung, ist der Rest lösbar. Das ist ein Irreducible Infeasible Subset, kurz IIS. Kommerzielle Solver bieten das als fertige Funktion an (computeIIS bei Gurobi, der Conflict Refiner bei CPLEX); im Open-Source-Werkzeugkasten dieses Buchs gibt es sie nicht — sie ist aber in zwanzig Zeilen selbst geschrieben, und sie kostet n Aufrufe des Solvers statt 2^n durchprobierter Teilmengen.

    -
    #!/usr/bin/env python3
    -
    -# Konfliktsuche.py
    -"""
    -Anhang Fehlerdiagnose: Den Konflikt finden, der INFEASIBLE verursacht.
    -
    -Ein Deletion Filter (auch: IIS, Irreducible Infeasible Subset) isoliert aus
    -einem unloesbaren Modell eine kleinste widerspruechliche Teilmenge von
    -Bedingungen: Nimmt man aus ihr auch nur eine einzige Bedingung heraus, ist
    -der Rest wieder loesbar.
    -
    -Das Programm zeigt sechs Dinge:
    -  1. Der Solver meldet INFEASIBLE - und sonst nichts.
    -  2. Die naive Suche ("jede Bedingung einmal weglassen") findet hier gar nichts.
    -  3. Der Deletion Filter findet einen Konflikt in n Solveraufrufen.
    -  4. Ein Konflikt ist selten einer: nach der Reparatur folgt der naechste.
    -  5. "Den" kleinsten Konflikt gibt es nicht - dieses Modell enthaelt neun.
    -  6. Welchen davon man zu sehen bekommt, steuert die Pruefreihenfolge.
    -
    -Abgrenzung zu Infeasibility_Diagnose.py (Kapitel Praxisfallen): Dort geht es
    -um die Relaxation - das Modell soll trotz Widerspruch eine brauchbare Antwort
    -liefern. Hier geht es um die Diagnose - welche Bedingungen widersprechen sich
    -ueberhaupt. Beides zusammen ergibt den Umgang mit INFEASIBLE in Produktion.
    -
    -Benoetigt: numpy, scipy
    -"""
    -
    -from __future__ import annotations
    -
    -import numpy as np
    -from scipy.optimize import linprog
    -
    -PRODUKTE = ["Rahmen", "Gehaeuse", "Deckel", "Traeger", "Halter"]
    -
    -# Jede Bedingung traegt einen sprechenden Namen - das ist keine Kosmetik,
    -# sondern die Voraussetzung dafuer, dass der Befund lesbar wird.
    -# (Name, Koeffizienten je Produkt, Richtung, rechte Seite)
    -BEDINGUNGEN: list[tuple[str, list[float], str, float]] = [
    -    ("Kapazitaet Montage",       [3, 2, 1, 4, 2],      "<=",   400),
    -    ("Kapazitaet Lackieren",     [2, 3, 0, 1, 0],      "<=",   150),
    -    ("Kapazitaet Pruefung",      [1, 1, 1, 1, 1],      "<=",   300),
    -    ("Liefervertrag Rahmen",     [1, 0, 0, 0, 0],      ">=",    40),
    -    ("Liefervertrag Gehaeuse",   [0, 1, 0, 0, 0],      ">=",    30),
    -    ("Liefervertrag Deckel",     [0, 0, 1, 0, 0],      ">=",    20),
    -    ("Liefervertrag Traeger",    [0, 0, 0, 1, 0],      ">=",    25),
    -    ("Marktgrenze Rahmen",       [1, 0, 0, 0, 0],      "<=",   120),
    -    ("Marktgrenze Gehaeuse",     [0, 1, 0, 0, 0],      "<=",    90),
    -    ("Marktgrenze Deckel",       [0, 0, 1, 0, 0],      "<=",   150),
    -    ("Marktgrenze Halter",       [0, 0, 0, 0, 1],      "<=",   200),
    -    ("Mindestumsatz",            [90, 70, 30, 60, 20], ">=", 12000),
    -    ("Sortimentsbreite",         [0, 0, 1, 1, 1],      ">=",   100),
    -    ("Lackierbudget Schicht 2",  [0, 1, 0, 1, 0],      "<=",    55),
    -]
    -
    -# Welche Bedingungen koennte ein Planer im Ernstfall wirklich veraendern?
    -# Kapazitaeten lassen sich durch Sonderschichten dehnen, Liefervertraege und
    -# Marktgrenzen nicht.
    -VERHANDELBAR = {"Kapazitaet Montage", "Kapazitaet Lackieren",
    -                "Kapazitaet Pruefung", "Lackierbudget Schicht 2"}
    -
    -aufrufe = 0          # zaehlt jeden Solveraufruf mit - der Preis des Verfahrens
    -
    -
    -def ist_loesbar(auswahl: list[int]) -> bool:
    -    """Gibt es einen Punkt, der ALLE Bedingungen aus 'auswahl' erfuellt?
    -
    -    Es wird nur Zulaessigkeit geprueft, keine Zielfunktion optimiert: die
    -    Zielfunktion ist konstant null. INFEASIBLE haengt nie an der Zielfunktion.
    -    """
    -    global aufrufe
    -    aufrufe += 1
    -    matrix, rechte_seite = [], []
    -    for i in auswahl:
    -        _, koeffizienten, richtung, grenze = BEDINGUNGEN[i]
    -        zeile = np.array(koeffizienten, dtype=float)
    -        # linprog kennt nur "<=", also ">=" durch Negation umdrehen
    -        matrix.append(zeile if richtung == "<=" else -zeile)
    -        rechte_seite.append(grenze if richtung == "<=" else -grenze)
    -    ergebnis = linprog(np.zeros(len(PRODUKTE)),
    -                       A_ub=np.array(matrix), b_ub=np.array(rechte_seite),
    -                       bounds=(0, None), method="highs")
    -    return ergebnis.status == 0
    -
    -
    -def deletion_filter(auswahl: list[int]) -> list[int]:
    -    """Verkleinert eine unloesbare Menge zu einer kleinsten unloesbaren Menge.
    -
    -    Der Kern des Verfahrens ist eine einzige Regel: Nimm eine Bedingung
    -    versuchsweise heraus. Bleibt der Rest unloesbar, wurde sie fuer den
    -    Widerspruch nicht gebraucht - sie darf endgueltig weg. Wird der Rest
    -    loesbar, war sie beteiligt und muss bleiben.
    -
    -    Nach genau einem Durchlauf ueber alle Bedingungen ist das Ergebnis
    -    unreduzierbar: n Solveraufrufe statt 2^n Teilmengen.
    -    """
    -    rest = list(auswahl)
    -    for i in list(auswahl):
    -        probe = [j for j in rest if j != i]
    -        if not ist_loesbar(probe):
    -            rest = probe
    -    return rest
    -
    -
    -def namen(auswahl: list[int]) -> list[str]:
    -    return [BEDINGUNGEN[i][0] for i in auswahl]
    -
    -
    -def teil_1_der_befund(alle: list[int]) -> None:
    -    print("=" * 68)
    -    print("1. Was der Solver sagt")
    -    print("=" * 68)
    -    print(f"Modell: {len(PRODUKTE)} Variablen, {len(BEDINGUNGEN)} Bedingungen")
    -    status = "OPTIMAL" if ist_loesbar(alle) else "INFEASIBLE"
    -    print(f"Status: {status}")
    -    print("\nMehr ist es nicht. Der Solver nennt keine Ursache, weil es die eine")
    -    print("Ursache nicht gibt: Unloesbarkeit ist eine Eigenschaft von Mengen von")
    -    print("Bedingungen, nicht von einzelnen Bedingungen.")
    -
    -
    -def teil_2_die_naive_suche(alle: list[int]) -> None:
    -    print("\n" + "=" * 68)
    -    print("2. Die naheliegende Idee - und warum sie hier scheitert")
    -    print("=" * 68)
    -    print("Jede Bedingung einmal weglassen und schauen, ob es dann geht:\n")
    -    treffer = [i for i in alle if ist_loesbar([j for j in alle if j != i])]
    -    for i in alle:
    -        print(f"   ohne {BEDINGUNGEN[i][0]:<26} {'loesbar' if i in treffer else 'weiter unloesbar'}")
    -    print(f"\nGefundene Schuldige: {len(treffer)}")
    -    print("Keine einzelne Bedingung ist schuld. Genau das ist der Normalfall -")
    -    print("und der Grund, warum diese Suche in der Praxis so oft im Nichts endet.")
    -
    -
    -def teil_3_der_filter(alle: list[int]) -> list[int]:
    -    global aufrufe
    -    print("\n" + "=" * 68)
    -    print("3. Der Deletion Filter")
    -    print("=" * 68)
    -    vorher = aufrufe
    -    konflikt = deletion_filter(alle)
    -    kosten = aufrufe - vorher
    -    print(f"{kosten} Solveraufrufe -> Konflikt aus {len(konflikt)} von "
    -          f"{len(BEDINGUNGEN)} Bedingungen:\n")
    -    for name in namen(konflikt):
    -        print(f"   * {name}")
    -    print("\nProbe auf Unreduzierbarkeit - jede einzelne davon weglassen:")
    -    for i in konflikt:
    -        rest_loesbar = ist_loesbar([j for j in konflikt if j != i])
    -        print(f"   ohne {BEDINGUNGEN[i][0]:<26} {'loesbar' if rest_loesbar else 'UNLOESBAR - nicht minimal!'}")
    -    print(f"\nAufwand: {kosten} Aufrufe. Alle Teilmengen durchzuprobieren waeren "
    -          f"2^{len(BEDINGUNGEN)} = {2 ** len(BEDINGUNGEN):,} gewesen.".replace(",", "."))
    -    return konflikt
    -
    -
    -def teil_4_der_naechste_konflikt(alle: list[int], konflikt: list[int]) -> None:
    -    print("\n" + "=" * 68)
    -    print("4. Ein Konflikt ist selten einer")
    -    print("=" * 68)
    -    entfernt = konflikt[0]
    -    rest = [i for i in alle if i != entfernt]
    -    print(f"Angenommen, '{BEDINGUNGEN[entfernt][0]}' laesst sich verhandeln")
    -    print("und wird aus dem Modell genommen. Dann ist das Modell ...")
    -    if ist_loesbar(rest):
    -        print("... loesbar. Fertig.")
    -        return
    -    print("... immer noch unloesbar. Der Filter erneut:\n")
    -    zweiter = deletion_filter(rest)
    -    for name in namen(zweiter):
    -        print(f"   * {name}")
    -    gemeinsam = set(zweiter) & set(konflikt)
    -    print(f"\nUeberschneidung mit dem ersten Konflikt: {len(gemeinsam)} Bedingungen")
    -    print("Ein zweiter, unabhaengiger Widerspruch, den der erste verdeckt hat.")
    -    print("Deshalb ist die Konfliktsuche eine Schleife, kein einzelner Aufruf:")
    -    print("reparieren, neu suchen, bis das Modell loesbar ist.")
    -
    -
    -def teil_5_die_reihenfolge(alle: list[int]) -> None:
    -    print("\n" + "=" * 68)
    -    print("5. Es gibt nicht DEN Konflikt")
    -    print("=" * 68)
    -    print("Derselbe Filter, nur eine andere Pruefreihenfolge - 200-mal gewuerfelt:\n")
    -    zufall = np.random.default_rng(0)
    -    haeufigkeit: dict[tuple[int, ...], int] = {}
    -    for _ in range(200):
    -        ordnung = [int(i) for i in zufall.permutation(alle)]
    -        schluessel = tuple(sorted(deletion_filter(ordnung)))
    -        haeufigkeit[schluessel] = haeufigkeit.get(schluessel, 0) + 1
    -    for schluessel, anzahl in sorted(haeufigkeit.items(), key=lambda p: -p[1]):
    -        print(f"   {anzahl:3d}x  {len(schluessel)} Bedingungen: "
    -              f"{', '.join(namen(list(schluessel)))}")
    -    print(f"\n{len(haeufigkeit)} verschiedene minimale Konflikte in EINEM Modell.")
    -    print("Der Filter liefert *einen* kleinsten Konflikt, nicht *den* kleinsten -")
    -    print("den gibt es nicht. Alle oben sind gleichermassen korrekt.")
    -
    -
    -def teil_6_den_befund_steuern(alle: list[int]) -> None:
    -    print("\n" + "=" * 68)
    -    print("6. Den Befund brauchbar machen")
    -    print("=" * 68)
    -    print("Der Filter wirft heraus, was er zuerst in die Hand bekommt. Wer die")
    -    print("unveraenderlichen Bedingungen zuerst pruefen laesst, bekommt sie eher")
    -    print("aus dem Befund heraus - und sieht dafuer mehr Stellschrauben.\n")
    -    fest = [i for i in alle if BEDINGUNGEN[i][0] not in VERHANDELBAR]
    -    frei = [i for i in alle if BEDINGUNGEN[i][0] in VERHANDELBAR]
    -    for titel, ordnung in (("unveraenderliche zuerst", fest + frei),
    -                           ("veraenderliche zuerst  ", frei + fest)):
    -        konflikt = sorted(deletion_filter(ordnung))
    -        stellschrauben = [i for i in konflikt if BEDINGUNGEN[i][0] in VERHANDELBAR]
    -        print(f"   {titel} -> {len(konflikt)} Bedingungen, davon "
    -              f"{len(stellschrauben)} veraenderlich")
    -        print(f"      {', '.join(namen(konflikt))}")
    -    print("\nBeide Befunde sind wahr. Nur einer davon nennt dem Planer etwas,")
    -    print("das er tatsaechlich tun kann.")
    -
    -
    -if __name__ == "__main__":
    -    alle = list(range(len(BEDINGUNGEN)))
    -    teil_1_der_befund(alle)
    -    teil_2_die_naive_suche(alle)
    -    konflikt = teil_3_der_filter(alle)
    -    teil_4_der_naechste_konflikt(alle, konflikt)
    -    teil_5_die_reihenfolge(alle)
    -    teil_6_den_befund_steuern(alle)
    -    print("\n" + "=" * 68)
    -    print(f"Insgesamt {aufrufe} Solveraufrufe fuer die gesamte Diagnose.")
    -    print("=" * 68)
    +
    #!/usr/bin/env python3
    +
    +# Konfliktsuche.py
    +"""
    +Anhang Fehlerdiagnose: Den Konflikt finden, der INFEASIBLE verursacht.
    +
    +Ein Deletion Filter (auch: IIS, Irreducible Infeasible Subset) isoliert aus
    +einem unloesbaren Modell eine kleinste widerspruechliche Teilmenge von
    +Bedingungen: Nimmt man aus ihr auch nur eine einzige Bedingung heraus, ist
    +der Rest wieder loesbar.
    +
    +Das Programm zeigt sechs Dinge:
    +  1. Der Solver meldet INFEASIBLE - und sonst nichts.
    +  2. Die naive Suche ("jede Bedingung einmal weglassen") findet hier gar nichts.
    +  3. Der Deletion Filter findet einen Konflikt in n Solveraufrufen.
    +  4. Ein Konflikt ist selten einer: nach der Reparatur folgt der naechste.
    +  5. "Den" kleinsten Konflikt gibt es nicht - dieses Modell enthaelt neun.
    +  6. Welchen davon man zu sehen bekommt, steuert die Pruefreihenfolge.
    +
    +Abgrenzung zu Infeasibility_Diagnose.py (Kapitel Praxisfallen): Dort geht es
    +um die Relaxation - das Modell soll trotz Widerspruch eine brauchbare Antwort
    +liefern. Hier geht es um die Diagnose - welche Bedingungen widersprechen sich
    +ueberhaupt. Beides zusammen ergibt den Umgang mit INFEASIBLE in Produktion.
    +
    +Benoetigt: numpy, scipy
    +"""
    +
    +from __future__ import annotations
    +
    +import numpy as np
    +from scipy.optimize import linprog
    +
    +PRODUKTE = ["Rahmen", "Gehaeuse", "Deckel", "Traeger", "Halter"]
    +
    +# Jede Bedingung traegt einen sprechenden Namen - das ist keine Kosmetik,
    +# sondern die Voraussetzung dafuer, dass der Befund lesbar wird.
    +# (Name, Koeffizienten je Produkt, Richtung, rechte Seite)
    +BEDINGUNGEN: list[tuple[str, list[float], str, float]] = [
    +    ("Kapazitaet Montage",       [3, 2, 1, 4, 2],      "<=",   400),
    +    ("Kapazitaet Lackieren",     [2, 3, 0, 1, 0],      "<=",   150),
    +    ("Kapazitaet Pruefung",      [1, 1, 1, 1, 1],      "<=",   300),
    +    ("Liefervertrag Rahmen",     [1, 0, 0, 0, 0],      ">=",    40),
    +    ("Liefervertrag Gehaeuse",   [0, 1, 0, 0, 0],      ">=",    30),
    +    ("Liefervertrag Deckel",     [0, 0, 1, 0, 0],      ">=",    20),
    +    ("Liefervertrag Traeger",    [0, 0, 0, 1, 0],      ">=",    25),
    +    ("Marktgrenze Rahmen",       [1, 0, 0, 0, 0],      "<=",   120),
    +    ("Marktgrenze Gehaeuse",     [0, 1, 0, 0, 0],      "<=",    90),
    +    ("Marktgrenze Deckel",       [0, 0, 1, 0, 0],      "<=",   150),
    +    ("Marktgrenze Halter",       [0, 0, 0, 0, 1],      "<=",   200),
    +    ("Mindestumsatz",            [90, 70, 30, 60, 20], ">=", 12000),
    +    ("Sortimentsbreite",         [0, 0, 1, 1, 1],      ">=",   100),
    +    ("Lackierbudget Schicht 2",  [0, 1, 0, 1, 0],      "<=",    55),
    +]
    +
    +# Welche Bedingungen koennte ein Planer im Ernstfall wirklich veraendern?
    +# Kapazitaeten lassen sich durch Sonderschichten dehnen, Liefervertraege und
    +# Marktgrenzen nicht.
    +VERHANDELBAR = {"Kapazitaet Montage", "Kapazitaet Lackieren",
    +                "Kapazitaet Pruefung", "Lackierbudget Schicht 2"}
    +
    +aufrufe = 0          # zaehlt jeden Solveraufruf mit - der Preis des Verfahrens
    +
    +
    +def ist_loesbar(auswahl: list[int]) -> bool:
    +    """Gibt es einen Punkt, der ALLE Bedingungen aus 'auswahl' erfuellt?
    +
    +    Es wird nur Zulaessigkeit geprueft, keine Zielfunktion optimiert: die
    +    Zielfunktion ist konstant null. INFEASIBLE haengt nie an der Zielfunktion.
    +    """
    +    global aufrufe
    +    aufrufe += 1
    +    matrix, rechte_seite = [], []
    +    for i in auswahl:
    +        _, koeffizienten, richtung, grenze = BEDINGUNGEN[i]
    +        zeile = np.array(koeffizienten, dtype=float)
    +        # linprog kennt nur "<=", also ">=" durch Negation umdrehen
    +        matrix.append(zeile if richtung == "<=" else -zeile)
    +        rechte_seite.append(grenze if richtung == "<=" else -grenze)
    +    ergebnis = linprog(np.zeros(len(PRODUKTE)),
    +                       A_ub=np.array(matrix), b_ub=np.array(rechte_seite),
    +                       bounds=(0, None), method="highs")
    +    return ergebnis.status == 0
    +
    +
    +def deletion_filter(auswahl: list[int]) -> list[int]:
    +    """Verkleinert eine unloesbare Menge zu einer kleinsten unloesbaren Menge.
    +
    +    Der Kern des Verfahrens ist eine einzige Regel: Nimm eine Bedingung
    +    versuchsweise heraus. Bleibt der Rest unloesbar, wurde sie fuer den
    +    Widerspruch nicht gebraucht - sie darf endgueltig weg. Wird der Rest
    +    loesbar, war sie beteiligt und muss bleiben.
    +
    +    Nach genau einem Durchlauf ueber alle Bedingungen ist das Ergebnis
    +    unreduzierbar: n Solveraufrufe statt 2^n Teilmengen.
    +    """
    +    rest = list(auswahl)
    +    for i in list(auswahl):
    +        probe = [j for j in rest if j != i]
    +        if not ist_loesbar(probe):
    +            rest = probe
    +    return rest
    +
    +
    +def namen(auswahl: list[int]) -> list[str]:
    +    return [BEDINGUNGEN[i][0] for i in auswahl]
    +
    +
    +def teil_1_der_befund(alle: list[int]) -> None:
    +    print("=" * 68)
    +    print("1. Was der Solver sagt")
    +    print("=" * 68)
    +    print(f"Modell: {len(PRODUKTE)} Variablen, {len(BEDINGUNGEN)} Bedingungen")
    +    status = "OPTIMAL" if ist_loesbar(alle) else "INFEASIBLE"
    +    print(f"Status: {status}")
    +    print("\nMehr ist es nicht. Der Solver nennt keine Ursache, weil es die eine")
    +    print("Ursache nicht gibt: Unloesbarkeit ist eine Eigenschaft von Mengen von")
    +    print("Bedingungen, nicht von einzelnen Bedingungen.")
    +
    +
    +def teil_2_die_naive_suche(alle: list[int]) -> None:
    +    print("\n" + "=" * 68)
    +    print("2. Die naheliegende Idee - und warum sie hier scheitert")
    +    print("=" * 68)
    +    print("Jede Bedingung einmal weglassen und schauen, ob es dann geht:\n")
    +    treffer = [i for i in alle if ist_loesbar([j for j in alle if j != i])]
    +    for i in alle:
    +        print(f"   ohne {BEDINGUNGEN[i][0]:<26} {'loesbar' if i in treffer else 'weiter unloesbar'}")
    +    print(f"\nGefundene Schuldige: {len(treffer)}")
    +    print("Keine einzelne Bedingung ist schuld. Genau das ist der Normalfall -")
    +    print("und der Grund, warum diese Suche in der Praxis so oft im Nichts endet.")
    +
    +
    +def teil_3_der_filter(alle: list[int]) -> list[int]:
    +    global aufrufe
    +    print("\n" + "=" * 68)
    +    print("3. Der Deletion Filter")
    +    print("=" * 68)
    +    vorher = aufrufe
    +    konflikt = deletion_filter(alle)
    +    kosten = aufrufe - vorher
    +    print(f"{kosten} Solveraufrufe -> Konflikt aus {len(konflikt)} von "
    +          f"{len(BEDINGUNGEN)} Bedingungen:\n")
    +    for name in namen(konflikt):
    +        print(f"   * {name}")
    +    print("\nProbe auf Unreduzierbarkeit - jede einzelne davon weglassen:")
    +    for i in konflikt:
    +        rest_loesbar = ist_loesbar([j for j in konflikt if j != i])
    +        print(f"   ohne {BEDINGUNGEN[i][0]:<26} {'loesbar' if rest_loesbar else 'UNLOESBAR - nicht minimal!'}")
    +    print(f"\nAufwand: {kosten} Aufrufe. Alle Teilmengen durchzuprobieren waeren "
    +          f"2^{len(BEDINGUNGEN)} = {2 ** len(BEDINGUNGEN):,} gewesen.".replace(",", "."))
    +    return konflikt
    +
    +
    +def teil_4_der_naechste_konflikt(alle: list[int], konflikt: list[int]) -> None:
    +    print("\n" + "=" * 68)
    +    print("4. Ein Konflikt ist selten einer")
    +    print("=" * 68)
    +    entfernt = konflikt[0]
    +    rest = [i for i in alle if i != entfernt]
    +    print(f"Angenommen, '{BEDINGUNGEN[entfernt][0]}' laesst sich verhandeln")
    +    print("und wird aus dem Modell genommen. Dann ist das Modell ...")
    +    if ist_loesbar(rest):
    +        print("... loesbar. Fertig.")
    +        return
    +    print("... immer noch unloesbar. Der Filter erneut:\n")
    +    zweiter = deletion_filter(rest)
    +    for name in namen(zweiter):
    +        print(f"   * {name}")
    +    gemeinsam = set(zweiter) & set(konflikt)
    +    print(f"\nUeberschneidung mit dem ersten Konflikt: {len(gemeinsam)} Bedingungen")
    +    print("Ein zweiter, unabhaengiger Widerspruch, den der erste verdeckt hat.")
    +    print("Deshalb ist die Konfliktsuche eine Schleife, kein einzelner Aufruf:")
    +    print("reparieren, neu suchen, bis das Modell loesbar ist.")
    +
    +
    +def teil_5_die_reihenfolge(alle: list[int]) -> None:
    +    print("\n" + "=" * 68)
    +    print("5. Es gibt nicht DEN Konflikt")
    +    print("=" * 68)
    +    print("Derselbe Filter, nur eine andere Pruefreihenfolge - 200-mal gewuerfelt:\n")
    +    zufall = np.random.default_rng(0)
    +    haeufigkeit: dict[tuple[int, ...], int] = {}
    +    for _ in range(200):
    +        ordnung = [int(i) for i in zufall.permutation(alle)]
    +        schluessel = tuple(sorted(deletion_filter(ordnung)))
    +        haeufigkeit[schluessel] = haeufigkeit.get(schluessel, 0) + 1
    +    for schluessel, anzahl in sorted(haeufigkeit.items(), key=lambda p: -p[1]):
    +        print(f"   {anzahl:3d}x  {len(schluessel)} Bedingungen: "
    +              f"{', '.join(namen(list(schluessel)))}")
    +    print(f"\n{len(haeufigkeit)} verschiedene minimale Konflikte in EINEM Modell.")
    +    print("Der Filter liefert *einen* kleinsten Konflikt, nicht *den* kleinsten -")
    +    print("den gibt es nicht. Alle oben sind gleichermassen korrekt.")
    +
    +
    +def teil_6_den_befund_steuern(alle: list[int]) -> None:
    +    print("\n" + "=" * 68)
    +    print("6. Den Befund brauchbar machen")
    +    print("=" * 68)
    +    print("Der Filter wirft heraus, was er zuerst in die Hand bekommt. Wer die")
    +    print("unveraenderlichen Bedingungen zuerst pruefen laesst, bekommt sie eher")
    +    print("aus dem Befund heraus - und sieht dafuer mehr Stellschrauben.\n")
    +    fest = [i for i in alle if BEDINGUNGEN[i][0] not in VERHANDELBAR]
    +    frei = [i for i in alle if BEDINGUNGEN[i][0] in VERHANDELBAR]
    +    for titel, ordnung in (("unveraenderliche zuerst", fest + frei),
    +                           ("veraenderliche zuerst  ", frei + fest)):
    +        konflikt = sorted(deletion_filter(ordnung))
    +        stellschrauben = [i for i in konflikt if BEDINGUNGEN[i][0] in VERHANDELBAR]
    +        print(f"   {titel} -> {len(konflikt)} Bedingungen, davon "
    +              f"{len(stellschrauben)} veraenderlich")
    +        print(f"      {', '.join(namen(konflikt))}")
    +    print("\nBeide Befunde sind wahr. Nur einer davon nennt dem Planer etwas,")
    +    print("das er tatsaechlich tun kann.")
    +
    +
    +if __name__ == "__main__":
    +    alle = list(range(len(BEDINGUNGEN)))
    +    teil_1_der_befund(alle)
    +    teil_2_die_naive_suche(alle)
    +    konflikt = teil_3_der_filter(alle)
    +    teil_4_der_naechste_konflikt(alle, konflikt)
    +    teil_5_die_reihenfolge(alle)
    +    teil_6_den_befund_steuern(alle)
    +    print("\n" + "=" * 68)
    +    print(f"Insgesamt {aufrufe} Solveraufrufe fuer die gesamte Diagnose.")
    +    print("=" * 68)

    Die Ausgabe:

    ====================================================================
     1. Was der Solver sagt
    @@ -31062,31 +31403,31 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.
     
  • Vorzeichenfehler. Sie minimieren, wo Sie maximieren wollten (oder umgekehrt), und der Zielwert läuft in die falsche Richtung davon.
  • Freie Variablen ohne Nichtnegativität. bounds=[(None, None)] statt [(0, None)].
  • -
    # Diagnose: kuenstliche Schranke einziehen und sehen, wohin es laeuft
    -bounds = [(0, 1e6)] * n
    -res = linprog(...)
    -gross = [j for j in range(n) if res.x[j] > 1e5]
    -print(f"Diese Variablen laufen an die Kunstschranke: {gross}")
    +
    # Diagnose: kuenstliche Schranke einziehen und sehen, wohin es laeuft
    +bounds = [(0, 1e6)] * n
    +res = linprog(...)
    +gross = [j for j in range(n) if res.x[j] > 1e5]
    +print(f"Diese Variablen laufen an die Kunstschranke: {gross}")

    Die so gefundenen Variablen sind die, denen eine echte Beschränkung fehlt.


    C3 — Zu langsam

    Diagnosereihenfolge

    1. Ist es überhaupt das Lösen?

    -
    import time
    -t0 = time.perf_counter(); modell_bauen(); t1 = time.perf_counter()
    -loesen();                                  t2 = time.perf_counter()
    -print(f"Aufbau {t1-t0:.2f}s | Loesen {t2-t1:.2f}s")
    +
    import time
    +t0 = time.perf_counter(); modell_bauen(); t1 = time.perf_counter()
    +loesen();                                  t2 = time.perf_counter()
    +print(f"Aufbau {t1-t0:.2f}s | Loesen {t2-t1:.2f}s")

    Bei CVXPY und bei Schleifen über Szenarien ist oft der Aufbau der Engpass, nicht der Solver (siehe Abschnitt 20.6).

    2. Big-M zu groß? Der häufigste Grund für explodierende MILP-Laufzeiten. Setzen Sie M auf die kleinste gültige Schranke (Abschnitt 6.5).

    3. Symmetrie im Modell? Identische Maschinen, austauschbare Mitarbeitende, gleichwertige Fahrzeuge — der Solver durchsucht alle Vertauschungen. Abhilfe: Ordnungsbedingungen (Muster B20).

    4. Falsche Solverfamilie? Zuordnungsprobleme mit MILP, Routing mit CP-SAT von Hand nachgebaut, konvexe Probleme mit scipy.optimize.minimize — jeweils Größenordnungen langsamer als das passende Werkzeug (Abschnitt 3.6).

    5. Wird vektorisiert?

    -
    # langsam:                       # schnell:
    -for s in range(S):               constraints.append(u >= -(R @ w) - gamma)
    -    constraints.append(u[s] >= -R[s] @ w - gamma)
    +
    # langsam:                       # schnell:
    +for s in range(S):               constraints.append(u >= -(R @ w) - gamma)
    +    constraints.append(u[s] >= -R[s] @ w - gamma)

    6. Gap akzeptieren.

    -
    loeser.parameters.relative_gap_limit = 0.02     # CP-SAT
    -loeser.parameters.max_time_in_seconds = 30.0
    +
    loeser.parameters.relative_gap_limit = 0.02     # CP-SAT
    +loeser.parameters.max_time_in_seconds = 30.0

    Bei ±10 % Datenunsicherheit sind die letzten 2 % Optimalität verlorene Zeit.


    C4 — Unsinniges Ergebnis

    @@ -31100,12 +31441,12 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.
  • Die wirksamste Gegenmaßnahme

    -
    # Nach JEDEM Lösen: alle Regeln explizit nachprüfen
    -assert abs(w.sum() - 1) < 1e-6,       "Budget nicht eingehalten"
    -assert w.min() > -1e-9,               "negatives Gewicht"
    -assert w.max() <= grenze + 1e-9,      "Positionsgrenze verletzt"
    -for sektor, idx in sektoren.items():
    -    assert w[idx].sum() <= limit[sektor] + 1e-9, f"Sektor {sektor} verletzt"
    +
    # Nach JEDEM Lösen: alle Regeln explizit nachprüfen
    +assert abs(w.sum() - 1) < 1e-6,       "Budget nicht eingehalten"
    +assert w.min() > -1e-9,               "negatives Gewicht"
    +assert w.max() <= grenze + 1e-9,      "Positionsgrenze verletzt"
    +for sektor, idx in sektoren.items():
    +    assert w[idx].sum() <= limit[sektor] + 1e-9, f"Sektor {sektor} verletzt"

    Diese Zeilen kosten Sekunden und fangen die Fehlerklasse ab, die der Solver nicht melden kann: dass Ihr Modell etwas anderes beschreibt, als Sie glauben.


    C5 — Instabile Lösung

    @@ -31147,37 +31488,37 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose. -
    eig = np.linalg.eigvalsh(Sigma)
    -print(f"Kondition {eig.max()/eig.min():.0f} | kleinster EW {eig.min():.2e}")
    -# Faustregel: Kondition > 1000 ist bedenklich, > 10000 kritisch
    +
    eig = np.linalg.eigvalsh(Sigma)
    +print(f"Kondition {eig.max()/eig.min():.0f} | kleinster EW {eig.min():.2e}")
    +# Faustregel: Kondition > 1000 ist bedenklich, > 10000 kritisch

    Derselbe Fehler außerhalb der Finanzwelt

    Instabilität ist kein Portfolioproblem, sondern ein Skalierungsproblem — und es trifft jedes Modell, in dem Größen sehr verschiedener Ordnung nebeneinanderstehen. Euro-Beträge (10^7) und Tonnen-Angaben (10^{-3}) in derselben Matrix erzeugen eine Konditionszahl, die aus einer Datenunsicherheit von 0,1 % eine Lösungsänderung von 100 % machen kann. Die Konditionszahl ist genau die Obergrenze dieses Verstärkungsfaktors.

    -
    print(f"Kondition der Restriktionsmatrix: {np.linalg.cond(A):.1e}")
    -# Grobe Peilung: Wertebereiche der Koeffizienten anschauen
    -print(f"kleinster / groesster Betrag: {np.abs(A[A != 0]).min():.1e} "
    -      f"/ {np.abs(A).max():.1e}")
    +
    print(f"Kondition der Restriktionsmatrix: {np.linalg.cond(A):.1e}")
    +# Grobe Peilung: Wertebereiche der Koeffizienten anschauen
    +print(f"kleinster / groesster Betrag: {np.abs(A[A != 0]).min():.1e} "
    +      f"/ {np.abs(A).max():.1e}")

    Klaffen die Beträge um mehr als drei, vier Größenordnungen auseinander, rechnen Sie in anderen Einheiten (Tausend Euro statt Euro, Kilogramm statt Tonnen) oder skalieren Sie Zeilen und Spalten automatisch. Skalierung_Kondition.py zeigt beides samt Ruiz-Equilibrierung (Abschnitt 2.7).


    C6 — Falsche Dualwerte

    Symptom. Schattenpreise sind negativ, wo sie positiv sein sollten — oder null bei einer offensichtlich knappen Ressource.

    Die drei Prüfungen

    1. Vorzeichenkonvention. Haben Sie zur Maximierung negiert?

    -
    schattenpreise = -res.ineqlin.marginals    # bei linprog nach Negation
    +
    schattenpreise = -res.ineqlin.marginals    # bei linprog nach Negation

    Genau das ist die Vorzeichenfalle aus Abschnitt 5.7.

    2. Komplementärer Schlupf.

    -
    for s, y in zip(res.slack, schattenpreise):
    -    assert abs(s * y) < 1e-6, "Komplementaerer Schlupf verletzt!"
    +
    for s, y in zip(res.slack, schattenpreise):
    +    assert abs(s * y) < 1e-6, "Komplementaerer Schlupf verletzt!"

    Eine Ressource mit Reserve muss Schattenpreis 0 haben.

    3. Numerische Gegenprobe — der zuverlässigste Test.

    -
    b_plus = b.copy(); b_plus[i] += 1.0
    -zuwachs = loese(b_plus) - loese(b)
    -assert abs(zuwachs - schattenpreise[i]) < 1e-6
    +
    b_plus = b.copy(); b_plus[i] += 1.0
    +zuwachs = loese(b_plus) - loese(b)
    +assert abs(zuwachs - schattenpreise[i]) < 1e-6

    Dieser Test ist unabhängig von jeder Vorzeichenkonvention. Nutzen Sie ihn.

    4. Wenn alle drei Prüfungen bestehen und der Wert trotzdem seltsam ist: Entartung. Die drei Prüfungen oben setzen voraus, dass es einen richtigen Schattenpreis gibt. Bei einem entarteten Optimum — mehr aktive Nebenbedingungen als Variablen — gibt es den nicht. Der Wert ist dann nicht falsch, sondern mehrdeutig: zwei korrekte Solver liefern für dasselbe Optimum verschiedene Dualwerte, und die numerische Gegenprobe aus Schritt 3 schlägt fehl, weil der Zuwachs nach links und nach rechts unterschiedlich ausfällt.

    -
    aktiv = int(np.sum(res.slack < 1e-9))          # wie viele Bedingungen sind straff?
    -if aktiv > len(res.x):
    -    print(f"Entartet: {aktiv} aktive Bedingungen bei {len(res.x)} Variablen.")
    -    print("Einen einzelnen Schattenpreis zu berichten waere hier irrefuehrend.")
    +
    aktiv = int(np.sum(res.slack < 1e-9))          # wie viele Bedingungen sind straff?
    +if aktiv > len(res.x):
    +    print(f"Entartet: {aktiv} aktive Bedingungen bei {len(res.x)} Variablen.")
    +    print("Einen einzelnen Schattenpreis zu berichten waere hier irrefuehrend.")

    Die belastbare Antwort ist dann keine Zahl, sondern eine Spanne über alle optimalen Dualwerte — Toleranzen_und_Entartung.py rechnet sie vor (Abschnitt 5.9).


    C7 — Widersprüchliche Solver

    @@ -31233,10 +31574,10 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose. -
    print(problem.is_dcp())                      # False?
    -for c in problem.constraints:
    -    print(c.is_dcp(), c)                     # zeigt die Schuldige
    -print(problem.objective.is_dcp())
    +
    print(problem.is_dcp())                      # False?
    +for c in problem.constraints:
    +    print(c.is_dcp(), c)                     # zeigt die Schuldige
    +print(problem.objective.is_dcp())

    C9 — Importfehler

    ImportError: .../highspy/_core...so: undefined symbol: _ZN5Highs13releaseMemoryEv
    @@ -31247,10 +31588,10 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.

    Faustregel: Eine Sharpe Ratio über 2 bei einer einfachen Strategie ist fast immer ein Fehler, kein Fund.

    Prüfreihenfolge

    1. Lookahead-Selbsttest.

    -
    signal_vorher = berechne_signal(stichtag)
    -kurse.loc[kurse.index > stichtag] *= 3.0
    -signal_nachher = berechne_signal(stichtag)
    -assert np.allclose(signal_vorher, signal_nachher), "LOOKAHEAD-BIAS!"
    +
    signal_vorher = berechne_signal(stichtag)
    +kurse.loc[kurse.index > stichtag] *= 3.0
    +signal_nachher = berechne_signal(stichtag)
    +assert np.allclose(signal_vorher, signal_nachher), "LOOKAHEAD-BIAS!"

    2. Zeitindizes prüfen. iloc[t] gegen iloc[t+1] — die Gewichte von heute gehören auf die Rendite von morgen.

    3. Survivorship. Wurde das Universum nach Kriterien gefiltert, die erst am Ende bekannt waren (dropna() über den gesamten Zeitraum!)?

    4. Kosten. Sind Gebühren, Spread und Slippage verbucht?

    @@ -31260,15 +31601,15 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.

    C11 — Vertauschte Spalten

    Symptom. Ergebnisse sind plausibel, aber falsch beschriftet — oder eine Sektorgrenze greift auf die falschen Titel.

    Ursache. yfinance und viele andere Datenquellen liefern Spalten alphabetisch sortiert, nicht in der Reihenfolge Ihrer Anfrage.

    -
    # IMMER nach dem Import:
    -kurse = roh["Close"][tickers].dropna()          # erzwingt die Reihenfolge
    -assert list(kurse.columns) == tickers, (
    -    f"Reihenfolge weicht ab!\n erwartet: {tickers}\n erhalten: {list(kurse.columns)}")
    -
    -# Und Gruppen NIE ueber Positionsindizes definieren:
    -SEKTOREN = {"Technologie": ["AAPL", "MSFT", "NVDA", "AMZN"]}       # gut
    -tech_idx = [tickers.index(t) for t in SEKTOREN["Technologie"]]     # bricht bei Tippfehler
    -tech_indices = [0, 1, 2, 3]                                        # SCHLECHT
    +
    # IMMER nach dem Import:
    +kurse = roh["Close"][tickers].dropna()          # erzwingt die Reihenfolge
    +assert list(kurse.columns) == tickers, (
    +    f"Reihenfolge weicht ab!\n erwartet: {tickers}\n erhalten: {list(kurse.columns)}")
    +
    +# Und Gruppen NIE ueber Positionsindizes definieren:
    +SEKTOREN = {"Technologie": ["AAPL", "MSFT", "NVDA", "AMZN"]}       # gut
    +tech_idx = [tickers.index(t) for t in SEKTOREN["Technologie"]]     # bricht bei Tippfehler
    +tech_indices = [0, 1, 2, 3]                                        # SCHLECHT

    Die goldene Diagnoseregel

    @@ -31289,51 +31630,51 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.

    D1 — SciPy: linprog und milp

    Wofür. Die Einstiegsschicht: keine zusätzliche Installation, HiGHS als Unterbau, ideal für lineare und gemischt-ganzzahlige Probleme in Matrixform. Wofür nicht: alles, was sich nicht als Matrix schreiben lässt, und jede nichtlineare Zielfunktion.

    Lineares Programm

    -
    import numpy as np
    -from scipy.optimize import linprog
    -
    -# linprog MINIMIERT immer -> zum Maximieren die Zielfunktion negieren
    -ergebnis = linprog(
    -    c=[-10.0, -15.0, -25.0],                       # Zielkoeffizienten
    -    A_ub=[[1, 1, 2], [2, 3, 1]], b_ub=[40, 50],    # A_ub @ x <= b_ub
    -    A_eq=None, b_eq=None,                          # Gleichungen, falls vorhanden
    -    bounds=[(0, None)] * 3,                        # je Variable (unten, oben)
    -    method="highs")
    -
    -if ergebnis.status == 0:
    -    print(f"optimal: {-ergebnis.fun:.2f}")         # Vorzeichen zuruecknehmen!
    -    print(f"x = {np.round(ergebnis.x, 4)}")
    -    print(f"Schattenpreise: {-ergebnis.ineqlin.marginals}")
    -elif ergebnis.status == 2:
    -    print("INFEASIBLE - kein zulaessiger Punkt")
    -elif ergebnis.status == 3:
    -    print("UNBOUNDED - Zielfunktion unbeschraenkt")
    -elif ergebnis.status == 1:
    -    print("Iterations- oder Zeitlimit erreicht")
    -else:
    -    print(f"numerisches Problem (status {ergebnis.status}): {ergebnis.message}")
    +
    import numpy as np
    +from scipy.optimize import linprog
    +
    +# linprog MINIMIERT immer -> zum Maximieren die Zielfunktion negieren
    +ergebnis = linprog(
    +    c=[-10.0, -15.0, -25.0],                       # Zielkoeffizienten
    +    A_ub=[[1, 1, 2], [2, 3, 1]], b_ub=[40, 50],    # A_ub @ x <= b_ub
    +    A_eq=None, b_eq=None,                          # Gleichungen, falls vorhanden
    +    bounds=[(0, None)] * 3,                        # je Variable (unten, oben)
    +    method="highs")
    +
    +if ergebnis.status == 0:
    +    print(f"optimal: {-ergebnis.fun:.2f}")         # Vorzeichen zuruecknehmen!
    +    print(f"x = {np.round(ergebnis.x, 4)}")
    +    print(f"Schattenpreise: {-ergebnis.ineqlin.marginals}")
    +elif ergebnis.status == 2:
    +    print("INFEASIBLE - kein zulaessiger Punkt")
    +elif ergebnis.status == 3:
    +    print("UNBOUNDED - Zielfunktion unbeschraenkt")
    +elif ergebnis.status == 1:
    +    print("Iterations- oder Zeitlimit erreicht")
    +else:
    +    print(f"numerisches Problem (status {ergebnis.status}): {ergebnis.message}")

    Ganzzahlig: milp

    -
    import numpy as np
    -from scipy.optimize import milp, LinearConstraint, Bounds
    -
    -# Ganzzahlig: dasselbe Problem, aber x muss ganzzahlig sein
    -ergebnis = milp(
    -    c=[-10.0, -15.0, -25.0],                       # auch milp MINIMIERT
    -    constraints=LinearConstraint([[1, 1, 2], [2, 3, 1]], -np.inf, [40, 50]),
    -    integrality=[1, 1, 1],                         # 0 = stetig, 1 = ganzzahlig
    -    bounds=Bounds(0, np.inf))
    -
    -if ergebnis.status == 0:
    -    print(f"optimal: {-ergebnis.fun:.2f}  x = {np.round(ergebnis.x).astype(int)}")
    -    print(f"MIP-Gap: {ergebnis.mip_gap:.4f}")
    -elif ergebnis.status == 1:
    -    print("Zeitlimit - beste gefundene Loesung nutzen, Gap pruefen")
    -elif ergebnis.status == 2:
    -    print("INFEASIBLE")
    -elif ergebnis.status == 3:
    -    print("UNBOUNDED")
    -else:
    -    print(f"kein Ergebnis: {ergebnis.message}")
    +
    import numpy as np
    +from scipy.optimize import milp, LinearConstraint, Bounds
    +
    +# Ganzzahlig: dasselbe Problem, aber x muss ganzzahlig sein
    +ergebnis = milp(
    +    c=[-10.0, -15.0, -25.0],                       # auch milp MINIMIERT
    +    constraints=LinearConstraint([[1, 1, 2], [2, 3, 1]], -np.inf, [40, 50]),
    +    integrality=[1, 1, 1],                         # 0 = stetig, 1 = ganzzahlig
    +    bounds=Bounds(0, np.inf))
    +
    +if ergebnis.status == 0:
    +    print(f"optimal: {-ergebnis.fun:.2f}  x = {np.round(ergebnis.x).astype(int)}")
    +    print(f"MIP-Gap: {ergebnis.mip_gap:.4f}")
    +elif ergebnis.status == 1:
    +    print("Zeitlimit - beste gefundene Loesung nutzen, Gap pruefen")
    +elif ergebnis.status == 2:
    +    print("INFEASIBLE")
    +elif ergebnis.status == 3:
    +    print("UNBOUNDED")
    +else:
    +    print(f"kein Ergebnis: {ergebnis.message}")

    Die drei häufigsten Stolpersteine

    1. linprog minimiert immer. Zum Maximieren c negieren — und beim Ausgeben des Zielwerts das Vorzeichen wieder zurücknehmen. Dieselbe Negation dreht auch die Schattenpreise (Abschnitt 5.7).
    2. @@ -31343,41 +31684,41 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.

      D2 — HiGHS über highspy

      Wofür. Derselbe Solver wie unter SciPy, aber direkt gesteuert: Optionen, Warm-Starts, inkrementelles Ändern eines bestehenden Modells. Wofür nicht: schnelles Hinschreiben — die CSR-Matrixübergabe ist fehleranfällig.

      -
      import numpy as np
      -import highspy
      -
      -h = highspy.Highs()
      -h.setOptionValue("output_flag", False)             # Solverprotokoll abschalten
      -h.setOptionValue("time_limit", 60.0)
      -h.setOptionValue("mip_rel_gap", 0.01)              # 1 % Gap genuegt
      -
      -# Variablen: Anzahl, Untergrenzen, Obergrenzen
      -h.addVars(3, np.zeros(3), np.full(3, highspy.kHighsInf))
      -h.changeObjectiveSense(highspy.ObjSense.kMaximize)
      -for spalte, wert in enumerate([10.0, 15.0, 25.0]):
      -    h.changeColCost(spalte, wert)
      -
      -# Zeilen im CSR-Format: starts[i] = Beginn von Zeile i in indices/values
      -h.addRows(2, np.full(2, -highspy.kHighsInf), np.array([40.0, 50.0]), 6,
      -          np.array([0, 3], dtype=np.int32),                  # starts
      -          np.array([0, 1, 2, 0, 1, 2], dtype=np.int32),      # Spaltenindizes
      -          np.array([1.0, 1.0, 2.0, 2.0, 3.0, 1.0]))          # Koeffizienten
      -# Ganzzahligkeit: h.changeColsIntegrality(...) mit highspy.HighsVarType.kInteger
      -
      -h.run()
      -status = h.getModelStatus()
      -if status == highspy.HighsModelStatus.kOptimal:
      -    print(f"optimal: {h.getInfo().objective_function_value:.2f}")
      -    print(f"x = {np.round(h.getSolution().col_value[:3], 4)}")
      -    print(f"Schattenpreise: {np.round(h.getSolution().row_dual[:2], 4)}")
      -elif status == highspy.HighsModelStatus.kInfeasible:
      -    print("INFEASIBLE")
      -elif status == highspy.HighsModelStatus.kUnbounded:
      -    print("UNBOUNDED")
      -elif status == highspy.HighsModelStatus.kTimeLimit:
      -    print(f"Zeitlimit, Gap {h.getInfo().mip_gap:.3f}")
      -else:
      -    print("kein Optimum:", h.modelStatusToString(status))
      +
      import numpy as np
      +import highspy
      +
      +h = highspy.Highs()
      +h.setOptionValue("output_flag", False)             # Solverprotokoll abschalten
      +h.setOptionValue("time_limit", 60.0)
      +h.setOptionValue("mip_rel_gap", 0.01)              # 1 % Gap genuegt
      +
      +# Variablen: Anzahl, Untergrenzen, Obergrenzen
      +h.addVars(3, np.zeros(3), np.full(3, highspy.kHighsInf))
      +h.changeObjectiveSense(highspy.ObjSense.kMaximize)
      +for spalte, wert in enumerate([10.0, 15.0, 25.0]):
      +    h.changeColCost(spalte, wert)
      +
      +# Zeilen im CSR-Format: starts[i] = Beginn von Zeile i in indices/values
      +h.addRows(2, np.full(2, -highspy.kHighsInf), np.array([40.0, 50.0]), 6,
      +          np.array([0, 3], dtype=np.int32),                  # starts
      +          np.array([0, 1, 2, 0, 1, 2], dtype=np.int32),      # Spaltenindizes
      +          np.array([1.0, 1.0, 2.0, 2.0, 3.0, 1.0]))          # Koeffizienten
      +# Ganzzahligkeit: h.changeColsIntegrality(...) mit highspy.HighsVarType.kInteger
      +
      +h.run()
      +status = h.getModelStatus()
      +if status == highspy.HighsModelStatus.kOptimal:
      +    print(f"optimal: {h.getInfo().objective_function_value:.2f}")
      +    print(f"x = {np.round(h.getSolution().col_value[:3], 4)}")
      +    print(f"Schattenpreise: {np.round(h.getSolution().row_dual[:2], 4)}")
      +elif status == highspy.HighsModelStatus.kInfeasible:
      +    print("INFEASIBLE")
      +elif status == highspy.HighsModelStatus.kUnbounded:
      +    print("UNBOUNDED")
      +elif status == highspy.HighsModelStatus.kTimeLimit:
      +    print(f"Zeitlimit, Gap {h.getInfo().mip_gap:.3f}")
      +else:
      +    print("kein Optimum:", h.modelStatusToString(status))

      Die drei häufigsten Stolpersteine

      1. Nicht zusammen mit ortools importieren. Beide bringen eine eigene HiGHS-Kopie mit; im selben Prozess endet das in undefined symbol (Anhang C, C9). Auch cvxpy zieht highspy bei der Solver-Erkennung mit hinein.
      2. @@ -31387,34 +31728,34 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.

        D3 — OR-Tools: pywraplp

        Wofür. Bequeme algebraische Schreibweise für LP und MILP mit umschaltbarem Backend (GLOP, SCIP, CBC, SAT). Wofür nicht: Scheduling und kombinatorische Bedingungen — dafür ist CP-SAT (D4) da.

        -
        from ortools.linear_solver import pywraplp
        -
        -# "GLOP" = LP, "SCIP" oder "CBC" = MILP, "SAT" = CP-SAT als MILP-Backend
        -loeser = pywraplp.Solver.CreateSolver("GLOP")
        -if loeser is None:
        -    raise SystemExit("Solver nicht verfuegbar")
        -loeser.SetTimeLimit(60_000)                        # Millisekunden!
        -
        -unendlich = loeser.infinity()
        -x = [loeser.NumVar(0, unendlich, f"x{j}") for j in range(3)]
        -# ganzzahlig: loeser.IntVar(0, unendlich, "n") | binaer: loeser.BoolVar("b")
        -
        -loeser.Add(x[0] + x[1] + 2 * x[2] <= 40)
        -loeser.Add(2 * x[0] + 3 * x[1] + x[2] <= 50)
        -loeser.Maximize(10 * x[0] + 15 * x[1] + 25 * x[2])
        -
        -status = loeser.Solve()
        -if status == pywraplp.Solver.OPTIMAL:
        -    print(f"optimal: {loeser.Objective().Value():.2f}")
        -    print(f"x = {[round(v.solution_value(), 4) for v in x]}")
        -elif status == pywraplp.Solver.FEASIBLE:
        -    print("zulaessig, nicht bewiesen optimal (Zeitlimit)")
        -elif status == pywraplp.Solver.INFEASIBLE:
        -    print("INFEASIBLE")
        -elif status == pywraplp.Solver.UNBOUNDED:
        -    print("UNBOUNDED")
        -else:
        -    print("ABNORMAL / NOT_SOLVED - Modell oder Solver pruefen")
        +
        from ortools.linear_solver import pywraplp
        +
        +# "GLOP" = LP, "SCIP" oder "CBC" = MILP, "SAT" = CP-SAT als MILP-Backend
        +loeser = pywraplp.Solver.CreateSolver("GLOP")
        +if loeser is None:
        +    raise SystemExit("Solver nicht verfuegbar")
        +loeser.SetTimeLimit(60_000)                        # Millisekunden!
        +
        +unendlich = loeser.infinity()
        +x = [loeser.NumVar(0, unendlich, f"x{j}") for j in range(3)]
        +# ganzzahlig: loeser.IntVar(0, unendlich, "n") | binaer: loeser.BoolVar("b")
        +
        +loeser.Add(x[0] + x[1] + 2 * x[2] <= 40)
        +loeser.Add(2 * x[0] + 3 * x[1] + x[2] <= 50)
        +loeser.Maximize(10 * x[0] + 15 * x[1] + 25 * x[2])
        +
        +status = loeser.Solve()
        +if status == pywraplp.Solver.OPTIMAL:
        +    print(f"optimal: {loeser.Objective().Value():.2f}")
        +    print(f"x = {[round(v.solution_value(), 4) for v in x]}")
        +elif status == pywraplp.Solver.FEASIBLE:
        +    print("zulaessig, nicht bewiesen optimal (Zeitlimit)")
        +elif status == pywraplp.Solver.INFEASIBLE:
        +    print("INFEASIBLE")
        +elif status == pywraplp.Solver.UNBOUNDED:
        +    print("UNBOUNDED")
        +else:
        +    print("ABNORMAL / NOT_SOLVED - Modell oder Solver pruefen")

        Die drei häufigsten Stolpersteine

        1. CreateSolver liefert None, wenn der Backend-Name unbekannt oder nicht gebaut ist — immer prüfen, statt am None später zu scheitern.
        2. @@ -31424,40 +31765,40 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.

          D4 — CP-SAT: cp_model

          Wofür. Scheduling, Zuordnung, Reihenfolgen, alles Kombinatorische mit globalen Bedingungen. Wofür nicht: stetige Größen — CP-SAT rechnet ausschließlich ganzzahlig. Wer Nachkommastellen braucht, skaliert (Cent statt Euro, Promille statt Anteil).

          -
          from ortools.sat.python import cp_model
          -
          -modell = cp_model.CpModel()
          -
          -# Variablen - CP-SAT rechnet ausschliesslich mit GANZEN Zahlen
          -x = modell.NewIntVar(0, 100, "x")                  # untere, obere Schranke, Name
          -y = modell.NewIntVar(0, 100, "y")
          -b = modell.NewBoolVar("b")                         # 0/1
          -
          -# Nebenbedingungen
          -modell.Add(2 * x + 3 * y <= 50)
          -modell.Add(x >= 5).OnlyEnforceIf(b)                # gilt nur, wenn b wahr ist
          -modell.AddAllDifferent([x, y])                     # globale Bedingung
          -modell.AddMaxEquality(z := modell.NewIntVar(0, 100, "z"), [x, y])
          -
          -modell.Maximize(10 * x + 15 * y - 3 * z)
          -
          -loeser = cp_model.CpSolver()
          -loeser.parameters.max_time_in_seconds = 10.0
          -loeser.parameters.num_workers = 1                  # 1 = reproduzierbar
          -loeser.parameters.random_seed = 1
          -status = loeser.Solve(modell)
          -
          -if status == cp_model.OPTIMAL:
          -    print(f"optimal: {loeser.ObjectiveValue():.0f}  x={loeser.Value(x)} y={loeser.Value(y)}")
          -elif status == cp_model.FEASIBLE:
          -    print(f"zulaessig, nicht bewiesen optimal - Gap-Schranke: {loeser.BestObjectiveBound()}")
          -elif status == cp_model.INFEASIBLE:
          -    print("INFEASIBLE - Bedingungen widersprechen sich")
          -elif status == cp_model.MODEL_INVALID:
          -    print("Modellfehler:", modell.Validate())
          -else:
          -    print("UNKNOWN - Zeit abgelaufen, ohne eine Loesung zu finden")
          -print(f"Laufzeit {loeser.WallTime():.3f}s, {loeser.NumBranches()} Verzweigungen")
          +
          from ortools.sat.python import cp_model
          +
          +modell = cp_model.CpModel()
          +
          +# Variablen - CP-SAT rechnet ausschliesslich mit GANZEN Zahlen
          +x = modell.NewIntVar(0, 100, "x")                  # untere, obere Schranke, Name
          +y = modell.NewIntVar(0, 100, "y")
          +b = modell.NewBoolVar("b")                         # 0/1
          +
          +# Nebenbedingungen
          +modell.Add(2 * x + 3 * y <= 50)
          +modell.Add(x >= 5).OnlyEnforceIf(b)                # gilt nur, wenn b wahr ist
          +modell.AddAllDifferent([x, y])                     # globale Bedingung
          +modell.AddMaxEquality(z := modell.NewIntVar(0, 100, "z"), [x, y])
          +
          +modell.Maximize(10 * x + 15 * y - 3 * z)
          +
          +loeser = cp_model.CpSolver()
          +loeser.parameters.max_time_in_seconds = 10.0
          +loeser.parameters.num_workers = 1                  # 1 = reproduzierbar
          +loeser.parameters.random_seed = 1
          +status = loeser.Solve(modell)
          +
          +if status == cp_model.OPTIMAL:
          +    print(f"optimal: {loeser.ObjectiveValue():.0f}  x={loeser.Value(x)} y={loeser.Value(y)}")
          +elif status == cp_model.FEASIBLE:
          +    print(f"zulaessig, nicht bewiesen optimal - Gap-Schranke: {loeser.BestObjectiveBound()}")
          +elif status == cp_model.INFEASIBLE:
          +    print("INFEASIBLE - Bedingungen widersprechen sich")
          +elif status == cp_model.MODEL_INVALID:
          +    print("Modellfehler:", modell.Validate())
          +else:
          +    print("UNKNOWN - Zeit abgelaufen, ohne eine Loesung zu finden")
          +print(f"Laufzeit {loeser.WallTime():.3f}s, {loeser.NumBranches()} Verzweigungen")

          Die drei häufigsten Stolpersteine

          1. Alles ist ganzzahlig. 0.5 * x gibt es nicht. Skalieren Sie das ganze Modell auf eine feinere Einheit, statt zu runden.
          2. @@ -31467,32 +31808,32 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.

            D5 — CVXPY

            Wofür. Konvexe Probleme: quadratische Ziele, Normen, CVaR, alles mit Regularisierungstermen. Wofür nicht: große kombinatorische Modelle — der Aufbau der Ausdrücke wird dann selbst zum Engpass.

            -
            import numpy as np
            -import cvxpy as cp
            -
            -x = cp.Variable(3, nonneg=True)                    # nonneg=True statt x >= 0
            -gewichte = cp.Variable(3)
            -ganzzahlig = cp.Variable(3, integer=True)          # macht daraus ein MIP
            -
            -ziel = cp.Maximize(np.array([10.0, 15.0, 25.0]) @ x)
            -bedingungen = [np.array([[1, 1, 2], [2, 3, 1]]) @ x <= np.array([40.0, 50.0])]
            -
            -problem = cp.Problem(ziel, bedingungen)
            -if not problem.is_dcp():                           # VOR dem Loesen pruefen
            -    print("nicht DCP:", [c for c in bedingungen if not c.is_dcp()])
            -problem.solve()
            -
            -if problem.status == cp.OPTIMAL:
            -    print(f"optimal: {problem.value:.2f}  x = {np.round(x.value, 4)}")
            -    print(f"Schattenpreis: {np.round(bedingungen[0].dual_value, 4)}")
            -elif problem.status == cp.OPTIMAL_INACCURATE:
            -    print("Loesung numerisch unsicher - Skalierung pruefen, anderen Solver testen")
            -elif problem.status == cp.INFEASIBLE:
            -    print("INFEASIBLE")
            -elif problem.status == cp.UNBOUNDED:
            -    print("UNBOUNDED")
            -else:
            -    print("Solverfehler:", problem.status)
            +
            import numpy as np
            +import cvxpy as cp
            +
            +x = cp.Variable(3, nonneg=True)                    # nonneg=True statt x >= 0
            +gewichte = cp.Variable(3)
            +ganzzahlig = cp.Variable(3, integer=True)          # macht daraus ein MIP
            +
            +ziel = cp.Maximize(np.array([10.0, 15.0, 25.0]) @ x)
            +bedingungen = [np.array([[1, 1, 2], [2, 3, 1]]) @ x <= np.array([40.0, 50.0])]
            +
            +problem = cp.Problem(ziel, bedingungen)
            +if not problem.is_dcp():                           # VOR dem Loesen pruefen
            +    print("nicht DCP:", [c for c in bedingungen if not c.is_dcp()])
            +problem.solve()
            +
            +if problem.status == cp.OPTIMAL:
            +    print(f"optimal: {problem.value:.2f}  x = {np.round(x.value, 4)}")
            +    print(f"Schattenpreis: {np.round(bedingungen[0].dual_value, 4)}")
            +elif problem.status == cp.OPTIMAL_INACCURATE:
            +    print("Loesung numerisch unsicher - Skalierung pruefen, anderen Solver testen")
            +elif problem.status == cp.INFEASIBLE:
            +    print("INFEASIBLE")
            +elif problem.status == cp.UNBOUNDED:
            +    print("UNBOUNDED")
            +else:
            +    print("Solverfehler:", problem.status)

            Die drei häufigsten Stolpersteine

            1. DCP-Regeln vor dem Lösen prüfen. problem.is_dcp() und dann die einzelnen Bedingungen — das nennt die Schuldige, statt einen DCPError ohne Ort zu werfen (Anhang C, C8).
            2. @@ -31503,73 +31844,73 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.

              D6 — Modellierungssprachen: Pyomo und Linopy

              Wofür. Beide trennen Modell von Solver: dasselbe Modell läuft ohne Änderung unter HiGHS, CBC, Gurobi. Pyomo denkt in Mengen und Indizes wie eine mathematische Formulierung; Linopy denkt in beschrifteten Arrays und baut Nebenbedingungen als Matrixoperation statt in Python-Schleifen. Wofür nicht: ein Modell mit zehn Nebenbedingungen — dort ist der Aufwand höher als der Nutzen.

              Pyomo

              -
              import pyomo.environ as pyo
              -
              -modell = pyo.ConcreteModel()
              -modell.J = pyo.RangeSet(0, 2)                      # Indexmenge
              -modell.x = pyo.Var(modell.J, domain=pyo.NonNegativeReals)
              -# ganzzahlig: domain=pyo.NonNegativeIntegers | binaer: domain=pyo.Binary
              -
              -ertrag = {0: 10.0, 1: 15.0, 2: 25.0}
              -modell.ziel = pyo.Objective(expr=sum(ertrag[j] * modell.x[j] for j in modell.J),
              -                            sense=pyo.maximize)
              -modell.montage = pyo.Constraint(
              -    expr=modell.x[0] + modell.x[1] + 2 * modell.x[2] <= 40)
              -modell.pruefung = pyo.Constraint(
              -    expr=2 * modell.x[0] + 3 * modell.x[1] + modell.x[2] <= 50)
              -modell.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT)   # fuer Schattenpreise
              -
              -ergebnis = pyo.SolverFactory("appsi_highs").solve(modell)
              -zustand = ergebnis.solver.termination_condition
              -if zustand == pyo.TerminationCondition.optimal:
              -    print(f"optimal: {pyo.value(modell.ziel):.2f}")
              -    print(f"x = {[round(pyo.value(modell.x[j]), 4) for j in modell.J]}")
              -    print(f"Schattenpreis Montage: {modell.dual[modell.montage]:.4f}")
              -elif zustand == pyo.TerminationCondition.infeasible:
              -    print("INFEASIBLE")
              -elif zustand == pyo.TerminationCondition.unbounded:
              -    print("UNBOUNDED")
              -elif zustand == pyo.TerminationCondition.maxTimeLimit:
              -    print("Zeitlimit")
              -else:
              -    print("kein Optimum:", zustand)
              +
              import pyomo.environ as pyo
              +
              +modell = pyo.ConcreteModel()
              +modell.J = pyo.RangeSet(0, 2)                      # Indexmenge
              +modell.x = pyo.Var(modell.J, domain=pyo.NonNegativeReals)
              +# ganzzahlig: domain=pyo.NonNegativeIntegers | binaer: domain=pyo.Binary
              +
              +ertrag = {0: 10.0, 1: 15.0, 2: 25.0}
              +modell.ziel = pyo.Objective(expr=sum(ertrag[j] * modell.x[j] for j in modell.J),
              +                            sense=pyo.maximize)
              +modell.montage = pyo.Constraint(
              +    expr=modell.x[0] + modell.x[1] + 2 * modell.x[2] <= 40)
              +modell.pruefung = pyo.Constraint(
              +    expr=2 * modell.x[0] + 3 * modell.x[1] + modell.x[2] <= 50)
              +modell.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT)   # fuer Schattenpreise
              +
              +ergebnis = pyo.SolverFactory("appsi_highs").solve(modell)
              +zustand = ergebnis.solver.termination_condition
              +if zustand == pyo.TerminationCondition.optimal:
              +    print(f"optimal: {pyo.value(modell.ziel):.2f}")
              +    print(f"x = {[round(pyo.value(modell.x[j]), 4) for j in modell.J]}")
              +    print(f"Schattenpreis Montage: {modell.dual[modell.montage]:.4f}")
              +elif zustand == pyo.TerminationCondition.infeasible:
              +    print("INFEASIBLE")
              +elif zustand == pyo.TerminationCondition.unbounded:
              +    print("UNBOUNDED")
              +elif zustand == pyo.TerminationCondition.maxTimeLimit:
              +    print("Zeitlimit")
              +else:
              +    print("kein Optimum:", zustand)

              Linopy

              -
              import linopy
              -import numpy as np
              -import pandas as pd
              -import xarray as xr
              -
              -# Benannte Indizes statt blosser Listen - sonst heissen die Achsen "dim_0"
              -produkt = pd.Index(["Rahmen", "Gehaeuse", "Deckel"], name="produkt")
              -ressource = pd.Index(["Montage", "Pruefung"], name="ressource")
              -
              -modell = linopy.Model()
              -modell.add_variables(lower=0, coords=[produkt], name="menge")
              -x = modell.variables["menge"]
              -# ganzzahlig: integer=True | binaer: binary=True
              -
              -ertrag = xr.DataArray([10.0, 15.0, 25.0], coords=[produkt])
              -verbrauch = xr.DataArray([[1.0, 1.0, 2.0], [2.0, 3.0, 1.0]],
              -                         coords=[ressource, produkt])
              -vorrat = xr.DataArray([40.0, 50.0], coords=[ressource])
              -
              -modell.add_objective((ertrag * x).sum(), sense="max")
              -# EINE Zeile, so viele Nebenbedingungen wie 'ressource' Eintraege hat:
              -modell.add_constraints((verbrauch * x).sum("produkt") <= vorrat, name="kapazitaet")
              -
              -modell.solve(solver_name="highs", output_flag=False)
              -
              -if modell.termination_condition == "optimal":
              -    print(f"optimal: {modell.objective.value:.2f}")
              -    print(x.solution.to_series().round(4).to_dict())
              -    print("Schattenpreise:",
              -          modell.constraints["kapazitaet"].dual.to_series().round(4).to_dict())
              -elif modell.termination_condition == "infeasible":
              -    print("INFEASIBLE")
              -elif modell.termination_condition == "unbounded":
              -    print("UNBOUNDED")
              -else:
              -    print("kein Optimum:", modell.status, modell.termination_condition)
              +
              import linopy
              +import numpy as np
              +import pandas as pd
              +import xarray as xr
              +
              +# Benannte Indizes statt blosser Listen - sonst heissen die Achsen "dim_0"
              +produkt = pd.Index(["Rahmen", "Gehaeuse", "Deckel"], name="produkt")
              +ressource = pd.Index(["Montage", "Pruefung"], name="ressource")
              +
              +modell = linopy.Model()
              +modell.add_variables(lower=0, coords=[produkt], name="menge")
              +x = modell.variables["menge"]
              +# ganzzahlig: integer=True | binaer: binary=True
              +
              +ertrag = xr.DataArray([10.0, 15.0, 25.0], coords=[produkt])
              +verbrauch = xr.DataArray([[1.0, 1.0, 2.0], [2.0, 3.0, 1.0]],
              +                         coords=[ressource, produkt])
              +vorrat = xr.DataArray([40.0, 50.0], coords=[ressource])
              +
              +modell.add_objective((ertrag * x).sum(), sense="max")
              +# EINE Zeile, so viele Nebenbedingungen wie 'ressource' Eintraege hat:
              +modell.add_constraints((verbrauch * x).sum("produkt") <= vorrat, name="kapazitaet")
              +
              +modell.solve(solver_name="highs", output_flag=False)
              +
              +if modell.termination_condition == "optimal":
              +    print(f"optimal: {modell.objective.value:.2f}")
              +    print(x.solution.to_series().round(4).to_dict())
              +    print("Schattenpreise:",
              +          modell.constraints["kapazitaet"].dual.to_series().round(4).to_dict())
              +elif modell.termination_condition == "infeasible":
              +    print("INFEASIBLE")
              +elif modell.termination_condition == "unbounded":
              +    print("UNBOUNDED")
              +else:
              +    print("kein Optimum:", modell.status, modell.termination_condition)

              Die drei häufigsten Stolpersteine

              1. Der Solver ist ein eigenes Programm. SolverFactory("appsi_highs") scheitert, wenn HiGHS nicht auffindbar ist — die Fehlermeldung nennt dann das Modell, nicht die fehlende Installation.
              2. diff --git a/OR_HTML_04/mehrziel.html b/OR_HTML_04/mehrziel.html index bd46844..fb826a8 100644 --- a/OR_HTML_04/mehrziel.html +++ b/OR_HTML_04/mehrziel.html @@ -252,7 +252,7 @@ f_j(x) < f_j(y)\ \text{ für mindestens ein } j w -„So viele Euro ist mir ein Kilogramm CO₂ wert.“ Ein Wechselkurs, kein Wichtigkeitsregler (Abschnitt 7.9). +„So viele Euro ist mir ein Kilogramm CO₂ wert.“ Ein Wechselkurs, kein Wichtigkeitsregler (Abschnitt 7.10). \min_{x \in X} diff --git a/OR_HTML_04/milp.html b/OR_HTML_04/milp.html index 1224600..c358e0b 100644 --- a/OR_HTML_04/milp.html +++ b/OR_HTML_04/milp.html @@ -1342,68 +1342,69 @@ mit Warm-Start 8.44s 150 69,134.14
    loeser = cp_model.CpSolver() loeser.parameters.max_time_in_seconds = zeitlimit - # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist. - # Im Produktivbetrieb laesst man beides auf den Standardwerten. - loeser.parameters.num_workers = 1 - loeser.parameters.random_seed = 1 - - t0 = time.perf_counter() - status = loeser.Solve(modell) - dauer_s = time.perf_counter() - t0 - - if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE): - raise RuntimeError(f"Kein Plan gefunden: {loeser.StatusName(status)}") - return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer_s - + # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist - + # der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming). + # Im Produktivbetrieb laesst man beides auf den Standardwerten. + loeser.parameters.num_workers = 1 + loeser.parameters.random_seed = 1 + + t0 = time.perf_counter() + status = loeser.Solve(modell) + dauer_s = time.perf_counter() - t0 + + if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE): + raise RuntimeError(f"Kein Plan gefunden: {loeser.StatusName(status)}") + return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer_s -if __name__ == "__main__": - print("=" * 78) - print(" WARM-START: WENN DIE HEURISTIK MEHR WEISS ALS DER SOLVER") - print("=" * 78) - print("Lastverteilung: Auftraege auf gleichartige Maschinen verteilen,") - print("sodass die letzte Maschine so frueh wie moeglich fertig wird.\n") - - print(f"{'Instanz':<22} {'Variante':<22} {'Makespan':>9} {'Zeit':>9} " - f"{'Faktor':>8}") - print("-" * 78) - - for n_auftraege, n_maschinen in [(60, 7), (80, 9)]: - dauer = RNG.integers(10, 90, n_auftraege) - start, lpt_wert = lpt_heuristik(dauer, n_maschinen) - untere_schranke = dauer.sum() / n_maschinen - - instanz = f"{n_auftraege} Auftr., {n_maschinen} Masch." - print(f"{instanz:<22} {'LPT-Heuristik':<22} {lpt_wert:>9} " - f"{'< 0.001s':>9} {'':>8}") - - _, ziel_kalt, zeit_kalt = loese(dauer, n_maschinen, 60.0) - print(f"{'':<22} {'CP-SAT kalt':<22} {ziel_kalt:>9} " - f"{zeit_kalt:>8.3f}s {'1,0x':>8}") - - _, ziel_warm, zeit_warm = loese(dauer, n_maschinen, 60.0, hinweis=start) - print(f"{'':<22} {'CP-SAT + LPT-Hinweis':<22} {ziel_warm:>9} " - f"{zeit_warm:>8.3f}s {zeit_kalt / zeit_warm:>7.1f}x") - - assert ziel_kalt == ziel_warm, \ - "Der Hinweis darf das Optimum nicht veraendern - nur den Weg dorthin!" - print(f"{'':<22} {'untere Schranke':<22} {untere_schranke:>9.1f}") - print("-" * 78) - - print("\nDrei Beobachtungen:") - print("1. Der Hinweis aendert das ERGEBNIS nicht - beide Laeufe finden") - print(" dasselbe Optimum. Er aendert nur, wie lange der Beweis dauert.") - print(" Genau deshalb ist ein Warm-Start ungefaehrlich: Ein schlechter") - print(" Hinweis kostet Zeit, er verfaelscht aber nie die Loesung.") - print("2. Die LPT-Heuristik liegt schon sehr nah am Optimum. Ihr Wert fuer") - print(" den Solver liegt weniger in der Qualitaet als darin, dass sie") - print(" SOFORT da ist - der Solver kann von Beginn an alles verwerfen,") - print(" was schlechter ist.") - print("3. Der Faktor schwankt von Instanz zu Instanz - oben 2,2x und 1,4x -") - print(" und laesst sich NICHT aus der Problemgroesse ableiten. Er haengt") - print(" davon ab, wie schnell der Solver von allein eine vergleichbar gute") - print(" Loesung findet. Das ist die eigentliche Lehre: Ein Warm-Start ist") - print(" eine Messung wert, keine Glaubensfrage.") - print("=" * 78)
    + +if __name__ == "__main__": + print("=" * 78) + print(" WARM-START: WENN DIE HEURISTIK MEHR WEISS ALS DER SOLVER") + print("=" * 78) + print("Lastverteilung: Auftraege auf gleichartige Maschinen verteilen,") + print("sodass die letzte Maschine so frueh wie moeglich fertig wird.\n") + + print(f"{'Instanz':<22} {'Variante':<22} {'Makespan':>9} {'Zeit':>9} " + f"{'Faktor':>8}") + print("-" * 78) + + for n_auftraege, n_maschinen in [(60, 7), (80, 9)]: + dauer = RNG.integers(10, 90, n_auftraege) + start, lpt_wert = lpt_heuristik(dauer, n_maschinen) + untere_schranke = dauer.sum() / n_maschinen + + instanz = f"{n_auftraege} Auftr., {n_maschinen} Masch." + print(f"{instanz:<22} {'LPT-Heuristik':<22} {lpt_wert:>9} " + f"{'< 0.001s':>9} {'':>8}") + + _, ziel_kalt, zeit_kalt = loese(dauer, n_maschinen, 60.0) + print(f"{'':<22} {'CP-SAT kalt':<22} {ziel_kalt:>9} " + f"{zeit_kalt:>8.3f}s {'1,0x':>8}") + + _, ziel_warm, zeit_warm = loese(dauer, n_maschinen, 60.0, hinweis=start) + print(f"{'':<22} {'CP-SAT + LPT-Hinweis':<22} {ziel_warm:>9} " + f"{zeit_warm:>8.3f}s {zeit_kalt / zeit_warm:>7.1f}x") + + assert ziel_kalt == ziel_warm, \ + "Der Hinweis darf das Optimum nicht veraendern - nur den Weg dorthin!" + print(f"{'':<22} {'untere Schranke':<22} {untere_schranke:>9.1f}") + print("-" * 78) + + print("\nDrei Beobachtungen:") + print("1. Der Hinweis aendert das ERGEBNIS nicht - beide Laeufe finden") + print(" dasselbe Optimum. Er aendert nur, wie lange der Beweis dauert.") + print(" Genau deshalb ist ein Warm-Start ungefaehrlich: Ein schlechter") + print(" Hinweis kostet Zeit, er verfaelscht aber nie die Loesung.") + print("2. Die LPT-Heuristik liegt schon sehr nah am Optimum. Ihr Wert fuer") + print(" den Solver liegt weniger in der Qualitaet als darin, dass sie") + print(" SOFORT da ist - der Solver kann von Beginn an alles verwerfen,") + print(" was schlechter ist.") + print("3. Der Faktor schwankt von Instanz zu Instanz - oben 2,2x und 1,4x -") + print(" und laesst sich NICHT aus der Problemgroesse ableiten. Er haengt") + print(" davon ab, wie schnell der Solver von allein eine vergleichbar gute") + print(" Loesung findet. Das ist die eigentliche Lehre: Ein Warm-Start ist") + print(" eine Messung wert, keine Glaubensfrage.") + print("=" * 78)

    Erwartete Ausgabe (Zeiten hardwareabhängig):

    ==============================================================================
       WARM-START: WENN DIE HEURISTIK MEHR WEISS ALS DER SOLVER
    diff --git a/OR_HTML_04/modellierung.html b/OR_HTML_04/modellierung.html
    index 87181ac..d77fd56 100644
    --- a/OR_HTML_04/modellierung.html
    +++ b/OR_HTML_04/modellierung.html
    @@ -706,7 +706,7 @@ Drei Dinge, die deshalb vor dem ersten Modell geklaert gehoeren:
     
     
     

    Bei 3 000 € ändert sich nichts: Die Strafe liegt unter dem Deckungsbeitrag, den die Stammkundenaufträge verdrängen würden. Bei 10 000 € kommt genau Lesart A heraus.

    -

    Daraus folgt eine Einsicht, die das Modellieren erleichtert: Eine harte Regel ist eine weiche mit hinreichend großer Strafe. Man kann also immer weich anfangen und die Strafe hochdrehen, bis die Regel greift — und sieht dabei, was sie kostet. Was ein Strafgewicht darüber hinaus bedeutet und warum zwei Gewichte einen Wechselkurs festlegen und keine Wichtigkeiten, steht in Abschnitt 7.9.

    +

    Daraus folgt eine Einsicht, die das Modellieren erleichtert: Eine harte Regel ist eine weiche mit hinreichend großer Strafe. Man kann also immer weich anfangen und die Strafe hochdrehen, bis die Regel greift — und sieht dabei, was sie kostet. Was ein Strafgewicht darüber hinaus bedeutet und warum zwei Gewichte einen Wechselkurs festlegen und keine Wichtigkeiten, steht in Abschnitt 7.10.

    Teil 4: Ein Quartal später

    Die harte Lesart A wurde eingebaut, alles lief drei Monate lang. Dann fragt Weber einen Auftrag über 320 Maschinenstunden an — mehr, als der ganze Zeitraum hergibt.

    Lesart A (hart):  UNZULAESSIG - der Nachtlauf bricht ab
    diff --git a/OR_HTML_04/praxisfallen.html b/OR_HTML_04/praxisfallen.html
    index b1e0088..320f820 100644
    --- a/OR_HTML_04/praxisfallen.html
    +++ b/OR_HTML_04/praxisfallen.html
    @@ -1664,7 +1664,7 @@ Domaenenschicht.
     
     SolverStatus.modellfehler
     trennt Modell- von Zeitproblemen
    -Genau die Unterscheidung, die Abschnitt 7.7 als häufigste Verwechslung benennt.
    +Genau die Unterscheidung, die Abschnitt 7.8 als häufigste Verwechslung benennt.
     
     
     model_validator(mode="after")
    diff --git a/OR_HTML_04/programme.html b/OR_HTML_04/programme.html
    index 93d17f8..e8fc297 100644
    --- a/OR_HTML_04/programme.html
    +++ b/OR_HTML_04/programme.html
    @@ -99,7 +99,7 @@
         
         
         
    -

    Beispielprogramme

    Alle 75 im Buch abgedruckten Programme, gruppiert nach Kapitel, zum direkten Herunterladen.

    Kapitel 1: Einführung in Operations Research — Vom Ursprung zur mathematischen Entscheidungsfindung

    Kapitel 2: Das mathematische Fundament — Vektoren, Matrizen, Konvexität

    Kapitel 3: Das Python-Ökosystem für OR — Solver, Bindings und Modellierungsschichten

    Kapitel 4: Vom Management-Wunsch zum Modell

    Kapitel 5: Lineare Programmierung — Simplex, Dualität und Schattenpreise

    Kapitel 6: Gemischt-ganzzahlige Optimierung — Diskrete Entscheidungen und Branch-and-Bound

    Kapitel 7: Constraint Programming mit CP-SAT — Logik, Scheduling und Zuweisung

    Kapitel 8: Graphen, Flüsse und Touren — Min-Cost-Flow, Matching und VRP

    Kapitel 9: Metaheuristiken — wenn der exakte Solver aussteigt

    Kapitel 10: Spaltengenerierung — das Modell umbauen statt die Lösung raten

    Kapitel 11: Quadratische und nichtlineare Optimierung — KKT, Lagrange, Konvexität

    Kapitel 12: Optimierung unter Unsicherheit — Monte-Carlo, Stochastik, Robustheit

    Kapitel 13: Dynamische Programmierung — Die Bellman-Gleichung und Order-Execution

    Kapitel 14: Mehrere Ziele — Pareto-Fronten statt Gewichte

    Kapitel 15: Predict-then-Optimize — die bessere Prognose, die schlechtere Entscheidung

    Kapitel 16: Die Strukturbrücke — dieselbe Mathematik, zwei Welten

    Kapitel 17: Supply-Chain und Energieeinsatz unter Unsicherheit

    Kapitel 18: Finanzdaten-Modellierung — Renditen, Kovarianz und Shrinkage

    Kapitel 19: Die moderne Portfoliotheorie nach Markowitz

    Kapitel 20: Tail-Risiko, CVaR und Transaktionskosten

    Kapitel 21: Die vollständige quantitative Handelsmaschine

    Kapitel 22: Praxisfallen und der Weg zum produktiven Einsatz

    Kapitel 23: Testen, Messen, Ausliefern

    +

    Beispielprogramme

    Alle 76 im Buch abgedruckten Programme, gruppiert nach Kapitel, zum direkten Herunterladen.

    Kapitel 1: Einführung in Operations Research — Vom Ursprung zur mathematischen Entscheidungsfindung

    Kapitel 2: Das mathematische Fundament — Vektoren, Matrizen, Konvexität

    Kapitel 3: Das Python-Ökosystem für OR — Solver, Bindings und Modellierungsschichten

    Kapitel 4: Vom Management-Wunsch zum Modell

    Kapitel 5: Lineare Programmierung — Simplex, Dualität und Schattenpreise

    Kapitel 6: Gemischt-ganzzahlige Optimierung — Diskrete Entscheidungen und Branch-and-Bound

    Kapitel 7: Constraint Programming mit CP-SAT — Logik, Scheduling und Zuweisung

    Kapitel 8: Graphen, Flüsse und Touren — Min-Cost-Flow, Matching und VRP

    Kapitel 9: Metaheuristiken — wenn der exakte Solver aussteigt

    Kapitel 10: Spaltengenerierung — das Modell umbauen statt die Lösung raten

    Kapitel 11: Quadratische und nichtlineare Optimierung — KKT, Lagrange, Konvexität

    Kapitel 12: Optimierung unter Unsicherheit — Monte-Carlo, Stochastik, Robustheit

    Kapitel 13: Dynamische Programmierung — Die Bellman-Gleichung und Order-Execution

    Kapitel 14: Mehrere Ziele — Pareto-Fronten statt Gewichte

    Kapitel 15: Predict-then-Optimize — die bessere Prognose, die schlechtere Entscheidung

    Kapitel 16: Die Strukturbrücke — dieselbe Mathematik, zwei Welten

    Kapitel 17: Supply-Chain und Energieeinsatz unter Unsicherheit

    Kapitel 18: Finanzdaten-Modellierung — Renditen, Kovarianz und Shrinkage

    Kapitel 19: Die moderne Portfoliotheorie nach Markowitz

    Kapitel 20: Tail-Risiko, CVaR und Transaktionskosten

    Kapitel 21: Die vollständige quantitative Handelsmaschine

    Kapitel 22: Praxisfallen und der Weg zum produktiven Einsatz

    Kapitel 23: Testen, Messen, Ausliefern

    diff --git a/OR_HTML_04/programme/Parallele_Suche.py b/OR_HTML_04/programme/Parallele_Suche.py new file mode 100644 index 0000000..a23ed10 --- /dev/null +++ b/OR_HTML_04/programme/Parallele_Suche.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 + +# Parallele_Suche.py +""" +Kapitel CP-SAT: Was mehrere Arbeiter wirklich bringen - und was sie kosten. + +Das Buch setzt an mehreren Stellen 'num_workers = 1', damit die abgedruckte +Ausgabe reproduzierbar ist. Behauptet wird dabei zweierlei: dass parallele +Suche schneller ist, und dass sie von Lauf zu Lauf verschiedene, gleich gute +Loesungen findet. Beides wird hier gemessen statt geglaubt. + +Gerechnet wird auf einem Job-Shop wie in JobShop_Intervalle.py, nur gross +genug, dass die Suche wirklich arbeitet: 12 Auftraege auf 10 Maschinen, +jeder Auftrag besucht jede Maschine. + +Zwei Messungen: + 1. Dieselbe Aufgabe mit 1, 2, 4 und 8 Arbeitern - Laufzeit und Ergebnis. + 2. Dieselbe Konfiguration mehrfach, bei FESTEM random_seed - wie oft kommt + derselbe Plan heraus? + +Die zweite ist die wichtigere: Sie entscheidet, wie man Tests schreibt. + +Achtung, Laufzeiten sind hardwareabhaengig. Die ZAHL der verschiedenen +Plaene ist es auch - und genau das ist die Aussage. + +Benoetigt: numpy, ortools +""" + +from __future__ import annotations + +import collections +import time + +import numpy as np +from ortools.sat.python import cp_model + +AUFTRAEGE = 12 +MASCHINEN = 10 +INSTANZ_SEED = 20260908 +SOLVER_SEED = 1 # bleibt ueber ALLE Laeufe gleich - das ist der Punkt +ARBEITERZAHLEN = (1, 2, 4, 8) +WIEDERHOLUNGEN = 4 +ZEITLIMIT = 120.0 + + +def baue_instanz() -> list[list[tuple[int, int]]]: + """Klassischer Job-Shop: Jeder Auftrag besucht jede Maschine genau einmal, + in einer eigenen zufaelligen Reihenfolge.""" + rng = np.random.default_rng(INSTANZ_SEED) + return [[(int(m), int(rng.integers(2, 20))) for m in rng.permutation(MASCHINEN)] + for _ in range(AUFTRAEGE)] + + +def loese(auftraege, arbeiter: int): + """Minimiert den Makespan. Gibt Status, Zielwert, Laufzeit und den Plan + zurueck - den Plan als Tupel aller Startzeiten, damit sich zwei Laeufe + vergleichen lassen.""" + horizont = sum(dauer for auftrag in auftraege for _, dauer in auftrag) + modell = cp_model.CpModel() + Gang = collections.namedtuple("Gang", "start ende intervall") + plaene: dict[tuple[int, int], Gang] = {} + je_maschine = collections.defaultdict(list) + + for a, auftrag in enumerate(auftraege): + for g, (maschine, dauer) in enumerate(auftrag): + start = modell.NewIntVar(0, horizont, f"start_{a}_{g}") + ende = modell.NewIntVar(0, horizont, f"ende_{a}_{g}") + intervall = modell.NewIntervalVar(start, dauer, ende, f"iv_{a}_{g}") + plaene[a, g] = Gang(start, ende, intervall) + je_maschine[maschine].append(intervall) + + for maschine in range(MASCHINEN): + modell.AddNoOverlap(je_maschine[maschine]) + for a, auftrag in enumerate(auftraege): + for g in range(len(auftrag) - 1): + modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende) + + makespan = modell.NewIntVar(0, horizont, "makespan") + modell.AddMaxEquality( + makespan, [plaene[a, len(auftrag) - 1].ende + for a, auftrag in enumerate(auftraege)]) + modell.Minimize(makespan) + + loeser = cp_model.CpSolver() + loeser.parameters.num_workers = arbeiter + loeser.parameters.random_seed = SOLVER_SEED + loeser.parameters.max_time_in_seconds = ZEITLIMIT + + beginn = time.perf_counter() + status = loeser.Solve(modell) + dauer = time.perf_counter() - beginn + + if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE): + return loeser.StatusName(status), None, dauer, None + plan = tuple(loeser.Value(plaene[a, g].start) + for a, auftrag in enumerate(auftraege) + for g in range(len(auftrag))) + return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer, plan + + +if __name__ == "__main__": + auftraege = baue_instanz() + gaenge = sum(len(a) for a in auftraege) + + print("=" * 78) + print(" WAS MEHRERE ARBEITER WIRKLICH BRINGEN") + print("=" * 78) + print(f"Job-Shop: {AUFTRAEGE} Auftraege, {MASCHINEN} Maschinen, " + f"{gaenge} Arbeitsgaenge.") + print(f"random_seed = {SOLVER_SEED} in JEDEM Lauf, " + f"{WIEDERHOLUNGEN} Wiederholungen je Arbeiterzahl.\n") + + # Jede Konfiguration mehrfach - ein einzelner Zeitwert sagt nichts. + ergebnisse: dict[int, list[tuple]] = {} + for arbeiter in ARBEITERZAHLEN: + ergebnisse[arbeiter] = [loese(auftraege, arbeiter) + for _ in range(WIEDERHOLUNGEN)] + + print("=" * 78) + print(" (1) Laufzeit: was die Arbeiter bringen") + print("=" * 78) + print(f"{'Arbeiter':>9} {'Status':>9} {'Makespan':>9} " + f"{'schnellste':>11} {'langsamste':>11} {'Beschleunigung':>15}") + print("-" * 78) + basis_zeit = min(d for _, _, d, _ in ergebnisse[1]) + for arbeiter in ARBEITERZAHLEN: + laeufe = ergebnisse[arbeiter] + zeiten = [d for _, _, d, _ in laeufe] + ziele = {o for _, o, _, _ in laeufe} + status = {s for s, _, _, _ in laeufe} + print(f"{arbeiter:>9} {'/'.join(sorted(status)):>9} " + f"{'/'.join(str(z) for z in sorted(ziele)):>9} " + f"{min(zeiten):>10.2f}s {max(zeiten):>10.2f}s " + f"{basis_zeit / min(zeiten):>14.1f}x") + + beste = min(ARBEITERZAHLEN, key=lambda w: min(d for _, _, d, _ in ergebnisse[w])) + faktor = basis_zeit / min(d for _, _, d, _ in ergebnisse[beste]) + print(f"\n Am schnellsten sind {beste} Arbeiter: Faktor {faktor:.1f} gegenueber") + print(f" einem einzigen - also MEHR, als {beste} Kerne hergeben sollten.") + print(f" Das ist kein Messfehler. CP-SAT laesst nicht {beste}-mal dieselbe Suche") + print(" laufen, sondern verschiedene Strategien nebeneinander, die einander") + print(" ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der") + print(" 'zweite Kern', sondern ein anderes Verfahren.") + letzte = ARBEITERZAHLEN[-1] + if beste != letzte: + zeit_letzte = min(d for _, _, d, _ in ergebnisse[letzte]) + print(f"\n Und mehr ist nicht immer besser: {letzte} Arbeiter brauchen " + f"{zeit_letzte:.2f}s") + print(f" gegenueber {min(d for _, _, d, _ in ergebnisse[beste]):.2f}s bei " + f"{beste}. Ab einer gewissen Zahl kosten Abstimmung") + print(" und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt.") + + print("\n" + "=" * 78) + print(" (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?") + print("=" * 78) + print(f"{'Arbeiter':>9} {'Laeufe':>7} {'verschiedene Plaene':>21} " + f"{'Makespan':>10}") + print("-" * 78) + for arbeiter in ARBEITERZAHLEN: + plaene = {p for _, _, _, p in ergebnisse[arbeiter] if p is not None} + ziele = {o for _, o, _, _ in ergebnisse[arbeiter]} + print(f"{arbeiter:>9} {WIEDERHOLUNGEN:>7} {len(plaene):>21} " + f"{'/'.join(str(z) for z in sorted(ziele)):>10}") + + einer = len({p for _, _, _, p in ergebnisse[1]}) + # Ab welcher Arbeiterzahl bricht die Reproduzierbarkeit? Das ist die + # eigentliche Zahl - nicht, was acht Arbeiter anrichten. + ab = next((w for w in ARBEITERZAHLEN + if len({p for _, _, _, p in ergebnisse[w]}) > 1), None) + print(f"\n Mit einem Arbeiter: {einer} Plan aus {WIEDERHOLUNGEN} Laeufen.") + if ab is not None: + viele = len({p for _, _, _, p in ergebnisse[ab]}) + print(f" Schon mit {ab} Arbeitern: {viele} verschiedene Plaene aus " + f"{WIEDERHOLUNGEN} Laeufen -") + print(" bei identischem random_seed und identischem Zielwert.") + print(f" Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit") + print(f" zu verlieren. {ab} genuegen.") + print("\n Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt") + print(" fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von") + print(" mehreren zuerst fertig wird. Das entscheidet die Uhr.") + + print("\n" + "=" * 78) + print(" (3) Was daraus fuer Tests folgt") + print("=" * 78) + print(" Ein Test der Form assert plan == erwarteter_plan besteht mal und") + print(" scheitert mal, ohne dass sich am Code etwas geaendert haette.") + print(" Zu pruefen sind stattdessen:") + print(" * der ZIELWERT (hier in allen Laeufen gleich),") + print(" * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),") + print(" * und der Status - nicht die Gestalt der Loesung.") + print("\n Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte") + print(" Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.") + print(f" Das kostet hier den Faktor {faktor:.1f} an Laufzeit und ist genau") + print(" deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.") + print("=" * 78) diff --git a/OR_HTML_04/programme/Warmstart_Effekt.py b/OR_HTML_04/programme/Warmstart_Effekt.py index ca1d586..6c08303 100644 --- a/OR_HTML_04/programme/Warmstart_Effekt.py +++ b/OR_HTML_04/programme/Warmstart_Effekt.py @@ -83,7 +83,8 @@ def loese(dauer: np.ndarray, n_maschinen: int, zeitlimit: float, loeser = cp_model.CpSolver() loeser.parameters.max_time_in_seconds = zeitlimit - # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist. + # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist - + # der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming). # Im Produktivbetrieb laesst man beides auf den Standardwerten. loeser.parameters.num_workers = 1 loeser.parameters.random_seed = 1 diff --git a/OR_HTML_04/stichwortverzeichnis.html b/OR_HTML_04/stichwortverzeichnis.html index d015476..f9d6555 100644 --- a/OR_HTML_04/stichwortverzeichnis.html +++ b/OR_HTML_04/stichwortverzeichnis.html @@ -99,7 +99,7 @@
    -

    Stichwortverzeichnis

    219 Fachbegriffe. Ein Klick führt zum jeweiligen Kapitel/Abschnitt (statt einer im Web bedeutungslosen Seitenzahl wie im gedruckten Register).

    A

    Absolutbetrag (Modellierungsmuster)Anhang B: Katalog der Modellierungsmuster
    Aktivierungsschalter (Modellierungsmuster)Anhang B: Katalog der Modellierungsmuster
    AlternativoptimaAnhang E: Glossar und Literatur

    B

    Bedingte Kopplung (Konjunktion)Anhang B: Katalog der Modellierungsmuster

    C

    D

    DualitätstheorieAnhang E: Glossar und Literatur

    E

    Entweder-Oder-BedingungAnhang B: Katalog der Modellierungsmuster

    F

    Falsche Dualwerte (Fehlerbild)Anhang C: Fehlerdiagnose-Handbuch

    G

    Gestaffelte Preise (stückweise linear)Anhang B: Katalog der Modellierungsmuster
    Gini-KoeffizientProjektwerkstatt

    H

    Herfindahl-IndexProjektwerkstatt

    I

    IIS (Irreducible Infeasible Subset)Anhang C: Fehlerdiagnose-Handbuch
    Implikation (Modellierungsmuster)Anhang B: Katalog der Modellierungsmuster
    Importfehler (ortools und highspy)Anhang C: Fehlerdiagnose-Handbuch
    Instabile Lösung (Fehlerbild)Anhang C: Fehlerdiagnose-Handbuch

    J

    K

    Kanonische StandardformAnhang E: Glossar und Literatur
    Kohärentes RisikomaßAnhang E: Glossar und Literatur

    L

    Lagrange-MultiplikatorAnhang E: Glossar und Literatur
    Laufzeitexplosion (Fehlerbild)Anhang C: Fehlerdiagnose-Handbuch
    Lineare Programmierung (LP)Anhang E: Glossar und Literatur

    M

    Maximum DrawdownAnhang E: Glossar und Literatur

    N

    Nichtlineare Programmierung (NLP)Anhang E: Glossar und Literatur

    O

    P

    Positiv (semi-)definitAnhang E: Glossar und Literatur

    Q

    R

    S

    T

    TransaktionskostenAnhang E: Glossar und Literatur

    U

    Umrüstkosten (Modellierungsmuster)Anhang B: Katalog der Modellierungsmuster
    Unbounded (Fehlerbild)Anhang C: Fehlerdiagnose-Handbuch
    UnsicherheitsmengeAnhang E: Glossar und Literatur
    Unsinniges Ergebnis (Fehlerbild)Anhang C: Fehlerdiagnose-Handbuch

    V

    Vehicle Routing Problem (VRP)Anhang E: Glossar und Literatur
    Verdächtig guter Backtest (Fehlerbild)Anhang C: Fehlerdiagnose-Handbuch
    Vertauschte Spalten (Fehlerbild)Anhang C: Fehlerdiagnose-Handbuch

    W

    Weiche Grenze mit StrafkostenAnhang B: Katalog der Modellierungsmuster
    Widersprüchliche Solver (Fehlerbild)Anhang C: Fehlerdiagnose-Handbuch
    Wurzel-Zeit-RegelAnhang E: Glossar und Literatur

    Z

    Zuordnungsproblem (1:1)Anhang B: Katalog der Modellierungsmuster
    Zwei-Phasen-Simplex-MethodeAnhang A: Lösungen zu allen Übungsaufgaben
    +

    Stichwortverzeichnis

    219 Fachbegriffe. Ein Klick führt zum jeweiligen Kapitel/Abschnitt (statt einer im Web bedeutungslosen Seitenzahl wie im gedruckten Register).

    A

    Absolutbetrag (Modellierungsmuster)Anhang B: Katalog der Modellierungsmuster
    Aktivierungsschalter (Modellierungsmuster)Anhang B: Katalog der Modellierungsmuster
    AlternativoptimaAnhang E: Glossar und Literatur

    B

    Bedingte Kopplung (Konjunktion)Anhang B: Katalog der Modellierungsmuster

    C

    D

    DualitätstheorieAnhang E: Glossar und Literatur

    E

    Entweder-Oder-BedingungAnhang B: Katalog der Modellierungsmuster

    F

    Falsche Dualwerte (Fehlerbild)Anhang C: Fehlerdiagnose-Handbuch

    G

    Gestaffelte Preise (stückweise linear)Anhang B: Katalog der Modellierungsmuster
    Gini-KoeffizientProjektwerkstatt

    H

    Herfindahl-IndexProjektwerkstatt

    I

    IIS (Irreducible Infeasible Subset)Anhang C: Fehlerdiagnose-Handbuch
    Implikation (Modellierungsmuster)Anhang B: Katalog der Modellierungsmuster
    Importfehler (ortools und highspy)Anhang C: Fehlerdiagnose-Handbuch
    Instabile Lösung (Fehlerbild)Anhang C: Fehlerdiagnose-Handbuch

    J

    K

    Kanonische StandardformAnhang E: Glossar und Literatur
    Kohärentes RisikomaßAnhang E: Glossar und Literatur

    L

    Lagrange-MultiplikatorAnhang E: Glossar und Literatur
    Laufzeitexplosion (Fehlerbild)Anhang C: Fehlerdiagnose-Handbuch
    Lineare Programmierung (LP)Anhang E: Glossar und Literatur

    M

    Maximum DrawdownAnhang E: Glossar und Literatur

    N

    Nichtlineare Programmierung (NLP)Anhang E: Glossar und Literatur

    O

    P

    Positiv (semi-)definitAnhang E: Glossar und Literatur

    Q

    R

    S

    T

    TransaktionskostenAnhang E: Glossar und Literatur

    U

    Umrüstkosten (Modellierungsmuster)Anhang B: Katalog der Modellierungsmuster
    Unbounded (Fehlerbild)Anhang C: Fehlerdiagnose-Handbuch
    UnsicherheitsmengeAnhang E: Glossar und Literatur
    Unsinniges Ergebnis (Fehlerbild)Anhang C: Fehlerdiagnose-Handbuch

    V

    Vehicle Routing Problem (VRP)Anhang E: Glossar und Literatur
    Verdächtig guter Backtest (Fehlerbild)Anhang C: Fehlerdiagnose-Handbuch
    Vertauschte Spalten (Fehlerbild)Anhang C: Fehlerdiagnose-Handbuch

    W

    Weiche Grenze mit StrafkostenAnhang B: Katalog der Modellierungsmuster
    Widersprüchliche Solver (Fehlerbild)Anhang C: Fehlerdiagnose-Handbuch
    Wurzel-Zeit-RegelAnhang E: Glossar und Literatur

    Z

    Zuordnungsproblem (1:1)Anhang B: Katalog der Modellierungsmuster
    Zwei-Phasen-Simplex-MethodeAnhang A: Lösungen zu allen Übungsaufgaben
    diff --git a/OR_HTML_04/vorwort.html b/OR_HTML_04/vorwort.html index 497b758..ea42f14 100644 --- a/OR_HTML_04/vorwort.html +++ b/OR_HTML_04/vorwort.html @@ -519,241 +519,246 @@ Alles bereit — Sie können mit dem ersten Kapitel beginnen.
    Kapitel 7 +Parallele_Suche.py +num_workers: Tempo gegen Reproduzierbarkeit +Kapitel 7 + + CP_SAT_Statusfaelle.py Die fünf Solver-Antworten Kapitel 7 - + Strafgewichte.py Gewichte als Wechselkurse Kapitel 7 - + Min_Cost_Flow.py Netzwerkfluss Kapitel 8 - + Zuordnung_Ungarisch.py Zuordnung, Unimodularität Kapitel 8 - + VRP_Flotten_Routing.py CVRPTW Kapitel 8 - + VRP_Kapazitaetsfalle.py Vergessene Dimension im Routing Kapitel 8 - + Simulated_Annealing.py Lokale Suche, Temperatur kalibrieren Kapitel 9 - + Metaheuristik_vs_Exakt.py Der Umschlagpunkt, und die Schranke Kapitel 9 - + Large_Neighborhood_Search.py Zerstören und exakt reparieren Kapitel 9 - + Spaltengenerierung.py Muster statt Stücke, Master und Pricing Kapitel 10 - + QP_Grundlagen.py Konvexität, DCP-Check Kapitel 11 - + KKT_Nachweis.py KKT numerisch prüfen Kapitel 11 - + Entropie_Maximierte_Allokation.py NLP mit Entropie Kapitel 11 - + Lokale_Optima_Multistart.py Lokale Optima, Multistart Kapitel 11 - + Fluch_des_Durchschnitts.py Optimum ≠ Mittelwert Kapitel 12 - + Monte_Carlo.py Monte-Carlo-Bewertung Kapitel 12 - + Stochastische_Optimierung.py Two-Stage mit Recourse Kapitel 12 - + Robuste_Optimierung.py Worst-Case-Absicherung Kapitel 12 - + Chance_Constraints.py Zusage „mit 95 % Sicherheit“, SOC und Big-M Kapitel 12 - + Bellman_Minimalbeispiel.py Rückwärtsinduktion Kapitel 13 - + Mehrziel_Pareto.py Pareto-Front, ε-Constraint, Gewichtslücke Kapitel 14 - + Predict_then_Optimize.py MSE gegen Entscheidungskosten Kapitel 15 - + Strukturbruecke.py Derselbe Code über Werkstatt und Depot Kapitel 16 - + Kraftwerkseinsatz.py Unit Commitment unter Windunsicherheit Kapitel 17 - + Mehrperiodige_Order_Execution.py Almgren-Chriss Kapitel 13 - + Renditen_Vergleich.py Diskret vs. logarithmisch Kapitel 18 - + Schaetzrauschen_Demo.py Error-Maximizer messen Kapitel 18 - + Finanzdaten_Ledoit_Wolf.py Datenpipeline, Shrinkage Kapitel 18 - + Kovarianz_Falle.py Singuläre Kovarianz, Error-Maximizer Kapitel 18 - + Markowitz_CVXPY.py GMV, Max Sharpe, Frontier Kapitel 19 - + Diversifikation_Demo.py Korrelation und Portfoliorisiko Kapitel 19 - + Renditeschaetzung_Falle.py Schätzfehler in erwarteten Renditen Kapitel 19 - + VaR_CVaR_Demo.py Fat Tails, Subadditivität Kapitel 20 - + CVaR_Portfolio.py CVaR mit Reibung Kapitel 20 - + QuantitativeTradingEngine.py Walk-Forward-Backtest Kapitel 21 - + Backtest_Fallen.py Fünf Selbsttäuschungen Kapitel 21 - + Data_Snooping.py Bestes aus N Versuchen auf Rauschen Kapitel 21 - + Infeasibility_Diagnose.py Notfallplan statt Fehler Kapitel 22 - + Erklaerbarkeit.py Constraint-Trace, Was-wäre-wenn Kapitel 22 - + Constraint_Attribution.py Was kostet welche Bedingung? Managementbericht Kapitel 22 - + Betriebsueberwachung.py Status, Gap und Zeitausschöpfung Kapitel 22 - + or_kern.py Gemeinsamer Unterbau: Domäne, Status, Prüfung Kapitel 22 - + Solverwechsel_CPSAT_HiGHS.py Derselbe Fall in zwei Solvern Kapitel 22 - + test_or_kern.py Testsuite für ein Optimierungsmodell Kapitel 23 - + Mutationstest.py Findet die Lücken der eigenen Tests Kapitel 23 - + Benchmark_Skalierung.py Aufbau gegen Lösen, vier Bibliotheken Kapitel 23 - + Optimierungsdienst.py Das Modell als HTTP-Dienst Kapitel 23 - + Konfliktsuche.py Deletion Filter: den Widerspruch einkreisen Anhang C diff --git a/Operations_Research_mit_Python_Version_04.md b/Operations_Research_mit_Python_Version_04.md index ed2d241..45f5f3c 100644 --- a/Operations_Research_mit_Python_Version_04.md +++ b/Operations_Research_mit_Python_Version_04.md @@ -341,6 +341,7 @@ Alle Beispielprogramme dieses Buchs im Überblick, sortiert nach Kapitel — pra | `Propagation_Demo.py` | Propagation messbar | [Kapitel 7](#kap-cpsat) | | `CP_SAT_Vertretungssystem.py` | Vertretungsplan | [Kapitel 7](#kap-cpsat) | | `JobShop_Intervalle.py` | Job-Shop-Scheduling | [Kapitel 7](#kap-cpsat) | +| `Parallele_Suche.py` | num_workers: Tempo gegen Reproduzierbarkeit | [Kapitel 7](#kap-cpsat) | | `CP_SAT_Statusfaelle.py` | Die fünf Solver-Antworten | [Kapitel 7](#kap-cpsat) | | `Strafgewichte.py` | Gewichte als Wechselkurse | [Kapitel 7](#kap-cpsat) | | `Min_Cost_Flow.py` | Netzwerkfluss | [Kapitel 8](#kap-graphen) | @@ -4412,7 +4413,7 @@ Dieselbe Regel als Strafkosten je abgelehntem Stammkundenauftrag: Bei 3 000 € ändert sich **nichts**: Die Strafe liegt unter dem Deckungsbeitrag, den die Stammkundenaufträge verdrängen würden. Bei 10 000 € kommt genau Lesart A heraus. -Daraus folgt eine Einsicht, die das Modellieren erleichtert: **Eine harte Regel ist eine weiche mit hinreichend großer Strafe.** Man kann also immer weich anfangen und die Strafe hochdrehen, bis die Regel greift — und sieht dabei, was sie kostet. Was ein Strafgewicht darüber hinaus bedeutet und warum zwei Gewichte einen *Wechselkurs* festlegen und keine Wichtigkeiten, steht in [Abschnitt 7.9](#sec:cpsat-denkfehler). +Daraus folgt eine Einsicht, die das Modellieren erleichtert: **Eine harte Regel ist eine weiche mit hinreichend großer Strafe.** Man kann also immer weich anfangen und die Strafe hochdrehen, bis die Regel greift — und sieht dabei, was sie kostet. Was ein Strafgewicht darüber hinaus bedeutet und warum zwei Gewichte einen *Wechselkurs* festlegen und keine Wichtigkeiten, steht in [Abschnitt 7.10](#sec:cpsat-denkfehler). ### Teil 4: Ein Quartal später @@ -6909,7 +6910,8 @@ def loese(dauer: np.ndarray, n_maschinen: int, zeitlimit: float, loeser = cp_model.CpSolver() loeser.parameters.max_time_in_seconds = zeitlimit - # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist. + # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist - + # der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming). # Im Produktivbetrieb laesst man beides auf den Standardwerten. loeser.parameters.num_workers = 1 loeser.parameters.random_seed = 1 @@ -7366,6 +7368,7 @@ funktioniert immer. > `Propagation_Demo.py`\ > `CP_SAT_Vertretungssystem.py`\ > `JobShop_Intervalle.py`\ +> `Parallele_Suche.py`\ > `CP_SAT_Statusfaelle.py`\ > `Strafgewichte.py` > @@ -7419,7 +7422,7 @@ funktioniert immer. Nein — und das ist die wichtigste Lektion dieses Kapitels. Der Plan erfüllt **jede** Regel, die wir aufgeschrieben haben. Wir haben nur nie nach Fairness gefragt, und wir haben nie gesagt, dass Dienste nicht aufeinanderfolgen sollen. Ein Solver liefert exakt das Bestellte — nicht das Gemeinte. -> **🎯 Merksatz** Ein Modell, das durchläuft, ist noch kein richtiges Modell. Die eigentliche Arbeit im Constraint Programming ist nicht das Lösen, sondern das vollständige Aufschreiben dessen, was „ein guter Plan“ überhaupt heißt. Wie man Wünsche wie Fairness als **weiche** Bedingungen mit Strafkosten formuliert, zeigt [Abschnitt 7.5](#sec:cpsat-praxisfall-dynamisches-vertretungssystem) — und [Abschnitt 7.9](#sec:cpsat-denkfehler) zeigt, was passiert, wenn man ihr Gewicht falsch wählt. +> **🎯 Merksatz** Ein Modell, das durchläuft, ist noch kein richtiges Modell. Die eigentliche Arbeit im Constraint Programming ist nicht das Lösen, sondern das vollständige Aufschreiben dessen, was „ein guter Plan“ überhaupt heißt. Wie man Wünsche wie Fairness als **weiche** Bedingungen mit Strafkosten formuliert, zeigt [Abschnitt 7.5](#sec:cpsat-praxisfall-dynamisches-vertretungssystem) — und [Abschnitt 7.10](#sec:cpsat-denkfehler) zeigt, was passiert, wenn man ihr Gewicht falsch wählt. --- @@ -7433,7 +7436,8 @@ Nach diesem Kapitel können Sie … 4. … mit **Intervallvariablen**`\index{Intervallvariable}`{=latex} ein Maschinenbelegungsproblem modellieren. 5. … harte Regeln und weiche Ziele in einem Modell kombinieren und gewichten. 6. … die fünf CP-SAT-Statusfälle unterscheiden — insbesondere `INFEASIBLE` (es gibt keine Lösung) von `UNKNOWN` (es wurde keine gefunden) und `MODEL_INVALID` (Ihr Code ist fehlerhaft). -7. … begründen, warum ein Strafgewicht ein **Wechselkurs** ist, und welche Regeln deshalb niemals in die Zielfunktion gehören. +7. … begründen, warum `num_workers = 1` für einen reproduzierbaren Lauf nötig ist und ein fester `random_seed` allein nicht genügt — und was ein Test deshalb prüfen darf. +8. … begründen, warum ein Strafgewicht ein **Wechselkurs** ist, und welche Regeln deshalb niemals in die Zielfunktion gehören. --- @@ -8064,6 +8068,8 @@ Erreichter Makespan: 11 > > Deshalb setzt das Programm oben `num_workers = 1` und `random_seed = 1`: nur so ist die abgedruckte Ausgabe reproduzierbar. **Im Produktivbetrieb lässt man beides weg** — mehrere Arbeiter sind deutlich schneller. > +> Wie viel schneller, und warum der Seed allein **nicht** genügt, misst [Abschnitt 7.7](#sec:cpsat-parallele-suche) an genau diesem Modell. +> > Die Lehre für Ihre eigenen Tests: Prüfen Sie bei mehrdeutigen Optima nie auf einen **bestimmten Plan**, sondern immer auf den **Zielwert** und auf die Einhaltung aller Regeln. Ein Test wie `assert plan == erwarteter_plan` besteht sonst mal und scheitert mal, ohne dass sich am Code etwas geändert hätte. Bemerkenswert ist die letzte Zeile: Die untere Schranke liegt bei 10, erreicht wurden 11. Die Schranke ist also **nicht erreichbar** — kein Plan kann 10 schaffen, obwohl weder ein einzelner Auftrag noch eine einzelne Maschine mehr als 10 Zeiteinheiten braucht. Der Grund sind die Wechselwirkungen. Das ist typisch für Scheduling: **Einfache Schranken sind selten scharf.** Trotzdem sind sie nützlich — sie sagten uns hier, dass wir höchstens 10 % vom theoretischen Ideal entfernt sein können, noch bevor der Solver fertig war. @@ -8074,7 +8080,302 @@ Bemerkenswert ist die letzte Zeile: Die untere Schranke liegt bei 10, erreicht w --- -## 7.7 Die fünf Antworten von CP-SAT {#sec:cpsat-statusfaelle} +## 7.7 Parallele Suche: was `num_workers` wirklich bewirkt {#sec:cpsat-parallele-suche} + +An mehreren Stellen dieses Buchs steht `loeser.parameters.num_workers = 1`, mit dem Kommentar „für eine reproduzierbare Ausgabe“. Darin stecken zwei Behauptungen: dass parallele Suche **schneller** ist, und dass sie **von Lauf zu Lauf andere, gleich gute** Lösungen liefert. Beides lässt sich messen — und beim Messen kommt mehr heraus als erwartet. + +Gerechnet wird auf demselben Modell wie [Abschnitt 7.6](#sec:cpsat-intervallvariablen-job-shop-scheduling), nur groß genug, dass die Suche wirklich arbeitet: 12 Aufträge auf 10 Maschinen, jeder Auftrag besucht jede Maschine. Entscheidend ist, dass `random_seed` in **jedem** Lauf denselben Wert hat. + +```python +#!/usr/bin/env python3 + +# Parallele_Suche.py +""" +Kapitel CP-SAT: Was mehrere Arbeiter wirklich bringen - und was sie kosten. + +Das Buch setzt an mehreren Stellen 'num_workers = 1', damit die abgedruckte +Ausgabe reproduzierbar ist. Behauptet wird dabei zweierlei: dass parallele +Suche schneller ist, und dass sie von Lauf zu Lauf verschiedene, gleich gute +Loesungen findet. Beides wird hier gemessen statt geglaubt. + +Gerechnet wird auf einem Job-Shop wie in JobShop_Intervalle.py, nur gross +genug, dass die Suche wirklich arbeitet: 12 Auftraege auf 10 Maschinen, +jeder Auftrag besucht jede Maschine. + +Zwei Messungen: + 1. Dieselbe Aufgabe mit 1, 2, 4 und 8 Arbeitern - Laufzeit und Ergebnis. + 2. Dieselbe Konfiguration mehrfach, bei FESTEM random_seed - wie oft kommt + derselbe Plan heraus? + +Die zweite ist die wichtigere: Sie entscheidet, wie man Tests schreibt. + +Achtung, Laufzeiten sind hardwareabhaengig. Die ZAHL der verschiedenen +Plaene ist es auch - und genau das ist die Aussage. + +Benoetigt: numpy, ortools +""" + +from __future__ import annotations + +import collections +import time + +import numpy as np +from ortools.sat.python import cp_model + +AUFTRAEGE = 12 +MASCHINEN = 10 +INSTANZ_SEED = 20260908 +SOLVER_SEED = 1 # bleibt ueber ALLE Laeufe gleich - das ist der Punkt +ARBEITERZAHLEN = (1, 2, 4, 8) +WIEDERHOLUNGEN = 4 +ZEITLIMIT = 120.0 + + +def baue_instanz() -> list[list[tuple[int, int]]]: + """Klassischer Job-Shop: Jeder Auftrag besucht jede Maschine genau einmal, + in einer eigenen zufaelligen Reihenfolge.""" + rng = np.random.default_rng(INSTANZ_SEED) + return [[(int(m), int(rng.integers(2, 20))) for m in rng.permutation(MASCHINEN)] + for _ in range(AUFTRAEGE)] + + +def loese(auftraege, arbeiter: int): + """Minimiert den Makespan. Gibt Status, Zielwert, Laufzeit und den Plan + zurueck - den Plan als Tupel aller Startzeiten, damit sich zwei Laeufe + vergleichen lassen.""" + horizont = sum(dauer for auftrag in auftraege for _, dauer in auftrag) + modell = cp_model.CpModel() + Gang = collections.namedtuple("Gang", "start ende intervall") + plaene: dict[tuple[int, int], Gang] = {} + je_maschine = collections.defaultdict(list) + + for a, auftrag in enumerate(auftraege): + for g, (maschine, dauer) in enumerate(auftrag): + start = modell.NewIntVar(0, horizont, f"start_{a}_{g}") + ende = modell.NewIntVar(0, horizont, f"ende_{a}_{g}") + intervall = modell.NewIntervalVar(start, dauer, ende, f"iv_{a}_{g}") + plaene[a, g] = Gang(start, ende, intervall) + je_maschine[maschine].append(intervall) + + for maschine in range(MASCHINEN): + modell.AddNoOverlap(je_maschine[maschine]) + for a, auftrag in enumerate(auftraege): + for g in range(len(auftrag) - 1): + modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende) + + makespan = modell.NewIntVar(0, horizont, "makespan") + modell.AddMaxEquality( + makespan, [plaene[a, len(auftrag) - 1].ende + for a, auftrag in enumerate(auftraege)]) + modell.Minimize(makespan) + + loeser = cp_model.CpSolver() + loeser.parameters.num_workers = arbeiter + loeser.parameters.random_seed = SOLVER_SEED + loeser.parameters.max_time_in_seconds = ZEITLIMIT + + beginn = time.perf_counter() + status = loeser.Solve(modell) + dauer = time.perf_counter() - beginn + + if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE): + return loeser.StatusName(status), None, dauer, None + plan = tuple(loeser.Value(plaene[a, g].start) + for a, auftrag in enumerate(auftraege) + for g in range(len(auftrag))) + return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer, plan + + +if __name__ == "__main__": + auftraege = baue_instanz() + gaenge = sum(len(a) for a in auftraege) + + print("=" * 78) + print(" WAS MEHRERE ARBEITER WIRKLICH BRINGEN") + print("=" * 78) + print(f"Job-Shop: {AUFTRAEGE} Auftraege, {MASCHINEN} Maschinen, " + f"{gaenge} Arbeitsgaenge.") + print(f"random_seed = {SOLVER_SEED} in JEDEM Lauf, " + f"{WIEDERHOLUNGEN} Wiederholungen je Arbeiterzahl.\n") + + # Jede Konfiguration mehrfach - ein einzelner Zeitwert sagt nichts. + ergebnisse: dict[int, list[tuple]] = {} + for arbeiter in ARBEITERZAHLEN: + ergebnisse[arbeiter] = [loese(auftraege, arbeiter) + for _ in range(WIEDERHOLUNGEN)] + + print("=" * 78) + print(" (1) Laufzeit: was die Arbeiter bringen") + print("=" * 78) + print(f"{'Arbeiter':>9} {'Status':>9} {'Makespan':>9} " + f"{'schnellste':>11} {'langsamste':>11} {'Beschleunigung':>15}") + print("-" * 78) + basis_zeit = min(d for _, _, d, _ in ergebnisse[1]) + for arbeiter in ARBEITERZAHLEN: + laeufe = ergebnisse[arbeiter] + zeiten = [d for _, _, d, _ in laeufe] + ziele = {o for _, o, _, _ in laeufe} + status = {s for s, _, _, _ in laeufe} + print(f"{arbeiter:>9} {'/'.join(sorted(status)):>9} " + f"{'/'.join(str(z) for z in sorted(ziele)):>9} " + f"{min(zeiten):>10.2f}s {max(zeiten):>10.2f}s " + f"{basis_zeit / min(zeiten):>14.1f}x") + + beste = min(ARBEITERZAHLEN, key=lambda w: min(d for _, _, d, _ in ergebnisse[w])) + faktor = basis_zeit / min(d for _, _, d, _ in ergebnisse[beste]) + print(f"\n Am schnellsten sind {beste} Arbeiter: Faktor {faktor:.1f} gegenueber") + print(f" einem einzigen - also MEHR, als {beste} Kerne hergeben sollten.") + print(f" Das ist kein Messfehler. CP-SAT laesst nicht {beste}-mal dieselbe Suche") + print(" laufen, sondern verschiedene Strategien nebeneinander, die einander") + print(" ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der") + print(" 'zweite Kern', sondern ein anderes Verfahren.") + letzte = ARBEITERZAHLEN[-1] + if beste != letzte: + zeit_letzte = min(d for _, _, d, _ in ergebnisse[letzte]) + print(f"\n Und mehr ist nicht immer besser: {letzte} Arbeiter brauchen " + f"{zeit_letzte:.2f}s") + print(f" gegenueber {min(d for _, _, d, _ in ergebnisse[beste]):.2f}s bei " + f"{beste}. Ab einer gewissen Zahl kosten Abstimmung") + print(" und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt.") + + print("\n" + "=" * 78) + print(" (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?") + print("=" * 78) + print(f"{'Arbeiter':>9} {'Laeufe':>7} {'verschiedene Plaene':>21} " + f"{'Makespan':>10}") + print("-" * 78) + for arbeiter in ARBEITERZAHLEN: + plaene = {p for _, _, _, p in ergebnisse[arbeiter] if p is not None} + ziele = {o for _, o, _, _ in ergebnisse[arbeiter]} + print(f"{arbeiter:>9} {WIEDERHOLUNGEN:>7} {len(plaene):>21} " + f"{'/'.join(str(z) for z in sorted(ziele)):>10}") + + einer = len({p for _, _, _, p in ergebnisse[1]}) + # Ab welcher Arbeiterzahl bricht die Reproduzierbarkeit? Das ist die + # eigentliche Zahl - nicht, was acht Arbeiter anrichten. + ab = next((w for w in ARBEITERZAHLEN + if len({p for _, _, _, p in ergebnisse[w]}) > 1), None) + print(f"\n Mit einem Arbeiter: {einer} Plan aus {WIEDERHOLUNGEN} Laeufen.") + if ab is not None: + viele = len({p for _, _, _, p in ergebnisse[ab]}) + print(f" Schon mit {ab} Arbeitern: {viele} verschiedene Plaene aus " + f"{WIEDERHOLUNGEN} Laeufen -") + print(" bei identischem random_seed und identischem Zielwert.") + print(f" Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit") + print(f" zu verlieren. {ab} genuegen.") + print("\n Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt") + print(" fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von") + print(" mehreren zuerst fertig wird. Das entscheidet die Uhr.") + + print("\n" + "=" * 78) + print(" (3) Was daraus fuer Tests folgt") + print("=" * 78) + print(" Ein Test der Form assert plan == erwarteter_plan besteht mal und") + print(" scheitert mal, ohne dass sich am Code etwas geaendert haette.") + print(" Zu pruefen sind stattdessen:") + print(" * der ZIELWERT (hier in allen Laeufen gleich),") + print(" * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),") + print(" * und der Status - nicht die Gestalt der Loesung.") + print("\n Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte") + print(" Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.") + print(f" Das kostet hier den Faktor {faktor:.1f} an Laufzeit und ist genau") + print(" deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.") + print("=" * 78) +``` + +**Erwartete Ausgabe** (Laufzeiten *und* die Zahl der verschiedenen Pläne sind hardwareabhängig — genau das ist die Aussage; der Zielwert ist es nicht): + +``` +============================================================================== + WAS MEHRERE ARBEITER WIRKLICH BRINGEN +============================================================================== +Job-Shop: 12 Auftraege, 10 Maschinen, 120 Arbeitsgaenge. +random_seed = 1 in JEDEM Lauf, 4 Wiederholungen je Arbeiterzahl. + +============================================================================== + (1) Laufzeit: was die Arbeiter bringen +============================================================================== + Arbeiter Status Makespan schnellste langsamste Beschleunigung +------------------------------------------------------------------------------ + 1 OPTIMAL 183 5.62s 5.82s 1.0x + 2 OPTIMAL 183 1.43s 2.04s 3.9x + 4 OPTIMAL 183 0.60s 0.66s 9.4x + 8 OPTIMAL 183 0.46s 0.65s 12.3x + + Am schnellsten sind 8 Arbeiter: Faktor 12.3 gegenueber + einem einzigen - also MEHR, als 8 Kerne hergeben sollten. + Das ist kein Messfehler. CP-SAT laesst nicht 8-mal dieselbe Suche + laufen, sondern verschiedene Strategien nebeneinander, die einander + ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der + 'zweite Kern', sondern ein anderes Verfahren. + +============================================================================== + (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan? +============================================================================== + Arbeiter Laeufe verschiedene Plaene Makespan +------------------------------------------------------------------------------ + 1 4 1 183 + 2 4 3 183 + 4 4 3 183 + 8 4 4 183 + + Mit einem Arbeiter: 1 Plan aus 4 Laeufen. + Schon mit 2 Arbeitern: 3 verschiedene Plaene aus 4 Laeufen - + bei identischem random_seed und identischem Zielwert. + Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit + zu verlieren. 2 genuegen. + + Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt + fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von + mehreren zuerst fertig wird. Das entscheidet die Uhr. + +============================================================================== + (3) Was daraus fuer Tests folgt +============================================================================== + Ein Test der Form assert plan == erwarteter_plan besteht mal und + scheitert mal, ohne dass sich am Code etwas geaendert haette. + Zu pruefen sind stattdessen: + * der ZIELWERT (hier in allen Laeufen gleich), + * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen), + * und der Status - nicht die Gestalt der Loesung. + + Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte + Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1. + Das kostet hier den Faktor 12.3 an Laufzeit und ist genau + deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb. +============================================================================== +``` + +### Was die Messung zeigt + +**Erstens: Die Beschleunigung ist überlinear.** Acht Arbeiter sind im abgedruckten Lauf nicht achtmal, sondern **gut zwölfmal** schneller als einer. Das sieht nach Messfehler aus, ist aber die korrekte Beschreibung dessen, was CP-SAT tut: Es lässt **nicht** achtmal dieselbe Suche auf verschiedenen Kernen laufen, sondern **verschiedene Strategien nebeneinander** — Suche mit LP-Relaxation, Suche mit anderen Verzweigungsregeln, lokale Suche —, und die teilen einander ihre Schranken mit. Der zweite Arbeiter ist deshalb nicht „der zweite Kern“, sondern ein anderes Verfahren, das zufällig auch rechnet. + +Daraus folgt zweierlei. Ein Speedup über der Kernzahl ist bei CP-SAT normal und kein Grund zum Misstrauen. Und der Zugewinn flacht ab: Zwischen vier und acht Arbeitern lag in unseren Wiederholungen mal das eine, mal das andere vorn — ab einer gewissen Zahl kosten Abstimmung und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt. Auch hier gilt: messen, nicht schätzen. + +**Zweitens — und das ist der Befund, der die Testpraxis bestimmt: Der Seed genügt nicht.** Bei einem Arbeiter liefern vier Läufe **einen** Plan. Schon bei **zwei** Arbeitern liefern vier Läufe **drei verschiedene** — bei identischem `random_seed` und identischem Zielwert 183. + +Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit zu verlieren. Zwei genügen. Der Grund ist einfach, sobald man ihn ausspricht: Der Seed legt fest, wie ein **einzelner** Suchstrang würfelt. Er legt nicht fest, **welcher von mehreren zuerst fertig wird** — das entscheidet die Uhr, und die ist bei jedem Lauf anders. + +> **⚠️ `random_seed` allein macht nichts reproduzierbar** Ein häufiger Irrtum: „Ich habe doch einen Seed gesetzt.“ Für einen Zufallsgenerator stimmt das. Für einen **parallelen** Solver nicht — dort ist die zweite Zufallsquelle die Ausführungsreihenfolge, und die lässt sich nicht mit einem Parameter festnageln. Reproduzierbar wird der Lauf erst mit `num_workers = 1` **und** Seed. Wer nur eines von beidem setzt, hat einen Test, der gelegentlich grundlos rot wird. + +### Was daraus für Tests folgt + +Der Zielwert war in **allen** sechzehn Läufen derselbe: 183. Die Gestalt der Lösung war es nicht. Genau daran entscheidet sich, was ein Test prüfen darf: + +| Prüfen | Nicht prüfen | +| --- | --- | +| den **Zielwert** — er ist eindeutig, wenn das Modell optimal gelöst wurde | die **Gestalt** des Plans (`assert plan == erwarteter_plan`) | +| die **Einhaltung aller Regeln** über eine Abnahmeprüfung ([Abschnitt 22.6](#sec:praxisfallen-or-kern)) | die Reihenfolge, in der gleichwertige Aufträge zugewiesen wurden | +| den **Status** (`OPTIMAL`, `FEASIBLE`, `INFEASIBLE`) | die Laufzeit als feste Zahl | + +Wer doch einen bestimmten Plan braucht — für eine abgedruckte Ausgabe, für einen Regressionstest —, setzt `num_workers = 1`. Das kostet hier den Faktor 12 an Laufzeit, und genau deshalb ist es eine Entscheidung **für den Test**, nicht für den Betrieb. In Produktion lässt man beide Parameter auf ihren Standardwerten. + +> **🎯 Merksatz** Ein paralleler Solver hat zwei Zufallsquellen: den Seed und die Uhr. Nur die erste lässt sich setzen. Wer Reproduzierbarkeit braucht, muss die zweite abschalten — und das heißt `num_workers = 1`. + +--- + +## 7.8 Die fünf Antworten von CP-SAT {#sec:cpsat-statusfaelle} [Abschnitt 6.8](#sec:milp-gap) hat gezeigt, warum `OPTIMAL` bei ganzzahligen Problemen nicht der Normalfall ist. Bei CP-SAT kommt eine Besonderheit hinzu: Es gibt **fünf** verschiedene Antworten, und drei davon werden regelmäßig verwechselt. @@ -8391,7 +8692,7 @@ genau die Information, die zur Fehlersuche noetig waere. --- -## 7.8 Übungsaufgaben {#sec:cpsat-uebungsaufgaben} +## 7.9 Übungsaufgaben {#sec:cpsat-uebungsaufgaben} > Lösungen: [Abschnitt A.7](#sec:loesungen-cpsat). @@ -8405,13 +8706,15 @@ genau die Information, die zur Fehlersuche noetig waere. **Aufgabe 7.5 ⭐⭐ — Sudoku.** Lösen Sie ein 9×9-Sudoku mit CP-SAT. Sie brauchen nur `NewIntVar(1, 9, ...)` und `AddAllDifferent` für Zeilen, Spalten und die neun 3×3-Blöcke. Wie viele Zeilen Modellcode benötigen Sie? -**Aufgabe 7.6 ⭐⭐⭐ — Job-Shop erweitern.** Ergänzen Sie `JobShop_Intervalle.py` um: (a) Rüstzeiten von 1 Zeiteinheit zwischen zwei Aufträgen auf derselben Maschine. (b) Fälligkeitstermine je Auftrag mit Strafkosten für Verspätung. (c) Eine zweite Fräse (Kapazität 2 statt 1) mit `AddCumulative`. +**Aufgabe 7.6 ⭐⭐ — Die Grenze der Parallelität finden.** `Parallele_Suche.py` misst 1, 2, 4 und 8 Arbeiter. (a) Ergänzen Sie 16 und 24 (bzw. die Kernzahl Ihres Rechners). Ab wo bringt ein weiterer Arbeiter nichts mehr — und wird es irgendwann wieder schlechter? (b) Setzen Sie `random_seed` bei jedem Lauf auf einen **anderen** Wert, aber `num_workers = 1`. Wie viele verschiedene Pläne kommen jetzt heraus? Was folgt daraus für die Frage, welcher der beiden Parameter die Reproduzierbarkeit sichert? (c) Vergrößern Sie die Instanz auf 15 Aufträge. Ab welcher Größe erreicht der Lauf mit einem Arbeiter das Zeitlimit, während acht noch `OPTIMAL` melden? -**Aufgabe 7.7 ⭐⭐⭐ — Eigener Dienstplan.** Modellieren Sie einen Wochendienstplan: 7 Tage, 3 Schichten (früh/spät/nacht), 8 Mitarbeitende. Regeln: Jede Schicht 2 Personen; nach Nachtschicht mindestens 2 Tage frei; höchstens 5 Arbeitstage in Folge; jede Person mindestens 2 freie Tage pro Woche; Wünsche als weiche Ziele. Geben Sie den Plan als Wochentabelle aus. +**Aufgabe 7.7 ⭐⭐⭐ — Job-Shop erweitern.** Ergänzen Sie `JobShop_Intervalle.py` um: (a) Rüstzeiten von 1 Zeiteinheit zwischen zwei Aufträgen auf derselben Maschine. (b) Fälligkeitstermine je Auftrag mit Strafkosten für Verspätung. (c) Eine zweite Fräse (Kapazität 2 statt 1) mit `AddCumulative`. + +**Aufgabe 7.8 ⭐⭐⭐ — Eigener Dienstplan.** Modellieren Sie einen Wochendienstplan: 7 Tage, 3 Schichten (früh/spät/nacht), 8 Mitarbeitende. Regeln: Jede Schicht 2 Personen; nach Nachtschicht mindestens 2 Tage frei; höchstens 5 Arbeitstage in Folge; jede Person mindestens 2 freie Tage pro Woche; Wünsche als weiche Ziele. Geben Sie den Plan als Wochentabelle aus. --- -## 7.9 Finde den Denkfehler {#sec:cpsat-denkfehler} +## 7.10 Finde den Denkfehler {#sec:cpsat-denkfehler} > **🐛 Finde den Denkfehler: Die Betriebsvereinbarung, die niemanden interessiert** > @@ -8619,7 +8922,7 @@ Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft. --- -## 7.10 Micro-Quiz {#sec:cpsat-quiz} +## 7.11 Micro-Quiz {#sec:cpsat-quiz} > **❓ Micro-Quiz 7: Drei Fragen zum Selbstcheck** > @@ -8633,7 +8936,7 @@ Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft. --- -## 7.11 Selbsttest {#sec:cpsat-selbsttest} +## 7.12 Selbsttest {#sec:cpsat-selbsttest} > Antworten: [Anhang A](#selbsttest-loesung-cpsat). @@ -8641,11 +8944,13 @@ Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft. 2. Welchen Vorteil hat `AddAllDifferent(x)` gegenüber $\binom{n}{2}$ Einzelbedingungen? 3. Was ist eine Intervallvariable, und welche Bedingung garantiert sie automatisch? 4. Warum sollten möglichst wenige Bedingungen hart formuliert werden? -5. Warum ist die Aufschlüsselung der Strafkosten in der Praxis wichtiger als der Zielwert selbst? +5. Warum ist die Aufschlüsselung der Strafkosten in der Praxis wichtiger als der Zielwert +6. Sie setzen `random_seed = 1`, bekommen aber trotzdem bei jedem Lauf einen anderen Plan. Woran liegt das, und was hilft? +7. Warum kann ein CP-SAT-Lauf mit acht Arbeitern **mehr** als achtmal schneller sein als mit einem? selbst? --- -## 7.12 Zusammenfassung {#sec:cpsat-zusammenfassung} +## 7.13 Zusammenfassung {#sec:cpsat-zusammenfassung} * **CP denkt in Wertebereichen und Regeln**, nicht in Ungleichungen und Schranken. * **Propagation** streicht unmögliche Werte, bevor gesucht wird; **Conflict Learning**`\index{CDCL!Conflict Learning}`{=latex} verhindert, dieselbe Sackgasse zweimal zu betreten. @@ -8656,7 +8961,8 @@ Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft. * **Fünf Antworten, nicht zwei.** `INFEASIBLE` (es gibt keine Lösung), `UNKNOWN` (es wurde keine gefunden) und `MODEL_INVALID` (Ihr Code ist fehlerhaft) bedeuten Grundverschiedenes. Wer sie in ein `else` zusammenfasst, verliert die Information zur Fehlersuche. * **Bei mehrdeutigen Optima nie auf einen bestimmten Plan testen** — nur auf den Zielwert und die Einhaltung aller Regeln. -**Ausblick.** [Kapitel 8](#kap-graphen) behandelt Probleme, die sich am besten als **Graph** begreifen lassen: Flüsse durch Netzwerke, Zuordnungen und die Tourenplanung mit Fahrzeugen und Zeitfenstern. +* **Ein paralleler Solver hat zwei Zufallsquellen: den Seed und die Uhr.** Nur die erste lässt sich setzen. Reproduzierbar wird ein Lauf erst mit `num_workers = 1` **und** Seed — gemessen in [Abschnitt 7.7](#sec:cpsat-parallele-suche): schon zwei Arbeiter liefern bei identischem Seed drei verschiedene Pläne zum selben Zielwert. +* **Parallele Suche kann überlinear beschleunigen**, weil CP-SAT nicht dieselbe Suche vervielfacht, sondern verschiedene Strategien nebeneinander laufen lässt, die einander ihre Schranken mitteilen. **Ausblick.** [Kapitel 8](#kap-graphen) behandelt Probleme, die sich am besten als **Graph** begreifen lassen: Flüsse durch Netzwerke, Zuordnungen und die Tourenplanung mit Fahrzeugen und Zeitfenstern. --- @@ -14808,7 +15114,7 @@ $$ > | --- | --- | > | $c(x)$ | Transportkosten des Plans $x$, in Euro. | > | $e(x)$ | CO₂-Ausstoß desselben Plans, in Kilogramm. | -> | $w$ | „So viele Euro ist mir ein Kilogramm CO₂ wert.“ Ein **Wechselkurs**, kein Wichtigkeitsregler ([Abschnitt 7.9](#sec:cpsat-denkfehler)). | +> | $w$ | „So viele Euro ist mir ein Kilogramm CO₂ wert.“ Ein **Wechselkurs**, kein Wichtigkeitsregler ([Abschnitt 7.10](#sec:cpsat-denkfehler)). | > | $\min_{x \in X}$ | über alle zulässigen Pläne. | > > Der Ausdruck hat eine Einheit: Euro. Damit ist $w$ keine freie Zahl zwischen 0 und 1, sondern ein CO₂-Preis in €/kg — und den kann man tatsächlich beziffern. @@ -21993,7 +22299,7 @@ Domaenenschicht. > | --- | --- | --- | > | `PositiveZahl = Annotated[float, Field(gt=0)]` | Prüfung im **Typ**, nicht im Code | Sie gilt damit überall, wo der Typ verwendet wird — man kann sie nicht vergessen. | > | `SolverStatus.brauchbar` | fragt „habe ich etwas in der Hand?“ | Nicht „war der Solver erfolgreich?“. `ZULAESSIG` ist im Betrieb meist völlig ausreichend ([Abschnitt 6.8](#sec:milp-gap)). | -> | `SolverStatus.modellfehler` | trennt Modell- von Zeitproblemen | Genau die Unterscheidung, die [Abschnitt 7.7](#sec:cpsat-statusfaelle) als häufigste Verwechslung benennt. | +> | `SolverStatus.modellfehler` | trennt Modell- von Zeitproblemen | Genau die Unterscheidung, die [Abschnitt 7.8](#sec:cpsat-statusfaelle) als häufigste Verwechslung benennt. | > | `model_validator(mode="after")` | prüft Zusammenhänge **zwischen** Feldern | Einzelne Felder prüft Pydantic selbst; dass jedes Produkt nur bekannte Ressourcen verbraucht, muss man sagen. | > | `ressourcen` als sortierte Liste | feste Spaltenreihenfolge | Die Vertauschungsfalle aus [Kapitel 18](#kap-finanzdaten) wird hier strukturell unmöglich. | > | `loese_mit_glop` / `loese_mit_scipy` | zwei Modellbauer, ein Auswertungscode | Der Solverwechsel betrifft **eine** Funktion. Alles danach — Bericht, Prüfung, Test — bleibt unverändert. | @@ -25087,9 +25393,18 @@ for i in range(9): ``` **Etwa 10 Zeilen Modellcode** — und der Solver löst jedes Sudoku in Millisekunden. Das ist die Stärke globaler Constraints. -**7.6 — Job-Shop erweitern.** (a) Rüstzeiten: `AddNoOverlap` durch paarweise Disjunktionen mit Übergangszeit ersetzen, oder `AddCircuit` je Maschine mit Übergangsmatrix. (b) Verspätung: `tardiness = MaxEquality(0, ende - faellig)`, in die Zielfunktion. (c) `AddCumulative(intervalle, [1]*n, 2)` statt `AddNoOverlap` für die Doppelmaschine. +**7.6 — Die Grenze der Parallelität finden.** (a) Der Gewinn flacht ab und kehrt sich um. Auf einem Rechner mit 24 Kernen gemessen (Faktor gegenüber einem Arbeiter): 8 Arbeiter **10,2**, 16 Arbeiter **12,3**, 24 Arbeiter **9,0**. Wer alle Kerne belegt, ist langsamer als mit zwei Dritteln — dem Betriebssystem, dem Speicherbus und den Suchsträngen untereinander bleibt zu wenig Luft. Die Faustregel „so viele Arbeiter wie Kerne" ist also falsch; die richtige Zahl ist eine Messung, keine Einstellung. (b) **Fünf verschiedene Pläne aus fünf Seeds** — bei `num_workers = 1` und identischem Zielwert 183. Das ist die andere Hälfte der Antwort: Der Seed *wirkt*, aber nur auf den einzelnen Suchstrang. Es sichert also **keiner der beiden Parameter allein** die Reproduzierbarkeit: -**7.7 — Wochendienstplan.** Siehe Projekt P2. Kernpunkte: Nachtschicht-Folgeregel über `AddImplication`; „höchstens 5 Tage in Folge“ über gleitende Fenster (`sum(x[p,t..t+5]) <= 5`). +| | Seed fest | Seed variabel | +| --- | --- | --- | +| **1 Arbeiter** | reproduzierbar | 5 Pläne aus 5 Läufen | +| **mehrere Arbeiter** | 3 Pläne aus 4 Läufen | erst recht nicht | + +Nur die Kombination trägt. (c) Bei **15 Aufträgen** (60 s Zeitlimit): Ein Arbeiter läuft ins Limit und meldet `FEASIBLE` mit Makespan 200; acht Arbeiter melden `OPTIMAL` mit **demselben** Makespan 200 nach 26,4 s. Bei 13 Aufträgen sind es 11,2 s gegen 2,8 s, bei 14 schon 21,4 s gegen 3,7 s. Bemerkenswert ist, dass beide denselben Wert finden — der Unterschied liegt nicht in der Lösung, sondern im **Beweis**, dass es keine bessere gibt. Genau diese Arbeit teilen sich die parallelen Stränge auf. + +**7.7 — Job-Shop erweitern.** (a) Rüstzeiten: `AddNoOverlap` durch paarweise Disjunktionen mit Übergangszeit ersetzen, oder `AddCircuit` je Maschine mit Übergangsmatrix. (b) Verspätung: `tardiness = MaxEquality(0, ende - faellig)`, in die Zielfunktion. (c) `AddCumulative(intervalle, [1]*n, 2)` statt `AddNoOverlap` für die Doppelmaschine. + +**7.8 — Wochendienstplan.** Siehe Projekt P2. Kernpunkte: Nachtschicht-Folgeregel über `AddImplication`; „höchstens 5 Tage in Folge“ über gleitende Fenster (`sum(x[p,t..t+5]) <= 5`). ### Finde den Denkfehler — Die Betriebsvereinbarung, die niemanden interessiert @@ -25140,6 +25455,8 @@ Das ist eine Frage, die eine Zahl als Antwort hat. Und wenn der Auftraggeber sie 3. Eine Variable mit Start, Dauer und Ende; sie garantiert automatisch `start + dauer == ende`. 4. Weil jede zusätzliche harte Bedingung das Risiko von `INFEASIBLE` erhöht — und eine Fehlermeldung dem Anwender nicht hilft. 5. Weil Anwender die Frage „Warum ich?“ stellen. Ohne Antwort wird das System umgangen. +6. Weil der Seed nur festlegt, wie ein **einzelner** Suchstrang würfelt — nicht, welcher von mehreren parallelen zuerst fertig wird. Das entscheidet die Ausführungsreihenfolge, also die Uhr. Abhilfe: zusätzlich `num_workers = 1`. Gemessen: schon zwei Arbeiter liefern bei identischem Seed drei verschiedene Pläne zum selben Zielwert. +7. Weil CP-SAT nicht dieselbe Suche vervielfacht, sondern **verschiedene Strategien** nebeneinander laufen lässt (LP-gestützt, andere Verzweigungsregeln, lokale Suche), die einander ihre Schranken mitteilen. Ein zusätzlicher Arbeiter ist deshalb nicht ein weiterer Kern für dieselbe Arbeit, sondern ein anderes Verfahren — und wenn eines davon früh eine gute Schranke findet, profitieren alle. --- diff --git a/Operations_Research_mit_Python_Version_04.pdf b/Operations_Research_mit_Python_Version_04.pdf index 4e50397..408f1e1 100644 Binary files a/Operations_Research_mit_Python_Version_04.pdf and b/Operations_Research_mit_Python_Version_04.pdf differ diff --git a/Operations_Research_mit_Python_Version_04/00_Vorwort_und_Lesehilfe.md b/Operations_Research_mit_Python_Version_04/00_Vorwort_und_Lesehilfe.md index f5eb016..31f6632 100644 --- a/Operations_Research_mit_Python_Version_04/00_Vorwort_und_Lesehilfe.md +++ b/Operations_Research_mit_Python_Version_04/00_Vorwort_und_Lesehilfe.md @@ -431,6 +431,7 @@ Pfad E oder um gezielt nach einem Thema zu suchen: | `Propagation_Demo.py` | Propagation messbar | {ref:kap:cpsat} | | `CP_SAT_Vertretungssystem.py` | Vertretungsplan | {ref:kap:cpsat} | | `JobShop_Intervalle.py` | Job-Shop-Scheduling | {ref:kap:cpsat} | +| `Parallele_Suche.py` | num_workers: Tempo gegen Reproduzierbarkeit | {ref:kap:cpsat} | | `CP_SAT_Statusfaelle.py` | Die fünf Solver-Antworten | {ref:kap:cpsat} | | `Strafgewichte.py` | Gewichte als Wechselkurse | {ref:kap:cpsat} | | `Min_Cost_Flow.py` | Netzwerkfluss | {ref:kap:graphen} | diff --git a/Operations_Research_mit_Python_Version_04/21_MILP.md b/Operations_Research_mit_Python_Version_04/21_MILP.md index 056485b..020fbf8 100644 --- a/Operations_Research_mit_Python_Version_04/21_MILP.md +++ b/Operations_Research_mit_Python_Version_04/21_MILP.md @@ -1308,7 +1308,8 @@ def loese(dauer: np.ndarray, n_maschinen: int, zeitlimit: float, loeser = cp_model.CpSolver() loeser.parameters.max_time_in_seconds = zeitlimit - # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist. + # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist - + # der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming). # Im Produktivbetrieb laesst man beides auf den Standardwerten. loeser.parameters.num_workers = 1 loeser.parameters.random_seed = 1 diff --git a/Operations_Research_mit_Python_Version_04/22_CP_SAT.md b/Operations_Research_mit_Python_Version_04/22_CP_SAT.md index edc7628..da67fa6 100644 --- a/Operations_Research_mit_Python_Version_04/22_CP_SAT.md +++ b/Operations_Research_mit_Python_Version_04/22_CP_SAT.md @@ -18,6 +18,7 @@ > `Propagation_Demo.py`\ > `CP_SAT_Vertretungssystem.py`\ > `JobShop_Intervalle.py`\ +> `Parallele_Suche.py`\ > `CP_SAT_Statusfaelle.py`\ > `Strafgewichte.py` @@ -101,7 +102,9 @@ Nach diesem Kapitel können Sie … 6. … die fünf CP-SAT-Statusfälle unterscheiden — insbesondere `INFEASIBLE` (es gibt keine Lösung) von `UNKNOWN` (es wurde keine gefunden) und `MODEL_INVALID` (Ihr Code ist fehlerhaft). -7. … begründen, warum ein Strafgewicht ein **Wechselkurs** ist, und welche Regeln deshalb +7. … begründen, warum `num_workers = 1` für einen reproduzierbaren Lauf nötig ist und ein + fester `random_seed` allein nicht genügt — und was ein Test deshalb prüfen darf. +8. … begründen, warum ein Strafgewicht ein **Wechselkurs** ist, und welche Regeln deshalb niemals in die Zielfunktion gehören. --- @@ -794,6 +797,9 @@ Zusammenspiel von Reihenfolge- und Maschinenbedingungen, nicht aus einer schlech > abgedruckte Ausgabe reproduzierbar. **Im Produktivbetrieb lässt man beides weg** — mehrere > Arbeiter sind deutlich schneller. > +> Wie viel schneller, und warum der Seed allein **nicht** genügt, misst +> {ref:sec:cpsat-parallele-suche} an genau diesem Modell. +> > Die Lehre für Ihre eigenen Tests: Prüfen Sie bei mehrdeutigen Optima nie auf einen > **bestimmten Plan**, sondern immer auf den **Zielwert** und auf die Einhaltung aller > Regeln. Ein Test wie `assert plan == erwarteter_plan` besteht sonst mal und scheitert mal, @@ -817,6 +823,336 @@ theoretischen Ideal entfernt sein können, noch bevor der Solver fertig war. --- +## Parallele Suche: was `num_workers` wirklich bewirkt {#sec:cpsat-parallele-suche} + +An mehreren Stellen dieses Buchs steht `loeser.parameters.num_workers = 1`, mit dem +Kommentar „für eine reproduzierbare Ausgabe“. Darin stecken zwei Behauptungen: dass +parallele Suche **schneller** ist, und dass sie **von Lauf zu Lauf andere, gleich gute** +Lösungen liefert. Beides lässt sich messen — und beim Messen kommt mehr heraus als erwartet. + +Gerechnet wird auf demselben Modell wie +{ref:sec:cpsat-intervallvariablen-job-shop-scheduling}, nur groß genug, dass die Suche +wirklich arbeitet: 12 Aufträge auf 10 Maschinen, jeder Auftrag besucht jede Maschine. +Entscheidend ist, dass `random_seed` in **jedem** Lauf denselben Wert hat. + +```python +#!/usr/bin/env python3 + +# Parallele_Suche.py +""" +Kapitel CP-SAT: Was mehrere Arbeiter wirklich bringen - und was sie kosten. + +Das Buch setzt an mehreren Stellen 'num_workers = 1', damit die abgedruckte +Ausgabe reproduzierbar ist. Behauptet wird dabei zweierlei: dass parallele +Suche schneller ist, und dass sie von Lauf zu Lauf verschiedene, gleich gute +Loesungen findet. Beides wird hier gemessen statt geglaubt. + +Gerechnet wird auf einem Job-Shop wie in JobShop_Intervalle.py, nur gross +genug, dass die Suche wirklich arbeitet: 12 Auftraege auf 10 Maschinen, +jeder Auftrag besucht jede Maschine. + +Zwei Messungen: + 1. Dieselbe Aufgabe mit 1, 2, 4 und 8 Arbeitern - Laufzeit und Ergebnis. + 2. Dieselbe Konfiguration mehrfach, bei FESTEM random_seed - wie oft kommt + derselbe Plan heraus? + +Die zweite ist die wichtigere: Sie entscheidet, wie man Tests schreibt. + +Achtung, Laufzeiten sind hardwareabhaengig. Die ZAHL der verschiedenen +Plaene ist es auch - und genau das ist die Aussage. + +Benoetigt: numpy, ortools +""" + +from __future__ import annotations + +import collections +import time + +import numpy as np +from ortools.sat.python import cp_model + +AUFTRAEGE = 12 +MASCHINEN = 10 +INSTANZ_SEED = 20260908 +SOLVER_SEED = 1 # bleibt ueber ALLE Laeufe gleich - das ist der Punkt +ARBEITERZAHLEN = (1, 2, 4, 8) +WIEDERHOLUNGEN = 4 +ZEITLIMIT = 120.0 + + +def baue_instanz() -> list[list[tuple[int, int]]]: + """Klassischer Job-Shop: Jeder Auftrag besucht jede Maschine genau einmal, + in einer eigenen zufaelligen Reihenfolge.""" + rng = np.random.default_rng(INSTANZ_SEED) + return [[(int(m), int(rng.integers(2, 20))) for m in rng.permutation(MASCHINEN)] + for _ in range(AUFTRAEGE)] + + +def loese(auftraege, arbeiter: int): + """Minimiert den Makespan. Gibt Status, Zielwert, Laufzeit und den Plan + zurueck - den Plan als Tupel aller Startzeiten, damit sich zwei Laeufe + vergleichen lassen.""" + horizont = sum(dauer for auftrag in auftraege for _, dauer in auftrag) + modell = cp_model.CpModel() + Gang = collections.namedtuple("Gang", "start ende intervall") + plaene: dict[tuple[int, int], Gang] = {} + je_maschine = collections.defaultdict(list) + + for a, auftrag in enumerate(auftraege): + for g, (maschine, dauer) in enumerate(auftrag): + start = modell.NewIntVar(0, horizont, f"start_{a}_{g}") + ende = modell.NewIntVar(0, horizont, f"ende_{a}_{g}") + intervall = modell.NewIntervalVar(start, dauer, ende, f"iv_{a}_{g}") + plaene[a, g] = Gang(start, ende, intervall) + je_maschine[maschine].append(intervall) + + for maschine in range(MASCHINEN): + modell.AddNoOverlap(je_maschine[maschine]) + for a, auftrag in enumerate(auftraege): + for g in range(len(auftrag) - 1): + modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende) + + makespan = modell.NewIntVar(0, horizont, "makespan") + modell.AddMaxEquality( + makespan, [plaene[a, len(auftrag) - 1].ende + for a, auftrag in enumerate(auftraege)]) + modell.Minimize(makespan) + + loeser = cp_model.CpSolver() + loeser.parameters.num_workers = arbeiter + loeser.parameters.random_seed = SOLVER_SEED + loeser.parameters.max_time_in_seconds = ZEITLIMIT + + beginn = time.perf_counter() + status = loeser.Solve(modell) + dauer = time.perf_counter() - beginn + + if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE): + return loeser.StatusName(status), None, dauer, None + plan = tuple(loeser.Value(plaene[a, g].start) + for a, auftrag in enumerate(auftraege) + for g in range(len(auftrag))) + return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer, plan + + +if __name__ == "__main__": + auftraege = baue_instanz() + gaenge = sum(len(a) for a in auftraege) + + print("=" * 78) + print(" WAS MEHRERE ARBEITER WIRKLICH BRINGEN") + print("=" * 78) + print(f"Job-Shop: {AUFTRAEGE} Auftraege, {MASCHINEN} Maschinen, " + f"{gaenge} Arbeitsgaenge.") + print(f"random_seed = {SOLVER_SEED} in JEDEM Lauf, " + f"{WIEDERHOLUNGEN} Wiederholungen je Arbeiterzahl.\n") + + # Jede Konfiguration mehrfach - ein einzelner Zeitwert sagt nichts. + ergebnisse: dict[int, list[tuple]] = {} + for arbeiter in ARBEITERZAHLEN: + ergebnisse[arbeiter] = [loese(auftraege, arbeiter) + for _ in range(WIEDERHOLUNGEN)] + + print("=" * 78) + print(" (1) Laufzeit: was die Arbeiter bringen") + print("=" * 78) + print(f"{'Arbeiter':>9} {'Status':>9} {'Makespan':>9} " + f"{'schnellste':>11} {'langsamste':>11} {'Beschleunigung':>15}") + print("-" * 78) + basis_zeit = min(d for _, _, d, _ in ergebnisse[1]) + for arbeiter in ARBEITERZAHLEN: + laeufe = ergebnisse[arbeiter] + zeiten = [d for _, _, d, _ in laeufe] + ziele = {o for _, o, _, _ in laeufe} + status = {s for s, _, _, _ in laeufe} + print(f"{arbeiter:>9} {'/'.join(sorted(status)):>9} " + f"{'/'.join(str(z) for z in sorted(ziele)):>9} " + f"{min(zeiten):>10.2f}s {max(zeiten):>10.2f}s " + f"{basis_zeit / min(zeiten):>14.1f}x") + + beste = min(ARBEITERZAHLEN, key=lambda w: min(d for _, _, d, _ in ergebnisse[w])) + faktor = basis_zeit / min(d for _, _, d, _ in ergebnisse[beste]) + print(f"\n Am schnellsten sind {beste} Arbeiter: Faktor {faktor:.1f} gegenueber") + print(f" einem einzigen - also MEHR, als {beste} Kerne hergeben sollten.") + print(f" Das ist kein Messfehler. CP-SAT laesst nicht {beste}-mal dieselbe Suche") + print(" laufen, sondern verschiedene Strategien nebeneinander, die einander") + print(" ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der") + print(" 'zweite Kern', sondern ein anderes Verfahren.") + letzte = ARBEITERZAHLEN[-1] + if beste != letzte: + zeit_letzte = min(d for _, _, d, _ in ergebnisse[letzte]) + print(f"\n Und mehr ist nicht immer besser: {letzte} Arbeiter brauchen " + f"{zeit_letzte:.2f}s") + print(f" gegenueber {min(d for _, _, d, _ in ergebnisse[beste]):.2f}s bei " + f"{beste}. Ab einer gewissen Zahl kosten Abstimmung") + print(" und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt.") + + print("\n" + "=" * 78) + print(" (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?") + print("=" * 78) + print(f"{'Arbeiter':>9} {'Laeufe':>7} {'verschiedene Plaene':>21} " + f"{'Makespan':>10}") + print("-" * 78) + for arbeiter in ARBEITERZAHLEN: + plaene = {p for _, _, _, p in ergebnisse[arbeiter] if p is not None} + ziele = {o for _, o, _, _ in ergebnisse[arbeiter]} + print(f"{arbeiter:>9} {WIEDERHOLUNGEN:>7} {len(plaene):>21} " + f"{'/'.join(str(z) for z in sorted(ziele)):>10}") + + einer = len({p for _, _, _, p in ergebnisse[1]}) + # Ab welcher Arbeiterzahl bricht die Reproduzierbarkeit? Das ist die + # eigentliche Zahl - nicht, was acht Arbeiter anrichten. + ab = next((w for w in ARBEITERZAHLEN + if len({p for _, _, _, p in ergebnisse[w]}) > 1), None) + print(f"\n Mit einem Arbeiter: {einer} Plan aus {WIEDERHOLUNGEN} Laeufen.") + if ab is not None: + viele = len({p for _, _, _, p in ergebnisse[ab]}) + print(f" Schon mit {ab} Arbeitern: {viele} verschiedene Plaene aus " + f"{WIEDERHOLUNGEN} Laeufen -") + print(" bei identischem random_seed und identischem Zielwert.") + print(f" Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit") + print(f" zu verlieren. {ab} genuegen.") + print("\n Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt") + print(" fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von") + print(" mehreren zuerst fertig wird. Das entscheidet die Uhr.") + + print("\n" + "=" * 78) + print(" (3) Was daraus fuer Tests folgt") + print("=" * 78) + print(" Ein Test der Form assert plan == erwarteter_plan besteht mal und") + print(" scheitert mal, ohne dass sich am Code etwas geaendert haette.") + print(" Zu pruefen sind stattdessen:") + print(" * der ZIELWERT (hier in allen Laeufen gleich),") + print(" * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),") + print(" * und der Status - nicht die Gestalt der Loesung.") + print("\n Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte") + print(" Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.") + print(f" Das kostet hier den Faktor {faktor:.1f} an Laufzeit und ist genau") + print(" deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.") + print("=" * 78) +``` + +**Erwartete Ausgabe** (Laufzeiten *und* die Zahl der verschiedenen Pläne sind +hardwareabhängig — genau das ist die Aussage; der Zielwert ist es nicht): + +``` +============================================================================== + WAS MEHRERE ARBEITER WIRKLICH BRINGEN +============================================================================== +Job-Shop: 12 Auftraege, 10 Maschinen, 120 Arbeitsgaenge. +random_seed = 1 in JEDEM Lauf, 4 Wiederholungen je Arbeiterzahl. + +============================================================================== + (1) Laufzeit: was die Arbeiter bringen +============================================================================== + Arbeiter Status Makespan schnellste langsamste Beschleunigung +------------------------------------------------------------------------------ + 1 OPTIMAL 183 5.62s 5.82s 1.0x + 2 OPTIMAL 183 1.43s 2.04s 3.9x + 4 OPTIMAL 183 0.60s 0.66s 9.4x + 8 OPTIMAL 183 0.46s 0.65s 12.3x + + Am schnellsten sind 8 Arbeiter: Faktor 12.3 gegenueber + einem einzigen - also MEHR, als 8 Kerne hergeben sollten. + Das ist kein Messfehler. CP-SAT laesst nicht 8-mal dieselbe Suche + laufen, sondern verschiedene Strategien nebeneinander, die einander + ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der + 'zweite Kern', sondern ein anderes Verfahren. + +============================================================================== + (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan? +============================================================================== + Arbeiter Laeufe verschiedene Plaene Makespan +------------------------------------------------------------------------------ + 1 4 1 183 + 2 4 3 183 + 4 4 3 183 + 8 4 4 183 + + Mit einem Arbeiter: 1 Plan aus 4 Laeufen. + Schon mit 2 Arbeitern: 3 verschiedene Plaene aus 4 Laeufen - + bei identischem random_seed und identischem Zielwert. + Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit + zu verlieren. 2 genuegen. + + Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt + fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von + mehreren zuerst fertig wird. Das entscheidet die Uhr. + +============================================================================== + (3) Was daraus fuer Tests folgt +============================================================================== + Ein Test der Form assert plan == erwarteter_plan besteht mal und + scheitert mal, ohne dass sich am Code etwas geaendert haette. + Zu pruefen sind stattdessen: + * der ZIELWERT (hier in allen Laeufen gleich), + * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen), + * und der Status - nicht die Gestalt der Loesung. + + Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte + Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1. + Das kostet hier den Faktor 12.3 an Laufzeit und ist genau + deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb. +============================================================================== +``` + +### Was die Messung zeigt + +**Erstens: Die Beschleunigung ist überlinear.** Acht Arbeiter sind im abgedruckten Lauf +nicht achtmal, sondern **gut zwölfmal** schneller als einer. Das sieht nach Messfehler aus, ist aber die +korrekte Beschreibung dessen, was CP-SAT tut: Es lässt **nicht** achtmal dieselbe Suche auf +verschiedenen Kernen laufen, sondern **verschiedene Strategien nebeneinander** — Suche mit +LP-Relaxation, Suche mit anderen Verzweigungsregeln, lokale Suche —, und die teilen +einander ihre Schranken mit. Der zweite Arbeiter ist deshalb nicht „der zweite Kern“, +sondern ein anderes Verfahren, das zufällig auch rechnet. + +Daraus folgt zweierlei. Ein Speedup über der Kernzahl ist bei CP-SAT normal und kein Grund +zum Misstrauen. Und der Zugewinn flacht ab: Zwischen vier und acht Arbeitern lag in unseren +Wiederholungen mal das eine, mal das andere vorn — ab einer gewissen Zahl kosten Abstimmung +und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt. Auch hier gilt: messen, +nicht schätzen. + +**Zweitens — und das ist der Befund, der die Testpraxis bestimmt: Der Seed genügt nicht.** +Bei einem Arbeiter liefern vier Läufe **einen** Plan. Schon bei **zwei** Arbeitern liefern +vier Läufe **drei verschiedene** — bei identischem `random_seed` und identischem Zielwert +183. + +Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit zu verlieren. Zwei genügen. +Der Grund ist einfach, sobald man ihn ausspricht: Der Seed legt fest, wie ein **einzelner** +Suchstrang würfelt. Er legt nicht fest, **welcher von mehreren zuerst fertig wird** — das +entscheidet die Uhr, und die ist bei jedem Lauf anders. + +> **⚠️ `random_seed` allein macht nichts reproduzierbar** +> Ein häufiger Irrtum: „Ich habe doch einen Seed gesetzt.“ Für einen Zufallsgenerator +> stimmt das. Für einen **parallelen** Solver nicht — dort ist die zweite Zufallsquelle die +> Ausführungsreihenfolge, und die lässt sich nicht mit einem Parameter festnageln. +> Reproduzierbar wird der Lauf erst mit `num_workers = 1` **und** Seed. Wer nur eines von +> beidem setzt, hat einen Test, der gelegentlich grundlos rot wird. + +### Was daraus für Tests folgt + +Der Zielwert war in **allen** sechzehn Läufen derselbe: 183. Die Gestalt der Lösung war es +nicht. Genau daran entscheidet sich, was ein Test prüfen darf: + +| Prüfen | Nicht prüfen | +| --- | --- | +| den **Zielwert** — er ist eindeutig, wenn das Modell optimal gelöst wurde | die **Gestalt** des Plans (`assert plan == erwarteter_plan`) | +| die **Einhaltung aller Regeln** über eine Abnahmeprüfung ({ref:sec:praxisfallen-or-kern}) | die Reihenfolge, in der gleichwertige Aufträge zugewiesen wurden | +| den **Status** (`OPTIMAL`, `FEASIBLE`, `INFEASIBLE`) | die Laufzeit als feste Zahl | + +Wer doch einen bestimmten Plan braucht — für eine abgedruckte Ausgabe, für einen +Regressionstest —, setzt `num_workers = 1`. Das kostet hier den Faktor 12 an Laufzeit, und +genau deshalb ist es eine Entscheidung **für den Test**, nicht für den Betrieb. In +Produktion lässt man beide Parameter auf ihren Standardwerten. + +> **🎯 Merksatz** +> Ein paralleler Solver hat zwei Zufallsquellen: den Seed und die Uhr. Nur die erste lässt +> sich setzen. Wer Reproduzierbarkeit braucht, muss die zweite abschalten — und das heißt +> `num_workers = 1`. + +--- + ## Die fünf Antworten von CP-SAT {#sec:cpsat-statusfaelle} {ref:sec:milp-gap} hat gezeigt, warum `OPTIMAL` bei ganzzahligen Problemen nicht der @@ -1180,6 +1516,16 @@ Lösen Sie ein 9×9-Sudoku mit CP-SAT. Sie brauchen nur `NewIntVar(1, 9, ...)` u `AddAllDifferent` für Zeilen, Spalten und die neun 3×3-Blöcke. Wie viele Zeilen Modellcode benötigen Sie? +**Aufgabe ⭐⭐ — Die Grenze der Parallelität finden.** +`Parallele_Suche.py` misst 1, 2, 4 und 8 Arbeiter. +(a) Ergänzen Sie 16 und 24 (bzw. die Kernzahl Ihres Rechners). Ab wo bringt ein weiterer + Arbeiter nichts mehr — und wird es irgendwann wieder schlechter? +(b) Setzen Sie `random_seed` bei jedem Lauf auf einen **anderen** Wert, aber + `num_workers = 1`. Wie viele verschiedene Pläne kommen jetzt heraus? Was folgt daraus + für die Frage, welcher der beiden Parameter die Reproduzierbarkeit sichert? +(c) Vergrößern Sie die Instanz auf 15 Aufträge. Ab welcher Größe erreicht der Lauf mit + einem Arbeiter das Zeitlimit, während acht noch `OPTIMAL` melden? + **Aufgabe ⭐⭐⭐ — Job-Shop erweitern.** Ergänzen Sie `JobShop_Intervalle.py` um: (a) Rüstzeiten von 1 Zeiteinheit zwischen zwei Aufträgen auf derselben Maschine. @@ -1466,6 +1812,10 @@ Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft. 3. Was ist eine Intervallvariable, und welche Bedingung garantiert sie automatisch? 4. Warum sollten möglichst wenige Bedingungen hart formuliert werden? 5. Warum ist die Aufschlüsselung der Strafkosten in der Praxis wichtiger als der Zielwert +6. Sie setzen `random_seed = 1`, bekommen aber trotzdem bei jedem Lauf einen anderen Plan. + Woran liegt das, und was hilft? +7. Warum kann ein CP-SAT-Lauf mit acht Arbeitern **mehr** als achtmal schneller sein als + mit einem? selbst? --- @@ -1491,6 +1841,13 @@ Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft. * **Bei mehrdeutigen Optima nie auf einen bestimmten Plan testen** — nur auf den Zielwert und die Einhaltung aller Regeln. +* **Ein paralleler Solver hat zwei Zufallsquellen: den Seed und die Uhr.** Nur die erste + lässt sich setzen. Reproduzierbar wird ein Lauf erst mit `num_workers = 1` **und** Seed — + gemessen in {ref:sec:cpsat-parallele-suche}: schon zwei Arbeiter liefern bei identischem + Seed drei verschiedene Pläne zum selben Zielwert. +* **Parallele Suche kann überlinear beschleunigen**, weil CP-SAT nicht dieselbe Suche + vervielfacht, sondern verschiedene Strategien nebeneinander laufen lässt, die einander + ihre Schranken mitteilen. **Ausblick.** {ref:kap:graphen} behandelt Probleme, die sich am besten als **Graph** begreifen lassen: Flüsse durch Netzwerke, Zuordnungen und die Tourenplanung mit Fahrzeugen und Zeitfenstern. diff --git a/Operations_Research_mit_Python_Version_04/90_Anhang_Loesungen.md b/Operations_Research_mit_Python_Version_04/90_Anhang_Loesungen.md index c08de8e..86b2956 100644 --- a/Operations_Research_mit_Python_Version_04/90_Anhang_Loesungen.md +++ b/Operations_Research_mit_Python_Version_04/90_Anhang_Loesungen.md @@ -850,6 +850,31 @@ for i in range(9): **Etwa 10 Zeilen Modellcode** — und der Solver löst jedes Sudoku in Millisekunden. Das ist die Stärke globaler Constraints. +**{loesung} — Die Grenze der Parallelität finden.** +(a) Der Gewinn flacht ab und kehrt sich um. Auf einem Rechner mit 24 Kernen gemessen +(Faktor gegenüber einem Arbeiter): 8 Arbeiter **10,2**, 16 Arbeiter **12,3**, +24 Arbeiter **9,0**. Wer alle Kerne belegt, ist langsamer als mit zwei Dritteln — dem +Betriebssystem, dem Speicherbus und den Suchsträngen untereinander bleibt zu wenig Luft. +Die Faustregel „so viele Arbeiter wie Kerne" ist also falsch; die richtige Zahl ist eine +Messung, keine Einstellung. +(b) **Fünf verschiedene Pläne aus fünf Seeds** — bei `num_workers = 1` und identischem +Zielwert 183. Das ist die andere Hälfte der Antwort: Der Seed *wirkt*, aber nur auf den +einzelnen Suchstrang. Es sichert also **keiner der beiden Parameter allein** die +Reproduzierbarkeit: + +| | Seed fest | Seed variabel | +| --- | --- | --- | +| **1 Arbeiter** | reproduzierbar | 5 Pläne aus 5 Läufen | +| **mehrere Arbeiter** | 3 Pläne aus 4 Läufen | erst recht nicht | + +Nur die Kombination trägt. +(c) Bei **15 Aufträgen** (60 s Zeitlimit): Ein Arbeiter läuft ins Limit und meldet +`FEASIBLE` mit Makespan 200; acht Arbeiter melden `OPTIMAL` mit **demselben** Makespan 200 +nach 26,4 s. Bei 13 Aufträgen sind es 11,2 s gegen 2,8 s, bei 14 schon 21,4 s gegen 3,7 s. +Bemerkenswert ist, dass beide denselben Wert finden — der Unterschied liegt nicht in der +Lösung, sondern im **Beweis**, dass es keine bessere gibt. Genau diese Arbeit teilen sich +die parallelen Stränge auf. + **{loesung} — Job-Shop erweitern.** (a) Rüstzeiten: `AddNoOverlap` durch paarweise Disjunktionen mit Übergangszeit ersetzen, oder `AddCircuit` je Maschine mit Übergangsmatrix. @@ -950,6 +975,15 @@ Regeln. Wer eine reproduzierbare Ausgabe braucht — etwa für ein Buch —, fix 4. Weil jede zusätzliche harte Bedingung das Risiko von `INFEASIBLE` erhöht — und eine Fehlermeldung dem Anwender nicht hilft. 5. Weil Anwender die Frage „Warum ich?“ stellen. Ohne Antwort wird das System umgangen. +6. Weil der Seed nur festlegt, wie ein **einzelner** Suchstrang würfelt — nicht, welcher + von mehreren parallelen zuerst fertig wird. Das entscheidet die Ausführungsreihenfolge, + also die Uhr. Abhilfe: zusätzlich `num_workers = 1`. Gemessen: schon zwei Arbeiter + liefern bei identischem Seed drei verschiedene Pläne zum selben Zielwert. +7. Weil CP-SAT nicht dieselbe Suche vervielfacht, sondern **verschiedene Strategien** + nebeneinander laufen lässt (LP-gestützt, andere Verzweigungsregeln, lokale Suche), die + einander ihre Schranken mitteilen. Ein zusätzlicher Arbeiter ist deshalb nicht ein + weiterer Kern für dieselbe Arbeit, sondern ein anderes Verfahren — und wenn eines davon + früh eine gute Schranke findet, profitieren alle. --- diff --git a/Operations_Research_mit_Python_Version_04_Programme/Parallele_Suche.py b/Operations_Research_mit_Python_Version_04_Programme/Parallele_Suche.py new file mode 100644 index 0000000..a23ed10 --- /dev/null +++ b/Operations_Research_mit_Python_Version_04_Programme/Parallele_Suche.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 + +# Parallele_Suche.py +""" +Kapitel CP-SAT: Was mehrere Arbeiter wirklich bringen - und was sie kosten. + +Das Buch setzt an mehreren Stellen 'num_workers = 1', damit die abgedruckte +Ausgabe reproduzierbar ist. Behauptet wird dabei zweierlei: dass parallele +Suche schneller ist, und dass sie von Lauf zu Lauf verschiedene, gleich gute +Loesungen findet. Beides wird hier gemessen statt geglaubt. + +Gerechnet wird auf einem Job-Shop wie in JobShop_Intervalle.py, nur gross +genug, dass die Suche wirklich arbeitet: 12 Auftraege auf 10 Maschinen, +jeder Auftrag besucht jede Maschine. + +Zwei Messungen: + 1. Dieselbe Aufgabe mit 1, 2, 4 und 8 Arbeitern - Laufzeit und Ergebnis. + 2. Dieselbe Konfiguration mehrfach, bei FESTEM random_seed - wie oft kommt + derselbe Plan heraus? + +Die zweite ist die wichtigere: Sie entscheidet, wie man Tests schreibt. + +Achtung, Laufzeiten sind hardwareabhaengig. Die ZAHL der verschiedenen +Plaene ist es auch - und genau das ist die Aussage. + +Benoetigt: numpy, ortools +""" + +from __future__ import annotations + +import collections +import time + +import numpy as np +from ortools.sat.python import cp_model + +AUFTRAEGE = 12 +MASCHINEN = 10 +INSTANZ_SEED = 20260908 +SOLVER_SEED = 1 # bleibt ueber ALLE Laeufe gleich - das ist der Punkt +ARBEITERZAHLEN = (1, 2, 4, 8) +WIEDERHOLUNGEN = 4 +ZEITLIMIT = 120.0 + + +def baue_instanz() -> list[list[tuple[int, int]]]: + """Klassischer Job-Shop: Jeder Auftrag besucht jede Maschine genau einmal, + in einer eigenen zufaelligen Reihenfolge.""" + rng = np.random.default_rng(INSTANZ_SEED) + return [[(int(m), int(rng.integers(2, 20))) for m in rng.permutation(MASCHINEN)] + for _ in range(AUFTRAEGE)] + + +def loese(auftraege, arbeiter: int): + """Minimiert den Makespan. Gibt Status, Zielwert, Laufzeit und den Plan + zurueck - den Plan als Tupel aller Startzeiten, damit sich zwei Laeufe + vergleichen lassen.""" + horizont = sum(dauer for auftrag in auftraege for _, dauer in auftrag) + modell = cp_model.CpModel() + Gang = collections.namedtuple("Gang", "start ende intervall") + plaene: dict[tuple[int, int], Gang] = {} + je_maschine = collections.defaultdict(list) + + for a, auftrag in enumerate(auftraege): + for g, (maschine, dauer) in enumerate(auftrag): + start = modell.NewIntVar(0, horizont, f"start_{a}_{g}") + ende = modell.NewIntVar(0, horizont, f"ende_{a}_{g}") + intervall = modell.NewIntervalVar(start, dauer, ende, f"iv_{a}_{g}") + plaene[a, g] = Gang(start, ende, intervall) + je_maschine[maschine].append(intervall) + + for maschine in range(MASCHINEN): + modell.AddNoOverlap(je_maschine[maschine]) + for a, auftrag in enumerate(auftraege): + for g in range(len(auftrag) - 1): + modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende) + + makespan = modell.NewIntVar(0, horizont, "makespan") + modell.AddMaxEquality( + makespan, [plaene[a, len(auftrag) - 1].ende + for a, auftrag in enumerate(auftraege)]) + modell.Minimize(makespan) + + loeser = cp_model.CpSolver() + loeser.parameters.num_workers = arbeiter + loeser.parameters.random_seed = SOLVER_SEED + loeser.parameters.max_time_in_seconds = ZEITLIMIT + + beginn = time.perf_counter() + status = loeser.Solve(modell) + dauer = time.perf_counter() - beginn + + if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE): + return loeser.StatusName(status), None, dauer, None + plan = tuple(loeser.Value(plaene[a, g].start) + for a, auftrag in enumerate(auftraege) + for g in range(len(auftrag))) + return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer, plan + + +if __name__ == "__main__": + auftraege = baue_instanz() + gaenge = sum(len(a) for a in auftraege) + + print("=" * 78) + print(" WAS MEHRERE ARBEITER WIRKLICH BRINGEN") + print("=" * 78) + print(f"Job-Shop: {AUFTRAEGE} Auftraege, {MASCHINEN} Maschinen, " + f"{gaenge} Arbeitsgaenge.") + print(f"random_seed = {SOLVER_SEED} in JEDEM Lauf, " + f"{WIEDERHOLUNGEN} Wiederholungen je Arbeiterzahl.\n") + + # Jede Konfiguration mehrfach - ein einzelner Zeitwert sagt nichts. + ergebnisse: dict[int, list[tuple]] = {} + for arbeiter in ARBEITERZAHLEN: + ergebnisse[arbeiter] = [loese(auftraege, arbeiter) + for _ in range(WIEDERHOLUNGEN)] + + print("=" * 78) + print(" (1) Laufzeit: was die Arbeiter bringen") + print("=" * 78) + print(f"{'Arbeiter':>9} {'Status':>9} {'Makespan':>9} " + f"{'schnellste':>11} {'langsamste':>11} {'Beschleunigung':>15}") + print("-" * 78) + basis_zeit = min(d for _, _, d, _ in ergebnisse[1]) + for arbeiter in ARBEITERZAHLEN: + laeufe = ergebnisse[arbeiter] + zeiten = [d for _, _, d, _ in laeufe] + ziele = {o for _, o, _, _ in laeufe} + status = {s for s, _, _, _ in laeufe} + print(f"{arbeiter:>9} {'/'.join(sorted(status)):>9} " + f"{'/'.join(str(z) for z in sorted(ziele)):>9} " + f"{min(zeiten):>10.2f}s {max(zeiten):>10.2f}s " + f"{basis_zeit / min(zeiten):>14.1f}x") + + beste = min(ARBEITERZAHLEN, key=lambda w: min(d for _, _, d, _ in ergebnisse[w])) + faktor = basis_zeit / min(d for _, _, d, _ in ergebnisse[beste]) + print(f"\n Am schnellsten sind {beste} Arbeiter: Faktor {faktor:.1f} gegenueber") + print(f" einem einzigen - also MEHR, als {beste} Kerne hergeben sollten.") + print(f" Das ist kein Messfehler. CP-SAT laesst nicht {beste}-mal dieselbe Suche") + print(" laufen, sondern verschiedene Strategien nebeneinander, die einander") + print(" ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der") + print(" 'zweite Kern', sondern ein anderes Verfahren.") + letzte = ARBEITERZAHLEN[-1] + if beste != letzte: + zeit_letzte = min(d for _, _, d, _ in ergebnisse[letzte]) + print(f"\n Und mehr ist nicht immer besser: {letzte} Arbeiter brauchen " + f"{zeit_letzte:.2f}s") + print(f" gegenueber {min(d for _, _, d, _ in ergebnisse[beste]):.2f}s bei " + f"{beste}. Ab einer gewissen Zahl kosten Abstimmung") + print(" und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt.") + + print("\n" + "=" * 78) + print(" (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?") + print("=" * 78) + print(f"{'Arbeiter':>9} {'Laeufe':>7} {'verschiedene Plaene':>21} " + f"{'Makespan':>10}") + print("-" * 78) + for arbeiter in ARBEITERZAHLEN: + plaene = {p for _, _, _, p in ergebnisse[arbeiter] if p is not None} + ziele = {o for _, o, _, _ in ergebnisse[arbeiter]} + print(f"{arbeiter:>9} {WIEDERHOLUNGEN:>7} {len(plaene):>21} " + f"{'/'.join(str(z) for z in sorted(ziele)):>10}") + + einer = len({p for _, _, _, p in ergebnisse[1]}) + # Ab welcher Arbeiterzahl bricht die Reproduzierbarkeit? Das ist die + # eigentliche Zahl - nicht, was acht Arbeiter anrichten. + ab = next((w for w in ARBEITERZAHLEN + if len({p for _, _, _, p in ergebnisse[w]}) > 1), None) + print(f"\n Mit einem Arbeiter: {einer} Plan aus {WIEDERHOLUNGEN} Laeufen.") + if ab is not None: + viele = len({p for _, _, _, p in ergebnisse[ab]}) + print(f" Schon mit {ab} Arbeitern: {viele} verschiedene Plaene aus " + f"{WIEDERHOLUNGEN} Laeufen -") + print(" bei identischem random_seed und identischem Zielwert.") + print(f" Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit") + print(f" zu verlieren. {ab} genuegen.") + print("\n Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt") + print(" fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von") + print(" mehreren zuerst fertig wird. Das entscheidet die Uhr.") + + print("\n" + "=" * 78) + print(" (3) Was daraus fuer Tests folgt") + print("=" * 78) + print(" Ein Test der Form assert plan == erwarteter_plan besteht mal und") + print(" scheitert mal, ohne dass sich am Code etwas geaendert haette.") + print(" Zu pruefen sind stattdessen:") + print(" * der ZIELWERT (hier in allen Laeufen gleich),") + print(" * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),") + print(" * und der Status - nicht die Gestalt der Loesung.") + print("\n Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte") + print(" Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.") + print(f" Das kostet hier den Faktor {faktor:.1f} an Laufzeit und ist genau") + print(" deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.") + print("=" * 78) diff --git a/Operations_Research_mit_Python_Version_04_Programme/README.md b/Operations_Research_mit_Python_Version_04_Programme/README.md index 782687e..670ac7c 100644 --- a/Operations_Research_mit_Python_Version_04_Programme/README.md +++ b/Operations_Research_mit_Python_Version_04_Programme/README.md @@ -1,6 +1,6 @@ # Beispielprogramme zu „Optimierte Entscheidungsfindung mit Python" (Version 04) -Diese 75 Dateien sind automatisch aus den Kapiteldateien in +Diese 76 Dateien sind automatisch aus den Kapiteldateien in `../Operations_Research_mit_Python_Version_04/` extrahiert — jede Datei entspricht genau einem im Buch vollständig abgedruckten Programm. Sie sind hier zusätzlich als eigenständige, direkt ausführbare `.py`-Dateien verfügbar, damit man sie ohne @@ -15,7 +15,7 @@ python3 Operations_Research_mit_Python_Version_04/extract_programme_04.py ``` Das Verzeichnis „Verzeichnis der Beispielprogramme" im Vorwort des Buchs -(`00_Vorwort_und_Lesehilfe.md`) listet dieselben 75 Programme mit Kurzbeschreibung. +(`00_Vorwort_und_Lesehilfe.md`) listet dieselben 76 Programme mit Kurzbeschreibung. Auch dieses README ist selbst generiert: `extract_programme.py` erzeugt es bei jedem Lauf aus der Vorlage `README_Programme.md` im Quellverzeichnis und setzt die diff --git a/Operations_Research_mit_Python_Version_04_Programme/Warmstart_Effekt.py b/Operations_Research_mit_Python_Version_04_Programme/Warmstart_Effekt.py index ca1d586..6c08303 100644 --- a/Operations_Research_mit_Python_Version_04_Programme/Warmstart_Effekt.py +++ b/Operations_Research_mit_Python_Version_04_Programme/Warmstart_Effekt.py @@ -83,7 +83,8 @@ def loese(dauer: np.ndarray, n_maschinen: int, zeitlimit: float, loeser = cp_model.CpSolver() loeser.parameters.max_time_in_seconds = zeitlimit - # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist. + # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist - + # der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming). # Im Produktivbetrieb laesst man beides auf den Standardwerten. loeser.parameters.num_workers = 1 loeser.parameters.random_seed = 1 diff --git a/PROGRESS.md b/PROGRESS.md index 17885f3..50c43e1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -77,14 +77,14 @@ sie unerwartet ab, ist etwas kaputtgegangen. | Kapiteldateien | 22 | **31** | | Kapitel | 15 | **23** | | Anhänge | 4 | **5** | -| Zeilen im Gesamtdokument | 11 082 | **27 868** | -| Größe des Gesamtdokuments | 606 KB | **1 565 KB** | -| Hauptüberschriften | 131 | **297** | -| registrierte Abschnitte | 122 | **294** | -| aufgelöste Querverweise | 314 | **725** (0 unaufgelöst) | +| Zeilen im Gesamtdokument | 11 082 | **28 185** | +| Größe des Gesamtdokuments | 606 KB | **1 585 KB** | +| Hauptüberschriften | 131 | **298** | +| registrierte Abschnitte | 122 | **295** | +| aufgelöste Querverweise | 314 | **730** (0 unaufgelöst) | | Indexmarken | 295 | **328** | -| Beispielprogramme | 41 | **75** (alle lauffähig) | -| PDF-Seiten | — | **737** | +| Beispielprogramme | 41 | **76** (alle lauffähig) | +| PDF-Seiten | — | **744** | | Notebooks | — | **25** | | Plotly-Figuren | — | **4** | | Diagramme (SVG) | 26 | **32**, davon **18** mit Generatorskript (15 Skripte) | @@ -1565,6 +1565,59 @@ war. Genau der Fall, für den sie gebaut wurde. Stand danach: 294 Abschnitte, 725 Querverweise, 328 Indexmarken, **75** Programme, 139 Aufgaben mit 139 Lösungen, PDF **737** Seiten. +### ✅ 6.3 Parallelität und Determinismus ({ref:kap:cpsat}) + +Neuer Abschnitt {ref:sec:cpsat-parallele-suche} plus `Parallele_Suche.py` (76. Programm). +Setzt Paket 2 aus `Verbesserungen_02.md` um. + +**Was fehlte, war nicht die Regel, sondern die Messung.** Das Buch setzt an acht Stellen +`num_workers = 1` mit dem Kommentar „für eine reproduzierbare Ausgabe", und der Spickzettel +nennt den Grund. Nachgeprüft hatte es nie jemand. Gerechnet wird auf demselben Job-Shop wie +{ref:sec:cpsat-intervallvariablen-job-shop-scheduling}, nur groß genug, dass die Suche +arbeitet: 12 Aufträge, 10 Maschinen, 120 Arbeitsgänge. + +**Zwei Befunde, beide stärker als die Behauptung:** + +1. **Die Beschleunigung ist überlinear.** Acht Arbeiter waren im abgedruckten Lauf nicht + achtmal, sondern **12,3-mal** schneller als einer. Kein Messfehler: CP-SAT vervielfacht + nicht dieselbe Suche, sondern lässt **verschiedene Strategien** nebeneinander laufen, die + einander ihre Schranken mitteilen. Der zweite Arbeiter ist nicht „der zweite Kern", + sondern ein anderes Verfahren. +2. **Der Seed genügt nicht — und zwar schon ab zwei Arbeitern.** Ein Arbeiter: 1 Plan aus + 4 Läufen. **Zwei** Arbeiter: 3 verschiedene Pläne aus 4 Läufen, bei identischem + `random_seed` und identischem Zielwert 183. Es braucht also keine acht Arbeiter, um die + Reproduzierbarkeit zu verlieren. + +**Die vollständige Antwort kam erst über die Übungsaufgabe**, und sie ist besser als der +Abschnitt allein: Mit `num_workers = 1`, aber **fünf verschiedenen Seeds**, ergeben sich +ebenfalls **fünf verschiedene Pläne**. Keiner der beiden Parameter sichert die +Reproduzierbarkeit allein: + +| | Seed fest | Seed variabel | +| --- | --- | --- | +| **1 Arbeiter** | reproduzierbar | 5 Pläne aus 5 Läufen | +| **mehrere Arbeiter** | 3 Pläne aus 4 Läufen | erst recht nicht | + +Weiter gemessen für die Aufgabe: Der Gewinn kehrt sich um — auf 24 Kernen Faktor **10,2** +(8 Arbeiter), **12,3** (16), **9,0** (24). „So viele Arbeiter wie Kerne" ist damit +widerlegt. Und bei 15 Aufträgen läuft ein Arbeiter ins 60-s-Limit (`FEASIBLE`, Makespan +200), während acht `OPTIMAL` mit **demselben** Makespan 200 nach 26,4 s melden — der +Unterschied liegt nicht in der Lösung, sondern im **Beweis**, dass es keine bessere gibt. + +**Zur abgedruckten Ausgabe:** Sie trägt die Kennzeichnung „Laufzeiten *und* die Zahl der +verschiedenen Pläne sind hardwareabhängig" — nach dem Muster, das +{ref:kap:testing} für `Benchmark_Skalierung.py` schon verwendet. Der Vergleich des +extrahierten Programms mit dem Abdruck weicht denn auch in genau einer Zelle ab (4 statt 3 +verschiedene Pläne bei 4 Arbeitern); Zielwert und Struktur sind identisch. Hier ist die +Nichtreproduzierbarkeit der abgedruckten Zahl **die Aussage selbst** — der einzige Fall im +Buch, in dem das so ist. + +Im MILP-Kapitel bekam die entsprechende Stelle in `Warmstart_Effekt.py` einen Verweis; +dort steht nach Regel 12 der Kapitel**name**, keine Nummer. + +Stand danach: 295 Abschnitte, 730 Querverweise, **76** Programme, 140 Aufgaben mit +140 Lösungen, PDF **744** Seiten. + --- ## 8. Commit-Historie des V04-Strangs diff --git a/README.md b/README.md index 034719c..efdbb6e 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ daraus ab, und alle Befehle unten werden **hier** ausgeführt. | `Operations_Research_mit_Python_Version_04/` | **Quelle**: 31 Kapiteldateien + Build-Skripte | | `bilder_04/` | **Quelle**: Diagramme (SVG/PNG) + `erzeuge_*.py`-Generatoren | | `Operations_Research_mit_Python_Version_04.md` | generiert: Gesamtdokument (Pandoc-Eingabe) | -| `Operations_Research_mit_Python_Version_04.pdf` | generiert: PDF, 737 Seiten | +| `Operations_Research_mit_Python_Version_04.pdf` | generiert: PDF, 744 Seiten | | `OR_HTML_04/` | generiert: **Mehrseiten-Website** — dieser Ordner wird veröffentlicht | -| `Operations_Research_mit_Python_Version_04_Programme/` | generiert: 75 lauffähige Beispielprogramme | +| `Operations_Research_mit_Python_Version_04_Programme/` | generiert: 76 lauffähige Beispielprogramme | | `Notebooks_04/` | generiert: ein Jupyter-Notebook je Kapitel | | `PLAN.md` / `PROGRESS.md` | das Ziel des Umbaus und der erreichte Stand | | `pyproject.toml` | Abhängigkeiten in Gruppen (`finance`, `large-scale`, `api`, …) |