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:
dschlueter 2026-09-08 10:30:33 +02:00
commit dd4acda129
26 changed files with 7048 additions and 3815 deletions

View file

@ -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} |

View file

@ -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}
![Architektur einer produktionsreifen OR-Plattform](bilder_04/kap15_or_plattform.svg)
@ -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.

View file

@ -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.
---