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 0f0ecc3d23
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
> 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 <repo> && 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
```

View File

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

View File

@ -18,7 +18,8 @@ data class PKPreset(
object PKPresets {
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(
nameRes = R.string.preset_ev_ese,
type = TreatmentType.ESTRADIOL,

View File

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

View File

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

View File

@ -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<Pair<Long, Double>>()
var maxPredictionSoFar = 0.0
for (lab in e2Labs.filter { it.marker.equals("E2", ignoreCase = true) }.sortedBy { it.timestamp }) {
val predicted = PharmacokineticEngine.e2At(
unscaled, doseLogs, lab.timestamp, modelOverride, 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<LevelPoint>()
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

View File

@ -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<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(
treatments: List<Treatment>,
doseLogs: List<DoseLog>,
@ -177,43 +236,7 @@ object PharmacokineticEngine {
*/
scalePerEster: Map<String, Double>? = 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<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
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<String, MutableList<Double>>()
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)

View File

@ -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<Long>()
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<String>): Map<String, Boolean> {
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,21 +510,24 @@ 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 ->
tExtrema.orEmpty().forEach { ex ->
drawExtremum(xOf(ex.timestamp), yT(ex.value), ex, tStyle.color)
}
}
}
}
}
}
// Labs (points/carrés oranges)
if (options.showLabs) {
@ -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()
/**
* 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<String, android.graphics.Paint>()
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 = 10.dp.toPx()
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)
}

View File

@ -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<com.hormonetrack.data.model.DoseLog>(),
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,

View File

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

View File

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

View File

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

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
fun `xLabelTicks hourly steps land at whole local hours`() {
val now = System.currentTimeMillis()

View File

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

View File

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