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:
Siphonight 2026-09-20 12:56:15 +02:00
parent e4f52bc6da
commit 9eda34b4a9
15 changed files with 315 additions and 159 deletions

View File

@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue. > sang et aux consignes de ton endocrinologue.
- **Statut** : v1.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
``` ```

View File

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

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)
} }

View File

@ -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,

View File

@ -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),

View File

@ -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())

View File

@ -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 {

View File

@ -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()

View File

@ -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

View File

@ -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