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:
Siphonight 2026-09-05 16:53:53 +02:00
parent e2aa3ecbbf
commit a4ec1fa71f
5 changed files with 292 additions and 9 deletions

View File

@ -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

View File

@ -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,

View File

@ -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
// ------------------------------------------------------------------

View File

@ -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
}
}
}

View 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)
}
}