diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dda800d..4197b1a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,8 @@ android { // tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0" // (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans // les APK publiés (Paramètres + titre du dialog « Nouveautés »). - versionCode = 31 - versionName = "1.5.0" + versionCode = 32 + versionName = "1.6.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt b/app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt index 583e06d..b863024 100644 --- a/app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt +++ b/app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt @@ -38,11 +38,12 @@ import kotlin.math.ln * PORTÉE ET LIMITES (essentielles — cf doc §7.10 / décision plan v1.5.0) : * - **E2 uniquement** (les labs T : 1) sont trop rares, 2) leurs unités * variées ont causé le bug #26 — hors scope) ; - * - **fenêtre = [1er lab significatif ; dernier lab]** — rien AVANT (M=0 - * sans dose) ni APRÈS (aucune extrapolation : le vieillissement après le - * dernier lab est visible = honnête, l'app n'invente pas de niveau) ; + * - **fenêtre = [1er lab significatif ; dernier lab]** par défaut — rien + * AVANT (M=0 sans dose) ni APRÈS (aucune extrapolation) ; + * - **PROLONGATION (v1.6.0, opt-in `extendBeyondLastLab = true`)** : la + * courbe peut se PROLONGER au-delà du dernier lab — cf détail ci-dessous ; * - < 2 labs significatifs → courbe vide (un lab seul ne définit pas - * d'intervalle) ; + * d'intervalle) — et donc jamais de prolongation non plus ; * - indépendante de la calibration : cette courbe EST SA PROPRE * calibration continue (ratio brut, scaleFactor des traitements ignoré * ET scalePerEster ignoré — l'inverse serait une double correction, @@ -50,13 +51,63 @@ import kotlin.math.ln * - **display-end only** : utilisée UNIQUEMENT par le graphique (chip, * off par défaut). Jamais branchée sur `levelAt` — l'accueil, les * seuils d'alerte et les rappels continuent d'utiliser le modèle - * calibré (invariant v1.4.2 §9.bis). + * calibré (invariant v1.4.2 §9.bis). La partie PROLONGÉE ne fait pas + * exception : elle est purement visuelle (série "LABX" du chart). * - courbes T non modélisées ici : la série n'a pas de tStyle (le chart * ne dessine pas de T pour la série — cf CurveChart). * * PUR (aucune dépendance Android) → testable en JVM * (LabTrajectoryModelTest — l'ancrage exact, la garde #61 exactement, - * les bornes de fenêtre). + * les bornes de fenêtre, la prolongation). + * + * ── PROLONGATION AU-DELÀ DU DERNIER LAB (v1.6.0) ────────────────────────── + * + * DEMANDE (v1.6.0) : « permettre au tracé labs de s'étendre au-delà du + * dernier lab, en simulant à partir des labs précédents, du dosage et du + * type d'ester injecté, et de l'évolution classique de l'ester ». + * + * Le constat v1.5.0 : la fenêtre s'arrêtait AU dernier lab — entre ce lab + * et « maintenant » (voire au-delà, avec la prévision), la courbe n'existait + * pas, alors que c'est précisément la période la plus récente qu'on veut + * comparer aux courbes des modèles. + * + * PRINCIPE : après le dernier ancrage, ρ cesse d'interpoler et reste CONSTANT : + * + * pour t > t_last_lab : courbe(t) = M(t) × ρ_last + * + * Justification, composant par composant (la demande, point par point) : + * - **« l'évolution classique de l'ester »** = M(t) : la forme du modèle PK + * (montée, Tmax, queues, accumulation) porte toute la dynamique ; + * - **« le dosage et le type d'ester injecté »** = M(t) inclut AUTOMATIQUEMENT + * les doses loguées après le dernier lab : une nouvelle injection (même à + * un autre ester) fait repartir la courbe en pic, à l'amplitude calibrée ; + * - **« les données de labs précédentes »** = ρ_last : le rapport + * corps-réel ÷ modèle observé au dernier lab est supposé rester valable. + * + * POURQUOI ρ CONSTANT et pas une extrapolation de la PENTE de ρ : la pente + * entre deux labs ne mesure pas une tendance du corps, elle morphing deux + * corrections d'amplitude — l'extrapoler divergerait sans aucune base + * physiologique (un ratio de calibration qui monte de 5 %/jour finirait par + * des facteurs ×10 inventés). La calibration observée au dernier lab est le + * meilleur estimateur disponible pour la suite ; c'est exactement ce que + * fait la calibration classique (le facteur stocké s'applique aux doses + * futures — §7.6). + * + * BORNE DE PROLONGATION (horizon) : la courbe prolongée s'arrête quand le + * MODÈLE lui-même s'éteint — `extensionHorizonEndMs` = dernière dose E2 + + * [PharmacokineticEngine.cutoffHours] de son traitement (10 t½ terminales pour V3C/WHS, fin de + * table pour ODS, 30 t½ Bateman). Au-delà, M(t) = 0 : dessiner plus serait + * une ligne à zéro purement inventée. La prolongation est en plus clippée + * par la fenêtre DEMANDÉE (celle du graphique). + * + * LIMITES ASSUMÉES (documentées, pas de bug — cf §7.10.bis) : + * - changement d'ESTER après le dernier lab : ρ_last a été mesuré sous + * l'ancien ester ; l'amplitude du nouveau reste calibrée par ce ρ — + * même limite que la calibration classique (fallback du facteur stocké, + * pas de lab de la nouvelle période) ; + * - les doses PRÉVISIONNELLES ne participent PAS : la prolongation ne + * consomme que les doses RÉELLES (le futur projeté reste la série + * « Prévision », qui superpose déjà les modèles calibrés). */ object LabTrajectoryModel { @@ -64,10 +115,20 @@ object LabTrajectoryModel { * Résultat : les points (E2 ancrée, t = 0 — pas de courbe T pour cette * série) + le nombre de labs réellement utilisés comme ancrages * (diagnostic dans la doc ; 0 → courbe vide). + * + * [lastAnchorMs] (v1.6.0) : timestamp du DERNIER lab significatif (le + * dernier point « réel » de la courbe). `null` si < 2 ancres (courbe + * vide). L'écran Graphiques l'utilise pour SPLITTER la série en deux + * traits distincts : + * - "LAB" = points ≤ lastAnchorMs (ancré — passe par les labs) ; + * - "LABX" = points > lastAnchorMs (prolongé — ρ constant, estimation). + * La distinction visuelle préserve l'honnêteté de l'affichage : la partie + * inventée ne peut pas se confondre avec la partie mesurée. */ data class AnchoredCurve( val points: List, - val anchoredLabs: Int + val anchoredLabs: Int, + val lastAnchorMs: Long? = null ) // Les ancrages sont des couples (timestamp du lab, ratio lab ÷ prédiction) @@ -79,15 +140,28 @@ object LabTrajectoryModel { * @param treatments TOUS les traitements (actifs ET inactifs — §6.bis : * l'inactivation ne retire pas l'historique de la * prédiction M(t)) - * @param doseLogs toutes les doses + * @param doseLogs toutes les doses (RÉELLES uniquement — les doses + * prévisionnelles ne participent jamais au tracé labs ; + * les doses loguées APRÈS le dernier lab sont au + * contraire INCLUSES : elles portent la forme de la + * prolongation, cf KDoc de l'objet) * @param e2Labs labs E2 uniquement (les marqueurs autres sont * ignorés par `marker == "E2"`) — unité pg/mL * @param startMs/endMs fenêtre demandée (celle du graphique) — la courbe - * est CLIPPÉE à l'intersection avec [lab 1er ; lab dernier] + * est CLIPPÉE à l'intersection avec [lab 1er ; lab + * dernier], ou avec [lab 1er ; horizon de prolongation] + * quand [extendBeyondLastLab] est actif * @param stepMs pas d'échantillonnage (stepForRange, v1.2.9) * @param modelOverride modèle pour la prédiction M(t) (défaut = modèle * stocké, même sémantique que l'auto-calibration * Home v1.4.8) + * @param extendBeyondLastLab PROLONGATION (v1.6.0, opt-in) : au-delà du + * dernier lab significatif, ρ reste CONSTANT + * (= ρ du dernier lab) et la courbe continue + * `M(t) × ρ_last` jusqu'à + * [extensionHorizonEndMs] ∩ fenêtre demandée. + * false (défaut) = comportement v1.5.0 exact : + * la fenêtre s'arrête au dernier lab. */ fun computeLabAnchoredCurve( treatments: List, @@ -96,7 +170,8 @@ object LabTrajectoryModel { startMs: Long, endMs: Long, stepMs: Long = 3_600_000L, - modelOverride: String? = null + modelOverride: String? = null, + extendBeyondLastLab: Boolean = false ): AnchoredCurve { if (startMs >= endMs) return AnchoredCurve(emptyList(), 0) @@ -124,9 +199,22 @@ object LabTrajectoryModel { val firstLabMs = anchors.first().first val lastLabMs = anchors.last().first - // ── 2) Fenêtre effective = [1er ; dernier lab] ∩ fenêtre demandée ──── + // ── 2) Fenêtre effective ───────────────────────────────────────────── + // v1.5.0 : [1er lab ; dernier lab] ∩ fenêtre demandée. + // v1.6.0 (extendBeyondLastLab) : la borne droite devient le min de la + // fenêtre demandée et de l'HORIZON DE PROLONGATION (dernière dose E2 + + // cutoff de son traitement). Au-delà du dernier lab, ratioAt() retourne + // déjà ρ_last (garde défensive aux bornes) : la boucle de calcul est + // INCHANGÉE — élargir la fenêtre EST la prolongation, et la continuité + // au point de suture est garantie par construction (ρ(t_last) = ρ_last + // exactement, dérivable à gauche comme à droite puisque ρ est constant + // après). val t0 = maxOf(startMs, firstLabMs) - val t1 = minOf(endMs, lastLabMs) + val t1 = if (extendBeyondLastLab) { + minOf(endMs, extensionHorizonEndMs(estrogenTreatments, doseLogs, lastLabMs) ?: lastLabMs) + } else { + minOf(endMs, lastLabMs) + } if (t1 <= t0) return AnchoredCurve(emptyList(), anchors.size) // ── 3) Courbe = M(t) × ρ(t) sur la grille ───────────────────────────── @@ -138,7 +226,34 @@ object LabTrajectoryModel { points.add(LevelPoint(t, m * rho, 0.0)) // t=0 : pas de courbe T pour cette série t += stepMs } - return AnchoredCurve(points, anchors.size) + return AnchoredCurve(points, anchors.size, lastLabMs) + } + + /** + * BORNE DE PROLONGATION (v1.6.0) : timestamp jusqu'où la partie extrapolée + * a un sens. Défini par le MODÈLE, pas par un cap arbitraire : + * + * horizon = (timestamp de la dernière dose E2) + cutoffHours(son traitement) + * + * où cutoffHours = 10 t½ terminales (V3C/WHS), fin de table (ODS) ou + * 30 t½ (Bateman) — cf [PharmacokineticEngine.cutoffHours]. Après cette + * borne, M(t) = 0 : la courbe prolongée serait une ligne à zéro inventée. + * + * @return le timestamp de fin de prolongation, ou `null` quand il n'y a + * RIEN à prolonger : aucune dose E2, ou modèle déjà éteint au dernier + * lab (le caller retombe alors sur la fenêtre v1.5.0). + */ + internal fun extensionHorizonEndMs( + estrogenTreatments: List, + doseLogs: List, + lastLabMs: Long + ): Long? { + val e2Doses = doseLogs.filter { d -> estrogenTreatments.any { it.id == d.treatmentId } } + val lastDose = e2Doses.maxByOrNull { it.timestamp } ?: return null + val tr = estrogenTreatments.first { it.id == lastDose.treatmentId } + val cutoffMs = (PharmacokineticEngine.cutoffHours(tr) * 3_600_000.0).toLong() + val horizonEnd = lastDose.timestamp + cutoffMs + return if (horizonEnd > lastLabMs) horizonEnd else null } /** @@ -151,10 +266,14 @@ object LabTrajectoryModel { * multiplicative (pas de saut), monotone entre deux références. * Doublons de timestamp (2 labs au même instant) : le 2ᵉ ratio gagne * (garde division par zéro). + * + * HORS bornes : avant le 1ᵉʳ / après le DERNIER ancre → ρ de borne. + * v1.6.0 : la branche « après le dernier » (return ρ_last) est devenue un + * COMPORTEMENT, pas une simple garde — c'est elle qui fonde la + * prolongation (ρ constant au-delà du dernier lab, cf KDoc de l'objet) ; + * en v1.5.0 la fenêtre du caller n'y entrait jamais. */ internal fun ratioAt(anchors: List>, tMs: Long): Double { - // Avant le 1er / après le dernier : exclus par la fenêtre du caller — - // garde défensive = bornes. if (tMs <= anchors.first().first) return anchors.first().second if (tMs >= anchors.last().first) return anchors.last().second diff --git a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt index 24363e1..e778cc4 100644 --- a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt +++ b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt @@ -114,7 +114,20 @@ object PharmacokineticEngine { return max(0.0, c) } - private fun cutoffHours(treatment: Treatment): Double { + /** + * Coupure temporelle (HEURES) au-delà de laquelle une dose de ce traitement + * ne contribue plus à la prédiction : + * - profil avec modèle V3C/WHS → 10 demi-vies TERMINALES du fit + * (ex. EEn TFS ≈ 45 j, PEP ≈ 284 j) ; + * - profil tables ODS (Estrannaise) → longueur de la table (8001 h ≈ 333 j) ; + * - Bateman (gel/patch/oral) → 30 demi-vies d'élimination. + * + * Public (v1.6.0) : réutilisé par [LabTrajectoryModel.extensionHorizonEndMs] + * pour borner la PROLONGATION du tracé labs — la partie extrapolée s'éteint + * exactement quand le modèle lui-même s'éteint (même sémantique de coupure, + * une seule source de vérité). + */ + fun cutoffHours(treatment: Treatment): Double { val profileH = if (treatment.usesProfileModel) { val model = treatment.pkModel when { diff --git a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt index ff44ed6..90eb4bf 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt @@ -118,6 +118,12 @@ fun ChartScreen() { // revendique « passer par tes labs » et doit rester une référence de // comparaison, pas un affichage par défiant var showLabTrack by remember { mutableStateOf(false) } + // v1.6.0 : PROLONGATION du tracé labs au-delà du dernier lab (ρ du + // dernier lab constant × modèle, bornée par l'extinction du modèle — + // cf LabTrajectoryModel.extensionHorizonEndMs). OFF par défaut : la + // partie prolongée est une ESTIMATION non ancrée, elle doit être + // demandée explicitement ; le chip n'est actif que si Tracé labs est ON. + var showLabTrackExtend by remember { mutableStateOf(false) } // v1.4.7 (fix #58) : au CHARGEMENT des traitements, les toggles sont // alignés sur les modèles réellement UTILISÉS par des traitements à // PROFIL PK (injection + ester, `usesProfileModel`) — en cours ou passés @@ -258,11 +264,12 @@ fun ChartScreen() { } // Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS") — - // v1.5.0 : la 4ᵉ série possible est "LAB" (Tracé labs, indépendante) + // v1.5.0 : la 4ᵉ série possible est "LAB" (Tracé labs, indépendante) ; + // v1.6.0 : la 5ᵉ est "LABX" (partie PROLONGÉE du tracé labs, ρ constant) val curves by produceState( emptyList>>(), autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast, - showLabTrack, labResults.size + showLabTrack, showLabTrackExtend, labResults.size ) { withContext(Dispatchers.Default) { val list = mutableListOf>>() @@ -271,14 +278,32 @@ fun ChartScreen() { // modèle STOCKÉ des traitements — sémantique Home, cf doc §7.10) // et de la calibration (elle EST sa propre calibration continue — // scalePerEster ignoré, épinglé par LabTrajectoryModelTest). - // showLabTrack + serie visible UNIQUEMENT si ≥ 2 labs that anchor. + // showLabTrack + série visible UNIQUEMENT si ≥ 2 labs qui ancrent. + // + // ── v1.6.0 : split ancré / prolongé ────────────────────────────── + // Avec « Prolonger », la série est SPLITTÉE au dernier lab + // significatif (lastAnchorMs) : "LAB" = partie ancrée (passe par + // les labs), "LABX" = partie prolongée (M(t) × ρ_last — une + // estimation). Deux traits distincts (LABX en atténué) préservent + // l'honnêteté de l'affichage : la partie inventée ne peut pas se + // confondre avec la partie mesurée. ⚠️ Deux clés DISTINCTES et + // non vides seulement — la légende (plus bas) doit connaître + // "LABX" AVANT son else (leçon #63). if (showLabTrack) { val step = com.hormonetrack.ui.components.stepForRange(rangeHours) - list.add( - "LAB" to LabTrajectoryModel.computeLabAnchoredCurve( - treatments, doseLogs, labResults, startMs, endMs, step - ).points + val labCurve = LabTrajectoryModel.computeLabAnchoredCurve( + treatments, doseLogs, labResults, startMs, endMs, step, + extendBeyondLastLab = showLabTrackExtend ) + val lastAnchor = labCurve.lastAnchorMs + if (showLabTrackExtend && lastAnchor != null) { + val anchored = labCurve.points.filter { it.timestamp <= lastAnchor } + val extended = labCurve.points.filter { it.timestamp > lastAnchor } + list.add("LAB" to anchored) + if (extended.isNotEmpty()) list.add("LABX" to extended) + } else { + list.add("LAB" to labCurve.points) + } } if (showEse || showTfs || showWhs) { // Calibration PAR MODÈLE (fix #60) : chaque courbe reçoit les @@ -359,6 +384,17 @@ fun ChartScreen() { // et hors pics/creux (labIsSignificant a déjà filtré, §7.10) "LAB" -> ChartSeries(points, CurveStyle(ChartLabTrajectory, dashed = true), null, showExtrema = false) + // v1.6.0 : partie PROLONGÉE du tracé labs — même couleur ATTÉNUÉE + // (alpha 0,55) pour distinguer visuellement l'estimation non + // ancrée de la partie qui passe par les labs. Même exclusions + // (pas de T, pas d'extrema — les extrema de l'interp ρ constant + // ne sont que ceux du modèle brut). + "LABX" -> ChartSeries( + points, + CurveStyle(ChartLabTrajectory.copy(alpha = 0.55f), dashed = true), + null, + showExtrema = false + ) else -> ChartSeries(points, CurveStyle(TealTertiary), CurveStyle(ChartT, dashed = true)) } } @@ -484,6 +520,16 @@ fun ChartScreen() { onClick = { showLabTrack = !showLabTrack }, label = { Text(stringResource(R.string.show_lab_track)) } ) + // v1.6.0 : prolongation du tracé labs au-delà du dernier lab + // (off par défaut, sans effet — donc désactivé — si Tracé labs + // est off : la prolongation n'existe que comme extension de CETTE + // courbe, pas comme série autonome) + FilterChip( + selected = showLabTrackExtend, + onClick = { showLabTrackExtend = !showLabTrackExtend }, + enabled = showLabTrack, + label = { Text(stringResource(R.string.show_lab_track_extend)) } + ) } Spacer(Modifier.height(12.dp)) @@ -609,6 +655,11 @@ fun ChartScreen() { } } "LAB" -> { /* légende dédiée, cf plus bas */ } + // v1.6.0 : idem — la partie prolongée a sa légende + // dédiée plus bas. ⚠️ branche EXPLICITE requise : + // sinon le `else` (= TFS) imprimerait la légende + // TFS une 2ᵉ fois (leçon #63, v1.5.0) + "LABX" -> { /* légende dédiée, cf plus bas */ } else -> { Text( stringResource(R.string.legend_tfs_e2), @@ -655,6 +706,30 @@ fun ChartScreen() { color = ChartLabTrajectory ) } + // v1.6.0 : légende de la partie PROLONGÉE + AVERTISSEMENT — + // seulement si la série "LABX" existe (chip Prolonger actif + // ET horizon de prolongation atteint dans la fenêtre visible) + val labExtendVisible = showLabTrack && showLabTrackExtend && + curves.any { it.first == "LABX" && it.second.size >= 2 } + if (labExtendVisible) { + Text( + stringResource(R.string.legend_lab_track_extend), + style = MaterialTheme.typography.labelMedium, + color = ChartLabTrajectory.copy(alpha = 0.55f) + ) + // Avertissement (demande v1.6.0) : la partie prolongée + // est une EXTRAPOLATION sans garantie — elle repose sur + // des mesures de labs elles-mêmes faillibles. Affiché + // TANT QUE la prolongation est visible (sous la + // légende), pour qu'elle ne puisse pas être lue comme + // une prédiction fiable de l'avenir. + Text( + stringResource(R.string.lab_track_extend_warning), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp) + ) + } Text( stringResource(R.string.drag_hint), style = MaterialTheme.typography.labelMedium, diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 5513f5d..c3fa65b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -33,6 +33,9 @@ Prévision Pics / creux Tracé labs + + Prolonger Zoom avant (fenêtre plus courte) Zoom arrière (fenêtre plus longue) Sous THS depuis le %1$s @@ -74,6 +77,12 @@ L\'autorisation agenda est demandée à l\'activation. ▲▼ pics & creux estimés (par courbe) E2 ancrée sur tes labs — forme du modèle forcée de passer par chaque prise de sang (entre le 1er et le dernier lab) + + -- Tracé labs prolongé après ton dernier lab (modèle × ratio du dernier lab — estimation, plus ancrée) + + ⚠ Simple simulation, sans garantie de correspondre au réel : la prolongation extrapole ton modèle à partir de tes résultats de laboratoire — qui peuvent eux-mêmes être erronés. Fie-toi à ta prochaine prise de sang, pas à cette courbe. — prochaine dose projetée (marqueur vertical) Revenir à maintenant Fais glisser le graphique vers la droite pour remonter dans le passé. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5ebc2f6..21d5398 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,6 +33,9 @@ Forecast Peaks / troughs Lab track + + Extend Zoom in (shorter window) Zoom out (longer window) Under HRT since %1$s @@ -74,6 +77,12 @@ Calendar permission is requested when you enable this. ▲▼ estimated peaks & troughs (per curve) Lab-anchored curve — model shape forced through your labs (E2 only, between first and last lab) + + -- Lab track extended past your last lab (model × last lab ratio — estimate, not anchored) + + ⚠ Simulation only, with no guarantee of matching reality: the extension extrapolates your model from your lab results — which can themselves be inaccurate. Trust your next blood test, not this curve. — next projected dose (vertical marker) Back to now Drag the chart to the right to travel into the past. diff --git a/app/src/test/java/com/hormonetrack/pk/LabTrajectoryModelTest.kt b/app/src/test/java/com/hormonetrack/pk/LabTrajectoryModelTest.kt index 3fd38f3..90b98a4 100644 --- a/app/src/test/java/com/hormonetrack/pk/LabTrajectoryModelTest.kt +++ b/app/src/test/java/com/hormonetrack/pk/LabTrajectoryModelTest.kt @@ -14,12 +14,14 @@ import kotlin.math.ln /** * Tests du « Tracé labs » (v1.5.0) — courbe hybride ancrée sur les labs - * (cf doc §7.10). Chaque test épingle une GARANTIE de la construction : + * (cf doc §7.10), et de sa PROLONGATION au-delà du dernier lab (v1.6.0, + * cf doc §7.10.bis). Chaque test épingle une GARANTIE de la construction : * - passage EXACT sur chaque lab (comme les sanity checks TFS) ; * - interp log-linéaire de ρ (math exacte, tolérance FP seule) ; * - garde de significativité jamais contournée (leçon #61 : une dose de * test ancienne + labs tardifs ne doivent pas créer de zigzag) ; - * - fenêtre = [1er ; dernier lab] — JAMAIS d'extrapolation ; + * - fenêtre = [1er ; dernier lab] par défaut ; avec l'option v1.6.0, + * prolongation ρ constant jusqu'au cutoff du modèle (JAMAIS au-delà) ; * - indépendance du scaleFactor stocké (cette courbe est SA PROPRE * calibration continue — combiner = double correction). * @@ -261,4 +263,188 @@ class LabTrajectoryModelTest { assertEquals(1.0, LabTrajectoryModel.ratioAt(anchors, -5L), 1e-12) assertEquals(2.0, LabTrajectoryModel.ratioAt(anchors, 100L), 1e-12) } + + // ── Prolongation au-delà du dernier lab (v1.6.0) ───────────────────────── + // Garanties de l'extension (cf doc §7.10.bis) : + // - au-delà du dernier lab : courbe(t) = M(t) × ρ_last (ρ CONSTANT — + // fondement = la garde défensive de ratioAt devenue comportement) ; + // - continuité au point de suture (le dernier lab reste exact) ; + // - horizon = dernière dose E2 + cutoff de son traitement (jamais de + // ligne à zéro inventée au-delà de l'extinction du modèle) ; + // - les doses loguées APRÈS le dernier lab (autre ester/dose compris) + // refont monter la courbe via M(t) — la demande « dosage + type + // d'ester + évolution classique » ; + // - dernier lab non significatif : ni ancre ni point de départ de la + // prolongation (garde #61 inchangée) ; + // - flag OFF (défaut) = comportement v1.5.0 bit-compatible. + + /** Labs DATA-DRIVEN ancrés à 2 j (ρ=0,9) et 7 j (ρ=1,2) sur la fixture EEn/TFS. */ + private fun anchoredLabs(tr: Treatment, doses: List): + Pair, Double> { + val m2 = PharmacokineticEngine.e2At(listOf(tr), doses, 2 * DAY_MS, null, null) + val m7 = PharmacokineticEngine.e2At(listOf(tr), doses, 7 * DAY_MS, null, null) + return listOf(lab(0.9 * m2, 2 * DAY_MS), lab(1.2 * m7, 7 * DAY_MS)) to 1.2 + } + + @Test + fun `extension - la courbe continue apres le dernier lab avec le rho du dernier lab`() { + initProfiles() + val tr = treatment() + val doses = doses(tr) + val (labs, rhoLast) = anchoredLabs(tr, doses) + + val curve = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(tr), doses, labs, 0L, 60 * DAY_MS, HOUR_MS, + extendBeyondLastLab = true + ) + + assertTrue("la prolongation produit des points après le dernier lab", + curve.points.any { it.timestamp > 7 * DAY_MS }) + // Suture : le dernier lab reste EXACT (continuité par construction) + assertEquals(labs[1].value, curve.points.first { it.timestamp == 7 * DAY_MS }.e2, 1e-6) + // Identité de la zone extrapolée : courbe(t) = M(t) × ρ_last, exact + for (day in listOf(10L, 20L, 30L)) { + val t = day * DAY_MS + val p = curve.points.first { it.timestamp == t } + val m = PharmacokineticEngine.e2At(listOf(tr.copy(scaleFactor = 1.0)), doses, t, null, null) + assertEquals("courbe($day j) == M × ρ_last", m * rhoLast, p.e2, 1e-9) + } + } + + @Test + fun `extension - horizon borne par le cutoff de la derniere dose`() { + initProfiles() + val tr = treatment() + val (labs, _) = anchoredLabs(tr, doses(tr)) + + // Demande TRÈS large (90 j) : la prolongation s'arrête au modèle + val curve = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(tr), doses(tr), labs, 0L, 90 * DAY_MS, HOUR_MS, + extendBeyondLastLab = true + ) + + val horizonEnd = LabTrajectoryModel.extensionHorizonEndMs( + listOf(tr), doses(tr), 7 * DAY_MS)!! + assertTrue("l'horizon EEn/TFS dépasse le dernier lab (sinon le test ne prouve rien)", + horizonEnd > 7 * DAY_MS) + assertTrue("aucun point au-delà de l'extinction du modèle", + curve.points.all { it.timestamp <= horizonEnd }) + assertTrue("le dernier point s'arrête AU cutoff (pas au-delà d'un pas)", + curve.points.last().timestamp <= horizonEnd) + assertTrue(curve.points.last().timestamp + HOUR_MS > horizonEnd) + } + + @Test + fun `extension - une dose apres le dernier lab refait monter la courbe (dose et ester)`() { + initProfiles() + val tr = treatment() + val doses = doses(tr) + com.hormonetrack.data.model.DoseLog( + id = 2, treatmentId = 1, timestamp = 10 * DAY_MS, + doseAmount = 4.0, esterType = "EV" // override d'ester : EEn → EV + ) + val (labs, rhoLast) = anchoredLabs(tr, doses(tr)) + + val curve = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(tr), doses, labs, 0L, 20 * DAY_MS, HOUR_MS, + extendBeyondLastLab = true + ) + val extension = curve.points.filter { it.timestamp > 7 * DAY_MS } + assertTrue(extension.isNotEmpty()) + + // L'identité vaut AUSSI avec la dose EV : M(t) inclut les deux doses + val t12 = 12 * DAY_MS + val m12 = PharmacokineticEngine.e2At(listOf(tr.copy(scaleFactor = 1.0)), doses, t12, null, null) + assertEquals(m12 * rhoLast, extension.first { it.timestamp == t12 }.e2, 1e-9) + + // La FORME suit l'injection EV : montée nette après le 10 j (Tmax EV + // ~45 h) — la partie prolongée n'est PAS une simple décroissance EEn + val at8 = extension.first { it.timestamp == 8 * DAY_MS }.e2 + val peak = extension.filter { it.timestamp in 10 * DAY_MS..14 * DAY_MS }.maxOf { it.e2 } + assertTrue("le pic EV ($peak) doit dépasser le niveau pré-injection ($at8)", + peak > at8 * 1.3) + } + + @Test + fun `extension desactivee - rien apres le dernier lab (comportement initial v1-5-0)`() { + initProfiles() + val tr = treatment() + val (labs, _) = anchoredLabs(tr, doses(tr)) + // DÉFAUT (flag absent) ET flag explicite à false : identiques + val byDefault = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(tr), doses(tr), labs, 0L, 60 * DAY_MS, HOUR_MS) + val explicitOff = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(tr), doses(tr), labs, 0L, 60 * DAY_MS, HOUR_MS, + extendBeyondLastLab = false) + assertEquals(byDefault.points, explicitOff.points) + assertTrue(byDefault.points.all { it.timestamp <= 7 * DAY_MS }) + assertTrue("lastAnchorMs est positionné même sans prolongation (l'UI peut splitter)", + byDefault.lastAnchorMs == 7 * DAY_MS) + } + + @Test + fun `extension - un lab non significatif ne prolonge pas (garde 61)`() { + initProfiles() + val tr = treatment() + // Scénario #61 : lab TARDIF (60 j — prédiction résiduelle 0, ratio aberrant) + val labs = anchoredLabs(tr, doses(tr)).first + lab(3000.0, 60 * DAY_MS) + + val curve = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(tr), doses(tr), labs, 0L, 90 * DAY_MS, HOUR_MS, + extendBeyondLastLab = true + ) + + assertTrue("le lab tardif reste rejeté", curve.anchoredLabs == 2) + assertEquals("la prolongation part du dernier lab SIGNIFICATIF", + 7 * DAY_MS, curve.lastAnchorMs) + assertTrue("des points prolongés existent bien après le dernier ancre", + curve.points.any { it.timestamp > 7 * DAY_MS }) + assertTrue("aucun point ne suit la valeur absurde du lab rejeté", + curve.points.none { it.e2 > 600.0 }) + } + + @Test + fun `extension - demande finissant avant le dernier lab ne prolonge rien`() { + initProfiles() + val tr = treatment() + val (labs, _) = anchoredLabs(tr, doses(tr)) + val curve = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(tr), doses(tr), labs, 0L, 5 * DAY_MS, HOUR_MS, + extendBeyondLastLab = true + ) + assertTrue(curve.points.all { it.timestamp <= 5 * DAY_MS }) + // la suture au dernier ancre n'est pas dans la fenêtre → aucun point + // au-delà du dernier lab, comme en v1.5.0 + assertTrue(curve.points.none { it.timestamp > 7 * DAY_MS }) + } + + @Test + fun `extensionHorizonEndMs - gardes aucune dose ou modele deja eteint`() { + initProfiles() + val tr = treatment() + val trs = listOf(tr) + // Aucune dose E2 → rien à prolonger + assertTrue(LabTrajectoryModel.extensionHorizonEndMs(trs, emptyList(), 7 * DAY_MS) == null) + // Dose à t=0 → horizon = cutoff du traitement (EEn TFS : 10 t½ term.) + val horizon = LabTrajectoryModel.extensionHorizonEndMs(trs, doses(tr), 7 * DAY_MS) + val expected = (PharmacokineticEngine.cutoffHours(tr) * 3_600_000.0).toLong() + assertEquals(expected, horizon) + // Modèle DÉJÀ éteint au dernier lab → null (retour fenêtre v1.5.0) + assertTrue(LabTrajectoryModel.extensionHorizonEndMs( + trs, doses(tr), expected + HOUR_MS) == null) + } + + @Test + fun `lastAnchorMs - null si moins de 2 ancres, positionne sinon`() { + initProfiles() + val tr = treatment() + val one = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(tr), doses(tr), listOf(lab(200.0, 3 * DAY_MS)), 0L, 30 * DAY_MS, HOUR_MS) + assertTrue(one.points.isEmpty()) + assertTrue(one.lastAnchorMs == null) + + val two = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(tr), doses(tr), listOf(lab(300.0, 2 * DAY_MS), lab(260.0, 7 * DAY_MS)), + 0L, 30 * DAY_MS, HOUR_MS) + assertEquals(7 * DAY_MS, two.lastAnchorMs) + } }