FEAT-8b: bake Julian's authoritative Wurfchronik semantics into the extractor
- survivedToGoHome: unlabeled Tabelle1 col E detected positionally; value-
adaptive row parsing recovers it from schema-shifted Tabelle2 rows too
(118 recovered, 79 confirmed by the E=F-TG-s identity)
- breakdown G -> males/females/stillborn/diedLater ('s' = died before Abgabe)
- validation: E = F - TG - s; 113 mismatches as German review-report warnings
- (name, Zucht) canonicalisation: [brackets] == of/von suffix ([ZdkC] ==
von den Kleinen Chaoten); Zucht = dedup discriminator (0 splits in data)
- animal->litter matching via DOB+(Vater,Mutter): 95 high-confidence,
40 date-only, 9 ambiguous; litterRef in animals.json
- regenerated report: 889 raw -> 574 unique (279 dated), 32 conflicts
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,53 @@ def clean_name(raw):
|
||||
return n
|
||||
|
||||
|
||||
# --- (name, Zucht) canonicalisation -------------------------------------------
|
||||
# Julian (FEAT-8 ruling): the [brackets] in Wurfchronik names ARE the Zucht
|
||||
# (breeding line) and are equivalent to the Stammbaum "of/von <line>" suffix.
|
||||
# Both fold into one canonical (call-name, zucht) pair; the Zucht acts as a
|
||||
# dedup DISCRIMINATOR (same name+DOB but different Zucht = different animal).
|
||||
_BRACKET_ZUCHT = re.compile(r"^(.*?)\s*\[([^\]]+)\]\s*$")
|
||||
_SUFFIX_ZUCHT = re.compile(
|
||||
r"^(.+?)\s+(?:of|von\s+den|von\s+der|v\.\s?d\.|von)\s+(.+)$", re.IGNORECASE)
|
||||
# canonical values are post-norm_zucht (word-final 'n' folded: "kleinechaote")
|
||||
ZUCHT_ALIASES = {
|
||||
"zdkc": "kleinechaote", # "Zucht der kleinen Chaoten" (home cattery shorthand)
|
||||
}
|
||||
|
||||
|
||||
def split_name_zucht(raw):
|
||||
"""'Luna [ZdkC]' -> ('Luna','ZdkC'); 'Pikachu of Black Forest' ->
|
||||
('Pikachu','Black Forest'); plain names -> (name, '')."""
|
||||
n = (raw or "").strip()
|
||||
m = _BRACKET_ZUCHT.match(n)
|
||||
if m:
|
||||
return m.group(1).strip(), m.group(2).strip()
|
||||
m = _SUFFIX_ZUCHT.match(n)
|
||||
if m:
|
||||
return m.group(1).strip(), m.group(2).strip()
|
||||
return n, ""
|
||||
|
||||
|
||||
def norm_zucht(z):
|
||||
"""Canonical Zucht key: drops Zucht/von/der fillers, folds declension
|
||||
('kleinen Chaoten' == 'kleine Chaoten'), resolves known shorthands."""
|
||||
if not z:
|
||||
return ""
|
||||
n = z.lower()
|
||||
n = re.sub(r"\bv\.\s?d\.\b", " ", n)
|
||||
n = re.sub(r"\b(zucht|privatzucht|der|die|den|des|dem|von|of)\b", " ", n)
|
||||
n = re.sub(r"[^a-z0-9äöüß ]", " ", n)
|
||||
words = [w[:-1] if len(w) > 4 and w.endswith("n") else w for w in n.split()]
|
||||
key = "".join(words)
|
||||
return ZUCHT_ALIASES.get(key, key)
|
||||
|
||||
|
||||
def canon_pair(raw):
|
||||
"""Full raw name -> (normalised call-name, canonical zucht)."""
|
||||
name, zucht = split_name_zucht(raw)
|
||||
return norm_name(name), norm_zucht(zucht)
|
||||
|
||||
|
||||
# --------------------------------------------------- Stammbaum extraction ----
|
||||
def parse_detail(text):
|
||||
"""From a string that contains *DOB and/or genotype, pull (dob, death, geno_str).
|
||||
@@ -167,6 +214,10 @@ def extract_stammbaum(path):
|
||||
|
||||
g = parse_detail(t) if compact else (dob, death, geno)
|
||||
genodict = gt.parse(geno)
|
||||
# Zucht: from the name's [tag]/of-von suffix, else from the breeder line
|
||||
_, zraw = split_name_zucht(name)
|
||||
if not zraw and breeder:
|
||||
zraw = breeder
|
||||
animals.append({
|
||||
"id": None, # assigned in dedup
|
||||
"name": name,
|
||||
@@ -177,6 +228,7 @@ def extract_stammbaum(path):
|
||||
"farbschlag": farbschlag,
|
||||
"genotype": genodict,
|
||||
"breeder": breeder,
|
||||
"zucht": zraw,
|
||||
"parentRefs": [],
|
||||
"photos": [],
|
||||
"sourceFiles": [fname],
|
||||
@@ -184,6 +236,7 @@ def extract_stammbaum(path):
|
||||
"_col": c,
|
||||
"_row": r,
|
||||
"_file": fname,
|
||||
"_zucht": norm_zucht(zraw),
|
||||
})
|
||||
|
||||
# name-pair cells "X & Y" (deepest generation, names only)
|
||||
@@ -194,12 +247,14 @@ def extract_stammbaum(path):
|
||||
for part in t.split(" & "):
|
||||
part = clean_name(part)
|
||||
if part:
|
||||
_, zraw = split_name_zucht(part)
|
||||
animals.append({
|
||||
"id": None, "name": part, "nameVariants": [],
|
||||
"dob": "", "death": "", "gender": None, "farbschlag": "",
|
||||
"genotype": gt.parse(""), "breeder": "", "parentRefs": [],
|
||||
"photos": [], "sourceFiles": [fname],
|
||||
"genotype": gt.parse(""), "breeder": "", "zucht": zraw,
|
||||
"parentRefs": [], "photos": [], "sourceFiles": [fname],
|
||||
"_gen": gen_of(c), "_col": c, "_row": r, "_file": fname,
|
||||
"_zucht": norm_zucht(zraw),
|
||||
})
|
||||
|
||||
_reconstruct_parents(animals)
|
||||
@@ -297,7 +352,19 @@ def extract_wurfchronik(path):
|
||||
col_ws = find("ws", "wurfstärke", "wurfstaerke")
|
||||
col_breakdown = find("männchen", "maennchen", "weibchen")
|
||||
col_zn = find("zuchtnummer")
|
||||
col_note = find("bemerkung")
|
||||
col_note = find("bemerkung", "anmerkung")
|
||||
# Julian (authoritative): Tabelle1 has an UNLABELED numeric column between
|
||||
# Vater and Wurfstärke = "Überlebende bis zum Abgabedatum" (survivors to
|
||||
# go-home). Tabelle2 dropped it. Header-based find() cannot see it, so
|
||||
# detect it positionally.
|
||||
col_survived = None
|
||||
if col_sire and col_ws and col_ws - col_sire > 1:
|
||||
mapped = {col_id, col_date, col_dam, col_sire, col_ws,
|
||||
col_breakdown, col_zn, col_note}
|
||||
for c in range(col_sire + 1, col_ws):
|
||||
if c not in mapped:
|
||||
col_survived = c
|
||||
break
|
||||
sheet_name = os.path.basename(sp)
|
||||
for r in sorted(rows):
|
||||
if r == hdr_row: # skip the header row itself
|
||||
@@ -310,25 +377,86 @@ def extract_wurfchronik(path):
|
||||
if "jahr" in " ".join(row.values()).lower() and not DOB.search(txt_b):
|
||||
continue
|
||||
dob = DOB.search(txt_b)
|
||||
bd = row.get(col_breakdown, "") if col_breakdown else ""
|
||||
m = re.findall(r"\d+", bd)
|
||||
breakdown = {}
|
||||
if len(m) >= 1:
|
||||
keys = ["maennchen", "weibchen", "totgeburt", "s"]
|
||||
for k, val in zip(keys, m):
|
||||
breakdown[k] = int(val)
|
||||
lid = row.get(col_id, "")
|
||||
datestr = dob.group(1) if dob else ""
|
||||
dam_raw = row.get(col_dam, "") if col_dam else ""
|
||||
sire_raw = row.get(col_sire, "") if col_sire else ""
|
||||
_, dam_zucht = split_name_zucht(dam_raw)
|
||||
# '&' = multiple sires possible (litter with uncertain/dual sire)
|
||||
sire_names = [s.strip() for s in sire_raw.split("&") if s.strip()]
|
||||
_, sire_zucht = split_name_zucht(sire_names[0] if sire_names else "")
|
||||
|
||||
# Numeric layout is read VALUE-ADAPTIVELY per row: a handful of
|
||||
# Tabelle2 rows insert an extra numeric column (Überlebende, T1
|
||||
# order E,F) before WS and shift the breakdown right of its header.
|
||||
warnings = []
|
||||
scan_end = (col_note or (col_sire or 4) + 6) + 1
|
||||
bd_col = None
|
||||
for c in range((col_sire or 4) + 1, scan_end):
|
||||
if re.fullmatch(r"\d+(?:[,;]\d+){1,3}", row.get(c, "")):
|
||||
bd_col = c
|
||||
break
|
||||
if bd_col and col_breakdown and bd_col != col_breakdown:
|
||||
warnings.append(
|
||||
f"Spaltenschema-Abweichung: Geschlechter-Aufschlüsselung in "
|
||||
f"Spalte {xu.num_to_col(bd_col)} statt "
|
||||
f"{xu.num_to_col(col_breakdown)} gefunden")
|
||||
# single numeric cells between Vater and the breakdown: E (Überlebende)
|
||||
# and/or F (Wurfstärke), in T1 order
|
||||
singles = []
|
||||
for c in range((col_sire or 4) + 1, bd_col or scan_end):
|
||||
v = row.get(c, "")
|
||||
if re.fullmatch(r"\d+", v):
|
||||
singles.append(int(v))
|
||||
if len(singles) >= 2:
|
||||
survived, total_born = singles[0], singles[1]
|
||||
if not col_survived:
|
||||
warnings.append(
|
||||
"Spaltenschema-Abweichung: zusätzliche Zahlenspalte als "
|
||||
"„Überlebende“ interpretiert (bitte prüfen)")
|
||||
elif len(singles) == 1:
|
||||
total_born = singles[0]
|
||||
survived = None
|
||||
else:
|
||||
total_born = _to_int(row.get(col_ws)) if col_ws else None
|
||||
survived = _to_int(row.get(col_survived)) if col_survived else None
|
||||
|
||||
# breakdown "2,0,2,0" = Männchen, Weibchen, Totgeburt, später
|
||||
# verstorben ('s' = died after birth, before Abgabe — Julian).
|
||||
bd = row.get(bd_col, "") if bd_col else ""
|
||||
m = re.findall(r"\d+", bd)
|
||||
males = females = stillborn = died_later = None
|
||||
if m:
|
||||
vals = [int(x) for x in m] + [None] * 4
|
||||
males, females, stillborn, died_later = vals[:4]
|
||||
# Validation (Julian): Überlebende E should equal F − TG − s.
|
||||
# Mismatch = data-quality signal for the review report, NOT a blocker.
|
||||
if survived is not None and total_born is not None:
|
||||
expected = total_born - (stillborn or 0) - (died_later or 0)
|
||||
if survived != expected:
|
||||
warnings.append(
|
||||
f"Überlebende ({survived}) ≠ Wurfstärke ({total_born}) "
|
||||
f"− Totgeburten ({stillborn or 0}) − später verstorben "
|
||||
f"({died_later or 0}) = {expected}")
|
||||
litters.append({
|
||||
"id": f"{sheet_name.replace('.xml','')}-{lid}-{norm_dob(datestr)}",
|
||||
"litterId": lid,
|
||||
"date": datestr,
|
||||
"damName": row.get(col_dam, "") if col_dam else "",
|
||||
"sireName": row.get(col_sire, "") if col_sire else "",
|
||||
"wurfstaerke": _to_int(row.get(col_ws)) if col_ws else None,
|
||||
"sexBreakdown": breakdown,
|
||||
"damName": dam_raw,
|
||||
"damZucht": dam_zucht,
|
||||
"sireName": sire_raw,
|
||||
"sireNames": sire_names,
|
||||
"sireZucht": sire_zucht,
|
||||
"totalBorn": total_born,
|
||||
"survivedToGoHome": survived,
|
||||
"males": males,
|
||||
"females": females,
|
||||
"stillborn": stillborn,
|
||||
"diedLater": died_later,
|
||||
"breakdownRaw": bd,
|
||||
"zuchtnummer": row.get(col_zn, "") if col_zn else "",
|
||||
"note": row.get(col_note, "") if col_note else "",
|
||||
"warnings": warnings,
|
||||
"sourceFile": fname,
|
||||
"sheet": sheet_name,
|
||||
"row": r,
|
||||
@@ -345,20 +473,48 @@ def _to_int(s):
|
||||
|
||||
# ------------------------------------------------------------- stage 2: dedup
|
||||
def dedup(animals):
|
||||
"""Merge by normalise(name)+DOB. Returns (merged, conflicts, orphans)."""
|
||||
"""Merge by normalise(call-name)+DOB, with the canonical Zucht as
|
||||
DISCRIMINATOR (Julian: same name+DOB but different Zucht = different
|
||||
animal). Returns (merged, conflicts, orphans, zucht_splits)."""
|
||||
groups = {}
|
||||
orphans = []
|
||||
for a in animals:
|
||||
key = (norm_name(a["name"]), norm_dob(a["dob"]))
|
||||
call, _ = split_name_zucht(a["name"])
|
||||
key = (norm_name(call), norm_dob(a["dob"]))
|
||||
if not key[0] or not key[1]:
|
||||
orphans.append(a)
|
||||
# orphans still get a stable id but are not merged
|
||||
key = ("__orphan__", id(a))
|
||||
groups.setdefault(key, []).append(a)
|
||||
|
||||
# split groups whose members carry DIFFERENT (non-empty) canonical Zuchten;
|
||||
# members without a Zucht merge into the group only if it is unambiguous.
|
||||
final_groups = []
|
||||
zucht_splits = []
|
||||
for key, grp in groups.items():
|
||||
by_zucht = {}
|
||||
for a in grp:
|
||||
by_zucht.setdefault(a.get("_zucht", ""), []).append(a)
|
||||
nonempty = sorted(z for z in by_zucht if z)
|
||||
if len(nonempty) <= 1:
|
||||
final_groups.append(grp)
|
||||
continue
|
||||
# genuine split: same call-name+DOB, different Zucht
|
||||
for z in nonempty:
|
||||
final_groups.append(by_zucht[z])
|
||||
if "" in by_zucht:
|
||||
# Zucht-less mentions cannot be attributed -> own (flagged) entry
|
||||
final_groups.append(by_zucht[""])
|
||||
zucht_splits.append({
|
||||
"name": grp[0]["name"],
|
||||
"dob": norm_dob(grp[0]["dob"]),
|
||||
"zuechte": sorted(set(a["zucht"] for a in grp if a.get("zucht"))),
|
||||
"files": sorted(set(f for a in grp for f in a["sourceFiles"])),
|
||||
})
|
||||
|
||||
merged = []
|
||||
conflicts = []
|
||||
for key, grp in groups.items():
|
||||
for grp in final_groups:
|
||||
base = dict(grp[0])
|
||||
variants = set([base["name"]])
|
||||
files = set(base["sourceFiles"])
|
||||
@@ -392,6 +548,8 @@ def dedup(animals):
|
||||
"farbschlagVariants": sorted(farb),
|
||||
"genotype": best,
|
||||
"breeder": next((a["breeder"] for a in grp if a["breeder"]), ""),
|
||||
"zucht": next((a["zucht"] for a in grp if a.get("zucht")), ""),
|
||||
"zuchtCanon": next((a["_zucht"] for a in grp if a.get("_zucht")), ""),
|
||||
"parentRefs": _dedup_parentrefs(parent_refs),
|
||||
"photos": sorted(set(photos)),
|
||||
"sourceFiles": sorted(files),
|
||||
@@ -406,7 +564,64 @@ def dedup(animals):
|
||||
"deaths": sorted(deaths), "files": sorted(files),
|
||||
})
|
||||
merged.sort(key=lambda a: (a["dob"], a["name"]))
|
||||
return merged, conflicts, orphans
|
||||
# ids must stay unique even when a Zucht split duplicates name+DOB slugs
|
||||
seen_ids = {}
|
||||
for a in merged:
|
||||
if a["id"] in seen_ids:
|
||||
seen_ids[a["id"]] += 1
|
||||
a["id"] = f"{a['id']}-{seen_ids[a['id']]}"
|
||||
else:
|
||||
seen_ids[a["id"]] = 1
|
||||
return merged, conflicts, orphans, zucht_splits
|
||||
|
||||
|
||||
# ------------------------------------------- animal <-> litter matching ------
|
||||
def match_litters(merged, litters):
|
||||
"""Attach each animal to its Wurfchronik litter (Pam-validated build order:
|
||||
litters are canonical, animals match onto them via DOB + (Vater, Mutter)).
|
||||
Sets a['litterRef']; returns match statistics for the report."""
|
||||
by_date = {}
|
||||
for l in litters:
|
||||
d = norm_dob(l["date"])
|
||||
if d:
|
||||
by_date.setdefault(d, []).append(l)
|
||||
|
||||
stats = {"parents": 0, "dateOnly": 0, "ambiguous": 0}
|
||||
for a in merged:
|
||||
if not a["dob"]:
|
||||
continue
|
||||
cands = by_date.get(a["dob"])
|
||||
if not cands:
|
||||
continue
|
||||
a_parents = set()
|
||||
for ref in a["parentRefs"]:
|
||||
cn, _ = canon_pair(ref["name"])
|
||||
if cn:
|
||||
a_parents.add(cn)
|
||||
|
||||
def score(l):
|
||||
s = 0
|
||||
for nm in [l["damName"]] + l.get("sireNames", []):
|
||||
cn, _ = canon_pair(nm)
|
||||
if cn and cn in a_parents:
|
||||
s += 1
|
||||
return s
|
||||
|
||||
scored = sorted(((score(l), l["id"]) for l in cands), reverse=True)
|
||||
best_score, best_id = scored[0]
|
||||
if best_score > 0 and (len(scored) == 1 or scored[1][0] < best_score):
|
||||
a["litterRef"] = {"litterId": best_id,
|
||||
"method": "geburtsdatum+eltern", "confidence": "hoch"}
|
||||
stats["parents"] += 1
|
||||
elif len(cands) == 1:
|
||||
a["litterRef"] = {"litterId": cands[0]["id"],
|
||||
"method": "nur-geburtsdatum", "confidence": "niedrig"}
|
||||
stats["dateOnly"] += 1
|
||||
else:
|
||||
a["litterRef"] = {"litterId": None, "method": "mehrdeutig",
|
||||
"candidates": [l["id"] for l in cands]}
|
||||
stats["ambiguous"] += 1
|
||||
return stats
|
||||
|
||||
|
||||
def _dedup_parentrefs(refs):
|
||||
@@ -419,12 +634,13 @@ def _dedup_parentrefs(refs):
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ reporting
|
||||
def write_report(merged, conflicts, orphans, raw_count, litters, photo_count):
|
||||
keyset = set((a["dob"], norm_name(a["name"])) for a in merged)
|
||||
def write_report(merged, conflicts, orphans, raw_count, litters, photo_count,
|
||||
zucht_splits, match_stats):
|
||||
multi = [a for a in merged if a["mentions"] > 1]
|
||||
with_dob = [a for a in merged if a["dob"]]
|
||||
lit_dates = set(norm_dob(l["date"]) for l in litters if l["date"])
|
||||
joinable = [a for a in merged if a["dob"] and norm_dob(a["dob"]) in lit_dates]
|
||||
val_warn = [l for l in litters if any("≠" in w for w in l["warnings"])]
|
||||
schema_warn = [l for l in litters if any("Abweichung" in w for w in l["warnings"])]
|
||||
matched = match_stats["parents"] + match_stats["dateOnly"]
|
||||
|
||||
L = []
|
||||
L.append("# FEAT-8b — Import-Vorschau & Prüfbericht (Stammbäume + Wurfchronik)\n")
|
||||
@@ -439,12 +655,32 @@ def write_report(merged, conflicts, orphans, raw_count, litters, photo_count):
|
||||
L.append(f"- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **{len(orphans)}**")
|
||||
L.append(f"- Fotos zugeordnet: **{photo_count}**")
|
||||
L.append(f"- Würfe aus der Wurfchronik: **{len(litters)}**")
|
||||
L.append(f" - Tiere, deren Geburtsdatum zu einem Wurf passt (verknüpfbar): {len(joinable)}\n")
|
||||
L.append(f" - Tiere mit Wurf verknüpft: **{matched}** "
|
||||
f"(davon über Geburtsdatum **und** Eltern: {match_stats['parents']}, "
|
||||
f"nur über Geburtsdatum: {match_stats['dateOnly']}; "
|
||||
f"mehrdeutig: {match_stats['ambiguous']})")
|
||||
L.append(f" - Würfe mit Datenqualitäts-Hinweisen: {len(val_warn)} "
|
||||
f"(+ {len(schema_warn)} Zeilen mit abweichendem Spaltenschema)\n")
|
||||
|
||||
L.append("## Zusammenführungs-Schlüssel\n")
|
||||
L.append("Tiere wurden zusammengeführt über **normalisierter Name + Geburtsdatum**. "
|
||||
"Namensvarianten (z. B. `v.d.` ↔ `von den`, `gen.`-Spitznamen, Zuchtsuffixe) "
|
||||
"werden als `nameVariants` erhalten.\n")
|
||||
L.append("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.\n")
|
||||
|
||||
if zucht_splits:
|
||||
L.append("### Gleicher Name + Geburtsdatum, aber unterschiedliche Zucht "
|
||||
"(NICHT zusammengeführt — bitte prüfen)\n")
|
||||
L.append("| Tier | Geburtsdatum | Zuchten | Dateien |")
|
||||
L.append("|---|---|---|---|")
|
||||
for s in zucht_splits[:50]:
|
||||
L.append("| {} | {} | {} | {} |".format(
|
||||
split_name_zucht(s["name"])[0], s["dob"],
|
||||
" // ".join(s["zuechte"]),
|
||||
", ".join(os.path.splitext(f)[0] for f in s["files"])))
|
||||
L.append("")
|
||||
|
||||
L.append("## ⚠️ Konflikte (bitte prüfen)\n")
|
||||
if conflicts:
|
||||
@@ -506,6 +742,37 @@ def write_report(merged, conflicts, orphans, raw_count, litters, photo_count):
|
||||
for t, n in tok.most_common(25):
|
||||
L.append(f"| `{t}` | {n} | {hint.get(t, '?')} |")
|
||||
|
||||
# Wurfchronik data quality (Julian: E sollte = F − TG − s sein)
|
||||
L.append("\n## Wurfchronik — Datenqualitäts-Hinweise\n")
|
||||
L.append("Julians Spaltenregel: **Überlebende bis Abgabe (E) = Wurfstärke (F) − "
|
||||
"Totgeburten (TG) − später verstorben (s)**. Bei diesen Würfen geht die "
|
||||
"Rechnung nicht auf — kein Import-Hindernis, aber ein Hinweis auf "
|
||||
"Tippfehler oder fehlende Einträge:\n")
|
||||
if val_warn:
|
||||
L.append("| Wurf | Datum | Mutter × Vater | Hinweis |")
|
||||
L.append("|---|---|---|---|")
|
||||
for l in val_warn[:120]:
|
||||
L.append("| {} | {} | {} × {} | {} |".format(
|
||||
l["litterId"], l["date"], l["damName"], l["sireName"],
|
||||
"; ".join(w for w in l["warnings"] if "≠" in w)))
|
||||
if len(val_warn) > 120:
|
||||
L.append(f"\n… und {len(val_warn) - 120} weitere (siehe `litters.json`).")
|
||||
else:
|
||||
L.append("_Keine — alle Würfe sind rechnerisch konsistent._")
|
||||
|
||||
L.append("\n### Zeilen mit abweichendem Spaltenschema (automatisch interpretiert)\n")
|
||||
L.append(f"{len(schema_warn)} Zeilen (überwiegend Tabelle2 ab 2014) tragen eine "
|
||||
"zusätzliche Zahlenspalte vor der Wurfstärke bzw. eine verschobene "
|
||||
"Geschlechter-Aufschlüsselung. Sie wurden nach dem Muster von Tabelle1 "
|
||||
"gelesen (**Überlebende, Wurfstärke, Aufschlüsselung**) — bei "
|
||||
f"{sum(1 for l in schema_warn if not any('≠' in w for w in l['warnings']))} "
|
||||
"davon geht die Rechnung E = F − TG − s damit exakt auf, was die Lesart "
|
||||
"bestätigt. Alle betroffenen Zeilen sind in `litters.json` mit "
|
||||
"`warnings` markiert. Beispiele:\n")
|
||||
for l in schema_warn[:8]:
|
||||
L.append(f"- Wurf {l['litterId']} ({l['date']}): E={l['survivedToGoHome']}, "
|
||||
f"F={l['totalBorn']}, Aufschlüsselung `{l['breakdownRaw']}`")
|
||||
|
||||
L.append("\n## Hinweise für den Import (Stufe 3, später)\n")
|
||||
L.append("- **Wurfchronik = Quelle der Würfe** (Datum, Wurfstärke, Eltern, Zuchtnummer); "
|
||||
"**Stammbäume = Abstammung + Genotyp + Fotos**. Verknüpfung über Geburtsdatum + Elternnamen.")
|
||||
@@ -550,7 +817,8 @@ def main():
|
||||
litters = extract_wurfchronik(args.wurfchronik)
|
||||
print(f"Wurfchronik: {len(litters)} Würfe")
|
||||
|
||||
merged, conflicts, orphans = dedup(raw_animals)
|
||||
merged, conflicts, orphans, zucht_splits = dedup(raw_animals)
|
||||
match_stats = match_litters(merged, litters)
|
||||
photo_count = sum(len(a["photos"]) for a in merged)
|
||||
|
||||
# strip private (_) fields from the JSON output
|
||||
@@ -562,10 +830,15 @@ def main():
|
||||
with open(os.path.join(OUT, "litters.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(litters, f, ensure_ascii=False, indent=2)
|
||||
|
||||
write_report(merged, conflicts, orphans, len(raw_animals), litters, photo_count)
|
||||
write_report(merged, conflicts, orphans, len(raw_animals), litters, photo_count,
|
||||
zucht_splits, match_stats)
|
||||
|
||||
print(f"\nRoh: {len(raw_animals)} → eindeutig: {len(merged)} "
|
||||
f"| Konflikte: {len(conflicts)} | Orphans: {len(orphans)} | Fotos: {photo_count}")
|
||||
f"| Konflikte: {len(conflicts)} | Zucht-Splits: {len(zucht_splits)} "
|
||||
f"| Orphans: {len(orphans)} | Fotos: {photo_count}")
|
||||
print(f"Wurf-Verknüpfung: {match_stats['parents']} (Datum+Eltern), "
|
||||
f"{match_stats['dateOnly']} (nur Datum), {match_stats['ambiguous']} mehrdeutig "
|
||||
f"| Wurf-Warnungen: {sum(1 for l in litters if l['warnings'])}")
|
||||
print(f"Ausgabe in {OUT}")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user