v1.2.3 : pics/creux estimés sur le graphique (toggle) + calibration T par période d'ester

- detectExtrema : extrema locaux stricts (plateaux fusionnés), alternance
  pic/creux imposée, filtre d'amplitude (2 pg/mL E2, 0,02 ng/mL T) — sémantique
  zigzag documentée (oscillation sous le seuil = un seul pivot)
- CurveChart : triangles ▲▼ aux extrema de chaque série (couleur de la série),
  chip « Pics / creux » + légende (désactivé par défaut)
- computeTKPerEster : le k du modèle T est calibré PAR PÉRIODE D'ESTER (labs T
  attribués à la période de la dernière dose ≤ lab, médiane) — la suppression T
  diffère valerate vs enanthate ; la courbe T utilise le k de l'ester ACTIF à
  chaque instant (activeEsterAt + curseur de doses triées dans computeCurve)
- Fix : la calibration T automatique utilise désormais l'E2 CALIBRÉE
  (scalePerEster) — calibrer contre l'E2 brute faussait les k
- levelAt/currentLevel : paramètre tKPerEster (fallback = tConfig.k stocké)
- ExtremaTest : 6 tests (alternance, monotone, plat, court, filtre d'amplitude,
  anti-corrélation E2/T) — 56 tests verts
- versionCode 6, versionName 1.2.3 ; docs mises à jour (§7.5/§7.6, §11, README, guide)
This commit is contained in:
Siphonight 2026-09-05 18:58:10 +02:00
parent b4a81351bf
commit fdd3fee8f6
14 changed files with 517 additions and 45 deletions

View File

@ -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)

View File

@ -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 {

View File

@ -178,18 +178,42 @@ object PharmacokineticEngine {
treatments: List<Treatment>,
doseLogs: List<DoseLog>,
tMs: Long,
tConfig: TConfig
tConfig: TConfig,
tKPerEster: Map<String, Double>? = 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<Treatment>,
doseLogs: List<DoseLog>,
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<Treatment>,
doseLogs: List<DoseLog>,
tConfig: TConfig,
tKPerEster: Map<String, Double>? = null,
nowMs: Long = System.currentTimeMillis()
): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig)
): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig, tKPerEster)
fun computeCurve(
treatments: List<Treatment>,
@ -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<String, Double>? = null
scalePerEster: Map<String, Double>? = 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<String, Double>? = null
): List<LevelPoint> {
if (treatments.isEmpty() || doseLogs.isEmpty() || endMs <= startMs) return emptyList()
@ -216,10 +246,25 @@ object PharmacokineticEngine {
val batemanCache = HashMap<Long, BatemanParams>()
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<LevelPoint>()
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<Treatment>,
doseLogs: List<DoseLog>,
tLabs: List<LabResult>,
current: TConfig,
scalePerEster: Map<String, Double>? = null
): Map<String, Double> {
val estrogenDoses = doseLogs.filter { d ->
treatments.any { it.id == d.treatmentId && it.type == TreatmentType.ESTRADIOL }
}
if (estrogenDoses.isEmpty()) return emptyMap()
val ksByEster = HashMap<String, MutableList<Double>>()
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<Treatment>,
@ -360,8 +457,10 @@ object PharmacokineticEngine {
val esterScales: Map<String, Double>,
/** 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<String, Double>
)
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<LevelPoint>,
selector: (LevelPoint) -> Double,
minSwingAbs: Double = 2.0
): List<Extremum> {
if (points.size < 3) return emptyList()
val values = points.map(selector)
// 1) extrema locaux stricts (un plateau = son premier point)
val raw = mutableListOf<Pair<Int, Boolean>>()
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<Extremum>()
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
}
}

View File

@ -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)
}

View File

@ -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,

View File

@ -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
)
}
}

View File

@ -30,6 +30,8 @@
<string name="chart_30j">30 jours</string>
<string name="show_labs">Analyses</string>
<string name="show_forecast">Prévision</string>
<string name="show_extrema">Pics / creux</string>
<string name="legend_extrema">▲▼ pics &amp; creux estimés (par courbe)</string>
<string name="back_to_present">Revenir à maintenant</string>
<string name="drag_hint">Fais glisser le graphique vers la droite pour remonter dans le passé.</string>
<string name="legend_ese_e2">— E2 · Estrannaise (pg/mL, axe gauche)</string>

View File

@ -30,6 +30,8 @@
<string name="chart_30j">30 days</string>
<string name="show_labs">Labs</string>
<string name="show_forecast">Forecast</string>
<string name="show_extrema">Peaks / troughs</string>
<string name="legend_extrema">▲▼ estimated peaks &amp; troughs (per curve)</string>
<string name="back_to_present">Back to now</string>
<string name="drag_hint">Drag the chart to the right to travel into the past.</string>
<string name="legend_ese_e2">— E2 · Estrannaise (pg/mL, left axis)</string>

View File

@ -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())
}
}

View File

@ -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)
}
}

View File

@ -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.

View File

@ -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)

View File

@ -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**

View File

@ -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