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