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:
parent
4731e4c159
commit
5f5244d8eb
10
README.md
10
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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 & 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>
|
||||
|
||||
@ -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 & 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>
|
||||
|
||||
145
app/src/test/java/com/hormonetrack/pk/ExtremaTest.kt
Normal file
145
app/src/test/java/com/hormonetrack/pk/ExtremaTest.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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**
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user