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