operations_research/OR_HTML_04/programme/VRP_Kapazitaetsfalle.py
dschlueter b7af2f1d9a 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

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)