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 4ed9f67..eb15586 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. "Play" says whether the Google Play +Basic (Tmax = 3/8 t½, P = 14 · M · F · 0.875, except ³ rows). P is per mg unless noted. "—" = log only, no curve. "Play" says whether the Google Play build ships the preset (`PLAY_PRESET_IDS` in `pk/PresetChannel.kt`); the flavour decides only whether a preset is there and how its name is shown (Play drops the common name, except hCG), never its parameters. @@ -145,17 +175,17 @@ its name is shown (Play drops the common name, except hCG), never its parameters | Injectable | Test P (testosterone propionate) | A | 1.0375 | 1.0625 | 26 | 1 | 26 | 0.84 | Sheet; JCEM 63(6):1361 | yes | | Injectable | Nebido (testosterone undecanoate, castor oil) | A | 33.9 | 10 | 1.187 | 1 | 1.187 | 0.63 ¹ | Sheet; jandrol.109.009597, Nebido PI | yes | | 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 | no | -| Injectable | Test PP (testosterone phenylpropionate) | B | 2.5 | 0.9375 | — | 1 | 8.453 | 0.69 | Sheet; PMC9611952 | yes | -| Injectable | Test Iso (testosterone isocaproate) | B | 3.1 | 1.1625 | — | 0.6 | 5.513 | 0.75 | Sheet; PMC9611952 | yes | -| Injectable | Test Dec (testosterone decanoate) | B | 5.6 | 2.1 | — | 1 | 7.963 | 0.65 | Sheet | yes | +| Injectable | Test PP (testosterone phenylpropionate) | B ³ | 2.5 | 0.9375 | — | 1 | 9.701 | 0.69 | Sheet; PMC9611952 | yes | +| Injectable | Test Iso (testosterone isocaproate) | B ³ | 3.1 | 1.1625 | — | 1 | 8.504 | 0.75 | Sheet; PMC9611952 | yes | +| Injectable | Test Dec (testosterone decanoate) | B ³ | 5.6 | 2.1 | — | 1 | 4.080 | 0.65 | Sheet | yes | | Injectable | Test Susp (testosterone suspension) | A | 1.375 | 0.25 | 5.067 | 6 | 30.40 | 1.0 | Sheet (horse study, scaled) | no | | Injectable | Deca (nandrolone decanoate) | A | 10.2 | 1.833 | 3.99 | 1 | 3.99 | 0.64 ¹ | Sheet; JCEM 90(5):2624 | no | | Injectable | NPP (nandrolone phenylpropionate) | A | 2.4 | 1 | 8.91465 | 1 | 8.915 | 0.67 | Sheet; PMID 9103484 | no | | Injectable | Tren A (trenbolone acetate) | B | 1.5 | 0.5625 | — | 1 | 10.66 | 0.87 | Sheet; S002228602030452X | no | -| Injectable | Tren E (trenbolone enanthate) | B | 11 | 4.125 | — | 1 | 8.698 | 0.71 | Sheet; S002228602030452X | no | -| Injectable | Parabolan (trenbolone hexahydrobenzylcarbonate) | B | 8 | 3 | — | 1 | 8.085 | 0.66 | Sheet; S002228602030452X | no | +| Injectable | Tren E (trenbolone enanthate) | B ³ | 11 | 4.125 | — | 1 | 1.186 | 0.71 | Sheet; S002228602030452X | no | +| Injectable | Parabolan (trenbolone hexahydrobenzylcarbonate) | B ³ | 8 | 3 | — | 1 | 1.516 | 0.66 | Sheet; S002228602030452X | no | | Injectable | Masteron P (drostanolone propionate) | B | 2 | 0.75 | — | 1 | 10.29 | 0.84 | Sheet | no | -| Injectable | Masteron E (drostanolone enanthate) | B | 4.5 | 1.6875 | — | 1 | 8.943 | 0.73 | Sheet | no | +| Injectable | Masteron E (drostanolone enanthate) | B ³ | 4.5 | 1.6875 | — | 1 | 3.974 | 0.73 | Sheet | no | | Injectable | Primobolan Depot (methenolone enanthate) | B | 10.5 | 3.9375 | — | 0.5 | 4.471 | 0.73 | Sheet | no | | Injectable | EQ (boldenone undecylenate) | A | 5.125 | 6 | 1.098 | 1 | 1.098 | 0.63 | Sheet; PMID 17348894 | no | | Injectable | Boldenone Cyp | A | 6.9 | 4.5 | 5.56 | 0.6 | 3.336 | 0.70 | Sheet: cloned from Test C | no | @@ -192,7 +222,7 @@ its name is shown (Play drops the common name, except hCG), never its parameters | Support | Clomid (clomiphene) | A | 5 | 0.2083 | 40 | 1 | 40 | 0.95 | Sheet; PMID 19033451 | yes | | Support | hCG | A | 1.9625 | 1 | 2072 | 1 | 0.2072 per IU | 0.45 | Sheet; PMC8301557 | yes | | Support | Caber (cabergoline) | A | 3.5833 | 0.083 | 4.03 | 1 | 4.03 | 0.465 | Sheet; PMID 12844325 | yes | -| Support | Telmisartan | A | 0.9708 | 0.0708 | 770.06 | 1 | 770.06 | 0.43 | Sheet; PMID 17009837 | yes | +| Support | Telmisartan | A | 0.9708 | 0.0708 | 770.06 | 0.5298 | 408 | 0.43 | Sheet; PMID 17009837 (40 mg arm) | yes | | Support | Nebivolol | A | 0.7054 | 0.1296 | 11.6 | 1 | 11.6 | 0.54 | Sheet; PMID 24845234 | yes | | Support | Cialis (tadalafil) | A | 0.7083 | 0.1042 | 1725 | 1 | 1725 | n/a → 1.0 | Sheet; PMC1885023 | yes | | Support | T3 (liothyronine) | A | 0.9183 | 0.1042 | 6.92 per mcg | 1 | 6920 | n/a → 1.0 | Sheet; PMC5167556 | yes | @@ -228,6 +258,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 | yes | | 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) | yes, except MK-677 (no) | +³ 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; @@ -244,8 +278,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): @@ -268,6 +303,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. @@ -294,6 +340,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.