Die Rockafellar-Uryasev-Formel behandelt Atome von sich aus korrekt. Verwenden Sie sie auch dann, wenn Sie „nur schnell“ einen CVaR ausrechnen wollen.
@@ -24305,8 +24333,8 @@ CVaR: Portfolio 101.33 vs. Summe 159.24 -> subadditiv (kohaerent)
Das lässt sich auch unabhängig von der Handrechnung nachweisen: Primal- und Dualproblem werden als zwei getrennte, voneinander unabhängige LPs aufgestellt und gelöst — wenn der starke Dualitätssatz stimmt, müssen beide denselben Zielwert liefern, ohne dass eines vom anderen „weiß“.
#!/usr/bin/env python3
@@ -1126,8 +1132,8 @@ Pruefung: komplementaerer Schlupf fuer alle Ressourcen erfuellt.
5.9 Wann Schattenpreise lügen: Entartung und Toleranzen
Der vorige Abschnitt hat gezeigt, wie wertvoll Schattenpreise sind. Dieser zeigt die zwei Fälle, in denen sie eine Entscheidung nicht tragen — und wie man beide erkennt, bevor jemand auf ihrer Grundlage eine Maschine kauft.
-
-Abb. 5.3: Links laufen drei Geraden durch dieselbe Ecke — eine mehr, als zwei Dimensionen brauchen. Rechts die Folge: Der Schattenpreis ist keine Zahl mehr, sondern eine Spanne. Erzeugt von bilder_04/erzeuge_entartung_polyeder.py.
+
+Abb. 5.4: Links laufen drei Geraden durch dieselbe Ecke — eine mehr, als zwei Dimensionen brauchen. Rechts die Folge: Der Schattenpreis ist keine Zahl mehr, sondern eine Spanne. Erzeugt von bilder_04/erzeuge_entartung_polyeder.py.
Was Sie in der Abbildung sehen. Jeder Wert innerhalb der farbigen Balken erfüllt die Optimalitätsbedingungen. Zwei Solver dürfen also verschiedene Schattenpreise melden und haben beide recht — die Frage „was ist eine Stunde wert?” hat hier schlicht keine eindeutige Antwort.
Fall 1: Entartung
diff --git a/OR_HTML_04/mehrziel.html b/OR_HTML_04/mehrziel.html
index 0a814c7..1e77da1 100644
--- a/OR_HTML_04/mehrziel.html
+++ b/OR_HTML_04/mehrziel.html
@@ -629,14 +629,15 @@ dem CO2-Preis, den das Unternehmen ohnehin ansetzt.
14.6 Was die gewichtete Summe nicht sieht
Die Front hat zehn Punkte. Ein Raster aus 1 401 Gewichten findet sechs davon. Vier Kompromisse sind für kein Gewicht erreichbar — und das ist keine Frage der Rasterweite.
-
-Abb. 14.1: Pareto-Front: Kosten gegen CO₂. Die gefüllten Punkte liegen auf der unteren konvexen Hülle und sind über Gewichte erreichbar; die vier offenen Kreise sind pareto-optimal, werden aber von keiner Geraden gestützt.
+
+Abb. 14.1: Pareto-Front: Kosten gegen CO₂. Die gefüllten Punkte liegen auf der unteren konvexen Hülle und sind über Gewichte erreichbar; die vier offenen Kreise sind pareto-optimal, werden aber von keiner Geraden gestützt. Die dünne Gerade ist eine solche Stützgerade, die punktierte die parallele Gerade durch den am weitesten abliegenden der vier Punkte. Erzeugt von bilder_04/erzeuge_pareto_front.py.
Warum es keine Frage des Rasters ist. „c(x) + w\,e(x) minimieren” heißt geometrisch: eine Gerade der Steigung -1/w von links unten an die Punktwolke schieben und schauen, welchen Punkt sie zuerst berührt. Eine Gerade berührt immer einen Eckpunkt der unteren konvexen Hülle . Punkte, die oberhalb der Hülle liegen, werden von keiner Geraden zuerst getroffen — für kein w , bei beliebig feiner Abstufung.
Das Programm rechnet diese Hülle in untere_huelle() unabhängig aus und vergleicht: Genau die vier Punkte, die das Gewichtsraster verfehlt, liegen strikt oberhalb. Die Gegenprobe steht in der Ausgabe.
+Was Sie in der Abbildung sehen. Die beiden Geraden haben dieselbe Steigung, also dasselbe Gewicht w . Die untere berührt die Hülle in zwei Ecken — eine von beiden meldet der Solver als Optimum. Die obere geht durch einen pareto-optimalen Punkt, der damit für dieses Gewicht chancenlos ist, und zwar nicht knapp: Er liegt eine ganze Stufe darüber. Verschieben Sie die Steigung, verschiebt sich die Berührung von einer Ecke zur nächsten — über den Punkt dazwischen springt sie hinweg. Die waagerechte Linie zeigt den Ausweg, um den es in Abschnitt 14.7 geht: Eine Schranke für den CO₂-Ausstoß statt eines Gewichts findet genau diesen Punkt.
⚠️ Der Grund liegt in der Ganzzahligkeit
Bei einem reinen LP ist der zulässige Bereich konvex, und die Pareto-Front liegt vollständig auf ihrer eigenen konvexen Hülle — dort findet die gewichtete Summe alles. Sobald Entscheidungen ganzzahlig werden (welcher Träger, welches Lager, welche Schicht), zerfällt der Bereich in einzelne Punkte, und zwischen ihnen entstehen Einbuchtungen.
diff --git a/OR_HTML_04/milp.html b/OR_HTML_04/milp.html
index f7ff495..c19d5b7 100644
--- a/OR_HTML_04/milp.html
+++ b/OR_HTML_04/milp.html
@@ -382,6 +382,11 @@ Loesung eine Nebenbedingung verletzt. 'Verlust' = Abstand zum exakten Op
oder die LP-Lösung bereits ganzzahlig ist (dann ist dieser Ast fertig).
+
+
+Abb. 6.3: Was der erste Verzweigungsschritt des Suchbaums geometrisch bedeutet. Graue Punkte sind die 13 ganzzahligen zulässigen Punkte. Erzeugt von bilder_04/erzeuge_bnb_geometrie.py.
+
+Was Sie in der Abbildung sehen. Der rote Streifen ist genau das, was Phase 2 wegnimmt — und in ihm liegt kein einziger grauer Punkt. Deshalb ist Verzweigen verlustfrei. Weggenommen wird trotzdem etwas Wertvolles: das LP-Optimum (3; 1,5) selbst. Genau darum sinkt die Schranke von 21 auf 20,67, und genau dieses Sinken ist der Fortschritt, den der Suchbaum Knoten für Knoten protokolliert.
Zusätzlich fügen Solver Schnittebenen (cutting planes , z. B. Gomory-Cuts) hinzu: gültige Ungleichungen, die gebrochene Bereiche wegschneiden, ohne einen einzigen zulässigen ganzzahligen Punkt zu entfernen.
✏️ Handrechnung 6.2: Ein Suchbaum von Hand
@@ -396,8 +401,8 @@ Loesung eine Nebenbedingung verletzt. 'Verlust' = Abstand zum exakten Op
6.5 Modellierungstricks: Big-M und logische Bedingungen
Hier kommt das Handwerkszeug, das MILP so mächtig macht: Man übersetzt Geschäftsregeln in lineare Ungleichungen — mithilfe binärer Hilfsvariablen y \in \{0,1\} und einer hinreichend großen Konstante M .
-
-Abb. 6.3: Dieselbe Bedingung x \le M\,y , zwei Zahlen für M . Gezeigt ist die LP-Relaxation — also das, was der Solver sieht, bevor y ganzzahlig wird. Erzeugt von bilder_04/erzeuge_big_m_vergleich.py.
+
+Abb. 6.4: Dieselbe Bedingung x \le M\,y , zwei Zahlen für M . Gezeigt ist die LP-Relaxation — also das, was der Solver sieht, bevor y ganzzahlig wird. Erzeugt von bilder_04/erzeuge_big_m_vergleich.py.
Was Sie in der Abbildung sehen. Für y \in \{0,1\} sind beide Modelle gleichwertig; die beiden blauen Punkte stimmen überein. Verschieden ist nur, was dazwischen erlaubt ist — und rechts genügt bereits y = 0{,}08 , um die volle Kapazität freizugeben.
Muster 1 — Fixkosten / Aktivierungsschalter
diff --git a/OR_HTML_04/praxisfallen.html b/OR_HTML_04/praxisfallen.html
index 6a3f9f7..74f5b35 100644
--- a/OR_HTML_04/praxisfallen.html
+++ b/OR_HTML_04/praxisfallen.html
@@ -923,8 +923,8 @@ aus.
-
-Abb. 22.2: Architektur einer produktionsreifen OR-Plattform
+
+Abb. 22.2: Die Schichten dieses Abschnitts als Bild — erzeugt aus der Tabelle daneben, damit beide nicht auseinanderlaufen können. Erzeugt von bilder_04/erzeuge_or_architektur.py.
In einer professionellen Umgebung ist der Optimierer kein Skript, sondern ein versionierter, zustandsloser Dienst :
+Was Sie in der Abbildung sehen. Fünf der sechs Schichten bilden einen Weg: Daten gehen oben hinein und unten als Handlung wieder hinaus. Die sechste steht daneben, und das ist keine zeichnerische Verlegenheit — Monitoring und Audit-Trail sind keine Station, die man durchläuft, sondern eine Anforderung an jede. Wer sie als sechsten Kasten unten anhängt, baut sie am Ende auch so: als etwas, das man hinterher noch macht.
Fünf Prinzipien, die sich in der Praxis bewährt haben:
Snapshot-Prinzip. Jeder Optimierungslauf arbeitet auf einem unveränderlichen Datenschnappschuss mit eigener ID. Nur so ist ein Ergebnis später reproduzierbar — und im Streitfall belegbar.
diff --git a/Operations_Research_mit_Python_Version_04.md b/Operations_Research_mit_Python_Version_04.md
index 2d788df..82c2ba3 100644
--- a/Operations_Research_mit_Python_Version_04.md
+++ b/Operations_Research_mit_Python_Version_04.md
@@ -2104,6 +2104,11 @@ $$
> **🎯 Merksatz** Konvex heißt: Wer bergab geht, kommt am tiefsten Punkt an. Nicht-konvex heißt: Wer bergab geht, kommt *irgendwo* an — und weiß nicht, ob es der tiefste Punkt war.
+
+
+**Was Sie in der Abbildung sehen.** Links sind es fünf Läufe desselben Verfahrens, die in vier verschiedenen Tälern enden — dieselben vier, die Teil 2 von `Konvexitaet_Demo.py` weiter unten auflistet. In zwei Dimensionen werden aus den Tälern Mulden in einem Gitter, und der Unterschied zwischen Start A und Start B beträgt hier bereits mehr als 24 Einheiten. Keiner der beiden Läufe hat etwas falsch gemacht: Beide enden dort, wo es nicht mehr bergab geht.
+
### Was ist konvex, was nicht?
| Problemtyp | Konvex? | Konsequenz |
@@ -2423,7 +2428,7 @@ Bis hierher war die lineare Algebra exakt. Ein Rechner rechnet aber nicht exakt,
> **Wenn meine Eingabedaten leicht ungenau sind — wie ungenau ist dann meine Lösung?**
-
Die Antwort trägt einen Namen: die **Konditionszahl**`\index{Konditionszahl}`{=latex} $\kappa(\mathbf{A})$. Sie ist der wichtigste Begriff dieses Kapitels für den Produktivbetrieb, weil er erklärt, warum ein mathematisch korrektes Modell trotzdem unbrauchbare Ergebnisse liefern kann — oder sich mit einem sachlich falschen `INFEASIBLE` verabschiedet.
@@ -4873,7 +4878,9 @@ $$
Der von George `\index{Dantzig, George}`{=latex}Dantzig 1947 formulierte `\index{Simplex-Algorithmus}`{=latex}Simplex-Algorithmus nutzt den Fundamentalsatz aus [Kapitel 2](#kap-fundament): **Das Optimum liegt in einer Ecke.** Statt alle Ecken aufzuzählen, wandert er von Ecke zu Ecke — und zwar immer in Richtung Verbesserung.

+(0; 8) zum Optimum (7,5; 5). Drei der fünf Ecken werden besucht, keine zweimal — das ist der ganze Gewinn gegenüber dem Aufzählen aller Ecken. Punktiert die Niveaulinien der Zielfunktion, der Pfeil ihr Gradient $\mathbf{c}$. Erzeugt von `bilder_04/erzeuge_polyeder.py`.](bilder_04/kap_lp_simplex_ecken.svg)
+
+**Was Sie in der Abbildung sehen.** Durch jede besuchte Ecke läuft genau eine Niveaulinie — die Linien sind nicht gewählt, sondern die Zielwerte 0 €, 2.000 € und 2.375 € der drei Ecken. Der Simplex schiebt diese Linie in Richtung des Pfeils so weit hinaus, wie das Polyeder es zulässt. Dass er dabei an einer Ecke anhält und nicht mittendrin, ist keine Eigenheit des Verfahrens, sondern der Fundamentalsatz: Weiter geht es nur noch hinaus.
### Der Ablauf
@@ -5187,6 +5194,11 @@ Zu **jedem** linearen Optimierungsproblem (dem **primalen Problem**`\index{Prima
Prüfen wir das an unserem Beispiel: $\mathbf{b}^\top\mathbf{y}^* = 40 \cdot 12{,}5 + 60 \cdot 31{,}25 + 8 \cdot 0 = 500 + 1875 + 0 = 2375$ ✓ — identisch mit $Z^* = 2375$.
+
+
+**Was Sie in der Abbildung sehen.** Die beiden Tafeln haben nichts gemeinsam: verschiedene Achsen, verschiedene Einheiten, links ein geschlossenes Polyeder, rechts ein nach oben offener Bereich. Links wird maximiert, rechts minimiert. Und doch steht an beiden Optima dieselbe Zahl — 2.375 €. Genau das behauptet der Satz, und mehr ist an ihm nicht zu sehen.
+
Das lässt sich auch unabhängig von der Handrechnung nachweisen: Primal- und Dualproblem werden als **zwei getrennte, voneinander unabhängige LPs** aufgestellt und gelöst — wenn der starke Dualitätssatz`\index{Starker Dualitätssatz}`{=latex} stimmt, müssen beide denselben Zielwert liefern, ohne dass eines vom anderen „weiß“.
```python
@@ -5555,7 +5567,7 @@ Der Zukauf für 18 €/h ist hochprofitabel: Jede zusätzliche Prüfstunde bring
Der vorige Abschnitt hat gezeigt, wie wertvoll Schattenpreise sind. Dieser zeigt die zwei Fälle, in denen sie eine Entscheidung **nicht** tragen — und wie man beide erkennt, bevor jemand auf ihrer Grundlage eine Maschine kauft.
-
**Was Sie in der Abbildung sehen.** Jeder Wert innerhalb der farbigen Balken erfüllt die Optimalitätsbedingungen. Zwei Solver dürfen also verschiedene Schattenpreise melden und haben beide recht — die Frage „was ist eine Stunde wert?" hat hier schlicht keine eindeutige Antwort.
@@ -6222,6 +6234,11 @@ Skript löst die LP-Relaxationen und kappt nach denselben drei Regeln, die oben
* seine LP-Schranke schlechter ist als die beste bereits gefundene ganzzahlige Lösung (der **Incumbent**`\index{Branch-and-Bound!Incumbent}`{=latex}),
* oder die LP-Lösung bereits ganzzahlig ist (dann ist dieser Ast fertig).
+
+
+**Was Sie in der Abbildung sehen.** Der rote Streifen ist genau das, was Phase 2 wegnimmt — und in ihm liegt kein einziger grauer Punkt. Deshalb ist Verzweigen verlustfrei. Weggenommen wird trotzdem etwas Wertvolles: das LP-Optimum (3; 1,5) selbst. Genau darum sinkt die Schranke von 21 auf 20,67, und genau dieses Sinken ist der Fortschritt, den der Suchbaum Knoten für Knoten protokolliert.
+
Zusätzlich fügen Solver **Schnittebenen**`\index{Schnittebenen}`{=latex} (*cutting planes*, z. B. Gomory-Cuts`\index{Schnittebenen!Gomory-Cuts}`{=latex}) hinzu: gültige Ungleichungen, die gebrochene Bereiche wegschneiden, ohne einen einzigen zulässigen ganzzahligen Punkt zu entfernen.
> **✏️ Handrechnung 6.2: Ein Suchbaum von Hand**
@@ -6246,7 +6263,7 @@ Zusätzlich fügen Solver **Schnittebenen**`\index{Schnittebenen}`{=latex} (*cut
Hier kommt das Handwerkszeug, das MILP so mächtig macht:`\index{Big-M-Methode}`{=latex} Man übersetzt Geschäftsregeln in lineare Ungleichungen — mithilfe binärer Hilfsvariablen $y \in \{0,1\}$ und einer hinreichend großen Konstante $M$.
-
**Was Sie in der Abbildung sehen.** Für $y \in \{0,1\}$ sind beide Modelle gleichwertig; die beiden blauen Punkte stimmen überein. Verschieden ist nur, was dazwischen erlaubt ist — und rechts genügt bereits $y = 0{,}08$, um die volle Kapazität freizugeben.
@@ -7693,6 +7710,11 @@ Der **CP-SAT-Solver**`\index{CP-SAT-Solver}`{=latex} von Google OR-Tools verbind
>
> **Und wenn ein Widerspruch auftritt?** Dann lernt der SAT-Kern eine **Sperrklausel** (*nogood*): „Diese Kombination nie wieder probieren.“ Der Solver springt dann nicht nur einen Schritt zurück, sondern direkt zur Ursache des Konflikts (*non-chronological backtracking*).
+
+
+**Was Sie in der Abbildung sehen.** Jede Runde streicht genau **einen** Wert — und trotzdem sinkt die Zahl der Kombinationen von 16 auf 9, also um gut vierzig Prozent. Das ist der Hebel: Gestrichen wird nicht eine Kombination, sondern ein Wert, und mit ihm alle Kombinationen, in denen er vorkam.
+
```python
#!/usr/bin/env python3
@@ -8273,11 +8295,14 @@ Erreichter Makespan: 11
==========================================================================
```
-
+
*Eine interaktive Fassung dieser Grafik steht auf den Kapitelseiten der Website bereit.*
-**Lesen Sie das Gantt-Diagramm.** Auftrag 1 kommt zuerst an die Fräse (0–2), Auftrag 0 muss warten und ist dort erst von 2 bis 5 an der Reihe. Auffällig ist die Lücke in der Dreherei bei Zeitpunkt 4: Die Maschine steht still, obwohl Auftrag 0 schon Arbeit hätte — er ist aber bis 5 noch an der Fräse. Dieses **Warten ist unvermeidlich**; es entsteht aus dem Zusammenspiel von Reihenfolge- und Maschinenbedingungen, nicht aus einer schlechten Planung.
+**Lesen Sie das Gantt-Diagramm.** Auftrag 1 kommt zuerst an die Fräse (0–2), Auftrag 0 muss warten und ist dort erst von 2 bis 5 an der Reihe. Auffällig sind die beiden schraffierten Lücken. Die in der Dreherei bei Zeitpunkt 4 ist die sprechendere: Die Maschine steht still, obwohl Auftrag 0 schon Arbeit hätte — er ist aber bis 5 noch an der Fräse. Dieses **Warten ist unvermeidlich**; es entsteht aus dem Zusammenspiel von Reihenfolge- und Maschinenbedingungen, nicht aus einer schlechten Planung.
+
+Die gestrichelte Linie bei 10 sagt dasselbe von der anderen Seite. Sie ist die untere Schranke aus dem Programm, und sie stammt von der Dreherei: Deren drei Arbeitsgänge dauern zusammen 2 + 4 + 4 = 10 Zeiteinheiten, die sich nicht überlappen dürfen. Die Dreherei ist im Plan von 0 bis 11 belegt — also genau ihre 10 Einheiten Arbeit plus die eine schraffierte Einheit Stillstand. Der Plan ist somit nicht deshalb elf lang, weil schlecht geplant wurde, sondern weil die Engpassmaschine einmal warten muss. (Die zweite Lücke, in der Lackiererei, kostet nichts: Diese Maschine ist ohnehin nicht der Engpass.)
> **⚠️ Mehrere gleich gute Pläne — und was das für Ihre Tests bedeutet**
>
@@ -15778,7 +15803,7 @@ dem CO2-Preis, den das Unternehmen ohnehin ansetzt.
Die Front hat **zehn** Punkte. Ein Raster aus 1 401 Gewichten findet **sechs** davon. Vier Kompromisse sind für kein Gewicht erreichbar — und das ist keine Frage der Rasterweite.

+Hülle und sind über Gewichte erreichbar; die vier offenen Kreise sind pareto-optimal, werden aber von keiner Geraden gestützt. Die dünne Gerade ist eine solche Stützgerade, die punktierte die parallele Gerade durch den am weitesten abliegenden der vier Punkte. Erzeugt von `bilder_04/erzeuge_pareto_front.py`.](bilder_04/kap_mehrziel_pareto.svg)
*Eine interaktive Fassung dieser Grafik steht auf den Kapitelseiten der Website bereit.*
@@ -15786,6 +15811,8 @@ Hülle und sind über Gewichte erreichbar; die vier offenen Kreise sind pareto-o
Das Programm rechnet diese Hülle in `untere_huelle()` unabhängig aus und vergleicht: Genau die vier Punkte, die das Gewichtsraster verfehlt, liegen strikt oberhalb. Die Gegenprobe steht in der Ausgabe.
+**Was Sie in der Abbildung sehen.** Die beiden Geraden haben dieselbe Steigung, also dasselbe Gewicht $w$. Die untere berührt die Hülle in zwei Ecken — eine von beiden meldet der Solver als Optimum. Die obere geht durch einen pareto-optimalen Punkt, der damit für dieses Gewicht chancenlos ist, und zwar nicht knapp: Er liegt eine ganze Stufe darüber. Verschieben Sie die Steigung, verschiebt sich die Berührung von einer Ecke zur nächsten — über den Punkt dazwischen springt sie hinweg. Die waagerechte Linie zeigt den Ausweg, um den es in [Abschnitt 14.7](#sec:mehrziel-epsilon) geht: Eine Schranke für den CO₂-Ausstoß statt eines Gewichts findet genau diesen Punkt.
+
> **⚠️ Der Grund liegt in der Ganzzahligkeit**
>
> Bei einem reinen LP ist der zulässige Bereich konvex, und die Pareto-Front liegt vollständig auf ihrer eigenen konvexen Hülle — dort findet die gewichtete Summe alles. Sobald Entscheidungen **ganzzahlig** werden (welcher Träger, welches Lager, welche Schicht), zerfällt der Bereich in einzelne Punkte, und zwischen ihnen entstehen Einbuchtungen.
@@ -19842,6 +19869,11 @@ CVaR: Portfolio 101.33 vs. Summe 159.24 -> subadditiv (kohaerent)
Und Teil 2 zeigt das Grundproblem des VaR an einem Beispiel, das Sie von Hand nachrechnen können: Einzeln meldet er für jede Anleihe einen *Gewinn* von 2 €, im Portfolio einen *Verlust* von 98 €. Diversifikation hätte demnach das Risiko um 102 € erhöht. Der CVaR dagegen verhält sich korrekt: 101,33 € im Portfolio gegenüber 159,24 € bei getrennter Betrachtung — die Streuung **senkt** das Risiko, wie es sein muss.
+
+
+**Was Sie in der Abbildung sehen.** Links wechselt der Balken über null das Vorzeichen: Aus zwei gemeldeten Gewinnen wird im Portfolio ein Verlust. Rechts passiert nichts dergleichen — der Portfoliobalken ist kürzer als der Summenbalken, und genau das heißt Subadditivität. Der Grund für den Sprung links ist keine Rechenungenauigkeit, sondern die Bauart des Maßes: Eine Schwelle bei 95 % sieht ein Ereignis mit 4 % Wahrscheinlichkeit nicht — bis zwei davon zusammenkommen.
+
> **⚠️ Eine Falle bei der CVaR-Berechnung** Der naheliegende Weg — „Mittelwert aller Verluste $\ge$ VaR“ — ist **falsch**, sobald die Verteilung **Atome** hat (also einzelne Werte mit positiver Wahrscheinlichkeit, wie hier die zwei möglichen Ausgänge). Der VaR liegt dann selbst auf einem Atom, und der Vergleich `>=` erfasst zu viel Wahrscheinlichkeitsmasse. Im Beispiel oben liefert dieser naive Schätzer für Anleihe A den Wert 2,08 statt der korrekten 79,59 — ein Fehler um Faktor 38.
>
> Die Rockafellar-Uryasev-Formel behandelt Atome von sich aus korrekt. Verwenden Sie sie auch dann, wenn Sie „nur schnell“ einen CVaR ausrechnen wollen.
@@ -19850,7 +19882,7 @@ Und Teil 2 zeigt das Grundproblem des VaR an einem Beispiel, das Sie von Hand na
## 20.4 Value at Risk und Conditional Value at Risk {#sec:cvar-value-at-risk-und-conditional-value}
-
**Value at Risk ($\text{VaR}_\alpha$):**`\index{Value at Risk (VaR)}`{=latex} Der Verlust, der mit Wahrscheinlichkeit $\alpha$ nicht überschritten wird.
@@ -19925,7 +19957,7 @@ $$
= \lVert \mathbf{w}-\mathbf{w}_{\text{alt}}\rVert_1
$$
-
+
> **📐 Formel-Lesehilfe** Die $L_1$-Norm ist die Summe der **Beträge**. Sie misst, wie viel Prozent des Portfolios insgesamt bewegt werden — Käufe und Verkäufe zusammen.
>
@@ -22050,7 +22082,8 @@ Alle drei Antworten stehen bis hierhin in Tabellen. Tabellen liest niemand in ei
## 22.5 Architektur einer produktionsreifen OR-Plattform {#sec:praxisfallen-architektur-einer-produktionsreifen-or-plattform}
-
+
In einer professionellen Umgebung ist der Optimierer kein Skript, sondern ein **versionierter, zustandsloser Dienst**:
@@ -22063,6 +22096,8 @@ In einer professionellen Umgebung ist der Optimierer kein Skript, sondern ein **
| **Ausführung** | Kalender-Sync, Benachrichtigung, Order-Routing | Kalender-APIs, Messaging, Broker |
| **Betrieb** | Monitoring, Audit-Trail, Alarme | Logging, Metriken, Versionierung |
+**Was Sie in der Abbildung sehen.** Fünf der sechs Schichten bilden einen Weg: Daten gehen oben hinein und unten als Handlung wieder hinaus. Die sechste steht daneben, und das ist keine zeichnerische Verlegenheit — Monitoring und Audit-Trail sind keine Station, die man durchläuft, sondern eine Anforderung an jede. Wer sie als sechsten Kasten unten anhängt, baut sie am Ende auch so: als etwas, das man hinterher noch macht.
+
**Fünf Prinzipien, die sich in der Praxis bewährt haben:**
1. **Snapshot-Prinzip.**`\index{Snapshot-Prinzip}`{=latex} Jeder Optimierungslauf arbeitet auf einem unveränderlichen Datenschnappschuss mit eigener ID. Nur so ist ein Ergebnis später reproduzierbar — und im Streitfall belegbar.
diff --git a/Operations_Research_mit_Python_Version_04.pdf b/Operations_Research_mit_Python_Version_04.pdf
index 5107196..198bb49 100644
Binary files a/Operations_Research_mit_Python_Version_04.pdf and b/Operations_Research_mit_Python_Version_04.pdf differ
diff --git a/Operations_Research_mit_Python_Version_04/11_Mathematisches_Fundament.md b/Operations_Research_mit_Python_Version_04/11_Mathematisches_Fundament.md
index 516fc10..eb5f8d9 100644
--- a/Operations_Research_mit_Python_Version_04/11_Mathematisches_Fundament.md
+++ b/Operations_Research_mit_Python_Version_04/11_Mathematisches_Fundament.md
@@ -457,6 +457,18 @@ und deren Zahl wächst wieder kombinatorisch.
> Konvex heißt: Wer bergab geht, kommt am tiefsten Punkt an. Nicht-konvex heißt: Wer bergab
> geht, kommt *irgendwo* an — und weiß nicht, ob es der tiefste Punkt war.
+
+
+**Was Sie in der Abbildung sehen.** Links sind es fünf Läufe desselben Verfahrens, die in
+vier verschiedenen Tälern enden — dieselben vier, die Teil 2 von `Konvexitaet_Demo.py`
+weiter unten auflistet. In
+zwei Dimensionen werden aus den Tälern Mulden in einem Gitter, und der Unterschied zwischen
+Start A und Start B beträgt hier bereits mehr als 24 Einheiten. Keiner der beiden Läufe hat
+etwas falsch gemacht: Beide enden dort, wo es nicht mehr bergab geht.
+
### Was ist konvex, was nicht?
| Problemtyp | Konvex? | Konsequenz |
diff --git a/Operations_Research_mit_Python_Version_04/20_Lineare_Programmierung.md b/Operations_Research_mit_Python_Version_04/20_Lineare_Programmierung.md
index 2b41ffc..06d9a2c 100644
--- a/Operations_Research_mit_Python_Version_04/20_Lineare_Programmierung.md
+++ b/Operations_Research_mit_Python_Version_04/20_Lineare_Programmierung.md
@@ -181,9 +181,16 @@ Ecke zu Ecke — und zwar immer in Richtung Verbesserung.

+**Was Sie in der Abbildung sehen.** Durch jede besuchte Ecke läuft genau eine Niveaulinie —
+die Linien sind nicht gewählt, sondern die Zielwerte 0 €, 2.000 € und 2.375 € der drei Ecken.
+Der Simplex schiebt diese Linie in Richtung des Pfeils so weit hinaus, wie das Polyeder es
+zulässt. Dass er dabei an einer Ecke anhält und nicht mittendrin, ist keine Eigenheit des
+Verfahrens, sondern der Fundamentalsatz: Weiter geht es nur noch hinaus.
+
### Der Ablauf
1. **Initialisierung.** Starte an einer bekannten Ecke — meist dem Ursprung
@@ -540,6 +547,16 @@ Prüfen wir das an unserem Beispiel: $\mathbf{b}^\top\mathbf{y}^*
= 40 \cdot 12{,}5 + 60 \cdot 31{,}25 + 8 \cdot 0 = 500 + 1875 + 0 = 2375$ ✓ — identisch mit
$Z^* = 2375$.
+
+
+**Was Sie in der Abbildung sehen.** Die beiden Tafeln haben nichts gemeinsam: verschiedene
+Achsen, verschiedene Einheiten, links ein geschlossenes Polyeder, rechts ein nach oben
+offener Bereich. Links wird maximiert, rechts minimiert. Und doch steht an beiden Optima
+dieselbe Zahl — 2.375 €. Genau das behauptet der Satz, und mehr ist an ihm nicht zu sehen.
+
Das lässt sich auch unabhängig von der Handrechnung nachweisen: Primal- und Dualproblem
werden als **zwei getrennte, voneinander unabhängige LPs** aufgestellt und gelöst — wenn
der starke Dualitätssatz{idx:Starker Dualitätssatz} stimmt, müssen beide denselben Zielwert liefern, ohne dass eines
diff --git a/Operations_Research_mit_Python_Version_04/21_MILP.md b/Operations_Research_mit_Python_Version_04/21_MILP.md
index a67ad6b..b68d961 100644
--- a/Operations_Research_mit_Python_Version_04/21_MILP.md
+++ b/Operations_Research_mit_Python_Version_04/21_MILP.md
@@ -328,6 +328,16 @@ wird verworfen, weil sein Wert 18 den Incumbent 20 aus Ast A nicht schlägt. Erz
(der **Incumbent**{idx:Branch-and-Bound!Incumbent}),
* oder die LP-Lösung bereits ganzzahlig ist (dann ist dieser Ast fertig).
+
+
+**Was Sie in der Abbildung sehen.** Der rote Streifen ist genau das, was Phase 2 wegnimmt —
+und in ihm liegt kein einziger grauer Punkt. Deshalb ist Verzweigen verlustfrei. Weggenommen
+wird trotzdem etwas Wertvolles: das LP-Optimum (3; 1,5) selbst. Genau darum sinkt die
+Schranke von 21 auf 20,67, und genau dieses Sinken ist der Fortschritt, den der Suchbaum
+Knoten für Knoten protokolliert.
+
Zusätzlich fügen Solver **Schnittebenen**{idx:Schnittebenen} (*cutting planes*, z. B. Gomory-Cuts{idx:Schnittebenen!Gomory-Cuts}) hinzu:
gültige Ungleichungen, die gebrochene Bereiche wegschneiden, ohne einen einzigen zulässigen
ganzzahligen Punkt zu entfernen.
diff --git a/Operations_Research_mit_Python_Version_04/22_CP_SAT.md b/Operations_Research_mit_Python_Version_04/22_CP_SAT.md
index 2bef0e2..af0dc70 100644
--- a/Operations_Research_mit_Python_Version_04/22_CP_SAT.md
+++ b/Operations_Research_mit_Python_Version_04/22_CP_SAT.md
@@ -157,6 +157,15 @@ Projektterminierung und Job-Shop-Scheduling.
> einen Schritt zurück, sondern direkt zur Ursache des Konflikts
> (*non-chronological backtracking*).
+
+
+**Was Sie in der Abbildung sehen.** Jede Runde streicht genau **einen** Wert — und trotzdem
+sinkt die Zahl der Kombinationen von 16 auf 9, also um gut vierzig Prozent. Das ist der
+Hebel: Gestrichen wird nicht eine Kombination, sondern ein Wert, und mit ihm alle
+Kombinationen, in denen er vorkam.
+
```python
#!/usr/bin/env python3
@@ -776,15 +785,26 @@ Erreichter Makespan: 11
==========================================================================
```
-
+
{plotly:kap06_jobshop_gantt}
**Lesen Sie das Gantt-Diagramm.** Auftrag 1 kommt zuerst an die Fräse (0–2), Auftrag 0 muss
-warten und ist dort erst von 2 bis 5 an der Reihe. Auffällig ist die Lücke in der Dreherei
-bei Zeitpunkt 4: Die Maschine steht still, obwohl Auftrag 0 schon Arbeit hätte — er ist aber
-bis 5 noch an der Fräse. Dieses **Warten ist unvermeidlich**; es entsteht aus dem
-Zusammenspiel von Reihenfolge- und Maschinenbedingungen, nicht aus einer schlechten Planung.
+warten und ist dort erst von 2 bis 5 an der Reihe. Auffällig sind die beiden schraffierten
+Lücken. Die in der Dreherei bei Zeitpunkt 4 ist die sprechendere: Die Maschine steht still,
+obwohl Auftrag 0 schon Arbeit hätte — er ist aber bis 5 noch an der Fräse. Dieses **Warten
+ist unvermeidlich**; es entsteht aus dem Zusammenspiel von Reihenfolge- und
+Maschinenbedingungen, nicht aus einer schlechten Planung.
+
+Die gestrichelte Linie bei 10 sagt dasselbe von der anderen Seite. Sie ist die untere Schranke
+aus dem Programm, und sie stammt von der Dreherei: Deren drei Arbeitsgänge dauern zusammen
+2 + 4 + 4 = 10 Zeiteinheiten, die sich nicht überlappen dürfen. Die Dreherei ist im Plan von 0
+bis 11 belegt — also genau ihre 10 Einheiten Arbeit plus die eine schraffierte Einheit
+Stillstand. Der Plan ist somit nicht deshalb elf lang, weil schlecht geplant wurde, sondern
+weil die Engpassmaschine einmal warten muss. (Die zweite Lücke, in der Lackiererei, kostet
+nichts: Diese Maschine ist ohnehin nicht der Engpass.)
> **⚠️ Mehrere gleich gute Pläne — und was das für Ihre Tests bedeutet**
>
diff --git a/Operations_Research_mit_Python_Version_04/33_Mehrziel_und_Pareto.md b/Operations_Research_mit_Python_Version_04/33_Mehrziel_und_Pareto.md
index aef5320..8f848eb 100644
--- a/Operations_Research_mit_Python_Version_04/33_Mehrziel_und_Pareto.md
+++ b/Operations_Research_mit_Python_Version_04/33_Mehrziel_und_Pareto.md
@@ -543,7 +543,9 @@ Kompromisse sind für kein Gewicht erreichbar — und das ist keine Frage der Ra

+werden aber von keiner Geraden gestützt. Die dünne Gerade ist eine solche Stützgerade, die
+punktierte die parallele Gerade durch den am weitesten abliegenden der vier Punkte. Erzeugt
+von `bilder_04/erzeuge_pareto_front.py`.](bilder_04/kap_mehrziel_pareto.svg)
{plotly:kap_mehrziel_pareto}
@@ -557,6 +559,15 @@ Das Programm rechnet diese Hülle in `untere_huelle()` unabhängig aus und vergl
die vier Punkte, die das Gewichtsraster verfehlt, liegen strikt oberhalb. Die Gegenprobe
steht in der Ausgabe.
+**Was Sie in der Abbildung sehen.** Die beiden Geraden haben dieselbe Steigung, also dasselbe
+Gewicht $w$. Die untere berührt die Hülle in zwei Ecken — eine von beiden meldet der Solver
+als Optimum. Die obere geht durch einen pareto-optimalen Punkt, der damit für dieses Gewicht
+chancenlos ist, und zwar nicht knapp: Er liegt eine ganze Stufe darüber. Verschieben Sie die
+Steigung, verschiebt sich die Berührung von einer Ecke zur nächsten — über den Punkt
+dazwischen springt sie hinweg. Die waagerechte Linie zeigt den Ausweg, um den es in
+{ref:sec:mehrziel-epsilon} geht: Eine Schranke für den CO₂-Ausstoß statt eines Gewichts
+findet genau diesen Punkt.
+
> **⚠️ Der Grund liegt in der Ganzzahligkeit**
>
> Bei einem reinen LP ist der zulässige Bereich konvex, und die Pareto-Front liegt vollständig
diff --git a/Operations_Research_mit_Python_Version_04/42_CVaR.md b/Operations_Research_mit_Python_Version_04/42_CVaR.md
index bacd723..6936807 100644
--- a/Operations_Research_mit_Python_Version_04/42_CVaR.md
+++ b/Operations_Research_mit_Python_Version_04/42_CVaR.md
@@ -254,6 +254,16 @@ können: Einzeln meldet er für jede Anleihe einen *Gewinn* von 2 €, im Portfo
dagegen verhält sich korrekt: 101,33 € im Portfolio gegenüber 159,24 € bei getrennter
Betrachtung — die Streuung **senkt** das Risiko, wie es sein muss.
+
+
+**Was Sie in der Abbildung sehen.** Links wechselt der Balken über null das Vorzeichen: Aus
+zwei gemeldeten Gewinnen wird im Portfolio ein Verlust. Rechts passiert nichts dergleichen —
+der Portfoliobalken ist kürzer als der Summenbalken, und genau das heißt Subadditivität. Der
+Grund für den Sprung links ist keine Rechenungenauigkeit, sondern die Bauart des Maßes: Eine
+Schwelle bei 95 % sieht ein Ereignis mit 4 % Wahrscheinlichkeit nicht — bis zwei davon
+zusammenkommen.
+
> **⚠️ Eine Falle bei der CVaR-Berechnung**
> Der naheliegende Weg — „Mittelwert aller Verluste $\ge$ VaR“ — ist **falsch**, sobald die
> Verteilung **Atome** hat (also einzelne Werte mit positiver Wahrscheinlichkeit, wie hier
diff --git a/Operations_Research_mit_Python_Version_04/50_Praxis.md b/Operations_Research_mit_Python_Version_04/50_Praxis.md
index b02bbb0..634390b 100644
--- a/Operations_Research_mit_Python_Version_04/50_Praxis.md
+++ b/Operations_Research_mit_Python_Version_04/50_Praxis.md
@@ -918,7 +918,9 @@ und das ist weniger trivial, als es aussieht:
## Architektur einer produktionsreifen OR-Plattform {#sec:praxisfallen-architektur-einer-produktionsreifen-or-plattform}
-
+
In einer professionellen Umgebung ist der Optimierer kein Skript, sondern ein
**versionierter, zustandsloser Dienst**:
@@ -932,6 +934,12 @@ In einer professionellen Umgebung ist der Optimierer kein Skript, sondern ein
| **Ausführung** | Kalender-Sync, Benachrichtigung, Order-Routing | Kalender-APIs, Messaging, Broker |
| **Betrieb** | Monitoring, Audit-Trail, Alarme | Logging, Metriken, Versionierung |
+**Was Sie in der Abbildung sehen.** Fünf der sechs Schichten bilden einen Weg: Daten gehen
+oben hinein und unten als Handlung wieder hinaus. Die sechste steht daneben, und das ist
+keine zeichnerische Verlegenheit — Monitoring und Audit-Trail sind keine Station, die man
+durchläuft, sondern eine Anforderung an jede. Wer sie als sechsten Kasten unten anhängt, baut
+sie am Ende auch so: als etwas, das man hinterher noch macht.
+
**Fünf Prinzipien, die sich in der Praxis bewährt haben:**
1. **Snapshot-Prinzip.**{idx:Snapshot-Prinzip} Jeder Optimierungslauf arbeitet auf einem unveränderlichen
diff --git a/PROGRESS.md b/PROGRESS.md
index fa4e6bd..a98eff4 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -2539,6 +2539,59 @@ Neu: `bilder_04/stil_04.py` bündelt Palette, `rcParams` und `speichere()`. `net
entgegen der Planung **nicht** gebraucht — `pyproject.toml` bleibt unverändert.
PDF von 781 auf **788 Seiten**, 44 Diagramme, 27 Generatoren.
+### ✅ Grafik-Upgrade, Stufe 2: die neun Überarbeitungen
+
+Erledigt: Konvexität, Simplex-Pfad, Primal/Dual, Branch-and-Bound, Propagation, Gantt,
+Pareto, VaR/CVaR, OR-Architektur.
+
+**Der Befund, der die Arbeit geprägt hat: „Überarbeiten" hieß fast nie „ersetzen".** Acht der
+neun vorhandenen Diagramme zeigten etwas anderes als das, was der Auftrag verlangte — die
+Sehnenbedingung neben der Talkarte, den Suchbaum neben der Polyedergeometrie, die
+Verlustverteilung neben der Subadditivität. Sie zu überschreiben hätte Inhalt vernichtet.
+Also sind sechs neue Bilder danebengetreten, und nur an drei Stellen wurde ein vorhandener
+Generator erweitert. Beim ersten Bild hatte ich `kap02_konvexitaet.svg` bereits überschrieben,
+bevor der Auftragstext („**Ergänzung** zu Abb. 2.2") das widerlegte — zurückgenommen mit
+`git checkout`.
+
+**Der einzige echte Ersatz war zugleich ein Fund:** `kap15_or_plattform.svg` war
+handgezeichnet und zeigte **fünf** Schichten, während die Tabelle unmittelbar daneben
+**sechs** nennt — „Betrieb" (Monitoring, Audit-Trail, Alarme) fehlte, „Audit-Trail" kam nur
+noch als Pfeilbeschriftung vor. `erzeuge_or_architektur.py` liest die Schichten jetzt **aus
+dieser Tabelle** und bricht ab, wenn deren Aufbau sich ändert. Damit ist das Kriterium aus
+6c geschärft: Schematisch heißt nur dann „kein Generator nötig", wenn das Bild keine
+Aufzählung und keine Zahl aus dem Text wiederholt.
+
+**Vier eigene Fehler, die die Selbsttests gefunden haben:**
+
+* Die Prüfung der Pareto-Tangente stand **verkehrt herum** — bei einer *unteren* konvexen
+ Hülle liegen alle Punkte **über** der Stützgeraden, nicht darunter. Der Test schlug an,
+ bevor das Bild jemanden in die Irre führen konnte.
+* `erzeuge_or_architektur.py` trug **fünf** beschriftete Übergänge für **vier** Pfeile; die
+ fünfte („Freigabe") wurde still nicht gezeichnet. Meine erste Zählprüfung war um eins
+ daneben und ließ es durch.
+* Wieder `.replace(",", ".")` auf einen **mehrteiligen** f-String — aus `Delle: (13978, 5912)`
+ wurde `(13978. 5912)`. Derselbe Fehler wie mehrfach zuvor; die Zahl wird jetzt getrennt
+ formatiert.
+* Im Gantt-Text hatte ich behauptet, die eine Zeiteinheit zwischen Schranke 10 und Makespan 11
+ stecke „in den beiden schraffierten Feldern". Sie steckt allein in der Dreherei — der
+ Engpassmaschine. Der Generator rechnet die Summe jetzt nach: 10 Arbeit + 1 Stillstand = 11.
+
+**Nachweise:** Alle 53 Bilddateien sind über zwei Läufe byte-identisch. Die drei erweiterten
+Generatoren haben ihre *anderen* Ausgaben nicht verändert — `kap_fundament_polyeder.svg` und
+beide Plotly-Fassungen sind bitgleich geblieben. Zahlen gegen das Buch geprüft: die fünf
+BFGS-Läufe der Konvexität ziffergenau, $Z^*=W^*=2375$ bei der Dualität, 16 → 12 → 9 bei der
+Propagation, 21 → 20,67 bei Branch-and-Bound, und beim CVaR alle vier Zeilen einschließlich
+der Unterscheidung 79,59 gegen 79,66 — was beweist, dass die Ziehungsreihenfolge des
+Buchprogramms getroffen ist.
+
+PDF von 788 auf **792 Seiten**, 48 Kapiteldiagramme (38 erzeugt, 10 schematisch),
+33 Generatoren, davon 17 auf `stil_04.py`.
+
+**Offen, weil es eine Entscheidung des Autors ist:** Die Bildunterschriften tragen im PDF
+eine **doppelte Nummer** — „Abbildung 21: Abb. 7.2: …". Die erste vergibt LaTeX, die zweite
+`nummeriere_marken()`. Das betrifft alle 48 Abbildungen und besteht seit Langem; behoben wäre
+es dadurch, dass die LaTeX-Nummerierung der Gleitumgebung unterdrückt wird.
+
---
## 8. Commit-Historie des V04-Strangs
diff --git a/bilder_04/erzeuge_3d_konvexitaet.py b/bilder_04/erzeuge_3d_konvexitaet.py
new file mode 100644
index 0000000..e175c68
--- /dev/null
+++ b/bilder_04/erzeuge_3d_konvexitaet.py
@@ -0,0 +1,195 @@
+#!/usr/bin/env python3
+
+# erzeuge_3d_konvexitaet.py
+"""
+Erzeugt die Landschaftsgrafik zum Abschnitt Konvexitaet:
+
+ bilder_04/kap02_konvexitaet_landschaft.svg
+
+ERGAENZT das vorhandene kap02_konvexitaet.svg, ersetzt es nicht: Jenes zeigt die
+Sehnenbedingung - die Definition. Dieses zeigt ihre Folge - dass bei einer
+nicht-konvexen Funktion der Startpunkt darueber entscheidet, was am Ende
+herauskommt.
+
+Beide Tafeln zeigen DIESELBE Funktion wie das Kapitelprogramm
+Konvexitaet_Demo.py:
+
+ links f(x) = x^2 + 3 sin(3x) - eindimensional
+ rechts f(x1, x2) = x1^2 + 3 sin(3 x1) + x2^2 + 3 sin(3 x2) - separabel
+
+Die rechte Funktion ist die separable Fortsetzung der linken: Auf jeder Achse
+liegt exakt die Kurve der linken Tafel. Deshalb ist die rechte Tafel keine neue
+Behauptung, sondern dieselbe eine Dimension weiter - und die Taeler, die links
+nebeneinander liegen, werden rechts zu einem Gitter aus Mulden.
+
+Gerechnet wird mit BFGS aus denselben Startpunkten wie im Buchprogramm. Die
+fuenf Ergebnisse der linken Tafel muessen deshalb Ziffer fuer Ziffer der im
+Kapitel abgedruckten Tabelle entsprechen; pruefe_gegen_buch() bricht ab, wenn
+nicht. Ein Bild, das andere Minima zeigt als die Tabelle daneben, waere
+schlimmer als gar kein Bild.
+
+Aufruf (aus dem Repository-Wurzelverzeichnis):
+ python3 bilder_04/erzeuge_3d_konvexitaet.py
+
+Benoetigt: numpy, scipy, matplotlib
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+
+import numpy as np
+from scipy.optimize import minimize
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from stil_04 import FARBEN, speichere # noqa: E402
+import matplotlib.pyplot as plt # noqa: E402
+
+# Die Startpunkte des Buchprogramms - nicht selbst gewaehlt.
+STARTPUNKTE = [-3.0, -1.5, 0.0, 1.5, 3.0]
+
+# Die im Kapitel abgedruckte Tabelle: Start -> (Minimum, Funktionswert).
+# Nur zum Gegenpruefen; gerechnet wird unabhaengig davon.
+TABELLE_IM_BUCH = {
+ -3.0: (-2.4280, 3.3695),
+ -1.5: (-0.4874, -2.7448),
+ 0.0: (-0.4874, -2.7448),
+ 1.5: (1.4606, -0.7042),
+ 3.0: (3.3817, 9.4570),
+}
+
+
+def welle(x):
+ """Die Funktion des Buchprogramms: Parabel mit aufmodulierter Welle."""
+ return x ** 2 + 3.0 * np.sin(3.0 * x)
+
+
+def welle_1d(x):
+ """Dieselbe Funktion in der Form, die scipy.optimize erwartet."""
+ return welle(x[0])
+
+
+def welle_2d(x):
+ """Separable Fortsetzung in zwei Dimensionen."""
+ return welle(x[0]) + welle(x[1])
+
+
+def suche_1d():
+ """Lokale Suche von jedem Startpunkt - wie in Teil 2 des Buchprogramms."""
+ ergebnisse = []
+ for start in STARTPUNKTE:
+ res = minimize(welle_1d, x0=[start], method="BFGS")
+ ergebnisse.append((start, float(res.x[0]), float(res.fun)))
+ return ergebnisse
+
+
+def suche_2d(start):
+ """Dieselbe lokale Suche in 2D, mit aufgezeichnetem Weg."""
+ weg = [np.array(start, dtype=float)]
+ minimize(welle_2d, x0=list(start), method="BFGS",
+ callback=lambda xk: weg.append(np.array(xk, dtype=float)))
+ return np.array(weg)
+
+
+def pruefe_gegen_buch(ergebnisse) -> None:
+ """Bild und abgedruckte Tabelle muessen dieselben Minima nennen."""
+ for start, ort, wert in ergebnisse:
+ soll_ort, soll_wert = TABELLE_IM_BUCH[start]
+ if abs(ort - soll_ort) > 5e-4 or abs(wert - soll_wert) > 5e-4:
+ raise SystemExit(
+ f"Konvexitaets-Landschaft: Start {start:+.1f} fuehrt zu "
+ f"x = {ort:.4f} (f = {wert:.4f}), das Kapitel druckt "
+ f"x = {soll_ort:.4f} (f = {soll_wert:.4f}) ab.")
+ gefunden = len({round(ort, 3) for _, ort, _ in ergebnisse})
+ if gefunden != 4:
+ raise SystemExit(
+ f"Konvexitaets-Landschaft: {gefunden} verschiedene Minima, das "
+ f"Kapitel druckt 4 ab.")
+
+
+def zeichne(ergebnisse, wege) -> None:
+ figur, achsen = plt.subplots(1, 2, figsize=(10.0, 4.3))
+
+ # --- links: die Funktion des Buchprogramms mit ihren fuenf Laeufen -----
+ links = achsen[0]
+ x = np.linspace(-3.8, 3.8, 800)
+ links.plot(x, welle(x), "-", color=FARBEN["haupt"], linewidth=2.0,
+ zorder=3)
+ bester = min(ergebnisse, key=lambda z: z[2])
+ for start, ort, wert in ergebnisse:
+ global_ = abs(ort - bester[1]) < 1e-3
+ farbe = FARBEN["gut"] if global_ else FARBEN["fehler"]
+ links.annotate("", xy=(ort, wert), xytext=(start, welle(start)),
+ arrowprops=dict(arrowstyle="->", color=farbe,
+ linewidth=1.4,
+ connectionstyle="arc3,rad=0.18"),
+ zorder=4)
+ links.plot(start, welle(start), "o", color=farbe, markersize=6,
+ zorder=5)
+ links.plot(ort, wert, "*", color=farbe, markersize=13, zorder=6)
+ links.set_xlabel("$x$")
+ links.set_ylabel("$f(x)$")
+ links.set_title("fünf Startpunkte, vier verschiedene Minima",
+ fontsize=10.5, color=FARBEN["text"])
+ links.annotate("grün: im tiefsten Tal gelandet\nrot: in einem anderen",
+ xy=(0.36, 0.97), xycoords="axes fraction", fontsize=8.5,
+ color=FARBEN["text"], va="top")
+
+ # --- rechts: dieselbe Funktion, eine Dimension weiter ------------------
+ rechts = achsen[1]
+ gitter = np.linspace(-3.8, 3.8, 420)
+ xx, yy = np.meshgrid(gitter, gitter)
+ zz = welle(xx) + welle(yy)
+ rechts.contourf(xx, yy, zz, levels=28, cmap="Blues_r", alpha=0.7, zorder=1)
+ rechts.contour(xx, yy, zz, levels=16, colors=[FARBEN["linie"]],
+ linewidths=0.5, zorder=2)
+ for name, weg, farbe, versatz, ausrichtung in wege:
+ rechts.plot(weg[:, 0], weg[:, 1], "-", color=farbe, linewidth=2.2,
+ zorder=4)
+ rechts.plot(*weg[0], "o", color=farbe, markersize=7, zorder=5)
+ rechts.plot(*weg[-1], "*", color=farbe, markersize=15, zorder=6)
+ rechts.annotate(f"{name}\nf = {welle_2d(weg[-1]):.2f}",
+ xy=weg[-1], xytext=versatz,
+ textcoords="offset points", fontsize=8.5,
+ color=farbe, fontweight="bold", ha=ausrichtung,
+ zorder=7)
+ rechts.set_xlabel("$x_1$")
+ rechts.set_ylabel("$x_2$")
+ rechts.set_title("dieselbe Funktion in zwei Dimensionen",
+ fontsize=10.5, color=FARBEN["text"])
+ rechts.grid(visible=False)
+
+ speichere(figur, "kap02_konvexitaet_landschaft")
+
+
+if __name__ == "__main__":
+ ergebnisse = suche_1d()
+ for start, ort, wert in ergebnisse:
+ print(f" Start {start:>5.1f} -> x = {ort:>8.4f} f = {wert:>8.4f}")
+ pruefe_gegen_buch(ergebnisse)
+ print(" stimmt mit der abgedruckten Tabelle überein (5 Zeilen, 4 Minima)")
+
+ weg_a = suche_2d((-1.4, -1.6))
+ weg_b = suche_2d((2.9, 3.1))
+ if welle_2d(weg_a[-1]) >= welle_2d(weg_b[-1]):
+ raise SystemExit("Konvexitaets-Landschaft: Lauf A landet nicht "
+ "tiefer als Lauf B - Farben und Aussage verkehrt.")
+ if np.linalg.norm(weg_a[-1] - weg_b[-1]) < 1.0:
+ raise SystemExit("Konvexitaets-Landschaft: Beide 2D-Laeufe enden im "
+ "selben Tal - dann ist keine Falle zu sehen.")
+ # Der Begleittext nennt diesen Abstand ("mehr als 24 Einheiten").
+ abstand = welle_2d(weg_b[-1]) - welle_2d(weg_a[-1])
+ if not 24.0 <= abstand < 25.0:
+ raise SystemExit(
+ f"Konvexitaets-Landschaft: Der Abstand der beiden Laeufe betraegt "
+ f"{abstand:.2f} Einheiten. Der Text im Kapitel sagt 'mehr als 24' "
+ f"- beides muss zusammenpassen.")
+ print(f" 2D: Lauf A endet bei f = {welle_2d(weg_a[-1]):.2f}, "
+ f"Lauf B bei f = {welle_2d(weg_b[-1]):.2f}")
+
+ # Versatz und Ausrichtung der Beschriftung: Lauf B endet in der oberen
+ # rechten Ecke, dort muss der Text nach innen zeigen.
+ zeichne(ergebnisse,
+ [("Start A", weg_a, FARBEN["gut"], (16, 16), "left"),
+ ("Start B", weg_b, FARBEN["fehler"], (-14, -30), "right")])
diff --git a/bilder_04/erzeuge_bnb_geometrie.py b/bilder_04/erzeuge_bnb_geometrie.py
new file mode 100644
index 0000000..bfc5ed8
--- /dev/null
+++ b/bilder_04/erzeuge_bnb_geometrie.py
@@ -0,0 +1,189 @@
+#!/usr/bin/env python3
+
+# erzeuge_bnb_geometrie.py
+"""
+Erzeugt die geometrische Tafel zum Branch-and-Bound im MILP-Kapitel:
+
+ bilder_04/kap_milp_bnb_geometrie.svg
+
+ERGAENZT den Suchbaum kap_milp_suchbaum.svg, ersetzt ihn nicht: Der Baum zeigt,
+WELCHE Teilprobleme entstehen. Diese Tafel zeigt, WAS Verzweigen mit dem
+Loesungsraum macht - und warum es erlaubt ist.
+
+Links das Polyeder der LP-Relaxation mit seinem Optimum (3; 1,5). Rechts
+dasselbe Polyeder, aus dem der Streifen 1 < x2 < 2 herausgeschnitten ist; uebrig
+bleiben die Aeste A (x2 <= 1) und B (x2 >= 2) mit ihren eigenen Optima.
+
+Der Punkt, auf den es ankommt: In dem herausgeschnittenen Streifen liegt KEIN
+einziger ganzzahliger Punkt - es geht also nichts verloren. Der Streifen
+enthaelt aber das Optimum der Relaxation, und deshalb sinkt die Schranke von 21
+auf 20,67 bzw. 18. Verzweigen heisst, wertlosen Raum wegzuschneiden, bis der
+beste verbleibende Punkt ganzzahlig ist.
+
+Modell und LP-Loeser stammen aus erzeuge_branch_and_bound.py, damit Baum und
+Geometrie nicht auseinanderlaufen koennen. pruefe_befund() rechnet nach, dass im
+Streifen wirklich kein ganzzahliger Punkt liegt und dass die drei Zielwerte die
+des Baums sind.
+
+Aufruf (aus dem Repository-Wurzelverzeichnis):
+ python3 bilder_04/erzeuge_bnb_geometrie.py
+
+Benoetigt: numpy, scipy, matplotlib
+"""
+
+from __future__ import annotations
+
+import itertools
+import os
+import sys
+
+import numpy as np
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from stil_04 import FARBEN, speichere # noqa: E402
+import matplotlib.pyplot as plt # noqa: E402
+from erzeuge_branch_and_bound import A, B, C, loese # noqa: E402
+
+# Die Verzweigung, die der Baum als Erstes vornimmt: x2 <= 1 gegen x2 >= 2.
+GRENZE = 1.0
+
+# Was der Suchbaum ausweist; nur zum Gegenpruefen.
+SCHRANKEN_IM_BAUM = {"P0": 21.0, "A": 62.0 / 3.0, "B": 18.0}
+
+
+def gitter(masche: int = 420):
+ x1 = np.linspace(0, 4.6, masche)
+ x2 = np.linspace(0, 3.4, masche)
+ return np.meshgrid(x1, x2)
+
+
+def zulaessig(xx1, xx2, unten=None, oben=None):
+ """Maske des zulaessigen Bereichs, wahlweise mit zusaetzlichen Schranken."""
+ maske = np.ones_like(xx1, dtype=bool)
+ for zeile, grenze in zip(A, B):
+ maske &= (zeile[0] * xx1 + zeile[1] * xx2 <= grenze + 1e-9)
+ if unten is not None:
+ maske &= (xx2 >= unten - 1e-9)
+ if oben is not None:
+ maske &= (xx2 <= oben + 1e-9)
+ return maske
+
+
+def ganzzahlige_punkte():
+ """Alle ganzzahligen zulaessigen Punkte - fuer die Tafel und den Test."""
+ punkte = []
+ for p1, p2 in itertools.product(range(0, 6), range(0, 5)):
+ if np.all(A @ np.array([p1, p2], dtype=float) <= B + 1e-9):
+ punkte.append((p1, p2))
+ return punkte
+
+
+def pruefe_befund(loesungen, punkte) -> None:
+ """Die beiden Aussagen der Tafel muessen stimmen."""
+ im_streifen = [p for p in punkte if GRENZE < p[1] < GRENZE + 1]
+ if im_streifen:
+ raise SystemExit(
+ f"B&B-Geometrie: Im herausgeschnittenen Streifen liegen die "
+ f"ganzzahligen Punkte {im_streifen}. Dann waere das Verzweigen "
+ f"nicht verlustfrei, und die Tafel behauptete etwas Falsches.")
+ for name, soll in SCHRANKEN_IM_BAUM.items():
+ ist = loesungen[name][1]
+ if abs(ist - soll) > 1e-6:
+ raise SystemExit(
+ f"B&B-Geometrie: {name} hat den Zielwert {ist:.4f}, der "
+ f"Suchbaum weist {soll:.4f} aus.")
+
+
+def zahl(wert: float) -> str:
+ return f"{wert:.2f}".rstrip("0").rstrip(".").replace(".", ",")
+
+
+def zeichne(loesungen, punkte) -> None:
+ figur, achsen = plt.subplots(1, 2, figsize=(10.0, 4.5))
+ xx1, xx2 = gitter()
+
+ for achse in achsen:
+ for p1, p2 in punkte:
+ achse.plot(p1, p2, "o", color=FARBEN["gedaempft"], markersize=4.5,
+ zorder=5)
+ for zeile, grenze in zip(A, B):
+ x = np.linspace(0, 4.6, 100)
+ achse.plot(x, (grenze - zeile[0] * x) / zeile[1], "--",
+ color=FARBEN["linie"], linewidth=1.1, zorder=3)
+ achse.set_xlim(0, 4.6)
+ achse.set_ylim(0, 3.4)
+ achse.set_xlabel("$x_1$")
+ achse.grid(linestyle=":", alpha=0.4)
+
+ achsen[0].set_ylabel("$x_2$")
+
+ # --- links: die Relaxation, ungeschnitten -----------------------------
+ achsen[0].contourf(xx1, xx2, zulaessig(xx1, xx2).astype(float),
+ levels=[0.5, 1.5], colors=[FARBEN["haupt"]],
+ alpha=0.20, zorder=1)
+ x_p0, z_p0 = loesungen["P0"]
+ achsen[0].plot(*x_p0, "*", color=FARBEN["fehler"], markersize=17, zorder=6)
+ achsen[0].annotate(f"LP-Optimum ({zahl(x_p0[0])}; {zahl(x_p0[1])})\n"
+ f"Z = {zahl(z_p0)} — nicht ganzzahlig",
+ xy=x_p0, xytext=(12, 12), textcoords="offset points",
+ fontsize=8.5, color=FARBEN["fehler"],
+ fontweight="bold", zorder=7)
+ achsen[0].set_title("$P_0$ — die Relaxation", fontsize=10.5,
+ color=FARBEN["text"])
+
+ # --- rechts: derselbe Bereich, der Streifen heraus ---------------------
+ for name, unten, oben, farbe, beschriftung in (
+ ("A", None, GRENZE, FARBEN["zweit"], f"Ast A: $x_2 \\leq 1$"),
+ ("B", GRENZE + 1, None, FARBEN["warnung"],
+ f"Ast B: $x_2 \\geq 2$")):
+ achsen[1].contourf(xx1, xx2,
+ zulaessig(xx1, xx2, unten, oben).astype(float),
+ levels=[0.5, 1.5], colors=[farbe], alpha=0.22,
+ zorder=1)
+ x_ast, z_ast = loesungen[name]
+ achsen[1].plot(*x_ast, "*", color=farbe, markersize=15, zorder=6)
+ # Ast A liegt an der Schnittkante; sein Text gehoert nach unten in die
+ # eigene Flaeche, sonst laeuft er in den Streifen hinein.
+ achsen[1].annotate(f"{beschriftung}\nZ = {zahl(z_ast)}",
+ xy=x_ast,
+ xytext=(-12, -30) if name == "A" else (12, 14),
+ textcoords="offset points", fontsize=8.5,
+ ha="right" if name == "A" else "left",
+ color=farbe, fontweight="bold", zorder=7)
+
+ achsen[1].axhspan(GRENZE, GRENZE + 1, color=FARBEN["fehler"], alpha=0.11,
+ zorder=2)
+ achsen[1].axhline(GRENZE, color=FARBEN["fehler"], linewidth=1.3, zorder=4)
+ achsen[1].axhline(GRENZE + 1, color=FARBEN["fehler"], linewidth=1.3,
+ zorder=4)
+ achsen[1].text(4.5, GRENZE + 0.5,
+ "herausgeschnitten:\n$1 < x_2 < 2$\nkein ganzzahliger "
+ "Punkt darin", fontsize=8, color=FARBEN["fehler"],
+ ha="right", va="center", zorder=7)
+ achsen[1].set_title("nach dem Verzweigen über $x_2$", fontsize=10.5,
+ color=FARBEN["text"])
+
+ figur.suptitle(f"Verzweigen schneidet weg, wo kein ganzzahliger Punkt "
+ f"liegt — die Schranke sinkt von {zahl(z_p0)} auf "
+ f"{zahl(max(loesungen['A'][1], loesungen['B'][1]))}",
+ fontsize=10.5, color=FARBEN["text"], y=1.02)
+ speichere(figur, "kap_milp_bnb_geometrie")
+
+
+if __name__ == "__main__":
+ unendlich = float("inf")
+ loesungen = {
+ "P0": loese([0.0, 0.0], [unendlich, unendlich]),
+ "A": loese([0.0, 0.0], [unendlich, GRENZE]),
+ "B": loese([0.0, GRENZE + 1], [unendlich, unendlich]),
+ }
+ punkte = ganzzahlige_punkte()
+ for name, (x, z) in loesungen.items():
+ print(f" {name:<3} x = ({x[0]:.4f}; {x[1]:.4f}) Z = {z:.4f}")
+ print(f" {len(punkte)} ganzzahlige zulässige Punkte")
+
+ pruefe_befund(loesungen, punkte)
+ print(" Streifen 1 < x₂ < 2 enthält keinen ganzzahligen Punkt")
+ print(" Zielwerte stimmen mit dem Suchbaum überein")
+
+ zeichne(loesungen, punkte)
diff --git a/bilder_04/erzeuge_dualitaets_bruecke.py b/bilder_04/erzeuge_dualitaets_bruecke.py
new file mode 100644
index 0000000..15288b9
--- /dev/null
+++ b/bilder_04/erzeuge_dualitaets_bruecke.py
@@ -0,0 +1,190 @@
+#!/usr/bin/env python3
+
+# erzeuge_dualitaets_bruecke.py
+"""
+Erzeugt die Doppeltafel zum starken Dualitaetssatz:
+
+ bilder_04/kap_lp_dualitaets_bruecke.svg
+
+ERGAENZT das vorhandene kap04_primal_dual.svg, ersetzt es nicht: Jenes stellt
+die beiden Probleme schematisch gegenueber - wer fragt was. Dieses zeigt ihre
+Geometrie und damit den Satz selbst.
+
+Links der primale Mengenraum (x1, x2): ein beschraenktes Polyeder, das Maximum
+in einer Ecke. Rechts der duale Preisraum (y1, y2): ein nach oben offener
+Bereich, das Minimum ebenfalls in einer Ecke. Die beiden Bereiche haben nichts
+miteinander gemein - andere Achsen, andere Einheiten, der eine beschraenkt, der
+andere nicht. Trotzdem steht an beiden Optima dieselbe Zahl. Das ist der Satz,
+und das ist der einzige Grund fuer die Doppeltafel.
+
+Der duale Raum hat drei Variablen; gezeichnet wird die Ebene y3 = 0. Das ist
+keine Vereinfachung, sondern folgt aus dem komplementaeren Schlupf: Das
+Marktlimit x1 <= 8 ist bei x1* = 7,5 nicht bindend, also ist y3* = 0. Der
+Selbsttest rechnet nach, dass das Minimum in dieser Ebene wirklich das Minimum
+des vollen Dualproblems ist - sonst zeigte die rechte Tafel einen zu grossen
+Wert und der optische Beweis waere keiner.
+
+Modell und Loesung stammen aus dem Kapitelprogramm Dualitaet_Nachweis.py; hier
+wird nichts nachgebaut und nichts eingetragen.
+
+Aufruf (aus dem Repository-Wurzelverzeichnis):
+ python3 bilder_04/erzeuge_dualitaets_bruecke.py
+
+Benoetigt: numpy, scipy, matplotlib
+"""
+
+from __future__ import annotations
+
+import itertools
+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 Dualitaet_Nachweis import (A_PRIMAL, B_PRIMAL, # noqa: E402
+ C_PRIMAL, loese)
+
+
+def ecken(A: np.ndarray, b: np.ndarray) -> np.ndarray:
+ """Ecken des Bereichs {x >= 0 : A x <= b}, gegen den Uhrzeigersinn.
+
+ Alle Paare von Randgeraden schneiden (die Achsen zaehlen mit), die
+ unzulaessigen Schnittpunkte verwerfen, den Rest um den Schwerpunkt
+ sortieren. Fuer zwei Dimensionen ist das vollstaendig."""
+ zeilen = np.vstack([A, [[-1.0, 0.0], [0.0, -1.0]]])
+ rechte = np.concatenate([b, [0.0, 0.0]])
+ punkte = []
+ for i, j in itertools.combinations(range(len(zeilen)), 2):
+ M = zeilen[[i, j]]
+ if abs(np.linalg.det(M)) < 1e-9:
+ continue
+ p = np.linalg.solve(M, rechte[[i, j]])
+ if np.all(zeilen @ p <= rechte + 1e-9):
+ punkte.append(p)
+ punkte = np.unique(np.round(punkte, 9), axis=0)
+ mitte = punkte.mean(axis=0)
+ winkel = np.arctan2(punkte[:, 1] - mitte[1], punkte[:, 0] - mitte[0])
+ return punkte[np.argsort(winkel)]
+
+
+def zeichne(x_stern, z_stern, y_stern, w_stern) -> None:
+ figur, achsen = plt.subplots(1, 2, figsize=(10.2, 4.6))
+ beschriftung = f"{z_stern:,.0f} €".replace(",", ".")
+
+ # --- links: der primale Mengenraum ------------------------------------
+ links = achsen[0]
+ polygon = ecken(A_PRIMAL, B_PRIMAL)
+ links.fill(polygon[:, 0], polygon[:, 1], color=FARBEN["haupt"],
+ alpha=0.18, zorder=2)
+ links.plot(np.append(polygon[:, 0], polygon[0, 0]),
+ np.append(polygon[:, 1], polygon[0, 1]), "-",
+ color=FARBEN["haupt"], linewidth=1.8, zorder=3)
+
+ x1 = np.linspace(0, 11, 200)
+ # Ein Niveau unter dem Optimum, das Optimum selbst, eines darueber - das
+ # obere liegt sichtbar ausserhalb des Polyeders, und genau das ist die
+ # Aussage: weiter geht es nicht.
+ for niveau, stil in ((1500.0, ":"), (z_stern, "--"), (3000.0, ":")):
+ # 150 x1 + 250 x2 = niveau
+ links.plot(x1, (niveau - C_PRIMAL[0] * x1) / C_PRIMAL[1], stil,
+ color=FARBEN["warnung"], linewidth=1.4, zorder=4)
+ links.annotate("", xy=(4.2, 4.2), xytext=(2.4, 3.1),
+ arrowprops=dict(arrowstyle="-|>", color=FARBEN["warnung"],
+ linewidth=2.0), zorder=5)
+ links.text(4.35, 4.25, r"$\mathbf{c} = (150, 250)^\top$", fontsize=8.5,
+ color=FARBEN["warnung"])
+
+ links.plot(*x_stern, "*", color=FARBEN["gut"], markersize=17, zorder=6)
+ links.annotate(f"$\\mathbf{{x}}^* = ({x_stern[0]:.1f}\\,;\\,"
+ f"{x_stern[1]:.0f})$\n$Z^* = {beschriftung}$",
+ xy=x_stern, xytext=(4.55, 1.05), textcoords="data",
+ fontsize=9, color=FARBEN["gut"], fontweight="bold",
+ ha="left", zorder=7,
+ arrowprops=dict(arrowstyle="-", color=FARBEN["gut"],
+ linewidth=1.0, alpha=0.7))
+ links.set_xlim(0, 11)
+ links.set_ylim(0, 9)
+ links.set_xlabel("$x_1$ — Bots Typ A")
+ links.set_ylabel("$x_2$ — Bots Typ B")
+ links.set_title("primal: Mengen — maximieren, beschränkt",
+ fontsize=10.5, color=FARBEN["text"])
+
+ # --- rechts: der duale Preisraum, Ebene y3 = 0 ------------------------
+ rechts = achsen[1]
+ g1 = np.linspace(0, 55, 320)
+ g2 = np.linspace(0, 55, 320)
+ yy1, yy2 = np.meshgrid(g1, g2)
+ # A^T y >= c, hier ohne y3: 2 y1 + 4 y2 >= 150 und 5 y1 + 6 y2 >= 250
+ zulaessig = ((A_PRIMAL[0, 0] * yy1 + A_PRIMAL[1, 0] * yy2
+ >= C_PRIMAL[0] - 1e-9)
+ & (A_PRIMAL[0, 1] * yy1 + A_PRIMAL[1, 1] * yy2
+ >= C_PRIMAL[1] - 1e-9))
+ rechts.contourf(yy1, yy2, zulaessig.astype(float), levels=[0.5, 1.5],
+ colors=[FARBEN["zweit"]], alpha=0.18, zorder=2)
+ for spalte, farbe in ((0, FARBEN["zweit"]), (1, FARBEN["zweit"])):
+ # A[0, spalte] * y1 + A[1, spalte] * y2 = c[spalte]
+ rechts.plot(g1, (C_PRIMAL[spalte] - A_PRIMAL[0, spalte] * g1)
+ / A_PRIMAL[1, spalte], "-", color=farbe, linewidth=1.8,
+ zorder=3)
+
+ for niveau, stil in ((1200.0, ":"), (w_stern, "--"), (3600.0, ":")):
+ # 40 y1 + 60 y2 = niveau
+ rechts.plot(g1, (niveau - B_PRIMAL[0] * g1) / B_PRIMAL[1], stil,
+ color=FARBEN["warnung"], linewidth=1.4, zorder=4)
+
+ rechts.plot(y_stern[0], y_stern[1], "*", color=FARBEN["gut"],
+ markersize=17, zorder=6)
+ rechts.annotate(f"$\\mathbf{{y}}^* = ({y_stern[0]:.2f}\\,;\\,"
+ f"{y_stern[1]:.2f})$\n$W^* = "
+ f"{f'{w_stern:,.0f}'.replace(',', '.')}$ €",
+ xy=(y_stern[0], y_stern[1]), xytext=(14, 16),
+ textcoords="offset points", fontsize=9,
+ color=FARBEN["gut"], fontweight="bold", zorder=7)
+ rechts.annotate("zulässig: nach oben offen —\nteurer geht immer",
+ xy=(0.04, 0.97), xycoords="axes fraction", fontsize=8.5,
+ color=FARBEN["text"], va="top")
+ rechts.set_xlim(0, 55)
+ rechts.set_ylim(0, 55)
+ rechts.set_xlabel("$y_1$ — Preis je vCPU-Stunde")
+ rechts.set_ylabel("$y_2$ — Preis je GB RAM")
+ rechts.set_title("dual: Preise — minimieren, unbeschränkt",
+ fontsize=10.5, color=FARBEN["text"])
+
+ figur.suptitle(f"Zwei verschiedene Räume, ein gemeinsamer Wert: "
+ f"{beschriftung}",
+ fontsize=11.5, color=FARBEN["text"], y=1.01)
+ speichere(figur, "kap_lp_dualitaets_bruecke")
+
+
+if __name__ == "__main__":
+ primal, dual = loese()
+ x_stern, z_stern = primal.x, -primal.fun
+ y_stern, w_stern = dual.x, dual.fun
+ print(f" primal: x* = ({x_stern[0]:.4f}, {x_stern[1]:.4f}) "
+ f"Z* = {z_stern:.4f}")
+ print(f" dual: y* = ({y_stern[0]:.4f}, {y_stern[1]:.4f}, "
+ f"{y_stern[2]:.4f}) W* = {w_stern:.4f}")
+
+ if abs(z_stern - w_stern) > 1e-6:
+ raise SystemExit(
+ f"Dualitaets-Bruecke: Z* = {z_stern:.6f}, W* = {w_stern:.6f}. "
+ f"Die Tafel behauptet einen gemeinsamen Wert - er ist keiner.")
+ # Die rechte Tafel zeigt die Ebene y3 = 0. Das ist nur zulaessig, wenn
+ # das Optimum dort liegt.
+ if abs(y_stern[2]) > 1e-9:
+ raise SystemExit(
+ f"Dualitaets-Bruecke: y3* = {y_stern[2]:.6f} ist nicht null. Die "
+ f"gezeichnete Ebene y3 = 0 enthaelt das Optimum dann nicht.")
+ print(f" starker Dualitätssatz bestätigt: "
+ f"{z_stern:,.0f} €".replace(",", "."))
+ print(f" y3* = 0 — die gezeichnete Ebene enthält das Optimum")
+
+ zeichne(x_stern, z_stern, y_stern, w_stern)
diff --git a/bilder_04/erzeuge_kap06_gantt.py b/bilder_04/erzeuge_kap06_gantt.py
index 65ee732..4d81981 100644
--- a/bilder_04/erzeuge_kap06_gantt.py
+++ b/bilder_04/erzeuge_kap06_gantt.py
@@ -91,9 +91,43 @@ def loese() -> tuple[list[tuple[int, int, int, int]], int]:
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.2))
+ 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)],
@@ -108,7 +142,21 @@ def zeichne_statisch(plan, makespan: int) -> None:
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)
+
+ 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)
@@ -170,6 +218,40 @@ def zeichne_interaktiv(plan, makespan: int) -> None:
if __name__ == "__main__":
plan, makespan = loese()
- print(f"Makespan: {makespan}")
+ 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)
diff --git a/bilder_04/erzeuge_or_architektur.py b/bilder_04/erzeuge_or_architektur.py
new file mode 100644
index 0000000..f60d253
--- /dev/null
+++ b/bilder_04/erzeuge_or_architektur.py
@@ -0,0 +1,214 @@
+#!/usr/bin/env python3
+
+# erzeuge_or_architektur.py
+"""
+Erzeugt das Schichtenbild der OR-Plattform im Kapitel Praxisfallen:
+
+ bilder_04/kap15_or_plattform.svg
+
+ERSETZT die bisherige, von Hand gezeichnete Fassung. Der Anlass war ein
+Widerspruch: Die Tabelle im Abschnitt nennt SECHS Schichten, das gezeichnete
+Bild daneben zeigte nur FUENF. Die sechste - "Betrieb" mit Monitoring,
+Audit-Trail und Alarmen - fehlte; "Audit-Trail" kam nur noch als Beschriftung
+eines Pfeils vor. Genau so entstehen Bilder, die dem Text daneben
+widersprechen, und genau deshalb bekommt dieses hier einen Generator.
+
+Die Schichten werden NICHT in diesem Skript aufgezaehlt, sondern aus der
+Tabelle des Kapitels gelesen. Damit gibt es nur eine Quelle: Wer eine Zeile in
+50_Praxis.md ergaenzt, ergaenzt das Bild mit. Fehlt die Tabelle oder aendert
+sich ihr Aufbau, bricht der Generator ab, statt ein halbes Bild zu schreiben.
+
+Dargestellt wird die Kette als das, was sie ist: Die ersten fuenf Schichten
+sind ein Datenweg von den Quellsystemen bis zur Ausfuehrung. "Betrieb" ist
+keine Station auf diesem Weg, sondern liegt neben allen - Monitoring und
+Audit-Trail betreffen jede Schicht. Als sechster Kasten in der Reihe waere sie
+falsch dargestellt; deshalb steht sie als Spange daneben. Dass sie beim
+Handzeichnen ganz verschwand, hat vermutlich denselben Grund.
+
+Aufruf (aus dem Repository-Wurzelverzeichnis):
+ python3 bilder_04/erzeuge_or_architektur.py
+
+Benoetigt: matplotlib
+"""
+
+from __future__ import annotations
+
+import os
+import re
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from stil_04 import FARBEN, speichere # noqa: E402
+import matplotlib.pyplot as plt # noqa: E402
+from matplotlib.patches import FancyArrowPatch, FancyBboxPatch # noqa: E402
+
+BASIS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+KAPITEL = os.path.join(BASIS, "Operations_Research_mit_Python_Version_04",
+ "50_Praxis.md")
+UEBERSCHRIFT = "## Architektur einer produktionsreifen OR-Plattform"
+
+# Was zwischen den Schichten fliesst. Steht nicht in der Tabelle - die
+# beschreibt die Schichten, nicht die Uebergaenge. Fuenf Stationen in der
+# Kette ergeben VIER Pfeile; lies_schichten() rechnet das nach.
+UEBERGAENGE = [
+ "Events · REST · Polling",
+ "geprüfter Snapshot (JSON)",
+ "Ergebnis + Kostenzerlegung",
+ "Freigabe",
+]
+
+# Die Schicht, die neben der Kette steht statt in ihr.
+QUERSCHNITT = "Betrieb"
+
+
+def saeubere(zelle: str) -> str:
+ """Markdown-Auszeichnung und Indexmarken aus einer Tabellenzelle nehmen."""
+ zelle = re.sub(r"\{idx:[^}]*\}", "", zelle)
+ zelle = zelle.replace("**", "").replace("`", "")
+ return zelle.strip()
+
+
+def lies_schichten() -> list[tuple[str, str, str]]:
+ """Die Schichtentabelle des Kapitels als Liste (Schicht, Aufgabe, Werkzeuge).
+
+ Einzige Quelle fuer den Bildinhalt - siehe Modulkommentar."""
+ with open(KAPITEL, encoding="utf-8") as datei:
+ text = datei.read()
+ if UEBERSCHRIFT not in text:
+ raise SystemExit(f"OR-Architektur: Überschrift '{UEBERSCHRIFT}' nicht "
+ f"in {os.path.basename(KAPITEL)} gefunden.")
+ rest = text.split(UEBERSCHRIFT, 1)[1]
+
+ zeilen = []
+ gefunden = False
+ for zeile in rest.splitlines():
+ if zeile.startswith("| Schicht |"):
+ gefunden = True
+ continue
+ if not gefunden:
+ continue
+ if zeile.startswith("| ---"):
+ continue
+ if not zeile.startswith("|"):
+ break
+ felder = [saeubere(f) for f in zeile.strip().strip("|").split("|")]
+ if len(felder) != 3:
+ raise SystemExit(f"OR-Architektur: Tabellenzeile mit "
+ f"{len(felder)} statt 3 Spalten: {zeile!r}")
+ zeilen.append(tuple(felder))
+
+ if not zeilen:
+ raise SystemExit("OR-Architektur: Die Schichtentabelle wurde nicht "
+ "gefunden. Hat sich der Abschnitt geändert?")
+ if zeilen[-1][0] != QUERSCHNITT:
+ raise SystemExit(
+ f"OR-Architektur: Die letzte Tabellenzeile heißt "
+ f"'{zeilen[-1][0]}', erwartet wurde '{QUERSCHNITT}'. Diese "
+ f"Schicht wird als Spange neben der Kette gezeichnet, nicht in "
+ f"ihr - die Zuordnung muss stimmen.")
+ # Die Kette sind alle Zeilen ausser der Querschnittsschicht; zwischen n
+ # Stationen liegen n-1 Pfeile.
+ if len(zeilen) - 2 != len(UEBERGAENGE):
+ raise SystemExit(
+ f"OR-Architektur: {len(zeilen) - 1} Stationen in der Kette "
+ f"brauchen {len(zeilen) - 2} beschriftete Übergänge, es sind "
+ f"aber {len(UEBERGAENGE)}. Beim Ergänzen einer Schicht gehört "
+ f"ein Übergang dazu.")
+ return zeilen
+
+
+def umbreche(text: str, breite: int) -> str:
+ """Einfacher Wortumbruch - textwrap.fill ohne den Import."""
+ zeilen, aktuell = [], ""
+ for wort in text.split():
+ if aktuell and len(aktuell) + 1 + len(wort) > breite:
+ zeilen.append(aktuell)
+ aktuell = wort
+ else:
+ aktuell = f"{aktuell} {wort}".strip()
+ if aktuell:
+ zeilen.append(aktuell)
+ return "\n".join(zeilen)
+
+
+def zeichne(schichten) -> None:
+ kette = schichten[:-1]
+ quer = schichten[-1]
+
+ figur, achse = plt.subplots(figsize=(8.8, 6.6))
+ achse.set_axis_off()
+
+ kastenbreite, kastenhoehe = 6.4, 1.24
+ abstand = 1.88
+ links = 0.0
+
+ for stufe, (name, aufgabe, werkzeuge) in enumerate(kette):
+ unten = -stufe * abstand
+ achse.add_patch(FancyBboxPatch(
+ (links, unten), kastenbreite, kastenhoehe,
+ boxstyle="round,pad=0.02,rounding_size=0.10",
+ facecolor=FARBEN["flaeche"], edgecolor=FARBEN["haupt"],
+ linewidth=1.6, zorder=3))
+ achse.text(links + 0.18, unten + kastenhoehe - 0.26, name,
+ fontsize=10.5, color=FARBEN["haupt"], fontweight="bold",
+ va="center", zorder=4)
+ achse.text(links + 0.18, unten + kastenhoehe - 0.52,
+ umbreche(aufgabe, 66), fontsize=8.5,
+ color=FARBEN["text"], va="top", zorder=4)
+ achse.text(links + 0.18, unten + 0.17,
+ umbreche(werkzeuge, 70), fontsize=8,
+ color=FARBEN["gedaempft"], va="bottom", style="italic",
+ zorder=4)
+
+ if stufe < len(kette) - 1:
+ pfeilspitze = unten - abstand + kastenhoehe
+ achse.add_patch(FancyArrowPatch(
+ (links + kastenbreite / 2, unten),
+ (links + kastenbreite / 2, pfeilspitze),
+ arrowstyle="-|>", mutation_scale=16,
+ color=FARBEN["zweit"], linewidth=1.6, zorder=2))
+ achse.text(links + kastenbreite / 2 + 0.16,
+ (unten + pfeilspitze) / 2, UEBERGAENGE[stufe],
+ fontsize=8, color=FARBEN["zweit"], va="center",
+ zorder=4)
+
+ # Die Querschnittsschicht als Spange neben der ganzen Kette.
+ unten_ganz = -(len(kette) - 1) * abstand
+ spange_links = links + kastenbreite + 0.45
+ achse.add_patch(FancyBboxPatch(
+ (spange_links, unten_ganz), 0.92,
+ (len(kette) - 1) * abstand + kastenhoehe,
+ boxstyle="round,pad=0.02,rounding_size=0.10",
+ facecolor=FARBEN["warnung"], alpha=0.13,
+ edgecolor=FARBEN["warnung"], linewidth=1.6, zorder=3))
+ achse.text(spange_links + 0.46,
+ unten_ganz + ((len(kette) - 1) * abstand + kastenhoehe) / 2,
+ f"{quer[0]} — {quer[1]}\n{quer[2]}",
+ fontsize=9, color=FARBEN["warnung"], fontweight="bold",
+ ha="center", va="center", rotation=90, zorder=4)
+
+ achse.text(links, kastenhoehe + 0.42,
+ "Der Optimierer ist kein Skript, sondern ein Dienst",
+ fontsize=11.5, color=FARBEN["text"], fontweight="bold")
+ achse.text(links, -(len(kette) - 1) * abstand - 0.42,
+ f"„{quer[0]}“ ist keine Station auf dem Weg — Monitoring und "
+ f"Audit-Trail betreffen jede Schicht.\nDeshalb steht sie "
+ f"neben der Kette, nicht in ihr.",
+ fontsize=8.5, color=FARBEN["gedaempft"], va="top")
+
+ achse.set_xlim(-0.3, spange_links + 1.5)
+ achse.set_ylim(-(len(kette) - 1) * abstand - 1.3, kastenhoehe + 0.85)
+ achse.set_aspect("equal")
+
+ speichere(figur, "kap15_or_plattform")
+
+
+if __name__ == "__main__":
+ schichten = lies_schichten()
+ for name, aufgabe, werkzeuge in schichten:
+ markierung = " (Spange)" if name == QUERSCHNITT else ""
+ print(f" {name:<22}{markierung}")
+ print(f" {len(schichten)} Schichten aus der Tabelle in "
+ f"{os.path.basename(KAPITEL)} gelesen — "
+ f"{len(schichten) - 1} in der Kette, 1 quer.")
+ zeichne(schichten)
diff --git a/bilder_04/erzeuge_pareto_front.py b/bilder_04/erzeuge_pareto_front.py
index cb59146..c4c1808 100644
--- a/bilder_04/erzeuge_pareto_front.py
+++ b/bilder_04/erzeuge_pareto_front.py
@@ -107,6 +107,72 @@ def untere_huelle(punkte):
return huelle
+def tiefste_delle(front, huelle):
+ """Der unerreichbare Punkt mit dem groessten Abstand zur Huelle.
+
+ Liefert (Punkt, linke Huellenecke, rechte Huellenecke, Abstand). Genau an
+ diesem Punkt ist die Luecke am besten zu sehen, und genau dort wird sie
+ beschriftet - gesucht wird sie, nicht ausgewaehlt.
+
+ Gemessen wird SENKRECHT zur Huellenkante, nicht senkrecht nach oben. Der
+ senkrechte Abstand ist der Abstand eines Punktes zu einer Geraden; der
+ Abstand in y-Richtung haengt dagegen davon ab, welche Zielgroesse man auf
+ die Hochachse legt - bei vertauschten Achsen faende er eine andere Delle."""
+ unerreichbar = [p for p in front if p not in huelle]
+ if not unerreichbar:
+ return None
+ bestes = None
+ for punkt in unerreichbar:
+ for links, rechts in zip(huelle, huelle[1:]):
+ if not links[0] <= punkt[0] <= rechts[0]:
+ continue
+ if rechts[0] == links[0]:
+ continue
+ steigung = (rechts[1] - links[1]) / (rechts[0] - links[0])
+ auf_huelle = links[1] + steigung * (punkt[0] - links[0])
+ abstand = (punkt[1] - auf_huelle) / np.hypot(1.0, steigung)
+ if bestes is None or abstand > bestes[3]:
+ bestes = (punkt, links, rechts, abstand)
+ return bestes
+
+
+def zeichne_luecke(achse, front, huelle) -> None:
+ """Warum die gewichtete Summe die Delle nicht erreicht - und was hilft.
+
+ Gezeichnet werden zwei Geraden derselben Steigung: die Tangente an die
+ Huelle und die parallele Gerade durch den unerreichbaren Punkt. Eine
+ gewichtete Summe minimiert genau diese Steigung; sie waehlt also immer die
+ untere der beiden Geraden und damit nie den Punkt darueber. Der
+ waagerechte Strich zeigt die Alternative: eine CO2-Schranke, unter der
+ dieser Punkt der billigste ist - so ist die Front oben ueberhaupt
+ entstanden."""
+ delle = tiefste_delle(front, huelle)
+ if delle is None:
+ return
+ punkt, links, rechts, _abstand = delle
+ steigung = ((rechts[1] - links[1]) / (rechts[0] - links[0])
+ if rechts[0] != links[0] else 0.0)
+
+ x = np.array(achse.get_xlim())
+ achse.plot(x, links[1] + steigung * (x - links[0]), "-", color=INDIGO,
+ linewidth=1.1, alpha=0.75, zorder=1)
+ achse.plot(x, punkt[1] + steigung * (x - punkt[0]), ":", color=AMBER,
+ linewidth=1.6, zorder=2)
+ achse.annotate("dieselbe Steigung, eine Stufe höher —\n"
+ "jede gewichtete Summe nimmt die untere",
+ xy=(punkt[0], punkt[1]), xytext=(12, 26),
+ textcoords="offset points", fontsize=7.5, color=AMBER,
+ zorder=6)
+
+ achse.axhline(punkt[1], color="#059669", linestyle="--", linewidth=1.2,
+ alpha=0.9, zorder=1)
+ achse.annotate(f"ε-Constraint: CO₂ ≤ {punkt[1]:,} kg".replace(",", ".")
+ + "\nfindet genau diesen Punkt",
+ xy=(x[0], punkt[1]), xytext=(6, -22),
+ textcoords="offset points", fontsize=7.5, color="#059669",
+ zorder=6)
+
+
# --- Statische Fassung ------------------------------------------------------
def zeichne_statisch(front, huelle) -> None:
@@ -132,6 +198,9 @@ def zeichne_statisch(front, huelle) -> None:
achse.set_xlabel("Transportkosten (EUR)")
achse.set_ylabel("CO₂-Ausstoß (kg)")
achse.set_title("Pareto-Front: Kosten gegen CO₂", fontsize=11)
+ # Erst jetzt, damit die Geraden die endgueltigen Achsengrenzen benutzen.
+ achse.autoscale(enable=False)
+ zeichne_luecke(achse, front, huelle)
achse.grid(linestyle=":", alpha=0.5)
for rand in ("top", "right"):
achse.spines[rand].set_visible(False)
@@ -203,5 +272,49 @@ if __name__ == "__main__":
huelle = untere_huelle(front)
print(f"Pareto-Punkte: {len(front)}, davon gestuetzt: {len(huelle)}, "
f"unerreichbar: {len(front) - len(huelle)}")
+
+ # Die beiden Behauptungen des Luecken-Bildes nachrechnen, statt sie
+ # hinzuschreiben.
+ delle = tiefste_delle(front, huelle)
+ if delle is not None:
+ punkt, links, rechts, abstand = delle
+ steigung = (rechts[1] - links[1]) / (rechts[0] - links[0])
+
+ def auf_tangente(x):
+ return links[1] + steigung * (x - links[0])
+
+ # Eine Stuetzgerade der UNTEREN Huelle liegt unter allen Punkten und
+ # beruehrt die beiden Ecken der Kante. Beides zusammen macht sie zur
+ # Tangente; nur eines davon reicht nicht.
+ for ecke in huelle:
+ if ecke[1] < auf_tangente(ecke[0]) - 1e-6:
+ raise SystemExit(
+ f"Pareto-Luecke: Die Huellenecke {ecke} liegt UNTER der "
+ f"gezeichneten Geraden - dann stuetzt sie die Huelle "
+ f"nicht.")
+ for ecke in (links, rechts):
+ if abs(ecke[1] - auf_tangente(ecke[0])) > 1e-6:
+ raise SystemExit(
+ f"Pareto-Luecke: Die Gerade beruehrt die Huellenecke "
+ f"{ecke} nicht - dann ist sie keine Tangente an diese "
+ f"Kante.")
+ if punkt[1] <= auf_tangente(punkt[0]) + 1e-6:
+ raise SystemExit(
+ f"Pareto-Luecke: {punkt} liegt nicht ueber der Tangente - "
+ f"dann waere der Punkt von einer gewichteten Summe erreichbar.")
+ gefunden = plane(KOSTEN, co2_grenze=punkt[1])
+ if gefunden != punkt:
+ raise SystemExit(
+ f"Pareto-Luecke: Die Schranke CO2 <= {punkt[1]} liefert "
+ f"{gefunden}, nicht {punkt}. Die Bildunterschrift zum "
+ f"ε-Constraint waere falsch.")
+ # Die Zahl getrennt formatieren: Ein .replace(",", ".") auf den ganzen
+ # f-String erwischt auch das Komma im Tupel.
+ abstand_text = f"{abstand:.1f}".replace(".", ",")
+ print(f" tiefste Delle: {punkt}, Abstand zur Hülle "
+ f"{abstand_text} (senkrecht)")
+ print(f" Tangente stützt die Hülle, der Punkt liegt darüber")
+ print(f" ε-Constraint CO₂ ≤ {punkt[1]} liefert genau diesen Punkt")
+
zeichne_statisch(front, huelle)
zeichne_interaktiv(front, huelle)
diff --git a/bilder_04/erzeuge_polyeder.py b/bilder_04/erzeuge_polyeder.py
index 86a86cf..3cec016 100644
--- a/bilder_04/erzeuge_polyeder.py
+++ b/bilder_04/erzeuge_polyeder.py
@@ -50,6 +50,7 @@ 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"
+VIOLETT = "#7c3aed" # Niveaulinien und Zielfunktionsgradient
# Modell wortgleich zu Visualisierung_Loesungsraum.py
C = np.array([150.0, 250.0])
@@ -200,12 +201,46 @@ def zeichne_polyeder(ecken: np.ndarray, bestes: np.ndarray) -> None:
schreibe(figur, "kap_fundament_polyeder")
+def zeichne_niveaulinien(achse, besucht: list[np.ndarray]) -> None:
+ """Eine Niveaulinie der Zielfunktion durch JEDE besuchte Ecke.
+
+ Die Niveaus werden aus dem Weg gerechnet, nicht gewaehlt: Jede Ecke des
+ Simplexwegs liegt damit auf genau einer Linie, und der Abstand der Linien
+ ist der Fortschritt eines Schritts. Damit zeigt das Bild nicht nur, WELCHEN
+ Weg der Simplex nimmt, sondern warum - er schiebt die Linie so weit nach
+ aussen, wie das Polyeder es zulaesst."""
+ x = np.linspace(-0.9, 11.4, 100)
+ for punkt in besucht:
+ niveau = float(C @ punkt)
+ achse.plot(x, (niveau - C[0] * x) / C[1], linestyle=":",
+ color=VIOLETT, linewidth=1.3, alpha=0.9, zorder=2)
+ # Beschriftung dort, wo die Linie im Bild noch Platz hat. Die
+ # Nulllinie laeuft nahe am unteren Rand - ihre Beschriftung gehoert
+ # darueber, sonst wird sie abgeschnitten.
+ unten = niveau < 500
+ x_text = 1.7 if unten else 10.1
+ y_linie = (niveau - C[0] * x_text) / C[1]
+ achse.text(x_text, y_linie + (0.16 if unten else -0.28),
+ f"Z = {euro(niveau)}", fontsize=7.5, color=VIOLETT,
+ ha="center", va="bottom" if unten else "top", zorder=6)
+
+ # Der Gradient: die Richtung, in die sich die Linien verschieben lassen.
+ start = np.array([2.6, 2.2])
+ ende = start + C / np.linalg.norm(C) * 2.6
+ achse.annotate("", xy=tuple(ende), xytext=tuple(start),
+ arrowprops=dict(arrowstyle="-|>", color=VIOLETT,
+ linewidth=2.2), zorder=6)
+ achse.text(ende[0] + 0.15, ende[1], "$\\mathbf{c} = (150; 250)$",
+ fontsize=8.5, color=VIOLETT, va="center", zorder=6)
+
+
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]
+ zeichne_niveaulinien(achse, besucht)
unbesucht = [e for e in ecken
if not any(np.allclose(e, p) for p in besucht)]
for ecke in unbesucht:
diff --git a/bilder_04/erzeuge_propagation_ablauf.py b/bilder_04/erzeuge_propagation_ablauf.py
new file mode 100644
index 0000000..f0e912f
--- /dev/null
+++ b/bilder_04/erzeuge_propagation_ablauf.py
@@ -0,0 +1,168 @@
+#!/usr/bin/env python3
+
+# erzeuge_propagation_ablauf.py
+"""
+Erzeugt die Ablaufgrafik zur Propagation im Kapitel CP-SAT:
+
+ bilder_04/kap_cpsat_propagation_ablauf.svg
+
+ERGAENZT das vorhandene kap06_cp_sat_propagation.svg, ersetzt es nicht: Jenes
+zeigt schematisch das Zusammenspiel von Propagation und Konfliktlernen. Dieses
+zeigt die Propagation selbst - Runde fuer Runde, an Zahlen.
+
+Gezeigt wird GENAU die Handrechnung des Kapitels:
+
+ x1 in {1,2,3,4}, x2 in {2,3,4,5}, Bedingung x1 + x2 = 7
+
+ Ausgangslage 4 x 4 = 16 Kombinationen
+ nach Runde 1 x1 -> {2,3,4} 12 Kombinationen
+ nach Runde 2 x2 -> {3,4,5} 9 Kombinationen
+
+Bewusst nicht das Beispiel aus der Auftragsbeschreibung ([0,10] mit x1+x2<=6):
+Die Handrechnung steht unmittelbar neben dem Bild. Ein Bild mit anderen Zahlen
+als der Text daneben zwingt den Leser, beide zu vergleichen, statt eines zu
+verstehen.
+
+Die Domains werden PROPAGIERT, nicht eingetragen: revidiere() streicht jeden
+Wert, fuer den es im Partner keinen Partner gibt - das ist Kantenkonsistenz,
+und zwar in derselben Reihenfolge wie in der Handrechnung. Der Selbsttest
+vergleicht das Ergebnis mit den drei im Kapitel abgedruckten Zahlen.
+
+Aufruf (aus dem Repository-Wurzelverzeichnis):
+ python3 bilder_04/erzeuge_propagation_ablauf.py
+
+Benoetigt: matplotlib
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from stil_04 import FARBEN, speichere # noqa: E402
+import matplotlib.pyplot as plt # noqa: E402
+from matplotlib.patches import Rectangle # noqa: E402
+
+# Die Aufgabe der Handrechnung.
+START_X1 = {1, 2, 3, 4}
+START_X2 = {2, 3, 4, 5}
+SUMME = 7
+ANZEIGE = range(1, 7) # 1 bis 6 - alle je vorkommenden Werte
+
+# Was das Kapitel abdruckt; nur zum Gegenpruefen.
+KOMBINATIONEN_IM_BUCH = [16, 12, 9]
+ENDDOMAINS_IM_BUCH = ({2, 3, 4}, {3, 4, 5})
+
+
+def revidiere(eigene: set[int], partner: set[int]) -> set[int]:
+ """Streicht jeden Wert, zu dem es im Partner keinen Partner gibt."""
+ return {v for v in eigene if (SUMME - v) in partner}
+
+
+def propagiere():
+ """Die zwei Runden der Handrechnung, jede als eigener Zustand."""
+ d1, d2 = set(START_X1), set(START_X2)
+ verlauf = [("Ausgangslage", set(d1), set(d2), None)]
+
+ d1 = revidiere(d1, d2)
+ verlauf.append((f"Runde 1 — von $x_1$ aus", set(d1), set(d2), 0))
+
+ d2 = revidiere(d2, d1)
+ verlauf.append((f"Runde 2 — von $x_2$ aus", set(d1), set(d2), 1))
+ return verlauf
+
+
+def zeichne(verlauf) -> None:
+ figur, achse = plt.subplots(figsize=(8.6, 4.6))
+ achse.set_axis_off()
+
+ zellbreite, zellhoehe = 1.0, 0.62
+ zeilenabstand = 1.05
+ blockabstand = 2.6
+
+ for stufe, (titel, d1, d2, geaendert) in enumerate(verlauf):
+ oben = -stufe * blockabstand
+ achse.text(-0.4, oben + 0.75, titel, fontsize=10,
+ color=FARBEN["text"], fontweight="bold", ha="left",
+ va="center")
+
+ for reihe, (name, domain, vorher) in enumerate((
+ ("$x_1$", d1, verlauf[stufe - 1][1] if stufe else d1),
+ ("$x_2$", d2, verlauf[stufe - 1][2] if stufe else d2))):
+ y = oben - reihe * zeilenabstand
+ achse.text(-0.4, y + zellhoehe / 2, name, fontsize=11,
+ color=FARBEN["text"], ha="left", va="center")
+ for spalte, wert in enumerate(ANZEIGE):
+ x = spalte * zellbreite
+ gerade_gestrichen = wert in vorher and wert not in domain
+ if wert in domain:
+ flaeche, rand, schrift = (FARBEN["gut"], FARBEN["gut"],
+ "white")
+ alpha = 0.85
+ elif gerade_gestrichen:
+ flaeche, rand, schrift = (FARBEN["fehler"],
+ FARBEN["fehler"], "white")
+ alpha = 0.75
+ else:
+ flaeche, rand, schrift = (FARBEN["flaeche"],
+ FARBEN["linie"],
+ FARBEN["linie"])
+ alpha = 1.0
+ achse.add_patch(Rectangle((x, y), zellbreite * 0.9, zellhoehe,
+ facecolor=flaeche, edgecolor=rand,
+ alpha=alpha, linewidth=1.0))
+ achse.text(x + zellbreite * 0.45, y + zellhoehe / 2, str(wert),
+ fontsize=10, color=schrift, ha="center",
+ va="center", fontweight="bold")
+ if gerade_gestrichen:
+ achse.plot([x + 0.1, x + zellbreite * 0.8],
+ [y + zellhoehe - 0.08, y + 0.08], "-",
+ color="white", linewidth=1.6)
+
+ anzahl = len(d1) * len(d2)
+ achse.text(len(ANZEIGE) * zellbreite + 0.35, oben - 0.22,
+ f"{len(d1)} × {len(d2)} = {anzahl}\nKombinationen",
+ fontsize=9.5, color=FARBEN["text"], va="center")
+ if geaendert is not None:
+ gestrichen = sorted(verlauf[stufe - 1][1 + geaendert]
+ - (d1, d2)[geaendert])
+ achse.text(len(ANZEIGE) * zellbreite + 0.35, oben - 1.15,
+ "gestrichen: "
+ + ", ".join(str(w) for w in gestrichen),
+ fontsize=9, color=FARBEN["fehler"], va="center")
+
+ achse.text(-0.4, -2 * blockabstand - 1.65,
+ f"Bedingung $x_1 + x_2 = {SUMME}$ — kein einziger Wert wurde "
+ f"ausprobiert. Gestrichen wird,\nwofür es im Partner keinen "
+ f"Partner gibt.",
+ fontsize=9.5, color=FARBEN["text"], va="top")
+
+ achse.set_xlim(-0.6, len(ANZEIGE) * zellbreite + 2.6)
+ achse.set_ylim(-2 * blockabstand - 2.5, 1.2)
+ achse.set_aspect("equal")
+
+ speichere(figur, "kap_cpsat_propagation_ablauf")
+
+
+if __name__ == "__main__":
+ verlauf = propagiere()
+ for titel, d1, d2, _ in verlauf:
+ reintitel = titel.replace("$", "").replace("\\", "")
+ print(f" {reintitel:<24} x1 = {sorted(d1)} x2 = {sorted(d2)} "
+ f"-> {len(d1) * len(d2)} Kombinationen")
+
+ anzahlen = [len(d1) * len(d2) for _, d1, d2, _ in verlauf]
+ if anzahlen != KOMBINATIONEN_IM_BUCH:
+ raise SystemExit(
+ f"Propagations-Ablauf: Die Propagation ergibt {anzahlen} "
+ f"Kombinationen, das Kapitel druckt {KOMBINATIONEN_IM_BUCH} ab.")
+ if (verlauf[-1][1], verlauf[-1][2]) != ENDDOMAINS_IM_BUCH:
+ raise SystemExit(
+ f"Propagations-Ablauf: Enddomains {sorted(verlauf[-1][1])} / "
+ f"{sorted(verlauf[-1][2])} weichen von der Handrechnung "
+ f"{sorted(ENDDOMAINS_IM_BUCH[0])} / "
+ f"{sorted(ENDDOMAINS_IM_BUCH[1])} ab.")
+ print(" stimmt mit der Handrechnung überein: 16 → 12 → 9")
+
+ zeichne(verlauf)
diff --git a/bilder_04/erzeuge_var_vs_cvar.py b/bilder_04/erzeuge_var_vs_cvar.py
new file mode 100644
index 0000000..52e4b49
--- /dev/null
+++ b/bilder_04/erzeuge_var_vs_cvar.py
@@ -0,0 +1,169 @@
+#!/usr/bin/env python3
+
+# erzeuge_var_vs_cvar.py
+"""
+Erzeugt das Subadditivitaets-Bild zum Kapitel CVaR:
+
+ bilder_04/kap_cvar_subadditivitaet.svg
+
+ERGAENZT kap_cvar_var_vergleich.svg, ersetzt es nicht: Jenes zeigt, WAS die
+beiden Kennzahlen sind - eine Schwelle und ein Mittelwert. Dieses zeigt, warum
+der Unterschied zaehlt.
+
+Gerechnet wird das Gegenbeispiel aus Teil 2 von VaR_CVaR_Demo.py: zwei
+unabhaengige Anleihen, je 100 EUR Nominal, Ausfallwahrscheinlichkeit 4 %,
+sonst 2 EUR Kupon. Einzeln meldet der VaR fuer jede Anleihe einen GEWINN von
+2 EUR - denn 4 % liegen unter der 5-%-Schwelle, der Ausfall faellt also aus der
+Betrachtung heraus. Im Portfolio steigt die Wahrscheinlichkeit mindestens eines
+Ausfalls auf 7,8 % und damit UEBER die Schwelle; der VaR springt auf 98 EUR.
+
+Das ist die Aussage des Bildes: Der VaR behauptet, Streuung habe das Risiko um
+102 EUR ERHOEHT. Der CVaR, der den ganzen Rand mittelt statt ihn abzuschneiden,
+kommt auf 101,33 EUR gegenueber 159,24 EUR - Streuung senkt das Risiko, wie es
+sein muss.
+
+Die Ziehungsreihenfolge des Buchprogramms wird nachgespielt: default_rng(2026),
+und Teil 1 des Programms zieht nichts, also sind die beiden Anleihen die ersten
+Ziehungen. Deshalb stehen im Bild genau die Zahlen der abgedruckten Tabelle;
+pruefe_gegen_buch() bricht ab, wenn nicht.
+
+Aufruf (aus dem Repository-Wurzelverzeichnis):
+ python3 bilder_04/erzeuge_var_vs_cvar.py
+
+Benoetigt: numpy, 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 VaR_CVaR_Demo import cvar_rockafellar, var_quantil # noqa: E402
+
+# Instanz wortgleich zu Teil 2 von VaR_CVaR_Demo.py.
+SAAT = 2026
+ZIEHUNGEN = 2_000_000
+AUSFALLWAHRSCHEINLICHKEIT = 0.04
+VERLUST_BEI_AUSFALL = 100.0
+KUPON = 2.0
+
+# Die im Kapitel abgedruckte Tabelle; nur zum Gegenpruefen.
+TABELLE_IM_BUCH = {
+ "Anleihe A allein": (-2.00, 79.59),
+ "Anleihe B allein": (-2.00, 79.66),
+ "Portfolio A+B": (98.00, 101.33),
+ "Summe der Einzelwerte": (-4.00, 159.24),
+}
+
+
+def rechne() -> dict[str, tuple[float, float]]:
+ """Dieselben Ziehungen wie im Buchprogramm - siehe Modulkommentar."""
+ rng = np.random.default_rng(SAAT)
+ verlust_a = np.where(rng.random(ZIEHUNGEN) < AUSFALLWAHRSCHEINLICHKEIT,
+ VERLUST_BEI_AUSFALL, -KUPON)
+ verlust_b = np.where(rng.random(ZIEHUNGEN) < AUSFALLWAHRSCHEINLICHKEIT,
+ VERLUST_BEI_AUSFALL, -KUPON)
+
+ werte = {}
+ for name, v in (("Anleihe A allein", verlust_a),
+ ("Anleihe B allein", verlust_b),
+ ("Portfolio A+B", verlust_a + verlust_b)):
+ werte[name] = (var_quantil(v), cvar_rockafellar(v))
+ werte["Summe der Einzelwerte"] = tuple(
+ werte["Anleihe A allein"][i] + werte["Anleihe B allein"][i]
+ for i in (0, 1))
+ return werte
+
+
+def pruefe_gegen_buch(werte) -> None:
+ """Die Tafel muss dieselben Zahlen zeigen wie die Tabelle im Kapitel."""
+ for name, (soll_var, soll_cvar) in TABELLE_IM_BUCH.items():
+ ist_var, ist_cvar = werte[name]
+ if abs(ist_var - soll_var) > 0.005 or abs(ist_cvar - soll_cvar) > 0.005:
+ raise SystemExit(
+ f"Subadditivitaets-Bild: '{name}' ergibt VaR {ist_var:.2f} / "
+ f"CVaR {ist_cvar:.2f}, das Kapitel druckt {soll_var:.2f} / "
+ f"{soll_cvar:.2f} ab.")
+ # Die Aussage selbst - nicht nur die Zahlen.
+ if not werte["Portfolio A+B"][0] > werte["Summe der Einzelwerte"][0]:
+ raise SystemExit("Subadditivitaets-Bild: Der VaR verletzt die "
+ "Subadditivitaet hier nicht - dann zeigt die Tafel "
+ "das Gegenteil ihrer Überschrift.")
+ if not werte["Portfolio A+B"][1] <= werte["Summe der Einzelwerte"][1]:
+ raise SystemExit("Subadditivitaets-Bild: Der CVaR ist hier nicht "
+ "subadditiv. Das widerspricht dem Kapiteltext.")
+
+
+def euro(wert: float) -> str:
+ return f"{wert:,.2f} €".replace(",", "@").replace(".", ",").replace("@", ".")
+
+
+def zeichne(werte) -> None:
+ figur, achsen = plt.subplots(1, 2, figsize=(10.2, 4.4))
+ namen = ["Anleihe A allein", "Anleihe B allein",
+ "Summe der Einzelwerte", "Portfolio A+B"]
+ y = np.arange(len(namen))[::-1]
+
+ for achse, index, titel, urteil, farbe in (
+ (achsen[0], 0, "VaR 95 %",
+ "Portfolio teurer als die Summe —\nSubadditivität verletzt",
+ FARBEN["fehler"]),
+ (achsen[1], 1, "CVaR 95 %",
+ "Portfolio billiger als die Summe —\nsubadditiv, also kohärent",
+ FARBEN["gut"])):
+ wert = [werte[n][index] for n in namen]
+ farben = [FARBEN["gedaempft"], FARBEN["gedaempft"],
+ FARBEN["zweit"], farbe]
+ achse.barh(y, wert, height=0.6, color=farben, zorder=3)
+ # Balken muessen bei null beginnen, sonst luegt ihre Laenge. Der Rand
+ # links richtet sich nach der Spannweite, damit die Zahl an einem
+ # negativen Balken nicht an der Achsenbeschriftung klebt.
+ spanne = max(wert) - min(0.0, min(wert))
+ achse.set_xlim(min(0.0, min(wert)) - 0.20 * spanne, max(wert) * 1.22)
+ for stelle, w in enumerate(wert):
+ abstand = 0.015 * spanne
+ achse.text(w + (abstand if w >= 0 else -abstand), y[stelle],
+ euro(w), va="center", ha="left" if w >= 0 else "right",
+ fontsize=9, color=FARBEN["text"], zorder=4)
+ achse.axvline(0, color=FARBEN["text"], linewidth=1.0, zorder=2)
+ achse.set_yticks(y)
+ achse.set_yticklabels(namen if index == 0 else [], fontsize=9,
+ color=FARBEN["text"])
+ achse.set_title(titel, fontsize=11, color=FARBEN["text"], pad=34)
+ achse.set_xlabel("Verlust in € (negativ = Gewinn)")
+ achse.grid(axis="y", visible=False)
+ # Das Urteil ueber der Achse, nicht in ihr - sonst stoesst es an die
+ # Zahl am obersten Balken.
+ achse.text(0.5, 1.008, urteil, transform=achse.transAxes,
+ fontsize=8.5, color=farbe, ha="center", va="bottom",
+ fontweight="bold")
+
+ figur.suptitle("Zwei unabhängige Anleihen, je 4 % Ausfallrisiko — "
+ "und zwei Urteile über dieselbe Diversifikation",
+ fontsize=11, color=FARBEN["text"], y=1.02)
+ speichere(figur, "kap_cvar_subadditivitaet")
+
+
+if __name__ == "__main__":
+ werte = rechne()
+ print(f" {'':<24} {'VaR 95%':>10} {'CVaR 95%':>10}")
+ for name in ("Anleihe A allein", "Anleihe B allein", "Portfolio A+B",
+ "Summe der Einzelwerte"):
+ print(f" {name:<24} {werte[name][0]:>10.2f} {werte[name][1]:>10.2f}")
+ pruefe_gegen_buch(werte)
+ print(" stimmt mit der abgedruckten Tabelle überein")
+ print(f" VaR: Portfolio {werte['Portfolio A+B'][0]:.2f} > Summe "
+ f"{werte['Summe der Einzelwerte'][0]:.2f} — verletzt")
+ print(f" CVaR: Portfolio {werte['Portfolio A+B'][1]:.2f} ≤ Summe "
+ f"{werte['Summe der Einzelwerte'][1]:.2f} — kohärent")
+ zeichne(werte)
diff --git a/bilder_04/kap02_konvexitaet_landschaft.svg b/bilder_04/kap02_konvexitaet_landschaft.svg
new file mode 100644
index 0000000..83c65f2
--- /dev/null
+++ b/bilder_04/kap02_konvexitaet_landschaft.svg
@@ -0,0 +1,71417 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+ Matplotlib v3.10.8, https://matplotlib.org/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bilder_04/kap06_jobshop_gantt.svg b/bilder_04/kap06_jobshop_gantt.svg
index 1bccc9b..a63fc86 100644
--- a/bilder_04/kap06_jobshop_gantt.svg
+++ b/bilder_04/kap06_jobshop_gantt.svg
@@ -1,7 +1,7 @@
-
+
@@ -20,8 +20,8 @@
-
-
-
+" clip-path="url(#p3043647a47)" style="fill: #4338ca; stroke: #ffffff; stroke-width: 1.2; stroke-linejoin: miter"/>
-
+" clip-path="url(#p3043647a47)" style="fill: #4338ca; stroke: #ffffff; stroke-width: 1.2; stroke-linejoin: miter"/>
-
+" clip-path="url(#p3043647a47)" style="fill: #4338ca; stroke: #ffffff; stroke-width: 1.2; stroke-linejoin: miter"/>
-
+" clip-path="url(#p3043647a47)" style="fill: #0891b2; stroke: #ffffff; stroke-width: 1.2; stroke-linejoin: miter"/>
-
+" clip-path="url(#p3043647a47)" style="fill: #0891b2; stroke: #ffffff; stroke-width: 1.2; stroke-linejoin: miter"/>
-
+" clip-path="url(#p3043647a47)" style="fill: #0891b2; stroke: #ffffff; stroke-width: 1.2; stroke-linejoin: miter"/>
-
+" clip-path="url(#p3043647a47)" style="fill: #b45309; stroke: #ffffff; stroke-width: 1.2; stroke-linejoin: miter"/>
-
+" clip-path="url(#p3043647a47)" style="fill: #b45309; stroke: #ffffff; stroke-width: 1.2; stroke-linejoin: miter"/>
-
+" clip-path="url(#p3043647a47)" style="fill: none; stroke-dasharray: 0.8,1.32; stroke-dashoffset: 0; stroke: #b0b0b0; stroke-opacity: 0.5; stroke-width: 0.8"/>
@@ -114,12 +114,12 @@ L 0 3.5
" style="stroke: #000000; stroke-width: 0.8"/>
-
+
-
+
-
+" clip-path="url(#p3043647a47)" style="fill: none; stroke-dasharray: 0.8,1.32; stroke-dashoffset: 0; stroke: #b0b0b0; stroke-opacity: 0.5; stroke-width: 0.8"/>
-
+
-
+
-
+" clip-path="url(#p3043647a47)" style="fill: none; stroke-dasharray: 0.8,1.32; stroke-dashoffset: 0; stroke: #b0b0b0; stroke-opacity: 0.5; stroke-width: 0.8"/>
-
+
-
+
-
+" clip-path="url(#p3043647a47)" style="fill: none; stroke-dasharray: 0.8,1.32; stroke-dashoffset: 0; stroke: #b0b0b0; stroke-opacity: 0.5; stroke-width: 0.8"/>
-
+
-
+
-
+" clip-path="url(#p3043647a47)" style="fill: none; stroke-dasharray: 0.8,1.32; stroke-dashoffset: 0; stroke: #b0b0b0; stroke-opacity: 0.5; stroke-width: 0.8"/>
-
+
-
+
-
+" clip-path="url(#p3043647a47)" style="fill: none; stroke-dasharray: 0.8,1.32; stroke-dashoffset: 0; stroke: #b0b0b0; stroke-opacity: 0.5; stroke-width: 0.8"/>
-
+
-
+
-
+" clip-path="url(#p3043647a47)" style="fill: none; stroke-dasharray: 0.8,1.32; stroke-dashoffset: 0; stroke: #b0b0b0; stroke-opacity: 0.5; stroke-width: 0.8"/>
-
+
-
+
-
+" clip-path="url(#p3043647a47)" style="fill: none; stroke-dasharray: 0.8,1.32; stroke-dashoffset: 0; stroke: #b0b0b0; stroke-opacity: 0.5; stroke-width: 0.8"/>
-
+
-
+
-
+" clip-path="url(#p3043647a47)" style="fill: none; stroke-dasharray: 0.8,1.32; stroke-dashoffset: 0; stroke: #b0b0b0; stroke-opacity: 0.5; stroke-width: 0.8"/>
-
+
-
+
-
+" clip-path="url(#p3043647a47)" style="fill: none; stroke-dasharray: 0.8,1.32; stroke-dashoffset: 0; stroke: #b0b0b0; stroke-opacity: 0.5; stroke-width: 0.8"/>
-
+
-
+
-
+" clip-path="url(#p3043647a47)" style="fill: none; stroke-dasharray: 0.8,1.32; stroke-dashoffset: 0; stroke: #b0b0b0; stroke-opacity: 0.5; stroke-width: 0.8"/>
-
+
-
+
@@ -571,18 +571,18 @@ L 580.080909 25.44
-
+" clip-path="url(#p3043647a47)" style="fill: none; stroke-dasharray: 0.8,1.32; stroke-dashoffset: 0; stroke: #b0b0b0; stroke-opacity: 0.5; stroke-width: 0.8"/>
-
+
-
+
@@ -590,7 +590,7 @@ L 630.81 25.44
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
+
+
+
+
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
-
+
+
-
-
+
+
+
+
@@ -1299,28 +1391,157 @@ z
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
diff --git a/bilder_04/kap15_or_plattform.svg b/bilder_04/kap15_or_plattform.svg
index fb9296b..29a5a96 100644
--- a/bilder_04/kap15_or_plattform.svg
+++ b/bilder_04/kap15_or_plattform.svg
@@ -1,81 +1,3771 @@
-
+
-
-
-
-
-d18
-
-
-s0
-
-QUELL-SYSTEME
-ERP / CRM / HR-Kalender
-Marktdaten-Feeds / Broker-APIs
-
-
-
-s1
-
-DATA QUALITY & SNAPSHOTS
-Validierung (Pydantic / Pandera)
-Unveränderlicher Planungs-Snapshot
-
-
-
-s0->s1
-
-
- Events / REST / Polling
-
-
-
-s2
-
-OPTIMIERUNGS-WORKER (async)
-Queue (Celery / Redis / Dramatiq)
-Solver: OR-Tools / HiGHS / CVXPY
-Timeouts, Fallbacks, Diagnose
-
-
-
-s1->s2
-
-
- JSON-Payload
-
-
-
-s3
-
-DISPOSITION & HUMAN-IN-THE-LOOP
-Web-UI / Dashboard
-Freigabeprozess (Mensch bestätigt)
-
-
-
-s2->s3
-
-
- Ergebnis + Kostenzerlegung + Logs
-
-
-
-s4
-
-AUSFÜHRUNGSSCHICHT
-Kalender-Sync / Push-Benachrichtigung
-Broker-Order-Routing
-
-
-
-s3->s4
-
-
- Audit-Trail
-
-
+ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+
+
+
+
+
+ image/svg+xml
+
+
+ Matplotlib v3.10.8, https://matplotlib.org/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bilder_04/kap_cpsat_propagation_ablauf.svg b/bilder_04/kap_cpsat_propagation_ablauf.svg
new file mode 100644
index 0000000..d152d41
--- /dev/null
+++ b/bilder_04/kap_cpsat_propagation_ablauf.svg
@@ -0,0 +1,2208 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+ Matplotlib v3.10.8, https://matplotlib.org/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bilder_04/kap_cvar_subadditivitaet.svg b/bilder_04/kap_cvar_subadditivitaet.svg
new file mode 100644
index 0000000..58da125
--- /dev/null
+++ b/bilder_04/kap_cvar_subadditivitaet.svg
@@ -0,0 +1,2698 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+ Matplotlib v3.10.8, https://matplotlib.org/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bilder_04/kap_lp_dualitaets_bruecke.svg b/bilder_04/kap_lp_dualitaets_bruecke.svg
new file mode 100644
index 0000000..43ea21e
--- /dev/null
+++ b/bilder_04/kap_lp_dualitaets_bruecke.svg
@@ -0,0 +1,3683 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+ Matplotlib v3.10.8, https://matplotlib.org/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bilder_04/kap_lp_simplex_ecken.svg b/bilder_04/kap_lp_simplex_ecken.svg
index a4da969..e154057 100644
--- a/bilder_04/kap_lp_simplex_ecken.svg
+++ b/bilder_04/kap_lp_simplex_ecken.svg
@@ -749,6 +749,260 @@ L 492.615285 242.66828
+
+
+
+
+
+
+
+
+
-
+
-
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bilder_04/kap_mehrziel_pareto.svg b/bilder_04/kap_mehrziel_pareto.svg
index f68f621..9e40a3b 100644
--- a/bilder_04/kap_mehrziel_pareto.svg
+++ b/bilder_04/kap_mehrziel_pareto.svg
@@ -44,15 +44,25 @@ L 275.546558 234.022071
L 399.362509 260.929849
L 469.719463 263.503636
" clip-path="url(#pc5415b3683)" style="fill: none; stroke-dasharray: 5.18,2.24; stroke-dashoffset: 0; stroke: #4338ca; stroke-width: 1.4"/>
+
+
+
+
+
+
-
+
-
+
-
+
-
+
@@ -236,12 +246,12 @@ z
-
+
-
+
@@ -279,12 +289,12 @@ z
-
+
-
+
@@ -327,12 +337,12 @@ z
-
+
-
+
@@ -349,12 +359,12 @@ L 340.373272 25.44
-
+
-
+
@@ -371,12 +381,12 @@ L 401.820393 25.44
-
+
-
+
@@ -728,12 +738,12 @@ z
-
+
-
+
-
+
-
+
@@ -802,12 +812,12 @@ L 489.351818 194.713318
-
+
-
+
@@ -823,12 +833,12 @@ L 489.351818 147.917184
-
+
-
+
@@ -844,12 +854,12 @@ L 489.351818 101.121049
-
+
-
+
@@ -1066,6 +1076,11 @@ z
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bilder_04/kap_milp_bnb_geometrie.svg b/bilder_04/kap_milp_bnb_geometrie.svg
new file mode 100644
index 0000000..1365275
--- /dev/null
+++ b/bilder_04/kap_milp_bnb_geometrie.svg
@@ -0,0 +1,6157 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+ Matplotlib v3.10.8, https://matplotlib.org/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+