HormoneTrack-web/js/pk/estrannaise-cloud.js
Siphonight be4d26b3e5 v1.9.5 : revue de code — garde cutoffHours ESE, portage optimisations v1.8.2, perf canvas, docs alignées
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)
2026-09-20 12:55:51 +02:00

130 lines
5.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é).
*
* 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;
}