operations_research/bilder_04/erzeuge_polyeder.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

254 lines
9.8 KiB
Python

#!/usr/bin/env python3
# erzeuge_polyeder.py
"""
Erzeugt die beiden Polyeder-Diagramme zum Fundament- und zum LP-Kapitel:
bilder_04/kap_fundament_polyeder.svg / .png der zulaessige Bereich
bilder_04/kap_lp_simplex_ecken.svg / .png der Eckenlauf des Simplex
Beide zeigen DASSELBE Modell - das Bot-Allokationsproblem, das sich durch die
ersten Kapitel zieht:
max 150 x1 + 250 x2
u.d.N. 2 x1 + 5 x2 <= 40 (vCPU)
4 x1 + 6 x2 <= 60 (RAM)
x1 <= 8 (Marktlimit)
x1, x2 >= 0
Warum das wichtig ist: Die Vorgaengerbilder zeigten ein voellig anderes LP
(2x1+3x2 <= 24, 3x1+2x2 <= 24, Optimum bei (4,8; 4,8)) - unmittelbar neben einer
Handrechnung, die mit dem Bot-Modell rechnet und (7,5; 5) herausbekommt. Ein
Leser musste annehmen, beides gehoere zusammen.
Die Ecken werden NICHT hingeschrieben, sondern nach demselben Verfahren
bestimmt wie in Visualisierung_Loesungsraum.py: Schnittpunkte je zweier
Begrenzungsgeraden, die zulaessig sind. Der Simplex-Weg im zweiten Bild ist der
Weg der Handrechnung im LP-Kapitel: (0,0) -> (0,8) -> (7,5; 5), also drei der
fuenf Ecken.
Aufruf (aus dem Repository-Wurzelverzeichnis):
python3 bilder_04/erzeuge_polyeder.py
Benoetigt: numpy, matplotlib
"""
from __future__ import annotations
import itertools
import os
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
# 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")
INDIGO, CYAN, AMBER, GRUEN = "#4338ca", "#0891b2", "#b45309", "#059669"
# Modell wortgleich zu Visualisierung_Loesungsraum.py
C = np.array([150.0, 250.0])
A = np.array([[2.0, 5.0], [4.0, 6.0], [1.0, 0.0]])
B = np.array([40.0, 60.0, 8.0])
NAMEN = ["vCPU: $2x_1+5x_2 \\leq 40$", "RAM: $4x_1+6x_2 \\leq 60$",
"Marktlimit: $x_1 \\leq 8$"]
FARBEN = [INDIGO, CYAN, AMBER]
# Der Weg der Handrechnung im LP-Kapitel: Start im Ursprung, dann x2 aufnehmen
# (Pivotspalte -250), dann x1 (Pivotspalte -50).
SIMPLEXWEG = [(0.0, 0.0), (0.0, 8.0), (7.5, 5.0)]
def ist_zulaessig(punkt: np.ndarray, tol: float = 1e-7) -> bool:
return bool(np.all(A @ punkt <= B + tol) and np.all(punkt >= -tol))
def berechne_ecken() -> np.ndarray:
"""Ecken = zulaessige Schnittpunkte je zweier Begrenzungsgeraden.
Wortgleich zu Visualisierung_Loesungsraum.py - die Koordinaten im Bild
stammen also aus derselben Rechnung wie die im Buch abgedruckte Tabelle,
nicht aus einer zweiten Quelle.
"""
geraden = [(A[i], B[i]) for i in range(len(B))]
geraden.append((np.array([1.0, 0.0]), 0.0)) # x1 = 0
geraden.append((np.array([0.0, 1.0]), 0.0)) # x2 = 0
ecken: list[np.ndarray] = []
for (n1, d1), (n2, d2) in itertools.combinations(geraden, 2):
matrix = np.array([n1, n2])
if abs(np.linalg.det(matrix)) < 1e-9:
continue
punkt = np.linalg.solve(matrix, np.array([d1, d2]))
if ist_zulaessig(punkt) and not any(np.allclose(punkt, e) for e in ecken):
ecken.append(punkt)
return np.array(ecken)
# Beschriftungsposition je Ecke, in Punkten relativ zum Eckpunkt. Von Hand
# gesetzt und nicht gerechnet: (7,5; 5) und (8; 4,67) liegen eine halbe Einheit
# auseinander, und jede automatische Regel legt ihre Beschriftungen uebereinander.
# Das ist Layout, keine Aussage - die Koordinaten selbst bleiben gerechnet.
VERSATZ = {
(0.0, 0.0): (14, 10),
(0.0, 8.0): (14, -2),
(8.0, 0.0): (-10, 14),
(7.5, 5.0): (-12, 16),
(8.0, 4.67): (16, -4),
}
# Im Simplex-Bild laeuft der Pfad durch zwei dieser Positionen - dort andere.
VERSATZ_SIMPLEX = {
(0.0, 8.0): (16, 12),
(7.5, 5.0): (14, 16),
}
def versatz(punkt: np.ndarray, mit_pfad: bool = False) -> tuple:
schluessel = (round(float(punkt[0]), 2), round(float(punkt[1]), 2))
if mit_pfad and schluessel in VERSATZ_SIMPLEX:
return VERSATZ_SIMPLEX[schluessel]
return VERSATZ.get(schluessel, (12, 8))
def punkt_text(punkt: np.ndarray) -> str:
"""Koordinaten deutsch und auf zwei Stellen - '(8; 4,67)' statt '(8; 4.66667)'."""
teile = []
for wert in punkt:
text = f"{wert:.2f}".rstrip("0").rstrip(".")
teile.append(text.replace(".", ","))
return f"({teile[0]}; {teile[1]})"
def euro(wert: float) -> str:
return f"{wert:,.0f}".replace(",", ".")
def sortiere_umlaufend(ecken: np.ndarray) -> np.ndarray:
"""Ecken gegen den Uhrzeigersinn ordnen, damit das Polygon nicht verknotet."""
mitte = ecken.mean(axis=0)
winkel = np.arctan2(ecken[:, 1] - mitte[1], ecken[:, 0] - mitte[0])
return ecken[np.argsort(winkel)]
def zeichne_rahmen(achse, ecken: np.ndarray) -> None:
"""Restriktionsgeraden, zulaessiger Bereich und Achsenbeschriftung."""
rand = sortiere_umlaufend(ecken)
achse.fill(rand[:, 0], rand[:, 1], color=INDIGO, alpha=0.10, zorder=1)
achse.plot(np.append(rand[:, 0], rand[0, 0]),
np.append(rand[:, 1], rand[0, 1]),
color=INDIGO, linewidth=1.6, zorder=2)
x = np.linspace(0, 11, 200)
for zeile, grenze, name, farbe in zip(A, B, NAMEN, FARBEN):
if abs(zeile[1]) < 1e-9: # senkrechte Gerade x1 = c
achse.axvline(grenze / zeile[0], color=farbe, linewidth=1.3,
linestyle="--", alpha=0.8, label=name)
else:
achse.plot(x, (grenze - zeile[0] * x) / zeile[1], color=farbe,
linewidth=1.3, linestyle="--", alpha=0.8, label=name)
achse.set_xlim(-0.9, 11.4)
achse.set_ylim(-0.9, 11)
achse.set_xlabel("$x_1$ — Bots vom Typ A")
achse.set_ylabel("$x_2$ — Bots vom Typ B")
achse.grid(linestyle=":", alpha=0.45)
for seite in ("top", "right"):
achse.spines[seite].set_visible(False)
def schreibe(figur, name: str) -> None:
os.makedirs(BILDER, exist_ok=True)
for endung in ("svg", "png"):
pfad = os.path.join(BILDER, f"{name}.{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_polyeder(ecken: np.ndarray, bestes: np.ndarray) -> None:
"""Fundament: alle Ecken mit ihrem Zielwert - der Fundamentalsatz zum Anfassen."""
figur, achse = plt.subplots(figsize=(7.2, 5.2))
zeichne_rahmen(achse, ecken)
for ecke in ecken:
wert = float(C @ ecke)
optimal = np.allclose(ecke, bestes)
achse.scatter(*ecke, s=120 if optimal else 55, zorder=4,
color=GRUEN if optimal else INDIGO)
beschriftung = f"{punkt_text(ecke)}\n{euro(wert)}"
if optimal:
beschriftung += "\n← Optimum"
weg = versatz(ecke)
achse.annotate(beschriftung, ecke, textcoords="offset points",
xytext=weg, fontsize=8.5,
ha="left" if weg[0] >= 0 else "right",
va="bottom" if weg[1] >= 0 else "top",
color=GRUEN if optimal else "#334155",
fontweight="bold" if optimal else "normal")
achse.set_title("Der zulässige Bereich und seine Ecken\n"
"$\\max\\ 150x_1 + 250x_2$", fontsize=11)
achse.legend(fontsize=8, loc="upper right", frameon=False)
figur.tight_layout()
schreibe(figur, "kap_fundament_polyeder")
def zeichne_simplexweg(ecken: np.ndarray, bestes: np.ndarray) -> None:
"""LP-Kapitel: derselbe Bereich, aber der Weg der Handrechnung."""
figur, achse = plt.subplots(figsize=(7.2, 5.2))
zeichne_rahmen(achse, ecken)
besucht = [np.array(p) for p in SIMPLEXWEG]
unbesucht = [e for e in ecken
if not any(np.allclose(e, p) for p in besucht)]
for ecke in unbesucht:
achse.scatter(*ecke, s=55, color="#94a3b8", zorder=3)
weg = versatz(ecke, mit_pfad=True)
achse.annotate(punkt_text(ecke), ecke, textcoords="offset points",
xytext=weg, fontsize=8, color="#64748b",
ha="left" if weg[0] >= 0 else "right",
va="bottom" if weg[1] >= 0 else "top")
weg = np.array(besucht)
achse.plot(weg[:, 0], weg[:, 1], color=AMBER, linewidth=2.4, zorder=4)
for schritt, punkt in enumerate(besucht):
wert = float(C @ punkt)
letzter = schritt == len(besucht) - 1
achse.scatter(*punkt, s=140 if letzter else 90, zorder=5,
color=GRUEN if letzter else AMBER)
weg = versatz(punkt, mit_pfad=True)
achse.annotate(f"{schritt}. {punkt_text(punkt)}\nZ = {euro(wert)}",
punkt, textcoords="offset points", xytext=weg,
fontsize=8.5, fontweight="bold",
ha="left" if weg[0] >= 0 else "right",
va="bottom" if weg[1] >= 0 else "top",
color=GRUEN if letzter else "#334155")
achse.set_title(f"Der Simplex besucht {len(besucht)} von {len(ecken)} Ecken\n"
"und keine davon zweimal", fontsize=11)
achse.legend(fontsize=8, loc="upper right", frameon=False)
figur.tight_layout()
schreibe(figur, "kap_lp_simplex_ecken")
if __name__ == "__main__":
ecken = berechne_ecken()
werte = ecken @ C
bestes = ecken[int(np.argmax(werte))]
print(f"{len(ecken)} Ecken gefunden:")
for ecke, wert in sorted(zip(ecken.tolist(), werte.tolist()),
key=lambda p: -p[1]):
print(f" ({ecke[0]:6.2f}; {ecke[1]:5.2f}) Z = {wert:9,.2f} EUR")
print(f"Optimum: ({bestes[0]:g}; {bestes[1]:g})")
for punkt in SIMPLEXWEG:
if not any(np.allclose(np.array(punkt), e) for e in ecken):
raise SystemExit(f"FEHLER: {punkt} ist keine Ecke des Polyeders.")
zeichne_polyeder(ecken, bestes)
zeichne_simplexweg(ecken, bestes)