v1.9.0 : modèle Estrannaise ANALYTIQUE (abandon de l'ODS) + nuage d'incertitude MCMC

Miroir fidèle du Kotlin (doc Android §7.12) :
- estrannaise-models.js : forme close 3C de estrannaise.js (paramètres
  publiés EV/EU/EEn/EC/EB/EUCS), posterior MCMC (313 échantillons/ester,
  asset mcmc_samples.json ≈ 48 Ko fetché au démarrage — remplace le fetch
  des tables ODS, 550 Ko), t½ terminale analytique, mapping en objets
  {d,k1,k2,k3} (miroir du Param Kotlin).
- estrannaise-cloud.js : nuage d'incertitude — 32 courbes du posterior
  (échelonnées, déterministes) superposant les doses des porteurs ESE ;
  exclusif ESE (TFS/WHSAH sans posterior publié), gardes (sans dose,
  nbCurves<2, MCMC non chargé → vide).
- pk-engine.js : dispatch ESE → forme close (plus de fallback PKProfileStore
  au runtime) ; cutoffHours ESE → 10 × t½ analytique ; lab-timing.js t½ ESE
  analytique ; app.js fetch MCMC remplace le fetch des tables ODS
  (démarrage plus rapide).
- chart.js/chart-canvas.js : chip « Nuage » (off, activable si ESE affiché,
  coupure auto si ESE off), clé 'CLOUD' skippée des légendes (leçon #63),
  nuage dessiné en alpha faible, légende dédiée ; seed démo + traitement
  EV/ESE (2 doses) pour démontrer le nuage ; choicesForModel ESE=6.
- Tests : estrannaise-models.test.js (6 : fidélité RMS/pics, dégénérés,
  MCMC 313×6, t½ analytique, gardes) + estrannaise-cloud.test.js (5 :
  32 courbes à dispersion réelle, fenêtre, exclusivité ESE, gardes)
  → 166 verts + E2E (chip/légende/exclusivité nuage) + check --release.
- WEB_VERSION 1.9.0 (versions sync Android). CHANGELOG [1.9.0] web.
This commit is contained in:
Siphonight 2026-09-19 20:45:53 +02:00
parent 61683ba5c4
commit 8eddd885f4
14 changed files with 589 additions and 37 deletions

1
assets/mcmc_samples.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,30 @@
> section la plus récente EN PREMIER ; ce fichier est lu par le dialog > section la plus récente EN PREMIER ; ce fichier est lu par le dialog
> « Nouveautés » de l'app web (fetch `docs/CHANGELOG.md`). > « Nouveautés » de l'app web (fetch `docs/CHANGELOG.md`).
## [1.9.0] — 2026-09-18
### Modèle Estrannaise ANALYTIQUE + nuage d'incertitude (portage Android v1.9.0, versions sync)
- **Le modèle ESE n'est plus les tables horaires ODS** : il est désormais la
**forme close 3C publiée par estrannaise.js** (les sources originelles
dont l'ODS n'était que l'échantillonnage horaire) — fidélité épinglée :
RMS 0,00 vs les tables sur 0→200 j, écart de pic ≤ 0,1 %. Les tables ne
sont plus fetchées au démarrage (−550 Ko, démarrage plus rapide) ;
`pk_profiles.json` reste uniquement comme référence des tests.
- **6 esters injectables couverts** (EV, EU, EEn, EC, EB + **EUCS**, undécylate
suspension cristalline, exclusif à ce fit) — l'ancien ESE n'en couvrait
que 3.
- **NUAGE D'INCERTITUDE (exclusif ESE)** : Estrannaise publie le posterior
MCMC de ses paramètres (313 échantillons/ester) — chip `Nuage`
(off par défaut, activable à volonté, actif SEULEMENT si ESE est affiché)
→ nuage diffus de courbes montrant la plage d'imprécision du modèle,
comme sur le site estrannaise. TFS/WHSAH ne publient pas de posterior →
pas de nuage pour eux.
- t½ terminale ESE désormais **analytique** (ln2/min(k1,k2,k3)) — la
recommandation de prise de sang n'estime plus la t½ par pente de table.
- **Tests** : fidélité (RMS, pics), cas dégénérés, MCMC (313 × 6), nuage
(32 courbes, dispersion, exclusivité ESE), t½ analytique.
## [1.8.1] — 2026-09-17 ## [1.8.1] — 2026-09-17
### Corrigé — la stabilisation ignorait les changements récents (dose/ester/intervalle) ### Corrigé — la stabilisation ignorait les changements récents (dose/ester/intervalle)

103
js/pk/estrannaise-cloud.js Normal file
View File

@ -0,0 +1,103 @@
/**
* 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<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,
) {
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;
}

141
js/pk/estrannaise-models.js Normal file
View File

@ -0,0 +1,141 @@
/**
* ─────────────────────────────────────────────────────────────────────────────
* Modèle **Estrannaise analytique** (v1.9.0, miroir fidèle de
* `pk/EstrannaiseModels.kt`) — remplace définitivement les tables horaires
* ODS pour le modèle ESE.
*
* SOURCE ORIGINELLE : https://github.com/WHSAH/estrannaise.js/ — le modèle 3
* compartiments avec les paramètres d'ajustement **publiés par Estrannaise**
* (t en JOURS, dose en mg → pg/mL) :
*
* C(t) = dose · d · k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3))
* − e^(−k2·t)/((k1−k2)(k2−k3))
* + e^(−k3·t)/((k1−k3)(k2−k3)) ]
*
* FIDÉLITÉ (épinglée par EstrannaiseModelsTest) : cette forme close
* reproduit les anciennes tables ODS à l'identique (RMS 0,00 sur 0→200 j,
* écart de pic ≤ 0,1 %) — l'ODS n'était que l'échantillonnage horaire de
* ces formules. Décision v1.9.0 : « tous les modèles ont maintenant leurs
* sources originelles — on abandonne complètement les liens avec le
* fichier ODS ».
*
* NUAGE D'INCERTITUDE (v1.9.0, exclusif ESE) : Estrannaise publie le
* **posterior MCMC** de ses paramètres (313 échantillons par ester —
* `assets/mcmc_samples.json`, 48 Ko, fetché au démarrage). Le graphique
* peut dessiner un nuage diffus de courbes montrant la plage
* d'imprécision du modèle. TFS/WHSAH ne publient pas de posterior.
*
* PUR sauf [init] (fetch de l'asset MCMC) — la formule est testable en
* Node (tests/estrannaise-models.test.js).
* ─────────────────────────────────────────────────────────────────────────────
*/
// ── Paramètres d'ajustement publiés par Estrannaise (src/modeldata.js) ──────
// Clés = clés esters de l'app. Mapping depuis le repo : « EV im »→EV,
// « EUn im »→EU, « EEn im »→EEn, « EC im »→EC, « EB im »→EB,
// « EUn casubq »→EUCS (nouveau code ester v1.9.0).
const PK_PARAMETERS = {
EV: [478.0, 0.236, 4.85, 1.24],
EU: [471.5, 0.01729, 6.528, 2.285],
EEN: [191.4, 0.119, 0.601, 0.402],
EC: [246.0, 0.0825, 3.57, 0.669],
EB: [1893.1, 0.67, 61.5, 4.34],
EUCS: [16.15, 0.046, 0.022, 0.101],
};
let mcmc = null; // posterior MCMC (chargé via init()) — { ester: [[d,k1,k2,k3] × 313] }
/**
* Fetch de l'asset `assets/mcmc_samples.json` (48 Ko) puis init — appelé au
* démarrage par app.js (remplace l'ancien fetch des tables ODS, 550 Ko).
* Silencieux en cas d'échec (le nuage restera simplement vide — les courbes
* nommées ne dépendent pas du posterior).
*/
export async function init() {
try {
const resp = await fetch('assets/mcmc_samples.json');
if (resp.ok) initWithJson(await resp.text());
} catch (_e) {
// best-effort : sans MCMC, pas de nuage — les courbes restent calculables
}
}
export function hasModel(ester) {
return Object.prototype.hasOwnProperty.call(PK_PARAMETERS, String(ester).toUpperCase());
}
/** Les paramètres de l'ester, ou null (lookup insensible à la casse). */
export function model(ester) {
const key = String(ester).toUpperCase();
if (!hasModel(key)) return null;
const [d, k1, k2, k3] = PK_PARAMETERS[key];
return {
d, k1, k2, k3,
/** t½ terminale ANALYTIQUE : la phase la plus lente domine la queue. */
get terminalHalfLifeDays() {
return Math.log(2) / Math.min(k1, k2, k3);
},
};
}
/** Forme close 3C (t en JOURS) — cas général + 3 cas dégénérés (taux égaux). */
function curveDays(t, dose, m) {
if (t < 0) return 0.0;
let ret = 0.0;
if (dose > 0 && m.d > 0) {
const { k1, k2, k3 } = m;
if (k1 === k2 && k2 === k3) {
ret += dose * m.d * k1 * k1 * t * t * Math.exp(-k1 * t) / 2;
} else if (k1 === k2 && k2 !== k3) {
ret += dose * m.d * k1 * k1 * (Math.exp(-k3 * t) - Math.exp(-k1 * t) * (1 + (k1 - k3) * t)) / (k1 - k3) / (k1 - k3);
} else if (k1 !== k2 && k1 === k3) {
ret += dose * m.d * k1 * k2 * (Math.exp(-k2 * t) - Math.exp(-k1 * t) * (1 + (k1 - k2) * t)) / (k1 - k2) / (k1 - k2);
} else if (k1 !== k2 && k2 === k3) {
ret += dose * m.d * k1 * k2 * (Math.exp(-k1 * t) - Math.exp(-k2 * t) * (1 - (k1 - k2) * t)) / (k1 - k2) / (k1 - k2);
} else {
ret += dose * m.d * k1 * k2 * (
Math.exp(-k1 * t) / (k1 - k2) / (k1 - k3)
- Math.exp(-k2 * t) / (k1 - k2) / (k2 - k3)
+ Math.exp(-k3 * t) / (k1 - k3) / (k2 - k3)
);
}
}
return Number.isNaN(ret) ? 0.0 : ret;
}
/**
* Réponse normalisée (pg/mL PAR mg) à dtHours — t converti en jours ici.
* dtHours ≤ 0 → 0.
*/
export function sample(ester, dtHours) {
const m = model(ester);
if (!m || dtHours <= 0) return 0.0;
return curveDays(dtHours / 24, 1.0, m);
}
/** Courbe avec un jeu de paramètres DONNÉ (échantillon MCMC) — nuage. */
export function sampleWithParams(ester, dtHours, p) {
if (dtHours <= 0) return 0.0;
return curveDays(dtHours / 24, 1.0, p);
}
/**
* Charge le posterior MCMC depuis `assets/mcmc_samples.json` (appelé au
* démarrage par app.js — remplace le fetch des tables ODS).
* @param {string} json contenu JSON brut
*/
export function initWithJson(json) {
const raw = JSON.parse(json);
// Mapper vers des objets {d, k1, k2, k3} (miroir du Param Kotlin) — les
// tableaux bruts du JSON ne portent pas les noms de champs
const parsed = {};
for (const [ester, arr] of Object.entries(raw)) {
parsed[ester.toUpperCase()] = arr.map(([d, k1, k2, k3]) => ({ d, k1, k2, k3 }));
}
mcmc = parsed;
}
/** Les échantillons MCMC d'un ester (vide si non chargé / inconnu). */
export function mcmcSamples(ester) {
return (mcmc && mcmc[String(ester).toUpperCase()]) || [];
}

View File

@ -11,10 +11,12 @@ export * from './pk-extrema.js';
export * as LabTrajectoryModel from './lab-trajectory-model.js'; export * as LabTrajectoryModel from './lab-trajectory-model.js';
export { computeLabAnchoredCurve, ratioAt, extensionHorizonEndMs } from './lab-trajectory-model.js'; export { computeLabAnchoredCurve, ratioAt, extensionHorizonEndMs } from './lab-trajectory-model.js';
export * as LabTiming from './lab-timing.js'; export * as LabTiming from './lab-timing.js';
export * as EstrannaiseModels from './estrannaise-models.js';
export { sample as estrannaiseSample, hasModel as estrannaiseHasModel } from './estrannaise-models.js';
export { nextBloodDrawRecommendation, shouldSuggestPosology } from './lab-timing.js'; export { nextBloodDrawRecommendation, shouldSuggestPosology } from './lab-timing.js';
export { compute as computeEstrannaiseCloud, DEFAULT_NB_CURVES } from './estrannaise-cloud.js';
export * from './chart-helpers.js'; export * from './chart-helpers.js';
export * as Alerts from './alerts.js'; export * as Alerts from './alerts.js';
export * as PKProfileStore from './pk-profile-store.js';
export * as TransfemScienceModels from './transfem-science-models.js'; export * as TransfemScienceModels from './transfem-science-models.js';
export * as WhsahModels from './whsah-models.js'; export * as WhsahModels from './whsah-models.js';
export * as PKPresets from './presets.js'; export * as PKPresets from './presets.js';

View File

@ -35,6 +35,7 @@
*/ */
import { computeCurve, generateForecastDoses, TConfig, usesProfileModel, doseEster } from './pk-engine.js'; import { computeCurve, generateForecastDoses, TConfig, usesProfileModel, doseEster } from './pk-engine.js';
import * as EstrannaiseModels from './estrannaise-models.js';
import { model as tfsModel } from './transfem-science-models.js'; import { model as tfsModel } from './transfem-science-models.js';
import { model as whsahModel } from './whsah-models.js'; import { model as whsahModel } from './whsah-models.js';
import * as pkProfileStore from './pk-profile-store.js'; import * as pkProfileStore from './pk-profile-store.js';
@ -77,7 +78,8 @@ export function nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs =
let tHalfDays = null; let tHalfDays = null;
if (carrier.pkModel === 'TFS') tHalfDays = tfsModel(carrier.esterType)?.terminalHalfLifeDays ?? null; if (carrier.pkModel === 'TFS') tHalfDays = tfsModel(carrier.esterType)?.terminalHalfLifeDays ?? null;
else if (carrier.pkModel === 'WHS') tHalfDays = whsahModel(carrier.esterType)?.terminalHalfLifeDays ?? null; else if (carrier.pkModel === 'WHS') tHalfDays = whsahModel(carrier.esterType)?.terminalHalfLifeDays ?? null;
else if (carrier.pkModel === 'ESE') tHalfDays = pkProfileStore.terminalHalfLifeDays(carrier.esterType, 'ESE'); // v1.9.0 : ESE analytique (estrannaise.js) — t½ terminale analytique
else if (carrier.pkModel === 'ESE') tHalfDays = EstrannaiseModels.model(carrier.esterType)?.terminalHalfLifeDays ?? null;
if (tHalfDays === null || tHalfDays === undefined) return null; if (tHalfDays === null || tHalfDays === undefined) return null;
// ── 3) Début du régime courant + date de stabilisation ──────────────────── // ── 3) Début du régime courant + date de stabilisation ────────────────────

View File

@ -26,7 +26,7 @@
import * as TransfemScienceModels from './transfem-science-models.js'; import * as TransfemScienceModels from './transfem-science-models.js';
import * as WhsahModels from './whsah-models.js'; import * as WhsahModels from './whsah-models.js';
import * as PKProfileStore from './pk-profile-store.js'; import { sample as estrannaiseSample, hasModel as estrannaiseHasModel, model as estrannaiseModel } from './estrannaise-models.js';
/** Une heure en ms. */ /** Une heure en ms. */
export const HOUR_MS = 3600000; export const HOUR_MS = 3600000;
@ -140,7 +140,7 @@ export function doseEster(treatment, dose) {
* 1. traitement à PROFIL PK (injection + ester ≠ NONE) : * 1. traitement à PROFIL PK (injection + ester ≠ NONE) :
* - TFS + ester couvert par le V3C → TransfemScienceModels ; * - TFS + ester couvert par le V3C → TransfemScienceModels ;
* - WHSAH + ester couvert par le fit Mona → WhsahModels ; * - WHSAH + ester couvert par le fit Mona → WhsahModels ;
* - sinon → tables ODS via PKProfileStore (ester sans modèle → 0) ; * - ESE → forme close estrannaise.js (v1.9.0, analytique) ; ester sans modèle → 0 ;
* 2. sinon → Bateman (gel / patch / oral / custom). * 2. sinon → Bateman (gel / patch / oral / custom).
* *
* `modelOverride` force ESE/TFS/WHS pour CE calcul (le graphique dessine les * `modelOverride` force ESE/TFS/WHS pour CE calcul (le graphique dessine les
@ -173,7 +173,10 @@ export function concentrationOfDose(treatment, dose, queryTimeMs, bateman = null
if (mod === PKModels.WHSAH && WhsahModels.hasModel(ester)) { if (mod === PKModels.WHSAH && WhsahModels.hasModel(ester)) {
return WhsahModels.sample(ester, dtH) * mg; return WhsahModels.sample(ester, dtH) * mg;
} }
return PKProfileStore.sample(ester, mod, dtH) * mg; if (mod === 'ESE' && estrannaiseHasModel(ester)) {
return estrannaiseSample(ester, dtH) * mg;
}
return 0.0; // ester sans modèle (ni TFS ni WHSAH ni ESE analytique)
} }
} }
@ -207,7 +210,9 @@ export function cutoffHours(treatment) {
} else if (mod === PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType)) { } else if (mod === PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType)) {
profileH = TransfemScienceModels.model(treatment.esterType).terminalHalfLifeDays * 24.0 * 10.0; profileH = TransfemScienceModels.model(treatment.esterType).terminalHalfLifeDays * 24.0 * 10.0;
} else { } else {
profileH = PKProfileStore.profileLength(treatment.esterType, mod); // v1.9.0 : ESE analytique → t½ terminale analytique (comme TFS/WHS),
// plus la longueur de table ODS
profileH = 10 * 24 * estrannaiseModel(treatment.esterType).terminalHalfLifeDays;
} }
} }
const batemanH = usesProfileModel(treatment) ? 0.0 : 30.0 * treatment.eliminationHalfLifeHours; const batemanH = usesProfileModel(treatment) ? 0.0 : 30.0 * treatment.eliminationHalfLifeHours;

View File

@ -3,7 +3,7 @@
* HormoneTrack Web — shell applicatif (portage de HormoneTrackApp.kt + * HormoneTrack Web — shell applicatif (portage de HormoneTrackApp.kt +
* MainActivity.kt + HormoneTrackRoot.kt). * MainActivity.kt + HormoneTrackRoot.kt).
* *
* - attend l'initialisation de PKProfileStore (fetch de l'asset) ; * - attend l'initialisation de l'asset MCMC (nuage d'incertitude ESE, v1.9.0) ;
* - routing par hash (#home/#chart/#doses/#labs/#treatments/#settings/ * - routing par hash (#home/#chart/#doses/#labs/#treatments/#settings/
* #treatment-edit/{id}) — permet la navigation headless et le lien profond ; * #treatment-edit/{id}) — permet la navigation headless et le lien profond ;
* - NavigationBar 5 onglets (barre masquée sur settings/éditeur, miroir * - NavigationBar 5 onglets (barre masquée sur settings/éditeur, miroir
@ -20,7 +20,7 @@
* ───────────────────────────────────────────────────────────────────────────── * ─────────────────────────────────────────────────────────────────────────────
*/ */
import * as PKProfileStore from '../pk/pk-profile-store.js'; import * as EstrannaiseModels from '../pk/estrannaise-models.js';
import { store } from '../data/store.js'; import { store } from '../data/store.js';
import * as AppLog from '../util/app-log.js'; import * as AppLog from '../util/app-log.js';
import { isVersionNewer, sectionsSince } from '../util/changelog.js'; import { isVersionNewer, sectionsSince } from '../util/changelog.js';
@ -57,7 +57,9 @@ async function main() {
// 4) Asset PK (équivalent de PKProfileStore.init(context) dans // 4) Asset PK (équivalent de PKProfileStore.init(context) dans
// HormoneTrackApp.onCreate) — les courbes ne sont calculées qu'après ; // HormoneTrackApp.onCreate) — les courbes ne sont calculées qu'après ;
// le re-rendu ci-dessous remplace le « Chargement… » par les données. // le re-rendu ci-dessous remplace le « Chargement… » par les données.
await PKProfileStore.init(); // v1.9.0 : l'asset MCMC (nuage ESE, 48 Ko) remplace le fetch des tables
// ODS (550 Ko) — démarrage plus rapide, runtime 100 % sources analytiques
await EstrannaiseModels.init();
// 5) Langue au démarrage (miroir MainActivity : setApplicationLocales) // 5) Langue au démarrage (miroir MainActivity : setApplicationLocales)
document.documentElement.lang = resolveLocale(store.getSettings().language); document.documentElement.lang = resolveLocale(store.getSettings().language);
@ -154,14 +156,6 @@ function renderRoute() {
const preserveChartState = route === 'chart' && lastRoute === 'chart'; const preserveChartState = route === 'chart' && lastRoute === 'chart';
lastRoute = route; lastRoute = route;
// Écran de chargement tant que l'asset PK n'est pas chargé (les écrans
// afficheraient « aucune donnée » à tort — les profils valent 0 avant init).
if (!PKProfileStore.isInitialized()) {
contentEl.textContent = '';
contentEl.appendChild(el('p', { class: 'hint', style: 'padding:24px 0;' }, t('loading')));
return;
}
// Marqueur E2E : l'app est prête (asset chargé, écran rendu) — utilisé par // Marqueur E2E : l'app est prête (asset chargé, écran rendu) — utilisé par
// scripts/e2e.mjs pour attendre la fin du rendu (plus de course au screenshot). // scripts/e2e.mjs pour attendre la fin du rendu (plus de course au screenshot).
document.body.setAttribute('data-ready', '1'); document.body.setAttribute('data-ready', '1');
@ -364,6 +358,41 @@ function maybeLoadDemoData() {
notes: isLastPair ? 'DHT : 12 ng/dL' : null, notes: isLastPair ? 'DHT : 12 ng/dL' : null,
}); });
}); });
// v1.9.0 : un 2ᵉ traitement E2 à modèle ESE (EV) avec 2 doses — permet
// de démontrer/tester le nuage d'incertitude ESE et la couverture
// multi-esters sans toucher au traitement EEn (TFS) principal.
const evId = store.upsertTreatment({
name: 'EV — Estrannaise (démo)',
type: 'ESTRADIOL',
route: 'INJECTION_IM',
doseAmount: 4.0,
doseUnit: 'mg',
isActive: true,
notes: null,
esterType: 'EV',
pkModel: 'ESE',
absorptionHours: 45,
eliminationHalfLifeHours: 70,
bioavailabilityFraction: 1,
scaleFactor: 1.0,
forecastIntervalDays: null,
reminderHour: null,
reminderMinute: null,
reminderEnabled: false,
calendarEventId: null,
createdAt: now - 60 * DAY,
});
// doses vieilles de 10-24 j : contribution résiduelle au présent (la
// courbe EV/SE fait partie du mini-chart sans saturer le niveau actuel)
for (const k of [24, 17, 10]) {
store.upsertDoseLog({
treatmentId: evId,
timestamp: now - k * DAY,
doseAmount: 4.0,
notes: null,
esterType: null,
});
}
AppLog.log('demo', 'données de démonstration chargées (?demo=1)'); AppLog.log('demo', 'données de démonstration chargées (?demo=1)');
} }

View File

@ -31,7 +31,7 @@
import { import {
computeCurve, generateForecastDoses, autoCalibrated, TConfig, computeCurve, generateForecastDoses, autoCalibrated, TConfig,
computeLabAnchoredCurve, computeLabAnchoredCurve, computeEstrannaiseCloud,
} from '../pk/index.js'; } from '../pk/index.js';
import { import {
stepForRange, defaultModelToggles, panDeltaHours, clampPanHours, stepForRange, defaultModelToggles, panDeltaHours, clampPanHours,
@ -124,6 +124,12 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
// demandée explicitement ; le chip n'est actif que si Lab track est ON // demandée explicitement ; le chip n'est actif que si Lab track est ON
// (miroir ChartScreen.kt). // (miroir ChartScreen.kt).
showLabTrackExtend: false, showLabTrackExtend: false,
// v1.9.0 : NUAGE D'INCERTITUDE du modèle ESE (posterior MCMC de
// estrannaise.js) — OFF par défaut, activable à volonté, ACTIVABLE
// SEULEMENT si le modèle ESE est affiché (exclusif à ce modèle — les
// fits TFS/WHSAH ne publient pas de posterior). Éteindre ESE coupe le
// nuage automatiquement (miroir ChartScreen.kt).
showCloud: false,
panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62) panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62)
togglesInitialized: false, togglesInitialized: false,
}; };
@ -253,7 +259,13 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
// Rangée 3 : toggles modèles superposables // Rangée 3 : toggles modèles superposables
modelRow.textContent = ''; modelRow.textContent = '';
modelRow.appendChild(filterChip(t('model_ese'), state.showEse, () => { state.showEse = !state.showEse; redraw(); })); modelRow.appendChild(filterChip(t('model_ese'), state.showEse, () => {
state.showEse = !state.showEse;
// v1.9.0 : le nuage est exclusif à ESE — éteindre ESE éteint le nuage
// (sinon il flotterait sans sa courbe)
if (!state.showEse) state.showCloud = false;
redraw();
}));
modelRow.appendChild(filterChip(t('model_tfs'), state.showTfs, () => { state.showTfs = !state.showTfs; redraw(); })); modelRow.appendChild(filterChip(t('model_tfs'), state.showTfs, () => { state.showTfs = !state.showTfs; redraw(); }));
modelRow.appendChild(filterChip(t('model_whs'), state.showWhs, () => { state.showWhs = !state.showWhs; redraw(); })); modelRow.appendChild(filterChip(t('model_whs'), state.showWhs, () => { state.showWhs = !state.showWhs; redraw(); }));
// v1.5.0 : courbe hybride ancrée sur les labs (off par défaut) // v1.5.0 : courbe hybride ancrée sur les labs (off par défaut)
@ -262,6 +274,9 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
// défaut, SANS EFFET — donc désactivé — tant que Lab track est off : // défaut, SANS EFFET — donc désactivé — tant que Lab track est off :
// la prolongation n'existe que comme extension de CETTE courbe) // la prolongation n'existe que comme extension de CETTE courbe)
modelRow.appendChild(filterChip(t('show_lab_track_extend'), state.showLabTrackExtend, () => { state.showLabTrackExtend = !state.showLabTrackExtend; redraw(); }, !state.showLabTrack)); modelRow.appendChild(filterChip(t('show_lab_track_extend'), state.showLabTrackExtend, () => { state.showLabTrackExtend = !state.showLabTrackExtend; redraw(); }, !state.showLabTrack));
// v1.9.0 : nuage d'incertitude (off par défaut, activable à volonté,
// SEULEMENT si ESE est affiché — même pattern que Prolonger/Tracé labs)
modelRow.appendChild(filterChip(t('show_cloud'), state.showCloud, () => { state.showCloud = !state.showCloud; redraw(); }, !state.showEse));
// ── Courbes par modèle sélectionné ───────────────────────────────────── // ── Courbes par modèle sélectionné ─────────────────────────────────────
const curves = []; const curves = [];
@ -277,8 +292,8 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
// ⚠️ Les DEUX clés doivent être SKIPPÉES par la boucle de légende // ⚠️ Les DEUX clés doivent être SKIPPÉES par la boucle de légende
// (leçon #63 Android : sinon le catchall imprime la légende TFS en // (leçon #63 Android : sinon le catchall imprime la légende TFS en
// doublon) et avoir leurs entrées dédiées plus bas. // doublon) et avoir leurs entrées dédiées plus bas.
const step = stepForRange(state.rangeHours); // partagé : LAB + modèles (v1.9.0)
if (state.showLabTrack) { if (state.showLabTrack) {
const step = stepForRange(state.rangeHours);
const labCurve = computeLabAnchoredCurve( const labCurve = computeLabAnchoredCurve(
treatments, doseLogs, labResults, startMs, endMs, step, null, treatments, doseLogs, labResults, startMs, endMs, step, null,
state.showLabTrackExtend, state.showLabTrackExtend,
@ -292,6 +307,16 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
curves.push(['LAB', labCurve.points]); curves.push(['LAB', labCurve.points]);
} }
} }
// v1.9.0 : NUAGE D'INCERTITUDE ESE — INDÉPENDANT du tracé labs (il ne
// dépend que de showCloud && showEse) : M courbes du posterior MCMC, cf
// EstrannaiseCloud. Clé 'CLOUD' skippée des légendes (leçon #63 miroir)
// et dessinée en alpha faible.
if (state.showCloud) {
const cloud = computeEstrannaiseCloud(
treatments, doseLogs, startMs, endMs, step, nowMs,
);
if (cloud.length > 0) curves.push(['CLOUD', cloud.flat()]);
}
if (state.showEse || state.showTfs || state.showWhs) { if (state.showEse || state.showTfs || state.showWhs) {
// Home utilise le modèle stocké ; le graphique calibre chaque modèle // Home utilise le modèle stocké ; le graphique calibre chaque modèle
// avec SA prédiction (fix #60). Référence = auto du modèle TFS (les // avec SA prédiction (fix #60). Référence = auto du modèle TFS (les
@ -307,7 +332,6 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
const allDoses = doseLogs.concat(forecastDoses); const allDoses = doseLogs.concat(forecastDoses);
// Pas d'échantillonnage adaptatif au zoom (v1.2.9) // Pas d'échantillonnage adaptatif au zoom (v1.2.9)
const step = stepForRange(state.rangeHours);
const addCurve = (model) => { const addCurve = (model) => {
const autoM = autoByModel.get(model); const autoM = autoByModel.get(model);
curves.push([model, computeCurve( curves.push([model, computeCurve(
@ -354,6 +378,14 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
drawChart( drawChart(
canvas, canvas,
curves.map(([model, pts]) => { curves.map(([model, pts]) => {
if (model === 'CLOUD') {
// v1.9.0 : nuage MCMC — chaque courbe individuelle est tracée
// en alpha faible ; PAS de labels/extrema (incertitude, pas des
// courbes nommées). Ne participe pas aux échelles via allPoints
// ? Si : allPoints inclut CLOUD — les échelles s'adaptent donc
// au nuage (cohérent : le nuage montre le range réel possible).
return { points: pts, e2Style: { color: 'rgba(194,24,91,0.12)', dashed: true }, tStyle: null, showExtrema: false };
}
if (model === 'LAB') { if (model === 'LAB') {
// v1.5.0 : Tracé labs = E2 rose foncé pointillé, PAS de courbe T // v1.5.0 : Tracé labs = E2 rose foncé pointillé, PAS de courbe T
// (série E2-only) et hors pics/creux (showExtrema=false) // (série E2-only) et hors pics/creux (showExtrema=false)
@ -396,7 +428,7 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
// ⚠️ Les clés 'LAB' (v1.5.0) et 'LABX' (v1.6.0) sont SKIPPÉES // ⚠️ Les clés 'LAB' (v1.5.0) et 'LABX' (v1.6.0) sont SKIPPÉES
// (légendes dédiées plus bas, fix #63 Android-miroir : sinon le // (légendes dédiées plus bas, fix #63 Android-miroir : sinon le
// catchall imprime la légende TFS en doublon) // catchall imprime la légende TFS en doublon)
if (model === 'LAB' || model === 'LABX') continue; if (model === 'LAB' || model === 'LABX' || model === 'CLOUD') continue;
const st = seriesStyles(model); const st = seriesStyles(model);
if (model === 'ESE') { if (model === 'ESE') {
legend.appendChild(legendLine(t('legend_ese_e2'), st.e2.color)); legend.appendChild(legendLine(t('legend_ese_e2'), st.e2.color));
@ -423,6 +455,12 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
// si la série "LABX" existe (chip Prolonger actif ET horizon atteint // si la série "LABX" existe (chip Prolonger actif ET horizon atteint
// dans la fenêtre visible). Même condition pour les deux (factorisée) : // dans la fenêtre visible). Même condition pour les deux (factorisée) :
// il ne peut jamais y avoir de courbe prolongée sans son avertissement. // il ne peut jamais y avoir de courbe prolongée sans son avertissement.
// v1.9.0 : légende du nuage d'incertitude (ESE) — visible tant que
// le nuage est affiché (l'alpha faible le rend nettement secondaire)
if (state.showCloud && state.showEse
&& curves.some(([m, pts]) => m === 'CLOUD' && pts.length >= 2)) {
legend.appendChild(el('div', { class: 'legend-muted' }, t('legend_cloud')));
}
const labExtendVisible = state.showLabTrack && state.showLabTrackExtend const labExtendVisible = state.showLabTrack && state.showLabTrackExtend
&& curves.some(([m, pts]) => m === 'LABX' && pts.length >= 2); && curves.some(([m, pts]) => m === 'LABX' && pts.length >= 2);
if (labExtendVisible) { if (labExtendVisible) {

View File

@ -31,7 +31,7 @@ import { el, showDialog, field, switchRow, filterChip, toast } from './component
* (APK + web au MÊME numéro, tags appariés `vX.Y.Z` + `vX.Y.Z-web`). * (APK + web au MÊME numéro, tags appariés `vX.Y.Z` + `vX.Y.Z-web`).
* v1.4.10 = portage initial de l'Android v1.4.10. * v1.4.10 = portage initial de l'Android v1.4.10.
*/ */
export const WEB_VERSION = '1.8.1'; export const WEB_VERSION = '1.9.0';
/** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */ /** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */
function parseNumOrNull(text) { function parseNumOrNull(text) {

View File

@ -194,6 +194,10 @@ const EN = {
// v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in, // v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in,
// actif seulement quand Lab track est ON — miroir strings.xml Android) // actif seulement quand Lab track est ON — miroir strings.xml Android)
show_lab_track_extend: 'Extend', show_lab_track_extend: 'Extend',
// v1.9.0 : nuage d'incertitude du modèle ESE (posterior MCMC de
// estrannaise.js) — exclusif à ESE, off par défaut
show_cloud: 'Cloud',
legend_cloud: 'Dotted cloud = Estrannaise MCMC parameter uncertainty (ESE model only)',
lab_track_extend_warning: '⚠ Simulation only, with no guarantee of matching reality: the extension extrapolates your model from your lab results — which can themselves be inaccurate. Trust your next blood test, not this curve.', lab_track_extend_warning: '⚠ Simulation only, with no guarantee of matching reality: the extension extrapolates your model from your lab results — which can themselves be inaccurate. Trust your next blood test, not this curve.',
back_to_present: 'Back to present', back_to_present: 'Back to present',
legend_ese_e2: 'E2 — Estrannaise (blue)', legend_ese_e2: 'E2 — Estrannaise (blue)',
@ -387,6 +391,8 @@ const FR = {
// v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in, // v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in,
// actif seulement quand Tracé labs est ON — miroir strings.xml Android) // actif seulement quand Tracé labs est ON — miroir strings.xml Android)
show_lab_track_extend: 'Prolonger', show_lab_track_extend: 'Prolonger',
show_cloud: 'Nuage',
legend_cloud: 'Nuage pointillé = incertitude des paramètres MCMC de l\'Estrannaise (modèle ESE seul)',
lab_track_extend_warning: '⚠ Simple simulation, sans garantie de correspondre au réel : la prolongation extrapole ton modèle à partir de tes résultats de laboratoire — qui peuvent eux-mêmes être erronés. Fie-toi à ta prochaine prise de sang, pas à cette courbe.', lab_track_extend_warning: '⚠ Simple simulation, sans garantie de correspondre au réel : la prolongation extrapole ton modèle à partir de tes résultats de laboratoire — qui peuvent eux-mêmes être erronés. Fie-toi à ta prochaine prise de sang, pas à cette courbe.',
back_to_present: 'Revenir à maintenant', back_to_present: 'Revenir à maintenant',
legend_ese_e2: 'E2 — Estrannaise (bleu)', legend_ese_e2: 'E2 — Estrannaise (bleu)',

View File

@ -131,7 +131,7 @@ async function main() {
ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)'); ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)');
if (dialog) { if (dialog) {
const dlgText = await dialog.textContent(); const dlgText = await dialog.textContent();
ok(dlgText.includes('Version 1.8.1'), 'dialog : section de la version courante'); ok(dlgText.includes('Version 1.9.0'), 'dialog : section de la version courante');
const hasRenderedMd = await dialog.$('.md-body h3, .md-body strong'); const hasRenderedMd = await dialog.$('.md-body h3, .md-body strong');
ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut'); ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut');
await page.click('.dialog .actions button'); await page.click('.dialog .actions button');
@ -183,24 +183,25 @@ async function main() {
} }
await page.waitForTimeout(400); await page.waitForTimeout(400);
// 1) Le chip « Prolonger » est DÉSACTIVÉ tant que « Tracé labs » est off // 1) Le chip « Prolonger » est DÉSACTIVÉ tant que « Tracé labs » est off
const extendDisabledBefore = await page.evaluate((lbl) => { const chipDisabled = (lbl) => page.evaluate((label) => {
const b = [...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl); const b = [...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === label);
return b ? /opacity:\s*0\.4/.test(b.getAttribute('style') || '') : null; return b ? /opacity:\s*0\.4/.test(b.getAttribute('style') || '') : null;
}, 'Prolonger'); }, lbl);
const clickChip = (lbl) => page.evaluate((label) => {
[...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === label)?.click();
}, lbl);
const extendDisabledBefore = await chipDisabled('Prolonger');
ok(extendDisabledBefore === true, 'chip « Prolonger » désactivé tant que Tracé labs est off'); ok(extendDisabledBefore === true, 'chip « Prolonger » désactivé tant que Tracé labs est off');
// 2) Active Tracé labs → la légende ancrée apparaît (sans avertissement) // 2) Active Tracé labs → la légende ancrée apparaît (sans avertissement)
await page.evaluate((lbl) => { await clickChip('Tracé labs');
[...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl)?.click(); await page.waitForTimeout(400);
}, 'Tracé labs');
await page.waitForTimeout(400); await page.waitForTimeout(400);
const labTrackText = await page.textContent('main.content'); const labTrackText = await page.textContent('main.content');
ok(labTrackText.includes('E2 ancrée sur tes labs'), 'légende « Tracé labs » (ancrée) affichée'); ok(labTrackText.includes('E2 ancrée sur tes labs'), 'légende « Tracé labs » (ancrée) affichée');
ok(!labTrackText.includes('Simple simulation, sans garantie'), ok(!labTrackText.includes('Simple simulation, sans garantie'),
'pas d\'avertissement tant que la prolongation est off'); 'pas d\'avertissement tant que la prolongation est off');
// 3) Active « Prolonger » → légende prolongée + AVERTISSEMENT VISIBLE // 3) Active « Prolonger » → légende prolongée + AVERTISSEMENT VISIBLE
await page.evaluate((lbl) => { await clickChip('Prolonger');
[...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl)?.click();
}, 'Prolonger');
await page.waitForTimeout(400); await page.waitForTimeout(400);
const extendText = await page.textContent('main.content'); const extendText = await page.textContent('main.content');
ok(extendText.includes('Tracé labs prolongé'), 'légende de la partie prolongée affichée'); ok(extendText.includes('Tracé labs prolongé'), 'légende de la partie prolongée affichée');
@ -209,15 +210,50 @@ async function main() {
ok(extendText.includes('qui peuvent eux-mêmes être erronés'), ok(extendText.includes('qui peuvent eux-mêmes être erronés'),
'avertissement : les résultats de lab peuvent être erronés'); 'avertissement : les résultats de lab peuvent être erronés');
// 4) Désactive « Prolonger » → avertissement ET légende prolongée disparaissent // 4) Désactive « Prolonger » → avertissement ET légende prolongée disparaissent
await page.evaluate((lbl) => { await clickChip('Prolonger');
[...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl)?.click();
}, 'Prolonger');
await page.waitForTimeout(400); await page.waitForTimeout(400);
const offText = await page.textContent('main.content'); const offText = await page.textContent('main.content');
ok(!offText.includes('Tracé labs prolongé') && !offText.includes('Simple simulation, sans garantie'), ok(!offText.includes('Tracé labs prolongé') && !offText.includes('Simple simulation, sans garantie'),
'désactivation → légende prolongée + avertissement disparaissent'); 'désactivation → légende prolongée + avertissement disparaissent');
await page.screenshot({ path: join(SHOTS, 'e2e-chart-extend.png') }); await page.screenshot({ path: join(SHOTS, 'e2e-chart-extend.png') });
// ── 2.quater NUAGE D'INCERTITUDE (v1.9.0, exclusif ESE) ────────────────
// La démo a maintenant 2 traitements E2 (EEn TFS + EV ESE) → toggles
// alignés : ESE et TFS ON par défaut (v1.4.7) → le chip Nuage est
// ACTIVABLE dès le départ. Séquence : activer Nuage → légende visible ;
// désactiver ESE → le nuage se coupe AUTOMATIQUEMENT (exclusivité) ;
// réactiver ESE → nuage de retour.
console.log('── Nuage d incertitude (#chart, v1.9.0) ──');
const cloudIsSelected = () => page.evaluate(() =>
[...document.querySelectorAll('.chip')]
.some((x) => x.textContent.trim() === 'Nuage' && x.classList.contains('selected')));
const cloudIsDisabled = () => page.evaluate(() => {
const b = [...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === 'Nuage');
return b ? /opacity:\s*0\.4/.test(b.getAttribute('style') || '') : null;
});
// ESE est ON à ce stade (scénario 2.bis) → Nuage activable
await clickChip('Nuage');
await page.waitForTimeout(500);
ok(await cloudIsSelected() === true, 'chip « Nuage » activé (ESE affiché)');
const cloudText1 = await page.textContent('main.content');
ok(cloudText1.includes('Nuage pointillé = incertitude des paramètres MCMC'),
'légende du nuage MCMC affichée (ESE + Nuage ON)');
// Désactiver ESE → le nuage se coupe AUTOMATIQUEMENT (exclusivité)
await clickChip('Estrannaise'); // ESE OFF
await page.waitForTimeout(500);
ok(await cloudIsSelected() === false,
'désactiver ESE → le nuage se coupe automatiquement (exclusivité ESE)');
const offAfter = await page.evaluate(() => {
const b = [...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === 'Nuage');
return b ? /opacity:\s*0\.4/.test(b.getAttribute('style') || '') : null;
});
ok(offAfter === true, 'chip « Nuage » redevient désactivé sans ESE');
// Réactiver ESE → Nuage ré-activable
await clickChip('Estrannaise');
await page.waitForTimeout(400);
ok(await cloudIsDisabled() === false, 'réactiver ESE → Nuage redevient activable');
await page.screenshot({ path: join(SHOTS, 'e2e-chart-cloud.png') });
// ── 2.ter PERSISTANCE de l'état du graphique (v1.6.1) ─────────────────── // ── 2.ter PERSISTANCE de l'état du graphique (v1.6.1) ───────────────────
// Bug remonté : les options se réinitialisaient « toutes seules » car le // Bug remonté : les options se réinitialisaient « toutes seules » car le
// tick minute / une mutation du store / un resize re-créaient l'écran // tick minute / une mutation du store / un resize re-créaient l'écran
@ -298,7 +334,7 @@ async function main() {
await page.waitForTimeout(300); await page.waitForTimeout(300);
text = await page.textContent('main.content'); text = await page.textContent('main.content');
ok(text.includes('Langue'), 'Paramètres : carte langue'); ok(text.includes('Langue'), 'Paramètres : carte langue');
ok(text.includes('Version 1.8.1 (web)'), 'Paramètres : version affichée'); ok(text.includes('Version 1.9.0 (web)'), 'Paramètres : version affichée');
ok(text.includes('100 % local'), 'Paramètres : note vie privée'); ok(text.includes('100 % local'), 'Paramètres : note vie privée');
await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') }); await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') });

View File

@ -0,0 +1,69 @@
/**
* Tests du NUAGE D'INCERTITUDE (v1.9.0 — miroir de `EstrannaiseCloudTest.kt`,
* `js/pk/estrannaise-cloud.js`) : 32 courbes par défaut, dispersion réelle
* entre courbes, fenêtre respectée, exclusivité ESE (TFS/oral → vide).
*/
import { test, describe, before } from 'node:test';
import { join } from 'node:path';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { initProfiles, makeTreatment, makeDose, DAY_MS, WEB_ROOT } from './helpers.js';
import { compute } from '../js/pk/estrannaise-cloud.js';
import { initWithJson as initMcmc } from '../js/pk/estrannaise-models.js';
const NOW = 1_790_000_000_000;
function ese() {
return makeTreatment({
name: 'EEn ESE', type: 'ESTRADIOL', route: 'INJECTION_IM',
doseAmount: 5.0, doseUnit: 'mg', esterType: 'EEN', pkModel: 'ESE',
forecastIntervalDays: 7.0, isActive: true,
});
}
function doses() {
return [4, 3, 2, 1].map((k) => makeDose(NOW - k * DAY_MS, 5.0));
}
describe('EstrannaiseCloud web (miroir Android v1.9.0)', () => {
before(() => {
initProfiles();
// Le nuage exige le posterior MCMC chargé (313 échantillons/ester)
initMcmc(readFileSync(join(WEB_ROOT, 'assets/mcmc_samples.json'), 'utf8'));
});
test('32 courbes avec dispersion réelle entre elles', () => {
const cloud = compute([ese()], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, NOW);
assert.equal(cloud.length, 32);
assert.ok(cloud.every((c) => c.length >= 2));
const peaks = [...new Set(cloud.map((c) => Math.max(...c.map((p) => p.e2))))];
assert.ok(peaks.length >= 8, `les courbes divergent (${peaks.length} maxima distincts)`);
});
test('le nuage reste dans la fenêtre demandée', () => {
const start = NOW - 10 * DAY_MS;
const end = NOW;
const cloud = compute([ese()], doses(), start, end, 3_600_000, NOW);
assert.ok(cloud.every((c) => c.every((p) => p.timestamp >= start && p.timestamp <= end)));
});
test('exclusivité ESE : TFS et oral produisent un nuage vide', () => {
const tfs = { ...ese(), pkModel: 'TFS' };
assert.equal(compute([tfs], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, NOW).length, 0);
const oral = makeTreatment({
name: 'CPA', type: 'ANTI_ANDROGEN', route: 'ORAL',
doseAmount: 12.5, doseUnit: 'mg', esterType: 'NONE',
forecastIntervalDays: 2.0, isActive: true,
});
assert.equal(compute([oral], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, NOW).length, 0);
});
test('aucune dose sur le porteur → nuage vide', () => {
assert.equal(compute([ese()], [], NOW - 30 * DAY_MS, NOW, 3_600_000, NOW).length, 0);
});
test('nbCurves < 2 → nuage vide', () => {
assert.equal(compute([ese()], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, NOW, 1).length, 0);
});
});

View File

@ -0,0 +1,96 @@
/**
* Tests du modèle **Estrannaise analytique** (v1.9.0 — miroir de
* `EstrannaiseModelsTest.kt`) : forme close 3C publiée par estrannaise.js,
* fidélité aux tables ODS (RMS ≈ 0), cas dégénérés, MCMC 313, t½ analytique.
*/
import { test, describe, before } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { initProfiles, WEB_ROOT } from './helpers.js';
import {
sample, hasModel, model, mcmcSamples, initWithJson, sampleWithParams,
} from '../js/pk/estrannaise-models.js';
describe('EstrannaiseModels web (miroir Android v1.9.0)', () => {
before(() => {
initProfiles(); // tables ODS (référence de fidélité, src/test side)
const mcmc = readFileSync(join(WEB_ROOT, 'assets/mcmc_samples.json'), 'utf8');
initWithJson(mcmc);
});
test('FIDÉLITÉ : la forme close reproduit les tables ODS (RMS ≈ 0)', () => {
const profiles = JSON.parse(
readFileSync(join(WEB_ROOT, 'assets/pk_profiles.json'), 'utf8'),
).profiles;
for (const ester of ['EV', 'EU', 'EEN']) {
const key = Object.keys(profiles)
.find((k) => k.toLowerCase().startsWith(ester.toLowerCase() + '_') && k.toLowerCase().endsWith('ese'));
const arr = profiles[key];
let maxAbs = 0;
let sumSq = 0;
for (let h = 0; h <= 4800; h++) {
const diff = sample(ester, h) - arr[h];
maxAbs = Math.max(maxAbs, Math.abs(diff));
sumSq += diff * diff;
}
const rms = Math.sqrt(sumSq / 4801);
assert.ok(maxAbs <= 0.01 && rms <= 0.01, `${ester} : maxAbs=${maxAbs} rms=${rms}`);
}
});
test('les pics publiés sont reproduits (61,12 / 3,44 / 31,35 pg/mL/mg)', () => {
const expects = [
['EV', 61.12], ['EU', 3.44], ['EEN', 31.35],
];
for (const [ester, peakExpect] of expects) {
let peak = 0;
for (let t = 0; t <= 200; t += 0.25 / 24) {
peak = Math.max(peak, sample(ester, t * 24));
}
assert.ok(
Math.abs(peak - peakExpect) <= peakExpect * 0.002,
`pic ${ester} = ${peak} (attendu ${peakExpect} ±0,2 %)`,
);
}
});
test('taux dégénérés : solutions limites sans NaN ni négatif', () => {
for (const p of [
{ d: 478, k1: 0.236, k2: 0.236, k3: 1.24 },
{ d: 478, k1: 0.236, k2: 1.24, k3: 0.236 },
{ d: 478, k1: 0.236, k2: 1.24, k3: 1.24 },
{ d: 478, k1: 0.5, k2: 0.5, k3: 0.5 },
]) {
for (const t of [0.5, 1.0, 24.0, 240.0, 2400.0]) {
const v = sampleWithParams('EV', t, p);
assert.ok(v >= 0 && !Number.isNaN(v), `finie et ≥ 0 (t=${t})`);
}
}
});
test('MCMC : 313 échantillons par ester injectable (6 esters)', () => {
for (const ester of ['EV', 'EU', 'EEN', 'EC', 'EB', 'EUCS']) {
assert.equal(mcmcSamples(ester).length, 313, `posterior ${ester}`);
assert.notEqual(
mcmcSamples(ester)[0].d, mcmcSamples(ester)[312].d,
'le posterior couvre une plage',
);
}
});
test('t½ terminale ANALYTIQUE : EV = ln2/0,236 ≈ 2,94 j', () => {
const tHalf = model('EV').terminalHalfLifeDays;
assert.ok(Math.abs(tHalf - Math.log(2) / 0.236) < 1e-12);
});
test('normalisation par mg + gardes (dt ≤ 0, ester inconnu, EUCS couvert)', () => {
assert.equal(sample('EEN', 0), 0);
assert.equal(sample('EEN', -1), 0);
assert.equal(sample('XXX', 24), 0);
assert.ok(sample('EUCS', 24) > 0);
assert.equal(hasModel('XXX'), false);
assert.equal(hasModel('EUCS'), true);
});
});