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