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
174928bb6b
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
|
> 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
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 * 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';
|
||||||
|
|||||||
@ -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 ────────────────────
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
51
js/ui/app.js
51
js/ui/app.js
@ -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)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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)',
|
||||||
|
|||||||
@ -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') });
|
||||||
|
|
||||||
|
|||||||
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