#!/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)