Compare commits

...

3 Commits

Author SHA1 Message Date
cb2ee03a14 fix: Naho Eltern + Foto-Layout-Erkennung + Eltern-Fallback-Lookup
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m34s
CI / Docker Build & Push (push) Failing after 9s
2026-06-21 22:55:50 +02:00
41ff973f9a feat(import): merge animals with different DOBs if their parent names match
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m35s
CI / Docker Build & Push (push) Failing after 12s
2026-06-21 22:23:59 +02:00
d2993418b6 fix(import): remap Kazuya DOB and correct parents using Stammbaum von Kazuya.xlsx
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m4s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-21 22:18:04 +02:00
5 changed files with 228 additions and 21 deletions

View File

@@ -242,6 +242,29 @@
"decision": "Genotype is aa CC D- Ee Gg pp Spsp [DP] (C locus is CC, full color, duplicate colourpoint record resolved).",
"genotype": "aa CC D- Ee Gg pp Spsp [DP]",
"source": "Julian 2026-06-12"
},
{
"name": "Kazuya von den Kleinen Chaoten",
"dob": "22.08.2018",
"decision": "duplicate with wrong birthdate and parents — same animal as Kazuya *14.07.2019; merge into it and correct parents to Wilbur + Naho",
"correctDob": "14.07.2019",
"source": "Stammbaum von Kazuya.xlsx"
},
{
"name": "Kazuya von den Kleinen Chaoten",
"dob": "14.07.2019",
"decision": "father = Wilbur von den Kleinen Chaoten, mother = Naho von den Kleinen Chaoten (from Stammbaum von Kazuya.xlsx)",
"father": "Wilbur von den Kleinen Chaoten",
"mother": "Naho von den Kleinen Chaoten",
"source": "Stammbaum von Kazuya.xlsx"
},
{
"name": "Naho von den Kleinen Chaoten",
"dob": "20.07.2017",
"decision": "father = Osamu von den Kleinen Chaoten, mother = Montana v.d. Kleinen Chaoten (from Stammbaum von Kazuya.xlsx)",
"father": "Osamu von den Kleinen Chaoten",
"mother": "Montana v.d. Kleinen Chaoten",
"source": "Stammbaum von Kazuya.xlsx"
}
]
}

View File

@@ -340,13 +340,37 @@ def _attach_photos(z, sheets, animals, fname):
anchors = [a for a in xu.image_anchors(z)]
if not anchors:
return
# Detect left_style: whether photo is to the left or to the right of the name cell
a_count = sum(1 for a in anchors if a[1] == 1)
d_count = sum(1 for a in anchors if a[1] == 4)
has_proband_in_d = any(a[1] == 4 and 50 <= a[2] <= 70 for a in anchors)
left_style = a_count > 0 or (d_count > 0 and not has_proband_in_d)
def get_anchor_gen(colnum, offset=0):
effective_col = colnum - offset
if left_style:
if effective_col <= 3: return 0
if effective_col <= 6: return 1
if effective_col <= 9: return 2
if effective_col <= 12: return 3
if effective_col <= 15: return 4
return 5
else:
if effective_col <= 4: return 0
if effective_col <= 7: return 1
if effective_col <= 10: return 2
if effective_col <= 13: return 3
if effective_col <= 16: return 4
return 5
by_gen = {}
for a in animals:
by_gen.setdefault(a["_gen"], []).append(a)
media_dir = os.path.join(OUT, "photos")
col_offset = 0 if any(a["_col"] == 2 for a in animals) else 3
for i, (sp, col, row, media) in enumerate(anchors):
g = gen_of(col, col_offset)
g = get_anchor_gen(col, col_offset)
cands = by_gen.get(g, [])
if not cands:
# fall back to nearest animal by row across all gens
@@ -362,6 +386,7 @@ def _attach_photos(z, sheets, animals, fname):
try:
with z.open(media) as src, open(os.path.join(OUT, rel), "wb") as dst:
shutil.copyfileobj(src, dst)
if rel not in target["photos"]:
target["photos"].append(rel)
except KeyError:
pass
@@ -612,6 +637,49 @@ def dedup(animals):
"files": sorted(set(f for a in grp for f in a["sourceFiles"])),
})
# 2. Merge groups that have different DOBs but same call-name (and Zucht) and matching parents
def get_parent_keys(a):
parent_refs = a.get("parentRefs", [])
f_name = next((p["name"] for p in parent_refs if p.get("roleGuess") == "father"), "")
m_name = next((p["name"] for p in parent_refs if p.get("roleGuess") == "mother"), "")
return norm_name(f_name), norm_name(m_name)
def groups_parents_match(g1, g2):
for a1 in g1:
for a2 in g2:
f1, m1 = get_parent_keys(a1)
f2, m2 = get_parent_keys(a2)
if f1 and f2 and f1 == f2 and m1 and m2 and m1 == m2:
return True
return False
i = 0
while i < len(final_groups):
g1 = final_groups[i]
call1, _ = split_name_zucht(g1[0]["name"])
n_call1 = norm_name(call1)
zucht1 = g1[0].get("_zucht", "")
j = i + 1
merged_any = False
while j < len(final_groups):
g2 = final_groups[j]
call2, _ = split_name_zucht(g2[0]["name"])
n_call2 = norm_name(call2)
zucht2 = g2[0].get("_zucht", "")
if n_call1 == n_call2:
if zucht1 == zucht2 or not zucht1 or not zucht2:
if groups_parents_match(g1, g2):
g1.extend(g2)
final_groups.pop(j)
merged_any = True
continue
j += 1
if merged_any:
continue
i += 1
merged = []
conflicts = []
for grp in final_groups:
@@ -652,11 +720,25 @@ def dedup(animals):
return sum(1 for pair in gd["mapped8locus"].values() for a in pair if a != "?")
best = max((a["genotype"] for a in grp),
key=lambda gd: (len(gd["mapped8locus"]), _specificity(gd), len(gd["rawGenotype"])))
# Find best DOB (proband first)
best_dob = ""
for a in grp:
if a.get("_gen") == 0 and a.get("dob"):
best_dob = a["dob"]
break
if not best_dob:
for a in grp:
if a.get("dob"):
best_dob = a["dob"]
break
chosen_dob = norm_dob(best_dob or base["dob"])
out = {
"id": slug(base["name"], base["dob"]),
"id": slug(base["name"], chosen_dob),
"name": base["name"],
"nameVariants": sorted(v for v in variants if v),
"dob": norm_dob(base["dob"]),
"dob": chosen_dob,
"death": sorted(deaths)[0] if deaths else "",
# box-colour sex (blue=male, white=female): majority across mentions, else None.
"gender": Counter(genders).most_common(1)[0][0] if genders else None,
@@ -999,6 +1081,25 @@ def apply_conflict_decisions(merged, conflicts, path):
a["farbschlagVariants"] = [d["farbschlag"]]
if d.get("dateOfDeath"): # D5 death-date resolutions
a["death"] = norm_dob(d["dateOfDeath"])
if "father" in d or "mother" in d:
new_refs = []
if d.get("father"):
new_refs.append({
"name": d["father"],
"dob": "",
"roleGuess": "father",
"method": "decision",
"confidence": "high"
})
if d.get("mother"):
new_refs.append({
"name": d["mother"],
"dob": "",
"roleGuess": "mother",
"method": "decision",
"confidence": "high"
})
a["parentRefs"] = new_refs
if a.get("conflict"):
a["conflict"] = False
conflicts[:] = [c for c in conflicts if c.get("id") != a["id"]]

View File

@@ -1173,6 +1173,17 @@ def main():
norm_b, keep_b = get_normalized_contact_name(raw_breeder)
raw_breeder = norm_b if keep_b else None
parent_refs = []
if old_litter_id:
rl = next((l for l in raw_litters if l.get("_scoped_id") == old_litter_id), None)
if rl:
f_name = rl.get("FatherName") or rl.get("fatherName") or rl.get("ParentMaleName") or rl.get("parentMaleName") or rl.get("_father_name")
m_name = rl.get("MotherName") or rl.get("motherName") or rl.get("ParentFemaleName") or rl.get("parentFemaleName") or rl.get("_mother_name")
if f_name:
parent_refs.append({"name": f_name, "roleGuess": "father"})
if m_name:
parent_refs.append({"name": m_name, "roleGuess": "mother"})
all_processed_gerbils.append({
"Id": new_guid,
"Name": name_val,
@@ -1198,6 +1209,7 @@ def main():
"CharacterNote": char_note,
"IsDeaf": is_deaf,
"IsResident": is_resident,
"parentRefs": parent_refs,
"_photos": rg.get("photos", []),
"_old_scoped_litter_id": old_litter_id,
"_eff_dob": eff_dob_val,
@@ -1306,6 +1318,7 @@ def main():
"CharacterNote": None,
"IsDeaf": is_deaf,
"IsResident": a_id in stammbaum_resident_ids,
"parentRefs": a.get("parentRefs", []),
"_photos": a.get("photos", []),
"_old_scoped_litter_id": scoped_litter_id,
"_eff_dob": dob_val or "2010-01-01",
@@ -1364,6 +1377,15 @@ def main():
# Residents: if sold/given away, it's not a resident
is_resident = not bool(o_name)
parent_refs = []
if scoped_litter_id:
dl = next((l for l in docx_litters if docx_litter_id_map.get((l["wsCode"], parse_date(l["dob"]))) == scoped_litter_id), None)
if dl:
if dl.get("fatherName"):
parent_refs.append({"name": dl["fatherName"], "roleGuess": "father"})
if dl.get("motherName"):
parent_refs.append({"name": dl["motherName"], "roleGuess": "mother"})
all_processed_gerbils.append({
"Id": scoped_id,
"Name": name_val,
@@ -1389,6 +1411,7 @@ def main():
"CharacterNote": None,
"IsDeaf": None,
"IsResident": is_resident,
"parentRefs": parent_refs,
"_photos": da.get("photos", []),
"_old_scoped_litter_id": scoped_litter_id,
"_eff_dob": dob_val or "2020-01-01",
@@ -1418,6 +1441,20 @@ def main():
bd1 = g1.get("_birth_date")
bd2 = g2.get("_birth_date")
# New rule: if name and parents match, they are compatible regardless of DOB!
p1 = g1.get("parentRefs", [])
p2 = g2.get("parentRefs", [])
f1 = next((p["name"] for p in p1 if p.get("roleGuess") == "father"), "")
m1 = next((p["name"] for p in p1 if p.get("roleGuess") == "mother"), "")
f2 = next((p["name"] for p in p2 if p.get("roleGuess") == "father"), "")
m2 = next((p["name"] for p in p2 if p.get("roleGuess") == "mother"), "")
parents_match = False
if f1 and f2 and m1 and m2:
if normalize_name(f1) == normalize_name(f2) and normalize_name(m1) == normalize_name(m2):
parents_match = True
if not parents_match:
# If both have explicit birth dates, they must match within 30 days
if bd1 and bd2:
days1 = date_to_days(bd1)

View File

@@ -5,20 +5,31 @@ _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): **1006**
- davon mit Geburtsdatum: 681
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 460
- Konflikte zur Klärung: **2**
- 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: **416**
- Fotos zugeordnet: **422**
- Würfe aus der Wurfchronik: **752**
- Tiere mit Wurf verknüpft: **271** (davon über Geburtsdatum **und** Eltern: 166, nur über Geburtsdatum: 105; mehrdeutig: 16)
- Tiere mit Wurf verknüpft: **269** (davon über Geburtsdatum **und** Eltern: 165, nur über Geburtsdatum: 104; mehrdeutig: 16)
- 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 |
@@ -35,7 +46,9 @@ 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 |
| | 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
@@ -84,7 +97,7 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
## Wahrscheinliche Zuordnungen unvollständiger Einträge
91 namenlose/datenlose Einträge tragen denselben Namen wie ein vollständiges Tier — vermutlich dasselbe Tier (zur Bestätigung):
89 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)
@@ -95,7 +108,6 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
- „Oscar of Black Forest“ → Oscar of Black Forest (*12.06.2019)
- „Hagrid Rubeus of Black Forest“ → Hagrid Rubeus of Black Forest (*18.07.2019)
- „Lilo of LennyLengo“ → Lilo of LennyLengo (*04.11.2018)
- „Kazuya“ → Kazuya (*14.07.2019)
- „Harumi“ → Harumi (*21.02.2015)
- „Pan“ → Pan (*09.12.2014)
- „Gin“ → Gin (*05.02.2015)
@@ -146,6 +158,7 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
- „Zenon von Elea“ → Zenon von Elea (*06.10.2019)
- „Nisha of Black Forest“ → Nisha of Black Forest (*28.03.2018)
- „Zenon von Elea“ → Zenon von Elea (*06.10.2019)
- „Nisha of Black Forest“ → Nisha of Black Forest (*28.03.2018)
## Nicht ins 8-Loci-Modell abgebildete Tokens (verbatim erhalten)
@@ -173,11 +186,11 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
| `/+Dezember'2014` | 1 | ? |
| `(Ansatz)` | 1 | ? |
| `-psp` | 1 | ? |
| `G(G)` | 1 | ? |
| `!Niereninsuffizienz!` | 1 | ? |
| `+09.07.2017` | 1 | ? |
| `+2018` | 1 | ? |
| `AAA` | 1 | ? |
| `chmchm` | 1 | Schreibweise (c[chm]c[chm]) |
## Wurfchronik — Datenqualitäts-Hinweise

View File

@@ -111,10 +111,15 @@ def main():
# Index animals for fast lookup by slug ID and by normalized name+dob
animal_by_id = {a["id"]: a for a in animals}
animal_by_name_dob = {}
animal_by_name = {}
for a in animals:
key = (ex.norm_name(a["name"]), ex.norm_dob(a["dob"]))
if key[0] and key[1]:
animal_by_name_dob.setdefault(key, []).append(a)
nk = ex.norm_name(a["name"])
if nk:
animal_by_name.setdefault(nk, []).append(a)
# 2. Extract and resolve unique Contacts (Breeders & Zuchten)
print("Extracting unique contacts...")
@@ -163,8 +168,36 @@ def main():
l_parents = litter_parent_map.setdefault(l_id, [None, None]) # [father, mother]
for p_ref in a.get("parentRefs", []):
p_key = (ex.norm_name(p_ref["name"]), ex.norm_dob(p_ref["dob"]))
p_dob = ex.norm_dob(p_ref.get("dob", ""))
p_key = (ex.norm_name(p_ref["name"]), p_dob)
p_candidates = animal_by_name_dob.get(p_key, [])
if not p_candidates:
# Fallback to name-only lookup when parent DOB is empty/not found
p_norm = ex.norm_name(p_ref["name"])
candidates = animal_by_name.get(p_norm, [])
if candidates:
offspring_dob_str = parse_date_only(a["dob"])
if offspring_dob_str:
try:
o_dob = datetime.strptime(offspring_dob_str, "%Y-%m-%d")
valid_candidates = []
for cand in candidates:
cand_dob_str = parse_date_only(cand["dob"])
if cand_dob_str:
try:
c_dob = datetime.strptime(cand_dob_str, "%Y-%m-%d")
if c_dob < o_dob:
valid_candidates.append(cand)
except ValueError:
valid_candidates.append(cand)
else:
valid_candidates.append(cand)
if valid_candidates:
p_candidates = [valid_candidates[0]]
except ValueError:
p_candidates = [candidates[0]]
else:
p_candidates = [candidates[0]]
if p_candidates:
p_guid = animal_guid_map[p_candidates[0]["id"]]
if p_ref["roleGuess"] == "father":