HormoneTrack-web/js/pk/lab-timing.js
Siphonight b865b1606f v1.10.0 (web) : fix #68 — creux pré-injection dans la reco de prise de sang (miroir Android)
Le « creux » était le minimum de la fenêtre entière (créneau N−1 → N) :
pour un ester à montée lente (EEn, pic ~J+5 ≈ intervalle 7 j) il tombait
juste après l'injection PRÉCÉDENTE — date antérieure à la stabilisation
affichée sur la même carte, et « juste avant ton injection du … » faux de
plusieurs jours. Le creux est désormais le niveau PRÉ-INJECTION du créneau
(principe v1.8.0 restauré ; EV inchangé — le point pré-injection EST son
minimum de fenêtre). WEB_VERSION alignée sur 1.10.0.

180 tests + E2E verts (check.sh) ; suite lab-timing inchangée (sa
sémantique épinglée était celle du fix).
2026-09-27 23:10:10 +02:00

235 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ─────────────────────────────────────────────────────────────────────────────
* 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) {
// ⚠️ FIX #68 (v1.10.0, miroir du Kotlin) : le creux = niveau
// PRÉ-INJECTION du créneau (dernier point de courbe avant l'injection —
// principe 1, « le moment le plus comparable »). L'ancien MINIMUM DE LA
// FENÊTRE ENTIÈRE tombait au DÉBUT de la fenêtre pour les esters à
// montée lente (EEn : pic ~J+5 ≈ intervalle 7 j → le minimum local est
// le creux d'absorption, quelques heures après l'injection PRÉCÉDENTE) :
// la date proposée précédait la stabilisation affichée sur la MÊME
// carte, et le texte « juste avant ton injection du … » était faux de
// plusieurs jours (remontée v1.9.8). Pour les esters à t½ courte (EV),
// le point pré-injection EST le minimum de fenêtre : inchangé.
let trough = null;
for (const p of curve) {
if (p.timestamp > windowStart && p.timestamp < slot.timestamp) trough = p;
}
if (
trough !== null
&& trough.timestamp >= minTroughMs
&& trough.timestamp > lastLabMs
&& trough.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
);
}