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 c71a50429d
commit 34a7b588be
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 > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue. > sang et aux consignes de ton endocrinologue.
- **Statut** : v1.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) - **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) - **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) - **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
@ -18,7 +18,8 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
## Fonctionnalités ## Fonctionnalités
- **Courbes estimées heure par heure** : E2 (pg/mL) et T (ng/mL), vue 24 h / 7 j / 30 j, - **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`) : - **Deux modèles PK au choix, superposables** (issus de la feuille `Estrogen.ods`) :
**Estrannaise (EstraNase)** et **Transfem Science** pour les injections EV / EU / EEn, **Estrannaise (EstraNase)** et **Transfem Science** pour les injections EV / EU / EEn,
affichés côte à côte avec toggles indépendants 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 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 avec les labs de sa période** (labs valerate → doses valerate, labs enanthate → doses
enanthate) et recalibre le modèle T enanthate) et recalibre le modèle T
- **Estimation T** empirique `T = plancher + (base − plancher) ÷ (1 + k·E2)`, calibrable - **Estimation T** empirique `T = plancher + (base − plancher) ÷ (1 + k·E2)`, avec
avec tes résultats T (unités ng/mL, ng/dL, ng/L, nmol/L acceptées) **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 ; - **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ;
les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health)
- **Sauvegarde/Restauration JSON** complète (traitements + doses + analyses + réglages T) - **Sauvegarde/Restauration JSON** complète (traitements + doses + analyses + réglages T)

View File

@ -12,8 +12,8 @@ android {
applicationId = "com.hormonetrack" applicationId = "com.hormonetrack"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 5 versionCode = 6
versionName = "1.2.2" versionName = "1.2.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -178,18 +178,42 @@ object PharmacokineticEngine {
treatments: List<Treatment>, treatments: List<Treatment>,
doseLogs: List<DoseLog>, doseLogs: List<DoseLog>,
tMs: Long, tMs: Long,
tConfig: TConfig tConfig: TConfig,
tKPerEster: Map<String, Double>? = null
): LevelPoint { ): LevelPoint {
val e2 = e2At(treatments, doseLogs, tMs) 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( fun currentLevel(
treatments: List<Treatment>, treatments: List<Treatment>,
doseLogs: List<DoseLog>, doseLogs: List<DoseLog>,
tConfig: TConfig, tConfig: TConfig,
tKPerEster: Map<String, Double>? = null,
nowMs: Long = System.currentTimeMillis() nowMs: Long = System.currentTimeMillis()
): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig) ): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig, tKPerEster)
fun computeCurve( fun computeCurve(
treatments: List<Treatment>, treatments: List<Treatment>,
@ -201,7 +225,13 @@ object PharmacokineticEngine {
/** Force un modèle PK (ESE/TFS) pour les traitements par profil. */ /** Force un modèle PK (ESE/TFS) pour les traitements par profil. */
modelOverride: String? = null, modelOverride: String? = null,
/** Facteurs d'échelle par ester (calibration automatique, cf e2At). */ /** 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> { ): List<LevelPoint> {
if (treatments.isEmpty() || doseLogs.isEmpty() || endMs <= startMs) return emptyList() if (treatments.isEmpty() || doseLogs.isEmpty() || endMs <= startMs) return emptyList()
@ -216,10 +246,25 @@ object PharmacokineticEngine {
val batemanCache = HashMap<Long, BatemanParams>() val batemanCache = HashMap<Long, BatemanParams>()
relevantTreatments.forEach { batemanCache[it.id] = batemanParams(it) } 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>() val points = mutableListOf<LevelPoint>()
var t = searchStart var t = searchStart
while (t <= endMs) { while (t <= endMs) {
var e2 = 0.0 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) { for (treatment in relevantTreatments) {
val cutoff = cutoffHours(treatment) val cutoff = cutoffHours(treatment)
val p = batemanCache[treatment.id] 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 t += stepMs
} }
return points return points
@ -344,14 +389,66 @@ object PharmacokineticEngine {
} }
/** /**
* Calibration automatique (option « Auto-calibration » des Paramètres), v1.2.1 : * Calibration du k du modèle T **PAR PÉRIODE D'ESTER** (v1.2.3).
* - facteurs d'échelle **par période d'ester** ([computeEsterScaleFactors]) ;
* - recalibrage du modèle T depuis les labs T.
* *
* Tout est renvoyé en copies, UNIQUEMENT pour l'affichage : les traitements et * La suppression de la testostérone n'est pas la même selon l'ester (un valerate
* réglages stockés ne sont jamais modifiés. Les écrans branchent le résultat via * donne des pics hauts et courts, un enanthate un plateau plus doux) : chaque lab
* le paramètre `scalePerEster` du moteur ; pour un ester sans lab, le fallback * T est attribué à la période d'injection dans laquelle il tombe (dernière dose
* naturel est le scaleFactor stocké du traitement. * 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( data class AutoCalibrated(
val treatments: List<Treatment>, val treatments: List<Treatment>,
@ -360,8 +457,10 @@ object PharmacokineticEngine {
val esterScales: Map<String, Double>, val esterScales: Map<String, Double>,
/** Nombre d'esters calibrés avec au moins un lab exploitable. */ /** Nombre d'esters calibrés avec au moins un lab exploitable. */
val calibratedEsters: Int, val calibratedEsters: Int,
/** true si le modèle T a pu être recalibré. */ /** true si le modèle T a pu être recalibré (au moins un ester avec des labs T). */
val tRecalibrated: Boolean val tRecalibrated: Boolean,
/** k du modèle T par ester, recalculé depuis les labs T. */
val tKPerEster: Map<String, Double>
) )
fun autoCalibrated( fun autoCalibrated(
@ -374,13 +473,16 @@ object PharmacokineticEngine {
val tLabs = labs.filter { it.marker.equals("T", true) } val tLabs = labs.filter { it.marker.equals("T", true) }
val esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs) val esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs)
val newT = computeTConfigCalibration(tLabs, treatments, doseLogs, tConfig) val tKPerEster = computeTKPerEster(
treatments, doseLogs, tLabs, tConfig, scalePerEster = esterScales
)
return AutoCalibrated( return AutoCalibrated(
treatments = treatments, treatments = treatments,
tConfig = newT ?: tConfig, tConfig = tConfig,
esterScales = esterScales, esterScales = esterScales,
calibratedEsters = esterScales.size, calibratedEsters = esterScales.size,
tRecalibrated = newT != null tRecalibrated = tKPerEster.isNotEmpty(),
tKPerEster = tKPerEster
) )
} }
@ -471,4 +573,64 @@ object PharmacokineticEngine {
fun isInjectionRoute(route: AdministrationRoute): Boolean = fun isInjectionRoute(route: AdministrationRoute): Boolean =
route == AdministrationRoute.INJECTION_IM || route == AdministrationRoute.INJECTION_SUBCUT 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( data class ChartOptions(
val showT: Boolean = true, val showT: Boolean = true,
val showLabs: 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() 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) // 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) { /** Triangle ▲ (pic, au-dessus du point) ou ▼ (creux, en dessous), couleur de la série. */
for (i in 0..rows) { 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 val y = padTop + h * i / rows
drawLine(color, Offset(padLeft, y), Offset(padLeft + w, y), strokeWidth = 1f) 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 showEse by remember { mutableStateOf(true) }
var showTfs by remember { mutableStateOf(true) } var showTfs by remember { mutableStateOf(true) }
var showForecast by remember { mutableStateOf(false) } var showForecast by remember { mutableStateOf(false) }
var showExtrema by remember { mutableStateOf(false) }
var chartWidthPx by remember { mutableIntStateOf(0) } var chartWidthPx by remember { mutableIntStateOf(0) }
// « Maintenant » rafraîchi chaque minute pour le repère vertical // « Maintenant » rafraîchi chaque minute pour le repère vertical
@ -145,7 +146,8 @@ fun ChartScreen() {
"ESE" to PharmacokineticEngine.computeCurve( "ESE" to PharmacokineticEngine.computeCurve(
effTreatments, allDoses, startMs, endMs, effTreatments, allDoses, startMs, endMs,
tConfig = effTConfig, modelOverride = "ESE", tConfig = effTConfig, modelOverride = "ESE",
scalePerEster = effScales scalePerEster = effScales,
tKPerEster = effectiveAuto?.tKPerEster
) )
) )
} }
@ -154,7 +156,8 @@ fun ChartScreen() {
"TFS" to PharmacokineticEngine.computeCurve( "TFS" to PharmacokineticEngine.computeCurve(
effTreatments, allDoses, startMs, endMs, effTreatments, allDoses, startMs, endMs,
tConfig = effTConfig, modelOverride = "TFS", tConfig = effTConfig, modelOverride = "TFS",
scalePerEster = effScales scalePerEster = effScales,
tKPerEster = effectiveAuto?.tKPerEster
) )
) )
} }
@ -233,6 +236,11 @@ fun ChartScreen() {
enabled = forecastHorizonHours != null, enabled = forecastHorizonHours != null,
label = { Text(stringResource(R.string.show_forecast)) } 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)) Spacer(Modifier.height(8.dp))
@ -282,7 +290,12 @@ fun ChartScreen() {
series = series, series = series,
e2Labs = e2Labs, e2Labs = e2Labs,
tLabs = tLabs, tLabs = tLabs,
options = ChartOptions(showT = showT, showLabs = showLabs, nowMs = nowMs), options = ChartOptions(
showT = showT,
showLabs = showLabs,
showExtrema = showExtrema,
nowMs = nowMs
),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(320.dp) .height(320.dp)
@ -333,6 +346,13 @@ fun ChartScreen() {
color = LabDot color = LabDot
) )
} }
if (showExtrema) {
Text(
stringResource(R.string.legend_extrema),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text( Text(
stringResource(R.string.drag_hint), stringResource(R.string.drag_hint),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,

View File

@ -131,8 +131,10 @@ fun HomeScreen(
endMs = end, endMs = end,
tConfig = effectiveAuto?.tConfig ?: tConfig, tConfig = effectiveAuto?.tConfig ?: tConfig,
// Calibration par période d'ester : chaque dose est scalée par le // Calibration par période d'ester : chaque dose est scalée par le
// facteur de SON ester (fallback = scaleFactor du traitement) // facteur de SON ester (fallback = scaleFactor du traitement) et le
scalePerEster = effectiveAuto?.esterScales // 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="chart_30j">30 jours</string>
<string name="show_labs">Analyses</string> <string name="show_labs">Analyses</string>
<string name="show_forecast">Prévision</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="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="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> <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="chart_30j">30 days</string>
<string name="show_labs">Labs</string> <string name="show_labs">Labs</string>
<string name="show_forecast">Forecast</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="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="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> <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.calibratedEsters == 1)
assertTrue(result.esterScales["EEN"]!! in 0.2..2.0) assertTrue(result.esterScales["EEN"]!! in 0.2..2.0)
// Le T recalibré reste dans une plage physiologique // Tout est sous EEn → le k T recalibré vit dans la map par ester ;
assertTrue("k=${result.tConfig.k}", result.tConfig.k in 0.01..1.0) // 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()) 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 @Test
fun `per-ester calibration attributes labs by ester period`() { fun `per-ester calibration attributes labs by ester period`() {
// Scénario de l'utilisatrice : valerate avant, enanthate ensuite. // Scénario de l'utilisatrice : valerate avant, enanthate ensuite.

View File

@ -1,7 +1,31 @@
# Changelog — HormoneTrack # Changelog — HormoneTrack
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.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) ## [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) ### 7.5 Courbe T (empirique)
`T(t) = floor + (base − floor) / (1 + k·E2(t))` [ng/mL]. Défauts `TConfig` : `T(t) = floor + (base − floor) / (1 + k(ester actif) · E2_calibrée(t))` [ng/mL].
base 6.0, floor 0.2, k 0.19 (→ T≈0,4 à E2≈150). **Non issu du `.ods`** (qui ne modélise Défauts `TConfig` : base 6.0, floor 0.2, k 0.19 (→ T≈0,4 à E2≈150). **Non issu du
pas la T) — modèle d'inhibition simple, étiqueté « estimation » partout. `.ods`** (qui ne modélise pas la T) — modèle d'inhibition simple, étiqueté
Calibration : `k_i = ((base−floor)/(T_lab − floor) − 1)/E2_est(t_lab)`, garde « estimation » partout.
k ∈ (1e-4, 10), **médiane** (plante k=0.25 → recalibre 0.25 ±15 %, testé).
- **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 — **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) ; `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, pg/mL ÷1000 défensif) ; appliqué à la calibration ET au rendu du chart (sinon l'axe
bug réel remonté par l'utilisatrice : labs 33/44 ng/dL). 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) ### 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 ## 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 = 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/…`). `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 - **`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 ; même timestamp ; timestamps différents séparés ; tri E2 avant T avant autres ;
ordre chronologique décroissant). 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 **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. 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 pointillé) ; **chip Prévision** (doses projetées via `generateForecastDoses`, horizon
= 2× le plus grand intervalle configuré, borné 7–30 j) ; **auto-calibration** branchée = 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 = 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 - `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même
traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression
avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition** 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 - **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 (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) 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 - **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 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 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 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 d'être sous enanthate, tes labs valerate calibrent les doses valerate (E2 **et**
enanthate calibrent les doses enanthate (le mélange des périodes ne fausse plus rien) la suppression T), et inversement pour l'enanthate
## 9. Dépannage ## 9. Dépannage