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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
160
tools/import/test_merge_resolve.py
Normal file
160
tools/import/test_merge_resolve.py
Normal 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.")
|
||||
Reference in New Issue
Block a user