Strafen können durch benannte Profile festgelegt werden

This commit is contained in:
Michael Stangl
2026-07-15 17:05:13 +02:00
parent db69f3498d
commit 4a059f32c5
5 changed files with 114 additions and 53 deletions
+15 -6
View File
@@ -72,8 +72,9 @@ the same layout:
The GA cycle and penalty scheme are read directly from `$PLATZ_CFG/zyklus.cfg` and 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 `$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 more — it was removed; `zyklus.cfg`/`strafen.cfg` are loaded straight from `$PLATZ_CFG`, the
the GA profile is chosen by the `--zyklus-art <name>` switch (default `Adaptiv`). GA profile is chosen by the `--zyklus-art <name>` switch (default `Adaptiv`), and the penalty
profile by the `--strafen <name>` switch (default `default`).
- `cfg/zyklus.cfg` — defines the GA profiles. Two flavours: **static** profiles (`Easy`, - `cfg/zyklus.cfg` — defines the GA profiles. Two flavours: **static** profiles (`Easy`,
`Simple`) give an explicit sequence of actions (`e`=erzeugen/create, `s`=selektieren/ `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 <name>` switch (default `Adaptiv`)
with a shrinking population (`Start`, `Schrumpfung`, `MinPopulation`) and a convergence with a shrinking population (`Start`, `Schrumpfung`, `MinPopulation`) and a convergence
stop (`Geduld` generations without an improvement of at least `Schwelle`, capped by stop (`Geduld` generations without an improvement of at least `Schwelle`, capped by
`MaxGenerationen`). Static profiles run via `Farm._LaufStatisch()`. `MaxGenerationen`). Static profiles run via `Farm._LaufStatisch()`.
- `cfg/strafen.cfg` — the penalty ("Strafpunkte") table: cost of splitting a group across - `cfg/strafen.cfg` — the penalty ("Strafpunkte") profiles. Each section is one complete
tables (by group size and number of splits), cost of a lone person, per-table profile ("Vorgehen") in a single namespace, chosen by `--strafen <section>` (default
under-utilization penalties, and (optional `[Abstand]` section) a penalty for the spatial `default`); this lets several scoring strategies live in one file (the shipped file also
distance between split-off parts of a group. 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 `tische.ini`/`bestellung.json`/`TischePersonen.txt`/`PersonenTische.csv` are fixed filenames
resolved as `os.path.join(indir, ...)` in `platz.py`. resolved as `os.path.join(indir, ...)` in `platz.py`.
+51 -33
View File
@@ -1,38 +1,56 @@
# Strafpunkte fuer die Trennung einer Gruppe: # Strafpunkte-Profile ("Vorgehen") fuer die Bewertung einer Sitzplatzverteilung.
# Aufbau: Anzahl der Trennungen = { Gruppengroesse:Strafpunkte, .. } #
# Deutlich hoeher als frueher, damit eine Trennung teurer ist als der Gewinn, # Jede Sektion ist EIN vollstaendiges Bewertungsprofil mit allen Angaben. So
# einen Tisch dafuer teilbelegt zu lassen (sonst werden Gruppen getrennt, # lassen sich mehrere Vorgehensweisen nebeneinander pflegen und speichern. Per
# obwohl daneben leere Tische stehen). # Schalter --strafen <Sektionsname> waehlt man ein Profil; ohne Schalter wird
[Trennung] # [default] verwendet.
1={ 2:-25, 3:-22, 4:-18, 6:-14, 8:-12, 10:-10 } #
2={ 3:-40, 4:-30, 6:-22, 8:-18, 10:-14 } # Schluessel je Profil:
3={ 4:-60, 6:-40, 8:-30, 10:-20 } # 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 # Standardprofil: Trennungen sind bewusst teuer, damit eine Gruppe lieber
# wenn zwei Gruppen nicht am Nachbartisch sitzen # zusammen an einem teilbelegten Tisch sitzt, als neben einem leeren getrennt zu
# wenn einer aus einer Gruppe allein sitzen muss # werden.
[Globals] [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 NichtNachbar=-3
Allein=-30 Allein=-30
TischWertigkeiten={ 1:-3, 2:-3, 4:-5, 6:-6, 8:-4 } 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 # Beispiel fuer ein alternatives Vorgehen: Trennungen sind guenstiger, der
# Anzahl freier Plaetze (fehlt ein exakter Eintrag, gilt der naechstkleinere). # Abstand wird nicht bestraft, und freie Plaetze werden gar nicht bestraft
# Ziel: teilbelegte Tische sind am schlechtesten (dort passt keine ganze # (TischWertigkeiten fehlt bewusst) — so sind mehr Teilbelegungen moeglich.
# Gruppe mehr hin und die Sitzenden haben leere Nachbarplaetze), daher steigt # Nuetzlich fuer grosse Saele, in denen sich Gruppen kaum ungeteilt platzieren
# die Strafe mit den freien Plaetzen an (1..7 frei: -3 -> -6). Ein *komplett* # lassen. Aufruf: --strafen locker
# leerer Tisch ist etwas besser als ein halbleerer (-4 bei 8 frei), weil ein [locker]
# leerer Tisch als Ganzes fuer eine Gruppe frei bleibt - er soll aber nicht Anzahl_Trennungen_1={ 2:-12, 3:-10, 4:-8, 6:-6, 8:-5, 10:-4 }
# gratis sein, damit sich das Zusammenziehen von Gruppen lohnt. Anzahl_Trennungen_2={ 3:-20, 4:-16, 6:-12, 8:-9, 10:-7 }
Anzahl_Trennungen_3={ 4:-30, 6:-22, 8:-16, 10:-12 }
# Strafpunkte fuer den Abstand zwischen getrennten Teilen einer Gruppe. NichtNachbar=-2
# Wird eine Gruppe auf mehrere Tische aufgeteilt, ist ein grosser raeumlicher Allein=-20
# Abstand zwischen den Teilen viel schlechter als ein kleiner. AbstandFaktor=0.0
# 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
+35 -11
View File
@@ -437,21 +437,45 @@ class Strafliste:
return self.gibPunkte('Trennung',Gruppengroesse-1, N_Teilungen) return self.gibPunkte('Trennung',Gruppengroesse-1, N_Teilungen)
else: else:
raise IndexError("Bezeichner gibts nicht") raise IndexError("Bezeichner gibts nicht")
def laden( self, FileName ): def laden( self, FileName, Sektion='default' ):
print("-- Strafliste laden") """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 = configparser.ConfigParser()
config.read( os.path.expanduser(FileName) ) 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 # ast.literal_eval statt eval: die Werte sind reine Literale
# (Zahlen und Dicts wie {2:-10}), also kein Code noetig. # (Zahlen und Dicts wie {2:-10}), also kein Code noetig.
self.NichtNachbar = ast.literal_eval(config.get( 'Globals', 'NichtNachbar' )) self.NichtNachbar = ast.literal_eval(config.get( Sektion, 'NichtNachbar' ))
self.Allein = ast.literal_eval(config.get( 'Globals', 'Allein' )) self.Allein = ast.literal_eval(config.get( Sektion, 'Allein' ))
self.TischWert = ast.literal_eval( config.get( 'Globals', 'TischWertigkeiten' ) ) # TischWertigkeiten ist optional; fehlt der Schluessel, werden freie
# Abschnitt [Abstand] ist optional; fehlt er, wird der Abstand # Plaetze gar nicht bestraft (leeres Dict -> gibPunkte('Tischbesetzung')
# getrennter Gruppen nicht bestraft (Faktor 0). # gibt 0), wodurch mehr Teilbelegungen moeglich sind.
if config.has_section( 'Abstand' ): if config.has_option( Sektion, 'TischWertigkeiten' ):
self.AbstandFaktor = ast.literal_eval(config.get( 'Abstand', 'Faktor' )) self.TischWert = ast.literal_eval( config.get( Sektion, 'TischWertigkeiten' ) )
for (k,v) in config.items( 'Trennung' ): else:
self.GruppenTrennen[int(k)] = ast.literal_eval(v) 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 # Sitzplatzverteilung, bestehend aus den Einzelelementen
+9 -1
View File
@@ -51,6 +51,10 @@ if __name__ == '__main__':
parser.add_option( "--zyklus-art", dest="zyklusart", default="Adaptiv", parser.add_option( "--zyklus-art", dest="zyklusart", default="Adaptiv",
help="Name des GA-Profils in zyklus.cfg (Vorgabe: Adaptiv). " help="Name des GA-Profils in zyklus.cfg (Vorgabe: Adaptiv). "
"Weitere mitgelieferte Profile: Easy, Simple" ) "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, parser.add_option( "--seed", dest="seed", type="int", default=None,
help="Zufalls-Startwert (int) fuer random.seed(), macht den Lauf " help="Zufalls-Startwert (int) fuer random.seed(), macht den Lauf "
"reproduzierbar. Ohne Angabe laeuft der GA nichtdeterministisch. " "reproduzierbar. Ohne Angabe laeuft der GA nichtdeterministisch. "
@@ -83,6 +87,7 @@ if __name__ == '__main__':
Zyklusconfig = os.path.join( CfgDir, 'zyklus.cfg' ) Zyklusconfig = os.path.join( CfgDir, 'zyklus.cfg' )
Zyklusart = options.zyklusart Zyklusart = options.zyklusart
Strafenconfig = os.path.join( CfgDir, 'strafen.cfg' ) Strafenconfig = os.path.join( CfgDir, 'strafen.cfg' )
Strafenart = options.strafen
# lade Eingabedateien # lade Eingabedateien
Tischconfig = os.path.join( InDir, 'tische.ini' ) Tischconfig = os.path.join( InDir, 'tische.ini' )
@@ -99,7 +104,10 @@ if __name__ == '__main__':
Z.laden( Zyklusconfig, Zyklusart ) Z.laden( Zyklusconfig, Zyklusart )
print(Z) print(Z)
SL = Strafliste() SL = Strafliste()
SL.laden( Strafenconfig ) try:
SL.laden( Strafenconfig, Strafenart )
except ValueError as Fehler:
parser.error( str(Fehler) )
print(SL) print(SL)
TE = Tische() TE = Tische()
TE.laden( Tischconfig ) TE.laden( Tischconfig )
+4 -2
View File
@@ -56,15 +56,17 @@ app = FastAPI(
# Zyklus und Strafliste einmal beim Start laden (wie in libs/platz.py): # 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. # zyklus.cfg und strafen.cfg direkt aus dem cfg-Verzeichnis (PLATZ_CFG bzw.
# Projekt-cfg). GA-Profil per Umgebungsvariable PLATZ_ZYKLUSART (Vorgabe: # 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' ) ZYKLUSDATEI = os.path.join( CFGDIR, 'zyklus.cfg' )
STRAFENDATEI = os.path.join( CFGDIR, 'strafen.cfg' ) STRAFENDATEI = os.path.join( CFGDIR, 'strafen.cfg' )
ZYKLUSART = os.environ.get( 'PLATZ_ZYKLUSART', 'Adaptiv' ) ZYKLUSART = os.environ.get( 'PLATZ_ZYKLUSART', 'Adaptiv' )
STRAFENART = os.environ.get( 'PLATZ_STRAFENART', 'default' )
GAZyklus = Zyklus() GAZyklus = Zyklus()
GAZyklus.laden( ZYKLUSDATEI, ZYKLUSART ) GAZyklus.laden( ZYKLUSDATEI, ZYKLUSART )
Strafen = Strafliste() Strafen = Strafliste()
Strafen.laden( STRAFENDATEI ) Strafen.laden( STRAFENDATEI, STRAFENART )
# ---------------------------------------------------------------- Sessions # ---------------------------------------------------------------- Sessions
SESSION_TTL_SEKUNDEN = 24 * 3600 SESSION_TTL_SEKUNDEN = 24 * 3600