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
644
Notebooks_04/graphen.ipynb
Normal file
644
Notebooks_04/graphen.ipynb
Normal file
|
|
@ -0,0 +1,644 @@
|
|||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Kapitel 8: Graphen, Flüsse und Touren — Min-Cost-Flow, Matching und VRP\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": [
|
||||
"## Das Minimum-Cost-Flow-Problem (MCNFP)\n",
|
||||
"\n",
|
||||
"`Min_Cost_Flow.py`\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"#!/usr/bin/env python3\n",
|
||||
"\n",
|
||||
"# Min_Cost_Flow.py\n",
|
||||
"\"\"\"\n",
|
||||
"Kapitel Graphen: Kostenminimaler Fluss durch ein Netzwerk.\n",
|
||||
"\n",
|
||||
"Loest dasselbe Problem zweimal:\n",
|
||||
" (1) als allgemeines LP mit scipy -> zeigt die Modellstruktur\n",
|
||||
" (2) mit dem spezialisierten Netzwerk-Solver von OR-Tools -> zeigt den\n",
|
||||
" Geschwindigkeitsvorteil eines Verfahrens, das die Struktur ausnutzt\n",
|
||||
"\n",
|
||||
"Beide laufen in eigenen Prozessen nicht noetig: scipy und ortools vertragen\n",
|
||||
"sich (nur ortools + highspy kollidieren, siehe Kapitel Oekosystem).\n",
|
||||
"\"\"\"\n",
|
||||
"\n",
|
||||
"import numpy as np\n",
|
||||
"from scipy.optimize import linprog\n",
|
||||
"\n",
|
||||
"# --- Netzwerk definieren ---------------------------------------------------\n",
|
||||
"KNOTEN = [\"Werk_A\", \"Werk_B\", \"Umschlag\", \"Kunde_1\", \"Kunde_2\"]\n",
|
||||
"# (von, nach, Kosten je Einheit, Kapazitaet)\n",
|
||||
"KANTEN = [\n",
|
||||
" (\"Werk_A\", \"Umschlag\", 2.0, 15),\n",
|
||||
" (\"Werk_A\", \"Kunde_1\", 5.0, 10),\n",
|
||||
" (\"Werk_B\", \"Umschlag\", 4.0, 10),\n",
|
||||
" (\"Werk_B\", \"Kunde_2\", 6.0, 10),\n",
|
||||
" (\"Umschlag\", \"Kunde_1\", 1.0, 20),\n",
|
||||
" (\"Umschlag\", \"Kunde_2\", 3.0, 10),\n",
|
||||
"]\n",
|
||||
"# Angebot (+) bzw. Bedarf (-) je Knoten\n",
|
||||
"SALDO = {\"Werk_A\": 20, \"Werk_B\": 10, \"Umschlag\": 0, \"Kunde_1\": -15, \"Kunde_2\": -15}\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def loese_als_lp():\n",
|
||||
" \"\"\"Flussproblem als allgemeines lineares Programm.\"\"\"\n",
|
||||
" n_kanten = len(KANTEN)\n",
|
||||
" knoten_index = {k: i for i, k in enumerate(KNOTEN)}\n",
|
||||
"\n",
|
||||
" # Zielfunktion: Summe der Transportkosten\n",
|
||||
" kosten = np.array([k[2] for k in KANTEN])\n",
|
||||
"\n",
|
||||
" # Flusserhaltung als Gleichungssystem: A_eq @ x = b_eq\n",
|
||||
" A_eq = np.zeros((len(KNOTEN), n_kanten))\n",
|
||||
" for e, (von, nach, _, _) in enumerate(KANTEN):\n",
|
||||
" A_eq[knoten_index[von], e] = +1.0 # fliesst hinaus\n",
|
||||
" A_eq[knoten_index[nach], e] = -1.0 # fliesst hinein\n",
|
||||
" b_eq = np.array([SALDO[k] for k in KNOTEN], dtype=float)\n",
|
||||
"\n",
|
||||
" schranken = [(0, k[3]) for k in KANTEN] # 0 <= x_ij <= u_ij\n",
|
||||
"\n",
|
||||
" ergebnis = linprog(c=kosten, A_eq=A_eq, b_eq=b_eq, bounds=schranken, method=\"highs\")\n",
|
||||
" if not ergebnis.success:\n",
|
||||
" raise SystemExit(f\"Nicht loesbar: {ergebnis.message}\")\n",
|
||||
" return ergebnis.fun, ergebnis.x, ergebnis.eqlin.marginals\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"if __name__ == \"__main__\":\n",
|
||||
" # Vorabpruefung: Angebot muss Bedarf entsprechen\n",
|
||||
" gesamt = sum(SALDO.values())\n",
|
||||
" print(\"=\" * 78)\n",
|
||||
" print(\" KOSTENMINIMALER FLUSS DURCH EIN TRANSPORTNETZ\")\n",
|
||||
" print(\"=\" * 78)\n",
|
||||
" print(f\"Angebot gesamt: {sum(v for v in SALDO.values() if v > 0)} | \"\n",
|
||||
" f\"Bedarf gesamt: {-sum(v for v in SALDO.values() if v < 0)} | \"\n",
|
||||
" f\"Saldo: {gesamt}\")\n",
|
||||
" if gesamt != 0:\n",
|
||||
" raise SystemExit(\"Angebot und Bedarf stimmen nicht ueberein - unloesbar!\")\n",
|
||||
"\n",
|
||||
" kosten_gesamt, fluss, knotenpreise = loese_als_lp()\n",
|
||||
"\n",
|
||||
" print(f\"\\nMinimale Transportkosten: {kosten_gesamt:,.2f} EUR\\n\")\n",
|
||||
" print(f\"{'Kante':<24} {'Fluss':>7} {'Kapazitaet':>11} {'Kosten/E':>9} {'Kosten':>9}\")\n",
|
||||
" print(\"-\" * 78)\n",
|
||||
" for e, (von, nach, c, u) in enumerate(KANTEN):\n",
|
||||
" menge = fluss[e] + 0.0 if abs(fluss[e]) > 1e-9 else 0.0 # vermeidet \"-0.0\"\n",
|
||||
" ausgelastet = \" (VOLL)\" if abs(menge - u) < 1e-6 else \"\"\n",
|
||||
" print(f\"{von + ' -> ' + nach:<24} {menge:>7.1f} {u:>11} \"\n",
|
||||
" f\"{c:>9.2f} {menge * c:>9.2f}{ausgelastet}\")\n",
|
||||
"\n",
|
||||
" # --- Flusserhaltung nachpruefen --------------------------------------\n",
|
||||
" print(\"\\n--- Pruefung der Flusserhaltung je Knoten ---\")\n",
|
||||
" for k in KNOTEN:\n",
|
||||
" hinaus = sum(fluss[e] for e, (v, n, _, _) in enumerate(KANTEN) if v == k)\n",
|
||||
" hinein = sum(fluss[e] for e, (v, n, _, _) in enumerate(KANTEN) if n == k)\n",
|
||||
" netto = hinaus - hinein\n",
|
||||
" art = \"Quelle\" if SALDO[k] > 0 else (\"Senke\" if SALDO[k] < 0 else \"Umschlag\")\n",
|
||||
" print(f\" {k:<10} ({art:<8}): hinaus {hinaus:5.1f} - hinein {hinein:5.1f} \"\n",
|
||||
" f\"= {netto:+6.1f} (gefordert: {SALDO[k]:+d})\")\n",
|
||||
" assert abs(netto - SALDO[k]) < 1e-6, f\"Flusserhaltung verletzt bei {k}!\"\n",
|
||||
"\n",
|
||||
" # --- Knotenpreise (Dualwerte) interpretieren -------------------------\n",
|
||||
" print(\"\\n--- Knotenpreise (Dualwerte der Flusserhaltung) ---\")\n",
|
||||
" print(\" Differenz zweier Knotenpreise = Grenzkosten einer zusaetzlichen Einheit\")\n",
|
||||
" print(\" auf dem guenstigsten Weg zwischen ihnen.\")\n",
|
||||
" for k, preis in zip(KNOTEN, knotenpreise):\n",
|
||||
" print(f\" {k:<10}: {preis:7.2f}\")\n",
|
||||
" print(\"=\" * 78)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Der Satz von Birkhoff und von Neumann\n",
|
||||
"\n",
|
||||
"`Zuordnung_Ungarisch.py`\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"#!/usr/bin/env python3\n",
|
||||
"\n",
|
||||
"# Zuordnung_Ungarisch.py\n",
|
||||
"\"\"\"\n",
|
||||
"Kapitel Graphen: Das Zuordnungsproblem, dreifach geloest.\n",
|
||||
"\n",
|
||||
" (1) Ungarischer Algorithmus (scipy.optimize.linear_sum_assignment) - O(n^3)\n",
|
||||
" (2) als LP OHNE Ganzzahligkeitsforderung -> liefert trotzdem 0/1 (Birkhoff)\n",
|
||||
" (3) als MILP MIT Ganzzahligkeitsforderung -> gleiches Ergebnis, mehr Aufwand\n",
|
||||
"\n",
|
||||
"Zeigt damit die praktische Bedeutung der totalen Unimodularitaet.\n",
|
||||
"\"\"\"\n",
|
||||
"\n",
|
||||
"import time\n",
|
||||
"\n",
|
||||
"import numpy as np\n",
|
||||
"from scipy.optimize import linear_sum_assignment, linprog\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def erzeuge_kosten(n, seed=11):\n",
|
||||
" rng = np.random.default_rng(seed)\n",
|
||||
" return rng.integers(10, 99, size=(n, n)).astype(float)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def loese_ungarisch(kosten):\n",
|
||||
" zeilen, spalten = linear_sum_assignment(kosten)\n",
|
||||
" return kosten[zeilen, spalten].sum(), spalten\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def baue_lp(kosten):\n",
|
||||
" \"\"\"Gemeinsame LP-Struktur fuer Variante 2 und 3.\"\"\"\n",
|
||||
" n = len(kosten)\n",
|
||||
" c = kosten.flatten() # x_ij in Zeilenreihenfolge\n",
|
||||
" A_eq = np.zeros((2 * n, n * n))\n",
|
||||
" for i in range(n): # jede Person genau eine Aufgabe\n",
|
||||
" A_eq[i, i * n:(i + 1) * n] = 1.0\n",
|
||||
" for j in range(n): # jede Aufgabe genau einer Person\n",
|
||||
" A_eq[n + j, j::n] = 1.0\n",
|
||||
" b_eq = np.ones(2 * n)\n",
|
||||
" return c, A_eq, b_eq\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def loese_lp(kosten, ganzzahlig):\n",
|
||||
" n = len(kosten)\n",
|
||||
" c, A_eq, b_eq = baue_lp(kosten)\n",
|
||||
" ergebnis = linprog(c=c, A_eq=A_eq, b_eq=b_eq, bounds=[(0, 1)] * (n * n),\n",
|
||||
" integrality=np.ones(n * n) if ganzzahlig else None,\n",
|
||||
" method=\"highs\")\n",
|
||||
" x = ergebnis.x.reshape(n, n)\n",
|
||||
" return ergebnis.fun, x\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"if __name__ == \"__main__\":\n",
|
||||
" print(\"=\" * 84)\n",
|
||||
" print(\" ZUORDNUNGSPROBLEM: DREI WEGE ZUM SELBEN ERGEBNIS\")\n",
|
||||
" print(\"=\" * 84)\n",
|
||||
"\n",
|
||||
" # --- Kleines Beispiel zum Nachvollziehen ------------------------------\n",
|
||||
" kosten = np.array([[82., 83., 69., 92.],\n",
|
||||
" [77., 37., 49., 92.],\n",
|
||||
" [11., 69., 5., 86.],\n",
|
||||
" [8., 9., 98., 23.]])\n",
|
||||
" namen = [\"Anna\", \"Ben\", \"Carla\", \"David\"]\n",
|
||||
" aufgaben = [\"Auftrag W\", \"Auftrag X\", \"Auftrag Y\", \"Auftrag Z\"]\n",
|
||||
"\n",
|
||||
" print(\"\\nKostenmatrix (wer bearbeitet was zu welchen Kosten?):\")\n",
|
||||
" print(f\"{'':<8}\" + \"\".join(f\"{a:>12}\" for a in aufgaben))\n",
|
||||
" for i, name in enumerate(namen):\n",
|
||||
" print(f\"{name:<8}\" + \"\".join(f\"{kosten[i, j]:>12.0f}\" for j in range(4)))\n",
|
||||
"\n",
|
||||
" wert, zuordnung = loese_ungarisch(kosten)\n",
|
||||
" print(f\"\\nOptimale Zuordnung (Gesamtkosten {wert:.0f}):\")\n",
|
||||
" for i, j in enumerate(zuordnung):\n",
|
||||
" print(f\" {namen[i]:<8} -> {aufgaben[j]:<12} ({kosten[i, j]:.0f} EUR)\")\n",
|
||||
"\n",
|
||||
" # --- Nachweis: LP ohne Ganzzahligkeit liefert trotzdem 0/1 -----------\n",
|
||||
" wert_lp, x_lp = loese_lp(kosten, ganzzahlig=False)\n",
|
||||
" ist_binaer = np.all((np.abs(x_lp) < 1e-9) | (np.abs(x_lp - 1) < 1e-9))\n",
|
||||
" print(f\"\\nLP OHNE Ganzzahligkeitsforderung: Kosten {wert_lp:.0f}, \"\n",
|
||||
" f\"Loesung ist {'0/1-wertig' if ist_binaer else 'GEBROCHEN'}\")\n",
|
||||
" print(\" -> Satz von Birkhoff/von Neumann bestaetigt: Die Ecken sind Permutationen.\")\n",
|
||||
"\n",
|
||||
" # --- Laufzeitvergleich bei wachsender Groesse ------------------------\n",
|
||||
" print(\"\\n\" + \"-\" * 84)\n",
|
||||
" print(f\"{'n':>4} | {'Ungarisch':>12} | {'LP (kontinuierlich)':>21} | \"\n",
|
||||
" f\"{'MILP (ganzzahlig)':>19} | {'gleich?':>8}\")\n",
|
||||
" print(\"-\" * 84)\n",
|
||||
" for n in [10, 25, 50, 100]:\n",
|
||||
" k = erzeuge_kosten(n)\n",
|
||||
"\n",
|
||||
" t0 = time.perf_counter(); w1, _ = loese_ungarisch(k); t1 = time.perf_counter() - t0\n",
|
||||
" t0 = time.perf_counter(); w2, _ = loese_lp(k, False); t2 = time.perf_counter() - t0\n",
|
||||
" if n <= 50:\n",
|
||||
" t0 = time.perf_counter(); w3, _ = loese_lp(k, True); t3 = time.perf_counter() - t0\n",
|
||||
" t3_text, gleich = f\"{t3*1000:>16.1f} ms\", abs(w1 - w3) < 1e-6\n",
|
||||
" else:\n",
|
||||
" t3_text, gleich = f\"{'uebersprungen':>19}\", abs(w1 - w2) < 1e-6\n",
|
||||
"\n",
|
||||
" print(f\"{n:>4} | {t1*1000:>9.1f} ms | {t2*1000:>18.1f} ms | {t3_text} | \"\n",
|
||||
" f\"{'ja' if gleich else 'NEIN':>8}\")\n",
|
||||
"\n",
|
||||
" print(\"-\" * 84)\n",
|
||||
" print(\"Fazit: Der spezialisierte Ungarische Algorithmus ist um Groessenordnungen\")\n",
|
||||
" print(\"schneller. Nutzen Sie fuer reine Zuordnungen NIE einen MILP-Solver.\")\n",
|
||||
" print(\"=\" * 84)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Praxisbeispiel: Flotten-Routing\n",
|
||||
"\n",
|
||||
"`VRP_Flotten_Routing.py`\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"#!/usr/bin/env python3\n",
|
||||
"\n",
|
||||
"# VRP_Flotten_Routing.py\n",
|
||||
"\"\"\"\n",
|
||||
"Kapitel Graphen: Capacitated Vehicle Routing Problem with Time Windows (CVRPTW)\n",
|
||||
"mit der Routing-Bibliothek von Google OR-Tools.\n",
|
||||
"\n",
|
||||
"Eigenschaften:\n",
|
||||
" * Eingabedaten werden vorab auf Plausibilitaet geprueft (Kapazitaet\n",
|
||||
" ausreichend? Zeitfenster erreichbar?)\n",
|
||||
" * Fahrzeit und Servicezeit werden getrennt ausgewiesen\n",
|
||||
" * Ausgabe als lesbarer Tourenplan mit Ankunftszeiten\n",
|
||||
" * Kennzahlen: Auslastung, Leerfahrten, Wartezeit\n",
|
||||
"\"\"\"\n",
|
||||
"\n",
|
||||
"import numpy as np\n",
|
||||
"from ortools.constraint_solver import pywrapcp, routing_enums_pb2\n",
|
||||
"\n",
|
||||
"SERVICEZEIT = 10 # Minuten je Kundenstopp\n",
|
||||
"WARTEZEIT_MAX = 60 # zulaessige Wartezeit bei zu frueher Ankunft\n",
|
||||
"SCHICHTLAENGE = 600 # Minuten\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def erzeuge_daten(seed: int = 42):\n",
|
||||
" \"\"\"Synthetische, aber reproduzierbare Instanz: 1 Depot + 16 Kunden.\"\"\"\n",
|
||||
" anzahl_orte = 17\n",
|
||||
" rng = np.random.default_rng(seed)\n",
|
||||
" koordinaten = rng.random((anzahl_orte, 2)) * 100 # 100 x 100 km Raster\n",
|
||||
"\n",
|
||||
" distanz = np.zeros((anzahl_orte, anzahl_orte), dtype=int)\n",
|
||||
" for i in range(anzahl_orte):\n",
|
||||
" for j in range(anzahl_orte):\n",
|
||||
" distanz[i][j] = int(np.linalg.norm(koordinaten[i] - koordinaten[j]))\n",
|
||||
"\n",
|
||||
" return {\n",
|
||||
" \"distanzmatrix\": distanz.tolist(),\n",
|
||||
" \"zeitfenster\": [\n",
|
||||
" (0, SCHICHTLAENGE), # 0: Depot\n",
|
||||
" (30, 120), (60, 180), (100, 240), (150, 300), # Kunden 1-4\n",
|
||||
" (60, 180), (120, 240), (200, 360), (300, 450), # Kunden 5-8\n",
|
||||
" (180, 300), (240, 360), (300, 480), (360, 500), # Kunden 9-12\n",
|
||||
" (60, 200), (120, 300), (240, 400), (300, 550), # Kunden 13-16\n",
|
||||
" ],\n",
|
||||
" \"bedarfe\": [0, 2, 3, 1, 4, 2, 2, 3, 1, 2, 4, 3, 2, 1, 2, 3, 2],\n",
|
||||
" \"kapazitaeten\": [10, 10, 10, 10],\n",
|
||||
" \"anzahl_fahrzeuge\": 4,\n",
|
||||
" \"depot\": 0,\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def pruefe_daten(daten) -> None:\n",
|
||||
" \"\"\"Vorabdiagnose - fangt die haeufigsten Ursachen fuer 'keine Loesung' ab.\"\"\"\n",
|
||||
" gesamtbedarf = sum(daten[\"bedarfe\"])\n",
|
||||
" gesamtkapazitaet = sum(daten[\"kapazitaeten\"])\n",
|
||||
" print(f\"Gesamtbedarf {gesamtbedarf} Einheiten | \"\n",
|
||||
" f\"Flottenkapazitaet {gesamtkapazitaet} Einheiten | \"\n",
|
||||
" f\"Auslastung {gesamtbedarf / gesamtkapazitaet * 100:.0f} %\")\n",
|
||||
" if gesamtbedarf > gesamtkapazitaet:\n",
|
||||
" raise SystemExit(\"UNLOESBAR: Der Bedarf uebersteigt die Flottenkapazitaet.\")\n",
|
||||
"\n",
|
||||
" d = daten[\"distanzmatrix\"]\n",
|
||||
" for kunde, (fruehestens, spaetestens) in enumerate(daten[\"zeitfenster\"]):\n",
|
||||
" if kunde == 0:\n",
|
||||
" continue\n",
|
||||
" direktfahrt = d[0][kunde]\n",
|
||||
" if direktfahrt > spaetestens:\n",
|
||||
" raise SystemExit(\n",
|
||||
" f\"UNLOESBAR: Kunde {kunde} ist erst nach {direktfahrt} min erreichbar, \"\n",
|
||||
" f\"sein Zeitfenster endet aber bei {spaetestens} min.\")\n",
|
||||
" print(\"Vorabpruefung bestanden: Kapazitaet und Zeitfenster sind grundsaetzlich machbar.\")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def loese_cvrptw(zeitlimit_s: int = 5):\n",
|
||||
" daten = erzeuge_daten()\n",
|
||||
" pruefe_daten(daten)\n",
|
||||
"\n",
|
||||
" manager = pywrapcp.RoutingIndexManager(\n",
|
||||
" len(daten[\"distanzmatrix\"]), daten[\"anzahl_fahrzeuge\"], daten[\"depot\"])\n",
|
||||
" routing = pywrapcp.RoutingModel(manager)\n",
|
||||
"\n",
|
||||
" # --- Fahrzeit + Servicezeit als Kantengewicht ------------------------\n",
|
||||
" def zeit_callback(von_index, nach_index):\n",
|
||||
" von = manager.IndexToNode(von_index)\n",
|
||||
" nach = manager.IndexToNode(nach_index)\n",
|
||||
" service = SERVICEZEIT if von != daten[\"depot\"] else 0\n",
|
||||
" return daten[\"distanzmatrix\"][von][nach] + service\n",
|
||||
"\n",
|
||||
" zeit_index = routing.RegisterTransitCallback(zeit_callback)\n",
|
||||
" routing.SetArcCostEvaluatorOfAllVehicles(zeit_index)\n",
|
||||
"\n",
|
||||
" # --- Kapazitaetsdimension ---------------------------------------------\n",
|
||||
" def bedarf_callback(von_index):\n",
|
||||
" return daten[\"bedarfe\"][manager.IndexToNode(von_index)]\n",
|
||||
"\n",
|
||||
" bedarf_index = routing.RegisterUnaryTransitCallback(bedarf_callback)\n",
|
||||
" routing.AddDimensionWithVehicleCapacity(\n",
|
||||
" bedarf_index, 0, daten[\"kapazitaeten\"], True, \"Kapazitaet\")\n",
|
||||
"\n",
|
||||
" # --- Zeitdimension mit Zeitfenstern -----------------------------------\n",
|
||||
" routing.AddDimension(zeit_index, WARTEZEIT_MAX, SCHICHTLAENGE, False, \"Zeit\")\n",
|
||||
" zeit_dimension = routing.GetDimensionOrDie(\"Zeit\")\n",
|
||||
" for ort, (fruehestens, spaetestens) in enumerate(daten[\"zeitfenster\"]):\n",
|
||||
" zeit_dimension.CumulVar(manager.NodeToIndex(ort)).SetRange(fruehestens, spaetestens)\n",
|
||||
"\n",
|
||||
" # --- Suchparameter -----------------------------------------------------\n",
|
||||
" parameter = pywrapcp.DefaultRoutingSearchParameters()\n",
|
||||
" parameter.first_solution_strategy = (\n",
|
||||
" routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC)\n",
|
||||
" parameter.local_search_metaheuristic = (\n",
|
||||
" routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH)\n",
|
||||
" parameter.time_limit.seconds = zeitlimit_s\n",
|
||||
"\n",
|
||||
" loesung = routing.SolveWithParameters(parameter)\n",
|
||||
" if not loesung:\n",
|
||||
" print(\"Keine zulaessige Routenfuehrung gefunden.\")\n",
|
||||
" return\n",
|
||||
"\n",
|
||||
" # --- Auswertung --------------------------------------------------------\n",
|
||||
" print(\"\\n\" + \"=\" * 84)\n",
|
||||
" print(\" OPTIMIERTER TOURENPLAN (CVRPTW)\")\n",
|
||||
" print(\"=\" * 84)\n",
|
||||
"\n",
|
||||
" gesamtzeit = gesamtfracht = gesamtdistanz = 0\n",
|
||||
" kapazitaet = daten[\"kapazitaeten\"]\n",
|
||||
"\n",
|
||||
" for fahrzeug in range(daten[\"anzahl_fahrzeuge\"]):\n",
|
||||
" index = routing.Start(fahrzeug)\n",
|
||||
" if routing.IsEnd(loesung.Value(routing.NextVar(index))):\n",
|
||||
" print(f\"\\nFahrzeug {fahrzeug + 1}: nicht eingesetzt\")\n",
|
||||
" continue\n",
|
||||
"\n",
|
||||
" stationen, fracht, distanz = [], 0, 0\n",
|
||||
" while not routing.IsEnd(index):\n",
|
||||
" knoten = manager.IndexToNode(index)\n",
|
||||
" ankunft = loesung.Min(zeit_dimension.CumulVar(index))\n",
|
||||
" fracht += daten[\"bedarfe\"][knoten]\n",
|
||||
" bezeichnung = \"Depot\" if knoten == 0 else f\"K{knoten}\"\n",
|
||||
" stationen.append(f\"{bezeichnung}@{ankunft}\")\n",
|
||||
" naechster = loesung.Value(routing.NextVar(index))\n",
|
||||
" distanz += daten[\"distanzmatrix\"][knoten][manager.IndexToNode(naechster)]\n",
|
||||
" index = naechster\n",
|
||||
"\n",
|
||||
" endzeit = loesung.Min(zeit_dimension.CumulVar(index))\n",
|
||||
" stationen.append(f\"Depot@{endzeit}\")\n",
|
||||
" gesamtzeit += endzeit\n",
|
||||
" gesamtfracht += fracht\n",
|
||||
" gesamtdistanz += distanz\n",
|
||||
"\n",
|
||||
" print(f\"\\nFahrzeug {fahrzeug + 1}:\")\n",
|
||||
" print(\" \" + \" -> \".join(stationen))\n",
|
||||
" print(f\" Schichtzeit {endzeit} min | Fahrstrecke {distanz} km | \"\n",
|
||||
" f\"Fracht {fracht}/{kapazitaet[fahrzeug]} \"\n",
|
||||
" f\"({fracht / kapazitaet[fahrzeug] * 100:.0f} % Auslastung)\")\n",
|
||||
"\n",
|
||||
" print(\"\\n\" + \"-\" * 84)\n",
|
||||
" print(f\"Summe Schichtzeiten: {gesamtzeit} min\")\n",
|
||||
" print(f\"Summe Fahrstrecken: {gesamtdistanz} km\")\n",
|
||||
" print(f\"Transportierte Fracht: {gesamtfracht} von {sum(daten['bedarfe'])} Einheiten\")\n",
|
||||
" assert gesamtfracht == sum(daten[\"bedarfe\"]), \"Nicht alle Kunden wurden beliefert!\"\n",
|
||||
" print(\"Alle Kunden wurden innerhalb ihrer Zeitfenster beliefert.\")\n",
|
||||
" print(\"=\" * 84)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"if __name__ == \"__main__\":\n",
|
||||
" loese_cvrptw()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Finde den Denkfehler\n",
|
||||
"\n",
|
||||
"`VRP_Kapazitaetsfalle.py`\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"#!/usr/bin/env python3\n",
|
||||
"\n",
|
||||
"# VRP_Kapazitaetsfalle.py\n",
|
||||
"\"\"\"\n",
|
||||
"Kapitel Graphen: Die vergessene Dimension.\n",
|
||||
"\n",
|
||||
"Die Routing-Bibliothek von OR-Tools kennt keine \"Kapazitaet\" von sich aus.\n",
|
||||
"Sie kennt nur DIMENSIONEN - benannte Groessen, die sich entlang einer Tour\n",
|
||||
"aufsummieren und begrenzt werden koennen. Distanz ist eine, Zeit ist eine,\n",
|
||||
"Ladung ist eine. Wer eine davon nicht anlegt, bekommt trotzdem eine Loesung:\n",
|
||||
"eine schoene, kurze, guenstige - und unfahrbare.\n",
|
||||
"\n",
|
||||
"Dieses Programm loest dieselbe Instanz zweimal und prueft beide Ergebnisse\n",
|
||||
"gegen die tatsaechlichen Lademengen.\n",
|
||||
"\n",
|
||||
"Instanz: 1 Depot, 16 Kunden, 4 Fahrzeuge zu je 10 Paletten.\n",
|
||||
"Gesamtbedarf 37 Paletten bei 40 Paletten Flottenkapazitaet - es ist also\n",
|
||||
"knapp, aber machbar.\n",
|
||||
"\n",
|
||||
"Benoetigt: numpy, ortools\n",
|
||||
"\"\"\"\n",
|
||||
"\n",
|
||||
"from __future__ import annotations\n",
|
||||
"\n",
|
||||
"import numpy as np\n",
|
||||
"from ortools.constraint_solver import pywrapcp, routing_enums_pb2\n",
|
||||
"\n",
|
||||
"# Dieselbe Instanz wie VRP_Flotten_Routing.py\n",
|
||||
"BEDARFE = [0, 2, 3, 1, 4, 2, 2, 3, 1, 2, 4, 3, 2, 1, 2, 3, 2]\n",
|
||||
"KAPAZITAETEN = [10, 10, 10, 10]\n",
|
||||
"ANZAHL_FAHRZEUGE = 4\n",
|
||||
"DEPOT = 0\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def distanzmatrix(seed: int = 42) -> list[list[int]]:\n",
|
||||
" rng = np.random.default_rng(seed)\n",
|
||||
" koordinaten = rng.random((len(BEDARFE), 2)) * 100 # 100 x 100 km\n",
|
||||
" n = len(BEDARFE)\n",
|
||||
" return [[int(np.linalg.norm(koordinaten[i] - koordinaten[j]))\n",
|
||||
" for j in range(n)] for i in range(n)]\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def plane(mit_kapazitaet: bool, zeitlimit: int = 5) -> dict:\n",
|
||||
" \"\"\"Loest die Tourenplanung - wahlweise mit oder ohne Ladungsdimension.\"\"\"\n",
|
||||
" distanz = distanzmatrix()\n",
|
||||
" manager = pywrapcp.RoutingIndexManager(len(distanz), ANZAHL_FAHRZEUGE, DEPOT)\n",
|
||||
" routing = pywrapcp.RoutingModel(manager)\n",
|
||||
"\n",
|
||||
" def entfernung(von_index, nach_index):\n",
|
||||
" return distanz[manager.IndexToNode(von_index)][manager.IndexToNode(nach_index)]\n",
|
||||
"\n",
|
||||
" kosten_id = routing.RegisterTransitCallback(entfernung)\n",
|
||||
" routing.SetArcCostEvaluatorOfAllVehicles(kosten_id)\n",
|
||||
"\n",
|
||||
" # DIE entscheidende Stelle. Ohne diesen Block existiert im Modell keine\n",
|
||||
" # Ladung - die Fahrzeuge sind dann unendlich gross.\n",
|
||||
" if mit_kapazitaet:\n",
|
||||
" def bedarf(index):\n",
|
||||
" return BEDARFE[manager.IndexToNode(index)]\n",
|
||||
"\n",
|
||||
" bedarf_id = routing.RegisterUnaryTransitCallback(bedarf)\n",
|
||||
" routing.AddDimensionWithVehicleCapacity(\n",
|
||||
" bedarf_id,\n",
|
||||
" 0, # kein Zwischenpuffer\n",
|
||||
" KAPAZITAETEN, # Obergrenze je Fahrzeug\n",
|
||||
" True, # Ladung startet bei 0\n",
|
||||
" \"Ladung\")\n",
|
||||
"\n",
|
||||
" parameter = pywrapcp.DefaultRoutingSearchParameters()\n",
|
||||
" parameter.first_solution_strategy = (\n",
|
||||
" routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC)\n",
|
||||
" parameter.local_search_metaheuristic = (\n",
|
||||
" routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH)\n",
|
||||
" parameter.time_limit.FromSeconds(zeitlimit)\n",
|
||||
"\n",
|
||||
" loesung = routing.SolveWithParameters(parameter)\n",
|
||||
" if loesung is None:\n",
|
||||
" raise RuntimeError(\"Keine Loesung gefunden\")\n",
|
||||
"\n",
|
||||
" touren, strecken, ladungen = [], [], []\n",
|
||||
" for fahrzeug in range(ANZAHL_FAHRZEUGE):\n",
|
||||
" index = routing.Start(fahrzeug)\n",
|
||||
" tour, strecke, ladung = [], 0, 0\n",
|
||||
" while not routing.IsEnd(index):\n",
|
||||
" knoten = manager.IndexToNode(index)\n",
|
||||
" tour.append(knoten)\n",
|
||||
" ladung += BEDARFE[knoten]\n",
|
||||
" vorher = index\n",
|
||||
" index = loesung.Value(routing.NextVar(index))\n",
|
||||
" strecke += routing.GetArcCostForVehicle(vorher, index, fahrzeug)\n",
|
||||
" tour.append(manager.IndexToNode(index))\n",
|
||||
" touren.append(tour)\n",
|
||||
" strecken.append(strecke)\n",
|
||||
" ladungen.append(ladung)\n",
|
||||
"\n",
|
||||
" return {\"touren\": touren, \"strecken\": strecken, \"ladungen\": ladungen,\n",
|
||||
" \"gesamtstrecke\": sum(strecken)}\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def pruefe(ergebnis: dict) -> list[str]:\n",
|
||||
" \"\"\"Prueft den Plan gegen die Wirklichkeit - unabhaengig vom Modell.\n",
|
||||
"\n",
|
||||
" Genau diese Trennung ist der Punkt: Die Pruefung darf nicht dieselben\n",
|
||||
" Annahmen benutzen wie das Modell, sonst prueft sie nichts.\n",
|
||||
" \"\"\"\n",
|
||||
" beanstandungen = []\n",
|
||||
" for fahrzeug, (ladung, kapazitaet) in enumerate(\n",
|
||||
" zip(ergebnis[\"ladungen\"], KAPAZITAETEN)):\n",
|
||||
" if ladung > kapazitaet:\n",
|
||||
" beanstandungen.append(\n",
|
||||
" f\"Fahrzeug {fahrzeug + 1}: {ladung} Paletten geladen, \"\n",
|
||||
" f\"Kapazitaet {kapazitaet} ({ladung - kapazitaet} zu viel)\")\n",
|
||||
"\n",
|
||||
" beliefert = sorted(k for tour in ergebnis[\"touren\"] for k in tour[1:-1])\n",
|
||||
" erwartet = list(range(1, len(BEDARFE)))\n",
|
||||
" if beliefert != erwartet:\n",
|
||||
" fehlend = set(erwartet) - set(beliefert)\n",
|
||||
" if fehlend:\n",
|
||||
" beanstandungen.append(f\"nicht beliefert: {sorted(fehlend)}\")\n",
|
||||
" return beanstandungen\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def zeige(titel: str, ergebnis: dict) -> None:\n",
|
||||
" print(f\"\\n{titel}\")\n",
|
||||
" print(f\" Gesamtstrecke {ergebnis['gesamtstrecke']} km\")\n",
|
||||
" print(f\" {'Fahrzeug':<10} {'Stopps':>7} {'Strecke':>9} {'Ladung':>8} \"\n",
|
||||
" f\"{'Kapazitaet':>11}\")\n",
|
||||
" for i, (tour, strecke, ladung) in enumerate(\n",
|
||||
" zip(ergebnis[\"touren\"], ergebnis[\"strecken\"], ergebnis[\"ladungen\"])):\n",
|
||||
" markierung = \" <-- ueberladen\" if ladung > KAPAZITAETEN[i] else \"\"\n",
|
||||
" print(f\" {i + 1:<10} {len(tour) - 2:>7} {strecke:>8} km {ladung:>8} \"\n",
|
||||
" f\"{KAPAZITAETEN[i]:>11}{markierung}\")\n",
|
||||
"\n",
|
||||
" beanstandungen = pruefe(ergebnis)\n",
|
||||
" if beanstandungen:\n",
|
||||
" print(\" PRUEFUNG: DURCHGEFALLEN\")\n",
|
||||
" for text in beanstandungen:\n",
|
||||
" print(f\" - {text}\")\n",
|
||||
" else:\n",
|
||||
" print(\" PRUEFUNG: bestanden\")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"if __name__ == \"__main__\":\n",
|
||||
" print(\"=\" * 78)\n",
|
||||
" print(\" DIE VERGESSENE DIMENSION\")\n",
|
||||
" print(\"=\" * 78)\n",
|
||||
" print(f\"16 Kunden, Gesamtbedarf {sum(BEDARFE)} Paletten, \"\n",
|
||||
" f\"{ANZAHL_FAHRZEUGE} Fahrzeuge zu je {KAPAZITAETEN[0]} \"\n",
|
||||
" f\"= {sum(KAPAZITAETEN)} Paletten Flottenkapazitaet.\")\n",
|
||||
"\n",
|
||||
" ohne = plane(mit_kapazitaet=False)\n",
|
||||
" zeige(\"[1] Ohne Ladungsdimension\", ohne)\n",
|
||||
"\n",
|
||||
" mit = plane(mit_kapazitaet=True)\n",
|
||||
" zeige(\"[2] Mit AddDimensionWithVehicleCapacity\", mit)\n",
|
||||
"\n",
|
||||
" print(\"\\n\" + \"=\" * 78)\n",
|
||||
" mehr = mit[\"gesamtstrecke\"] - ohne[\"gesamtstrecke\"]\n",
|
||||
" print(f\"Der korrekte Plan ist {mehr} km laenger \"\n",
|
||||
" f\"({mehr / ohne['gesamtstrecke'] * 100:.1f} %).\")\n",
|
||||
" print()\n",
|
||||
" print(\"Und genau darin liegt die Gefahr: Lauf [1] sieht BESSER aus. Wer\")\n",
|
||||
" print(\"beide Zahlen nebeneinander legt, ohne die Ladung zu pruefen, haelt\")\n",
|
||||
" print(\"die unfahrbare Loesung fuer die bessere Optimierung - und den\")\n",
|
||||
" print(\"korrekten Plan fuer schlechte Arbeit.\")\n",
|
||||
" print()\n",
|
||||
" print(\"Die Routing-Bibliothek kennt keine 'Kapazitaet'. Sie kennt nur\")\n",
|
||||
" print(\"Dimensionen, die man ihr anlegt. Was nicht als Dimension existiert,\")\n",
|
||||
" print(\"wird nicht begrenzt - und faellt niemandem auf, weil das Ergebnis\")\n",
|
||||
" print(\"plausibel aussieht.\")\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