Corrigé (audit) : - cutoffHours : garde hasModel pour ESE (parité Kotlin) — un backup importé ESE + ester non couvert levait TypeError → écran vide - min/max en une passe : les spreads Math.min(...allPoints) levaient RangeError au-delà de ~1e5 arguments (Nuage × zoom max) et allouaient par frame - init MCMC non bloquante : data-ready garantit désormais « asset chargé » Perf (portage v1.8.2 Android, fin de la dérive de miroir) : - prepareE2Context/e2AtCtx : doses groupées, cutoffs précalculés, Bateman paresseux ; computeCurve ne recalcule plus cutoffHours par point - résultat identique (175 tests verts) E2E : version lue via data-version (anti-bug v1.9.3) Docs/commentaires : pk-engine (ESE analytique), pk-calibration (JSDoc arrondi), dialogs (ESE=6), lab-timing (import mort, JSDoc orpheline), home (COLORS), pk-profile-store (test-only), pk_profiles.json exclu du zip (export-ignore, −548 Ko)
313 lines
14 KiB
JavaScript
313 lines
14 KiB
JavaScript
/**
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
* 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 (v1.9.2 : Math.round — miroir
|
||
* medianRounded Kotlin v1.8.2 ; la troncature historique (x*100|0)/100
|
||
* faussait les échelles d'un cran, cf le test rounding.test.js). */
|
||
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 (v1.9.2 : Math.round, cf round2). */
|
||
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<string,number>} 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<string,number>|null} [scalePerEster=null]
|
||
* @param {string|null} [modelOverride=null]
|
||
* @returns {Object<string,number>} 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<string,number>} 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<string,number>} 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)));
|
||
}
|