operations_research/OR_HTML_04/programme/VRP_Kapazitaetsfalle.py

171 lines
6.5 KiB
Python
Raw Permalink Normal View History

Version 04 als eigenes Repository 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>
2026-09-08 01:20:09 +02:00
#!/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)