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:
@@ -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")
|
||||
@@ -1716,59 +1836,70 @@ def main():
|
||||
# Parent Resolver (Global Name Matching)
|
||||
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)
|
||||
|
||||
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"]
|
||||
# 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 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")}
|
||||
|
||||
Reference in New Issue
Block a user