#!/usr/bin/env python3 # VRP_Kapazitaetsfalle.py """ Kapitel Graphen: Die vergessene Dimension. Die Routing-Bibliothek von OR-Tools kennt keine "Kapazitaet" von sich aus. Sie kennt nur DIMENSIONEN - benannte Groessen, die sich entlang einer Tour aufsummieren und begrenzt werden koennen. Distanz ist eine, Zeit ist eine, Ladung ist eine. Wer eine davon nicht anlegt, bekommt trotzdem eine Loesung: eine schoene, kurze, guenstige - und unfahrbare. Dieses Programm loest dieselbe Instanz zweimal und prueft beide Ergebnisse gegen die tatsaechlichen Lademengen. Instanz: 1 Depot, 16 Kunden, 4 Fahrzeuge zu je 10 Paletten. Gesamtbedarf 37 Paletten bei 40 Paletten Flottenkapazitaet - es ist also knapp, aber machbar. Benoetigt: numpy, ortools """ from __future__ import annotations import numpy as np from ortools.constraint_solver import pywrapcp, routing_enums_pb2 # Dieselbe Instanz wie VRP_Flotten_Routing.py BEDARFE = [0, 2, 3, 1, 4, 2, 2, 3, 1, 2, 4, 3, 2, 1, 2, 3, 2] KAPAZITAETEN = [10, 10, 10, 10] ANZAHL_FAHRZEUGE = 4 DEPOT = 0 def distanzmatrix(seed: int = 42) -> list[list[int]]: rng = np.random.default_rng(seed) koordinaten = rng.random((len(BEDARFE), 2)) * 100 # 100 x 100 km n = len(BEDARFE) return [[int(np.linalg.norm(koordinaten[i] - koordinaten[j])) for j in range(n)] for i in range(n)] def plane(mit_kapazitaet: bool, zeitlimit: int = 5) -> dict: """Loest die Tourenplanung - wahlweise mit oder ohne Ladungsdimension.""" distanz = distanzmatrix() manager = pywrapcp.RoutingIndexManager(len(distanz), ANZAHL_FAHRZEUGE, DEPOT) routing = pywrapcp.RoutingModel(manager) def entfernung(von_index, nach_index): return distanz[manager.IndexToNode(von_index)][manager.IndexToNode(nach_index)] kosten_id = routing.RegisterTransitCallback(entfernung) routing.SetArcCostEvaluatorOfAllVehicles(kosten_id) # DIE entscheidende Stelle. Ohne diesen Block existiert im Modell keine # Ladung - die Fahrzeuge sind dann unendlich gross. if mit_kapazitaet: def bedarf(index): return BEDARFE[manager.IndexToNode(index)] bedarf_id = routing.RegisterUnaryTransitCallback(bedarf) routing.AddDimensionWithVehicleCapacity( bedarf_id, 0, # kein Zwischenpuffer KAPAZITAETEN, # Obergrenze je Fahrzeug True, # Ladung startet bei 0 "Ladung") parameter = pywrapcp.DefaultRoutingSearchParameters() parameter.first_solution_strategy = ( routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC) parameter.local_search_metaheuristic = ( routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH) parameter.time_limit.FromSeconds(zeitlimit) loesung = routing.SolveWithParameters(parameter) if loesung is None: raise RuntimeError("Keine Loesung gefunden") touren, strecken, ladungen = [], [], [] for fahrzeug in range(ANZAHL_FAHRZEUGE): index = routing.Start(fahrzeug) tour, strecke, ladung = [], 0, 0 while not routing.IsEnd(index): knoten = manager.IndexToNode(index) tour.append(knoten) ladung += BEDARFE[knoten] vorher = index index = loesung.Value(routing.NextVar(index)) strecke += routing.GetArcCostForVehicle(vorher, index, fahrzeug) tour.append(manager.IndexToNode(index)) touren.append(tour) strecken.append(strecke) ladungen.append(ladung) return {"touren": touren, "strecken": strecken, "ladungen": ladungen, "gesamtstrecke": sum(strecken)} def pruefe(ergebnis: dict) -> list[str]: """Prueft den Plan gegen die Wirklichkeit - unabhaengig vom Modell. Genau diese Trennung ist der Punkt: Die Pruefung darf nicht dieselben Annahmen benutzen wie das Modell, sonst prueft sie nichts. """ beanstandungen = [] for fahrzeug, (ladung, kapazitaet) in enumerate( zip(ergebnis["ladungen"], KAPAZITAETEN)): if ladung > kapazitaet: beanstandungen.append( f"Fahrzeug {fahrzeug + 1}: {ladung} Paletten geladen, " f"Kapazitaet {kapazitaet} ({ladung - kapazitaet} zu viel)") beliefert = sorted(k for tour in ergebnis["touren"] for k in tour[1:-1]) erwartet = list(range(1, len(BEDARFE))) if beliefert != erwartet: fehlend = set(erwartet) - set(beliefert) if fehlend: beanstandungen.append(f"nicht beliefert: {sorted(fehlend)}") return beanstandungen def zeige(titel: str, ergebnis: dict) -> None: print(f"\n{titel}") print(f" Gesamtstrecke {ergebnis['gesamtstrecke']} km") print(f" {'Fahrzeug':<10} {'Stopps':>7} {'Strecke':>9} {'Ladung':>8} " f"{'Kapazitaet':>11}") for i, (tour, strecke, ladung) in enumerate( zip(ergebnis["touren"], ergebnis["strecken"], ergebnis["ladungen"])): markierung = " <-- ueberladen" if ladung > KAPAZITAETEN[i] else "" print(f" {i + 1:<10} {len(tour) - 2:>7} {strecke:>8} km {ladung:>8} " f"{KAPAZITAETEN[i]:>11}{markierung}") beanstandungen = pruefe(ergebnis) if beanstandungen: print(" PRUEFUNG: DURCHGEFALLEN") for text in beanstandungen: print(f" - {text}") else: print(" PRUEFUNG: bestanden") if __name__ == "__main__": print("=" * 78) print(" DIE VERGESSENE DIMENSION") print("=" * 78) print(f"16 Kunden, Gesamtbedarf {sum(BEDARFE)} Paletten, " f"{ANZAHL_FAHRZEUGE} Fahrzeuge zu je {KAPAZITAETEN[0]} " f"= {sum(KAPAZITAETEN)} Paletten Flottenkapazitaet.") ohne = plane(mit_kapazitaet=False) zeige("[1] Ohne Ladungsdimension", ohne) mit = plane(mit_kapazitaet=True) zeige("[2] Mit AddDimensionWithVehicleCapacity", mit) print("\n" + "=" * 78) mehr = mit["gesamtstrecke"] - ohne["gesamtstrecke"] print(f"Der korrekte Plan ist {mehr} km laenger " f"({mehr / ohne['gesamtstrecke'] * 100:.1f} %).") print() print("Und genau darin liegt die Gefahr: Lauf [1] sieht BESSER aus. Wer") print("beide Zahlen nebeneinander legt, ohne die Ladung zu pruefen, haelt") print("die unfahrbare Loesung fuer die bessere Optimierung - und den") print("korrekten Plan fuer schlechte Arbeit.") print() print("Die Routing-Bibliothek kennt keine 'Kapazitaet'. Sie kennt nur") print("Dimensionen, die man ihr anlegt. Was nicht als Dimension existiert,") print("wird nicht begrenzt - und faellt niemandem auf, weil das Ergebnis") print("plausibel aussieht.") print("=" * 78)