HormoneTrack-web/js/pk/lab-trajectory-model.js
Siphonight bd338decbf v1.6.0 (web, moteur) : prolongation du tracé labs au-delà du dernier lab
Miroir fidèle du Kotlin (doc Android §7.10.bis) :
- extendBeyondLastLab (ρ_last constant au-delà du dernier ancre),
  lastAnchorMs, extensionHorizonEndMs (dernière dose E2 + cutoff du
  moteur — même sémantique d'extinction, une seule source de vérité).
- 8 nouveaux tests unitaires miroirs (140 verts au total) : identité
  M×ρ_last, suture exacte, horizon cutoff, dose EV après le dernier lab,
  garde #61, flag off = v1.5.0 bit-compatible, gardes du helper,
  lastAnchorMs.
2026-09-12 23:51:20 +02:00

198 lines
10 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 + v1.6.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
* (DÉFAUT — cf prolongation ci-dessous) ;
* - < 2 labs significatifs → courbe vide (et donc jamais de prolongation) ;
* - indépendante du scaleFactor stocké (sinon DOUBLE correction — épinglé).
*
* ── PROLONGATION AU-DELÀ DU DERNIER LAB (v1.6.0, miroir §7.10.bis Android) ──
*
* DEMANDE : permettre au tracé labs de s'étendre au-delà du dernier lab, en
* simulant à partir des labs précédents, du dosage et du type d'ester
* injecté, et de l'évolution classique de l'ester.
*
* Avec `extendBeyondLastLab = true` (chip « Prolonger » — off par défaut),
* la borne droite de la fenêtre devient min(demande, horizon) et ρ cesse
* d'interpoler pour rester CONSTANT :
*
* pour t > t_last_lab : courbe(t) = M(t) × ρ_last
*
* - « l'évolution classique de l'ester » = M(t) (Tmax, queues, accumulation) ;
* - « le dosage et le type d'ester injecté » = M(t) inclut AUTOMATIQUEMENT
* les doses loguées après le dernier lab (une nouvelle injection, même à
* un autre ester, fait repartir la courbe en pic, à l'amplitude calibrée) ;
* - « les données de labs précédentes » = ρ_last (supposé constant).
*
* POURQUOI ρ CONSTANT et pas une extrapolation de pente : la pente entre
* deux labs est un morphing entre deux corrections d'amplitude, pas une
* tendance physiologique — l'extrapoler divergerait sans base. C'est la
* même sémantique que la calibration classique (facteur stocké appliqué
* aux doses futures).
*
* HORIZON (`extensionHorizonEndMs`) : dernière dose E2 + cutoffHours de son
* traitement (10 t½ terminales V3C/WHS, fin de table ODS, 30 t½ Bateman —
* le MÊME cutoff que le moteur). Au-delà, M(t) = 0 : dessiner plus serait
* une ligne à zéro inventée. Retourne null (pas de prolongation) si aucune
* dose E2 ou si le modèle est DÉJÀ éteint au dernier lab.
*
* LIMITES ASSUMÉES (documentées, pas des bugs) : changement d'ester après
* le dernier lab → l'amplitude reste calibrée par le ρ de l'ancien (même
* limite que la calibration classique) ; les doses PRÉVISIONNELLES ne
* participent jamais (la projection reste la série « Prévision »).
*
* ⚠️ display-only : la série (ancrée ET prolongée) n'entre JAMAIS dans
* levelAt/alertes/reminders (invariant §9.bis Android transposé).
* ─────────────────────────────────────────────────────────────────────────────
*/
import { e2At, cutoffHours } from './pk-engine.js';
import { labIsSignificant } from './pk-calibration.js';
/** ms par heure (évite un import rien que pour la constante). */
const HOUR_MS = 3600000;
/**
* 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 RÉELLES (les doses
* loguées APRÈS le dernier lab sont incluses : elles portent la forme
* de la prolongation ; les doses PRÉVISIONNELLES ne participent jamais)
* @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]
* @param {boolean} [extendBeyondLastLab=false] PROLONGATION (v1.6.0, opt-in) :
* au-delà du dernier lab significatif, ρ reste CONSTANT (= ρ du dernier
* lab) et la courbe continue M(t) × ρ_last jusqu'à
* extensionHorizonEndMs ∩ fenêtre demandée. false = comportement v1.5.0.
* @returns {{points:{timestamp:number,e2:number,t:number}[], anchoredLabs:number,
* lastAnchorMs:number|null}} lastAnchorMs = timestamp du DERNIER lab
* significatif (null si < 2 ancres) — l'UI splitte la série en
* "LAB" (≤ lastAnchorMs, ancrée) et "LABX" (> lastAnchorMs, prolongée).
*/
export function computeLabAnchoredCurve(treatments, doseLogs, labs, startMs, endMs, stepMs = 3600000, modelOverride = null, extendBeyondLastLab = false) {
if (startMs >= endMs) return { points: [], anchoredLabs: 0, lastAnchorMs: null };
const estrogenTreatments = treatments.filter((t) => t.type === 'ESTRADIOL');
if (estrogenTreatments.length === 0) return { points: [], anchoredLabs: 0, lastAnchorMs: null };
// 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, lastAnchorMs: null };
const firstLabMs = anchors[0][0];
const lastLabMs = anchors[anchors.length - 1][0];
// ── 2) Fenêtre effective ───────────────────────────────────────────────────
// v1.5.0 : [1er lab ; dernier lab] ∩ fenêtre demandée.
// v1.6.0 (extendBeyondLastLab) : la borne droite devient le min de la
// fenêtre demandée et de l'HORIZON DE PROLONGATION. ratioAt() retourne
// déjà ρ_last au-delà du dernier ancre (garde défensive) : la boucle de
// calcul est INCHANGÉE — élargir la fenêtre EST la prolongation, et la
// continuité au point de suture est garantie par construction.
const t0 = Math.max(startMs, firstLabMs);
const t1 = extendBeyondLastLab
? Math.min(endMs, extensionHorizonEndMs(estrogenTreatments, doseLogs, lastLabMs) ?? lastLabMs)
: Math.min(endMs, lastLabMs);
if (t1 <= t0) return { points: [], anchoredLabs: anchors.length, lastAnchorMs: null };
// ── 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, lastAnchorMs: lastLabMs };
}
/**
* BORNE DE PROLONGATION (v1.6.0) : timestamp jusqu'où la partie extrapolée
* a un sens — dernière dose E2 + cutoffHours(son traitement), le MÊME cutoff
* que le moteur (10 t½ V3C/WHS, fin de table ODS, 30 t½ Bateman).
*
* @param {object[]} estrogenTreatments traitements ESTRADIOL (pré-filtrés)
* @param {object[]} doseLogs toutes les doses
* @param {number} lastLabMs timestamp du dernier ancre
* @returns {number|null} fin de prolongation, ou null quand il n'y a RIEN à
* prolonger : aucune dose E2, ou modèle déjà éteint au dernier lab (le
* caller retombe alors sur la fenêtre v1.5.0).
*/
export function extensionHorizonEndMs(estrogenTreatments, doseLogs, lastLabMs) {
const e2Doses = doseLogs.filter((d) => estrogenTreatments.some((tr) => tr.id === d.treatmentId));
if (e2Doses.length === 0) return null;
let lastDose = e2Doses[0];
for (const d of e2Doses) {
if (d.timestamp > lastDose.timestamp) lastDose = d;
}
const tr = estrogenTreatments.find((t) => t.id === lastDose.treatmentId);
const horizonEnd = lastDose.timestamp + cutoffHours(tr) * HOUR_MS;
return horizonEnd > lastLabMs ? horizonEnd : null;
}
/**
* ρ(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.
*
* v1.6.0 : la branche « après le dernier ancre » (retour ρ_last) est
* devenue un COMPORTEMENT, pas une simple garde — c'est elle qui fonde la
* prolongation (ρ constant au-delà du dernier lab) ; en v1.5.0 la fenêtre
* du caller n'y entrait jamais.
*
* @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
}