v1.9.5 : revue de code — fix #67 ticks X/fuseau, contexte moteur, Canvas mémoïsé, docs alignées
Corrigé (audit lecture-seule) : - #67 : drawXLabels formatait dans le fuseau CHOISI mais xLabelTicks alignait sur le fuseau APPAREIL (zone non passée, contredisant #55) ; le pas fixe +24 h dérivait d'1 h aux transitions DST — zone passée + avancement Calendar, test DST épinglé (Europe/Paris 29/03) - EstrannaiseCloud.compute : paramètre nowMs mort supprimé (KDoc mensonger), appelants + tests alignés Perf (suite v1.8.2) : - E2Context/prepareE2Context : le tracé labs (720 pts) reconstruisait les maps moteur par point — préparées une fois, résultat bit-identique - Canvas : Paints texte mémoïsés (clé couleur|taille|align|gras), detectExtrema mémoïsé par série, formateurs X/marqueurs mémoïsés Règles ré-appliquées : - modelLabelRes (règle anti-#59) ; PKModels.* au lieu de literals ; scheduler2 supprimé Docs : §7.2/§7.3 à jour (asset historique, cutoff 10×t½), PKPresets/ Treatment/LabTimingTest commentaires alignés, §14 #67, §2, README
This commit is contained in:
parent
e4f52bc6da
commit
0f0ecc3d23
@ -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.9.4 — build Android ✅, **lint vert** ✅, **243 tests unitaires** ✅ (213 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.9.5 — build Android ✅, **lint vert** ✅, **244 tests unitaires** ✅ (214 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)
|
||||||
@ -123,7 +123,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 # 243 tests (213 sans les données locales)
|
./gradlew testDebugUnitTest # 244 tests (214 sans les données locales)
|
||||||
./gradlew lint # lint vert obligatoire avant release
|
./gradlew lint # lint vert obligatoire avant release
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -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 = 41
|
versionCode = 42
|
||||||
versionName = "1.9.4"
|
versionName = "1.9.5"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
|
|||||||
@ -18,7 +18,8 @@ data class PKPreset(
|
|||||||
object PKPresets {
|
object PKPresets {
|
||||||
|
|
||||||
val all: List<PKPreset> = listOf(
|
val all: List<PKPreset> = listOf(
|
||||||
// --- Injections: Estrannaise models (profiles from the ODS) ---
|
// --- Injections : modèle Estrannaise ANALYTIQUE (v1.9.0 — la
|
||||||
|
// source est estrannaise.js, plus de tables ODS au runtime) ---
|
||||||
PKPreset(
|
PKPreset(
|
||||||
nameRes = R.string.preset_ev_ese,
|
nameRes = R.string.preset_ev_ese,
|
||||||
type = TreatmentType.ESTRADIOL,
|
type = TreatmentType.ESTRADIOL,
|
||||||
|
|||||||
@ -94,7 +94,8 @@ data class Treatment(
|
|||||||
val isActive: Boolean = true,
|
val isActive: Boolean = true,
|
||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
|
|
||||||
// PK: lookup-table model for injections (EV/EU/EEn from Estrannaise / Transfem Science)
|
// PK: analytical models for injections — Estrannaise (v1.9.0, 6 esters),
|
||||||
|
// Transfem Science (V3C, 7), WHSAH (6); see Esters.choicesForModel().
|
||||||
val esterType: String = Esters.NONE,
|
val esterType: String = Esters.NONE,
|
||||||
val pkModel: String = PKModels.ESTRANNAISE,
|
val pkModel: String = PKModels.ESTRANNAISE,
|
||||||
|
|
||||||
|
|||||||
@ -58,10 +58,6 @@ object EstrannaiseCloud {
|
|||||||
* @param doseLogs toutes les doses réelles
|
* @param doseLogs toutes les doses réelles
|
||||||
* @param startMs/endMs fenêtre demandée (celle du graphique)
|
* @param startMs/endMs fenêtre demandée (celle du graphique)
|
||||||
* @param stepMs pas de la grille (stepForRange)
|
* @param stepMs pas de la grille (stepForRange)
|
||||||
* @param nowMs « maintenant » (les doses futures du porteur ne sont
|
|
||||||
* pas loguées — seules les doses à horizon prévisionnel
|
|
||||||
* alimentées par le caller seraient incluses ; v1.9.0
|
|
||||||
* limite le nuage aux doses RÉELLES, comme le tracé labs)
|
|
||||||
* @param nbCurves nombre de courbes du nuage (défaut [DEFAULT_NB_CURVES])
|
* @param nbCurves nombre de courbes du nuage (défaut [DEFAULT_NB_CURVES])
|
||||||
* @return M listes de points (M = [nbCurves]) — **vide** si rien n'est
|
* @return M listes de points (M = [nbCurves]) — **vide** si rien n'est
|
||||||
* calculable : aucune dose E2 à profil injectable dont l'ester
|
* calculable : aucune dose E2 à profil injectable dont l'ester
|
||||||
@ -74,7 +70,6 @@ object EstrannaiseCloud {
|
|||||||
startMs: Long,
|
startMs: Long,
|
||||||
endMs: Long,
|
endMs: Long,
|
||||||
stepMs: Long,
|
stepMs: Long,
|
||||||
nowMs: Long,
|
|
||||||
nbCurves: Int = DEFAULT_NB_CURVES,
|
nbCurves: Int = DEFAULT_NB_CURVES,
|
||||||
/**
|
/**
|
||||||
* Calibration à appliquer au nuage (v1.9.1 — fix « le nuage ne
|
* Calibration à appliquer au nuage (v1.9.1 — fix « le nuage ne
|
||||||
|
|||||||
@ -178,17 +178,19 @@ object LabTrajectoryModel {
|
|||||||
if (estrogenTreatments.isEmpty()) return AnchoredCurve(emptyList(), 0)
|
if (estrogenTreatments.isEmpty()) return AnchoredCurve(emptyList(), 0)
|
||||||
|
|
||||||
// Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le
|
// Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le
|
||||||
// facteur — combiner avec le facteur stocké = double correction)
|
// facteur — combiner avec le facteur stocké = double correction).
|
||||||
|
// Contexte moteur pré-calculé UNE fois (v1.9.5, audit) : la boucle
|
||||||
|
// de grille appelle e2At par point (720 pts) — sans contexte
|
||||||
|
// partagé, chaque appel reconstruisait les maps internes.
|
||||||
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
|
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
|
||||||
|
val e2Ctx = PharmacokineticEngine.prepareE2Context(unscaled, doseLogs)
|
||||||
|
|
||||||
// ── 1) Timeline des ANCRAGES (labs croissants + garde #61) ───────────
|
// ── 1) Timeline des ANCRAGES (labs croissants + garde #61) ───────────
|
||||||
// Un couple (t_lab, ratio) par lab significatif, trié croissant.
|
// Un couple (t_lab, ratio) par lab significatif, trié croissant.
|
||||||
val anchors = mutableListOf<Pair<Long, Double>>()
|
val anchors = mutableListOf<Pair<Long, Double>>()
|
||||||
var maxPredictionSoFar = 0.0
|
var maxPredictionSoFar = 0.0
|
||||||
for (lab in e2Labs.filter { it.marker.equals("E2", ignoreCase = true) }.sortedBy { it.timestamp }) {
|
for (lab in e2Labs.filter { it.marker.equals("E2", ignoreCase = true) }.sortedBy { it.timestamp }) {
|
||||||
val predicted = PharmacokineticEngine.e2At(
|
val predicted = PharmacokineticEngine.e2At(ctx = e2Ctx, tMs = lab.timestamp, modelOverride = modelOverride)
|
||||||
unscaled, doseLogs, lab.timestamp, modelOverride, scalePerEster = null
|
|
||||||
)
|
|
||||||
if (!PharmacokineticEngine.labIsSignificant(predicted, maxPredictionSoFar)) continue
|
if (!PharmacokineticEngine.labIsSignificant(predicted, maxPredictionSoFar)) continue
|
||||||
anchors.add(lab.timestamp to (lab.value / predicted))
|
anchors.add(lab.timestamp to (lab.value / predicted))
|
||||||
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
|
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
|
||||||
@ -220,7 +222,7 @@ object LabTrajectoryModel {
|
|||||||
val points = mutableListOf<LevelPoint>()
|
val points = mutableListOf<LevelPoint>()
|
||||||
var t = t0
|
var t = t0
|
||||||
while (t <= t1) {
|
while (t <= t1) {
|
||||||
val m = PharmacokineticEngine.e2At(unscaled, doseLogs, t, modelOverride, scalePerEster = null)
|
val m = PharmacokineticEngine.e2At(e2Ctx, t, modelOverride)
|
||||||
val rho = ratioAt(anchors, t)
|
val rho = ratioAt(anchors, t)
|
||||||
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
|
||||||
|
|||||||
@ -164,6 +164,65 @@ object PharmacokineticEngine {
|
|||||||
// Aggregated levels
|
// Aggregated levels
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contexte PRÉ-CALCULÉ d'une série d'appels [e2At] (v1.9.5, audit) :
|
||||||
|
* cutoff par traitement + Bateman paresseux + doses pré-groupées. Le
|
||||||
|
* chemin chaud du TRACÉ LABS appelle e2At par point de grille (720 pts)
|
||||||
|
* et par lab — reconstruire ce contexte à CHAQUE appel diluait
|
||||||
|
* l'optimisation v1.8.2 (720 × N traitements reconstructions de maps).
|
||||||
|
* Préparer UNE fois puis appeler [e2At] surcharge contexte — résultat
|
||||||
|
* bit-identique (mêmes boucles, même ordre de sommation flottante).
|
||||||
|
*/
|
||||||
|
class E2Context internal constructor(
|
||||||
|
internal val treatments: List<Treatment>,
|
||||||
|
internal val dosesByTreatment: Map<Long, List<DoseLog>>,
|
||||||
|
internal val cutoffByTreatment: HashMap<Long, Double>,
|
||||||
|
internal val batemanCache: HashMap<Long, BatemanParams>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Prépare un [E2Context] pour une série d'appels e2At (même entrées). */
|
||||||
|
fun prepareE2Context(treatments: List<Treatment>, doseLogs: List<DoseLog>): E2Context {
|
||||||
|
val dosesByTreatment = doseLogs.groupBy { it.treatmentId }
|
||||||
|
val cutoffByTreatment = HashMap<Long, Double>()
|
||||||
|
val batemanCache = HashMap<Long, BatemanParams>()
|
||||||
|
for (treatment in treatments) {
|
||||||
|
if (treatment.type != TreatmentType.ESTRADIOL) continue
|
||||||
|
cutoffByTreatment[treatment.id] = cutoffHours(treatment)
|
||||||
|
if (!treatment.usesProfileModel) {
|
||||||
|
batemanCache[treatment.id] = batemanParams(treatment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return E2Context(treatments, dosesByTreatment, cutoffByTreatment, batemanCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** e2At sur contexte pré-calculé (voir [E2Context]) — la forme WORKHORSE. */
|
||||||
|
fun e2At(
|
||||||
|
ctx: E2Context,
|
||||||
|
tMs: Long,
|
||||||
|
modelOverride: String? = null,
|
||||||
|
scalePerEster: Map<String, Double>? = null
|
||||||
|
): Double {
|
||||||
|
var total = 0.0
|
||||||
|
for (treatment in ctx.treatments) {
|
||||||
|
if (treatment.type != TreatmentType.ESTRADIOL) continue
|
||||||
|
for (dose in ctx.dosesByTreatment[treatment.id].orEmpty()) {
|
||||||
|
if (dose.timestamp > tMs) continue
|
||||||
|
val dtH = (tMs - dose.timestamp) / 3_600_000.0
|
||||||
|
if (dtH > ctx.cutoffByTreatment.getValue(treatment.id)) continue
|
||||||
|
val c = concentrationOfDose(
|
||||||
|
treatment, dose, tMs, ctx.batemanCache[treatment.id], modelOverride
|
||||||
|
)
|
||||||
|
if (c > 0.0) {
|
||||||
|
val scale = scalePerEster?.get(doseEster(treatment, dose))
|
||||||
|
?: treatment.scaleFactor
|
||||||
|
total += c * scale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
/** e2At à appel unique : prépare le contexte puis délègue. */
|
||||||
fun e2At(
|
fun e2At(
|
||||||
treatments: List<Treatment>,
|
treatments: List<Treatment>,
|
||||||
doseLogs: List<DoseLog>,
|
doseLogs: List<DoseLog>,
|
||||||
@ -177,43 +236,7 @@ object PharmacokineticEngine {
|
|||||||
*/
|
*/
|
||||||
scalePerEster: Map<String, Double>? = null
|
scalePerEster: Map<String, Double>? = null
|
||||||
): Double {
|
): Double {
|
||||||
// Pré-calculs PAR TRAITEMENT (v1.8.2 — le chemin chaud du tracé labs
|
return e2At(prepareE2Context(treatments, doseLogs), tMs, modelOverride, scalePerEster)
|
||||||
// appelle e2At par point de grille : 720 pts × N traitements × D doses
|
|
||||||
// recalculaient cutoffHours et batemanParams à chaque itération) :
|
|
||||||
// - cutoff : déterministe par traitement → calculé UNE fois ;
|
|
||||||
// - Bateman (bisection 50 itérations + ln) : inutile pour les
|
|
||||||
// traitements à PROFIL (concentrationOfDose ne lit le paramètre
|
|
||||||
// que dans la branche Bateman, inatteignable quand
|
|
||||||
// usesProfileModel == true) → calcul paresseux.
|
|
||||||
// - doses pré-groupées par traitement (fini le scan complet de
|
|
||||||
// doseLogs par traitement) — l'ordre d'itération des doses d'UN
|
|
||||||
// traitement est préservé (groupBy préserve l'ordre relatif) →
|
|
||||||
// sommation flottante identique.
|
|
||||||
val dosesByTreatment = doseLogs.groupBy { it.treatmentId }
|
|
||||||
val cutoffByTreatment = HashMap<Long, Double>()
|
|
||||||
val batemanCache = HashMap<Long, BatemanParams>()
|
|
||||||
var total = 0.0
|
|
||||||
for (treatment in treatments) {
|
|
||||||
if (treatment.type != TreatmentType.ESTRADIOL) continue
|
|
||||||
cutoffByTreatment[treatment.id] = cutoffHours(treatment)
|
|
||||||
if (!treatment.usesProfileModel) {
|
|
||||||
batemanCache[treatment.id] = batemanParams(treatment)
|
|
||||||
}
|
|
||||||
for (dose in dosesByTreatment[treatment.id].orEmpty()) {
|
|
||||||
if (dose.timestamp > tMs) continue
|
|
||||||
val dtH = (tMs - dose.timestamp) / 3_600_000.0
|
|
||||||
if (dtH > cutoffByTreatment.getValue(treatment.id)) continue
|
|
||||||
val c = concentrationOfDose(
|
|
||||||
treatment, dose, tMs, batemanCache[treatment.id], modelOverride
|
|
||||||
)
|
|
||||||
if (c > 0.0) {
|
|
||||||
val scale = scalePerEster?.get(doseEster(treatment, dose))
|
|
||||||
?: treatment.scaleFactor
|
|
||||||
total += c * scale
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun testosteroneAt(e2Level: Double, config: TConfig): Double {
|
fun testosteroneAt(e2Level: Double, config: TConfig): Double {
|
||||||
@ -460,6 +483,8 @@ object PharmacokineticEngine {
|
|||||||
// stocké du traitement, ce qui produisait des facteurs absurdes dès
|
// stocké du traitement, ce qui produisait des facteurs absurdes dès
|
||||||
// qu'un traitement basculait de modèle, ex. ×2,21 sur WHSAH)
|
// qu'un traitement basculait de modèle, ex. ×2,21 sur WHSAH)
|
||||||
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
|
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
|
||||||
|
// Contexte pré-calculé UNE fois pour toute la série de labs (v1.9.5)
|
||||||
|
val ctx = prepareE2Context(unscaled, doseLogs)
|
||||||
val ratiosByEster = HashMap<String, MutableList<Double>>()
|
val ratiosByEster = HashMap<String, MutableList<Double>>()
|
||||||
var maxPredictionSoFar = 0.0
|
var maxPredictionSoFar = 0.0
|
||||||
// ⚠️ fix #61 : labs par timestamp CROISSANT + garde labIsSignificant —
|
// ⚠️ fix #61 : labs par timestamp CROISSANT + garde labIsSignificant —
|
||||||
@ -467,7 +492,7 @@ object PharmacokineticEngine {
|
|||||||
// résiduelle) produisaient des ratios aberrants dont la MÉDIANE
|
// résiduelle) produisaient des ratios aberrants dont la MÉDIANE
|
||||||
// tombait à ×2,21
|
// tombait à ×2,21
|
||||||
for (lab in e2Labs.sortedBy { it.timestamp }) {
|
for (lab in e2Labs.sortedBy { it.timestamp }) {
|
||||||
val predicted = e2At(unscaled, doseLogs, lab.timestamp, modelOverride = modelOverride)
|
val predicted = e2At(ctx, lab.timestamp, modelOverride = modelOverride)
|
||||||
if (!labIsSignificant(predicted, maxPredictionSoFar)) continue
|
if (!labIsSignificant(predicted, maxPredictionSoFar)) continue
|
||||||
|
|
||||||
// Attribution : dernière dose E2 ≤ lab → son ester (override compris)
|
// Attribution : dernière dose E2 ≤ lab → son ester (override compris)
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.hormonetrack.ui.components
|
|||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
@ -14,6 +15,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
|
|||||||
import androidx.compose.ui.graphics.nativeCanvas
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hormonetrack.data.model.DoseLog
|
import com.hormonetrack.data.model.DoseLog
|
||||||
|
import com.hormonetrack.data.model.PKModels
|
||||||
import com.hormonetrack.data.model.LabResult
|
import com.hormonetrack.data.model.LabResult
|
||||||
import com.hormonetrack.pk.LevelPoint
|
import com.hormonetrack.pk.LevelPoint
|
||||||
import com.hormonetrack.pk.PharmacokineticEngine
|
import com.hormonetrack.pk.PharmacokineticEngine
|
||||||
@ -101,12 +103,20 @@ fun xLabelTicks(
|
|||||||
cal.set(Calendar.MILLISECOND, 0)
|
cal.set(Calendar.MILLISECOND, 0)
|
||||||
while (cal.timeInMillis <= t0) cal.add(Calendar.HOUR_OF_DAY, 1)
|
while (cal.timeInMillis <= t0) cal.add(Calendar.HOUR_OF_DAY, 1)
|
||||||
}
|
}
|
||||||
val stepMs = stepHours * 3_600_000L
|
|
||||||
val out = mutableListOf<Long>()
|
val out = mutableListOf<Long>()
|
||||||
var t = cal.timeInMillis
|
var t = cal.timeInMillis
|
||||||
while (t <= t1) {
|
while (t <= t1) {
|
||||||
out.add(t)
|
out.add(t)
|
||||||
t += stepMs
|
// On avance via le Calendar (et non un +stepMs fixe) : une addition
|
||||||
|
// en millisecondes dérive d'1 h aux transitions DST, alors que le
|
||||||
|
// pas « 24 h » doit tomber sur minuit LOCAL tous les jours — y
|
||||||
|
// compris le jour du changement d'heure.
|
||||||
|
if (stepHours >= 24) {
|
||||||
|
cal.add(Calendar.DAY_OF_MONTH, (stepHours / 24L).toInt().coerceAtLeast(1))
|
||||||
|
} else {
|
||||||
|
cal.add(Calendar.HOUR_OF_DAY, stepHours.toInt())
|
||||||
|
}
|
||||||
|
t = cal.timeInMillis
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@ -174,12 +184,12 @@ fun niceCeil(value: Double): Double {
|
|||||||
*/
|
*/
|
||||||
fun defaultModelToggles(pkModelsUsed: Set<String>): Map<String, Boolean> {
|
fun defaultModelToggles(pkModelsUsed: Set<String>): Map<String, Boolean> {
|
||||||
val toggles = mapOf(
|
val toggles = mapOf(
|
||||||
"ESE" to pkModelsUsed.contains("ESE"),
|
PKModels.ESTRANNAISE to pkModelsUsed.contains(PKModels.ESTRANNAISE),
|
||||||
"TFS" to pkModelsUsed.contains("TFS"),
|
PKModels.TRANSFEM_SCIENCE to pkModelsUsed.contains(PKModels.TRANSFEM_SCIENCE),
|
||||||
"WHS" to pkModelsUsed.contains("WHS")
|
PKModels.WHSAH to pkModelsUsed.contains(PKModels.WHSAH)
|
||||||
)
|
)
|
||||||
// Garde-fou : aucune courbe sinon (Bateman tracé dans chaque série)
|
// Garde-fou : aucune courbe sinon (Bateman tracé dans chaque série)
|
||||||
return if (toggles.values.none { it }) toggles + ("TFS" to true) else toggles
|
return if (toggles.values.none { it }) toggles + (PKModels.TRANSFEM_SCIENCE to true) else toggles
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -310,6 +320,35 @@ fun CurveChart(
|
|||||||
val forecastMarkerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f)
|
val forecastMarkerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f)
|
||||||
val realDoseMarkerColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f)
|
val realDoseMarkerColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f)
|
||||||
|
|
||||||
|
// v1.9.5 (audit) : extrema mémoïsés PAR SÉRIE (index aligné sur
|
||||||
|
// `series`) — detectExtrema scanne tous les points (O(n)) et ne change
|
||||||
|
// que si les séries/options changent, pas à chaque pan/zoom (le Canvas
|
||||||
|
// se redessine à chaque geste).
|
||||||
|
val extremaBySeries = remember(series, options) {
|
||||||
|
series.map { s ->
|
||||||
|
Pair(
|
||||||
|
if (s.showExtrema) {
|
||||||
|
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0)
|
||||||
|
} else emptyList(),
|
||||||
|
if (s.showExtrema) {
|
||||||
|
s.tStyle?.let { PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02) }
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// v1.9.5 (audit) : formatters mémoïsés (SimpleDateFormat n'est pas
|
||||||
|
// thread-safe — thread UI seul ici — et l'allouer par frame était du
|
||||||
|
// gaspillage, même standard que le fix des marqueurs v1.8.2).
|
||||||
|
val markerTimeFmt = remember(zone) {
|
||||||
|
SimpleDateFormat("HH:mm", Locale.getDefault()).apply { timeZone = zone }
|
||||||
|
}
|
||||||
|
val xAxisHourFmt = remember(zone) {
|
||||||
|
SimpleDateFormat("HH'h'", Locale.getDefault()).apply { timeZone = zone }
|
||||||
|
}
|
||||||
|
val xAxisDayFmt = remember(zone) {
|
||||||
|
SimpleDateFormat("dd/MM", Locale.getDefault()).apply { timeZone = zone }
|
||||||
|
}
|
||||||
|
|
||||||
Canvas(modifier = modifier) {
|
Canvas(modifier = modifier) {
|
||||||
val allPoints = series.flatMap { it.points }
|
val allPoints = series.flatMap { it.points }
|
||||||
if (allPoints.size < 2) return@Canvas
|
if (allPoints.size < 2) return@Canvas
|
||||||
@ -364,27 +403,19 @@ fun CurveChart(
|
|||||||
drawAxisUnit(T_UNIT, padLeft + w + 6.dp.toPx(), padTop - 6.dp.toPx(),
|
drawAxisUnit(T_UNIT, padLeft + w + 6.dp.toPx(), padTop - 6.dp.toPx(),
|
||||||
android.graphics.Paint.Align.LEFT)
|
android.graphics.Paint.Align.LEFT)
|
||||||
}
|
}
|
||||||
drawXLabels(labelColor, t0, t1, padLeft, padTop + h, w, zone)
|
drawXLabels(labelColor, t0, t1, padLeft, padTop + h, w, zone, xAxisHourFmt, xAxisDayFmt)
|
||||||
|
|
||||||
// Doses PRÉVISIONNELLES (v1.4.5) : ligne verticale pointillée + petit
|
// Doses PRÉVISIONNELLES (v1.4.5) : ligne verticale pointillée + petit
|
||||||
// triangle ▼ en haut de la zone — rend l'instant de la dose simulée
|
// triangle ▼ en haut de la zone — rend l'instant de la dose simulée
|
||||||
// VISIBLE (sinon la projection paraît décalée d'un jour, bug #55).
|
// VISIBLE (sinon la projection paraît décalée d'un jour, bug #55).
|
||||||
if (forecastDoses.isNotEmpty()) {
|
if (forecastDoses.isNotEmpty()) {
|
||||||
val markerColor = forecastMarkerColor
|
val markerColor = forecastMarkerColor
|
||||||
val paint = labelPaint(android.graphics.Paint.Align.CENTER).apply {
|
val paint = textPaint(markerColor, 10f, android.graphics.Paint.Align.CENTER)
|
||||||
color = android.graphics.Color.argb(
|
|
||||||
(markerColor.alpha * 255).toInt(),
|
|
||||||
(markerColor.red * 255).toInt(),
|
|
||||||
(markerColor.green * 255).toInt(),
|
|
||||||
(markerColor.blue * 255).toInt()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// SimpleDateFormat créé UNE fois (hors boucle) : le Canvas se
|
// SimpleDateFormat créé UNE fois (hors boucle) : le Canvas se
|
||||||
// redessine à chaque pan/zoom, une allocation par dose × frame
|
// redessine à chaque pan/zoom, une allocation par dose × frame
|
||||||
// était du gaspillage (la classe n'est de toute façon pas
|
// était du gaspillage (la classe n'est de toute façon pas
|
||||||
// thread-safe — ici on reste dans le DrawScope UI)
|
// thread-safe — ici on reste dans le DrawScope UI)
|
||||||
val hourFmt = SimpleDateFormat("HH:mm", Locale.getDefault())
|
val hourFmt = markerTimeFmt
|
||||||
hourFmt.timeZone = zone
|
|
||||||
for (dose in forecastDoses) {
|
for (dose in forecastDoses) {
|
||||||
if (dose.timestamp !in t0..t1) continue
|
if (dose.timestamp !in t0..t1) continue
|
||||||
val x = xOf(dose.timestamp)
|
val x = xOf(dose.timestamp)
|
||||||
@ -439,7 +470,7 @@ fun CurveChart(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Une courbe E2 + une courbe T par série
|
// Une courbe E2 + une courbe T par série
|
||||||
for (s in series) {
|
for ((sIdx, s) in series.withIndex()) {
|
||||||
if (s.points.size < 2) continue
|
if (s.points.size < 2) continue
|
||||||
val e2Path = Path()
|
val e2Path = Path()
|
||||||
s.points.forEachIndexed { i, p ->
|
s.points.forEachIndexed { i, p ->
|
||||||
@ -479,17 +510,20 @@ 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 estimée du pic/creux).
|
// (v1.2.6 : le triangle est accompagné de la valeur estimée du pic/creux).
|
||||||
// v1.5.0 : une série peut être exclue (Tracé labs → showExtrema=false).
|
// v1.5.0 : une série peut être exclue (Tracé labs → showExtrema=false).
|
||||||
|
// v1.9.5 (audit) : les extrema sont mémoïsés (extremaByModel, hors
|
||||||
|
// Canvas, recalculés seulement quand séries/options changent) — le
|
||||||
|
// scan local était refait PAR FRAME, x2 séries.
|
||||||
if (options.showExtrema && s.showExtrema) {
|
if (options.showExtrema && s.showExtrema) {
|
||||||
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0)
|
extremaBySeries[sIdx]?.let { (e2Extrema, tExtrema) ->
|
||||||
.forEach { ex ->
|
e2Extrema.forEach { ex ->
|
||||||
drawExtremum(xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color)
|
drawExtremum(xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color)
|
||||||
}
|
}
|
||||||
if (options.showT) {
|
if (options.showT) {
|
||||||
s.tStyle?.let { tStyle ->
|
s.tStyle?.let { tStyle ->
|
||||||
PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02)
|
tExtrema.orEmpty().forEach { ex ->
|
||||||
.forEach { ex ->
|
|
||||||
drawExtremum(xOf(ex.timestamp), yT(ex.value), ex, tStyle.color)
|
drawExtremum(xOf(ex.timestamp), yT(ex.value), ex, tStyle.color)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -574,19 +608,9 @@ private fun DrawScope.drawExtremum(cx: Float, cy: Float, extremum: Pharmacokinet
|
|||||||
}
|
}
|
||||||
path.close()
|
path.close()
|
||||||
drawPath(path, color)
|
drawPath(path, color)
|
||||||
// Valeur estimée du pic/creux, dans la couleur de la courbe
|
// Valeur estimée du pic/creux, dans la couleur de la courbe (Paint mis
|
||||||
val paint = android.graphics.Paint().apply {
|
// en cache — v1.9.5 : un Paint par extremum × frame était du gaspillage)
|
||||||
this.color = android.graphics.Color.argb(
|
val paint = textPaint(color, 9f, android.graphics.Paint.Align.CENTER, fakeBold = true)
|
||||||
(color.alpha * 255).toInt(),
|
|
||||||
(color.red * 255).toInt(),
|
|
||||||
(color.green * 255).toInt(),
|
|
||||||
(color.blue * 255).toInt()
|
|
||||||
)
|
|
||||||
textSize = 9.dp.toPx()
|
|
||||||
textAlign = android.graphics.Paint.Align.CENTER
|
|
||||||
isAntiAlias = true
|
|
||||||
isFakeBoldText = true
|
|
||||||
}
|
|
||||||
drawContext.canvas.nativeCanvas.drawText(
|
drawContext.canvas.nativeCanvas.drawText(
|
||||||
formatValue(extremum.value), cx, valueY, paint
|
formatValue(extremum.value), cx, valueY, paint
|
||||||
)
|
)
|
||||||
@ -619,22 +643,42 @@ private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: F
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Paint partagé pour les labels texte (couleur gris neutre fixe). */
|
/**
|
||||||
private fun DrawScope.labelPaint(align: android.graphics.Paint.Align): android.graphics.Paint {
|
* Cache de Paints TEXTE du graphique (v1.9.5, audit) : un Paint par
|
||||||
val c = Color(0xFF6B7280)
|
* (couleur, taille, align, gras) au lieu d'une allocation PAR EXTREMUM /
|
||||||
return android.graphics.Paint().apply {
|
* label / frame — le Canvas se redessine à chaque pan/zoom/tick. Tout le
|
||||||
color = android.graphics.Color.argb(
|
* dessin Compose est séquentiel sur le thread UI → un cache d'objet suffit.
|
||||||
(c.alpha * 255).toInt(),
|
* Les clés sont des value classes (Color) → pas de fuite.
|
||||||
(c.red * 255).toInt(),
|
*/
|
||||||
(c.green * 255).toInt(),
|
private val textPaintCache = HashMap<String, android.graphics.Paint>()
|
||||||
(c.blue * 255).toInt()
|
|
||||||
)
|
private fun DrawScope.textPaint(
|
||||||
textSize = 10.dp.toPx()
|
color: Color,
|
||||||
textAlign = align
|
textSizeDp: Float,
|
||||||
isAntiAlias = true
|
align: android.graphics.Paint.Align,
|
||||||
|
fakeBold: Boolean = false
|
||||||
|
): android.graphics.Paint {
|
||||||
|
val key = "$color|$textSizeDp|$align|$fakeBold"
|
||||||
|
return textPaintCache.getOrPut(key) {
|
||||||
|
android.graphics.Paint().apply {
|
||||||
|
this.color = android.graphics.Color.argb(
|
||||||
|
(color.alpha * 255).toInt(),
|
||||||
|
(color.red * 255).toInt(),
|
||||||
|
(color.green * 255).toInt(),
|
||||||
|
(color.blue * 255).toInt()
|
||||||
|
)
|
||||||
|
textSize = textSizeDp.dp.toPx()
|
||||||
|
textAlign = align
|
||||||
|
isAntiAlias = true
|
||||||
|
isFakeBoldText = fakeBold
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Paint partagé pour les labels texte (couleur gris neutre fixe, 10 dp). */
|
||||||
|
private fun DrawScope.labelPaint(align: android.graphics.Paint.Align): android.graphics.Paint =
|
||||||
|
textPaint(Color(0xFF6B7280), 10f, align)
|
||||||
|
|
||||||
private fun DrawScope.drawLeftYLabels(color: Color, e2Max: Double, padLeft: Float, padTop: Float, h: Float) {
|
private fun DrawScope.drawLeftYLabels(color: Color, e2Max: Double, padLeft: Float, padTop: Float, h: Float) {
|
||||||
val paint = labelPaint(android.graphics.Paint.Align.RIGHT)
|
val paint = labelPaint(android.graphics.Paint.Align.RIGHT)
|
||||||
for (i in 0..4) {
|
for (i in 0..4) {
|
||||||
@ -656,7 +700,11 @@ private fun DrawScope.drawRightYLabels(color: Color, tMax: Double, xRight: Float
|
|||||||
private fun DrawScope.drawXLabels(
|
private fun DrawScope.drawXLabels(
|
||||||
color: Color, t0: Long, t1: Long,
|
color: Color, t0: Long, t1: Long,
|
||||||
padLeft: Float, yBottom: Float, w: Float,
|
padLeft: Float, yBottom: Float, w: Float,
|
||||||
zone: java.util.TimeZone = java.util.TimeZone.getDefault()
|
zone: java.util.TimeZone = java.util.TimeZone.getDefault(),
|
||||||
|
// v1.9.5 (audit) : formatters mémoïsés du composable (plus de
|
||||||
|
// SimpleDateFormat alloué par frame)
|
||||||
|
hourFmt: SimpleDateFormat = SimpleDateFormat("HH'h'", Locale.getDefault()),
|
||||||
|
dayFmt: SimpleDateFormat = SimpleDateFormat("dd/MM", Locale.getDefault())
|
||||||
) {
|
) {
|
||||||
val paint = labelPaint(android.graphics.Paint.Align.CENTER)
|
val paint = labelPaint(android.graphics.Paint.Align.CENTER)
|
||||||
val spanH = (t1 - t0) / 3_600_000.0
|
val spanH = (t1 - t0) / 3_600_000.0
|
||||||
@ -670,28 +718,25 @@ private fun DrawScope.drawXLabels(
|
|||||||
}
|
}
|
||||||
// v1.4.5 : le label est formaté DANS le fuseau de lecture — sinon le
|
// v1.4.5 : le label est formaté DANS le fuseau de lecture — sinon le
|
||||||
// tick « minuit local » serait étiqueté avec l'heure système.
|
// tick « minuit local » serait étiqueté avec l'heure système.
|
||||||
val fmt = if (spanH <= 25) SimpleDateFormat("HH'h'", Locale.getDefault())
|
// v1.9.5 : formatters mémoïsés passés par le composable (plus d'alloc/frame).
|
||||||
else SimpleDateFormat("dd/MM", Locale.getDefault())
|
val fmt = if (spanH <= 25) hourFmt else dayFmt
|
||||||
fmt.timeZone = zone
|
|
||||||
// v1.4.5 (fix #55) : les ticks sont alignés sur les frontières LOCALES
|
// v1.4.5 (fix #55) : les ticks sont alignés sur les frontières LOCALES
|
||||||
// (minuit local pour les pas en jours) — l'ancien alignement sur des
|
// (minuit local pour les pas en jours) — l'ancien alignement sur des
|
||||||
// multiples UTC plaçait le label « 13/09 » à 02:00 du matin FR.
|
// multiples UTC plaçait le label « 13/09 » à 02:00 du matin FR.
|
||||||
for (tick in xLabelTicks(t0, t1, stepHours.toLong())) {
|
// v1.9.5 (audit) : la ZONE de lecture est passée à xLabelTicks — l'oubli
|
||||||
|
// alignait les ticks sur le fuseau de l'appareil pendant que les labels
|
||||||
|
// sont formatés dans le fuseau choisi (heures décalées dès que les deux
|
||||||
|
// divergent, contredisant le fix #55).
|
||||||
|
for (tick in xLabelTicks(t0, t1, stepHours.toLong(), zone)) {
|
||||||
val x = padLeft + w * ((tick - t0).toFloat() / (t1 - t0).toFloat())
|
val x = padLeft + w * ((tick - t0).toFloat() / (t1 - t0).toFloat())
|
||||||
drawContext.canvas.nativeCanvas.drawText(fmt.format(Date(tick)), x, yBottom + 16.dp.toPx(), paint)
|
drawContext.canvas.nativeCanvas.drawText(fmt.format(Date(tick)), x, yBottom + 16.dp.toPx(), paint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawDotLabel(v: Double, c: Offset) {
|
private fun DrawScope.drawDotLabel(v: Double, c: Offset) {
|
||||||
val paint = labelPaint(android.graphics.Paint.Align.CENTER).apply {
|
// Paint en cache (v1.9.5 : une allocation par lab × frame → une par
|
||||||
color = android.graphics.Color.rgb(
|
// couleur, réutilisée entre frames)
|
||||||
(LabDot.red * 255).toInt(),
|
val paint = textPaint(LabDot, 9f, android.graphics.Paint.Align.CENTER, fakeBold = true)
|
||||||
(LabDot.green * 255).toInt(),
|
|
||||||
(LabDot.blue * 255).toInt()
|
|
||||||
)
|
|
||||||
textSize = 9.dp.toPx()
|
|
||||||
isFakeBoldText = true
|
|
||||||
}
|
|
||||||
drawContext.canvas.nativeCanvas.drawText(formatValue(v), c.x, c.y, paint)
|
drawContext.canvas.nativeCanvas.drawText(formatValue(v), c.x, c.y, paint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -42,6 +42,7 @@ import androidx.compose.ui.layout.onSizeChanged
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hormonetrack.R
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.data.model.PKModels
|
||||||
import com.hormonetrack.pk.LevelPoint
|
import com.hormonetrack.pk.LevelPoint
|
||||||
import com.hormonetrack.pk.EstrannaiseCloud
|
import com.hormonetrack.pk.EstrannaiseCloud
|
||||||
import com.hormonetrack.pk.LabTrajectoryModel
|
import com.hormonetrack.pk.LabTrajectoryModel
|
||||||
@ -153,9 +154,9 @@ fun ChartScreen() {
|
|||||||
val toggles = defaultModelToggles(
|
val toggles = defaultModelToggles(
|
||||||
treatments.filter { it.usesProfileModel }.map { it.pkModel }.toSet()
|
treatments.filter { it.usesProfileModel }.map { it.pkModel }.toSet()
|
||||||
)
|
)
|
||||||
showEse = toggles.getValue("ESE")
|
showEse = toggles.getValue(PKModels.ESTRANNAISE)
|
||||||
showTfs = toggles.getValue("TFS")
|
showTfs = toggles.getValue(PKModels.TRANSFEM_SCIENCE)
|
||||||
showWhs = toggles.getValue("WHS")
|
showWhs = toggles.getValue(PKModels.WHSAH)
|
||||||
modelTogglesInitialized = true
|
modelTogglesInitialized = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -192,14 +193,14 @@ fun ChartScreen() {
|
|||||||
} else {
|
} else {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
mapOf(
|
mapOf(
|
||||||
"ESE" to PharmacokineticEngine.autoCalibrated(
|
PKModels.ESTRANNAISE to PharmacokineticEngine.autoCalibrated(
|
||||||
treatments, doseLogs, labResults, tConfig, modelOverride = "ESE"
|
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.ESTRANNAISE
|
||||||
),
|
),
|
||||||
"TFS" to PharmacokineticEngine.autoCalibrated(
|
PKModels.TRANSFEM_SCIENCE to PharmacokineticEngine.autoCalibrated(
|
||||||
treatments, doseLogs, labResults, tConfig, modelOverride = "TFS"
|
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.TRANSFEM_SCIENCE
|
||||||
),
|
),
|
||||||
"WHS" to PharmacokineticEngine.autoCalibrated(
|
PKModels.WHSAH to PharmacokineticEngine.autoCalibrated(
|
||||||
treatments, doseLogs, labResults, tConfig, modelOverride = "WHS"
|
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.WHSAH
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -263,6 +264,11 @@ fun ChartScreen() {
|
|||||||
// exposées pour être MARQUÉES sur le graphique (lignes pointillées aux
|
// exposées pour être MARQUÉES sur le graphique (lignes pointillées aux
|
||||||
// créneaux projetés, fix #55 : sans marqueur, la projection paraissait
|
// créneaux projetés, fix #55 : sans marqueur, la projection paraissait
|
||||||
// décalée d'un jour).
|
// décalée d'un jour).
|
||||||
|
// ⚠️ v1.9.5 (audit) : ce producer et le producer `curves` calculent
|
||||||
|
// chacun leurs doses prévisionnelles — VOLONTAIRE. Un partage d'état
|
||||||
|
// (curves lisant ce producer) créerait une lecture STALE d'un cycle de
|
||||||
|
// recomposition (leçon #65 : un producer doit redériver de ses vraies
|
||||||
|
// entrées) pour un calcul léger (O(traitements), pas par point de grille).
|
||||||
val forecastDoses by produceState(
|
val forecastDoses by produceState(
|
||||||
emptyList<com.hormonetrack.data.model.DoseLog>(),
|
emptyList<com.hormonetrack.data.model.DoseLog>(),
|
||||||
treatments, doseLogs, showForecast, endMs, forecastHorizonHours
|
treatments, doseLogs, showForecast, endMs, forecastHorizonHours
|
||||||
@ -338,8 +344,8 @@ fun ChartScreen() {
|
|||||||
// 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
|
||||||
// → scaleFactor stocké des traitements (comportement historique).
|
// → scaleFactor stocké des traitements (comportement historique).
|
||||||
val effTreatments = autoByModel["TFS"]?.treatments ?: treatments
|
val effTreatments = autoByModel[PKModels.TRANSFEM_SCIENCE]?.treatments ?: treatments
|
||||||
val effTConfig = autoByModel["TFS"]?.tConfig ?: tConfig
|
val effTConfig = autoByModel[PKModels.TRANSFEM_SCIENCE]?.tConfig ?: tConfig
|
||||||
// Doses réelles + doses prévisionnelles éventuelles
|
// Doses réelles + doses prévisionnelles éventuelles
|
||||||
val forecastDoses = if (showForecast) {
|
val forecastDoses = if (showForecast) {
|
||||||
effTreatments.flatMap {
|
effTreatments.flatMap {
|
||||||
@ -352,7 +358,7 @@ fun ChartScreen() {
|
|||||||
// v1.8.2 : les 3 blocs copiés (ESE/TFS/WHS) sont factorisés —
|
// v1.8.2 : les 3 blocs copiés (ESE/TFS/WHS) sont factorisés —
|
||||||
// le modèle est la seule variable (même calcul, même calibration
|
// le modèle est la seule variable (même calcul, même calibration
|
||||||
// PAR MODÈLE via autoByModel[model], fix #60)
|
// PAR MODÈLE via autoByModel[model], fix #60)
|
||||||
for ((model, enabled) in listOf("ESE" to showEse, "TFS" to showTfs, "WHS" to showWhs)) {
|
for ((model, enabled) in listOf(PKModels.ESTRANNAISE to showEse, PKModels.TRANSFEM_SCIENCE to showTfs, PKModels.WHSAH to showWhs)) {
|
||||||
if (!enabled) continue
|
if (!enabled) continue
|
||||||
val autoM = autoByModel[model]
|
val autoM = autoByModel[model]
|
||||||
val curve = PharmacokineticEngine.computeCurve(
|
val curve = PharmacokineticEngine.computeCurve(
|
||||||
@ -365,11 +371,11 @@ fun ChartScreen() {
|
|||||||
// v1.9.0 : le nuage d'incertitude est attaché à la série
|
// v1.9.0 : le nuage d'incertitude est attaché à la série
|
||||||
// ESE (exclusif à ce modèle — les fits TFS/WHSAH ne
|
// ESE (exclusif à ce modèle — les fits TFS/WHSAH ne
|
||||||
// publient pas de posterior MCMC)
|
// publient pas de posterior MCMC)
|
||||||
val cloud = if (model == "ESE" && showCloud) {
|
val cloud = if (model == PKModels.ESTRANNAISE && showCloud) {
|
||||||
// MÊME calibration que la courbe ESE (fix v1.9.1 :
|
// MÊME calibration que la courbe ESE (fix v1.9.1 :
|
||||||
// nuage brut ≠ échelle de la courbe calibrée)
|
// nuage brut ≠ échelle de la courbe calibrée)
|
||||||
EstrannaiseCloud.compute(
|
EstrannaiseCloud.compute(
|
||||||
treatments, doseLogs, startMs, endMs, step, nowMs,
|
treatments, doseLogs, startMs, endMs, step,
|
||||||
scalePerEster = autoM?.esterScales
|
scalePerEster = autoM?.esterScales
|
||||||
)
|
)
|
||||||
} else emptyList()
|
} else emptyList()
|
||||||
@ -394,9 +400,9 @@ fun ChartScreen() {
|
|||||||
// T Estrannaise = rose plein, T TFS = rose pointillé.
|
// T Estrannaise = rose plein, T TFS = rose pointillé.
|
||||||
val series = curves.map { (model, points, cloud) ->
|
val series = curves.map { (model, points, cloud) ->
|
||||||
when (model) {
|
when (model) {
|
||||||
"ESE" -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT), cloud = curves.first { it.model == "ESE" }.cloud)
|
PKModels.ESTRANNAISE -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT), cloud = cloud)
|
||||||
// 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))
|
PKModels.WHSAH -> ChartSeries(points, CurveStyle(ChartWhs), CurveStyle(ChartTWhs, dashed = true))
|
||||||
// v1.5.0 : « Tracé labs » = E2 rose foncé pointillé, PAS de courbe T
|
// 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é)
|
// (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)
|
// et hors pics/creux (labIsSignificant a déjà filtré, §7.10)
|
||||||
@ -658,7 +664,7 @@ fun ChartScreen() {
|
|||||||
// une 2ᵉ fois (bug v1.5.0-trouvé-en-émulateur #63).
|
// une 2ᵉ fois (bug v1.5.0-trouvé-en-émulateur #63).
|
||||||
curves.forEach { (model, _) ->
|
curves.forEach { (model, _) ->
|
||||||
when (model) {
|
when (model) {
|
||||||
"ESE" -> {
|
PKModels.ESTRANNAISE -> {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.legend_ese_e2),
|
stringResource(R.string.legend_ese_e2),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
@ -673,7 +679,7 @@ fun ChartScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// v1.4.6 : légende WHSAH (vert / violet pointillé)
|
// v1.4.6 : légende WHSAH (vert / violet pointillé)
|
||||||
"WHS" -> {
|
PKModels.WHSAH -> {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.legend_whs_e2),
|
stringResource(R.string.legend_whs_e2),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
@ -741,7 +747,7 @@ fun ChartScreen() {
|
|||||||
}
|
}
|
||||||
// v1.9.0 : légende du nuage d'incertitude (ESE) — visible
|
// v1.9.0 : légende du nuage d'incertitude (ESE) — visible
|
||||||
// tant que le nuage est affiché (série ESE avec cloud non vide)
|
// tant que le nuage est affiché (série ESE avec cloud non vide)
|
||||||
if (showCloud && showEse && curves.any { it.model == "ESE" && it.cloud.isNotEmpty() }) {
|
if (showCloud && showEse && curves.any { it.model == PKModels.ESTRANNAISE && it.cloud.isNotEmpty() }) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.legend_cloud),
|
stringResource(R.string.legend_cloud),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
|||||||
@ -48,6 +48,7 @@ import com.hormonetrack.R
|
|||||||
import com.hormonetrack.data.model.AdministrationRoute
|
import com.hormonetrack.data.model.AdministrationRoute
|
||||||
import com.hormonetrack.data.model.Esters
|
import com.hormonetrack.data.model.Esters
|
||||||
import com.hormonetrack.data.model.PKModels
|
import com.hormonetrack.data.model.PKModels
|
||||||
|
import com.hormonetrack.data.model.modelLabelRes
|
||||||
import com.hormonetrack.data.model.PKPresets
|
import com.hormonetrack.data.model.PKPresets
|
||||||
import com.hormonetrack.data.model.Treatment
|
import com.hormonetrack.data.model.Treatment
|
||||||
import com.hormonetrack.data.model.TreatmentType
|
import com.hormonetrack.data.model.TreatmentType
|
||||||
@ -289,11 +290,10 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
|||||||
label = stringResource(R.string.pk_model),
|
label = stringResource(R.string.pk_model),
|
||||||
// v1.4.6 : 3ᵉ modèle PK (WHSAH — fit license-free de Mona,
|
// v1.4.6 : 3ᵉ modèle PK (WHSAH — fit license-free de Mona,
|
||||||
// cf pk/WhsahModels.kt) superposable dans le graphique.
|
// cf pk/WhsahModels.kt) superposable dans le graphique.
|
||||||
selectedLabel = when (model) {
|
// Label par le helper UNIQUE (règle anti-bug #59 :
|
||||||
PKModels.TRANSFEM_SCIENCE -> stringResource(R.string.model_tfs)
|
// jamais de when/ternaire de labels en ligne — l'ancien
|
||||||
PKModels.WHSAH -> stringResource(R.string.model_whs)
|
// à 2 branches affichait « Estrannaise » pour WHSAH).
|
||||||
else -> stringResource(R.string.model_ese)
|
selectedLabel = stringResource(modelLabelRes(model)),
|
||||||
},
|
|
||||||
options = listOf(
|
options = listOf(
|
||||||
PKModels.ESTRANNAISE to stringResource(R.string.model_ese),
|
PKModels.ESTRANNAISE to stringResource(R.string.model_ese),
|
||||||
PKModels.TRANSFEM_SCIENCE to stringResource(R.string.model_tfs),
|
PKModels.TRANSFEM_SCIENCE to stringResource(R.string.model_tfs),
|
||||||
@ -450,8 +450,9 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
|||||||
String.format(Locale.getDefault(), "%02d:%02d", reminderTime.hour, reminderTime.minute)
|
String.format(Locale.getDefault(), "%02d:%02d", reminderTime.hour, reminderTime.minute)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val scheduler2 = AlarmScheduler(context)
|
// Le scheduler est déjà créé au niveau de l'écran
|
||||||
if (!scheduler2.canScheduleExact()) {
|
// (var `scheduler`) — pas de recréation par recompose.
|
||||||
|
if (!scheduler.canScheduleExact()) {
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.exact_alarm_needed),
|
stringResource(R.string.exact_alarm_needed),
|
||||||
|
|||||||
@ -56,7 +56,7 @@ class EstrannaiseCloudTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `cloud has 32 curves with real dispersion between them`() {
|
fun `cloud has 32 curves with real dispersion between them`() {
|
||||||
val cloud = EstrannaiseCloud.compute(
|
val cloud = EstrannaiseCloud.compute(
|
||||||
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW
|
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L
|
||||||
)
|
)
|
||||||
assertEquals("32 courbes par défaut", 32, cloud.size)
|
assertEquals("32 courbes par défaut", 32, cloud.size)
|
||||||
assertTrue(cloud.all { it.size >= 2 })
|
assertTrue(cloud.all { it.size >= 2 })
|
||||||
@ -74,7 +74,7 @@ class EstrannaiseCloudTest {
|
|||||||
val start = NOW - 10 * DAY_MS
|
val start = NOW - 10 * DAY_MS
|
||||||
val end = NOW
|
val end = NOW
|
||||||
val cloud = EstrannaiseCloud.compute(
|
val cloud = EstrannaiseCloud.compute(
|
||||||
listOf(ese()), doses(), start, end, 3_600_000L, NOW
|
listOf(ese()), doses(), start, end, 3_600_000L
|
||||||
)
|
)
|
||||||
assertTrue(cloud.all { c -> c.all { it.timestamp in start..end } })
|
assertTrue(cloud.all { c -> c.all { it.timestamp in start..end } })
|
||||||
}
|
}
|
||||||
@ -88,7 +88,7 @@ class EstrannaiseCloudTest {
|
|||||||
// (le pkModel stocké n'est pas un prérequis). Le nuage superpose les
|
// (le pkModel stocké n'est pas un prérequis). Le nuage superpose les
|
||||||
// doses EEn avec les params MCMC de EEN.
|
// doses EEn avec les params MCMC de EEN.
|
||||||
val tfsStored = ese().copy(pkModel = "TFS")
|
val tfsStored = ese().copy(pkModel = "TFS")
|
||||||
val cloud = EstrannaiseCloud.compute(listOf(tfsStored), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW)
|
val cloud = EstrannaiseCloud.compute(listOf(tfsStored), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L)
|
||||||
assertEquals(32, cloud.size)
|
assertEquals(32, cloud.size)
|
||||||
assertTrue(cloud.all { it.size >= 2 })
|
assertTrue(cloud.all { it.size >= 2 })
|
||||||
}
|
}
|
||||||
@ -103,7 +103,7 @@ class EstrannaiseCloudTest {
|
|||||||
route = AdministrationRoute.ORAL, doseAmount = 12.5, doseUnit = "mg",
|
route = AdministrationRoute.ORAL, doseAmount = 12.5, doseUnit = "mg",
|
||||||
esterType = "NONE", forecastIntervalDays = 2.0, createdAt = 0
|
esterType = "NONE", forecastIntervalDays = 2.0, createdAt = 0
|
||||||
)
|
)
|
||||||
assertTrue(EstrannaiseCloud.compute(listOf(oral), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW).isEmpty())
|
assertTrue(EstrannaiseCloud.compute(listOf(oral), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L).isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -112,7 +112,7 @@ class EstrannaiseCloudTest {
|
|||||||
// (comme dans la courbe ESE) — le nuage des autres esters reste
|
// (comme dans la courbe ESE) — le nuage des autres esters reste
|
||||||
val pep = ese().copy(esterType = "PEP")
|
val pep = ese().copy(esterType = "PEP")
|
||||||
val dosesPep = listOf(DoseLog(id = 9, treatmentId = 1, timestamp = NOW - 7 * DAY_MS, doseAmount = 25.0))
|
val dosesPep = listOf(DoseLog(id = 9, treatmentId = 1, timestamp = NOW - 7 * DAY_MS, doseAmount = 25.0))
|
||||||
val cloud = EstrannaiseCloud.compute(listOf(pep), dosesPep, NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW)
|
val cloud = EstrannaiseCloud.compute(listOf(pep), dosesPep, NOW - 30 * DAY_MS, NOW, 3_600_000L)
|
||||||
// PEP n'est pas couvert par le fit ESE → pas d'échantillon → nuage vide
|
// PEP n'est pas couvert par le fit ESE → pas d'échantillon → nuage vide
|
||||||
assertTrue(cloud.isEmpty())
|
assertTrue(cloud.isEmpty())
|
||||||
}
|
}
|
||||||
@ -120,14 +120,14 @@ class EstrannaiseCloudTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `no doses on the carrier produces no cloud`() {
|
fun `no doses on the carrier produces no cloud`() {
|
||||||
assertTrue(EstrannaiseCloud.compute(
|
assertTrue(EstrannaiseCloud.compute(
|
||||||
listOf(ese()), emptyList(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW
|
listOf(ese()), emptyList(), NOW - 30 * DAY_MS, NOW, 3_600_000L
|
||||||
).isEmpty())
|
).isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `nbCurves below 2 produces no cloud`() {
|
fun `nbCurves below 2 produces no cloud`() {
|
||||||
assertTrue(EstrannaiseCloud.compute(
|
assertTrue(EstrannaiseCloud.compute(
|
||||||
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW, nbCurves = 1
|
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, nbCurves = 1
|
||||||
).isEmpty())
|
).isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,11 +141,11 @@ class EstrannaiseCloudTest {
|
|||||||
// niveau entre nuage calibré et nuage brut ≈ ×2 également).
|
// niveau entre nuage calibré et nuage brut ≈ ×2 également).
|
||||||
val scaled = mapOf("EEN" to 2.0)
|
val scaled = mapOf("EEN" to 2.0)
|
||||||
val calibrated = EstrannaiseCloud.compute(
|
val calibrated = EstrannaiseCloud.compute(
|
||||||
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW,
|
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L,
|
||||||
scalePerEster = scaled
|
scalePerEster = scaled
|
||||||
)
|
)
|
||||||
val raw = EstrannaiseCloud.compute(
|
val raw = EstrannaiseCloud.compute(
|
||||||
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW
|
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L
|
||||||
)
|
)
|
||||||
assertEquals(calibrated.size, raw.size)
|
assertEquals(calibrated.size, raw.size)
|
||||||
assertTrue(calibrated.isNotEmpty())
|
assertTrue(calibrated.isNotEmpty())
|
||||||
|
|||||||
@ -25,7 +25,8 @@ import java.io.File
|
|||||||
* honnête — l'UI affiche alors l'invite « renseigne une Posologie ») ;
|
* honnête — l'UI affiche alors l'invite « renseigne une Posologie ») ;
|
||||||
* - une prise de sang RÉCENTE fait recommander le creux SUIVANT (jamais un
|
* - une prise de sang RÉCENTE fait recommander le creux SUIVANT (jamais un
|
||||||
* creux déjà mesuré) ;
|
* creux déjà mesuré) ;
|
||||||
* - modèle Estrannaise : t½ LUE dans la table (aucun crash, valeur finie).
|
* - modèle Estrannaise : t½ ANALYTIQUE depuis v1.9.0 (fin de la lecture
|
||||||
|
* de table — cf le dernier test du fichier ; aucun crash, valeur finie).
|
||||||
*/
|
*/
|
||||||
class LabTimingTest {
|
class LabTimingTest {
|
||||||
|
|
||||||
|
|||||||
@ -281,6 +281,32 @@ class ChartZoomTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `xLabelTicks daily steps stay at local midnight across DST transition`() {
|
||||||
|
// v1.9.5 (audit) : l'avancement du pas se fait par Calendar (et non
|
||||||
|
// un +24h fixe en ms) — sans ça, le tick qui FRANCHIT la transition
|
||||||
|
// tombait à 23h/01h locale au lieu de minuit. Europe/Paris 2026 :
|
||||||
|
// passage à l'heure d'été le 29 mars (02:00 → 03:00).
|
||||||
|
val zone = java.util.TimeZone.getTimeZone("Europe/Paris")
|
||||||
|
val cal = java.util.Calendar.getInstance(zone).apply {
|
||||||
|
clear()
|
||||||
|
set(2026, 2, 27, 12, 0, 0) // 27/03/2026 12:00 — la fenêtre chevauche la transition
|
||||||
|
}
|
||||||
|
val t0 = cal.timeInMillis
|
||||||
|
val ticks = com.hormonetrack.ui.components.xLabelTicks(
|
||||||
|
t0, t0 + 6L * 86_400_000L, 24L, zone
|
||||||
|
)
|
||||||
|
assertTrue(ticks.isNotEmpty())
|
||||||
|
var crossed = false
|
||||||
|
for (t in ticks) {
|
||||||
|
val c = java.util.Calendar.getInstance(zone).apply { timeInMillis = t }
|
||||||
|
assertEquals("tick à ${c.time} doit être minuit Paris (DST franchi)",
|
||||||
|
0, c.get(java.util.Calendar.HOUR_OF_DAY))
|
||||||
|
if (c.get(java.util.Calendar.DAY_OF_MONTH) >= 30) crossed = true
|
||||||
|
}
|
||||||
|
assertTrue("la fenêtre doit franchir la transition DST pour distinguer les deux implémentations", crossed)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `xLabelTicks hourly steps land at whole local hours`() {
|
fun `xLabelTicks hourly steps land at whole local hours`() {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
|
|||||||
@ -3,6 +3,44 @@
|
|||||||
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.9.5] — 2026-09-20 (versionCode 42)
|
||||||
|
|
||||||
|
### Corrigé — revue de code post-v1.9.4 (audit lecture-seule des deux dépôts)
|
||||||
|
|
||||||
|
- **#67 — les ticks X ignoraient le fuseau choisi** : `drawXLabels` formatait
|
||||||
|
les labels DANS le fuseau choisi mais appelait `xLabelTicks` SANS le
|
||||||
|
passer — les ticks étaient alignés sur le fuseau de l'appareil (heures
|
||||||
|
décalées dès que les deux divergent, contredisant le fix #55). Fix :
|
||||||
|
zone passée + **pas avancé par Calendar** (un `+24 h` fixe dérivait d'1 h
|
||||||
|
aux transitions DST) ; test DST épinglé (Europe/Paris, 29/03/2026).
|
||||||
|
- **`EstrannaiseCloud.compute` : paramètre `nowMs` mort supprimé** (jamais
|
||||||
|
lu — le KDoc prétendait une sémantique que le code n'avait pas) ;
|
||||||
|
appelants + tests mis à jour.
|
||||||
|
|
||||||
|
### Amélioré — perf (suite de l'optimisation v1.8.2)
|
||||||
|
|
||||||
|
- **Contexte moteur pré-calculé** (`E2Context` + `prepareE2Context`) : le
|
||||||
|
tracé labs appelle `e2At` par point de grille (720 pts) — chaque appel
|
||||||
|
reconstruisait les maps internes (groupBy/cutoffs/Bateman), diluant
|
||||||
|
l'optimisation v1.8.2. Préparé UNE fois par courbe labs ET par
|
||||||
|
calibration par ester ; **résultat bit-identique** (244 tests verts).
|
||||||
|
- **Canvas** : Paints texte MÉMOÏSÉS par (couleur, taille, align, gras)
|
||||||
|
(une alloc par extremum/label × frame → une par clé) ; `detectExtrema`
|
||||||
|
mémoïsé par série (scan O(n) par frame → par changement de séries) ;
|
||||||
|
formatters X/marqueurs mémoïsés (SimpleDateFormat par frame → par
|
||||||
|
fuseau).
|
||||||
|
|
||||||
|
### Documenté — commentaires et docs rattrapés après les refontes
|
||||||
|
|
||||||
|
- Commentaires à jour avec le code : `PKPresets.kt` (l'ODS n'existe plus
|
||||||
|
au runtime), `Treatment.kt` (ESE analytique 6 esters),
|
||||||
|
`LabTimingTest.kt` (t½ analytique), doc §7.2/§7.3 (asset historique,
|
||||||
|
cutoff = 10 × t½ depuis v1.9.0).
|
||||||
|
- Règle anti-#59 ré-appliquée : labels de modèle via `modelLabelRes` (le
|
||||||
|
`when` inline de l'éditeur violait la règle) ; literals « ESE »/« TFS »/
|
||||||
|
« WHS » remplacés par `PKModels.*` (ChartScreen/CurveChart) ;
|
||||||
|
`scheduler2` supprimé (le scheduler de l'écran existe déjà).
|
||||||
|
|
||||||
## [1.9.4] — 2026-09-19 (versionCode 41)
|
## [1.9.4] — 2026-09-19 (versionCode 41)
|
||||||
|
|
||||||
### Ajouté — les presets du modèle ESE couvrent ses 6 esters injectables
|
### Ajouté — les presets du modèle ESE couvrent ses 6 esters injectables
|
||||||
|
|||||||
@ -63,6 +63,7 @@ Fonctionnalités v1 :
|
|||||||
|
|
||||||
| Date | Événement |
|
| Date | Événement |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| 20 sept. 2026 (session v1.9.5) | **Revue de code post-v1.9.4 — audit lecture-seule des deux dépôts puis corrections** (demande : « les logiques, choix techniques, commentaires, optimisations ») : (a) **#67** : ticks X alignés sur le fuseau APPAREIL pendant que les labels étaient formatés dans le fuseau CHOISI (zone manquante à `xLabelTicks`, contredisant #55) + pas fixe `+24 h` dérivant d'1 h aux transitions DST → zone passée + avancement Calendar, test DST (Europe/Paris 29/03) ; (b) `EstrannaiseCloud.compute` : `nowMs` mort supprimé (KDoc mensonger) ; (c) **perf** : `E2Context`/`prepareE2Context` — le tracé labs (720 pts) reconstruisait les maps moteur par point, diluant v1.8.2 (résultat bit-identique) ; Paints texte + `detectExtrema` + formatters mémoïsés dans le Canvas ; (d) **règles ré-appliquées** : `modelLabelRes` (anti-#59), `PKModels.*` partout (fini les literals), `scheduler2` supprimé ; (e) **doc rattrapée** : 3 commentaires périmés (ODS/lookup/t½-table), §7.2/§7.3 (asset historique, cutoff 10×t½). **Web miroir v1.9.5** : garde `hasModel` ESE dans `cutoffHours` (un backup importé ESE+ester non couvert levait TypeError → écran vide), portage des optimisations v1.8.2 (contexte préparé, cutoffs par traitement, Bateman paresseux), min/max en une passe (RangeError possible sur ~230 k pts avec le Nuage), init MCMC non bloquante (data-ready ⇒ asset chargé), `pk_profiles.json` exclu du zip (export-ignore, −548 Ko livrés), E2E version paramétrée (`data-version` — anti-bug v1.9.3), nettoyages (import mort lab-timing, JSDoc « troncature », dialogs ESE=3, COLORS accueil, JSDoc orpheline). 244 verts Android + lint · 175 verts web + E2E ✓. |
|
||||||
| 19 sept. 2026 (session v1.9.4) | **Presets du modèle ESE étendus à ses 6 esters injectables** (remontée : « depuis l'ajout du modèle source ESE, les presets n'ont pas été mis à jour ») : 3 presets ajoutés (EC huile 5 mg IM, EB 1 mg IM, EUCS suspension cristalline 10 mg SC — 25 entrées), champs Bateman informatifs = t½ des fits ; **éditeur** : la liste des esters suit `Esters.choicesForModel` (parité DoseDialog/web) et rebascule sur EV si l'ester sort de la couverture au changement de modèle ; **web** : 2 trous du portage v1.9.0 corrigés (`Esters.EUCS` manquant dans pk-engine.js — preset référençant `undefined` — et `choicesForModel` resté sur la couverture ODS à 3 esters) + `tests/presets.test.js` (6 tests, miroir `PKPresetsTest`). 243 verts / 213 sans données locales + lint + check web (175). Doc : doublon §7.12 supprimé. Web v1.9.4 sync. |
|
| 19 sept. 2026 (session v1.9.4) | **Presets du modèle ESE étendus à ses 6 esters injectables** (remontée : « depuis l'ajout du modèle source ESE, les presets n'ont pas été mis à jour ») : 3 presets ajoutés (EC huile 5 mg IM, EB 1 mg IM, EUCS suspension cristalline 10 mg SC — 25 entrées), champs Bateman informatifs = t½ des fits ; **éditeur** : la liste des esters suit `Esters.choicesForModel` (parité DoseDialog/web) et rebascule sur EV si l'ester sort de la couverture au changement de modèle ; **web** : 2 trous du portage v1.9.0 corrigés (`Esters.EUCS` manquant dans pk-engine.js — preset référençant `undefined` — et `choicesForModel` resté sur la couverture ODS à 3 esters) + `tests/presets.test.js` (6 tests, miroir `PKPresetsTest`). 243 verts / 213 sans données locales + lint + check web (175). Doc : doublon §7.12 supprimé. Web v1.9.4 sync. |
|
||||||
| 19 sept. 2026 (session v1.9.2) | **Fix nuage vide sans traitement stocké ESE** (remontée : « il ne s'active que autour du tracé émulé, pas autour du modèle Estrannaise ») : le filtre du nuage testait le pkModel STOCKÉ — or la courbe ESE redessine TOUTES les doses E2 (modelOverride ESE) quel que soit le modèle stocké. FIX : le nuage couvre les doses à ester effectif couvert par ESE (peu importe le stocké) ; l'exclusivité reste portée par le chip ; l'oral Bateman reste hors nuage. 2 tests réécrits (237 verts / 207 sans). Web miroir v1.9.2. |
|
| 19 sept. 2026 (session v1.9.2) | **Fix nuage vide sans traitement stocké ESE** (remontée : « il ne s'active que autour du tracé émulé, pas autour du modèle Estrannaise ») : le filtre du nuage testait le pkModel STOCKÉ — or la courbe ESE redessine TOUTES les doses E2 (modelOverride ESE) quel que soit le modèle stocké. FIX : le nuage couvre les doses à ester effectif couvert par ESE (peu importe le stocké) ; l'exclusivité reste portée par le chip ; l'oral Bateman reste hors nuage. 2 tests réécrits (237 verts / 207 sans). Web miroir v1.9.2. |
|
||||||
| 19 sept. 2026 (session v1.9.1) | **Fix nuage calibré + suggestion sur l'Accueil + explication +4 pg/mL** : (a) remontée « le nuage ne s'active que autour du tracé, pas autour du modèle Estrannaise » : la courbe ESE est CALIBRÉE (scalePerEster) mais le nuage était BRUT → échelles différentes. FIX : EstrannaiseCloud.compute reçoit le MÊME scalePerEster que la courbe ESE (Android + web miroir) ; test nuage calibré ×2 (Android). (b) Le +4 pg/mL constaté depuis v1.8.2 : l'ARRONDI de la médiane (fix v1.8.2, plus juste que la troncature) a déplacé une échelle auto-calibrée d'un cran — le niveau actuel est le CORRIGÉ, documenté CHANGELOG 1.9.1. (c) Émulateur : nuage visible autour de la courbe ESE calibrée (1363 px rose pâle, seed réel + traitement EV ESE de test), 0 crash. Web v1.9.1 sync (harmonisation de l'arrondi — les niveaux web/android convergent). 235 verts / 205 sans données locales. |
|
| 19 sept. 2026 (session v1.9.1) | **Fix nuage calibré + suggestion sur l'Accueil + explication +4 pg/mL** : (a) remontée « le nuage ne s'active que autour du tracé, pas autour du modèle Estrannaise » : la courbe ESE est CALIBRÉE (scalePerEster) mais le nuage était BRUT → échelles différentes. FIX : EstrannaiseCloud.compute reçoit le MÊME scalePerEster que la courbe ESE (Android + web miroir) ; test nuage calibré ×2 (Android). (b) Le +4 pg/mL constaté depuis v1.8.2 : l'ARRONDI de la médiane (fix v1.8.2, plus juste que la troncature) a déplacé une échelle auto-calibrée d'un cran — le niveau actuel est le CORRIGÉ, documenté CHANGELOG 1.9.1. (c) Émulateur : nuage visible autour de la courbe ESE calibrée (1363 px rose pâle, seed réel + traitement EV ESE de test), 0 crash. Web v1.9.1 sync (harmonisation de l'arrondi — les niveaux web/android convergent). 235 verts / 205 sans données locales. |
|
||||||
@ -286,11 +287,16 @@ Pics de référence (pg/mL par mg) :
|
|||||||
| `EU_tfs` | Transfem Science | undecylate | 10,11 | ~198 h |
|
| `EU_tfs` | Transfem Science | undecylate | 10,11 | ~198 h |
|
||||||
| `EEn_tfs` | Transfem Science | enanthate | 31,97 | ~156 h |
|
| `EEn_tfs` | Transfem Science | enanthate | 31,97 | ~156 h |
|
||||||
|
|
||||||
### 7.2 `PKProfileStore` (asset loader + échantillonnage)
|
### 7.2 `PKProfileStore` (⚠️ HORS RUNTIME depuis v1.9.0 — voir §7.12)
|
||||||
|
|
||||||
- Asset `app/src/main/assets/pk_profiles.json` : `{ "params": {D/k1/k2/k3…},
|
- **v1.9.0 (§7.12)** : le moteur Estrannaise est devenu ANALYTIQUE (forme
|
||||||
|
close de estrannaise.js) et `PKProfileStore` a quitté le runtime (asset
|
||||||
|
déplacé en `src/test/assets/`, −550 Ko d'APK). Ce qui suit décrit
|
||||||
|
l'architecture HISTORIQUE, encore active pour le TEST de fidélité RMS.
|
||||||
|
- Asset `src/test/assets/pk_profiles.json` : `{ "params": {D/k1/k2/k3…},
|
||||||
"profiles": { "EV_ese": [8001 floats], … } }` (550 KB, parse ~ms via `JsonParser`)
|
"profiles": { "EV_ese": [8001 floats], … } }` (550 KB, parse ~ms via `JsonParser`)
|
||||||
- **`initWithJson(json)`** = point d'entrée testable (JVM) ; `init(context)` lit l'asset
|
- **`initWithJson(json)`** = point d'entrée testable (JVM) ; `init(context)`
|
||||||
|
(ancienne lecture d'asset) n'existe plus au runtime
|
||||||
- `sample(ester, model, dtHours)` :
|
- `sample(ester, model, dtHours)` :
|
||||||
- modèle **strict** : seul "TFS"→`tfs` et "ESE"→`ese` ; tout autre → 0 (piège corrigé,
|
- modèle **strict** : seul "TFS"→`tfs` et "ESE"→`ese` ; tout autre → 0 (piège corrigé,
|
||||||
cf §14)
|
cf §14)
|
||||||
@ -305,8 +311,9 @@ Pics de référence (pg/mL par mg) :
|
|||||||
|
|
||||||
Contribution d'une dose = `sample(...) × dose_mg` ; niveau total = somme des contributions
|
Contribution d'une dose = `sample(...) × dose_mg` ; niveau total = somme des contributions
|
||||||
de toutes les doses E2, chacune multipliée par le `scaleFactor` de son traitement.
|
de toutes les doses E2, chacune multipliée par le `scaleFactor` de son traitement.
|
||||||
Coupure par dose : `cutoffHours` = longueur de table (8001 h) pour les profils,
|
Coupure par dose `cutoffHours` (§7.12 pour l'ESE actuel) : **10 × t½ terminale**
|
||||||
`30 × t½` pour Bateman.
|
du modèle de profil (v1.9.0 — analytique), `30 × t½` pour Bateman ;
|
||||||
|
la longueur de table 8001 h ne s'applique qu'à l'historique pré-1.9.0.
|
||||||
|
|
||||||
### 7.3b Override de modèle + prévision + auto-calibration (v1.2)
|
### 7.3b Override de modèle + prévision + auto-calibration (v1.2)
|
||||||
|
|
||||||
@ -1724,6 +1731,14 @@ valeur d'un lab ne rafraîchissait pas les courbes avant le tick de 60 s. Leçon
|
|||||||
les clés d'un producteur d'état Compose doivent couvrir TOUTE entrée du calcul (pas
|
les clés d'un producteur d'état Compose doivent couvrir TOUTE entrée du calcul (pas
|
||||||
un proxy comme `.size`).
|
un proxy comme `.size`).
|
||||||
|
|
||||||
|
**#67 (v1.9.5) — ticks X alignés sur le mauvais fuseau + dérive DST** : `drawXLabels`
|
||||||
|
formatait les labels dans le fuseau CHOISI mais appelait `xLabelTicks(t0, t1, step)`
|
||||||
|
sans lui passer la zone — l'alignement « minuit local » (fix #55) se faisait sur le
|
||||||
|
fuseau de l'APPAREIL (heures décalées dès que les deux divergent). Accessoirement le
|
||||||
|
pas avançait en ms fixes (`t += stepMs`) : dérive d'1 h aux transitions DST. Leçon :
|
||||||
|
quand un helper prend un fuseau pour FORMATTER, la même zone doit régler
|
||||||
|
l'ALIGNEMENT — et un pas « 24 h » s'ajoute via Calendar (minuit LOCAL), pas en ms.
|
||||||
|
|
||||||
## 15.bis Comment régénérer l'asset mcmc_samples.json (v1.9.0)
|
## 15.bis Comment régénérer l'asset mcmc_samples.json (v1.9.0)
|
||||||
|
|
||||||
L'asset `src/main/assets/mcmc_samples.json` (posterior MCMC de
|
L'asset `src/main/assets/mcmc_samples.json` (posterior MCMC de
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user