diff --git a/Notebooks_04/praxisfallen.ipynb b/Notebooks_04/praxisfallen.ipynb
index 9af1b14..d1958eb 100644
--- a/Notebooks_04/praxisfallen.ipynb
+++ b/Notebooks_04/praxisfallen.ipynb
@@ -252,6 +252,328 @@
" print(\"=\" * 78)"
]
},
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Constraint Attribution: welche Bedingung kostet wie viel?\n",
+ "\n",
+ "`Constraint_Attribution.py`\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#!/usr/bin/env python3\n",
+ "\n",
+ "# Constraint_Attribution.py\n",
+ "\"\"\"\n",
+ "Kapitel Praxisfallen: Welche Bedingung kostet wie viel - und was sagt man\n",
+ "dem Management?\n",
+ "\n",
+ "Zwischen den beiden bekannten Faellen klafft eine Luecke:\n",
+ "\n",
+ " * Erklaerbarkeit.py beantwortet \"warum diese eine Zuweisung?\"\n",
+ " * Konfliktsuche.py (Anhang Fehlerdiagnose) beantwortet \"warum geht es gar\n",
+ " nicht?\"\n",
+ "\n",
+ "Der haeufigste Fall in der Praxis liegt dazwischen: Das Modell rechnet, der\n",
+ "Plan ist zulaessig - und trotzdem unbefriedigend. Niemand weiss, WORAN es\n",
+ "liegt. Genau das beantwortet Constraint Attribution.\n",
+ "\n",
+ "Gerechnet wird auf derselben Fabrik wie in Konfliktsuche.py, nur nach der\n",
+ "Reparatur: Dort war die Lackierkapazitaet von 150 Stunden der Kern des\n",
+ "Widerspruchs (2*40 + 3*30 = 170 > 150). Eine zweite Schicht hat sie auf 210\n",
+ "gebracht, und der Mindestumsatz wurde auf 10.000 EUR gesenkt. Jetzt ist das\n",
+ "Modell loesbar - und die Frage lautet nicht mehr \"warum nicht?\", sondern\n",
+ "\"was kostet uns was?\".\n",
+ "\n",
+ "Gezeigt werden vier Dinge:\n",
+ " 1. Welche Bedingungen binden - und dass \"bindend\" nicht \"teuer\" heisst.\n",
+ " 2. Dass der Schattenpreis eine MOMENTAUFNAHME ist: hochgerechnet auf eine\n",
+ " realistische Sonderschicht verspricht er hier das Doppelte des\n",
+ " tatsaechlichen Nutzens.\n",
+ " 3. Dass ein Wunsch aus dem Vertrieb nicht teuer, sondern unmoeglich sein\n",
+ " kann - und welche Bedingungen ihn gemeinsam blockieren.\n",
+ " 4. Wie aus alledem ein Bericht in Alltagssprache wird.\n",
+ "\n",
+ "Benoetigt: numpy, scipy\n",
+ "\"\"\"\n",
+ "\n",
+ "from __future__ import annotations\n",
+ "\n",
+ "import textwrap\n",
+ "\n",
+ "import numpy as np\n",
+ "from scipy.optimize import linprog\n",
+ "\n",
+ "PRODUKTE = [\"Rahmen\", \"Gehaeuse\", \"Deckel\", \"Traeger\", \"Halter\"]\n",
+ "DECKUNGSBEITRAG = np.array([35.0, 28.0, 9.0, 22.0, 6.0]) # EUR je Stueck\n",
+ "\n",
+ "# Dieselben Bedingungen wie in Konfliktsuche.py, mit den beiden Reparaturen.\n",
+ "# Der sprechende Name ist keine Kosmetik: Ohne ihn ist kein Bericht moeglich,\n",
+ "# der jemandem ausserhalb der Modellierung etwas sagt.\n",
+ "BEDINGUNGEN: list[tuple[str, list[float], str, float]] = [\n",
+ " (\"Kapazitaet Montage\", [3, 2, 1, 4, 2], \"<=\", 400),\n",
+ " (\"Kapazitaet Lackieren\", [2, 3, 0, 1, 0], \"<=\", 210),\n",
+ " (\"Kapazitaet Pruefung\", [1, 1, 1, 1, 1], \"<=\", 300),\n",
+ " (\"Liefervertrag Rahmen\", [1, 0, 0, 0, 0], \">=\", 40),\n",
+ " (\"Liefervertrag Gehaeuse\", [0, 1, 0, 0, 0], \">=\", 30),\n",
+ " (\"Liefervertrag Deckel\", [0, 0, 1, 0, 0], \">=\", 20),\n",
+ " (\"Liefervertrag Traeger\", [0, 0, 0, 1, 0], \">=\", 25),\n",
+ " (\"Marktgrenze Rahmen\", [1, 0, 0, 0, 0], \"<=\", 120),\n",
+ " (\"Marktgrenze Gehaeuse\", [0, 1, 0, 0, 0], \"<=\", 90),\n",
+ " (\"Marktgrenze Deckel\", [0, 0, 1, 0, 0], \"<=\", 150),\n",
+ " (\"Marktgrenze Halter\", [0, 0, 0, 0, 1], \"<=\", 200),\n",
+ " (\"Mindestumsatz\", [90, 70, 30, 60, 20], \">=\", 10000),\n",
+ " (\"Sortimentsbreite\", [0, 0, 1, 1, 1], \">=\", 100),\n",
+ " (\"Lackierbudget Schicht 2\", [0, 1, 0, 1, 0], \"<=\", 55),\n",
+ "]\n",
+ "\n",
+ "# Was liesse sich im Ernstfall wirklich bewegen - und um wie viel? Nicht \"eine\n",
+ "# Einheit\", sondern der Schritt, den ein Planer tatsaechlich gehen kann: eine\n",
+ "# Sonderschicht, ein nachverhandelter Vertrag. Genau an dieser Groesse\n",
+ "# scheitert das blosse Hochrechnen des Schattenpreises.\n",
+ "STELLSCHRAUBEN: dict[str, tuple[float, str]] = {\n",
+ " \"Kapazitaet Montage\": (+50, \"eine Sonderschicht Montage\"),\n",
+ " \"Kapazitaet Lackieren\": (+30, \"eine Sonderschicht Lackieren\"),\n",
+ " \"Kapazitaet Pruefung\": (+50, \"eine Sonderschicht Pruefung\"),\n",
+ " \"Lackierbudget Schicht 2\": (+10, \"10 Stunden mehr Budget in Schicht 2\"),\n",
+ " \"Liefervertrag Traeger\": (-10, \"10 Stueck weniger Abnahmepflicht Traeger\"),\n",
+ " \"Liefervertrag Gehaeuse\": (-10, \"10 Stueck weniger Abnahmepflicht Gehaeuse\"),\n",
+ "}\n",
+ "\n",
+ "WUNSCH_MENGE = 55.0 # der Vertrieb haette gern mehr Rahmen\n",
+ "WUNSCH_PRODUKT = \"Rahmen\"\n",
+ "\n",
+ "\n",
+ "def loese(bedingungen: list[tuple[str, list[float], str, float]]):\n",
+ " \"\"\"Maximiert den Deckungsbeitrag. linprog minimiert, daher das Vorzeichen.\"\"\"\n",
+ " matrix, rechte_seite = [], []\n",
+ " for _, koeffizienten, richtung, grenze in bedingungen:\n",
+ " zeile = np.array(koeffizienten, dtype=float)\n",
+ " matrix.append(zeile if richtung == \"<=\" else -zeile)\n",
+ " rechte_seite.append(grenze if richtung == \"<=\" else -grenze)\n",
+ " return linprog(-DECKUNGSBEITRAG,\n",
+ " A_ub=np.array(matrix), b_ub=np.array(rechte_seite),\n",
+ " bounds=[(0, None)] * len(PRODUKTE), method=\"highs\")\n",
+ "\n",
+ "\n",
+ "def geaendert(name: str, delta: float,\n",
+ " basis=None) -> list[tuple[str, list[float], str, float]]:\n",
+ " \"\"\"Dieselben Bedingungen, eine davon um delta verschoben.\"\"\"\n",
+ " return [(n, k, r, g + delta if n == name else g)\n",
+ " for n, k, r, g in (basis or BEDINGUNGEN)]\n",
+ "\n",
+ "\n",
+ "def auswertung(ergebnis, bedingungen=None):\n",
+ " \"\"\"Schlupf und Schattenpreis je Bedingung.\"\"\"\n",
+ " x = ergebnis.x\n",
+ " dual = ergebnis.ineqlin.marginals\n",
+ " zeilen = []\n",
+ " for i, (name, koeff, richtung, grenze) in enumerate(bedingungen or BEDINGUNGEN):\n",
+ " links = float(np.array(koeff, dtype=float) @ x)\n",
+ " schlupf = (grenze - links) if richtung == \"<=\" else (links - grenze)\n",
+ " zeilen.append((name, links, richtung, grenze, schlupf, abs(dual[i])))\n",
+ " return zeilen\n",
+ "\n",
+ "\n",
+ "def blockierer(bedingungen, zusatz) -> list[str]:\n",
+ " \"\"\"Welche Bedingungen verhindern 'zusatz' - einzeln weggelassen.\n",
+ "\n",
+ " Dasselbe Vorgehen wie der Deletion Filter in Konfliktsuche.py, nur mit\n",
+ " umgekehrtem Vorzeichen: Dort wird gesucht, was den Widerspruch AUSMACHT,\n",
+ " hier, was einen Wunsch VERHINDERT. Wird das Modell ohne eine Bedingung\n",
+ " loesbar, gehoert sie zum Konflikt.\n",
+ " \"\"\"\n",
+ " gefunden = []\n",
+ " for i, (name, _, _, _) in enumerate(bedingungen):\n",
+ " rest = [c for j, c in enumerate(bedingungen) if j != i] + [zusatz]\n",
+ " if loese(rest).success:\n",
+ " gefunden.append(name)\n",
+ " return gefunden\n",
+ "\n",
+ "\n",
+ "def euro(betrag: float) -> str:\n",
+ " return f\"{betrag:,.2f} EUR\"\n",
+ "\n",
+ "\n",
+ "if __name__ == \"__main__\":\n",
+ " basis = loese(BEDINGUNGEN)\n",
+ " db_basis = -basis.fun\n",
+ " plan = dict(zip(PRODUKTE, basis.x))\n",
+ "\n",
+ " print(\"=\" * 78)\n",
+ " print(\" CONSTRAINT ATTRIBUTION: Was kostet uns welche Bedingung?\")\n",
+ " print(\"=\" * 78)\n",
+ " print(f\"Status: {basis.message.split('(')[0].strip()}\")\n",
+ " print(f\"Deckungsbeitrag des Plans: {euro(db_basis)}\\n\")\n",
+ " print(\" \" + \" \".join(f\"{p}: {m:.1f}\" for p, m in plan.items()))\n",
+ "\n",
+ " # --- 1. Wer begrenzt den Plan? -----------------------------------------\n",
+ " print(\"\\n\" + \"=\" * 78)\n",
+ " print(\" (1) Welche Bedingungen binden - und was kostet die naechste Einheit?\")\n",
+ " print(\"=\" * 78)\n",
+ " print(f\"{'Bedingung':<24} {'genutzt':>9} {'Grenze':>7} {'Schlupf':>8} \"\n",
+ " f\"{'Schattenpreis':>11}\")\n",
+ " print(\"-\" * 78)\n",
+ " bindend = []\n",
+ " for name, links, richtung, grenze, schlupf, dual in auswertung(basis):\n",
+ " marke = \"\"\n",
+ " if abs(schlupf) < 1e-6:\n",
+ " bindend.append((name, dual))\n",
+ " marke = \" <-- bindend\"\n",
+ " print(f\"{name:<24} {links:>9.1f} {grenze:>7.0f} {schlupf:>8.1f} \"\n",
+ " f\"{dual:>11.2f}{marke}\")\n",
+ "\n",
+ " ohne_preis = [n for n, d in bindend if d < 1e-9]\n",
+ " print(f\"\\n {len(bindend)} Bedingungen binden. Aber: \"\n",
+ " f\"{', '.join(ohne_preis)} bindet\")\n",
+ " print(\" mit einem Schattenpreis von 0,00 EUR - sie beruehrt den Plan, ohne ihn\")\n",
+ " print(\" zu verteuern. Bindend und kostenrelevant sind zwei verschiedene Dinge.\")\n",
+ "\n",
+ " # --- 2. Der Schattenpreis als Momentaufnahme ---------------------------\n",
+ " print(\"\\n\" + \"=\" * 78)\n",
+ " print(\" (2) Was der Schattenpreis verschweigt\")\n",
+ " print(\"=\" * 78)\n",
+ " print(\"Der Schattenpreis gilt fuer die NAECHSTE Einheit. Ein Planer kauft aber\")\n",
+ " print(\"keine Einheit, sondern eine ganze Schicht. Beides gegenuebergestellt:\\n\")\n",
+ " preise = {name: dual for name, *_, dual in auswertung(basis)}\n",
+ " print(f\"{'Stellschraube':<24} {'Preis':>9} {'Schritt':>7} \"\n",
+ " f\"{'hochgerechnet':>13} {'gemessen':>9} {'Abw.':>9}\")\n",
+ " print(\"-\" * 78)\n",
+ " wirkung = {}\n",
+ " for name, (delta, _) in STELLSCHRAUBEN.items():\n",
+ " ergebnis = loese(geaendert(name, delta))\n",
+ " echt = (-ergebnis.fun) - db_basis if ergebnis.success else 0.0\n",
+ " linear = preise[name] * abs(delta)\n",
+ " wirkung[name] = echt\n",
+ " print(f\"{name:<24} {preise[name]:>9.2f} {delta:>+7.0f} \"\n",
+ " f\"{linear:>13.2f} {echt:>9.2f} {echt - linear:>+9.2f}\")\n",
+ "\n",
+ " # Wo endet der Gueltigkeitsbereich? Schrittweise nachfahren.\n",
+ " abweichler = max(STELLSCHRAUBEN,\n",
+ " key=lambda n: preise[n] * abs(STELLSCHRAUBEN[n][0]) - wirkung[n])\n",
+ " delta_max = STELLSCHRAUBEN[abweichler][0]\n",
+ " grenze_gueltig = 0.0\n",
+ " schritt = delta_max / 60.0\n",
+ " vorher = db_basis\n",
+ " for i in range(1, 61):\n",
+ " d = schritt * i\n",
+ " e = loese(geaendert(abweichler, d))\n",
+ " db = -e.fun if e.success else vorher\n",
+ " if db - vorher > 1e-9:\n",
+ " grenze_gueltig = d\n",
+ " vorher = db\n",
+ " print(f\"\\n '{abweichler}': hochgerechnet \"\n",
+ " f\"{preise[abweichler] * abs(delta_max):.2f} EUR, tatsaechlich \"\n",
+ " f\"{wirkung[abweichler]:.2f} EUR.\")\n",
+ " print(f\" Der Preis von {preise[abweichler]:.2f} EUR gilt nur bis \"\n",
+ " f\"+{grenze_gueltig:.0f} Stunden. Jede weitere Stunde\")\n",
+ " print(\" dieser Sonderschicht bringt exakt nichts - der Engpass ist dann ein anderer.\")\n",
+ "\n",
+ " nach_preis = sorted(STELLSCHRAUBEN, key=lambda n: -preise[n])[0]\n",
+ " nach_wirkung = max(wirkung, key=wirkung.get)\n",
+ " print(f\"\\n Und die Ranglisten drehen sich: Nach Schattenpreis fuehrt\")\n",
+ " print(f\" '{nach_preis}' ({preise[nach_preis]:.2f} EUR je Einheit),\")\n",
+ " print(f\" nach tatsaechlicher Wirkung '{nach_wirkung}' \"\n",
+ " f\"({wirkung[nach_wirkung]:.2f} EUR).\")\n",
+ " print(\" Der Schattenpreis sagt, was eine Einheit wert ist - nicht, wie viele\")\n",
+ " print(\" Einheiten zu haben sind.\")\n",
+ "\n",
+ " # --- 3. Der Wunsch aus dem Vertrieb ------------------------------------\n",
+ " print(\"\\n\" + \"=\" * 78)\n",
+ " print(f\" (3) Was-waere-wenn: mindestens {WUNSCH_MENGE:.0f} \"\n",
+ " f\"{WUNSCH_PRODUKT} statt {plan[WUNSCH_PRODUKT]:.1f}\")\n",
+ " print(\"=\" * 78)\n",
+ " wunsch = (f\"Wunsch Vertrieb {WUNSCH_PRODUKT}\",\n",
+ " [1 if p == WUNSCH_PRODUKT else 0 for p in PRODUKTE], \">=\", WUNSCH_MENGE)\n",
+ " mit_wunsch = loese(BEDINGUNGEN + [wunsch])\n",
+ " schuldige: list[str] = []\n",
+ " engpass = noetig = None\n",
+ " wunsch_gratis = False\n",
+ "\n",
+ " if mit_wunsch.success:\n",
+ " print(f\"Erfuellbar, kostet {euro(db_basis - (-mit_wunsch.fun))}.\")\n",
+ " else:\n",
+ " schuldige = blockierer(BEDINGUNGEN, wunsch)\n",
+ " print(\"Der Wunsch ist nicht teuer - er ist UNMOEGLICH.\\n\")\n",
+ " print(f\"Blockiert wird er von {len(schuldige)} Bedingungen gemeinsam. \"\n",
+ " f\"Faellt eine davon\")\n",
+ " print(\"weg, ist er erfuellbar; keine allein ist 'der' Grund:\")\n",
+ " for name in schuldige:\n",
+ " e = loese([c for c in BEDINGUNGEN if c[0] != name] + [wunsch])\n",
+ " print(f\" ohne '{name}': moeglich, Deckungsbeitrag {euro(-e.fun)}\")\n",
+ "\n",
+ " engpass = next((n for n in schuldige if n in STELLSCHRAUBEN), schuldige[0])\n",
+ " for schritt in range(1, 61):\n",
+ " if loese(geaendert(engpass, schritt) + [wunsch]).success:\n",
+ " noetig = schritt\n",
+ " break\n",
+ " if noetig is not None:\n",
+ " e = loese(geaendert(engpass, noetig) + [wunsch])\n",
+ " ohne = loese(geaendert(engpass, noetig))\n",
+ " wunsch_gratis = abs((-e.fun) - (-ohne.fun)) < 1e-6\n",
+ " print(f\"\\n Mit +{noetig} bei '{engpass}' wird der Wunsch erfuellbar:\")\n",
+ " print(f\" Deckungsbeitrag {euro(-e.fun)} \"\n",
+ " f\"({-e.fun - db_basis:+,.2f} gegenueber heute).\")\n",
+ " if wunsch_gratis:\n",
+ " print(f\" Und ohne den Wunsch ergaebe dieselbe Lockerung \"\n",
+ " f\"{euro(-ohne.fun)} bei\")\n",
+ " print(f\" {ohne.x[PRODUKTE.index(WUNSCH_PRODUKT)]:.0f} \"\n",
+ " f\"{WUNSCH_PRODUKT} - der Wunsch kostet also NICHTS.\")\n",
+ " print(\" Er war nie das Problem. Der Engpass war es.\")\n",
+ "\n",
+ " # --- 4. Der Bericht ----------------------------------------------------\n",
+ " # Aus denselben Zahlen, die oben in Tabellen stehen, wird hier Prosa. Kein\n",
+ " # Satz enthaelt eine Zahl, die nicht vorher gerechnet wurde - das ist der\n",
+ " # ganze Trick und zugleich die Bedingung dafuer, dass der Bericht nicht\n",
+ " # irgendwann leise falsch wird.\n",
+ " print(\"\\n\" + \"=\" * 78)\n",
+ " print(\" (4) Und so steht es in der Vorlage fuer die Abteilungsleiterrunde\")\n",
+ " print(\"=\" * 78)\n",
+ "\n",
+ " teuerste = sorted(((n, d) for n, d in bindend if d > 0), key=lambda t: -t[1])\n",
+ " gross = STELLSCHRAUBEN[nach_wirkung][1]\n",
+ " klein = STELLSCHRAUBEN[abweichler][1]\n",
+ "\n",
+ " absaetze = [\n",
+ " f\"Der Plan bringt {euro(db_basis)} Deckungsbeitrag. Begrenzt wird er von \"\n",
+ " f\"{len(bindend)} Bedingungen, von denen {len(teuerste)} tatsaechlich Geld \"\n",
+ " f\"kosten; \\\"{ohne_preis[0]}\\\" beruehrt den Plan, ohne ihn zu verteuern.\",\n",
+ "\n",
+ " f\"Teuerste Bindung ist \\\"{teuerste[0][0]}\\\" mit \"\n",
+ " f\"{teuerste[0][1]:.2f} EUR je Einheit, gefolgt von \\\"{teuerste[1][0]}\\\" \"\n",
+ " f\"mit {teuerste[1][1]:.2f} EUR.\",\n",
+ "\n",
+ " f\"Die groesste einzelne Verbesserung bringt {gross}: \"\n",
+ " f\"{euro(wirkung[nach_wirkung])} mehr Deckungsbeitrag. {klein[0].upper()}{klein[1:]} \"\n",
+ " f\"dagegen bringt nur {euro(wirkung[abweichler])} statt der rechnerischen \"\n",
+ " f\"{euro(preise[abweichler] * abs(delta_max))} - ab +{grenze_gueltig:.0f} \"\n",
+ " f\"Stunden begrenzt uns etwas anderes. Wer die ganze Schicht bezahlt, \"\n",
+ " f\"bezahlt die zweite Haelfte umsonst.\",\n",
+ " ]\n",
+ "\n",
+ " if schuldige and noetig is not None:\n",
+ " namen = \", \".join(f'\"{s}\"' for s in schuldige)\n",
+ " satz = (f\"Zum Wunsch des Vertriebs ({WUNSCH_MENGE:.0f} {WUNSCH_PRODUKT} statt \"\n",
+ " f\"{plan[WUNSCH_PRODUKT]:.1f}): Er ist mit der heutigen Ausstattung \"\n",
+ " f\"nicht erfuellbar - nicht aus Kostengruenden, sondern weil {namen} \"\n",
+ " f\"ihn gemeinsam blockieren. Mit +{noetig} Stunden bei \"\n",
+ " f'\"{engpass}\" wird er moeglich')\n",
+ " satz += (\", und er kostet dann nichts: Der Plan waehlt diese Menge von \"\n",
+ " \"sich aus.\" if wunsch_gratis else \".\")\n",
+ " absaetze.append(satz)\n",
+ "\n",
+ " for absatz in absaetze:\n",
+ " print()\n",
+ " print(textwrap.fill(absatz, width=76))\n",
+ " print(\"\\n\" + \"=\" * 78)"
+ ]
+ },
{
"cell_type": "markdown",
"metadata": {},
diff --git a/OR_HTML_04/Notebooks_04/praxisfallen.ipynb b/OR_HTML_04/Notebooks_04/praxisfallen.ipynb
index 9af1b14..d1958eb 100644
--- a/OR_HTML_04/Notebooks_04/praxisfallen.ipynb
+++ b/OR_HTML_04/Notebooks_04/praxisfallen.ipynb
@@ -252,6 +252,328 @@
" print(\"=\" * 78)"
]
},
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Constraint Attribution: welche Bedingung kostet wie viel?\n",
+ "\n",
+ "`Constraint_Attribution.py`\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#!/usr/bin/env python3\n",
+ "\n",
+ "# Constraint_Attribution.py\n",
+ "\"\"\"\n",
+ "Kapitel Praxisfallen: Welche Bedingung kostet wie viel - und was sagt man\n",
+ "dem Management?\n",
+ "\n",
+ "Zwischen den beiden bekannten Faellen klafft eine Luecke:\n",
+ "\n",
+ " * Erklaerbarkeit.py beantwortet \"warum diese eine Zuweisung?\"\n",
+ " * Konfliktsuche.py (Anhang Fehlerdiagnose) beantwortet \"warum geht es gar\n",
+ " nicht?\"\n",
+ "\n",
+ "Der haeufigste Fall in der Praxis liegt dazwischen: Das Modell rechnet, der\n",
+ "Plan ist zulaessig - und trotzdem unbefriedigend. Niemand weiss, WORAN es\n",
+ "liegt. Genau das beantwortet Constraint Attribution.\n",
+ "\n",
+ "Gerechnet wird auf derselben Fabrik wie in Konfliktsuche.py, nur nach der\n",
+ "Reparatur: Dort war die Lackierkapazitaet von 150 Stunden der Kern des\n",
+ "Widerspruchs (2*40 + 3*30 = 170 > 150). Eine zweite Schicht hat sie auf 210\n",
+ "gebracht, und der Mindestumsatz wurde auf 10.000 EUR gesenkt. Jetzt ist das\n",
+ "Modell loesbar - und die Frage lautet nicht mehr \"warum nicht?\", sondern\n",
+ "\"was kostet uns was?\".\n",
+ "\n",
+ "Gezeigt werden vier Dinge:\n",
+ " 1. Welche Bedingungen binden - und dass \"bindend\" nicht \"teuer\" heisst.\n",
+ " 2. Dass der Schattenpreis eine MOMENTAUFNAHME ist: hochgerechnet auf eine\n",
+ " realistische Sonderschicht verspricht er hier das Doppelte des\n",
+ " tatsaechlichen Nutzens.\n",
+ " 3. Dass ein Wunsch aus dem Vertrieb nicht teuer, sondern unmoeglich sein\n",
+ " kann - und welche Bedingungen ihn gemeinsam blockieren.\n",
+ " 4. Wie aus alledem ein Bericht in Alltagssprache wird.\n",
+ "\n",
+ "Benoetigt: numpy, scipy\n",
+ "\"\"\"\n",
+ "\n",
+ "from __future__ import annotations\n",
+ "\n",
+ "import textwrap\n",
+ "\n",
+ "import numpy as np\n",
+ "from scipy.optimize import linprog\n",
+ "\n",
+ "PRODUKTE = [\"Rahmen\", \"Gehaeuse\", \"Deckel\", \"Traeger\", \"Halter\"]\n",
+ "DECKUNGSBEITRAG = np.array([35.0, 28.0, 9.0, 22.0, 6.0]) # EUR je Stueck\n",
+ "\n",
+ "# Dieselben Bedingungen wie in Konfliktsuche.py, mit den beiden Reparaturen.\n",
+ "# Der sprechende Name ist keine Kosmetik: Ohne ihn ist kein Bericht moeglich,\n",
+ "# der jemandem ausserhalb der Modellierung etwas sagt.\n",
+ "BEDINGUNGEN: list[tuple[str, list[float], str, float]] = [\n",
+ " (\"Kapazitaet Montage\", [3, 2, 1, 4, 2], \"<=\", 400),\n",
+ " (\"Kapazitaet Lackieren\", [2, 3, 0, 1, 0], \"<=\", 210),\n",
+ " (\"Kapazitaet Pruefung\", [1, 1, 1, 1, 1], \"<=\", 300),\n",
+ " (\"Liefervertrag Rahmen\", [1, 0, 0, 0, 0], \">=\", 40),\n",
+ " (\"Liefervertrag Gehaeuse\", [0, 1, 0, 0, 0], \">=\", 30),\n",
+ " (\"Liefervertrag Deckel\", [0, 0, 1, 0, 0], \">=\", 20),\n",
+ " (\"Liefervertrag Traeger\", [0, 0, 0, 1, 0], \">=\", 25),\n",
+ " (\"Marktgrenze Rahmen\", [1, 0, 0, 0, 0], \"<=\", 120),\n",
+ " (\"Marktgrenze Gehaeuse\", [0, 1, 0, 0, 0], \"<=\", 90),\n",
+ " (\"Marktgrenze Deckel\", [0, 0, 1, 0, 0], \"<=\", 150),\n",
+ " (\"Marktgrenze Halter\", [0, 0, 0, 0, 1], \"<=\", 200),\n",
+ " (\"Mindestumsatz\", [90, 70, 30, 60, 20], \">=\", 10000),\n",
+ " (\"Sortimentsbreite\", [0, 0, 1, 1, 1], \">=\", 100),\n",
+ " (\"Lackierbudget Schicht 2\", [0, 1, 0, 1, 0], \"<=\", 55),\n",
+ "]\n",
+ "\n",
+ "# Was liesse sich im Ernstfall wirklich bewegen - und um wie viel? Nicht \"eine\n",
+ "# Einheit\", sondern der Schritt, den ein Planer tatsaechlich gehen kann: eine\n",
+ "# Sonderschicht, ein nachverhandelter Vertrag. Genau an dieser Groesse\n",
+ "# scheitert das blosse Hochrechnen des Schattenpreises.\n",
+ "STELLSCHRAUBEN: dict[str, tuple[float, str]] = {\n",
+ " \"Kapazitaet Montage\": (+50, \"eine Sonderschicht Montage\"),\n",
+ " \"Kapazitaet Lackieren\": (+30, \"eine Sonderschicht Lackieren\"),\n",
+ " \"Kapazitaet Pruefung\": (+50, \"eine Sonderschicht Pruefung\"),\n",
+ " \"Lackierbudget Schicht 2\": (+10, \"10 Stunden mehr Budget in Schicht 2\"),\n",
+ " \"Liefervertrag Traeger\": (-10, \"10 Stueck weniger Abnahmepflicht Traeger\"),\n",
+ " \"Liefervertrag Gehaeuse\": (-10, \"10 Stueck weniger Abnahmepflicht Gehaeuse\"),\n",
+ "}\n",
+ "\n",
+ "WUNSCH_MENGE = 55.0 # der Vertrieb haette gern mehr Rahmen\n",
+ "WUNSCH_PRODUKT = \"Rahmen\"\n",
+ "\n",
+ "\n",
+ "def loese(bedingungen: list[tuple[str, list[float], str, float]]):\n",
+ " \"\"\"Maximiert den Deckungsbeitrag. linprog minimiert, daher das Vorzeichen.\"\"\"\n",
+ " matrix, rechte_seite = [], []\n",
+ " for _, koeffizienten, richtung, grenze in bedingungen:\n",
+ " zeile = np.array(koeffizienten, dtype=float)\n",
+ " matrix.append(zeile if richtung == \"<=\" else -zeile)\n",
+ " rechte_seite.append(grenze if richtung == \"<=\" else -grenze)\n",
+ " return linprog(-DECKUNGSBEITRAG,\n",
+ " A_ub=np.array(matrix), b_ub=np.array(rechte_seite),\n",
+ " bounds=[(0, None)] * len(PRODUKTE), method=\"highs\")\n",
+ "\n",
+ "\n",
+ "def geaendert(name: str, delta: float,\n",
+ " basis=None) -> list[tuple[str, list[float], str, float]]:\n",
+ " \"\"\"Dieselben Bedingungen, eine davon um delta verschoben.\"\"\"\n",
+ " return [(n, k, r, g + delta if n == name else g)\n",
+ " for n, k, r, g in (basis or BEDINGUNGEN)]\n",
+ "\n",
+ "\n",
+ "def auswertung(ergebnis, bedingungen=None):\n",
+ " \"\"\"Schlupf und Schattenpreis je Bedingung.\"\"\"\n",
+ " x = ergebnis.x\n",
+ " dual = ergebnis.ineqlin.marginals\n",
+ " zeilen = []\n",
+ " for i, (name, koeff, richtung, grenze) in enumerate(bedingungen or BEDINGUNGEN):\n",
+ " links = float(np.array(koeff, dtype=float) @ x)\n",
+ " schlupf = (grenze - links) if richtung == \"<=\" else (links - grenze)\n",
+ " zeilen.append((name, links, richtung, grenze, schlupf, abs(dual[i])))\n",
+ " return zeilen\n",
+ "\n",
+ "\n",
+ "def blockierer(bedingungen, zusatz) -> list[str]:\n",
+ " \"\"\"Welche Bedingungen verhindern 'zusatz' - einzeln weggelassen.\n",
+ "\n",
+ " Dasselbe Vorgehen wie der Deletion Filter in Konfliktsuche.py, nur mit\n",
+ " umgekehrtem Vorzeichen: Dort wird gesucht, was den Widerspruch AUSMACHT,\n",
+ " hier, was einen Wunsch VERHINDERT. Wird das Modell ohne eine Bedingung\n",
+ " loesbar, gehoert sie zum Konflikt.\n",
+ " \"\"\"\n",
+ " gefunden = []\n",
+ " for i, (name, _, _, _) in enumerate(bedingungen):\n",
+ " rest = [c for j, c in enumerate(bedingungen) if j != i] + [zusatz]\n",
+ " if loese(rest).success:\n",
+ " gefunden.append(name)\n",
+ " return gefunden\n",
+ "\n",
+ "\n",
+ "def euro(betrag: float) -> str:\n",
+ " return f\"{betrag:,.2f} EUR\"\n",
+ "\n",
+ "\n",
+ "if __name__ == \"__main__\":\n",
+ " basis = loese(BEDINGUNGEN)\n",
+ " db_basis = -basis.fun\n",
+ " plan = dict(zip(PRODUKTE, basis.x))\n",
+ "\n",
+ " print(\"=\" * 78)\n",
+ " print(\" CONSTRAINT ATTRIBUTION: Was kostet uns welche Bedingung?\")\n",
+ " print(\"=\" * 78)\n",
+ " print(f\"Status: {basis.message.split('(')[0].strip()}\")\n",
+ " print(f\"Deckungsbeitrag des Plans: {euro(db_basis)}\\n\")\n",
+ " print(\" \" + \" \".join(f\"{p}: {m:.1f}\" for p, m in plan.items()))\n",
+ "\n",
+ " # --- 1. Wer begrenzt den Plan? -----------------------------------------\n",
+ " print(\"\\n\" + \"=\" * 78)\n",
+ " print(\" (1) Welche Bedingungen binden - und was kostet die naechste Einheit?\")\n",
+ " print(\"=\" * 78)\n",
+ " print(f\"{'Bedingung':<24} {'genutzt':>9} {'Grenze':>7} {'Schlupf':>8} \"\n",
+ " f\"{'Schattenpreis':>11}\")\n",
+ " print(\"-\" * 78)\n",
+ " bindend = []\n",
+ " for name, links, richtung, grenze, schlupf, dual in auswertung(basis):\n",
+ " marke = \"\"\n",
+ " if abs(schlupf) < 1e-6:\n",
+ " bindend.append((name, dual))\n",
+ " marke = \" <-- bindend\"\n",
+ " print(f\"{name:<24} {links:>9.1f} {grenze:>7.0f} {schlupf:>8.1f} \"\n",
+ " f\"{dual:>11.2f}{marke}\")\n",
+ "\n",
+ " ohne_preis = [n for n, d in bindend if d < 1e-9]\n",
+ " print(f\"\\n {len(bindend)} Bedingungen binden. Aber: \"\n",
+ " f\"{', '.join(ohne_preis)} bindet\")\n",
+ " print(\" mit einem Schattenpreis von 0,00 EUR - sie beruehrt den Plan, ohne ihn\")\n",
+ " print(\" zu verteuern. Bindend und kostenrelevant sind zwei verschiedene Dinge.\")\n",
+ "\n",
+ " # --- 2. Der Schattenpreis als Momentaufnahme ---------------------------\n",
+ " print(\"\\n\" + \"=\" * 78)\n",
+ " print(\" (2) Was der Schattenpreis verschweigt\")\n",
+ " print(\"=\" * 78)\n",
+ " print(\"Der Schattenpreis gilt fuer die NAECHSTE Einheit. Ein Planer kauft aber\")\n",
+ " print(\"keine Einheit, sondern eine ganze Schicht. Beides gegenuebergestellt:\\n\")\n",
+ " preise = {name: dual for name, *_, dual in auswertung(basis)}\n",
+ " print(f\"{'Stellschraube':<24} {'Preis':>9} {'Schritt':>7} \"\n",
+ " f\"{'hochgerechnet':>13} {'gemessen':>9} {'Abw.':>9}\")\n",
+ " print(\"-\" * 78)\n",
+ " wirkung = {}\n",
+ " for name, (delta, _) in STELLSCHRAUBEN.items():\n",
+ " ergebnis = loese(geaendert(name, delta))\n",
+ " echt = (-ergebnis.fun) - db_basis if ergebnis.success else 0.0\n",
+ " linear = preise[name] * abs(delta)\n",
+ " wirkung[name] = echt\n",
+ " print(f\"{name:<24} {preise[name]:>9.2f} {delta:>+7.0f} \"\n",
+ " f\"{linear:>13.2f} {echt:>9.2f} {echt - linear:>+9.2f}\")\n",
+ "\n",
+ " # Wo endet der Gueltigkeitsbereich? Schrittweise nachfahren.\n",
+ " abweichler = max(STELLSCHRAUBEN,\n",
+ " key=lambda n: preise[n] * abs(STELLSCHRAUBEN[n][0]) - wirkung[n])\n",
+ " delta_max = STELLSCHRAUBEN[abweichler][0]\n",
+ " grenze_gueltig = 0.0\n",
+ " schritt = delta_max / 60.0\n",
+ " vorher = db_basis\n",
+ " for i in range(1, 61):\n",
+ " d = schritt * i\n",
+ " e = loese(geaendert(abweichler, d))\n",
+ " db = -e.fun if e.success else vorher\n",
+ " if db - vorher > 1e-9:\n",
+ " grenze_gueltig = d\n",
+ " vorher = db\n",
+ " print(f\"\\n '{abweichler}': hochgerechnet \"\n",
+ " f\"{preise[abweichler] * abs(delta_max):.2f} EUR, tatsaechlich \"\n",
+ " f\"{wirkung[abweichler]:.2f} EUR.\")\n",
+ " print(f\" Der Preis von {preise[abweichler]:.2f} EUR gilt nur bis \"\n",
+ " f\"+{grenze_gueltig:.0f} Stunden. Jede weitere Stunde\")\n",
+ " print(\" dieser Sonderschicht bringt exakt nichts - der Engpass ist dann ein anderer.\")\n",
+ "\n",
+ " nach_preis = sorted(STELLSCHRAUBEN, key=lambda n: -preise[n])[0]\n",
+ " nach_wirkung = max(wirkung, key=wirkung.get)\n",
+ " print(f\"\\n Und die Ranglisten drehen sich: Nach Schattenpreis fuehrt\")\n",
+ " print(f\" '{nach_preis}' ({preise[nach_preis]:.2f} EUR je Einheit),\")\n",
+ " print(f\" nach tatsaechlicher Wirkung '{nach_wirkung}' \"\n",
+ " f\"({wirkung[nach_wirkung]:.2f} EUR).\")\n",
+ " print(\" Der Schattenpreis sagt, was eine Einheit wert ist - nicht, wie viele\")\n",
+ " print(\" Einheiten zu haben sind.\")\n",
+ "\n",
+ " # --- 3. Der Wunsch aus dem Vertrieb ------------------------------------\n",
+ " print(\"\\n\" + \"=\" * 78)\n",
+ " print(f\" (3) Was-waere-wenn: mindestens {WUNSCH_MENGE:.0f} \"\n",
+ " f\"{WUNSCH_PRODUKT} statt {plan[WUNSCH_PRODUKT]:.1f}\")\n",
+ " print(\"=\" * 78)\n",
+ " wunsch = (f\"Wunsch Vertrieb {WUNSCH_PRODUKT}\",\n",
+ " [1 if p == WUNSCH_PRODUKT else 0 for p in PRODUKTE], \">=\", WUNSCH_MENGE)\n",
+ " mit_wunsch = loese(BEDINGUNGEN + [wunsch])\n",
+ " schuldige: list[str] = []\n",
+ " engpass = noetig = None\n",
+ " wunsch_gratis = False\n",
+ "\n",
+ " if mit_wunsch.success:\n",
+ " print(f\"Erfuellbar, kostet {euro(db_basis - (-mit_wunsch.fun))}.\")\n",
+ " else:\n",
+ " schuldige = blockierer(BEDINGUNGEN, wunsch)\n",
+ " print(\"Der Wunsch ist nicht teuer - er ist UNMOEGLICH.\\n\")\n",
+ " print(f\"Blockiert wird er von {len(schuldige)} Bedingungen gemeinsam. \"\n",
+ " f\"Faellt eine davon\")\n",
+ " print(\"weg, ist er erfuellbar; keine allein ist 'der' Grund:\")\n",
+ " for name in schuldige:\n",
+ " e = loese([c for c in BEDINGUNGEN if c[0] != name] + [wunsch])\n",
+ " print(f\" ohne '{name}': moeglich, Deckungsbeitrag {euro(-e.fun)}\")\n",
+ "\n",
+ " engpass = next((n for n in schuldige if n in STELLSCHRAUBEN), schuldige[0])\n",
+ " for schritt in range(1, 61):\n",
+ " if loese(geaendert(engpass, schritt) + [wunsch]).success:\n",
+ " noetig = schritt\n",
+ " break\n",
+ " if noetig is not None:\n",
+ " e = loese(geaendert(engpass, noetig) + [wunsch])\n",
+ " ohne = loese(geaendert(engpass, noetig))\n",
+ " wunsch_gratis = abs((-e.fun) - (-ohne.fun)) < 1e-6\n",
+ " print(f\"\\n Mit +{noetig} bei '{engpass}' wird der Wunsch erfuellbar:\")\n",
+ " print(f\" Deckungsbeitrag {euro(-e.fun)} \"\n",
+ " f\"({-e.fun - db_basis:+,.2f} gegenueber heute).\")\n",
+ " if wunsch_gratis:\n",
+ " print(f\" Und ohne den Wunsch ergaebe dieselbe Lockerung \"\n",
+ " f\"{euro(-ohne.fun)} bei\")\n",
+ " print(f\" {ohne.x[PRODUKTE.index(WUNSCH_PRODUKT)]:.0f} \"\n",
+ " f\"{WUNSCH_PRODUKT} - der Wunsch kostet also NICHTS.\")\n",
+ " print(\" Er war nie das Problem. Der Engpass war es.\")\n",
+ "\n",
+ " # --- 4. Der Bericht ----------------------------------------------------\n",
+ " # Aus denselben Zahlen, die oben in Tabellen stehen, wird hier Prosa. Kein\n",
+ " # Satz enthaelt eine Zahl, die nicht vorher gerechnet wurde - das ist der\n",
+ " # ganze Trick und zugleich die Bedingung dafuer, dass der Bericht nicht\n",
+ " # irgendwann leise falsch wird.\n",
+ " print(\"\\n\" + \"=\" * 78)\n",
+ " print(\" (4) Und so steht es in der Vorlage fuer die Abteilungsleiterrunde\")\n",
+ " print(\"=\" * 78)\n",
+ "\n",
+ " teuerste = sorted(((n, d) for n, d in bindend if d > 0), key=lambda t: -t[1])\n",
+ " gross = STELLSCHRAUBEN[nach_wirkung][1]\n",
+ " klein = STELLSCHRAUBEN[abweichler][1]\n",
+ "\n",
+ " absaetze = [\n",
+ " f\"Der Plan bringt {euro(db_basis)} Deckungsbeitrag. Begrenzt wird er von \"\n",
+ " f\"{len(bindend)} Bedingungen, von denen {len(teuerste)} tatsaechlich Geld \"\n",
+ " f\"kosten; \\\"{ohne_preis[0]}\\\" beruehrt den Plan, ohne ihn zu verteuern.\",\n",
+ "\n",
+ " f\"Teuerste Bindung ist \\\"{teuerste[0][0]}\\\" mit \"\n",
+ " f\"{teuerste[0][1]:.2f} EUR je Einheit, gefolgt von \\\"{teuerste[1][0]}\\\" \"\n",
+ " f\"mit {teuerste[1][1]:.2f} EUR.\",\n",
+ "\n",
+ " f\"Die groesste einzelne Verbesserung bringt {gross}: \"\n",
+ " f\"{euro(wirkung[nach_wirkung])} mehr Deckungsbeitrag. {klein[0].upper()}{klein[1:]} \"\n",
+ " f\"dagegen bringt nur {euro(wirkung[abweichler])} statt der rechnerischen \"\n",
+ " f\"{euro(preise[abweichler] * abs(delta_max))} - ab +{grenze_gueltig:.0f} \"\n",
+ " f\"Stunden begrenzt uns etwas anderes. Wer die ganze Schicht bezahlt, \"\n",
+ " f\"bezahlt die zweite Haelfte umsonst.\",\n",
+ " ]\n",
+ "\n",
+ " if schuldige and noetig is not None:\n",
+ " namen = \", \".join(f'\"{s}\"' for s in schuldige)\n",
+ " satz = (f\"Zum Wunsch des Vertriebs ({WUNSCH_MENGE:.0f} {WUNSCH_PRODUKT} statt \"\n",
+ " f\"{plan[WUNSCH_PRODUKT]:.1f}): Er ist mit der heutigen Ausstattung \"\n",
+ " f\"nicht erfuellbar - nicht aus Kostengruenden, sondern weil {namen} \"\n",
+ " f\"ihn gemeinsam blockieren. Mit +{noetig} Stunden bei \"\n",
+ " f'\"{engpass}\" wird er moeglich')\n",
+ " satz += (\", und er kostet dann nichts: Der Plan waehlt diese Menge von \"\n",
+ " \"sich aus.\" if wunsch_gratis else \".\")\n",
+ " absaetze.append(satz)\n",
+ "\n",
+ " for absatz in absaetze:\n",
+ " print()\n",
+ " print(textwrap.fill(absatz, width=76))\n",
+ " print(\"\\n\" + \"=\" * 78)"
+ ]
+ },
{
"cell_type": "markdown",
"metadata": {},
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 fbf9d44..4e50397 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 35b8014..d7f775e 100644
--- a/OR_HTML_04/anhang-loesungen.html
+++ b/OR_HTML_04/anhang-loesungen.html
@@ -1348,8 +1348,9 @@ T \ge \left(\frac{2\sigma}{\mu}\right)^2 = \left(\frac{2 \cdot 0{,}012}{0{,}0004
22.2 — Zeitlimit wählen. (a) 1–3 s, Gap 5–10 % (Echtzeit schlägt Optimalität). (b) 1–5 min, Gap 1–2 %. (c) Stunden, Gap ~0 % (einmalige, folgenreiche Entscheidung). (d) 30–60 s, Gap 1 % — die Datenunsicherheit ist ohnehin größer.
22.3 — Relaxation einbauen. Muster wie in Infeasibility_Diagnose.py: Schlupfvariable je Mindestbesetzung, Strafe deutlich über allen weichen Zielen, aber endlich. Wichtig: gestaffelte Strafen, damit der Solver die am wenigsten schmerzhafte Verletzung wählt.
22.4 — Erklärbarkeit. Report-Struktur: (1) Zielwert und Aufschlüsselung nach Bestandteilen; (2) je Entscheidung die bindenden Bedingungen; (3) Schattenpreise der knappsten Ressourcen mit Handlungsempfehlung.
-
22.5 — Gap gegen Laufzeit. Erwartetes Muster: Von 10 % auf 2 % kostet wenig Zeit; von 1 % auf 0 % kann die Laufzeit um Größenordnungen steigen, ohne dass sich der Zielwert nennenswert verbessert. Empfehlung: Gap dort ansetzen, wo die Kurve knickt — typischerweise 1–2 %.
-
22.6 — Post-Mortem. Bewertungskriterien: Wird zwischen Symptom, Ursache und fehlender Prüfung unterschieden? Ist die abgeleitete Regel allgemein genug, um beim nächsten Projekt zu helfen (z. B. „Nach jeder Vorzeichenumkehr eine numerische Gegenprobe“), aber konkret genug, um überprüfbar zu sein?
+
22.5 — Den Bericht in die Irre führen. (a) Der Plan kippt: Rahmen fällt von 47,5 auf 40 — das Minimum aus dem Liefervertrag —, Deckel steigt von 97,5 auf 120, die Marktgrenze. Der Deckungsbeitrag springt von 3 930 € auf 5 190 €. Mit ihm wechselt die Engpassstruktur: „Kapazität Lackieren” bindet nicht mehr, dafür bindet jetzt „Liefervertrag Rahmen” — und zwar als Kosten von 25 € je Stück, weil der Vertrag zur Herstellung eines inzwischen unattraktiven Produkts zwingt. Alle Schattenpreise steigen deutlich (Träger von 18 € auf 58 €). Der entscheidende Punkt der Aufgabe: Der Satz „Teuerste Bindung ist Liefervertrag Träger” steht wörtlich unverändert im Bericht — obwohl die Zahl dahinter sich mehr als verdreifacht hat und ein anderer Engpass die Fabrik begrenzt. Ein Bericht, der sich nicht ändert, ist kein Beweis dafür, dass sich nichts geändert hat. (b) Etwa: „Unter der Annahme eines Deckungsbeitrags von 9 € je Deckel ist ‚Liefervertrag Träger’ die teuerste Bindung (18 € je Stück).” Die Aussage bekommt damit ihre Voraussetzung mit — und wird angreifbar, was sie sein soll. (c) Mindestens eines von beidem: den Gültigkeitsbereich jedes Schattenpreises (bis wohin gilt er?), oder das Ergebnis einer Sensitivitätsrechnung über die unsichersten Eingangsgrößen — etwa „bei ±20 % Deckungsbeitrag Deckel bleibt die Rangfolge/kippt sie”. Das Programm rechnet den Gültigkeitsbereich für die auffälligste Stellschraube bereits aus; ihn für alle auszuweisen ist eine kleine Erweiterung.
+
22.6 — Gap gegen Laufzeit. Erwartetes Muster: Von 10 % auf 2 % kostet wenig Zeit; von 1 % auf 0 % kann die Laufzeit um Größenordnungen steigen, ohne dass sich der Zielwert nennenswert verbessert. Empfehlung: Gap dort ansetzen, wo die Kurve knickt — typischerweise 1–2 %.
+
22.7 — Post-Mortem. Bewertungskriterien: Wird zwischen Symptom, Ursache und fehlender Prüfung unterschieden? Ist die abgeleitete Regel allgemein genug, um beim nächsten Projekt zu helfen (z. B. „Nach jeder Vorzeichenumkehr eine numerische Gegenprobe“), aber konkret genug, um überprüfbar zu sein?
Finde den Denkfehler — Das Modell, das seit einem Jahr nicht mehr optimiert
Warum die konstante Laufzeit das Symptom ist. Ein Solver, der fertig wird, braucht so lange, wie das Problem eben dauert — und diese Zeit wächst mit den Daten. Eine Laufzeit, die über Jahre exakt bei 3,00 Sekunden liegt, kann deshalb nur eines bedeuten: Der Job wird nicht fertig, sondern abgeschnitten.
Weil die Datenunsicherheit (oft ±10 %) die verbleibende Optimalitätslücke bei Weitem übersteigt.
Jeder Lauf arbeitet auf einem unveränderlichen, mit ID versehenen Datenstand — nur so sind Ergebnisse reproduzierbar und belegbar.
Die Einführung: mangelnde Akzeptanz, weil Ergebnisse nicht nachvollziehbar sind.
+
Sie berührt den Plan, ohne ihn zu verteuern — die Lösung liegt genau auf ihrer Grenze, wäre aber auch ohne sie dieselbe (Entartung). Für eine Nachverhandlung folgt: nichts tun. Der Aufwand brächte 0 €. „Bindend” ist ein geometrischer, „teuer” ein wirtschaftlicher Befund.
+
Weil der Schattenpreis nur für die nächste Einheit gilt und sein Gültigkeitsbereich endet, sobald ein anderer Engpass bindend wird. Realistische Lockerungen sind unterschiedlich groß — eine Sonderschicht bringt 50 Einheiten, ein nachverhandelter Vertrag 10. Der Preis je Einheit sagt daher nichts darüber, welcher Hebel insgesamt am meisten bringt; dafür muss man die Lockerung rechnen, nicht den Preis multiplizieren.
diff --git a/OR_HTML_04/assets/programme.js b/OR_HTML_04/assets/programme.js
index 5c39e70..ed78974 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", "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", "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 48224fc..f2b493e 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 Architektur einer produktionsreifen OR-Plattform","anker":"sec:praxisfallen-architektur-einer-produktionsreifen-or-plattform","text":""},{"seite":"praxisfallen.html","titel":"22.5 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.6 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.7 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.8 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.9 Übungsaufgaben","anker":"sec:praxisfallen-uebungsaufgaben","text":""},{"seite":"praxisfallen.html","titel":"22.10 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.11 Micro-Quiz","anker":"sec:praxisfallen-quiz","text":""},{"seite":"praxisfallen.html","titel":"22.12 Selbsttest","anker":"sec:praxisfallen-selbsttest","text":""},{"seite":"praxisfallen.html","titel":"22.13 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 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":""}];
diff --git a/OR_HTML_04/bruecke.html b/OR_HTML_04/bruecke.html
index 4b1fdb0..b2280cd 100644
--- a/OR_HTML_04/bruecke.html
+++ b/OR_HTML_04/bruecke.html
@@ -121,7 +121,7 @@
📌 Kapitel auf einen Blick
Worum geht es? Um den Nachweis, dass die Verfahren aus Teil II und III unverändert in der Finanzwelt funktionieren — und um die drei Stellen, an denen die Analogie endet.
Danach können Sie: ein betriebswirtschaftliches Modell in die andere Domäne übersetzen, ohne den Modellcode anzufassen; Schattenpreise in beiden Welten lesen; und benennen, welche Annahmen dabei nicht mitwandern.
3. Kostenzerlegung. Der Block „Woraus bestehen die Strafkosten?“ ist kein Beiwerk. In der Praxis ist die erste Frage jedes Anwenders: „Warum bekomme ich die Stunde?“ Wer darauf keine Antwort hat, dessen System wird nicht benutzt (Kapitel 22, Explainable OR).
4. Statusauswertung ohne CP-SAT-Vokabular. Nach Solve() steht nur eine einzige Zeile mit einer CP-SAT-Konstante darin:
status = status_von_cpsat(rohstatus)
-
Danach heißen die Fälle status.modellfehler (die Regeln widersprechen sich — das Modell muss geändert werden) und status.brauchbar (es liegt etwas Ausführbares vor). Das ist dieselbe Fallunterscheidung wie bei HiGHS in Kapitel 6 und bei GLOP in Kapitel 5; nur die eine Übersetzerzeile weiß, welcher Solver gerechnet hat. Wer den Auswertungscode direkt gegen cp_model.INFEASIBLE schreibt, kann ihn bei einem Solverwechsel wegwerfen — Abschnitt 22.6 führt genau diesen Wechsel vor.
+
Danach heißen die Fälle status.modellfehler (die Regeln widersprechen sich — das Modell muss geändert werden) und status.brauchbar (es liegt etwas Ausführbares vor). Das ist dieselbe Fallunterscheidung wie bei HiGHS in Kapitel 6 und bei GLOP in Kapitel 5; nur die eine Übersetzerzeile weiß, welcher Solver gerechnet hat. Wer den Auswertungscode direkt gegen cp_model.INFEASIBLE schreibt, kann ihn bei einem Solverwechsel wegwerfen — Abschnitt 22.7 führt genau diesen Wechsel vor.
⚠️ Typische Fehler
diff --git a/OR_HTML_04/einfuehrung.html b/OR_HTML_04/einfuehrung.html
index 328ac8d..7cafecd 100644
--- a/OR_HTML_04/einfuehrung.html
+++ b/OR_HTML_04/einfuehrung.html
@@ -1099,7 +1099,7 @@ Vorteil der Optimierung: 300.00 EUR pro Tag (15.0 %)
📎 Warum dieses Programm alles selbst macht
-
Wenige Seiten weiter, in der Excel-Brücke, steht ein Programm, das mit 96 Zeilen auskommt, weil es die immer gleichen Arbeitsschritte aus dem gemeinsamen Modul or_kern.py bezieht (Abschnitt 22.5). Warum tut Bot_Allokation.py das nicht auch?
+
Wenige Seiten weiter, in der Excel-Brücke, steht ein Programm, das mit 96 Zeilen auskommt, weil es die immer gleichen Arbeitsschritte aus dem gemeinsamen Modul or_kern.py bezieht (Abschnitt 22.6). Warum tut Bot_Allokation.py das nicht auch?
Weil es hier nicht darum geht, wenig Code zu schreiben, sondern darum zu sehen, wie ein Modell entsteht: Variablen, Nebenbedingungen, Zielfunktion, Status, Prüfung — jeder Schritt einzeln und sichtbar. Eine Abstraktion, die diese Schritte zusammenfasst, ist genau dann ein Gewinn, wenn man sie schon einmal von Hand gegangen ist; vorher versteckt sie nur, was man verstehen will.
Das ist eine allgemeine Regel für eigene Projekte: Erst die Wiederholung rechtfertigt die Abstraktion. Wer sein erstes Modell gleich als Framework baut, hat ein Framework für einen Fall — und die Erfahrung, welche Fälle es tragen müsste, hat er noch nicht gemacht.
@@ -1166,7 +1166,7 @@ Vorteil der Optimierung: 300.00 EUR pro Tag (15.0 %)
Das folgende Programm zeigt den ganzen Weg an einem Stück. Es legt die Eingabemappe beim ersten Start selbst an, damit Sie es sofort ausführen können; danach ersetzen Sie die Datei durch Ihre eigene. Solange die Spalten gleich heißen, ändert sich am Code nichts.
📎 Zum Modul or_kern.py
-
Die vier Arbeitsschritte — lesen, lösen, prüfen, schreiben — stehen nicht in diesem Programm, sondern in or_kern.py. Das ist ein gemeinsamer Unterbau, den alle Programme dieses Buchs benutzen können; er liegt im selben Verzeichnis und ist in Abschnitt 22.5 vollständig abgedruckt und erklärt.
+
Die vier Arbeitsschritte — lesen, lösen, prüfen, schreiben — stehen nicht in diesem Programm, sondern in or_kern.py. Das ist ein gemeinsamer Unterbau, den alle Programme dieses Buchs benutzen können; er liegt im selben Verzeichnis und ist in Abschnitt 22.6 vollständig abgedruckt und erklärt.
Sie müssen ihn jetzt noch nicht verstehen. Wichtig ist nur die Aufteilung: Hier steht, was diesen Fall ausmacht — dort steht, was jedes Optimierungsprogramm braucht. Genau deshalb ist der Code unten so kurz.
@@ -3976,7 +3986,7 @@ Vorteil der Optimierung: 300.00 EUR pro Tag (15.0 %)
📎 Warum dieses Programm alles selbst macht
-
Wenige Seiten weiter, in der Excel-Brücke, steht ein Programm, das mit 96 Zeilen auskommt, weil es die immer gleichen Arbeitsschritte aus dem gemeinsamen Modul or_kern.py bezieht (Abschnitt 22.5). Warum tut Bot_Allokation.py das nicht auch?
+
Wenige Seiten weiter, in der Excel-Brücke, steht ein Programm, das mit 96 Zeilen auskommt, weil es die immer gleichen Arbeitsschritte aus dem gemeinsamen Modul or_kern.py bezieht (Abschnitt 22.6). Warum tut Bot_Allokation.py das nicht auch?
Weil es hier nicht darum geht, wenig Code zu schreiben, sondern darum zu sehen, wie ein Modell entsteht: Variablen, Nebenbedingungen, Zielfunktion, Status, Prüfung — jeder Schritt einzeln und sichtbar. Eine Abstraktion, die diese Schritte zusammenfasst, ist genau dann ein Gewinn, wenn man sie schon einmal von Hand gegangen ist; vorher versteckt sie nur, was man verstehen will.
Das ist eine allgemeine Regel für eigene Projekte: Erst die Wiederholung rechtfertigt die Abstraktion. Wer sein erstes Modell gleich als Framework baut, hat ein Framework für einen Fall — und die Erfahrung, welche Fälle es tragen müsste, hat er noch nicht gemacht.
@@ -4041,7 +4051,7 @@ Vorteil der Optimierung: 300.00 EUR pro Tag (15.0 %)
Das folgende Programm zeigt den ganzen Weg an einem Stück. Es legt die Eingabemappe beim ersten Start selbst an, damit Sie es sofort ausführen können; danach ersetzen Sie die Datei durch Ihre eigene. Solange die Spalten gleich heißen, ändert sich am Code nichts.
📎 Zum Modul or_kern.py
-
Die vier Arbeitsschritte — lesen, lösen, prüfen, schreiben — stehen nicht in diesem Programm, sondern in or_kern.py. Das ist ein gemeinsamer Unterbau, den alle Programme dieses Buchs benutzen können; er liegt im selben Verzeichnis und ist in Abschnitt 22.5 vollständig abgedruckt und erklärt.
+
Die vier Arbeitsschritte — lesen, lösen, prüfen, schreiben — stehen nicht in diesem Programm, sondern in or_kern.py. Das ist ein gemeinsamer Unterbau, den alle Programme dieses Buchs benutzen können; er liegt im selben Verzeichnis und ist in Abschnitt 22.6 vollständig abgedruckt und erklärt.
Sie müssen ihn jetzt noch nicht verstehen. Wichtig ist nur die Aufteilung: Hier steht, was diesen Fall ausmacht — dort steht, was jedes Optimierungsprogramm braucht. Genau deshalb ist der Code unten so kurz.
Das Programm wertet den Solver nicht über ergebnis.success aus, sondern über status_von_scipy() und das Loesung-Objekt aus dem gemeinsamen Unterbau (Abschnitt 22.5). Der Unterschied ist kein Schönheitsfehler:
+
Das Programm wertet den Solver nicht über ergebnis.success aus, sondern über status_von_scipy() und das Loesung-Objekt aus dem gemeinsamen Unterbau (Abschnitt 22.6). Der Unterschied ist kein Schönheitsfehler:
success ist ein Bit. Es unterscheidet nicht zwischen „es gibt keine Lösung“ (Modellfehler — das Modell muss geändert werden) und „die Zeit war um“ (Rechenproblem — mehr Zeit oder ein besserer Startwert hilft). Das sind zwei völlig verschiedene Nachrichten an völlig verschiedene Adressaten.
Das Loesung-Objekt führt neben dem Zielwert die Schranke mit und rechnet daraus den Gap aus. Erst dadurch steht in der Ausgabe Gap: 0.00% — die Zusage, dass hier wirklich das Optimum gefunden und bewiesen wurde und nicht bloß irgendetwas.
@@ -9656,7 +9666,7 @@ Abnahmepruefung: alle Nebenbedingungen geprueft und eingehalten.
status = status_von_scipy(ergebnis)
-
übersetzt den SciPy-Rückgabewert in die gemeinsame Sprache aus Abschnitt 22.5 — die drei if-Zweige darunter behandeln Modellfehler, Abbruch ohne Lösung und „zulässig, aber unbewiesen“ getrennt
+
übersetzt den SciPy-Rückgabewert in die gemeinsame Sprache aus Abschnitt 22.6 — die drei if-Zweige darunter behandeln Modellfehler, Abbruch ohne Lösung und „zulässig, aber unbewiesen“ getrennt
3. Kostenzerlegung. Der Block „Woraus bestehen die Strafkosten?“ ist kein Beiwerk. In der Praxis ist die erste Frage jedes Anwenders: „Warum bekomme ich die Stunde?“ Wer darauf keine Antwort hat, dessen System wird nicht benutzt (Kapitel 22, Explainable OR).
4. Statusauswertung ohne CP-SAT-Vokabular. Nach Solve() steht nur eine einzige Zeile mit einer CP-SAT-Konstante darin:
status = status_von_cpsat(rohstatus)
-
Danach heißen die Fälle status.modellfehler (die Regeln widersprechen sich — das Modell muss geändert werden) und status.brauchbar (es liegt etwas Ausführbares vor). Das ist dieselbe Fallunterscheidung wie bei HiGHS in Kapitel 6 und bei GLOP in Kapitel 5; nur die eine Übersetzerzeile weiß, welcher Solver gerechnet hat. Wer den Auswertungscode direkt gegen cp_model.INFEASIBLE schreibt, kann ihn bei einem Solverwechsel wegwerfen — Abschnitt 22.6 führt genau diesen Wechsel vor.
+
Danach heißen die Fälle status.modellfehler (die Regeln widersprechen sich — das Modell muss geändert werden) und status.brauchbar (es liegt etwas Ausführbares vor). Das ist dieselbe Fallunterscheidung wie bei HiGHS in Kapitel 6 und bei GLOP in Kapitel 5; nur die eine Übersetzerzeile weiß, welcher Solver gerechnet hat. Wer den Auswertungscode direkt gegen cp_model.INFEASIBLE schreibt, kann ihn bei einem Solverwechsel wegwerfen — Abschnitt 22.7 führt genau diesen Wechsel vor.
⚠️ Typische Fehler
@@ -19749,7 +19759,7 @@ Kosten statt auf Fehler trainiert.
📌 Kapitel auf einen Blick
Worum geht es? Um den Nachweis, dass die Verfahren aus Teil II und III unverändert in der Finanzwelt funktionieren — und um die drei Stellen, an denen die Analogie endet.
Danach können Sie: ein betriebswirtschaftliches Modell in die andere Domäne übersetzen, ohne den Modellcode anzufassen; Schattenpreise in beiden Welten lesen; und benennen, welche Annahmen dabei nicht mitwandern.
Zeitbedarf: ca. 2,5 Stunden.
Programme:
@@ -24646,6 +24656,7 @@ von allen Fehlversuchen genau einen: den erfolgreichsten.
… für jede Nebenbedingung beziffern, was sie den Plan kostet — und begründen, warum der Schattenpreis diese Frage nur für die nächste Einheit beantwortet.
… Zeitlimits und Optimalitätslücken sinnvoll setzen.
… die Architektur einer produktionsreifen OR-Plattform skizzieren.
… eine modellunabhängige Abnahmeprüfung schreiben, die eine Lösung gegen die Anforderungen prüft statt gegen das Modell.
@@ -24972,6 +24984,7 @@ von allen Fehlversuchen genau einen: den erfolgreichsten.
Blockierend und kostenrelevant sind zwei verschiedene Dinge.
==============================================================================
Der letzte Punkt ist der eigentliche Lerneffekt: Die Obergrenze schließt Ben zwar technisch aus, aber selbst wenn er dürfte, wäre er wegen seiner Vorbelastung immer noch teurer als Anna. Eine bindende Bedingung zu lockern hilft nur, wenn sie tatsächlich die kostenrelevante ist — sonst ist die Mühe umsonst.
+
Derselbe Unterschied kehrt auf Modellebene wieder, und dort lässt er sich in Euro beziffern: Abschnitt 22.4 misst für jede Bedingung, was sie den ganzen Plan kostet — und baut daraus einen Bericht, den man vorlegen kann.
Falle 3 — Regimewechsel und Schätzfehler
Ein Modell, das auf einer mehrjährigen Aufwärtsphase kalibriert wurde, kennt keine Liquiditätskrise und keine Zinswende.
@@ -25019,7 +25032,438 @@ von allen Fehlversuchen genau einen: den erfolgreichsten.
Dem gegenüber steht die Datenunsicherheit: Ihre Nachfrageprognose ist um ±10 % genau, Ihre Fahrzeiten um ±15 %. Die letzten 2 % Optimalität in einem Modell zu erkämpfen, dessen Eingangsdaten um 10 % schwanken, ist verlorene Zeit. Optimieren Sie nicht genauer, als Ihre Daten sind.
-
22.4 Architektur einer produktionsreifen OR-Plattform
+
22.4 Constraint Attribution: welche Bedingung kostet wie viel?
+
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, und für ihn hat bisher niemand ein Werkzeug bekommen:
+
+
Das Modell rechnet. Der Plan ist zulässig. Und trotzdem ist er enttäuschend — nur weiß niemand, woran es liegt.
+
+
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) inenumerate(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, _, _, _) inenumerate(bedingungen):
+ rest = [c for j, c inenumerate(bedingungen) if j != i] + [zusatz]
+if loese(rest).success:
+ gefunden.append(name)
+return gefunden
+
+
+def euro(betrag: float) ->str:
+returnf"{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 =""
+ifabs(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 else0.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 inrange(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}",
+ [1if p == WUNSCH_PRODUKT else0for 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 inrange(1, 61):
+if loese(geaendert(engpass, schritt) + [wunsch]).success:
+ noetig = schritt
+break
+if noetig isnotNone:
+ 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 isnotNone:
+ 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?
+==============================================================================
+Status: Optimization terminated successfully.
+Deckungsbeitrag des Plans: 3,930.00 EUR
+
+ Rahmen: 47.5 Gehaeuse: 30.0 Deckel: 97.5 Traeger: 25.0 Halter: 0.0
+
+==============================================================================
+ (1) Welche Bedingungen binden - und was kostet die naechste Einheit?
+==============================================================================
+Bedingung genutzt Grenze Schlupf Schattenpreis
+------------------------------------------------------------------------------
+Kapazitaet Montage 400.0 400 0.0 9.00 <-- bindend
+Kapazitaet Lackieren 210.0 210 0.0 4.00 <-- bindend
+Kapazitaet Pruefung 200.0 300 100.0 0.00
+Liefervertrag Rahmen 47.5 40 7.5 0.00
+Liefervertrag Gehaeuse 30.0 30 0.0 2.00 <-- bindend
+Liefervertrag Deckel 97.5 20 77.5 0.00
+Liefervertrag Traeger 25.0 25 0.0 18.00 <-- bindend
+Marktgrenze Rahmen 47.5 120 72.5 0.00
+Marktgrenze Gehaeuse 30.0 90 60.0 0.00
+Marktgrenze Deckel 97.5 150 52.5 0.00
+Marktgrenze Halter 0.0 200 200.0 0.00
+Mindestumsatz 10800.0 10000 800.0 0.00
+Sortimentsbreite 122.5 100 22.5 0.00
+Lackierbudget Schicht 2 55.0 55 0.0 0.00 <-- bindend
+
+ 5 Bedingungen binden. Aber: Lackierbudget Schicht 2 bindet
+ mit einem Schattenpreis von 0,00 EUR - sie beruehrt den Plan, ohne ihn
+ zu verteuern. Bindend und kostenrelevant sind zwei verschiedene Dinge.
+
+==============================================================================
+ (2) Was der Schattenpreis verschweigt
+==============================================================================
+Der Schattenpreis gilt fuer die NAECHSTE Einheit. Ein Planer kauft aber
+keine Einheit, sondern eine ganze Schicht. Beides gegenuebergestellt:
+
+Stellschraube Preis Schritt hochgerechnet gemessen Abw.
+------------------------------------------------------------------------------
+Kapazitaet Montage 9.00 +50 450.00 450.00 +0.00
+Kapazitaet Lackieren 4.00 +30 120.00 60.00 -60.00
+Kapazitaet Pruefung 0.00 +50 0.00 0.00 +0.00
+Lackierbudget Schicht 2 0.00 +10 0.00 0.00 +0.00
+Liefervertrag Traeger 18.00 -10 180.00 180.00 +0.00
+Liefervertrag Gehaeuse 2.00 -10 20.00 18.00 -2.00
+
+ 'Kapazitaet Lackieren': hochgerechnet 120.00 EUR, tatsaechlich 60.00 EUR.
+ Der Preis von 4.00 EUR gilt nur bis +15 Stunden. Jede weitere Stunde
+ dieser Sonderschicht bringt exakt nichts - der Engpass ist dann ein anderer.
+
+ Und die Ranglisten drehen sich: Nach Schattenpreis fuehrt
+ 'Liefervertrag Traeger' (18.00 EUR je Einheit),
+ nach tatsaechlicher Wirkung 'Kapazitaet Montage' (450.00 EUR).
+ Der Schattenpreis sagt, was eine Einheit wert ist - nicht, wie viele
+ Einheiten zu haben sind.
+
+==============================================================================
+ (3) Was-waere-wenn: mindestens 55 Rahmen statt 47.5
+==============================================================================
+Der Wunsch ist nicht teuer - er ist UNMOEGLICH.
+
+Blockiert wird er von 3 Bedingungen gemeinsam. Faellt eine davon
+weg, ist er erfuellbar; keine allein ist 'der' Grund:
+ ohne 'Kapazitaet Lackieren': moeglich, Deckungsbeitrag 3,990.00 EUR
+ ohne 'Liefervertrag Gehaeuse': moeglich, Deckungsbeitrag 3,948.00 EUR
+ ohne 'Liefervertrag Traeger': moeglich, Deckungsbeitrag 4,320.00 EUR
+
+ Mit +15 bei 'Kapazitaet Lackieren' wird der Wunsch erfuellbar:
+ Deckungsbeitrag 3,990.00 EUR (+60.00 gegenueber heute).
+ Und ohne den Wunsch ergaebe dieselbe Lockerung 3,990.00 EUR bei
+ 55 Rahmen - der Wunsch kostet also NICHTS.
+ Er war nie das Problem. Der Engpass war es.
+
+==============================================================================
+ (4) Und so steht es in der Vorlage fuer die Abteilungsleiterrunde
+==============================================================================
+
+Der Plan bringt 3,930.00 EUR Deckungsbeitrag. Begrenzt wird er von 5
+Bedingungen, von denen 4 tatsaechlich Geld kosten; "Lackierbudget Schicht 2"
+beruehrt den Plan, ohne ihn zu verteuern.
+
+Teuerste Bindung ist "Liefervertrag Traeger" mit 18.00 EUR je Einheit,
+gefolgt von "Kapazitaet Montage" mit 9.00 EUR.
+
+Die groesste einzelne Verbesserung bringt eine Sonderschicht Montage: 450.00
+EUR mehr Deckungsbeitrag. Eine Sonderschicht Lackieren dagegen bringt nur
+60.00 EUR statt der rechnerischen 120.00 EUR - ab +15 Stunden begrenzt uns
+etwas anderes. Wer die ganze Schicht bezahlt, bezahlt die zweite Haelfte
+umsonst.
+
+Zum Wunsch des Vertriebs (55 Rahmen statt 47.5): Er ist mit der heutigen
+Ausstattung nicht erfuellbar - nicht aus Kostengruenden, sondern weil
+"Kapazitaet Lackieren", "Liefervertrag Gehaeuse", "Liefervertrag Traeger"
+ihn gemeinsam blockieren. Mit +15 Stunden bei "Kapazitaet Lackieren" wird er
+moeglich, und er kostet dann nichts: Der Plan waehlt diese Menge von sich
+aus.
+
+==============================================================================
+
Drei Befunde, und keiner steht im Solverergebnis
+
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 — wer sie nachverhandelt, gewinnt nichts. Das ist derselbe Unterschied, den Erklaerbarkeit.py oben an einer einzelnen Zuweisung zeigt, hier im Dualwert eines LP.
+
Zweitens: Der Schattenpreis ist eine Momentaufnahme. Er gilt für die nächste Einheit. Ein Planer kauft aber keine Einheit, sondern eine Schicht. Für die Lackiererei verspricht die Hochrechnung 4{,}00 \cdot 30 = 120 €; gemessen kommen 60 €. Der Preis von 4,00 € gilt exakt bis +15 Stunden, danach begrenzt etwas anderes — die zweite Hälfte der Sonderschicht wäre bezahlt und wirkungslos.
+
Und die Ranglisten drehen sich dabei um: Nach Schattenpreis führt der Trägervertrag mit 18,00 € je Stück, nach tatsächlicher Wirkung die Montage-Sonderschicht mit 450 €. Der Schattenpreis sagt, was eine Einheit wert ist — nicht, wie viele Einheiten zu haben sind. Für die Frage „welchen Hebel ziehen wir?“ ist das der entscheidende Unterschied.
+
Drittens: Ein Wunsch kann unmöglich statt teuer sein. Der Vertrieb hätte gern 55 Rahmen statt 47,5. Das Modell antwortet nicht mit einem Preis, sondern mit INFEASIBLE — und drei Bedingungen blockieren den Wunsch gemeinsam: Fällt eine davon weg, ist er erfüllbar, keine allein ist „der“ Grund. Genau die Struktur, die Anhang C für den unlösbaren Fall beschreibt, hier auf einen Wunsch angewandt.
+
Die Pointe steht am Ende: Mit +15 Stunden Lackierkapazität wird der Wunsch erfüllbar — und kostet dann nichts. Der Plan wählt diese Menge von sich aus, sobald der Engpass weg ist. Der Konflikt bestand nie zwischen Vertrieb und Produktion. Er bestand zwischen dem Vertrieb und der Lackiererei, und niemand wusste das.
+
+
🎯 Die drei Fragen, jetzt auf Modellebene Die Faustregel oben stellt sie für eine einzelne Zuweisung. Constraint Attribution stellt dieselben drei für den ganzen Plan: 1. Warum dieser Zielwert? — welche Bedingungen binden, und welche davon kosten Geld. 2. Warum nicht besser? — was eine realistische Lockerung wirklich bringt, gemessen statt hochgerechnet. 3. Was ist mit meinem Wunsch? — erfüllbar und zu welchem Preis, oder unmöglich und warum.
+
+
Vom Zahlenwerk zur Vorlage
+
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 weniger trivial, als es aussieht:
+
+
Kein Satz enthält eine Zahl, die nicht vorher gerechnet wurde. Auch nicht die Rangfolge, auch nicht die Namen der blockierenden Bedingungen. Sobald sich das Modell ändert, ändert sich der Bericht mit — er kann nicht leise falsch werden.
+
Der sprechende Name jeder Bedingung ist die Voraussetzung dafür.A_ub[7] ergibt keinen Satz. Deshalb tragen die Bedingungen in beiden Programmen einen Namen, und deshalb ist das keine Kosmetik.
+
+
+
⚠️ Was ein solcher Bericht nicht leistet Er erklärt das Modell, nicht die Wirklichkeit. „Liefervertrag Träger kostet 18 € je Stück“ heißt: in diesem Modell, mit diesen Deckungsbeiträgen. Ist ein Deckungsbeitrag falsch geschätzt, ist der Bericht überzeugend und falsch — die gefährlichste Kombination. Ein generierter Text macht ein Modell nicht richtiger, nur schwerer anzuzweifeln. Deshalb gehört zu jedem solchen Bericht die Angabe, worauf er beruht.
+
+
+
22.5 Architektur einer produktionsreifen OR-Plattform
Abb. 22.2: Architektur einer produktionsreifen OR-Plattform
@@ -25083,7 +25527,7 @@ von allen Fehlversuchen genau einen: den erfolgreichsten.
⚠️ Der häufigste Projektfehler Nicht das Modell scheitert, sondern die Einführung. Ein technisch überlegener Plan, den die Disponentin nicht versteht und dem sie nicht traut, wird umgangen — sie plant weiter in ihrer Tabelle. Rechnen Sie mindestens so viel Zeit für Erklärbarkeit, Schulung und schrittweise Einführung ein wie für das Modell selbst.
-
22.5 Der gemeinsame Unterbau: or_kern.py
+
22.6 Der gemeinsame Unterbau: or_kern.py
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.
Sehen Sie sich die Programme dieses Buchs an: Jedes liest Daten ein, baut ein Modell, wertet einen Solverstatus aus und prüft das Ergebnis. Nur der mittlere Schritt ist wirklich problemspezifisch. Die anderen drei schreibt man in jedem Projekt neu — und macht dabei jedes Mal dieselben Fehler.
Die vier Schichten
@@ -25200,510 +25644,510 @@ von allen Fehlversuchen genau einen: den erfolgreichsten.
⚠️ 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?"""
-returnselfin (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG)
-
-@property
-def modellfehler(self) ->bool:
-"""Liegt die Ursache im Modell (und nicht in der Rechenzeit)?"""
-returnselfin (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.
- """
-ifself.zielwert isNoneorself.schranke isNone:
-returnNone
- nenner =max(abs(self.zielwert), 1e-9)
-returnabs(self.zielwert -self.schranke) / nenner
-
-def als_bericht(self) ->str:
-"""Eine Zeile fuers Betriebsprotokoll - siehe Betriebsueberwachung.py."""
- teile = [f"Status: {self.status.value}"]
-ifself.zielwert isnotNone:
- teile.append(f"Zielwert: {self.zielwert:,.2f}")
-ifself.gap isnotNone:
- teile.append(f"Gap: {self.gap:.2%}")
-ifself.laufzeit isnotNone:
- 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 inself.produkte]
-iflen(set(namen)) !=len(namen):
- doppelt =sorted({n for n in namen if namen.count(n) >1})
-raiseValueError(f"Produktnamen kommen mehrfach vor: {doppelt}")
-
-for produkt inself.produkte:
- unbekannt =set(produkt.verbrauch) -set(self.kapazitaeten)
-if unbekannt:
-raiseValueError(
-f"Produkt '{produkt.name}' verbraucht Ressourcen ohne "
-f"Kapazitaetsangabe: {sorted(unbekannt)}")
-returnself
-
-@property
-def ressourcen(self) ->list[str]:
-"""Feste Reihenfolge - siehe die Spaltenfalle in Kapitel Finanzdaten."""
-returnsorted(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 inself.produkte]
-for r inself.ressourcen])
-
-def kapazitaetsvektor(self) -> np.ndarray:
-return np.array([self.kapazitaeten[r] for r inself.ressourcen])
-
-def deckungsbeitragsvektor(self) -> np.ndarray:
-return np.array([p.deckungsbeitrag for p inself.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] = []
-
-ifnot loesung.status.brauchbar:
-return [f"Kein verwertbares Ergebnis (Status: {loesung.status.value})"]
-
- fehlend = [p.name for p in problem.produkte if p.name notin 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 inzip(problem.produkte, mengen) if m <-toleranz]
- beanstandungen.append(f"negative Mengen bei: {negativ}")
-
- verbrauch = problem.verbrauchsmatrix() @ mengen
-for ressource, ist, grenze inzip(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]
-ifabs(wert -round(wert)) > toleranz:
- beanstandungen.append(f"'{name}' = {wert!r} ist nicht ganzzahlig")
-
-if loesung.zielwert isnotNone:
- nachgerechnet =float(problem.deckungsbeitragsvektor() @ mengen)
-ifabs(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)
-ifnot 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)
-ifnot 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 inzip(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 notin produkte_tabelle.columns]
-if fehlende:
-raiseValueError(f"Blatt '{blatt_produkte}': Spalten fehlen: {fehlende}")
-if produkte_tabelle.isna().any().any():
- zeilen = produkte_tabelle[produkte_tabelle.isna().any(axis=1)].index.tolist()
-raiseValueError(f"Blatt '{blatt_produkte}': leere Zellen in Zeile(n) {zeilen}")
-
- ressourcen = [s for s in produkte_tabelle.columns if s notin 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
-
-ifnot loesung.status.brauchbar:
-raiseValueError(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 or0.0,
- }
-for ressource, ist, grenze inzip(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) ifisinstance(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'ifnot 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 (!)")
-exceptExceptionas 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?"""
+returnselfin (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG)
+
+@property
+def modellfehler(self) ->bool:
+"""Liegt die Ursache im Modell (und nicht in der Rechenzeit)?"""
+returnselfin (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.
+ """
+ifself.zielwert isNoneorself.schranke isNone:
+returnNone
+ nenner =max(abs(self.zielwert), 1e-9)
+returnabs(self.zielwert -self.schranke) / nenner
+
+def als_bericht(self) ->str:
+"""Eine Zeile fuers Betriebsprotokoll - siehe Betriebsueberwachung.py."""
+ teile = [f"Status: {self.status.value}"]
+ifself.zielwert isnotNone:
+ teile.append(f"Zielwert: {self.zielwert:,.2f}")
+ifself.gap isnotNone:
+ teile.append(f"Gap: {self.gap:.2%}")
+ifself.laufzeit isnotNone:
+ 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 inself.produkte]
+iflen(set(namen)) !=len(namen):
+ doppelt =sorted({n for n in namen if namen.count(n) >1})
+raiseValueError(f"Produktnamen kommen mehrfach vor: {doppelt}")
+
+for produkt inself.produkte:
+ unbekannt =set(produkt.verbrauch) -set(self.kapazitaeten)
+if unbekannt:
+raiseValueError(
+f"Produkt '{produkt.name}' verbraucht Ressourcen ohne "
+f"Kapazitaetsangabe: {sorted(unbekannt)}")
+returnself
+
+@property
+def ressourcen(self) ->list[str]:
+"""Feste Reihenfolge - siehe die Spaltenfalle in Kapitel Finanzdaten."""
+returnsorted(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 inself.produkte]
+for r inself.ressourcen])
+
+def kapazitaetsvektor(self) -> np.ndarray:
+return np.array([self.kapazitaeten[r] for r inself.ressourcen])
+
+def deckungsbeitragsvektor(self) -> np.ndarray:
+return np.array([p.deckungsbeitrag for p inself.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] = []
+
+ifnot loesung.status.brauchbar:
+return [f"Kein verwertbares Ergebnis (Status: {loesung.status.value})"]
+
+ fehlend = [p.name for p in problem.produkte if p.name notin 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 inzip(problem.produkte, mengen) if m <-toleranz]
+ beanstandungen.append(f"negative Mengen bei: {negativ}")
+
+ verbrauch = problem.verbrauchsmatrix() @ mengen
+for ressource, ist, grenze inzip(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]
+ifabs(wert -round(wert)) > toleranz:
+ beanstandungen.append(f"'{name}' = {wert!r} ist nicht ganzzahlig")
+
+if loesung.zielwert isnotNone:
+ nachgerechnet =float(problem.deckungsbeitragsvektor() @ mengen)
+ifabs(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)
+ifnot 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)
+ifnot 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 inzip(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 notin produkte_tabelle.columns]
+if fehlende:
+raiseValueError(f"Blatt '{blatt_produkte}': Spalten fehlen: {fehlende}")
+if produkte_tabelle.isna().any().any():
+ zeilen = produkte_tabelle[produkte_tabelle.isna().any(axis=1)].index.tolist()
+raiseValueError(f"Blatt '{blatt_produkte}': leere Zellen in Zeile(n) {zeilen}")
+
+ ressourcen = [s for s in produkte_tabelle.columns if s notin 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
+
+ifnot loesung.status.brauchbar:
+raiseValueError(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 or0.0,
+ }
+for ressource, ist, grenze inzip(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) ifisinstance(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'ifnot 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 (!)")
+exceptExceptionas 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
@@ -25800,7 +26244,7 @@ Domaenenschicht.
🎯 Merksatz Ein Fehler, der beim Einlesen auffliegt, kostet Minuten. Derselbe Fehler, der erst im Bericht auffällt, kostet Tage — und derselbe Fehler, der nie auffällt, kostet am meisten. Validierung ist deshalb keine Fleißarbeit, sondern die günstigste Stelle im ganzen Ablauf.
-
22.6 Der Solverwechsel in der Praxis
+
22.7 Der Solverwechsel in der Praxis
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 nicht glauben sollte, bevor sie jemand vorgeführt hat.
Also führen wir sie vor — und zwar am unbequemsten Paar, das dieses Buch zu bieten hat: CP-SAT gegen HiGHS. Die beiden sind grundverschieden. CP-SAT ist ein Constraint-Programming-Solver, der ausschließlich ganzzahlig rechnet und Restriktionen als Ausdrücke entgegennimmt. HiGHS ist ein klassischer Branch-and-Bound-Löser, der eine Koeffizientenmatrix erwartet. Und sie lassen sich nicht einmal gemeinsam importieren (Kapitel 3).
Die Aufgabe ist eine kapazitierte Standortplanung: Aus sechs möglichen Lagern sind diejenigen zu eröffnen, mit denen zwölf Kunden am günstigsten beliefert werden. Jeder Kunde wird von genau einem Lager bedient, jedes eröffnete Lager kostet eine Fixgebühr, und die Kapazität reicht je Lager für 174 der insgesamt 387 Paletten — es müssen also mindestens drei Lager öffnen.
@@ -25845,330 +26289,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)
-iflen(self.fixkosten) != n orlen(self.kapazitaet) != n:
-raiseValueError(f"fixkosten/kapazitaet muessen {n} Eintraege haben")
-iflen(self.bedarf) != m:
-raiseValueError(f"bedarf muss {m} Eintraege haben")
-iflen(self.transport) != n orany(len(z) != m for z inself.transport):
-raiseValueError(f"transport muss {n} x {m} sein")
-ifsum(self.kapazitaet) <sum(self.bedarf):
-raiseValueError(f"Gesamtkapazitaet {sum(self.kapazitaet)} deckt den "
-f"Gesamtbedarf {sum(self.bedarf)} nicht")
-returnself
-
-def schluessel(self, i: int, j: int) ->str:
-"""Variablenname im Loesung-DTO - beide Modellbauer benutzen ihn."""
-returnf"{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 inrange(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 inrange(n)]
- x = {(i, j): modell.NewBoolVar(f"liefert_{i}_{j}")
-for i inrange(n) for j inrange(m)}
-
-for j inrange(m): # jeder Kunde genau einmal
- modell.AddExactlyOne(x[i, j] for i inrange(n))
-for i inrange(n):
-for j inrange(m): # nur aus offenen Lagern
- modell.AddImplication(x[i, j], y[i])
- modell.Add(sum(problem.bedarf[j] * x[i, j] for j inrange(m))
-<= problem.kapazitaet[i] * y[i]) # Kapazitaet
-
- modell.Minimize(
-sum(problem.fixkosten[i] * y[i] for i inrange(n))
-+sum(problem.transport[i][j] * x[i, j] for i inrange(n) for j inrange(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))
-ifnot 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 inrange(n) for j inrange(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 inrange(anzahl_x + n):
- modell.changeColIntegrality(spalte, highspy.HighsVarType.kInteger)
-for i inrange(n):
- modell.changeColCost(spalte_y(i), float(problem.fixkosten[i]))
-for j inrange(m):
- modell.changeColCost(i * m + j, float(problem.transport[i][j]))
-
-for j inrange(m): # jeder Kunde genau einmal
- index = np.array([i * m + j for i inrange(n)], dtype=np.int32)
- modell.addRow(1.0, 1.0, n, index, np.ones(n))
-for i inrange(n):
-for j inrange(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 inrange(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()))
-ifnot 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 inrange(n) for j inrange(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."""
-ifnot 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 inrange(m)] for i inrange(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 inenumerate(problem.kunden):
-ifabs(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 inenumerate(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 isnotNone:
- offen = beliefert > toleranz
- nachgerechnet = (np.array(problem.fixkosten, dtype=float) @ offen
-+ (np.array(problem.transport, dtype=float) * zuordnung).sum())
-ifabs(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 inenumerate(problem.lager)
-ifany(loesung.werte[problem.schluessel(i, j)] >0.5for j inrange(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:
-raiseRuntimeError(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 ---------------
-iflen(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'ifnot 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'}")
-
-assertabs(zielwerte[0] - zielwerte[1]) <0.5, "Die Solver widersprechen sich!"
-assertall(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)
+iflen(self.fixkosten) != n orlen(self.kapazitaet) != n:
+raiseValueError(f"fixkosten/kapazitaet muessen {n} Eintraege haben")
+iflen(self.bedarf) != m:
+raiseValueError(f"bedarf muss {m} Eintraege haben")
+iflen(self.transport) != n orany(len(z) != m for z inself.transport):
+raiseValueError(f"transport muss {n} x {m} sein")
+ifsum(self.kapazitaet) <sum(self.bedarf):
+raiseValueError(f"Gesamtkapazitaet {sum(self.kapazitaet)} deckt den "
+f"Gesamtbedarf {sum(self.bedarf)} nicht")
+returnself
+
+def schluessel(self, i: int, j: int) ->str:
+"""Variablenname im Loesung-DTO - beide Modellbauer benutzen ihn."""
+returnf"{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 inrange(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 inrange(n)]
+ x = {(i, j): modell.NewBoolVar(f"liefert_{i}_{j}")
+for i inrange(n) for j inrange(m)}
+
+for j inrange(m): # jeder Kunde genau einmal
+ modell.AddExactlyOne(x[i, j] for i inrange(n))
+for i inrange(n):
+for j inrange(m): # nur aus offenen Lagern
+ modell.AddImplication(x[i, j], y[i])
+ modell.Add(sum(problem.bedarf[j] * x[i, j] for j inrange(m))
+<= problem.kapazitaet[i] * y[i]) # Kapazitaet
+
+ modell.Minimize(
+sum(problem.fixkosten[i] * y[i] for i inrange(n))
++sum(problem.transport[i][j] * x[i, j] for i inrange(n) for j inrange(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))
+ifnot 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 inrange(n) for j inrange(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 inrange(anzahl_x + n):
+ modell.changeColIntegrality(spalte, highspy.HighsVarType.kInteger)
+for i inrange(n):
+ modell.changeColCost(spalte_y(i), float(problem.fixkosten[i]))
+for j inrange(m):
+ modell.changeColCost(i * m + j, float(problem.transport[i][j]))
+
+for j inrange(m): # jeder Kunde genau einmal
+ index = np.array([i * m + j for i inrange(n)], dtype=np.int32)
+ modell.addRow(1.0, 1.0, n, index, np.ones(n))
+for i inrange(n):
+for j inrange(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 inrange(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()))
+ifnot 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 inrange(n) for j inrange(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."""
+ifnot 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 inrange(m)] for i inrange(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 inenumerate(problem.kunden):
+ifabs(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 inenumerate(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 isnotNone:
+ offen = beliefert > toleranz
+ nachgerechnet = (np.array(problem.fixkosten, dtype=float) @ offen
++ (np.array(problem.transport, dtype=float) * zuordnung).sum())
+ifabs(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 inenumerate(problem.lager)
+ifany(loesung.werte[problem.schluessel(i, j)] >0.5for j inrange(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:
+raiseRuntimeError(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 ---------------
+iflen(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'ifnot 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'}")
+
+assertabs(zielwerte[0] - zielwerte[1]) <0.5, "Die Solver widersprechen sich!"
+assertall(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
@@ -26227,14 +26671,14 @@ ein unzuverlaessiger Test (siehe JobShop_Intervalle.py).
🎯 Merksatz Die Frage „welcher Solver ist der beste?“ ist schlecht gestellt. Die gute Frage lautet: „Wie teuer ist es, den Solver zu wechseln?“ Wer sie mit eine Funktion beantworten kann, muss die erste Frage nie entscheiden — er misst.
-
22.7 Eine Checkliste vor dem Produktivgang
+
22.8 Eine Checkliste vor dem Produktivgang
Arbeiten Sie diese Liste ab, bevor ein OR-System in Betrieb geht:
Modell - [ ] Alle Nebenbedingungen sind dokumentiert — mit fachlicher Begründung, nicht nur als Code. - [ ] Für jede harte Bedingung ist geprüft: Muss sie wirklich hart sein? - [ ] Es gibt eine Relaxationsstrategie für den Infeasible-Fall. - [ ] Big-M-Werte sind so klein wie möglich gewählt. - [ ] Die Zielfunktion ist in konsistenten Einheiten formuliert.
Daten - [ ] Spaltenreihenfolge und Datentypen werden nach jedem Import geprüft. - [ ] Fehlende Werte und Ausreißer haben eine definierte Behandlung. - [ ] Kovarianzmatrizen werden auf positive Definitheit geprüft. - [ ] Datenstände sind versioniert und reproduzierbar.
Validierung - [ ] Das Ergebnis wurde gegen eine unabhängige Rechnung geprüft (Handrechnung, zweiter Solver, Simulation). - [ ] Alle Nebenbedingungen werden nach dem Lösen per assert verifiziert. - [ ] Es gibt einen Vergleich gegen eine naive Referenzstrategie. - [ ] Bei Backtests: Lookahead-Selbsttest bestanden, Zahl der Versuche protokolliert.
Betrieb - [ ] Zeitlimit und akzeptierter Gap sind festgelegt und begründet. - [ ] Es gibt eine Fallback-Strategie und Alarmierung. - [ ] Jedes Ergebnis ist mit Snapshot-ID, Modellversion und Solver-Version protokolliert. - [ ] Die Ergebnisse sind erklärbar (Kostenzerlegung, Schattenpreise, Constraint-Trace).
-
22.8 Weiterführende Literatur und Roadmap
+
22.9 Weiterführende Literatur und Roadmap
Grundlagen und lineare/ganzzahlige Optimierung * Bertsimas & Tsitsiklis: Introduction to Linear Optimization — Standardwerk zu Simplex, Dualität, Polyedertheorie. * Wolsey: Integer Programming — Branch-and-Cut, Schnittebenen, Formulierungsstärke.
Konvexe Optimierung * Boyd & Vandenberghe: Convex Optimization — Pflichtlektüre; frei verfügbar. Die theoretische Basis von CVXPY.
Constraint Programming und Scheduling * Google OR-Tools Dokumentation und das CP-SAT-Primer von Laurent Perron.
@@ -26284,7 +26728,7 @@ ein unzuverlaessiger Test (siehe JobShop_Intervalle.py).
-
@@ -26292,10 +26736,11 @@ ein unzuverlaessiger Test (siehe JobShop_Intervalle.py).
Aufgabe 22.2 ⭐ — Zeitlimit wählen. Für welche Szenarien setzen Sie welches Zeitlimit und welchen Gap? (a) Taxi-Disposition in Echtzeit, (b) Wochendienstplan, freitags erstellt, (c) Jahresproduktionsplanung, (d) Portfolio-Rebalancing monatlich.
Aufgabe 22.3 ⭐⭐ — Relaxation einbauen. Nehmen Sie Ihr Modell aus Übung 6.7 (Wochendienstplan) und machen Sie es INFEASIBLE-sicher: Schlupfvariablen für unbesetzte Schichten, gestaffelte Strafen. Testen Sie mit einem bewusst überlasteten Szenario.
Aufgabe 22.4 ⭐⭐ — Erklärbarkeit implementieren. Erweitern Sie ein beliebiges Modell aus dem Kurs um einen erklaere(loesung)-Report, der die drei Fragen aus Abschnitt 22.3 beantwortet.
-
Aufgabe 22.5 ⭐⭐⭐ — Gap gegen Laufzeit. Nehmen Sie das MILP-Portfolio aus Kapitel 6 und vergrößern Sie es auf 50 Anlagen. Messen Sie Laufzeit und Zielwert bei Gap-Vorgaben von 10 %, 5 %, 2 %, 1 %, 0,1 % und 0 %. Stellen Sie den Zusammenhang grafisch dar und leiten Sie eine Empfehlung ab.
-
Aufgabe 22.6 ⭐⭐⭐ — Post-Mortem schreiben. Suchen Sie sich einen der im Buch besprochenen Fehler aus (z. B. die Vorzeichenfalle in Abschnitt 5.7, die ungültige Kovarianzmatrix in Kapitel 11, die Sektor-Positionsindizes in Kapitel 19, die vermischten Einheiten in Abschnitt 20.6 oder die Rebalancing-Termine in Abschnitt 21.4) und schreiben Sie ein einseitiges Post-Mortem: Was war der Fehler? Warum fiel er nicht auf? Welche Prüfung hätte ihn gefunden? Welche Regel leiten Sie daraus für eigene Projekte ab?
+
Aufgabe 22.5 ⭐⭐ — Den Bericht in die Irre führen.Constraint_Attribution.py erzeugt seinen Bericht aus gerechneten Zahlen — richtig ist er deshalb noch nicht. (a) Setzen Sie den Deckungsbeitrag von Deckel von 9 € auf 20 €. Wie ändern sich Plan, Rangliste und Bericht? Welche Sätze bleiben wörtlich stehen, obwohl sie jetzt etwas anderes bedeuten? (b) Der Bericht nennt „Liefervertrag Träger” als teuerste Bindung. Formulieren Sie den Satz so um, dass ein Leser erkennt, worauf die Aussage beruht. (c) Welche Angabe müsste das Programm zusätzlich ausgeben, damit ein Empfänger die Belastbarkeit selbst einschätzen kann?
+
Aufgabe 22.6 ⭐⭐⭐ — Gap gegen Laufzeit. Nehmen Sie das MILP-Portfolio aus Kapitel 6 und vergrößern Sie es auf 50 Anlagen. Messen Sie Laufzeit und Zielwert bei Gap-Vorgaben von 10 %, 5 %, 2 %, 1 %, 0,1 % und 0 %. Stellen Sie den Zusammenhang grafisch dar und leiten Sie eine Empfehlung ab.
+
Aufgabe 22.7 ⭐⭐⭐ — Post-Mortem schreiben. Suchen Sie sich einen der im Buch besprochenen Fehler aus (z. B. die Vorzeichenfalle in Abschnitt 5.7, die ungültige Kovarianzmatrix in Kapitel 11, die Sektor-Positionsindizes in Kapitel 19, die vermischten Einheiten in Abschnitt 20.6 oder die Rebalancing-Termine in Abschnitt 21.4) und schreiben Sie ein einseitiges Post-Mortem: Was war der Fehler? Warum fiel er nicht auf? Welche Prüfung hätte ihn gefunden? Welche Regel leiten Sie daraus für eigene Projekte ab?
-
22.10 Finde den Denkfehler
+
22.11 Finde den Denkfehler
Falle 5 — die Laufzeitexplosion — zeigt sich im Betrieb selten als Absturz. Sie zeigt sich als gar nichts.
🐛 Finde den Denkfehler: Das Modell, das seit einem Jahr nicht mehr optimiert
@@ -26336,186 +26781,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?
#!/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 = []
-ifself.status !="Optimal":
- meldungen.append(f"nicht beweisbar optimal ({self.status})")
-ifself.gap > GAP_WARNSCHWELLE:
- meldungen.append(f"Gap {self.gap:.1%} ueber Schwelle "
-f"{GAP_WARNSCHWELLE:.0%}")
-ifself.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 inrange(n_lager):
- modell.changeColIntegrality(anzahl_x + i, highspy.HighsVarType.kInteger)
- modell.changeColCost(anzahl_x + i, fixkosten[i])
-for j inrange(n_kunden):
- modell.changeColCost(i * n_kunden + j, transport[i, j])
-
-for j inrange(n_kunden):
- index = np.array([i * n_kunden + j for i inrange(n_lager)], dtype=np.int32)
- modell.addRow(bedarf[j], bedarf[j], len(index), index, np.ones(len(index)))
-for i inrange(n_lager):
- index = np.array([i * n_kunden + j for j inrange(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 = []
+ifself.status !="Optimal":
+ meldungen.append(f"nicht beweisbar optimal ({self.status})")
+ifself.gap > GAP_WARNSCHWELLE:
+ meldungen.append(f"Gap {self.gap:.1%} ueber Schwelle "
+f"{GAP_WARNSCHWELLE:.0%}")
+ifself.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 inrange(n_lager):
+ modell.changeColIntegrality(anzahl_x + i, highspy.HighsVarType.kInteger)
+ modell.changeColCost(anzahl_x + i, fixkosten[i])
+for j inrange(n_kunden):
+ modell.changeColCost(i * n_kunden + j, transport[i, j])
+
+for j inrange(n_kunden):
+ index = np.array([i * n_kunden + j for i inrange(n_lager)], dtype=np.int32)
+ modell.addRow(bedarf[j], bedarf[j], len(index), index, np.ones(len(index)))
+for i inrange(n_lager):
+ index = np.array([i * n_kunden + j for j inrange(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
@@ -26579,7 +27024,7 @@ sichtbar wird, und gibt Zeit zum Handeln.
🎯 Merksatz Ein Optimierungsjob, der ein Zeitlimit hat, wird bei wachsenden Daten nicht langsamer — er wird schlechter. Genau deshalb greift die übliche Betriebsüberwachung nicht: Sie achtet auf Laufzeit und Abstürze, und beides bleibt unauffällig. Protokollieren Sie Status, Gap und Zeitausschöpfung bei jedem Lauf, oder Sie erfahren nie, wann Ihr Modell aufgehört hat zu optimieren.
-
22.11 Micro-Quiz
+
22.12 Micro-Quiz
❓ Micro-Quiz 22: Drei Fragen zum Selbstcheck
Genau eine Antwort ist jeweils richtig. Auflösung in Anhang A.
@@ -26588,7 +27033,7 @@ sichtbar wird, und gibt Zeit zum Handeln.
3. Ein Modell meldet INFEASIBLE, nachdem eine neue Regel aufgenommen wurde. Was ist der produktivste erste Schritt? (a) Das Zeitlimit erhöhen. (b) Die neue Regel wieder entfernen und in Ruhe abwarten. (c) Die harten Regeln hierarchisch lockern — mit teuren Strafkosten statt Verboten —, damit der Solver benennt, welche Regel den Widerspruch erzeugt, statt nur zu sagen, dass es einen gibt.
@@ -26598,12 +27043,18 @@ sichtbar wird, und gibt Zeit zum Handeln.
Warum ist ein MIP-Gap von 2 % in der Praxis meist ausreichend?
Was ist das Snapshot-Prinzip und wozu dient es?
Was ist der häufigste Grund, aus dem OR-Projekte scheitern?
+
Eine Bedingung bindet, ihr Schattenpreis ist 0. Was heißt das — und was folgt daraus für eine Nachverhandlung?
+
Warum kann die Rangfolge nach Schattenpreis eine andere sein als die Rangfolge nach tatsächlichem Nutzen einer Lockerung?
-
22.13 Zusammenfassung
+
22.14 Zusammenfassung
Infeasibility ist ein Entwurfsproblem, kein Solverproblem. Wer alles hart formuliert, erhält irgendwann eine Fehlermeldung statt eines Plans.
Erklärbarkeit entscheidet über Akzeptanz. Schattenpreise, Kostenzerlegung und Constraint-Traces sind keine Kür.
+
Bindend heißt nicht teuer. Eine Bedingung kann den Plan berühren und trotzdem 0,00 € kosten. Nur die kostenrelevanten lohnen eine Nachverhandlung.
+
Der Schattenpreis gilt für die nächste Einheit, nicht für die nächste Schicht. Hochgerechnet auf einen realistischen Schritt überschätzt er den Nutzen — im Beispiel um das Doppelte. Wer wissen will, was ein Hebel bringt, misst die Lockerung, statt zu multiplizieren; die beiden Ranglisten können sich dabei umdrehen.
+
Ein Wunsch kann unmöglich statt teuer sein — und dann ist die richtige Antwort nicht ein Preis, sondern die Liste der Bedingungen, die ihn gemeinsam blockieren.
+
Ein generierter Bericht macht ein Modell nicht richtiger, nur schwerer anzuzweifeln. Er erklärt das Modell, nicht die Wirklichkeit — deshalb gehört dazu, worauf er beruht.
Optimieren Sie nicht genauer als Ihre Daten. Ein Gap von 2 % ist bei ±10 % Datenunsicherheit irrelevant.
Der Optimierer schlägt vor, der Mensch entscheidet — jedenfalls anfangs.
Reproduzierbarkeit braucht Snapshots und Versionierung, nicht guten Willen.
@@ -26617,7 +27068,7 @@ sichtbar wird, und gibt Zeit zum Handeln.
📌 Kapitel auf einen Blick
Worum geht es? Um die drei Fragen, die zwischen einem funktionierenden Modell und einem System stehen, auf das sich jemand verlässt: Woher weiß ich, dass es stimmt? Woher weiß ich, dass es schnell genug ist? Wie kommt es zu den Leuten, die es brauchen?
-
Voraussetzungen:Kapitel 22, insbesondere or_kern.py aus Abschnitt 22.5 — dieses Kapitel testet genau dieses Modul.
+
Voraussetzungen:Kapitel 22, insbesondere or_kern.py aus Abschnitt 22.6 — dieses Kapitel testet genau dieses Modul.
Danach können Sie: ein Optimierungsmodell testen, obwohl Sie die richtige Antwort nicht kennen; Ihre eigene Testsuite auf Lücken prüfen; einen Solververgleich aufsetzen, dem man glauben kann; und das Modell als Dienst betreiben, ohne dass die erste große Anfrage ihn blockiert.
Zeitbedarf: ca. 5 Stunden.
Programme:
@@ -26633,22 +27084,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>
@@ -26675,7 +27126,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:
@@ -26722,315 +27173,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)
-assertany("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)
-assertany("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)
-assertany("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"])
-assertany("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)
-assertlen(beanstandungen) ==1and"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 instr(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"instr(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:
-assertnot (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):
-assertnot 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)
+assertany("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)
+assertany("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)
+assertany("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"])
+assertany("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)
+assertlen(beanstandungen) ==1and"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 instr(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"instr(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:
+assertnot (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):
+assertnot 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
@@ -27107,185 +27558,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:
-returnNone# 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):
-ifnot os.path.exists(pfad):
-raiseSystemExit(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:
-raiseSystemExit("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 isNone:
-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:
+returnNone# 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):
+ifnot os.path.exists(pfad):
+raiseSystemExit(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:
+raiseSystemExit("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 isNone:
+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
@@ -27340,208 +27791,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:
-returnNone, 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 isNone:
-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:
+returnNone, 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 isNone:
+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
@@ -27668,321 +28119,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
-andnot antwort.beanstandungen
-else Auftragsstand.GESCHEITERT)
-ifnot loesung.status.brauchbar:
- antwort.fehler =f"Solverstatus: {loesung.status.value}"
-elif antwort.beanstandungen:
- antwort.fehler ="Abnahmepruefung fehlgeschlagen"
-exceptExceptionas 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 notin 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(1for 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)
-raiseTimeoutError("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 ==202else''}")
-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'ifnot 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 ==422else''}")
-
-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 inrange(n)]
- gewicht = rng.integers(10, 60, n)
- wert = rng.integers(10, 60, n)
- solver.Add(sum(int(gewicht[i]) * x[i] for i inrange(n))
-<=int(gewicht.sum() *0.5))
-for _ inrange(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 inrange(n)))
- solver.Solve()
-
- t0 = time.perf_counter()
-for _ inrange(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
+andnot antwort.beanstandungen
+else Auftragsstand.GESCHEITERT)
+ifnot loesung.status.brauchbar:
+ antwort.fehler =f"Solverstatus: {loesung.status.value}"
+elif antwort.beanstandungen:
+ antwort.fehler ="Abnahmepruefung fehlgeschlagen"
+exceptExceptionas 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 notin 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(1for 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)
+raiseTimeoutError("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 ==202else''}")
+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'ifnot 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 ==422else''}")
+
+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 inrange(n)]
+ gewicht = rng.integers(10, 60, n)
+ wert = rng.integers(10, 60, n)
+ solver.Add(sum(int(gewicht[i]) * x[i] for i inrange(n))
+<=int(gewicht.sum() *0.5))
+for _ inrange(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 inrange(n)))
+ solver.Solve()
+
+ t0 = time.perf_counter()
+for _ inrange(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
@@ -28061,7 +28512,7 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
💻 Code-Durchgang: drei Entscheidungen
1. Das Domänenmodell ist das API-Schema. Der Parameter heißt problem: Produktionsproblem — mehr steht nicht da. FastAPI validiert die Anfrage damit gegen dieselben Pydantic-Regeln wie der Excel-Import aus Kapitel 1 und antwortet bei Unsinn mit 422, bevor ein Solver startet. Die drei abgewiesenen Anfragen im Selbsttest sind genau die drei Fälle, die or_kern.py schon beim Einlesen abfängt.
-
2. Die Antwort enthält 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 hält. Das Loesung-Objekt geht deshalb vollständig durch die Schnittstelle — dasselbe DTO, das in Abschnitt 22.6 schon eine Prozessgrenze überlebt hat.
+
2. Die Antwort enthält 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 hält. Das Loesung-Objekt geht deshalb vollständig durch die Schnittstelle — dasselbe DTO, das in Abschnitt 22.7 schon eine Prozessgrenze überlebt hat.
3. Die Abnahmeprüfung läuft auch hier.pruefe_loesung() steht im Arbeiter, nicht nur im Skript. Ein Dienst ohne sie liefert Fehler schneller aus, als ein Mensch sie finden könnte.
Threads oder Prozesse? Eine Messung, keine Meinung
@@ -28399,15 +28850,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.
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.
@@ -28422,14 +28873,14 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
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])
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.
@@ -28452,9 +28903,9 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
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
@@ -28467,8 +28918,8 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
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.
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:
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
@@ -28504,24 +28955,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 _ inrange(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 _ inrange(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.
@@ -28542,10 +28993,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 else0for 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 else0for 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?“.
@@ -28690,10 +29141,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:
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.
@@ -28731,17 +29182,17 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
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
-assertnot (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()
-assertabs(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
+assertnot (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()
+assertabs(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
@@ -28763,15 +29214,15 @@ 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 inrange(9)] for i inrange(9)]
-for i inrange(9): m.AddAllDifferent(x[i]) # Zeilen
-for j inrange(9): m.AddAllDifferent([x[i][j] for i inrange(9)]) # Spalten
-for bi inrange(3):
-for bj inrange(3):
- m.AddAllDifferent([x[3*bi+i][3*bj+j] for i inrange(3) for j inrange(3)])
-for i inrange(9):
-for j inrange(9):
-if vorgabe[i][j]: m.Add(x[i][j] == vorgabe[i][j])
+
x = [[m.NewIntVar(1, 9, f"x{i}{j}") for j inrange(9)] for i inrange(9)]
+for i inrange(9): m.AddAllDifferent(x[i]) # Zeilen
+for j inrange(9): m.AddAllDifferent([x[i][j] for i inrange(9)]) # Spalten
+for bi inrange(3):
+for bj inrange(3):
+ m.AddAllDifferent([x[3*bi+i][3*bj+j] for i inrange(3) for j inrange(3)])
+for i inrange(9):
+for j inrange(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).
@@ -28925,12 +29376,12 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
Die Prüfung — und warum sie unabhängig sein muss.
-
for fahrzeug, (ladung, kapazitaet) inenumerate(zip(ladungen, KAPAZITAETEN)):
-assert ladung <= kapazitaet, \
-f"Fahrzeug {fahrzeug +1}: {ladung} Paletten bei Kapazitaet {kapazitaet}"
+
for fahrzeug, (ladung, kapazitaet) inenumerate(zip(ladungen, KAPAZITAETEN)):
+assert ladung <= kapazitaet, \
+f"Fahrzeug {fahrzeug +1}: {ladung} Paletten bei Kapazitaet {kapazitaet}"
Dabei wird ladungaus 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
@@ -29056,9 +29507,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:
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.
@@ -29096,8 +29547,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.“
\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
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.
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.
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 inrange(1, n +1):
-for c inrange(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 inrange(1, n +1):
+for c inrange(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.
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.
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 >0andnot lief_vorher) else0) + ...
- rest, plan = V(t +1, neu, p >0)
+
@functools.lru_cache(None)
+def V(t, lager, lief_vorher):
+ ...
+ kosten = (RUESTKOSTEN if (p >0andnot lief_vorher) else0) + ...
+ 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.
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.
22.2 — Zeitlimit wählen. (a) 1–3 s, Gap 5–10 % (Echtzeit schlägt Optimalität). (b) 1–5 min, Gap 1–2 %. (c) Stunden, Gap ~0 % (einmalige, folgenreiche Entscheidung). (d) 30–60 s, Gap 1 % — die Datenunsicherheit ist ohnehin größer.
22.3 — Relaxation einbauen. Muster wie in Infeasibility_Diagnose.py: Schlupfvariable je Mindestbesetzung, Strafe deutlich über allen weichen Zielen, aber endlich. Wichtig: gestaffelte Strafen, damit der Solver die am wenigsten schmerzhafte Verletzung wählt.
22.4 — Erklärbarkeit. Report-Struktur: (1) Zielwert und Aufschlüsselung nach Bestandteilen; (2) je Entscheidung die bindenden Bedingungen; (3) Schattenpreise der knappsten Ressourcen mit Handlungsempfehlung.
-
22.5 — Gap gegen Laufzeit. Erwartetes Muster: Von 10 % auf 2 % kostet wenig Zeit; von 1 % auf 0 % kann die Laufzeit um Größenordnungen steigen, ohne dass sich der Zielwert nennenswert verbessert. Empfehlung: Gap dort ansetzen, wo die Kurve knickt — typischerweise 1–2 %.
-
22.6 — Post-Mortem. Bewertungskriterien: Wird zwischen Symptom, Ursache und fehlender Prüfung unterschieden? Ist die abgeleitete Regel allgemein genug, um beim nächsten Projekt zu helfen (z. B. „Nach jeder Vorzeichenumkehr eine numerische Gegenprobe“), aber konkret genug, um überprüfbar zu sein?
+
22.5 — Den Bericht in die Irre führen. (a) Der Plan kippt: Rahmen fällt von 47,5 auf 40 — das Minimum aus dem Liefervertrag —, Deckel steigt von 97,5 auf 120, die Marktgrenze. Der Deckungsbeitrag springt von 3 930 € auf 5 190 €. Mit ihm wechselt die Engpassstruktur: „Kapazität Lackieren” bindet nicht mehr, dafür bindet jetzt „Liefervertrag Rahmen” — und zwar als Kosten von 25 € je Stück, weil der Vertrag zur Herstellung eines inzwischen unattraktiven Produkts zwingt. Alle Schattenpreise steigen deutlich (Träger von 18 € auf 58 €). Der entscheidende Punkt der Aufgabe: Der Satz „Teuerste Bindung ist Liefervertrag Träger” steht wörtlich unverändert im Bericht — obwohl die Zahl dahinter sich mehr als verdreifacht hat und ein anderer Engpass die Fabrik begrenzt. Ein Bericht, der sich nicht ändert, ist kein Beweis dafür, dass sich nichts geändert hat. (b) Etwa: „Unter der Annahme eines Deckungsbeitrags von 9 € je Deckel ist ‚Liefervertrag Träger’ die teuerste Bindung (18 € je Stück).” Die Aussage bekommt damit ihre Voraussetzung mit — und wird angreifbar, was sie sein soll. (c) Mindestens eines von beidem: den Gültigkeitsbereich jedes Schattenpreises (bis wohin gilt er?), oder das Ergebnis einer Sensitivitätsrechnung über die unsichersten Eingangsgrößen — etwa „bei ±20 % Deckungsbeitrag Deckel bleibt die Rangfolge/kippt sie”. Das Programm rechnet den Gültigkeitsbereich für die auffälligste Stellschraube bereits aus; ihn für alle auszuweisen ist eine kleine Erweiterung.
+
22.6 — Gap gegen Laufzeit. Erwartetes Muster: Von 10 % auf 2 % kostet wenig Zeit; von 1 % auf 0 % kann die Laufzeit um Größenordnungen steigen, ohne dass sich der Zielwert nennenswert verbessert. Empfehlung: Gap dort ansetzen, wo die Kurve knickt — typischerweise 1–2 %.
+
22.7 — Post-Mortem. Bewertungskriterien: Wird zwischen Symptom, Ursache und fehlender Prüfung unterschieden? Ist die abgeleitete Regel allgemein genug, um beim nächsten Projekt zu helfen (z. B. „Nach jeder Vorzeichenumkehr eine numerische Gegenprobe“), aber konkret genug, um überprüfbar zu sein?
Finde den Denkfehler — Das Modell, das seit einem Jahr nicht mehr optimiert
Warum die konstante Laufzeit das Symptom ist. Ein Solver, der fertig wird, braucht so lange, wie das Problem eben dauert — und diese Zeit wächst mit den Daten. Eine Laufzeit, die über Jahre exakt bei 3,00 Sekunden liegt, kann deshalb nur eines bedeuten: Der Job wird nicht fertig, sondern abgeschnitten.
Weil die Datenunsicherheit (oft ±10 %) die verbleibende Optimalitätslücke bei Weitem übersteigt.
Jeder Lauf arbeitet auf einem unveränderlichen, mit ID versehenen Datenstand — nur so sind Ergebnisse reproduzierbar und belegbar.
Die Einführung: mangelnde Akzeptanz, weil Ergebnisse nicht nachvollziehbar sind.
+
Sie berührt den Plan, ohne ihn zu verteuern — die Lösung liegt genau auf ihrer Grenze, wäre aber auch ohne sie dieselbe (Entartung). Für eine Nachverhandlung folgt: nichts tun. Der Aufwand brächte 0 €. „Bindend” ist ein geometrischer, „teuer” ein wirtschaftlicher Befund.
+
Weil der Schattenpreis nur für die nächste Einheit gilt und sein Gültigkeitsbereich endet, sobald ein anderer Engpass bindend wird. Realistische Lockerungen sind unterschiedlich groß — eine Sonderschicht bringt 50 Einheiten, ein nachverhandelter Vertrag 10. Der Preis je Einheit sagt daher nichts darüber, welcher Hebel insgesamt am meisten bringt; dafür muss man die Lockerung rechnen, nicht den Preis multiplizieren.
Zurück zumWegweiseroder weiter zuAnhang B — Modellierungsmuster
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, kostet aber Deckungsbeitrag und ist im Allgemeinen nicht die optimale ganzzahlige Lösung. (b) Ganzzahlig modellieren — linprog(..., 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).
# 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_tje 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.
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).
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.
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 inenumerate(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 inenumerate(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.
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) inenumerate(zeitfenster):
-assert distanz[depot][kunde] <= spaet, f"Kunde {kunde} nicht rechtzeitig erreichbar"
+
for kunde, (frueh, spaet) inenumerate(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.
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 inlist(auswahl):
- probe = [j for j in rest if j != i]
-ifnot 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 _ inrange(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 insorted(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] notin 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 inlist(auswahl):
+ probe = [j for j in rest if j != i]
+ifnot 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 _ inrange(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 insorted(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] notin 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
@@ -30608,31 +31062,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 inrange(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 inrange(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.
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 inrange(S): constraints.append(u >=-(R @ w) - gamma)
- constraints.append(u[s] >=-R[s] @ w - gamma)
+
# langsam: # schnell:
+for s inrange(S): constraints.append(u >=-(R @ w) - gamma)
+ constraints.append(u[s] >=-R[s] @ w - gamma)
Bei ±10 % Datenunsicherheit sind die letzten 2 % Optimalität verlorene Zeit.
C4 — Unsinniges Ergebnis
@@ -30646,12 +31100,12 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.
Die wirksamste Gegenmaßnahme
-
# Nach JEDEM Lösen: alle Regeln explizit nachprüfen
-assertabs(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
+assertabs(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
@@ -30693,37 +31147,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 inzip(res.slack, schattenpreise):
-assertabs(s * y) <1e-6, "Komplementaerer Schlupf verletzt!"
+
for s, y inzip(res.slack, schattenpreise):
+assertabs(s * y) <1e-6, "Komplementaerer Schlupf verletzt!"
Eine Ressource mit Reserve muss Schattenpreis 0 haben.
3. Numerische Gegenprobe — der zuverlässigste Test.
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
@@ -30779,10 +31233,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())
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?
@@ -30806,15 +31260,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
-assertlist(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
+assertlist(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
@@ -30835,51 +31289,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.
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
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).
@@ -30889,41 +31343,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.
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.
@@ -30933,34 +31387,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 isNone:
-raiseSystemExit("Solver nicht verfuegbar")
-loeser.SetTimeLimit(60_000) # Millisekunden!
-
-unendlich = loeser.infinity()
-x = [loeser.NumVar(0, unendlich, f"x{j}") for j inrange(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 isNone:
+raiseSystemExit("Solver nicht verfuegbar")
+loeser.SetTimeLimit(60_000) # Millisekunden!
+
+unendlich = loeser.infinity()
+x = [loeser.NumVar(0, unendlich, f"x{j}") for j inrange(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
CreateSolver liefert None, wenn der Backend-Name unbekannt oder nicht gebaut ist — immer prüfen, statt am None später zu scheitern.
@@ -30970,40 +31424,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
Alles ist ganzzahlig.0.5 * x gibt es nicht. Skalieren Sie das ganze Modell auf eine feinere Einheit, statt zu runden.
@@ -31013,32 +31467,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)
-ifnot problem.is_dcp(): # VOR dem Loesen pruefen
-print("nicht DCP:", [c for c in bedingungen ifnot 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)
+ifnot problem.is_dcp(): # VOR dem Loesen pruefen
+print("nicht DCP:", [c for c in bedingungen ifnot 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
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).
@@ -31049,73 +31503,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.
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.
Das Programm wertet den Solver nicht über ergebnis.success aus, sondern über status_von_scipy() und das Loesung-Objekt aus dem gemeinsamen Unterbau (Abschnitt 22.5). Der Unterschied ist kein Schönheitsfehler:
+
Das Programm wertet den Solver nicht über ergebnis.success aus, sondern über status_von_scipy() und das Loesung-Objekt aus dem gemeinsamen Unterbau (Abschnitt 22.6). Der Unterschied ist kein Schönheitsfehler:
success ist ein Bit. Es unterscheidet nicht zwischen „es gibt keine Lösung“ (Modellfehler — das Modell muss geändert werden) und „die Zeit war um“ (Rechenproblem — mehr Zeit oder ein besserer Startwert hilft). Das sind zwei völlig verschiedene Nachrichten an völlig verschiedene Adressaten.
Das Loesung-Objekt führt neben dem Zielwert die Schranke mit und rechnet daraus den Gap aus. Erst dadurch steht in der Ausgabe Gap: 0.00% — die Zusage, dass hier wirklich das Optimum gefunden und bewiesen wurde und nicht bloß irgendetwas.
@@ -863,7 +863,7 @@ Abnahmepruefung: alle Nebenbedingungen geprueft und eingehalten.
status = status_von_scipy(ergebnis)
-
übersetzt den SciPy-Rückgabewert in die gemeinsame Sprache aus Abschnitt 22.5 — die drei if-Zweige darunter behandeln Modellfehler, Abbruch ohne Lösung und „zulässig, aber unbewiesen“ getrennt
+
übersetzt den SciPy-Rückgabewert in die gemeinsame Sprache aus Abschnitt 22.6 — die drei if-Zweige darunter behandeln Modellfehler, Abbruch ohne Lösung und „zulässig, aber unbewiesen“ getrennt
… für jede Nebenbedingung beziffern, was sie den Plan kostet — und begründen, warum der Schattenpreis diese Frage nur für die nächste Einheit beantwortet.
… Zeitlimits und Optimalitätslücken sinnvoll setzen.
… die Architektur einer produktionsreifen OR-Plattform skizzieren.
… eine modellunabhängige Abnahmeprüfung schreiben, die eine Lösung gegen die Anforderungen prüft statt gegen das Modell.
@@ -441,6 +443,7 @@
Blockierend und kostenrelevant sind zwei verschiedene Dinge.
==============================================================================
Der letzte Punkt ist der eigentliche Lerneffekt: Die Obergrenze schließt Ben zwar technisch aus, aber selbst wenn er dürfte, wäre er wegen seiner Vorbelastung immer noch teurer als Anna. Eine bindende Bedingung zu lockern hilft nur, wenn sie tatsächlich die kostenrelevante ist — sonst ist die Mühe umsonst.
+
Derselbe Unterschied kehrt auf Modellebene wieder, und dort lässt er sich in Euro beziffern: Abschnitt 22.4 misst für jede Bedingung, was sie den ganzen Plan kostet — und baut daraus einen Bericht, den man vorlegen kann.
Falle 3 — Regimewechsel und Schätzfehler
Ein Modell, das auf einer mehrjährigen Aufwärtsphase kalibriert wurde, kennt keine Liquiditätskrise und keine Zinswende.
@@ -488,7 +491,438 @@
Dem gegenüber steht die Datenunsicherheit: Ihre Nachfrageprognose ist um ±10 % genau, Ihre Fahrzeiten um ±15 %. Die letzten 2 % Optimalität in einem Modell zu erkämpfen, dessen Eingangsdaten um 10 % schwanken, ist verlorene Zeit. Optimieren Sie nicht genauer, als Ihre Daten sind.
-
22.4 Architektur einer produktionsreifen OR-Plattform
+
22.4 Constraint Attribution: welche Bedingung kostet wie viel?
+
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, und für ihn hat bisher niemand ein Werkzeug bekommen:
+
+
Das Modell rechnet. Der Plan ist zulässig. Und trotzdem ist er enttäuschend — nur weiß niemand, woran es liegt.
+
+
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) inenumerate(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, _, _, _) inenumerate(bedingungen):
+ rest = [c for j, c inenumerate(bedingungen) if j != i] + [zusatz]
+if loese(rest).success:
+ gefunden.append(name)
+return gefunden
+
+
+def euro(betrag: float) ->str:
+returnf"{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 =""
+ifabs(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 else0.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 inrange(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}",
+ [1if p == WUNSCH_PRODUKT else0for 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 inrange(1, 61):
+if loese(geaendert(engpass, schritt) + [wunsch]).success:
+ noetig = schritt
+break
+if noetig isnotNone:
+ 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 isnotNone:
+ 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?
+==============================================================================
+Status: Optimization terminated successfully.
+Deckungsbeitrag des Plans: 3,930.00 EUR
+
+ Rahmen: 47.5 Gehaeuse: 30.0 Deckel: 97.5 Traeger: 25.0 Halter: 0.0
+
+==============================================================================
+ (1) Welche Bedingungen binden - und was kostet die naechste Einheit?
+==============================================================================
+Bedingung genutzt Grenze Schlupf Schattenpreis
+------------------------------------------------------------------------------
+Kapazitaet Montage 400.0 400 0.0 9.00 <-- bindend
+Kapazitaet Lackieren 210.0 210 0.0 4.00 <-- bindend
+Kapazitaet Pruefung 200.0 300 100.0 0.00
+Liefervertrag Rahmen 47.5 40 7.5 0.00
+Liefervertrag Gehaeuse 30.0 30 0.0 2.00 <-- bindend
+Liefervertrag Deckel 97.5 20 77.5 0.00
+Liefervertrag Traeger 25.0 25 0.0 18.00 <-- bindend
+Marktgrenze Rahmen 47.5 120 72.5 0.00
+Marktgrenze Gehaeuse 30.0 90 60.0 0.00
+Marktgrenze Deckel 97.5 150 52.5 0.00
+Marktgrenze Halter 0.0 200 200.0 0.00
+Mindestumsatz 10800.0 10000 800.0 0.00
+Sortimentsbreite 122.5 100 22.5 0.00
+Lackierbudget Schicht 2 55.0 55 0.0 0.00 <-- bindend
+
+ 5 Bedingungen binden. Aber: Lackierbudget Schicht 2 bindet
+ mit einem Schattenpreis von 0,00 EUR - sie beruehrt den Plan, ohne ihn
+ zu verteuern. Bindend und kostenrelevant sind zwei verschiedene Dinge.
+
+==============================================================================
+ (2) Was der Schattenpreis verschweigt
+==============================================================================
+Der Schattenpreis gilt fuer die NAECHSTE Einheit. Ein Planer kauft aber
+keine Einheit, sondern eine ganze Schicht. Beides gegenuebergestellt:
+
+Stellschraube Preis Schritt hochgerechnet gemessen Abw.
+------------------------------------------------------------------------------
+Kapazitaet Montage 9.00 +50 450.00 450.00 +0.00
+Kapazitaet Lackieren 4.00 +30 120.00 60.00 -60.00
+Kapazitaet Pruefung 0.00 +50 0.00 0.00 +0.00
+Lackierbudget Schicht 2 0.00 +10 0.00 0.00 +0.00
+Liefervertrag Traeger 18.00 -10 180.00 180.00 +0.00
+Liefervertrag Gehaeuse 2.00 -10 20.00 18.00 -2.00
+
+ 'Kapazitaet Lackieren': hochgerechnet 120.00 EUR, tatsaechlich 60.00 EUR.
+ Der Preis von 4.00 EUR gilt nur bis +15 Stunden. Jede weitere Stunde
+ dieser Sonderschicht bringt exakt nichts - der Engpass ist dann ein anderer.
+
+ Und die Ranglisten drehen sich: Nach Schattenpreis fuehrt
+ 'Liefervertrag Traeger' (18.00 EUR je Einheit),
+ nach tatsaechlicher Wirkung 'Kapazitaet Montage' (450.00 EUR).
+ Der Schattenpreis sagt, was eine Einheit wert ist - nicht, wie viele
+ Einheiten zu haben sind.
+
+==============================================================================
+ (3) Was-waere-wenn: mindestens 55 Rahmen statt 47.5
+==============================================================================
+Der Wunsch ist nicht teuer - er ist UNMOEGLICH.
+
+Blockiert wird er von 3 Bedingungen gemeinsam. Faellt eine davon
+weg, ist er erfuellbar; keine allein ist 'der' Grund:
+ ohne 'Kapazitaet Lackieren': moeglich, Deckungsbeitrag 3,990.00 EUR
+ ohne 'Liefervertrag Gehaeuse': moeglich, Deckungsbeitrag 3,948.00 EUR
+ ohne 'Liefervertrag Traeger': moeglich, Deckungsbeitrag 4,320.00 EUR
+
+ Mit +15 bei 'Kapazitaet Lackieren' wird der Wunsch erfuellbar:
+ Deckungsbeitrag 3,990.00 EUR (+60.00 gegenueber heute).
+ Und ohne den Wunsch ergaebe dieselbe Lockerung 3,990.00 EUR bei
+ 55 Rahmen - der Wunsch kostet also NICHTS.
+ Er war nie das Problem. Der Engpass war es.
+
+==============================================================================
+ (4) Und so steht es in der Vorlage fuer die Abteilungsleiterrunde
+==============================================================================
+
+Der Plan bringt 3,930.00 EUR Deckungsbeitrag. Begrenzt wird er von 5
+Bedingungen, von denen 4 tatsaechlich Geld kosten; "Lackierbudget Schicht 2"
+beruehrt den Plan, ohne ihn zu verteuern.
+
+Teuerste Bindung ist "Liefervertrag Traeger" mit 18.00 EUR je Einheit,
+gefolgt von "Kapazitaet Montage" mit 9.00 EUR.
+
+Die groesste einzelne Verbesserung bringt eine Sonderschicht Montage: 450.00
+EUR mehr Deckungsbeitrag. Eine Sonderschicht Lackieren dagegen bringt nur
+60.00 EUR statt der rechnerischen 120.00 EUR - ab +15 Stunden begrenzt uns
+etwas anderes. Wer die ganze Schicht bezahlt, bezahlt die zweite Haelfte
+umsonst.
+
+Zum Wunsch des Vertriebs (55 Rahmen statt 47.5): Er ist mit der heutigen
+Ausstattung nicht erfuellbar - nicht aus Kostengruenden, sondern weil
+"Kapazitaet Lackieren", "Liefervertrag Gehaeuse", "Liefervertrag Traeger"
+ihn gemeinsam blockieren. Mit +15 Stunden bei "Kapazitaet Lackieren" wird er
+moeglich, und er kostet dann nichts: Der Plan waehlt diese Menge von sich
+aus.
+
+==============================================================================
+
Drei Befunde, und keiner steht im Solverergebnis
+
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 — wer sie nachverhandelt, gewinnt nichts. Das ist derselbe Unterschied, den Erklaerbarkeit.py oben an einer einzelnen Zuweisung zeigt, hier im Dualwert eines LP.
+
Zweitens: Der Schattenpreis ist eine Momentaufnahme. Er gilt für die nächste Einheit. Ein Planer kauft aber keine Einheit, sondern eine Schicht. Für die Lackiererei verspricht die Hochrechnung 4{,}00 \cdot 30 = 120 €; gemessen kommen 60 €. Der Preis von 4,00 € gilt exakt bis +15 Stunden, danach begrenzt etwas anderes — die zweite Hälfte der Sonderschicht wäre bezahlt und wirkungslos.
+
Und die Ranglisten drehen sich dabei um: Nach Schattenpreis führt der Trägervertrag mit 18,00 € je Stück, nach tatsächlicher Wirkung die Montage-Sonderschicht mit 450 €. Der Schattenpreis sagt, was eine Einheit wert ist — nicht, wie viele Einheiten zu haben sind. Für die Frage „welchen Hebel ziehen wir?“ ist das der entscheidende Unterschied.
+
Drittens: Ein Wunsch kann unmöglich statt teuer sein. Der Vertrieb hätte gern 55 Rahmen statt 47,5. Das Modell antwortet nicht mit einem Preis, sondern mit INFEASIBLE — und drei Bedingungen blockieren den Wunsch gemeinsam: Fällt eine davon weg, ist er erfüllbar, keine allein ist „der“ Grund. Genau die Struktur, die Anhang C für den unlösbaren Fall beschreibt, hier auf einen Wunsch angewandt.
+
Die Pointe steht am Ende: Mit +15 Stunden Lackierkapazität wird der Wunsch erfüllbar — und kostet dann nichts. Der Plan wählt diese Menge von sich aus, sobald der Engpass weg ist. Der Konflikt bestand nie zwischen Vertrieb und Produktion. Er bestand zwischen dem Vertrieb und der Lackiererei, und niemand wusste das.
+
+
🎯 Die drei Fragen, jetzt auf Modellebene Die Faustregel oben stellt sie für eine einzelne Zuweisung. Constraint Attribution stellt dieselben drei für den ganzen Plan: 1. Warum dieser Zielwert? — welche Bedingungen binden, und welche davon kosten Geld. 2. Warum nicht besser? — was eine realistische Lockerung wirklich bringt, gemessen statt hochgerechnet. 3. Was ist mit meinem Wunsch? — erfüllbar und zu welchem Preis, oder unmöglich und warum.
+
+
Vom Zahlenwerk zur Vorlage
+
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 weniger trivial, als es aussieht:
+
+
Kein Satz enthält eine Zahl, die nicht vorher gerechnet wurde. Auch nicht die Rangfolge, auch nicht die Namen der blockierenden Bedingungen. Sobald sich das Modell ändert, ändert sich der Bericht mit — er kann nicht leise falsch werden.
+
Der sprechende Name jeder Bedingung ist die Voraussetzung dafür.A_ub[7] ergibt keinen Satz. Deshalb tragen die Bedingungen in beiden Programmen einen Namen, und deshalb ist das keine Kosmetik.
+
+
+
⚠️ Was ein solcher Bericht nicht leistet Er erklärt das Modell, nicht die Wirklichkeit. „Liefervertrag Träger kostet 18 € je Stück“ heißt: in diesem Modell, mit diesen Deckungsbeiträgen. Ist ein Deckungsbeitrag falsch geschätzt, ist der Bericht überzeugend und falsch — die gefährlichste Kombination. Ein generierter Text macht ein Modell nicht richtiger, nur schwerer anzuzweifeln. Deshalb gehört zu jedem solchen Bericht die Angabe, worauf er beruht.
+
+
+
22.5 Architektur einer produktionsreifen OR-Plattform
Abb. 22.2: Architektur einer produktionsreifen OR-Plattform
@@ -552,7 +986,7 @@
⚠️ Der häufigste Projektfehler Nicht das Modell scheitert, sondern die Einführung. Ein technisch überlegener Plan, den die Disponentin nicht versteht und dem sie nicht traut, wird umgangen — sie plant weiter in ihrer Tabelle. Rechnen Sie mindestens so viel Zeit für Erklärbarkeit, Schulung und schrittweise Einführung ein wie für das Modell selbst.
-
22.5 Der gemeinsame Unterbau: or_kern.py
+
22.6 Der gemeinsame Unterbau: or_kern.py
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.
Sehen Sie sich die Programme dieses Buchs an: Jedes liest Daten ein, baut ein Modell, wertet einen Solverstatus aus und prüft das Ergebnis. Nur der mittlere Schritt ist wirklich problemspezifisch. Die anderen drei schreibt man in jedem Projekt neu — und macht dabei jedes Mal dieselben Fehler.
Die vier Schichten
@@ -669,510 +1103,510 @@
⚠️ 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?"""
-returnselfin (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG)
-
-@property
-def modellfehler(self) ->bool:
-"""Liegt die Ursache im Modell (und nicht in der Rechenzeit)?"""
-returnselfin (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.
- """
-ifself.zielwert isNoneorself.schranke isNone:
-returnNone
- nenner =max(abs(self.zielwert), 1e-9)
-returnabs(self.zielwert -self.schranke) / nenner
-
-def als_bericht(self) ->str:
-"""Eine Zeile fuers Betriebsprotokoll - siehe Betriebsueberwachung.py."""
- teile = [f"Status: {self.status.value}"]
-ifself.zielwert isnotNone:
- teile.append(f"Zielwert: {self.zielwert:,.2f}")
-ifself.gap isnotNone:
- teile.append(f"Gap: {self.gap:.2%}")
-ifself.laufzeit isnotNone:
- 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 inself.produkte]
-iflen(set(namen)) !=len(namen):
- doppelt =sorted({n for n in namen if namen.count(n) >1})
-raiseValueError(f"Produktnamen kommen mehrfach vor: {doppelt}")
-
-for produkt inself.produkte:
- unbekannt =set(produkt.verbrauch) -set(self.kapazitaeten)
-if unbekannt:
-raiseValueError(
-f"Produkt '{produkt.name}' verbraucht Ressourcen ohne "
-f"Kapazitaetsangabe: {sorted(unbekannt)}")
-returnself
-
-@property
-def ressourcen(self) ->list[str]:
-"""Feste Reihenfolge - siehe die Spaltenfalle in Kapitel Finanzdaten."""
-returnsorted(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 inself.produkte]
-for r inself.ressourcen])
-
-def kapazitaetsvektor(self) -> np.ndarray:
-return np.array([self.kapazitaeten[r] for r inself.ressourcen])
-
-def deckungsbeitragsvektor(self) -> np.ndarray:
-return np.array([p.deckungsbeitrag for p inself.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] = []
-
-ifnot loesung.status.brauchbar:
-return [f"Kein verwertbares Ergebnis (Status: {loesung.status.value})"]
-
- fehlend = [p.name for p in problem.produkte if p.name notin 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 inzip(problem.produkte, mengen) if m <-toleranz]
- beanstandungen.append(f"negative Mengen bei: {negativ}")
-
- verbrauch = problem.verbrauchsmatrix() @ mengen
-for ressource, ist, grenze inzip(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]
-ifabs(wert -round(wert)) > toleranz:
- beanstandungen.append(f"'{name}' = {wert!r} ist nicht ganzzahlig")
-
-if loesung.zielwert isnotNone:
- nachgerechnet =float(problem.deckungsbeitragsvektor() @ mengen)
-ifabs(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)
-ifnot 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)
-ifnot 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 inzip(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 notin produkte_tabelle.columns]
-if fehlende:
-raiseValueError(f"Blatt '{blatt_produkte}': Spalten fehlen: {fehlende}")
-if produkte_tabelle.isna().any().any():
- zeilen = produkte_tabelle[produkte_tabelle.isna().any(axis=1)].index.tolist()
-raiseValueError(f"Blatt '{blatt_produkte}': leere Zellen in Zeile(n) {zeilen}")
-
- ressourcen = [s for s in produkte_tabelle.columns if s notin 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
-
-ifnot loesung.status.brauchbar:
-raiseValueError(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 or0.0,
- }
-for ressource, ist, grenze inzip(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) ifisinstance(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'ifnot 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 (!)")
-exceptExceptionas 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?"""
+returnselfin (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG)
+
+@property
+def modellfehler(self) ->bool:
+"""Liegt die Ursache im Modell (und nicht in der Rechenzeit)?"""
+returnselfin (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.
+ """
+ifself.zielwert isNoneorself.schranke isNone:
+returnNone
+ nenner =max(abs(self.zielwert), 1e-9)
+returnabs(self.zielwert -self.schranke) / nenner
+
+def als_bericht(self) ->str:
+"""Eine Zeile fuers Betriebsprotokoll - siehe Betriebsueberwachung.py."""
+ teile = [f"Status: {self.status.value}"]
+ifself.zielwert isnotNone:
+ teile.append(f"Zielwert: {self.zielwert:,.2f}")
+ifself.gap isnotNone:
+ teile.append(f"Gap: {self.gap:.2%}")
+ifself.laufzeit isnotNone:
+ 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 inself.produkte]
+iflen(set(namen)) !=len(namen):
+ doppelt =sorted({n for n in namen if namen.count(n) >1})
+raiseValueError(f"Produktnamen kommen mehrfach vor: {doppelt}")
+
+for produkt inself.produkte:
+ unbekannt =set(produkt.verbrauch) -set(self.kapazitaeten)
+if unbekannt:
+raiseValueError(
+f"Produkt '{produkt.name}' verbraucht Ressourcen ohne "
+f"Kapazitaetsangabe: {sorted(unbekannt)}")
+returnself
+
+@property
+def ressourcen(self) ->list[str]:
+"""Feste Reihenfolge - siehe die Spaltenfalle in Kapitel Finanzdaten."""
+returnsorted(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 inself.produkte]
+for r inself.ressourcen])
+
+def kapazitaetsvektor(self) -> np.ndarray:
+return np.array([self.kapazitaeten[r] for r inself.ressourcen])
+
+def deckungsbeitragsvektor(self) -> np.ndarray:
+return np.array([p.deckungsbeitrag for p inself.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] = []
+
+ifnot loesung.status.brauchbar:
+return [f"Kein verwertbares Ergebnis (Status: {loesung.status.value})"]
+
+ fehlend = [p.name for p in problem.produkte if p.name notin 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 inzip(problem.produkte, mengen) if m <-toleranz]
+ beanstandungen.append(f"negative Mengen bei: {negativ}")
+
+ verbrauch = problem.verbrauchsmatrix() @ mengen
+for ressource, ist, grenze inzip(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]
+ifabs(wert -round(wert)) > toleranz:
+ beanstandungen.append(f"'{name}' = {wert!r} ist nicht ganzzahlig")
+
+if loesung.zielwert isnotNone:
+ nachgerechnet =float(problem.deckungsbeitragsvektor() @ mengen)
+ifabs(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)
+ifnot 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)
+ifnot 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 inzip(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 notin produkte_tabelle.columns]
+if fehlende:
+raiseValueError(f"Blatt '{blatt_produkte}': Spalten fehlen: {fehlende}")
+if produkte_tabelle.isna().any().any():
+ zeilen = produkte_tabelle[produkte_tabelle.isna().any(axis=1)].index.tolist()
+raiseValueError(f"Blatt '{blatt_produkte}': leere Zellen in Zeile(n) {zeilen}")
+
+ ressourcen = [s for s in produkte_tabelle.columns if s notin 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
+
+ifnot loesung.status.brauchbar:
+raiseValueError(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 or0.0,
+ }
+for ressource, ist, grenze inzip(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) ifisinstance(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'ifnot 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 (!)")
+exceptExceptionas 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
@@ -1269,7 +1703,7 @@ Domaenenschicht.
🎯 Merksatz Ein Fehler, der beim Einlesen auffliegt, kostet Minuten. Derselbe Fehler, der erst im Bericht auffällt, kostet Tage — und derselbe Fehler, der nie auffällt, kostet am meisten. Validierung ist deshalb keine Fleißarbeit, sondern die günstigste Stelle im ganzen Ablauf.
-
22.6 Der Solverwechsel in der Praxis
+
22.7 Der Solverwechsel in der Praxis
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 nicht glauben sollte, bevor sie jemand vorgeführt hat.
Also führen wir sie vor — und zwar am unbequemsten Paar, das dieses Buch zu bieten hat: CP-SAT gegen HiGHS. Die beiden sind grundverschieden. CP-SAT ist ein Constraint-Programming-Solver, der ausschließlich ganzzahlig rechnet und Restriktionen als Ausdrücke entgegennimmt. HiGHS ist ein klassischer Branch-and-Bound-Löser, der eine Koeffizientenmatrix erwartet. Und sie lassen sich nicht einmal gemeinsam importieren (Kapitel 3).
Die Aufgabe ist eine kapazitierte Standortplanung: Aus sechs möglichen Lagern sind diejenigen zu eröffnen, mit denen zwölf Kunden am günstigsten beliefert werden. Jeder Kunde wird von genau einem Lager bedient, jedes eröffnete Lager kostet eine Fixgebühr, und die Kapazität reicht je Lager für 174 der insgesamt 387 Paletten — es müssen also mindestens drei Lager öffnen.
@@ -1314,330 +1748,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)
-iflen(self.fixkosten) != n orlen(self.kapazitaet) != n:
-raiseValueError(f"fixkosten/kapazitaet muessen {n} Eintraege haben")
-iflen(self.bedarf) != m:
-raiseValueError(f"bedarf muss {m} Eintraege haben")
-iflen(self.transport) != n orany(len(z) != m for z inself.transport):
-raiseValueError(f"transport muss {n} x {m} sein")
-ifsum(self.kapazitaet) <sum(self.bedarf):
-raiseValueError(f"Gesamtkapazitaet {sum(self.kapazitaet)} deckt den "
-f"Gesamtbedarf {sum(self.bedarf)} nicht")
-returnself
-
-def schluessel(self, i: int, j: int) ->str:
-"""Variablenname im Loesung-DTO - beide Modellbauer benutzen ihn."""
-returnf"{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 inrange(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 inrange(n)]
- x = {(i, j): modell.NewBoolVar(f"liefert_{i}_{j}")
-for i inrange(n) for j inrange(m)}
-
-for j inrange(m): # jeder Kunde genau einmal
- modell.AddExactlyOne(x[i, j] for i inrange(n))
-for i inrange(n):
-for j inrange(m): # nur aus offenen Lagern
- modell.AddImplication(x[i, j], y[i])
- modell.Add(sum(problem.bedarf[j] * x[i, j] for j inrange(m))
-<= problem.kapazitaet[i] * y[i]) # Kapazitaet
-
- modell.Minimize(
-sum(problem.fixkosten[i] * y[i] for i inrange(n))
-+sum(problem.transport[i][j] * x[i, j] for i inrange(n) for j inrange(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))
-ifnot 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 inrange(n) for j inrange(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 inrange(anzahl_x + n):
- modell.changeColIntegrality(spalte, highspy.HighsVarType.kInteger)
-for i inrange(n):
- modell.changeColCost(spalte_y(i), float(problem.fixkosten[i]))
-for j inrange(m):
- modell.changeColCost(i * m + j, float(problem.transport[i][j]))
-
-for j inrange(m): # jeder Kunde genau einmal
- index = np.array([i * m + j for i inrange(n)], dtype=np.int32)
- modell.addRow(1.0, 1.0, n, index, np.ones(n))
-for i inrange(n):
-for j inrange(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 inrange(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()))
-ifnot 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 inrange(n) for j inrange(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."""
-ifnot 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 inrange(m)] for i inrange(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 inenumerate(problem.kunden):
-ifabs(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 inenumerate(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 isnotNone:
- offen = beliefert > toleranz
- nachgerechnet = (np.array(problem.fixkosten, dtype=float) @ offen
-+ (np.array(problem.transport, dtype=float) * zuordnung).sum())
-ifabs(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 inenumerate(problem.lager)
-ifany(loesung.werte[problem.schluessel(i, j)] >0.5for j inrange(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:
-raiseRuntimeError(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 ---------------
-iflen(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'ifnot 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'}")
-
-assertabs(zielwerte[0] - zielwerte[1]) <0.5, "Die Solver widersprechen sich!"
-assertall(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)
+iflen(self.fixkosten) != n orlen(self.kapazitaet) != n:
+raiseValueError(f"fixkosten/kapazitaet muessen {n} Eintraege haben")
+iflen(self.bedarf) != m:
+raiseValueError(f"bedarf muss {m} Eintraege haben")
+iflen(self.transport) != n orany(len(z) != m for z inself.transport):
+raiseValueError(f"transport muss {n} x {m} sein")
+ifsum(self.kapazitaet) <sum(self.bedarf):
+raiseValueError(f"Gesamtkapazitaet {sum(self.kapazitaet)} deckt den "
+f"Gesamtbedarf {sum(self.bedarf)} nicht")
+returnself
+
+def schluessel(self, i: int, j: int) ->str:
+"""Variablenname im Loesung-DTO - beide Modellbauer benutzen ihn."""
+returnf"{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 inrange(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 inrange(n)]
+ x = {(i, j): modell.NewBoolVar(f"liefert_{i}_{j}")
+for i inrange(n) for j inrange(m)}
+
+for j inrange(m): # jeder Kunde genau einmal
+ modell.AddExactlyOne(x[i, j] for i inrange(n))
+for i inrange(n):
+for j inrange(m): # nur aus offenen Lagern
+ modell.AddImplication(x[i, j], y[i])
+ modell.Add(sum(problem.bedarf[j] * x[i, j] for j inrange(m))
+<= problem.kapazitaet[i] * y[i]) # Kapazitaet
+
+ modell.Minimize(
+sum(problem.fixkosten[i] * y[i] for i inrange(n))
++sum(problem.transport[i][j] * x[i, j] for i inrange(n) for j inrange(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))
+ifnot 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 inrange(n) for j inrange(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 inrange(anzahl_x + n):
+ modell.changeColIntegrality(spalte, highspy.HighsVarType.kInteger)
+for i inrange(n):
+ modell.changeColCost(spalte_y(i), float(problem.fixkosten[i]))
+for j inrange(m):
+ modell.changeColCost(i * m + j, float(problem.transport[i][j]))
+
+for j inrange(m): # jeder Kunde genau einmal
+ index = np.array([i * m + j for i inrange(n)], dtype=np.int32)
+ modell.addRow(1.0, 1.0, n, index, np.ones(n))
+for i inrange(n):
+for j inrange(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 inrange(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()))
+ifnot 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 inrange(n) for j inrange(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."""
+ifnot 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 inrange(m)] for i inrange(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 inenumerate(problem.kunden):
+ifabs(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 inenumerate(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 isnotNone:
+ offen = beliefert > toleranz
+ nachgerechnet = (np.array(problem.fixkosten, dtype=float) @ offen
++ (np.array(problem.transport, dtype=float) * zuordnung).sum())
+ifabs(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 inenumerate(problem.lager)
+ifany(loesung.werte[problem.schluessel(i, j)] >0.5for j inrange(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:
+raiseRuntimeError(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 ---------------
+iflen(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'ifnot 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'}")
+
+assertabs(zielwerte[0] - zielwerte[1]) <0.5, "Die Solver widersprechen sich!"
+assertall(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
@@ -1696,14 +2130,14 @@ ein unzuverlaessiger Test (siehe JobShop_Intervalle.py).
🎯 Merksatz Die Frage „welcher Solver ist der beste?“ ist schlecht gestellt. Die gute Frage lautet: „Wie teuer ist es, den Solver zu wechseln?“ Wer sie mit eine Funktion beantworten kann, muss die erste Frage nie entscheiden — er misst.
-
22.7 Eine Checkliste vor dem Produktivgang
+
22.8 Eine Checkliste vor dem Produktivgang
Arbeiten Sie diese Liste ab, bevor ein OR-System in Betrieb geht:
Modell - [ ] Alle Nebenbedingungen sind dokumentiert — mit fachlicher Begründung, nicht nur als Code. - [ ] Für jede harte Bedingung ist geprüft: Muss sie wirklich hart sein? - [ ] Es gibt eine Relaxationsstrategie für den Infeasible-Fall. - [ ] Big-M-Werte sind so klein wie möglich gewählt. - [ ] Die Zielfunktion ist in konsistenten Einheiten formuliert.
Daten - [ ] Spaltenreihenfolge und Datentypen werden nach jedem Import geprüft. - [ ] Fehlende Werte und Ausreißer haben eine definierte Behandlung. - [ ] Kovarianzmatrizen werden auf positive Definitheit geprüft. - [ ] Datenstände sind versioniert und reproduzierbar.
Validierung - [ ] Das Ergebnis wurde gegen eine unabhängige Rechnung geprüft (Handrechnung, zweiter Solver, Simulation). - [ ] Alle Nebenbedingungen werden nach dem Lösen per assert verifiziert. - [ ] Es gibt einen Vergleich gegen eine naive Referenzstrategie. - [ ] Bei Backtests: Lookahead-Selbsttest bestanden, Zahl der Versuche protokolliert.
Betrieb - [ ] Zeitlimit und akzeptierter Gap sind festgelegt und begründet. - [ ] Es gibt eine Fallback-Strategie und Alarmierung. - [ ] Jedes Ergebnis ist mit Snapshot-ID, Modellversion und Solver-Version protokolliert. - [ ] Die Ergebnisse sind erklärbar (Kostenzerlegung, Schattenpreise, Constraint-Trace).
-
22.8 Weiterführende Literatur und Roadmap
+
22.9 Weiterführende Literatur und Roadmap
Grundlagen und lineare/ganzzahlige Optimierung * Bertsimas & Tsitsiklis: Introduction to Linear Optimization — Standardwerk zu Simplex, Dualität, Polyedertheorie. * Wolsey: Integer Programming — Branch-and-Cut, Schnittebenen, Formulierungsstärke.
Konvexe Optimierung * Boyd & Vandenberghe: Convex Optimization — Pflichtlektüre; frei verfügbar. Die theoretische Basis von CVXPY.
Constraint Programming und Scheduling * Google OR-Tools Dokumentation und das CP-SAT-Primer von Laurent Perron.
@@ -1753,7 +2187,7 @@ ein unzuverlaessiger Test (siehe JobShop_Intervalle.py).
-
@@ -1761,10 +2195,11 @@ ein unzuverlaessiger Test (siehe JobShop_Intervalle.py).
Aufgabe 22.2 ⭐ — Zeitlimit wählen. Für welche Szenarien setzen Sie welches Zeitlimit und welchen Gap? (a) Taxi-Disposition in Echtzeit, (b) Wochendienstplan, freitags erstellt, (c) Jahresproduktionsplanung, (d) Portfolio-Rebalancing monatlich.
Aufgabe 22.3 ⭐⭐ — Relaxation einbauen. Nehmen Sie Ihr Modell aus Übung 6.7 (Wochendienstplan) und machen Sie es INFEASIBLE-sicher: Schlupfvariablen für unbesetzte Schichten, gestaffelte Strafen. Testen Sie mit einem bewusst überlasteten Szenario.
Aufgabe 22.4 ⭐⭐ — Erklärbarkeit implementieren. Erweitern Sie ein beliebiges Modell aus dem Kurs um einen erklaere(loesung)-Report, der die drei Fragen aus Abschnitt 22.3 beantwortet.
-
Aufgabe 22.5 ⭐⭐⭐ — Gap gegen Laufzeit. Nehmen Sie das MILP-Portfolio aus Kapitel 6 und vergrößern Sie es auf 50 Anlagen. Messen Sie Laufzeit und Zielwert bei Gap-Vorgaben von 10 %, 5 %, 2 %, 1 %, 0,1 % und 0 %. Stellen Sie den Zusammenhang grafisch dar und leiten Sie eine Empfehlung ab.
-
Aufgabe 22.6 ⭐⭐⭐ — Post-Mortem schreiben. Suchen Sie sich einen der im Buch besprochenen Fehler aus (z. B. die Vorzeichenfalle in Abschnitt 5.7, die ungültige Kovarianzmatrix in Kapitel 11, die Sektor-Positionsindizes in Kapitel 19, die vermischten Einheiten in Abschnitt 20.6 oder die Rebalancing-Termine in Abschnitt 21.4) und schreiben Sie ein einseitiges Post-Mortem: Was war der Fehler? Warum fiel er nicht auf? Welche Prüfung hätte ihn gefunden? Welche Regel leiten Sie daraus für eigene Projekte ab?
+
Aufgabe 22.5 ⭐⭐ — Den Bericht in die Irre führen.Constraint_Attribution.py erzeugt seinen Bericht aus gerechneten Zahlen — richtig ist er deshalb noch nicht. (a) Setzen Sie den Deckungsbeitrag von Deckel von 9 € auf 20 €. Wie ändern sich Plan, Rangliste und Bericht? Welche Sätze bleiben wörtlich stehen, obwohl sie jetzt etwas anderes bedeuten? (b) Der Bericht nennt „Liefervertrag Träger” als teuerste Bindung. Formulieren Sie den Satz so um, dass ein Leser erkennt, worauf die Aussage beruht. (c) Welche Angabe müsste das Programm zusätzlich ausgeben, damit ein Empfänger die Belastbarkeit selbst einschätzen kann?
+
Aufgabe 22.6 ⭐⭐⭐ — Gap gegen Laufzeit. Nehmen Sie das MILP-Portfolio aus Kapitel 6 und vergrößern Sie es auf 50 Anlagen. Messen Sie Laufzeit und Zielwert bei Gap-Vorgaben von 10 %, 5 %, 2 %, 1 %, 0,1 % und 0 %. Stellen Sie den Zusammenhang grafisch dar und leiten Sie eine Empfehlung ab.
+
Aufgabe 22.7 ⭐⭐⭐ — Post-Mortem schreiben. Suchen Sie sich einen der im Buch besprochenen Fehler aus (z. B. die Vorzeichenfalle in Abschnitt 5.7, die ungültige Kovarianzmatrix in Kapitel 11, die Sektor-Positionsindizes in Kapitel 19, die vermischten Einheiten in Abschnitt 20.6 oder die Rebalancing-Termine in Abschnitt 21.4) und schreiben Sie ein einseitiges Post-Mortem: Was war der Fehler? Warum fiel er nicht auf? Welche Prüfung hätte ihn gefunden? Welche Regel leiten Sie daraus für eigene Projekte ab?
-
22.10 Finde den Denkfehler
+
22.11 Finde den Denkfehler
Falle 5 — die Laufzeitexplosion — zeigt sich im Betrieb selten als Absturz. Sie zeigt sich als gar nichts.
@@ -1807,186 +2242,186 @@ ein unzuverlaessiger Test (siehe JobShop_Intervalle.py).
#!/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 = []
-ifself.status !="Optimal":
- meldungen.append(f"nicht beweisbar optimal ({self.status})")
-ifself.gap > GAP_WARNSCHWELLE:
- meldungen.append(f"Gap {self.gap:.1%} ueber Schwelle "
-f"{GAP_WARNSCHWELLE:.0%}")
-ifself.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 inrange(n_lager):
- modell.changeColIntegrality(anzahl_x + i, highspy.HighsVarType.kInteger)
- modell.changeColCost(anzahl_x + i, fixkosten[i])
-for j inrange(n_kunden):
- modell.changeColCost(i * n_kunden + j, transport[i, j])
-
-for j inrange(n_kunden):
- index = np.array([i * n_kunden + j for i inrange(n_lager)], dtype=np.int32)
- modell.addRow(bedarf[j], bedarf[j], len(index), index, np.ones(len(index)))
-for i inrange(n_lager):
- index = np.array([i * n_kunden + j for j inrange(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 = []
+ifself.status !="Optimal":
+ meldungen.append(f"nicht beweisbar optimal ({self.status})")
+ifself.gap > GAP_WARNSCHWELLE:
+ meldungen.append(f"Gap {self.gap:.1%} ueber Schwelle "
+f"{GAP_WARNSCHWELLE:.0%}")
+ifself.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 inrange(n_lager):
+ modell.changeColIntegrality(anzahl_x + i, highspy.HighsVarType.kInteger)
+ modell.changeColCost(anzahl_x + i, fixkosten[i])
+for j inrange(n_kunden):
+ modell.changeColCost(i * n_kunden + j, transport[i, j])
+
+for j inrange(n_kunden):
+ index = np.array([i * n_kunden + j for i inrange(n_lager)], dtype=np.int32)
+ modell.addRow(bedarf[j], bedarf[j], len(index), index, np.ones(len(index)))
+for i inrange(n_lager):
+ index = np.array([i * n_kunden + j for j inrange(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
@@ -2050,7 +2485,7 @@ sichtbar wird, und gibt Zeit zum Handeln.
🎯 Merksatz Ein Optimierungsjob, der ein Zeitlimit hat, wird bei wachsenden Daten nicht langsamer — er wird schlechter. Genau deshalb greift die übliche Betriebsüberwachung nicht: Sie achtet auf Laufzeit und Abstürze, und beides bleibt unauffällig. Protokollieren Sie Status, Gap und Zeitausschöpfung bei jedem Lauf, oder Sie erfahren nie, wann Ihr Modell aufgehört hat zu optimieren.
-
22.11 Micro-Quiz
+
22.12 Micro-Quiz
❓ Micro-Quiz 22: Drei Fragen zum Selbstcheck
@@ -2061,7 +2496,7 @@ sichtbar wird, und gibt Zeit zum Handeln.
@@ -2071,12 +2506,18 @@ sichtbar wird, und gibt Zeit zum Handeln.
Warum ist ein MIP-Gap von 2 % in der Praxis meist ausreichend?
Was ist das Snapshot-Prinzip und wozu dient es?
Was ist der häufigste Grund, aus dem OR-Projekte scheitern?
+
Eine Bedingung bindet, ihr Schattenpreis ist 0. Was heißt das — und was folgt daraus für eine Nachverhandlung?
+
Warum kann die Rangfolge nach Schattenpreis eine andere sein als die Rangfolge nach tatsächlichem Nutzen einer Lockerung?
-
22.13 Zusammenfassung
+
22.14 Zusammenfassung
Infeasibility ist ein Entwurfsproblem, kein Solverproblem. Wer alles hart formuliert, erhält irgendwann eine Fehlermeldung statt eines Plans.
Erklärbarkeit entscheidet über Akzeptanz. Schattenpreise, Kostenzerlegung und Constraint-Traces sind keine Kür.
+
Bindend heißt nicht teuer. Eine Bedingung kann den Plan berühren und trotzdem 0,00 € kosten. Nur die kostenrelevanten lohnen eine Nachverhandlung.
+
Der Schattenpreis gilt für die nächste Einheit, nicht für die nächste Schicht. Hochgerechnet auf einen realistischen Schritt überschätzt er den Nutzen — im Beispiel um das Doppelte. Wer wissen will, was ein Hebel bringt, misst die Lockerung, statt zu multiplizieren; die beiden Ranglisten können sich dabei umdrehen.
+
Ein Wunsch kann unmöglich statt teuer sein — und dann ist die richtige Antwort nicht ein Preis, sondern die Liste der Bedingungen, die ihn gemeinsam blockieren.
+
Ein generierter Bericht macht ein Modell nicht richtiger, nur schwerer anzuzweifeln. Er erklärt das Modell, nicht die Wirklichkeit — deshalb gehört dazu, worauf er beruht.
Optimieren Sie nicht genauer als Ihre Daten. Ein Gap von 2 % ist bei ±10 % Datenunsicherheit irrelevant.
Der Optimierer schlägt vor, der Mensch entscheidet — jedenfalls anfangs.
Reproduzierbarkeit braucht Snapshots und Versionierung, nicht guten Willen.
diff --git a/OR_HTML_04/programme/Constraint_Attribution.py b/OR_HTML_04/programme/Constraint_Attribution.py
new file mode 100644
index 0000000..c5a9ab7
--- /dev/null
+++ b/OR_HTML_04/programme/Constraint_Attribution.py
@@ -0,0 +1,305 @@
+#!/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)
diff --git a/OR_HTML_04/stichwortverzeichnis.html b/OR_HTML_04/stichwortverzeichnis.html
index 66f726b..d015476 100644
--- a/OR_HTML_04/stichwortverzeichnis.html
+++ b/OR_HTML_04/stichwortverzeichnis.html
@@ -99,7 +99,7 @@
-
Stichwortverzeichnis
218 Fachbegriffe. Ein Klick führt zum jeweiligen Kapitel/Abschnitt (statt einer im Web bedeutungslosen Seitenzahl wie im gedruckten Register).
Worum geht es? Um die drei Fragen, die zwischen einem funktionierenden Modell und einem System stehen, auf das sich jemand verlässt: Woher weiß ich, dass es stimmt? Woher weiß ich, dass es schnell genug ist? Wie kommt es zu den Leuten, die es brauchen?
-
Voraussetzungen:Kapitel 22, insbesondere or_kern.py aus Abschnitt 22.5 — dieses Kapitel testet genau dieses Modul.
+
Voraussetzungen:Kapitel 22, insbesondere or_kern.py aus Abschnitt 22.6 — dieses Kapitel testet genau dieses Modul.
Danach können Sie: ein Optimierungsmodell testen, obwohl Sie die richtige Antwort nicht kennen; Ihre eigene Testsuite auf Lücken prüfen; einen Solververgleich aufsetzen, dem man glauben kann; und das Modell als Dienst betreiben, ohne dass die erste große Anfrage ihn blockiert.
Zeitbedarf: ca. 5 Stunden.
Programme:
@@ -1551,7 +1551,7 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
💻 Code-Durchgang: drei Entscheidungen
1. Das Domänenmodell ist das API-Schema. Der Parameter heißt problem: Produktionsproblem — mehr steht nicht da. FastAPI validiert die Anfrage damit gegen dieselben Pydantic-Regeln wie der Excel-Import aus Kapitel 1 und antwortet bei Unsinn mit 422, bevor ein Solver startet. Die drei abgewiesenen Anfragen im Selbsttest sind genau die drei Fälle, die or_kern.py schon beim Einlesen abfängt.
-
2. Die Antwort enthält 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 hält. Das Loesung-Objekt geht deshalb vollständig durch die Schnittstelle — dasselbe DTO, das in Abschnitt 22.6 schon eine Prozessgrenze überlebt hat.
+
2. Die Antwort enthält 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 hält. Das Loesung-Objekt geht deshalb vollständig durch die Schnittstelle — dasselbe DTO, das in Abschnitt 22.7 schon eine Prozessgrenze überlebt hat.
3. Die Abnahmeprüfung läuft auch hier.pruefe_loesung() steht im Arbeiter, nicht nur im Skript. Ein Dienst ohne sie liefert Fehler schneller aus, als ein Mensch sie finden könnte.
Threads oder Prozesse? Eine Messung, keine Meinung
diff --git a/OR_HTML_04/vorwort.html b/OR_HTML_04/vorwort.html
index 753f40f..497b758 100644
--- a/OR_HTML_04/vorwort.html
+++ b/OR_HTML_04/vorwort.html
@@ -714,41 +714,46 @@ Alles bereit — Sie können mit dem ersten Kapitel beginnen.
diff --git a/Operations_Research_mit_Python_Version_04.md b/Operations_Research_mit_Python_Version_04.md
index 2bbb60f..ed2d241 100644
--- a/Operations_Research_mit_Python_Version_04.md
+++ b/Operations_Research_mit_Python_Version_04.md
@@ -380,6 +380,7 @@ Alle Beispielprogramme dieses Buchs im Überblick, sortiert nach Kapitel — pra
| `Data_Snooping.py` | Bestes aus N Versuchen auf Rauschen | [Kapitel 21](#kap-handelsmaschine) |
| `Infeasibility_Diagnose.py` | Notfallplan statt Fehler | [Kapitel 22](#kap-praxisfallen) |
| `Erklaerbarkeit.py` | Constraint-Trace, Was-wäre-wenn | [Kapitel 22](#kap-praxisfallen) |
+| `Constraint_Attribution.py` | Was kostet welche Bedingung? Managementbericht | [Kapitel 22](#kap-praxisfallen) |
| `Betriebsueberwachung.py` | Status, Gap und Zeitausschöpfung | [Kapitel 22](#kap-praxisfallen) |
| `or_kern.py` | Gemeinsamer Unterbau: Domäne, Status, Prüfung | [Kapitel 22](#kap-praxisfallen) |
| `Solverwechsel_CPSAT_HiGHS.py` | Derselbe Fall in zwei Solvern | [Kapitel 22](#kap-praxisfallen) |
@@ -1408,7 +1409,7 @@ Das Ergebnis deckt sich mit der Handrechnung *Das Optimum zu Fuß finden* — ei
> **📎 Warum dieses Programm alles selbst macht**
>
-> Wenige Seiten weiter, in der Excel-Brücke, steht ein Programm, das mit 96 Zeilen auskommt, weil es die immer gleichen Arbeitsschritte aus dem gemeinsamen Modul `or_kern.py` bezieht ([Abschnitt 22.5](#sec:praxisfallen-or-kern)). Warum tut `Bot_Allokation.py` das nicht auch?
+> Wenige Seiten weiter, in der Excel-Brücke, steht ein Programm, das mit 96 Zeilen auskommt, weil es die immer gleichen Arbeitsschritte aus dem gemeinsamen Modul `or_kern.py` bezieht ([Abschnitt 22.6](#sec:praxisfallen-or-kern)). Warum tut `Bot_Allokation.py` das nicht auch?
>
> Weil es hier nicht darum geht, **wenig Code** zu schreiben, sondern darum zu sehen, wie ein Modell **entsteht**: Variablen, Nebenbedingungen, Zielfunktion, Status, Prüfung — jeder Schritt einzeln und sichtbar. Eine Abstraktion, die diese Schritte zusammenfasst, ist genau dann ein Gewinn, wenn man sie schon einmal von Hand gegangen ist; vorher versteckt sie nur, was man verstehen will.
>
@@ -1438,7 +1439,7 @@ Das folgende Programm zeigt den ganzen Weg an einem Stück. Es legt die Eingabem
> **📎 Zum Modul `or_kern.py`**
>
-> Die vier Arbeitsschritte — lesen, lösen, prüfen, schreiben — stehen nicht in diesem Programm, sondern in `or_kern.py`. Das ist ein gemeinsamer Unterbau, den alle Programme dieses Buchs benutzen können; er liegt im selben Verzeichnis und ist in [Abschnitt 22.5](#sec:praxisfallen-or-kern) vollständig abgedruckt und erklärt.
+> Die vier Arbeitsschritte — lesen, lösen, prüfen, schreiben — stehen nicht in diesem Programm, sondern in `or_kern.py`. Das ist ein gemeinsamer Unterbau, den alle Programme dieses Buchs benutzen können; er liegt im selben Verzeichnis und ist in [Abschnitt 22.6](#sec:praxisfallen-or-kern) vollständig abgedruckt und erklärt.
>
> Sie müssen ihn jetzt noch nicht verstehen. Wichtig ist nur die Aufteilung: **Hier** steht, was diesen Fall ausmacht — dort steht, was jedes Optimierungsprogramm braucht. Genau deshalb ist der Code unten so kurz.
@@ -6240,7 +6241,7 @@ $$
> **📎 Zwei Bausteine aus `or_kern.py`**
>
-> Das Programm wertet den Solver nicht über `ergebnis.success` aus, sondern über `status_von_scipy()` und das `Loesung`-Objekt aus dem gemeinsamen Unterbau ([Abschnitt 22.5](#sec:praxisfallen-or-kern)). Der Unterschied ist kein Schönheitsfehler:
+> Das Programm wertet den Solver nicht über `ergebnis.success` aus, sondern über `status_von_scipy()` und das `Loesung`-Objekt aus dem gemeinsamen Unterbau ([Abschnitt 22.6](#sec:praxisfallen-or-kern)). Der Unterschied ist kein Schönheitsfehler:
>
> * `success` ist **ein Bit**. Es unterscheidet nicht zwischen „es gibt keine Lösung“ (Modellfehler — das Modell muss geändert werden) und „die Zeit war um“ (Rechenproblem — mehr Zeit oder ein besserer Startwert hilft). Das sind zwei völlig verschiedene Nachrichten an völlig verschiedene Adressaten.
> * Das `Loesung`-Objekt führt neben dem Zielwert die **Schranke** mit und rechnet daraus den Gap aus. Erst dadurch steht in der Ausgabe `Gap: 0.00%` — die Zusage, dass hier wirklich das Optimum gefunden *und bewiesen* wurde und nicht bloß irgendetwas.
@@ -6491,7 +6492,7 @@ Abnahmepruefung: alle Nebenbedingungen geprueft und eingehalten.
> | `zeile[i]=1; zeile[N+i]=-MAX_POSITION` | Muster 1: $x_i - U y_i \le 0$. **$M = U = 40\,000$** — die kleinstmögliche gültige Wahl |
> | `zeile[i]=-1; zeile[N+i]=MIN_POSITION` | Muster 5 unten: $L y_i - x_i \le 0$ |
> | `integrality=concat([zeros(N), ones(N)])` | nur die $y$ sind ganzzahlig — das ist das „mixed“ in MILP |
-> | `status = status_von_scipy(ergebnis)` | übersetzt den SciPy-Rückgabewert in die gemeinsame Sprache aus [Abschnitt 22.5](#sec:praxisfallen-or-kern) — die drei `if`-Zweige darunter behandeln Modellfehler, Abbruch ohne Lösung und „zulässig, aber unbewiesen“ getrennt |
+> | `status = status_von_scipy(ergebnis)` | übersetzt den SciPy-Rückgabewert in die gemeinsame Sprache aus [Abschnitt 22.6](#sec:praxisfallen-or-kern) — die drei `if`-Zweige darunter behandeln Modellfehler, Abbruch ohne Lösung und „zulässig, aber unbewiesen“ getrennt |
> | `schranke=-ergebnis.mip_dual_bound` | die zweite Zahl aus [Abschnitt 6.8](#sec:milp-gap). Erst mit ihr kann das `Loesung`-Objekt den Gap ausrechnen — und der Bericht sagt `Gap: 0.00%`, also *beweisbar* optimal |
> | `pruefe_portfolio(...)` | prüft **jede** modellierte Regel einzeln nach und liefert eine Liste von Beanstandungen |
>
@@ -7895,7 +7896,7 @@ Suchstatistik: 113 Verzweigungen, 3 Konflikte, 0.006 s
> ```python
> status = status_von_cpsat(rohstatus)
> ```
-> Danach heißen die Fälle `status.modellfehler` (die Regeln widersprechen sich — das Modell muss geändert werden) und `status.brauchbar` (es liegt etwas Ausführbares vor). Das ist **dieselbe** Fallunterscheidung wie bei HiGHS in [Kapitel 6](#kap-milp) und bei GLOP in [Kapitel 5](#kap-lp); nur die eine Übersetzerzeile weiß, welcher Solver gerechnet hat. Wer den Auswertungscode direkt gegen `cp_model.INFEASIBLE` schreibt, kann ihn bei einem Solverwechsel wegwerfen — [Abschnitt 22.6](#sec:praxisfallen-solverwechsel) führt genau diesen Wechsel vor.
+> Danach heißen die Fälle `status.modellfehler` (die Regeln widersprechen sich — das Modell muss geändert werden) und `status.brauchbar` (es liegt etwas Ausführbares vor). Das ist **dieselbe** Fallunterscheidung wie bei HiGHS in [Kapitel 6](#kap-milp) und bei GLOP in [Kapitel 5](#kap-lp); nur die eine Übersetzerzeile weiß, welcher Solver gerechnet hat. Wer den Auswertungscode direkt gegen `cp_model.INFEASIBLE` schreibt, kann ihn bei einem Solverwechsel wegwerfen — [Abschnitt 22.7](#sec:praxisfallen-solverwechsel) führt genau diesen Wechsel vor.
> **⚠️ Typische Fehler**
>
@@ -15967,7 +15968,7 @@ Er hat drei Abschnitte, und Sie können nach jedem aufhören:
>
> **Worum geht es?** Um den Nachweis, dass die Verfahren aus Teil II und III unverändert in der Finanzwelt funktionieren — und um die drei Stellen, an denen die Analogie endet.
>
-> **Voraussetzungen:** [Kapitel 1](#kap-einfuehrung) (Allokation als LP), [Kapitel 12](#kap-unsicherheit) (Szenarien) und [Abschnitt 22.5](#sec:praxisfallen-or-kern) (`or_kern.py`).
+> **Voraussetzungen:** [Kapitel 1](#kap-einfuehrung) (Allokation als LP), [Kapitel 12](#kap-unsicherheit) (Szenarien) und [Abschnitt 22.6](#sec:praxisfallen-or-kern) (`or_kern.py`).
>
> **Danach können Sie:** ein betriebswirtschaftliches Modell in die andere Domäne übersetzen, ohne den Modellcode anzufassen; Schattenpreise in beiden Welten lesen; und benennen, welche Annahmen dabei **nicht** mitwandern.
>
@@ -20565,6 +20566,7 @@ von allen Fehlversuchen genau einen: den erfolgreichsten.
> **Programme:**\
> `Infeasibility_Diagnose.py`\
> `Erklaerbarkeit.py`\
+> `Constraint_Attribution.py`\
> `Betriebsueberwachung.py`\
> `or_kern.py`\
> `Solverwechsel_CPSAT_HiGHS.py`
@@ -20624,12 +20626,13 @@ Nach diesem Kapitel können Sie …
1. … die fünf typischen Praxisfallen benennen und je ein Gegenmittel nennen.
2. … ein unlösbares Modell in einen „am wenigsten schlechten“ Plan verwandeln.
3. … Solver-Ergebnisse erklärbar machen (*Explainable OR*).
-4. … Zeitlimits und Optimalitätslücken sinnvoll setzen.
-5. … die Architektur einer produktionsreifen OR-Plattform skizzieren.
-6. … eine **modellunabhängige Abnahmeprüfung** schreiben, die eine Lösung gegen die Anforderungen prüft statt gegen das Modell.
-7. … Status, Gap und Zeitausschöpfung protokollieren und begründen, warum eine konstante Laufzeit bei Zeitlimit ein Warnsignal ist.
-8. … ein Modell in Domänenschicht, Modellbauer und Lösungs-DTO trennen — und begründen, warum sich das nicht wegen des Solverwechsels lohnt, sondern wegen der Prüfbarkeit.
-9. … die Statuswerte verschiedener Solverbibliotheken auf eine gemeinsame Sprache abbilden.
+4. … für jede Nebenbedingung beziffern, was sie den Plan kostet — und begründen, warum der Schattenpreis diese Frage nur für die *nächste* Einheit beantwortet.
+5. … Zeitlimits und Optimalitätslücken sinnvoll setzen.
+6. … die Architektur einer produktionsreifen OR-Plattform skizzieren.
+7. … eine **modellunabhängige Abnahmeprüfung** schreiben, die eine Lösung gegen die Anforderungen prüft statt gegen das Modell.
+8. … Status, Gap und Zeitausschöpfung protokollieren und begründen, warum eine konstante Laufzeit bei Zeitlimit ein Warnsignal ist.
+9. … ein Modell in Domänenschicht, Modellbauer und Lösungs-DTO trennen — und begründen, warum sich das nicht wegen des Solverwechsels lohnt, sondern wegen der Prüfbarkeit.
+10. … die Statuswerte verschiedener Solverbibliotheken auf eine gemeinsame Sprache abbilden.
---
@@ -20890,6 +20893,8 @@ if __name__ == "__main__":
Der letzte Punkt ist der eigentliche Lerneffekt: Die Obergrenze schließt Ben zwar technisch aus, aber selbst wenn er dürfte, wäre er wegen seiner Vorbelastung immer noch teurer als Anna. **Eine bindende Bedingung zu lockern hilft nur, wenn sie tatsächlich die kostenrelevante ist** — sonst ist die Mühe umsonst.
+Derselbe Unterschied kehrt auf Modellebene wieder, und dort lässt er sich in Euro beziffern: [Abschnitt 22.4](#sec:praxisfallen-attribution) misst für jede Bedingung, was sie den ganzen Plan kostet — und baut daraus einen Bericht, den man vorlegen kann.
+
### Falle 3 — Regimewechsel und Schätzfehler
Ein Modell, das auf einer mehrjährigen Aufwärtsphase kalibriert wurde, kennt keine Liquiditätskrise und keine Zinswende.
@@ -20919,7 +20924,457 @@ Exakte Optimalität kann bei MILP oder CP-SAT Minuten bis Stunden dauern — und
---
-## 22.4 Architektur einer produktionsreifen OR-Plattform {#sec:praxisfallen-architektur-einer-produktionsreifen-or-plattform}
+## 22.4 Constraint Attribution: welche Bedingung kostet wie viel? {#sec:praxisfallen-attribution}
+
+Der Constraint-Trace beantwortet „warum **diese** Zuweisung?“. Der Deletion Filter in [Anhang C](#anhang-fehlerdiagnose) beantwortet „warum geht es **gar nicht**?“. Der häufigste Fall in der Praxis liegt dazwischen, und für ihn hat bisher niemand ein Werkzeug bekommen:
+
+> *Das Modell rechnet. Der Plan ist zulässig. Und trotzdem ist er enttäuschend — nur weiß niemand, woran es liegt.*
+
+Die Antwort heißt **Constraint Attribution**`\index{Constraint Attribution}`{=latex}: 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](#anhang-fehlerdiagnose), 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?“.
+
+```python
+#!/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?
+==============================================================================
+Status: Optimization terminated successfully.
+Deckungsbeitrag des Plans: 3,930.00 EUR
+
+ Rahmen: 47.5 Gehaeuse: 30.0 Deckel: 97.5 Traeger: 25.0 Halter: 0.0
+
+==============================================================================
+ (1) Welche Bedingungen binden - und was kostet die naechste Einheit?
+==============================================================================
+Bedingung genutzt Grenze Schlupf Schattenpreis
+------------------------------------------------------------------------------
+Kapazitaet Montage 400.0 400 0.0 9.00 <-- bindend
+Kapazitaet Lackieren 210.0 210 0.0 4.00 <-- bindend
+Kapazitaet Pruefung 200.0 300 100.0 0.00
+Liefervertrag Rahmen 47.5 40 7.5 0.00
+Liefervertrag Gehaeuse 30.0 30 0.0 2.00 <-- bindend
+Liefervertrag Deckel 97.5 20 77.5 0.00
+Liefervertrag Traeger 25.0 25 0.0 18.00 <-- bindend
+Marktgrenze Rahmen 47.5 120 72.5 0.00
+Marktgrenze Gehaeuse 30.0 90 60.0 0.00
+Marktgrenze Deckel 97.5 150 52.5 0.00
+Marktgrenze Halter 0.0 200 200.0 0.00
+Mindestumsatz 10800.0 10000 800.0 0.00
+Sortimentsbreite 122.5 100 22.5 0.00
+Lackierbudget Schicht 2 55.0 55 0.0 0.00 <-- bindend
+
+ 5 Bedingungen binden. Aber: Lackierbudget Schicht 2 bindet
+ mit einem Schattenpreis von 0,00 EUR - sie beruehrt den Plan, ohne ihn
+ zu verteuern. Bindend und kostenrelevant sind zwei verschiedene Dinge.
+
+==============================================================================
+ (2) Was der Schattenpreis verschweigt
+==============================================================================
+Der Schattenpreis gilt fuer die NAECHSTE Einheit. Ein Planer kauft aber
+keine Einheit, sondern eine ganze Schicht. Beides gegenuebergestellt:
+
+Stellschraube Preis Schritt hochgerechnet gemessen Abw.
+------------------------------------------------------------------------------
+Kapazitaet Montage 9.00 +50 450.00 450.00 +0.00
+Kapazitaet Lackieren 4.00 +30 120.00 60.00 -60.00
+Kapazitaet Pruefung 0.00 +50 0.00 0.00 +0.00
+Lackierbudget Schicht 2 0.00 +10 0.00 0.00 +0.00
+Liefervertrag Traeger 18.00 -10 180.00 180.00 +0.00
+Liefervertrag Gehaeuse 2.00 -10 20.00 18.00 -2.00
+
+ 'Kapazitaet Lackieren': hochgerechnet 120.00 EUR, tatsaechlich 60.00 EUR.
+ Der Preis von 4.00 EUR gilt nur bis +15 Stunden. Jede weitere Stunde
+ dieser Sonderschicht bringt exakt nichts - der Engpass ist dann ein anderer.
+
+ Und die Ranglisten drehen sich: Nach Schattenpreis fuehrt
+ 'Liefervertrag Traeger' (18.00 EUR je Einheit),
+ nach tatsaechlicher Wirkung 'Kapazitaet Montage' (450.00 EUR).
+ Der Schattenpreis sagt, was eine Einheit wert ist - nicht, wie viele
+ Einheiten zu haben sind.
+
+==============================================================================
+ (3) Was-waere-wenn: mindestens 55 Rahmen statt 47.5
+==============================================================================
+Der Wunsch ist nicht teuer - er ist UNMOEGLICH.
+
+Blockiert wird er von 3 Bedingungen gemeinsam. Faellt eine davon
+weg, ist er erfuellbar; keine allein ist 'der' Grund:
+ ohne 'Kapazitaet Lackieren': moeglich, Deckungsbeitrag 3,990.00 EUR
+ ohne 'Liefervertrag Gehaeuse': moeglich, Deckungsbeitrag 3,948.00 EUR
+ ohne 'Liefervertrag Traeger': moeglich, Deckungsbeitrag 4,320.00 EUR
+
+ Mit +15 bei 'Kapazitaet Lackieren' wird der Wunsch erfuellbar:
+ Deckungsbeitrag 3,990.00 EUR (+60.00 gegenueber heute).
+ Und ohne den Wunsch ergaebe dieselbe Lockerung 3,990.00 EUR bei
+ 55 Rahmen - der Wunsch kostet also NICHTS.
+ Er war nie das Problem. Der Engpass war es.
+
+==============================================================================
+ (4) Und so steht es in der Vorlage fuer die Abteilungsleiterrunde
+==============================================================================
+
+Der Plan bringt 3,930.00 EUR Deckungsbeitrag. Begrenzt wird er von 5
+Bedingungen, von denen 4 tatsaechlich Geld kosten; "Lackierbudget Schicht 2"
+beruehrt den Plan, ohne ihn zu verteuern.
+
+Teuerste Bindung ist "Liefervertrag Traeger" mit 18.00 EUR je Einheit,
+gefolgt von "Kapazitaet Montage" mit 9.00 EUR.
+
+Die groesste einzelne Verbesserung bringt eine Sonderschicht Montage: 450.00
+EUR mehr Deckungsbeitrag. Eine Sonderschicht Lackieren dagegen bringt nur
+60.00 EUR statt der rechnerischen 120.00 EUR - ab +15 Stunden begrenzt uns
+etwas anderes. Wer die ganze Schicht bezahlt, bezahlt die zweite Haelfte
+umsonst.
+
+Zum Wunsch des Vertriebs (55 Rahmen statt 47.5): Er ist mit der heutigen
+Ausstattung nicht erfuellbar - nicht aus Kostengruenden, sondern weil
+"Kapazitaet Lackieren", "Liefervertrag Gehaeuse", "Liefervertrag Traeger"
+ihn gemeinsam blockieren. Mit +15 Stunden bei "Kapazitaet Lackieren" wird er
+moeglich, und er kostet dann nichts: Der Plan waehlt diese Menge von sich
+aus.
+
+==============================================================================
+```
+
+### Drei Befunde, und keiner steht im Solverergebnis
+
+**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 — wer sie nachverhandelt, gewinnt nichts. Das ist derselbe Unterschied, den `Erklaerbarkeit.py` oben an einer einzelnen Zuweisung zeigt, hier im Dualwert eines LP.
+
+**Zweitens: Der Schattenpreis ist eine Momentaufnahme.** Er gilt für die **nächste** Einheit. Ein Planer kauft aber keine Einheit, sondern eine Schicht. Für die Lackiererei verspricht die Hochrechnung $4{,}00 \cdot 30 = 120$ €; gemessen kommen **60 €**. Der Preis von 4,00 € gilt exakt bis **+15 Stunden**, danach begrenzt etwas anderes — **die zweite Hälfte der Sonderschicht wäre bezahlt und wirkungslos.**
+
+Und die Ranglisten drehen sich dabei um: Nach Schattenpreis führt der Trägervertrag mit 18,00 € je Stück, nach tatsächlicher Wirkung die Montage-Sonderschicht mit 450 €. Der Schattenpreis sagt, was **eine** Einheit wert ist — nicht, wie viele Einheiten zu haben sind. Für die Frage „welchen Hebel ziehen wir?“ ist das der entscheidende Unterschied.
+
+**Drittens: Ein Wunsch kann unmöglich statt teuer sein.** Der Vertrieb hätte gern 55 Rahmen statt 47,5. Das Modell antwortet nicht mit einem Preis, sondern mit `INFEASIBLE` — und drei Bedingungen blockieren den Wunsch **gemeinsam**: Fällt eine davon weg, ist er erfüllbar, keine allein ist „der“ Grund. Genau die Struktur, die [Anhang C](#anhang-fehlerdiagnose) für den unlösbaren Fall beschreibt, hier auf einen Wunsch angewandt.
+
+Die Pointe steht am Ende: Mit +15 Stunden Lackierkapazität wird der Wunsch erfüllbar — und kostet dann **nichts**. Der Plan wählt diese Menge von sich aus, sobald der Engpass weg ist. Der Konflikt bestand nie zwischen Vertrieb und Produktion. Er bestand zwischen dem Vertrieb und der Lackiererei, und niemand wusste das.
+
+> **🎯 Die drei Fragen, jetzt auf Modellebene** Die Faustregel oben stellt sie für eine einzelne Zuweisung. Constraint Attribution stellt dieselben drei für den **ganzen Plan**:
+> 1. **Warum dieser Zielwert?** — welche Bedingungen binden, und welche davon kosten Geld.
+> 2. **Warum nicht besser?** — was eine realistische Lockerung wirklich bringt, gemessen statt hochgerechnet.
+> 3. **Was ist mit meinem Wunsch?** — erfüllbar und zu welchem Preis, oder unmöglich und warum.
+
+### Vom Zahlenwerk zur Vorlage
+
+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 weniger trivial, als es aussieht:
+
+* **Kein Satz enthält eine Zahl, die nicht vorher gerechnet wurde.** Auch nicht die Rangfolge, auch nicht die Namen der blockierenden Bedingungen. Sobald sich das Modell ändert, ändert sich der Bericht mit — er kann nicht leise falsch werden.
+* **Der sprechende Name jeder Bedingung ist die Voraussetzung dafür.** `A_ub[7]` ergibt keinen Satz. Deshalb tragen die Bedingungen in beiden Programmen einen Namen, und deshalb ist das keine Kosmetik.
+
+> **⚠️ Was ein solcher Bericht nicht leistet** Er erklärt das **Modell**, nicht die **Wirklichkeit**. „Liefervertrag Träger kostet 18 € je Stück“ heißt: *in diesem Modell, mit diesen Deckungsbeiträgen*. Ist ein Deckungsbeitrag falsch geschätzt, ist der Bericht überzeugend **und** falsch — die gefährlichste Kombination. Ein generierter Text macht ein Modell nicht richtiger, nur schwerer anzuzweifeln. Deshalb gehört zu jedem solchen Bericht die Angabe, worauf er beruht.
+
+---
+
+## 22.5 Architektur einer produktionsreifen OR-Plattform {#sec:praxisfallen-architektur-einer-produktionsreifen-or-plattform}

@@ -20946,7 +21401,7 @@ In einer professionellen Umgebung ist der Optimierer kein Skript, sondern ein **
---
-## 22.5 Der gemeinsame Unterbau: `or_kern.py` {#sec:praxisfallen-or-kern}
+## 22.6 Der gemeinsame Unterbau: `or_kern.py` {#sec:praxisfallen-or-kern}
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.
@@ -21550,7 +22005,7 @@ Domaenenschicht.
---
-## 22.6 Der Solverwechsel in der Praxis {#sec:praxisfallen-solverwechsel}
+## 22.7 Der Solverwechsel in der Praxis {#sec:praxisfallen-solverwechsel}
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 nicht glauben sollte, bevor sie jemand vorgeführt hat.
@@ -21966,7 +22421,7 @@ ein unzuverlaessiger Test (siehe JobShop_Intervalle.py).
---
-## 22.7 Eine Checkliste vor dem Produktivgang {#sec:praxisfallen-eine-checkliste-vor-dem-produktivgang}
+## 22.8 Eine Checkliste vor dem Produktivgang {#sec:praxisfallen-eine-checkliste-vor-dem-produktivgang}
Arbeiten Sie diese Liste ab, bevor ein OR-System in Betrieb geht:
@@ -21997,7 +22452,7 @@ Arbeiten Sie diese Liste ab, bevor ein OR-System in Betrieb geht:
---
-## 22.8 Weiterführende Literatur und Roadmap {#sec:praxisfallen-weiterfuehrende-literatur-und-roadmap}
+## 22.9 Weiterführende Literatur und Roadmap {#sec:praxisfallen-weiterfuehrende-literatur-und-roadmap}
**Grundlagen und lineare/ganzzahlige Optimierung**
* Bertsimas & Tsitsiklis: *Introduction to Linear Optimization* — Standardwerk zu Simplex, Dualität, Polyedertheorie.
@@ -22029,7 +22484,7 @@ Wenn Sie doch tiefer in die Methodik wollen, sind das die lohnendsten Richtungen
---
-## 22.9 Übungsaufgaben {#sec:praxisfallen-uebungsaufgaben}
+## 22.10 Übungsaufgaben {#sec:praxisfallen-uebungsaufgaben}
> Lösungen: [Abschnitt A.22](#sec:loesungen-praxisfallen).
@@ -22041,13 +22496,15 @@ Wenn Sie doch tiefer in die Methodik wollen, sind das die lohnendsten Richtungen
**Aufgabe 22.4 ⭐⭐ — Erklärbarkeit implementieren.** Erweitern Sie ein beliebiges Modell aus dem Kurs um einen `erklaere(loesung)`-Report, der die drei Fragen aus [Abschnitt 22.3](#sec:praxisfallen-die-fuenf-typischen-praxisfallen) beantwortet.
-**Aufgabe 22.5 ⭐⭐⭐ — Gap gegen Laufzeit.** Nehmen Sie das MILP-Portfolio aus [Kapitel 6](#kap-milp) und vergrößern Sie es auf 50 Anlagen. Messen Sie Laufzeit und Zielwert bei Gap-Vorgaben von 10 %, 5 %, 2 %, 1 %, 0,1 % und 0 %. Stellen Sie den Zusammenhang grafisch dar und leiten Sie eine Empfehlung ab.
+**Aufgabe 22.5 ⭐⭐ — Den Bericht in die Irre führen.** `Constraint_Attribution.py` erzeugt seinen Bericht aus gerechneten Zahlen — richtig ist er deshalb noch nicht. (a) Setzen Sie den Deckungsbeitrag von *Deckel* von 9 € auf 20 €. Wie ändern sich Plan, Rangliste und Bericht? Welche Sätze bleiben wörtlich stehen, obwohl sie jetzt etwas anderes bedeuten? (b) Der Bericht nennt „Liefervertrag Träger" als teuerste Bindung. Formulieren Sie den Satz so um, dass ein Leser erkennt, **worauf** die Aussage beruht. (c) Welche Angabe müsste das Programm zusätzlich ausgeben, damit ein Empfänger die Belastbarkeit selbst einschätzen kann?
-**Aufgabe 22.6 ⭐⭐⭐ — Post-Mortem schreiben.** Suchen Sie sich einen der im Buch besprochenen Fehler aus (z. B. die Vorzeichenfalle in [Abschnitt 5.7](#sec:lp-die-vorzeichenfalle-bei-schattenpreisen), die ungültige Kovarianzmatrix in [Kapitel 11](#kap-qp-nlp), die Sektor-Positionsindizes in [Kapitel 19](#kap-markowitz), die vermischten Einheiten in [Abschnitt 20.6](#sec:cvar-implementierung-cvar-portfolio-mit-reibung) oder die Rebalancing-Termine in [Abschnitt 21.4](#sec:handelsmaschine-rebalancing-termine-richtig-bestimmen)) und schreiben Sie ein einseitiges Post-Mortem: Was war der Fehler? Warum fiel er nicht auf? Welche Prüfung hätte ihn gefunden? Welche Regel leiten Sie daraus für eigene Projekte ab?
+**Aufgabe 22.6 ⭐⭐⭐ — Gap gegen Laufzeit.** Nehmen Sie das MILP-Portfolio aus [Kapitel 6](#kap-milp) und vergrößern Sie es auf 50 Anlagen. Messen Sie Laufzeit und Zielwert bei Gap-Vorgaben von 10 %, 5 %, 2 %, 1 %, 0,1 % und 0 %. Stellen Sie den Zusammenhang grafisch dar und leiten Sie eine Empfehlung ab.
+
+**Aufgabe 22.7 ⭐⭐⭐ — Post-Mortem schreiben.** Suchen Sie sich einen der im Buch besprochenen Fehler aus (z. B. die Vorzeichenfalle in [Abschnitt 5.7](#sec:lp-die-vorzeichenfalle-bei-schattenpreisen), die ungültige Kovarianzmatrix in [Kapitel 11](#kap-qp-nlp), die Sektor-Positionsindizes in [Kapitel 19](#kap-markowitz), die vermischten Einheiten in [Abschnitt 20.6](#sec:cvar-implementierung-cvar-portfolio-mit-reibung) oder die Rebalancing-Termine in [Abschnitt 21.4](#sec:handelsmaschine-rebalancing-termine-richtig-bestimmen)) und schreiben Sie ein einseitiges Post-Mortem: Was war der Fehler? Warum fiel er nicht auf? Welche Prüfung hätte ihn gefunden? Welche Regel leiten Sie daraus für eigene Projekte ab?
---
-## 22.10 Finde den Denkfehler {#sec:praxisfallen-denkfehler}
+## 22.11 Finde den Denkfehler {#sec:praxisfallen-denkfehler}
Falle 5 — die Laufzeitexplosion — zeigt sich im Betrieb selten als Absturz. Sie zeigt sich als **gar nichts**.
@@ -22323,7 +22780,7 @@ sichtbar wird, und gibt Zeit zum Handeln.
---
-## 22.11 Micro-Quiz {#sec:praxisfallen-quiz}
+## 22.12 Micro-Quiz {#sec:praxisfallen-quiz}
> **❓ Micro-Quiz 22: Drei Fragen zum Selbstcheck**
>
@@ -22337,7 +22794,7 @@ sichtbar wird, und gibt Zeit zum Handeln.
---
-## 22.12 Selbsttest {#sec:praxisfallen-selbsttest}
+## 22.13 Selbsttest {#sec:praxisfallen-selbsttest}
> Antworten: [Anhang A](#selbsttest-loesung-praxisfallen).
@@ -22346,13 +22803,19 @@ sichtbar wird, und gibt Zeit zum Handeln.
3. Warum ist ein MIP-Gap von 2 % in der Praxis meist ausreichend?
4. Was ist das Snapshot-Prinzip und wozu dient es?
5. Was ist der häufigste Grund, aus dem OR-Projekte scheitern?
+6. Eine Bedingung bindet, ihr Schattenpreis ist 0. Was heißt das — und was folgt daraus für eine Nachverhandlung?
+7. Warum kann die Rangfolge nach Schattenpreis eine andere sein als die Rangfolge nach tatsächlichem Nutzen einer Lockerung?
---
-## 22.13 Zusammenfassung {#sec:praxisfallen-zusammenfassung}
+## 22.14 Zusammenfassung {#sec:praxisfallen-zusammenfassung}
* **Infeasibility ist ein Entwurfsproblem, kein Solverproblem.** Wer alles hart formuliert, erhält irgendwann eine Fehlermeldung statt eines Plans.
* **Erklärbarkeit entscheidet über Akzeptanz.** Schattenpreise, Kostenzerlegung und Constraint-Traces sind keine Kür.
+* **Bindend heißt nicht teuer.** Eine Bedingung kann den Plan berühren und trotzdem 0,00 € kosten. Nur die kostenrelevanten lohnen eine Nachverhandlung.
+* **Der Schattenpreis gilt für die nächste Einheit, nicht für die nächste Schicht.** Hochgerechnet auf einen realistischen Schritt überschätzt er den Nutzen — im Beispiel um das Doppelte. Wer wissen will, was ein Hebel bringt, **misst** die Lockerung, statt zu multiplizieren; die beiden Ranglisten können sich dabei umdrehen.
+* **Ein Wunsch kann unmöglich statt teuer sein** — und dann ist die richtige Antwort nicht ein Preis, sondern die Liste der Bedingungen, die ihn gemeinsam blockieren.
+* **Ein generierter Bericht macht ein Modell nicht richtiger, nur schwerer anzuzweifeln.** Er erklärt das Modell, nicht die Wirklichkeit — deshalb gehört dazu, worauf er beruht.
* **Optimieren Sie nicht genauer als Ihre Daten.** Ein Gap von 2 % ist bei ±10 % Datenunsicherheit irrelevant.
* **Der Optimierer schlägt vor, der Mensch entscheidet** — jedenfalls anfangs.
* **Reproduzierbarkeit braucht Snapshots und Versionierung**, nicht guten Willen.
@@ -22370,7 +22833,7 @@ sichtbar wird, und gibt Zeit zum Handeln.
>
> **Worum geht es?** Um die drei Fragen, die zwischen einem funktionierenden Modell und einem System stehen, auf das sich jemand verlässt: *Woher weiß ich, dass es stimmt? Woher weiß ich, dass es schnell genug ist? Wie kommt es zu den Leuten, die es brauchen?*
>
-> **Voraussetzungen:** [Kapitel 22](#kap-praxisfallen), insbesondere `or_kern.py` aus [Abschnitt 22.5](#sec:praxisfallen-or-kern) — dieses Kapitel testet genau dieses Modul.
+> **Voraussetzungen:** [Kapitel 22](#kap-praxisfallen), insbesondere `or_kern.py` aus [Abschnitt 22.6](#sec:praxisfallen-or-kern) — dieses Kapitel testet genau dieses Modul.
>
> **Danach können Sie:** ein Optimierungsmodell testen, obwohl Sie die richtige Antwort nicht kennen; Ihre eigene Testsuite auf Lücken prüfen; einen Solververgleich aufsetzen, dem man glauben kann; und das Modell als Dienst betreiben, ohne dass die erste große Anfrage ihn blockiert.
>
@@ -23783,7 +24246,7 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
>
> **1. Das Domänenmodell ist das API-Schema.** Der Parameter heißt `problem: Produktionsproblem` — mehr steht nicht da. FastAPI validiert die Anfrage damit gegen dieselben Pydantic-Regeln wie der Excel-Import aus [Kapitel 1](#kap-einfuehrung) und antwortet bei Unsinn mit `422`, **bevor** ein Solver startet. Die drei abgewiesenen Anfragen im Selbsttest sind genau die drei Fälle, die `or_kern.py` schon beim Einlesen abfängt.
>
-> **2. Die Antwort enthält 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 hält. Das `Loesung`-Objekt geht deshalb vollständig durch die Schnittstelle — dasselbe DTO, das in [Abschnitt 22.6](#sec:praxisfallen-solverwechsel) schon eine Prozessgrenze überlebt hat.
+> **2. Die Antwort enthält 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 hält. Das `Loesung`-Objekt geht deshalb vollständig durch die Schnittstelle — dasselbe DTO, das in [Abschnitt 22.7](#sec:praxisfallen-solverwechsel) schon eine Prozessgrenze überlebt hat.
>
> **3. Die Abnahmeprüfung läuft auch hier.** `pruefe_loesung()` steht im Arbeiter, nicht nur im Skript. Ein Dienst ohne sie liefert Fehler schneller aus, als ein Mensch sie finden könnte.
@@ -25621,9 +26084,11 @@ Ein zweiter Blick auf denselben Testzeitraum ist deshalb kein „nochmal prüfen
**22.4 — Erklärbarkeit.** Report-Struktur: (1) Zielwert und Aufschlüsselung nach Bestandteilen; (2) je Entscheidung die bindenden Bedingungen; (3) Schattenpreise der knappsten Ressourcen mit Handlungsempfehlung.
-**22.5 — Gap gegen Laufzeit.** Erwartetes Muster: Von 10 % auf 2 % kostet wenig Zeit; von 1 % auf 0 % kann die Laufzeit um Größenordnungen steigen, ohne dass sich der Zielwert nennenswert verbessert. Empfehlung: Gap dort ansetzen, wo die Kurve knickt — typischerweise 1–2 %.
+**22.5 — Den Bericht in die Irre führen.** (a) Der Plan kippt: *Rahmen* fällt von 47,5 auf **40** — das Minimum aus dem Liefervertrag —, *Deckel* steigt von 97,5 auf **120**, die Marktgrenze. Der Deckungsbeitrag springt von 3 930 € auf **5 190 €**. Mit ihm wechselt die Engpassstruktur: „Kapazität Lackieren" bindet **nicht mehr**, dafür bindet jetzt „Liefervertrag Rahmen" — und zwar als *Kosten* von 25 € je Stück, weil der Vertrag zur Herstellung eines inzwischen unattraktiven Produkts zwingt. Alle Schattenpreise steigen deutlich (Träger von 18 € auf 58 €). Der entscheidende Punkt der Aufgabe: Der Satz *„Teuerste Bindung ist Liefervertrag Träger"* steht **wörtlich unverändert** im Bericht — obwohl die Zahl dahinter sich mehr als verdreifacht hat und ein anderer Engpass die Fabrik begrenzt. Ein Bericht, der sich nicht ändert, ist kein Beweis dafür, dass sich nichts geändert hat. (b) Etwa: *„Unter der Annahme eines Deckungsbeitrags von 9 € je Deckel ist ‚Liefervertrag Träger' die teuerste Bindung (18 € je Stück)."* Die Aussage bekommt damit ihre Voraussetzung mit — und wird angreifbar, was sie sein soll. (c) Mindestens eines von beidem: den **Gültigkeitsbereich** jedes Schattenpreises (bis wohin gilt er?), oder das Ergebnis einer **Sensitivitätsrechnung** über die unsichersten Eingangsgrößen — etwa „bei ±20 % Deckungsbeitrag Deckel bleibt die Rangfolge/kippt sie". Das Programm rechnet den Gültigkeitsbereich für die auffälligste Stellschraube bereits aus; ihn für alle auszuweisen ist eine kleine Erweiterung.
-**22.6 — Post-Mortem.** Bewertungskriterien: Wird zwischen **Symptom**, **Ursache** und **fehlender Prüfung** unterschieden? Ist die abgeleitete Regel allgemein genug, um beim nächsten Projekt zu helfen (z. B. „Nach jeder Vorzeichenumkehr eine numerische Gegenprobe“), aber konkret genug, um überprüfbar zu sein?
+**22.6 — Gap gegen Laufzeit.** Erwartetes Muster: Von 10 % auf 2 % kostet wenig Zeit; von 1 % auf 0 % kann die Laufzeit um Größenordnungen steigen, ohne dass sich der Zielwert nennenswert verbessert. Empfehlung: Gap dort ansetzen, wo die Kurve knickt — typischerweise 1–2 %.
+
+**22.7 — Post-Mortem.** Bewertungskriterien: Wird zwischen **Symptom**, **Ursache** und **fehlender Prüfung** unterschieden? Ist die abgeleitete Regel allgemein genug, um beim nächsten Projekt zu helfen (z. B. „Nach jeder Vorzeichenumkehr eine numerische Gegenprobe“), aber konkret genug, um überprüfbar zu sein?
### Finde den Denkfehler — Das Modell, das seit einem Jahr nicht mehr optimiert
@@ -25669,6 +26134,8 @@ Das ist das eigentliche Argument für die Überwachung: Nicht, dass ein Gap von
3. Weil die Datenunsicherheit (oft ±10 %) die verbleibende Optimalitätslücke bei Weitem übersteigt.
4. Jeder Lauf arbeitet auf einem unveränderlichen, mit ID versehenen Datenstand — nur so sind Ergebnisse reproduzierbar und belegbar.
5. Die **Einführung**: mangelnde Akzeptanz, weil Ergebnisse nicht nachvollziehbar sind.
+6. Sie berührt den Plan, ohne ihn zu verteuern — die Lösung liegt genau auf ihrer Grenze, wäre aber auch ohne sie dieselbe (Entartung). Für eine Nachverhandlung folgt: **nichts tun.** Der Aufwand brächte 0 €. „Bindend" ist ein geometrischer, „teuer" ein wirtschaftlicher Befund.
+7. Weil der Schattenpreis nur für die **nächste** Einheit gilt und sein Gültigkeitsbereich endet, sobald ein anderer Engpass bindend wird. Realistische Lockerungen sind unterschiedlich groß — eine Sonderschicht bringt 50 Einheiten, ein nachverhandelter Vertrag 10. Der Preis je Einheit sagt daher nichts darüber, welcher Hebel insgesamt am meisten bringt; dafür muss man die Lockerung rechnen, nicht den Preis multiplizieren.
---
diff --git a/Operations_Research_mit_Python_Version_04.pdf b/Operations_Research_mit_Python_Version_04.pdf
index fbf9d44..4e50397 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 aabc7f4..f5eb016 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
@@ -470,6 +470,7 @@ Pfad E oder um gezielt nach einem Thema zu suchen:
| `Data_Snooping.py` | Bestes aus N Versuchen auf Rauschen | {ref:kap:handelsmaschine} |
| `Infeasibility_Diagnose.py` | Notfallplan statt Fehler | {ref:kap:praxisfallen} |
| `Erklaerbarkeit.py` | Constraint-Trace, Was-wäre-wenn | {ref:kap:praxisfallen} |
+| `Constraint_Attribution.py` | Was kostet welche Bedingung? Managementbericht | {ref:kap:praxisfallen} |
| `Betriebsueberwachung.py` | Status, Gap und Zeitausschöpfung | {ref:kap:praxisfallen} |
| `or_kern.py` | Gemeinsamer Unterbau: Domäne, Status, Prüfung | {ref:kap:praxisfallen} |
| `Solverwechsel_CPSAT_HiGHS.py` | Derselbe Fall in zwei Solvern | {ref:kap:praxisfallen} |
diff --git a/Operations_Research_mit_Python_Version_04/50_Praxis.md b/Operations_Research_mit_Python_Version_04/50_Praxis.md
index a7acbca..c35cb78 100644
--- a/Operations_Research_mit_Python_Version_04/50_Praxis.md
+++ b/Operations_Research_mit_Python_Version_04/50_Praxis.md
@@ -20,6 +20,7 @@
> **Programme:**\
> `Infeasibility_Diagnose.py`\
> `Erklaerbarkeit.py`\
+> `Constraint_Attribution.py`\
> `Betriebsueberwachung.py`\
> `or_kern.py`\
> `Solverwechsel_CPSAT_HiGHS.py`
@@ -89,15 +90,17 @@ Nach diesem Kapitel können Sie …
1. … die fünf typischen Praxisfallen benennen und je ein Gegenmittel nennen.
2. … ein unlösbares Modell in einen „am wenigsten schlechten“ Plan verwandeln.
3. … Solver-Ergebnisse erklärbar machen (*Explainable OR*).
-4. … Zeitlimits und Optimalitätslücken sinnvoll setzen.
-5. … die Architektur einer produktionsreifen OR-Plattform skizzieren.
-6. … eine **modellunabhängige Abnahmeprüfung** schreiben, die eine Lösung gegen die
+4. … für jede Nebenbedingung beziffern, was sie den Plan kostet — und begründen, warum
+ der Schattenpreis diese Frage nur für die *nächste* Einheit beantwortet.
+5. … Zeitlimits und Optimalitätslücken sinnvoll setzen.
+6. … die Architektur einer produktionsreifen OR-Plattform skizzieren.
+7. … eine **modellunabhängige Abnahmeprüfung** schreiben, die eine Lösung gegen die
Anforderungen prüft statt gegen das Modell.
-7. … Status, Gap und Zeitausschöpfung protokollieren und begründen, warum eine konstante
+8. … Status, Gap und Zeitausschöpfung protokollieren und begründen, warum eine konstante
Laufzeit bei Zeitlimit ein Warnsignal ist.
-8. … ein Modell in Domänenschicht, Modellbauer und Lösungs-DTO trennen — und begründen,
+9. … ein Modell in Domänenschicht, Modellbauer und Lösungs-DTO trennen — und begründen,
warum sich das nicht wegen des Solverwechsels lohnt, sondern wegen der Prüfbarkeit.
-9. … die Statuswerte verschiedener Solverbibliotheken auf eine gemeinsame Sprache
+10. … die Statuswerte verschiedener Solverbibliotheken auf eine gemeinsame Sprache
abbilden.
---
@@ -373,6 +376,10 @@ technisch aus, aber selbst wenn er dürfte, wäre er wegen seiner Vorbelastung i
teurer als Anna. **Eine bindende Bedingung zu lockern hilft nur, wenn sie tatsächlich die
kostenrelevante ist** — sonst ist die Mühe umsonst.
+Derselbe Unterschied kehrt auf Modellebene wieder, und dort lässt er sich in Euro
+beziffern: {ref:sec:praxisfallen-attribution} misst für jede Bedingung, was sie den
+ganzen Plan kostet — und baut daraus einen Bericht, den man vorlegen kann.
+
### Falle 3 — Regimewechsel und Schätzfehler
Ein Modell, das auf einer mehrjährigen Aufwärtsphase kalibriert wurde, kennt keine
@@ -415,6 +422,500 @@ wächst nicht linear mit der Problemgröße.
---
+## Constraint Attribution: welche Bedingung kostet wie viel? {#sec:praxisfallen-attribution}
+
+Der Constraint-Trace beantwortet „warum **diese** Zuweisung?“. Der Deletion Filter in
+{ref:anhang:fehlerdiagnose} beantwortet „warum geht es **gar nicht**?“. Der häufigste Fall
+in der Praxis liegt dazwischen, und für ihn hat bisher niemand ein Werkzeug bekommen:
+
+> *Das Modell rechnet. Der Plan ist zulässig. Und trotzdem ist er enttäuschend — nur weiß
+> niemand, woran es liegt.*
+
+Die Antwort heißt **Constraint Attribution**{idx: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 {ref:anhang:fehlerdiagnose},
+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?“.
+
+```python
+#!/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?
+==============================================================================
+Status: Optimization terminated successfully.
+Deckungsbeitrag des Plans: 3,930.00 EUR
+
+ Rahmen: 47.5 Gehaeuse: 30.0 Deckel: 97.5 Traeger: 25.0 Halter: 0.0
+
+==============================================================================
+ (1) Welche Bedingungen binden - und was kostet die naechste Einheit?
+==============================================================================
+Bedingung genutzt Grenze Schlupf Schattenpreis
+------------------------------------------------------------------------------
+Kapazitaet Montage 400.0 400 0.0 9.00 <-- bindend
+Kapazitaet Lackieren 210.0 210 0.0 4.00 <-- bindend
+Kapazitaet Pruefung 200.0 300 100.0 0.00
+Liefervertrag Rahmen 47.5 40 7.5 0.00
+Liefervertrag Gehaeuse 30.0 30 0.0 2.00 <-- bindend
+Liefervertrag Deckel 97.5 20 77.5 0.00
+Liefervertrag Traeger 25.0 25 0.0 18.00 <-- bindend
+Marktgrenze Rahmen 47.5 120 72.5 0.00
+Marktgrenze Gehaeuse 30.0 90 60.0 0.00
+Marktgrenze Deckel 97.5 150 52.5 0.00
+Marktgrenze Halter 0.0 200 200.0 0.00
+Mindestumsatz 10800.0 10000 800.0 0.00
+Sortimentsbreite 122.5 100 22.5 0.00
+Lackierbudget Schicht 2 55.0 55 0.0 0.00 <-- bindend
+
+ 5 Bedingungen binden. Aber: Lackierbudget Schicht 2 bindet
+ mit einem Schattenpreis von 0,00 EUR - sie beruehrt den Plan, ohne ihn
+ zu verteuern. Bindend und kostenrelevant sind zwei verschiedene Dinge.
+
+==============================================================================
+ (2) Was der Schattenpreis verschweigt
+==============================================================================
+Der Schattenpreis gilt fuer die NAECHSTE Einheit. Ein Planer kauft aber
+keine Einheit, sondern eine ganze Schicht. Beides gegenuebergestellt:
+
+Stellschraube Preis Schritt hochgerechnet gemessen Abw.
+------------------------------------------------------------------------------
+Kapazitaet Montage 9.00 +50 450.00 450.00 +0.00
+Kapazitaet Lackieren 4.00 +30 120.00 60.00 -60.00
+Kapazitaet Pruefung 0.00 +50 0.00 0.00 +0.00
+Lackierbudget Schicht 2 0.00 +10 0.00 0.00 +0.00
+Liefervertrag Traeger 18.00 -10 180.00 180.00 +0.00
+Liefervertrag Gehaeuse 2.00 -10 20.00 18.00 -2.00
+
+ 'Kapazitaet Lackieren': hochgerechnet 120.00 EUR, tatsaechlich 60.00 EUR.
+ Der Preis von 4.00 EUR gilt nur bis +15 Stunden. Jede weitere Stunde
+ dieser Sonderschicht bringt exakt nichts - der Engpass ist dann ein anderer.
+
+ Und die Ranglisten drehen sich: Nach Schattenpreis fuehrt
+ 'Liefervertrag Traeger' (18.00 EUR je Einheit),
+ nach tatsaechlicher Wirkung 'Kapazitaet Montage' (450.00 EUR).
+ Der Schattenpreis sagt, was eine Einheit wert ist - nicht, wie viele
+ Einheiten zu haben sind.
+
+==============================================================================
+ (3) Was-waere-wenn: mindestens 55 Rahmen statt 47.5
+==============================================================================
+Der Wunsch ist nicht teuer - er ist UNMOEGLICH.
+
+Blockiert wird er von 3 Bedingungen gemeinsam. Faellt eine davon
+weg, ist er erfuellbar; keine allein ist 'der' Grund:
+ ohne 'Kapazitaet Lackieren': moeglich, Deckungsbeitrag 3,990.00 EUR
+ ohne 'Liefervertrag Gehaeuse': moeglich, Deckungsbeitrag 3,948.00 EUR
+ ohne 'Liefervertrag Traeger': moeglich, Deckungsbeitrag 4,320.00 EUR
+
+ Mit +15 bei 'Kapazitaet Lackieren' wird der Wunsch erfuellbar:
+ Deckungsbeitrag 3,990.00 EUR (+60.00 gegenueber heute).
+ Und ohne den Wunsch ergaebe dieselbe Lockerung 3,990.00 EUR bei
+ 55 Rahmen - der Wunsch kostet also NICHTS.
+ Er war nie das Problem. Der Engpass war es.
+
+==============================================================================
+ (4) Und so steht es in der Vorlage fuer die Abteilungsleiterrunde
+==============================================================================
+
+Der Plan bringt 3,930.00 EUR Deckungsbeitrag. Begrenzt wird er von 5
+Bedingungen, von denen 4 tatsaechlich Geld kosten; "Lackierbudget Schicht 2"
+beruehrt den Plan, ohne ihn zu verteuern.
+
+Teuerste Bindung ist "Liefervertrag Traeger" mit 18.00 EUR je Einheit,
+gefolgt von "Kapazitaet Montage" mit 9.00 EUR.
+
+Die groesste einzelne Verbesserung bringt eine Sonderschicht Montage: 450.00
+EUR mehr Deckungsbeitrag. Eine Sonderschicht Lackieren dagegen bringt nur
+60.00 EUR statt der rechnerischen 120.00 EUR - ab +15 Stunden begrenzt uns
+etwas anderes. Wer die ganze Schicht bezahlt, bezahlt die zweite Haelfte
+umsonst.
+
+Zum Wunsch des Vertriebs (55 Rahmen statt 47.5): Er ist mit der heutigen
+Ausstattung nicht erfuellbar - nicht aus Kostengruenden, sondern weil
+"Kapazitaet Lackieren", "Liefervertrag Gehaeuse", "Liefervertrag Traeger"
+ihn gemeinsam blockieren. Mit +15 Stunden bei "Kapazitaet Lackieren" wird er
+moeglich, und er kostet dann nichts: Der Plan waehlt diese Menge von sich
+aus.
+
+==============================================================================
+```
+
+### Drei Befunde, und keiner steht im Solverergebnis
+
+**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 — wer sie nachverhandelt, gewinnt nichts. Das ist derselbe Unterschied, den
+`Erklaerbarkeit.py` oben an einer einzelnen Zuweisung zeigt, hier im Dualwert eines LP.
+
+**Zweitens: Der Schattenpreis ist eine Momentaufnahme.** Er gilt für die **nächste**
+Einheit. Ein Planer kauft aber keine Einheit, sondern eine Schicht. Für die Lackiererei
+verspricht die Hochrechnung $4{,}00 \cdot 30 = 120$ €; gemessen kommen **60 €**. Der Preis
+von 4,00 € gilt exakt bis **+15 Stunden**, danach begrenzt etwas anderes — **die zweite
+Hälfte der Sonderschicht wäre bezahlt und wirkungslos.**
+
+Und die Ranglisten drehen sich dabei um: Nach Schattenpreis führt der Trägervertrag mit
+18,00 € je Stück, nach tatsächlicher Wirkung die Montage-Sonderschicht mit 450 €. Der
+Schattenpreis sagt, was **eine** Einheit wert ist — nicht, wie viele Einheiten zu haben
+sind. Für die Frage „welchen Hebel ziehen wir?“ ist das der entscheidende Unterschied.
+
+**Drittens: Ein Wunsch kann unmöglich statt teuer sein.** Der Vertrieb hätte gern
+55 Rahmen statt 47,5. Das Modell antwortet nicht mit einem Preis, sondern mit
+`INFEASIBLE` — und drei Bedingungen blockieren den Wunsch **gemeinsam**: Fällt eine davon
+weg, ist er erfüllbar, keine allein ist „der“ Grund. Genau die Struktur, die
+{ref:anhang:fehlerdiagnose} für den unlösbaren Fall beschreibt, hier auf einen Wunsch
+angewandt.
+
+Die Pointe steht am Ende: Mit +15 Stunden Lackierkapazität wird der Wunsch erfüllbar — und
+kostet dann **nichts**. Der Plan wählt diese Menge von sich aus, sobald der Engpass weg ist.
+Der Konflikt bestand nie zwischen Vertrieb und Produktion. Er bestand zwischen dem Vertrieb
+und der Lackiererei, und niemand wusste das.
+
+> **🎯 Die drei Fragen, jetzt auf Modellebene**
+> Die Faustregel oben stellt sie für eine einzelne Zuweisung. Constraint Attribution stellt
+> dieselben drei für den **ganzen Plan**:
+> 1. **Warum dieser Zielwert?** — welche Bedingungen binden, und welche davon kosten Geld.
+> 2. **Warum nicht besser?** — was eine realistische Lockerung wirklich bringt, gemessen
+> statt hochgerechnet.
+> 3. **Was ist mit meinem Wunsch?** — erfüllbar und zu welchem Preis, oder unmöglich und
+> warum.
+
+### Vom Zahlenwerk zur Vorlage
+
+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 weniger trivial, als es aussieht:
+
+* **Kein Satz enthält eine Zahl, die nicht vorher gerechnet wurde.** Auch nicht die
+ Rangfolge, auch nicht die Namen der blockierenden Bedingungen. Sobald sich das Modell
+ ändert, ändert sich der Bericht mit — er kann nicht leise falsch werden.
+* **Der sprechende Name jeder Bedingung ist die Voraussetzung dafür.** `A_ub[7]` ergibt
+ keinen Satz. Deshalb tragen die Bedingungen in beiden Programmen einen Namen, und deshalb
+ ist das keine Kosmetik.
+
+> **⚠️ Was ein solcher Bericht nicht leistet**
+> Er erklärt das **Modell**, nicht die **Wirklichkeit**. „Liefervertrag Träger kostet 18 €
+> je Stück“ heißt: *in diesem Modell, mit diesen Deckungsbeiträgen*. Ist ein
+> Deckungsbeitrag falsch geschätzt, ist der Bericht überzeugend **und** falsch — die
+> gefährlichste Kombination. Ein generierter Text macht ein Modell nicht richtiger, nur
+> schwerer anzuzweifeln. Deshalb gehört zu jedem solchen Bericht die Angabe, worauf er
+> beruht.
+
+---
+
## Architektur einer produktionsreifen OR-Plattform {#sec:praxisfallen-architektur-einer-produktionsreifen-or-plattform}

@@ -1615,6 +2116,17 @@ Sie mit einem bewusst überlasteten Szenario.
Erweitern Sie ein beliebiges Modell aus dem Kurs um einen `erklaere(loesung)`-Report, der
die drei Fragen aus {ref:sec:praxisfallen-die-fuenf-typischen-praxisfallen} beantwortet.
+**Aufgabe ⭐⭐ — Den Bericht in die Irre führen.**
+`Constraint_Attribution.py` erzeugt seinen Bericht aus gerechneten Zahlen — richtig ist er
+deshalb noch nicht.
+(a) Setzen Sie den Deckungsbeitrag von *Deckel* von 9 € auf 20 €. Wie ändern sich Plan,
+ Rangliste und Bericht? Welche Sätze bleiben wörtlich stehen, obwohl sie jetzt etwas
+ anderes bedeuten?
+(b) Der Bericht nennt „Liefervertrag Träger" als teuerste Bindung. Formulieren Sie den
+ Satz so um, dass ein Leser erkennt, **worauf** die Aussage beruht.
+(c) Welche Angabe müsste das Programm zusätzlich ausgeben, damit ein Empfänger die
+ Belastbarkeit selbst einschätzen kann?
+
**Aufgabe ⭐⭐⭐ — Gap gegen Laufzeit.**
Nehmen Sie das MILP-Portfolio aus {ref:kap:milp} und vergrößern Sie es auf 50 Anlagen. Messen
Sie Laufzeit und Zielwert bei Gap-Vorgaben von 10 %, 5 %, 2 %, 1 %, 0,1 % und 0 %.
@@ -1962,6 +2474,10 @@ sichtbar wird, und gibt Zeit zum Handeln.
3. Warum ist ein MIP-Gap von 2 % in der Praxis meist ausreichend?
4. Was ist das Snapshot-Prinzip und wozu dient es?
5. Was ist der häufigste Grund, aus dem OR-Projekte scheitern?
+6. Eine Bedingung bindet, ihr Schattenpreis ist 0. Was heißt das — und was folgt daraus für
+ eine Nachverhandlung?
+7. Warum kann die Rangfolge nach Schattenpreis eine andere sein als die Rangfolge nach
+ tatsächlichem Nutzen einer Lockerung?
---
@@ -1971,6 +2487,16 @@ sichtbar wird, und gibt Zeit zum Handeln.
erhält irgendwann eine Fehlermeldung statt eines Plans.
* **Erklärbarkeit entscheidet über Akzeptanz.** Schattenpreise, Kostenzerlegung und
Constraint-Traces sind keine Kür.
+* **Bindend heißt nicht teuer.** Eine Bedingung kann den Plan berühren und trotzdem
+ 0,00 € kosten. Nur die kostenrelevanten lohnen eine Nachverhandlung.
+* **Der Schattenpreis gilt für die nächste Einheit, nicht für die nächste Schicht.**
+ Hochgerechnet auf einen realistischen Schritt überschätzt er den Nutzen — im Beispiel um
+ das Doppelte. Wer wissen will, was ein Hebel bringt, **misst** die Lockerung, statt zu
+ multiplizieren; die beiden Ranglisten können sich dabei umdrehen.
+* **Ein Wunsch kann unmöglich statt teuer sein** — und dann ist die richtige Antwort nicht
+ ein Preis, sondern die Liste der Bedingungen, die ihn gemeinsam blockieren.
+* **Ein generierter Bericht macht ein Modell nicht richtiger, nur schwerer anzuzweifeln.**
+ Er erklärt das Modell, nicht die Wirklichkeit — deshalb gehört dazu, worauf er beruht.
* **Optimieren Sie nicht genauer als Ihre Daten.** Ein Gap von 2 % ist bei ±10 %
Datenunsicherheit irrelevant.
* **Der Optimierer schlägt vor, der Mensch entscheidet** — jedenfalls anfangs.
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 8be17ce..c08de8e 100644
--- a/Operations_Research_mit_Python_Version_04/90_Anhang_Loesungen.md
+++ b/Operations_Research_mit_Python_Version_04/90_Anhang_Loesungen.md
@@ -2878,6 +2878,26 @@ Verletzung wählt.
Bestandteilen; (2) je Entscheidung die bindenden Bedingungen; (3) Schattenpreise der
knappsten Ressourcen mit Handlungsempfehlung.
+**{loesung} — Den Bericht in die Irre führen.**
+(a) Der Plan kippt: *Rahmen* fällt von 47,5 auf **40** — das Minimum aus dem Liefervertrag —,
+*Deckel* steigt von 97,5 auf **120**, die Marktgrenze. Der Deckungsbeitrag springt von
+3 930 € auf **5 190 €**. Mit ihm wechselt die Engpassstruktur: „Kapazität Lackieren" bindet
+**nicht mehr**, dafür bindet jetzt „Liefervertrag Rahmen" — und zwar als *Kosten* von
+25 € je Stück, weil der Vertrag zur Herstellung eines inzwischen unattraktiven Produkts
+zwingt. Alle Schattenpreise steigen deutlich (Träger von 18 € auf 58 €).
+Der entscheidende Punkt der Aufgabe: Der Satz *„Teuerste Bindung ist Liefervertrag
+Träger"* steht **wörtlich unverändert** im Bericht — obwohl die Zahl dahinter sich mehr
+als verdreifacht hat und ein anderer Engpass die Fabrik begrenzt. Ein Bericht, der sich
+nicht ändert, ist kein Beweis dafür, dass sich nichts geändert hat.
+(b) Etwa: *„Unter der Annahme eines Deckungsbeitrags von 9 € je Deckel ist ‚Liefervertrag
+Träger' die teuerste Bindung (18 € je Stück)."* Die Aussage bekommt damit ihre
+Voraussetzung mit — und wird angreifbar, was sie sein soll.
+(c) Mindestens eines von beidem: den **Gültigkeitsbereich** jedes Schattenpreises (bis
+wohin gilt er?), oder das Ergebnis einer **Sensitivitätsrechnung** über die unsichersten
+Eingangsgrößen — etwa „bei ±20 % Deckungsbeitrag Deckel bleibt die Rangfolge/kippt sie".
+Das Programm rechnet den Gültigkeitsbereich für die auffälligste Stellschraube bereits
+aus; ihn für alle auszuweisen ist eine kleine Erweiterung.
+
**{loesung} — Gap gegen Laufzeit.** Erwartetes Muster: Von 10 % auf 2 % kostet wenig Zeit; von
1 % auf 0 % kann die Laufzeit um Größenordnungen steigen, ohne dass sich der Zielwert
nennenswert verbessert. Empfehlung: Gap dort ansetzen, wo die Kurve knickt — typischerweise
@@ -2975,6 +2995,15 @@ Ursache zu finden, solange sie noch frisch ist.
4. Jeder Lauf arbeitet auf einem unveränderlichen, mit ID versehenen Datenstand — nur so
sind Ergebnisse reproduzierbar und belegbar.
5. Die **Einführung**: mangelnde Akzeptanz, weil Ergebnisse nicht nachvollziehbar sind.
+6. Sie berührt den Plan, ohne ihn zu verteuern — die Lösung liegt genau auf ihrer Grenze,
+ wäre aber auch ohne sie dieselbe (Entartung). Für eine Nachverhandlung folgt: **nichts
+ tun.** Der Aufwand brächte 0 €. „Bindend" ist ein geometrischer, „teuer" ein
+ wirtschaftlicher Befund.
+7. Weil der Schattenpreis nur für die **nächste** Einheit gilt und sein Gültigkeitsbereich
+ endet, sobald ein anderer Engpass bindend wird. Realistische Lockerungen sind
+ unterschiedlich groß — eine Sonderschicht bringt 50 Einheiten, ein nachverhandelter
+ Vertrag 10. Der Preis je Einheit sagt daher nichts darüber, welcher Hebel insgesamt am
+ meisten bringt; dafür muss man die Lockerung rechnen, nicht den Preis multiplizieren.
---
diff --git a/Operations_Research_mit_Python_Version_04_Programme/Constraint_Attribution.py b/Operations_Research_mit_Python_Version_04_Programme/Constraint_Attribution.py
new file mode 100644
index 0000000..c5a9ab7
--- /dev/null
+++ b/Operations_Research_mit_Python_Version_04_Programme/Constraint_Attribution.py
@@ -0,0 +1,305 @@
+#!/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)
diff --git a/Operations_Research_mit_Python_Version_04_Programme/README.md b/Operations_Research_mit_Python_Version_04_Programme/README.md
index a47a71c..782687e 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 74 Dateien sind automatisch aus den Kapiteldateien in
+Diese 75 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 74 Programme mit Kurzbeschreibung.
+(`00_Vorwort_und_Lesehilfe.md`) listet dieselben 75 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/PROGRESS.md b/PROGRESS.md
index fd42ca5..17885f3 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 401** |
-| Größe des Gesamtdokuments | 606 KB | **1 536 KB** |
-| Hauptüberschriften | 131 | **289** |
-| registrierte Abschnitte | 122 | **293** |
-| aufgelöste Querverweise | 314 | **720** (0 unaufgelöst) |
-| Indexmarken | 295 | **327** |
-| Beispielprogramme | 41 | **74** (alle lauffähig) |
-| PDF-Seiten | — | **725** |
+| 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) |
+| Indexmarken | 295 | **328** |
+| Beispielprogramme | 41 | **75** (alle lauffähig) |
+| PDF-Seiten | — | **737** |
| Notebooks | — | **25** |
| Plotly-Figuren | — | **4** |
| Diagramme (SVG) | 26 | **32**, davon **18** mit Generatorskript (15 Skripte) |
@@ -1512,6 +1512,59 @@ den Aufgabentiteln ab (gekürzt: „Zweistufiges Modell rechnen" → „Zweistuf
Nummer wieder stimmt, ist sie der Schlüssel; das Angleichen wäre eine rein redaktionelle
Runde.
+### ✅ 6.2 Constraint Attribution und Managementbericht ({ref:kap:praxisfallen})
+
+Neuer Abschnitt {ref:sec:praxisfallen-attribution} plus `Constraint_Attribution.py`
+(75. Programm). Setzt Paket 3 aus `Verbesserungen_02.md` um — den Teil, der noch fehlte.
+
+**Die Lücke, die es schließt.** Zwei Fälle waren abgedeckt: `Erklaerbarkeit.py` beantwortet
+„warum **diese** Zuweisung?", `Konfliktsuche.py` „warum geht es **gar nicht**?". Der
+häufigste Fall der Praxis lag dazwischen und hatte kein Werkzeug: *Das Modell rechnet, der
+Plan ist zulässig — und trotzdem enttäuschend, nur weiß niemand woran es liegt.*
+
+**Dieselbe Fabrik wie die Konfliktsuche, 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 bringt sie auf 210, der Mindestumsatz sinkt auf 10 000 €. Damit kann ein
+Leser dieselben 14 benannten Bedingungen über zwei Kapitel hinweg verfolgen — erst
+„warum nicht?", dann „was kostet was?".
+
+**Drei gemessene Befunde:**
+
+1. **Bindend heißt nicht teuer.** Fünf Bedingungen binden, „Lackierbudget Schicht 2" mit
+ einem Schattenpreis von **0,00 €**. Dieselbe Unterscheidung, die `Erklaerbarkeit.py` an
+ einer einzelnen Zuweisung zeigt — hier im Dualwert eines LP.
+2. **Der Schattenpreis ist eine Momentaufnahme.** Für die Lackiererei verspricht die
+ Hochrechnung $4{,}00 \cdot 30 = 120$ €, gemessen kommen **60 €**; der Preis gilt exakt
+ bis **+15 Stunden**. Die zweite Hälfte der Sonderschicht wäre bezahlt und wirkungslos.
+ Und die Ranglisten drehen sich: nach Schattenpreis führt der Trägervertrag (18 €/Stück),
+ nach tatsächlicher Wirkung die Montage-Sonderschicht (450 €).
+3. **Ein Wunsch kann unmöglich statt teuer sein.** „Mindestens 55 Rahmen" ist `INFEASIBLE`,
+ blockiert von **drei** Bedingungen gemeinsam (jede einzeln entfernt macht ihn möglich) —
+ die Deletion-Filter-Struktur, angewandt auf einen Wunsch statt auf einen Widerspruch. Mit
+ +15 Stunden Lackierkapazität wird er erfüllbar und kostet dann **nichts**: Der Plan wählt
+ diese Menge von sich aus. Der Konflikt bestand nie zwischen Vertrieb und Produktion.
+
+**Der Managementbericht** entsteht in Teil (4) aus genau diesen Zahlen — kein Satz enthält
+eine Zahl, die nicht vorher gerechnet wurde, auch nicht die Rangfolge und nicht die Namen
+der blockierenden Bedingungen. Voraussetzung dafür ist der sprechende Name jeder Bedingung;
+aus `A_ub[7]` wird kein Satz.
+
+**Und die Grenze, die dazugehört** — als ⚠️-Kasten und als Übungsaufgabe: Ein solcher
+Bericht erklärt das **Modell**, nicht die Wirklichkeit. Die Aufgabe lässt den
+Deckungsbeitrag von *Deckel* von 9 € auf 20 € steigen. Gemessen: Der Plan kippt (Rahmen auf
+das Vertragsminimum 40, Deckel auf die Marktgrenze 120), „Kapazität Lackieren" hört auf zu
+binden, „Liefervertrag Rahmen" fängt damit an — **und der Satz „Teuerste Bindung ist
+Liefervertrag Träger" steht wörtlich unverändert im Bericht**, während die Zahl dahinter von
+18 € auf 58 € springt. Ein Bericht, der sich nicht ändert, ist kein Beweis dafür, dass sich
+nichts geändert hat.
+
+**Die neue Zählprobe aus 6.1a hat sich sofort bewährt:** Nach dem Einbau der Übungsaufgabe
+meldete `--check` „6 Loesungen, das Kapitel aber 7 Aufgaben", bevor die Lösung geschrieben
+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.
+
---
## 8. Commit-Historie des V04-Strangs
diff --git a/README.md b/README.md
index 7fc9beb..034719c 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, 725 Seiten |
+| `Operations_Research_mit_Python_Version_04.pdf` | generiert: PDF, 737 Seiten |
| `OR_HTML_04/` | generiert: **Mehrseiten-Website** — dieser Ordner wird veröffentlicht |
-| `Operations_Research_mit_Python_Version_04_Programme/` | generiert: 74 lauffähige Beispielprogramme |
+| `Operations_Research_mit_Python_Version_04_Programme/` | generiert: 75 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`, …) |