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).
This commit is contained in:
Siphonight 2026-09-11 16:17:36 +02:00
parent e078650045
commit 6088543784
12 changed files with 330 additions and 8 deletions

View File

@ -17,7 +17,7 @@
> Fie-toi toujours à tes analyses de sang et aux consignes de ton
> endocrinologue.
- **Statut** : web v1.4.10 — portage de l'Android v1.4.10 · **121 tests verts** ·
- **Statut** : web v1.5.0 — portage de l'Android v1.5.0 · **132 tests verts** ·
E2E navigateur ✅ · conteneur testé en configuration durcie ✅ · lint/i18n ✅
- **Journal des versions web** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Releases** : [gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases](https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases)
@ -113,6 +113,8 @@ machine qui sert les fichiers.
(auto-calibration optionnelle, désactivée par défaut + bouton manuel).
- **Seuils d'alerte** configurables (E2/T haut/bas) avec notification
navigateur et anti-spam.
- **« Tracé labs » (v1.5.0)** : courbe hybride ancrée sur tes labs
(chip `Tracé labs`, off par défaut) — miroir strict de l'Android v1.5.0
- **Rappels** via les notifications du navigateur (actions « Loguer
maintenant » / « Reporter 1 h ») — tant que la page est ouverte, cf
[limites](#limites-vs-lapp-android).

View File

@ -5,6 +5,24 @@
> section la plus récente EN PREMIER ; ce fichier est lu par le dialog
> « Nouveautés » de l'app web (fetch `docs/CHANGELOG.md`).
## [1.5.0] — 2026-09-11
### « Tracé labs » — portage de la version Android v1.5.0 (versions synchronisées)
- **Nouvelle courbe superposable** (chip `Tracé labs`, **off par défaut**)
: `courbe(t) = M(t) × ρ(t)` — le modèle PK garde la forme biologique
(Tmax, queues, accumulation), l'amplitude est recalée sur CHAQUE
prise de sang — **passage EXACT garanti** sur chaque lab, transition
log-linéaire d'un lab au suivant (jamais de saut vertical).
- Garde anti-ratés (#61) : un lab hors fenêtre d'action n'est pas un
ancrage ; E2 uniquement ; fenêtre bornée [1er ; dernier lab] — la
courbe s'arrête au dernier lab (pas d'extrapolation).
- Indépendante de la calibration (sa propre référence) ; hors
pics/creux ; n'entre JAMAIS dans niveau accueil/alertes.
- La rangée de chips du graphique défile horizontalement (4ᵉ chip —
miroir du fix Android v1.5.0).
- 11 tests miroirs de `LabTrajectoryModelTest.kt` (Android).
## [1.4.10] — 2026-09-08
Version initiale de la version web — portage navigateur de l'app Android

View File

@ -159,7 +159,11 @@ HormoneTrack-web/ (dépôt séparé)
│ │ ├── transfem-science-models.js V3C méta-analyse TFS (7 esters, params
│ │ │ copiés à l'identique du Kotlin)
│ │ ├── whsah-models.js fit WHSAH de Mona (6 esters, params identiques)
│ │ ├── alerts.js Alerts.kt : seuils opt-in, codec d'état,
│ │ ├── lab-trajectory-model.js « Tracé labs » (v1.5.0) : courbe hybride
│ │ M(t)×ρ(t) ancrée sur les labs (miroir strict
│ │ de pk/LabTrajectoryModel.kt — garde #61,
│ │ ρ log-linéaire, fenêtre labs)
│ ├── alerts.js Alerts.kt : seuils opt-in, codec d'état,
│ │ │ shouldNotify (anti-spam)
│ │ ├── presets.js PKPresets.kt (22 presets, clés i18n)
│ │ ├── chart-helpers.kt fonctions pures de CurveChart.kt :
@ -391,6 +395,9 @@ launch) — d'où le build dédié dans le cache Playwright, hors dépôt.
- **calibration** : par période d'ester, **par modèle** (#60, chaque courbe
calibrée passe par le lab), garde #61 (labs hors fenêtre exclus des DEUX
pipelines — jamais de médiane contaminée), k T contre l'E2 CALIBRÉE.
- **lab-trajectory-model** : v1.5.0 « Tracé labs » — miroir du Kotlin
(passage exact, ρ log-linéaire, garde #61, fenêtres, indépendance du
scaleFactor, modelOverride, doublons, bornes).
- **backup** : round-trip v2, rétrocompat v1, écrasement + IDs conservés,
**imports des exports Android réels** (data-driven, hors dépôt).
- **chart-helpers** : cumul fractionnaire du pan (#62), minuit local (#55),
@ -693,7 +700,7 @@ Structurels (plateforme), pas des bugs :
§16.ter de la doc mère).
---
*Doc mise à jour le 8 sept. 2026 (web v1.4.10, dépôt séparé
*Doc mise à jour le 11 sept. 2026 (web v1.5.0 — sync Android, dépôt séparé
HormoneTrack-web) — portage de l'Android v1.4.10, 121 tests verts + E2E,
backup compatible bidirectionnel, historique propre (aucune donnée de
santée, aucune dette de confidentialité), versions alignées sur

View File

@ -8,6 +8,8 @@ export * from './pk-engine.js';
export * from './pk-calibration.js';
export * from './pk-reminders.js';
export * from './pk-extrema.js';
export * as LabTrajectoryModel from './lab-trajectory-model.js';
export { computeLabAnchoredCurve, ratioAt } from './lab-trajectory-model.js';
export * from './chart-helpers.js';
export * as Alerts from './alerts.js';
export * as PKProfileStore from './pk-profile-store.js';

View File

@ -0,0 +1,112 @@
/**
* ─────────────────────────────────────────────────────────────────────────────
* « 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
}

View File

@ -36,10 +36,18 @@ export const COLORS = {
grid: '#cac4d0',
label: '#6b7280',
nowLine: '#2fa48d',
chartLabTrajectory: '#c2185b', // v1.5.0 — Tracé labs (rose foncé)
forecastMarker: 'rgba(47,164,141,0.85)',
realDoseMarker: 'rgba(73,69,79,0.55)',
};
/**
* Une série objet : { points, e2Style:{color,dashed}, tStyle|null, showExtrema }
* v1.5.0 : `showExtrema=false` → hors pics/creux (la série « Tracé labs »
* épouse l'interp ρ, pas la physiologie du modèle).
*/
/**
* Dessine le graphique dans un canvas.
*
@ -195,7 +203,7 @@ export function drawChart(canvas, series, e2Labs, tLabs, options, forecastDoses
}
// Pics/creux (v1.2.3/v1.2.6) : triangles + valeur estimée
if (options.showExtrema) {
if (options.showExtrema && (s.showExtrema !== false)) {
for (const ex of detectExtrema(s.points, (p) => p.e2, 2.0)) {
drawExtremum(ctx, xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color);
}

View File

@ -26,6 +26,7 @@
import {
computeCurve, generateForecastDoses, autoCalibrated, TConfig,
computeLabAnchoredCurve,
} from '../pk/index.js';
import {
stepForRange, defaultModelToggles, panDeltaHours, clampPanHours,
@ -74,6 +75,7 @@ export function renderChart(container, ctx) {
showWhs: false,
showForecast: false,
showExtrema: false,
showLabTrack: false, // v1.5.0 — courbe ancrée sur les labs, OFF par défaut (choix v1.5.0)
panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62)
togglesInitialized: false,
};
@ -204,9 +206,21 @@ export function renderChart(container, ctx) {
modelRow.appendChild(filterChip(t('model_ese'), state.showEse, () => { state.showEse = !state.showEse; redraw(); }));
modelRow.appendChild(filterChip(t('model_tfs'), state.showTfs, () => { state.showTfs = !state.showTfs; redraw(); }));
modelRow.appendChild(filterChip(t('model_whs'), state.showWhs, () => { state.showWhs = !state.showWhs; redraw(); }));
// v1.5.0 : courbe hybride ancrée sur les labs (off par défaut)
modelRow.appendChild(filterChip(t('show_lab_track'), state.showLabTrack, () => { state.showLabTrack = !state.showLabTrack; redraw(); }));
// ── Courbes par modèle sélectionné ─────────────────────────────────────
const curves = [];
// ── v1.5.0 : courbe « Tracé labs » ───────────────────────────────────────
// Indépendante des toggles modèles (modèle STOCKÉ — sémantique Home) et
// de la calibration (elle EST sa propre calibration continue — épinglé).
if (state.showLabTrack) {
const step = stepForRange(state.rangeHours);
const anchored = computeLabAnchoredCurve(
treatments, doseLogs, labResults, startMs, endMs, step, null,
);
curves.push(['LAB', anchored.points]);
}
if (state.showEse || state.showTfs || state.showWhs) {
// Home utilise le modèle stocké ; le graphique calibre chaque modèle
// avec SA prédiction (fix #60). Référence = auto du modèle TFS (les
@ -269,6 +283,11 @@ export function renderChart(container, ctx) {
drawChart(
canvas,
curves.map(([model, pts]) => {
if (model === 'LAB') {
// v1.5.0 : Tracé labs = E2 rose foncé pointillé, PAS de courbe T
// (série E2-only) et hors pics/creux (showExtrema=false)
return { points: pts, e2Style: { color: COLORS.chartLabTrajectory, dashed: true }, tStyle: null, showExtrema: false };
}
const st = seriesStyles(model);
return { points: pts, e2Style: st.e2, tStyle: st.t };
}),
@ -291,6 +310,9 @@ export function renderChart(container, ctx) {
// Légende dynamique (miroir du bloc Compose)
for (const [model] of curves) {
// ⚠️ La clé 'LAB' est SKIPPÉE (légende dédiée plus bas, fix #63
// Android-miroir : sinon le `else` imprime la légende TFS en doublon)
if (model === 'LAB') continue;
const st = seriesStyles(model);
if (model === 'ESE') {
legend.appendChild(legendLine(t('legend_ese_e2'), st.e2.color));
@ -303,9 +325,16 @@ export function renderChart(container, ctx) {
if (state.showT) legend.appendChild(legendLine(t('legend_tfs_t'), st.t.color));
}
}
// Légende modèles : la clé 'LAB' est SKIPPÉE (la boucle for-of ne
// gère que ESE/TFS/WHS — pas de catchall pour LAB, sinon légende
// dupliquée : miroir de la leçon #63 Android)
if (state.showLabs) legend.appendChild(legendLine(t('legend_labs'), COLORS.labDot));
if (state.showForecast && horizonHours !== null) legend.appendChild(legendLine(t('legend_forecast_dose'), COLORS.nowLine));
if (state.showExtrema) legend.appendChild(el('div', { class: 'legend-muted' }, t('legend_extrema')));
// v1.5.0 : légende dédiée « Tracé labs » (génère rien si la série vide)
if (state.showLabTrack && curves.some(([m, pts]) => m === 'LAB' && pts.length >= 2)) {
legend.appendChild(legendLine(t('legend_lab_track'), COLORS.chartLabTrajectory));
}
legend.appendChild(el('div', { class: 'legend-muted' }, t('drag_hint')));
}

View File

@ -31,7 +31,7 @@ import { el, showDialog, field, switchRow, filterChip, toast } from './component
* (APK + web au MÊME numéro, tags appariés `vX.Y.Z` + `vX.Y.Z-web`).
* v1.4.10 = portage initial de l'Android v1.4.10.
*/
export const WEB_VERSION = '1.4.10';
export const WEB_VERSION = '1.5.0';
/** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */
function parseNumOrNull(text) {

View File

@ -181,6 +181,7 @@ const EN = {
show_labs: 'Labs',
show_forecast: 'Forecast',
show_extrema: 'Peaks & troughs',
show_lab_track: 'Lab track',
back_to_present: 'Back to present',
legend_ese_e2: 'E2 — Estrannaise (blue)',
legend_ese_t: 'T — Estrannaise (pink)',
@ -191,6 +192,7 @@ const EN = {
legend_labs: '● labs (orange) — circle = E2, square = T',
legend_forecast_dose: 'Dashed line + ▼ = projected dose',
legend_extrema: '▲▼ = local peaks & troughs (with estimated values)',
legend_lab_track: 'Lab-anchored curve — model shape forced through your labs (E2 only, between first and last lab)',
drag_hint: 'Drag horizontally to pan (right = past, left = future with forecast). Pinch or − / + to zoom (6 h → 300 d).',
// Rappels (bannière + notification)
reminder_notif_title: 'HormoneTrack — dose reminder',
@ -357,6 +359,7 @@ const FR = {
show_labs: 'Labs',
show_forecast: 'Prévision',
show_extrema: 'Pics & creux',
show_lab_track: 'Tracé labs',
back_to_present: 'Revenir à maintenant',
legend_ese_e2: 'E2 — Estrannaise (bleu)',
legend_ese_t: 'T — Estrannaise (rose)',
@ -367,6 +370,7 @@ const FR = {
legend_labs: '● labs (orange) — cercle = E2, carré = T',
legend_forecast_dose: 'Ligne pointillée + ▼ = dose projetée',
legend_extrema: '▲▼ = pics/creux locaux (avec valeurs estimées)',
legend_lab_track: 'E2 ancrée sur tes labs — forme du modèle forcée de passer par chaque prise de sang (entre le 1er et le dernier lab)',
drag_hint: 'Glisser horizontalement pour panoramique (droite = passé, gauche = futur avec la prévision). Pincer ou − / + pour zoomer (6 h → 300 j).',
reminder_notif_title: 'HormoneTrack — rappel de dose',
reminder_notif_body: '{0} · {1}{2} — prévu à {3}',

View File

@ -1,6 +1,6 @@
{
"name": "hormonetrack-web",
"version": "1.4.10",
"version": "1.5.0",
"description": "HormoneTrack — version web (100% locale, localStorage). Portage navigateur de l'app Android.",
"private": true,
"type": "module",

View File

@ -131,7 +131,7 @@ async function main() {
ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)');
if (dialog) {
const dlgText = await dialog.textContent();
ok(dlgText.includes('Version 1.4.10'), 'dialog : section de la version courante');
ok(dlgText.includes('Version 1.5.0'), 'dialog : section de la version courante');
const hasRenderedMd = await dialog.$('.md-body h3, .md-body strong');
ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut');
await page.click('.dialog .actions button');
@ -192,7 +192,7 @@ async function main() {
await page.waitForTimeout(300);
text = await page.textContent('main.content');
ok(text.includes('Langue'), 'Paramètres : carte langue');
ok(text.includes('Version 1.4.10 (web)'), 'Paramètres : version affichée');
ok(text.includes('Version 1.5.0 (web)'), 'Paramètres : version affichée');
ok(text.includes('100 % local'), 'Paramètres : note vie privée');
await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') });

View File

@ -0,0 +1,140 @@
/**
* Tests « Tracé labs » web (miroir de `LabTrajectoryModelTest.kt` Android)
* — mêmes garanties épinglées : passage exact, ρ log-linéaire, garde #61,
* fenêtre bornée aux labs, indépendance du scaleFactor.
*/
import { test, describe, before } from 'node:test';
import assert from 'node:assert/strict';
import {
initProfiles, makeTreatment, makeDose, makeLab, HOUR_MS, DAY_MS, assertClose,
} from './helpers.js';
import { e2At } from '../js/pk/pk-engine.js';
import {
computeLabAnchoredCurve, ratioAt,
} from '../js/pk/lab-trajectory-model.js';
/** Traitement injectable EEn/TFS 5 mg, une dose à t=0, scaleFactor paramétrable. */
function treatment(scaleFactor = 1.0) {
return makeTreatment({ scaleFactor });
}
function doses() {
return [makeDose(0, 5)];
}
const lab = (value, ts) => makeLab('E2', value, 'pg/mL', ts);
const tLab = (ts) => makeLab('T', 0.35, 'ng/mL', ts);
describe('LabTrajectoryModel web (miroir Android v1.5.0)', () => {
before(() => initProfiles());
test('passe exactement sur chaque lab', () => {
const t2 = 2 * DAY_MS;
const t7 = 7 * DAY_MS;
const labs = [lab(300, t2), lab(260, t7)];
const curve = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
assert.equal(curve.anchoredLabs, 2);
assertClose(curve.points.find((p) => p.timestamp === t2).e2, 300, 1e-6, 'ancrage lab 1');
assertClose(curve.points.find((p) => p.timestamp === t7).e2, 260, 1e-6, 'ancrage lab 2');
});
test('rho interpole en log entre deux labs (math exacte)', () => {
const t2 = 2 * DAY_MS;
const t7 = 7 * DAY_MS;
const tr = treatment();
// Labs DATA-DRIVEN (miroir du fix du test Kotlin : M(2 d) ≠ le pic de référence)
const m2 = e2At([tr], doses(), t2, null, null);
const m7 = e2At([tr], doses(), t7, null, null);
const labs = [lab(0.9 * m2, t2), lab(1.2 * m7, t7)];
// 1) La fonction PURE ratioAt interpole exactement
const pure = [[t2, 0.9], [t7, 1.2]];
const expectedMid = Math.exp((Math.log(0.9) + Math.log(1.2)) / 2.0);
assertClose(ratioAt(pure, t2), 0.9, 1e-12);
assertClose(ratioAt(pure, t7), 1.2, 1e-12);
assertClose(ratioAt(pure, (t2 + t7) / 2), expectedMid, 1e-9);
// 2) La courbe restitue le même ρ
const curve = computeLabAnchoredCurve([tr], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
const mid = curve.points[Math.trunc(curve.points.length / 2)];
const m = e2At([{ ...tr, scaleFactor: 1.0 }], doses(), mid.timestamp, null, null);
assertClose(mid.e2 / m, expectedMid, 1e-9);
});
test('un seul lab significatif donne une courbe vide', () => {
const curve = computeLabAnchoredCurve([treatment()], doses(),
[lab(200, 3 * DAY_MS)], 0, 30 * DAY_MS, HOUR_MS);
assert.equal(curve.points.length, 0);
assert.equal(curve.anchoredLabs, 1, 'le lab compte mais ne suffit pas');
});
test('garde 61 - un lab hors fenêtre d action n est pas un ancrage', () => {
const t2 = 2 * DAY_MS;
const t7 = 7 * DAY_MS;
const curve = computeLabAnchoredCurve([treatment()], doses(),
[lab(300, t2), lab(260, t7), lab(3000, 60 * DAY_MS)], 0, 90 * DAY_MS, HOUR_MS);
assert.equal(curve.anchoredLabs, 2, 'le lab tardif est rejeté');
assert.ok(curve.points.every((p) => p.e2 <= 600), 'aucun point ne suit la valeur absurde');
assert.ok(curve.points.every((p) => p.timestamp <= t7), 'fenêtre bornée au dernier lab significatif');
});
test('labs avant la première dose ne produisent aucun ancrage', () => {
const curve = computeLabAnchoredCurve([treatment()], doses(),
[lab(200, -10 * DAY_MS), lab(210, -5 * DAY_MS)], -15 * DAY_MS, 0, HOUR_MS);
assert.equal(curve.points.length, 0);
assert.equal(curve.anchoredLabs, 0);
});
test('la fenêtre du tracé est bornée par les labs et par la demande', () => {
const t2 = 2 * DAY_MS;
const t7 = 7 * DAY_MS;
const labs = [lab(160, t2), lab(140, t7)];
const wide = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 60 * DAY_MS, HOUR_MS);
assert.ok(wide.points.length > 0);
assert.ok(wide.points.every((p) => p.timestamp >= t2 && p.timestamp <= t7));
const narrow = computeLabAnchoredCurve([treatment()], doses(), labs, 3 * DAY_MS, 5 * DAY_MS, HOUR_MS);
assert.ok(narrow.points.length > 0);
assert.ok(narrow.points.every((p) => p.timestamp >= 3 * DAY_MS && p.timestamp <= 5 * DAY_MS));
});
test('indépendant du scaleFactor stocké (sinon double correction)', () => {
const labs = [lab(300, 2 * DAY_MS), lab(260, 7 * DAY_MS)];
const a = computeLabAnchoredCurve([treatment(1.0)], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
const b = computeLabAnchoredCurve([treatment(0.55)], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
assert.deepEqual(a.points, b.points, 'M diffère mais ρ compense exactement');
});
test('modelOverride - chaque modèle ancre avec sa prédiction (sémantique 60)', () => {
// ESE vs WHS (ESE≈TFS quasi identiques par construction — seuil doc §8)
const trEv = { ...treatment(), esterType: 'EV', route: 'INJECTION_IM' };
const labs = [lab(320, 2 * DAY_MS), lab(240, 7 * DAY_MS)];
const ese = computeLabAnchoredCurve([trEv], doses(), labs, 0, 30 * DAY_MS, HOUR_MS, 'ESE');
const whs = computeLabAnchoredCurve([trEv], doses(), labs, 0, 30 * DAY_MS, HOUR_MS, 'WHS');
assertClose(ese.points.find((p) => p.timestamp === 2 * DAY_MS).e2, 320, 1e-6);
assertClose(whs.points.find((p) => p.timestamp === 2 * DAY_MS).e2, 320, 1e-6);
const midEse = ese.points[Math.trunc(ese.points.length / 2)].e2;
const midWhs = whs.points[Math.trunc(whs.points.length / 2)].e2;
const relDiff = Math.abs(midEse - midWhs) / Math.max(Math.abs(midEse), Math.abs(midWhs));
assert.ok(relDiff > 0.02, `empreinte intermédiaire ESE=${midEse} vs WHS=${midWhs}`);
});
test('les labs T sont ignorés (E2 uniquement)', () => {
const labs = [lab(300, 2 * DAY_MS), tLab(3 * DAY_MS), lab(240, 7 * DAY_MS)];
const curve = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
assert.equal(curve.anchoredLabs, 2);
});
test('doublons de timestamp - le deuxième ratio gagne', () => {
const t = 5 * DAY_MS;
const anchors = [[t, 1.0], [t, 2.0], [10 * DAY_MS, 3.0]];
assertClose(ratioAt(anchors, 10 * DAY_MS), 3.0, 1e-12);
const expected = Math.exp(Math.log(2) + (7 - 5) / 5 * (Math.log(3) - Math.log(2)));
assertClose(ratioAt(anchors, 7 * DAY_MS), expected, 1e-9);
});
test('ratioAt - gardes defensives aux bornes', () => {
const anchors = [[0, 1.0], [10, 2.0]];
assertClose(ratioAt(anchors, -5), 1.0, 1e-12);
assertClose(ratioAt(anchors, 100), 2.0, 1e-12);
});
});