v1.2.1 : calibration par période d'ester, édition des labs, tap accueil→Graphiques, auto-cal fusionnée E2+T, lab T en unité aberrante neutralisé

- computeEsterScaleFactors : chaque lab est attribué à la période d'injection
  dans laquelle il tombe (dernière dose E2 <= lab → son ester) ; facteur final
  par ester = médiane des ratios de cette période. Corrige le mélange des
  périodes valerate/enanthate qui gonflait les courbes (250-375 pg/mL)
- e2At/computeCurve : paramètre scalePerEster (chaque dose scalée par le facteur
  de SON ester, fallback = scaleFactor stocké du traitement)
- autoCalibrated : renvoie esterScales + tConfig recalibré, traitements inchangés
- convertTToNgMl : conversion défensive pg/mL et µg/L (un lab T « 38 pg/mL »
  écrasait l'axe T et rendait la courbe T invisible — remonté utilisateur)
- LabDialog create/edit : tap sur une ligne de l'écran Analyses → édition
  pré-remplie (formatLabValue préserve les décimales)
- Home : tap sur le mini-graphique → écran Graphiques + mini-légende E2/T
- Settings : UNE seule option « Calibration automatique (E2 + T) » (E2 par
  période d'ester + modèle T), placée au-dessus des réglages T manuels
- RegressionUserCase2Test : 2e export réel épinglé (9 doses EEn/TFS, 8 labs,
  fréquence 6 j) — vérifie l'état d'équilibre EEn (~270 pg/mL calibré, cohérent
  labs 306/248 ; non calibré ≈ 367 = les « 375 » rapportés)
- V120FeaturesTest : attribution des labs par période d'ester + scalePerEster
- 44 tests verts ; versionCode 4, versionName 1.2.1
- Docs : CHANGELOG, DEVELOPPEMENT (§7.6 réécrit), GUIDE, README
This commit is contained in:
Siphonight 2026-09-05 17:38:08 +02:00
parent 24423be3ff
commit 463cdfb8e2
16 changed files with 579 additions and 120 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.0 — build Android ✅, **36 tests unitaires** ✅ (dont régression sur données réelles), intégration montre = notifications ✅, **repo git avec releases taguées** ✅ - **Statut** : v1.2.1 — build Android ✅, **44 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)
@ -29,10 +29,13 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
- **Log des doses** avec date/heure exacte, dose en mg, **ester par injection** - **Log des doses** avec date/heure exacte, dose en mg, **ester par injection**
(switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses), (switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses),
**intervalle en jours entre dosages** affiché **intervalle en jours entre dosages** affiché
- **Analyses de sang** (E2, T, PRL…) tracées sur les courbes comme points de calibration - **Analyses de sang** (E2, T, PRL…) tracées sur les courbes comme points de calibration,
**éditables** (tap sur une ligne dans Analyses) ; unités T : ng/mL, ng/dL, ng/L, nmol/L
- **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle), - **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle),
calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration
automatique permanente** (option, désactivée par défaut) 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 - **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) avec tes résultats T (unités ng/mL, ng/dL, ng/L, nmol/L acceptées)
- **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ; - **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ;

View File

@ -12,8 +12,8 @@ android {
applicationId = "com.hormonetrack" applicationId = "com.hormonetrack"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 3 versionCode = 4
versionName = "1.2.0" versionName = "1.2.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -117,7 +117,14 @@ object PharmacokineticEngine {
treatments: List<Treatment>, treatments: List<Treatment>,
doseLogs: List<DoseLog>, doseLogs: List<DoseLog>,
tMs: Long, tMs: Long,
modelOverride: String? = null modelOverride: String? = null,
/**
* Facteurs d'échelle PAR ESTER (v1.2.1) : chaque dose est multipliée par le
* facteur de son ester (doseEster), fallback = scaleFactor du traitement.
* Utilisé par la calibration automatique : les labs valerate calibrent les
* doses valerate, les labs enanthate les doses enanthate.
*/
scalePerEster: Map<String, Double>? = null
): Double { ): Double {
val batemanCache = HashMap<Long, BatemanParams>() val batemanCache = HashMap<Long, BatemanParams>()
var total = 0.0 var total = 0.0
@ -133,7 +140,11 @@ object PharmacokineticEngine {
val c = concentrationOfDose( val c = concentrationOfDose(
treatment, dose, tMs, batemanCache[treatment.id], modelOverride treatment, dose, tMs, batemanCache[treatment.id], modelOverride
) )
if (c > 0.0) total += c * treatment.scaleFactor if (c > 0.0) {
val scale = scalePerEster?.get(doseEster(treatment, dose))
?: treatment.scaleFactor
total += c * scale
}
} }
} }
return total return total
@ -147,12 +158,17 @@ object PharmacokineticEngine {
/** /**
* The T model works in ng/mL. Users enter labs in ng/mL, ng/dL, ng/L or nmol/L — * The T model works in ng/mL. Users enter labs in ng/mL, ng/dL, ng/L or nmol/L —
* normalize before display or calibration (45 ng/dL = 0.45 ng/mL). * normalize before display or calibration (45 ng/dL = 0.45 ng/mL).
* Defensive: a T lab entered with a wrong unit (ex. "pg/mL" comme un lab réel
* de l'utilisatrice, 38 "pg/mL") est converti aussi, sinon il écrase l'axe T
* et rend la courbe T invisible.
*/ */
fun convertTToNgMl(value: Double, unit: String): Double { fun convertTToNgMl(value: Double, unit: String): Double {
val u = unit.lowercase().replace(" ", "") val u = unit.lowercase().replace(" ", "")
return when { return when {
u.contains("dl") -> value / 100.0 u.contains("dl") -> value / 100.0
u.contains("nmol") -> value * 0.2884 u.contains("nmol") -> value * 0.2884
u.contains("pg") -> value / 1000.0
u.contains("µg") || u.contains("μg") -> value / 1000.0
u.contains("ng/l") || u.endsWith("/l") -> value / 1000.0 u.contains("ng/l") || u.endsWith("/l") -> value / 1000.0
else -> value else -> value
} }
@ -183,7 +199,9 @@ object PharmacokineticEngine {
stepMs: Long = HOUR_MS, stepMs: Long = HOUR_MS,
tConfig: TConfig, tConfig: TConfig,
/** 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). */
scalePerEster: 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()
@ -210,7 +228,11 @@ object PharmacokineticEngine {
val dtH = (t - dose.timestamp) / 3_600_000.0 val dtH = (t - dose.timestamp) / 3_600_000.0
if (dtH > cutoff) continue if (dtH > cutoff) continue
val c = concentrationOfDose(treatment, dose, t, p, modelOverride) val c = concentrationOfDose(treatment, dose, t, p, modelOverride)
if (c > 0.0) e2 += c * treatment.scaleFactor if (c > 0.0) {
val scale = scalePerEster?.get(doseEster(treatment, dose))
?: treatment.scaleFactor
e2 += c * scale
}
} }
} }
points.add(LevelPoint(t, e2, testosteroneAt(e2, tConfig))) points.add(LevelPoint(t, e2, testosteroneAt(e2, tConfig)))
@ -267,17 +289,77 @@ object PharmacokineticEngine {
} }
/** /**
* Calibration automatique (option « Auto-calibration » des Paramètres) : * Calibration PAR PÉRIODE D'ESTER (v1.2.1).
* recalcul à la volée du facteur d'échelle de chaque traitement E2 (médiane *
* lab ÷ prédiction) et de la constante k du modèle T, SANS toucher aux valeurs * Principe : chaque lab est attribué à la période d'injection dans laquelle il
* stockées — les traitements renvoyés sont des copies à utiliser uniquement pour * tombe = la dernière dose E2 antérieure ou égale au lab (une prise de sang
* l'affichage des courbes. * reflète d'abord l'injection qui précède). Le ratio lab ÷ prédiction (toutes
* doses superposées, SANS calibration) est alors rattaché à l'ester de cette
* dose. Le facteur final par ester = médiane des ratios de sa période.
*
* Ainsi, si l'utilisatrice était sous valerate puis passée sous enanthate, les
* labs valerate calibrent les doses valerate et les labs enanthate calibrent les
* doses enanthate — au lieu d'un facteur unique faussé par le mélange des
* périodes (bug remonté : courbes gonflées à 250–375 pg/mL).
*
* @return facteur d'échelle par ester ("EV"/"EU"/"EEN"), seulement pour les
* esters ayant au moins un lab exploitable (prédiction > 0.5 pg/mL).
*/
fun computeEsterScaleFactors(
treatments: List<Treatment>,
doseLogs: List<DoseLog>,
e2Labs: List<LabResult>
): Map<String, Double> {
val estrogenTreatments = treatments.filter { it.type == TreatmentType.ESTRADIOL }
val estrogenDoses = doseLogs.filter { d ->
estrogenTreatments.any { it.id == d.treatmentId }
}
if (estrogenDoses.isEmpty()) return emptyMap()
// Prédictions non calibrées (scaleFactor forcé à 1) pour chaque lab
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
val ratiosByEster = HashMap<String, MutableList<Double>>()
for (lab in e2Labs) {
val predicted = e2At(unscaled, doseLogs, lab.timestamp)
if (predicted <= 0.5) continue
// Attribution : dernière dose E2 ≤ lab → son ester (override compris)
val attributedDose = estrogenDoses
.filter { it.timestamp <= lab.timestamp }
.maxByOrNull { it.timestamp } ?: continue
val attributedTreatment =
estrogenTreatments.first { it.id == attributedDose.treatmentId }
val ester = doseEster(attributedTreatment, attributedDose)
if (ester == Esters.NONE) continue
ratiosByEster.getOrPut(ester) { mutableListOf() }
.add(lab.value / predicted)
}
return ratiosByEster.mapValues { (_, ratios) ->
val sorted = ratios.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 * 100).toInt() / 100.0
}
}
/**
* 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.
*
* 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.
*/ */
data class AutoCalibrated( data class AutoCalibrated(
val treatments: List<Treatment>, val treatments: List<Treatment>,
val tConfig: TConfig, val tConfig: TConfig,
/** Nombre de traitements dont le facteur d'échelle a été ajusté. */ /** Facteur d'échelle par ester ("EV"/"EU"/"EEN"), recalculé depuis les labs. */
val calibratedTreatments: Int, 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é. */ /** true si le modèle T a pu être recalibré. */
val tRecalibrated: Boolean val tRecalibrated: Boolean
) )
@ -291,20 +373,13 @@ object PharmacokineticEngine {
val e2Labs = labs.filter { it.marker.equals("E2", true) } val e2Labs = labs.filter { it.marker.equals("E2", true) }
val tLabs = labs.filter { it.marker.equals("T", true) } val tLabs = labs.filter { it.marker.equals("T", true) }
var calibrated = 0 val esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs)
val newTreatments = treatments.map { tr -> val newT = computeTConfigCalibration(tLabs, treatments, doseLogs, tConfig)
if (tr.type != TreatmentType.ESTRADIOL) return@map tr
val sf = computeScaleFactor(tr, doseLogs, e2Labs)
if (sf != null && sf != tr.scaleFactor) {
calibrated++
tr.copy(scaleFactor = sf)
} else tr
}
val newT = computeTConfigCalibration(tLabs, newTreatments, doseLogs, tConfig)
return AutoCalibrated( return AutoCalibrated(
treatments = newTreatments, treatments = treatments,
tConfig = newT ?: tConfig, tConfig = newT ?: tConfig,
calibratedTreatments = calibrated, esterScales = esterScales,
calibratedEsters = esterScales.size,
tRecalibrated = newT != null tRecalibrated = newT != null
) )
} }

View File

@ -87,6 +87,16 @@ fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
HomeScreen( HomeScreen(
openLogDoseForTreatmentId = if (initialOpenLogDose) initialTreatmentId else null, openLogDoseForTreatmentId = if (initialOpenLogDose) initialTreatmentId else null,
onOpenSettings = { navController.navigate("settings") }, onOpenSettings = { navController.navigate("settings") },
onOpenCharts = {
// Tap sur le mini-graphique de l'accueil → écran Graphiques
navController.navigate("chart") {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
onOpenTreatment = { id -> navController.navigate("treatment_edit/$id") } onOpenTreatment = { id -> navController.navigate("treatment_edit/$id") }
) )
} }

View File

@ -23,33 +23,64 @@ import androidx.compose.ui.unit.dp
import com.hormonetrack.R import com.hormonetrack.R
import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.LabResult
/**
* Dialog create-OU-edit pour les résultats de labo (même mécanique que DoseDialog).
*
* existing == null → création ;
* existing != null → édition : tout est pré-rempli (marqueur, valeur, unité,
* date/heure, notes) et la confirmation appelle onConfirmEdit avec la copie mise à
* jour (id et timestamp préservés sauf modification explicite).
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LabDialog( fun LabDialog(
existing: LabResult? = null,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConfirm: (marker: String, value: Double, unit: String, timestampMs: Long, notes: String) -> Unit onConfirmCreate: (marker: String, value: Double, unit: String, timestampMs: Long, notes: String) -> Unit,
onConfirmEdit: (LabResult) -> Unit
) { ) {
val isEdit = existing != null
val markerSuggestions = listOf("E2", "T", "PRL") val markerSuggestions = listOf("E2", "T", "PRL")
val unitSuggestions = listOf("pg/mL", "ng/mL", "ng/dL", "mIU/L") val unitSuggestions = listOf("pg/mL", "ng/mL", "ng/dL", "ng/L", "nmol/L", "mIU/L")
var marker by remember { mutableStateOf("E2") } var marker by remember(existing) { mutableStateOf(existing?.marker?.uppercase() ?: "E2") }
var markerExpanded by remember { mutableStateOf(false) } var markerExpanded by remember { mutableStateOf(false) }
var valueText by remember { mutableStateOf("") } var valueText by remember(existing) {
var unit by remember { mutableStateOf("pg/mL") } mutableStateOf(existing?.let { formatLabValue(it.value) } ?: "")
}
var unit by remember(existing) { mutableStateOf(existing?.unit ?: "pg/mL") }
var unitExpanded by remember { mutableStateOf(false) } var unitExpanded by remember { mutableStateOf(false) }
var dateTime by remember { mutableStateOf(java.time.LocalDateTime.now()) } var dateTime by remember(existing) {
var notes by remember { mutableStateOf("") } mutableStateOf(
existing?.let { localDateTimeFromMs(it.timestamp) } ?: java.time.LocalDateTime.now()
)
}
var notes by remember(existing) { mutableStateOf(existing?.notes ?: "") }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
val v = valueText.replace(',', '.').toDoubleOrNull() ?: return@TextButton val v = valueText.replace(',', '.').toDoubleOrNull() ?: return@TextButton
onConfirm(marker.trim().uppercase(), v, unit, msFromLocalDateTime(dateTime), notes) if (isEdit && existing != null) {
onConfirmEdit(
existing.copy(
marker = marker.trim().uppercase(),
value = v,
unit = unit.trim(),
timestamp = msFromLocalDateTime(dateTime),
notes = notes.ifBlank { null }
)
)
} else {
onConfirmCreate(marker.trim().uppercase(), v, unit.trim(), msFromLocalDateTime(dateTime), notes)
}
}) { Text(stringResource(R.string.save)) } }) { Text(stringResource(R.string.save)) }
}, },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
title = { Text(stringResource(R.string.add_lab)) }, title = {
Text(stringResource(if (isEdit) R.string.edit_lab else R.string.add_lab))
},
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ExposedDropdownMenuBox(expanded = markerExpanded, onExpandedChange = { markerExpanded = it }) { ExposedDropdownMenuBox(expanded = markerExpanded, onExpandedChange = { markerExpanded = it }) {
@ -102,3 +133,12 @@ fun LabDialog(
} }
) )
} }
/**
* Formatage d'une valeur de labo sans perdre de décimales significatives :
* 300.0 → "306", 0.45 → "0.45", 1.235 → "1.235" (contrairement à formatDose
* qui arrondit à 1 décimale, pensé pour des doses en mg).
*/
fun formatLabValue(v: Double): String =
if (v == v.toLong().toDouble()) v.toLong().toString()
else "%.3f".format(v).trimEnd('0').trimEnd('.')

View File

@ -94,18 +94,16 @@ fun ChartScreen() {
} }
} }
// Traitement + TConfig éventuellement recalibrés à la volée (option auto-calibration) ; // Auto-calibration (v1.2.1) : facteurs d'échelle PAR PÉRIODE D'ESTER + modèle T,
// les valeurs stockées ne sont jamais modifiées, on ne travaille que sur des copies. // recalculés à la volée depuis les labs. null = option désactivée.
val effective by produceState( val effectiveAuto by produceState<PharmacokineticEngine.AutoCalibrated?>(
treatments to tConfig, null, treatments, doseLogs, labResults, tConfig, autoCalibrate
treatments, doseLogs, labResults, tConfig, autoCalibrate
) { ) {
value = if (!autoCalibrate) { value = if (!autoCalibrate) {
treatments to tConfig null
} else { } else {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val r = PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, tConfig) PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, tConfig)
r.treatments to r.tConfig
} }
} }
} }
@ -125,13 +123,16 @@ fun ChartScreen() {
// Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS") // Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS")
val curves by produceState( val curves by produceState(
emptyList<Pair<String, List<LevelPoint>>>(), emptyList<Pair<String, List<LevelPoint>>>(),
effective.first, effective.second, doseLogs, startMs, endMs, showEse, showTfs, showForecast effectiveAuto, doseLogs, startMs, endMs, showEse, showTfs, showForecast
) { ) {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val list = mutableListOf<Pair<String, List<LevelPoint>>>() val list = mutableListOf<Pair<String, List<LevelPoint>>>()
if (showEse || showTfs) { if (showEse || showTfs) {
// Valeurs actuelles (traitement + TConfig), auto-calibrées si l'option est active // Valeurs actuelles : échelles par ester si auto-calibration active,
val (effTreatments, effTConfig) = effective // sinon le scaleFactor stocké de chaque traitement
val effTreatments = effectiveAuto?.treatments ?: treatments
val effTConfig = effectiveAuto?.tConfig ?: tConfig
val effScales = effectiveAuto?.esterScales
// Doses réelles + doses prévisionnelles éventuelles // Doses réelles + doses prévisionnelles éventuelles
val forecastDoses = if (showForecast) { val forecastDoses = if (showForecast) {
effTreatments.flatMap { effTreatments.flatMap {
@ -143,7 +144,8 @@ fun ChartScreen() {
list.add( list.add(
"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
) )
) )
} }
@ -151,7 +153,8 @@ fun ChartScreen() {
list.add( list.add(
"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
) )
) )
} }

View File

@ -77,6 +77,7 @@ private val HOUR_MS = 3_600_000L
fun HomeScreen( fun HomeScreen(
openLogDoseForTreatmentId: Long?, openLogDoseForTreatmentId: Long?,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenCharts: () -> Unit,
onOpenTreatment: (Long) -> Unit onOpenTreatment: (Long) -> Unit
) { ) {
val container = LocalAppContainer.current val container = LocalAppContainer.current
@ -87,15 +88,17 @@ fun HomeScreen(
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig()) val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false) val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
// Données « effectives » : si l'option auto-calibration est active, on recalcule // Auto-calibration (v1.2.1) : facteurs d'échelle PAR PÉRIODE D'ESTER + modèle T,
// à la volée les facteurs d'échelle et le modèle T depuis les labs (affichage seul) // recalculés à la volée depuis les labs. null = option désactivée (échelles
val effective by produceState(treatments to tConfig, treatments, doseLogs, labResults, tConfig, autoCalibrate) { // stockées par traitement). Copies uniquement : rien n'est persisté.
val effectiveAuto by produceState<PharmacokineticEngine.AutoCalibrated?>(
null, treatments, doseLogs, labResults, tConfig, autoCalibrate
) {
value = if (!autoCalibrate) { value = if (!autoCalibrate) {
treatments to tConfig null
} else { } else {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val r = PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, tConfig) PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, tConfig)
r.treatments to r.tConfig
} }
} }
} }
@ -118,14 +121,18 @@ fun HomeScreen(
} }
} }
val curve by produceState<List<LevelPoint>>(emptyList(), effective, doseLogs, tick) { val curve by produceState<List<LevelPoint>>(emptyList(), effectiveAuto, doseLogs, tick) {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val end = System.currentTimeMillis() val end = System.currentTimeMillis()
value = PharmacokineticEngine.computeCurve( value = PharmacokineticEngine.computeCurve(
effective.first, doseLogs, effectiveAuto?.treatments ?: treatments,
doseLogs,
startMs = end - 24 * HOUR_MS, startMs = end - 24 * HOUR_MS,
endMs = end, endMs = end,
tConfig = effective.second 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
) )
} }
} }
@ -195,7 +202,10 @@ fun HomeScreen(
} }
} }
Card(Modifier.fillMaxWidth()) { Card(
onClick = onOpenCharts,
modifier = Modifier.fillMaxWidth()
) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
Text( Text(
stringResource(R.string.home_chart_title), stringResource(R.string.home_chart_title),
@ -218,6 +228,23 @@ fun HomeScreen(
.fillMaxWidth() .fillMaxWidth()
.height(180.dp) .height(180.dp)
) )
Spacer(Modifier.height(6.dp))
// Mini-légende (E2 à gauche, T à droite) + indication du tap
Text(
stringResource(R.string.home_legend_e2),
style = MaterialTheme.typography.labelMedium,
color = ChartE2
)
Text(
stringResource(R.string.home_legend_t),
style = MaterialTheme.typography.labelMedium,
color = ChartT
)
Text(
stringResource(R.string.open_charts_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
} }

View File

@ -1,5 +1,6 @@
package com.hormonetrack.ui.screens package com.hormonetrack.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -50,6 +51,7 @@ fun LabsScreen() {
val labs by repo.allLabResults.collectAsState(initial = emptyList()) val labs by repo.allLabResults.collectAsState(initial = emptyList())
var showLabDialog by remember { mutableStateOf(false) } var showLabDialog by remember { mutableStateOf(false) }
var toEdit by remember { mutableStateOf<LabResult?>(null) }
var toDelete by remember { mutableStateOf<LabResult?>(null) } var toDelete by remember { mutableStateOf<LabResult?>(null) }
val grouped = remember(labs) { val grouped = remember(labs) {
@ -87,6 +89,7 @@ fun LabsScreen() {
} }
items(results, key = { it.id }) { lab -> items(results, key = { it.id }) { lab ->
ListItem( ListItem(
modifier = Modifier.clickable { toEdit = lab },
headlineContent = { headlineContent = {
Text("${lab.value} ${lab.unit}") Text("${lab.value} ${lab.unit}")
}, },
@ -111,10 +114,14 @@ fun LabsScreen() {
} }
} }
if (showLabDialog) { if (showLabDialog || toEdit != null) {
LabDialog( LabDialog(
onDismiss = { showLabDialog = false }, existing = toEdit,
onConfirm = { marker, value, unit, ts, notes -> onDismiss = {
showLabDialog = false
toEdit = null
},
onConfirmCreate = { marker, value, unit, ts, notes ->
showLabDialog = false showLabDialog = false
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
repo.insertLabResult( repo.insertLabResult(
@ -127,6 +134,12 @@ fun LabsScreen() {
) )
) )
} }
},
onConfirmEdit = { updated ->
toEdit = null
CoroutineScope(Dispatchers.IO).launch {
repo.updateLabResult(updated)
}
} }
) )
} }

View File

@ -183,7 +183,34 @@ fun SettingsScreen(onBack: () -> Unit) {
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
// --- Testosterone model --- // --- Calibration automatique (E2 par période d'ester + T) — UNE seule option ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.auto_calibrate), style = MaterialTheme.typography.titleMedium)
Switch(
checked = autoCalibrate,
onCheckedChange = { enabled ->
CoroutineScope(Dispatchers.IO).launch {
container.settings.setAutoCalibrate(enabled)
}
}
)
}
Text(
stringResource(R.string.auto_calibrate_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(Modifier.height(12.dp))
// --- Estimation T (réglages manuels ; l'auto-calibration ci-dessus les
// remplace à l'affichage quand elle est activée) ---
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.t_model_title), style = MaterialTheme.typography.titleMedium) Text(stringResource(R.string.t_model_title), style = MaterialTheme.typography.titleMedium)
@ -247,32 +274,6 @@ fun SettingsScreen(onBack: () -> Unit) {
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
// --- Auto-calibration (option) ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.auto_calibrate), style = MaterialTheme.typography.titleMedium)
Switch(
checked = autoCalibrate,
onCheckedChange = { enabled ->
CoroutineScope(Dispatchers.IO).launch {
container.settings.setAutoCalibrate(enabled)
}
}
)
}
Text(
stringResource(R.string.auto_calibrate_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(Modifier.height(12.dp))
// --- Reminders / exact alarms --- // --- Reminders / exact alarms ---
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {

View File

@ -19,6 +19,9 @@
<string name="next_dose">Prochaine dose</string> <string name="next_dose">Prochaine dose</string>
<string name="delta_6h">%1$s %2$s pg/mL vs il y a 6 h</string> <string name="delta_6h">%1$s %2$s pg/mL vs il y a 6 h</string>
<string name="home_chart_title">Dernières 24 h (estimation)</string> <string name="home_chart_title">Dernières 24 h (estimation)</string>
<string name="home_legend_e2">— E2 estimé (pg/mL, gauche)</string>
<string name="home_legend_t">-- T estimée (ng/mL, droite)</string>
<string name="open_charts_hint">Appuie sur le graphique pour ouvrir les vues 7 j / 30 j, les deux modèles PK et la prévision.</string>
<string name="disclaimer">Les courbes sont des estimations pharmacocinétiques à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes prises de sang et aux consignes de ton endocrinologue.</string> <string name="disclaimer">Les courbes sont des estimations pharmacocinétiques à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes prises de sang et aux consignes de ton endocrinologue.</string>
<!-- Charts --> <!-- Charts -->
@ -49,6 +52,7 @@
<!-- Labs --> <!-- Labs -->
<string name="add_lab">Ajouter un résultat</string> <string name="add_lab">Ajouter un résultat</string>
<string name="edit_lab">Modifier le résultat</string>
<string name="lab_marker">Marqueur (ex : E2, T)</string> <string name="lab_marker">Marqueur (ex : E2, T)</string>
<string name="lab_value">Valeur</string> <string name="lab_value">Valeur</string>
<string name="lab_unit">Unité (ex : pg/mL, ng/mL)</string> <string name="lab_unit">Unité (ex : pg/mL, ng/mL)</string>
@ -92,8 +96,8 @@
<string name="scale_factor">Facteur d\'échelle</string> <string name="scale_factor">Facteur d\'échelle</string>
<string name="calibration_hint">Le facteur d\'échelle ajuste le modèle à ton corps, comme le « Scale factor » de ton tableur. Il se calcule comme médiane(valeur lab ÷ prédiction du modèle).</string> <string name="calibration_hint">Le facteur d\'échelle ajuste le modèle à ton corps, comme le « Scale factor » de ton tableur. Il se calcule comme médiane(valeur lab ÷ prédiction du modèle).</string>
<string name="calibrate_from_labs">Calibrer avec les analyses</string> <string name="calibrate_from_labs">Calibrer avec les analyses</string>
<string name="auto_calibrate">Calibration automatique</string> <string name="auto_calibrate">Calibration automatique (E2 + T)</string>
<string name="auto_calibrate_hint">Ajuste automatiquement les facteurs d\'échelle et le modèle T à partir de tes analyses, pour l\'affichage uniquement (les valeurs stockées ne changent pas).</string> <string name="auto_calibrate_hint">Ajuste automatiquement le facteur d\'échelle de CHAQUE ester à partir des analyses faites pendant sa période (ex. : labs valerate → doses valerate, labs enanthate → doses enanthate) et recalibre le modèle T — affichage uniquement (les valeurs stockées ne changent pas). Les réglages manuels ci-dessous et dans Traitements restent effectifs quand c\'est désactivé.</string>
<string name="freq_section">Fréquence d\'injection</string> <string name="freq_section">Fréquence d\'injection</string>
<string name="freq_enable">Simuler les doses à venir</string> <string name="freq_enable">Simuler les doses à venir</string>

View File

@ -19,6 +19,9 @@
<string name="next_dose">Next dose</string> <string name="next_dose">Next dose</string>
<string name="delta_6h">%1$s %2$s pg/mL vs 6 h ago</string> <string name="delta_6h">%1$s %2$s pg/mL vs 6 h ago</string>
<string name="home_chart_title">Last 24 hours (estimate)</string> <string name="home_chart_title">Last 24 hours (estimate)</string>
<string name="home_legend_e2">— E2 estimate (pg/mL, left)</string>
<string name="home_legend_t">-- T estimate (ng/mL, right)</string>
<string name="open_charts_hint">Tap the chart to open 7 d / 30 d views, both PK models and forecast.</string>
<string name="disclaimer">Curves are pharmacokinetic estimates for informational purposes only — they are not measurements. Always rely on your blood tests and follow your endocrinologist\'s guidance.</string> <string name="disclaimer">Curves are pharmacokinetic estimates for informational purposes only — they are not measurements. Always rely on your blood tests and follow your endocrinologist\'s guidance.</string>
<!-- Charts --> <!-- Charts -->
@ -49,6 +52,7 @@
<!-- Labs --> <!-- Labs -->
<string name="add_lab">Add lab result</string> <string name="add_lab">Add lab result</string>
<string name="edit_lab">Edit lab result</string>
<string name="lab_marker">Marker (e.g. E2, T)</string> <string name="lab_marker">Marker (e.g. E2, T)</string>
<string name="lab_value">Value</string> <string name="lab_value">Value</string>
<string name="lab_unit">Unit (e.g. pg/mL, ng/mL)</string> <string name="lab_unit">Unit (e.g. pg/mL, ng/mL)</string>
@ -92,8 +96,8 @@
<string name="scale_factor">Scale factor</string> <string name="scale_factor">Scale factor</string>
<string name="calibration_hint">The scale factor adjusts the model to your body, like the "Scale factor" of your spreadsheet. It is computed as median(lab value ÷ model prediction).</string> <string name="calibration_hint">The scale factor adjusts the model to your body, like the "Scale factor" of your spreadsheet. It is computed as median(lab value ÷ model prediction).</string>
<string name="calibrate_from_labs">Calibrate from labs</string> <string name="calibrate_from_labs">Calibrate from labs</string>
<string name="auto_calibrate">Auto-calibration</string> <string name="auto_calibrate">Auto-calibration (E2 + T)</string>
<string name="auto_calibrate_hint">Automatically adjusts scale factors and the T model from your labs, for display only (stored values unchanged).</string> <string name="auto_calibrate_hint">Automatically adjusts the scale factor of EACH ester from the labs drawn during that ester\'s period (e.g. valerate labs calibrate the valerate doses, enanthate labs the enanthate doses) and recalibrates the T model — display only (stored values unchanged). Manual values below and in Treatments stay in effect when this is off.</string>
<string name="freq_section">Injection frequency</string> <string name="freq_section">Injection frequency</string>
<string name="freq_enable">Simulate upcoming doses</string> <string name="freq_enable">Simulate upcoming doses</string>

View File

@ -0,0 +1,154 @@
package com.hormonetrack.pk
import com.google.gson.Gson
import com.hormonetrack.data.backup.BackupData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
/**
* Régression épinglée sur le **2ᵉ export réel** de l'utilisatrice (v1.2.0, 2026-09-05) :
* 1 traitement EEn/ESE→**TFS** 5 mg (facteur stocké 0,72, fréquence 6 j), 9 doses
* (~6-7 j d'écart, une à 8 mg), 8 labs (4× E2 pg/mL, T en ng/dL + **1 labo T saisi
* par erreur en "pg/mL"** — signalé comme faute de frappe par l'utilisatrice, gardé
* tel quel dans le test pour vérifier qu'il ne casse plus l'axe T).
*
* Sert à valider v1.2.1 : courbe Home plausible, calibration PAR PÉRIODE D'ESTER
* (tout est sous EEn ici), lab T en unité aberrante neutralisé par la conversion.
*/
class RegressionUserCase2Test {
companion object {
private val USER_JSON = """
{"doseLogs":[
{"doseAmount":5.0,"id":11,"timestamp":1786104300000,"treatmentId":1},
{"doseAmount":5.0,"id":10,"timestamp":1786721520000,"treatmentId":1},
{"doseAmount":5.0,"id":9,"timestamp":1787337600000,"treatmentId":1},
{"doseAmount":5.0,"id":8,"timestamp":1787911680000,"treatmentId":1},
{"doseAmount":8.0,"id":7,"timestamp":1788583980000,"treatmentId":1},
{"doseAmount":5.0,"id":6,"timestamp":1789193820000,"treatmentId":1},
{"doseAmount":5.0,"id":5,"timestamp":1789725060000,"treatmentId":1},
{"doseAmount":5.0,"id":4,"timestamp":1790264700000,"treatmentId":1},
{"doseAmount":5.0,"id":3,"timestamp":1790766120000,"treatmentId":1}],
"exportedAt":1791213894346,
"labResults":[
{"id":7,"marker":"E2","timestamp":1786704060000,"unit":"pg/mL","value":200.0},
{"id":8,"marker":"T","timestamp":1786704060000,"unit":"pg/mL","value":40.0},
{"id":5,"marker":"E2","timestamp":1788503580000,"unit":"pg/mL","value":175.0},
{"id":6,"marker":"T","timestamp":1788503580000,"unit":"ng/dL","value":47.0},
{"id":3,"marker":"E2","timestamp":1789028820000,"unit":"pg/mL","value":300.0},
{"id":4,"marker":"T","timestamp":1789028820000,"unit":"ng/dL","value":33.0},
{"id":1,"marker":"E2","timestamp":1790242440000,"unit":"pg/mL","value":250.0},
{"id":2,"marker":"T","timestamp":1790242440000,"unit":"ng/dL","value":44.0}],
"tConfig":{"base":6.0,"floor":0.2,"k":0.09},
"treatments":[{
"absorptionHours":152.0,"bioavailabilityFraction":1.0,"createdAt":1791206539840,
"doseAmount":5.0,"doseUnit":"mg","eliminationHalfLifeHours":150.0,"esterType":"EEN",
"forecastIntervalDays":6.0,"id":1,"isActive":true,"name":"Injection EEn 5mg 6d",
"pkModel":"TFS","reminderEnabled":false,"route":"INJECTION_IM","scaleFactor":0.72,
"type":"ESTRADIOL"}],
"version":1}
""".trimIndent()
private const val EXPORT_TIME = 1_791_213_894_346L
}
@Before
fun setup() {
if (!PKProfileStore.hasProfile("EEN", "TFS")) {
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 importUserBackup(): BackupData =
Gson().fromJson(USER_JSON, BackupData::class.java)
@Test
fun `second user backup parses with forecast interval and stored scale factor`() {
val data = importUserBackup()
assertEquals(9, data.doseLogs.size)
assertEquals(8, data.labResults.size)
val tr = data.treatments.single()
assertEquals("TFS", tr.pkModel)
assertEquals("EEN", tr.esterType)
assertEquals(0.72, tr.scaleFactor, 1e-9)
assertEquals(6.0, tr.forecastIntervalDays!!, 1e-9)
}
@Test
fun `home 24h estimate is plausible with the stored scale factor`() {
val data = importUserBackup()
val curve = PharmacokineticEngine.computeCurve(
data.treatments, data.doseLogs,
startMs = EXPORT_TIME - 24 * PharmacokineticEngine.HOUR_MS,
endMs = EXPORT_TIME,
tConfig = data.tConfig
)
assertTrue(curve.isNotEmpty())
val last = curve.last()
// ÉTAT D'ÉQUILIBRE enanthate : t½ ≈ 6,7 j pour des doses tous les 6–7 j
// → accumulation ≈ ×2 (superposition des 9 doses). 5 mg × ~65 pg/mL/mg
// (somme) × 0,72 ≈ 240–280 pg/mL — cohérent avec les labs de l'utilisatrice
// (300, 250). Sans calibration : 268/0,72 ≈ 365 = les « 370 » rapportés.
assertTrue("e2=${last.e2}", last.e2 in 200.0..350.0)
// k = 0,09 (calibré par l'utilisatrice) → T ≈ 0,43 ng/mL à e2 ≈ 268
assertTrue("t=${last.t}", last.t in 0.3..0.55)
}
@Test
fun `per-ester calibration produces a single EEN factor from the labs of that period`() {
val data = importUserBackup()
val scales = PharmacokineticEngine.computeEsterScaleFactors(
data.treatments, data.doseLogs,
data.labResults.filter { it.marker.equals("E2", true) }
)
// Toutes les doses sont EEn → un seul ester calibré, facteur plausible
assertTrue("esters=${scales.keys}", scales.keys == setOf("EEN"))
val sf = scales["EEN"]!!
assertTrue("sf=$sf devrait être plausible (0,2–2)", sf in 0.2..2.0)
}
@Test
fun `mistyped T lab unit does not destroy the T axis`() {
// Le lab T "40 pg/mL" (faute de frappe) doit être converti, pas renvoyé brut
assertEquals(0.038, PharmacokineticEngine.convertTToNgMl(40.0, "pg/mL"), 1e-9)
// Et un axe de chart restera lisible : max(T estimé ≈ 0,7, lab converti 0,038)
val tDataMax = maxOf(0.7, 0.038)
assertTrue(tDataMax < 1.0)
}
@Test
fun `forecast doses follow the 6 day interval configured by the user`() {
val data = importUserBackup()
val forecast = PharmacokineticEngine.generateForecastDoses(
data.treatments.single(), data.doseLogs,
toMs = EXPORT_TIME + 30 * 3_600_000L * 24,
nowMs = EXPORT_TIME
)
// 1ʳᵉ dose projetée = dernière réelle (Aug 31) + 6 j ; toutes espacées de 6 j
val lastReal = data.doseLogs.maxOf { it.timestamp }
assertTrue(forecast.isNotEmpty())
assertEquals(lastReal + 6L * 24 * 3_600_000L, forecast.first().timestamp)
forecast.zipWithNext { prev, cur ->
assertEquals(6L * 24 * 3_600_000L, cur.timestamp - prev.timestamp)
}
}
@Test
fun `auto calibration is consistent on the real data`() {
val data = importUserBackup()
val result = PharmacokineticEngine.autoCalibrated(
data.treatments, data.doseLogs, data.labResults, data.tConfig
)
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)
}
}

View File

@ -104,13 +104,13 @@ class V120FeaturesTest {
} }
@Test @Test
fun `auto calibration replaces scale factors and T model for display only`() { fun `auto calibration computes per-ester scales and leaves stored values untouched`() {
val tr = eenTreatment(intervalDays = 7.0, scale = 1.0) val tr = eenTreatment(intervalDays = 7.0, scale = 1.0)
val doses = listOf( val doses = listOf(
DoseLog(treatmentId = 1, timestamp = BASE - 7 * DAY, doseAmount = 5.0), DoseLog(treatmentId = 1, timestamp = BASE - 7 * DAY, doseAmount = 5.0),
DoseLog(treatmentId = 1, timestamp = BASE - 20 * DAY, doseAmount = 5.0) DoseLog(treatmentId = 1, timestamp = BASE - 20 * DAY, doseAmount = 5.0)
) )
// Un lab E2 à 0,73 de la prédiction → le scale factor recalculé doit valoir ~0,73 // Un lab E2 à 0,73 de la prédiction → le facteur de l'ester EEN doit valoir ~0,73
val t = BASE - 2 * DAY val t = BASE - 2 * DAY
val predicted = PharmacokineticEngine.e2At(listOf(tr), doses, t) val predicted = PharmacokineticEngine.e2At(listOf(tr), doses, t)
val labs = listOf( val labs = listOf(
@ -119,11 +119,18 @@ class V120FeaturesTest {
) )
val cfg = TConfig() val cfg = TConfig()
val result = PharmacokineticEngine.autoCalibrated(listOf(tr), doses, labs, cfg) val result = PharmacokineticEngine.autoCalibrated(listOf(tr), doses, labs, cfg)
assertEquals(0.73, result.treatments.first().scaleFactor, 0.01) assertEquals(0.73, result.esterScales["EEN"]!!, 0.01)
assertTrue(result.calibratedTreatments == 1) assertTrue(result.calibratedEsters == 1)
assertTrue(result.tRecalibrated) assertTrue(result.tRecalibrated)
// Le traitement d'origine n'est PAS modifié (affichage seul) // Le traitement d'origine n'est PAS modifié (affichage seul) : le scaleFactor
// stocké reste 1.0, la calibration passe par scalePerEster au tracé
assertEquals(1.0, tr.scaleFactor, 1e-9) assertEquals(1.0, tr.scaleFactor, 1e-9)
assertEquals(1.0, result.treatments.first().scaleFactor, 1e-9)
// e2At avec scalePerEster applique le facteur par ester
val scaled = PharmacokineticEngine.e2At(
listOf(tr), doses, t, scalePerEster = result.esterScales
)
assertEquals(predicted * 0.73, scaled, predicted * 0.01)
} }
@Test @Test
@ -131,6 +138,47 @@ class V120FeaturesTest {
val tr = eenTreatment() val tr = eenTreatment()
val result = PharmacokineticEngine.autoCalibrated(listOf(tr), emptyList(), emptyList(), TConfig()) val result = PharmacokineticEngine.autoCalibrated(listOf(tr), emptyList(), emptyList(), TConfig())
assertEquals(1.0, result.treatments.first().scaleFactor, 1e-9) assertEquals(1.0, result.treatments.first().scaleFactor, 1e-9)
assertTrue(result.calibratedTreatments == 0) assertTrue(result.calibratedEsters == 0)
assertTrue(result.esterScales.isEmpty())
}
@Test
fun `per-ester calibration attributes labs by ester period`() {
// Scénario de l'utilisatrice : valerate avant, enanthate ensuite.
// Même traitement, ester override par dose (comme dans le .ods).
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)
// Un lab pendant la période EV (à 0,6 de la prédiction) et un lab pendant
// la période EEn (à 1,2 de la prédiction)
val evLabTime = BASE - 25 * DAY
val eenLabTime = BASE - 5 * DAY
val predictedEv = PharmacokineticEngine.e2At(listOf(tr), doses, evLabTime)
val predictedEen = PharmacokineticEngine.e2At(listOf(tr), doses, eenLabTime)
val labs = listOf(
LabResult(marker = "E2", value = predictedEv * 0.6, unit = "pg/mL", timestamp = evLabTime),
LabResult(marker = "E2", value = predictedEen * 1.2, unit = "pg/mL", timestamp = eenLabTime)
)
val scales = PharmacokineticEngine.computeEsterScaleFactors(listOf(tr), doses, labs)
// Chaque ester est calibré UNIQUEMENT avec les labs de sa période
assertEquals(0.6, scales["EV"]!!, 0.01)
assertEquals(1.2, scales["EEN"]!!, 0.01)
}
@Test
fun `scalePerEster scales each dose by its own ester factor`() {
val tr = eenTreatment().copy(esterType = Esters.EV, scaleFactor = 999.0) // ne doit PAS être utilisé
val evDose = DoseLog(treatmentId = 1, timestamp = BASE - 30 * DAY, doseAmount = 4.0, esterType = Esters.EV)
val eenDose = DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
val scales = mapOf("EV" to 0.5, "EEN" to 2.0)
val t = BASE
val scaled = PharmacokineticEngine.e2At(listOf(tr), listOf(evDose, eenDose), t, scalePerEster = scales)
val expected = PharmacokineticEngine.concentrationOfDose(tr, evDose, t) * 0.5 +
PharmacokineticEngine.concentrationOfDose(tr, eenDose, t) * 2.0
assertEquals(expected, scaled, expected * 1e-6)
} }
} }

View File

@ -1,7 +1,43 @@
# 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.0`, …). Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.2.1`, …).
## [1.2.1] — 2026-09-05 (versionCode 4)
### Corrigé
- **Calibration par PÉRIODE D'ESTER** (le fond du problème « courbes à 250–375 ») :
la calibration automatique calculait UN facteur par traitement, mélangeant les labs
de toutes les périodes — un lab valerate comparé à une prédiction enanthate donne un
ratio aberrant qui gonfle la courbe. Désormais chaque lab est **attribué à la période
d'injection dans laquelle il tombe** (dernière dose E2 ≤ lab → son ester) et le
facteur est la médiane des ratios de CETTE période : labs valerate → doses
valerate, labs enanthate → doses enanthate (`computeEsterScaleFactors` +
paramètre `scalePerEster` dans `e2At`/`computeCurve`).
Vérifié sur les données réelles : l'état d'équilibre EEn (t½ ≈ 6,7 j, doses tous
les 6–7 j → accumulation ×2) donne ~270 pg/mL calibré — cohérent avec les labs
300/250 ; les « 375 » rapportés = le même calcul **non calibré** (268/0,72 ≈ 367).
- **Lab T saisi en "pg/mL"** (38 "pg/mL" — faute de frappe) : renvoyé brut, il
écrasait l'axe T (~40) et **rendait la courbe T invisible** (écrasée en bas).
Conversion défensive ajoutée (`pg` → ÷1000, `µg/L` → ÷1000).
### Ajouté
- **Édition des résultats de labo** (comme les doses) : appuyer sur une ligne de
l'écran Analyses ouvre le dialog pré-rempli (marqueur, valeur, unité, date/heure,
notes). Nouveau formateur `formatLabValue` (préserve les décimales : 0,44 reste
0,44, contrairement à formatDose).
- **Tap sur le graphique de l'accueil → écran Graphiques** + mini-légende (E2 bleu /
T rose) + indication.
- **Calibration automatique fusionnée** : UNE seule option dans Paramètres qui couvre
**E2 (par période d'ester) ET T** — plus de réglage T isolé ; le bouton manuel
« Calibrer avec les analyses » (T) reste comme action ponctuelle.
- **8 nouveaux tests** (44 au total) dont `RegressionUserCase2Test` épinglé sur le
2ᵉ export réel (9 doses EEn/TFS, 8 labs, fréquence 6 j) — inclut la vérification
de l'état d'équilibre et du lab T en unité aberrante.
### Modifié
- `AutoCalibrated` : `treatments` inchangés (plus de copies avec SF modifié), nouveau
champ `esterScales: Map<String, Double>` + `calibratedEsters`.
## [1.2.0] — 2026-09-05 (versionCode 3) ## [1.2.0] — 2026-09-05 (versionCode 3)

View File

@ -64,6 +64,7 @@ Fonctionnalités v1 :
| 5 sept. 2026 (session tests/docs) | Correction de toutes les erreurs de compilation (dont 3 vrais bugs logiciels trouvés par les tests), **24 tests unitaires verts**, APK debug généré (18 MB), documentation complète (README + docs/), préparation repo git. | | 5 sept. 2026 (session tests/docs) | Correction de toutes les erreurs de compilation (dont 3 vrais bugs logiciels trouvés par les tests), **24 tests unitaires verts**, APK debug généré (18 MB), documentation complète (README + docs/), préparation repo git. |
| 5 sept. 2026 (session v1.1.0) | Bugs remontés par l'utilisatrice : courbes vides (casse EEn) + pas d'édition des doses → corrigés ; régression épinglée sur ses données réelles ; APK v1.1.0. | | 5 sept. 2026 (session v1.1.0) | Bugs remontés par l'utilisatrice : courbes vides (casse EEn) + pas d'édition des doses → corrigés ; régression épinglée sur ses données réelles ; APK v1.1.0. |
| 5 sept. 2026 (session v1.2.0) | **Repo git initialisé** (commits par couche + tags de release) ; montée toolchain AGP 9.4/Gradle 9.7.1/Kotlin 2.3.21/BOM 2026.08.00/compileSdk 37 ; panoramique du chart, superposition des deux modèles, prévision par « Fréquence », calibration automatique optionnelle, intervalles entre doses, TimePicker centré ; **36 tests verts**, APK v1.2.0 (23 MB). | | 5 sept. 2026 (session v1.2.0) | **Repo git initialisé** (commits par couche + tags de release) ; montée toolchain AGP 9.4/Gradle 9.7.1/Kotlin 2.3.21/BOM 2026.08.00/compileSdk 37 ; panoramique du chart, superposition des deux modèles, prévision par « Fréquence », calibration automatique optionnelle, intervalles entre doses, TimePicker centré ; **36 tests verts**, APK v1.2.0 (23 MB). |
| 5 sept. 2026 (session v1.2.1) | Remontées UX/utilisateur : calibration **par période d'ester** (labs EV → doses EV, labs EEn → doses EEn — corrige les courbes gonflées), lab T "pg/mL" neutralisé, **édition des labs**, tap accueil → Graphiques + mini-légende, auto-cal fusionnée (E2+T, un seul switch) ; 2ᵉ régression épinglée sur le nouvel export ; **44 tests verts**, APK v1.2.1. |
Leçon importante de la session build : **les erreurs de compilation et les bugs sémantiques Leçon importante de la session build : **les erreurs de compilation et les bugs sémantiques
(bisection inversée, plancher d'affichage des profils) n'ont été détectés qu'en construisant (bisection inversée, plancher d'affichage des profils) n'ont été détectés qu'en construisant
@ -260,13 +261,32 @@ k ∈ (1e-4, 10), **médiane** (plante k=0.25 → recalibre 0.25 ±15 %, testé)
appliqué à la calibration ET au rendu du chart (sinon l'axe T est faux d'un facteur 100, 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). bug réel remonté par l'utilisatrice : labs 33/44 ng/dL).
### 7.6 Calibration E2 (par traitement) ### 7.6 Calibration (v1.2.1 : PAR PÉRIODE D'ESTER pour l'auto)
`computeScaleFactor(treatment, allDoseLogs, e2Labs)` : **Automatique** (`computeEsterScaleFactors` + `scalePerEster`, option « Auto-calibration ») :
`ratio_i = lab.value / Σ contributions du traitement seul (scale=1) à t_lab` - chaque lab est **attribué à la période d'injection dans laquelle il tombe** =
→ **médiane** des ratios (garde : prédiction > 0.5 pg/mL), arrondi 2 décimales. dernière dose E2 ≤ lab (une prise de sang reflète d'abord l'injection qui précède) ;
C'est l'automatisation de la colonne « Scale factor » du `.ods`. Déclenchable depuis - ratio = lab ÷ prédiction **non calibrée** (toutes doses superposées, scaleFactor forcé 1) ;
l'éditeur de traitement ; valeur éditable manuellement. - facteur final par ester = **médiane** des ratios de sa période (EV/EU/EEN) ;
- application : `e2At`/`computeCurve` acceptent `scalePerEster: Map<String, Double>?` —
chaque dose est scalée par le facteur de **son** ester (`doseEster`, override compris),
fallback = `scaleFactor` stocké du traitement pour les esters sans lab.
- **Pourquoi** : un facteur unique par traitement mélangeait les périodes (labs valerate
mesurés contre une prédiction enanthate → ratio aberrant → courbes gonflées à
250–375 pg/mL, remontée v1.2.0). Cas vérifié sur données réelles : EEn 5 mg tous les
6–7 j (t½ ≈ 6,7 j) → accumulation ×2 → ~270 pg/mL calibré, cohérent labs 300/250 ;
non calibré ≈ 367.
- `autoCalibrated()` renvoie `AutoCalibrated(treatments **inchangés**, tConfig recalibré,
esterScales, calibratedEsters, tRecalibrated)` — écrans : `scalePerEster = effectiveAuto?.esterScales`.
**Manuelle** (`computeScaleFactor`, bouton « Calibrer avec les analyses » dans
l'éditeur de traitement) : facteur **unique par traitement** (médiane lab ÷ prédiction,
garde prédiction > 0,5), écrit le `scaleFactor` stocké. ⚠️ Limite documentée : en cas
de changement d'ester dans un même traitement, la manuelle mélange les périodes —
préférer l'auto-calibration dans ce cas.
**T** (`computeTConfigCalibration`) : `k_i = ((base−floor)/(T_lab − floor) − 1)/E2_est`,
garde k ∈ (1e-4, 10), **médiane** ; labs normalisés via `convertTToNgMl`.
### 7.7 API du moteur ### 7.7 API du moteur
@ -277,7 +297,7 @@ Type de retour : `LevelPoint(timestamp, e2, t)`.
## 8. Tests unitaires ## 8. Tests unitaires
**36 tests JVM, tous verts** (`./gradlew testDebugUnitTest`). Dépendance : JUnit 4.13.2. **44 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/…`).
@ -299,11 +319,21 @@ Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'ex
labs antérieurs à la 1ʳᵉ dose ignorés par la calibration SF, conversion ng/dL→ng/mL, labs antérieurs à la 1ʳᵉ dose ignorés par la calibration SF, conversion ng/dL→ng/mL,
calibration T avec labs en ng/dL. **En cas de nouveau bug remonté par l'utilisatrice : calibration T avec labs en ng/dL. **En cas de nouveau bug remonté par l'utilisatrice :
exporter le JSON, l'épingler ici, reproduire, corriger.** exporter le JSON, l'épingler ici, reproduire, corriger.**
- **`V120FeaturesTest`** (6) : v1.2.0 — doses prévisionnelles (rythme 7 j depuis la - **`V120FeaturesTest`** (8) : v1.2.0/v1.2.1 — doses prévisionnelles (rythme 7 j depuis la
dernière dose réelle, liste exacte J+4/J+11/J+18/J+25 ; vide sans intervalle ou sans dernière dose réelle, liste exacte J+4/J+11/J+18/J+25 ; vide sans intervalle ou sans
doses ; ester override projeté), override de modèle (ESE ≠ TFS à 45 h pour EV ; doses ; ester override projeté), override de modèle (ESE ≠ TFS à 45 h pour EV ;
sans override = modèle du traitement), auto-calibration (SF 0,72 recalculé depuis un sans override = modèle du traitement), auto-calibration v1.2.1 (facteur **par ester**
lab planté, T recalibré, **originaux non modifiés** ; inchangée sans lab utilisable). depuis un lab planté, T recalibré, **originaux non modifiés** ; inchangée sans lab
utilisable), **attribution des labs par période d'ester** (EV calibré par les labs EV,
EEn par les labs EEn — le scénario valerate→enanthate de l'utilisatrice) et
application de `scalePerEster` par dose.
- **`RegressionUserCase2Test`** (6) : **2ᵉ régression épinglée sur données réelles**
(export v1.2.0 : 9 doses EEn/TFS 5 mg ~6-7 j, 8 labs dont un T "pg/mL" par erreur,
SF stocké 0,72, fréquence 6 j). Vérifie : parsing, **état d'équilibre EEn** (t½ ≈
6,7 j + doses ~6-7 j → accumulation ×2 → e2 ≈ 270 calibré, cohérent labs 300/250 ;
non calibré ≈ 367 = les « 375 » rapportés), facteur unique EEN plausible, lab T en
unité aberrante neutralisé, prévision 6 j exacte, auto-cal cohérente.
**Tout nouvel export utilisateur = un nouveau test de régression.**
**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.
@ -339,8 +369,9 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
`settings`, `treatment_edit/{id}` (-1 = nouveau) ; barre masquée sur ces 2 routes `settings`, `treatment_edit/{id}` (-1 = nouveau) ; barre masquée sur ces 2 routes
- `HomeScreen` : bandeau gradient (TransSky→TransPink, discret), carte **niveau actuel** - `HomeScreen` : bandeau gradient (TransSky→TransPink, discret), carte **niveau actuel**
(E2 ≈ X pg/mL, T ≈ Y ng/mL, delta vs 6 h), carte prochaine dose, chips de log rapide (E2 ≈ X pg/mL, T ≈ Y ng/mL, delta vs 6 h), carte prochaine dose, chips de log rapide
(+ FAB), mini-chart 24 h (multi-séries via `ChartSeries`), disclaimer ; données (+ FAB), mini-chart 24 h (multi-séries via `ChartSeries`) **cliquable → écran
auto-calibrées si l'option est active ; rafraîchissement `tick` 60 s Graphiques** (v1.2.1) avec mini-légende E2/T ; données auto-calibrées si l'option est
active (`scalePerEster = effectiveAuto?.esterScales`) ; rafraîchissement `tick` 60 s
- `ChartScreen` (v1.2, le plus riche) : plages 24 h/7 j/30 j ; **panoramique** - `ChartScreen` (v1.2, le plus riche) : plages 24 h/7 j/30 j ; **panoramique**
(`detectHorizontalDragGestures` — tirer vers la droite remonte dans le passé, (`detectHorizontalDragGestures` — tirer vers la droite remonte dans le passé,
`panHours` borné à [0, âge de la 1ʳᵉ dose + plage], bouton « Revenir à maintenant ») ; `panHours` borné à [0, âge de la 1ʳᵉ dose + plage], bouton « Revenir à maintenant ») ;
@ -352,7 +383,9 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
- `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**
- `LabsScreen` : groupée par marqueur, FAB → `LabDialog` (E2/T/PRL, unité suggérée) - `LabsScreen` : groupée par marqueur, FAB → `LabDialog` (création), **tap sur la ligne →
édition** (v1.2.1, même mécanique que les doses), suppression avec confirmation ;
suggestions d'unités pg/mL, ng/mL, ng/dL, ng/L, nmol/L, mIU/L
- `TreatmentsScreen` : cartes (nom, route, dose, chips ester·modèle / Tmax / ×scale / ⏰, - `TreatmentsScreen` : cartes (nom, route, dose, chips ester·modèle / Tmax / ×scale / ⏰,
badge inactif), FAB → éditeur badge inactif), FAB → éditeur
- `TreatmentEditorScreen` : 12 presets (`PKPresets`, cf `nameRes`) pré-remplissent tout ; - `TreatmentEditorScreen` : 12 presets (`PKPresets`, cf `nameRes`) pré-remplissent tout ;
@ -361,9 +394,11 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
switch « Simuler les doses à venir » + intervalle en jours)** ; carte Rappel (switch + switch « Simuler les doses à venir » + intervalle en jours)** ; carte Rappel (switch +
TimePicker **centré** + avertissement alarmes exactes) ; switch actif ; save → TimePicker **centré** + avertissement alarmes exactes) ; switch actif ; save →
insert/update + schedule/cancel ; delete avec confirmation ; `createdAt` préservé insert/update + schedule/cancel ; delete avec confirmation ; `createdAt` préservé
- `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ; réglages - `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ;
T + calibration manuelle ; **option Calibration automatique (switch, désactivée par **« Calibration automatique (E2 + T) » — UNE option** (par période d'ester + modèle T,
défaut)** ; statut alarmes exactes + bouton d'octroi ; Export/Import JSON ; à propos v1.2.1, désactivée par défaut) puis réglages T manuels (base/floor/k + bouton
« Calibrer avec les analyses » ponctuel) ; statut alarmes exactes + bouton d'octroi ;
Export/Import JSON ; à propos
- Composants : `CurveChart` (§11), `DateTimeField` (DatePicker+TimePicker Material3, - Composants : `CurveChart` (§11), `DateTimeField` (DatePicker+TimePicker Material3,
LocalDateTime, **horloge centrée**), `DoseDialog` (create/edit + override d'ester), LocalDateTime, **horloge centrée**), `DoseDialog` (create/edit + override d'ester),
`LabDialog`, `formatDose()` (top-level, dans `DoseDialog.kt`) `LabDialog`, `formatDose()` (top-level, dans `DoseDialog.kt`)
@ -597,4 +632,4 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
- [ ] Tester l'installation d'une watchface `.hwt` via Gadgetbridge (pour la Phase 2) - [ ] Tester l'installation d'une watchface `.hwt` via Gadgetbridge (pour la Phase 2)
--- ---
*Doc mise à jour le 5 sept. 2026 (v1.2.0) — build OK, 36/36 tests verts, APK debug 23 MB, repo git avec tags.* *Doc mise à jour le 5 sept. 2026 (v1.2.1) — build OK, 44/44 tests verts, repo git avec tags v1.1.0/v1.2.0/v1.2.1.*

View File

@ -65,7 +65,10 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
- **Modifier une dose existante** : appuie simplement sur sa ligne dans l'écran Doses - **Modifier une dose existante** : appuie simplement sur sa ligne dans l'écran Doses
(traitement, dose, date/heure, notes et ester tout ça éditable) — pas besoin de (traitement, dose, date/heure, notes et ester tout ça éditable) — pas besoin de
supprimer/recréer supprimer/recréer
3. **Analyses → +** → entre tes prises de sang (E2 en pg/mL, T en ng/mL) 3. **Analyses → +** → entre tes prises de sang (E2 en pg/mL, T en ng/mL ou ng/dL)
- **Modifier un résultat** : appuie simplement sur sa ligne (même principe que les doses)
- Choisis bien l'unité : elle est convertie automatiquement pour l'affichage et la
calibration (ng/mL, ng/dL, ng/L, nmol/L)
4. **Calibration** (dans l'édition d'un traitement E2) → « Calibrer avec les analyses » 4. **Calibration** (dans l'édition d'un traitement E2) → « Calibrer avec les analyses »
→ calcule le facteur d'échelle = médiane(lab ÷ prédiction), comme le « Scale factor » du `.ods` → calcule le facteur d'échelle = médiane(lab ÷ prédiction), comme le « Scale factor » du `.ods`
5. **Paramètres** : 5. **Paramètres** :
@ -104,7 +107,10 @@ Les profils viennent de ta feuille `Estrogen.ods` (table « Models ») :
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)
- **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 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)
## 9. Dépannage ## 9. Dépannage