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:
Siphonight 2026-09-19 22:16:13 +02:00
parent fc1b6e1700
commit fab5a2abfb
5 changed files with 100 additions and 32 deletions

View File

@ -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

View File

@ -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 });

View File

@ -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. */

View File

@ -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
View 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);
});
});