fix: correct Wright COI algorithm — handle direct parent-offspring pairings #44
@@ -2,69 +2,102 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDatabase } = require('../db/init');
|
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();
|
const db = getDatabase();
|
||||||
|
|
||||||
// Get all ancestors for both parents
|
// Returns a list of { id, name, generation } for every ancestor of dogId
|
||||||
function getAncestors(dogId, currentGen = 0, maxGen = generations) {
|
// INCLUDING dogId itself at generation 0.
|
||||||
if (currentGen >= maxGen) return [];
|
function getAncestorMap(dogId) {
|
||||||
|
const visited = new Map(); // id -> [{ id, name, generation }, ...]
|
||||||
const parents = db.prepare(`
|
|
||||||
SELECT p.parent_type, p.parent_id, d.name
|
function recurse(id, gen) {
|
||||||
FROM parents p
|
if (gen > generations) return;
|
||||||
JOIN dogs d ON p.parent_id = d.id
|
|
||||||
WHERE p.dog_id = ?
|
const dog = db.prepare('SELECT id, name FROM dogs WHERE id = ?').get(id);
|
||||||
`).all(dogId);
|
if (!dog) return;
|
||||||
|
|
||||||
const ancestors = parents.map(p => ({
|
if (!visited.has(id)) visited.set(id, []);
|
||||||
id: p.parent_id,
|
visited.get(id).push({ id: dog.id, name: dog.name, generation: gen });
|
||||||
name: p.name,
|
|
||||||
type: p.parent_type,
|
// Only recurse into parents if we haven't already processed this id
|
||||||
generation: currentGen + 1
|
// at this generation (prevents infinite loops on circular data)
|
||||||
}));
|
if (visited.get(id).length === 1) {
|
||||||
|
const parents = db.prepare(`
|
||||||
parents.forEach(p => {
|
SELECT p.parent_id, d.name
|
||||||
ancestors.push(...getAncestors(p.parent_id, currentGen + 1, maxGen));
|
FROM parents p
|
||||||
});
|
JOIN dogs d ON p.parent_id = d.id
|
||||||
|
WHERE p.dog_id = ?
|
||||||
return ancestors;
|
`).all(id);
|
||||||
}
|
parents.forEach(p => recurse(p.parent_id, gen + 1));
|
||||||
|
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
coefficient: Math.round(coi * 10000) / 100, // Percentage with 2 decimals
|
coefficient: Math.round(coi * 10000) / 100,
|
||||||
commonAncestors: [...new Map(commonAncestors.map(a => [a.id, a])).values()]
|
commonAncestors: commonAncestorList
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,38 +106,34 @@ router.get('/:id', (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const generations = parseInt(req.query.generations) || 5;
|
const generations = parseInt(req.query.generations) || 5;
|
||||||
|
|
||||||
function buildTree(dogId, currentGen = 0) {
|
function buildTree(dogId, currentGen = 0) {
|
||||||
if (currentGen >= generations) return null;
|
if (currentGen >= generations) return null;
|
||||||
|
|
||||||
const dog = db.prepare('SELECT * FROM dogs WHERE id = ?').get(dogId);
|
const dog = db.prepare('SELECT * FROM dogs WHERE id = ?').get(dogId);
|
||||||
if (!dog) return null;
|
if (!dog) return null;
|
||||||
|
|
||||||
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
||||||
|
|
||||||
const parents = db.prepare(`
|
const parents = db.prepare(`
|
||||||
SELECT p.parent_type, p.parent_id
|
SELECT p.parent_type, p.parent_id
|
||||||
FROM parents p
|
FROM parents p
|
||||||
WHERE p.dog_id = ?
|
WHERE p.dog_id = ?
|
||||||
`).all(dogId);
|
`).all(dogId);
|
||||||
|
|
||||||
const sire = parents.find(p => p.parent_type === 'sire');
|
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 {
|
return {
|
||||||
...dog,
|
...dog,
|
||||||
generation: currentGen,
|
generation: currentGen,
|
||||||
sire: sire ? buildTree(sire.parent_id, currentGen + 1) : null,
|
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);
|
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);
|
res.json(tree);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -116,35 +145,31 @@ router.get('/:id/descendants', (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const generations = parseInt(req.query.generations) || 3;
|
const generations = parseInt(req.query.generations) || 3;
|
||||||
|
|
||||||
function buildDescendantTree(dogId, currentGen = 0) {
|
function buildDescendantTree(dogId, currentGen = 0) {
|
||||||
if (currentGen >= generations) return null;
|
if (currentGen >= generations) return null;
|
||||||
|
|
||||||
const dog = db.prepare('SELECT * FROM dogs WHERE id = ?').get(dogId);
|
const dog = db.prepare('SELECT * FROM dogs WHERE id = ?').get(dogId);
|
||||||
if (!dog) return null;
|
if (!dog) return null;
|
||||||
|
|
||||||
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
||||||
|
|
||||||
const offspring = db.prepare(`
|
const offspring = db.prepare(`
|
||||||
SELECT DISTINCT d.id, d.name, d.sex, d.birth_date
|
SELECT DISTINCT d.id, d.name, d.sex, d.birth_date
|
||||||
FROM dogs d
|
FROM dogs d
|
||||||
JOIN parents p ON d.id = p.dog_id
|
JOIN parents p ON d.id = p.dog_id
|
||||||
WHERE p.parent_id = ? AND d.is_active = 1
|
WHERE p.parent_id = ? AND d.is_active = 1
|
||||||
`).all(dogId);
|
`).all(dogId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...dog,
|
...dog,
|
||||||
generation: currentGen,
|
generation: currentGen,
|
||||||
offspring: offspring.map(child => buildDescendantTree(child.id, currentGen + 1))
|
offspring: offspring.map(child => buildDescendantTree(child.id, currentGen + 1))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const tree = buildDescendantTree(req.params.id);
|
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);
|
res.json(tree);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -155,28 +180,29 @@ router.get('/:id/descendants', (req, res) => {
|
|||||||
router.post('/trial-pairing', (req, res) => {
|
router.post('/trial-pairing', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { sire_id, dam_id } = req.body;
|
const { sire_id, dam_id } = req.body;
|
||||||
|
|
||||||
if (!sire_id || !dam_id) {
|
if (!sire_id || !dam_id) {
|
||||||
return res.status(400).json({ error: 'Both sire_id and dam_id are required' });
|
return res.status(400).json({ error: 'Both sire_id and dam_id are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = getDatabase();
|
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 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);
|
const dam = db.prepare("SELECT * FROM dogs WHERE id = ? AND sex = 'female'").get(dam_id);
|
||||||
|
|
||||||
if (!sire || !dam) {
|
if (!sire || !dam) {
|
||||||
return res.status(404).json({ error: 'Invalid sire or dam' });
|
return res.status(404).json({ error: 'Invalid sire or dam' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = calculateCOI(sire_id, dam_id);
|
const result = calculateCOI(sire_id, dam_id);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
sire: { id: sire.id, name: sire.name },
|
sire: { id: sire.id, name: sire.name },
|
||||||
dam: { id: dam.id, name: dam.name },
|
dam: { id: dam.id, name: dam.name },
|
||||||
coi: result.coefficient,
|
coi: result.coefficient,
|
||||||
commonAncestors: result.commonAncestors,
|
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) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
|
|||||||
Reference in New Issue
Block a user