v1.4.6 : 3ᵉ modèle PK WHSAH (fit license-free du WHSAH Collective publié dans Mona — crédité) superposable au même niveau qu'Estrannaise/TFS : dispatch parallèle, 6 presets, dropdown 3 choix, chip + courbe verte/violette, choicesForModel (TFS=7/WHS=6/ESE=3) ; #57 : l'écran Graphiques défile (rangée toggles coupée sous le pli) ; 10 tests (155 verts) + lint ; validé émulateur (3 légendes + 3 courbes de couleurs distinctes)
This commit is contained in:
parent
15a7e6614c
commit
a8ed745c84
12
README.md
12
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 <repo> && 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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -19,11 +19,28 @@ object Esters {
|
||||
|
||||
/** Esters injectables proposables par ester (override par dose). */
|
||||
val INJECTABLE: List<String> = 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<String> = 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 {
|
||||
|
||||
@ -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,13 +117,16 @@ 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).
|
||||
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 {
|
||||
else ->
|
||||
PKProfileStore.profileLength(treatment.esterType, model).toDouble()
|
||||
}
|
||||
} else 0.0
|
||||
|
||||
102
app/src/main/java/com/hormonetrack/pk/WhsahModels.kt
Normal file
102
app/src/main/java/com/hormonetrack/pk/WhsahModels.kt
Normal file
@ -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<String, WHS> = 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)
|
||||
}
|
||||
}
|
||||
@ -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)) },
|
||||
|
||||
@ -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<Pair<String, List<LevelPoint>>>(),
|
||||
effectiveAuto, doseLogs, startMs, endMs, showEse, showTfs, showForecast
|
||||
effectiveAuto, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast
|
||||
) {
|
||||
withContext(Dispatchers.Default) {
|
||||
val list = mutableListOf<Pair<String, List<LevelPoint>>>()
|
||||
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,7 +476,8 @@ fun ChartScreen() {
|
||||
|
||||
// Légende dynamique
|
||||
curves.forEach { (model, _) ->
|
||||
if (model == "ESE") {
|
||||
when (model) {
|
||||
"ESE" -> {
|
||||
Text(
|
||||
stringResource(R.string.legend_ese_e2),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
@ -461,7 +490,23 @@ fun ChartScreen() {
|
||||
color = ChartT
|
||||
)
|
||||
}
|
||||
} else {
|
||||
}
|
||||
// v1.4.6 : légende WHSAH (vert / violet pointillé)
|
||||
"WHS" -> {
|
||||
Text(
|
||||
stringResource(R.string.legend_whs_e2),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
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,
|
||||
@ -476,6 +521,7 @@ fun ChartScreen() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showLabs) {
|
||||
Text(
|
||||
stringResource(R.string.legend_labs),
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -79,6 +79,8 @@
|
||||
<string name="legend_ese_t">-- T · Estrannaise (ng/mL, axe droit)</string>
|
||||
<string name="legend_tfs_e2">— E2 · Transfem Science</string>
|
||||
<string name="legend_tfs_t">-- T · Transfem Science</string>
|
||||
<string name="legend_whs_e2">— E2 WHSAH (pg/mL, gauche)</string>
|
||||
<string name="legend_whs_t">-- T WHSAH (ng/mL, droite)</string>
|
||||
<string name="legend_labs">● Résultats de prise de sang</string>
|
||||
<string name="interval_value">Δ %1$s j</string>
|
||||
|
||||
@ -136,6 +138,7 @@
|
||||
<string name="pk_model">Modèle PK</string>
|
||||
<string name="model_ese">Estrannaise</string>
|
||||
<string name="model_tfs">Transfem Science</string>
|
||||
<string name="model_whs">WHSAH</string>
|
||||
<string name="select_preset">Choisir un modèle (optionnel)</string>
|
||||
|
||||
<string name="pk_absorption">Temps jusqu\'au pic (h)</string>
|
||||
@ -208,6 +211,12 @@
|
||||
<string name="preset_eb_tfs">Injection EB — Transfem Science</string>
|
||||
<string name="preset_ec_tfs">Injection EC (huile) — Transfem Science</string>
|
||||
<string name="preset_ecs_tfs">Injection EC (suspension aqueuse) — Transfem Science</string>
|
||||
<string name="preset_ev_whs">Injection EV — WHSAH</string>
|
||||
<string name="preset_eu_whs">Injection EU — WHSAH</string>
|
||||
<string name="preset_een_whs">Injection EEn — WHSAH</string>
|
||||
<string name="preset_eb_whs">Injection EB — WHSAH</string>
|
||||
<string name="preset_ec_whs">Injection EC (huile) — WHSAH</string>
|
||||
<string name="preset_ecs_whs">Injection EC (suspension aqueuse) — WHSAH</string>
|
||||
<string name="preset_pep_tfs">Injection PEP — Transfem Science</string>
|
||||
<string name="preset_e2_gel">E2 gel transdermique</string>
|
||||
<string name="preset_e2_patch">E2 patch</string>
|
||||
|
||||
@ -79,6 +79,8 @@
|
||||
<string name="legend_ese_t">-- T · Estrannaise (ng/mL, right axis)</string>
|
||||
<string name="legend_tfs_e2">— E2 · Transfem Science</string>
|
||||
<string name="legend_tfs_t">-- T · Transfem Science</string>
|
||||
<string name="legend_whs_e2">— E2 WHSAH (pg/mL, left)</string>
|
||||
<string name="legend_whs_t">-- T WHSAH (ng/mL, right)</string>
|
||||
<string name="legend_labs">● Lab results</string>
|
||||
<string name="interval_value">Δ %1$s d</string>
|
||||
|
||||
@ -136,6 +138,7 @@
|
||||
<string name="pk_model">PK model</string>
|
||||
<string name="model_ese">Estrannaise</string>
|
||||
<string name="model_tfs">Transfem Science</string>
|
||||
<string name="model_whs">WHSAH</string>
|
||||
<string name="select_preset">Choose a preset (optional)</string>
|
||||
|
||||
<string name="pk_absorption">Time to peak (h)</string>
|
||||
@ -208,6 +211,12 @@
|
||||
<string name="preset_eb_tfs">EB injection — Transfem Science</string>
|
||||
<string name="preset_ec_tfs">EC injection (oil) — Transfem Science</string>
|
||||
<string name="preset_ecs_tfs">EC injection (aqueous suspension) — Transfem Science</string>
|
||||
<string name="preset_ev_whs">EV injection — WHSAH</string>
|
||||
<string name="preset_eu_whs">EU injection — WHSAH</string>
|
||||
<string name="preset_een_whs">EEn injection — WHSAH</string>
|
||||
<string name="preset_eb_whs">EB injection — WHSAH</string>
|
||||
<string name="preset_ec_whs">EC injection (oil) — WHSAH</string>
|
||||
<string name="preset_ecs_whs">EC injection (aqueous suspension) — WHSAH</string>
|
||||
<string name="preset_pep_tfs">PEP injection — Transfem Science</string>
|
||||
<string name="preset_e2_gel">E2 transdermal gel</string>
|
||||
<string name="preset_e2_patch">E2 patch</string>
|
||||
|
||||
123
app/src/test/java/com/hormonetrack/pk/WhsahEngineTest.kt
Normal file
123
app/src/test/java/com/hormonetrack/pk/WhsahEngineTest.kt
Normal file
@ -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)
|
||||
}
|
||||
111
app/src/test/java/com/hormonetrack/pk/WhsahModelsTest.kt
Normal file
111
app/src/test/java/com/hormonetrack/pk/WhsahModelsTest.kt
Normal file
@ -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<Double, Double> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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.*
|
||||
|
||||
@ -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) :
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user