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:
parent
61683ba5c4
commit
8eddd885f4
1
assets/mcmc_samples.json
Normal file
1
assets/mcmc_samples.json
Normal file
File diff suppressed because one or more lines are too long
@ -5,6 +5,30 @@
|
||||
> section la plus récente EN PREMIER ; ce fichier est lu par le dialog
|
||||
> « 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
|
||||
|
||||
### Corrigé — la stabilisation ignorait les changements récents (dose/ester/intervalle)
|
||||
|
||||
103
js/pk/estrannaise-cloud.js
Normal file
103
js/pk/estrannaise-cloud.js
Normal 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
141
js/pk/estrannaise-models.js
Normal 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()]) || [];
|
||||
}
|
||||
@ -11,10 +11,12 @@ export * from './pk-extrema.js';
|
||||
export * as LabTrajectoryModel from './lab-trajectory-model.js';
|
||||
export { computeLabAnchoredCurve, ratioAt, extensionHorizonEndMs } from './lab-trajectory-model.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 { compute as computeEstrannaiseCloud, DEFAULT_NB_CURVES } from './estrannaise-cloud.js';
|
||||
export * from './chart-helpers.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 WhsahModels from './whsah-models.js';
|
||||
export * as PKPresets from './presets.js';
|
||||
|
||||
@ -35,6 +35,7 @@
|
||||
*/
|
||||
|
||||
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 whsahModel } from './whsah-models.js';
|
||||
import * as pkProfileStore from './pk-profile-store.js';
|
||||
@ -77,7 +78,8 @@ export function nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs =
|
||||
let tHalfDays = 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 === '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;
|
||||
|
||||
// ── 3) Début du régime courant + date de stabilisation ────────────────────
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
|
||||
import * as TransfemScienceModels from './transfem-science-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. */
|
||||
export const HOUR_MS = 3600000;
|
||||
@ -140,7 +140,7 @@ export function doseEster(treatment, dose) {
|
||||
* 1. traitement à PROFIL PK (injection + ester ≠ NONE) :
|
||||
* - TFS + ester couvert par le V3C → TransfemScienceModels ;
|
||||
* - 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).
|
||||
*
|
||||
* `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)) {
|
||||
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)) {
|
||||
profileH = TransfemScienceModels.model(treatment.esterType).terminalHalfLifeDays * 24.0 * 10.0;
|
||||
} 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;
|
||||
|
||||
51
js/ui/app.js
51
js/ui/app.js
@ -3,7 +3,7 @@
|
||||
* HormoneTrack Web — shell applicatif (portage de HormoneTrackApp.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/
|
||||
* #treatment-edit/{id}) — permet la navigation headless et le lien profond ;
|
||||
* - 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 * as AppLog from '../util/app-log.js';
|
||||
import { isVersionNewer, sectionsSince } from '../util/changelog.js';
|
||||
@ -57,7 +57,9 @@ async function main() {
|
||||
// 4) Asset PK (équivalent de PKProfileStore.init(context) dans
|
||||
// HormoneTrackApp.onCreate) — les courbes ne sont calculées qu'après ;
|
||||
// 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)
|
||||
document.documentElement.lang = resolveLocale(store.getSettings().language);
|
||||
@ -154,14 +156,6 @@ function renderRoute() {
|
||||
const preserveChartState = route === 'chart' && lastRoute === 'chart';
|
||||
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
|
||||
// scripts/e2e.mjs pour attendre la fin du rendu (plus de course au screenshot).
|
||||
document.body.setAttribute('data-ready', '1');
|
||||
@ -364,6 +358,41 @@ function maybeLoadDemoData() {
|
||||
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)');
|
||||
}
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
|
||||
import {
|
||||
computeCurve, generateForecastDoses, autoCalibrated, TConfig,
|
||||
computeLabAnchoredCurve,
|
||||
computeLabAnchoredCurve, computeEstrannaiseCloud,
|
||||
} from '../pk/index.js';
|
||||
import {
|
||||
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
|
||||
// (miroir ChartScreen.kt).
|
||||
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)
|
||||
togglesInitialized: false,
|
||||
};
|
||||
@ -253,7 +259,13 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
|
||||
|
||||
// Rangée 3 : toggles modèles superposables
|
||||
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_whs'), state.showWhs, () => { state.showWhs = !state.showWhs; redraw(); }));
|
||||
// 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 :
|
||||
// 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));
|
||||
// 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é ─────────────────────────────────────
|
||||
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
|
||||
// (leçon #63 Android : sinon le catchall imprime la légende TFS en
|
||||
// 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) {
|
||||
const step = stepForRange(state.rangeHours);
|
||||
const labCurve = computeLabAnchoredCurve(
|
||||
treatments, doseLogs, labResults, startMs, endMs, step, null,
|
||||
state.showLabTrackExtend,
|
||||
@ -292,6 +307,16 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
|
||||
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) {
|
||||
// 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
|
||||
@ -307,7 +332,6 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
|
||||
const allDoses = doseLogs.concat(forecastDoses);
|
||||
|
||||
// Pas d'échantillonnage adaptatif au zoom (v1.2.9)
|
||||
const step = stepForRange(state.rangeHours);
|
||||
const addCurve = (model) => {
|
||||
const autoM = autoByModel.get(model);
|
||||
curves.push([model, computeCurve(
|
||||
@ -354,6 +378,14 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
|
||||
drawChart(
|
||||
canvas,
|
||||
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') {
|
||||
// v1.5.0 : Tracé labs = E2 rose foncé pointillé, PAS de courbe T
|
||||
// (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
|
||||
// (légendes dédiées plus bas, fix #63 Android-miroir : sinon le
|
||||
// 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);
|
||||
if (model === 'ESE') {
|
||||
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
|
||||
// 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.
|
||||
// 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
|
||||
&& curves.some(([m, pts]) => m === 'LABX' && pts.length >= 2);
|
||||
if (labExtendVisible) {
|
||||
|
||||
@ -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`).
|
||||
* 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. */
|
||||
function parseNumOrNull(text) {
|
||||
|
||||
@ -194,6 +194,10 @@ const EN = {
|
||||
// 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)
|
||||
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.',
|
||||
back_to_present: 'Back to present',
|
||||
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,
|
||||
// actif seulement quand Tracé labs est ON — miroir strings.xml Android)
|
||||
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.',
|
||||
back_to_present: 'Revenir à maintenant',
|
||||
legend_ese_e2: 'E2 — Estrannaise (bleu)',
|
||||
|
||||
@ -131,7 +131,7 @@ async function main() {
|
||||
ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)');
|
||||
if (dialog) {
|
||||
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');
|
||||
ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut');
|
||||
await page.click('.dialog .actions button');
|
||||
@ -183,24 +183,25 @@ async function main() {
|
||||
}
|
||||
await page.waitForTimeout(400);
|
||||
// 1) Le chip « Prolonger » est DÉSACTIVÉ tant que « Tracé labs » est off
|
||||
const extendDisabledBefore = await page.evaluate((lbl) => {
|
||||
const b = [...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl);
|
||||
const chipDisabled = (lbl) => page.evaluate((label) => {
|
||||
const b = [...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === label);
|
||||
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');
|
||||
// 2) Active Tracé labs → la légende ancrée apparaît (sans avertissement)
|
||||
await page.evaluate((lbl) => {
|
||||
[...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl)?.click();
|
||||
}, 'Tracé labs');
|
||||
await clickChip('Tracé labs');
|
||||
await page.waitForTimeout(400);
|
||||
await page.waitForTimeout(400);
|
||||
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('Simple simulation, sans garantie'),
|
||||
'pas d\'avertissement tant que la prolongation est off');
|
||||
// 3) Active « Prolonger » → légende prolongée + AVERTISSEMENT VISIBLE
|
||||
await page.evaluate((lbl) => {
|
||||
[...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl)?.click();
|
||||
}, 'Prolonger');
|
||||
await clickChip('Prolonger');
|
||||
await page.waitForTimeout(400);
|
||||
const extendText = await page.textContent('main.content');
|
||||
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'),
|
||||
'avertissement : les résultats de lab peuvent être erronés');
|
||||
// 4) Désactive « Prolonger » → avertissement ET légende prolongée disparaissent
|
||||
await page.evaluate((lbl) => {
|
||||
[...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl)?.click();
|
||||
}, 'Prolonger');
|
||||
await clickChip('Prolonger');
|
||||
await page.waitForTimeout(400);
|
||||
const offText = await page.textContent('main.content');
|
||||
ok(!offText.includes('Tracé labs prolongé') && !offText.includes('Simple simulation, sans garantie'),
|
||||
'désactivation → légende prolongée + avertissement disparaissent');
|
||||
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) ───────────────────
|
||||
// Bug remonté : les options se réinitialisaient « toutes seules » car le
|
||||
// tick minute / une mutation du store / un resize re-créaient l'écran
|
||||
@ -298,7 +334,7 @@ async function main() {
|
||||
await page.waitForTimeout(300);
|
||||
text = await page.textContent('main.content');
|
||||
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');
|
||||
await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') });
|
||||
|
||||
|
||||
69
tests/estrannaise-cloud.test.js
Normal file
69
tests/estrannaise-cloud.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
96
tests/estrannaise-models.test.js
Normal file
96
tests/estrannaise-models.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user