From 59534a98f07a27e017370beb66fcd674e03304d8 Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Sun, 10 May 2026 16:20:16 +0200 Subject: [PATCH] aufwandsspreizung (wip) versuch, aufwand zeitleich besser zu streuen. unklar, ob es funktioniert, daher auskommentiert --- elterndienstplaner.py | 208 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 198 insertions(+), 10 deletions(-) diff --git a/elterndienstplaner.py b/elterndienstplaner.py index bd2f741..28330f0 100755 --- a/elterndienstplaner.py +++ b/elterndienstplaner.py @@ -383,13 +383,14 @@ class Elterndienstplaner: fairness_abweichung_lokal: Dict, fairness_abweichung_global: Dict, fairness_abweichung_gesamt_global: Dict, - fairness_abweichung_gesamt_lokal: Dict + fairness_abweichung_gesamt_lokal: Dict, + spreizungs_penalty: pulp.LpAffineExpression = None ) -> None: - """Erstellt die Zielfunktion mit Fairness und Praeferenzen""" + """Erstellt die Zielfunktion mit Fairness, Präferenzen und Spreizung""" objective_terms = [] - gewicht_global = 50 - gewicht_lokal = 50 + gewicht_global = 60 + gewicht_lokal = 40 gewicht_f1 = gewicht_global gewicht_f2 = gewicht_lokal gewicht_f3_global = 0.25 * gewicht_global @@ -415,6 +416,10 @@ class Elterndienstplaner: if (eltern, tag, dienst) in x and praef == -1: 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: prob += pulp.lpSum(objective_terms) else: @@ -427,11 +432,7 @@ class Elterndienstplaner: pulp.LpProblem, Entscheidungsvariablen ]: - """Erstellt das PuLP Optimierungsmodell - - Returns: - Tuple mit (prob, x, ziel_dienste_lokal, ziel_dienste_global) - """ + """Erstellt das PuLP Optimierungsmodell mit Aufwandsspreizung""" print("Erstelle Optimierungsmodell...") print("\nDebug: Verfuegbarkeit analysieren...") @@ -473,8 +474,17 @@ class Elterndienstplaner: 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, - 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") return prob, x @@ -518,6 +528,184 @@ class Elterndienstplaner: 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: # New: stats-only mode: ./elterndienstplaner.py --stats-only []