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