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:
parent
fb942e4a24
commit
016606b96c
27
README.md
27
README.md
@ -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.
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
103
app/src/main/java/com/hormonetrack/pk/TransfemScienceModels.kt
Normal file
103
app/src/main/java/com/hormonetrack/pk/TransfemScienceModels.kt
Normal 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))
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
142
app/src/test/java/com/hormonetrack/pk/ReminderScheduleTest.kt
Normal file
142
app/src/test/java/com/hormonetrack/pk/ReminderScheduleTest.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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.*
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user