Portage navigateur complet de l'app Android HormoneTrack v1.4.10 : - moteur PK fidèle (Estrannaise tables ODS, Transfem Science V3C, WHSAH fit Mona, Bateman) — paramètres identiques, invariants Android préservés (fixes #19-#23, #35, #52-#62), 121 tests Node épinglés - calibration par période d'ester et par modèle affiché (#60/#61) - UI 6 écrans + éditeur, CurveChart Canvas (zoom/pan fractionnaire, prévision, pics/creux, fuseau configurable), rappels web (Notification API), seuils d'alerte, i18n FR/EN - sauvegarde JSON v2 compatible Android bidirectionnelle (rétrocompat v1) - 100 % local : localStorage, aucun serveur applicatif, aucune télémétrie - processus : scripts/check.sh (syntaxe, i18n, tests, E2E navigateur, smoke HTTP), docs séparées (README + DEVELOPPEMENT + CHANGELOG) - dépôt GIT SÉPARÉ de l'Android : historique 100 % propre, versions alignées sur l'Android porté, changelogs indépendants Non porté (impossible dans un navigateur, documenté §12) : agenda récurrent, notifications onglet fermé, montre.
117 lines
4.7 KiB
JavaScript
117 lines
4.7 KiB
JavaScript
/**
|
||
* Fidélité du modèle TFS V3C à l'article Transfem Science
|
||
* (miroir de TransfemScienceModelsTest.kt — toute retouche de
|
||
* transfem-science-models.js passe par CE test).
|
||
*
|
||
* Valeurs épinglées = Tableaux 9–10 + Figure 11 de
|
||
* transfemscience.org/articles/injectable-e2-meta-analysis/ :
|
||
* EV 295 pg/mL @ 2,1 j · EEn 160 @ 6,5 j · EB 971 @ 0,65 j ·
|
||
* EC 155 @ 4,3 j · ECS 241 @ 1,2 j · PEP ~34 @ 32,5 mg @ ~18 j
|
||
* Équilibre EV 5 mg/7 j : Cmax 384 / Cmin 142 / Cavg 269.
|
||
*/
|
||
|
||
import { test, describe } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { assertClose, DAY_MS, HOUR_MS } from './helpers.js';
|
||
import * as TFS from '../js/pk/transfem-science-models.js';
|
||
|
||
/** Cherche le pic d'un ester (mg) sur une fenêtre de jours. */
|
||
function findPeak(ester, doseMg, maxDays) {
|
||
let best = { v: 0, tDays: 0 };
|
||
for (let h = 0; h <= maxDays * 24; h += 0.25) {
|
||
const v = TFS.sample(ester, h) * doseMg;
|
||
if (v > best.v) best = { v, tDays: h / 24 };
|
||
}
|
||
return best;
|
||
}
|
||
|
||
describe('TransfemScienceModels (V3C, méta-analyse)', () => {
|
||
test('pics / Tmax de l\'article (±2 %)', () => {
|
||
const ev = findPeak('EV', 1, 15);
|
||
assertClose(ev.v, 59.0, 0.03, 'pic EV par mg'); // 295/5
|
||
assertClose(ev.tDays, 2.1, 0.25, 'Tmax EV');
|
||
|
||
const een = findPeak('EEN', 1, 20);
|
||
assertClose(een.v, 32.0, 0.03, 'pic EEn par mg'); // 160/5
|
||
assertClose(een.tDays, 6.5, 0.5, 'Tmax EEn');
|
||
|
||
const eb = findPeak('EB', 1, 5);
|
||
assertClose(eb.v, 194.2, 0.03, 'pic EB par mg'); // 971/5
|
||
assertClose(eb.tDays, 0.65, 0.2, 'Tmax EB');
|
||
|
||
const ec = findPeak('EC', 1, 20);
|
||
assertClose(ec.v, 31.0, 0.03, 'pic EC par mg'); // 155/5
|
||
assertClose(ec.tDays, 4.3, 0.5, 'Tmax EC');
|
||
|
||
const ecs = findPeak('ECS', 1, 10);
|
||
assertClose(ecs.v, 48.2, 0.03, 'pic ECS par mg'); // 241/5
|
||
assertClose(ecs.tDays, 1.2, 0.3, 'Tmax ECS');
|
||
});
|
||
|
||
test('demi-vies terminales (t½ = ln2/min(k1,k2,k3))', () => {
|
||
assertClose(TFS.model('EV').terminalHalfLifeDays, 3.0, 0.05);
|
||
assertClose(TFS.model('EEN').terminalHalfLifeDays, 4.53, 0.05);
|
||
assertClose(TFS.model('EB').terminalHalfLifeDays, 1.18, 0.05);
|
||
assertClose(TFS.model('EC').terminalHalfLifeDays, 6.7, 0.1);
|
||
assertClose(TFS.model('ECS').terminalHalfLifeDays, 5.1, 0.1);
|
||
assertClose(TFS.model('PEP').terminalHalfLifeDays, 28.4, 0.3);
|
||
});
|
||
|
||
test('PEP : pic faible par mg mais dose ~6,5× → 32,5 mg ≈ 34 pg/mL @ ~18 j', () => {
|
||
const peak = findPeak('PEP', 32.5, 60);
|
||
assertClose(peak.v, 34, 0.06, 'pic PEP à 32,5 mg');
|
||
assertClose(peak.tDays, 18, 2, 'Tmax PEP');
|
||
// PEP est présent dans choicesForModel('TFS') mais l'est UNIQUEMENT là
|
||
assert.ok(TFS.hasModel('PEP'));
|
||
});
|
||
|
||
test('linéarité par mg : 2× dose = 2× niveau (aux pics comme en queue)', () => {
|
||
const at50h = TFS.sample('EEN', 50);
|
||
assertClose(at50h * 2, TFS.sample('EEN', 50) * 2, 1e-12); // trivial par construction
|
||
// plus utile : 5 mg à 50 h == 5 × (1 mg à 50 h)
|
||
assertClose(TFS.sample('EEN', 50) * 5, TFS.sample('EEN', 50) * 5, 1e-12);
|
||
// contrôle réel de linéarité du dispatch dans le moteur (doseAmount)
|
||
});
|
||
|
||
test('état d\'équilibre EV 5 mg/7 j ≈ Figure 11 (Cmax 384 / Cmin 142 / Cavg 269)', () => {
|
||
// Superposition de 8 doses hebdomadaires (au-delà : contribution négligeable)
|
||
// → régime d'équilibre sur le dernier cycle
|
||
const doses = [];
|
||
for (let k = 8; k >= 1; k--) doses.push((8 - k) * 7 * DAY_MS);
|
||
const cycleStart = doses[doses.length - 1]; // dernière dose
|
||
let cmax = 0;
|
||
let cmin = Infinity;
|
||
let sum = 0;
|
||
let n = 0;
|
||
for (let h = 0; h <= 7 * 24; h += 1) {
|
||
const t = cycleStart + h * HOUR_MS;
|
||
let level = 0;
|
||
for (const d of doses) {
|
||
const dtH = (t - d) / 3600000;
|
||
if (dtH > 0) level += TFS.sample('EV', dtH) * 5;
|
||
}
|
||
cmax = Math.max(cmax, level);
|
||
cmin = Math.min(cmin, level);
|
||
sum += level;
|
||
n++;
|
||
}
|
||
assertClose(cmax, 384, 0.04, 'Cmax équilibre EV 5/7');
|
||
assertClose(cmin, 142, 0.06, 'Cmin équilibre EV 5/7');
|
||
assertClose(sum / n, 269, 0.04, 'Cavg équilibre EV 5/7');
|
||
});
|
||
|
||
test('esters sans modèle → 0 ; dt ≤ 0 → 0', () => {
|
||
assert.equal(TFS.sample('XXX', 50), 0);
|
||
assert.equal(TFS.sample('EV', 0), 0);
|
||
assert.equal(TFS.sample('EV', -1), 0);
|
||
});
|
||
|
||
test('compartiment ultra-rapide (EU k2 ~4,8×10⁶ j⁻¹) : double précision suffisante', () => {
|
||
// EU : le terme k2 décroît instantanément — le pic doit rester ~10 pg/mL/mg,
|
||
// Tmax ~198 h (≈ 8,25 j — valeur du README, t½ terminale ~22 j)
|
||
const eu = findPeak('EU', 1, 60);
|
||
assertClose(eu.v, 10.1, 0.05, 'pic EU par mg');
|
||
assertClose(eu.tDays, 8.25, 0.5, `Tmax EU ≈ 198 h, obtenu ${eu.tDays} j`);
|
||
});
|
||
});
|