/** * 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é). * * QU'EST-CE QUE LE NUAGE REPRÉSENTE : la superposition des doses des * traitements E2 **à profil ESE** évaluée avec UN échantillon MCMC par * courbe. Les contributions des autres traitements ne sont PAS incluses * (pas d'incertitude publiée pour eux — limite documentée §12). * * 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 } 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, ) { if (startMs >= endMs || stepMs <= 0 || nbCurves < 2) return []; // Porteurs : traitements E2 actifs à profil ESE avec ester couvert const carriers = treatments.filter((t) => t.type === 'ESTRADIOL' && t.isActive && usesProfileModel(t) && t.pkModel === 'ESE' && EstrannaiseModels.hasModel(t.esterType)); if (carriers.length === 0) return []; const carrierIds = new Set(carriers.map((t) => t.id)); const carrierDoses = doseLogs.filter((d) => carrierIds.has(d.treatmentId) && d.timestamp <= endMs); if (carrierDoses.length === 0) return []; // Échantillons échelonnés du posterior (déterministes) const samplesByEster = new Map( carriers.map((t) => [t.esterType, EstrannaiseModels.mcmcSamples(t.esterType)]), ); if ([...samplesByEster.values()].some((s) => s.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((d) => d.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 of carrierDoses) { if (dose.timestamp > t) continue; const dtH = (t - dose.timestamp) / 3600000; const tr = carriers.find((x) => x.id === dose.treatmentId); e2 += EstrannaiseModels.sampleWithParams( tr.esterType, dtH, paramsByEster.get(tr.esterType), ) * dose.doseAmount; } curve.push({ timestamp: t, e2, t: 0.0 }); } if (curve.length >= 2) cloud.push(curve); } return cloud; }