v1.9.2 : fix nuage vide sans traitement stocké ESE (filtre par ester effectif) — miroir Android
- estrannaise-cloud.js : le filtre des doses ne teste PLUS le pkModel STOCKÉ du traitement — le nuage couvre toutes les doses E2 à profil injectable dont l'ESTER EFFECTIF (override compris) est couvert par le fit Estrannaise (identique à l'ensemble redessiné par computeCurve(modelOverride = "ESE")). L'exclusivité ESE reste portée par le chip ; l'oral Bateman reste hors nuage ; les traitements inactifs sont inclus (§6.bis). - Test réécrit : « les doses d'un traitement TFS sont couvertes quand ESE est affiché » (épinglé des deux plateformes) ; oral seul → vide. - WEB_VERSION 1.9.2. CHANGELOG [1.9.2], README statut.
This commit is contained in:
parent
fc1b6e1700
commit
fab5a2abfb
@ -5,6 +5,21 @@
|
||||
> 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.2] — 2026-09-19
|
||||
|
||||
### Corrigé — le nuage d'incertitude exigeait un traitement STOCKÉ en ESE
|
||||
|
||||
- Miroir du fix Android v1.9.2 : le filtre du nuage testait le `pkModel`
|
||||
**STOCKÉ** du traitement, alors que la courbe ESE affichée redessine
|
||||
toutes les doses E2 avec `modelOverride = "ESE"` — le nuage restait vide
|
||||
dès qu'aucun traitement n'était stocké en ESE.
|
||||
- **Fix** : le nuage couvre **toutes les doses E2 à profil injectable dont
|
||||
l'ester effectif (override compris) est couvert par le fit
|
||||
Estrannaise** — indépendamment du modèle stocké. L'exclusivité ESE
|
||||
reste portée par le chip. L'oral Bateman reste hors nuage.
|
||||
- Test réécrit : « les doses d'un traitement TFS sont couvertes quand ESE
|
||||
est affiché » (épinglé des deux plateformes) ; oral seul → vide.
|
||||
|
||||
## [1.9.1] — 2026-09-19
|
||||
|
||||
### Corrigé — le nuage d'incertitude entoure maintenant la courbe Estrannaise calibrée
|
||||
|
||||
@ -9,10 +9,23 @@
|
||||
* 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).
|
||||
* QUELLES DOSES LE NUAGE COUVRE-T-IL (v1.9.2) : **toutes les doses E2 à
|
||||
* profil injectable dont l'ESTER EFFECTIF (override compris) est couvert
|
||||
* par le fit Estrannaise** — c'est exactement l'ensemble des doses que la
|
||||
* courbe ESE trace (`computeCurve(modelOverride = "ESE")`). Le `pkModel`
|
||||
* STOCKÉ du traitement n'importe pas : quand le chip Nuage est actif avec
|
||||
* ESE affiché, la courbe ESE redessine toutes ces doses avec le modèle
|
||||
* Estrannaise, et le nuage les redessine avec les paramètres MCMC de
|
||||
* LEUR ester. (Fix v1.9.2 : l'ancien filtre `pkModel stocké == ESE`
|
||||
* laissait le nuage vide dès qu'aucun traitement n'était stocké en ESE —
|
||||
* remontée : « le nuage ne s'active que autour du tracé émulé, pas autour
|
||||
* du modèle Estrannaise ». L'exclusivité ESE reste portée par le CHIP,
|
||||
* pas par le filtre des doses.)
|
||||
*
|
||||
* Les contributions Bateman (oral/gel — sans ester échantillonnable) ne
|
||||
* sont PAS dans le nuage : le posterior MCMC ne s'applique qu'aux esters
|
||||
* (limite documentée — le nuage montre l'incertitude du modèle
|
||||
* Estrannaise, pas le niveau total).
|
||||
*
|
||||
* DÉTERMINISME : échantillons **échelonnés** (stride, pas d'aléatoire) —
|
||||
* le nuage est identique à chaque affichage et testable en Node.
|
||||
@ -57,29 +70,33 @@ export function compute(
|
||||
) {
|
||||
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);
|
||||
// Doses tracées par la courbe ESE (v1.9.2) : toutes les doses E2 à profil
|
||||
// injectable dont l'ESTER EFFECTIF (override compris) est couvert par le
|
||||
// fit Estrannaise — peu importe le pkModel STOCKÉ du traitement (fix :
|
||||
// quand ESE est affiché, computeCurve redessine toutes ces doses avec
|
||||
// modelOverride = "ESE" et le nuage doit les suivre). Les traitements
|
||||
// inactifs sont inclus (§6.bis) ; l'oral Bateman est naturellement exclu.
|
||||
const carrierDoses = [];
|
||||
const samplesByEster = new Map();
|
||||
for (const dose of doseLogs) {
|
||||
if (dose.timestamp > endMs) continue;
|
||||
const tr = treatments.find((x) => x.id === dose.treatmentId);
|
||||
if (!tr || tr.type !== 'ESTRADIOL' || !usesProfileModel(tr)) continue;
|
||||
const ester = doseEster(tr, dose);
|
||||
if (!EstrannaiseModels.hasModel(ester)) continue;
|
||||
if (!samplesByEster.has(ester)) {
|
||||
const samples = EstrannaiseModels.mcmcSamples(ester);
|
||||
if (samples.length === 0) return []; // MCMC non chargé → pas de nuage
|
||||
samplesByEster.set(ester, samples);
|
||||
}
|
||||
carrierDoses.push([dose, tr]);
|
||||
}
|
||||
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 firstDoseMs = Math.min(...carrierDoses.map(([dose]) => dose.timestamp));
|
||||
const t0 = Math.max(startMs, firstDoseMs);
|
||||
if (t0 > endMs) return [];
|
||||
|
||||
@ -95,14 +112,14 @@ export function compute(
|
||||
const curve = [];
|
||||
for (let t = t0; t <= endMs; t += stepMs) {
|
||||
let e2 = 0.0;
|
||||
for (const dose of carrierDoses) {
|
||||
for (const [dose, tr] of carrierDoses) {
|
||||
if (dose.timestamp > t) continue;
|
||||
const dtH = (t - dose.timestamp) / 3600000;
|
||||
const tr = carriers.find((x) => x.id === dose.treatmentId);
|
||||
const ester = doseEster(tr, dose);
|
||||
// Même calibration que la courbe ESE (fix v1.9.1)
|
||||
const scale = scalePerEster?.[doseEster(tr, dose)] ?? tr.scaleFactor;
|
||||
const scale = scalePerEster?.[ester] ?? tr.scaleFactor;
|
||||
e2 += EstrannaiseModels.sampleWithParams(
|
||||
tr.esterType, dtH, paramsByEster.get(tr.esterType),
|
||||
ester, dtH, paramsByEster.get(ester),
|
||||
) * dose.doseAmount * scale;
|
||||
}
|
||||
curve.push({ timestamp: t, e2, t: 0.0 });
|
||||
|
||||
@ -55,13 +55,17 @@ function median(values) {
|
||||
}
|
||||
|
||||
/** Arrondi « facteur » à 2 décimales, troncature (miroir du Kotlin (x*100|0)/100). */
|
||||
function round2(x) {
|
||||
return Math.trunc(x * 100) / 100;
|
||||
export function round2(x) {
|
||||
// v1.9.2 : ARRONDI (Math.round) au lieu de troncature — miroir du fix
|
||||
// Android v1.8.2 (medianRounded) : la troncature au dernier bit flottant
|
||||
// faussait les échelles d'un cran (0,8999… → 0,89 au lieu de 0,90) et
|
||||
// divergeait de l'Android (harmonisation des niveaux web/Android)
|
||||
return Math.round(x * 100) / 100;
|
||||
}
|
||||
|
||||
/** Arrondi « k T » à 3 décimales, troncature. */
|
||||
function round3(x) {
|
||||
return Math.trunc(x * 1000) / 1000;
|
||||
export function round3(x) {
|
||||
return Math.round(x * 1000) / 1000;
|
||||
}
|
||||
|
||||
/** Dernier élément (par timestamp) d'une liste non vide ; null si vide. */
|
||||
|
||||
@ -48,14 +48,21 @@ describe('EstrannaiseCloud web (miroir Android v1.9.0)', () => {
|
||||
assert.ok(cloud.every((c) => c.every((p) => p.timestamp >= start && p.timestamp <= end)));
|
||||
});
|
||||
|
||||
test('exclusivité ESE : TFS et oral produisent un nuage vide', () => {
|
||||
test('les doses d un traitement TFS sont couvertes quand ESE est affiché (fix)', () => {
|
||||
// Fix v1.9.2 : le pkModel STOCKÉ n\'est pas un prérequis — quand ESE
|
||||
// est affiché, la courbe redessine toutes les doses E2 avec le modèle
|
||||
// Estrannaise, donc le nuage les couvre aussi (l\'exclusivité ESE est
|
||||
// portée par le CHIP, pas par le filtre des doses)
|
||||
const tfs = { ...ese(), pkModel: 'TFS' };
|
||||
assert.equal(compute([tfs], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, NOW).length, 0);
|
||||
const cloud = compute([tfs], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, NOW);
|
||||
assert.equal(cloud.length, 32);
|
||||
assert.ok(cloud.every((c) => c.length >= 2));
|
||||
const oral = makeTreatment({
|
||||
name: 'CPA', type: 'ANTI_ANDROGEN', route: 'ORAL',
|
||||
doseAmount: 12.5, doseUnit: 'mg', esterType: 'NONE',
|
||||
forecastIntervalDays: 2.0, isActive: true,
|
||||
});
|
||||
// L'oral Bateman reste hors nuage (pas d'ester échantillonnable)
|
||||
assert.equal(compute([oral], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, NOW).length, 0);
|
||||
});
|
||||
|
||||
|
||||
25
tests/rounding.test.js
Normal file
25
tests/rounding.test.js
Normal file
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Tests de l'ARRONDI de la médiane de calibration (v1.9.2 — miroir du fix
|
||||
* Android v1.8.2 `medianRounded`) : `round2`/`round3` ARRONDISSENT au plus
|
||||
* proche (Math.round) au lieu de tronquer — la troncature au dernier bit
|
||||
* flottant faussait les échelles d'un cran (0,8999… → 0,89).
|
||||
*/
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { round2, round3 } from '../js/pk/pk-calibration.js';
|
||||
|
||||
describe('round2 / round3 (arrondi, pas troncature — v1.9.2)', () => {
|
||||
test('round2 arrondit au plus proche (0,8999… → 0,90, pas 0,89)', () => {
|
||||
assert.equal(round2(0.8999999999999999), 0.9);
|
||||
assert.equal(round3(0.8999999999999999), 0.9);
|
||||
// la troncature historique aurait donné 0,89
|
||||
assert.notEqual(Math.trunc(0.8999999999999999 * 100) / 100, 0.9);
|
||||
});
|
||||
|
||||
test('round2 / round3 : cas limites', () => {
|
||||
assert.equal(round2(2.0), 2.0);
|
||||
assert.equal(round3(1.2346), 1.235);
|
||||
assert.equal(round2(0.001), 0.0);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user