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.
198 lines
10 KiB
JavaScript
198 lines
10 KiB
JavaScript
/**
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
* « 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
|
||
}
|