- estrannaise-cloud.js : paramètre scalePerEster — le nuage reçoit la
MÊME calibration que la courbe ESE (autoByModel ESE). Avant : courbe ESE
calibrée mais nuage BRUT → échelles différentes (remontée v1.9.1 :
« le nuage ne s'active que autour du tracé, pas autour du modèle
Estrannaise »).
- chart.js : autoByModel.get('ESE')?.esterScales passé au nuage ;
DEFAULT_NB_CURVES importé.
- +1 test : nuage calibré ×2 = nuage brut ×2 (167 verts au total).
- CHANGELOG [1.9.1] : fix nuage calibré + HARMONISATION de l'arrondi de
la médiane de calibration (le web avait la troncature historique — les
niveaux web/android convergent ; le niveau correct = l'ARRONDI).
- WEB_VERSION 1.9.1.
114 lines
4.9 KiB
JavaScript
114 lines
4.9 KiB
JavaScript
/**
|
||
* 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, 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<Array<{timestamp:number,e2:number,t:number}>>} 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 [];
|
||
|
||
// 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);
|
||
// Même calibration que la courbe ESE (fix v1.9.1)
|
||
const scale = scalePerEster?.[doseEster(tr, dose)] ?? tr.scaleFactor;
|
||
e2 += EstrannaiseModels.sampleWithParams(
|
||
tr.esterType, dtH, paramsByEster.get(tr.esterType),
|
||
) * dose.doseAmount * scale;
|
||
}
|
||
curve.push({ timestamp: t, e2, t: 0.0 });
|
||
}
|
||
if (curve.length >= 2) cloud.push(curve);
|
||
}
|
||
return cloud;
|
||
}
|