/** * NUAGE D'INCERTITUDE (v1.9.0, miroir fidèle de `pk/EstrannaiseCloud.kt`) — * feature **exclusive au modèle ESE**. * * SOURCE : Estrannaise publie le **posterior MCMC** de ses paramètres * (313 échantillons `(d, k1, k2, k3)` par ester injectable — cf * js/pk/estrannaise-models.js). Le graphique dessine un **nuage diffus** : * M courbes (une par échantillon échelonné) qui montrent la plage * d'imprécision possible du modèle — l'affichage du site estrannaise. * TFS/WHSAH ne publient pas de posterior → pas de nuage (choix confirmé). * * QUELLES DOSES LE NUAGE COUVRE-T-IL (v1.9.2) : **toutes les doses E2 à * profil injectable dont l'ESTER EFFECTIF (override compris) est couvert * par le fit Estrannaise** — c'est exactement l'ensemble des doses que la * courbe ESE trace (`computeCurve(modelOverride = "ESE")`). Le `pkModel` * STOCKÉ du traitement n'importe pas : quand le chip Nuage est actif avec * ESE affiché, la courbe ESE redessine toutes ces doses avec le modèle * Estrannaise, et le nuage les redessine avec les paramètres MCMC de * LEUR ester. (Fix v1.9.2 : l'ancien filtre `pkModel stocké == ESE` * laissait le nuage vide dès qu'aucun traitement n'était stocké en ESE — * remontée : « le nuage ne s'active que autour du tracé émulé, pas autour * du modèle Estrannaise ». L'exclusivité ESE reste portée par le CHIP, * pas par le filtre des doses.) * * Les contributions Bateman (oral/gel — sans ester échantillonnable) ne * sont PAS dans le nuage : le posterior MCMC ne s'applique qu'aux esters * (limite documentée — le nuage montre l'incertitude du modèle * Estrannaise, pas le niveau total). * * DÉTERMINISME : échantillons **échelonnés** (stride, pas d'aléatoire) — * le nuage est identique à chaque affichage et testable en Node. * * PUR (aucun DOM) → testable en Node (tests/estrannaise-cloud.test.js). */ import * as EstrannaiseModels from './estrannaise-models.js'; import { usesProfileModel, doseEster } from './pk-engine.js'; /** Nombre de courbes du nuage (compromis étendue visuelle / coût ×32). */ export const DEFAULT_NB_CURVES = 32; /** Un creux à moins de 6 h n'est pas exploitable (réutilisé pour l'horizon). */ export const MIN_HORIZON_HOURS = 6; /** * Calcule le nuage d'incertitude. * * @param {object[]} treatments TOUS les traitements (actifs ET inactifs, §6.bis) * @param {object[]} doseLogs toutes les doses réelles * @param {number} startMs fenêtre demandée (celle du graphique) * @param {number} endMs * @param {number} stepMs pas de la grille (stepForRange) * @param {number} nowMs « maintenant » * @param {number} [nbCurves=32] * @returns {Array>} M courbes — * **vide** si rien n'est calculable : aucun traitement E2 actif à profil * ESE couvert, aucune dose, ou MCMC non chargé. */ export function compute( treatments, doseLogs, startMs, endMs, stepMs, nowMs, nbCurves = DEFAULT_NB_CURVES, /** * Calibration à appliquer au nuage (v1.9.1 — fix « le nuage ne s'active * que autour du tracé, pas autour du modèle Estrannaise ») : passer le * MÊME scalePerEster que la courbe ESE pour que le nuage ENTOURE la * courbe calibrée. */ scalePerEster = null, ) { if (startMs >= endMs || stepMs <= 0 || nbCurves < 2) return []; // Doses tracées par la courbe ESE (v1.9.2) : toutes les doses E2 à profil // injectable dont l'ESTER EFFECTIF (override compris) est couvert par le // fit Estrannaise — peu importe le pkModel STOCKÉ du traitement (fix : // quand ESE est affiché, computeCurve redessine toutes ces doses avec // modelOverride = "ESE" et le nuage doit les suivre). Les traitements // inactifs sont inclus (§6.bis) ; l'oral Bateman est naturellement exclu. const carrierDoses = []; const samplesByEster = new Map(); for (const dose of doseLogs) { if (dose.timestamp > endMs) continue; const tr = treatments.find((x) => x.id === dose.treatmentId); if (!tr || tr.type !== 'ESTRADIOL' || !usesProfileModel(tr)) continue; const ester = doseEster(tr, dose); if (!EstrannaiseModels.hasModel(ester)) continue; if (!samplesByEster.has(ester)) { const samples = EstrannaiseModels.mcmcSamples(ester); if (samples.length === 0) return []; // MCMC non chargé → pas de nuage samplesByEster.set(ester, samples); } carrierDoses.push([dose, tr]); } if (carrierDoses.length === 0) return []; const maxSamples = Math.min(...[...samplesByEster.values()].map((s) => s.length)); const stride = Math.max(1, Math.trunc(maxSamples / Math.min(nbCurves, maxSamples))); // Grille : de max(start, 1ʳᵉ dose du porteur) à endMs — rien avant la 1ʳᵉ dose const firstDoseMs = Math.min(...carrierDoses.map(([dose]) => dose.timestamp)); const t0 = Math.max(startMs, firstDoseMs); if (t0 > endMs) return []; // ── M courbes ────────────────────────────────────────────────────────────── const cloud = []; for (let m = 0; m < nbCurves; m++) { const sampleIdx = Math.min(m * stride, maxSamples - 1); const paramsByEster = new Map( [...samplesByEster.entries()].map(([ester, samples]) => [ ester, samples[Math.min(sampleIdx, samples.length - 1)], ]), ); const curve = []; for (let t = t0; t <= endMs; t += stepMs) { let e2 = 0.0; for (const [dose, tr] of carrierDoses) { if (dose.timestamp > t) continue; const dtH = (t - dose.timestamp) / 3600000; const ester = doseEster(tr, dose); // Même calibration que la courbe ESE (fix v1.9.1) const scale = scalePerEster?.[ester] ?? tr.scaleFactor; e2 += EstrannaiseModels.sampleWithParams( ester, dtH, paramsByEster.get(ester), ) * dose.doseAmount * scale; } curve.push({ timestamp: t, e2, t: 0.0 }); } if (curve.length >= 2) cloud.push(curve); } return cloud; }