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>
175 lines
6.9 KiB
Python
175 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# erzeuge_kap06_gantt.py
|
|
"""
|
|
Erzeugt das Gantt-Diagramm zum Job-Shop-Beispiel aus Kapitel 6 in zwei Fassungen:
|
|
|
|
bilder_04/kap06_jobshop_gantt.svg statisch, fuer das PDF
|
|
bilder_04/plotly/kap06_jobshop_gantt.html interaktiv, fuer die Website
|
|
|
|
Die Daten sind identisch mit JobShop_Intervalle.py; geloest wird hier erneut,
|
|
damit Diagramm und Buchtext nicht auseinanderlaufen koennen.
|
|
|
|
Aufruf (aus dem Repository-Wurzelverzeichnis):
|
|
python3 bilder_04/erzeuge_kap06_gantt.py
|
|
|
|
Benoetigt: ortools, matplotlib, plotly
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import matplotlib
|
|
matplotlib.use("Agg") # kein Fenster, auch auf Servern
|
|
import matplotlib.pyplot as plt
|
|
import plotly.graph_objects as go
|
|
from ortools.sat.python import cp_model
|
|
|
|
# Reproduzierbare SVG-Ausgabe (siehe erzeuge_titelseite.py). Dieser Generator
|
|
# war als einziger ohne beides - sein SVG trug deshalb einen echten
|
|
# Zeitstempel und bei jedem Lauf andere clip-path-IDs.
|
|
plt.rcParams["svg.hashsalt"] = "or-mit-python-v04"
|
|
|
|
# Identisch zu JobShop_Intervalle.py
|
|
AUFTRAEGE = [
|
|
[(0, 3), (1, 2), (2, 2)], # Auftrag 0
|
|
[(0, 2), (2, 1), (1, 4)], # Auftrag 1
|
|
[(1, 4), (2, 3)], # Auftrag 2
|
|
]
|
|
MASCHINENNAMEN = ["Fraese", "Dreherei", "Lackiererei"]
|
|
|
|
# Buchpalette (Indigo, Cyan, Amber) - dieselben Farben wie im uebrigen Werk
|
|
FARBEN = ["#4338ca", "#0891b2", "#b45309"]
|
|
|
|
BASIS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
BILDER = os.path.join(BASIS, "bilder_04")
|
|
PLOTLY_VERZ = os.path.join(BILDER, "plotly")
|
|
|
|
|
|
def loese() -> tuple[list[tuple[int, int, int, int]], int]:
|
|
"""Liefert (Auftrag, Maschine, Beginn, Dauer) je Arbeitsgang und den Makespan."""
|
|
anzahl_maschinen = 1 + max(m for auftrag in AUFTRAEGE for m, _ in auftrag)
|
|
horizont = sum(dauer for auftrag in AUFTRAEGE for _, dauer in auftrag)
|
|
|
|
modell = cp_model.CpModel()
|
|
intervalle: dict[int, list] = {m: [] for m in range(anzahl_maschinen)}
|
|
beginn_var: dict[tuple[int, int], object] = {}
|
|
ende_var: dict[tuple[int, int], object] = {}
|
|
|
|
for a, auftrag in enumerate(AUFTRAEGE):
|
|
for g, (maschine, dauer) in enumerate(auftrag):
|
|
beginn = modell.NewIntVar(0, horizont, f"b_{a}_{g}")
|
|
ende = modell.NewIntVar(0, horizont, f"e_{a}_{g}")
|
|
intervall = modell.NewIntervalVar(beginn, dauer, ende, f"i_{a}_{g}")
|
|
intervalle[maschine].append(intervall)
|
|
beginn_var[a, g] = beginn
|
|
ende_var[a, g] = ende
|
|
if g > 0: # Arbeitsgaenge in ihrer Reihenfolge
|
|
modell.Add(beginn >= ende_var[a, g - 1])
|
|
|
|
for maschine in range(anzahl_maschinen):
|
|
modell.AddNoOverlap(intervalle[maschine])
|
|
|
|
makespan = modell.NewIntVar(0, horizont, "makespan")
|
|
modell.AddMaxEquality(makespan, [ende_var[a, len(auftrag) - 1]
|
|
for a, auftrag in enumerate(AUFTRAEGE)])
|
|
modell.Minimize(makespan)
|
|
|
|
loeser = cp_model.CpSolver()
|
|
# Dieselben Parameter wie in JobShop_Intervalle.py - sonst zeigt das
|
|
# Diagramm einen anderen (gleich guten) Plan als der Buchtext.
|
|
loeser.parameters.num_workers = 1
|
|
loeser.parameters.random_seed = 1
|
|
status = loeser.Solve(modell)
|
|
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
|
|
raise RuntimeError(f"Kein Plan gefunden: {loeser.StatusName(status)}")
|
|
|
|
plan = [(a, maschine, loeser.Value(beginn_var[a, g]), dauer)
|
|
for a, auftrag in enumerate(AUFTRAEGE)
|
|
for g, (maschine, dauer) in enumerate(auftrag)]
|
|
return plan, loeser.Value(makespan)
|
|
|
|
|
|
def zeichne_statisch(plan, makespan: int) -> None:
|
|
"""Matplotlib-Fassung fuer das PDF."""
|
|
figur, achse = plt.subplots(figsize=(9.0, 3.2))
|
|
for auftrag, maschine, beginn, dauer in plan:
|
|
achse.barh(y=maschine, width=dauer, left=beginn, height=0.55,
|
|
color=FARBEN[auftrag % len(FARBEN)],
|
|
edgecolor="white", linewidth=1.2)
|
|
achse.text(beginn + dauer / 2, maschine, f"A{auftrag}",
|
|
ha="center", va="center", color="white",
|
|
fontsize=10, fontweight="bold")
|
|
|
|
achse.set_yticks(range(len(MASCHINENNAMEN)))
|
|
achse.set_yticklabels(MASCHINENNAMEN)
|
|
achse.invert_yaxis()
|
|
achse.set_xlabel("Zeiteinheiten")
|
|
achse.set_xlim(0, makespan)
|
|
achse.set_xticks(range(makespan + 1))
|
|
achse.set_title(f"Job-Shop-Plan, Makespan = {makespan}", fontsize=11)
|
|
achse.grid(axis="x", linestyle=":", alpha=0.5)
|
|
for rand in ("top", "right", "left"):
|
|
achse.spines[rand].set_visible(False)
|
|
figur.tight_layout()
|
|
|
|
os.makedirs(BILDER, exist_ok=True)
|
|
for endung in ("svg",):
|
|
pfad = os.path.join(BILDER, f"kap06_jobshop_gantt.{endung}")
|
|
figur.savefig(pfad, format=endung, dpi=160,
|
|
metadata={"Date": None} if endung == "svg" else None)
|
|
print(f"geschrieben: {pfad}")
|
|
plt.close(figur)
|
|
|
|
|
|
def zeichne_interaktiv(plan, makespan: int) -> None:
|
|
"""Plotly-Fassung fuer die Website.
|
|
|
|
include_plotlyjs=False: Die Bibliothek wird vom Build einmal je Seite
|
|
eingebunden (assets/plotly.min.js), nicht in jede Figur kopiert.
|
|
"""
|
|
figur = go.Figure()
|
|
schon_beschriftet = set()
|
|
for auftrag, maschine, beginn, dauer in plan:
|
|
figur.add_trace(go.Bar(
|
|
x=[dauer], y=[MASCHINENNAMEN[maschine]], base=[beginn],
|
|
orientation="h", name=f"Auftrag {auftrag}",
|
|
legendgroup=f"A{auftrag}",
|
|
showlegend=auftrag not in schon_beschriftet,
|
|
marker_color=FARBEN[auftrag % len(FARBEN)],
|
|
marker_line=dict(color="white", width=1.5),
|
|
hovertemplate=(f"<b>Auftrag {auftrag}</b><br>"
|
|
f"{MASCHINENNAMEN[maschine]}<br>"
|
|
f"von {beginn} bis {beginn + dauer} "
|
|
f"({dauer} Zeiteinheiten)<extra></extra>"),
|
|
))
|
|
schon_beschriftet.add(auftrag)
|
|
|
|
figur.update_layout(
|
|
barmode="overlay",
|
|
title=f"Job-Shop-Plan, Makespan = {makespan}",
|
|
xaxis=dict(title="Zeiteinheiten", dtick=1, range=[0, makespan]),
|
|
yaxis=dict(autorange="reversed"),
|
|
height=320,
|
|
margin=dict(l=90, r=20, t=50, b=45),
|
|
# Transparenter Hintergrund, damit die Figur sich in helles wie
|
|
# dunkles Seitendesign einfuegt.
|
|
paper_bgcolor="rgba(0,0,0,0)",
|
|
plot_bgcolor="rgba(0,0,0,0)",
|
|
legend=dict(orientation="h", y=-0.25),
|
|
)
|
|
|
|
os.makedirs(PLOTLY_VERZ, exist_ok=True)
|
|
pfad = os.path.join(PLOTLY_VERZ, "kap06_jobshop_gantt.html")
|
|
with open(pfad, "w", encoding="utf-8") as datei:
|
|
datei.write(figur.to_html(full_html=False, include_plotlyjs=False,
|
|
div_id="kap06-jobshop-gantt"))
|
|
print(f"geschrieben: {pfad}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
plan, makespan = loese()
|
|
print(f"Makespan: {makespan}")
|
|
zeichne_statisch(plan, makespan)
|
|
zeichne_interaktiv(plan, makespan)
|