La règle de stabilisation comparait l'écart inter-doses EXACTEMENT à l'écart précédent : un log 30 min plus tard cassait le régime et repoussait la stabilisation de 5 × t½ à chaque injection (creux fuyant). Fix : chaque écart doit rester dans l'intervalle de Posologie ± 24 h (GAP_TOLERANCE_MS, fenêtre vs l'intervalle THÉORIQUE) — des logs à 6,8 j puis 7,2 j ne se déstabilisent plus en cascade ; un vrai changement de créneau reste hors fenêtre. Tests : « interval change » ré-épinglé (+21 j) + 2 tests de tolérance miroirs (12 verts lab-timing). WEB_VERSION 1.9.7 + CHANGELOG/README.
228 lines
11 KiB
JavaScript
228 lines
11 KiB
JavaScript
/**
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
* 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 pour les trois modèles (`terminalHalfLifeDays`
|
||
* — v1.9.0 : Estrannaise analytique aussi, plus aucune lecture de table) ;
|
||
* - 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 * as EstrannaiseModels from './estrannaise-models.js';
|
||
import { model as tfsModel } from './transfem-science-models.js';
|
||
import { model as whsahModel } from './whsah-models.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;
|
||
// v1.9.0 : ESE analytique (estrannaise.js) — t½ terminale analytique
|
||
else if (carrier.pkModel === 'ESE') tHalfDays = EstrannaiseModels.model(carrier.esterType)?.terminalHalfLifeDays ?? null;
|
||
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
|
||
}
|
||
|
||
/**
|
||
* 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).
|
||
*
|
||
* v1.9.7 (miroir du Kotlin, remontée : « la suggestion change tout le temps
|
||
* à chaque injection si l'injection n'est pas faite pile à la même heure ») :
|
||
* la comparaison EXACTE cassait le régime pour un log à 12:30 au lieu de
|
||
* 12:00 — chaque écart doit rester dans l'INTERVALLE DE POSOLOGIE ± 24 h
|
||
* (« je m'injecte le même jour, à l'heure près »). La fenêtre se réfère à
|
||
* l'intervalle THÉORIQUE, pas à l'écart précédent : des logs à 6,8 j puis
|
||
* 7,2 j ne se déstabilisent plus en cascade. Un vrai changement (2 j au lieu
|
||
* de 7 j) reste hors fenêtre.
|
||
*
|
||
* @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];
|
||
// Fenêtre d'écart = INTERVALLE DE POSOLOGIE ± 24 h (la Posologie est un
|
||
// prérequis de la carte : absente → on s'arrête à la dernière dose).
|
||
const intervalDays = carrier.forecastIntervalDays;
|
||
if (!intervalDays || intervalDays <= 0) return regimeStart;
|
||
const intervalMs = Math.round(intervalDays * 24 * HOUR_MS);
|
||
const gapMin = intervalMs - GAP_TOLERANCE_MS;
|
||
const gapMax = intervalMs + GAP_TOLERANCE_MS;
|
||
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;
|
||
// gap > 0 : un doublon de log n'est jamais un régime
|
||
const sameGap = gap > 0 && gap >= gapMin && gap <= gapMax;
|
||
if (!sameMarker || !sameGap) break;
|
||
regimeStart = prev.timestamp;
|
||
current = prev;
|
||
idx--;
|
||
}
|
||
return regimeStart;
|
||
}
|
||
|
||
/** Tolérance de l'écart inter-doses (v1.9.7, miroir GAP_TOLERANCE_MS Kotlin) :
|
||
* ± 24 h autour de l'intervalle de Posologie — un log à 12:30 au lieu de
|
||
* 12:00 ne repousse plus la stabilisation de 5 × t½. */
|
||
const GAP_TOLERANCE_MS = 24 * HOUR_MS;
|
||
|
||
/**
|
||
* 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}
|
||
*/
|
||
export function shouldSuggestPosology(treatments) {
|
||
return treatments.some((t) =>
|
||
t.type === 'ESTRADIOL'
|
||
&& t.isActive
|
||
&& usesProfileModel(t)
|
||
&& (t.forecastIntervalDays ?? 0) <= 0
|
||
);
|
||
}
|