Teil V: Praxis
Kapitel 22: Praxisfallen und der Weg zum produktiven Einsatz
📌 Kapitel auf einen Blick
Worum geht es? Um alles, was zwischen einem funktionierenden Notebook und einem System steht, das Menschen tatsächlich benutzen.
Voraussetzungen: Die vorangegangenen Kapitel — dieses Kapitel zieht die Summe.
Danach können Sie: Unlösbarkeit systematisch diagnostizieren und auffangen, Ergebnisse erklärbar machen, Laufzeiten begrenzen und ein OR-System betriebsfähig aufsetzen.
Zeitbedarf: ca. 4 Stunden.
Programme:
Infeasibility_Diagnose.py
Erklaerbarkeit.py
Betriebsueberwachung.py
or_kern.py
Solverwechsel_CPSAT_HiGHS.pyNotebook: praxisfallen.ipynb
In Google Colab öffnen
22.1 In 5 Minuten gelöst
Die vorangegangenen vierzehn Kapitel enden fast alle mit derselben Empfehlung: Prüfen Sie das Ergebnis gegen die Wirklichkeit. Dieses Kapitel beginnt damit, diese Prüfung als wiederverwendbaren Baustein hinzuschreiben.
🚀 In 5 Minuten gelöst: Die Abnahmeprüfung, die in jedes Modell gehört
def pruefe_loesung(x, A, b, kosten, zielwert, ganzzahlig=(), toleranz=1e-6): """Prueft eine Loesung gegen die Anforderungen - OHNE den Solver zu fragen.""" beanstandungen = [] verbrauch = A @ x # 1. Werden Grenzen eingehalten? for i, (ist, grenze) in enumerate(zip(verbrauch, b)): if ist > grenze + toleranz: beanstandungen.append(f"Bedingung {i}: {ist:.4f} > {grenze:.4f}") for j in ganzzahlig: # 2. Ist ganzzahlig auch ganzzahlig? if abs(x[j] - round(x[j])) > toleranz: beanstandungen.append(f"x[{j}] = {x[j]!r} ist nicht ganzzahlig") nachgerechnet = float(kosten @ x) # 3. Stimmt der Zielwert? if abs(nachgerechnet - zielwert) > toleranz * max(1.0, abs(nachgerechnet)): beanstandungen.append( f"Zielwert {zielwert:.4f} passt nicht zu den Daten ({nachgerechnet:.4f})") return beanstandungen
Zwölf Zeilen, drei Prüfungen — und jede davon hätte einen der Denkfehler dieses Buches gefunden:
| Prüfung | fängt | aus |
|---|---|---|
| Grenzen einhalten | die pro Produkt statt insgesamt formulierte Kapazität | Abschnitt 1.10 |
| Grenzen einhalten | die vergessene Ladungsdimension im Routing | Abschnitt 8.7 |
| Ganzzahligkeit | Binärvariablen, die bei 10^{-8} hängen (Big-M) | Abschnitt 6.10 |
| Zielwert nachrechnen | Fixkosten, die im Modell nicht verbucht wurden | Abschnitt 6.10 |
🎯 Merksatz Entscheidend ist, was diese Funktion nicht benutzt: kein Solver-Objekt, keine Modellvariable, keine Dimension aus der Bibliothek. Sie bekommt nur die ausgegebene Lösung und die Anforderungen — und rechnet unabhängig nach. Eine Prüfung, die dieselben Bausteine verwendet wie das Modell, prüft das Modell gegen sich selbst und findet nichts.
Und was sie nicht kann. Sie prüft, ob die gefundene Lösung zulässig ist. Sie kann nicht prüfen, ob es eine bessere gäbe — genau daran ist die Prüfung in Abschnitt 13.7 gescheitert, wo das Modell einen korrekten Kostenbetrag für einen unnötig teuren Plan meldete. Dafür braucht es ein zweites, unabhängiges Verfahren oder eine bekannte Schranke. Die restlichen Abschnitte dieses Kapitels handeln davon, was im Betrieb sonst noch schiefgeht — und wie man es bemerkt.
22.2 Lernziele
Nach diesem Kapitel können Sie …
- … die fünf typischen Praxisfallen benennen und je ein Gegenmittel nennen.
- … ein unlösbares Modell in einen „am wenigsten schlechten“ Plan verwandeln.
- … Solver-Ergebnisse erklärbar machen (Explainable OR).
- … Zeitlimits und Optimalitätslücken sinnvoll setzen.
- … die Architektur einer produktionsreifen OR-Plattform skizzieren.
- … eine modellunabhängige Abnahmeprüfung schreiben, die eine Lösung gegen die Anforderungen prüft statt gegen das Modell.
- … Status, Gap und Zeitausschöpfung protokollieren und begründen, warum eine konstante Laufzeit bei Zeitlimit ein Warnsignal ist.
- … 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.
- … die Statuswerte verschiedener Solverbibliotheken auf eine gemeinsame Sprache abbilden.
22.3 Die fünf typischen Praxisfallen
Falle 1 — Infeasibility
Wenn Parameter unglücklich zusammentreffen — drei Mitarbeitende gleichzeitig krank, kein qualifizierter Ersatz —, meldet der Solver schlicht INFEASIBLE.
- Falsch: Das System stürzt ab oder zeigt „Fehler“. Der Disponent steht ohne Plan da.
- Richtig: Hierarchische Relaxation. Jede harte Bedingung, die im Notfall gebrochen werden darf, erhält eine teure Schlupfvariable. So liefert der Solver immer einen Plan — und zeigt zugleich präzise, wo es klemmt.
#!/usr/bin/env python3
# Infeasibility_Diagnose.py
"""
Kapitel Praxisfallen: Aus INFEASIBLE einen Notfallplan machen.
Demonstriert die hierarchische Relaxation an einem Dienstplan, der in der
harten Fassung unloesbar ist, und zeigt, wie der Solver die Ursache benennt.
"""
from ortools.sat.python import cp_model
SLOTS = ["Mo frueh", "Mo spaet", "Di frueh", "Di spaet"]
FAECHER = ["Mathematik", "Physik", "Mathematik", "Chemie"] # Chemie kann niemand!
PERSONAL = ["Alice", "Bob", "Carla"]
QUALIFIKATION = {
"Alice": {"Mathematik", "Physik"},
"Bob": {"Mathematik"},
"Carla": {"Physik", "Mathematik"},
}
MAX_PRO_PERSON = 2
STRAFE_UNBESETZT = 10_000 # sehr teuer, aber nicht unmoeglich
STRAFE_UEBERLAST = 500 # teuer, aber billiger als Ausfall
def plane(harte_fassung: bool):
"""harte_fassung=True: klassisch (kann INFEASIBLE werden).
harte_fassung=False: mit Schlupfvariablen (immer loesbar)."""
modell = cp_model.CpModel()
x = {(p, s): modell.NewBoolVar(f"x_{p}_{s}")
for p in PERSONAL for s in range(len(SLOTS))}
strafen = []
unbesetzt = {}
ueberlast = {}
for s in range(len(SLOTS)):
if harte_fassung:
modell.AddExactlyOne(x[p, s] for p in PERSONAL)
else:
# Schlupfvariable: der Slot DARF unbesetzt bleiben - gegen hohe Strafe
unbesetzt[s] = modell.NewBoolVar(f"unbesetzt_{s}")
modell.AddExactlyOne([x[p, s] for p in PERSONAL] + [unbesetzt[s]])
strafen.append(unbesetzt[s] * STRAFE_UNBESETZT)
# Qualifikation bleibt IMMER hart - fachfremder Unterricht ist keine Option
for s, fach in enumerate(FAECHER):
for p in PERSONAL:
if fach not in QUALIFIKATION[p]:
modell.Add(x[p, s] == 0)
for p in PERSONAL:
last = sum(x[p, s] for s in range(len(SLOTS)))
if harte_fassung:
modell.Add(last <= MAX_PRO_PERSON)
else:
# Ueberlast erlaubt - aber teuer
ueberlast[p] = modell.NewIntVar(0, len(SLOTS), f"ueberlast_{p}")
modell.Add(last <= MAX_PRO_PERSON + ueberlast[p])
strafen.append(ueberlast[p] * STRAFE_UEBERLAST)
if strafen:
modell.Minimize(sum(strafen))
loeser = cp_model.CpSolver()
loeser.parameters.max_time_in_seconds = 5.0
status = loeser.Solve(modell)
return loeser, status, x, unbesetzt, ueberlast
if __name__ == "__main__":
print("=" * 78)
print(" VARIANTE A: KLASSISCH MIT LAUTER HARTEN BEDINGUNGEN")
print("=" * 78)
loeser, status, *_ = plane(harte_fassung=True)
print(f"Solver-Status: {loeser.StatusName(status)}")
if status == cp_model.INFEASIBLE:
print("Das System kann keinen Plan liefern. Der Anwender erfaehrt NICHT,")
print("welche Regel das Problem verursacht - nur, dass es nicht geht.\n")
print("=" * 78)
print(" VARIANTE B: MIT HIERARCHISCHER RELAXATION")
print("=" * 78)
loeser, status, x, unbesetzt, ueberlast = plane(harte_fassung=False)
print(f"Solver-Status: {loeser.StatusName(status)} | "
f"Strafkosten: {loeser.ObjectiveValue():.0f}\n")
print(f"{'Slot':<12} {'Fach':<12} {'Zuweisung':<18} {'Bemerkung'}")
print("-" * 78)
for s, slot in enumerate(SLOTS):
if loeser.Value(unbesetzt[s]):
print(f"{slot:<12} {FAECHER[s]:<12} {'-- UNBESETZT --':<18} "
f"Kein qualifiziertes Personal verfuegbar")
else:
person = next(p for p in PERSONAL if loeser.Value(x[p, s]))
print(f"{slot:<12} {FAECHER[s]:<12} {person:<18}")
print("\n--- Diagnose ---")
for s, slot in enumerate(SLOTS):
if loeser.Value(unbesetzt[s]):
fach = FAECHER[s]
qualifiziert = [p for p in PERSONAL if fach in QUALIFIKATION[p]]
print(f" '{slot}' ({fach}): {len(qualifiziert)} qualifizierte Personen "
f"{qualifiziert if qualifiziert else '-> URSACHE: niemand kann dieses Fach'}")
for p in PERSONAL:
if loeser.Value(ueberlast[p]):
print(f" {p} arbeitet {loeser.Value(ueberlast[p])} Stunden ueber der Grenze.")
print("\nDer Anwender bekommt jetzt einen Plan PLUS eine konkrete Ursache -")
print("und kann handeln: Vertretung von aussen holen, Stunde verlegen,")
print("oder die Klasse zusammenlegen.")
print("=" * 78)Falle 2 — Der Black-Box-Effekt
Ein Planer oder Trader lehnt ein System ab, wenn er die Zuweisungen nicht nachvollziehen kann. Akzeptanz ist keine Nebensache, sondern Voraussetzung für den Betrieb.
Jede Entscheidung braucht einen Constraint-Trace:
„Warum bekommt Person A die Stunde und nicht Person B?“ → „Weil B heute bereits zwei Vertretungsstunden hat und bei einer dritten die gesetzliche Ruhezeit von 11 Stunden unterschritten würde. A hat 80 Strafpunkte weniger.“
Bei Portfolioentscheidungen: Kostenzerlegung ausweisen — Alpha-Ertrag, Risikoprämie, Transaktionskostenstrafe (siehe das Muster in Kapitel 7 und Kapitel 20).
🎯 Die Faustregel für Erklärbarkeit Für jede Entscheidung sollten Sie drei Fragen beantworten können: 1. Warum diese Lösung? — Zielfunktionswert und seine Bestandteile. 2. Warum nicht die naheliegende Alternative? — Welche Bedingung verhindert sie? 3. Was würde die Lösung verbessern? — Schattenpreise: welcher Engpass, welcher Wert.
Kapitel 5 liefert für Frage 3 das Werkzeug. Nutzen Sie es — Schattenpreise sind in Managementgesprächen oft wertvoller als die Lösung selbst.
Ein vollständiger Constraint-Trace beantwortet alle drei Fragen der Faustregel für eine einzelne Zuweisung — inklusive einer Unterscheidung, die in der Praxis oft übersehen wird: eine Bedingung kann blockieren, ohne etwas zu kosten.
#!/usr/bin/env python3
# Erklaerbarkeit.py
"""
Kapitel Praxisfallen: Ein Constraint-Trace, der die drei Fragen der Faustregel fuer
Erklaerbarkeit (Abschnitt 'Die fuenf typischen Praxisfallen') fuer eine
einzelne Zuweisung beantwortet:
Warum diese Loesung? Warum nicht die Alternative? Was wuerde sie verbessern?
"""
from ortools.sat.python import cp_model
PERSONAL = ["Anna", "Ben", "Clara"]
BEREITS_STUNDEN = {"Anna": 1, "Ben": 2, "Clara": 0} # diese Woche schon geleistet
MAX_STUNDEN = 2 # gesetzliche Obergrenze
STRAFE_VORBELASTUNG = 50 # pro bereits geleisteter Stunde
STRAFKOSTEN_WUNSCH = {"Anna": 0, "Ben": 30, "Clara": 100}
def loese(max_stunden: int):
modell = cp_model.CpModel()
x = {p: modell.NewBoolVar(f"x_{p}") for p in PERSONAL}
modell.AddExactlyOne(x.values())
kosten = []
for p in PERSONAL:
if BEREITS_STUNDEN[p] + 1 > max_stunden:
modell.Add(x[p] == 0) # hartes Ausschlusskriterium
strafe = BEREITS_STUNDEN[p] * STRAFE_VORBELASTUNG + STRAFKOSTEN_WUNSCH[p]
kosten.append(strafe * x[p])
modell.Minimize(sum(kosten))
loeser = cp_model.CpSolver()
status = loeser.Solve(modell)
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
return None
gewinner = next(p for p in PERSONAL if loeser.Value(x[p]))
return gewinner, loeser.ObjectiveValue()
def gesamtkosten(p: str) -> int:
return BEREITS_STUNDEN[p] * STRAFE_VORBELASTUNG + STRAFKOSTEN_WUNSCH[p]
if __name__ == "__main__":
gewinner, kosten_opt = loese(MAX_STUNDEN)
print("=" * 78)
print(" CONSTRAINT-TRACE: Warum bekommt", gewinner, "die Vertretungsstunde?")
print("=" * 78)
print("\n1. WARUM DIESE LOESUNG?")
v, w = BEREITS_STUNDEN[gewinner] * STRAFE_VORBELASTUNG, STRAFKOSTEN_WUNSCH[gewinner]
print(f" {gewinner}: Vorbelastung {v} Strafpunkte + Wunsch {w} Strafpunkte "
f"= {v + w} Strafpunkte insgesamt (Minimum).")
print("\n2. WARUM NICHT DIE NAHELIEGENDEN ALTERNATIVEN?")
for p in PERSONAL:
if p == gewinner:
continue
if BEREITS_STUNDEN[p] + 1 > MAX_STUNDEN:
print(f" {p}: AUSGESCHLOSSEN - hat bereits {BEREITS_STUNDEN[p]} Stunden, "
f"eine dritte würde die Obergrenze von {MAX_STUNDEN} Stunden verletzen "
f"(harte Bedingung, nicht verhandelbar).")
else:
differenz = gesamtkosten(p) - kosten_opt
print(f" {p}: zulässig, aber {differenz:.0f} Strafpunkte teurer als "
f"{gewinner} ({gesamtkosten(p):.0f} statt {kosten_opt:.0f}).")
print("\n3. WAS WUERDE DIE LOESUNG VERBESSERN?")
gewinner_gelockert, kosten_gelockert = loese(MAX_STUNDEN + 1)
if kosten_gelockert < kosten_opt:
print(f" Obergrenze auf {MAX_STUNDEN + 1} Stunden gelockert: {gewinner_gelockert} "
f"uebernimmt jetzt zu {kosten_gelockert:.0f} Strafpunkten "
f"({kosten_opt - kosten_gelockert:.0f} weniger als heute).")
print(" -> Diese Bedingung hat tatsaechlich einen Preis.")
else:
print(f" Obergrenze auf {MAX_STUNDEN + 1} Stunden gelockert: Ergebnis bleibt bei "
f"{gewinner} mit {kosten_gelockert:.0f} Strafpunkten (unveraendert).")
blockiert = ", ".join(p for p in PERSONAL
if BEREITS_STUNDEN[p] + 1 > MAX_STUNDEN and p != gewinner)
print(f" -> Die Obergrenze blockiert zwar {blockiert}, kostet aber gerade NICHTS:")
print(" Selbst ohne sie waere die Loesung dieselbe.")
print(" Blockierend und kostenrelevant sind zwei verschiedene Dinge.")
print("=" * 78)Erwartete Ausgabe:
==============================================================================
CONSTRAINT-TRACE: Warum bekommt Anna die Vertretungsstunde?
==============================================================================
1. WARUM DIESE LOESUNG?
Anna: Vorbelastung 50 Strafpunkte + Wunsch 0 Strafpunkte = 50 Strafpunkte insgesamt (Minimum).
2. WARUM NICHT DIE NAHELIEGENDEN ALTERNATIVEN?
Ben: AUSGESCHLOSSEN - hat bereits 2 Stunden, eine dritte würde die Obergrenze von 2 Stunden verletzen (harte Bedingung, nicht verhandelbar).
Clara: zulässig, aber 50 Strafpunkte teurer als Anna (100 statt 50).
3. WAS WUERDE DIE LOESUNG VERBESSERN?
Obergrenze auf 3 Stunden gelockert: Ergebnis bleibt bei Anna mit 50 Strafpunkten (unveraendert).
-> Die Obergrenze blockiert zwar Ben, kostet aber gerade NICHTS:
Selbst ohne sie waere die Loesung dieselbe.
Blockierend und kostenrelevant sind zwei verschiedene Dinge.
==============================================================================
Der letzte Punkt ist der eigentliche Lerneffekt: Die Obergrenze schließt Ben zwar technisch aus, aber selbst wenn er dürfte, wäre er wegen seiner Vorbelastung immer noch teurer als Anna. Eine bindende Bedingung zu lockern hilft nur, wenn sie tatsächlich die kostenrelevante ist — sonst ist die Mühe umsonst.
Falle 3 — Regimewechsel und Schätzfehler
Ein Modell, das auf einer mehrjährigen Aufwärtsphase kalibriert wurde, kennt keine Liquiditätskrise und keine Zinswende.
- Robuste Schätzung statt roher Historie: Ledoit-Wolf-Shrinkage (Kapitel 18), Szenarien (Kapitel 12).
- Harte Obergrenzen für Einzelpositionen und Sektoren — auch dann, wenn der Solver rechnerisch alles in einen Titel legen möchte. Die Grenze kostet Ertrag im Normalfall und rettet im Ernstfall.
- Rollierende Neuschätzung statt einmaliger Kalibrierung.
Falle 4 — Lookahead- und Survivorship-Bias
Ausführlich in Kapitel 21 behandelt. Die Kurzfassung: Beide Fehler machen Backtests systematisch zu gut, ohne eine Fehlermeldung zu erzeugen. Der Lookahead-Selbsttest aus Abschnitt 21.5 findet den ersten automatisch.
Falle 5 — Laufzeitexplosion
Exakte Optimalität kann bei MILP oder CP-SAT Minuten bis Stunden dauern — und die Laufzeit wächst nicht linear mit der Problemgröße.
| Einsatzszenario | Empfohlenes Zeitlimit | Akzeptabler MIP-Gap |
|---|---|---|
| Interaktive Oberfläche | 5–10 Sekunden | 2–5 % |
| Batch tagsüber | 1–5 Minuten | 1–2 % |
| Nachtlauf | 30–60 Minuten | 0,1–1 % |
| Strategische Planung | Stunden | exakt anstreben |
💡 Warum ein Gap von 2 % fast immer genügt Der MIP-Gap misst den Abstand zwischen der besten gefundenen Lösung und der besten beweisbaren Schranke. Ein Gap von 2 % heißt: „Diese Lösung ist höchstens 2 % vom theoretischen Optimum entfernt.“
Dem gegenüber steht die Datenunsicherheit: Ihre Nachfrageprognose ist um ±10 % genau, Ihre Fahrzeiten um ±15 %. Die letzten 2 % Optimalität in einem Modell zu erkämpfen, dessen Eingangsdaten um 10 % schwanken, ist verlorene Zeit. Optimieren Sie nicht genauer, als Ihre Daten sind.
22.4 Architektur einer produktionsreifen OR-Plattform
In einer professionellen Umgebung ist der Optimierer kein Skript, sondern ein versionierter, zustandsloser Dienst:
| Schicht | Aufgabe | Typische Werkzeuge |
|---|---|---|
| Quellsysteme | ERP, Personalplanung, Marktdaten-Feeds | REST, Datenbanken, Broker-APIs |
| Datenqualität | Validierung, Plausibilität, unveränderlicher Snapshot | Pydantic, Pandera, Great Expectations |
| Optimierungs-Worker | Modell bauen, lösen, Timeout, Fallback | Celery/Redis, OR-Tools, HiGHS, CVXPY |
| Disposition | Ergebnis prüfen, ändern, freigeben | Web-UI, Dashboard, Freigabeworkflow |
| Ausführung | Kalender-Sync, Benachrichtigung, Order-Routing | Kalender-APIs, Messaging, Broker |
| Betrieb | Monitoring, Audit-Trail, Alarme | Logging, Metriken, Versionierung |
Fünf Prinzipien, die sich in der Praxis bewährt haben:
- Snapshot-Prinzip. Jeder Optimierungslauf arbeitet auf einem unveränderlichen Datenschnappschuss mit eigener ID. Nur so ist ein Ergebnis später reproduzierbar — und im Streitfall belegbar.
- Zustandslosigkeit. Der Worker hält keinen Zustand; Eingabe und Ausgabe sind Daten. Das macht Skalierung und Wiederholung trivial.
- Mensch in der Schleife. Der Optimierer schlägt vor, ein Mensch entscheidet — zumindest in der Einführungsphase. Das baut Vertrauen auf und fängt Modellfehler ab.
- Fallback-Strategie. Was passiert, wenn der Solver kein Ergebnis liefert? Antwort: letzter gültiger Plan, regelbasierte Notlösung, Alarm. Nie: nichts.
- Versionierung. Modellversion, Parametersatz und Solver-Version gehören ins Protokoll jedes Laufs. Ohne das lässt sich nicht klären, warum das Ergebnis von letzter Woche anders aussah.
⚠️ Der häufigste Projektfehler Nicht das Modell scheitert, sondern die Einführung. Ein technisch überlegener Plan, den die Disponentin nicht versteht und dem sie nicht traut, wird umgangen — sie plant weiter in ihrer Tabelle. Rechnen Sie mindestens so viel Zeit für Erklärbarkeit, Schulung und schrittweise Einführung ein wie für das Modell selbst.
22.5 Der gemeinsame Unterbau: or_kern.py
Die fünf Prinzipien oben beschreiben eine Plattform. Der Weg dorthin beginnt aber viel kleiner — mit der Frage, welche Teile eines Optimierungsprogramms immer dieselben sind.
Sehen Sie sich die Programme dieses Buchs an: Jedes liest Daten ein, baut ein Modell, wertet einen Solverstatus aus und prüft das Ergebnis. Nur der mittlere Schritt ist wirklich problemspezifisch. Die anderen drei schreibt man in jedem Projekt neu — und macht dabei jedes Mal dieselben Fehler.
Die vier Schichten
\underbrace{\text{Rohdaten}}_{\text{Excel, CSV, ERP}} \;\longrightarrow\; \underbrace{\text{Domänenmodell}}_{\text{geprüft, solverfrei}} \;\longrightarrow\; \underbrace{\text{Modellbauer}}_{\text{solverabhängig}} \;\longrightarrow\; \underbrace{\text{Lösung}}_{\text{DTO, solverfrei}}
| Schicht | Weiß nichts von … | Gewinn |
|---|---|---|
| Domänenmodell | Solvern, Matrizen, Variablen | Datenfehler schlagen beim Einlesen zu, wo man sie noch zuordnen kann |
| Modellbauer | Datenherkunft, Berichtsformat | Solverwechsel betrifft genau eine Datei |
| Lösung (DTO) | wie gerechnet wurde | Auswertung, Test und Bericht funktionieren für jeden Solver gleich |
| Abnahmeprüfung | Modell und Solver | prüft gegen die Anforderung, nicht gegen sich selbst |
🎯 Merksatz Die Trennung lohnt sich nicht wegen des Solverwechsels — den macht man selten. Sie lohnt sich, weil jede Schicht dadurch einzeln prüfbar wird. Das Domänenmodell testet man ohne Solver, die Abnahmeprüfung ohne Modell, den Bericht ohne Daten.
Ein Enum für fünf Bibliotheken
Der unscheinbarste, aber wirksamste Teil ist die Statusübersetzung. Dieselbe Aussage heißt in den fünf im Buch verwendeten Bibliotheken:
| Bedeutung | pywraplp | CP-SAT | HiGHS | SciPy | CVXPY |
|---|---|---|---|---|---|
| beweisbar optimal | Solver.OPTIMAL |
cp_model.OPTIMAL |
"Optimal" |
status == 0 |
"optimal" |
| zulässig, nicht bewiesen | Solver.FEASIBLE |
cp_model.FEASIBLE |
"Time limit reached" |
— | "optimal_inaccurate" |
| es gibt keine Lösung | Solver.INFEASIBLE |
cp_model.INFEASIBLE |
"Infeasible" |
status == 2 |
"infeasible" |
| Modell ist fehlerhaft | Solver.ABNORMAL |
cp_model.MODEL_INVALID |
"Model error" |
status == 4 |
— |
Fünf Schreibweisen für dieselben vier Aussagen. Wer direkt darauf prüft, bindet seinen gesamten Auswertungscode an eine Bibliothek — und muss ihn beim Wechsel überall anfassen.
⚠️ Eine Besonderheit beim Import
or_kern.pyimportiert von sich aus keine Solverbibliothek. Die Statusübersetzer laden ihre Bibliothek erst beim Aufruf. Das ist kein Stilmittel, sondern notwendig:ortoolsundhighspyvertragen sich nicht im selben Prozess (Abschnitt 3.5). Ein Modul, das beide importierte, wäre mit keinem von beiden benutzbar.
#!/usr/bin/env python3
# or_kern.py
"""
Kapitel Praxisfallen: Der gemeinsame Unterbau fuer produktionsreife OR-Modelle.
Alle Beispiele dieses Buchs loesen dieselben vier Aufgaben immer wieder:
Daten einlesen und pruefen, ein Modell bauen, den Solverstatus auswerten, die
Loesung gegen die Wirklichkeit kontrollieren. Dieses Modul zieht diese vier
Aufgaben aus den Einzelprogrammen heraus.
Rohdaten (Excel/CSV)
-> Domaenenmodell (Pydantic, geprueft)
-> Modellbauer (solverabhaengig)
-> Loesung (DTO, solverunabhaengig)
-> Abnahmepruefung
Der Sinn dieser Trennung: Die Domaenenschicht weiss nichts von Solvern, die
Loesungsschicht weiss nichts vom Modellaufbau. Wer den Solver wechselt, tauscht
genau EINEN Baustein aus - der Rest bleibt.
WICHTIG ZUM IMPORT: Dieses Modul importiert von sich aus KEINE Solver-
bibliothek. Der Grund steht im Kapitel Oekosystem: ortools und highspy vertragen sich
nicht im selben Prozess. Die Statusuebersetzer laden ihre Bibliothek erst,
wenn sie aufgerufen werden - so bleibt or_kern.py mit jedem Solver benutzbar.
Benoetigt: pydantic (v2), numpy; optional pandas und openpyxl fuer Excel.
"""
from __future__ import annotations
from enum import Enum
from typing import Annotated, Any, Sequence
import numpy as np
from pydantic import BaseModel, Field, model_validator
# Wiederverwendbare Feldtypen. Sie tragen die Pruefung im Typ, nicht im Code -
# damit gilt sie ueberall, wo der Typ verwendet wird.
PositiveZahl = Annotated[float, Field(gt=0)]
NichtNegativ = Annotated[float, Field(ge=0)]
# --- 1. Solverstatus: eine Sprache fuer fuenf Bibliotheken -------------------
class SolverStatus(str, Enum):
"""Was ein Solverlauf ergeben hat - unabhaengig davon, wer gerechnet hat.
Die fuenf im Buch verwendeten Bibliotheken benennen dasselbe Ergebnis
unterschiedlich: 'Optimal', 'OPTIMAL', 2, 'optimal'. Wer darauf direkt
prueft, bindet seinen Auswertungscode an eine Bibliothek.
"""
OPTIMAL = "optimal" # beweisbar bestmoeglich
ZULAESSIG = "zulaessig" # brauchbare Loesung, Beweis fehlt
UNZULAESSIG = "unzulaessig" # es gibt keine Loesung (Aussage ueber das Modell)
UNBESCHRAENKT = "unbeschraenkt" # Ziel waechst ins Unendliche
ZEITLIMIT = "zeitlimit" # abgebrochen, nichts Brauchbares gefunden
FEHLERHAFT = "fehlerhaft" # das Modell ist kein gueltiges Modell
UNBEKANNT = "unbekannt" # alles Uebrige
@property
def brauchbar(self) -> bool:
"""Habe ich etwas in der Hand, das ich ausfuehren kann?"""
return self in (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG)
@property
def modellfehler(self) -> bool:
"""Liegt die Ursache im Modell (und nicht in der Rechenzeit)?"""
return self in (SolverStatus.UNZULAESSIG, SolverStatus.UNBESCHRAENKT,
SolverStatus.FEHLERHAFT)
def status_von_pywraplp(rohstatus: int) -> SolverStatus:
"""OR-Tools linear_solver (GLOP, SCIP, CBC)."""
from ortools.linear_solver import pywraplp
zuordnung = {
pywraplp.Solver.OPTIMAL: SolverStatus.OPTIMAL,
pywraplp.Solver.FEASIBLE: SolverStatus.ZULAESSIG,
pywraplp.Solver.INFEASIBLE: SolverStatus.UNZULAESSIG,
pywraplp.Solver.UNBOUNDED: SolverStatus.UNBESCHRAENKT,
pywraplp.Solver.ABNORMAL: SolverStatus.FEHLERHAFT,
pywraplp.Solver.NOT_SOLVED: SolverStatus.ZEITLIMIT,
}
return zuordnung.get(rohstatus, SolverStatus.UNBEKANNT)
def status_von_cpsat(rohstatus: int) -> SolverStatus:
"""OR-Tools CP-SAT (siehe Kapitel CP-SAT, alle fuenf Faelle)."""
from ortools.sat.python import cp_model
zuordnung = {
cp_model.OPTIMAL: SolverStatus.OPTIMAL,
cp_model.FEASIBLE: SolverStatus.ZULAESSIG,
cp_model.INFEASIBLE: SolverStatus.UNZULAESSIG,
cp_model.MODEL_INVALID: SolverStatus.FEHLERHAFT,
cp_model.UNKNOWN: SolverStatus.ZEITLIMIT,
}
return zuordnung.get(rohstatus, SolverStatus.UNBEKANNT)
def status_von_highs(statustext: str) -> SolverStatus:
"""HiGHS ueber highspy - hier kommt der Status als Text."""
zuordnung = {
"Optimal": SolverStatus.OPTIMAL,
"Time limit reached": SolverStatus.ZULAESSIG, # meist mit Loesung
"Solution limit reached": SolverStatus.ZULAESSIG,
"Infeasible": SolverStatus.UNZULAESSIG,
"Unbounded": SolverStatus.UNBESCHRAENKT,
"Primal infeasible or unbounded": SolverStatus.UNZULAESSIG,
"Model error": SolverStatus.FEHLERHAFT,
}
return zuordnung.get(statustext, SolverStatus.UNBEKANNT)
def status_von_scipy(ergebnis: Any) -> SolverStatus:
"""scipy.optimize.linprog und milp liefern ein Ergebnisobjekt mit .status."""
zuordnung = {
0: SolverStatus.OPTIMAL,
1: SolverStatus.ZEITLIMIT, # Iterations-/Zeitgrenze
2: SolverStatus.UNZULAESSIG,
3: SolverStatus.UNBESCHRAENKT,
4: SolverStatus.FEHLERHAFT, # numerische Schwierigkeiten
}
return zuordnung.get(int(ergebnis.status), SolverStatus.UNBEKANNT)
def status_von_cvxpy(statustext: str) -> SolverStatus:
"""CVXPY - Statuszeichenketten wie 'optimal' oder 'infeasible'."""
zuordnung = {
"optimal": SolverStatus.OPTIMAL,
"optimal_inaccurate": SolverStatus.ZULAESSIG,
"infeasible": SolverStatus.UNZULAESSIG,
"infeasible_inaccurate": SolverStatus.UNZULAESSIG,
"unbounded": SolverStatus.UNBESCHRAENKT,
"unbounded_inaccurate": SolverStatus.UNBESCHRAENKT,
}
return zuordnung.get(statustext, SolverStatus.UNBEKANNT)
# --- 2. Die Loesung als solverunabhaengiges Datenobjekt ----------------------
class Loesung(BaseModel):
"""Das Ergebnis eines Solverlaufs, so wie es weiterverarbeitet wird.
Bewusst OHNE Referenz auf Solver, Modell oder Variablen: Ein Bericht, ein
Test oder eine Weiterverarbeitung soll nicht wissen muessen, womit
gerechnet wurde.
"""
status: SolverStatus
werte: dict[str, float] = Field(default_factory=dict)
zielwert: float | None = None
schranke: float | None = None
laufzeit: float | None = None
# Schattenpreise je Nebenbedingung, sofern der Solver sie liefert
# (nur bei kontinuierlichen Problemen, siehe Kapitel LP). Auch das ist
# ein solverunabhaengiger Begriff und gehoert deshalb hierher.
schattenpreise: dict[str, float] = Field(default_factory=dict)
@property
def gap(self) -> float | None:
"""Relativer Abstand zwischen gefundener Loesung und bewiesener Schranke.
Das ist die Zahl fuer den Bericht: keine Schaetzung, sondern eine
Zusage - schlechter als das kann die Loesung nicht sein.
"""
if self.zielwert is None or self.schranke is None:
return None
nenner = max(abs(self.zielwert), 1e-9)
return abs(self.zielwert - self.schranke) / nenner
def als_bericht(self) -> str:
"""Eine Zeile fuers Betriebsprotokoll - siehe Betriebsueberwachung.py."""
teile = [f"Status: {self.status.value}"]
if self.zielwert is not None:
teile.append(f"Zielwert: {self.zielwert:,.2f}")
if self.gap is not None:
teile.append(f"Gap: {self.gap:.2%}")
if self.laufzeit is not None:
teile.append(f"Zeit: {self.laufzeit:.2f}s")
return " | ".join(teile)
# --- 3. Domaenenmodell: die Fakten, bevor ein Solver sie sieht ---------------
class Produkt(BaseModel):
"""Ein Produkt mit Deckungsbeitrag und Ressourcenverbrauch."""
name: str = Field(min_length=1)
deckungsbeitrag: float
verbrauch: dict[str, NichtNegativ]
model_config = {"frozen": True} # Stammdaten aendern sich nicht im Lauf
class Produktionsproblem(BaseModel):
"""Produktionsprogrammplanung - das Modell aus Kapitel Einfuehrung.
Die Pruefungen stehen hier und nicht im Solvercode. Damit gelten sie
unabhaengig davon, mit welcher Bibliothek spaeter gerechnet wird - und sie
schlagen beim EINLESEN zu, wo der Fehler noch zuzuordnen ist.
"""
produkte: list[Produkt] = Field(min_length=1)
kapazitaeten: dict[str, PositiveZahl]
@model_validator(mode="after")
def pruefe_ressourcen(self) -> "Produktionsproblem":
namen = [p.name for p in self.produkte]
if len(set(namen)) != len(namen):
doppelt = sorted({n for n in namen if namen.count(n) > 1})
raise ValueError(f"Produktnamen kommen mehrfach vor: {doppelt}")
for produkt in self.produkte:
unbekannt = set(produkt.verbrauch) - set(self.kapazitaeten)
if unbekannt:
raise ValueError(
f"Produkt '{produkt.name}' verbraucht Ressourcen ohne "
f"Kapazitaetsangabe: {sorted(unbekannt)}")
return self
@property
def ressourcen(self) -> list[str]:
"""Feste Reihenfolge - siehe die Spaltenfalle in Kapitel Finanzdaten."""
return sorted(self.kapazitaeten)
def verbrauchsmatrix(self) -> np.ndarray:
"""Zeilen = Ressourcen, Spalten = Produkte (Matrixform, Kapitel Fundament)."""
return np.array([[p.verbrauch.get(r, 0.0) for p in self.produkte]
for r in self.ressourcen])
def kapazitaetsvektor(self) -> np.ndarray:
return np.array([self.kapazitaeten[r] for r in self.ressourcen])
def deckungsbeitragsvektor(self) -> np.ndarray:
return np.array([p.deckungsbeitrag for p in self.produkte])
# --- 4. Abnahmepruefung: Loesung gegen Anforderung, ohne Solver --------------
def pruefe_loesung(problem: Produktionsproblem, loesung: Loesung,
ganzzahlig: Sequence[str] = (),
toleranz: float = 1e-6) -> list[str]:
"""Prueft eine Loesung gegen die Anforderungen - OHNE den Solver zu fragen.
Diese Funktion darf ausdruecklich KEIN Solverobjekt und keine
Modellvariable benutzen. Eine Pruefung aus denselben Bausteinen wie das
Modell prueft das Modell gegen sich selbst und findet nichts (siehe die
Denkfehler in den Kapiteln Graphen und MILP).
Liefert eine Liste von Beanstandungen; leer heisst bestanden.
"""
beanstandungen: list[str] = []
if not loesung.status.brauchbar:
return [f"Kein verwertbares Ergebnis (Status: {loesung.status.value})"]
fehlend = [p.name for p in problem.produkte if p.name not in loesung.werte]
if fehlend:
return [f"Loesung enthaelt keine Werte fuer: {fehlend}"]
mengen = np.array([loesung.werte[p.name] for p in problem.produkte])
if (mengen < -toleranz).any():
negativ = [p.name for p, m in zip(problem.produkte, mengen) if m < -toleranz]
beanstandungen.append(f"negative Mengen bei: {negativ}")
verbrauch = problem.verbrauchsmatrix() @ mengen
for ressource, ist, grenze in zip(problem.ressourcen, verbrauch,
problem.kapazitaetsvektor()):
if ist > grenze + toleranz:
beanstandungen.append(
f"{ressource}: Verbrauch {ist:,.3f} ueber Kapazitaet {grenze:,.3f}")
for name in ganzzahlig:
wert = loesung.werte[name]
if abs(wert - round(wert)) > toleranz:
beanstandungen.append(f"'{name}' = {wert!r} ist nicht ganzzahlig")
if loesung.zielwert is not None:
nachgerechnet = float(problem.deckungsbeitragsvektor() @ mengen)
if abs(nachgerechnet - loesung.zielwert) > toleranz * max(1.0, abs(nachgerechnet)):
beanstandungen.append(
f"Zielwert {loesung.zielwert:,.4f} passt nicht zu den Mengen "
f"(nachgerechnet {nachgerechnet:,.4f})")
return beanstandungen
# --- 5. Ein Modellbauer je Solver - austauschbar ----------------------------
def loese_mit_glop(problem: Produktionsproblem) -> Loesung:
"""Modellbauer fuer OR-Tools GLOP (kontinuierlich)."""
import time
from ortools.linear_solver import pywraplp
solver = pywraplp.Solver.CreateSolver("GLOP")
menge = {p.name: solver.NumVar(0, solver.infinity(), p.name)
for p in problem.produkte}
# Die Nebenbedingung wird gemerkt - ohne die Referenz gibt es spaeter
# keinen Schattenpreis (siehe Kapitel LP).
bedingung = {}
for ressource in problem.ressourcen:
bedingung[ressource] = solver.Add(
sum(menge[p.name] * p.verbrauch.get(ressource, 0.0)
for p in problem.produkte)
<= problem.kapazitaeten[ressource], name=ressource)
solver.Maximize(sum(menge[p.name] * p.deckungsbeitrag for p in problem.produkte))
t0 = time.perf_counter()
rohstatus = solver.Solve()
laufzeit = time.perf_counter() - t0
status = status_von_pywraplp(rohstatus)
if not status.brauchbar:
return Loesung(status=status, laufzeit=laufzeit)
return Loesung(status=status,
werte={name: v.solution_value() for name, v in menge.items()},
zielwert=solver.Objective().Value(),
schranke=solver.Objective().Value(),
schattenpreise={r: b.dual_value() for r, b in bedingung.items()},
laufzeit=laufzeit)
def loese_mit_scipy(problem: Produktionsproblem) -> Loesung:
"""Derselbe Fall mit scipy.optimize.linprog - anderer Solver, gleiche Loesung.
Beachten Sie, wie wenig hier steht: Die Daten kommen fertig geprueft aus
dem Domaenenmodell, die Auswertung geht ans gemeinsame Loesung-Objekt.
Genau das ist der Ertrag der Trennung.
"""
import time
from scipy.optimize import linprog
t0 = time.perf_counter()
ergebnis = linprog(c=-problem.deckungsbeitragsvektor(), # linprog minimiert
A_ub=problem.verbrauchsmatrix(),
b_ub=problem.kapazitaetsvektor(),
bounds=(0, None), method="highs")
laufzeit = time.perf_counter() - t0
status = status_von_scipy(ergebnis)
if not status.brauchbar:
return Loesung(status=status, laufzeit=laufzeit)
# linprog rechnet minimierend mit negierten Kosten - die Dualwerte
# muessen entsprechend zurueckgedreht werden.
return Loesung(status=status,
werte={p.name: float(x)
for p, x in zip(problem.produkte, ergebnis.x)},
zielwert=float(-ergebnis.fun),
schranke=float(-ergebnis.fun),
schattenpreise={r: float(-m) for r, m in
zip(problem.ressourcen,
ergebnis.ineqlin.marginals)},
laufzeit=laufzeit)
# --- 6. Ein- und Ausgabe: Tabellen sind die Wirklichkeit ---------------------
#
# In den meisten Betrieben liegen die Zahlen in einer Tabellenkalkulation
# (Kapitel Einfuehrung). Diese beiden Funktionen sind die einzige Stelle im
# Modul, die das weiss - alles andere arbeitet mit dem Domaenenmodell.
#
# pandas und openpyxl werden bewusst erst hier importiert: Wer or_kern nur
# fuer Statusauswertung und Pruefung benutzt, soll sie nicht installieren
# muessen.
STAMMSPALTEN = ("Produkt", "Deckungsbeitrag")
def lade_produktionsproblem(pfad: str, blatt_produkte: str = "Produkte",
blatt_kapazitaeten: str = "Kapazitaeten"
) -> Produktionsproblem:
"""Liest eine Excel-Mappe und liefert ein geprueftes Domaenenmodell.
Alle Spalten ausser 'Produkt' und 'Deckungsbeitrag' gelten als
Verbrauchsspalten - eine neue Ressource ist damit eine neue SPALTE in der
Mappe und erfordert keine Codeaenderung.
Die eigentliche Pruefung passiert nicht hier, sondern im Konstruktor von
Produktionsproblem. Diese Funktion uebersetzt nur Tabelle in Objekt; was
ein gueltiges Problem ist, steht an genau einer Stelle.
"""
import pandas as pd
produkte_tabelle = pd.read_excel(pfad, sheet_name=blatt_produkte)
kapazitaeten_tabelle = pd.read_excel(pfad, sheet_name=blatt_kapazitaeten)
fehlende = [s for s in STAMMSPALTEN if s not in produkte_tabelle.columns]
if fehlende:
raise ValueError(f"Blatt '{blatt_produkte}': Spalten fehlen: {fehlende}")
if produkte_tabelle.isna().any().any():
zeilen = produkte_tabelle[produkte_tabelle.isna().any(axis=1)].index.tolist()
raise ValueError(f"Blatt '{blatt_produkte}': leere Zellen in Zeile(n) {zeilen}")
ressourcen = [s for s in produkte_tabelle.columns if s not in STAMMSPALTEN]
produkte = [
Produkt(name=str(zeile.Produkt),
deckungsbeitrag=float(zeile.Deckungsbeitrag),
verbrauch={r: float(getattr(zeile, r)) for r in ressourcen})
for zeile in produkte_tabelle.itertuples()
]
kapazitaeten = {str(z.Ressource): float(z.Verfuegbar)
for z in kapazitaeten_tabelle.itertuples()}
return Produktionsproblem(produkte=produkte, kapazitaeten=kapazitaeten)
def schreibe_ergebnis(pfad: str, problem: Produktionsproblem,
loesung: Loesung) -> None:
"""Schreibt Plan und Kennzahlen als zwei Blaetter zurueck nach Excel.
Die Fachabteilung bekommt das Ergebnis im Format, das sie ohnehin benutzt.
Akzeptanz entscheidet ueber Projekterfolg (Kapitel Praxisfallen).
"""
import pandas as pd
if not loesung.status.brauchbar:
raise ValueError(f"Nichts zu schreiben - Status: {loesung.status.value}")
mengen = np.array([loesung.werte[p.name] for p in problem.produkte])
plan = pd.DataFrame({
"Produkt": [p.name for p in problem.produkte],
"Menge": mengen,
"Deckungsbeitrag": mengen * problem.deckungsbeitragsvektor(),
})
verbrauch = problem.verbrauchsmatrix() @ mengen
kennzahlen: dict[str, float | str] = {
"Status": loesung.status.value,
"Gesamtdeckungsbeitrag": loesung.zielwert or 0.0,
}
for ressource, ist, grenze in zip(problem.ressourcen, verbrauch,
problem.kapazitaetsvektor()):
kennzahlen[f"{ressource}: Verbrauch"] = float(ist)
kennzahlen[f"{ressource}: Auslastung %"] = float(100.0 * ist / grenze)
if ressource in loesung.schattenpreise:
kennzahlen[f"{ressource}: Schattenpreis"] = \
loesung.schattenpreise[ressource]
kennzahlen_tabelle = pd.DataFrame({
"Kennzahl": list(kennzahlen),
"Wert": [round(w, 2) if isinstance(w, float) else w
for w in kennzahlen.values()],
})
with pd.ExcelWriter(pfad, engine="openpyxl") as mappe:
plan.round(2).to_excel(mappe, sheet_name="Plan", index=False)
kennzahlen_tabelle.to_excel(mappe, sheet_name="Kennzahlen", index=False)
if __name__ == "__main__":
# Die Schreinerei aus Kapitel Einfuehrung - jetzt als Domaenenmodell.
schreinerei = Produktionsproblem(
produkte=[
Produkt(name="Tisch", deckungsbeitrag=240.0,
verbrauch={"Montagestunden": 3.0, "Plattenmaterial": 6.0}),
Produkt(name="Stuhl", deckungsbeitrag=60.0,
verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0}),
],
kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0},
)
print("=" * 78)
print(" DERSELBE FALL, ZWEI SOLVER, EIN AUSWERTUNGSCODE")
print("=" * 78)
for name, bauer in [("OR-Tools GLOP", loese_mit_glop),
("scipy / HiGHS", loese_mit_scipy)]:
loesung = bauer(schreinerei)
beanstandungen = pruefe_loesung(schreinerei, loesung)
print(f"\n{name}")
print(f" {loesung.als_bericht()}")
for produkt, menge in loesung.werte.items():
print(f" {produkt:<10} {menge:8.2f}")
print(f" Abnahmepruefung: "
f"{'bestanden' if not beanstandungen else beanstandungen}")
print("\n" + "=" * 78)
print(" WAS DIE PRUEFUNGEN ABFANGEN")
print("=" * 78)
faelle = [
("Kapazitaet 0", dict(
produkte=schreinerei.produkte,
kapazitaeten={"Montagestunden": 0.0, "Plattenmaterial": 240.0})),
("Ressource ohne Kapazitaet", dict(
produkte=[Produkt(name="Regal", deckungsbeitrag=130.0,
verbrauch={"Lackieren": 2.0})],
kapazitaeten={"Montagestunden": 150.0})),
("doppelter Produktname", dict(
produkte=[schreinerei.produkte[0], schreinerei.produkte[0]],
kapazitaeten=schreinerei.kapazitaeten)),
]
for beschreibung, daten in faelle:
try:
Produktionsproblem(**daten)
print(f" {beschreibung:<28} NICHT erkannt (!)")
except Exception as fehler:
meldung = str(fehler).splitlines()
kern = next((z.strip() for z in meldung
if "Value error" in z or "greater than" in z), meldung[-1])
print(f" {beschreibung:<28} abgefangen: {kern[:44]}")
print("\nAlle drei scheitern beim EINLESEN - nicht erst beim Loesen und")
print("schon gar nicht erst im Bericht. Das ist der ganze Zweck der")
print("Domaenenschicht.")
print("=" * 78)Erwartete Ausgabe:
==============================================================================
DERSELBE FALL, ZWEI SOLVER, EIN AUSWERTUNGSCODE
==============================================================================
OR-Tools GLOP
Status: optimal | Zielwert: 10,800.00 | Gap: 0.00% | Zeit: 0.00s
Tisch 30.00
Stuhl 60.00
Abnahmepruefung: bestanden
scipy / HiGHS
Status: optimal | Zielwert: 10,800.00 | Gap: 0.00% | Zeit: 0.00s
Tisch 30.00
Stuhl 60.00
Abnahmepruefung: bestanden
==============================================================================
WAS DIE PRUEFUNGEN ABFANGEN
==============================================================================
Kapazitaet 0 abgefangen: Input should be greater than 0 [type=greater
Ressource ohne Kapazitaet abgefangen: Value error, Produkt 'Regal' verbraucht Ress
doppelter Produktname abgefangen: Value error, Produktnamen kommen mehrfach vo
Alle drei scheitern beim EINLESEN - nicht erst beim Loesen und
schon gar nicht erst im Bericht. Das ist der ganze Zweck der
Domaenenschicht.
==============================================================================
💻 Code-Durchgang
Stelle Was passiert Warum so PositiveZahl = Annotated[float, Field(gt=0)]Prüfung im Typ, nicht im Code Sie gilt damit überall, wo der Typ verwendet wird — man kann sie nicht vergessen. SolverStatus.brauchbarfragt „habe ich etwas in der Hand?“ Nicht „war der Solver erfolgreich?“. ZULAESSIGist im Betrieb meist völlig ausreichend (Abschnitt 6.8).SolverStatus.modellfehlertrennt Modell- von Zeitproblemen Genau die Unterscheidung, die Abschnitt 7.7 als häufigste Verwechslung benennt. model_validator(mode="after")prüft Zusammenhänge zwischen Feldern Einzelne Felder prüft Pydantic selbst; dass jedes Produkt nur bekannte Ressourcen verbraucht, muss man sagen. ressourcenals sortierte Listefeste Spaltenreihenfolge Die Vertauschungsfalle aus Kapitel 18 wird hier strukturell unmöglich. loese_mit_glop/loese_mit_scipyzwei Modellbauer, ein Auswertungscode Der Solverwechsel betrifft eine Funktion. Alles danach — Bericht, Prüfung, Test — bleibt unverändert. die drei Fehlerfälle am Ende Kapazität 0, unbekannte Ressource, doppelter Name Alle drei scheitern beim Einlesen. Ohne Domänenschicht fielen sie erst beim Lösen auf — oder gar nicht. import pandasin der FunktionExcel-Schicht als optionale Abhängigkeit Wer or_kernnur für Statusauswertung und Prüfung benutzt, soll pandas nicht installieren müssen.schattenpreiseim DTODualwerte gehören zur Lösung, nicht zum Solver Beide Modellbauer liefern hier identische Werte (40 / 20) — der Begriff ist solverunabhängig, die Abfrage nicht.
🎯 Merksatz Ein Fehler, der beim Einlesen auffliegt, kostet Minuten. Derselbe Fehler, der erst im Bericht auffällt, kostet Tage — und derselbe Fehler, der nie auffällt, kostet am meisten. Validierung ist deshalb keine Fleißarbeit, sondern die günstigste Stelle im ganzen Ablauf.
22.6 Der Solverwechsel in der Praxis
Der vorige Abschnitt endet mit einer Behauptung: Ein Solverwechsel betreffe genau einen Baustein. Das ist die Art Satz, die in jedem Architekturvortrag vorkommt und die man deshalb nicht glauben sollte, bevor sie jemand vorgeführt hat.
Also führen wir sie vor — und zwar am unbequemsten Paar, das dieses Buch zu bieten hat: CP-SAT gegen HiGHS. Die beiden sind grundverschieden. CP-SAT ist ein Constraint-Programming-Solver, der ausschließlich ganzzahlig rechnet und Restriktionen als Ausdrücke entgegennimmt. HiGHS ist ein klassischer Branch-and-Bound-Löser, der eine Koeffizientenmatrix erwartet. Und sie lassen sich nicht einmal gemeinsam importieren (Kapitel 3).
Die Aufgabe ist eine kapazitierte Standortplanung: Aus sechs möglichen Lagern sind diejenigen zu eröffnen, mit denen zwölf Kunden am günstigsten beliefert werden. Jeder Kunde wird von genau einem Lager bedient, jedes eröffnete Lager kostet eine Fixgebühr, und die Kapazität reicht je Lager für 174 der insgesamt 387 Paletten — es müssen also mindestens drei Lager öffnen.
Der Aufbau folgt dem Muster aus or_kern.py:
| Baustein | Solverabhängig? | Zeilen |
|---|---|---|
Standortproblem (Pydantic-Domänenmodell) |
nein | 38 |
baue_und_loese_mit_cpsat() |
ja | 39 |
baue_und_loese_mit_highs() |
ja | 52 |
pruefe_zuordnung() (Abnahmeprüfung) |
nein | 36 |
| Bericht und Vergleich | nein | Rest |
Ausgetauscht wird eine Zeile: welche der beiden Funktionen aufgerufen wird. Prüfung und Bericht bekommen davon nichts mit, weil beide Funktionen dasselbe Loesung-Objekt liefern.
📎 Warum zwei Prozesse?
ortoolsundhighspybringen beide eine eigene HiGHS-Kopie mit und kollidieren beim gemeinsamen Import (Kapitel 3). Das Programm startet sich deshalb für jeden Solver selbst noch einmal als Kindprozess und lässt sich das Ergebnis als JSON zurückgeben:print(MODELLBAUER[sys.argv[1]](problem).model_dump_json()) # im Kindprozess Loesung.model_validate_json(...) # im HauptprozessDas ist der zweite, weniger offensichtliche Ertrag des DTOs: Ein Lösungsobjekt ohne Solverbezug ist nicht nur eine Sprachregelung, sondern ein Datenformat. Es überlebt eine Prozessgrenze, passt in eine Warteschlange und lässt sich protokollieren. Ein
pywraplp.Solver-Objekt kann das alles nicht.
#!/usr/bin/env python3
# Solverwechsel_CPSAT_HiGHS.py
"""
Kapitel Praxisfallen: Denselben Fall einmal mit CP-SAT und einmal mit HiGHS rechnen.
Die Trennung aus or_kern.py behauptet, ein Solverwechsel koste genau EINEN
Baustein. Dieses Programm loest diese Behauptung ein. Aufgabe ist eine
Standortplanung: Welche Lager oeffnen wir, und wer beliefert welchen Kunden?
Standortproblem (Pydantic, geprueft) <- gemeinsam
baue_und_loese_mit_cpsat() <- der EINE Baustein
baue_und_loese_mit_highs() <- ... in zwei Ausfuehrungen
Loesung (DTO) <- gemeinsam
pruefe_zuordnung() <- gemeinsam
berichte() <- gemeinsam
Die beiden Modellbauer sind 39 und 52 Zeilen lang - sie sind der gesamte
solverabhaengige Teil des Programms. Alles andere wird zweimal benutzt und
einmal geschrieben.
WARUM ZWEI PROZESSE? ortools und highspy bringen beide eine eigene
HiGHS-Kopie mit und lassen sich auf vielen Systemen nicht gemeinsam
importieren (Kapitel Oekosystem). Das Hauptprogramm startet deshalb fuer jeden
Solver einen eigenen Python-Prozess und laesst sich die Loesung als JSON
zurueckgeben - das DTO ist nicht nur eine Sprachregelung, sondern ein
Datenformat, das eine Prozessgrenze ueberlebt.
Aufruf:
python3 Solverwechsel_CPSAT_HiGHS.py # beide, mit Vergleich
python3 Solverwechsel_CPSAT_HiGHS.py cpsat # nur der Kindprozess
python3 Solverwechsel_CPSAT_HiGHS.py highs
Benoetigt: numpy, pydantic, ortools, highspy (jeweils im eigenen Prozess)
"""
from __future__ import annotations
import subprocess
import sys
import time
import numpy as np
from pydantic import BaseModel, Field, model_validator
from or_kern import Loesung, SolverStatus, status_von_cpsat, status_von_highs
ZEITLIMIT = 30.0
# --- 1. Domaenenmodell: dieselben Fakten fuer beide Solver -------------------
class Standortproblem(BaseModel):
"""Kapazitierte Standortplanung mit Einzelbelieferung.
Nach dem Muster von Produktionsproblem in or_kern.py: Die Pruefungen
stehen im Konstruktor, nicht im Solvercode - sie gelten damit fuer beide
Solver, und sie schlagen beim Einlesen zu.
Alle Kosten sind ganzzahlig (Euro). Das ist keine Bequemlichkeit, sondern
Voraussetzung: CP-SAT rechnet ausschliesslich ganzzahlig.
"""
lager: list[str] = Field(min_length=1)
kunden: list[str] = Field(min_length=1)
fixkosten: list[int] # je Lager, faellt bei Eroeffnung an
kapazitaet: list[int] # je Lager, in Paletten
bedarf: list[int] # je Kunde, in Paletten
transport: list[list[int]] # [Lager][Kunde], Kosten der Belieferung
@model_validator(mode="after")
def pruefe_masse(self) -> "Standortproblem":
n, m = len(self.lager), len(self.kunden)
if len(self.fixkosten) != n or len(self.kapazitaet) != n:
raise ValueError(f"fixkosten/kapazitaet muessen {n} Eintraege haben")
if len(self.bedarf) != m:
raise ValueError(f"bedarf muss {m} Eintraege haben")
if len(self.transport) != n or any(len(z) != m for z in self.transport):
raise ValueError(f"transport muss {n} x {m} sein")
if sum(self.kapazitaet) < sum(self.bedarf):
raise ValueError(f"Gesamtkapazitaet {sum(self.kapazitaet)} deckt den "
f"Gesamtbedarf {sum(self.bedarf)} nicht")
return self
def schluessel(self, i: int, j: int) -> str:
"""Variablenname im Loesung-DTO - beide Modellbauer benutzen ihn."""
return f"{self.lager[i]}->{self.kunden[j]}"
def beispielproblem(saat: int = 11) -> Standortproblem:
"""Sechs moegliche Lager, zwoelf Kunden - klein genug fuer beide Solver."""
rng = np.random.default_rng(saat)
lager = [f"Lager_{k}" for k in "ABCDEF"]
kunden = [f"Kunde_{k:02d}" for k in range(1, 13)]
bedarf = rng.integers(10, 60, len(kunden))
return Standortproblem(
lager=lager,
kunden=kunden,
fixkosten=rng.integers(3000, 9000, len(lager)).tolist(),
kapazitaet=[int(bedarf.sum() * 0.45)] * len(lager),
bedarf=bedarf.tolist(),
transport=rng.integers(200, 1800, (len(lager), len(kunden))).tolist(),
)
# --- 2. Der eine Baustein, der sich aendert: der Modellbauer -----------------
def baue_und_loese_mit_cpsat(problem: Standortproblem) -> Loesung:
"""CP-SAT: Bool-Variablen, ganzzahlige Koeffizienten, Minimize."""
from ortools.sat.python import cp_model
n, m = len(problem.lager), len(problem.kunden)
modell = cp_model.CpModel()
y = [modell.NewBoolVar(f"offen_{i}") for i in range(n)]
x = {(i, j): modell.NewBoolVar(f"liefert_{i}_{j}")
for i in range(n) for j in range(m)}
for j in range(m): # jeder Kunde genau einmal
modell.AddExactlyOne(x[i, j] for i in range(n))
for i in range(n):
for j in range(m): # nur aus offenen Lagern
modell.AddImplication(x[i, j], y[i])
modell.Add(sum(problem.bedarf[j] * x[i, j] for j in range(m))
<= problem.kapazitaet[i] * y[i]) # Kapazitaet
modell.Minimize(
sum(problem.fixkosten[i] * y[i] for i in range(n))
+ sum(problem.transport[i][j] * x[i, j] for i in range(n) for j in range(m)))
loeser = cp_model.CpSolver()
loeser.parameters.max_time_in_seconds = ZEITLIMIT
loeser.parameters.num_workers = 1
loeser.parameters.random_seed = 1
status = status_von_cpsat(loeser.Solve(modell))
if not status.brauchbar:
return Loesung(status=status, laufzeit=loeser.WallTime())
return Loesung(
status=status,
werte={problem.schluessel(i, j): float(loeser.Value(x[i, j]))
for i in range(n) for j in range(m)},
zielwert=loeser.ObjectiveValue(),
schranke=loeser.BestObjectiveBound(),
laufzeit=loeser.WallTime())
def baue_und_loese_mit_highs(problem: Standortproblem) -> Loesung:
"""HiGHS: dieselben Restriktionen als Ungleichungszeilen einer Matrix."""
import highspy
n, m = len(problem.lager), len(problem.kunden)
anzahl_x = n * m # Spalten 0..n*m-1
spalte_y = lambda i: anzahl_x + i # danach die y_i # noqa: E731
modell = highspy.Highs()
modell.setOptionValue("output_flag", False)
modell.setOptionValue("time_limit", ZEITLIMIT)
modell.addVars(anzahl_x + n, np.zeros(anzahl_x + n), np.ones(anzahl_x + n))
for spalte in range(anzahl_x + n):
modell.changeColIntegrality(spalte, highspy.HighsVarType.kInteger)
for i in range(n):
modell.changeColCost(spalte_y(i), float(problem.fixkosten[i]))
for j in range(m):
modell.changeColCost(i * m + j, float(problem.transport[i][j]))
for j in range(m): # jeder Kunde genau einmal
index = np.array([i * m + j for i in range(n)], dtype=np.int32)
modell.addRow(1.0, 1.0, n, index, np.ones(n))
for i in range(n):
for j in range(m): # x_ij - y_i <= 0
modell.addRow(-highspy.kHighsInf, 0.0, 2,
np.array([i * m + j, spalte_y(i)], dtype=np.int32),
np.array([1.0, -1.0]))
index = np.array([i * m + j for j in range(m)] + [spalte_y(i)],
dtype=np.int32) # Kapazitaet
werte = np.concatenate([np.array(problem.bedarf, dtype=float),
[-float(problem.kapazitaet[i])]])
modell.addRow(-highspy.kHighsInf, 0.0, m + 1, index, werte)
t0 = time.perf_counter()
modell.run()
laufzeit = time.perf_counter() - t0
status = status_von_highs(modell.modelStatusToString(modell.getModelStatus()))
if not status.brauchbar:
return Loesung(status=status, laufzeit=laufzeit)
loesungswerte = modell.getSolution().col_value
info = modell.getInfo()
return Loesung(
status=status,
werte={problem.schluessel(i, j): float(loesungswerte[i * m + j])
for i in range(n) for j in range(m)},
zielwert=info.objective_function_value,
schranke=info.mip_dual_bound,
laufzeit=laufzeit)
MODELLBAUER = {"cpsat": baue_und_loese_mit_cpsat, "highs": baue_und_loese_mit_highs}
# --- 3. Alles Weitere ist wieder gemeinsam -----------------------------------
def pruefe_zuordnung(problem: Standortproblem, loesung: Loesung,
toleranz: float = 1e-6) -> list[str]:
"""Prueft die Loesung gegen die Anforderung - ohne Solver, ohne Modell."""
if not loesung.status.brauchbar:
return [f"kein verwertbares Ergebnis ({loesung.status.value})"]
n, m = len(problem.lager), len(problem.kunden)
zuordnung = np.array([[loesung.werte[problem.schluessel(i, j)]
for j in range(m)] for i in range(n)])
beanstandungen: list[str] = []
if (np.abs(zuordnung - np.round(zuordnung)) > toleranz).any():
beanstandungen.append("Zuordnungen sind nicht 0/1")
zuordnung = np.round(zuordnung)
for j, kunde in enumerate(problem.kunden):
if abs(zuordnung[:, j].sum() - 1.0) > toleranz:
beanstandungen.append(f"{kunde} wird {zuordnung[:, j].sum():.0f}-mal beliefert")
beliefert = zuordnung @ np.array(problem.bedarf, dtype=float)
for i, lagername in enumerate(problem.lager):
if beliefert[i] > problem.kapazitaet[i] + toleranz:
beanstandungen.append(f"{lagername}: {beliefert[i]:.0f} Paletten ueber "
f"Kapazitaet {problem.kapazitaet[i]}")
if loesung.zielwert is not None:
offen = beliefert > toleranz
nachgerechnet = (np.array(problem.fixkosten, dtype=float) @ offen
+ (np.array(problem.transport, dtype=float) * zuordnung).sum())
if abs(nachgerechnet - loesung.zielwert) > 0.5:
beanstandungen.append(f"Zielwert {loesung.zielwert:,.0f} passt nicht zur "
f"Zuordnung (nachgerechnet {nachgerechnet:,.0f})")
return beanstandungen
def geoeffnete_lager(problem: Standortproblem, loesung: Loesung) -> list[str]:
m = len(problem.kunden)
return [name for i, name in enumerate(problem.lager)
if any(loesung.werte[problem.schluessel(i, j)] > 0.5 for j in range(m))]
def loese_in_eigenem_prozess(name: str) -> Loesung:
"""Startet dieses Programm noch einmal - mit genau einem Solverimport."""
ergebnis = subprocess.run([sys.executable, __file__, name],
capture_output=True, text=True, timeout=300)
if ergebnis.returncode != 0:
raise RuntimeError(ergebnis.stderr.strip().splitlines()[-1])
# Das DTO als JSON - genau dafuer ist ein Datenobjekt ohne Solverbezug gut.
return Loesung.model_validate_json(ergebnis.stdout.strip().splitlines()[-1])
if __name__ == "__main__":
problem = beispielproblem()
# --- Kindprozess: rechnen und das DTO als JSON ausgeben ---------------
if len(sys.argv) > 1:
print(MODELLBAUER[sys.argv[1]](problem).model_dump_json())
sys.exit(0)
# --- Hauptprozess: beide Solver anstossen und vergleichen -------------
print("=" * 82)
print(" DERSELBE FALL, ZWEI SOLVER - UND EIN AUSWERTUNGSCODE")
print("=" * 82)
print(f"Standortplanung: {len(problem.lager)} moegliche Lager, "
f"{len(problem.kunden)} Kunden, {sum(problem.bedarf)} Paletten Bedarf.")
print(f"Kapazitaet je Lager: {problem.kapazitaet[0]} Paletten "
f"-> mindestens 3 Lager noetig.\n")
loesungen: dict[str, Loesung] = {}
for name, beschriftung in [("cpsat", "OR-Tools CP-SAT"),
("highs", "HiGHS (highspy)")]:
loesung = loesungen[name] = loese_in_eigenem_prozess(name)
beanstandungen = pruefe_zuordnung(problem, loesung)
print(f"{beschriftung}")
print(f" {loesung.als_bericht()}")
print(f" eroeffnete Lager: {', '.join(geoeffnete_lager(problem, loesung))}")
print(f" Abnahmepruefung: "
f"{'bestanden' if not beanstandungen else beanstandungen}")
# --- Was der Vergleich zeigt -----------------------------------------
zielwerte = [loesung.zielwert for loesung in loesungen.values()]
print("-" * 82)
print(f"Zielwertdifferenz: {abs(zielwerte[0] - zielwerte[1]):.6f} EUR")
gleich_belegt = all(
round(loesungen["cpsat"].werte[s]) == round(loesungen["highs"].werte[s])
for s in loesungen["cpsat"].werte)
print(f"Identische Zuordnung: {'ja' if gleich_belegt else 'nein'}")
assert abs(zielwerte[0] - zielwerte[1]) < 0.5, "Die Solver widersprechen sich!"
assert all(l.status is SolverStatus.OPTIMAL for l in loesungen.values())
print("\n" + "=" * 82)
print(" WAS DER WECHSEL GEKOSTET HAT")
print("=" * 82)
print("Ausgetauscht wurde EINE Funktion. Domaenenmodell, Abnahmepruefung und")
print("Bericht sind woertlich dieselben - sie sehen den Solver nie.")
print()
print("Nicht umsonst ist der Wechsel trotzdem:")
print(" * CP-SAT rechnet ausschliesslich GANZZAHLIG. Alle Kosten sind hier")
print(" deshalb int. Wer in Euro und Cent rechnet, skaliert vorher auf Cent -")
print(" und muss das im Bericht wieder zuruecknehmen.")
print(" * HiGHS braucht die Restriktionen als Matrixzeilen, CP-SAT nimmt sie")
print(" als Ausdruecke. Das ist der Grund, warum der HiGHS-Modellbauer")
print(" laenger ist, obwohl er dasselbe Modell beschreibt.")
print(" * Beide Bibliotheken bringen eine eigene HiGHS-Kopie mit und lassen")
print(" sich nicht gemeinsam importieren - daher die zwei Prozesse.")
print()
print("Der Ertrag: Beide beweisen denselben optimalen Zielwert, und die")
print("Entscheidung zwischen ihnen ist eine Frage der Laufzeit geworden -")
print("nicht eine Frage, wie viel Code man neu schreiben muss.")
print()
print("Verglichen wird deshalb der ZIELWERT, nicht der Plan: Gibt es mehrere")
print("gleich teure Loesungen, darf jeder Solver eine andere davon liefern.")
print("Hier stimmen sie zufaellig ueberein - darauf zu testen waere trotzdem")
print("ein unzuverlaessiger Test (siehe JobShop_Intervalle.py).")
print("=" * 82)Erwartete Ausgabe (Laufzeiten hardwareabhängig):
==================================================================================
DERSELBE FALL, ZWEI SOLVER - UND EIN AUSWERTUNGSCODE
==================================================================================
Standortplanung: 6 moegliche Lager, 12 Kunden, 387 Paletten Bedarf.
Kapazitaet je Lager: 174 Paletten -> mindestens 3 Lager noetig.
OR-Tools CP-SAT
Status: optimal | Zielwert: 22,010.00 | Gap: 0.00% | Zeit: 0.07s
eroeffnete Lager: Lager_A, Lager_B, Lager_D
Abnahmepruefung: bestanden
HiGHS (highspy)
Status: optimal | Zielwert: 22,010.00 | Gap: 0.00% | Zeit: 0.02s
eroeffnete Lager: Lager_A, Lager_B, Lager_D
Abnahmepruefung: bestanden
----------------------------------------------------------------------------------
Zielwertdifferenz: 0.000000 EUR
Identische Zuordnung: ja
==================================================================================
WAS DER WECHSEL GEKOSTET HAT
==================================================================================
Ausgetauscht wurde EINE Funktion. Domaenenmodell, Abnahmepruefung und
Bericht sind woertlich dieselben - sie sehen den Solver nie.
Nicht umsonst ist der Wechsel trotzdem:
* CP-SAT rechnet ausschliesslich GANZZAHLIG. Alle Kosten sind hier
deshalb int. Wer in Euro und Cent rechnet, skaliert vorher auf Cent -
und muss das im Bericht wieder zuruecknehmen.
* HiGHS braucht die Restriktionen als Matrixzeilen, CP-SAT nimmt sie
als Ausdruecke. Das ist der Grund, warum der HiGHS-Modellbauer
laenger ist, obwohl er dasselbe Modell beschreibt.
* Beide Bibliotheken bringen eine eigene HiGHS-Kopie mit und lassen
sich nicht gemeinsam importieren - daher die zwei Prozesse.
Der Ertrag: Beide beweisen denselben optimalen Zielwert, und die
Entscheidung zwischen ihnen ist eine Frage der Laufzeit geworden -
nicht eine Frage, wie viel Code man neu schreiben muss.
Verglichen wird deshalb der ZIELWERT, nicht der Plan: Gibt es mehrere
gleich teure Loesungen, darf jeder Solver eine andere davon liefern.
Hier stimmen sie zufaellig ueberein - darauf zu testen waere trotzdem
ein unzuverlaessiger Test (siehe JobShop_Intervalle.py).
==================================================================================
💻 Was dieser Vergleich belegt — und was er kostet
Belegt: Beide Solver beweisen denselben Zielwert von 22 010 €. Domänenmodell, Abnahmeprüfung und Bericht sind wörtlich dieselben; sie sehen den Solver nie. Die Entscheidung zwischen CP-SAT und HiGHS ist damit zu einer Laufzeitfrage geworden — man kann beide messen, statt sich vorher festlegen zu müssen.
Kostet: Der Wechsel ist nicht gratis, und es wäre unehrlich, das zu verschweigen.
- CP-SAT rechnet ausschließlich ganzzahlig. Alle Kosten im
Standortproblemsind deshalbint. Wer in Euro und Cent rechnet, skaliert vor dem Modellaufbau auf Cent — und muss die Skalierung im Bericht zurücknehmen. Diese Umrechnung ist eine klassische Fehlerquelle; sie gehört an genau eine Stelle und in einen Test.- Der HiGHS-Modellbauer ist ein Drittel länger, obwohl er dasselbe Modell beschreibt. Der Unterschied ist nicht Qualität, sondern Schnittstelle:
AddImplication(x, y)gegen eine Matrixzeile mit den Koeffizienten +1 und -1.- Verglichen wird der Zielwert, nicht der Plan. Gibt es mehrere gleich teure Lösungen, darf jeder Solver eine andere davon liefern — hier stimmen sie überein, aber darauf zu testen wäre unzuverlässig. Denselben Fehler zeigt
JobShop_Intervalle.pyin Kapitel 7.
🎯 Merksatz Die Frage „welcher Solver ist der beste?“ ist schlecht gestellt. Die gute Frage lautet: „Wie teuer ist es, den Solver zu wechseln?“ Wer sie mit eine Funktion beantworten kann, muss die erste Frage nie entscheiden — er misst.
22.7 Eine Checkliste vor dem Produktivgang
Arbeiten Sie diese Liste ab, bevor ein OR-System in Betrieb geht:
Modell - [ ] Alle Nebenbedingungen sind dokumentiert — mit fachlicher Begründung, nicht nur als Code. - [ ] Für jede harte Bedingung ist geprüft: Muss sie wirklich hart sein? - [ ] Es gibt eine Relaxationsstrategie für den Infeasible-Fall. - [ ] Big-M-Werte sind so klein wie möglich gewählt. - [ ] Die Zielfunktion ist in konsistenten Einheiten formuliert.
Daten - [ ] Spaltenreihenfolge und Datentypen werden nach jedem Import geprüft. - [ ] Fehlende Werte und Ausreißer haben eine definierte Behandlung. - [ ] Kovarianzmatrizen werden auf positive Definitheit geprüft. - [ ] Datenstände sind versioniert und reproduzierbar.
Validierung - [ ] Das Ergebnis wurde gegen eine unabhängige Rechnung geprüft (Handrechnung, zweiter Solver, Simulation). - [ ] Alle Nebenbedingungen werden nach dem Lösen per assert verifiziert. - [ ] Es gibt einen Vergleich gegen eine naive Referenzstrategie. - [ ] Bei Backtests: Lookahead-Selbsttest bestanden, Zahl der Versuche protokolliert.
Betrieb - [ ] Zeitlimit und akzeptierter Gap sind festgelegt und begründet. - [ ] Es gibt eine Fallback-Strategie und Alarmierung. - [ ] Jedes Ergebnis ist mit Snapshot-ID, Modellversion und Solver-Version protokolliert. - [ ] Die Ergebnisse sind erklärbar (Kostenzerlegung, Schattenpreise, Constraint-Trace).
22.8 Weiterführende Literatur und Roadmap
Grundlagen und lineare/ganzzahlige Optimierung * Bertsimas & Tsitsiklis: Introduction to Linear Optimization — Standardwerk zu Simplex, Dualität, Polyedertheorie. * Wolsey: Integer Programming — Branch-and-Cut, Schnittebenen, Formulierungsstärke.
Konvexe Optimierung * Boyd & Vandenberghe: Convex Optimization — Pflichtlektüre; frei verfügbar. Die theoretische Basis von CVXPY.
Constraint Programming und Scheduling * Google OR-Tools Dokumentation und das CP-SAT-Primer von Laurent Perron.
Quantitative Finanzmathematik * López de Prado: Advances in Financial Machine Learning — Backtest-Overfitting, Denoising, Deflated Sharpe Ratio. * Cornuéjols, Peña & Tütüncü: Optimization Methods in Finance.
Wie es weitergeht
Der nächste Schritt nach diesem Kurs ist selten ein weiteres Verfahren, sondern die Verankerung im Betrieb: reproduzierbare Datenpipelines, versionierte Modelle, Monitoring der Lösungsqualität, bewusster Umgang mit Zeitlimits.
Wenn Sie doch tiefer in die Methodik wollen, sind das die lohnendsten Richtungen:
| Richtung | Wofür | Einstieg |
|---|---|---|
| Column Generation / Branch-and-Price | sehr große Zuschnitt- und Dienstplanprobleme | Kapitel 10, Wolsey |
| Benders-Zerlegung | zweistufige stochastische Modelle mit vielen Szenarien | Birge & Louveaux |
| Approximate Dynamic Programming / RL | hochdimensionale Zustandsräume | Powell: Reinforcement Learning and Stochastic Optimization |
| Konische Optimierung (SOCP, SDP) | robuste Portfolios, Ellipsoid-Unsicherheit | Boyd & Vandenberghe, Kapitel 5 |
| Multi-Objective Optimization | echte Zielkonflikte ohne Gewichtung | Ehrgott: Multicriteria Optimization |
22.9 Übungsaufgaben
Lösungen: Abschnitt A.22.
Aufgabe 22.1 ⭐ — Hart oder weich, revisited. Nennen Sie für ein Vertretungsplanungssystem je zwei Bedingungen, die (a) zwingend hart bleiben müssen, (b) unbedingt weich sein sollten, (c) diskutabel sind. Begründen Sie.
Aufgabe 22.2 ⭐ — Zeitlimit wählen. Für welche Szenarien setzen Sie welches Zeitlimit und welchen Gap? (a) Taxi-Disposition in Echtzeit, (b) Wochendienstplan, freitags erstellt, (c) Jahresproduktionsplanung, (d) Portfolio-Rebalancing monatlich.
Aufgabe 22.3 ⭐⭐ — Relaxation einbauen. Nehmen Sie Ihr Modell aus Übung 6.7 (Wochendienstplan) und machen Sie es INFEASIBLE-sicher: Schlupfvariablen für unbesetzte Schichten, gestaffelte Strafen. Testen Sie mit einem bewusst überlasteten Szenario.
Aufgabe 22.4 ⭐⭐ — Erklärbarkeit implementieren. Erweitern Sie ein beliebiges Modell aus dem Kurs um einen erklaere(loesung)-Report, der die drei Fragen aus Abschnitt 22.3 beantwortet.
Aufgabe 22.5 ⭐⭐⭐ — Gap gegen Laufzeit. Nehmen Sie das MILP-Portfolio aus Kapitel 6 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 %. Stellen Sie den Zusammenhang grafisch dar und leiten Sie eine Empfehlung ab.
Aufgabe 22.6 ⭐⭐⭐ — Post-Mortem schreiben. Suchen Sie sich einen der im Buch besprochenen Fehler aus (z. B. die Vorzeichenfalle in Abschnitt 5.7, die ungültige Kovarianzmatrix in Kapitel 11, die Sektor-Positionsindizes in Kapitel 19, die vermischten Einheiten in Abschnitt 20.6 oder die Rebalancing-Termine in Abschnitt 21.4) und schreiben Sie ein einseitiges Post-Mortem: Was war der Fehler? Warum fiel er nicht auf? Welche Prüfung hätte ihn gefunden? Welche Regel leiten Sie daraus für eigene Projekte ab?
22.10 Finde den Denkfehler
Falle 5 — die Laufzeitexplosion — zeigt sich im Betrieb selten als Absturz. Sie zeigt sich als gar nichts.
🐛 Finde den Denkfehler: Das Modell, das seit einem Jahr nicht mehr optimiert
Ein Standortplanungsmodell geht 2024 in Betrieb. Es läuft jede Nacht, hat drei Sekunden Zeitbudget und liefert zuverlässig die Kosten des Tagesplans. Das Betriebsteam überwacht Abstürze und Laufzeitspitzen — beides tritt nie auf.
Zwei Jahre später ist das Unternehmen von 15 auf 80 Lager gewachsen. Am Code hat niemand etwas geändert; es gab ja keinen Anlass. Das Protokoll sieht so aus:
Zeitpunkt Kosten 2024-Q1 40 484 2024-Q3 51 042 2025-Q1 64 053 2025-Q3 85 791 2026-Q1 108 582 Die Kosten steigen — das Unternehmen wächst schließlich. Alles unauffällig.
Tatsächlich liefert das Modell seit Anfang 2025 nicht mehr das Optimum, sondern eine Lösung, die zuletzt 17,2 % davon entfernt ist.
Ihre Aufgabe: (a) Warum ist die gleichbleibende Laufzeit hier kein Zeichen von Stabilität, sondern das Symptom? (b) Welche drei Größen fehlen im Protokoll? (c) Welche der drei warnt am frühesten — und warum ist gerade sie so wertvoll? (d) Das Team bemerkt den Fehler 2026. Wie hoch waren die Mehrkosten, und warum lässt sich das nachträglich nicht mehr genau beziffern?
Auflösung: Abschnitt A.22.
#!/usr/bin/env python3
# Betriebsueberwachung.py
"""
Kapitel Praxisfallen: Das Modell, das still aufgehoert hat zu optimieren.
Ein Optimierungsmodell geht in Betrieb und laeuft jede Nacht. Es liefert
zuverlaessig eine Zahl, die Job-Dauer bleibt konstant, es gibt keine
Fehlermeldung. Alles sieht gut aus.
Waehrenddessen waechst das Unternehmen. Aus 15 Lagern werden 40, dann 80. Das
Zeitlimit von drei Sekunden - vor zwei Jahren grosszuegig bemessen - reicht
irgendwann nicht mehr. Der Solver bricht ab und liefert die beste Loesung, die
er bis dahin gefunden hat.
Das ist nicht falsch. Es ist sogar richtig so. Aber es ist etwas anderes als
das, was das Modell einmal geleistet hat - und niemand merkt es, weil im
Protokoll nur die Kosten stehen.
Dieses Programm zeigt die Verschlechterung und die Ueberwachung, die sie
sichtbar macht.
WICHTIG: ortools wird hier nicht importiert (Konflikt mit highspy, siehe
Kapitel Oekosystem).
Benoetigt: numpy, highspy
"""
from __future__ import annotations
import time
from dataclasses import dataclass
import numpy as np
import highspy
ZEITLIMIT = 3.0 # das Budget, das der Nachtjob hat
GAP_WARNSCHWELLE = 0.02 # ab 2 % Gap wollen wir es wissen
ZEITAUSLASTUNG_WARNSCHWELLE = 0.9
@dataclass
class Laufprotokoll:
"""Was nach JEDEM Produktivlauf protokolliert gehoert.
Die meisten Systeme schreiben nur 'kosten'. Genau deshalb faellt eine
schleichende Verschlechterung ueber Monate nicht auf.
"""
zeitpunkt: str
lager: int
kunden: int
status: str
kosten: float
gap: float
laufzeit: float
knoten: int
@property
def warnungen(self) -> list[str]:
meldungen = []
if self.status != "Optimal":
meldungen.append(f"nicht beweisbar optimal ({self.status})")
if self.gap > GAP_WARNSCHWELLE:
meldungen.append(f"Gap {self.gap:.1%} ueber Schwelle "
f"{GAP_WARNSCHWELLE:.0%}")
if self.laufzeit > ZEITAUSLASTUNG_WARNSCHWELLE * ZEITLIMIT:
meldungen.append(f"Zeitbudget zu {self.laufzeit / ZEITLIMIT:.0%} "
f"ausgeschoepft")
return meldungen
def plane_netzwerk(n_lager: int, n_kunden: int, zeitlimit: float,
saat: int = 7) -> tuple[str, float, float, float, int]:
"""Standortplanung wie in Kapitel MILP, nur mit wachsender Groesse."""
rng = np.random.default_rng(saat)
fixkosten = rng.uniform(3000, 9000, n_lager)
transport = rng.uniform(5, 60, (n_lager, n_kunden))
bedarf = rng.uniform(10, 60, n_kunden)
kapazitaet = np.full(n_lager, bedarf.sum() * 0.22)
modell = highspy.Highs()
modell.setOptionValue("output_flag", False)
modell.setOptionValue("time_limit", zeitlimit)
anzahl_x = n_lager * n_kunden
unendlich = highspy.kHighsInf
modell.addVars(anzahl_x, np.zeros(anzahl_x), np.full(anzahl_x, unendlich))
modell.addVars(n_lager, np.zeros(n_lager), np.ones(n_lager))
for i in range(n_lager):
modell.changeColIntegrality(anzahl_x + i, highspy.HighsVarType.kInteger)
modell.changeColCost(anzahl_x + i, fixkosten[i])
for j in range(n_kunden):
modell.changeColCost(i * n_kunden + j, transport[i, j])
for j in range(n_kunden):
index = np.array([i * n_kunden + j for i in range(n_lager)], dtype=np.int32)
modell.addRow(bedarf[j], bedarf[j], len(index), index, np.ones(len(index)))
for i in range(n_lager):
index = np.array([i * n_kunden + j for j in range(n_kunden)] + [anzahl_x + i],
dtype=np.int32)
werte = np.concatenate([np.ones(n_kunden), [-kapazitaet[i]]])
modell.addRow(-unendlich, 0.0, len(index), index, werte)
t0 = time.perf_counter()
modell.run()
laufzeit = time.perf_counter() - t0
info = modell.getInfo()
return (modell.modelStatusToString(modell.getModelStatus()),
info.objective_function_value, info.mip_gap, laufzeit,
info.mip_node_count)
if __name__ == "__main__":
# So ist das Unternehmen ueber zwei Jahre gewachsen.
entwicklung = [("2024-Q1", 15, 40), ("2024-Q3", 25, 70),
("2025-Q1", 40, 110), ("2025-Q3", 60, 160),
("2026-Q1", 80, 220)]
print("=" * 84)
print(" WAS DAS PROTOKOLL ZEIGT - UND WAS ES ZEIGEN SOLLTE")
print("=" * 84)
print(f"Derselbe Nachtjob, unveraendert, mit {ZEITLIMIT:.0f} Sekunden Zeitlimit.\n")
protokolle = []
for zeitpunkt, lager, kunden in entwicklung:
status, kosten, gap, laufzeit, knoten = plane_netzwerk(
lager, kunden, ZEITLIMIT)
protokolle.append(Laufprotokoll(zeitpunkt, lager, kunden, status,
kosten, gap, laufzeit, knoten))
print("So sieht das ueblich gefuehrte Protokoll aus:\n")
print(f" {'Zeitpunkt':<10} {'Kosten':>12}")
print(" " + "-" * 24)
for p in protokolle:
print(f" {p.zeitpunkt:<10} {p.kosten:>12,.0f}")
print("\n -> Die Kosten steigen. Das Unternehmen waechst ja auch.")
print(" Nichts an dieser Tabelle deutet auf ein Problem hin.")
print("\n" + "-" * 84)
print("So sieht ein vollstaendiges Protokoll aus:\n")
print(f" {'Zeitpunkt':<10} {'Groesse':>10} {'Status':>20} {'Kosten':>11} "
f"{'Gap':>8} {'Zeit':>7}")
print(" " + "-" * 80)
for p in protokolle:
groesse = f"{p.lager}x{p.kunden}"
print(f" {p.zeitpunkt:<10} {groesse:>10} {p.status:>20} "
f"{p.kosten:>11,.0f} {p.gap * 100:7.2f} % {p.laufzeit:6.2f}s")
print("\n" + "-" * 84)
print("Und so sehen die Warnungen aus, die daraus folgen:\n")
for p in protokolle:
if p.warnungen:
print(f" {p.zeitpunkt}: " + "; ".join(p.warnungen))
else:
print(f" {p.zeitpunkt}: in Ordnung")
letzte = protokolle[-1]
erste_warnung = next(p for p in protokolle if p.warnungen)
print("\n" + "=" * 84)
print(" WAS DA PASSIERT IST")
print("=" * 84)
print(f"Seit {erste_warnung.zeitpunkt} erreicht der Job das Zeitlimit und liefert")
print("nicht mehr das Optimum, sondern die beste bis dahin gefundene Loesung.")
print(f"Im letzten Lauf betraegt der Abstand zum Bestmoeglichen "
f"{letzte.gap:.1%}.")
print()
print("Der Job ist nicht abgestuerzt. Er hat keine Fehlermeldung erzeugt.")
print("Die Laufzeit ist sogar bemerkenswert STABIL geblieben - genau deshalb,")
print("weil das Zeitlimit greift. Ein Ueberwachungssystem, das auf Abstuerze")
print("und Laufzeitspitzen achtet, sieht hier nichts.")
print()
print("Drei Zahlen gehoeren deshalb in jedes Protokoll eines Optimierungsjobs:")
print(" * der STATUS - 'Optimal' oder etwas anderes?")
print(" * der GAP - wie weit ist die Loesung vom Bestmoeglichen entfernt?")
print(" * die LAUFZEIT im Verhaeltnis zum Limit - wie nah am Anschlag?")
print()
print("Die dritte ist die frueheste Warnung: Sie steigt, lange bevor der Gap")
print("sichtbar wird, und gibt Zeit zum Handeln.")
print("=" * 84)Erwartete Ausgabe (Zeiten hardwareabhängig):
====================================================================================
WAS DAS PROTOKOLL ZEIGT - UND WAS ES ZEIGEN SOLLTE
====================================================================================
Derselbe Nachtjob, unveraendert, mit 3 Sekunden Zeitlimit.
So sieht das ueblich gefuehrte Protokoll aus:
Zeitpunkt Kosten
------------------------
2024-Q1 40,484
2024-Q3 51,042
2025-Q1 64,053
2025-Q3 85,791
2026-Q1 108,582
-> Die Kosten steigen. Das Unternehmen waechst ja auch.
Nichts an dieser Tabelle deutet auf ein Problem hin.
------------------------------------------------------------------------------------
So sieht ein vollstaendiges Protokoll aus:
Zeitpunkt Groesse Status Kosten Gap Zeit
--------------------------------------------------------------------------------
2024-Q1 15x40 Optimal 40,484 0.00 % 0.57s
2024-Q3 25x70 Optimal 51,042 0.00 % 1.50s
2025-Q1 40x110 Time limit reached 64,053 8.41 % 3.00s
2025-Q3 60x160 Time limit reached 85,791 14.99 % 3.01s
2026-Q1 80x220 Time limit reached 108,582 17.16 % 3.01s
------------------------------------------------------------------------------------
Und so sehen die Warnungen aus, die daraus folgen:
2024-Q1: in Ordnung
2024-Q3: in Ordnung
2025-Q1: nicht beweisbar optimal (Time limit reached); Gap 8.4% ueber Schwelle 2%; Zeitbudget zu 100% ausgeschoepft
2025-Q3: nicht beweisbar optimal (Time limit reached); Gap 15.0% ueber Schwelle 2%; Zeitbudget zu 100% ausgeschoepft
2026-Q1: nicht beweisbar optimal (Time limit reached); Gap 17.2% ueber Schwelle 2%; Zeitbudget zu 100% ausgeschoepft
====================================================================================
WAS DA PASSIERT IST
====================================================================================
Seit 2025-Q1 erreicht der Job das Zeitlimit und liefert
nicht mehr das Optimum, sondern die beste bis dahin gefundene Loesung.
Im letzten Lauf betraegt der Abstand zum Bestmoeglichen 17.2%.
Der Job ist nicht abgestuerzt. Er hat keine Fehlermeldung erzeugt.
Die Laufzeit ist sogar bemerkenswert STABIL geblieben - genau deshalb,
weil das Zeitlimit greift. Ein Ueberwachungssystem, das auf Abstuerze
und Laufzeitspitzen achtet, sieht hier nichts.
Drei Zahlen gehoeren deshalb in jedes Protokoll eines Optimierungsjobs:
* der STATUS - 'Optimal' oder etwas anderes?
* der GAP - wie weit ist die Loesung vom Bestmoeglichen entfernt?
* die LAUFZEIT im Verhaeltnis zum Limit - wie nah am Anschlag?
Die dritte ist die frueheste Warnung: Sie steigt, lange bevor der Gap
sichtbar wird, und gibt Zeit zum Handeln.
====================================================================================
🎯 Merksatz Ein Optimierungsjob, der ein Zeitlimit hat, wird bei wachsenden Daten nicht langsamer — er wird schlechter. Genau deshalb greift die übliche Betriebsüberwachung nicht: Sie achtet auf Laufzeit und Abstürze, und beides bleibt unauffällig. Protokollieren Sie Status, Gap und Zeitausschöpfung bei jedem Lauf, oder Sie erfahren nie, wann Ihr Modell aufgehört hat zu optimieren.
22.11 Micro-Quiz
❓ Micro-Quiz 22: Drei Fragen zum Selbstcheck
Genau eine Antwort ist jeweils richtig. Auflösung in Anhang A.
1. Ihr Nachtjob läuft seit Monaten konstant 3,0 Sekunden bei einem Zeitlimit von 3 Sekunden. Was schließen Sie daraus? (a) Das System ist bemerkenswert stabil. (b) Das Zeitlimit greift jedes Mal. Der Solver bricht ab, statt fertig zu werden — die konstante Laufzeit ist das Symptom, nicht die Beruhigung. (c) Der Rechner ist ausgelastet und sollte aufgerüstet werden.
2. Eine Prüffunktion soll kontrollieren, ob ein Tourenplan die Fahrzeugkapazitäten einhält. Woher nimmt sie die geladenen Mengen? (a) Aus der Ladungsdimension des Routing-Modells — dort stehen sie ja bereits. (b) Sie summiert die Bedarfe der Kunden aus dem ausgegebenen Tourenplan auf. Alles andere fragt das Modell, ob es sich an sich selbst hält. (c) Aus der Zielfunktion, sofern die Kapazität dort bepreist ist.
3. Ein Modell meldet
INFEASIBLE, nachdem eine neue Regel aufgenommen wurde. Was ist der produktivste erste Schritt? (a) Das Zeitlimit erhöhen. (b) Die neue Regel wieder entfernen und in Ruhe abwarten. (c) Die harten Regeln hierarchisch lockern — mit teuren Strafkosten statt Verboten —, damit der Solver benennt, welche Regel den Widerspruch erzeugt, statt nur zu sagen, dass es einen gibt.
22.12 Selbsttest
Antworten: Anhang A.
- Wie verwandelt man ein unlösbares Modell in einen brauchbaren Notfallplan?
- Welche drei Fragen muss ein erklärbares OR-System beantworten können?
- Warum ist ein MIP-Gap von 2 % in der Praxis meist ausreichend?
- Was ist das Snapshot-Prinzip und wozu dient es?
- Was ist der häufigste Grund, aus dem OR-Projekte scheitern?
22.13 Zusammenfassung
- Infeasibility ist ein Entwurfsproblem, kein Solverproblem. Wer alles hart formuliert, erhält irgendwann eine Fehlermeldung statt eines Plans.
- Erklärbarkeit entscheidet über Akzeptanz. Schattenpreise, Kostenzerlegung und Constraint-Traces sind keine Kür.
- 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.
- Reproduzierbarkeit braucht Snapshots und Versionierung, nicht guten Willen.
- Projekte scheitern an der Einführung, nicht am Modell.
- Schreiben Sie die Abnahmeprüfung als eigenen Baustein — sie darf keinen Solver und keine Modellvariable anfassen, sonst prüft sie das Modell gegen sich selbst. Sie belegt Zulässigkeit, nicht Optimalität; für Letztere braucht es ein zweites Verfahren oder eine bekannte Schranke.
- Trennen Sie Domäne, Modellbau und Lösung (
or_kern.py). Der Gewinn liegt nicht im Solverwechsel, sondern darin, dass jede Schicht einzeln prüfbar wird — und dass Datenfehler beim Einlesen auffliegen statt im Bericht. - Protokollieren Sie Status, Gap und Zeitausschöpfung bei jedem Lauf. Ein Job mit Zeitlimit wird bei wachsenden Daten nicht langsamer, sondern schlechter — und eine konstante Laufzeit am Limit ist deshalb ein Warnsignal, keine Beruhigung.