diff --git a/README.md b/README.md index 4a04187..4da46b7 100644 --- a/README.md +++ b/README.md @@ -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 > 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) - **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) @@ -123,7 +123,7 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1. git clone && cd HormoneTrack echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME ./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 ``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a5e40a8..25ceaa8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,8 @@ android { // tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0" // (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans // les APK publiés (Paramètres + titre du dialog « Nouveautés »). - versionCode = 39 - versionName = "1.9.1" + versionCode = 40 + versionName = "1.9.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/hormonetrack/pk/EstrannaiseCloud.kt b/app/src/main/java/com/hormonetrack/pk/EstrannaiseCloud.kt index 43f0a9b..c4b2ba6 100644 --- a/app/src/main/java/com/hormonetrack/pk/EstrannaiseCloud.kt +++ b/app/src/main/java/com/hormonetrack/pk/EstrannaiseCloud.kt @@ -16,13 +16,23 @@ import com.hormonetrack.data.model.TreatmentType * site estrannaise. TFS/WHSAH ne publient pas de posterior → pas de nuage * pour eux (choix confirmé : le nuage reste sur ESE). * - * 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 — chaque polyligne du nuage est « ce que serait la courbe si le - * vrai paramètre physiologique était celui-là ». Les contributions des - * autres traitements (TFS/WHSAH/Bateman) ne sont PAS incluses dans le - * nuage : elles n'ont pas d'incertitude publiée (limite documentée — le - * nuage montre l'incertitude du modèle Estrannaise, pas le niveau total). + * 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 : les M échantillons sont **échelonnés** (stride sur les * 313, pas d'aléatoire) — le nuage est identique à chaque affichage et @@ -78,34 +88,51 @@ object EstrannaiseCloud { ): List> { if (startMs >= endMs || stepMs <= 0 || nbCurves < 2) return emptyList() - // ── Porteurs : traitements E2 actifs à profil ESE avec ester couvert ── - val carriers = treatments.filter { - it.type == TreatmentType.ESTRADIOL && it.isActive && - it.usesProfileModel && it.pkModel == PKModels.ESTRANNAISE && - EstrannaiseModels.hasModel(it.esterType) + // ── 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 : quand ESE est affiché, + // 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>() + val samplesByEster = HashMap>() + 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 + } + carrierDoses.add(dose to tr) } - if (carriers.isEmpty()) return emptyList() - - val carrierIds = carriers.map { it.id }.toSet() - val carrierDoses = doseLogs - .filter { it.treatmentId in carrierIds && it.timestamp <= endMs } - .ifEmpty { return emptyList() } + if (carrierDoses.isEmpty()) return emptyList() // ── Échantillons échelonnés du posterior (déterministes) ───────────── // Un échantillon m est appliqué À TOUS les esters du nuage (même // index) : le nuage explore le posterior ester par ester, index par // 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 stride = (maxSamples.toDouble() / nbCurves.coerceAtMost(maxSamples)).toInt() .coerceAtLeast(1) // Grille : de max(start, 1ʳᵉ dose du porteur) à endMs — M=0 avant la // 1ʳᵉ dose (rien à faire varier) - val firstDoseMs = carrierDoses.minOf { it.timestamp } + val firstDoseMs = carrierDoses.minOf { (dose, _) -> dose.timestamp } val t0 = maxOf(startMs, firstDoseMs) if (t0 > endMs) return emptyList() @@ -120,17 +147,15 @@ object EstrannaiseCloud { var t = t0 while (t <= endMs) { var e2 = 0.0 - for (dose in carrierDoses) { + for ((dose, tr) in carrierDoses) { if (dose.timestamp > t) continue val dtH = (t - dose.timestamp) / 3_600_000.0 - val tr = carriers.first { it.id == dose.treatmentId } - val params = paramsByEster.getValue(tr.esterType) + val ester = PharmacokineticEngine.doseEster(tr, dose) + val params = paramsByEster.getValue(ester) // Même calibration que la courbe ESE : le nuage ENTOURE // la courbe calibrée (fix v1.9.1) - val scale = scalePerEster?.get( - PharmacokineticEngine.doseEster(tr, dose) - ) ?: tr.scaleFactor - e2 += EstrannaiseModels.sampleWithParams(tr.esterType, dtH, params) * dose.doseAmount * scale + val scale = scalePerEster?.get(ester) ?: tr.scaleFactor + e2 += EstrannaiseModels.sampleWithParams(ester, dtH, params) * dose.doseAmount * scale } curve.add(LevelPoint(t, e2, 0.0)) t += stepMs diff --git a/app/src/test/java/com/hormonetrack/pk/EstrannaiseCloudTest.kt b/app/src/test/java/com/hormonetrack/pk/EstrannaiseCloudTest.kt index bfc1748..97d026c 100644 --- a/app/src/test/java/com/hormonetrack/pk/EstrannaiseCloudTest.kt +++ b/app/src/test/java/com/hormonetrack/pk/EstrannaiseCloudTest.kt @@ -80,11 +80,24 @@ class EstrannaiseCloudTest { } @Test - fun `non-ESE carriers produce no cloud (feature exclusive to ESE)`() { - // TFS : pas de posterior publié → nuage vide - val tfs = ese().copy(pkModel = "TFS") - assertTrue(EstrannaiseCloud.compute(listOf(tfs), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW).isEmpty()) - // Oral (Bateman) : idem + fun `doses of a TFS-stored treatment ARE covered when ESE is displayed (fix v1_9_2)`() { + // FIX « le nuage ne s'active que autour du tracé émulé, pas autour du + // modèle Estrannaise » : le traitement est STOCKÉ en TFS, mais quand + // ESE est affiché, computeCurve(modelOverride = "ESE") redessine ses + // 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( id = 2, name = "CPA", type = TreatmentType.ANTI_ANDROGEN, 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()) } + @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 fun `no doses on the carrier produces no cloud`() { assertTrue(EstrannaiseCloud.compute( diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 61cf230..cb68db9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,31 @@ 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`, …). +## [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) ### Corrigé — le nuage d'incertitude entoure maintenant la courbe Estrannaise calibrée diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 5ae1fd5..e143b72 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -63,6 +63,7 @@ Fonctionnalités v1 : | 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. | | 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). | @@ -667,13 +668,21 @@ devient à son tour**. l'ODS n'était que l'échantillonnage horaire de ces formules. Épinglé par `EstrannaiseModelsTest` (Android) et `estrannaise-models.test.js` (web). - **Débranchement runtime** : `concentrationOfDose` dispatche ESE → - [EstrannaiseModels.sample] (plus de fallback - [PKProfileStore.sample]) ; `cutoffHours` ESE → 10 × t½ terminale - analytique (plus la longueur de table) ; [LabTiming] t½ ESE analytique ; - [PKProfileStore.init] retirée du démarrage. **L'asset `pk_profiles.json` - quitte `src/main/assets` (−550 Ko d'APK) pour `src/test/assets/`** : - l'ODS survit UNIQUEMENT comme référence des tests de fidélité. - [PKProfileStore] est conservé (lecteur pur) pour ces tests. + `EstrannaiseModels.sample` (plus de fallback `PKProfileStore.sample`) ; + `cutoffHours` ESE → 10 × t½ terminale analytique (plus la longueur de + table) ; `LabTiming` t½ ESE analytique ; `PKProfileStore.init` retirée + du démarrage. **L'asset `pk_profiles.json` quitte `src/main/assets` + (−550 Ko d'APK) pour `src/test/assets/`** : l'ODS survit UNIQUEMENT + comme référence des tests de fidélité. `PKProfileStore` est conservé + (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** (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). @@ -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 → carte remplacée par l'invite « renseigne une Posologie » ; 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 changement de dose/ester/intervalle, la carte ne dit plus « stabilisé depuis » l'ancienne période (régime = séquence terminale (ester, dose, diff --git a/docs/GUIDE_INSTALLATION.md b/docs/GUIDE_INSTALLATION.md index ecff557..ad13147 100644 --- a/docs/GUIDE_INSTALLATION.md +++ b/docs/GUIDE_INSTALLATION.md @@ -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 seulement quand Estrannaise est affiché) → nuage diffus de courbes du 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 l'axe droit « ng/mL » (T, avec le toggle T) au sommet des colonnes de labels — plus besoin de deviner l'unité des nombres