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.
This commit is contained in:
parent
38cf62826e
commit
bd338decbf
@ -9,7 +9,7 @@ export * from './pk-calibration.js';
|
|||||||
export * from './pk-reminders.js';
|
export * from './pk-reminders.js';
|
||||||
export * from './pk-extrema.js';
|
export * from './pk-extrema.js';
|
||||||
export * as LabTrajectoryModel from './lab-trajectory-model.js';
|
export * as LabTrajectoryModel from './lab-trajectory-model.js';
|
||||||
export { computeLabAnchoredCurve, ratioAt } from './lab-trajectory-model.js';
|
export { computeLabAnchoredCurve, ratioAt, extensionHorizonEndMs } from './lab-trajectory-model.js';
|
||||||
export * from './chart-helpers.js';
|
export * from './chart-helpers.js';
|
||||||
export * as Alerts from './alerts.js';
|
export * as Alerts from './alerts.js';
|
||||||
export * as PKProfileStore from './pk-profile-store.js';
|
export * as PKProfileStore from './pk-profile-store.js';
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* ─────────────────────────────────────────────────────────────────────────────
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
* « Tracé labs » (version web de `pk/LabTrajectoryModel.kt`, v1.5.0) —
|
* « Tracé labs » (version web de `pk/LabTrajectoryModel.kt`, v1.5.0 + v1.6.0) —
|
||||||
* courbe hybride ancrée sur les résultats de sang.
|
* 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
|
* DEMANDE (décision v1.5.0, débattue avec l'utilisatrice — cf doc de dev
|
||||||
@ -18,35 +18,83 @@
|
|||||||
* GARDES (invariants Android portés tels quels) :
|
* GARDES (invariants Android portés tels quels) :
|
||||||
* - garde anti-#61 (labIsSignificant) : lab hors fenêtre d'action PAS un ancrage ;
|
* - 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) ;
|
* - E2 uniquement (labs T trop rares + unités variées → bug #26) ;
|
||||||
* - fenêtre = [1er lab significatif ; dernier lab] ∩ fenêtre du graphique ;
|
* - fenêtre = [1er lab significatif ; dernier lab] ∩ fenêtre du graphique
|
||||||
* - < 2 labs significatifs → courbe vide ;
|
* (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é).
|
* - indépendante du scaleFactor stocké (sinon DOUBLE correction — épinglé).
|
||||||
*
|
*
|
||||||
* ⚠️ display-only : la série n'entre JAMAIS dans levelAt/alertes/reminders
|
* ── PROLONGATION AU-DELÀ DU DERNIER LAB (v1.6.0, miroir §7.10.bis Android) ──
|
||||||
* (invariant §9.bis Android transposé).
|
*
|
||||||
|
* 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 } from './pk-engine.js';
|
import { e2At, cutoffHours } from './pk-engine.js';
|
||||||
import { labIsSignificant } from './pk-calibration.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.
|
* Construit la courbe ancrée sur les labs.
|
||||||
*
|
*
|
||||||
* @param {object[]} treatments TOUS les traitements (actifs ET inactifs — §6.bis)
|
* @param {object[]} treatments TOUS les traitements (actifs ET inactifs — §6.bis)
|
||||||
* @param {object[]} doseLogs Toutes les doses
|
* @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 {object[]} labs Tous les LabResult (filtrés E2 ici)
|
||||||
* @param {number} startMs Fenêtre demandée (celle du graphique)
|
* @param {number} startMs Fenêtre demandée (celle du graphique)
|
||||||
* @param {number} endMs
|
* @param {number} endMs
|
||||||
* @param {number} [stepMs=3600000] Pas d'échantillonnage (stepForRange)
|
* @param {number} [stepMs=3600000] Pas d'échantillonnage (stepForRange)
|
||||||
* @param {string|null} [modelOverride=null]
|
* @param {string|null} [modelOverride=null]
|
||||||
* @returns {{points:{timestamp:number,e2:number,t:number}[], anchoredLabs:number}}
|
* @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) {
|
export function computeLabAnchoredCurve(treatments, doseLogs, labs, startMs, endMs, stepMs = 3600000, modelOverride = null, extendBeyondLastLab = false) {
|
||||||
if (startMs >= endMs) return { points: [], anchoredLabs: 0 };
|
if (startMs >= endMs) return { points: [], anchoredLabs: 0, lastAnchorMs: null };
|
||||||
|
|
||||||
const estrogenTreatments = treatments.filter((t) => t.type === 'ESTRADIOL');
|
const estrogenTreatments = treatments.filter((t) => t.type === 'ESTRADIOL');
|
||||||
if (estrogenTreatments.length === 0) return { points: [], anchoredLabs: 0 };
|
if (estrogenTreatments.length === 0) return { points: [], anchoredLabs: 0, lastAnchorMs: null };
|
||||||
|
|
||||||
// Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le facteur)
|
// Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le facteur)
|
||||||
const unscaled = estrogenTreatments.map((t) => ({ ...t, scaleFactor: 1.0 }));
|
const unscaled = estrogenTreatments.map((t) => ({ ...t, scaleFactor: 1.0 }));
|
||||||
@ -64,15 +112,23 @@ export function computeLabAnchoredCurve(treatments, doseLogs, labs, startMs, end
|
|||||||
anchors.push([lab.timestamp, lab.value / predicted]);
|
anchors.push([lab.timestamp, lab.value / predicted]);
|
||||||
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted;
|
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted;
|
||||||
}
|
}
|
||||||
if (anchors.length < 2) return { points: [], anchoredLabs: anchors.length };
|
if (anchors.length < 2) return { points: [], anchoredLabs: anchors.length, lastAnchorMs: null };
|
||||||
|
|
||||||
const firstLabMs = anchors[0][0];
|
const firstLabMs = anchors[0][0];
|
||||||
const lastLabMs = anchors[anchors.length - 1][0];
|
const lastLabMs = anchors[anchors.length - 1][0];
|
||||||
|
|
||||||
// ── 2) Fenêtre effective = [1er ; dernier lab] ∩ fenêtre demandée ─────────
|
// ── 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 t0 = Math.max(startMs, firstLabMs);
|
||||||
const t1 = Math.min(endMs, lastLabMs);
|
const t1 = extendBeyondLastLab
|
||||||
if (t1 <= t0) return { points: [], anchoredLabs: anchors.length };
|
? 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) ───────
|
// ── 3) Courbe = M(t) × ρ(t) sur la grille (t = 0 : pas de courbe T) ───────
|
||||||
const points = [];
|
const points = [];
|
||||||
@ -81,7 +137,31 @@ export function computeLabAnchoredCurve(treatments, doseLogs, labs, startMs, end
|
|||||||
const rho = ratioAt(anchors, t);
|
const rho = ratioAt(anchors, t);
|
||||||
points.push({ timestamp: t, e2: m * rho, t: 0.0 });
|
points.push({ timestamp: t, e2: m * rho, t: 0.0 });
|
||||||
}
|
}
|
||||||
return { points, anchoredLabs: anchors.length };
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -90,6 +170,11 @@ export function computeLabAnchoredCurve(treatments, doseLogs, labs, startMs, end
|
|||||||
* multiplicative, monotone ; doublons de timestamp → le 2ᵉ ratio gagne
|
* multiplicative, monotone ; doublons de timestamp → le 2ᵉ ratio gagne
|
||||||
* (garde division par zéro) ; gardes défensives aux bornes.
|
* (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, number][]} anchors (t_lab, ratio) trié croissant
|
||||||
* @param {number} tMs
|
* @param {number} tMs
|
||||||
* @returns {number}
|
* @returns {number}
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Tests « Tracé labs » web (miroir de `LabTrajectoryModelTest.kt` Android)
|
* Tests « Tracé labs » web (miroir de `LabTrajectoryModelTest.kt` Android)
|
||||||
* — mêmes garanties épinglées : passage exact, ρ log-linéaire, garde #61,
|
* — mêmes garanties épinglées : passage exact, ρ log-linéaire, garde #61,
|
||||||
* fenêtre bornée aux labs, indépendance du scaleFactor.
|
* fenêtre bornée aux labs, indépendance du scaleFactor. v1.6.0 :
|
||||||
|
* PROLONGATION au-delà du dernier lab (ρ constant, horizon = cutoff du
|
||||||
|
* traitement, doses après le dernier lab, garde #61, flag off = v1.5.0).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, describe, before } from 'node:test';
|
import { test, describe, before } from 'node:test';
|
||||||
@ -9,9 +11,9 @@ import assert from 'node:assert/strict';
|
|||||||
import {
|
import {
|
||||||
initProfiles, makeTreatment, makeDose, makeLab, HOUR_MS, DAY_MS, assertClose,
|
initProfiles, makeTreatment, makeDose, makeLab, HOUR_MS, DAY_MS, assertClose,
|
||||||
} from './helpers.js';
|
} from './helpers.js';
|
||||||
import { e2At } from '../js/pk/pk-engine.js';
|
import { e2At, cutoffHours } from '../js/pk/pk-engine.js';
|
||||||
import {
|
import {
|
||||||
computeLabAnchoredCurve, ratioAt,
|
computeLabAnchoredCurve, ratioAt, extensionHorizonEndMs,
|
||||||
} from '../js/pk/lab-trajectory-model.js';
|
} from '../js/pk/lab-trajectory-model.js';
|
||||||
/** Traitement injectable EEn/TFS 5 mg, une dose à t=0, scaleFactor paramétrable. */
|
/** Traitement injectable EEn/TFS 5 mg, une dose à t=0, scaleFactor paramétrable. */
|
||||||
function treatment(scaleFactor = 1.0) {
|
function treatment(scaleFactor = 1.0) {
|
||||||
@ -138,3 +140,120 @@ describe('LabTrajectoryModel web (miroir Android v1.5.0)', () => {
|
|||||||
assertClose(ratioAt(anchors, 100), 2.0, 1e-12);
|
assertClose(ratioAt(anchors, 100), 2.0, 1e-12);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Prolongation au-delà du dernier lab (v1.6.0 — miroir Android) ───────────
|
||||||
|
// Garanties (cf doc web §7.10.bis / KDoc du module) :
|
||||||
|
// - au-delà du dernier lab : courbe(t) = M(t) × ρ_last (ρ CONSTANT) ;
|
||||||
|
// - continuité au point de suture (le dernier lab reste exact) ;
|
||||||
|
// - horizon = dernière dose E2 + cutoff de son traitement (JAMAIS au-delà) ;
|
||||||
|
// - une dose loguée après le dernier lab (autre ester compris) refait
|
||||||
|
// monter la courbe via M(t) — « dosage + ester + évolution classique » ;
|
||||||
|
// - dernier lab non significatif : ni ancre ni départ de prolongation (#61) ;
|
||||||
|
// - flag OFF (défaut) = comportement v1.5.0 bit-compatible.
|
||||||
|
describe('LabTrajectoryModel prolongation (miroir Android v1.6.0)', () => {
|
||||||
|
before(() => initProfiles());
|
||||||
|
|
||||||
|
/** Labs DATA-DRIVEN ancrés à 2 j (ρ=0,9) et 7 j (ρ=1,2) sur la fixture EEn/TFS. */
|
||||||
|
function anchoredLabs(tr, ds) {
|
||||||
|
const m2 = e2At([tr], ds, 2 * DAY_MS, null, null);
|
||||||
|
const m7 = e2At([tr], ds, 7 * DAY_MS, null, null);
|
||||||
|
return [lab(0.9 * m2, 2 * DAY_MS), lab(1.2 * m7, 7 * DAY_MS)];
|
||||||
|
}
|
||||||
|
const RHO_LAST = 1.2;
|
||||||
|
|
||||||
|
test('extension - la courbe continue après le dernier lab avec le rho du dernier lab', () => {
|
||||||
|
const tr = treatment();
|
||||||
|
const ds = doses();
|
||||||
|
const curve = computeLabAnchoredCurve([tr], ds, anchoredLabs(tr, ds), 0, 60 * DAY_MS, HOUR_MS, null, true);
|
||||||
|
|
||||||
|
assert.ok(curve.points.some((p) => p.timestamp > 7 * DAY_MS), 'prolongation présente');
|
||||||
|
// Suture : le dernier lab reste EXACT (continuité par construction)
|
||||||
|
const m7 = e2At([{ ...tr, scaleFactor: 1.0 }], ds, 7 * DAY_MS, null, null);
|
||||||
|
assertClose(curve.points.find((p) => p.timestamp === 7 * DAY_MS).e2, 1.2 * m7, 1e-6,
|
||||||
|
'le point au dernier lab vaut toujours le lab (1,2 × M)');
|
||||||
|
// Identité de la zone extrapolée : courbe(t) = M(t) × ρ_last, exact
|
||||||
|
for (const day of [10, 20, 30]) {
|
||||||
|
const t = day * DAY_MS;
|
||||||
|
const p = curve.points.find((q) => q.timestamp === t);
|
||||||
|
const m = e2At([{ ...tr, scaleFactor: 1.0 }], ds, t, null, null);
|
||||||
|
assertClose(p.e2, m * RHO_LAST, 1e-9, `courbe(${day} j) == M × ρ_last`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extension - horizon borné par le cutoff de la dernière dose', () => {
|
||||||
|
const tr = treatment();
|
||||||
|
const curve = computeLabAnchoredCurve([tr], doses(), anchoredLabs(tr, doses()), 0, 90 * DAY_MS, HOUR_MS, null, true);
|
||||||
|
const horizonEnd = extensionHorizonEndMs([tr], doses(), 7 * DAY_MS);
|
||||||
|
assert.ok(horizonEnd > 7 * DAY_MS, 'l\'horizon EEn/TFS dépasse le dernier lab (sinon le test ne prouve rien)');
|
||||||
|
assert.ok(curve.points.every((p) => p.timestamp <= horizonEnd), 'aucun point au-delà de l\'extinction du modèle');
|
||||||
|
const last = curve.points[curve.points.length - 1].timestamp;
|
||||||
|
assert.ok(last <= horizonEnd && last + HOUR_MS > horizonEnd, 'le dernier point s\'arrête AU cutoff (pas au-delà d\'un pas)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extension - une dose après le dernier lab refait monter la courbe (dose et ester)', () => {
|
||||||
|
const tr = treatment();
|
||||||
|
const ds = doses().concat([makeDose(10 * DAY_MS, 4, { esterType: 'EV' })]);
|
||||||
|
const curve = computeLabAnchoredCurve([tr], ds, anchoredLabs(tr, doses()), 0, 20 * DAY_MS, HOUR_MS, null, true);
|
||||||
|
const extension = curve.points.filter((p) => p.timestamp > 7 * DAY_MS);
|
||||||
|
assert.ok(extension.length > 0);
|
||||||
|
|
||||||
|
// L'identité vaut AUSSI avec la dose EV : M(t) inclut les deux doses
|
||||||
|
const t12 = 12 * DAY_MS;
|
||||||
|
const m12 = e2At([{ ...tr, scaleFactor: 1.0 }], ds, t12, null, null);
|
||||||
|
assertClose(extension.find((p) => p.timestamp === t12).e2, m12 * RHO_LAST, 1e-9);
|
||||||
|
|
||||||
|
// La FORME suit l'injection EV : montée nette après le 10 j (Tmax ~45 h)
|
||||||
|
const at8 = extension.find((p) => p.timestamp === 8 * DAY_MS).e2;
|
||||||
|
const peak = Math.max(...extension
|
||||||
|
.filter((p) => p.timestamp >= 10 * DAY_MS && p.timestamp <= 14 * DAY_MS)
|
||||||
|
.map((p) => p.e2));
|
||||||
|
assert.ok(peak > at8 * 1.3, `le pic EV (${peak}) doit dépasser le niveau pré-injection (${at8})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extension désactivée - rien après le dernier lab (comportement initial)', () => {
|
||||||
|
const tr = treatment();
|
||||||
|
const labs = anchoredLabs(tr, doses());
|
||||||
|
const byDefault = computeLabAnchoredCurve([tr], doses(), labs, 0, 60 * DAY_MS, HOUR_MS);
|
||||||
|
const explicitOff = computeLabAnchoredCurve([tr], doses(), labs, 0, 60 * DAY_MS, HOUR_MS, null, false);
|
||||||
|
assert.deepEqual(byDefault.points, explicitOff.points);
|
||||||
|
assert.ok(byDefault.points.every((p) => p.timestamp <= 7 * DAY_MS));
|
||||||
|
assert.equal(byDefault.lastAnchorMs, 7 * DAY_MS, 'lastAnchorMs positionné même sans prolongation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extension - un lab non significatif ne prolonge pas (garde 61)', () => {
|
||||||
|
const tr = treatment();
|
||||||
|
const labs = anchoredLabs(tr, doses()).concat([lab(3000, 60 * DAY_MS)]);
|
||||||
|
const curve = computeLabAnchoredCurve([tr], doses(), labs, 0, 90 * DAY_MS, HOUR_MS, null, true);
|
||||||
|
assert.equal(curve.anchoredLabs, 2, 'le lab tardif reste rejeté');
|
||||||
|
assert.equal(curve.lastAnchorMs, 7 * DAY_MS, 'la prolongation part du dernier lab SIGNIFICATIF');
|
||||||
|
assert.ok(curve.points.some((p) => p.timestamp > 7 * DAY_MS), 'des points prolongés existent après le dernier ancre');
|
||||||
|
assert.ok(curve.points.every((p) => p.e2 <= 600), 'aucun point ne suit la valeur absurde du lab rejeté');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extension - demande finissant avant le dernier lab ne prolonge rien', () => {
|
||||||
|
const tr = treatment();
|
||||||
|
const curve = computeLabAnchoredCurve([tr], doses(), anchoredLabs(tr, doses()), 0, 5 * DAY_MS, HOUR_MS, null, true);
|
||||||
|
assert.ok(curve.points.every((p) => p.timestamp <= 5 * DAY_MS));
|
||||||
|
assert.ok(curve.points.every((p) => p.timestamp <= 7 * DAY_MS));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extensionHorizonEndMs - gardes (aucune dose ou modèle déjà éteint)', () => {
|
||||||
|
const tr = treatment();
|
||||||
|
const trs = [tr];
|
||||||
|
assert.equal(extensionHorizonEndMs(trs, [], 7 * DAY_MS), null, 'aucune dose E2 → rien à prolonger');
|
||||||
|
// Dose à t=0 → horizon = cutoff du traitement (EEn TFS : 10 t½ term.)
|
||||||
|
const expected = cutoffHours(tr) * HOUR_MS;
|
||||||
|
assert.equal(extensionHorizonEndMs(trs, doses(), 7 * DAY_MS), expected);
|
||||||
|
// Modèle DÉJÀ éteint au dernier lab → null (retour fenêtre initiale)
|
||||||
|
assert.equal(extensionHorizonEndMs(trs, doses(), expected + HOUR_MS), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lastAnchorMs - null si moins de 2 ancres, positionné sinon', () => {
|
||||||
|
const tr = treatment();
|
||||||
|
const one = computeLabAnchoredCurve([tr], doses(), [lab(200, 3 * DAY_MS)], 0, 30 * DAY_MS, HOUR_MS);
|
||||||
|
assert.equal(one.points.length, 0);
|
||||||
|
assert.equal(one.lastAnchorMs, null);
|
||||||
|
const two = computeLabAnchoredCurve([tr], doses(), [lab(300, 2 * DAY_MS), lab(260, 7 * DAY_MS)], 0, 30 * DAY_MS, HOUR_MS);
|
||||||
|
assert.equal(two.lastAnchorMs, 7 * DAY_MS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user