v1.9.2 : fix nuage vide sans traitement stocké ESE (filtre par ester effectif)

Bug remonté : activer Estrannaise dans le graphique puis le nuage
n'affichait rien tant qu'aucun traitement n'était STOCKÉ avec le modèle
ESE — « la seule méthode trouvée était de mettre un traitement en cours
sur le modèle ESE, mais ça ne devrait pas être un prérequis ».

Cause : 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" (peu importe le modèle stocké) — courbe et nuage
n'utilisaient pas la même définition de « quelles doses sont tracées en
ESE ».

Fix : EstrannaiseCloud.compute 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 (activable seulement si ESE est affiché).
L'oral Bateman reste hors nuage (pas d'ester échantillonnable). Les
traitements inactifs sont inclus (§6.bis : la courbe ESE les trace aussi).

Tests réécrits (237 verts / 207 sans données locales) : « les doses d'un
traitement TFS sont couvertes quand ESE est affiché » (épinglé Android +
web) ; oral seul → vide (conservé). Validé émulateur §16.ter sur le
profil réel de l'utilisatrice (EEn stocké TFS, ESE affiché, nuage visible
autour de la courbe, 0 crash). Web miroir v1.9.2. versionCode 40 /
versionName 1.9.2.
This commit is contained in:
Siphonight 2026-09-19 22:10:35 +02:00
parent 96a0dbc56a
commit 03fdbf61ed
7 changed files with 139 additions and 47 deletions

View File

@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue. > sang et aux consignes de ton endocrinologue.
- **Statut** : v1.9.1 — build Android ✅, **lint vert** ✅, **235 tests unitaires** ✅ (205 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅ - **Statut** : v1.9.2 — build Android ✅, **lint vert** ✅, **237 tests unitaires** ✅ (207 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) - **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) - **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
@ -123,7 +123,7 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1.
git clone <repo> && cd HormoneTrack git clone <repo> && cd HormoneTrack
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk ./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
./gradlew testDebugUnitTest # 235 tests (205 sans les données locales) ./gradlew testDebugUnitTest # 237 tests (207 sans les données locales)
./gradlew lint # lint vert obligatoire avant release ./gradlew lint # lint vert obligatoire avant release
``` ```

View File

@ -17,8 +17,8 @@ android {
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0" // tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans // (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
// les APK publiés (Paramètres + titre du dialog « Nouveautés »). // les APK publiés (Paramètres + titre du dialog « Nouveautés »).
versionCode = 39 versionCode = 40
versionName = "1.9.1" versionName = "1.9.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -16,13 +16,23 @@ import com.hormonetrack.data.model.TreatmentType
* site estrannaise. TFS/WHSAH ne publient pas de posterior → pas de nuage * site estrannaise. TFS/WHSAH ne publient pas de posterior → pas de nuage
* pour eux (choix confirmé : le nuage reste sur ESE). * pour eux (choix confirmé : le nuage reste sur ESE).
* *
* QU'EST-CE QUE LE NUAGE REPRÉSENTE : la superposition des doses des * QUELLES DOSES LE NUAGE COUVRE-T-IL (v1.9.2) : **toutes les doses E2 à
* traitements E2 **à profil ESE** évaluée avec UN échantillon MCMC par * profil injectable dont l'ESTER EFFECTIF (override compris) est couvert
* courbe — chaque polyligne du nuage est « ce que serait la courbe si le * par le fit Estrannaise** — c'est exactement l'ensemble des doses que la
* vrai paramètre physiologique était celui-là ». Les contributions des * courbe ESE trace (`computeCurve(modelOverride = "ESE")`). Le `pkModel`
* autres traitements (TFS/WHSAH/Bateman) ne sont PAS incluses dans le * STOCKÉ du traitement n'importe pas : quand le chip Nuage est actif avec
* nuage : elles n'ont pas d'incertitude publiée (limite documentée — le * ESE affiché, la courbe ESE redessine toutes ces doses avec le modèle
* nuage montre l'incertitude du modèle Estrannaise, pas le niveau total). * 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 : les M échantillons sont **échelonnés** (stride sur les * DÉTERMINISME : les M échantillons sont **échelonnés** (stride sur les
* 313, pas d'aléatoire) — le nuage est identique à chaque affichage et * 313, pas d'aléatoire) — le nuage est identique à chaque affichage et
@ -78,34 +88,51 @@ object EstrannaiseCloud {
): List<List<LevelPoint>> { ): List<List<LevelPoint>> {
if (startMs >= endMs || stepMs <= 0 || nbCurves < 2) return emptyList() if (startMs >= endMs || stepMs <= 0 || nbCurves < 2) return emptyList()
// ── Porteurs : traitements E2 actifs à profil ESE avec ester couvert ── // ── Doses tracées par la courbe ESE (v1.9.2) ─────────────────────────
val carriers = treatments.filter { // Toutes les doses E2 à profil injectable dont l'ESTER EFFECTIF
it.type == TreatmentType.ESTRADIOL && it.isActive && // (override compris) est couvert par le fit Estrannaise — peu importe
it.usesProfileModel && it.pkModel == PKModels.ESTRANNAISE && // le pkModel STOCKÉ du traitement : quand ESE est affiché,
EstrannaiseModels.hasModel(it.esterType) // computeCurve redessine toutes ces doses avec modelOverride = "ESE"
// et le nuage doit les suivre (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 doses des traitements inactifs sont
// incluses (§6.bis : l'inactif reste simulé, la courbe ESE les
// trace aussi). La couche Bateman (oral/gel sans ester
// échantillonnable) est naturellement exclue.
val carrierDoses = ArrayList<Pair<DoseLog, Treatment>>()
val samplesByEster = HashMap<String, List<EstrannaiseModels.Param>>()
for (dose in doseLogs) {
if (dose.timestamp > endMs) continue
val tr = treatments.firstOrNull { it.id == dose.treatmentId } ?: continue
if (tr.type != TreatmentType.ESTRADIOL || !tr.usesProfileModel) continue
val ester = PharmacokineticEngine.doseEster(tr, dose)
if (!EstrannaiseModels.hasModel(ester)) continue
if (!samplesByEster.containsKey(ester)) {
// Posterior MCMC de cet ester (chargé au démarrage) — vide si
// l'asset n'est pas chargé → pas de nuage (sans posterior,
// pas de plage d'imprécision à dessiner)
val samples = EstrannaiseModels.mcmcSamples(ester)
if (samples.isEmpty()) return emptyList()
samplesByEster[ester] = samples
} }
if (carriers.isEmpty()) return emptyList() carrierDoses.add(dose to tr)
}
val carrierIds = carriers.map { it.id }.toSet() if (carrierDoses.isEmpty()) return emptyList()
val carrierDoses = doseLogs
.filter { it.treatmentId in carrierIds && it.timestamp <= endMs }
.ifEmpty { return emptyList() }
// ── Échantillons échelonnés du posterior (déterministes) ───────────── // ── Échantillons échelonnés du posterior (déterministes) ─────────────
// Un échantillon m est appliqué À TOUS les esters du nuage (même // Un échantillon m est appliqué À TOUS les esters du nuage (même
// index) : le nuage explore le posterior ester par ester, index par // index) : le nuage explore le posterior ester par ester, index par
// index — reproductible. // index — reproductible.
val samplesByEster = carriers.associate {
it.esterType to EstrannaiseModels.mcmcSamples(it.esterType)
}
if (samplesByEster.values.any { it.isEmpty() }) return emptyList()
val maxSamples = samplesByEster.values.minOf { it.size } val maxSamples = samplesByEster.values.minOf { it.size }
val stride = (maxSamples.toDouble() / nbCurves.coerceAtMost(maxSamples)).toInt() val stride = (maxSamples.toDouble() / nbCurves.coerceAtMost(maxSamples)).toInt()
.coerceAtLeast(1) .coerceAtLeast(1)
// Grille : de max(start, 1ʳᵉ dose du porteur) à endMs — M=0 avant la // Grille : de max(start, 1ʳᵉ dose du porteur) à endMs — M=0 avant la
// 1ʳᵉ dose (rien à faire varier) // 1ʳᵉ dose (rien à faire varier)
val firstDoseMs = carrierDoses.minOf { it.timestamp } val firstDoseMs = carrierDoses.minOf { (dose, _) -> dose.timestamp }
val t0 = maxOf(startMs, firstDoseMs) val t0 = maxOf(startMs, firstDoseMs)
if (t0 > endMs) return emptyList() if (t0 > endMs) return emptyList()
@ -120,17 +147,15 @@ object EstrannaiseCloud {
var t = t0 var t = t0
while (t <= endMs) { while (t <= endMs) {
var e2 = 0.0 var e2 = 0.0
for (dose in carrierDoses) { for ((dose, tr) in carrierDoses) {
if (dose.timestamp > t) continue if (dose.timestamp > t) continue
val dtH = (t - dose.timestamp) / 3_600_000.0 val dtH = (t - dose.timestamp) / 3_600_000.0
val tr = carriers.first { it.id == dose.treatmentId } val ester = PharmacokineticEngine.doseEster(tr, dose)
val params = paramsByEster.getValue(tr.esterType) val params = paramsByEster.getValue(ester)
// Même calibration que la courbe ESE : le nuage ENTOURE // Même calibration que la courbe ESE : le nuage ENTOURE
// la courbe calibrée (fix v1.9.1) // la courbe calibrée (fix v1.9.1)
val scale = scalePerEster?.get( val scale = scalePerEster?.get(ester) ?: tr.scaleFactor
PharmacokineticEngine.doseEster(tr, dose) e2 += EstrannaiseModels.sampleWithParams(ester, dtH, params) * dose.doseAmount * scale
) ?: tr.scaleFactor
e2 += EstrannaiseModels.sampleWithParams(tr.esterType, dtH, params) * dose.doseAmount * scale
} }
curve.add(LevelPoint(t, e2, 0.0)) curve.add(LevelPoint(t, e2, 0.0))
t += stepMs t += stepMs

View File

@ -80,11 +80,24 @@ class EstrannaiseCloudTest {
} }
@Test @Test
fun `non-ESE carriers produce no cloud (feature exclusive to ESE)`() { fun `doses of a TFS-stored treatment ARE covered when ESE is displayed (fix v1_9_2)`() {
// TFS : pas de posterior publié → nuage vide // FIX « le nuage ne s'active que autour du tracé émulé, pas autour du
val tfs = ese().copy(pkModel = "TFS") // modèle Estrannaise » : le traitement est STOCKÉ en TFS, mais quand
assertTrue(EstrannaiseCloud.compute(listOf(tfs), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW).isEmpty()) // ESE est affiché, computeCurve(modelOverride = "ESE") redessine ses
// Oral (Bateman) : idem // doses avec le modèle Estrannaise → le nuage doit les couvrir aussi
// (le pkModel stocké n'est pas un prérequis). Le nuage superpose les
// doses EEn avec les params MCMC de EEN.
val tfsStored = ese().copy(pkModel = "TFS")
val cloud = EstrannaiseCloud.compute(listOf(tfsStored), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW)
assertEquals(32, cloud.size)
assertTrue(cloud.all { it.size >= 2 })
}
@Test
fun `oral-only treatment produces no cloud (Bateman is not MCMC-sampled)`() {
// L'oral Bateman n'a pas d'ester E2 échantillonnable : le posterior
// MCMC ne s'applique pas → pas de nuage (cohérent : rien n'est tracé
// « en ESE » pour ces doses)
val oral = Treatment( val oral = Treatment(
id = 2, name = "CPA", type = TreatmentType.ANTI_ANDROGEN, id = 2, name = "CPA", type = TreatmentType.ANTI_ANDROGEN,
route = AdministrationRoute.ORAL, doseAmount = 12.5, doseUnit = "mg", route = AdministrationRoute.ORAL, doseAmount = 12.5, doseUnit = "mg",
@ -93,6 +106,17 @@ class EstrannaiseCloudTest {
assertTrue(EstrannaiseCloud.compute(listOf(oral), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW).isEmpty()) assertTrue(EstrannaiseCloud.compute(listOf(oral), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW).isEmpty())
} }
@Test
fun `unknown ester doses contribute zero (EUCS-free esters covered by ESE)`() {
// PEP : pas de fit Estrannaise → ses doses contribuent 0 au nuage
// (comme dans la courbe ESE) — le nuage des autres esters reste
val pep = ese().copy(esterType = "PEP")
val dosesPep = listOf(DoseLog(id = 9, treatmentId = 1, timestamp = NOW - 7 * DAY_MS, doseAmount = 25.0))
val cloud = EstrannaiseCloud.compute(listOf(pep), dosesPep, NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW)
// PEP n'est pas couvert par le fit ESE → pas d'échantillon → nuage vide
assertTrue(cloud.isEmpty())
}
@Test @Test
fun `no doses on the carrier produces no cloud`() { fun `no doses on the carrier produces no cloud`() {
assertTrue(EstrannaiseCloud.compute( assertTrue(EstrannaiseCloud.compute(

View File

@ -3,6 +3,31 @@
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …). Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …).
## [1.9.2] — 2026-09-19 (versionCode 40)
### Corrigé — le nuage d'incertitude exigeait un traitement STOCKÉ en ESE
- **Bug remonté** : activer Estrannaise dans le graphique puis le nuage
n'affichait rien tant qu'aucun traitement n'était **stocké** avec le
modèle ESE — « la seule méthode trouvée était de mettre un traitement en
cours sur le modèle ESE, mais ça ne devrait pas être un prérequis ».
- **Cause** : 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"` (peu importe le modèle stocké) — courbe
et nuage n'utilisaient pas la même définition de « quelles doses sont
tracées 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 (activable seulement si ESE est affiché). L'oral Bateman reste hors
nuage (pas d'ester échantillonnable). Les traitements inactifs sont
inclus (§6.bis : la courbe ESE les trace aussi).
- 2 tests réécrits (237 au total / 207 sans les données locales) :
« doses d'un traitement TFS couvertes quand ESE est affiché » (le fix,
épinglé des deux plateformes) ; oral seul → vide (conservé).
- Validé émulateur §16.ter sur le profil réel (EEn stocké TFS, ESE
activé) : nuage visible autour de la courbe ESE, 0 crash.
## [1.9.1] — 2026-09-19 (versionCode 39) ## [1.9.1] — 2026-09-19 (versionCode 39)
### Corrigé — le nuage d'incertitude entoure maintenant la courbe Estrannaise calibrée ### Corrigé — le nuage d'incertitude entoure maintenant la courbe Estrannaise calibrée

View File

@ -63,6 +63,7 @@ Fonctionnalités v1 :
| Date | Événement | | Date | Événement |
|---|---| |---|---|
| 19 sept. 2026 (session v1.9.2) | **Fix nuage vide sans traitement stocké ESE** (remontée : « il ne s'active que autour du tracé émulé, pas autour du modèle Estrannaise ») : le filtre du nuage testait le pkModel STOCKÉ — or la courbe ESE redessine TOUTES les doses E2 (modelOverride ESE) quel que soit le modèle stocké. FIX : le nuage couvre les doses à ester effectif couvert par ESE (peu importe le stocké) ; l'exclusivité reste portée par le chip ; l'oral Bateman reste hors nuage. 2 tests réécrits (237 verts / 207 sans). Web miroir v1.9.2. |
| 19 sept. 2026 (session v1.9.1) | **Fix nuage calibré + suggestion sur l'Accueil + explication +4 pg/mL** : (a) remontée « le nuage ne s'active que autour du tracé, pas autour du modèle Estrannaise » : la courbe ESE est CALIBRÉE (scalePerEster) mais le nuage était BRUT → échelles différentes. FIX : EstrannaiseCloud.compute reçoit le MÊME scalePerEster que la courbe ESE (Android + web miroir) ; test nuage calibré ×2 (Android). (b) Le +4 pg/mL constaté depuis v1.8.2 : l'ARRONDI de la médiane (fix v1.8.2, plus juste que la troncature) a déplacé une échelle auto-calibrée d'un cran — le niveau actuel est le CORRIGÉ, documenté CHANGELOG 1.9.1. (c) Émulateur : nuage visible autour de la courbe ESE calibrée (1363 px rose pâle, seed réel + traitement EV ESE de test), 0 crash. Web v1.9.1 sync (harmonisation de l'arrondi — les niveaux web/android convergent). 235 verts / 205 sans données locales. | | 19 sept. 2026 (session v1.9.1) | **Fix nuage calibré + suggestion sur l'Accueil + explication +4 pg/mL** : (a) remontée « le nuage ne s'active que autour du tracé, pas autour du modèle Estrannaise » : la courbe ESE est CALIBRÉE (scalePerEster) mais le nuage était BRUT → échelles différentes. FIX : EstrannaiseCloud.compute reçoit le MÊME scalePerEster que la courbe ESE (Android + web miroir) ; test nuage calibré ×2 (Android). (b) Le +4 pg/mL constaté depuis v1.8.2 : l'ARRONDI de la médiane (fix v1.8.2, plus juste que la troncature) a déplacé une échelle auto-calibrée d'un cran — le niveau actuel est le CORRIGÉ, documenté CHANGELOG 1.9.1. (c) Émulateur : nuage visible autour de la courbe ESE calibrée (1363 px rose pâle, seed réel + traitement EV ESE de test), 0 crash. Web v1.9.1 sync (harmonisation de l'arrondi — les niveaux web/android convergent). 235 verts / 205 sans données locales. |
| 17 sept. 2026 (session v1.9.0) | **Modèle Estrannaise ANALYTIQUE (abandon de l'ODS) + nuage d'incertitude MCMC (Android + web sync)** (demande) : découverte des sources originelles (github.com/WHSAH/estrannaise.js — forme close 3C + paramètres publiés + posterior MCMC 313/ester). Vérification numérique AVANT débranchement : **RMS 0,00 vs tables ODS** (l'ODS était l'échantillonnage de ces formules). ESE → analytique (dispatch, cutoff 10×t½, t½ LabTiming), PKProfileStore retiré du runtime (asset ODS → src/test/assets, −550 Ko d'APK), ESE couvre 6 esters (+EUCS), TFS = liste explicite (EUCS sans V3C TFS). **Nuage MCMC exclusif ESE** : chip `Nuage` (off, activable si ESE affiché, coupure auto si ESE off), 32 courbes du posterior déterministes, alpha faible sous les courbes, hors échelles/labels/extrema. 11 nouveaux tests (234 verts / 204 sans données locales) + lint + check web (166). Validé émulateur §16.ter : courbe ESE analytique affichée, unités, chips Extend/Cloud, 0 crash. | | 17 sept. 2026 (session v1.9.0) | **Modèle Estrannaise ANALYTIQUE (abandon de l'ODS) + nuage d'incertitude MCMC (Android + web sync)** (demande) : découverte des sources originelles (github.com/WHSAH/estrannaise.js — forme close 3C + paramètres publiés + posterior MCMC 313/ester). Vérification numérique AVANT débranchement : **RMS 0,00 vs tables ODS** (l'ODS était l'échantillonnage de ces formules). ESE → analytique (dispatch, cutoff 10×t½, t½ LabTiming), PKProfileStore retiré du runtime (asset ODS → src/test/assets, −550 Ko d'APK), ESE couvre 6 esters (+EUCS), TFS = liste explicite (EUCS sans V3C TFS). **Nuage MCMC exclusif ESE** : chip `Nuage` (off, activable si ESE affiché, coupure auto si ESE off), 32 courbes du posterior déterministes, alpha faible sous les courbes, hors échelles/labels/extrema. 11 nouveaux tests (234 verts / 204 sans données locales) + lint + check web (166). Validé émulateur §16.ter : courbe ESE analytique affichée, unités, chips Extend/Cloud, 0 crash. |
| 17 sept. 2026 (session v1.8.2) | **Maintenance — audit complet code + doc** : (a) **perf moteur** : cutoffHours pré-calculé par traitement dans e2At/computeCurve (le chemin chaud du tracé labs recalculait ~430 k appels/refresh), Bateman paresseux (inutile pour les traitements à profil), doses pré-groupées par traitement — résultat identique (ordre de sommation préservé, 223 tests verts inchangés) ; (b) **2 fixes de fraîcheur** (#65 accueil : clés produceState sans allTreatments/tConfig — éditer un scaleFactor n'actualisait pas la courbe avant le tick ; #66 graphiques : clé `labResults.size` — éditer la valeur d'un lab ne recalculait pas) ; (c) **nettoyage** : ~9 imports morts, qualifications raccourcies, code mort (Repository/DAO, currentLevel, scheduler2), médiane factorisée (4 copies), SimpleDateFormat hors boucle, AppLog sans recopie, Regex précompilées, formatDose dédupliqué ; (d) **doc** : ~35 corrections (compteurs actuels 223/193 partout, TOC + ancres réparées, inventaire §8 à jour et fusionné, doublons CHANGELOG, §19 backup, fautes). Audit préalable par double exploration lecture-seule (46 fichiers + 4 docs). 223 verts + lint + émulateur smoke. Android seul (web non concerné — re-render complet, aucune release web). | | 17 sept. 2026 (session v1.8.2) | **Maintenance — audit complet code + doc** : (a) **perf moteur** : cutoffHours pré-calculé par traitement dans e2At/computeCurve (le chemin chaud du tracé labs recalculait ~430 k appels/refresh), Bateman paresseux (inutile pour les traitements à profil), doses pré-groupées par traitement — résultat identique (ordre de sommation préservé, 223 tests verts inchangés) ; (b) **2 fixes de fraîcheur** (#65 accueil : clés produceState sans allTreatments/tConfig — éditer un scaleFactor n'actualisait pas la courbe avant le tick ; #66 graphiques : clé `labResults.size` — éditer la valeur d'un lab ne recalculait pas) ; (c) **nettoyage** : ~9 imports morts, qualifications raccourcies, code mort (Repository/DAO, currentLevel, scheduler2), médiane factorisée (4 copies), SimpleDateFormat hors boucle, AppLog sans recopie, Regex précompilées, formatDose dédupliqué ; (d) **doc** : ~35 corrections (compteurs actuels 223/193 partout, TOC + ancres réparées, inventaire §8 à jour et fusionné, doublons CHANGELOG, §19 backup, fautes). Audit préalable par double exploration lecture-seule (46 fichiers + 4 docs). 223 verts + lint + émulateur smoke. Android seul (web non concerné — re-render complet, aucune release web). |
@ -667,13 +668,21 @@ devient à son tour**.
l'ODS n'était que l'échantillonnage horaire de ces formules. Épinglé par l'ODS n'était que l'échantillonnage horaire de ces formules. Épinglé par
`EstrannaiseModelsTest` (Android) et `estrannaise-models.test.js` (web). `EstrannaiseModelsTest` (Android) et `estrannaise-models.test.js` (web).
- **Débranchement runtime** : `concentrationOfDose` dispatche ESE → - **Débranchement runtime** : `concentrationOfDose` dispatche ESE →
[EstrannaiseModels.sample] (plus de fallback `EstrannaiseModels.sample` (plus de fallback `PKProfileStore.sample`) ;
[PKProfileStore.sample]) ; `cutoffHours` ESE → 10 × t½ terminale `cutoffHours` ESE → 10 × t½ terminale analytique (plus la longueur de
analytique (plus la longueur de table) ; [LabTiming] t½ ESE analytique ; table) ; `LabTiming` t½ ESE analytique ; `PKProfileStore.init` retirée
[PKProfileStore.init] retirée du démarrage. **L'asset `pk_profiles.json` du démarrage. **L'asset `pk_profiles.json` quitte `src/main/assets`
quitte `src/main/assets` (−550 Ko d'APK) pour `src/test/assets/`** : (−550 Ko d'APK) pour `src/test/assets/`** : l'ODS survit UNIQUEMENT
l'ODS survit UNIQUEMENT comme référence des tests de fidélité. comme référence des tests de fidélité. `PKProfileStore` est conservé
[PKProfileStore] est conservé (lecteur pur) pour ces tests. (lecteur pur) pour ces tests.
- **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 — indépendamment du `pkModel` stocké du
traitement (fix v1.9.2 : l'ancien filtre `pkModel stocké == ESE` laissait
le nuage vide dès qu'aucun traitement n'était stocké en ESE — la courbe
ESE affichée trace pourtant toutes ces doses via modelOverride). Les
doses des traitements inactifs sont incluses (§6.bis) ; l'oral Bateman
reste hors nuage (pas d'ester échantillonnable).
- **Couverture étendue** : ESE passe de 3 à **6 esters injectables** - **Couverture étendue** : ESE passe de 3 à **6 esters injectables**
(EV/EU/EEn/EC/EB/**EUCS**) — `Esters.choicesForModel` mis à jour (TFS (EV/EU/EEn/EC/EB/**EUCS**) — `Esters.choicesForModel` mis à jour (TFS
reste 7, WHSAH 6 ; ⚠️ TFS = liste EXPLICITE : EUCS n'a pas de V3C TFS). reste 7, WHSAH 6 ; ⚠️ TFS = liste EXPLICITE : EUCS n'a pas de V3C TFS).
@ -2292,6 +2301,13 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
recommandé → la carte passe au creux SUIVANT ; enlever la Posologie recommandé → la carte passe au creux SUIVANT ; enlever la Posologie
→ carte remplacée par l'invite « renseigne une Posologie » ; → carte remplacée par l'invite « renseigne une Posologie » ;
traitement oral seul → aucune carte traitement oral seul → aucune carte
- [ ] **v1.9.2** : **nuage sans traitement stocké ESE** : avec un
traitement E2 injectable STOCKÉ en TFS (ou autre), activer Estrannaise
dans le graphique → la courbe ESE apparaît ; activer Nuage (exclusif
ESE) → le nuage ENTOURE la courbe ESE tracée sur ces doses (le
pkModel stocké n'est pas un prérequis) ; l'oral Bateman seul → pas de
nuage ; l'exclusivité ESE reste sur le chip
- [ ] **v1.8.1** : **stabilisation corrigée + suggestion Accueil** : après - [ ] **v1.8.1** : **stabilisation corrigée + suggestion Accueil** : après
changement de dose/ester/intervalle, la carte ne dit plus « stabilisé changement de dose/ester/intervalle, la carte ne dit plus « stabilisé
depuis » l'ancienne période (régime = séquence terminale (ester, dose, depuis » l'ancienne période (régime = séquence terminale (ester, dose,

View File

@ -221,7 +221,9 @@ Pics de référence (dose unique de 5 mg, IM) :
- **Nuage d'incertitude (v1.9.0, exclusif ESE)** : chip `Nuage` (actif - **Nuage d'incertitude (v1.9.0, exclusif ESE)** : chip `Nuage` (actif
seulement quand Estrannaise est affiché) → nuage diffus de courbes du seulement quand Estrannaise est affiché) → nuage diffus de courbes du
posterior MCMC d'Estrannaise montrant la plage d'imprécision du modèle posterior MCMC d'Estrannaise montrant la plage d'imprécision du modèle
(comme sur le site estrannaise) (comme sur le site estrannaise). v1.9.2 : il couvre toutes les doses E2
injectables quand ESE est affiché — aucun traitement n'a besoin d'être
stocké en ESE
- **Unités sur les axes** (v1.7.0) : l'axe gauche affiche « pg/mL » (E2) et - **Unités sur les axes** (v1.7.0) : l'axe gauche affiche « pg/mL » (E2) et
l'axe droit « ng/mL » (T, avec le toggle T) au sommet des colonnes de l'axe droit « ng/mL » (T, avec le toggle T) au sommet des colonnes de
labels — plus besoin de deviner l'unité des nombres labels — plus besoin de deviner l'unité des nombres