HormoneTrack-web/js/pk/lab-trajectory-model.js
Siphonight dfef8e90ef v1.5.0 : « Tracé labs » — portage complet de l'Android v1.5.0 (versions sync)
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).
2026-09-11 16:17:36 +02:00

113 lines
5.3 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.

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