diff --git a/CLAUDE.md b/CLAUDE.md index dc4dc97..bbbbfbc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,8 +72,9 @@ the same layout: The GA cycle and penalty scheme are read directly from `$PLATZ_CFG/zyklus.cfg` and `$PLATZ_CFG/strafen.cfg` (independent of `--indir`). There is no `platz.cfg` indirection any -more — it was removed; `zyklus.cfg`/`strafen.cfg` are loaded straight from `$PLATZ_CFG`, and -the GA profile is chosen by the `--zyklus-art ` switch (default `Adaptiv`). +more — it was removed; `zyklus.cfg`/`strafen.cfg` are loaded straight from `$PLATZ_CFG`, the +GA profile is chosen by the `--zyklus-art ` switch (default `Adaptiv`), and the penalty +profile by the `--strafen ` switch (default `default`). - `cfg/zyklus.cfg` — defines the GA profiles. Two flavours: **static** profiles (`Easy`, `Simple`) give an explicit sequence of actions (`e`=erzeugen/create, `s`=selektieren/ @@ -84,10 +85,18 @@ the GA profile is chosen by the `--zyklus-art ` switch (default `Adaptiv`) with a shrinking population (`Start`, `Schrumpfung`, `MinPopulation`) and a convergence stop (`Geduld` generations without an improvement of at least `Schwelle`, capped by `MaxGenerationen`). Static profiles run via `Farm._LaufStatisch()`. -- `cfg/strafen.cfg` — the penalty ("Strafpunkte") table: cost of splitting a group across - tables (by group size and number of splits), cost of a lone person, per-table - under-utilization penalties, and (optional `[Abstand]` section) a penalty for the spatial - distance between split-off parts of a group. +- `cfg/strafen.cfg` — the penalty ("Strafpunkte") profiles. Each section is one complete + profile ("Vorgehen") in a single namespace, chosen by `--strafen
` (default + `default`); this lets several scoring strategies live in one file (the shipped file also + has a `locker` example with cheaper splits). Keys per profile: `Anzahl_Trennungen_N` + (`{groupsize:points}` for a group split across `N+1` tables, i.e. `N` splits — recursing to + smaller sizes when no exact entry), `NichtNachbar` (no group-mate at a neighbouring table), + `Allein` (a lone person), optional `TischWertigkeiten` (`{freeseats:points}` under-utilization, + with a nearest-lower-key fallback; absent = free seats not penalised, as in the `locker` + profile), and optional `AbstandFaktor` (per spatial-distance unit + between split-off parts; absent = distance not penalised). `Strafliste.laden(FileName, + Sektion='default')` parses one section; the old `[Trennung]`/`[Globals]`/`[Abstand]` + three-section layout is gone. `tische.ini`/`bestellung.json`/`TischePersonen.txt`/`PersonenTische.csv` are fixed filenames resolved as `os.path.join(indir, ...)` in `platz.py`. diff --git a/cfg/strafen.cfg b/cfg/strafen.cfg index 5109dc4..005646f 100644 --- a/cfg/strafen.cfg +++ b/cfg/strafen.cfg @@ -1,38 +1,56 @@ -# Strafpunkte fuer die Trennung einer Gruppe: -# Aufbau: Anzahl der Trennungen = { Gruppengroesse:Strafpunkte, .. } -# Deutlich hoeher als frueher, damit eine Trennung teurer ist als der Gewinn, -# einen Tisch dafuer teilbelegt zu lassen (sonst werden Gruppen getrennt, -# obwohl daneben leere Tische stehen). -[Trennung] -1={ 2:-25, 3:-22, 4:-18, 6:-14, 8:-12, 10:-10 } -2={ 3:-40, 4:-30, 6:-22, 8:-18, 10:-14 } -3={ 4:-60, 6:-40, 8:-30, 10:-20 } +# Strafpunkte-Profile ("Vorgehen") fuer die Bewertung einer Sitzplatzverteilung. +# +# Jede Sektion ist EIN vollstaendiges Bewertungsprofil mit allen Angaben. So +# lassen sich mehrere Vorgehensweisen nebeneinander pflegen und speichern. Per +# Schalter --strafen waehlt man ein Profil; ohne Schalter wird +# [default] verwendet. +# +# Schluessel je Profil: +# Anzahl_Trennungen_N = { Gruppengroesse:Strafpunkte, .. } +# Strafpunkte fuer das Aufteilen einer Gruppe (N = Anzahl der Trennungen, +# also auf N+1 Tische verteilt), gestaffelt nach Gruppengroesse. Fehlt ein +# exakter Gruppengroessen-Eintrag, wird auf die naechstkleinere Groesse +# zurueckgegriffen. Mindestens Anzahl_Trennungen_1 sollte gesetzt sein. +# NichtNachbar = Strafe, wenn zwei Gruppenteile nicht am Nachbartisch sitzen +# Allein = Strafe, wenn eine Person ohne Gruppenmitglied am Tisch sitzt +# TischWertigkeiten = { freiePlaetze:Strafpunkte, .. } fuer freie Sitze an einem +# (teil-)belegten Tisch. Fehlt ein exakter Eintrag, gilt der +# naechstkleinere. Teilbelegte Tische sind am schlechtesten +# (dort passt keine ganze Gruppe mehr hin), daher steigt die +# Strafe mit den freien Plaetzen; ein komplett leerer Tisch +# (hoechster Schluessel) ist etwas besser, aber nicht gratis, +# damit sich das Zusammenziehen von Gruppen lohnt. +# Optional; fehlt der ganze Schluessel, werden freie Plaetze +# gar nicht bestraft -> mehr Teilbelegungen moeglich. +# AbstandFaktor = Strafpunkte je normierter Abstandseinheit (0..100) zwischen +# getrennten Teilen einer Gruppe. Der Abstand ist auf die +# maximale Ausdehnung der Tischanordnung normiert (100 = +# ein Teil ganz links/oben, der andere ganz rechts/unten). +# AbstandFaktor=-0.2 kostet den Maximalabstand also -20. +# Optional; fehlt der Schluessel, wird der Abstand nicht +# bestraft (Faktor 0). -# Wieviele Strafpunkte bekommt man -# wenn zwei Gruppen nicht am Nachbartisch sitzen -# wenn einer aus einer Gruppe allein sitzen muss -[Globals] +# Standardprofil: Trennungen sind bewusst teuer, damit eine Gruppe lieber +# zusammen an einem teilbelegten Tisch sitzt, als neben einem leeren getrennt zu +# werden. +[default] +Anzahl_Trennungen_1={ 2:-25, 3:-22, 4:-18, 6:-14, 8:-12, 10:-10 } +Anzahl_Trennungen_2={ 3:-40, 4:-30, 6:-22, 8:-18, 10:-14 } +Anzahl_Trennungen_3={ 4:-60, 6:-40, 8:-30, 10:-20 } NichtNachbar=-3 Allein=-30 TischWertigkeiten={ 1:-3, 2:-3, 4:-5, 6:-6, 8:-4 } +AbstandFaktor=-0.2 -# TischWertigkeiten sind Strafpunkte fuer freie Sitze an einem Tisch, je nach -# Anzahl freier Plaetze (fehlt ein exakter Eintrag, gilt der naechstkleinere). -# Ziel: teilbelegte Tische sind am schlechtesten (dort passt keine ganze -# Gruppe mehr hin und die Sitzenden haben leere Nachbarplaetze), daher steigt -# die Strafe mit den freien Plaetzen an (1..7 frei: -3 -> -6). Ein *komplett* -# leerer Tisch ist etwas besser als ein halbleerer (-4 bei 8 frei), weil ein -# leerer Tisch als Ganzes fuer eine Gruppe frei bleibt - er soll aber nicht -# gratis sein, damit sich das Zusammenziehen von Gruppen lohnt. - -# Strafpunkte fuer den Abstand zwischen getrennten Teilen einer Gruppe. -# Wird eine Gruppe auf mehrere Tische aufgeteilt, ist ein grosser raeumlicher -# Abstand zwischen den Teilen viel schlechter als ein kleiner. -# Der Abstand wird als normierter Wert 0..100 berechnet: Bezugsgroesse ist die -# maximale Ausdehnung der Tischanordnung (Breite oder Laenge). Ein Abstand von -# 100 heisst also, dass ein Gruppenteil ganz links/oben und der andere ganz -# rechts/unten sitzt. -# Faktor = Strafpunkte je normierter Abstandseinheit. Bei Faktor=-0.2 kostet -# der maximal moegliche Abstand (100) demnach -20 Punkte. -[Abstand] -Faktor=-0.2 +# Beispiel fuer ein alternatives Vorgehen: Trennungen sind guenstiger, der +# Abstand wird nicht bestraft, und freie Plaetze werden gar nicht bestraft +# (TischWertigkeiten fehlt bewusst) — so sind mehr Teilbelegungen moeglich. +# Nuetzlich fuer grosse Saele, in denen sich Gruppen kaum ungeteilt platzieren +# lassen. Aufruf: --strafen locker +[locker] +Anzahl_Trennungen_1={ 2:-12, 3:-10, 4:-8, 6:-6, 8:-5, 10:-4 } +Anzahl_Trennungen_2={ 3:-20, 4:-16, 6:-12, 8:-9, 10:-7 } +Anzahl_Trennungen_3={ 4:-30, 6:-22, 8:-16, 10:-12 } +NichtNachbar=-2 +Allein=-20 +AbstandFaktor=0.0 diff --git a/libs/Strukturdaten.py b/libs/Strukturdaten.py index 9772736..b733ccb 100755 --- a/libs/Strukturdaten.py +++ b/libs/Strukturdaten.py @@ -437,21 +437,45 @@ class Strafliste: return self.gibPunkte('Trennung',Gruppengroesse-1, N_Teilungen) else: raise IndexError("Bezeichner gibts nicht") - def laden( self, FileName ): - print("-- Strafliste laden") + def laden( self, FileName, Sektion='default' ): + """laedt ein Strafpunkte-Profil aus einer Sektion der strafen.cfg. + + Jede Sektion ist ein vollstaendiges Bewertungs-'Vorgehen' mit allen + Angaben in einem gemeinsamen Namensraum. Der Aufrufer waehlt das + Profil ueber 'Sektion' (Vorgabe 'default'); so lassen sich mehrere + Bewertungsstrategien in derselben Datei pflegen. Die Trennungs- + Straffen stehen unter den Schluesseln 'Anzahl_Trennungen_N'.""" + print("-- Strafliste laden (Profil '%s')" % Sektion) config = configparser.ConfigParser() config.read( os.path.expanduser(FileName) ) + if not config.has_section( Sektion ): + raise ValueError( + "Strafprofil '%s' nicht in %s gefunden (vorhanden: %s)" + % ( Sektion, FileName, ', '.join(config.sections()) or '-' ) ) # ast.literal_eval statt eval: die Werte sind reine Literale # (Zahlen und Dicts wie {2:-10}), also kein Code noetig. - self.NichtNachbar = ast.literal_eval(config.get( 'Globals', 'NichtNachbar' )) - self.Allein = ast.literal_eval(config.get( 'Globals', 'Allein' )) - self.TischWert = ast.literal_eval( config.get( 'Globals', 'TischWertigkeiten' ) ) - # Abschnitt [Abstand] ist optional; fehlt er, wird der Abstand - # getrennter Gruppen nicht bestraft (Faktor 0). - if config.has_section( 'Abstand' ): - self.AbstandFaktor = ast.literal_eval(config.get( 'Abstand', 'Faktor' )) - for (k,v) in config.items( 'Trennung' ): - self.GruppenTrennen[int(k)] = ast.literal_eval(v) + self.NichtNachbar = ast.literal_eval(config.get( Sektion, 'NichtNachbar' )) + self.Allein = ast.literal_eval(config.get( Sektion, 'Allein' )) + # TischWertigkeiten ist optional; fehlt der Schluessel, werden freie + # Plaetze gar nicht bestraft (leeres Dict -> gibPunkte('Tischbesetzung') + # gibt 0), wodurch mehr Teilbelegungen moeglich sind. + if config.has_option( Sektion, 'TischWertigkeiten' ): + self.TischWert = ast.literal_eval( config.get( Sektion, 'TischWertigkeiten' ) ) + else: + self.TischWert = {} + # AbstandFaktor ist optional; fehlt er, wird der Abstand getrennter + # Gruppen nicht bestraft (Faktor 0). + if config.has_option( Sektion, 'AbstandFaktor' ): + self.AbstandFaktor = ast.literal_eval(config.get( Sektion, 'AbstandFaktor' )) + # Trennungs-Straffen: Schluessel 'Anzahl_Trennungen_N' -> N Trennungen. + # frisches Dict, damit nicht der (mutable) Konstruktor-Default geteilt + # wird und Reste eines frueheren laden() bleiben. + self.GruppenTrennen = {} + for (k, v) in config.items( Sektion ): + # configparser liefert Schluessel klein geschrieben + if k.startswith( 'anzahl_trennungen_' ): + N = int( k.rsplit( '_', 1 )[-1] ) + self.GruppenTrennen[N] = ast.literal_eval(v) # Sitzplatzverteilung, bestehend aus den Einzelelementen diff --git a/libs/platz.py b/libs/platz.py index 40c3655..0e68d67 100755 --- a/libs/platz.py +++ b/libs/platz.py @@ -51,6 +51,10 @@ if __name__ == '__main__': parser.add_option( "--zyklus-art", dest="zyklusart", default="Adaptiv", help="Name des GA-Profils in zyklus.cfg (Vorgabe: Adaptiv). " "Weitere mitgelieferte Profile: Easy, Simple" ) + parser.add_option( "--strafen", dest="strafen", default="default", + help="Name des Strafpunkte-Profils (Sektion in strafen.cfg; " + "Vorgabe: default). Erlaubt mehrere Bewertungs-Vorgehen in einer " + "Datei, z.B. --strafen locker" ) parser.add_option( "--seed", dest="seed", type="int", default=None, help="Zufalls-Startwert (int) fuer random.seed(), macht den Lauf " "reproduzierbar. Ohne Angabe laeuft der GA nichtdeterministisch. " @@ -83,6 +87,7 @@ if __name__ == '__main__': Zyklusconfig = os.path.join( CfgDir, 'zyklus.cfg' ) Zyklusart = options.zyklusart Strafenconfig = os.path.join( CfgDir, 'strafen.cfg' ) + Strafenart = options.strafen # lade Eingabedateien Tischconfig = os.path.join( InDir, 'tische.ini' ) @@ -99,7 +104,10 @@ if __name__ == '__main__': Z.laden( Zyklusconfig, Zyklusart ) print(Z) SL = Strafliste() - SL.laden( Strafenconfig ) + try: + SL.laden( Strafenconfig, Strafenart ) + except ValueError as Fehler: + parser.error( str(Fehler) ) print(SL) TE = Tische() TE.laden( Tischconfig ) diff --git a/web/main.py b/web/main.py index 3e39f6b..562bfb2 100644 --- a/web/main.py +++ b/web/main.py @@ -56,15 +56,17 @@ app = FastAPI( # Zyklus und Strafliste einmal beim Start laden (wie in libs/platz.py): # zyklus.cfg und strafen.cfg direkt aus dem cfg-Verzeichnis (PLATZ_CFG bzw. # Projekt-cfg). GA-Profil per Umgebungsvariable PLATZ_ZYKLUSART (Vorgabe: -# Adaptiv). +# Adaptiv), Strafpunkte-Profil per PLATZ_STRAFENART (Vorgabe: default) — +# analog zum Schalter --strafen in libs/platz.py. ZYKLUSDATEI = os.path.join( CFGDIR, 'zyklus.cfg' ) STRAFENDATEI = os.path.join( CFGDIR, 'strafen.cfg' ) ZYKLUSART = os.environ.get( 'PLATZ_ZYKLUSART', 'Adaptiv' ) +STRAFENART = os.environ.get( 'PLATZ_STRAFENART', 'default' ) GAZyklus = Zyklus() GAZyklus.laden( ZYKLUSDATEI, ZYKLUSART ) Strafen = Strafliste() -Strafen.laden( STRAFENDATEI ) +Strafen.laden( STRAFENDATEI, STRAFENART ) # ---------------------------------------------------------------- Sessions SESSION_TTL_SEKUNDEN = 24 * 3600