HormoneTrack-web/js/pk/pk-calibration.js
Siphonight be4d26b3e5 v1.9.5 : revue de code — garde cutoffHours ESE, portage optimisations v1.8.2, perf canvas, docs alignées
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)
2026-09-20 12:55:51 +02:00

313 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ─────────────────────────────────────────────────────────────────────────────
* 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)));
}