diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f5f8d4f..425d376 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/js/pk/estrannaise-cloud.js b/js/pk/estrannaise-cloud.js index d8a01de..82cfb88 100644 --- a/js/pk/estrannaise-cloud.js +++ b/js/pk/estrannaise-cloud.js @@ -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 }); diff --git a/js/pk/pk-calibration.js b/js/pk/pk-calibration.js index 4a4938d..2bde451 100644 --- a/js/pk/pk-calibration.js +++ b/js/pk/pk-calibration.js @@ -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. */ diff --git a/tests/estrannaise-cloud.test.js b/tests/estrannaise-cloud.test.js index 1d59bbe..e93aeba 100644 --- a/tests/estrannaise-cloud.test.js +++ b/tests/estrannaise-cloud.test.js @@ -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); }); diff --git a/tests/rounding.test.js b/tests/rounding.test.js new file mode 100644 index 0000000..0d8ea30 --- /dev/null +++ b/tests/rounding.test.js @@ -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); + }); +});