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
Constraint_Attribution.py
Betriebsueberwachung.py
or_kern.py
Solverwechsel_CPSAT_HiGHS.py

Notebook: praxisfallen.ipynb — herunterladen und in Jupyter öffnen, in Colab hochladen oder mit dem Kurs-Image starten


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 …

  1. … die fünf typischen Praxisfallen benennen und je ein Gegenmittel nennen.
  2. … ein unlösbares Modell in einen „am wenigsten schlechten“ Plan verwandeln.
  3. … Solver-Ergebnisse erklärbar machen (Explainable OR).
  4. … für jede Nebenbedingung beziffern, was sie den Plan kostet — und begründen, warum der Schattenpreis diese Frage nur für die nächste Einheit beantwortet.
  5. … Zeitlimits und Optimalitätslücken sinnvoll setzen.
  6. … die Architektur einer produktionsreifen OR-Plattform skizzieren.
  7. … eine modellunabhängige Abnahmeprüfung schreiben, die eine Lösung gegen die Anforderungen prüft statt gegen das Modell.
  8. … Status, Gap und Zeitausschöpfung protokollieren und begründen, warum eine konstante Laufzeit bei Zeitlimit ein Warnsignal ist.
  9. … 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.
  10. … die Statuswerte verschiedener Solverbibliotheken auf eine gemeinsame Sprache abbilden.

22.3 Die fünf typischen Praxisfallen

Abb. 22.1: 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.

Derselbe Unterschied kehrt auf Modellebene wieder, und dort lässt er sich in Euro beziffern: Abschnitt 22.4 misst für jede Bedingung, was sie den ganzen Plan kostet — und baut daraus einen Bericht, den man vorlegen kann.

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 Constraint Attribution: welche Bedingung kostet wie viel?

Der Constraint-Trace beantwortet „warum diese Zuweisung?“. Der Deletion Filter in Anhang C beantwortet „warum geht es gar nicht?“. Der häufigste Fall in der Praxis liegt dazwischen, und für ihn hat bisher niemand ein Werkzeug bekommen:

Das Modell rechnet. Der Plan ist zulässig. Und trotzdem ist er enttäuschend — nur weiß niemand, woran es liegt.

Die Antwort heißt Constraint Attribution: Jede Bedingungsgruppe wird einzeln gelockert, und gemessen wird, was sich am Zielwert ändert. Das ist dasselbe Verfahren wie der Deletion Filter — nur mit Kosten statt Zulässigkeit als Kriterium.

Gerechnet wird auf derselben Fabrik wie die Konfliktsuche in Anhang C, aber nach der Reparatur: Dort war die Lackierkapazität von 150 Stunden der Kern des Widerspruchs (2 \cdot 40 + 3 \cdot 30 = 170 > 150). Eine zweite Schicht hat sie auf 210 gebracht, der Mindestumsatz wurde auf 10 000 € gesenkt. Jetzt ist das Modell lösbar — und die Frage lautet nicht mehr „warum nicht?“, sondern „was kostet uns was?“.

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

Ausgabe:

==============================================================================
  CONSTRAINT ATTRIBUTION: Was kostet uns welche Bedingung?
==============================================================================
Status: Optimization terminated successfully.
Deckungsbeitrag des Plans: 3,930.00 EUR

  Rahmen: 47.5  Gehaeuse: 30.0  Deckel: 97.5  Traeger: 25.0  Halter: 0.0

==============================================================================
  (1) Welche Bedingungen binden - und was kostet die naechste Einheit?
==============================================================================
Bedingung                  genutzt  Grenze  Schlupf Schattenpreis
------------------------------------------------------------------------------
Kapazitaet Montage           400.0     400      0.0        9.00  <-- bindend
Kapazitaet Lackieren         210.0     210      0.0        4.00  <-- bindend
Kapazitaet Pruefung          200.0     300    100.0        0.00
Liefervertrag Rahmen          47.5      40      7.5        0.00
Liefervertrag Gehaeuse        30.0      30      0.0        2.00  <-- bindend
Liefervertrag Deckel          97.5      20     77.5        0.00
Liefervertrag Traeger         25.0      25      0.0       18.00  <-- bindend
Marktgrenze Rahmen            47.5     120     72.5        0.00
Marktgrenze Gehaeuse          30.0      90     60.0        0.00
Marktgrenze Deckel            97.5     150     52.5        0.00
Marktgrenze Halter             0.0     200    200.0        0.00
Mindestumsatz              10800.0   10000    800.0        0.00
Sortimentsbreite             122.5     100     22.5        0.00
Lackierbudget Schicht 2       55.0      55      0.0        0.00  <-- bindend

  5 Bedingungen binden. Aber: Lackierbudget Schicht 2 bindet
  mit einem Schattenpreis von 0,00 EUR - sie beruehrt den Plan, ohne ihn
  zu verteuern. Bindend und kostenrelevant sind zwei verschiedene Dinge.

==============================================================================
  (2) Was der Schattenpreis verschweigt
==============================================================================
Der Schattenpreis gilt fuer die NAECHSTE Einheit. Ein Planer kauft aber
keine Einheit, sondern eine ganze Schicht. Beides gegenuebergestellt:

Stellschraube                Preis Schritt hochgerechnet  gemessen      Abw.
------------------------------------------------------------------------------
Kapazitaet Montage            9.00     +50        450.00    450.00     +0.00
Kapazitaet Lackieren          4.00     +30        120.00     60.00    -60.00
Kapazitaet Pruefung           0.00     +50          0.00      0.00     +0.00
Lackierbudget Schicht 2       0.00     +10          0.00      0.00     +0.00
Liefervertrag Traeger        18.00     -10        180.00    180.00     +0.00
Liefervertrag Gehaeuse        2.00     -10         20.00     18.00     -2.00

  'Kapazitaet Lackieren': hochgerechnet 120.00 EUR, tatsaechlich 60.00 EUR.
  Der Preis von 4.00 EUR gilt nur bis +15 Stunden. Jede weitere Stunde
  dieser Sonderschicht bringt exakt nichts - der Engpass ist dann ein anderer.

  Und die Ranglisten drehen sich: Nach Schattenpreis fuehrt
  'Liefervertrag Traeger' (18.00 EUR je Einheit),
  nach tatsaechlicher Wirkung 'Kapazitaet Montage' (450.00 EUR).
  Der Schattenpreis sagt, was eine Einheit wert ist - nicht, wie viele
  Einheiten zu haben sind.

==============================================================================
  (3) Was-waere-wenn: mindestens 55 Rahmen statt 47.5
==============================================================================
Der Wunsch ist nicht teuer - er ist UNMOEGLICH.

Blockiert wird er von 3 Bedingungen gemeinsam. Faellt eine davon
weg, ist er erfuellbar; keine allein ist 'der' Grund:
   ohne 'Kapazitaet Lackieren': moeglich, Deckungsbeitrag 3,990.00 EUR
   ohne 'Liefervertrag Gehaeuse': moeglich, Deckungsbeitrag 3,948.00 EUR
   ohne 'Liefervertrag Traeger': moeglich, Deckungsbeitrag 4,320.00 EUR

   Mit +15 bei 'Kapazitaet Lackieren' wird der Wunsch erfuellbar:
   Deckungsbeitrag 3,990.00 EUR (+60.00 gegenueber heute).
   Und ohne den Wunsch ergaebe dieselbe Lockerung 3,990.00 EUR bei
   55 Rahmen - der Wunsch kostet also NICHTS.
   Er war nie das Problem. Der Engpass war es.

==============================================================================
  (4) Und so steht es in der Vorlage fuer die Abteilungsleiterrunde
==============================================================================

Der Plan bringt 3,930.00 EUR Deckungsbeitrag. Begrenzt wird er von 5
Bedingungen, von denen 4 tatsaechlich Geld kosten; "Lackierbudget Schicht 2"
beruehrt den Plan, ohne ihn zu verteuern.

Teuerste Bindung ist "Liefervertrag Traeger" mit 18.00 EUR je Einheit,
gefolgt von "Kapazitaet Montage" mit 9.00 EUR.

Die groesste einzelne Verbesserung bringt eine Sonderschicht Montage: 450.00
EUR mehr Deckungsbeitrag. Eine Sonderschicht Lackieren dagegen bringt nur
60.00 EUR statt der rechnerischen 120.00 EUR - ab +15 Stunden begrenzt uns
etwas anderes. Wer die ganze Schicht bezahlt, bezahlt die zweite Haelfte
umsonst.

Zum Wunsch des Vertriebs (55 Rahmen statt 47.5): Er ist mit der heutigen
Ausstattung nicht erfuellbar - nicht aus Kostengruenden, sondern weil
"Kapazitaet Lackieren", "Liefervertrag Gehaeuse", "Liefervertrag Traeger"
ihn gemeinsam blockieren. Mit +15 Stunden bei "Kapazitaet Lackieren" wird er
moeglich, und er kostet dann nichts: Der Plan waehlt diese Menge von sich
aus.

==============================================================================

Drei Befunde, und keiner steht im Solverergebnis

Erstens: bindend heißt nicht teuer. Fünf Bedingungen binden, aber „Lackierbudget Schicht 2“ tut es mit einem Schattenpreis von 0,00 €. Sie berührt den Plan, ohne ihn zu verteuern — wer sie nachverhandelt, gewinnt nichts. Das ist derselbe Unterschied, den Erklaerbarkeit.py oben an einer einzelnen Zuweisung zeigt, hier im Dualwert eines LP.

Zweitens: Der Schattenpreis ist eine Momentaufnahme. Er gilt für die nächste Einheit. Ein Planer kauft aber keine Einheit, sondern eine Schicht. Für die Lackiererei verspricht die Hochrechnung 4{,}00 \cdot 30 = 120 €; gemessen kommen 60 €. Der Preis von 4,00 € gilt exakt bis +15 Stunden, danach begrenzt etwas anderes — die zweite Hälfte der Sonderschicht wäre bezahlt und wirkungslos.

Und die Ranglisten drehen sich dabei um: Nach Schattenpreis führt der Trägervertrag mit 18,00 € je Stück, nach tatsächlicher Wirkung die Montage-Sonderschicht mit 450 €. Der Schattenpreis sagt, was eine Einheit wert ist — nicht, wie viele Einheiten zu haben sind. Für die Frage „welchen Hebel ziehen wir?“ ist das der entscheidende Unterschied.

Drittens: Ein Wunsch kann unmöglich statt teuer sein. Der Vertrieb hätte gern 55 Rahmen statt 47,5. Das Modell antwortet nicht mit einem Preis, sondern mit INFEASIBLE — und drei Bedingungen blockieren den Wunsch gemeinsam: Fällt eine davon weg, ist er erfüllbar, keine allein ist „der“ Grund. Genau die Struktur, die Anhang C für den unlösbaren Fall beschreibt, hier auf einen Wunsch angewandt.

Die Pointe steht am Ende: Mit +15 Stunden Lackierkapazität wird der Wunsch erfüllbar — und kostet dann nichts. Der Plan wählt diese Menge von sich aus, sobald der Engpass weg ist. Der Konflikt bestand nie zwischen Vertrieb und Produktion. Er bestand zwischen dem Vertrieb und der Lackiererei, und niemand wusste das.

🎯 Die drei Fragen, jetzt auf Modellebene Die Faustregel oben stellt sie für eine einzelne Zuweisung. Constraint Attribution stellt dieselben drei für den ganzen Plan: 1. Warum dieser Zielwert? — welche Bedingungen binden, und welche davon kosten Geld. 2. Warum nicht besser? — was eine realistische Lockerung wirklich bringt, gemessen statt hochgerechnet. 3. Was ist mit meinem Wunsch? — erfüllbar und zu welchem Preis, oder unmöglich und warum.

Vom Zahlenwerk zur Vorlage

Alle drei Antworten stehen bis hierhin in Tabellen. Tabellen liest niemand in einer Abteilungsleiterrunde. Teil (4) des Programms baut aus denselben Zahlen einen Text — und das ist weniger trivial, als es aussieht:

  • Kein Satz enthält eine Zahl, die nicht vorher gerechnet wurde. Auch nicht die Rangfolge, auch nicht die Namen der blockierenden Bedingungen. Sobald sich das Modell ändert, ändert sich der Bericht mit — er kann nicht leise falsch werden.
  • Der sprechende Name jeder Bedingung ist die Voraussetzung dafür. A_ub[7] ergibt keinen Satz. Deshalb tragen die Bedingungen in beiden Programmen einen Namen, und deshalb ist das keine Kosmetik.

⚠️ Was ein solcher Bericht nicht leistet Er erklärt das Modell, nicht die Wirklichkeit. „Liefervertrag Träger kostet 18 € je Stück“ heißt: in diesem Modell, mit diesen Deckungsbeiträgen. Ist ein Deckungsbeitrag falsch geschätzt, ist der Bericht überzeugend und falsch — die gefährlichste Kombination. Ein generierter Text macht ein Modell nicht richtiger, nur schwerer anzuzweifeln. Deshalb gehört zu jedem solchen Bericht die Angabe, worauf er beruht.


22.5 Architektur einer produktionsreifen OR-Plattform

Abb. 22.2: 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:

  1. 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.
  2. Zustandslosigkeit. Der Worker hält keinen Zustand; Eingabe und Ausgabe sind Daten. Das macht Skalierung und Wiederholung trivial.
  3. 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.
  4. Fallback-Strategie. Was passiert, wenn der Solver kein Ergebnis liefert? Antwort: letzter gültiger Plan, regelbasierte Notlösung, Alarm. Nie: nichts.
  5. 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.6 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
Abb. 22.3: Dieselben Schichten als Abhängigkeitsbild. Die Aussage steckt in der Pfeilrichtung: Alles zeigt nach innen auf die Domäne, und die Domäne zeigt auf nichts. Erzeugt von bilder_04/erzeuge_architektur_diagramme.py.

🎯 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.py importiert von sich aus keine Solverbibliothek. Die Statusübersetzer laden ihre Bibliothek erst beim Aufruf. Das ist kein Stilmittel, sondern notwendig: ortools und highspy vertragen 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.brauchbar fragt „habe ich etwas in der Hand?“ Nicht „war der Solver erfolgreich?“. ZULAESSIG ist im Betrieb meist völlig ausreichend (Abschnitt 6.8).
SolverStatus.modellfehler trennt Modell- von Zeitproblemen Genau die Unterscheidung, die Abschnitt 7.8 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.
ressourcen als sortierte Liste feste Spaltenreihenfolge Die Vertauschungsfalle aus Kapitel 18 wird hier strukturell unmöglich.
loese_mit_glop / loese_mit_scipy zwei 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 pandas in der Funktion Excel-Schicht als optionale Abhängigkeit Wer or_kern nur für Statusauswertung und Prüfung benutzt, soll pandas nicht installieren müssen.
schattenpreise im DTO Dualwerte 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.7 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?

ortools und highspy bringen 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 Hauptprozess

Das 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 multiprocessing
import time
from concurrent.futures import ProcessPoolExecutor

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, problem: Standortproblem) -> Loesung:
    """Laesst genau einen Modellbauer in einem frischen Prozess rechnen.

    'spawn' statt des Linux-Standards 'fork': Der Kindprozess startet mit
    einem leeren Interpreter und importiert nur den Solver, den SEIN
    Modellbauer braucht. max_tasks_per_child=1 sorgt dafuer, dass der Pool
    seinen Arbeiter nicht wiederverwendet - sonst saessen beim zweiten Aufruf
    wieder beide Bibliotheken im selben Prozess.

    Hin und zurueck wandert das Domaenenmodell bzw. das Loesungs-DTO. Beide
    kennen keinen Solver, sind also serialisierbar - genau dafuer sind sie da.
    """
    with ProcessPoolExecutor(
            max_workers=1,
            mp_context=multiprocessing.get_context("spawn"),
            max_tasks_per_child=1) as pool:
        return pool.submit(MODELLBAUER[name], problem).result(timeout=300)


if __name__ == "__main__":
    problem = beispielproblem()

    # --- 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, problem)
        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 Standortproblem sind deshalb int. 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.py in 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.8 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.9 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.10 Ü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 der Aufgabe Eigener Dienstplan (Abschnitt 7.9) 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 ⭐⭐ — Den Bericht in die Irre führen. Constraint_Attribution.py erzeugt seinen Bericht aus gerechneten Zahlen — richtig ist er deshalb noch nicht. (a) Setzen Sie den Deckungsbeitrag von Deckel von 9 € auf 20 €. Wie ändern sich Plan, Rangliste und Bericht? Welche Sätze bleiben wörtlich stehen, obwohl sie jetzt etwas anderes bedeuten? (b) Der Bericht nennt „Liefervertrag Träger” als teuerste Bindung. Formulieren Sie den Satz so um, dass ein Leser erkennt, worauf die Aussage beruht. (c) Welche Angabe müsste das Programm zusätzlich ausgeben, damit ein Empfänger die Belastbarkeit selbst einschätzen kann?

Aufgabe 22.6 ⭐⭐⭐ — 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.7 ⭐⭐⭐ — 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.11 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.12 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.13 Selbsttest

Antworten: Anhang A.

  1. Wie verwandelt man ein unlösbares Modell in einen brauchbaren Notfallplan?
  2. Welche drei Fragen muss ein erklärbares OR-System beantworten können?
  3. Warum ist ein MIP-Gap von 2 % in der Praxis meist ausreichend?
  4. Was ist das Snapshot-Prinzip und wozu dient es?
  5. Was ist der häufigste Grund, aus dem OR-Projekte scheitern?
  6. Eine Bedingung bindet, ihr Schattenpreis ist 0. Was heißt das — und was folgt daraus für eine Nachverhandlung?
  7. Warum kann die Rangfolge nach Schattenpreis eine andere sein als die Rangfolge nach tatsächlichem Nutzen einer Lockerung?

22.14 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.
  • Bindend heißt nicht teuer. Eine Bedingung kann den Plan berühren und trotzdem 0,00 € kosten. Nur die kostenrelevanten lohnen eine Nachverhandlung.
  • Der Schattenpreis gilt für die nächste Einheit, nicht für die nächste Schicht. Hochgerechnet auf einen realistischen Schritt überschätzt er den Nutzen — im Beispiel um das Doppelte. Wer wissen will, was ein Hebel bringt, misst die Lockerung, statt zu multiplizieren; die beiden Ranglisten können sich dabei umdrehen.
  • Ein Wunsch kann unmöglich statt teuer sein — und dann ist die richtige Antwort nicht ein Preis, sondern die Liste der Bedingungen, die ihn gemeinsam blockieren.
  • Ein generierter Bericht macht ein Modell nicht richtiger, nur schwerer anzuzweifeln. Er erklärt das Modell, nicht die Wirklichkeit — deshalb gehört dazu, worauf er beruht.
  • 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.