644f61ef6b
Zyklus/Farm: - neuer adaptiver Modus: grosse Startpopulation, die je Generation schrumpft (Start/Schrumpfung/MinPopulation) und bei Konvergenz stoppt (Geduld Generationen ohne Verbesserung >= Schwelle, max MaxGenerationen) - Zyklus.laden erkennt adaptives Profil am Feld 'Start' (Modus adaptiv), sonst klassisch (Abfolge/Anzahl); Farm._LaufStatisch/_LaufAdaptiv - neue Trunkierungs-Selektion Farm.PopulationBegrenzen - [Adaptiv]-Profil in zyklus.cfg (dokumentiert) Entwicklungsgrafik: - zweites Panel unter dem Verlauf zeigt die Anzahl erzeugter Verteilungen im Pool je Schritt (gemeinsame Zeitachse) Konfiguration: - platz.cfg entfernt; zyklus.cfg/strafen.cfg werden direkt aus $PLATZ_CFG geladen - neuer Schalter --zyklus-art (Default Adaptiv) waehlt das GA-Profil - web/main.py analog: direktes Laden, Profil via PLATZ_ZYKLUSART Doku (README.md, CLAUDE.md) und Tests entsprechend aktualisiert; Beispiele mit dem Adaptiv-Default neu berechnet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
332 lines
12 KiB
Python
332 lines
12 KiB
Python
"""
|
|
Unittests fuer Sitzplatzverteilung anhand von drei einfachen Szenarien:
|
|
|
|
1. Drei Tische in einer Kette (je 2 Plaetze), drei Paare
|
|
-> jedes Paar passt exakt an einen Tisch, keine Gruppe wird getrennt.
|
|
2. Vier Tische in einer Linie (je 2 Plaetze), zwei Vierergruppen
|
|
-> jede Vierergruppe verteilt sich auf ein zusammenhaengendes
|
|
Tischpaar (T1+T2 bzw. T3+T4), nie ueber die mittlere Naht hinweg.
|
|
3. Sechs Tische im Kreis (je 2 Plaetze), vier Dreiergruppen
|
|
-> jede Dreiergruppe wird genau einmal auf zwei Nachbartische
|
|
aufgeteilt (2+1), da kein Tisch drei Plaetze hat.
|
|
"""
|
|
|
|
import os
|
|
import random
|
|
import sys
|
|
import unittest
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'libs'))
|
|
|
|
from Strukturdaten import Gruppe, Gruppen, JSONConfig, Person, Sitzplatzverteilung, Strafliste, Tisch, Tische
|
|
from ga import Entwicklung, Farm, Zyklus, _StringAusdruck
|
|
|
|
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)]
|
|
|
|
|
|
class TestDreiTischeKettePaare(unittest.TestCase):
|
|
"""Drei Tische paarweise benachbart (Kette T1-T2-T3), je 2 Plaetze, 3 Paare."""
|
|
|
|
def setUp(self):
|
|
random.seed(1)
|
|
self.T1 = Tisch(1, Plaetze=2, Koordinaten=(1, 1), Nachbarliste=[2])
|
|
self.T2 = Tisch(2, Plaetze=2, Koordinaten=(2, 1), Nachbarliste=[1, 3])
|
|
self.T3 = Tisch(3, Plaetze=2, Koordinaten=(3, 1), Nachbarliste=[2])
|
|
self.Tische = Tische([self.T1, self.T2, self.T3])
|
|
|
|
Personen = erzeuge_personen(6)
|
|
self.G1 = Gruppe(1, Personen[0:2])
|
|
self.G2 = Gruppe(2, Personen[2:4])
|
|
self.G3 = Gruppe(3, Personen[4:6])
|
|
self.Gruppen = Gruppen([self.G1, self.G2, self.G3])
|
|
|
|
self.Strafen = Strafliste(
|
|
GruppenTrennen={1: {2: -10}, 2: {}, 3: {}},
|
|
NichtNachbar=-3, Allein=-30, TischWert={1: -3, 2: -2} )
|
|
|
|
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,
|
|
"Paar %s haette nicht getrennt werden duerfen" % G.Id )
|
|
|
|
# jeder der drei Tische ist von genau einem Paar voll besetzt
|
|
Tid_zu_Gid = {}
|
|
for Gid, Tids in SV.Gid_Tid.items():
|
|
self.assertEqual( len(Tids), 1 )
|
|
Tid_zu_Gid[next(iter(Tids))] = Gid
|
|
self.assertEqual( set(Tid_zu_Gid.keys()), {1, 2, 3} )
|
|
|
|
# optimale Verteilung: keine Strafpunkte
|
|
self.assertEqual( SV.value, 0 )
|
|
|
|
|
|
class TestVierTischeLinieViergruppen(unittest.TestCase):
|
|
"""Vier Tische in einer Linie (T1-T2-T3-T4), je 2 Plaetze, 2 Vierergruppen."""
|
|
|
|
def setUp(self):
|
|
random.seed(1)
|
|
self.T1 = Tisch(1, Plaetze=2, Koordinaten=(1, 1), Nachbarliste=[2])
|
|
self.T2 = Tisch(2, Plaetze=2, Koordinaten=(2, 1), Nachbarliste=[1, 3])
|
|
self.T3 = Tisch(3, Plaetze=2, Koordinaten=(3, 1), Nachbarliste=[2, 4])
|
|
self.T4 = Tisch(4, Plaetze=2, Koordinaten=(4, 1), Nachbarliste=[3])
|
|
self.Tische = Tische([self.T1, self.T2, self.T3, self.T4])
|
|
|
|
Personen = erzeuge_personen(8)
|
|
self.G1 = Gruppe(1, Personen[0:4])
|
|
self.G2 = Gruppe(2, Personen[4:8])
|
|
self.Gruppen = Gruppen([self.G1, self.G2])
|
|
|
|
self.Strafen = Strafliste(
|
|
GruppenTrennen={1: {4: -6}, 2: {}, 3: {}},
|
|
NichtNachbar=-3, Allein=-30, TischWert={1: -3, 2: -2} )
|
|
|
|
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 )
|
|
self.assertEqual( SV.GruppenTeilungGet(self.G2), 1 )
|
|
|
|
Tids_G1 = SV.Gid_Tid[self.G1.Id]
|
|
Tids_G2 = SV.Gid_Tid[self.G2.Id]
|
|
|
|
# jede Gruppe liegt auf genau einem der beiden erwarteten Nachbarpaare,
|
|
# nie ueber die mittlere Naht (T2-T3) hinweg vermischt
|
|
erwartete_paare = ({1, 2}, {3, 4})
|
|
self.assertIn( Tids_G1, erwartete_paare )
|
|
self.assertIn( Tids_G2, erwartete_paare )
|
|
self.assertNotEqual( Tids_G1, Tids_G2 )
|
|
|
|
# alle Tische sind voll besetzt, es sitzt niemand allein
|
|
# (SV.Tische ist eine Kopie der urspruenglichen Tische, siehe
|
|
# Sitzplatzverteilung.__init__ -> Tische.deepcopy())
|
|
for T in SV.Tische:
|
|
self.assertTrue( T.istvoll() )
|
|
|
|
|
|
class TestSechsTischeKreisDreiergruppen(unittest.TestCase):
|
|
"""Sechs Tische im Kreis (T1..T6), je 2 Plaetze, 4 Dreiergruppen."""
|
|
|
|
def setUp(self):
|
|
random.seed(2)
|
|
self.T1 = Tisch(1, Plaetze=2, Koordinaten=(0, 2), Nachbarliste=[2, 6])
|
|
self.T2 = Tisch(2, Plaetze=2, Koordinaten=(1, 1), Nachbarliste=[1, 3])
|
|
self.T3 = Tisch(3, Plaetze=2, Koordinaten=(1, -1), Nachbarliste=[2, 4])
|
|
self.T4 = Tisch(4, Plaetze=2, Koordinaten=(0, -2), Nachbarliste=[3, 5])
|
|
self.T5 = Tisch(5, Plaetze=2, Koordinaten=(-1, -1), Nachbarliste=[4, 6])
|
|
self.T6 = Tisch(6, Plaetze=2, Koordinaten=(-1, 1), Nachbarliste=[5, 1])
|
|
self.Tische = Tische([self.T1, self.T2, self.T3, self.T4, self.T5, self.T6])
|
|
self.NachbarnVonTid = {
|
|
1: {2, 6}, 2: {1, 3}, 3: {2, 4}, 4: {3, 5}, 5: {4, 6}, 6: {5, 1} }
|
|
|
|
Personen = erzeuge_personen(12)
|
|
self.G1 = Gruppe(1, Personen[0:3])
|
|
self.G2 = Gruppe(2, Personen[3:6])
|
|
self.G3 = Gruppe(3, Personen[6:9])
|
|
self.G4 = Gruppe(4, Personen[9:12])
|
|
self.Gruppen = Gruppen([self.G1, self.G2, self.G3, self.G4])
|
|
|
|
self.Strafen = Strafliste(
|
|
GruppenTrennen={1: {3: -8}, 2: {}, 3: {}},
|
|
NichtNachbar=-3, Allein=-30, TischWert={1: -3, 2: -2} )
|
|
|
|
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
|
|
# Sitzplatzverteilung.__init__ -> Tische.deepcopy())
|
|
for T in SV.Tische:
|
|
self.assertTrue( T.istvoll() )
|
|
|
|
# da kein Tisch 3 Plaetze hat, muss jede Dreiergruppe genau einmal
|
|
# auf zwei Tische aufgeteilt werden (2+1); dies gilt unabhaengig
|
|
# vom Zufalls-Seed, da bei voller Auslastung (12 Personen auf
|
|
# 12 Plaetzen) keine andere Aufteilung uebrig bleibt.
|
|
for G in (self.G1, self.G2, self.G3, self.G4):
|
|
self.assertEqual( SV.GruppenTeilungGet(G), 1 )
|
|
Tids = SV.Gid_Tid[G.Id]
|
|
self.assertEqual( len(Tids), 2 )
|
|
|
|
# mit dem hier fest gewaehlten Seed sitzen alle geteilten Gruppen
|
|
# zusaetzlich tatsaechlich an zwei benachbarten Tischen (der
|
|
# GA-Fallback duerfte bei knapper Auslastung im Kreis im
|
|
# Allgemeinen auch nicht-benachbarte Tische waehlen)
|
|
for G in (self.G1, self.G2, self.G3, self.G4):
|
|
Tid_a, Tid_b = tuple(SV.Gid_Tid[G.Id])
|
|
self.assertIn( Tid_b, self.NachbarnVonTid[Tid_a],
|
|
"Tische %s der Gruppe %s sind keine Nachbarn" % (SV.Gid_Tid[G.Id], G.Id) )
|
|
|
|
|
|
class TestJSONConfig(unittest.TestCase):
|
|
"""Parsen der bestellung.json: Gruppen, VIP-Bindung, Anzahl-Expansion."""
|
|
|
|
def test_gruppen_vips_und_sammelbuchung(self):
|
|
Daten = {
|
|
"gruppen": [
|
|
{
|
|
"name": "Brautpaar",
|
|
"tisch": 1,
|
|
"personen": [
|
|
{"vorname": "Julia", "nachname": "Brandt"},
|
|
{"vorname": "Daniel", "nachname": "Sommer", "titel": "Dr."},
|
|
],
|
|
},
|
|
{
|
|
"name": "Familie Huber",
|
|
"personen": [
|
|
{"vorname": "Anna", "nachname": "Huber"},
|
|
{"vorname": "Berta", "nachname": "Huber"},
|
|
],
|
|
},
|
|
{
|
|
"name": "Musikverein",
|
|
"anzahl": 5,
|
|
"personen": [ {"vorname": "Gast", "nachname": "Musikverein"} ],
|
|
},
|
|
]
|
|
}
|
|
PN, GN, VIPGruppe, VipHash, GruppenNamen = JSONConfig().HandleBestellung(Daten)
|
|
|
|
# VIP-Gruppe: beide ans Tisch 1 gebunden, nicht in den GA-Gruppen
|
|
self.assertEqual( VIPGruppe.Anzahl(), 2 )
|
|
self.assertEqual( set(VipHash.values()), {1} )
|
|
self.assertEqual( len(VipHash), 2 )
|
|
|
|
# regulaere Gruppen: Familie Huber (2) + Musikverein (5x expandiert) = 7
|
|
self.assertEqual( GN.NLeute, 7 )
|
|
self.assertEqual( len(GN.GruppenListe), 2 )
|
|
|
|
# Sammelbuchung: die eine Musikverein-Person wurde 5x vervielfacht
|
|
Groessen = sorted( G.Anzahl() for G in GN.GruppenListe )
|
|
self.assertEqual( Groessen, [2, 5] )
|
|
|
|
# Anzeigenamen kommen durch
|
|
self.assertIn( "Familie Huber", GruppenNamen.values() )
|
|
self.assertIn( "Musikverein", GruppenNamen.values() )
|
|
|
|
def test_fehlende_pflichtfelder_werfen_fehler(self):
|
|
with self.assertRaises(ValueError):
|
|
JSONConfig().HandleBestellung( {"keine_gruppen": []} )
|
|
with self.assertRaises(ValueError):
|
|
JSONConfig().HandleBestellung(
|
|
{"gruppen": [ {"personen": [ {"vorname": "Nur"} ]} ]} )
|
|
|
|
|
|
class TestStringAusdruck(unittest.TestCase):
|
|
"""Der sichere Zyklus-Ausdruck-Auswerter (Ersatz fuer eval)."""
|
|
|
|
def test_verkettung_und_vervielfachung(self):
|
|
self.assertEqual(
|
|
_StringAusdruck("'e,'+'s,z,m,j,z,'*3+'s'"),
|
|
'e,s,z,m,j,z,s,z,m,j,z,s,z,m,j,z,s' )
|
|
self.assertEqual( _StringAusdruck("'e,s'"), 'e,s' )
|
|
|
|
def test_lehnt_code_ab(self):
|
|
# kein Funktionsaufruf, keine Namen, kein Import
|
|
for boese in ("__import__('os')", "open('x')", "1+1", "a+'b'"):
|
|
with self.assertRaises(ValueError):
|
|
_StringAusdruck(boese)
|
|
|
|
|
|
class TestEntwicklung(unittest.TestCase):
|
|
"""Das Beobachtungsobjekt zeichnet den Verlauf auf und rendert SVG."""
|
|
|
|
class _L:
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def test_erfassen_und_svg(self):
|
|
E = Entwicklung()
|
|
E.erfassen( 'e', [self._L(-200), self._L(-150), self._L(-90)] )
|
|
E.erfassen( 's', [self._L(-90), self._L(-70)] )
|
|
E.erfassen( 'm', [self._L(-70), self._L(-40)] )
|
|
|
|
self.assertEqual( len(E.Punkte), 3 )
|
|
# je Punkt: (index, aktion, best, schnitt, schlecht, N)
|
|
_i, aktion, best, schnitt, schlecht, n = E.Punkte[0]
|
|
self.assertEqual( aktion, 'e' )
|
|
self.assertEqual( best, -90 )
|
|
self.assertEqual( schlecht, -200 )
|
|
self.assertEqual( n, 3 )
|
|
|
|
Pfad = os.path.join( IMAGES_DIR, 'test_entwicklung.svg' )
|
|
E.alsSVG( Pfad )
|
|
with open(Pfad) as f:
|
|
svg = f.read()
|
|
self.assertIn( '<svg', svg )
|
|
# 3 Wertigkeitskurven (best/schnitt/schlecht) + 1 Pool-Groessenkurve
|
|
self.assertEqual( svg.count('<polyline'), 4 )
|
|
# das zweite Panel zeigt die Anzahl erzeugter Verteilungen im Pool
|
|
self.assertIn( 'Anzahl erzeugter Verteilungen im Pool', svg )
|
|
self.assertIn( 'stroke="#4060a0"', svg ) # Pool-Kurve
|
|
|
|
def test_leere_entwicklung_wirft_beim_svg(self):
|
|
with self.assertRaises(ValueError):
|
|
Entwicklung().alsSVG( os.path.join(IMAGES_DIR, 'leer.svg') )
|
|
|
|
|
|
class TestAdaptiverZyklus(unittest.TestCase):
|
|
"""Der adaptive Zyklus startet mit vielen Loesungen, laesst die
|
|
Population schrumpfen und bricht bei Konvergenz ab."""
|
|
|
|
class _L:
|
|
"""minimale GA-Loesung: fester Wert, Mutation aendert nichts (so
|
|
konvergiert der beste Wert sofort -> Abbruch nach 'Geduld')."""
|
|
def __init__(self, *a, **k):
|
|
self.value = -100
|
|
def mutieren(self):
|
|
return self
|
|
def __eq__(self, other):
|
|
return self.value == other.value
|
|
def __lt__(self, other):
|
|
return self.value < other.value
|
|
|
|
def _zyklus(self, **params):
|
|
Z = Zyklus()
|
|
Z.Modus = 'adaptiv'
|
|
Z.Adaptiv = dict(Zyklus.AdaptivDefaults)
|
|
Z.Adaptiv.update(params)
|
|
return Z
|
|
|
|
def test_population_schrumpft_und_bricht_bei_konvergenz_ab(self):
|
|
Z = self._zyklus( Start=40, Schrumpfung=0.85, MinPopulation=5,
|
|
MaxGenerationen=50, Geduld=3, Schwelle=1.0 )
|
|
F = Farm( Z, self._L )
|
|
|
|
# Pool-Groessen je 's'-Aufzeichnung (Ende jeder Generation)
|
|
pop_pro_gen = [ p[5] for p in F.Entwicklung.Punkte if p[1] == 's' ]
|
|
# monoton fallend (Population schrumpft) ...
|
|
self.assertTrue( all( a >= b for a, b in zip(pop_pro_gen, pop_pro_gen[1:]) ),
|
|
"Population muss von Generation zu Generation schrumpfen: %s"
|
|
% pop_pro_gen )
|
|
self.assertLess( pop_pro_gen[-1], pop_pro_gen[0] )
|
|
# ... und da nichts besser wird, nach Geduld(=3) Generationen Abbruch,
|
|
# also klar vor MaxGenerationen(=50).
|
|
self.assertLessEqual( len(pop_pro_gen), 4 )
|
|
|
|
def test_population_faellt_nicht_unter_min(self):
|
|
Z = self._zyklus( Start=8, Schrumpfung=0.5, MinPopulation=5,
|
|
MaxGenerationen=10, Geduld=99, Schwelle=1.0 )
|
|
F = Farm( Z, self._L )
|
|
pop_pro_gen = [ p[5] for p in F.Entwicklung.Punkte if p[1] == 's' ]
|
|
self.assertTrue( all( n >= 5 for n in pop_pro_gen ),
|
|
"MinPopulation=5 darf nicht unterschritten werden: %s"
|
|
% pop_pro_gen )
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|