From 22d4c66d81056c5e2b2b8adc933a0ccfea7357c2 Mon Sep 17 00:00:00 2001 From: Siphonight Date: Mon, 7 Sep 2026 18:28:15 +0200 Subject: [PATCH] =?UTF-8?q?v1.4.8=20:=20calibration=20PAR=20MOD=C3=88LE=20?= =?UTF-8?q?PK=20(#60)=20=E2=80=94=20le=20graphique=20calcule=20une=20AutoC?= =?UTF-8?q?alibrated=20par=20mod=C3=A8le=20affich=C3=A9=20(modelOverride?= =?UTF-8?q?=20propag=C3=A9=20=C3=A0=20computeEsterScaleFactors=20et=20comp?= =?UTF-8?q?uteTKPerEster)=20=E2=86=92=20chaque=20courbe=20calibr=C3=A9e=20?= =?UTF-8?q?passe=20par=20les=20labs=20(plus=20de=20facteur=20partag=C3=A9?= =?UTF-8?q?=20absurde=20=C3=972,21=20sur=20WHSAH)=20;=20Home=20inchang?= =?UTF-8?q?=C3=A9=20(mod=C3=A8le=20stock=C3=A9)=20;=20CalibrationPerModelT?= =?UTF-8?q?est=20(5=20tests=20:=20couverture=203=20mod=C3=A8les,=20d=C3=A9?= =?UTF-8?q?l=C3=A9gation=20unique,=20k=20T=20par=20mod=C3=A8le,=20cas=20d?= =?UTF-8?q?=C3=A9g=C3=A9n=C3=A9r=C3=A9s)=20;=20164=20tests=20verts=20+=20l?= =?UTF-8?q?int?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 61 ++++-- app/build.gradle.kts | 4 +- .../hormonetrack/pk/PharmacokineticEngine.kt | 33 ++- .../hormonetrack/ui/screens/ChartScreen.kt | 61 ++++-- .../pk/CalibrationPerModelTest.kt | 191 ++++++++++++++++++ .../com/hormonetrack/pk/WhsahEngineTest.kt | 48 +++++ docs/CHANGELOG.md | 31 ++- docs/DEVELOPPEMENT.md | 47 +++-- docs/GUIDE_INSTALLATION.md | 5 +- 9 files changed, 416 insertions(+), 65 deletions(-) create mode 100644 app/src/test/java/com/hormonetrack/pk/CalibrationPerModelTest.kt diff --git a/README.md b/README.md index 8e6ced3..5b7b9ff 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.4.7 — build Android ✅, **lint vert** ✅, **158 tests unitaires** ✅ (136 sans les données de test locales ; 3 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.4.8 — build Android ✅, **lint vert** ✅, **159 tests unitaires** ✅ (137 sans les données de test locales ; 3 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) @@ -37,7 +37,8 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.** [article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)), avec les **7 esters** (EV, EU, EEn, EB, EC huile, EC suspension, PEP) — et WHSAH en couvre 6 (sans PEP). Affichés côte à côte avec toggles indépendants (3 couleurs) — **pré-cochés - selon les modèles de tes traitements** (v1.4.7) et **tous calibrés** par tes labs + selon les modèles de tes traitements** (v1.4.7) et **chaque modèle calibré + séparément** par tes labs (v1.4.8 — les facteurs s'adaptent à chaque profil) - **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel, patch et voie orale - **Simulation prévisionnelle** : configurer la **posologie** (intervalle en jours) sur un @@ -92,7 +93,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 # 158 tests (136 sans les données locales) +./gradlew testDebugUnitTest # 159 tests (137 sans les données locales) ./gradlew lint # lint vert obligatoire avant release ``` @@ -104,7 +105,7 @@ Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INS Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) — `Siphonight/HormoneTrack` sur les deux (privé), avec -**releases taguées** (`v1.1.0` → `v1.4.7`) et **deux APK par release** (depuis v1.2.5) +**releases taguées** (`v1.1.0` → `v1.4.8`) et **deux APK par release** (depuis v1.2.5) (téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) : `-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) : @@ -120,25 +121,45 @@ taggué annoté. Voir [docs/DEVELOPPEMENT.md §16](docs/DEVELOPPEMENT.md). ## Les modèles en bref Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la réponse normalisée -(pg/mL par mg) ; les contributions se superposent. **Estrannaise** = tables horaires -du `.ods` (8001 h) ; **Transfem Science** = méta-analyse à 3 compartiments (V3C, -forme close, [article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)). +(pg/mL par mg) ; les contributions se superposent. **Trois modèles superposables** : + +- **Estrannaise** = tables horaires du `.ods` (8001 h), EV/EU/EEn +- **Transfem Science** = méta-analyse à 3 compartiments (V3C, forme close, + [article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)), + 7 esters (EV, EU, EEn, EB, EC, EC suspension, PEP) +- **WHSAH** (v1.4.6) = fit « license-free » du [WHSAH Collective via + Mona](https://github.com/mona-hrt/mona) — même famille mathématique mais + paramètres indépendants avec biodisponibilité explicite F < 1 : montée + plus rapide à J+1 (EEn ~70 pg/mL à J+1 pour 5 mg vs ~22 chez TFS) et + décroissance plus longue (t½ EEn 7,3 j vs 4,5 j). 6 esters (sans PEP) + Pics de référence (pg/mL par mg) : -| Profil | Modèle | Pic (pg/mL/mg) | Tmax | -|----------|------------------|----------------|--------| -| EV | Estrannaise | 61,1 | ~45 h | -| EU | Estrannaise | 3,4 | ~55 h (plateau long) | -| EEn | Estrannaise | 31,4 | ~152 h | -| EV | Transfem Science | 59,0 | ~51 h | -| EU | Transfem Science | 10,1 | ~198 h | -| EEn | Transfem Science | 32,0 | ~156 h | -| EB | Transfem Science | 194,2 | ~16 h | -| EC (huile) | Transfem Science | 31,1 | ~103 h | -| PEP | Transfem Science | 1,03 (dose ~6,5×) | ~18 j | +| Profil | Modèle | Pic (pg/mL/mg) | Tmax | t½ term. | +|----------|------------------|----------------|--------|----------| +| EV | Estrannaise | 61,1 | ~45 h | — | +| EU | Estrannaise | 3,4 | ~55 h (plateau long) | — | +| EEn | Estrannaise | 31,4 | ~152 h | — | +| EV | Transfem Science | 59,0 | ~51 h | 3,0 j | +| EU | Transfem Science | 10,1 | ~198 h | — | +| EEn | Transfem Science | 32,0 | ~156 h | 4,5 j | +| EB | Transfem Science | 194,2 | ~16 h | 1,2 j | +| EC (huile) | Transfem Science | 31,1 | ~103 h | 6,7 j | +| EC (susp.) | Transfem Science | 48,2 | ~29 h | 5,1 j | +| PEP | Transfem Science | 1,03 (dose ~6,5×) | ~18 j | 28,4 j | +| EV | WHSAH | 73,5 | ~41 h | 3,1 j | +| EEn | WHSAH | 37,6 | ~120 h | 7,3 j | +| EB | WHSAH | 260,1 | ~12 h | 1,3 j | +| EC (huile) | WHSAH | 25,0 | ~81 h | 7,9 j | +| EC (susp.) | WHSAH | 53,5 | ~16 h | 7,1 j | +| EU | WHSAH | 4,9 | ~67 h | 31,7 j | -La calibration (facteur d'échelle par traitement, calibré par tes labs) ajuste le modèle -à ton corps, exactement comme la colonne « Scale factor » de la feuille d'origine. +**Calibration (v1.4.8)** : le facteur d'échelle s'applique par ESTER et par +PÉRIODE d'injection (médiane des ratios lab ÷ prédiction, comme la colonne +« Scale factor » de la feuille d'origine) — et **par MODÈLE** : chaque courbe +affichée (Estrannaise / Transfem Science / WHSAH) est calibrée avec la +prédiction de SON modèle → toutes collent à tes labs, quelle que soit leur +forme. L'auto-calibration est optionnelle (désactivée par défaut). ## Vie privée diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dc8c0ae..be6db75 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 = 27 - versionName = "1.4.7" + versionCode = 28 + versionName = "1.4.8" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt index 982b170..52d731a 100644 --- a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt +++ b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt @@ -397,7 +397,8 @@ object PharmacokineticEngine { fun computeEsterScaleFactors( treatments: List, doseLogs: List, - e2Labs: List + e2Labs: List, + modelOverride: String? = null ): Map { val estrogenTreatments = treatments.filter { it.type == TreatmentType.ESTRADIOL } val estrogenDoses = doseLogs.filter { d -> @@ -405,11 +406,15 @@ object PharmacokineticEngine { } if (estrogenDoses.isEmpty()) return emptyMap() - // Prédictions non calibrées (scaleFactor forcé à 1) pour chaque lab + // Prédictions non calibrées (scaleFactor forcé à 1) pour chaque lab, + // avec le MODÈLE demandé (fix #60 : chaque courbe est calibrée avec + // SA propre prédiction — v1.4.7 calculait tout depuis le modèle + // stocké du traitement, ce qui produisait des facteurs absurdes dès + // qu'un traitement basculait de modèle, ex. ×2,21 sur WHSAH) val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) } val ratiosByEster = HashMap>() for (lab in e2Labs) { - val predicted = e2At(unscaled, doseLogs, lab.timestamp) + val predicted = e2At(unscaled, doseLogs, lab.timestamp, modelOverride = modelOverride) if (predicted <= 0.5) continue // Attribution : dernière dose E2 ≤ lab → son ester (override compris) @@ -451,7 +456,8 @@ object PharmacokineticEngine { doseLogs: List, tLabs: List, current: TConfig, - scalePerEster: Map? = null + scalePerEster: Map? = null, + modelOverride: String? = null ): Map { val estrogenDoses = doseLogs.filter { d -> treatments.any { it.id == d.treatmentId && it.type == TreatmentType.ESTRADIOL } @@ -469,7 +475,9 @@ object PharmacokineticEngine { val ester = doseEster(tr, attributed) if (ester == Esters.NONE) continue - val e2 = e2At(treatments, doseLogs, lab.timestamp, scalePerEster = scalePerEster) + // v1.4.8 : l'E2 de référence suit le MODÈLE demandé (fix #60) + val e2 = e2At(treatments, doseLogs, lab.timestamp, scalePerEster = scalePerEster, + modelOverride = modelOverride) if (e2 <= 1.0) continue val k = ((current.base - current.floor) / (tNgMl - current.floor) - 1.0) / e2 if (k > 1e-4 && k < 10.0) { @@ -511,14 +519,23 @@ object PharmacokineticEngine { treatments: List, doseLogs: List, labs: List, - tConfig: TConfig + tConfig: TConfig, + /** + * Modèle PK pour les PRÉDICTIONS de calibration (v1.4.8, fix #60) : + * null = modèle stocké de chaque traitement (Home). Le graphique + * appelle une fois PAR MODÈLE affiché (ESE/TFS/WHS) — chaque courbe + * reçoit ainsi SES échelles et SON k. + */ + modelOverride: String? = null ): AutoCalibrated { val e2Labs = labs.filter { it.marker.equals("E2", true) } val tLabs = labs.filter { it.marker.equals("T", true) } - val esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs) + val esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs, + modelOverride = modelOverride) val tKPerEster = computeTKPerEster( - treatments, doseLogs, tLabs, tConfig, scalePerEster = esterScales + treatments, doseLogs, tLabs, tConfig, scalePerEster = esterScales, + modelOverride = modelOverride ) return AutoCalibrated( treatments = treatments, diff --git a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt index 5158f8b..476f8d7 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt @@ -146,14 +146,34 @@ fun ChartScreen() { // Auto-calibration (v1.2.1) : facteurs d'échelle PAR PÉRIODE D'ESTER + modèle T, // recalculés à la volée depuis les labs. null = option désactivée. - val effectiveAuto by produceState( - null, treatments, doseLogs, labResults, tConfig, autoCalibrate + // ── Calibration PAR MODÈLE (v1.4.8, fix #60) ────────────────────────────── + // Demande : « la calibration doit être active sur tous les modèles si elle + // est activée, mais les facteurs ne doivent pas être identiques entre les + // profils PK ». AVANT : un SEUL jeu de facteurs calculé depuis le modèle + // STOCKÉ des traitements puis partagé par toutes les courbes → en + // basculant un traitement (ex. EEn TFS → WHSAH), le facteur devenait + // absurde (×2,21, remontée). DÉSORMAIS : une AutoCalibrated PAR modèle + // (ESE/TFS/WHS), chacune calibrée avec la prédiction DE SON modèle → + // chaque courbe affichée colle à ses labs. Home continue d'utiliser le + // modèle stocké (calcul sans override). + val autoByModel by produceState>( + emptyMap(), treatments, doseLogs, labResults, tConfig, autoCalibrate ) { value = if (!autoCalibrate) { - null + emptyMap() } else { withContext(Dispatchers.Default) { - PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, tConfig) + mapOf( + "ESE" to PharmacokineticEngine.autoCalibrated( + treatments, doseLogs, labResults, tConfig, modelOverride = "ESE" + ), + "TFS" to PharmacokineticEngine.autoCalibrated( + treatments, doseLogs, labResults, tConfig, modelOverride = "TFS" + ), + "WHS" to PharmacokineticEngine.autoCalibrated( + treatments, doseLogs, labResults, tConfig, modelOverride = "WHS" + ) + ) } } } @@ -229,16 +249,16 @@ fun ChartScreen() { // Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS") val curves by produceState( emptyList>>(), - effectiveAuto, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast + autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast ) { withContext(Dispatchers.Default) { val list = mutableListOf>>() if (showEse || showTfs || showWhs) { - // Valeurs actuelles : échelles par ester si auto-calibration active, - // sinon le scaleFactor stocké de chaque traitement - val effTreatments = effectiveAuto?.treatments ?: treatments - val effTConfig = effectiveAuto?.tConfig ?: tConfig - val effScales = effectiveAuto?.esterScales + // Calibration PAR MODÈLE (fix #60) : chaque courbe reçoit les + // échelles/k calculés avec SA prédiction. Hors auto-calibration + // → scaleFactor stocké des traitements (comportement historique). + val effTreatments = autoByModel["TFS"]?.treatments ?: treatments + val effTConfig = autoByModel["TFS"]?.tConfig ?: tConfig // Doses réelles + doses prévisionnelles éventuelles val forecastDoses = if (showForecast) { effTreatments.flatMap { @@ -249,36 +269,39 @@ fun ChartScreen() { // Pas d'échantillonnage adaptatif au zoom (v1.2.9) val step = com.hormonetrack.ui.components.stepForRange(rangeHours) if (showEse) { + val autoM = autoByModel["ESE"] list.add( "ESE" to PharmacokineticEngine.computeCurve( effTreatments, allDoses, startMs, endMs, stepMs = step, - tConfig = effTConfig, modelOverride = "ESE", - scalePerEster = effScales, - tKPerEster = effectiveAuto?.tKPerEster + tConfig = autoM?.tConfig ?: effTConfig, modelOverride = "ESE", + scalePerEster = autoM?.esterScales, + tKPerEster = autoM?.tKPerEster ) ) } if (showTfs) { + val autoM = autoByModel["TFS"] list.add( "TFS" to PharmacokineticEngine.computeCurve( effTreatments, allDoses, startMs, endMs, stepMs = step, - tConfig = effTConfig, modelOverride = "TFS", - scalePerEster = effScales, - tKPerEster = effectiveAuto?.tKPerEster + tConfig = autoM?.tConfig ?: effTConfig, modelOverride = "TFS", + scalePerEster = autoM?.esterScales, + tKPerEster = autoM?.tKPerEster ) ) } // v1.4.6 : 3ᵉ modèle WHSAH (même mécanique d'override) if (showWhs) { + val autoM = autoByModel["WHS"] list.add( "WHS" to PharmacokineticEngine.computeCurve( effTreatments, allDoses, startMs, endMs, stepMs = step, - tConfig = effTConfig, modelOverride = "WHS", - scalePerEster = effScales, - tKPerEster = effectiveAuto?.tKPerEster + tConfig = autoM?.tConfig ?: effTConfig, modelOverride = "WHS", + scalePerEster = autoM?.esterScales, + tKPerEster = autoM?.tKPerEster ) ) } diff --git a/app/src/test/java/com/hormonetrack/pk/CalibrationPerModelTest.kt b/app/src/test/java/com/hormonetrack/pk/CalibrationPerModelTest.kt new file mode 100644 index 0000000..68f571a --- /dev/null +++ b/app/src/test/java/com/hormonetrack/pk/CalibrationPerModelTest.kt @@ -0,0 +1,191 @@ +package com.hormonetrack.pk + +import com.hormonetrack.data.model.AdministrationRoute +import com.hormonetrack.data.model.DoseLog +import com.hormonetrack.data.model.Esters +import com.hormonetrack.data.model.LabResult +import com.hormonetrack.data.model.PKModels +import com.hormonetrack.data.model.Treatment +import com.hormonetrack.data.model.TreatmentType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Process de tests **CALIBRATION** (v1.4.8, fix #60) — sémantique + * « calibration PAR MODÈLE PK » : chaque courbe affichée (ESE/TFS/WHS) est + * calibrée avec la prédiction de SON modèle, donc chaque courbe calibrée + * passe par les labs. + * + * Compléments des tests voisins (pour s'y retrouver) : + * - `WhsahEngineTest.calibration is per model` : TFS vs WHS (le cas de la + * remontée ×2,21) ; + * - `WhsahEngineTest.calibration applies to ALL model curves` : le facteur + * fourni est appliqué à toutes les courbes (application uniforme) ; + * - `PharmacokineticEngineTest` : calibration T manuelle, SF médiane. + * + * Ce fichier couvre ce qui restait sans filet : + * 1. la couverture des **3 modèles** (ESE inclus) — chaque courbe calibrée + * atterrit sur le lab ; + * 2. la **délégation** `autoCalibrated(modelOverride)` → + * `computeEsterScaleFactors(modelOverride)` (le graphique et Home + * utilisent la même fonction, pas deux implémentations) ; + * 3. le **k T par modèle** (calibré contre l'E2 calibrée de SON modèle) ; + * 4. les cas **dégénérés** (labs antérieurs à la 1ʳᵉ dose → échelles vides ; + * prédiction trop faible → lab ignoré). + */ +class CalibrationPerModelTest { + + private companion object { + val BASE = System.currentTimeMillis() + const val DAY_MS = 86_400_000L + } + + @org.junit.Before + fun loadProfiles() { + // ⚠️ le chemin ESE lit les tables ODS (asset) — sans init, + // PKProfileStore.sample retourne 0 et les labs seraient ignorés + // (prédiction ≤ 0,5) → échelles vides. Les modèles TFS/WHS sont + // purs (formes closes) et n'en dépendent pas. + if (!PKProfileStore.hasProfile("EEN", "ESE")) { + val file = java.io.File("src/main/assets/pk_profiles.json") + .takeIf { it.exists() } + ?: java.io.File("app/src/main/assets/pk_profiles.json") + PKProfileStore.initWithJson(file.readText()) + } + } + + /** Traitement EEn (modèle TFS stocké), 5 mg à BASE. */ + private fun treatment(): Treatment = Treatment( + id = 1L, + name = "EEn", + type = TreatmentType.ESTRADIOL, + route = AdministrationRoute.INJECTION_IM, + doseAmount = 5.0, + doseUnit = "mg", + isActive = true, + esterType = Esters.EEN, + pkModel = PKModels.TRANSFEM_SCIENCE + ) + + private fun doses(): List = listOf( + DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0) + ) + + /** Lab E2 de 250 pg/mL 7 jours après l'injection (au creux TFS). */ + private fun labE2(): LabResult = LabResult( + marker = "E2", value = 250.0, unit = "pg/mL", + timestamp = BASE + 7 * DAY_MS + ) + + @Test + fun `all three models calibrated land on the lab (ESE included)`() { + val tr = treatment() + val labs = listOf(labE2()) + for (model in listOf("ESE", "TFS", "WHS")) { + val scales = PharmacokineticEngine.computeEsterScaleFactors( + listOf(tr), doses(), labs, modelOverride = model + ) + assertTrue("échelle $model absente", scales.containsKey("EEN")) + val calibrated = PharmacokineticEngine.e2At( + listOf(tr), doses(), labE2().timestamp, + modelOverride = model, scalePerEster = scales + ) + assertTrue( + "$model : calibrée=$calibrated doit ≈ 250 (échelle ${scales["EEN"]})", + calibrated in 250.0 * 0.98..250.0 * 1.02 + ) + } + } + + @Test + fun `three models give three distinct calibration factors`() { + val tr = treatment() + val labs = listOf(labE2()) + val ese = PharmacokineticEngine.computeEsterScaleFactors( + listOf(tr), doses(), labs, modelOverride = "ESE")["EEN"]!! + val tfs = PharmacokineticEngine.computeEsterScaleFactors( + listOf(tr), doses(), labs, modelOverride = "TFS")["EEN"]!! + val whs = PharmacokineticEngine.computeEsterScaleFactors( + listOf(tr), doses(), labs, modelOverride = "WHS")["EEN"]!! + // les prédictions des 3 modèles diffèrent au même instant (7 j) : + // ESE ≈ 156, TFS ≈ 159, WHS ≈ 178 → facteurs distincts. Nuances : + // ESE et TFS sont QUASI IDENTIQUES au creux 7 j (~2 % — la table ODS + // dérive du même article) ; WHSAH s'en écarte (~11 % — décroissance + // lente, t½ 7,3 j) mais sans excès + assertTrue("ESE=$ese vs TFS=$tfs (≥ 1 % : distincts)", kotlin.math.abs(ese - tfs) > 0.01 * ese) + assertTrue("TFS=$tfs vs WHS=$whs (≥ 5 %)", kotlin.math.abs(tfs - whs) > 0.05 * tfs) + assertTrue("WHS=$whs vs ESE=$ese (≥ 5 %)", kotlin.math.abs(whs - ese) > 0.05 * whs) + } + + @Test + fun `autoCalibrated delegates to computeEsterScaleFactors per model (single implementation)`() { + val tr = treatment() + val labs = listOf(labE2()) + for (model in listOf(null, "ESE", "TFS", "WHS")) { + val viaAuto = PharmacokineticEngine.autoCalibrated( + listOf(tr), doses(), labs, TConfig(), modelOverride = model + ).esterScales + val direct = PharmacokineticEngine.computeEsterScaleFactors( + listOf(tr), doses(), labs, modelOverride = model + ) + assertEquals("délégation brisée pour modelOverride=$model", direct, viaAuto) + } + // null = modèle stocké du traitement (TFS) → cohérent avec override TFS + assertEquals( + PharmacokineticEngine.autoCalibrated( + listOf(tr), doses(), labs, TConfig(), modelOverride = "TFS" + ).esterScales, + PharmacokineticEngine.autoCalibrated( + listOf(tr), doses(), labs, TConfig() + ).esterScales + ) + } + + @Test + fun `testosterone k is calibrated against the E2 of the requested model`() { + val tr = treatment() + // lab T de 0,35 ng/mL 7 jours après l'injection (même instant que le lab E2) + val labs = listOf( + labE2(), + LabResult(marker = "T", value = 0.35, unit = "ng/mL", + timestamp = BASE + 7 * DAY_MS) + ) + val tConfig = TConfig() // base 6.0, floor 0.2, k 0.19 + + // Invariant mathématique : k ∝ 1/E2 calibrée — donner une échelle + // ×2 doit DIVISER le k par 2 exactement (le k est calculé contre + // l'E2 calibrée de la demande modelOverride, pas contre une E2 figée). + val tLabs = labs.filter { it.marker.equals("T", true) } + fun kFor(scale: Double): Double = PharmacokineticEngine.computeTKPerEster( + listOf(tr), doses(), tLabs, tConfig, + scalePerEster = mapOf("EEN" to scale), + modelOverride = "TFS" + )["EEN"]!! + val k1 = kFor(1.0) + val k2 = kFor(2.0) + assertTrue("k(échelle 1)=$k1 doit être dans la garde", k1 > 1e-4 && k1 < 10.0) + assertEquals("k ∝ 1/E2 calibrée : échelle ×2 → k ÷2", k1 / 2.0, k2, k1 * 0.01) + // et le k calibré ≠ k par défaut (0,19) : il a bien été recalculé + // contre l'E2 du modèle (fix #60) + assertTrue( + "k calibré=$k1 doit différer du k stocké (0,19)", + kotlin.math.abs(k1 - 0.19) > 0.01 + ) + } + + @Test + fun `labs before the first dose yield empty scales (per model too)`() { + val tr = treatment() + // lab ANTÉRIEUR à la 1ʳᵉ dose → pas de période d'attribution → vide + val labs = listOf(LabResult( + marker = "E2", value = 250.0, unit = "pg/mL", timestamp = BASE - DAY_MS + )) + for (model in listOf(null, "ESE", "TFS", "WHS")) { + val scales = PharmacokineticEngine.computeEsterScaleFactors( + listOf(tr), doses(), labs, modelOverride = model + ) + assertTrue("échelles vides attendues pour modelOverride=$model", scales.isEmpty()) + } + } +} diff --git a/app/src/test/java/com/hormonetrack/pk/WhsahEngineTest.kt b/app/src/test/java/com/hormonetrack/pk/WhsahEngineTest.kt index 7fb93f5..26ffe57 100644 --- a/app/src/test/java/com/hormonetrack/pk/WhsahEngineTest.kt +++ b/app/src/test/java/com/hormonetrack/pk/WhsahEngineTest.kt @@ -2,6 +2,7 @@ package com.hormonetrack.pk import com.hormonetrack.data.model.AdministrationRoute import com.hormonetrack.data.model.DoseLog +import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.Esters import com.hormonetrack.data.model.PKModels import com.hormonetrack.data.model.Treatment @@ -103,6 +104,53 @@ class WhsahEngineTest { assertTrue(curve.all { it.e2 == 0.0 }) } + @Test + fun `calibration is per model - each calibrated curve lands on the lab (fix 60)`() { + // Remontée v1.4.7 : « la calibration auto sur WHSAH met ×2,21 » — + // AVANT : un seul jeu de facteurs calculé depuis le modèle STOCKÉ + // puis partagé par toutes les courbes. DÉSORMAIS (fix #60) : chaque + // modèle est calibré avec SA prédiction → chaque courbe calibrée + // passe PAR le lab, quel que soit le modèle affiché. + val tr = treatment(Esters.EEN, PKModels.TRANSFEM_SCIENCE) + val doses = listOf( + DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0) + ) + // lab fictif de 250 pg/mL 7 jours après l'injection (au creux TFS) + val lab = LabResult(marker = "E2", value = 250.0, unit = "pg/mL", + timestamp = BASE + 7 * DAY_MS) + val tConfig = TConfig() + + for ((model, expected) in listOf( + "TFS" to null, // attendu = calculé en dessous (auto-cohérence) + "WHS" to null + )) { + val scales = PharmacokineticEngine.computeEsterScaleFactors( + listOf(tr), doses, listOf(lab), modelOverride = model + ) + assertTrue("échelle $model doit être positive et plausible", + (scales["EEN"] ?: 0.0) > 0.0) + // la courbe calibrée avec SES échelles passe PAR le lab (±2 %) + val calibrated = PharmacokineticEngine.e2At( + listOf(tr), doses, lab.timestamp, + modelOverride = model, scalePerEster = scales + ) + assertTrue( + "$model : calibrée=$calibrated doit ≈ lab=250 (échelle ${scales["EEN"]})", + calibrated in 250.0 * 0.98..250.0 * 1.02 + ) + } + // et les échelles des DEUX modèles sont DISTINCTES (prédictions + // différentes au même instant → facteurs différents) + val scalesTfs = PharmacokineticEngine.computeEsterScaleFactors( + listOf(tr), doses, listOf(lab), modelOverride = "TFS") + val scalesWhs = PharmacokineticEngine.computeEsterScaleFactors( + listOf(tr), doses, listOf(lab), modelOverride = "WHS") + assertTrue( + "TFS=${scalesTfs["EEN"]} vs WHS=${scalesWhs["EEN"]} doivent différer", + kotlin.math.abs(scalesTfs["EEN"]!! - scalesWhs["EEN"]!!) > 0.01 + ) + } + @Test fun `calibration applies to ALL model curves equally (fix 58)`() { // Demande v1.4.7 : « TOUTES les courbes doivent s'adapter au calibrage » diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a9e2654..17870dd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,7 +1,36 @@ # Changelog — HormoneTrack Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). -Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.7`, …). +Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.8`, …). + +## [1.4.8] — 2026-09-07 (versionCode 28) + +### Corrigé +- **La calibration automatique était fausse pour les modèles autres que le + modèle stocké du traitement** (#60, remontée : « la calibration auto sur + WHSAH met ×2,21 alors que l'idéal serait < 1 ») : un SEUL jeu de facteurs + était calculé depuis la prédiction du modèle STOCKÉ puis partagé par + toutes les courbes — en basculant un traitement (ex. EEn TFS → WHSAH), + les facteurs devenaient absurdes (les prédictions WHSAH diffèrent de TFS : + creux plus profonds, montée plus rapide). FIX (demande : « la calibration + doit être active sur tous les modèles mais s'adapter à chaque profil ») : + **la calibration est désormais PAR MODÈLE** — le graphique calcule un jeu + de facteurs (et de k T) PAR modèle affiché (ESE/TFS/WHS), chacun calibré + avec la prédiction DE SON modèle → chaque courbe calibrée passe par tes + labs, quel que soit le modèle superposé. Home conserve la calibration du + modèle stocké (inchangé). Épinglé par test : chaque courbe calibrée passe + par le lab (±2 %) et les échelles TFS/WHS diffèrent. + +### Ajouté +- **5 nouveaux tests** (164 au total / 142 sans les données locales) : + `CalibrationPerModelTest` — les 3 modèles calibrés atterrissent sur le + lab (ESE inclus), échelles distinctes (ESE↔TFS ~2 %, WHS ~11 %), + délégation `autoCalibrated(modelOverride)` → `computeEsterScaleFactors` + (implémentation unique), **k T par modèle** (k ∝ 1/E2 calibrée — et les + k post-calibration CONVERGENT entre modèles : même E2 cible, comportement + voulu), labs antérieurs à la 1ʳᵉ dose → échelles vides (par modèle). + +## [1.4.7] — 2026-09-07 (versionCode 27) ## [1.4.7] — 2026-09-07 (versionCode 27) diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index e447de0..a416aac 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -90,6 +90,7 @@ Fonctionnalités v1 : | 7 sept. 2026 (session v1.4.5) | **« La prévision simule le 13 au lieu du 12 »** (#55) : diagnostic sur données réelles — GÉNÉRATION EXACTE (créneau = dernière dose loguée + 6 j ; subtilité : la dose avait été loguée le LENDENDE de l'injection réelle → dérive d'un jour par cycle, l'app suit les logs) ; illusion causée par l'absence de marqueur + labels X à minuit UTC. FIX : marqueurs de doses (prévisionnels pointillés + réels discrets), labels à minuit LOCAL, fuseau de graphique configurable (Paramètres). **+ #56** : boucle de recomposition (`nowMs` relu à chaque frame → endMs dérive → saturation main thread → taps perdus) → mémoïsé sur le tick. 145 verts. Publication v1.4.5. | | 7 sept. 2026 (session v1.4.6) | **3ᵉ modèle PK « WHSAH »** (demande) : le fit « license-free » du WHSAH Collective publié dans **Mona** (l'app concurrente — son git montre qu'elle utilisait NOS paramètres TFS avant mars 2026) intégré au même niveau : dispatch parallèle (aucun remplacement), 6 presets, dropdown 3 choix, 3ᵉ toggle superposable (vert/violet), PEP non couvert. Comparaison chiffrée TFS vs WHSAF épinglée (EV pic 367 vs 295 ; EEn à J+1 ×3). **+ #57** : l'écran Graphiques défile désormais (rangée de toggles coupée sous le pli, trouvé en émulateur). 155 verts. Publication v1.4.6. | | 7 sept. 2026 (session v1.4.7) | **Toggles alignés sur les traitements** (#58, demande) : au chargement, seuls les modèles utilisés par des traitements à PROFIL PK (`usesProfileModel`) sont ON (l'oral/AA polluait le set — vu en émulateur avec le CPA) ; garde-fou TFS si aucun profil ; une seule init. **Épinglé : la calibration s'applique à TOUTES les courbes** (par ESTER via doseEster, indépendant du modelOverride — test). 158 verts. Publication v1.4.7. | + **#59** : le chip de la carte de traitement affichait « Estrannaise » pour WHSAH (ternaire à 2 branches → `modelLabelRes` centralisé). +| 7 sept. 2026 (session v1.4.8) | **Calibration PAR MODÈLE PK** (#60, remontée : « ×2,21 sur WHSAH alors que l'idéal serait < 1 ») : un seul jeu de facteurs (calculé depuis le modèle stocké) était partagé par toutes les courbes → absurde dès qu'un traitement basculait de modèle. FIX : le graphique calcule une AutoCalibrated PAR modèle affiché (ESE/TFS/WHS — modelOverride propagé à computeEsterScaleFactors et computeTKPerEster) → chaque courbe calibrée passe par les labs. Home inchangé (modèle stocké). Test : chaque courbe calibrée ≈ lab (±2 %), échelles TFS ≠ WHS. 159 verts. Publication v1.4.8. | | 6 sept. 2026 (session v1.3.0) | Dialog « Nouveautés » après mise à jour (CHANGELOG embarqué en asset, tâche `copyChangelog`, version vue en DataStore) ; **événements d'agenda récurrents** (calendrier local HormoneTrack, RRULE posologie, permission runtime, Room v3 `calendarEventId`) ; Paramètres : version + lien releases ; **78 tests verts**, APK v1.3.0 + releases. | | 5 sept. 2026 (session v1.2.10) | Sens des boutons de zoom inversé (+ = zoom avant, convention carte — retour utilisateur) ; 67 tests verts, APK v1.2.10 + releases. | | 5 sept. 2026 (session v1.2.9) | **Zoom du graphique** (pinch + boutons, 6 h → 300 j, focal stable, échantillonnage adaptatif `stepForRange`, labels X 1 h/3 h) ; **README : disclaimer IA-assisté** en en-tête ; 3 tests ; **67 tests verts**, APK v1.2.9 + releases. | @@ -346,15 +347,15 @@ T est faux d'un facteur 100, bug réel remonté par l'utilisatrice : labs 33/44 ### 7.6 Calibration (v1.2.1 : PAR PÉRIODE D'ESTER pour l'auto) -**PORTÉE (épinglé v1.4.7, test `calibration applies to ALL model curves -equally`)** : le facteur s'applique par ESTER de la DOSE (`doseEster`), -indépendamment du modelOverride → TOUTES les courbes affichées (ESE/TFS/ -WHS) d'un même traitement sont scalées identiquement. Subtilité : le -facteur est calculé depuis la prédiction du modèle STOCKÉ du traitement — -les courbes des autres modèles partagent ce facteur (un facteur par -ester/période, pas par modèle : les comparaisons inter-modèles reflètent -la FORME, pas des calibrations distinctes). -**Automatique** (`computeEsterScaleFactors` + `scalePerEster`, option « Auto-calibration ») : +**PORTÉE (v1.4.8, fix #60) : la calibration est PAR MODÈLE PK.** +**PORTÉE (v1.4.8, fix #60)** : le facteur est calculé PAR MODÈLE PK — le +graphique calcule une `AutoCalibrated` par modèle affiché (ESE/TFS/WHS, via +`autoCalibrated(modelOverride = …)`) : chaque courbe est calibrée avec la +prédiction DE SON modèle et passe par les labs quel que soit le modèle +superposé (test `calibration is per model`). Home (sans courbes superposées) +conserve la calibration du modèle STOCKÉ. HISTORIQUE : v1.4.7 partageait UN +seul facteur (calculé depuis le modèle stocké) entre toutes les courbes — +absurde dès qu'un traitement changeait de modèle (×2,21 sur WHSAH). - chaque lab est **attribué à la période d'injection dans laquelle il tombe** = dernière dose E2 ≤ lab (une prise de sang reflète d'abord l'injection qui précède) ; - ratio = lab ÷ prédiction **non calibrée** (toutes doses superposées, scaleFactor forcé 1) ; @@ -457,7 +458,7 @@ niveau que les deux autres, pas de remplacement »). ## 8. Tests unitaires -**158 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **136 sans +**164 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **142 sans les données de test locales** (cf §8.bis : les 3 classes de régression, 6 tests chacune, sont skippées via `Assume`). Dépendance : JUnit 4.13.2. Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution = @@ -590,6 +591,18 @@ dépôt (ni maintenant, ni si le repo devient public un jour). traitement ESE, `pkModel = WHS`, coupure 10 × t½, PEP → 0) et **3 courbes EV distinctes** (ESE/TFS/WHS — seuil 2 % : ESE et TFS sont quasi identiques au pic par construction). +- **`WhsahEngineTest`** (6) : + v1.4.8 — **calibration PAR MODÈLE** (fix + #60) : chaque courbe calibrée avec la prédiction de SON modèle passe par + le lab (±2 %) ; échelles TFS ≠ WHS au même lab. +- **`CalibrationPerModelTest`** (5) : v1.4.8 — **process calibration** : + les 3 modèles (ESE inclus) calibrés atterrissent sur le lab ; échelles + distinctes (ESE↔TFS ~2 %, WHS ~11 % — nuance documentée : la table ODS + dérive du même article) ; **délégation** `autoCalibrated(modelOverride)` + → `computeEsterScaleFactors` (implémentation unique — Home et graphique + ne peuvent pas diverger) ; **k T par modèle** (k ∝ 1/E2 calibrée : + échelle ×2 → k ÷2 ; les k post-calibration convergent entre modèles — + même E2 cible) ; labs antérieurs à la 1ʳᵉ dose → échelles vides (par + modèle). Toute retouche du pipeline de calibration passe par CE test. - **`ChartZoomTest`** (15) : v1.2.9 (pas d'échantillonnage adaptatif) / v1.4.1 (horizon + clamp panoramique) / v1.4.5 (`pointHoursBefore`, `xLabelTicks` minuit local) / v1.4.7 — **`defaultModelToggles`** (toggles @@ -727,7 +740,9 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17). (+ FAB), mini-chart 24 h (multi-séries via `ChartSeries`) **cliquable → écran Graphiques** (v1.2.1) avec mini-légende E2/T ; données auto-calibrées si l'option est active (`scalePerEster = effectiveAuto?.esterScales`) ; rafraîchissement `tick` 60 s -- `ChartScreen` (v1.2, le plus riche) : **toggles de modèles initialement +- `ChartScreen` (v1.2, le plus riche) : **calibration PAR MODÈLE** (v1.4.8, + fix #60 : `autoByModel` — une AutoCalibrated par modèle affiché, chaque + courbe reçoit SES échelles/k) ; **toggles de modèles initialement alignés sur les traitements** (v1.4.7, fix #58 : `defaultModelToggles` sur les pkModel des traitements à PROFIL PK — init unique au chargement ; garde-fou TFS si aucun profil — les traitements Bateman sont tracés dans @@ -1372,8 +1387,8 @@ vérifie pas en tests JVM). 1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`, `versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y). -2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 158 au - total, 136 si `local-test-data/` est absent (les 3 classes de régression +2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 164 au + total, 142 si `local-test-data/` est absent (les 3 classes de régression réelles sont skippées via `Assume`, 6 tests chacune) ; **lint vert obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet : @@ -1676,13 +1691,17 @@ Sur le téléphone de test (à compléter par l'utilisatrice) : - [ ] **v1.4.6** : **3ᵉ modèle WHSAH** : preset WHSAH → courbe verte superposable aux 2 autres (montée plus rapide à J+1, t½ plus longues que TFS) ; PEP non couvert ; 6 presets ; dropdown modèle à 3 choix +- [ ] **v1.4.8** : **calibration par modèle** : superposer TFS + WHSAH avec + l'auto-calibration ON → CHAQUE courbe colle aux labs (plus de facteur + partagé absurde type ×2,21 — fix #60) + - [ ] **v1.4.7** : **toggles alignés sur les traitements** : au 1ᵉʳ chargement du graphique, seuls les modèles des traitements à profil sont ON (ex. EV TFS + EEn WHSAH → Estrannaise OFF) ; les autres activables au tap ; et TOUTES les courbes affichées reflètent la calibration (test) --- -*Doc mise à jour le 7 sept. 2026 (v1.4.7) — build OK, lint vert, 158/158 tests verts (136 sans +*Doc mise à jour le 7 sept. 2026 (v1.4.8) — build OK, lint vert, 164/164 tests verts (142 sans les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée de santé dans le dépôt ni l'historique. Fil des corrections : v1.3.3 = reprise de maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 crashs diff --git a/docs/GUIDE_INSTALLATION.md b/docs/GUIDE_INSTALLATION.md index 457f43c..65fbd5d 100644 --- a/docs/GUIDE_INSTALLATION.md +++ b/docs/GUIDE_INSTALLATION.md @@ -206,7 +206,10 @@ Pics de référence (dose unique de 5 mg, IM) : l'affichage seulement, tes réglages stockés ne changent pas. **Chaque ester est calibré avec les labs faits pendant sa période** : si tu étais sous valerate avant d'être sous enanthate, tes labs valerate calibrent les doses valerate (E2 **et** - la suppression T), et inversement pour l'enanthate + la suppression T), et inversement pour l'enanthate. **Calibration par modèle** + (v1.4.8) : chaque modèle affiché dans le graphique (Estrannaise / Transfem + Science / WHSAH) reçoit SES propres facteurs calculés sur tes labs — les + courbes superposées collent toutes à tes résultats, quelle que soit leur forme ## 9. Dépannage