HormoneTrack-web/tests/estrannaise-models.test.js
Siphonight 174928bb6b 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.
2026-09-19 20:45:53 +02:00

97 lines
3.5 KiB
JavaScript

/**
* 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);
});
});