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 e8f87c096d
commit d9471cadaf
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
> 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)
- **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)
@ -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**
(switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses),
**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),
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
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 ;

View File

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

View File

@ -117,7 +117,14 @@ object PharmacokineticEngine {
treatments: List<Treatment>,
doseLogs: List<DoseLog>,
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 {
val batemanCache = HashMap<Long, BatemanParams>()
var total = 0.0
@ -133,7 +140,11 @@ object PharmacokineticEngine {
val c = concentrationOfDose(
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
@ -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 —
* 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 {
val u = unit.lowercase().replace(" ", "")
return when {
u.contains("dl") -> value / 100.0
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
else -> value
}
@ -183,7 +199,9 @@ object PharmacokineticEngine {
stepMs: Long = HOUR_MS,
tConfig: TConfig,
/** 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> {
if (treatments.isEmpty() || doseLogs.isEmpty() || endMs <= startMs) return emptyList()
@ -210,7 +228,11 @@ object PharmacokineticEngine {
val dtH = (t - dose.timestamp) / 3_600_000.0
if (dtH > cutoff) continue
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)))
@ -267,17 +289,77 @@ object PharmacokineticEngine {
}
/**
* Calibration automatique (option « Auto-calibration » des Paramètres) :
* 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
* stockées — les traitements renvoyés sont des copies à utiliser uniquement pour
* l'affichage des courbes.
* Calibration PAR PÉRIODE D'ESTER (v1.2.1).
*
* Principe : chaque lab est attribué à la période d'injection dans laquelle il
* tombe = la dernière dose E2 antérieure ou égale au lab (une prise de sang
* 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(
val treatments: List<Treatment>,
val tConfig: TConfig,
/** Nombre de traitements dont le facteur d'échelle a été ajusté. */
val calibratedTreatments: Int,
/** Facteur d'échelle par ester ("EV"/"EU"/"EEN"), recalculé depuis les labs. */
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
)
@ -291,20 +373,13 @@ object PharmacokineticEngine {
val e2Labs = labs.filter { it.marker.equals("E2", true) }
val tLabs = labs.filter { it.marker.equals("T", true) }
var calibrated = 0
val newTreatments = treatments.map { tr ->
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)
val esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs)
val newT = computeTConfigCalibration(tLabs, treatments, doseLogs, tConfig)
return AutoCalibrated(
treatments = newTreatments,
treatments = treatments,
tConfig = newT ?: tConfig,
calibratedTreatments = calibrated,
esterScales = esterScales,
calibratedEsters = esterScales.size,
tRecalibrated = newT != null
)
}

View File

@ -87,6 +87,16 @@ fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
HomeScreen(
openLogDoseForTreatmentId = if (initialOpenLogDose) initialTreatmentId else null,
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") }
)
}

View File

@ -23,33 +23,64 @@ import androidx.compose.ui.unit.dp
import com.hormonetrack.R
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)
@Composable
fun LabDialog(
existing: LabResult? = null,
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 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 valueText by remember { mutableStateOf("") }
var unit by remember { mutableStateOf("pg/mL") }
var valueText by remember(existing) {
mutableStateOf(existing?.let { formatLabValue(it.value) } ?: "")
}
var unit by remember(existing) { mutableStateOf(existing?.unit ?: "pg/mL") }
var unitExpanded by remember { mutableStateOf(false) }
var dateTime by remember { mutableStateOf(java.time.LocalDateTime.now()) }
var notes by remember { mutableStateOf("") }
var dateTime by remember(existing) {
mutableStateOf(
existing?.let { localDateTimeFromMs(it.timestamp) } ?: java.time.LocalDateTime.now()
)
}
var notes by remember(existing) { mutableStateOf(existing?.notes ?: "") }
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
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)) }
},
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 = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
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) ;
// les valeurs stockées ne sont jamais modifiées, on ne travaille que sur des copies.
val effective by produceState(
treatments to tConfig,
treatments, doseLogs, labResults, tConfig, autoCalibrate
// Auto-calibration (v1.2.1) : facteurs d'échelle PAR PÉRIODE D'ESTER + modèle T,
// recalculés à la volée depuis les labs. null = option désactivée.
val effectiveAuto by produceState<PharmacokineticEngine.AutoCalibrated?>(
null, treatments, doseLogs, labResults, tConfig, autoCalibrate
) {
value = if (!autoCalibrate) {
treatments to tConfig
null
} else {
withContext(Dispatchers.Default) {
val r = PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, tConfig)
r.treatments to r.tConfig
PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, tConfig)
}
}
}
@ -125,13 +123,16 @@ fun ChartScreen() {
// Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS")
val curves by produceState(
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) {
val list = mutableListOf<Pair<String, List<LevelPoint>>>()
if (showEse || showTfs) {
// Valeurs actuelles (traitement + TConfig), auto-calibrées si l'option est active
val (effTreatments, effTConfig) = effective
// Valeurs actuelles : échelles par ester si auto-calibration active,
// 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
val forecastDoses = if (showForecast) {
effTreatments.flatMap {
@ -143,7 +144,8 @@ fun ChartScreen() {
list.add(
"ESE" to PharmacokineticEngine.computeCurve(
effTreatments, allDoses, startMs, endMs,
tConfig = effTConfig, modelOverride = "ESE"
tConfig = effTConfig, modelOverride = "ESE",
scalePerEster = effScales
)
)
}
@ -151,7 +153,8 @@ fun ChartScreen() {
list.add(
"TFS" to PharmacokineticEngine.computeCurve(
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(
openLogDoseForTreatmentId: Long?,
onOpenSettings: () -> Unit,
onOpenCharts: () -> Unit,
onOpenTreatment: (Long) -> Unit
) {
val container = LocalAppContainer.current
@ -87,15 +88,17 @@ fun HomeScreen(
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
// Données « effectives » : si l'option auto-calibration est active, on recalcule
// à la volée les facteurs d'échelle et le modèle T depuis les labs (affichage seul)
val effective by produceState(treatments to tConfig, treatments, doseLogs, labResults, tConfig, autoCalibrate) {
// Auto-calibration (v1.2.1) : facteurs d'échelle PAR PÉRIODE D'ESTER + modèle T,
// recalculés à la volée depuis les labs. null = option désactivée (échelles
// 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) {
treatments to tConfig
null
} else {
withContext(Dispatchers.Default) {
val r = PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, tConfig)
r.treatments to r.tConfig
PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, 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) {
val end = System.currentTimeMillis()
value = PharmacokineticEngine.computeCurve(
effective.first, doseLogs,
effectiveAuto?.treatments ?: treatments,
doseLogs,
startMs = end - 24 * HOUR_MS,
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)) {
Text(
stringResource(R.string.home_chart_title),
@ -218,6 +228,23 @@ fun HomeScreen(
.fillMaxWidth()
.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
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -50,6 +51,7 @@ fun LabsScreen() {
val labs by repo.allLabResults.collectAsState(initial = emptyList())
var showLabDialog by remember { mutableStateOf(false) }
var toEdit by remember { mutableStateOf<LabResult?>(null) }
var toDelete by remember { mutableStateOf<LabResult?>(null) }
val grouped = remember(labs) {
@ -87,6 +89,7 @@ fun LabsScreen() {
}
items(results, key = { it.id }) { lab ->
ListItem(
modifier = Modifier.clickable { toEdit = lab },
headlineContent = {
Text("${lab.value} ${lab.unit}")
},
@ -111,10 +114,14 @@ fun LabsScreen() {
}
}
if (showLabDialog) {
if (showLabDialog || toEdit != null) {
LabDialog(
onDismiss = { showLabDialog = false },
onConfirm = { marker, value, unit, ts, notes ->
existing = toEdit,
onDismiss = {
showLabDialog = false
toEdit = null
},
onConfirmCreate = { marker, value, unit, ts, notes ->
showLabDialog = false
CoroutineScope(Dispatchers.IO).launch {
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))
// --- 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()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.t_model_title), style = MaterialTheme.typography.titleMedium)
@ -247,32 +274,6 @@ fun SettingsScreen(onBack: () -> Unit) {
}
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 ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {

View File

@ -19,6 +19,9 @@
<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="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>
<!-- Charts -->
@ -49,6 +52,7 @@
<!-- Labs -->
<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_value">Valeur</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="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="auto_calibrate">Calibration automatique</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">Calibration automatique (E2 + T)</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_enable">Simuler les doses à venir</string>

View File

@ -19,6 +19,9 @@
<string name="next_dose">Next dose</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_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>
<!-- Charts -->
@ -49,6 +52,7 @@
<!-- Labs -->
<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_value">Value</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="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="auto_calibrate">Auto-calibration</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">Auto-calibration (E2 + T)</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_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
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 doses = listOf(
DoseLog(treatmentId = 1, timestamp = BASE - 7 * 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 predicted = PharmacokineticEngine.e2At(listOf(tr), doses, t)
val labs = listOf(
@ -119,11 +119,18 @@ class V120FeaturesTest {
)
val cfg = TConfig()
val result = PharmacokineticEngine.autoCalibrated(listOf(tr), doses, labs, cfg)
assertEquals(0.73, result.treatments.first().scaleFactor, 0.01)
assertTrue(result.calibratedTreatments == 1)
assertEquals(0.73, result.esterScales["EEN"]!!, 0.01)
assertTrue(result.calibratedEsters == 1)
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, 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
@ -131,6 +138,47 @@ class V120FeaturesTest {
val tr = eenTreatment()
val result = PharmacokineticEngine.autoCalibrated(listOf(tr), emptyList(), emptyList(), TConfig())
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
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)

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 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.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
(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,
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)` :
`ratio_i = lab.value / Σ contributions du traitement seul (scale=1) à t_lab`
→ **médiane** des ratios (garde : prédiction > 0.5 pg/mL), arrondi 2 décimales.
C'est l'automatisation de la colonne « Scale factor » du `.ods`. Déclenchable depuis
l'éditeur de traitement ; valeur éditable manuellement.
**Automatique** (`computeEsterScaleFactors` + `scalePerEster`, option « Auto-calibration ») :
- chaque lab est **attribué à la période d'injection dans laquelle il tombe** =
dernière dose E2 ≤ lab (une prise de sang reflète d'abord l'injection qui précède) ;
- ratio = lab ÷ prédiction **non calibrée** (toutes doses superposées, scaleFactor forcé 1) ;
- 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
@ -277,7 +297,7 @@ Type de retour : `LevelPoint(timestamp, e2, t)`.
## 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 =
`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,
calibration T avec labs en ng/dL. **En cas de nouveau bug remonté par l'utilisatrice :
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
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
lab planté, T recalibré, **originaux non modifiés** ; inchangée sans lab utilisable).
sans override = modèle du traitement), auto-calibration v1.2.1 (facteur **par ester**
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
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
- `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
(+ FAB), mini-chart 24 h (multi-séries via `ChartSeries`), disclaimer ; données
auto-calibrées si l'option est active ; rafraîchissement `tick` 60 s
(+ FAB), mini-chart 24 h (multi-séries via `ChartSeries`) **cliquable → écran
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**
(`detectHorizontalDragGestures` — tirer vers la droite remonte dans le passé,
`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
traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression
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 / ⏰,
badge inactif), FAB → éditeur
- `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 +
TimePicker **centré** + avertissement alarmes exactes) ; switch actif ; save →
insert/update + schedule/cancel ; delete avec confirmation ; `createdAt` préservé
- `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ; réglages
T + calibration manuelle ; **option Calibration automatique (switch, désactivée par
défaut)** ; statut alarmes exactes + bouton d'octroi ; Export/Import JSON ; à propos
- `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ;
**« Calibration automatique (E2 + T) » — UNE option** (par période d'ester + modèle T,
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,
LocalDateTime, **horloge centrée**), `DoseDialog` (create/edit + override d'ester),
`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)
---
*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
(traitement, dose, date/heure, notes et ester tout ça éditable) — pas besoin de
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 »
→ calcule le facteur d'échelle = médiane(lab ÷ prédiction), comme le « Scale factor » du `.ods`
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)
- **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
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