Corrigé (audit) : - cutoffHours : garde hasModel pour ESE (parité Kotlin) — un backup importé ESE + ester non couvert levait TypeError → écran vide - min/max en une passe : les spreads Math.min(...allPoints) levaient RangeError au-delà de ~1e5 arguments (Nuage × zoom max) et allouaient par frame - init MCMC non bloquante : data-ready garantit désormais « asset chargé » Perf (portage v1.8.2 Android, fin de la dérive de miroir) : - prepareE2Context/e2AtCtx : doses groupées, cutoffs précalculés, Bateman paresseux ; computeCurve ne recalcule plus cutoffHours par point - résultat identique (175 tests verts) E2E : version lue via data-version (anti-bug v1.9.3) Docs/commentaires : pk-engine (ESE analytique), pk-calibration (JSDoc arrondi), dialogs (ESE=6), lab-timing (import mort, JSDoc orpheline), home (COLORS), pk-profile-store (test-only), pk_profiles.json exclu du zip (export-ignore, −548 Ko)
130 lines
5.9 KiB
JavaScript
130 lines
5.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é).
|
||
*
|
||
* 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} [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,
|
||
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;
|
||
}
|