v1.6.0 : « Tracé labs » prolongé au-delà du dernier lab (chip Prolonger + avertissement)
- LabTrajectoryModel : extendBeyondLastLab (ρ_last constant au-delà du dernier lab significatif), lastAnchorMs, extensionHorizonEndMs (horizon = dernière dose E2 + cutoff du traitement — cutoffHours rendu public, une seule source de vérité) ; boucle de calcul inchangée. - ChartScreen : chip « Prolonger » (5ᵉ, off par défaut, désactivé tant que Tracé labs est off), split série "LAB" (ancrée) / "LABX" (prolongée, rose atténué α 0,55), légendes dédiées (branche explicite avant le else — leçon #63), AVERTISSEMENT « simple simulation, sans garantie de correspondre au réel, labs potentiellement erronés » sous la légende quand la prolongation est visible. - 8 nouveaux tests (191 verts / 168 sans données locales) + lint vert. - Bump versionCode 32 / versionName 1.6.0.
This commit is contained in:
parent
dd5a9e3013
commit
3e2bf3992e
@ -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 {
|
||||
|
||||
@ -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<LevelPoint>,
|
||||
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<Treatment>,
|
||||
@ -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<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
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<Pair<Long, Double>>, 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
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<Pair<String, List<LevelPoint>>>(),
|
||||
autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast,
|
||||
showLabTrack, labResults.size
|
||||
showLabTrack, showLabTrackExtend, labResults.size
|
||||
) {
|
||||
withContext(Dispatchers.Default) {
|
||||
val list = mutableListOf<Pair<String, List<LevelPoint>>>()
|
||||
@ -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,
|
||||
|
||||
@ -33,6 +33,9 @@
|
||||
<string name="show_forecast">Prévision</string>
|
||||
<string name="show_extrema">Pics / creux</string>
|
||||
<string name="show_lab_track">Tracé labs</string>
|
||||
<!-- v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in,
|
||||
actif seulement quand Tracé labs est ON) -->
|
||||
<string name="show_lab_track_extend">Prolonger</string>
|
||||
<string name="zoom_in">Zoom avant (fenêtre plus courte)</string>
|
||||
<string name="zoom_out">Zoom arrière (fenêtre plus longue)</string>
|
||||
<string name="hrt_since">Sous THS depuis le %1$s</string>
|
||||
@ -74,6 +77,12 @@
|
||||
<string name="agenda_permission">L\'autorisation agenda est demandée à l\'activation.</string>
|
||||
<string name="legend_extrema">▲▼ pics & creux estimés (par courbe)</string>
|
||||
<string name="legend_lab_track">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)</string>
|
||||
<!-- v1.6.0 : légende de la partie PROLONGÉE du tracé labs (série "LABX") -->
|
||||
<string name="legend_lab_track_extend">-- Tracé labs prolongé après ton dernier lab (modèle × ratio du dernier lab — estimation, plus ancrée)</string>
|
||||
<!-- v1.6.0 : AVERTISSEMENT affiché sous la légende quand la prolongation
|
||||
est active — l\'extrapolation n\'a aucune garantie et repose sur des
|
||||
mesures elles-mêmes faillibles -->
|
||||
<string name="lab_track_extend_warning">⚠ 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.</string>
|
||||
<string name="legend_forecast_dose">— prochaine dose projetée (marqueur vertical)</string>
|
||||
<string name="back_to_present">Revenir à maintenant</string>
|
||||
<string name="drag_hint">Fais glisser le graphique vers la droite pour remonter dans le passé.</string>
|
||||
|
||||
@ -33,6 +33,9 @@
|
||||
<string name="show_forecast">Forecast</string>
|
||||
<string name="show_extrema">Peaks / troughs</string>
|
||||
<string name="show_lab_track">Lab track</string>
|
||||
<!-- v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in,
|
||||
actif seulement quand Lab track est ON) -->
|
||||
<string name="show_lab_track_extend">Extend</string>
|
||||
<string name="zoom_in">Zoom in (shorter window)</string>
|
||||
<string name="zoom_out">Zoom out (longer window)</string>
|
||||
<string name="hrt_since">Under HRT since %1$s</string>
|
||||
@ -74,6 +77,12 @@
|
||||
<string name="agenda_permission">Calendar permission is requested when you enable this.</string>
|
||||
<string name="legend_extrema">▲▼ estimated peaks & troughs (per curve)</string>
|
||||
<string name="legend_lab_track">Lab-anchored curve — model shape forced through your labs (E2 only, between first and last lab)</string>
|
||||
<!-- v1.6.0 : légende de la partie PROLONGÉE du tracé labs (série "LABX") -->
|
||||
<string name="legend_lab_track_extend">-- Lab track extended past your last lab (model × last lab ratio — estimate, not anchored)</string>
|
||||
<!-- v1.6.0 : AVERTISSEMENT affiché sous la légende quand la prolongation
|
||||
est active — l\'extrapolation n\'a aucune garantie et repose sur des
|
||||
mesures elles-mêmes faillibles -->
|
||||
<string name="lab_track_extend_warning">⚠ 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.</string>
|
||||
<string name="legend_forecast_dose">— next projected dose (vertical marker)</string>
|
||||
<string name="back_to_present">Back to now</string>
|
||||
<string name="drag_hint">Drag the chart to the right to travel into the past.</string>
|
||||
|
||||
@ -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<com.hormonetrack.data.model.DoseLog>):
|
||||
Pair<List<LabResult>, 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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user