operations_research/bilder_04/erzeuge_entartung_polyeder.py
dschlueter 0f9c815b86 Grafik-Upgrade Stufe 1: elf neue Diagramme
Grafik_Upgrade.md verlangt "37 didaktisch wertvolle Visualisierungen". Es sind
19: Die Abschnitte "Modul 1-4" wiederholen die Grafiken 1-18 unter den Nummern
19-36, nur Grafik 37 ist zusaetzlich. Belegt an den Skriptnamen - 38
Nennungen, 19 verschiedene Dateien, erzeuge_kombinatorik_wand.py steht dreimal
darin. Von den 19 betreffen neun bereits vorhandene Diagramme.

Umgesetzt sind die zehn wirklich neuen plus eine elfte, die aus einem
Sachfehler des Auftrags entstand: Grafik 14 sollte in Abschnitt 12.3 stehen,
nannte aber Zahlen aus Kapitel 17. Beide Stellen haben jetzt ihre eigene
Grafik mit ihren eigenen Zahlen.

Vier Vorgaben des Auftrags wurden bewusst nicht befolgt: PNG bei 300 dpi (das
Buch kennt nur SVG - die 33 verwaisten PNGs waren gerade erst geloescht
worden), Matplotlib-Standardfarben (die Buchpalette steht in 33 Diagrammen),
harte Kapitelnummern im Fliesstext ({ref:sec:...} stattdessen) und die
fehlende Byte-Reproduzierbarkeit.

Der eigentliche Ertrag waren die Selbsttests. Jeder Generator prueft seine
Zahlen gegen das, was im Kapitel steht, und bricht bei Abweichung ab. Vier
haben angeschlagen:

* Kombinatorik-Wand: 25! ergibt 491,5 Mio. Jahre, die Tabelle nennt 490 Mio. -
  Rundung auf zwei signifikante Stellen, nicht Fehler des Buchs.
* Laufzeit-Diagramm: Die abgedruckte Prozentspalte laesst sich aus den
  abgedruckten Zeiten nicht exakt nachrechnen (0,001/0,008 ergibt 12 %,
  gedruckt sind 14 %) - die Zeiten sind gerundet, die Prozente nicht.
* Predict-then-Optimize: geratene Datenaufteilung ergab 197,35 statt 200,95
  MSE. Mit TRAINING aus dem Buchprogramm stimmen alle vier Kostenwerte.
* Almgren-Chriss: ein echter Fehler im Buchprogramm.

DER FUND: analytische_loesung() minimiert die eigene Kostenfunktion nicht. Ihr
Pfad kostet 10.860,40 EUR, der DP-Pfad 10.266,24 EUR - ein Gitterverfahren
kann das kontinuierliche Optimum aber nicht unterbieten. periodenkosten()
rechnet das Risiko mit lambda/2, die geschlossene Formel setzt
kappa~^2 = lambda*sigma^2*P0^2/eta ohne dieses Halbe. Mit lambda/2 liefert die
Formel 10.264,85 EUR und denselben Pfad wie die DP, knapp UNTER der
Gitterloesung. Die Grafik zeigt bis zur Entscheidung darueber den DP-Pfad; er
ist der, den das Kapitel abdruckt.

Nachweise: Alle elf Diagramme sind ueber zwei Laeufe byte-identisch. Kein
vorhandenes Diagramm wurde veraendert. Die Lastabwurf-Grafik reproduziert die
Befundtabelle exakt (874.870/2.504.154/28 von 40 gegen 351.356/415.924/0).

Zwei Abbildungen landeten beim automatischen Einfuegen im Codeblock (ihre
Anker standen in abgedruckten Ausgaben) und wurden dahinter verschoben; eine
Pruefung ueber alle elf zeigt null Fehlplatzierungen.

Neu: bilder_04/stil_04.py buendelt Palette, rcParams und speichere().
networkx wurde entgegen der Planung nicht gebraucht - pyproject.toml bleibt
unveraendert. PDF von 781 auf 788 Seiten, 44 Diagramme, 27 Generatoren.
Veroeffentlicht: 25 Dateien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 01:11:33 +02:00

130 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# erzeuge_entartung_polyeder.py
"""
Erzeugt das Entartungs-Diagramm zum LP-Kapitel:
bilder_04/kap_lp_entartung.svg
Links die Geometrie: Drei Geraden laufen durch DIESELBE Ecke. In zwei
Dimensionen legen schon zwei eine Ecke fest - eine ist zu viel, und genau das
ist Entartung. Rechts die Folge davon: Der Schattenpreis jeder Nebenbedingung
ist nicht mehr eine Zahl, sondern eine Spanne.
Warum beides in ein Bild gehoert: Die Geometrie allein erklaert nicht, warum
zwei Solver verschiedene Dualwerte melden duerfen und beide recht haben. Die
Spannen allein sehen nach einem Fehler aus. Nebeneinander ergibt es Sinn.
Modell und Spannen kommen aus Toleranzen_und_Entartung.py - importiert, nicht
abgeschrieben. pruefe_ecke() rechnet zusaetzlich nach, dass wirklich alle drei
Geraden durch den Punkt laufen; waere das nicht so, zeigte das Bild etwas
anderes als der Text daneben behauptet.
Aufruf (aus dem Repository-Wurzelverzeichnis):
python3 bilder_04/erzeuge_entartung_polyeder.py
Benoetigt: numpy, scipy, matplotlib
"""
from __future__ import annotations
import os
import sys
import numpy as np
HIER = os.path.dirname(os.path.abspath(__file__))
BASIS = os.path.dirname(HIER)
sys.path.insert(0, HIER)
sys.path.insert(0, os.path.join(
BASIS, "Operations_Research_mit_Python_Version_04_Programme"))
from stil_04 import FARBEN, speichere # noqa: E402
import matplotlib.pyplot as plt # noqa: E402
from Toleranzen_und_Entartung import (A_UB, B_UB, NAMEN, # noqa: E402
schattenpreis_spanne)
ECKE = np.array([4 / 3, 4 / 3])
LINIENFARBEN = (FARBEN["haupt"], FARBEN["zweit"], FARBEN["akzent"])
def pruefe_ecke() -> None:
"""Alle drei Nebenbedingungen muessen in der Ecke aktiv sein."""
schlupf = B_UB - A_UB @ ECKE
aktiv = np.abs(schlupf) < 1e-9
if not aktiv.all():
raise SystemExit(
f"Entartungsbild: In {tuple(np.round(ECKE, 3))} sind nur "
f"{int(aktiv.sum())} von 3 Bedingungen aktiv (Schlupf "
f"{np.round(schlupf, 6)}). Ohne drei aktive Geraden gibt es keine "
f"Entartung zu zeigen.")
def zeichne(spannen) -> None:
figur, (links, rechts) = plt.subplots(1, 2, figsize=(9.4, 4.3),
gridspec_kw={"width_ratios": [1, 1]})
# --- links: die drei Geraden ---------------------------------------
x = np.linspace(0, 3.2, 200)
for (a, b), grenze, name, farbe in zip(A_UB, B_UB, NAMEN, LINIENFARBEN):
y = (grenze - a * x) / b
links.plot(x, y, "-", color=farbe, linewidth=2.0, label=name, zorder=3)
# Zulaessiger Bereich: Schnitt aller Halbraeume, plus x >= 0.
gitter = np.linspace(0, 3.2, 400)
xx, yy = np.meshgrid(gitter, gitter)
zulaessig = np.ones_like(xx, dtype=bool)
for (a, b), grenze in zip(A_UB, B_UB):
zulaessig &= (a * xx + b * yy <= grenze + 1e-12)
links.contourf(xx, yy, zulaessig.astype(float), levels=[0.5, 1.5],
colors=[FARBEN["gut"]], alpha=0.13, zorder=1)
links.plot(*ECKE, "o", color=FARBEN["fehler"], markersize=9, zorder=5)
links.annotate(r"Optimum $(4/3,\ 4/3)$" "\n" "drei Geraden, eine Ecke",
xy=tuple(ECKE), xytext=(18, 26),
textcoords="offset points", fontsize=9,
color=FARBEN["fehler"], fontweight="bold",
arrowprops=dict(arrowstyle="->", color=FARBEN["fehler"],
linewidth=1.2))
links.set_xlim(0, 3.0)
links.set_ylim(0, 3.0)
links.set_xlabel(r"$x_1$")
links.set_ylabel(r"$x_2$")
links.set_title("Eine Bedingung zu viel", fontsize=10.5,
color=FARBEN["text"])
links.legend(fontsize=8.5, loc="upper right", frameon=False)
# --- rechts: die Spanne je Schattenpreis ---------------------------
y_pos = np.arange(len(NAMEN))[::-1]
for stelle, ((unten, oben), name, farbe) in enumerate(
zip(spannen, NAMEN, LINIENFARBEN)):
y = y_pos[stelle]
rechts.plot([unten, oben], [y, y], "-", color=farbe, linewidth=7,
solid_capstyle="round", alpha=0.75, zorder=3)
rechts.plot([unten, oben], [y, y], "|", color=farbe, markersize=14,
markeredgewidth=2, zorder=4)
rechts.text(oben + 0.012, y, f"{unten:.2f} {oben:.2f}",
va="center", fontsize=9, color=FARBEN["text"])
rechts.text(-0.012, y, name, va="center", ha="right", fontsize=9,
color=FARBEN["text"])
rechts.set_yticks([])
rechts.set_ylim(-0.7, len(NAMEN) - 0.3)
rechts.set_xlim(-0.16, max(o for _, o in spannen) * 1.9)
rechts.set_xlabel("Schattenpreis in € je zusätzlicher Stunde")
rechts.set_title("Was der Solver melden darf — jeder Wert der Spanne "
"ist richtig", fontsize=10.5, color=FARBEN["text"])
rechts.grid(axis="y", visible=False)
rechts.spines["left"].set_visible(False)
speichere(figur, "kap_lp_entartung")
if __name__ == "__main__":
pruefe_ecke()
zielwert = float(np.ones(2) @ ECKE)
spannen = schattenpreis_spanne(zielwert)
for name, (unten, oben) in zip(NAMEN, spannen):
print(f" {name:<18} {unten:>7.4f} bis {oben:>7.4f} EUR")
zeichne(spannen)