fix(import): Farbschlag aus Genotyp ableiten + Mamta-Eltern — Ticket-Triage
genotype.py: Python-Port von genotypeToFarbschlag (0 Abw. über 3402 Genotypen). resolve_color_and_genotype: bei vorhandenem Genotyp gewinnt der berechnete Farbschlag (Goldfuchs≠Gold, Dilute Agouti/Anthrazit, Blaufuchs statt -schimmel bei (schimmel), spsp statt Schecke). Mamta Mini: Ee + Eltern Geely×Gaida am Wurf. Regressionstests je Fall. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_doc": "Human conflict resolutions for the import quarantine (HUMANQUESTION section D / C6). The importer consumes this to UN-QUARANTINE an animal: for a matching (name + dob) it accepts the given authoritative field(s) — `genotype`, `farbschlag`, and/or `dateOfDeath` (DD.MM.YYYY) — and skips the conflict. Special field `correctDob` (DD.MM.YYYY): the matched (name + dob) record is a DUPLICATE with a WRONG birthdate — remap its DOB to `correctDob` BEFORE dedup so it merges into the canonical same-named animal. Other override fields per resolution: `gender` (male|female|m|w) — fix a misread box-colour gender (applies to stammbaum AND Wurfchronik/docx animals via merge_and_resolve.apply_decision_overrides); `father`/`mother` — authoritative parent NAMES; optional `fatherDob`/`motherDob` (DD.MM.YYYY) disambiguate a parent when several same-named animals exist. Special top-level array `addAnimals` [{name, gender, zucht?, dob?}] materialises a non-resident stub gerbil for a KNOWN parent that has no own source record (e.g. a mother named only on a Wurfchronik litter), so the litter's parent link resolves. Key match = normalize(call-name) + dob, same identity as dedup. Maintained by god (Michael) as Julian/his wife answer the D-conflicts; originals (xlsx) stay read-only.",
|
"_doc": "Human conflict resolutions for the import quarantine (HUMANQUESTION section D / C6). The importer consumes this to UN-QUARANTINE an animal: for a matching (name + dob) it accepts the given authoritative field(s) — `genotype`, `farbschlag`, and/or `dateOfDeath` (DD.MM.YYYY) — and skips the conflict. Special field `externalRef` (the dedup slug / animals.json id, e.g. \"unbekannt-13082025-3\"): matches ONE specific record even when several NAMELESS animals share the same (name=\"\" + dob) key — externalRef wins over the name/dob keys. An externalRef-only resolution (no `name`) does NOT register a name/dob key. Special field `correctDob` (DD.MM.YYYY): the matched (name + dob) record is a DUPLICATE with a WRONG birthdate — remap its DOB to `correctDob` BEFORE dedup so it merges into the canonical same-named animal. Other override fields per resolution: `gender` (male|female|m|w) — fix a misread box-colour gender (applies to stammbaum AND Wurfchronik/docx animals via merge_and_resolve.apply_decision_overrides); `father`/`mother` — authoritative parent NAMES; optional `fatherDob`/`motherDob` (DD.MM.YYYY) disambiguate a parent when several same-named animals exist. Special top-level array `addAnimals` [{name, gender, zucht?, dob?}] materialises a non-resident stub gerbil for a KNOWN parent that has no own source record (e.g. a mother named only on a Wurfchronik litter), so the litter's parent link resolves. Key match = normalize(call-name) + dob, same identity as dedup. Maintained by god (Michael) as Julian/his wife answer the D-conflicts; originals (xlsx) stay read-only.",
|
||||||
"resolutions": [
|
"resolutions": [
|
||||||
{
|
{
|
||||||
"name": "Firefly von den Kleinen Chaoten",
|
"name": "Firefly von den Kleinen Chaoten",
|
||||||
@@ -368,6 +368,24 @@
|
|||||||
["stammbaum-unbekannt-13112023", "stammbaum-unbekannt-13112022"]
|
["stammbaum-unbekannt-13112023", "stammbaum-unbekannt-13112022"]
|
||||||
],
|
],
|
||||||
"source": "Züchterin 2026-06-22 — Ticket f618dcc3 (doppelter namenloser Bock)"
|
"source": "Züchterin 2026-06-22 — Ticket f618dcc3 (doppelter namenloser Bock)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mamta Mini v.d. Kleinen Chaoten",
|
||||||
|
"dob": "11.11.2023",
|
||||||
|
"decision": "E-Locus = Ee (das unbekannte zweite Allel ist erzwungen 'e', weil Vater Geely von den Kleinen Chaoten am E-Locus reinerbig ee=Fuchs ist und nur 'e' vererben kann). Eltern Geely (Vater) × Gaida (Mutter) verbindlich am Geburtswurf verankert (die chart-position-Heuristik lieferte sie bereits) — als Entscheidung/high gesetzt, damit der Wurf die Eltern sicher verknuepft.",
|
||||||
|
"genotype": "AA CC D- Ee Gg PP spsp",
|
||||||
|
"father": "Geely von den Kleinen Chaoten",
|
||||||
|
"mother": "Gaida von den Kleinen Chaoten",
|
||||||
|
"fatherDob": "04.03.2023",
|
||||||
|
"motherDob": "02.08.2022",
|
||||||
|
"source": "Züchterin 2026-06-22 — Tickets cc9ea3fe / 1a508c04 (Mamta Mini Ee[-]→Ee, Eltern Geely×Gaida)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"externalRef": "unbekannt-13082025-3",
|
||||||
|
"decision": "Sp-Locus = spsp (KEINE Schecke). Das namenlose Weibchen (*13.08.2025, Quelle 'Stammbaum von Martin.xlsx') war im Quell-Stammbaum als Spsp notiert, ist aber ungescheckt — der Sp-Locus muss spsp sein. Der Farbschlag bleibt der genotyp-berechnete Kohlfuchsschimmel (ee[f] = Fuchsschimmel). externalRef pinnt genau dieses Tier (mehrere namenlose Tiere teilen das Datum 13.08.2025).",
|
||||||
|
"genotype": "aa Cc[chm] D- ee[f] Gg Pp spsp",
|
||||||
|
"source": "Züchterin 2026-06-22 — Ticket e09d6f22 (faelschlich Schecke, soll spsp)"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"addAnimals": [
|
"addAnimals": [
|
||||||
|
|||||||
@@ -1099,9 +1099,19 @@ def apply_conflict_decisions(merged, conflicts, path):
|
|||||||
Returns the number of conflicts resolved. (god/HUMANQUESTION D.)"""
|
Returns the number of conflicts resolved. (god/HUMANQUESTION D.)"""
|
||||||
decisions_full = {} # (nameCanon, zuchtCanon, dob) -> r — when decision carries a Zucht
|
decisions_full = {} # (nameCanon, zuchtCanon, dob) -> r — when decision carries a Zucht
|
||||||
decisions_name = {} # (nameCanon, dob) -> r — fallback, decision has no Zucht
|
decisions_name = {} # (nameCanon, dob) -> r — fallback, decision has no Zucht
|
||||||
|
decisions_ref = {} # externalRef (merged-animal id) -> r — for NAMELESS animals whose
|
||||||
|
# (name="" + dob) key is shared by several records: the externalRef
|
||||||
|
# (the dedup slug, e.g. „unbekannt-13082025-3") pins exactly one.
|
||||||
try:
|
try:
|
||||||
with open(path, encoding="utf-8") as fh:
|
with open(path, encoding="utf-8") as fh:
|
||||||
for r in (json.load(fh).get("resolutions") or []):
|
for r in (json.load(fh).get("resolutions") or []):
|
||||||
|
ref = r.get("externalRef")
|
||||||
|
if ref:
|
||||||
|
decisions_ref[ref] = r
|
||||||
|
# An externalRef-only decision (no name) must NOT register a name/dob
|
||||||
|
# key — a ("", "") key would match every nameless, dateless animal.
|
||||||
|
if not r.get("name"):
|
||||||
|
continue
|
||||||
nc, zc = canon_pair(r.get("name", ""))
|
nc, zc = canon_pair(r.get("name", ""))
|
||||||
dob = norm_dob(r.get("dob", ""))
|
dob = norm_dob(r.get("dob", ""))
|
||||||
if zc:
|
if zc:
|
||||||
@@ -1110,14 +1120,17 @@ def apply_conflict_decisions(merged, conflicts, path):
|
|||||||
decisions_name[(nc, dob)] = r
|
decisions_name[(nc, dob)] = r
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return 0
|
return 0
|
||||||
if not decisions_full and not decisions_name:
|
if not decisions_full and not decisions_name and not decisions_ref:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
resolved = 0
|
resolved = 0
|
||||||
for a in merged:
|
for a in merged:
|
||||||
nc, zc = canon_pair(a["name"])
|
nc, zc = canon_pair(a["name"])
|
||||||
dob = norm_dob(a["dob"])
|
dob = norm_dob(a["dob"])
|
||||||
d = decisions_full.get((nc, zc, dob)) or decisions_name.get((nc, dob))
|
# externalRef (the dedup id) wins — it is the most specific key and the only
|
||||||
|
# way to address one of several same-(name,dob) nameless animals.
|
||||||
|
d = decisions_ref.get(a.get("id")) or decisions_full.get((nc, zc, dob)) \
|
||||||
|
or decisions_name.get((nc, dob))
|
||||||
if not d:
|
if not d:
|
||||||
continue
|
continue
|
||||||
a["resolvedByDecision"] = True
|
a["resolvedByDecision"] = True
|
||||||
|
|||||||
@@ -176,3 +176,261 @@ def looks_like_genotype(text):
|
|||||||
if any(p.match(t) for p in _LOCUS_TOKEN.values()):
|
if any(p.match(t) for p in _LOCUS_TOKEN.values()):
|
||||||
n += 1
|
n += 1
|
||||||
return n >= 3
|
return n >= 3
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Genotype → Farbschlag (German variety name)
|
||||||
|
#
|
||||||
|
# A faithful Python port of gerbil-manager-web/src/genetics/catalog.ts
|
||||||
|
# (`genotypeToFarbschlag` + the loci/genotype helpers it relies on). The engine
|
||||||
|
# is the single source of truth for the colour names; the IMPORT mirrors it here
|
||||||
|
# so the stored colorVarietyId can be DERIVED from a known genotype instead of a
|
||||||
|
# fragile free-text colour label (ticket cluster genetics-farbschlag).
|
||||||
|
#
|
||||||
|
# Allele symbols here use the CATALOG form (cchm / ch / ef), so we normalise the
|
||||||
|
# parser's '^'-form ('c^chm' -> 'cchm', 'e^f' -> 'ef') and treat '?' as unknown.
|
||||||
|
# Keep this in lockstep with catalog.ts — when the TS catalog changes, change here
|
||||||
|
# too (the round-trip tests in test_genotype.py guard the mapping).
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Alleles per locus, MOST-DOMINANT FIRST (mirror of loci.ts LOCI).
|
||||||
|
_FARB_LOCI = {
|
||||||
|
"A": ["A", "a"],
|
||||||
|
"C": ["C", "cchm", "ch"],
|
||||||
|
"D": ["D", "d"],
|
||||||
|
"E": ["E", "ef", "e"],
|
||||||
|
"G": ["G", "g"],
|
||||||
|
"P": ["P", "p"],
|
||||||
|
"Sp": ["Sp", "sp"],
|
||||||
|
"Re": ["Re", "re"],
|
||||||
|
}
|
||||||
|
_MARKER_LOCI = ("Sp", "Re", "Sls")
|
||||||
|
|
||||||
|
UNKNOWN_FARBSCHLAG = "Unbekannter Farbschlag"
|
||||||
|
|
||||||
|
# BASE_COLORS — order matters (first match wins). Mirror of catalog.ts BASE_COLORS.
|
||||||
|
# Each entry: (name, {locus: token, ...}); omitted loci are wildcards.
|
||||||
|
_BASE_COLORS = [
|
||||||
|
# ── Frozen names (DB-key contract) ──
|
||||||
|
("REW", {"C": "ch", "P": "p"}),
|
||||||
|
("Hermelin", {"A": "a", "C": "ch", "D": "D", "P": "P"}),
|
||||||
|
("Himalaya", {"A": "A", "C": "ch", "D": "D", "P": "P"}),
|
||||||
|
("Zobel", {"A": "a", "C": "cchm", "D": "D", "E": "E", "G": "g", "P": "P"}),
|
||||||
|
("Rotaugenschimmel", {"C": "C", "D": "D", "E": "ef", "G": "G", "P": "p"}),
|
||||||
|
("Agouti", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "G", "P": "P"}),
|
||||||
|
("Schwarz", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "G", "P": "P"}),
|
||||||
|
("Silberagouti", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "g", "P": "P"}),
|
||||||
|
("Anthrazit", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "g", "P": "P"}),
|
||||||
|
("Algierfuchs", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
|
||||||
|
("Blau", {"A": "a", "C": "C", "D": "d", "E": "E", "G": "G", "P": "P"}),
|
||||||
|
("Gold", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
|
||||||
|
("Platin", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
|
||||||
|
("Goldfuchs", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "G", "P": "p"}),
|
||||||
|
("Rotfuchs", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "p"}),
|
||||||
|
("Dilute Gold", {"A": "A", "C": "C", "D": "d", "E": "E", "G": "G", "P": "p"}),
|
||||||
|
("Dilute Platin", {"A": "a", "C": "C", "D": "d", "E": "E", "G": "G", "P": "p"}),
|
||||||
|
# ── baseportal.de varieties ──
|
||||||
|
("Altweiss (REW)", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "g", "P": "p"}),
|
||||||
|
("Apricot (Blassfuchs)", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "g", "P": "p"}),
|
||||||
|
("Blaufuchs", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "g", "P": "P"}),
|
||||||
|
("C-Separator", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "g", "P": "p"}),
|
||||||
|
("Elfenbein", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "g", "P": "p"}),
|
||||||
|
("Kohlfuchs", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
|
||||||
|
("Polarfuchs", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "g", "P": "P"}),
|
||||||
|
("Saphir", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
|
||||||
|
("Orangeschimmel", {"A": "A", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "P"}),
|
||||||
|
("Topas", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
|
||||||
|
("Platin-Hell", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
|
||||||
|
("Dilute Agouti", {"A": "A", "C": "C", "D": "d", "E": "E", "G": "G", "P": "P"}),
|
||||||
|
("Dilute Silberagouti", {"A": "A", "C": "C", "D": "d", "E": "E", "G": "g", "P": "P"}),
|
||||||
|
("Dilute Kohlfuchs", {"A": "a", "C": "C", "D": "d", "E": "e", "G": "G", "P": "P"}),
|
||||||
|
("Dilute Anthrazit", {"A": "a", "C": "C", "D": "d", "E": "E", "G": "g", "P": "P"}),
|
||||||
|
("Dilute Algierfuchs", {"A": "A", "C": "C", "D": "d", "E": "e", "G": "G", "P": "P"}),
|
||||||
|
("Dilute Goldfuchs", {"A": "A", "C": "C", "D": "d", "E": "e", "G": "G", "P": "p"}),
|
||||||
|
("Dilute Rotfuchs", {"A": "a", "C": "C", "D": "d", "E": "e", "G": "G", "P": "p"}),
|
||||||
|
("Dilute Polarfuchs", {"A": "A", "C": "C", "D": "d", "E": "e", "G": "g", "P": "P"}),
|
||||||
|
("Silberschimmel", {"C": "C", "D": "D", "E": "ef", "G": "g", "P": "P"}),
|
||||||
|
("Polarfuchsschimmel", {"A": "A", "C": "C", "D": "D", "E": "ef", "G": "g", "P": "P"}),
|
||||||
|
("Algierfuchsschimmel", {"A": "A", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "P"}),
|
||||||
|
("Kohlfuchsschimmel", {"A": "a", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "P"}),
|
||||||
|
("Blaufuchsschimmel", {"A": "a", "C": "C", "D": "D", "E": "ef", "G": "g", "P": "P"}),
|
||||||
|
("Kohlfuchs, hell", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
|
||||||
|
("Goldfuchs, hell", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "G", "P": "p"}),
|
||||||
|
("Goldfuchsschimmel", {"A": "A", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "p"}),
|
||||||
|
("Gold-Hell", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
|
||||||
|
("Blaufuchs, hell", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "g", "P": "P"}),
|
||||||
|
("Rotfuchsschimmel", {"A": "a", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "p"}),
|
||||||
|
("Polarfuchs, hell", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "g", "P": "P"}),
|
||||||
|
("Kohlfuchsschimmel, hell", {"A": "a", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "P"}),
|
||||||
|
("Rotfuchs, hell", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "p"}),
|
||||||
|
("Kohlfuchs-Hell", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
|
||||||
|
("Algierfuchs, hell", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
|
||||||
|
("Dilute Topas", {"A": "A", "C": "C", "D": "d", "E": "E", "G": "G", "P": "p"}),
|
||||||
|
("Dilute Blaufuchs", {"A": "a", "C": "C", "D": "d", "E": "e", "G": "g", "P": "P"}),
|
||||||
|
# ── c^chm colourpoint varieties ──
|
||||||
|
("Marder", {"A": "a", "C": "cchm", "D": "D", "E": "E", "G": "G", "P": "P"}),
|
||||||
|
("Siam", {"A": "a", "C": "cchm/ch", "D": "D", "E": "E", "G": "G", "P": "P"}),
|
||||||
|
("Zobel-Hell", {"A": "a", "C": "cchm/ch", "D": "D", "E": "E", "G": "g", "P": "P"}),
|
||||||
|
("CP-Agouti", {"A": "A", "C": "cchm", "D": "D", "E": "E", "G": "G", "P": "P"}),
|
||||||
|
("CP-Agouti-Hell", {"A": "A", "C": "cchm/ch", "D": "D", "E": "E", "G": "G", "P": "P"}),
|
||||||
|
("CP-Silberagouti", {"A": "A", "C": "cchm", "D": "D", "E": "E", "G": "g", "P": "P"}),
|
||||||
|
("CP-Silberagouti-Hell", {"A": "A", "C": "cchm/ch", "D": "D", "E": "E", "G": "g", "P": "P"}),
|
||||||
|
("CP-Algierfuchs", {"A": "A", "C": "cchm", "D": "D", "E": "e", "G": "G", "P": "P"}),
|
||||||
|
("CP-Algierfuchs-Hell", {"A": "A", "C": "cchm/ch", "D": "D", "E": "e", "G": "G", "P": "P"}),
|
||||||
|
("CP-Polarfuchs", {"A": "A", "C": "cchm", "D": "D", "E": "e", "G": "g", "P": "P"}),
|
||||||
|
("CP-Polarfuchs-Hell", {"A": "A", "C": "cchm/ch", "D": "D", "E": "e", "G": "g", "P": "P"}),
|
||||||
|
("CP-Fuchs", {"A": "A", "C": "cchm", "D": "d", "E": "e", "G": "G", "P": "P"}),
|
||||||
|
("CP-Fuchs-Hell", {"A": "A", "C": "cchm/ch", "D": "d", "E": "e", "G": "G", "P": "P"}),
|
||||||
|
("CP-Blaufuchs", {"A": "A", "C": "cchm", "D": "d", "E": "e", "G": "g", "P": "P"}),
|
||||||
|
("CP-Orangeschimmel", {"C": "cchm", "D": "D", "E": "ef", "G": "G", "P": "P"}),
|
||||||
|
("CP-Orangeschimmel-Hell", {"C": "cchm/ch", "D": "D", "E": "ef", "G": "G", "P": "P"}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_allele(a):
|
||||||
|
"""Parser allele form -> catalog form. 'c^chm'->'cchm', 'e^f'->'ef', '-'/None->'?'."""
|
||||||
|
if a is None or a == "-":
|
||||||
|
return "?"
|
||||||
|
return a.replace("^", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_allele_pair(locus, pair):
|
||||||
|
"""GEN-5 unknown-allele rule (mirror of genotype.ts resolveAllelePair).
|
||||||
|
An unknown '?' is a COPY of the known partner; both unknown -> wild-type
|
||||||
|
(markers default to the unmarked recessive)."""
|
||||||
|
a = _normalize_allele(pair[0] if len(pair) > 0 else "?")
|
||||||
|
b = _normalize_allele(pair[1] if len(pair) > 1 else "?")
|
||||||
|
a_unknown = a == "?"
|
||||||
|
b_unknown = b == "?"
|
||||||
|
if not a_unknown and not b_unknown:
|
||||||
|
return [a, b]
|
||||||
|
if a_unknown and b_unknown:
|
||||||
|
alleles = _FARB_LOCI.get(locus, ["?"])
|
||||||
|
fb = alleles[-1] if locus in _MARKER_LOCI else alleles[0]
|
||||||
|
return [fb, fb]
|
||||||
|
known = b if a_unknown else a
|
||||||
|
return [known, known]
|
||||||
|
|
||||||
|
|
||||||
|
def _dominance_rank(locus, allele):
|
||||||
|
alleles = _FARB_LOCI.get(locus, [])
|
||||||
|
return alleles.index(allele) if allele in alleles else len(alleles)
|
||||||
|
|
||||||
|
|
||||||
|
def _dominant_allele(locus, a, b):
|
||||||
|
return a if _dominance_rank(locus, a) <= _dominance_rank(locus, b) else b
|
||||||
|
|
||||||
|
|
||||||
|
def _locus_token(mapped, locus):
|
||||||
|
"""Expressed token at a locus (mirror of catalog.ts locusToken)."""
|
||||||
|
x, y = _resolve_allele_pair(locus, mapped.get(locus, ["?", "?"]))
|
||||||
|
if locus == "E":
|
||||||
|
if x == y:
|
||||||
|
return x
|
||||||
|
if (x == "e" and y == "ef") or (x == "ef" and y == "e"):
|
||||||
|
return "ef"
|
||||||
|
return _dominant_allele("E", x, y)
|
||||||
|
return _dominant_allele(locus, x, y)
|
||||||
|
|
||||||
|
|
||||||
|
def _e_family(mapped):
|
||||||
|
"""E-locus family tag ('Fuchs'/'Fuchsschimmel'/'Schimmel') or None."""
|
||||||
|
x, y = _resolve_allele_pair("E", mapped.get("E", ["?", "?"]))
|
||||||
|
if x == "e" and y == "e":
|
||||||
|
return "Fuchs"
|
||||||
|
if (x == "e" and y == "ef") or (x == "ef" and y == "e"):
|
||||||
|
return "Fuchsschimmel"
|
||||||
|
if x == "ef" and y == "ef":
|
||||||
|
return "Schimmel"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_in_e_family(name, tokens, family):
|
||||||
|
if tokens.get("E") is None:
|
||||||
|
return False
|
||||||
|
n = name.lower()
|
||||||
|
if family == "Fuchsschimmel":
|
||||||
|
return "fuchsschimmel" in n
|
||||||
|
if family == "Schimmel":
|
||||||
|
return "schimmel" in n and "fuchsschimmel" not in n
|
||||||
|
return "schimmel" not in n
|
||||||
|
|
||||||
|
|
||||||
|
def _matches(mapped, tokens):
|
||||||
|
return all(_locus_token(mapped, locus) == tok for locus, tok in tokens.items())
|
||||||
|
|
||||||
|
|
||||||
|
def _base_colour_for(mapped):
|
||||||
|
family = _e_family(mapped)
|
||||||
|
if family:
|
||||||
|
for name, tokens in _BASE_COLORS:
|
||||||
|
if _entry_in_e_family(name, tokens, family) and _matches(mapped, tokens):
|
||||||
|
return name
|
||||||
|
return None
|
||||||
|
for name, tokens in _BASE_COLORS:
|
||||||
|
if _matches(mapped, tokens):
|
||||||
|
return name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _colourpoint_name(mapped):
|
||||||
|
"""C-locus colourpoint NAMING transform (mirror of catalog.ts colourpointName)."""
|
||||||
|
c = _resolve_allele_pair("C", mapped.get("C", ["?", "?"]))
|
||||||
|
if "C" in c:
|
||||||
|
return None
|
||||||
|
if c[0] == "ch" and c[1] == "ch":
|
||||||
|
return None
|
||||||
|
both_cchm = c[0] == "cchm" and c[1] == "cchm"
|
||||||
|
agouti = "A" in _resolve_allele_pair("A", mapped.get("A", ["?", "?"]))
|
||||||
|
if not agouti and _e_family(mapped) is None:
|
||||||
|
g1, g2 = _resolve_allele_pair("G", mapped.get("G", ["?", "?"]))
|
||||||
|
grey = g1 == "g" and g2 == "g"
|
||||||
|
if grey:
|
||||||
|
return "Zobel" if both_cchm else "Zobel-Hell"
|
||||||
|
return "Marder" if both_cchm else "Siam"
|
||||||
|
# Name the colour as if C were full, then prefix 'CP-'.
|
||||||
|
forced = dict(mapped)
|
||||||
|
forced["C"] = ["C", "C"]
|
||||||
|
base = _base_colour_for(forced)
|
||||||
|
if not base:
|
||||||
|
return None
|
||||||
|
DILUTE = "Dilute "
|
||||||
|
if base.startswith(DILUTE):
|
||||||
|
return f"{DILUTE}CP-{base[len(DILUTE):]}{'' if both_cchm else '-Hell'}"
|
||||||
|
return f"CP-{base}{'' if both_cchm else '-Hell'}"
|
||||||
|
|
||||||
|
|
||||||
|
_CATEGORY_NAMES = {
|
||||||
|
"Standard", "Colourpoint", "Dilute",
|
||||||
|
"Fuchs", "Fuchsschimmel", "Schimmel",
|
||||||
|
"Colourpoint Dilute",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def genotype_to_farbschlag(mapped):
|
||||||
|
"""Resolve a parsed `mapped8locus` dict to its German Farbschlag (BASE name,
|
||||||
|
WITHOUT Schecke/Rex modifiers), or `UNKNOWN_FARBSCHLAG`.
|
||||||
|
|
||||||
|
`mapped` is genotype.parse(...)['mapped8locus'] (allele '^'-form / '-' / missing
|
||||||
|
loci tolerated). Modifiers (Schecke/Rex) are intentionally OMITTED — the import
|
||||||
|
stores them as the genotype's Sp/Re loci and via the colour label, not in the
|
||||||
|
catalog colorVarietyId. Mirror of catalog.ts genotypeToFarbschlag (minus the
|
||||||
|
appended modifiers).
|
||||||
|
"""
|
||||||
|
if not mapped:
|
||||||
|
return UNKNOWN_FARBSCHLAG
|
||||||
|
# REW check: both C reduced (no full 'C') AND pink-eyed (pp).
|
||||||
|
c0, c1 = _resolve_allele_pair("C", mapped.get("C", ["?", "?"]))
|
||||||
|
p0, p1 = _resolve_allele_pair("P", mapped.get("P", ["?", "?"]))
|
||||||
|
c_reduced = lambda c: c in ("cchm", "ch")
|
||||||
|
if c_reduced(c0) and c_reduced(c1) and p0 == "p" and p1 == "p":
|
||||||
|
return "REW"
|
||||||
|
base_name = _colourpoint_name(mapped) or _base_colour_for(mapped)
|
||||||
|
if not base_name or base_name in _CATEGORY_NAMES:
|
||||||
|
return UNKNOWN_FARBSCHLAG
|
||||||
|
return base_name
|
||||||
|
|
||||||
|
|
||||||
|
def farbschlag_from_genotype_string(raw):
|
||||||
|
"""Convenience: raw genotype string -> Farbschlag base name (or UNKNOWN)."""
|
||||||
|
return genotype_to_farbschlag(parse(raw).get("mapped8locus") or {})
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import uuid
|
|||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
import genotype as gt
|
||||||
|
|
||||||
# Prevent encoding crashes on Windows consoles when printing unicode
|
# Prevent encoding crashes on Windows consoles when printing unicode
|
||||||
if sys.platform.startswith('win'):
|
if sys.platform.startswith('win'):
|
||||||
try:
|
try:
|
||||||
@@ -422,33 +424,39 @@ def get_dedup_name_key(name):
|
|||||||
return "".join(c for c in n if c.isalnum())
|
return "".join(c for c in n if c.isalnum())
|
||||||
|
|
||||||
def clean_color_name(c_desc):
|
def clean_color_name(c_desc):
|
||||||
|
"""Normalise a free-text colour label to a catalog key + Schecke flag.
|
||||||
|
|
||||||
|
Returns (clean_name, is_schecke). A PARENTHETICAL „(schimmel)" is NOT a
|
||||||
|
definitive Schimmel — the breeder writes it to mean „könnte sich später als
|
||||||
|
Schimmel entpuppen" (ticket e22764aa). So we STRIP the „(…)" instead of
|
||||||
|
folding it into the name (which used to turn „Blaufuchs(schimmel)" into the
|
||||||
|
wrong „blaufuchsschimmel"); the still-uncertain Schimmel-modifier is carried
|
||||||
|
by the genotype (ee[-] = Fuchs, Schimmel unknown), not the colour label.
|
||||||
|
"""
|
||||||
if not c_desc:
|
if not c_desc:
|
||||||
return "", False
|
return "", False
|
||||||
|
|
||||||
# Lowercase and strip
|
# Lowercase and strip
|
||||||
c = c_desc.lower().strip()
|
c = c_desc.lower().strip()
|
||||||
|
|
||||||
# Check for Schecke
|
# Check for Schecke
|
||||||
is_schecke = False
|
is_schecke = False
|
||||||
if re.search(r'\bsp\b|\bsp\d|\bsp[*(²³]|\bspotted|\bschecke|[- ]sp\b|\w+sp\b', c):
|
if re.search(r'\bsp\b|\bsp\d|\bsp[*(²³]|\bspotted|\bschecke|[- ]sp\b|\w+sp\b', c):
|
||||||
is_schecke = True
|
is_schecke = True
|
||||||
|
|
||||||
# Standardize parentheticals for schimmel
|
|
||||||
c = c.replace("(schimmel)", "schimmel")
|
|
||||||
c = c.replace("(schimmel-hell)", "schimmel hell")
|
|
||||||
c = c.replace("(schimmel hell)", "schimmel hell")
|
|
||||||
|
|
||||||
# Strip schecke/sp markers and any trailing text starting from sp
|
# Strip schecke/sp markers and any trailing text starting from sp
|
||||||
c = re.sub(r'\([- ]?sp(otted)?\)', '', c) # handles (-sp)
|
c = re.sub(r'\([- ]?sp(otted)?\)', '', c) # handles (-sp)
|
||||||
c = re.sub(r'[- ]?sp(otted)?\b.*', '', c) # handles -sp(k), -sp*(k), -sp, etc.
|
c = re.sub(r'[- ]?sp(otted)?\b.*', '', c) # handles -sp(k), -sp*(k), -sp, etc.
|
||||||
c = re.sub(r'[- ]?schecke\b.*', '', c)
|
c = re.sub(r'[- ]?schecke\b.*', '', c)
|
||||||
c = re.sub(r'[- ]?spotted\b.*', '', c)
|
c = re.sub(r'[- ]?spotted\b.*', '', c)
|
||||||
|
|
||||||
# Strip any other parentheticals, symbols, or trailing stars/numbers
|
# Strip any other parentheticals (incl. „(schimmel)" = „möglich/unbestimmt"),
|
||||||
|
# symbols, or trailing stars/numbers. The parenthetical is deliberately NOT
|
||||||
|
# promoted to a definitive part of the colour name (ticket e22764aa).
|
||||||
c = re.sub(r'\s*\(.*?\)\s*', ' ', c)
|
c = re.sub(r'\s*\(.*?\)\s*', ' ', c)
|
||||||
c = re.sub(r'[²³*]', '', c)
|
c = re.sub(r'[²³*]', '', c)
|
||||||
c = c.strip()
|
c = c.strip()
|
||||||
|
|
||||||
# Mapping table for abbreviations, typos, and specific combinations
|
# Mapping table for abbreviations, typos, and specific combinations
|
||||||
mapping = {
|
mapping = {
|
||||||
"antra": "anthrazit",
|
"antra": "anthrazit",
|
||||||
@@ -476,27 +484,87 @@ def clean_color_name(c_desc):
|
|||||||
|
|
||||||
return c, is_schecke
|
return c, is_schecke
|
||||||
|
|
||||||
def resolve_color_and_genotype(color_val, existing_genotype, variety_map, variety_genotypes):
|
def _match_color_label(clean_name, variety_map):
|
||||||
if not color_val:
|
"""Map a cleaned colour label to a ColorVariety id (text-only path).
|
||||||
return None, existing_genotype
|
|
||||||
color_str = str(color_val).strip()
|
Exact name wins; otherwise pick the LONGEST/most-specific substring match
|
||||||
clean_name, is_schecke = clean_color_name(color_str)
|
(ticket 3f5942a2 — the old code broke on the FIRST substring hit, so „Goldfuchs"
|
||||||
# Match color in variety_map
|
matched the shorter „Gold" first). Among substring candidates the longest seed
|
||||||
color_variety_id = None
|
name wins, then the longest clean_name overlap; ties broken deterministically.
|
||||||
|
"""
|
||||||
|
if not clean_name:
|
||||||
|
return None
|
||||||
if clean_name in variety_map:
|
if clean_name in variety_map:
|
||||||
color_variety_id = variety_map[clean_name]
|
return variety_map[clean_name]
|
||||||
else:
|
candidates = []
|
||||||
for seed_name, seed_id in variety_map.items():
|
for seed_name, seed_id in variety_map.items():
|
||||||
if seed_name in clean_name or clean_name in seed_name:
|
if not seed_name:
|
||||||
color_variety_id = seed_id
|
continue
|
||||||
break
|
if seed_name in clean_name or clean_name in seed_name:
|
||||||
# Update genotype if it's a Schecke
|
# Specificity score: prefer the longer seed name (more specific),
|
||||||
|
# then the closeness of lengths so „goldfuchs" beats „gold" for the
|
||||||
|
# label „goldfuchs".
|
||||||
|
candidates.append((len(seed_name), -abs(len(seed_name) - len(clean_name)),
|
||||||
|
seed_name, seed_id))
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
candidates.sort(reverse=True)
|
||||||
|
return candidates[0][3]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_color_and_genotype(color_val, existing_genotype, variety_map, variety_genotypes):
|
||||||
|
"""Resolve a gerbil's stored ColorVariety id + genotype.
|
||||||
|
|
||||||
|
GENOTYPE WINS (ticket cluster genetics-farbschlag): when a parseable genotype
|
||||||
|
is present and the genetics engine (genotype.genotype_to_farbschlag — a faithful
|
||||||
|
Python mirror of catalog.ts) computes a KNOWN catalog variety, that variety is
|
||||||
|
authoritative for colorVarietyId. The free-text colour label is only a fallback
|
||||||
|
(no genotype, or genotype resolves to „Unbekannt"). This fixes the imports where
|
||||||
|
the source label ignored a locus (dd → „Agouti" instead of „Dilute Agouti",
|
||||||
|
ee → „Gold" instead of „Goldfuchs", parenthetical „(schimmel)", …).
|
||||||
|
|
||||||
|
Returns (color_variety_id, genotype). `genotype` is the (possibly Schecke-
|
||||||
|
annotated) genotype STRING — never silently flips an explicit spsp to Spsp.
|
||||||
|
"""
|
||||||
|
if not color_val and not existing_genotype:
|
||||||
|
return None, existing_genotype
|
||||||
|
|
||||||
|
clean_name, is_schecke = clean_color_name(str(color_val).strip()) if color_val else ("", False)
|
||||||
|
|
||||||
|
# 1) Genotype-derived variety (authoritative when it resolves to a known name).
|
||||||
|
# GUARD (VORSICHTIG): only trust the genotype when it parsed CLEANLY enough to
|
||||||
|
# decide a colour — both the C and E loci must be mapped. The breeder sometimes
|
||||||
|
# writes the genotype in the COMPACT catalog notation („cchmcchm", „efef",
|
||||||
|
# „chch") which this parser leaves UNMAPPED (it expects the bracketed „c[chm]"
|
||||||
|
# form); a dropped C/E locus would silently read as wild-type and mis-recolour
|
||||||
|
# an otherwise-correct animal (e.g. Marder→Schwarz, Orangeschimmel→Agouti). When
|
||||||
|
# the parse is incomplete we keep the source text label instead.
|
||||||
|
color_variety_id = None
|
||||||
|
geno_name = None
|
||||||
|
if existing_genotype:
|
||||||
|
try:
|
||||||
|
mapped = gt.parse(existing_genotype).get("mapped8locus") or {}
|
||||||
|
except Exception:
|
||||||
|
mapped = {}
|
||||||
|
if mapped.get("C") and mapped.get("E"):
|
||||||
|
fs = gt.genotype_to_farbschlag(mapped)
|
||||||
|
if fs and fs != gt.UNKNOWN_FARBSCHLAG:
|
||||||
|
geno_name = fs
|
||||||
|
color_variety_id = variety_map.get(fs.strip().lower())
|
||||||
|
|
||||||
|
# 2) Fall back to the text label when the genotype gave nothing usable.
|
||||||
|
if not color_variety_id:
|
||||||
|
color_variety_id = _match_color_label(clean_name, variety_map)
|
||||||
|
|
||||||
|
# Update genotype if the LABEL says Schecke — but never override an explicit
|
||||||
|
# Sp-locus already present in the source genotype (ticket e09d6f22: a source
|
||||||
|
# „spsp" must NOT be flipped to „Spsp" just because the label looked scheckig;
|
||||||
|
# the source genotype is authoritative for the Sp-locus). Only ADD Spsp when
|
||||||
|
# the genotype carries no Sp token at all.
|
||||||
genotype = existing_genotype
|
genotype = existing_genotype
|
||||||
if is_schecke:
|
if is_schecke:
|
||||||
if genotype:
|
if genotype:
|
||||||
if "spsp" in genotype:
|
if "Sp" not in genotype and "sp" not in genotype:
|
||||||
genotype = genotype.replace("spsp", "Spsp")
|
|
||||||
elif "Spsp" not in genotype and "Sp" not in genotype:
|
|
||||||
genotype = f"{genotype} Spsp".strip()
|
genotype = f"{genotype} Spsp".strip()
|
||||||
else:
|
else:
|
||||||
canonical = variety_genotypes.get(color_variety_id)
|
canonical = variety_genotypes.get(color_variety_id)
|
||||||
|
|||||||
@@ -91,6 +91,42 @@ check("Algierfuchs genotype: no unmapped tokens", r["unmappedTokens"] == [])
|
|||||||
check("looks_like_genotype sees Uw as G",
|
check("looks_like_genotype sees Uw as G",
|
||||||
g.looks_like_genotype("aa Cc Uwuw") is True)
|
g.looks_like_genotype("aa Cc Uwuw") is True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── genotype_to_farbschlag — Python mirror of catalog.ts genotypeToFarbschlag ──
|
||||||
|
# Genetik/Farbschlag ist korrektheitskritisch: jeder Ticket-Fall bekommt einen
|
||||||
|
# Regressionstest (Konvention). The base name is returned WITHOUT the Schecke/Rex
|
||||||
|
# modifier (the import carries those via the Sp/Re loci, not the variety id).
|
||||||
|
def fs(s):
|
||||||
|
return g.genotype_to_farbschlag(g.parse(s)["mapped8locus"])
|
||||||
|
|
||||||
|
# Ticket 3f5942a2 — Goldfuchs (ee) NOT Gold (EE): a fox genotype must resolve to
|
||||||
|
# a Fuchs variety, never the substring-shorter „Gold".
|
||||||
|
check("3f5942a2: ee Fuchs -> Goldfuchs (not Gold)", fs("AA CC DD ee GG pp spsp") == "Goldfuchs")
|
||||||
|
check("3f5942a2: ee[f] Fuchsschimmel -> Goldfuchsschimmel",
|
||||||
|
fs("Aa C- D- ee[f] G- pp Spsp") == "Goldfuchsschimmel")
|
||||||
|
# Ticket 1aac054f — namenloses Weibchen *13.08.2025: Kohlfuchsschimmel (not Gold).
|
||||||
|
check("1aac054f: aa ee[f] -> Kohlfuchsschimmel",
|
||||||
|
fs("aa Cc[chm] D- ee[f] Gg Pp Spsp") == "Kohlfuchsschimmel")
|
||||||
|
# Ticket 998087e2 — dd must NOT be ignored: Dilute Agouti (not Agouti).
|
||||||
|
check("998087e2: AA dd EE -> Dilute Agouti", fs("AA CC dd EE GG PP spsp") == "Dilute Agouti")
|
||||||
|
check("998087e2 counter: AA DD EE -> Agouti (no dilute)", fs("AA CC DD EE GG PP spsp") == "Agouti")
|
||||||
|
# Ticket 06217eb3 — dd Anthrazit: Dilute Anthrazit (not Anthrazit).
|
||||||
|
check("06217eb3: aa dd gg -> Dilute Anthrazit", fs("aa CC dd Ee gg P- spsp") == "Dilute Anthrazit")
|
||||||
|
# Ticket e22764aa — ee[-] = Fuchs (Schimmel-Modifier unbekannt) -> Blaufuchs,
|
||||||
|
# NEVER Blaufuchsschimmel (the „(schimmel)" parenthetical is „möglich", not definitiv).
|
||||||
|
check("e22764aa: aa ee[-] gg -> Blaufuchs (not …schimmel)",
|
||||||
|
fs("aa C- D- ee[-] gg P- spsp") == "Blaufuchs")
|
||||||
|
# Ticket cc9ea3fe / 1a508c04 — Mamta Mini Ee resolves to Agouti (AA, E_).
|
||||||
|
check("Mamta Mini: AA Ee -> Agouti", fs("AA CC D- Ee Gg PP spsp") == "Agouti")
|
||||||
|
# E-locus phenotype rules (breeder): ef/ef = Schimmel family, ef/e = Fuchsschimmel.
|
||||||
|
check("efef agouti base -> Orangeschimmel", fs("AA CC DD e[f]e[f] GG PP spsp") == "Orangeschimmel")
|
||||||
|
check("ef/e het -> Fuchsschimmel family (Kohlfuchsschimmel)",
|
||||||
|
fs("aa CC DD ee[f] GG PP spsp") == "Kohlfuchsschimmel")
|
||||||
|
# Unknown allele = copy of the visible partner (GEN-5): A? -> AA, D? -> DD.
|
||||||
|
check("unknown copies known: AA C? DD ee GG pp -> Goldfuchs", fs("AA C- DD ee GG pp spsp") == "Goldfuchs")
|
||||||
|
# An incomplete parse must NOT throw and must not be invented as a real colour.
|
||||||
|
check("empty mapping -> Unbekannt", g.genotype_to_farbschlag({}) == g.UNKNOWN_FARBSCHLAG)
|
||||||
|
|
||||||
if check.failed:
|
if check.failed:
|
||||||
print(f"\n{check.failed} test(s) FAILED")
|
print(f"\n{check.failed} test(s) FAILED")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -432,6 +432,61 @@ check("contracts: animal-less record has empty Animals list",
|
|||||||
_sale3 and _sale3[0]["Animals"] == [])
|
_sale3 and _sale3[0]["Animals"] == [])
|
||||||
|
|
||||||
|
|
||||||
|
# ── resolve_color_and_genotype + clean_color_name (genetics-farbschlag cluster) ──
|
||||||
|
# A tiny synthetic variety_map (name->id) with the keys these cases need.
|
||||||
|
_VM = {
|
||||||
|
"gold": "ID-gold", "goldfuchs": "ID-goldfuchs", "goldfuchsschimmel": "ID-gfs",
|
||||||
|
"agouti": "ID-agouti", "dilute agouti": "ID-dagouti",
|
||||||
|
"anthrazit": "ID-anthrazit", "dilute anthrazit": "ID-danthrazit",
|
||||||
|
"blaufuchs": "ID-blaufuchs", "blaufuchsschimmel": "ID-bfs",
|
||||||
|
"kohlfuchsschimmel": "ID-kfs", "marder": "ID-marder", "schwarz": "ID-schwarz",
|
||||||
|
"orangeschimmel": "ID-orange",
|
||||||
|
}
|
||||||
|
_VG = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _rc(color, geno):
|
||||||
|
return m.resolve_color_and_genotype(color, geno, _VM, _VG)[0]
|
||||||
|
|
||||||
|
|
||||||
|
# Ticket 3f5942a2 — specificity: „Goldfuchs"-label must NOT collapse to „Gold".
|
||||||
|
check("3f5942a2 label: 'Goldfuchs' -> goldfuchs (not gold)",
|
||||||
|
m._match_color_label("goldfuchs", _VM) == "ID-goldfuchs")
|
||||||
|
# Genotype wins: ee fox genotype overrides a stale „Gold" label.
|
||||||
|
check("3f5942a2 genotype wins: ee -> Goldfuchs over 'Gold' label",
|
||||||
|
_rc("Gold", "AA CC DD ee GG pp spsp") == "ID-goldfuchs")
|
||||||
|
# Ticket 998087e2 — dd ignored by label: genotype gives Dilute Agouti.
|
||||||
|
check("998087e2: dd genotype -> Dilute Agouti over 'Agouti' label",
|
||||||
|
_rc("Agouti", "AA CC dd EE GG PP spsp") == "ID-dagouti")
|
||||||
|
# Ticket 06217eb3 — Dilute Anthrazit.
|
||||||
|
check("06217eb3: dd genotype -> Dilute Anthrazit over 'Anthrazit'",
|
||||||
|
_rc("Anthrazit", "aa CC dd Ee gg P- spsp") == "ID-danthrazit")
|
||||||
|
# Ticket 1aac054f — Kohlfuchsschimmel over a stale 'Gold' label.
|
||||||
|
check("1aac054f: ee[f] genotype -> Kohlfuchsschimmel over 'Gold'",
|
||||||
|
_rc("Gold", "aa Cc[chm] D- ee[f] Gg Pp Spsp") == "ID-kfs")
|
||||||
|
# Ticket e22764aa — „Blaufuchs(schimmel)" parenthetical is NOT definitive; the
|
||||||
|
# cleaned label is „blaufuchs" and the ee[-] genotype confirms Blaufuchs.
|
||||||
|
_cn, _sc = m.clean_color_name("Blaufuchs(schimmel)")
|
||||||
|
check("e22764aa: '(schimmel)' stripped, not promoted -> 'blaufuchs'", _cn == "blaufuchs")
|
||||||
|
check("e22764aa: ee[-] genotype -> Blaufuchs (not Blaufuchsschimmel)",
|
||||||
|
_rc("Blaufuchs(schimmel)", "aa C- D- ee[-] gg P- spsp") == "ID-blaufuchs")
|
||||||
|
# Ticket e09d6f22 — a Schecke-looking LABEL must not flip an explicit source spsp
|
||||||
|
# to Spsp (the source genotype is authoritative for the Sp-locus).
|
||||||
|
_, _g_spsp = m.resolve_color_and_genotype("Kohlfuchsschimmel, hell",
|
||||||
|
"aa Cc[chm] D- ee[f] Gg Pp spsp", _VM, _VG)
|
||||||
|
check("e09d6f22: explicit spsp kept (label-Schecke does not force Spsp)",
|
||||||
|
"Spsp" not in _g_spsp and "spsp" in _g_spsp)
|
||||||
|
# VORSICHTIG guard: a COMPACT-notation genotype (cchmcchm/efef) the parser can't
|
||||||
|
# read must fall back to the text label, NOT mis-recolour (e.g. Marder->Schwarz).
|
||||||
|
check("guard: compact 'cchmcchm' unparsable -> keep label 'Marder'",
|
||||||
|
_rc("Marder", "aa cchmcchm DD EE GG PP spsp rere") == "ID-marder")
|
||||||
|
check("guard: compact 'efef' unparsable -> keep label 'Orangeschimmel'",
|
||||||
|
_rc("Orangeschimmel", "AA CC DD efef GG PP spsp rere") == "ID-orange")
|
||||||
|
# A genuinely Schecke label with no Sp in the genotype still appends Spsp.
|
||||||
|
_, _g_add = m.resolve_color_and_genotype("Agouti Schecke", "AA CC DD EE GG PP", _VM, _VG)
|
||||||
|
check("schecke label + no Sp token -> appends Spsp", "Spsp" in _g_add)
|
||||||
|
|
||||||
|
|
||||||
# ── Integration: assert the resolved_import.json output reflects the ticket fixes ──
|
# ── Integration: assert the resolved_import.json output reflects the ticket fixes ──
|
||||||
# (Only when the pipeline has already been run; tolerant if the file is absent.)
|
# (Only when the pipeline has already been run; tolerant if the file is absent.)
|
||||||
import os as _os, json as _json
|
import os as _os, json as _json
|
||||||
@@ -534,6 +589,59 @@ if _os.path.exists(_resolved):
|
|||||||
and "unbekannt" in (g.get("ExternalRef") or "").lower()]
|
and "unbekannt" in (g.get("ExternalRef") or "").lower()]
|
||||||
check("Duplicate-merge: nameless buck *15.02.2024 deduped to one record",
|
check("Duplicate-merge: nameless buck *15.02.2024 deduped to one record",
|
||||||
len(_bucks) == 1)
|
len(_bucks) == 1)
|
||||||
|
|
||||||
|
# ── genetics-farbschlag cluster: the STORED colorVarietyId is now genotype-
|
||||||
|
# correct for the ticket animals. Build the id→name map from the authoritative
|
||||||
|
# ApplicationContext.cs catalog (same source the pipeline uses for the ids).
|
||||||
|
import re as _re
|
||||||
|
_app = _os.path.abspath(_os.path.join(_os.path.dirname(__file__),
|
||||||
|
"../../GerbilManagerWebAPI/ApplicationContext.cs"))
|
||||||
|
_idname = {}
|
||||||
|
if _os.path.exists(_app):
|
||||||
|
_cm = _re.search(r"catalog\s*=\s*\{(.*?)\};", open(_app, encoding="utf-8").read(), _re.DOTALL)
|
||||||
|
if _cm:
|
||||||
|
for _i, (_n, _g, _so) in enumerate(_re.findall(
|
||||||
|
r'\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*(\d+)\s*\)', _cm.group(1))):
|
||||||
|
_idname[f"00000000-0000-0000-0000-{_i + 1:012d}"] = _n
|
||||||
|
|
||||||
|
def _by_ref(ref):
|
||||||
|
return next((g for g in _d["gerbils"] if g.get("ExternalRef") == ref), None)
|
||||||
|
|
||||||
|
def _cv_name(g):
|
||||||
|
return _idname.get(g.get("ColorVarietyId")) if g else None
|
||||||
|
|
||||||
|
if _idname:
|
||||||
|
# Ticket 1aac054f — namenloses Weibchen *13.08.2025 -> Kohlfuchsschimmel.
|
||||||
|
_t1 = _by_ref("stammbaum-unbekannt-13082025-2")
|
||||||
|
check("1aac054f: nameless *13.08.2025 stored as Kohlfuchsschimmel",
|
||||||
|
_cv_name(_t1) == "Kohlfuchsschimmel")
|
||||||
|
# Ticket e09d6f22 — same litter, *-3: spsp (NOT Schecke) + Kohlfuchsschimmel.
|
||||||
|
_t2 = _by_ref("stammbaum-unbekannt-13082025-3")
|
||||||
|
check("e09d6f22: Sp-locus is spsp (no Schecke)",
|
||||||
|
_t2 is not None and "Spsp" not in (_t2.get("Genotype") or "")
|
||||||
|
and "spsp" in (_t2.get("Genotype") or ""))
|
||||||
|
check("e09d6f22: stored as Kohlfuchsschimmel", _cv_name(_t2) == "Kohlfuchsschimmel")
|
||||||
|
# Ticket 06217eb3 — Dilute Anthrazit (dd).
|
||||||
|
_t3 = _by_ref("stammbaum-unbekannt-27052025")
|
||||||
|
check("06217eb3: nameless dd-Weibchen stored as Dilute Anthrazit",
|
||||||
|
_cv_name(_t3) == "Dilute Anthrazit")
|
||||||
|
# Ticket e22764aa — Blaufuchs (NOT Blaufuchsschimmel).
|
||||||
|
_t4 = _by_ref("stammbaum-unbekannt-16012026")
|
||||||
|
check("e22764aa: '(schimmel)' animal stored as Blaufuchs",
|
||||||
|
_cv_name(_t4) == "Blaufuchs")
|
||||||
|
# Ticket 3f5942a2 — named fox animals are Goldfuchs (ee), not Gold (EE).
|
||||||
|
_banjo = _find("Banjo of Fiomi")
|
||||||
|
check("3f5942a2: Banjo of Fiomi stored as Goldfuchs",
|
||||||
|
_cv_name(_banjo) == "Goldfuchs")
|
||||||
|
|
||||||
|
# ── Mamta Mini (cc9ea3fe / 1a508c04): Ee[-] resolved to Ee + parents linked. ──
|
||||||
|
_mamta = _find("Mamta Mini")
|
||||||
|
check("Mamta Mini: E-locus resolved to Ee (no unknown [-])",
|
||||||
|
_mamta is not None and "Ee[-]" not in (_mamta.get("Genotype") or "")
|
||||||
|
and "Ee" in (_mamta.get("Genotype") or ""))
|
||||||
|
_mf, _mm = _parents(_mamta)
|
||||||
|
check("Mamta Mini: father Geely, mother Gaida linked at the litter",
|
||||||
|
(_mf or "").startswith("Geely") and (_mm or "").startswith("Gaida"))
|
||||||
else:
|
else:
|
||||||
print("note: output/resolved_import.json not present — skipped integration assertions")
|
print("note: output/resolved_import.json not present — skipped integration assertions")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user