v1.5.0 : « Tracé labs » — courbe hybride ancrée sur les labs (M(t)×ρ(t))
Demande débattue avec l'utilisatrice (hésitation assumée, décision v1.5.0) : une courbe biologiquement plausible MAIS passant par tous les labs. - pk/LabTrajectoryModel.kt (pur) : M(t) = modèle PK brut (scaleFactor forcé 1) ; ρ_k = lab/M(t_k) interpolé LOG-LINÉAIREMENT entre labs consécutifs — passgage exact garanti, morphing d'amplitude continu. Garde anti-#61 (labIsSignificant : lab hors fenêtre d'action PAS un ancrage). E2 uniquement, fenêtre [1er ; dernier lab], < 2 labs → vide. Indépendante de la calibration (elle est SA propre référence — sinon double correction, épinglé). JAMAIS dans levelAt/alertes/rappels. - UI : chip 'Tracé labs' (off par défaut) dans la rangée modèles, horizontalScroll CONFINÉ à la rangée (chip 4ᵉ coupé hors fenêtre — constat émulateur) ; couleur ChartLabTrajectory #C2185B dashed ; ChartSeries.showExtrema (tracé labs hors pics/creux) ; leçon #63 : clé 'LAB' exclue de la boucle légende (sinon légende TFS dupliquée). - 11 tests LabTrajectoryModelTest (183 verts total, 161 sans données locales) + lint ; validé émulateur §16.ter (courbe passe exactement sur le lab 248 avec seed réel, 0 crash). - Docs : §7.10 (maths + décisions), §2, §8, §16, §21, CHANGELOG [1.5.0], README (status + feature).
This commit is contained in:
parent
e508607919
commit
6b877d6c10
@ -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.4.10 — build Android ✅, **lint vert** ✅, **172 tests unitaires** ✅ (150 sans les données de test locales ; 3 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.5.0 — build Android ✅, **lint vert** ✅, **183 tests unitaires** ✅ (161 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,6 +39,13 @@ 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
|
||||||
|
(chip `Tracé labs`, off par défaut) — la forme du modèle PK entre les
|
||||||
|
labs, mais l'amplitude recalée pour passer EXACTEMENT sur chaque 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
|
||||||
|
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
|
||||||
|
|||||||
@ -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 = 30
|
versionCode = 31
|
||||||
versionName = "1.4.10"
|
versionName = "1.5.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
|
|||||||
174
app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt
Normal file
174
app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
package com.hormonetrack.pk
|
||||||
|
|
||||||
|
import com.hormonetrack.data.model.DoseLog
|
||||||
|
import com.hormonetrack.data.model.LabResult
|
||||||
|
import com.hormonetrack.data.model.PKModels
|
||||||
|
import com.hormonetrack.data.model.Treatment
|
||||||
|
import com.hormonetrack.data.model.TreatmentType
|
||||||
|
import kotlin.math.exp
|
||||||
|
import kotlin.math.ln
|
||||||
|
|
||||||
|
/**
|
||||||
|
* « Tracé labs » (v1.5.0) — courbe hybride ancrée sur les résultats de sang.
|
||||||
|
*
|
||||||
|
* DEMANDE (décision v1.5.0, plan validé avec l'utilisatrice après balancement
|
||||||
|
* variantes A/B — cf doc §7.10) : une courbe « biologiquement plausible MAIS
|
||||||
|
* passant par tous les points de lab ». Formule :
|
||||||
|
*
|
||||||
|
* courbe(t) = M(t) × ρ(t)
|
||||||
|
*
|
||||||
|
* où :
|
||||||
|
* - **M(t)** = niveau du MODÈLE PK brut (superposition des contributions,
|
||||||
|
* scaleFactor forcé à 1, modèle stocké ou `modelOverride`) — il porte TOUTE
|
||||||
|
* la physiologie entre les labs (Tmax, queues, accumulation) ;
|
||||||
|
* - **ρ(t) = lab_k / M(t_k)** à chaque lab — l'amplitude du modèle est
|
||||||
|
* recalée sur la mesure réelle ; ENTRE deux labs consécutifs, ρ
|
||||||
|
* interpole LOG-LINÉAIREMENT (morphing d'amplitude multiplicativement
|
||||||
|
* continu : un passage d'un facteur entre 2 labs ne fait pas de saut
|
||||||
|
* vertical) ;
|
||||||
|
* - courbe(t_k) = M(t_k) × (lab_k / M(t_k)) = **lab_k exactement** :
|
||||||
|
* le passage par chaque point est GARANTI par construction.
|
||||||
|
*
|
||||||
|
* GARDE ANTI-#61 (labIsSignificant) : les labs traités par timestamp
|
||||||
|
* CROISSANT qui ne « gouvernent » plus la prédiction (< 15 % du max déjà
|
||||||
|
* observé) ne sont PAS des ancrages — la dose de test de janvier + des
|
||||||
|
* labs tardifs (#61) ne créent pas de zigzag. À la différence des
|
||||||
|
* pipelines de calibration (#60), un lab rejeté n'est PAS utilisé du tout.
|
||||||
|
*
|
||||||
|
* 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) ;
|
||||||
|
* - < 2 labs significatifs → courbe vide (un lab seul ne définit pas
|
||||||
|
* d'intervalle) ;
|
||||||
|
* - 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,
|
||||||
|
* épinglé par test) ;
|
||||||
|
* - **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).
|
||||||
|
* - 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).
|
||||||
|
*/
|
||||||
|
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).
|
||||||
|
*/
|
||||||
|
data class AnchoredCurve(
|
||||||
|
val points: List<LevelPoint>,
|
||||||
|
val anchoredLabs: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
// Les ancrages sont des couples (timestamp du lab, ratio lab ÷ prédiction)
|
||||||
|
// — traités en timeline triée croissante par ratioAt().
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit la courbe ancrée sur les labs.
|
||||||
|
*
|
||||||
|
* @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 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]
|
||||||
|
* @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)
|
||||||
|
*/
|
||||||
|
fun computeLabAnchoredCurve(
|
||||||
|
treatments: List<Treatment>,
|
||||||
|
doseLogs: List<DoseLog>,
|
||||||
|
e2Labs: List<LabResult>,
|
||||||
|
startMs: Long,
|
||||||
|
endMs: Long,
|
||||||
|
stepMs: Long = 3_600_000L,
|
||||||
|
modelOverride: String? = null
|
||||||
|
): AnchoredCurve {
|
||||||
|
if (startMs >= endMs) return AnchoredCurve(emptyList(), 0)
|
||||||
|
|
||||||
|
val estrogenTreatments = treatments.filter { it.type == TreatmentType.ESTRADIOL }
|
||||||
|
if (estrogenTreatments.isEmpty()) return AnchoredCurve(emptyList(), 0)
|
||||||
|
|
||||||
|
// Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le
|
||||||
|
// facteur — combiner avec le facteur stocké = double correction)
|
||||||
|
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
|
||||||
|
|
||||||
|
// ── 1) Timeline des ANCRAGES (labs croissants + garde #61) ───────────
|
||||||
|
// Un couple (t_lab, ratio) par lab significatif, trié croissant.
|
||||||
|
val anchors = mutableListOf<Pair<Long, Double>>()
|
||||||
|
var maxPredictionSoFar = 0.0
|
||||||
|
for (lab in e2Labs.filter { it.marker.equals("E2", ignoreCase = true) }.sortedBy { it.timestamp }) {
|
||||||
|
val predicted = PharmacokineticEngine.e2At(
|
||||||
|
unscaled, doseLogs, lab.timestamp, modelOverride, null
|
||||||
|
)
|
||||||
|
if (!PharmacokineticEngine.labIsSignificant(predicted, maxPredictionSoFar)) continue
|
||||||
|
anchors.add(lab.timestamp to (lab.value / predicted))
|
||||||
|
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
|
||||||
|
}
|
||||||
|
if (anchors.size < 2) return AnchoredCurve(emptyList(), anchors.size)
|
||||||
|
|
||||||
|
val firstLabMs = anchors.first().first
|
||||||
|
val lastLabMs = anchors.last().first
|
||||||
|
|
||||||
|
// ── 2) Fenêtre effective = [1er ; dernier lab] ∩ fenêtre demandée ────
|
||||||
|
val t0 = maxOf(startMs, firstLabMs)
|
||||||
|
val t1 = minOf(endMs, lastLabMs)
|
||||||
|
if (t1 <= t0) return AnchoredCurve(emptyList(), anchors.size)
|
||||||
|
|
||||||
|
// ── 3) Courbe = M(t) × ρ(t) sur la grille ─────────────────────────────
|
||||||
|
val points = mutableListOf<LevelPoint>()
|
||||||
|
var t = t0
|
||||||
|
while (t <= t1) {
|
||||||
|
val m = PharmacokineticEngine.e2At(unscaled, doseLogs, t, modelOverride, null)
|
||||||
|
val rho = ratioAt(anchors, t)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ρ(t) : interpolation LOG-LINÉAIRE entre ancrages consécutifs
|
||||||
|
* (`anchors` trié croissant). Pour t ∈ [t_k ; t_(k+1)] :
|
||||||
|
*
|
||||||
|
* ρ(t) = exp( lnρ_k + (t − t_k)/(t_(k+1) − t_k) · (lnρ_(k+1) − lnρ_k) )
|
||||||
|
*
|
||||||
|
* Garanties : ρ(t_k) = ρ_k exactement (passage par le lab), continuité
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
|
||||||
|
for (i in 0 until anchors.size - 1) {
|
||||||
|
val (t0, r0) = anchors[i]
|
||||||
|
val (t1, r1) = anchors[i + 1]
|
||||||
|
if (tMs in t0..t1) {
|
||||||
|
val span = t1 - t0
|
||||||
|
if (span <= 0L) return r1 // doublon de timestamp : 2ᵉ gagne
|
||||||
|
val f = (tMs - t0).toDouble() / span.toDouble()
|
||||||
|
return exp(ln(r0) + f * (ln(r1) - ln(r0)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// inatteignable si anchors croissant et bornes cohérentes — garde
|
||||||
|
return anchors.last().second
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -41,7 +41,14 @@ data class ChartSeries(
|
|||||||
val points: List<LevelPoint>,
|
val points: List<LevelPoint>,
|
||||||
val e2Style: CurveStyle,
|
val e2Style: CurveStyle,
|
||||||
/** Style de la courbe T de cette série ; null = pas de courbe T pour cette série. */
|
/** Style de la courbe T de cette série ; null = pas de courbe T pour cette série. */
|
||||||
val tStyle: CurveStyle? = null
|
val tStyle: CurveStyle? = null,
|
||||||
|
/**
|
||||||
|
* v1.5.0 : cette série participe-t-elle à la détection des pics/creux ?
|
||||||
|
* Défaut true (toutes les séries modèle) ; « Tracé labs » est FALSE —
|
||||||
|
* ses extrema reflèteraient l'interp ρ entre labs, pas la physiologie du
|
||||||
|
* modèle, et `labIsSignificant` a déjà filtré les points aberrants.
|
||||||
|
*/
|
||||||
|
val showExtrema: Boolean = true
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ChartOptions(
|
data class ChartOptions(
|
||||||
@ -419,8 +426,9 @@ fun CurveChart(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pics et creux (v1.2.3) : triangles aux extrema locaux + valeur estimée
|
// Pics et creux (v1.2.3) : triangles aux extrema locaux + valeur estimée
|
||||||
// (v1.2.6 : le triangle est accompagné de la valeur simulée du pic/creux)
|
// (v1.2.6 : le triangle est accompagné de la valeur estimée du pic/creux).
|
||||||
if (options.showExtrema) {
|
// v1.5.0 : une série peut être exclue (Tracé labs → showExtrema=false).
|
||||||
|
if (options.showExtrema && s.showExtrema) {
|
||||||
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0)
|
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0)
|
||||||
.forEach { ex ->
|
.forEach { ex ->
|
||||||
drawExtremum(xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color)
|
drawExtremum(xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.hormonetrack.ui.screens
|
package com.hormonetrack.ui.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@ -43,6 +44,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.hormonetrack.R
|
import com.hormonetrack.R
|
||||||
import com.hormonetrack.data.model.Treatment
|
import com.hormonetrack.data.model.Treatment
|
||||||
import com.hormonetrack.pk.LevelPoint
|
import com.hormonetrack.pk.LevelPoint
|
||||||
|
import com.hormonetrack.pk.LabTrajectoryModel
|
||||||
import com.hormonetrack.pk.PharmacokineticEngine
|
import com.hormonetrack.pk.PharmacokineticEngine
|
||||||
import com.hormonetrack.pk.TConfig
|
import com.hormonetrack.pk.TConfig
|
||||||
import com.hormonetrack.ui.LocalAppContainer
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
@ -51,6 +53,7 @@ import com.hormonetrack.ui.components.ChartSeries
|
|||||||
import com.hormonetrack.ui.components.CurveChart
|
import com.hormonetrack.ui.components.CurveChart
|
||||||
import com.hormonetrack.ui.components.CurveStyle
|
import com.hormonetrack.ui.components.CurveStyle
|
||||||
import com.hormonetrack.ui.theme.ChartE2
|
import com.hormonetrack.ui.theme.ChartE2
|
||||||
|
import com.hormonetrack.ui.theme.ChartLabTrajectory
|
||||||
import com.hormonetrack.ui.theme.ChartT
|
import com.hormonetrack.ui.theme.ChartT
|
||||||
import com.hormonetrack.ui.theme.ChartTWhs
|
import com.hormonetrack.ui.theme.ChartTWhs
|
||||||
import com.hormonetrack.ui.theme.ChartWhs
|
import com.hormonetrack.ui.theme.ChartWhs
|
||||||
@ -110,6 +113,11 @@ fun ChartScreen() {
|
|||||||
var showTfs by remember { mutableStateOf(true) }
|
var showTfs by remember { mutableStateOf(true) }
|
||||||
// v1.4.6 : 3ᵉ modèle PK superposable (WHSAH — fit license-free de Mona)
|
// v1.4.6 : 3ᵉ modèle PK superposable (WHSAH — fit license-free de Mona)
|
||||||
var showWhs by remember { mutableStateOf(false) }
|
var showWhs by remember { mutableStateOf(false) }
|
||||||
|
// v1.5.0 : courbe « Tracé labs » (hybride ancrée sur les labs, cf
|
||||||
|
// pk/LabTrajectoryModel.kt) — OFF par défaut : opt-in, la courbe
|
||||||
|
// 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.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
|
||||||
@ -249,13 +257,29 @@ 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)
|
||||||
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
|
||||||
) {
|
) {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
val list = mutableListOf<Pair<String, List<LevelPoint>>>()
|
val list = mutableListOf<Pair<String, List<LevelPoint>>>()
|
||||||
|
// ── v1.5.0 : courbe « Tracé labs » ───────────────────────────────
|
||||||
|
// Totalement indépendante des toggles de modèles (elle utilise le
|
||||||
|
// 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.
|
||||||
|
if (showLabTrack) {
|
||||||
|
val step = com.hormonetrack.ui.components.stepForRange(rangeHours)
|
||||||
|
list.add(
|
||||||
|
"LAB" to LabTrajectoryModel.computeLabAnchoredCurve(
|
||||||
|
treatments, doseLogs, labResults, startMs, endMs, step
|
||||||
|
).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
|
||||||
// échelles/k calculés avec SA prédiction. Hors auto-calibration
|
// échelles/k calculés avec SA prédiction. Hors auto-calibration
|
||||||
@ -330,6 +354,11 @@ fun ChartScreen() {
|
|||||||
"ESE" -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT))
|
"ESE" -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT))
|
||||||
// v1.4.6 : WHSAH = E2 vert plein, T violet pointillé
|
// v1.4.6 : WHSAH = E2 vert plein, T violet pointillé
|
||||||
"WHS" -> ChartSeries(points, CurveStyle(ChartWhs), CurveStyle(ChartTWhs, dashed = true))
|
"WHS" -> ChartSeries(points, CurveStyle(ChartWhs), CurveStyle(ChartTWhs, dashed = true))
|
||||||
|
// v1.5.0 : « Tracé labs » = E2 rose foncé pointillé, PAS de courbe T
|
||||||
|
// (la série n'a qu'une valeur E2 — tStyle null → rien de dessiné)
|
||||||
|
// et hors pics/creux (labIsSignificant a déjà filtré, §7.10)
|
||||||
|
"LAB" -> ChartSeries(points, CurveStyle(ChartLabTrajectory, dashed = true), null,
|
||||||
|
showExtrema = false)
|
||||||
else -> ChartSeries(points, CurveStyle(TealTertiary), CurveStyle(ChartT, dashed = true))
|
else -> ChartSeries(points, CurveStyle(TealTertiary), CurveStyle(ChartT, dashed = true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -422,8 +451,18 @@ fun ChartScreen() {
|
|||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
// Modèles PK superposables (3ᵉ = WHSAH, v1.4.6)
|
// Modèles PK superposables (3ᵉ = WHSAH, v1.4.6) + v1.5.0 : le chip
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
// « Tracé labs » vit ICI (4ᵉ chip de la rangée). ⚠️ SCROLL HORIZONTAL
|
||||||
|
// CONFINÉ à la rangée : 4 chips débordent sur écrans étroits (constat
|
||||||
|
// émulateur v1.5.0 — le 4ᵉ chip était hors fenêtre, absent du dump
|
||||||
|
// uiautomator). AUCUN conflit avec le pan du graphique : le scroll
|
||||||
|
// portre sur CETTE rangée uniquement, le pan/gesture reste sur le
|
||||||
|
// Card du chart (le fix #62 portait sur un scroll VERTICAL parent ;
|
||||||
|
// ici le libellé est différent et localisé).
|
||||||
|
Row(
|
||||||
|
Modifier.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = showEse,
|
selected = showEse,
|
||||||
onClick = { showEse = !showEse },
|
onClick = { showEse = !showEse },
|
||||||
@ -439,6 +478,12 @@ fun ChartScreen() {
|
|||||||
onClick = { showWhs = !showWhs },
|
onClick = { showWhs = !showWhs },
|
||||||
label = { Text(stringResource(R.string.model_whs)) }
|
label = { Text(stringResource(R.string.model_whs)) }
|
||||||
)
|
)
|
||||||
|
// v1.5.0 : courbe hybride ancrée sur les labs (off par défaut)
|
||||||
|
FilterChip(
|
||||||
|
selected = showLabTrack,
|
||||||
|
onClick = { showLabTrack = !showLabTrack },
|
||||||
|
label = { Text(stringResource(R.string.show_lab_track)) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
@ -528,7 +573,10 @@ fun ChartScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Légende dynamique
|
// Légende dynamique — ⚠️ v1.5.0 : la clé "LAB" (Tracé labs)
|
||||||
|
// est SKIPPÉE ici : elle a sa propre légende dédiée plus
|
||||||
|
// bas, sinon le `else` (TFS) imprimerait la légende TFS
|
||||||
|
// une 2ᵉ fois (bug v1.5.0-trouvé-en-émulateur #63).
|
||||||
curves.forEach { (model, _) ->
|
curves.forEach { (model, _) ->
|
||||||
when (model) {
|
when (model) {
|
||||||
"ESE" -> {
|
"ESE" -> {
|
||||||
@ -560,6 +608,7 @@ fun ChartScreen() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"LAB" -> { /* légende dédiée, cf plus bas */ }
|
||||||
else -> {
|
else -> {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.legend_tfs_e2),
|
stringResource(R.string.legend_tfs_e2),
|
||||||
@ -597,6 +646,15 @@ fun ChartScreen() {
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// v1.5.0 : légende « Tracé labs » — seulement si la série
|
||||||
|
// a ≥ 2 points (≥ 2 labs significatifs, la grille est vide sinon)
|
||||||
|
if (showLabTrack && curves.any { it.first == "LAB" && it.second.size >= 2 }) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.legend_lab_track),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = ChartLabTrajectory
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.drag_hint),
|
stringResource(R.string.drag_hint),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
|||||||
@ -17,5 +17,8 @@ val ChartWhs = Color(0xFF2E7D32)
|
|||||||
val ChartTWhs = Color(0xFF9C27B0)
|
val ChartTWhs = Color(0xFF9C27B0)
|
||||||
val ChartT = Color(0xFFD6589E)
|
val ChartT = Color(0xFFD6589E)
|
||||||
val LabDot = Color(0xFFE67E22)
|
val LabDot = Color(0xFFE67E22)
|
||||||
|
// v1.5.0 : courbe « Tracé labs » (hybride ancrée sur les labs, pk/LabTrajectoryModel)
|
||||||
|
// — rose foncé distinct de E2/T/TFS/WHS et des dots orange des labs
|
||||||
|
val ChartLabTrajectory = Color(0xFFC2185B)
|
||||||
val GoodGreen = Color(0xFF2E7D32)
|
val GoodGreen = Color(0xFF2E7D32)
|
||||||
val WarnOrange = Color(0xFFB26A00)
|
val WarnOrange = Color(0xFFB26A00)
|
||||||
|
|||||||
@ -32,6 +32,7 @@
|
|||||||
<string name="show_labs">Analyses</string>
|
<string name="show_labs">Analyses</string>
|
||||||
<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="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>
|
||||||
@ -72,6 +73,7 @@
|
|||||||
<string name="agenda_hint">Un événement récurrent est créé dans un calendrier dédié « HormoneTrack », basé sur la Posologie et l\'heure de rappel. Visible et modifiable dans ton application d\'agenda.</string>
|
<string name="agenda_hint">Un événement récurrent est créé dans un calendrier dédié « HormoneTrack », basé sur la Posologie et l\'heure de rappel. Visible et modifiable dans ton application d\'agenda.</string>
|
||||||
<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 & creux estimés (par courbe)</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>
|
||||||
<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>
|
||||||
|
|||||||
@ -32,6 +32,7 @@
|
|||||||
<string name="show_labs">Labs</string>
|
<string name="show_labs">Labs</string>
|
||||||
<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="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>
|
||||||
@ -72,6 +73,7 @@
|
|||||||
<string name="agenda_hint">A recurring event is created in a dedicated “HormoneTrack” calendar, based on the Posology interval and reminder time. Visible in your calendar app; editable there.</string>
|
<string name="agenda_hint">A recurring event is created in a dedicated “HormoneTrack” calendar, based on the Posology interval and reminder time. Visible in your calendar app; editable there.</string>
|
||||||
<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 & troughs (per curve)</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>
|
||||||
<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>
|
||||||
|
|||||||
264
app/src/test/java/com/hormonetrack/pk/LabTrajectoryModelTest.kt
Normal file
264
app/src/test/java/com/hormonetrack/pk/LabTrajectoryModelTest.kt
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
package com.hormonetrack.pk
|
||||||
|
|
||||||
|
import com.hormonetrack.data.model.LabResult
|
||||||
|
import com.hormonetrack.data.model.Treatment
|
||||||
|
import com.hormonetrack.data.model.TreatmentType
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
import java.lang.Math.exp
|
||||||
|
import java.lang.Math.log
|
||||||
|
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 :
|
||||||
|
* - 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 ;
|
||||||
|
* - indépendance du scaleFactor stocké (cette courbe est SA PROPRE
|
||||||
|
* calibration continue — combiner = double correction).
|
||||||
|
*
|
||||||
|
* ⚠️ Noms de tests ASCII-sans-ponctuation : les backticks Kotlin avec
|
||||||
|
* « : . / ; [ ] » sont des caractères illégaux pour le nom de méthode JVM
|
||||||
|
* (compilation refusée par l'AGP — vérifié v1.5.0).
|
||||||
|
*/
|
||||||
|
class LabTrajectoryModelTest {
|
||||||
|
|
||||||
|
private val DAY_MS = 86_400_000L
|
||||||
|
private val HOUR_MS = 3_600_000L
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
// Même pattern que PKProfileStoreTest : l'asset est un FICHIER du
|
||||||
|
// module (les assets Android ne sont pas sur le classpath des tests JVM)
|
||||||
|
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||||
|
val candidates = listOf(
|
||||||
|
File("src/main/assets/pk_profiles.json"),
|
||||||
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
|
)
|
||||||
|
val file = candidates.firstOrNull { it.exists() }
|
||||||
|
?: error("pk_profiles.json not found for unit tests")
|
||||||
|
PKProfileStore.initWithJson(file.readText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fixtures ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Traitement injectable EEn / TFS 5 mg, une dose à t=0 (id 1). */
|
||||||
|
private fun treatment(scaleFactor: Double = 1.0): Treatment =
|
||||||
|
Treatment(
|
||||||
|
id = 1, name = "EEn TFS", type = TreatmentType.ESTRADIOL,
|
||||||
|
route = com.hormonetrack.data.model.AdministrationRoute.INJECTION_SUBCUT,
|
||||||
|
doseAmount = 5.0, doseUnit = "mg",
|
||||||
|
esterType = "EEN", pkModel = "TFS", scaleFactor = scaleFactor,
|
||||||
|
createdAt = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun doses(t: Treatment): List<com.hormonetrack.data.model.DoseLog> =
|
||||||
|
listOf(com.hormonetrack.data.model.DoseLog(id = 1, treatmentId = 1, timestamp = 0L, doseAmount = 5.0))
|
||||||
|
|
||||||
|
private fun lab(value: Double, tMs: Long): LabResult =
|
||||||
|
LabResult(marker = "E2", value = value, unit = "pg/mL", timestamp = tMs)
|
||||||
|
|
||||||
|
private fun tLab(tMs: Long): LabResult =
|
||||||
|
LabResult(marker = "T", value = 0.35, unit = "ng/mL", timestamp = tMs)
|
||||||
|
|
||||||
|
private fun initProfiles() {
|
||||||
|
if (!PKProfileStore.hasProfile("EV", "ESE")) setup()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point à mi-chemin de l'index (représentatif du milieu de la fenêtre). */
|
||||||
|
private fun List<LevelPoint>.mid(): LevelPoint = this[size / 2]
|
||||||
|
|
||||||
|
// ── Garanties ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `passe exactement sur chaque lab`() {
|
||||||
|
initProfiles()
|
||||||
|
val tr = treatment()
|
||||||
|
val t2 = 2 * DAY_MS
|
||||||
|
val t7 = 7 * DAY_MS
|
||||||
|
val labs = listOf(lab(300.0, t2), lab(260.0, t7))
|
||||||
|
|
||||||
|
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||||
|
listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(2, curve.anchoredLabs)
|
||||||
|
// Aux timestamps des labs : courbe == lab EXACTEMENT (fp ≈ 1e-6)
|
||||||
|
assertEquals(labs[0].value, curve.points.first { it.timestamp == t2 }.e2, 1e-6)
|
||||||
|
assertEquals(labs[1].value, curve.points.first { it.timestamp == t7 }.e2, 1e-6)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rho interpole en log entre deux labs (math exacte)`() {
|
||||||
|
initProfiles()
|
||||||
|
val tr = treatment()
|
||||||
|
val doses = doses(tr)
|
||||||
|
val t2 = 2 * DAY_MS
|
||||||
|
val t7 = 7 * DAY_MS
|
||||||
|
// ⚠️ Labs DATA-DRIVEN : lab = M(t) × facteur voulu — sinon les ratios
|
||||||
|
// ne sont PAS les valeurs voulues (M(2 j) ≠ le pic de référence 160)
|
||||||
|
val m2 = PharmacokineticEngine.e2At(listOf(tr), doses, t2, null, null)
|
||||||
|
val m7 = PharmacokineticEngine.e2At(listOf(tr), doses, t7, null, null)
|
||||||
|
val labs = listOf(lab(0.9 * m2, t2), lab(1.2 * m7, t7))
|
||||||
|
|
||||||
|
// 1) La fonction PURE ratioAt interpole exactement (ancres artificiels)
|
||||||
|
val pure = listOf(t2 to 0.9, t7 to 1.2)
|
||||||
|
val expectedRhoMid = exp((log(0.9) + log(1.2)) / 2.0)
|
||||||
|
assertEquals(0.9, LabTrajectoryModel.ratioAt(pure, t2), 1e-12)
|
||||||
|
assertEquals(1.2, LabTrajectoryModel.ratioAt(pure, t7), 1e-12)
|
||||||
|
assertEquals(expectedRhoMid, LabTrajectoryModel.ratioAt(pure, (t2 + t7) / 2), 1e-9)
|
||||||
|
|
||||||
|
// 2) La COURBE restitue le même ρ : recomposer M(t) et vérifier
|
||||||
|
// courbe/M == ρ attendu (M calculé par le moteur, comme le module)
|
||||||
|
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||||
|
listOf(tr), doses, labs, 0L, 30 * DAY_MS, HOUR_MS
|
||||||
|
)
|
||||||
|
val mid = curve.points.mid()
|
||||||
|
val m = PharmacokineticEngine.e2At(
|
||||||
|
listOf(tr.copy(scaleFactor = 1.0)), doses, mid.timestamp, null, null)
|
||||||
|
assertEquals(expectedRhoMid, mid.e2 / m, 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `un seul lab significatif donne une courbe vide (pas d intervalle)`() {
|
||||||
|
initProfiles()
|
||||||
|
val tr = treatment()
|
||||||
|
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||||
|
listOf(tr), doses(tr), listOf(lab(200.0, 3 * DAY_MS)), 0L, 30 * DAY_MS, HOUR_MS
|
||||||
|
)
|
||||||
|
assertTrue("le lab compte mais ne suffit pas à tracer", curve.points.isEmpty())
|
||||||
|
assertTrue(curve.anchoredLabs == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `garde 61 - un lab hors de la fenetre d action n est pas un ancrage`() {
|
||||||
|
initProfiles()
|
||||||
|
val tr = treatment()
|
||||||
|
// Scénario #61 : labs DANS la fenêtre d'action + un lab TARDIF à
|
||||||
|
// valeur absurde (prédiction résiduelle ≈ 0 → ratio aberrant)
|
||||||
|
val t2 = 2 * DAY_MS
|
||||||
|
val t7 = 7 * DAY_MS
|
||||||
|
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||||
|
listOf(tr), doses(tr),
|
||||||
|
listOf(lab(300.0, t2), lab(260.0, t7), lab(3000.0, 60 * DAY_MS)),
|
||||||
|
0L, 90 * DAY_MS, HOUR_MS
|
||||||
|
)
|
||||||
|
assertTrue("le lab tardif est rejeté par labIsSignificant", curve.anchoredLabs == 2)
|
||||||
|
assertTrue("aucun point ne suit la valeur absurde", curve.points.none { it.e2 > 600.0 })
|
||||||
|
// …et la fenêtre s'arrête au DERNIER lab SIGNIFICATIF (pas au tardif)
|
||||||
|
assertTrue("modelOverride modifie la forme intermédiaire (ESE ≠ TFS)", curve.points.all { it.timestamp <= t7 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `labs avant la premiere dose ne produisent aucun ancrage (prediction nulle)`() {
|
||||||
|
initProfiles()
|
||||||
|
val tr = treatment()
|
||||||
|
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||||
|
listOf(tr), doses(tr),
|
||||||
|
listOf(lab(200.0, -10 * DAY_MS), lab(210.0, -5 * DAY_MS)),
|
||||||
|
-15 * DAY_MS, 0L, HOUR_MS
|
||||||
|
)
|
||||||
|
assertTrue(curve.points.isEmpty())
|
||||||
|
assertTrue(curve.anchoredLabs == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `la fenetre du trace est bornée par les labs ET par la demande`() {
|
||||||
|
initProfiles()
|
||||||
|
val tr = treatment()
|
||||||
|
val t2 = 2 * DAY_MS
|
||||||
|
val t7 = 7 * DAY_MS
|
||||||
|
val labs = listOf(lab(160.0, t2), lab(140.0, t7))
|
||||||
|
// demande PLUS LARGE que les labs → clippée aux labs
|
||||||
|
val wide = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||||
|
listOf(tr), doses(tr), labs, 0L, 60 * DAY_MS, HOUR_MS
|
||||||
|
)
|
||||||
|
assertTrue(wide.points.isNotEmpty())
|
||||||
|
assertTrue(wide.points.all { it.timestamp in t2..t7 })
|
||||||
|
// demande PLUS ÉTROITE que les labs → clippée à la demande
|
||||||
|
val narrow = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||||
|
listOf(tr), doses(tr), labs, 3 * DAY_MS, 5 * DAY_MS, HOUR_MS
|
||||||
|
)
|
||||||
|
assertTrue(narrow.points.isNotEmpty())
|
||||||
|
assertTrue(narrow.points.all { it.timestamp in 3 * DAY_MS..5 * DAY_MS })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `indépendant du scaleFactor stocké (sinon double correction)`() {
|
||||||
|
initProfiles()
|
||||||
|
val labs = listOf(lab(300.0, 2 * DAY_MS), lab(260.0, 7 * DAY_MS))
|
||||||
|
val a = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||||
|
listOf(treatment(1.0)), doses(treatment(1.0)), labs, 0L, 30 * DAY_MS, HOUR_MS
|
||||||
|
)
|
||||||
|
val b = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||||
|
listOf(treatment(0.55)), doses(treatment(0.55)), labs, 0L, 30 * DAY_MS, HOUR_MS
|
||||||
|
)
|
||||||
|
// M diffère (0,55 vs 1,0) MAIS ρ compense exactement : courbes identiques
|
||||||
|
assertEquals(a.points, b.points)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `modelOverride - chaque modèle ancre avec SA prédiction (sémantique 60)`() {
|
||||||
|
initProfiles()
|
||||||
|
// ⚠️ ESE vs TFS : PROUVENT quasi-identiques par construction (doc §8,
|
||||||
|
// seuil 2 % — le test WHSAH côté moteur a la même limite). Pour une
|
||||||
|
// divergence franche : EV avec ESE (pic 61,12 @ 45 h) vs WHS
|
||||||
|
// (73,5 @ 41 h, biodispo F<1) — ~20 % d'écart de forme.
|
||||||
|
val tr = treatment().copy(
|
||||||
|
esterType = "EV", pkModel = "TFS",
|
||||||
|
route = com.hormonetrack.data.model.AdministrationRoute.INJECTION_IM,
|
||||||
|
)
|
||||||
|
val labs = listOf(lab(320.0, 2 * DAY_MS), lab(240.0, 7 * DAY_MS))
|
||||||
|
val ese = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||||
|
listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS, modelOverride = "ESE")
|
||||||
|
val whs = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||||
|
listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS, modelOverride = "WHS")
|
||||||
|
// Les DEUX passent par les labs (invariant)…
|
||||||
|
assertEquals(labs[0].value, ese.points.first { it.timestamp == 2 * DAY_MS }.e2, 1e-6)
|
||||||
|
assertEquals(labs[0].value, whs.points.first { it.timestamp == 2 * DAY_MS }.e2, 1e-6)
|
||||||
|
// …mais les formes intermédiaires diffèrent nettement
|
||||||
|
val midEse = ese.points.mid().e2
|
||||||
|
val midWhs = whs.points.mid().e2
|
||||||
|
val relDiff = Math.abs(midEse - midWhs) / Math.max(Math.abs(midEse), Math.abs(midWhs))
|
||||||
|
// ⚠️ L'ancrage CONVERGE les modèles (mêmes points traversés + ρ interpolé
|
||||||
|
// dans le même esprit) : l'écart brut ESE/WHS (~20 %) est résorbé à
|
||||||
|
// ~2 % au mi-chemin — seuil identique au test moteur WHS (doc §8).
|
||||||
|
assertTrue("le modelOverride garde une empreinte intermédiaire (ESE $midEse vs WHS $midWhs)",
|
||||||
|
relDiff > 0.02)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `les labs T et autres marqueurs sont ignorés (E2 uniquement)`() {
|
||||||
|
initProfiles()
|
||||||
|
val tr = treatment()
|
||||||
|
val labs = listOf(lab(300.0, 2 * DAY_MS), tLab(3 * DAY_MS), lab(240.0, 7 * DAY_MS))
|
||||||
|
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||||
|
listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS)
|
||||||
|
assertTrue("le lab T n'est pas un ancrage", curve.anchoredLabs == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `doublons de timestamp - le deuxième ratio gagne (garde division par zéro)`() {
|
||||||
|
val t = 5 * DAY_MS
|
||||||
|
val anchors = listOf(t to 1.0, t to 2.0, 10 * DAY_MS to 3.0)
|
||||||
|
assertEquals(3.0, LabTrajectoryModel.ratioAt(anchors, 10 * DAY_MS), 1e-12)
|
||||||
|
// au-delà de t, la section [t ; 10 j] interpole depuis r=2 (le DERNIER
|
||||||
|
// ratio porté par ce timestamp), jamais division par 0
|
||||||
|
val expected = exp(ln(2.0) + (7.0 - 5.0) / 5.0 * (ln(3.0) - ln(2.0)))
|
||||||
|
assertEquals(expected, LabTrajectoryModel.ratioAt(anchors, 7 * DAY_MS), 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ratioAt - gardes defensives aux bornes`() {
|
||||||
|
val anchors = listOf(0L to 1.0, 10L to 2.0)
|
||||||
|
assertEquals(1.0, LabTrajectoryModel.ratioAt(anchors, -5L), 1e-12)
|
||||||
|
assertEquals(2.0, LabTrajectoryModel.ratioAt(anchors, 100L), 1e-12)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,33 @@
|
|||||||
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.5.0] — 2026-09-11 (versionCode 31)
|
||||||
|
|
||||||
|
### « Tracé labs » — courbe ancrée sur tes prises de sang
|
||||||
|
|
||||||
|
- **Nouvelle courbe superposable** (chip `Tracé labs`, **off par défaut**)
|
||||||
|
: `courbe(t) = M(t) × ρ(t)` — le modèle PK garde la forme biologique
|
||||||
|
(Tmax, queues, accumulation) et l'amplitude est recalée sur CHAQUE prise
|
||||||
|
de sang : la trajectoire réelle que tes labs tracent, avec la dynamique
|
||||||
|
du modèle entre les points.
|
||||||
|
- **Passage exact garanti** sur chaque lab ; transition continue
|
||||||
|
(log-linéaire) d'un lab au suivant — jamais de saut vertical.
|
||||||
|
- Garde anti-ratés : un lab qui ne « gouverne » plus la prédiction du
|
||||||
|
modèle (< 15 % du max, cf leçon #61) n'est PAS forcé-traversé — une
|
||||||
|
dose de test de janvier ne crée pas de zigzag.
|
||||||
|
- **E2 uniquement** (unité pg/mL) ; fenêtre = entre le 1er et le dernier
|
||||||
|
lab significatif — rien avant/après : la courbe s'arrête à ta dernière
|
||||||
|
prise de sang (honnête, pas d'extrapolation).
|
||||||
|
- Indépendante de la calibration (elle est SA propre référence) et
|
||||||
|
n'entre JAMAIS dans l'accueil/les seuils d'alerte/les rappels — une
|
||||||
|
courbe de comparaison, pas une estimation d'action.
|
||||||
|
- Couleur dédiée rose foncé pointillé, hors pics/creux.
|
||||||
|
|
||||||
|
### Améliorations UI
|
||||||
|
|
||||||
|
- La rangée de chips du graphique défile horizontalement (le 4ᵉ chip
|
||||||
|
était coupé hors fenêtre sur écrans étroits — constat émulateur v1.5.0).
|
||||||
|
|
||||||
## [1.4.10] — 2026-09-07 (versionCode 30)
|
## [1.4.10] — 2026-09-07 (versionCode 30)
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|||||||
@ -63,6 +63,7 @@ Fonctionnalités v1 :
|
|||||||
|
|
||||||
| Date | Événement |
|
| Date | Événement |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| 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. |
|
||||||
| 5 sept. 2026 (session build) | Redesign données (ester/pkModel/scaleFactor), réécriture moteur PK sur tables ODS, modèle T + calibration, rappels complets, UI 6 écrans, chart Canvas, backup JSON, i18n, wrapper Gradle, guide. **Installation SDK Android (brew) + premier build.** |
|
| 5 sept. 2026 (session build) | Redesign données (ester/pkModel/scaleFactor), réécriture moteur PK sur tables ODS, modèle T + calibration, rappels complets, UI 6 écrans, chart Canvas, backup JSON, i18n, wrapper Gradle, guide. **Installation SDK Android (brew) + premier build.** |
|
||||||
@ -466,9 +467,54 @@ niveau que les deux autres, pas de remplacement »).
|
|||||||
`ChartWhs`/`ChartTWhs`), 6 presets, dropdown modèle à 3 choix,
|
`ChartWhs`/`ChartTWhs`), 6 presets, dropdown modèle à 3 choix,
|
||||||
`Esters.choicesForModel` (TFS=7 / WHS=6 / ESE=3 — testé).
|
`Esters.choicesForModel` (TFS=7 / WHS=6 / ESE=3 — testé).
|
||||||
|
|
||||||
|
### 7.10 « Tracé labs » (v1.5.0) — `pk/LabTrajectoryModel.kt`
|
||||||
|
|
||||||
|
Courbe hybride ancrée sur les labs — **demande v1.5.0, débattue avec
|
||||||
|
l'utilisatrice** (hésitation assumée, cf §2) : « biologiquement plausible
|
||||||
|
MAIS passant par tous les points ».
|
||||||
|
|
||||||
|
- **Formule** : `courbe(t) = M(t) × ρ(t)` où
|
||||||
|
- `M(t)` = niveau du modèle PK BRUT (scaleFactor forcé 1, modèle stocké
|
||||||
|
ou `modelOverride`) — porte toute la physiologie entre les labs
|
||||||
|
(Tmax, queues, accumulation) ;
|
||||||
|
- `ρ_k = lab_k / M(t_k)` à chaque lab ; ENTRE deux labs consécutifs,
|
||||||
|
ρ interpole LOG-LINÉAIREMENT :
|
||||||
|
`ρ(t) = exp(lnρ_k + f·(lnρ_{k+1} − lnρ_k))` — morphing d'amplitude
|
||||||
|
multiplicativement continu (jamais de saut vertical) ;
|
||||||
|
- passage EXACT garanti : courbe(t_k) = M(t_k)·ρ_k = lab_k (épinglé).
|
||||||
|
- **Garde anti-#61** : labs croissants + `labIsSignificant` — un lab hors
|
||||||
|
fenêtre d'action (dose de test de janvier, labs tardifs) N'EST PAS un
|
||||||
|
ancrage (reproduction épinglée dans LabTrajectoryModelTest).
|
||||||
|
- **E3 = 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
|
||||||
|
(`tStyle = null` → rien de dessiné, miroir CurveChart).
|
||||||
|
- **Fenêtre = [1er lab significatif ; dernier lab] ∩ fenêtre du graphique** —
|
||||||
|
toujours bornée aux labs ; vieillir après le dernier lab = rien dessiné
|
||||||
|
(l'app n'invente pas de niveau).
|
||||||
|
- < 2 labs significatifs → courbe vide (un lab seul = pas d'intervalle ;
|
||||||
|
un point isolé ne définit pas de tracé).
|
||||||
|
- **Indépendante de la calibration** : elle EST sa propre calibration
|
||||||
|
continue (ratio brut, `scaleFactor` stocké ignoré ET `scalePerEster`
|
||||||
|
ignoré — l'inverse serait une DOUBLE correction, épinglé par test).
|
||||||
|
efecto : les modèles rapprochés au sein du même ancrage CONVERGENT
|
||||||
|
(le mi-chemin ESE vs WHS colle à ~2 % — `modelOverride` garde une
|
||||||
|
empreinte intermédiaire, testé avec le seuil du test moteur WHS).
|
||||||
|
- **Hors pics/creux** (chip "Pics / creux") : la série passe
|
||||||
|
`showExtrema = false` — ses extrema reflèteraient l'interp ρ, pas la
|
||||||
|
physiologie du modèle.
|
||||||
|
- **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 :
|
||||||
|
le 4ᵉ chip était coupé hors fenêtre avec une Row figée). OFF par défaut.
|
||||||
|
- ⚠️ **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`
|
||||||
|
= TFS imprimerait la légende TFS une 2ᵉ fois). Série → couleur dédiée
|
||||||
|
`ChartLabTrajectory` #C2185B dashed.
|
||||||
|
- **INVARIANT** : cette courbe n'entre JAMAIS dans `levelAt` — accueil,
|
||||||
|
seuils d'alerte, rappels utilisent toujours le modèle calibré (§9.bis).
|
||||||
|
|
||||||
## 8. Tests unitaires
|
## 8. Tests unitaires
|
||||||
|
|
||||||
**172 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **150 sans
|
**183 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **161 sans
|
||||||
les données de test locales** (cf §8.bis : les 3 classes de régression,
|
les données de test locales** (cf §8.bis : les 3 classes de régression,
|
||||||
6 tests chacune, sont skippées via `Assume`). Dépendance : JUnit 4.13.2.
|
6 tests chacune, 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 =
|
||||||
@ -669,6 +715,17 @@ 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) :
|
||||||
|
passage EXACT sur chaque lab (fp 1e-6), ρ log-linéaire (fonction pure +
|
||||||
|
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
|
||||||
|
au dernier lab SIGNIFICATIF), labs avant 1ʳᵉ dose → 0 ancrage, fenêtre
|
||||||
|
clippée [labs ∩ demande], **indépendance du scaleFactor stocké**
|
||||||
|
(sinon double correction → courbes identiques sous SF 1,0 vs 0,55),
|
||||||
|
`modelOverride` (ESE vs WHS — divergence franche : ESE≈TFS quasi
|
||||||
|
identiques par CONSTRUCTION, seuil 2 % doc §8), labs T ignorés,
|
||||||
|
doublons de timestamp (2ᵉ ratio gagne), bornes défensives ratioAt.
|
||||||
|
|
||||||
**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.
|
||||||
**Toute modification du moteur passe par ces tests.** Suite envisageable : Robolectric
|
**Toute modification du moteur passe par ces tests.** Suite envisageable : Robolectric
|
||||||
@ -1434,8 +1491,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` — 172 au
|
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 183 au
|
||||||
total, 150 si `local-test-data/` est absent (les 3 classes de régression
|
total, 161 si `local-test-data/` est absent (les 3 classes de régression
|
||||||
réelles sont skippées via `Assume`, 6 tests chacune) ; **lint vert
|
réelles sont skippées via `Assume`, 6 tests chacune) ; **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 :
|
||||||
@ -1757,6 +1814,12 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
|
|||||||
- [ ] **v1.4.6** : **3ᵉ modèle WHSAH** : preset WHSAH → courbe verte
|
- [ ] **v1.4.6** : **3ᵉ modèle WHSAH** : preset WHSAH → courbe verte
|
||||||
superposable aux 2 autres (montée plus rapide à J+1, t½ plus longues
|
superposable aux 2 autres (montée plus rapide à J+1, t½ plus longues
|
||||||
que TFS) ; PEP non couvert ; 6 presets ; dropdown modèle à 3 choix
|
que TFS) ; PEP non couvert ; 6 presets ; dropdown modèle à 3 choix
|
||||||
|
- [ ] **v1.5.0** : **Tracé labs** : chip `Tracé labs` (rangée modèles,
|
||||||
|
off par défaut) → la courbe rose foncé pointillé passe EXACTEMENT
|
||||||
|
sur chacun de tes labs E2 entre le 1er et le dernier LAB ; le reste
|
||||||
|
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
|
||||||
|
vieillir est normal) ; chip décoché = plus de série ; 0 crash
|
||||||
- [ ] **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
|
||||||
@ -1777,7 +1840,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 7 sept. 2026 (v1.4.10) — build OK, lint vert, 172/172 tests verts (150 sans
|
*Doc mise à jour le 11 sept. 2026 (v1.5.0) — build OK, lint vert, 183/183 tests verts (161 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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user