From 095e022724d5eeac9fe08b890a8d95f2911cced8 Mon Sep 17 00:00:00 2001 From: Jan Hoheisel Date: Mon, 22 Dec 2025 00:56:13 +0100 Subject: [PATCH] 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