Bessere Verteilung: Strafen/Mutation, plus Abstammungsbaum mit Id-Herkunft

Weniger vermeidbare Gruppentrennungen:
- strafen.cfg: Tischbesetzung bestraft jetzt gestaffelt nach freien
  Plaetzen (teilbelegte Tische am teuersten, mit Fallback auf den
  naechstkleineren Eintrag), Trennungsstrafen deutlich erhoeht - so
  werden Gruppen nicht mehr getrennt, obwohl leere Tische danebenstehen
- gibPunkte('Tischbesetzung') mit Naechst-kleiner-Fallback
- mutieren() setzt geraeumte Gruppen groesste-zuerst neu
  (GruppenSetzen(GrosseZuerst=True)), damit grosse Gruppen die frei
  gewordenen ganzen Tische bekommen und wieder zusammenkommen
  (uebrig bleiben nur physisch unvermeidbare Trennungen, Gruppe > Tisch)

Abstammungsbaum (neuer Schalter --show-lineage -> Abstammung.svg):
- neue Klasse ga.Abstammung zeichnet den vollen Mutationsbaum aller
  Loesungen; der Ast der finalen besten Loesung ist hervorgehoben und
  mit ihrer Id beschriftet
- hierarchische Loesungs-Ids (Farm._WurzelId/_KindId): Wurzel '7',
  Mutationskind '7.2', dessen Kind '7.2.1' - die ganze Abstammung ist
  direkt aus der Id ablesbar

Tests (15 gruen), README und CLAUDE.md aktualisiert; Beispiele mit den
neuen Strafen und beiden Grafiken neu berechnet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Michael Stangl
2026-07-11 22:38:43 +02:00
parent 644f61ef6b
commit 6710d4f06a
28 changed files with 3908 additions and 1468 deletions
+39 -12
View File
@@ -398,10 +398,20 @@ class Strafliste:
# je groesser der Abstand, desto mehr Strafe.
return self.AbstandFaktor * Abstand
elif Typ == 'Tischbesetzung':
try:
# Strafe fuer freie Plaetze an einem (teil-)belegten Tisch. Gibt
# es keinen exakten Eintrag fuer 'freiePlaetze', wird auf den
# naechstkleineren konfigurierten Wert zurueckgegriffen, damit man
# nicht jede moegliche Platzzahl auflisten muss (z.B. deckt ein
# Eintrag fuer 4 auch 5..7 ab, bis der naechste Eintrag greift).
# Auch ein komplett leerer Tisch (freiePlaetze == Plaetze) wird
# bewertet - er trifft in der Regel den hoechsten konfigurierten
# Eintrag (z.B. 8 frei bei 8er-Tisch) oder dessen Fallback.
if freiePlaetze in self.TischWert:
return self.TischWert[freiePlaetze]
except KeyError:
return 0
kleinere = [ k for k in self.TischWert if k < freiePlaetze ]
if kleinere:
return self.TischWert[ max(kleinere) ]
return 0
elif Typ == 'Trennung':
if (N_Teilungen == 0):
return 0
@@ -746,7 +756,10 @@ class Sitzplatzverteilung:
G = self.Gruppen.vonId(Gid)
self.GruppeEntfernen(G)
SchlechteGruppen.append(G)
self.GruppenSetzen( SchlechteGruppen )
# beim Reseat die groessten zuerst, damit sie die durch das Entfernen
# frei gewordenen (ganzen) Tische bekommen und wieder zusammenkommen,
# statt erneut getrennt zu werden
self.GruppenSetzen( SchlechteGruppen, GrosseZuerst=True )
self.bewerten()
print("mutiert: ", self.value)
@@ -828,20 +841,34 @@ class Sitzplatzverteilung:
self.PersonSetzen( neuePerson, Tisch )
# Gruppenspezifische Funktionen
def GruppenSetzen(self, Gruppen ):
"""setzt mal die grossen mal die kleinen Gruppen auf beliebige Plaetze"""
# Mache Liste der Gruppen, der Groesse nach sortiert
def GruppenSetzen(self, Gruppen, GrosseZuerst=False ):
"""setzt Gruppen auf freie Plaetze.
GrosseZuerst=False (Erstbelegung): mal die grossen, mal die kleinen
zuerst (Zufall) - erzeugt Vielfalt in der Startpopulation.
GrosseZuerst=True (Mutation): konsequent die groesste Gruppe zuerst,
damit sie einen noch komplett freien Tisch bekommt und nicht getrennt
werden muss, weil kleine Gruppen die freien Tische schon anbrechen.
"""
# Mache Liste der Gruppen, der Groesse nach sortiert
Gruppenliste = []
for G in Gruppen:
Gruppenliste.append(G)
# uns setzte sie auf freie Plaetze
while len(Gruppenliste) > 0:
wahl = choice(['grosse', 'kleine'])
if wahl == 'grosse':
G = Gruppenliste.pop(0)
else:
G = Gruppenliste.pop(-1)
if GrosseZuerst:
# groesste zuerst: sie beanspruchen die noch komplett freien
# Tische, bevor kleine Gruppen diese teilbelegen (sonst muessen
# grosse Gruppen mangels ganzem Tisch getrennt werden)
G = max( Gruppenliste, key=lambda g: g.Anzahl() )
Gruppenliste.remove(G)
else:
wahl = choice(['grosse', 'kleine'])
if wahl == 'grosse':
G = Gruppenliste.pop(0)
else:
G = Gruppenliste.pop(-1)
self.GruppeSetzen(G)
def GruppeSetzen(self, Gruppe, Tisch=None ):
"""setzt eine Gruppe irgendwohin wo Platz ist
+179 -1
View File
@@ -326,6 +326,149 @@ class Entwicklung:
fout.close()
class Abstammung:
"""Beobachtungsobjekt: zeichnet den Stammbaum aller Loesungen auf.
Jede Loesung bekommt von der Farm eine fortlaufende Abstammungs-Id.
Beim Erzeugen entsteht ein Wurzelknoten (keine Eltern), beim Mutieren
ein Kindknoten mit Verweis auf die Eltern-Id. alsSVG() zeichnet daraus
einen Baum: Generationen als Zeilen (oben Start, nach unten spaeter),
Knoten als Punkte (Farbe = Wertigkeit), Mutationskanten Eltern->Kind.
Domaenen-agnostisch; funktioniert mit jeder Loesung, die .value hat.
"""
def __init__(self):
# je Knoten: Id -> dict(eltern, generation, value, aktion)
self.Knoten = {}
def erfassen(self, Id, Eltern, Generation, value, Aktion):
self.Knoten[Id] = { 'eltern': Eltern, 'generation': Generation,
'value': value, 'aktion': Aktion }
def __repr__(self):
return "-- Abstammung (%d Loesungen)\n" % len(self.Knoten)
def alsSVG(self, OutputSVG, HervorId=None, Breite=1200, Rand=60,
KnotenAbstand=26, ZeilenHoehe=90):
"""zeichnet den aufgezeichneten Stammbaum als SVG.
HervorId (optional): Id der hervorzuhebenden Loesung (z.B. der
finalen besten). Ihr Ast wird kraeftig gezeichnet und mit der
sprechenden Id (z.B. 7.2.1) beschriftet, so dass man ihre ganze
Abstammung ablesen kann."""
if not self.Knoten:
raise ValueError("keine Abstammung aufgezeichnet")
# Ahnenkette der hervorgehobenen Loesung bestimmen (fuer die
# kraeftige Darstellung ihres Astes)
Ahnen = set()
Id = HervorId
while Id is not None and Id in self.Knoten:
Ahnen.add(Id)
Id = self.Knoten[Id]['eltern']
# Knoten nach Generation gruppieren
Generationen = {}
for Id, K in self.Knoten.items():
Generationen.setdefault( K['generation'], [] ).append(Id)
GenListe = sorted(Generationen)
# je Generation die Knoten stabil sortieren (nach Id), damit das Bild
# reproduzierbar ist
for g in Generationen:
Generationen[g].sort()
MaxBreiteKnoten = max( len(v) for v in Generationen.values() )
PlotBreite = max( Breite - 2 * Rand, MaxBreiteKnoten * KnotenAbstand )
Breite = PlotBreite + 2 * Rand
Hoehe = Rand * 2 + (len(GenListe) - 1) * ZeilenHoehe + 40
# Position (x,y) je Knoten-Id bestimmen
Pos = {}
for gi, g in enumerate(GenListe):
Ids = Generationen[g]
n = len(Ids)
y = Rand + gi * ZeilenHoehe
for k, Id in enumerate(Ids):
# horizontal mittig ausrichten
if n == 1:
x = Rand + PlotBreite / 2
else:
x = Rand + PlotBreite * k / (n - 1)
Pos[Id] = (x, y)
# Wertebereich fuer die Knotenfarbe (gut = gruen, schlecht = rot)
Werte = [ K['value'] for K in self.Knoten.values() ]
WMin, WMax = min(Werte), max(Werte)
if WMax == WMin:
WMax = WMin + 1
def Farbe(v):
t = (v - WMin) / (WMax - WMin) # 0 schlecht .. 1 gut
r = int(200 * (1 - t) + 40 * t)
gg = int(50 * (1 - t) + 140 * t)
b = int(50 * (1 - t) + 80 * t)
return '#%02x%02x%02x' % (r, gg, b)
Teile = []
Teile.append( '<?xml version="1.0" encoding="UTF-8"?>\n' )
Teile.append(
'<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" '
'viewBox="0 0 %d %d" font-family="sans-serif">\n'
% (Breite, Hoehe, Breite, Hoehe) )
Teile.append( '<rect width="100%" height="100%" fill="white"/>\n' )
Teile.append(
'<text x="%d" y="30" text-anchor="middle" font-size="16" '
'fill="#333">Abstammung der Loesungen (Mutationsbaum)</text>\n'
% (Breite // 2) )
# zuerst die Kanten Eltern -> Kind (darunter liegend). Kanten entlang
# des hervorgehobenen Astes kraeftig, der Rest blass.
for Id, K in self.Knoten.items():
Eltern = K['eltern']
if Eltern is None or Eltern not in Pos or Id not in Pos:
continue
x1, y1 = Pos[Eltern]
x2, y2 = Pos[Id]
ImAst = Id in Ahnen and Eltern in Ahnen
Stil = ('stroke="#c04040" stroke-width="2.5"' if ImAst
else 'stroke="#bbb" stroke-width="1"')
Teile.append(
'<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" %s/>\n'
% (x1, y1, x2, y2, Stil) )
# dann die Knoten
for Id, K in self.Knoten.items():
if Id not in Pos:
continue
x, y = Pos[Id]
r = 7 if Id in Ahnen else 5
Rahmen = ('stroke="#c04040" stroke-width="2"' if Id in Ahnen
else 'stroke="#333" stroke-width="0.5"')
Teile.append(
'<circle cx="%.1f" cy="%.1f" r="%d" fill="%s" %s>'
'<title>Id %s, Wert %.1f</title></circle>\n'
% (x, y, r, Farbe(K['value']), Rahmen, Id, K['value']) )
# die hervorgehobene Loesung mit ihrer sprechenden Abstammungs-Id
# beschriften (aus ihr laesst sich die ganze Herkunft ablesen)
if HervorId in Pos:
x, y = Pos[HervorId]
Teile.append(
'<text x="%.1f" y="%.1f" text-anchor="middle" font-size="12" '
'font-weight="bold" fill="#c04040">%s</text>\n'
% (x, y + 22, HervorId) )
# Generationsbeschriftung links
for gi, g in enumerate(GenListe):
y = Rand + gi * ZeilenHoehe
Teile.append(
'<text x="%d" y="%.1f" text-anchor="end" font-size="11" '
'fill="#666">Gen %s</text>\n' % (Rand - 12, y + 4, g) )
Teile.append( '</svg>\n' )
fout = open( OutputSVG, 'w' )
fout.writelines( Teile )
fout.close()
class Farm:
"""enthaelt und pflegt Loesungsobjekte, kreuzt, mutiert, selektiert sie"""
def __init__(self, Zyklus, Klasse, *Arguments, **keywords):
@@ -342,6 +485,12 @@ class Farm:
self.Zyklus = Zyklus
self.Klasse = Klasse
self.Entwicklung = Entwicklung()
# Abstammung: jede Loesung bekommt eine fortlaufende _absid; die
# aktuelle Generationsnummer waechst mit jedem Erzeugen/Mutieren.
self.Abstammung = Abstammung()
self._AbsZaehler = 0
self._Generation = 0
self._KindZaehler = {} # Eltern-Id -> Anzahl bisher vergebener Kinder
if getattr(Zyklus, 'Modus', 'statisch') == 'adaptiv':
self._LaufAdaptiv(Klasse, *Arguments, **keywords)
else:
@@ -421,10 +570,30 @@ class Farm:
print("+ Abbruch: %d Generationen ohne nennenswerte "
"Verbesserung" % OhneVerbesserung)
break
def _WurzelId(self):
"""neue Id fuer eine erzeugte (elternlose) Loesung: schlicht eine
fortlaufende Zahl als String ('1', '2', ...)."""
self._AbsZaehler = self._AbsZaehler + 1
return str(self._AbsZaehler)
def _KindId(self, ElternId):
"""neue Id fuer ein Mutationskind: Eltern-Id + '.' + laufender Index,
z.B. '7' -> '7.1', '7.2'; '7.2' -> '7.2.1'. So laesst sich die
ganze Abstammung direkt aus der Id ablesen. Fehlt eine Eltern-Id
(z.B. bei einer Loesung ohne Aufzeichnung), wird eine Wurzel-Id
vergeben."""
if not ElternId:
return self._WurzelId()
self._KindZaehler[ElternId] = self._KindZaehler.get(ElternId, 0) + 1
return "%s.%d" % (ElternId, self._KindZaehler[ElternId])
def LoesungenErzeugen(self, N, Klasse, *Arguments, **keywords):
""" Erzeuge Loesungen"""
for i in range(N):
L = Klasse( *Arguments, **keywords )
L._absid = self._WurzelId()
self.Abstammung.erfassen(
L._absid, None, self._Generation, getattr(L, 'value', 0), 'e' )
self.Pool.append(L)
#print(repr(len(self.Pool)) + ' Loesungsobjekte erzeugt')
def LoesungenKreuzen(self,N):
@@ -435,11 +604,20 @@ class Farm:
def LoesungenMutieren(self, N):
"""veraendere die Loesung, so dass sie hoffentlich besser wird"""
print("+ Loesungen mutieren: ", N)
self._Generation = self._Generation + 1
for n in range(N):
#print(" -"+repr(n+1)+" mal")
for L in chain(self.Pool):
O = deepcopy(L)
self.PoolNeu.append(O.mutieren())
Kind = O.mutieren()
# Kind-Id = Eltern-Id + '.' + laufender Kindindex, damit man
# an der Id die ganze Abstammung ablesen kann (z.B. 7.2.1).
ElternId = getattr(L, '_absid', None)
Kind._absid = self._KindId(ElternId)
self.Abstammung.erfassen(
Kind._absid, ElternId, self._Generation,
getattr(Kind, 'value', 0), 'm' )
self.PoolNeu.append(Kind)
#print(self)
def LoesungenBehalten(self):
"""Behalte alle Eltern aus dem alten Pool"""
+11
View File
@@ -41,6 +41,12 @@ if __name__ == '__main__':
help="zusaetzlich eine Entwicklung.svg im --indir Verzeichnis "
"erzeugen: Verlauf der Loesungsfindung (Zeitachse nach rechts, "
"beste/durchschnittliche/schlechteste Wertigkeit auf der Y-Achse)" )
parser.add_option( "--show-lineage", dest="showlineage", action="store_true",
default=False,
help="zusaetzlich eine Abstammung.svg im --indir Verzeichnis "
"erzeugen: Stammbaum aller Loesungen (Mutationsbaum); der Ast der "
"finalen besten Loesung ist hervorgehoben und mit ihrer "
"Abstammungs-Id beschriftet" )
parser.add_option( "--zyklus-art", dest="zyklusart", default="Adaptiv",
help="Name des GA-Profils in zyklus.cfg (Vorgabe: Adaptiv). "
"Weitere mitgelieferte Profile: Easy, Simple" )
@@ -72,6 +78,7 @@ if __name__ == '__main__':
AusgabePT = os.path.join( InDir, 'PersonenTische.csv' )
AusgabeSVG = os.path.join( InDir, 'Sitzplatzverteilung.svg' )
AusgabeEntwicklung = os.path.join( InDir, 'Entwicklung.svg' )
AusgabeAbstammung = os.path.join( InDir, 'Abstammung.svg' )
Z = Zyklus()
Z.laden( Zyklusconfig, Zyklusart )
@@ -101,6 +108,10 @@ if __name__ == '__main__':
if options.showdev:
F1.Entwicklung.alsSVG( AusgabeEntwicklung )
print( "Entwicklungsverlauf geschrieben:", AusgabeEntwicklung )
if options.showlineage:
F1.Abstammung.alsSVG( AusgabeAbstammung,
HervorId=getattr(S, '_absid', None) )
print( "Abstammungsbaum geschrieben:", AusgabeAbstammung )
else:
pass