operations_research/OR_HTML_04/Notebooks_04/graphen.ipynb
dschlueter 99cb85907c Stichwortregister: automatische Markierung aller Glossarbegriffe (988 statt 456 Marker)
Neues Skript erzeuge_stichwortregister_04.py:
- Sucht alle Glossarbegriffe (Anzeigename) in den Kapiteldateien
  und markiert die erste Fundstelle je Datei mit {idx:Indexmarke}
- Termliste aus glossar_eintraege_04.py + vorhandenen {idx:...}-Markern
- Idempotent: erkennt vorhandene Marker und überspringt sie
- Überspringt Codeblöcke, Inline-Code und Math
- --check und --bericht Modi
- 605 Marker eingefügt (2 Durchläufe: 597 + 8)

Zusätzlich:
- titeltexte_04.py _saeubern(): entfernt {idx:...}-Marker vor
  Titeltext-Vergleich (pruefe_titeltexte schlug sonst fehl)
2026-09-10 00:07:01 +02:00

644 lines
29 KiB
Text
Generated

{
"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",
"\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
}