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
ca917ba772
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
|
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
|
||||||
> sang et aux consignes de ton endocrinologue.
|
> sang et aux consignes de ton endocrinologue.
|
||||||
|
|
||||||
- **Statut** : v1.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)
|
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
|
||||||
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
|
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
|
||||||
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
|
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
|
||||||
@ -35,8 +35,8 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
|||||||
**méta-analyse officielle à 3 compartiments** (forme close exacte, params du
|
**méta-analyse officielle à 3 compartiments** (forme close exacte, params du
|
||||||
[simulateur TFS](https://transfemscience.org/misc/injectable-e2-simulator/),
|
[simulateur TFS](https://transfemscience.org/misc/injectable-e2-simulator/),
|
||||||
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
|
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
|
||||||
avec les **7 esters** : EV, EU, EEn, EB, EC (huile), EC (suspension), PEP,
|
avec les **7 esters** (EV, EU, EEn, EB, EC huile, EC suspension, PEP) — et WHSAH
|
||||||
affichés côte à côte avec toggles indépendants
|
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,
|
- **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel,
|
||||||
patch et voie orale
|
patch et voie orale
|
||||||
- **Simulation prévisionnelle** : configurer la **posologie** (intervalle en jours) sur un
|
- **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
|
git clone <repo> && cd HormoneTrack
|
||||||
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
|
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
|
||||||
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
|
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
|
||||||
./gradlew testDebugUnitTest # 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
|
./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) —
|
Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
|
||||||
`Siphonight/HormoneTrack` sur les deux (privé), avec
|
`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)) :
|
(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) :
|
`-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)
|
│ ├── assets/pk_profiles.json ← tables horaires (Estrannaise/TFS)
|
||||||
│ ├── java/com/hormonetrack/
|
│ ├── java/com/hormonetrack/
|
||||||
│ │ ├── data/ (Room : models, DAOs, repository, backup)
|
│ │ ├── 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)
|
│ │ ├── reminder/ (alarmes exactes, notifs + actions, boot, worker alertes)
|
||||||
│ │ ├── settings/ (DataStore : TConfig, langue)
|
│ │ ├── settings/ (DataStore : TConfig, langue)
|
||||||
│ │ ├── ui/ (Compose : screens, components, theme)
|
│ │ ├── ui/ (Compose : screens, components, theme)
|
||||||
|
|||||||
@ -17,8 +17,8 @@ android {
|
|||||||
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
|
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
|
||||||
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
|
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
|
||||||
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
|
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
|
||||||
versionCode = 25
|
versionCode = 26
|
||||||
versionName = "1.4.5"
|
versionName = "1.4.6"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
|
|||||||
@ -113,6 +113,64 @@ object PKPresets {
|
|||||||
absorptionHours = 432f, eliminationHalfLifeHours = 682f, bioavailabilityFraction = 1f,
|
absorptionHours = 432f, eliminationHalfLifeHours = 682f, bioavailabilityFraction = 1f,
|
||||||
defaultDoseUnit = "mg", defaultDoseAmount = 32.5
|
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) ---
|
// --- Transdermal / oral (Bateman fallback) ---
|
||||||
PKPreset(
|
PKPreset(
|
||||||
nameRes = R.string.preset_e2_gel,
|
nameRes = R.string.preset_e2_gel,
|
||||||
|
|||||||
@ -19,11 +19,28 @@ object Esters {
|
|||||||
|
|
||||||
/** Esters injectables proposables par ester (override par dose). */
|
/** Esters injectables proposables par ester (override par dose). */
|
||||||
val INJECTABLE: List<String> = listOf(EV, EU, EEN, EB, EC, ECS, PEP)
|
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 {
|
object PKModels {
|
||||||
const val ESTRANNAISE = "ESE"
|
const val ESTRANNAISE = "ESE"
|
||||||
const val TRANSFEM_SCIENCE = "TFS"
|
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 {
|
enum class TreatmentType {
|
||||||
|
|||||||
@ -90,10 +90,15 @@ object PharmacokineticEngine {
|
|||||||
// V3C (3 compartiments) de la méta-analyse TFS, évalués en
|
// V3C (3 compartiments) de la méta-analyse TFS, évalués en
|
||||||
// forme close — les tables ODS restent le fallback (esters
|
// forme close — les tables ODS restent le fallback (esters
|
||||||
// sans modèle V3C, ou modèle Estrannaise).
|
// sans modèle V3C, ou modèle Estrannaise).
|
||||||
if (model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(ester)) {
|
// v1.4.6 : le modèle WHSAH (fit license-free de Mona) est la
|
||||||
return TransfemScienceModels.sample(ester, dtH) * mg
|
// 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 {
|
private fun cutoffHours(treatment: Treatment): Double {
|
||||||
val profileH = if (treatment.usesProfileModel) {
|
val profileH = if (treatment.usesProfileModel) {
|
||||||
val model = treatment.pkModel
|
val model = treatment.pkModel
|
||||||
if (model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType)) {
|
when {
|
||||||
// v1.4.0 : modèle V3C (forme close, pas de table bornée) →
|
// v1.4.6 : modèle WHSAH → coupure à 10 demi-vies terminales
|
||||||
// coupure à 10 demi-vies TERMINALES (~0,1 % de résidu), la
|
// du fit (ex. EEn t½ 7,3 j → 73 j), comme pour le V3C TFS.
|
||||||
// t½ venant des paramètres de l'article (ex. EV 3 j → 30 j).
|
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)!!
|
TransfemScienceModels.model(treatment.esterType)!!
|
||||||
.terminalHalfLifeDays * 24.0 * 10.0
|
.terminalHalfLifeDays * 24.0 * 10.0
|
||||||
} else {
|
else ->
|
||||||
PKProfileStore.profileLength(treatment.esterType, model).toDouble()
|
PKProfileStore.profileLength(treatment.esterType, model).toDouble()
|
||||||
}
|
}
|
||||||
} else 0.0
|
} 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)) },
|
text = { Text(stringResource(R.string.ester_default, selected.esterType)) },
|
||||||
onClick = { esterOverride = null; esterExpanded = false }
|
onClick = { esterOverride = null; esterExpanded = false }
|
||||||
)
|
)
|
||||||
// v1.4.0 : la liste d'esters suit le MODÈLE du
|
// v1.4.0/v1.4.6 : la liste d'esters suit le MODÈLE
|
||||||
// traitement — TFS = tous les esters de la
|
// du traitement (helper pur Esters.choicesForModel,
|
||||||
// méta-analyse (V3C, cf TransfemScienceModels),
|
// testé) — TFS = 7 esters (V3C), WHSAH = 6 (PEP non
|
||||||
// Estrannaise (ODS) = EV/EU/EEn seulement (les
|
// couvert), Estrannaise (ODS) = EV/EU/EEn seulement.
|
||||||
// tables ne couvrent pas les autres esters).
|
val choices = Esters.choicesForModel(selected.pkModel)
|
||||||
val choices = if (selected.pkModel == PKModels.TRANSFEM_SCIENCE) {
|
|
||||||
Esters.INJECTABLE
|
|
||||||
} else {
|
|
||||||
listOf(Esters.EV, Esters.EU, Esters.EEN)
|
|
||||||
}
|
|
||||||
choices.forEach { e ->
|
choices.forEach { e ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(esterLabel(e)) },
|
text = { Text(esterLabel(e)) },
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
package com.hormonetrack.ui.screens
|
package com.hormonetrack.ui.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.components.CurveStyle
|
||||||
import com.hormonetrack.ui.theme.ChartE2
|
import com.hormonetrack.ui.theme.ChartE2
|
||||||
import com.hormonetrack.ui.theme.ChartT
|
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.LabDot
|
||||||
import com.hormonetrack.ui.theme.TealTertiary
|
import com.hormonetrack.ui.theme.TealTertiary
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -105,6 +109,8 @@ fun ChartScreen() {
|
|||||||
var showLabs by remember { mutableStateOf(true) }
|
var showLabs by remember { mutableStateOf(true) }
|
||||||
var showEse by remember { mutableStateOf(true) }
|
var showEse by remember { mutableStateOf(true) }
|
||||||
var showTfs 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 showForecast by remember { mutableStateOf(false) }
|
||||||
var showExtrema by remember { mutableStateOf(false) }
|
var showExtrema by remember { mutableStateOf(false) }
|
||||||
var chartWidthPx by remember { mutableIntStateOf(0) }
|
var chartWidthPx by remember { mutableIntStateOf(0) }
|
||||||
@ -203,11 +209,11 @@ fun ChartScreen() {
|
|||||||
// Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS")
|
// Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS")
|
||||||
val curves by produceState(
|
val curves by produceState(
|
||||||
emptyList<Pair<String, List<LevelPoint>>>(),
|
emptyList<Pair<String, List<LevelPoint>>>(),
|
||||||
effectiveAuto, doseLogs, startMs, endMs, showEse, showTfs, showForecast
|
effectiveAuto, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast
|
||||||
) {
|
) {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
val list = mutableListOf<Pair<String, List<LevelPoint>>>()
|
val list = mutableListOf<Pair<String, List<LevelPoint>>>()
|
||||||
if (showEse || showTfs) {
|
if (showEse || showTfs || showWhs) {
|
||||||
// Valeurs actuelles : échelles par ester si auto-calibration active,
|
// Valeurs actuelles : échelles par ester si auto-calibration active,
|
||||||
// sinon le scaleFactor stocké de chaque traitement
|
// sinon le scaleFactor stocké de chaque traitement
|
||||||
val effTreatments = effectiveAuto?.treatments ?: treatments
|
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
|
value = list
|
||||||
}
|
}
|
||||||
@ -262,16 +280,21 @@ fun ChartScreen() {
|
|||||||
// Série visuelle : E2 Estrannaise = bleu plein, E2 TFS = turquoise plein ;
|
// Série visuelle : E2 Estrannaise = bleu plein, E2 TFS = turquoise plein ;
|
||||||
// T Estrannaise = rose plein, T TFS = rose pointillé.
|
// T Estrannaise = rose plein, T TFS = rose pointillé.
|
||||||
val series = curves.map { (model, points) ->
|
val series = curves.map { (model, points) ->
|
||||||
if (model == "ESE") {
|
when (model) {
|
||||||
ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT))
|
"ESE" -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT))
|
||||||
} else {
|
// v1.4.6 : WHSAH = E2 vert plein, T violet pointillé
|
||||||
ChartSeries(points, CurveStyle(TealTertiary), CurveStyle(ChartT, dashed = true))
|
"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(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@ -351,7 +374,7 @@ fun ChartScreen() {
|
|||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
// Modèles PK superposables
|
// Modèles PK superposables (3ᵉ = WHSAH, v1.4.6)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = showEse,
|
selected = showEse,
|
||||||
@ -363,6 +386,11 @@ fun ChartScreen() {
|
|||||||
onClick = { showTfs = !showTfs },
|
onClick = { showTfs = !showTfs },
|
||||||
label = { Text(stringResource(R.string.model_tfs)) }
|
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))
|
Spacer(Modifier.height(12.dp))
|
||||||
@ -448,7 +476,8 @@ fun ChartScreen() {
|
|||||||
|
|
||||||
// Légende dynamique
|
// Légende dynamique
|
||||||
curves.forEach { (model, _) ->
|
curves.forEach { (model, _) ->
|
||||||
if (model == "ESE") {
|
when (model) {
|
||||||
|
"ESE" -> {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.legend_ese_e2),
|
stringResource(R.string.legend_ese_e2),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
@ -461,7 +490,23 @@ fun ChartScreen() {
|
|||||||
color = ChartT
|
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(
|
Text(
|
||||||
stringResource(R.string.legend_tfs_e2),
|
stringResource(R.string.legend_tfs_e2),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
@ -476,6 +521,7 @@ fun ChartScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (showLabs) {
|
if (showLabs) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.legend_labs),
|
stringResource(R.string.legend_labs),
|
||||||
|
|||||||
@ -277,12 +277,17 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
|||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
DropdownField(
|
DropdownField(
|
||||||
label = stringResource(R.string.pk_model),
|
label = stringResource(R.string.pk_model),
|
||||||
selectedLabel = stringResource(
|
// v1.4.6 : 3ᵉ modèle PK (WHSAH — fit license-free de Mona,
|
||||||
if (model == PKModels.TRANSFEM_SCIENCE) R.string.model_tfs else R.string.model_ese
|
// 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(
|
options = listOf(
|
||||||
PKModels.ESTRANNAISE to stringResource(R.string.model_ese),
|
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 }
|
onSelect = { model = it }
|
||||||
)
|
)
|
||||||
|
|||||||
@ -12,6 +12,9 @@ val TransPink = Color(0xFFF5A9B8)
|
|||||||
val TealTertiary = Color(0xFF2FA48D)
|
val TealTertiary = Color(0xFF2FA48D)
|
||||||
val TealContainer = Color(0xFFBFF0E4)
|
val TealContainer = Color(0xFFBFF0E4)
|
||||||
val ChartE2 = Color(0xFF4F5BD5)
|
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 ChartT = Color(0xFFD6589E)
|
||||||
val LabDot = Color(0xFFE67E22)
|
val LabDot = Color(0xFFE67E22)
|
||||||
val GoodGreen = Color(0xFF2E7D32)
|
val GoodGreen = Color(0xFF2E7D32)
|
||||||
|
|||||||
@ -79,6 +79,8 @@
|
|||||||
<string name="legend_ese_t">-- T · Estrannaise (ng/mL, axe droit)</string>
|
<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_e2">— E2 · Transfem Science</string>
|
||||||
<string name="legend_tfs_t">-- T · 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="legend_labs">● Résultats de prise de sang</string>
|
||||||
<string name="interval_value">Δ %1$s j</string>
|
<string name="interval_value">Δ %1$s j</string>
|
||||||
|
|
||||||
@ -136,6 +138,7 @@
|
|||||||
<string name="pk_model">Modèle PK</string>
|
<string name="pk_model">Modèle PK</string>
|
||||||
<string name="model_ese">Estrannaise</string>
|
<string name="model_ese">Estrannaise</string>
|
||||||
<string name="model_tfs">Transfem Science</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="select_preset">Choisir un modèle (optionnel)</string>
|
||||||
|
|
||||||
<string name="pk_absorption">Temps jusqu\'au pic (h)</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_eb_tfs">Injection EB — Transfem Science</string>
|
||||||
<string name="preset_ec_tfs">Injection EC (huile) — 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_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_pep_tfs">Injection PEP — Transfem Science</string>
|
||||||
<string name="preset_e2_gel">E2 gel transdermique</string>
|
<string name="preset_e2_gel">E2 gel transdermique</string>
|
||||||
<string name="preset_e2_patch">E2 patch</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_ese_t">-- T · Estrannaise (ng/mL, right axis)</string>
|
||||||
<string name="legend_tfs_e2">— E2 · Transfem Science</string>
|
<string name="legend_tfs_e2">— E2 · Transfem Science</string>
|
||||||
<string name="legend_tfs_t">-- T · 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="legend_labs">● Lab results</string>
|
||||||
<string name="interval_value">Δ %1$s d</string>
|
<string name="interval_value">Δ %1$s d</string>
|
||||||
|
|
||||||
@ -136,6 +138,7 @@
|
|||||||
<string name="pk_model">PK model</string>
|
<string name="pk_model">PK model</string>
|
||||||
<string name="model_ese">Estrannaise</string>
|
<string name="model_ese">Estrannaise</string>
|
||||||
<string name="model_tfs">Transfem Science</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="select_preset">Choose a preset (optional)</string>
|
||||||
|
|
||||||
<string name="pk_absorption">Time to peak (h)</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_eb_tfs">EB injection — Transfem Science</string>
|
||||||
<string name="preset_ec_tfs">EC injection (oil) — 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_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_pep_tfs">PEP injection — Transfem Science</string>
|
||||||
<string name="preset_e2_gel">E2 transdermal gel</string>
|
<string name="preset_e2_gel">E2 transdermal gel</string>
|
||||||
<string name="preset_e2_patch">E2 patch</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
|
# Changelog — HormoneTrack
|
||||||
|
|
||||||
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
|
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
|
||||||
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.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)
|
## [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.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.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.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. |
|
| 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.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. |
|
| 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
|
(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).
|
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
|
## 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,
|
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.
|
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 =
|
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
|
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
|
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).
|
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,
|
- **`ChartZoomTest`** (13) : v1.2.9/v1.4.1/v1.4.5 — pas d'échantillonnage,
|
||||||
horizon + clamp de panoramique, `pointHoursBefore`, extension de prévision,
|
horizon + clamp de panoramique, `pointHoursBefore`, extension de prévision,
|
||||||
**`xLabelTicks`** (minuit LOCAL, fuseau CHOISI America/New_York, heures
|
**`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) :**
|
**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) :
|
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
|
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) —
|
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`,
|
1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`,
|
||||||
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
|
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
|
||||||
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 145 au
|
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 155 au
|
||||||
total, 123 si `local-test-data/` est absent (les 3 classes de régression
|
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
|
réelles sont skippées via `Assume`, 6 tests chacune) ; **lint vert
|
||||||
obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait
|
obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait
|
||||||
attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet :
|
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
|
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
|
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
|
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
|
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
|
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
|
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é
|
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.*
|
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/)) —
|
[simulateur](https://transfemscience.org/misc/injectable-e2-simulator/)) —
|
||||||
courbe calculée en forme close, 7 esters disponibles (EV, EU, EEn, EB, EC,
|
courbe calculée en forme close, 7 esters disponibles (EV, EU, EEn, EB, EC,
|
||||||
EC suspension, PEP)
|
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) :
|
Pics de référence (dose unique de 5 mg, IM) :
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user