Compare commits

..

2 Commits

Author SHA1 Message Date
0768b83a88 Docs : v1.6.0 — prolongation tracé labs (§7.10.bis), session §2, compteur 191 tests
- DEVELOPPEMENT : §7.10.bis (recette ρ constant, horizon, split LAB/LABX,
  avertissement, limites), session v1.6.0 §2, §8 191/168 tests, §11
  séries LAB/LABX, §21 checklist manuelle, TOC 7.10/7.10.bis, fix coquilles
  (E3→E2, efecto→effet, trais→traits), checklist §16 compteur à jour.
- CHANGELOG : entrée [1.6.0]. README : statut + puce Tracé labs prolongé.
- GUIDE_INSTALLATION : chip Prolonger + avertissement (§8).
2026-09-12 23:49:24 +02:00
3e2bf3992e 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.
2026-09-12 23:49:24 +02:00
11 changed files with 623 additions and 49 deletions

View File

@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue. > sang et aux consignes de ton endocrinologue.
- **Statut** : v1.5.0 — build Android ✅, **lint vert** ✅, **183 tests unitaires** ✅ (160 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅ - **Statut** : v1.6.0 — build Android ✅, **lint vert** ✅, **191 tests unitaires** ✅ (168 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) - **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) - **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
@ -39,13 +39,15 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
en couvre 6 (sans PEP). Affichés côte à côte avec toggles indépendants (3 couleurs) — **pré-cochés en couvre 6 (sans PEP). Affichés côte à côte avec toggles indépendants (3 couleurs) — **pré-cochés
selon les modèles de tes traitements** (v1.4.7) et **chaque modèle calibré selon les modèles de tes traitements** (v1.4.7) et **chaque modèle calibré
séparément** par tes labs (v1.4.8 — les facteurs s'adaptent à chaque profil) séparément** par tes labs (v1.4.8 — les facteurs s'adaptent à chaque profil)
- **« Tracé labs » (v1.5.0)** : la courbe ancrée sur tes prises de sang - **« Tracé labs » (v1.5.0, prolongé en v1.6.0)** : la courbe ancrée sur tes
(chip `Tracé labs`, off par défaut) — la forme du modèle PK entre les prises de sang (chip `Tracé labs`, off par défaut) — la forme du modèle PK
labs, mais l'amplitude recalée pour passer EXACTEMENT sur chaque lab E2 entre les labs, mais l'amplitude recalée pour passer EXACTEMENT sur chaque
; transition log-linéaire d'un lab au suivant, garde des labs hors lab E2 ; transition log-linéaire d'un lab au suivant, garde des labs hors
fenêtre d'action (#61), fenêtre bornée [1er ; dernier lab] — la courbe fenêtre d'action (#61) ; **chip `Prolonger` (v1.6.0)** : la courbe se
est une référence de COMPARAISON, jamais une estimation d'action (elle prolonge au-delà du dernier lab (modèle × ratio du dernier lab, horizon =
n'entre jamais dans l'accueil/les alertes) extinction du modèle, partie estimée dessinée atténuée + légende dédiée) —
la courbe est une référence de COMPARAISON, jamais une estimation d'action
(elle n'entre jamais dans l'accueil/les alertes)
- **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel, - **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel,
patch et voie orale patch et voie orale
- **Simulation prévisionnelle** : configurer la **posologie** (intervalle en jours) sur un - **Simulation prévisionnelle** : configurer la **posologie** (intervalle en jours) sur un
@ -100,7 +102,7 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1.
git clone <repo> && cd HormoneTrack git clone <repo> && cd HormoneTrack
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk ./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
./gradlew testDebugUnitTest # 183 tests (160 sans les données locales) ./gradlew testDebugUnitTest # 191 tests (168 sans les données locales)
./gradlew lint # lint vert obligatoire avant release ./gradlew lint # lint vert obligatoire avant release
``` ```

View File

@ -17,8 +17,8 @@ android {
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0" // tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans // (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
// les APK publiés (Paramètres + titre du dialog « Nouveautés »). // les APK publiés (Paramètres + titre du dialog « Nouveautés »).
versionCode = 31 versionCode = 32
versionName = "1.5.0" versionName = "1.6.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -38,11 +38,12 @@ import kotlin.math.ln
* PORTÉE ET LIMITES (essentielles — cf doc §7.10 / décision plan v1.5.0) : * 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 * - **E2 uniquement** (les labs T : 1) sont trop rares, 2) leurs unités
* variées ont causé le bug #26 — hors scope) ; * variées ont causé le bug #26 — hors scope) ;
* - **fenêtre = [1er lab significatif ; dernier lab]** — rien AVANT (M=0 * - **fenêtre = [1er lab significatif ; dernier lab]** par défaut — rien
* sans dose) ni APRÈS (aucune extrapolation : le vieillissement après le * AVANT (M=0 sans dose) ni APRÈS (aucune extrapolation) ;
* dernier lab est visible = honnête, l'app n'invente pas de niveau) ; * - **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 * - < 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 * - indépendante de la calibration : cette courbe EST SA PROPRE
* calibration continue (ratio brut, scaleFactor des traitements ignoré * calibration continue (ratio brut, scaleFactor des traitements ignoré
* ET scalePerEster ignoré — l'inverse serait une double correction, * 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, * - **display-end only** : utilisée UNIQUEMENT par le graphique (chip,
* off par défaut). Jamais branchée sur `levelAt` — l'accueil, les * off par défaut). Jamais branchée sur `levelAt` — l'accueil, les
* seuils d'alerte et les rappels continuent d'utiliser le modèle * 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 * - 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). * ne dessine pas de T pour la série — cf CurveChart).
* *
* PUR (aucune dépendance Android) → testable en JVM * PUR (aucune dépendance Android) → testable en JVM
* (LabTrajectoryModelTest — l'ancrage exact, la garde #61 exactement, * (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 { object LabTrajectoryModel {
@ -64,10 +115,20 @@ object LabTrajectoryModel {
* Résultat : les points (E2 ancrée, t = 0 — pas de courbe T pour cette * 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 * série) + le nombre de labs réellement utilisés comme ancrages
* (diagnostic dans la doc ; 0 → courbe vide). * (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( data class AnchoredCurve(
val points: List<LevelPoint>, 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) // 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 : * @param treatments TOUS les traitements (actifs ET inactifs — §6.bis :
* l'inactivation ne retire pas l'historique de la * l'inactivation ne retire pas l'historique de la
* prédiction M(t)) * 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 * @param e2Labs labs E2 uniquement (les marqueurs autres sont
* ignorés par `marker == "E2"`) — unité pg/mL * ignorés par `marker == "E2"`) — unité pg/mL
* @param startMs/endMs fenêtre demandée (celle du graphique) — la courbe * @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 stepMs pas d'échantillonnage (stepForRange, v1.2.9)
* @param modelOverride modèle pour la prédiction M(t) (défaut = modèle * @param modelOverride modèle pour la prédiction M(t) (défaut = modèle
* stocké, même sémantique que l'auto-calibration * stocké, même sémantique que l'auto-calibration
* Home v1.4.8) * 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( fun computeLabAnchoredCurve(
treatments: List<Treatment>, treatments: List<Treatment>,
@ -96,7 +170,8 @@ object LabTrajectoryModel {
startMs: Long, startMs: Long,
endMs: Long, endMs: Long,
stepMs: Long = 3_600_000L, stepMs: Long = 3_600_000L,
modelOverride: String? = null modelOverride: String? = null,
extendBeyondLastLab: Boolean = false
): AnchoredCurve { ): AnchoredCurve {
if (startMs >= endMs) return AnchoredCurve(emptyList(), 0) if (startMs >= endMs) return AnchoredCurve(emptyList(), 0)
@ -124,9 +199,22 @@ object LabTrajectoryModel {
val firstLabMs = anchors.first().first val firstLabMs = anchors.first().first
val lastLabMs = anchors.last().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 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) if (t1 <= t0) return AnchoredCurve(emptyList(), anchors.size)
// ── 3) Courbe = M(t) × ρ(t) sur la grille ───────────────────────────── // ── 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 points.add(LevelPoint(t, m * rho, 0.0)) // t=0 : pas de courbe T pour cette série
t += stepMs 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. * multiplicative (pas de saut), monotone entre deux références.
* Doublons de timestamp (2 labs au même instant) : le 2ᵉ ratio gagne * Doublons de timestamp (2 labs au même instant) : le 2ᵉ ratio gagne
* (garde division par zéro). * (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 { 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.first().first) return anchors.first().second
if (tMs >= anchors.last().first) return anchors.last().second if (tMs >= anchors.last().first) return anchors.last().second

View File

@ -114,7 +114,20 @@ object PharmacokineticEngine {
return max(0.0, c) 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 profileH = if (treatment.usesProfileModel) {
val model = treatment.pkModel val model = treatment.pkModel
when { when {

View File

@ -118,6 +118,12 @@ fun ChartScreen() {
// revendique « passer par tes labs » et doit rester une référence de // revendique « passer par tes labs » et doit rester une référence de
// comparaison, pas un affichage par défiant // comparaison, pas un affichage par défiant
var showLabTrack by remember { mutableStateOf(false) } 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 // 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 à // alignés sur les modèles réellement UTILISÉS par des traitements à
// PROFIL PK (injection + ester, `usesProfileModel`) — en cours ou passés // 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") — // 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( val curves by produceState(
emptyList<Pair<String, List<LevelPoint>>>(), emptyList<Pair<String, List<LevelPoint>>>(),
autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast, autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast,
showLabTrack, labResults.size showLabTrack, showLabTrackExtend, labResults.size
) { ) {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val list = mutableListOf<Pair<String, List<LevelPoint>>>() 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) // modèle STOCKÉ des traitements — sémantique Home, cf doc §7.10)
// et de la calibration (elle EST sa propre calibration continue — // et de la calibration (elle EST sa propre calibration continue —
// scalePerEster ignoré, épinglé par LabTrajectoryModelTest). // 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) { if (showLabTrack) {
val step = com.hormonetrack.ui.components.stepForRange(rangeHours) val step = com.hormonetrack.ui.components.stepForRange(rangeHours)
list.add( val labCurve = LabTrajectoryModel.computeLabAnchoredCurve(
"LAB" to LabTrajectoryModel.computeLabAnchoredCurve( treatments, doseLogs, labResults, startMs, endMs, step,
treatments, doseLogs, labResults, startMs, endMs, step extendBeyondLastLab = showLabTrackExtend
).points
) )
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) { if (showEse || showTfs || showWhs) {
// Calibration PAR MODÈLE (fix #60) : chaque courbe reçoit les // 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) // et hors pics/creux (labIsSignificant a déjà filtré, §7.10)
"LAB" -> ChartSeries(points, CurveStyle(ChartLabTrajectory, dashed = true), null, "LAB" -> ChartSeries(points, CurveStyle(ChartLabTrajectory, dashed = true), null,
showExtrema = false) 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)) else -> ChartSeries(points, CurveStyle(TealTertiary), CurveStyle(ChartT, dashed = true))
} }
} }
@ -484,6 +520,16 @@ fun ChartScreen() {
onClick = { showLabTrack = !showLabTrack }, onClick = { showLabTrack = !showLabTrack },
label = { Text(stringResource(R.string.show_lab_track)) } 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)) Spacer(Modifier.height(12.dp))
@ -609,6 +655,11 @@ fun ChartScreen() {
} }
} }
"LAB" -> { /* légende dédiée, cf plus bas */ } "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 -> { else -> {
Text( Text(
stringResource(R.string.legend_tfs_e2), stringResource(R.string.legend_tfs_e2),
@ -655,6 +706,30 @@ fun ChartScreen() {
color = ChartLabTrajectory 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( Text(
stringResource(R.string.drag_hint), stringResource(R.string.drag_hint),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,

View File

@ -33,6 +33,9 @@
<string name="show_forecast">Prévision</string> <string name="show_forecast">Prévision</string>
<string name="show_extrema">Pics / creux</string> <string name="show_extrema">Pics / creux</string>
<string name="show_lab_track">Tracé labs</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_in">Zoom avant (fenêtre plus courte)</string>
<string name="zoom_out">Zoom arrière (fenêtre plus longue)</string> <string name="zoom_out">Zoom arrière (fenêtre plus longue)</string>
<string name="hrt_since">Sous THS depuis le %1$s</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="agenda_permission">L\'autorisation agenda est demandée à l\'activation.</string>
<string name="legend_extrema">▲▼ pics &amp; creux estimés (par courbe)</string> <string name="legend_extrema">▲▼ pics &amp; 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> <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="legend_forecast_dose">— prochaine dose projetée (marqueur vertical)</string>
<string name="back_to_present">Revenir à maintenant</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> <string name="drag_hint">Fais glisser le graphique vers la droite pour remonter dans le passé.</string>

View File

@ -33,6 +33,9 @@
<string name="show_forecast">Forecast</string> <string name="show_forecast">Forecast</string>
<string name="show_extrema">Peaks / troughs</string> <string name="show_extrema">Peaks / troughs</string>
<string name="show_lab_track">Lab track</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_in">Zoom in (shorter window)</string>
<string name="zoom_out">Zoom out (longer window)</string> <string name="zoom_out">Zoom out (longer window)</string>
<string name="hrt_since">Under HRT since %1$s</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="agenda_permission">Calendar permission is requested when you enable this.</string>
<string name="legend_extrema">▲▼ estimated peaks &amp; troughs (per curve)</string> <string name="legend_extrema">▲▼ estimated peaks &amp; 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> <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="legend_forecast_dose">— next projected dose (vertical marker)</string>
<string name="back_to_present">Back to now</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> <string name="drag_hint">Drag the chart to the right to travel into the past.</string>

View File

@ -14,12 +14,14 @@ import kotlin.math.ln
/** /**
* Tests du « Tracé labs » (v1.5.0) — courbe hybride ancrée sur les labs * 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) ; * - passage EXACT sur chaque lab (comme les sanity checks TFS) ;
* - interp log-linéaire de ρ (math exacte, tolérance FP seule) ; * - interp log-linéaire de ρ (math exacte, tolérance FP seule) ;
* - garde de significativité jamais contournée (leçon #61 : une dose de * - garde de significativité jamais contournée (leçon #61 : une dose de
* test ancienne + labs tardifs ne doivent pas créer de zigzag) ; * 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 * - indépendance du scaleFactor stocké (cette courbe est SA PROPRE
* calibration continue — combiner = double correction). * calibration continue — combiner = double correction).
* *
@ -261,4 +263,188 @@ class LabTrajectoryModelTest {
assertEquals(1.0, LabTrajectoryModel.ratioAt(anchors, -5L), 1e-12) assertEquals(1.0, LabTrajectoryModel.ratioAt(anchors, -5L), 1e-12)
assertEquals(2.0, LabTrajectoryModel.ratioAt(anchors, 100L), 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)
}
} }

View File

@ -3,6 +3,49 @@
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …). Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …).
## [1.6.0] — 2026-09-12 (versionCode 32)
### « Tracé labs » prolongé au-delà du dernier lab
- **Nouveau chip `Prolonger`** (à côté de `Tracé labs`, **off par défaut**,
sans effet — donc désactivé — tant que Tracé labs est off) : la courbe
ancrée sur tes prises de sang se PROLONGE désormais au-delà du dernier lab,
au lieu de s'arrêter là (limite v1.5.0 : justement la période la plus
récente était invisible).
- **Recette de la prolongation** : `courbe(t) = M(t) × ρ_dernier lab` où
M(t) = le modèle PK brut (porte TOUTE la dynamique : Tmax, queues,
accumulation **et les doses loguées après le dernier lab** — une nouvelle
injection, même à un autre ester, fait repartir la courbe en pic) et
ρ = le ratio corps-réel ÷ modèle observé au dernier lab, supposé constant.
Demande couverte : simuler à partir des labs précédents, du dosage et du
type d'ester injecté, et de l'évolution classique de l'ester.
- **Horizon honnête** : la prolongation s'arrête quand le MODÈLE lui-même
s'éteint (dernière dose + coupure moteur = 10 t½ terminales V3C/WHS, fin
de table ODS, 30 t½ Bateman) — jamais de ligne à zéro inventée au-delà.
- **Lisibilité** : la partie prolongée est dessinée SÉPARÉMENT (même rose,
atténué) avec sa propre légende — « estimation, plus ancrée » : la partie
mesurée (qui passe par les labs) ne peut pas se confondre avec la partie
simulée.
- **Avertissement visible** (demande) : tant que la partie prolongée est
affichée, un texte sous la légende rappelle que c'est une **simple
simulation sans garantie de correspondre au réel**, extrapolée à partir
de tes résultats de laboratoire — qui peuvent **eux-mêmes être erronés**
— et qu'il faut se fier à la prochaine prise de sang, pas à cette courbe.
- **Garde anti-ratés inchangée** : seuls les labs SIGNIFICATIFS ancrent
(leçon #61) — un lab tardif hors fenêtre d'action ne prolonge pas la
courbe et n'est jamais traversé.
- **Limites documentées** (cf doc de développement §7.10.bis) : ρ est
constant (pas d'extrapolation de pente — elle divergerait sans base
physiologique) ; après un changement d'ester, l'amplitude reste calibrée
par le ρ de l'ancien ester (même limite que la calibration classique) ;
les doses PRÉVISIONNELLES ne participent pas (la projection reste la
série Prévision).
- **8 nouveaux tests** (191 au total / 168 sans les données locales) :
identité ρ constant + continuité de suture, horizon = cutoff du
traitement, montée après une dose EV loguée après le dernier lab,
flag off = comportement v1.5.0 bit-compatible, garde #61 sur la
prolongation, gardes du helper d'horizon, `lastAnchorMs`.
## [1.5.0] — 2026-09-11 (versionCode 31) ## [1.5.0] — 2026-09-11 (versionCode 31)
### « Tracé labs » — courbe ancrée sur tes prises de sang ### « Tracé labs » — courbe ancrée sur tes prises de sang

View File

@ -15,7 +15,7 @@
4. [Environnement de build (cette machine)](#4-environnement-de-build-cette-machine) 4. [Environnement de build (cette machine)](#4-environnement-de-build-cette-machine)
5. [Architecture générale](#5-architecture-générale) 5. [Architecture générale](#5-architecture-générale)
6. [Modèle de données (Room)](#6-modèle-de-données-room) + [6.bis Sémantique isActive](#6bis-sémantique-isactive-v124--drapeau-administratif-jamais-un-filtre) 6. [Modèle de données (Room)](#6-modèle-de-données-room) + [6.bis Sémantique isActive](#6bis-sémantique-isactive-v124--drapeau-administratif-jamais-un-filtre)
7. [Moteur pharmacocinétique](#7-moteur-pharmacocinétique) + [7.8 Modèle TFS V3C](#78-modèle-tfs-v3c-v140--pktransfemsciencemodelskt) + [7.9 Modèle WHSAH](#79-modèle-whsah-v146--pkwhsahmodelskt) 7. [Moteur pharmacocinétique](#7-moteur-pharmacocinétique) + [7.8 Modèle TFS V3C](#78-modèle-tfs-v3c-v140--pktransfemsciencemodelskt) + [7.9 Modèle WHSAH](#79-modèle-whsah-v146--pkwhsahmodelskt) + [7.10 « Tracé labs »](#710--tracé-labs--v150--pklabtrajectorymodelkt) + [7.10.bis Prolongation](#710bis-prolongation-au-delà-du-dernier-lab-v160--pklabtrajectorymodelkt)
8. [Tests unitaires](#8-tests-unitaires) + [8.bis Données de test hors dépôt](#8bis-données-de-test-réelles--hors-dépôt-local-test-data) 8. [Tests unitaires](#8-tests-unitaires) + [8.bis Données de test hors dépôt](#8bis-données-de-test-réelles--hors-dépôt-local-test-data)
9. [Système de rappels](#9-système-de-rappels) + [9.bis Alertes de seuil](#9bis-alertes-de-seuil-reminderalertnotifierkt--worker-v142) 9. [Système de rappels](#9-système-de-rappels) + [9.bis Alertes de seuil](#9bis-alertes-de-seuil-reminderalertnotifierkt--worker-v142)
10. [UI & navigation](#10-ui--navigation) 10. [UI & navigation](#10-ui--navigation)
@ -63,6 +63,7 @@ Fonctionnalités v1 :
| Date | Événement | | Date | Événement |
|---|---| |---|---|
| 12 sept. 2026 (session v1.6.0) | **« Tracé labs » PROLONGÉ au-delà du dernier lab** (demande) : le tracé s'arrêtait AU dernier lab (v1.5.0) — justement la période la plus récente était invisible. FIX : chip `Prolonger` (5ᵉ de la rangée, off par défaut, désactivé tant que Tracé labs est off) → au-delà du dernier lab significatif, `courbe(t) = M(t) × ρ_last` (ρ CONSTANT — pas d'extrapolation de pente, elle divergerait sans base physiologique) ; M(t) inclut AUTOMATIQUEMENT les doses loguées après le dernier lab (une injection EV après des labs EEn refait monter la courbe — épinglé par test) ; horizon = dernière dose E2 + cutoffHours de son traitement (une seule source de vérité : `cutoffHours` rendu public) ; série SPLITTÉE en "LAB" (ancré) / "LABX" (prolongé, rose atténué α 0,55 + légende dédiée — l'estimation ne se confond pas avec le mesuré) ; branche "LABX" EXPLICITE dans le when de légende AVANT le else (leçon #63) ; **AVERTISSEMENT visible sous la légende quand la prolongation est affichée** (demande) : « simple simulation, sans garantie de correspondre au réel, basée sur tes labs qui peuvent eux-mêmes être erronés » (string FR/EN `lab_track_extend_warning`). < 2 ancres, modèle déjà éteint au dernier lab, ou demande finissant avant → pas de prolongation (retour v1.5.0 exact, épinglé bit-compatible). 8 nouveaux tests (191 verts / 168 sans données locales) + lint vert. |
| 11 sept. 2026 (session v1.5.0) | **« Tracé labs »** (demande débattue) : courbe hybride `M(t)×ρ(t)` ancrée sur les labs — ρ log-linéaire entre labs, garde #61, E2 seul, fenêtre labs, chip off par défaut + scroll horizontal CONFINÉ (chip 4ᵉ coupé hors fenêtre, constat émulateur) + leçon #63 légende dupliquée ("LAB" tombant dans else TFS) ; ChartSeries.showExtrema=false pour la série ; 11 tests LabTrajectoryModelTest (183 verts) + lint ; validé émulateur (courbe passe exactement sur le lab 248, 0 crash). | | 11 sept. 2026 (session v1.5.0) | **« Tracé labs »** (demande débattue) : courbe hybride `M(t)×ρ(t)` ancrée sur les labs — ρ log-linéaire entre labs, garde #61, E2 seul, fenêtre labs, chip off par défaut + scroll horizontal CONFINÉ (chip 4ᵉ coupé hors fenêtre, constat émulateur) + leçon #63 légende dupliquée ("LAB" tombant dans else TFS) ; ChartSeries.showExtrema=false pour la série ; 11 tests LabTrajectoryModelTest (183 verts) + lint ; validé émulateur (courbe passe exactement sur le lab 248, 0 crash). |
| 5 sept. 2026 (session 1) | Plan, vérification GT 3 = Lite Wearable, création couche données + ancien moteur Bateman + ancien ReminderManager. Extraction des modèles du `Estrogen.ods` → `/tmp/pk_models.json` (6 profils × 8001 h + params D/k1–k3). | | 5 sept. 2026 (session 1) | Plan, vérification GT 3 = Lite Wearable, création couche données + ancien moteur Bateman + ancien ReminderManager. Extraction des modèles du `Estrogen.ods` → `/tmp/pk_models.json` (6 profils × 8001 h + params D/k1–k3). |
| 5 sept. 2026 | L'utilisatrice mentionne un travail d'un assistant tiers « **Mimo V2.5** » : **aucune trace trouvée** (fichiers identiques à la session 1, timestamps identiques). Reprise depuis l'état existant. Bugs trouvés au passage : settings.gradle, BootReceiver, cancel PendingIntent. | | 5 sept. 2026 | L'utilisatrice mentionne un travail d'un assistant tiers « **Mimo V2.5** » : **aucune trace trouvée** (fichiers identiques à la session 1, timestamps identiques). Reprise depuis l'état existant. Bugs trouvés au passage : settings.gradle, BootReceiver, cancel PendingIntent. |
@ -485,18 +486,21 @@ MAIS passant par tous les points ».
- **Garde anti-#61** : labs croissants + `labIsSignificant` — un lab hors - **Garde anti-#61** : labs croissants + `labIsSignificant` — un lab hors
fenêtre d'action (dose de test de janvier, labs tardifs) N'EST PAS un fenêtre d'action (dose de test de janvier, labs tardifs) N'EST PAS un
ancrage (reproduction épinglée dans LabTrajectoryModelTest). ancrage (reproduction épinglée dans LabTrajectoryModelTest).
- **E3 = E2 uniquement** : les labs T sont trop rares + unités variées - **E2 uniquement** : les labs T sont trop rares + unités variées
(bug #26) — hors scope (choix v1.5.0). Pas de courbe T pour la série (bug #26) — hors scope (choix v1.5.0). Pas de courbe T pour la série
(`tStyle = null` → rien de dessiné, miroir CurveChart). (`tStyle = null` → rien de dessiné, miroir CurveChart).
- **Fenêtre = [1er lab significatif ; dernier lab] ∩ fenêtre du graphique** — - **Fenêtre = [1er lab significatif ; dernier lab] ∩ fenêtre du graphique** —
toujours bornée aux labs ; vieillir après le dernier lab = rien dessiné toujours bornée aux labs ; vieillir après le dernier lab = rien dessiné
(l'app n'invente pas de niveau). (l'app n'invente pas de niveau). ⚠️ v1.6.0 : ce comportement reste le
DÉFAUT — avec le chip « Prolonger », la courbe peut se PROLONGER au-delà
du dernier lab (ρ constant, horizon = extinction du modèle — cf
§7.10.bis).
- < 2 labs significatifs → courbe vide (un lab seul = pas d'intervalle ; - < 2 labs significatifs → courbe vide (un lab seul = pas d'intervalle ;
un point isolé ne définit pas de tracé). un point isolé ne définit pas de tracé) — et donc jamais de prolongation.
- **Indépendante de la calibration** : elle EST sa propre calibration - **Indépendante de la calibration** : elle EST sa propre calibration
continue (ratio brut, `scaleFactor` stocké ignoré ET `scalePerEster` continue (ratio brut, `scaleFactor` stocké ignoré ET `scalePerEster`
ignoré — l'inverse serait une DOUBLE correction, épinglé par test). ignoré — l'inverse serait une DOUBLE correction, épinglé par test).
efecto : les modèles rapprochés au sein du même ancrage CONVERGENT effet : les modèles rapprochés au sein du même ancrage CONVERGENT
(le mi-chemin ESE vs WHS colle à ~2 % — `modelOverride` garde une (le mi-chemin ESE vs WHS colle à ~2 % — `modelOverride` garde une
empreinte intermédiaire, testé avec le seuil du test moteur WHS). empreinte intermédiaire, testé avec le seuil du test moteur WHS).
- **Hors pics/creux** (chip "Pics / creux") : la série passe - **Hors pics/creux** (chip "Pics / creux") : la série passe
@ -505,16 +509,93 @@ MAIS passant par tous les points ».
- **UI** : chip « Tracé labs » / "Lab track" dans la RANGÉE MODÈLES (4ᵉ - **UI** : chip « Tracé labs » / "Lab track" dans la RANGÉE MODÈLES (4ᵉ
chip — scroll horizontal CONFINÉ à la rangée ; constat v1.5.0 en émulateur : chip — scroll horizontal CONFINÉ à la rangée ; constat v1.5.0 en émulateur :
le 4ᵉ chip était coupé hors fenêtre avec une Row figée). OFF par défaut. le 4ᵉ chip était coupé hors fenêtre avec une Row figée). OFF par défaut.
v1.6.0 : 5ᵉ chip « Prolonger » (off par défaut, désactivé tant que
Tracé labs est off) — prolongation au-delà du dernier lab (§7.10.bis).
- ⚠️ **Leçon #63 (v1.5.0, émulateur)** : la clé "LAB" dans la liste `curves` - ⚠️ **Leçon #63 (v1.5.0, émulateur)** : la clé "LAB" dans la liste `curves`
doit être EXCLUE de la boucle de légende (`when (model)` — le `else` doit être EXCLUE de la boucle de légende (`when (model)` — le `else`
= TFS imprimerait la légende TFS une 2ᵉ fois). Série → couleur dédiée = TFS imprimerait la légende TFS une 2ᵉ fois). Série → couleur dédiée
`ChartLabTrajectory` #C2185B dashed. `ChartLabTrajectory` #C2185B dashed. v1.6.0 : la clé "LABX" (partie
prolongée) a la MÊME exigence — branche explicite dans le `when`, sinon
le `else` (= TFS) imprimerait la légende TFS une 2ᵉ fois.
- **INVARIANT** : cette courbe n'entre JAMAIS dans `levelAt` — accueil, - **INVARIANT** : cette courbe n'entre JAMAIS dans `levelAt` — accueil,
seuils d'alerte, rappels utilisent toujours le modèle calibré (§9.bis). seuils d'alerte, rappels utilisent toujours le modèle calibré (§9.bis).
### 7.10.bis Prolongation au-delà du dernier lab (v1.6.0) — `pk/LabTrajectoryModel.kt`
**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 — précisément la période la
plus récente (entre la dernière prise de sang et maintenant) était
invisible sur la courbe de comparaison.
**Recette** : `extendBeyondLastLab = true` (paramètre de
`computeLabAnchoredCurve`, chip `Prolonger` — off par défaut, désactivé
tant que Tracé labs est off). La borne droite de la fenêtre devient
`min(demande, horizon)` et **la boucle de calcul est INCHANGÉE** :
`ratioAt` retournait déjà ρ_last au-delà du dernier ancre (garde
défensive v1.5.0) — élargir la fenêtre EST la prolongation. Continuité au
point de suture garantie par construction (ρ(t_last) = ρ_last exactement,
constant à droite comme l'interpolation aboutit à ρ_last à gauche).
- **Formule au-delà du dernier lab** : `courbe(t) = M(t) × ρ_last` où
- `M(t)` = le modèle PK brut : porte l'« évolution classique de l'ester »
(Tmax, queues, accumulation) ET LES DOSES LOGUÉES après le dernier lab —
une nouvelle injection (override d'ester compris : EV après des labs
EEn, etc.) fait repartir la courbe en pic à l'amplitude calibrée
(épinglé par test `une dose apres le dernier lab refait monter la
courbe`) ;
- `ρ_last` = ratio lab ÷ prédiction au dernier lab significatif, supposé
CONSTANT. **Pourquoi pas une extrapolation de pente** : la pente entre
deux labs est un morphing entre deux corrections d'amplitude, pas une
tendance physiologique — l'extrapoler divergerait (un ratio qui monte
de 5 %/jour finirait en facteurs ×10 inventés). ρ_last = le meilleur
estimateur disponible, même sémantique que la calibration classique
(le facteur stocké s'applique aux doses futures).
- **Horizon (`extensionHorizonEndMs`, internal)** : dernière dose E2 +
`cutoffHours(son traitement)` — `cutoffHours` a été rendu PUBLIC (v1.6.0)
pour que la prolongation partage la MÊME sémantique d'extinction que le
moteur (10 t½ terminales V3C/WHS, fin de table ODS, 30 t½ Bateman). Au-delà,
M(t) = 0 : dessiner plus serait une ligne à zéro inventée. Retourne
`null` (pas de prolongation) si aucune dose E2 ou si le modèle est DÉJÀ
éteint au dernier lab.
- **Split visuel "LAB" / "LABX" (ChartScreen)** : `AnchoredCurve` expose
`lastAnchorMs` (timestamp du dernier ancre, `null` si < 2 ancres) ;
l'écran splitte la série au dernier ancre — "LAB" = partie ancrée (rose
plein de la v1.5.0), "LABX" = partie prolongée (MÊME couleur atténuée
α 0,55, dashed) avec SA légende (`legend_lab_track_extend` FR/EN) :
« estimation, plus ancrée ». L'estimation ne peut pas se confondre avec
le mesuré.
- **AVERTISSEMENT (demande v1.6.0)** : quand la partie prolongée est
visible, un texte s'affiche sous la légende
(`lab_track_extend_warning` FR/EN) : « simple simulation, sans garantie
de correspondre au réel — 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 ». Même condition d'affichage que la légende
LABX (`labExtendVisible`) : il ne peut jamais y avoir de courbe
prolongée sans son avertissement.
- **Leçon #63 réappliquée** : la branche `"LABX" ->` du `when` de légende
est EXPLICITE et AVANT le `else` — sinon la légende TFS serait imprimée
une 2ᵉ fois (le `else` = TFS).
- **Garde #61 inchangée** : seuls les labs significatifs ancrent — un lab
tardif hors fenêtre d'action ne prolonge PAS la courbe et n'est jamais
traversé (épinglé par test).
- **Comportement par défaut inchangé** : flag absent ou `false` =
v1.5.0 bit-compatible (testé : points identiques).
- **Limites assumées (documentées, pas des bugs)** :
- changement d'ESTER après le dernier lab : l'amplitude du nouvel ester
reste calibrée par le ρ de l'ancien — même limite que la calibration
classique (pas de lab de la nouvelle période → fallback) ;
- les doses PRÉVISIONNELLES ne participent jamais : la projection future
reste la série « Prévision » (modèles calibrés) — le tracé labs ne
consomme que des doses RÉELLES ;
- **INVARIANT v1.5.0 étendu** : la partie prolongée non plus n'entre
JAMAIS dans `levelAt` (display-end only, série "LABX" purement visuelle).
- 8 nouveaux tests dans `LabTrajectoryModelTest` (191 verts au total).
## 8. Tests unitaires ## 8. Tests unitaires
**183 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **160 sans **191 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **168 sans
les données de test locales** (cf §8.bis : les 4 classes de régression, les données de test locales** (cf §8.bis : les 4 classes de régression,
6/6/6/5 tests, sont skippées via `Assume`). Dépendance : JUnit 4.13.2. 6/6/6/5 tests, sont skippées via `Assume`). Dépendance : JUnit 4.13.2.
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution = Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
@ -589,8 +670,8 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
dans le code (le scanner refuserait, et ce serait une fuite). dans le code (le scanner refuserait, et ce serait une fuite).
- les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le - les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le
fichier, la classe est IGNORÉE** (skipped, pas failed) — un clone neuf ou une CI fichier, la classe est IGNORÉE** (skipped, pas failed) — un clone neuf ou une CI
exécute 160 tests au lieu de 183 (v1.5.0 ; chiffres historiques : 44/87 à v1.3.x, exécute 168 tests au lieu de 191 (v1.6.0 ; chiffres historiques : 44/87 à v1.3.x,
150/172 à v1.4.x) ; 150/172 à v1.4.x, 160/183 à v1.5.0) ;
- le workdir des tests Gradle est le dossier du module (`app/`) → les tests - le workdir des tests Gradle est le dossier du module (`app/`) → les tests
cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ; cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ;
- **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous - **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous
@ -716,7 +797,7 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
sans Posologie ou sans doses, agrégat multi-traitements (le plus tôt sans Posologie ou sans doses, agrégat multi-traitements (le plus tôt
gagne), traitements inactifs/désactivés → null. gagne), traitements inactifs/désactivés → null.
- **`LabTrajectoryModelTest`** (11) : v1.5.0 — « Tracé labs » (§7.10) : - **`LabTrajectoryModelTest`** (19) : v1.5.0 — « Tracé labs » (§7.10) :
passage EXACT sur chaque lab (fp 1e-6), ρ log-linéaire (fonction pure + passage EXACT sur chaque lab (fp 1e-6), ρ log-linéaire (fonction pure +
courbe recomposée via le moteur — M recalcule et ρ = geometric mean), courbe recomposée via le moteur — M recalcule et ρ = geometric mean),
1 lab → vide, **garde #61** (lab tardif PAS un ancrage + fenêtre bornée 1 lab → vide, **garde #61** (lab tardif PAS un ancrage + fenêtre bornée
@ -726,6 +807,14 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
`modelOverride` (ESE vs WHS — divergence franche : ESE≈TFS quasi `modelOverride` (ESE vs WHS — divergence franche : ESE≈TFS quasi
identiques par CONSTRUCTION, seuil 2 % doc §8), labs T ignorés, identiques par CONSTRUCTION, seuil 2 % doc §8), labs T ignorés,
doublons de timestamp (2ᵉ ratio gagne), bornes défensives ratioAt. doublons de timestamp (2ᵉ ratio gagne), bornes défensives ratioAt.
v1.6.0 — **prolongation** (§7.10.bis) : identité `M × ρ_last` exacte
au-delà du dernier lab (10/20/30 j), suture exacte au dernier ancre,
horizon = cutoff du traitement (dernier point ≤ cutoff < dernier point + pas),
dose EV loguée après le dernier lab → pic de prolongation (> ×1,3 le
niveau pré-injection — la forme suit dosage + ester), flag off =
v1.5.0 bit-compatible, garde #61 sur la prolongation (le lab tardif ne
prolonge ni n'ancre), gardes du helper d'horizon (pas de dose / modèle
éteint → null), `lastAnchorMs` (null si < 2 ancres).
**Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis **Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis
la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu. la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu.
@ -912,6 +1001,13 @@ triangles ▲▼ via `PharmacokineticEngine.detectExtrema` **accompagnés de la
valeur estimée** du pic (au-dessus) ou du creux (en dessous), dans la couleur valeur estimée** du pic (au-dessus) ou du creux (en dessous), dans la couleur
de la série (`drawExtremum`). de la série (`drawExtremum`).
**Séries « Tracé labs » (v1.5.0/v1.6.0)** : le ChartScreen peut ajouter les
clés "LAB" (partie ANCRÉE — passe par les labs, rose #C2185B dashed) et
"LABX" (partie PROLONGÉE — M × ρ_last constant, même rose atténué α 0,55)
— cf §7.10 / §7.10.bis pour le moteur et les gardes ; les deux clés sont
SKIPPÉES par la boucle de légende (leçon #63) et ont leurs entrées dédiées
+ avertissement « simulation » quand LABX est visible.
**Zoom (v1.2.9)** : pinch **et** boutons − / + — plage temporelle bornée **Zoom (v1.2.9)** : pinch **et** boutons − / + — plage temporelle bornée
**6 h → 300 j** (`MIN_RANGE_H`/`MAX_RANGE_H`), point focal du pinch maintenu **6 h → 300 j** (`MIN_RANGE_H`/`MAX_RANGE_H`), point focal du pinch maintenu
fixe dans le temps (formule centroid), échantillonnage **adaptatif** fixe dans le temps (formule centroid), échantillonnage **adaptatif**
@ -1507,8 +1603,8 @@ vérifie pas en tests JVM).
1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`, 1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`,
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y). `versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 183 au 2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 191 au
total, 160 si `local-test-data/` est absent (les 4 classes de régression total, 168 si `local-test-data/` est absent (les 4 classes de régression
réelles sont skippées via `Assume`, 6/6/6/5 tests) ; **lint vert réelles sont skippées via `Assume`, 6/6/6/5 tests) ; **lint vert
obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait
attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet : attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet :
@ -1836,6 +1932,14 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
du graphique (toggles modèles, calibration) n'est pas modifié ; les du graphique (toggles modèles, calibration) n'est pas modifié ; les
jours sans labs récents : la courbe s'arrête au dernier lab (le jours sans labs récents : la courbe s'arrête au dernier lab (le
vieillir est normal) ; chip décoché = plus de série ; 0 crash vieillir est normal) ; chip décoché = plus de série ; 0 crash
- [ ] **v1.6.0** : **Prolonger le tracé labs** : avec `Tracé labs` ON,
activer `Prolonger` → la courbe continue APRÈS le dernier lab
(rose atténué) jusqu'à l'extinction du modèle, avec légende dédiée
ET AVERTISSEMENT « simple simulation, sans garantie… » sous la
légende ; une dose loguée après le dernier lab fait repartir la
courbe en pic ; désactiver `Prolonger` → retour à la fenêtre
[1er ; dernier lab] exacte ; `Tracé labs` OFF → le chip `Prolonger`
est désactivé ; l'avertissement disparaît avec la série
- [ ] **v1.4.10** : **pan sur la vue 24 h** : glisser horizontalement à - [ ] **v1.4.10** : **pan sur la vue 24 h** : glisser horizontalement à
24 h doit déplacer la courbe (fix #62 : les deltas < 1 h s'accumulent 24 h doit déplacer la courbe (fix #62 : les deltas < 1 h s'accumulent
— avant, le pan ne bougeait jamais sur 24 h) ; tester aussi à 7 j et — avant, le pan ne bougeait jamais sur 24 h) ; tester aussi à 7 j et
@ -1856,7 +1960,7 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
tap ; et TOUTES les courbes affichées reflètent la calibration (test) tap ; et TOUTES les courbes affichées reflètent la calibration (test)
--- ---
*Doc mise à jour le 11 sept. 2026 (v1.5.0) — build OK, lint vert, 183/183 tests verts (160 sans *Doc mise à jour le 12 sept. 2026 (v1.6.0) — build OK, lint vert, 191/191 tests verts (168 sans
les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée
de santé dans le dépôt ni l'historique. Fil des corrections : v1.3.3 = reprise de de santé dans le dépôt ni l'historique. Fil des corrections : v1.3.3 = reprise de
maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 crashs maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 crashs

View File

@ -204,6 +204,20 @@ Pics de référence (dose unique de 5 mg, IM) :
- **Pics / creux** (chip sur le graphique) : triangles ▲▼ aux extrema estimés de - **Pics / creux** (chip sur le graphique) : triangles ▲▼ aux extrema estimés de
chaque courbe (E2 et T, les deux modèles) — pratique pour visualiser d'un coup chaque courbe (E2 et T, les deux modèles) — pratique pour visualiser d'un coup
d'œil les hauts et les bas entre deux injections d'œil les hauts et les bas entre deux injections
- **Tracé labs** (chip `Tracé labs`, off par défaut) : une courbe E2 qui suit la
forme du modèle PK MAIS passe EXACTEMENT par chacune de tes prises de sang —
la trajectoire que tes labs tracent, avec la dynamique du modèle entre deux
analyses (rose pointillé). Entre le 1er et le dernier lab seulement : la
courbe ne s'invente pas de niveau.
**Prolonger** (chip, off par défaut — actif seulement quand Tracé labs est on) :
la courbe se PROLONGE après ton dernier lab : le modèle (dosages, esters
injectés depuis, pics et descentes classiques) × le ratio mesuré à ton dernier
lab. La partie prolongée est dessinée en rose ATTÉNUÉ avec sa légende
(« estimation, plus ancrée ») et un AVERTISSEMENT : c'est une simple
simulation sans garantie de correspondre au réel, extrapolée à partir de tes
résultats de laboratoire — qui peuvent eux-mêmes être erronés. Elle s'arrête
quand le modèle lui-même s'éteint. Compare-la à ta prochaine prise de sang,
et fie-toi à elle plutôt qu'à cette courbe
- **Calibration automatique** (Paramètres, désactivée par défaut) : quand activée, les - **Calibration automatique** (Paramètres, désactivée par défaut) : quand activée, les
facteurs d'échelle et le modèle T sont ajustés en continu depuis tes labs — pour facteurs d'échelle et le modèle T sont ajustés en continu depuis tes labs — pour
l'affichage seulement, tes réglages stockés ne changent pas. **Chaque ester est l'affichage seulement, tes réglages stockés ne changent pas. **Chaque ester est