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.
307 lines
14 KiB
JavaScript
307 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, 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)));
|
||
}
|