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

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