v1.2.3 : pics/creux estimés sur le graphique (toggle) + calibration T par période d'ester
- detectExtrema : extrema locaux stricts (plateaux fusionnés), alternance pic/creux imposée, filtre d'amplitude (2 pg/mL E2, 0,02 ng/mL T) — sémantique zigzag documentée (oscillation sous le seuil = un seul pivot) - CurveChart : triangles ▲▼ aux extrema de chaque série (couleur de la série), chip « Pics / creux » + légende (désactivé par défaut) - computeTKPerEster : le k du modèle T est calibré PAR PÉRIODE D'ESTER (labs T attribués à la période de la dernière dose ≤ lab, médiane) — la suppression T diffère valerate vs enanthate ; la courbe T utilise le k de l'ester ACTIF à chaque instant (activeEsterAt + curseur de doses triées dans computeCurve) - Fix : la calibration T automatique utilise désormais l'E2 CALIBRÉE (scalePerEster) — calibrer contre l'E2 brute faussait les k - levelAt/currentLevel : paramètre tKPerEster (fallback = tConfig.k stocké) - ExtremaTest : 6 tests (alternance, monotone, plat, court, filtre d'amplitude, anti-corrélation E2/T) — 56 tests verts - versionCode 6, versionName 1.2.3 ; docs mises à jour (§7.5/§7.6, §11, README, guide)
This commit is contained in:
parent
4731e4c159
commit
5f5244d8eb
10
README.md
10
README.md
@ -9,7 +9,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.2.2 — build Android ✅, **48 tests unitaires** ✅ (2 régressions épinglées sur données réelles), intégration montre = notifications ✅, **repo git avec releases taguées** ✅
|
- **Statut** : v1.2.3 — build Android ✅, **56 tests unitaires** ✅ (2 régressions épinglées sur données réelles), intégration montre = notifications ✅, **repo git avec releases taguées** ✅
|
||||||
- **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)
|
||||||
@ -18,7 +18,8 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
|||||||
## Fonctionnalités
|
## Fonctionnalités
|
||||||
|
|
||||||
- **Courbes estimées heure par heure** : E2 (pg/mL) et T (ng/mL), vue 24 h / 7 j / 30 j,
|
- **Courbes estimées heure par heure** : E2 (pg/mL) et T (ng/mL), vue 24 h / 7 j / 30 j,
|
||||||
**panoramique** (glisser pour remonter dans le passé)
|
**panoramique** (glisser pour remonter dans le passé), **pics & creux** affichables
|
||||||
|
(triangles ▲▼ aux extrema locaux, toggle)
|
||||||
- **Deux modèles PK au choix, superposables** (issus de la feuille `Estrogen.ods`) :
|
- **Deux modèles PK au choix, superposables** (issus de la feuille `Estrogen.ods`) :
|
||||||
**Estrannaise (EstraNase)** et **Transfem Science** pour les injections EV / EU / EEn,
|
**Estrannaise (EstraNase)** et **Transfem Science** pour les injections EV / EU / EEn,
|
||||||
affichés côte à côte avec toggles indépendants
|
affichés côte à côte avec toggles indépendants
|
||||||
@ -37,8 +38,9 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
|||||||
automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester
|
automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester
|
||||||
avec les labs de sa période** (labs valerate → doses valerate, labs enanthate → doses
|
avec les labs de sa période** (labs valerate → doses valerate, labs enanthate → doses
|
||||||
enanthate) et recalibre le modèle T
|
enanthate) et recalibre le modèle T
|
||||||
- **Estimation T** empirique `T = plancher + (base − plancher) ÷ (1 + k·E2)`, calibrable
|
- **Estimation T** empirique `T = plancher + (base − plancher) ÷ (1 + k·E2)`, avec
|
||||||
avec tes résultats T (unités ng/mL, ng/dL, ng/L, nmol/L acceptées)
|
**k calibré par période d'ester** (la suppression T diffère valerate vs enanthate),
|
||||||
|
contre l'E2 déjà calibrée — unités T acceptées : ng/mL, ng/dL, ng/L, nmol/L
|
||||||
- **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ;
|
- **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ;
|
||||||
les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health)
|
les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health)
|
||||||
- **Sauvegarde/Restauration JSON** complète (traitements + doses + analyses + réglages T)
|
- **Sauvegarde/Restauration JSON** complète (traitements + doses + analyses + réglages T)
|
||||||
|
|||||||
@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "com.hormonetrack"
|
applicationId = "com.hormonetrack"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 5
|
versionCode = 6
|
||||||
versionName = "1.2.2"
|
versionName = "1.2.3"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
|
|||||||
@ -178,18 +178,42 @@ object PharmacokineticEngine {
|
|||||||
treatments: List<Treatment>,
|
treatments: List<Treatment>,
|
||||||
doseLogs: List<DoseLog>,
|
doseLogs: List<DoseLog>,
|
||||||
tMs: Long,
|
tMs: Long,
|
||||||
tConfig: TConfig
|
tConfig: TConfig,
|
||||||
|
tKPerEster: Map<String, Double>? = null
|
||||||
): LevelPoint {
|
): LevelPoint {
|
||||||
val e2 = e2At(treatments, doseLogs, tMs)
|
val e2 = e2At(treatments, doseLogs, tMs)
|
||||||
return LevelPoint(tMs, e2, testosteroneAt(e2, tConfig))
|
val k = activeEsterAt(treatments, doseLogs, tMs)
|
||||||
|
?.let { tKPerEster?.get(it) } ?: tConfig.k
|
||||||
|
return LevelPoint(tMs, e2, testosteroneAt(e2, tConfig.copy(k = k)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'ester « actif » à l'instant t = celui de la dernière dose E2 ≤ t
|
||||||
|
* (une prise de sang / un point de courbe reflète l'injection qui précède).
|
||||||
|
* null avant la première dose ou si l'ester est NONE.
|
||||||
|
*/
|
||||||
|
fun activeEsterAt(
|
||||||
|
treatments: List<Treatment>,
|
||||||
|
doseLogs: List<DoseLog>,
|
||||||
|
tMs: Long
|
||||||
|
): String? {
|
||||||
|
val last = doseLogs
|
||||||
|
.filter { d ->
|
||||||
|
d.timestamp <= tMs &&
|
||||||
|
treatments.any { it.id == d.treatmentId && it.type == TreatmentType.ESTRADIOL }
|
||||||
|
}
|
||||||
|
.maxByOrNull { it.timestamp } ?: return null
|
||||||
|
val tr = treatments.first { it.id == last.treatmentId }
|
||||||
|
return doseEster(tr, last).takeIf { it != Esters.NONE }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun currentLevel(
|
fun currentLevel(
|
||||||
treatments: List<Treatment>,
|
treatments: List<Treatment>,
|
||||||
doseLogs: List<DoseLog>,
|
doseLogs: List<DoseLog>,
|
||||||
tConfig: TConfig,
|
tConfig: TConfig,
|
||||||
|
tKPerEster: Map<String, Double>? = null,
|
||||||
nowMs: Long = System.currentTimeMillis()
|
nowMs: Long = System.currentTimeMillis()
|
||||||
): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig)
|
): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig, tKPerEster)
|
||||||
|
|
||||||
fun computeCurve(
|
fun computeCurve(
|
||||||
treatments: List<Treatment>,
|
treatments: List<Treatment>,
|
||||||
@ -201,7 +225,13 @@ object PharmacokineticEngine {
|
|||||||
/** Force un modèle PK (ESE/TFS) pour les traitements par profil. */
|
/** Force un modèle PK (ESE/TFS) pour les traitements par profil. */
|
||||||
modelOverride: String? = null,
|
modelOverride: String? = null,
|
||||||
/** Facteurs d'échelle par ester (calibration automatique, cf e2At). */
|
/** Facteurs d'échelle par ester (calibration automatique, cf e2At). */
|
||||||
scalePerEster: Map<String, Double>? = null
|
scalePerEster: Map<String, Double>? = null,
|
||||||
|
/**
|
||||||
|
* Constante k du modèle T PAR PÉRIODE D'ESTER (v1.2.3) : à chaque instant,
|
||||||
|
* le k utilisé est celui de l'ester de la dernière dose ≤ t (la suppression
|
||||||
|
* n'est pas la même selon l'ester). Fallback = tConfig.k.
|
||||||
|
*/
|
||||||
|
tKPerEster: Map<String, Double>? = null
|
||||||
): List<LevelPoint> {
|
): List<LevelPoint> {
|
||||||
if (treatments.isEmpty() || doseLogs.isEmpty() || endMs <= startMs) return emptyList()
|
if (treatments.isEmpty() || doseLogs.isEmpty() || endMs <= startMs) return emptyList()
|
||||||
|
|
||||||
@ -216,10 +246,25 @@ object PharmacokineticEngine {
|
|||||||
val batemanCache = HashMap<Long, BatemanParams>()
|
val batemanCache = HashMap<Long, BatemanParams>()
|
||||||
relevantTreatments.forEach { batemanCache[it.id] = batemanParams(it) }
|
relevantTreatments.forEach { batemanCache[it.id] = batemanParams(it) }
|
||||||
|
|
||||||
|
// Doses triées pour suivre l'« ester actif » le long de la grille (pour le k de T)
|
||||||
|
val sortedEstrogenDoses = doseLogs
|
||||||
|
.filter { d -> relevantTreatments.any { it.id == d.treatmentId } }
|
||||||
|
.sortedBy { it.timestamp }
|
||||||
|
var doseCursor = 0
|
||||||
|
var activeEster: String? = null
|
||||||
|
|
||||||
val points = mutableListOf<LevelPoint>()
|
val points = mutableListOf<LevelPoint>()
|
||||||
var t = searchStart
|
var t = searchStart
|
||||||
while (t <= endMs) {
|
while (t <= endMs) {
|
||||||
var e2 = 0.0
|
var e2 = 0.0
|
||||||
|
// Avance le curseur : la dose à t définit l'ester actif
|
||||||
|
while (doseCursor < sortedEstrogenDoses.size && sortedEstrogenDoses[doseCursor].timestamp <= t) {
|
||||||
|
val d = sortedEstrogenDoses[doseCursor]
|
||||||
|
val tr = relevantTreatments.first { it.id == d.treatmentId }
|
||||||
|
activeEster = doseEster(tr, d)
|
||||||
|
doseCursor++
|
||||||
|
}
|
||||||
|
val k = activeEster?.let { tKPerEster?.get(it) } ?: tConfig.k
|
||||||
for (treatment in relevantTreatments) {
|
for (treatment in relevantTreatments) {
|
||||||
val cutoff = cutoffHours(treatment)
|
val cutoff = cutoffHours(treatment)
|
||||||
val p = batemanCache[treatment.id]
|
val p = batemanCache[treatment.id]
|
||||||
@ -235,7 +280,7 @@ object PharmacokineticEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
points.add(LevelPoint(t, e2, testosteroneAt(e2, tConfig)))
|
points.add(LevelPoint(t, e2, testosteroneAt(e2, tConfig.copy(k = k))))
|
||||||
t += stepMs
|
t += stepMs
|
||||||
}
|
}
|
||||||
return points
|
return points
|
||||||
@ -344,14 +389,66 @@ object PharmacokineticEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calibration automatique (option « Auto-calibration » des Paramètres), v1.2.1 :
|
* Calibration du k du modèle T **PAR PÉRIODE D'ESTER** (v1.2.3).
|
||||||
* - facteurs d'échelle **par période d'ester** ([computeEsterScaleFactors]) ;
|
|
||||||
* - recalibrage du modèle T depuis les labs T.
|
|
||||||
*
|
*
|
||||||
* Tout est renvoyé en copies, UNIQUEMENT pour l'affichage : les traitements et
|
* La suppression de la testostérone n'est pas la même selon l'ester (un valerate
|
||||||
* réglages stockés ne sont jamais modifiés. Les écrans branchent le résultat via
|
* donne des pics hauts et courts, un enanthate un plateau plus doux) : chaque lab
|
||||||
* le paramètre `scalePerEster` du moteur ; pour un ester sans lab, le fallback
|
* T est attribué à la période d'injection dans laquelle il tombe (dernière dose
|
||||||
* naturel est le scaleFactor stocké du traitement.
|
* E2 ≤ lab → son ester) et le k final = médiane des k de CETTE période.
|
||||||
|
*
|
||||||
|
* L'E2 utilisé dans la formule est la version **calibrée** (scalePerEster) :
|
||||||
|
* calibrer k contre une E2 non calibrée donnerait des k faussés.
|
||||||
|
*
|
||||||
|
* @return k par ester ("EV"/"EU"/"EEN"), seulement pour les périodes ayant au
|
||||||
|
* moins un lab T exploitable (valeur > floor, E2 estimée > 1 pg/mL, k ∈ (1e-4, 10)).
|
||||||
|
*/
|
||||||
|
fun computeTKPerEster(
|
||||||
|
treatments: List<Treatment>,
|
||||||
|
doseLogs: List<DoseLog>,
|
||||||
|
tLabs: List<LabResult>,
|
||||||
|
current: TConfig,
|
||||||
|
scalePerEster: Map<String, Double>? = null
|
||||||
|
): Map<String, Double> {
|
||||||
|
val estrogenDoses = doseLogs.filter { d ->
|
||||||
|
treatments.any { it.id == d.treatmentId && it.type == TreatmentType.ESTRADIOL }
|
||||||
|
}
|
||||||
|
if (estrogenDoses.isEmpty()) return emptyMap()
|
||||||
|
|
||||||
|
val ksByEster = HashMap<String, MutableList<Double>>()
|
||||||
|
for (lab in tLabs) {
|
||||||
|
val tNgMl = convertTToNgMl(lab.value, lab.unit)
|
||||||
|
if (tNgMl <= current.floor + 0.02) continue
|
||||||
|
val attributed = estrogenDoses
|
||||||
|
.filter { it.timestamp <= lab.timestamp }
|
||||||
|
.maxByOrNull { it.timestamp } ?: continue
|
||||||
|
val tr = treatments.first { it.id == attributed.treatmentId }
|
||||||
|
val ester = doseEster(tr, attributed)
|
||||||
|
if (ester == Esters.NONE) continue
|
||||||
|
|
||||||
|
val e2 = e2At(treatments, doseLogs, lab.timestamp, scalePerEster = scalePerEster)
|
||||||
|
if (e2 <= 1.0) continue
|
||||||
|
val k = ((current.base - current.floor) / (tNgMl - current.floor) - 1.0) / e2
|
||||||
|
if (k > 1e-4 && k < 10.0) {
|
||||||
|
ksByEster.getOrPut(ester) { mutableListOf() }.add(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ksByEster.mapValues { (_, ks) ->
|
||||||
|
val sorted = ks.sorted()
|
||||||
|
val median = if (sorted.size % 2 == 1) sorted[sorted.size / 2]
|
||||||
|
else (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2.0
|
||||||
|
(median * 1000).toInt() / 1000.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calibration automatique (option « Auto-calibration » des Paramètres), v1.2.3 :
|
||||||
|
* - facteurs d'échelle E2 **par période d'ester** ([computeEsterScaleFactors]) ;
|
||||||
|
* - k du modèle T **par période d'ester** ([computeTKPerEster]), calibré contre
|
||||||
|
* l'E2 déjà calibrée — la suppression T diffère selon l'ester.
|
||||||
|
*
|
||||||
|
* Tout est renvoyé en copies/paramètres, UNIQUEMENT pour l'affichage : les
|
||||||
|
* traitements et réglages stockés ne sont jamais modifiés. Pour un ester sans
|
||||||
|
* lab : E2 → scaleFactor stocké, T → k stocké (tConfig.k).
|
||||||
*/
|
*/
|
||||||
data class AutoCalibrated(
|
data class AutoCalibrated(
|
||||||
val treatments: List<Treatment>,
|
val treatments: List<Treatment>,
|
||||||
@ -360,8 +457,10 @@ object PharmacokineticEngine {
|
|||||||
val esterScales: Map<String, Double>,
|
val esterScales: Map<String, Double>,
|
||||||
/** Nombre d'esters calibrés avec au moins un lab exploitable. */
|
/** Nombre d'esters calibrés avec au moins un lab exploitable. */
|
||||||
val calibratedEsters: Int,
|
val calibratedEsters: Int,
|
||||||
/** true si le modèle T a pu être recalibré. */
|
/** true si le modèle T a pu être recalibré (au moins un ester avec des labs T). */
|
||||||
val tRecalibrated: Boolean
|
val tRecalibrated: Boolean,
|
||||||
|
/** k du modèle T par ester, recalculé depuis les labs T. */
|
||||||
|
val tKPerEster: Map<String, Double>
|
||||||
)
|
)
|
||||||
|
|
||||||
fun autoCalibrated(
|
fun autoCalibrated(
|
||||||
@ -374,13 +473,16 @@ object PharmacokineticEngine {
|
|||||||
val tLabs = labs.filter { it.marker.equals("T", true) }
|
val tLabs = labs.filter { it.marker.equals("T", true) }
|
||||||
|
|
||||||
val esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs)
|
val esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs)
|
||||||
val newT = computeTConfigCalibration(tLabs, treatments, doseLogs, tConfig)
|
val tKPerEster = computeTKPerEster(
|
||||||
|
treatments, doseLogs, tLabs, tConfig, scalePerEster = esterScales
|
||||||
|
)
|
||||||
return AutoCalibrated(
|
return AutoCalibrated(
|
||||||
treatments = treatments,
|
treatments = treatments,
|
||||||
tConfig = newT ?: tConfig,
|
tConfig = tConfig,
|
||||||
esterScales = esterScales,
|
esterScales = esterScales,
|
||||||
calibratedEsters = esterScales.size,
|
calibratedEsters = esterScales.size,
|
||||||
tRecalibrated = newT != null
|
tRecalibrated = tKPerEster.isNotEmpty(),
|
||||||
|
tKPerEster = tKPerEster
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -471,4 +573,64 @@ object PharmacokineticEngine {
|
|||||||
|
|
||||||
fun isInjectionRoute(route: AdministrationRoute): Boolean =
|
fun isInjectionRoute(route: AdministrationRoute): Boolean =
|
||||||
route == AdministrationRoute.INJECTION_IM || route == AdministrationRoute.INJECTION_SUBCUT
|
route == AdministrationRoute.INJECTION_IM || route == AdministrationRoute.INJECTION_SUBCUT
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Détection des pics et creux (v1.2.3) — pour l'affichage sur le graphique
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Un extremum détecté : pic (isPeak = true) ou creux, avec sa valeur. */
|
||||||
|
data class Extremum(val timestamp: Long, val value: Double, val isPeak: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détecte les maxima et minima LOCAUX d'une courbe (les pics après chaque
|
||||||
|
* injection et les creux juste avant la suivante, en régime d'équilibre).
|
||||||
|
*
|
||||||
|
* - les plateaux (valeurs égales consécutives) comptent pour un seul extremum ;
|
||||||
|
* - les extrema consécutifs de même type sont fusionnés (le plus extrême gagne) ;
|
||||||
|
* - les oscillations d'amplitude < minSwingAbs sont ignorées (bruit d'arrondi) —
|
||||||
|
* utiliser ~2 pg/mL pour l'E2 et ~0,02 ng/mL pour la T.
|
||||||
|
*
|
||||||
|
* @param selector ce qu'on analyse (`it.e2` ou `it.t` d'un LevelPoint).
|
||||||
|
*/
|
||||||
|
fun detectExtrema(
|
||||||
|
points: List<LevelPoint>,
|
||||||
|
selector: (LevelPoint) -> Double,
|
||||||
|
minSwingAbs: Double = 2.0
|
||||||
|
): List<Extremum> {
|
||||||
|
if (points.size < 3) return emptyList()
|
||||||
|
val values = points.map(selector)
|
||||||
|
|
||||||
|
// 1) extrema locaux stricts (un plateau = son premier point)
|
||||||
|
val raw = mutableListOf<Pair<Int, Boolean>>()
|
||||||
|
var i = 1
|
||||||
|
while (i < values.size - 1) {
|
||||||
|
val v = values[i]
|
||||||
|
val prev = values[i - 1]
|
||||||
|
var j = i
|
||||||
|
while (j < values.size - 1 && values[j + 1] == v) j++ // plateau
|
||||||
|
if (j >= values.size - 1) break
|
||||||
|
val next = values[j + 1]
|
||||||
|
if (v > prev && v > next) {
|
||||||
|
raw += i to true
|
||||||
|
} else if (v < prev && v < next) {
|
||||||
|
raw += i to false
|
||||||
|
}
|
||||||
|
i = j + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) alternance pic/creux + filtre d'amplitude
|
||||||
|
val out = mutableListOf<Extremum>()
|
||||||
|
for ((idx, isPeak) in raw) {
|
||||||
|
val last = out.lastOrNull()
|
||||||
|
if (last != null && last.isPeak == isPeak) {
|
||||||
|
// doublon de même type : garder le plus extrême
|
||||||
|
val better = if (isPeak) values[idx] > last.value else values[idx] < last.value
|
||||||
|
if (better) out[out.size - 1] = Extremum(points[idx].timestamp, values[idx], isPeak)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (last != null && abs(last.value - values[idx]) < minSwingAbs) continue
|
||||||
|
out += Extremum(points[idx].timestamp, values[idx], isPeak)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,6 +46,8 @@ data class ChartSeries(
|
|||||||
data class ChartOptions(
|
data class ChartOptions(
|
||||||
val showT: Boolean = true,
|
val showT: Boolean = true,
|
||||||
val showLabs: Boolean = true,
|
val showLabs: Boolean = true,
|
||||||
|
/** Afficher les pics et creux détectés (triangles ▲▼ aux extrema locaux). */
|
||||||
|
val showExtrema: Boolean = false,
|
||||||
val nowMs: Long = System.currentTimeMillis()
|
val nowMs: Long = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -165,6 +167,22 @@ fun CurveChart(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pics et creux (v1.2.3) : triangles aux extrema locaux, couleur de la série
|
||||||
|
if (options.showExtrema) {
|
||||||
|
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0)
|
||||||
|
.forEach { ex ->
|
||||||
|
drawTriangle(xOf(ex.timestamp), yE2(ex.value), ex.isPeak, s.e2Style.color)
|
||||||
|
}
|
||||||
|
if (options.showT) {
|
||||||
|
s.tStyle?.let { tStyle ->
|
||||||
|
PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02)
|
||||||
|
.forEach { ex ->
|
||||||
|
drawTriangle(xOf(ex.timestamp), yT(ex.value), ex.isPeak, tStyle.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Labs (points/carrés oranges)
|
// Labs (points/carrés oranges)
|
||||||
@ -201,8 +219,27 @@ fun CurveChart(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: Float, h: Float, rows: Int) {
|
/** Triangle ▲ (pic, au-dessus du point) ou ▼ (creux, en dessous), couleur de la série. */
|
||||||
for (i in 0..rows) {
|
private fun DrawScope.drawTriangle(cx: Float, cy: Float, isPeak: Boolean, color: Color) {
|
||||||
|
val r = 5.dp.toPx()
|
||||||
|
val offset = 6.dp.toPx()
|
||||||
|
val path = Path()
|
||||||
|
if (isPeak) {
|
||||||
|
val y = cy - offset
|
||||||
|
path.moveTo(cx, y - r)
|
||||||
|
path.lineTo(cx - r, y + r * 0.5f)
|
||||||
|
path.lineTo(cx + r, y + r * 0.5f)
|
||||||
|
} else {
|
||||||
|
val y = cy + offset
|
||||||
|
path.moveTo(cx, y + r)
|
||||||
|
path.lineTo(cx - r, y - r * 0.5f)
|
||||||
|
path.lineTo(cx + r, y - r * 0.5f)
|
||||||
|
}
|
||||||
|
path.close()
|
||||||
|
drawPath(path, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: Float, h: Float, rows: Int) { for (i in 0..rows) {
|
||||||
val y = padTop + h * i / rows
|
val y = padTop + h * i / rows
|
||||||
drawLine(color, Offset(padLeft, y), Offset(padLeft + w, y), strokeWidth = 1f)
|
drawLine(color, Offset(padLeft, y), Offset(padLeft + w, y), strokeWidth = 1f)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -83,6 +83,7 @@ fun ChartScreen() {
|
|||||||
var showEse by remember { mutableStateOf(true) }
|
var showEse by remember { mutableStateOf(true) }
|
||||||
var showTfs by remember { mutableStateOf(true) }
|
var showTfs by remember { mutableStateOf(true) }
|
||||||
var showForecast by remember { mutableStateOf(false) }
|
var showForecast by remember { mutableStateOf(false) }
|
||||||
|
var showExtrema by remember { mutableStateOf(false) }
|
||||||
var chartWidthPx by remember { mutableIntStateOf(0) }
|
var chartWidthPx by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
// « Maintenant » rafraîchi chaque minute pour le repère vertical
|
// « Maintenant » rafraîchi chaque minute pour le repère vertical
|
||||||
@ -145,7 +146,8 @@ fun ChartScreen() {
|
|||||||
"ESE" to PharmacokineticEngine.computeCurve(
|
"ESE" to PharmacokineticEngine.computeCurve(
|
||||||
effTreatments, allDoses, startMs, endMs,
|
effTreatments, allDoses, startMs, endMs,
|
||||||
tConfig = effTConfig, modelOverride = "ESE",
|
tConfig = effTConfig, modelOverride = "ESE",
|
||||||
scalePerEster = effScales
|
scalePerEster = effScales,
|
||||||
|
tKPerEster = effectiveAuto?.tKPerEster
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -154,7 +156,8 @@ fun ChartScreen() {
|
|||||||
"TFS" to PharmacokineticEngine.computeCurve(
|
"TFS" to PharmacokineticEngine.computeCurve(
|
||||||
effTreatments, allDoses, startMs, endMs,
|
effTreatments, allDoses, startMs, endMs,
|
||||||
tConfig = effTConfig, modelOverride = "TFS",
|
tConfig = effTConfig, modelOverride = "TFS",
|
||||||
scalePerEster = effScales
|
scalePerEster = effScales,
|
||||||
|
tKPerEster = effectiveAuto?.tKPerEster
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -233,6 +236,11 @@ fun ChartScreen() {
|
|||||||
enabled = forecastHorizonHours != null,
|
enabled = forecastHorizonHours != null,
|
||||||
label = { Text(stringResource(R.string.show_forecast)) }
|
label = { Text(stringResource(R.string.show_forecast)) }
|
||||||
)
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = showExtrema,
|
||||||
|
onClick = { showExtrema = !showExtrema },
|
||||||
|
label = { Text(stringResource(R.string.show_extrema)) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@ -282,7 +290,12 @@ fun ChartScreen() {
|
|||||||
series = series,
|
series = series,
|
||||||
e2Labs = e2Labs,
|
e2Labs = e2Labs,
|
||||||
tLabs = tLabs,
|
tLabs = tLabs,
|
||||||
options = ChartOptions(showT = showT, showLabs = showLabs, nowMs = nowMs),
|
options = ChartOptions(
|
||||||
|
showT = showT,
|
||||||
|
showLabs = showLabs,
|
||||||
|
showExtrema = showExtrema,
|
||||||
|
nowMs = nowMs
|
||||||
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(320.dp)
|
.height(320.dp)
|
||||||
@ -333,6 +346,13 @@ fun ChartScreen() {
|
|||||||
color = LabDot
|
color = LabDot
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showExtrema) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.legend_extrema),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.drag_hint),
|
stringResource(R.string.drag_hint),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
|||||||
@ -131,8 +131,10 @@ fun HomeScreen(
|
|||||||
endMs = end,
|
endMs = end,
|
||||||
tConfig = effectiveAuto?.tConfig ?: tConfig,
|
tConfig = effectiveAuto?.tConfig ?: tConfig,
|
||||||
// Calibration par période d'ester : chaque dose est scalée par le
|
// Calibration par période d'ester : chaque dose est scalée par le
|
||||||
// facteur de SON ester (fallback = scaleFactor du traitement)
|
// facteur de SON ester (fallback = scaleFactor du traitement) et le
|
||||||
scalePerEster = effectiveAuto?.esterScales
|
// k de la T est celui de l'ester actif à chaque instant
|
||||||
|
scalePerEster = effectiveAuto?.esterScales,
|
||||||
|
tKPerEster = effectiveAuto?.tKPerEster
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,6 +30,8 @@
|
|||||||
<string name="chart_30j">30 jours</string>
|
<string name="chart_30j">30 jours</string>
|
||||||
<string name="show_labs">Analyses</string>
|
<string name="show_labs">Analyses</string>
|
||||||
<string name="show_forecast">Prévision</string>
|
<string name="show_forecast">Prévision</string>
|
||||||
|
<string name="show_extrema">Pics / creux</string>
|
||||||
|
<string name="legend_extrema">▲▼ pics & creux estimés (par courbe)</string>
|
||||||
<string name="back_to_present">Revenir à maintenant</string>
|
<string name="back_to_present">Revenir à maintenant</string>
|
||||||
<string name="drag_hint">Fais glisser le graphique vers la droite pour remonter dans le passé.</string>
|
<string name="drag_hint">Fais glisser le graphique vers la droite pour remonter dans le passé.</string>
|
||||||
<string name="legend_ese_e2">— E2 · Estrannaise (pg/mL, axe gauche)</string>
|
<string name="legend_ese_e2">— E2 · Estrannaise (pg/mL, axe gauche)</string>
|
||||||
|
|||||||
@ -30,6 +30,8 @@
|
|||||||
<string name="chart_30j">30 days</string>
|
<string name="chart_30j">30 days</string>
|
||||||
<string name="show_labs">Labs</string>
|
<string name="show_labs">Labs</string>
|
||||||
<string name="show_forecast">Forecast</string>
|
<string name="show_forecast">Forecast</string>
|
||||||
|
<string name="show_extrema">Peaks / troughs</string>
|
||||||
|
<string name="legend_extrema">▲▼ estimated peaks & troughs (per curve)</string>
|
||||||
<string name="back_to_present">Back to now</string>
|
<string name="back_to_present">Back to now</string>
|
||||||
<string name="drag_hint">Drag the chart to the right to travel into the past.</string>
|
<string name="drag_hint">Drag the chart to the right to travel into the past.</string>
|
||||||
<string name="legend_ese_e2">— E2 · Estrannaise (pg/mL, left axis)</string>
|
<string name="legend_ese_e2">— E2 · Estrannaise (pg/mL, left axis)</string>
|
||||||
|
|||||||
145
app/src/test/java/com/hormonetrack/pk/ExtremaTest.kt
Normal file
145
app/src/test/java/com/hormonetrack/pk/ExtremaTest.kt
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
package com.hormonetrack.pk
|
||||||
|
|
||||||
|
import com.hormonetrack.data.model.AdministrationRoute
|
||||||
|
import com.hormonetrack.data.model.DoseLog
|
||||||
|
import com.hormonetrack.data.model.Esters
|
||||||
|
import com.hormonetrack.data.model.PKModels
|
||||||
|
import com.hormonetrack.data.model.Treatment
|
||||||
|
import com.hormonetrack.data.model.TreatmentType
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détection des pics et creux (v1.2.3) : en régime d'équilibre, une injection tous
|
||||||
|
* les N jours crée un pic après chaque dose et un creux juste avant la suivante.
|
||||||
|
*/
|
||||||
|
class ExtremaTest {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val BASE = 1_700_000_000_000L
|
||||||
|
private const val HOUR = 3_600_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||||
|
val file = listOf(
|
||||||
|
File("src/main/assets/pk_profiles.json"),
|
||||||
|
File("app/src/main/assets/pk_profiles.json")
|
||||||
|
).first { it.exists() }
|
||||||
|
PKProfileStore.initWithJson(file.readText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun evTreatment() = Treatment(
|
||||||
|
id = 1,
|
||||||
|
name = "EV",
|
||||||
|
type = TreatmentType.ESTRADIOL,
|
||||||
|
route = AdministrationRoute.INJECTION_IM,
|
||||||
|
doseAmount = 4.0,
|
||||||
|
doseUnit = "mg",
|
||||||
|
esterType = Esters.EV,
|
||||||
|
pkModel = PKModels.ESTRANNAISE
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `steady state curve has alternating peaks and troughs at each dose cycle`() {
|
||||||
|
val tr = evTreatment()
|
||||||
|
// 4 injections hebdomadaires → fenêtre couvrant tout + 7 j
|
||||||
|
val doses = (0..3).map {
|
||||||
|
DoseLog(treatmentId = 1, timestamp = BASE + it * 7 * 24 * HOUR, doseAmount = 4.0)
|
||||||
|
}
|
||||||
|
val start = BASE
|
||||||
|
val end = BASE + 28 * 24 * HOUR
|
||||||
|
val curve = PharmacokineticEngine.computeCurve(listOf(tr), doses, start, end, tConfig = TConfig())
|
||||||
|
|
||||||
|
val extrema = PharmacokineticEngine.detectExtrema(curve, { it.e2 }, minSwingAbs = 2.0)
|
||||||
|
assertTrue("extrema=${extrema.size}", extrema.size >= 4)
|
||||||
|
|
||||||
|
// Alternance stricte pic/creux
|
||||||
|
extrema.zipWithNext { a, b ->
|
||||||
|
assertTrue(a.isPeak != b.isPeak)
|
||||||
|
}
|
||||||
|
// Les pics sont au-dessus des creux voisins
|
||||||
|
extrema.zipWithNext { a, b ->
|
||||||
|
if (a.isPeak) assertTrue(a.value > b.value) else assertTrue(a.value < b.value)
|
||||||
|
}
|
||||||
|
// Tous les extrema sont dans les bornes de la série
|
||||||
|
val minV = curve.minOf { it.e2 }
|
||||||
|
val maxV = curve.maxOf { it.e2 }
|
||||||
|
extrema.forEach { assertTrue(it.value in minV..maxV) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `monotonic rising curve has no extrema`() {
|
||||||
|
val tr = evTreatment()
|
||||||
|
// Dose 2 h avant la fenêtre : toute la fenêtre est dans la phase de montée
|
||||||
|
// (pic EV vers 45 h, très loin après) → strictement monotone
|
||||||
|
val doses = listOf(DoseLog(treatmentId = 1, timestamp = BASE - 2 * HOUR, doseAmount = 4.0))
|
||||||
|
val curve = PharmacokineticEngine.computeCurve(
|
||||||
|
listOf(tr), doses,
|
||||||
|
startMs = BASE - HOUR, endMs = BASE,
|
||||||
|
tConfig = TConfig()
|
||||||
|
)
|
||||||
|
assertTrue(PharmacokineticEngine.detectExtrema(curve, { it.e2 }).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `flat constant series has no extrema`() {
|
||||||
|
val points = (0..20).map {
|
||||||
|
LevelPoint(timestamp = BASE + it * HOUR, e2 = 100.0, t = 0.4)
|
||||||
|
}
|
||||||
|
assertTrue(PharmacokineticEngine.detectExtrema(points, { it.e2 }).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `too short series has no extrema`() {
|
||||||
|
val points = listOf(
|
||||||
|
LevelPoint(BASE, 100.0, 0.4),
|
||||||
|
LevelPoint(BASE + HOUR, 110.0, 0.4)
|
||||||
|
)
|
||||||
|
assertTrue(PharmacokineticEngine.detectExtrema(points, { it.e2 }).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `min swing filters negligible oscillations`() {
|
||||||
|
// Série oscillant doucement autour de 100 (amplitude 0,5 < seuil 2,0)
|
||||||
|
val points = (0..10).map {
|
||||||
|
LevelPoint(
|
||||||
|
timestamp = BASE + it * HOUR,
|
||||||
|
e2 = 100.0 + if (it % 2 == 0) 0.0 else 0.5,
|
||||||
|
t = 0.4
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Sémantique zigzag : une oscillation sous le seuil produit UN seul pivot
|
||||||
|
// (le premier sommet local), jamais une alternance pic/creux
|
||||||
|
val filtered = PharmacokineticEngine.detectExtrema(points, { it.e2 }, minSwingAbs = 2.0)
|
||||||
|
assertTrue("size=${filtered.size}", filtered.size <= 1)
|
||||||
|
// Avec un seuil plus bas que l'amplitude, l'alternance complète apparaît
|
||||||
|
val full = PharmacokineticEngine.detectExtrema(points, { it.e2 }, minSwingAbs = 0.1)
|
||||||
|
assertTrue("size=${full.size}", full.size >= 4)
|
||||||
|
full.zipWithNext { a, b -> assertTrue(a.isPeak != b.isPeak) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `testosterone extrema work with their own scale`() {
|
||||||
|
val tr = evTreatment()
|
||||||
|
val doses = (0..2).map {
|
||||||
|
DoseLog(treatmentId = 1, timestamp = BASE + it * 7 * 24 * HOUR, doseAmount = 4.0)
|
||||||
|
}
|
||||||
|
val curve = PharmacokineticEngine.computeCurve(
|
||||||
|
listOf(tr), doses, startMs = BASE, endMs = BASE + 21 * 24 * HOUR, tConfig = TConfig()
|
||||||
|
)
|
||||||
|
// La T est l'inverse de l'E2 : creux d'E2 = pics de T (modèle empirique)
|
||||||
|
val e2Ext = PharmacokineticEngine.detectExtrema(curve, { it.e2 }, 2.0)
|
||||||
|
val tExt = PharmacokineticEngine.detectExtrema(curve, { it.t }, 0.02)
|
||||||
|
assertTrue(e2Ext.isNotEmpty())
|
||||||
|
assertTrue(tExt.isNotEmpty())
|
||||||
|
// Un pic d'E2 correspond approximativement à un creux de T
|
||||||
|
val tPeaks = tExt.filter { it.isPeak }
|
||||||
|
assertTrue(tPeaks.isNotEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -148,7 +148,10 @@ class RegressionUserCase2Test {
|
|||||||
)
|
)
|
||||||
assertTrue(result.calibratedEsters == 1)
|
assertTrue(result.calibratedEsters == 1)
|
||||||
assertTrue(result.esterScales["EEN"]!! in 0.2..2.0)
|
assertTrue(result.esterScales["EEN"]!! in 0.2..2.0)
|
||||||
// Le T recalibré reste dans une plage physiologique
|
// Tout est sous EEn → le k T recalibré vit dans la map par ester ;
|
||||||
assertTrue("k=${result.tConfig.k}", result.tConfig.k in 0.01..1.0)
|
// le k stocké (0,09, calibré par l'utilisatrice) reste le fallback
|
||||||
|
assertTrue("k=${result.tKPerEster["EEN"]}", result.tKPerEster["EEN"]!! in 0.01..1.0)
|
||||||
|
assertTrue(result.tRecalibrated)
|
||||||
|
assertEquals(0.09, result.tConfig.k, 1e-9)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -142,6 +142,57 @@ class V120FeaturesTest {
|
|||||||
assertTrue(result.esterScales.isEmpty())
|
assertTrue(result.esterScales.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `per-ester T calibration attributes k by ester period`() {
|
||||||
|
// Scénario valerate → enanthate : la suppression T n'est pas la même selon
|
||||||
|
// l'ester, donc k doit être calibré séparément par période.
|
||||||
|
val tr = eenTreatment().copy(esterType = Esters.EV)
|
||||||
|
val evDose = DoseLog(treatmentId = 1, timestamp = BASE - 30 * DAY, doseAmount = 4.0, esterType = Esters.EV)
|
||||||
|
val evDose2 = DoseLog(treatmentId = 1, timestamp = BASE - 23 * DAY, doseAmount = 4.0, esterType = Esters.EV)
|
||||||
|
val eenDose = DoseLog(treatmentId = 1, timestamp = BASE - 10 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
|
||||||
|
val eenDose2 = DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
|
||||||
|
val doses = listOf(evDose, evDose2, eenDose, eenDose2)
|
||||||
|
val cfg = TConfig()
|
||||||
|
|
||||||
|
// Labs T plantés : k_EV = 0,10 pendant la période EV, k_EEN = 0,30 pendant EEn
|
||||||
|
val evLabTime = BASE - 25 * DAY
|
||||||
|
val eenLabTime = BASE - 5 * DAY
|
||||||
|
val e2Ev = PharmacokineticEngine.e2At(listOf(tr), doses, evLabTime)
|
||||||
|
val e2Een = PharmacokineticEngine.e2At(listOf(tr), doses, eenLabTime)
|
||||||
|
fun tFor(k: Double, e2: Double) = cfg.floor + (cfg.base - cfg.floor) / (1.0 + k * e2)
|
||||||
|
val labs = listOf(
|
||||||
|
LabResult(marker = "T", value = tFor(0.10, e2Ev), unit = "ng/mL", timestamp = evLabTime),
|
||||||
|
LabResult(marker = "T", value = tFor(0.30, e2Een), unit = "ng/mL", timestamp = eenLabTime)
|
||||||
|
)
|
||||||
|
val kMap = PharmacokineticEngine.computeTKPerEster(listOf(tr), doses, labs, cfg)
|
||||||
|
assertEquals(0.10, kMap["EV"]!!, 0.10 * 0.15)
|
||||||
|
assertEquals(0.30, kMap["EEN"]!!, 0.30 * 0.15)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `T curve uses the k of the active ester at each point`() {
|
||||||
|
val tr = eenTreatment().copy(esterType = Esters.EV)
|
||||||
|
val doses = listOf(
|
||||||
|
DoseLog(treatmentId = 1, timestamp = BASE - 30 * DAY, doseAmount = 4.0, esterType = Esters.EV),
|
||||||
|
DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
|
||||||
|
)
|
||||||
|
val tKPerEster = mapOf("EV" to 0.10, "EEN" to 0.30)
|
||||||
|
val cfg = TConfig()
|
||||||
|
val curve = PharmacokineticEngine.computeCurve(
|
||||||
|
listOf(tr), doses,
|
||||||
|
startMs = BASE - 25 * DAY, endMs = BASE,
|
||||||
|
tConfig = cfg, tKPerEster = tKPerEster
|
||||||
|
)
|
||||||
|
// Point en pleine période EV (25 j avant BASE + 24 h ≈ J-29) : k = 0,10
|
||||||
|
val evPoint = curve.first()
|
||||||
|
val evE2 = evPoint.e2
|
||||||
|
assertEquals(cfg.floor + (cfg.base - cfg.floor) / (1.0 + 0.10 * evE2), evPoint.t, 1e-6)
|
||||||
|
// Point en pleine période EEn (fin de fenêtre) : k = 0,30
|
||||||
|
val eenPoint = curve.last()
|
||||||
|
val eenE2 = eenPoint.e2
|
||||||
|
assertEquals(cfg.floor + (cfg.base - cfg.floor) / (1.0 + 0.30 * eenE2), eenPoint.t, 1e-6)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `per-ester calibration attributes labs by ester period`() {
|
fun `per-ester calibration attributes labs by ester period`() {
|
||||||
// Scénario de l'utilisatrice : valerate avant, enanthate ensuite.
|
// Scénario de l'utilisatrice : valerate avant, enanthate ensuite.
|
||||||
|
|||||||
@ -1,7 +1,31 @@
|
|||||||
# Changelog — HormoneTrack
|
# Changelog — HormoneTrack
|
||||||
|
|
||||||
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
|
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
|
||||||
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.2.2`, …).
|
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.2.3`, …).
|
||||||
|
|
||||||
|
## [1.2.3] — 2026-09-05 (versionCode 6)
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- **Pics et creux estimés sur le graphique** : chip « Pics / creux » (désactivé par
|
||||||
|
défaut) qui affiche des triangles ▲▼ aux extrema locaux de CHAQUE courbe
|
||||||
|
(E2 et T, pour les deux modèles), colorés comme leur série. Détection dans le
|
||||||
|
moteur (`detectExtrema`) : extrema locaux stricts, plateaux fusionnés,
|
||||||
|
alternance pic/creux imposée, oscillations d'amplitude < seuil ignorées
|
||||||
|
(2 pg/mL pour l'E2, 0,02 ng/mL pour la T — sémantique zigzag : une oscillation
|
||||||
|
sous le seuil produit un seul pivot).
|
||||||
|
- **Calibration T PAR PÉRIODE D'ESTER** : la suppression de la testostérone n'est
|
||||||
|
pas la même selon l'ester (valerate = pics hauts et courts, enanthate = plateau
|
||||||
|
doux) → le k du modèle T est désormais calibré **par période** comme le facteur
|
||||||
|
d'échelle E2 (`computeTKPerEster`) : labs T valerate → k des doses valerate,
|
||||||
|
labs T enanthate → k des doses enanthate. La courbe T utilise à chaque instant
|
||||||
|
le k de l'ester **actif** (dernière dose ≤ t, `activeEsterAt`), fallback = k stocké.
|
||||||
|
Le k T est calibré contre l'E2 **déjà calibrée** (scalePerEster appliqué).
|
||||||
|
- 8 nouveaux tests (56 au total) : détection d'extrema (6) + attribution du k T
|
||||||
|
par période et courbe T utilisant le k de l'ester actif (2).
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- La calibration automatique de la T utilisait l'E2 **non calibrée** → les k
|
||||||
|
étaient faussés ; elle utilise maintenant l'E2 calibrée par période d'ester.
|
||||||
|
|
||||||
## [1.2.2] — 2026-09-05 (versionCode 5)
|
## [1.2.2] — 2026-09-05 (versionCode 5)
|
||||||
|
|
||||||
|
|||||||
@ -250,16 +250,27 @@ Coupure par dose : `cutoffHours` = longueur de table (8001 h) pour les profils,
|
|||||||
|
|
||||||
### 7.5 Courbe T (empirique)
|
### 7.5 Courbe T (empirique)
|
||||||
|
|
||||||
`T(t) = floor + (base − floor) / (1 + k·E2(t))` [ng/mL]. Défauts `TConfig` :
|
`T(t) = floor + (base − floor) / (1 + k(ester actif) · E2_calibrée(t))` [ng/mL].
|
||||||
base 6.0, floor 0.2, k 0.19 (→ T≈0,4 à E2≈150). **Non issu du `.ods`** (qui ne modélise
|
Défauts `TConfig` : base 6.0, floor 0.2, k 0.19 (→ T≈0,4 à E2≈150). **Non issu du
|
||||||
pas la T) — modèle d'inhibition simple, étiqueté « estimation » partout.
|
`.ods`** (qui ne modélise pas la T) — modèle d'inhibition simple, étiqueté
|
||||||
Calibration : `k_i = ((base−floor)/(T_lab − floor) − 1)/E2_est(t_lab)`, garde
|
« estimation » partout.
|
||||||
k ∈ (1e-4, 10), **médiane** (plante k=0.25 → recalibre 0.25 ±15 %, testé).
|
|
||||||
|
- **k par période d'ester** (v1.2.3) : la suppression T diffère selon l'ester
|
||||||
|
(valerate = pics hauts et courts, enanthate = plateau plus doux) →
|
||||||
|
`computeTKPerEster` attribue chaque lab T à la période de la dernière dose ≤ lab
|
||||||
|
et k = **médiane** des k de cette période ; la courbe utilise à chaque instant le
|
||||||
|
k de l'ester **actif** (`activeEsterAt`, curseur sur les doses triées dans
|
||||||
|
`computeCurve`), fallback = `tConfig.k` stocké.
|
||||||
|
- Formule : `k_i = ((base−floor)/(T_lab − floor) − 1)/E2_est(t_lab)`, garde
|
||||||
|
k ∈ (1e-4, 10). ⚠️ L'E2 utilisée est la version **calibrée** (scalePerEster) —
|
||||||
|
calibrer k contre une E2 brute faussait les k (corrigé v1.2.3).
|
||||||
|
- `computeTConfigCalibration` (k global unique) reste pour le bouton manuel
|
||||||
|
« Calibrer avec les analyses » des Paramètres.
|
||||||
|
|
||||||
**Unités** : les labs T peuvent être saisis en ng/mL, ng/dL, ng/L ou nmol/L —
|
**Unités** : les labs T peuvent être saisis en ng/mL, ng/dL, ng/L ou nmol/L —
|
||||||
`convertTToNgMl(value, unit)` normalise (ng/dL ÷100, ng/L ÷1000, nmol/L ×0,2884) ;
|
`convertTToNgMl(value, unit)` normalise (ng/dL ÷100, ng/L ÷1000, nmol/L ×0,2884,
|
||||||
appliqué à la calibration ET au rendu du chart (sinon l'axe T est faux d'un facteur 100,
|
pg/mL ÷1000 défensif) ; appliqué à la calibration ET au rendu du chart (sinon l'axe
|
||||||
bug réel remonté par l'utilisatrice : labs 33/44 ng/dL).
|
T est faux d'un facteur 100, bug réel remonté par l'utilisatrice : labs 33/44 ng/dL).
|
||||||
|
|
||||||
### 7.6 Calibration (v1.2.1 : PAR PÉRIODE D'ESTER pour l'auto)
|
### 7.6 Calibration (v1.2.1 : PAR PÉRIODE D'ESTER pour l'auto)
|
||||||
|
|
||||||
@ -297,7 +308,7 @@ Type de retour : `LevelPoint(timestamp, e2, t)`.
|
|||||||
|
|
||||||
## 8. Tests unitaires
|
## 8. Tests unitaires
|
||||||
|
|
||||||
**48 tests JVM, tous verts** (`./gradlew testDebugUnitTest`). Dépendance : JUnit 4.13.2.
|
**54 tests JVM, tous verts** (`./gradlew testDebugUnitTest`). Dépendance : JUnit 4.13.2.
|
||||||
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
|
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
|
||||||
`app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`).
|
`app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`).
|
||||||
|
|
||||||
@ -337,6 +348,12 @@ Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'ex
|
|||||||
- **`LabsGroupingTest`** (4) : v1.2.2 — regroupement de l'écran Analyses (paire E2+T
|
- **`LabsGroupingTest`** (4) : v1.2.2 — regroupement de l'écran Analyses (paire E2+T
|
||||||
même timestamp ; timestamps différents séparés ; tri E2 avant T avant autres ;
|
même timestamp ; timestamps différents séparés ; tri E2 avant T avant autres ;
|
||||||
ordre chronologique décroissant).
|
ordre chronologique décroissant).
|
||||||
|
- **`ExtremaTest`** (6) : v1.2.3 — détection des pics/creux (`detectExtrema`) :
|
||||||
|
alternance stricte pic/creux en régime d'équilibre (4 doses hebdo → ≥ 4 extrema,
|
||||||
|
pic > creux voisin, valeurs dans les bornes), courbe monotone → vide, série plate →
|
||||||
|
vide, série trop courte → vide, **filtre d'amplitude** (sémantique zigzag : une
|
||||||
|
oscillation sous le seuil produit UN pivot, l'alternance complète apparaît quand le
|
||||||
|
seuil baisse), anti-corrélation E2/T (un pic d'E2 ≈ un creux de T).
|
||||||
|
|
||||||
**Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis
|
**Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis
|
||||||
la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu.
|
la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu.
|
||||||
@ -383,7 +400,9 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
|
|||||||
pointillé) ; **chip Prévision** (doses projetées via `generateForecastDoses`, horizon
|
pointillé) ; **chip Prévision** (doses projetées via `generateForecastDoses`, horizon
|
||||||
= 2× le plus grand intervalle configuré, borné 7–30 j) ; **auto-calibration** branchée
|
= 2× le plus grand intervalle configuré, borné 7–30 j) ; **auto-calibration** branchée
|
||||||
sur les Paramètres ; légende dynamique ; labs T normalisés en ng/mL ; **toggle T =
|
sur les Paramètres ; légende dynamique ; labs T normalisés en ng/mL ; **toggle T =
|
||||||
masque aussi les labs T** (v1.2.2)
|
masque aussi les labs T** (v1.2.2) ; **chip « Pics / creux »** (v1.2.3 : triangles ▲▼
|
||||||
|
aux extrema locaux de chaque courbe, via `detectExtrema` — E2 seuil 2 pg/mL, T seuil
|
||||||
|
0,02 ng/mL)
|
||||||
- `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même
|
- `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même
|
||||||
traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression
|
traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression
|
||||||
avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition**
|
avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition**
|
||||||
|
|||||||
@ -109,12 +109,15 @@ Les profils viennent de ta feuille `Estrogen.ods` (table « Models ») :
|
|||||||
- **Prévision** : configure la **Fréquence d'injection** (jours) dans un traitement
|
- **Prévision** : configure la **Fréquence d'injection** (jours) dans un traitement
|
||||||
(section « Fréquence ») puis active le chip « Prévision » → les doses à venir sont
|
(section « Fréquence ») puis active le chip « Prévision » → les doses à venir sont
|
||||||
simulées et dessinées après la ligne « maintenant » (jamais sauvegardées)
|
simulées et dessinées après la ligne « maintenant » (jamais sauvegardées)
|
||||||
|
- **Pics / creux** (chip sur le graphique) : triangles ▲▼ aux extrema estimés de
|
||||||
|
chaque courbe (E2 et T, les deux modèles) — pratique pour visualiser d'un coup
|
||||||
|
d'œil les hauts et les bas entre deux injections
|
||||||
- **Calibration automatique** (Paramètres, désactivée par défaut) : quand activée, les
|
- **Calibration automatique** (Paramètres, désactivée par défaut) : quand activée, les
|
||||||
facteurs d'échelle et le modèle T sont ajustés en continu depuis tes labs — pour
|
facteurs d'échelle et le modèle T sont ajustés en continu depuis tes labs — pour
|
||||||
l'affichage seulement, tes réglages stockés ne changent pas. **Chaque ester est
|
l'affichage seulement, tes réglages stockés ne changent pas. **Chaque ester est
|
||||||
calibré avec les labs faits pendant sa période** : si tu étais sous valerate avant
|
calibré avec les labs faits pendant sa période** : si tu étais sous valerate avant
|
||||||
d'être sous enanthate, tes labs valerate calibrent les doses valerate et tes labs
|
d'être sous enanthate, tes labs valerate calibrent les doses valerate (E2 **et**
|
||||||
enanthate calibrent les doses enanthate (le mélange des périodes ne fausse plus rien)
|
la suppression T), et inversement pour l'enanthate
|
||||||
|
|
||||||
## 9. Dépannage
|
## 9. Dépannage
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user