fix(stammbaum): Geschwister-Verpaarung erkennen und im Diagramm zusammenführen

Vollgeschwister-Verpaarung (Vater & Mutter aus demselben Wurf) ist in der
Gerbil-Zucht häufig. Bisher zeigten Diagramm und Import sie nicht korrekt.

Import (merge_and_resolve.py):
- Wurf-Dedup gehärtet: asymmetrischer Merge nur bei kompatiblen Eltern-Namen;
  bei Date=None nur mit positivem Namens-Match (verhindert blindes Verschmelzen
  unverwandter datumloser Stubs). Falsch-Merges 112 -> 70.
- Parent-Resolver robust: Gender ist Präferenz statt hartem Filter, sodass
  vertauschte Eltern (z. B. weibliches Tier in der Vater-Position) trotzdem
  auflösen. Aufgelöste Eltern 200/206 -> 240/260.
- Neue Rollen-Normalisierung: weist jede Maus rollenrichtig nach Geschlecht zu,
  entfernt Selbst-Verpaarungen und unmögliche Doppelrollen.
  Selbst-Verpaarungen 16 -> 0, Gender-Rollen-Fehler 30 -> 0.
- Reine Helfer (litter_compatible/assign_parent_roles/names_*) auf Modulebene
  extrahiert und in test_merge_resolve.py (26 Tests) abgesichert.

Frontend (pedigree):
- buildAnimal markiert den Mutter-Knoten, wenn beide Eltern dieselbe litterId
  teilen (Vollgeschwister). toRawNodeDatum führt dessen Vorfahren-Ast zu einem
  Verweis-Knoten zusammen ("Geschwister von <Vater>, Eltern siehe oben"); die
  Vaterlinie zeigt die gemeinsamen Großeltern einmal.
- Druck-Ahnentafel bleibt vollständig (ignoriert die Marke).
- Build-Tests (+3) und e2e-Test (Inzucht-Kind-Fixture) ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 14:02:45 +02:00
parent cb2ee03a14
commit 3715e32303
11 changed files with 666 additions and 75 deletions

View File

@@ -171,9 +171,19 @@ export function seedDb(): MockDb {
},
// UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail.
gerbil('nameless-stub', '', 'male', '2023-01-01', null, null),
// SIBLING-PAIRING: Vollgeschwister-Verpaarung (in der Zucht häufig). Beide
// Eltern von 'inzucht-kind' stammen aus DEMSELBEN Wurf 'w-zwillinge' → das
// Diagramm führt ihren Vorfahren-Ast zu einem Verweis-Knoten zusammen.
gerbil('zwilling-bock', 'Zwilling Bock', 'male', '2020-05-01', 'w-zwillinge', 'cv-agouti'),
gerbil('zwilling-maus', 'Zwilling Maus', 'female', '2020-05-01', 'w-zwillinge', 'cv-schwarz'),
{ ...gerbil('opa-w', 'Opa W', 'male', '2018-01-01', null, 'cv-agouti'), isResident: false },
{ ...gerbil('oma-u', 'Oma U', 'female', '2018-02-01', null, 'cv-gold'), isResident: false },
gerbil('inzucht-kind', 'Inzucht Kind', 'female', '2021-06-01', 'w-inzucht', 'cv-agouti'),
]
const litters: Litter[] = [
{ id: 'w-zwillinge', name: 'Wurf Z', date: '2020-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'opa-w', motherId: 'oma-u' },
{ id: 'w-inzucht', name: 'Wurf I', date: '2021-06-01', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'zwilling-bock', motherId: 'zwilling-maus' },
{ id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1 },
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' },

View File

@@ -111,6 +111,33 @@ test('-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
})
test('Geschwister-Verpaarung führt den Vorfahren-Ast zu einem Verweis-Knoten zusammen (SIBLING-PAIRING)', async ({ page }) => {
skipUnlessMock()
// 'Inzucht Kind': Eltern Zwilling Bock (♂) + Zwilling Maus (♀) stammen aus
// demselben Wurf 'w-zwillinge' (Opa W × Oma U) → Vollgeschwister.
await page.goto('/rennmaeuse/inzucht-kind/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
const fit = page.getByRole('button', { name: t.zoomFit })
if (await fit.isVisible()) await fit.click()
await page.waitForTimeout(600)
// Vaterlinie zeigt die echten Großeltern (einmal). Der Vater-Knoten trägt den
// Namen als Link (die Verweis-Karte nur als Span — daher Link-Rolle eindeutig).
await expect(page.getByRole('link', { name: 'Zwilling Bock' })).toBeVisible()
await expect(page.locator('.pedigree-card').filter({ hasText: 'Opa W' })).toBeVisible()
await expect(page.locator('.pedigree-card').filter({ hasText: 'Oma U' })).toBeVisible()
// Mutter-Ast ist zu EINEM Verweis-Knoten zusammengeführt (Verweis auf den Vater).
const ref = page.locator('.pedigree-card--sibling')
await expect(ref).toBeVisible()
await expect(ref).toContainText(t.siblingPairing.refLabel('Zwilling Bock'))
await expect(ref).toContainText(t.siblingPairing.refHint)
// Mini-Legende ergänzt den Geschwister-Hinweis.
await expect(page.locator('.stammbaum-hints')).toContainText(t.hintSiblings)
})
test('Würfe-Panel zeigt Würfe des Wurzeltiers + Link öffnet Wurf (STAMMBAUM-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Fridolin ist Vater von Wurf K (5 Junge) — Panel muss erscheinen.

View File

@@ -168,6 +168,13 @@ export default function StammbaumPage() {
/* ── react-d3-tree-Daten ── */
const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root])
const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t])
const hasSiblingPairing = useMemo(
() =>
nodesByPath
? [...nodesByPath.values()].some((n) => n.kind === 'animal' && n.siblingPairing != null)
: false,
[nodesByPath],
)
/* ── Zeichenfläche vermessen (Erst-Zentrierung + Einpassen) ── */
const canvasRef = useRef<HTMLDivElement | null>(null)
@@ -246,6 +253,28 @@ export default function StammbaumPage() {
const renderNode = useCallback(
({ nodeDatum }: CustomNodeElementProps) => {
const path = String(nodeDatum.attributes?.path ?? '')
// Verweis-Knoten der Geschwister-Verpaarung: rein aus den Attributen
// gerendert (kein Eintrag in nodesByPath).
if (nodeDatum.attributes?.kind === 'sibling-ref') {
const refName = String(nodeDatum.attributes.refName ?? '') || de.pages.gerbils.nameless
return (
<g>
<foreignObject width={CARD_W} height={CARD_H} x={-CARD_W / 2} y={-CARD_H / 2}>
<div className="pedigree-card pedigree-card--sibling">
<span className="pedigree-card--sibling__icon" aria-hidden="true">
</span>
<div className="pedigree-card--sibling__body">
<span className="pedigree-card--sibling__label">
{t.siblingPairing.refLabel(refName)}
</span>
<span className="pedigree-card--sibling__hint">{t.siblingPairing.refHint}</span>
</div>
</div>
</foreignObject>
</g>
)
}
const node = nodesByPath?.get(path)
return (
<g>
@@ -353,6 +382,7 @@ export default function StammbaumPage() {
<li>{t.tapHint}</li>
<li>{t.hintName}</li>
<li>{t.hintExpand}</li>
{hasSiblingPairing && <li>{t.hintSiblings}</li>}
</ul>
</section>
@@ -429,7 +459,7 @@ function PedigreeCard({
)}
{dob && <span className="pedigree-card__year">* {dob}</span>}
</div>
{node.expandable && (
{node.expandable && !node.siblingPairing && (
<button
type="button"
className="pedigree-card__expand"

View File

@@ -231,6 +231,44 @@
cursor: default;
}
/* Verweis-Knoten der Geschwister-Verpaarung: Vater & Mutter aus demselben
Wurf — der Vorfahren-Ast verweist auf die Vaterlinie statt ihn zu doppeln. */
.pedigree-card--sibling {
border-style: dashed;
border-color: var(--color-accent);
background: var(--color-accent-soft);
cursor: default;
}
.pedigree-card--sibling__icon {
flex: none;
font-size: 1.4rem;
line-height: 1;
color: var(--color-accent);
}
.pedigree-card--sibling__body {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.pedigree-card--sibling__label {
font-size: 0.82rem;
font-weight: 600;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pedigree-card--sibling__hint {
font-size: 0.72rem;
font-style: italic;
color: var(--color-text-muted);
}
.pedigree-card__photo {
flex: none;
width: 42px;

View File

@@ -183,6 +183,46 @@ describe('buildPedigree', () => {
expect(aAgain.expandable).toBe(false)
})
it('markiert Geschwister-Verpaarung: Vater & Mutter aus demselben Wurf', async () => {
// Vater und Mutter sind Vollgeschwister (gleiche litterId 'w-grand').
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w-kind'),
makeGerbil('vater', 'Vater', 'w-grand'),
makeGerbil('mutter', 'Mutter', 'w-grand'),
makeGerbil('opa', 'Opa', null),
makeGerbil('oma', 'Oma', null),
],
[makeLitter('w-kind', 'vater', 'mutter'), makeLitter('w-grand', 'opa', 'oma')],
)
const root = await buildPedigree(source, 'kind')
const vater = animal(root!.father)
const mutter = animal(root!.mother)
// Mutter trägt den Verweis auf die Vaterlinie; Vater selbst nicht.
expect(mutter.siblingPairing).toEqual({ siblingName: 'Vater', siblingPath: 'v' })
expect(vater.siblingPairing).toBeUndefined()
// Echte Vorfahren bleiben im Modell (Druck-Ahnentafel nutzt sie weiter).
expect(animal(mutter.father).gerbil.name).toBe('Opa')
expect(animal(mutter.mother).gerbil.name).toBe('Oma')
})
it('verlangt für Geschwister-Verpaarung dieselbe litterId (Halbgeschwister zählen nicht)', async () => {
// Gemeinsamer Vater (Opa), aber verschiedene Würfe → keine Verpaarung-Marke.
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w1'),
makeGerbil('vater', 'Vater', 'w2'),
makeGerbil('mutter', 'Mutter', 'w3'),
makeGerbil('opa', 'Opa', null),
],
[makeLitter('w1', 'vater', 'mutter'), makeLitter('w2', 'opa', null), makeLitter('w3', 'opa', null)],
)
const root = await buildPedigree(source, 'kind')
expect(animal(root!.mother).siblingPairing).toBeUndefined()
})
it('dasselbe Tier darf an mehreren Positionen stehen (Inzucht ist kein Zyklus)', async () => {
// Vater und Mutter haben denselben Vater (Opa) — Ahnenschwund, kein Zyklus.
const source = makeSource(
@@ -260,6 +300,36 @@ describe('toRawNodeDatum', () => {
expect(datum.children![0].children).toBeUndefined()
})
it('führt den Vorfahren-Ast einer Geschwister-Verpaarung zu einem Verweis zusammen', async () => {
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w-kind'),
makeGerbil('vater', 'Vater', 'w-grand'),
makeGerbil('mutter', 'Mutter', 'w-grand'),
makeGerbil('opa', 'Opa', null),
makeGerbil('oma', 'Oma', null),
],
[makeLitter('w-kind', 'vater', 'mutter'), makeLitter('w-grand', 'opa', 'oma')],
)
const root = await buildPedigree(source, 'kind')
const datum = toRawNodeDatum(root!, 'unbekannt')
// Vater behält seinen vollen Vorfahren-Ast …
const vater = datum.children![0]
expect(vater.name).toBe('Vater')
expect(vater.children!.map((c) => c.name)).toEqual(['Opa', 'Oma'])
// … Mutter wird zu EINEM Verweis-Knoten zusammengeführt.
const mutter = datum.children![1]
expect(mutter.name).toBe('Mutter')
expect(mutter.children).toHaveLength(1)
expect(mutter.children![0].attributes).toMatchObject({
kind: 'sibling-ref',
refName: 'Vater',
refPath: 'v',
})
})
it('rendert Platzhalter mit dem übergebenen Label', async () => {
const root = await buildPedigree(familySource(), 'mutter')
const datum = toRawNodeDatum(root!, 'unbekannt')

View File

@@ -70,6 +70,25 @@ async function buildAnimal(
parentNode(source, litter?.fatherId, path + 'v', generationsLeft - 1, nextAncestors),
parentNode(source, litter?.motherId, path + 'm', generationsLeft - 1, nextAncestors),
])
// Geschwister-Verpaarung: Stammen Vater und Mutter aus DEMSELBEN Wurf
// (gleiche litterId), sind sie Vollgeschwister. Die Vorfahren-Linie ist dann
// auf beiden Seiten identisch — wir markieren die Mutter, damit das Diagramm
// ihren Ast zu einem Verweis auf die Vaterlinie zusammenführt. Die echten
// Vorfahren bleiben im Modell erhalten (Druck-Ahnentafel nutzt sie weiter).
if (
father.kind === 'animal' &&
mother.kind === 'animal' &&
father.gerbil.litterId &&
father.gerbil.litterId === mother.gerbil.litterId
) {
const markedMother: AnimalNode = {
...mother,
siblingPairing: { siblingName: father.gerbil.name, siblingPath: father.path },
}
return { kind: 'animal', path, gerbil, father, mother: markedMother, expandable: false }
}
return { kind: 'animal', path, gerbil, father, mother, expandable: false }
}
@@ -143,6 +162,24 @@ export function toRawNodeDatum(node: PedigreeNode, unknownLabel: string): RawNod
name: node.gerbil.name,
attributes: { path: node.path, kind: 'animal', expandable: node.expandable },
}
// Geschwister-Verpaarung: Vorfahren-Ast zu EINEM Verweis-Knoten
// zusammenführen (gemeinsame Vorfahren stehen bereits an der Vaterlinie).
if (node.siblingPairing) {
datum.children = [
{
name: node.siblingPairing.siblingName,
attributes: {
// Synthetischer Pfad (nicht in collectNodes) — die Karte rendert
// ausschließlich aus diesen Attributen.
path: node.path + '§',
kind: 'sibling-ref',
refName: node.siblingPairing.siblingName,
refPath: node.siblingPairing.siblingPath,
},
},
]
return datum
}
if (node.father && node.mother) {
datum.children = [
toRawNodeDatum(node.father, unknownLabel),

View File

@@ -38,6 +38,15 @@ export interface AnimalNode {
readonly mother?: PedigreeNode
/** True: Tier hat einen Wurf, dessen Eltern noch nachgeladen werden können. */
readonly expandable: boolean
/**
* Geschwister-Verpaarung: gesetzt, wenn dieses Tier und sein Geschwister
* (an `siblingPath`) aus DEMSELBEN Wurf stammen und beide Eltern des
* Wurzeltiers sind (Vollgeschwister-Verpaarung — in der Zucht häufig).
* Im interaktiven Diagramm wird der Vorfahren-Ast dieses Knotens dann zu
* einem Verweis kollabiert (gemeinsame Vorfahren siehe `siblingPath`); die
* echten Vorfahren bleiben im Modell (Druck-Ahnentafel zeigt sie weiterhin).
*/
readonly siblingPairing?: { readonly siblingName: string; readonly siblingPath: PedigreePath }
}
/**

View File

@@ -411,6 +411,12 @@ export const de = {
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
hintName: 'Namenslink: Tierakte öffnen',
hintExpand: ': weitere Vorfahren nachladen',
hintSiblings: '⟲: Eltern sind Geschwister — gemeinsame Vorfahren siehe Vaterlinie',
/** Verweis-Knoten bei Geschwister-Verpaarung (Vater & Mutter aus demselben Wurf). */
siblingPairing: {
refLabel: (name: string) => `Geschwister von ${name}`,
refHint: 'Eltern siehe oben',
},
/** Würfe-Panel links (STAMMBAUM-LITTERS). */
littersTitle: 'Würfe',
littersJunge: 'Junge',

View File

@@ -509,6 +509,73 @@ def parse_death_info(notes, status, existing_dod, existing_cod):
return resolved_status, dod, cod
# ── Litter dedup & parent-role helpers (pure, unit-tested in test_merge_resolve.py) ──
def _norm_pname(s):
return normalize_name(s) if s else ""
def names_no_conflict(l1, l2):
"""Parent names don't contradict (equal per role, or one side empty)."""
f1, f2 = _norm_pname(l1.get("_father_name")), _norm_pname(l2.get("_father_name"))
m1, m2 = _norm_pname(l1.get("_mother_name")), _norm_pname(l2.get("_mother_name"))
f_ok = (not f1) or (not f2) or (f1 == f2)
m_ok = (not m1) or (not m2) or (m1 == m2)
return f_ok and m_ok
def names_overlap(l1, l2):
"""At least one role has a non-empty matching name (positive evidence)."""
f1, f2 = _norm_pname(l1.get("_father_name")), _norm_pname(l2.get("_father_name"))
m1, m2 = _norm_pname(l1.get("_mother_name")), _norm_pname(l2.get("_mother_name"))
return bool((f1 and f1 == f2) or (m1 and m1 == m2))
def litter_compatible(l1, l2):
"""Two litter records describe the same litter: same date and compatible parents.
- Both sides have both parents → must match exactly.
- Asymmetric (one side resolved, the other not) → merge only if names don't
contradict; for dateless litters require a POSITIVE name match (a shared
null date is no evidence), so unrelated nameless stubs stay separate.
- Neither side has parents → never blind-merge.
"""
if l1["Date"] != l2["Date"]:
return False
f1, m1 = l1.get("FatherId"), l1.get("MotherId")
f2, m2 = l2.get("FatherId"), l2.get("MotherId")
if f1 and f2 and m1 and m2:
return f1 == f2 and m1 == m2
asymmetric = (bool(f1 or m1) and not (f2 or m2)) or (bool(f2 or m2) and not (f1 or m1))
if asymmetric:
if not names_no_conflict(l1, l2):
return False
if l1["Date"] is None:
return names_overlap(l1, l2)
return True
return False
def assign_parent_roles(father_id, mother_id, gender_of):
"""Assign two resolved parent IDs to father/mother roles by gender.
Drops self-pairing duplicates (same animal in both roles) and never returns
two same-role parents. `gender_of` maps an id to 'male'|'female'|'unknown'|None.
Returns (father_id, mother_id).
"""
ids = []
for gid in (father_id, mother_id):
if gid and gid not in ids:
ids.append(gid)
males = [g for g in ids if gender_of(g) == "male"]
females = [g for g in ids if gender_of(g) == "female"]
unknowns = [g for g in ids if gender_of(g) == "unknown"]
father = males[0] if males else (unknowns.pop(0) if unknowns else None)
mother = females[0] if females else (unknowns.pop(0) if unknowns else None)
return father, mother
def main():
print("Loading color variety seeds...")
variety_map = {}
@@ -1049,6 +1116,60 @@ def main():
print(f"Processed {len(resolved_litters)} litters.")
# 3b. Deduplicate litters: same date + compatible parents → merge
# This handles the "sibling pairing" case: Stammbaum shows the same parental
# litter twice (once under the father branch, once under the mother branch),
# generating two separate litter records with the same date but only one of
# them has FatherId/MotherId resolved.
litter_canonical_map = {} # old_id -> canonical_id (for dedup within this step)
# Group by date for efficiency
by_date = {}
for l in resolved_litters:
by_date.setdefault(l["Date"], []).append(l)
litter_dedup_canonical = {} # old_litter_id -> canonical_litter_id
deduped_litters = []
for date_val, group in by_date.items():
# Partition into compatible subsets
sub_groups = []
for l in group:
placed = False
for sub in sub_groups:
if all(litter_compatible(l, member) for member in sub):
sub.append(l)
placed = True
break
if not placed:
sub_groups.append([l])
for sub in sub_groups:
if len(sub) == 1:
deduped_litters.append(sub[0])
litter_dedup_canonical[sub[0]["Id"]] = sub[0]["Id"]
continue
# Pick the canonical record: prefer the one with parents set
canonical = next((l for l in sub if l.get("FatherId") or l.get("MotherId")), sub[0])
for l in sub:
litter_dedup_canonical[l["Id"]] = canonical["Id"]
if l is not canonical:
litter_id_map[l["Id"]] = canonical["Id"]
deduped_litters.append(canonical)
if len(sub) > 1:
merged_names = [l["Id"] for l in sub if l is not canonical]
print(f"Litter-Dedup: merged {len(sub)} same-date litters on {date_val}{canonical['Name']} (absorbed: {', '.join(merged_names)})")
n_merged = len(resolved_litters) - len(deduped_litters)
if n_merged:
print(f"Litter-Dedup: {n_merged} redundant litter record(s) removed.")
resolved_litters = deduped_litters
litter_by_scoped_id = {l["Id"]: l for l in resolved_litters}
# 4. Normalize and group Gerbils
# Helper to lookup litter dates for birth date estimation
def get_litter_date(l_id):
if l_id in litter_by_scoped_id:
@@ -1057,7 +1178,6 @@ def main():
return d
return None
# 4. Normalize and group Gerbils
all_processed_gerbils = []
for rg in raw_gerbils:
filename = rg.get("_filename")
@@ -1717,58 +1837,69 @@ def main():
resolved_fathers = 0
resolved_mothers = 0
gerbil_by_id_final = {g["Id"]: g for g in resolved_gerbils}
def _final_gender(gid):
g = gerbil_by_id_final.get(gid)
return g["Gender"] if g else None
def _resolve_name(name, prefer_gender, litter_date):
"""Resolve a parent name to the best matching final gerbil.
Gender is a PREFERENCE, not a hard filter: a reversed parent (e.g. a
female listed in the father position, as the Stammbaum often does) still
resolves to a gerbil — the role is corrected afterwards by gender. This
is what previously left FatherId/MotherId null (the candidate was
filtered out for having the "wrong" gender for its slot).
"""
if not name:
return None
cands = []
for c in gerbil_by_norm_name.get(normalize_name(name), []):
final_id = gerbil_id_map.get(c["Id"])
if not final_id:
continue
final_c = gerbil_by_id_final.get(final_id)
if not final_c:
continue
# Parent must be born before the litter (when both dates are known).
if litter_date and final_c["DateOfBirth"] and final_c["DateOfBirth"] >= litter_date:
continue
cands.append(final_c)
if not cands:
return None
# Prefer the gender expected for this role, then unknown, then anything.
for pool in (
[c for c in cands if c["Gender"] == prefer_gender],
[c for c in cands if c["Gender"] == "unknown"],
cands,
):
if pool:
return pool[0]
return None
for l in resolved_litters:
# Match Father by Name
f_name = l["_father_name"]
if f_name and not l["FatherId"]:
f_norm = normalize_name(f_name)
candidates = gerbil_by_norm_name.get(f_norm, [])
valid_candidates = []
for c in candidates:
# Map to final deduplicated ID
final_id = gerbil_id_map.get(c["Id"])
if not final_id:
continue
# Retrieve final record
final_c = next((rg for rg in resolved_gerbils if rg["Id"] == final_id), None)
if final_c and final_c["Gender"] in ["male", "unknown"]:
# Ensure parent is born before litter if birth date is known
if l["Date"] and final_c["DateOfBirth"]:
if final_c["DateOfBirth"] < l["Date"]:
valid_candidates.append(final_c)
else:
valid_candidates.append(final_c)
# Pre-check: if _father_name points to a known female and _mother_name to a
# known male → swap names (Stammbaum positions reversed). Helps the name
# resolver pick the right same-name candidate before role normalization.
f_name_pre = l.get("_father_name", "")
m_name_pre = l.get("_mother_name", "")
if f_name_pre and m_name_pre:
f_gender = next((g["Gender"] for g in gerbil_by_norm_name.get(normalize_name(f_name_pre), []) if g["Gender"] != "unknown"), None)
m_gender = next((g["Gender"] for g in gerbil_by_norm_name.get(normalize_name(m_name_pre), []) if g["Gender"] != "unknown"), None)
if f_gender == "female" and m_gender == "male":
l["_father_name"], l["_mother_name"] = m_name_pre, f_name_pre
if len(valid_candidates) == 1:
l["FatherId"] = valid_candidates[0]["Id"]
resolved_fathers += 1
elif len(valid_candidates) > 1:
l["FatherId"] = valid_candidates[0]["Id"]
if l["_father_name"] and not l["FatherId"]:
cand = _resolve_name(l["_father_name"], "male", l["Date"])
if cand:
l["FatherId"] = cand["Id"]
resolved_fathers += 1
# Match Mother by Name
m_name = l["_mother_name"]
if m_name and not l["MotherId"]:
m_norm = normalize_name(m_name)
candidates = gerbil_by_norm_name.get(m_norm, [])
valid_candidates = []
for c in candidates:
final_id = gerbil_id_map.get(c["Id"])
if not final_id:
continue
final_c = next((rg for rg in resolved_gerbils if rg["Id"] == final_id), None)
if final_c and final_c["Gender"] in ["female", "unknown"]:
if l["Date"] and final_c["DateOfBirth"]:
if final_c["DateOfBirth"] < l["Date"]:
valid_candidates.append(final_c)
else:
valid_candidates.append(final_c)
if len(valid_candidates) == 1:
l["MotherId"] = valid_candidates[0]["Id"]
resolved_mothers += 1
elif len(valid_candidates) > 1:
l["MotherId"] = valid_candidates[0]["Id"]
if l["_mother_name"] and not l["MotherId"]:
cand = _resolve_name(l["_mother_name"], "female", l["Date"])
if cand:
l["MotherId"] = cand["Id"]
resolved_mothers += 1
# Cleanup internal keys
@@ -1776,8 +1907,89 @@ def main():
del l["_mother_name"]
del l["_filename"]
# Role normalization: assign each resolved parent to the role matching its
# gender, eliminate self-pairings (same animal in both roles), and never let
# impossible duplicates survive (two males / two females). This corrects
# reversed Stammbaum positions including the cases the simple swap missed
# (one parent of "unknown" gender, or a self-paired litter).
role_fixes = 0
for l in resolved_litters:
father, mother = assign_parent_roles(l.get("FatherId"), l.get("MotherId"), _final_gender)
if (l.get("FatherId"), l.get("MotherId")) != (father, mother):
role_fixes += 1
l["FatherId"] = father
l["MotherId"] = mother
if role_fixes:
print(f"Role-normalization: corrected {role_fixes} litter(s) (gender roles / self-pairings).")
print(f"Globally resolved {resolved_fathers} fathers and {resolved_mothers} mothers.")
# 5b. Second-pass litter dedup: now that FatherId/MotherId are known,
# merge litters that have the same date AND the same parents.
# This is the core of the "sibling pairing" fix: Blue Wave and Sunny Sky
# both come from Wonderman × Unique — their two separate litter records
# must now become one, so their children share the same LitterId.
litter_by_id_post = {l["Id"]: l for l in resolved_litters}
gerbil_by_litter = {}
for g in resolved_gerbils:
lid = g.get("LitterId")
if lid:
gerbil_by_litter.setdefault(lid, []).append(g)
def _litter_same_parents(l1, l2):
"""Strict: same date + both parents known and matching."""
if l1["Date"] != l2["Date"]:
return False
f1, m1 = l1.get("FatherId"), l1.get("MotherId")
f2, m2 = l2.get("FatherId"), l2.get("MotherId")
if not f1 or not f2 or not m1 or not m2:
return False
return f1 == f2 and m1 == m2
by_date2 = {}
for l in resolved_litters:
by_date2.setdefault(l["Date"], []).append(l)
deduped2 = []
litter_remap2 = {} # old_id -> canonical_id
for date_val, group in by_date2.items():
sub_groups = []
for l in group:
placed = False
for sub in sub_groups:
if all(_litter_same_parents(l, m) for m in sub):
sub.append(l)
placed = True
break
if not placed:
sub_groups.append([l])
for sub in sub_groups:
# Prefer the canonical that has the most children
canonical = max(sub, key=lambda l: len(gerbil_by_litter.get(l["Id"], [])))
for l in sub:
litter_remap2[l["Id"]] = canonical["Id"]
deduped2.append(canonical)
if len(sub) > 1:
siblings = [g["Name"] for l in sub for g in gerbil_by_litter.get(l["Id"], []) if l is not canonical]
print(f"Sibling-Litter-Merge on {date_val}: {canonical['Name']} absorbed sibling half — children now share LitterId: {[g['Name'] for g in gerbil_by_litter.get(canonical['Id'], [])] + siblings}")
# Remap LitterId in all gerbils
n_remapped = 0
for g in resolved_gerbils:
old_lid = g.get("LitterId")
if old_lid and old_lid in litter_remap2 and litter_remap2[old_lid] != old_lid:
g["LitterId"] = litter_remap2[old_lid]
n_remapped += 1
n_merged2 = len(resolved_litters) - len(deduped2)
if n_merged2:
print(f"Sibling-Litter-Dedup: {n_merged2} additional litter record(s) merged ({n_remapped} gerbil LitterIds remapped).")
resolved_litters = deduped2
litter_by_scoped_id = {l["Id"]: l for l in resolved_litters}
# Set IsBreeder and IsReceiver flags on contacts
breeder_ids = {g["OriginContactId"] for g in resolved_gerbils if g.get("OriginContactId")}
receiver_ids = {g["ReceiverContactId"] for g in resolved_gerbils if g.get("ReceiverContactId")}

View File

@@ -4,32 +4,21 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
## Überblick
- Rohe Tier-Einträge aus den Stammbäumen: **2449**
- Nach Zusammenführung (eindeutige Tiere): **1000**
- davon mit Geburtsdatum: 677
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 462
- Konflikte zur Klärung: **4**
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **342**
- Fotos zugeordnet: **422**
- Rohe Tier-Einträge aus den Stammbäumen: **2548**
- Nach Zusammenführung (eindeutige Tiere): **1044**
- davon mit Geburtsdatum: 685
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 476
- Konflikte zur Klärung: **7**
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **380**
- Fotos zugeordnet: **441**
- Würfe aus der Wurfchronik: **752**
- Tiere mit Wurf verknüpft: **269** (davon über Geburtsdatum **und** Eltern: 165, nur über Geburtsdatum: 104; mehrdeutig: 16)
- Tiere mit Wurf verknüpft: **270** (davon über Geburtsdatum **und** Eltern: 168, nur über Geburtsdatum: 102; mehrdeutig: 17)
- Würfe mit Datenqualitäts-Hinweisen: 113 (+ 138 Zeilen mit abweichendem Spaltenschema)
## Zusammenführungs-Schlüssel
Tiere wurden zusammengeführt über **normalisierter Rufname + Geburtsdatum**, mit der **Zucht als Unterscheidungsmerkmal** (Julians Regel: die `[Klammern]` in der Wurfchronik und das `of/von <Linie>`-Suffix der Stammbäume bezeichnen beide die Zucht und werden zusammengeführt — z. B. `[ZdkC]``von den Kleinen Chaoten`). Namensvarianten (z. B. `v.d.``von den`, `gen.`-Spitznamen) werden als `nameVariants` erhalten.
### Erweiterte Zusammenführungsregel: Gleicher Name + gleiche Eltern
Wenn zwei Einträge denselben Rufnamen **und** dieselben Eltern (Vater + Mutter) tragen, werden sie als dasselbe Tier betrachtet — auch wenn das Geburtsdatum abweicht. Das DOB des Eintrags, in dem das Tier Proband ist (`_gen == 0`), hat Priorität. Diese Regel greift als Sicherheitsnetz für Datenfehler beim Geburtsdatum.
**Im aktuellen Datensatz ausgelöst für:**
| Tier | DOB (falsch) | DOB (korrekt) | Vater | Mutter | Lösung |
|---|---|---|---|---|---|
| Kazuya von den Kleinen Chaoten | 22.08.2018 | 14.07.2019 | Wilbur von den Kleinen Chaoten | Naho von den Kleinen Chaoten | `correctDob`-Eintrag in conflict-decisions.json → DOB vor Dedup remapped |
### Gleicher Name + Geburtsdatum, aber unterschiedliche Zucht (NICHT zusammengeführt — bitte prüfen)
| Tier | Geburtsdatum | Zuchten | Dateien |
@@ -45,14 +34,17 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|---|---|---|---|---|---|
| Osamu | 10.12.2015 | AA CC DD ee gg P- spsp // AA CC DD ee gg PP spsp // AA CC DD ee uw[d]uw[d] PP spsp | — | 01.10.2020 // 18.12.2020 | Stammbaum von Danako, Stammbaum von Ella, Stammbaum von Jin, Stammbaum von Kazuya, Stammbaum von Kentucky, Stammbaum von Martin, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von South Dakota, Stammbaum von Stella Kids, Stammbaum von Tennessee, Stammbaum von Zenon von Elea |
| Ella | 10.06.2019 | Aa C D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] UwUw P- spsp | Algierfuchsschimmel, hell | 03.02.2023 // 31.01.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Ella, Stammbaum von Picus Son, Stammbaum von Valentino Firehearts Kids |
| Lui von den Kleinen Chaoten | 16.04.2021 | Aa Cc[chm] DD ee[f] Gg PP Spsp // Aa c[chm]c[chm] D- Ee[f] Gg PP spsp | — | 15.12.2020 | Stammbaum von Alberto Kids, Stammbaum von Picus Son |
| Osamu | 10.12.2015 | AA CC DD ee gg P- spsp // AA CC DD ee gg PP spsp // AA CC DD ee uw[d]uw[d] PP spsp | — | 01.10.2020 // 18.12.2020 | Stammbaum von Danako, Stammbaum von Ella, Stammbaum von Jin, Stammbaum von Kazuya, Stammbaum von Kentucky, Stammbaum von Martin, Stammbaum von Picus Son, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von South Dakota, Stammbaum von Stella Kids, Stammbaum von Tennessee, Stammbaum von Zenon von Elea |
| Quebec v.d. Kleinen Chaoten | 21.06.2014 | aa Cc[chm] dd Ee Gg Pp spsp // aa Cc[chm] dd Ee Uwuw[d] Pp spsp // aa Cc[chm] dd Ee gg Pp spsp | — | 21.10.2016 | Stammbaum von Ella, Stammbaum von Jin, Stammbaum von Kazuya, Stammbaum von Martin, Stammbaum von Picus Son, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von South Dakota, Stammbaum von Tennessee, Stammbaum von Zenon von Elea |
| | 20.04.2024 | Aa C- dd Ee Gg P- Spsp | Dilute Agouti Kragenschecke // Dilute Kohlfuchs Kragenschecke DP | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids |
| Hanami | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp // aa Cc[chm] D- Ee uw[d]uw[d] P- spsp | — | 02.01.2020 // 12.12.2019 // 14.01.2020 | Stammbaum von Hana, Stammbaum von Kentucky, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von Stella Kids, Stammbaum von Vance, Stammbaum von Zac (Vance.Dorie) |
| | 13.08.2025 | Aa C- D- ee[f] G(G) pp Spsp // aa Cc[chm] D- ee[f] Gg Pp Spsp | Goldfuchsschimmel Kragenschecke // Kohlfuchsschimmel, hell | — | Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus Kids |
## Mehrdeutige / unvollständige Einträge
342 Einträge ohne sichere Name+Datum-Kombination (z. B. `Name1 & Name2`-Paarzellen der tiefsten Generation, oder Zellen ohne Datum). Diese werden NICHT automatisch zusammengeführt.
380 Einträge ohne sichere Name+Datum-Kombination (z. B. `Name1 & Name2`-Paarzellen der tiefsten Generation, oder Zellen ohne Datum). Diese werden NICHT automatisch zusammengeführt.
- Oskar v.d. bunten Fellnase · Stammbaum von Akio Kids.xlsx
- Raya v.d. bunten Fellnasen · Stammbaum von Akio Kids.xlsx
@@ -97,7 +89,7 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
## Wahrscheinliche Zuordnungen unvollständiger Einträge
89 namenlose/datenlose Einträge tragen denselben Namen wie ein vollständiges Tier — vermutlich dasselbe Tier (zur Bestätigung):
98 namenlose/datenlose Einträge tragen denselben Namen wie ein vollständiges Tier — vermutlich dasselbe Tier (zur Bestätigung):
- „Tai of Lennylengo“ → Tai of Lennylengo (*01.10.2011)
- „Arrow PZ Niederlande“ → Arrow PZ Niederlande (*20.05.2014)
@@ -176,7 +168,6 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
| `!` | 2 | ? |
| `C(C)` | 2 | Schreibweise (C trägt c) |
| `(KW` | 2 | ? |
| `Cc[]` | 1 | ? |
| `[WFNZ-Maroon]` | 1 | ? |
| `/+April'2017` | 1 | ? |
| `[meliert]-[DP]` | 1 | ? |
@@ -191,6 +182,7 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
| `+2018` | 1 | ? |
| `AAA` | 1 | ? |
| `chmchm` | 1 | Schreibweise (c[chm]c[chm]) |
| `c[chm]chm]` | 1 | ? |
## Wurfchronik — Datenqualitäts-Hinweise

View File

@@ -0,0 +1,160 @@
"""Zero-dep tests for merge_and_resolve.py litter-dedup & parent-role logic.
Run: python test_merge_resolve.py (exit 0 = all pass)
Covers the sibling-pairing data fix:
- litter_compatible(): same-date / compatible-parent dedup, incl. the dateless
guard that stops unrelated nameless stubs from blind-merging.
- assign_parent_roles(): gender-correct role assignment, self-pairing removal,
no two same-role parents — the fix for the 16 self-pairings / 30 gender-role
errors that the old simple swap missed.
"""
import sys
import merge_and_resolve as m
def check(name, cond):
if not cond:
print(f"FAIL: {name}")
check.failed += 1
else:
print(f"ok: {name}")
check.failed = 0
def litter(date, fid=None, mid=None, fname=None, mname=None):
return {
"Date": date,
"FatherId": fid,
"MotherId": mid,
"_father_name": fname,
"_mother_name": mname,
}
# ── litter_compatible: both sides fully parented ──
check(
"same date + same parents → compatible",
m.litter_compatible(litter("2018-09-22", "F", "M"), litter("2018-09-22", "F", "M")),
)
check(
"same date + different parents → NOT compatible",
not m.litter_compatible(litter("2018-09-22", "F", "M"), litter("2018-09-22", "X", "Y")),
)
check(
"different date → NOT compatible",
not m.litter_compatible(litter("2018-09-22", "F", "M"), litter("2019-01-01", "F", "M")),
)
# ── asymmetric (one resolved, one not), real date ──
check(
"asymmetric same real date, names agree → merge",
m.litter_compatible(
litter("2018-09-22", "F", "M", "Wonderman", "Unique"),
litter("2018-09-22", None, None, "Wonderman", "Unique"),
),
)
check(
"asymmetric same real date, conflicting names → NO merge",
not m.litter_compatible(
litter("2018-09-22", "F", "M", "Wonderman", "Unique"),
litter("2018-09-22", None, None, "Someone", "Else"),
),
)
check(
"asymmetric real date, parented side nameless stub → merge (no conflict)",
m.litter_compatible(
litter("2018-09-22", "F", "M", "Wonderman", "Unique"),
litter("2018-09-22", None, None, None, None),
),
)
# ── dateless guard: the bug that wrongly merged unrelated stubs ──
check(
"dateless asymmetric, NO name evidence → do NOT merge (was the bug)",
not m.litter_compatible(
litter(None, "F", "M", "Akina", "Arrow"),
litter(None, None, None, None, None),
),
)
check(
"dateless asymmetric WITH positive name match → merge",
m.litter_compatible(
litter(None, "F", "M", "Akina", "Arrow"),
litter(None, None, None, "Akina", None),
),
)
check(
"dateless asymmetric, contradicting names → do NOT merge",
not m.litter_compatible(
litter(None, "F", "M", "Akina", "Arrow"),
litter(None, None, None, "Mismatch", None),
),
)
# ── neither side parented → never blind-merge ──
check(
"neither parented, same date → NOT compatible",
not m.litter_compatible(litter("2018-09-22"), litter("2018-09-22")),
)
# ── assign_parent_roles ──
GENDER = {"bock": "male", "bock2": "male", "maus": "female", "maus2": "female", "u": "unknown", "u2": "unknown"}
gof = lambda gid: GENDER.get(gid)
check("correct roles stay put", m.assign_parent_roles("bock", "maus", gof) == ("bock", "maus"))
check("reversed roles get swapped", m.assign_parent_roles("maus", "bock", gof) == ("bock", "maus"))
check(
"self-pairing collapses to gender-correct single role (male→father)",
m.assign_parent_roles("bock", "bock", gof) == ("bock", None),
)
check(
"self-pairing collapses to gender-correct single role (female→mother)",
m.assign_parent_roles("maus", "maus", gof) == (None, "maus"),
)
check(
"female in father slot, empty mother → moved to mother",
m.assign_parent_roles("maus", None, gof) == (None, "maus"),
)
check(
"male in mother slot, empty father → moved to father",
m.assign_parent_roles(None, "bock", gof) == ("bock", None),
)
check(
"two males → keep one father, drop impossible second",
m.assign_parent_roles("bock", "bock2", gof) == ("bock", None),
)
check(
"two females → keep one mother, drop impossible second",
m.assign_parent_roles("maus", "maus2", gof) == (None, "maus"),
)
check(
"male + unknown → unknown fills mother",
m.assign_parent_roles("bock", "u", gof) == ("bock", "u"),
)
check(
"female + unknown → unknown fills father",
m.assign_parent_roles("u", "maus", gof) == ("u", "maus"),
)
check("both empty → both None", m.assign_parent_roles(None, None, gof) == (None, None))
check(
"single unknown parent kept as father",
m.assign_parent_roles("u", None, gof) == ("u", None),
)
# ── name helpers ──
check("names_no_conflict: one side empty", m.names_no_conflict(litter(None, fname="A"), litter(None)))
check(
"names_no_conflict: contradiction detected",
not m.names_no_conflict(litter(None, fname="A"), litter(None, fname="B")),
)
check("names_overlap: matching father name", m.names_overlap(litter(None, fname="A"), litter(None, fname="A")))
check("names_overlap: nothing in common", not m.names_overlap(litter(None, fname="A"), litter(None, mname="B")))
if check.failed:
print(f"\n{check.failed} test(s) FAILED")
sys.exit(1)
print("\nAll merge_and_resolve tests passed.")