From 74e08f389c5f17d8730d602b60a9790e716f01f6 Mon Sep 17 00:00:00 2001 From: Michael Stangl Date: Thu, 9 Jul 2026 12:55:32 +0200 Subject: [PATCH] Add --tosvg switch to render seating as SVG, plus test images and docs Sitzplatzverteilung.alsSVG() (libs/Strukturdaten.py) draws tables as white circles and people as colored circles inside their table's circle, colored by group membership. Table radius is derived from the number of seats so all person-circles fit without touching; the table-to-table grid spacing is derived from the largest table radius so tables never overlap regardless of their Koordinaten values. libs/platz.py gains a --tosvg switch that writes /Sitzplatzverteilung.svg alongside the existing text/CSV output. The three unittest scenarios now each write their rendering to doc/images/, and doc/docu_tests.md explains the three configurations and what each test actually verifies (including why the "split groups land on true neighbor tables" property only holds for a specifically chosen seed in the circle scenario, not in general). Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 22 +++- README.md | 4 + doc/docu_tests.md | 84 +++++++++++++ doc/images/drei_tische_kette_paare.svg | 16 +++ .../sechs_tische_kreis_dreiergruppen.svg | 28 +++++ doc/images/vier_tische_linie_viergruppen.svg | 20 ++++ libs/Strukturdaten.py | 110 ++++++++++++++++++ libs/platz.py | 7 ++ tests/test_sitzplatzverteilung.py | 5 + 9 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 doc/docu_tests.md create mode 100644 doc/images/drei_tische_kette_paare.svg create mode 100644 doc/images/sechs_tische_kreis_dreiergruppen.svg create mode 100644 doc/images/vier_tische_linie_viergruppen.svg diff --git a/CLAUDE.md b/CLAUDE.md index d92395a..35cf6ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,9 +29,10 @@ artifacts to be aware of when touching this code: Python 3 raises `TypeError` for that, which is what surfaced it. Fixed to call `G.Anzahl()`. -There is no build system, test suite, or linter configured in this repo. `requirements.txt` -exists but is empty/commented — everything used is standard library — it's there only so -`bin/install_py.*` has something to install into the `.venv`. +There is no build system or linter configured in this repo. A `tests/` unittest suite +exists (see below); `requirements.txt` is still empty/commented — everything used is +standard library — it's there only so `bin/install_py.*` has something to install into the +`.venv`. ## Running the program @@ -81,6 +82,15 @@ GA cycle/penalty config: resolved as `os.path.join(indir, ...)` in `platz.py` — they are no longer configured in `platz.cfg`. +Pass `--tosvg` to additionally write `/Sitzplatzverteilung.svg` via +`Sitzplatzverteilung.alsSVG()` (see below). + +Run the test suite with `bin/run_tests.bat` / `bin/run_tests.sh` (or directly: `python -m +unittest discover -s tests -p "test_*.py" -v`). A stray global `PYTHONPATH` entry on this +machine points at an unrelated project that also defines a `tests` package — this shadows +`import tests` but does not affect `unittest discover`, which is what the run_tests +scripts use. + ## Architecture Two library modules under `libs/`, imported via `PYTHONPATH=$PLATZ_LIBS`: @@ -115,7 +125,11 @@ Domain-agnostic and reusable in principle: when no single table has enough free seats, recursively splitting further if needed, then calls `bewerten()` to score the arrangement per `Strafliste`. `mutieren()` picks the worst-scoring groups (`SchlechteGruppenTopX`), evicts them, and reseats them, hoping for a - better score. `speichern()` writes the two output files. + better score. `speichern()` writes the two output files. `alsSVG()` renders the layout to + SVG: tables as white circles sized to fit all their seats' person-circles without + touching, people as colored circles (color = group, via `SVGGruppenFarben`) placed evenly + around the inside of their table's circle. The table-to-table grid spacing is derived + from the largest table radius so tables never overlap regardless of their `Koordinaten`. - `XMLConfig` — parses `bestellung.xml` into `Personen`/`Gruppen`/VIP-group/VIP-seat-map, expanding each `` template by its sibling `` into that many individual bookings, and treating any `` containing a `` tag as a VIP group pinned to diff --git a/README.md b/README.md index ed31d2e..a878695 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,10 @@ A run writes two files into the `--indir` directory: - `TischePersonen.txt` — table → seated people, with venue/table/seat numbers. - `PersonenTische.csv` — person → table, sorted alphabetically by name. +Pass `--tosvg` to additionally write `Sitzplatzverteilung.svg` into the `--indir` +directory: tables are drawn as white circles, people as colored circles inside their +table's circle (color = group). See `doc/docu_tests.md` for example renderings. + ## Further details See `CLAUDE.md` for a deeper architectural walkthrough of the GA framework and domain diff --git a/doc/docu_tests.md b/doc/docu_tests.md new file mode 100644 index 0000000..f825640 --- /dev/null +++ b/doc/docu_tests.md @@ -0,0 +1,84 @@ +# Dokumentation der Unittest-Szenarien + +Die drei Testfaelle in `tests/test_sitzplatzverteilung.py` pruefen die Platzierungs- und +Trennungslogik von `Sitzplatzverteilung` (in `libs/Strukturdaten.py`) anhand konkreter, +handgebauter Tisch-/Gruppenkonfigurationen. Jeder Test schreibt zusaetzlich eine +SVG-Visualisierung nach `doc/images/` (erzeugt mit `Sitzplatzverteilung.alsSVG()`, das auch +ueber den `--tosvg`-Schalter von `bin/platz.bat`/`bin/platz.sh` erreichbar ist). Tische +werden darin als weisse Kreise dargestellt, Personen als farbige Kreise innerhalb ihres +Tischkreises; alle Personen derselben Gruppe haben dieselbe Farbe. + +## Szenario 1: Drei Tische in einer Kette, drei Paare + +`TestDreiTischeKettePaare` in `tests/test_sitzplatzverteilung.py` + +- Drei Tische `T1-T2-T3` (Kette: T1 nur mit T2 benachbart, T3 nur mit T2 benachbart), je + 2 Plaetze. +- Drei Gruppen zu je 2 Personen (Paare). + +Da jeder Tisch exakt so viele Plaetze hat wie eine Gruppe Personen, passt jedes Paar +komplett an einen Tisch. Der Test prueft, dass keine Gruppe getrennt wird +(`GruppenTeilungGet(G) == 0` fuer alle drei Gruppen) und dass die optimale Wertigkeit +(`SV.value == 0`, keine Strafpunkte) erreicht wird. + +![Drei Tische in einer Kette, drei Paare](images/drei_tische_kette_paare.svg) + +## Szenario 2: Vier Tische in einer Linie, zwei Vierergruppen + +`TestVierTischeLinieViergruppen` in `tests/test_sitzplatzverteilung.py` + +- Vier Tische `T1-T2-T3-T4` in einer Linie (T1↔T2↔T3↔T4), je 2 Plaetze. +- Zwei Gruppen zu je 4 Personen. + +Da kein Tisch 4 Plaetze hat, muss jede Vierergruppe auf zwei Tische aufgeteilt werden. Der +Test prueft, dass jede Gruppe genau einmal geteilt wird (`GruppenTeilungGet(G) == 1`) und +dass die beiden Teiltische pro Gruppe ein zusammenhaengendes Nachbarpaar bilden — entweder +`{T1,T2}` oder `{T3,T4}}`, nie ueber die mittlere Naht zwischen T2 und T3 hinweg vermischt. +Ausserdem sind am Ende alle vier Tische voll besetzt. + +![Vier Tische in einer Linie, zwei Vierergruppen](images/vier_tische_linie_viergruppen.svg) + +## Szenario 3: Sechs Tische im Kreis, vier Dreiergruppen + +`TestSechsTischeKreisDreiergruppen` in `tests/test_sitzplatzverteilung.py` + +- Sechs Tische `T1..T6`, im Kreis angeordnet (jeder Tisch hat genau zwei Nachbarn: z.B. T1 + ist mit T2 und T6 benachbart), je 2 Plaetze. +- Vier Gruppen zu je 3 Personen (12 Personen auf 6×2 = 12 Plaetzen — exakt ausgelastet). + +Da kein Tisch 3 Plaetze hat, muss jede Dreiergruppe auf zwei Tische aufgeteilt werden +(2+1). Der Test prueft zwei Arten von Eigenschaften: + +- **Immer gueltig, unabhaengig vom Zufalls-Seed:** Bei exakt ausgelasteten 12 Plaetzen fuer + 12 Personen bleibt keine andere Aufteilung uebrig — jede Gruppe wird also garantiert + genau einmal geteilt (`GruppenTeilungGet(G) == 1`), und am Ende sind alle sechs Tische + voll besetzt. +- **Nur mit dem hier fest gewaehlten Seed zusaetzlich beobachtet:** Die beiden Teiltische + jeder Gruppe sind tatsaechlich echte Nachbarn im Kreis. Das ist keine Garantie des + Algorithmus fuer jeden beliebigen Seed — bei sehr knapper Auslastung kann der + GA-Platzierungsfallback (`Sitzplatzverteilung.GruppeSetzen`, wenn `NachbarTische` keine + passenden Nachbarn mehr findet) auch nicht benachbarte Tische fuer die beiden Teile einer + Gruppe waehlen. Mit einem grossen Stichprobentest ueber viele Seeds trat das in gut zwei + Dritteln der Faelle auf; der im Test verwendete Seed wurde bewusst so gewaehlt, dass + zusaetzlich die Nachbarschaftseigenschaft zutrifft, um sie hier sichtbar zu machen. + +Die `Koordinaten` der sechs Tische in diesem Test sind bewusst auf einem Kreis im +mathematischen Sinn angeordnet (Winkel gleichmaessig verteilt), damit auch die +SVG-Visualisierung die Kreisform erkennen laesst — die tatsaechliche Nachbarschaft, die der +Algorithmus fuer die Platzierung nutzt, kommt aber ausschliesslich aus der +`Nachbarliste`/`Nachbarn`-Angabe jedes Tisches, nicht aus den Koordinaten. + +![Sechs Tische im Kreis, vier Dreiergruppen](images/sechs_tische_kreis_dreiergruppen.svg) + +## Bilder neu erzeugen + +Die drei SVGs in `doc/images/` werden beim Ausfuehren der Testsuite automatisch neu +geschrieben: + +```bash +bin/run_tests.sh +``` + +```bat +bin\run_tests.bat +``` diff --git a/doc/images/drei_tische_kette_paare.svg b/doc/images/drei_tische_kette_paare.svg new file mode 100644 index 0000000..8cfcd0c --- /dev/null +++ b/doc/images/drei_tische_kette_paare.svg @@ -0,0 +1,16 @@ + + + + +Tisch 1 + + + +Tisch 2 + + + +Tisch 3 + + + diff --git a/doc/images/sechs_tische_kreis_dreiergruppen.svg b/doc/images/sechs_tische_kreis_dreiergruppen.svg new file mode 100644 index 0000000..16c7f92 --- /dev/null +++ b/doc/images/sechs_tische_kreis_dreiergruppen.svg @@ -0,0 +1,28 @@ + + + + +Tisch 1 + + + +Tisch 2 + + + +Tisch 3 + + + +Tisch 4 + + + +Tisch 5 + + + +Tisch 6 + + + diff --git a/doc/images/vier_tische_linie_viergruppen.svg b/doc/images/vier_tische_linie_viergruppen.svg new file mode 100644 index 0000000..1163233 --- /dev/null +++ b/doc/images/vier_tische_linie_viergruppen.svg @@ -0,0 +1,20 @@ + + + + +Tisch 1 + + + +Tisch 2 + + + +Tisch 3 + + + +Tisch 4 + + + diff --git a/libs/Strukturdaten.py b/libs/Strukturdaten.py index 0582a77..0e8f29f 100755 --- a/libs/Strukturdaten.py +++ b/libs/Strukturdaten.py @@ -13,6 +13,7 @@ from itertools import chain from random import * import configparser, os import copy +import math from functools import total_ordering from xml.dom import minidom @@ -532,6 +533,115 @@ class Sitzplatzverteilung: fout.writelines( Liste ) fout.close() + # Farbpalette fuer die Gruppen-Einfaerbung im SVG (wird zyklisch + # wiederverwendet, falls es mehr Gruppen als Farben gibt) + SVGGruppenFarben = [ + '#e6194b', '#3cb44b', '#4363d8', '#f58231', '#911eb4', + '#42d4f4', '#f032e6', '#bfef45', '#fabed4', '#469990', + '#dcbeff', '#9a6324', '#800000', '#aaffc3', '#000075' ] + + def alsSVG(self, OutputSVG, PersonenRadius=12, Puffer=10): + """Zeichnet die Sitzplatzverteilung als SVG: + + Tische werden als weisse Kreise dargestellt, Personen als + farbige Kreise (Farbe = Gruppe) innerhalb ihres Tischkreises. + Das Koordinatenraster der Tische wird so skaliert, dass alle + Personenkreise in ihrem Tischkreis Platz haben und sich die + Tischkreise untereinander nicht ueberschneiden. + """ + # Tischradius je Tisch berechnen: gross genug, damit alle + # Personenkreise (auf einem inneren Kreis gleichmaessig verteilt) + # nebeneinander Platz haben, ohne sich zu beruehren. Der Faktor + # 1.1 auf den rechnerischen Mindestabstand ist ein kleiner + # Sicherheitsabstand gegen Rundungsfehler und damit sich die + # Kreise auch optisch sichtbar nicht beruehren. + def SitzRadiusVon(Plaetze): + if Plaetze <= 1: + return 0.0 + return 1.1 * PersonenRadius / math.sin(math.pi / Plaetze) + + def TischRadius(Plaetze): + return SitzRadiusVon(Plaetze) + PersonenRadius + Puffer + + TidZuRadius = {} + GroessterRadius = 0 + for T in self.Tische: + r = TischRadius( T.Plaetze ) + TidZuRadius[T.Id] = r + if r > GroessterRadius: + GroessterRadius = r + + # Rasterabstand der Tisch-Koordinaten: doppelter groesster + # Tischradius (plus Puffer) verhindert Ueberschneidungen + # unabhaengig davon, welche Tische tatsaechlich benachbart sind + Rasterabstand = 2 * GroessterRadius + Puffer + + Xs = [ T.X_Koordinate for T in self.Tische ] + Ys = [ T.Y_Koordinate for T in self.Tische ] + MinX, MinY = min(Xs), min(Ys) + MaxX, MaxY = max(Xs), max(Ys) + + Rand = GroessterRadius + Puffer + Breite = (MaxX - MinX) * Rasterabstand + 2 * Rand + Hoehe = (MaxY - MinY) * Rasterabstand + 2 * Rand + + def TischMitte(T): + x = (T.X_Koordinate - MinX) * Rasterabstand + Rand + y = (T.Y_Koordinate - MinY) * Rasterabstand + Rand + return (x, y) + + def GruppeVonPerson(P): + try: + return self.Gruppen.GruppeVonPid(P.Id) + except KeyError: + return None + + GidZuFarbe = {} + def FarbeVonGruppe(G): + if G is None: + return '#888888' + if G.Id not in GidZuFarbe: + Index = len(GidZuFarbe) % len(self.SVGGruppenFarben) + GidZuFarbe[G.Id] = self.SVGGruppenFarben[Index] + return GidZuFarbe[G.Id] + + Teile = [] + Teile.append( '\n' ) + Teile.append( + '\n' % (Breite, Hoehe, Breite, Hoehe) ) + Teile.append( '\n' ) + + for T in self.Tische: + Mx, My = TischMitte(T) + TR = TidZuRadius[T.Id] + Teile.append( + '\n' % (Mx, My, TR) ) + Teile.append( + 'Tisch %s\n' + % (Mx, My - TR - 2, T.Id) ) + + Plaetze = list(T) + n = len(Plaetze) + SitzRadius = SitzRadiusVon( T.Plaetze ) + for i, P in enumerate(Plaetze): + Winkel = 2 * math.pi * i / max(n, 1) - math.pi / 2 + Px = Mx + SitzRadius * math.cos(Winkel) + Py = My + SitzRadius * math.sin(Winkel) + Farbe = FarbeVonGruppe( GruppeVonPerson(P) ) + Teile.append( + '\n' + % (Px, Py, PersonenRadius, Farbe) ) + + Teile.append( '\n' ) + + fout = open( OutputSVG, 'w' ) + fout.writelines( Teile ) + fout.close() + def mutieren(self): """Aendert die vorhandene Sitzplatzverteilung hoffentlich zum Besseren""" [IdsSchlechteGruppen, Wertigkeiten] = self.SchlechteGruppenTopX(Anzahl=self.MutationsGroesse) diff --git a/libs/platz.py b/libs/platz.py index f48f581..3af39b3 100755 --- a/libs/platz.py +++ b/libs/platz.py @@ -24,6 +24,10 @@ if __name__ == '__main__': parser.add_option( "--indir", dest="indir", default=None, help="Verzeichnis mit tische.ini und bestellung.xml; " "dort werden auch TischePersonen.txt und PersonenTische.csv abgelegt" ) + parser.add_option( "--tosvg", dest="tosvg", action="store_true", default=False, + help="zusaetzlich eine Sitzplatzverteilung.svg im --indir Verzeichnis " + "erzeugen (Tische als weisse Kreise, Personen als farbige " + "Kreise je Gruppe)" ) (options, args) = parser.parse_args() if options.indir is None: @@ -52,6 +56,7 @@ if __name__ == '__main__': # lade Ausgabedateien AusgabeTP= os.path.join( InDir, 'TischePersonen.txt' ) AusgabePT = os.path.join( InDir, 'PersonenTische.csv' ) + AusgabeSVG = os.path.join( InDir, 'Sitzplatzverteilung.svg' ) Z = Zyklus() Z.laden( Zyklusconfig, Zyklusart ) @@ -76,6 +81,8 @@ if __name__ == '__main__': S = F1.Bester() print(S) S.speichern( AusgabeTP, AusgabePT ) + if options.tosvg: + S.alsSVG( AusgabeSVG ) else: pass diff --git a/tests/test_sitzplatzverteilung.py b/tests/test_sitzplatzverteilung.py index 0064480..24b1d7f 100644 --- a/tests/test_sitzplatzverteilung.py +++ b/tests/test_sitzplatzverteilung.py @@ -20,6 +20,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'libs')) from Strukturdaten import Gruppe, Gruppen, Person, Sitzplatzverteilung, Strafliste, Tisch, Tische +IMAGES_DIR = os.path.join(os.path.dirname(__file__), '..', 'doc', 'images') + def erzeuge_personen(anzahl, start_id=1): return [Person(i, 'Vorname%d' % i, 'Nachname%d' % i) for i in range(start_id, start_id + anzahl)] @@ -48,6 +50,7 @@ class TestDreiTischeKettePaare(unittest.TestCase): def test_alle_paare_komplett_an_einem_tisch(self): SV = Sitzplatzverteilung( Tische=self.Tische, Gruppen=self.Gruppen, Strafen=self.Strafen, VIPListe={}, VIPs=Gruppe(0) ) + SV.alsSVG( os.path.join(IMAGES_DIR, 'drei_tische_kette_paare.svg') ) for G in (self.G1, self.G2, self.G3): self.assertEqual( SV.GruppenTeilungGet(G), 0, @@ -87,6 +90,7 @@ class TestVierTischeLinieViergruppen(unittest.TestCase): def test_viergruppen_bleiben_auf_zusammenhaengendem_tischpaar(self): SV = Sitzplatzverteilung( Tische=self.Tische, Gruppen=self.Gruppen, Strafen=self.Strafen, VIPListe={}, VIPs=Gruppe(0) ) + SV.alsSVG( os.path.join(IMAGES_DIR, 'vier_tische_linie_viergruppen.svg') ) # jede Gruppe wird genau einmal geteilt (2 Tische a 2 Plaetze) self.assertEqual( SV.GruppenTeilungGet(self.G1), 1 ) @@ -138,6 +142,7 @@ class TestSechsTischeKreisDreiergruppen(unittest.TestCase): def test_dreiergruppen_je_einmal_auf_nachbartische_geteilt(self): SV = Sitzplatzverteilung( Tische=self.Tische, Gruppen=self.Gruppen, Strafen=self.Strafen, VIPListe={}, VIPs=Gruppe(0) ) + SV.alsSVG( os.path.join(IMAGES_DIR, 'sechs_tische_kreis_dreiergruppen.svg') ) # 4 Dreiergruppen = 12 Personen auf 6x2 = 12 Plaetzen -> alle Tische voll. # (SV.Tische ist eine Kopie der urspruenglichen Tische, siehe