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
902
OR_HTML_04/Notebooks_04/cpsat.ipynb
Normal file
902
OR_HTML_04/Notebooks_04/cpsat.ipynb
Normal file
|
|
@ -0,0 +1,902 @@
|
|||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Kapitel 7: Constraint Programming mit CP-SAT — Logik, Scheduling und Zuweisung\n",
|
||||
"\n",
|
||||
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
|
||||
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
|
||||
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
|
||||
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Was Propagation leistet\n",
|
||||
"\n",
|
||||
"`Propagation_Demo.py`\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"#!/usr/bin/env python3\n",
|
||||
"\n",
|
||||
"# Propagation_Demo.py\n",
|
||||
"\"\"\"\n",
|
||||
"Kapitel CP-SAT: Propagation sichtbar machen.\n",
|
||||
"Vergleicht die Zahl der geprueften Kombinationen bei roher Aufzaehlung\n",
|
||||
"mit der Zahl der Verzweigungen, die CP-SAT tatsaechlich braucht.\n",
|
||||
"\"\"\"\n",
|
||||
"\n",
|
||||
"import itertools\n",
|
||||
"\n",
|
||||
"from ortools.sat.python import cp_model\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def rohe_aufzaehlung(n: int, obergrenze: int) -> tuple[int, int]:\n",
|
||||
" \"\"\"Zaehlt ALLE Kombinationen und prueft jede einzeln (Brute Force).\"\"\"\n",
|
||||
" geprueft = 0\n",
|
||||
" loesungen = 0\n",
|
||||
" for kombination in itertools.product(range(1, obergrenze + 1), repeat=n):\n",
|
||||
" geprueft += 1\n",
|
||||
" if len(set(kombination)) == n and sum(kombination) == 3 * n:\n",
|
||||
" loesungen += 1\n",
|
||||
" return geprueft, loesungen\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def mit_cp_sat(n: int, obergrenze: int) -> tuple[int, int, float]:\n",
|
||||
" \"\"\"Dasselbe Problem deklarativ: alle verschieden, Summe = 3n.\"\"\"\n",
|
||||
" modell = cp_model.CpModel()\n",
|
||||
" x = [modell.NewIntVar(1, obergrenze, f\"x{i}\") for i in range(n)]\n",
|
||||
" modell.AddAllDifferent(x) # globales Constraint\n",
|
||||
" modell.Add(sum(x) == 3 * n)\n",
|
||||
"\n",
|
||||
" loeser = cp_model.CpSolver()\n",
|
||||
" loeser.parameters.enumerate_all_solutions = True\n",
|
||||
"\n",
|
||||
" class Zaehler(cp_model.CpSolverSolutionCallback):\n",
|
||||
" def __init__(self):\n",
|
||||
" super().__init__()\n",
|
||||
" self.anzahl = 0\n",
|
||||
"\n",
|
||||
" def on_solution_callback(self):\n",
|
||||
" self.anzahl += 1\n",
|
||||
"\n",
|
||||
" zaehler = Zaehler()\n",
|
||||
" loeser.Solve(modell, zaehler)\n",
|
||||
" return zaehler.anzahl, loeser.NumBranches(), loeser.WallTime()\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"if __name__ == \"__main__\":\n",
|
||||
" print(\"=\" * 84)\n",
|
||||
" print(\" PROPAGATION: WIE VIEL ARBEIT SPART SICH DER SOLVER?\")\n",
|
||||
" print(\" Aufgabe: n Zahlen aus 1..G, alle verschieden, Summe = 3n\")\n",
|
||||
" print(\"=\" * 84)\n",
|
||||
" print(f\"{'n':>3} {'G':>4} | {'Brute Force':>14} | {'CP-SAT':>10} | \"\n",
|
||||
" f\"{'Ersparnis':>11} | {'Loesungen':>10}\")\n",
|
||||
" print(\"-\" * 84)\n",
|
||||
"\n",
|
||||
" for n, grenze in [(4, 8), (5, 10), (6, 12), (7, 14)]:\n",
|
||||
" kombis, treffer_bf = rohe_aufzaehlung(n, grenze)\n",
|
||||
" treffer_cp, verzweigungen, dauer = mit_cp_sat(n, grenze)\n",
|
||||
" assert treffer_bf == treffer_cp, \"Beide Verfahren muessen gleich viele finden!\"\n",
|
||||
" ersparnis = kombis / max(verzweigungen, 1)\n",
|
||||
" print(f\"{n:>3} {grenze:>4} | {kombis:>14,} | {verzweigungen:>10,} | \"\n",
|
||||
" f\"{ersparnis:>10.0f}x | {treffer_cp:>10,}\")\n",
|
||||
"\n",
|
||||
" print(\"-\" * 84)\n",
|
||||
" print(\"'Brute Force' = alle Kombinationen. 'CP-SAT' = tatsaechliche Verzweigungen.\")\n",
|
||||
" print(\"Der Rest wurde durch Propagation ausgeschlossen, ohne ihn anzusehen.\")\n",
|
||||
" print(\"=\" * 84)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Praxisfall: Dynamisches Vertretungssystem\n",
|
||||
"\n",
|
||||
"`CP_SAT_Vertretungssystem.py`\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"#!/usr/bin/env python3\n",
|
||||
"\n",
|
||||
"# CP_SAT_Vertretungssystem.py\n",
|
||||
"\"\"\"\n",
|
||||
"Kapitel CP-SAT: Dynamisches Vertretungs- und Einsatzplanungssystem mit CP-SAT.\n",
|
||||
"\n",
|
||||
"Eigenschaften:\n",
|
||||
" * Solver-Status wird ueber SolverStatus aus or_kern.py ausgewertet - dieselbe\n",
|
||||
" Fallunterscheidung wie bei jedem anderen Solver im Buch\n",
|
||||
" * Strafkosten-Zerlegung wird ausgewiesen (Erklaerbarkeit, Kapitel Praxisfallen)\n",
|
||||
" * Fairness-Kriterium ergaenzt (Spannweite der Belastung minimieren)\n",
|
||||
" * Vorabdiagnose auf offensichtliche Unloesbarkeit\n",
|
||||
" * Abnahmepruefung des fertigen Plans OHNE den Solver zu fragen\n",
|
||||
"\n",
|
||||
"Benoetigt: ortools, pandas, pydantic (ueber or_kern)\n",
|
||||
"\"\"\"\n",
|
||||
"\n",
|
||||
"from __future__ import annotations\n",
|
||||
"\n",
|
||||
"from ortools.sat.python import cp_model\n",
|
||||
"import pandas as pd\n",
|
||||
"\n",
|
||||
"from or_kern import Loesung, SolverStatus, status_von_cpsat\n",
|
||||
"\n",
|
||||
"# --- 1. Datenbasis --------------------------------------------------------\n",
|
||||
"SLOTS = [1, 2, 3, 4]\n",
|
||||
"FAECHER = [\"Mathematik\", \"Physik\", \"Mathematik\", \"Informatik\"]\n",
|
||||
"KLASSEN = [\"Klasse 8a\", \"Klasse 10b\", \"Klasse 7a\", \"Klasse 9c\"]\n",
|
||||
"\n",
|
||||
"PERSONAL = [\"Frau_Mueller\", \"Herr_Schmidt\", \"Frau_Albrecht\", \"Herr_Bauer\", \"Frau_Koch\"]\n",
|
||||
"\n",
|
||||
"QUALIFIKATION = {\n",
|
||||
" \"Frau_Mueller\": {\"Mathematik\", \"Physik\"},\n",
|
||||
" \"Herr_Schmidt\": {\"Informatik\", \"Mathematik\"},\n",
|
||||
" \"Frau_Albrecht\": {\"Physik\", \"Informatik\"},\n",
|
||||
" \"Herr_Bauer\": {\"Mathematik\"},\n",
|
||||
" \"Frau_Koch\": {\"Mathematik\", \"Physik\", \"Informatik\"},\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"VORBELASTUNG = { # bereits geleistete Stunden heute\n",
|
||||
" \"Frau_Mueller\": 1, \"Herr_Schmidt\": 0, \"Frau_Albrecht\": 2,\n",
|
||||
" \"Herr_Bauer\": 0, \"Frau_Koch\": 1,\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"MAX_VERTRETUNGEN = 2\n",
|
||||
"STRAFE_VORBELASTUNG = 50 # je Stunde Vorbelastung und Zuweisung\n",
|
||||
"STRAFE_LOCH = 80 # je Freistundenloch\n",
|
||||
"STRAFE_UNFAIRNESS = 30 # je Einheit Spannweite der Gesamtbelastung\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def pruefe_grundsaetzliche_loesbarkeit() -> bool:\n",
|
||||
" \"\"\"Vorabdiagnose: Gibt es fuer jeden Slot ueberhaupt qualifiziertes Personal?\"\"\"\n",
|
||||
" ok = True\n",
|
||||
" for s, fach in enumerate(FAECHER):\n",
|
||||
" kandidaten = [p for p in PERSONAL if fach in QUALIFIKATION[p]]\n",
|
||||
" if not kandidaten:\n",
|
||||
" print(f\" UNLOESBAR: Fuer Slot {SLOTS[s]} ({fach}) gibt es niemanden.\")\n",
|
||||
" ok = False\n",
|
||||
" kapazitaet = len(PERSONAL) * MAX_VERTRETUNGEN\n",
|
||||
" if kapazitaet < len(SLOTS):\n",
|
||||
" print(f\" UNLOESBAR: Kapazitaet {kapazitaet} < {len(SLOTS)} offene Stunden.\")\n",
|
||||
" ok = False\n",
|
||||
" return ok\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def pruefe_plan(plan: dict[int, str]) -> list[str]:\n",
|
||||
" \"\"\"Prueft den fertigen Plan gegen die harten Regeln - ohne den Solver.\n",
|
||||
"\n",
|
||||
" Der Solver kann nur pruefen, was ihm gesagt wurde. Diese Funktion prueft\n",
|
||||
" gegen die ANFORDERUNG und benutzt dafuer bewusst keine Modellvariable\n",
|
||||
" (siehe Kapitel Praxisfallen). Leere Liste heisst bestanden.\n",
|
||||
" \"\"\"\n",
|
||||
" beanstandungen: list[str] = []\n",
|
||||
" if sorted(plan) != list(range(len(SLOTS))):\n",
|
||||
" beanstandungen.append(f\"nicht jede Stunde genau einmal besetzt: {sorted(plan)}\")\n",
|
||||
" return beanstandungen\n",
|
||||
" for s, person in plan.items():\n",
|
||||
" if FAECHER[s] not in QUALIFIKATION[person]:\n",
|
||||
" beanstandungen.append(f\"{person} ist nicht fuer {FAECHER[s]} qualifiziert\")\n",
|
||||
" for person in PERSONAL:\n",
|
||||
" anzahl = sum(1 for p in plan.values() if p == person)\n",
|
||||
" if anzahl > MAX_VERTRETUNGEN:\n",
|
||||
" beanstandungen.append(f\"{person} hat {anzahl} Vertretungen \"\n",
|
||||
" f\"(hoechstens {MAX_VERTRETUNGEN})\")\n",
|
||||
" return beanstandungen\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def plane_vertretung() -> list[dict[str, str]] | None:\n",
|
||||
" if not pruefe_grundsaetzliche_loesbarkeit():\n",
|
||||
" return None\n",
|
||||
"\n",
|
||||
" modell = cp_model.CpModel()\n",
|
||||
"\n",
|
||||
" # Entscheidungsvariablen: x[person, slot] = 1 <=> Person uebernimmt Slot\n",
|
||||
" x = {(p, s): modell.NewBoolVar(f\"zuweisung_{p}_slot{s+1}\")\n",
|
||||
" for p in PERSONAL for s in range(len(SLOTS))}\n",
|
||||
"\n",
|
||||
" # --- HARTE NEBENBEDINGUNGEN ------------------------------------------\n",
|
||||
" # H1: Jede Stunde genau einmal besetzen\n",
|
||||
" for s in range(len(SLOTS)):\n",
|
||||
" modell.AddExactlyOne(x[p, s] for p in PERSONAL)\n",
|
||||
"\n",
|
||||
" # H2: Qualifikation - unqualifizierte Zuweisung ausschliessen\n",
|
||||
" for s, fach in enumerate(FAECHER):\n",
|
||||
" for p in PERSONAL:\n",
|
||||
" if fach not in QUALIFIKATION[p]:\n",
|
||||
" modell.Add(x[p, s] == 0)\n",
|
||||
"\n",
|
||||
" # H3: Hoechstens MAX_VERTRETUNGEN Stunden je Person\n",
|
||||
" for p in PERSONAL:\n",
|
||||
" modell.Add(sum(x[p, s] for s in range(len(SLOTS))) <= MAX_VERTRETUNGEN)\n",
|
||||
"\n",
|
||||
" # (H4 \"nicht an zwei Orten gleichzeitig\" ist durch H1 bereits erfuellt:\n",
|
||||
" # jeder Slot hat genau eine Person, und eine Person kann pro Slot nur\n",
|
||||
" # eine Variable auf 1 setzen.)\n",
|
||||
"\n",
|
||||
" # --- WEICHE ZIELE ----------------------------------------------------\n",
|
||||
" strafterme = []\n",
|
||||
"\n",
|
||||
" # W1: Vorbelastete Personen schonen\n",
|
||||
" strafe_last = []\n",
|
||||
" for p in PERSONAL:\n",
|
||||
" zuweisungen = sum(x[p, s] for s in range(len(SLOTS)))\n",
|
||||
" strafe_last.append(VORBELASTUNG[p] * STRAFE_VORBELASTUNG * zuweisungen)\n",
|
||||
" strafterme.extend(strafe_last)\n",
|
||||
"\n",
|
||||
" # W2: Freistundenloecher vermeiden (Muster: arbeitet, frei, arbeitet)\n",
|
||||
" loch_variablen = []\n",
|
||||
" for p in PERSONAL:\n",
|
||||
" for s in range(len(SLOTS) - 2):\n",
|
||||
" loch = modell.NewBoolVar(f\"loch_{p}_{s}\")\n",
|
||||
" # Reifizierung: loch == 1 <=> (x_s AND NOT x_{s+1} AND x_{s+2})\n",
|
||||
" modell.AddBoolAnd([x[p, s], x[p, s + 1].Not(), x[p, s + 2]]).OnlyEnforceIf(loch)\n",
|
||||
" modell.AddBoolOr([x[p, s].Not(), x[p, s + 1], x[p, s + 2].Not()]) \\\n",
|
||||
" .OnlyEnforceIf(loch.Not())\n",
|
||||
" loch_variablen.append(loch)\n",
|
||||
" strafterme.append(loch * STRAFE_LOCH)\n",
|
||||
"\n",
|
||||
" # W3: Fairness - Spannweite der Gesamtbelastung minimieren\n",
|
||||
" gesamtlast = {}\n",
|
||||
" for p in PERSONAL:\n",
|
||||
" last = modell.NewIntVar(0, len(SLOTS) + max(VORBELASTUNG.values()), f\"last_{p}\")\n",
|
||||
" modell.Add(last == VORBELASTUNG[p] + sum(x[p, s] for s in range(len(SLOTS))))\n",
|
||||
" gesamtlast[p] = last\n",
|
||||
" max_last = modell.NewIntVar(0, 10, \"max_last\")\n",
|
||||
" min_last = modell.NewIntVar(0, 10, \"min_last\")\n",
|
||||
" modell.AddMaxEquality(max_last, list(gesamtlast.values()))\n",
|
||||
" modell.AddMinEquality(min_last, list(gesamtlast.values()))\n",
|
||||
" spannweite = modell.NewIntVar(0, 10, \"spannweite\")\n",
|
||||
" modell.Add(spannweite == max_last - min_last)\n",
|
||||
" strafterme.append(spannweite * STRAFE_UNFAIRNESS)\n",
|
||||
"\n",
|
||||
" modell.Minimize(sum(strafterme))\n",
|
||||
"\n",
|
||||
" # --- LOESEN -----------------------------------------------------------\n",
|
||||
" loeser = cp_model.CpSolver()\n",
|
||||
" loeser.parameters.max_time_in_seconds = 5.0\n",
|
||||
" # Reproduzierbarkeit: ein Arbeiter, fester Startwert. Ohne das kann CP-SAT\n",
|
||||
" # bei mehreren gleich guten Plaenen von Lauf zu Lauf einen anderen liefern\n",
|
||||
" # (siehe JobShop_Intervalle.py im naechsten Abschnitt).\n",
|
||||
" loeser.parameters.num_workers = 1\n",
|
||||
" loeser.parameters.random_seed = 1\n",
|
||||
" rohstatus = loeser.Solve(modell)\n",
|
||||
"\n",
|
||||
" # Der Rueckgabewert wird in die gemeinsame Sprache uebersetzt. Ab hier\n",
|
||||
" # sieht die Auswertung genauso aus wie bei HiGHS, GLOP oder CVXPY.\n",
|
||||
" status = status_von_cpsat(rohstatus)\n",
|
||||
" loesung = Loesung(\n",
|
||||
" status=status,\n",
|
||||
" zielwert=loeser.ObjectiveValue() if status.brauchbar else None,\n",
|
||||
" schranke=loeser.BestObjectiveBound() if status.brauchbar else None,\n",
|
||||
" laufzeit=loeser.WallTime())\n",
|
||||
"\n",
|
||||
" print(\"=\" * 78)\n",
|
||||
" print(\" DYNAMISCHER VERTRETUNGSPLAN (CP-SAT OPTIMIERT)\")\n",
|
||||
" print(\"=\" * 78)\n",
|
||||
"\n",
|
||||
" if status.modellfehler:\n",
|
||||
" print(f\"Kein zulaessiger Plan moeglich (Status: {status.value}).\")\n",
|
||||
" print(\"Die harten Regeln widersprechen sich. Naechster Schritt: eine Regel\")\n",
|
||||
" print(\"weich machen (Kapitel Praxisfallen) oder den Konflikt eingrenzen\")\n",
|
||||
" print(\"(Anhang Fehlerdiagnose).\")\n",
|
||||
" return None\n",
|
||||
" if not status.brauchbar:\n",
|
||||
" print(f\"Solver lieferte kein Ergebnis (Status: {status.value}).\")\n",
|
||||
" print(\"Zeitlimit erhoehen oder das Modell vereinfachen.\")\n",
|
||||
" return None\n",
|
||||
"\n",
|
||||
" guete = (\"beweisbar optimal\" if status is SolverStatus.OPTIMAL\n",
|
||||
" else \"zulaessig, aber nicht bewiesen - Gap beachten\")\n",
|
||||
" print(f\"Solver-Status: {loeser.StatusName(rohstatus)} -> {status.value} ({guete})\")\n",
|
||||
" print(f\"{loesung.als_bericht()}\\n\")\n",
|
||||
"\n",
|
||||
" # --- Plan ausgeben ----------------------------------------------------\n",
|
||||
" zuweisung = {s: next(p for p in PERSONAL if loeser.Value(x[p, s]) == 1)\n",
|
||||
" for s in range(len(SLOTS))}\n",
|
||||
" plan = [{\n",
|
||||
" \"Stunde\": f\"Std. {SLOTS[s]}\",\n",
|
||||
" \"Klasse\": KLASSEN[s],\n",
|
||||
" \"Fach\": FAECHER[s],\n",
|
||||
" \"Vertretung\": zuweisung[s],\n",
|
||||
" \"Vorbelastung\": f\"{VORBELASTUNG[zuweisung[s]]} Std.\",\n",
|
||||
" } for s in range(len(SLOTS))]\n",
|
||||
" print(pd.DataFrame(plan).to_string(index=False))\n",
|
||||
"\n",
|
||||
" # --- Abnahmepruefung gegen die Anforderung ----------------------------\n",
|
||||
" beanstandungen = pruefe_plan(zuweisung)\n",
|
||||
" if beanstandungen:\n",
|
||||
" print(\"\\nABNAHMEPRUEFUNG FEHLGESCHLAGEN:\")\n",
|
||||
" for beanstandung in beanstandungen:\n",
|
||||
" print(f\" - {beanstandung}\")\n",
|
||||
" return None\n",
|
||||
" print(\"\\nAbnahmepruefung: bestanden (Besetzung, Qualifikation, Hoechstzahl).\")\n",
|
||||
"\n",
|
||||
" # --- Erklaerbarkeit: Strafkosten aufschluesseln -----------------------\n",
|
||||
" kosten_last = sum(VORBELASTUNG[p] * STRAFE_VORBELASTUNG\n",
|
||||
" * sum(loeser.Value(x[p, s]) for s in range(len(SLOTS)))\n",
|
||||
" for p in PERSONAL)\n",
|
||||
" kosten_loecher = sum(loeser.Value(v) for v in loch_variablen) * STRAFE_LOCH\n",
|
||||
" kosten_fairness = loeser.Value(spannweite) * STRAFE_UNFAIRNESS\n",
|
||||
"\n",
|
||||
" print(\"\\n--- Woraus bestehen die Strafkosten? ---\")\n",
|
||||
" print(f\" Vorbelastung geschont: {kosten_last:5.0f} Punkte\")\n",
|
||||
" print(f\" Freistundenloecher: {kosten_loecher:5.0f} Punkte \"\n",
|
||||
" f\"({sum(loeser.Value(v) for v in loch_variablen)} Loch/Loecher)\")\n",
|
||||
" print(f\" Fairness (Spannweite {loeser.Value(spannweite)}): {kosten_fairness:5.0f} Punkte\")\n",
|
||||
" print(f\" {'Summe':<23} {loeser.ObjectiveValue():5.0f} Punkte\")\n",
|
||||
"\n",
|
||||
" print(\"\\n--- Auslastung nach Optimierung ---\")\n",
|
||||
" for p in PERSONAL:\n",
|
||||
" heute = sum(loeser.Value(x[p, s]) for s in range(len(SLOTS)))\n",
|
||||
" print(f\" * {p:<15}: {VORBELASTUNG[p]} vorher + {heute} Vertretung \"\n",
|
||||
" f\"= {VORBELASTUNG[p] + heute} Stunden\")\n",
|
||||
"\n",
|
||||
" print(f\"\\nSuchstatistik: {loeser.NumBranches()} Verzweigungen, \"\n",
|
||||
" f\"{loeser.NumConflicts()} Konflikte, {loeser.WallTime():.3f} s\")\n",
|
||||
" print(\"=\" * 78)\n",
|
||||
" return plan\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"if __name__ == \"__main__\":\n",
|
||||
" plane_vertretung()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Intervallvariablen: Job-Shop-Scheduling\n",
|
||||
"\n",
|
||||
"`JobShop_Intervalle.py`\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"#!/usr/bin/env python3\n",
|
||||
"\n",
|
||||
"# JobShop_Intervalle.py\n",
|
||||
"\"\"\"\n",
|
||||
"Kapitel CP-SAT: Job-Shop-Scheduling mit Intervallvariablen.\n",
|
||||
"Zeigt NewIntervalVar, AddNoOverlap und die Minimierung des Makespan.\n",
|
||||
"\"\"\"\n",
|
||||
"\n",
|
||||
"import collections\n",
|
||||
"\n",
|
||||
"from ortools.sat.python import cp_model\n",
|
||||
"\n",
|
||||
"# (Maschine, Dauer) je Arbeitsgang, in der Reihenfolge des Auftrags\n",
|
||||
"AUFTRAEGE = [\n",
|
||||
" [(0, 3), (1, 2), (2, 2)], # Auftrag 0\n",
|
||||
" [(0, 2), (2, 1), (1, 4)], # Auftrag 1\n",
|
||||
" [(1, 4), (2, 3)], # Auftrag 2\n",
|
||||
"]\n",
|
||||
"MASCHINENNAMEN = [\"Fraese\", \"Dreherei\", \"Lackiererei\"]\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def loese_jobshop():\n",
|
||||
" anzahl_maschinen = 1 + max(m for auftrag in AUFTRAEGE for m, _ in auftrag)\n",
|
||||
" horizont = sum(dauer for auftrag in AUFTRAEGE for _, dauer in auftrag)\n",
|
||||
"\n",
|
||||
" modell = cp_model.CpModel()\n",
|
||||
" Gang = collections.namedtuple(\"Gang\", \"start ende intervall\")\n",
|
||||
" plaene = {}\n",
|
||||
" maschinen_intervalle = collections.defaultdict(list)\n",
|
||||
"\n",
|
||||
" # --- Intervallvariablen anlegen --------------------------------------\n",
|
||||
" for a, auftrag in enumerate(AUFTRAEGE):\n",
|
||||
" for g, (maschine, dauer) in enumerate(auftrag):\n",
|
||||
" start = modell.NewIntVar(0, horizont, f\"start_{a}_{g}\")\n",
|
||||
" ende = modell.NewIntVar(0, horizont, f\"ende_{a}_{g}\")\n",
|
||||
" # Ein Intervall koppelt start + dauer == ende automatisch\n",
|
||||
" intervall = modell.NewIntervalVar(start, dauer, ende, f\"intervall_{a}_{g}\")\n",
|
||||
" plaene[a, g] = Gang(start, ende, intervall)\n",
|
||||
" maschinen_intervalle[maschine].append(intervall)\n",
|
||||
"\n",
|
||||
" # --- H1: eine Maschine bearbeitet nur einen Gang gleichzeitig --------\n",
|
||||
" for maschine in range(anzahl_maschinen):\n",
|
||||
" modell.AddNoOverlap(maschinen_intervalle[maschine])\n",
|
||||
"\n",
|
||||
" # --- H2: Reihenfolge innerhalb eines Auftrags einhalten -------------\n",
|
||||
" for a, auftrag in enumerate(AUFTRAEGE):\n",
|
||||
" for g in range(len(auftrag) - 1):\n",
|
||||
" modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende)\n",
|
||||
"\n",
|
||||
" # --- Ziel: Makespan minimieren ---------------------------------------\n",
|
||||
" makespan = modell.NewIntVar(0, horizont, \"makespan\")\n",
|
||||
" modell.AddMaxEquality(makespan, [plaene[a, len(auftrag) - 1].ende\n",
|
||||
" for a, auftrag in enumerate(AUFTRAEGE)])\n",
|
||||
" modell.Minimize(makespan)\n",
|
||||
"\n",
|
||||
" loeser = cp_model.CpSolver()\n",
|
||||
" loeser.parameters.max_time_in_seconds = 10.0\n",
|
||||
" # Es gibt mehrere Plaene mit demselben kuerzesten Makespan. Welchen CP-SAT\n",
|
||||
" # findet, haengt sonst davon ab, welcher seiner parallelen Suchstraenge\n",
|
||||
" # zuerst fertig wird - dasselbe Programm liefert dann von Lauf zu Lauf\n",
|
||||
" # verschiedene (gleich gute) Plaene. Fuer ein reproduzierbares Buchbeispiel\n",
|
||||
" # fixieren wir beides. Im Produktivbetrieb laesst man die Standardwerte\n",
|
||||
" # stehen: Mehrere Arbeiter sind dort deutlich schneller.\n",
|
||||
" loeser.parameters.num_workers = 1\n",
|
||||
" loeser.parameters.random_seed = 1\n",
|
||||
" status = loeser.Solve(modell)\n",
|
||||
"\n",
|
||||
" if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):\n",
|
||||
" print(f\"Kein Plan gefunden: {loeser.StatusName(status)}\")\n",
|
||||
" return\n",
|
||||
"\n",
|
||||
" print(\"=\" * 74)\n",
|
||||
" print(\" JOB-SHOP-SCHEDULING MIT INTERVALLVARIABLEN\")\n",
|
||||
" print(\"=\" * 74)\n",
|
||||
" print(f\"Status: {loeser.StatusName(status)} | \"\n",
|
||||
" f\"Kuerzeste Gesamtdauer (Makespan): {loeser.Value(makespan)} Zeiteinheiten\\n\")\n",
|
||||
"\n",
|
||||
" # --- Gantt-Diagramm als Text ------------------------------------------\n",
|
||||
" dauer_gesamt = loeser.Value(makespan)\n",
|
||||
" print(f\"{'Maschine':<14}|\" + \"\".join(f\"{t:<3}\" for t in range(dauer_gesamt)))\n",
|
||||
" print(\"-\" * (15 + 3 * dauer_gesamt))\n",
|
||||
" for maschine in range(anzahl_maschinen):\n",
|
||||
" zeile = [\" . \"] * dauer_gesamt\n",
|
||||
" for a, auftrag in enumerate(AUFTRAEGE):\n",
|
||||
" for g, (m, dauer) in enumerate(auftrag):\n",
|
||||
" if m == maschine:\n",
|
||||
" beginn = loeser.Value(plaene[a, g].start)\n",
|
||||
" for t in range(beginn, beginn + dauer):\n",
|
||||
" zeile[t] = f\" A{a}\"\n",
|
||||
" print(f\"{MASCHINENNAMEN[maschine]:<14}|\" + \"\".join(zeile))\n",
|
||||
"\n",
|
||||
" print(\"\\n--- Detailplan ---\")\n",
|
||||
" for a, auftrag in enumerate(AUFTRAEGE):\n",
|
||||
" teile = []\n",
|
||||
" for g, (maschine, dauer) in enumerate(auftrag):\n",
|
||||
" beginn = loeser.Value(plaene[a, g].start)\n",
|
||||
" teile.append(f\"{MASCHINENNAMEN[maschine]} {beginn}-{beginn + dauer}\")\n",
|
||||
" print(f\" Auftrag {a}: \" + \" -> \".join(teile))\n",
|
||||
"\n",
|
||||
" # --- Untere Schranke zum Vergleich ------------------------------------\n",
|
||||
" laengster_auftrag = max(sum(d for _, d in a) for a in AUFTRAEGE)\n",
|
||||
" hoechste_maschinenlast = max(\n",
|
||||
" sum(d for auftrag in AUFTRAEGE for m, d in auftrag if m == maschine)\n",
|
||||
" for maschine in range(anzahl_maschinen))\n",
|
||||
" schranke = max(laengster_auftrag, hoechste_maschinenlast)\n",
|
||||
" print(f\"\\nUntere Schranke (laengster Auftrag / hoechste Maschinenlast): {schranke}\")\n",
|
||||
" print(f\"Erreichter Makespan: {loeser.Value(makespan)}\"\n",
|
||||
" f\"{' -> beweisbar bestmoeglich' if loeser.Value(makespan) == schranke else ''}\")\n",
|
||||
" print(\"=\" * 74)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"if __name__ == \"__main__\":\n",
|
||||
" loese_jobshop()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Die fünf Antworten von CP-SAT\n",
|
||||
"\n",
|
||||
"`CP_SAT_Statusfaelle.py`\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"#!/usr/bin/env python3\n",
|
||||
"\n",
|
||||
"# CP_SAT_Statusfaelle.py\n",
|
||||
"\"\"\"\n",
|
||||
"Kapitel CP-SAT: Alle fuenf Antworten von CP-SAT - und was jede bedeutet.\n",
|
||||
"\n",
|
||||
"Der haeufigste Fehler beim Einsatz von CP-SAT im Betrieb ist eine Zeile wie\n",
|
||||
"\n",
|
||||
" if status == cp_model.OPTIMAL:\n",
|
||||
" ...verwerte die Loesung...\n",
|
||||
"\n",
|
||||
"Sie wirft in drei von fuenf Faellen etwas weg, das man haette gebrauchen\n",
|
||||
"koennen, und verschweigt in einem weiteren Fall einen Modellierungsfehler.\n",
|
||||
"\n",
|
||||
"Dieses Programm erzeugt jeden Statusfall absichtlich und zeigt die passende\n",
|
||||
"Reaktion:\n",
|
||||
"\n",
|
||||
" OPTIMAL beweisbar bestmoeglich -> ausfuehren\n",
|
||||
" FEASIBLE zulaessig, Zeit war um -> Gap pruefen, dann entscheiden\n",
|
||||
" INFEASIBLE kein Plan existiert -> harte Regeln lockern\n",
|
||||
" MODEL_INVALID das Modell ist fehlerhaft -> Programmierfehler beheben\n",
|
||||
" UNKNOWN nichts gefunden, Zeit war um -> mehr Zeit oder Heuristik\n",
|
||||
"\n",
|
||||
"Benoetigt: numpy, ortools\n",
|
||||
"\"\"\"\n",
|
||||
"\n",
|
||||
"from __future__ import annotations\n",
|
||||
"\n",
|
||||
"from dataclasses import dataclass\n",
|
||||
"\n",
|
||||
"import numpy as np\n",
|
||||
"from ortools.sat.python import cp_model\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"@dataclass\n",
|
||||
"class Auswertung:\n",
|
||||
" \"\"\"Was nach einem CP-SAT-Lauf tatsaechlich feststeht.\"\"\"\n",
|
||||
" status: str\n",
|
||||
" hat_loesung: bool\n",
|
||||
" beweisbar_optimal: bool\n",
|
||||
" zielwert: float | None\n",
|
||||
" schranke: float | None\n",
|
||||
" gap: float | None\n",
|
||||
" empfehlung: str\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def werte_aus(loeser: cp_model.CpSolver, status: int,\n",
|
||||
" mit_zielfunktion: bool) -> Auswertung:\n",
|
||||
" \"\"\"Uebersetzt einen CP-SAT-Status in eine Handlungsempfehlung.\n",
|
||||
"\n",
|
||||
" Genau diese Funktion fehlt in den meisten Projekten. Sie ist kurz, aber\n",
|
||||
" sie ist der Unterschied zwischen einem Prototyp und einem System, das\n",
|
||||
" nachts ohne Aufsicht laeuft.\n",
|
||||
" \"\"\"\n",
|
||||
" name = loeser.StatusName(status)\n",
|
||||
" hat_loesung = status in (cp_model.OPTIMAL, cp_model.FEASIBLE)\n",
|
||||
"\n",
|
||||
" zielwert = schranke = gap = None\n",
|
||||
" if hat_loesung and mit_zielfunktion:\n",
|
||||
" zielwert = loeser.ObjectiveValue()\n",
|
||||
" schranke = loeser.BestObjectiveBound()\n",
|
||||
" nenner = max(abs(zielwert), 1e-9)\n",
|
||||
" gap = abs(zielwert - schranke) / nenner\n",
|
||||
"\n",
|
||||
" if status == cp_model.OPTIMAL:\n",
|
||||
" empfehlung = \"Ausfuehren. Besser geht es nachweislich nicht.\"\n",
|
||||
" elif status == cp_model.FEASIBLE:\n",
|
||||
" empfehlung = (f\"Brauchbar. Der Plan ist hoechstens {gap * 100:.2f} % \"\n",
|
||||
" f\"schlechter als das theoretisch Bestmoegliche.\"\n",
|
||||
" if gap is not None else\n",
|
||||
" \"Brauchbar, aber nicht als optimal bewiesen.\")\n",
|
||||
" elif status == cp_model.INFEASIBLE:\n",
|
||||
" empfehlung = (\"KEIN Plan existiert. Das ist eine Aussage ueber Ihre \"\n",
|
||||
" \"harten Regeln, nicht ueber die Rechenzeit: Auch \"\n",
|
||||
" \"unendlich viel Zeit wuerde nichts aendern. Regeln \"\n",
|
||||
" \"lockern oder weich machen (Anhang C).\")\n",
|
||||
" elif status == cp_model.MODEL_INVALID:\n",
|
||||
" empfehlung = (\"Das MODELL ist fehlerhaft, nicht das Problem. \"\n",
|
||||
" \"Typisch: eine Variable mit leerem Wertebereich. \"\n",
|
||||
" \"model.Validate() nennt die Ursache.\")\n",
|
||||
" else: # UNKNOWN\n",
|
||||
" empfehlung = (\"Nichts gefunden - aber auch nicht widerlegt. Mehr Zeit \"\n",
|
||||
" \"geben, das Modell vereinfachen oder mit einer \"\n",
|
||||
" \"Heuristik vorbelegen (AddHint).\")\n",
|
||||
"\n",
|
||||
" return Auswertung(\n",
|
||||
" status=name,\n",
|
||||
" hat_loesung=hat_loesung,\n",
|
||||
" beweisbar_optimal=status == cp_model.OPTIMAL,\n",
|
||||
" zielwert=zielwert, schranke=schranke, gap=gap,\n",
|
||||
" empfehlung=empfehlung,\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def loese(baue, zeitlimit: float, mit_zielfunktion: bool) -> Auswertung:\n",
|
||||
" modell = cp_model.CpModel()\n",
|
||||
" baue(modell)\n",
|
||||
" loeser = cp_model.CpSolver()\n",
|
||||
" loeser.parameters.max_time_in_seconds = zeitlimit\n",
|
||||
" # Fuer reproduzierbare Buchausgaben; im Betrieb weglassen.\n",
|
||||
" loeser.parameters.num_workers = 1\n",
|
||||
" loeser.parameters.random_seed = 1\n",
|
||||
" status = loeser.Solve(modell)\n",
|
||||
" return werte_aus(loeser, status, mit_zielfunktion)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"# --- Die fuenf Faelle --------------------------------------------------------\n",
|
||||
"\n",
|
||||
"def dienstplan_loesbar(modell: cp_model.CpModel) -> None:\n",
|
||||
" \"\"\"Vier Personen, fuenf Dienste, hoechstens zwei je Person - loesbar.\"\"\"\n",
|
||||
" personen, dienste = 4, 5\n",
|
||||
" x = [[modell.NewBoolVar(f\"x_{i}_{j}\") for j in range(dienste)]\n",
|
||||
" for i in range(personen)]\n",
|
||||
" for j in range(dienste):\n",
|
||||
" modell.AddExactlyOne(x[i][j] for i in range(personen))\n",
|
||||
" for i in range(personen):\n",
|
||||
" modell.Add(sum(x[i]) <= 2)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def dienstplan_unloesbar(modell: cp_model.CpModel) -> None:\n",
|
||||
" \"\"\"Dieselben fuenf Dienste, aber hoechstens EIN Dienst je Person.\n",
|
||||
"\n",
|
||||
" Vier Personen koennen zusammen hoechstens vier Dienste uebernehmen - fuer\n",
|
||||
" fuenf Dienste reicht das nicht. Kein Solver der Welt findet hier etwas.\n",
|
||||
" \"\"\"\n",
|
||||
" personen, dienste = 4, 5\n",
|
||||
" x = [[modell.NewBoolVar(f\"x_{i}_{j}\") for j in range(dienste)]\n",
|
||||
" for i in range(personen)]\n",
|
||||
" for j in range(dienste):\n",
|
||||
" modell.AddExactlyOne(x[i][j] for i in range(personen))\n",
|
||||
" for i in range(personen):\n",
|
||||
" modell.Add(sum(x[i]) <= 1)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def modell_fehlerhaft(modell: cp_model.CpModel) -> None:\n",
|
||||
" \"\"\"Eine Variable mit leerem Wertebereich: untere Schranke > obere.\n",
|
||||
"\n",
|
||||
" Im Alltag entsteht das durch einen Rechenfehler in den Grenzen, etwa\n",
|
||||
" NewIntVar(mindestbesetzung, kapazitaet, ...) bei falsch sortierten Daten.\n",
|
||||
" \"\"\"\n",
|
||||
" kaputt = modell.NewIntVar(5, 2, \"leerer_bereich\")\n",
|
||||
" modell.Add(kaputt >= 0)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def lastverteilung(modell: cp_model.CpModel) -> None:\n",
|
||||
" \"\"\"120 Auftraege auf 11 Maschinen - gross genug, dass der Optimalitaets-\n",
|
||||
" beweis laenger dauert als das Finden einer sehr guten Loesung.\"\"\"\n",
|
||||
" rng = np.random.default_rng(3)\n",
|
||||
" dauer = rng.integers(100, 9000, 120)\n",
|
||||
" n, maschinen = len(dauer), 11\n",
|
||||
" obergrenze = int(dauer.sum())\n",
|
||||
"\n",
|
||||
" x = [[modell.NewBoolVar(f\"x_{i}_{k}\") for k in range(maschinen)]\n",
|
||||
" for i in range(n)]\n",
|
||||
" for i in range(n):\n",
|
||||
" modell.AddExactlyOne(x[i])\n",
|
||||
" belegung = [modell.NewIntVar(0, obergrenze, f\"l_{k}\") for k in range(maschinen)]\n",
|
||||
" for k in range(maschinen):\n",
|
||||
" modell.Add(belegung[k] == sum(int(dauer[i]) * x[i][k] for i in range(n)))\n",
|
||||
" makespan = modell.NewIntVar(0, obergrenze, \"makespan\")\n",
|
||||
" modell.AddMaxEquality(makespan, belegung)\n",
|
||||
" modell.Minimize(makespan)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def zahlpartition(modell: cp_model.CpModel) -> None:\n",
|
||||
" \"\"\"46 grosse Zahlen exakt in zwei gleich schwere Haelften teilen.\n",
|
||||
"\n",
|
||||
" Ein beruehmt schweres Problem: Es gibt keine Zielfunktion, entweder es\n",
|
||||
" geht auf oder nicht - und beides zu entscheiden dauert lange.\n",
|
||||
" \"\"\"\n",
|
||||
" rng = np.random.default_rng(1)\n",
|
||||
" gewichte = rng.integers(10**6, 2 * 10**6, 46)\n",
|
||||
" x = [modell.NewBoolVar(f\"x_{i}\") for i in range(len(gewichte))]\n",
|
||||
" modell.Add(sum(int(gewichte[i]) * x[i] for i in range(len(gewichte)))\n",
|
||||
" == int(gewichte.sum() // 2))\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def zeige(titel: str, a: Auswertung) -> None:\n",
|
||||
" print(f\"\\n{titel}\")\n",
|
||||
" print(f\" Status {a.status}\")\n",
|
||||
" print(f\" Loesung vorhanden {'ja' if a.hat_loesung else 'nein'}\")\n",
|
||||
" if a.zielwert is not None:\n",
|
||||
" print(f\" Zielwert / Schranke {a.zielwert:,.0f} / {a.schranke:,.0f}\"\n",
|
||||
" f\" (Gap {a.gap * 100:.3f} %)\")\n",
|
||||
" print(f\" -> {a.empfehlung}\")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"if __name__ == \"__main__\":\n",
|
||||
" print(\"=\" * 78)\n",
|
||||
" print(\" DIE FUENF ANTWORTEN VON CP-SAT\")\n",
|
||||
" print(\"=\" * 78)\n",
|
||||
"\n",
|
||||
" zeige(\"[1] Dienstplan, hoechstens 2 Dienste je Person\",\n",
|
||||
" loese(dienstplan_loesbar, 10.0, mit_zielfunktion=False))\n",
|
||||
"\n",
|
||||
" zeige(\"[2] Lastverteilung 120 Auftraege / 11 Maschinen, 0,5 s Limit\",\n",
|
||||
" loese(lastverteilung, 0.5, mit_zielfunktion=True))\n",
|
||||
"\n",
|
||||
" zeige(\"[3] Derselbe Fall mit 10 s Limit\",\n",
|
||||
" loese(lastverteilung, 10.0, mit_zielfunktion=True))\n",
|
||||
"\n",
|
||||
" zeige(\"[4] Dienstplan, hoechstens 1 Dienst je Person\",\n",
|
||||
" loese(dienstplan_unloesbar, 10.0, mit_zielfunktion=False))\n",
|
||||
"\n",
|
||||
" zeige(\"[5] Variable mit leerem Wertebereich\",\n",
|
||||
" loese(modell_fehlerhaft, 10.0, mit_zielfunktion=False))\n",
|
||||
"\n",
|
||||
" zeige(\"[6] Zahlpartition mit 46 grossen Zahlen, 2 s Limit\",\n",
|
||||
" loese(zahlpartition, 2.0, mit_zielfunktion=False))\n",
|
||||
"\n",
|
||||
" print(\"\\n\" + \"=\" * 78)\n",
|
||||
" print(\" DIE DREI, DIE MAN NICHT VERWECHSELN DARF\")\n",
|
||||
" print(\"=\" * 78)\n",
|
||||
" print(\"INFEASIBLE Es gibt keine Loesung. Eine Aussage ueber Ihr MODELL.\")\n",
|
||||
" print(\" Mehr Rechenzeit aendert daran nichts.\")\n",
|
||||
" print(\"UNKNOWN Es wurde keine gefunden. Eine Aussage ueber die ZEIT.\")\n",
|
||||
" print(\" Ob es eine gibt, ist offen.\")\n",
|
||||
" print(\"MODEL_INVALID Das Modell ist gar kein gueltiges Modell. Eine\")\n",
|
||||
" print(\" Aussage ueber Ihren CODE - immer ein Programmierfehler.\")\n",
|
||||
" print()\n",
|
||||
" print(\"Wer diese drei in einem 'else: return None' zusammenfasst, verliert\")\n",
|
||||
" print(\"genau die Information, die zur Fehlersuche noetig waere.\")\n",
|
||||
" print(\"=\" * 78)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Finde den Denkfehler\n",
|
||||
"\n",
|
||||
"`Strafgewichte.py`\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"#!/usr/bin/env python3\n",
|
||||
"\n",
|
||||
"# Strafgewichte.py\n",
|
||||
"\"\"\"\n",
|
||||
"Kapitel CP-SAT: Warum ein Strafgewicht keine Wichtigkeit ausdrueckt, sondern\n",
|
||||
"einen Wechselkurs.\n",
|
||||
"\n",
|
||||
"Weiche Regeln kommen als Strafterm in die Zielfunktion. Die Frage, die dabei\n",
|
||||
"regelmaessig falsch beantwortet wird, lautet: \"Wie gross muss die Strafe sein?\"\n",
|
||||
"Die uebliche Antwort - \"je wichtiger, desto groesser\" - klingt vernuenftig und\n",
|
||||
"fuehrt zuverlaessig zu Plaenen, die niemand unterschreiben will.\n",
|
||||
"\n",
|
||||
"Der Grund: Zwei Strafgewichte legen nicht Wichtigkeiten fest, sondern einen\n",
|
||||
"UMRECHNUNGSKURS. Steht die Serienregel bei 1 und der Wunschfrei-Tag bei 10,\n",
|
||||
"dann hat man dem Solver woertlich gesagt: \"Zehn Verstoesse gegen die\n",
|
||||
"Serienregel sind so schlimm wie ein abgelehnter Wunsch.\" Er nimmt das\n",
|
||||
"ernst - und handelt danach.\n",
|
||||
"\n",
|
||||
"Szenario: Schichtplan, 6 Personen, 14 Tage, 4 Besetzungen pro Tag.\n",
|
||||
" HART jeder Tag genau 4 Personen; hoechstens 11 Dienste je Person\n",
|
||||
" WEICH keine drei Dienste in Folge (Betriebsvereinbarung)\n",
|
||||
" WEICH Wunschfrei-Tage einhalten (26 Antraege)\n",
|
||||
"\n",
|
||||
"Benoetigt: numpy, ortools\n",
|
||||
"\"\"\"\n",
|
||||
"\n",
|
||||
"from __future__ import annotations\n",
|
||||
"\n",
|
||||
"import numpy as np\n",
|
||||
"from ortools.sat.python import cp_model\n",
|
||||
"\n",
|
||||
"PERSONEN, TAGE, PRO_TAG = 6, 14, 4\n",
|
||||
"MAX_DIENSTE = 11\n",
|
||||
"STRAFE_WUNSCH = 10 # bleibt fest - variiert wird die Serienstrafe\n",
|
||||
"\n",
|
||||
"RNG = np.random.default_rng(2)\n",
|
||||
"WUNSCHFREI = sorted({(p, t) for p in range(PERSONEN) for t in range(TAGE)\n",
|
||||
" if RNG.random() < 0.35})\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def plane(strafe_serie: int, zeitlimit: float = 25.0) -> dict:\n",
|
||||
" \"\"\"Baut den Schichtplan mit dem angegebenen Gewicht fuer die Serienregel.\"\"\"\n",
|
||||
" modell = cp_model.CpModel()\n",
|
||||
" x = {(p, t): modell.NewBoolVar(f\"x_{p}_{t}\")\n",
|
||||
" for p in range(PERSONEN) for t in range(TAGE)}\n",
|
||||
"\n",
|
||||
" # --- harte Regeln --------------------------------------------------\n",
|
||||
" for t in range(TAGE):\n",
|
||||
" modell.Add(sum(x[p, t] for p in range(PERSONEN)) == PRO_TAG)\n",
|
||||
" for p in range(PERSONEN):\n",
|
||||
" modell.Add(sum(x[p, t] for t in range(TAGE)) <= MAX_DIENSTE)\n",
|
||||
"\n",
|
||||
" # --- weiche Regel 1: keine drei Dienste in Folge --------------------\n",
|
||||
" # serie[p, t] = 1 <=> Person p arbeitet an t, t+1 UND t+2.\n",
|
||||
" # Die Ungleichung erzwingt das nur in eine Richtung; das genuegt, weil\n",
|
||||
" # der Solver serie minimiert - er wuerde die Variable nie freiwillig\n",
|
||||
" # auf 1 setzen.\n",
|
||||
" strafterme = []\n",
|
||||
" serien = []\n",
|
||||
" for p in range(PERSONEN):\n",
|
||||
" for t in range(TAGE - 2):\n",
|
||||
" serie = modell.NewBoolVar(f\"serie_{p}_{t}\")\n",
|
||||
" modell.Add(x[p, t] + x[p, t + 1] + x[p, t + 2] <= 2 + serie)\n",
|
||||
" serien.append(serie)\n",
|
||||
" strafterme.append(serie * strafe_serie)\n",
|
||||
"\n",
|
||||
" # --- weiche Regel 2: Wunschfrei ------------------------------------\n",
|
||||
" wunschverstoesse = []\n",
|
||||
" for p, t in WUNSCHFREI:\n",
|
||||
" wunschverstoesse.append(x[p, t])\n",
|
||||
" strafterme.append(x[p, t] * STRAFE_WUNSCH)\n",
|
||||
"\n",
|
||||
" modell.Minimize(sum(strafterme))\n",
|
||||
"\n",
|
||||
" loeser = cp_model.CpSolver()\n",
|
||||
" loeser.parameters.max_time_in_seconds = zeitlimit\n",
|
||||
" loeser.parameters.num_workers = 1 # reproduzierbare Buchausgabe\n",
|
||||
" loeser.parameters.random_seed = 1\n",
|
||||
" status = loeser.Solve(modell)\n",
|
||||
" if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):\n",
|
||||
" raise RuntimeError(f\"Kein Plan: {loeser.StatusName(status)}\")\n",
|
||||
"\n",
|
||||
" return {\n",
|
||||
" \"status\": loeser.StatusName(status),\n",
|
||||
" \"zielwert\": loeser.ObjectiveValue(),\n",
|
||||
" \"serien\": sum(loeser.Value(v) for v in serien),\n",
|
||||
" \"wunsch_verletzt\": sum(loeser.Value(v) for v in wunschverstoesse),\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"if __name__ == \"__main__\":\n",
|
||||
" print(\"=\" * 78)\n",
|
||||
" print(\" STRAFGEWICHTE SIND WECHSELKURSE, KEINE WICHTIGKEITEN\")\n",
|
||||
" print(\"=\" * 78)\n",
|
||||
" print(f\"{PERSONEN} Personen, {TAGE} Tage, {PRO_TAG} Besetzungen pro Tag \"\n",
|
||||
" f\"= {TAGE * PRO_TAG} Dienste\")\n",
|
||||
" print(f\"Das sind {TAGE * PRO_TAG / PERSONEN:.1f} Dienste je Person - der Plan ist eng.\")\n",
|
||||
" print(f\"{len(WUNSCHFREI)} Wunschfrei-Antraege, Strafe je abgelehntem Wunsch: \"\n",
|
||||
" f\"{STRAFE_WUNSCH}\\n\")\n",
|
||||
"\n",
|
||||
" print(f\"{'Strafe je 3er-Serie':>20} {'3er-Serien':>12} {'Wunsch verletzt':>17} \"\n",
|
||||
" f\"{'Zielwert':>10}\")\n",
|
||||
" print(\"-\" * 78)\n",
|
||||
" ergebnisse = {}\n",
|
||||
" for strafe in (1, 2, 5, 10, 30, 100):\n",
|
||||
" e = plane(strafe)\n",
|
||||
" ergebnisse[strafe] = e\n",
|
||||
" print(f\"{strafe:>20} {e['serien']:>12} {e['wunsch_verletzt']:>17} \"\n",
|
||||
" f\"{e['zielwert']:>10.0f}\")\n",
|
||||
" print(\"-\" * 78)\n",
|
||||
"\n",
|
||||
" niedrig, hoch = ergebnisse[1], ergebnisse[30]\n",
|
||||
" print(f\"\\nBei Strafe 1 baut der Solver {niedrig['serien']} Drei-Tage-Serien ein.\")\n",
|
||||
" print(\"Die Betriebsvereinbarung steht im Modell - und wird trotzdem\")\n",
|
||||
" print(\"systematisch verletzt. Das ist kein Fehler des Solvers: Bei einem\")\n",
|
||||
" print(f\"Gewicht von 1 gegen {STRAFE_WUNSCH} lohnt sich jede Serie, solange sie\")\n",
|
||||
" print(\"auch nur einen Zehntel-Wunsch rettet.\")\n",
|
||||
" print(f\"\\nBei Strafe 30 sind es {hoch['serien']} Serien - dafuer werden\")\n",
|
||||
" print(f\"{hoch['wunsch_verletzt']} statt {niedrig['wunsch_verletzt']} Wuensche abgelehnt.\")\n",
|
||||
" print(\"Beide Plaene sind optimal. Sie beantworten nur verschiedene Fragen.\")\n",
|
||||
" print()\n",
|
||||
" print(\"Die Frage lautet also nie 'wie wichtig ist mir diese Regel?', sondern:\")\n",
|
||||
" print(\" 'Wie viele abgelehnte Wuensche bin ich bereit zu akzeptieren,\")\n",
|
||||
" print(\" um eine Drei-Tage-Serie zu vermeiden?'\")\n",
|
||||
" print(\"Wer darauf keine Zahl nennen kann, hat das Problem noch nicht\")\n",
|
||||
" print(\"verstanden - und sollte diese Tabelle dem Auftraggeber vorlegen,\")\n",
|
||||
" print(\"statt ein Gewicht zu raten.\")\n",
|
||||
" print()\n",
|
||||
" print(\"Und die wichtigste Konsequenz: Eine Regel, die NIE gebrochen werden\")\n",
|
||||
" print(\"darf, gehoert nicht in die Zielfunktion, sondern unter die harten\")\n",
|
||||
" print(\"Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.\")\n",
|
||||
" print(\"=\" * 78)"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.11"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
Loading…
Reference in a new issue