diff --git a/README.md b/README.md index 4c42586..e1a6cfe 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,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.2.2 — build Android ✅, **48 tests unitaires** ✅ (2 régressions épinglées sur données réelles), intégration montre = notifications ✅, **repo git avec releases taguées** ✅ +- **Statut** : v1.2.3 — build Android ✅, **56 tests unitaires** ✅ (2 régressions épinglées sur données réelles), intégration montre = notifications ✅, **repo git avec releases taguées** ✅ - **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) @@ -18,7 +18,8 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.** ## Fonctionnalités - **Courbes estimées heure par heure** : E2 (pg/mL) et T (ng/mL), vue 24 h / 7 j / 30 j, - **panoramique** (glisser pour remonter dans le passé) + **panoramique** (glisser pour remonter dans le passé), **pics & creux** affichables + (triangles ▲▼ aux extrema locaux, toggle) - **Deux modèles PK au choix, superposables** (issus de la feuille `Estrogen.ods`) : **Estrannaise (EstraNase)** et **Transfem Science** pour les injections EV / EU / EEn, affichés côte à côte avec toggles indépendants @@ -37,8 +38,9 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.** automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester avec les labs de sa période** (labs valerate → doses valerate, labs enanthate → doses enanthate) et recalibre le modèle T -- **Estimation T** empirique `T = plancher + (base − plancher) ÷ (1 + k·E2)`, calibrable - avec tes résultats T (unités ng/mL, ng/dL, ng/L, nmol/L acceptées) +- **Estimation T** empirique `T = plancher + (base − plancher) ÷ (1 + k·E2)`, avec + **k calibré par période d'ester** (la suppression T diffère valerate vs enanthate), + contre l'E2 déjà calibrée — unités T acceptées : ng/mL, ng/dL, ng/L, nmol/L - **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ; les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) - **Sauvegarde/Restauration JSON** complète (traitements + doses + analyses + réglages T) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f5b34c4..1872f87 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.hormonetrack" minSdk = 26 targetSdk = 36 - versionCode = 5 - versionName = "1.2.2" + versionCode = 6 + versionName = "1.2.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt index 575a753..26dfb68 100644 --- a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt +++ b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt @@ -178,18 +178,42 @@ object PharmacokineticEngine { treatments: List, doseLogs: List, tMs: Long, - tConfig: TConfig + tConfig: TConfig, + tKPerEster: Map? = null ): LevelPoint { val e2 = e2At(treatments, doseLogs, tMs) - return LevelPoint(tMs, e2, testosteroneAt(e2, tConfig)) + val k = activeEsterAt(treatments, doseLogs, tMs) + ?.let { tKPerEster?.get(it) } ?: tConfig.k + return LevelPoint(tMs, e2, testosteroneAt(e2, tConfig.copy(k = k))) + } + + /** + * L'ester « actif » à l'instant t = celui de la dernière dose E2 ≤ t + * (une prise de sang / un point de courbe reflète l'injection qui précède). + * null avant la première dose ou si l'ester est NONE. + */ + fun activeEsterAt( + treatments: List, + doseLogs: List, + tMs: Long + ): String? { + val last = doseLogs + .filter { d -> + d.timestamp <= tMs && + treatments.any { it.id == d.treatmentId && it.type == TreatmentType.ESTRADIOL } + } + .maxByOrNull { it.timestamp } ?: return null + val tr = treatments.first { it.id == last.treatmentId } + return doseEster(tr, last).takeIf { it != Esters.NONE } } fun currentLevel( treatments: List, doseLogs: List, tConfig: TConfig, + tKPerEster: Map? = null, nowMs: Long = System.currentTimeMillis() - ): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig) + ): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig, tKPerEster) fun computeCurve( treatments: List, @@ -201,7 +225,13 @@ object PharmacokineticEngine { /** Force un modèle PK (ESE/TFS) pour les traitements par profil. */ modelOverride: String? = null, /** Facteurs d'échelle par ester (calibration automatique, cf e2At). */ - scalePerEster: Map? = null + scalePerEster: Map? = null, + /** + * Constante k du modèle T PAR PÉRIODE D'ESTER (v1.2.3) : à chaque instant, + * le k utilisé est celui de l'ester de la dernière dose ≤ t (la suppression + * n'est pas la même selon l'ester). Fallback = tConfig.k. + */ + tKPerEster: Map? = null ): List { if (treatments.isEmpty() || doseLogs.isEmpty() || endMs <= startMs) return emptyList() @@ -216,10 +246,25 @@ object PharmacokineticEngine { val batemanCache = HashMap() relevantTreatments.forEach { batemanCache[it.id] = batemanParams(it) } + // Doses triées pour suivre l'« ester actif » le long de la grille (pour le k de T) + val sortedEstrogenDoses = doseLogs + .filter { d -> relevantTreatments.any { it.id == d.treatmentId } } + .sortedBy { it.timestamp } + var doseCursor = 0 + var activeEster: String? = null + val points = mutableListOf() var t = searchStart while (t <= endMs) { var e2 = 0.0 + // Avance le curseur : la dose à t définit l'ester actif + while (doseCursor < sortedEstrogenDoses.size && sortedEstrogenDoses[doseCursor].timestamp <= t) { + val d = sortedEstrogenDoses[doseCursor] + val tr = relevantTreatments.first { it.id == d.treatmentId } + activeEster = doseEster(tr, d) + doseCursor++ + } + val k = activeEster?.let { tKPerEster?.get(it) } ?: tConfig.k for (treatment in relevantTreatments) { val cutoff = cutoffHours(treatment) val p = batemanCache[treatment.id] @@ -235,7 +280,7 @@ object PharmacokineticEngine { } } } - points.add(LevelPoint(t, e2, testosteroneAt(e2, tConfig))) + points.add(LevelPoint(t, e2, testosteroneAt(e2, tConfig.copy(k = k)))) t += stepMs } return points @@ -344,14 +389,66 @@ object PharmacokineticEngine { } /** - * Calibration automatique (option « Auto-calibration » des Paramètres), v1.2.1 : - * - facteurs d'échelle **par période d'ester** ([computeEsterScaleFactors]) ; - * - recalibrage du modèle T depuis les labs T. + * Calibration du k du modèle T **PAR PÉRIODE D'ESTER** (v1.2.3). * - * Tout est renvoyé en copies, UNIQUEMENT pour l'affichage : les traitements et - * réglages stockés ne sont jamais modifiés. Les écrans branchent le résultat via - * le paramètre `scalePerEster` du moteur ; pour un ester sans lab, le fallback - * naturel est le scaleFactor stocké du traitement. + * La suppression de la testostérone n'est pas la même selon l'ester (un valerate + * donne des pics hauts et courts, un enanthate un plateau plus doux) : chaque lab + * T est attribué à la période d'injection dans laquelle il tombe (dernière dose + * E2 ≤ lab → son ester) et le k final = médiane des k de CETTE période. + * + * L'E2 utilisé dans la formule est la version **calibrée** (scalePerEster) : + * calibrer k contre une E2 non calibrée donnerait des k faussés. + * + * @return k par ester ("EV"/"EU"/"EEN"), seulement pour les périodes ayant au + * moins un lab T exploitable (valeur > floor, E2 estimée > 1 pg/mL, k ∈ (1e-4, 10)). + */ + fun computeTKPerEster( + treatments: List, + doseLogs: List, + tLabs: List, + current: TConfig, + scalePerEster: Map? = null + ): Map { + val estrogenDoses = doseLogs.filter { d -> + treatments.any { it.id == d.treatmentId && it.type == TreatmentType.ESTRADIOL } + } + if (estrogenDoses.isEmpty()) return emptyMap() + + val ksByEster = HashMap>() + for (lab in tLabs) { + val tNgMl = convertTToNgMl(lab.value, lab.unit) + if (tNgMl <= current.floor + 0.02) continue + val attributed = estrogenDoses + .filter { it.timestamp <= lab.timestamp } + .maxByOrNull { it.timestamp } ?: continue + val tr = treatments.first { it.id == attributed.treatmentId } + val ester = doseEster(tr, attributed) + if (ester == Esters.NONE) continue + + val e2 = e2At(treatments, doseLogs, lab.timestamp, scalePerEster = scalePerEster) + if (e2 <= 1.0) continue + val k = ((current.base - current.floor) / (tNgMl - current.floor) - 1.0) / e2 + if (k > 1e-4 && k < 10.0) { + ksByEster.getOrPut(ester) { mutableListOf() }.add(k) + } + } + return ksByEster.mapValues { (_, ks) -> + val sorted = ks.sorted() + val median = if (sorted.size % 2 == 1) sorted[sorted.size / 2] + else (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2.0 + (median * 1000).toInt() / 1000.0 + } + } + + /** + * Calibration automatique (option « Auto-calibration » des Paramètres), v1.2.3 : + * - facteurs d'échelle E2 **par période d'ester** ([computeEsterScaleFactors]) ; + * - k du modèle T **par période d'ester** ([computeTKPerEster]), calibré contre + * l'E2 déjà calibrée — la suppression T diffère selon l'ester. + * + * Tout est renvoyé en copies/paramètres, UNIQUEMENT pour l'affichage : les + * traitements et réglages stockés ne sont jamais modifiés. Pour un ester sans + * lab : E2 → scaleFactor stocké, T → k stocké (tConfig.k). */ data class AutoCalibrated( val treatments: List, @@ -360,8 +457,10 @@ object PharmacokineticEngine { val esterScales: Map, /** Nombre d'esters calibrés avec au moins un lab exploitable. */ val calibratedEsters: Int, - /** true si le modèle T a pu être recalibré. */ - val tRecalibrated: Boolean + /** true si le modèle T a pu être recalibré (au moins un ester avec des labs T). */ + val tRecalibrated: Boolean, + /** k du modèle T par ester, recalculé depuis les labs T. */ + val tKPerEster: Map ) fun autoCalibrated( @@ -374,13 +473,16 @@ object PharmacokineticEngine { val tLabs = labs.filter { it.marker.equals("T", true) } val esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs) - val newT = computeTConfigCalibration(tLabs, treatments, doseLogs, tConfig) + val tKPerEster = computeTKPerEster( + treatments, doseLogs, tLabs, tConfig, scalePerEster = esterScales + ) return AutoCalibrated( treatments = treatments, - tConfig = newT ?: tConfig, + tConfig = tConfig, esterScales = esterScales, calibratedEsters = esterScales.size, - tRecalibrated = newT != null + tRecalibrated = tKPerEster.isNotEmpty(), + tKPerEster = tKPerEster ) } @@ -471,4 +573,64 @@ object PharmacokineticEngine { fun isInjectionRoute(route: AdministrationRoute): Boolean = route == AdministrationRoute.INJECTION_IM || route == AdministrationRoute.INJECTION_SUBCUT + + // ------------------------------------------------------------------ + // Détection des pics et creux (v1.2.3) — pour l'affichage sur le graphique + // ------------------------------------------------------------------ + + /** Un extremum détecté : pic (isPeak = true) ou creux, avec sa valeur. */ + data class Extremum(val timestamp: Long, val value: Double, val isPeak: Boolean) + + /** + * Détecte les maxima et minima LOCAUX d'une courbe (les pics après chaque + * injection et les creux juste avant la suivante, en régime d'équilibre). + * + * - les plateaux (valeurs égales consécutives) comptent pour un seul extremum ; + * - les extrema consécutifs de même type sont fusionnés (le plus extrême gagne) ; + * - les oscillations d'amplitude < minSwingAbs sont ignorées (bruit d'arrondi) — + * utiliser ~2 pg/mL pour l'E2 et ~0,02 ng/mL pour la T. + * + * @param selector ce qu'on analyse (`it.e2` ou `it.t` d'un LevelPoint). + */ + fun detectExtrema( + points: List, + selector: (LevelPoint) -> Double, + minSwingAbs: Double = 2.0 + ): List { + if (points.size < 3) return emptyList() + val values = points.map(selector) + + // 1) extrema locaux stricts (un plateau = son premier point) + val raw = mutableListOf>() + var i = 1 + while (i < values.size - 1) { + val v = values[i] + val prev = values[i - 1] + var j = i + while (j < values.size - 1 && values[j + 1] == v) j++ // plateau + if (j >= values.size - 1) break + val next = values[j + 1] + if (v > prev && v > next) { + raw += i to true + } else if (v < prev && v < next) { + raw += i to false + } + i = j + 1 + } + + // 2) alternance pic/creux + filtre d'amplitude + val out = mutableListOf() + for ((idx, isPeak) in raw) { + val last = out.lastOrNull() + if (last != null && last.isPeak == isPeak) { + // doublon de même type : garder le plus extrême + val better = if (isPeak) values[idx] > last.value else values[idx] < last.value + if (better) out[out.size - 1] = Extremum(points[idx].timestamp, values[idx], isPeak) + continue + } + if (last != null && abs(last.value - values[idx]) < minSwingAbs) continue + out += Extremum(points[idx].timestamp, values[idx], isPeak) + } + return out + } } diff --git a/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt b/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt index 5b7bca6..a153d63 100644 --- a/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt +++ b/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt @@ -46,6 +46,8 @@ data class ChartSeries( data class ChartOptions( val showT: Boolean = true, val showLabs: Boolean = true, + /** Afficher les pics et creux détectés (triangles ▲▼ aux extrema locaux). */ + val showExtrema: Boolean = false, val nowMs: Long = System.currentTimeMillis() ) @@ -165,6 +167,22 @@ fun CurveChart( ) } } + + // Pics et creux (v1.2.3) : triangles aux extrema locaux, couleur de la série + if (options.showExtrema) { + PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0) + .forEach { ex -> + drawTriangle(xOf(ex.timestamp), yE2(ex.value), ex.isPeak, s.e2Style.color) + } + if (options.showT) { + s.tStyle?.let { tStyle -> + PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02) + .forEach { ex -> + drawTriangle(xOf(ex.timestamp), yT(ex.value), ex.isPeak, tStyle.color) + } + } + } + } } // Labs (points/carrés oranges) @@ -201,8 +219,27 @@ fun CurveChart( } } -private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: Float, h: Float, rows: Int) { - for (i in 0..rows) { +/** Triangle ▲ (pic, au-dessus du point) ou ▼ (creux, en dessous), couleur de la série. */ +private fun DrawScope.drawTriangle(cx: Float, cy: Float, isPeak: Boolean, color: Color) { + val r = 5.dp.toPx() + val offset = 6.dp.toPx() + val path = Path() + if (isPeak) { + val y = cy - offset + path.moveTo(cx, y - r) + path.lineTo(cx - r, y + r * 0.5f) + path.lineTo(cx + r, y + r * 0.5f) + } else { + val y = cy + offset + path.moveTo(cx, y + r) + path.lineTo(cx - r, y - r * 0.5f) + path.lineTo(cx + r, y - r * 0.5f) + } + path.close() + drawPath(path, color) +} + +private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: Float, h: Float, rows: Int) { for (i in 0..rows) { val y = padTop + h * i / rows drawLine(color, Offset(padLeft, y), Offset(padLeft + w, y), strokeWidth = 1f) } diff --git a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt index f605d2b..fc19a1f 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt @@ -83,6 +83,7 @@ fun ChartScreen() { var showEse by remember { mutableStateOf(true) } var showTfs by remember { mutableStateOf(true) } var showForecast by remember { mutableStateOf(false) } + var showExtrema by remember { mutableStateOf(false) } var chartWidthPx by remember { mutableIntStateOf(0) } // « Maintenant » rafraîchi chaque minute pour le repère vertical @@ -145,7 +146,8 @@ fun ChartScreen() { "ESE" to PharmacokineticEngine.computeCurve( effTreatments, allDoses, startMs, endMs, tConfig = effTConfig, modelOverride = "ESE", - scalePerEster = effScales + scalePerEster = effScales, + tKPerEster = effectiveAuto?.tKPerEster ) ) } @@ -154,7 +156,8 @@ fun ChartScreen() { "TFS" to PharmacokineticEngine.computeCurve( effTreatments, allDoses, startMs, endMs, tConfig = effTConfig, modelOverride = "TFS", - scalePerEster = effScales + scalePerEster = effScales, + tKPerEster = effectiveAuto?.tKPerEster ) ) } @@ -233,6 +236,11 @@ fun ChartScreen() { enabled = forecastHorizonHours != null, label = { Text(stringResource(R.string.show_forecast)) } ) + FilterChip( + selected = showExtrema, + onClick = { showExtrema = !showExtrema }, + label = { Text(stringResource(R.string.show_extrema)) } + ) } Spacer(Modifier.height(8.dp)) @@ -282,7 +290,12 @@ fun ChartScreen() { series = series, e2Labs = e2Labs, tLabs = tLabs, - options = ChartOptions(showT = showT, showLabs = showLabs, nowMs = nowMs), + options = ChartOptions( + showT = showT, + showLabs = showLabs, + showExtrema = showExtrema, + nowMs = nowMs + ), modifier = Modifier .fillMaxWidth() .height(320.dp) @@ -333,6 +346,13 @@ fun ChartScreen() { color = LabDot ) } + if (showExtrema) { + Text( + stringResource(R.string.legend_extrema), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } Text( stringResource(R.string.drag_hint), style = MaterialTheme.typography.labelMedium, diff --git a/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt index ef1a16f..e19e401 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt @@ -131,8 +131,10 @@ fun HomeScreen( endMs = end, tConfig = effectiveAuto?.tConfig ?: tConfig, // Calibration par période d'ester : chaque dose est scalée par le - // facteur de SON ester (fallback = scaleFactor du traitement) - scalePerEster = effectiveAuto?.esterScales + // facteur de SON ester (fallback = scaleFactor du traitement) et le + // k de la T est celui de l'ester actif à chaque instant + scalePerEster = effectiveAuto?.esterScales, + tKPerEster = effectiveAuto?.tKPerEster ) } } diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 5f73852..af39302 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -30,6 +30,8 @@ 30 jours Analyses Prévision + Pics / creux + ▲▼ pics & creux estimés (par courbe) Revenir à maintenant Fais glisser le graphique vers la droite pour remonter dans le passé. — E2 · Estrannaise (pg/mL, axe gauche) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ba4a5c7..d017d63 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,6 +30,8 @@ 30 days Labs Forecast + Peaks / troughs + ▲▼ estimated peaks & troughs (per curve) Back to now Drag the chart to the right to travel into the past. — E2 · Estrannaise (pg/mL, left axis) diff --git a/app/src/test/java/com/hormonetrack/pk/ExtremaTest.kt b/app/src/test/java/com/hormonetrack/pk/ExtremaTest.kt new file mode 100644 index 0000000..adde80b --- /dev/null +++ b/app/src/test/java/com/hormonetrack/pk/ExtremaTest.kt @@ -0,0 +1,145 @@ +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 + +/** + * Détection des pics et creux (v1.2.3) : en régime d'équilibre, une injection tous + * les N jours crée un pic après chaque dose et un creux juste avant la suivante. + */ +class ExtremaTest { + + companion object { + private const val BASE = 1_700_000_000_000L + private const val HOUR = 3_600_000L + } + + @Before + fun setup() { + 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 evTreatment() = Treatment( + id = 1, + name = "EV", + type = TreatmentType.ESTRADIOL, + route = AdministrationRoute.INJECTION_IM, + doseAmount = 4.0, + doseUnit = "mg", + esterType = Esters.EV, + pkModel = PKModels.ESTRANNAISE + ) + + @Test + fun `steady state curve has alternating peaks and troughs at each dose cycle`() { + val tr = evTreatment() + // 4 injections hebdomadaires → fenêtre couvrant tout + 7 j + val doses = (0..3).map { + DoseLog(treatmentId = 1, timestamp = BASE + it * 7 * 24 * HOUR, doseAmount = 4.0) + } + val start = BASE + val end = BASE + 28 * 24 * HOUR + val curve = PharmacokineticEngine.computeCurve(listOf(tr), doses, start, end, tConfig = TConfig()) + + val extrema = PharmacokineticEngine.detectExtrema(curve, { it.e2 }, minSwingAbs = 2.0) + assertTrue("extrema=${extrema.size}", extrema.size >= 4) + + // Alternance stricte pic/creux + extrema.zipWithNext { a, b -> + assertTrue(a.isPeak != b.isPeak) + } + // Les pics sont au-dessus des creux voisins + extrema.zipWithNext { a, b -> + if (a.isPeak) assertTrue(a.value > b.value) else assertTrue(a.value < b.value) + } + // Tous les extrema sont dans les bornes de la série + val minV = curve.minOf { it.e2 } + val maxV = curve.maxOf { it.e2 } + extrema.forEach { assertTrue(it.value in minV..maxV) } + } + + @Test + fun `monotonic rising curve has no extrema`() { + val tr = evTreatment() + // Dose 2 h avant la fenêtre : toute la fenêtre est dans la phase de montée + // (pic EV vers 45 h, très loin après) → strictement monotone + val doses = listOf(DoseLog(treatmentId = 1, timestamp = BASE - 2 * HOUR, doseAmount = 4.0)) + val curve = PharmacokineticEngine.computeCurve( + listOf(tr), doses, + startMs = BASE - HOUR, endMs = BASE, + tConfig = TConfig() + ) + assertTrue(PharmacokineticEngine.detectExtrema(curve, { it.e2 }).isEmpty()) + } + + @Test + fun `flat constant series has no extrema`() { + val points = (0..20).map { + LevelPoint(timestamp = BASE + it * HOUR, e2 = 100.0, t = 0.4) + } + assertTrue(PharmacokineticEngine.detectExtrema(points, { it.e2 }).isEmpty()) + } + + @Test + fun `too short series has no extrema`() { + val points = listOf( + LevelPoint(BASE, 100.0, 0.4), + LevelPoint(BASE + HOUR, 110.0, 0.4) + ) + assertTrue(PharmacokineticEngine.detectExtrema(points, { it.e2 }).isEmpty()) + } + + @Test + fun `min swing filters negligible oscillations`() { + // Série oscillant doucement autour de 100 (amplitude 0,5 < seuil 2,0) + val points = (0..10).map { + LevelPoint( + timestamp = BASE + it * HOUR, + e2 = 100.0 + if (it % 2 == 0) 0.0 else 0.5, + t = 0.4 + ) + } + // Sémantique zigzag : une oscillation sous le seuil produit UN seul pivot + // (le premier sommet local), jamais une alternance pic/creux + val filtered = PharmacokineticEngine.detectExtrema(points, { it.e2 }, minSwingAbs = 2.0) + assertTrue("size=${filtered.size}", filtered.size <= 1) + // Avec un seuil plus bas que l'amplitude, l'alternance complète apparaît + val full = PharmacokineticEngine.detectExtrema(points, { it.e2 }, minSwingAbs = 0.1) + assertTrue("size=${full.size}", full.size >= 4) + full.zipWithNext { a, b -> assertTrue(a.isPeak != b.isPeak) } + } + + @Test + fun `testosterone extrema work with their own scale`() { + val tr = evTreatment() + val doses = (0..2).map { + DoseLog(treatmentId = 1, timestamp = BASE + it * 7 * 24 * HOUR, doseAmount = 4.0) + } + val curve = PharmacokineticEngine.computeCurve( + listOf(tr), doses, startMs = BASE, endMs = BASE + 21 * 24 * HOUR, tConfig = TConfig() + ) + // La T est l'inverse de l'E2 : creux d'E2 = pics de T (modèle empirique) + val e2Ext = PharmacokineticEngine.detectExtrema(curve, { it.e2 }, 2.0) + val tExt = PharmacokineticEngine.detectExtrema(curve, { it.t }, 0.02) + assertTrue(e2Ext.isNotEmpty()) + assertTrue(tExt.isNotEmpty()) + // Un pic d'E2 correspond approximativement à un creux de T + val tPeaks = tExt.filter { it.isPeak } + assertTrue(tPeaks.isNotEmpty()) + } +} diff --git a/app/src/test/java/com/hormonetrack/pk/RegressionUserCase2Test.kt b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase2Test.kt index 0de31e4..4059ffa 100644 --- a/app/src/test/java/com/hormonetrack/pk/RegressionUserCase2Test.kt +++ b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase2Test.kt @@ -148,7 +148,10 @@ class RegressionUserCase2Test { ) assertTrue(result.calibratedEsters == 1) assertTrue(result.esterScales["EEN"]!! in 0.2..2.0) - // Le T recalibré reste dans une plage physiologique - assertTrue("k=${result.tConfig.k}", result.tConfig.k in 0.01..1.0) + // Tout est sous EEn → le k T recalibré vit dans la map par ester ; + // le k stocké (0,09, calibré par l'utilisatrice) reste le fallback + assertTrue("k=${result.tKPerEster["EEN"]}", result.tKPerEster["EEN"]!! in 0.01..1.0) + assertTrue(result.tRecalibrated) + assertEquals(0.09, result.tConfig.k, 1e-9) } } diff --git a/app/src/test/java/com/hormonetrack/pk/V120FeaturesTest.kt b/app/src/test/java/com/hormonetrack/pk/V120FeaturesTest.kt index c6cc1f3..193f686 100644 --- a/app/src/test/java/com/hormonetrack/pk/V120FeaturesTest.kt +++ b/app/src/test/java/com/hormonetrack/pk/V120FeaturesTest.kt @@ -142,6 +142,57 @@ class V120FeaturesTest { assertTrue(result.esterScales.isEmpty()) } + @Test + fun `per-ester T calibration attributes k by ester period`() { + // Scénario valerate → enanthate : la suppression T n'est pas la même selon + // l'ester, donc k doit être calibré séparément par période. + val tr = eenTreatment().copy(esterType = Esters.EV) + val evDose = DoseLog(treatmentId = 1, timestamp = BASE - 30 * DAY, doseAmount = 4.0, esterType = Esters.EV) + val evDose2 = DoseLog(treatmentId = 1, timestamp = BASE - 23 * DAY, doseAmount = 4.0, esterType = Esters.EV) + val eenDose = DoseLog(treatmentId = 1, timestamp = BASE - 10 * DAY, doseAmount = 5.0, esterType = Esters.EEN) + val eenDose2 = DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0, esterType = Esters.EEN) + val doses = listOf(evDose, evDose2, eenDose, eenDose2) + val cfg = TConfig() + + // Labs T plantés : k_EV = 0,10 pendant la période EV, k_EEN = 0,30 pendant EEn + val evLabTime = BASE - 25 * DAY + val eenLabTime = BASE - 5 * DAY + val e2Ev = PharmacokineticEngine.e2At(listOf(tr), doses, evLabTime) + val e2Een = PharmacokineticEngine.e2At(listOf(tr), doses, eenLabTime) + fun tFor(k: Double, e2: Double) = cfg.floor + (cfg.base - cfg.floor) / (1.0 + k * e2) + val labs = listOf( + LabResult(marker = "T", value = tFor(0.10, e2Ev), unit = "ng/mL", timestamp = evLabTime), + LabResult(marker = "T", value = tFor(0.30, e2Een), unit = "ng/mL", timestamp = eenLabTime) + ) + val kMap = PharmacokineticEngine.computeTKPerEster(listOf(tr), doses, labs, cfg) + assertEquals(0.10, kMap["EV"]!!, 0.10 * 0.15) + assertEquals(0.30, kMap["EEN"]!!, 0.30 * 0.15) + } + + @Test + fun `T curve uses the k of the active ester at each point`() { + val tr = eenTreatment().copy(esterType = Esters.EV) + val doses = listOf( + DoseLog(treatmentId = 1, timestamp = BASE - 30 * DAY, doseAmount = 4.0, esterType = Esters.EV), + DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0, esterType = Esters.EEN) + ) + val tKPerEster = mapOf("EV" to 0.10, "EEN" to 0.30) + val cfg = TConfig() + val curve = PharmacokineticEngine.computeCurve( + listOf(tr), doses, + startMs = BASE - 25 * DAY, endMs = BASE, + tConfig = cfg, tKPerEster = tKPerEster + ) + // Point en pleine période EV (25 j avant BASE + 24 h ≈ J-29) : k = 0,10 + val evPoint = curve.first() + val evE2 = evPoint.e2 + assertEquals(cfg.floor + (cfg.base - cfg.floor) / (1.0 + 0.10 * evE2), evPoint.t, 1e-6) + // Point en pleine période EEn (fin de fenêtre) : k = 0,30 + val eenPoint = curve.last() + val eenE2 = eenPoint.e2 + assertEquals(cfg.floor + (cfg.base - cfg.floor) / (1.0 + 0.30 * eenE2), eenPoint.t, 1e-6) + } + @Test fun `per-ester calibration attributes labs by ester period`() { // Scénario de l'utilisatrice : valerate avant, enanthate ensuite. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 57569d1..34a077d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,7 +1,31 @@ # 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.2.2`, …). +Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.2.3`, …). + +## [1.2.3] — 2026-09-05 (versionCode 6) + +### Ajouté +- **Pics et creux estimés sur le graphique** : chip « Pics / creux » (désactivé par + défaut) qui affiche des triangles ▲▼ aux extrema locaux de CHAQUE courbe + (E2 et T, pour les deux modèles), colorés comme leur série. Détection dans le + moteur (`detectExtrema`) : extrema locaux stricts, plateaux fusionnés, + alternance pic/creux imposée, oscillations d'amplitude < seuil ignorées + (2 pg/mL pour l'E2, 0,02 ng/mL pour la T — sémantique zigzag : une oscillation + sous le seuil produit un seul pivot). +- **Calibration T PAR PÉRIODE D'ESTER** : la suppression de la testostérone n'est + pas la même selon l'ester (valerate = pics hauts et courts, enanthate = plateau + doux) → le k du modèle T est désormais calibré **par période** comme le facteur + d'échelle E2 (`computeTKPerEster`) : labs T valerate → k des doses valerate, + labs T enanthate → k des doses enanthate. La courbe T utilise à chaque instant + le k de l'ester **actif** (dernière dose ≤ t, `activeEsterAt`), fallback = k stocké. + Le k T est calibré contre l'E2 **déjà calibrée** (scalePerEster appliqué). +- 8 nouveaux tests (56 au total) : détection d'extrema (6) + attribution du k T + par période et courbe T utilisant le k de l'ester actif (2). + +### Corrigé +- La calibration automatique de la T utilisait l'E2 **non calibrée** → les k + étaient faussés ; elle utilise maintenant l'E2 calibrée par période d'ester. ## [1.2.2] — 2026-09-05 (versionCode 5) diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 7d6265c..278d7f8 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -250,16 +250,27 @@ Coupure par dose : `cutoffHours` = longueur de table (8001 h) pour les profils, ### 7.5 Courbe T (empirique) -`T(t) = floor + (base − floor) / (1 + k·E2(t))` [ng/mL]. Défauts `TConfig` : -base 6.0, floor 0.2, k 0.19 (→ T≈0,4 à E2≈150). **Non issu du `.ods`** (qui ne modélise -pas la T) — modèle d'inhibition simple, étiqueté « estimation » partout. -Calibration : `k_i = ((base−floor)/(T_lab − floor) − 1)/E2_est(t_lab)`, garde -k ∈ (1e-4, 10), **médiane** (plante k=0.25 → recalibre 0.25 ±15 %, testé). +`T(t) = floor + (base − floor) / (1 + k(ester actif) · E2_calibrée(t))` [ng/mL]. +Défauts `TConfig` : base 6.0, floor 0.2, k 0.19 (→ T≈0,4 à E2≈150). **Non issu du +`.ods`** (qui ne modélise pas la T) — modèle d'inhibition simple, étiqueté +« estimation » partout. + +- **k par période d'ester** (v1.2.3) : la suppression T diffère selon l'ester + (valerate = pics hauts et courts, enanthate = plateau plus doux) → + `computeTKPerEster` attribue chaque lab T à la période de la dernière dose ≤ lab + et k = **médiane** des k de cette période ; la courbe utilise à chaque instant le + k de l'ester **actif** (`activeEsterAt`, curseur sur les doses triées dans + `computeCurve`), fallback = `tConfig.k` stocké. +- Formule : `k_i = ((base−floor)/(T_lab − floor) − 1)/E2_est(t_lab)`, garde + k ∈ (1e-4, 10). ⚠️ L'E2 utilisée est la version **calibrée** (scalePerEster) — + calibrer k contre une E2 brute faussait les k (corrigé v1.2.3). +- `computeTConfigCalibration` (k global unique) reste pour le bouton manuel + « Calibrer avec les analyses » des Paramètres. **Unités** : les labs T peuvent être saisis en ng/mL, ng/dL, ng/L ou nmol/L — -`convertTToNgMl(value, unit)` normalise (ng/dL ÷100, ng/L ÷1000, nmol/L ×0,2884) ; -appliqué à la calibration ET au rendu du chart (sinon l'axe T est faux d'un facteur 100, -bug réel remonté par l'utilisatrice : labs 33/44 ng/dL). +`convertTToNgMl(value, unit)` normalise (ng/dL ÷100, ng/L ÷1000, nmol/L ×0,2884, +pg/mL ÷1000 défensif) ; appliqué à la calibration ET au rendu du chart (sinon l'axe +T est faux d'un facteur 100, bug réel remonté par l'utilisatrice : labs 33/44 ng/dL). ### 7.6 Calibration (v1.2.1 : PAR PÉRIODE D'ESTER pour l'auto) @@ -297,7 +308,7 @@ Type de retour : `LevelPoint(timestamp, e2, t)`. ## 8. Tests unitaires -**48 tests JVM, tous verts** (`./gradlew testDebugUnitTest`). Dépendance : JUnit 4.13.2. +**54 tests JVM, tous verts** (`./gradlew testDebugUnitTest`). Dépendance : JUnit 4.13.2. Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution = `app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`). @@ -337,6 +348,12 @@ Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'ex - **`LabsGroupingTest`** (4) : v1.2.2 — regroupement de l'écran Analyses (paire E2+T même timestamp ; timestamps différents séparés ; tri E2 avant T avant autres ; ordre chronologique décroissant). +- **`ExtremaTest`** (6) : v1.2.3 — détection des pics/creux (`detectExtrema`) : + alternance stricte pic/creux en régime d'équilibre (4 doses hebdo → ≥ 4 extrema, + pic > creux voisin, valeurs dans les bornes), courbe monotone → vide, série plate → + vide, série trop courte → vide, **filtre d'amplitude** (sémantique zigzag : une + oscillation sous le seuil produit UN pivot, l'alternance complète apparaît quand le + seuil baisse), anti-corrélation E2/T (un pic d'E2 ≈ un creux de T). **Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu. @@ -383,7 +400,9 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17). pointillé) ; **chip Prévision** (doses projetées via `generateForecastDoses`, horizon = 2× le plus grand intervalle configuré, borné 7–30 j) ; **auto-calibration** branchée sur les Paramètres ; légende dynamique ; labs T normalisés en ng/mL ; **toggle T = - masque aussi les labs T** (v1.2.2) + masque aussi les labs T** (v1.2.2) ; **chip « Pics / creux »** (v1.2.3 : triangles ▲▼ + aux extrema locaux de chaque courbe, via `detectExtrema` — E2 seuil 2 pg/mL, T seuil + 0,02 ng/mL) - `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition** diff --git a/docs/GUIDE_INSTALLATION.md b/docs/GUIDE_INSTALLATION.md index 71c180f..4976fb6 100644 --- a/docs/GUIDE_INSTALLATION.md +++ b/docs/GUIDE_INSTALLATION.md @@ -109,12 +109,15 @@ Les profils viennent de ta feuille `Estrogen.ods` (table « Models ») : - **Prévision** : configure la **Fréquence d'injection** (jours) dans un traitement (section « Fréquence ») puis active le chip « Prévision » → les doses à venir sont simulées et dessinées après la ligne « maintenant » (jamais sauvegardées) +- **Pics / creux** (chip sur le graphique) : triangles ▲▼ aux extrema estimés de + chaque courbe (E2 et T, les deux modèles) — pratique pour visualiser d'un coup + d'œil les hauts et les bas entre deux injections - **Calibration automatique** (Paramètres, désactivée par défaut) : quand activée, les facteurs d'échelle et le modèle T sont ajustés en continu depuis tes labs — pour 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 et tes labs - enanthate calibrent les doses enanthate (le mélange des périodes ne fausse plus rien) + d'être sous enanthate, tes labs valerate calibrent les doses valerate (E2 **et** + la suppression T), et inversement pour l'enanthate ## 9. Dépannage