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