Version 04 als eigenes Repository
Erster Commit des Strangs "Optimierte Entscheidungsfindung mit Python" (Version 04). Die Historie der 71 Commits bis zur Trennung bleibt im uebergeordneten Repository OR_mit_Python liegen, das ab jetzt nur noch Version_03 (eingefroren) verwaltet und Version_04/ ignoriert. Bewusst kein "git subtree split": Der Pfad Version_04/ existiert erst seit der Verzeichnistrennung, ein Split braechte daher nur 7 der 41 einschlaegigen Commits - eine Teilhistorie, die vollstaendig aussieht und es nicht ist. Stand: 5 Teile, 23 Kapitel, 5 Anhaenge, 292 Abschnitte, 703 Querverweise, 325 Indexmarken, 73 Beispielprogramme, 32 SVGs, 4 Plotly-Figuren, 25 Notebooks, PDF mit 715 Seiten. Zusaetzlich in diesem Commit: * pyproject.toml mit Abhaengigkeitsgruppen finance, large-scale, api, figures, dev, empfehlungen. Die abgedruckte requirements.txt bleibt unveraendert daneben bestehen. ortools steht in der Grundausstattung, highspy erst in [large-scale] - so kann der HiGHS-Symbolkonflikt bei der schlanken Installation gar nicht erst auftreten. * Dabei zwei Funde: graphviz wird von erzeuge_architektur_diagramme.py importiert, fehlt aber in requirements.txt (jetzt in [figures]); pymoo steht in requirements.txt, wird aber von keinem Programm importiert, sondern nur im Kapitel Metaheuristiken empfohlen (jetzt in [empfehlungen]). * NEUER_TITEL.md nach Kritik_und_Verbesserungsvorschlaege/ verschoben - es ist die Vorlage des Titelblatts, kein Bestandteil des Werks. Die beiden Fundstellen in PROGRESS.md und erzeuge_titelseite.py nachgezogen. * PROGRESS.md nannte noch den Untertitel der ersten Fassung; auf den tatsaechlichen aus erzeuge_titelseite.py korrigiert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
b7af2f1d9a
468 changed files with 262141 additions and 0 deletions
315
OR_HTML_04/programme/Optimierungsdienst.py
Normal file
315
OR_HTML_04/programme/Optimierungsdienst.py
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# Optimierungsdienst.py
|
||||
"""
|
||||
Kapitel Testen: Das Modell als Dienst - und warum es kein normaler Endpunkt ist.
|
||||
|
||||
Ein Optimierungsmodell hinter eine HTTP-Schnittstelle zu haengen sieht nach
|
||||
einer Fingeruebung aus. Es gibt aber einen Unterschied, der alles bestimmt:
|
||||
|
||||
Eine gewoehnliche Anfrage dauert Millisekunden.
|
||||
Eine Optimierung dauert Sekunden bis Minuten - und manchmal laenger,
|
||||
als jemand warten will.
|
||||
|
||||
Damit scheidet die naheliegende Bauform aus. Wer den Solver direkt im
|
||||
Request-Handler aufruft, baut einen Dienst, der bei der ersten grossen Instanz
|
||||
in einen Timeout des Reverse Proxy laeuft, und bei zehn gleichzeitigen Anfragen
|
||||
alle Arbeiter blockiert.
|
||||
|
||||
Die tragfaehige Bauform ist deshalb ZWEISTUFIG:
|
||||
|
||||
POST /plaene nimmt den Auftrag an, prueft ihn, gibt sofort
|
||||
202 Accepted und eine Auftragsnummer zurueck
|
||||
GET /plaene/{id} sagt, wie weit es ist - und liefert am Ende die
|
||||
Loesung samt Status und Gap
|
||||
|
||||
Drei Dinge, die dabei leicht untergehen und hier bewusst sichtbar sind:
|
||||
|
||||
1. Das Domaenenmodell aus or_kern.py ist zugleich das API-Schema. Eine
|
||||
unsinnige Anfrage wird von FastAPI mit 422 abgewiesen, bevor irgendein
|
||||
Solver startet - dieselbe Pruefung wie beim Excel-Import.
|
||||
2. Die Antwort enthaelt STATUS und GAP, nicht nur Zahlen. Ein Aufrufer, der
|
||||
nur die Mengen bekommt, kann nicht unterscheiden, ob er ein bewiesenes
|
||||
Optimum oder einen Zeitlimit-Abbruch in der Hand haelt.
|
||||
3. Jeder Auftrag hat ein ZEITLIMIT. Ohne das belegt eine einzige unguenstige
|
||||
Instanz einen Arbeiter auf unbestimmte Zeit.
|
||||
|
||||
Dieses Programm laeuft ohne Server: Der Selbsttest unten benutzt den
|
||||
TestClient von FastAPI und spricht die Anwendung direkt an. Fuer den echten
|
||||
Betrieb steht am Ende, was sich aendert.
|
||||
|
||||
Aufruf:
|
||||
python3 Optimierungsdienst.py # Selbsttest, kein Server noetig
|
||||
uvicorn Optimierungsdienst:app # echter Server auf Port 8000
|
||||
|
||||
Benoetigt: fastapi, httpx (fuer den Selbsttest), pydantic, ortools (ueber or_kern)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from enum import Enum
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from or_kern import (Loesung, Produktionsproblem, loese_mit_glop,
|
||||
pruefe_loesung)
|
||||
|
||||
ZEITLIMIT_SEKUNDEN = 30.0
|
||||
ARBEITER = 2
|
||||
|
||||
|
||||
class Auftragsstand(str, Enum):
|
||||
WARTET = "wartet"
|
||||
LAEUFT = "laeuft"
|
||||
FERTIG = "fertig"
|
||||
GESCHEITERT = "gescheitert"
|
||||
|
||||
|
||||
class Auftragsantwort(BaseModel):
|
||||
"""Was der Aufrufer beim Abholen bekommt.
|
||||
|
||||
Bewusst NICHT nur die Mengen: 'stand' und die Felder aus 'loesung'
|
||||
(Status, Gap) sind der Unterschied zwischen einer Zahl und einer
|
||||
belastbaren Auskunft.
|
||||
"""
|
||||
id: str
|
||||
stand: Auftragsstand
|
||||
eingegangen: float
|
||||
laufzeit: float | None = None
|
||||
loesung: Loesung | None = None
|
||||
beanstandungen: list[str] = Field(default_factory=list)
|
||||
fehler: str | None = None
|
||||
|
||||
|
||||
app = FastAPI(title="Optimierungsdienst",
|
||||
summary="Produktionsplanung als Auftrag, nicht als Abfrage")
|
||||
|
||||
# Fuer das Buchbeispiel: Auftragsbuch im Speicher, Arbeiter im selben Prozess.
|
||||
# Was daran im echten Betrieb nicht reicht, steht unten.
|
||||
AUFTRAEGE: dict[str, Auftragsantwort] = {}
|
||||
POOL = ThreadPoolExecutor(max_workers=ARBEITER)
|
||||
|
||||
|
||||
def rechne(auftrags_id: str, problem: Produktionsproblem) -> None:
|
||||
"""Laeuft im Arbeiterthread - nie im Request-Handler."""
|
||||
antwort = AUFTRAEGE[auftrags_id]
|
||||
antwort.stand = Auftragsstand.LAEUFT
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
loesung = loese_mit_glop(problem)
|
||||
antwort.loesung = loesung
|
||||
# Dieselbe Abnahmepruefung wie ueberall sonst. Ein Dienst, der sie
|
||||
# weglaesst, liefert Fehler schneller aus als ein Mensch sie faende.
|
||||
antwort.beanstandungen = pruefe_loesung(problem, loesung)
|
||||
antwort.stand = (Auftragsstand.FERTIG if loesung.status.brauchbar
|
||||
and not antwort.beanstandungen
|
||||
else Auftragsstand.GESCHEITERT)
|
||||
if not loesung.status.brauchbar:
|
||||
antwort.fehler = f"Solverstatus: {loesung.status.value}"
|
||||
elif antwort.beanstandungen:
|
||||
antwort.fehler = "Abnahmepruefung fehlgeschlagen"
|
||||
except Exception as fehler: # noqa: BLE001
|
||||
antwort.stand = Auftragsstand.GESCHEITERT
|
||||
antwort.fehler = f"{type(fehler).__name__}: {fehler}"
|
||||
finally:
|
||||
antwort.laufzeit = time.perf_counter() - start
|
||||
|
||||
|
||||
@app.post("/plaene", status_code=202, response_model=Auftragsantwort)
|
||||
def auftrag_annehmen(problem: Produktionsproblem) -> Auftragsantwort:
|
||||
"""Nimmt an, prueft, gibt sofort zurueck.
|
||||
|
||||
Der Typ 'Produktionsproblem' im Parameter ist der ganze Trick: FastAPI
|
||||
validiert die Anfrage damit gegen das Domaenenmodell und antwortet bei
|
||||
Unsinn mit 422, ohne dass hier eine Zeile Pruefcode steht.
|
||||
"""
|
||||
auftrags_id = str(uuid.uuid4())
|
||||
AUFTRAEGE[auftrags_id] = Auftragsantwort(
|
||||
id=auftrags_id, stand=Auftragsstand.WARTET, eingegangen=time.time())
|
||||
POOL.submit(rechne, auftrags_id, problem)
|
||||
return AUFTRAEGE[auftrags_id]
|
||||
|
||||
|
||||
@app.get("/plaene/{auftrags_id}", response_model=Auftragsantwort)
|
||||
def auftrag_abholen(auftrags_id: str) -> Auftragsantwort:
|
||||
if auftrags_id not in AUFTRAEGE:
|
||||
raise HTTPException(status_code=404, detail="Unbekannter Auftrag")
|
||||
return AUFTRAEGE[auftrags_id]
|
||||
|
||||
|
||||
@app.get("/gesundheit")
|
||||
def gesundheit() -> dict[str, object]:
|
||||
"""Was ein Ueberwachungssystem abfragt - siehe Betriebsueberwachung.py."""
|
||||
offen = sum(1 for a in AUFTRAEGE.values()
|
||||
if a.stand in (Auftragsstand.WARTET, Auftragsstand.LAEUFT))
|
||||
return {"zustand": "bereit", "auftraege_gesamt": len(AUFTRAEGE),
|
||||
"offen": offen, "arbeiter": ARBEITER}
|
||||
|
||||
|
||||
# --- Selbsttest ohne Server -------------------------------------------------
|
||||
|
||||
SCHREINEREI = {
|
||||
"produkte": [
|
||||
{"name": "Tisch", "deckungsbeitrag": 240.0,
|
||||
"verbrauch": {"Montagestunden": 3.0, "Plattenmaterial": 6.0}},
|
||||
{"name": "Stuhl", "deckungsbeitrag": 60.0,
|
||||
"verbrauch": {"Montagestunden": 1.0, "Plattenmaterial": 1.0}},
|
||||
],
|
||||
"kapazitaeten": {"Montagestunden": 150.0, "Plattenmaterial": 240.0},
|
||||
}
|
||||
|
||||
DOCKERFILE = """\
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY or_kern.py Optimierungsdienst.py .
|
||||
# Ein Arbeiter je CPU-Kern - der Solver rechnet selbst schon parallel,
|
||||
# mehr Prozesse machen ihn nicht schneller, sondern langsamer.
|
||||
CMD ["uvicorn", "Optimierungsdienst:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
"""
|
||||
|
||||
|
||||
def warte_auf_ergebnis(klient, auftrags_id: str, grenze: float = 30.0) -> dict:
|
||||
ende = time.time() + grenze
|
||||
while time.time() < ende:
|
||||
antwort = klient.get(f"/plaene/{auftrags_id}").json()
|
||||
if antwort["stand"] in ("fertig", "gescheitert"):
|
||||
return antwort
|
||||
time.sleep(0.02)
|
||||
raise TimeoutError("Auftrag wurde nicht fertig")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
print("=" * 80)
|
||||
print(" DAS MODELL ALS DIENST")
|
||||
print("=" * 80)
|
||||
|
||||
with TestClient(app) as klient:
|
||||
# 1. Gueltiger Auftrag
|
||||
print("\n1. Auftrag einreichen")
|
||||
angenommen = klient.post("/plaene", json=SCHREINEREI)
|
||||
auftrags_id = angenommen.json()["id"]
|
||||
print(f" POST /plaene -> {angenommen.status_code} "
|
||||
f"{'Accepted' if angenommen.status_code == 202 else ''}")
|
||||
print(f" Auftragsnummer -> UUID, {len(auftrags_id)} Zeichen")
|
||||
# Der entscheidende Punkt, und er ist pruefbar: Die Antwort ist da,
|
||||
# BEVOR es eine Loesung gibt. Genau dafuer ist 202 gedacht.
|
||||
print(f" Loesung schon dabei? -> "
|
||||
f"{'ja' if angenommen.json()['loesung'] else 'nein - genau so soll es sein'}")
|
||||
|
||||
fertig = warte_auf_ergebnis(klient, auftrags_id)
|
||||
loesung = fertig["loesung"]
|
||||
print("\n2. Ergebnis abholen")
|
||||
print(f" Stand -> {fertig['stand']}")
|
||||
print(f" Solverstatus -> {loesung['status']}")
|
||||
print(f" Zielwert -> {loesung['zielwert']:,.2f} EUR")
|
||||
print(f" Mengen -> " + ", ".join(
|
||||
f"{name} {wert:.0f}" for name, wert in loesung["werte"].items()))
|
||||
print(f" Schattenpreise -> " + ", ".join(
|
||||
f"{name} {wert:.0f}" for name, wert in loesung["schattenpreise"].items()))
|
||||
print(f" Abnahmepruefung -> "
|
||||
f"{'bestanden' if not fertig['beanstandungen'] else fertig['beanstandungen']}")
|
||||
|
||||
# 2. Unsinnige Anfrage - muss abgewiesen werden, BEVOR gerechnet wird
|
||||
print("\n3. Unsinnige Anfragen (der Solver startet gar nicht erst)")
|
||||
for beschreibung, aenderung in [
|
||||
("Kapazitaet 0", {"kapazitaeten": {"Montagestunden": 0.0}}),
|
||||
("keine Produkte", {"produkte": []}),
|
||||
("Ressource ohne Kapazitaet",
|
||||
{"produkte": [{"name": "Regal", "deckungsbeitrag": 130.0,
|
||||
"verbrauch": {"Lackieren": 2.0}}]})]:
|
||||
anfrage = {**SCHREINEREI, **aenderung}
|
||||
antwort = klient.post("/plaene", json=anfrage)
|
||||
print(f" {beschreibung:<24} -> {antwort.status_code} "
|
||||
f"{'Unprocessable Content' if antwort.status_code == 422 else ''}")
|
||||
|
||||
print("\n4. Unbekannter Auftrag")
|
||||
print(f" GET /plaene/gibtsnicht -> "
|
||||
f"{klient.get('/plaene/gibtsnicht').status_code} Not Found")
|
||||
|
||||
print("\n5. Gesundheitsabfrage")
|
||||
print(f" GET /gesundheit -> {klient.get('/gesundheit').json()}")
|
||||
|
||||
# --- Warum ein Thread je Auftrag genuegt -----------------------------
|
||||
print("\n" + "-" * 80)
|
||||
print("6. Warum Threads hier reichen (und wann nicht)\n")
|
||||
from concurrent.futures import ThreadPoolExecutor as Pool
|
||||
|
||||
# Wichtig: eine Rechnung mit ECHTER Last. Ein Modell, das in zehn
|
||||
# Millisekunden fertig ist, misst nur den Aufwand fuers Threadstarten -
|
||||
# der erste Entwurf dieser Messung ist genau daran gescheitert und zeigte
|
||||
# eine Verlangsamung, wo in Wirklichkeit eine Beschleunigung steht.
|
||||
def eine_rechnung() -> None:
|
||||
import numpy as np
|
||||
from ortools.linear_solver import pywraplp
|
||||
rng = np.random.default_rng(3)
|
||||
n = 260
|
||||
solver = pywraplp.Solver.CreateSolver("SCIP")
|
||||
x = [solver.IntVar(0, 1, f"x{i}") for i in range(n)]
|
||||
gewicht = rng.integers(10, 60, n)
|
||||
wert = rng.integers(10, 60, n)
|
||||
solver.Add(sum(int(gewicht[i]) * x[i] for i in range(n))
|
||||
<= int(gewicht.sum() * 0.5))
|
||||
for _ in range(30):
|
||||
auswahl = rng.choice(n, 40, replace=False)
|
||||
solver.Add(sum(x[int(i)] for i in auswahl) <= 12)
|
||||
solver.Maximize(sum(int(wert[i]) * x[i] for i in range(n)))
|
||||
solver.Solve()
|
||||
|
||||
t0 = time.perf_counter()
|
||||
for _ in range(4):
|
||||
eine_rechnung()
|
||||
seriell = time.perf_counter() - t0
|
||||
t0 = time.perf_counter()
|
||||
with Pool(max_workers=4) as p:
|
||||
list(p.map(lambda _: eine_rechnung(), range(4)))
|
||||
parallel = time.perf_counter() - t0
|
||||
|
||||
print(f" vier Rechnungen nacheinander -> {seriell:6.2f} s")
|
||||
print(f" vier Rechnungen in Threads -> {parallel:6.2f} s "
|
||||
f"(Faktor {seriell / parallel:.1f})")
|
||||
print()
|
||||
if parallel < seriell * 0.75:
|
||||
print(" Die Laeufe ueberlappen sich. ortools rechnet in C++ und gibt")
|
||||
print(" den GIL waehrend Solve() frei - deshalb genuegt hier ein")
|
||||
print(" Threadpool, es braucht keine eigenen Prozesse.")
|
||||
else:
|
||||
print(" Keine Ueberlappung - hier waeren Prozesse noetig.")
|
||||
print()
|
||||
print(" ACHTUNG, das gilt nicht allgemein: Eine in reinem Python")
|
||||
print(" geschriebene Heuristik (Kapitel Metaheuristiken) haelt den GIL")
|
||||
print(" die ganze Zeit. Fuer sie braucht derselbe Dienst einen")
|
||||
print(" ProcessPoolExecutor statt eines Threadpools.")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print(" WAS SICH IM ECHTEN BETRIEB AENDERT")
|
||||
print("=" * 80)
|
||||
print("Dieses Beispiel haelt das Auftragsbuch im Speicher und rechnet in")
|
||||
print("Threads desselben Prozesses. Das reicht zum Zeigen und fuer einen")
|
||||
print("einzelnen Rechner - nicht darueber hinaus:")
|
||||
print()
|
||||
print(" * NEUSTART LOESCHT ALLES. Auftragsbuch in eine Datenbank oder eine")
|
||||
print(" Warteschlange (Redis, RabbitMQ), nicht in ein dict.")
|
||||
print(" * ZWEI INSTANZEN KENNEN EINANDER NICHT. Sobald der Dienst mehr als")
|
||||
print(" einmal laeuft, muss die Warteschlange ausserhalb liegen -")
|
||||
print(" typischerweise Celery mit Redis als Vermittler.")
|
||||
print(" * DIE WAHL THREADS/PROZESSE HAENGT AM SOLVER - siehe die Messung")
|
||||
print(" unter Punkt 6. Sie gehoert gemessen, nicht angenommen.")
|
||||
print(" * OHNE ZEITLIMIT KEIN DIENST. Jede Instanz bekommt eines, und der")
|
||||
print(" Aufrufer erfaehrt im Status, ob es gegriffen hat.")
|
||||
print()
|
||||
print("Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:")
|
||||
print()
|
||||
for zeile in DOCKERFILE.splitlines():
|
||||
print(f" {zeile}")
|
||||
print()
|
||||
print("(Es wird hier nicht gebaut - das Buch setzt keine laufende")
|
||||
print(" Docker-Installation voraus.)")
|
||||
print("=" * 80)
|
||||
Loading…
Reference in a new issue