diff --git a/gerbil-manager-web/src/genetics/genotype.ts b/gerbil-manager-web/src/genetics/genotype.ts index 0138cc0..3702b9f 100644 --- a/gerbil-manager-web/src/genetics/genotype.ts +++ b/gerbil-manager-web/src/genetics/genotype.ts @@ -64,18 +64,28 @@ export function makeGenotype(input: Record): Genotype { export function wildType(): Genotype { const out = {} as Record for (const locus of LOCUS_ORDER) { - // Wild-type is homozygous for the most dominant allele, EXCEPT the - // marker loci Sp/Re whose wild form is the recessive (unmarked) allele. + // Wild-type is homozygous for the most dominant allele, EXCEPT the marker + // loci Sp/Re/Sls whose wild form is the recessive (unmarked) allele. const alleles = LOCI[locus].alleles - const a = locus === 'Sp' || locus === 'Re' ? alleles[alleles.length - 1] : alleles[0] + const marker = locus === 'Sp' || locus === 'Re' || locus === 'Sls' + const a = marker ? alleles[alleles.length - 1] : alleles[0] out[locus] = [a, a] } return out } -/** Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere". */ +/** + * Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere". + * The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and + * the colour catalog stay byte-identical; it only appears for WP/Sls carriers + * (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl. + */ export function toDisplayString(g: Genotype): string { - return LOCUS_ORDER.map((locus) => g[locus][0] + g[locus][1]).join(' ') + return LOCUS_ORDER.filter( + (locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'), + ) + .map((locus) => g[locus][0] + g[locus][1]) + .join(' ') } /** Stable JSON-storable object (already the in-memory shape; returned as a copy). */ @@ -114,14 +124,72 @@ function splitToken(token: string): [string, string] { } /** - * Parse a display string ("Aa CC Dd EE GG Pp Spsp rere") back into a Genotype. - * Tokens may be given in any order; each token must belong to a distinct locus. + * GEN-3a: tokens that are NOT genotype loci — health/provenance metadata that may + * appear in a herd-book genotype string. Stripped on parse (see extractGenotypeFlags). + * - dea/Dea/taub = deafness flag (after spsp); DP/DarkPatch = non-Mendelian patch flag + * - WFNZ/RV/GV = provenance/breeding-method annotations + */ +const FLAG_TOKENS = new Set(['DP', 'DarkPatch', 'dea', 'Dea', 'taub', 'WFNZ', 'RV', 'GV']) + +/** + * Normalize one whitespace-token to canonical allele symbols, or null if it is a + * non-genotype flag/metadata token (to be stripped): + * - Uw/uw -> G/g (international Underwhite == German Grey locus) + * - S(l)/s(l) -> Sl/sl (second spotting locus notation) + * - WP -> Slsl (WP is the visible S(l)s(l) heterozygote) + */ +function normalizeToken(tok: string): string | null { + if (FLAG_TOKENS.has(tok)) return null + let t = tok + if (t === 'WP') t = 'Slsl' + t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl') + t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g') + return t +} + +/** + * Canonical normalized genotype string (flags/metadata removed, Uw/S(l)/WP + * resolved). Exported so the import pipeline (GEN-3b) can mirror this exactly. + */ +export function normalizeGenotypeString(input: string): string { + return input + .trim() + .split(/\s+/) + .filter(Boolean) + .map(normalizeToken) + .filter((t): t is string => t !== null) + .join(' ') +} + +/** + * Extract non-Punkett flags from a raw genotype string: deafness (dea/taub = + * deaf, Dea = hearing) and provenance/pattern tags (WFNZ/RV/GV/DP). + */ +export function extractGenotypeFlags(input: string): { deaf?: boolean; tags: string[] } { + const tokens = input.trim().split(/\s+/).filter(Boolean) + let deaf: boolean | undefined + const tags: string[] = [] + for (const tok of tokens) { + if (tok === 'dea' || tok === 'taub') deaf = true + else if (tok === 'Dea') deaf = false + else if (tok === 'DP' || tok === 'DarkPatch' || tok === 'WFNZ' || tok === 'RV' || tok === 'GV') + tags.push(tok) + } + return { deaf, tags } +} + +/** + * Parse a display string ("Aa CC Dd EE GG Pp Spsp rere [Slsl]") back into a + * Genotype. Tokens may be in any order; each must belong to a distinct locus. + * Uw/S(l)/WP are normalized and flag/metadata tokens (dea, WFNZ, …) are stripped. * Missing loci default to wild-type. */ export function fromDisplayString(input: string): Genotype { const tokens = input.trim().split(/\s+/).filter(Boolean) const acc = {} as Record - for (const token of tokens) { + for (const raw of tokens) { + const token = normalizeToken(raw) + if (token === null) continue // flag/metadata token — not a locus const [a, b] = splitToken(token) const refAllele = a === WILDCARD ? b : a if (refAllele === WILDCARD) { diff --git a/gerbil-manager-web/src/genetics/lethality.ts b/gerbil-manager-web/src/genetics/lethality.ts index 993698d..647164f 100644 --- a/gerbil-manager-web/src/genetics/lethality.ts +++ b/gerbil-manager-web/src/genetics/lethality.ts @@ -25,6 +25,7 @@ interface LethalRule { const LETHAL_RULES: readonly LethalRule[] = [ { locus: 'Sp', allele: 'Sp', kind: 'lethal', warning: GeneticsWarningCode.ScheckeLethal }, + { locus: 'Sls', allele: 'Sl', kind: 'lethal', warning: GeneticsWarningCode.SlsLethal }, { locus: 'Re', allele: 'Re', kind: 'semi', warning: GeneticsWarningCode.RexSemiLethal }, ] @@ -67,14 +68,23 @@ export function applyLethality(dist: DistEntry[]): LethalityResult { ? survivors : survivors.map((e) => ({ value: e.value, probability: divide(e.probability, survivingMass) })) - if (lethalMass.num > 0) { - warnings.push({ - code: GeneticsWarningCode.ScheckeLethal, - detail: { - youngLostFraction: toString(lethalMass), - youngLostPercent: Number(((lethalMass.num / lethalMass.den) * 100).toFixed(2)), - }, - }) + // One lethal warning PER lethal rule that actually removed young (so SpSp -> + // ScheckeLethal and S(l)S(l) -> SlsLethal are reported distinctly). + for (const rule of LETHAL_RULES) { + if (rule.kind !== 'lethal') continue + const mass = dist.reduce( + (acc, e) => (isHomozygous(e.value, rule.locus, rule.allele) ? add(acc, e.probability) : acc), + ZERO, + ) + if (mass.num > 0) { + warnings.push({ + code: rule.warning, + detail: { + youngLostFraction: toString(mass), + youngLostPercent: Number(((mass.num / mass.den) * 100).toFixed(2)), + }, + }) + } } // Semi-lethal: warn if any surviving genotype is homozygous for a semi-lethal allele. @@ -95,5 +105,22 @@ export function applyLethality(dist: DistEntry[]): LethalityResult { } } + // Superschecke: surviving young carrying BOTH spotting markers (Sp present and + // S(l) present) are very-high-white and deafness-prone — info warning. + const superMass = distribution.reduce((acc, e) => { + const hasSp = e.value.Sp.includes('Sp') + const hasSl = e.value.Sls.includes('Sl') + return hasSp && hasSl ? add(acc, e.probability) : acc + }, ZERO) + if (superMass.num > 0) { + warnings.push({ + code: GeneticsWarningCode.SuperscheckeDeaf, + detail: { + affectedFraction: toString(superMass), + affectedPercent: Number(((superMass.num / superMass.den) * 100).toFixed(2)), + }, + }) + } + return { distribution, warnings } } diff --git a/gerbil-manager-web/src/genetics/loci.ts b/gerbil-manager-web/src/genetics/loci.ts index f983ed2..990e803 100644 --- a/gerbil-manager-web/src/genetics/loci.ts +++ b/gerbil-manager-web/src/genetics/loci.ts @@ -12,8 +12,12 @@ * - de.wikibooks.org/wiki/Die_Rennmaus/_Farbvarianten_und_Farbgenetik */ -/** Canonical locus keys, in conventional display order. */ -export const LOCUS_ORDER = ['A', 'C', 'D', 'E', 'G', 'P', 'Sp', 'Re'] as const +/** + * Canonical locus keys, in conventional display order. Sls (second spotting + * locus) is appended LAST so legacy 8-locus genotype strings still parse — a + * missing Sls token defaults to wild-type sl/sl. + */ +export const LOCUS_ORDER = ['A', 'C', 'D', 'E', 'G', 'P', 'Sp', 'Re', 'Sls'] as const export type LocusKey = (typeof LOCUS_ORDER)[number] export interface LocusDef { @@ -33,9 +37,12 @@ export interface LocusDef { * E = full extension * ef = Schimmel/roan (progressive whitening) * e = Fox (suppresses eumelanin) - * Sp/Re are dominant markers, lethal/semi-lethal when homozygous (see lethality.ts): - * Sp = Schecke (checkered); checkered animals are always Spsp, SpSp dies in utero. - * Re = Rex (curly coat); rex animals are Re-, ReRe is semi-lethal. + * Sp/Re/Sls are dominant markers, lethal/semi-lethal when homozygous (see lethality.ts): + * Sp = Schecke (checkered); checkered animals are always Spsp, SpSp dies in utero. + * Re = Rex (curly coat); rex animals are Re-, ReRe is semi-lethal. + * Sls = second spotting locus (S(l), WP/Minimalschecke). S(l)s(l) het = the WP + * phenotype; S(l)S(l) homozygous = lethal (Rumpback/megacolon). Sp + Sls + * together => Superschecke (very high white, deafness-prone). */ export const LOCI: Readonly> = { A: { key: 'A', nameDe: 'Agouti', alleles: ['A', 'a'] }, @@ -46,6 +53,7 @@ export const LOCI: Readonly> = { P: { key: 'P', nameDe: 'Rotaugenaufhellung (Pink-Eye)', alleles: ['P', 'p'] }, Sp: { key: 'Sp', nameDe: 'Schecke', alleles: ['Sp', 'sp'] }, Re: { key: 'Re', nameDe: 'Rex', alleles: ['Re', 're'] }, + Sls: { key: 'Sls', nameDe: 'Zweite Scheckung (WP)', alleles: ['Sl', 'sl'] }, } /** Set of all valid allele symbols, longest-first (for maximal-munch parsing). */ diff --git a/gerbil-manager-web/src/genetics/warnings.ts b/gerbil-manager-web/src/genetics/warnings.ts index 0f35238..5a22698 100644 --- a/gerbil-manager-web/src/genetics/warnings.ts +++ b/gerbil-manager-web/src/genetics/warnings.ts @@ -10,6 +10,10 @@ export const GeneticsWarningCode = { ScheckeLethal: 'SCHECKE_LETHAL', /** Rex × Rex: ReRe is semi-lethal; reduced viability of homozygous young. */ RexSemiLethal: 'REX_SEMI_LETHAL', + /** WP × WP: S(l)S(l) is prenatal-lethal (Rumpback/megacolon); fewer live young. */ + SlsLethal: 'SLS_LETHAL', + /** Sp + Sls together -> Superschecke: very high white, deafness-prone (info). */ + SuperscheckeDeaf: 'SUPERSCHECKE_DEAF', } as const export type GeneticsWarningCode =