operations_research/OR_HTML_04/Notebooks_04/testing.ipynb
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

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
}