/** * ───────────────────────────────────────────────────────────────────────────── * Calibration (partie 2/4 du moteur) — portage de `PharmacokineticEngine.kt` * sections « Calibration » (Android v1.2.1 → v1.4.9). * * - PAR PÉRIODE D'ESTER (v1.2.1) : labs valerate → doses valerate, labs * enanthate → doses enanthate (un facteur unique mélangeait les périodes * → courbes gonflées à 250–375 pg/mL) ; * - PAR MODÈLE (v1.4.8, fix #60) : chaque courbe affichée (ESE/TFS/WHS) est * calibrée avec la prédiction de SON modèle (`modelOverride` propagé) ; * - GARDE DE SIGNIFICATIVITÉ (v1.4.9, fix #61) : un lab ne calibre que si * la prédiction reste ≥ 15 % du max observé — sinon ratios aberrants et * médiane tombant à ×2,21 (dose de test ancienne + labs tardifs). * * PUR, testable en Node (web/tests/calibration.test.js). * ───────────────────────────────────────────────────────────────────────────── */ import { e2At, convertTToNgMl, batemanParams, concentrationOfDose, cutoffHours, doseEster, Esters, TConfig } from './pk-engine.js'; /** * Garde de significativité (fix #61) : fraction minimale de la prédiction max * observée pour qu'un lab participe à la calibration. */ export const LAB_MIN_PREDICTION_FRACTION = 0.15; /** * Un lab ne CALIBRE que si la prédiction du modèle à son instant reste * ≥ [LAB_MIN_PREDICTION_FRACTION] du max de prédiction déjà observé — * c'est-à-dire tant que l'injection « gouverne » encore le taux. Au-delà de * la fenêtre d'action (prédiction résiduelle), le lab reflète AUTRE CHOSE * (temps mort, début de traitement, autre ester) et son ratio lab ÷ * prédiction devient aberrant. * * Pourquoi une garde RELATIVE (et pas temporelle en jours) : la fenêtre * d'action dépend du profil (l'injection gouverne ~2 j pour le benzoate, * des mois pour l'undécylate) — une fenêtre fixe casserait autant les labs * réels que les tests. La PRÉDICTION, elle, contient déjà toute la * physiologie du modèle. * * @param {number} predicted Prédiction du modèle à l'instant du lab (pg/mL) * @param {number} maxSoFar Max des prédictions déjà observées (labs par timestamp CROISSANT) * @returns {boolean} */ export function labIsSignificant(predicted, maxSoFar) { return predicted > 0.5 && (maxSoFar <= 0.0 || predicted >= LAB_MIN_PREDICTION_FRACTION * maxSoFar); } /** Médiane d'une liste numérique (triée ou non). */ function median(values) { const sorted = values.slice().sort((a, b) => a - b); const n = sorted.length; if (n % 2 === 1) return sorted[(n - 1) / 2]; return (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0; } /** Arrondi « facteur » à 2 décimales, troncature (miroir du Kotlin (x*100|0)/100). */ export function round2(x) { // v1.9.2 : ARRONDI (Math.round) au lieu de troncature — miroir du fix // Android v1.8.2 (medianRounded) : la troncature au dernier bit flottant // faussait les échelles d'un cran (0,8999… → 0,89 au lieu de 0,90) et // divergeait de l'Android (harmonisation des niveaux web/Android) return Math.round(x * 100) / 100; } /** Arrondi « k T » à 3 décimales, troncature. */ export function round3(x) { return Math.round(x * 1000) / 1000; } /** Dernier élément (par timestamp) d'une liste non vide ; null si vide. */ function latest(list) { let acc = null; for (const d of list) { if (acc === null || d.timestamp > acc.timestamp) acc = d; } return acc; } /** * Calibration PAR PÉRIODE D'ESTER + PAR MODÈLE (fix #60). * * Principe : chaque lab est attribué à la période d'injection dans laquelle * il tombe = la dernière dose E2 ≤ lab (une prise de sang reflète d'abord * l'injection qui précède). Le ratio lab ÷ prédiction (toutes doses * superposées, SANS calibration, avec le MODÈLE demandé) est rattaché à * l'ester de cette dose. Facteur final par ester = MÉDIANE des ratios de sa * période, arrondie à 2 décimales. * * @param {object[]} treatments TOUS les traitements (actifs ET inactifs — §6.bis) * @param {object[]} doseLogs * @param {object[]} e2Labs Labs E2 (marker "E2", unité pg/mL) * @param {string|null} [modelOverride=null] "ESE" | "TFS" | "WHS" * @returns {Object} facteur par ester (≥ 1 lab exploitable) */ export function computeEsterScaleFactors(treatments, doseLogs, e2Labs, modelOverride = null) { const estrogenTreatments = treatments.filter((tr) => tr.type === 'ESTRADIOL'); const estrogenDoses = doseLogs.filter( (d) => estrogenTreatments.some((tr) => tr.id === d.treatmentId), ); if (estrogenDoses.length === 0) return {}; // Prédictions non calibrées (scaleFactor forcé à 1) pour chaque lab, // avec le MODÈLE demandé (fix #60 : chaque courbe est calibrée avec SA // propre prédiction). const unscaled = estrogenTreatments.map((tr) => ({ ...tr, scaleFactor: 1.0 })); const ratiosByEster = new Map(); let maxPredictionSoFar = 0.0; // ⚠️ labs par timestamp CROISSANT + garde labIsSignificant (fix #61) const sortedLabs = e2Labs.slice().sort((a, b) => a.timestamp - b.timestamp); for (const lab of sortedLabs) { const predicted = e2At(unscaled, doseLogs, lab.timestamp, modelOverride, null); if (!labIsSignificant(predicted, maxPredictionSoFar)) continue; // Attribution : dernière dose E2 ≤ lab → son ester (override compris) const attributedDose = latest(estrogenDoses.filter((d) => d.timestamp <= lab.timestamp)); if (!attributedDose) continue; const attributedTreatment = estrogenTreatments.find((tr) => tr.id === attributedDose.treatmentId); const ester = doseEster(attributedTreatment, attributedDose); if (ester === Esters.NONE) continue; if (!ratiosByEster.has(ester)) ratiosByEster.set(ester, []); ratiosByEster.get(ester).push(lab.value / predicted); if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted; } const out = {}; for (const [ester, ratios] of ratiosByEster) out[ester] = round2(median(ratios)); return out; } /** * Calibration du k du modèle T PAR PÉRIODE D'ESTER (v1.2.3) + PAR MODÈLE * (v1.4.8) : la suppression de la testostérone diffère selon l'ester * (valerate = pics hauts et courts, enanthate = plateau plus doux) → chaque * lab T est attribué à la période de la dernière dose E2 ≤ lab et * `k = ((base−floor)/(T_lab − floor) − 1) / E2_est(t_lab)`, garde * k ∈ (1e-4, 10), médiane par période, arrondie à 3 décimales. * * ⚠️ L'E2 utilisée est la version CALIBRÉE (scalePerEster) et suit le MODÈLE * demandé — calibrer k contre une E2 brute/fausse faussait les k. * * @param {object[]} treatments * @param {object[]} doseLogs * @param {object[]} tLabs Labs T (marker "T", unités variées) * @param {TConfig} current * @param {Object|null} [scalePerEster=null] * @param {string|null} [modelOverride=null] * @returns {Object} k par ester */ export function computeTKPerEster(treatments, doseLogs, tLabs, current, scalePerEster = null, modelOverride = null) { const estrogenIds = new Set(treatments.filter((tr) => tr.type === 'ESTRADIOL').map((tr) => tr.id)); const estrogenDoses = doseLogs.filter((d) => estrogenIds.has(d.treatmentId)); if (estrogenDoses.length === 0) return {}; const ksByEster = new Map(); let maxE2SoFar = 0.0; // ⚠️ labs par timestamp CROISSANT + garde labIsSignificant (fix #61) const sortedLabs = tLabs.slice().sort((a, b) => a.timestamp - b.timestamp); for (const lab of sortedLabs) { const tNgMl = convertTToNgMl(lab.value, lab.unit); if (tNgMl <= current.floor + 0.02) continue; const attributed = latest(estrogenDoses.filter((d) => d.timestamp <= lab.timestamp)); if (!attributed) continue; const tr = treatments.find((x) => x.id === attributed.treatmentId); const ester = doseEster(tr, attributed); if (ester === Esters.NONE) continue; // v1.4.8 : l'E2 de référence suit le MODÈLE demandé (fix #60) const e2 = e2At(treatments, doseLogs, lab.timestamp, modelOverride, scalePerEster); if (!labIsSignificant(e2, maxE2SoFar)) continue; const k = ((current.base - current.floor) / (tNgMl - current.floor) - 1.0) / e2; if (k > 1e-4 && k < 10.0) { if (!ksByEster.has(ester)) ksByEster.set(ester, []); ksByEster.get(ester).push(k); } if (e2 > maxE2SoFar) maxE2SoFar = e2; } const out = {}; for (const [ester, ks] of ksByEster) out[ester] = round3(median(ks)); return out; } /** * Résultat de l'auto-calibration (miroir de AutoCalibrated.kt) : * traitements/réglages stockés INCHANGÉS — tout est renvoyé en copies pour * l'affichage uniquement (rien n'est persisté par l'auto-calibration). */ export class AutoCalibrated { /** * @param {object[]} treatments (identiques à l'entrée) * @param {TConfig} tConfig (identique à l'entrée) * @param {Object} esterScales facteur par ester recalculé * @param {number} calibratedEsters nb d'esters avec ≥ 1 lab exploitable * @param {boolean} tRecalibrated true si le modèle T a pu être recalibré * @param {Object} tKPerEster k T par ester recalculé */ constructor(treatments, tConfig, esterScales, calibratedEsters, tRecalibrated, tKPerEster) { this.treatments = treatments; this.tConfig = tConfig; this.esterScales = esterScales; this.calibratedEsters = calibratedEsters; this.tRecalibrated = tRecalibrated; this.tKPerEster = tKPerEster; } } /** * Calibration automatique (option « Calibration automatique » des Paramètres, * désactivée par défaut) : * - facteurs d'échelle E2 par période d'ester ([computeEsterScaleFactors]) ; * - k du modèle T par période d'ester ([computeTKPerEster]), calibré contre * l'E2 déjà calibrée. * * @param {object[]} treatments * @param {object[]} doseLogs * @param {object[]} labs Tous les labs (filtrés E2/T ici) * @param {TConfig} tConfig * @param {string|null} [modelOverride=null] Modèle pour les PRÉDICTIONS * (null = modèle stocké de chaque traitement = comportement Home ; le * graphique appelle une fois PAR MODÈLE affiché — fix #60) * @returns {AutoCalibrated} */ export function autoCalibrated(treatments, doseLogs, labs, tConfig, modelOverride = null) { const e2Labs = labs.filter((l) => String(l.marker).toUpperCase() === 'E2'); const tLabs = labs.filter((l) => String(l.marker).toUpperCase() === 'T'); const esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs, modelOverride); const tKPerEster = computeTKPerEster(treatments, doseLogs, tLabs, tConfig, esterScales, modelOverride); return new AutoCalibrated( treatments, tConfig, esterScales, Object.keys(esterScales).length, Object.keys(tKPerEster).length > 0, tKPerEster, ); } /** * Calibration MANUELLE (bouton « Calibrer avec les analyses » de l'éditeur) : * facteur UNIQUE par traitement (médiane lab ÷ prédiction, modèle stocké), * garde labIsSignificant (fix #61), arrondi 2 décimales. * * ⚠️ Limite documentée (Android §7.6) : en cas de changement d'ester dans un * même traitement, la manuelle mélange les périodes — préférer l'auto-calibration. * * @param {object} treatment * @param {object[]} allDoseLogs * @param {object[]} e2Labs * @returns {number|null} facteur, ou null (pas de lab exploitable) */ export function computeScaleFactor(treatment, allDoseLogs, e2Labs) { if (treatment.type !== 'ESTRADIOL') return null; const myDoses = allDoseLogs.filter((d) => d.treatmentId === treatment.id); if (myDoses.length === 0) return null; const p = batemanParams(treatment); const ratios = []; let maxPredictionSoFar = 0.0; // ⚠️ labs par timestamp CROISSANT (fix #61) const sortedLabs = e2Labs.slice().sort((a, b) => a.timestamp - b.timestamp); for (const lab of sortedLabs) { let predicted = 0.0; for (const dose of myDoses) { if (dose.timestamp > lab.timestamp) continue; const dtH = (lab.timestamp - dose.timestamp) / 3600000.0; if (dtH > cutoffHours(treatment)) continue; predicted += concentrationOfDose(treatment, dose, lab.timestamp, p, null); } if (labIsSignificant(predicted, maxPredictionSoFar)) { ratios.push(lab.value / predicted); } if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted; } if (ratios.length === 0) return null; return round2(median(ratios)); } /** * Calibration T GLOBALE (k unique — bouton « Calibrer avec les analyses » des * Paramètres) : labs T normalisés via convertTToNgMl, k = médiane, garde * k ∈ (1e-4, 10). L'auto-calibration (k par ester) la remplace à l'affichage * quand elle est active. * * @param {object[]} tLabs * @param {object[]} treatments * @param {object[]} doseLogs * @param {TConfig} current * @returns {TConfig|null} nouvelle config, ou null (aucun lab exploitable) */ export function computeTConfigCalibration(tLabs, treatments, doseLogs, current) { // Filtrage pré-conversion (valeur brute) puis post-conversion (ng/mL) — // un lab déjà sous le plancher est ignoré dans les deux cas. const usable = tLabs .filter((l) => l.value > current.floor + 0.02) .map((l) => ({ ...l, value: convertTToNgMl(l.value, l.unit) })) .filter((l) => l.value > current.floor + 0.02); if (usable.length === 0) return null; const ks = []; for (const lab of usable) { const e2 = e2At(treatments, doseLogs, lab.timestamp, null, null); if (e2 <= 1.0) continue; const k = ((current.base - current.floor) / (lab.value - current.floor) - 1.0) / e2; if (k > 1e-4 && k < 10.0) ks.push(k); } if (ks.length === 0) return null; return new TConfig(current.base, current.floor, round3(median(ks))); }