Drei Aufraeumarbeiten - und zwei Funde, die dabei auffielen.
1. DIE PNG-ZWEITFASSUNGEN SIND WEG. Jedes Diagramm lag doppelt vor, als SVG
und als PNG, und kein einziges src=/href= in der Website zeigte je auf ein
PNG. Der Build kopierte sie trotzdem mit: 3,3 MB im Repository plus 3,3 MB,
die bei jeder Veroeffentlichung auf den Webserver gingen. Die 15 Generatoren
schreiben jetzt nur noch SVG, die Docstrings sind mitgezogen. Vor dem
Loeschen geprueft: Jedes PNG hatte sein gleichnamiges SVG, alle 33 waren
versioniert.
FUND 1: erzeuge_kap06_gantt.py folgte als einziger Generator nicht der
Konvention - weder svg.hashsalt noch metadata={"Date": None}. Sein SVG trug
einen echten Zeitstempel und bei jedem Lauf andere clip-path-IDs, war also
nie byteidentisch reproduzierbar, obwohl CLAUDE.md genau das fuer alle
Generatoren festhaelt. Aufgefallen nur, weil nach der PNG-Umstellung 32 von
33 SVGs bitgleich blieben und eines nicht. Jetzt byteidentisch ueber zwei
Laeufe.
FUND 2: spiegle_bilder() legte leere Verzeichnisse auf dem Webserver an. Der
Dateifilter arbeitete korrekt, aber os.walk durchlief auch __pycache__/, und
os.makedirs() erzeugte es am Ziel. Die Verzeichnisliste wird jetzt vorher
gefiltert. Gegengetestet.
2. BAU-UEBERBLEIBSEL entfernt (alle ignoriert und neu erzeugbar):
svg-inkscape/, build_v04.log, .pytest_cache/, Programme/output/, vier
__pycache__/ und die Excel-Mappen. Arbeitsbaum 36 -> 33 MB, danach null
ignorierte Ueberbleibsel.
3. Excel_Bruecke.py SCHREIBT NEBEN DAS SKRIPT statt ins
Arbeitsverzeichnis. Es benutzte blanke relative Namen; wer es aus der
Repository-Wurzel startete, verstreute dort produktionsmix.xlsx und
produktionsmix_ergebnis.xlsx. Jetzt wie die vier anderen schreibenden
Programme ueber os.path.dirname(os.path.abspath(__file__)). Nachgemessen:
Lauf aus der Wurzel legt dort null Dateien ab.
Ausserdem git gc: 653 lose Objekte gepackt, .git von 71 MB auf 28 MB -
reines Repacken, kein Inhalt beruehrt.
Geprueft: alle 33 im Buch referenzierten SVGs vorhanden, in Quelle und
Website; 16 Generatoren fehlerfrei; keine fehlenden Bilder im LaTeX-Lauf;
33 pytest-Tests; PDF unveraendert 760 Seiten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
7.4 KiB
Python
191 lines
7.4 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# erzeuge_vrp_touren.py
|
|
"""
|
|
Erzeugt die Tourenkarte zum Graphen-Kapitel:
|
|
|
|
bilder_04/kap_graphen_vrp_touren.svg
|
|
|
|
Das Vorgaengerbild war ein Schema: zwei Touren, vier Kunden, Zeitfenster in
|
|
Uhrzeiten. Die Instanz des Kapitels hat 16 Kunden, vier Fahrzeuge und
|
|
Zeitfenster in Minuten - das Bild zeigte also die Bauform, nicht den Fall.
|
|
|
|
Hier wird die Instanz aus VRP_Flotten_Routing.py geloest und die Loesung als
|
|
Karte gezeichnet: Depot, Kunden (Punktgroesse = Bedarf), die vier Touren in
|
|
vier Farben, und in der Legende je Fahrzeug Strecke und Auslastung.
|
|
|
|
ZUR REPRODUZIERBARKEIT: Das Buchprogramm gibt der Suche ein ZEITBUDGET von
|
|
fuenf Sekunden. Ein Zeitbudget ist auf einer anderen Maschine ein anderes
|
|
Rechenbudget, das Ergebnis also nicht garantiert identisch. Fuer das Bild wird
|
|
deshalb zusaetzlich eine feste Loesungszahl als Abbruchkriterium gesetzt und
|
|
die Gesamtstrecke ausgegeben - weicht sie vom abgedruckten Lauf ab, faellt es
|
|
sofort auf, statt still ins Buch zu wandern.
|
|
|
|
Aufruf (aus dem Repository-Wurzelverzeichnis):
|
|
python3 bilder_04/erzeuge_vrp_touren.py
|
|
|
|
Benoetigt: numpy, matplotlib, ortools
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import matplotlib
|
|
matplotlib.use("Agg")
|
|
import matplotlib.pyplot as plt
|
|
import numpy as np
|
|
from ortools.constraint_solver import pywrapcp, routing_enums_pb2
|
|
|
|
# Reproduzierbare SVG-Ausgabe (siehe erzeuge_titelseite.py)
|
|
plt.rcParams["svg.hashsalt"] = "or-mit-python-v04"
|
|
|
|
BASIS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
BILDER = os.path.join(BASIS, "bilder_04")
|
|
|
|
TOURFARBEN = ["#4338ca", "#0891b2", "#b45309", "#059669"]
|
|
|
|
# --- Instanz: wortgleich zu VRP_Flotten_Routing.py --------------------------
|
|
SERVICEZEIT = 10
|
|
WARTEZEIT_MAX = 60
|
|
SCHICHTLAENGE = 600
|
|
SAAT = 42
|
|
ZEITBUDGET_S = 5
|
|
LOESUNGSGRENZE = 300 # zusaetzliches, maschinenunabhaengiges Kriterium
|
|
|
|
ZEITFENSTER = [
|
|
(0, SCHICHTLAENGE),
|
|
(30, 120), (60, 180), (100, 240), (150, 300),
|
|
(60, 180), (120, 240), (200, 360), (300, 450),
|
|
(180, 300), (240, 360), (300, 480), (360, 500),
|
|
(60, 200), (120, 300), (240, 400), (300, 550),
|
|
]
|
|
BEDARFE = [0, 2, 3, 1, 4, 2, 2, 3, 1, 2, 4, 3, 2, 1, 2, 3, 2]
|
|
KAPAZITAETEN = [10, 10, 10, 10]
|
|
|
|
|
|
def erzeuge_daten():
|
|
anzahl_orte = len(ZEITFENSTER)
|
|
rng = np.random.default_rng(SAAT)
|
|
koordinaten = rng.random((anzahl_orte, 2)) * 100
|
|
distanz = np.zeros((anzahl_orte, anzahl_orte), dtype=int)
|
|
for i in range(anzahl_orte):
|
|
for j in range(anzahl_orte):
|
|
distanz[i][j] = int(np.linalg.norm(koordinaten[i] - koordinaten[j]))
|
|
return koordinaten, distanz
|
|
|
|
|
|
def loese(distanz):
|
|
"""CVRPTW mit denselben Einstellungen wie im Buchprogramm."""
|
|
manager = pywrapcp.RoutingIndexManager(len(distanz), len(KAPAZITAETEN), 0)
|
|
routing = pywrapcp.RoutingModel(manager)
|
|
|
|
def zeit_callback(von_index, nach_index):
|
|
von = manager.IndexToNode(von_index)
|
|
nach = manager.IndexToNode(nach_index)
|
|
service = SERVICEZEIT if von != 0 else 0
|
|
return int(distanz[von][nach]) + service
|
|
|
|
zeit_index = routing.RegisterTransitCallback(zeit_callback)
|
|
routing.SetArcCostEvaluatorOfAllVehicles(zeit_index)
|
|
|
|
def bedarf_callback(von_index):
|
|
return BEDARFE[manager.IndexToNode(von_index)]
|
|
|
|
bedarf_index = routing.RegisterUnaryTransitCallback(bedarf_callback)
|
|
routing.AddDimensionWithVehicleCapacity(
|
|
bedarf_index, 0, KAPAZITAETEN, True, "Kapazitaet")
|
|
|
|
routing.AddDimension(zeit_index, WARTEZEIT_MAX, SCHICHTLAENGE, False, "Zeit")
|
|
zeit_dimension = routing.GetDimensionOrDie("Zeit")
|
|
for ort, (fruehestens, spaetestens) in enumerate(ZEITFENSTER):
|
|
zeit_dimension.CumulVar(manager.NodeToIndex(ort)).SetRange(
|
|
fruehestens, spaetestens)
|
|
|
|
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.seconds = ZEITBUDGET_S
|
|
parameter.solution_limit = LOESUNGSGRENZE
|
|
|
|
loesung = routing.SolveWithParameters(parameter)
|
|
if not loesung:
|
|
raise SystemExit("Keine zulaessige Routenfuehrung gefunden.")
|
|
|
|
touren = []
|
|
for fahrzeug in range(len(KAPAZITAETEN)):
|
|
index = routing.Start(fahrzeug)
|
|
if routing.IsEnd(loesung.Value(routing.NextVar(index))):
|
|
continue
|
|
stationen, fracht, strecke = [], 0, 0
|
|
while not routing.IsEnd(index):
|
|
knoten = manager.IndexToNode(index)
|
|
stationen.append(knoten)
|
|
fracht += BEDARFE[knoten]
|
|
vorher = index
|
|
index = loesung.Value(routing.NextVar(index))
|
|
strecke += int(distanz[manager.IndexToNode(vorher)][
|
|
manager.IndexToNode(index)])
|
|
stationen.append(0)
|
|
touren.append({"stationen": stationen, "fracht": fracht,
|
|
"strecke": strecke,
|
|
"kapazitaet": KAPAZITAETEN[fahrzeug]})
|
|
return touren
|
|
|
|
|
|
def zeichne(koordinaten, touren) -> None:
|
|
figur, achse = plt.subplots(figsize=(7.6, 6.2))
|
|
gesamt = sum(t["strecke"] for t in touren)
|
|
|
|
for nummer, (tour, farbe) in enumerate(zip(touren, TOURFARBEN), start=1):
|
|
weg = koordinaten[tour["stationen"]]
|
|
achse.plot(weg[:, 0], weg[:, 1], "-", color=farbe, linewidth=1.8,
|
|
alpha=0.85, zorder=2,
|
|
label=f"Fahrzeug {nummer}: {tour['strecke']} km, "
|
|
f"{tour['fracht']}/{tour['kapazitaet']} Einheiten")
|
|
|
|
for ort in range(1, len(koordinaten)):
|
|
achse.scatter(*koordinaten[ort], s=30 + 26 * BEDARFE[ort],
|
|
color="#334155", zorder=3)
|
|
achse.annotate(f"K{ort}", koordinaten[ort], textcoords="offset points",
|
|
xytext=(7, 5), fontsize=7.5, color="#475569")
|
|
|
|
achse.scatter(*koordinaten[0], marker="s", s=190, color="#be123c", zorder=4)
|
|
achse.annotate("Depot", koordinaten[0], textcoords="offset points",
|
|
xytext=(9, 7), fontsize=9.5, color="#be123c",
|
|
fontweight="bold")
|
|
|
|
achse.set_xlabel("km")
|
|
achse.set_ylabel("km")
|
|
achse.set_title(f"Vier Touren für 16 Kunden mit Zeitfenstern — "
|
|
f"{gesamt} km insgesamt\n"
|
|
"Punktgröße = Liefermenge", fontsize=10.5)
|
|
achse.grid(linestyle=":", alpha=0.4)
|
|
achse.legend(fontsize=8, loc="upper center", bbox_to_anchor=(0.5, -0.11),
|
|
ncol=2, frameon=False)
|
|
for seite in ("top", "right"):
|
|
achse.spines[seite].set_visible(False)
|
|
figur.tight_layout()
|
|
|
|
os.makedirs(BILDER, exist_ok=True)
|
|
for endung in ("svg",):
|
|
pfad = os.path.join(BILDER, f"kap_graphen_vrp_touren.{endung}")
|
|
figur.savefig(pfad, format=endung, dpi=160, bbox_inches="tight",
|
|
metadata={"Date": None} if endung == "svg" else None)
|
|
print(f"geschrieben: {pfad}")
|
|
plt.close(figur)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
koordinaten, distanz = erzeuge_daten()
|
|
touren = loese(distanz)
|
|
gesamt = sum(t["strecke"] for t in touren)
|
|
for nummer, tour in enumerate(touren, start=1):
|
|
stationen = " -> ".join("Depot" if s == 0 else f"K{s}"
|
|
for s in tour["stationen"])
|
|
print(f"Fahrzeug {nummer}: {tour['strecke']:3} km, "
|
|
f"{tour['fracht']}/{tour['kapazitaet']} | {stationen}")
|
|
print(f"Gesamtstrecke: {gesamt} km")
|
|
zeichne(koordinaten, touren)
|