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>
1122 lines
54 KiB
Text
Generated
1122 lines
54 KiB
Text
Generated
{
|
|
"cells": [
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"# Kapitel 23: Testen, Messen, Ausliefern\n",
|
|
"\n",
|
|
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
|
|
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
|
|
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
|
|
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## Die Testsuite\n",
|
|
"\n",
|
|
"`test_or_kern.py`\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"#!/usr/bin/env python3\n",
|
|
"\n",
|
|
"# test_or_kern.py\n",
|
|
"\"\"\"\n",
|
|
"Kapitel Testen: Eine Testsuite fuer ein Optimierungsmodell.\n",
|
|
"\n",
|
|
"Das Grundproblem beim Testen von Optimierungsmodellen: Man kennt die richtige\n",
|
|
"Antwort nicht. Bei einer Funktion `steuer(brutto)` schreibt man den erwarteten\n",
|
|
"Wert hin. Bei \"der beste Produktionsplan fuer 400 Auftraege\" gibt es niemanden,\n",
|
|
"der ihn unabhaengig ausrechnen koennte - sonst braeuchte man den Solver nicht.\n",
|
|
"\n",
|
|
"Deshalb testet man nicht den WERT, sondern vier andere Dinge:\n",
|
|
"\n",
|
|
" 1. EIGENSCHAFTEN Die Loesung haelt jede Nebenbedingung ein, und der\n",
|
|
" ausgewiesene Zielwert passt zu den Mengen.\n",
|
|
" 2. INVARIANTEN Was das Ergebnis NICHT aendern darf: die Reihenfolge der\n",
|
|
" Produkte, die Waehrungseinheit, ein zusaetzliches Produkt\n",
|
|
" mit Deckungsbeitrag 0.\n",
|
|
" 3. REGRESSION Eine kleine Instanz mit von Hand nachgerechnetem Optimum.\n",
|
|
" 4. FEHLERFAELLE Unsinnige Eingaben werden abgewiesen, unloesbare Modelle\n",
|
|
" als unloesbar erkannt - und die Abnahmepruefung schlaegt\n",
|
|
" tatsaechlich an, wenn man ihr eine kaputte Loesung gibt.\n",
|
|
"\n",
|
|
"Der vierte Punkt ist der wichtigste und wird am haeufigsten vergessen: Eine\n",
|
|
"Pruefung, die noch nie etwas gefunden hat, ist keine Pruefung, sondern eine\n",
|
|
"Vermutung. Die Tests unten fuettern `pruefe_loesung()` deshalb absichtlich mit\n",
|
|
"falschen Loesungen und verlangen, dass sie anschlaegt.\n",
|
|
"\n",
|
|
"Aufruf:\n",
|
|
" pytest test_or_kern.py -v\n",
|
|
" python3 test_or_kern.py # ruft pytest selbst auf\n",
|
|
"\n",
|
|
"Benoetigt: pytest, numpy, pydantic, ortools, scipy (ueber or_kern)\n",
|
|
"\"\"\"\n",
|
|
"\n",
|
|
"from __future__ import annotations\n",
|
|
"\n",
|
|
"import pytest\n",
|
|
"\n",
|
|
"from or_kern import (Loesung, Produkt, Produktionsproblem, SolverStatus,\n",
|
|
" loese_mit_glop, loese_mit_scipy, pruefe_loesung,\n",
|
|
" status_von_scipy)\n",
|
|
"\n",
|
|
"\n",
|
|
"# --- Die Instanz, gegen die getestet wird ----------------------------------\n",
|
|
"\n",
|
|
"def schreinerei() -> Produktionsproblem:\n",
|
|
" \"\"\"Der Fall aus Kapitel Einfuehrung - klein genug fuer die Handrechnung.\n",
|
|
"\n",
|
|
" Optimum: 30 Tische, 60 Stuehle, Deckungsbeitrag 10.800 EUR.\n",
|
|
" Beide Ressourcen sind voll ausgelastet (3*30 + 1*60 = 150,\n",
|
|
" 6*30 + 1*60 = 240).\n",
|
|
" \"\"\"\n",
|
|
" return Produktionsproblem(\n",
|
|
" produkte=[\n",
|
|
" Produkt(name=\"Tisch\", deckungsbeitrag=240.0,\n",
|
|
" verbrauch={\"Montagestunden\": 3.0, \"Plattenmaterial\": 6.0}),\n",
|
|
" Produkt(name=\"Stuhl\", deckungsbeitrag=60.0,\n",
|
|
" verbrauch={\"Montagestunden\": 1.0, \"Plattenmaterial\": 1.0}),\n",
|
|
" ],\n",
|
|
" kapazitaeten={\"Montagestunden\": 150.0, \"Plattenmaterial\": 240.0},\n",
|
|
" )\n",
|
|
"\n",
|
|
"\n",
|
|
"# Beide Modellbauer durchlaufen JEDEN Test. Ein Test, der nur mit einem Solver\n",
|
|
"# laeuft, prueft die Bibliothek mit - nicht das Modell.\n",
|
|
"MODELLBAUER = [loese_mit_glop, loese_mit_scipy]\n",
|
|
"NAMEN = [\"glop\", \"scipy\"]\n",
|
|
"\n",
|
|
"\n",
|
|
"@pytest.fixture(params=MODELLBAUER, ids=NAMEN)\n",
|
|
"def bauer(request):\n",
|
|
" return request.param\n",
|
|
"\n",
|
|
"\n",
|
|
"# --- 1. Eigenschaften ------------------------------------------------------\n",
|
|
"\n",
|
|
"def test_loesung_haelt_alle_nebenbedingungen_ein(bauer):\n",
|
|
" \"\"\"Die Abnahmepruefung darf nichts zu beanstanden haben.\"\"\"\n",
|
|
" problem = schreinerei()\n",
|
|
" loesung = bauer(problem)\n",
|
|
" assert loesung.status is SolverStatus.OPTIMAL\n",
|
|
" assert pruefe_loesung(problem, loesung) == []\n",
|
|
"\n",
|
|
"\n",
|
|
"def test_zielwert_passt_zu_den_mengen(bauer):\n",
|
|
" \"\"\"Der ausgewiesene Zielwert wird unabhaengig nachgerechnet.\n",
|
|
"\n",
|
|
" Klingt trivial, ist es nicht: Wer die Zielfunktion im Modell anders\n",
|
|
" zusammensetzt als im Bericht (etwa Gebuehren einmal abgezogen, einmal\n",
|
|
" nicht), merkt es sonst nie.\n",
|
|
" \"\"\"\n",
|
|
" problem = schreinerei()\n",
|
|
" loesung = bauer(problem)\n",
|
|
" nachgerechnet = sum(p.deckungsbeitrag * loesung.werte[p.name]\n",
|
|
" for p in problem.produkte)\n",
|
|
" assert loesung.zielwert == pytest.approx(nachgerechnet, abs=1e-6)\n",
|
|
"\n",
|
|
"\n",
|
|
"def test_beide_solver_liefern_dasselbe():\n",
|
|
" \"\"\"Der Kern der Architektur aus Kapitel Praxisfallen, als Test.\"\"\"\n",
|
|
" problem = schreinerei()\n",
|
|
" glop, scipy_ = loese_mit_glop(problem), loese_mit_scipy(problem)\n",
|
|
" assert glop.zielwert == pytest.approx(scipy_.zielwert, abs=1e-6)\n",
|
|
" for ressource in problem.ressourcen:\n",
|
|
" assert glop.schattenpreise[ressource] == pytest.approx(\n",
|
|
" scipy_.schattenpreise[ressource], abs=1e-6)\n",
|
|
"\n",
|
|
"\n",
|
|
"# --- 2. Invarianten --------------------------------------------------------\n",
|
|
"\n",
|
|
"def test_produktreihenfolge_aendert_nichts(bauer):\n",
|
|
" \"\"\"Dieselben Daten in anderer Zeilenreihenfolge - gleiches Ergebnis.\n",
|
|
"\n",
|
|
" Das faengt die Spaltenvertauschungsfalle aus Kapitel Finanzdaten ab: ein\n",
|
|
" Modell, das Positionen statt Namen benutzt, faellt hier durch.\n",
|
|
" \"\"\"\n",
|
|
" problem = schreinerei()\n",
|
|
" gedreht = Produktionsproblem(produkte=list(reversed(problem.produkte)),\n",
|
|
" kapazitaeten=problem.kapazitaeten)\n",
|
|
" assert bauer(problem).zielwert == pytest.approx(bauer(gedreht).zielwert)\n",
|
|
"\n",
|
|
"\n",
|
|
"def test_waehrungseinheit_skaliert_linear(bauer):\n",
|
|
" \"\"\"Deckungsbeitraege in Cent statt Euro: Zielwert mal 100, Mengen gleich.\n",
|
|
"\n",
|
|
" Der Test prueft nicht die Mathematik - die ist offensichtlich -, sondern\n",
|
|
" die NUMERIK. Wer schlecht skalierte Modelle baut (Kapitel Fundament),\n",
|
|
" bekommt hier Abweichungen weit ueber der Toleranz.\n",
|
|
" \"\"\"\n",
|
|
" problem = schreinerei()\n",
|
|
" in_cent = Produktionsproblem(\n",
|
|
" produkte=[Produkt(name=p.name, deckungsbeitrag=p.deckungsbeitrag * 100,\n",
|
|
" verbrauch=p.verbrauch) for p in problem.produkte],\n",
|
|
" kapazitaeten=problem.kapazitaeten)\n",
|
|
" basis, skaliert = bauer(problem), bauer(in_cent)\n",
|
|
" assert skaliert.zielwert == pytest.approx(basis.zielwert * 100, rel=1e-9)\n",
|
|
" for p in problem.produkte:\n",
|
|
" assert skaliert.werte[p.name] == pytest.approx(basis.werte[p.name],\n",
|
|
" abs=1e-6)\n",
|
|
"\n",
|
|
"\n",
|
|
"def test_produkt_ohne_deckungsbeitrag_aendert_das_optimum_nicht(bauer):\n",
|
|
" \"\"\"Ein Produkt, das nichts einbringt, darf den Zielwert nicht heben.\"\"\"\n",
|
|
" problem = schreinerei()\n",
|
|
" mit_nullprodukt = Produktionsproblem(\n",
|
|
" produkte=problem.produkte + [\n",
|
|
" Produkt(name=\"Muster\", deckungsbeitrag=0.0,\n",
|
|
" verbrauch={\"Montagestunden\": 1.0, \"Plattenmaterial\": 1.0})],\n",
|
|
" kapazitaeten=problem.kapazitaeten)\n",
|
|
" assert bauer(mit_nullprodukt).zielwert <= bauer(problem).zielwert + 1e-6\n",
|
|
"\n",
|
|
"\n",
|
|
"# --- 3. Regression ---------------------------------------------------------\n",
|
|
"\n",
|
|
"def test_bekanntes_optimum(bauer):\n",
|
|
" \"\"\"Von Hand nachgerechnet (Kapitel Einfuehrung, Handrechnung).\n",
|
|
"\n",
|
|
" Das ist der einzige Test, der einen ZAHLENWERT festschreibt - und er darf\n",
|
|
" es, weil dieser Wert von Hand belegt ist. Fuer grosse Instanzen gibt es\n",
|
|
" diesen Test nicht; dort tragen die Eigenschaften und Invarianten.\n",
|
|
" \"\"\"\n",
|
|
" loesung = bauer(schreinerei())\n",
|
|
" assert loesung.zielwert == pytest.approx(10_800.0, abs=1e-6)\n",
|
|
" assert loesung.werte[\"Tisch\"] == pytest.approx(30.0, abs=1e-6)\n",
|
|
" assert loesung.werte[\"Stuhl\"] == pytest.approx(60.0, abs=1e-6)\n",
|
|
"\n",
|
|
"\n",
|
|
"def test_schattenpreise_bekannt(bauer):\n",
|
|
" \"\"\"40 EUR je Montagestunde, 20 EUR je Einheit Plattenmaterial.\"\"\"\n",
|
|
" loesung = bauer(schreinerei())\n",
|
|
" assert loesung.schattenpreise[\"Montagestunden\"] == pytest.approx(40.0, abs=1e-6)\n",
|
|
" assert loesung.schattenpreise[\"Plattenmaterial\"] == pytest.approx(20.0, abs=1e-6)\n",
|
|
"\n",
|
|
"\n",
|
|
"# --- 4. Fehlerfaelle: schlaegt die Pruefung ueberhaupt an? -----------------\n",
|
|
"\n",
|
|
"def test_pruefung_findet_kapazitaetsverletzung():\n",
|
|
" \"\"\"Eine Loesung, die zu viel verbraucht, MUSS beanstandet werden.\"\"\"\n",
|
|
" problem = schreinerei()\n",
|
|
" kaputt = Loesung(status=SolverStatus.OPTIMAL,\n",
|
|
" werte={\"Tisch\": 50.0, \"Stuhl\": 60.0}, # 3*50+60 = 210 > 150\n",
|
|
" zielwert=50 * 240.0 + 60 * 60.0)\n",
|
|
" beanstandungen = pruefe_loesung(problem, kaputt)\n",
|
|
" assert any(\"Montagestunden\" in b for b in beanstandungen)\n",
|
|
"\n",
|
|
"\n",
|
|
"def test_pruefung_findet_falschen_zielwert():\n",
|
|
" \"\"\"Mengen und Zielwert passen nicht zusammen - der haeufigste stille Fehler.\"\"\"\n",
|
|
" problem = schreinerei()\n",
|
|
" kaputt = Loesung(status=SolverStatus.OPTIMAL,\n",
|
|
" werte={\"Tisch\": 30.0, \"Stuhl\": 60.0},\n",
|
|
" zielwert=99_999.0)\n",
|
|
" assert any(\"Zielwert\" in b for b in pruefe_loesung(problem, kaputt))\n",
|
|
"\n",
|
|
"\n",
|
|
"def test_pruefung_findet_negative_mengen():\n",
|
|
" problem = schreinerei()\n",
|
|
" kaputt = Loesung(status=SolverStatus.OPTIMAL,\n",
|
|
" werte={\"Tisch\": -5.0, \"Stuhl\": 60.0},\n",
|
|
" zielwert=-5 * 240.0 + 60 * 60.0)\n",
|
|
" assert any(\"negativ\" in b for b in pruefe_loesung(problem, kaputt))\n",
|
|
"\n",
|
|
"\n",
|
|
"def test_pruefung_findet_gebrochene_ganzzahligkeit():\n",
|
|
" \"\"\"Die Falle aus Kapitel MILP: 0,99999998 ist nicht 1.\"\"\"\n",
|
|
" problem = schreinerei()\n",
|
|
" fast_ganz = Loesung(status=SolverStatus.OPTIMAL,\n",
|
|
" werte={\"Tisch\": 29.4, \"Stuhl\": 61.8},\n",
|
|
" zielwert=29.4 * 240.0 + 61.8 * 60.0)\n",
|
|
" beanstandungen = pruefe_loesung(problem, fast_ganz, ganzzahlig=[\"Tisch\"])\n",
|
|
" assert any(\"ganzzahlig\" in b for b in beanstandungen)\n",
|
|
"\n",
|
|
"\n",
|
|
"def test_pruefung_verweigert_unbrauchbaren_status():\n",
|
|
" \"\"\"Ohne verwertbares Ergebnis wird gar nicht erst gerechnet.\"\"\"\n",
|
|
" problem = schreinerei()\n",
|
|
" ohne = Loesung(status=SolverStatus.UNZULAESSIG)\n",
|
|
" beanstandungen = pruefe_loesung(problem, ohne)\n",
|
|
" assert len(beanstandungen) == 1 and \"unzulaessig\" in beanstandungen[0]\n",
|
|
"\n",
|
|
"\n",
|
|
"@pytest.mark.parametrize(\"daten, stichwort\", [\n",
|
|
" (dict(produkte=[Produkt(name=\"Tisch\", deckungsbeitrag=240.0,\n",
|
|
" verbrauch={\"Montagestunden\": 3.0})],\n",
|
|
" kapazitaeten={\"Montagestunden\": 0.0}), \"greater than\"),\n",
|
|
" (dict(produkte=[Produkt(name=\"Regal\", deckungsbeitrag=130.0,\n",
|
|
" verbrauch={\"Lackieren\": 2.0})],\n",
|
|
" kapazitaeten={\"Montagestunden\": 150.0}), \"Kapazitaetsangabe\"),\n",
|
|
" (dict(produkte=[], kapazitaeten={\"Montagestunden\": 150.0}), \"at least 1\"),\n",
|
|
"])\n",
|
|
"def test_unsinnige_eingaben_werden_beim_einlesen_abgewiesen(daten, stichwort):\n",
|
|
" \"\"\"Drei Fehler, die NICHT erst beim Loesen auffallen duerfen.\"\"\"\n",
|
|
" with pytest.raises(Exception) as fehler:\n",
|
|
" Produktionsproblem(**daten)\n",
|
|
" assert stichwort in str(fehler.value)\n",
|
|
"\n",
|
|
"\n",
|
|
"def test_doppelter_produktname_wird_abgewiesen():\n",
|
|
" produkt = Produkt(name=\"Tisch\", deckungsbeitrag=240.0,\n",
|
|
" verbrauch={\"Montagestunden\": 3.0})\n",
|
|
" with pytest.raises(Exception) as fehler:\n",
|
|
" Produktionsproblem(produkte=[produkt, produkt],\n",
|
|
" kapazitaeten={\"Montagestunden\": 150.0})\n",
|
|
" assert \"mehrfach\" in str(fehler.value)\n",
|
|
"\n",
|
|
"\n",
|
|
"@pytest.mark.parametrize(\"rohstatus, erwartet\", [\n",
|
|
" (0, SolverStatus.OPTIMAL),\n",
|
|
" (1, SolverStatus.ZEITLIMIT),\n",
|
|
" (2, SolverStatus.UNZULAESSIG),\n",
|
|
" (3, SolverStatus.UNBESCHRAENKT),\n",
|
|
" (4, SolverStatus.FEHLERHAFT),\n",
|
|
" (99, SolverStatus.UNBEKANNT),\n",
|
|
"])\n",
|
|
"def test_statusuebersetzung_scipy(rohstatus, erwartet):\n",
|
|
" \"\"\"Die Uebersetzertabelle selbst - inklusive des unbekannten Falls.\"\"\"\n",
|
|
" class Ergebnis:\n",
|
|
" status = rohstatus\n",
|
|
" assert status_von_scipy(Ergebnis()) is erwartet\n",
|
|
"\n",
|
|
"\n",
|
|
"def test_status_eigenschaften_sind_konsistent():\n",
|
|
" \"\"\"brauchbar und modellfehler duerfen sich nie ueberschneiden.\"\"\"\n",
|
|
" for status in SolverStatus:\n",
|
|
" assert not (status.brauchbar and status.modellfehler)\n",
|
|
"\n",
|
|
"\n",
|
|
"# Die beiden folgenden Tests gab es zuerst nicht. Sie sind entstanden, weil\n",
|
|
"# Mutationstest.py zwei eingebaute Fehler UEBERLEBEN liess - siehe den\n",
|
|
"# Abschnitt \"Wer testet die Tests?\".\n",
|
|
"\n",
|
|
"def test_nur_optimal_und_zulaessig_gelten_als_brauchbar():\n",
|
|
" \"\"\"ZEITLIMIT ist NICHT brauchbar - der Solver hat nichts gefunden.\n",
|
|
"\n",
|
|
" Der Unterschied entscheidet, ob ein Nachtjob einen Plan ausliefert oder\n",
|
|
" Alarm schlaegt. Ein aufgeweichtes 'brauchbar' faellt sonst nirgends auf.\n",
|
|
" \"\"\"\n",
|
|
" assert SolverStatus.OPTIMAL.brauchbar\n",
|
|
" assert SolverStatus.ZULAESSIG.brauchbar\n",
|
|
" for status in (SolverStatus.ZEITLIMIT, SolverStatus.UNZULAESSIG,\n",
|
|
" SolverStatus.UNBESCHRAENKT, SolverStatus.FEHLERHAFT,\n",
|
|
" SolverStatus.UNBEKANNT):\n",
|
|
" assert not status.brauchbar, f\"{status.value} darf nicht brauchbar sein\"\n",
|
|
"\n",
|
|
"\n",
|
|
"def test_ressourcenreihenfolge_haengt_nicht_an_der_eingabe():\n",
|
|
" \"\"\"Zwei Mappings mit gleichem Inhalt, andere Einfuegereihenfolge.\n",
|
|
"\n",
|
|
" Die Verbrauchsmatrix muss zeilenweise identisch sein. Ohne diese\n",
|
|
" Zusicherung passt die Matrix irgendwann nicht mehr zum\n",
|
|
" Kapazitaetsvektor - die Vertauschungsfalle aus Kapitel Finanzdaten,\n",
|
|
" nur eine Ebene tiefer.\n",
|
|
" \"\"\"\n",
|
|
" produkte = schreinerei().produkte\n",
|
|
" vorwaerts = Produktionsproblem(\n",
|
|
" produkte=produkte,\n",
|
|
" kapazitaeten={\"Montagestunden\": 150.0, \"Plattenmaterial\": 240.0})\n",
|
|
" rueckwaerts = Produktionsproblem(\n",
|
|
" produkte=produkte,\n",
|
|
" kapazitaeten={\"Plattenmaterial\": 240.0, \"Montagestunden\": 150.0})\n",
|
|
" assert vorwaerts.ressourcen == rueckwaerts.ressourcen\n",
|
|
" assert (vorwaerts.verbrauchsmatrix() == rueckwaerts.verbrauchsmatrix()).all()\n",
|
|
" assert (vorwaerts.kapazitaetsvektor() == rueckwaerts.kapazitaetsvektor()).all()\n",
|
|
"\n",
|
|
"\n",
|
|
"if __name__ == \"__main__\":\n",
|
|
" import sys\n",
|
|
" sys.exit(pytest.main([__file__, \"-v\", \"--tb=short\", \"-p\", \"no:cacheprovider\"]))"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## Wer testet die Tests?\n",
|
|
"\n",
|
|
"`Mutationstest.py`\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"#!/usr/bin/env python3\n",
|
|
"\n",
|
|
"# Mutationstest.py\n",
|
|
"\"\"\"\n",
|
|
"Kapitel Testen: Wer testet die Tests?\n",
|
|
"\n",
|
|
"`test_or_kern.py` besteht aus 31 Tests, und alle sind gruen. Das ist noch kein\n",
|
|
"Ergebnis. Eine Testsuite, die nie rot war, ist keine Pruefung, sondern eine\n",
|
|
"Vermutung - sie koennte aus lauter `assert True` bestehen und saehe genauso aus.\n",
|
|
"\n",
|
|
"Dieses Programm prueft die Pruefung. Es baut nacheinander KLEINE, gezielte\n",
|
|
"Fehler in `or_kern.py` ein - jeder einzelne ist ein Fehler, den man beim\n",
|
|
"Programmieren tatsaechlich macht - und laesst die Testsuite darauf los. Fuer\n",
|
|
"jede Mutation gibt es genau zwei moegliche Ausgaenge:\n",
|
|
"\n",
|
|
" GETOETET Mindestens ein Test wird rot. Die Suite haette diesen Fehler\n",
|
|
" in der Praxis gefunden.\n",
|
|
" UEBERLEBT Alle Tests bleiben gruen. Die Suite haette diesen Fehler\n",
|
|
" DURCHGEHEN LASSEN - hier fehlt ein Test.\n",
|
|
"\n",
|
|
"Ueberlebende Mutationen sind das eigentliche Ergebnis: Sie zeigen die Luecken,\n",
|
|
"und zwar ohne dass man sie erraten muss.\n",
|
|
"\n",
|
|
"Das Verfahren heisst Mutationstest und ist alt; neu ist nur, wie gut es zu\n",
|
|
"Optimierungsmodellen passt. Dort kann man den Zielwert meist nicht gegen einen\n",
|
|
"bekannten Sollwert pruefen - also weiss man ohne so ein Experiment nicht, ob\n",
|
|
"die Ersatzpruefungen (Eigenschaften, Invarianten) ueberhaupt greifen.\n",
|
|
"\n",
|
|
"WICHTIG: Es wird nichts veraendert. Jede Mutation laeuft in einem eigenen\n",
|
|
"temporaeren Verzeichnis mit einer Kopie von or_kern.py.\n",
|
|
"\n",
|
|
"Benoetigt: pytest; im selben Verzeichnis or_kern.py und test_or_kern.py\n",
|
|
"\"\"\"\n",
|
|
"\n",
|
|
"from __future__ import annotations\n",
|
|
"\n",
|
|
"import os\n",
|
|
"import shutil\n",
|
|
"import subprocess\n",
|
|
"import sys\n",
|
|
"import tempfile\n",
|
|
"\n",
|
|
"# (Name, gesuchter Text, Ersatz) - jede Zeile ein realistischer Fehler.\n",
|
|
"MUTATIONEN = [\n",
|
|
" (\"Nebenbedingung umgedreht (<= wird >=)\",\n",
|
|
" \"<= problem.kapazitaeten[ressource], name=ressource)\",\n",
|
|
" \">= problem.kapazitaeten[ressource], name=ressource)\"),\n",
|
|
"\n",
|
|
" (\"Maximierung wird Minimierung\",\n",
|
|
" \"solver.Maximize(sum(menge[p.name] * p.deckungsbeitrag\",\n",
|
|
" \"solver.Minimize(sum(menge[p.name] * p.deckungsbeitrag\"),\n",
|
|
"\n",
|
|
" (\"Vorzeichen der Dualwerte vergessen\",\n",
|
|
" \"schattenpreise={r: float(-m) for r, m in\",\n",
|
|
" \"schattenpreise={r: float(m) for r, m in\"),\n",
|
|
"\n",
|
|
" (\"Abnahmepruefung aufgeweicht (Toleranz 1e-6 -> 1.0)\",\n",
|
|
" \"toleranz: float = 1e-6) -> list[str]:\",\n",
|
|
" \"toleranz: float = 1.0) -> list[str]:\"),\n",
|
|
"\n",
|
|
" (\"Kapazitaetspruefung uebersprungen\",\n",
|
|
" \"if ist > grenze + toleranz:\",\n",
|
|
" \"if False:\"),\n",
|
|
"\n",
|
|
" (\"Pruefung auf doppelte Produktnamen entfernt\",\n",
|
|
" \"if len(set(namen)) != len(namen):\",\n",
|
|
" \"if False:\"),\n",
|
|
"\n",
|
|
" (\"Ganzzahligkeitspruefung entfernt\",\n",
|
|
" \"if abs(wert - round(wert)) > toleranz:\",\n",
|
|
" \"if False:\"),\n",
|
|
"\n",
|
|
" (\"Zielwertabgleich entfernt\",\n",
|
|
" \"if abs(nachgerechnet - loesung.zielwert) > toleranz * max(1.0, abs(nachgerechnet)):\",\n",
|
|
" \"if False:\"),\n",
|
|
"\n",
|
|
" (\"ZEITLIMIT gilt faelschlich als brauchbar\",\n",
|
|
" \"return self in (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG)\",\n",
|
|
" \"return self in (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG,\\n\"\n",
|
|
" \" SolverStatus.ZEITLIMIT)\"),\n",
|
|
"\n",
|
|
" (\"Ressourcenreihenfolge nicht mehr stabil\",\n",
|
|
" \"return sorted(self.kapazitaeten)\",\n",
|
|
" \"return list(self.kapazitaeten)\"),\n",
|
|
"]\n",
|
|
"\n",
|
|
"\n",
|
|
"def fuehre_suite_aus(verzeichnis: str) -> tuple[int, int, str]:\n",
|
|
" \"\"\"Laesst pytest im Verzeichnis laufen; liefert (bestanden, fehlgeschlagen).\"\"\"\n",
|
|
" ergebnis = subprocess.run(\n",
|
|
" [sys.executable, \"-m\", \"pytest\", \"test_or_kern.py\", \"-q\",\n",
|
|
" \"--tb=no\", \"-p\", \"no:cacheprovider\"],\n",
|
|
" cwd=verzeichnis, capture_output=True, text=True, timeout=600)\n",
|
|
" letzte = [z for z in ergebnis.stdout.strip().splitlines() if z.strip()]\n",
|
|
" zeile = letzte[-1] if letzte else \"\"\n",
|
|
" bestanden = fehlgeschlagen = 0\n",
|
|
" for teil in zeile.replace(\"=\", \" \").split(\",\"):\n",
|
|
" for wort in teil.split():\n",
|
|
" if wort.isdigit():\n",
|
|
" zahl = int(wort)\n",
|
|
" elif wort.startswith(\"passed\"):\n",
|
|
" bestanden = zahl\n",
|
|
" elif wort.startswith(\"failed\") or wort.startswith(\"error\"):\n",
|
|
" fehlgeschlagen += zahl\n",
|
|
" return bestanden, fehlgeschlagen, zeile\n",
|
|
"\n",
|
|
"\n",
|
|
"def mutiere_und_pruefe(quelle: str, ziel: str, suche: str,\n",
|
|
" ersatz: str) -> tuple[int, int, str] | None:\n",
|
|
" \"\"\"Legt eine mutierte Kopie an und laesst die Suite darauf laufen.\"\"\"\n",
|
|
" with tempfile.TemporaryDirectory() as verzeichnis:\n",
|
|
" text = open(quelle, encoding=\"utf-8\").read()\n",
|
|
" if text.count(suche) != 1:\n",
|
|
" return None # Muster passt nicht (mehr)\n",
|
|
" open(os.path.join(verzeichnis, \"or_kern.py\"), \"w\",\n",
|
|
" encoding=\"utf-8\").write(text.replace(suche, ersatz))\n",
|
|
" shutil.copy(ziel, verzeichnis)\n",
|
|
" return fuehre_suite_aus(verzeichnis)\n",
|
|
"\n",
|
|
"\n",
|
|
"if __name__ == \"__main__\":\n",
|
|
" hier = os.path.dirname(os.path.abspath(__file__))\n",
|
|
" quelle = os.path.join(hier, \"or_kern.py\")\n",
|
|
" suite = os.path.join(hier, \"test_or_kern.py\")\n",
|
|
" for pfad in (quelle, suite):\n",
|
|
" if not os.path.exists(pfad):\n",
|
|
" raise SystemExit(f\"Nicht gefunden: {pfad}\\n\"\n",
|
|
" f\"Beide Dateien muessen im selben Verzeichnis liegen.\")\n",
|
|
"\n",
|
|
" print(\"=\" * 84)\n",
|
|
" print(\" MUTATIONSTEST: WUERDE DIE SUITE DIESE FEHLER FINDEN?\")\n",
|
|
" print(\"=\" * 84)\n",
|
|
"\n",
|
|
" with tempfile.TemporaryDirectory() as v:\n",
|
|
" shutil.copy(quelle, v)\n",
|
|
" shutil.copy(suite, v)\n",
|
|
" bestanden, fehlgeschlagen, _ = fuehre_suite_aus(v)\n",
|
|
" print(f\"Ausgangslage: {bestanden} Tests, {fehlgeschlagen} rot.\\n\")\n",
|
|
" if fehlgeschlagen:\n",
|
|
" raise SystemExit(\"Die Suite ist schon ohne Mutation rot - erst das reparieren.\")\n",
|
|
"\n",
|
|
" print(f\"{'eingebauter Fehler':<48} {'rote Tests':>11} {'Urteil':>12}\")\n",
|
|
" print(\"-\" * 84)\n",
|
|
"\n",
|
|
" getoetet, ueberlebt = [], []\n",
|
|
" for name, suche, ersatz in MUTATIONEN:\n",
|
|
" ergebnis = mutiere_und_pruefe(quelle, suite, suche, ersatz)\n",
|
|
" if ergebnis is None:\n",
|
|
" print(f\"{name:<48} {'-':>11} {'nicht anwendbar':>12}\")\n",
|
|
" continue\n",
|
|
" _, rot, _ = ergebnis\n",
|
|
" urteil = \"GETOETET\" if rot else \"UEBERLEBT\"\n",
|
|
" (getoetet if rot else ueberlebt).append(name)\n",
|
|
" print(f\"{name:<48} {rot:>11} {urteil:>12}\")\n",
|
|
"\n",
|
|
" print(\"-\" * 84)\n",
|
|
" quote = len(getoetet) / max(1, len(getoetet) + len(ueberlebt)) * 100\n",
|
|
" print(f\"Getoetet: {len(getoetet)} von {len(getoetet) + len(ueberlebt)} \"\n",
|
|
" f\"({quote:.0f} %)\")\n",
|
|
"\n",
|
|
" print(\"\\n\" + \"=\" * 84)\n",
|
|
" print(\" WAS DAS ERGEBNIS BEDEUTET\")\n",
|
|
" print(\"=\" * 84)\n",
|
|
" if ueberlebt:\n",
|
|
" print(\"Diese Fehler haette die Suite DURCHGEHEN LASSEN:\\n\")\n",
|
|
" for name in ueberlebt:\n",
|
|
" print(f\" * {name}\")\n",
|
|
" print(\"\\nJeder ueberlebende Eintrag ist eine Testluecke - und zwar eine\")\n",
|
|
" print(\"gefundene, keine vermutete. Das ist der ganze Zweck des Verfahrens:\")\n",
|
|
" print(\"Es sagt einem, welchen Test man als naechstes schreiben sollte,\")\n",
|
|
" print(\"statt dass man raten muss.\")\n",
|
|
" else:\n",
|
|
" print(\"Alle eingebauten Fehler wurden gefunden. Das heisst nicht, dass die\")\n",
|
|
" print(\"Suite vollstaendig ist - nur, dass sie diese zehn Fehler faengt.\")\n",
|
|
" print()\n",
|
|
" print(\"Die Quote selbst ist keine Kennzahl fuer ein Dashboard. Zehn von Hand\")\n",
|
|
" print(\"gewaehlte Mutationen sind keine Stichprobe aus der Menge aller\")\n",
|
|
" print(\"moeglichen Fehler. Was zaehlt, ist die LISTE der Ueberlebenden.\")\n",
|
|
" print(\"=\" * 84)"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## Ein Vergleich, dem man glauben kann\n",
|
|
"\n",
|
|
"`Benchmark_Skalierung.py`\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"#!/usr/bin/env python3\n",
|
|
"\n",
|
|
"# Benchmark_Skalierung.py\n",
|
|
"\"\"\"\n",
|
|
"Kapitel Testen: Ein Vergleich, dem man glauben kann.\n",
|
|
"\n",
|
|
"Solververgleiche stehen in jedem Blog, und die meisten sind wertlos - nicht\n",
|
|
"weil falsch gemessen wurde, sondern weil zu wenig dazugesagt wird. Dieses\n",
|
|
"Programm misst dasselbe Transportproblem in drei Groessen mit vier\n",
|
|
"Bibliotheken und haelt sich dabei an fuenf Regeln, die den Unterschied machen:\n",
|
|
"\n",
|
|
" 1. EIGENER PROZESS je Bibliothek. Nicht nur wegen des Importkonflikts\n",
|
|
" zwischen ortools und highspy (Kapitel Oekosystem) - auch, damit der\n",
|
|
" Speicherverbrauch der einen nicht in der Messung der anderen auftaucht.\n",
|
|
" 2. AUFBAU UND LOESEN GETRENNT messen. Bei grossen Instanzen ist der Aufbau\n",
|
|
" des Modells in Python regelmaessig teurer als das Loesen. Wer nur die\n",
|
|
" Gesamtzeit misst, optimiert am Ende die falsche Haelfte.\n",
|
|
" 3. ZIELWERTE GEGENEINANDER PRUEFEN. Eine Bibliothek, die schneller ist und\n",
|
|
" etwas anderes ausrechnet, hat den Vergleich nicht gewonnen. Diese Pruefung\n",
|
|
" ist der wichtigste Teil des Programms.\n",
|
|
" 4. SPEICHER MITMESSEN. Bei 10.000 Variablen entscheidet oft er und nicht die\n",
|
|
" Zeit darueber, was auf einer Maschine noch laeuft.\n",
|
|
" 5. DIESELBE INSTANZ fuer alle. Feste Saat, kein Neuwuerfeln zwischendurch.\n",
|
|
"\n",
|
|
"Und die Einschraenkung, die dazugehoert: Gemessen wird EIN Problemtyp in EINER\n",
|
|
"Formulierung auf EINER Maschine. Das Ergebnis ist keine Rangliste der Solver,\n",
|
|
"sondern eine Entscheidungshilfe fuer genau diesen Fall. Wer es verallgemeinert,\n",
|
|
"macht denselben Fehler wie jemand, der aus einem Backtest auf die Zukunft\n",
|
|
"schliesst (Kapitel Handelsmaschine).\n",
|
|
"\n",
|
|
"Benoetigt: numpy; in den Kindprozessen scipy, highspy, ortools, cvxpy\n",
|
|
"\"\"\"\n",
|
|
"\n",
|
|
"from __future__ import annotations\n",
|
|
"\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",
|
|
"# 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: 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",
|
|
"\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",
|
|
"\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",
|
|
" 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",
|
|
" return float(r.fun), aufbau, loesen, speicher_mb()\n",
|
|
"\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.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",
|
|
" aufbau = time.perf_counter() - t0\n",
|
|
" t0 = time.perf_counter(); h.run(); loesen = time.perf_counter() - t0\n",
|
|
" return h.getInfo().objective_function_value, aufbau, loesen, speicher_mb()\n",
|
|
"\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",
|
|
" 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",
|
|
" s.Minimize(sum(float(kosten[i, j]) * x[i][j]\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",
|
|
" return s.Objective().Value(), aufbau, loesen, speicher_mb()\n",
|
|
"\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",
|
|
" 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",
|
|
" return float(problem.value), aufbau, loesen, speicher_mb()\n",
|
|
"\n",
|
|
"\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",
|
|
" print(\"=\" * 92)\n",
|
|
" print(\" SKALIERUNGSVERGLEICH: TRANSPORTPROBLEM, VIER BIBLIOTHEKEN\")\n",
|
|
" print(\"=\" * 92)\n",
|
|
" print(\"Jede Zeile ein eigener Prozess. Zeiten und Speicher sind \"\n",
|
|
" \"hardwareabhaengig,\")\n",
|
|
" print(\"die Zielwerte und ihr Verhaeltnis zueinander nicht.\\n\")\n",
|
|
"\n",
|
|
" for m, n in GROESSEN:\n",
|
|
" kopf = f\"--- {m} Lager x {n} Kunden = {m * n:,} Variablen \"\n",
|
|
" print(kopf + \"-\" * max(3, 92 - len(kopf)))\n",
|
|
" print(f\" {'Bibliothek':<16} {'Zielwert':>14} {'Aufbau':>9} \"\n",
|
|
" f\"{'Loesen':>9} {'Anteil':>8} {'Speicher':>10}\")\n",
|
|
" print(\" \" + \"-\" * 72)\n",
|
|
" zielwerte = {}\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",
|
|
" ziel, aufbau, loesen, speicher = werte\n",
|
|
" zielwerte[name] = ziel\n",
|
|
" anteil = aufbau / (aufbau + loesen) * 100\n",
|
|
" print(f\" {name:<16} {ziel:>14,.2f} {aufbau:>8.3f}s \"\n",
|
|
" f\"{loesen:>8.3f}s {anteil:>7.0f}% {speicher:>9.0f} MB\")\n",
|
|
"\n",
|
|
" # Die wichtigste Zeile: Rechnen alle dasselbe aus?\n",
|
|
" spanne = max(zielwerte.values()) - min(zielwerte.values())\n",
|
|
" bezug = max(abs(v) for v in zielwerte.values())\n",
|
|
" print(f\" {'':16} Spannweite der Zielwerte: {spanne:.2e} \"\n",
|
|
" f\"(relativ {spanne / bezug:.1e})\")\n",
|
|
" if spanne / bezug > 1e-6:\n",
|
|
" print(\" ACHTUNG: Die Bibliotheken widersprechen sich - \"\n",
|
|
" \"der Zeitvergleich ist wertlos.\")\n",
|
|
" print()\n",
|
|
"\n",
|
|
" print(\"=\" * 92)\n",
|
|
" print(\" WAS MAN AUS SO EINER TABELLE ABLESEN DARF - UND WAS NICHT\")\n",
|
|
" print(\"=\" * 92)\n",
|
|
" print(\"DARF man ablesen:\")\n",
|
|
" print(\" * Die Spalte 'Anteil' - wie viel der Zeit in den AUFBAU geht statt\")\n",
|
|
" print(\" ins Loesen. Wenn dort 80 % stehen, ist ein schnellerer Solver die\")\n",
|
|
" print(\" falsche Antwort; dann gehoert das Modell vektorisiert aufgebaut\")\n",
|
|
" print(\" (Kapitel Oekosystem).\")\n",
|
|
" print(\" * Die Groessenordnung des Speicherbedarfs. Sie entscheidet, was auf\")\n",
|
|
" print(\" einer bestimmten Maschine ueberhaupt laeuft.\")\n",
|
|
" print(\" * Wie sich beides mit der Groesse ENTWICKELT. Der Trend ist\")\n",
|
|
" print(\" uebertragbarer als der Absolutwert.\")\n",
|
|
" print()\n",
|
|
" print(\"NICHT ablesen darf man:\")\n",
|
|
" print(\" * 'Bibliothek X ist schneller als Y.' Gemessen wurde EIN\")\n",
|
|
" print(\" Problemtyp in EINER Formulierung. Ein MILP, ein QP oder eine\")\n",
|
|
" print(\" andere Modellierung desselben Problems koennen die Reihenfolge\")\n",
|
|
" print(\" umdrehen.\")\n",
|
|
" print(\" * Etwas ueber Ihre Maschine. Diese Zahlen stammen von einer\")\n",
|
|
" print(\" anderen. Der Sinn des Programms ist, dass Sie es auf Ihrer\")\n",
|
|
" print(\" laufen lassen.\")\n",
|
|
" print(\"=\" * 92)"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## Das Modell als Dienst\n",
|
|
"\n",
|
|
"`Optimierungsdienst.py`\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"#!/usr/bin/env python3\n",
|
|
"\n",
|
|
"# Optimierungsdienst.py\n",
|
|
"\"\"\"\n",
|
|
"Kapitel Testen: Das Modell als Dienst - und warum es kein normaler Endpunkt ist.\n",
|
|
"\n",
|
|
"Ein Optimierungsmodell hinter eine HTTP-Schnittstelle zu haengen sieht nach\n",
|
|
"einer Fingeruebung aus. Es gibt aber einen Unterschied, der alles bestimmt:\n",
|
|
"\n",
|
|
" Eine gewoehnliche Anfrage dauert Millisekunden.\n",
|
|
" Eine Optimierung dauert Sekunden bis Minuten - und manchmal laenger,\n",
|
|
" als jemand warten will.\n",
|
|
"\n",
|
|
"Damit scheidet die naheliegende Bauform aus. Wer den Solver direkt im\n",
|
|
"Request-Handler aufruft, baut einen Dienst, der bei der ersten grossen Instanz\n",
|
|
"in einen Timeout des Reverse Proxy laeuft, und bei zehn gleichzeitigen Anfragen\n",
|
|
"alle Arbeiter blockiert.\n",
|
|
"\n",
|
|
"Die tragfaehige Bauform ist deshalb ZWEISTUFIG:\n",
|
|
"\n",
|
|
" POST /plaene nimmt den Auftrag an, prueft ihn, gibt sofort\n",
|
|
" 202 Accepted und eine Auftragsnummer zurueck\n",
|
|
" GET /plaene/{id} sagt, wie weit es ist - und liefert am Ende die\n",
|
|
" Loesung samt Status und Gap\n",
|
|
"\n",
|
|
"Drei Dinge, die dabei leicht untergehen und hier bewusst sichtbar sind:\n",
|
|
"\n",
|
|
" 1. Das Domaenenmodell aus or_kern.py ist zugleich das API-Schema. Eine\n",
|
|
" unsinnige Anfrage wird von FastAPI mit 422 abgewiesen, bevor irgendein\n",
|
|
" Solver startet - dieselbe Pruefung wie beim Excel-Import.\n",
|
|
" 2. Die Antwort enthaelt STATUS und GAP, nicht nur Zahlen. Ein Aufrufer, der\n",
|
|
" nur die Mengen bekommt, kann nicht unterscheiden, ob er ein bewiesenes\n",
|
|
" Optimum oder einen Zeitlimit-Abbruch in der Hand haelt.\n",
|
|
" 3. Jeder Auftrag hat ein ZEITLIMIT. Ohne das belegt eine einzige unguenstige\n",
|
|
" Instanz einen Arbeiter auf unbestimmte Zeit.\n",
|
|
"\n",
|
|
"Dieses Programm laeuft ohne Server: Der Selbsttest unten benutzt den\n",
|
|
"TestClient von FastAPI und spricht die Anwendung direkt an. Fuer den echten\n",
|
|
"Betrieb steht am Ende, was sich aendert.\n",
|
|
"\n",
|
|
"Aufruf:\n",
|
|
" python3 Optimierungsdienst.py # Selbsttest, kein Server noetig\n",
|
|
" uvicorn Optimierungsdienst:app # echter Server auf Port 8000\n",
|
|
"\n",
|
|
"Benoetigt: fastapi, httpx (fuer den Selbsttest), pydantic, ortools (ueber or_kern)\n",
|
|
"\"\"\"\n",
|
|
"\n",
|
|
"from __future__ import annotations\n",
|
|
"\n",
|
|
"import time\n",
|
|
"import uuid\n",
|
|
"from concurrent.futures import ThreadPoolExecutor\n",
|
|
"from enum import Enum\n",
|
|
"\n",
|
|
"from fastapi import FastAPI, HTTPException\n",
|
|
"from pydantic import BaseModel, Field\n",
|
|
"\n",
|
|
"from or_kern import (Loesung, Produktionsproblem, loese_mit_glop,\n",
|
|
" pruefe_loesung)\n",
|
|
"\n",
|
|
"ZEITLIMIT_SEKUNDEN = 30.0\n",
|
|
"ARBEITER = 2\n",
|
|
"\n",
|
|
"\n",
|
|
"class Auftragsstand(str, Enum):\n",
|
|
" WARTET = \"wartet\"\n",
|
|
" LAEUFT = \"laeuft\"\n",
|
|
" FERTIG = \"fertig\"\n",
|
|
" GESCHEITERT = \"gescheitert\"\n",
|
|
"\n",
|
|
"\n",
|
|
"class Auftragsantwort(BaseModel):\n",
|
|
" \"\"\"Was der Aufrufer beim Abholen bekommt.\n",
|
|
"\n",
|
|
" Bewusst NICHT nur die Mengen: 'stand' und die Felder aus 'loesung'\n",
|
|
" (Status, Gap) sind der Unterschied zwischen einer Zahl und einer\n",
|
|
" belastbaren Auskunft.\n",
|
|
" \"\"\"\n",
|
|
" id: str\n",
|
|
" stand: Auftragsstand\n",
|
|
" eingegangen: float\n",
|
|
" laufzeit: float | None = None\n",
|
|
" loesung: Loesung | None = None\n",
|
|
" beanstandungen: list[str] = Field(default_factory=list)\n",
|
|
" fehler: str | None = None\n",
|
|
"\n",
|
|
"\n",
|
|
"app = FastAPI(title=\"Optimierungsdienst\",\n",
|
|
" summary=\"Produktionsplanung als Auftrag, nicht als Abfrage\")\n",
|
|
"\n",
|
|
"# Fuer das Buchbeispiel: Auftragsbuch im Speicher, Arbeiter im selben Prozess.\n",
|
|
"# Was daran im echten Betrieb nicht reicht, steht unten.\n",
|
|
"AUFTRAEGE: dict[str, Auftragsantwort] = {}\n",
|
|
"POOL = ThreadPoolExecutor(max_workers=ARBEITER)\n",
|
|
"\n",
|
|
"\n",
|
|
"def rechne(auftrags_id: str, problem: Produktionsproblem) -> None:\n",
|
|
" \"\"\"Laeuft im Arbeiterthread - nie im Request-Handler.\"\"\"\n",
|
|
" antwort = AUFTRAEGE[auftrags_id]\n",
|
|
" antwort.stand = Auftragsstand.LAEUFT\n",
|
|
" start = time.perf_counter()\n",
|
|
" try:\n",
|
|
" loesung = loese_mit_glop(problem)\n",
|
|
" antwort.loesung = loesung\n",
|
|
" # Dieselbe Abnahmepruefung wie ueberall sonst. Ein Dienst, der sie\n",
|
|
" # weglaesst, liefert Fehler schneller aus als ein Mensch sie faende.\n",
|
|
" antwort.beanstandungen = pruefe_loesung(problem, loesung)\n",
|
|
" antwort.stand = (Auftragsstand.FERTIG if loesung.status.brauchbar\n",
|
|
" and not antwort.beanstandungen\n",
|
|
" else Auftragsstand.GESCHEITERT)\n",
|
|
" if not loesung.status.brauchbar:\n",
|
|
" antwort.fehler = f\"Solverstatus: {loesung.status.value}\"\n",
|
|
" elif antwort.beanstandungen:\n",
|
|
" antwort.fehler = \"Abnahmepruefung fehlgeschlagen\"\n",
|
|
" except Exception as fehler: # noqa: BLE001\n",
|
|
" antwort.stand = Auftragsstand.GESCHEITERT\n",
|
|
" antwort.fehler = f\"{type(fehler).__name__}: {fehler}\"\n",
|
|
" finally:\n",
|
|
" antwort.laufzeit = time.perf_counter() - start\n",
|
|
"\n",
|
|
"\n",
|
|
"@app.post(\"/plaene\", status_code=202, response_model=Auftragsantwort)\n",
|
|
"def auftrag_annehmen(problem: Produktionsproblem) -> Auftragsantwort:\n",
|
|
" \"\"\"Nimmt an, prueft, gibt sofort zurueck.\n",
|
|
"\n",
|
|
" Der Typ 'Produktionsproblem' im Parameter ist der ganze Trick: FastAPI\n",
|
|
" validiert die Anfrage damit gegen das Domaenenmodell und antwortet bei\n",
|
|
" Unsinn mit 422, ohne dass hier eine Zeile Pruefcode steht.\n",
|
|
" \"\"\"\n",
|
|
" auftrags_id = str(uuid.uuid4())\n",
|
|
" AUFTRAEGE[auftrags_id] = Auftragsantwort(\n",
|
|
" id=auftrags_id, stand=Auftragsstand.WARTET, eingegangen=time.time())\n",
|
|
" POOL.submit(rechne, auftrags_id, problem)\n",
|
|
" return AUFTRAEGE[auftrags_id]\n",
|
|
"\n",
|
|
"\n",
|
|
"@app.get(\"/plaene/{auftrags_id}\", response_model=Auftragsantwort)\n",
|
|
"def auftrag_abholen(auftrags_id: str) -> Auftragsantwort:\n",
|
|
" if auftrags_id not in AUFTRAEGE:\n",
|
|
" raise HTTPException(status_code=404, detail=\"Unbekannter Auftrag\")\n",
|
|
" return AUFTRAEGE[auftrags_id]\n",
|
|
"\n",
|
|
"\n",
|
|
"@app.get(\"/gesundheit\")\n",
|
|
"def gesundheit() -> dict[str, object]:\n",
|
|
" \"\"\"Was ein Ueberwachungssystem abfragt - siehe Betriebsueberwachung.py.\"\"\"\n",
|
|
" offen = sum(1 for a in AUFTRAEGE.values()\n",
|
|
" if a.stand in (Auftragsstand.WARTET, Auftragsstand.LAEUFT))\n",
|
|
" return {\"zustand\": \"bereit\", \"auftraege_gesamt\": len(AUFTRAEGE),\n",
|
|
" \"offen\": offen, \"arbeiter\": ARBEITER}\n",
|
|
"\n",
|
|
"\n",
|
|
"# --- Selbsttest ohne Server -------------------------------------------------\n",
|
|
"\n",
|
|
"SCHREINEREI = {\n",
|
|
" \"produkte\": [\n",
|
|
" {\"name\": \"Tisch\", \"deckungsbeitrag\": 240.0,\n",
|
|
" \"verbrauch\": {\"Montagestunden\": 3.0, \"Plattenmaterial\": 6.0}},\n",
|
|
" {\"name\": \"Stuhl\", \"deckungsbeitrag\": 60.0,\n",
|
|
" \"verbrauch\": {\"Montagestunden\": 1.0, \"Plattenmaterial\": 1.0}},\n",
|
|
" ],\n",
|
|
" \"kapazitaeten\": {\"Montagestunden\": 150.0, \"Plattenmaterial\": 240.0},\n",
|
|
"}\n",
|
|
"\n",
|
|
"DOCKERFILE = \"\"\"\\\n",
|
|
"FROM python:3.12-slim\n",
|
|
"WORKDIR /app\n",
|
|
"COPY requirements.txt .\n",
|
|
"RUN pip install --no-cache-dir -r requirements.txt\n",
|
|
"COPY or_kern.py Optimierungsdienst.py .\n",
|
|
"# Ein Arbeiter je CPU-Kern - der Solver rechnet selbst schon parallel,\n",
|
|
"# mehr Prozesse machen ihn nicht schneller, sondern langsamer.\n",
|
|
"CMD [\"uvicorn\", \"Optimierungsdienst:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\"]\n",
|
|
"\"\"\"\n",
|
|
"\n",
|
|
"\n",
|
|
"def warte_auf_ergebnis(klient, auftrags_id: str, grenze: float = 30.0) -> dict:\n",
|
|
" ende = time.time() + grenze\n",
|
|
" while time.time() < ende:\n",
|
|
" antwort = klient.get(f\"/plaene/{auftrags_id}\").json()\n",
|
|
" if antwort[\"stand\"] in (\"fertig\", \"gescheitert\"):\n",
|
|
" return antwort\n",
|
|
" time.sleep(0.02)\n",
|
|
" raise TimeoutError(\"Auftrag wurde nicht fertig\")\n",
|
|
"\n",
|
|
"\n",
|
|
"if __name__ == \"__main__\":\n",
|
|
" from fastapi.testclient import TestClient\n",
|
|
"\n",
|
|
" print(\"=\" * 80)\n",
|
|
" print(\" DAS MODELL ALS DIENST\")\n",
|
|
" print(\"=\" * 80)\n",
|
|
"\n",
|
|
" with TestClient(app) as klient:\n",
|
|
" # 1. Gueltiger Auftrag\n",
|
|
" print(\"\\n1. Auftrag einreichen\")\n",
|
|
" angenommen = klient.post(\"/plaene\", json=SCHREINEREI)\n",
|
|
" auftrags_id = angenommen.json()[\"id\"]\n",
|
|
" print(f\" POST /plaene -> {angenommen.status_code} \"\n",
|
|
" f\"{'Accepted' if angenommen.status_code == 202 else ''}\")\n",
|
|
" print(f\" Auftragsnummer -> UUID, {len(auftrags_id)} Zeichen\")\n",
|
|
" # Der entscheidende Punkt, und er ist pruefbar: Die Antwort ist da,\n",
|
|
" # BEVOR es eine Loesung gibt. Genau dafuer ist 202 gedacht.\n",
|
|
" print(f\" Loesung schon dabei? -> \"\n",
|
|
" f\"{'ja' if angenommen.json()['loesung'] else 'nein - genau so soll es sein'}\")\n",
|
|
"\n",
|
|
" fertig = warte_auf_ergebnis(klient, auftrags_id)\n",
|
|
" loesung = fertig[\"loesung\"]\n",
|
|
" print(\"\\n2. Ergebnis abholen\")\n",
|
|
" print(f\" Stand -> {fertig['stand']}\")\n",
|
|
" print(f\" Solverstatus -> {loesung['status']}\")\n",
|
|
" print(f\" Zielwert -> {loesung['zielwert']:,.2f} EUR\")\n",
|
|
" print(f\" Mengen -> \" + \", \".join(\n",
|
|
" f\"{name} {wert:.0f}\" for name, wert in loesung[\"werte\"].items()))\n",
|
|
" print(f\" Schattenpreise -> \" + \", \".join(\n",
|
|
" f\"{name} {wert:.0f}\" for name, wert in loesung[\"schattenpreise\"].items()))\n",
|
|
" print(f\" Abnahmepruefung -> \"\n",
|
|
" f\"{'bestanden' if not fertig['beanstandungen'] else fertig['beanstandungen']}\")\n",
|
|
"\n",
|
|
" # 2. Unsinnige Anfrage - muss abgewiesen werden, BEVOR gerechnet wird\n",
|
|
" print(\"\\n3. Unsinnige Anfragen (der Solver startet gar nicht erst)\")\n",
|
|
" for beschreibung, aenderung in [\n",
|
|
" (\"Kapazitaet 0\", {\"kapazitaeten\": {\"Montagestunden\": 0.0}}),\n",
|
|
" (\"keine Produkte\", {\"produkte\": []}),\n",
|
|
" (\"Ressource ohne Kapazitaet\",\n",
|
|
" {\"produkte\": [{\"name\": \"Regal\", \"deckungsbeitrag\": 130.0,\n",
|
|
" \"verbrauch\": {\"Lackieren\": 2.0}}]})]:\n",
|
|
" anfrage = {**SCHREINEREI, **aenderung}\n",
|
|
" antwort = klient.post(\"/plaene\", json=anfrage)\n",
|
|
" print(f\" {beschreibung:<24} -> {antwort.status_code} \"\n",
|
|
" f\"{'Unprocessable Content' if antwort.status_code == 422 else ''}\")\n",
|
|
"\n",
|
|
" print(\"\\n4. Unbekannter Auftrag\")\n",
|
|
" print(f\" GET /plaene/gibtsnicht -> \"\n",
|
|
" f\"{klient.get('/plaene/gibtsnicht').status_code} Not Found\")\n",
|
|
"\n",
|
|
" print(\"\\n5. Gesundheitsabfrage\")\n",
|
|
" print(f\" GET /gesundheit -> {klient.get('/gesundheit').json()}\")\n",
|
|
"\n",
|
|
" # --- Warum ein Thread je Auftrag genuegt -----------------------------\n",
|
|
" print(\"\\n\" + \"-\" * 80)\n",
|
|
" print(\"6. Warum Threads hier reichen (und wann nicht)\\n\")\n",
|
|
" from concurrent.futures import ThreadPoolExecutor as Pool\n",
|
|
"\n",
|
|
" # Wichtig: eine Rechnung mit ECHTER Last. Ein Modell, das in zehn\n",
|
|
" # Millisekunden fertig ist, misst nur den Aufwand fuers Threadstarten -\n",
|
|
" # der erste Entwurf dieser Messung ist genau daran gescheitert und zeigte\n",
|
|
" # eine Verlangsamung, wo in Wirklichkeit eine Beschleunigung steht.\n",
|
|
" def eine_rechnung() -> None:\n",
|
|
" import numpy as np\n",
|
|
" from ortools.linear_solver import pywraplp\n",
|
|
" rng = np.random.default_rng(3)\n",
|
|
" n = 260\n",
|
|
" solver = pywraplp.Solver.CreateSolver(\"SCIP\")\n",
|
|
" x = [solver.IntVar(0, 1, f\"x{i}\") for i in range(n)]\n",
|
|
" gewicht = rng.integers(10, 60, n)\n",
|
|
" wert = rng.integers(10, 60, n)\n",
|
|
" solver.Add(sum(int(gewicht[i]) * x[i] for i in range(n))\n",
|
|
" <= int(gewicht.sum() * 0.5))\n",
|
|
" for _ in range(30):\n",
|
|
" auswahl = rng.choice(n, 40, replace=False)\n",
|
|
" solver.Add(sum(x[int(i)] for i in auswahl) <= 12)\n",
|
|
" solver.Maximize(sum(int(wert[i]) * x[i] for i in range(n)))\n",
|
|
" solver.Solve()\n",
|
|
"\n",
|
|
" t0 = time.perf_counter()\n",
|
|
" for _ in range(4):\n",
|
|
" eine_rechnung()\n",
|
|
" seriell = time.perf_counter() - t0\n",
|
|
" t0 = time.perf_counter()\n",
|
|
" with Pool(max_workers=4) as p:\n",
|
|
" list(p.map(lambda _: eine_rechnung(), range(4)))\n",
|
|
" parallel = time.perf_counter() - t0\n",
|
|
"\n",
|
|
" print(f\" vier Rechnungen nacheinander -> {seriell:6.2f} s\")\n",
|
|
" print(f\" vier Rechnungen in Threads -> {parallel:6.2f} s \"\n",
|
|
" f\"(Faktor {seriell / parallel:.1f})\")\n",
|
|
" print()\n",
|
|
" if parallel < seriell * 0.75:\n",
|
|
" print(\" Die Laeufe ueberlappen sich. ortools rechnet in C++ und gibt\")\n",
|
|
" print(\" den GIL waehrend Solve() frei - deshalb genuegt hier ein\")\n",
|
|
" print(\" Threadpool, es braucht keine eigenen Prozesse.\")\n",
|
|
" else:\n",
|
|
" print(\" Keine Ueberlappung - hier waeren Prozesse noetig.\")\n",
|
|
" print()\n",
|
|
" print(\" ACHTUNG, das gilt nicht allgemein: Eine in reinem Python\")\n",
|
|
" print(\" geschriebene Heuristik (Kapitel Metaheuristiken) haelt den GIL\")\n",
|
|
" print(\" die ganze Zeit. Fuer sie braucht derselbe Dienst einen\")\n",
|
|
" print(\" ProcessPoolExecutor statt eines Threadpools.\")\n",
|
|
"\n",
|
|
" print(\"\\n\" + \"=\" * 80)\n",
|
|
" print(\" WAS SICH IM ECHTEN BETRIEB AENDERT\")\n",
|
|
" print(\"=\" * 80)\n",
|
|
" print(\"Dieses Beispiel haelt das Auftragsbuch im Speicher und rechnet in\")\n",
|
|
" print(\"Threads desselben Prozesses. Das reicht zum Zeigen und fuer einen\")\n",
|
|
" print(\"einzelnen Rechner - nicht darueber hinaus:\")\n",
|
|
" print()\n",
|
|
" print(\" * NEUSTART LOESCHT ALLES. Auftragsbuch in eine Datenbank oder eine\")\n",
|
|
" print(\" Warteschlange (Redis, RabbitMQ), nicht in ein dict.\")\n",
|
|
" print(\" * ZWEI INSTANZEN KENNEN EINANDER NICHT. Sobald der Dienst mehr als\")\n",
|
|
" print(\" einmal laeuft, muss die Warteschlange ausserhalb liegen -\")\n",
|
|
" print(\" typischerweise Celery mit Redis als Vermittler.\")\n",
|
|
" print(\" * DIE WAHL THREADS/PROZESSE HAENGT AM SOLVER - siehe die Messung\")\n",
|
|
" print(\" unter Punkt 6. Sie gehoert gemessen, nicht angenommen.\")\n",
|
|
" print(\" * OHNE ZEITLIMIT KEIN DIENST. Jede Instanz bekommt eines, und der\")\n",
|
|
" print(\" Aufrufer erfaehrt im Status, ob es gegriffen hat.\")\n",
|
|
" print()\n",
|
|
" print(\"Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:\")\n",
|
|
" print()\n",
|
|
" for zeile in DOCKERFILE.splitlines():\n",
|
|
" print(f\" {zeile}\")\n",
|
|
" print()\n",
|
|
" print(\"(Es wird hier nicht gebaut - das Buch setzt keine laufende\")\n",
|
|
" print(\" Docker-Installation voraus.)\")\n",
|
|
" print(\"=\" * 80)"
|
|
]
|
|
}
|
|
],
|
|
"metadata": {
|
|
"kernelspec": {
|
|
"display_name": "Python 3",
|
|
"language": "python",
|
|
"name": "python3"
|
|
},
|
|
"language_info": {
|
|
"name": "python",
|
|
"version": "3.11"
|
|
}
|
|
},
|
|
"nbformat": 4,
|
|
"nbformat_minor": 5
|
|
}
|