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 208ba56843
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 > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue. > sang et aux consignes de ton endocrinologue.
- **Statut** : v1.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) - **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) - **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) - **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
@ -28,8 +28,12 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
**panoramique** (glisser pour remonter dans le passé), **zoom** (pinch ou boutons **panoramique** (glisser pour remonter dans le passé), **zoom** (pinch ou boutons
− / +, 6 h → 300 j, échantillonnage adaptatif), **pics & creux** affichables avec − / +, 6 h → 300 j, échantillonnage adaptatif), **pics & creux** affichables avec
leurs **valeurs estimées** (triangles ▲▼ aux extrema locaux, toggle) leurs **valeurs estimées** (triangles ▲▼ aux extrema locaux, toggle)
- **Deux modèles PK au choix, superposables** (issus de la feuille `Estrogen.ods`) : - **Deux modèles PK au choix, superposables** : **Estrannaise (EstraNase)** (tables
**Estrannaise (EstraNase)** et **Transfem Science** pour les injections EV / EU / EEn, 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 affichés côte à côte avec toggles indépendants
- **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel, - **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel,
patch et voie orale 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), **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 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 ; - **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 - **Nouveautés à chaque mise à jour** : dialog de changelog automatique
(fermé = ne réapparaît pas avant la prochaine version) (fermé = ne réapparaît pas avant la prochaine version)
- **Temps sous THS** affiché en haut de la page Doses (depuis la 1re prise) - **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 git clone <repo> && cd HormoneTrack
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk ./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
./gradlew testDebugUnitTest # 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 ./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) — Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
`Siphonight/HormoneTrack` sur les deux (privé), avec `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)) : (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) : `-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 ## Les modèles en bref
Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la réponse normalisée 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) ; (pg/mL par mg) ; les contributions se superposent. **Estrannaise** = tables horaires
les contributions se superposent. Pics de référence : 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 | | 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 | | EV | Transfem Science | 59,0 | ~51 h |
| EU | Transfem Science | 10,1 | ~198 h | | EU | Transfem Science | 10,1 | ~198 h |
| EEn | Transfem Science | 32,0 | ~156 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 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. à 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" // tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans // (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
// les APK publiés (Paramètres + titre du dialog « Nouveautés »). // les APK publiés (Paramètres + titre du dialog « Nouveautés »).
versionCode = 19 versionCode = 20
versionName = "1.3.5" versionName = "1.4.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -53,7 +53,7 @@ object PKPresets {
route = AdministrationRoute.INJECTION_IM, route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EV, esterType = Esters.EV,
pkModel = PKModels.TRANSFEM_SCIENCE, pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 51f, eliminationHalfLifeHours = 100f, bioavailabilityFraction = 1f, absorptionHours = 50f, eliminationHalfLifeHours = 72f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 4.0 defaultDoseUnit = "mg", defaultDoseAmount = 4.0
), ),
PKPreset( PKPreset(
@ -62,7 +62,7 @@ object PKPresets {
route = AdministrationRoute.INJECTION_IM, route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EU, esterType = Esters.EU,
pkModel = PKModels.TRANSFEM_SCIENCE, pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 198f, eliminationHalfLifeHours = 400f, bioavailabilityFraction = 1f, absorptionHours = 204f, eliminationHalfLifeHours = 528f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 10.0 defaultDoseUnit = "mg", defaultDoseAmount = 10.0
), ),
PKPreset( PKPreset(
@ -71,8 +71,47 @@ object PKPresets {
route = AdministrationRoute.INJECTION_SUBCUT, route = AdministrationRoute.INJECTION_SUBCUT,
esterType = Esters.EEN, esterType = Esters.EEN,
pkModel = PKModels.TRANSFEM_SCIENCE, pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 156f, eliminationHalfLifeHours = 150f, bioavailabilityFraction = 1f, absorptionHours = 156f, eliminationHalfLifeHours = 110f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 10.0 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) --- // --- Transdermal / oral (Bateman fallback) ---
PKPreset( PKPreset(

View File

@ -8,6 +8,17 @@ object Esters {
const val EV = "EV" const val EV = "EV"
const val EU = "EU" const val EU = "EU"
const val EEN = "EEN" 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 { 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.DoseLog
import com.hormonetrack.data.model.Esters import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType import com.hormonetrack.data.model.TreatmentType
import kotlin.math.abs import kotlin.math.abs
@ -84,7 +85,15 @@ object PharmacokineticEngine {
if (treatment.usesProfileModel) { if (treatment.usesProfileModel) {
val ester = doseEster(treatment, dose) val ester = doseEster(treatment, dose)
if (ester != Esters.NONE) { 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 { private fun cutoffHours(treatment: Treatment): Double {
val profileH = if (treatment.usesProfileModel) { 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 } else 0.0
val batemanH = if (treatment.usesProfileModel) 0.0 val batemanH = if (treatment.usesProfileModel) 0.0
else 30.0 * treatment.eliminationHalfLifeHours.toDouble() else 30.0 * treatment.eliminationHalfLifeHours.toDouble()
@ -561,22 +579,75 @@ object PharmacokineticEngine {
return current.copy(k = (median * 1000).toInt() / 1000.0) 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() val cal = java.util.Calendar.getInstance()
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.timeInMillis = nowMs
cal.set(java.util.Calendar.HOUR_OF_DAY, h) cal.set(java.util.Calendar.HOUR_OF_DAY, h)
cal.set(java.util.Calendar.MINUTE, m) cal.set(java.util.Calendar.MINUTE, m)
cal.set(java.util.Calendar.SECOND, 0) cal.set(java.util.Calendar.SECOND, 0)
cal.set(java.util.Calendar.MILLISECOND, 0) cal.set(java.util.Calendar.MILLISECOND, 0)
if (cal.timeInMillis <= nowMs) cal.add(java.util.Calendar.DAY_OF_MONTH, 1) if (cal.timeInMillis <= nowMs) cal.add(java.util.Calendar.DAY_OF_MONTH, 1)
val fire = cal.timeInMillis return cal.timeInMillis
if (next == null || fire < next) next = fire }
/**
* 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) {
val fire = nextReminderFireFor(tr, doseLogs, nowMs)
if (fire != null && (next == null || fire < next)) next = fire
} }
return next 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 treatment = db.treatmentDao().getById(treatmentId) ?: return@launch
val amount = if (dose > 0.0) dose else treatment.doseAmount val amount = if (dose > 0.0) dose else treatment.doseAmount
db.doseLogDao().insert(DoseLog(treatmentId = treatmentId, doseAmount = amount)) 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 { } finally {
pending.finish() pending.finish()
} }

View File

@ -12,7 +12,13 @@ import androidx.core.app.NotificationCompat
import com.hormonetrack.MainActivity import com.hormonetrack.MainActivity
import com.hormonetrack.R import com.hormonetrack.R
import com.hormonetrack.data.AppDatabase import com.hormonetrack.data.AppDatabase
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Treatment 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 import java.util.Calendar
object ReminderContract { 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 = fun canScheduleExact(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms() Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
fun scheduleDaily(treatment: Treatment): Boolean { /**
val hour = treatment.reminderHour ?: return false * Programme la PROCHAINE alarme du traitement (v1.4.0, fix #52) :
val minute = treatment.reminderMinute ?: return false * la date vient du MOTEUR (`nextReminderFireFor`) — unique source de
val triggerAt = nextOccurrence(hour, minute) * 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) 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 { 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) 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 -> treatments.forEach { tr ->
// Un traitement inactif ne rappelle plus : son alarme est annulée // Un traitement inactif ne rappelle plus : son alarme est annulée
if (tr.reminderEnabled && tr.isActive) { if (tr.reminderEnabled && tr.isActive) {
scheduleDaily(tr) scheduleFor(tr, doseLogs)
} else { } else {
cancel(tr.id) cancel(tr.id)
} }
@ -205,6 +210,22 @@ class ReminderReceiver : BroadcastReceiver() {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(treatmentId.toInt(), notification) 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() val result = goAsync()
Thread { Thread {
try { try {
val treatments = kotlinx.coroutines.runBlocking { val db = AppDatabase.getInstance(context)
// TOUS les traitements : les inactifs doivent voir leur alarme // TOUS les traitements : les inactifs doivent voir leur alarme
// annulée au boot (sinon une alarme périmée continue de sonner) // annulée au boot (sinon une alarme périmée continue de sonner) ;
AppDatabase.getInstance(context).treatmentDao().getAllOnce() // les doses alimentent la grille Posologie (fix #52)
val treatments = kotlinx.coroutines.runBlocking {
db.treatmentDao().getAllOnce()
} }
AlarmScheduler(context).rescheduleAll(treatments) val doses = kotlinx.coroutines.runBlocking {
db.doseLogDao().getAllOnce()
}
AlarmScheduler(context).rescheduleAll(treatments, doses)
} finally { } finally {
result.finish() result.finish()
} }

View File

@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp
import com.hormonetrack.R import com.hormonetrack.R
import com.hormonetrack.data.model.DoseLog import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Esters import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.Treatment
/** /**
@ -139,7 +140,17 @@ fun DoseDialog(
text = { Text(stringResource(R.string.ester_default, selected.esterType)) }, text = { Text(stringResource(R.string.ester_default, selected.esterType)) },
onClick = { esterOverride = null; esterExpanded = false } 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( DropdownMenuItem(
text = { Text(esterLabel(e)) }, text = { Text(esterLabel(e)) },
onClick = { esterOverride = e; esterExpanded = false } onClick = { esterOverride = e; esterExpanded = false }
@ -175,6 +186,10 @@ private fun esterLabel(ester: String): String = when (ester) {
Esters.EV -> "EV — valerate" Esters.EV -> "EV — valerate"
Esters.EU -> "EU — undecylate" Esters.EU -> "EU — undecylate"
Esters.EEN -> "EEn — enanthate" 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 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hormonetrack.R import com.hormonetrack.R
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.Treatment
import com.hormonetrack.pk.LevelPoint import com.hormonetrack.pk.LevelPoint
@ -172,7 +173,7 @@ fun HomeScreen(
NowLevelCard(curve) NowLevelCard(curve)
NextDoseCard(activeTreatments) NextDoseCard(activeTreatments, doseLogs)
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -342,18 +343,15 @@ private fun NowLevelCard(curve: List<LevelPoint>) {
} }
@Composable @Composable
private fun NextDoseCard(treatments: List<Treatment>) { private fun NextDoseCard(treatments: List<Treatment>, doseLogs: List<DoseLog>) {
val next = PharmacokineticEngine.nextReminderFireMs(treatments) // v1.4.0 (fix #52) : les dates de rappel passent par le moteur — la carte
if (next == null) return // suit la grille Posologie (jour du créneau uniquement) comme les alarmes.
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 nowMs = System.currentTimeMillis()
val nowOfDay = nowMs % (24 * HOUR_MS) val fires = treatments
if (candidate >= nowOfDay) candidate else candidate + 24 * HOUR_MS .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()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) { Column(Modifier.padding(16.dp)) {
Text( Text(

View File

@ -443,7 +443,10 @@ fun SettingsScreen(onBack: () -> Unit) {
} }
result?.let { result?.let {
container.settings.setTConfig(it.tConfig) 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) { withContext(Dispatchers.Main) {
message = if (result != null) { message = if (result != null) {

View File

@ -517,9 +517,11 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
} }
val saved = tr.copy(id = savedId) val saved = tr.copy(id = savedId)
// Rappel actif seulement si le traitement l'est aussi ; // 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) { if (saved.reminderEnabled && saved.isActive) {
scheduler.scheduleDaily(saved) scheduler.scheduleFor(saved, repo.allDoseLogsOnce())
} else { } else {
scheduler.cancel(savedId) scheduler.cancel(savedId)
} }

View File

@ -183,6 +183,10 @@
<string name="preset_ev_tfs">Injection EV — Transfem Science</string> <string name="preset_ev_tfs">Injection EV — Transfem Science</string>
<string name="preset_eu_tfs">Injection EU — 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_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_gel">E2 gel transdermique</string>
<string name="preset_e2_patch">E2 patch</string> <string name="preset_e2_patch">E2 patch</string>
<string name="preset_e2_oral">E2 orale</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_ev_tfs">EV injection — Transfem Science</string>
<string name="preset_eu_tfs">EU 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_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_gel">E2 transdermal gel</string>
<string name="preset_e2_patch">E2 patch</string> <string name="preset_e2_patch">E2 patch</string>
<string name="preset_e2_oral">E2 oral</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`() { fun `next reminder fire time is in the future`() {
val tr = evTreatment().copy(reminderEnabled = true, reminderHour = 8, reminderMinute = 30) val tr = evTreatment().copy(reminderEnabled = true, reminderHour = 8, reminderMinute = 30)
val now = BASE 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) 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 # Changelog — HormoneTrack
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.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) ## [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 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.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.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. | | 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.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. | | 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 ### 7.7 API du moteur
`levelAt / currentLevel / computeCurve(start, end, step=1h, tConfig) / e2At / `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`. batemanParams / concentrationOfDose / computeKa / doseEster / isInjectionRoute`.
Type de retour : `LevelPoint(timestamp, e2, t)`. 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 ## 8. Tests unitaires
**87 tests JVM, tous verts** → **90 depuis v1.3.4** (`./gradlew testDebugUnitTest`) — **102 tests JVM, tous verts** → **v1.4.0** (`./gradlew testDebugUnitTest`) —
**72 sans les données de test locales** (cf §8.bis : les 3 classes de régression, **84 sans les données de test locales** (cf §8.bis : les 3 classes de
6 tests chacune, sont skippées via `Assume`). Dépendance : JUnit 4.13.2. 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 = 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/…`). `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 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 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). 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 **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. 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 ## 9. Système de rappels
`reminder/ReminderManager.kt` (+ `DoseActionReceiver.kt`). `reminder/ReminderManager.kt` (+ `DoseActionReceiver.kt`, `ReminderReceiver`).
- `ReminderContract` : constantes + **fabrique unique `reminderIntent()`** pour schedule - `ReminderContract` : constantes + **fabrique unique `reminderIntent()`** pour schedule
ET cancel (même action = même PendingIntent — cf bug §14.3) ET cancel (même action = même PendingIntent — cf bug §14.3)
- `AlarmScheduler` : - `AlarmScheduler` :
- quotidien : `setExactAndAllowWhileIdle` si `canScheduleExact()` (API≥31 : - **`scheduleFor(treatment, doseLogs)` (v1.4.0, source de vérité = moteur)** :
`alarmManager.canScheduleExactAlarms()`), sinon `setWindow` ±10 min 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 - permission **SCHEDULE_EXACT_ALARM** : bouton d'octroi dans Paramètres + éditeur
(`Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM`) (`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 - `ReminderReceiver` : notif HIGH/REMINDER, 2 actions + tap → MainActivity
(`open_log_dose`, `treatment_id`) → Home ouvre le dialog pré-rempli ; requestCodes (`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 - `DoseActionReceiver` (non exporté) : **« Pris »** → `goAsync()` + coroutine IO → insert
DoseLog (dose = extra ou standard) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif DoseLog (dose = extra ou standard) **+ reprogramme** (la dose loguée avance
- `BootReceiver` : `goAsync()` + thread + **`runBlocking`** + one-shot `getActiveOnce()` la grille) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif
(jamais un Flow en runBlocking !) → reschedule - `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 **Événements d'agenda (v1.3.0, `reminder/CalendarEvents.kt`)** : switch dans
l'éditeur (sous Rappels) → insertion dans un **calendrier local dédié 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`, un save doit repartir des champs NON ÉDITABLES (voir `loadedCreatedAt`,
déjà protégé, et désormais `loadedCalendarEventId`). 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é ; **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 (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 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 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 ni création ni suppression ne fonctionnaient, et les re-saves
orphelinaient les événements — cf §14) 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 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 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 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 reproduits sur émulateur (#47 stringResource arity, #48 LocalDate+pattern horaire), lint
LocalDate+pattern horaire), lint filet bloquant ; v1.3.5 = premier diagnostic à distance filet bloquant ; v1.3.5 = diagnostic à distance via les logs exportés (#50/#50bis/#51
RéUSSI via les logs exportés de l'utilisatrice (#50 CalendarProvider sync-adapter account, agenda) ; v1.4.0 = modèle TFS V3C sur la méta-analyse officielle (7 esters, fidélité
log agenda véridique). Dette connue : fragments de labs dans l'historique git ~1 % épinglée) + rappels sur la grille Posologie (#52). Dette connue : fragments de
(v1.1.0→v1.2.3) — cf §8.bis.* 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 3. Test : programme un rappel 2 min à l'avance → la notif doit apparaître au poignet
avec les boutons **« Pris »** et **« Reporter 1 h »** 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 > 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 > 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. > 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 ## 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) | Pics de référence (dose unique de 5 mg, IM) :
|-----------|-------------------|--------------------|----------|
| EV(ese) | Estrannaise | 61,1 | ~45 h | | Profil | Source | Cmax (pg/mL) | Tmax | t½ terminale |
| EU(ese) | Estrannaise | 3,4 | ~55 h (plateau long) | |--------|--------|--------------|------|---------------|
| EEn(ese) | Estrannaise | 31,4 | ~152 h | | EV | Estrannaise | — tables ODS | ~45 h | — |
| EV(tfs) | Transfem Science | 59,0 | ~51 h | | EEn | Estrannaise | — tables ODS | ~152 h | — |
| EU(tfs) | Transfem Science | 10,1 | ~198 h | | EV | Transfem Science | 295 | 2,1 j | 3,0 j |
| EEn(tfs) | Transfem Science | 32,0 | ~156 h | | 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 - **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) - **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 - **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) - Unités T acceptées : ng/mL, **ng/dL**, ng/L, nmol/L (conversion automatique)