Erster Commit des Strangs "Optimierte Entscheidungsfindung mit Python" (Version 04). Die Historie der 71 Commits bis zur Trennung bleibt im uebergeordneten Repository OR_mit_Python liegen, das ab jetzt nur noch Version_03 (eingefroren) verwaltet und Version_04/ ignoriert. Bewusst kein "git subtree split": Der Pfad Version_04/ existiert erst seit der Verzeichnistrennung, ein Split braechte daher nur 7 der 41 einschlaegigen Commits - eine Teilhistorie, die vollstaendig aussieht und es nicht ist. Stand: 5 Teile, 23 Kapitel, 5 Anhaenge, 292 Abschnitte, 703 Querverweise, 325 Indexmarken, 73 Beispielprogramme, 32 SVGs, 4 Plotly-Figuren, 25 Notebooks, PDF mit 715 Seiten. Zusaetzlich in diesem Commit: * pyproject.toml mit Abhaengigkeitsgruppen finance, large-scale, api, figures, dev, empfehlungen. Die abgedruckte requirements.txt bleibt unveraendert daneben bestehen. ortools steht in der Grundausstattung, highspy erst in [large-scale] - so kann der HiGHS-Symbolkonflikt bei der schlanken Installation gar nicht erst auftreten. * Dabei zwei Funde: graphviz wird von erzeuge_architektur_diagramme.py importiert, fehlt aber in requirements.txt (jetzt in [figures]); pymoo steht in requirements.txt, wird aber von keinem Programm importiert, sondern nur im Kapitel Metaheuristiken empfohlen (jetzt in [empfehlungen]). * NEUER_TITEL.md nach Kritik_und_Verbesserungsvorschlaege/ verschoben - es ist die Vorlage des Titelblatts, kein Bestandteil des Werks. Die beiden Fundstellen in PROGRESS.md und erzeuge_titelseite.py nachgezogen. * PROGRESS.md nannte noch den Untertitel der ersten Fassung; auf den tatsaechlichen aus erzeuge_titelseite.py korrigiert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
110 lines
4.6 KiB
Python
110 lines
4.6 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# Sensitivitaetsanalyse.py
|
|
"""
|
|
Kapitel LP: Schattenpreis- und Sensitivitätsanalyse mit SciPy und HiGHS.
|
|
|
|
Achtung: Ohne Vorzeichenumkehr der Dualwerte waere die Handlungsempfehlung
|
|
strukturell immer "kein Zukauf noetig" - selbst bei harten Engpaessen. Dieses
|
|
Programm zeigt die korrekte Vorzeichenbehandlung.
|
|
"""
|
|
|
|
import numpy as np
|
|
from scipy.optimize import linprog
|
|
|
|
# --- Modell: Maximiere Deckungsbeitrag aus 3 Produkten ---------------------
|
|
# max 40*x1 + 30*x2 + 50*x3
|
|
DECKUNGSBEITRAG = np.array([40.0, 30.0, 50.0])
|
|
|
|
# Ressourcenverbrauch je Produkt (Zeile = Ressource, Spalte = Produkt)
|
|
VERBRAUCH = np.array([
|
|
[2.0, 1.0, 3.0], # Montagezeit
|
|
[1.0, 2.0, 1.0], # Lackierzeit
|
|
[1.0, 0.5, 2.0], # Qualitätsprüfung
|
|
])
|
|
KAPAZITAET = np.array([120.0, 80.0, 50.0]) # Stunden
|
|
RESSOURCEN = ["Montage", "Lackieren", "Qualitätsprüfung"]
|
|
PRODUKTE = ["Produkt 1", "Produkt 2", "Produkt 3"]
|
|
|
|
ANGEBOTSPREIS_PRUEFSTUNDE = 18.0 # EUR/h - lohnt sich der Zukauf?
|
|
|
|
|
|
def analysiere():
|
|
# linprog MINIMIERT -> Zielfunktion negieren
|
|
ergebnis = linprog(c=-DECKUNGSBEITRAG, A_ub=VERBRAUCH, b_ub=KAPAZITAET,
|
|
bounds=[(0, None)] * len(DECKUNGSBEITRAG), method="highs")
|
|
if not ergebnis.success:
|
|
raise SystemExit(f"Kein Optimum gefunden: {ergebnis.message}")
|
|
|
|
max_gewinn = -ergebnis.fun
|
|
mengen = ergebnis.x
|
|
schlupf = ergebnis.slack
|
|
|
|
# ------------------------------------------------------------------
|
|
# DER ENTSCHEIDENDE PUNKT:
|
|
# Weil wir zur Maximierung negiert haben, sind die Dualwerte aus
|
|
# linprog fuer "<="-Bedingungen <= 0. Zurueckdrehen!
|
|
# ------------------------------------------------------------------
|
|
schattenpreise = -ergebnis.ineqlin.marginals
|
|
|
|
print("=" * 74)
|
|
print(" PRIMALE UND DUALE ERGEBNISANALYSE (SENSITIVITAET)")
|
|
print("=" * 74)
|
|
print(f"Maximaler Deckungsbeitrag: {max_gewinn:,.2f} EUR\n")
|
|
|
|
print("--- Primalloesung: optimale Produktionsmengen ---")
|
|
for name, menge in zip(PRODUKTE, mengen):
|
|
print(f" * {name}: {menge:8.2f} Stueck")
|
|
|
|
print("\n--- Duale Analyse: Schattenpreise und Auslastung ---")
|
|
for i, name in enumerate(RESSOURCEN):
|
|
kapazitaet = KAPAZITAET[i]
|
|
genutzt = kapazitaet - schlupf[i]
|
|
auslastung = genutzt / kapazitaet * 100
|
|
preis = schattenpreise[i]
|
|
bindend = abs(schlupf[i]) < 1e-9
|
|
|
|
print(f"\nRessource '{name}':")
|
|
print(f" Auslastung: {genutzt:6.1f} / {kapazitaet:6.1f} h ({auslastung:5.1f} %)"
|
|
f" -> {'ENGPASS' if bindend else 'Reserve: %.1f h' % schlupf[i]}")
|
|
print(f" Schattenpreis: {preis:6.2f} EUR je zusaetzlicher Stunde")
|
|
|
|
if preis > 1e-9:
|
|
print(f" >> Zusaetzliche Stunden lohnen sich bis zu einem Preis von "
|
|
f"{preis:.2f} EUR/h.")
|
|
else:
|
|
print(f" >> Kein Zukauf noetig - die Kapazitaet ist nicht erschoepft.")
|
|
|
|
# --- Konkrete Kaufentscheidung ---------------------------------------
|
|
preis_pruefung = schattenpreise[RESSOURCEN.index("Qualitätsprüfung")]
|
|
marge = preis_pruefung - ANGEBOTSPREIS_PRUEFSTUNDE
|
|
print("\n" + "-" * 74)
|
|
print(f"ENTSCHEIDUNG: Pruefstunden werden fuer "
|
|
f"{ANGEBOTSPREIS_PRUEFSTUNDE:.2f} EUR/h angeboten.")
|
|
print(f" Schattenpreis: {preis_pruefung:6.2f} EUR/h")
|
|
print(f" Angebotspreis: {ANGEBOTSPREIS_PRUEFSTUNDE:6.2f} EUR/h")
|
|
print(f" Marge: {marge:+6.2f} EUR je zugekaufter Stunde")
|
|
print(f" >> {'ZUKAUFEN' if marge > 0 else 'NICHT ZUKAUFEN'}")
|
|
|
|
# --- Numerische Gegenprobe: Kapazitaet wirklich um 1 erhoehen --------
|
|
kapazitaet_plus = KAPAZITAET.copy()
|
|
kapazitaet_plus[RESSOURCEN.index("Qualitätsprüfung")] += 1.0
|
|
gegenprobe = linprog(c=-DECKUNGSBEITRAG, A_ub=VERBRAUCH, b_ub=kapazitaet_plus,
|
|
bounds=[(0, None)] * 3, method="highs")
|
|
tatsaechlicher_zuwachs = -gegenprobe.fun - max_gewinn
|
|
print("\n--- Gegenprobe: Modell mit +1 Pruefstunde neu geloest ---")
|
|
print(f" Vorhergesagt (Schattenpreis): {preis_pruefung:8.4f} EUR")
|
|
print(f" Tatsaechlich gemessen: {tatsaechlicher_zuwachs:8.4f} EUR")
|
|
assert abs(tatsaechlicher_zuwachs - preis_pruefung) < 1e-6, \
|
|
"Schattenpreis stimmt nicht mit der Messung ueberein!"
|
|
print(" -> Der Schattenpreis ist bestaetigt.")
|
|
|
|
# --- Komplementaerer Schlupf pruefen --------------------------------
|
|
for s, y in zip(schlupf, schattenpreise):
|
|
assert abs(s * y) < 1e-6, "Komplementaerer Schlupf verletzt!"
|
|
print("\nPruefung: komplementaerer Schlupf fuer alle Ressourcen erfuellt.")
|
|
print("=" * 74)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
analysiere()
|