From a4ec1fa71fae8cb012c22983fa716747a23c41b1 Mon Sep 17 00:00:00 2001 From: Siphonight Date: Sat, 5 Sep 2026 16:53:53 +0200 Subject: [PATCH] =?UTF-8?q?Moteur=20v1.2.0=20:=20override=20de=20mod=C3=A8?= =?UTF-8?q?le=20PK,=20doses=20pr=C3=A9visionnelles=20(=C2=AB=20Fr=C3=A9que?= =?UTF-8?q?nce=20=C2=BB),=20calibration=20automatique?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - concentrationOfDose/e2At/computeCurve : paramètre modelOverride (dessiner Estrannaise et Transfem Science simultanément, indépendamment du pkModel stocké) - generateForecastDoses : projette les doses à venir depuis la dernière injection réelle + forecastIntervalDays (jamais persistées, ester override conservé) - autoCalibrated : recalcul à la volée des scale factors et du modèle T depuis les labs (copies uniquement — les valeurs stockées ne changent jamais) - Treatment.forecastIntervalDays + migration Room v1→v2 (ALTER TABLE), sans fallbackToDestructiveMigration (données réelles de l'utilisatrice protégées) - AppSettings.autoCalibrate (DataStore, désactivé par défaut) - V120FeaturesTest : 6 tests (rythme des prévisions, override d'ester projeté, divergence des deux modèles, auto-cal appliquée et préservation des originaux) --- .../java/com/hormonetrack/data/AppDatabase.kt | 25 +++- .../com/hormonetrack/data/model/Treatment.kt | 7 +- .../hormonetrack/pk/PharmacokineticEngine.kt | 115 ++++++++++++++- .../com/hormonetrack/settings/AppSettings.kt | 18 +++ .../com/hormonetrack/pk/V120FeaturesTest.kt | 136 ++++++++++++++++++ 5 files changed, 292 insertions(+), 9 deletions(-) create mode 100644 app/src/test/java/com/hormonetrack/pk/V120FeaturesTest.kt diff --git a/app/src/main/java/com/hormonetrack/data/AppDatabase.kt b/app/src/main/java/com/hormonetrack/data/AppDatabase.kt index 7c98dfa..f60944c 100644 --- a/app/src/main/java/com/hormonetrack/data/AppDatabase.kt +++ b/app/src/main/java/com/hormonetrack/data/AppDatabase.kt @@ -4,6 +4,8 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import com.hormonetrack.data.dao.DoseLogDao import com.hormonetrack.data.dao.LabResultDao import com.hormonetrack.data.dao.TreatmentDao @@ -11,9 +13,17 @@ import com.hormonetrack.data.model.DoseLog import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.Treatment +/** + * Base locale Room. + * + * ⚠️ Toute évolution du schéma = version++ + MIGRATION explicite ci-dessous. + * Ne JAMAIS réintroduire fallbackToDestructiveMigration() : l'utilisatrice a des + * données réelles sur son téléphone, une migration manquante doit planter bruyamment + * plutôt que tout effacer. + */ @Database( entities = [Treatment::class, DoseLog::class, LabResult::class], - version = 1, + version = 2, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { @@ -22,6 +32,17 @@ abstract class AppDatabase : RoomDatabase() { abstract fun labResultDao(): LabResultDao companion object { + + /** + * v1 → v2 (2026-09-05) : ajout de la colonne forecastIntervalDays (nullable, + * REAL) pour la simulation prévisionnelle par fréquence d'injection. + */ + private val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE treatments ADD COLUMN forecastIntervalDays REAL") + } + } + @Volatile private var INSTANCE: AppDatabase? = null @@ -32,7 +53,7 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, "hormonetrack.db" ) - .fallbackToDestructiveMigration() + .addMigrations(MIGRATION_1_2) .build() INSTANCE = instance instance diff --git a/app/src/main/java/com/hormonetrack/data/model/Treatment.kt b/app/src/main/java/com/hormonetrack/data/model/Treatment.kt index 8f1f3c1..7bfd8e7 100644 --- a/app/src/main/java/com/hormonetrack/data/model/Treatment.kt +++ b/app/src/main/java/com/hormonetrack/data/model/Treatment.kt @@ -51,9 +51,14 @@ data class Treatment( val eliminationHalfLifeHours: Float = 24f, val bioavailabilityFraction: Float = 1.0f, - // Calibration: ratio lab_value / model_prediction (like the ODS "Scale factor") + // Calibration : ratio lab_value / model_prediction (comme le « Scale factor » du .ods) val scaleFactor: Double = 1.0, + // Simulation prévisionnelle : si renseigné (en jours), l'app génère des doses + // à venir à partir de la dernière injection réelle (section « Fréquence » + // de l'éditeur). null = pas de prévision. + val forecastIntervalDays: Double? = null, + // Reminder val reminderHour: Int? = null, val reminderMinute: Int? = null, diff --git a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt index cd8a59d..d310dff 100644 --- a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt +++ b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt @@ -67,7 +67,13 @@ object PharmacokineticEngine { treatment: Treatment, dose: DoseLog, queryTimeMs: Long, - bateman: BatemanParams? = null + bateman: BatemanParams? = null, + /** + * Force un modèle PK (ESE/TFS) pour ce calcul, indépendamment du pkModel du + * traitement — utilisé par le graphique pour dessiner les deux modèles côte à + * côte. Ne concerne que les traitements par profil (injections EV/EU/EEn). + */ + modelOverride: String? = null ): Double { val dtH = (queryTimeMs - dose.timestamp) / 3_600_000.0 if (dtH <= 0.0) return 0.0 @@ -78,7 +84,7 @@ object PharmacokineticEngine { if (treatment.usesProfileModel) { val ester = doseEster(treatment, dose) if (ester != Esters.NONE) { - return PKProfileStore.sample(ester, treatment.pkModel, dtH) * mg + return PKProfileStore.sample(ester, modelOverride ?: treatment.pkModel, dtH) * mg } } @@ -107,7 +113,12 @@ object PharmacokineticEngine { // Aggregated levels // ------------------------------------------------------------------ - fun e2At(treatments: List, doseLogs: List, tMs: Long): Double { + fun e2At( + treatments: List, + doseLogs: List, + tMs: Long, + modelOverride: String? = null + ): Double { val batemanCache = HashMap() var total = 0.0 for (treatment in treatments) { @@ -120,7 +131,7 @@ object PharmacokineticEngine { val dtH = (tMs - dose.timestamp) / 3_600_000.0 if (dtH > cutoffHours(treatment)) continue val c = concentrationOfDose( - treatment, dose, tMs, batemanCache[treatment.id] + treatment, dose, tMs, batemanCache[treatment.id], modelOverride ) if (c > 0.0) total += c * treatment.scaleFactor } @@ -170,7 +181,9 @@ object PharmacokineticEngine { startMs: Long, endMs: Long, stepMs: Long = HOUR_MS, - tConfig: TConfig + tConfig: TConfig, + /** Force un modèle PK (ESE/TFS) pour les traitements par profil. */ + modelOverride: String? = null ): List { if (treatments.isEmpty() || doseLogs.isEmpty() || endMs <= startMs) return emptyList() @@ -196,7 +209,7 @@ object PharmacokineticEngine { if (dose.treatmentId != treatment.id || dose.timestamp > t) continue val dtH = (t - dose.timestamp) / 3_600_000.0 if (dtH > cutoff) continue - val c = concentrationOfDose(treatment, dose, t, p) + val c = concentrationOfDose(treatment, dose, t, p, modelOverride) if (c > 0.0) e2 += c * treatment.scaleFactor } } @@ -206,6 +219,96 @@ object PharmacokineticEngine { return points } + /** + * Génère les doses PRÉVISIONNELLES d'un traitement, à partir de sa « fréquence » + * (forecastIntervalDays en jours) et de la dernière dose réellement enregistrée. + * + * Règles : + * - intervalle null ou ≤ 0 → aucune prévision ; + * - la première dose projetée suit exactement l'intervalle après la dernière dose + * réelle (pas d'alignement sur un rythme passé moyen — le rythme reste sous le + * contrôle de l'utilisatrice) ; + * - la dose projetée reprend la dose standard du traitement et l'ester de la + * dernière injection réelle (override compris) ; + * - toutes les doses projetées sont STRICTEMENT postérieures à nowMs et jusqu'à toMs. + * + * Les doses retournées ne sont jamais persistées : elles alimentent uniquement + * computeCurve pour dessiner la partie « prévision » du graphique. + */ + fun generateForecastDoses( + treatment: Treatment, + allDoseLogs: List, + toMs: Long, + nowMs: Long = System.currentTimeMillis() + ): List { + val intervalDays = treatment.forecastIntervalDays ?: return emptyList() + if (intervalDays <= 0.0) return emptyList() + val intervalMs = (intervalDays * 24.0 * HOUR_MS).toLong() + if (intervalMs <= 0L) return emptyList() + + val last = allDoseLogs + .filter { it.treatmentId == treatment.id && it.timestamp <= nowMs } + .maxByOrNull { it.timestamp } ?: return emptyList() + + val forecast = mutableListOf() + var t = last.timestamp + intervalMs + while (t <= toMs) { + forecast.add( + DoseLog( + treatmentId = treatment.id, + timestamp = t, + doseAmount = treatment.doseAmount, + esterType = doseEster(treatment, last) + ) + ) + t += intervalMs + } + return forecast + } + + /** + * 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. + */ + data class AutoCalibrated( + val treatments: List, + val tConfig: TConfig, + /** Nombre de traitements dont le facteur d'échelle a été ajusté. */ + val calibratedTreatments: Int, + /** true si le modèle T a pu être recalibré. */ + val tRecalibrated: Boolean + ) + + fun autoCalibrated( + treatments: List, + doseLogs: List, + labs: List, + tConfig: TConfig + ): AutoCalibrated { + 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) + return AutoCalibrated( + treatments = newTreatments, + tConfig = newT ?: tConfig, + calibratedTreatments = calibrated, + tRecalibrated = newT != null + ) + } + // ------------------------------------------------------------------ // Calibration from lab results // ------------------------------------------------------------------ diff --git a/app/src/main/java/com/hormonetrack/settings/AppSettings.kt b/app/src/main/java/com/hormonetrack/settings/AppSettings.kt index cc555fb..43aa770 100644 --- a/app/src/main/java/com/hormonetrack/settings/AppSettings.kt +++ b/app/src/main/java/com/hormonetrack/settings/AppSettings.kt @@ -3,6 +3,7 @@ package com.hormonetrack.settings import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.doublePreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey @@ -13,6 +14,9 @@ import kotlinx.coroutines.flow.map val Context.dataStore: DataStore by preferencesDataStore(name = "settings") +/** + * Préférences persistées (DataStore) : modèle T, langue, options d'affichage. + */ class AppSettings(private val context: Context) { private object Keys { @@ -20,6 +24,9 @@ class AppSettings(private val context: Context) { val T_FLOOR = doublePreferencesKey("t_floor") val T_K = doublePreferencesKey("t_k") val LANGUAGE = stringPreferencesKey("language") + + /** Option : ajuster automatiquement scale factor + modèle T depuis les labs. */ + val AUTO_CALIBRATE = booleanPreferencesKey("auto_calibrate") } val tConfig: Flow = context.dataStore.data.map { prefs -> @@ -34,6 +41,11 @@ class AppSettings(private val context: Context) { prefs[Keys.LANGUAGE] ?: "system" } + /** Par défaut DÉSACTIVÉ : la calibration reste sous contrôle de l'utilisatrice. */ + val autoCalibrate: Flow = context.dataStore.data.map { prefs -> + prefs[Keys.AUTO_CALIBRATE] ?: false + } + suspend fun setTConfig(config: TConfig) { context.dataStore.edit { prefs -> prefs[Keys.T_BASE] = config.base @@ -47,4 +59,10 @@ class AppSettings(private val context: Context) { prefs[Keys.LANGUAGE] = code } } + + suspend fun setAutoCalibrate(enabled: Boolean) { + context.dataStore.edit { prefs -> + prefs[Keys.AUTO_CALIBRATE] = enabled + } + } } diff --git a/app/src/test/java/com/hormonetrack/pk/V120FeaturesTest.kt b/app/src/test/java/com/hormonetrack/pk/V120FeaturesTest.kt new file mode 100644 index 0000000..4750653 --- /dev/null +++ b/app/src/test/java/com/hormonetrack/pk/V120FeaturesTest.kt @@ -0,0 +1,136 @@ +package com.hormonetrack.pk + +import com.hormonetrack.data.model.AdministrationRoute +import com.hormonetrack.data.model.DoseLog +import com.hormonetrack.data.model.Esters +import com.hormonetrack.data.model.LabResult +import com.hormonetrack.data.model.PKModels +import com.hormonetrack.data.model.Treatment +import com.hormonetrack.data.model.TreatmentType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File + +/** + * Tests des nouveautés v1.2.0 : doses prévisionnelles (« Fréquence »), override de + * modèle PK (courbes Estrannaise / Transfem Science superposables) et calibration + * automatique optionnelle. + */ +class V120FeaturesTest { + + companion object { + private const val BASE = 1_700_000_000_000L + private const val HOUR = 3_600_000L + private const val DAY = 24 * HOUR + } + + @Before + fun setup() { + if (!PKProfileStore.hasProfile("EEN", "ESE")) { + val file = listOf( + File("src/main/assets/pk_profiles.json"), + File("app/src/main/assets/pk_profiles.json") + ).first { it.exists() } + PKProfileStore.initWithJson(file.readText()) + } + } + + private fun eenTreatment(intervalDays: Double? = null, scale: Double = 1.0) = Treatment( + id = 1, + name = "EEn", + type = TreatmentType.ESTRADIOL, + route = AdministrationRoute.INJECTION_SUBCUT, + doseAmount = 5.0, + doseUnit = "mg", + esterType = Esters.EEN, + pkModel = PKModels.ESTRANNAISE, + scaleFactor = scale, + forecastIntervalDays = intervalDays + ) + + @Test + fun `forecast doses follow the configured interval from the last real dose`() { + val tr = eenTreatment(intervalDays = 7.0) + // 2 doses réelles à 7 jours d'écart, la dernière 3 jours avant « maintenant » + val doses = listOf( + DoseLog(treatmentId = 1, timestamp = BASE - 10 * DAY, doseAmount = 5.0), + DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0) + ) + val forecast = PharmacokineticEngine.generateForecastDoses( + tr, doses, toMs = BASE + 30 * DAY, nowMs = BASE + ) + // 1ʳᵉ prévision à J+4 (dernière dose J-3 + 7 j), puis J+11, J+18, J+25 + assertEquals(listOf(4.0, 11.0, 18.0, 25.0), forecast.map { + (it.timestamp - BASE) / DAY.toDouble() + }) + // Dose standard + ester de la dernière injection réelle + assertEquals(5.0, forecast.first().doseAmount, 1e-9) + assertEquals(Esters.EEN, forecast.first().esterType) + } + + @Test + fun `forecast is empty without interval or without doses`() { + val tr = eenTreatment(intervalDays = null) + val doses = listOf(DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0)) + assertTrue(PharmacokineticEngine.generateForecastDoses(tr, doses, BASE + 30 * DAY, BASE).isEmpty()) + + val trWithInterval = eenTreatment(intervalDays = 7.0) + assertTrue(PharmacokineticEngine.generateForecastDoses(trWithInterval, emptyList(), BASE + 30 * DAY, BASE).isEmpty()) + } + + @Test + fun `forecast respects the ester override of the last real dose`() { + val tr = eenTreatment(intervalDays = 7.0) + val doses = listOf( + DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0, esterType = Esters.EV) + ) + val forecast = PharmacokineticEngine.generateForecastDoses(tr, doses, BASE + 20 * DAY, BASE) + assertEquals(Esters.EV, forecast.first().esterType) + } + + @Test + fun `model override produces different curves for the two models`() { + val tr = eenTreatment().copy(esterType = Esters.EV) + val doses = listOf(DoseLog(treatmentId = 1, timestamp = BASE, doseAmount = 1.0)) + val t = BASE + 45 * HOUR + val ese = PharmacokineticEngine.e2At(listOf(tr), doses, t, modelOverride = "ESE") + val tfs = PharmacokineticEngine.e2At(listOf(tr), doses, t, modelOverride = "TFS") + assertTrue("ese=$ese tfs=$tfs doivent différer à 45 h", kotlin.math.abs(ese - tfs) > 1.0) + // Sans override : le modèle du traitement (ESE) est utilisé + val none = PharmacokineticEngine.e2At(listOf(tr), doses, t) + assertEquals(ese, none, 1e-9) + } + + @Test + fun `auto calibration replaces scale factors and T model for display only`() { + 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 + val t = BASE - 2 * DAY + val predicted = PharmacokineticEngine.e2At(listOf(tr), doses, t) + val labs = listOf( + LabResult(marker = "E2", value = predicted * 0.73, unit = "pg/mL", timestamp = t), + LabResult(marker = "T", value = 0.45, unit = "ng/mL", timestamp = t) + ) + 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) + assertTrue(result.tRecalibrated) + // Le traitement d'origine n'est PAS modifié (affichage seul) + assertEquals(1.0, tr.scaleFactor, 1e-9) + } + + @Test + fun `auto calibration keeps originals when no lab is usable`() { + 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) + } +}