v1.4.8 : calibration PAR MODÈLE PK (#60) — le graphique calcule une AutoCalibrated par modèle affiché (modelOverride propagé à computeEsterScaleFactors et computeTKPerEster) → chaque courbe calibrée passe par les labs (plus de facteur partagé absurde ×2,21 sur WHSAH) ; Home inchangé (modèle stocké) ; CalibrationPerModelTest (5 tests : couverture 3 modèles, délégation unique, k T par modèle, cas dégénérés) ; 164 tests verts + lint

This commit is contained in:
Siphonight 2026-09-07 18:28:15 +02:00
parent 1c9d2e4e6a
commit b45b6affb2
9 changed files with 416 additions and 65 deletions

View File

@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue.
- **Statut** : v1.4.7 — build Android ✅, **lint vert** ✅, **158 tests unitaires** ✅ (136 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
- **Statut** : v1.4.8 — build Android ✅, **lint vert** ✅, **159 tests unitaires** ✅ (137 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
@ -37,7 +37,8 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
avec les **7 esters** (EV, EU, EEn, EB, EC huile, EC suspension, PEP) — et WHSAH
en couvre 6 (sans PEP). Affichés côte à côte avec toggles indépendants (3 couleurs) — **pré-cochés
selon les modèles de tes traitements** (v1.4.7) et **tous calibrés** par tes labs
selon les modèles de tes traitements** (v1.4.7) et **chaque modèle calibré
séparément** par tes labs (v1.4.8 — les facteurs s'adaptent à chaque profil)
- **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel,
patch et voie orale
- **Simulation prévisionnelle** : configurer la **posologie** (intervalle en jours) sur un
@ -92,7 +93,7 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1.
git clone <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 # 158 tests (136 sans les données locales)
./gradlew testDebugUnitTest # 159 tests (137 sans les données locales)
./gradlew lint # lint vert obligatoire avant release
```
@ -104,7 +105,7 @@ Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INS
Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
`Siphonight/HormoneTrack` sur les deux (privé), avec
**releases taguées** (`v1.1.0` → `v1.4.7`) et **deux APK par release** (depuis v1.2.5)
**releases taguées** (`v1.1.0` → `v1.4.8`) et **deux APK par release** (depuis v1.2.5)
(téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) :
`-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) :
@ -120,25 +121,45 @@ taggué annoté. Voir [docs/DEVELOPPEMENT.md §16](docs/DEVELOPPEMENT.md).
## Les modèles en bref
Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la réponse normalisée
(pg/mL par mg) ; les contributions se superposent. **Estrannaise** = tables horaires
du `.ods` (8001 h) ; **Transfem Science** = méta-analyse à 3 compartiments (V3C,
forme close, [article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)).
(pg/mL par mg) ; les contributions se superposent. **Trois modèles superposables** :
- **Estrannaise** = tables horaires du `.ods` (8001 h), EV/EU/EEn
- **Transfem Science** = méta-analyse à 3 compartiments (V3C, forme close,
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
7 esters (EV, EU, EEn, EB, EC, EC suspension, PEP)
- **WHSAH** (v1.4.6) = fit « license-free » du [WHSAH Collective via
Mona](https://github.com/mona-hrt/mona) — même famille mathématique mais
paramètres indépendants avec biodisponibilité explicite F < 1 : montée
plus rapide à J+1 (EEn ~70 pg/mL à J+1 pour 5 mg vs ~22 chez TFS) et
décroissance plus longue (t½ EEn 7,3 j vs 4,5 j). 6 esters (sans PEP)
Pics de référence (pg/mL par mg) :
| Profil | Modèle | Pic (pg/mL/mg) | Tmax |
|----------|------------------|----------------|--------|
| EV | Estrannaise | 61,1 | ~45 h |
| EU | Estrannaise | 3,4 | ~55 h (plateau long) |
| EEn | Estrannaise | 31,4 | ~152 h |
| EV | Transfem Science | 59,0 | ~51 h |
| EU | Transfem Science | 10,1 | ~198 h |
| EEn | Transfem Science | 32,0 | ~156 h |
| EB | Transfem Science | 194,2 | ~16 h |
| EC (huile) | Transfem Science | 31,1 | ~103 h |
| PEP | Transfem Science | 1,03 (dose ~6,5×) | ~18 j |
| Profil | Modèle | Pic (pg/mL/mg) | Tmax | t½ term. |
|----------|------------------|----------------|--------|----------|
| EV | Estrannaise | 61,1 | ~45 h | — |
| EU | Estrannaise | 3,4 | ~55 h (plateau long) | — |
| EEn | Estrannaise | 31,4 | ~152 h | — |
| EV | Transfem Science | 59,0 | ~51 h | 3,0 j |
| EU | Transfem Science | 10,1 | ~198 h | — |
| EEn | Transfem Science | 32,0 | ~156 h | 4,5 j |
| EB | Transfem Science | 194,2 | ~16 h | 1,2 j |
| EC (huile) | Transfem Science | 31,1 | ~103 h | 6,7 j |
| EC (susp.) | Transfem Science | 48,2 | ~29 h | 5,1 j |
| PEP | Transfem Science | 1,03 (dose ~6,5×) | ~18 j | 28,4 j |
| EV | WHSAH | 73,5 | ~41 h | 3,1 j |
| EEn | WHSAH | 37,6 | ~120 h | 7,3 j |
| EB | WHSAH | 260,1 | ~12 h | 1,3 j |
| EC (huile) | WHSAH | 25,0 | ~81 h | 7,9 j |
| EC (susp.) | WHSAH | 53,5 | ~16 h | 7,1 j |
| EU | WHSAH | 4,9 | ~67 h | 31,7 j |
La calibration (facteur d'échelle par traitement, calibré par tes labs) ajuste le modèle
à ton corps, exactement comme la colonne « Scale factor » de la feuille d'origine.
**Calibration (v1.4.8)** : le facteur d'échelle s'applique par ESTER et par
PÉRIODE d'injection (médiane des ratios lab ÷ prédiction, comme la colonne
« Scale factor » de la feuille d'origine) — et **par MODÈLE** : chaque courbe
affichée (Estrannaise / Transfem Science / WHSAH) est calibrée avec la
prédiction de SON modèle → toutes collent à tes labs, quelle que soit leur
forme. L'auto-calibration est optionnelle (désactivée par défaut).
## Vie privée

View File

@ -17,8 +17,8 @@ android {
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
versionCode = 27
versionName = "1.4.7"
versionCode = 28
versionName = "1.4.8"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@ -397,7 +397,8 @@ object PharmacokineticEngine {
fun computeEsterScaleFactors(
treatments: List<Treatment>,
doseLogs: List<DoseLog>,
e2Labs: List<LabResult>
e2Labs: List<LabResult>,
modelOverride: String? = null
): Map<String, Double> {
val estrogenTreatments = treatments.filter { it.type == TreatmentType.ESTRADIOL }
val estrogenDoses = doseLogs.filter { d ->
@ -405,11 +406,15 @@ object PharmacokineticEngine {
}
if (estrogenDoses.isEmpty()) return emptyMap()
// Prédictions non calibrées (scaleFactor forcé à 1) pour chaque lab
// Prédictions non calibrées (scaleFactor forcé à 1) pour chaque lab,
// avec le MODÈLE demandé (fix #60 : chaque courbe est calibrée avec
// SA propre prédiction — v1.4.7 calculait tout depuis le modèle
// stocké du traitement, ce qui produisait des facteurs absurdes dès
// qu'un traitement basculait de modèle, ex. ×2,21 sur WHSAH)
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
val ratiosByEster = HashMap<String, MutableList<Double>>()
for (lab in e2Labs) {
val predicted = e2At(unscaled, doseLogs, lab.timestamp)
val predicted = e2At(unscaled, doseLogs, lab.timestamp, modelOverride = modelOverride)
if (predicted <= 0.5) continue
// Attribution : dernière dose E2 ≤ lab → son ester (override compris)
@ -451,7 +456,8 @@ object PharmacokineticEngine {
doseLogs: List<DoseLog>,
tLabs: List<LabResult>,
current: TConfig,
scalePerEster: Map<String, Double>? = null
scalePerEster: Map<String, Double>? = null,
modelOverride: String? = null
): Map<String, Double> {
val estrogenDoses = doseLogs.filter { d ->
treatments.any { it.id == d.treatmentId && it.type == TreatmentType.ESTRADIOL }
@ -469,7 +475,9 @@ object PharmacokineticEngine {
val ester = doseEster(tr, attributed)
if (ester == Esters.NONE) continue
val e2 = e2At(treatments, doseLogs, lab.timestamp, scalePerEster = scalePerEster)
// v1.4.8 : l'E2 de référence suit le MODÈLE demandé (fix #60)
val e2 = e2At(treatments, doseLogs, lab.timestamp, scalePerEster = scalePerEster,
modelOverride = modelOverride)
if (e2 <= 1.0) continue
val k = ((current.base - current.floor) / (tNgMl - current.floor) - 1.0) / e2
if (k > 1e-4 && k < 10.0) {
@ -511,14 +519,23 @@ object PharmacokineticEngine {
treatments: List<Treatment>,
doseLogs: List<DoseLog>,
labs: List<LabResult>,
tConfig: TConfig
tConfig: TConfig,
/**
* Modèle PK pour les PRÉDICTIONS de calibration (v1.4.8, fix #60) :
* null = modèle stocké de chaque traitement (Home). Le graphique
* appelle une fois PAR MODÈLE affiché (ESE/TFS/WHS) — chaque courbe
* reçoit ainsi SES échelles et SON k.
*/
modelOverride: String? = null
): AutoCalibrated {
val e2Labs = labs.filter { it.marker.equals("E2", true) }
val tLabs = labs.filter { it.marker.equals("T", true) }
val esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs)
val esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs,
modelOverride = modelOverride)
val tKPerEster = computeTKPerEster(
treatments, doseLogs, tLabs, tConfig, scalePerEster = esterScales
treatments, doseLogs, tLabs, tConfig, scalePerEster = esterScales,
modelOverride = modelOverride
)
return AutoCalibrated(
treatments = treatments,

View File

@ -146,14 +146,34 @@ fun ChartScreen() {
// Auto-calibration (v1.2.1) : facteurs d'échelle PAR PÉRIODE D'ESTER + modèle T,
// recalculés à la volée depuis les labs. null = option désactivée.
val effectiveAuto by produceState<PharmacokineticEngine.AutoCalibrated?>(
null, treatments, doseLogs, labResults, tConfig, autoCalibrate
// ── Calibration PAR MODÈLE (v1.4.8, fix #60) ──────────────────────────────
// Demande : « la calibration doit être active sur tous les modèles si elle
// est activée, mais les facteurs ne doivent pas être identiques entre les
// profils PK ». AVANT : un SEUL jeu de facteurs calculé depuis le modèle
// STOCKÉ des traitements puis partagé par toutes les courbes → en
// basculant un traitement (ex. EEn TFS → WHSAH), le facteur devenait
// absurde (×2,21, remontée). DÉSORMAIS : une AutoCalibrated PAR modèle
// (ESE/TFS/WHS), chacune calibrée avec la prédiction DE SON modèle →
// chaque courbe affichée colle à ses labs. Home continue d'utiliser le
// modèle stocké (calcul sans override).
val autoByModel by produceState<Map<String, PharmacokineticEngine.AutoCalibrated>>(
emptyMap(), treatments, doseLogs, labResults, tConfig, autoCalibrate
) {
value = if (!autoCalibrate) {
null
emptyMap()
} else {
withContext(Dispatchers.Default) {
PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, tConfig)
mapOf(
"ESE" to PharmacokineticEngine.autoCalibrated(
treatments, doseLogs, labResults, tConfig, modelOverride = "ESE"
),
"TFS" to PharmacokineticEngine.autoCalibrated(
treatments, doseLogs, labResults, tConfig, modelOverride = "TFS"
),
"WHS" to PharmacokineticEngine.autoCalibrated(
treatments, doseLogs, labResults, tConfig, modelOverride = "WHS"
)
)
}
}
}
@ -229,16 +249,16 @@ fun ChartScreen() {
// Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS")
val curves by produceState(
emptyList<Pair<String, List<LevelPoint>>>(),
effectiveAuto, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast
autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast
) {
withContext(Dispatchers.Default) {
val list = mutableListOf<Pair<String, List<LevelPoint>>>()
if (showEse || showTfs || showWhs) {
// Valeurs actuelles : échelles par ester si auto-calibration active,
// sinon le scaleFactor stocké de chaque traitement
val effTreatments = effectiveAuto?.treatments ?: treatments
val effTConfig = effectiveAuto?.tConfig ?: tConfig
val effScales = effectiveAuto?.esterScales
// Calibration PAR MODÈLE (fix #60) : chaque courbe reçoit les
// échelles/k calculés avec SA prédiction. Hors auto-calibration
// → scaleFactor stocké des traitements (comportement historique).
val effTreatments = autoByModel["TFS"]?.treatments ?: treatments
val effTConfig = autoByModel["TFS"]?.tConfig ?: tConfig
// Doses réelles + doses prévisionnelles éventuelles
val forecastDoses = if (showForecast) {
effTreatments.flatMap {
@ -249,36 +269,39 @@ fun ChartScreen() {
// Pas d'échantillonnage adaptatif au zoom (v1.2.9)
val step = com.hormonetrack.ui.components.stepForRange(rangeHours)
if (showEse) {
val autoM = autoByModel["ESE"]
list.add(
"ESE" to PharmacokineticEngine.computeCurve(
effTreatments, allDoses, startMs, endMs,
stepMs = step,
tConfig = effTConfig, modelOverride = "ESE",
scalePerEster = effScales,
tKPerEster = effectiveAuto?.tKPerEster
tConfig = autoM?.tConfig ?: effTConfig, modelOverride = "ESE",
scalePerEster = autoM?.esterScales,
tKPerEster = autoM?.tKPerEster
)
)
}
if (showTfs) {
val autoM = autoByModel["TFS"]
list.add(
"TFS" to PharmacokineticEngine.computeCurve(
effTreatments, allDoses, startMs, endMs,
stepMs = step,
tConfig = effTConfig, modelOverride = "TFS",
scalePerEster = effScales,
tKPerEster = effectiveAuto?.tKPerEster
tConfig = autoM?.tConfig ?: effTConfig, modelOverride = "TFS",
scalePerEster = autoM?.esterScales,
tKPerEster = autoM?.tKPerEster
)
)
}
// v1.4.6 : 3ᵉ modèle WHSAH (même mécanique d'override)
if (showWhs) {
val autoM = autoByModel["WHS"]
list.add(
"WHS" to PharmacokineticEngine.computeCurve(
effTreatments, allDoses, startMs, endMs,
stepMs = step,
tConfig = effTConfig, modelOverride = "WHS",
scalePerEster = effScales,
tKPerEster = effectiveAuto?.tKPerEster
tConfig = autoM?.tConfig ?: effTConfig, modelOverride = "WHS",
scalePerEster = autoM?.esterScales,
tKPerEster = autoM?.tKPerEster
)
)
}

View File

@ -0,0 +1,191 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Process de tests **CALIBRATION** (v1.4.8, fix #60) — sémantique
* « calibration PAR MODÈLE PK » : chaque courbe affichée (ESE/TFS/WHS) est
* calibrée avec la prédiction de SON modèle, donc chaque courbe calibrée
* passe par les labs.
*
* Compléments des tests voisins (pour s'y retrouver) :
* - `WhsahEngineTest.calibration is per model` : TFS vs WHS (le cas de la
* remontée ×2,21) ;
* - `WhsahEngineTest.calibration applies to ALL model curves` : le facteur
* fourni est appliqué à toutes les courbes (application uniforme) ;
* - `PharmacokineticEngineTest` : calibration T manuelle, SF médiane.
*
* Ce fichier couvre ce qui restait sans filet :
* 1. la couverture des **3 modèles** (ESE inclus) — chaque courbe calibrée
* atterrit sur le lab ;
* 2. la **délégation** `autoCalibrated(modelOverride)` →
* `computeEsterScaleFactors(modelOverride)` (le graphique et Home
* utilisent la même fonction, pas deux implémentations) ;
* 3. le **k T par modèle** (calibré contre l'E2 calibrée de SON modèle) ;
* 4. les cas **dégénérés** (labs antérieurs à la 1ʳᵉ dose → échelles vides ;
* prédiction trop faible → lab ignoré).
*/
class CalibrationPerModelTest {
private companion object {
val BASE = System.currentTimeMillis()
const val DAY_MS = 86_400_000L
}
@org.junit.Before
fun loadProfiles() {
// ⚠️ le chemin ESE lit les tables ODS (asset) — sans init,
// PKProfileStore.sample retourne 0 et les labs seraient ignorés
// (prédiction ≤ 0,5) → échelles vides. Les modèles TFS/WHS sont
// purs (formes closes) et n'en dépendent pas.
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
val file = java.io.File("src/main/assets/pk_profiles.json")
.takeIf { it.exists() }
?: java.io.File("app/src/main/assets/pk_profiles.json")
PKProfileStore.initWithJson(file.readText())
}
}
/** Traitement EEn (modèle TFS stocké), 5 mg à BASE. */
private fun treatment(): Treatment = Treatment(
id = 1L,
name = "EEn",
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
doseAmount = 5.0,
doseUnit = "mg",
isActive = true,
esterType = Esters.EEN,
pkModel = PKModels.TRANSFEM_SCIENCE
)
private fun doses(): List<DoseLog> = listOf(
DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)
)
/** Lab E2 de 250 pg/mL 7 jours après l'injection (au creux TFS). */
private fun labE2(): LabResult = LabResult(
marker = "E2", value = 250.0, unit = "pg/mL",
timestamp = BASE + 7 * DAY_MS
)
@Test
fun `all three models calibrated land on the lab (ESE included)`() {
val tr = treatment()
val labs = listOf(labE2())
for (model in listOf("ESE", "TFS", "WHS")) {
val scales = PharmacokineticEngine.computeEsterScaleFactors(
listOf(tr), doses(), labs, modelOverride = model
)
assertTrue("échelle $model absente", scales.containsKey("EEN"))
val calibrated = PharmacokineticEngine.e2At(
listOf(tr), doses(), labE2().timestamp,
modelOverride = model, scalePerEster = scales
)
assertTrue(
"$model : calibrée=$calibrated doit ≈ 250 (échelle ${scales["EEN"]})",
calibrated in 250.0 * 0.98..250.0 * 1.02
)
}
}
@Test
fun `three models give three distinct calibration factors`() {
val tr = treatment()
val labs = listOf(labE2())
val ese = PharmacokineticEngine.computeEsterScaleFactors(
listOf(tr), doses(), labs, modelOverride = "ESE")["EEN"]!!
val tfs = PharmacokineticEngine.computeEsterScaleFactors(
listOf(tr), doses(), labs, modelOverride = "TFS")["EEN"]!!
val whs = PharmacokineticEngine.computeEsterScaleFactors(
listOf(tr), doses(), labs, modelOverride = "WHS")["EEN"]!!
// les prédictions des 3 modèles diffèrent au même instant (7 j) :
// ESE ≈ 156, TFS ≈ 159, WHS ≈ 178 → facteurs distincts. Nuances :
// ESE et TFS sont QUASI IDENTIQUES au creux 7 j (~2 % — la table ODS
// dérive du même article) ; WHSAH s'en écarte (~11 % — décroissance
// lente, t½ 7,3 j) mais sans excès
assertTrue("ESE=$ese vs TFS=$tfs (≥ 1 % : distincts)", kotlin.math.abs(ese - tfs) > 0.01 * ese)
assertTrue("TFS=$tfs vs WHS=$whs (≥ 5 %)", kotlin.math.abs(tfs - whs) > 0.05 * tfs)
assertTrue("WHS=$whs vs ESE=$ese (≥ 5 %)", kotlin.math.abs(whs - ese) > 0.05 * whs)
}
@Test
fun `autoCalibrated delegates to computeEsterScaleFactors per model (single implementation)`() {
val tr = treatment()
val labs = listOf(labE2())
for (model in listOf(null, "ESE", "TFS", "WHS")) {
val viaAuto = PharmacokineticEngine.autoCalibrated(
listOf(tr), doses(), labs, TConfig(), modelOverride = model
).esterScales
val direct = PharmacokineticEngine.computeEsterScaleFactors(
listOf(tr), doses(), labs, modelOverride = model
)
assertEquals("délégation brisée pour modelOverride=$model", direct, viaAuto)
}
// null = modèle stocké du traitement (TFS) → cohérent avec override TFS
assertEquals(
PharmacokineticEngine.autoCalibrated(
listOf(tr), doses(), labs, TConfig(), modelOverride = "TFS"
).esterScales,
PharmacokineticEngine.autoCalibrated(
listOf(tr), doses(), labs, TConfig()
).esterScales
)
}
@Test
fun `testosterone k is calibrated against the E2 of the requested model`() {
val tr = treatment()
// lab T de 0,35 ng/mL 7 jours après l'injection (même instant que le lab E2)
val labs = listOf(
labE2(),
LabResult(marker = "T", value = 0.35, unit = "ng/mL",
timestamp = BASE + 7 * DAY_MS)
)
val tConfig = TConfig() // base 6.0, floor 0.2, k 0.19
// Invariant mathématique : k ∝ 1/E2 calibrée — donner une échelle
// ×2 doit DIVISER le k par 2 exactement (le k est calculé contre
// l'E2 calibrée de la demande modelOverride, pas contre une E2 figée).
val tLabs = labs.filter { it.marker.equals("T", true) }
fun kFor(scale: Double): Double = PharmacokineticEngine.computeTKPerEster(
listOf(tr), doses(), tLabs, tConfig,
scalePerEster = mapOf("EEN" to scale),
modelOverride = "TFS"
)["EEN"]!!
val k1 = kFor(1.0)
val k2 = kFor(2.0)
assertTrue("k(échelle 1)=$k1 doit être dans la garde", k1 > 1e-4 && k1 < 10.0)
assertEquals("k ∝ 1/E2 calibrée : échelle ×2 → k ÷2", k1 / 2.0, k2, k1 * 0.01)
// et le k calibré ≠ k par défaut (0,19) : il a bien été recalculé
// contre l'E2 du modèle (fix #60)
assertTrue(
"k calibré=$k1 doit différer du k stocké (0,19)",
kotlin.math.abs(k1 - 0.19) > 0.01
)
}
@Test
fun `labs before the first dose yield empty scales (per model too)`() {
val tr = treatment()
// lab ANTÉRIEUR à la 1ʳᵉ dose → pas de période d'attribution → vide
val labs = listOf(LabResult(
marker = "E2", value = 250.0, unit = "pg/mL", timestamp = BASE - DAY_MS
))
for (model in listOf(null, "ESE", "TFS", "WHS")) {
val scales = PharmacokineticEngine.computeEsterScaleFactors(
listOf(tr), doses(), labs, modelOverride = model
)
assertTrue("échelles vides attendues pour modelOverride=$model", scales.isEmpty())
}
}
}

View File

@ -2,6 +2,7 @@ package com.hormonetrack.pk
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment
@ -103,6 +104,53 @@ class WhsahEngineTest {
assertTrue(curve.all { it.e2 == 0.0 })
}
@Test
fun `calibration is per model - each calibrated curve lands on the lab (fix 60)`() {
// Remontée v1.4.7 : « la calibration auto sur WHSAH met ×2,21 » —
// AVANT : un seul jeu de facteurs calculé depuis le modèle STOCKÉ
// puis partagé par toutes les courbes. DÉSORMAIS (fix #60) : chaque
// modèle est calibré avec SA prédiction → chaque courbe calibrée
// passe PAR le lab, quel que soit le modèle affiché.
val tr = treatment(Esters.EEN, PKModels.TRANSFEM_SCIENCE)
val doses = listOf(
DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)
)
// lab fictif de 250 pg/mL 7 jours après l'injection (au creux TFS)
val lab = LabResult(marker = "E2", value = 250.0, unit = "pg/mL",
timestamp = BASE + 7 * DAY_MS)
val tConfig = TConfig()
for ((model, expected) in listOf(
"TFS" to null, // attendu = calculé en dessous (auto-cohérence)
"WHS" to null
)) {
val scales = PharmacokineticEngine.computeEsterScaleFactors(
listOf(tr), doses, listOf(lab), modelOverride = model
)
assertTrue("échelle $model doit être positive et plausible",
(scales["EEN"] ?: 0.0) > 0.0)
// la courbe calibrée avec SES échelles passe PAR le lab (±2 %)
val calibrated = PharmacokineticEngine.e2At(
listOf(tr), doses, lab.timestamp,
modelOverride = model, scalePerEster = scales
)
assertTrue(
"$model : calibrée=$calibrated doit ≈ lab=250 (échelle ${scales["EEN"]})",
calibrated in 250.0 * 0.98..250.0 * 1.02
)
}
// et les échelles des DEUX modèles sont DISTINCTES (prédictions
// différentes au même instant → facteurs différents)
val scalesTfs = PharmacokineticEngine.computeEsterScaleFactors(
listOf(tr), doses, listOf(lab), modelOverride = "TFS")
val scalesWhs = PharmacokineticEngine.computeEsterScaleFactors(
listOf(tr), doses, listOf(lab), modelOverride = "WHS")
assertTrue(
"TFS=${scalesTfs["EEN"]} vs WHS=${scalesWhs["EEN"]} doivent différer",
kotlin.math.abs(scalesTfs["EEN"]!! - scalesWhs["EEN"]!!) > 0.01
)
}
@Test
fun `calibration applies to ALL model curves equally (fix 58)`() {
// Demande v1.4.7 : « TOUTES les courbes doivent s'adapter au calibrage »

View File

@ -1,7 +1,36 @@
# Changelog — HormoneTrack
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.7`, …).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.8`, …).
## [1.4.8] — 2026-09-07 (versionCode 28)
### Corrigé
- **La calibration automatique était fausse pour les modèles autres que le
modèle stocké du traitement** (#60, remontée : « la calibration auto sur
WHSAH met ×2,21 alors que l'idéal serait < 1 ») : un SEUL jeu de facteurs
était calculé depuis la prédiction du modèle STOCKÉ puis partagé par
toutes les courbes — en basculant un traitement (ex. EEn TFS → WHSAH),
les facteurs devenaient absurdes (les prédictions WHSAH diffèrent de TFS :
creux plus profonds, montée plus rapide). FIX (demande : « la calibration
doit être active sur tous les modèles mais s'adapter à chaque profil ») :
**la calibration est désormais PAR MODÈLE** — le graphique calcule un jeu
de facteurs (et de k T) PAR modèle affiché (ESE/TFS/WHS), chacun calibré
avec la prédiction DE SON modèle → chaque courbe calibrée passe par tes
labs, quel que soit le modèle superposé. Home conserve la calibration du
modèle stocké (inchangé). Épinglé par test : chaque courbe calibrée passe
par le lab (±2 %) et les échelles TFS/WHS diffèrent.
### Ajouté
- **5 nouveaux tests** (164 au total / 142 sans les données locales) :
`CalibrationPerModelTest` — les 3 modèles calibrés atterrissent sur le
lab (ESE inclus), échelles distinctes (ESE↔TFS ~2 %, WHS ~11 %),
délégation `autoCalibrated(modelOverride)` → `computeEsterScaleFactors`
(implémentation unique), **k T par modèle** (k ∝ 1/E2 calibrée — et les
k post-calibration CONVERGENT entre modèles : même E2 cible, comportement
voulu), labs antérieurs à la 1ʳᵉ dose → échelles vides (par modèle).
## [1.4.7] — 2026-09-07 (versionCode 27)
## [1.4.7] — 2026-09-07 (versionCode 27)

View File

@ -90,6 +90,7 @@ Fonctionnalités v1 :
| 7 sept. 2026 (session v1.4.5) | **« La prévision simule le 13 au lieu du 12 »** (#55) : diagnostic sur données réelles — GÉNÉRATION EXACTE (créneau = dernière dose loguée + 6 j ; subtilité : la dose avait été loguée le LENDENDE de l'injection réelle → dérive d'un jour par cycle, l'app suit les logs) ; illusion causée par l'absence de marqueur + labels X à minuit UTC. FIX : marqueurs de doses (prévisionnels pointillés + réels discrets), labels à minuit LOCAL, fuseau de graphique configurable (Paramètres). **+ #56** : boucle de recomposition (`nowMs` relu à chaque frame → endMs dérive → saturation main thread → taps perdus) → mémoïsé sur le tick. 145 verts. Publication v1.4.5. |
| 7 sept. 2026 (session v1.4.6) | **3ᵉ modèle PK « WHSAH »** (demande) : le fit « license-free » du WHSAH Collective publié dans **Mona** (l'app concurrente — son git montre qu'elle utilisait NOS paramètres TFS avant mars 2026) intégré au même niveau : dispatch parallèle (aucun remplacement), 6 presets, dropdown 3 choix, 3ᵉ toggle superposable (vert/violet), PEP non couvert. Comparaison chiffrée TFS vs WHSAF épinglée (EV pic 367 vs 295 ; EEn à J+1 ×3). **+ #57** : l'écran Graphiques défile désormais (rangée de toggles coupée sous le pli, trouvé en émulateur). 155 verts. Publication v1.4.6. |
| 7 sept. 2026 (session v1.4.7) | **Toggles alignés sur les traitements** (#58, demande) : au chargement, seuls les modèles utilisés par des traitements à PROFIL PK (`usesProfileModel`) sont ON (l'oral/AA polluait le set — vu en émulateur avec le CPA) ; garde-fou TFS si aucun profil ; une seule init. **Épinglé : la calibration s'applique à TOUTES les courbes** (par ESTER via doseEster, indépendant du modelOverride — test). 158 verts. Publication v1.4.7. | + **#59** : le chip de la carte de traitement affichait « Estrannaise » pour WHSAH (ternaire à 2 branches → `modelLabelRes` centralisé).
| 7 sept. 2026 (session v1.4.8) | **Calibration PAR MODÈLE PK** (#60, remontée : « ×2,21 sur WHSAH alors que l'idéal serait < 1 ») : un seul jeu de facteurs (calculé depuis le modèle stocké) était partagé par toutes les courbes → absurde dès qu'un traitement basculait de modèle. FIX : le graphique calcule une AutoCalibrated PAR modèle affiché (ESE/TFS/WHS — modelOverride propagé à computeEsterScaleFactors et computeTKPerEster) → chaque courbe calibrée passe par les labs. Home inchangé (modèle stocké). Test : chaque courbe calibrée ≈ lab (±2 %), échelles TFS ≠ WHS. 159 verts. Publication v1.4.8. |
| 6 sept. 2026 (session v1.3.0) | Dialog « Nouveautés » après mise à jour (CHANGELOG embarqué en asset, tâche `copyChangelog`, version vue en DataStore) ; **événements d'agenda récurrents** (calendrier local HormoneTrack, RRULE posologie, permission runtime, Room v3 `calendarEventId`) ; Paramètres : version + lien releases ; **78 tests verts**, APK v1.3.0 + releases. |
| 5 sept. 2026 (session v1.2.10) | Sens des boutons de zoom inversé (+ = zoom avant, convention carte — retour utilisateur) ; 67 tests verts, APK v1.2.10 + releases. |
| 5 sept. 2026 (session v1.2.9) | **Zoom du graphique** (pinch + boutons, 6 h → 300 j, focal stable, échantillonnage adaptatif `stepForRange`, labels X 1 h/3 h) ; **README : disclaimer IA-assisté** en en-tête ; 3 tests ; **67 tests verts**, APK v1.2.9 + releases. |
@ -346,15 +347,15 @@ T est faux d'un facteur 100, bug réel remonté par l'utilisatrice : labs 33/44
### 7.6 Calibration (v1.2.1 : PAR PÉRIODE D'ESTER pour l'auto)
**PORTÉE (épinglé v1.4.7, test `calibration applies to ALL model curves
equally`)** : le facteur s'applique par ESTER de la DOSE (`doseEster`),
indépendamment du modelOverride → TOUTES les courbes affichées (ESE/TFS/
WHS) d'un même traitement sont scalées identiquement. Subtilité : le
facteur est calculé depuis la prédiction du modèle STOCKÉ du traitement —
les courbes des autres modèles partagent ce facteur (un facteur par
ester/période, pas par modèle : les comparaisons inter-modèles reflètent
la FORME, pas des calibrations distinctes).
**Automatique** (`computeEsterScaleFactors` + `scalePerEster`, option « Auto-calibration ») :
**PORTÉE (v1.4.8, fix #60) : la calibration est PAR MODÈLE PK.**
**PORTÉE (v1.4.8, fix #60)** : le facteur est calculé PAR MODÈLE PK — le
graphique calcule une `AutoCalibrated` par modèle affiché (ESE/TFS/WHS, via
`autoCalibrated(modelOverride = …)`) : chaque courbe est calibrée avec la
prédiction DE SON modèle et passe par les labs quel que soit le modèle
superposé (test `calibration is per model`). Home (sans courbes superposées)
conserve la calibration du modèle STOCKÉ. HISTORIQUE : v1.4.7 partageait UN
seul facteur (calculé depuis le modèle stocké) entre toutes les courbes —
absurde dès qu'un traitement changeait de modèle (×2,21 sur WHSAH).
- chaque lab est **attribué à la période d'injection dans laquelle il tombe** =
dernière dose E2 ≤ lab (une prise de sang reflète d'abord l'injection qui précède) ;
- ratio = lab ÷ prédiction **non calibrée** (toutes doses superposées, scaleFactor forcé 1) ;
@ -457,7 +458,7 @@ niveau que les deux autres, pas de remplacement »).
## 8. Tests unitaires
**158 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **136 sans
**164 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **142 sans
les données de test locales** (cf §8.bis : les 3 classes de régression,
6 tests chacune, sont skippées via `Assume`). Dépendance : JUnit 4.13.2.
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
@ -590,6 +591,18 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
traitement ESE, `pkModel = WHS`, coupure 10 × t½, PEP → 0) et **3 courbes
EV distinctes** (ESE/TFS/WHS — seuil 2 % : ESE et TFS sont quasi
identiques au pic par construction).
- **`WhsahEngineTest`** (6) : + v1.4.8 — **calibration PAR MODÈLE** (fix
#60) : chaque courbe calibrée avec la prédiction de SON modèle passe par
le lab (±2 %) ; échelles TFS ≠ WHS au même lab.
- **`CalibrationPerModelTest`** (5) : v1.4.8 — **process calibration** :
les 3 modèles (ESE inclus) calibrés atterrissent sur le lab ; échelles
distinctes (ESE↔TFS ~2 %, WHS ~11 % — nuance documentée : la table ODS
dérive du même article) ; **délégation** `autoCalibrated(modelOverride)`
→ `computeEsterScaleFactors` (implémentation unique — Home et graphique
ne peuvent pas diverger) ; **k T par modèle** (k ∝ 1/E2 calibrée :
échelle ×2 → k ÷2 ; les k post-calibration convergent entre modèles —
même E2 cible) ; labs antérieurs à la 1ʳᵉ dose → échelles vides (par
modèle). Toute retouche du pipeline de calibration passe par CE test.
- **`ChartZoomTest`** (15) : v1.2.9 (pas d'échantillonnage adaptatif) /
v1.4.1 (horizon + clamp panoramique) / v1.4.5 (`pointHoursBefore`,
`xLabelTicks` minuit local) / v1.4.7 — **`defaultModelToggles`** (toggles
@ -727,7 +740,9 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
(+ FAB), mini-chart 24 h (multi-séries via `ChartSeries`) **cliquable → écran
Graphiques** (v1.2.1) avec mini-légende E2/T ; données auto-calibrées si l'option est
active (`scalePerEster = effectiveAuto?.esterScales`) ; rafraîchissement `tick` 60 s
- `ChartScreen` (v1.2, le plus riche) : **toggles de modèles initialement
- `ChartScreen` (v1.2, le plus riche) : **calibration PAR MODÈLE** (v1.4.8,
fix #60 : `autoByModel` — une AutoCalibrated par modèle affiché, chaque
courbe reçoit SES échelles/k) ; **toggles de modèles initialement
alignés sur les traitements** (v1.4.7, fix #58 : `defaultModelToggles` sur
les pkModel des traitements à PROFIL PK — init unique au chargement ;
garde-fou TFS si aucun profil — les traitements Bateman sont tracés dans
@ -1372,8 +1387,8 @@ vérifie pas en tests JVM).
1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`,
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 158 au
total, 136 si `local-test-data/` est absent (les 3 classes de régression
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 164 au
total, 142 si `local-test-data/` est absent (les 3 classes de régression
réelles sont skippées via `Assume`, 6 tests chacune) ; **lint vert
obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait
attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet :
@ -1676,13 +1691,17 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
- [ ] **v1.4.6** : **3ᵉ modèle WHSAH** : preset WHSAH → courbe verte
superposable aux 2 autres (montée plus rapide à J+1, t½ plus longues
que TFS) ; PEP non couvert ; 6 presets ; dropdown modèle à 3 choix
- [ ] **v1.4.8** : **calibration par modèle** : superposer TFS + WHSAH avec
l'auto-calibration ON → CHAQUE courbe colle aux labs (plus de facteur
partagé absurde type ×2,21 — fix #60)
- [ ] **v1.4.7** : **toggles alignés sur les traitements** : au 1ᵉʳ chargement
du graphique, seuls les modèles des traitements à profil sont ON
(ex. EV TFS + EEn WHSAH → Estrannaise OFF) ; les autres activables au
tap ; et TOUTES les courbes affichées reflètent la calibration (test)
---
*Doc mise à jour le 7 sept. 2026 (v1.4.7) — build OK, lint vert, 158/158 tests verts (136 sans
*Doc mise à jour le 7 sept. 2026 (v1.4.8) — build OK, lint vert, 164/164 tests verts (142 sans
les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée
de santé dans le dépôt ni l'historique. Fil des corrections : v1.3.3 = reprise de
maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 crashs

View File

@ -206,7 +206,10 @@ Pics de référence (dose unique de 5 mg, IM) :
l'affichage seulement, tes réglages stockés ne changent pas. **Chaque ester est
calibré avec les labs faits pendant sa période** : si tu étais sous valerate avant
d'être sous enanthate, tes labs valerate calibrent les doses valerate (E2 **et**
la suppression T), et inversement pour l'enanthate
la suppression T), et inversement pour l'enanthate. **Calibration par modèle**
(v1.4.8) : chaque modèle affiché dans le graphique (Estrannaise / Transfem
Science / WHSAH) reçoit SES propres facteurs calculés sur tes labs — les
courbes superposées collent toutes à tes résultats, quelle que soit leur forme
## 9. Dépannage