From 0f0ecc3d23324e9975507b14e3c2c8ea976d15fd Mon Sep 17 00:00:00 2001 From: Siphonight Date: Sun, 20 Sep 2026 12:56:15 +0200 Subject: [PATCH] =?UTF-8?q?v1.9.5=20:=20revue=20de=20code=20=E2=80=94=20fi?= =?UTF-8?q?x=20#67=20ticks=20X/fuseau,=20contexte=20moteur,=20Canvas=20m?= =?UTF-8?q?=C3=A9mo=C3=AFs=C3=A9,=20docs=20align=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 4 +- app/build.gradle.kts | 4 +- .../com/hormonetrack/data/model/PKPresets.kt | 3 +- .../com/hormonetrack/data/model/Treatment.kt | 3 +- .../com/hormonetrack/pk/EstrannaiseCloud.kt | 5 - .../com/hormonetrack/pk/LabTrajectoryModel.kt | 12 +- .../hormonetrack/pk/PharmacokineticEngine.kt | 101 ++++++---- .../hormonetrack/ui/components/CurveChart.kt | 173 +++++++++++------- .../hormonetrack/ui/screens/ChartScreen.kt | 44 +++-- .../ui/screens/TreatmentEditorScreen.kt | 15 +- .../hormonetrack/pk/EstrannaiseCloudTest.kt | 18 +- .../java/com/hormonetrack/pk/LabTimingTest.kt | 3 +- .../java/com/hormonetrack/ui/ChartZoomTest.kt | 26 +++ docs/CHANGELOG.md | 38 ++++ docs/DEVELOPPEMENT.md | 25 ++- 15 files changed, 315 insertions(+), 159 deletions(-) diff --git a/README.md b/README.md index 1fb18fc..aa5ee19 100644 --- a/README.md +++ b/README.md @@ -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 > 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) - **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) @@ -123,7 +123,7 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1. git clone && cd HormoneTrack echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME ./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 ``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5618b61..52e5963 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,8 @@ android { // tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0" // (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans // les APK publiés (Paramètres + titre du dialog « Nouveautés »). - versionCode = 41 - versionName = "1.9.4" + versionCode = 42 + versionName = "1.9.5" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/hormonetrack/data/model/PKPresets.kt b/app/src/main/java/com/hormonetrack/data/model/PKPresets.kt index bb846ea..f7c7bca 100644 --- a/app/src/main/java/com/hormonetrack/data/model/PKPresets.kt +++ b/app/src/main/java/com/hormonetrack/data/model/PKPresets.kt @@ -18,7 +18,8 @@ data class PKPreset( object PKPresets { val all: List = 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( nameRes = R.string.preset_ev_ese, type = TreatmentType.ESTRADIOL, diff --git a/app/src/main/java/com/hormonetrack/data/model/Treatment.kt b/app/src/main/java/com/hormonetrack/data/model/Treatment.kt index 6a0188b..682338d 100644 --- a/app/src/main/java/com/hormonetrack/data/model/Treatment.kt +++ b/app/src/main/java/com/hormonetrack/data/model/Treatment.kt @@ -94,7 +94,8 @@ data class Treatment( val isActive: Boolean = true, 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 pkModel: String = PKModels.ESTRANNAISE, diff --git a/app/src/main/java/com/hormonetrack/pk/EstrannaiseCloud.kt b/app/src/main/java/com/hormonetrack/pk/EstrannaiseCloud.kt index 3346cb3..ece6b46 100644 --- a/app/src/main/java/com/hormonetrack/pk/EstrannaiseCloud.kt +++ b/app/src/main/java/com/hormonetrack/pk/EstrannaiseCloud.kt @@ -58,10 +58,6 @@ object EstrannaiseCloud { * @param doseLogs toutes les doses réelles * @param startMs/endMs fenêtre demandée (celle du graphique) * @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]) * @return M listes de points (M = [nbCurves]) — **vide** si rien n'est * calculable : aucune dose E2 à profil injectable dont l'ester @@ -74,7 +70,6 @@ object EstrannaiseCloud { startMs: Long, endMs: Long, stepMs: Long, - nowMs: Long, nbCurves: Int = DEFAULT_NB_CURVES, /** * Calibration à appliquer au nuage (v1.9.1 — fix « le nuage ne diff --git a/app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt b/app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt index 48126c4..88a6a03 100644 --- a/app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt +++ b/app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt @@ -178,17 +178,19 @@ object LabTrajectoryModel { 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) + // 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 e2Ctx = PharmacokineticEngine.prepareE2Context(unscaled, doseLogs) // ── 1) Timeline des ANCRAGES (labs croissants + garde #61) ─────────── // Un couple (t_lab, ratio) par lab significatif, trié croissant. val anchors = mutableListOf>() 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, scalePerEster = null - ) + val predicted = PharmacokineticEngine.e2At(ctx = e2Ctx, tMs = lab.timestamp, modelOverride = modelOverride) if (!PharmacokineticEngine.labIsSignificant(predicted, maxPredictionSoFar)) continue anchors.add(lab.timestamp to (lab.value / predicted)) if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted @@ -220,7 +222,7 @@ object LabTrajectoryModel { val points = mutableListOf() var t = t0 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) points.add(LevelPoint(t, m * rho, 0.0)) // t=0 : pas de courbe T pour cette série t += stepMs diff --git a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt index 387c392..6ba57f8 100644 --- a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt +++ b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt @@ -164,6 +164,65 @@ object PharmacokineticEngine { // 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, + internal val dosesByTreatment: Map>, + internal val cutoffByTreatment: HashMap, + internal val batemanCache: HashMap, + ) + + /** Prépare un [E2Context] pour une série d'appels e2At (même entrées). */ + fun prepareE2Context(treatments: List, doseLogs: List): E2Context { + val dosesByTreatment = doseLogs.groupBy { it.treatmentId } + val cutoffByTreatment = HashMap() + val batemanCache = HashMap() + 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? = 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( treatments: List, doseLogs: List, @@ -177,43 +236,7 @@ object PharmacokineticEngine { */ scalePerEster: Map? = null ): Double { - // Pré-calculs PAR TRAITEMENT (v1.8.2 — le chemin chaud du tracé labs - // 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() - val batemanCache = HashMap() - 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 + return e2At(prepareE2Context(treatments, doseLogs), tMs, modelOverride, scalePerEster) } fun testosteroneAt(e2Level: Double, config: TConfig): Double { @@ -460,6 +483,8 @@ object PharmacokineticEngine { // stocké du traitement, ce qui produisait des facteurs absurdes dès // qu'un traitement basculait de modèle, ex. ×2,21 sur WHSAH) 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>() var maxPredictionSoFar = 0.0 // ⚠️ fix #61 : labs par timestamp CROISSANT + garde labIsSignificant — @@ -467,7 +492,7 @@ object PharmacokineticEngine { // résiduelle) produisaient des ratios aberrants dont la MÉDIANE // tombait à ×2,21 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 // Attribution : dernière dose E2 ≤ lab → son ester (override compris) diff --git a/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt b/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt index 8491ae9..44bdb04 100644 --- a/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt +++ b/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt @@ -3,6 +3,7 @@ package com.hormonetrack.ui.components import androidx.compose.foundation.Canvas import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset 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.unit.dp import com.hormonetrack.data.model.DoseLog +import com.hormonetrack.data.model.PKModels import com.hormonetrack.data.model.LabResult import com.hormonetrack.pk.LevelPoint import com.hormonetrack.pk.PharmacokineticEngine @@ -101,12 +103,20 @@ fun xLabelTicks( cal.set(Calendar.MILLISECOND, 0) while (cal.timeInMillis <= t0) cal.add(Calendar.HOUR_OF_DAY, 1) } - val stepMs = stepHours * 3_600_000L val out = mutableListOf() var t = cal.timeInMillis while (t <= t1) { 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 } @@ -174,12 +184,12 @@ fun niceCeil(value: Double): Double { */ fun defaultModelToggles(pkModelsUsed: Set): Map { val toggles = mapOf( - "ESE" to pkModelsUsed.contains("ESE"), - "TFS" to pkModelsUsed.contains("TFS"), - "WHS" to pkModelsUsed.contains("WHS") + PKModels.ESTRANNAISE to pkModelsUsed.contains(PKModels.ESTRANNAISE), + PKModels.TRANSFEM_SCIENCE to pkModelsUsed.contains(PKModels.TRANSFEM_SCIENCE), + PKModels.WHSAH to pkModelsUsed.contains(PKModels.WHSAH) ) // 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 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) { val allPoints = series.flatMap { it.points } if (allPoints.size < 2) return@Canvas @@ -364,27 +403,19 @@ fun CurveChart( drawAxisUnit(T_UNIT, padLeft + w + 6.dp.toPx(), padTop - 6.dp.toPx(), 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 // 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). if (forecastDoses.isNotEmpty()) { val markerColor = forecastMarkerColor - val paint = labelPaint(android.graphics.Paint.Align.CENTER).apply { - color = android.graphics.Color.argb( - (markerColor.alpha * 255).toInt(), - (markerColor.red * 255).toInt(), - (markerColor.green * 255).toInt(), - (markerColor.blue * 255).toInt() - ) - } + val paint = textPaint(markerColor, 10f, android.graphics.Paint.Align.CENTER) // SimpleDateFormat créé UNE fois (hors boucle) : le Canvas se // redessine à chaque pan/zoom, une allocation par dose × frame // était du gaspillage (la classe n'est de toute façon pas // thread-safe — ici on reste dans le DrawScope UI) - val hourFmt = SimpleDateFormat("HH:mm", Locale.getDefault()) - hourFmt.timeZone = zone + val hourFmt = markerTimeFmt for (dose in forecastDoses) { if (dose.timestamp !in t0..t1) continue val x = xOf(dose.timestamp) @@ -439,7 +470,7 @@ fun CurveChart( } // 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 val e2Path = Path() s.points.forEachIndexed { i, p -> @@ -479,17 +510,20 @@ fun CurveChart( // 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.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) { - PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0) - .forEach { ex -> + extremaBySeries[sIdx]?.let { (e2Extrema, tExtrema) -> + e2Extrema.forEach { ex -> drawExtremum(xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color) } - if (options.showT) { - s.tStyle?.let { tStyle -> - PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02) - .forEach { ex -> + if (options.showT) { + s.tStyle?.let { tStyle -> + tExtrema.orEmpty().forEach { ex -> 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() drawPath(path, color) - // Valeur estimée du pic/creux, dans la couleur de la courbe - val paint = 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 = 9.dp.toPx() - textAlign = android.graphics.Paint.Align.CENTER - isAntiAlias = true - isFakeBoldText = true - } + // Valeur estimée du pic/creux, dans la couleur de la courbe (Paint mis + // en cache — v1.9.5 : un Paint par extremum × frame était du gaspillage) + val paint = textPaint(color, 9f, android.graphics.Paint.Align.CENTER, fakeBold = true) drawContext.canvas.nativeCanvas.drawText( 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 { - val c = Color(0xFF6B7280) - return android.graphics.Paint().apply { - color = android.graphics.Color.argb( - (c.alpha * 255).toInt(), - (c.red * 255).toInt(), - (c.green * 255).toInt(), - (c.blue * 255).toInt() - ) - textSize = 10.dp.toPx() - textAlign = align - isAntiAlias = true +/** + * Cache de Paints TEXTE du graphique (v1.9.5, audit) : un Paint par + * (couleur, taille, align, gras) au lieu d'une allocation PAR EXTREMUM / + * label / frame — le Canvas se redessine à chaque pan/zoom/tick. Tout le + * dessin Compose est séquentiel sur le thread UI → un cache d'objet suffit. + * Les clés sont des value classes (Color) → pas de fuite. + */ +private val textPaintCache = HashMap() + +private fun DrawScope.textPaint( + color: Color, + textSizeDp: Float, + 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) { val paint = labelPaint(android.graphics.Paint.Align.RIGHT) for (i in 0..4) { @@ -656,7 +700,11 @@ private fun DrawScope.drawRightYLabels(color: Color, tMax: Double, xRight: Float private fun DrawScope.drawXLabels( color: Color, t0: Long, t1: Long, 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 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 // tick « minuit local » serait étiqueté avec l'heure système. - val fmt = if (spanH <= 25) SimpleDateFormat("HH'h'", Locale.getDefault()) - else SimpleDateFormat("dd/MM", Locale.getDefault()) - fmt.timeZone = zone + // v1.9.5 : formatters mémoïsés passés par le composable (plus d'alloc/frame). + val fmt = if (spanH <= 25) hourFmt else dayFmt // 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 // 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()) drawContext.canvas.nativeCanvas.drawText(fmt.format(Date(tick)), x, yBottom + 16.dp.toPx(), paint) } } private fun DrawScope.drawDotLabel(v: Double, c: Offset) { - val paint = labelPaint(android.graphics.Paint.Align.CENTER).apply { - color = android.graphics.Color.rgb( - (LabDot.red * 255).toInt(), - (LabDot.green * 255).toInt(), - (LabDot.blue * 255).toInt() - ) - textSize = 9.dp.toPx() - isFakeBoldText = true - } + // Paint en cache (v1.9.5 : une allocation par lab × frame → une par + // couleur, réutilisée entre frames) + val paint = textPaint(LabDot, 9f, android.graphics.Paint.Align.CENTER, fakeBold = true) drawContext.canvas.nativeCanvas.drawText(formatValue(v), c.x, c.y, paint) } diff --git a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt index 09d59d9..484aae7 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.hormonetrack.R +import com.hormonetrack.data.model.PKModels import com.hormonetrack.pk.LevelPoint import com.hormonetrack.pk.EstrannaiseCloud import com.hormonetrack.pk.LabTrajectoryModel @@ -153,9 +154,9 @@ fun ChartScreen() { val toggles = defaultModelToggles( treatments.filter { it.usesProfileModel }.map { it.pkModel }.toSet() ) - showEse = toggles.getValue("ESE") - showTfs = toggles.getValue("TFS") - showWhs = toggles.getValue("WHS") + showEse = toggles.getValue(PKModels.ESTRANNAISE) + showTfs = toggles.getValue(PKModels.TRANSFEM_SCIENCE) + showWhs = toggles.getValue(PKModels.WHSAH) modelTogglesInitialized = true } } @@ -192,14 +193,14 @@ fun ChartScreen() { } else { withContext(Dispatchers.Default) { mapOf( - "ESE" to PharmacokineticEngine.autoCalibrated( - treatments, doseLogs, labResults, tConfig, modelOverride = "ESE" + PKModels.ESTRANNAISE to PharmacokineticEngine.autoCalibrated( + treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.ESTRANNAISE ), - "TFS" to PharmacokineticEngine.autoCalibrated( - treatments, doseLogs, labResults, tConfig, modelOverride = "TFS" + PKModels.TRANSFEM_SCIENCE to PharmacokineticEngine.autoCalibrated( + treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.TRANSFEM_SCIENCE ), - "WHS" to PharmacokineticEngine.autoCalibrated( - treatments, doseLogs, labResults, tConfig, modelOverride = "WHS" + PKModels.WHSAH to PharmacokineticEngine.autoCalibrated( + 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 // créneaux projetés, fix #55 : sans marqueur, la projection paraissait // 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( emptyList(), treatments, doseLogs, showForecast, endMs, forecastHorizonHours @@ -338,8 +344,8 @@ fun ChartScreen() { // Calibration PAR MODÈLE (fix #60) : chaque courbe reçoit les // échelles/k calculés avec SA prédiction. Hors auto-calibration // → scaleFactor stocké des traitements (comportement historique). - val effTreatments = autoByModel["TFS"]?.treatments ?: treatments - val effTConfig = autoByModel["TFS"]?.tConfig ?: tConfig + val effTreatments = autoByModel[PKModels.TRANSFEM_SCIENCE]?.treatments ?: treatments + val effTConfig = autoByModel[PKModels.TRANSFEM_SCIENCE]?.tConfig ?: tConfig // Doses réelles + doses prévisionnelles éventuelles val forecastDoses = if (showForecast) { effTreatments.flatMap { @@ -352,7 +358,7 @@ fun ChartScreen() { // 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 // 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 val autoM = autoByModel[model] val curve = PharmacokineticEngine.computeCurve( @@ -365,11 +371,11 @@ fun ChartScreen() { // v1.9.0 : le nuage d'incertitude est attaché à la série // ESE (exclusif à ce modèle — les fits TFS/WHSAH ne // 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 : // nuage brut ≠ échelle de la courbe calibrée) EstrannaiseCloud.compute( - treatments, doseLogs, startMs, endMs, step, nowMs, + treatments, doseLogs, startMs, endMs, step, scalePerEster = autoM?.esterScales ) } else emptyList() @@ -394,9 +400,9 @@ fun ChartScreen() { // T Estrannaise = rose plein, T TFS = rose pointillé. val series = curves.map { (model, points, cloud) -> 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é - "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 // (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) @@ -658,7 +664,7 @@ fun ChartScreen() { // une 2ᵉ fois (bug v1.5.0-trouvé-en-émulateur #63). curves.forEach { (model, _) -> when (model) { - "ESE" -> { + PKModels.ESTRANNAISE -> { Text( stringResource(R.string.legend_ese_e2), style = MaterialTheme.typography.labelMedium, @@ -673,7 +679,7 @@ fun ChartScreen() { } } // v1.4.6 : légende WHSAH (vert / violet pointillé) - "WHS" -> { + PKModels.WHSAH -> { Text( stringResource(R.string.legend_whs_e2), style = MaterialTheme.typography.labelMedium, @@ -741,7 +747,7 @@ fun ChartScreen() { } // v1.9.0 : légende du nuage d'incertitude (ESE) — visible // 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( stringResource(R.string.legend_cloud), style = MaterialTheme.typography.labelMedium, diff --git a/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt index 4ea818c..53436d4 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt @@ -48,6 +48,7 @@ import com.hormonetrack.R import com.hormonetrack.data.model.AdministrationRoute import com.hormonetrack.data.model.Esters import com.hormonetrack.data.model.PKModels +import com.hormonetrack.data.model.modelLabelRes import com.hormonetrack.data.model.PKPresets import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.TreatmentType @@ -289,11 +290,10 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) { label = stringResource(R.string.pk_model), // v1.4.6 : 3ᵉ modèle PK (WHSAH — fit license-free de Mona, // cf pk/WhsahModels.kt) superposable dans le graphique. - selectedLabel = when (model) { - PKModels.TRANSFEM_SCIENCE -> stringResource(R.string.model_tfs) - PKModels.WHSAH -> stringResource(R.string.model_whs) - else -> stringResource(R.string.model_ese) - }, + // Label par le helper UNIQUE (règle anti-bug #59 : + // jamais de when/ternaire de labels en ligne — l'ancien + // à 2 branches affichait « Estrannaise » pour WHSAH). + selectedLabel = stringResource(modelLabelRes(model)), options = listOf( PKModels.ESTRANNAISE to stringResource(R.string.model_ese), 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) ) } - val scheduler2 = AlarmScheduler(context) - if (!scheduler2.canScheduleExact()) { + // Le scheduler est déjà créé au niveau de l'écran + // (var `scheduler`) — pas de recréation par recompose. + if (!scheduler.canScheduleExact()) { Spacer(Modifier.height(6.dp)) Text( stringResource(R.string.exact_alarm_needed), diff --git a/app/src/test/java/com/hormonetrack/pk/EstrannaiseCloudTest.kt b/app/src/test/java/com/hormonetrack/pk/EstrannaiseCloudTest.kt index 97d026c..2a33e84 100644 --- a/app/src/test/java/com/hormonetrack/pk/EstrannaiseCloudTest.kt +++ b/app/src/test/java/com/hormonetrack/pk/EstrannaiseCloudTest.kt @@ -56,7 +56,7 @@ class EstrannaiseCloudTest { @Test fun `cloud has 32 curves with real dispersion between them`() { 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) assertTrue(cloud.all { it.size >= 2 }) @@ -74,7 +74,7 @@ class EstrannaiseCloudTest { val start = NOW - 10 * DAY_MS val end = NOW 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 } }) } @@ -88,7 +88,7 @@ class EstrannaiseCloudTest { // (le pkModel stocké n'est pas un prérequis). Le nuage superpose les // doses EEn avec les params MCMC de EEN. 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) assertTrue(cloud.all { it.size >= 2 }) } @@ -103,7 +103,7 @@ class EstrannaiseCloudTest { route = AdministrationRoute.ORAL, doseAmount = 12.5, doseUnit = "mg", 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 @@ -112,7 +112,7 @@ class EstrannaiseCloudTest { // (comme dans la courbe ESE) — le nuage des autres esters reste val pep = ese().copy(esterType = "PEP") 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 assertTrue(cloud.isEmpty()) } @@ -120,14 +120,14 @@ class EstrannaiseCloudTest { @Test fun `no doses on the carrier produces no cloud`() { 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()) } @Test fun `nbCurves below 2 produces no cloud`() { 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()) } @@ -141,11 +141,11 @@ class EstrannaiseCloudTest { // niveau entre nuage calibré et nuage brut ≈ ×2 également). val scaled = mapOf("EEN" to 2.0) 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 ) 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) assertTrue(calibrated.isNotEmpty()) diff --git a/app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt b/app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt index 8ee6f81..c9b2cff 100644 --- a/app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt +++ b/app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt @@ -25,7 +25,8 @@ import java.io.File * honnête — l'UI affiche alors l'invite « renseigne une Posologie ») ; * - une prise de sang RÉCENTE fait recommander le creux SUIVANT (jamais un * 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 { diff --git a/app/src/test/java/com/hormonetrack/ui/ChartZoomTest.kt b/app/src/test/java/com/hormonetrack/ui/ChartZoomTest.kt index 2af8133..81075c5 100644 --- a/app/src/test/java/com/hormonetrack/ui/ChartZoomTest.kt +++ b/app/src/test/java/com/hormonetrack/ui/ChartZoomTest.kt @@ -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 fun `xLabelTicks hourly steps land at whole local hours`() { val now = System.currentTimeMillis() diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 10b2c5b..0859b62 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,44 @@ 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`, …). +## [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) ### Ajouté — les presets du modèle ESE couvrent ses 6 esters injectables diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index c014ef2..618b46b 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -63,6 +63,7 @@ Fonctionnalités v1 : | 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.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. | @@ -286,11 +287,16 @@ Pics de référence (pg/mL par mg) : | `EU_tfs` | Transfem Science | undecylate | 10,11 | ~198 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`) -- **`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)` : - modèle **strict** : seul "TFS"→`tfs` et "ESE"→`ese` ; tout autre → 0 (piège corrigé, 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 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, -`30 × t½` pour Bateman. +Coupure par dose `cutoffHours` (§7.12 pour l'ESE actuel) : **10 × t½ terminale** +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) @@ -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 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) L'asset `src/main/assets/mcmc_samples.json` (posterior MCMC de