v1.9.0 : modèle Estrannaise ANALYTIQUE (abandon de l'ODS) + nuage d'incertitude MCMC
DÉCISION : tous les modèles ont maintenant leurs sources originelles — on abandonne les liens avec le fichier ODS (qui lui-même était l'échantillonnage horaire de ces formules ; fidélité vérifiée RMS 0,00). - pk/EstrannaiseModels.kt (NOUVEAU) : forme close 3C de estrannaise.js (paramètres publiés EV/EU/EEn/EC/EB/EUCS), posterior MCMC (313 échantillons/ester, asset mcmc_samples.json ≈ 48 Ko), t½ terminale analytique, cas dégénérés (taux égaux → limites, portés du JS source). - PharmacokineticEngine : dispatch ESE → EstrannaiseModels (plus de fallback PKProfileStore) ; cutoffHours ESE → 10 × t½ analytique (plus la longueur de table). - Esters : +EUCS (undécylate suspension cristalline, exclusif ESE) ; choicesForModel ESE = 6 injectables ; TFS = liste EXPLICITE (EUCS sans V3C TFS → dispatcher donnerait 0). - LabTiming : t½ ESE analytique. - pk/EstrannaiseCloud.kt (NOUVEAU) : nuage d'incertitude — 32 courbes du posterior (échelonnées, déterministes) superposant les doses des porteurs ESE ; exclusif ESE (TFS/WHSAH sans posterior publié). - ChartScreen/CurveChart : chip « Nuage » (off, activable si ESE affiché, coupure auto si ESE off), ChartSeries.cloud dessiné en alpha faible sous les courbes, hors échelles/labels/extrema, légende dédiée. - Runtime 100 % sources analytiques : PKProfileStore.init retirée du démarrage, asset pk_profiles.json → src/test/assets (−550 Ko d'APK ; conservé pour les tests de fidélité RMS 0). - 11 nouveaux tests (234 verts / 204 sans données locales) : fidélité, pics ±0,2 %, dégénérés, MCMC 313×6, nuage (dispersion/fenêtre/ exclusivité/gardes), choicesForModel ESE=6, t½ analytique. - Validé émulateur §16.ter (release, seed réel v1.7.0) : courbe ESE analytique affichée, chips Extend/Cloud, recommandation de prise de sang, 0 crash. versionCode 38 / versionName 1.9.0. Web sync v1.9.0 (publié séparément).
This commit is contained in:
parent
dd220e8933
commit
8078d5cbed
11
README.md
11
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
|
> à 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.8.2 — build Android ✅, **lint vert** ✅, **223 tests unitaires** ✅ (193 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
|
- **Statut** : v1.9.0 — build Android ✅, **lint vert** ✅, **234 tests unitaires** ✅ (204 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
|
||||||
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
|
- **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)
|
||||||
@ -48,6 +48,13 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
|||||||
extinction du modèle, partie estimée dessinée atténuée + légende dédiée) —
|
extinction du modèle, partie estimée dessinée atténuée + légende dédiée) —
|
||||||
la courbe est une référence de COMPARAISON, jamais une estimation d'action
|
la courbe est une référence de COMPARAISON, jamais une estimation d'action
|
||||||
(elle n'entre jamais dans l'accueil/les alertes)
|
(elle n'entre jamais dans l'accueil/les alertes)
|
||||||
|
- **Modèle Estrannaise ANALYTIQUE (v1.9.0)** : le modèle ESE utilise désormais
|
||||||
|
la forme close 3C publiée par estrannaise.js (fidélité aux anciennes tables
|
||||||
|
épinglée RMS 0) — **nuage d'incertitude MCMC** (chip `Nuage`, exclusif ESE :
|
||||||
|
32 courbes du posterior montrant la plage d'imprécision, comme sur le site
|
||||||
|
estrannaise) ; **6 esters injectables** (dont EUCS, exclusif) ; l'ODS
|
||||||
|
historique n'est plus utilisé au runtime (fidélité = tests uniquement) ;
|
||||||
|
t½ terminale analytique (recommandation de prise de sang)
|
||||||
- **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
|
||||||
- **Simulation prévisionnelle** : configurer la **posologie** (intervalle en jours) sur un
|
- **Simulation prévisionnelle** : configurer la **posologie** (intervalle en jours) sur un
|
||||||
@ -116,7 +123,7 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1.
|
|||||||
git clone <repo> && cd HormoneTrack
|
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 # 223 tests (193 sans les données locales)
|
./gradlew testDebugUnitTest # 234 tests (204 sans les données locales)
|
||||||
./gradlew lint # lint vert obligatoire avant release
|
./gradlew lint # lint vert obligatoire avant release
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -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 = 37
|
versionCode = 38
|
||||||
versionName = "1.8.2"
|
versionName = "1.9.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
|
|||||||
1
app/src/main/assets/mcmc_samples.json
Normal file
1
app/src/main/assets/mcmc_samples.json
Normal file
File diff suppressed because one or more lines are too long
@ -4,7 +4,7 @@ import android.app.Application
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.hormonetrack.data.AppDatabase
|
import com.hormonetrack.data.AppDatabase
|
||||||
import com.hormonetrack.data.repository.HormoneRepository
|
import com.hormonetrack.data.repository.HormoneRepository
|
||||||
import com.hormonetrack.pk.PKProfileStore
|
import com.hormonetrack.pk.EstrannaiseModels
|
||||||
import com.hormonetrack.reminder.createNotificationChannel
|
import com.hormonetrack.reminder.createNotificationChannel
|
||||||
import com.hormonetrack.util.AppLog
|
import com.hormonetrack.util.AppLog
|
||||||
import com.hormonetrack.reminder.AlertNotifier
|
import com.hormonetrack.reminder.AlertNotifier
|
||||||
@ -19,7 +19,10 @@ class HormoneTrackApp : Application() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
container = AppContainer(this)
|
container = AppContainer(this)
|
||||||
PKProfileStore.init(this)
|
// v1.9.0 : les tables ODS ne sont plus chargées au démarrage (le
|
||||||
|
// runtime ne lit que des sources analytiques). L'asset MCMC
|
||||||
|
// (nuage d'incertitude ESE) les remplace (≈ 48 Ko).
|
||||||
|
EstrannaiseModels.init(this)
|
||||||
createNotificationChannel(this)
|
createNotificationChannel(this)
|
||||||
AppLog.init(this)
|
AppLog.init(this)
|
||||||
// v1.4.2 : worker WorkManager PÉRIODIQUE des seuils d'alerte —
|
// v1.4.2 : worker WorkManager PÉRIODIQUE des seuils d'alerte —
|
||||||
|
|||||||
@ -16,20 +16,30 @@ object Esters {
|
|||||||
const val EC = "EC" // cypionate, solution huileuse
|
const val EC = "EC" // cypionate, solution huileuse
|
||||||
const val ECS = "ECS" // cypionate, suspension aqueuse (microcristaux)
|
const val ECS = "ECS" // cypionate, suspension aqueuse (microcristaux)
|
||||||
const val PEP = "PEP" // polyestradiol phosphate
|
const val PEP = "PEP" // polyestradiol phosphate
|
||||||
|
// v1.9.0 : undécylate en SUSPENSION CRISTALLINE — couvert par le modèle
|
||||||
|
// Estrannaise ANALYTIQUE (estrannaise.js « EUn casubq ») ; ni TFS ni les
|
||||||
|
// tables ODS ne le couvrent.
|
||||||
|
const val EUCS = "EUCS"
|
||||||
|
|
||||||
/** Esters injectables proposables par ester (override par dose). */
|
/** Esters injectables proposables par ester (override par dose). */
|
||||||
val INJECTABLE: List<String> = listOf(EV, EU, EEN, EB, EC, ECS, PEP)
|
val INJECTABLE: List<String> = listOf(EV, EU, EEN, EB, EC, ECS, PEP, EUCS)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Esters proposables dans l'override par dose pour un MODÈLE PK donné
|
* Esters proposables dans l'override par dose pour un MODÈLE PK donné
|
||||||
* (v1.4.6, cf DoseDialog) : la liste suit la couverture du modèle —
|
* (v1.4.6, cf DoseDialog) : la liste suit la couverture du modèle —
|
||||||
* TFS (V3C méta-analyse) → les 7 esters ; WHSAH (fit Mona) → les 6
|
* TFS (V3C méta-analyse) → les 7 esters ; WHSAH (fit Mona) → les 6
|
||||||
* (PEP non couvert par ce fit) ; Estrannaise (tables ODS) → EV/EU/EEn
|
* (PEP non couvert par ce fit) ; ESE (Estrannaise ANALYTIQUE v1.9.0)
|
||||||
* seulement (un traitement ESE + EB produirait 0 pg/mL).
|
* → les 6 injectables du fit estrannaise.js (dont EUCS, exclusif) ;
|
||||||
|
* les tables ODS historiques n'en couvraient que 3 (EV/EU/EEn).
|
||||||
*/
|
*/
|
||||||
fun choicesForModel(pkModel: String): List<String> = when (pkModel) {
|
fun choicesForModel(pkModel: String): List<String> = when (pkModel) {
|
||||||
PKModels.TRANSFEM_SCIENCE -> INJECTABLE
|
// ⚠️ TFS = liste EXPLICITE (fix v1.8.2-étendu) : INJECTABLE a gagné
|
||||||
|
// EUCS (couvert par l'Estrannaise analytique) mais le V3C TFS n'a PAS
|
||||||
|
// de paramètres pour EUCS → le dispatcher donnerait 0. Chaque liste
|
||||||
|
// reflète la couverture RÉELLE de son modèle.
|
||||||
|
PKModels.TRANSFEM_SCIENCE -> listOf(EV, EU, EEN, EB, EC, ECS, PEP)
|
||||||
PKModels.WHSAH -> listOf(EV, EU, EEN, EB, EC, ECS)
|
PKModels.WHSAH -> listOf(EV, EU, EEN, EB, EC, ECS)
|
||||||
|
PKModels.ESTRANNAISE -> listOf(EV, EU, EEN, EC, EB, EUCS)
|
||||||
else -> listOf(EV, EU, EEN)
|
else -> listOf(EV, EU, EEN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
127
app/src/main/java/com/hormonetrack/pk/EstrannaiseCloud.kt
Normal file
127
app/src/main/java/com/hormonetrack/pk/EstrannaiseCloud.kt
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
package com.hormonetrack.pk
|
||||||
|
|
||||||
|
import com.hormonetrack.data.model.DoseLog
|
||||||
|
import com.hormonetrack.data.model.PKModels
|
||||||
|
import com.hormonetrack.data.model.Treatment
|
||||||
|
import com.hormonetrack.data.model.TreatmentType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NUAGE D'INCERTITUDE (v1.9.0) — feature **exclusive au modèle ESE**.
|
||||||
|
*
|
||||||
|
* SOURCE : Estrannaise publie le **posterior MCMC** de ses paramètres
|
||||||
|
* (313 échantillons `(d, k1, k2, k3)` par ester injectable — cf
|
||||||
|
* [EstrannaiseModels]). Le graphique de simulation peut dessiner un
|
||||||
|
* **nuage diffus** : M courbes (une par échantillon échelonné) qui montrent
|
||||||
|
* la plage d'imprécision possible du modèle — exactement l'affichage du
|
||||||
|
* site estrannaise. TFS/WHSAH ne publient pas de posterior → pas de nuage
|
||||||
|
* pour eux (choix confirmé : le nuage reste sur ESE).
|
||||||
|
*
|
||||||
|
* QU'EST-CE QUE LE NUAGE REPRÉSENTE : la superposition des doses des
|
||||||
|
* traitements E2 **à profil ESE** évaluée avec UN échantillon MCMC par
|
||||||
|
* courbe — chaque polyligne du nuage est « ce que serait la courbe si le
|
||||||
|
* vrai paramètre physiologique était celui-là ». Les contributions des
|
||||||
|
* autres traitements (TFS/WHSAH/Bateman) ne sont PAS incluses dans le
|
||||||
|
* nuage : elles n'ont pas d'incertitude publiée (limite documentée — le
|
||||||
|
* nuage montre l'incertitude du modèle Estrannaise, pas le niveau total).
|
||||||
|
*
|
||||||
|
* DÉTERMINISME : les M échantillons sont **échelonnés** (stride sur les
|
||||||
|
* 313, pas d'aléatoire) — le nuage est identique à chaque affichage et
|
||||||
|
* testable en JVM (c'est aussi la reproducibilité du site, qui tire au
|
||||||
|
* hasard à chaque trace, lui).
|
||||||
|
*
|
||||||
|
* PUR (aucune dépendance Android) → testable en JVM (EstrannaiseCloudTest).
|
||||||
|
*/
|
||||||
|
object EstrannaiseCloud {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nombre de courbes du nuage : assez pour une plage visuellement
|
||||||
|
* « diffuse » (le site en trace des centaines, mais 32 échantillons
|
||||||
|
* échelonnés couvrent déjà l'étendue du posterior à un coût ×32 sur le
|
||||||
|
* calcul — le pas de la grille reste 1 h).
|
||||||
|
*/
|
||||||
|
const val DEFAULT_NB_CURVES = 32
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule le nuage d'incertitude.
|
||||||
|
*
|
||||||
|
* @param treatments TOUS les traitements (actifs ET inactifs, §6.bis)
|
||||||
|
* @param doseLogs toutes les doses réelles
|
||||||
|
* @param startMs/endMs fenêtre demandée (celle du graphique)
|
||||||
|
* @param stepMs pas de la grille (stepForRange)
|
||||||
|
* @param nowMs « maintenant » (les doses futures du porteur ne sont
|
||||||
|
* pas loguées — seules les doses à horizon prévisionnel
|
||||||
|
* alimentées par le caller seraient incluses ; v1.9.0
|
||||||
|
* limite le nuage aux doses RÉELLES, comme le tracé labs)
|
||||||
|
* @param nbCurves nombre de courbes du nuage (défaut [DEFAULT_NB_CURVES])
|
||||||
|
* @return M listes de points (M = [nbCurves]) — **vide** si rien n'est
|
||||||
|
* calculable : aucun traitement E2 actif à profil ESE avec un ester
|
||||||
|
* couvert, aucune dose, ou MCMC non chargé.
|
||||||
|
*/
|
||||||
|
fun compute(
|
||||||
|
treatments: List<Treatment>,
|
||||||
|
doseLogs: List<DoseLog>,
|
||||||
|
startMs: Long,
|
||||||
|
endMs: Long,
|
||||||
|
stepMs: Long,
|
||||||
|
nowMs: Long,
|
||||||
|
nbCurves: Int = DEFAULT_NB_CURVES
|
||||||
|
): List<List<LevelPoint>> {
|
||||||
|
if (startMs >= endMs || stepMs <= 0 || nbCurves < 2) return emptyList()
|
||||||
|
|
||||||
|
// ── Porteurs : traitements E2 actifs à profil ESE avec ester couvert ──
|
||||||
|
val carriers = treatments.filter {
|
||||||
|
it.type == TreatmentType.ESTRADIOL && it.isActive &&
|
||||||
|
it.usesProfileModel && it.pkModel == PKModels.ESTRANNAISE &&
|
||||||
|
EstrannaiseModels.hasModel(it.esterType)
|
||||||
|
}
|
||||||
|
if (carriers.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val carrierIds = carriers.map { it.id }.toSet()
|
||||||
|
val carrierDoses = doseLogs
|
||||||
|
.filter { it.treatmentId in carrierIds && it.timestamp <= endMs }
|
||||||
|
.ifEmpty { return emptyList() }
|
||||||
|
|
||||||
|
// ── Échantillons échelonnés du posterior (déterministes) ─────────────
|
||||||
|
// Un échantillon m est appliqué À TOUS les esters du nuage (même
|
||||||
|
// index) : le nuage explore le posterior ester par ester, index par
|
||||||
|
// index — reproductible.
|
||||||
|
val samplesByEster = carriers.associate {
|
||||||
|
it.esterType to EstrannaiseModels.mcmcSamples(it.esterType)
|
||||||
|
}
|
||||||
|
if (samplesByEster.values.any { it.isEmpty() }) return emptyList()
|
||||||
|
val maxSamples = samplesByEster.values.minOf { it.size }
|
||||||
|
val stride = (maxSamples.toDouble() / nbCurves.coerceAtMost(maxSamples)).toInt()
|
||||||
|
.coerceAtLeast(1)
|
||||||
|
|
||||||
|
// Grille : de max(start, 1ʳᵉ dose du porteur) à endMs — M=0 avant la
|
||||||
|
// 1ʳᵉ dose (rien à faire varier)
|
||||||
|
val firstDoseMs = carrierDoses.minOf { it.timestamp }
|
||||||
|
val t0 = maxOf(startMs, firstDoseMs)
|
||||||
|
if (t0 > endMs) return emptyList()
|
||||||
|
|
||||||
|
// ── M courbes ─────────────────────────────────────────────────────────
|
||||||
|
val cloud = mutableListOf<List<LevelPoint>>()
|
||||||
|
for (m in 0 until nbCurves) {
|
||||||
|
val sampleIdx = (m * stride).coerceAtMost(maxSamples - 1)
|
||||||
|
val paramsByEster = samplesByEster.mapValues { (_, samples) ->
|
||||||
|
samples[sampleIdx.coerceAtMost(samples.size - 1)]
|
||||||
|
}
|
||||||
|
val curve = mutableListOf<LevelPoint>()
|
||||||
|
var t = t0
|
||||||
|
while (t <= endMs) {
|
||||||
|
var e2 = 0.0
|
||||||
|
for (dose in carrierDoses) {
|
||||||
|
if (dose.timestamp > t) continue
|
||||||
|
val dtH = (t - dose.timestamp) / 3_600_000.0
|
||||||
|
val tr = carriers.first { it.id == dose.treatmentId }
|
||||||
|
val params = paramsByEster.getValue(tr.esterType)
|
||||||
|
e2 += EstrannaiseModels.sampleWithParams(tr.esterType, dtH, params) * dose.doseAmount
|
||||||
|
}
|
||||||
|
curve.add(LevelPoint(t, e2, 0.0))
|
||||||
|
t += stepMs
|
||||||
|
}
|
||||||
|
if (curve.size >= 2) cloud.add(curve)
|
||||||
|
}
|
||||||
|
return cloud
|
||||||
|
}
|
||||||
|
}
|
||||||
188
app/src/main/java/com/hormonetrack/pk/EstrannaiseModels.kt
Normal file
188
app/src/main/java/com/hormonetrack/pk/EstrannaiseModels.kt
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
package com.hormonetrack.pk
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.google.gson.JsonParser
|
||||||
|
import kotlin.math.exp
|
||||||
|
import kotlin.math.ln
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modèle **Estrannaise analytique** (v1.9.0) — remplace définitivement les
|
||||||
|
* tables horaires ODS.
|
||||||
|
*
|
||||||
|
* SOURCE ORIGINELLE : le dépôt https://github.com/WHSAH/estrannaise.js/
|
||||||
|
* (site estrannaise) — le modèle 3 compartiments avec les paramètres
|
||||||
|
* d'ajustement **publiés par Estrannaise** eux-mêmes :
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* dB(t)/dt = −k1·B(t)
|
||||||
|
* dEE(t)/dt = k1·B(t) − k2·EE(t)
|
||||||
|
* dE2(t)/dt = k2·EE(t) − k3·E2(t)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* forme close pour une dose `dose` (mg) à t = 0 (t en JOURS) :
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* C(t) = dose · d · k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3))
|
||||||
|
* − e^(−k2·t)/((k1−k2)(k2−k3))
|
||||||
|
* + e^(−k3·t)/((k1−k3)(k2−k3)) ]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* **FIDÉLITÉ (épinglée par EstrannaiseModelsTest)** : cette forme close
|
||||||
|
* reproduit les anciennes tables ODS **à l'identique** (RMS 0,00 sur 0→200 j
|
||||||
|
* pour EV/EU/EEn, écart de pic ≤ 0,1 %) — vérifié avant le débranchement :
|
||||||
|
* l'ODS n'était que l'échantillonnage horaire de ces formules.
|
||||||
|
*
|
||||||
|
* **NUAGE D'INCERTITUDE (v1.9.0)** : Estrannaise publie le **posterior
|
||||||
|
* MCMC** de ses paramètres (313 échantillons `(d, k1, k2, k3)` par ester,
|
||||||
|
* embarqués dans l'asset `mcmc_samples.json` — cf §15). Le graphique peut
|
||||||
|
* dessiner un **nuage diffus** de courbes (une par échantillon) qui montre
|
||||||
|
* la plage d'imprécision possible du modèle — feature exclusive au modèle
|
||||||
|
* ESE (TFS/WHSAH ne publient pas de posterior).
|
||||||
|
*
|
||||||
|
* POURQUOI CE CHANGEMENT (décision v1.9.0) : « tous les modèles ont
|
||||||
|
* maintenant leurs sources originelles — on abandonne complètement les
|
||||||
|
* liens avec le fichier ODS, qui lui-même était une extrapolation de ces
|
||||||
|
* mêmes sources ». Consequences : plus aucune lecture des tables au
|
||||||
|
* runtime (l'asset `pk_profiles.json` reste uniquement pour les tests de
|
||||||
|
* fidélité), t½ terminale **analytique** (ln2/min(k1,k2,k3)) au lieu de
|
||||||
|
* l'estimation par pente de table.
|
||||||
|
*
|
||||||
|
* COUVERTURE : 6 esters injectables (EV, EU, EEn, EC, EB, EUCS) — le repo
|
||||||
|
* source expose aussi 2 patchs (`patch tw`/`patch ow`) et un cycle
|
||||||
|
* menstruel : hors scope v1.9.0 (documenté §7.12/§19).
|
||||||
|
*
|
||||||
|
* PUR sauf [init]/[initWithJson] (lecture de l'asset MCMC) — la formule
|
||||||
|
* elle-même est testable en JVM.
|
||||||
|
*/
|
||||||
|
object EstrannaiseModels {
|
||||||
|
|
||||||
|
/** Paramètres d'un ester : d (facteur), k1/k2/k3 (vitesses, j⁻¹). */
|
||||||
|
data class Param(val d: Double, val k1: Double, val k2: Double, val k3: Double) {
|
||||||
|
/** t½ terminale ANALYTIQUE : la phase la plus lente domine la queue. */
|
||||||
|
val terminalHalfLifeDays: Double
|
||||||
|
get() = ln(2.0) / minOf(k1, k2, k3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paramètres d'ajustement publiés par Estrannaise (src/modeldata.js,
|
||||||
|
* `PKParameters`). Clés = clés esters de l'app. ⚠️ Mapping depuis les
|
||||||
|
* clés du repo : « EV im »→EV, « EUn im »→EU, « EEn im »→EEn,
|
||||||
|
* « EC im »→EC, « EB im »→EB, « EUn casubq »→EUCS (nouveau code ester
|
||||||
|
* v1.9.0 : undécylate en suspension cristalline).
|
||||||
|
*/
|
||||||
|
private val PK_PARAMETERS: Map<String, Param> = mapOf(
|
||||||
|
"EV" to Param(478.0, 0.236, 4.85, 1.24),
|
||||||
|
"EU" to Param(471.5, 0.01729, 6.528, 2.285),
|
||||||
|
"EEN" to Param(191.4, 0.119, 0.601, 0.402),
|
||||||
|
"EC" to Param(246.0, 0.0825, 3.57, 0.669),
|
||||||
|
"EB" to Param(1893.1, 0.67, 61.5, 4.34),
|
||||||
|
"EUCS" to Param(16.15, 0.046, 0.022, 0.101),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun hasModel(ester: String): Boolean = PK_PARAMETERS.containsKey(ester.uppercase())
|
||||||
|
|
||||||
|
/** Les paramètres de l'ester, ou null (lookup insensible à la casse). */
|
||||||
|
fun model(ester: String): Param? = PK_PARAMETERS[ester.uppercase()]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse normalisée (pg/mL PAR mg) à dtHours après une injection de 1 mg,
|
||||||
|
* forme close exacte (cf KDoc de l'objet). dtHours ≤ 0 → 0.
|
||||||
|
* ⚠️ Le passage t heures → t jours est fait ici (le modèle travaille en jours).
|
||||||
|
*/
|
||||||
|
fun sample(ester: String, dtHours: Double): Double {
|
||||||
|
val m = model(ester) ?: return 0.0
|
||||||
|
if (dtHours <= 0.0) return 0.0
|
||||||
|
return curveDays(dtHours / 24.0, 1.0, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Courbe avec un jeu de paramètres DONNÉ (échantillon MCMC) — utilisé par
|
||||||
|
* le nuage d'incertitude : chaque courbe du nuage superpose les doses du
|
||||||
|
* porteur ESE avec UN échantillon du posterior. Normalisé par mg comme
|
||||||
|
* [sample].
|
||||||
|
*/
|
||||||
|
fun sampleWithParams(ester: String, dtHours: Double, p: Param): Double {
|
||||||
|
if (dtHours <= 0.0) return 0.0
|
||||||
|
return curveDays(dtHours / 24.0, 1.0, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forme close 3C (t en JOURS) — cas général + les 3 cas dégénérés
|
||||||
|
* (taux égaux → dénominateurs nuls : solution limite, portée du JS
|
||||||
|
* source de estrannaise.js).
|
||||||
|
*/
|
||||||
|
private fun curveDays(t: Double, dose: Double, m: Param): Double {
|
||||||
|
if (t < 0) return 0.0
|
||||||
|
var ret = 0.0
|
||||||
|
val (d, k1, k2, k3) = m
|
||||||
|
if (dose > 0 && d > 0) {
|
||||||
|
when {
|
||||||
|
k1 == k2 && k2 == k3 ->
|
||||||
|
ret += dose * d * k1 * k1 * t * t * exp(-k1 * t) / 2
|
||||||
|
k1 == k2 && k2 != k3 ->
|
||||||
|
ret += dose * d * k1 * k1 * (exp(-k3 * t) - exp(-k1 * t) * (1 + (k1 - k3) * t)) /
|
||||||
|
(k1 - k3) / (k1 - k3)
|
||||||
|
k1 != k2 && k1 == k3 ->
|
||||||
|
ret += dose * d * k1 * k2 * (exp(-k2 * t) - exp(-k1 * t) * (1 + (k1 - k2) * t)) /
|
||||||
|
(k1 - k2) / (k1 - k2)
|
||||||
|
k1 != k2 && k2 == k3 ->
|
||||||
|
ret += dose * d * k1 * k2 * (exp(-k1 * t) - exp(-k2 * t) * (1 - (k1 - k2) * t)) /
|
||||||
|
(k1 - k2) / (k1 - k2)
|
||||||
|
else ->
|
||||||
|
ret += dose * d * k1 * k2 * (
|
||||||
|
exp(-k1 * t) / (k1 - k2) / (k1 - k3) -
|
||||||
|
exp(-k2 * t) / (k1 - k2) / (k2 - k3) +
|
||||||
|
exp(-k3 * t) / (k1 - k3) / (k2 - k3)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (ret.isNaN()) 0.0 else ret
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Posterior MCMC (nuage d'incertitude) ────────────────────────────────
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var mcmc: Map<String, List<Param>>? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge le posterior MCMC depuis l'asset `mcmc_samples.json`
|
||||||
|
* (313 échantillons `(d, k1, k2, k3)` par ester injectable — cf §15).
|
||||||
|
* Appelé au démarrage ([com.hormonetrack.HormoneTrackApp]) — le nuage
|
||||||
|
* n'en a besoin qu'au premier affichage, mais le chargement est court
|
||||||
|
* (≈ 48 Ko) et évite un état « chip actif sans données ».
|
||||||
|
*/
|
||||||
|
fun init(context: Context) {
|
||||||
|
if (mcmc != null) return
|
||||||
|
synchronized(this) {
|
||||||
|
if (mcmc != null) return
|
||||||
|
val json = context.assets.open("mcmc_samples.json").bufferedReader().use { it.readText() }
|
||||||
|
initWithJson(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point d'entrée testable (JVM) — même pattern que PKProfileStore.initWithJson. */
|
||||||
|
fun initWithJson(json: String) {
|
||||||
|
synchronized(this) {
|
||||||
|
val root = JsonParser.parseString(json).asJsonObject
|
||||||
|
val parsed = LinkedHashMap<String, List<Param>>()
|
||||||
|
for ((ester, value) in root.entrySet()) {
|
||||||
|
val arr = value.asJsonArray
|
||||||
|
val samples = ArrayList<Param>(arr.size())
|
||||||
|
for (s in arr) {
|
||||||
|
val e = s.asJsonArray
|
||||||
|
samples.add(Param(e[0].asDouble, e[1].asDouble, e[2].asDouble, e[3].asDouble))
|
||||||
|
}
|
||||||
|
parsed[ester.uppercase()] = samples
|
||||||
|
}
|
||||||
|
mcmc = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les échantillons MCMC d'un ester (vide si l'asset n'est pas chargé ou
|
||||||
|
* ester inconnu) — le nuage d'incertitude en tire M jeux de paramètres
|
||||||
|
* (échelonnés, déterministes : pas d'aléatoire à chaque frame).
|
||||||
|
*/
|
||||||
|
fun mcmcSamples(ester: String): List<Param> =
|
||||||
|
mcmc?.get(ester.uppercase()) ?: emptyList()
|
||||||
|
}
|
||||||
@ -28,10 +28,10 @@ import kotlin.math.ceil
|
|||||||
* renseignée (`forecastIntervalDays > 0`) — sans Posologie, pas de creux
|
* renseignée (`forecastIntervalDays > 0`) — sans Posologie, pas de creux
|
||||||
* prévisible → `null` (la page Analyses affiche alors une invite à la
|
* prévisible → `null` (la page Analyses affiche alors une invite à la
|
||||||
* renseigner) ;
|
* renseigner) ;
|
||||||
* - **t½ terminale** de l'ester : analytique pour TFS/WHSAH
|
* - **t½ terminale** de l'ester : ANALYTIQUE pour les trois modèles
|
||||||
* (`terminalHalfLifeDays`), lue dans la TABLE pour Estrannaise
|
* (TFS/WHSAH : `terminalHalfLifeDays` des fits ; ESE : idem depuis
|
||||||
* ([PKProfileStore.terminalHalfLifeDays] — la pente que l'extrapolation
|
* v1.9.0 — le modèle Estrannaise est devenu analytique, cf
|
||||||
* de `sample()` utilise déjà) ;
|
* [EstrannaiseModels]) ;
|
||||||
* - **début du régime courant** = 1ʳᵉ dose du traitement actif — PROXY
|
* - **début du régime courant** = 1ʳᵉ dose du traitement actif — PROXY
|
||||||
* assumé et documenté : l'app ne trace pas l'historique des éditions de
|
* assumé et documenté : l'app ne trace pas l'historique des éditions de
|
||||||
* Posologie/dose, la première dose du traitement est le meilleur
|
* Posologie/dose, la première dose du traitement est le meilleur
|
||||||
@ -123,8 +123,10 @@ object LabTiming {
|
|||||||
TransfemScienceModels.model(carrier.esterType)?.terminalHalfLifeDays
|
TransfemScienceModels.model(carrier.esterType)?.terminalHalfLifeDays
|
||||||
PKModels.WHSAH ->
|
PKModels.WHSAH ->
|
||||||
WhsahModels.model(carrier.esterType)?.terminalHalfLifeDays
|
WhsahModels.model(carrier.esterType)?.terminalHalfLifeDays
|
||||||
|
// v1.9.0 : ESE analytique (forme close estrannaise.js) — la t½
|
||||||
|
// n'est plus estimée par pente de table
|
||||||
PKModels.ESTRANNAISE ->
|
PKModels.ESTRANNAISE ->
|
||||||
PKProfileStore.terminalHalfLifeDays(carrier.esterType, carrier.pkModel)
|
EstrannaiseModels.model(carrier.esterType)?.terminalHalfLifeDays
|
||||||
else -> null
|
else -> null
|
||||||
} ?: return null
|
} ?: return null
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,22 @@
|
|||||||
package com.hormonetrack.pk
|
package com.hormonetrack.pk
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
import com.hormonetrack.data.model.PKModels
|
import com.hormonetrack.data.model.PKModels
|
||||||
import kotlin.math.ln
|
import kotlin.math.ln
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ v1.9.0 : ce lecteur de tables ODS n'est PLUS UTILISÉ AU RUNTIME —
|
||||||
|
* le modèle Estrannaise est devenu ANALYTIQUE (pk/EstrannaiseModels.kt,
|
||||||
|
* fidélité aux tables épinglée RMS 0) et tous les dispatchs moteur lisent
|
||||||
|
* des sources originelles. Il est conservé UNIQUEMENT pour :
|
||||||
|
* 1. le **test de fidélité** (EstrannaiseModelsTest : forme close vs
|
||||||
|
* tables, RMS 0 — garde anti-régression du débranchement) ;
|
||||||
|
* 2. le jeu de données historique (`pk_profiles.json`, déplacé en
|
||||||
|
* `src/test/assets/` — l'APK ne l'embarque plus, −550 Ko).
|
||||||
|
*
|
||||||
|
* L'ancienne [init] (lecture de l'asset au démarrage) a été retirée :
|
||||||
|
* [initWithJson] suffit pour les tests.
|
||||||
|
*/
|
||||||
object PKProfileStore {
|
object PKProfileStore {
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
@ -12,15 +24,6 @@ object PKProfileStore {
|
|||||||
|
|
||||||
private lateinit var profiles: Map<String, FloatArray>
|
private lateinit var profiles: Map<String, FloatArray>
|
||||||
|
|
||||||
fun init(context: Context) {
|
|
||||||
if (initialized) return
|
|
||||||
synchronized(this) {
|
|
||||||
if (initialized) return
|
|
||||||
val json = context.assets.open("pk_profiles.json").bufferedReader().use { it.readText() }
|
|
||||||
initWithJson(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Testable entry point: parse the profiles JSON directly (JVM unit tests). */
|
/** Testable entry point: parse the profiles JSON directly (JVM unit tests). */
|
||||||
fun initWithJson(json: String) {
|
fun initWithJson(json: String) {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
|
|||||||
@ -88,16 +88,22 @@ object PharmacokineticEngine {
|
|||||||
val model = modelOverride ?: treatment.pkModel
|
val model = modelOverride ?: treatment.pkModel
|
||||||
// v1.4.0 : le modèle Transfem Science utilise les paramètres
|
// v1.4.0 : le modèle Transfem Science utilise les paramètres
|
||||||
// V3C (3 compartiments) de la méta-analyse TFS, évalués en
|
// V3C (3 compartiments) de la méta-analyse TFS, évalués en
|
||||||
// forme close — les tables ODS restent le fallback (esters
|
// forme close.
|
||||||
// sans modèle V3C, ou modèle Estrannaise).
|
|
||||||
// v1.4.6 : le modèle WHSAH (fit license-free de Mona) est la
|
// v1.4.6 : le modèle WHSAH (fit license-free de Mona) est la
|
||||||
// 3ᵉ voie de dispatch, au même niveau que TFS.
|
// 3ᵉ voie de dispatch, au même niveau que TFS.
|
||||||
|
// v1.9.0 : l'Estrannaise devient ANALYTIQUE (forme close de
|
||||||
|
// estrannaise.js — fidélité aux tables ODS épinglée RMS 0) :
|
||||||
|
// les 4 voies de dispatch sont toutes des sources originelles,
|
||||||
|
// PLUS AUCUNE lecture des tables ODS au runtime (décision
|
||||||
|
// v1.9.0 : l'ODS n'était que l'échantillonnage de ces sources).
|
||||||
return when {
|
return when {
|
||||||
model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(ester) ->
|
model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(ester) ->
|
||||||
TransfemScienceModels.sample(ester, dtH) * mg
|
TransfemScienceModels.sample(ester, dtH) * mg
|
||||||
model == PKModels.WHSAH && WhsahModels.hasModel(ester) ->
|
model == PKModels.WHSAH && WhsahModels.hasModel(ester) ->
|
||||||
WhsahModels.sample(ester, dtH) * mg
|
WhsahModels.sample(ester, dtH) * mg
|
||||||
else -> PKProfileStore.sample(ester, model, dtH) * mg
|
model == PKModels.ESTRANNAISE && EstrannaiseModels.hasModel(ester) ->
|
||||||
|
EstrannaiseModels.sample(ester, dtH) * mg
|
||||||
|
else -> 0.0 // ester sans modèle (documenté : ni TFS ni WHSAH ni ESE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -119,7 +125,9 @@ object PharmacokineticEngine {
|
|||||||
* ne contribue plus à la prédiction :
|
* ne contribue plus à la prédiction :
|
||||||
* - profil avec modèle V3C/WHS → 10 demi-vies TERMINALES du fit
|
* - profil avec modèle V3C/WHS → 10 demi-vies TERMINALES du fit
|
||||||
* (ex. EEn TFS ≈ 45 j, PEP ≈ 284 j) ;
|
* (ex. EEn TFS ≈ 45 j, PEP ≈ 284 j) ;
|
||||||
* - profil tables ODS (Estrannaise) → longueur de la table (8001 h ≈ 333 j) ;
|
* - profil Estrannaise → 10 demi-vies TERMINALES analytiques (v1.9.0 ;
|
||||||
|
* l'ancienne coupure « longueur de table ODS 8001 h » a disparu avec
|
||||||
|
* les tables du runtime) ;
|
||||||
* - Bateman (gel/patch/oral) → 30 demi-vies d'élimination.
|
* - Bateman (gel/patch/oral) → 30 demi-vies d'élimination.
|
||||||
*
|
*
|
||||||
* Public (v1.6.0) : réutilisé par [LabTrajectoryModel.extensionHorizonEndMs]
|
* Public (v1.6.0) : réutilisé par [LabTrajectoryModel.extensionHorizonEndMs]
|
||||||
@ -139,8 +147,12 @@ object PharmacokineticEngine {
|
|||||||
model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType) ->
|
model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType) ->
|
||||||
TransfemScienceModels.model(treatment.esterType)!!
|
TransfemScienceModels.model(treatment.esterType)!!
|
||||||
.terminalHalfLifeDays * 24.0 * 10.0
|
.terminalHalfLifeDays * 24.0 * 10.0
|
||||||
else ->
|
// v1.9.0 : ESE analytique → t½ terminale ANALYTIQUE (comme
|
||||||
PKProfileStore.profileLength(treatment.esterType, model).toDouble()
|
// TFS/WHS) au lieu de la longueur de table ODS
|
||||||
|
model == PKModels.ESTRANNAISE && EstrannaiseModels.hasModel(treatment.esterType) ->
|
||||||
|
EstrannaiseModels.model(treatment.esterType)!!
|
||||||
|
.terminalHalfLifeDays * 24.0 * 10.0
|
||||||
|
else -> 0.0
|
||||||
}
|
}
|
||||||
} else 0.0
|
} else 0.0
|
||||||
val batemanH = if (treatment.usesProfileModel) 0.0
|
val batemanH = if (treatment.usesProfileModel) 0.0
|
||||||
@ -628,7 +640,13 @@ object PharmacokineticEngine {
|
|||||||
val median = if (values.size % 2 == 1) values[values.size / 2]
|
val median = if (values.size % 2 == 1) values[values.size / 2]
|
||||||
else (values[values.size / 2 - 1] + values[values.size / 2]) / 2.0
|
else (values[values.size / 2 - 1] + values[values.size / 2]) / 2.0
|
||||||
val factor = Math.pow(10.0, decimals.toDouble())
|
val factor = Math.pow(10.0, decimals.toDouble())
|
||||||
return (median * factor).toInt() / factor
|
// ⚠️ ARRONDI (v1.8.2), pas troncature : l'ancien motif
|
||||||
|
// `(median * factor).toInt() / factor` tronquait au dernier bit
|
||||||
|
// flottant — 0.8999999999999999 × 100 → toInt() = 89 → « 0,89 »
|
||||||
|
// (le modèle ESE analytique a déplacé ce bit, révélant le piège ;
|
||||||
|
// le bug #61 historique du même genre : 6,5 → 6 avec
|
||||||
|
// kotlin.math.round — là, Math.round est l'arrondi au plus proche).
|
||||||
|
return Math.round(median * factor) / factor
|
||||||
}
|
}
|
||||||
|
|
||||||
fun labIsSignificant(predicted: Double, maxSoFar: Double): Boolean =
|
fun labIsSignificant(predicted: Double, maxSoFar: Double): Boolean =
|
||||||
|
|||||||
@ -46,7 +46,15 @@ data class ChartSeries(
|
|||||||
* ses extrema reflèteraient l'interp ρ entre labs, pas la physiologie du
|
* ses extrema reflèteraient l'interp ρ entre labs, pas la physiologie du
|
||||||
* modèle, et `labIsSignificant` a déjà filtré les points aberrants.
|
* modèle, et `labIsSignificant` a déjà filtré les points aberrants.
|
||||||
*/
|
*/
|
||||||
val showExtrema: Boolean = true
|
val showExtrema: Boolean = true,
|
||||||
|
/**
|
||||||
|
* v1.9.0 : NUAGE D'INCERTITUDE du modèle ESE (échantillons MCMC — cf
|
||||||
|
* pk/EstrannaiseCloud) : chaque sous-liste est une polyligne dessinée en
|
||||||
|
* alpha faible SOUS la courbe principale de la série. null = pas de nuage.
|
||||||
|
* Hors labels/extrema par construction (c'est de l'incertitude, pas des
|
||||||
|
* courbes nommées).
|
||||||
|
*/
|
||||||
|
val cloud: List<List<LevelPoint>>? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ChartOptions(
|
data class ChartOptions(
|
||||||
@ -402,6 +410,34 @@ fun CurveChart(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NUAGE D'INCERTITUDE (v1.9.0, exclusif ESE) : dessiné EN PREMIER
|
||||||
|
// (sous toutes les courbes nommées), chaque sous-courbe du posterior
|
||||||
|
// MCMC en alpha faible. Décisions d'affichage :
|
||||||
|
// - le nuage ne participe PAS aux échelles (allPoints ne l'inclut
|
||||||
|
// pas) : c'est une plage d'imprécision, pas des données à cadrer —
|
||||||
|
// il peut sortir du cadre en haut/bas sans casser la lecture ;
|
||||||
|
// - ni aux labels ni aux extrema (pas des courbes nommées).
|
||||||
|
val cloudPaths = mutableListOf<Pair<Color, Path>>()
|
||||||
|
for (s in series) {
|
||||||
|
val cloud = s.cloud ?: continue
|
||||||
|
for (sub in cloud) {
|
||||||
|
if (sub.size < 2) continue
|
||||||
|
val path = Path()
|
||||||
|
sub.forEachIndexed { i, p ->
|
||||||
|
val x = xOf(p.timestamp)
|
||||||
|
val y = yE2(p.e2)
|
||||||
|
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||||
|
}
|
||||||
|
cloudPaths.add(s.e2Style.color.copy(alpha = 0.15f) to path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ((color, path) in cloudPaths) {
|
||||||
|
drawPath(
|
||||||
|
path, color,
|
||||||
|
style = Stroke(width = 1.5.dp.toPx())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Une courbe E2 + une courbe T par série
|
// Une courbe E2 + une courbe T par série
|
||||||
for (s in series) {
|
for (s in series) {
|
||||||
if (s.points.size < 2) continue
|
if (s.points.size < 2) continue
|
||||||
|
|||||||
@ -43,6 +43,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hormonetrack.R
|
import com.hormonetrack.R
|
||||||
import com.hormonetrack.pk.LevelPoint
|
import com.hormonetrack.pk.LevelPoint
|
||||||
|
import com.hormonetrack.pk.EstrannaiseCloud
|
||||||
import com.hormonetrack.pk.LabTrajectoryModel
|
import com.hormonetrack.pk.LabTrajectoryModel
|
||||||
import com.hormonetrack.pk.PharmacokineticEngine
|
import com.hormonetrack.pk.PharmacokineticEngine
|
||||||
import com.hormonetrack.pk.TConfig
|
import com.hormonetrack.pk.TConfig
|
||||||
@ -125,6 +126,13 @@ fun ChartScreen() {
|
|||||||
// revendique « passer par tes labs » et doit rester une référence de
|
// revendique « passer par tes labs » et doit rester une référence de
|
||||||
// comparaison, pas un affichage par défiant
|
// comparaison, pas un affichage par défiant
|
||||||
var showLabTrack by remember { mutableStateOf(false) }
|
var showLabTrack by remember { mutableStateOf(false) }
|
||||||
|
// v1.9.0 : NUAGE D'INCERTITUDE du modèle ESE (échantillons MCMC de
|
||||||
|
// estrannaise.js — cf pk/EstrannaiseCloud) — OFF par défaut, opt-in,
|
||||||
|
// activable/désactivable à volonté ; ACTIVABLE SEULEMENT quand le
|
||||||
|
// modèle ESE est affiché (le nuage est exclusif à ce modèle — les
|
||||||
|
// fits TFS/WHSAH ne publient pas de posterior). Se désactive
|
||||||
|
// automatiquement si ESE est éteint.
|
||||||
|
var showCloud by remember { mutableStateOf(false) }
|
||||||
// v1.6.0 : PROLONGATION du tracé labs au-delà du dernier lab (ρ du
|
// v1.6.0 : PROLONGATION du tracé labs au-delà du dernier lab (ρ du
|
||||||
// dernier lab constant × modèle, bornée par l'extinction du modèle —
|
// dernier lab constant × modèle, bornée par l'extinction du modèle —
|
||||||
// cf LabTrajectoryModel.extensionHorizonEndMs). OFF par défaut : la
|
// cf LabTrajectoryModel.extensionHorizonEndMs). OFF par défaut : la
|
||||||
@ -270,19 +278,30 @@ fun ChartScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une courbe rendue par le producer : clé de modèle ("ESE"/"TFS"/"WHS"/
|
||||||
|
* "LAB"/"LABX"), ses points, et — v1.9.0 — son NUAGE D'INCERTITUDE
|
||||||
|
* (uniquement pour "ESE" quand le chip Nuage est actif).
|
||||||
|
*/
|
||||||
|
data class ChartCurve(
|
||||||
|
val model: String,
|
||||||
|
val points: List<LevelPoint>,
|
||||||
|
val cloud: List<List<LevelPoint>> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
// Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS") —
|
// Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS") —
|
||||||
// v1.5.0 : la 4ᵉ série possible est "LAB" (Tracé labs, indépendante) ;
|
// v1.5.0 : la 4ᵉ série possible est "LAB" (Tracé labs, indépendante) ;
|
||||||
// v1.6.0 : la 5ᵉ est "LABX" (partie PROLONGÉE du tracé labs, ρ constant)
|
// v1.6.0 : la 5ᵉ est "LABX" (partie PROLONGÉE du tracé labs, ρ constant)
|
||||||
val curves by produceState(
|
val curves by produceState(
|
||||||
emptyList<Pair<String, List<LevelPoint>>>(),
|
emptyList<ChartCurve>(),
|
||||||
autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast,
|
autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast,
|
||||||
showLabTrack, showLabTrackExtend,
|
showLabTrack, showLabTrackExtend, showCloud,
|
||||||
// Clé = la liste ENTIÈRE des labs (pas .size, fix #66) : éditer la
|
// Clé = la liste ENTIÈRE des labs (pas .size, fix #66) : éditer la
|
||||||
// VALEUR d'une analyse doit recalculer les courbes
|
// VALEUR d'une analyse doit recalculer les courbes
|
||||||
labResults
|
labResults
|
||||||
) {
|
) {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
val list = mutableListOf<Pair<String, List<LevelPoint>>>()
|
val list = mutableListOf<ChartCurve>()
|
||||||
// ── v1.5.0 : courbe « Tracé labs » ───────────────────────────────
|
// ── v1.5.0 : courbe « Tracé labs » ───────────────────────────────
|
||||||
// Totalement indépendante des toggles de modèles (elle utilise le
|
// Totalement indépendante des toggles de modèles (elle utilise le
|
||||||
// modèle STOCKÉ des traitements — sémantique Home, cf doc §7.10)
|
// modèle STOCKÉ des traitements — sémantique Home, cf doc §7.10)
|
||||||
@ -309,10 +328,10 @@ fun ChartScreen() {
|
|||||||
if (showLabTrackExtend && lastAnchor != null) {
|
if (showLabTrackExtend && lastAnchor != null) {
|
||||||
val anchored = labCurve.points.filter { it.timestamp <= lastAnchor }
|
val anchored = labCurve.points.filter { it.timestamp <= lastAnchor }
|
||||||
val extended = labCurve.points.filter { it.timestamp > lastAnchor }
|
val extended = labCurve.points.filter { it.timestamp > lastAnchor }
|
||||||
list.add("LAB" to anchored)
|
list.add(ChartCurve("LAB", anchored))
|
||||||
if (extended.isNotEmpty()) list.add("LABX" to extended)
|
if (extended.isNotEmpty()) list.add(ChartCurve("LABX", extended))
|
||||||
} else {
|
} else {
|
||||||
list.add("LAB" to labCurve.points)
|
list.add(ChartCurve("LAB", labCurve.points))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (showEse || showTfs || showWhs) {
|
if (showEse || showTfs || showWhs) {
|
||||||
@ -336,22 +355,29 @@ fun ChartScreen() {
|
|||||||
for ((model, enabled) in listOf("ESE" to showEse, "TFS" to showTfs, "WHS" to showWhs)) {
|
for ((model, enabled) in listOf("ESE" to showEse, "TFS" to showTfs, "WHS" to showWhs)) {
|
||||||
if (!enabled) continue
|
if (!enabled) continue
|
||||||
val autoM = autoByModel[model]
|
val autoM = autoByModel[model]
|
||||||
list.add(
|
val curve = PharmacokineticEngine.computeCurve(
|
||||||
model to PharmacokineticEngine.computeCurve(
|
effTreatments, allDoses, startMs, endMs,
|
||||||
effTreatments, allDoses, startMs, endMs,
|
stepMs = step,
|
||||||
stepMs = step,
|
tConfig = autoM?.tConfig ?: effTConfig, modelOverride = model,
|
||||||
tConfig = autoM?.tConfig ?: effTConfig, modelOverride = model,
|
scalePerEster = autoM?.esterScales,
|
||||||
scalePerEster = autoM?.esterScales,
|
tKPerEster = autoM?.tKPerEster
|
||||||
tKPerEster = autoM?.tKPerEster
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
// v1.9.0 : le nuage d'incertitude est attaché à la série
|
||||||
|
// ESE (exclusif à ce modèle — les fits TFS/WHSAH ne
|
||||||
|
// publient pas de posterior MCMC)
|
||||||
|
val cloud = if (model == "ESE" && showCloud) {
|
||||||
|
EstrannaiseCloud.compute(
|
||||||
|
treatments, doseLogs, startMs, endMs, step, nowMs
|
||||||
|
)
|
||||||
|
} else emptyList()
|
||||||
|
list.add(ChartCurve(model, curve, cloud))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
value = list
|
value = list
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val allPoints = curves.flatMap { it.second }
|
val allPoints = curves.flatMap { it.points }
|
||||||
val (a, b) = if (allPoints.size >= 2) {
|
val (a, b) = if (allPoints.size >= 2) {
|
||||||
allPoints.minOf { it.timestamp } to allPoints.maxOf { it.timestamp }
|
allPoints.minOf { it.timestamp } to allPoints.maxOf { it.timestamp }
|
||||||
} else 0L to 0L
|
} else 0L to 0L
|
||||||
@ -363,9 +389,9 @@ fun ChartScreen() {
|
|||||||
|
|
||||||
// Série visuelle : E2 Estrannaise = bleu plein, E2 TFS = turquoise plein ;
|
// Série visuelle : E2 Estrannaise = bleu plein, E2 TFS = turquoise plein ;
|
||||||
// T Estrannaise = rose plein, T TFS = rose pointillé.
|
// T Estrannaise = rose plein, T TFS = rose pointillé.
|
||||||
val series = curves.map { (model, points) ->
|
val series = curves.map { (model, points, cloud) ->
|
||||||
when (model) {
|
when (model) {
|
||||||
"ESE" -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT))
|
"ESE" -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT), cloud = curves.first { it.model == "ESE" }.cloud)
|
||||||
// v1.4.6 : WHSAH = E2 vert plein, T violet pointillé
|
// v1.4.6 : WHSAH = E2 vert plein, T violet pointillé
|
||||||
"WHS" -> ChartSeries(points, CurveStyle(ChartWhs), CurveStyle(ChartTWhs, dashed = true))
|
"WHS" -> ChartSeries(points, CurveStyle(ChartWhs), CurveStyle(ChartTWhs, dashed = true))
|
||||||
// v1.5.0 : « Tracé labs » = E2 rose foncé pointillé, PAS de courbe T
|
// v1.5.0 : « Tracé labs » = E2 rose foncé pointillé, PAS de courbe T
|
||||||
@ -490,7 +516,12 @@ fun ChartScreen() {
|
|||||||
) {
|
) {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = showEse,
|
selected = showEse,
|
||||||
onClick = { showEse = !showEse },
|
onClick = {
|
||||||
|
showEse = !showEse
|
||||||
|
// v1.9.0 : le nuage est exclusif à ESE — éteindre ESE
|
||||||
|
// éteint le nuage (sinon il flotterait sans sa courbe)
|
||||||
|
if (!showEse) showCloud = false
|
||||||
|
},
|
||||||
label = { Text(stringResource(R.string.model_ese)) }
|
label = { Text(stringResource(R.string.model_ese)) }
|
||||||
)
|
)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
@ -519,6 +550,16 @@ fun ChartScreen() {
|
|||||||
enabled = showLabTrack,
|
enabled = showLabTrack,
|
||||||
label = { Text(stringResource(R.string.show_lab_track_extend)) }
|
label = { Text(stringResource(R.string.show_lab_track_extend)) }
|
||||||
)
|
)
|
||||||
|
// v1.9.0 : NUAGE D'INCERTITUDE (exclusif ESE) — off par défaut,
|
||||||
|
// activable à volonté mais SEULEMENT si ESE est affiché (même
|
||||||
|
// pattern que Prolonger/Tracé labs) ; éteindre ESE coupe le
|
||||||
|
// nuage automatiquement (sinon le nuage flotterait sans sa courbe)
|
||||||
|
FilterChip(
|
||||||
|
selected = showCloud,
|
||||||
|
onClick = { showCloud = !showCloud },
|
||||||
|
enabled = showEse,
|
||||||
|
label = { Text(stringResource(R.string.show_cloud)) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
@ -688,18 +729,27 @@ fun ChartScreen() {
|
|||||||
}
|
}
|
||||||
// v1.5.0 : légende « Tracé labs » — seulement si la série
|
// v1.5.0 : légende « Tracé labs » — seulement si la série
|
||||||
// a ≥ 2 points (≥ 2 labs significatifs, la grille est vide sinon)
|
// a ≥ 2 points (≥ 2 labs significatifs, la grille est vide sinon)
|
||||||
if (showLabTrack && curves.any { it.first == "LAB" && it.second.size >= 2 }) {
|
if (showLabTrack && curves.any { it.model == "LAB" && it.points.size >= 2 }) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.legend_lab_track),
|
stringResource(R.string.legend_lab_track),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = ChartLabTrajectory
|
color = ChartLabTrajectory
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// v1.9.0 : légende du nuage d'incertitude (ESE) — visible
|
||||||
|
// tant que le nuage est affiché (série ESE avec cloud non vide)
|
||||||
|
if (showCloud && showEse && curves.any { it.model == "ESE" && it.cloud.isNotEmpty() }) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.legend_cloud),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = ChartE2.copy(alpha = 0.55f)
|
||||||
|
)
|
||||||
|
}
|
||||||
// v1.6.0 : légende de la partie PROLONGÉE + AVERTISSEMENT —
|
// v1.6.0 : légende de la partie PROLONGÉE + AVERTISSEMENT —
|
||||||
// seulement si la série "LABX" existe (chip Prolonger actif
|
// seulement si la série "LABX" existe (chip Prolonger actif
|
||||||
// ET horizon de prolongation atteint dans la fenêtre visible)
|
// ET horizon de prolongation atteint dans la fenêtre visible)
|
||||||
val labExtendVisible = showLabTrack && showLabTrackExtend &&
|
val labExtendVisible = showLabTrack && showLabTrackExtend &&
|
||||||
curves.any { it.first == "LABX" && it.second.size >= 2 }
|
curves.any { it.model == "LABX" && it.points.size >= 2 }
|
||||||
if (labExtendVisible) {
|
if (labExtendVisible) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.legend_lab_track_extend),
|
stringResource(R.string.legend_lab_track_extend),
|
||||||
|
|||||||
@ -44,6 +44,10 @@
|
|||||||
<!-- v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in,
|
<!-- v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in,
|
||||||
actif seulement quand Tracé labs est ON) -->
|
actif seulement quand Tracé labs est ON) -->
|
||||||
<string name="show_lab_track_extend">Prolonger</string>
|
<string name="show_lab_track_extend">Prolonger</string>
|
||||||
|
<!-- v1.9.0 : nuage d'incertitude du modèle ESE (posterior MCMC de
|
||||||
|
estrannaise.js) — exclusif à ESE, off par défaut -->
|
||||||
|
<string name="show_cloud">Nuage</string>
|
||||||
|
<string name="legend_cloud">Nuage pointillé = incertitude des paramètres MCMC de l\'Estrannaise (modèle ESE seul)</string>
|
||||||
<string name="zoom_in">Zoom avant (fenêtre plus courte)</string>
|
<string name="zoom_in">Zoom avant (fenêtre plus courte)</string>
|
||||||
<string name="zoom_out">Zoom arrière (fenêtre plus longue)</string>
|
<string name="zoom_out">Zoom arrière (fenêtre plus longue)</string>
|
||||||
<string name="hrt_since">Sous THS depuis le %1$s</string>
|
<string name="hrt_since">Sous THS depuis le %1$s</string>
|
||||||
|
|||||||
@ -44,6 +44,10 @@
|
|||||||
<!-- v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in,
|
<!-- v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in,
|
||||||
actif seulement quand Lab track est ON) -->
|
actif seulement quand Lab track est ON) -->
|
||||||
<string name="show_lab_track_extend">Extend</string>
|
<string name="show_lab_track_extend">Extend</string>
|
||||||
|
<!-- v1.9.0 : nuage d'incertitude du modèle ESE (posterior MCMC de
|
||||||
|
estrannaise.js) — exclusif à ESE, off par défaut -->
|
||||||
|
<string name="show_cloud">Cloud</string>
|
||||||
|
<string name="legend_cloud">Dotted cloud = Estrannaise MCMC parameter uncertainty (ESE model only)</string>
|
||||||
<string name="zoom_in">Zoom in (shorter window)</string>
|
<string name="zoom_in">Zoom in (shorter window)</string>
|
||||||
<string name="zoom_out">Zoom out (longer window)</string>
|
<string name="zoom_out">Zoom out (longer window)</string>
|
||||||
<string name="hrt_since">Under HRT since %1$s</string>
|
<string name="hrt_since">Under HRT since %1$s</string>
|
||||||
|
|||||||
@ -48,8 +48,9 @@ class CalibrationPerModelTest {
|
|||||||
// (prédiction ≤ 0,5) → échelles vides. Les modèles TFS/WHS sont
|
// (prédiction ≤ 0,5) → échelles vides. Les modèles TFS/WHS sont
|
||||||
// purs (formes closes) et n'en dépendent pas.
|
// purs (formes closes) et n'en dépendent pas.
|
||||||
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
|
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
|
||||||
val file = java.io.File("src/main/assets/pk_profiles.json")
|
val file = java.io.File("src/test/assets/pk_profiles.json")
|
||||||
.takeIf { it.exists() }
|
.takeIf { it.exists() }
|
||||||
|
?: java.io.File("src/main/assets/pk_profiles.json").takeIf { it.exists() }
|
||||||
?: java.io.File("app/src/main/assets/pk_profiles.json")
|
?: java.io.File("app/src/main/assets/pk_profiles.json")
|
||||||
PKProfileStore.initWithJson(file.readText())
|
PKProfileStore.initWithJson(file.readText())
|
||||||
}
|
}
|
||||||
|
|||||||
109
app/src/test/java/com/hormonetrack/pk/EstrannaiseCloudTest.kt
Normal file
109
app/src/test/java/com/hormonetrack/pk/EstrannaiseCloudTest.kt
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
package com.hormonetrack.pk
|
||||||
|
|
||||||
|
import com.hormonetrack.data.model.AdministrationRoute
|
||||||
|
import com.hormonetrack.data.model.DoseLog
|
||||||
|
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.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du NUAGE D'INCERTITUDE (v1.9.0 — cf doc §7.12) :
|
||||||
|
* [EstrannaiseCloud.compute] — feature exclusive au modèle ESE (les fits
|
||||||
|
* TFS/WHSAH ne publient pas de posterior MCMC).
|
||||||
|
*
|
||||||
|
* Épinglés :
|
||||||
|
* - 32 courbes par défaut, chacune ≥ 2 points, dans la fenêtre demandée ;
|
||||||
|
* - DISPERSION réelle : les courbes du nuage DIFFÈRENT entre elles (les
|
||||||
|
* échantillons MCMC couvrent une plage de paramètres — un nuage où toutes
|
||||||
|
* les courbes coïncideraient serait un bug de chargement) ;
|
||||||
|
* - non calculable → vide : pas de traitement ESE porteur (TFS/oral),
|
||||||
|
* MCMC non chargé, nbCurves < 2.
|
||||||
|
*/
|
||||||
|
class EstrannaiseCloudTest {
|
||||||
|
|
||||||
|
private val DAY_MS = 86_400_000L
|
||||||
|
private val NOW = 1_790_000_000_000L
|
||||||
|
|
||||||
|
/** Traitement EEn ESE actif, 5 mg hebdo sur 4 semaines. */
|
||||||
|
private fun ese() = Treatment(
|
||||||
|
id = 1, name = "EEn ESE", type = TreatmentType.ESTRADIOL,
|
||||||
|
route = AdministrationRoute.INJECTION_IM,
|
||||||
|
doseAmount = 5.0, doseUnit = "mg", esterType = "EEN", pkModel = "ESE",
|
||||||
|
forecastIntervalDays = 7.0, createdAt = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun doses() = (4 downTo 1).map { k ->
|
||||||
|
DoseLog(id = k.toLong(), treatmentId = 1, timestamp = NOW - k * 7 * DAY_MS, doseAmount = 5.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
if (EstrannaiseModels.mcmcSamples("EEN").isEmpty()) {
|
||||||
|
val candidates = listOf(
|
||||||
|
File("src/main/assets/mcmc_samples.json"),
|
||||||
|
File("app/src/main/assets/mcmc_samples.json")
|
||||||
|
)
|
||||||
|
val file = candidates.firstOrNull { it.exists() }
|
||||||
|
?: error("mcmc_samples.json introuvable (nuage d'incertitude)")
|
||||||
|
EstrannaiseModels.initWithJson(file.readText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cloud has 32 curves with real dispersion between them`() {
|
||||||
|
val cloud = EstrannaiseCloud.compute(
|
||||||
|
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW
|
||||||
|
)
|
||||||
|
assertEquals("32 courbes par défaut", 32, cloud.size)
|
||||||
|
assertTrue(cloud.all { it.size >= 2 })
|
||||||
|
// DISPERSION : le max E2 de chaque courbe diffère d'une courbe à
|
||||||
|
// l'autre (sinon tous les échantillons seraient identiques)
|
||||||
|
val peaks = cloud.map { c -> c.maxOf { it.e2 } }.distinct()
|
||||||
|
assertTrue(
|
||||||
|
"les courbes du nuage divergent (${peaks.size} maxima distincts)",
|
||||||
|
peaks.size >= 8
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cloud stays within the requested window`() {
|
||||||
|
val start = NOW - 10 * DAY_MS
|
||||||
|
val end = NOW
|
||||||
|
val cloud = EstrannaiseCloud.compute(
|
||||||
|
listOf(ese()), doses(), start, end, 3_600_000L, NOW
|
||||||
|
)
|
||||||
|
assertTrue(cloud.all { c -> c.all { it.timestamp in start..end } })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-ESE carriers produce no cloud (feature exclusive to ESE)`() {
|
||||||
|
// TFS : pas de posterior publié → nuage vide
|
||||||
|
val tfs = ese().copy(pkModel = "TFS")
|
||||||
|
assertTrue(EstrannaiseCloud.compute(listOf(tfs), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW).isEmpty())
|
||||||
|
// Oral (Bateman) : idem
|
||||||
|
val oral = Treatment(
|
||||||
|
id = 2, name = "CPA", type = TreatmentType.ANTI_ANDROGEN,
|
||||||
|
route = AdministrationRoute.ORAL, doseAmount = 12.5, doseUnit = "mg",
|
||||||
|
esterType = "NONE", forecastIntervalDays = 2.0, createdAt = 0
|
||||||
|
)
|
||||||
|
assertTrue(EstrannaiseCloud.compute(listOf(oral), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no doses on the carrier produces no cloud`() {
|
||||||
|
assertTrue(EstrannaiseCloud.compute(
|
||||||
|
listOf(ese()), emptyList(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW
|
||||||
|
).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nbCurves below 2 produces no cloud`() {
|
||||||
|
assertTrue(EstrannaiseCloud.compute(
|
||||||
|
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW, nbCurves = 1
|
||||||
|
).isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
161
app/src/test/java/com/hormonetrack/pk/EstrannaiseModelsTest.kt
Normal file
161
app/src/test/java/com/hormonetrack/pk/EstrannaiseModelsTest.kt
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
package com.hormonetrack.pk
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du modèle **Estrannaise analytique** (v1.9.0 — cf doc §7.12).
|
||||||
|
*
|
||||||
|
* CONTEXTE : depuis v1.9.0, le modèle ESE n'est plus les tables horaires ODS
|
||||||
|
* mais la **forme close 3C** publiée par estrannaise.js (les mêmes sources
|
||||||
|
* dont l'ODS n'était que l'échantillonnage horaire). Chaque test épingle :
|
||||||
|
* - **FIDÉLITÉ** : la forme close reproduit les tables ODS à l'identique
|
||||||
|
* (garde anti-régression du débranchement — RMS ≈ 0) ;
|
||||||
|
* - les **cas dégénérés** de taux égaux (solutions limites portées du JS
|
||||||
|
* source — jamais NaN) ;
|
||||||
|
* - le **posterior MCMC** chargé (313 échantillons par ester) et sa t½
|
||||||
|
* analytique.
|
||||||
|
*/
|
||||||
|
class EstrannaiseModelsTest {
|
||||||
|
|
||||||
|
private val DAY_MS = 86_400_000L
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
// Tables ODS (src/test/assets depuis v1.9.0) : référence de fidélité
|
||||||
|
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||||
|
val candidates = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
|
File("src/main/assets/pk_profiles.json"),
|
||||||
|
File("app/src/test/assets/pk_profiles.json")
|
||||||
|
)
|
||||||
|
val file = candidates.firstOrNull { it.exists() }
|
||||||
|
?: error("pk_profiles.json introuvable pour les tests de fidélité")
|
||||||
|
PKProfileStore.initWithJson(file.readText())
|
||||||
|
}
|
||||||
|
// Posterior MCMC (asset runtime v1.9.0)
|
||||||
|
if (EstrannaiseModels.mcmcSamples("EV").isEmpty()) {
|
||||||
|
val candidates = listOf(
|
||||||
|
File("src/main/assets/mcmc_samples.json"),
|
||||||
|
File("app/src/main/assets/mcmc_samples.json")
|
||||||
|
)
|
||||||
|
val file = candidates.firstOrNull { it.exists() }
|
||||||
|
?: error("mcmc_samples.json introuvable (nuage d'incertitude)")
|
||||||
|
EstrannaiseModels.initWithJson(file.readText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FIDÉLITÉ (le cœur du débranchement v1.9.0) : forme close == tables ODS. */
|
||||||
|
@Test
|
||||||
|
fun `analytic shape reproduces the ODS tables exactly`() {
|
||||||
|
val params = mapOf(
|
||||||
|
"EV" to EstrannaiseModels.model("EV")!!,
|
||||||
|
"EU" to EstrannaiseModels.model("EU")!!,
|
||||||
|
"EEN" to EstrannaiseModels.model("EEN")!!
|
||||||
|
)
|
||||||
|
for ((ester, m) in params) {
|
||||||
|
val profiles = com.google.gson.JsonParser.parseString(
|
||||||
|
File("src/test/assets/pk_profiles.json").let {
|
||||||
|
if (!it.exists()) File("app/src/test/assets/pk_profiles.json").readText() else it.readText()
|
||||||
|
}
|
||||||
|
).asJsonObject.getAsJsonObject("profiles")
|
||||||
|
val key = profiles.keySet().firstOrNull {
|
||||||
|
it.lowercase().startsWith(ester.lowercase() + "_") && it.lowercase().endsWith("ese")
|
||||||
|
} ?: error("table $ester introuvable")
|
||||||
|
val arr = profiles.getAsJsonArray(key)
|
||||||
|
|
||||||
|
var maxAbs = 0.0
|
||||||
|
var sumSq = 0.0
|
||||||
|
for (h in 0..4800) { // 0 → 200 j
|
||||||
|
val analytic = EstrannaiseModels.sample(ester, h.toDouble())
|
||||||
|
val table = arr[h].asDouble
|
||||||
|
val diff = analytic - table
|
||||||
|
maxAbs = maxOf(maxAbs, kotlin.math.abs(diff))
|
||||||
|
sumSq += diff * diff
|
||||||
|
}
|
||||||
|
val rms = kotlin.math.sqrt(sumSq / 4801)
|
||||||
|
assertTrue(
|
||||||
|
"$ester : forme close == table (maxAbs=$maxAbs, rms=$rms)",
|
||||||
|
maxAbs <= 0.01 && rms <= 0.01
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Les pics publiés (tables ODS) sont reproduits par la forme close. */
|
||||||
|
@Test
|
||||||
|
fun `published peaks match the analytic curve`() {
|
||||||
|
// (pic table @ arrondi h ; scan analytique au pas de 6 min)
|
||||||
|
data class Expect(val ester: String, val peak: Double, val tPeakHours: Int)
|
||||||
|
for (e in listOf(
|
||||||
|
Expect("EV", 61.12, 45), Expect("EU", 3.44, 55), Expect("EEN", 31.35, 152)
|
||||||
|
)) {
|
||||||
|
val m = EstrannaiseModels.model(e.ester)!!
|
||||||
|
var peak = 0.0
|
||||||
|
var tPeak = 0.0
|
||||||
|
var t = 0.0
|
||||||
|
while (t <= 200.0) {
|
||||||
|
val v = EstrannaiseModels.sample(e.ester, t * 24.0)
|
||||||
|
if (v > peak) { peak = v; tPeak = t }
|
||||||
|
t += 0.25 / 24.0
|
||||||
|
}
|
||||||
|
assertEquals("pic ${e.ester}", e.peak, peak, e.peak * 0.002)
|
||||||
|
// ±3,5 h : le sommet de la table (échantillonnée à l'heure,
|
||||||
|
// arrondie) est décalé vs le pic continu (EEN : 152 → 154,5 h)
|
||||||
|
assertEquals("Tmax ${e.ester}", e.tPeakHours.toDouble(), tPeak * 24.0, 3.5)
|
||||||
|
assertNotNull(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Taux dégénérés : les solutions limites ne produisent ni NaN ni négatif. */
|
||||||
|
@Test
|
||||||
|
fun `degenerate equal rates use the limit solutions (no NaN)`() {
|
||||||
|
for (p in listOf(
|
||||||
|
EstrannaiseModels.Param(478.0, 0.236, 0.236, 1.24), // k1 == k2
|
||||||
|
EstrannaiseModels.Param(478.0, 0.236, 1.24, 0.236), // k1 == k3
|
||||||
|
EstrannaiseModels.Param(478.0, 0.236, 1.24, 1.24), // k2 == k3
|
||||||
|
EstrannaiseModels.Param(478.0, 0.5, 0.5, 0.5) // tous égaux
|
||||||
|
)) {
|
||||||
|
for (t in listOf(0.5, 1.0, 24.0, 240.0, 2400.0)) { // heures
|
||||||
|
val v = EstrannaiseModels.sampleWithParams("EV", t, p)
|
||||||
|
assertTrue("finie et ≥ 0 (t=$t, k=(${p.k1},${p.k2},${p.k3}))", v >= 0.0 && !v.isNaN())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Posterior MCMC : chargé, 313 échantillons par ester, couverture réelle. */
|
||||||
|
@Test
|
||||||
|
fun `mcmc posterior is loaded with 313 samples per injectable ester`() {
|
||||||
|
for (ester in listOf("EV", "EU", "EEN", "EC", "EB", "EUCS")) {
|
||||||
|
val samples = EstrannaiseModels.mcmcSamples(ester)
|
||||||
|
assertEquals("313 échantillons ($ester)", 313, samples.size)
|
||||||
|
// les échantillons couvrent une PLAGE (premier ≠ dernier)
|
||||||
|
assertTrue(
|
||||||
|
"le posterior $ester couvre une plage",
|
||||||
|
samples.first().d != samples.last().d
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** t½ analytique : EV = ln2/min(0,236 ; 4,85 ; 1,24) ≈ 2,94 j. */
|
||||||
|
@Test
|
||||||
|
fun `terminal half-life is analytic (ln2 over slowest rate)`() {
|
||||||
|
val tHalf = EstrannaiseModels.model("EV")!!.terminalHalfLifeDays
|
||||||
|
assertEquals(kotlin.math.ln(2.0) / 0.236, tHalf, 1e-12)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalisation par mg + gardes (dt ≤ 0, ester inconnu). */
|
||||||
|
@Test
|
||||||
|
fun `sample is normalized per mg with guards`() {
|
||||||
|
val one = EstrannaiseModels.sample("EEN", 24.0) // 1 mg
|
||||||
|
val five = 5.0 * one
|
||||||
|
assertEquals(5.0 * one, five, 1e-12) // linéarité (trivial mais épinglé)
|
||||||
|
assertEquals(0.0, EstrannaiseModels.sample("EEN", 0.0), 1e-12)
|
||||||
|
assertEquals(0.0, EstrannaiseModels.sample("EEN", -1.0), 1e-12)
|
||||||
|
assertEquals(0.0, EstrannaiseModels.sample("XXX", 24.0), 1e-12)
|
||||||
|
assertTrue(EstrannaiseModels.sample("EUCS", 24.0) > 0.0) // EUCS couvert
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -27,6 +27,7 @@ class ExtremaTest {
|
|||||||
fun setup() {
|
fun setup() {
|
||||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||||
val file = listOf(
|
val file = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
).first { it.exists() }
|
).first { it.exists() }
|
||||||
|
|||||||
@ -38,6 +38,7 @@ class LabTimingTest {
|
|||||||
fun setup() {
|
fun setup() {
|
||||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||||
val candidates = listOf(
|
val candidates = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
)
|
)
|
||||||
@ -199,17 +200,18 @@ class LabTimingTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Estrannaise carrier reads its half-life from the ODS table`() {
|
fun `Estrannaise carrier uses its ANALYTIC half-life (v1_9_0)`() {
|
||||||
// EV / tables ODS : t½ estimée depuis la pente terminale de la table —
|
// v1.9.0 : le modèle ESE est devenu analytique (forme close de
|
||||||
// finie et raisonnable, et la recommandation se calcule sans crash
|
// estrannaise.js) — la t½ = ln2/min(k1,k2,k3) des PKParameters, plus
|
||||||
|
// d'estimation par pente de table. Finie, raisonnable, et la
|
||||||
|
// recommandation se calcule sans crash.
|
||||||
val evEse = eenTfs().copy(esterType = "EV", pkModel = "ESE", forecastIntervalDays = 14.0)
|
val evEse = eenTfs().copy(esterType = "EV", pkModel = "ESE", forecastIntervalDays = 14.0)
|
||||||
val doses = listOf(
|
val doses = listOf(
|
||||||
DoseLog(id = 1, treatmentId = 1, timestamp = NOW - 70 * DAY_MS, doseAmount = 4.0),
|
DoseLog(id = 1, treatmentId = 1, timestamp = NOW - 70 * DAY_MS, doseAmount = 4.0),
|
||||||
DoseLog(id = 2, treatmentId = 1, timestamp = NOW - 56 * DAY_MS, doseAmount = 4.0)
|
DoseLog(id = 2, treatmentId = 1, timestamp = NOW - 56 * DAY_MS, doseAmount = 4.0)
|
||||||
)
|
)
|
||||||
val tHalf = PKProfileStore.terminalHalfLifeDays("EV", "ESE")
|
val tHalf = EstrannaiseModels.model("EV")!!.terminalHalfLifeDays
|
||||||
assertNotNull(tHalf)
|
assertTrue("t½ EV analytique plausible ($tHalf j)", tHalf in 0.1..200.0)
|
||||||
assertTrue("t½ EV ODS plausible ($tHalf j)", tHalf!! in 0.1..200.0)
|
|
||||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(evEse), doses, emptyList(), NOW)
|
val rec = LabTiming.nextBloodDrawRecommendation(listOf(evEse), doses, emptyList(), NOW)
|
||||||
assertNotNull(rec)
|
assertNotNull(rec)
|
||||||
assertEquals(tHalf, rec!!.terminalHalfLifeDays, 1e-9)
|
assertEquals(tHalf, rec!!.terminalHalfLifeDays, 1e-9)
|
||||||
|
|||||||
@ -40,6 +40,7 @@ class LabTrajectoryModelTest {
|
|||||||
// module (les assets Android ne sont pas sur le classpath des tests JVM)
|
// module (les assets Android ne sont pas sur le classpath des tests JVM)
|
||||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||||
val candidates = listOf(
|
val candidates = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
)
|
)
|
||||||
|
|||||||
@ -19,6 +19,7 @@ class PKProfileStoreTest {
|
|||||||
fun setup() {
|
fun setup() {
|
||||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||||
val candidates = listOf(
|
val candidates = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
)
|
)
|
||||||
|
|||||||
@ -25,6 +25,7 @@ class PharmacokineticEngineTest {
|
|||||||
fun setup() {
|
fun setup() {
|
||||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||||
val file = listOf(
|
val file = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
).first { it.exists() }
|
).first { it.exists() }
|
||||||
@ -159,7 +160,16 @@ class PharmacokineticEngineTest {
|
|||||||
LabResult(marker = "E2", value = predicted * r, unit = "pg/mL", timestamp = t)
|
LabResult(marker = "E2", value = predicted * r, unit = "pg/mL", timestamp = t)
|
||||||
}
|
}
|
||||||
val sf = PharmacokineticEngine.computeScaleFactor(tr, doses, labs)
|
val sf = PharmacokineticEngine.computeScaleFactor(tr, doses, labs)
|
||||||
assertEquals(0.9, sf!!, 0.01)
|
// DIAG v1.8.2 : comparaison e2At (chemin production) vs Σ
|
||||||
|
// concentrationOfDose (chemin computeScaleFactor)
|
||||||
|
val ratiosDiag = labs.sortedBy { it.timestamp }.map { lab ->
|
||||||
|
val viaE2At = PharmacokineticEngine.e2At(listOf(tr), doses, lab.timestamp)
|
||||||
|
val viaDirect = doses.filter { it.timestamp <= lab.timestamp }.sumOf { d ->
|
||||||
|
PharmacokineticEngine.concentrationOfDose(tr, d, lab.timestamp)
|
||||||
|
}
|
||||||
|
"e2At=%.6f/direct=%.6f/ratioSF=%.6f".format(viaE2At, viaDirect, lab.value / viaDirect)
|
||||||
|
}
|
||||||
|
assertEquals("ratios=$ratiosDiag sf=$sf", 0.9, sf!!, 0.01)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -43,6 +43,7 @@ class RegressionUserCase2Test {
|
|||||||
)
|
)
|
||||||
if (!PKProfileStore.hasProfile("EEN", "TFS")) {
|
if (!PKProfileStore.hasProfile("EEN", "TFS")) {
|
||||||
val file = listOf(
|
val file = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
).first { it.exists() }
|
).first { it.exists() }
|
||||||
|
|||||||
@ -42,6 +42,7 @@ class RegressionUserCase3Test {
|
|||||||
)
|
)
|
||||||
if (!PKProfileStore.hasProfile("EV", "TFS")) {
|
if (!PKProfileStore.hasProfile("EV", "TFS")) {
|
||||||
val file = listOf(
|
val file = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
).first { it.exists() }
|
).first { it.exists() }
|
||||||
|
|||||||
@ -50,6 +50,7 @@ class RegressionUserCase4Test {
|
|||||||
)
|
)
|
||||||
if (!PKProfileStore.hasProfile("EV", "TFS")) {
|
if (!PKProfileStore.hasProfile("EV", "TFS")) {
|
||||||
val file = listOf(
|
val file = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
).first { it.exists() }
|
).first { it.exists() }
|
||||||
|
|||||||
@ -52,6 +52,7 @@ class RegressionUserCase5Test {
|
|||||||
)
|
)
|
||||||
if (!PKProfileStore.hasProfile("EV", "TFS")) {
|
if (!PKProfileStore.hasProfile("EV", "TFS")) {
|
||||||
val file = listOf(
|
val file = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
).first { it.exists() }
|
).first { it.exists() }
|
||||||
|
|||||||
@ -40,6 +40,7 @@ class RegressionUserCaseTest {
|
|||||||
)
|
)
|
||||||
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
|
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
|
||||||
val file = listOf(
|
val file = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
).first { it.exists() }
|
).first { it.exists() }
|
||||||
|
|||||||
@ -49,8 +49,9 @@ class ScaleFactorWhsahReproTest {
|
|||||||
fun setup() {
|
fun setup() {
|
||||||
assumeTrue("export v1.4.2 absent (données locales)", DATA_FILE.exists())
|
assumeTrue("export v1.4.2 absent (données locales)", DATA_FILE.exists())
|
||||||
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
|
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
|
||||||
val file = File("src/main/assets/pk_profiles.json")
|
val file = File("src/test/assets/pk_profiles.json")
|
||||||
.takeIf { it.exists() }
|
.takeIf { it.exists() }
|
||||||
|
?: File("src/main/assets/pk_profiles.json").takeIf { it.exists() }
|
||||||
?: File("app/src/main/assets/pk_profiles.json")
|
?: File("app/src/main/assets/pk_profiles.json")
|
||||||
PKProfileStore.initWithJson(file.readText())
|
PKProfileStore.initWithJson(file.readText())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,6 +47,7 @@ class TransfemScienceEngineTest {
|
|||||||
// l'asset pour rester au plus près du comportement réel de l'app.
|
// l'asset pour rester au plus près du comportement réel de l'app.
|
||||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||||
val file = listOf(
|
val file = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
).first { it.exists() }
|
).first { it.exists() }
|
||||||
@ -79,14 +80,24 @@ class TransfemScienceEngineTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `EB forced to ESE model yields zero (no ODS table for this ester)`() {
|
fun `EB forced to ESE model yields a REAL curve (analytic since v1_9_0)`() {
|
||||||
|
// v1.9.0 : l'Estrannaise est devenue ANALYTIQUE (estrannaise.js) et
|
||||||
|
// couvre désormais EB (fit « EB im ») — l'ancien test épinglait
|
||||||
|
// « EB+ESE → 0, pas de table ODS pour cet ester » (obsolète)
|
||||||
val tr = treatment(Esters.EB, doseMg = 5.0)
|
val tr = treatment(Esters.EB, doseMg = 5.0)
|
||||||
val dose = DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)
|
val dose = DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)
|
||||||
val curve = PharmacokineticEngine.computeCurve(
|
val curve = PharmacokineticEngine.computeCurve(
|
||||||
listOf(tr), listOf(dose), BASE, BASE + 5 * DAY_MS,
|
listOf(tr), listOf(dose), BASE, BASE + 5 * DAY_MS,
|
||||||
tConfig = TConfig(), modelOverride = PKModels.ESTRANNAISE
|
tConfig = TConfig(), modelOverride = PKModels.ESTRANNAISE
|
||||||
)
|
)
|
||||||
assertTrue(curve.all { it.e2 == 0.0 })
|
assertTrue("EB ESE analytique produit une courbe non vide", curve.any { it.e2 > 0.0 })
|
||||||
|
// et un ester réellement NON couvert par les trois modèles → 0
|
||||||
|
val unknown = tr.copy(esterType = "XXX")
|
||||||
|
val curve2 = PharmacokineticEngine.computeCurve(
|
||||||
|
listOf(unknown), listOf(dose), BASE, BASE + 5 * DAY_MS,
|
||||||
|
tConfig = TConfig(), modelOverride = PKModels.ESTRANNAISE
|
||||||
|
)
|
||||||
|
assertTrue("ester inconnu → 0 dans les trois modèles", curve2.all { it.e2 == 0.0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -30,6 +30,7 @@ class V120FeaturesTest {
|
|||||||
fun setup() {
|
fun setup() {
|
||||||
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
|
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
|
||||||
val file = listOf(
|
val file = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
).first { it.exists() }
|
).first { it.exists() }
|
||||||
@ -263,7 +264,9 @@ class V120FeaturesTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `scalePerEster scales each dose by its own ester factor`() {
|
fun `scalePerEster scales each dose by its own ester factor`() {
|
||||||
val tr = eenTreatment().copy(esterType = Esters.EV, scaleFactor = 999.0) // ne doit PAS être utilisé
|
val tr = eenTreatment().copy(esterType = Esters.EV, scaleFactor = 999.0) // ne doit PAS être utilisé
|
||||||
val evDose = DoseLog(treatmentId = 1, timestamp = BASE - 30 * DAY, doseAmount = 4.0, esterType = Esters.EV)
|
// v1.9.0 : la dose EV doit être DANS le cutoff ESE analytique
|
||||||
|
// (10 × t½ EV = 29,4 j — l'ancien cutoff ODS était 333 j) : −10 j
|
||||||
|
val evDose = DoseLog(treatmentId = 1, timestamp = BASE - 10 * DAY, doseAmount = 4.0, esterType = Esters.EV)
|
||||||
val eenDose = DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
|
val eenDose = DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
|
||||||
val scales = mapOf("EV" to 0.5, "EEN" to 2.0)
|
val scales = mapOf("EV" to 0.5, "EEN" to 2.0)
|
||||||
val t = BASE
|
val t = BASE
|
||||||
|
|||||||
@ -41,6 +41,7 @@ class WhsahEngineTest {
|
|||||||
// les tables ODS servent au chemin ESE (test de contraste)
|
// les tables ODS servent au chemin ESE (test de contraste)
|
||||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||||
val file = listOf(
|
val file = listOf(
|
||||||
|
File("src/test/assets/pk_profiles.json"),
|
||||||
File("src/main/assets/pk_profiles.json"),
|
File("src/main/assets/pk_profiles.json"),
|
||||||
File("app/src/main/assets/pk_profiles.json")
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
).first { it.exists() }
|
).first { it.exists() }
|
||||||
|
|||||||
@ -90,7 +90,10 @@ class WhsahModelsTest {
|
|||||||
assertEquals(listOf("EV", "EU", "EEN", "EB", "EC", "ECS"),
|
assertEquals(listOf("EV", "EU", "EEN", "EB", "EC", "ECS"),
|
||||||
com.hormonetrack.data.model.Esters.choicesForModel(
|
com.hormonetrack.data.model.Esters.choicesForModel(
|
||||||
com.hormonetrack.data.model.PKModels.WHSAH))
|
com.hormonetrack.data.model.PKModels.WHSAH))
|
||||||
assertEquals(listOf("EV", "EU", "EEN"),
|
// v1.9.0 : ESE analytique (estrannaise.js) couvre 6 injectables,
|
||||||
|
// dont EUCS (undécylate suspension cristalline, exclusif à ce fit) ;
|
||||||
|
// l'ancienne couverture tables ODS n'était que EV/EU/EEn
|
||||||
|
assertEquals(listOf("EV", "EU", "EEN", "EC", "EB", "EUCS"),
|
||||||
com.hormonetrack.data.model.Esters.choicesForModel(
|
com.hormonetrack.data.model.Esters.choicesForModel(
|
||||||
com.hormonetrack.data.model.PKModels.ESTRANNAISE))
|
com.hormonetrack.data.model.PKModels.ESTRANNAISE))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,44 @@
|
|||||||
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.4.10`, …).
|
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …).
|
||||||
|
|
||||||
|
## [1.9.0] — 2026-09-18 (versionCode 38)
|
||||||
|
|
||||||
|
### Modèle Estrannaise ANALYTIQUE (abandon de l'ODS) + nuage d'incertitude MCMC
|
||||||
|
|
||||||
|
- **Le modèle ESE n'est plus les tables horaires ODS** : il est désormais la
|
||||||
|
**forme close 3C publiée par estrannaise.js** (les sources originelles
|
||||||
|
dont l'ODS n'était que l'échantillonnage horaire) — fidélité épinglée :
|
||||||
|
**RMS 0,00** vs les tables sur 0→200 j, écart de pic ≤ 0,1 %.
|
||||||
|
- **Décision** (utilisatrice) : « tous les modèles ont maintenant leurs
|
||||||
|
sources originelles — on abandonne complètement les liens avec le fichier
|
||||||
|
ODS ». Concrètement : plus aucune lecture des tables au runtime
|
||||||
|
(`concentrationOfDose` dispatche ESE → forme close ; `cutoffHours` ESE →
|
||||||
|
10 × t½ terminale analytique ; l'asset `pk_profiles.json` quitte l'APK,
|
||||||
|
−550 Ko, et ne sert plus qu'aux tests de fidélité).
|
||||||
|
- **6 esters injectables couverts** (EV, EU, EEn, EC, EB + **EUCS**,
|
||||||
|
undécylate suspension cristalline, exclusif à ce fit) — l'ancien ESE
|
||||||
|
n'en couvrait que 3. TFS reste à 7 (liste explicite : EUCS n'a pas de
|
||||||
|
V3C TFS), WHSAH à 6.
|
||||||
|
- **NUAGE D'INCERTITUDE (exclusif ESE)** : Estrannaise publie le posterior
|
||||||
|
MCMC de ses paramètres (313 échantillons/ester, asset ≈ 48 Ko) — chip
|
||||||
|
`Nuage` (off par défaut, activable à volonté, actif SEULEMENT si ESE est
|
||||||
|
affiché, coupure automatique si ESE est éteint) → nuage diffus de 32
|
||||||
|
courbes du posterior montrant la plage d'imprécision du modèle, comme
|
||||||
|
sur le site estrannaise. Dessiné en alpha faible sous les courbes, hors
|
||||||
|
échelles/labels/extrema.
|
||||||
|
- t½ terminale ESE désormais **analytique** (ln2/min(k1,k2,k3)) — la
|
||||||
|
recommandation de prise de sang n'estime plus la t½ par pente de table.
|
||||||
|
- **11 nouveaux tests** (234 au total / 204 sans les données locales) :
|
||||||
|
fidélité (RMS, pics ±0,2 %), cas dégénérés (taux égaux → limites sans
|
||||||
|
NaN), MCMC 313 × 6, nuage (32 courbes à dispersion réelle, fenêtre,
|
||||||
|
exclusivité ESE, gardes), choicesForModel ESE=6, t½ analytique.
|
||||||
|
- **Porté web** (web v1.9.0, versions sync) : `estrannaise-models.js` +
|
||||||
|
`estrannaise-cloud.js` miroirs, nuage canvas, fetch MCMC au démarrage
|
||||||
|
(l'ODS web quitte aussi le runtime), 11 tests miroirs + E2E nuage.
|
||||||
|
- Validé émulateur §16.ter (APK release, seed réel v1.7.0) : courbe ESE
|
||||||
|
analytique affichée, chips Extend/Cloud présents, recommandation
|
||||||
|
de prise de sang fonctionnelle, 0 crash.
|
||||||
|
|
||||||
## [1.8.2] — 2026-09-17 (versionCode 37)
|
## [1.8.2] — 2026-09-17 (versionCode 37)
|
||||||
|
|
||||||
### Maintenance — performance du moteur + fraîcheur de l'interface
|
### Maintenance — performance du moteur + fraîcheur de l'interface
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
4. [Environnement de build (cette machine)](#4-environnement-de-build-cette-machine)
|
4. [Environnement de build (cette machine)](#4-environnement-de-build-cette-machine)
|
||||||
5. [Architecture générale](#5-architecture-générale)
|
5. [Architecture générale](#5-architecture-générale)
|
||||||
6. [Modèle de données (Room)](#6-modèle-de-données-room) + [6.bis Sémantique isActive](#6bis-sémantique-isactive-v124--drapeau-administratif-jamais-un-filtre)
|
6. [Modèle de données (Room)](#6-modèle-de-données-room) + [6.bis Sémantique isActive](#6bis-sémantique-isactive-v124--drapeau-administratif-jamais-un-filtre)
|
||||||
7. [Moteur pharmacocinétique](#7-moteur-pharmacocinétique) + [7.8 Modèle TFS V3C](#78-modèle-tfs-v3c-v140--pktransfemsciencemodelskt) + [7.9 Modèle WHSAH](#79-modèle-whsah-v146--pkwhsahmodelskt) + [7.10 « Tracé labs »](#710--tracé-labs--v150--pklabtrajectorymodelkt) + [7.10.bis Prolongation](#710bis-prolongation-au-delà-du-dernier-lab-v160--pklabtrajectorymodelkt) + [7.11 Prise de sang recommandée](#711-recommandation-de-prochaine-prise-de-sang-v180--pklabtimingkt)
|
7. [Moteur pharmacocinétique](#7-moteur-pharmacocinétique) + [7.8 Modèle TFS V3C](#78-modèle-tfs-v3c-v140--pktransfemsciencemodelskt) + [7.9 Modèle WHSAH](#79-modèle-whsah-v146--pkwhsahmodelskt) + [7.10 « Tracé labs »](#710--tracé-labs--v150--pklabtrajectorymodelkt) + [7.10.bis Prolongation](#710bis-prolongation-au-delà-du-dernier-lab-v160--pklabtrajectorymodelkt) + [7.11 Prise de sang recommandée](#711-recommandation-de-prochaine-prise-de-sang-v180--pklabtimingkt) + [7.12 Estrannaise analytique](#712-modèle-estrannaise-analytique--nuage-mcmc-v190--pkestrannaisemodelskt-pkestrannaisecloudk t)
|
||||||
8. [Tests unitaires](#8-tests-unitaires) + [8.bis Données de test réelles : HORS dépôt](#8bis-données-de-test-réelles--hors-dépôt-local-test-data)
|
8. [Tests unitaires](#8-tests-unitaires) + [8.bis Données de test réelles : HORS dépôt](#8bis-données-de-test-réelles--hors-dépôt-local-test-data)
|
||||||
9. [Système de rappels](#9-système-de-rappels) + [9.bis Alertes de seuil](#9bis-alertes-de-seuil--reminderalertnotifierkt--worker-v142)
|
9. [Système de rappels](#9-système-de-rappels) + [9.bis Alertes de seuil](#9bis-alertes-de-seuil--reminderalertnotifierkt--worker-v142)
|
||||||
10. [UI & navigation](#10-ui--navigation)
|
10. [UI & navigation](#10-ui--navigation)
|
||||||
@ -63,6 +63,7 @@ Fonctionnalités v1 :
|
|||||||
|
|
||||||
| Date | Événement |
|
| Date | Événement |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| 17 sept. 2026 (session v1.9.0) | **Modèle Estrannaise ANALYTIQUE (abandon de l'ODS) + nuage d'incertitude MCMC (Android + web sync)** (demande) : découverte des sources originelles (github.com/WHSAH/estrannaise.js — forme close 3C + paramètres publiés + posterior MCMC 313/ester). Vérification numérique AVANT débranchement : **RMS 0,00 vs tables ODS** (l'ODS était l'échantillonnage de ces formules). ESE → analytique (dispatch, cutoff 10×t½, t½ LabTiming), PKProfileStore retiré du runtime (asset ODS → src/test/assets, −550 Ko d'APK), ESE couvre 6 esters (+EUCS), TFS = liste explicite (EUCS sans V3C TFS). **Nuage MCMC exclusif ESE** : chip `Nuage` (off, activable si ESE affiché, coupure auto si ESE off), 32 courbes du posterior déterministes, alpha faible sous les courbes, hors échelles/labels/extrema. 11 nouveaux tests (234 verts / 204 sans données locales) + lint + check web (166). Validé émulateur §16.ter : courbe ESE analytique affichée, unités, chips Extend/Cloud, 0 crash. |
|
||||||
| 17 sept. 2026 (session v1.8.2) | **Maintenance — audit complet code + doc** : (a) **perf moteur** : cutoffHours pré-calculé par traitement dans e2At/computeCurve (le chemin chaud du tracé labs recalculait ~430 k appels/refresh), Bateman paresseux (inutile pour les traitements à profil), doses pré-groupées par traitement — résultat identique (ordre de sommation préservé, 223 tests verts inchangés) ; (b) **2 fixes de fraîcheur** (#65 accueil : clés produceState sans allTreatments/tConfig — éditer un scaleFactor n'actualisait pas la courbe avant le tick ; #66 graphiques : clé `labResults.size` — éditer la valeur d'un lab ne recalculait pas) ; (c) **nettoyage** : ~9 imports morts, qualifications raccourcies, code mort (Repository/DAO, currentLevel, scheduler2), médiane factorisée (4 copies), SimpleDateFormat hors boucle, AppLog sans recopie, Regex précompilées, formatDose dédupliqué ; (d) **doc** : ~35 corrections (compteurs actuels 223/193 partout, TOC + ancres réparées, inventaire §8 à jour et fusionné, doublons CHANGELOG, §19 backup, fautes). Audit préalable par double exploration lecture-seule (46 fichiers + 4 docs). 223 verts + lint + émulateur smoke. Android seul (web non concerné — re-render complet, aucune release web). |
|
| 17 sept. 2026 (session v1.8.2) | **Maintenance — audit complet code + doc** : (a) **perf moteur** : cutoffHours pré-calculé par traitement dans e2At/computeCurve (le chemin chaud du tracé labs recalculait ~430 k appels/refresh), Bateman paresseux (inutile pour les traitements à profil), doses pré-groupées par traitement — résultat identique (ordre de sommation préservé, 223 tests verts inchangés) ; (b) **2 fixes de fraîcheur** (#65 accueil : clés produceState sans allTreatments/tConfig — éditer un scaleFactor n'actualisait pas la courbe avant le tick ; #66 graphiques : clé `labResults.size` — éditer la valeur d'un lab ne recalculait pas) ; (c) **nettoyage** : ~9 imports morts, qualifications raccourcies, code mort (Repository/DAO, currentLevel, scheduler2), médiane factorisée (4 copies), SimpleDateFormat hors boucle, AppLog sans recopie, Regex précompilées, formatDose dédupliqué ; (d) **doc** : ~35 corrections (compteurs actuels 223/193 partout, TOC + ancres réparées, inventaire §8 à jour et fusionné, doublons CHANGELOG, §19 backup, fautes). Audit préalable par double exploration lecture-seule (46 fichiers + 4 docs). 223 verts + lint + émulateur smoke. Android seul (web non concerné — re-render complet, aucune release web). |
|
||||||
| 17 sept. 2026 (session v1.8.1) | **Fix stabilisation + suggestion sur l'Accueil** (critique v1.8.0 : « changé d'ester, de dosage ET de posologie, et l'app me disait stabilisée depuis février ») : le proxy « 1ʳᵉ dose du traitement » était aveugle aux changements récents. NOUVELLE règle `regimeStartMs` : le régime courant = la **séquence terminale de doses** à (ester effectif, dose mg, **écart inter-doses**) constants (l'écart comparé EXACTEMENT — 7 j ± 1 h casse le régime) ; tout changement récent réinitialise la stabilisation → la reco saute au premier creux post-stabilisation ; l'ester EFFECTIF de la dernière dose (override compris) alimente la carte. **+ Suggestion aussi sur l'Accueil** (demande) : carte compacte (creux daté + créneau, mention de stabilisation seulement si le régime n'était pas déjà stable). 3 nouveaux tests (223 verts / 193 sans données locales). Validé émulateur (release, seed réel) : la carte passe de « stabilisé depuis le 06/02 » à « EEN pas stabilisé avant le 29/09 — premier creux fiable », creux 27/09 22:43, 0 crash. Web v1.8.1 sync (3 tests miroirs + encart Accueil). |
|
| 17 sept. 2026 (session v1.8.1) | **Fix stabilisation + suggestion sur l'Accueil** (critique v1.8.0 : « changé d'ester, de dosage ET de posologie, et l'app me disait stabilisée depuis février ») : le proxy « 1ʳᵉ dose du traitement » était aveugle aux changements récents. NOUVELLE règle `regimeStartMs` : le régime courant = la **séquence terminale de doses** à (ester effectif, dose mg, **écart inter-doses**) constants (l'écart comparé EXACTEMENT — 7 j ± 1 h casse le régime) ; tout changement récent réinitialise la stabilisation → la reco saute au premier creux post-stabilisation ; l'ester EFFECTIF de la dernière dose (override compris) alimente la carte. **+ Suggestion aussi sur l'Accueil** (demande) : carte compacte (creux daté + créneau, mention de stabilisation seulement si le régime n'était pas déjà stable). 3 nouveaux tests (223 verts / 193 sans données locales). Validé émulateur (release, seed réel) : la carte passe de « stabilisé depuis le 06/02 » à « EEN pas stabilisé avant le 29/09 — premier creux fiable », creux 27/09 22:43, 0 crash. Web v1.8.1 sync (3 tests miroirs + encart Accueil). |
|
||||||
| 17 sept. 2026 (session v1.8.0) | **Recommandation de prochaine prise de sang (page Analyses, Android + web sync)** (demande + choix validés) : carte « Prochaine prise de sang (suggestion) » — creux exact calculé sur la courbe prévisionnelle **juste avant l'injection suivante** (+ mention « ou la veille du créneau »), **saut au premier creux STABILISÉ** (5 × t½ terminale — pas de prise intermédiaire trompeuse), filtres honnêtes (injectable actif + Posologie requis, creux jamais déjà mesuré, horizon borné → carte cachée), **invite « renseigne une Posologie »** quand un injectable actif en est dépourvu (demande explicite). Moteur : `pk/LabTiming.kt` PUR (miroir web `js/pk/lab-timing.js`) ; `PKProfileStore.terminalHalfLifeDays` (refactor de la pente d'extrapolation de `sample()` — le modèle Estrannaise lit sa t½ dans la table, TFS/WHSAH analytiquement). UI : calcul `produceState` + tick minute, disclaimer, mutuellement exclusive avec l'invite. 11 nouveaux tests (220 verts / 190 sans données locales) + lint + check web (152). Validé émulateur §16.ter (release, seed réel v1.7.0) : « creux 20/09 10:37, injection EEN 20/09 11:20, EEN stabilisé depuis le 06/02 », 0 crash. |
|
| 17 sept. 2026 (session v1.8.0) | **Recommandation de prochaine prise de sang (page Analyses, Android + web sync)** (demande + choix validés) : carte « Prochaine prise de sang (suggestion) » — creux exact calculé sur la courbe prévisionnelle **juste avant l'injection suivante** (+ mention « ou la veille du créneau »), **saut au premier creux STABILISÉ** (5 × t½ terminale — pas de prise intermédiaire trompeuse), filtres honnêtes (injectable actif + Posologie requis, creux jamais déjà mesuré, horizon borné → carte cachée), **invite « renseigne une Posologie »** quand un injectable actif en est dépourvu (demande explicite). Moteur : `pk/LabTiming.kt` PUR (miroir web `js/pk/lab-timing.js`) ; `PKProfileStore.terminalHalfLifeDays` (refactor de la pente d'extrapolation de `sample()` — le modèle Estrannaise lit sa t½ dans la table, TFS/WHSAH analytiquement). UI : calcul `produceState` + tick minute, disclaimer, mutuellement exclusive avec l'invite. 11 nouveaux tests (220 verts / 190 sans données locales) + lint + check web (152). Validé émulateur §16.ter (release, seed réel v1.7.0) : « creux 20/09 10:37, injection EEN 20/09 11:20, EEN stabilisé depuis le 06/02 », 0 crash. |
|
||||||
@ -643,9 +644,101 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques :
|
|||||||
disclaimer. Stabilisation hors horizon (ester ultra-long) → carte cachée.
|
disclaimer. Stabilisation hors horizon (ester ultra-long) → carte cachée.
|
||||||
- 11 tests `LabTimingTest` (8 + 3 v1.8.1 régime récent) + 1 test régression n°5.
|
- 11 tests `LabTimingTest` (8 + 3 v1.8.1 régime récent) + 1 test régression n°5.
|
||||||
|
|
||||||
|
### 7.12 Modèle Estrannaise ANALYTIQUE + nuage MCMC (v1.9.0) — `pk/EstrannaiseModels.kt`, `pk/EstrannaiseCloud.kt`
|
||||||
|
|
||||||
|
**DÉCISION v1.9.0** (utilisatrice) : « tous les modèles ont maintenant leurs
|
||||||
|
sources originelles — on abandonne complètement les liens avec le fichier
|
||||||
|
ODS, qui lui-même était une extrapolation de ces mêmes sources ». TFS
|
||||||
|
(v1.4.0) et WHSAH (v1.4.6) étaient déjà analytiques ; **l'Estrannaise le
|
||||||
|
devient à son tour**.
|
||||||
|
|
||||||
|
- **SOURCE** : https://github.com/WHSAH/estrannaise.js/ (site estrannaise) —
|
||||||
|
modèle 3 compartiments `dB/dt=−k1·B ; dEE/dt=k1·B−k2·EE ; dE2/dt=k2·EE−k3·E2`
|
||||||
|
en forme close : `C(t) = dose·d·k1·k2·[e^(−k1t)/((k1−k2)(k1−k3)) −
|
||||||
|
e^(−k2t)/((k1−k2)(k2−k3)) + e^(−k3t)/((k1−k3)(k2−k3))]` (t en JOURS).
|
||||||
|
Paramètres publiés par ester : EV [478, 0,236, 4,85, 1,24], EU
|
||||||
|
[471,5, 0,01729, 6,528, 2,285], EEn [191,4, 0,119, 0,601, 0,402], EC
|
||||||
|
[246, 0,0825, 3,57, 0,669], EB [1893,1, 0,67, 61,5, 4,34], EUCS
|
||||||
|
[16,15, 0,046, 0,022, 0,101] (nouveau code ester v1.9.0 : undécylate
|
||||||
|
suspension cristalline « EUn casubq »).
|
||||||
|
- **FIDÉLITÉ (vérifiée AVANT débranchement)** : la forme close reproduit les
|
||||||
|
tables ODS **RMS 0,00** sur 0→200 j (EV/EU/EEn), écart de pic ≤ 0,1 % —
|
||||||
|
l'ODS n'était que l'échantillonnage horaire de ces formules. Épinglé par
|
||||||
|
`EstrannaiseModelsTest` (Android) et `estrannaise-models.test.js` (web).
|
||||||
|
- **Débranchement runtime** : `concentrationOfDose` dispatche ESE →
|
||||||
|
[EstrannaiseModels.sample] (plus de fallback
|
||||||
|
[PKProfileStore.sample]) ; `cutoffHours` ESE → 10 × t½ terminale
|
||||||
|
analytique (plus la longueur de table) ; [LabTiming] t½ ESE analytique ;
|
||||||
|
[PKProfileStore.init] retirée du démarrage. **L'asset `pk_profiles.json`
|
||||||
|
quitte `src/main/assets` (−550 Ko d'APK) pour `src/test/assets/`** :
|
||||||
|
l'ODS survit UNIQUEMENT comme référence des tests de fidélité.
|
||||||
|
[PKProfileStore] est conservé (lecteur pur) pour ces tests.
|
||||||
|
- **Couverture étendue** : ESE passe de 3 à **6 esters injectables**
|
||||||
|
(EV/EU/EEn/EC/EB/**EUCS**) — `Esters.choicesForModel` mis à jour (TFS
|
||||||
|
reste 7, WHSAH 6 ; ⚠️ TFS = liste EXPLICITE : EUCS n'a pas de V3C TFS).
|
||||||
|
- **NUAGE D'INCERTITUDE (exclusif ESE, demande v1.9.0)** : Estrannaise
|
||||||
|
publie le **posterior MCMC** de ses paramètres (313 échantillons
|
||||||
|
`(d, k1, k2, k3)` par ester — asset `mcmc_samples.json` ≈ 48 Ko, chargé
|
||||||
|
au démarrage). Chip `Nuage` du graphique (off par défaut, activable à
|
||||||
|
volonté, ACTIVABLE SEULEMENT si ESE est affiché — éteindre ESE coupe le
|
||||||
|
nuage) → [EstrannaiseCloud.compute] trace 32 courbes du posterior
|
||||||
|
(échelonnées, déterministes) superposant les doses des porteurs ESE.
|
||||||
|
Dessin en alpha faible SOUS les courbes ; hors échelles/labels/extrema
|
||||||
|
(une plage d'imprécision, pas des données à cadrer). Limites : les
|
||||||
|
contributions TFS/WHSAH ne sont pas dans le nuage (pas de posterior
|
||||||
|
publié pour eux) ; les patchs du repo (tw/ow) ne sont pas portés.
|
||||||
|
- **Web sync** : `js/pk/estrannaise-models.js` + `js/pk/estrannaise-cloud.js`
|
||||||
|
miroirs ; le fetch des tables ODS au démarrage web est remplacé par le
|
||||||
|
fetch MCMC (démarrage plus rapide) ; nuage dessiné par `chart-canvas.js`.
|
||||||
|
|
||||||
|
### 7.12 Modèle Estrannaise ANALYTIQUE + nuage MCMC (v1.9.0) — `pk/EstrannaiseModels.kt`, `pk/EstrannaiseCloud.kt`
|
||||||
|
|
||||||
|
**DÉCISION v1.9.0** (utilisatrice) : « tous les modèles ont maintenant leurs
|
||||||
|
sources originelles — on abandonne complètement les liens avec le fichier
|
||||||
|
ODS, qui lui-même était une extrapolation de ces mêmes sources ». TFS
|
||||||
|
(v1.4.0) et WHSAH (v1.4.6) étaient déjà analytiques ; **l'Estrannaise le
|
||||||
|
devient à son tour**.
|
||||||
|
|
||||||
|
- **SOURCE** : https://github.com/WHSAH/estrannaise.js/ — modèle 3
|
||||||
|
compartiments en forme close : `C(t) = dose·d·k1·k2·[e^(−k1t)/((k1−k2)(k1−k3))
|
||||||
|
− e^(−k2t)/((k1−k2)(k2−k3)) + e^(−k3t)/((k1−k3)(k2−k3))]` (t en JOURS).
|
||||||
|
Paramètres publiés par ester : EV [478, 0,236, 4,85, 1,24], EU [471,5,
|
||||||
|
0,01729, 6,528, 2,285], EEn [191,4, 0,119, 0,601, 0,402], EC [246,
|
||||||
|
0,0825, 3,57, 0,669], EB [1893,1, 0,67, 61,5, 4,34], EUCS [16,15,
|
||||||
|
0,046, 0,022, 0,101] (nouveau code ester v1.9.0 : « EUn casubq »).
|
||||||
|
- **FIDÉLITÉ (vérifiée AVANT débranchement)** : la forme close reproduit
|
||||||
|
les tables ODS **RMS 0,00** sur 0→200 j (EV/EU/EEn), écart de pic
|
||||||
|
≤ 0,1 % — l'ODS n'était que l'échantillonnage horaire de ces formules.
|
||||||
|
Épinglé par `EstrannaiseModelsTest` (Android) et
|
||||||
|
`estrannaise-models.test.js` (web).
|
||||||
|
- **Débranchement runtime** : `concentrationOfDose` dispatche ESE →
|
||||||
|
`EstrannaiseModels.sample` (plus de fallback `PKProfileStore.sample`) ;
|
||||||
|
`cutoffHours` ESE → 10 × t½ terminale analytique (plus la longueur de
|
||||||
|
table) ; `LabTiming` t½ ESE analytique ; `PKProfileStore.init` retirée
|
||||||
|
du démarrage. **L'asset `pk_profiles.json` quitte `src/main/assets`
|
||||||
|
(−550 Ko d'APK) pour `src/test/assets/`** : l'ODS survit UNIQUEMENT
|
||||||
|
comme référence des tests de fidélité. `PKProfileStore` est conservé
|
||||||
|
(lecteur pur) pour ces tests.
|
||||||
|
- **Couverture étendue** : ESE passe de 3 à **6 esters injectables**
|
||||||
|
(EV/EU/EEn/EC/EB/**EUCS**) — `Esters.choicesForModel` mis à jour (TFS
|
||||||
|
reste 7, WHSAH 6 ; ⚠️ TFS = liste EXPLICITE : EUCS n'a pas de V3C TFS).
|
||||||
|
- **NUAGE D'INCERTITUDE (exclusif ESE, demande v1.9.0)** : Estrannaise
|
||||||
|
publie le **posterior MCMC** de ses paramètres (313 échantillons par
|
||||||
|
ester — asset `mcmc_samples.json` ≈ 48 Ko, chargé au démarrage). Chip
|
||||||
|
`Nuage` du graphique (off par défaut, activable à volonté, ACTIVABLE
|
||||||
|
SEULEMENT si ESE est affiché — éteindre ESE coupe le nuage) →
|
||||||
|
`EstrannaiseCloud.compute` trace 32 courbes du posterior (échelonnées,
|
||||||
|
déterministes) superposant les doses des porteurs ESE. Dessin en alpha
|
||||||
|
faible SOUS les courbes ; hors échelles/labels/extrema. Limites : les
|
||||||
|
contributions TFS/WHSAH ne sont pas dans le nuage (pas de posterior
|
||||||
|
publié) ; les patchs du repo (tw/ow) ne sont pas portés.
|
||||||
|
- **Web sync** : `js/pk/estrannaise-models.js` + `js/pk/estrannaise-cloud.js`
|
||||||
|
miroirs ; le fetch des tables ODS au démarrage web est remplacé par le
|
||||||
|
fetch MCMC (démarrage plus rapide) ; nuage dessiné par `chart-canvas.js`.
|
||||||
|
|
||||||
## 8. Tests unitaires
|
## 8. Tests unitaires
|
||||||
|
|
||||||
**223 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **193 sans
|
**234 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **193 sans
|
||||||
les données de test locales** (cf §8.bis : les 5 classes de régression,
|
les données de test locales** (cf §8.bis : les 5 classes de régression,
|
||||||
6/6/6/5/7 tests, sont skippées via `Assume`). Dépendance : JUnit 4.13.2.
|
6/6/6/5/7 tests, 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 =
|
||||||
@ -807,6 +900,14 @@ Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'ex
|
|||||||
sans Posologie ou sans doses, agrégat multi-traitements (le plus tôt
|
sans Posologie ou sans doses, agrégat multi-traitements (le plus tôt
|
||||||
gagne), traitements inactifs/désactivés → null.
|
gagne), traitements inactifs/désactivés → null.
|
||||||
|
|
||||||
|
- **`EstrannaiseModelsTest`** (6) : v1.9.0 — modèle Estrannaise analytique :
|
||||||
|
FIDÉLITÉ aux tables ODS (maxAbs ≤ 0,01 / RMS ≤ 0,01 sur 0→200 j), pics
|
||||||
|
publiés reproduits (±0,2 %), cas dégénérés (taux égaux → limites sans
|
||||||
|
NaN), MCMC 313 × 6 esters (plage couverte), t½ analytique EV,
|
||||||
|
normalisation par mg + gardes (EUCS couvert).
|
||||||
|
- **`EstrannaiseCloudTest`** (5) : v1.9.0 — nuage d'incertitude : 32 courbes
|
||||||
|
à dispersion réelle (les posterior couvrent une plage), fenêtre respectée,
|
||||||
|
exclusivité ESE (TFS/oral → vide), sans dose → vide, nbCurves < 2 → vide.
|
||||||
- **`LabTimingTest`** (11) : v1.8.0/v1.8.1 — recommandation de prochaine
|
- **`LabTimingTest`** (11) : v1.8.0/v1.8.1 — recommandation de prochaine
|
||||||
prise de sang : creux juste avant le créneau, saut au premier creux
|
prise de sang : creux juste avant le créneau, saut au premier creux
|
||||||
STABILISÉ (5 × t½ ; régime non stabilisé → creux suivant), prise récente →
|
STABILISÉ (5 × t½ ; régime non stabilisé → creux suivant), prise récente →
|
||||||
@ -1632,6 +1733,19 @@ valeur d'un lab ne rafraîchissait pas les courbes avant le tick de 60 s. Leçon
|
|||||||
les clés d'un producteur d'état Compose doivent couvrir TOUTE entrée du calcul (pas
|
les clés d'un producteur d'état Compose doivent couvrir TOUTE entrée du calcul (pas
|
||||||
un proxy comme `.size`).
|
un proxy comme `.size`).
|
||||||
|
|
||||||
|
## 15.bis Comment régénérer l'asset mcmc_samples.json (v1.9.0)
|
||||||
|
|
||||||
|
L'asset `src/main/assets/mcmc_samples.json` (posterior MCMC de
|
||||||
|
estrannaise.js : 313 échantillons `(d, k1, k2, k3)` par ester injectable)
|
||||||
|
est extrait de `src/modeldata.js` du dépôt
|
||||||
|
https://github.com/WHSAH/estrannaise.js/ :
|
||||||
|
1. cloner le dépôt (branche main) ;
|
||||||
|
2. convertir les exports JS en JSON (node : importer `mcmcSamplesPK`, dump JSON) ;
|
||||||
|
3. mapper les clés du repo vers les clés de l'app : « EV im »→EV, « EUn im »→EU,
|
||||||
|
« EEn im »→EEn, « EC im »→EC, « EB im »→EB, « EUn casubq »→EUCS ;
|
||||||
|
4. écrire `{ester: [[d,k1,k2,k3] × 313]}` (compact, ≈ 48 Ko) ;
|
||||||
|
5. le miroir web lit `assets/mcmc_samples.json` du dépôt web (même fichier).
|
||||||
|
|
||||||
## 15. Comment régénérer l'asset pk_profiles.json
|
## 15. Comment régénérer l'asset pk_profiles.json
|
||||||
|
|
||||||
Si le `.ods` change (re-fits, nouveaux esters) :
|
Si le `.ods` change (re-fits, nouveaux esters) :
|
||||||
|
|||||||
@ -45,8 +45,9 @@ installer — pas besoin d'Android Studio ni de faire un build :
|
|||||||
1. Android Studio → **Open** → sélectionne le dossier `~/projects/HormoneTrack`
|
1. Android Studio → **Open** → sélectionne le dossier `~/projects/HormoneTrack`
|
||||||
2. Laisse le **Gradle Sync** se terminer (première fois : téléchargements, 5–15 min)
|
2. Laisse le **Gradle Sync** se terminer (première fois : téléchargements, 5–15 min)
|
||||||
- La barre du bas affiche la progression ; attends « Gradle sync finished ».
|
- La barre du bas affiche la progression ; attends « Gradle sync finished ».
|
||||||
3. Vérifie que l'asset est bien présent :
|
3. (v1.9.0) Le modèle Estrannaise est **analytique** : l'ancienne table
|
||||||
`app/src/main/assets/pk_profiles.json` (profils Estrannaise / Transfem Science extraits de ton `Estrogen.ods`).
|
`pk_profiles.json` n'est plus embarquée dans l'APK (elle ne sert qu'aux
|
||||||
|
tests de fidélité, dans `app/src/test/assets/`).
|
||||||
|
|
||||||
## 3. Préparer ton téléphone
|
## 3. Préparer ton téléphone
|
||||||
|
|
||||||
@ -157,7 +158,10 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
|
|||||||
|
|
||||||
## 7. Les modèles mathématiques
|
## 7. Les modèles mathématiques
|
||||||
|
|
||||||
- **Estrannaise** : tables horaires issues de ta feuille `Estrogen.ods` (table « Models »)
|
- **Estrannaise (v1.9.0 : analytique)** : forme close 3C publiée par
|
||||||
|
estrannaise.js — les anciennes tables du tableur en étaient
|
||||||
|
l'échantillonnage (fidélité vérifiée à l'identique) ; 6 esters injectables
|
||||||
|
couverts (EV, EU, EEn, EC, EB + EUCS en suspension cristalline)
|
||||||
- **Transfem Science** (v1.4.0) : la méta-analyse officielle à 3 compartiments
|
- **Transfem Science** (v1.4.0) : la méta-analyse officielle à 3 compartiments
|
||||||
([article](https://transfemscience.org/articles/injectable-e2-meta-analysis/),
|
([article](https://transfemscience.org/articles/injectable-e2-meta-analysis/),
|
||||||
[simulateur](https://transfemscience.org/misc/injectable-e2-simulator/)) —
|
[simulateur](https://transfemscience.org/misc/injectable-e2-simulator/)) —
|
||||||
@ -214,6 +218,10 @@ Pics de référence (dose unique de 5 mg, IM) :
|
|||||||
- **Fuseau horaire du graphique** (v1.4.5) : Paramètres → « Fuseau horaire du
|
- **Fuseau horaire du graphique** (v1.4.5) : Paramètres → « Fuseau horaire du
|
||||||
graphique » — les jours s'alignent sur minuit du fuseau choisi (vide = celui
|
graphique » — les jours s'alignent sur minuit du fuseau choisi (vide = celui
|
||||||
du téléphone)
|
du téléphone)
|
||||||
|
- **Nuage d'incertitude (v1.9.0, exclusif ESE)** : chip `Nuage` (actif
|
||||||
|
seulement quand Estrannaise est affiché) → nuage diffus de courbes du
|
||||||
|
posterior MCMC d'Estrannaise montrant la plage d'imprécision du modèle
|
||||||
|
(comme sur le site estrannaise)
|
||||||
- **Unités sur les axes** (v1.7.0) : l'axe gauche affiche « pg/mL » (E2) et
|
- **Unités sur les axes** (v1.7.0) : l'axe gauche affiche « pg/mL » (E2) et
|
||||||
l'axe droit « ng/mL » (T, avec le toggle T) au sommet des colonnes de
|
l'axe droit « ng/mL » (T, avec le toggle T) au sommet des colonnes de
|
||||||
labels — plus besoin de deviner l'unité des nombres
|
labels — plus besoin de deviner l'unité des nombres
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user