HormoneTrack-web/js/pk/estrannaise-cloud.js
Siphonight fc1b6e1700 v1.9.1 : fix nuage calibré (il entoure la courbe ESE calibrée) + harmonisation arrondi (miroir Android)
- 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.
2026-09-19 21:14:27 +02:00

114 lines
4.9 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.

/**
* 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;
}