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:
Siphonight 2026-09-19 20:44:30 +02:00
parent dd220e8933
commit 8078d5cbed
36 changed files with 994 additions and 71 deletions

View File

@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue. > sang et aux consignes de ton endocrinologue.
- **Statut** : v1.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
``` ```

View File

@ -17,8 +17,8 @@ android {
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0" // tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans // (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
// les APK publiés (Paramètres + titre du dialog « Nouveautés »). // les APK publiés (Paramètres + titre du dialog « Nouveautés »).
versionCode = 37 versionCode = 38
versionName = "1.8.2" versionName = "1.9.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

File diff suppressed because one or more lines are too long

View File

@ -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 —

View File

@ -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)
} }
} }

View 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
}
}

View 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()
}

View File

@ -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

View File

@ -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) {

View File

@ -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 =

View File

@ -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

View File

@ -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),

View File

@ -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>

View File

@ -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>

View File

@ -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())
} }

View 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())
}
}

View 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
}
}

View File

@ -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() }

View File

@ -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)

View File

@ -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")
) )

View File

@ -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")
) )

View File

@ -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

View File

@ -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() }

View File

@ -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() }

View File

@ -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() }

View File

@ -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() }

View File

@ -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() }

View File

@ -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())
} }

View File

@ -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

View File

@ -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

View File

@ -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() }

View File

@ -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))
} }

View File

@ -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

View File

@ -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) :

View File

@ -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