Version 04 als eigenes Repository
Erster Commit des Strangs "Optimierte Entscheidungsfindung mit Python" (Version 04). Die Historie der 71 Commits bis zur Trennung bleibt im uebergeordneten Repository OR_mit_Python liegen, das ab jetzt nur noch Version_03 (eingefroren) verwaltet und Version_04/ ignoriert. Bewusst kein "git subtree split": Der Pfad Version_04/ existiert erst seit der Verzeichnistrennung, ein Split braechte daher nur 7 der 41 einschlaegigen Commits - eine Teilhistorie, die vollstaendig aussieht und es nicht ist. Stand: 5 Teile, 23 Kapitel, 5 Anhaenge, 292 Abschnitte, 703 Querverweise, 325 Indexmarken, 73 Beispielprogramme, 32 SVGs, 4 Plotly-Figuren, 25 Notebooks, PDF mit 715 Seiten. Zusaetzlich in diesem Commit: * pyproject.toml mit Abhaengigkeitsgruppen finance, large-scale, api, figures, dev, empfehlungen. Die abgedruckte requirements.txt bleibt unveraendert daneben bestehen. ortools steht in der Grundausstattung, highspy erst in [large-scale] - so kann der HiGHS-Symbolkonflikt bei der schlanken Installation gar nicht erst auftreten. * Dabei zwei Funde: graphviz wird von erzeuge_architektur_diagramme.py importiert, fehlt aber in requirements.txt (jetzt in [figures]); pymoo steht in requirements.txt, wird aber von keinem Programm importiert, sondern nur im Kapitel Metaheuristiken empfohlen (jetzt in [empfehlungen]). * NEUER_TITEL.md nach Kritik_und_Verbesserungsvorschlaege/ verschoben - es ist die Vorlage des Titelblatts, kein Bestandteil des Werks. Die beiden Fundstellen in PROGRESS.md und erzeuge_titelseite.py nachgezogen. * PROGRESS.md nannte noch den Untertitel der ersten Fassung; auf den tatsaechlichen aus erzeuge_titelseite.py korrigiert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
b7af2f1d9a
468 changed files with 262141 additions and 0 deletions
132
OR_HTML_04/programme/Strafgewichte.py
Normal file
132
OR_HTML_04/programme/Strafgewichte.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
#!/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)
|
||||
Loading…
Reference in a new issue