Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<SheetRow> = emptyList(),
)

// Test E and Test C: the sheet says "No" multiplier, but the model scales them.
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<String, PkParams> = 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!!))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading