v1.4.0 : modèle Transfem Science reconstruit sur la méta-analyse officielle (V3C forme close, 7 esters, pics de l'article reproduits à ~1 % — TransfemScienceModels + 5 tests) ; fix #52 : rappels sur la grille Posologie (nextReminderFireFor, re-programmation après notif et 'Pris', ReminderScheduleTest ×7) ; nouveaux presets EB/EC/ECS/PEP ; 102 tests verts + lint

This commit is contained in:
Siphonight 2026-09-06 22:28:18 +02:00
parent fb942e4a24
commit 016606b96c
20 changed files with 832 additions and 102 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.3.5 — build Android ✅, **lint vert** ✅, **90 tests unitaires** ✅ (72 sans les données de test locales ; 3 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.4.0 — build Android ✅, **lint vert** ✅, **102 tests unitaires** ✅ (84 sans les données de test locales ; 3 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)
@ -28,8 +28,12 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
**panoramique** (glisser pour remonter dans le passé), **zoom** (pinch ou boutons
− / +, 6 h → 300 j, échantillonnage adaptatif), **pics & creux** affichables avec
leurs **valeurs estimées** (triangles ▲▼ aux extrema locaux, toggle)
- **Deux modèles PK au choix, superposables** (issus de la feuille `Estrogen.ods`) :
**Estrannaise (EstraNase)** et **Transfem Science** pour les injections EV / EU / EEn,
- **Deux modèles PK au choix, superposables** : **Estrannaise (EstraNase)** (tables
horaires du `.ods`) et **Transfem Science** — depuis v1.4.0, le modèle TFS est la
**méta-analyse officielle à 3 compartiments** (forme close exacte, params du
[simulateur TFS](https://transfemscience.org/misc/injectable-e2-simulator/),
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
avec les **7 esters** : EV, EU, EEn, EB, EC (huile), EC (suspension), PEP,
affichés côte à côte avec toggles indépendants
- **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel,
patch et voie orale
@ -53,7 +57,9 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
**k calibré par période d'ester** (la suppression T diffère valerate vs enanthate),
contre l'E2 déjà calibrée — unités T acceptées : ng/mL, ng/dL, ng/L, nmol/L
- **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ;
les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health)
les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) ;
**les rappels suivent la Posologie** (v1.4.0) : un traitement injecté tous les
7 jours ne sonne que le jour d'injection, pas tous les jours
- **Nouveautés à chaque mise à jour** : dialog de changelog automatique
(fermé = ne réapparaît pas avant la prochaine version)
- **Temps sous THS** affiché en haut de la page Doses (depuis la 1re prise)
@ -73,7 +79,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 # 90 tests (72 sans les données locales)
./gradlew testDebugUnitTest # 102 tests (84 sans les données locales)
./gradlew lint # lint vert obligatoire avant release
```
@ -85,7 +91,7 @@ Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INS
Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
`Siphonight/HormoneTrack` sur les deux (privé), avec
**releases taguées** (`v1.1.0` → `v1.3.5`) et **deux APK par release** (depuis v1.2.5)
**releases taguées** (`v1.1.0` → `v1.4.0`) et **deux APK par release** (depuis v1.2.5)
(téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) :
`-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) :
@ -101,8 +107,10 @@ taggué annoté. Voir [docs/DEVELOPPEMENT.md §16](docs/DEVELOPPEMENT.md).
## Les modèles en bref
Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la réponse normalisée
(pg/mL par mg) issue des tables horaires d'Estrannaise / Transfem Science (8001 h) ;
les contributions se superposent. Pics de référence :
(pg/mL par mg) ; les contributions se superposent. **Estrannaise** = tables horaires
du `.ods` (8001 h) ; **Transfem Science** = méta-analyse à 3 compartiments (V3C,
forme close, [article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)).
Pics de référence (pg/mL par mg) :
| Profil | Modèle | Pic (pg/mL/mg) | Tmax |
|----------|------------------|----------------|--------|
@ -112,6 +120,9 @@ les contributions se superposent. Pics de référence :
| EV | Transfem Science | 59,0 | ~51 h |
| EU | Transfem Science | 10,1 | ~198 h |
| EEn | Transfem Science | 32,0 | ~156 h |
| EB | Transfem Science | 194,2 | ~16 h |
| EC (huile) | Transfem Science | 31,1 | ~103 h |
| PEP | Transfem Science | 1,03 (dose ~6,5×) | ~18 j |
La calibration (facteur d'échelle par traitement, calibré par tes labs) ajuste le modèle
à ton corps, exactement comme la colonne « Scale factor » de la feuille d'origine.

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 = 19
versionName = "1.3.5"
versionCode = 20
versionName = "1.4.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@ -53,7 +53,7 @@ object PKPresets {
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EV,
pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 51f, eliminationHalfLifeHours = 100f, bioavailabilityFraction = 1f,
absorptionHours = 50f, eliminationHalfLifeHours = 72f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 4.0
),
PKPreset(
@ -62,7 +62,7 @@ object PKPresets {
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EU,
pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 198f, eliminationHalfLifeHours = 400f, bioavailabilityFraction = 1f,
absorptionHours = 204f, eliminationHalfLifeHours = 528f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 10.0
),
PKPreset(
@ -71,8 +71,47 @@ object PKPresets {
route = AdministrationRoute.INJECTION_SUBCUT,
esterType = Esters.EEN,
pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 156f, eliminationHalfLifeHours = 150f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 10.0
absorptionHours = 156f, eliminationHalfLifeHours = 110f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
),
// --- Nouveaux esters de la méta-analyse TFS (v1.4.0, modèle V3C) :
// EB / EC (huile) / EC suspension / PEP — PAS de tables Estrannaise
// pour ces clés : les presets sont créés en modèle TFS uniquement.
PKPreset(
nameRes = R.string.preset_eb_tfs,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EB,
pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 16f, eliminationHalfLifeHours = 29f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 1.0
),
PKPreset(
nameRes = R.string.preset_ec_tfs,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EC,
pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 103f, eliminationHalfLifeHours = 161f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
),
PKPreset(
nameRes = R.string.preset_ecs_tfs,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.ECS,
pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 30f, eliminationHalfLifeHours = 122f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
),
PKPreset(
nameRes = R.string.preset_pep_tfs,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.PEP,
pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 432f, eliminationHalfLifeHours = 682f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 32.5
),
// --- Transdermal / oral (Bateman fallback) ---
PKPreset(

View File

@ -8,6 +8,17 @@ object Esters {
const val EV = "EV"
const val EU = "EU"
const val EEN = "EEN"
// Esters supplémentaires couverts par la méta-analyse Transfem Science
// (v1.4.0, modèle V3C — cf pk/TransfemScienceModels.kt). Ces clés n'ont
// PAS de tables Estrannaise (ODS) : un traitement ESE + EB/EC/ECS/PEP
// produirait 0 pg/mL (les presets les créent en modèle TFS uniquement).
const val EB = "EB" // benzoate
const val EC = "EC" // cypionate, solution huileuse
const val ECS = "ECS" // cypionate, suspension aqueuse (microcristaux)
const val PEP = "PEP" // polyestradiol phosphate
/** Esters injectables proposables par ester (override par dose). */
val INJECTABLE: List<String> = listOf(EV, EU, EEN, EB, EC, ECS, PEP)
}
object PKModels {

View File

@ -4,6 +4,7 @@ import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import kotlin.math.abs
@ -84,7 +85,15 @@ object PharmacokineticEngine {
if (treatment.usesProfileModel) {
val ester = doseEster(treatment, dose)
if (ester != Esters.NONE) {
return PKProfileStore.sample(ester, modelOverride ?: treatment.pkModel, dtH) * mg
val model = modelOverride ?: treatment.pkModel
// v1.4.0 : le modèle Transfem Science utilise les paramètres
// V3C (3 compartiments) de la méta-analyse TFS, évalués en
// forme close — les tables ODS restent le fallback (esters
// sans modèle V3C, ou modèle Estrannaise).
if (model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(ester)) {
return TransfemScienceModels.sample(ester, dtH) * mg
}
return PKProfileStore.sample(ester, model, dtH) * mg
}
}
@ -102,7 +111,16 @@ object PharmacokineticEngine {
private fun cutoffHours(treatment: Treatment): Double {
val profileH = if (treatment.usesProfileModel) {
PKProfileStore.profileLength(treatment.esterType, treatment.pkModel).toDouble()
val model = treatment.pkModel
if (model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType)) {
// v1.4.0 : modèle V3C (forme close, pas de table bornée) →
// coupure à 10 demi-vies TERMINALES (~0,1 % de résidu), la
// t½ venant des paramètres de l'article (ex. EV 3 j → 30 j).
TransfemScienceModels.model(treatment.esterType)!!
.terminalHalfLifeDays * 24.0 * 10.0
} else {
PKProfileStore.profileLength(treatment.esterType, model).toDouble()
}
} else 0.0
val batemanH = if (treatment.usesProfileModel) 0.0
else 30.0 * treatment.eliminationHalfLifeHours.toDouble()
@ -561,22 +579,75 @@ object PharmacokineticEngine {
return current.copy(k = (median * 1000).toInt() / 1000.0)
}
fun nextReminderFireMs(treatments: List<Treatment>, nowMs: Long = System.currentTimeMillis()): Long? {
var next: Long? = null
/**
* Prochain déclenchement de rappel pour UN traitement (v1.4.0).
*
* ⚠️ FIX du bug « rappels quotidiens » (#52) : si le traitement a une
* **Posologie** (forecastIntervalDays) ET un historique de doses, le
* rappel suit la GRILLE des prises (dernière dose réelle + k × intervalle,
* créneaux passés sautés — même sémantique que [generateForecastDoses])
* et sonne le jour du créneau à l'heure de rappel choisie : une injection
* tous les samedis à 18 h → rappel uniquement le samedi à 18 h.
* Sinon (gel/oral, ou aucune dose pour cadrer la grille) → comportement
* QUOTIDIEN historique (prochaine occurrence HH:mm).
*/
fun nextReminderFireFor(treatment: Treatment, doseLogs: List<DoseLog>, nowMs: Long): Long? {
if (!treatment.reminderEnabled || !treatment.isActive) return null
val h = treatment.reminderHour ?: return null
val m = treatment.reminderMinute ?: return null
val intervalDays = treatment.forecastIntervalDays
val myDoses = doseLogs.filter { it.treatmentId == treatment.id && it.timestamp <= nowMs }
if (intervalDays != null && intervalDays > 0.0 && myDoses.isNotEmpty()) {
val intervalMs = (intervalDays * 24.0 * HOUR_MS).toLong()
if (intervalMs > 0L) {
var slot = myDoses.maxByOrNull { it.timestamp }!!.timestamp
// Le premier créneau STRICTEMENT futur (un créneau passé = oubli :
// on avance au rythme configuré, cf generateForecastDoses).
while (slot <= nowMs) slot += intervalMs
val cal = java.util.Calendar.getInstance()
var fireMs = 0L
var candidate = slot
// L'alarme sonne le jour du créneau, à l'heure de rappel choisie.
while (true) {
cal.timeInMillis = candidate
cal.set(java.util.Calendar.HOUR_OF_DAY, h)
cal.set(java.util.Calendar.MINUTE, m)
cal.set(java.util.Calendar.SECOND, 0)
cal.set(java.util.Calendar.MILLISECOND, 0)
fireMs = cal.timeInMillis
if (fireMs > nowMs) return fireMs
// L'heure de rappel du jour du créneau est déjà passée → créneau suivant
candidate += intervalMs
}
}
}
// Fallback QUOTIDIEN (comportement historique) : prochaine occurrence HH:mm
val cal = java.util.Calendar.getInstance()
cal.timeInMillis = nowMs
cal.set(java.util.Calendar.HOUR_OF_DAY, h)
cal.set(java.util.Calendar.MINUTE, m)
cal.set(java.util.Calendar.SECOND, 0)
cal.set(java.util.Calendar.MILLISECOND, 0)
if (cal.timeInMillis <= nowMs) cal.add(java.util.Calendar.DAY_OF_MONTH, 1)
return cal.timeInMillis
}
/**
* Prochain rappel parmi tous les traitements (min). [doseLogs] active la
* planification par Posologie (cf [nextReminderFireFor]) ; vide = fallback
* quotidien pur (compatibilité avec les appels historiques).
*/
fun nextReminderFireMs(
treatments: List<Treatment>,
doseLogs: List<DoseLog> = emptyList(),
nowMs: Long = System.currentTimeMillis()
): Long? {
var next: Long? = null
for (tr in treatments) {
// Un traitement inactif ne génère plus de rappels
if (!tr.reminderEnabled || !tr.isActive) continue
val h = tr.reminderHour ?: continue
val m = tr.reminderMinute ?: continue
cal.timeInMillis = nowMs
cal.set(java.util.Calendar.HOUR_OF_DAY, h)
cal.set(java.util.Calendar.MINUTE, m)
cal.set(java.util.Calendar.SECOND, 0)
cal.set(java.util.Calendar.MILLISECOND, 0)
if (cal.timeInMillis <= nowMs) cal.add(java.util.Calendar.DAY_OF_MONTH, 1)
val fire = cal.timeInMillis
if (next == null || fire < next) next = fire
val fire = nextReminderFireFor(tr, doseLogs, nowMs)
if (fire != null && (next == null || fire < next)) next = fire
}
return next
}

View File

@ -0,0 +1,103 @@
package com.hormonetrack.pk
/**
* Modèles pharmacocinétiques « Transfem Science » — méta-analyse des esters
* d'estradiol injectables (transfemscience.org/articles/injectable-e2-meta-analysis/).
*
* SOURCE DES PARAMÈTRES (v1.4.0) : les coefficients du modèle à 3 compartiments
* publiés par le simulateur officiel de Transfem Science
* (https://transfemscience.org/misc/injectable-e2-simulator/ — les valeurs sont
* des DONNÉES SCIENTIFIQUES issues des études agrégées, Tableaux 9–10 de
* l'article). Avant v1.4.0, le modèle TFS de l'app utilisait des tables
* horaires dérivées du tableur de l'utilisatrice (ODS) ; désormais la courbe
* TFS est calculée EXACTEMENT comme le simulateur :
*
* Cp(t) = D·k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3))
* + e^(−k3·t)/((k1−k3)(k2−k3))
* + e^(−k2·t)·(k3−k1)/((k1−k2)(k1−k3)(k2−k3)) ]
*
* avec t en JOURS, Cp en pg/mL pour la dose de calibration fitDose (5 mg).
* La courbe est NORMALISÉE PAR MG : `sample()` retourne Cp(t) / fitDose —
* le moteur PK multiplie déjà par la dose réelle (dose_mg), exactement comme
* avec les tables ODS. Sanity checks épinglés par `TransfemScienceModelsTest`
* (Cmax/Tmax/t½ de l'article reproduits à ~1 %) :
*
* EV : Cmax 295 pg/mL @ 2,1 j, t½ 3,0 j | EEn : 160 @ 6,5 j, t½ 4,6 j
* EB : 971 @ 0,65 j, t½ 1,2 j | EC (huile) : 155 @ 4,3 j, t½ 6,7 j
* EC susp. : 241 @ 1,2 j, t½ 5,1 j | PEP : 34 pg/mL @ 32,5 mg @ 18 j, t½ 28,4 j
*
* Remarques de l'article conservées dans la doc de dev (§7) : l'undécylate
* (EU) a des données d'étude limitées (ajustement moins fiable) ; PEP a une
* biodisponibilité réduite (~15–20 % — d'où des doses ~6,5× plus élevées).
*/
object TransfemScienceModels {
/** Modèle à 3 compartiments (V3C) d'un ester, tel que publié par TFS. */
data class V3C(
/** Clé ester de l'app ("EV", "EB", …). */
val ester: String,
/** Constante d'amplitude (pg/mL pour la dose de fit, unités TFS). */
val D: Double,
/** Constantes de vitesse en j⁻¹ (k1 = absorption dépôt, k2/k3 phases). */
val k1: Double,
val k2: Double,
val k3: Double,
/** Dose à laquelle les paramètres ont été ajustés (5 mg dans l'article). */
val fitDoseMg: Double = 5.0
) {
/** Demi-vie TERMINALE (jours) = la phase la plus lente des trois. */
val terminalHalfLifeDays: Double
get() = kotlin.math.ln(2.0) / minOf(k1, k2, k3)
}
private val MODELS: Map<String, V3C> = mapOf(
// estradiol valerate (solution huileuse)
"EV" to V3C("EV", D = 2596.05956, k1 = 2.38229125, k2 = 0.23345814, k3 = 1.37642769),
// estradiol enanthate (solution huileuse)
"EEN" to V3C("EEN", D = 333.874181, k1 = 0.42412968, k2 = 0.43452980, k3 = 0.15291485),
// estradiol undecylate — ⚠️ données d'étude limitées (cf article, §Limites)
"EU" to V3C("EU", D = 65.9493374, k1 = 0.29634323, k2 = 4799337.57, k3 = 0.03141554),
// estradiol benzoate (solution huileuse)
"EB" to V3C("EB", D = 1.7050e+08, k1 = 3.22397192, k2 = 0.58870148, k3 = 70721.4018),
// estradiol cypionate — SOLUTION HUILEUSE
"EC" to V3C("EC", D = 1920.89671, k1 = 0.10321089, k2 = 0.89854779, k3 = 0.89359759),
// estradiol cypionate — SUSPENSION AQUEUSE (microcristaux) ; clé dédiée
"ECS" to V3C("ECS", D = 1.5669e+08, k1 = 0.13586726, k2 = 2.51772731, k3 = 74768.1493),
// polyestradiol phosphate — biodispo réduite, doses ~6,5× (cf article)
"PEP" to V3C("PEP", D = 34.46836875, k1 = 0.02456035, k2 = 135643.711, k3 = 0.10582368)
)
fun hasModel(ester: String): Boolean = MODELS.containsKey(ester.uppercase())
/** Le modèle V3C de l'ester, ou null (lookup insensible à la casse). */
fun model(ester: String): V3C? = MODELS[ester.uppercase()]
/**
* Réponse normalisée (pg/mL PAR mg) à dtHours après une injection de 1 mg,
* modèle V3C de la méta-analyse TFS. dtHours ≤ 0 → 0 (rien avant la dose).
*/
fun sample(ester: String, dtHours: Double): Double {
if (dtHours <= 0.0) return 0.0
val m = model(ester) ?: return 0.0
val t = dtHours / 24.0 // la formule TFS travaille en jours
return rawConcentrationDays(m, t) / m.fitDoseMg
}
/**
* Cp(t) en pg/mL pour la dose de fit (formule V3C exacte du simulateur).
* Le compartiment k2 peut être « ultra-rapide » (EU, PEP : k2 ~10⁵–10⁶ j⁻¹) :
* son terme décroît instantanément et n'affecte que t≈0 — double précision
* suffisante (vérifié par les tests de pics de l'article).
*/
private fun rawConcentrationDays(m: V3C, tDays: Double): Double {
val D = m.D
val k1 = m.k1
val k2 = m.k2
val k3 = m.k3
return D * k1 * k2 * (
kotlin.math.exp(-k1 * tDays) / ((k1 - k2) * (k1 - k3)) +
kotlin.math.exp(-k3 * tDays) / ((k1 - k3) * (k2 - k3)) +
kotlin.math.exp(-k2 * tDays) * (k3 - k1) / ((k1 - k2) * (k1 - k3) * (k2 - k3))
)
}
}

View File

@ -31,6 +31,10 @@ class DoseActionReceiver : BroadcastReceiver() {
val treatment = db.treatmentDao().getById(treatmentId) ?: return@launch
val amount = if (dose > 0.0) dose else treatment.doseAmount
db.doseLogDao().insert(DoseLog(treatmentId = treatmentId, doseAmount = amount))
// ⚠️ v1.4.0 (fix #52) : la dose loguée AVANCE la grille —
// reprogrammer immédiatement le rappel au créneau suivant
// (Posologie si configurée, sinon demain à la même heure).
AlarmScheduler(context).scheduleFor(treatment, db.doseLogDao().getAllOnce())
} finally {
pending.finish()
}

View File

@ -12,7 +12,13 @@ import androidx.core.app.NotificationCompat
import com.hormonetrack.MainActivity
import com.hormonetrack.R
import com.hormonetrack.data.AppDatabase
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.pk.PharmacokineticEngine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.Calendar
object ReminderContract {
@ -59,28 +65,23 @@ class AlarmScheduler(private val context: Context) {
)
}
private fun nextOccurrence(hour: Int, minute: Int): Long {
val cal = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
if (cal.timeInMillis <= System.currentTimeMillis()) {
cal.add(Calendar.DAY_OF_MONTH, 1)
}
return cal.timeInMillis
}
fun canScheduleExact(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
fun scheduleDaily(treatment: Treatment): Boolean {
val hour = treatment.reminderHour ?: return false
val minute = treatment.reminderMinute ?: return false
val triggerAt = nextOccurrence(hour, minute)
/**
* Programme la PROCHAINE alarme du traitement (v1.4.0, fix #52) :
* la date vient du MOTEUR (`nextReminderFireFor`) — unique source de
* vérité — donc suit la Posologie quand elle existe (rappel le jour du
* créneau uniquement), sinon quotidien. One-shot : l'alarme suivante est
* reprogrammée par ReminderReceiver (après notif) et DoseActionReceiver
* (après « Pris »).
*/
fun scheduleFor(treatment: Treatment, doseLogs: List<DoseLog>): Boolean {
val fireAt = PharmacokineticEngine.nextReminderFireFor(
treatment, doseLogs, System.currentTimeMillis()
) ?: return false
val pi = pendingBroadcast(treatment.id, treatment.name, treatment.doseAmount)
return scheduleAt(triggerAt, pi)
return scheduleAt(fireAt, pi)
}
fun scheduleSnooze(treatmentId: Long, name: String, dose: Double, delayMs: Long = 60 * 60_000L): Boolean {
@ -112,11 +113,15 @@ class AlarmScheduler(private val context: Context) {
alarmManager.cancel(pi)
}
fun rescheduleAll(treatments: List<Treatment>) {
/**
* Reprogramme/annule tous les rappels. [doseLogs] alimente la grille
* Posologie (fix #52) ; vide = fallback quotidien (compat. appelants).
*/
fun rescheduleAll(treatments: List<Treatment>, doseLogs: List<DoseLog> = emptyList()) {
treatments.forEach { tr ->
// Un traitement inactif ne rappelle plus : son alarme est annulée
if (tr.reminderEnabled && tr.isActive) {
scheduleDaily(tr)
scheduleFor(tr, doseLogs)
} else {
cancel(tr.id)
}
@ -205,6 +210,22 @@ class ReminderReceiver : BroadcastReceiver() {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(treatmentId.toInt(), notification)
// ⚠️ Les alarmes sont one-shot (setExactAndAllowWhileIdle) : reprogrammer
// ICI le déclenchement SUIVANT (v1.4.0, fix #52) — sinon le rappel suivant
// n'existait qu'après un reboot/une édition. Le calcul passe par le moteur
// (grille Posologie si elle existe, sinon quotidien).
val pending = goAsync()
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.IO).launch {
try {
val db = AppDatabase.getInstance(context)
db.treatmentDao().getById(treatmentId)?.let { tr ->
AlarmScheduler(context).scheduleFor(tr, db.doseLogDao().getAllOnce())
}
} finally {
pending.finish()
}
}
}
}
@ -214,12 +235,17 @@ class BootReceiver : BroadcastReceiver() {
val result = goAsync()
Thread {
try {
val db = AppDatabase.getInstance(context)
// TOUS les traitements : les inactifs doivent voir leur alarme
// annulée au boot (sinon une alarme périmée continue de sonner) ;
// les doses alimentent la grille Posologie (fix #52)
val treatments = kotlinx.coroutines.runBlocking {
// TOUS les traitements : les inactifs doivent voir leur alarme
// annulée au boot (sinon une alarme périmée continue de sonner)
AppDatabase.getInstance(context).treatmentDao().getAllOnce()
db.treatmentDao().getAllOnce()
}
AlarmScheduler(context).rescheduleAll(treatments)
val doses = kotlinx.coroutines.runBlocking {
db.doseLogDao().getAllOnce()
}
AlarmScheduler(context).rescheduleAll(treatments, doses)
} finally {
result.finish()
}

View File

@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment
/**
@ -139,7 +140,17 @@ fun DoseDialog(
text = { Text(stringResource(R.string.ester_default, selected.esterType)) },
onClick = { esterOverride = null; esterExpanded = false }
)
listOf(Esters.EV, Esters.EU, Esters.EEN).forEach { e ->
// v1.4.0 : la liste d'esters suit le MODÈLE du
// traitement — TFS = tous les esters de la
// méta-analyse (V3C, cf TransfemScienceModels),
// Estrannaise (ODS) = EV/EU/EEn seulement (les
// tables ne couvrent pas les autres esters).
val choices = if (selected.pkModel == PKModels.TRANSFEM_SCIENCE) {
Esters.INJECTABLE
} else {
listOf(Esters.EV, Esters.EU, Esters.EEN)
}
choices.forEach { e ->
DropdownMenuItem(
text = { Text(esterLabel(e)) },
onClick = { esterOverride = e; esterExpanded = false }
@ -175,6 +186,10 @@ private fun esterLabel(ester: String): String = when (ester) {
Esters.EV -> "EV — valerate"
Esters.EU -> "EU — undecylate"
Esters.EEN -> "EEn — enanthate"
Esters.EB -> "EB — benzoate"
Esters.EC -> "EC — cypionate (oil)"
Esters.ECS -> "EC — cypionate (suspension)"
Esters.PEP -> "PEP — polyestradiol phosphate"
else -> ester
}

View File

@ -43,6 +43,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.pk.LevelPoint
@ -172,7 +173,7 @@ fun HomeScreen(
NowLevelCard(curve)
NextDoseCard(activeTreatments)
NextDoseCard(activeTreatments, doseLogs)
Card(
modifier = Modifier.fillMaxWidth(),
@ -342,18 +343,15 @@ private fun NowLevelCard(curve: List<LevelPoint>) {
}
@Composable
private fun NextDoseCard(treatments: List<Treatment>) {
val next = PharmacokineticEngine.nextReminderFireMs(treatments)
if (next == null) return
val nextTreatment = treatments.filter { it.reminderEnabled }
.minByOrNull { tr ->
val h = tr.reminderHour ?: 23
val m = tr.reminderMinute ?: 59
val candidate = (h * 60L + m) * 60_000L
val nowMs = System.currentTimeMillis()
val nowOfDay = nowMs % (24 * HOUR_MS)
if (candidate >= nowOfDay) candidate else candidate + 24 * HOUR_MS
}
private fun NextDoseCard(treatments: List<Treatment>, doseLogs: List<DoseLog>) {
// v1.4.0 (fix #52) : les dates de rappel passent par le moteur — la carte
// suit la grille Posologie (jour du créneau uniquement) comme les alarmes.
val nowMs = System.currentTimeMillis()
val fires = treatments
.mapNotNull { tr -> PharmacokineticEngine.nextReminderFireFor(tr, doseLogs, nowMs)?.let { tr to it } }
if (fires.isEmpty()) return
val next = fires.minByOrNull { it.second }!!.second
val nextTreatment = fires.minByOrNull { it.second }!!.first
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(

View File

@ -443,7 +443,10 @@ fun SettingsScreen(onBack: () -> Unit) {
}
result?.let {
container.settings.setTConfig(it.tConfig)
AlarmScheduler(context).rescheduleAll(repo.allTreatmentsOnce())
// Doses + traitements : grille Posologie pour les rappels (#52)
AlarmScheduler(context).rescheduleAll(
repo.allTreatmentsOnce(), repo.allDoseLogsOnce()
)
}
withContext(Dispatchers.Main) {
message = if (result != null) {

View File

@ -517,9 +517,11 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
}
val saved = tr.copy(id = savedId)
// Rappel actif seulement si le traitement l'est aussi ;
// désactiver un traitement annule son alarme
// désactiver un traitement annule son alarme. Les doses
// alimentent la grille Posologie (fix #52) : le rappel
// sonne uniquement les jours de créneau.
if (saved.reminderEnabled && saved.isActive) {
scheduler.scheduleDaily(saved)
scheduler.scheduleFor(saved, repo.allDoseLogsOnce())
} else {
scheduler.cancel(savedId)
}

View File

@ -183,6 +183,10 @@
<string name="preset_ev_tfs">Injection EV — Transfem Science</string>
<string name="preset_eu_tfs">Injection EU — Transfem Science</string>
<string name="preset_een_tfs">Injection EEn — Transfem Science</string>
<string name="preset_eb_tfs">Injection EB — Transfem Science</string>
<string name="preset_ec_tfs">Injection EC (huile) — Transfem Science</string>
<string name="preset_ecs_tfs">Injection EC (suspension aqueuse) — Transfem Science</string>
<string name="preset_pep_tfs">Injection PEP — Transfem Science</string>
<string name="preset_e2_gel">E2 gel transdermique</string>
<string name="preset_e2_patch">E2 patch</string>
<string name="preset_e2_oral">E2 orale</string>

View File

@ -183,6 +183,10 @@
<string name="preset_ev_tfs">EV injection — Transfem Science</string>
<string name="preset_eu_tfs">EU injection — Transfem Science</string>
<string name="preset_een_tfs">EEn injection — Transfem Science</string>
<string name="preset_eb_tfs">EB injection — Transfem Science</string>
<string name="preset_ec_tfs">EC injection (oil) — Transfem Science</string>
<string name="preset_ecs_tfs">EC injection (aqueous suspension) — Transfem Science</string>
<string name="preset_pep_tfs">PEP injection — Transfem Science</string>
<string name="preset_e2_gel">E2 transdermal gel</string>
<string name="preset_e2_patch">E2 patch</string>
<string name="preset_e2_oral">E2 oral</string>

View File

@ -220,7 +220,9 @@ class PharmacokineticEngineTest {
fun `next reminder fire time is in the future`() {
val tr = evTreatment().copy(reminderEnabled = true, reminderHour = 8, reminderMinute = 30)
val now = BASE
val next = PharmacokineticEngine.nextReminderFireMs(listOf(tr), now)
// v1.4.0 : signature (treatments, doseLogs, nowMs) — doseLogs vide =
// fallback quotidien (pas de Posologie sur ce traitement)
val next = PharmacokineticEngine.nextReminderFireMs(listOf(tr), emptyList(), now)
assertTrue(next != null && next > now && next - now <= 24 * HOUR)
}

View File

@ -0,0 +1,142 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Calendar
/**
* Épingle la planification des rappels AVEC Posologie (v1.4.0, fix #52) :
* un traitement injecté tous les 7 jours avec un rappel à 18 h doit sonner
* UNIQUEMENT les jours de créneau (le samedi 18 h), pas tous les jours.
*
* Précédemment : `nextReminderFireMs` ignorait les doses et l'intervalle →
* l'alarme se ré-armait chaque jour à HH:mm, même hors jour d'injection.
*/
class ReminderScheduleTest {
private companion object {
const val DAY_MS = 86_400_000L
const val HOUR_MS = 3_600_000L
/** 18 h locales du jour de [ms] (comme le ferait l'alarme). */
fun at(ms: Long, hour: Int, minute: Int): Long =
Calendar.getInstance().apply {
timeInMillis = ms
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
private fun injectionTreatment(intervalDays: Double?, hour: Int, minute: Int): Treatment =
Treatment(
id = 1L,
name = "EEn",
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_SUBCUT,
doseAmount = 5.0,
doseUnit = "mg",
isActive = true,
esterType = Esters.EEN,
pkModel = PKModels.TRANSFEM_SCIENCE,
forecastIntervalDays = intervalDays,
reminderHour = hour,
reminderMinute = minute,
reminderEnabled = true
)
@Test
fun `reminder follows the dosing grid (every 7 days at 18h, not daily)`() {
// now = un instant fixe ; dernière dose il y a 2 jours (le même jour
// de semaine que le créneau suivant, cf grille +7 j) ; rappel 18 h.
val now = System.currentTimeMillis()
val lastDose = now - 2 * DAY_MS
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = lastDose, doseAmount = 5.0))
val tr = injectionTreatment(intervalDays = 7.0, hour = 18, minute = 0)
val fire = PharmacokineticEngine.nextReminderFireFor(tr, doses, now)
val expected = at(lastDose + 7 * DAY_MS, 18, 0)
assertEquals(expected, fire)
// invariants : dans le futur, à ~5 jours (7 j − 2 j), PAS demain matin
assertTrue("fire doit être futur", fire!! > now)
assertTrue("fire doit être dans ~5 jours, pas demain", fire - now > 3 * DAY_MS)
}
@Test
fun `reminder time already passed on slot day moves to next slot`() {
// Dose loguée à 20 h aujourd'hui, rappel prévu 18 h : l'occurrence
// du jour est déjà passée → le prochain rappel est le créneau suivant.
val now = at(System.currentTimeMillis(), 20, 0)
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = now, doseAmount = 5.0))
val tr = injectionTreatment(intervalDays = 7.0, hour = 18, minute = 0)
val fire = PharmacokineticEngine.nextReminderFireFor(tr, doses, now)
val expected = at(now + 7 * DAY_MS, 18, 0)
assertEquals(expected, fire)
}
@Test
fun `missed slot is skipped without simulating the past`() {
// Dernière dose il y a 10 jours pour un intervalle de 7 j : le créneau
// théorique (il y a 3 jours) est PASSÉ → on avance au suivant (+7 j).
val now = System.currentTimeMillis()
val lastDose = now - 10 * DAY_MS
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = lastDose, doseAmount = 5.0))
val tr = injectionTreatment(intervalDays = 7.0, hour = 9, minute = 30)
val fire = PharmacokineticEngine.nextReminderFireFor(tr, doses, now)
val expected = at(lastDose + 14 * DAY_MS, 9, 30)
assertEquals(expected, fire)
assertTrue(fire!! > now)
}
@Test
fun `no posology keeps the legacy daily behaviour`() {
val now = at(System.currentTimeMillis(), 10, 0) // 10 h < 18 h
val tr = injectionTreatment(intervalDays = null, hour = 18, minute = 0)
// Sans historique de doses non plus (gel/oral ou app neuve)
val fire = PharmacokineticEngine.nextReminderFireFor(tr, emptyList(), now)
assertEquals(at(now, 18, 0), fire)
}
@Test
fun `posology without any logged dose falls back to daily`() {
// Aucune dose pour cadrer la grille : on retombe sur le quotidien
// (l'app neuve ne peut pas deviner le jour d'injection).
val now = at(System.currentTimeMillis(), 10, 0)
val tr = injectionTreatment(intervalDays = 7.0, hour = 18, minute = 0)
val fire = PharmacokineticEngine.nextReminderFireFor(tr, emptyList(), now)
assertEquals(at(now, 18, 0), fire)
}
@Test
fun `aggregate helper picks the earliest fire across treatments`() {
val now = System.currentTimeMillis()
val trWeekly = injectionTreatment(intervalDays = 7.0, hour = 18, minute = 0)
val trDaily = injectionTreatment(intervalDays = null, hour = 8, minute = 0)
.copy(id = 2L)
val doses = listOf(
DoseLog(treatmentId = 1L, timestamp = now - 2 * DAY_MS, doseAmount = 5.0)
)
val next = PharmacokineticEngine.nextReminderFireMs(listOf(trWeekly, trDaily), doses, now)
assertEquals(PharmacokineticEngine.nextReminderFireFor(trDaily, doses, now), next)
}
@Test
fun `inactive or disabled treatments never schedule`() {
val now = System.currentTimeMillis()
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = now - DAY_MS, doseAmount = 5.0))
assertEquals(null, PharmacokineticEngine.nextReminderFireFor(
injectionTreatment(7.0, 18, 0).copy(isActive = false), doses, now))
assertEquals(null, PharmacokineticEngine.nextReminderFireFor(
injectionTreatment(7.0, 18, 0).copy(reminderEnabled = false), doses, now))
}
}

View File

@ -0,0 +1,160 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.Esters
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
/**
* Épingle le modèle **Transfem Science V3C** (v1.4.0) sur les valeurs de la
* méta-analyse https://transfemscience.org/articles/injectable-e2-meta-analysis/
* (Tableaux 9–10 : dose unique de 5 mg IM ; Figure 11 : état d'équilibre EV).
*
* Les paramètres (D/k1/k2/k3) viennent du simulateur officiel TFS
* (cf pk/TransfemScienceModels.kt pour la provenance exacte) : ce test
* garantit que l'app reproduit le simulateur à ~1 % près.
*/
class TransfemScienceModelsTest {
companion object {
private const val DAY_MS = 86_400_000L
/** Pas d'intégration numérique pour l'AUC (jours). */
private const val DT_DAYS = 0.01
}
@Before
fun warmup() {
// V3C est purement analytique : rien à charger (les tables ODS ne sont
// pas impliquées). Ce hook garde la même structure que les autres tests.
}
/** Courbe pour une dose de 5 mg = sample × 5 (pg/mL). */
private fun c5mg(ester: String, days: Double): Double =
TransfemScienceModels.sample(ester, days * 24.0) * 5.0
/** Max/t de la courbe 5 mg sur 0..60 j (pas 0,01 j). */
private fun peak(ester: String): Pair<Double, Double> {
var best = 0.0 to 0.0
var t = 0.0
while (t <= 60.0) {
val v = c5mg(ester, t)
if (v > best.first) best = v to t
t += DT_DAYS
}
return best
}
@Test
fun `single-dose peaks match the article tables (5 mg IM)`() {
// (Cmax pg/mL, Tmax jours) — Tableaux 9/10 de la méta-analyse
val expected = mapOf(
"EV" to (295.0 to 2.1),
"EEN" to (160.0 to 6.5),
"EB" to (971.0 to 0.65),
"EC" to (155.0 to 4.3),
"ECS" to (241.0 to 1.2),
"EU" to null, // pas de valeurs tabulées fiables pour l'undécylate
// PEP : l'article donne Cmax=34 pg/mL à la dose ÉQUIVALENTE 32,5 mg
// (biodispo réduite) → à la dose de fit 5 mg : 34/6,5 ≈ 5,23
"PEP" to (34.0 / 6.5 to 18.0)
)
for ((ester, exp) in expected) {
val (cmax, tmax) = peak(ester)
if (exp == null) continue
val (cExp, tExp) = exp
assertTrue("$ester Cmax=$cmax attendu $cExp", cmax in cExp * 0.98..cExp * 1.02)
assertTrue("$ester Tmax=$tmax attendu $tExp", tmax in tExp * 0.9..tExp * 1.1)
}
}
@Test
fun `terminal half-lives match the article tables`() {
// t½ terminale (la phase la plus lente) — Tableaux 9/10
val expected = mapOf(
"EV" to 3.0, "EEN" to 4.6, "EB" to 1.2,
"EC" to 6.7, "ECS" to 5.1, "PEP" to 28.4
)
for ((ester, tHalfDays) in expected) {
val m = TransfemScienceModels.model(ester)!!
assertTrue(
"$ester t½=${m.terminalHalfLifeDays} attendu $tHalfDays",
m.terminalHalfLifeDays in tHalfDays * 0.95..tHalfDays * 1.05
)
}
}
/** AUC numérique (pg·jour/mL par mg) = exposition totale normalisée. */
private fun aucPerMg(ester: String, cutDays: Double = 400.0): Double {
var auc = 0.0
var t = 0.0
while (t < cutDays) {
auc += TransfemScienceModels.sample(ester, t * 24.0) * DT_DAYS
t += DT_DAYS
}
// Queue exponentielle au-delà de cutDays : valeur / λ terminale
val tail = TransfemScienceModels.sample(ester, cutDays * 24.0) *
TransfemScienceModels.model(ester)!!.terminalHalfLifeDays / kotlin.math.ln(2.0)
return auc + tail
}
@Test
fun `AUC matches the article (bioavailability ~100 percent, PEP reduced)`() {
// Article (5 mg) : EV 1886, EEn 2183, EB 2410, EC 2150, ECS 2096, PEP 2117 @32,5 mg
val expectedPerMg = mapOf(
"EV" to 1886.0 / 5.0,
"EEN" to 2183.0 / 5.0,
"EC" to 2150.0 / 5.0,
"PEP" to 2117.0 / 32.5 // l'article donne l'AUC à la dose équivalente 32,5 mg
)
for ((ester, exp) in expectedPerMg) {
val auc = aucPerMg(ester)
assertTrue("$ester AUC/mg=$auc attendu $exp", auc in exp * 0.97..exp * 1.03)
}
}
@Test
fun `EV steady state at 5 mg per week matches figure 11 (Cmax 384, Cmin 142, Cavg 269)`() {
// Superposition de 40 doses hebdomadaires de 5 mg ; on analyse les
// 2 derniers cycles (équilibre atteint, t½ EV = 3 j).
val interval = 7.0
val doses = 40
fun steady(days: Double): Double {
var total = 0.0
for (k in 0 until doses) {
val dt = days - k * interval
if (dt > 0) total += c5mg("EV", dt)
}
return total
}
// Fenêtre = dernier intervalle complet [ (doses-1)·7 , doses·7 )
val start = (doses - 1) * interval
var cmax = 0.0
var cmin = Double.MAX_VALUE
var sum = 0.0
var n = 0
var t = start
while (t < start + interval) {
val v = steady(t)
cmax = maxOf(cmax, v)
cmin = minOf(cmin, v)
sum += v
n++
t += DT_DAYS
}
val cavg = sum / n
assertTrue("Cmax=$cmax", cmax in 384.0 * 0.95..384.0 * 1.05)
assertTrue("Cmin=$cmin", cmin in 142.0 * 0.9..142.0 * 1.1)
assertTrue("Cavg=$cavg", cavg in 269.0 * 0.95..269.0 * 1.05)
}
@Test
fun `unknown esters have no V3C model and sample to zero`() {
assertTrue(!TransfemScienceModels.hasModel("NONE"))
assertEquals(0.0, TransfemScienceModels.sample("NONE", 10.0), 1e-12)
// avant la dose : 0
assertEquals(0.0, TransfemScienceModels.sample(Esters.EV, -1.0), 1e-12)
assertEquals(0.0, TransfemScienceModels.sample(Esters.EV, 0.0), 1e-12)
}
}

View File

@ -1,7 +1,48 @@
# Changelog — HormoneTrack
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.3.5`, …).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.0`, …).
## [1.4.0] — 2026-09-06 (versionCode 20)
### Ajouté
- **Modèle Transfem Science reconstruit sur la méta-analyse officielle**
(https://transfemscience.org/articles/injectable-e2-meta-analysis/) :
la courbe TFS n'est plus dérivée des tables horaires du tableur — elle
est désormais la **forme close exacte du modèle à 3 compartiments (V3C)**
publié par le simulateur TFS, avec les paramètres D/k1/k2/k3 des 7 esters
(EV, EEn, EU, EB, EC huile, EC suspension, PEP). PICS DE RÉFÉRENCE
REPRODUITS À ~1 % (épinglés par `TransfemScienceModelsTest`) : EV 295
pg/mL @ 2,1 j ; EEn 160 @ 6,5 j ; EB 971 @ 0,65 j ; EC 155 @ 4,3 j ;
EC susp. 241 @ 1,2 j ; PEP 34 @ 32,5 mg @ 18 j ; AUC et état d'équilibre
EV 5 mg/7 j (Cmax 384 / Cmin 142 / Cavg 269) conformes à la Figure 11.
Avantages : plus de plancher d'arrondi 0,01 en queue de table, pas de
coupure à 8001 h (extrapolation exacte), coupure moteur à 10 demi-vies
terminales.
- **Nouveaux esters** : benzoate (EB), cypionate huile (EC), cypionate
suspension aqueuse (ECS) et polyestradiol phosphate (PEP) — 4 presets
TFS, override d'ester par dose étendu (la liste suit le modèle du
traitement : TFS = les 7 esters, Estrannaise = EV/EU/EEn faute de tables
ODS). PEP = biodisponibilité réduite, doses ~6,5× (cf article).
- **Rappels qui suivent la Posologie** (fix #52, Corrigé ci-dessous) :
la carte « Prochaine dose » de l'accueil passe par le même moteur que
les alarmes.
- **12 nouveaux tests** (102 au total / 84 sans les données locales) :
`TransfemScienceModelsTest` (pics, t½, AUC, équilibre de la Figure 11)
et `ReminderScheduleTest` (grille Posologie, créneau manqué, fallback
quotidien).
### Corrigé
- **Les rappels se déclenchaient TOUS LES JOURS** sans tenir compte de
l'intervalle entre prises (#52, remontée utilisateur : « je m'injecte
tous les samedis à 18 h, je dois recevoir le rappel uniquement le
samedi à 18 h ») : le calcul du prochain déclenchement ignorait doses et
Posologie. Désormais, un traitement avec Posologie + historique sonne
le jour du créneau à l'heure choisie (dernière dose + k × intervalle,
créneaux passés sautés comme la prévision) ; sans Posologie ou sans
historique → comportement quotidien inchangé. De plus, l'alarme suivante
est reprogrammée **après chaque notification** et **après « Pris »**
(les alarmes sont one-shot : la chaîne n'existait qu'au boot/au save).
## [1.3.5] — 2026-09-06 (versionCode 19)

View File

@ -81,7 +81,8 @@ Fonctionnalités v1 :
| 6 sept. 2026 (session farewell) | Repo créé côté farewell → **push SSH (alias `farewell` : giteassh:2222) + 12 releases publiées avec APK vérifiés par téléchargement** ; piège lexicographique `v1.2.10 < v1.2.5` en comparaison de chaînes épinglé (§14 #38) ; les deux instances Gitea sont synchrones. |
| 6 sept. 2026 (session v1.3.3) | **Audit de reprise de maintenance** (nouvelle session IA) : 3 bugs racines trouvés — permissions agenda ABSENTES du manifest (jamais déclarées, §14 #44), export logs plantait TOUJOURS (le fix v1.3.2 réimplémentait l'IO au lieu de réutiliser BackupManager, §14 #45), bump de version JAMAIS commité (tags v1.3.0–1.3.2 tous `versionCode 14 / "1.3.0"`, §14 #46). Corrigés + doc rafraîchie (DB v3, targetSdk 36, régression 3 = export v1.3.1, §19/§20 staleness) ; **téléphone de test documenté : Google Pixel 9 /e/OS** (AOSP ; SAF DocumentsUI standard). |
| 6 sept. 2026 (session v1.3.4) | **Deux crashs v1.3.3 remontés (Doses + export logs)** → reproduits sur ÉMULATEUR avec les vraies données (recette §16.ter, désormais standard) : (a) Doses = string `hrt_duration` 3 placeholders vs 2 args (**né en v1.3.1**, §14 #47) ; (b) export logs = `LocalDate.format("…-HHmm")` levant `UnsupportedTemporalTypeException` au TAP (§14 #48) — les fixes IO v1.3.2/v1.3.3 étaient à côté du vrai problème. Lint mis en filet bloquant (aurait attrapé les deux) + #49 notif ; helper `ExportFileNames` + 3 tests (90 verts) ; fixes VÉRIFIÉS sur émulateur avec l'APK release + données réelles (flux SAF complet : SAVE → fichier + message). Publication v1.3.4. |
| 6 sept. 2026 (session v1.3.5) | **Premier diagnostic à distance RÉUSSI via les logs exportés** : les AppLog de la v1.3.4 fournis par l'utilisatrice prouvent export logs `ok=true` (fix #48 ✓ au téléphone), permission agenda accordée (fix manifest ✓) et révèlent le bug racine EXPLICITE : `ensureCalendar` plantait car l'URI sync-adapter n'embarquait pas ACCOUNT_NAME/ACCOUNT_TYPE (§14 #50). En validant sur émulateur, DEUX bugs découverts : delete d'event sans account (#50 bis — « supprimé » logué mais l'event restait) et `buildTreatment` sans `calendarEventId` (#51 — événements orphelins). Cycle ON→save→OFF→save validé end-to-end (`content query`). Publication v1.3.5. |
| 6 sept. 2026 (session v1.3.5) | **Premier diagnostic à distance RÉUSSI via les logs exportés** : les AppLog de la v1.3.4 prouvent export logs `ok=true` (fix #48 ✓ au téléphone), permission agenda accordée (fix manifest ✓) et révèlent le bug racine EXPLICITE : `ensureCalendar` plantait car l'URI sync-adapter n'embarquait pas ACCOUNT_NAME/ACCOUNT_TYPE (§14 #50). En validant sur émulateur, DEUX bugs découverts : delete d'event sans account (#50 bis — « supprimé » logué mais l'event restait) et `buildTreatment` sans `calendarEventId` (#51 — événements orphelins). Cycle ON→save→OFF→save validé end-to-end (`content query`). Publication v1.3.5. |
| 6 sept. 2026 (session v1.4.0) | **Modèle TFS reconstruit sur la méta-analyse officielle** (params V3C du simulateur transfemscience.github.io/injectable-e2-simulator, extraits de `ester-data.js`/`calc-curve.js`) : forme close 3 compartiments, 7 esters (EB/EC/ECS/PEP ajoutés), pics de l'article reproduits à ~1 % (AUC + Figure 11 épinglés). **+ Fix #52** (remontée) : rappels quotidiens même hors jour d'injection → grille Posologie (moteur = source unique), re-programmation après notif et « Pris ». 12 nouveaux tests (102 verts), lint vert. Publication v1.4.0. |
| 6 sept. 2026 (session v1.3.0) | Dialog « Nouveautés » après mise à jour (CHANGELOG embarqué en asset, tâche `copyChangelog`, version vue en DataStore) ; **événements d'agenda récurrents** (calendrier local HormoneTrack, RRULE posologie, permission runtime, Room v3 `calendarEventId`) ; Paramètres : version + lien releases ; **78 tests verts**, APK v1.3.0 + releases. |
| 5 sept. 2026 (session v1.2.10) | Sens des boutons de zoom inversé (+ = zoom avant, convention carte — retour utilisateur) ; 67 tests verts, APK v1.2.10 + releases. |
| 5 sept. 2026 (session v1.2.9) | **Zoom du graphique** (pinch + boutons, 6 h → 300 j, focal stable, échantillonnage adaptatif `stepForRange`, labels X 1 h/3 h) ; **README : disclaimer IA-assisté** en en-tête ; 3 tests ; **67 tests verts**, APK v1.2.9 + releases. |
@ -366,15 +367,50 @@ garde k ∈ (1e-4, 10), **médiane** ; labs normalisés via `convertTToNgMl`.
### 7.7 API du moteur
`levelAt / currentLevel / computeCurve(start, end, step=1h, tConfig) / e2At /
testosteroneAt / computeScaleFactor / computeTConfigCalibration / nextReminderFireMs /
testosteroneAt / computeScaleFactor / computeTConfigCalibration /
nextReminderFireMs(treatments, doseLogs, now) / nextReminderFireFor /
batemanParams / concentrationOfDose / computeKa / doseEster / isInjectionRoute`.
Type de retour : `LevelPoint(timestamp, e2, t)`.
### 7.8 Modèle TFS V3C (v1.4.0) — `pk/TransfemScienceModels.kt`
Le modèle « Transfem Science » n'est PLUS dérivé des tables ODS : c'est la
**forme close exacte** du modèle à 3 compartiments de la méta-analyse
(https://transfemscience.org/articles/injectable-e2-meta-analysis/), avec les
paramètres D/k1/k2/k3 publiés par le simulateur officiel
(transfemscience.github.io/injectable-e2-simulator — fichiers `ester-data.js`
/ `calc-curve.js`, valeurs = données scientifiques des études) :
```
Cp(t) = D·k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3)) + e^(−k3·t)/((k1−k3)(k2−k3))
+ e^(−k2·t)·(k3−k1)/((k1−k2)(k1−k3)(k2−k3)) ] (t en JOURS)
```
- `sample(ester, dtHours)` = `Cp(t_jours) / fitDose(5 mg)` → pg/mL PAR mg ;
le moteur multiplie par la dose réelle, comme pour les tables ODS.
- Branchement dans `concentrationOfDose` : si `model == TFS` et que l'ester a
un V3C (EV, EEn, EU, EB, EC, ECS, PEP) → `TransfemScienceModels.sample` ;
sinon fallback tables ODS (Estrannaise, ou ester sans modèle).
- **Coupure moteur** (`cutoffHours`) : 10 demi-vies TERMINALES (t½ = ln2 /
min(k1,k2,k3)) — ex. EV 30 j, PEP 284 j — au lieu de la longueur de table.
- Compartiment k2 « ultra-rapide » (EU/PEP : k2 ~10⁵–10⁶ j⁻¹) : son terme
décroît instantanément, double précision suffisante (testé).
- Sanity checks épinglés (±1–3 %) : pics/t½ des Tableaux 9–10, AUC par mg
(EV 377,2 / EEn 436,6 / EC 430 / PEP 65,1 pg·j/mL/mg), état d'équilibre
EV 5 mg/7 j (Cmax 384 / Cmin 142 / Cavg 269, Figure 11 de l'article).
- ⚠️ EU (undécylate) : ajustement sur données d'étude LIMITÉES (cf article
§Limites) — précision moindre, conservé car c'est le meilleur disponible.
- Les valeurs des pics par mg restent cohérentes avec l'ancienne table ODS
(la table en DÉRIVAIT) : EV 59 pg/mL/mg @ 51 h, EEn 31,97 @ 156 h — le
changement est la FORME (queues exactes, pas d'arrondi 2 décimales).
## 8. Tests unitaires
**87 tests JVM, tous verts** → **90 depuis v1.3.4** (`./gradlew testDebugUnitTest`) —
**72 sans les données de test locales** (cf §8.bis : les 3 classes de régression,
6 tests chacune, sont skippées via `Assume`). Dépendance : JUnit 4.13.2.
**102 tests JVM, tous verts** → **v1.4.0** (`./gradlew testDebugUnitTest`) —
**84 sans les données de test locales** (cf §8.bis : les 3 classes de
régression, 6 tests chacune, sont skippées via `Assume`). Dépendance :
JUnit 4.13.2.
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
`app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`).
@ -478,6 +514,17 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
vide, série trop courte → vide, **filtre d'amplitude** (sémantique zigzag : une
oscillation sous le seuil produit UN pivot, l'alternance complète apparaît quand le
seuil baisse), anti-corrélation E2/T (un pic d'E2 ≈ un creux de T).
- **`TransfemScienceModelsTest`** (5) : v1.4.0 — épingle le modèle TFS V3C sur
l'article : pics/t½ des Tableaux 9–10 (±1–2 %), AUC par mg (±3 %), état
d'équilibre EV 5 mg/7 j = Figure 11 (Cmax 384/Cmin 142/Cavg 269), esters
sans modèle → 0, dt ≤ 0 → 0. **Toute retouche de TransfemScienceModels.kt
passe par CE test** (c'est la fidélité au simulateur TFS qui est vérifiée).
- **`ReminderScheduleTest`** (7) : v1.4.0 — fix #52 : rappel sur la GRILLE
Posologie (dose il y a 2 j + intervalle 7 j → alarme dans 5 j à l'heure
choisie, PAS demain), heure déjà passée le jour du créneau → créneau
suivant, créneau manqué sauté (jamais dans le passé), fallback quotidien
sans Posologie ou sans doses, agrégat multi-traitements (le plus tôt
gagne), traitements inactifs/désactivés → null.
**Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis
la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu.
@ -486,23 +533,33 @@ la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du mod
## 9. Système de rappels
`reminder/ReminderManager.kt` (+ `DoseActionReceiver.kt`).
`reminder/ReminderManager.kt` (+ `DoseActionReceiver.kt`, `ReminderReceiver`).
- `ReminderContract` : constantes + **fabrique unique `reminderIntent()`** pour schedule
ET cancel (même action = même PendingIntent — cf bug §14.3)
- `AlarmScheduler` :
- quotidien : `setExactAndAllowWhileIdle` si `canScheduleExact()` (API≥31 :
`alarmManager.canScheduleExactAlarms()`), sinon `setWindow` ±10 min
- **`scheduleFor(treatment, doseLogs)` (v1.4.0, source de vérité = moteur)** :
la date du prochain déclenchement vient de
`PharmacokineticEngine.nextReminderFireFor` — **grille Posologie** si le
traitement a `forecastIntervalDays` + des doses (rappel UNIQUEMENT le jour
du créneau, à l'heure choisie — fix #52), sinon quotidien (comportement
historique, gel/oral ou app sans historique)
- alarmes **one-shot** (`setExactAndAllowWhileIdle`) : la suivante est
reprogrammée par `ReminderReceiver` (après notif) et `DoseActionReceiver`
(après « Pris ») — avant v1.4.0 la chaîne ne se recousait qu'au boot/au save
- permission **SCHEDULE_EXACT_ALARM** : bouton d'octroi dans Paramètres + éditeur
(`Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM`)
- `scheduleDaily` (prochaine occurrence HH:mm), `scheduleSnooze` (+1 h), `rescheduleAll`
- `scheduleSnooze` (+1 h), `rescheduleAll(treatments, doseLogs)`
- `ReminderReceiver` : notif HIGH/REMINDER, 2 actions + tap → MainActivity
(`open_log_dose`, `treatment_id`) → Home ouvre le dialog pré-rempli ; requestCodes
PendingIntent = `id*10+{0,1,2}` ; notificationId = `id.toInt()`
PendingIntent = `id*10+{0,1,2}` ; notificationId = `id.toInt()` ; **puis
reprogramme le prochain déclenchement** (goAsync + IO)
- `DoseActionReceiver` (non exporté) : **« Pris »** → `goAsync()` + coroutine IO → insert
DoseLog (dose = extra ou standard) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif
- `BootReceiver` : `goAsync()` + thread + **`runBlocking`** + one-shot `getActiveOnce()`
(jamais un Flow en runBlocking !) → reschedule
DoseLog (dose = extra ou standard) **+ reprogramme** (la dose loguée avance
la grille) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif
- `BootReceiver` : `goAsync()` + thread + **`runBlocking`** + one-shots
`getAllOnce()` (traitements) + `doseLogDao.getAllOnce()` → `rescheduleAll`
(jamais un Flow en runBlocking !)
**Événements d'agenda (v1.3.0, `reminder/CalendarEvents.kt`)** : switch dans
l'éditeur (sous Rappels) → insertion dans un **calendrier local dédié
@ -922,6 +979,19 @@ l'exemple) :**
un save doit repartir des champs NON ÉDITABLES (voir `loadedCreatedAt`,
déjà protégé, et désormais `loadedCalendarEventId`).
**Session v1.4.0 (modèle TFS sur la méta-analyse + fix #52) :**
52. **Rappels quotidiens même hors jour d'injection** (remontée utilisateur :
« je m'injecte tous les samedis à 18 h → je dois recevoir le rappel
uniquement le samedi à 18 h ») : `nextReminderFireMs` ignorait doses et
Posologie → l'alarme se ré-armait chaque jour à HH:mm. FIX : le prochain
déclenchement suit la GRILLE (dernière dose + k × intervalle, créneaux
passés sautés — même sémantique que la prévision) à l'heure de rappel
choisie ; fallback quotidien sans Posologie/sans doses ; **et** la
chaîne one-shot est recousue après notif ET après « Pris » (avant :
seulement au boot/save). Leçon : toute logique de scheduling doit vivre
au MOTEUR (testable JVM), pas dans les composants Android.
**Leçons** : (a) ne jamais croire un build « probablement bon » sans l'avoir lancé ;
(b) les tests sémantiques attrapent ce que la compilation ne voit pas ; (c) se méfier des
constantes stdlib « de mémoire » (`ln2`), des mélanges Float/Double, et des APIs M3
@ -1325,14 +1395,24 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
RÉELLEMENT** de l'app d'agenda (bugs #50 / #50 bis / #51 : en v1.3.4
ni création ni suppression ne fonctionnaient, et les re-saves
orphelinaient les événements — cf §14)
- [ ] **v1.4.0** : **rappel = jour du créneau uniquement** (fix #52) : un
traitement avec Posologie 7 j + rappel 18 h ne doit sonner que le jour
d'injection à 18 h (pas les autres jours) ; la carte « Prochaine
dose » de l'accueil montre le même créneau ; après « Pris » dans la
notification, le rappel suivant se cale sur le créneau d'après
- [ ] **v1.4.0** : **courbes TFS V3C** : superposer Estrannaise/TFS sur un
traitement EV — les deux doivent être proches aux pics (58–61
pg/mL/mg) et diverger en queues (V3C = queues exactes) ; vérifier un
preset EB/EC/PEP (nouvelles courbes) ; PEP à 32,5 mg → niveaux
comparables à un EEn à 5 mg
---
*Doc mise à jour le 6 sept. 2026 (v1.3.5) — build OK, lint vert, 90/90 tests verts (72 sans
*Doc mise à jour le 6 sept. 2026 (v1.4.0) — build OK, lint vert, 102/102 tests verts (84 sans
les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée
de santé dans le dépôt ni l'historique. Fil des corrections : v1.3.3 = reprise de
maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 crashs
reproduits sur émulateur avec les données réelles (#47 stringResource arity, #48
LocalDate+pattern horaire), lint filet bloquant ; v1.3.5 = premier diagnostic à distance
RéUSSI via les logs exportés de l'utilisatrice (#50 CalendarProvider sync-adapter account,
log agenda véridique). Dette connue : fragments de labs dans l'historique git
(v1.1.0→v1.2.3) — cf §8.bis.*
reproduits sur émulateur (#47 stringResource arity, #48 LocalDate+pattern horaire), lint
filet bloquant ; v1.3.5 = diagnostic à distance via les logs exportés (#50/#50bis/#51
agenda) ; v1.4.0 = modèle TFS V3C sur la méta-analyse officielle (7 esters, fidélité
~1 % épinglée) + rappels sur la grille Posologie (#52). Dette connue : fragments de
labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.*

View File

@ -75,6 +75,12 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
3. Test : programme un rappel 2 min à l'avance → la notif doit apparaître au poignet
avec les boutons **« Pris »** et **« Reporter 1 h »**
> **Quand le rappel sonne-t-il ?** (v1.4.0) : si le traitement a une **Posologie**
> (intervalle en jours) et un historique de prises, le rappel sonne **uniquement le
> jour du créneau** à l'heure choisie (ex. : injection tous les samedis, rappel 18 h
> → notification le samedi à 18 h, pas les autres jours). Après « Pris », le rappel
> suivant se cale sur le créneau d'après. Sans Posologie (gel, oral) → rappel quotidien.
> Sur la GT 3, on ne peut pas installer d'app au poignet facilement (Lite Wearable, sideload
> via DevEco Assistant). La v1 utilise la montre comme **écran de notifications**, ce qui est
> fiable et sans maintenance. Une mini-app au poignet reste possible en Phase 2 si tu veux.
@ -125,19 +131,27 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
## 7. Les modèles mathématiques
Les profils viennent de ta feuille `Estrogen.ods` (table « Models ») :
- **Estrannaise** : tables horaires issues de ta feuille `Estrogen.ods` (table « Models »)
- **Transfem Science** (v1.4.0) : la méta-analyse officielle à 3 compartiments
([article](https://transfemscience.org/articles/injectable-e2-meta-analysis/),
[simulateur](https://transfemscience.org/misc/injectable-e2-simulator/)) —
courbe calculée en forme close, 7 esters disponibles (EV, EU, EEn, EB, EC,
EC suspension, PEP)
| Profil | Source | Pic (pg/mL par mg) | Tmax (h) |
|-----------|-------------------|--------------------|----------|
| EV(ese) | Estrannaise | 61,1 | ~45 h |
| EU(ese) | Estrannaise | 3,4 | ~55 h (plateau long) |
| EEn(ese) | Estrannaise | 31,4 | ~152 h |
| EV(tfs) | Transfem Science | 59,0 | ~51 h |
| EU(tfs) | Transfem Science | 10,1 | ~198 h |
| EEn(tfs) | Transfem Science | 32,0 | ~156 h |
Pics de référence (dose unique de 5 mg, IM) :
| Profil | Source | Cmax (pg/mL) | Tmax | t½ terminale |
|--------|--------|--------------|------|---------------|
| EV | Estrannaise | — tables ODS | ~45 h | — |
| EEn | Estrannaise | — tables ODS | ~152 h | — |
| EV | Transfem Science | 295 | 2,1 j | 3,0 j |
| EEn | Transfem Science | 160 | 6,5 j | 4,6 j |
| EB | Transfem Science | 971 | 0,65 j | 1,2 j |
| EC (huile) | Transfem Science | 155 | 4,3 j | 6,7 j |
| EC (susp.) | Transfem Science | 241 | 1,2 j | 5,1 j |
| PEP | Transfem Science | 34 (à 32,5 mg) | 18 j | 28,4 j |
- **Superposition** : chaque injection contribue `dose_mg × profil(dt)` ; les courbes s'additionnent
- **Interpolation** linéaire entre les heures ; au-delà de 8000 h, extrapolation avec la pente terminale
- **Calibration** : facteur d'échelle par traitement (médiane des ratios lab/prédiction)
- **Courbe T** : dérivée de l'E2 estimé (modèle empirique, calibrable) — indicative seulement
- Unités T acceptées : ng/mL, **ng/dL**, ng/L, nmol/L (conversion automatique)