Moteur v1.2.0 : override de modèle PK, doses prévisionnelles (« Fréquence »), calibration automatique
- 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)
This commit is contained in:
parent
977d40baa1
commit
693ab24f3d
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<Treatment>, doseLogs: List<DoseLog>, tMs: Long): Double {
|
||||
fun e2At(
|
||||
treatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
tMs: Long,
|
||||
modelOverride: String? = null
|
||||
): Double {
|
||||
val batemanCache = HashMap<Long, BatemanParams>()
|
||||
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<LevelPoint> {
|
||||
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<DoseLog>,
|
||||
toMs: Long,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): List<DoseLog> {
|
||||
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<DoseLog>()
|
||||
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<Treatment>,
|
||||
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<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
labs: List<LabResult>,
|
||||
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
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@ -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<Preferences> 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<TConfig> = 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<Boolean> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
136
app/src/test/java/com/hormonetrack/pk/V120FeaturesTest.kt
Normal file
136
app/src/test/java/com/hormonetrack/pk/V120FeaturesTest.kt
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user