- js/pk/lab-timing.js : miroir fidèle de pk/LabTiming.kt — creux prévisionnel (minimum de la courbe E2 entre 2 injections), saut au premier creux STABILISÉ (5 × t½ terminale : TFS/WHS analytiques, Estrannaise lue dans la table via le nouveau pkProfileStore.terminalHalfLifeDays — refactor de la pente d'extrapolation de sample(), zéro duplication), filtres honnêtes (injectable actif + Posologie requis, creux ≥ now+6 h et jamais déjà mesuré, horizon borné → null), shouldSuggestPosology (invite quand un injectable actif est sans Posologie — demande v1.8.0). - labs.js : encart « Prochaine prise de sang (suggestion) » ou invite, mutuellement exclusifs (miroir carte Android). - i18n FR/EN (labrec_*), WEB_VERSION 1.8.0, assertions E2E. - tests/lab-timing.test.js : 7 tests miroirs (152 verts au total) + E2E (encart rendu : titre, creux daté, créneau, stabilisation). - Doc : §4 arbre, §8 suite + E2E, CHANGELOG [1.8.0], README.
155 lines
7.5 KiB
JavaScript
155 lines
7.5 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 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 } 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 = Math.min(...myDoses.map((d) => d.timestamp));
|
|
const stabilizedAtMs = regimeStartMs + Math.round(STABILIZATION_HALF_LIVES * tHalfDays * 24 * HOUR_MS);
|
|
|
|
// ── 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: carrier.esterType,
|
|
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}
|
|
*/
|
|
export function shouldSuggestPosology(treatments) {
|
|
return treatments.some((t) =>
|
|
t.type === 'ESTRADIOL'
|
|
&& t.isActive
|
|
&& usesProfileModel(t)
|
|
&& (t.forecastIntervalDays ?? 0) <= 0
|
|
);
|
|
}
|