diff --git a/server/routes/pedigree.js b/server/routes/pedigree.js index efd6f7f..d00d0ab 100644 --- a/server/routes/pedigree.js +++ b/server/routes/pedigree.js @@ -2,69 +2,102 @@ const express = require('express'); const router = express.Router(); const { getDatabase } = require('../db/init'); -// Helper function to calculate inbreeding coefficient -function calculateCOI(sireId, damId, generations = 5) { +/** + * calculateCOI — Wright Path Coefficient method + * + * Builds a full ancestor map for BOTH the sire and dam, including + * themselves at generation 0, so that: + * - if sire IS a parent of dam (or vice versa), it shows as a + * common ancestor at gen 0 vs gen 1, giving the correct COI. + * - multiple paths through the same ancestor are ALL counted. + * + * The Wright formula per path: (0.5)^(L+1) * (1 + F_A) + * where L = number of segregation steps sire-side + dam-side. + * F_A (inbreeding of common ancestor) is approximated as 0 here. + * + * For a direct parent-offspring pair (sire is direct parent of dam): + * sireGen = 0, damGen = 1 => (0.5)^(0+1+1) = 0.25 => 25% COI + */ +function calculateCOI(sireId, damId, generations = 6) { const db = getDatabase(); - - // Get all ancestors for both parents - function getAncestors(dogId, currentGen = 0, maxGen = generations) { - if (currentGen >= maxGen) return []; - - const parents = db.prepare(` - SELECT p.parent_type, p.parent_id, d.name - FROM parents p - JOIN dogs d ON p.parent_id = d.id - WHERE p.dog_id = ? - `).all(dogId); - - const ancestors = parents.map(p => ({ - id: p.parent_id, - name: p.name, - type: p.parent_type, - generation: currentGen + 1 - })); - - parents.forEach(p => { - ancestors.push(...getAncestors(p.parent_id, currentGen + 1, maxGen)); - }); - - return ancestors; - } - - const sireAncestors = getAncestors(sireId); - const damAncestors = getAncestors(damId); - - // Find common ancestors - const commonAncestors = []; - sireAncestors.forEach(sireAnc => { - damAncestors.forEach(damAnc => { - if (sireAnc.id === damAnc.id) { - commonAncestors.push({ - id: sireAnc.id, - name: sireAnc.name, - sireGen: sireAnc.generation, - damGen: damAnc.generation - }); + + // Returns a list of { id, name, generation } for every ancestor of dogId + // INCLUDING dogId itself at generation 0. + function getAncestorMap(dogId) { + const visited = new Map(); // id -> [{ id, name, generation }, ...] + + function recurse(id, gen) { + if (gen > generations) return; + + const dog = db.prepare('SELECT id, name FROM dogs WHERE id = ?').get(id); + if (!dog) return; + + if (!visited.has(id)) visited.set(id, []); + visited.get(id).push({ id: dog.id, name: dog.name, generation: gen }); + + // Only recurse into parents if we haven't already processed this id + // at this generation (prevents infinite loops on circular data) + if (visited.get(id).length === 1) { + const parents = db.prepare(` + SELECT p.parent_id, d.name + FROM parents p + JOIN dogs d ON p.parent_id = d.id + WHERE p.dog_id = ? + `).all(id); + parents.forEach(p => recurse(p.parent_id, gen + 1)); } + } + + recurse(dogId, 0); + return visited; + } + + const sireMap = getAncestorMap(sireId); + const damMap = getAncestorMap(damId); + + // Find all IDs that appear in BOTH ancestor maps (common ancestors) + // Exclude the sire and dam themselves from being listed as common ancestors + // (they can't be common ancestors of the hypothetical offspring with each other) + const commonIds = [...sireMap.keys()].filter( + id => damMap.has(id) && id !== parseInt(sireId) && id !== parseInt(damId) + ); + + let coi = 0; + const commonAncestorList = []; + const processedPaths = new Set(); + + commonIds.forEach(ancId => { + const sireOccurrences = sireMap.get(ancId); + const damOccurrences = damMap.get(ancId); + + // Wright: sum over every combination of paths through this ancestor + sireOccurrences.forEach(sireOcc => { + damOccurrences.forEach(damOcc => { + const pathKey = `${ancId}-${sireOcc.generation}-${damOcc.generation}`; + if (!processedPaths.has(pathKey)) { + processedPaths.add(pathKey); + // L = steps from sire to ancestor + steps from dam to ancestor + // Wright formula: (0.5)^(L+1) where L = sireGen + damGen + const L = sireOcc.generation + damOcc.generation; + coi += Math.pow(0.5, L + 1); + } + }); + }); + + // Build display list using closest occurrence per side + const closestSire = sireOccurrences.reduce((a, b) => a.generation < b.generation ? a : b); + const closestDam = damOccurrences.reduce((a, b) => a.generation < b.generation ? a : b); + commonAncestorList.push({ + id: ancId, + name: sireOccurrences[0].name, + sireGen: closestSire.generation, + damGen: closestDam.generation }); }); - - // Calculate COI using path coefficient method - let coi = 0; - const processed = new Set(); - - commonAncestors.forEach(anc => { - const key = `${anc.id}-${anc.sireGen}-${anc.damGen}`; - if (!processed.has(key)) { - processed.add(key); - const pathLength = anc.sireGen + anc.damGen; - coi += Math.pow(0.5, pathLength); - } - }); - + return { - coefficient: Math.round(coi * 10000) / 100, // Percentage with 2 decimals - commonAncestors: [...new Map(commonAncestors.map(a => [a.id, a])).values()] + coefficient: Math.round(coi * 10000) / 100, + commonAncestors: commonAncestorList }; } @@ -73,38 +106,34 @@ router.get('/:id', (req, res) => { try { const db = getDatabase(); const generations = parseInt(req.query.generations) || 5; - + function buildTree(dogId, currentGen = 0) { if (currentGen >= generations) return null; - + const dog = db.prepare('SELECT * FROM dogs WHERE id = ?').get(dogId); if (!dog) return null; - + dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; - + const parents = db.prepare(` SELECT p.parent_type, p.parent_id FROM parents p WHERE p.dog_id = ? `).all(dogId); - + const sire = parents.find(p => p.parent_type === 'sire'); - const dam = parents.find(p => p.parent_type === 'dam'); - + const dam = parents.find(p => p.parent_type === 'dam'); + return { ...dog, generation: currentGen, sire: sire ? buildTree(sire.parent_id, currentGen + 1) : null, - dam: dam ? buildTree(dam.parent_id, currentGen + 1) : null + dam: dam ? buildTree(dam.parent_id, currentGen + 1) : null }; } - + const tree = buildTree(req.params.id); - - if (!tree) { - return res.status(404).json({ error: 'Dog not found' }); - } - + if (!tree) return res.status(404).json({ error: 'Dog not found' }); res.json(tree); } catch (error) { res.status(500).json({ error: error.message }); @@ -116,35 +145,31 @@ router.get('/:id/descendants', (req, res) => { try { const db = getDatabase(); const generations = parseInt(req.query.generations) || 3; - + function buildDescendantTree(dogId, currentGen = 0) { if (currentGen >= generations) return null; - + const dog = db.prepare('SELECT * FROM dogs WHERE id = ?').get(dogId); if (!dog) return null; - + dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; - + const offspring = db.prepare(` SELECT DISTINCT d.id, d.name, d.sex, d.birth_date FROM dogs d JOIN parents p ON d.id = p.dog_id WHERE p.parent_id = ? AND d.is_active = 1 `).all(dogId); - + return { ...dog, generation: currentGen, offspring: offspring.map(child => buildDescendantTree(child.id, currentGen + 1)) }; } - + const tree = buildDescendantTree(req.params.id); - - if (!tree) { - return res.status(404).json({ error: 'Dog not found' }); - } - + if (!tree) return res.status(404).json({ error: 'Dog not found' }); res.json(tree); } catch (error) { res.status(500).json({ error: error.message }); @@ -155,28 +180,29 @@ router.get('/:id/descendants', (req, res) => { router.post('/trial-pairing', (req, res) => { try { const { sire_id, dam_id } = req.body; - + if (!sire_id || !dam_id) { return res.status(400).json({ error: 'Both sire_id and dam_id are required' }); } - + const db = getDatabase(); - // FIX: use single quotes for string literals — SQLite treats double quotes as identifiers const sire = db.prepare("SELECT * FROM dogs WHERE id = ? AND sex = 'male'").get(sire_id); const dam = db.prepare("SELECT * FROM dogs WHERE id = ? AND sex = 'female'").get(dam_id); - + if (!sire || !dam) { return res.status(404).json({ error: 'Invalid sire or dam' }); } - + const result = calculateCOI(sire_id, dam_id); - + res.json({ - sire: { id: sire.id, name: sire.name }, - dam: { id: dam.id, name: dam.name }, - coi: result.coefficient, + sire: { id: sire.id, name: sire.name }, + dam: { id: dam.id, name: dam.name }, + coi: result.coefficient, commonAncestors: result.commonAncestors, - recommendation: result.coefficient < 5 ? 'Low risk' : result.coefficient < 10 ? 'Moderate risk' : 'High risk' + recommendation: result.coefficient < 5 ? 'Low risk' + : result.coefficient < 10 ? 'Moderate risk' + : 'High risk' }); } catch (error) { res.status(500).json({ error: error.message });