diff --git a/README.md b/README.md index 0a67152..479b985 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.5 — build Android ✅, **lint vert** ✅, **145 tests unitaires** ✅ (123 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.6 — build Android ✅, **lint vert** ✅, **155 tests unitaires** ✅ (133 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) @@ -35,8 +35,8 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.** **méta-analyse officielle à 3 compartiments** (forme close exacte, params du [simulateur TFS](https://transfemscience.org/misc/injectable-e2-simulator/), [article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)), - avec les **7 esters** : EV, EU, EEn, EB, EC (huile), EC (suspension), PEP, - affichés côte à côte avec toggles indépendants + 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) - **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 @@ -91,7 +91,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 # 145 tests (123 sans les données locales) +./gradlew testDebugUnitTest # 155 tests (133 sans les données locales) ./gradlew lint # lint vert obligatoire avant release ``` @@ -103,7 +103,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.5`) et **deux APK par release** (depuis v1.2.5) +**releases taguées** (`v1.1.0` → `v1.4.6`) 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) : @@ -183,7 +183,7 @@ HormoneTrack/ │ ├── assets/pk_profiles.json ← tables horaires (Estrannaise/TFS) │ ├── java/com/hormonetrack/ │ │ ├── data/ (Room : models, DAOs, repository, backup) - │ │ ├── pk/ (moteur pharmacocinétique + profils) + │ │ ├── pk/ (moteur PK + profils : Estrannaise/TFS/WHSAH, alertes, export) │ │ ├── reminder/ (alarmes exactes, notifs + actions, boot, worker alertes) │ │ ├── settings/ (DataStore : TConfig, langue) │ │ ├── ui/ (Compose : screens, components, theme) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e7fbb5c..65ac2be 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 = 25 - versionName = "1.4.5" + versionCode = 26 + versionName = "1.4.6" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/hormonetrack/data/model/PKPresets.kt b/app/src/main/java/com/hormonetrack/data/model/PKPresets.kt index 9f04375..2fdb7f1 100644 --- a/app/src/main/java/com/hormonetrack/data/model/PKPresets.kt +++ b/app/src/main/java/com/hormonetrack/data/model/PKPresets.kt @@ -113,6 +113,64 @@ object PKPresets { absorptionHours = 432f, eliminationHalfLifeHours = 682f, bioavailabilityFraction = 1f, defaultDoseUnit = "mg", defaultDoseAmount = 32.5 ), + // --- Nouveaux esters : modèle WHSAH (v1.4.6, fit license-free de + // Mona — cf pk/WhsahModels.kt) : 6 esters, PEP non couvert. Les + // champs Bateman reflètent les t½ WHSAH (pas utilisés au calcul, + // affichés dans l'éditeur). + PKPreset( + nameRes = R.string.preset_ev_whs, + type = TreatmentType.ESTRADIOL, + route = AdministrationRoute.INJECTION_IM, + esterType = Esters.EV, + pkModel = PKModels.WHSAH, + absorptionHours = 41f, eliminationHalfLifeHours = 75f, bioavailabilityFraction = 1f, + defaultDoseUnit = "mg", defaultDoseAmount = 4.0 + ), + PKPreset( + nameRes = R.string.preset_eu_whs, + type = TreatmentType.ESTRADIOL, + route = AdministrationRoute.INJECTION_IM, + esterType = Esters.EU, + pkModel = PKModels.WHSAH, + absorptionHours = 80f, eliminationHalfLifeHours = 722f, bioavailabilityFraction = 1f, + defaultDoseUnit = "mg", defaultDoseAmount = 10.0 + ), + PKPreset( + nameRes = R.string.preset_een_whs, + type = TreatmentType.ESTRADIOL, + route = AdministrationRoute.INJECTION_SUBCUT, + esterType = Esters.EEN, + pkModel = PKModels.WHSAH, + absorptionHours = 120f, eliminationHalfLifeHours = 176f, bioavailabilityFraction = 1f, + defaultDoseUnit = "mg", defaultDoseAmount = 5.0 + ), + PKPreset( + nameRes = R.string.preset_eb_whs, + type = TreatmentType.ESTRADIOL, + route = AdministrationRoute.INJECTION_IM, + esterType = Esters.EB, + pkModel = PKModels.WHSAH, + absorptionHours = 13f, eliminationHalfLifeHours = 25f, bioavailabilityFraction = 1f, + defaultDoseUnit = "mg", defaultDoseAmount = 1.0 + ), + PKPreset( + nameRes = R.string.preset_ec_whs, + type = TreatmentType.ESTRADIOL, + route = AdministrationRoute.INJECTION_IM, + esterType = Esters.EC, + pkModel = PKModels.WHSAH, + absorptionHours = 91f, eliminationHalfLifeHours = 161f, bioavailabilityFraction = 1f, + defaultDoseUnit = "mg", defaultDoseAmount = 5.0 + ), + PKPreset( + nameRes = R.string.preset_ecs_whs, + type = TreatmentType.ESTRADIOL, + route = AdministrationRoute.INJECTION_IM, + esterType = Esters.ECS, + pkModel = PKModels.WHSAH, + absorptionHours = 60f, eliminationHalfLifeHours = 110f, bioavailabilityFraction = 1f, + defaultDoseUnit = "mg", defaultDoseAmount = 5.0 + ), // --- Transdermal / oral (Bateman fallback) --- PKPreset( nameRes = R.string.preset_e2_gel, diff --git a/app/src/main/java/com/hormonetrack/data/model/Treatment.kt b/app/src/main/java/com/hormonetrack/data/model/Treatment.kt index d2465a3..8e66979 100644 --- a/app/src/main/java/com/hormonetrack/data/model/Treatment.kt +++ b/app/src/main/java/com/hormonetrack/data/model/Treatment.kt @@ -19,11 +19,28 @@ object Esters { /** Esters injectables proposables par ester (override par dose). */ val INJECTABLE: List = listOf(EV, EU, EEN, EB, EC, ECS, PEP) + + /** + * Esters proposables dans l'override par dose pour un MODÈLE PK donné + * (v1.4.6, cf DoseDialog) : la liste suit la couverture du modèle — + * TFS (V3C méta-analyse) → les 7 esters ; WHSAH (fit Mona) → les 6 + * (PEP non couvert par ce fit) ; Estrannaise (tables ODS) → EV/EU/EEn + * seulement (un traitement ESE + EB produirait 0 pg/mL). + */ + fun choicesForModel(pkModel: String): List = when (pkModel) { + PKModels.TRANSFEM_SCIENCE -> INJECTABLE + PKModels.WHSAH -> listOf(EV, EU, EEN, EB, EC, ECS) + else -> listOf(EV, EU, EEN) + } } object PKModels { const val ESTRANNAISE = "ESE" const val TRANSFEM_SCIENCE = "TFS" + // v1.4.6 : 3ᵉ modèle PK — le fit « license-free » du WHSAH Collective + // (source : app Mona, cf pk/WhsahModels.kt). Champ texte → aucune + // migration Room ni changement de backup nécessaires. + const val WHSAH = "WHS" } enum class TreatmentType { diff --git a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt index 04e195b..982b170 100644 --- a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt +++ b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt @@ -90,10 +90,15 @@ object PharmacokineticEngine { // V3C (3 compartiments) de la méta-analyse TFS, évalués en // forme close — les tables ODS restent le fallback (esters // sans modèle V3C, ou modèle Estrannaise). - if (model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(ester)) { - return TransfemScienceModels.sample(ester, dtH) * mg + // v1.4.6 : le modèle WHSAH (fit license-free de Mona) est la + // 3ᵉ voie de dispatch, au même niveau que TFS. + return when { + model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(ester) -> + TransfemScienceModels.sample(ester, dtH) * mg + model == PKModels.WHSAH && WhsahModels.hasModel(ester) -> + WhsahModels.sample(ester, dtH) * mg + else -> PKProfileStore.sample(ester, model, dtH) * mg } - return PKProfileStore.sample(ester, model, dtH) * mg } } @@ -112,14 +117,17 @@ object PharmacokineticEngine { private fun cutoffHours(treatment: Treatment): Double { val profileH = if (treatment.usesProfileModel) { val model = treatment.pkModel - if (model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType)) { - // v1.4.0 : modèle V3C (forme close, pas de table bornée) → - // coupure à 10 demi-vies TERMINALES (~0,1 % de résidu), la - // t½ venant des paramètres de l'article (ex. EV 3 j → 30 j). - TransfemScienceModels.model(treatment.esterType)!! - .terminalHalfLifeDays * 24.0 * 10.0 - } else { - PKProfileStore.profileLength(treatment.esterType, model).toDouble() + when { + // v1.4.6 : modèle WHSAH → coupure à 10 demi-vies terminales + // du fit (ex. EEn t½ 7,3 j → 73 j), comme pour le V3C TFS. + model == PKModels.WHSAH && WhsahModels.hasModel(treatment.esterType) -> + WhsahModels.model(treatment.esterType)!! + .terminalHalfLifeDays * 24.0 * 10.0 + model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType) -> + TransfemScienceModels.model(treatment.esterType)!! + .terminalHalfLifeDays * 24.0 * 10.0 + else -> + PKProfileStore.profileLength(treatment.esterType, model).toDouble() } } else 0.0 val batemanH = if (treatment.usesProfileModel) 0.0 diff --git a/app/src/main/java/com/hormonetrack/pk/WhsahModels.kt b/app/src/main/java/com/hormonetrack/pk/WhsahModels.kt new file mode 100644 index 0000000..c9ca7ed --- /dev/null +++ b/app/src/main/java/com/hormonetrack/pk/WhsahModels.kt @@ -0,0 +1,102 @@ +package com.hormonetrack.pk + +/** + * Modèle PK « WHSAH » (v1.4.6) — 3ᵉ modèle au niveau d'Estrannaise et + * Transfem Science, superposable sur le graphique. + * + * SOURCE : le fit « license-free » publié par le WHSAH Collective dans + * l'app open-source Mona (projet Flutter — lib/data/model/graph_calculator.dart, + * commit « new license-free pk params » ; leurs auteurs déclarent explicitement + * les coefficients libres d'usage, « Inferred with love exclusively for Mona »). + * Crédit : alix / WHSAH Collective via Mona. + * + * POURQUOI CE MODÈLE EXISTE (cf diagnostic comparatif du 7 sept. 2026) : + * Mona a d'abord utilisé les paramètres Transfem Science (identiques aux + * nôtres, commit du 6 mars 2026) PUIS les a remplacés par ce fit indépendant. + * Les deux sont des tri-exponentielles en forme close ajustées sur les mêmes + * études, avec des conventions différentes : + * + * - WHSAH paramètre une BIODISPONIBILITÉ explicite F < 1 (0,62–0,76) et + * un facteur d'échelle `auc` intégré — les courbes « brutes » sont ~24 % + * plus basses que TFS non calibrée (la calibration de l'app absorbe ça) ; + * - la MONTÉE est plus rapide (compartiment rapide plus vif) et la t½ + * terminale plus longue sur certains esters : EV Cmax 367 pg/mL @ 1,7 j + * (TFS : 295 @ 2,1 j), EEn Cmax 188 @ 5,0 j (TFS : 160 @ 6,5 j) et + * t½ EEn 7,3 j (TFS : 4,5 j) — à J+1 l'EEn WHSAH est ~3× plus haut + * (70 vs 22 pg/mL) ; + * - PEP NON COUVERT (retiré du jeu WHSAH) → `sample("PEP", …) = 0`. + * + * FORMULE (telle qu'implémentée dans Mona, normalisée PAR MG pour le moteur + * — le multiplicateur de dose est appliqué par `PharmacokineticEngine`) : + * + * sample = F · auc · k1·k2·k3 · [ e^(−k1·t)/((k1−k2)(k1−k3)) + * − e^(−k2·t)/((k1−k2)(k2−k3)) + * + e^(−k3·t)/((k1−k3)(k2−k3)) ] + * + * avec t en JOURS, résultat en pg/mL pour 1 mg. Sanity checks épinglés par + * `WhsahModelsTest` (fidélité au comportement de Mona, ±2 %) : + * EV Cmax 367,5 @ 1,69 j · EEn 187,9 @ 4,99 j · t½ EEn 7,34 j. + */ +object WhsahModels { + + /** Modèle WHSAH d'un ester (fit F/auc/k1/k2/k3). */ + data class WHS( + /** Clé ester de l'app ("EV", "EB", …). */ + val ester: String, + /** Biodisponibilité explicite (< 1 dans le fit WHSAH). */ + val F: Double, + /** Constante d'échelle du fit (pg/mL intégrés). */ + val auc: Double, + /** Constantes de vitesse en j⁻¹ (k1 = absorption, k2/k3 phases). */ + val k1: Double, + val k2: Double, + val k3: Double + ) { + /** Demi-vie TERMINALE (jours) = la phase la plus lente des trois. */ + val terminalHalfLifeDays: Double + get() = kotlin.math.ln(2.0) / minOf(k1, k2, k3) + } + + private val MODELS: Map = mapOf( + // estradiol enanthate (solution huileuse) + "EEN" to WHS("EEN", F = 0.708, auc = 875.4, k1 = 0.09441, k2 = 3.354, k3 = 0.4078), + // estradiol valerate (solution huileuse) + "EV" to WHS("EV", F = 0.764, auc = 621.3, k1 = 0.2230, k2 = 17.62, k3 = 1.305), + // estradiol benzoate (solution huileuse) + "EB" to WHS("EB", F = 0.723, auc = 889.9, k1 = 0.5220, k2 = 521.9, k3 = 5.223), + // estradiol cypionate — SOLUTION HUILEUSE + "EC" to WHS("EC", F = 0.687, auc = 554.5, k1 = 0.0880, k2 = 17.95, k3 = 0.7177), + // estradiol cypionate — SUSPENSION AQUEUSE (microcristaux) + "ECS" to WHS("ECS", F = 0.687, auc = 852.6, k1 = 0.0973, k2 = 218.67, k3 = 6.624), + // estradiol undecylate — données d'étude limitées (comme chez TFS) + "EU" to WHS("EU", F = 0.618, auc = 385.8, k1 = 0.02189, k2 = 183.4, k3 = 1.564) + ) + + fun hasModel(ester: String): Boolean = MODELS.containsKey(ester.uppercase()) + + /** Le modèle WHSAH de l'ester, ou null (lookup insensible à la casse). */ + fun model(ester: String): WHS? = MODELS[ester.uppercase()] + + /** + * Réponse normalisée (pg/mL PAR mg) à dtHours après une injection de 1 mg, + * modèle WHSAH. dtHours ≤ 0 → 0 (rien avant la dose). + */ + fun sample(ester: String, dtHours: Double): Double { + if (dtHours <= 0.0) return 0.0 + val m = model(ester) ?: return 0.0 + return rawConcentrationDays(m, dtHours / 24.0) // t en jours + } + + /** + * Cp(t) en pg/mL pour 1 mg (forme Mona exacte — voir KDoc de l'objet). + * Les compartiments « ultra-rapides » (k2 jusqu'à ~520 j⁻¹ : EB) décroissent + * instantanément et n'affectent que t≈0 — double précision suffisante + * (vérifié par les tests de pics). + */ + private fun rawConcentrationDays(m: WHS, tDays: Double): Double { + val part1 = kotlin.math.exp(-m.k1 * tDays) / ((m.k1 - m.k2) * (m.k1 - m.k3)) + val part2 = kotlin.math.exp(-m.k2 * tDays) / ((m.k1 - m.k2) * (m.k2 - m.k3)) + val part3 = kotlin.math.exp(-m.k3 * tDays) / ((m.k1 - m.k3) * (m.k2 - m.k3)) + return m.F * m.auc * m.k1 * m.k2 * m.k3 * (part1 - part2 + part3) + } +} diff --git a/app/src/main/java/com/hormonetrack/ui/components/DoseDialog.kt b/app/src/main/java/com/hormonetrack/ui/components/DoseDialog.kt index 76c553a..caf72d9 100644 --- a/app/src/main/java/com/hormonetrack/ui/components/DoseDialog.kt +++ b/app/src/main/java/com/hormonetrack/ui/components/DoseDialog.kt @@ -140,16 +140,11 @@ fun DoseDialog( text = { Text(stringResource(R.string.ester_default, selected.esterType)) }, onClick = { esterOverride = null; esterExpanded = false } ) - // v1.4.0 : la liste d'esters suit le MODÈLE du - // traitement — TFS = tous les esters de la - // méta-analyse (V3C, cf TransfemScienceModels), - // Estrannaise (ODS) = EV/EU/EEn seulement (les - // tables ne couvrent pas les autres esters). - val choices = if (selected.pkModel == PKModels.TRANSFEM_SCIENCE) { - Esters.INJECTABLE - } else { - listOf(Esters.EV, Esters.EU, Esters.EEN) - } + // v1.4.0/v1.4.6 : la liste d'esters suit le MODÈLE + // du traitement (helper pur Esters.choicesForModel, + // testé) — TFS = 7 esters (V3C), WHSAH = 6 (PEP non + // couvert), Estrannaise (ODS) = EV/EU/EEn seulement. + val choices = Esters.choicesForModel(selected.pkModel) choices.forEach { e -> DropdownMenuItem( text = { Text(esterLabel(e)) }, 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 c6fd48c..b24f13d 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt @@ -1,6 +1,8 @@ package com.hormonetrack.ui.screens import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -50,6 +52,8 @@ import com.hormonetrack.ui.components.CurveChart import com.hormonetrack.ui.components.CurveStyle import com.hormonetrack.ui.theme.ChartE2 import com.hormonetrack.ui.theme.ChartT +import com.hormonetrack.ui.theme.ChartTWhs +import com.hormonetrack.ui.theme.ChartWhs import com.hormonetrack.ui.theme.LabDot import com.hormonetrack.ui.theme.TealTertiary import kotlinx.coroutines.Dispatchers @@ -105,6 +109,8 @@ fun ChartScreen() { var showLabs by remember { mutableStateOf(true) } var showEse by remember { mutableStateOf(true) } var showTfs by remember { mutableStateOf(true) } + // v1.4.6 : 3ᵉ modèle PK superposable (WHSAH — fit license-free de Mona) + var showWhs by remember { mutableStateOf(false) } var showForecast by remember { mutableStateOf(false) } var showExtrema by remember { mutableStateOf(false) } var chartWidthPx by remember { mutableIntStateOf(0) } @@ -203,11 +209,11 @@ 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, showForecast + effectiveAuto, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast ) { withContext(Dispatchers.Default) { val list = mutableListOf>>() - if (showEse || showTfs) { + 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 @@ -244,6 +250,18 @@ fun ChartScreen() { ) ) } + // v1.4.6 : 3ᵉ modèle WHSAH (même mécanique d'override) + if (showWhs) { + list.add( + "WHS" to PharmacokineticEngine.computeCurve( + effTreatments, allDoses, startMs, endMs, + stepMs = step, + tConfig = effTConfig, modelOverride = "WHS", + scalePerEster = effScales, + tKPerEster = effectiveAuto?.tKPerEster + ) + ) + } } value = list } @@ -262,16 +280,21 @@ fun ChartScreen() { // Série visuelle : E2 Estrannaise = bleu plein, E2 TFS = turquoise plein ; // T Estrannaise = rose plein, T TFS = rose pointillé. val series = curves.map { (model, points) -> - if (model == "ESE") { - ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT)) - } else { - ChartSeries(points, CurveStyle(TealTertiary), CurveStyle(ChartT, dashed = true)) + when (model) { + "ESE" -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT)) + // v1.4.6 : WHSAH = E2 vert plein, T violet pointillé + "WHS" -> ChartSeries(points, CurveStyle(ChartWhs), CurveStyle(ChartTWhs, dashed = true)) + else -> ChartSeries(points, CurveStyle(TealTertiary), CurveStyle(ChartT, dashed = true)) } } + // v1.4.6 : l'écran DÉFILIE verticalement — la rangée des toggles de + // modèles (Estrannaise/Transfem/WHSAH) était coupée sous le pli sur les + // écrans standards (le chip WHSAH était littéralement hors écran). Column( Modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(16.dp) ) { TopAppBar( @@ -351,7 +374,7 @@ fun ChartScreen() { Spacer(Modifier.height(8.dp)) - // Modèles PK superposables + // Modèles PK superposables (3ᵉ = WHSAH, v1.4.6) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip( selected = showEse, @@ -363,6 +386,11 @@ fun ChartScreen() { onClick = { showTfs = !showTfs }, label = { Text(stringResource(R.string.model_tfs)) } ) + FilterChip( + selected = showWhs, + onClick = { showWhs = !showWhs }, + label = { Text(stringResource(R.string.model_whs)) } + ) } Spacer(Modifier.height(12.dp)) @@ -448,31 +476,49 @@ fun ChartScreen() { // Légende dynamique curves.forEach { (model, _) -> - if (model == "ESE") { - Text( - stringResource(R.string.legend_ese_e2), - style = MaterialTheme.typography.labelMedium, - color = ChartE2 - ) - if (showT) { + when (model) { + "ESE" -> { Text( - stringResource(R.string.legend_ese_t), + stringResource(R.string.legend_ese_e2), style = MaterialTheme.typography.labelMedium, - color = ChartT + color = ChartE2 ) + if (showT) { + Text( + stringResource(R.string.legend_ese_t), + style = MaterialTheme.typography.labelMedium, + color = ChartT + ) + } } - } else { - Text( - stringResource(R.string.legend_tfs_e2), - style = MaterialTheme.typography.labelMedium, - color = TealTertiary - ) - if (showT) { + // v1.4.6 : légende WHSAH (vert / violet pointillé) + "WHS" -> { Text( - stringResource(R.string.legend_tfs_t), + stringResource(R.string.legend_whs_e2), style = MaterialTheme.typography.labelMedium, - color = ChartT + color = ChartWhs ) + if (showT) { + Text( + stringResource(R.string.legend_whs_t), + style = MaterialTheme.typography.labelMedium, + color = ChartTWhs + ) + } + } + else -> { + Text( + stringResource(R.string.legend_tfs_e2), + style = MaterialTheme.typography.labelMedium, + color = TealTertiary + ) + if (showT) { + Text( + stringResource(R.string.legend_tfs_t), + style = MaterialTheme.typography.labelMedium, + color = ChartT + ) + } } } } diff --git a/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt index fb6743b..d6879d7 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt @@ -277,12 +277,17 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) { Spacer(Modifier.height(8.dp)) DropdownField( label = stringResource(R.string.pk_model), - selectedLabel = stringResource( - if (model == PKModels.TRANSFEM_SCIENCE) R.string.model_tfs else R.string.model_ese - ), + // v1.4.6 : 3ᵉ modèle PK (WHSAH — fit license-free de Mona, + // cf pk/WhsahModels.kt) superposable dans le graphique. + selectedLabel = when (model) { + PKModels.TRANSFEM_SCIENCE -> stringResource(R.string.model_tfs) + PKModels.WHSAH -> stringResource(R.string.model_whs) + else -> stringResource(R.string.model_ese) + }, options = listOf( PKModels.ESTRANNAISE to stringResource(R.string.model_ese), - PKModels.TRANSFEM_SCIENCE to stringResource(R.string.model_tfs) + PKModels.TRANSFEM_SCIENCE to stringResource(R.string.model_tfs), + PKModels.WHSAH to stringResource(R.string.model_whs) ), onSelect = { model = it } ) diff --git a/app/src/main/java/com/hormonetrack/ui/theme/Color.kt b/app/src/main/java/com/hormonetrack/ui/theme/Color.kt index 63a74b7..d31ac65 100644 --- a/app/src/main/java/com/hormonetrack/ui/theme/Color.kt +++ b/app/src/main/java/com/hormonetrack/ui/theme/Color.kt @@ -12,6 +12,9 @@ val TransPink = Color(0xFFF5A9B8) val TealTertiary = Color(0xFF2FA48D) val TealContainer = Color(0xFFBFF0E4) val ChartE2 = Color(0xFF4F5BD5) +// v1.4.6 : modèle WHSAH (3ᵉ PK) — E2 vert profond, T violet (pointillé) +val ChartWhs = Color(0xFF2E7D32) +val ChartTWhs = Color(0xFF9C27B0) val ChartT = Color(0xFFD6589E) val LabDot = Color(0xFFE67E22) val GoodGreen = Color(0xFF2E7D32) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 3c907d5..561fbeb 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -79,6 +79,8 @@ -- T · Estrannaise (ng/mL, axe droit) — E2 · Transfem Science -- T · Transfem Science + — E2 WHSAH (pg/mL, gauche) + -- T WHSAH (ng/mL, droite) ● Résultats de prise de sang Δ %1$s j @@ -136,6 +138,7 @@ Modèle PK Estrannaise Transfem Science + WHSAH Choisir un modèle (optionnel) Temps jusqu\'au pic (h) @@ -208,6 +211,12 @@ Injection EB — Transfem Science Injection EC (huile) — Transfem Science Injection EC (suspension aqueuse) — Transfem Science + Injection EV — WHSAH + Injection EU — WHSAH + Injection EEn — WHSAH + Injection EB — WHSAH + Injection EC (huile) — WHSAH + Injection EC (suspension aqueuse) — WHSAH Injection PEP — Transfem Science E2 gel transdermique E2 patch diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 43624ed..415e4ad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -79,6 +79,8 @@ -- T · Estrannaise (ng/mL, right axis) — E2 · Transfem Science -- T · Transfem Science + — E2 WHSAH (pg/mL, left) + -- T WHSAH (ng/mL, right) ● Lab results Δ %1$s d @@ -136,6 +138,7 @@ PK model Estrannaise Transfem Science + WHSAH Choose a preset (optional) Time to peak (h) @@ -208,6 +211,12 @@ EB injection — Transfem Science EC injection (oil) — Transfem Science EC injection (aqueous suspension) — Transfem Science + EV injection — WHSAH + EU injection — WHSAH + EEn injection — WHSAH + EB injection — WHSAH + EC injection (oil) — WHSAH + EC injection (aqueous suspension) — WHSAH PEP injection — Transfem Science E2 transdermal gel E2 patch diff --git a/app/src/test/java/com/hormonetrack/pk/WhsahEngineTest.kt b/app/src/test/java/com/hormonetrack/pk/WhsahEngineTest.kt new file mode 100644 index 0000000..b1de3f7 --- /dev/null +++ b/app/src/test/java/com/hormonetrack/pk/WhsahEngineTest.kt @@ -0,0 +1,123 @@ +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.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.Before +import org.junit.Test +import java.io.File + +/** + * Test d'INTÉGRATION du câblage moteur ↔ modèle WHSAH (v1.4.6) — miroir de + * `TransfemScienceEngineTest` : on vérifie le DISPATCH (`concentrationOfDose`/ + * `e2At`/`computeCurve` avec `pkModel = WHS` ou `modelOverride = "WHS"`) et la + * coupure moteur (`cutoffHours` = 10 × t½ terminale WHSAH). + * + * Scénarios épinglés : + * 1. un traitement stocké en ESE mais tracé avec `modelOverride = WHS` + * reproduit le comportement Mona (EV : pic 367 pg/mL @ 1,7 j) ; + * 2. `pkModel = WHS` sur EEn → courbe WHSAH (t½ 7,3 j → contribution bien + * au-delà de 30 j, contrairement au cutoff TFS) ; + * 3. WHS + ester sans modèle (PEP) → 0 partout (documenté, PEP non couvert) ; + * 4. ESE vs TFS vs WHS donnent 3 courbes DISTINCTES sur EV (les toggles + * du graphique ne peuvent pas se recouvrir). + */ +class WhsahEngineTest { + + private companion object { + val BASE = System.currentTimeMillis() + const val DAY_MS = 86_400_000L + const val HOUR_MS = 3_600_000L + } + + @Before + fun loadProfiles() { + // les tables ODS servent au chemin ESE (test de contraste) + if (!PKProfileStore.hasProfile("EV", "ESE")) { + val file = listOf( + File("src/main/assets/pk_profiles.json"), + File("app/src/main/assets/pk_profiles.json") + ).first { it.exists() } + PKProfileStore.initWithJson(file.readText()) + } + } + + private fun treatment(ester: String, model: String, doseMg: Double = 5.0): Treatment = Treatment( + id = 1L, + name = "Injection $ester", + type = TreatmentType.ESTRADIOL, + route = AdministrationRoute.INJECTION_IM, + doseAmount = doseMg, + doseUnit = "mg", + isActive = true, + esterType = ester, + pkModel = model + ) + + private fun dose(treatmentId: Long = 1L, mg: Double = 5.0): DoseLog = + DoseLog(treatmentId = treatmentId, timestamp = BASE, doseAmount = mg) + + @Test + fun `EV stocke en ESE mais override WHS reproduit Mona`() { + val tr = treatment(Esters.EV, PKModels.ESTRANNAISE) + val d = dose(mg = 5.0) + // Override WHS → courbe WHSAH : pic de Mona 367 pg/mL @ 1,69 j + val atPeak = PharmacokineticEngine.e2At( + listOf(tr), listOf(d), BASE + (1.69 * DAY_MS).toLong(), modelOverride = "WHS" + ) + assertTrue("EV@1,69j WHS = $atPeak (attendu ~367)", atPeak in 355.0..380.0) + // sans override → chemin ESE (tables ODS) : pic différent (~61 pg/mL/mg → 305) + val ese = PharmacokineticEngine.e2At(listOf(tr), listOf(d), BASE + (0.75 * DAY_MS).toLong()) + assertTrue("EV ESE (tables ODS) = $ese", ese > 200.0) + } + + @Test + fun `pkModel WHS on EEn contributes far beyond 30 days (10 x t-half cutoff)`() { + val tr = treatment(Esters.EEN, PKModels.WHSAH) + val d = dose(mg = 5.0) + // t½ WHSAH EEn = 7,34 j → coupure à ~73 j : à 40 j la contribution + // existe (le cutoff TFS 10×4,5 j = 45 j la laisserait aussi, mais le + // cutoff ANCIEN 24 h du fallback tables aurait tout tué) + val at30d = PharmacokineticEngine.e2At(listOf(tr), listOf(d), BASE + 30 * DAY_MS) + assertTrue("EEn WHS @30 j = $at30d", at30d > 20.0) + val at70d = PharmacokineticEngine.e2At(listOf(tr), listOf(d), BASE + 70 * DAY_MS) + assertTrue("EEn WHS @70 j = $at70d (10×t½ = 73,4 j → résiduel mais non nul)", at70d > 0.1) + // au-delà de la coupure : 0 par design + val at80d = PharmacokineticEngine.e2At(listOf(tr), listOf(d), BASE + 80 * DAY_MS) + assertEquals(0.0, at80d, 1e-9) + } + + @Test + fun `WHS model with uncovered ester yields zero (PEP not in the fit)`() { + val tr = treatment(Esters.PEP, PKModels.WHSAH, doseMg = 32.5) + val d = dose(mg = 32.5) + val curve = PharmacokineticEngine.computeCurve( + listOf(tr), listOf(d), BASE, BASE + 5 * DAY_MS, + tConfig = TConfig() + ) + assertTrue(curve.all { it.e2 == 0.0 }) + } + + @Test + fun `three models give three distinct EV curves (no toggle collapse)`() { + val tr = treatment(Esters.EV, PKModels.ESTRANNAISE) + val d = dose(mg = 5.0) + val t = BASE + (2.0 * DAY_MS).toLong() + val ese = PharmacokineticEngine.e2At(listOf(tr), listOf(d), t, modelOverride = "ESE") + val tfs = PharmacokineticEngine.e2At(listOf(tr), listOf(d), t, modelOverride = "TFS") + val whs = PharmacokineticEngine.e2At(listOf(tr), listOf(d), t, modelOverride = "WHS") + // 3 interprétations DISTINCTES du même ester — attention : EV ESE et + // TFS sont quasi identiques au pic (305 vs 294 = 3,7 %, les tables + // ODS dérivaient du même article) ; le seuil de distinction est 2 %. + assertTrue("ESE=$ese vs TFS=$tfs", abs(ese - tfs) > 0.02 * ese) + assertTrue("TFS=$tfs vs WHS=$whs", abs(tfs - whs) > 0.02 * tfs) + assertTrue("WHS=$whs vs ESE=$ese", abs(whs - ese) > 0.02 * whs) + } + + private fun abs(x: Double): Double = kotlin.math.abs(x) +} diff --git a/app/src/test/java/com/hormonetrack/pk/WhsahModelsTest.kt b/app/src/test/java/com/hormonetrack/pk/WhsahModelsTest.kt new file mode 100644 index 0000000..75410b5 --- /dev/null +++ b/app/src/test/java/com/hormonetrack/pk/WhsahModelsTest.kt @@ -0,0 +1,111 @@ +package com.hormonetrack.pk + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Épingle le modèle **WHSAH** (v1.4.6) sur le comportement de MONA — le fit + * « license-free » du WHSAH Collective (app Mona, + * lib/data/model/graph_calculator.dart, params F/auc/k1/k2/k3). + * + * ⚠️ Contrairement à `TransfemScienceModelsTest` (fidélité à l'ARTICLE), + * ici la référence est le CODE de Mona : les valeurs de référence ci-dessous + * ont été calculées en portant la formule Mona avec SES paramètres. Toute + * retouche de `WhsahModels.kt` doit faire correspondre Mona à ±2 %. + */ +class WhsahModelsTest { + + /** Courbe 5 mg = sample × 5 (pg/mL). */ + private fun c5mg(ester: String, days: Double): Double = + WhsahModels.sample(ester, days * 24.0) * 5.0 + + /** Max/t de la courbe 5 mg sur 0..60 j (pas 0,005 j). */ + private fun peak(ester: String): Pair { + var best = 0.0 to 0.0 + var t = 0.005 + while (t < 60.0) { + val v = c5mg(ester, t) + if (v > best.first) best = v to t + t += 0.005 + } + return best + } + + @Test + fun `EV peak matches Mona behavior (367 pg per mL at 1-7 days)`() { + val (cmax, tmax) = peak("EV") + assertTrue("EV Cmax=$cmax attendu 367,5", cmax in 367.5 * 0.98..367.5 * 1.02) + assertTrue("EV Tmax=$tmax attendu 1,69", tmax in 1.69 * 0.95..1.69 * 1.05) + } + + @Test + fun `EEn peak matches Mona behavior (188 pg per mL at 5-0 days)`() { + val (cmax, tmax) = peak("EEn") + assertTrue("EEn Cmax=$cmax attendu 187,9", cmax in 187.9 * 0.98..187.9 * 1.02) + assertTrue("EEn Tmax=$tmax attendu 4,99", tmax in 4.99 * 0.95..4.99 * 1.05) + } + + @Test + fun `terminal half-lives match the WHSAH fit`() { + // t½ terminale (la phase la plus lente) — dérivées des params WHSAH + val expected = mapOf( + "EV" to 3.11, // ln2 / 0,2230 + "EEn" to 7.34, // ln2 / 0,09441 + "EB" to 1.33, // ln2 / 0,522 (min des trois k : k1, pas k2=521,9) + "EC" to 7.88, // ln2 / 0,0880 + "ECS" to 7.12, // ln2 / 0,0973 + "EU" to 31.7 // ln2 / 0,02189 + ) + for ((ester, tHalfDays) in expected) { + val m = WhsahModels.model(ester)!! + assertTrue( + "$ester t½=${m.terminalHalfLifeDays} attendu $tHalfDays", + m.terminalHalfLifeDays in tHalfDays * 0.98..tHalfDays * 1.02 + ) + } + } + + @Test + fun `normalization is linear per mg (5 mg = 5x the unit response)`() { + // sample retourne pg/mL PAR MG : 5 mg = 5 × sample (cohérence moteur) + val t = 2.0 + assertEquals( + WhsahModels.sample("EEn", t * 24.0) * 5.0, + c5mg("EEn", t), + 1e-9 + ) + // et la linéarité en dose (le moteur superpose les contributions) + val one = WhsahModels.sample("EV", 48.0) + val two = WhsahModels.sample("EV", 48.0) * 2.0 + assertEquals(one * 2.0, two, 1e-12) + } + + @Test + fun `ester choices follow the pk model (TFS=7, WHS=6, ESE=3)`() { + // v1.4.6 : la liste d'override par dose suit la couverture du modèle + assertEquals(listOf("EV", "EU", "EEN", "EB", "EC", "ECS", "PEP"), + com.hormonetrack.data.model.Esters.choicesForModel( + com.hormonetrack.data.model.PKModels.TRANSFEM_SCIENCE)) + assertEquals(listOf("EV", "EU", "EEN", "EB", "EC", "ECS"), + com.hormonetrack.data.model.Esters.choicesForModel( + com.hormonetrack.data.model.PKModels.WHSAH)) + assertEquals(listOf("EV", "EU", "EEN"), + com.hormonetrack.data.model.Esters.choicesForModel( + com.hormonetrack.data.model.PKModels.ESTRANNAISE)) + } + + @Test + fun `PEP is not covered by the WHSAH fit (like Mona)`() { + assertTrue(!WhsahModels.hasModel("PEP")) + assertEquals(0.0, WhsahModels.sample("PEP", 10.0), 1e-12) + // dt ≤ 0 → 0 + assertEquals(0.0, WhsahModels.sample("EEn", -1.0), 1e-12) + assertEquals(0.0, WhsahModels.sample("EEn", 0.0), 1e-12) + // esters couverts : les 6 (lookup insensible à la casse) + for (ester in listOf("EV", "EEN", "EB", "EC", "ECS", "EU")) { + assertTrue(WhsahModels.hasModel(ester)) + } + assertTrue(WhsahModels.hasModel("een")) // casse libre + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cb4edd5..4f1d27f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,7 +1,39 @@ # 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.5`, …). +Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.6`, …). + +## [1.4.6] — 2026-09-07 (versionCode 26) + +### Ajouté +- **3ᵉ modèle PK superposable : WHSAH** (demande : « ajouter le nouveau + modèle à côté de tous les autres comme un 3ᵉ choix possible, au même + niveau ») : le fit « license-free » du **WHSAH Collective** publié dans + l'app open-source **Mona** (libre d'usage par leurs auteurs — crédit : + alix / WHSAH Collective via Mona). Même famille mathématique (tri- + exponentielle en forme close) mais paramètres refittés avec une + **biodisponibilité explicite F < 1** : montée plus rapide à J+1 (EEn + ~70 pg/mL vs ~22 chez TFS pour 5 mg) et t½ terminale plus longue + (EEn 7,3 j vs 4,5 j). **PEP non couvert** par ce fit (comme chez Mona). +- **6 presets WHSAH** (EV/EU/EEn/EB/EC/ECS) + dropdown « PK model » à 3 + choix dans l'éditeur ; l'override d'ester par dose suit la couverture du + modèle (helper `Esters.choicesForModel`, testé : TFS=7 / WHS=6 / ESE=3). +- **Écran Graphiques : 3 toggles de modèles indépendants** (Estrannaise / + Transfem Science / WHSAH superposables simultanément — légende en 3 + couleurs : bleu / turquoise / vert) + **l'écran défile verticalement** + (la rangée des toggles était coupée sous le pli, #57). +- **7 nouveaux tests** (155 au total / 133 sans les données locales) : + `WhsahModelsTest` (fidélité au comportement de Mona : EV 367,5 @ 1,69 j, + EEn 187,9 @ 4,99 j, t½ 7,34 j), `WhsahEngineTest` (dispatch, coupure + 10×t½, PEP → 0, 3 courbes distinctes), `choicesForModel`. + +### Corrigé +- **Écran Graphiques : rangée des toggles de modèles coupée sous le pli** + (#57, découvert en validant sur émulateur) : le Column ne défilait pas — + le chip WHSAH était littéralement hors écran sur un téléphone standard. + FIX : écran à défilement vertical. + +## [1.4.5] — 2026-09-07 (versionCode 25) ## [1.4.5] — 2026-09-07 (versionCode 25) diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 4e3a32a..33dd176 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -88,6 +88,7 @@ Fonctionnalités v1 : | 7 sept. 2026 (session v1.4.3) | **Question pharmacocinétique → 1 bug + 1 confirmation** : « l'estimation ne remonte pas le lendemain de mon injection EEn » → (a) **comportement NORMAL** (contribution J+1 < 12 % du total, plateau d'équilibre — épinglé par la régression n°4 sur le 4ᵉ export réel) ; (b) **bug #53** : le delta « vs il y a 6 h » de l'accueil comparait en réalité il y a 24 h (`firstOrNull` sur la fenêtre 24 h) → helper `pointHoursBefore` + 4 tests. 137 verts. Publication v1.4.3. | | 7 sept. 2026 (session v1.4.4) | **Prévision inaccessible depuis v1.4.1** (#54, remontée) : DEUX causes emboîtées — (a) captures FIGÉES dans `pointerInput(Unit)` (`futurePanHorizon` lu à 0 pour toujours → pan futur interdit) → `rememberUpdatedState` + **garde de source** (`ChartScreenSourceGuardTest`, le lint Compose ne voit pas ce pattern) ; (b) toggle sans effet visible (`endMs = now` → 0 dose générée) → extension contrôlée à droite (`forecastExtensionHours`, jusqu'au 1ᵉʳ créneau, sans déplacer le début). Validé émulateur : drag gauche → panHours < 0, courbe dessinée au-delà de « maintenant » (screencap). 140 verts. Publication v1.4.4. | | 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. | | 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. | @@ -410,9 +411,44 @@ Cp(t) = D·k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3)) + e^(−k3·t)/((k1−k (la table en DÉRIVAIT) : EV 59 pg/mL/mg @ 51 h, EEn 31,97 @ 156 h — le changement est la FORME (queues exactes, pas d'arrondi 2 décimales). +### 7.9 Modèle WHSAH (v1.4.6) — `pk/WhsahModels.kt` + +3ᵉ modèle PK, superposable, **demande exprès de l'utilisatrice** (« au même +niveau que les deux autres, pas de remplacement »). + +- **Source** : le fit « license-free » publié par le WHSAH Collective dans + l'app open-source **Mona** (projet Flutter voisin `~/projects/mona`, + lib/data/model/graph_calculator.dart). Archéologie git de Mona : elle + embarquait les paramètres TFS (identiques aux nôtres) depuis mars 2026, + puis les a remplacés par ce fit indépendant — **pour des raisons de + licence** (les données du simulateur TFS portent un copyright « don't + reproduce » ; les valeurs numériques restent des résultats scientifiques + publiés, mais Mona a préféré re-fitter). Crédit : alix / WHSAH Collective. +- **Formule** (même tri-exponentielle, conventions Mona) : + `Cp = dose·F·auc·k1·k2·k3·(e^(−k1t)/((k1−k2)(k1−k3)) − e^(−k2t)/((k1−k2)(k2−k3)) + e^(−k3t)/((k1−k3)(k2−k3)))` + — normalisée PAR MG comme les autres modèles ; **F = biodisponibilité + explicite < 1** (0,62–0,76) vs ≈100 % chez TFS → courbes brutes ~24 % + plus basses (la calibration absorbe). +- **Params par ester** (F/auc/k1/k2/k3) : EEn 0.708/875.4/0.09441, 3.354, 0.4078 · + EV 0.764/621.3/0.2230, 17.62, 1.305 · EB 0.723/889.9/0.5220, 521.9, 5.223 · + EC 0.687/554.5/0.0880, 17.95, 0.7177 · ECS 0.687/852.6/0.0973, 218.67, 6.624 · + EU 0.618/385.8/0.02189, 183.4, 1.564. **PEP non couvert** (retiré du fit). +- **Différences vs TFS** (5 mg, épinglé par `WhsahModelsTest` fidèle à + Mona ±2 %) : EV pic 367 @ 1,7 j (TFS 295 @ 2,1 j, +25 %) ; EEn 188 @ 5 j + (TFS 160 @ 6,5 j) ; **EEn à J+1 : 70 vs 22 pg/mL (×3)** ; t½ EEn 7,3 j vs + 4,5 j. La MONTÉE rapide + le plateau haut/long de WHSAH expliquent que + les deux apps « ne montrent pas la même chose » le lendemain d'une + injection (cf #55). +- **Dispatch** (`concentrationOfDose`) : `WHSAH && WhsahModels.hasModel` → + `WhsahModels.sample` (avant le fallback tables ODS) ; `cutoffHours` = + 10 × t½ terminale (EEn ≈ 73 j). Ester sans modèle → 0. +- UI : 3ᵉ toggle du graphique (E2 vert plein, T violet pointillé — + `ChartWhs`/`ChartTWhs`), 6 presets, dropdown modèle à 3 choix, + `Esters.choicesForModel` (TFS=7 / WHS=6 / ESE=3 — testé). + ## 8. Tests unitaires -**145 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **123 sans +**155 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **133 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 = @@ -535,6 +571,13 @@ dépôt (ni maintenant, ni si le repo devient public un jour). d'équilibre EV 5 mg/7 j = Figure 11 (Cmax 384/Cmin 142/Cavg 269), esters sans modèle → 0, dt ≤ 0 → 0. **Toute retouche de TransfemScienceModels.kt passe par CE test** (c'est la fidélité au simulateur TFS qui est vérifiée). +- **`WhsahModelsTest`** (6) : v1.4.6 — fidélité au comportement **Mona** + (fit WHSAH) : pics EV 367,5 @ 1,69 j / EEn 187,9 @ 4,99 j (±2 %), t½ par + ester, linéarité par mg, PEP non couvert, `choicesForModel` ×3 modèles. +- **`WhsahEngineTest`** (4) : v1.4.6 — dispatch WHSAH (override depuis un + 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). - **`ChartZoomTest`** (13) : v1.2.9/v1.4.1/v1.4.5 — pas d'échantillonnage, horizon + clamp de panoramique, `pointHoursBefore`, extension de prévision, **`xLabelTicks`** (minuit LOCAL, fuseau CHOISI America/New_York, heures @@ -1082,6 +1125,14 @@ l'exemple) :** **Session v1.4.0 (modèle TFS sur la méta-analyse + fix #52) :** +57. **Écran Graphiques : rangée des toggles coupée sous le pli** (v1.4.6, + découvert en validant WHSAH sur émulateur : le chip était absent du + dump UI) : le Column du ChartScreen ne défilait pas — les toggles de + modèles (et leur LÉGENDE) étaient invisibles sur un téléphone standard. + FIX : écran à défilement vertical. Leçon : un ajout de rangée dans un + écran non-scrollable coupe ce qui suit — tester tout nouvel élément UI + par dump émulateur, pas seulement le code. + 55. **« La prévision simule le 13 au lieu du 12 »** (v1.4.5, remontée) : diagnostic sur les données réelles (régression #4) : la GÉNÉRATION est exacte (créneau = dernière dose LOGUÉE + intervalle en ms exact) — @@ -1289,8 +1340,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` — 145 au - total, 123 si `local-test-data/` est absent (les 3 classes de régression +2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 155 au + total, 133 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 : @@ -1584,12 +1635,12 @@ Sur le téléphone de test (à compléter par l'utilisatrice) : la courbe 24 h du graphique --- -*Doc mise à jour le 7 sept. 2026 (v1.4.5) — build OK, lint vert, 145/145 tests verts (123 sans +*Doc mise à jour le 7 sept. 2026 (v1.4.6) — build OK, lint vert, 155/155 tests verts (133 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 reproduits sur émulateur (#47 stringResource arity, #48 LocalDate+pattern horaire), lint filet bloquant ; v1.3.5 = diagnostic à distance via les logs exportés (#50/#50bis/#51 agenda) ; v1.4.0 = modèle TFS V3C sur la méta-analyse officielle (7 esters, fidélité -~1 % épinglée) + rappels sur la grille Posologie (#52) ; v1.4.1 = prévision étendue sans saut (scroll futur, horizon 1 an) + delta en jours sur l'accueil ; v1.4.2 = seuils d'alerte configurables (carte accueil + notification WorkManager 15 min + anti-spam) et backup JSON v2 avec paramètres ; v1.4.3 = fix du delta 6 h (#53) + régression n°4 (plateau EEn épinglé) ; v1.4.4 = prévision réparée (captures figées #54 + extension de fenêtre) ; v1.4.5 = marqueurs de doses + minuit local + fuseau configurable (#55) + boucle de recomposition tuée (#56). Dette connue : fragments de +~1 % épinglée) + rappels sur la grille Posologie (#52) ; v1.4.1 = prévision étendue sans saut (scroll futur, horizon 1 an) + delta en jours sur l'accueil ; v1.4.2 = seuils d'alerte configurables (carte accueil + notification WorkManager 15 min + anti-spam) et backup JSON v2 avec paramètres ; v1.4.3 = fix du delta 6 h (#53) + régression n°4 (plateau EEn épinglé) ; v1.4.4 = prévision réparée (captures figées #54 + extension de fenêtre) ; v1.4.5 = marqueurs de doses + minuit local + fuseau configurable (#55) + boucle de recomposition tuée (#56) ; v1.4.6 = 3ᵉ modèle PK WHSAH (fit Mona, superposable) + scroll du graphique (#57). Dette connue : fragments de labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.* diff --git a/docs/GUIDE_INSTALLATION.md b/docs/GUIDE_INSTALLATION.md index 0f4d784..2675d06 100644 --- a/docs/GUIDE_INSTALLATION.md +++ b/docs/GUIDE_INSTALLATION.md @@ -150,6 +150,10 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei [simulateur](https://transfemscience.org/misc/injectable-e2-simulator/)) — courbe calculée en forme close, 7 esters disponibles (EV, EU, EEn, EB, EC, EC suspension, PEP) +- **WHSAH** (v1.4.6) : un 3ᵉ fit indépendant (« license-free », publié par le + WHSAH Collective dans l'app [Mona](https://github.com/mona-hrt/mona)) — + montée plus rapide à J+1 et décroissance plus longue sur certains esters ; + superposable avec les deux autres dans le graphique (vert). 6 esters (sans PEP) Pics de référence (dose unique de 5 mg, IM) :