operations_research/OR_HTML_04/programme/Betriebsueberwachung.py
dschlueter b7af2f1d9a 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>
2026-09-08 01:20:09 +02:00

180 lines
7 KiB
Python

#!/usr/bin/env python3
# Betriebsueberwachung.py
"""
Kapitel Praxisfallen: Das Modell, das still aufgehoert hat zu optimieren.
Ein Optimierungsmodell geht in Betrieb und laeuft jede Nacht. Es liefert
zuverlaessig eine Zahl, die Job-Dauer bleibt konstant, es gibt keine
Fehlermeldung. Alles sieht gut aus.
Waehrenddessen waechst das Unternehmen. Aus 15 Lagern werden 40, dann 80. Das
Zeitlimit von drei Sekunden - vor zwei Jahren grosszuegig bemessen - reicht
irgendwann nicht mehr. Der Solver bricht ab und liefert die beste Loesung, die
er bis dahin gefunden hat.
Das ist nicht falsch. Es ist sogar richtig so. Aber es ist etwas anderes als
das, was das Modell einmal geleistet hat - und niemand merkt es, weil im
Protokoll nur die Kosten stehen.
Dieses Programm zeigt die Verschlechterung und die Ueberwachung, die sie
sichtbar macht.
WICHTIG: ortools wird hier nicht importiert (Konflikt mit highspy, siehe
Kapitel Oekosystem).
Benoetigt: numpy, highspy
"""
from __future__ import annotations
import time
from dataclasses import dataclass
import numpy as np
import highspy
ZEITLIMIT = 3.0 # das Budget, das der Nachtjob hat
GAP_WARNSCHWELLE = 0.02 # ab 2 % Gap wollen wir es wissen
ZEITAUSLASTUNG_WARNSCHWELLE = 0.9
@dataclass
class Laufprotokoll:
"""Was nach JEDEM Produktivlauf protokolliert gehoert.
Die meisten Systeme schreiben nur 'kosten'. Genau deshalb faellt eine
schleichende Verschlechterung ueber Monate nicht auf.
"""
zeitpunkt: str
lager: int
kunden: int
status: str
kosten: float
gap: float
laufzeit: float
knoten: int
@property
def warnungen(self) -> list[str]:
meldungen = []
if self.status != "Optimal":
meldungen.append(f"nicht beweisbar optimal ({self.status})")
if self.gap > GAP_WARNSCHWELLE:
meldungen.append(f"Gap {self.gap:.1%} ueber Schwelle "
f"{GAP_WARNSCHWELLE:.0%}")
if self.laufzeit > ZEITAUSLASTUNG_WARNSCHWELLE * ZEITLIMIT:
meldungen.append(f"Zeitbudget zu {self.laufzeit / ZEITLIMIT:.0%} "
f"ausgeschoepft")
return meldungen
def plane_netzwerk(n_lager: int, n_kunden: int, zeitlimit: float,
saat: int = 7) -> tuple[str, float, float, float, int]:
"""Standortplanung wie in Kapitel MILP, nur mit wachsender Groesse."""
rng = np.random.default_rng(saat)
fixkosten = rng.uniform(3000, 9000, n_lager)
transport = rng.uniform(5, 60, (n_lager, n_kunden))
bedarf = rng.uniform(10, 60, n_kunden)
kapazitaet = np.full(n_lager, bedarf.sum() * 0.22)
modell = highspy.Highs()
modell.setOptionValue("output_flag", False)
modell.setOptionValue("time_limit", zeitlimit)
anzahl_x = n_lager * n_kunden
unendlich = highspy.kHighsInf
modell.addVars(anzahl_x, np.zeros(anzahl_x), np.full(anzahl_x, unendlich))
modell.addVars(n_lager, np.zeros(n_lager), np.ones(n_lager))
for i in range(n_lager):
modell.changeColIntegrality(anzahl_x + i, highspy.HighsVarType.kInteger)
modell.changeColCost(anzahl_x + i, fixkosten[i])
for j in range(n_kunden):
modell.changeColCost(i * n_kunden + j, transport[i, j])
for j in range(n_kunden):
index = np.array([i * n_kunden + j for i in range(n_lager)], dtype=np.int32)
modell.addRow(bedarf[j], bedarf[j], len(index), index, np.ones(len(index)))
for i in range(n_lager):
index = np.array([i * n_kunden + j for j in range(n_kunden)] + [anzahl_x + i],
dtype=np.int32)
werte = np.concatenate([np.ones(n_kunden), [-kapazitaet[i]]])
modell.addRow(-unendlich, 0.0, len(index), index, werte)
t0 = time.perf_counter()
modell.run()
laufzeit = time.perf_counter() - t0
info = modell.getInfo()
return (modell.modelStatusToString(modell.getModelStatus()),
info.objective_function_value, info.mip_gap, laufzeit,
info.mip_node_count)
if __name__ == "__main__":
# So ist das Unternehmen ueber zwei Jahre gewachsen.
entwicklung = [("2024-Q1", 15, 40), ("2024-Q3", 25, 70),
("2025-Q1", 40, 110), ("2025-Q3", 60, 160),
("2026-Q1", 80, 220)]
print("=" * 84)
print(" WAS DAS PROTOKOLL ZEIGT - UND WAS ES ZEIGEN SOLLTE")
print("=" * 84)
print(f"Derselbe Nachtjob, unveraendert, mit {ZEITLIMIT:.0f} Sekunden Zeitlimit.\n")
protokolle = []
for zeitpunkt, lager, kunden in entwicklung:
status, kosten, gap, laufzeit, knoten = plane_netzwerk(
lager, kunden, ZEITLIMIT)
protokolle.append(Laufprotokoll(zeitpunkt, lager, kunden, status,
kosten, gap, laufzeit, knoten))
print("So sieht das ueblich gefuehrte Protokoll aus:\n")
print(f" {'Zeitpunkt':<10} {'Kosten':>12}")
print(" " + "-" * 24)
for p in protokolle:
print(f" {p.zeitpunkt:<10} {p.kosten:>12,.0f}")
print("\n -> Die Kosten steigen. Das Unternehmen waechst ja auch.")
print(" Nichts an dieser Tabelle deutet auf ein Problem hin.")
print("\n" + "-" * 84)
print("So sieht ein vollstaendiges Protokoll aus:\n")
print(f" {'Zeitpunkt':<10} {'Groesse':>10} {'Status':>20} {'Kosten':>11} "
f"{'Gap':>8} {'Zeit':>7}")
print(" " + "-" * 80)
for p in protokolle:
groesse = f"{p.lager}x{p.kunden}"
print(f" {p.zeitpunkt:<10} {groesse:>10} {p.status:>20} "
f"{p.kosten:>11,.0f} {p.gap * 100:7.2f} % {p.laufzeit:6.2f}s")
print("\n" + "-" * 84)
print("Und so sehen die Warnungen aus, die daraus folgen:\n")
for p in protokolle:
if p.warnungen:
print(f" {p.zeitpunkt}: " + "; ".join(p.warnungen))
else:
print(f" {p.zeitpunkt}: in Ordnung")
letzte = protokolle[-1]
erste_warnung = next(p for p in protokolle if p.warnungen)
print("\n" + "=" * 84)
print(" WAS DA PASSIERT IST")
print("=" * 84)
print(f"Seit {erste_warnung.zeitpunkt} erreicht der Job das Zeitlimit und liefert")
print("nicht mehr das Optimum, sondern die beste bis dahin gefundene Loesung.")
print(f"Im letzten Lauf betraegt der Abstand zum Bestmoeglichen "
f"{letzte.gap:.1%}.")
print()
print("Der Job ist nicht abgestuerzt. Er hat keine Fehlermeldung erzeugt.")
print("Die Laufzeit ist sogar bemerkenswert STABIL geblieben - genau deshalb,")
print("weil das Zeitlimit greift. Ein Ueberwachungssystem, das auf Abstuerze")
print("und Laufzeitspitzen achtet, sieht hier nichts.")
print()
print("Drei Zahlen gehoeren deshalb in jedes Protokoll eines Optimierungsjobs:")
print(" * der STATUS - 'Optimal' oder etwas anderes?")
print(" * der GAP - wie weit ist die Loesung vom Bestmoeglichen entfernt?")
print(" * die LAUFZEIT im Verhaeltnis zum Limit - wie nah am Anschlag?")
print()
print("Die dritte ist die frueheste Warnung: Sie steigt, lange bevor der Gap")
print("sichtbar wird, und gibt Zeit zum Handeln.")
print("=" * 84)