171 lines
6.5 KiB
Python
171 lines
6.5 KiB
Python
|
|
#!/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)
|