Miroir strict de pk/LabTrajectoryModel.kt (Android v1.5.0) : - js/pk/lab-trajectory-model.js : courbe hybride M(t)×ρ(t) ancrée sur les labs — passage EXACT garanti, ρ log-linéaire, garde #61 (lab hors fenêtre d'action pas un ancrage), E2 seul, fenêtre [1er ; dernier lab], < 2 labs → vide, indépendante de la calibration (sinon double correction). JAMAIS dans levelAt/alertes. - UI : chip 'Tracé labs' (off par défaut) dans la rangée modèles + scroll horizontal CONFINÉ à la rangée (miroir du fix Android) ; couleur #c2185b dashed ; leçon #63 Android-miroir — clé 'LAB' SKIPPÉE de la boucle légende (sinon légende TFS dupliquée). - 11 tests miroirs de LabTrajectoryModelTest.kt (132 verts total). - WEB_VERSION = 1.5.0 + CHANGELOG [1.5.0] + docs (§4/§8, README). Détail d'exécution épingle: les deux problèmes du portage ont été attrapés par node --check/E2E avant release (imports ESM faux — labIsSignificant vit dans pk-calibration.js, pas pk-engine.js).
113 lines
5.3 KiB
JavaScript
113 lines
5.3 KiB
JavaScript
/**
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
* « Tracé labs » (version web de `pk/LabTrajectoryModel.kt`, v1.5.0) —
|
||
* courbe hybride ancrée sur les résultats de sang.
|
||
*
|
||
* DEMANDE (décision v1.5.0, débattue avec l'utilisatrice — cf doc de dev
|
||
* web §7.10/§3) : courbe « biologiquement plausible MAIS passant par tous
|
||
* les points ». Formule identique à l'Android :
|
||
*
|
||
* courbe(t) = M(t) × ρ(t)
|
||
*
|
||
* - **M(t)** = niveau du modèle PK BRUT (scaleFactor forcé à 1, modèle
|
||
* stocké ou modelOverride) — porte toute la physiologie entre les labs ;
|
||
* - **ρ_k = lab_k / M(t_k)** à chaque lab ; ENTRE deux labs consécutifs ρ
|
||
* interpole LOG-LINÉAIREMENT (morphing d'amplitude continu) ;
|
||
* - courbe(t_k) = lab_k EXACTEMENT (garantie par construction, épinglé).
|
||
*
|
||
* GARDES (invariants Android portés tels quels) :
|
||
* - garde anti-#61 (labIsSignificant) : lab hors fenêtre d'action PAS un ancrage ;
|
||
* - E2 uniquement (labs T trop rares + unités variées → bug #26) ;
|
||
* - fenêtre = [1er lab significatif ; dernier lab] ∩ fenêtre du graphique ;
|
||
* - < 2 labs significatifs → courbe vide ;
|
||
* - indépendante du scaleFactor stocké (sinon DOUBLE correction — épinglé).
|
||
*
|
||
* ⚠️ display-only : la série n'entre JAMAIS dans levelAt/alertes/reminders
|
||
* (invariant §9.bis Android transposé).
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
*/
|
||
|
||
import { e2At } from './pk-engine.js';
|
||
import { labIsSignificant } from './pk-calibration.js';
|
||
|
||
/**
|
||
* Construit la courbe ancrée sur les labs.
|
||
*
|
||
* @param {object[]} treatments TOUS les traitements (actifs ET inactifs — §6.bis)
|
||
* @param {object[]} doseLogs Toutes les doses
|
||
* @param {object[]} labs Tous les LabResult (filtrés E2 ici)
|
||
* @param {number} startMs Fenêtre demandée (celle du graphique)
|
||
* @param {number} endMs
|
||
* @param {number} [stepMs=3600000] Pas d'échantillonnage (stepForRange)
|
||
* @param {string|null} [modelOverride=null]
|
||
* @returns {{points:{timestamp:number,e2:number,t:number}[], anchoredLabs:number}}
|
||
*/
|
||
export function computeLabAnchoredCurve(treatments, doseLogs, labs, startMs, endMs, stepMs = 3600000, modelOverride = null) {
|
||
if (startMs >= endMs) return { points: [], anchoredLabs: 0 };
|
||
|
||
const estrogenTreatments = treatments.filter((t) => t.type === 'ESTRADIOL');
|
||
if (estrogenTreatments.length === 0) return { points: [], anchoredLabs: 0 };
|
||
|
||
// Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le facteur)
|
||
const unscaled = estrogenTreatments.map((t) => ({ ...t, scaleFactor: 1.0 }));
|
||
|
||
// ── 1) Timeline des ANCRAGES (labs croissants + garde #61) ────────────────
|
||
const anchors = []; // (t_lab, ratio)
|
||
let maxPredictionSoFar = 0.0;
|
||
const e2Labs = labs
|
||
.filter((l) => String(l.marker).toUpperCase() === 'E2')
|
||
.slice()
|
||
.sort((a, b) => a.timestamp - b.timestamp);
|
||
for (const lab of e2Labs) {
|
||
const predicted = e2At(unscaled, doseLogs, lab.timestamp, modelOverride, null);
|
||
if (!labIsSignificant(predicted, maxPredictionSoFar)) continue;
|
||
anchors.push([lab.timestamp, lab.value / predicted]);
|
||
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted;
|
||
}
|
||
if (anchors.length < 2) return { points: [], anchoredLabs: anchors.length };
|
||
|
||
const firstLabMs = anchors[0][0];
|
||
const lastLabMs = anchors[anchors.length - 1][0];
|
||
|
||
// ── 2) Fenêtre effective = [1er ; dernier lab] ∩ fenêtre demandée ─────────
|
||
const t0 = Math.max(startMs, firstLabMs);
|
||
const t1 = Math.min(endMs, lastLabMs);
|
||
if (t1 <= t0) return { points: [], anchoredLabs: anchors.length };
|
||
|
||
// ── 3) Courbe = M(t) × ρ(t) sur la grille (t = 0 : pas de courbe T) ───────
|
||
const points = [];
|
||
for (let t = t0; t <= t1; t += stepMs) {
|
||
const m = e2At(unscaled, doseLogs, t, modelOverride, null);
|
||
const rho = ratioAt(anchors, t);
|
||
points.push({ timestamp: t, e2: m * rho, t: 0.0 });
|
||
}
|
||
return { points, anchoredLabs: anchors.length };
|
||
}
|
||
|
||
/**
|
||
* ρ(t) : interpolation LOG-LINÉAIRE entre ancrages consécutifs
|
||
* (`anchors` trié croissant). Garanties : ρ(t_k) exactement, continuité
|
||
* multiplicative, monotone ; doublons de timestamp → le 2ᵉ ratio gagne
|
||
* (garde division par zéro) ; gardes défensives aux bornes.
|
||
*
|
||
* @param {[number, number][]} anchors (t_lab, ratio) trié croissant
|
||
* @param {number} tMs
|
||
* @returns {number}
|
||
*/
|
||
export function ratioAt(anchors, tMs) {
|
||
if (tMs <= anchors[0][0]) return anchors[0][1];
|
||
if (tMs >= anchors[anchors.length - 1][0]) return anchors[anchors.length - 1][1];
|
||
|
||
for (let i = 0; i < anchors.length - 1; i++) {
|
||
const [t0, r0] = anchors[i];
|
||
const [t1, r1] = anchors[i + 1];
|
||
if (tMs >= t0 && tMs <= t1) {
|
||
const span = t1 - t0;
|
||
if (span <= 0) return r1; // doublon de timestamp : 2ᵉ ratio gagne
|
||
const f = (tMs - t0) / span;
|
||
return Math.exp(Math.log(r0) + f * (Math.log(r1) - Math.log(r0)));
|
||
}
|
||
}
|
||
return anchors[anchors.length - 1][1]; // inatteignable — garde
|
||
}
|