From 3f170e57b6a10233cd427a1435920796ca2fc061 Mon Sep 17 00:00:00 2001 From: ApolloF Date: Sun, 11 Oct 2026 16:53:44 +0200 Subject: [PATCH 1/3] Levels: equal exposure per mg hormone for Basic esters; telmisartan at the 40 mg arm (presets-2026-10d) --- .../protocoltracker/domain/pk/PkSheet.kt | 29 +++-- .../protocoltracker/domain/pk/PkSheetModel.kt | 43 +++++-- .../domain/pk/PresetMigration.kt | 29 +++-- .../protocoltracker/domain/pk/Presets.kt | 4 +- .../domain/pk/BasicExposureTest.kt | 109 ++++++++++++++++++ .../domain/pk/PkSheetGoldenTest.kt | 16 ++- .../protocoltracker/domain/pk/PkSheetTest.kt | 4 +- .../domain/pk/PresetMigrationTest.kt | 27 +++++ .../protocoltracker/domain/pk/PresetsTest.kt | 21 +++- docs/MODELS.md | 74 +++++++++--- 10 files changed, 302 insertions(+), 54 deletions(-) create mode 100644 core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/BasicExposureTest.kt diff --git a/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/pk/PkSheet.kt b/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/pk/PkSheet.kt index dafcffb..d6409e2 100644 --- a/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/pk/PkSheet.kt +++ b/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/pk/PkSheet.kt @@ -11,7 +11,8 @@ package com.apollof.protocoltracker.domain.pk * `PkSheetTest` checks every row here against it. * * [SheetRow.multiplier] is not a sheet column: the sheet only says whether a multiplier is used. The values are the - * per-compound multipliers of the model ([PkSheetModel]), which applies them to every curve. + * per-compound multipliers of the model ([PkSheetModel]), which applies them to every curve. [SheetRow.exposureAnchors] + * is a model choice too. * Sheet rows without a preset are listed in docs/MODELS.md. */ object PkSheet { @@ -35,6 +36,12 @@ object PkSheet { val model: Model, val multiplierUsed: Boolean?, val multiplier: Double, + /** + * Not a sheet column. Basic esters only: rows whose exposure per unit of hormone the model sets to the mean of + * these rows' ([PkSheetModel.exposurePerUnitHormone]); empty = the plain Basic peak. Anchors are declared before + * the rows that use them. + */ + val exposureAnchors: List = emptyList(), ) // Test E and Test C: the sheet says "No" multiplier, but the model scales them. @@ -45,11 +52,12 @@ object PkSheet { val TEST_U_CASTOR = SheetRow("Testosterone", "Undecanoate (Caster Oil)", 33.9, 1.187, 10.0, 0.65, Model.ADVANCED, multiplierUsed = false, multiplier = 1.0) val TEST_U_MCT = SheetRow("Testosterone", "Undecanoate (MCT Oil)", 20.0, 1.85, 8.5, 0.65, Model.ADVANCED, multiplierUsed = false, multiplier = 1.0) val TEST_U_ORAL = SheetRow("Testosterone", "Undecanoate (Oral)", 0.7666666667, 2.4876225, 0.2041666667, 0.0683, Model.ADVANCED, multiplierUsed = true, multiplier = 0.7583841) - val TEST_PP = SheetRow("Testosterone", "Phenylpropionate", 2.5, null, null, 0.69, Model.BASIC, multiplierUsed = false, multiplier = 1.0) - // Test Iso: the sheet says "No" multiplier, but the model uses 0.6. - val TEST_ISO = SheetRow("Testosterone", "Isocaproate", 3.1, null, null, 0.75, Model.BASIC, multiplierUsed = false, multiplier = 0.6) + // Test PP, Iso and Dec: the same exposure per mg testosterone as the study-based testosterone esters. + private val TEST_ESTERS = listOf(TEST_E, TEST_C, TEST_P) + val TEST_PP = SheetRow("Testosterone", "Phenylpropionate", 2.5, null, null, 0.69, Model.BASIC, multiplierUsed = false, multiplier = 1.0, exposureAnchors = TEST_ESTERS) + val TEST_ISO = SheetRow("Testosterone", "Isocaproate", 3.1, null, null, 0.75, Model.BASIC, multiplierUsed = false, multiplier = 1.0, exposureAnchors = TEST_ESTERS) val TEST_SUSP = SheetRow("Testosterone", "Suspension", 1.375, 5.067, 0.25, 1.0, Model.ADVANCED, multiplierUsed = true, multiplier = 6.0) - val TEST_DEC = SheetRow("Testosterone", "Decanoate", 5.6, null, null, 0.65, Model.BASIC, multiplierUsed = false, multiplier = 1.0) + val TEST_DEC = SheetRow("Testosterone", "Decanoate", 5.6, null, null, 0.65, Model.BASIC, multiplierUsed = false, multiplier = 1.0, exposureAnchors = TEST_ESTERS) // Gel: Cmax per mg of testosterone in the gel applied. Sublingual base: per mg held under the tongue. val TEST_GEL = SheetRow("Testosterone", "Gel (Testogel/Androgel)", 2.983333333, 4.44, 0.4166666667, null, Model.ADVANCED, multiplierUsed = false, multiplier = 1.0) val TEST_BASE = SheetRow("Testosterone", "Test Base (Sublingual)", 0.02569444444, 1132.0, 0.009722222222, null, Model.ADVANCED, multiplierUsed = false, multiplier = 1.0) @@ -59,12 +67,12 @@ object PkSheet { val PROG_ORAL = SheetRow("Progesterone", "Oral", 0.9491666667, 169.53, 0.0625, null, Model.ADVANCED, multiplierUsed = true, multiplier = 0.06) val PROG_VAGINAL = SheetRow("Progesterone", "Vaginal", 0.8608333333, 12.3, 0.25, null, Model.ADVANCED, multiplierUsed = false, multiplier = 1.0) val T3 = SheetRow("T3 (Triiodothyronine)", "---", 0.9183333333, 6.92, 0.1041666667, null, Model.ADVANCED, multiplierUsed = false, multiplier = 1.0) - val TREN_E = SheetRow("Trenbolone", "Enanthate", 11.0, null, null, 0.71, Model.BASIC, multiplierUsed = false, multiplier = 1.0) val TREN_A = SheetRow("Trenbolone", "Acetate", 1.5, null, null, 0.87, Model.BASIC, multiplierUsed = false, multiplier = 1.0) - val TREN_HEX = SheetRow("Trenbolone", "Hexahydrobenzylcarbonate", 8.0, null, null, 0.66, Model.BASIC, multiplierUsed = false, multiplier = 1.0) + val TREN_E = SheetRow("Trenbolone", "Enanthate", 11.0, null, null, 0.71, Model.BASIC, multiplierUsed = false, multiplier = 1.0, exposureAnchors = listOf(TREN_A)) + val TREN_HEX = SheetRow("Trenbolone", "Hexahydrobenzylcarbonate", 8.0, null, null, 0.66, Model.BASIC, multiplierUsed = false, multiplier = 1.0, exposureAnchors = listOf(TREN_A)) val MENT = SheetRow("Trestolone Acetate (MENT)", "---", 0.1555555556, null, null, 0.87, Model.BASIC, multiplierUsed = false, multiplier = 1.0) val MAST_P = SheetRow("Masteron", "Propionate", 2.0, null, null, 0.84, Model.BASIC, multiplierUsed = false, multiplier = 1.0) - val MAST_E = SheetRow("Masteron", "Enanthate", 4.5, null, null, 0.73, Model.BASIC, multiplierUsed = false, multiplier = 1.0) + val MAST_E = SheetRow("Masteron", "Enanthate", 4.5, null, null, 0.73, Model.BASIC, multiplierUsed = false, multiplier = 1.0, exposureAnchors = listOf(MAST_P)) val PROVIRON = SheetRow("Mesterolone (Proviron)", "---", 0.5208333333, 12.4, 0.06666666667, 0.03, Model.ADVANCED, multiplierUsed = false, multiplier = 1.0) val NPP = SheetRow("Nandrolone (Deca/NPP)", "Phenylpropionate", 2.4, 8.91465, 1.0, 0.67, Model.ADVANCED, multiplierUsed = false, multiplier = 1.0) val DECA = SheetRow("Nandrolone (Deca/NPP)", "Decanoate", 10.2, 3.99, 1.833, 0.73, Model.ADVANCED, multiplierUsed = false, multiplier = 1.0) @@ -96,7 +104,10 @@ object PkSheet { val CABERGOLINE = SheetRow("Cabergoline", "---", 3.583333333, 4.03, 0.083, 0.465, Model.ADVANCED, multiplierUsed = false, multiplier = 1.0) val LETROZOLE = SheetRow("Letrozole", "---", 1.389583333, 45.684, 0.0775, null, Model.ADVANCED, multiplierUsed = false, multiplier = 1.0) val NEBIVOLOL = SheetRow("Nebivolol", "---", 0.7054166667, 11.6, 0.1295833333, 0.54, Model.ADVANCED, multiplierUsed = false, multiplier = 1.0) - val TELMISARTAN = SheetRow("Telmistartan", "---", 0.9708333333, 770.0625, 0.07083333333, 0.43, Model.ADVANCED, multiplierUsed = false, multiplier = 1.0) + // Telmisartan: the sheet's Cmax is the mean per mg of the two arms of PMID 17009837 (40 mg: 163.2 ng/mL, 80 mg: + // 905.7 ng/mL). Telmisartan is more than dose-proportional, so the model uses the 40 mg arm (the usual dose): + // 163.2 / 40 = 4.08 ng/mL per mg, the sheet's 7.700625 ng/mL per mg × this multiplier. + val TELMISARTAN = SheetRow("Telmistartan", "---", 0.9708333333, 770.0625, 0.07083333333, 0.43, Model.ADVANCED, multiplierUsed = false, multiplier = 163.2 / 40 / 7.700625) val TADALAFIL = SheetRow("Tadalafil", "---", 0.7083333333, 1725.0, 0.1041666667, null, Model.ADVANCED, multiplierUsed = false, multiplier = 1.0) // SARMs (the sheet's "Sarms" category, which also holds cardarine). Ostarine, cardarine and S-23 have no study // peak; their multipliers were set to match the other SARMs' levels (sheet notes). diff --git a/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/pk/PkSheetModel.kt b/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/pk/PkSheetModel.kt index c888a5a..33f4ca0 100644 --- a/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/pk/PkSheetModel.kt +++ b/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/pk/PkSheetModel.kt @@ -11,7 +11,10 @@ import com.apollof.protocoltracker.domain.pk.PkSheet.SheetRow * - Advanced (study Cmax and Tmax): rises linearly to `M × Cmax × dose` at Tmax, then halves every half-life. * Bioavailability is not used. * - Basic (no Cmax): first-order absorption with a half-time of t½ / 8, so Tmax = 3/8 t½, towards - * `14 × M × F × dose`, which it reaches 87.5 % of at Tmax; then halves every half-life. + * `14 × M × F × dose`, which it reaches 87.5 % of at Tmax; then halves every half-life. That peak does not depend + * on the half-life, so the area under a dose would grow with it. A row with [SheetRow.exposureAnchors] (an ester + * whose hormone has a better-anchored sibling) instead gets the peak that gives the same exposure per unit of + * hormone as the mean of its anchors; the shape (t½, Tmax, rise) stays. * M is the compound's multiplier. Doses add up. Levels are ng/dL per unit dosed in the sheet's own dose unit. */ object PkSheetModel { @@ -37,15 +40,35 @@ object PkSheetModel { levelUnit = unit, rise = Rise.LINEAR, ) - Model.BASIC -> PkParams( - halfLifeH = halfLifeD * HOURS_PER_DAY, - tmaxH = halfLifeD * BASIC_TMAX_PER_HALF_LIFE * HOURS_PER_DAY, - peakPerUnit = BASIC_SCALE * row.multiplier * requireNotNull(row.bioavailability) { "${row.compound}: no F" } * - PkParams.FIRST_ORDER_PEAK_SHARE * perBaseUnit, - activeFraction = fraction, - levelUnit = unit, - rise = Rise.FIRST_ORDER, - ) + Model.BASIC -> { + val shape = basicShape(row) + val peak = if (row.exposureAnchors.isEmpty()) shape.peakPerUnit!! else { + val target = row.exposureAnchors.map(::exposurePerUnitHormone).average() + target * requireNotNull(row.bioavailability) / shape.areaPerPeakH + } + shape.copy(peakPerUnit = peak * perBaseUnit, activeFraction = fraction, levelUnit = unit) + } } } + + /** + * Area under one dose's curve per unit of hormone (ng/dL·h per sheet dose unit): the row's own unanchored curve, + * `peakPerUnit × areaPerPeakH ÷ F`. The dose is the ester, of which a share F is hormone. + */ + fun exposurePerUnitHormone(row: SheetRow): Double { + val own = pk(row.copy(exposureAnchors = emptyList())) + return own.peakPerUnit!! * own.areaPerPeakH / requireNotNull(row.bioavailability) { "${row.compound}: no F" } + } + + /** The plain Basic curve (peak `14 × M × F × 87.5 %`). */ + private fun basicShape(row: SheetRow): PkParams { + val halfLifeD = requireNotNull(row.halfLifeD) { "${row.compound}: no half-life" } + return PkParams( + halfLifeH = halfLifeD * HOURS_PER_DAY, + tmaxH = halfLifeD * BASIC_TMAX_PER_HALF_LIFE * HOURS_PER_DAY, + peakPerUnit = BASIC_SCALE * row.multiplier * requireNotNull(row.bioavailability) { "${row.compound}: no F" } * + PkParams.FIRST_ORDER_PEAK_SHARE, + rise = Rise.FIRST_ORDER, + ) + } } diff --git a/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/pk/PresetMigration.kt b/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/pk/PresetMigration.kt index ffaf775..9ac5f14 100644 --- a/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/pk/PresetMigration.kt +++ b/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/pk/PresetMigration.kt @@ -6,10 +6,12 @@ import com.apollof.protocoltracker.domain.model.Rise import kotlin.math.abs /** - * Moves stored kinetics off the presets before the PK sheet (presets-2026-09b and -10a, which differ only in F). - * Log snapshots and edited presets keep a copy of the kinetics; a copy that still equals the preset's old kinetics was - * never changed by the user, so it is replaced by the preset's current kinetics. Anything else is the user's own and - * stays. F is ignored in the comparison: 10a only corrected F. + * Moves stored kinetics off older presets: those before the PK sheet (presets-2026-09b and -10a, which differ only in + * F) and the presets whose peak changed in presets-2026-10d (shipped in -10c): six Basic esters (exposure anchoring: + * Test PP, Iso, Dec, Tren E, Parabolan, Masteron E) and telmisartan (40 mg arm). Log snapshots and edited presets + * keep a copy of the kinetics; a copy that still equals the preset's old kinetics was never changed by the user, so it + * is replaced by the preset's current kinetics. Anything else is the user's own and stays. F is ignored in the + * comparison: 10a only corrected F. */ object PresetMigration { /** Preset id (without "preset:") → kinetics as shipped in presets-2026-10a: half-life h, Tmax h, peak, F, unit. */ @@ -72,16 +74,27 @@ object PresetMigration { "pt-141" to PkParams(2.7, 1.0, 4160.0, 1.0, LevelUnit.NG_ML), ) + /** Preset id → kinetics as shipped in presets-2026-10c, for the presets whose peak changed in -10d. */ + private val shipped10c: Map = mapOf( + "test-pp" to PkParams(60.0, 22.5, 8.4525, 0.69, LevelUnit.NG_DL, Rise.FIRST_ORDER), + "test-iso" to PkParams(74.4, 27.9, 5.5125, 0.75, LevelUnit.NG_DL, Rise.FIRST_ORDER), + "test-dec" to PkParams(134.39999999999998, 50.4, 7.9625, 0.65, LevelUnit.NG_DL, Rise.FIRST_ORDER), + "tren-enan" to PkParams(264.0, 99.0, 8.6975, 0.71, LevelUnit.NG_DL, Rise.FIRST_ORDER), + "tren-hex" to PkParams(192.0, 72.0, 8.085, 0.66, LevelUnit.NG_DL, Rise.FIRST_ORDER), + "mast-enan" to PkParams(108.0, 40.5, 8.9425, 0.73, LevelUnit.NG_DL, Rise.FIRST_ORDER), + "telmisartan" to PkParams(23.2999999992, 1.69999999992, 770.0625, 0.43, LevelUnit.NG_ML), + ) + /** The current preset kinetics when [pk] is still the legacy value of preset [compoundId]; otherwise [pk]. */ fun upgrade(compoundId: String, pk: PkParams?): PkParams? { if (pk == null) return null - val old = legacy[compoundId.removePrefix("preset:")] ?: return pk - if (!sameKinetics(pk, old)) return pk - return Presets.byId(compoundId)?.pk ?: pk + val id = compoundId.removePrefix("preset:") + val isOld = listOfNotNull(legacy[id], shipped10c[id]).any { sameKinetics(pk, it) } + return if (isOld) Presets.byId(compoundId)?.pk ?: pk else pk } private fun sameKinetics(a: PkParams, b: PkParams): Boolean = - a.rise == Rise.LINEAR && b.rise == Rise.LINEAR && a.levelUnit == b.levelUnit && + a.rise == b.rise && a.levelUnit == b.levelUnit && close(a.halfLifeH, b.halfLifeH) && close(a.tmaxH, b.tmaxH) && (a.peakPerUnit == null) == (b.peakPerUnit == null) && (a.peakPerUnit == null || close(a.peakPerUnit, b.peakPerUnit!!)) diff --git a/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/pk/Presets.kt b/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/pk/Presets.kt index 32a2473..d881f70 100644 --- a/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/pk/Presets.kt +++ b/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/pk/Presets.kt @@ -25,7 +25,7 @@ object CompoundColors { */ object Presets { /** Bump when preset data changes; seeding then refreshes presets the user has not edited. */ - const val VERSION = "presets-2026-10c" + const val VERSION = "presets-2026-10d" private const val SHEET = "PK sheet" @@ -183,7 +183,7 @@ object Presets { compound("cabergoline", "Caber", "cabergoline", "Cabergoline", SUP, PO, 3, sp(S.CABERGOLINE, LevelUnit.PG_ML), "$SHEET: PMID 12844325", SupportKind.PROLACTIN, perTablet = 0.5), compound("telmisartan", "", "telmisartan", "Telmisartan", SUP, PO, 5, - sp(S.TELMISARTAN, LevelUnit.NG_ML), "$SHEET: PMID 17009837; PMID 11185629", SupportKind.CARDIO, perTablet = 40.0), + sp(S.TELMISARTAN, LevelUnit.NG_ML), "$SHEET: PMID 17009837 (40 mg arm); PMID 11185629", SupportKind.CARDIO, perTablet = 40.0), compound("nebivolol", "", "nebivolol", "Nebivolol", SUP, PO, 1, sp(S.NEBIVOLOL, LevelUnit.NG_ML), "$SHEET: PMID 24845234; PMC4576779", SupportKind.CARDIO, perTablet = 5.0), compound("tadalafil", "Cialis", "tadalafil", "Tadalafil", SUP, PO, 2, diff --git a/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/BasicExposureTest.kt b/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/BasicExposureTest.kt new file mode 100644 index 0000000..915e7ed --- /dev/null +++ b/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/BasicExposureTest.kt @@ -0,0 +1,109 @@ +package com.apollof.protocoltracker.domain.pk + +import com.apollof.protocoltracker.domain.model.PkParams +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** Basic esters with exposure anchors give the same exposure per mg of hormone as their anchors (docs/MODELS.md). */ +class BasicExposureTest { + private val anchored = mapOf( + "test-pp" to PkSheet.TEST_PP, + "test-iso" to PkSheet.TEST_ISO, + "test-dec" to PkSheet.TEST_DEC, + "tren-enan" to PkSheet.TREN_E, + "tren-hex" to PkSheet.TREN_HEX, + "mast-enan" to PkSheet.MAST_E, + ) + private val byRow = mapOf( + PkSheet.TEST_E to "test-enan", PkSheet.TEST_C to "test-cyp", PkSheet.TEST_P to "test-prop", + PkSheet.TREN_A to "tren-ace", PkSheet.MAST_P to "mast-prop", + ) + + private fun pk(id: String) = assertNotNull(Presets.byId("preset:$id")?.pk, id) + + /** Area of one 1 mg dose by the closed form, per mg of hormone (ng/dL·h). */ + private fun formulaExposure(id: String, row: PkSheet.SheetRow): Double { + val p = pk(id) + return p.peakPerUnit!! * p.areaPerPeakH / row.bioavailability!! + } + + /** Area of one 1 mg dose summed on a 0.25 h grid out to Tmax + 20 half-lives, per mg of hormone. */ + private fun numericExposure(id: String, row: PkSheet.SheetRow): Double { + val p = pk(id) + val stepMs = 900_000L + val endH = p.tmaxH + 20 * p.halfLifeH + val times = LongArray((endH * 4).toInt() + 1) { it * stepMs } + val dose = CurveDose(0, p.peakPerUnit!!, p.tmaxH, p.halfLifeH, p.rise) + val levels = CurveEngine.simulate(listOf(dose), times) + var area = 0.0 + for (i in 1 until levels.size) area += (levels[i - 1] + levels[i]) / 2 * 0.25 + return area / row.bioavailability!! + } + + private fun rowOf(id: String): PkSheet.SheetRow = byRow.entries.first { it.value == id }.key + + @Test + fun anchoredExposureEqualsTheMeanOfTheAnchors() { + for ((id, row) in anchored) { + val anchors = row.exposureAnchors + assertTrue(anchors.isNotEmpty(), id) + val expected = anchors.map { formulaExposure(byRow.getValue(it), it) }.average() + assertEquals(1.0, formulaExposure(id, row) / expected, 1e-9, id) + val expectedNumeric = anchors.map { numericExposure(byRow.getValue(it), it) }.average() + assertEquals(1.0, numericExposure(id, row) / expectedNumeric, 0.005, "$id numeric") + } + } + + @Test + fun perMgHormoneRatiosAreOneWhereTheyWere5xOrMore() { + fun ratio(id: String, other: String) = + formulaExposure(id, anchored.getValue(id)) / formulaExposure(other, rowOf(other)) + assertEquals(1.0, ratio("tren-enan", "tren-ace"), 1e-9) // was 7.3 + assertEquals(1.0, ratio("tren-hex", "tren-ace"), 1e-9) // was 5.3 + assertEquals(1.0, ratio("mast-enan", "mast-prop"), 1e-9) // was 2.25 + // Test Dec against Test E: the study-based esters differ by ~10 % among themselves. + val testDec = ratio("test-dec", "test-enan") + assertTrue(abs(testDec - 1) < 0.1, "Test Dec / Test E = $testDec") // was 1.9 + } + + @Test + fun peaksPerMg() { + assertEquals(9.701, pk("test-pp").peakPerUnit!!, 0.001) + assertEquals(8.504, pk("test-iso").peakPerUnit!!, 0.001) + assertEquals(4.080, pk("test-dec").peakPerUnit!!, 0.001) + assertEquals(1.186, pk("tren-enan").peakPerUnit!!, 0.001) + assertEquals(1.516, pk("tren-hex").peakPerUnit!!, 0.001) + assertEquals(3.974, pk("mast-enan").peakPerUnit!!, 0.001) + } + + @Test + fun unanchoredBasicRowsKeepThePlainPeak() { + for (row in PkSheet.rows) { + if (row.model != PkSheet.Model.BASIC || row.exposureAnchors.isNotEmpty()) continue + val pk: PkParams = PkSheetModel.pk(row) + assertEquals(14 * row.multiplier * row.bioavailability!! * 0.875, pk.peakPerUnit!!, 1e-12, row.compound + " " + row.type) + } + assertEquals(10.6575, pk("tren-ace").peakPerUnit!!, 1e-9) + assertEquals(10.29, pk("mast-prop").peakPerUnit!!, 1e-9) + } + + @Test + fun anchorsAreBasicRowsOfTheSameHormone() { + for (row in PkSheet.rows) { + if (row.exposureAnchors.isEmpty()) continue + assertEquals(PkSheet.Model.BASIC, row.model, row.type) + assertTrue(row.exposureAnchors.all { it.compound == row.compound && it.exposureAnchors.isEmpty() }, row.type) + } + } + + @Test + fun shapeOfAnchoredRowsIsUnchanged() { + for ((id, row) in anchored) { + assertEquals(row.halfLifeD!! * 24, pk(id).halfLifeH, 1e-9, id) + assertEquals(row.halfLifeD * 24 * 3 / 8, pk(id).tmaxH, 1e-9, id) + } + } +} diff --git a/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/PkSheetGoldenTest.kt b/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/PkSheetGoldenTest.kt index 8470ed1..b499b7e 100644 --- a/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/PkSheetGoldenTest.kt +++ b/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/PkSheetGoldenTest.kt @@ -82,18 +82,23 @@ class PkSheetGoldenTest { listOf(840 to 2031.68, 852 to 1758.493, 864 to 1522.039, 876 to 1763.114, 887 to 2008.367, 1007 to 1540.506, 1100 to 503.066), ) + // presets-2026-10d anchors Tren E's exposure to Tren A: the peak went from 8.6975 to 1.1860227 per mg and the shape + // is unchanged, so every reference value of the earlier model is scaled by 1.1860227 / 8.6975 = 3/22 = 0.1363636. @Test fun trenEnanthateBasicModel() = check( "Tren E 200 mg / 3.5 d", "tren-enan", 200.0, 84.0, 12, - listOf(1848 to 8908.624, 1860 to 9202.203, 1932 to 8922.76, 1947 to 9271.11, 1948 to 9278.364, 2015 to 8946.022, 2351 to 3774.519), + listOf(1848 to 1214.812, 1860 to 1254.846, 1932 to 1216.74, 1947 to 1264.242, 1948 to 1265.231, 2015 to 1219.912, 2351 to 514.707), ) + // presets-2026-10d anchors Test Iso's exposure to the testosterone esters (no multiplier): the peak went from + // 5.5125 to 8.5036774 per mg and the shape is unchanged, so every reference value of the earlier model is scaled by + // 8.5036774 / 5.5125 = 1.5426172. @Test - fun testIsocaproateBasicModelWithMultiplier() = check( + fun testIsocaproateBasicModelAnchored() = check( "Test Iso 100 mg / 7 d", "test-iso", 100.0, 168.0, 4, listOf( - 0 to 0.0, 1 to 45.248, 6 to 227.165, 12 to 372.418, 27 to 545.786, 28 to 550.737, 29 to 545.63, 48 to 457.111, - 167 to 150.847, 600 to 368.834, 839 to 39.793, + 0 to 0.0, 1 to 69.8, 6 to 350.429, 12 to 574.498, 27 to 841.939, 28 to 849.576, 29 to 841.698, 48 to 705.147, + 167 to 232.699, 600 to 568.97, 839 to 61.385, ), ) @@ -229,7 +234,8 @@ class PkSheetGoldenTest { assertSteady("test-cyp", 200.0, 168.0, 1321.194, 1027.774, 1163.272) assertSteady("test-prop", 100.0, 48.0, 3527.078, 1885.385, 2636.460) assertSteady("npp", 100.0, 48.0, 2031.741, 1522.086, 1766.201) - assertSteady("tren-enan", 400.0, 168.0, 9836.650, 8137.903, 9244.312) + // Tren E: the earlier references 9836.650 / 8137.903 / 9244.312 scaled by 3/22 (anchored in presets-2026-10d). + assertSteady("tren-enan", 400.0, 168.0, 1341.361, 1109.714, 1260.588) assertSteady("estradiol-cyp", 5.0, 168.0, 19.253, 5.983, 11.479) assertSteady("estradiol-val", 5.0, 120.0, 43.854, 21.128, 32.624) assertSteady("test-gel", 50.0, 24.0, 1070.791, 935.07, 1001.747) diff --git a/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/PkSheetTest.kt b/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/PkSheetTest.kt index b95d5ba..d4a63c9 100644 --- a/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/PkSheetTest.kt +++ b/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/PkSheetTest.kt @@ -79,11 +79,11 @@ class PkSheetTest { } @Test - fun rowsFlaggedWithAMultiplierHaveOneAndOnlyTestEsterRowsAreUnflagged() { + fun rowsFlaggedWithAMultiplierHaveOneAndOnlyTheListedRowsAreUnflagged() { val rows = PkSheet.rows assertTrue(rows.filter { it.multiplierUsed == true }.none { it.multiplier == 1.0 }) // The model scales these although the sheet says "No" (docs/MODELS.md). val unflagged = rows.filter { it.multiplier != 1.0 && it.multiplierUsed != true } - assertEquals(listOf(PkSheet.TEST_E, PkSheet.TEST_C, PkSheet.TEST_ISO), unflagged) + assertEquals(listOf(PkSheet.TEST_E, PkSheet.TEST_C, PkSheet.TELMISARTAN), unflagged) } } diff --git a/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/PresetMigrationTest.kt b/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/PresetMigrationTest.kt index f204b19..14ccdfd 100644 --- a/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/PresetMigrationTest.kt +++ b/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/PresetMigrationTest.kt @@ -2,6 +2,7 @@ package com.apollof.protocoltracker.domain.pk import com.apollof.protocoltracker.domain.model.LevelUnit import com.apollof.protocoltracker.domain.model.PkParams +import com.apollof.protocoltracker.domain.model.Rise import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -36,4 +37,30 @@ class PresetMigrationTest { fun currentPresetKineticsAreLeftAlone() { for (preset in Presets.all) assertEquals(preset.pk, PresetMigration.upgrade(preset.id, preset.pk), preset.id) } + + @Test + fun anchoringChangesOfPresets10cBecomeTheCurrentOnes() { + val tren10c = PkParams(264.0, 99.0, 8.6975, 0.71, LevelUnit.NG_DL, Rise.FIRST_ORDER) + val trenNow = Presets.byId("preset:tren-enan")!!.pk + assertEquals(trenNow, PresetMigration.upgrade("preset:tren-enan", tren10c)) + val dec10c = PkParams(134.39999999999998, 50.4, 7.9625, 0.65, LevelUnit.NG_DL, Rise.FIRST_ORDER) + assertEquals(Presets.byId("preset:test-dec")!!.pk, PresetMigration.upgrade("preset:test-dec", dec10c)) + // F is ignored here too. + assertEquals(trenNow, PresetMigration.upgrade("preset:tren-enan", tren10c.copy(activeFraction = 0.5))) + } + + @Test + fun editedCopyOfPresets10cStays() { + val edited = PkParams(264.0, 99.0, 9.0, 0.71, LevelUnit.NG_DL, Rise.FIRST_ORDER) + assertSame(edited, PresetMigration.upgrade("preset:tren-enan", edited)) + } + + @Test + fun telmisartanOf10cBecomesThe40MgArm() { + // As stored in a v0.6.0 backup (presets-2026-10c). + val stored = PkParams(23.2999999992, 1.69999999992, 770.0625, 0.43, LevelUnit.NG_ML) + val now = Presets.byId("preset:telmisartan")!!.pk!! + assertEquals(now, PresetMigration.upgrade("preset:telmisartan", stored)) + assertEquals(408.0, now.peakPerUnit!!, 1e-9) + } } diff --git a/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/PresetsTest.kt b/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/PresetsTest.kt index b444e07..5f32c94 100644 --- a/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/PresetsTest.kt +++ b/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/pk/PresetsTest.kt @@ -66,12 +66,17 @@ class PresetsTest { @Test fun basicSheetRowsUseTheBasicModel() { - // Tren E: t½ 11 d, F 0.71, no multiplier → Tmax 3/8 t½, peak 14 × F × 87.5 % per mg. - val tren = byId("tren-enan").pk!! - assertEquals(11 * 24.0, tren.halfLifeH, 1e-9) - assertEquals(11 * 24.0 * 3 / 8, tren.tmaxH, 1e-9) - assertEquals(14 * 0.71 * 0.875, tren.peakPerUnit!!, 1e-9) + // Tren A: t½ 1.5 d, F 0.87, no multiplier → Tmax 3/8 t½, peak 14 × F × 87.5 % per mg. + val tren = byId("tren-ace").pk!! + assertEquals(1.5 * 24.0, tren.halfLifeH, 1e-9) + assertEquals(1.5 * 24.0 * 3 / 8, tren.tmaxH, 1e-9) + assertEquals(14 * 0.87 * 0.875, tren.peakPerUnit!!, 1e-9) assertEquals(Rise.FIRST_ORDER, tren.rise) + // Tren E keeps its shape (t½ 11 d) but its peak is anchored to Tren A's exposure (BasicExposureTest). + val trenE = byId("tren-enan").pk!! + assertEquals(11 * 24.0, trenE.halfLifeH, 1e-9) + assertEquals(11 * 24.0 * 3 / 8, trenE.tmaxH, 1e-9) + assertEquals(Rise.FIRST_ORDER, trenE.rise) assertEquals(Rise.LINEAR, byId("test-enan").pk!!.rise) } @@ -118,4 +123,10 @@ class PresetsTest { for (id in listOf("bpc-157", "tb-500", "ghk-cu", "melanotan-2", "aod-9604", "mots-c")) assertNull(byId(id).pk, id) assertNotNull(byId("semaglutide").pk) } + + @Test + fun telmisartan40MgPeaksAtTheStudys40MgArm() { + // PMID 17009837: 40 mg → Cmax 163.2 ng/mL. Peaks are ng/dL per mg (1 ng/mL = 100 ng/dL). + assertEquals(163.2 * 100, byId("telmisartan").pk!!.peakPerUnit!! * 40, 1e-6) + } } diff --git a/docs/MODELS.md b/docs/MODELS.md index e52398d..f45de95 100644 --- a/docs/MODELS.md +++ b/docs/MODELS.md @@ -40,6 +40,28 @@ Basic rows added in presets-2026-10c (estradiol valerate, ostarine, cardarine, S same curve. `rise` is absent in data stored before presets-2026-10b and defaults to LINEAR. The curve editor has no field for it; an edited preset keeps its own. +**Exposure anchoring (Basic esters, presets-2026-10d).** The Basic peak does not depend on `t½`, so the area under one +dose (AUC = `P · areaPerPeakH`) grows with `t½`: per mg of hormone (AUC ÷ F) it is `20.71 · M · t½` (days). A longer ester +would give more exposure from the same mg of hormone, which is wrong: the ester only sets the release rate. In +presets-2026-10c Tren E showed 7.3× the exposure of Tren A per mg trenbolone, Parabolan 5.3×, Masteron E 2.25× Masteron P +and Test Dec 1.9× the study-based testosterone esters. A row with `SheetRow.exposureAnchors` (a model choice, not a +sheet column) therefore gets the peak that gives the same exposure per mg of hormone as the mean of its anchors' own +curves (`PkSheetModel.exposurePerUnitHormone` = `P · areaPerPeakH ÷ F`); `t½`, Tmax and the rise are unchanged: + +``` +P = mean(exposure of anchors) · F / areaPerPeakH (this row's first-order shape) +``` + +| Row | Anchors | +|---|---| +| Test PP, Test Iso, Test Dec | Test E, Test C, Test P (within ~10 % of each other: AUC ÷ F 57.4, 58.2, 62.8 ng/dL·day per mg) | +| Tren E, Parabolan | Tren A | +| Masteron E | Masteron P | + +Tren A and Masteron P are the family anchors (no human data to pick another level). Primobolan Depot, DHB and estradiol +valerate keep their multipliers, which the sheet author calibrated ("Multiplier Used: Yes"); MENT, all orals and the +SARMs are first-pass or tuned the same way. `BasicExposureTest` checks the equal exposure. + Doses add up (linear superposition). The engine groups doses by (Tmax, t½, rise); within a group peaks occur in dose order, so the decaying part is one running sum advanced by `e^(−k·Δt)` and only doses still rising are summed individually. Doses older than `Tmax + 10·t½` are dropped (< 0.1 % contribution). @@ -49,7 +71,7 @@ are summed individually. Doses older than `Tmax + 10·t½` are dropped (< 0.1 % | Compound | M | Sheet says | |---|---|---| | Test E | 0.33 | No | -| Test C, Test Iso | 0.6 | No | +| Test C | 0.6 | No | | Boldenone Cyp (Test C clone) | 0.6 | (empty row) | | Test Susp | 6 | Yes | | Andriol (oral TU) | 0.7583841 | Yes | @@ -62,6 +84,7 @@ are summed individually. Doses older than `Tmax + 10·t½` are dropped (< 0.1 % | Anavar | 0.3 | Yes | | Superdrol | 20 | Yes | | Semaglutide (oral) | 1.5 | Yes | +| Telmisartan | 0.5298 (40 mg arm, see notes) | No | | Estradiol valerate | 0.45 | Yes | | Progesterone (oral) | 0.06 | Yes | | Ostarine (enobosarm) | 15 | Yes | @@ -102,7 +125,14 @@ not an object reads as no adjustment, entries that are not numbers are dropped, mislabelled. Not imported: the castor-oil row (t½ 33.9 d) is the Nebido preset. - **Boldenone cypionate:** the sheet row is empty ("Cloned settings from Testosterone Cypionate"); the preset uses Test C's settings, M 0.6 included. -- **Test E, Test C and Test Iso** have a multiplier although the sheet says "No". +- **Telmisartan:** the sheet's Cmax 770.06 ng/dL per mg (7.70 ng/mL) is the mean per mg of the two arms of the cited + study (PMID 17009837, healthy men, single dose, LC-MS/MS): 40 mg gave 163.2 ng/mL (4.08 per mg), 80 mg gave + 905.7 ng/mL (11.32 per mg). Telmisartan is more than dose-proportional (label: 20–160 mg), so one per-mg value fits only + one dose; the average was 1.9× too high at 40 mg and 0.68× too low at 80 mg. Since presets-2026-10d the multiplier + 163.2 / 40 / 7.700625 = 0.5298 gives the 40 mg arm (408 ng/dL = 4.08 ng/mL per mg), right at the usual 20–40 mg; it + underestimates 80 mg about 2.8×. Half-life and Tmax (means of both arms) are unchanged. +- **Test E, Test C and telmisartan** have a multiplier although the sheet says "No". Test Iso had 0.6 until presets-2026-10c; exposure + anchoring (above) replaces it. ## Peak `P` and F in the app - `PkParams.peakPerUnit` is P per base unit of the preset. The sheet's Cmax is ng/dL per mg dosed (ester mass as @@ -132,9 +162,9 @@ A group is *in use* when a plan item active today takes it, or a taken dose is s (`takenAt + Tmax + 6.64 t½ ≥ now`, since 2^-6.64 ≈ 1 %). Skipped doses never count. Other groups (other phases, paused items, old doses) are listed under "Not in use now". Order follows the plan sections; the colour is that of the compound in use. -## Presets (`Presets.kt`, `VERSION = presets-2026-10c`) +## Presets (`Presets.kt`, `VERSION = presets-2026-10d`) Sheet values unchanged: t½ and Tmax in days (stored in hours), Cmax in the sheet's unit, F. "Model" A = Advanced, B = -Basic (Tmax = 3/8 t½, P = 14 · M · F · 0.875). P is per mg unless noted. "—" = log only, no curve. +Basic (Tmax = 3/8 t½, P = 14 · M · F · 0.875, except ³ rows). P is per mg unless noted. "—" = log only, no curve. | Section | Preset | Model | t½ (d) | Tmax (d) | Cmax | M | P | F | Source | |---|---|---|---|---|---|---|---|---|---| @@ -143,17 +173,17 @@ Basic (Tmax = 3/8 t½, P = 14 · M · F · 0.875). P is per mg unless noted. " | Injectable | Test P (testosterone propionate) | A | 1.0375 | 1.0625 | 26 | 1 | 26 | 0.84 | Sheet; JCEM 63(6):1361 | | Injectable | Nebido (testosterone undecanoate, castor oil) | A | 33.9 | 10 | 1.187 | 1 | 1.187 | 0.63 ¹ | Sheet; jandrol.109.009597, Nebido PI | | Injectable | Test U MCT (testosterone undecanoate, MCT oil) **new** | A | 20 | 8.5 | 1.85 | 1 | 1.85 | 0.63 ¹ | Sheet: estimated values, no study | -| Injectable | Test PP (testosterone phenylpropionate) | B | 2.5 | 0.9375 | — | 1 | 8.453 | 0.69 | Sheet; PMC9611952 | -| Injectable | Test Iso (testosterone isocaproate) | B | 3.1 | 1.1625 | — | 0.6 | 5.513 | 0.75 | Sheet; PMC9611952 | -| Injectable | Test Dec (testosterone decanoate) | B | 5.6 | 2.1 | — | 1 | 7.963 | 0.65 | Sheet | +| Injectable | Test PP (testosterone phenylpropionate) | B ³ | 2.5 | 0.9375 | — | 1 | 9.701 | 0.69 | Sheet; PMC9611952 | +| Injectable | Test Iso (testosterone isocaproate) | B ³ | 3.1 | 1.1625 | — | 1 | 8.504 | 0.75 | Sheet; PMC9611952 | +| Injectable | Test Dec (testosterone decanoate) | B ³ | 5.6 | 2.1 | — | 1 | 4.080 | 0.65 | Sheet | | Injectable | Test Susp (testosterone suspension) | A | 1.375 | 0.25 | 5.067 | 6 | 30.40 | 1.0 | Sheet (horse study, scaled) | | Injectable | Deca (nandrolone decanoate) | A | 10.2 | 1.833 | 3.99 | 1 | 3.99 | 0.64 ¹ | Sheet; JCEM 90(5):2624 | | Injectable | NPP (nandrolone phenylpropionate) | A | 2.4 | 1 | 8.91465 | 1 | 8.915 | 0.67 | Sheet; PMID 9103484 | | Injectable | Tren A (trenbolone acetate) | B | 1.5 | 0.5625 | — | 1 | 10.66 | 0.87 | Sheet; S002228602030452X | -| Injectable | Tren E (trenbolone enanthate) | B | 11 | 4.125 | — | 1 | 8.698 | 0.71 | Sheet; S002228602030452X | -| Injectable | Parabolan (trenbolone hexahydrobenzylcarbonate) | B | 8 | 3 | — | 1 | 8.085 | 0.66 | Sheet; S002228602030452X | +| Injectable | Tren E (trenbolone enanthate) | B ³ | 11 | 4.125 | — | 1 | 1.186 | 0.71 | Sheet; S002228602030452X | +| Injectable | Parabolan (trenbolone hexahydrobenzylcarbonate) | B ³ | 8 | 3 | — | 1 | 1.516 | 0.66 | Sheet; S002228602030452X | | Injectable | Masteron P (drostanolone propionate) | B | 2 | 0.75 | — | 1 | 10.29 | 0.84 | Sheet | -| Injectable | Masteron E (drostanolone enanthate) | B | 4.5 | 1.6875 | — | 1 | 8.943 | 0.73 | Sheet | +| Injectable | Masteron E (drostanolone enanthate) | B ³ | 4.5 | 1.6875 | — | 1 | 3.974 | 0.73 | Sheet | | Injectable | Primobolan Depot (methenolone enanthate) | B | 10.5 | 3.9375 | — | 0.5 | 4.471 | 0.73 | Sheet | | Injectable | EQ (boldenone undecylenate) | A | 5.125 | 6 | 1.098 | 1 | 1.098 | 0.63 | Sheet; PMID 17348894 | | Injectable | Boldenone Cyp | A | 6.9 | 4.5 | 5.56 | 0.6 | 3.336 | 0.70 | Sheet: cloned from Test C | @@ -190,7 +220,7 @@ Basic (Tmax = 3/8 t½, P = 14 · M · F · 0.875). P is per mg unless noted. " | Support | Clomid (clomiphene) | A | 5 | 0.2083 | 40 | 1 | 40 | 0.95 | Sheet; PMID 19033451 | | Support | hCG | A | 1.9625 | 1 | 2072 | 1 | 0.2072 per IU | 0.45 | Sheet; PMC8301557 | | Support | Caber (cabergoline) | A | 3.5833 | 0.083 | 4.03 | 1 | 4.03 | 0.465 | Sheet; PMID 12844325 | -| Support | Telmisartan | A | 0.9708 | 0.0708 | 770.06 | 1 | 770.06 | 0.43 | Sheet; PMID 17009837 | +| Support | Telmisartan | A | 0.9708 | 0.0708 | 770.06 | 0.5298 | 408 | 0.43 | Sheet; PMID 17009837 (40 mg arm) | | Support | Nebivolol | A | 0.7054 | 0.1296 | 11.6 | 1 | 11.6 | 0.54 | Sheet; PMID 24845234 | | Support | Cialis (tadalafil) | A | 0.7083 | 0.1042 | 1725 | 1 | 1725 | n/a → 1.0 | Sheet; PMC1885023 | | Support | T3 (liothyronine) | A | 0.9183 | 0.1042 | 6.92 per mcg | 1 | 6920 | n/a → 1.0 | Sheet; PMC5167556 | @@ -226,6 +256,10 @@ Compounds outside the sheet keep their label-based values (unchanged from preset | Peptide | Ipamorelin | 2 h | est. | relative | 1.0 | Gobburu et al. 1999: t½ ≈ 2 h | | Peptide | CJC-1295 (no DAC), MK-677, BPC-157, TB-500, GHK-Cu, Melanotan II, AOD-9604, MOTS-c | — | — | — | — | No reliable human level data (MK-677: reported half-lives conflict) | +³ Exposure-anchored Basic ester (see Per-dose curve): P is set so that the exposure per mg of hormone equals that of the +anchors, not 14 · M · F · 0.875 (10c values: Test PP 8.453, Test Iso 5.513 with M 0.6, Test Dec 7.963, Tren E 8.698, +Parabolan 8.085, Masteron E 8.943). + ¹ F corrected from the sheet (Nebido and Test U MCT 0.65, Deca 0.73, DHB 1.0) to the ester fraction, parent ÷ ester molar mass from the formulas with IUPAC standard atomic weights (C 12.011, H 1.008, O 15.999): testosterone undecanoate C30H48O3 456.71 → 288.43 ÷ 456.71 = 0.632; nandrolone decanoate C28H44O3 428.66 → 274.40 ÷ 428.66 = 0.640; @@ -242,8 +276,9 @@ Tmax longer than t½ for EQ and Winstrol Depot. - DNP: not a medicine and not planned in the app. **Stored data (`PresetMigration`).** Log snapshots and edited presets keep their own copy of the kinetics. On seeding -(start-up and after a restore), a copy that still equals the kinetics a preset shipped with in presets-2026-09b/-10a -(half-life, Tmax, peak, unit; F ignored) is replaced by the current preset's, so old doses are plotted with the +(start-up and after a restore), a copy that still equals the kinetics a preset shipped with in presets-2026-09b/-10a (or, for +Test PP, Iso, Dec, Tren E, Parabolan, Masteron E and telmisartan, in presets-2026-10c) +(half-life, Tmax, peak, unit, rise; F ignored) is replaced by the current preset's, so old doses are plotted with the current model too. Kinetics the user changed are left alone. No database schema change: kinetics are stored as JSON. **Before and after** (steady state, ng/dL, average and trough–peak; "relative only" = no absolute curve before): @@ -266,6 +301,17 @@ current model too. Kinetics the user changed are left alone. No database schema | Anavar 20 mg/day | 6,733 (1,660–16,833) | 2,020 (498–5,050) | | Dianabol 30 mg/day | relative only | 1,291 (199–3,644) | +presets-2026-10d (exposure anchoring), steady state, average (trough–peak): + +| Dose | Before (presets-2026-10c) | After (presets-2026-10d) | +|---|---|---| +| Tren E 200 mg/wk (100 mg every 3.5 d) | 4,622 (4,487–4,687) | 630 (612–639) | +| Parabolan 150 mg/wk (75 mg every 3.5 d) | 2,344 (2,218–2,396) | 439 (416–449) | +| Masteron E 400 mg/wk (weekly) | 3,888 (2,391–5,420) | 1,728 (1,062–2,409) | +| Test Dec 250 mg/wk (weekly) | 2,693 (1,872–3,433) | 1,379 (959–1,759) | + +Telmisartan 40 mg once: peak 308 ng/mL before, 163 ng/mL after (study: 163.2 ng/mL at 1.8 h). + Test E 300 mg/wk now has a steady trough of 1,328 ng/dL against the 1,345 ng/dL nadir of Bhasin et al. 2001, so the Test E multiplier does what the earlier calibration proposal ([proposals/pk-calibration.md](proposals/pk-calibration.md), superseded) asked for. @@ -292,6 +338,8 @@ a limit ("<0.3") is flagged only when its true value cannot lie on both sides of it cannot change the shape of a curve. - No endogenous production, suppression, active metabolites or individual factors (weight, injection site, volume). - Estimates are for planning and comparison, not blood test results. +- Study-based (Advanced) rows still differ per mg of hormone because their Cmax and Tmax come from different studies: + Deca gives about 1.85× the exposure of NPP, Nebido about 1.75× Test E. Kept as the sheet has them. - Estradiol is estimated only from estradiol doses (gel, cypionate, valerate), with lab estradiol drawn on that curve. Estradiol made from testosterone is not estimated: a single factor on the testosterone curve cannot account for aromatase inhibitor, Dbol or hCG changes, so without an estradiol preset measured estradiol is shown only as lab results. From d59b4c40e50604a27c1c3c553044772bae9e9452 Mon Sep 17 00:00:00 2001 From: ApolloF <98666317+ApolloF@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:16:19 +0200 Subject: [PATCH 2/3] Tests: awaitMain waits 60 s LevelsGateTest.openingLevelsStartsTheTrialOnce missed its 30 s wait on CI runners twice (#18, #20) while passing locally; the gated Levels screen test already waits 60 s. --- app/src/test/kotlin/com/apollof/protocoltracker/GatedApps.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/test/kotlin/com/apollof/protocoltracker/GatedApps.kt b/app/src/test/kotlin/com/apollof/protocoltracker/GatedApps.kt index d309823..cf0eeb4 100644 --- a/app/src/test/kotlin/com/apollof/protocoltracker/GatedApps.kt +++ b/app/src/test/kotlin/com/apollof/protocoltracker/GatedApps.kt @@ -41,9 +41,9 @@ class LevelsOffApp : ProtocolTrackerApp() { /** * Waits until [condition] holds, running the main looper in between: view models launch on Robolectric's paused - * main looper, which a plain wait never lets run. + * main looper, which a plain wait never lets run. 60 s: CI runners have taken over 30 s to recompute Levels state. */ -fun awaitMain(what: String, timeoutMillis: Long = 30_000, condition: () -> Boolean) { +fun awaitMain(what: String, timeoutMillis: Long = 60_000, condition: () -> Boolean) { val deadline = System.currentTimeMillis() + timeoutMillis while (true) { ShadowLooper.idleMainLooper() From dca32f369047e6b2de3e0d4548fd13bfc677c6a4 Mon Sep 17 00:00:00 2001 From: ApolloF <98666317+ApolloF@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:52:36 +0200 Subject: [PATCH 3/3] Back to the 30 s wait in awaitMain; the Levels trial check no longer needs more --- app/src/test/kotlin/com/apollof/protocoltracker/GatedApps.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/test/kotlin/com/apollof/protocoltracker/GatedApps.kt b/app/src/test/kotlin/com/apollof/protocoltracker/GatedApps.kt index cf0eeb4..d309823 100644 --- a/app/src/test/kotlin/com/apollof/protocoltracker/GatedApps.kt +++ b/app/src/test/kotlin/com/apollof/protocoltracker/GatedApps.kt @@ -41,9 +41,9 @@ class LevelsOffApp : ProtocolTrackerApp() { /** * Waits until [condition] holds, running the main looper in between: view models launch on Robolectric's paused - * main looper, which a plain wait never lets run. 60 s: CI runners have taken over 30 s to recompute Levels state. + * main looper, which a plain wait never lets run. */ -fun awaitMain(what: String, timeoutMillis: Long = 60_000, condition: () -> Boolean) { +fun awaitMain(what: String, timeoutMillis: Long = 30_000, condition: () -> Boolean) { val deadline = System.currentTimeMillis() + timeoutMillis while (true) { ShadowLooper.idleMainLooper()