305 lines
14 KiB
Python
305 lines
14 KiB
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)
|