operations_research/bilder_04/erzeuge_kap06_gantt.py
2026-09-09 07:37:07 +02:00

257 lines
11 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 untere_schranke() -> int:
"""Wie in JobShop_Intervalle.py: laengster Auftrag bzw. hoechste Maschinenlast.
Kein Plan kann kuerzer sein als der laengste Auftrag (seine Schritte laufen
nacheinander) und auch nicht kuerzer als die am staerksten belegte Maschine
(ihre Schritte koennen sich nicht ueberlappen)."""
je_auftrag = max(sum(dauer for _, dauer in auftrag)
for auftrag in AUFTRAEGE)
last = [0] * len(MASCHINENNAMEN)
for auftrag in AUFTRAEGE:
for maschine, dauer in auftrag:
last[maschine] += dauer
return max(je_auftrag, max(last))
def stillstaende(plan):
"""Luecken ZWISCHEN zwei Arbeitsgaengen derselben Maschine.
Nur die inneren: Was eine Maschine vor ihrem ersten oder nach ihrem letzten
Arbeitsgang tut, ist keine Luecke im Plan, sondern sein Rand."""
luecken = []
for maschine in range(len(MASCHINENNAMEN)):
zeiten = sorted((beginn, beginn + dauer)
for _, m, beginn, dauer in plan if m == maschine)
for (_, ende), (naechster_beginn, _) in zip(zeiten, zeiten[1:]):
if naechster_beginn > ende:
luecken.append((maschine, ende, naechster_beginn))
return luecken
def zeichne_statisch(plan, makespan: int) -> None:
"""Matplotlib-Fassung fuer das PDF."""
figur, achse = plt.subplots(figsize=(9.0, 3.4))
for maschine, von, bis in stillstaende(plan):
achse.barh(y=maschine, width=bis - von, left=von, height=0.55,
facecolor="none", edgecolor="#be123c", linewidth=1.2,
hatch="///", zorder=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))
schranke = untere_schranke()
achse.axvline(schranke, color="#059669", linestyle="--", linewidth=1.5,
zorder=5)
# x in Datenkoordinaten, y als Anteil der Achsenhoehe: Die y-Achse ist
# umgekehrt, ein fester y-Wert laege sonst ausserhalb des Bildes.
unten = achse.get_xaxis_transform()
# Der Makespan steht schon im Titel - hier nur die Schranke, sonst
# stossen beide Beschriftungen aneinander.
achse.text(schranke - 0.12, 0.03, f"untere Schranke {schranke}",
transform=unten, fontsize=8.5, color="#059669", ha="right",
va="bottom", zorder=6)
achse.set_title(f"Job-Shop-Plan: Makespan {makespan}, untere Schranke "
f"{schranke} — schraffiert steht die Maschine still",
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()
schranke = untere_schranke()
print(f"Makespan: {makespan}, untere Schranke: {schranke}")
for maschine, von, bis in stillstaende(plan):
print(f" Stillstand: {MASCHINENNAMEN[maschine]} von {von} bis {bis}")
# Gegenprobe zu den Zahlen, die das Kapitel abdruckt.
if schranke > makespan:
raise SystemExit(f"Gantt: Die untere Schranke {schranke} liegt ueber "
f"dem Makespan {makespan} - eine der beiden Zahlen "
f"ist falsch.")
if (schranke, makespan) != (10, 11):
raise SystemExit(f"Gantt: Schranke {schranke} / Makespan {makespan}; "
f"das Kapitel druckt 10 / 11 ab.")
if not any(m == 1 and von == 4 for m, von, _ in stillstaende(plan)):
raise SystemExit("Gantt: Die Luecke in der Dreherei bei Zeitpunkt 4 "
"fehlt - der Text daneben bespricht genau sie.")
# Der Text daneben rechnet vor: Die Engpassmaschine ist von 0 bis zum
# Makespan belegt, ihre Arbeit ist die Schranke, der Rest Stillstand.
last = [0] * len(MASCHINENNAMEN)
for auftrag in AUFTRAEGE:
for maschine, dauer in auftrag:
last[maschine] += dauer
engpass = last.index(max(last))
leerlauf = sum(bis - von for m, von, bis in stillstaende(plan)
if m == engpass)
if last[engpass] + leerlauf != makespan:
raise SystemExit(
f"Gantt: {MASCHINENNAMEN[engpass]} arbeitet {last[engpass]} und "
f"steht {leerlauf} still, zusammen "
f"{last[engpass] + leerlauf} statt {makespan}. Der Text daneben "
f"rechnet genau diese Summe vor.")
print(f" Engpass {MASCHINENNAMEN[engpass]}: {last[engpass]} Arbeit + "
f"{leerlauf} Stillstand = {makespan}")
zeichne_statisch(plan, makespan)
zeichne_interaktiv(plan, makespan)