v1.9.7 : la suggestion de prise de sang ne fuit plus quand l'heure d'injection varie

Remontée : « la suggestion change tout le temps à chaque injection si
l'injection n'est pas faite pile à la même heure » — la règle v1.8.1
comparait l'écart inter-doses EXACTEMENT à l'écart précédent : un log
30 min plus tard cassait le régime et repoussait la stabilisation de
5 × t½ à chaque injection (creux recommandé fuyant).

Fix : chaque écart doit rester dans l'INTERVALLE DE POSOLOGIE ± 24 h
(GAP_TOLERANCE_MS — « je m'injecte le même jour, à l'heure près »). La
fenêtre se réfère à l'intervalle THÉORIQUE : des logs à 6,8 j puis 7,2 j
ne se déstabilisent plus en cascade ; un vrai changement de créneau
(2 j au lieu de 7 j) reste hors fenêtre. Le créneau affiché suit
toujours la dernière dose réelle (voulu).

Tests : « interval change » ré-épinglé (NOW+21 j) + 2 nouveaux (flou
d'heure ± 23 h → pas de reset ; écart 25 h → reset). 249 verts + lint.
versionCode 44 / 1.9.7 + CHANGELOG/§2/§7.11/README.
This commit is contained in:
Siphonight 2026-09-21 22:19:46 +02:00
parent a40d9bc076
commit 7d64ec6fb7
6 changed files with 123 additions and 34 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.6 — build Android ✅, **lint vert** ✅, **247 tests unitaires** ✅ (217 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.7 — build Android ✅, **lint vert** ✅, **249 tests unitaires** ✅ (219 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 # 247 tests (217 sans les données locales)
./gradlew testDebugUnitTest # 249 tests (219 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 = 43
versionName = "1.9.6"
versionCode = 44
versionName = "1.9.7"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@ -194,6 +194,15 @@ object LabTiming {
/** Tolérance de comparaison des doses (mg) — évite les faux changements d'arrondi. */
private const val DOSE_EPS = 1e-6
/**
* Tolérance de l'écart inter-doses du régime (v1.9.7) : ± 24 h autour de
* l'intervalle de Posologie — « je m'injecte le même jour de la semaine,
* à l'heure près ». Un log à 12:30 au lieu de 12:00 ne doit pas
* repousser la stabilisation de 5 × t½ (remontée v1.9.7) ; un vrai
* changement de créneau (2 j au lieu de 7 j) reste hors fenêtre.
*/
private val GAP_TOLERANCE_MS = 24L * 3_600_000L
/**
* DÉBUT DU RÉGIME COURANT (v1.8.1 — **correction du proxy v1.8.0**).
*
@ -202,25 +211,33 @@ object LabTiming {
* depuis février" » — l'ancien proxy (1ʳᵉ dose du traitement) ne voyait
* PAS les changements récents de dose ni d'intervalle.
*
* **NOUVELLE RÈGLE** : le régime courant = la **séquence terminale de
* doses** où (a) l'**ester effectif** et la **dose (mg)** sont identiques
* à celles de la dose la plus récente, ET (b) **l'écart entre doses
* consécutives est constant** (= l'écart des deux doses les plus
* récentes). On remonte depuis la dose la plus récente tant que ces
* conditions tiennent ; le régime commence à la dose la plus ancienne
* de la séquence.
* **Critique remontée** (v1.9.7) : « la suggestion de prochaine prise de
* sang change tout le temps à chaque injection si l'injection n'est pas
* faite pile à la même heure » — la comparaison EXACTE de v1.8.1 (écart
* identique au ms près) cassait le régime pour un log à 12:30 au lieu de
* 12:00, repoussant la stabilisation de 5 × t½ à CHAQUE injection : le
* creux recommandé fuyait vers l'avant sans jamais se fixer.
*
* **RÈGLE** : le régime courant = la **séquence terminale de doses** où
* (a) l'**ester effectif** et la **dose (mg)** sont identiques à celles
* de la dose la plus récente, ET (b) chaque **écart entre doses
* consécutives reste dans l'INTERVALLE DE POSOLOGIE ± 24 h**
* ([GAP_TOLERANCE_MS] — « je m'injecte le même jour, à l'heure près »).
* On remonte depuis la dose la plus récente tant que ces conditions
* tiennent ; le régime commence à la dose la plus ancienne de la
* séquence.
*
* Pourquoi chaque composante :
* - **ester effectif** (override par dose compris) : un switch EV → EEn
* réinitialise la stabilisation ;
* - **doseAmount** : 3 mg → 5 mg change le niveau d'équilibre ;
* - **écart inter-doses constant** : 7 j → 2 j → 9 j (posologie
* modifiée/irrégulière) change le creux — conservateur : une série
* d'intervalles chaotiques maintient la carte « non stabilisée », ce
* qui est pharmacocinétiquement vrai (le trough n'est comparable que
* sur un intervalle régulier). La comparaison est EXACTE (arrondi au
* ms) : un écart 7 j → 7 j ± 1 h casse le régime, ce qui reflète un
* vrai changement de créneau.
* - **écart ≈ intervalle de Posologie ± 24 h** : le trough n'est
* comparable que sur un intervalle régulier (pharmacocinétique), mais
* l'heure de log RÉELLE d'une humaine varie — un écart 7 j ± 23 h EST
* le même régime ; 7 j + 25 h (ou un vrai passage à 2 j = 48 h, hors
* fenêtre) casse le régime. La fenêtre se réfère à l'INTERVALLE
* THÉORIQUE (Posologie), pas à l'écart précédent : des logs à 6,8 j
* puis 7,2 j ne se déstabilisent plus en cascade.
*
* @param carrier traitement porteur (injectable E2 actif à Posologie)
* @param myDoses doses du porteur (ordre indifférent)
@ -231,18 +248,23 @@ object LabTiming {
var idx = sorted.size - 1
var regimeStart = sorted[idx].timestamp
var current = sorted[idx]
// Écart EXIGÉ pour remonter : celui des deux doses les plus récentes
// (inconnu si une seule dose — la séquence s'arrête là de toute façon)
var gapExpected = -1L
// Fenêtre d'écart = INTERVALLE DE POSOLOGIE ± tolérance (v1.9.7 —
// remplace la comparaison exacte v1.8.1 qui cassait le régime à
// chaque variation d'heure de log). La Posologie est un prérequis de
// la carte : absente → on s'arrête à la dernière dose (dégradé).
val intervalDays = carrier.forecastIntervalDays ?: return regimeStart
val intervalMs = (intervalDays * 24.0 * 3_600_000.0).toLong()
val gapMin = intervalMs - GAP_TOLERANCE_MS
val gapMax = intervalMs + GAP_TOLERANCE_MS
while (idx > 0) {
val prev = sorted[idx - 1]
val gap = current.timestamp - prev.timestamp
val sameMarker = PharmacokineticEngine.doseEster(carrier, current) == PharmacokineticEngine.doseEster(carrier, prev) &&
Math.abs(current.doseAmount - prev.doseAmount) < DOSE_EPS
// Remonter seulement si même (ester, dose) ET même écart
val sameGap = gapExpected < 0L || gap == gapExpected
// Remonter seulement si même (ester, dose) ET écart dans la
// fenêtre (gap > 0 : un doublon de log n'est jamais un régime)
val sameGap = gap > 0 && gap in gapMin..gapMax
if (!sameMarker || !sameGap) break
gapExpected = gap
regimeStart = prev.timestamp
current = prev
idx--

View File

@ -175,16 +175,55 @@ class LabTimingTest {
@Test
fun `recent interval change resets stabilization`() {
// 9 semaines à 7 j, puis une dose 2 j après la première des deux plus
// récentes : l'écart terminal (NOW−9 j → NOW−7 j = 2 j) devient
// l'écart EXIGÉ du régime → la dose à NOW−9 j fonde le nouveau régime
// (la dose NOW−14 j avait un écart de 5 j ≠ 2 j → hors régime)
// 9 semaines à 7 j, puis une dose 2 j après la précédente : l'écart
// de 2 j (48 h) est HORS de la fenêtre Posologie ± 24 h (v1.9.7,
// [6 j ; 8 j] pour 7 j) → le régime s'arrête à la dose NOW−7 j (un
// vrai changement de créneau reste un changement — la tolérance ne
// rattrape pas un écart de 2 j).
val doses = weeklyDoses(9) + DoseLog(
id = 99, treatmentId = 1, timestamp = NOW - 9 * DAY_MS, doseAmount = 5.0
)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
// régimeStart = NOW−9 j → stabilisation ≈ NOW+13,7 j → 1ᵉʳ créneau : +14 j
assertEquals(NOW + 14 * DAY_MS, rec.injectionMs)
// régimeStart = NOW−7 j → stabilisation ≈ NOW+15,7 j → 1ᵉʳ créneau : +21 j
assertEquals(NOW + 21 * DAY_MS, rec.injectionMs)
assertEquals(false, rec.wasAlreadyStabilized)
}
@Test
fun `logging time jitter does NOT reset stabilization (v1_9_7)`() {
// REMONTÉE v1.9.7 : « la suggestion change tout le temps à chaque
// injection si l'injection n'est pas faite pile à la même heure » —
// l'ancienne comparaison EXACTE cassait le régime pour un log à
// 12:30 au lieu de 12:00. Désormais : chaque écart reste dans
// Posologie ± 24 h → le régime remonte toute la séquence (63 j) →
// déjà stabilisée → 1ᵉʳ creux du créneau, wasAlreadyStabilized=true.
val doses = listOf(
DoseLog(id = 91, treatmentId = 1, timestamp = NOW - 63 * DAY_MS + 2 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 92, treatmentId = 1, timestamp = NOW - 56 * DAY_MS - 3 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 93, treatmentId = 1, timestamp = NOW - 49 * DAY_MS + 5 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 94, treatmentId = 1, timestamp = NOW - 42 * DAY_MS - 1 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 95, treatmentId = 1, timestamp = NOW - 35 * DAY_MS + 23 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 96, treatmentId = 1, timestamp = NOW - 28 * DAY_MS - 23 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 97, treatmentId = 1, timestamp = NOW - 21 * DAY_MS + 1 * HOUR_MS, doseAmount = 5.0),
DoseLog(id = 98, treatmentId = 1, timestamp = NOW - 14 * DAY_MS - 30 * 60_000L, doseAmount = 5.0),
DoseLog(id = 99, treatmentId = 1, timestamp = NOW - 7 * DAY_MS + 45 * 60_000L, doseAmount = 5.0),
)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
// Le créneau se cale sur la dernière dose RÉELLE (+45 min) — voulu :
// le creux suit l'heure réelle, la tolérance protège la STABILISATION.
assertEquals(NOW + 7 * DAY_MS + 45 * 60_000L, rec.injectionMs)
assertTrue("les écarts ± 23 h sont le MÊME régime", rec.wasAlreadyStabilized)
}
@Test
fun `gap beyond 24h tolerance resets stabilization (v1_9_7)`() {
// 8 écarts réguliers, puis un retard de 25 h sur le 7 j (8 j + 1 h) :
// hors fenêtre Posologie ± 24 h → le régime ne remonte PAS avant la
// dose en retard → PAS stabilisée (5 × t½ ≈ 22,7 j > 1,4 j).
val doses = weeklyDoses(9).mapIndexed { idx, d ->
if (idx == 7) d.copy(timestamp = NOW - 14 * DAY_MS + 25 * HOUR_MS) else d
}
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
assertEquals(false, rec.wasAlreadyStabilized)
}

View File

@ -3,6 +3,29 @@
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.7] — 2026-09-20 (versionCode 44)
### Corrigé — la suggestion de prise de sang « change tout le temps » à chaque injection
- **Remontée** : « la suggestion de prochain test sanguin change tout le
temps à chaque injection si l'injection n'est pas faite pile à la même
heure ou si elle est faite 30 minutes plus tard que la précédente ».
- **Cause** : la règle de stabilisation v1.8.1 comparait l'écart inter-doses
**EXACTEMENT** à l'écart précédent (au ms près) — un log à 12:30 au lieu
de 12:00 cassait le régime, repoussant la stabilisation de 5 × t½ à
CHAQUE injection : le creux recommandé fuyait vers l'avant sans jamais
se fixer.
- **Fix (tolérance)** : chaque écart doit rester dans **l'intervalle de
Posologie ± 24 h** (`GAP_TOLERANCE_MS` — « je m'injecte le même jour, à
l'heure près »). La fenêtre se réfère à l'INTERVALLE THÉORIQUE, pas à
l'écart précédent : des logs à 6,8 j puis 7,2 j ne se déstabilisent plus
en cascade. Un vrai changement de créneau (2 j au lieu de 7 j) reste
hors fenêtre et réinitialise, comme avant. Le créneau affiché se cale
toujours sur la dernière dose réelle (voulu).
- Tests : le test v1.8.1 « interval change » ré-épinglé (NOW+21 j) + 2
nouveaux (flou d'heure ± 23 h → PAS de reset ; écart 25 h → reset).
249 tests verts + lint. Web miroir v1.9.7 (12 tests lab-timing).
## [1.9.6] — 2026-09-20 (versionCode 43)
### Modifié — la carte de l'Accueil liste TOUTES les prochaines prises

View File

@ -63,6 +63,7 @@ Fonctionnalités v1 :
| Date | Événement |
|---|---|
| 20 sept. 2026 (session v1.9.7) | **Fix « la suggestion de prise de sang change tout le temps »** (remontée + données réelles : logs d'injection à heures variables) : la règle de stabilisation v1.8.1 comparait l'écart inter-doses EXACTEMENT à l'écart précédent — un log 30 min plus tard cassait le régime et repoussait la stabilisation de 5 × t½ à CHAQUE injection (creux fuyant). FIX : l'écart doit rester dans **l'intervalle de Posologie ± 24 h** (`GAP_TOLERANCE_MS`, fenêtre vs l'interval THÉORIQUE — des logs 6,8 j puis 7,2 j ne se déstabilisent plus en cascade ; un vrai passage à 2 j reste hors fenêtre). Le créneau affiché suit toujours la dernière dose réelle (voulu). Tests : « interval change » ré-épinglé + 2 tests de tolérance. 249 verts + lint. Web miroir v1.9.7 (12 verts lab-timing). §7.11 mis à jour. |
| 20 sept. 2026 (session v1.9.6) | **Carte Accueil « Prochaines doses » — toutes les prises, triées** (remontée : le rappel quotidien CPA masquait le rappel hebdomadaire EEn — « je ne le vois quasiment pas ») : la carte liste UNE ligne par traitement TRIÉES par prochaine prise (1ʳᵉ en avant, suivantes en style secondaire), titre pluriel dès 2 lignes (`next_doses` FR/EN) ; helper PUR `nextDoseLine` (locale/zone paramétrables, template jours localisé `next_dose_days`) — 3 tests `NextDoseLineTest`. Notifications inchangées (une alarme par traitement). Web miroir v1.9.6 : helper pur `nextDoseLineText` (format.js) + même carte + 3 tests miroirs (misc) — 247 verts Android / 178 web. |
| 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. |
@ -635,9 +636,12 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques :
critique : « changé d'ester, de dosage ET de posologie, et l'app disait
stabilisée depuis février ») : le régime courant = la **séquence terminale
de doses** où (a) l'**ester effectif** et la **dose (mg)** sont identiques
à la dose la plus récente, ET (b) **l'écart entre doses consécutives est
constant** (= l'écart des deux doses les plus récentes, comparé
exactement — 7 j ± 1 h casse le régime). Stabilisation = régimeStart +
à la dose la plus récente, ET (b) **l'écart entre doses consécutives
reste dans l'intervalle de Posologie ± 24 h** (v1.9.7 — la comparaison
exacte v1.8.1 cassait le régime à chaque variation d'heure de log et
faisait fuir le creux recommandé ; « je m'injecte le même jour, à l'heure
près » — un vrai changement de créneau, ex. 2 j au lieu de 7 j, reste
hors fenêtre). Stabilisation = régimeStart +
5 × t½. Conservative assumée : des intervalles chaotiques maintiennent la
carte « non stabilisée » (pharmacocinétiquement vrai — le trough n'est
comparable que sur un intervalle régulier).
@ -653,7 +657,8 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques :
minute ; carte avec creux exact (« At the estimated trough »), créneau
associé (+ mention « ou simplement la veille »), statut de stabilisation,
disclaimer. Stabilisation hors horizon (ester ultra-long) → carte cachée.
- 11 tests `LabTimingTest` (8 + 3 v1.8.1 régime récent) + 1 test régression n°5.
- Tests `LabTimingTest` (v1.9.7 : 2 tests de tolérance ± 24 h ajoutés,
le test v1.8.1 « interval change » ré-épinglé) + 1 test régression n°5.
### 7.12 Modèle Estrannaise ANALYTIQUE + nuage MCMC (v1.9.0) — `pk/EstrannaiseModels.kt`, `pk/EstrannaiseCloud.kt`