operations_research/OR_HTML_04/Notebooks_04/graphen.ipynb

644 lines
29 KiB
Text
Raw Permalink Normal View History

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>
2026-09-08 01:20:09 +02:00
{
"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{idx:Satz von Birkhoff und von Neumann}\n",
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>
2026-09-08 01:20:09 +02:00
"\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
}