HormoneTrack-web/js/pk/pk-calibration.js
Siphonight 491efef8eb v1.4.10 — portage initial de l'app Android v1.4.10
Portage navigateur complet de l'app Android HormoneTrack v1.4.10 :
- moteur PK fidèle (Estrannaise tables ODS, Transfem Science V3C,
  WHSAH fit Mona, Bateman) — paramètres identiques, invariants Android
  préservés (fixes #19-#23, #35, #52-#62), 121 tests Node épinglés
- calibration par période d'ester et par modèle affiché (#60/#61)
- UI 6 écrans + éditeur, CurveChart Canvas (zoom/pan fractionnaire,
  prévision, pics/creux, fuseau configurable), rappels web
  (Notification API), seuils d'alerte, i18n FR/EN
- sauvegarde JSON v2 compatible Android bidirectionnelle (rétrocompat v1)
- 100 % local : localStorage, aucun serveur applicatif, aucune télémétrie
- processus : scripts/check.sh (syntaxe, i18n, tests, E2E navigateur,
  smoke HTTP), docs séparées (README + DEVELOPPEMENT + CHANGELOG)
- dépôt GIT SÉPARÉ de l'Android : historique 100 % propre, versions
  alignées sur l'Android porté, changelogs indépendants

Non porté (impossible dans un navigateur, documenté §12) : agenda
récurrent, notifications onglet fermé, montre.
2026-09-08 14:34:58 +02:00

307 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, troncature (miroir du Kotlin (x*100|0)/100). */
function round2(x) {
return Math.trunc(x * 100) / 100;
}
/** Arrondi « k T » à 3 décimales, troncature. */
function round3(x) {
return Math.trunc(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)));
}