/** * ───────────────────────────────────────────────────────────────────────────── * RECOMMANDATION DE PROCHAINE PRISE DE SANG (v1.8.0, miroir fidèle de * `pk/LabTiming.kt` Android) — affichée dans la page Analyses * (« Prochaine analyse recommandée »). * * PRINCIPE PHARMACOCINÉTIQUE (choix validé avec l'utilisatrice) : * 1. le **creux (trough) juste avant l'injection suivante** est le moment le * plus informatif et reproductible pour un ester injectable (le pic varie * énormément, le creux reflète le niveau de fond et l'accumulation) ; * 2. un creux n'est interprétable que si le régime est **stabilisé** — règle * des 5 demi-vies terminales (≈ 97 % de l'équilibre) ; * 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0). * * CALCUL (déduit de ce que l'app connaît déjà) : * - traitement porteur : ESTRADIOL + injectable + ACTIF + Posologie * (`forecastIntervalDays > 0`) — sinon null (la page Analyses affiche * alors l'invite « renseigne une Posologie ») ; * - t½ terminale : analytique TFS/WHSAH (`terminalHalfLifeDays`), lue dans * la TABLE pour Estrannaise (`pkProfileStore.terminalHalfLifeDays`) ; * - début du régime courant = 1ʳᵉ dose du traitement actif — PROXY assumé : * l'app ne trace pas l'historique des éditions de Posologie/dose ; * - creux = minimum local de la courbe E2 PRÉVISIONNELLE (doses réelles + * créneaux generateForecastDoses) entre deux injections — la FORME suffit * (calcul sans calibration : le facteur ne déplace pas le minimum) ; * - filtres : creux ≥ maintenant + 6 h, creux > dernière prise de sang * (jamais recommander un creux déjà mesuré), créneau ≥ stabilisation. * * LIMITES ASSUMÉES (doc web §7.11/§12) : le creux est une estimation du * modèle (disclaimer affiché) ; pas de Posologie → pas de recommandation ; * stabilisation hors horizon (ester ultra-long) → null (carte cachée). * * PUR (aucun DOM) → testable en Node (tests/lab-timing.test.js). * ───────────────────────────────────────────────────────────────────────────── */ import { computeCurve, generateForecastDoses, TConfig, usesProfileModel, doseEster } from './pk-engine.js'; import { model as tfsModel } from './transfem-science-models.js'; import { model as whsahModel } from './whsah-models.js'; import * as pkProfileStore from './pk-profile-store.js'; /** Règle de stabilisation : 5 demi-vies terminales (≈ 97 % de l'équilibre). */ export const STABILIZATION_HALF_LIVES = 5; /** Horizon max de créneaux explorés (ester ultra-long → null, carte cachée). */ export const MAX_FORECAST_INTERVALS = 12; /** Un creux à moins de 6 h n'est pas exploitable (organisation d'une prise). */ export const MIN_HORIZON_HOURS = 6; const HOUR_MS = 3600000; /** * Calcule la prochaine prise de sang recommandée, ou `null` si rien n'est * calculable honnêtement (l'appelant cache l'encart sur null, et peut * afficher l'invite « renseigne une Posologie » via [shouldSuggestPosology]). * * @param {object[]} treatments TOUS les traitements (actifs ET inactifs, §6.bis) * @param {object[]} doseLogs toutes les doses * @param {object[]} labs toutes les prises de sang existantes * @param {number} [nowMs=Date.now()] * @returns {{troughMs:number, injectionMs:number, ester:string, * terminalHalfLifeDays:number, regimeStartMs:number, stabilizedAtMs:number, * wasAlreadyStabilized:boolean}|null} */ export function nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs = Date.now()) { // ── 1) Traitement PORTEUR : E2 + injectable + actif + Posologie ─────────── const carrier = treatments.find((t) => t.type === 'ESTRADIOL' && t.isActive && usesProfileModel(t) && (t.forecastIntervalDays ?? 0) > 0 ); if (!carrier) return null; // ── 2) t½ terminale de l'ester (analytique ou lue dans la table) ────────── let tHalfDays = null; if (carrier.pkModel === 'TFS') tHalfDays = tfsModel(carrier.esterType)?.terminalHalfLifeDays ?? null; else if (carrier.pkModel === 'WHS') tHalfDays = whsahModel(carrier.esterType)?.terminalHalfLifeDays ?? null; else if (carrier.pkModel === 'ESE') tHalfDays = pkProfileStore.terminalHalfLifeDays(carrier.esterType, 'ESE'); if (tHalfDays === null || tHalfDays === undefined) return null; // ── 3) Début du régime courant + date de stabilisation ──────────────────── const myDoses = doseLogs.filter((d) => d.treatmentId === carrier.id); if (myDoses.length === 0) return null; const regimeStartMs = computeRegimeStartMs(carrier, myDoses); const stabilizedAtMs = regimeStartMs + Math.round(STABILIZATION_HALF_LIVES * tHalfDays * 24 * HOUR_MS); // Ester EFFECTIF de la dernière dose (override compris) : c'est lui qui // gouverne les creux futurs et le texte de la carte let lastDose = myDoses[0]; for (const d of myDoses) { if (d.timestamp > lastDose.timestamp) lastDose = d; } const activeEster = String(doseEster(carrier, lastDose)).toUpperCase(); // ── 4) Créneaux prévisionnels : assez pour couvrir la stabilisation ─────── const intervalDays = carrier.forecastIntervalDays; const intervalsNeeded = Math.min( MAX_FORECAST_INTERVALS, Math.max(3, Math.ceil((STABILIZATION_HALF_LIVES * tHalfDays) / intervalDays) + 1), ); const horizonMs = nowMs + Math.round(intervalsNeeded * intervalDays * 24 * HOUR_MS); const slots = generateForecastDoses(carrier, doseLogs, horizonMs, nowMs); if (slots.length === 0) return null; // ── 5) Courbe E2 fine (FORME brute — sans calibration) ──────────────────── const curve = computeCurve( treatments, doseLogs.concat(slots), nowMs, slots[slots.length - 1].timestamp, HOUR_MS, new TConfig(), {}, ); // ── 6) Premier creux STABILISÉ jamais mesuré ────────────────────────────── const lastLabMs = labs.length ? Math.max(...labs.map((l) => l.timestamp)) : 0; const minTroughMs = nowMs + MIN_HORIZON_HOURS * HOUR_MS; let windowStart = nowMs; for (const slot of slots) { // Creux du créneau = minimum de la courbe entre l'injection précédente // (ou maintenant) et ce créneau const window = curve.filter((p) => p.timestamp > windowStart && p.timestamp < slot.timestamp); let trough = null; for (const p of window) { if (trough === null || p.e2 < trough.e2) trough = p; } if ( trough !== null && trough.timestamp >= minTroughMs && trough.timestamp > lastLabMs && slot.timestamp >= stabilizedAtMs // sauter au premier creux stabilisé ) { return { troughMs: trough.timestamp, injectionMs: slot.timestamp, ester: activeEster, terminalHalfLifeDays: tHalfDays, regimeStartMs, stabilizedAtMs, wasAlreadyStabilized: stabilizedAtMs <= nowMs, }; } windowStart = slot.timestamp; } return null; // stabilisation hors horizon (ester ultra-long) → carte cachée } /** * Invite d'AFFICHAGE (v1.8.0, demande explicite) : existe-t-il un traitement * E2 injectable ACTIF SANS Posologie ? Si oui, la page Analyses suggère de la * renseigner pour recevoir des recommandations. Ne s'affiche QUE si aucune * recommandation n'est calculable. * * @param {object[]} treatments * @returns {boolean} */ /** * DÉBUT DU RÉGIME COURANT (v1.8.1 — correction du proxy v1.8.0, miroir du * Kotlin). Critique remontée : « changé d'ester, de dosage ET de posologie, * et l'app me disait stabilisée depuis février » — l'ancien proxy (1ʳᵉ dose * du traitement) ne voyait aucun de ces changements. * * NOUVELLE RÈGLE : le régime courant = la séquence terminale de doses où * (a) l'ESTER EFFECTIF et la DOSE (mg) sont identiques à la dose la plus * récente, ET (b) l'ÉCART entre doses consécutives est constant (= l'écart * des deux doses les plus récentes). On remonte depuis la dose la plus * récente tant que ces conditions tiennent. * * Pourquoi l'écart fait partie du régime : 7 j → 2 j → 9 j (posologie * modifiée/irrégulière) change le creux — conservateur assumé : une série * d'intervalles chaotiques maintient la carte « non stabilisée », ce qui est * pharmacocinétiquement vrai (le trough n'est comparable que sur un * intervalle régulier). Comparaison EXACTE (au ms) : 7 j ± 1 h casse le * régime, reflétant un vrai changement de créneau. * * @private * @param {object} carrier traitement porteur * @param {object[]} myDoses doses du porteur (ordre indifférent) * @returns {number} timestamp de la 1ʳᵉ dose du régime courant */ function computeRegimeStartMs(carrier, myDoses) { const sorted = myDoses.slice().sort((a, b) => a.timestamp - b.timestamp); let idx = sorted.length - 1; let regimeStart = sorted[idx].timestamp; let current = sorted[idx]; // Écart EXIGÉ pour remonter : celui des deux doses les plus récentes let gapExpected = -1; const DOSE_EPS = 1e-6; while (idx > 0) { const prev = sorted[idx - 1]; const gap = current.timestamp - prev.timestamp; const sameMarker = String(doseEster(carrier, current)) === String(doseEster(carrier, prev)) && Math.abs(current.doseAmount - prev.doseAmount) < DOSE_EPS; const sameGap = gapExpected < 0 || gap === gapExpected; if (!sameMarker || !sameGap) break; gapExpected = gap; regimeStart = prev.timestamp; current = prev; idx--; } return regimeStart; } export function shouldSuggestPosology(treatments) { return treatments.some((t) => t.type === 'ESTRADIOL' && t.isActive && usesProfileModel(t) && (t.forecastIntervalDays ?? 0) <= 0 ); }