Compare commits

..

No commits in common. "59534a98f07a27e017370beb66fcd674e03304d8" and "beea66ef2f9dee27e3690b9d661c416dd6c28a78" have entirely different histories.

View File

@ -15,7 +15,6 @@ from typing import Dict, List, Tuple, DefaultDict, Optional
from datenmodell import ElterndienstplanerDaten, Dienst, Eltern, Zielverteilung, Entscheidungsvariablen from datenmodell import ElterndienstplanerDaten, Dienst, Eltern, Zielverteilung, Entscheidungsvariablen
from ausgabe import ElterndienstAusgabe from ausgabe import ElterndienstAusgabe
from csv_io import EingabeParser
class Elterndienstplaner: class Elterndienstplaner:
@ -383,14 +382,13 @@ 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, Präferenzen und Spreizung""" """Erstellt die Zielfunktion mit Fairness und Praeferenzen"""
objective_terms = [] objective_terms = []
gewicht_global = 60 gewicht_global = 50
gewicht_lokal = 40 gewicht_lokal = 50
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
@ -416,10 +414,6 @@ 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:
@ -432,7 +426,11 @@ class Elterndienstplaner:
pulp.LpProblem, pulp.LpProblem,
Entscheidungsvariablen Entscheidungsvariablen
]: ]:
"""Erstellt das PuLP Optimierungsmodell mit Aufwandsspreizung""" """Erstellt das PuLP Optimierungsmodell
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...")
@ -474,17 +472,8 @@ 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
@ -528,229 +517,8 @@ 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>]
if len(sys.argv) >= 2 and sys.argv[1] == '--stats-only':
if len(sys.argv) < 3:
print("Usage: ./elterndienstplaner.py --stats-only <eltern.csv> [<vorherige-ausgaben.csv>]")
sys.exit(1)
eltern_datei = sys.argv[2]
vorherige_datei = sys.argv[3] if len(sys.argv) > 3 else None
print("Elterndienstplaner: Statistik-Modus (keine Optimierung)")
try:
daten = ElterndienstplanerDaten()
# Nur Elterndaten und historische Dienste einlesen
daten.dienstfaktoren = EingabeParser.parse_eltern_csv(eltern_datei)
daten.eltern = list(daten.dienstfaktoren.keys())
if vorherige_datei:
daten.historische_dienste = EingabeParser.parse_vorherige_ausgaben_csv(
vorherige_datei, daten.eltern, daten.dienste
)
ausgabe = ElterndienstAusgabe(daten)
# Berechne globale Zielverteilung (benötigt für Übersicht)
planer = Elterndienstplaner(daten, ausgabe)
ziel_global = planer.berechne_faire_zielverteilung_global()
from collections import defaultdict
ziel_lokal = defaultdict(lambda: defaultdict(float))
ausgabe.setze_zielverteilungen(ziel_lokal, ziel_global)
ausgabe.setze_historische_dienste(daten.historische_dienste)
# Keine Optimierung: nur Übersicht ausgeben
ausgabe.visualisiere_dienste_uebersicht({})
sys.exit(0)
except Exception as e:
print(f"\n✗ Fehler im Statistik-Modus: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
# Standardmodus: Optimierung
if len(sys.argv) < 4: if len(sys.argv) < 4:
print("Usage: ./elterndienstplaner.py <eingabe.csv> <eltern.csv> <ausgabe.csv> [<vorherige-ausgaben.csv>]") print("Usage: ./elterndienstplaner.py <eingabe.csv> <eltern.csv> <ausgabe.csv> [<vorherige-ausgaben.csv>]")
sys.exit(1) sys.exit(1)