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
e2aa3ecbbf
commit
a4ec1fa71f
@ -4,6 +4,8 @@ import android.content.Context
|
|||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
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.DoseLogDao
|
||||||
import com.hormonetrack.data.dao.LabResultDao
|
import com.hormonetrack.data.dao.LabResultDao
|
||||||
import com.hormonetrack.data.dao.TreatmentDao
|
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.LabResult
|
||||||
import com.hormonetrack.data.model.Treatment
|
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(
|
@Database(
|
||||||
entities = [Treatment::class, DoseLog::class, LabResult::class],
|
entities = [Treatment::class, DoseLog::class, LabResult::class],
|
||||||
version = 1,
|
version = 2,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
@ -22,6 +32,17 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun labResultDao(): LabResultDao
|
abstract fun labResultDao(): LabResultDao
|
||||||
|
|
||||||
companion object {
|
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
|
@Volatile
|
||||||
private var INSTANCE: AppDatabase? = null
|
private var INSTANCE: AppDatabase? = null
|
||||||
|
|
||||||
@ -32,7 +53,7 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"hormonetrack.db"
|
"hormonetrack.db"
|
||||||
)
|
)
|
||||||
.fallbackToDestructiveMigration()
|
.addMigrations(MIGRATION_1_2)
|
||||||
.build()
|
.build()
|
||||||
INSTANCE = instance
|
INSTANCE = instance
|
||||||
instance
|
instance
|
||||||
|
|||||||
@ -51,9 +51,14 @@ data class Treatment(
|
|||||||
val eliminationHalfLifeHours: Float = 24f,
|
val eliminationHalfLifeHours: Float = 24f,
|
||||||
val bioavailabilityFraction: Float = 1.0f,
|
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,
|
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
|
// Reminder
|
||||||
val reminderHour: Int? = null,
|
val reminderHour: Int? = null,
|
||||||
val reminderMinute: Int? = null,
|
val reminderMinute: Int? = null,
|
||||||
|
|||||||
@ -67,7 +67,13 @@ object PharmacokineticEngine {
|
|||||||
treatment: Treatment,
|
treatment: Treatment,
|
||||||
dose: DoseLog,
|
dose: DoseLog,
|
||||||
queryTimeMs: Long,
|
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 {
|
): Double {
|
||||||
val dtH = (queryTimeMs - dose.timestamp) / 3_600_000.0
|
val dtH = (queryTimeMs - dose.timestamp) / 3_600_000.0
|
||||||
if (dtH <= 0.0) return 0.0
|
if (dtH <= 0.0) return 0.0
|
||||||
@ -78,7 +84,7 @@ object PharmacokineticEngine {
|
|||||||
if (treatment.usesProfileModel) {
|
if (treatment.usesProfileModel) {
|
||||||
val ester = doseEster(treatment, dose)
|
val ester = doseEster(treatment, dose)
|
||||||
if (ester != Esters.NONE) {
|
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
|
// 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>()
|
val batemanCache = HashMap<Long, BatemanParams>()
|
||||||
var total = 0.0
|
var total = 0.0
|
||||||
for (treatment in treatments) {
|
for (treatment in treatments) {
|
||||||
@ -120,7 +131,7 @@ object PharmacokineticEngine {
|
|||||||
val dtH = (tMs - dose.timestamp) / 3_600_000.0
|
val dtH = (tMs - dose.timestamp) / 3_600_000.0
|
||||||
if (dtH > cutoffHours(treatment)) continue
|
if (dtH > cutoffHours(treatment)) continue
|
||||||
val c = concentrationOfDose(
|
val c = concentrationOfDose(
|
||||||
treatment, dose, tMs, batemanCache[treatment.id]
|
treatment, dose, tMs, batemanCache[treatment.id], modelOverride
|
||||||
)
|
)
|
||||||
if (c > 0.0) total += c * treatment.scaleFactor
|
if (c > 0.0) total += c * treatment.scaleFactor
|
||||||
}
|
}
|
||||||
@ -170,7 +181,9 @@ object PharmacokineticEngine {
|
|||||||
startMs: Long,
|
startMs: Long,
|
||||||
endMs: Long,
|
endMs: Long,
|
||||||
stepMs: Long = HOUR_MS,
|
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> {
|
): List<LevelPoint> {
|
||||||
if (treatments.isEmpty() || doseLogs.isEmpty() || endMs <= startMs) return emptyList()
|
if (treatments.isEmpty() || doseLogs.isEmpty() || endMs <= startMs) return emptyList()
|
||||||
|
|
||||||
@ -196,7 +209,7 @@ object PharmacokineticEngine {
|
|||||||
if (dose.treatmentId != treatment.id || dose.timestamp > t) continue
|
if (dose.treatmentId != treatment.id || dose.timestamp > t) continue
|
||||||
val dtH = (t - dose.timestamp) / 3_600_000.0
|
val dtH = (t - dose.timestamp) / 3_600_000.0
|
||||||
if (dtH > cutoff) continue
|
if (dtH > cutoff) continue
|
||||||
val c = concentrationOfDose(treatment, dose, t, p)
|
val c = concentrationOfDose(treatment, dose, t, p, modelOverride)
|
||||||
if (c > 0.0) e2 += c * treatment.scaleFactor
|
if (c > 0.0) e2 += c * treatment.scaleFactor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -206,6 +219,96 @@ object PharmacokineticEngine {
|
|||||||
return points
|
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
|
// Calibration from lab results
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.hormonetrack.settings
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.doublePreferencesKey
|
import androidx.datastore.preferences.core.doublePreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
@ -13,6 +14,9 @@ import kotlinx.coroutines.flow.map
|
|||||||
|
|
||||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
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) {
|
class AppSettings(private val context: Context) {
|
||||||
|
|
||||||
private object Keys {
|
private object Keys {
|
||||||
@ -20,6 +24,9 @@ class AppSettings(private val context: Context) {
|
|||||||
val T_FLOOR = doublePreferencesKey("t_floor")
|
val T_FLOOR = doublePreferencesKey("t_floor")
|
||||||
val T_K = doublePreferencesKey("t_k")
|
val T_K = doublePreferencesKey("t_k")
|
||||||
val LANGUAGE = stringPreferencesKey("language")
|
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 ->
|
val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs ->
|
||||||
@ -34,6 +41,11 @@ class AppSettings(private val context: Context) {
|
|||||||
prefs[Keys.LANGUAGE] ?: "system"
|
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) {
|
suspend fun setTConfig(config: TConfig) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
prefs[Keys.T_BASE] = config.base
|
prefs[Keys.T_BASE] = config.base
|
||||||
@ -47,4 +59,10 @@ class AppSettings(private val context: Context) {
|
|||||||
prefs[Keys.LANGUAGE] = code
|
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