Phase 6.2: Constraint Attribution und Managementbericht
Neuer Abschnitt im Kapitel Praxisfallen plus Constraint_Attribution.py (75. Programm). Setzt den fehlenden Teil von Paket 3 aus Verbesserungen_02.md um. Die Luecke, die es schliesst: Erklaerbarkeit.py beantwortet "warum DIESE Zuweisung?", Konfliktsuche.py "warum geht es GAR NICHT?". Der haeufigste Fall der Praxis lag dazwischen und hatte kein Werkzeug - das Modell rechnet, der Plan ist zulaessig, und trotzdem enttaeuschend, nur weiss niemand woran es liegt. Gerechnet wird auf derselben Fabrik wie die Konfliktsuche, nach der Reparatur: Dort war die Lackierkapazitaet von 150 Stunden der Kern des Widerspruchs (2*40 + 3*30 = 170 > 150), eine zweite Schicht bringt sie auf 210. Damit laesst sich dieselbe Instanz ueber zwei Kapitel verfolgen - erst "warum nicht?", dann "was kostet was?". Drei gemessene Befunde: * Bindend heisst nicht teuer: Fuenf Bedingungen binden, "Lackierbudget Schicht 2" mit einem Schattenpreis von 0,00 EUR. * Der Schattenpreis ist eine Momentaufnahme: Fuer die Lackiererei verspricht die Hochrechnung 4,00 * 30 = 120 EUR, gemessen kommen 60 EUR; der Preis gilt exakt bis +15 Stunden. Die Ranglisten drehen sich dabei um - nach Schattenpreis fuehrt der Traegervertrag (18 EUR/Stueck), nach tatsaechlicher Wirkung die Montage-Sonderschicht (450 EUR). * Ein Wunsch kann unmoeglich statt teuer sein: "mindestens 55 Rahmen" ist INFEASIBLE, blockiert von drei Bedingungen gemeinsam. Mit +15 Stunden Lackierkapazitaet wird er erfuellbar und kostet dann nichts - der Plan waehlt diese Menge von sich aus. Der Konflikt bestand nie zwischen Vertrieb und Produktion. Der Managementbericht entsteht aus genau diesen Zahlen; kein Satz enthaelt eine Zahl, die nicht vorher gerechnet wurde. Voraussetzung ist der sprechende Name jeder Bedingung - aus A_ub[7] wird kein Satz. Dazu die Grenze, als Warnkasten und als Uebungsaufgabe: Der Bericht erklaert das Modell, nicht die Wirklichkeit. Steigt der Deckungsbeitrag von Deckel von 9 auf 20 EUR, kippt der Plan (Rahmen auf das Vertragsminimum 40, Deckel auf die Marktgrenze 120), "Kapazitaet Lackieren" hoert auf zu binden, "Liefervertrag Rahmen" faengt damit an - und der Satz "Teuerste Bindung ist Liefervertrag Traeger" steht woertlich unveraendert im Bericht, waehrend die Zahl dahinter von 18 auf 58 EUR springt. Die Zaehlprobe aus 6.1a hat sich sofort bewaehrt: Nach dem Einbau der Uebungsaufgabe meldete --check "6 Loesungen, das Kapitel aber 7 Aufgaben", bevor die Loesung geschrieben war. Mitgezogen: Kapitelkopf, Lernziele, Selbsttest, Zusammenfassung, Vorwort-Programmverzeichnis, Vorwaertsverweis aus Falle 2, Uebungsaufgabe und Loesung in Anhang A. Stand: 294 Abschnitte, 725 Querverweise, 328 Indexmarken, 75 Programme, 139 Aufgaben mit 139 Loesungen, 33 pytest-Tests, PDF 737 Seiten, 68 netzfreie Programme fehlerfrei. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
cc990c2998
commit
dd4acda129
26 changed files with 7048 additions and 3815 deletions
|
|
@ -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": {},
|
||||
|
|
|
|||
|
|
@ -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": {},
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1348,8 +1348,9 @@ T \ge \left(\frac{2\sigma}{\mu}\right)^2 = \left(\frac{2 \cdot 0{,}012}{0{,}0004
|
|||
<p><strong>22.2 — Zeitlimit wählen.</strong> (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.</p>
|
||||
<p><strong>22.3 — Relaxation einbauen.</strong> Muster wie in <code>Infeasibility_Diagnose.py</code>: Schlupfvariable je Mindestbesetzung, Strafe deutlich über allen weichen Zielen, aber endlich. Wichtig: <strong>gestaffelte</strong> Strafen, damit der Solver die <em>am wenigsten schmerzhafte</em> Verletzung wählt.</p>
|
||||
<p><strong>22.4 — Erklärbarkeit.</strong> Report-Struktur: (1) Zielwert und Aufschlüsselung nach Bestandteilen; (2) je Entscheidung die bindenden Bedingungen; (3) Schattenpreise der knappsten Ressourcen mit Handlungsempfehlung.</p>
|
||||
<p><strong>22.5 — Gap gegen Laufzeit.</strong> 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 %.</p>
|
||||
<p><strong>22.6 — Post-Mortem.</strong> Bewertungskriterien: Wird zwischen <strong>Symptom</strong>, <strong>Ursache</strong> und <strong>fehlender Prüfung</strong> 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?</p>
|
||||
<p><strong>22.5 — Den Bericht in die Irre führen.</strong> (a) Der Plan kippt: <em>Rahmen</em> fällt von 47,5 auf <strong>40</strong> — das Minimum aus dem Liefervertrag —, <em>Deckel</em> steigt von 97,5 auf <strong>120</strong>, die Marktgrenze. Der Deckungsbeitrag springt von 3 930 € auf <strong>5 190 €</strong>. Mit ihm wechselt die Engpassstruktur: „Kapazität Lackieren” bindet <strong>nicht mehr</strong>, dafür bindet jetzt „Liefervertrag Rahmen” — und zwar als <em>Kosten</em> 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 <em>„Teuerste Bindung ist Liefervertrag Träger”</em> steht <strong>wörtlich unverändert</strong> 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: <em>„Unter der Annahme eines Deckungsbeitrags von 9 € je Deckel ist ‚Liefervertrag Träger’ die teuerste Bindung (18 € je Stück).”</em> Die Aussage bekommt damit ihre Voraussetzung mit — und wird angreifbar, was sie sein soll. (c) Mindestens eines von beidem: den <strong>Gültigkeitsbereich</strong> jedes Schattenpreises (bis wohin gilt er?), oder das Ergebnis einer <strong>Sensitivitätsrechnung</strong> ü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.</p>
|
||||
<p><strong>22.6 — Gap gegen Laufzeit.</strong> 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 %.</p>
|
||||
<p><strong>22.7 — Post-Mortem.</strong> Bewertungskriterien: Wird zwischen <strong>Symptom</strong>, <strong>Ursache</strong> und <strong>fehlender Prüfung</strong> 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?</p>
|
||||
<h3 id="finde-den-denkfehler-das-modell-das-seit-einem-jahr-nicht-mehr-optimiert">Finde den Denkfehler — Das Modell, das seit einem Jahr nicht mehr optimiert</h3>
|
||||
<ol type="a">
|
||||
<li><strong>Warum die konstante Laufzeit das Symptom ist.</strong> 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 <strong>abgeschnitten</strong>.</li>
|
||||
|
|
@ -1409,6 +1410,8 @@ T \ge \left(\frac{2\sigma}{\mu}\right)^2 = \left(\frac{2 \cdot 0{,}012}{0{,}0004
|
|||
<li>Weil die Datenunsicherheit (oft ±10 %) die verbleibende Optimalitätslücke bei Weitem übersteigt.</li>
|
||||
<li>Jeder Lauf arbeitet auf einem unveränderlichen, mit ID versehenen Datenstand — nur so sind Ergebnisse reproduzierbar und belegbar.</li>
|
||||
<li>Die <strong>Einführung</strong>: mangelnde Akzeptanz, weil Ergebnisse nicht nachvollziehbar sind.</li>
|
||||
<li>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: <strong>nichts tun.</strong> Der Aufwand brächte 0 €. „Bindend” ist ein geometrischer, „teuer” ein wirtschaftlicher Befund.</li>
|
||||
<li>Weil der Schattenpreis nur für die <strong>nächste</strong> 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.</li>
|
||||
</ol>
|
||||
<hr />
|
||||
<p><em>Zurück zum</em> <strong>Wegweiser</strong> <em>oder weiter zu</em> <a href="anhang-modellierungsmuster.html">[Anhang B](anhang-modellierungsmuster.html#anhang-modellierungsmuster) — Modellierungsmuster</a></p>
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -121,7 +121,7 @@
|
|||
<blockquote>
|
||||
<p><strong>📌 Kapitel auf einen Blick</strong></p>
|
||||
<p><strong>Worum geht es?</strong> 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.</p>
|
||||
<p><strong>Voraussetzungen:</strong> <a href="einfuehrung.html#kap-einfuehrung">Kapitel 1</a> (Allokation als LP), <a href="unsicherheit.html#kap-unsicherheit">Kapitel 12</a> (Szenarien) und <a href="praxisfallen.html#sec:praxisfallen-or-kern">Abschnitt 22.5</a> (<code>or_kern.py</code>).</p>
|
||||
<p><strong>Voraussetzungen:</strong> <a href="einfuehrung.html#kap-einfuehrung">Kapitel 1</a> (Allokation als LP), <a href="unsicherheit.html#kap-unsicherheit">Kapitel 12</a> (Szenarien) und <a href="praxisfallen.html#sec:praxisfallen-or-kern">Abschnitt 22.6</a> (<code>or_kern.py</code>).</p>
|
||||
<p><strong>Danach können Sie:</strong> ein betriebswirtschaftliches Modell in die andere Domäne übersetzen, ohne den Modellcode anzufassen; Schattenpreise in beiden Welten lesen; und benennen, welche Annahmen dabei <strong>nicht</strong> mitwandern.</p>
|
||||
<p><strong>Zeitbedarf:</strong> ca. 2,5 Stunden.</p>
|
||||
<p><strong>Programme:</strong><br />
|
||||
|
|
|
|||
|
|
@ -721,7 +721,7 @@ Suchstatistik: 113 Verzweigungen, 3 Konflikte, 0.006 s
|
|||
<p><strong>3. Kostenzerlegung.</strong> Der Block „Woraus bestehen die Strafkosten?“ ist kein Beiwerk. In der Praxis ist die erste Frage jedes Anwenders: <em>„Warum bekomme <strong>ich</strong> die Stunde?“</em> Wer darauf keine Antwort hat, dessen System wird nicht benutzt (<a href="praxisfallen.html#kap-praxisfallen">Kapitel 22</a>, <em>Explainable OR</em>).</p>
|
||||
<p><strong>4. Statusauswertung ohne CP-SAT-Vokabular.</strong> Nach <code>Solve()</code> steht nur eine einzige Zeile mit einer CP-SAT-Konstante darin:</p>
|
||||
<div class="sourceCode" id="cb7"><pre class="sourceCode python"><code class="sourceCode python"><span id="cb7-1"><a href="#cb7-1" aria-hidden="true" tabindex="-1"></a>status <span class="op">=</span> status_von_cpsat(rohstatus)</span></code></pre></div>
|
||||
<p>Danach heißen die Fälle <code>status.modellfehler</code> (die Regeln widersprechen sich — das Modell muss geändert werden) und <code>status.brauchbar</code> (es liegt etwas Ausführbares vor). Das ist <strong>dieselbe</strong> Fallunterscheidung wie bei HiGHS in <a href="milp.html#kap-milp">Kapitel 6</a> und bei GLOP in <a href="lp.html#kap-lp">Kapitel 5</a>; nur die eine Übersetzerzeile weiß, welcher Solver gerechnet hat. Wer den Auswertungscode direkt gegen <code>cp_model.INFEASIBLE</code> schreibt, kann ihn bei einem Solverwechsel wegwerfen — <a href="praxisfallen.html#sec:praxisfallen-solverwechsel">Abschnitt 22.6</a> führt genau diesen Wechsel vor.</p>
|
||||
<p>Danach heißen die Fälle <code>status.modellfehler</code> (die Regeln widersprechen sich — das Modell muss geändert werden) und <code>status.brauchbar</code> (es liegt etwas Ausführbares vor). Das ist <strong>dieselbe</strong> Fallunterscheidung wie bei HiGHS in <a href="milp.html#kap-milp">Kapitel 6</a> und bei GLOP in <a href="lp.html#kap-lp">Kapitel 5</a>; nur die eine Übersetzerzeile weiß, welcher Solver gerechnet hat. Wer den Auswertungscode direkt gegen <code>cp_model.INFEASIBLE</code> schreibt, kann ihn bei einem Solverwechsel wegwerfen — <a href="praxisfallen.html#sec:praxisfallen-solverwechsel">Abschnitt 22.7</a> führt genau diesen Wechsel vor.</p>
|
||||
</blockquote>
|
||||
<blockquote>
|
||||
<p><strong>⚠️ Typische Fehler</strong></p>
|
||||
|
|
|
|||
|
|
@ -1099,7 +1099,7 @@ Vorteil der Optimierung: 300.00 EUR pro Tag (15.0 %)
|
|||
</blockquote>
|
||||
<blockquote>
|
||||
<p><strong>📎 Warum dieses Programm alles selbst macht</strong></p>
|
||||
<p>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 <code>or_kern.py</code> bezieht (<a href="praxisfallen.html#sec:praxisfallen-or-kern">Abschnitt 22.5</a>). Warum tut <code>Bot_Allokation.py</code> das nicht auch?</p>
|
||||
<p>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 <code>or_kern.py</code> bezieht (<a href="praxisfallen.html#sec:praxisfallen-or-kern">Abschnitt 22.6</a>). Warum tut <code>Bot_Allokation.py</code> das nicht auch?</p>
|
||||
<p>Weil es hier nicht darum geht, <strong>wenig Code</strong> zu schreiben, sondern darum zu sehen, wie ein Modell <strong>entsteht</strong>: 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.</p>
|
||||
<p>Das ist eine allgemeine Regel für eigene Projekte: <strong>Erst die Wiederholung rechtfertigt die Abstraktion.</strong> 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.</p>
|
||||
</blockquote>
|
||||
|
|
@ -1166,7 +1166,7 @@ Vorteil der Optimierung: 300.00 EUR pro Tag (15.0 %)
|
|||
<p>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.</p>
|
||||
<blockquote>
|
||||
<p><strong>📎 Zum Modul <code>or_kern.py</code></strong></p>
|
||||
<p>Die vier Arbeitsschritte — lesen, lösen, prüfen, schreiben — stehen nicht in diesem Programm, sondern in <code>or_kern.py</code>. Das ist ein gemeinsamer Unterbau, den alle Programme dieses Buchs benutzen können; er liegt im selben Verzeichnis und ist in <a href="praxisfallen.html#sec:praxisfallen-or-kern">Abschnitt 22.5</a> vollständig abgedruckt und erklärt.</p>
|
||||
<p>Die vier Arbeitsschritte — lesen, lösen, prüfen, schreiben — stehen nicht in diesem Programm, sondern in <code>or_kern.py</code>. Das ist ein gemeinsamer Unterbau, den alle Programme dieses Buchs benutzen können; er liegt im selben Verzeichnis und ist in <a href="praxisfallen.html#sec:praxisfallen-or-kern">Abschnitt 22.6</a> vollständig abgedruckt und erklärt.</p>
|
||||
<p>Sie müssen ihn jetzt noch nicht verstehen. Wichtig ist nur die Aufteilung: <strong>Hier</strong> steht, was diesen Fall ausmacht — dort steht, was jedes Optimierungsprogramm braucht. Genau deshalb ist der Code unten so kurz.</p>
|
||||
</blockquote>
|
||||
<div class="sourceCode" id="cb9"><pre class="sourceCode python"><code class="sourceCode python"><span id="cb9-1"><a href="#cb9-1" aria-hidden="true" tabindex="-1"></a><span class="co">#!/usr/bin/env python3</span></span>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -592,7 +592,7 @@ L\,y_i \le x_i &\le U\,y_i && \forall i \quad \text{(semikontinuierl
|
|||
</blockquote>
|
||||
<blockquote>
|
||||
<p><strong>📎 Zwei Bausteine aus <code>or_kern.py</code></strong></p>
|
||||
<p>Das Programm wertet den Solver nicht über <code>ergebnis.success</code> aus, sondern über <code>status_von_scipy()</code> und das <code>Loesung</code>-Objekt aus dem gemeinsamen Unterbau (<a href="praxisfallen.html#sec:praxisfallen-or-kern">Abschnitt 22.5</a>). Der Unterschied ist kein Schönheitsfehler:</p>
|
||||
<p>Das Programm wertet den Solver nicht über <code>ergebnis.success</code> aus, sondern über <code>status_von_scipy()</code> und das <code>Loesung</code>-Objekt aus dem gemeinsamen Unterbau (<a href="praxisfallen.html#sec:praxisfallen-or-kern">Abschnitt 22.6</a>). Der Unterschied ist kein Schönheitsfehler:</p>
|
||||
<ul>
|
||||
<li><code>success</code> ist <strong>ein Bit</strong>. 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.</li>
|
||||
<li>Das <code>Loesung</code>-Objekt führt neben dem Zielwert die <strong>Schranke</strong> mit und rechnet daraus den Gap aus. Erst dadurch steht in der Ausgabe <code>Gap: 0.00%</code> — die Zusage, dass hier wirklich das Optimum gefunden <em>und bewiesen</em> wurde und nicht bloß irgendetwas.</li>
|
||||
|
|
@ -863,7 +863,7 @@ Abnahmepruefung: alle Nebenbedingungen geprueft und eingehalten.
|
|||
</tr>
|
||||
<tr class="even">
|
||||
<td><code>status = status_von_scipy(ergebnis)</code></td>
|
||||
<td>übersetzt den SciPy-Rückgabewert in die gemeinsame Sprache aus <a href="praxisfallen.html#sec:praxisfallen-or-kern">Abschnitt 22.5</a> — die drei <code>if</code>-Zweige darunter behandeln Modellfehler, Abbruch ohne Lösung und „zulässig, aber unbewiesen“ getrennt</td>
|
||||
<td>übersetzt den SciPy-Rückgabewert in die gemeinsame Sprache aus <a href="praxisfallen.html#sec:praxisfallen-or-kern">Abschnitt 22.6</a> — die drei <code>if</code>-Zweige darunter behandeln Modellfehler, Abbruch ohne Lösung und „zulässig, aber unbewiesen“ getrennt</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td><code>schranke=-ergebnis.mip_dual_bound</code></td>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
305
OR_HTML_04/programme/Constraint_Attribution.py
Normal file
305
OR_HTML_04/programme/Constraint_Attribution.py
Normal file
|
|
@ -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)
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -104,7 +104,7 @@
|
|||
<blockquote>
|
||||
<p><strong>📌 Kapitel auf einen Blick</strong></p>
|
||||
<p><strong>Worum geht es?</strong> Um die drei Fragen, die zwischen einem funktionierenden Modell und einem System stehen, auf das sich jemand verlässt: <em>Woher weiß ich, dass es stimmt? Woher weiß ich, dass es schnell genug ist? Wie kommt es zu den Leuten, die es brauchen?</em></p>
|
||||
<p><strong>Voraussetzungen:</strong> <a href="praxisfallen.html#kap-praxisfallen">Kapitel 22</a>, insbesondere <code>or_kern.py</code> aus <a href="praxisfallen.html#sec:praxisfallen-or-kern">Abschnitt 22.5</a> — dieses Kapitel testet genau dieses Modul.</p>
|
||||
<p><strong>Voraussetzungen:</strong> <a href="praxisfallen.html#kap-praxisfallen">Kapitel 22</a>, insbesondere <code>or_kern.py</code> aus <a href="praxisfallen.html#sec:praxisfallen-or-kern">Abschnitt 22.6</a> — dieses Kapitel testet genau dieses Modul.</p>
|
||||
<p><strong>Danach können Sie:</strong> 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.</p>
|
||||
<p><strong>Zeitbedarf:</strong> ca. 5 Stunden.</p>
|
||||
<p><strong>Programme:</strong><br />
|
||||
|
|
@ -1551,7 +1551,7 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
|
|||
<blockquote>
|
||||
<p><strong>💻 Code-Durchgang: drei Entscheidungen</strong></p>
|
||||
<p><strong>1. Das Domänenmodell ist das API-Schema.</strong> Der Parameter heißt <code>problem: Produktionsproblem</code> — mehr steht nicht da. FastAPI validiert die Anfrage damit gegen dieselben Pydantic-Regeln wie der Excel-Import aus <a href="einfuehrung.html#kap-einfuehrung">Kapitel 1</a> und antwortet bei Unsinn mit <code>422</code>, <strong>bevor</strong> ein Solver startet. Die drei abgewiesenen Anfragen im Selbsttest sind genau die drei Fälle, die <code>or_kern.py</code> schon beim Einlesen abfängt.</p>
|
||||
<p><strong>2. Die Antwort enthält Status und Gap, nicht nur Zahlen.</strong> 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 <code>Loesung</code>-Objekt geht deshalb vollständig durch die Schnittstelle — dasselbe DTO, das in <a href="praxisfallen.html#sec:praxisfallen-solverwechsel">Abschnitt 22.6</a> schon eine Prozessgrenze überlebt hat.</p>
|
||||
<p><strong>2. Die Antwort enthält Status und Gap, nicht nur Zahlen.</strong> 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 <code>Loesung</code>-Objekt geht deshalb vollständig durch die Schnittstelle — dasselbe DTO, das in <a href="praxisfallen.html#sec:praxisfallen-solverwechsel">Abschnitt 22.7</a> schon eine Prozessgrenze überlebt hat.</p>
|
||||
<p><strong>3. Die Abnahmeprüfung läuft auch hier.</strong> <code>pruefe_loesung()</code> steht im Arbeiter, nicht nur im Skript. Ein Dienst ohne sie liefert Fehler schneller aus, als ein Mensch sie finden könnte.</p>
|
||||
</blockquote>
|
||||
<h3 id="threads-oder-prozesse-eine-messung-keine-meinung">Threads oder Prozesse? Eine Messung, keine Meinung</h3>
|
||||
|
|
|
|||
|
|
@ -714,41 +714,46 @@ Alles bereit — Sie können mit dem ersten Kapitel beginnen.</code></pre>
|
|||
<td><a href="praxisfallen.html#kap-praxisfallen">Kapitel 22</a></td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td><code>Constraint_Attribution.py</code></td>
|
||||
<td>Was kostet welche Bedingung? Managementbericht</td>
|
||||
<td><a href="praxisfallen.html#kap-praxisfallen">Kapitel 22</a></td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td><code>Betriebsueberwachung.py</code></td>
|
||||
<td>Status, Gap und Zeitausschöpfung</td>
|
||||
<td><a href="praxisfallen.html#kap-praxisfallen">Kapitel 22</a></td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<tr class="odd">
|
||||
<td><code>or_kern.py</code></td>
|
||||
<td>Gemeinsamer Unterbau: Domäne, Status, Prüfung</td>
|
||||
<td><a href="praxisfallen.html#kap-praxisfallen">Kapitel 22</a></td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<tr class="even">
|
||||
<td><code>Solverwechsel_CPSAT_HiGHS.py</code></td>
|
||||
<td>Derselbe Fall in zwei Solvern</td>
|
||||
<td><a href="praxisfallen.html#kap-praxisfallen">Kapitel 22</a></td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<tr class="odd">
|
||||
<td><code>test_or_kern.py</code></td>
|
||||
<td>Testsuite für ein Optimierungsmodell</td>
|
||||
<td><a href="testing.html#kap-testing">Kapitel 23</a></td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<tr class="even">
|
||||
<td><code>Mutationstest.py</code></td>
|
||||
<td>Findet die Lücken der eigenen Tests</td>
|
||||
<td><a href="testing.html#kap-testing">Kapitel 23</a></td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<tr class="odd">
|
||||
<td><code>Benchmark_Skalierung.py</code></td>
|
||||
<td>Aufbau gegen Lösen, vier Bibliotheken</td>
|
||||
<td><a href="testing.html#kap-testing">Kapitel 23</a></td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<tr class="even">
|
||||
<td><code>Optimierungsdienst.py</code></td>
|
||||
<td>Das Modell als HTTP-Dienst</td>
|
||||
<td><a href="testing.html#kap-testing">Kapitel 23</a></td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<tr class="odd">
|
||||
<td><code>Konfliktsuche.py</code></td>
|
||||
<td>Deletion Filter: den Widerspruch einkreisen</td>
|
||||
<td><a href="anhang-fehlerdiagnose.html#anhang-fehlerdiagnose">Anhang C</a></td>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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} |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
69
PROGRESS.md
69
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
|
||||
|
|
|
|||
|
|
@ -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`, …) |
|
||||
|
|
|
|||
Loading…
Reference in a new issue