Compare commits

...
Author SHA1 Message Date
dschlueter
3e5c6fc627 PROGRESS.md Abschnitt 7 auf den heutigen Stand bringen
Der Wiederaufnahme-Anker nannte noch 74 Programme, 32 Diagramme und "Phase 6
hat begonnen" - alles ueberholt. Jetzt steht dort: der erreichte Stand, die
drei Schritte, die von aussen kommen muessen (Remote-Repository,
COLAB_BASIS_URL, Veroeffentlichen), die fuenf bewusst zurueckgestellten
Pakete mit Begruendung, der eine rein redaktionelle Punkt (53 gekuerzte
Loesungstitel) und vier Dinge, die einer spaeteren Sitzung auffallen und
keine Fehler sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:26:40 +02:00
dschlueter
35cc0da166 Achter Fund (Uebung-Verweise) und Phase 8.3: Dockerfile
Zwei Dinge in einem Commit, weil beide den Plan abschliessen.

ACHTER FUND: sieben Saetze der Bauart "Uebung 8.5 laesst Sie diese Balance
untersuchen" - und sechs davon standen in alter Zaehlung. "8" war in Version
03 das QP/NLP-Kapitel, heute ist es Kapitel 11. Dieselbe Familie wie die
Denkfehler-Verweise aus 6.1a, nur mit einem Wort, das keine der bestehenden
Pruefungen kannte.

Aufgaben haben kein eigenes Label, ein {ref:} auf eine einzelne Aufgabe ist
also nicht moeglich. Verwiesen wird stattdessen auf Abschnitt plus
Aufgabentitel - und der ist stabil. Jedes Ziel wurde einzeln ueber die alte
Zaehlung bestimmt und am Zusammenhang geprueft.

Ein Fall war knifflig: "Uebung 6.7 (Wochendienstplan)" meinte die siebte
CP-SAT-Aufgabe der alten Zaehlung, also "Eigener Dienstplan" - die heute an
achter Stelle steht, weil in Phase 6.3 eine Aufgabe davor eingefuegt wurde.
Wer nur die Kapitelnummer angepasst haette, waere bei der falschen Aufgabe
gelandet.

--check kennt jetzt auch "Uebung"/"Übung". Gegengetestet.

PHASE 8.3: Dockerfile, zweistufig. Die erste Stufe uebersetzt die
Abhaengigkeiten in eine virtuelle Umgebung und braucht dafuer einen
Compiler, die zweite kopiert nur /opt/venv. Installiert werden die Gruppen
finance, large-scale, api und dev aus pyproject.toml; figures fehlt bewusst,
weil es zusaetzlich Graphviz verlangt.

Es wurde nicht behauptet, sondern gebaut. Ergebnis: 1,31 GB, und darin der
Installationstest mit allen drei Solver-Funktionstests bestanden, die 33
pytest-Tests bestanden und alle 69 netzfreien Programme fehlerfrei -
einschliesslich der drei aus 8.2, deren spawn-Isolation im Container ebenso
traegt wie ausserhalb.

Zwei Dinge, die der Bau gelehrt hat: libgomp1 fehlt im python:3.12-slim-Image
und wird von OR-Tools und HiGHS zur Laufzeit gebraucht (sonst
"libgomp.so.1: cannot open shared object file"). Und ein eigener Fehler:
USER kurs stand vor dem mkdir /buch/output, /buch gehoert root, der Bau
brach in der letzten Zeile ab. Beides steht jetzt als Kommentar im
Dockerfile.

Das Image fuehrt die Programme aus und baut das Buch nicht. Ein
.dockerignore haelt Website, PDF und Notebooks aus dem Build-Kontext. Und es
enthaelt ortools UND highspy, obwohl sie sich nicht gemeinsam importieren
lassen - der Konflikt wird zur Laufzeit durch getrennte Prozesse geloest,
nicht durch Weglassen.

Damit ist Phase 8 abgeschlossen und der Plan abgearbeitet.

Stand: 825 Querverweise, 76 Programme, 33 pytest-Tests, PDF 760 Seiten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:18:05 +02:00
dschlueter
862e92bc7b Phase 8.2: Solver-Isolation ohne subprocess-Codestrings
Setzt den Isolationsteil von Paket 1 aus Verbesserungen_02.md um. Der Plan
nannte zwei Programme; beim Suchen kam ein drittes dazu, das dasselbe Muster
verwendete.

Ein_System_Vier_Ansaetze.py und Benchmark_Skalierung.py hielten ihre vier
Solvervarianten als Zeichenketten in einem Dictionary und gaben sie an
"python -c" weiter - bei Benchmark_Skalierung.py sogar mit
.format()-Platzhaltern fuer die Instanzgroesse. Aus jeder Variante ist jetzt
eine gewoehnliche Funktion mit lokalem Import geworden.
Solverwechsel_CPSAT_HiGHS.py rief sich selbst ueber sys.argv erneut auf;
auch das entfaellt.

Ausgefuehrt wird ueber einen ProcessPoolExecutor mit zwei Einstellungen, die
beide noetig sind: mp_context "spawn" (frischer Interpreter statt geerbtem
Speicher - unter Linux ist fork der Standard) und max_tasks_per_child=1 (ein
neuer Prozess je Aufgabe; ohne das verwendet der Pool seinen Arbeiter
wieder, und beim zweiten Solver ist der Konflikt zurueck). Nachgemessen:
vier Aufgaben, vier verschiedene PIDs.

Der zweite Punkt hat einen eigenen Warnkasten bekommen, weil der Fehler
leicht zu machen und schwer zu finden ist: Der Absturz kaeme nicht beim
ersten Solver, sondern beim zweiten - und saehe aus wie ein Problem des
zweiten.

Regel 4, dreifach geprueft. Ein_System_Vier_Ansaetze.py: identisch bis auf
die Zeitspalte, einschliesslich der Spannweite 2,41e-08, auf die sich der
Merksatz des Kapitels beruft. Benchmark_Skalierung.py: alle zwoelf
Zielwerte und alle drei Spannweiten bitgleich; Zeiten und Speicher haben
sich verschoben, beide sind im Abdruck seit jeher als hardwareabhaengig
gekennzeichnet. Solverwechsel_CPSAT_HiGHS.py: Ausgabe ohne Zeiten
unveraendert.

Bewusst subprocess bleibt Mutationstest.py: Dort wird pytest auf einer
mutierten Kopie in einem temporaeren Verzeichnis gestartet - ein externes
Werkzeug auf veraenderten Dateien, nicht die Isolation eines Imports.

Neu im Kapitel Oekosystem: ein Abschnitt "Wie die Isolation aussieht, wenn
sie tragen soll" - warum ein Codestring die schlechteste Umsetzung von
"eigener Prozess" ist. Anhang C nennt jetzt ebenfalls ProcessPoolExecutor.

Ein eigener Fehler, gefunden und abgesichert: Ich hatte dem neuen ### ein
{#sec:...}-Label gegeben. ABSCHNITT_RE erkennt nur "## " - das Label waere
nie registriert worden und jeder Verweis darauf ins Leere gelaufen, ohne
Warnung. Label entfernt, --check meldet den Fall jetzt. Gegengetestet.

Stand: 818 Querverweise, 76 Programme, 33 pytest-Tests, PDF 760 Seiten, 69
netzfreie Programme fehlerfrei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:39:34 +02:00
dschlueter
9c83716095 Phase 8.1: Synthese-Seiten je Teil - und die gebrochene Lesekette
Setzt Paket 5 aus Verbesserungen_02.md um (den Teil, der nicht
zurueckgestellt wurde). Fuenf neue Dateien 19_/29_/39_/49_/52_Synthese_*.md,
je eine am Ende eines Teils, mit eigener Website-Seite ueber SONDERSEITEN -
sie tragen bewusst keine "# Kapitel:"-Ueberschrift, weil sie keine Kapitel
sind, sondern der Rueckblick auf einen Teil.

Der Entwurf musste sich abgrenzen: Die Teil-Einleitungen haben bereits
Entscheidungsdiagramme. Eine zweite Matrix am Teil-Ende waere eine Dopplung
gewesen. Die Synthesen leisten deshalb, was eine Einleitung nicht kann - den
Vergleich ueber die Kapitel hinweg (Verfahren nebeneinander, mit der Spalte
"wo es aufhoert"), eine Tabelle "was dieser Teil gemessen hat" (Behauptung
gegen Messung gegen Fundstelle) und drei Fehler, die der Teil verhindert.
Zitiert wird ausschliesslich, was im Buch tatsaechlich gerechnet wird.

Drei Funde beim Einbau:

* Teil III sagte "die drei Kapitel dieses Teils", hat aber fuenf. Phase 3
  hatte Mehrziel und Predict-then-Optimize hinzugefuegt, die Einleitung
  blieb stehen.
* 50_Praxis.md verwies auf die Projektwerkstatt mit "acht eigene
  Anwendungen" - sie hat elf.
* Und der eigentliche Fund: Die Lesekette der Quelldateien fuehrte an ACHT
  Kapiteln vorbei. 12_Python_Oekosystem zeigte direkt auf
  20_Lineare_Programmierung, 23_Graphen direkt auf 30_QP, 32_Dynamische
  direkt auf 40_Finanzdaten, 50_Praxis direkt auf die Projektwerkstatt. Wer
  der Kette folgte, uebersprang acht von 23 Kapiteln - darunter
  Metaheuristiken, Spaltengenerierung, Strukturbruecke, Supply-Chain und das
  ganze Testing-Kapitel. Zehn weitere Dateien hatten gar keine
  Navigationszeile.

Zur Reichweite, damit sie nicht ueberschaetzt wird: Diese Zeilen stehen nur
in den Quelldateien. entferne_navigation() streicht sie aus dem
Gesamtdokument, und die Website baut ihre Vor/Zurueck-Knoepfe selbst aus
DATEIEN. PDF und Website waren nie betroffen - wohl aber jeder, der die
Markdown-Dateien im Repository liest, und das wird nach der
Veroeffentlichung der Normalfall sein.

Die Kette ist jetzt ueber alle 35 Uebergaenge geschlossen, und --check
bewacht sie: Fehlt eine Zeile oder zeigt sie an der in DATEIEN folgenden
Datei vorbei, ist der Lauf rot. Gegengetestet mit beiden Bruchformen.

Stand: 36 Dateien, 296 Abschnitte, 815 Querverweise, 328 Indexmarken, 76
Programme (unveraendert), 33 pytest-Tests, PDF 758 Seiten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:10:02 +02:00
dschlueter
8ded88ac38 Phase 7.3: Glossar "Deutsch - OR" - und die Gegenrichtung
Setzt Paket 7 aus Verbesserungen_03.md um. Neuer Abschnitt im Kapitel "Vom
Management-Wunsch zum Modell", unmittelbar hinter dem Gespraechsleitfaden:
Die zwoelf Fragen liefern Saetze - hier werden sie uebersetzt.

Die Abgrenzung zu Anhang B war die eigentliche Entwurfsfrage. Der
Musterkatalog hat laengst eine Spalte "Umgangssprachlich", laeuft aber in
die andere Richtung: Muster -> Formulierung. Wer aus einer Besprechung
kommt, hat den Satz und sucht den Baustein. Das Lexikon ist genau so herum
gebaut (21 Eintraege, jeweils mit B-Nummer), und Anhang B bekam einen
Rueckverweis. Kein Duplikat, sondern die fehlende Gegenrichtung.

Der Teil, der den Abschnitt traegt, ist aber nicht die Tabelle, sondern was
danach kommt: fuenf Wendungen, bei denen die Uebersetzung eine Entscheidung
erzwingt - "moeglichst", "nicht mehr als drei pro Woche", "im Durchschnitt",
"so schnell wie moeglich", "fair". Bei jeder gibt es zwei bis drei
vertretbare Modelle, und die Wahl gehoert dem Betrieb, nicht dem
Modellierer. Das ist die These des Kapitels, auf die Alltagssprache
angewandt.

Dazu die Gegenrichtung OR -> Deutsch: acht Saetze fuer die Erklaerung eines
Ergebnisses, einschliesslich der Formulierung fuer INFEASIBLE ("Ihre Regeln
widersprechen sich", nicht "der Computer schafft es nicht").

Die neue Uebungsaufgabe laesst drei Zielfunktionen fuer "gleichmaessig
ausgelastet" aufstellen; die Loesung nennt je einen Fall, in dem die Wahl
den Plan sichtbar aendert - und weist darauf hin, dass zwei der drei einen
Halbtagsmonteur bestrafen.

Mitgezogen: Lernziel, Zusammenfassung, Rueckverweis aus Anhang B, Pfad E im
Vorwort (er fuehrte bisher direkt zum Musterkatalog, jetzt ueber das
Lexikon).

Damit ist Phase 7 abgeschlossen. 296 Abschnitte, 766 Querverweise, 141
Aufgaben mit 141 Loesungen, PDF 749 Seiten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:46:17 +02:00
dschlueter
a0691e1919 Phase 7.2: Wasserfalldiagramm - wofuer der Erwartungswert-Plan bezahlt
Setzt den umsetzbaren Teil von Paket 3 aus Verbesserungen_03.md um
(Vorher-Nachher-Wasserfall, Business Impact). Neuer Generator
bilder_04/erzeuge_wirkung.py; das Supply-Chain-Kapitel bekommt damit sein
erstes Diagramm ueberhaupt.

Der Anlass: Die Tabelle nannte nur die Summen - 874.870 EUR gegen
351.356 EUR, 149 % mehr. Wofuer genau, stand nirgends.

Der Generator rechnet die Instanz erneut, wie es die Konvention verlangt.
Die Probe fiel gut aus: Der unabhaengige Nachbau reproduziert beide
abgedruckten Summen exakt, dazu 178,7 MWh Fehlmenge und 28/40 Szenarien mit
Abwurf. Kein Auseinanderlaufen - der erste Generator dieser Reihe, der
nichts findet.

Was die Zerlegung zeigt und die Tabelle nicht: Die Anfahrkosten sind in
beiden Plaenen gleich (58.000 EUR). Der Erwartungswert-Plan spart also nicht
dort, wo man es vermuten wuerde. Er spart 12.725 EUR Brennstoff und bezahlt
dafuer 536.239 EUR Lastabwurf - das 42-fache. "149 % mehr" ist eine Zahl,
ueber die man streiten kann; "wir sparen 12.725 EUR und riskieren
536.239 EUR" ist eine, ueber die man entscheidet.

Die Trennung von Brennstoff- und Abwurfkosten ist der einzige Zusatz
gegenueber bewerte() im Buchprogramm; dort wird sie nicht gebraucht.

Ein eigener Fehler unterwegs: Ein .replace(",", ".") fuer deutsche
Tausenderpunkte lag auf dem ganzen Titelsatz und machte aus "Brennstoff,
Preis" ein "Brennstoff. Preis". Jetzt gibt es euro(), das nur auf Zahlen
angewandt wird.

SVG byteidentisch ueber zwei Laeufe. Diagramme jetzt 33, davon 19 mit
Generator (16 Skripte). PDF unveraendert 746 Seiten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:41:07 +02:00
dschlueter
5a645a3944 Phase 7.1: Vier Zielgruppen-One-Pager im Vorwort
Setzt Paket 4 aus Verbesserungen_03.md um. Das Titelblatt wirbt seit dem
Umbau mit Entscheidern, Ingenieuren und Investoren - die Lernpfade waren
aber nach Themen geschnitten, nicht nach Rollen. Der neue Abschnitt "Fuer
wen dieses Buch geschrieben ist" steht zwischen "Wie dieses Buch aufgebaut
ist" und den Lernpfaden: vier Einstiege, jeder mit Schmerzpunkt, drei
Belegen, dem passenden Pfad und der Frage, an der es in dieser Rolle
scheitert.

Die Entscheidung, die den Abschnitt traegt: nur gemessene Zahlen. Jeder
Beleg ist ein Ergebnis, das im Buch gerechnet wird - der 149-%-Aufschlag des
Erwartungswert-Plans, der Schattenpreis, der nur bis +15 Stunden gilt, VaR
1,86 % gegen CVaR 2,99 % bei einem schlechtesten Tag von -23,0 %, das Modell
mit dem schlechteren MSE und der besseren Entscheidung.

Und eine Erwartung wird ausdruecklich enttaeuscht: Verbesserungen_03
verlangt fuer die Managementseite "typischer ROI und Amortisationszeit". Das
Buch hat diese Zahlen nicht, und jede hier genannte waere erfunden. Der
One-Pager sagt das offen und nennt stattdessen die beiden Werkzeuge, mit
denen der Leser seine eigene Zahl ausrechnet: den EVPI als Obergrenze fuer
jedes Prognoseprojekt und die Kostenzurechnung aus Constraint Attribution.

Kein neues Programm, keine neue Aufgabe - der Abschnitt buendelt
Vorhandenes. Querverweise 730 -> 755, PDF 744 -> 746 Seiten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:29:00 +02:00
dschlueter
293370b7a1 Phase 6.3: Parallelitaet und Determinismus - gemessen statt behauptet
Neuer Abschnitt im CP-SAT-Kapitel plus Parallele_Suche.py (76. Programm).
Setzt Paket 2 aus Verbesserungen_02.md um.

Was fehlte, war nicht die Regel, sondern die Messung: Das Buch setzt an acht
Stellen num_workers = 1 mit dem Kommentar "fuer eine reproduzierbare
Ausgabe", nachgeprueft hatte es nie jemand. Gerechnet wird auf demselben
Job-Shop wie das Intervallvariablen-Kapitel, nur gross genug, dass die Suche
arbeitet: 12 Auftraege, 10 Maschinen, 120 Arbeitsgaenge.

Zwei Befunde, beide staerker als die Behauptung:

* Die Beschleunigung ist ueberlinear. Acht Arbeiter waren im abgedruckten
  Lauf nicht achtmal, sondern 12,3-mal schneller als einer. Kein Messfehler:
  CP-SAT vervielfacht nicht dieselbe Suche, sondern laesst verschiedene
  Strategien nebeneinander laufen, die einander ihre Schranken mitteilen.
* Der Seed genuegt nicht - und zwar schon ab ZWEI Arbeitern. Ein Arbeiter:
  1 Plan aus 4 Laeufen. Zwei Arbeiter: 3 verschiedene Plaene aus 4 Laeufen,
  bei identischem random_seed und identischem Zielwert 183.

Die vollstaendige Antwort kam erst ueber die Uebungsaufgabe: Mit
num_workers = 1, aber fuenf verschiedenen Seeds ergeben sich ebenfalls fuenf
verschiedene Plaene. Keiner der beiden Parameter sichert die
Reproduzierbarkeit allein - erst die Kombination traegt.

Weiter fuer die Aufgabe gemessen: Der Gewinn kehrt sich um (auf 24 Kernen
Faktor 10,2 bei 8 Arbeitern, 12,3 bei 16, 9,0 bei 24) - "so viele Arbeiter
wie Kerne" ist damit widerlegt. Und bei 15 Auftraegen laeuft ein Arbeiter
ins 60-s-Limit (FEASIBLE, Makespan 200), waehrend acht OPTIMAL mit
demselben Makespan 200 nach 26,4 s melden: Der Unterschied liegt nicht in
der Loesung, sondern im Beweis, dass es keine bessere gibt.

Die abgedruckte Ausgabe traegt die Kennzeichnung "Laufzeiten und die Zahl
der verschiedenen Plaene sind hardwareabhaengig" - nach dem Muster, das das
Testing-Kapitel fuer Benchmark_Skalierung.py schon verwendet. Der Vergleich
des extrahierten Programms mit dem Abdruck weicht denn auch in genau einer
Zelle ab (4 statt 3 verschiedene Plaene bei 4 Arbeitern); Zielwert und
Struktur sind identisch. Hier ist die Nichtreproduzierbarkeit der
abgedruckten Zahl die Aussage selbst.

Mitgezogen: Kapitelkopf, Lernziele, Selbsttest, Zusammenfassung,
Vorwort-Programmverzeichnis, Uebungsaufgabe und Loesung in Anhang A, ein
Verweis aus dem bestehenden Callout zu mehrdeutigen Optima und einer aus
Warmstart_Effekt.py im MILP-Kapitel (dort nach Regel 12 der Kapitelname).

Stand: 295 Abschnitte, 730 Querverweise, 328 Indexmarken, 76 Programme,
140 Aufgaben mit 140 Loesungen, 33 pytest-Tests, PDF 744 Seiten, 69
netzfreie Programme fehlerfrei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:19:53 +02:00
dschlueter
dd4acda129 Phase 6.2: Constraint Attribution und Managementbericht
Neuer Abschnitt im Kapitel Praxisfallen plus Constraint_Attribution.py (75.
Programm). Setzt den fehlenden Teil von Paket 3 aus Verbesserungen_02.md um.

Die Luecke, die es schliesst: Erklaerbarkeit.py beantwortet "warum DIESE
Zuweisung?", Konfliktsuche.py "warum geht es GAR NICHT?". Der haeufigste Fall
der Praxis lag dazwischen und hatte kein Werkzeug - das Modell rechnet, der
Plan ist zulaessig, und trotzdem enttaeuschend, nur weiss niemand woran es
liegt.

Gerechnet wird auf derselben Fabrik wie die Konfliktsuche, nach der
Reparatur: Dort war die Lackierkapazitaet von 150 Stunden der Kern des
Widerspruchs (2*40 + 3*30 = 170 > 150), eine zweite Schicht bringt sie auf
210. Damit laesst sich dieselbe Instanz ueber zwei Kapitel verfolgen - erst
"warum nicht?", dann "was kostet was?".

Drei gemessene Befunde:

* Bindend heisst nicht teuer: Fuenf Bedingungen binden, "Lackierbudget
  Schicht 2" mit einem Schattenpreis von 0,00 EUR.
* Der Schattenpreis ist eine Momentaufnahme: Fuer die Lackiererei verspricht
  die Hochrechnung 4,00 * 30 = 120 EUR, gemessen kommen 60 EUR; der Preis
  gilt exakt bis +15 Stunden. Die Ranglisten drehen sich dabei um - nach
  Schattenpreis fuehrt der Traegervertrag (18 EUR/Stueck), nach
  tatsaechlicher Wirkung die Montage-Sonderschicht (450 EUR).
* Ein Wunsch kann unmoeglich statt teuer sein: "mindestens 55 Rahmen" ist
  INFEASIBLE, blockiert von drei Bedingungen gemeinsam. Mit +15 Stunden
  Lackierkapazitaet wird er erfuellbar und kostet dann nichts - der Plan
  waehlt diese Menge von sich aus. Der Konflikt bestand nie zwischen
  Vertrieb und Produktion.

Der Managementbericht entsteht aus genau diesen Zahlen; kein Satz enthaelt
eine Zahl, die nicht vorher gerechnet wurde. Voraussetzung ist der
sprechende Name jeder Bedingung - aus A_ub[7] wird kein Satz.

Dazu die Grenze, als Warnkasten und als Uebungsaufgabe: Der Bericht erklaert
das Modell, nicht die Wirklichkeit. Steigt der Deckungsbeitrag von Deckel
von 9 auf 20 EUR, kippt der Plan (Rahmen auf das Vertragsminimum 40, Deckel
auf die Marktgrenze 120), "Kapazitaet Lackieren" hoert auf zu binden,
"Liefervertrag Rahmen" faengt damit an - und der Satz "Teuerste Bindung ist
Liefervertrag Traeger" steht woertlich unveraendert im Bericht, waehrend die
Zahl dahinter von 18 auf 58 EUR springt.

Die Zaehlprobe aus 6.1a hat sich sofort bewaehrt: Nach dem Einbau der
Uebungsaufgabe meldete --check "6 Loesungen, das Kapitel aber 7 Aufgaben",
bevor die Loesung geschrieben war.

Mitgezogen: Kapitelkopf, Lernziele, Selbsttest, Zusammenfassung,
Vorwort-Programmverzeichnis, Vorwaertsverweis aus Falle 2, Uebungsaufgabe
und Loesung in Anhang A.

Stand: 294 Abschnitte, 725 Querverweise, 328 Indexmarken, 75 Programme,
139 Aufgaben mit 139 Loesungen, 33 pytest-Tests, PDF 737 Seiten, 68
netzfreie Programme fehlerfrei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 10:30:33 +02:00
dschlueter
cc990c2998 Sechster und siebter Fund: die letzten handgeschriebenen Nummern
Der Loesungsanhang trug 98 Marken der Bauart "**9.2 — Ansatz waehlen.**",
saemtlich aus Version 03, wo Unsicherheit Kapitel 9 war. Heute ist es
Kapitel 12, und die Aufgabe wird korrekt als "Aufgabe 12.2" gesetzt.

Das war keine Schoenheitsfrage: "Aufgabe 9.2" existiert wirklich - sie
gehoert zum Kapitel Metaheuristiken ("Zuggroesse und Temperatur"). Wer die
Loesung zu 9.2 nachschlug, landete beim falschen Thema. Beide bisherigen
Pruefungen liefen vorbei: Vor der Zahl steht kein Schluesselwort, und es ist
keine Tabellenzelle.

Zwei Messungen machten die Reparatur billig. In allen 15 nummerierten
Abschnitten gab es exakt so viele Loesungen wie Aufgaben, lueckenlos 1..n -
nur der Kapitelteil war falsch. Und die acht in Phase 3 ergaenzten Kapitel
machten es laengst richtig (**Titel.** ohne Nummer); der Anhang wurde also
nicht auf etwas Neues umgestellt, sondern auf das, was seine neueren Teile
schon taten.

nummeriere_marken() vergibt die Marken jetzt selbst. Der Praefix kommt NICHT
aus "# Anhang A:" - der Anhang ist eine Ueberschrift, seine Abschnitte
gehoeren aber zu 23 verschiedenen Kapiteln. Er kommt aus
{#sec:loesungen-X} -> {#kap:X}; diese Zuordnung gilt geprueft fuer alle 23.
Im Quelltext steht "**{loesung} — Titel.**".

Siebter Fund unterwegs: neun Verweise auf Denkfehler-Nummern. Saetze wie
"siehe Denkfehler 8.1" standen in alter Zaehlung - "8" war in Version 03 das
QP/NLP-Kapitel, heute ist es Graphen. Eine blosse Umnummerierung haette sie
stillschweigend woanders hin zeigen lassen; das Ziel wurde deshalb fuer jeden
einzelnen aus dem Zusammenhang bestimmt und auf {ref:sec:<kapitel>-denkfehler}
umgestellt. Einer steckte in einem Programm-Docstring und bekam nach Regel 12
den Kapitelnamen statt eines Verweises.

Eine bewusste Abweichung vom Plan: Der Denkfehler bekommt GAR KEINE Nummer.
Es gibt je Kapitel genau einen, die zweite Stelle waere immer .1, und er
steht ohnehin unter einer nummerierten Ueberschrift. Eine Nummer, die nie
variiert, holte nur die Fragilitaet zurueck, die hier beseitigt wird. Auch
das folgt den acht neuen Kapiteln, die ihren Denkfehler schon vorher nur mit
dem Titel des Raetsels ankuendigen.

--check bewacht jetzt alle Familien (Denkfehler N.M, Micro-Quiz N, **N.M —
im Anhang) und zaehlt zusaetzlich ab, dass jeder Loesungsabschnitt so viele
{loesung}-Marken hat wie sein Kapitel Aufgaben - das faengt eine vergessene
oder doppelte Loesung, was keine Textsuche leisten kann. Gegengetestet mit
vier kuenstlichen Fehlern auf einmal: alle vier gemeldet, nach dem Rueckbau
null.

Gegenprobe der Umstellung: Gesamtdokument vorher gesichert, nachher
verglichen. 168 geaenderte Zeilen, restlos einer Familie zuzuordnen - 118
Loesungsmarken (138 gesamt minus 20, die in den Kapiteln 1 bis 3 zufaellig
schon stimmten), 15 Anhang-Ueberschriften, 15 Kapitel-Callouts, 12
Micro-Quiz, 7 Zeilen mit Verweisen, 1 Docstring. Keine verschobene oder
inhaltlich veraenderte Zeile.

Ergebnis: 138 Aufgaben, 138 Loesungen, keine ohne Gegenstueck. Micro-Quiz
tragen die echten Kapitelnummern. 293 Abschnitte, 720 Querverweise, 327
Indexmarken, PDF unveraendert 725 Seiten, 33 pytest-Tests, 67 netzfreie
Programme fehlerfrei (die vier yfinance-Programme scheiterten am Rate-Limit
des Anbieters, nicht an dieser Aenderung).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:52:41 +02:00
116 changed files with 27721 additions and 13705 deletions

28
.dockerignore Normal file
View file

@ -0,0 +1,28 @@
# Was NICHT in den Build-Kontext gehoert. Ohne diese Datei schickt Docker das
# ganze Verzeichnis an den Daemon - mitsamt Website (rund 50 MB), PDF und
# Notebooks, die im Image nichts zu suchen haben.
# Erzeugte Ausgaben
OR_HTML_04/
Notebooks_04/
*.pdf
Operations_Research_mit_Python_Version_04.md
# Quellen des Buchsatzes - das Image fuehrt die Programme aus, es baut das
# Buch nicht. Wer das PDF bauen will, braucht pandoc, xelatex und inkscape
# und ist mit einer lokalen Installation besser bedient.
bilder_04/
pandoc/
pandoc-defaults-*.yaml
Kritik_und_Verbesserungsvorschlaege/
PLAN.md
PROGRESS.md
CLAUDE.md
# Von Laeufen erzeugte Dateien
**/__pycache__/
**/output/
*.xlsx
.git/
.venv/
venv/

View file

@ -23,7 +23,7 @@ Dieses Verzeichnis ist die Wurzel; alle Skripte leiten ihre Pfade daraus ab
(`BASIS = dirname(dirname(__file__))` bzw. `dirname(HIER)`).
```
Operations_Research_mit_Python_Version_04/ Quelle: 31 Kapiteldateien + Build-Skripte
Operations_Research_mit_Python_Version_04/ Quelle: 36 Kapiteldateien + Build-Skripte
bilder_04/ Quelle: Diagramme + erzeuge_*.py-Generatoren
Operations_Research_mit_Python_Version_04.md generiert: Gesamtdokument
Operations_Research_mit_Python_Version_04.pdf generiert: PDF (xelatex)
@ -33,6 +33,7 @@ Notebooks_04/ generiert: ein .ipynb je Kapitel
Kritik_und_Verbesserungsvorschlaege/ Quelle: Rezension, Verbesserungsvorschläge,
NEUER_TITEL.md (Vorlage des Titelblatts)
pyproject.toml Quelle: Abhängigkeiten in Gruppen
Dockerfile, .dockerignore Quelle: Kurs-Image (nur Programme)
pandoc-defaults-basis.yaml, pandoc/, pandoc-defaults-buch.yaml PDF-Konfiguration
```
@ -66,15 +67,24 @@ sonst fälschlich als sichtbaren Umbruch dar.
## Die wichtigsten Konventionen
**Keine abgeleiteten Zahlen im Quelltext.** Das ist die Regel, an der dieses Projekt am
häufigsten gescheitert ist — sie wurde vier Mal an verschiedenen Stellen verletzt gefunden
(Anhang A, 66 Programm-Docstrings, die Übersichtstabellen der Anhänge, `CLAUDE.md` selbst).
Konkret:
häufigsten gescheitert ist — **sieben** Mal an verschiedenen Stellen verletzt gefunden
(Anhang A, 66 Programm-Docstrings, die Übersichtstabellen der Anhänge, `CLAUDE.md` selbst,
eine Tabellenzelle, die 98 Lösungsmarken des Anhangs, neun Denkfehler-Verweise).
Seit dem letzten Fund trägt **keine** Marke mehr eine handgeschriebene Nummer, und `--check`
bewacht jede Familie. Konkret:
* Überschriften: `# Kapitel: <Titel> {#kap:<label>}`, `## Titel {#sec:<label>}` — die Nummer
vergibt der Build (`resolve_numbering()`, `nummeriere_abschnitte()`).
* Aufgaben, Handrechnungen, Abbildungen: `**Aufgabe ⭐ — Titel.**`,
`> **✏️ Handrechnung: Titel**`, `![Bildunterschrift](bilder_04/…)` — Nummern von
`nummeriere_marken()`.
* Aufgaben, Handrechnungen, Abbildungen, Micro-Quiz: `**Aufgabe ⭐ — Titel.**`,
`> **✏️ Handrechnung: Titel**`, `![Bildunterschrift](bilder_04/…)`,
`> **❓ Micro-Quiz: Titel**` — Nummern von `nummeriere_marken()`.
* **Lösungen in Anhang A: `**{loesung} — Titel.**`** Der Präfix kommt nicht aus
`# Anhang A:`, sondern aus dem Kapitel des umgebenden `{#sec:loesungen-<X>}`-Abschnitts —
die Zuordnung `sec:loesungen-<X>` ↔ `kap:<X>` gilt für alle 23. `--check` zählt zusätzlich
ab, dass es je Kapitel so viele Lösungen wie Aufgaben gibt.
* **Der Denkfehler bekommt bewusst gar keine Nummer.** Es gibt je Kapitel genau einen, unter
einer bereits nummerierten Überschrift. Verwiesen wird auf
`{ref:sec:<kapitel>-denkfehler}`.
* Im Fließtext `{ref:<label>}`, nie eine Literalzahl.
* In Code-Kommentaren und Docstrings der Kapitel**name** („Kapitel Metaheuristiken:"), nie
die Nummer. Auch nicht im Dateinamen.

79
Dockerfile Normal file
View file

@ -0,0 +1,79 @@
# Kurs-Image zu "Optimierte Entscheidungsfindung mit Python".
#
# Was es ist: eine reproduzierbare Umgebung, in der alle Beispielprogramme
# laufen - ohne dass jemand Solver, BLAS-Bibliotheken und Python-Versionen von
# Hand zusammensuchen muss.
#
# Was es NICHT ist: eine Bauumgebung fuer das Buch. Das PDF braucht pandoc,
# xelatex und inkscape, zusammen ueber ein Gigabyte. Wer das Buch selbst baut,
# ist mit einer lokalen Installation besser bedient (siehe README.md).
#
# docker build -t or-mit-python .
# docker run --rm or-mit-python Installationstest.py
# docker run --rm -it or-mit-python # Python-Eingabe
# docker run --rm -v "$PWD/ausgabe:/buch/output" or-mit-python Excel_Bruecke.py
#
# Zwei Stufen: Die erste baut die Abhaengigkeiten in eine virtuelle Umgebung
# (und braucht dafuer Compiler), die zweite kopiert nur das Ergebnis. Das
# spart im fertigen Image die gesamte Bauumgebung.
# --- Stufe 1: Abhaengigkeiten uebersetzen und einsammeln ---------------------
FROM python:3.12-slim AS bau
# build-essential wird von Paketen gebraucht, die kein passendes Wheel
# mitbringen. Bleibt in dieser Stufe und landet nie im fertigen Image.
RUN apt-get update && apt-get install --no-install-recommends -y \
build-essential \
&& rm -rf /var/lib/apt/lists/*
ENV PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Nur die Abhaengigkeitsbeschreibung kopieren, nicht den Quelltext: So bleibt
# diese Schicht im Cache, solange sich die Abhaengigkeiten nicht aendern.
WORKDIR /buch
COPY pyproject.toml README.md ./
# Die Gruppen aus pyproject.toml. 'figures' fehlt bewusst - es dient dem
# Neuerzeugen der Diagramme, wofuer zusaetzlich Graphviz noetig waere.
#
# ACHTUNG, gewollt: Das Image enthaelt ortools UND highspy, obwohl beide eine
# eigene HiGHS-Kopie mitbringen und sich nicht gemeinsam importieren lassen.
# Der Konflikt wird nicht durch Weglassen geloest, sondern zur Laufzeit durch
# getrennte Prozesse (Kapitel Oekosystem). Ein Image, das nur eines von beiden
# enthaelt, koennte die Haelfte der Beispiele nicht ausfuehren.
RUN pip install ".[finance,large-scale,api,dev]"
# --- Stufe 2: das eigentliche Image -----------------------------------------
FROM python:3.12-slim AS laufzeit
# libgomp1 wird von OR-Tools und HiGHS zur Laufzeit gebraucht (OpenMP) und ist
# im slim-Image nicht enthalten. Ohne es scheitert der erste Solveraufruf mit
# einem 'libgomp.so.1: cannot open shared object file'.
RUN apt-get update && apt-get install --no-install-recommends -y \
libgomp1 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=bau /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
MPLBACKEND=Agg
# Kein root. Die Programme brauchen keine Rechte, und ein Kurs-Image, das als
# root laeuft, ist eine schlechte Angewohnheit, die man weitergibt.
RUN useradd --create-home --uid 1000 kurs
WORKDIR /buch
COPY --chown=kurs:kurs Operations_Research_mit_Python_Version_04_Programme/ ./
# Verzeichnis fuer erzeugte Dateien - als Volume einhaengbar, damit
# Excel-Ausgaben und Diagramme den Container ueberleben. Angelegt wird es
# VOR dem Benutzerwechsel, denn /buch gehoert root.
RUN mkdir -p /buch/output && chown kurs:kurs /buch/output
USER kurs
# Ohne Argument eine Python-Eingabeaufforderung, mit Argument das genannte
# Programm: 'docker run --rm or-mit-python Rucksack.py'
ENTRYPOINT ["python"]
CMD []

View file

@ -496,6 +496,218 @@
" loese_jobshop()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Parallele Suche: was `num_workers` wirklich bewirkt\n",
"\n",
"`Parallele_Suche.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Parallele_Suche.py\n",
"\"\"\"\n",
"Kapitel CP-SAT: Was mehrere Arbeiter wirklich bringen - und was sie kosten.\n",
"\n",
"Das Buch setzt an mehreren Stellen 'num_workers = 1', damit die abgedruckte\n",
"Ausgabe reproduzierbar ist. Behauptet wird dabei zweierlei: dass parallele\n",
"Suche schneller ist, und dass sie von Lauf zu Lauf verschiedene, gleich gute\n",
"Loesungen findet. Beides wird hier gemessen statt geglaubt.\n",
"\n",
"Gerechnet wird auf einem Job-Shop wie in JobShop_Intervalle.py, nur gross\n",
"genug, dass die Suche wirklich arbeitet: 12 Auftraege auf 10 Maschinen,\n",
"jeder Auftrag besucht jede Maschine.\n",
"\n",
"Zwei Messungen:\n",
" 1. Dieselbe Aufgabe mit 1, 2, 4 und 8 Arbeitern - Laufzeit und Ergebnis.\n",
" 2. Dieselbe Konfiguration mehrfach, bei FESTEM random_seed - wie oft kommt\n",
" derselbe Plan heraus?\n",
"\n",
"Die zweite ist die wichtigere: Sie entscheidet, wie man Tests schreibt.\n",
"\n",
"Achtung, Laufzeiten sind hardwareabhaengig. Die ZAHL der verschiedenen\n",
"Plaene ist es auch - und genau das ist die Aussage.\n",
"\n",
"Benoetigt: numpy, ortools\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import collections\n",
"import time\n",
"\n",
"import numpy as np\n",
"from ortools.sat.python import cp_model\n",
"\n",
"AUFTRAEGE = 12\n",
"MASCHINEN = 10\n",
"INSTANZ_SEED = 20260908\n",
"SOLVER_SEED = 1 # bleibt ueber ALLE Laeufe gleich - das ist der Punkt\n",
"ARBEITERZAHLEN = (1, 2, 4, 8)\n",
"WIEDERHOLUNGEN = 4\n",
"ZEITLIMIT = 120.0\n",
"\n",
"\n",
"def baue_instanz() -> list[list[tuple[int, int]]]:\n",
" \"\"\"Klassischer Job-Shop: Jeder Auftrag besucht jede Maschine genau einmal,\n",
" in einer eigenen zufaelligen Reihenfolge.\"\"\"\n",
" rng = np.random.default_rng(INSTANZ_SEED)\n",
" return [[(int(m), int(rng.integers(2, 20))) for m in rng.permutation(MASCHINEN)]\n",
" for _ in range(AUFTRAEGE)]\n",
"\n",
"\n",
"def loese(auftraege, arbeiter: int):\n",
" \"\"\"Minimiert den Makespan. Gibt Status, Zielwert, Laufzeit und den Plan\n",
" zurueck - den Plan als Tupel aller Startzeiten, damit sich zwei Laeufe\n",
" vergleichen lassen.\"\"\"\n",
" horizont = sum(dauer for auftrag in auftraege for _, dauer in auftrag)\n",
" modell = cp_model.CpModel()\n",
" Gang = collections.namedtuple(\"Gang\", \"start ende intervall\")\n",
" plaene: dict[tuple[int, int], Gang] = {}\n",
" je_maschine = collections.defaultdict(list)\n",
"\n",
" for a, auftrag in enumerate(auftraege):\n",
" for g, (maschine, dauer) in enumerate(auftrag):\n",
" start = modell.NewIntVar(0, horizont, f\"start_{a}_{g}\")\n",
" ende = modell.NewIntVar(0, horizont, f\"ende_{a}_{g}\")\n",
" intervall = modell.NewIntervalVar(start, dauer, ende, f\"iv_{a}_{g}\")\n",
" plaene[a, g] = Gang(start, ende, intervall)\n",
" je_maschine[maschine].append(intervall)\n",
"\n",
" for maschine in range(MASCHINEN):\n",
" modell.AddNoOverlap(je_maschine[maschine])\n",
" for a, auftrag in enumerate(auftraege):\n",
" for g in range(len(auftrag) - 1):\n",
" modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende)\n",
"\n",
" makespan = modell.NewIntVar(0, horizont, \"makespan\")\n",
" modell.AddMaxEquality(\n",
" makespan, [plaene[a, len(auftrag) - 1].ende\n",
" for a, auftrag in enumerate(auftraege)])\n",
" modell.Minimize(makespan)\n",
"\n",
" loeser = cp_model.CpSolver()\n",
" loeser.parameters.num_workers = arbeiter\n",
" loeser.parameters.random_seed = SOLVER_SEED\n",
" loeser.parameters.max_time_in_seconds = ZEITLIMIT\n",
"\n",
" beginn = time.perf_counter()\n",
" status = loeser.Solve(modell)\n",
" dauer = time.perf_counter() - beginn\n",
"\n",
" if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):\n",
" return loeser.StatusName(status), None, dauer, None\n",
" plan = tuple(loeser.Value(plaene[a, g].start)\n",
" for a, auftrag in enumerate(auftraege)\n",
" for g in range(len(auftrag)))\n",
" return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer, plan\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" auftraege = baue_instanz()\n",
" gaenge = sum(len(a) for a in auftraege)\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" WAS MEHRERE ARBEITER WIRKLICH BRINGEN\")\n",
" print(\"=\" * 78)\n",
" print(f\"Job-Shop: {AUFTRAEGE} Auftraege, {MASCHINEN} Maschinen, \"\n",
" f\"{gaenge} Arbeitsgaenge.\")\n",
" print(f\"random_seed = {SOLVER_SEED} in JEDEM Lauf, \"\n",
" f\"{WIEDERHOLUNGEN} Wiederholungen je Arbeiterzahl.\\n\")\n",
"\n",
" # Jede Konfiguration mehrfach - ein einzelner Zeitwert sagt nichts.\n",
" ergebnisse: dict[int, list[tuple]] = {}\n",
" for arbeiter in ARBEITERZAHLEN:\n",
" ergebnisse[arbeiter] = [loese(auftraege, arbeiter)\n",
" for _ in range(WIEDERHOLUNGEN)]\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" (1) Laufzeit: was die Arbeiter bringen\")\n",
" print(\"=\" * 78)\n",
" print(f\"{'Arbeiter':>9} {'Status':>9} {'Makespan':>9} \"\n",
" f\"{'schnellste':>11} {'langsamste':>11} {'Beschleunigung':>15}\")\n",
" print(\"-\" * 78)\n",
" basis_zeit = min(d for _, _, d, _ in ergebnisse[1])\n",
" for arbeiter in ARBEITERZAHLEN:\n",
" laeufe = ergebnisse[arbeiter]\n",
" zeiten = [d for _, _, d, _ in laeufe]\n",
" ziele = {o for _, o, _, _ in laeufe}\n",
" status = {s for s, _, _, _ in laeufe}\n",
" print(f\"{arbeiter:>9} {'/'.join(sorted(status)):>9} \"\n",
" f\"{'/'.join(str(z) for z in sorted(ziele)):>9} \"\n",
" f\"{min(zeiten):>10.2f}s {max(zeiten):>10.2f}s \"\n",
" f\"{basis_zeit / min(zeiten):>14.1f}x\")\n",
"\n",
" beste = min(ARBEITERZAHLEN, key=lambda w: min(d for _, _, d, _ in ergebnisse[w]))\n",
" faktor = basis_zeit / min(d for _, _, d, _ in ergebnisse[beste])\n",
" print(f\"\\n Am schnellsten sind {beste} Arbeiter: Faktor {faktor:.1f} gegenueber\")\n",
" print(f\" einem einzigen - also MEHR, als {beste} Kerne hergeben sollten.\")\n",
" print(f\" Das ist kein Messfehler. CP-SAT laesst nicht {beste}-mal dieselbe Suche\")\n",
" print(\" laufen, sondern verschiedene Strategien nebeneinander, die einander\")\n",
" print(\" ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der\")\n",
" print(\" 'zweite Kern', sondern ein anderes Verfahren.\")\n",
" letzte = ARBEITERZAHLEN[-1]\n",
" if beste != letzte:\n",
" zeit_letzte = min(d for _, _, d, _ in ergebnisse[letzte])\n",
" print(f\"\\n Und mehr ist nicht immer besser: {letzte} Arbeiter brauchen \"\n",
" f\"{zeit_letzte:.2f}s\")\n",
" print(f\" gegenueber {min(d for _, _, d, _ in ergebnisse[beste]):.2f}s bei \"\n",
" f\"{beste}. Ab einer gewissen Zahl kosten Abstimmung\")\n",
" print(\" und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt.\")\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?\")\n",
" print(\"=\" * 78)\n",
" print(f\"{'Arbeiter':>9} {'Laeufe':>7} {'verschiedene Plaene':>21} \"\n",
" f\"{'Makespan':>10}\")\n",
" print(\"-\" * 78)\n",
" for arbeiter in ARBEITERZAHLEN:\n",
" plaene = {p for _, _, _, p in ergebnisse[arbeiter] if p is not None}\n",
" ziele = {o for _, o, _, _ in ergebnisse[arbeiter]}\n",
" print(f\"{arbeiter:>9} {WIEDERHOLUNGEN:>7} {len(plaene):>21} \"\n",
" f\"{'/'.join(str(z) for z in sorted(ziele)):>10}\")\n",
"\n",
" einer = len({p for _, _, _, p in ergebnisse[1]})\n",
" # Ab welcher Arbeiterzahl bricht die Reproduzierbarkeit? Das ist die\n",
" # eigentliche Zahl - nicht, was acht Arbeiter anrichten.\n",
" ab = next((w for w in ARBEITERZAHLEN\n",
" if len({p for _, _, _, p in ergebnisse[w]}) > 1), None)\n",
" print(f\"\\n Mit einem Arbeiter: {einer} Plan aus {WIEDERHOLUNGEN} Laeufen.\")\n",
" if ab is not None:\n",
" viele = len({p for _, _, _, p in ergebnisse[ab]})\n",
" print(f\" Schon mit {ab} Arbeitern: {viele} verschiedene Plaene aus \"\n",
" f\"{WIEDERHOLUNGEN} Laeufen -\")\n",
" print(\" bei identischem random_seed und identischem Zielwert.\")\n",
" print(f\" Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit\")\n",
" print(f\" zu verlieren. {ab} genuegen.\")\n",
" print(\"\\n Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt\")\n",
" print(\" fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von\")\n",
" print(\" mehreren zuerst fertig wird. Das entscheidet die Uhr.\")\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (3) Was daraus fuer Tests folgt\")\n",
" print(\"=\" * 78)\n",
" print(\" Ein Test der Form assert plan == erwarteter_plan besteht mal und\")\n",
" print(\" scheitert mal, ohne dass sich am Code etwas geaendert haette.\")\n",
" print(\" Zu pruefen sind stattdessen:\")\n",
" print(\" * der ZIELWERT (hier in allen Laeufen gleich),\")\n",
" print(\" * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),\")\n",
" print(\" * und der Status - nicht die Gestalt der Loesung.\")\n",
" print(\"\\n Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte\")\n",
" print(\" Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.\")\n",
" print(f\" Das kostet hier den Faktor {faktor:.1f} an Laufzeit und ist genau\")\n",
" print(\" deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.\")\n",
" print(\"=\" * 78)"
]
},
{
"cell_type": "markdown",
"metadata": {},

View file

@ -455,7 +455,8 @@
" exakt positiv semidefinit ist - rechne trotzdem.' Das ist hier bewusst so\n",
" gewaehlt, denn genau diese Eigenschaft wollen wir vorfuehren. Im\n",
" Produktivbetrieb waere die DCP-Fehlermeldung das Warnsignal, dem man\n",
" nachgehen muss (siehe Denkfehler 8.1).\n",
" nachgehen muss (siehe den Denkfehler im Kapitel Quadratische und\n",
" nichtlineare Optimierung).\n",
" \"\"\"\n",
" gewichte = cp.Variable(ANZAHL_ANLAGEN)\n",
" problem = cp.Problem(cp.Minimize(cp.quad_form(gewichte, cp.psd_wrap(kovarianz))),\n",

View file

@ -757,7 +757,8 @@
"\n",
" loeser = cp_model.CpSolver()\n",
" loeser.parameters.max_time_in_seconds = zeitlimit\n",
" # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist.\n",
" # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist -\n",
" # der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming).\n",
" # Im Produktivbetrieb laesst man beides auf den Standardwerten.\n",
" loeser.parameters.num_workers = 1\n",
" loeser.parameters.random_seed = 1\n",

View file

@ -115,7 +115,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Ein System — vier Programmieransätze\n",
"## Wie die Isolation aussieht, wenn sie tragen soll\n",
"\n",
"`Ein_System_Vier_Ansaetze.py`\n"
]
@ -136,85 +136,104 @@
" 2*x1 + 3*x2 + x3 <= 50\n",
" x >= 0\n",
"\n",
"Deckt scipy.optimize, highspy, CVXPY und OR-Tools/GLOP ab, mit Kreuzvergleich am Ende.\n",
"Deckt scipy.optimize, highspy, CVXPY und OR-Tools/GLOP ab, mit Kreuzvergleich\n",
"am Ende.\n",
"\n",
"WICHTIG: Jeder Solver läuft in einem EIGENEN Prozess, weil sich ortools und\n",
"WICHTIG: Jeder Solver laeuft in einem EIGENEN Prozess, weil sich ortools und\n",
"highspy auf vielen Systemen nicht gemeinsam importieren lassen (beide bringen\n",
"eine eigene HiGHS-Kopie mit -> Symbolkonflikt).\n",
"\n",
"Die Isolation besorgt ein ProcessPoolExecutor. Drei Einstellungen ergeben\n",
"zusammen die Garantie:\n",
"\n",
" mp_context \"spawn\" Der Kindprozess startet mit einem FRISCHEN\n",
" Interpreter, statt den Speicher des Elternprozesses\n",
" zu erben. Was hier schon importiert ist, ist dort\n",
" nicht importiert. Mit dem Standard \"fork\" auf Linux\n",
" waere das nicht so.\n",
" max_tasks_per_child=1 Jede Aufgabe bekommt einen NEUEN Prozess. Ohne das\n",
" wuerde der Pool seinen Arbeiter wiederverwenden - und\n",
" beim zweiten Solver waere der Konflikt zurueck.\n",
" max_workers=1 Haelt die vier Laeufe nacheinander. Nicht aus\n",
" Vorsicht, sondern damit die gemessenen Zeiten\n",
" vergleichbar bleiben.\n",
"\n",
"Jeder Solver steht in einer eigenen Funktion mit LOKALEM Import. Das ist der\n",
"Unterschied zu einem Codestring, den man an 'python -c' uebergibt: Die\n",
"Funktion laesst sich einzeln aufrufen, testen und vom Editor pruefen - ein\n",
"String nicht.\n",
"\n",
"Benoetigt: scipy, highspy, cvxpy, ortools\n",
"\"\"\"\n",
"\n",
"import json\n",
"import subprocess\n",
"import sys\n",
"import textwrap\n",
"import multiprocessing\n",
"import time\n",
"from concurrent.futures import ProcessPoolExecutor\n",
"\n",
"ERWARTET = 530.0 # Ergebnis der Handrechnung zum Produktionsprogramm\n",
"\n",
"# Jeder Eintrag ist ein eigenständiges Miniprogramm, das sein Ergebnis als\n",
"# JSON auf stdout ausgibt. So bleibt jeder Import in seinem eigenen Prozess.\n",
"ANSAETZE: dict[str, str] = {\n",
"# Die Instanz - einmal notiert, von allen vier Funktionen benutzt.\n",
"ZIEL = [10.0, 15.0, 25.0]\n",
"MATRIX = [[1, 1, 2], [2, 3, 1]]\n",
"KAPAZITAET = [40.0, 50.0]\n",
"\n",
" \"scipy.optimize.linprog\": \"\"\"\n",
"\n",
"def loese_mit_scipy() -> tuple[float, list[float]]:\n",
" from scipy.optimize import linprog\n",
" res = linprog(c=[-10.0, -15.0, -25.0], # linprog MINIMIERT -> negieren\n",
" A_ub=[[1, 1, 2], [2, 3, 1]], b_ub=[40, 50],\n",
" ergebnis = linprog(c=[-w for w in ZIEL], # linprog MINIMIERT -> negieren\n",
" A_ub=MATRIX, b_ub=KAPAZITAET,\n",
" bounds=[(0, None)] * 3, method=\"highs\")\n",
" ausgabe = (-res.fun, list(res.x))\n",
" \"\"\",\n",
" return -ergebnis.fun, list(ergebnis.x)\n",
"\n",
" \"highspy (natives HiGHS)\": \"\"\"\n",
" import numpy as np, highspy\n",
"\n",
"def loese_mit_highspy() -> tuple[float, list[float]]:\n",
" import highspy\n",
" import numpy as np\n",
" h = highspy.Highs()\n",
" h.setOptionValue(\"output_flag\", False)\n",
" h.addVars(3, np.zeros(3), np.full(3, highspy.kHighsInf))\n",
" h.changeObjectiveSense(highspy.ObjSense.kMaximize)\n",
" for j, wert in enumerate([10.0, 15.0, 25.0]):\n",
" for j, wert in enumerate(ZIEL):\n",
" h.changeColCost(j, wert)\n",
" # CSR-Format: starts[i] = Beginn von Zeile i in indices/values\n",
" h.addRows(2, np.full(2, -highspy.kHighsInf), np.array([40.0, 50.0]), 6,\n",
" h.addRows(2, np.full(2, -highspy.kHighsInf), np.array(KAPAZITAET), 6,\n",
" np.array([0, 3], dtype=np.int32),\n",
" np.array([0, 1, 2, 0, 1, 2], dtype=np.int32),\n",
" np.array([1.0, 1.0, 2.0, 2.0, 3.0, 1.0]))\n",
" np.array([float(w) for zeile in MATRIX for w in zeile]))\n",
" h.run()\n",
" ausgabe = (h.getInfo().objective_function_value,\n",
" return (h.getInfo().objective_function_value,\n",
" list(h.getSolution().col_value[:3]))\n",
" \"\"\",\n",
"\n",
" \"cvxpy\": \"\"\"\n",
" import numpy as np, cvxpy as cp\n",
"\n",
"def loese_mit_cvxpy() -> tuple[float, list[float]]:\n",
" import cvxpy as cp\n",
" import numpy as np\n",
" x = cp.Variable(3, nonneg=True)\n",
" problem = cp.Problem(cp.Maximize(np.array([10.0, 15.0, 25.0]) @ x),\n",
" [np.array([[1, 1, 2], [2, 3, 1]]) @ x <= np.array([40, 50])])\n",
" problem = cp.Problem(cp.Maximize(np.array(ZIEL) @ x),\n",
" [np.array(MATRIX) @ x <= np.array(KAPAZITAET)])\n",
" problem.solve()\n",
" ausgabe = (float(problem.value), [float(v) for v in x.value])\n",
" \"\"\",\n",
" return float(problem.value), [float(v) for v in x.value]\n",
"\n",
" \"ortools / GLOP\": \"\"\"\n",
"\n",
"def loese_mit_ortools() -> tuple[float, list[float]]:\n",
" from ortools.linear_solver import pywraplp\n",
" s = pywraplp.Solver.CreateSolver(\"GLOP\")\n",
" x = [s.NumVar(0, s.infinity(), f\"x{j+1}\") for j in range(3)]\n",
" A = [[1, 1, 2], [2, 3, 1]]\n",
" for i, kap in enumerate([40, 50]):\n",
" s.Add(sum(A[i][j] * x[j] for j in range(3)) <= kap)\n",
" s.Maximize(10 * x[0] + 15 * x[1] + 25 * x[2])\n",
" for i, kapazitaet in enumerate(KAPAZITAET):\n",
" s.Add(sum(MATRIX[i][j] * x[j] for j in range(3)) <= kapazitaet)\n",
" s.Maximize(sum(ZIEL[j] * x[j] for j in range(3)))\n",
" s.Solve()\n",
" ausgabe = (s.Objective().Value(), [v.solution_value() for v in x])\n",
" \"\"\",\n",
" return s.Objective().Value(), [v.solution_value() for v in x]\n",
"\n",
"\n",
"ANSAETZE = {\n",
" \"scipy.optimize.linprog\": loese_mit_scipy,\n",
" \"highspy (natives HiGHS)\": loese_mit_highspy,\n",
" \"cvxpy\": loese_mit_cvxpy,\n",
" \"ortools / GLOP\": loese_mit_ortools,\n",
"}\n",
"\n",
"\n",
"def fuehre_in_eigenem_prozess_aus(quelltext: str) -> tuple[float, list[float]]:\n",
" \"\"\"Startet den Codeschnipsel als separaten Python-Prozess und liest das Ergebnis.\"\"\"\n",
" programm = textwrap.dedent(quelltext) + \"\\nimport json; print(json.dumps(ausgabe))\\n\"\n",
" ergebnis = subprocess.run([sys.executable, \"-c\", programm],\n",
" capture_output=True, text=True, timeout=120)\n",
" if ergebnis.returncode != 0:\n",
" raise RuntimeError(ergebnis.stderr.strip().splitlines()[-1])\n",
" wert, loesung = json.loads(ergebnis.stdout.strip().splitlines()[-1])\n",
" return wert, loesung\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 78)\n",
" print(\" EIN SYSTEM - VIER ANSAETZE (je eigener Prozess)\")\n",
@ -223,14 +242,20 @@
" print(\"-\" * 78)\n",
"\n",
" werte = []\n",
" for name, quelltext in ANSAETZE.items():\n",
" t0 = time.perf_counter()\n",
" # Ein Pool, vier Aufgaben, vier frische Prozesse. Der Kontext muss\n",
" # \"spawn\" sein - siehe Modulkommentar.\n",
" with ProcessPoolExecutor(\n",
" max_workers=1,\n",
" mp_context=multiprocessing.get_context(\"spawn\"),\n",
" max_tasks_per_child=1) as pool:\n",
" for name, funktion in ANSAETZE.items():\n",
" beginn = time.perf_counter()\n",
" try:\n",
" wert, x = fuehre_in_eigenem_prozess_aus(quelltext)\n",
" except RuntimeError as fehler:\n",
" print(f\"{name:<26} nicht verfuegbar: {fehler[:40]}\")\n",
" wert, x = pool.submit(funktion).result(timeout=120)\n",
" except Exception as fehler: # Bibliothek fehlt o. Ae.\n",
" print(f\"{name:<26} nicht verfuegbar: {str(fehler)[:40]}\")\n",
" continue\n",
" dauer = time.perf_counter() - t0\n",
" dauer = time.perf_counter() - beginn\n",
" werte.append(wert)\n",
" print(f\"{name:<26} {wert:>10.2f} {x[0]:>7.2f} {x[1]:>7.2f} {x[2]:>7.2f} \"\n",
" f\"{dauer:>8.2f} s\")\n",
@ -243,8 +268,8 @@
" assert spanne < 1e-6, \"Die Bibliotheken widersprechen sich!\"\n",
" assert abs(werte[0] - ERWARTET) < 1e-6, \"Ergebnis weicht von der Handrechnung ab!\"\n",
" print(\"Alle Wege fuehren zum selben, von Hand bestaetigten Optimum.\")\n",
" print(\"(Die Zeiten enthalten den Prozessstart und den Import - sie messen\")\n",
" print(\" NICHT die reine Solverleistung, siehe Uebung 3.5.)\")\n",
" print(\"(Die Zeiten enthalten Prozessstart und Import - sie messen NICHT die\")\n",
" print(\" reine Solverleistung. Die Uebungsaufgabe 'Laufzeitvergleich' trennt beides.)\")\n",
" print(\"=\" * 78)"
]
},

View file

@ -252,6 +252,328 @@
" print(\"=\" * 78)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Constraint Attribution: welche Bedingung kostet wie viel?\n",
"\n",
"`Constraint_Attribution.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Constraint_Attribution.py\n",
"\"\"\"\n",
"Kapitel Praxisfallen: Welche Bedingung kostet wie viel - und was sagt man\n",
"dem Management?\n",
"\n",
"Zwischen den beiden bekannten Faellen klafft eine Luecke:\n",
"\n",
" * Erklaerbarkeit.py beantwortet \"warum diese eine Zuweisung?\"\n",
" * Konfliktsuche.py (Anhang Fehlerdiagnose) beantwortet \"warum geht es gar\n",
" nicht?\"\n",
"\n",
"Der haeufigste Fall in der Praxis liegt dazwischen: Das Modell rechnet, der\n",
"Plan ist zulaessig - und trotzdem unbefriedigend. Niemand weiss, WORAN es\n",
"liegt. Genau das beantwortet Constraint Attribution.\n",
"\n",
"Gerechnet wird auf derselben Fabrik wie in Konfliktsuche.py, nur nach der\n",
"Reparatur: Dort war die Lackierkapazitaet von 150 Stunden der Kern des\n",
"Widerspruchs (2*40 + 3*30 = 170 > 150). Eine zweite Schicht hat sie auf 210\n",
"gebracht, und der Mindestumsatz wurde auf 10.000 EUR gesenkt. Jetzt ist das\n",
"Modell loesbar - und die Frage lautet nicht mehr \"warum nicht?\", sondern\n",
"\"was kostet uns was?\".\n",
"\n",
"Gezeigt werden vier Dinge:\n",
" 1. Welche Bedingungen binden - und dass \"bindend\" nicht \"teuer\" heisst.\n",
" 2. Dass der Schattenpreis eine MOMENTAUFNAHME ist: hochgerechnet auf eine\n",
" realistische Sonderschicht verspricht er hier das Doppelte des\n",
" tatsaechlichen Nutzens.\n",
" 3. Dass ein Wunsch aus dem Vertrieb nicht teuer, sondern unmoeglich sein\n",
" kann - und welche Bedingungen ihn gemeinsam blockieren.\n",
" 4. Wie aus alledem ein Bericht in Alltagssprache wird.\n",
"\n",
"Benoetigt: numpy, scipy\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import textwrap\n",
"\n",
"import numpy as np\n",
"from scipy.optimize import linprog\n",
"\n",
"PRODUKTE = [\"Rahmen\", \"Gehaeuse\", \"Deckel\", \"Traeger\", \"Halter\"]\n",
"DECKUNGSBEITRAG = np.array([35.0, 28.0, 9.0, 22.0, 6.0]) # EUR je Stueck\n",
"\n",
"# Dieselben Bedingungen wie in Konfliktsuche.py, mit den beiden Reparaturen.\n",
"# Der sprechende Name ist keine Kosmetik: Ohne ihn ist kein Bericht moeglich,\n",
"# der jemandem ausserhalb der Modellierung etwas sagt.\n",
"BEDINGUNGEN: list[tuple[str, list[float], str, float]] = [\n",
" (\"Kapazitaet Montage\", [3, 2, 1, 4, 2], \"<=\", 400),\n",
" (\"Kapazitaet Lackieren\", [2, 3, 0, 1, 0], \"<=\", 210),\n",
" (\"Kapazitaet Pruefung\", [1, 1, 1, 1, 1], \"<=\", 300),\n",
" (\"Liefervertrag Rahmen\", [1, 0, 0, 0, 0], \">=\", 40),\n",
" (\"Liefervertrag Gehaeuse\", [0, 1, 0, 0, 0], \">=\", 30),\n",
" (\"Liefervertrag Deckel\", [0, 0, 1, 0, 0], \">=\", 20),\n",
" (\"Liefervertrag Traeger\", [0, 0, 0, 1, 0], \">=\", 25),\n",
" (\"Marktgrenze Rahmen\", [1, 0, 0, 0, 0], \"<=\", 120),\n",
" (\"Marktgrenze Gehaeuse\", [0, 1, 0, 0, 0], \"<=\", 90),\n",
" (\"Marktgrenze Deckel\", [0, 0, 1, 0, 0], \"<=\", 150),\n",
" (\"Marktgrenze Halter\", [0, 0, 0, 0, 1], \"<=\", 200),\n",
" (\"Mindestumsatz\", [90, 70, 30, 60, 20], \">=\", 10000),\n",
" (\"Sortimentsbreite\", [0, 0, 1, 1, 1], \">=\", 100),\n",
" (\"Lackierbudget Schicht 2\", [0, 1, 0, 1, 0], \"<=\", 55),\n",
"]\n",
"\n",
"# Was liesse sich im Ernstfall wirklich bewegen - und um wie viel? Nicht \"eine\n",
"# Einheit\", sondern der Schritt, den ein Planer tatsaechlich gehen kann: eine\n",
"# Sonderschicht, ein nachverhandelter Vertrag. Genau an dieser Groesse\n",
"# scheitert das blosse Hochrechnen des Schattenpreises.\n",
"STELLSCHRAUBEN: dict[str, tuple[float, str]] = {\n",
" \"Kapazitaet Montage\": (+50, \"eine Sonderschicht Montage\"),\n",
" \"Kapazitaet Lackieren\": (+30, \"eine Sonderschicht Lackieren\"),\n",
" \"Kapazitaet Pruefung\": (+50, \"eine Sonderschicht Pruefung\"),\n",
" \"Lackierbudget Schicht 2\": (+10, \"10 Stunden mehr Budget in Schicht 2\"),\n",
" \"Liefervertrag Traeger\": (-10, \"10 Stueck weniger Abnahmepflicht Traeger\"),\n",
" \"Liefervertrag Gehaeuse\": (-10, \"10 Stueck weniger Abnahmepflicht Gehaeuse\"),\n",
"}\n",
"\n",
"WUNSCH_MENGE = 55.0 # der Vertrieb haette gern mehr Rahmen\n",
"WUNSCH_PRODUKT = \"Rahmen\"\n",
"\n",
"\n",
"def loese(bedingungen: list[tuple[str, list[float], str, float]]):\n",
" \"\"\"Maximiert den Deckungsbeitrag. linprog minimiert, daher das Vorzeichen.\"\"\"\n",
" matrix, rechte_seite = [], []\n",
" for _, koeffizienten, richtung, grenze in bedingungen:\n",
" zeile = np.array(koeffizienten, dtype=float)\n",
" matrix.append(zeile if richtung == \"<=\" else -zeile)\n",
" rechte_seite.append(grenze if richtung == \"<=\" else -grenze)\n",
" return linprog(-DECKUNGSBEITRAG,\n",
" A_ub=np.array(matrix), b_ub=np.array(rechte_seite),\n",
" bounds=[(0, None)] * len(PRODUKTE), method=\"highs\")\n",
"\n",
"\n",
"def geaendert(name: str, delta: float,\n",
" basis=None) -> list[tuple[str, list[float], str, float]]:\n",
" \"\"\"Dieselben Bedingungen, eine davon um delta verschoben.\"\"\"\n",
" return [(n, k, r, g + delta if n == name else g)\n",
" for n, k, r, g in (basis or BEDINGUNGEN)]\n",
"\n",
"\n",
"def auswertung(ergebnis, bedingungen=None):\n",
" \"\"\"Schlupf und Schattenpreis je Bedingung.\"\"\"\n",
" x = ergebnis.x\n",
" dual = ergebnis.ineqlin.marginals\n",
" zeilen = []\n",
" for i, (name, koeff, richtung, grenze) in enumerate(bedingungen or BEDINGUNGEN):\n",
" links = float(np.array(koeff, dtype=float) @ x)\n",
" schlupf = (grenze - links) if richtung == \"<=\" else (links - grenze)\n",
" zeilen.append((name, links, richtung, grenze, schlupf, abs(dual[i])))\n",
" return zeilen\n",
"\n",
"\n",
"def blockierer(bedingungen, zusatz) -> list[str]:\n",
" \"\"\"Welche Bedingungen verhindern 'zusatz' - einzeln weggelassen.\n",
"\n",
" Dasselbe Vorgehen wie der Deletion Filter in Konfliktsuche.py, nur mit\n",
" umgekehrtem Vorzeichen: Dort wird gesucht, was den Widerspruch AUSMACHT,\n",
" hier, was einen Wunsch VERHINDERT. Wird das Modell ohne eine Bedingung\n",
" loesbar, gehoert sie zum Konflikt.\n",
" \"\"\"\n",
" gefunden = []\n",
" for i, (name, _, _, _) in enumerate(bedingungen):\n",
" rest = [c for j, c in enumerate(bedingungen) if j != i] + [zusatz]\n",
" if loese(rest).success:\n",
" gefunden.append(name)\n",
" return gefunden\n",
"\n",
"\n",
"def euro(betrag: float) -> str:\n",
" return f\"{betrag:,.2f} EUR\"\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" basis = loese(BEDINGUNGEN)\n",
" db_basis = -basis.fun\n",
" plan = dict(zip(PRODUKTE, basis.x))\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" CONSTRAINT ATTRIBUTION: Was kostet uns welche Bedingung?\")\n",
" print(\"=\" * 78)\n",
" print(f\"Status: {basis.message.split('(')[0].strip()}\")\n",
" print(f\"Deckungsbeitrag des Plans: {euro(db_basis)}\\n\")\n",
" print(\" \" + \" \".join(f\"{p}: {m:.1f}\" for p, m in plan.items()))\n",
"\n",
" # --- 1. Wer begrenzt den Plan? -----------------------------------------\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (1) Welche Bedingungen binden - und was kostet die naechste Einheit?\")\n",
" print(\"=\" * 78)\n",
" print(f\"{'Bedingung':<24} {'genutzt':>9} {'Grenze':>7} {'Schlupf':>8} \"\n",
" f\"{'Schattenpreis':>11}\")\n",
" print(\"-\" * 78)\n",
" bindend = []\n",
" for name, links, richtung, grenze, schlupf, dual in auswertung(basis):\n",
" marke = \"\"\n",
" if abs(schlupf) < 1e-6:\n",
" bindend.append((name, dual))\n",
" marke = \" <-- bindend\"\n",
" print(f\"{name:<24} {links:>9.1f} {grenze:>7.0f} {schlupf:>8.1f} \"\n",
" f\"{dual:>11.2f}{marke}\")\n",
"\n",
" ohne_preis = [n for n, d in bindend if d < 1e-9]\n",
" print(f\"\\n {len(bindend)} Bedingungen binden. Aber: \"\n",
" f\"{', '.join(ohne_preis)} bindet\")\n",
" print(\" mit einem Schattenpreis von 0,00 EUR - sie beruehrt den Plan, ohne ihn\")\n",
" print(\" zu verteuern. Bindend und kostenrelevant sind zwei verschiedene Dinge.\")\n",
"\n",
" # --- 2. Der Schattenpreis als Momentaufnahme ---------------------------\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (2) Was der Schattenpreis verschweigt\")\n",
" print(\"=\" * 78)\n",
" print(\"Der Schattenpreis gilt fuer die NAECHSTE Einheit. Ein Planer kauft aber\")\n",
" print(\"keine Einheit, sondern eine ganze Schicht. Beides gegenuebergestellt:\\n\")\n",
" preise = {name: dual for name, *_, dual in auswertung(basis)}\n",
" print(f\"{'Stellschraube':<24} {'Preis':>9} {'Schritt':>7} \"\n",
" f\"{'hochgerechnet':>13} {'gemessen':>9} {'Abw.':>9}\")\n",
" print(\"-\" * 78)\n",
" wirkung = {}\n",
" for name, (delta, _) in STELLSCHRAUBEN.items():\n",
" ergebnis = loese(geaendert(name, delta))\n",
" echt = (-ergebnis.fun) - db_basis if ergebnis.success else 0.0\n",
" linear = preise[name] * abs(delta)\n",
" wirkung[name] = echt\n",
" print(f\"{name:<24} {preise[name]:>9.2f} {delta:>+7.0f} \"\n",
" f\"{linear:>13.2f} {echt:>9.2f} {echt - linear:>+9.2f}\")\n",
"\n",
" # Wo endet der Gueltigkeitsbereich? Schrittweise nachfahren.\n",
" abweichler = max(STELLSCHRAUBEN,\n",
" key=lambda n: preise[n] * abs(STELLSCHRAUBEN[n][0]) - wirkung[n])\n",
" delta_max = STELLSCHRAUBEN[abweichler][0]\n",
" grenze_gueltig = 0.0\n",
" schritt = delta_max / 60.0\n",
" vorher = db_basis\n",
" for i in range(1, 61):\n",
" d = schritt * i\n",
" e = loese(geaendert(abweichler, d))\n",
" db = -e.fun if e.success else vorher\n",
" if db - vorher > 1e-9:\n",
" grenze_gueltig = d\n",
" vorher = db\n",
" print(f\"\\n '{abweichler}': hochgerechnet \"\n",
" f\"{preise[abweichler] * abs(delta_max):.2f} EUR, tatsaechlich \"\n",
" f\"{wirkung[abweichler]:.2f} EUR.\")\n",
" print(f\" Der Preis von {preise[abweichler]:.2f} EUR gilt nur bis \"\n",
" f\"+{grenze_gueltig:.0f} Stunden. Jede weitere Stunde\")\n",
" print(\" dieser Sonderschicht bringt exakt nichts - der Engpass ist dann ein anderer.\")\n",
"\n",
" nach_preis = sorted(STELLSCHRAUBEN, key=lambda n: -preise[n])[0]\n",
" nach_wirkung = max(wirkung, key=wirkung.get)\n",
" print(f\"\\n Und die Ranglisten drehen sich: Nach Schattenpreis fuehrt\")\n",
" print(f\" '{nach_preis}' ({preise[nach_preis]:.2f} EUR je Einheit),\")\n",
" print(f\" nach tatsaechlicher Wirkung '{nach_wirkung}' \"\n",
" f\"({wirkung[nach_wirkung]:.2f} EUR).\")\n",
" print(\" Der Schattenpreis sagt, was eine Einheit wert ist - nicht, wie viele\")\n",
" print(\" Einheiten zu haben sind.\")\n",
"\n",
" # --- 3. Der Wunsch aus dem Vertrieb ------------------------------------\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(f\" (3) Was-waere-wenn: mindestens {WUNSCH_MENGE:.0f} \"\n",
" f\"{WUNSCH_PRODUKT} statt {plan[WUNSCH_PRODUKT]:.1f}\")\n",
" print(\"=\" * 78)\n",
" wunsch = (f\"Wunsch Vertrieb {WUNSCH_PRODUKT}\",\n",
" [1 if p == WUNSCH_PRODUKT else 0 for p in PRODUKTE], \">=\", WUNSCH_MENGE)\n",
" mit_wunsch = loese(BEDINGUNGEN + [wunsch])\n",
" schuldige: list[str] = []\n",
" engpass = noetig = None\n",
" wunsch_gratis = False\n",
"\n",
" if mit_wunsch.success:\n",
" print(f\"Erfuellbar, kostet {euro(db_basis - (-mit_wunsch.fun))}.\")\n",
" else:\n",
" schuldige = blockierer(BEDINGUNGEN, wunsch)\n",
" print(\"Der Wunsch ist nicht teuer - er ist UNMOEGLICH.\\n\")\n",
" print(f\"Blockiert wird er von {len(schuldige)} Bedingungen gemeinsam. \"\n",
" f\"Faellt eine davon\")\n",
" print(\"weg, ist er erfuellbar; keine allein ist 'der' Grund:\")\n",
" for name in schuldige:\n",
" e = loese([c for c in BEDINGUNGEN if c[0] != name] + [wunsch])\n",
" print(f\" ohne '{name}': moeglich, Deckungsbeitrag {euro(-e.fun)}\")\n",
"\n",
" engpass = next((n for n in schuldige if n in STELLSCHRAUBEN), schuldige[0])\n",
" for schritt in range(1, 61):\n",
" if loese(geaendert(engpass, schritt) + [wunsch]).success:\n",
" noetig = schritt\n",
" break\n",
" if noetig is not None:\n",
" e = loese(geaendert(engpass, noetig) + [wunsch])\n",
" ohne = loese(geaendert(engpass, noetig))\n",
" wunsch_gratis = abs((-e.fun) - (-ohne.fun)) < 1e-6\n",
" print(f\"\\n Mit +{noetig} bei '{engpass}' wird der Wunsch erfuellbar:\")\n",
" print(f\" Deckungsbeitrag {euro(-e.fun)} \"\n",
" f\"({-e.fun - db_basis:+,.2f} gegenueber heute).\")\n",
" if wunsch_gratis:\n",
" print(f\" Und ohne den Wunsch ergaebe dieselbe Lockerung \"\n",
" f\"{euro(-ohne.fun)} bei\")\n",
" print(f\" {ohne.x[PRODUKTE.index(WUNSCH_PRODUKT)]:.0f} \"\n",
" f\"{WUNSCH_PRODUKT} - der Wunsch kostet also NICHTS.\")\n",
" print(\" Er war nie das Problem. Der Engpass war es.\")\n",
"\n",
" # --- 4. Der Bericht ----------------------------------------------------\n",
" # Aus denselben Zahlen, die oben in Tabellen stehen, wird hier Prosa. Kein\n",
" # Satz enthaelt eine Zahl, die nicht vorher gerechnet wurde - das ist der\n",
" # ganze Trick und zugleich die Bedingung dafuer, dass der Bericht nicht\n",
" # irgendwann leise falsch wird.\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (4) Und so steht es in der Vorlage fuer die Abteilungsleiterrunde\")\n",
" print(\"=\" * 78)\n",
"\n",
" teuerste = sorted(((n, d) for n, d in bindend if d > 0), key=lambda t: -t[1])\n",
" gross = STELLSCHRAUBEN[nach_wirkung][1]\n",
" klein = STELLSCHRAUBEN[abweichler][1]\n",
"\n",
" absaetze = [\n",
" f\"Der Plan bringt {euro(db_basis)} Deckungsbeitrag. Begrenzt wird er von \"\n",
" f\"{len(bindend)} Bedingungen, von denen {len(teuerste)} tatsaechlich Geld \"\n",
" f\"kosten; \\\"{ohne_preis[0]}\\\" beruehrt den Plan, ohne ihn zu verteuern.\",\n",
"\n",
" f\"Teuerste Bindung ist \\\"{teuerste[0][0]}\\\" mit \"\n",
" f\"{teuerste[0][1]:.2f} EUR je Einheit, gefolgt von \\\"{teuerste[1][0]}\\\" \"\n",
" f\"mit {teuerste[1][1]:.2f} EUR.\",\n",
"\n",
" f\"Die groesste einzelne Verbesserung bringt {gross}: \"\n",
" f\"{euro(wirkung[nach_wirkung])} mehr Deckungsbeitrag. {klein[0].upper()}{klein[1:]} \"\n",
" f\"dagegen bringt nur {euro(wirkung[abweichler])} statt der rechnerischen \"\n",
" f\"{euro(preise[abweichler] * abs(delta_max))} - ab +{grenze_gueltig:.0f} \"\n",
" f\"Stunden begrenzt uns etwas anderes. Wer die ganze Schicht bezahlt, \"\n",
" f\"bezahlt die zweite Haelfte umsonst.\",\n",
" ]\n",
"\n",
" if schuldige and noetig is not None:\n",
" namen = \", \".join(f'\"{s}\"' for s in schuldige)\n",
" satz = (f\"Zum Wunsch des Vertriebs ({WUNSCH_MENGE:.0f} {WUNSCH_PRODUKT} statt \"\n",
" f\"{plan[WUNSCH_PRODUKT]:.1f}): Er ist mit der heutigen Ausstattung \"\n",
" f\"nicht erfuellbar - nicht aus Kostengruenden, sondern weil {namen} \"\n",
" f\"ihn gemeinsam blockieren. Mit +{noetig} Stunden bei \"\n",
" f'\"{engpass}\" wird er moeglich')\n",
" satz += (\", und er kostet dann nichts: Der Plan waehlt diese Menge von \"\n",
" \"sich aus.\" if wunsch_gratis else \".\")\n",
" absaetze.append(satz)\n",
"\n",
" for absatz in absaetze:\n",
" print()\n",
" print(textwrap.fill(absatz, width=76))\n",
" print(\"\\n\" + \"=\" * 78)"
]
},
{
"cell_type": "markdown",
"metadata": {},
@ -826,9 +1148,9 @@
"\n",
"from __future__ import annotations\n",
"\n",
"import subprocess\n",
"import sys\n",
"import multiprocessing\n",
"import time\n",
"from concurrent.futures import ProcessPoolExecutor\n",
"\n",
"import numpy as np\n",
"from pydantic import BaseModel, Field, model_validator\n",
@ -1031,25 +1353,29 @@
" if any(loesung.werte[problem.schluessel(i, j)] > 0.5 for j in range(m))]\n",
"\n",
"\n",
"def loese_in_eigenem_prozess(name: str) -> Loesung:\n",
" \"\"\"Startet dieses Programm noch einmal - mit genau einem Solverimport.\"\"\"\n",
" ergebnis = subprocess.run([sys.executable, __file__, name],\n",
" capture_output=True, text=True, timeout=300)\n",
" if ergebnis.returncode != 0:\n",
" raise RuntimeError(ergebnis.stderr.strip().splitlines()[-1])\n",
" # Das DTO als JSON - genau dafuer ist ein Datenobjekt ohne Solverbezug gut.\n",
" return Loesung.model_validate_json(ergebnis.stdout.strip().splitlines()[-1])\n",
"def loese_in_eigenem_prozess(name: str, problem: Standortproblem) -> Loesung:\n",
" \"\"\"Laesst genau einen Modellbauer in einem frischen Prozess rechnen.\n",
"\n",
" 'spawn' statt des Linux-Standards 'fork': Der Kindprozess startet mit\n",
" einem leeren Interpreter und importiert nur den Solver, den SEIN\n",
" Modellbauer braucht. max_tasks_per_child=1 sorgt dafuer, dass der Pool\n",
" seinen Arbeiter nicht wiederverwendet - sonst saessen beim zweiten Aufruf\n",
" wieder beide Bibliotheken im selben Prozess.\n",
"\n",
" Hin und zurueck wandert das Domaenenmodell bzw. das Loesungs-DTO. Beide\n",
" kennen keinen Solver, sind also serialisierbar - genau dafuer sind sie da.\n",
" \"\"\"\n",
" with ProcessPoolExecutor(\n",
" max_workers=1,\n",
" mp_context=multiprocessing.get_context(\"spawn\"),\n",
" max_tasks_per_child=1) as pool:\n",
" return pool.submit(MODELLBAUER[name], problem).result(timeout=300)\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" problem = beispielproblem()\n",
"\n",
" # --- Kindprozess: rechnen und das DTO als JSON ausgeben ---------------\n",
" if len(sys.argv) > 1:\n",
" print(MODELLBAUER[sys.argv[1]](problem).model_dump_json())\n",
" sys.exit(0)\n",
"\n",
" # --- Hauptprozess: beide Solver anstossen und vergleichen -------------\n",
" # --- Beide Solver anstossen und vergleichen ---------------------------\n",
" print(\"=\" * 82)\n",
" print(\" DERSELBE FALL, ZWEI SOLVER - UND EIN AUSWERTUNGSCODE\")\n",
" print(\"=\" * 82)\n",
@ -1061,7 +1387,7 @@
" loesungen: dict[str, Loesung] = {}\n",
" for name, beschriftung in [(\"cpsat\", \"OR-Tools CP-SAT\"),\n",
" (\"highs\", \"HiGHS (highspy)\")]:\n",
" loesung = loesungen[name] = loese_in_eigenem_prozess(name)\n",
" loesung = loesungen[name] = loese_in_eigenem_prozess(name, problem)\n",
" beanstandungen = pruefe_zuordnung(problem, loesung)\n",
"\n",
" print(f\"{beschriftung}\")\n",

View file

@ -593,113 +593,124 @@
"\n",
"from __future__ import annotations\n",
"\n",
"import json\n",
"import subprocess\n",
"import sys\n",
"import textwrap\n",
"import multiprocessing\n",
"import resource\n",
"import time\n",
"from concurrent.futures import ProcessPoolExecutor\n",
"\n",
"import numpy as np\n",
"\n",
"GROESSEN = [(10, 10), (32, 32), (100, 100)] # (Lager, Kunden) -> 100 / 1.024 / 10.000 Variablen\n",
"\n",
"\n",
"# Jeder Eintrag ist ein eigenstaendiges Programm: Instanz aufbauen, loesen,\n",
"# Ergebnis als JSON ausgeben. Die Instanz wird in jedem Kindprozess aus\n",
"# derselben Saat neu erzeugt - so reist nichts ueber die Prozessgrenze,\n",
"# was das Ergebnis verfaelschen koennte.\n",
"VORSPANN = \"\"\"\n",
"import json, time, resource\n",
"import numpy as np\n",
"# Instanz und Speichermessung stehen als gewoehnliche Funktionen hier - nicht\n",
"# in einem String, den ein Kindprozess ausfuehrt. Jede Messfunktion baut die\n",
"# Instanz aus derselben Saat neu auf, damit ueber die Prozessgrenze nichts\n",
"# reist, was das Ergebnis verfaelschen koennte.\n",
"\n",
"def instanz(m, n):\n",
"def instanz(m: int, n: int):\n",
" rng = np.random.default_rng(20)\n",
" kosten = rng.integers(5, 95, (m, n)).astype(float)\n",
" angebot = rng.integers(50, 150, m).astype(float)\n",
" bedarf = angebot.sum() * rng.dirichlet(np.ones(n))\n",
" return kosten, angebot, bedarf\n",
"\n",
"def speicher_mb():\n",
" # ru_maxrss ist unter Linux in Kilobyte\n",
"\n",
"def speicher_mb() -> float:\n",
" # ru_maxrss ist unter Linux in Kilobyte. Gemessen wird der Kindprozess -\n",
" # deshalb muss jede Messung einen eigenen bekommen.\n",
" return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024\n",
"\n",
"M, N = {m}, {n}\n",
"kosten, angebot, bedarf = instanz(M, N)\n",
"\"\"\"\n",
"\n",
"ANSAETZE = {\n",
" \"scipy.linprog\": \"\"\"\n",
"def messe_scipy(m: int, n: int):\n",
" from scipy.optimize import linprog\n",
" kosten, angebot, bedarf = instanz(m, n)\n",
" t0 = time.perf_counter()\n",
" c = kosten.reshape(-1)\n",
" A_ub = np.zeros((M, M * N)); A_eq = np.zeros((N, M * N))\n",
" for i in range(M):\n",
" A_ub[i, i * N:(i + 1) * N] = 1.0\n",
" for j in range(N):\n",
" A_eq[j, j::N] = 1.0\n",
" A_ub = np.zeros((m, m * n)); A_eq = np.zeros((n, m * n))\n",
" for i in range(m):\n",
" A_ub[i, i * n:(i + 1) * n] = 1.0\n",
" for j in range(n):\n",
" A_eq[j, j::n] = 1.0\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter()\n",
" r = linprog(c=c, A_ub=A_ub, b_ub=angebot, A_eq=A_eq, b_eq=bedarf,\n",
" bounds=(0, None), method=\"highs\")\n",
" loesen = time.perf_counter() - t0\n",
" ausgabe = (float(r.fun), aufbau, loesen, speicher_mb())\n",
" \"\"\",\n",
" return float(r.fun), aufbau, loesen, speicher_mb()\n",
"\n",
" \"highspy\": \"\"\"\n",
"\n",
"def messe_highspy(m: int, n: int):\n",
" import highspy\n",
" kosten, angebot, bedarf = instanz(m, n)\n",
" t0 = time.perf_counter()\n",
" h = highspy.Highs(); h.setOptionValue(\"output_flag\", False)\n",
" h.addVars(M * N, np.zeros(M * N), np.full(M * N, highspy.kHighsInf))\n",
" for k in range(M * N):\n",
" h.addVars(m * n, np.zeros(m * n), np.full(m * n, highspy.kHighsInf))\n",
" for k in range(m * n):\n",
" h.changeColCost(k, float(kosten.reshape(-1)[k]))\n",
" for i in range(M):\n",
" idx = np.arange(i * N, (i + 1) * N, dtype=np.int32)\n",
" h.addRow(-highspy.kHighsInf, float(angebot[i]), N, idx, np.ones(N))\n",
" for j in range(N):\n",
" idx = np.arange(j, M * N, N, dtype=np.int32)\n",
" h.addRow(float(bedarf[j]), float(bedarf[j]), M, idx, np.ones(M))\n",
" for i in range(m):\n",
" idx = np.arange(i * n, (i + 1) * n, dtype=np.int32)\n",
" h.addRow(-highspy.kHighsInf, float(angebot[i]), n, idx, np.ones(n))\n",
" for j in range(n):\n",
" idx = np.arange(j, m * n, n, dtype=np.int32)\n",
" h.addRow(float(bedarf[j]), float(bedarf[j]), m, idx, np.ones(m))\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); h.run(); loesen = time.perf_counter() - t0\n",
" ausgabe = (h.getInfo().objective_function_value, aufbau, loesen, speicher_mb())\n",
" \"\"\",\n",
" return h.getInfo().objective_function_value, aufbau, loesen, speicher_mb()\n",
"\n",
" \"ortools/GLOP\": \"\"\"\n",
"\n",
"def messe_ortools(m: int, n: int):\n",
" from ortools.linear_solver import pywraplp\n",
" kosten, angebot, bedarf = instanz(m, n)\n",
" t0 = time.perf_counter()\n",
" s = pywraplp.Solver.CreateSolver(\"GLOP\")\n",
" x = [[s.NumVar(0, s.infinity(), f\"x{i}_{j}\") for j in range(N)]\n",
" for i in range(M)]\n",
" for i in range(M):\n",
" x = [[s.NumVar(0, s.infinity(), f\"x{i}_{j}\") for j in range(n)]\n",
" for i in range(m)]\n",
" for i in range(m):\n",
" s.Add(sum(x[i]) <= float(angebot[i]))\n",
" for j in range(N):\n",
" s.Add(sum(x[i][j] for i in range(M)) == float(bedarf[j]))\n",
" for j in range(n):\n",
" s.Add(sum(x[i][j] for i in range(m)) == float(bedarf[j]))\n",
" s.Minimize(sum(float(kosten[i, j]) * x[i][j]\n",
" for i in range(M) for j in range(N)))\n",
" for i in range(m) for j in range(n)))\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); s.Solve(); loesen = time.perf_counter() - t0\n",
" ausgabe = (s.Objective().Value(), aufbau, loesen, speicher_mb())\n",
" \"\"\",\n",
" return s.Objective().Value(), aufbau, loesen, speicher_mb()\n",
"\n",
" \"cvxpy\": \"\"\"\n",
"\n",
"def messe_cvxpy(m: int, n: int):\n",
" import cvxpy as cp\n",
" kosten, angebot, bedarf = instanz(m, n)\n",
" t0 = time.perf_counter()\n",
" x = cp.Variable((M, N), nonneg=True)\n",
" x = cp.Variable((m, n), nonneg=True)\n",
" problem = cp.Problem(cp.Minimize(cp.sum(cp.multiply(kosten, x))),\n",
" [cp.sum(x, axis=1) <= angebot,\n",
" cp.sum(x, axis=0) == bedarf])\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); problem.solve(); loesen = time.perf_counter() - t0\n",
" ausgabe = (float(problem.value), aufbau, loesen, speicher_mb())\n",
" \"\"\",\n",
"}\n",
" return float(problem.value), aufbau, loesen, speicher_mb()\n",
"\n",
"\n",
"def messe(name: str, quelltext: str, m: int, n: int):\n",
" \"\"\"Fuehrt einen Ansatz in einem eigenen Prozess aus.\"\"\"\n",
" programm = (VORSPANN.format(m=m, n=n) + textwrap.dedent(quelltext)\n",
" + \"\\nprint(json.dumps(ausgabe))\\n\")\n",
" ergebnis = subprocess.run([sys.executable, \"-c\", programm],\n",
" capture_output=True, text=True, timeout=600)\n",
" if ergebnis.returncode != 0:\n",
" return None, ergebnis.stderr.strip().splitlines()[-1][:60]\n",
" return json.loads(ergebnis.stdout.strip().splitlines()[-1]), None\n",
"ANSAETZE = {\"scipy.linprog\": messe_scipy, \"highspy\": messe_highspy,\n",
" \"ortools/GLOP\": messe_ortools, \"cvxpy\": messe_cvxpy}\n",
"\n",
"\n",
"def messe(funktion, m: int, n: int):\n",
" \"\"\"Fuehrt eine Messfunktion in einem FRISCHEN Prozess aus.\n",
"\n",
" 'spawn' und max_tasks_per_child=1 zusammen garantieren, was Regel 1\n",
" verlangt: Jede Messung sieht einen leeren Interpreter. Ohne das\n",
" zweite wuerde der Pool seinen Arbeiter wiederverwenden - dann waere\n",
" der Speicherwert der zweiten Bibliothek um die erste zu hoch, und\n",
" ortools und highspy saessen im selben Prozess.\n",
" \"\"\"\n",
" with ProcessPoolExecutor(\n",
" max_workers=1,\n",
" mp_context=multiprocessing.get_context(\"spawn\"),\n",
" max_tasks_per_child=1) as pool:\n",
" try:\n",
" return pool.submit(funktion, m, n).result(timeout=600), None\n",
" except Exception as fehler:\n",
" return None, str(fehler).strip().splitlines()[-1][:60]\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
@ -717,8 +728,8 @@
" f\"{'Loesen':>9} {'Anteil':>8} {'Speicher':>10}\")\n",
" print(\" \" + \"-\" * 72)\n",
" zielwerte = {}\n",
" for name, quelltext in ANSAETZE.items():\n",
" werte, fehler = messe(name, quelltext, m, n)\n",
" for name, funktion in ANSAETZE.items():\n",
" werte, fehler = messe(funktion, m, n)\n",
" if werte is None:\n",
" print(f\" {name:<16} nicht verfuegbar: {fehler}\")\n",
" continue\n",

View file

@ -496,6 +496,218 @@
" loese_jobshop()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Parallele Suche: was `num_workers` wirklich bewirkt\n",
"\n",
"`Parallele_Suche.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Parallele_Suche.py\n",
"\"\"\"\n",
"Kapitel CP-SAT: Was mehrere Arbeiter wirklich bringen - und was sie kosten.\n",
"\n",
"Das Buch setzt an mehreren Stellen 'num_workers = 1', damit die abgedruckte\n",
"Ausgabe reproduzierbar ist. Behauptet wird dabei zweierlei: dass parallele\n",
"Suche schneller ist, und dass sie von Lauf zu Lauf verschiedene, gleich gute\n",
"Loesungen findet. Beides wird hier gemessen statt geglaubt.\n",
"\n",
"Gerechnet wird auf einem Job-Shop wie in JobShop_Intervalle.py, nur gross\n",
"genug, dass die Suche wirklich arbeitet: 12 Auftraege auf 10 Maschinen,\n",
"jeder Auftrag besucht jede Maschine.\n",
"\n",
"Zwei Messungen:\n",
" 1. Dieselbe Aufgabe mit 1, 2, 4 und 8 Arbeitern - Laufzeit und Ergebnis.\n",
" 2. Dieselbe Konfiguration mehrfach, bei FESTEM random_seed - wie oft kommt\n",
" derselbe Plan heraus?\n",
"\n",
"Die zweite ist die wichtigere: Sie entscheidet, wie man Tests schreibt.\n",
"\n",
"Achtung, Laufzeiten sind hardwareabhaengig. Die ZAHL der verschiedenen\n",
"Plaene ist es auch - und genau das ist die Aussage.\n",
"\n",
"Benoetigt: numpy, ortools\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import collections\n",
"import time\n",
"\n",
"import numpy as np\n",
"from ortools.sat.python import cp_model\n",
"\n",
"AUFTRAEGE = 12\n",
"MASCHINEN = 10\n",
"INSTANZ_SEED = 20260908\n",
"SOLVER_SEED = 1 # bleibt ueber ALLE Laeufe gleich - das ist der Punkt\n",
"ARBEITERZAHLEN = (1, 2, 4, 8)\n",
"WIEDERHOLUNGEN = 4\n",
"ZEITLIMIT = 120.0\n",
"\n",
"\n",
"def baue_instanz() -> list[list[tuple[int, int]]]:\n",
" \"\"\"Klassischer Job-Shop: Jeder Auftrag besucht jede Maschine genau einmal,\n",
" in einer eigenen zufaelligen Reihenfolge.\"\"\"\n",
" rng = np.random.default_rng(INSTANZ_SEED)\n",
" return [[(int(m), int(rng.integers(2, 20))) for m in rng.permutation(MASCHINEN)]\n",
" for _ in range(AUFTRAEGE)]\n",
"\n",
"\n",
"def loese(auftraege, arbeiter: int):\n",
" \"\"\"Minimiert den Makespan. Gibt Status, Zielwert, Laufzeit und den Plan\n",
" zurueck - den Plan als Tupel aller Startzeiten, damit sich zwei Laeufe\n",
" vergleichen lassen.\"\"\"\n",
" horizont = sum(dauer for auftrag in auftraege for _, dauer in auftrag)\n",
" modell = cp_model.CpModel()\n",
" Gang = collections.namedtuple(\"Gang\", \"start ende intervall\")\n",
" plaene: dict[tuple[int, int], Gang] = {}\n",
" je_maschine = collections.defaultdict(list)\n",
"\n",
" for a, auftrag in enumerate(auftraege):\n",
" for g, (maschine, dauer) in enumerate(auftrag):\n",
" start = modell.NewIntVar(0, horizont, f\"start_{a}_{g}\")\n",
" ende = modell.NewIntVar(0, horizont, f\"ende_{a}_{g}\")\n",
" intervall = modell.NewIntervalVar(start, dauer, ende, f\"iv_{a}_{g}\")\n",
" plaene[a, g] = Gang(start, ende, intervall)\n",
" je_maschine[maschine].append(intervall)\n",
"\n",
" for maschine in range(MASCHINEN):\n",
" modell.AddNoOverlap(je_maschine[maschine])\n",
" for a, auftrag in enumerate(auftraege):\n",
" for g in range(len(auftrag) - 1):\n",
" modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende)\n",
"\n",
" makespan = modell.NewIntVar(0, horizont, \"makespan\")\n",
" modell.AddMaxEquality(\n",
" makespan, [plaene[a, len(auftrag) - 1].ende\n",
" for a, auftrag in enumerate(auftraege)])\n",
" modell.Minimize(makespan)\n",
"\n",
" loeser = cp_model.CpSolver()\n",
" loeser.parameters.num_workers = arbeiter\n",
" loeser.parameters.random_seed = SOLVER_SEED\n",
" loeser.parameters.max_time_in_seconds = ZEITLIMIT\n",
"\n",
" beginn = time.perf_counter()\n",
" status = loeser.Solve(modell)\n",
" dauer = time.perf_counter() - beginn\n",
"\n",
" if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):\n",
" return loeser.StatusName(status), None, dauer, None\n",
" plan = tuple(loeser.Value(plaene[a, g].start)\n",
" for a, auftrag in enumerate(auftraege)\n",
" for g in range(len(auftrag)))\n",
" return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer, plan\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" auftraege = baue_instanz()\n",
" gaenge = sum(len(a) for a in auftraege)\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" WAS MEHRERE ARBEITER WIRKLICH BRINGEN\")\n",
" print(\"=\" * 78)\n",
" print(f\"Job-Shop: {AUFTRAEGE} Auftraege, {MASCHINEN} Maschinen, \"\n",
" f\"{gaenge} Arbeitsgaenge.\")\n",
" print(f\"random_seed = {SOLVER_SEED} in JEDEM Lauf, \"\n",
" f\"{WIEDERHOLUNGEN} Wiederholungen je Arbeiterzahl.\\n\")\n",
"\n",
" # Jede Konfiguration mehrfach - ein einzelner Zeitwert sagt nichts.\n",
" ergebnisse: dict[int, list[tuple]] = {}\n",
" for arbeiter in ARBEITERZAHLEN:\n",
" ergebnisse[arbeiter] = [loese(auftraege, arbeiter)\n",
" for _ in range(WIEDERHOLUNGEN)]\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" (1) Laufzeit: was die Arbeiter bringen\")\n",
" print(\"=\" * 78)\n",
" print(f\"{'Arbeiter':>9} {'Status':>9} {'Makespan':>9} \"\n",
" f\"{'schnellste':>11} {'langsamste':>11} {'Beschleunigung':>15}\")\n",
" print(\"-\" * 78)\n",
" basis_zeit = min(d for _, _, d, _ in ergebnisse[1])\n",
" for arbeiter in ARBEITERZAHLEN:\n",
" laeufe = ergebnisse[arbeiter]\n",
" zeiten = [d for _, _, d, _ in laeufe]\n",
" ziele = {o for _, o, _, _ in laeufe}\n",
" status = {s for s, _, _, _ in laeufe}\n",
" print(f\"{arbeiter:>9} {'/'.join(sorted(status)):>9} \"\n",
" f\"{'/'.join(str(z) for z in sorted(ziele)):>9} \"\n",
" f\"{min(zeiten):>10.2f}s {max(zeiten):>10.2f}s \"\n",
" f\"{basis_zeit / min(zeiten):>14.1f}x\")\n",
"\n",
" beste = min(ARBEITERZAHLEN, key=lambda w: min(d for _, _, d, _ in ergebnisse[w]))\n",
" faktor = basis_zeit / min(d for _, _, d, _ in ergebnisse[beste])\n",
" print(f\"\\n Am schnellsten sind {beste} Arbeiter: Faktor {faktor:.1f} gegenueber\")\n",
" print(f\" einem einzigen - also MEHR, als {beste} Kerne hergeben sollten.\")\n",
" print(f\" Das ist kein Messfehler. CP-SAT laesst nicht {beste}-mal dieselbe Suche\")\n",
" print(\" laufen, sondern verschiedene Strategien nebeneinander, die einander\")\n",
" print(\" ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der\")\n",
" print(\" 'zweite Kern', sondern ein anderes Verfahren.\")\n",
" letzte = ARBEITERZAHLEN[-1]\n",
" if beste != letzte:\n",
" zeit_letzte = min(d for _, _, d, _ in ergebnisse[letzte])\n",
" print(f\"\\n Und mehr ist nicht immer besser: {letzte} Arbeiter brauchen \"\n",
" f\"{zeit_letzte:.2f}s\")\n",
" print(f\" gegenueber {min(d for _, _, d, _ in ergebnisse[beste]):.2f}s bei \"\n",
" f\"{beste}. Ab einer gewissen Zahl kosten Abstimmung\")\n",
" print(\" und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt.\")\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?\")\n",
" print(\"=\" * 78)\n",
" print(f\"{'Arbeiter':>9} {'Laeufe':>7} {'verschiedene Plaene':>21} \"\n",
" f\"{'Makespan':>10}\")\n",
" print(\"-\" * 78)\n",
" for arbeiter in ARBEITERZAHLEN:\n",
" plaene = {p for _, _, _, p in ergebnisse[arbeiter] if p is not None}\n",
" ziele = {o for _, o, _, _ in ergebnisse[arbeiter]}\n",
" print(f\"{arbeiter:>9} {WIEDERHOLUNGEN:>7} {len(plaene):>21} \"\n",
" f\"{'/'.join(str(z) for z in sorted(ziele)):>10}\")\n",
"\n",
" einer = len({p for _, _, _, p in ergebnisse[1]})\n",
" # Ab welcher Arbeiterzahl bricht die Reproduzierbarkeit? Das ist die\n",
" # eigentliche Zahl - nicht, was acht Arbeiter anrichten.\n",
" ab = next((w for w in ARBEITERZAHLEN\n",
" if len({p for _, _, _, p in ergebnisse[w]}) > 1), None)\n",
" print(f\"\\n Mit einem Arbeiter: {einer} Plan aus {WIEDERHOLUNGEN} Laeufen.\")\n",
" if ab is not None:\n",
" viele = len({p for _, _, _, p in ergebnisse[ab]})\n",
" print(f\" Schon mit {ab} Arbeitern: {viele} verschiedene Plaene aus \"\n",
" f\"{WIEDERHOLUNGEN} Laeufen -\")\n",
" print(\" bei identischem random_seed und identischem Zielwert.\")\n",
" print(f\" Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit\")\n",
" print(f\" zu verlieren. {ab} genuegen.\")\n",
" print(\"\\n Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt\")\n",
" print(\" fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von\")\n",
" print(\" mehreren zuerst fertig wird. Das entscheidet die Uhr.\")\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (3) Was daraus fuer Tests folgt\")\n",
" print(\"=\" * 78)\n",
" print(\" Ein Test der Form assert plan == erwarteter_plan besteht mal und\")\n",
" print(\" scheitert mal, ohne dass sich am Code etwas geaendert haette.\")\n",
" print(\" Zu pruefen sind stattdessen:\")\n",
" print(\" * der ZIELWERT (hier in allen Laeufen gleich),\")\n",
" print(\" * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),\")\n",
" print(\" * und der Status - nicht die Gestalt der Loesung.\")\n",
" print(\"\\n Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte\")\n",
" print(\" Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.\")\n",
" print(f\" Das kostet hier den Faktor {faktor:.1f} an Laufzeit und ist genau\")\n",
" print(\" deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.\")\n",
" print(\"=\" * 78)"
]
},
{
"cell_type": "markdown",
"metadata": {},

View file

@ -455,7 +455,8 @@
" exakt positiv semidefinit ist - rechne trotzdem.' Das ist hier bewusst so\n",
" gewaehlt, denn genau diese Eigenschaft wollen wir vorfuehren. Im\n",
" Produktivbetrieb waere die DCP-Fehlermeldung das Warnsignal, dem man\n",
" nachgehen muss (siehe Denkfehler 8.1).\n",
" nachgehen muss (siehe den Denkfehler im Kapitel Quadratische und\n",
" nichtlineare Optimierung).\n",
" \"\"\"\n",
" gewichte = cp.Variable(ANZAHL_ANLAGEN)\n",
" problem = cp.Problem(cp.Minimize(cp.quad_form(gewichte, cp.psd_wrap(kovarianz))),\n",

View file

@ -757,7 +757,8 @@
"\n",
" loeser = cp_model.CpSolver()\n",
" loeser.parameters.max_time_in_seconds = zeitlimit\n",
" # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist.\n",
" # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist -\n",
" # der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming).\n",
" # Im Produktivbetrieb laesst man beides auf den Standardwerten.\n",
" loeser.parameters.num_workers = 1\n",
" loeser.parameters.random_seed = 1\n",

View file

@ -115,7 +115,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Ein System — vier Programmieransätze\n",
"## Wie die Isolation aussieht, wenn sie tragen soll\n",
"\n",
"`Ein_System_Vier_Ansaetze.py`\n"
]
@ -136,85 +136,104 @@
" 2*x1 + 3*x2 + x3 <= 50\n",
" x >= 0\n",
"\n",
"Deckt scipy.optimize, highspy, CVXPY und OR-Tools/GLOP ab, mit Kreuzvergleich am Ende.\n",
"Deckt scipy.optimize, highspy, CVXPY und OR-Tools/GLOP ab, mit Kreuzvergleich\n",
"am Ende.\n",
"\n",
"WICHTIG: Jeder Solver läuft in einem EIGENEN Prozess, weil sich ortools und\n",
"WICHTIG: Jeder Solver laeuft in einem EIGENEN Prozess, weil sich ortools und\n",
"highspy auf vielen Systemen nicht gemeinsam importieren lassen (beide bringen\n",
"eine eigene HiGHS-Kopie mit -> Symbolkonflikt).\n",
"\n",
"Die Isolation besorgt ein ProcessPoolExecutor. Drei Einstellungen ergeben\n",
"zusammen die Garantie:\n",
"\n",
" mp_context \"spawn\" Der Kindprozess startet mit einem FRISCHEN\n",
" Interpreter, statt den Speicher des Elternprozesses\n",
" zu erben. Was hier schon importiert ist, ist dort\n",
" nicht importiert. Mit dem Standard \"fork\" auf Linux\n",
" waere das nicht so.\n",
" max_tasks_per_child=1 Jede Aufgabe bekommt einen NEUEN Prozess. Ohne das\n",
" wuerde der Pool seinen Arbeiter wiederverwenden - und\n",
" beim zweiten Solver waere der Konflikt zurueck.\n",
" max_workers=1 Haelt die vier Laeufe nacheinander. Nicht aus\n",
" Vorsicht, sondern damit die gemessenen Zeiten\n",
" vergleichbar bleiben.\n",
"\n",
"Jeder Solver steht in einer eigenen Funktion mit LOKALEM Import. Das ist der\n",
"Unterschied zu einem Codestring, den man an 'python -c' uebergibt: Die\n",
"Funktion laesst sich einzeln aufrufen, testen und vom Editor pruefen - ein\n",
"String nicht.\n",
"\n",
"Benoetigt: scipy, highspy, cvxpy, ortools\n",
"\"\"\"\n",
"\n",
"import json\n",
"import subprocess\n",
"import sys\n",
"import textwrap\n",
"import multiprocessing\n",
"import time\n",
"from concurrent.futures import ProcessPoolExecutor\n",
"\n",
"ERWARTET = 530.0 # Ergebnis der Handrechnung zum Produktionsprogramm\n",
"\n",
"# Jeder Eintrag ist ein eigenständiges Miniprogramm, das sein Ergebnis als\n",
"# JSON auf stdout ausgibt. So bleibt jeder Import in seinem eigenen Prozess.\n",
"ANSAETZE: dict[str, str] = {\n",
"# Die Instanz - einmal notiert, von allen vier Funktionen benutzt.\n",
"ZIEL = [10.0, 15.0, 25.0]\n",
"MATRIX = [[1, 1, 2], [2, 3, 1]]\n",
"KAPAZITAET = [40.0, 50.0]\n",
"\n",
" \"scipy.optimize.linprog\": \"\"\"\n",
"\n",
"def loese_mit_scipy() -> tuple[float, list[float]]:\n",
" from scipy.optimize import linprog\n",
" res = linprog(c=[-10.0, -15.0, -25.0], # linprog MINIMIERT -> negieren\n",
" A_ub=[[1, 1, 2], [2, 3, 1]], b_ub=[40, 50],\n",
" ergebnis = linprog(c=[-w for w in ZIEL], # linprog MINIMIERT -> negieren\n",
" A_ub=MATRIX, b_ub=KAPAZITAET,\n",
" bounds=[(0, None)] * 3, method=\"highs\")\n",
" ausgabe = (-res.fun, list(res.x))\n",
" \"\"\",\n",
" return -ergebnis.fun, list(ergebnis.x)\n",
"\n",
" \"highspy (natives HiGHS)\": \"\"\"\n",
" import numpy as np, highspy\n",
"\n",
"def loese_mit_highspy() -> tuple[float, list[float]]:\n",
" import highspy\n",
" import numpy as np\n",
" h = highspy.Highs()\n",
" h.setOptionValue(\"output_flag\", False)\n",
" h.addVars(3, np.zeros(3), np.full(3, highspy.kHighsInf))\n",
" h.changeObjectiveSense(highspy.ObjSense.kMaximize)\n",
" for j, wert in enumerate([10.0, 15.0, 25.0]):\n",
" for j, wert in enumerate(ZIEL):\n",
" h.changeColCost(j, wert)\n",
" # CSR-Format: starts[i] = Beginn von Zeile i in indices/values\n",
" h.addRows(2, np.full(2, -highspy.kHighsInf), np.array([40.0, 50.0]), 6,\n",
" h.addRows(2, np.full(2, -highspy.kHighsInf), np.array(KAPAZITAET), 6,\n",
" np.array([0, 3], dtype=np.int32),\n",
" np.array([0, 1, 2, 0, 1, 2], dtype=np.int32),\n",
" np.array([1.0, 1.0, 2.0, 2.0, 3.0, 1.0]))\n",
" np.array([float(w) for zeile in MATRIX for w in zeile]))\n",
" h.run()\n",
" ausgabe = (h.getInfo().objective_function_value,\n",
" return (h.getInfo().objective_function_value,\n",
" list(h.getSolution().col_value[:3]))\n",
" \"\"\",\n",
"\n",
" \"cvxpy\": \"\"\"\n",
" import numpy as np, cvxpy as cp\n",
"\n",
"def loese_mit_cvxpy() -> tuple[float, list[float]]:\n",
" import cvxpy as cp\n",
" import numpy as np\n",
" x = cp.Variable(3, nonneg=True)\n",
" problem = cp.Problem(cp.Maximize(np.array([10.0, 15.0, 25.0]) @ x),\n",
" [np.array([[1, 1, 2], [2, 3, 1]]) @ x <= np.array([40, 50])])\n",
" problem = cp.Problem(cp.Maximize(np.array(ZIEL) @ x),\n",
" [np.array(MATRIX) @ x <= np.array(KAPAZITAET)])\n",
" problem.solve()\n",
" ausgabe = (float(problem.value), [float(v) for v in x.value])\n",
" \"\"\",\n",
" return float(problem.value), [float(v) for v in x.value]\n",
"\n",
" \"ortools / GLOP\": \"\"\"\n",
"\n",
"def loese_mit_ortools() -> tuple[float, list[float]]:\n",
" from ortools.linear_solver import pywraplp\n",
" s = pywraplp.Solver.CreateSolver(\"GLOP\")\n",
" x = [s.NumVar(0, s.infinity(), f\"x{j+1}\") for j in range(3)]\n",
" A = [[1, 1, 2], [2, 3, 1]]\n",
" for i, kap in enumerate([40, 50]):\n",
" s.Add(sum(A[i][j] * x[j] for j in range(3)) <= kap)\n",
" s.Maximize(10 * x[0] + 15 * x[1] + 25 * x[2])\n",
" for i, kapazitaet in enumerate(KAPAZITAET):\n",
" s.Add(sum(MATRIX[i][j] * x[j] for j in range(3)) <= kapazitaet)\n",
" s.Maximize(sum(ZIEL[j] * x[j] for j in range(3)))\n",
" s.Solve()\n",
" ausgabe = (s.Objective().Value(), [v.solution_value() for v in x])\n",
" \"\"\",\n",
" return s.Objective().Value(), [v.solution_value() for v in x]\n",
"\n",
"\n",
"ANSAETZE = {\n",
" \"scipy.optimize.linprog\": loese_mit_scipy,\n",
" \"highspy (natives HiGHS)\": loese_mit_highspy,\n",
" \"cvxpy\": loese_mit_cvxpy,\n",
" \"ortools / GLOP\": loese_mit_ortools,\n",
"}\n",
"\n",
"\n",
"def fuehre_in_eigenem_prozess_aus(quelltext: str) -> tuple[float, list[float]]:\n",
" \"\"\"Startet den Codeschnipsel als separaten Python-Prozess und liest das Ergebnis.\"\"\"\n",
" programm = textwrap.dedent(quelltext) + \"\\nimport json; print(json.dumps(ausgabe))\\n\"\n",
" ergebnis = subprocess.run([sys.executable, \"-c\", programm],\n",
" capture_output=True, text=True, timeout=120)\n",
" if ergebnis.returncode != 0:\n",
" raise RuntimeError(ergebnis.stderr.strip().splitlines()[-1])\n",
" wert, loesung = json.loads(ergebnis.stdout.strip().splitlines()[-1])\n",
" return wert, loesung\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 78)\n",
" print(\" EIN SYSTEM - VIER ANSAETZE (je eigener Prozess)\")\n",
@ -223,14 +242,20 @@
" print(\"-\" * 78)\n",
"\n",
" werte = []\n",
" for name, quelltext in ANSAETZE.items():\n",
" t0 = time.perf_counter()\n",
" # Ein Pool, vier Aufgaben, vier frische Prozesse. Der Kontext muss\n",
" # \"spawn\" sein - siehe Modulkommentar.\n",
" with ProcessPoolExecutor(\n",
" max_workers=1,\n",
" mp_context=multiprocessing.get_context(\"spawn\"),\n",
" max_tasks_per_child=1) as pool:\n",
" for name, funktion in ANSAETZE.items():\n",
" beginn = time.perf_counter()\n",
" try:\n",
" wert, x = fuehre_in_eigenem_prozess_aus(quelltext)\n",
" except RuntimeError as fehler:\n",
" print(f\"{name:<26} nicht verfuegbar: {fehler[:40]}\")\n",
" wert, x = pool.submit(funktion).result(timeout=120)\n",
" except Exception as fehler: # Bibliothek fehlt o. Ae.\n",
" print(f\"{name:<26} nicht verfuegbar: {str(fehler)[:40]}\")\n",
" continue\n",
" dauer = time.perf_counter() - t0\n",
" dauer = time.perf_counter() - beginn\n",
" werte.append(wert)\n",
" print(f\"{name:<26} {wert:>10.2f} {x[0]:>7.2f} {x[1]:>7.2f} {x[2]:>7.2f} \"\n",
" f\"{dauer:>8.2f} s\")\n",
@ -243,8 +268,8 @@
" assert spanne < 1e-6, \"Die Bibliotheken widersprechen sich!\"\n",
" assert abs(werte[0] - ERWARTET) < 1e-6, \"Ergebnis weicht von der Handrechnung ab!\"\n",
" print(\"Alle Wege fuehren zum selben, von Hand bestaetigten Optimum.\")\n",
" print(\"(Die Zeiten enthalten den Prozessstart und den Import - sie messen\")\n",
" print(\" NICHT die reine Solverleistung, siehe Uebung 3.5.)\")\n",
" print(\"(Die Zeiten enthalten Prozessstart und Import - sie messen NICHT die\")\n",
" print(\" reine Solverleistung. Die Uebungsaufgabe 'Laufzeitvergleich' trennt beides.)\")\n",
" print(\"=\" * 78)"
]
},

View file

@ -252,6 +252,328 @@
" print(\"=\" * 78)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Constraint Attribution: welche Bedingung kostet wie viel?\n",
"\n",
"`Constraint_Attribution.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Constraint_Attribution.py\n",
"\"\"\"\n",
"Kapitel Praxisfallen: Welche Bedingung kostet wie viel - und was sagt man\n",
"dem Management?\n",
"\n",
"Zwischen den beiden bekannten Faellen klafft eine Luecke:\n",
"\n",
" * Erklaerbarkeit.py beantwortet \"warum diese eine Zuweisung?\"\n",
" * Konfliktsuche.py (Anhang Fehlerdiagnose) beantwortet \"warum geht es gar\n",
" nicht?\"\n",
"\n",
"Der haeufigste Fall in der Praxis liegt dazwischen: Das Modell rechnet, der\n",
"Plan ist zulaessig - und trotzdem unbefriedigend. Niemand weiss, WORAN es\n",
"liegt. Genau das beantwortet Constraint Attribution.\n",
"\n",
"Gerechnet wird auf derselben Fabrik wie in Konfliktsuche.py, nur nach der\n",
"Reparatur: Dort war die Lackierkapazitaet von 150 Stunden der Kern des\n",
"Widerspruchs (2*40 + 3*30 = 170 > 150). Eine zweite Schicht hat sie auf 210\n",
"gebracht, und der Mindestumsatz wurde auf 10.000 EUR gesenkt. Jetzt ist das\n",
"Modell loesbar - und die Frage lautet nicht mehr \"warum nicht?\", sondern\n",
"\"was kostet uns was?\".\n",
"\n",
"Gezeigt werden vier Dinge:\n",
" 1. Welche Bedingungen binden - und dass \"bindend\" nicht \"teuer\" heisst.\n",
" 2. Dass der Schattenpreis eine MOMENTAUFNAHME ist: hochgerechnet auf eine\n",
" realistische Sonderschicht verspricht er hier das Doppelte des\n",
" tatsaechlichen Nutzens.\n",
" 3. Dass ein Wunsch aus dem Vertrieb nicht teuer, sondern unmoeglich sein\n",
" kann - und welche Bedingungen ihn gemeinsam blockieren.\n",
" 4. Wie aus alledem ein Bericht in Alltagssprache wird.\n",
"\n",
"Benoetigt: numpy, scipy\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import textwrap\n",
"\n",
"import numpy as np\n",
"from scipy.optimize import linprog\n",
"\n",
"PRODUKTE = [\"Rahmen\", \"Gehaeuse\", \"Deckel\", \"Traeger\", \"Halter\"]\n",
"DECKUNGSBEITRAG = np.array([35.0, 28.0, 9.0, 22.0, 6.0]) # EUR je Stueck\n",
"\n",
"# Dieselben Bedingungen wie in Konfliktsuche.py, mit den beiden Reparaturen.\n",
"# Der sprechende Name ist keine Kosmetik: Ohne ihn ist kein Bericht moeglich,\n",
"# der jemandem ausserhalb der Modellierung etwas sagt.\n",
"BEDINGUNGEN: list[tuple[str, list[float], str, float]] = [\n",
" (\"Kapazitaet Montage\", [3, 2, 1, 4, 2], \"<=\", 400),\n",
" (\"Kapazitaet Lackieren\", [2, 3, 0, 1, 0], \"<=\", 210),\n",
" (\"Kapazitaet Pruefung\", [1, 1, 1, 1, 1], \"<=\", 300),\n",
" (\"Liefervertrag Rahmen\", [1, 0, 0, 0, 0], \">=\", 40),\n",
" (\"Liefervertrag Gehaeuse\", [0, 1, 0, 0, 0], \">=\", 30),\n",
" (\"Liefervertrag Deckel\", [0, 0, 1, 0, 0], \">=\", 20),\n",
" (\"Liefervertrag Traeger\", [0, 0, 0, 1, 0], \">=\", 25),\n",
" (\"Marktgrenze Rahmen\", [1, 0, 0, 0, 0], \"<=\", 120),\n",
" (\"Marktgrenze Gehaeuse\", [0, 1, 0, 0, 0], \"<=\", 90),\n",
" (\"Marktgrenze Deckel\", [0, 0, 1, 0, 0], \"<=\", 150),\n",
" (\"Marktgrenze Halter\", [0, 0, 0, 0, 1], \"<=\", 200),\n",
" (\"Mindestumsatz\", [90, 70, 30, 60, 20], \">=\", 10000),\n",
" (\"Sortimentsbreite\", [0, 0, 1, 1, 1], \">=\", 100),\n",
" (\"Lackierbudget Schicht 2\", [0, 1, 0, 1, 0], \"<=\", 55),\n",
"]\n",
"\n",
"# Was liesse sich im Ernstfall wirklich bewegen - und um wie viel? Nicht \"eine\n",
"# Einheit\", sondern der Schritt, den ein Planer tatsaechlich gehen kann: eine\n",
"# Sonderschicht, ein nachverhandelter Vertrag. Genau an dieser Groesse\n",
"# scheitert das blosse Hochrechnen des Schattenpreises.\n",
"STELLSCHRAUBEN: dict[str, tuple[float, str]] = {\n",
" \"Kapazitaet Montage\": (+50, \"eine Sonderschicht Montage\"),\n",
" \"Kapazitaet Lackieren\": (+30, \"eine Sonderschicht Lackieren\"),\n",
" \"Kapazitaet Pruefung\": (+50, \"eine Sonderschicht Pruefung\"),\n",
" \"Lackierbudget Schicht 2\": (+10, \"10 Stunden mehr Budget in Schicht 2\"),\n",
" \"Liefervertrag Traeger\": (-10, \"10 Stueck weniger Abnahmepflicht Traeger\"),\n",
" \"Liefervertrag Gehaeuse\": (-10, \"10 Stueck weniger Abnahmepflicht Gehaeuse\"),\n",
"}\n",
"\n",
"WUNSCH_MENGE = 55.0 # der Vertrieb haette gern mehr Rahmen\n",
"WUNSCH_PRODUKT = \"Rahmen\"\n",
"\n",
"\n",
"def loese(bedingungen: list[tuple[str, list[float], str, float]]):\n",
" \"\"\"Maximiert den Deckungsbeitrag. linprog minimiert, daher das Vorzeichen.\"\"\"\n",
" matrix, rechte_seite = [], []\n",
" for _, koeffizienten, richtung, grenze in bedingungen:\n",
" zeile = np.array(koeffizienten, dtype=float)\n",
" matrix.append(zeile if richtung == \"<=\" else -zeile)\n",
" rechte_seite.append(grenze if richtung == \"<=\" else -grenze)\n",
" return linprog(-DECKUNGSBEITRAG,\n",
" A_ub=np.array(matrix), b_ub=np.array(rechte_seite),\n",
" bounds=[(0, None)] * len(PRODUKTE), method=\"highs\")\n",
"\n",
"\n",
"def geaendert(name: str, delta: float,\n",
" basis=None) -> list[tuple[str, list[float], str, float]]:\n",
" \"\"\"Dieselben Bedingungen, eine davon um delta verschoben.\"\"\"\n",
" return [(n, k, r, g + delta if n == name else g)\n",
" for n, k, r, g in (basis or BEDINGUNGEN)]\n",
"\n",
"\n",
"def auswertung(ergebnis, bedingungen=None):\n",
" \"\"\"Schlupf und Schattenpreis je Bedingung.\"\"\"\n",
" x = ergebnis.x\n",
" dual = ergebnis.ineqlin.marginals\n",
" zeilen = []\n",
" for i, (name, koeff, richtung, grenze) in enumerate(bedingungen or BEDINGUNGEN):\n",
" links = float(np.array(koeff, dtype=float) @ x)\n",
" schlupf = (grenze - links) if richtung == \"<=\" else (links - grenze)\n",
" zeilen.append((name, links, richtung, grenze, schlupf, abs(dual[i])))\n",
" return zeilen\n",
"\n",
"\n",
"def blockierer(bedingungen, zusatz) -> list[str]:\n",
" \"\"\"Welche Bedingungen verhindern 'zusatz' - einzeln weggelassen.\n",
"\n",
" Dasselbe Vorgehen wie der Deletion Filter in Konfliktsuche.py, nur mit\n",
" umgekehrtem Vorzeichen: Dort wird gesucht, was den Widerspruch AUSMACHT,\n",
" hier, was einen Wunsch VERHINDERT. Wird das Modell ohne eine Bedingung\n",
" loesbar, gehoert sie zum Konflikt.\n",
" \"\"\"\n",
" gefunden = []\n",
" for i, (name, _, _, _) in enumerate(bedingungen):\n",
" rest = [c for j, c in enumerate(bedingungen) if j != i] + [zusatz]\n",
" if loese(rest).success:\n",
" gefunden.append(name)\n",
" return gefunden\n",
"\n",
"\n",
"def euro(betrag: float) -> str:\n",
" return f\"{betrag:,.2f} EUR\"\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" basis = loese(BEDINGUNGEN)\n",
" db_basis = -basis.fun\n",
" plan = dict(zip(PRODUKTE, basis.x))\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" CONSTRAINT ATTRIBUTION: Was kostet uns welche Bedingung?\")\n",
" print(\"=\" * 78)\n",
" print(f\"Status: {basis.message.split('(')[0].strip()}\")\n",
" print(f\"Deckungsbeitrag des Plans: {euro(db_basis)}\\n\")\n",
" print(\" \" + \" \".join(f\"{p}: {m:.1f}\" for p, m in plan.items()))\n",
"\n",
" # --- 1. Wer begrenzt den Plan? -----------------------------------------\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (1) Welche Bedingungen binden - und was kostet die naechste Einheit?\")\n",
" print(\"=\" * 78)\n",
" print(f\"{'Bedingung':<24} {'genutzt':>9} {'Grenze':>7} {'Schlupf':>8} \"\n",
" f\"{'Schattenpreis':>11}\")\n",
" print(\"-\" * 78)\n",
" bindend = []\n",
" for name, links, richtung, grenze, schlupf, dual in auswertung(basis):\n",
" marke = \"\"\n",
" if abs(schlupf) < 1e-6:\n",
" bindend.append((name, dual))\n",
" marke = \" <-- bindend\"\n",
" print(f\"{name:<24} {links:>9.1f} {grenze:>7.0f} {schlupf:>8.1f} \"\n",
" f\"{dual:>11.2f}{marke}\")\n",
"\n",
" ohne_preis = [n for n, d in bindend if d < 1e-9]\n",
" print(f\"\\n {len(bindend)} Bedingungen binden. Aber: \"\n",
" f\"{', '.join(ohne_preis)} bindet\")\n",
" print(\" mit einem Schattenpreis von 0,00 EUR - sie beruehrt den Plan, ohne ihn\")\n",
" print(\" zu verteuern. Bindend und kostenrelevant sind zwei verschiedene Dinge.\")\n",
"\n",
" # --- 2. Der Schattenpreis als Momentaufnahme ---------------------------\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (2) Was der Schattenpreis verschweigt\")\n",
" print(\"=\" * 78)\n",
" print(\"Der Schattenpreis gilt fuer die NAECHSTE Einheit. Ein Planer kauft aber\")\n",
" print(\"keine Einheit, sondern eine ganze Schicht. Beides gegenuebergestellt:\\n\")\n",
" preise = {name: dual for name, *_, dual in auswertung(basis)}\n",
" print(f\"{'Stellschraube':<24} {'Preis':>9} {'Schritt':>7} \"\n",
" f\"{'hochgerechnet':>13} {'gemessen':>9} {'Abw.':>9}\")\n",
" print(\"-\" * 78)\n",
" wirkung = {}\n",
" for name, (delta, _) in STELLSCHRAUBEN.items():\n",
" ergebnis = loese(geaendert(name, delta))\n",
" echt = (-ergebnis.fun) - db_basis if ergebnis.success else 0.0\n",
" linear = preise[name] * abs(delta)\n",
" wirkung[name] = echt\n",
" print(f\"{name:<24} {preise[name]:>9.2f} {delta:>+7.0f} \"\n",
" f\"{linear:>13.2f} {echt:>9.2f} {echt - linear:>+9.2f}\")\n",
"\n",
" # Wo endet der Gueltigkeitsbereich? Schrittweise nachfahren.\n",
" abweichler = max(STELLSCHRAUBEN,\n",
" key=lambda n: preise[n] * abs(STELLSCHRAUBEN[n][0]) - wirkung[n])\n",
" delta_max = STELLSCHRAUBEN[abweichler][0]\n",
" grenze_gueltig = 0.0\n",
" schritt = delta_max / 60.0\n",
" vorher = db_basis\n",
" for i in range(1, 61):\n",
" d = schritt * i\n",
" e = loese(geaendert(abweichler, d))\n",
" db = -e.fun if e.success else vorher\n",
" if db - vorher > 1e-9:\n",
" grenze_gueltig = d\n",
" vorher = db\n",
" print(f\"\\n '{abweichler}': hochgerechnet \"\n",
" f\"{preise[abweichler] * abs(delta_max):.2f} EUR, tatsaechlich \"\n",
" f\"{wirkung[abweichler]:.2f} EUR.\")\n",
" print(f\" Der Preis von {preise[abweichler]:.2f} EUR gilt nur bis \"\n",
" f\"+{grenze_gueltig:.0f} Stunden. Jede weitere Stunde\")\n",
" print(\" dieser Sonderschicht bringt exakt nichts - der Engpass ist dann ein anderer.\")\n",
"\n",
" nach_preis = sorted(STELLSCHRAUBEN, key=lambda n: -preise[n])[0]\n",
" nach_wirkung = max(wirkung, key=wirkung.get)\n",
" print(f\"\\n Und die Ranglisten drehen sich: Nach Schattenpreis fuehrt\")\n",
" print(f\" '{nach_preis}' ({preise[nach_preis]:.2f} EUR je Einheit),\")\n",
" print(f\" nach tatsaechlicher Wirkung '{nach_wirkung}' \"\n",
" f\"({wirkung[nach_wirkung]:.2f} EUR).\")\n",
" print(\" Der Schattenpreis sagt, was eine Einheit wert ist - nicht, wie viele\")\n",
" print(\" Einheiten zu haben sind.\")\n",
"\n",
" # --- 3. Der Wunsch aus dem Vertrieb ------------------------------------\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(f\" (3) Was-waere-wenn: mindestens {WUNSCH_MENGE:.0f} \"\n",
" f\"{WUNSCH_PRODUKT} statt {plan[WUNSCH_PRODUKT]:.1f}\")\n",
" print(\"=\" * 78)\n",
" wunsch = (f\"Wunsch Vertrieb {WUNSCH_PRODUKT}\",\n",
" [1 if p == WUNSCH_PRODUKT else 0 for p in PRODUKTE], \">=\", WUNSCH_MENGE)\n",
" mit_wunsch = loese(BEDINGUNGEN + [wunsch])\n",
" schuldige: list[str] = []\n",
" engpass = noetig = None\n",
" wunsch_gratis = False\n",
"\n",
" if mit_wunsch.success:\n",
" print(f\"Erfuellbar, kostet {euro(db_basis - (-mit_wunsch.fun))}.\")\n",
" else:\n",
" schuldige = blockierer(BEDINGUNGEN, wunsch)\n",
" print(\"Der Wunsch ist nicht teuer - er ist UNMOEGLICH.\\n\")\n",
" print(f\"Blockiert wird er von {len(schuldige)} Bedingungen gemeinsam. \"\n",
" f\"Faellt eine davon\")\n",
" print(\"weg, ist er erfuellbar; keine allein ist 'der' Grund:\")\n",
" for name in schuldige:\n",
" e = loese([c for c in BEDINGUNGEN if c[0] != name] + [wunsch])\n",
" print(f\" ohne '{name}': moeglich, Deckungsbeitrag {euro(-e.fun)}\")\n",
"\n",
" engpass = next((n for n in schuldige if n in STELLSCHRAUBEN), schuldige[0])\n",
" for schritt in range(1, 61):\n",
" if loese(geaendert(engpass, schritt) + [wunsch]).success:\n",
" noetig = schritt\n",
" break\n",
" if noetig is not None:\n",
" e = loese(geaendert(engpass, noetig) + [wunsch])\n",
" ohne = loese(geaendert(engpass, noetig))\n",
" wunsch_gratis = abs((-e.fun) - (-ohne.fun)) < 1e-6\n",
" print(f\"\\n Mit +{noetig} bei '{engpass}' wird der Wunsch erfuellbar:\")\n",
" print(f\" Deckungsbeitrag {euro(-e.fun)} \"\n",
" f\"({-e.fun - db_basis:+,.2f} gegenueber heute).\")\n",
" if wunsch_gratis:\n",
" print(f\" Und ohne den Wunsch ergaebe dieselbe Lockerung \"\n",
" f\"{euro(-ohne.fun)} bei\")\n",
" print(f\" {ohne.x[PRODUKTE.index(WUNSCH_PRODUKT)]:.0f} \"\n",
" f\"{WUNSCH_PRODUKT} - der Wunsch kostet also NICHTS.\")\n",
" print(\" Er war nie das Problem. Der Engpass war es.\")\n",
"\n",
" # --- 4. Der Bericht ----------------------------------------------------\n",
" # Aus denselben Zahlen, die oben in Tabellen stehen, wird hier Prosa. Kein\n",
" # Satz enthaelt eine Zahl, die nicht vorher gerechnet wurde - das ist der\n",
" # ganze Trick und zugleich die Bedingung dafuer, dass der Bericht nicht\n",
" # irgendwann leise falsch wird.\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (4) Und so steht es in der Vorlage fuer die Abteilungsleiterrunde\")\n",
" print(\"=\" * 78)\n",
"\n",
" teuerste = sorted(((n, d) for n, d in bindend if d > 0), key=lambda t: -t[1])\n",
" gross = STELLSCHRAUBEN[nach_wirkung][1]\n",
" klein = STELLSCHRAUBEN[abweichler][1]\n",
"\n",
" absaetze = [\n",
" f\"Der Plan bringt {euro(db_basis)} Deckungsbeitrag. Begrenzt wird er von \"\n",
" f\"{len(bindend)} Bedingungen, von denen {len(teuerste)} tatsaechlich Geld \"\n",
" f\"kosten; \\\"{ohne_preis[0]}\\\" beruehrt den Plan, ohne ihn zu verteuern.\",\n",
"\n",
" f\"Teuerste Bindung ist \\\"{teuerste[0][0]}\\\" mit \"\n",
" f\"{teuerste[0][1]:.2f} EUR je Einheit, gefolgt von \\\"{teuerste[1][0]}\\\" \"\n",
" f\"mit {teuerste[1][1]:.2f} EUR.\",\n",
"\n",
" f\"Die groesste einzelne Verbesserung bringt {gross}: \"\n",
" f\"{euro(wirkung[nach_wirkung])} mehr Deckungsbeitrag. {klein[0].upper()}{klein[1:]} \"\n",
" f\"dagegen bringt nur {euro(wirkung[abweichler])} statt der rechnerischen \"\n",
" f\"{euro(preise[abweichler] * abs(delta_max))} - ab +{grenze_gueltig:.0f} \"\n",
" f\"Stunden begrenzt uns etwas anderes. Wer die ganze Schicht bezahlt, \"\n",
" f\"bezahlt die zweite Haelfte umsonst.\",\n",
" ]\n",
"\n",
" if schuldige and noetig is not None:\n",
" namen = \", \".join(f'\"{s}\"' for s in schuldige)\n",
" satz = (f\"Zum Wunsch des Vertriebs ({WUNSCH_MENGE:.0f} {WUNSCH_PRODUKT} statt \"\n",
" f\"{plan[WUNSCH_PRODUKT]:.1f}): Er ist mit der heutigen Ausstattung \"\n",
" f\"nicht erfuellbar - nicht aus Kostengruenden, sondern weil {namen} \"\n",
" f\"ihn gemeinsam blockieren. Mit +{noetig} Stunden bei \"\n",
" f'\"{engpass}\" wird er moeglich')\n",
" satz += (\", und er kostet dann nichts: Der Plan waehlt diese Menge von \"\n",
" \"sich aus.\" if wunsch_gratis else \".\")\n",
" absaetze.append(satz)\n",
"\n",
" for absatz in absaetze:\n",
" print()\n",
" print(textwrap.fill(absatz, width=76))\n",
" print(\"\\n\" + \"=\" * 78)"
]
},
{
"cell_type": "markdown",
"metadata": {},
@ -826,9 +1148,9 @@
"\n",
"from __future__ import annotations\n",
"\n",
"import subprocess\n",
"import sys\n",
"import multiprocessing\n",
"import time\n",
"from concurrent.futures import ProcessPoolExecutor\n",
"\n",
"import numpy as np\n",
"from pydantic import BaseModel, Field, model_validator\n",
@ -1031,25 +1353,29 @@
" if any(loesung.werte[problem.schluessel(i, j)] > 0.5 for j in range(m))]\n",
"\n",
"\n",
"def loese_in_eigenem_prozess(name: str) -> Loesung:\n",
" \"\"\"Startet dieses Programm noch einmal - mit genau einem Solverimport.\"\"\"\n",
" ergebnis = subprocess.run([sys.executable, __file__, name],\n",
" capture_output=True, text=True, timeout=300)\n",
" if ergebnis.returncode != 0:\n",
" raise RuntimeError(ergebnis.stderr.strip().splitlines()[-1])\n",
" # Das DTO als JSON - genau dafuer ist ein Datenobjekt ohne Solverbezug gut.\n",
" return Loesung.model_validate_json(ergebnis.stdout.strip().splitlines()[-1])\n",
"def loese_in_eigenem_prozess(name: str, problem: Standortproblem) -> Loesung:\n",
" \"\"\"Laesst genau einen Modellbauer in einem frischen Prozess rechnen.\n",
"\n",
" 'spawn' statt des Linux-Standards 'fork': Der Kindprozess startet mit\n",
" einem leeren Interpreter und importiert nur den Solver, den SEIN\n",
" Modellbauer braucht. max_tasks_per_child=1 sorgt dafuer, dass der Pool\n",
" seinen Arbeiter nicht wiederverwendet - sonst saessen beim zweiten Aufruf\n",
" wieder beide Bibliotheken im selben Prozess.\n",
"\n",
" Hin und zurueck wandert das Domaenenmodell bzw. das Loesungs-DTO. Beide\n",
" kennen keinen Solver, sind also serialisierbar - genau dafuer sind sie da.\n",
" \"\"\"\n",
" with ProcessPoolExecutor(\n",
" max_workers=1,\n",
" mp_context=multiprocessing.get_context(\"spawn\"),\n",
" max_tasks_per_child=1) as pool:\n",
" return pool.submit(MODELLBAUER[name], problem).result(timeout=300)\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" problem = beispielproblem()\n",
"\n",
" # --- Kindprozess: rechnen und das DTO als JSON ausgeben ---------------\n",
" if len(sys.argv) > 1:\n",
" print(MODELLBAUER[sys.argv[1]](problem).model_dump_json())\n",
" sys.exit(0)\n",
"\n",
" # --- Hauptprozess: beide Solver anstossen und vergleichen -------------\n",
" # --- Beide Solver anstossen und vergleichen ---------------------------\n",
" print(\"=\" * 82)\n",
" print(\" DERSELBE FALL, ZWEI SOLVER - UND EIN AUSWERTUNGSCODE\")\n",
" print(\"=\" * 82)\n",
@ -1061,7 +1387,7 @@
" loesungen: dict[str, Loesung] = {}\n",
" for name, beschriftung in [(\"cpsat\", \"OR-Tools CP-SAT\"),\n",
" (\"highs\", \"HiGHS (highspy)\")]:\n",
" loesung = loesungen[name] = loese_in_eigenem_prozess(name)\n",
" loesung = loesungen[name] = loese_in_eigenem_prozess(name, problem)\n",
" beanstandungen = pruefe_zuordnung(problem, loesung)\n",
"\n",
" print(f\"{beschriftung}\")\n",

View file

@ -593,113 +593,124 @@
"\n",
"from __future__ import annotations\n",
"\n",
"import json\n",
"import subprocess\n",
"import sys\n",
"import textwrap\n",
"import multiprocessing\n",
"import resource\n",
"import time\n",
"from concurrent.futures import ProcessPoolExecutor\n",
"\n",
"import numpy as np\n",
"\n",
"GROESSEN = [(10, 10), (32, 32), (100, 100)] # (Lager, Kunden) -> 100 / 1.024 / 10.000 Variablen\n",
"\n",
"\n",
"# Jeder Eintrag ist ein eigenstaendiges Programm: Instanz aufbauen, loesen,\n",
"# Ergebnis als JSON ausgeben. Die Instanz wird in jedem Kindprozess aus\n",
"# derselben Saat neu erzeugt - so reist nichts ueber die Prozessgrenze,\n",
"# was das Ergebnis verfaelschen koennte.\n",
"VORSPANN = \"\"\"\n",
"import json, time, resource\n",
"import numpy as np\n",
"# Instanz und Speichermessung stehen als gewoehnliche Funktionen hier - nicht\n",
"# in einem String, den ein Kindprozess ausfuehrt. Jede Messfunktion baut die\n",
"# Instanz aus derselben Saat neu auf, damit ueber die Prozessgrenze nichts\n",
"# reist, was das Ergebnis verfaelschen koennte.\n",
"\n",
"def instanz(m, n):\n",
"def instanz(m: int, n: int):\n",
" rng = np.random.default_rng(20)\n",
" kosten = rng.integers(5, 95, (m, n)).astype(float)\n",
" angebot = rng.integers(50, 150, m).astype(float)\n",
" bedarf = angebot.sum() * rng.dirichlet(np.ones(n))\n",
" return kosten, angebot, bedarf\n",
"\n",
"def speicher_mb():\n",
" # ru_maxrss ist unter Linux in Kilobyte\n",
"\n",
"def speicher_mb() -> float:\n",
" # ru_maxrss ist unter Linux in Kilobyte. Gemessen wird der Kindprozess -\n",
" # deshalb muss jede Messung einen eigenen bekommen.\n",
" return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024\n",
"\n",
"M, N = {m}, {n}\n",
"kosten, angebot, bedarf = instanz(M, N)\n",
"\"\"\"\n",
"\n",
"ANSAETZE = {\n",
" \"scipy.linprog\": \"\"\"\n",
"def messe_scipy(m: int, n: int):\n",
" from scipy.optimize import linprog\n",
" kosten, angebot, bedarf = instanz(m, n)\n",
" t0 = time.perf_counter()\n",
" c = kosten.reshape(-1)\n",
" A_ub = np.zeros((M, M * N)); A_eq = np.zeros((N, M * N))\n",
" for i in range(M):\n",
" A_ub[i, i * N:(i + 1) * N] = 1.0\n",
" for j in range(N):\n",
" A_eq[j, j::N] = 1.0\n",
" A_ub = np.zeros((m, m * n)); A_eq = np.zeros((n, m * n))\n",
" for i in range(m):\n",
" A_ub[i, i * n:(i + 1) * n] = 1.0\n",
" for j in range(n):\n",
" A_eq[j, j::n] = 1.0\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter()\n",
" r = linprog(c=c, A_ub=A_ub, b_ub=angebot, A_eq=A_eq, b_eq=bedarf,\n",
" bounds=(0, None), method=\"highs\")\n",
" loesen = time.perf_counter() - t0\n",
" ausgabe = (float(r.fun), aufbau, loesen, speicher_mb())\n",
" \"\"\",\n",
" return float(r.fun), aufbau, loesen, speicher_mb()\n",
"\n",
" \"highspy\": \"\"\"\n",
"\n",
"def messe_highspy(m: int, n: int):\n",
" import highspy\n",
" kosten, angebot, bedarf = instanz(m, n)\n",
" t0 = time.perf_counter()\n",
" h = highspy.Highs(); h.setOptionValue(\"output_flag\", False)\n",
" h.addVars(M * N, np.zeros(M * N), np.full(M * N, highspy.kHighsInf))\n",
" for k in range(M * N):\n",
" h.addVars(m * n, np.zeros(m * n), np.full(m * n, highspy.kHighsInf))\n",
" for k in range(m * n):\n",
" h.changeColCost(k, float(kosten.reshape(-1)[k]))\n",
" for i in range(M):\n",
" idx = np.arange(i * N, (i + 1) * N, dtype=np.int32)\n",
" h.addRow(-highspy.kHighsInf, float(angebot[i]), N, idx, np.ones(N))\n",
" for j in range(N):\n",
" idx = np.arange(j, M * N, N, dtype=np.int32)\n",
" h.addRow(float(bedarf[j]), float(bedarf[j]), M, idx, np.ones(M))\n",
" for i in range(m):\n",
" idx = np.arange(i * n, (i + 1) * n, dtype=np.int32)\n",
" h.addRow(-highspy.kHighsInf, float(angebot[i]), n, idx, np.ones(n))\n",
" for j in range(n):\n",
" idx = np.arange(j, m * n, n, dtype=np.int32)\n",
" h.addRow(float(bedarf[j]), float(bedarf[j]), m, idx, np.ones(m))\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); h.run(); loesen = time.perf_counter() - t0\n",
" ausgabe = (h.getInfo().objective_function_value, aufbau, loesen, speicher_mb())\n",
" \"\"\",\n",
" return h.getInfo().objective_function_value, aufbau, loesen, speicher_mb()\n",
"\n",
" \"ortools/GLOP\": \"\"\"\n",
"\n",
"def messe_ortools(m: int, n: int):\n",
" from ortools.linear_solver import pywraplp\n",
" kosten, angebot, bedarf = instanz(m, n)\n",
" t0 = time.perf_counter()\n",
" s = pywraplp.Solver.CreateSolver(\"GLOP\")\n",
" x = [[s.NumVar(0, s.infinity(), f\"x{i}_{j}\") for j in range(N)]\n",
" for i in range(M)]\n",
" for i in range(M):\n",
" x = [[s.NumVar(0, s.infinity(), f\"x{i}_{j}\") for j in range(n)]\n",
" for i in range(m)]\n",
" for i in range(m):\n",
" s.Add(sum(x[i]) <= float(angebot[i]))\n",
" for j in range(N):\n",
" s.Add(sum(x[i][j] for i in range(M)) == float(bedarf[j]))\n",
" for j in range(n):\n",
" s.Add(sum(x[i][j] for i in range(m)) == float(bedarf[j]))\n",
" s.Minimize(sum(float(kosten[i, j]) * x[i][j]\n",
" for i in range(M) for j in range(N)))\n",
" for i in range(m) for j in range(n)))\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); s.Solve(); loesen = time.perf_counter() - t0\n",
" ausgabe = (s.Objective().Value(), aufbau, loesen, speicher_mb())\n",
" \"\"\",\n",
" return s.Objective().Value(), aufbau, loesen, speicher_mb()\n",
"\n",
" \"cvxpy\": \"\"\"\n",
"\n",
"def messe_cvxpy(m: int, n: int):\n",
" import cvxpy as cp\n",
" kosten, angebot, bedarf = instanz(m, n)\n",
" t0 = time.perf_counter()\n",
" x = cp.Variable((M, N), nonneg=True)\n",
" x = cp.Variable((m, n), nonneg=True)\n",
" problem = cp.Problem(cp.Minimize(cp.sum(cp.multiply(kosten, x))),\n",
" [cp.sum(x, axis=1) <= angebot,\n",
" cp.sum(x, axis=0) == bedarf])\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); problem.solve(); loesen = time.perf_counter() - t0\n",
" ausgabe = (float(problem.value), aufbau, loesen, speicher_mb())\n",
" \"\"\",\n",
"}\n",
" return float(problem.value), aufbau, loesen, speicher_mb()\n",
"\n",
"\n",
"def messe(name: str, quelltext: str, m: int, n: int):\n",
" \"\"\"Fuehrt einen Ansatz in einem eigenen Prozess aus.\"\"\"\n",
" programm = (VORSPANN.format(m=m, n=n) + textwrap.dedent(quelltext)\n",
" + \"\\nprint(json.dumps(ausgabe))\\n\")\n",
" ergebnis = subprocess.run([sys.executable, \"-c\", programm],\n",
" capture_output=True, text=True, timeout=600)\n",
" if ergebnis.returncode != 0:\n",
" return None, ergebnis.stderr.strip().splitlines()[-1][:60]\n",
" return json.loads(ergebnis.stdout.strip().splitlines()[-1]), None\n",
"ANSAETZE = {\"scipy.linprog\": messe_scipy, \"highspy\": messe_highspy,\n",
" \"ortools/GLOP\": messe_ortools, \"cvxpy\": messe_cvxpy}\n",
"\n",
"\n",
"def messe(funktion, m: int, n: int):\n",
" \"\"\"Fuehrt eine Messfunktion in einem FRISCHEN Prozess aus.\n",
"\n",
" 'spawn' und max_tasks_per_child=1 zusammen garantieren, was Regel 1\n",
" verlangt: Jede Messung sieht einen leeren Interpreter. Ohne das\n",
" zweite wuerde der Pool seinen Arbeiter wiederverwenden - dann waere\n",
" der Speicherwert der zweiten Bibliothek um die erste zu hoch, und\n",
" ortools und highspy saessen im selben Prozess.\n",
" \"\"\"\n",
" with ProcessPoolExecutor(\n",
" max_workers=1,\n",
" mp_context=multiprocessing.get_context(\"spawn\"),\n",
" max_tasks_per_child=1) as pool:\n",
" try:\n",
" return pool.submit(funktion, m, n).result(timeout=600), None\n",
" except Exception as fehler:\n",
" return None, str(fehler).strip().splitlines()[-1][:60]\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
@ -717,8 +728,8 @@
" f\"{'Loesen':>9} {'Anteil':>8} {'Speicher':>10}\")\n",
" print(\" \" + \"-\" * 72)\n",
" zielwerte = {}\n",
" for name, quelltext in ANSAETZE.items():\n",
" werte, fehler = messe(name, quelltext, m, n)\n",
" for name, funktion in ANSAETZE.items():\n",
" werte, fehler = messe(funktion, m, n)\n",
" if werte is None:\n",
" print(f\" {name:<16} nicht verfuegbar: {fehler}\")\n",
" continue\n",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more