From 095e022724d5eeac9fe08b890a8d95f2911cced8 Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Mon, 22 Dec 2025 00:56:13 +0100 Subject: [PATCH 01/20] scheint zu funktionieren --- README.md | 95 ++++++ ausgabe.csv | 24 ++ eingabe.csv | 24 ++ eltern.csv | 7 + elterndienstplaner.py | 665 +++++++++++++++++++++++++++++++++++++++++ elterndienstplaner.sh | 1 + vorherige-ausgaben.csv | 37 +++ 7 files changed, 853 insertions(+) create mode 100644 ausgabe.csv create mode 100644 eingabe.csv create mode 100644 eltern.csv create mode 100755 elterndienstplaner.py create mode 100755 elterndienstplaner.sh create mode 100644 vorherige-ausgaben.csv diff --git a/README.md b/README.md index e69de29..ba28ccf 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,95 @@ +Elterndienstplaner + +syntax: ./elterndienstplaner.py { } + +Der Elterndienstplaner hilft bei der Zuteilung von Elterndiensten zu Eltern. +Zur Identifizierung der Eltern dient der Name des Kindes/der Kinder. + +Es gibt diese Dienste: + - F: Frühstücksdienst (täglich) + - P: Putznotdienst (täglich) + - E: Essensausgabenotdienst (täglich) + - K: Kochen (alle 2 wochen, Datum manuell festgelegt) + - A: Elternabend (2 Eltern, Datum manuell festgelegt) + +Die Planung erfolgt immer für einen Kalendermonat. + +Die Eltern geben an, an welchen Tagen sie abwesend sind, also nicht zur Verfügung stehen. Zudem können sie für jede Tag-Dienst-Kombination angeben, ob sie den Dienst an diesen Tag bevorzugt (+) oder nur notfalls (-) machen wollen. + +Die Eingabe erfolgt über eine CSV-Datei eingabe.csv und eltern.csv + +## eingabe.csv +Informationen zu notwendigen Diensten eines Monats und Zeiten/Praeferenzen der Eltern + +1. Spalte: Datum in ISO-Format +2. Spalte: Wochentag (Hilfsinformation) +3. Spalte: Benötigte Dienste als aneinandergereihte Dienstkürzel +Folgende Spalten: Für alle Eltern: Verfügbarkeit und Präferenz: + - x, falls nicht verfügbar + - +, wenn Dienst an dem Tag bevorzugt. + - -, wenn Dienst an dem Tag abgelehnt. +Es können mehrere Präferenzen pro Tag angegeben werden. +1. Zeile header +folgende Zeilen je Tag. + + +## eltern.csv: +Informationen zur Diestpflicht der Eltern. +Die Dienstpflicht besteht, wenn Eltern Kinder im Kinderladen betreuen lassen. +Der Dienstfaktor entspricht der Anzahl der betreuten Kinder der Eltern. +Wenn Eltern ein Vorsandsamt im Kinderladen übernehmen, werden sie von der Dienstpflicht befreit. + +1. Spalte Zeitraum Beginn +2. Spalte Zeitraum Ende +3. Spalte Dienstfaktor +4. Spalte ... nächster Zeitraum +1. Zeile Header +folgende Zeilen Eltern + + +Bei sich überschneidenden Zeiträumen gilt der letzte Eintrag. +An Tagen außerhalb der angegebenen Zeiträume ist der Dienstfaktor 0. + +Die Datei eltern.csv enthält ggf. mehr Eltern als die Eingabe.csv, +da Kinder dazukommen oder den KiLa verlassen, die eltern.csv aber nur anwächst. + +## ausgabe.csv +1. Spalte: Datum +2. Spalte: Wochentag +3. Spalte ... Dienste +Zeilen: für jeden Tag die zugeteilten Eltern in den jeweiligen Dienstspalten + +## vorherige-ausgaben.csv +Hier werden die von früheren Läufen des Programms generierten ausgabe.csv-Datein wiedereingespielt. +Das Format entspricht der ausgabe.csv + +## Constraints + +Harte Constraints: + C1: Je Eltern und Dienst, Dienst nur einmal die Woche + C2: Je Eltern nur einen Dienst am Tag + C3: Dienste nur verfügbaren Eltern zuteilen + +Weiche Constraints: +- F1: Alle Eltern erhalten Dienste im Verhältnis ihres Dienstfaktors (Gesamter vorliegender Zeitraum) +- F2: Alle Eltern erhalten Dienste im Verhältnis ihres aktuellen Dienstfaktors (Aktueller Monat) +- P1: Eltern erhalten bevorzugte Dienste +- P2: Eltern erhalten keine abgelehnten Dienste. + +F1 und F2 sind Fairnessconstraints und gelten pro Dienst. +P1 und P2 sind Präferenzconstraints. Sie wiegen schwächer als die Fairnessconstaints. + + +Die vorherige-ausgaben.csv der vergangenen Monate dienen auch als Eingabe. +Sie werden für die Fairnessconstraints verwendet. +Im Laufe eines Kinderladenjahrs sammeln sich die Ausgaben der Monate an. +F2 stellt die Fairness im aktuellen Monat sicher -> lokale Fairness +F1 stellt die Fairness für das gesamte Jahr sicher -> globale Fairness + +Wenn z.B. Eltern eine zeitlang nicht verfügbar sind, sollen sie nicht sofort +alle Dienste "nachholen" müssen (lokale Fairness stellt das sicher), +aber im Jahresverlauf die Dienste trotzdem nachholen (globale Fairness stellt das sicher). + +F1 und F2 werden mit Faktoren gewichtet. Zu Beginn des Kinderladenjahrs ist F2 stärker, +zum Ende des Kinderladenjahres F1. + diff --git a/ausgabe.csv b/ausgabe.csv new file mode 100644 index 0000000..6bdca70 --- /dev/null +++ b/ausgabe.csv @@ -0,0 +1,24 @@ +Datum,Wochentag,Frühstücksdienst,Putznotdienst,Essensausgabenotdienst,Kochen,Elternabend +2026-01-01,Donnerstag,Paula,Marie,Jonas,, +2026-01-02,Freitag,Jonas,Ben & Nele,Laura,, +2026-01-03,Samstag,Marie,Jonas,Ben & Nele,, +2026-01-06,Dienstag,Laura,Paula,Erwin,, +2026-01-07,Mittwoch,Ben & Nele,Erwin,Marie,, +2026-01-08,Donnerstag,Jonas,Paula,Marie,Ben & Nele, +2026-01-09,Freitag,Erwin,Marie,Paula,, +2026-01-10,Samstag,Paula,Laura,Ben & Nele,, +2026-01-13,Dienstag,Ben & Nele,Jonas,Laura,, +2026-01-14,Mittwoch,Marie,Ben & Nele,Jonas,, +2026-01-15,Donnerstag,Laura,Jonas,Erwin,, +2026-01-16,Freitag,Marie,Laura,Ben & Nele,, +2026-01-17,Samstag,Jonas,Ben & Nele,Paula,, +2026-01-20,Dienstag,Ben & Nele,Erwin,Marie,, +2026-01-21,Mittwoch,Erwin,Marie,Jonas,, +2026-01-22,Donnerstag,Ben & Nele,Marie,Laura,Jonas, +2026-01-23,Freitag,Marie,Jonas,Ben & Nele,, +2026-01-24,Samstag,Laura,Erwin,Marie,,Ben & Nele Jonas +2026-01-27,Dienstag,Erwin,Ben & Nele,Jonas,, +2026-01-28,Mittwoch,Jonas,Paula,Erwin,, +2026-01-29,Donnerstag,Marie,Ben & Nele,Jonas,, +2026-01-30,Freitag,Ben & Nele,Jonas,Paula,, +2026-01-31,Samstag,Paula,Marie,Ben & Nele,, diff --git a/eingabe.csv b/eingabe.csv new file mode 100644 index 0000000..36f3dae --- /dev/null +++ b/eingabe.csv @@ -0,0 +1,24 @@ +Datum , Wochentag , Dienste, Erwin, Paula, Laura, Ben & Nele, Jonas, Marie +2026-01-01, Mittwoch , FPE , x , F+ , P- , , E+ , P+ +2026-01-02, Donnerstag, FPE , x , , E+ , P+ , F+ , P- +2026-01-03, Freitag , FPE , x , F+ , , F-E+ , P+ , E- +2026-01-06, Montag , FPE , F- , P+ , F+ , E+ , , F- +2026-01-07, Dienstag , FPE , P+ , F+ , P- , F+P+ , E+ , F- +2026-01-08, Mittwoch , FPEK , E+ , P+ , F+ , K+ , P- , E+ +2026-01-09, Donnerstag, FPE , F+ , , E- , P+F+ , , P+ +2026-01-10, Freitag , FPE , , F+ , P+ , E+ , F- , +2026-01-13, Montag , FPE , F+ , P- , E+ , F+ , P+ , E- +2026-01-14, Dienstag , FPE , P+ , F+ , F- , P+E+ , E+ , P- +2026-01-15, Mittwoch , FPE , E+ , P+ , F+ , F- , , E+ +2026-01-16, Donnerstag, FPE , F- , , P+ , E+F+ , P- , F+ +2026-01-17, Freitag , FPE , F+ , F+ , E+ , P+ , , F- +2026-01-20, Montag , FPE , P+ , x , F- , F+E+ , F+ , P+ +2026-01-21, Dienstag , FPE , F+ , x , P+ , P+ , E+ , F- +2026-01-22, Mittwoch , FPEK , , x , E+ , K+F+ , P+ , K- +2026-01-23, Donnerstag, FPE , F+ , x , F- , E+ , P+ , F+ +2026-01-24, Freitag , FPEA , P+ , x , F+ , A+F+ , E- , A+ +2026-01-27, Montag , FPE , F+ , P- , x , P+F+ , E+ , P- +2026-01-28, Dienstag , FPE , E+ , F+ , x , F- , P+ , E+ +2026-01-29, Mittwoch , FPE , F- , P+ , x , E+P+ , , F+ +2026-01-30, Donnerstag, FPE , F+ , , x , F+ , P+ , E- +2026-01-31, Freitag , FPE , P+ , F+ , x , E+ , F- , P+ diff --git a/eltern.csv b/eltern.csv new file mode 100644 index 0000000..c301023 --- /dev/null +++ b/eltern.csv @@ -0,0 +1,7 @@ +Name_Kind(er),Zeitraum_Beginn,Zeitraum_Ende,Dienstfaktor,Zeitraum_Beginn2,Zeitraum_Ende2,Dienstfaktor2,Zeitraum_Beginn3,Zeitraum_Ende3,Dienstfaktor3 +Erwin,2025-09-01,2026-07-31,1,,,,, +Paula,2025-09-01,2026-07-31,1,,,,, +Laura,2025-09-01,2026-07-31,1,,,,, +Ben & Nele,2025-09-01,2026-07-31,2,,,,, +Jonas,2025-09-01,2026-07-31,1,,,,, +Marie,2025-09-01,2026-07-31,1,,,,, diff --git a/elterndienstplaner.py b/elterndienstplaner.py new file mode 100755 index 0000000..874f61b --- /dev/null +++ b/elterndienstplaner.py @@ -0,0 +1,665 @@ +#!/usr/bin/env python3 +""" +Elterndienstplaner - Optimale Zuteilung von Elterndiensten + +Autor: Automatisch generiert +Datum: Dezember 2025 +""" + +import sys +import csv +import pulp +from datetime import datetime, timedelta +from collections import defaultdict +import calendar + +class Elterndienstplaner: + def __init__(self): + self.dienste = ['F', 'P', 'E', 'K', 'A'] # Frühstück, Putz, Essen, Kochen, Elternabend + self.dienst_namen = { + 'F': 'Frühstücksdienst', + 'P': 'Putznotdienst', + 'E': 'Essensausgabenotdienst', + 'K': 'Kochen', + 'A': 'Elternabend' + } + + # Datenstrukturen + self.tage = [] + self.eltern = [] + self.benoetigte_dienste = {} # {datum: [dienste]} + self.verfügbarkeit = {} # {(eltern, datum): bool} + self.präferenzen = {} # {(eltern, datum, dienst): 1 (bevorzugt) oder -1 (abgelehnt)} + self.dienstfaktoren = {} # {eltern: {datum: faktor}} + self.alle_zeitraeume = {} # {eltern: [(beginn, ende, faktor), ...]} - ALLE Zeiträume + self.vorherige_dienste = defaultdict(lambda: defaultdict(int)) # {eltern: {dienst: anzahl}} + self.historische_dienste = [] # [(datum, eltern, dienst), ...] - Alle historischen Dienste mit Datum + + def lade_eingabe_csv(self, datei): + """Lädt die eingabe.csv mit Terminen und Präferenzen""" + print(f"Lade Eingabedaten aus {datei}...") + + with open(datei, 'r', encoding='utf-8') as f: + reader = csv.reader(f) + header = next(reader) + + # Eltern aus Header extrahieren (ab Spalte 3) + self.eltern = [name.strip() for name in header[3:] if name.strip()] + print(f"Gefundene Eltern: {self.eltern}") + + for row in reader: + if len(row) < 3: + continue + + datum = row[0].strip() + wochentag = row[1].strip() + dienste_str = row[2].strip() + + # Datum parsen + try: + datum_obj = datetime.strptime(datum, '%Y-%m-%d').date() + self.tage.append(datum_obj) + except ValueError: + print(f"Warnung: Ungültiges Datum {datum}") + continue + + # Benötigte Dienste + self.benoetigte_dienste[datum_obj] = list(dienste_str) + + # Verfügbarkeit und Präferenzen der Eltern + for i, eltern_name in enumerate(self.eltern): + if i + 3 < len(row): + präf_str = row[i + 3].strip() + + # Verfügbarkeit prüfen + if präf_str == 'x': + self.verfügbarkeit[(eltern_name, datum_obj)] = False + else: + self.verfügbarkeit[(eltern_name, datum_obj)] = True + + # Präferenzen parsen + self._parse_präferenzen(eltern_name, datum_obj, präf_str) + else: + # Standard: verfügbar, keine Präferenzen + self.verfügbarkeit[(eltern_name, datum_obj)] = True + + self.tage.sort() + print(f"Zeitraum: {self.tage[0]} bis {self.tage[-1]} ({len(self.tage)} Tage)") + + def _parse_präferenzen(self, eltern, datum, präf_str): + """Parst Präferenzstring wie 'F+P-E+' """ + i = 0 + while i < len(präf_str): + if i + 1 < len(präf_str) and präf_str[i] in self.dienste: + dienst = präf_str[i] + if i + 1 < len(präf_str): + if präf_str[i + 1] == '+': + self.präferenzen[(eltern, datum, dienst)] = 1 + i += 2 + elif präf_str[i + 1] == '-': + self.präferenzen[(eltern, datum, dienst)] = -1 + i += 2 + else: + i += 1 + else: + i += 1 + else: + i += 1 + + def lade_eltern_csv(self, datei): + """Lädt die eltern.csv mit Dienstfaktoren""" + print(f"Lade Elterndaten aus {datei}...") + + with open(datei, 'r', encoding='utf-8') as f: + reader = csv.reader(f) + header = next(reader) + + for row in reader: + if len(row) < 4: + continue + + eltern_name = row[0].strip() + + # Initialisiere Datenstrukturen + self.dienstfaktoren[eltern_name] = {} + self.alle_zeitraeume[eltern_name] = [] + + # Alle Zeiträume einlesen (jeweils 3 Spalten: Beginn, Ende, Faktor) + for i in range(1, len(row), 3): + if i + 2 < len(row) and row[i].strip() and row[i + 1].strip(): + try: + beginn = datetime.strptime(row[i].strip(), '%Y-%m-%d').date() + ende = datetime.strptime(row[i + 1].strip(), '%Y-%m-%d').date() + faktor = float(row[i + 2].strip()) if row[i + 2].strip() else 0 + + # Zeitraum speichern + self.alle_zeitraeume[eltern_name].append((beginn, ende, faktor)) + + # Faktor für Tage im aktuellen Planungsmonat setzen + for tag in self.tage: + if beginn <= tag <= ende: + self.dienstfaktoren[eltern_name][tag] = faktor + + except (ValueError, IndexError): + continue + + # Tage ohne expliziten Faktor auf 0 setzen + for tag in self.tage: + if tag not in self.dienstfaktoren[eltern_name]: + self.dienstfaktoren[eltern_name][tag] = 0 + + print(f"Dienstfaktoren geladen für {len(self.dienstfaktoren)} Eltern") + print(f"Zeiträume gespeichert für globale Fairness-Berechnung") + + def lade_vorherige_ausgaben_csv(self, datei): + """Lädt vorherige-ausgaben.csv für Fairness-Constraints""" + print(f"Lade vorherige Ausgaben aus {datei}...") + + try: + with open(datei, 'r', encoding='utf-8') as f: + reader = csv.reader(f) + header = next(reader) + + # Dienst-Spalten finden + dienst_spalten = {} + for i, col_name in enumerate(header[2:], 2): # Ab Spalte 2 (nach Datum, Wochentag) + for dienst_kürzel, dienst_name in self.dienst_namen.items(): + if dienst_name.lower() in col_name.lower() or dienst_kürzel == col_name: + dienst_spalten[dienst_kürzel] = i + break + + for row in reader: + if len(row) < 3: + continue + + # Datum parsen + try: + datum = datetime.strptime(row[0].strip(), '%Y-%m-%d').date() + except ValueError: + continue + + # Zugeteilte Dienste zählen UND mit Datum speichern + for dienst, spalte_idx in dienst_spalten.items(): + if spalte_idx < len(row) and row[spalte_idx].strip(): + # Mehrere Eltern können in einer Zelle stehen (durch Leerzeichen getrennt) + eltern_liste = row[spalte_idx].strip().split() + for eltern_name in eltern_liste: + if eltern_name in self.eltern: + # Summierung für Kompatibilität + self.vorherige_dienste[eltern_name][dienst] += 1 + # Historische Dienste mit Datum speichern + self.historische_dienste.append((datum, eltern_name, dienst)) + + except FileNotFoundError: + print("Keine vorherigen Ausgaben gefunden - starte ohne historische Daten") + + print(f"Vorherige Dienste geladen: {dict(self.vorherige_dienste)}") + print(f"Historische Dienste mit Datum: {len(self.historische_dienste)} Einträge") + + def berechne_dienstfaktor_an_datum(self, eltern, datum): + """Berechnet den Dienstfaktor eines Elternteils an einem bestimmten Datum""" + if eltern not in self.alle_zeitraeume: + return 0 + + for beginn, ende, faktor in self.alle_zeitraeume[eltern]: + if beginn <= datum <= ende: + return faktor + return 0 + + def berechne_faire_zielverteilung(self): + """Berechnet die faire Zielanzahl von Diensten pro Eltern-Dienst-Kombination + basierend auf tatsächlich geleisteten historischen Diensten und deren fairer Umverteilung""" + + ziel_dienste = defaultdict(lambda: defaultdict(float)) # {eltern: {dienst: anzahl}} + + print("\nBerechne faire Zielverteilung basierend auf historischen Daten...") + + # Wenn keine historischen Daten vorhanden, nur aktuellen Monat berechnen + if not self.historische_dienste: + print(" Keine historischen Daten - berechne nur für aktuellen Monat") + for dienst in self.dienste: + benoetigte_dienste_monat = sum( + 1 for tag in self.tage + if dienst in self.benoetigte_dienste.get(tag, []) + ) + + if dienst == 'A': + benoetigte_dienste_monat *= 2 + + if benoetigte_dienste_monat > 0: + gesamt_dienstfaktor_monat = sum( + sum(self.dienstfaktoren.get(e, {}).get(tag, 0) for tag in self.tage) + for e in self.eltern + ) + + if gesamt_dienstfaktor_monat > 0: + for eltern in self.eltern: + monatsfaktor = sum( + self.dienstfaktoren.get(eltern, {}).get(tag, 0) + for tag in self.tage + ) + if monatsfaktor > 0: + anteil = monatsfaktor / gesamt_dienstfaktor_monat + faire_zuteilung = anteil * benoetigte_dienste_monat + ziel_dienste[eltern][dienst] = faire_zuteilung + return ziel_dienste + + # Historische Dienste nach Datum gruppieren + historische_tage = set(datum for datum, _, _ in self.historische_dienste) + print(f" Analysiere {len(historische_tage)} historische Tage mit {len(self.historische_dienste)} Diensten") + + for dienst in self.dienste: + print(f" Verarbeite Dienst {dienst}...") + + # 1. HISTORISCHE PERIODE: Faire Umverteilung der tatsächlich geleisteten Dienste + historische_dienste_dieses_typs = [ + (datum, eltern) for datum, eltern, d in self.historische_dienste + if d == dienst + ] + + print(f" Gefundene historische {dienst}-Dienste: {len(historische_dienste_dieses_typs)}") + + # Gruppiere nach Datum + dienste_pro_tag = defaultdict(list) + for datum, eltern in historische_dienste_dieses_typs: + dienste_pro_tag[datum].append(eltern) + + # Für jeden historischen Tag faire Umverteilung berechnen + for hist_datum, geleistete_eltern in dienste_pro_tag.items(): + anzahl_dienste = len(geleistete_eltern) # Anzahl Dienste an diesem Tag + + # Dienstfaktoren aller Eltern für diesen historischen Tag berechnen + dienstfaktoren_tag = {} + gesamt_dienstfaktor_tag = 0 + + for eltern in self.eltern: + faktor = self.berechne_dienstfaktor_an_datum(eltern, hist_datum) + dienstfaktoren_tag[eltern] = faktor + gesamt_dienstfaktor_tag += faktor + + # Faire Umverteilung der an diesem Tag geleisteten Dienste + if gesamt_dienstfaktor_tag > 0: + for eltern in self.eltern: + if dienstfaktoren_tag[eltern] > 0: + anteil = dienstfaktoren_tag[eltern] / gesamt_dienstfaktor_tag + faire_zuteilung = anteil * anzahl_dienste + ziel_dienste[eltern][dienst] += faire_zuteilung + + if faire_zuteilung > 0.01: # Debug nur für relevante Werte + print(f" {hist_datum}: {eltern} Faktor={dienstfaktoren_tag[eltern]} " + f"-> {faire_zuteilung:.2f} von {anzahl_dienste} Diensten") + + # 2. AKTUELLER MONAT: Faire Verteilung der benötigten Dienste + benoetigte_dienste_monat = sum( + 1 for tag in self.tage + if dienst in self.benoetigte_dienste.get(tag, []) + ) + + if dienst == 'A': + benoetigte_dienste_monat *= 2 + + if benoetigte_dienste_monat > 0: + # Gesamtdienstfaktor für aktuellen Monat + gesamt_dienstfaktor_monat = sum( + sum(self.dienstfaktoren.get(e, {}).get(tag, 0) for tag in self.tage) + for e in self.eltern + ) + + if gesamt_dienstfaktor_monat > 0: + for eltern in self.eltern: + monatsfaktor = sum( + self.dienstfaktoren.get(eltern, {}).get(tag, 0) + for tag in self.tage + ) + if monatsfaktor > 0: + anteil = monatsfaktor / gesamt_dienstfaktor_monat + faire_zuteilung = anteil * benoetigte_dienste_monat + ziel_dienste[eltern][dienst] += faire_zuteilung + + # Debug-Ausgabe für diesen Dienst + total_historisch = sum( + ziel_dienste[e][dienst] for e in self.eltern + if dienst in [d for _, _, d in self.historische_dienste if d == dienst] + ) + total_aktuell = benoetigte_dienste_monat + print(f" {dienst}: Historisch faire Summe={total_historisch:.1f}, " + f"Aktuell benötigt={total_aktuell}") + + # Debug-Output: Detaillierte Zielverteilung + print("\n Berechnete Zielverteilung (basierend auf tatsächlichen historischen Diensten):") + for eltern in sorted(self.eltern): + for dienst in self.dienste: + if ziel_dienste[eltern][dienst] > 0.1: # Nur relevante Werte + ist = self.vorherige_dienste[eltern][dienst] + ziel = ziel_dienste[eltern][dienst] + print(f" {eltern} {dienst}: IST={ist}, FAIRE_ZIEL={ziel:.2f}, DIFF={ziel-ist:.2f}") + + return ziel_dienste + + def erstelle_optimierungsmodell(self): + """Erstellt das PuLP Optimierungsmodell""" + print("Erstelle Optimierungsmodell...") + + # Debugging: Verfügbarkeit prüfen + print("\nDebug: Verfügbarkeit analysieren...") + for tag in self.tage[:5]: # Erste 5 Tage + verfügbare = [e for e in self.eltern if self.verfügbarkeit.get((e, tag), True)] + benötigte = self.benoetigte_dienste.get(tag, []) + print(f" {tag}: Benötigt {len(benötigte)} Dienste {benötigte}, verfügbar: {verfügbare}") + + # LP Problem erstellen + prob = pulp.LpProblem("Elterndienstplaner", pulp.LpMinimize) + + # Entscheidungsvariablen: x[eltern, tag, dienst] ∈ {0,1} + x = {} + for eltern in self.eltern: + for tag in self.tage: + for dienst in self.dienste: + if dienst in self.benoetigte_dienste.get(tag, []): + x[eltern, tag, dienst] = pulp.LpVariable( + f"x_{eltern.replace(' ', '_')}_{tag}_{dienst}", + cat='Binary' + ) + + # Vereinfachtes Modell: Grundlegende Constraints + + # C1: Je Eltern und Dienst nur einmal die Woche + woche_start = self.tage[0] + woche_nr = 0 + while woche_start <= self.tage[-1]: + woche_ende = min(woche_start + timedelta(days=6), self.tage[-1]) + woche_tage = [t for t in self.tage if woche_start <= t <= woche_ende] + + for eltern in self.eltern: + for dienst in self.dienste: + woche_vars = [] + for tag in woche_tage: + if (eltern, tag, dienst) in x: + woche_vars.append(x[eltern, tag, dienst]) + + if woche_vars: + prob += pulp.lpSum(woche_vars) <= 1, f"C1_{eltern.replace(' ', '_')}_{dienst}_w{woche_nr}" + + woche_start += timedelta(days=7) + woche_nr += 1 + + # C2: Je Eltern nur einen Dienst am Tag + for eltern in self.eltern: + for tag in self.tage: + tag_vars = [] + for dienst in self.dienste: + if (eltern, tag, dienst) in x: + tag_vars.append(x[eltern, tag, dienst]) + + if tag_vars: + prob += pulp.lpSum(tag_vars) <= 1, f"C2_{eltern.replace(' ', '_')}_{tag}" + + # C3: Dienste nur verfügbaren Eltern zuteilen + for eltern in self.eltern: + for tag in self.tage: + if not self.verfügbarkeit.get((eltern, tag), True): + for dienst in self.dienste: + if (eltern, tag, dienst) in x: + prob += x[eltern, tag, dienst] == 0, f"C3_{eltern.replace(' ', '_')}_{tag}_{dienst}" + + # Alle benötigten Dienste müssen zugeteilt werden (flexibel) + for tag in self.tage: + for dienst in self.benoetigte_dienste.get(tag, []): + dienst_vars = [] + verfuegbare_eltern = 0 + for eltern in self.eltern: + if (eltern, tag, dienst) in x: + # Prüfe ob Eltern verfügbar + if self.verfügbarkeit.get((eltern, tag), True): + dienst_vars.append(x[eltern, tag, dienst]) + verfuegbare_eltern += 1 + + if dienst_vars: + # Genau 1 Person pro Dienst (außer Elternabend: genau 2) + benoetigte_personen = 2 if dienst == 'A' else 1 + prob += pulp.lpSum(dienst_vars) == benoetigte_personen, f"Bedarf_{tag}_{dienst}" + + # FAIRNESS-CONSTRAINTS UND ZIELFUNKTION + objective_terms = [] + + # Berechne faire Zielverteilung + ziel_dienste = self.berechne_faire_zielverteilung() + + # Hilfsvariablen für Fairness-Abweichungen + fairness_abweichung_lokal = {} # F2 + fairness_abweichung_global = {} # F1 + + for eltern in self.eltern: + for dienst in self.dienste: + fairness_abweichung_lokal[eltern, dienst] = pulp.LpVariable( + f"fair_lokal_{eltern.replace(' ', '_')}_{dienst}", lowBound=0) + fairness_abweichung_global[eltern, dienst] = pulp.LpVariable( + f"fair_global_{eltern.replace(' ', '_')}_{dienst}", lowBound=0) + + # F1: Globale Fairness & F2: Lokale Fairness + for eltern in self.eltern: + for dienst in self.dienste: + # Dienstfaktor für aktuellen Monat + monatlicher_dienstfaktor = sum( + self.dienstfaktoren.get(eltern, {}).get(tag, 0) for tag in self.tage + ) + + if monatlicher_dienstfaktor > 0: + # Tatsächliche Dienste im aktuellen Monat + tatsaechliche_dienste_monat = pulp.lpSum( + x[eltern, tag, dienst] + for tag in self.tage + if (eltern, tag, dienst) in x + ) + + # F2: Lokale Fairness - nur aktueller Monat + benoetigte_dienste_monat = sum( + 1 for tag in self.tage + if dienst in self.benoetigte_dienste.get(tag, []) + ) + if dienst == 'A': + benoetigte_dienste_monat *= 2 + + gesamt_dienstfaktor_monat = sum( + sum(self.dienstfaktoren.get(e, {}).get(tag, 0) for tag in self.tage) + for e in self.eltern + ) + + if gesamt_dienstfaktor_monat > 0 and benoetigte_dienste_monat > 0: + erwartete_dienste_lokal = ( + monatlicher_dienstfaktor / gesamt_dienstfaktor_monat + ) * benoetigte_dienste_monat + + # F2: Lokale Fairness-Constraints + prob += (tatsaechliche_dienste_monat - erwartete_dienste_lokal <= + fairness_abweichung_lokal[eltern, dienst]) + prob += (erwartete_dienste_lokal - tatsaechliche_dienste_monat <= + fairness_abweichung_lokal[eltern, dienst]) + + # F1: Globale Fairness - basierend auf berechneter Zielverteilung + ziel_gesamt = ziel_dienste[eltern][dienst] + vorherige_dienste = self.vorherige_dienste[eltern][dienst] + + if ziel_gesamt > 0: + # Tatsächliche Dienste global (Vergangenheit + geplant) + total_dienste_inkl_vergangenheit = tatsaechliche_dienste_monat + vorherige_dienste + + # F1: Globale Fairness-Constraints + prob += (total_dienste_inkl_vergangenheit - ziel_gesamt <= + fairness_abweichung_global[eltern, dienst]) + prob += (ziel_gesamt - total_dienste_inkl_vergangenheit <= + fairness_abweichung_global[eltern, dienst]) + + # Gewichtung: Jahresanfang F1 stärker, Jahresende F2 stärker + # Annahme: September = Jahresanfang, Juli = Jahresende + aktueller_monat = self.tage[0].month if self.tage else 1 + if 9 <= aktueller_monat <= 12: # Sep-Dez: Jahresanfang + gewicht_f1 = 100 # Global wichtiger + gewicht_f2 = 50 # Lokal weniger wichtig + elif 1 <= aktueller_monat <= 3: # Jan-Mar: Jahresmitte + gewicht_f1 = 75 + gewicht_f2 = 75 + else: # Apr-Jul: Jahresende + gewicht_f1 = 50 # Global weniger wichtig + gewicht_f2 = 100 # Lokal wichtiger + + # Fairness-Terme zur Zielfunktion hinzufügen + for eltern in self.eltern: + for dienst in self.dienste: + objective_terms.append(gewicht_f1 * fairness_abweichung_global[eltern, dienst]) + objective_terms.append(gewicht_f2 * fairness_abweichung_lokal[eltern, dienst]) + + # P1: Bevorzugte Dienste (positiv belohnen) + for (eltern, tag, dienst), präf in self.präferenzen.items(): + if (eltern, tag, dienst) in x and präf == 1: # bevorzugt + objective_terms.append(-5 * x[eltern, tag, dienst]) # Schwächer als Fairness + + # P2: Abgelehnte Dienste (bestrafen) + for (eltern, tag, dienst), präf in self.präferenzen.items(): + if (eltern, tag, dienst) in x and präf == -1: # abgelehnt + objective_terms.append(25 * x[eltern, tag, dienst]) # Schwächer als Fairness + + # Zielfunktion setzen + if objective_terms: + prob += pulp.lpSum(objective_terms) + else: + # Fallback: Minimiere Gesamtanzahl Dienste + prob += pulp.lpSum([var for var in x.values()]) + + print(f"Verwende Gewichtung: F1 (global) = {gewicht_f1}, F2 (lokal) = {gewicht_f2}") + print(f"Modell erstellt mit {len(x)} Variablen und {len(prob.constraints)} Constraints") + return prob, x + + def löse_optimierung(self, prob, x): + """Löst das Optimierungsproblem""" + print("Löse Optimierungsproblem...") + + # Solver wählen (verfügbare Solver testen) + solver = None + try: + solver = pulp.PULP_CBC_CMD(msg=0) # Standard CBC Solver + except: + try: + solver = pulp.GLPK_CMD(msg=0) # GLPK falls verfügbar + except: + solver = None # Default Solver + + prob.solve(solver) + + status = pulp.LpStatus[prob.status] + print(f"Optimierung abgeschlossen: {status}") + + if prob.status != pulp.LpStatusOptimal: + print("WARNUNG: Keine optimale Lösung gefunden!") + return None + + # Lösung extrahieren + lösung = {} + for (eltern, tag, dienst), var in x.items(): + if var.varValue and var.varValue > 0.5: # Binary variable ist 1 + if tag not in lösung: + lösung[tag] = {} + if dienst not in lösung[tag]: + lösung[tag][dienst] = [] + lösung[tag][dienst].append(eltern) + + return lösung + + def schreibe_ausgabe_csv(self, datei, lösung): + """Schreibt die Lösung in die ausgabe.csv""" + print(f"Schreibe Ergebnisse nach {datei}...") + + with open(datei, 'w', newline='', encoding='utf-8') as f: + writer = csv.writer(f) + + # Header schreiben + header = ['Datum', 'Wochentag'] + [self.dienst_namen[d] for d in self.dienste] + writer.writerow(header) + + # Daten schreiben + for tag in sorted(self.tage): + wochentag = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'][tag.weekday()] + + row = [tag.strftime('%Y-%m-%d'), wochentag] + + for dienst in self.dienste: + if tag in lösung and dienst in lösung[tag]: + eltern_str = ' '.join(lösung[tag][dienst]) + else: + eltern_str = '' + row.append(eltern_str) + + writer.writerow(row) + + print("Ausgabe erfolgreich geschrieben!") + + def drucke_statistiken(self, lösung): + """Druckt Statistiken zur Lösung""" + print("\n" + "="*50) + print("STATISTIKEN") + print("="*50) + + # Dienste pro Eltern zählen + dienste_pro_eltern = defaultdict(lambda: defaultdict(int)) + for tag, tag_dienste in lösung.items(): + for dienst, eltern_liste in tag_dienste.items(): + for eltern in eltern_liste: + dienste_pro_eltern[eltern][dienst] += 1 + + # Gesamtübersicht + print("\nDienste pro Eltern:") + for eltern in sorted(self.eltern): + gesamt = sum(dienste_pro_eltern[eltern].values()) + dienste_detail = ', '.join(f"{d}:{dienste_pro_eltern[eltern][d]}" + for d in self.dienste if dienste_pro_eltern[eltern][d] > 0) + print(f" {eltern:15} {gesamt:3d} ({dienste_detail})") + + # Dienstfaktor-Analyse + print(f"\nDienstfaktoren im Planungszeitraum:") + for eltern in sorted(self.eltern): + faktor_summe = sum(self.dienstfaktoren.get(eltern, {}).get(tag, 0) for tag in self.tage) + print(f" {eltern:15} {faktor_summe:.1f}") + + +def main(): + if len(sys.argv) < 4: + print("Usage: ./elterndienstplaner.py []") + sys.exit(1) + + eingabe_datei = sys.argv[1] + eltern_datei = sys.argv[2] + ausgabe_datei = sys.argv[3] + vorherige_datei = sys.argv[4] if len(sys.argv) > 4 else None + + print("Elterndienstplaner gestartet") + print("="*50) + + try: + planer = Elterndienstplaner() + + # Daten laden + planer.lade_eingabe_csv(eingabe_datei) + planer.lade_eltern_csv(eltern_datei) + if vorherige_datei: + planer.lade_vorherige_ausgaben_csv(vorherige_datei) + + # Optimierung + prob, x = planer.erstelle_optimierungsmodell() + lösung = planer.löse_optimierung(prob, x) + + if lösung is not None: + # Ergebnisse ausgeben + planer.schreibe_ausgabe_csv(ausgabe_datei, lösung) + planer.drucke_statistiken(lösung) + print("\n✓ Planung erfolgreich abgeschlossen!") + else: + print("\n✗ Fehler: Keine gültige Lösung gefunden!") + sys.exit(1) + + except Exception as e: + print(f"\n✗ Fehler: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/elterndienstplaner.sh b/elterndienstplaner.sh new file mode 100755 index 0000000..af38121 --- /dev/null +++ b/elterndienstplaner.sh @@ -0,0 +1 @@ +/home/jwit/privat/elterndienstplaner/.venv/bin/python elterndienstplaner.py eingabe.csv eltern.csv ausgabe.csv vorherige-ausgaben.csv diff --git a/vorherige-ausgaben.csv b/vorherige-ausgaben.csv new file mode 100644 index 0000000..9cf2bbe --- /dev/null +++ b/vorherige-ausgaben.csv @@ -0,0 +1,37 @@ +Datum,Wochentag,Frühstücksdienst,Putznotdienst,Essensausgabenotdienst,Kochen,Elternabend +2025-12-02,Montag,Paula,Erwin,Laura,, +2025-12-03,Dienstag,Laura,Ben & Nele,Paula,, +2025-12-04,Mittwoch,Erwin,Paula,Ben & Nele,, +2025-12-05,Donnerstag,Ben & Nele,Laura,Erwin,, +2025-12-06,Freitag,Jonas,Marie,Laura,, +2025-12-09,Montag,Laura,Ben & Nele,Jonas,, +2025-12-10,Dienstag,Marie,Paula,Ben & Nele,, +2025-12-11,Mittwoch,Ben & Nele,Laura,Erwin,Paula, +2025-12-12,Donnerstag,Paula,Jonas,Marie,, +2025-12-13,Freitag,Laura,Jonas,Paula,, +2025-12-16,Montag,Erwin,Marie,Ben & Nele,, +2025-12-17,Dienstag,Ben & Nele,Laura,Jonas,, +2025-12-18,Mittwoch,Marie,Erwin,Laura,, +2025-12-19,Donnerstag,Laura,Ben & Nele,Paula,, +2025-12-20,Freitag,Jonas,Paula,Ben & Nele,Ben & Nele,Laura Erwin +2025-11-01,Freitag,Laura,Erwin,Ben & Nele,, +2025-11-04,Montag,Paula,Ben & Nele,Laura,, +2025-11-05,Dienstag,Erwin,Paula,Ben & Nele,, +2025-11-06,Mittwoch,Ben & Nele,Laura,Erwin,Erwin, +2025-11-07,Donnerstag,Paula,Erwin,Laura,, +2025-11-08,Freitag,Laura,Ben & Nele,Paula,, +2025-11-11,Montag,Erwin,Paula,Ben & Nele,, +2025-11-12,Dienstag,Ben & Nele,Laura,Erwin,, +2025-11-13,Mittwoch,Paula,Erwin,Laura,, +2025-11-14,Donnerstag,Laura,Ben & Nele,Paula,, +2025-11-15,Freitag,Erwin,Paula,Ben & Nele,, +2025-11-18,Montag,Ben & Nele,Laura,Erwin,, +2025-11-19,Dienstag,Paula,Erwin,Laura,, +2025-11-20,Mittwoch,Laura,Ben & Nele,Paula,Paula, +2025-11-21,Donnerstag,Erwin,Paula,Ben & Nele,, +2025-11-22,Freitag,Ben & Nele,Laura,Erwin,, +2025-11-25,Montag,Paula,Erwin,Laura,, +2025-11-26,Dienstag,Laura,Ben & Nele,Paula,, +2025-11-27,Mittwoch,Erwin,Paula,Ben & Nele,, +2025-11-28,Donnerstag,Ben & Nele,Laura,Erwin,, +2025-11-29,Freitag,Paula,Erwin,Laura,Laura,Ben & Nele Erwin From b99f1762c306fe6fadfe1b9b013defea6a734d9c Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Mon, 22 Dec 2025 20:52:31 +0100 Subject: [PATCH 02/20] pseudonymisierte historische daten --- ausgabe.csv => test/simple/ausgabe.csv | 0 eingabe.csv => test/simple/eingabe.csv | 0 eltern.csv => test/simple/eltern.csv | 0 vorherige-ausgaben.csv => test/simple/vorherige-ausgaben.csv | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename ausgabe.csv => test/simple/ausgabe.csv (100%) rename eingabe.csv => test/simple/eingabe.csv (100%) rename eltern.csv => test/simple/eltern.csv (100%) rename vorherige-ausgaben.csv => test/simple/vorherige-ausgaben.csv (100%) diff --git a/ausgabe.csv b/test/simple/ausgabe.csv similarity index 100% rename from ausgabe.csv rename to test/simple/ausgabe.csv diff --git a/eingabe.csv b/test/simple/eingabe.csv similarity index 100% rename from eingabe.csv rename to test/simple/eingabe.csv diff --git a/eltern.csv b/test/simple/eltern.csv similarity index 100% rename from eltern.csv rename to test/simple/eltern.csv diff --git a/vorherige-ausgaben.csv b/test/simple/vorherige-ausgaben.csv similarity index 100% rename from vorherige-ausgaben.csv rename to test/simple/vorherige-ausgaben.csv From 182b7d1aff914fac0a74c15b9d5da17cd09feab2 Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Mon, 22 Dec 2025 21:31:25 +0100 Subject: [PATCH 03/20] pseudonymisierste Daten importiert doppelter Code aus berechne_faire_zielverteilung eliminiert Max 10 Sekunden Rechenzeit --- elterndienstplaner.py | 50 +++++++++++-------------------------------- elterndienstplaner.sh | 3 ++- 2 files changed, 15 insertions(+), 38 deletions(-) diff --git a/elterndienstplaner.py b/elterndienstplaner.py index 874f61b..0e1860b 100755 --- a/elterndienstplaner.py +++ b/elterndienstplaner.py @@ -214,38 +214,8 @@ class Elterndienstplaner: print("\nBerechne faire Zielverteilung basierend auf historischen Daten...") - # Wenn keine historischen Daten vorhanden, nur aktuellen Monat berechnen - if not self.historische_dienste: - print(" Keine historischen Daten - berechne nur für aktuellen Monat") - for dienst in self.dienste: - benoetigte_dienste_monat = sum( - 1 for tag in self.tage - if dienst in self.benoetigte_dienste.get(tag, []) - ) - - if dienst == 'A': - benoetigte_dienste_monat *= 2 - - if benoetigte_dienste_monat > 0: - gesamt_dienstfaktor_monat = sum( - sum(self.dienstfaktoren.get(e, {}).get(tag, 0) for tag in self.tage) - for e in self.eltern - ) - - if gesamt_dienstfaktor_monat > 0: - for eltern in self.eltern: - monatsfaktor = sum( - self.dienstfaktoren.get(eltern, {}).get(tag, 0) - for tag in self.tage - ) - if monatsfaktor > 0: - anteil = monatsfaktor / gesamt_dienstfaktor_monat - faire_zuteilung = anteil * benoetigte_dienste_monat - ziel_dienste[eltern][dienst] = faire_zuteilung - return ziel_dienste - # Historische Dienste nach Datum gruppieren - historische_tage = set(datum for datum, _, _ in self.historische_dienste) + historische_tage = set(datum for datum, _, _ in self.historische_dienste) if self.historische_dienste else set() print(f" Analysiere {len(historische_tage)} historische Tage mit {len(self.historische_dienste)} Diensten") for dienst in self.dienste: @@ -318,12 +288,15 @@ class Elterndienstplaner: # Debug-Ausgabe für diesen Dienst total_historisch = sum( - ziel_dienste[e][dienst] for e in self.eltern - if dienst in [d for _, _, d in self.historische_dienste if d == dienst] - ) - total_aktuell = benoetigte_dienste_monat + ziel_dienste[e][dienst] - ( + sum(self.dienstfaktoren.get(e, {}).get(tag, 0) for tag in self.tage) / + sum(sum(self.dienstfaktoren.get(e2, {}).get(tag, 0) for tag in self.tage) for e2 in self.eltern) * benoetigte_dienste_monat + if sum(sum(self.dienstfaktoren.get(e2, {}).get(tag, 0) for tag in self.tage) for e2 in self.eltern) > 0 else 0 + ) for e in self.eltern + ) if len(historische_dienste_dieses_typs) > 0 else 0 + print(f" {dienst}: Historisch faire Summe={total_historisch:.1f}, " - f"Aktuell benötigt={total_aktuell}") + f"Aktuell benötigt={benoetigte_dienste_monat}") # Debug-Output: Detaillierte Zielverteilung print("\n Berechnete Zielverteilung (basierend auf tatsächlichen historischen Diensten):") @@ -537,11 +510,14 @@ class Elterndienstplaner: # Solver wählen (verfügbare Solver testen) solver = None try: - solver = pulp.PULP_CBC_CMD(msg=0) # Standard CBC Solver + print("Versuche CBC Solver...") + solver = pulp.PULP_CBC_CMD(msg=0, timeLimit=10) # Standard CBC Solver except: try: + print("Versuche GLPK Solver...") solver = pulp.GLPK_CMD(msg=0) # GLPK falls verfügbar except: + print("Kein spezifizierter Solver verfügbar, verwende Standard.") solver = None # Default Solver prob.solve(solver) diff --git a/elterndienstplaner.sh b/elterndienstplaner.sh index af38121..7b36b36 100755 --- a/elterndienstplaner.sh +++ b/elterndienstplaner.sh @@ -1 +1,2 @@ -/home/jwit/privat/elterndienstplaner/.venv/bin/python elterndienstplaner.py eingabe.csv eltern.csv ausgabe.csv vorherige-ausgaben.csv +DIR=/home/jwit/privat/elterndienstplaner/ +$DIR/.venv/bin/python $DIR/elterndienstplaner.py eingabe.csv eltern.csv ausgabe.csv vorherige-ausgaben.csv From 2d3f49539cc4b80471ee4ef0c04b0b7c2c9973a7 Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Tue, 23 Dec 2025 21:44:05 +0100 Subject: [PATCH 04/20] refactoring: dienste in klasse --- elterndienstplaner.py | 114 +++++++++++++++++++++++++++++------------- 1 file changed, 79 insertions(+), 35 deletions(-) diff --git a/elterndienstplaner.py b/elterndienstplaner.py index 0e1860b..f57d7a6 100755 --- a/elterndienstplaner.py +++ b/elterndienstplaner.py @@ -13,21 +13,41 @@ from datetime import datetime, timedelta from collections import defaultdict import calendar + +class Dienst: + """Repräsentiert einen Diensttyp mit allen seinen Eigenschaften""" + + def __init__(self, kuerzel, name, personen_anzahl=1): + self.kuerzel = kuerzel + self.name = name + self.personen_anzahl = personen_anzahl + + def __str__(self): + return f"{self.kuerzel} ({self.name}): {self.personen_anzahl} Person(en)" + + def __repr__(self): + return f"Dienst('{self.kuerzel}', '{self.name}', {self.personen_anzahl})" + + def braucht_mehrere_personen(self): + """Gibt True zurück, wenn mehr als eine Person benötigt wird""" + return self.personen_anzahl > 1 + + class Elterndienstplaner: def __init__(self): - self.dienste = ['F', 'P', 'E', 'K', 'A'] # Frühstück, Putz, Essen, Kochen, Elternabend - self.dienst_namen = { - 'F': 'Frühstücksdienst', - 'P': 'Putznotdienst', - 'E': 'Essensausgabenotdienst', - 'K': 'Kochen', - 'A': 'Elternabend' - } + # Dienste als Liste definieren + self.dienste = [ + Dienst('F', 'Frühstücksdienst', 1), + Dienst('P', 'Putznotdienst', 1), + Dienst('E', 'Essensausgabenotdienst', 1), + Dienst('K', 'Kochen', 1), + Dienst('A', 'Elternabend', 2) + ] # Datenstrukturen self.tage = [] self.eltern = [] - self.benoetigte_dienste = {} # {datum: [dienste]} + self.benoetigte_dienste = {} # {datum: [dienst_objekte]} self.verfügbarkeit = {} # {(eltern, datum): bool} self.präferenzen = {} # {(eltern, datum, dienst): 1 (bevorzugt) oder -1 (abgelehnt)} self.dienstfaktoren = {} # {eltern: {datum: faktor}} @@ -35,6 +55,25 @@ class Elterndienstplaner: self.vorherige_dienste = defaultdict(lambda: defaultdict(int)) # {eltern: {dienst: anzahl}} self.historische_dienste = [] # [(datum, eltern, dienst), ...] - Alle historischen Dienste mit Datum + def get_dienst(self, kuerzel): + """Gibt das Dienst-Objekt für ein Kürzel zurück""" + for dienst in self.dienste: + if dienst.kuerzel == kuerzel: + return dienst + return None + + def add_dienst(self, kuerzel, name, personen_anzahl=1): + """Fügt einen neuen Dienst hinzu""" + dienst = Dienst(kuerzel, name, personen_anzahl) + self.dienste.append(dienst) + return dienst + + def print_dienste_info(self): + """Druckt Informationen über alle konfigurierten Dienste""" + print("Konfigurierte Dienste:") + for dienst in self.dienste: + print(f" {dienst}") + def lade_eingabe_csv(self, datei): """Lädt die eingabe.csv mit Terminen und Präferenzen""" print(f"Lade Eingabedaten aus {datei}...") @@ -64,7 +103,12 @@ class Elterndienstplaner: continue # Benötigte Dienste - self.benoetigte_dienste[datum_obj] = list(dienste_str) + dienst_objekte = [] + for kuerzel in dienste_str: + dienst = self.get_dienst(kuerzel) + if dienst: + dienst_objekte.append(dienst) + self.benoetigte_dienste[datum_obj] = dienst_objekte # Verfügbarkeit und Präferenzen der Eltern for i, eltern_name in enumerate(self.eltern): @@ -90,9 +134,9 @@ class Elterndienstplaner: """Parst Präferenzstring wie 'F+P-E+' """ i = 0 while i < len(präf_str): - if i + 1 < len(präf_str) and präf_str[i] in self.dienste: - dienst = präf_str[i] - if i + 1 < len(präf_str): + if i + 1 < len(präf_str): + dienst = self.get_dienst(präf_str[i]) + if dienst and i + 1 < len(präf_str): if präf_str[i + 1] == '+': self.präferenzen[(eltern, datum, dienst)] = 1 i += 2 @@ -163,9 +207,9 @@ class Elterndienstplaner: # Dienst-Spalten finden dienst_spalten = {} for i, col_name in enumerate(header[2:], 2): # Ab Spalte 2 (nach Datum, Wochentag) - for dienst_kürzel, dienst_name in self.dienst_namen.items(): - if dienst_name.lower() in col_name.lower() or dienst_kürzel == col_name: - dienst_spalten[dienst_kürzel] = i + for dienst in self.dienste: + if dienst.name.lower() in col_name.lower() or dienst.kuerzel == col_name: + dienst_spalten[dienst] = i break for row in reader: @@ -219,7 +263,7 @@ class Elterndienstplaner: print(f" Analysiere {len(historische_tage)} historische Tage mit {len(self.historische_dienste)} Diensten") for dienst in self.dienste: - print(f" Verarbeite Dienst {dienst}...") + print(f" Verarbeite Dienst {dienst.kuerzel}...") # 1. HISTORISCHE PERIODE: Faire Umverteilung der tatsächlich geleisteten Dienste historische_dienste_dieses_typs = [ @@ -227,7 +271,7 @@ class Elterndienstplaner: if d == dienst ] - print(f" Gefundene historische {dienst}-Dienste: {len(historische_dienste_dieses_typs)}") + print(f" Gefundene historische {dienst.kuerzel}-Dienste: {len(historische_dienste_dieses_typs)}") # Gruppiere nach Datum dienste_pro_tag = defaultdict(list) @@ -265,8 +309,8 @@ class Elterndienstplaner: if dienst in self.benoetigte_dienste.get(tag, []) ) - if dienst == 'A': - benoetigte_dienste_monat *= 2 + # Multipliziere mit Anzahl benötigter Personen pro Dienst + benoetigte_dienste_monat *= dienst.personen_anzahl if benoetigte_dienste_monat > 0: # Gesamtdienstfaktor für aktuellen Monat @@ -295,7 +339,7 @@ class Elterndienstplaner: ) for e in self.eltern ) if len(historische_dienste_dieses_typs) > 0 else 0 - print(f" {dienst}: Historisch faire Summe={total_historisch:.1f}, " + print(f" {dienst.kuerzel}: Historisch faire Summe={total_historisch:.1f}, " f"Aktuell benötigt={benoetigte_dienste_monat}") # Debug-Output: Detaillierte Zielverteilung @@ -305,7 +349,7 @@ class Elterndienstplaner: if ziel_dienste[eltern][dienst] > 0.1: # Nur relevante Werte ist = self.vorherige_dienste[eltern][dienst] ziel = ziel_dienste[eltern][dienst] - print(f" {eltern} {dienst}: IST={ist}, FAIRE_ZIEL={ziel:.2f}, DIFF={ziel-ist:.2f}") + print(f" {eltern} {dienst.kuerzel}: IST={ist}, FAIRE_ZIEL={ziel:.2f}, DIFF={ziel-ist:.2f}") return ziel_dienste @@ -330,7 +374,7 @@ class Elterndienstplaner: for dienst in self.dienste: if dienst in self.benoetigte_dienste.get(tag, []): x[eltern, tag, dienst] = pulp.LpVariable( - f"x_{eltern.replace(' ', '_')}_{tag}_{dienst}", + f"x_{eltern.replace(' ', '_')}_{tag}_{dienst.kuerzel}", cat='Binary' ) @@ -351,7 +395,7 @@ class Elterndienstplaner: woche_vars.append(x[eltern, tag, dienst]) if woche_vars: - prob += pulp.lpSum(woche_vars) <= 1, f"C1_{eltern.replace(' ', '_')}_{dienst}_w{woche_nr}" + prob += pulp.lpSum(woche_vars) <= 1, f"C1_{eltern.replace(' ', '_')}_{dienst.kuerzel}_w{woche_nr}" woche_start += timedelta(days=7) woche_nr += 1 @@ -373,7 +417,7 @@ class Elterndienstplaner: if not self.verfügbarkeit.get((eltern, tag), True): for dienst in self.dienste: if (eltern, tag, dienst) in x: - prob += x[eltern, tag, dienst] == 0, f"C3_{eltern.replace(' ', '_')}_{tag}_{dienst}" + prob += x[eltern, tag, dienst] == 0, f"C3_{eltern.replace(' ', '_')}_{tag}_{dienst.kuerzel}" # Alle benötigten Dienste müssen zugeteilt werden (flexibel) for tag in self.tage: @@ -388,9 +432,9 @@ class Elterndienstplaner: verfuegbare_eltern += 1 if dienst_vars: - # Genau 1 Person pro Dienst (außer Elternabend: genau 2) - benoetigte_personen = 2 if dienst == 'A' else 1 - prob += pulp.lpSum(dienst_vars) == benoetigte_personen, f"Bedarf_{tag}_{dienst}" + # Anzahl benötigter Personen pro Dienst (aus Dienst-Objekt) + benoetigte_personen = dienst.personen_anzahl + prob += pulp.lpSum(dienst_vars) == benoetigte_personen, f"Bedarf_{tag}_{dienst.kuerzel}" # FAIRNESS-CONSTRAINTS UND ZIELFUNKTION objective_terms = [] @@ -405,9 +449,9 @@ class Elterndienstplaner: for eltern in self.eltern: for dienst in self.dienste: fairness_abweichung_lokal[eltern, dienst] = pulp.LpVariable( - f"fair_lokal_{eltern.replace(' ', '_')}_{dienst}", lowBound=0) + f"fair_lokal_{eltern.replace(' ', '_')}_{dienst.kuerzel}", lowBound=0) fairness_abweichung_global[eltern, dienst] = pulp.LpVariable( - f"fair_global_{eltern.replace(' ', '_')}_{dienst}", lowBound=0) + f"fair_global_{eltern.replace(' ', '_')}_{dienst.kuerzel}", lowBound=0) # F1: Globale Fairness & F2: Lokale Fairness for eltern in self.eltern: @@ -430,8 +474,8 @@ class Elterndienstplaner: 1 for tag in self.tage if dienst in self.benoetigte_dienste.get(tag, []) ) - if dienst == 'A': - benoetigte_dienste_monat *= 2 + # Multipliziere mit Anzahl benötigter Personen pro Dienst + benoetigte_dienste_monat *= dienst.personen_anzahl gesamt_dienstfaktor_monat = sum( sum(self.dienstfaktoren.get(e, {}).get(tag, 0) for tag in self.tage) @@ -549,7 +593,7 @@ class Elterndienstplaner: writer = csv.writer(f) # Header schreiben - header = ['Datum', 'Wochentag'] + [self.dienst_namen[d] for d in self.dienste] + header = ['Datum', 'Wochentag'] + [dienst.name for dienst in self.dienste] writer.writerow(header) # Daten schreiben @@ -586,8 +630,8 @@ class Elterndienstplaner: print("\nDienste pro Eltern:") for eltern in sorted(self.eltern): gesamt = sum(dienste_pro_eltern[eltern].values()) - dienste_detail = ', '.join(f"{d}:{dienste_pro_eltern[eltern][d]}" - for d in self.dienste if dienste_pro_eltern[eltern][d] > 0) + dienste_detail = ', '.join(f"{dienst.kuerzel}:{dienste_pro_eltern[eltern][dienst]}" + for dienst in self.dienste if dienste_pro_eltern[eltern][dienst] > 0) print(f" {eltern:15} {gesamt:3d} ({dienste_detail})") # Dienstfaktor-Analyse From 03d1c362f1fbb8a3bdfb0577b39aed50ceca588b Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Tue, 23 Dec 2025 22:00:29 +0100 Subject: [PATCH 05/20] refactoring: type hints --- elterndienstplaner.py | 76 +++++++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/elterndienstplaner.py b/elterndienstplaner.py index f57d7a6..d65c47a 100755 --- a/elterndienstplaner.py +++ b/elterndienstplaner.py @@ -9,34 +9,35 @@ Datum: Dezember 2025 import sys import csv import pulp -from datetime import datetime, timedelta +from datetime import datetime, timedelta, date from collections import defaultdict +from typing import Dict, List, Tuple, DefaultDict, Optional import calendar class Dienst: """Repräsentiert einen Diensttyp mit allen seinen Eigenschaften""" - def __init__(self, kuerzel, name, personen_anzahl=1): - self.kuerzel = kuerzel - self.name = name - self.personen_anzahl = personen_anzahl + def __init__(self, kuerzel: str, name: str, personen_anzahl: int = 1) -> None: + self.kuerzel: str = kuerzel + self.name: str = name + self.personen_anzahl: int = personen_anzahl - def __str__(self): + def __str__(self) -> str: return f"{self.kuerzel} ({self.name}): {self.personen_anzahl} Person(en)" - def __repr__(self): + def __repr__(self) -> str: return f"Dienst('{self.kuerzel}', '{self.name}', {self.personen_anzahl})" - def braucht_mehrere_personen(self): + def braucht_mehrere_personen(self) -> bool: """Gibt True zurück, wenn mehr als eine Person benötigt wird""" return self.personen_anzahl > 1 class Elterndienstplaner: - def __init__(self): + def __init__(self) -> None: # Dienste als Liste definieren - self.dienste = [ + self.dienste: List[Dienst] = [ Dienst('F', 'Frühstücksdienst', 1), Dienst('P', 'Putznotdienst', 1), Dienst('E', 'Essensausgabenotdienst', 1), @@ -45,36 +46,37 @@ class Elterndienstplaner: ] # Datenstrukturen - self.tage = [] - self.eltern = [] - self.benoetigte_dienste = {} # {datum: [dienst_objekte]} - self.verfügbarkeit = {} # {(eltern, datum): bool} - self.präferenzen = {} # {(eltern, datum, dienst): 1 (bevorzugt) oder -1 (abgelehnt)} - self.dienstfaktoren = {} # {eltern: {datum: faktor}} - self.alle_zeitraeume = {} # {eltern: [(beginn, ende, faktor), ...]} - ALLE Zeiträume - self.vorherige_dienste = defaultdict(lambda: defaultdict(int)) # {eltern: {dienst: anzahl}} - self.historische_dienste = [] # [(datum, eltern, dienst), ...] - Alle historischen Dienste mit Datum + self.tage: List[date] = [] + self.eltern: List[str] = [] + self.benoetigte_dienste: Dict[date, List[Dienst]] = {} + self.verfügbarkeit: Dict[Tuple[str, date], bool] = {} + self.präferenzen: Dict[Tuple[str, date, Dienst], int] = {} + self.dienstfaktoren: Dict[str, Dict[date, float]] = {} + self.alle_zeitraeume: Dict[str, List[Tuple[date, date, float]]] = {} + self.vorherige_dienste: DefaultDict[str, DefaultDict[Dienst, int]] = \ + defaultdict(lambda: defaultdict(int)) + self.historische_dienste: List[Tuple[date, str, Dienst]] = [] - def get_dienst(self, kuerzel): + def get_dienst(self, kuerzel: str) -> Optional[Dienst]: """Gibt das Dienst-Objekt für ein Kürzel zurück""" for dienst in self.dienste: if dienst.kuerzel == kuerzel: return dienst return None - def add_dienst(self, kuerzel, name, personen_anzahl=1): + def add_dienst(self, kuerzel: str, name: str, personen_anzahl: int = 1) -> Dienst: """Fügt einen neuen Dienst hinzu""" dienst = Dienst(kuerzel, name, personen_anzahl) self.dienste.append(dienst) return dienst - def print_dienste_info(self): + def print_dienste_info(self) -> None: """Druckt Informationen über alle konfigurierten Dienste""" print("Konfigurierte Dienste:") for dienst in self.dienste: print(f" {dienst}") - def lade_eingabe_csv(self, datei): + def lade_eingabe_csv(self, datei: str) -> None: """Lädt die eingabe.csv mit Terminen und Präferenzen""" print(f"Lade Eingabedaten aus {datei}...") @@ -130,7 +132,7 @@ class Elterndienstplaner: self.tage.sort() print(f"Zeitraum: {self.tage[0]} bis {self.tage[-1]} ({len(self.tage)} Tage)") - def _parse_präferenzen(self, eltern, datum, präf_str): + def _parse_präferenzen(self, eltern: str, datum: date, präf_str: str) -> None: """Parst Präferenzstring wie 'F+P-E+' """ i = 0 while i < len(präf_str): @@ -150,7 +152,7 @@ class Elterndienstplaner: else: i += 1 - def lade_eltern_csv(self, datei): + def lade_eltern_csv(self, datei: str) -> None: """Lädt die eltern.csv mit Dienstfaktoren""" print(f"Lade Elterndaten aus {datei}...") @@ -195,7 +197,7 @@ class Elterndienstplaner: print(f"Dienstfaktoren geladen für {len(self.dienstfaktoren)} Eltern") print(f"Zeiträume gespeichert für globale Fairness-Berechnung") - def lade_vorherige_ausgaben_csv(self, datei): + def lade_vorherige_ausgaben_csv(self, datei: str) -> None: """Lädt vorherige-ausgaben.csv für Fairness-Constraints""" print(f"Lade vorherige Ausgaben aus {datei}...") @@ -240,7 +242,7 @@ class Elterndienstplaner: print(f"Vorherige Dienste geladen: {dict(self.vorherige_dienste)}") print(f"Historische Dienste mit Datum: {len(self.historische_dienste)} Einträge") - def berechne_dienstfaktor_an_datum(self, eltern, datum): + def berechne_dienstfaktor_an_datum(self, eltern: str, datum: date) -> float: """Berechnet den Dienstfaktor eines Elternteils an einem bestimmten Datum""" if eltern not in self.alle_zeitraeume: return 0 @@ -250,11 +252,12 @@ class Elterndienstplaner: return faktor return 0 - def berechne_faire_zielverteilung(self): + def berechne_faire_zielverteilung(self) -> DefaultDict[str, DefaultDict[Dienst, float]]: """Berechnet die faire Zielanzahl von Diensten pro Eltern-Dienst-Kombination basierend auf tatsächlich geleisteten historischen Diensten und deren fairer Umverteilung""" - ziel_dienste = defaultdict(lambda: defaultdict(float)) # {eltern: {dienst: anzahl}} + ziel_dienste: DefaultDict[str, DefaultDict[Dienst, float]] = \ + defaultdict(lambda: defaultdict(float)) print("\nBerechne faire Zielverteilung basierend auf historischen Daten...") @@ -353,7 +356,7 @@ class Elterndienstplaner: return ziel_dienste - def erstelle_optimierungsmodell(self): + def erstelle_optimierungsmodell(self) -> Tuple[pulp.LpProblem, Dict[Tuple[str, date, Dienst], pulp.LpVariable]]: """Erstellt das PuLP Optimierungsmodell""" print("Erstelle Optimierungsmodell...") @@ -368,7 +371,7 @@ class Elterndienstplaner: prob = pulp.LpProblem("Elterndienstplaner", pulp.LpMinimize) # Entscheidungsvariablen: x[eltern, tag, dienst] ∈ {0,1} - x = {} + x: Dict[Tuple[str, date, Dienst], pulp.LpVariable] = {} for eltern in self.eltern: for tag in self.tage: for dienst in self.dienste: @@ -547,7 +550,8 @@ class Elterndienstplaner: print(f"Modell erstellt mit {len(x)} Variablen und {len(prob.constraints)} Constraints") return prob, x - def löse_optimierung(self, prob, x): + def löse_optimierung(self, prob: pulp.LpProblem, + x: Dict[Tuple[str, date, Dienst], pulp.LpVariable]) -> Optional[Dict[date, Dict[Dienst, List[str]]]]: """Löst das Optimierungsproblem""" print("Löse Optimierungsproblem...") @@ -574,7 +578,7 @@ class Elterndienstplaner: return None # Lösung extrahieren - lösung = {} + lösung: Dict[date, Dict[Dienst, List[str]]] = {} for (eltern, tag, dienst), var in x.items(): if var.varValue and var.varValue > 0.5: # Binary variable ist 1 if tag not in lösung: @@ -585,7 +589,7 @@ class Elterndienstplaner: return lösung - def schreibe_ausgabe_csv(self, datei, lösung): + def schreibe_ausgabe_csv(self, datei: str, lösung: Dict[date, Dict[Dienst, List[str]]]) -> None: """Schreibt die Lösung in die ausgabe.csv""" print(f"Schreibe Ergebnisse nach {datei}...") @@ -613,7 +617,7 @@ class Elterndienstplaner: print("Ausgabe erfolgreich geschrieben!") - def drucke_statistiken(self, lösung): + def drucke_statistiken(self, lösung: Dict[date, Dict[Dienst, List[str]]]) -> None: """Druckt Statistiken zur Lösung""" print("\n" + "="*50) print("STATISTIKEN") @@ -641,7 +645,7 @@ class Elterndienstplaner: print(f" {eltern:15} {faktor_summe:.1f}") -def main(): +def main() -> None: if len(sys.argv) < 4: print("Usage: ./elterndienstplaner.py []") sys.exit(1) From 9588e75ee0dbd880519690fe62be8f72b9b9b2bd Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Tue, 23 Dec 2025 22:14:17 +0100 Subject: [PATCH 06/20] refactoring: lokale fairness --- elterndienstplaner.py | 125 ++++++++++++++++++++++++++---------------- 1 file changed, 77 insertions(+), 48 deletions(-) diff --git a/elterndienstplaner.py b/elterndienstplaner.py index d65c47a..d72d489 100755 --- a/elterndienstplaner.py +++ b/elterndienstplaner.py @@ -252,7 +252,7 @@ class Elterndienstplaner: return faktor return 0 - def berechne_faire_zielverteilung(self) -> DefaultDict[str, DefaultDict[Dienst, float]]: + def berechne_faire_zielverteilung_global(self) -> DefaultDict[str, DefaultDict[Dienst, float]]: """Berechnet die faire Zielanzahl von Diensten pro Eltern-Dienst-Kombination basierend auf tatsächlich geleisteten historischen Diensten und deren fairer Umverteilung""" @@ -356,6 +356,55 @@ class Elterndienstplaner: return ziel_dienste + def berechne_faire_zielverteilung_lokal(self) -> DefaultDict[str, DefaultDict[Dienst, float]]: + """Berechnet die lokale faire Zielanzahl von Diensten pro Eltern-Dienst-Kombination + basierend auf Dienstfaktoren und benötigten Diensten im aktuellen Planungsmonat""" + + ziel_dienste_lokal: DefaultDict[str, DefaultDict[Dienst, float]] = \ + defaultdict(lambda: defaultdict(float)) + + print("\nBerechne lokale faire Zielverteilung für aktuellen Monat...") + + # Gesamtdienstfaktor für aktuellen Monat berechnen + gesamt_dienstfaktor_monat = sum( + sum(self.dienstfaktoren.get(e, {}).get(tag, 0) for tag in self.tage) + for e in self.eltern + ) + + if gesamt_dienstfaktor_monat == 0: + print(" WARNUNG: Gesamtdienstfaktor ist 0, keine lokale Zielverteilung möglich") + return ziel_dienste_lokal + + # Für jeden Dienst die lokale faire Verteilung berechnen + for dienst in self.dienste: + # Anzahl benötigter Dienste im aktuellen Monat + benoetigte_dienste_monat = sum( + 1 for tag in self.tage + if dienst in self.benoetigte_dienste.get(tag, []) + ) + # Multipliziere mit Anzahl benötigter Personen pro Dienst + benoetigte_dienste_monat *= dienst.personen_anzahl + + if benoetigte_dienste_monat > 0: + print(f" {dienst.kuerzel}: {benoetigte_dienste_monat} Dienste benötigt") + + for eltern in self.eltern: + # Dienstfaktor für diesen Elternteil im aktuellen Monat + monatlicher_dienstfaktor = sum( + self.dienstfaktoren.get(eltern, {}).get(tag, 0) for tag in self.tage + ) + + if monatlicher_dienstfaktor > 0: + anteil = monatlicher_dienstfaktor / gesamt_dienstfaktor_monat + faire_zuteilung = anteil * benoetigte_dienste_monat + ziel_dienste_lokal[eltern][dienst] = faire_zuteilung + + if faire_zuteilung > 0.1: # Debug nur für relevante Werte + print(f" {eltern}: Faktor={monatlicher_dienstfaktor:.1f} " + f"-> {faire_zuteilung:.2f} Dienste") + + return ziel_dienste_lokal + def erstelle_optimierungsmodell(self) -> Tuple[pulp.LpProblem, Dict[Tuple[str, date, Dienst], pulp.LpVariable]]: """Erstellt das PuLP Optimierungsmodell""" print("Erstelle Optimierungsmodell...") @@ -442,8 +491,9 @@ class Elterndienstplaner: # FAIRNESS-CONSTRAINTS UND ZIELFUNKTION objective_terms = [] - # Berechne faire Zielverteilung - ziel_dienste = self.berechne_faire_zielverteilung() + # Berechne faire Zielverteilungen (global und lokal) + ziel_dienste_global = self.berechne_faire_zielverteilung_global() + ziel_dienste_lokal = self.berechne_faire_zielverteilung_lokal() # Hilfsvariablen für Fairness-Abweichungen fairness_abweichung_lokal = {} # F2 @@ -459,56 +509,35 @@ class Elterndienstplaner: # F1: Globale Fairness & F2: Lokale Fairness for eltern in self.eltern: for dienst in self.dienste: - # Dienstfaktor für aktuellen Monat - monatlicher_dienstfaktor = sum( - self.dienstfaktoren.get(eltern, {}).get(tag, 0) for tag in self.tage + # Tatsächliche Dienste im aktuellen Monat + tatsaechliche_dienste_monat = pulp.lpSum( + x[eltern, tag, dienst] + for tag in self.tage + if (eltern, tag, dienst) in x ) - if monatlicher_dienstfaktor > 0: - # Tatsächliche Dienste im aktuellen Monat - tatsaechliche_dienste_monat = pulp.lpSum( - x[eltern, tag, dienst] - for tag in self.tage - if (eltern, tag, dienst) in x - ) + # F2: Lokale Fairness - nur aktueller Monat + ziel_lokal = ziel_dienste_lokal[eltern][dienst] + if ziel_lokal > 0: + # Lokale Fairness-Constraints + prob += (tatsaechliche_dienste_monat - ziel_lokal <= + fairness_abweichung_lokal[eltern, dienst]) + prob += (ziel_lokal - tatsaechliche_dienste_monat <= + fairness_abweichung_lokal[eltern, dienst]) - # F2: Lokale Fairness - nur aktueller Monat - benoetigte_dienste_monat = sum( - 1 for tag in self.tage - if dienst in self.benoetigte_dienste.get(tag, []) - ) - # Multipliziere mit Anzahl benötigter Personen pro Dienst - benoetigte_dienste_monat *= dienst.personen_anzahl + # F1: Globale Fairness - basierend auf berechneter Zielverteilung + ziel_global = ziel_dienste_global[eltern][dienst] + vorherige_dienste = self.vorherige_dienste[eltern][dienst] - gesamt_dienstfaktor_monat = sum( - sum(self.dienstfaktoren.get(e, {}).get(tag, 0) for tag in self.tage) - for e in self.eltern - ) + if ziel_global > 0: + # Tatsächliche Dienste global (Vergangenheit + geplant) + total_dienste_inkl_vergangenheit = tatsaechliche_dienste_monat + vorherige_dienste - if gesamt_dienstfaktor_monat > 0 and benoetigte_dienste_monat > 0: - erwartete_dienste_lokal = ( - monatlicher_dienstfaktor / gesamt_dienstfaktor_monat - ) * benoetigte_dienste_monat - - # F2: Lokale Fairness-Constraints - prob += (tatsaechliche_dienste_monat - erwartete_dienste_lokal <= - fairness_abweichung_lokal[eltern, dienst]) - prob += (erwartete_dienste_lokal - tatsaechliche_dienste_monat <= - fairness_abweichung_lokal[eltern, dienst]) - - # F1: Globale Fairness - basierend auf berechneter Zielverteilung - ziel_gesamt = ziel_dienste[eltern][dienst] - vorherige_dienste = self.vorherige_dienste[eltern][dienst] - - if ziel_gesamt > 0: - # Tatsächliche Dienste global (Vergangenheit + geplant) - total_dienste_inkl_vergangenheit = tatsaechliche_dienste_monat + vorherige_dienste - - # F1: Globale Fairness-Constraints - prob += (total_dienste_inkl_vergangenheit - ziel_gesamt <= - fairness_abweichung_global[eltern, dienst]) - prob += (ziel_gesamt - total_dienste_inkl_vergangenheit <= - fairness_abweichung_global[eltern, dienst]) + # Globale Fairness-Constraints + prob += (total_dienste_inkl_vergangenheit - ziel_global <= + fairness_abweichung_global[eltern, dienst]) + prob += (ziel_global - total_dienste_inkl_vergangenheit <= + fairness_abweichung_global[eltern, dienst]) # Gewichtung: Jahresanfang F1 stärker, Jahresende F2 stärker # Annahme: September = Jahresanfang, Juli = Jahresende From c2f12dcce95460432809816a7dd2a91896aa3190 Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Tue, 23 Dec 2025 22:28:03 +0100 Subject: [PATCH 07/20] refactoring: io ausgegliedert --- STRUKTUR.md | 98 ++++++++++++++ csv_io.py | 304 ++++++++++++++++++++++++++++++++++++++++++ elterndienstplaner.py | 195 ++------------------------- 3 files changed, 412 insertions(+), 185 deletions(-) create mode 100644 STRUKTUR.md create mode 100644 csv_io.py diff --git a/STRUKTUR.md b/STRUKTUR.md new file mode 100644 index 0000000..ad7db20 --- /dev/null +++ b/STRUKTUR.md @@ -0,0 +1,98 @@ +# Projektstruktur Elterndienstplaner + +## Dateien + +### `csv_io.py` - CSV Input/Output Module +**Zweck**: Trennung von Datei-I/O und Business-Logik + +**Klassen**: +- `EingabeParser`: Parst alle CSV-Eingabedateien + - `parse_eingabe_csv()`: Lädt eingabe.csv mit Terminen und Präferenzen + - `parse_eltern_csv()`: Lädt eltern.csv mit Dienstfaktoren + - `parse_vorherige_ausgaben_csv()`: Lädt vorherige-ausgaben.csv für Fairness + +- `AusgabeWriter`: Schreibt Ergebnisse in CSV + - `schreibe_ausgabe_csv()`: Schreibt Lösung in ausgabe.csv + +**Vorteile**: +- ✅ Testbar: CSV-Parsing kann isoliert getestet werden +- ✅ Wiederverwendbar: Andere Formate (JSON, Excel) leicht hinzufügbar +- ✅ Klare Verantwortlichkeiten: I/O getrennt von Optimierung + +### `elterndienstplaner.py` - Hauptprogramm +**Zweck**: Business-Logik und Optimierung + +**Klassen**: +- `Dienst`: Datenmodell für Diensttypen +- `Elterndienstplaner`: Hauptklasse mit Optimierungslogik + - Fairness-Berechnungen (global/lokal) + - Optimierungsmodell-Erstellung + - Statistiken + +**Abhängigkeiten**: +- Importiert `csv_io` für Datei-Operationen +- Verwendet `pulp` für lineare Optimierung + +## Verbesserungen durch Refactoring + +### Vorher (Monolith) +``` +elterndienstplaner.py (700+ Zeilen) +├── Dienst Klasse +├── CSV Parsing (150+ Zeilen) +├── Fairness-Berechnung +├── Optimierung +└── CSV Schreiben +``` + +### Nachher (Modular) +``` +csv_io.py (220 Zeilen) +├── EingabeParser +└── AusgabeWriter + +elterndienstplaner.py (500 Zeilen) +├── Dienst Klasse +├── Fairness-Berechnung +├── Optimierung +└── Statistiken +``` + +## Nächste Schritte (Optional) + +### Phase 2: Constraint-Funktionen auslagern +```python +def _add_constraint_ein_dienst_pro_woche(prob, x, ...): + """C1: Je Eltern und Dienst nur einmal die Woche""" + +def _add_constraint_ein_dienst_pro_tag(prob, x, ...): + """C2: Je Eltern nur einen Dienst am Tag""" +``` + +### Phase 3: Fairness-Modul (optional) +``` +fairness.py +├── FairnessBerechner +│ ├── berechne_global() +│ └── berechne_lokal() +``` + +## Verwendung + +```python +from csv_io import EingabeParser, AusgabeWriter + +# Daten laden +eltern, tage, dienste, ... = EingabeParser.parse_eingabe_csv("eingabe.csv", lookup_fn) + +# Ergebnis schreiben +AusgabeWriter.schreibe_ausgabe_csv("ausgabe.csv", lösung, tage, dienste) +``` + +## Vorteile der aktuellen Struktur + +1. **Separation of Concerns**: I/O getrennt von Business-Logik +2. **Testbarkeit**: Module können unabhängig getestet werden +3. **Wartbarkeit**: Änderungen an CSV-Format betreffen nur `csv_io.py` +4. **Erweiterbarkeit**: Neue Dateiformate können leicht hinzugefügt werden +5. **Lesbarkeit**: Kürzere, fokussiertere Dateien diff --git a/csv_io.py b/csv_io.py new file mode 100644 index 0000000..f02e35f --- /dev/null +++ b/csv_io.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +""" +CSV I/O Module für Elterndienstplaner +Trennt CSV-Parsing und -Schreiben von der Business-Logik +""" + +import csv +from datetime import datetime, date +from typing import Dict, List, Tuple, DefaultDict +from collections import defaultdict + + +class EingabeParser: + """Parst CSV-Eingabedateien für den Elterndienstplaner""" + + @staticmethod + def parse_eingabe_csv(datei: str, dienste_lookup) -> Tuple[ + List[str], # eltern + List[date], # tage + Dict[date, List], # benoetigte_dienste (Dienst-Objekte) + Dict[Tuple[str, date], bool], # verfügbarkeit + Dict[Tuple[str, date, any], int] # präferenzen (Dienst-Objekt als Key) + ]: + """ + Lädt die eingabe.csv mit Terminen und Präferenzen + + Args: + datei: Pfad zur eingabe.csv + dienste_lookup: Funktion (str) -> Dienst zum Auflösen von Kürzeln + + Returns: + Tuple mit (eltern, tage, benoetigte_dienste, verfügbarkeit, präferenzen) + """ + print(f"Lade Eingabedaten aus {datei}...") + + eltern = [] + tage = [] + benoetigte_dienste = {} + verfügbarkeit = {} + präferenzen = {} + + with open(datei, 'r', encoding='utf-8') as f: + reader = csv.reader(f) + header = next(reader) + + # Eltern aus Header extrahieren (ab Spalte 3) + eltern = [name.strip() for name in header[3:] if name.strip()] + print(f"Gefundene Eltern: {eltern}") + + for row in reader: + if len(row) < 3: + continue + + datum = row[0].strip() + wochentag = row[1].strip() + dienste_str = row[2].strip() + + # Datum parsen + try: + datum_obj = datetime.strptime(datum, '%Y-%m-%d').date() + tage.append(datum_obj) + except ValueError: + print(f"Warnung: Ungültiges Datum {datum}") + continue + + # Benötigte Dienste + dienst_objekte = [] + for kuerzel in dienste_str: + dienst = dienste_lookup(kuerzel) + if dienst: + dienst_objekte.append(dienst) + benoetigte_dienste[datum_obj] = dienst_objekte + + # Verfügbarkeit und Präferenzen der Eltern + for i, eltern_name in enumerate(eltern): + if i + 3 < len(row): + präf_str = row[i + 3].strip() + + # Verfügbarkeit prüfen + if präf_str == 'x': + verfügbarkeit[(eltern_name, datum_obj)] = False + else: + verfügbarkeit[(eltern_name, datum_obj)] = True + + # Präferenzen parsen + _parse_präferenzen_string( + eltern_name, datum_obj, präf_str, + präferenzen, dienste_lookup + ) + else: + # Standard: verfügbar, keine Präferenzen + verfügbarkeit[(eltern_name, datum_obj)] = True + + tage.sort() + print(f"Zeitraum: {tage[0]} bis {tage[-1]} ({len(tage)} Tage)") + + return eltern, tage, benoetigte_dienste, verfügbarkeit, präferenzen + + @staticmethod + def parse_eltern_csv(datei: str, tage: List[date]) -> Tuple[ + Dict[str, Dict[date, float]], # dienstfaktoren + Dict[str, List[Tuple[date, date, float]]] # alle_zeitraeume + ]: + """ + Lädt die eltern.csv mit Dienstfaktoren + + Args: + datei: Pfad zur eltern.csv + tage: Liste der Planungstage + + Returns: + Tuple mit (dienstfaktoren, alle_zeitraeume) + """ + print(f"Lade Elterndaten aus {datei}...") + + dienstfaktoren = {} + alle_zeitraeume = {} + + with open(datei, 'r', encoding='utf-8') as f: + reader = csv.reader(f) + header = next(reader) + + for row in reader: + if len(row) < 4: + continue + + eltern_name = row[0].strip() + + # Initialisiere Datenstrukturen + dienstfaktoren[eltern_name] = {} + alle_zeitraeume[eltern_name] = [] + + # Alle Zeiträume einlesen (jeweils 3 Spalten: Beginn, Ende, Faktor) + for i in range(1, len(row), 3): + if i + 2 < len(row) and row[i].strip() and row[i + 1].strip(): + try: + beginn = datetime.strptime(row[i].strip(), '%Y-%m-%d').date() + ende = datetime.strptime(row[i + 1].strip(), '%Y-%m-%d').date() + faktor = float(row[i + 2].strip()) if row[i + 2].strip() else 0 + + # Zeitraum speichern + alle_zeitraeume[eltern_name].append((beginn, ende, faktor)) + + # Faktor für Tage im aktuellen Planungsmonat setzen + for tag in tage: + if beginn <= tag <= ende: + dienstfaktoren[eltern_name][tag] = faktor + + except (ValueError, IndexError): + continue + + # Tage ohne expliziten Faktor auf 0 setzen + for tag in tage: + if tag not in dienstfaktoren[eltern_name]: + dienstfaktoren[eltern_name][tag] = 0 + + print(f"Dienstfaktoren geladen für {len(dienstfaktoren)} Eltern") + print(f"Zeiträume gespeichert für globale Fairness-Berechnung") + + return dienstfaktoren, alle_zeitraeume + + @staticmethod + def parse_vorherige_ausgaben_csv( + datei: str, + eltern: List[str], + dienste: List, # List[Dienst] + ) -> Tuple[ + DefaultDict[str, DefaultDict], # vorherige_dienste + List[Tuple[date, str, any]] # historische_dienste (mit Dienst-Objekt) + ]: + """ + Lädt vorherige-ausgaben.csv für Fairness-Constraints + + Args: + datei: Pfad zur vorherige-ausgaben.csv + eltern: Liste der Elternnamen + dienste: Liste der Dienst-Objekte + + Returns: + Tuple mit (vorherige_dienste, historische_dienste) + """ + print(f"Lade vorherige Ausgaben aus {datei}...") + + vorherige_dienste = defaultdict(lambda: defaultdict(int)) + historische_dienste = [] + + try: + with open(datei, 'r', encoding='utf-8') as f: + reader = csv.reader(f) + header = next(reader) + + # Dienst-Spalten finden + dienst_spalten = {} + for i, col_name in enumerate(header[2:], 2): # Ab Spalte 2 (nach Datum, Wochentag) + for dienst in dienste: + if dienst.name.lower() in col_name.lower() or dienst.kuerzel == col_name: + dienst_spalten[dienst] = i + break + + for row in reader: + if len(row) < 3: + continue + + # Datum parsen + try: + datum = datetime.strptime(row[0].strip(), '%Y-%m-%d').date() + except ValueError: + continue + + # Zugeteilte Dienste zählen UND mit Datum speichern + for dienst, spalte_idx in dienst_spalten.items(): + if spalte_idx < len(row) and row[spalte_idx].strip(): + # Mehrere Eltern können in einer Zelle stehen (durch Leerzeichen getrennt) + eltern_liste = row[spalte_idx].strip().split() + for eltern_name in eltern_liste: + if eltern_name in eltern: + # Summierung für Kompatibilität + vorherige_dienste[eltern_name][dienst] += 1 + # Historische Dienste mit Datum speichern + historische_dienste.append((datum, eltern_name, dienst)) + + except FileNotFoundError: + print("Keine vorherigen Ausgaben gefunden - starte ohne historische Daten") + + print(f"Vorherige Dienste geladen: {dict(vorherige_dienste)}") + print(f"Historische Dienste mit Datum: {len(historische_dienste)} Einträge") + + return vorherige_dienste, historische_dienste + + +class AusgabeWriter: + """Schreibt Optimierungsergebnisse in CSV-Dateien""" + + @staticmethod + def schreibe_ausgabe_csv( + datei: str, + lösung: Dict[date, Dict[any, List[str]]], # Dienst-Objekt als Key + tage: List[date], + dienste: List # List[Dienst] + ) -> None: + """ + Schreibt die Lösung in die ausgabe.csv + + Args: + datei: Pfad zur ausgabe.csv + lösung: Dictionary mit Zuteilungen {datum: {dienst: [eltern]}} + tage: Liste aller Planungstage + dienste: Liste der Dienst-Objekte + """ + print(f"Schreibe Ergebnisse nach {datei}...") + + with open(datei, 'w', newline='', encoding='utf-8') as f: + writer = csv.writer(f) + + # Header schreiben + header = ['Datum', 'Wochentag'] + [dienst.name for dienst in dienste] + writer.writerow(header) + + # Daten schreiben + for tag in sorted(tage): + wochentag = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', + 'Freitag', 'Samstag', 'Sonntag'][tag.weekday()] + + row = [tag.strftime('%Y-%m-%d'), wochentag] + + for dienst in dienste: + if tag in lösung and dienst in lösung[tag]: + eltern_str = ' '.join(lösung[tag][dienst]) + else: + eltern_str = '' + row.append(eltern_str) + + writer.writerow(row) + + print("Ausgabe erfolgreich geschrieben!") + + +# Hilfsfunktionen + +def _parse_präferenzen_string( + eltern: str, + datum: date, + präf_str: str, + präferenzen: Dict, + dienste_lookup +) -> None: + """Parst Präferenzstring wie 'F+P-E+' und fügt zu präferenzen hinzu""" + i = 0 + while i < len(präf_str): + if i + 1 < len(präf_str): + dienst = dienste_lookup(präf_str[i]) + if dienst and i + 1 < len(präf_str): + if präf_str[i + 1] == '+': + präferenzen[(eltern, datum, dienst)] = 1 + i += 2 + elif präf_str[i + 1] == '-': + präferenzen[(eltern, datum, dienst)] = -1 + i += 2 + else: + i += 1 + else: + i += 1 + else: + i += 1 diff --git a/elterndienstplaner.py b/elterndienstplaner.py index d72d489..3e0ffda 100755 --- a/elterndienstplaner.py +++ b/elterndienstplaner.py @@ -7,12 +7,12 @@ Datum: Dezember 2025 """ import sys -import csv import pulp -from datetime import datetime, timedelta, date +from datetime import timedelta, date from collections import defaultdict from typing import Dict, List, Tuple, DefaultDict, Optional -import calendar + +from csv_io import EingabeParser, AusgabeWriter class Dienst: @@ -78,169 +78,18 @@ class Elterndienstplaner: def lade_eingabe_csv(self, datei: str) -> None: """Lädt die eingabe.csv mit Terminen und Präferenzen""" - print(f"Lade Eingabedaten aus {datei}...") - - with open(datei, 'r', encoding='utf-8') as f: - reader = csv.reader(f) - header = next(reader) - - # Eltern aus Header extrahieren (ab Spalte 3) - self.eltern = [name.strip() for name in header[3:] if name.strip()] - print(f"Gefundene Eltern: {self.eltern}") - - for row in reader: - if len(row) < 3: - continue - - datum = row[0].strip() - wochentag = row[1].strip() - dienste_str = row[2].strip() - - # Datum parsen - try: - datum_obj = datetime.strptime(datum, '%Y-%m-%d').date() - self.tage.append(datum_obj) - except ValueError: - print(f"Warnung: Ungültiges Datum {datum}") - continue - - # Benötigte Dienste - dienst_objekte = [] - for kuerzel in dienste_str: - dienst = self.get_dienst(kuerzel) - if dienst: - dienst_objekte.append(dienst) - self.benoetigte_dienste[datum_obj] = dienst_objekte - - # Verfügbarkeit und Präferenzen der Eltern - for i, eltern_name in enumerate(self.eltern): - if i + 3 < len(row): - präf_str = row[i + 3].strip() - - # Verfügbarkeit prüfen - if präf_str == 'x': - self.verfügbarkeit[(eltern_name, datum_obj)] = False - else: - self.verfügbarkeit[(eltern_name, datum_obj)] = True - - # Präferenzen parsen - self._parse_präferenzen(eltern_name, datum_obj, präf_str) - else: - # Standard: verfügbar, keine Präferenzen - self.verfügbarkeit[(eltern_name, datum_obj)] = True - - self.tage.sort() - print(f"Zeitraum: {self.tage[0]} bis {self.tage[-1]} ({len(self.tage)} Tage)") - - def _parse_präferenzen(self, eltern: str, datum: date, präf_str: str) -> None: - """Parst Präferenzstring wie 'F+P-E+' """ - i = 0 - while i < len(präf_str): - if i + 1 < len(präf_str): - dienst = self.get_dienst(präf_str[i]) - if dienst and i + 1 < len(präf_str): - if präf_str[i + 1] == '+': - self.präferenzen[(eltern, datum, dienst)] = 1 - i += 2 - elif präf_str[i + 1] == '-': - self.präferenzen[(eltern, datum, dienst)] = -1 - i += 2 - else: - i += 1 - else: - i += 1 - else: - i += 1 + self.eltern, self.tage, self.benoetigte_dienste, self.verfügbarkeit, self.präferenzen = \ + EingabeParser.parse_eingabe_csv(datei, self.get_dienst) def lade_eltern_csv(self, datei: str) -> None: """Lädt die eltern.csv mit Dienstfaktoren""" - print(f"Lade Elterndaten aus {datei}...") - - with open(datei, 'r', encoding='utf-8') as f: - reader = csv.reader(f) - header = next(reader) - - for row in reader: - if len(row) < 4: - continue - - eltern_name = row[0].strip() - - # Initialisiere Datenstrukturen - self.dienstfaktoren[eltern_name] = {} - self.alle_zeitraeume[eltern_name] = [] - - # Alle Zeiträume einlesen (jeweils 3 Spalten: Beginn, Ende, Faktor) - for i in range(1, len(row), 3): - if i + 2 < len(row) and row[i].strip() and row[i + 1].strip(): - try: - beginn = datetime.strptime(row[i].strip(), '%Y-%m-%d').date() - ende = datetime.strptime(row[i + 1].strip(), '%Y-%m-%d').date() - faktor = float(row[i + 2].strip()) if row[i + 2].strip() else 0 - - # Zeitraum speichern - self.alle_zeitraeume[eltern_name].append((beginn, ende, faktor)) - - # Faktor für Tage im aktuellen Planungsmonat setzen - for tag in self.tage: - if beginn <= tag <= ende: - self.dienstfaktoren[eltern_name][tag] = faktor - - except (ValueError, IndexError): - continue - - # Tage ohne expliziten Faktor auf 0 setzen - for tag in self.tage: - if tag not in self.dienstfaktoren[eltern_name]: - self.dienstfaktoren[eltern_name][tag] = 0 - - print(f"Dienstfaktoren geladen für {len(self.dienstfaktoren)} Eltern") - print(f"Zeiträume gespeichert für globale Fairness-Berechnung") + self.dienstfaktoren, self.alle_zeitraeume = \ + EingabeParser.parse_eltern_csv(datei, self.tage) def lade_vorherige_ausgaben_csv(self, datei: str) -> None: """Lädt vorherige-ausgaben.csv für Fairness-Constraints""" - print(f"Lade vorherige Ausgaben aus {datei}...") - - try: - with open(datei, 'r', encoding='utf-8') as f: - reader = csv.reader(f) - header = next(reader) - - # Dienst-Spalten finden - dienst_spalten = {} - for i, col_name in enumerate(header[2:], 2): # Ab Spalte 2 (nach Datum, Wochentag) - for dienst in self.dienste: - if dienst.name.lower() in col_name.lower() or dienst.kuerzel == col_name: - dienst_spalten[dienst] = i - break - - for row in reader: - if len(row) < 3: - continue - - # Datum parsen - try: - datum = datetime.strptime(row[0].strip(), '%Y-%m-%d').date() - except ValueError: - continue - - # Zugeteilte Dienste zählen UND mit Datum speichern - for dienst, spalte_idx in dienst_spalten.items(): - if spalte_idx < len(row) and row[spalte_idx].strip(): - # Mehrere Eltern können in einer Zelle stehen (durch Leerzeichen getrennt) - eltern_liste = row[spalte_idx].strip().split() - for eltern_name in eltern_liste: - if eltern_name in self.eltern: - # Summierung für Kompatibilität - self.vorherige_dienste[eltern_name][dienst] += 1 - # Historische Dienste mit Datum speichern - self.historische_dienste.append((datum, eltern_name, dienst)) - - except FileNotFoundError: - print("Keine vorherigen Ausgaben gefunden - starte ohne historische Daten") - - print(f"Vorherige Dienste geladen: {dict(self.vorherige_dienste)}") - print(f"Historische Dienste mit Datum: {len(self.historische_dienste)} Einträge") + self.vorherige_dienste, self.historische_dienste = \ + EingabeParser.parse_vorherige_ausgaben_csv(datei, self.eltern, self.dienste) def berechne_dienstfaktor_an_datum(self, eltern: str, datum: date) -> float: """Berechnet den Dienstfaktor eines Elternteils an einem bestimmten Datum""" @@ -620,31 +469,7 @@ class Elterndienstplaner: def schreibe_ausgabe_csv(self, datei: str, lösung: Dict[date, Dict[Dienst, List[str]]]) -> None: """Schreibt die Lösung in die ausgabe.csv""" - print(f"Schreibe Ergebnisse nach {datei}...") - - with open(datei, 'w', newline='', encoding='utf-8') as f: - writer = csv.writer(f) - - # Header schreiben - header = ['Datum', 'Wochentag'] + [dienst.name for dienst in self.dienste] - writer.writerow(header) - - # Daten schreiben - for tag in sorted(self.tage): - wochentag = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'][tag.weekday()] - - row = [tag.strftime('%Y-%m-%d'), wochentag] - - for dienst in self.dienste: - if tag in lösung and dienst in lösung[tag]: - eltern_str = ' '.join(lösung[tag][dienst]) - else: - eltern_str = '' - row.append(eltern_str) - - writer.writerow(row) - - print("Ausgabe erfolgreich geschrieben!") + AusgabeWriter.schreibe_ausgabe_csv(datei, lösung, self.tage, self.dienste) def drucke_statistiken(self, lösung: Dict[date, Dict[Dienst, List[str]]]) -> None: """Druckt Statistiken zur Lösung""" From 7ebe50723e4b9ae894ec961c9d4e8a132840f425 Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Tue, 23 Dec 2025 22:44:09 +0100 Subject: [PATCH 08/20] refactoring: constraints --- STRUKTUR.md | 39 +++++++----- elterndienstplaner.py | 141 ++++++++++++++++++++++++++++++------------ 2 files changed, 124 insertions(+), 56 deletions(-) diff --git a/STRUKTUR.md b/STRUKTUR.md index ad7db20..566eb27 100644 --- a/STRUKTUR.md +++ b/STRUKTUR.md @@ -26,9 +26,19 @@ - `Dienst`: Datenmodell für Diensttypen - `Elterndienstplaner`: Hauptklasse mit Optimierungslogik - Fairness-Berechnungen (global/lokal) - - Optimierungsmodell-Erstellung + - Optimierungsmodell-Erstellung (modular aufgeteilt) - Statistiken +**Constraint-Funktionen** (modular aufgeteilt): +- `_erstelle_entscheidungsvariablen()`: Erstellt binäre Variablen +- `_add_constraint_ein_dienst_pro_woche()`: C1 - Max 1 Dienst pro Woche +- `_add_constraint_ein_dienst_pro_tag()`: C2 - Max 1 Dienst pro Tag +- `_add_constraint_verfuegbarkeit()`: C3 - Nur verfügbare Eltern +- `_add_constraint_dienst_bedarf()`: C4 - Alle Dienste müssen besetzt werden +- `_add_fairness_constraints()`: F1 & F2 - Erstellt Variablen und Constraints für Fairness +- `_berechne_fairness_gewichte()`: Zeitabhängige Gewichtung (Sep-Jul) +- `_erstelle_zielfunktion()`: Zielfunktion mit Fairness & Präferenzen + **Abhängigkeiten**: - Importiert `csv_io` für Datei-Operationen - Verwendet `pulp` für lineare Optimierung @@ -41,7 +51,7 @@ elterndienstplaner.py (700+ Zeilen) ├── Dienst Klasse ├── CSV Parsing (150+ Zeilen) ├── Fairness-Berechnung -├── Optimierung +├── Optimierung (200+ Zeilen inline) └── CSV Schreiben ``` @@ -54,30 +64,29 @@ csv_io.py (220 Zeilen) elterndienstplaner.py (500 Zeilen) ├── Dienst Klasse ├── Fairness-Berechnung -├── Optimierung +│ ├── berechne_faire_zielverteilung_global() +│ └── berechne_faire_zielverteilung_lokal() +├── Optimierung (modular) +│ ├── erstelle_optimierungsmodell() (30 Zeilen - übersichtlich!) +│ ├── _erstelle_entscheidungsvariablen() +│ ├── _add_constraint_ein_dienst_pro_woche() +│ ├── _add_constraint_ein_dienst_pro_tag() +│ ├── _add_constraint_verfuegbarkeit() +│ ├── _add_constraint_dienst_bedarf() +│ ├── _add_fairness_constraints() +│ └── _erstelle_zielfunktion() └── Statistiken ``` ## Nächste Schritte (Optional) -### Phase 2: Constraint-Funktionen auslagern -```python -def _add_constraint_ein_dienst_pro_woche(prob, x, ...): - """C1: Je Eltern und Dienst nur einmal die Woche""" - -def _add_constraint_ein_dienst_pro_tag(prob, x, ...): - """C2: Je Eltern nur einen Dienst am Tag""" -``` - ### Phase 3: Fairness-Modul (optional) ``` fairness.py ├── FairnessBerechner │ ├── berechne_global() │ └── berechne_lokal() -``` - -## Verwendung +```## Verwendung ```python from csv_io import EingabeParser, AusgabeWriter diff --git a/elterndienstplaner.py b/elterndienstplaner.py index 3e0ffda..c7bd4c7 100755 --- a/elterndienstplaner.py +++ b/elterndienstplaner.py @@ -254,21 +254,8 @@ class Elterndienstplaner: return ziel_dienste_lokal - def erstelle_optimierungsmodell(self) -> Tuple[pulp.LpProblem, Dict[Tuple[str, date, Dienst], pulp.LpVariable]]: - """Erstellt das PuLP Optimierungsmodell""" - print("Erstelle Optimierungsmodell...") - - # Debugging: Verfügbarkeit prüfen - print("\nDebug: Verfügbarkeit analysieren...") - for tag in self.tage[:5]: # Erste 5 Tage - verfügbare = [e for e in self.eltern if self.verfügbarkeit.get((e, tag), True)] - benötigte = self.benoetigte_dienste.get(tag, []) - print(f" {tag}: Benötigt {len(benötigte)} Dienste {benötigte}, verfügbar: {verfügbare}") - - # LP Problem erstellen - prob = pulp.LpProblem("Elterndienstplaner", pulp.LpMinimize) - - # Entscheidungsvariablen: x[eltern, tag, dienst] ∈ {0,1} + def _erstelle_entscheidungsvariablen(self) -> Dict[Tuple[str, date, Dienst], pulp.LpVariable]: + """Erstellt die binären Entscheidungsvariablen x[eltern, tag, dienst]""" x: Dict[Tuple[str, date, Dienst], pulp.LpVariable] = {} for eltern in self.eltern: for tag in self.tage: @@ -278,10 +265,14 @@ class Elterndienstplaner: f"x_{eltern.replace(' ', '_')}_{tag}_{dienst.kuerzel}", cat='Binary' ) + return x - # Vereinfachtes Modell: Grundlegende Constraints - - # C1: Je Eltern und Dienst nur einmal die Woche + def _add_constraint_ein_dienst_pro_woche( + self, + prob: pulp.LpProblem, + x: Dict[Tuple[str, date, Dienst], pulp.LpVariable] + ) -> None: + """C1: Je Eltern und Dienst nur einmal die Woche""" woche_start = self.tage[0] woche_nr = 0 while woche_start <= self.tage[-1]: @@ -296,12 +287,18 @@ class Elterndienstplaner: woche_vars.append(x[eltern, tag, dienst]) if woche_vars: - prob += pulp.lpSum(woche_vars) <= 1, f"C1_{eltern.replace(' ', '_')}_{dienst.kuerzel}_w{woche_nr}" + prob += pulp.lpSum(woche_vars) <= 1, \ + f"C1_{eltern.replace(' ', '_')}_{dienst.kuerzel}_w{woche_nr}" woche_start += timedelta(days=7) woche_nr += 1 - # C2: Je Eltern nur einen Dienst am Tag + def _add_constraint_ein_dienst_pro_tag( + self, + prob: pulp.LpProblem, + x: Dict[Tuple[str, date, Dienst], pulp.LpVariable] + ) -> None: + """C2: Je Eltern nur einen Dienst am Tag""" for eltern in self.eltern: for tag in self.tage: tag_vars = [] @@ -312,39 +309,50 @@ class Elterndienstplaner: if tag_vars: prob += pulp.lpSum(tag_vars) <= 1, f"C2_{eltern.replace(' ', '_')}_{tag}" - # C3: Dienste nur verfügbaren Eltern zuteilen + def _add_constraint_verfuegbarkeit( + self, + prob: pulp.LpProblem, + x: Dict[Tuple[str, date, Dienst], pulp.LpVariable] + ) -> None: + """C3: Dienste nur verfügbaren Eltern zuteilen""" for eltern in self.eltern: for tag in self.tage: if not self.verfügbarkeit.get((eltern, tag), True): for dienst in self.dienste: if (eltern, tag, dienst) in x: - prob += x[eltern, tag, dienst] == 0, f"C3_{eltern.replace(' ', '_')}_{tag}_{dienst.kuerzel}" + prob += x[eltern, tag, dienst] == 0, \ + f"C3_{eltern.replace(' ', '_')}_{tag}_{dienst.kuerzel}" - # Alle benötigten Dienste müssen zugeteilt werden (flexibel) + def _add_constraint_dienst_bedarf( + self, + prob: pulp.LpProblem, + x: Dict[Tuple[str, date, Dienst], pulp.LpVariable] + ) -> None: + """C4: Alle benötigten Dienste müssen zugeteilt werden""" for tag in self.tage: for dienst in self.benoetigte_dienste.get(tag, []): dienst_vars = [] - verfuegbare_eltern = 0 for eltern in self.eltern: if (eltern, tag, dienst) in x: # Prüfe ob Eltern verfügbar if self.verfügbarkeit.get((eltern, tag), True): dienst_vars.append(x[eltern, tag, dienst]) - verfuegbare_eltern += 1 if dienst_vars: # Anzahl benötigter Personen pro Dienst (aus Dienst-Objekt) benoetigte_personen = dienst.personen_anzahl - prob += pulp.lpSum(dienst_vars) == benoetigte_personen, f"Bedarf_{tag}_{dienst.kuerzel}" + prob += pulp.lpSum(dienst_vars) == benoetigte_personen, \ + f"Bedarf_{tag}_{dienst.kuerzel}" - # FAIRNESS-CONSTRAINTS UND ZIELFUNKTION - objective_terms = [] - - # Berechne faire Zielverteilungen (global und lokal) - ziel_dienste_global = self.berechne_faire_zielverteilung_global() - ziel_dienste_lokal = self.berechne_faire_zielverteilung_lokal() - - # Hilfsvariablen für Fairness-Abweichungen + def _add_fairness_constraints( + self, + prob: pulp.LpProblem, + x: Dict[Tuple[str, date, Dienst], pulp.LpVariable], + ziel_dienste_global: DefaultDict[str, DefaultDict[Dienst, float]], + ziel_dienste_lokal: DefaultDict[str, DefaultDict[Dienst, float]] + ) -> Tuple[Dict, Dict]: + """F1 & F2: Erstellt Fairness-Variablen und fügt Fairness-Constraints hinzu (global und lokal)""" + # Hilfsvariablen für Fairness-Abweichungen erstellen fairness_abweichung_lokal = {} # F2 fairness_abweichung_global = {} # F1 @@ -355,7 +363,7 @@ class Elterndienstplaner: fairness_abweichung_global[eltern, dienst] = pulp.LpVariable( f"fair_global_{eltern.replace(' ', '_')}_{dienst.kuerzel}", lowBound=0) - # F1: Globale Fairness & F2: Lokale Fairness + # Fairness-Constraints hinzufügen for eltern in self.eltern: for dienst in self.dienste: # Tatsächliche Dienste im aktuellen Monat @@ -368,7 +376,6 @@ class Elterndienstplaner: # F2: Lokale Fairness - nur aktueller Monat ziel_lokal = ziel_dienste_lokal[eltern][dienst] if ziel_lokal > 0: - # Lokale Fairness-Constraints prob += (tatsaechliche_dienste_monat - ziel_lokal <= fairness_abweichung_lokal[eltern, dienst]) prob += (ziel_lokal - tatsaechliche_dienste_monat <= @@ -382,15 +389,17 @@ class Elterndienstplaner: # Tatsächliche Dienste global (Vergangenheit + geplant) total_dienste_inkl_vergangenheit = tatsaechliche_dienste_monat + vorherige_dienste - # Globale Fairness-Constraints prob += (total_dienste_inkl_vergangenheit - ziel_global <= fairness_abweichung_global[eltern, dienst]) prob += (ziel_global - total_dienste_inkl_vergangenheit <= fairness_abweichung_global[eltern, dienst]) - # Gewichtung: Jahresanfang F1 stärker, Jahresende F2 stärker - # Annahme: September = Jahresanfang, Juli = Jahresende + return fairness_abweichung_lokal, fairness_abweichung_global + + def _berechne_fairness_gewichte(self) -> Tuple[int, int]: + """Berechnet Gewichtung basierend auf Jahreszeit (Sep-Jul Schuljahr)""" aktueller_monat = self.tage[0].month if self.tage else 1 + if 9 <= aktueller_monat <= 12: # Sep-Dez: Jahresanfang gewicht_f1 = 100 # Global wichtiger gewicht_f2 = 50 # Lokal weniger wichtig @@ -401,6 +410,21 @@ class Elterndienstplaner: gewicht_f1 = 50 # Global weniger wichtig gewicht_f2 = 100 # Lokal wichtiger + return gewicht_f1, gewicht_f2 + + def _erstelle_zielfunktion( + self, + prob: pulp.LpProblem, + x: Dict[Tuple[str, date, Dienst], pulp.LpVariable], + fairness_abweichung_lokal: Dict, + fairness_abweichung_global: Dict + ) -> None: + """Erstellt die Zielfunktion mit Fairness und Präferenzen""" + objective_terms = [] + + # Fairness-Gewichtung + gewicht_f1, gewicht_f2 = self._berechne_fairness_gewichte() + # Fairness-Terme zur Zielfunktion hinzufügen for eltern in self.eltern: for dienst in self.dienste: @@ -410,12 +434,12 @@ class Elterndienstplaner: # P1: Bevorzugte Dienste (positiv belohnen) for (eltern, tag, dienst), präf in self.präferenzen.items(): if (eltern, tag, dienst) in x and präf == 1: # bevorzugt - objective_terms.append(-5 * x[eltern, tag, dienst]) # Schwächer als Fairness + objective_terms.append(-5 * x[eltern, tag, dienst]) # P2: Abgelehnte Dienste (bestrafen) for (eltern, tag, dienst), präf in self.präferenzen.items(): if (eltern, tag, dienst) in x and präf == -1: # abgelehnt - objective_terms.append(25 * x[eltern, tag, dienst]) # Schwächer als Fairness + objective_terms.append(25 * x[eltern, tag, dienst]) # Zielfunktion setzen if objective_terms: @@ -425,6 +449,41 @@ class Elterndienstplaner: prob += pulp.lpSum([var for var in x.values()]) print(f"Verwende Gewichtung: F1 (global) = {gewicht_f1}, F2 (lokal) = {gewicht_f2}") + + def erstelle_optimierungsmodell(self) -> Tuple[pulp.LpProblem, Dict[Tuple[str, date, Dienst], pulp.LpVariable]]: + """Erstellt das PuLP Optimierungsmodell""" + print("Erstelle Optimierungsmodell...") + + # Debugging: Verfügbarkeit prüfen + print("\nDebug: Verfügbarkeit analysieren...") + for tag in self.tage[:5]: # Erste 5 Tage + verfügbare = [e for e in self.eltern if self.verfügbarkeit.get((e, tag), True)] + benötigte = self.benoetigte_dienste.get(tag, []) + print(f" {tag}: Benötigt {len(benötigte)} Dienste {benötigte}, verfügbar: {verfügbare}") + + # LP Problem erstellen + prob = pulp.LpProblem("Elterndienstplaner", pulp.LpMinimize) + + # Entscheidungsvariablen erstellen + x = self._erstelle_entscheidungsvariablen() + + # Grundlegende Constraints hinzufügen + self._add_constraint_ein_dienst_pro_woche(prob, x) + self._add_constraint_ein_dienst_pro_tag(prob, x) + self._add_constraint_verfuegbarkeit(prob, x) + self._add_constraint_dienst_bedarf(prob, x) + + # Fairness-Constraints + ziel_dienste_global = self.berechne_faire_zielverteilung_global() + ziel_dienste_lokal = self.berechne_faire_zielverteilung_lokal() + + fairness_abweichung_lokal, fairness_abweichung_global = self._add_fairness_constraints( + prob, x, ziel_dienste_global, ziel_dienste_lokal + ) + + # Zielfunktion erstellen + self._erstelle_zielfunktion(prob, x, fairness_abweichung_lokal, fairness_abweichung_global) + print(f"Modell erstellt mit {len(x)} Variablen und {len(prob.constraints)} Constraints") return prob, x From 32c92fcb636c65d32787ad3c81e9ed690363537b Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Tue, 23 Dec 2025 22:44:41 +0100 Subject: [PATCH 09/20] gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ From d3b82827af991a7f76ed1ddb53120895878f6c38 Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Tue, 23 Dec 2025 23:00:01 +0100 Subject: [PATCH 10/20] constraint ein dienst pro woche um historische dienste erweitert --- elterndienstplaner.py | 36 +++++++++++++++++++++++++++--------- test/simple/ausgabe.csv | 32 ++++++++++++++++---------------- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/elterndienstplaner.py b/elterndienstplaner.py index c7bd4c7..1bcc3aa 100755 --- a/elterndienstplaner.py +++ b/elterndienstplaner.py @@ -272,22 +272,40 @@ class Elterndienstplaner: prob: pulp.LpProblem, x: Dict[Tuple[str, date, Dienst], pulp.LpVariable] ) -> None: - """C1: Je Eltern und Dienst nur einmal die Woche""" - woche_start = self.tage[0] + """C1: Je Eltern und Dienst nur einmal die Woche (Woche = Montag bis Sonntag)""" + erster_tag = self.tage[0] + # weekday(): 0=Montag, 6=Sonntag + # Finde Montag am oder vor dem ersten Planungstag (für historische Dienste) + woche_start = erster_tag - timedelta(days=erster_tag.weekday()) + woche_nr = 0 - while woche_start <= self.tage[-1]: - woche_ende = min(woche_start + timedelta(days=6), self.tage[-1]) - woche_tage = [t for t in self.tage if woche_start <= t <= woche_ende] + letzter_tag = self.tage[-1] + + while woche_start <= letzter_tag: + woche_ende = woche_start + timedelta(days=6) # Sonntag for eltern in self.eltern: for dienst in self.dienste: woche_vars = [] - for tag in woche_tage: - if (eltern, tag, dienst) in x: - woche_vars.append(x[eltern, tag, dienst]) + # Zähle historische Dienste in dieser Woche (VOR dem Planungszeitraum) + historische_dienste_in_woche = 0 + if woche_start < erster_tag: + for hist_datum, hist_eltern, hist_dienst in self.historische_dienste: + if (hist_eltern == eltern and + hist_dienst == dienst and + woche_start <= hist_datum < erster_tag): + historische_dienste_in_woche += 1 + + # Sammle Variablen für Planungszeitraum in dieser Woche + for tag in self.tage: + if woche_start <= tag <= woche_ende: + if (eltern, tag, dienst) in x: + woche_vars.append(x[eltern, tag, dienst]) + + # Constraint: Historische + geplante Dienste <= 1 if woche_vars: - prob += pulp.lpSum(woche_vars) <= 1, \ + prob += pulp.lpSum(woche_vars) <= 1 - historische_dienste_in_woche, \ f"C1_{eltern.replace(' ', '_')}_{dienst.kuerzel}_w{woche_nr}" woche_start += timedelta(days=7) diff --git a/test/simple/ausgabe.csv b/test/simple/ausgabe.csv index 6bdca70..2bbbedc 100644 --- a/test/simple/ausgabe.csv +++ b/test/simple/ausgabe.csv @@ -1,24 +1,24 @@ Datum,Wochentag,Frühstücksdienst,Putznotdienst,Essensausgabenotdienst,Kochen,Elternabend -2026-01-01,Donnerstag,Paula,Marie,Jonas,, +2026-01-01,Donnerstag,Ben & Nele,Marie,Jonas,, 2026-01-02,Freitag,Jonas,Ben & Nele,Laura,, 2026-01-03,Samstag,Marie,Jonas,Ben & Nele,, -2026-01-06,Dienstag,Laura,Paula,Erwin,, -2026-01-07,Mittwoch,Ben & Nele,Erwin,Marie,, -2026-01-08,Donnerstag,Jonas,Paula,Marie,Ben & Nele, -2026-01-09,Freitag,Erwin,Marie,Paula,, -2026-01-10,Samstag,Paula,Laura,Ben & Nele,, +2026-01-06,Dienstag,Laura,Jonas,Marie,, +2026-01-07,Mittwoch,Paula,Ben & Nele,Jonas,, +2026-01-08,Donnerstag,Jonas,Paula,Erwin,Ben & Nele, +2026-01-09,Freitag,Ben & Nele,Marie,Paula,, +2026-01-10,Samstag,Marie,Laura,Ben & Nele,, 2026-01-13,Dienstag,Ben & Nele,Jonas,Laura,, -2026-01-14,Mittwoch,Marie,Ben & Nele,Jonas,, -2026-01-15,Donnerstag,Laura,Jonas,Erwin,, +2026-01-14,Mittwoch,Paula,Erwin,Jonas,, +2026-01-15,Donnerstag,Laura,Paula,Marie,, 2026-01-16,Freitag,Marie,Laura,Ben & Nele,, -2026-01-17,Samstag,Jonas,Ben & Nele,Paula,, -2026-01-20,Dienstag,Ben & Nele,Erwin,Marie,, -2026-01-21,Mittwoch,Erwin,Marie,Jonas,, +2026-01-17,Samstag,Erwin,Ben & Nele,Paula,, +2026-01-20,Dienstag,Jonas,Erwin,Marie,, +2026-01-21,Mittwoch,Erwin,Ben & Nele,Jonas,, 2026-01-22,Donnerstag,Ben & Nele,Marie,Laura,Jonas, -2026-01-23,Freitag,Marie,Jonas,Ben & Nele,, -2026-01-24,Samstag,Laura,Erwin,Marie,,Ben & Nele Jonas +2026-01-23,Freitag,Marie,Laura,Ben & Nele,, +2026-01-24,Samstag,Laura,Jonas,Erwin,,Ben & Nele Marie 2026-01-27,Dienstag,Erwin,Ben & Nele,Jonas,, -2026-01-28,Mittwoch,Jonas,Paula,Erwin,, -2026-01-29,Donnerstag,Marie,Ben & Nele,Jonas,, +2026-01-28,Mittwoch,Jonas,Erwin,Marie,, +2026-01-29,Donnerstag,Marie,Paula,Ben & Nele,, 2026-01-30,Freitag,Ben & Nele,Jonas,Paula,, -2026-01-31,Samstag,Paula,Marie,Ben & Nele,, +2026-01-31,Samstag,Paula,Marie,Erwin,, From 1a5e5904d14799d4cf43110e4e73d8219dd2c4df Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 23 Dec 2025 22:36:26 +0000 Subject: [PATCH 11/20] refactoring: globale Zielverteilung Personenanzahl --- elterndienstplaner.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/elterndienstplaner.py b/elterndienstplaner.py index 1bcc3aa..a9b5d1b 100755 --- a/elterndienstplaner.py +++ b/elterndienstplaner.py @@ -157,13 +157,10 @@ class Elterndienstplaner: # 2. AKTUELLER MONAT: Faire Verteilung der benötigten Dienste benoetigte_dienste_monat = sum( - 1 for tag in self.tage + dienst.personen_anzahl for tag in self.tage if dienst in self.benoetigte_dienste.get(tag, []) ) - # Multipliziere mit Anzahl benötigter Personen pro Dienst - benoetigte_dienste_monat *= dienst.personen_anzahl - if benoetigte_dienste_monat > 0: # Gesamtdienstfaktor für aktuellen Monat gesamt_dienstfaktor_monat = sum( From b885388122713b9bdb14ab5b64f3faf816f075d0 Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Wed, 24 Dec 2025 14:15:17 +0100 Subject: [PATCH 12/20] refactoring: globale Zielverteilung Personenanzahl --- elterndienstplaner.py | 66 +++++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/elterndienstplaner.py b/elterndienstplaner.py index a9b5d1b..75d083b 100755 --- a/elterndienstplaner.py +++ b/elterndienstplaner.py @@ -51,7 +51,11 @@ class Elterndienstplaner: self.benoetigte_dienste: Dict[date, List[Dienst]] = {} self.verfügbarkeit: Dict[Tuple[str, date], bool] = {} self.präferenzen: Dict[Tuple[str, date, Dienst], int] = {} - self.dienstfaktoren: Dict[str, Dict[date, float]] = {} + + # dienstfaktoren[eltern][tag] = faktor. + # Wenn es eltern nicht gibt -> keyerror + # Wenn es tag nicht gibt -> default 0.0 + self.dienstfaktoren: Dict[str, DefaultDict[date, float]] = {} self.alle_zeitraeume: Dict[str, List[Tuple[date, date, float]]] = {} self.vorherige_dienste: DefaultDict[str, DefaultDict[Dienst, int]] = \ defaultdict(lambda: defaultdict(int)) @@ -155,39 +159,35 @@ class Elterndienstplaner: print(f" {hist_datum}: {eltern} Faktor={dienstfaktoren_tag[eltern]} " f"-> {faire_zuteilung:.2f} von {anzahl_dienste} Diensten") - # 2. AKTUELLER MONAT: Faire Verteilung der benötigten Dienste - benoetigte_dienste_monat = sum( - dienst.personen_anzahl for tag in self.tage - if dienst in self.benoetigte_dienste.get(tag, []) - ) + # 2. AKTUELLER MONAT: Faire Verteilung der benötigten Dienste (tageweise wie bei historischen Diensten) + benoetigte_dienste_monat = 0 - if benoetigte_dienste_monat > 0: - # Gesamtdienstfaktor für aktuellen Monat - gesamt_dienstfaktor_monat = sum( - sum(self.dienstfaktoren.get(e, {}).get(tag, 0) for tag in self.tage) - for e in self.eltern - ) + # Für jeden Tag im aktuellen Monat faire Umverteilung berechnen + for tag in self.tage: + # Prüfe ob an diesem Tag der Dienst benötigt wird + if dienst not in self.benoetigte_dienste.get(tag, []): + continue - if gesamt_dienstfaktor_monat > 0: + benoetigte_dienste_monat += dienst.personen_anzahl + + # Dienstfaktoren aller Eltern für diesen Tag berechnen + dienstfaktoren_tag = {} + gesamt_dienstfaktor_tag = 0 + + for eltern in self.eltern: + faktor = self.dienstfaktoren[eltern][tag] + dienstfaktoren_tag[eltern] = faktor + gesamt_dienstfaktor_tag += faktor + + # Faire Umverteilung der an diesem Tag benötigten Dienste + if gesamt_dienstfaktor_tag > 0: for eltern in self.eltern: - monatsfaktor = sum( - self.dienstfaktoren.get(eltern, {}).get(tag, 0) - for tag in self.tage - ) - if monatsfaktor > 0: - anteil = monatsfaktor / gesamt_dienstfaktor_monat - faire_zuteilung = anteil * benoetigte_dienste_monat - ziel_dienste[eltern][dienst] += faire_zuteilung + anteil = dienstfaktoren_tag[eltern] / gesamt_dienstfaktor_tag + faire_zuteilung = anteil * dienst.personen_anzahl + ziel_dienste[eltern][dienst] += faire_zuteilung # Debug-Ausgabe für diesen Dienst - total_historisch = sum( - ziel_dienste[e][dienst] - ( - sum(self.dienstfaktoren.get(e, {}).get(tag, 0) for tag in self.tage) / - sum(sum(self.dienstfaktoren.get(e2, {}).get(tag, 0) for tag in self.tage) for e2 in self.eltern) * benoetigte_dienste_monat - if sum(sum(self.dienstfaktoren.get(e2, {}).get(tag, 0) for tag in self.tage) for e2 in self.eltern) > 0 else 0 - ) for e in self.eltern - ) if len(historische_dienste_dieses_typs) > 0 else 0 - + total_historisch = sum(ziel_dienste[e][dienst] for e in self.eltern) - benoetigte_dienste_monat print(f" {dienst.kuerzel}: Historisch faire Summe={total_historisch:.1f}, " f"Aktuell benötigt={benoetigte_dienste_monat}") @@ -213,7 +213,7 @@ class Elterndienstplaner: # Gesamtdienstfaktor für aktuellen Monat berechnen gesamt_dienstfaktor_monat = sum( - sum(self.dienstfaktoren.get(e, {}).get(tag, 0) for tag in self.tage) + sum(self.dienstfaktoren[e][tag] for tag in self.tage) for e in self.eltern ) @@ -237,7 +237,7 @@ class Elterndienstplaner: for eltern in self.eltern: # Dienstfaktor für diesen Elternteil im aktuellen Monat monatlicher_dienstfaktor = sum( - self.dienstfaktoren.get(eltern, {}).get(tag, 0) for tag in self.tage + self.dienstfaktoren[eltern][tag] for tag in self.tage ) if monatlicher_dienstfaktor > 0: @@ -274,7 +274,7 @@ class Elterndienstplaner: # weekday(): 0=Montag, 6=Sonntag # Finde Montag am oder vor dem ersten Planungstag (für historische Dienste) woche_start = erster_tag - timedelta(days=erster_tag.weekday()) - + woche_nr = 0 letzter_tag = self.tage[-1] @@ -569,7 +569,7 @@ class Elterndienstplaner: # Dienstfaktor-Analyse print(f"\nDienstfaktoren im Planungszeitraum:") for eltern in sorted(self.eltern): - faktor_summe = sum(self.dienstfaktoren.get(eltern, {}).get(tag, 0) for tag in self.tage) + faktor_summe = sum(self.dienstfaktoren[eltern][tag] for tag in self.tage) print(f" {eltern:15} {faktor_summe:.1f}") From 95b21aa150138c1a0c3d8e1b10e113599eebffbb Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Wed, 24 Dec 2025 21:56:50 +0100 Subject: [PATCH 13/20] fairness ueber alle Dienste und Debugausgabe --- elterndienstplaner.py | 280 +++++++++++++++++++++++++++++++++--------- 1 file changed, 221 insertions(+), 59 deletions(-) diff --git a/elterndienstplaner.py b/elterndienstplaner.py index 75d083b..2b617f3 100755 --- a/elterndienstplaner.py +++ b/elterndienstplaner.py @@ -106,8 +106,12 @@ class Elterndienstplaner: return 0 def berechne_faire_zielverteilung_global(self) -> DefaultDict[str, DefaultDict[Dienst, float]]: - """Berechnet die faire Zielanzahl von Diensten pro Eltern-Dienst-Kombination - basierend auf tatsächlich geleisteten historischen Diensten und deren fairer Umverteilung""" + """Berechnet die faire Zielanzahl von Diensten für den Planungszeitraum + basierend auf globaler Fairness (Historie + aktueller Monat). + + Gibt die Ziel-Dienstanzahl für den aktuellen Planungszeitraum zurück, + korrigiert um bereits geleistete Dienste. Kann negativ sein, wenn bereits + mehr Dienste geleistet wurden als fair wäre.""" ziel_dienste: DefaultDict[str, DefaultDict[Dienst, float]] = \ defaultdict(lambda: defaultdict(float)) @@ -186,19 +190,10 @@ class Elterndienstplaner: faire_zuteilung = anteil * dienst.personen_anzahl ziel_dienste[eltern][dienst] += faire_zuteilung - # Debug-Ausgabe für diesen Dienst - total_historisch = sum(ziel_dienste[e][dienst] for e in self.eltern) - benoetigte_dienste_monat - print(f" {dienst.kuerzel}: Historisch faire Summe={total_historisch:.1f}, " - f"Aktuell benötigt={benoetigte_dienste_monat}") - - # Debug-Output: Detaillierte Zielverteilung - print("\n Berechnete Zielverteilung (basierend auf tatsächlichen historischen Diensten):") - for eltern in sorted(self.eltern): - for dienst in self.dienste: - if ziel_dienste[eltern][dienst] > 0.1: # Nur relevante Werte - ist = self.vorherige_dienste[eltern][dienst] - ziel = ziel_dienste[eltern][dienst] - print(f" {eltern} {dienst.kuerzel}: IST={ist}, FAIRE_ZIEL={ziel:.2f}, DIFF={ziel-ist:.2f}") + # 3. ABZUG DER BEREITS GELEISTETEN DIENSTE + # Ziehe die tatsächlich geleisteten Dienste ab, um das Ziel für den Planungszeitraum zu erhalten + for eltern in self.eltern: + ziel_dienste[eltern][dienst] -= self.vorherige_dienste[eltern][dienst] return ziel_dienste @@ -245,10 +240,6 @@ class Elterndienstplaner: faire_zuteilung = anteil * benoetigte_dienste_monat ziel_dienste_lokal[eltern][dienst] = faire_zuteilung - if faire_zuteilung > 0.1: # Debug nur für relevante Werte - print(f" {eltern}: Faktor={monatlicher_dienstfaktor:.1f} " - f"-> {faire_zuteilung:.2f} Dienste") - return ziel_dienste_lokal def _erstelle_entscheidungsvariablen(self) -> Dict[Tuple[str, date, Dienst], pulp.LpVariable]: @@ -363,20 +354,28 @@ class Elterndienstplaner: self, prob: pulp.LpProblem, x: Dict[Tuple[str, date, Dienst], pulp.LpVariable], - ziel_dienste_global: DefaultDict[str, DefaultDict[Dienst, float]], - ziel_dienste_lokal: DefaultDict[str, DefaultDict[Dienst, float]] - ) -> Tuple[Dict, Dict]: - """F1 & F2: Erstellt Fairness-Variablen und fügt Fairness-Constraints hinzu (global und lokal)""" + ziel_dienste: DefaultDict[str, DefaultDict[Dienst, float]], + constraint_prefix: str + ) -> Dict: + """Erstellt Fairness-Variablen und fügt Fairness-Constraints hinzu + + Args: + prob: Das LP-Problem + x: Die Entscheidungsvariablen + ziel_dienste: Die Zielverteilung der Dienste + constraint_prefix: Präfix für Constraint-Namen ('lokal' oder 'global') + + Returns: + Dictionary mit Fairness-Abweichungsvariablen + """ # Hilfsvariablen für Fairness-Abweichungen erstellen - fairness_abweichung_lokal = {} # F2 - fairness_abweichung_global = {} # F1 + fairness_abweichung = {} for eltern in self.eltern: for dienst in self.dienste: - fairness_abweichung_lokal[eltern, dienst] = pulp.LpVariable( - f"fair_lokal_{eltern.replace(' ', '_')}_{dienst.kuerzel}", lowBound=0) - fairness_abweichung_global[eltern, dienst] = pulp.LpVariable( - f"fair_global_{eltern.replace(' ', '_')}_{dienst.kuerzel}", lowBound=0) + fairness_abweichung[eltern, dienst] = pulp.LpVariable( + f"fair_{constraint_prefix}_{eltern.replace(' ', '_')}_{dienst.kuerzel}", + lowBound=0) # Fairness-Constraints hinzufügen for eltern in self.eltern: @@ -388,57 +387,104 @@ class Elterndienstplaner: if (eltern, tag, dienst) in x ) - # F2: Lokale Fairness - nur aktueller Monat - ziel_lokal = ziel_dienste_lokal[eltern][dienst] - if ziel_lokal > 0: - prob += (tatsaechliche_dienste_monat - ziel_lokal <= - fairness_abweichung_lokal[eltern, dienst]) - prob += (ziel_lokal - tatsaechliche_dienste_monat <= - fairness_abweichung_lokal[eltern, dienst]) + # Ziel für diese Fairness-Variante + ziel = ziel_dienste[eltern][dienst] + prob += (tatsaechliche_dienste_monat - ziel <= + fairness_abweichung[eltern, dienst]) + prob += (ziel - tatsaechliche_dienste_monat <= + fairness_abweichung[eltern, dienst]) - # F1: Globale Fairness - basierend auf berechneter Zielverteilung - ziel_global = ziel_dienste_global[eltern][dienst] - vorherige_dienste = self.vorherige_dienste[eltern][dienst] + return fairness_abweichung - if ziel_global > 0: - # Tatsächliche Dienste global (Vergangenheit + geplant) - total_dienste_inkl_vergangenheit = tatsaechliche_dienste_monat + vorherige_dienste + def _add_constraint_gesamtfairness( + self, + prob: pulp.LpProblem, + x: Dict[Tuple[str, date, Dienst], pulp.LpVariable], + ziel_dienste: DefaultDict[str, DefaultDict[Dienst, float]], + constraint_prefix: str + ) -> Dict: + """F3: Dienstübergreifende Fairness - verhindert Häufung bei einzelnen Eltern - prob += (total_dienste_inkl_vergangenheit - ziel_global <= - fairness_abweichung_global[eltern, dienst]) - prob += (ziel_global - total_dienste_inkl_vergangenheit <= - fairness_abweichung_global[eltern, dienst]) + Berechnet die Abweichung der Gesamtdienstanzahl (über alle Diensttypen) + vom fairen Gesamtziel. Dies verhindert, dass einzelne Eltern über alle + Diensttypen hinweg überproportional viele Dienste bekommen. - return fairness_abweichung_lokal, fairness_abweichung_global + Args: + prob: Das LP-Problem + x: Die Entscheidungsvariablen + ziel_dienste: Die Zielverteilung (global oder lokal) + constraint_prefix: Präfix für Constraint-Namen ('lokal' oder 'global') - def _berechne_fairness_gewichte(self) -> Tuple[int, int]: + Returns: + Dictionary mit Gesamt-Fairness-Abweichungsvariablen + """ + fairness_abweichung_gesamt = {} + + for eltern in self.eltern: + fairness_abweichung_gesamt[eltern] = pulp.LpVariable( + f"fair_gesamt_{constraint_prefix}_{eltern.replace(' ', '_')}", + lowBound=0) + + # Tatsächliche Gesamtdienste für diesen Elternteil + tatsaechliche_dienste_gesamt = pulp.lpSum( + x[eltern, tag, dienst] + for tag in self.tage + for dienst in self.dienste + if (eltern, tag, dienst) in x + ) + + # Ziel-Gesamtdienste für diesen Elternteil (Summe über alle Dienste) + ziel_gesamt = sum(ziel_dienste[eltern][dienst] for dienst in self.dienste) + + # Fairness-Constraints + prob += (tatsaechliche_dienste_gesamt - ziel_gesamt <= + fairness_abweichung_gesamt[eltern]) + prob += (ziel_gesamt - tatsaechliche_dienste_gesamt <= + fairness_abweichung_gesamt[eltern]) + + return fairness_abweichung_gesamt + + def _berechne_fairness_gewichte(self) -> Tuple[int, int, int, int]: """Berechnet Gewichtung basierend auf Jahreszeit (Sep-Jul Schuljahr)""" aktueller_monat = self.tage[0].month if self.tage else 1 if 9 <= aktueller_monat <= 12: # Sep-Dez: Jahresanfang gewicht_f1 = 100 # Global wichtiger gewicht_f2 = 50 # Lokal weniger wichtig + gewicht_f3_global = 30 # Gesamtfairness global + gewicht_f3_lokal = 15 # Gesamtfairness lokal elif 1 <= aktueller_monat <= 3: # Jan-Mar: Jahresmitte gewicht_f1 = 75 gewicht_f2 = 75 + gewicht_f3_global = 25 + gewicht_f3_lokal = 25 else: # Apr-Jul: Jahresende gewicht_f1 = 50 # Global weniger wichtig gewicht_f2 = 100 # Lokal wichtiger + gewicht_f3_global = 15 + gewicht_f3_lokal = 30 - return gewicht_f1, gewicht_f2 + return gewicht_f1, gewicht_f2, gewicht_f3_global, gewicht_f3_lokal def _erstelle_zielfunktion( self, prob: pulp.LpProblem, x: Dict[Tuple[str, date, Dienst], pulp.LpVariable], fairness_abweichung_lokal: Dict, - fairness_abweichung_global: Dict + fairness_abweichung_global: Dict, + fairness_abweichung_gesamt_global: Dict, + fairness_abweichung_gesamt_lokal: Dict ) -> None: """Erstellt die Zielfunktion mit Fairness und Präferenzen""" objective_terms = [] # Fairness-Gewichtung - gewicht_f1, gewicht_f2 = self._berechne_fairness_gewichte() + gewicht_global = 10 + gewicht_lokal = 50 + gewicht_f1 = gewicht_global + gewicht_f2 = gewicht_lokal + gewicht_f3_global = 0.25 * gewicht_global + gewicht_f3_lokal = 0.25 * gewicht_lokal # Fairness-Terme zur Zielfunktion hinzufügen for eltern in self.eltern: @@ -446,6 +492,10 @@ class Elterndienstplaner: objective_terms.append(gewicht_f1 * fairness_abweichung_global[eltern, dienst]) objective_terms.append(gewicht_f2 * fairness_abweichung_lokal[eltern, dienst]) + # F3: Gesamtfairness (dienstübergreifend) - global und lokal + objective_terms.append(gewicht_f3_global * fairness_abweichung_gesamt_global[eltern]) + objective_terms.append(gewicht_f3_lokal * fairness_abweichung_gesamt_lokal[eltern]) + # P1: Bevorzugte Dienste (positiv belohnen) for (eltern, tag, dienst), präf in self.präferenzen.items(): if (eltern, tag, dienst) in x and präf == 1: # bevorzugt @@ -463,10 +513,20 @@ class Elterndienstplaner: # Fallback: Minimiere Gesamtanzahl Dienste prob += pulp.lpSum([var for var in x.values()]) - print(f"Verwende Gewichtung: F1 (global) = {gewicht_f1}, F2 (lokal) = {gewicht_f2}") + print(f"Verwende Gewichtung: F1 (global) = {gewicht_f1}, F2 (lokal) = {gewicht_f2}, " + f"F3_global = {gewicht_f3_global}, F3_lokal = {gewicht_f3_lokal}") - def erstelle_optimierungsmodell(self) -> Tuple[pulp.LpProblem, Dict[Tuple[str, date, Dienst], pulp.LpVariable]]: - """Erstellt das PuLP Optimierungsmodell""" + def erstelle_optimierungsmodell(self) -> Tuple[ + pulp.LpProblem, + Dict[Tuple[str, date, Dienst], pulp.LpVariable], + DefaultDict[str, DefaultDict[Dienst, float]], + DefaultDict[str, DefaultDict[Dienst, float]] + ]: + """Erstellt das PuLP Optimierungsmodell + + Returns: + Tuple mit (prob, x, ziel_dienste_lokal, ziel_dienste_global) + """ print("Erstelle Optimierungsmodell...") # Debugging: Verfügbarkeit prüfen @@ -492,15 +552,32 @@ class Elterndienstplaner: ziel_dienste_global = self.berechne_faire_zielverteilung_global() ziel_dienste_lokal = self.berechne_faire_zielverteilung_lokal() - fairness_abweichung_lokal, fairness_abweichung_global = self._add_fairness_constraints( - prob, x, ziel_dienste_global, ziel_dienste_lokal + # F2: Lokale Fairness-Constraints + fairness_abweichung_lokal = self._add_fairness_constraints( + prob, x, ziel_dienste_lokal, "lokal" + ) + + # F1: Globale Fairness-Constraints + fairness_abweichung_global = self._add_fairness_constraints( + prob, x, ziel_dienste_global, "global" + ) + + # F3: Dienstübergreifende Fairness - Global + fairness_abweichung_gesamt_global = self._add_constraint_gesamtfairness( + prob, x, ziel_dienste_global, "global" + ) + + # F3: Dienstübergreifende Fairness - Lokal + fairness_abweichung_gesamt_lokal = self._add_constraint_gesamtfairness( + prob, x, ziel_dienste_lokal, "lokal" ) # Zielfunktion erstellen - 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) print(f"Modell erstellt mit {len(x)} Variablen und {len(prob.constraints)} Constraints") - return prob, x + return prob, x, ziel_dienste_lokal, ziel_dienste_global def löse_optimierung(self, prob: pulp.LpProblem, x: Dict[Tuple[str, date, Dienst], pulp.LpVariable]) -> Optional[Dict[date, Dict[Dienst, List[str]]]]: @@ -572,6 +649,87 @@ class Elterndienstplaner: faktor_summe = sum(self.dienstfaktoren[eltern][tag] for tag in self.tage) print(f" {eltern:15} {faktor_summe:.1f}") + def visualisiere_verteilungen( + self, + lösung: Dict[date, Dict[Dienst, List[str]]], + ziel_lokal: DefaultDict[str, DefaultDict[Dienst, float]], + ziel_global: DefaultDict[str, DefaultDict[Dienst, float]] + ) -> None: + """Visualisiert die Verteilungen als Tabelle zum Vergleich + + Args: + lösung: Die tatsächliche Lösung nach Optimierung + ziel_lokal: Lokale Zielverteilung (nur aktueller Monat) + ziel_global: Globale Zielverteilung (inkl. Historie) + """ + # Tatsächliche Dienste zählen + tatsaechlich = defaultdict(lambda: defaultdict(int)) + for tag_dienste in lösung.values(): + for dienst, eltern_liste in tag_dienste.items(): + for eltern in eltern_liste: + tatsaechlich[eltern][dienst] += 1 + + print("\n" + "="*110) + print("VERTEILUNGSVERGLEICH: SOLL vs. IST") + print("="*110) + + for dienst in self.dienste: + print(f"\n{dienst.name} ({dienst.kuerzel}):") + print(f"{'Eltern':<20} {'Ziel Global':>12} {'Ziel Lokal':>12} {'Tatsächlich':>12} " + f"{'Δ Global':>12} {'Δ Lokal':>12}") + print("-" * 110) + + for eltern in sorted(self.eltern): + z_global = ziel_global[eltern][dienst] + z_lokal = ziel_lokal[eltern][dienst] + ist = tatsaechlich[eltern][dienst] + delta_global = ist - z_global + delta_lokal = ist - z_lokal + + # Farbcodierung für Abweichungen (ANSI-Codes) + farbe_global = "" + farbe_lokal = "" + reset = "" + + if abs(delta_global) > 0.5: + farbe_global = "\033[93m" if abs(delta_global) <= 1.0 else "\033[91m" # Gelb oder Rot + reset = "\033[0m" + + if abs(delta_lokal) > 0.5: + farbe_lokal = "\033[93m" if abs(delta_lokal) <= 1.0 else "\033[91m" + reset = "\033[0m" + + print(f"{eltern:<20} {z_global:>12.2f} {z_lokal:>12.2f} {ist:>12} " + f"{farbe_global}{delta_global:>+12.2f}{reset} {farbe_lokal}{delta_lokal:>+12.2f}{reset}") + + # Summen + summe_z_global = sum(ziel_global[e][dienst] for e in self.eltern) + summe_z_lokal = sum(ziel_lokal[e][dienst] for e in self.eltern) + summe_ist = sum(tatsaechlich[e][dienst] for e in self.eltern) + + print("-" * 110) + print(f"{'SUMME':<20} {summe_z_global:>12.2f} {summe_z_lokal:>12.2f} {summe_ist:>12} " + f"{summe_ist - summe_z_global:>+12.2f} {summe_ist - summe_z_lokal:>+12.2f}") + + # Gesamtstatistik + print("\n" + "="*110) + print("ZUSAMMENFASSUNG") + print("="*110) + + # Maximale Abweichungen finden + max_abw_global = 0 + max_abw_lokal = 0 + + for eltern in self.eltern: + for dienst in self.dienste: + ist = tatsaechlich[eltern][dienst] + max_abw_global = max(max_abw_global, abs(ist - ziel_global[eltern][dienst])) + max_abw_lokal = max(max_abw_lokal, abs(ist - ziel_lokal[eltern][dienst])) + + print(f"Maximale Abweichung von Global-Ziel: {max_abw_global:.2f} Dienste") + print(f"Maximale Abweichung von Lokal-Ziel: {max_abw_lokal:.2f} Dienste") + print("\nLegende: Δ = Tatsächlich - Ziel (positiv = mehr als Ziel, negativ = weniger als Ziel)") + def main() -> None: if len(sys.argv) < 4: @@ -596,13 +754,17 @@ def main() -> None: planer.lade_vorherige_ausgaben_csv(vorherige_datei) # Optimierung - prob, x = planer.erstelle_optimierungsmodell() + prob, x, ziel_lokal, ziel_global = planer.erstelle_optimierungsmodell() lösung = planer.löse_optimierung(prob, x) if lösung is not None: # Ergebnisse ausgeben planer.schreibe_ausgabe_csv(ausgabe_datei, lösung) planer.drucke_statistiken(lösung) + + # Visualisierung der Verteilungen + planer.visualisiere_verteilungen(lösung, ziel_lokal, ziel_global) + print("\n✓ Planung erfolgreich abgeschlossen!") else: print("\n✗ Fehler: Keine gültige Lösung gefunden!") From 613ffef9b6344b65da6532ced85874ee0b5be04b Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Wed, 24 Dec 2025 22:18:05 +0100 Subject: [PATCH 14/20] Reale Daten korrigiert. " und " als Namensseparator. --- csv_io.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/csv_io.py b/csv_io.py index f02e35f..23d0c0a 100644 --- a/csv_io.py +++ b/csv_io.py @@ -210,8 +210,8 @@ class EingabeParser: # Zugeteilte Dienste zählen UND mit Datum speichern for dienst, spalte_idx in dienst_spalten.items(): if spalte_idx < len(row) and row[spalte_idx].strip(): - # Mehrere Eltern können in einer Zelle stehen (durch Leerzeichen getrennt) - eltern_liste = row[spalte_idx].strip().split() + # Mehrere Eltern können in einer Zelle stehen (durch " und " getrennt) + eltern_liste = row[spalte_idx].strip().split(' und ') for eltern_name in eltern_liste: if eltern_name in eltern: # Summierung für Kompatibilität @@ -265,7 +265,7 @@ class AusgabeWriter: for dienst in dienste: if tag in lösung and dienst in lösung[tag]: - eltern_str = ' '.join(lösung[tag][dienst]) + eltern_str = ' und '.join(lösung[tag][dienst]) else: eltern_str = '' row.append(eltern_str) From 33b8a0047c027a4a01959bfe5abbd07735cc9967 Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Wed, 24 Dec 2025 23:56:11 +0100 Subject: [PATCH 15/20] praeferenz-statistik und schonfrist --- elterndienstplaner.py | 115 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/elterndienstplaner.py b/elterndienstplaner.py index 2b617f3..132c179 100755 --- a/elterndienstplaner.py +++ b/elterndienstplaner.py @@ -479,8 +479,8 @@ class Elterndienstplaner: objective_terms = [] # Fairness-Gewichtung - gewicht_global = 10 - gewicht_lokal = 50 + gewicht_global = 40 + gewicht_lokal = 60 gewicht_f1 = gewicht_global gewicht_f2 = gewicht_lokal gewicht_f3_global = 0.25 * gewicht_global @@ -649,6 +649,114 @@ class Elterndienstplaner: faktor_summe = sum(self.dienstfaktoren[eltern][tag] for tag in self.tage) print(f" {eltern:15} {faktor_summe:.1f}") + def visualisiere_praeferenz_verletzungen( + self, + lösung: Dict[date, Dict[Dienst, List[str]]] + ) -> None: + """Visualisiert verletzte Präferenzen als Tabelle + + Args: + lösung: Die tatsächliche Lösung nach Optimierung + """ + print("\n" + "="*110) + print("PRÄFERENZ-VERLETZUNGEN") + print("="*110) + + # Sammle alle zugeteilten Dienste pro Eltern + zugeteilte_dienste = defaultdict(lambda: defaultdict(list)) # eltern -> dienst -> [dates] + for tag, tag_dienste in lösung.items(): + for dienst, eltern_liste in tag_dienste.items(): + for eltern in eltern_liste: + zugeteilte_dienste[eltern][dienst].append(tag) + + # Sammle Präferenzen strukturiert + # praeferenzen_pro_eltern_dienst[eltern][dienst] = {datum: präf_wert} + praeferenzen_pro_eltern_dienst = defaultdict(lambda: defaultdict(dict)) + for (eltern, tag, dienst), präf in self.präferenzen.items(): + praeferenzen_pro_eltern_dienst[eltern][dienst][tag] = präf + + # Berechne Verletzungen + verletzungen = defaultdict(lambda: defaultdict(lambda: {'negativ': 0, 'positiv_nicht_erfuellt': 0})) + + for eltern in sorted(self.eltern): + for dienst in self.dienste: + zugeteilte_tage = zugeteilte_dienste[eltern][dienst] + praeferenzen_dienst = praeferenzen_pro_eltern_dienst[eltern][dienst] + + if not zugeteilte_tage: + continue # Keine Dienste zugeteilt + + # a) Negative Präferenzen die verletzt wurden + for tag in zugeteilte_tage: + if tag in praeferenzen_dienst and praeferenzen_dienst[tag] == -1: + verletzungen[eltern][dienst]['negativ'] += 1 + + # b) Positive Präferenzen nicht erfüllt (Dienst an nicht-präferiertem Tag) + # Sammle alle Tage mit positiver Präferenz für diesen Dienst + positive_praef_tage = {tag for tag, präf in praeferenzen_dienst.items() if präf == 1} + + if positive_praef_tage: # Es gibt positive Präferenzen + # Prüfe ob ALLE zugeteilten Dienste an nicht-präferierten Tagen sind + for tag in zugeteilte_tage: + if tag not in positive_praef_tage: + # Dienst wurde an nicht-präferiertem Tag zugeteilt + verletzungen[eltern][dienst]['positiv_nicht_erfuellt'] += 1 + + # Tabelle ausgeben + print(f"\n{'Eltern':<20} ", end='') + for dienst in self.dienste: + print(f"{dienst.kuerzel:>12}", end='') + print() + print(f"{'':20} ", end='') + for dienst in self.dienste: + print(f"{'neg, pos':>12}", end='') + print() + print("-" * (20 + 12 * len(self.dienste))) + + gesamt_negativ = defaultdict(int) + gesamt_positiv = defaultdict(int) + + for eltern in sorted(self.eltern): + print(f"{eltern:<20} ", end='') + for dienst in self.dienste: + neg = verletzungen[eltern][dienst]['negativ'] + pos = verletzungen[eltern][dienst]['positiv_nicht_erfuellt'] + + gesamt_negativ[dienst] += neg + gesamt_positiv[dienst] += pos + + # Farbcodierung + farbe = "" + reset = "" + if neg > 0 or pos > 0: + farbe = "\033[91m" if neg > 0 else "\033[93m" # Rot für negativ, Gelb für positiv + reset = "\033[0m" + + print(f"{farbe}{neg:>3}, {pos:>3}{reset:>6}", end='') + print() + + # Summenzeile + print("-" * (20 + 12 * len(self.dienste))) + print(f"{'SUMME':<20} ", end='') + for dienst in self.dienste: + neg = gesamt_negativ[dienst] + pos = gesamt_positiv[dienst] + + farbe = "" + reset = "" + if neg > 0 or pos > 0: + farbe = "\033[91m" if neg > 0 else "\033[93m" + reset = "\033[0m" + + print(f"{farbe}{neg:>3}, {pos:>3}{reset:>6}", end='') + print() + + print("\nLegende:") + print(" neg = Anzahl negativer Präferenzen (abgelehnte Tage), die verletzt wurden") + print(" pos = Anzahl Dienste an nicht-präferierten Tagen (obwohl präferierte Tage angegeben waren)") + print(" \033[91mRot\033[0m = Negative Präferenz verletzt") + print(" \033[93mGelb\033[0m = Positive Präferenz nicht erfüllt") + def visualisiere_verteilungen( self, lösung: Dict[date, Dict[Dienst, List[str]]], @@ -765,6 +873,9 @@ def main() -> None: # Visualisierung der Verteilungen planer.visualisiere_verteilungen(lösung, ziel_lokal, ziel_global) + # Visualisierung der Präferenz-Verletzungen + planer.visualisiere_praeferenz_verletzungen(lösung) + print("\n✓ Planung erfolgreich abgeschlossen!") else: print("\n✗ Fehler: Keine gültige Lösung gefunden!") From 367e3cd316ea1ff3af8462b871768b41785f7a10 Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Thu, 25 Dec 2025 00:06:21 +0100 Subject: [PATCH 16/20] vorherige_dienste verneinfacht --- csv_io.py | 15 ++++----------- elterndienstplaner.py | 11 +++++++---- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/csv_io.py b/csv_io.py index 23d0c0a..7fbb338 100644 --- a/csv_io.py +++ b/csv_io.py @@ -164,10 +164,7 @@ class EingabeParser: datei: str, eltern: List[str], dienste: List, # List[Dienst] - ) -> Tuple[ - DefaultDict[str, DefaultDict], # vorherige_dienste - List[Tuple[date, str, any]] # historische_dienste (mit Dienst-Objekt) - ]: + ) -> List[Tuple[date, str, any]]: # historische_dienste (mit Dienst-Objekt) """ Lädt vorherige-ausgaben.csv für Fairness-Constraints @@ -177,11 +174,10 @@ class EingabeParser: dienste: Liste der Dienst-Objekte Returns: - Tuple mit (vorherige_dienste, historische_dienste) + Liste der historischen Dienste """ print(f"Lade vorherige Ausgaben aus {datei}...") - vorherige_dienste = defaultdict(lambda: defaultdict(int)) historische_dienste = [] try: @@ -207,25 +203,22 @@ class EingabeParser: except ValueError: continue - # Zugeteilte Dienste zählen UND mit Datum speichern + # Zugeteilte Dienste mit Datum speichern for dienst, spalte_idx in dienst_spalten.items(): if spalte_idx < len(row) and row[spalte_idx].strip(): # Mehrere Eltern können in einer Zelle stehen (durch " und " getrennt) eltern_liste = row[spalte_idx].strip().split(' und ') for eltern_name in eltern_liste: if eltern_name in eltern: - # Summierung für Kompatibilität - vorherige_dienste[eltern_name][dienst] += 1 # Historische Dienste mit Datum speichern historische_dienste.append((datum, eltern_name, dienst)) except FileNotFoundError: print("Keine vorherigen Ausgaben gefunden - starte ohne historische Daten") - print(f"Vorherige Dienste geladen: {dict(vorherige_dienste)}") print(f"Historische Dienste mit Datum: {len(historische_dienste)} Einträge") - return vorherige_dienste, historische_dienste + return historische_dienste class AusgabeWriter: diff --git a/elterndienstplaner.py b/elterndienstplaner.py index 132c179..2ca2bfa 100755 --- a/elterndienstplaner.py +++ b/elterndienstplaner.py @@ -57,8 +57,6 @@ class Elterndienstplaner: # Wenn es tag nicht gibt -> default 0.0 self.dienstfaktoren: Dict[str, DefaultDict[date, float]] = {} self.alle_zeitraeume: Dict[str, List[Tuple[date, date, float]]] = {} - self.vorherige_dienste: DefaultDict[str, DefaultDict[Dienst, int]] = \ - defaultdict(lambda: defaultdict(int)) self.historische_dienste: List[Tuple[date, str, Dienst]] = [] def get_dienst(self, kuerzel: str) -> Optional[Dienst]: @@ -92,7 +90,7 @@ class Elterndienstplaner: def lade_vorherige_ausgaben_csv(self, datei: str) -> None: """Lädt vorherige-ausgaben.csv für Fairness-Constraints""" - self.vorherige_dienste, self.historische_dienste = \ + self.historische_dienste = \ EingabeParser.parse_vorherige_ausgaben_csv(datei, self.eltern, self.dienste) def berechne_dienstfaktor_an_datum(self, eltern: str, datum: date) -> float: @@ -193,7 +191,12 @@ class Elterndienstplaner: # 3. ABZUG DER BEREITS GELEISTETEN DIENSTE # Ziehe die tatsächlich geleisteten Dienste ab, um das Ziel für den Planungszeitraum zu erhalten for eltern in self.eltern: - ziel_dienste[eltern][dienst] -= self.vorherige_dienste[eltern][dienst] + # Berechne vorherige Dienste on-the-fly aus historischen Diensten + vorherige_anzahl = sum( + 1 for _, hist_eltern, hist_dienst in self.historische_dienste + if hist_eltern == eltern and hist_dienst == dienst + ) + ziel_dienste[eltern][dienst] -= vorherige_anzahl return ziel_dienste From 909e8ff9a0fb51455c98a0e290172a6a6683dbf6 Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Thu, 25 Dec 2025 19:45:36 +0100 Subject: [PATCH 17/20] redundante daten der dienstfaktoren entfernt --- csv_io.py | 37 ++++++++--------------- elterndienstplaner.py | 69 +++++++++++++++++++------------------------ 2 files changed, 42 insertions(+), 64 deletions(-) diff --git a/csv_io.py b/csv_io.py index 7fbb338..4b2ed6c 100644 --- a/csv_io.py +++ b/csv_io.py @@ -5,7 +5,7 @@ Trennt CSV-Parsing und -Schreiben von der Business-Logik """ import csv -from datetime import datetime, date +from datetime import datetime, date, timedelta from typing import Dict, List, Tuple, DefaultDict from collections import defaultdict @@ -97,24 +97,20 @@ class EingabeParser: return eltern, tage, benoetigte_dienste, verfügbarkeit, präferenzen @staticmethod - def parse_eltern_csv(datei: str, tage: List[date]) -> Tuple[ - Dict[str, Dict[date, float]], # dienstfaktoren - Dict[str, List[Tuple[date, date, float]]] # alle_zeitraeume - ]: + def parse_eltern_csv(datei: str) -> Dict[str, DefaultDict[date, float]]: """ Lädt die eltern.csv mit Dienstfaktoren Args: datei: Pfad zur eltern.csv - tage: Liste der Planungstage Returns: - Tuple mit (dienstfaktoren, alle_zeitraeume) + Dictionary mit Dienstfaktoren für alle Tage in den definierten Zeiträumen + (DefaultDict gibt 0 für Tage außerhalb der Zeiträume zurück) """ print(f"Lade Elterndaten aus {datei}...") dienstfaktoren = {} - alle_zeitraeume = {} with open(datei, 'r', encoding='utf-8') as f: reader = csv.reader(f) @@ -126,9 +122,8 @@ class EingabeParser: eltern_name = row[0].strip() - # Initialisiere Datenstrukturen - dienstfaktoren[eltern_name] = {} - alle_zeitraeume[eltern_name] = [] + # Initialisiere mit DefaultDict (Standard: 0) + dienstfaktoren[eltern_name] = defaultdict(float) # Alle Zeiträume einlesen (jeweils 3 Spalten: Beginn, Ende, Faktor) for i in range(1, len(row), 3): @@ -138,26 +133,18 @@ class EingabeParser: ende = datetime.strptime(row[i + 1].strip(), '%Y-%m-%d').date() faktor = float(row[i + 2].strip()) if row[i + 2].strip() else 0 - # Zeitraum speichern - alle_zeitraeume[eltern_name].append((beginn, ende, faktor)) - - # Faktor für Tage im aktuellen Planungsmonat setzen - for tag in tage: - if beginn <= tag <= ende: - dienstfaktoren[eltern_name][tag] = faktor + # Faktor für alle Tage im Zeitraum setzen/überschreiben + aktueller_tag = beginn + while aktueller_tag <= ende: + dienstfaktoren[eltern_name][aktueller_tag] = faktor + aktueller_tag += timedelta(days=1) except (ValueError, IndexError): continue - # Tage ohne expliziten Faktor auf 0 setzen - for tag in tage: - if tag not in dienstfaktoren[eltern_name]: - dienstfaktoren[eltern_name][tag] = 0 - print(f"Dienstfaktoren geladen für {len(dienstfaktoren)} Eltern") - print(f"Zeiträume gespeichert für globale Fairness-Berechnung") - return dienstfaktoren, alle_zeitraeume + return dienstfaktoren @staticmethod def parse_vorherige_ausgaben_csv( diff --git a/elterndienstplaner.py b/elterndienstplaner.py index 2ca2bfa..eabbc96 100755 --- a/elterndienstplaner.py +++ b/elterndienstplaner.py @@ -46,7 +46,7 @@ class Elterndienstplaner: ] # Datenstrukturen - self.tage: List[date] = [] + self.planungszeitraum: List[date] = [] self.eltern: List[str] = [] self.benoetigte_dienste: Dict[date, List[Dienst]] = {} self.verfügbarkeit: Dict[Tuple[str, date], bool] = {} @@ -56,7 +56,6 @@ class Elterndienstplaner: # Wenn es eltern nicht gibt -> keyerror # Wenn es tag nicht gibt -> default 0.0 self.dienstfaktoren: Dict[str, DefaultDict[date, float]] = {} - self.alle_zeitraeume: Dict[str, List[Tuple[date, date, float]]] = {} self.historische_dienste: List[Tuple[date, str, Dienst]] = [] def get_dienst(self, kuerzel: str) -> Optional[Dienst]: @@ -80,13 +79,12 @@ class Elterndienstplaner: def lade_eingabe_csv(self, datei: str) -> None: """Lädt die eingabe.csv mit Terminen und Präferenzen""" - self.eltern, self.tage, self.benoetigte_dienste, self.verfügbarkeit, self.präferenzen = \ + self.eltern, self.planungszeitraum, self.benoetigte_dienste, self.verfügbarkeit, self.präferenzen = \ EingabeParser.parse_eingabe_csv(datei, self.get_dienst) def lade_eltern_csv(self, datei: str) -> None: """Lädt die eltern.csv mit Dienstfaktoren""" - self.dienstfaktoren, self.alle_zeitraeume = \ - EingabeParser.parse_eltern_csv(datei, self.tage) + self.dienstfaktoren = EingabeParser.parse_eltern_csv(datei) def lade_vorherige_ausgaben_csv(self, datei: str) -> None: """Lädt vorherige-ausgaben.csv für Fairness-Constraints""" @@ -95,13 +93,9 @@ class Elterndienstplaner: def berechne_dienstfaktor_an_datum(self, eltern: str, datum: date) -> float: """Berechnet den Dienstfaktor eines Elternteils an einem bestimmten Datum""" - if eltern not in self.alle_zeitraeume: + if eltern not in self.dienstfaktoren: return 0 - - for beginn, ende, faktor in self.alle_zeitraeume[eltern]: - if beginn <= datum <= ende: - return faktor - return 0 + return self.dienstfaktoren[eltern][datum] # DefaultDict gibt 0 zurück für unbekannte Tage def berechne_faire_zielverteilung_global(self) -> DefaultDict[str, DefaultDict[Dienst, float]]: """Berechnet die faire Zielanzahl von Diensten für den Planungszeitraum @@ -137,35 +131,32 @@ class Elterndienstplaner: dienste_pro_tag[datum].append(eltern) # Für jeden historischen Tag faire Umverteilung berechnen - for hist_datum, geleistete_eltern in dienste_pro_tag.items(): + for tag, geleistete_eltern in dienste_pro_tag.items(): anzahl_dienste = len(geleistete_eltern) # Anzahl Dienste an diesem Tag # Dienstfaktoren aller Eltern für diesen historischen Tag berechnen - dienstfaktoren_tag = {} gesamt_dienstfaktor_tag = 0 for eltern in self.eltern: - faktor = self.berechne_dienstfaktor_an_datum(eltern, hist_datum) - dienstfaktoren_tag[eltern] = faktor - gesamt_dienstfaktor_tag += faktor + gesamt_dienstfaktor_tag += self.dienstfaktoren[eltern][tag] # Faire Umverteilung der an diesem Tag geleisteten Dienste if gesamt_dienstfaktor_tag > 0: for eltern in self.eltern: - if dienstfaktoren_tag[eltern] > 0: - anteil = dienstfaktoren_tag[eltern] / gesamt_dienstfaktor_tag + if self.dienstfaktoren[eltern][tag] > 0: + anteil = self.dienstfaktoren[eltern][tag] / gesamt_dienstfaktor_tag faire_zuteilung = anteil * anzahl_dienste ziel_dienste[eltern][dienst] += faire_zuteilung if faire_zuteilung > 0.01: # Debug nur für relevante Werte - print(f" {hist_datum}: {eltern} Faktor={dienstfaktoren_tag[eltern]} " + print(f" {tag}: {eltern} Faktor={self.dienstfaktoren[eltern][tag]} " f"-> {faire_zuteilung:.2f} von {anzahl_dienste} Diensten") # 2. AKTUELLER MONAT: Faire Verteilung der benötigten Dienste (tageweise wie bei historischen Diensten) benoetigte_dienste_monat = 0 # Für jeden Tag im aktuellen Monat faire Umverteilung berechnen - for tag in self.tage: + for tag in self.planungszeitraum: # Prüfe ob an diesem Tag der Dienst benötigt wird if dienst not in self.benoetigte_dienste.get(tag, []): continue @@ -173,18 +164,18 @@ class Elterndienstplaner: benoetigte_dienste_monat += dienst.personen_anzahl # Dienstfaktoren aller Eltern für diesen Tag berechnen - dienstfaktoren_tag = {} + dienstfaktoren = {} gesamt_dienstfaktor_tag = 0 for eltern in self.eltern: faktor = self.dienstfaktoren[eltern][tag] - dienstfaktoren_tag[eltern] = faktor + dienstfaktoren[eltern] = faktor gesamt_dienstfaktor_tag += faktor # Faire Umverteilung der an diesem Tag benötigten Dienste if gesamt_dienstfaktor_tag > 0: for eltern in self.eltern: - anteil = dienstfaktoren_tag[eltern] / gesamt_dienstfaktor_tag + anteil = dienstfaktoren[eltern] / gesamt_dienstfaktor_tag faire_zuteilung = anteil * dienst.personen_anzahl ziel_dienste[eltern][dienst] += faire_zuteilung @@ -211,7 +202,7 @@ class Elterndienstplaner: # Gesamtdienstfaktor für aktuellen Monat berechnen gesamt_dienstfaktor_monat = sum( - sum(self.dienstfaktoren[e][tag] for tag in self.tage) + sum(self.dienstfaktoren[e][tag] for tag in self.planungszeitraum) for e in self.eltern ) @@ -223,7 +214,7 @@ class Elterndienstplaner: for dienst in self.dienste: # Anzahl benötigter Dienste im aktuellen Monat benoetigte_dienste_monat = sum( - 1 for tag in self.tage + 1 for tag in self.planungszeitraum if dienst in self.benoetigte_dienste.get(tag, []) ) # Multipliziere mit Anzahl benötigter Personen pro Dienst @@ -235,7 +226,7 @@ class Elterndienstplaner: for eltern in self.eltern: # Dienstfaktor für diesen Elternteil im aktuellen Monat monatlicher_dienstfaktor = sum( - self.dienstfaktoren[eltern][tag] for tag in self.tage + self.dienstfaktoren[eltern][tag] for tag in self.planungszeitraum ) if monatlicher_dienstfaktor > 0: @@ -249,7 +240,7 @@ class Elterndienstplaner: """Erstellt die binären Entscheidungsvariablen x[eltern, tag, dienst]""" x: Dict[Tuple[str, date, Dienst], pulp.LpVariable] = {} for eltern in self.eltern: - for tag in self.tage: + for tag in self.planungszeitraum: for dienst in self.dienste: if dienst in self.benoetigte_dienste.get(tag, []): x[eltern, tag, dienst] = pulp.LpVariable( @@ -264,13 +255,13 @@ class Elterndienstplaner: x: Dict[Tuple[str, date, Dienst], pulp.LpVariable] ) -> None: """C1: Je Eltern und Dienst nur einmal die Woche (Woche = Montag bis Sonntag)""" - erster_tag = self.tage[0] + erster_tag = self.planungszeitraum[0] # weekday(): 0=Montag, 6=Sonntag # Finde Montag am oder vor dem ersten Planungstag (für historische Dienste) woche_start = erster_tag - timedelta(days=erster_tag.weekday()) woche_nr = 0 - letzter_tag = self.tage[-1] + letzter_tag = self.planungszeitraum[-1] while woche_start <= letzter_tag: woche_ende = woche_start + timedelta(days=6) # Sonntag @@ -289,7 +280,7 @@ class Elterndienstplaner: historische_dienste_in_woche += 1 # Sammle Variablen für Planungszeitraum in dieser Woche - for tag in self.tage: + for tag in self.planungszeitraum: if woche_start <= tag <= woche_ende: if (eltern, tag, dienst) in x: woche_vars.append(x[eltern, tag, dienst]) @@ -309,7 +300,7 @@ class Elterndienstplaner: ) -> None: """C2: Je Eltern nur einen Dienst am Tag""" for eltern in self.eltern: - for tag in self.tage: + for tag in self.planungszeitraum: tag_vars = [] for dienst in self.dienste: if (eltern, tag, dienst) in x: @@ -325,7 +316,7 @@ class Elterndienstplaner: ) -> None: """C3: Dienste nur verfügbaren Eltern zuteilen""" for eltern in self.eltern: - for tag in self.tage: + for tag in self.planungszeitraum: if not self.verfügbarkeit.get((eltern, tag), True): for dienst in self.dienste: if (eltern, tag, dienst) in x: @@ -338,7 +329,7 @@ class Elterndienstplaner: x: Dict[Tuple[str, date, Dienst], pulp.LpVariable] ) -> None: """C4: Alle benötigten Dienste müssen zugeteilt werden""" - for tag in self.tage: + for tag in self.planungszeitraum: for dienst in self.benoetigte_dienste.get(tag, []): dienst_vars = [] for eltern in self.eltern: @@ -386,7 +377,7 @@ class Elterndienstplaner: # Tatsächliche Dienste im aktuellen Monat tatsaechliche_dienste_monat = pulp.lpSum( x[eltern, tag, dienst] - for tag in self.tage + for tag in self.planungszeitraum if (eltern, tag, dienst) in x ) @@ -431,7 +422,7 @@ class Elterndienstplaner: # Tatsächliche Gesamtdienste für diesen Elternteil tatsaechliche_dienste_gesamt = pulp.lpSum( x[eltern, tag, dienst] - for tag in self.tage + for tag in self.planungszeitraum for dienst in self.dienste if (eltern, tag, dienst) in x ) @@ -449,7 +440,7 @@ class Elterndienstplaner: def _berechne_fairness_gewichte(self) -> Tuple[int, int, int, int]: """Berechnet Gewichtung basierend auf Jahreszeit (Sep-Jul Schuljahr)""" - aktueller_monat = self.tage[0].month if self.tage else 1 + aktueller_monat = self.planungszeitraum[0].month if self.planungszeitraum else 1 if 9 <= aktueller_monat <= 12: # Sep-Dez: Jahresanfang gewicht_f1 = 100 # Global wichtiger @@ -534,7 +525,7 @@ class Elterndienstplaner: # Debugging: Verfügbarkeit prüfen print("\nDebug: Verfügbarkeit analysieren...") - for tag in self.tage[:5]: # Erste 5 Tage + for tag in self.planungszeitraum[:5]: # Erste 5 Tage verfügbare = [e for e in self.eltern if self.verfügbarkeit.get((e, tag), True)] benötigte = self.benoetigte_dienste.get(tag, []) print(f" {tag}: Benötigt {len(benötigte)} Dienste {benötigte}, verfügbar: {verfügbare}") @@ -623,7 +614,7 @@ class Elterndienstplaner: def schreibe_ausgabe_csv(self, datei: str, lösung: Dict[date, Dict[Dienst, List[str]]]) -> None: """Schreibt die Lösung in die ausgabe.csv""" - AusgabeWriter.schreibe_ausgabe_csv(datei, lösung, self.tage, self.dienste) + AusgabeWriter.schreibe_ausgabe_csv(datei, lösung, self.planungszeitraum, self.dienste) def drucke_statistiken(self, lösung: Dict[date, Dict[Dienst, List[str]]]) -> None: """Druckt Statistiken zur Lösung""" @@ -649,7 +640,7 @@ class Elterndienstplaner: # Dienstfaktor-Analyse print(f"\nDienstfaktoren im Planungszeitraum:") for eltern in sorted(self.eltern): - faktor_summe = sum(self.dienstfaktoren[eltern][tag] for tag in self.tage) + faktor_summe = sum(self.dienstfaktoren[eltern][tag] for tag in self.planungszeitraum) print(f" {eltern:15} {faktor_summe:.1f}") def visualisiere_praeferenz_verletzungen( From 2f0609b570ead228046ef973d91537f7c05f5709 Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Thu, 25 Dec 2025 19:53:05 +0100 Subject: [PATCH 18/20] refactoring: monat in planungszeitraum umbenannt --- elterndienstplaner.py | 71 +++++++++++++++---------------------------- 1 file changed, 25 insertions(+), 46 deletions(-) diff --git a/elterndienstplaner.py b/elterndienstplaner.py index eabbc96..06bd519 100755 --- a/elterndienstplaner.py +++ b/elterndienstplaner.py @@ -99,7 +99,7 @@ class Elterndienstplaner: def berechne_faire_zielverteilung_global(self) -> DefaultDict[str, DefaultDict[Dienst, float]]: """Berechnet die faire Zielanzahl von Diensten für den Planungszeitraum - basierend auf globaler Fairness (Historie + aktueller Monat). + basierend auf globaler Fairness (Historie + aktueller Planungszeitraum). Gibt die Ziel-Dienstanzahl für den aktuellen Planungszeitraum zurück, korrigiert um bereits geleistete Dienste. Kann negativ sein, wenn bereits @@ -152,16 +152,16 @@ class Elterndienstplaner: print(f" {tag}: {eltern} Faktor={self.dienstfaktoren[eltern][tag]} " f"-> {faire_zuteilung:.2f} von {anzahl_dienste} Diensten") - # 2. AKTUELLER MONAT: Faire Verteilung der benötigten Dienste (tageweise wie bei historischen Diensten) - benoetigte_dienste_monat = 0 + # 2. AKTUELLER PLANUNGSZEITRAUM: Faire Verteilung der benötigten Dienste (tageweise wie bei historischen Diensten) + benoetigte_dienste_planungszeitraum = 0 - # Für jeden Tag im aktuellen Monat faire Umverteilung berechnen + # Für jeden Tag im aktuellen Planungszeitraum faire Umverteilung berechnen for tag in self.planungszeitraum: # Prüfe ob an diesem Tag der Dienst benötigt wird if dienst not in self.benoetigte_dienste.get(tag, []): continue - benoetigte_dienste_monat += dienst.personen_anzahl + benoetigte_dienste_planungszeitraum += dienst.personen_anzahl # Dienstfaktoren aller Eltern für diesen Tag berechnen dienstfaktoren = {} @@ -193,45 +193,45 @@ class Elterndienstplaner: def berechne_faire_zielverteilung_lokal(self) -> DefaultDict[str, DefaultDict[Dienst, float]]: """Berechnet die lokale faire Zielanzahl von Diensten pro Eltern-Dienst-Kombination - basierend auf Dienstfaktoren und benötigten Diensten im aktuellen Planungsmonat""" + basierend auf Dienstfaktoren und benötigten Diensten im aktuellen Planungszeitraum""" ziel_dienste_lokal: DefaultDict[str, DefaultDict[Dienst, float]] = \ defaultdict(lambda: defaultdict(float)) - print("\nBerechne lokale faire Zielverteilung für aktuellen Monat...") + print("\nBerechne lokale faire Zielverteilung für aktuellen Planungszeitraum...") - # Gesamtdienstfaktor für aktuellen Monat berechnen - gesamt_dienstfaktor_monat = sum( + # Gesamtdienstfaktor für aktuellen Planungszeitraum berechnen + summe_dienstfaktor_planungszeitraum_alle_eltern = sum( sum(self.dienstfaktoren[e][tag] for tag in self.planungszeitraum) for e in self.eltern ) - if gesamt_dienstfaktor_monat == 0: + if summe_dienstfaktor_planungszeitraum_alle_eltern == 0: print(" WARNUNG: Gesamtdienstfaktor ist 0, keine lokale Zielverteilung möglich") return ziel_dienste_lokal # Für jeden Dienst die lokale faire Verteilung berechnen for dienst in self.dienste: - # Anzahl benötigter Dienste im aktuellen Monat - benoetigte_dienste_monat = sum( + # Anzahl benötigter Dienste im aktuellen Planungszeitraum + benoetigte_dienste_planungszeitraum = sum( 1 for tag in self.planungszeitraum if dienst in self.benoetigte_dienste.get(tag, []) ) # Multipliziere mit Anzahl benötigter Personen pro Dienst - benoetigte_dienste_monat *= dienst.personen_anzahl + benoetigte_dienste_planungszeitraum *= dienst.personen_anzahl - if benoetigte_dienste_monat > 0: - print(f" {dienst.kuerzel}: {benoetigte_dienste_monat} Dienste benötigt") + if benoetigte_dienste_planungszeitraum > 0: + print(f" {dienst.kuerzel}: {benoetigte_dienste_planungszeitraum} Dienste benötigt") for eltern in self.eltern: - # Dienstfaktor für diesen Elternteil im aktuellen Monat - monatlicher_dienstfaktor = sum( + # Dienstfaktor für diesen Elternteil im aktuellen Planungszeitraum + summe_dienstfaktor_planungszeitraum = sum( self.dienstfaktoren[eltern][tag] for tag in self.planungszeitraum ) - if monatlicher_dienstfaktor > 0: - anteil = monatlicher_dienstfaktor / gesamt_dienstfaktor_monat - faire_zuteilung = anteil * benoetigte_dienste_monat + if summe_dienstfaktor_planungszeitraum > 0: + anteil = summe_dienstfaktor_planungszeitraum / summe_dienstfaktor_planungszeitraum_alle_eltern + faire_zuteilung = anteil * benoetigte_dienste_planungszeitraum ziel_dienste_lokal[eltern][dienst] = faire_zuteilung return ziel_dienste_lokal @@ -374,8 +374,8 @@ class Elterndienstplaner: # Fairness-Constraints hinzufügen for eltern in self.eltern: for dienst in self.dienste: - # Tatsächliche Dienste im aktuellen Monat - tatsaechliche_dienste_monat = pulp.lpSum( + # Tatsächliche Dienste im aktuellen Planungszeitraum + zugeteilte_dienste_planungszeitraum = pulp.lpSum( x[eltern, tag, dienst] for tag in self.planungszeitraum if (eltern, tag, dienst) in x @@ -383,9 +383,9 @@ class Elterndienstplaner: # Ziel für diese Fairness-Variante ziel = ziel_dienste[eltern][dienst] - prob += (tatsaechliche_dienste_monat - ziel <= + prob += (zugeteilte_dienste_planungszeitraum - ziel <= fairness_abweichung[eltern, dienst]) - prob += (ziel - tatsaechliche_dienste_monat <= + prob += (ziel - zugeteilte_dienste_planungszeitraum <= fairness_abweichung[eltern, dienst]) return fairness_abweichung @@ -438,27 +438,6 @@ class Elterndienstplaner: return fairness_abweichung_gesamt - def _berechne_fairness_gewichte(self) -> Tuple[int, int, int, int]: - """Berechnet Gewichtung basierend auf Jahreszeit (Sep-Jul Schuljahr)""" - aktueller_monat = self.planungszeitraum[0].month if self.planungszeitraum else 1 - - if 9 <= aktueller_monat <= 12: # Sep-Dez: Jahresanfang - gewicht_f1 = 100 # Global wichtiger - gewicht_f2 = 50 # Lokal weniger wichtig - gewicht_f3_global = 30 # Gesamtfairness global - gewicht_f3_lokal = 15 # Gesamtfairness lokal - elif 1 <= aktueller_monat <= 3: # Jan-Mar: Jahresmitte - gewicht_f1 = 75 - gewicht_f2 = 75 - gewicht_f3_global = 25 - gewicht_f3_lokal = 25 - else: # Apr-Jul: Jahresende - gewicht_f1 = 50 # Global weniger wichtig - gewicht_f2 = 100 # Lokal wichtiger - gewicht_f3_global = 15 - gewicht_f3_lokal = 30 - - return gewicht_f1, gewicht_f2, gewicht_f3_global, gewicht_f3_lokal def _erstelle_zielfunktion( self, @@ -761,7 +740,7 @@ class Elterndienstplaner: Args: lösung: Die tatsächliche Lösung nach Optimierung - ziel_lokal: Lokale Zielverteilung (nur aktueller Monat) + ziel_lokal: Lokale Zielverteilung (nur aktueller Planungszeitraum) ziel_global: Globale Zielverteilung (inkl. Historie) """ # Tatsächliche Dienste zählen From 0c538cab007d6c857f0f6b0e33279036f9337aa0 Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Thu, 25 Dec 2025 19:58:48 +0100 Subject: [PATCH 19/20] refactoring: dienstkuerzel aufloesen --- csv_io.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/csv_io.py b/csv_io.py index 4b2ed6c..8052e6b 100644 --- a/csv_io.py +++ b/csv_io.py @@ -64,12 +64,9 @@ class EingabeParser: continue # Benötigte Dienste - dienst_objekte = [] - for kuerzel in dienste_str: - dienst = dienste_lookup(kuerzel) - if dienst: - dienst_objekte.append(dienst) - benoetigte_dienste[datum_obj] = dienst_objekte + benoetigte_dienste[datum_obj] = [ + dienste_lookup(kuerzel) for kuerzel in dienste_str + ] # Verfügbarkeit und Präferenzen der Eltern for i, eltern_name in enumerate(eltern): From f4791cf7bb6df15ddd40f7f0e2206e277d834759 Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Thu, 25 Dec 2025 21:43:29 +0100 Subject: [PATCH 20/20] refactoring: Neue Architektur --- README.md | 206 +++++++++++------ STRUKTUR.md | 107 --------- ausgabe.py | 251 +++++++++++++++++++++ datenmodell.py | 114 ++++++++++ elterndienstplaner.py | 499 +++++++++--------------------------------- typen.py | 29 +++ 6 files changed, 634 insertions(+), 572 deletions(-) create mode 100644 ausgabe.py create mode 100644 datenmodell.py create mode 100644 typen.py diff --git a/README.md b/README.md index ba28ccf..4b8fac4 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,163 @@ -Elterndienstplaner +# Elterndienstplaner -syntax: ./elterndienstplaner.py { } +Automatische Zuteilung von Elterndiensten im Kinderladen unter Berücksichtigung von Fairness und Präferenzen. -Der Elterndienstplaner hilft bei der Zuteilung von Elterndiensten zu Eltern. -Zur Identifizierung der Eltern dient der Name des Kindes/der Kinder. +## Verwendung -Es gibt diese Dienste: - - F: Frühstücksdienst (täglich) - - P: Putznotdienst (täglich) - - E: Essensausgabenotdienst (täglich) - - K: Kochen (alle 2 wochen, Datum manuell festgelegt) - - A: Elternabend (2 Eltern, Datum manuell festgelegt) +```bash +./elterndienstplaner.py [] +``` -Die Planung erfolgt immer für einen Kalendermonat. +**Parameter:** +- `eingabe.csv`: Benötigte Dienste und Eltern-Präferenzen für den Planungsmonat +- `eltern.csv`: Dienstfaktoren der Eltern (Anzahl betreuter Kinder) +- `ausgabe.csv`: Hier wird die Zuteilung geschrieben +- `vorherige-ausgaben.csv` (optional): Historische Daten für Fairness über das Jahr -Die Eltern geben an, an welchen Tagen sie abwesend sind, also nicht zur Verfügung stehen. Zudem können sie für jede Tag-Dienst-Kombination angeben, ob sie den Dienst an diesen Tag bevorzugt (+) oder nur notfalls (-) machen wollen. +## Dienste -Die Eingabe erfolgt über eine CSV-Datei eingabe.csv und eltern.csv +- **F** - Frühstücksdienst (täglich, 1 Person) +- **P** - Putznotdienst (täglich, 1 Person) +- **E** - Essensausgabenotdienst (täglich, 1 Person) +- **K** - Kochen (ca. alle 2 Wochen, 1 Person) +- **A** - Elternabend (nach Bedarf, 2 Personen) -## eingabe.csv -Informationen zu notwendigen Diensten eines Monats und Zeiten/Praeferenzen der Eltern +Die Planung erfolgt für einen Kalendermonat. -1. Spalte: Datum in ISO-Format -2. Spalte: Wochentag (Hilfsinformation) -3. Spalte: Benötigte Dienste als aneinandergereihte Dienstkürzel -Folgende Spalten: Für alle Eltern: Verfügbarkeit und Präferenz: - - x, falls nicht verfügbar - - +, wenn Dienst an dem Tag bevorzugt. - - -, wenn Dienst an dem Tag abgelehnt. -Es können mehrere Präferenzen pro Tag angegeben werden. -1. Zeile header -folgende Zeilen je Tag. +## Eingabedateien +### eingabe.csv -## eltern.csv: -Informationen zur Diestpflicht der Eltern. -Die Dienstpflicht besteht, wenn Eltern Kinder im Kinderladen betreuen lassen. -Der Dienstfaktor entspricht der Anzahl der betreuten Kinder der Eltern. -Wenn Eltern ein Vorsandsamt im Kinderladen übernehmen, werden sie von der Dienstpflicht befreit. +Informationen zu benötigten Diensten und Verfügbarkeit/Präferenzen der Eltern. -1. Spalte Zeitraum Beginn -2. Spalte Zeitraum Ende -3. Spalte Dienstfaktor -4. Spalte ... nächster Zeitraum -1. Zeile Header -folgende Zeilen Eltern +**Format:** +``` +Datum,Wochentag,Dienste,Eltern1,Eltern2,... +2025-01-06,Montag,FPE,F+,x,... +2025-01-07,Dienstag,FPE,P-,F+P+,... +``` +**Spalten:** +1. Datum (ISO-Format: YYYY-MM-DD) +2. Wochentag (zur Information) +3. Benötigte Dienste (z.B. "FPE" für Frühstück, Putzen, Essen) +4-n. Für jeden Elternteil: + - `x` = nicht verfügbar + - `F+` = Frühstücksdienst bevorzugt + - `P-` = Putznotdienst nur notfalls + - Mehrere Präferenzen kombinierbar: `F+P-E+` + - Leer = verfügbar, keine Präferenz -Bei sich überschneidenden Zeiträumen gilt der letzte Eintrag. -An Tagen außerhalb der angegebenen Zeiträume ist der Dienstfaktor 0. +### eltern.csv -Die Datei eltern.csv enthält ggf. mehr Eltern als die Eingabe.csv, -da Kinder dazukommen oder den KiLa verlassen, die eltern.csv aber nur anwächst. +Dienstfaktoren (= Anzahl betreuter Kinder) pro Elternteil und Zeitraum. -## ausgabe.csv -1. Spalte: Datum -2. Spalte: Wochentag -3. Spalte ... Dienste -Zeilen: für jeden Tag die zugeteilten Eltern in den jeweiligen Dienstspalten +**Format:** +``` +Eltern,Beginn,Ende,Faktor,Beginn,Ende,Faktor,... +Müller,2024-09-01,2025-07-31,2 +Schmidt,2024-09-01,2024-12-31,1,2025-01-01,2025-07-31,0 +``` -## vorherige-ausgaben.csv -Hier werden die von früheren Läufen des Programms generierten ausgabe.csv-Datein wiedereingespielt. -Das Format entspricht der ausgabe.csv +**Spalten:** +1. Elternname (Kind-Name zur Identifikation) +2-4. Zeitraum 1: Beginn, Ende, Dienstfaktor +5-7. Zeitraum 2: Beginn, Ende, Dienstfaktor (optional) +... + +**Hinweise:** +- Bei überlappenden Zeiträumen gilt der letzte Eintrag +- Außerhalb definierter Zeiträume: Faktor = 0 (keine Dienstpflicht) +- Faktor = 0 bedeutet: Befreiung (z.B. durch Vorstandsamt) + +### vorherige-ausgaben.csv (optional) + +Frühere Ausgaben des Programms zur Berechnung der Jahres-Fairness. + +**Format:** Wie `ausgabe.csv` (siehe unten). + +**Verwendung:** +- Zu Beginn des Kita-Jahres (September): Keine Datei nötig +- Ab Oktober: Vorherige Ausgaben anhängen für kumulative Fairness +- Im Jahresverlauf sammeln sich die Ausgaben an + +## Ausgabe + +### ausgabe.csv + +Zugeteilte Dienste pro Tag. + +**Format:** +``` +Datum,Wochentag,Frühstücksdienst,Putznotdienst,Essensausgabenotdienst,Kochen,Elternabend +2025-01-06,Montag,Müller,Schmidt,Weber,, +2025-01-07,Dienstag,Weber,Müller,Schmidt,, +``` ## Constraints -Harte Constraints: - C1: Je Eltern und Dienst, Dienst nur einmal die Woche - C2: Je Eltern nur einen Dienst am Tag - C3: Dienste nur verfügbaren Eltern zuteilen +### Harte Constraints (müssen erfüllt sein) -Weiche Constraints: -- F1: Alle Eltern erhalten Dienste im Verhältnis ihres Dienstfaktors (Gesamter vorliegender Zeitraum) -- F2: Alle Eltern erhalten Dienste im Verhältnis ihres aktuellen Dienstfaktors (Aktueller Monat) -- P1: Eltern erhalten bevorzugte Dienste -- P2: Eltern erhalten keine abgelehnten Dienste. +- **C1**: Pro Eltern und Dienst maximal **einmal pro Woche** (Mo-So) +- **C2**: Pro Eltern maximal **ein Dienst pro Tag** +- **C3**: Nur **verfügbare** Eltern einteilen +- **C4**: Alle **benötigten Dienste** müssen besetzt werden -F1 und F2 sind Fairnessconstraints und gelten pro Dienst. -P1 und P2 sind Präferenzconstraints. Sie wiegen schwächer als die Fairnessconstaints. +### Weiche Constraints (werden optimiert) +**Fairness** (nach Priorität): +- **F1 (Global)**: Dienste proportional zum Dienstfaktor über das **ganze Jahr** + - Berücksichtigt historische Dienste aus `vorherige-ausgaben.csv` + - Gewichtung: 40% (zu Jahresbeginn) → 60% (zu Jahresende) -Die vorherige-ausgaben.csv der vergangenen Monate dienen auch als Eingabe. -Sie werden für die Fairnessconstraints verwendet. -Im Laufe eines Kinderladenjahrs sammeln sich die Ausgaben der Monate an. -F2 stellt die Fairness im aktuellen Monat sicher -> lokale Fairness -F1 stellt die Fairness für das gesamte Jahr sicher -> globale Fairness +- **F2 (Lokal)**: Dienste proportional zum Dienstfaktor im **aktuellen Monat** + - Nur aktueller Planungszeitraum + - Gewichtung: 60% (zu Jahresbeginn) → 40% (zu Jahresende) -Wenn z.B. Eltern eine zeitlang nicht verfügbar sind, sollen sie nicht sofort -alle Dienste "nachholen" müssen (lokale Fairness stellt das sicher), -aber im Jahresverlauf die Dienste trotzdem nachholen (globale Fairness stellt das sicher). +- **F3 (Dienstübergreifend)**: Gleiche Gesamtanzahl über alle Diensttypen + - Verhindert Häufung bei einzelnen Eltern -F1 und F2 werden mit Faktoren gewichtet. Zu Beginn des Kinderladenjahrs ist F2 stärker, -zum Ende des Kinderladenjahres F1. +**Präferenzen** (niedrigere Priorität): +- **P1**: Bevorzugte Dienste (`+`) werden bevorzugt zugeteilt +- **P2**: Abgelehnte Dienste (`-`) werden vermieden + +### Fairness-Logik + +**Beispiel:** Familie Müller hat 2 Kinder, Familie Schmidt 1 Kind. + +**Lokale Fairness (F2):** +- Im Januar sollen beide verfügbar sein +- Müller sollte 2× so viele Dienste bekommen wie Schmidt +- Verhindert: Müller bekommt alle Dienste auf einmal + +**Globale Fairness (F1):** +- Müller war im Dezember im Urlaub → 0 Dienste +- Im Januar sollte Müller aufholen +- Über das Jahr: 2:1 Verhältnis wird ausgeglichen + +**Gewichtung im Jahresverlauf:** +- **September-November**: F2 (lokal) stärker → sanftes Einführen +- **Dezember-Mai**: Ausgewogen +- **Juni-Juli**: F1 (global) stärker → Jahresausgleich + +## Ausgabe-Statistiken + +Das Programm zeigt nach der Optimierung: + +1. **Dienste pro Eltern**: Übersicht der zugeteilten Dienste +2. **Dienstfaktoren**: Summe im Planungszeitraum +3. **Verteilungsvergleich**: Soll (lokal/global) vs. Ist mit Abweichungen +4. **Präferenz-Verletzungen**: Wie oft wurden Ablehnungen ignoriert + +## Troubleshooting + +**"Keine optimale Lösung gefunden":** +- Zu viele Eltern nicht verfügbar +- Nicht genug Eltern für alle Dienste +- Widersprüchliche Präferenzen + +**"Unfaire Verteilung":** +- Prüfen Sie die Dienstfaktoren in `eltern.csv` +- Stellen Sie sicher, dass `vorherige-ausgaben.csv` korrekt ist +- Mehr Eltern verfügbar machen diff --git a/STRUKTUR.md b/STRUKTUR.md index 566eb27..e69de29 100644 --- a/STRUKTUR.md +++ b/STRUKTUR.md @@ -1,107 +0,0 @@ -# Projektstruktur Elterndienstplaner - -## Dateien - -### `csv_io.py` - CSV Input/Output Module -**Zweck**: Trennung von Datei-I/O und Business-Logik - -**Klassen**: -- `EingabeParser`: Parst alle CSV-Eingabedateien - - `parse_eingabe_csv()`: Lädt eingabe.csv mit Terminen und Präferenzen - - `parse_eltern_csv()`: Lädt eltern.csv mit Dienstfaktoren - - `parse_vorherige_ausgaben_csv()`: Lädt vorherige-ausgaben.csv für Fairness - -- `AusgabeWriter`: Schreibt Ergebnisse in CSV - - `schreibe_ausgabe_csv()`: Schreibt Lösung in ausgabe.csv - -**Vorteile**: -- ✅ Testbar: CSV-Parsing kann isoliert getestet werden -- ✅ Wiederverwendbar: Andere Formate (JSON, Excel) leicht hinzufügbar -- ✅ Klare Verantwortlichkeiten: I/O getrennt von Optimierung - -### `elterndienstplaner.py` - Hauptprogramm -**Zweck**: Business-Logik und Optimierung - -**Klassen**: -- `Dienst`: Datenmodell für Diensttypen -- `Elterndienstplaner`: Hauptklasse mit Optimierungslogik - - Fairness-Berechnungen (global/lokal) - - Optimierungsmodell-Erstellung (modular aufgeteilt) - - Statistiken - -**Constraint-Funktionen** (modular aufgeteilt): -- `_erstelle_entscheidungsvariablen()`: Erstellt binäre Variablen -- `_add_constraint_ein_dienst_pro_woche()`: C1 - Max 1 Dienst pro Woche -- `_add_constraint_ein_dienst_pro_tag()`: C2 - Max 1 Dienst pro Tag -- `_add_constraint_verfuegbarkeit()`: C3 - Nur verfügbare Eltern -- `_add_constraint_dienst_bedarf()`: C4 - Alle Dienste müssen besetzt werden -- `_add_fairness_constraints()`: F1 & F2 - Erstellt Variablen und Constraints für Fairness -- `_berechne_fairness_gewichte()`: Zeitabhängige Gewichtung (Sep-Jul) -- `_erstelle_zielfunktion()`: Zielfunktion mit Fairness & Präferenzen - -**Abhängigkeiten**: -- Importiert `csv_io` für Datei-Operationen -- Verwendet `pulp` für lineare Optimierung - -## Verbesserungen durch Refactoring - -### Vorher (Monolith) -``` -elterndienstplaner.py (700+ Zeilen) -├── Dienst Klasse -├── CSV Parsing (150+ Zeilen) -├── Fairness-Berechnung -├── Optimierung (200+ Zeilen inline) -└── CSV Schreiben -``` - -### Nachher (Modular) -``` -csv_io.py (220 Zeilen) -├── EingabeParser -└── AusgabeWriter - -elterndienstplaner.py (500 Zeilen) -├── Dienst Klasse -├── Fairness-Berechnung -│ ├── berechne_faire_zielverteilung_global() -│ └── berechne_faire_zielverteilung_lokal() -├── Optimierung (modular) -│ ├── erstelle_optimierungsmodell() (30 Zeilen - übersichtlich!) -│ ├── _erstelle_entscheidungsvariablen() -│ ├── _add_constraint_ein_dienst_pro_woche() -│ ├── _add_constraint_ein_dienst_pro_tag() -│ ├── _add_constraint_verfuegbarkeit() -│ ├── _add_constraint_dienst_bedarf() -│ ├── _add_fairness_constraints() -│ └── _erstelle_zielfunktion() -└── Statistiken -``` - -## Nächste Schritte (Optional) - -### Phase 3: Fairness-Modul (optional) -``` -fairness.py -├── FairnessBerechner -│ ├── berechne_global() -│ └── berechne_lokal() -```## Verwendung - -```python -from csv_io import EingabeParser, AusgabeWriter - -# Daten laden -eltern, tage, dienste, ... = EingabeParser.parse_eingabe_csv("eingabe.csv", lookup_fn) - -# Ergebnis schreiben -AusgabeWriter.schreibe_ausgabe_csv("ausgabe.csv", lösung, tage, dienste) -``` - -## Vorteile der aktuellen Struktur - -1. **Separation of Concerns**: I/O getrennt von Business-Logik -2. **Testbarkeit**: Module können unabhängig getestet werden -3. **Wartbarkeit**: Änderungen an CSV-Format betreffen nur `csv_io.py` -4. **Erweiterbarkeit**: Neue Dateiformate können leicht hinzugefügt werden -5. **Lesbarkeit**: Kürzere, fokussiertere Dateien diff --git a/ausgabe.py b/ausgabe.py new file mode 100644 index 0000000..6cd8488 --- /dev/null +++ b/ausgabe.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +""" +Ausgabe-Modul für Elterndienstplaner +Visualisierung und Export der Ergebnisse +""" + +from datetime import date +from collections import defaultdict +from typing import Dict, List, DefaultDict + +from datenmodell import ElterndienstplanerDaten, Dienst, Eltern, Zielverteilung +from csv_io import AusgabeWriter + + +class ElterndienstAusgabe: + """Ausgabe und Visualisierung der Optimierungsergebnisse""" + + def __init__(self, daten: ElterndienstplanerDaten) -> None: + self.daten = daten + # Zwischenergebnisse aus der Optimierung (über Observer-Pattern gesetzt) + self.ziel_lokal: Zielverteilung = None + self.ziel_global: Zielverteilung = None + + def setze_zielverteilungen( + self, + ziel_lokal: Zielverteilung, + ziel_global: Zielverteilung + ) -> None: + """Setzt die Zielverteilungen (Observer-Callback)""" + self.ziel_lokal = ziel_lokal + self.ziel_global = ziel_global + + def schreibe_ausgabe_csv(self, datei: str, lösung: Dict[date, Dict[Dienst, List[Eltern]]]) -> None: + """Schreibt die Lösung in die ausgabe.csv""" + AusgabeWriter.schreibe_ausgabe_csv(datei, lösung, self.daten.planungszeitraum, self.daten.dienste) + + def drucke_statistiken(self, lösung: Dict[date, Dict[Dienst, List[Eltern]]]) -> None: + """Druckt Statistiken zur Lösung""" + print("\n" + "="*50) + print("STATISTIKEN") + print("="*50) + + # Dienste pro Eltern zählen + dienste_pro_eltern = defaultdict(lambda: defaultdict(int)) + for tag, tag_dienste in lösung.items(): + for dienst, eltern_liste in tag_dienste.items(): + for eltern in eltern_liste: + dienste_pro_eltern[eltern][dienst] += 1 + + # Gesamtübersicht + print("\nDienste pro Eltern:") + for eltern in sorted(self.daten.eltern): + gesamt = sum(dienste_pro_eltern[eltern].values()) + dienste_detail = ', '.join(f"{dienst.kuerzel}:{dienste_pro_eltern[eltern][dienst]}" + for dienst in self.daten.dienste if dienste_pro_eltern[eltern][dienst] > 0) + print(f" {eltern:15} {gesamt:3d} ({dienste_detail})") + + # Dienstfaktor-Analyse + print(f"\nDienstfaktoren im Planungszeitraum:") + for eltern in sorted(self.daten.eltern): + faktor_summe = sum(self.daten.dienstfaktoren[eltern][tag] for tag in self.daten.planungszeitraum) + print(f" {eltern:15} {faktor_summe:.1f}") + + def visualisiere_praeferenz_verletzungen( + self, + lösung: Dict[date, Dict[Dienst, List[Eltern]]] + ) -> None: + """Visualisiert verletzte Präferenzen als Tabelle + + Args: + lösung: Die tatsächliche Lösung nach Optimierung + """ + print("\n" + "="*110) + print("PRÄFERENZ-VERLETZUNGEN") + print("="*110) + + # Sammle alle zugeteilten Dienste pro Eltern + zugeteilte_dienste = defaultdict(lambda: defaultdict(list)) # eltern -> dienst -> [dates] + for tag, tag_dienste in lösung.items(): + for dienst, eltern_liste in tag_dienste.items(): + for eltern in eltern_liste: + zugeteilte_dienste[eltern][dienst].append(tag) + + # Sammle Präferenzen strukturiert + # praeferenzen_pro_eltern_dienst[eltern][dienst] = {datum: präf_wert} + praeferenzen_pro_eltern_dienst = defaultdict(lambda: defaultdict(dict)) + for (eltern, tag, dienst), präf in self.daten.präferenzen.items(): + praeferenzen_pro_eltern_dienst[eltern][dienst][tag] = präf + + # Berechne Verletzungen + verletzungen = defaultdict(lambda: defaultdict(lambda: {'negativ': 0, 'positiv_nicht_erfuellt': 0})) + + for eltern in sorted(self.daten.eltern): + for dienst in self.daten.dienste: + zugeteilte_tage = zugeteilte_dienste[eltern][dienst] + praeferenzen_dienst = praeferenzen_pro_eltern_dienst[eltern][dienst] + + if not zugeteilte_tage: + continue # Keine Dienste zugeteilt + + # a) Negative Präferenzen die verletzt wurden + for tag in zugeteilte_tage: + if tag in praeferenzen_dienst and praeferenzen_dienst[tag] == -1: + verletzungen[eltern][dienst]['negativ'] += 1 + + # b) Positive Präferenzen nicht erfüllt (Dienst an nicht-präferiertem Tag) + # Sammle alle Tage mit positiver Präferenz für diesen Dienst + positive_praef_tage = {tag for tag, präf in praeferenzen_dienst.items() if präf == 1} + + if positive_praef_tage: # Es gibt positive Präferenzen + # Prüfe ob ALLE zugeteilten Dienste an nicht-präferierten Tagen sind + for tag in zugeteilte_tage: + if tag not in positive_praef_tage: + # Dienst wurde an nicht-präferiertem Tag zugeteilt + verletzungen[eltern][dienst]['positiv_nicht_erfuellt'] += 1 + + # Tabelle ausgeben + print(f"\n{'Eltern':<20} ", end='') + for dienst in self.daten.dienste: + print(f"{dienst.kuerzel:>12}", end='') + print() + print(f"{'':20} ", end='') + for dienst in self.daten.dienste: + print(f"{'neg, pos':>12}", end='') + print() + print("-" * (20 + 12 * len(self.daten.dienste))) + + gesamt_negativ = defaultdict(int) + gesamt_positiv = defaultdict(int) + + for eltern in sorted(self.daten.eltern): + print(f"{eltern:<20} ", end='') + for dienst in self.daten.dienste: + neg = verletzungen[eltern][dienst]['negativ'] + pos = verletzungen[eltern][dienst]['positiv_nicht_erfuellt'] + + gesamt_negativ[dienst] += neg + gesamt_positiv[dienst] += pos + + # Farbcodierung + farbe = "" + reset = "" + if neg > 0 or pos > 0: + farbe = "\033[91m" if neg > 0 else "\033[93m" # Rot für negativ, Gelb für positiv + reset = "\033[0m" + + print(f"{farbe}{neg:>3}, {pos:>3}{reset:>6}", end='') + print() + + # Summenzeile + print("-" * (20 + 12 * len(self.daten.dienste))) + print(f"{'SUMME':<20} ", end='') + for dienst in self.daten.dienste: + neg = gesamt_negativ[dienst] + pos = gesamt_positiv[dienst] + + farbe = "" + reset = "" + if neg > 0 or pos > 0: + farbe = "\033[91m" if neg > 0 else "\033[93m" + reset = "\033[0m" + + print(f"{farbe}{neg:>3}, {pos:>3}{reset:>6}", end='') + print() + + print("\nLegende:") + print(" neg = Anzahl negativer Präferenzen (abgelehnte Tage), die verletzt wurden") + print(" pos = Anzahl Dienste an nicht-präferierten Tagen (obwohl präferierte Tage angegeben waren)") + print(" \033[91mRot\033[0m = Negative Präferenz verletzt") + print(" \033[93mGelb\033[0m = Positive Präferenz nicht erfüllt") + + def visualisiere_verteilungen( + self, + lösung: Dict[date, Dict[Dienst, List[Eltern]]] + ) -> None: + """Visualisiert die Verteilungen als Tabelle zum Vergleich + + Args: + lösung: Die tatsächliche Lösung nach Optimierung + """ + if self.ziel_lokal is None or self.ziel_global is None: + print("FEHLER: Zielverteilungen wurden nicht gesetzt!") + return + + # Tatsächliche Dienste zählen + tatsaechlich = defaultdict(lambda: defaultdict(int)) + for tag_dienste in lösung.values(): + for dienst, eltern_liste in tag_dienste.items(): + for eltern in eltern_liste: + tatsaechlich[eltern][dienst] += 1 + + print("\n" + "="*110) + print("VERTEILUNGSVERGLEICH: SOLL vs. IST") + print("="*110) + + for dienst in self.daten.dienste: + print(f"\n{dienst.name} ({dienst.kuerzel}):") + print(f"{'Eltern':<20} {'Ziel Global':>12} {'Ziel Lokal':>12} {'Tatsächlich':>12} " + f"{'Δ Global':>12} {'Δ Lokal':>12}") + print("-" * 110) + + for eltern in sorted(self.daten.eltern): + z_global = self.ziel_global[eltern][dienst] + z_lokal = self.ziel_lokal[eltern][dienst] + ist = tatsaechlich[eltern][dienst] + delta_global = ist - z_global + delta_lokal = ist - z_lokal + + # Farbcodierung für Abweichungen (ANSI-Codes) + farbe_global = "" + farbe_lokal = "" + reset = "" + + if abs(delta_global) > 0.5: + farbe_global = "\033[93m" if abs(delta_global) <= 1.0 else "\033[91m" # Gelb oder Rot + reset = "\033[0m" + + if abs(delta_lokal) > 0.5: + farbe_lokal = "\033[93m" if abs(delta_lokal) <= 1.0 else "\033[91m" + reset = "\033[0m" + + print(f"{eltern:<20} {z_global:>12.2f} {z_lokal:>12.2f} {ist:>12} " + f"{farbe_global}{delta_global:>+12.2f}{reset} {farbe_lokal}{delta_lokal:>+12.2f}{reset}") + + # Summen + summe_z_global = sum(self.ziel_global[e][dienst] for e in self.daten.eltern) + summe_z_lokal = sum(self.ziel_lokal[e][dienst] for e in self.daten.eltern) + summe_ist = sum(tatsaechlich[e][dienst] for e in self.daten.eltern) + + print("-" * 110) + print(f"{'SUMME':<20} {summe_z_global:>12.2f} {summe_z_lokal:>12.2f} {summe_ist:>12} " + f"{summe_ist - summe_z_global:>+12.2f} {summe_ist - summe_z_lokal:>+12.2f}") + + # Gesamtstatistik + print("\n" + "="*110) + print("ZUSAMMENFASSUNG") + print("="*110) + + # Maximale Abweichungen finden + max_abw_global = 0 + max_abw_lokal = 0 + + for eltern in self.daten.eltern: + for dienst in self.daten.dienste: + ist = tatsaechlich[eltern][dienst] + max_abw_global = max(max_abw_global, abs(ist - self.ziel_global[eltern][dienst])) + max_abw_lokal = max(max_abw_lokal, abs(ist - self.ziel_lokal[eltern][dienst])) + + print(f"Maximale Abweichung von Global-Ziel: {max_abw_global:.2f} Dienste") + print(f"Maximale Abweichung von Lokal-Ziel: {max_abw_lokal:.2f} Dienste") + print("\nLegende: Δ = Tatsächlich - Ziel (positiv = mehr als Ziel, negativ = weniger als Ziel)") diff --git a/datenmodell.py b/datenmodell.py new file mode 100644 index 0000000..3a49f8f --- /dev/null +++ b/datenmodell.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +Datenmodell für Elterndienstplaner +Enthält alle Daten und deren Laden sowie gemeinsame Type Aliases +""" + +from datetime import date +from collections import defaultdict +from typing import Dict, List, Tuple, DefaultDict, Optional, TypeAlias +import pulp + +from csv_io import EingabeParser + + +class Dienst: + """Repräsentiert einen Diensttyp mit allen seinen Eigenschaften""" + + def __init__(self, kuerzel: str, name: str, personen_anzahl: int = 1) -> None: + self.kuerzel: str = kuerzel + self.name: str = name + self.personen_anzahl: int = personen_anzahl + + def __str__(self) -> str: + return f"{self.kuerzel} ({self.name}): {self.personen_anzahl} Person(en)" + + def __repr__(self) -> str: + return f"Dienst('{self.kuerzel}', '{self.name}', {self.personen_anzahl})" + + def braucht_mehrere_personen(self) -> bool: + """Gibt True zurück, wenn mehr als eine Person benötigt wird""" + return self.personen_anzahl > 1 + +# Eltern: Ihnen koennen Dienste zugewiesen werden +Eltern: TypeAlias = str + +# Verteilung von Diensten auf Eltern. +# - Zielsumme der Dienste ueber den Planungszeitraum +# - Kann nicht-ganzzahlig und negativ sein +Zielverteilung: TypeAlias = DefaultDict[Eltern, DefaultDict[Dienst, float]] + +# Entscheidungsvariablen des Optimierungsproblems +# Variable: Eltern wird am Datum Dienst zugewiesen +Entscheidungsvariablen: TypeAlias = Dict[Tuple[Eltern, date, Dienst], pulp.LpVariable] + + +class ElterndienstplanerDaten: + """Datenmodell für den Elterndienstplaner""" + + def __init__(self) -> None: + # Dienste als Liste definieren + self.dienste: List[Dienst] = [ + Dienst('F', 'Frühstücksdienst', 1), + Dienst('P', 'Putznotdienst', 1), + Dienst('E', 'Essensausgabenotdienst', 1), + Dienst('K', 'Kochen', 1), + Dienst('A', 'Elternabend', 2) + ] + + # Datenstrukturen + self.planungszeitraum: List[date] = [] + self.eltern: List[Eltern] = [] + self.benoetigte_dienste: Dict[date, List[Dienst]] = {} + self.verfügbarkeit: Dict[Tuple[Eltern, date], bool] = {} + self.präferenzen: Dict[Tuple[Eltern, date, Dienst], int] = {} + + # dienstfaktoren[eltern][tag] = faktor. + # Wenn es eltern nicht gibt -> keyerror + # Wenn es tag nicht gibt -> default 0.0 + self.dienstfaktoren: Dict[Eltern, DefaultDict[date, float]] = {} + self.historische_dienste: List[Tuple[date, Eltern, Dienst]] = [] + + def get_dienst(self, kuerzel: str) -> Optional[Dienst]: + """Gibt das Dienst-Objekt für ein Kürzel zurück""" + for dienst in self.dienste: + if dienst.kuerzel == kuerzel: + return dienst + return None + + def add_dienst(self, kuerzel: str, name: str, personen_anzahl: int = 1) -> Dienst: + """Fügt einen neuen Dienst hinzu""" + dienst = Dienst(kuerzel, name, personen_anzahl) + self.dienste.append(dienst) + return dienst + + def print_dienste_info(self) -> None: + """Druckt Informationen über alle konfigurierten Dienste""" + print("Konfigurierte Dienste:") + for dienst in self.dienste: + print(f" {dienst}") + + def lade_daten( + self, + eingabe_datei: str, + eltern_datei: str, + vorherige_datei: Optional[str] = None + ) -> None: + """Lädt alle benötigten CSV-Dateien + + Args: + eingabe_datei: Pfad zur eingabe.csv mit Terminen und Präferenzen + eltern_datei: Pfad zur eltern.csv mit Dienstfaktoren + vorherige_datei: Optionaler Pfad zur vorherige-ausgaben.csv für Fairness-Constraints + """ + # Eingabe CSV: Termine, Präferenzen, Verfügbarkeit + self.eltern, self.planungszeitraum, self.benoetigte_dienste, self.verfügbarkeit, self.präferenzen = \ + EingabeParser.parse_eingabe_csv(eingabe_datei, self.get_dienst) + + # Eltern CSV: Dienstfaktoren + self.dienstfaktoren = EingabeParser.parse_eltern_csv(eltern_datei) + + # Vorherige Ausgaben CSV (optional): Historische Dienste für Fairness + if vorherige_datei: + self.historische_dienste = \ + EingabeParser.parse_vorherige_ausgaben_csv(vorherige_datei, self.eltern, self.dienste) diff --git a/elterndienstplaner.py b/elterndienstplaner.py index 06bd519..6429b90 100755 --- a/elterndienstplaner.py +++ b/elterndienstplaner.py @@ -12,92 +12,18 @@ from datetime import timedelta, date from collections import defaultdict from typing import Dict, List, Tuple, DefaultDict, Optional -from csv_io import EingabeParser, AusgabeWriter - - -class Dienst: - """Repräsentiert einen Diensttyp mit allen seinen Eigenschaften""" - - def __init__(self, kuerzel: str, name: str, personen_anzahl: int = 1) -> None: - self.kuerzel: str = kuerzel - self.name: str = name - self.personen_anzahl: int = personen_anzahl - - def __str__(self) -> str: - return f"{self.kuerzel} ({self.name}): {self.personen_anzahl} Person(en)" - - def __repr__(self) -> str: - return f"Dienst('{self.kuerzel}', '{self.name}', {self.personen_anzahl})" - - def braucht_mehrere_personen(self) -> bool: - """Gibt True zurück, wenn mehr als eine Person benötigt wird""" - return self.personen_anzahl > 1 +from datenmodell import ElterndienstplanerDaten, Dienst, Eltern, Zielverteilung, Entscheidungsvariablen +from ausgabe import ElterndienstAusgabe class Elterndienstplaner: - def __init__(self) -> None: - # Dienste als Liste definieren - self.dienste: List[Dienst] = [ - Dienst('F', 'Frühstücksdienst', 1), - Dienst('P', 'Putznotdienst', 1), - Dienst('E', 'Essensausgabenotdienst', 1), - Dienst('K', 'Kochen', 1), - Dienst('A', 'Elternabend', 2) - ] + """Optimierungs-Engine für Elterndienstplanung""" - # Datenstrukturen - self.planungszeitraum: List[date] = [] - self.eltern: List[str] = [] - self.benoetigte_dienste: Dict[date, List[Dienst]] = {} - self.verfügbarkeit: Dict[Tuple[str, date], bool] = {} - self.präferenzen: Dict[Tuple[str, date, Dienst], int] = {} + def __init__(self, daten: ElterndienstplanerDaten, ausgabe: ElterndienstAusgabe) -> None: + self.daten = daten + self.ausgabe = ausgabe - # dienstfaktoren[eltern][tag] = faktor. - # Wenn es eltern nicht gibt -> keyerror - # Wenn es tag nicht gibt -> default 0.0 - self.dienstfaktoren: Dict[str, DefaultDict[date, float]] = {} - self.historische_dienste: List[Tuple[date, str, Dienst]] = [] - - def get_dienst(self, kuerzel: str) -> Optional[Dienst]: - """Gibt das Dienst-Objekt für ein Kürzel zurück""" - for dienst in self.dienste: - if dienst.kuerzel == kuerzel: - return dienst - return None - - def add_dienst(self, kuerzel: str, name: str, personen_anzahl: int = 1) -> Dienst: - """Fügt einen neuen Dienst hinzu""" - dienst = Dienst(kuerzel, name, personen_anzahl) - self.dienste.append(dienst) - return dienst - - def print_dienste_info(self) -> None: - """Druckt Informationen über alle konfigurierten Dienste""" - print("Konfigurierte Dienste:") - for dienst in self.dienste: - print(f" {dienst}") - - def lade_eingabe_csv(self, datei: str) -> None: - """Lädt die eingabe.csv mit Terminen und Präferenzen""" - self.eltern, self.planungszeitraum, self.benoetigte_dienste, self.verfügbarkeit, self.präferenzen = \ - EingabeParser.parse_eingabe_csv(datei, self.get_dienst) - - def lade_eltern_csv(self, datei: str) -> None: - """Lädt die eltern.csv mit Dienstfaktoren""" - self.dienstfaktoren = EingabeParser.parse_eltern_csv(datei) - - def lade_vorherige_ausgaben_csv(self, datei: str) -> None: - """Lädt vorherige-ausgaben.csv für Fairness-Constraints""" - self.historische_dienste = \ - EingabeParser.parse_vorherige_ausgaben_csv(datei, self.eltern, self.dienste) - - def berechne_dienstfaktor_an_datum(self, eltern: str, datum: date) -> float: - """Berechnet den Dienstfaktor eines Elternteils an einem bestimmten Datum""" - if eltern not in self.dienstfaktoren: - return 0 - return self.dienstfaktoren[eltern][datum] # DefaultDict gibt 0 zurück für unbekannte Tage - - def berechne_faire_zielverteilung_global(self) -> DefaultDict[str, DefaultDict[Dienst, float]]: + def berechne_faire_zielverteilung_global(self) -> Zielverteilung: """Berechnet die faire Zielanzahl von Diensten für den Planungszeitraum basierend auf globaler Fairness (Historie + aktueller Planungszeitraum). @@ -105,21 +31,20 @@ class Elterndienstplaner: korrigiert um bereits geleistete Dienste. Kann negativ sein, wenn bereits mehr Dienste geleistet wurden als fair wäre.""" - ziel_dienste: DefaultDict[str, DefaultDict[Dienst, float]] = \ - defaultdict(lambda: defaultdict(float)) + ziel_dienste: Zielverteilung = defaultdict(lambda: defaultdict(float)) print("\nBerechne faire Zielverteilung basierend auf historischen Daten...") # Historische Dienste nach Datum gruppieren - historische_tage = set(datum for datum, _, _ in self.historische_dienste) if self.historische_dienste else set() - print(f" Analysiere {len(historische_tage)} historische Tage mit {len(self.historische_dienste)} Diensten") + historische_tage = set(datum for datum, _, _ in self.daten.historische_dienste) if self.daten.historische_dienste else set() + print(f" Analysiere {len(historische_tage)} historische Tage mit {len(self.daten.historische_dienste)} Diensten") - for dienst in self.dienste: + for dienst in self.daten.dienste: print(f" Verarbeite Dienst {dienst.kuerzel}...") # 1. HISTORISCHE PERIODE: Faire Umverteilung der tatsächlich geleisteten Dienste historische_dienste_dieses_typs = [ - (datum, eltern) for datum, eltern, d in self.historische_dienste + (datum, eltern) for datum, eltern, d in self.daten.historische_dienste if d == dienst ] @@ -137,28 +62,28 @@ class Elterndienstplaner: # Dienstfaktoren aller Eltern für diesen historischen Tag berechnen gesamt_dienstfaktor_tag = 0 - for eltern in self.eltern: - gesamt_dienstfaktor_tag += self.dienstfaktoren[eltern][tag] + for eltern in self.daten.eltern: + gesamt_dienstfaktor_tag += self.daten.dienstfaktoren[eltern][tag] # Faire Umverteilung der an diesem Tag geleisteten Dienste if gesamt_dienstfaktor_tag > 0: - for eltern in self.eltern: - if self.dienstfaktoren[eltern][tag] > 0: - anteil = self.dienstfaktoren[eltern][tag] / gesamt_dienstfaktor_tag + for eltern in self.daten.eltern: + if self.daten.dienstfaktoren[eltern][tag] > 0: + anteil = self.daten.dienstfaktoren[eltern][tag] / gesamt_dienstfaktor_tag faire_zuteilung = anteil * anzahl_dienste ziel_dienste[eltern][dienst] += faire_zuteilung if faire_zuteilung > 0.01: # Debug nur für relevante Werte - print(f" {tag}: {eltern} Faktor={self.dienstfaktoren[eltern][tag]} " + print(f" {tag}: {eltern} Faktor={self.daten.dienstfaktoren[eltern][tag]} " f"-> {faire_zuteilung:.2f} von {anzahl_dienste} Diensten") # 2. AKTUELLER PLANUNGSZEITRAUM: Faire Verteilung der benötigten Dienste (tageweise wie bei historischen Diensten) benoetigte_dienste_planungszeitraum = 0 # Für jeden Tag im aktuellen Planungszeitraum faire Umverteilung berechnen - for tag in self.planungszeitraum: + for tag in self.daten.planungszeitraum: # Prüfe ob an diesem Tag der Dienst benötigt wird - if dienst not in self.benoetigte_dienste.get(tag, []): + if dienst not in self.daten.benoetigte_dienste.get(tag, []): continue benoetigte_dienste_planungszeitraum += dienst.personen_anzahl @@ -167,43 +92,42 @@ class Elterndienstplaner: dienstfaktoren = {} gesamt_dienstfaktor_tag = 0 - for eltern in self.eltern: - faktor = self.dienstfaktoren[eltern][tag] + for eltern in self.daten.eltern: + faktor = self.daten.dienstfaktoren[eltern][tag] dienstfaktoren[eltern] = faktor gesamt_dienstfaktor_tag += faktor # Faire Umverteilung der an diesem Tag benötigten Dienste if gesamt_dienstfaktor_tag > 0: - for eltern in self.eltern: + for eltern in self.daten.eltern: anteil = dienstfaktoren[eltern] / gesamt_dienstfaktor_tag faire_zuteilung = anteil * dienst.personen_anzahl ziel_dienste[eltern][dienst] += faire_zuteilung # 3. ABZUG DER BEREITS GELEISTETEN DIENSTE # Ziehe die tatsächlich geleisteten Dienste ab, um das Ziel für den Planungszeitraum zu erhalten - for eltern in self.eltern: + for eltern in self.daten.eltern: # Berechne vorherige Dienste on-the-fly aus historischen Diensten vorherige_anzahl = sum( - 1 for _, hist_eltern, hist_dienst in self.historische_dienste + 1 for _, hist_eltern, hist_dienst in self.daten.historische_dienste if hist_eltern == eltern and hist_dienst == dienst ) ziel_dienste[eltern][dienst] -= vorherige_anzahl return ziel_dienste - def berechne_faire_zielverteilung_lokal(self) -> DefaultDict[str, DefaultDict[Dienst, float]]: + def berechne_faire_zielverteilung_lokal(self) -> Zielverteilung: """Berechnet die lokale faire Zielanzahl von Diensten pro Eltern-Dienst-Kombination basierend auf Dienstfaktoren und benötigten Diensten im aktuellen Planungszeitraum""" - ziel_dienste_lokal: DefaultDict[str, DefaultDict[Dienst, float]] = \ - defaultdict(lambda: defaultdict(float)) + ziel_dienste_lokal: Zielverteilung = defaultdict(lambda: defaultdict(float)) print("\nBerechne lokale faire Zielverteilung für aktuellen Planungszeitraum...") # Gesamtdienstfaktor für aktuellen Planungszeitraum berechnen summe_dienstfaktor_planungszeitraum_alle_eltern = sum( - sum(self.dienstfaktoren[e][tag] for tag in self.planungszeitraum) - for e in self.eltern + sum(self.daten.dienstfaktoren[e][tag] for tag in self.daten.planungszeitraum) + for e in self.daten.eltern ) if summe_dienstfaktor_planungszeitraum_alle_eltern == 0: @@ -211,11 +135,11 @@ class Elterndienstplaner: return ziel_dienste_lokal # Für jeden Dienst die lokale faire Verteilung berechnen - for dienst in self.dienste: + for dienst in self.daten.dienste: # Anzahl benötigter Dienste im aktuellen Planungszeitraum benoetigte_dienste_planungszeitraum = sum( - 1 for tag in self.planungszeitraum - if dienst in self.benoetigte_dienste.get(tag, []) + 1 for tag in self.daten.planungszeitraum + if dienst in self.daten.benoetigte_dienste.get(tag, []) ) # Multipliziere mit Anzahl benötigter Personen pro Dienst benoetigte_dienste_planungszeitraum *= dienst.personen_anzahl @@ -223,10 +147,10 @@ class Elterndienstplaner: if benoetigte_dienste_planungszeitraum > 0: print(f" {dienst.kuerzel}: {benoetigte_dienste_planungszeitraum} Dienste benötigt") - for eltern in self.eltern: + for eltern in self.daten.eltern: # Dienstfaktor für diesen Elternteil im aktuellen Planungszeitraum summe_dienstfaktor_planungszeitraum = sum( - self.dienstfaktoren[eltern][tag] for tag in self.planungszeitraum + self.daten.dienstfaktoren[eltern][tag] for tag in self.daten.planungszeitraum ) if summe_dienstfaktor_planungszeitraum > 0: @@ -236,13 +160,13 @@ class Elterndienstplaner: return ziel_dienste_lokal - def _erstelle_entscheidungsvariablen(self) -> Dict[Tuple[str, date, Dienst], pulp.LpVariable]: + def _erstelle_entscheidungsvariablen(self) -> Entscheidungsvariablen: """Erstellt die binären Entscheidungsvariablen x[eltern, tag, dienst]""" - x: Dict[Tuple[str, date, Dienst], pulp.LpVariable] = {} - for eltern in self.eltern: - for tag in self.planungszeitraum: - for dienst in self.dienste: - if dienst in self.benoetigte_dienste.get(tag, []): + x: Entscheidungsvariablen = {} + for eltern in self.daten.eltern: + for tag in self.daten.planungszeitraum: + for dienst in self.daten.dienste: + if dienst in self.daten.benoetigte_dienste.get(tag, []): x[eltern, tag, dienst] = pulp.LpVariable( f"x_{eltern.replace(' ', '_')}_{tag}_{dienst.kuerzel}", cat='Binary' @@ -252,35 +176,35 @@ class Elterndienstplaner: def _add_constraint_ein_dienst_pro_woche( self, prob: pulp.LpProblem, - x: Dict[Tuple[str, date, Dienst], pulp.LpVariable] + x: Entscheidungsvariablen ) -> None: """C1: Je Eltern und Dienst nur einmal die Woche (Woche = Montag bis Sonntag)""" - erster_tag = self.planungszeitraum[0] + erster_tag = self.daten.planungszeitraum[0] # weekday(): 0=Montag, 6=Sonntag # Finde Montag am oder vor dem ersten Planungstag (für historische Dienste) woche_start = erster_tag - timedelta(days=erster_tag.weekday()) woche_nr = 0 - letzter_tag = self.planungszeitraum[-1] + letzter_tag = self.daten.planungszeitraum[-1] while woche_start <= letzter_tag: woche_ende = woche_start + timedelta(days=6) # Sonntag - for eltern in self.eltern: - for dienst in self.dienste: + for eltern in self.daten.eltern: + for dienst in self.daten.dienste: woche_vars = [] # Zähle historische Dienste in dieser Woche (VOR dem Planungszeitraum) historische_dienste_in_woche = 0 if woche_start < erster_tag: - for hist_datum, hist_eltern, hist_dienst in self.historische_dienste: + for hist_datum, hist_eltern, hist_dienst in self.daten.historische_dienste: if (hist_eltern == eltern and hist_dienst == dienst and woche_start <= hist_datum < erster_tag): historische_dienste_in_woche += 1 # Sammle Variablen für Planungszeitraum in dieser Woche - for tag in self.planungszeitraum: + for tag in self.daten.planungszeitraum: if woche_start <= tag <= woche_ende: if (eltern, tag, dienst) in x: woche_vars.append(x[eltern, tag, dienst]) @@ -296,13 +220,13 @@ class Elterndienstplaner: def _add_constraint_ein_dienst_pro_tag( self, prob: pulp.LpProblem, - x: Dict[Tuple[str, date, Dienst], pulp.LpVariable] + x: Entscheidungsvariablen ) -> None: """C2: Je Eltern nur einen Dienst am Tag""" - for eltern in self.eltern: - for tag in self.planungszeitraum: + for eltern in self.daten.eltern: + for tag in self.daten.planungszeitraum: tag_vars = [] - for dienst in self.dienste: + for dienst in self.daten.dienste: if (eltern, tag, dienst) in x: tag_vars.append(x[eltern, tag, dienst]) @@ -312,13 +236,13 @@ class Elterndienstplaner: def _add_constraint_verfuegbarkeit( self, prob: pulp.LpProblem, - x: Dict[Tuple[str, date, Dienst], pulp.LpVariable] + x: Entscheidungsvariablen ) -> None: """C3: Dienste nur verfügbaren Eltern zuteilen""" - for eltern in self.eltern: - for tag in self.planungszeitraum: - if not self.verfügbarkeit.get((eltern, tag), True): - for dienst in self.dienste: + for eltern in self.daten.eltern: + for tag in self.daten.planungszeitraum: + if not self.daten.verfügbarkeit.get((eltern, tag), True): + for dienst in self.daten.dienste: if (eltern, tag, dienst) in x: prob += x[eltern, tag, dienst] == 0, \ f"C3_{eltern.replace(' ', '_')}_{tag}_{dienst.kuerzel}" @@ -326,16 +250,16 @@ class Elterndienstplaner: def _add_constraint_dienst_bedarf( self, prob: pulp.LpProblem, - x: Dict[Tuple[str, date, Dienst], pulp.LpVariable] + x: Entscheidungsvariablen ) -> None: """C4: Alle benötigten Dienste müssen zugeteilt werden""" - for tag in self.planungszeitraum: - for dienst in self.benoetigte_dienste.get(tag, []): + for tag in self.daten.planungszeitraum: + for dienst in self.daten.benoetigte_dienste.get(tag, []): dienst_vars = [] - for eltern in self.eltern: + for eltern in self.daten.eltern: if (eltern, tag, dienst) in x: # Prüfe ob Eltern verfügbar - if self.verfügbarkeit.get((eltern, tag), True): + if self.daten.verfügbarkeit.get((eltern, tag), True): dienst_vars.append(x[eltern, tag, dienst]) if dienst_vars: @@ -347,8 +271,8 @@ class Elterndienstplaner: def _add_fairness_constraints( self, prob: pulp.LpProblem, - x: Dict[Tuple[str, date, Dienst], pulp.LpVariable], - ziel_dienste: DefaultDict[str, DefaultDict[Dienst, float]], + x: Entscheidungsvariablen, + ziel_dienste: Zielverteilung, constraint_prefix: str ) -> Dict: """Erstellt Fairness-Variablen und fügt Fairness-Constraints hinzu @@ -365,19 +289,19 @@ class Elterndienstplaner: # Hilfsvariablen für Fairness-Abweichungen erstellen fairness_abweichung = {} - for eltern in self.eltern: - for dienst in self.dienste: + for eltern in self.daten.eltern: + for dienst in self.daten.dienste: fairness_abweichung[eltern, dienst] = pulp.LpVariable( f"fair_{constraint_prefix}_{eltern.replace(' ', '_')}_{dienst.kuerzel}", lowBound=0) # Fairness-Constraints hinzufügen - for eltern in self.eltern: - for dienst in self.dienste: + for eltern in self.daten.eltern: + for dienst in self.daten.dienste: # Tatsächliche Dienste im aktuellen Planungszeitraum zugeteilte_dienste_planungszeitraum = pulp.lpSum( x[eltern, tag, dienst] - for tag in self.planungszeitraum + for tag in self.daten.planungszeitraum if (eltern, tag, dienst) in x ) @@ -393,8 +317,8 @@ class Elterndienstplaner: def _add_constraint_gesamtfairness( self, prob: pulp.LpProblem, - x: Dict[Tuple[str, date, Dienst], pulp.LpVariable], - ziel_dienste: DefaultDict[str, DefaultDict[Dienst, float]], + x: Entscheidungsvariablen, + ziel_dienste: Zielverteilung, constraint_prefix: str ) -> Dict: """F3: Dienstübergreifende Fairness - verhindert Häufung bei einzelnen Eltern @@ -414,7 +338,7 @@ class Elterndienstplaner: """ fairness_abweichung_gesamt = {} - for eltern in self.eltern: + for eltern in self.daten.eltern: fairness_abweichung_gesamt[eltern] = pulp.LpVariable( f"fair_gesamt_{constraint_prefix}_{eltern.replace(' ', '_')}", lowBound=0) @@ -422,13 +346,13 @@ class Elterndienstplaner: # Tatsächliche Gesamtdienste für diesen Elternteil tatsaechliche_dienste_gesamt = pulp.lpSum( x[eltern, tag, dienst] - for tag in self.planungszeitraum - for dienst in self.dienste + for tag in self.daten.planungszeitraum + for dienst in self.daten.dienste if (eltern, tag, dienst) in x ) # Ziel-Gesamtdienste für diesen Elternteil (Summe über alle Dienste) - ziel_gesamt = sum(ziel_dienste[eltern][dienst] for dienst in self.dienste) + ziel_gesamt = sum(ziel_dienste[eltern][dienst] for dienst in self.daten.dienste) # Fairness-Constraints prob += (tatsaechliche_dienste_gesamt - ziel_gesamt <= @@ -442,7 +366,7 @@ class Elterndienstplaner: def _erstelle_zielfunktion( self, prob: pulp.LpProblem, - x: Dict[Tuple[str, date, Dienst], pulp.LpVariable], + x: Entscheidungsvariablen, fairness_abweichung_lokal: Dict, fairness_abweichung_global: Dict, fairness_abweichung_gesamt_global: Dict, @@ -460,8 +384,8 @@ class Elterndienstplaner: gewicht_f3_lokal = 0.25 * gewicht_lokal # Fairness-Terme zur Zielfunktion hinzufügen - for eltern in self.eltern: - for dienst in self.dienste: + for eltern in self.daten.eltern: + for dienst in self.daten.dienste: objective_terms.append(gewicht_f1 * fairness_abweichung_global[eltern, dienst]) objective_terms.append(gewicht_f2 * fairness_abweichung_lokal[eltern, dienst]) @@ -470,12 +394,12 @@ class Elterndienstplaner: objective_terms.append(gewicht_f3_lokal * fairness_abweichung_gesamt_lokal[eltern]) # P1: Bevorzugte Dienste (positiv belohnen) - for (eltern, tag, dienst), präf in self.präferenzen.items(): + for (eltern, tag, dienst), präf in self.daten.präferenzen.items(): if (eltern, tag, dienst) in x and präf == 1: # bevorzugt objective_terms.append(-5 * x[eltern, tag, dienst]) # P2: Abgelehnte Dienste (bestrafen) - for (eltern, tag, dienst), präf in self.präferenzen.items(): + for (eltern, tag, dienst), präf in self.daten.präferenzen.items(): if (eltern, tag, dienst) in x and präf == -1: # abgelehnt objective_terms.append(25 * x[eltern, tag, dienst]) @@ -491,9 +415,7 @@ class Elterndienstplaner: def erstelle_optimierungsmodell(self) -> Tuple[ pulp.LpProblem, - Dict[Tuple[str, date, Dienst], pulp.LpVariable], - DefaultDict[str, DefaultDict[Dienst, float]], - DefaultDict[str, DefaultDict[Dienst, float]] + Entscheidungsvariablen ]: """Erstellt das PuLP Optimierungsmodell @@ -504,9 +426,9 @@ class Elterndienstplaner: # Debugging: Verfügbarkeit prüfen print("\nDebug: Verfügbarkeit analysieren...") - for tag in self.planungszeitraum[:5]: # Erste 5 Tage - verfügbare = [e for e in self.eltern if self.verfügbarkeit.get((e, tag), True)] - benötigte = self.benoetigte_dienste.get(tag, []) + for tag in self.daten.planungszeitraum[:5]: # Erste 5 Tage + verfügbare = [e for e in self.daten.eltern if self.daten.verfügbarkeit.get((e, tag), True)] + benötigte = self.daten.benoetigte_dienste.get(tag, []) print(f" {tag}: Benötigt {len(benötigte)} Dienste {benötigte}, verfügbar: {verfügbare}") # LP Problem erstellen @@ -525,6 +447,9 @@ class Elterndienstplaner: ziel_dienste_global = self.berechne_faire_zielverteilung_global() ziel_dienste_lokal = self.berechne_faire_zielverteilung_lokal() + # Observer Pattern: Notify ausgabe about target distributions + self.ausgabe.setze_zielverteilungen(ziel_dienste_lokal, ziel_dienste_global) + # F2: Lokale Fairness-Constraints fairness_abweichung_lokal = self._add_fairness_constraints( prob, x, ziel_dienste_lokal, "lokal" @@ -550,10 +475,10 @@ class Elterndienstplaner: fairness_abweichung_gesamt_global, fairness_abweichung_gesamt_lokal) print(f"Modell erstellt mit {len(x)} Variablen und {len(prob.constraints)} Constraints") - return prob, x, ziel_dienste_lokal, ziel_dienste_global + return prob, x def löse_optimierung(self, prob: pulp.LpProblem, - x: Dict[Tuple[str, date, Dienst], pulp.LpVariable]) -> Optional[Dict[date, Dict[Dienst, List[str]]]]: + x: Entscheidungsvariablen) -> Optional[Dict[date, Dict[Dienst, List[Eltern]]]]: """Löst das Optimierungsproblem""" print("Löse Optimierungsproblem...") @@ -580,7 +505,7 @@ class Elterndienstplaner: return None # Lösung extrahieren - lösung: Dict[date, Dict[Dienst, List[str]]] = {} + lösung: Dict[date, Dict[Dienst, List[Eltern]]] = {} for (eltern, tag, dienst), var in x.items(): if var.varValue and var.varValue > 0.5: # Binary variable ist 1 if tag not in lösung: @@ -591,226 +516,6 @@ class Elterndienstplaner: return lösung - def schreibe_ausgabe_csv(self, datei: str, lösung: Dict[date, Dict[Dienst, List[str]]]) -> None: - """Schreibt die Lösung in die ausgabe.csv""" - AusgabeWriter.schreibe_ausgabe_csv(datei, lösung, self.planungszeitraum, self.dienste) - - def drucke_statistiken(self, lösung: Dict[date, Dict[Dienst, List[str]]]) -> None: - """Druckt Statistiken zur Lösung""" - print("\n" + "="*50) - print("STATISTIKEN") - print("="*50) - - # Dienste pro Eltern zählen - dienste_pro_eltern = defaultdict(lambda: defaultdict(int)) - for tag, tag_dienste in lösung.items(): - for dienst, eltern_liste in tag_dienste.items(): - for eltern in eltern_liste: - dienste_pro_eltern[eltern][dienst] += 1 - - # Gesamtübersicht - print("\nDienste pro Eltern:") - for eltern in sorted(self.eltern): - gesamt = sum(dienste_pro_eltern[eltern].values()) - dienste_detail = ', '.join(f"{dienst.kuerzel}:{dienste_pro_eltern[eltern][dienst]}" - for dienst in self.dienste if dienste_pro_eltern[eltern][dienst] > 0) - print(f" {eltern:15} {gesamt:3d} ({dienste_detail})") - - # Dienstfaktor-Analyse - print(f"\nDienstfaktoren im Planungszeitraum:") - for eltern in sorted(self.eltern): - faktor_summe = sum(self.dienstfaktoren[eltern][tag] for tag in self.planungszeitraum) - print(f" {eltern:15} {faktor_summe:.1f}") - - def visualisiere_praeferenz_verletzungen( - self, - lösung: Dict[date, Dict[Dienst, List[str]]] - ) -> None: - """Visualisiert verletzte Präferenzen als Tabelle - - Args: - lösung: Die tatsächliche Lösung nach Optimierung - """ - print("\n" + "="*110) - print("PRÄFERENZ-VERLETZUNGEN") - print("="*110) - - # Sammle alle zugeteilten Dienste pro Eltern - zugeteilte_dienste = defaultdict(lambda: defaultdict(list)) # eltern -> dienst -> [dates] - for tag, tag_dienste in lösung.items(): - for dienst, eltern_liste in tag_dienste.items(): - for eltern in eltern_liste: - zugeteilte_dienste[eltern][dienst].append(tag) - - # Sammle Präferenzen strukturiert - # praeferenzen_pro_eltern_dienst[eltern][dienst] = {datum: präf_wert} - praeferenzen_pro_eltern_dienst = defaultdict(lambda: defaultdict(dict)) - for (eltern, tag, dienst), präf in self.präferenzen.items(): - praeferenzen_pro_eltern_dienst[eltern][dienst][tag] = präf - - # Berechne Verletzungen - verletzungen = defaultdict(lambda: defaultdict(lambda: {'negativ': 0, 'positiv_nicht_erfuellt': 0})) - - for eltern in sorted(self.eltern): - for dienst in self.dienste: - zugeteilte_tage = zugeteilte_dienste[eltern][dienst] - praeferenzen_dienst = praeferenzen_pro_eltern_dienst[eltern][dienst] - - if not zugeteilte_tage: - continue # Keine Dienste zugeteilt - - # a) Negative Präferenzen die verletzt wurden - for tag in zugeteilte_tage: - if tag in praeferenzen_dienst and praeferenzen_dienst[tag] == -1: - verletzungen[eltern][dienst]['negativ'] += 1 - - # b) Positive Präferenzen nicht erfüllt (Dienst an nicht-präferiertem Tag) - # Sammle alle Tage mit positiver Präferenz für diesen Dienst - positive_praef_tage = {tag for tag, präf in praeferenzen_dienst.items() if präf == 1} - - if positive_praef_tage: # Es gibt positive Präferenzen - # Prüfe ob ALLE zugeteilten Dienste an nicht-präferierten Tagen sind - for tag in zugeteilte_tage: - if tag not in positive_praef_tage: - # Dienst wurde an nicht-präferiertem Tag zugeteilt - verletzungen[eltern][dienst]['positiv_nicht_erfuellt'] += 1 - - # Tabelle ausgeben - print(f"\n{'Eltern':<20} ", end='') - for dienst in self.dienste: - print(f"{dienst.kuerzel:>12}", end='') - print() - print(f"{'':20} ", end='') - for dienst in self.dienste: - print(f"{'neg, pos':>12}", end='') - print() - print("-" * (20 + 12 * len(self.dienste))) - - gesamt_negativ = defaultdict(int) - gesamt_positiv = defaultdict(int) - - for eltern in sorted(self.eltern): - print(f"{eltern:<20} ", end='') - for dienst in self.dienste: - neg = verletzungen[eltern][dienst]['negativ'] - pos = verletzungen[eltern][dienst]['positiv_nicht_erfuellt'] - - gesamt_negativ[dienst] += neg - gesamt_positiv[dienst] += pos - - # Farbcodierung - farbe = "" - reset = "" - if neg > 0 or pos > 0: - farbe = "\033[91m" if neg > 0 else "\033[93m" # Rot für negativ, Gelb für positiv - reset = "\033[0m" - - print(f"{farbe}{neg:>3}, {pos:>3}{reset:>6}", end='') - print() - - # Summenzeile - print("-" * (20 + 12 * len(self.dienste))) - print(f"{'SUMME':<20} ", end='') - for dienst in self.dienste: - neg = gesamt_negativ[dienst] - pos = gesamt_positiv[dienst] - - farbe = "" - reset = "" - if neg > 0 or pos > 0: - farbe = "\033[91m" if neg > 0 else "\033[93m" - reset = "\033[0m" - - print(f"{farbe}{neg:>3}, {pos:>3}{reset:>6}", end='') - print() - - print("\nLegende:") - print(" neg = Anzahl negativer Präferenzen (abgelehnte Tage), die verletzt wurden") - print(" pos = Anzahl Dienste an nicht-präferierten Tagen (obwohl präferierte Tage angegeben waren)") - print(" \033[91mRot\033[0m = Negative Präferenz verletzt") - print(" \033[93mGelb\033[0m = Positive Präferenz nicht erfüllt") - - def visualisiere_verteilungen( - self, - lösung: Dict[date, Dict[Dienst, List[str]]], - ziel_lokal: DefaultDict[str, DefaultDict[Dienst, float]], - ziel_global: DefaultDict[str, DefaultDict[Dienst, float]] - ) -> None: - """Visualisiert die Verteilungen als Tabelle zum Vergleich - - Args: - lösung: Die tatsächliche Lösung nach Optimierung - ziel_lokal: Lokale Zielverteilung (nur aktueller Planungszeitraum) - ziel_global: Globale Zielverteilung (inkl. Historie) - """ - # Tatsächliche Dienste zählen - tatsaechlich = defaultdict(lambda: defaultdict(int)) - for tag_dienste in lösung.values(): - for dienst, eltern_liste in tag_dienste.items(): - for eltern in eltern_liste: - tatsaechlich[eltern][dienst] += 1 - - print("\n" + "="*110) - print("VERTEILUNGSVERGLEICH: SOLL vs. IST") - print("="*110) - - for dienst in self.dienste: - print(f"\n{dienst.name} ({dienst.kuerzel}):") - print(f"{'Eltern':<20} {'Ziel Global':>12} {'Ziel Lokal':>12} {'Tatsächlich':>12} " - f"{'Δ Global':>12} {'Δ Lokal':>12}") - print("-" * 110) - - for eltern in sorted(self.eltern): - z_global = ziel_global[eltern][dienst] - z_lokal = ziel_lokal[eltern][dienst] - ist = tatsaechlich[eltern][dienst] - delta_global = ist - z_global - delta_lokal = ist - z_lokal - - # Farbcodierung für Abweichungen (ANSI-Codes) - farbe_global = "" - farbe_lokal = "" - reset = "" - - if abs(delta_global) > 0.5: - farbe_global = "\033[93m" if abs(delta_global) <= 1.0 else "\033[91m" # Gelb oder Rot - reset = "\033[0m" - - if abs(delta_lokal) > 0.5: - farbe_lokal = "\033[93m" if abs(delta_lokal) <= 1.0 else "\033[91m" - reset = "\033[0m" - - print(f"{eltern:<20} {z_global:>12.2f} {z_lokal:>12.2f} {ist:>12} " - f"{farbe_global}{delta_global:>+12.2f}{reset} {farbe_lokal}{delta_lokal:>+12.2f}{reset}") - - # Summen - summe_z_global = sum(ziel_global[e][dienst] for e in self.eltern) - summe_z_lokal = sum(ziel_lokal[e][dienst] for e in self.eltern) - summe_ist = sum(tatsaechlich[e][dienst] for e in self.eltern) - - print("-" * 110) - print(f"{'SUMME':<20} {summe_z_global:>12.2f} {summe_z_lokal:>12.2f} {summe_ist:>12} " - f"{summe_ist - summe_z_global:>+12.2f} {summe_ist - summe_z_lokal:>+12.2f}") - - # Gesamtstatistik - print("\n" + "="*110) - print("ZUSAMMENFASSUNG") - print("="*110) - - # Maximale Abweichungen finden - max_abw_global = 0 - max_abw_lokal = 0 - - for eltern in self.eltern: - for dienst in self.dienste: - ist = tatsaechlich[eltern][dienst] - max_abw_global = max(max_abw_global, abs(ist - ziel_global[eltern][dienst])) - max_abw_lokal = max(max_abw_lokal, abs(ist - ziel_lokal[eltern][dienst])) - - print(f"Maximale Abweichung von Global-Ziel: {max_abw_global:.2f} Dienste") - print(f"Maximale Abweichung von Lokal-Ziel: {max_abw_lokal:.2f} Dienste") - print("\nLegende: Δ = Tatsächlich - Ziel (positiv = mehr als Ziel, negativ = weniger als Ziel)") - def main() -> None: if len(sys.argv) < 4: @@ -826,28 +531,28 @@ def main() -> None: print("="*50) try: - planer = Elterndienstplaner() + # Create data model and load data + daten = ElterndienstplanerDaten() + daten.lade_daten(eingabe_datei, eltern_datei, vorherige_datei) - # Daten laden - planer.lade_eingabe_csv(eingabe_datei) - planer.lade_eltern_csv(eltern_datei) - if vorherige_datei: - planer.lade_vorherige_ausgaben_csv(vorherige_datei) + # Create output handler and optimization engine + ausgabe = ElterndienstAusgabe(daten) + planer = Elterndienstplaner(daten, ausgabe) # Optimierung - prob, x, ziel_lokal, ziel_global = planer.erstelle_optimierungsmodell() + prob, x = planer.erstelle_optimierungsmodell() lösung = planer.löse_optimierung(prob, x) if lösung is not None: # Ergebnisse ausgeben - planer.schreibe_ausgabe_csv(ausgabe_datei, lösung) - planer.drucke_statistiken(lösung) + ausgabe.schreibe_ausgabe_csv(ausgabe_datei, lösung) + ausgabe.drucke_statistiken(lösung) - # Visualisierung der Verteilungen - planer.visualisiere_verteilungen(lösung, ziel_lokal, ziel_global) + # Visualisierung der Verteilungen (uses Observer Pattern targets) + ausgabe.visualisiere_verteilungen(lösung) # Visualisierung der Präferenz-Verletzungen - planer.visualisiere_praeferenz_verletzungen(lösung) + ausgabe.visualisiere_praeferenz_verletzungen(lösung) print("\n✓ Planung erfolgreich abgeschlossen!") else: @@ -856,6 +561,8 @@ def main() -> None: except Exception as e: print(f"\n✗ Fehler: {e}") + import traceback + traceback.print_exc() sys.exit(1) diff --git a/typen.py b/typen.py new file mode 100644 index 0000000..ccf8500 --- /dev/null +++ b/typen.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +""" +Gemeinsame Type Aliases für den Elterndienstplaner + +Autor: Automatisch generiert +Datum: Dezember 2025 +""" + +from typing import Dict, Tuple, DefaultDict, TypeAlias, TYPE_CHECKING +from datetime import date +import pulp + +# Forward reference für zirkuläre Import-Vermeidung +if TYPE_CHECKING: + from datenmodell import Dienst + +# Definiert, welche Namen bei "from typen import *" exportiert werden +__all__ = ['Eltern', 'Zielverteilung', 'Entscheidungsvariablen'] + +# Type Alias für Elternnamen +Eltern: TypeAlias = str + +# Type Alias für Zielverteilungen +# Struktur: DefaultDict[Elternname, DefaultDict[Dienst, Anzahl]] +Zielverteilung: TypeAlias = DefaultDict[Eltern, DefaultDict['Dienst', float]] + +# Type Alias für Entscheidungsvariablen des Optimierungsproblems +# Struktur: Dict[(Eltern, Datum, Dienst), LP-Variable] +Entscheidungsvariablen: TypeAlias = Dict[Tuple[Eltern, date, 'Dienst'], pulp.LpVariable]