Kapitel 7: Constraint Programming mit CP-SAT — Logik, Scheduling und Zuweisung

📌 Kapitel auf einen Blick

Worum geht es? Um ein völlig anderes Denkmodell: Statt algebraischer Ungleichungen arbeitet Constraint Programming mit Wertebereichen und logischen Regeln. Für Dienstpläne, Zuordnungen und Reihenfolgen ist das die mit Abstand produktivste Methode.

Voraussetzungen: Kapitel 6 (Binärvariablen, logische Bedingungen).

Danach können Sie: Zuweisungs-, Scheduling- und Reihenfolgeprobleme mit CP-SAT modellieren, globale Constraints einsetzen, weiche Ziele über Strafkosten steuern — und alle fünf Solver-Antworten auseinanderhalten, statt nur auf OPTIMAL zu prüfen.

Zeitbedarf: ca. 6,5 Stunden.

Programme:
Propagation_Demo.py
CP_SAT_Vertretungssystem.py
JobShop_Intervalle.py
CP_SAT_Statusfaelle.py
Strafgewichte.py

Notebook: cpsat.ipynb
In Google Colab öffnen


7.1 In 5 Minuten gelöst

🚀 In 5 Minuten gelöst: Der Wochendienstplan

Vier Personen, fünf Tage Bereitschaftsdienst. Jeder Tag muss von genau einer Person besetzt sein, niemand soll mehr als zwei Dienste bekommen, und drei Abwesenheiten sind bekannt.

from ortools.sat.python import cp_model

personen = ["Abel", "Bux", "Cor", "Dane"]
dienste = ["Mo", "Di", "Mi", "Do", "Fr"]
nicht_verfuegbar = {("Bux", "Mi"), ("Cor", "Mo"), ("Cor", "Di")}

m = cp_model.CpModel()
x = {(p, d): m.NewBoolVar(f"{p}_{d}") for p in personen for d in dienste}
for d in dienste:
    m.AddExactlyOne(x[p, d] for p in personen)          # jeder Tag genau einmal besetzt
for p in personen:
    m.Add(sum(x[p, d] for d in dienste) <= 2)           # niemand mehr als zwei Dienste
for p, d in nicht_verfuegbar:
    m.Add(x[p, d] == 0)                                 # Abwesenheiten

s = cp_model.CpSolver()
print(s.StatusName(s.Solve(m)))
for d in dienste:
    print(d, "->", next(p for p in personen if s.Value(x[p, d])))

Ausgabe:

OPTIMAL
Mo -> Dane
Di -> Dane
Mi -> Abel
Do -> Bux
Fr -> Abel

Elf Zeilen Modell, und der Plan steht. Beachten Sie besonders die Zeile m.AddExactlyOne(...). Sie sagt wörtlich, was gemeint ist: genau eine Person je Tag. In MILP hätte man dafür eine Gleichung \sum_p x_{p,d} = 1 aufgestellt — machbar, aber schon eine Übersetzungsleistung. Bei Regeln wie „diese beiden Dienste nicht direkt hintereinander“ oder „höchstens eine Nachtschicht pro Woche“ wächst dieser Übersetzungsaufwand rasch; in CP-SAT bleibt er eine Zeile.

Und jetzt sehen Sie sich das Ergebnis genau an. Cor bekommt keinen einzigen Dienst. Dane arbeitet Montag und Dienstag hintereinander. Ist das ein Fehler?

Nein — und das ist die wichtigste Lektion dieses Kapitels. Der Plan erfüllt jede Regel, die wir aufgeschrieben haben. Wir haben nur nie nach Fairness gefragt, und wir haben nie gesagt, dass Dienste nicht aufeinanderfolgen sollen. Ein Solver liefert exakt das Bestellte — nicht das Gemeinte.

🎯 Merksatz Ein Modell, das durchläuft, ist noch kein richtiges Modell. Die eigentliche Arbeit im Constraint Programming ist nicht das Lösen, sondern das vollständige Aufschreiben dessen, was „ein guter Plan“ überhaupt heißt. Wie man Wünsche wie Fairness als weiche Bedingungen mit Strafkosten formuliert, zeigt Abschnitt 7.5 — und Abschnitt 7.9 zeigt, was passiert, wenn man ihr Gewicht falsch wählt.


7.2 Lernziele

Nach diesem Kapitel können Sie …

  1. … erklären, wodurch sich Constraint Programming von MILP unterscheidet.
  2. … die Wirkung von Constraint Propagation an einem Beispiel nachvollziehen.
  3. … die wichtigsten globalen Constraints einsetzen: AddExactlyOne, AddAllDifferent, AddNoOverlap, AddCumulative.
  4. … mit Intervallvariablen ein Maschinenbelegungsproblem modellieren.
  5. … harte Regeln und weiche Ziele in einem Modell kombinieren und gewichten.
  6. … die fünf CP-SAT-Statusfälle unterscheiden — insbesondere INFEASIBLE (es gibt keine Lösung) von UNKNOWN (es wurde keine gefunden) und MODEL_INVALID (Ihr Code ist fehlerhaft).
  7. … begründen, warum ein Strafgewicht ein Wechselkurs ist, und welche Regeln deshalb niemals in die Zielfunktion gehören.

7.3 Ein anderes Denkmodell

Während LP und MILP auf algebraischen Gleichungen und Simplex-Matrizen beruhen, kommt Constraint Programming (CP) aus der künstlichen Intelligenz:

MILP Constraint Programming
Variablen reell oder ganzzahlig, mit Schranken diskrete Wertebereiche (domains)
Bedingungen lineare Ungleichungen beliebige logische Relationen
Suchprinzip Relaxation + Verzweigen + Schranken Propagation + Verzweigen + Konfliktlernen
Stärke ökonomische Modelle, Kosten, Kapazitäten Zuordnung, Reihenfolge, Zeitfenster, Regeln
Schwäche viele logische Regeln → viele Big-M reine Kostenoptimierung über Kontinuum

Der CP-SAT-Solver von Google OR-Tools verbindet Constraint Programming mit modernen SAT-Techniken (Boolean Satisfiability, Erfüllbarkeitsproblem der Aussagenlogik), insbesondere CDCL (Conflict-Driven Clause Learning, konfliktgetriebenes Klausellernen). Er gehört zu den weltweit leistungsfähigsten Engines für Schichtpläne, Vertretungsplanung, Projektterminierung und Job-Shop-Scheduling.

Was Propagation leistet

Abb. 7.1: CP-SAT – Propagation und Conflict Learning

✏️ Handrechnung 7.1: Propagation von Hand

Gegeben: x_1 \in \{1,2,3,4\}, x_2 \in \{2,3,4,5\}, Bedingung x_1 + x_2 = 7.

Runde 1 — von x_1 aus: * x_1 = 1x_2 = 6. Aber 6 \notin \{2,3,4,5\}1 aus der Domain von x_1 streichen. * x_1 = 2x_2 = 5 ✓ bleibt. * x_1 = 3,4x_2 = 4,3 ✓ bleiben. Neue Domain: x_1 \in \{2,3,4\}.

Runde 2 — von x_2 aus: * x_2 = 2x_1 = 5 \notin \{2,3,4\}streichen. * x_2 = 3,4,5x_1 = 4,3,2 ✓ Neue Domain: x_2 \in \{3,4,5\}.

Aus 4 \times 4 = 16 Kombinationen sind ohne jedes Ausprobieren 9 geworden. Bei tausenden verketteten Bedingungen wirkt dieser Effekt lawinenartig — das ist der Kern der Leistungsfähigkeit von CP.

Und wenn ein Widerspruch auftritt? Dann lernt der SAT-Kern eine Sperrklausel (nogood): „Diese Kombination nie wieder probieren.“ Der Solver springt dann nicht nur einen Schritt zurück, sondern direkt zur Ursache des Konflikts (non-chronological backtracking).

#!/usr/bin/env python3

# Propagation_Demo.py
"""
Kapitel CP-SAT: Propagation sichtbar machen.
Vergleicht die Zahl der geprueften Kombinationen bei roher Aufzaehlung
mit der Zahl der Verzweigungen, die CP-SAT tatsaechlich braucht.
"""

import itertools

from ortools.sat.python import cp_model


def rohe_aufzaehlung(n: int, obergrenze: int) -> tuple[int, int]:
    """Zaehlt ALLE Kombinationen und prueft jede einzeln (Brute Force)."""
    geprueft = 0
    loesungen = 0
    for kombination in itertools.product(range(1, obergrenze + 1), repeat=n):
        geprueft += 1
        if len(set(kombination)) == n and sum(kombination) == 3 * n:
            loesungen += 1
    return geprueft, loesungen


def mit_cp_sat(n: int, obergrenze: int) -> tuple[int, int, float]:
    """Dasselbe Problem deklarativ: alle verschieden, Summe = 3n."""
    modell = cp_model.CpModel()
    x = [modell.NewIntVar(1, obergrenze, f"x{i}") for i in range(n)]
    modell.AddAllDifferent(x)                 # globales Constraint
    modell.Add(sum(x) == 3 * n)

    loeser = cp_model.CpSolver()
    loeser.parameters.enumerate_all_solutions = True

    class Zaehler(cp_model.CpSolverSolutionCallback):
        def __init__(self):
            super().__init__()
            self.anzahl = 0

        def on_solution_callback(self):
            self.anzahl += 1

    zaehler = Zaehler()
    loeser.Solve(modell, zaehler)
    return zaehler.anzahl, loeser.NumBranches(), loeser.WallTime()


if __name__ == "__main__":
    print("=" * 84)
    print("  PROPAGATION: WIE VIEL ARBEIT SPART SICH DER SOLVER?")
    print("  Aufgabe: n Zahlen aus 1..G, alle verschieden, Summe = 3n")
    print("=" * 84)
    print(f"{'n':>3} {'G':>4} | {'Brute Force':>14} | {'CP-SAT':>10} | "
          f"{'Ersparnis':>11} | {'Loesungen':>10}")
    print("-" * 84)

    for n, grenze in [(4, 8), (5, 10), (6, 12), (7, 14)]:
        kombis, treffer_bf = rohe_aufzaehlung(n, grenze)
        treffer_cp, verzweigungen, dauer = mit_cp_sat(n, grenze)
        assert treffer_bf == treffer_cp, "Beide Verfahren muessen gleich viele finden!"
        ersparnis = kombis / max(verzweigungen, 1)
        print(f"{n:>3} {grenze:>4} | {kombis:>14,} | {verzweigungen:>10,} | "
              f"{ersparnis:>10.0f}x | {treffer_cp:>10,}")

    print("-" * 84)
    print("'Brute Force' = alle Kombinationen. 'CP-SAT' = tatsaechliche Verzweigungen.")
    print("Der Rest wurde durch Propagation ausgeschlossen, ohne ihn anzusehen.")
    print("=" * 84)

🎯 Merksatz MILP fragt: „Wie weit kann ich den Zielwert noch verbessern?“ (Schranken). CP fragt: „Welche Werte sind überhaupt noch möglich?“ (Propagation). Bei Problemen, deren Schwierigkeit in den Regeln liegt und nicht in den Kosten, ist die zweite Frage die produktivere.


7.4 Globale Constraints — die Bausteine von CP-SAT

CP-SAT stellt mächtige Makros bereit, die in MILP hunderte Big-M-Ungleichungen erfordern würden. Jedes davon hat einen spezialisierten, hocheffizienten Propagator.

Constraint Bedeutung Typische Anwendung
AddAllDifferent(vars) Alle Variablen haben paarweise verschiedene Werte Sudoku, Zuordnung, Stundenplan-Räume
AddExactlyOne(bools) Genau eine der Booleschen ist wahr „Jede Schicht wird von genau einer Person besetzt“
AddAtMostOne(bools) Höchstens eine ist wahr Exklusive Ressourcennutzung
AddBoolOr(bools) Mindestens eine ist wahr „Mindestens eine Fachkraft im Dienst“
AddImplication(a, b) a \Rightarrow b „Wenn Frühschicht, dann keine Nachtschicht davor“
NewIntervalVar(start, size, end, name) Zeitintervall mit Start, Dauer, Ende Aufträge, Operationen, Belegungen
AddNoOverlap(intervals) Intervalle überschneiden sich nicht Eine Maschine, ein Raum, eine Person
AddCumulative(intervals, bedarfe, kapazitaet) Zu keinem Zeitpunkt übersteigt der Parallelverbrauch die Kapazität Personalstärke, Stromlast, Kranstunden
AddCircuit(arcs) Die gewählten Kanten bilden einen Rundweg TSP-artige Reihenfolgeprobleme
.OnlyEnforceIf(bool) Bedingung gilt nur wenn Reifizierung: Regel an-/abschaltbar machen

🔤 Formel-Übersetzer: was ein globales Constraint zusichert

Globale Constraints tragen englische Methodennamen, meinen aber alltägliche Regeln. Links die mathematische Zusicherung, rechts der Satz, den Sie im Planungsgespräch sagen würden:

Mathematik Alltagssprache
\sum_{p \in P} x_{p,s} = 1 \quad \forall s (AddExactlyOne) „Jede Schicht wird von genau einer Person übernommen — keine bleibt offen, keine ist doppelt besetzt.“
\sum_{p \in P} x_{p,s} \le 1 (AddAtMostOne) „Höchstens einer — es darf aber auch niemand.“
x_i \ne x_j \quad \forall i \ne j (AddAllDifferent) „Keine zwei bekommen dasselbe“ — dieselbe Maschine, denselben Raum, dieselbe Startzeit.
a \Rightarrow b (AddImplication) „Wenn das eine gilt, muss auch das andere gelten.“ Die Umkehrung ist damit nicht gefordert.
[s_i, e_i) \cap [s_j, e_j) = \emptyset (AddNoOverlap) „Diese Ressource kann immer nur eines auf einmal — was sich zeitlich überschneidet, ist verboten.“
\sum_{i:\, t \in [s_i, e_i)} r_i \le C \quad \forall t (AddCumulative) „Zu keinem Zeitpunkt dürfen mehr als C Einheiten gleichzeitig gebraucht werden“ — Personalstärke, Stromlast, Kranstunden.
Bedingung gilt nur falls b = 1 (.OnlyEnforceIf(b)) „Diese Regel gilt nur, wenn … “ — so wird aus einer festen Regel eine ein- und ausschaltbare.

Der Unterschied zu MILP in einem Satz: Dort formuliert man Ungleichungen, aus denen die Regel folgt; hier schreibt man die Regel selbst hin.

💡 Warum globale Constraints so viel bringen AddAllDifferent über 9 Variablen ersetzt \binom{9}{2} = 36 paarweise Ungleichheitsbedingungen — und propagiert stärker als die 36 Einzelbedingungen zusammen, weil der Propagator die Struktur als Ganzes betrachtet (er nutzt Matching-Theorie auf bipartiten Graphen). Ein MILP-Modell derselben Regel bräuchte Big-M-Konstruktionen und würde deutlich langsamer laufen.


7.5 Praxisfall: Dynamisches Vertretungssystem

Problemstellung. Eine Schule muss nach spontanen Ausfällen vier Unterrichtsstunden nachbesetzen.

  • 4 offene Stunden (Zeitslots 1–4), 5 verfügbare Lehrkräfte.
  • Harte Regeln:
    1. Jede Stunde wird von genau einer Person übernommen.
    2. Niemand kann an zwei Orten gleichzeitig sein.
    3. Qualifikation: Nur fachlich qualifizierte Personen dürfen übernehmen.
    4. Maximallast: Höchstens 2 Vertretungsstunden pro Person und Tag.
  • Weiche Ziele (über Strafkosten):
    • Personen mit hoher Vorbelastung möglichst schonen.
    • Freistundenlöcher vermeiden (Stunde 1 und 3 arbeiten, Stunde 2 frei).
    • Last fair verteilen.
#!/usr/bin/env python3

# CP_SAT_Vertretungssystem.py
"""
Kapitel CP-SAT: Dynamisches Vertretungs- und Einsatzplanungssystem mit CP-SAT.

Eigenschaften:
  * Solver-Status wird ueber SolverStatus aus or_kern.py ausgewertet - dieselbe
    Fallunterscheidung wie bei jedem anderen Solver im Buch
  * Strafkosten-Zerlegung wird ausgewiesen (Erklaerbarkeit, Kapitel Praxisfallen)
  * Fairness-Kriterium ergaenzt (Spannweite der Belastung minimieren)
  * Vorabdiagnose auf offensichtliche Unloesbarkeit
  * Abnahmepruefung des fertigen Plans OHNE den Solver zu fragen

Benoetigt: ortools, pandas, pydantic (ueber or_kern)
"""

from __future__ import annotations

from ortools.sat.python import cp_model
import pandas as pd

from or_kern import Loesung, SolverStatus, status_von_cpsat

# --- 1. Datenbasis --------------------------------------------------------
SLOTS = [1, 2, 3, 4]
FAECHER = ["Mathematik", "Physik", "Mathematik", "Informatik"]
KLASSEN = ["Klasse 8a", "Klasse 10b", "Klasse 7a", "Klasse 9c"]

PERSONAL = ["Frau_Mueller", "Herr_Schmidt", "Frau_Albrecht", "Herr_Bauer", "Frau_Koch"]

QUALIFIKATION = {
    "Frau_Mueller":  {"Mathematik", "Physik"},
    "Herr_Schmidt":  {"Informatik", "Mathematik"},
    "Frau_Albrecht": {"Physik", "Informatik"},
    "Herr_Bauer":    {"Mathematik"},
    "Frau_Koch":     {"Mathematik", "Physik", "Informatik"},
}

VORBELASTUNG = {                      # bereits geleistete Stunden heute
    "Frau_Mueller": 1, "Herr_Schmidt": 0, "Frau_Albrecht": 2,
    "Herr_Bauer": 0, "Frau_Koch": 1,
}

MAX_VERTRETUNGEN = 2
STRAFE_VORBELASTUNG = 50              # je Stunde Vorbelastung und Zuweisung
STRAFE_LOCH = 80                      # je Freistundenloch
STRAFE_UNFAIRNESS = 30                # je Einheit Spannweite der Gesamtbelastung


def pruefe_grundsaetzliche_loesbarkeit() -> bool:
    """Vorabdiagnose: Gibt es fuer jeden Slot ueberhaupt qualifiziertes Personal?"""
    ok = True
    for s, fach in enumerate(FAECHER):
        kandidaten = [p for p in PERSONAL if fach in QUALIFIKATION[p]]
        if not kandidaten:
            print(f"  UNLOESBAR: Fuer Slot {SLOTS[s]} ({fach}) gibt es niemanden.")
            ok = False
    kapazitaet = len(PERSONAL) * MAX_VERTRETUNGEN
    if kapazitaet < len(SLOTS):
        print(f"  UNLOESBAR: Kapazitaet {kapazitaet} < {len(SLOTS)} offene Stunden.")
        ok = False
    return ok


def pruefe_plan(plan: dict[int, str]) -> list[str]:
    """Prueft den fertigen Plan gegen die harten Regeln - ohne den Solver.

    Der Solver kann nur pruefen, was ihm gesagt wurde. Diese Funktion prueft
    gegen die ANFORDERUNG und benutzt dafuer bewusst keine Modellvariable
    (siehe Kapitel Praxisfallen). Leere Liste heisst bestanden.
    """
    beanstandungen: list[str] = []
    if sorted(plan) != list(range(len(SLOTS))):
        beanstandungen.append(f"nicht jede Stunde genau einmal besetzt: {sorted(plan)}")
        return beanstandungen
    for s, person in plan.items():
        if FAECHER[s] not in QUALIFIKATION[person]:
            beanstandungen.append(f"{person} ist nicht fuer {FAECHER[s]} qualifiziert")
    for person in PERSONAL:
        anzahl = sum(1 for p in plan.values() if p == person)
        if anzahl > MAX_VERTRETUNGEN:
            beanstandungen.append(f"{person} hat {anzahl} Vertretungen "
                                  f"(hoechstens {MAX_VERTRETUNGEN})")
    return beanstandungen


def plane_vertretung() -> list[dict[str, str]] | None:
    if not pruefe_grundsaetzliche_loesbarkeit():
        return None

    modell = cp_model.CpModel()

    # Entscheidungsvariablen: x[person, slot] = 1  <=>  Person uebernimmt Slot
    x = {(p, s): modell.NewBoolVar(f"zuweisung_{p}_slot{s+1}")
         for p in PERSONAL for s in range(len(SLOTS))}

    # --- HARTE NEBENBEDINGUNGEN ------------------------------------------
    # H1: Jede Stunde genau einmal besetzen
    for s in range(len(SLOTS)):
        modell.AddExactlyOne(x[p, s] for p in PERSONAL)

    # H2: Qualifikation - unqualifizierte Zuweisung ausschliessen
    for s, fach in enumerate(FAECHER):
        for p in PERSONAL:
            if fach not in QUALIFIKATION[p]:
                modell.Add(x[p, s] == 0)

    # H3: Hoechstens MAX_VERTRETUNGEN Stunden je Person
    for p in PERSONAL:
        modell.Add(sum(x[p, s] for s in range(len(SLOTS))) <= MAX_VERTRETUNGEN)

    # (H4 "nicht an zwei Orten gleichzeitig" ist durch H1 bereits erfuellt:
    #  jeder Slot hat genau eine Person, und eine Person kann pro Slot nur
    #  eine Variable auf 1 setzen.)

    # --- WEICHE ZIELE ----------------------------------------------------
    strafterme = []

    # W1: Vorbelastete Personen schonen
    strafe_last = []
    for p in PERSONAL:
        zuweisungen = sum(x[p, s] for s in range(len(SLOTS)))
        strafe_last.append(VORBELASTUNG[p] * STRAFE_VORBELASTUNG * zuweisungen)
    strafterme.extend(strafe_last)

    # W2: Freistundenloecher vermeiden (Muster: arbeitet, frei, arbeitet)
    loch_variablen = []
    for p in PERSONAL:
        for s in range(len(SLOTS) - 2):
            loch = modell.NewBoolVar(f"loch_{p}_{s}")
            # Reifizierung: loch == 1  <=>  (x_s AND NOT x_{s+1} AND x_{s+2})
            modell.AddBoolAnd([x[p, s], x[p, s + 1].Not(), x[p, s + 2]]).OnlyEnforceIf(loch)
            modell.AddBoolOr([x[p, s].Not(), x[p, s + 1], x[p, s + 2].Not()]) \
                  .OnlyEnforceIf(loch.Not())
            loch_variablen.append(loch)
            strafterme.append(loch * STRAFE_LOCH)

    # W3: Fairness - Spannweite der Gesamtbelastung minimieren
    gesamtlast = {}
    for p in PERSONAL:
        last = modell.NewIntVar(0, len(SLOTS) + max(VORBELASTUNG.values()), f"last_{p}")
        modell.Add(last == VORBELASTUNG[p] + sum(x[p, s] for s in range(len(SLOTS))))
        gesamtlast[p] = last
    max_last = modell.NewIntVar(0, 10, "max_last")
    min_last = modell.NewIntVar(0, 10, "min_last")
    modell.AddMaxEquality(max_last, list(gesamtlast.values()))
    modell.AddMinEquality(min_last, list(gesamtlast.values()))
    spannweite = modell.NewIntVar(0, 10, "spannweite")
    modell.Add(spannweite == max_last - min_last)
    strafterme.append(spannweite * STRAFE_UNFAIRNESS)

    modell.Minimize(sum(strafterme))

    # --- LOESEN -----------------------------------------------------------
    loeser = cp_model.CpSolver()
    loeser.parameters.max_time_in_seconds = 5.0
    # Reproduzierbarkeit: ein Arbeiter, fester Startwert. Ohne das kann CP-SAT
    # bei mehreren gleich guten Plaenen von Lauf zu Lauf einen anderen liefern
    # (siehe JobShop_Intervalle.py im naechsten Abschnitt).
    loeser.parameters.num_workers = 1
    loeser.parameters.random_seed = 1
    rohstatus = loeser.Solve(modell)

    # Der Rueckgabewert wird in die gemeinsame Sprache uebersetzt. Ab hier
    # sieht die Auswertung genauso aus wie bei HiGHS, GLOP oder CVXPY.
    status = status_von_cpsat(rohstatus)
    loesung = Loesung(
        status=status,
        zielwert=loeser.ObjectiveValue() if status.brauchbar else None,
        schranke=loeser.BestObjectiveBound() if status.brauchbar else None,
        laufzeit=loeser.WallTime())

    print("=" * 78)
    print("     DYNAMISCHER VERTRETUNGSPLAN (CP-SAT OPTIMIERT)")
    print("=" * 78)

    if status.modellfehler:
        print(f"Kein zulaessiger Plan moeglich (Status: {status.value}).")
        print("Die harten Regeln widersprechen sich. Naechster Schritt: eine Regel")
        print("weich machen (Kapitel Praxisfallen) oder den Konflikt eingrenzen")
        print("(Anhang Fehlerdiagnose).")
        return None
    if not status.brauchbar:
        print(f"Solver lieferte kein Ergebnis (Status: {status.value}).")
        print("Zeitlimit erhoehen oder das Modell vereinfachen.")
        return None

    guete = ("beweisbar optimal" if status is SolverStatus.OPTIMAL
             else "zulaessig, aber nicht bewiesen - Gap beachten")
    print(f"Solver-Status: {loeser.StatusName(rohstatus)} -> {status.value} ({guete})")
    print(f"{loesung.als_bericht()}\n")

    # --- Plan ausgeben ----------------------------------------------------
    zuweisung = {s: next(p for p in PERSONAL if loeser.Value(x[p, s]) == 1)
                 for s in range(len(SLOTS))}
    plan = [{
        "Stunde": f"Std. {SLOTS[s]}",
        "Klasse": KLASSEN[s],
        "Fach": FAECHER[s],
        "Vertretung": zuweisung[s],
        "Vorbelastung": f"{VORBELASTUNG[zuweisung[s]]} Std.",
    } for s in range(len(SLOTS))]
    print(pd.DataFrame(plan).to_string(index=False))

    # --- Abnahmepruefung gegen die Anforderung ----------------------------
    beanstandungen = pruefe_plan(zuweisung)
    if beanstandungen:
        print("\nABNAHMEPRUEFUNG FEHLGESCHLAGEN:")
        for beanstandung in beanstandungen:
            print(f"  - {beanstandung}")
        return None
    print("\nAbnahmepruefung: bestanden (Besetzung, Qualifikation, Hoechstzahl).")

    # --- Erklaerbarkeit: Strafkosten aufschluesseln -----------------------
    kosten_last = sum(VORBELASTUNG[p] * STRAFE_VORBELASTUNG
                      * sum(loeser.Value(x[p, s]) for s in range(len(SLOTS)))
                      for p in PERSONAL)
    kosten_loecher = sum(loeser.Value(v) for v in loch_variablen) * STRAFE_LOCH
    kosten_fairness = loeser.Value(spannweite) * STRAFE_UNFAIRNESS

    print("\n--- Woraus bestehen die Strafkosten? ---")
    print(f"  Vorbelastung geschont:  {kosten_last:5.0f} Punkte")
    print(f"  Freistundenloecher:     {kosten_loecher:5.0f} Punkte "
          f"({sum(loeser.Value(v) for v in loch_variablen)} Loch/Loecher)")
    print(f"  Fairness (Spannweite {loeser.Value(spannweite)}): {kosten_fairness:5.0f} Punkte")
    print(f"  {'Summe':<23} {loeser.ObjectiveValue():5.0f} Punkte")

    print("\n--- Auslastung nach Optimierung ---")
    for p in PERSONAL:
        heute = sum(loeser.Value(x[p, s]) for s in range(len(SLOTS)))
        print(f"  * {p:<15}: {VORBELASTUNG[p]} vorher + {heute} Vertretung "
              f"= {VORBELASTUNG[p] + heute} Stunden")

    print(f"\nSuchstatistik: {loeser.NumBranches()} Verzweigungen, "
          f"{loeser.NumConflicts()} Konflikte, {loeser.WallTime():.3f} s")
    print("=" * 78)
    return plan


if __name__ == "__main__":
    plane_vertretung()

Erwartete Ausgabe (Laufzeit und Suchstatistik sind hardwareabhängig):

==============================================================================
     DYNAMISCHER VERTRETUNGSPLAN (CP-SAT OPTIMIERT)
==============================================================================
Solver-Status: OPTIMAL -> optimal (beweisbar optimal)
Status: optimal | Zielwert: 80.00 | Gap: 0.00% | Zeit: 0.01s

Stunde     Klasse       Fach   Vertretung Vorbelastung
Std. 1  Klasse 8a Mathematik Herr_Schmidt       0 Std.
Std. 2 Klasse 10b     Physik Frau_Mueller       1 Std.
Std. 3  Klasse 7a Mathematik   Herr_Bauer       0 Std.
Std. 4  Klasse 9c Informatik Herr_Schmidt       0 Std.

Abnahmepruefung: bestanden (Besetzung, Qualifikation, Hoechstzahl).

--- Woraus bestehen die Strafkosten? ---
  Vorbelastung geschont:     50 Punkte
  Freistundenloecher:         0 Punkte (0 Loch/Loecher)
  Fairness (Spannweite 1):    30 Punkte
  Summe                      80 Punkte

--- Auslastung nach Optimierung ---
  * Frau_Mueller   : 1 vorher + 1 Vertretung = 2 Stunden
  * Herr_Schmidt   : 0 vorher + 2 Vertretung = 2 Stunden
  * Frau_Albrecht  : 2 vorher + 0 Vertretung = 2 Stunden
  * Herr_Bauer     : 0 vorher + 1 Vertretung = 1 Stunden
  * Frau_Koch      : 1 vorher + 0 Vertretung = 1 Stunden

Suchstatistik: 113 Verzweigungen, 3 Konflikte, 0.006 s
==============================================================================

💻 Code-Durchgang: die drei wichtigsten Stellen

1. Reifizierung (W2). Die zwei Zeilen

modell.AddBoolAnd([x[p,s], x[p,s+1].Not(), x[p,s+2]]).OnlyEnforceIf(loch)
modell.AddBoolOr([x[p,s].Not(), x[p,s+1], x[p,s+2].Not()]).OnlyEnforceIf(loch.Not())

koppeln die Hilfsvariable loch in beide Richtungen an das Muster „arbeitet–frei–arbeitet“. Die erste Zeile erzwingt: wenn loch = 1, dann liegt das Muster vor. Die zweite: wenn loch = 0, dann liegt es nicht vor. In diesem Modell genügte streng genommen die erste Zeile, weil loch nur mit positiven Strafkosten vorkommt und der Minimierer es daher von selbst auf 0 drückt — die zweite Zeile macht das Modell aber robust gegen spätere Änderungen und ist selbstdokumentierend.

2. Fairness über Spannweite (W3). AddMaxEquality und AddMinEquality sind CP-SAT-Konstrukte, die in MILP mehrere Big-M-Ungleichungen bräuchten. Die Spannweite \max - \min zu minimieren ist ein Standardrezept für „gleichmäßig verteilen“.

3. Kostenzerlegung. Der Block „Woraus bestehen die Strafkosten?“ ist kein Beiwerk. In der Praxis ist die erste Frage jedes Anwenders: „Warum bekomme ich die Stunde?“ Wer darauf keine Antwort hat, dessen System wird nicht benutzt (Kapitel 22, Explainable OR).

4. Statusauswertung ohne CP-SAT-Vokabular. Nach Solve() steht nur eine einzige Zeile mit einer CP-SAT-Konstante darin:

status = status_von_cpsat(rohstatus)

Danach heißen die Fälle status.modellfehler (die Regeln widersprechen sich — das Modell muss geändert werden) und status.brauchbar (es liegt etwas Ausführbares vor). Das ist dieselbe Fallunterscheidung wie bei HiGHS in Kapitel 6 und bei GLOP in Kapitel 5; nur die eine Übersetzerzeile weiß, welcher Solver gerechnet hat. Wer den Auswertungscode direkt gegen cp_model.INFEASIBLE schreibt, kann ihn bei einem Solverwechsel wegwerfen — Abschnitt 22.7 führt genau diesen Wechsel vor.

⚠️ Typische Fehler

  • Zu viele harte Regeln. Jede zusätzliche harte Bedingung erhöht das Risiko von INFEASIBLE. Faustregel: Hart ist nur, was rechtlich oder physikalisch unmöglich ist. Alles andere wird weich mit hoher Strafe — dann liefert der Solver den „am wenigsten schlechten“ Plan statt einer Fehlermeldung.
  • Strafgewichte willkürlich wählen. Die Gewichte (50, 80, 30) definieren implizit eine Werteordnung: „Ein Freistundenloch ist so schlimm wie 1,6 Stunden Vorbelastung.“ Besprechen Sie diese Zahlen mit den Betroffenen — sie sind eine fachliche, keine technische Entscheidung.
  • Gleitkommazahlen in CP-SAT. CP-SAT rechnet ausschließlich ganzzahlig. Wer Euro und Cent braucht, rechnet in Cent.

7.6 Intervallvariablen: Job-Shop-Scheduling

Die Königsdisziplin von CP-SAT sind Zeitplanungsprobleme. Intervallvariablen sind dabei das zentrale Werkzeug.

Problem. Drei Aufträge, je drei Arbeitsgänge auf drei Maschinen. Jeder Auftrag muss seine Gänge in fester Reihenfolge durchlaufen; jede Maschine kann nur einen Gang gleichzeitig bearbeiten. Gesucht: der Plan mit der kürzesten Gesamtdauer (Makespan).

#!/usr/bin/env python3

# JobShop_Intervalle.py
"""
Kapitel CP-SAT: Job-Shop-Scheduling mit Intervallvariablen.
Zeigt NewIntervalVar, AddNoOverlap und die Minimierung des Makespan.
"""

import collections

from ortools.sat.python import cp_model

# (Maschine, Dauer) je Arbeitsgang, in der Reihenfolge des Auftrags
AUFTRAEGE = [
    [(0, 3), (1, 2), (2, 2)],      # Auftrag 0
    [(0, 2), (2, 1), (1, 4)],      # Auftrag 1
    [(1, 4), (2, 3)],              # Auftrag 2
]
MASCHINENNAMEN = ["Fraese", "Dreherei", "Lackiererei"]


def loese_jobshop():
    anzahl_maschinen = 1 + max(m for auftrag in AUFTRAEGE for m, _ in auftrag)
    horizont = sum(dauer for auftrag in AUFTRAEGE for _, dauer in auftrag)

    modell = cp_model.CpModel()
    Gang = collections.namedtuple("Gang", "start ende intervall")
    plaene = {}
    maschinen_intervalle = collections.defaultdict(list)

    # --- Intervallvariablen anlegen --------------------------------------
    for a, auftrag in enumerate(AUFTRAEGE):
        for g, (maschine, dauer) in enumerate(auftrag):
            start = modell.NewIntVar(0, horizont, f"start_{a}_{g}")
            ende = modell.NewIntVar(0, horizont, f"ende_{a}_{g}")
            # Ein Intervall koppelt start + dauer == ende automatisch
            intervall = modell.NewIntervalVar(start, dauer, ende, f"intervall_{a}_{g}")
            plaene[a, g] = Gang(start, ende, intervall)
            maschinen_intervalle[maschine].append(intervall)

    # --- H1: eine Maschine bearbeitet nur einen Gang gleichzeitig --------
    for maschine in range(anzahl_maschinen):
        modell.AddNoOverlap(maschinen_intervalle[maschine])

    # --- H2: Reihenfolge innerhalb eines Auftrags einhalten -------------
    for a, auftrag in enumerate(AUFTRAEGE):
        for g in range(len(auftrag) - 1):
            modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende)

    # --- Ziel: Makespan minimieren ---------------------------------------
    makespan = modell.NewIntVar(0, horizont, "makespan")
    modell.AddMaxEquality(makespan, [plaene[a, len(auftrag) - 1].ende
                                     for a, auftrag in enumerate(AUFTRAEGE)])
    modell.Minimize(makespan)

    loeser = cp_model.CpSolver()
    loeser.parameters.max_time_in_seconds = 10.0
    # Es gibt mehrere Plaene mit demselben kuerzesten Makespan. Welchen CP-SAT
    # findet, haengt sonst davon ab, welcher seiner parallelen Suchstraenge
    # zuerst fertig wird - dasselbe Programm liefert dann von Lauf zu Lauf
    # verschiedene (gleich gute) Plaene. Fuer ein reproduzierbares Buchbeispiel
    # fixieren wir beides. Im Produktivbetrieb laesst man die Standardwerte
    # stehen: Mehrere Arbeiter sind dort deutlich schneller.
    loeser.parameters.num_workers = 1
    loeser.parameters.random_seed = 1
    status = loeser.Solve(modell)

    if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
        print(f"Kein Plan gefunden: {loeser.StatusName(status)}")
        return

    print("=" * 74)
    print("  JOB-SHOP-SCHEDULING MIT INTERVALLVARIABLEN")
    print("=" * 74)
    print(f"Status: {loeser.StatusName(status)} | "
          f"Kuerzeste Gesamtdauer (Makespan): {loeser.Value(makespan)} Zeiteinheiten\n")

    # --- Gantt-Diagramm als Text ------------------------------------------
    dauer_gesamt = loeser.Value(makespan)
    print(f"{'Maschine':<14}|" + "".join(f"{t:<3}" for t in range(dauer_gesamt)))
    print("-" * (15 + 3 * dauer_gesamt))
    for maschine in range(anzahl_maschinen):
        zeile = [" . "] * dauer_gesamt
        for a, auftrag in enumerate(AUFTRAEGE):
            for g, (m, dauer) in enumerate(auftrag):
                if m == maschine:
                    beginn = loeser.Value(plaene[a, g].start)
                    for t in range(beginn, beginn + dauer):
                        zeile[t] = f" A{a}"
        print(f"{MASCHINENNAMEN[maschine]:<14}|" + "".join(zeile))

    print("\n--- Detailplan ---")
    for a, auftrag in enumerate(AUFTRAEGE):
        teile = []
        for g, (maschine, dauer) in enumerate(auftrag):
            beginn = loeser.Value(plaene[a, g].start)
            teile.append(f"{MASCHINENNAMEN[maschine]} {beginn}-{beginn + dauer}")
        print(f"  Auftrag {a}: " + "  ->  ".join(teile))

    # --- Untere Schranke zum Vergleich ------------------------------------
    laengster_auftrag = max(sum(d for _, d in a) for a in AUFTRAEGE)
    hoechste_maschinenlast = max(
        sum(d for auftrag in AUFTRAEGE for m, d in auftrag if m == maschine)
        for maschine in range(anzahl_maschinen))
    schranke = max(laengster_auftrag, hoechste_maschinenlast)
    print(f"\nUntere Schranke (laengster Auftrag / hoechste Maschinenlast): {schranke}")
    print(f"Erreichter Makespan: {loeser.Value(makespan)}"
          f"{'  -> beweisbar bestmoeglich' if loeser.Value(makespan) == schranke else ''}")
    print("=" * 74)


if __name__ == "__main__":
    loese_jobshop()

Erwartete Ausgabe:

==========================================================================
  JOB-SHOP-SCHEDULING MIT INTERVALLVARIABLEN
==========================================================================
Status: OPTIMAL | Kuerzeste Gesamtdauer (Makespan): 11 Zeiteinheiten

Maschine      |0  1  2  3  4  5  6  7  8  9  10
------------------------------------------------
Fraese        | A1 A1 A0 A0 A0 .  .  .  .  .  .
Dreherei      | A2 A2 A2 A2 .  A0 A0 A1 A1 A1 A1
Lackiererei   | .  .  A1 .  A2 A2 A2 A0 A0 .  .

--- Detailplan ---
  Auftrag 0: Fraese 2-5  ->  Dreherei 5-7  ->  Lackiererei 7-9
  Auftrag 1: Fraese 0-2  ->  Lackiererei 2-3  ->  Dreherei 7-11
  Auftrag 2: Dreherei 0-4  ->  Lackiererei 4-7

Untere Schranke (laengster Auftrag / hoechste Maschinenlast): 10
Erreichter Makespan: 11
==========================================================================
Abb. 7.2: Job-Shop-Plan als Gantt-Diagramm

Lesen Sie das Gantt-Diagramm. Auftrag 1 kommt zuerst an die Fräse (0–2), Auftrag 0 muss warten und ist dort erst von 2 bis 5 an der Reihe. Auffällig ist die Lücke in der Dreherei bei Zeitpunkt 4: Die Maschine steht still, obwohl Auftrag 0 schon Arbeit hätte — er ist aber bis 5 noch an der Fräse. Dieses Warten ist unvermeidlich; es entsteht aus dem Zusammenspiel von Reihenfolge- und Maschinenbedingungen, nicht aus einer schlechten Planung.

⚠️ Mehrere gleich gute Pläne — und was das für Ihre Tests bedeutet

Der kürzeste Makespan beträgt 11, aber es gibt mehrere verschiedene Pläne, die ihn erreichen. Welchen CP-SAT findet, hängt normalerweise davon ab, welcher seiner parallelen Suchstränge zuerst fertig wird — dasselbe Programm liefert dann von Lauf zu Lauf einen anderen (gleich guten) Plan.

Deshalb setzt das Programm oben num_workers = 1 und random_seed = 1: nur so ist die abgedruckte Ausgabe reproduzierbar. Im Produktivbetrieb lässt man beides weg — mehrere Arbeiter sind deutlich schneller.

Die Lehre für Ihre eigenen Tests: Prüfen Sie bei mehrdeutigen Optima nie auf einen bestimmten Plan, sondern immer auf den Zielwert und auf die Einhaltung aller Regeln. Ein Test wie assert plan == erwarteter_plan besteht sonst mal und scheitert mal, ohne dass sich am Code etwas geändert hätte.

Bemerkenswert ist die letzte Zeile: Die untere Schranke liegt bei 10, erreicht wurden 11. Die Schranke ist also nicht erreichbar — kein Plan kann 10 schaffen, obwohl weder ein einzelner Auftrag noch eine einzelne Maschine mehr als 10 Zeiteinheiten braucht. Der Grund sind die Wechselwirkungen. Das ist typisch für Scheduling: Einfache Schranken sind selten scharf. Trotzdem sind sie nützlich — sie sagten uns hier, dass wir höchstens 10 % vom theoretischen Ideal entfernt sein können, noch bevor der Solver fertig war.

💡 Warum Intervallvariablen so wertvoll sind

Eine Intervallvariable bündelt drei Größen (Start, Dauer, Ende) und garantiert intern start + dauer == ende. Der eigentliche Gewinn ist aber AddNoOverlap: Diese eine Zeile ersetzt für k Gänge auf einer Maschine \binom{k}{2} Entweder-Oder-Konstruktionen mit je zwei Big-M-Ungleichungen und einer Binärvariablen. Bei 10 Gängen sind das 45 Disjunktionen — in MILP ein zäher Block, in CP-SAT eine Zeile mit einem spezialisierten Propagator.


7.7 Die fünf Antworten von CP-SAT

Abschnitt 6.8 hat gezeigt, warum OPTIMAL bei ganzzahligen Problemen nicht der Normalfall ist. Bei CP-SAT kommt eine Besonderheit hinzu: Es gibt fünf verschiedene Antworten, und drei davon werden regelmäßig verwechselt.

Status Was er über was aussagt Richtige Reaktion
OPTIMAL über die Lösung — sie ist beweisbar bestmöglich ausführen
FEASIBLE über die Zeit — ein gültiger Plan liegt vor, der Beweis fehlt Gap prüfen, dann entscheiden
INFEASIBLE über Ihr Modell — es gibt keinen zulässigen Plan harte Regeln lockern oder weich machen
MODEL_INVALID über Ihren Code — das Modell ist gar kein gültiges Modell Programmierfehler beheben
UNKNOWN über die Zeit — nichts gefunden, aber auch nichts widerlegt mehr Zeit, einfacheres Modell oder AddHint

🎯 Merksatz INFEASIBLE und UNKNOWN sehen im Log ähnlich aus und bedeuten das Gegenteil. INFEASIBLE heißt: Es gibt keine Lösung — auch unendlich viel Rechenzeit ändert daran nichts, die Ursache liegt in Ihren Regeln. UNKNOWN heißt: Ich habe keine gefunden — ob es eine gibt, ist völlig offen. Wer beides in ein else: return None zusammenfasst, wirft genau die Information weg, die zur Fehlersuche nötig wäre.

Und MODEL_INVALID ist von beiden nochmals verschieden: Es ist immer ein Programmierfehler, kein fachliches Problem. Der klassische Fall ist eine Variable mit leerem Wertebereich — NewIntVar(mindestbesetzung, kapazitaet, ...), wobei die Daten vertauscht ankamen und die untere Schranke über der oberen liegt.

Das folgende Programm erzeugt jeden Fall absichtlich und zeigt die passende Auswertung.

#!/usr/bin/env python3

# CP_SAT_Statusfaelle.py
"""
Kapitel CP-SAT: Alle fuenf Antworten von CP-SAT - und was jede bedeutet.

Der haeufigste Fehler beim Einsatz von CP-SAT im Betrieb ist eine Zeile wie

    if status == cp_model.OPTIMAL:
        ...verwerte die Loesung...

Sie wirft in drei von fuenf Faellen etwas weg, das man haette gebrauchen
koennen, und verschweigt in einem weiteren Fall einen Modellierungsfehler.

Dieses Programm erzeugt jeden Statusfall absichtlich und zeigt die passende
Reaktion:

    OPTIMAL        beweisbar bestmoeglich          -> ausfuehren
    FEASIBLE       zulaessig, Zeit war um          -> Gap pruefen, dann entscheiden
    INFEASIBLE     kein Plan existiert             -> harte Regeln lockern
    MODEL_INVALID  das Modell ist fehlerhaft       -> Programmierfehler beheben
    UNKNOWN        nichts gefunden, Zeit war um    -> mehr Zeit oder Heuristik

Benoetigt: numpy, ortools
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np
from ortools.sat.python import cp_model


@dataclass
class Auswertung:
    """Was nach einem CP-SAT-Lauf tatsaechlich feststeht."""
    status: str
    hat_loesung: bool
    beweisbar_optimal: bool
    zielwert: float | None
    schranke: float | None
    gap: float | None
    empfehlung: str


def werte_aus(loeser: cp_model.CpSolver, status: int,
              mit_zielfunktion: bool) -> Auswertung:
    """Uebersetzt einen CP-SAT-Status in eine Handlungsempfehlung.

    Genau diese Funktion fehlt in den meisten Projekten. Sie ist kurz, aber
    sie ist der Unterschied zwischen einem Prototyp und einem System, das
    nachts ohne Aufsicht laeuft.
    """
    name = loeser.StatusName(status)
    hat_loesung = status in (cp_model.OPTIMAL, cp_model.FEASIBLE)

    zielwert = schranke = gap = None
    if hat_loesung and mit_zielfunktion:
        zielwert = loeser.ObjectiveValue()
        schranke = loeser.BestObjectiveBound()
        nenner = max(abs(zielwert), 1e-9)
        gap = abs(zielwert - schranke) / nenner

    if status == cp_model.OPTIMAL:
        empfehlung = "Ausfuehren. Besser geht es nachweislich nicht."
    elif status == cp_model.FEASIBLE:
        empfehlung = (f"Brauchbar. Der Plan ist hoechstens {gap * 100:.2f} % "
                      f"schlechter als das theoretisch Bestmoegliche."
                      if gap is not None else
                      "Brauchbar, aber nicht als optimal bewiesen.")
    elif status == cp_model.INFEASIBLE:
        empfehlung = ("KEIN Plan existiert. Das ist eine Aussage ueber Ihre "
                      "harten Regeln, nicht ueber die Rechenzeit: Auch "
                      "unendlich viel Zeit wuerde nichts aendern. Regeln "
                      "lockern oder weich machen (Anhang C).")
    elif status == cp_model.MODEL_INVALID:
        empfehlung = ("Das MODELL ist fehlerhaft, nicht das Problem. "
                      "Typisch: eine Variable mit leerem Wertebereich. "
                      "model.Validate() nennt die Ursache.")
    else:                                    # UNKNOWN
        empfehlung = ("Nichts gefunden - aber auch nicht widerlegt. Mehr Zeit "
                      "geben, das Modell vereinfachen oder mit einer "
                      "Heuristik vorbelegen (AddHint).")

    return Auswertung(
        status=name,
        hat_loesung=hat_loesung,
        beweisbar_optimal=status == cp_model.OPTIMAL,
        zielwert=zielwert, schranke=schranke, gap=gap,
        empfehlung=empfehlung,
    )


def loese(baue, zeitlimit: float, mit_zielfunktion: bool) -> Auswertung:
    modell = cp_model.CpModel()
    baue(modell)
    loeser = cp_model.CpSolver()
    loeser.parameters.max_time_in_seconds = zeitlimit
    # Fuer reproduzierbare Buchausgaben; im Betrieb weglassen.
    loeser.parameters.num_workers = 1
    loeser.parameters.random_seed = 1
    status = loeser.Solve(modell)
    return werte_aus(loeser, status, mit_zielfunktion)


# --- Die fuenf Faelle --------------------------------------------------------

def dienstplan_loesbar(modell: cp_model.CpModel) -> None:
    """Vier Personen, fuenf Dienste, hoechstens zwei je Person - loesbar."""
    personen, dienste = 4, 5
    x = [[modell.NewBoolVar(f"x_{i}_{j}") for j in range(dienste)]
         for i in range(personen)]
    for j in range(dienste):
        modell.AddExactlyOne(x[i][j] for i in range(personen))
    for i in range(personen):
        modell.Add(sum(x[i]) <= 2)


def dienstplan_unloesbar(modell: cp_model.CpModel) -> None:
    """Dieselben fuenf Dienste, aber hoechstens EIN Dienst je Person.

    Vier Personen koennen zusammen hoechstens vier Dienste uebernehmen - fuer
    fuenf Dienste reicht das nicht. Kein Solver der Welt findet hier etwas.
    """
    personen, dienste = 4, 5
    x = [[modell.NewBoolVar(f"x_{i}_{j}") for j in range(dienste)]
         for i in range(personen)]
    for j in range(dienste):
        modell.AddExactlyOne(x[i][j] for i in range(personen))
    for i in range(personen):
        modell.Add(sum(x[i]) <= 1)


def modell_fehlerhaft(modell: cp_model.CpModel) -> None:
    """Eine Variable mit leerem Wertebereich: untere Schranke > obere.

    Im Alltag entsteht das durch einen Rechenfehler in den Grenzen, etwa
    NewIntVar(mindestbesetzung, kapazitaet, ...) bei falsch sortierten Daten.
    """
    kaputt = modell.NewIntVar(5, 2, "leerer_bereich")
    modell.Add(kaputt >= 0)


def lastverteilung(modell: cp_model.CpModel) -> None:
    """120 Auftraege auf 11 Maschinen - gross genug, dass der Optimalitaets-
    beweis laenger dauert als das Finden einer sehr guten Loesung."""
    rng = np.random.default_rng(3)
    dauer = rng.integers(100, 9000, 120)
    n, maschinen = len(dauer), 11
    obergrenze = int(dauer.sum())

    x = [[modell.NewBoolVar(f"x_{i}_{k}") for k in range(maschinen)]
         for i in range(n)]
    for i in range(n):
        modell.AddExactlyOne(x[i])
    belegung = [modell.NewIntVar(0, obergrenze, f"l_{k}") for k in range(maschinen)]
    for k in range(maschinen):
        modell.Add(belegung[k] == sum(int(dauer[i]) * x[i][k] for i in range(n)))
    makespan = modell.NewIntVar(0, obergrenze, "makespan")
    modell.AddMaxEquality(makespan, belegung)
    modell.Minimize(makespan)


def zahlpartition(modell: cp_model.CpModel) -> None:
    """46 grosse Zahlen exakt in zwei gleich schwere Haelften teilen.

    Ein beruehmt schweres Problem: Es gibt keine Zielfunktion, entweder es
    geht auf oder nicht - und beides zu entscheiden dauert lange.
    """
    rng = np.random.default_rng(1)
    gewichte = rng.integers(10**6, 2 * 10**6, 46)
    x = [modell.NewBoolVar(f"x_{i}") for i in range(len(gewichte))]
    modell.Add(sum(int(gewichte[i]) * x[i] for i in range(len(gewichte)))
               == int(gewichte.sum() // 2))


def zeige(titel: str, a: Auswertung) -> None:
    print(f"\n{titel}")
    print(f"  Status               {a.status}")
    print(f"  Loesung vorhanden    {'ja' if a.hat_loesung else 'nein'}")
    if a.zielwert is not None:
        print(f"  Zielwert / Schranke  {a.zielwert:,.0f} / {a.schranke:,.0f}"
              f"   (Gap {a.gap * 100:.3f} %)")
    print(f"  -> {a.empfehlung}")


if __name__ == "__main__":
    print("=" * 78)
    print("  DIE FUENF ANTWORTEN VON CP-SAT")
    print("=" * 78)

    zeige("[1] Dienstplan, hoechstens 2 Dienste je Person",
          loese(dienstplan_loesbar, 10.0, mit_zielfunktion=False))

    zeige("[2] Lastverteilung 120 Auftraege / 11 Maschinen, 0,5 s Limit",
          loese(lastverteilung, 0.5, mit_zielfunktion=True))

    zeige("[3] Derselbe Fall mit 10 s Limit",
          loese(lastverteilung, 10.0, mit_zielfunktion=True))

    zeige("[4] Dienstplan, hoechstens 1 Dienst je Person",
          loese(dienstplan_unloesbar, 10.0, mit_zielfunktion=False))

    zeige("[5] Variable mit leerem Wertebereich",
          loese(modell_fehlerhaft, 10.0, mit_zielfunktion=False))

    zeige("[6] Zahlpartition mit 46 grossen Zahlen, 2 s Limit",
          loese(zahlpartition, 2.0, mit_zielfunktion=False))

    print("\n" + "=" * 78)
    print("  DIE DREI, DIE MAN NICHT VERWECHSELN DARF")
    print("=" * 78)
    print("INFEASIBLE  Es gibt keine Loesung. Eine Aussage ueber Ihr MODELL.")
    print("            Mehr Rechenzeit aendert daran nichts.")
    print("UNKNOWN     Es wurde keine gefunden. Eine Aussage ueber die ZEIT.")
    print("            Ob es eine gibt, ist offen.")
    print("MODEL_INVALID  Das Modell ist gar kein gueltiges Modell. Eine")
    print("            Aussage ueber Ihren CODE - immer ein Programmierfehler.")
    print()
    print("Wer diese drei in einem 'else: return None' zusammenfasst, verliert")
    print("genau die Information, die zur Fehlersuche noetig waere.")
    print("=" * 78)

Erwartete Ausgabe (Zielwerte in Lauf [2] hardwareabhängig):

==============================================================================
  DIE FUENF ANTWORTEN VON CP-SAT
==============================================================================

[1] Dienstplan, hoechstens 2 Dienste je Person
  Status               OPTIMAL
  Loesung vorhanden    ja
  -> Ausfuehren. Besser geht es nachweislich nicht.

[2] Lastverteilung 120 Auftraege / 11 Maschinen, 0,5 s Limit
  Status               FEASIBLE
  Loesung vorhanden    ja
  Zielwert / Schranke  49,744 / 49,277   (Gap 0.939 %)
  -> Brauchbar. Der Plan ist hoechstens 0.94 % schlechter als das theoretisch Bestmoegliche.

[3] Derselbe Fall mit 10 s Limit
  Status               FEASIBLE
  Loesung vorhanden    ja
  Zielwert / Schranke  49,345 / 49,277   (Gap 0.138 %)
  -> Brauchbar. Der Plan ist hoechstens 0.14 % schlechter als das theoretisch Bestmoegliche.

[4] Dienstplan, hoechstens 1 Dienst je Person
  Status               INFEASIBLE
  Loesung vorhanden    nein
  -> KEIN Plan existiert. Das ist eine Aussage ueber Ihre harten Regeln, nicht ueber die Rechenzeit: Auch unendlich viel Zeit wuerde nichts aendern. Regeln lockern oder weich machen (Anhang C).

[5] Variable mit leerem Wertebereich
  Status               MODEL_INVALID
  Loesung vorhanden    nein
  -> Das MODELL ist fehlerhaft, nicht das Problem. Typisch: eine Variable mit leerem Wertebereich. model.Validate() nennt die Ursache.

[6] Zahlpartition mit 46 grossen Zahlen, 2 s Limit
  Status               UNKNOWN
  Loesung vorhanden    nein
  -> Nichts gefunden - aber auch nicht widerlegt. Mehr Zeit geben, das Modell vereinfachen oder mit einer Heuristik vorbelegen (AddHint).

==============================================================================
  DIE DREI, DIE MAN NICHT VERWECHSELN DARF
==============================================================================
INFEASIBLE  Es gibt keine Loesung. Eine Aussage ueber Ihr MODELL.
            Mehr Rechenzeit aendert daran nichts.
UNKNOWN     Es wurde keine gefunden. Eine Aussage ueber die ZEIT.
            Ob es eine gibt, ist offen.
MODEL_INVALID  Das Modell ist gar kein gueltiges Modell. Eine
            Aussage ueber Ihren CODE - immer ein Programmierfehler.

Wer diese drei in einem 'else: return None' zusammenfasst, verliert
genau die Information, die zur Fehlersuche noetig waere.
==============================================================================

💻 Code-Durchgang

Stelle Was passiert Warum es zählt
werte_aus() übersetzt Status in Handlungsempfehlung Genau diese Funktion fehlt in den meisten Projekten. Sie ist kurz — und der Unterschied zwischen einem Prototyp und einem System, das nachts ohne Aufsicht läuft.
hat_loesung = status in (OPTIMAL, FEASIBLE) die eigentliche Frage Nicht „war der Solver erfolgreich?“, sondern „habe ich etwas in der Hand, das ich ausführen kann?“
BestObjectiveBound() die bewiesene Schranke Ohne sie ist FEASIBLE eine nutzlose Angabe. Mit ihr wird daraus „höchstens 1,27 % vom Optimum“.
Läufe [2] und [3] 0,5 s gegen 10 s Der Gap fällt von 1,27 % auf 0,14 %. Zwanzigfache Rechenzeit für gut ein Prozent — die Entscheidung, ob sich das lohnt, trifft die Anwendung, nicht der Solver.
Lauf [4] gegen Lauf [6] INFEASIBLE gegen UNKNOWN Beide liefern keine Lösung. Bei [4] ist bewiesen, dass es keine gibt (vier Personen können keine fünf Dienste mit je höchstens einem übernehmen); bei [6] weiß niemand, ob eine existiert.

⚠️ Typische Fehler

  • if status == cp_model.OPTIMAL: als einzige Prüfung. Verwirft FEASIBLE-Lösungen, die im Betrieb völlig brauchbar wären — und macht aus einem INFEASIBLE stillschweigend ein „hat nicht geklappt“.
  • Bei INFEASIBLE das Zeitlimit erhöhen. Ändert nichts. Die Ursache liegt in den harten Regeln; Anhang C zeigt, wie man die widersprüchliche Teilmenge findet.
  • ObjectiveValue() ohne Statusprüfung abfragen. Bei INFEASIBLE oder UNKNOWN liefert der Aufruf einen bedeutungslosen Wert — und der wandert dann in einen Bericht.
  • MODEL_INVALID übersehen. Der Status verrät einen Fehler im eigenen Code. model.Validate() nennt die genaue Stelle; nutzen Sie es beim Entwickeln immer.

7.8 Übungsaufgaben

Lösungen: Abschnitt A.7.

Aufgabe 7.1 ⭐ — Propagation nachvollziehen. x_1 \in \{1,\dots,6\}, x_2 \in \{1,\dots,6\}, Bedingungen: x_1 < x_2 und x_1 + x_2 = 8. Reduzieren Sie beide Wertebereiche von Hand so weit wie möglich.

Aufgabe 7.2 ⭐ — Hart oder weich? Klassifizieren Sie und begründen Sie, welche Strafhöhe Sie ansetzen würden: (a) Gesetzliche Mindestruhezeit. (b) „Herr Bauer möchte montags frei.“ (c) „In jeder Schicht mindestens eine examinierte Kraft.“ (d) „Möglichst keine geteilten Dienste.“ (e) „Kein Dienst länger als 10 Stunden.“

Aufgabe 7.3 ⭐⭐ — Regeln ergänzen. Erweitern Sie das Vertretungssystem um: (a) Frau Albrecht ist ab Slot 3 nicht mehr verfügbar. (b) Herr Bauer und Frau Koch sollen nicht beide am selben Tag eingesetzt werden. (c) Wer Slot 1 übernimmt, soll bevorzugt auch Slot 2 bekommen (Belohnung statt Strafe).

Aufgabe 7.4 ⭐⭐ — Infeasibility erzeugen und diagnostizieren. Setzen Sie MAX_VERTRETUNGEN = 0. Was meldet das Programm? Bauen Sie anschließend eine Schlupfvariable je Slot ein („Stunde fällt aus“, Strafe 10 000) und beobachten Sie, wie der Solver statt einer Fehlermeldung einen Notfallplan liefert. Welche Stunde lässt er ausfallen und warum?

Aufgabe 7.5 ⭐⭐ — Sudoku. Lösen Sie ein 9×9-Sudoku mit CP-SAT. Sie brauchen nur NewIntVar(1, 9, ...) und AddAllDifferent für Zeilen, Spalten und die neun 3×3-Blöcke. Wie viele Zeilen Modellcode benötigen Sie?

Aufgabe 7.6 ⭐⭐⭐ — Job-Shop erweitern. Ergänzen Sie JobShop_Intervalle.py um: (a) Rüstzeiten von 1 Zeiteinheit zwischen zwei Aufträgen auf derselben Maschine. (b) Fälligkeitstermine je Auftrag mit Strafkosten für Verspätung. (c) Eine zweite Fräse (Kapazität 2 statt 1) mit AddCumulative.

Aufgabe 7.7 ⭐⭐⭐ — Eigener Dienstplan. Modellieren Sie einen Wochendienstplan: 7 Tage, 3 Schichten (früh/spät/nacht), 8 Mitarbeitende. Regeln: Jede Schicht 2 Personen; nach Nachtschicht mindestens 2 Tage frei; höchstens 5 Arbeitstage in Folge; jede Person mindestens 2 freie Tage pro Woche; Wünsche als weiche Ziele. Geben Sie den Plan als Wochentabelle aus.


7.9 Finde den Denkfehler

🐛 Finde den Denkfehler: Die Betriebsvereinbarung, die niemanden interessiert

Ein Klinikteam lässt seinen Schichtplan optimieren. Zwei Wünsche kommen aus dem Betrieb:

  • Betriebsvereinbarung: niemand mehr als zwei Dienste in Folge.
  • Wunschfrei: die eingereichten freien Tage möglichst einhalten.

Beides wird als weiche Regel modelliert. Bei der Gewichtung überlegt der Entwickler: „Wunschfrei ist für die Zufriedenheit zentral, das gewichte ich mit 10. Die Serienregel ist eine Formalie, die kaum je greift — 1 genügt.“

Der Solver meldet OPTIMAL. Im ausgegebenen Plan stehen zwölf Drei-Tage-Serien. Die Personalvertretung lehnt den Plan ab.

Ihre Aufgabe: (a) Der Solver hat korrekt gerechnet und die Regel steht im Modell — warum wird sie trotzdem systematisch verletzt? (b) Was besagt das Zahlenpaar (1, 10), wörtlich in Sätze übersetzt? (c) Ab welchem Gewicht verschwinden die Serien, und was kostet das an anderer Stelle? (d) Welche Frage hätte man dem Auftraggeber stellen müssen, bevor man eine Zahl einsetzt — und wann gehört eine Regel gar nicht erst in die Zielfunktion?

Auflösung: Abschnitt A.7.

Das folgende Programm rechnet den Plan für sechs verschiedene Gewichte durch. Es ist damit zugleich die Antwort auf (c) — und die Tabelle, die man in so einem Fall dem Auftraggeber vorlegt, statt ein Gewicht zu raten.

#!/usr/bin/env python3

# Strafgewichte.py
"""
Kapitel CP-SAT: Warum ein Strafgewicht keine Wichtigkeit ausdrueckt, sondern
einen Wechselkurs.

Weiche Regeln kommen als Strafterm in die Zielfunktion. Die Frage, die dabei
regelmaessig falsch beantwortet wird, lautet: "Wie gross muss die Strafe sein?"
Die uebliche Antwort - "je wichtiger, desto groesser" - klingt vernuenftig und
fuehrt zuverlaessig zu Plaenen, die niemand unterschreiben will.

Der Grund: Zwei Strafgewichte legen nicht Wichtigkeiten fest, sondern einen
UMRECHNUNGSKURS. Steht die Serienregel bei 1 und der Wunschfrei-Tag bei 10,
dann hat man dem Solver woertlich gesagt: "Zehn Verstoesse gegen die
Serienregel sind so schlimm wie ein abgelehnter Wunsch." Er nimmt das
ernst - und handelt danach.

Szenario: Schichtplan, 6 Personen, 14 Tage, 4 Besetzungen pro Tag.
  HART   jeder Tag genau 4 Personen; hoechstens 11 Dienste je Person
  WEICH  keine drei Dienste in Folge (Betriebsvereinbarung)
  WEICH  Wunschfrei-Tage einhalten (26 Antraege)

Benoetigt: numpy, ortools
"""

from __future__ import annotations

import numpy as np
from ortools.sat.python import cp_model

PERSONEN, TAGE, PRO_TAG = 6, 14, 4
MAX_DIENSTE = 11
STRAFE_WUNSCH = 10                    # bleibt fest - variiert wird die Serienstrafe

RNG = np.random.default_rng(2)
WUNSCHFREI = sorted({(p, t) for p in range(PERSONEN) for t in range(TAGE)
                     if RNG.random() < 0.35})


def plane(strafe_serie: int, zeitlimit: float = 25.0) -> dict:
    """Baut den Schichtplan mit dem angegebenen Gewicht fuer die Serienregel."""
    modell = cp_model.CpModel()
    x = {(p, t): modell.NewBoolVar(f"x_{p}_{t}")
         for p in range(PERSONEN) for t in range(TAGE)}

    # --- harte Regeln --------------------------------------------------
    for t in range(TAGE):
        modell.Add(sum(x[p, t] for p in range(PERSONEN)) == PRO_TAG)
    for p in range(PERSONEN):
        modell.Add(sum(x[p, t] for t in range(TAGE)) <= MAX_DIENSTE)

    # --- weiche Regel 1: keine drei Dienste in Folge --------------------
    # serie[p, t] = 1  <=>  Person p arbeitet an t, t+1 UND t+2.
    # Die Ungleichung erzwingt das nur in eine Richtung; das genuegt, weil
    # der Solver serie minimiert - er wuerde die Variable nie freiwillig
    # auf 1 setzen.
    strafterme = []
    serien = []
    for p in range(PERSONEN):
        for t in range(TAGE - 2):
            serie = modell.NewBoolVar(f"serie_{p}_{t}")
            modell.Add(x[p, t] + x[p, t + 1] + x[p, t + 2] <= 2 + serie)
            serien.append(serie)
            strafterme.append(serie * strafe_serie)

    # --- weiche Regel 2: Wunschfrei ------------------------------------
    wunschverstoesse = []
    for p, t in WUNSCHFREI:
        wunschverstoesse.append(x[p, t])
        strafterme.append(x[p, t] * STRAFE_WUNSCH)

    modell.Minimize(sum(strafterme))

    loeser = cp_model.CpSolver()
    loeser.parameters.max_time_in_seconds = zeitlimit
    loeser.parameters.num_workers = 1          # reproduzierbare Buchausgabe
    loeser.parameters.random_seed = 1
    status = loeser.Solve(modell)
    if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
        raise RuntimeError(f"Kein Plan: {loeser.StatusName(status)}")

    return {
        "status": loeser.StatusName(status),
        "zielwert": loeser.ObjectiveValue(),
        "serien": sum(loeser.Value(v) for v in serien),
        "wunsch_verletzt": sum(loeser.Value(v) for v in wunschverstoesse),
    }


if __name__ == "__main__":
    print("=" * 78)
    print("  STRAFGEWICHTE SIND WECHSELKURSE, KEINE WICHTIGKEITEN")
    print("=" * 78)
    print(f"{PERSONEN} Personen, {TAGE} Tage, {PRO_TAG} Besetzungen pro Tag "
          f"= {TAGE * PRO_TAG} Dienste")
    print(f"Das sind {TAGE * PRO_TAG / PERSONEN:.1f} Dienste je Person - der Plan ist eng.")
    print(f"{len(WUNSCHFREI)} Wunschfrei-Antraege, Strafe je abgelehntem Wunsch: "
          f"{STRAFE_WUNSCH}\n")

    print(f"{'Strafe je 3er-Serie':>20} {'3er-Serien':>12} {'Wunsch verletzt':>17} "
          f"{'Zielwert':>10}")
    print("-" * 78)
    ergebnisse = {}
    for strafe in (1, 2, 5, 10, 30, 100):
        e = plane(strafe)
        ergebnisse[strafe] = e
        print(f"{strafe:>20} {e['serien']:>12} {e['wunsch_verletzt']:>17} "
              f"{e['zielwert']:>10.0f}")
    print("-" * 78)

    niedrig, hoch = ergebnisse[1], ergebnisse[30]
    print(f"\nBei Strafe 1 baut der Solver {niedrig['serien']} Drei-Tage-Serien ein.")
    print("Die Betriebsvereinbarung steht im Modell - und wird trotzdem")
    print("systematisch verletzt. Das ist kein Fehler des Solvers: Bei einem")
    print(f"Gewicht von 1 gegen {STRAFE_WUNSCH} lohnt sich jede Serie, solange sie")
    print("auch nur einen Zehntel-Wunsch rettet.")
    print(f"\nBei Strafe 30 sind es {hoch['serien']} Serien - dafuer werden")
    print(f"{hoch['wunsch_verletzt']} statt {niedrig['wunsch_verletzt']} Wuensche abgelehnt.")
    print("Beide Plaene sind optimal. Sie beantworten nur verschiedene Fragen.")
    print()
    print("Die Frage lautet also nie 'wie wichtig ist mir diese Regel?', sondern:")
    print("  'Wie viele abgelehnte Wuensche bin ich bereit zu akzeptieren,")
    print("   um eine Drei-Tage-Serie zu vermeiden?'")
    print("Wer darauf keine Zahl nennen kann, hat das Problem noch nicht")
    print("verstanden - und sollte diese Tabelle dem Auftraggeber vorlegen,")
    print("statt ein Gewicht zu raten.")
    print()
    print("Und die wichtigste Konsequenz: Eine Regel, die NIE gebrochen werden")
    print("darf, gehoert nicht in die Zielfunktion, sondern unter die harten")
    print("Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.")
    print("=" * 78)

Erwartete Ausgabe:

==============================================================================
  STRAFGEWICHTE SIND WECHSELKURSE, KEINE WICHTIGKEITEN
==============================================================================
6 Personen, 14 Tage, 4 Besetzungen pro Tag = 56 Dienste
Das sind 9.3 Dienste je Person - der Plan ist eng.
26 Wunschfrei-Antraege, Strafe je abgelehntem Wunsch: 10

 Strafe je 3er-Serie   3er-Serien   Wunsch verletzt   Zielwert
------------------------------------------------------------------------------
                   1           12                 6         72
                   2           12                 6         84
                   5            9                 7        115
                  10            2                11        130
                  30            0                14        140
                 100            0                14        140
------------------------------------------------------------------------------

Bei Strafe 1 baut der Solver 12 Drei-Tage-Serien ein.
Die Betriebsvereinbarung steht im Modell - und wird trotzdem
systematisch verletzt. Das ist kein Fehler des Solvers: Bei einem
Gewicht von 1 gegen 10 lohnt sich jede Serie, solange sie
auch nur einen Zehntel-Wunsch rettet.

Bei Strafe 30 sind es 0 Serien - dafuer werden
14 statt 6 Wuensche abgelehnt.
Beide Plaene sind optimal. Sie beantworten nur verschiedene Fragen.

Die Frage lautet also nie 'wie wichtig ist mir diese Regel?', sondern:
  'Wie viele abgelehnte Wuensche bin ich bereit zu akzeptieren,
   um eine Drei-Tage-Serie zu vermeiden?'
Wer darauf keine Zahl nennen kann, hat das Problem noch nicht
verstanden - und sollte diese Tabelle dem Auftraggeber vorlegen,
statt ein Gewicht zu raten.

Und die wichtigste Konsequenz: Eine Regel, die NIE gebrochen werden
darf, gehoert nicht in die Zielfunktion, sondern unter die harten
Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.
==============================================================================

🎯 Merksatz Strafgewichte drücken keine Wichtigkeit aus, sondern einen Wechselkurs. Wer der Serienregel 1 und dem Wunschfrei 10 gibt, hat wörtlich gesagt: „Zehn Verstöße gegen die Betriebsvereinbarung sind so schlimm wie ein abgelehnter Wunsch.“ Der Solver nimmt das ernst und handelt danach — das ist seine Aufgabe.

⚠️ Typische Fehler bei weichen Regeln

  • Gewichte nach Bauchgefühl setzen. Rechnen Sie stattdessen die Tabelle wie oben und lassen Sie den Auftraggeber die Zeile aussuchen. Das dauert eine Stunde und ersetzt drei Abstimmungsrunden.
  • Eine unverhandelbare Regel weich modellieren. Alles, was einen Preis hat, wird irgendwann gekauft. Gesetzliche Ruhezeiten, Qualifikationsanforderungen und Betriebsvereinbarungen gehören unter die harten Nebenbedingungen — auch wenn das Modell dann INFEASIBLE melden kann. Dieses INFEASIBLE ist die ehrliche Antwort: Der Plan ist mit dem vorhandenen Personal nicht zulässig zu machen.
  • Gewichte in verschiedenen Einheiten mischen. Euro neben „Unzufriedenheitspunkten“ neben Minuten ergibt eine Zielfunktion, die niemand interpretieren kann — und deren Konditionszahl obendrein leidet (Abschnitt 2.7).
  • Nur ein Gewicht ausprobieren. Ein einziger Lauf sagt nichts darüber, ob Sie nahe an einem Kipppunkt stehen. Zwischen den Gewichten 5 und 10 ändert sich der Plan oben erheblich.

7.10 Micro-Quiz

❓ Micro-Quiz 7: Drei Fragen zum Selbstcheck

Genau eine Antwort ist jeweils richtig. Auflösung in Anhang A.

1. Ihr Dienstplanmodell meldet INFEASIBLE. Ein Kollege schlägt vor, das Zeitlimit von 10 auf 300 Sekunden zu erhöhen. Was halten Sie davon? (a) Sinnvoll — bei schweren Instanzen braucht CP-SAT einfach länger. (b) Wirkungslos: INFEASIBLE ist ein Beweis, dass keine zulässige Lösung existiert. Die Ursache liegt in den harten Regeln, nicht in der Rechenzeit. (c) Sinnvoll, aber nur zusammen mit mehr Arbeitern (num_workers).

2. Sie modellieren „auf einer Maschine läuft immer nur ein Auftrag“. Was ist der Vorteil von AddNoOverlap gegenüber der MILP-Formulierung mit Big-M? (a) AddNoOverlap findet grundsätzlich bessere Lösungen. (b) Eine Zeile ersetzt bei k Aufträgen \binom{k}{2} Entweder-Oder-Konstruktionen mit je zwei Ungleichungen und einer Binärvariablen — und propagiert stärker, weil der Propagator die Struktur als Ganzes betrachtet. (c) AddNoOverlap funktioniert auch mit kontinuierlichen Zeiten, Big-M nicht.

3. Zwei Läufe desselben Job-Shop-Programms liefern verschiedene Pläne, beide mit Makespan 11. Was folgt daraus für Ihre Tests? (a) Das Programm ist fehlerhaft; ein korrektes Modell hat genau eine Lösung. (b) Es gibt mehrere optimale Pläne. Tests dürfen deshalb nicht auf einen bestimmten Plan prüfen, sondern auf den Zielwert und die Einhaltung aller Regeln. (c) Der Solver arbeitet ungenau; man sollte die Toleranzen verschärfen.


7.11 Selbsttest

Antworten: Anhang A.

  1. Was macht Constraint Propagation, und warum ist sie effizienter als Ausprobieren?
  2. Welchen Vorteil hat AddAllDifferent(x) gegenüber \binom{n}{2} Einzelbedingungen?
  3. Was ist eine Intervallvariable, und welche Bedingung garantiert sie automatisch?
  4. Warum sollten möglichst wenige Bedingungen hart formuliert werden?
  5. Warum ist die Aufschlüsselung der Strafkosten in der Praxis wichtiger als der Zielwert selbst?

7.12 Zusammenfassung

  • CP denkt in Wertebereichen und Regeln, nicht in Ungleichungen und Schranken.
  • Propagation streicht unmögliche Werte, bevor gesucht wird; Conflict Learning verhindert, dieselbe Sackgasse zweimal zu betreten.
  • Globale Constraints wie , und ersetzen ganze Blöcke von Big-M-Ungleichungen und propagieren stärker.
  • Intervallvariablen sind das Werkzeug für Zeitplanung; AddNoOverlap erledigt in einer Zeile, was in MILP dutzende Disjunktionen kostet.
  • Weiche Ziele mit Strafkosten machen Modelle robust gegen INFEASIBLE — und die Kostenzerlegung macht sie erklärbar.
  • Strafgewichte sind Wechselkurse, keine Wichtigkeiten. Rechnen Sie mehrere Gewichte durch und lassen Sie den Auftraggeber eine Zeile der Tabelle wählen. Und was nie gebrochen werden darf, gehört unter die harten Nebenbedingungen: Alles, was einen Preis hat, wird irgendwann gekauft.
  • Fünf Antworten, nicht zwei. INFEASIBLE (es gibt keine Lösung), UNKNOWN (es wurde keine gefunden) und MODEL_INVALID (Ihr Code ist fehlerhaft) bedeuten Grundverschiedenes. Wer sie in ein else zusammenfasst, verliert die Information zur Fehlersuche.
  • Bei mehrdeutigen Optima nie auf einen bestimmten Plan testen — nur auf den Zielwert und die Einhaltung aller Regeln.

Ausblick. Kapitel 8 behandelt Probleme, die sich am besten als Graph begreifen lassen: Flüsse durch Netzwerke, Zuordnungen und die Tourenplanung mit Fahrzeugen und Zeitfenstern.