aufwandsspreizung (wip)

versuch, aufwand zeitleich besser zu streuen. unklar, ob es
funktioniert, daher auskommentiert
This commit is contained in:
Jan Hoheisel 2026-05-10 16:20:16 +02:00
parent de93cb1aa1
commit 59534a98f0

View File

@ -383,13 +383,14 @@ class Elterndienstplaner:
fairness_abweichung_lokal: Dict, fairness_abweichung_lokal: Dict,
fairness_abweichung_global: Dict, fairness_abweichung_global: Dict,
fairness_abweichung_gesamt_global: Dict, fairness_abweichung_gesamt_global: Dict,
fairness_abweichung_gesamt_lokal: Dict fairness_abweichung_gesamt_lokal: Dict,
spreizungs_penalty: pulp.LpAffineExpression = None
) -> None: ) -> None:
"""Erstellt die Zielfunktion mit Fairness und Praeferenzen""" """Erstellt die Zielfunktion mit Fairness, Präferenzen und Spreizung"""
objective_terms = [] objective_terms = []
gewicht_global = 50 gewicht_global = 60
gewicht_lokal = 50 gewicht_lokal = 40
gewicht_f1 = gewicht_global gewicht_f1 = gewicht_global
gewicht_f2 = gewicht_lokal gewicht_f2 = gewicht_lokal
gewicht_f3_global = 0.25 * gewicht_global gewicht_f3_global = 0.25 * gewicht_global
@ -415,6 +416,10 @@ class Elterndienstplaner:
if (eltern, tag, dienst) in x and praef == -1: if (eltern, tag, dienst) in x and praef == -1:
objective_terms.append(20 * dienst.aufwand * x[eltern, tag, dienst]) objective_terms.append(20 * dienst.aufwand * x[eltern, tag, dienst])
# Spreizungs-Penalty (neu)
if spreizungs_penalty is not None and spreizungs_penalty != 0:
objective_terms.append(spreizungs_penalty)
if objective_terms: if objective_terms:
prob += pulp.lpSum(objective_terms) prob += pulp.lpSum(objective_terms)
else: else:
@ -427,11 +432,7 @@ class Elterndienstplaner:
pulp.LpProblem, pulp.LpProblem,
Entscheidungsvariablen Entscheidungsvariablen
]: ]:
"""Erstellt das PuLP Optimierungsmodell """Erstellt das PuLP Optimierungsmodell mit Aufwandsspreizung"""
Returns:
Tuple mit (prob, x, ziel_dienste_lokal, ziel_dienste_global)
"""
print("Erstelle Optimierungsmodell...") print("Erstelle Optimierungsmodell...")
print("\nDebug: Verfuegbarkeit analysieren...") print("\nDebug: Verfuegbarkeit analysieren...")
@ -473,8 +474,17 @@ class Elterndienstplaner:
prob, x, ziel_dienste_lokal, "lokal" prob, x, ziel_dienste_lokal, "lokal"
) )
# Spreizungs-Penalty (neu)
#print("\nErstelle Aufwandsspreizungs-Penalty...")
#spreizungs_penalty = self._erstelle_spreizungs_penalty_mit_dienstfaktor(
# x, fenster_groesse=15, sigma=0.8, spreizungs_gewicht=1.0
#)
#self.visualisiere_fenster_gewichte_mit_dienstfaktoren(fenster_groesse=15, sigma=0.8)
spreizungs_penalty = None
self._erstelle_zielfunktion(prob, x, fairness_abweichung_lokal, fairness_abweichung_global, self._erstelle_zielfunktion(prob, x, fairness_abweichung_lokal, fairness_abweichung_global,
fairness_abweichung_gesamt_global, fairness_abweichung_gesamt_lokal) fairness_abweichung_gesamt_global, fairness_abweichung_gesamt_lokal,
spreizungs_penalty)
print(f"Modell erstellt mit {len(x)} Variablen und {len(prob.constraints)} Constraints") print(f"Modell erstellt mit {len(x)} Variablen und {len(prob.constraints)} Constraints")
return prob, x return prob, x
@ -518,6 +528,184 @@ class Elterndienstplaner:
return loesung return loesung
def _berechne_fenster_gewichte_glocke(self, num_fenster: int, sigma: float = 0.8) -> List[float]:
"""Berechnet Fenster-Gewichte basierend auf Gauß-Glockenkurve
Mittelfenster: Gewicht 1.0 (höchste Bestrafung von Häufungen)
Randfenster: Gewicht 0.3 (niedrigere Bestrafung)
Args:
num_fenster: Anzahl der Fenster
sigma: Steile der Kurve (0.3=spitz, 1.5=flach)
Returns:
Liste mit Gewichten [0.0...1.0] pro Fenster
"""
import math
if num_fenster <= 1:
return [1.0]
fenster_gewichte = []
mitte = (num_fenster - 1) / 2.0
for i in range(num_fenster):
# Normalisierte Position relativ zur Mitte (-1...+1)
position = (i - mitte) / (mitte + 1)
# Gauß-Funktion: exp(-(position² / sigma²))
gewicht = math.exp(-(position ** 2) / (sigma ** 2))
fenster_gewichte.append(gewicht)
return fenster_gewichte
def _erstelle_spreizungs_penalty_mit_dienstfaktor(
self,
x: Entscheidungsvariablen,
fenster_groesse: int = 15,
sigma: float = 0.8,
spreizungs_gewicht: float = 0.5
) -> pulp.LpAffineExpression:
"""Erstellt Penalty-Term für Aufwandsspreizung mit Dienstfaktor-Normalisierung
Verwendet 15-Tage-Sliding-Window (1-Tage-Schritte) zur Erkennung von Häufungen.
Höhere Aufwände in der Fenster-Mitte werden stärker bestraft.
Args:
x: Entscheidungsvariablen
fenster_groesse: Größe des Sliding-Window in Tagen (Standard: 15)
sigma: Parameter für Glockenkurven-Steile
spreizungs_gewicht: Gewichtung relative zur Fairness (Standard: 0.5)
Returns:
Penalty-Term als LpAffineExpression für Zielfunktion
"""
import math
from datetime import timedelta
penalty_terms = []
planungstage = self.daten.planungszeitraum
if len(planungstage) < fenster_groesse:
print(f" Warnung: Planungszeitraum ({len(planungstage)} Tage) < Fenster ({fenster_groesse} Tage)")
return pulp.lpSum([])
# Berechne Anzahl der Fenster für Glockenkurve
num_fenster = len(planungstage) - fenster_groesse + 1
fenster_gewichte = self._berechne_fenster_gewichte_glocke(num_fenster, sigma)
print(f" Spreizungs-Constraint: {len(planungstage)} Planungstage, "
f"{fenster_groesse}-Tage-Fenster → {num_fenster} Fenster")
print(f" Fenster-Gewichte (Glockenkurve, σ={sigma}):")
for i, gew in enumerate(fenster_gewichte):
print(f" Fenster {i+1}: {gew:.3f}")
# Erstelle Sliding-Windows (1-Tage-Schritte)
for fenster_idx in range(num_fenster):
fenster_start_idx = fenster_idx
fenster_ende_idx = fenster_idx + fenster_groesse
fenster_start_tag = planungstage[fenster_start_idx]
fenster_ende_tag = planungstage[fenster_ende_idx - 1]
fenster_gewicht = fenster_gewichte[fenster_idx]
# Für jeden Elterns: Aufwand im Fenster berechnen (normalisiert durch Dienstfaktor)
for eltern in self.daten.eltern:
dienstfaktor_summe = sum(
self.daten.dienstfaktoren[eltern][tag]
for tag in planungstage[fenster_start_idx:fenster_ende_idx]
)
# Schutz vor Division-by-Zero
if dienstfaktor_summe <= 0:
continue
# Summiere Aufwände aller Dienste im Fenster für diesen Elterns
aufwand_im_fenster = pulp.lpSum(
dienst.aufwand * x[eltern, tag, dienst]
for tag in planungstage[fenster_start_idx:fenster_ende_idx]
for dienst in self.daten.dienste
if (eltern, tag, dienst) in x
) if any(
(eltern, tag, dienst) in x
for tag in planungstage[fenster_start_idx:fenster_ende_idx]
for dienst in self.daten.dienste
) else 0
if aufwand_im_fenster == 0:
continue
# Normalisiere durch Dienstfaktor und multipliziere mit Fenster-Gewicht
# penalty = (aufwand / dienstfaktor) × fenster_gewicht
penalty_terms.append(
spreizungs_gewicht * fenster_gewicht * (aufwand_im_fenster / dienstfaktor_summe)
)
if penalty_terms:
return pulp.lpSum(penalty_terms)
else:
return pulp.lpSum([])
def visualisiere_fenster_gewichte_mit_dienstfaktoren(
self,
fenster_groesse: int = 15,
sigma: float = 0.8
) -> None:
"""Debug-Hilfsfunktion: Zeigt Fenster-Gewichte und Dienstfaktoren tabellarisch
Args:
fenster_groesse: Größe des Sliding-Window in Tagen
sigma: Parameter für Glockenkurven-Steile
"""
planungstage = self.daten.planungszeitraum
if len(planungstage) < fenster_groesse:
print(f"Planungszeitraum ({len(planungstage)} Tage) < Fenster ({fenster_groesse} Tage)")
return
num_fenster = len(planungstage) - fenster_groesse + 1
fenster_gewichte = self._berechne_fenster_gewichte_glocke(num_fenster, sigma)
print("\n" + "="*100)
print(f"DEBUG: Fenster-Gewichte ({fenster_groesse}-Tage-Sliding-Window, σ={sigma})")
print("="*100)
print(f"\nPlanungszeitraum: {planungstage[0]} bis {planungstage[-1]} ({len(planungstage)} Tage)")
print(f"Fenster: {num_fenster} × {fenster_groesse} Tage\n")
# Kopfzeile
print(f"{'Fenster':<12} {'Start':12} {'Ende':12} {'Gewicht':>10} {'→ Bedeutung':<30}")
print("-" * 100)
for fenster_idx in range(num_fenster):
fenster_start_idx = fenster_idx
fenster_ende_idx = fenster_idx + fenster_groesse
fenster_start_tag = planungstage[fenster_start_idx]
fenster_ende_tag = planungstage[fenster_ende_idx - 1]
fenster_gewicht = fenster_gewichte[fenster_idx]
# Interpretation
if fenster_gewicht > 0.9:
bedeutung = "Hoch (Häufungen stark bestraft)"
farbe = "\033[91m" # Rot
elif fenster_gewicht > 0.7:
bedeutung = "Mittel (Häufungen moderat bestraft)"
farbe = "\033[93m" # Gelb
else:
bedeutung = "Niedrig (Häufungen toleriert)"
farbe = "\033[92m" # Grün
reset = "\033[0m"
print(f"{fenster_idx+1:<12} {str(fenster_start_tag):12} {str(fenster_ende_tag):12} "
f"{farbe}{fenster_gewicht:>10.3f}{reset} {bedeutung}")
print("\n" + "-" * 100)
print("Legende:")
print(f" {'\033[91m'}Gewicht > 0.9{'\033[0m'}: Häufungen in der Mitte stark bestraft")
print(f" {'\033[93m'}Gewicht 0.7-0.9{'\033[0m'}: Häufungen moderat bestraft")
print(f" {'\033[92m'}Gewicht < 0.7{'\033[0m'}: Häufungen am Rand toleriert")
def main() -> None: def main() -> None:
# New: stats-only mode: ./elterndienstplaner.py --stats-only <eltern.csv> [<vorherige-ausgaben.csv>] # New: stats-only mode: ./elterndienstplaner.py --stats-only <eltern.csv> [<vorherige-ausgaben.csv>]