using Pumas
using PumasUtilities
using AlgebraOfGraphics
using CairoMakie
using DataFrames
using Random
TMDD Part 1b: Understanding TMDD Dynamics Through the Four Phases
1 Introduction
This tutorial builds intuition for Target-Mediated Drug Disposition (TMDD) through the elegant four-phase framework introduced by Peletier & Gabrielsson (2012). Rather than focusing on model fitting, we explore TMDD dynamics through simulation and visualization to develop a deep understanding of what drives the characteristic TMDD profile.
Learning Objectives:
By the end of this tutorial, you will be able to:
- Recognize and interpret the four phases (A, B, C, D) of TMDD profiles
- Understand how each phase relates to underlying biological processes
- Predict how parameter changes affect the concentration-time profile
- Identify which parameters can be estimated from each phase
- Apply this framework to interpret real TMDD data
This tutorial is based on the seminal work of Peletier & Gabrielsson (2012), which provides a comprehensive mathematical analysis of TMDD dynamics. We use their parameter sets and reproduce key figures with permission (CC BY license).
2 Libraries
3 The TMDD Model
We use a one-compartment full TMDD model as the foundation for our exploration. The complete model equations are covered in Part 2 of this series; here we focus on the dynamics and interpretation.
3.1 Model Definition
tmdd_model = @model begin
@metadata begin
desc = "One-compartment Full TMDD Model (Peletier Parameters)"
timeu = u"hr"
end
@param begin
"""
Linear elimination rate constant (hr⁻¹)
"""
tvKel ∈ RealDomain(lower = 0)
"""
Association rate constant (nM⁻¹hr⁻¹)
"""
tvKon ∈ RealDomain(lower = 0)
"""
Dissociation rate constant (hr⁻¹)
"""
tvKoff ∈ RealDomain(lower = 0)
"""
Receptor synthesis rate (nM/hr)
"""
tvKsyn ∈ RealDomain(lower = 0)
"""
Receptor degradation rate (hr⁻¹)
"""
tvKdeg ∈ RealDomain(lower = 0)
"""
Complex internalization rate (hr⁻¹)
"""
tvKint ∈ RealDomain(lower = 0)
end
@pre begin
Kel = tvKel
Kon = tvKon
Koff = tvKoff
Ksyn = tvKsyn
Kdeg = tvKdeg
Kint = tvKint
end
@init begin
Receptor = Ksyn / Kdeg # Baseline receptor level (R0)
end
@vars begin
# Derived constants
R0 := Ksyn / Kdeg
KD := Koff / Kon
KM := (Koff + Kint) / Kon
Rstar := Ksyn / Kint # Maximum receptor pool
# Totals
Ltot := Ligand + Complex
Rtot := Receptor + Complex
end
@dynamics begin
# Free ligand dynamics
Ligand' = -Kel * Ligand - Kon * Ligand * Receptor + Koff * Complex
# Free receptor dynamics
Receptor' = Ksyn - Kdeg * Receptor - Kon * Ligand * Receptor + Koff * Complex
# Ligand-receptor complex dynamics
Complex' = Kon * Ligand * Receptor - Koff * Complex - Kint * Complex
end
@derived begin
L = @. Ligand
R = @. Receptor
RL = @. Complex
L_total = @. Ltot
R_total = @. Rtot
end
endPumasModel
Parameters: tvKel, tvKon, tvKoff, tvKsyn, tvKdeg, tvKint
Random effects:
Covariates:
Dynamical system variables: Ligand, Receptor, Complex
Dynamical system type: Nonlinear ODE
Derived: L, R, RL, L_total, R_total
Observed: L, R, RL, L_total, R_total
3.2 Parameters from Peletier & Gabrielsson (2012)
The original paper uses mass concentration units (mg/L). For consistency with other tutorials and typical mAb modeling, we convert to molar units (nM) assuming a molecular weight of 150 kDa.
Conversion factor: 1 mg/L ≈ 6.67 nM for a 150 kDa molecule.
# Peletier paper parameters (Table 3) converted to nM and hr units
# Original: ke(L) = 0.0015 h⁻¹, kon = 0.091 (mg/L)⁻¹h⁻¹, koff = 0.001 h⁻¹
# kin = 0.11 mg/L/h, kout = 0.0089 h⁻¹, ke(RL) = 0.003 h⁻¹
peletier_params = (
tvKel = 0.0015, # Linear elimination rate (h⁻¹)
tvKon = 0.0136, # Association rate (nM⁻¹h⁻¹) - converted from 0.091 (mg/L)⁻¹h⁻¹
tvKoff = 0.001, # Dissociation rate (h⁻¹)
tvKsyn = 0.73, # Receptor synthesis (nM/h) - converted from 0.11 mg/L/h
tvKdeg = 0.0089, # Receptor degradation (h⁻¹)
tvKint = 0.003, # Internalization rate (h⁻¹)
)
# Calculate derived constants
R0 = peletier_params.tvKsyn / peletier_params.tvKdeg
KD = peletier_params.tvKoff / peletier_params.tvKon
KM = (peletier_params.tvKoff + peletier_params.tvKint) / peletier_params.tvKon
Rstar = peletier_params.tvKsyn / peletier_params.tvKint
println("Derived Constants:")
println(" R₀ (baseline receptor) = $(round(R0, digits=1)) nM")
println(" Kᴅ (dissociation constant) = $(round(KD, digits=3)) nM")
println(" Kᴍ (Michaelis constant) = $(round(KM, digits=3)) nM")
println(" R* (maximum receptor pool) = $(round(Rstar, digits=1)) nM")Derived Constants:
R₀ (baseline receptor) = 82.0 nM
Kᴅ (dissociation constant) = 0.074 nM
Kᴍ (Michaelis constant) = 0.294 nM
R* (maximum receptor pool) = 243.3 nM
3.3 Dose Levels
Following the paper, we simulate four doses that span a wide concentration range:
# Doses matching paper (converted to nM concentrations)
# Original: 1.5, 5, 15, 45 mg/kg with Vc = 0.05 L/kg → L0 = 30, 100, 300, 900 mg/L
# Converted to nM: L0 = 200, 667, 2000, 6000 nM
initial_concs = [200.0, 667.0, 2000.0, 6000.0] # nM
dose_labels = ["200 nM", "667 nM", "2000 nM", "6000 nM"]
println("Initial Concentrations (L₀):")
for (conc, label) in zip(initial_concs, dose_labels)
ratio = conc / R0
println(" $label = $(round(ratio, digits=1))× R₀")
endInitial Concentrations (L₀):
200 nM = 2.4× R₀
667 nM = 8.1× R₀
2000 nM = 24.4× R₀
6000 nM = 73.2× R₀
4 Part I: Bolus Administration
4.1 The Four Phases of TMDD
After a bolus dose, the TMDD concentration-time profile can be divided into four distinct phases. Each phase is dominated by different processes and reveals information about specific parameters.
Show/Hide Code
# Create schematic figure with inset for Phase A
fig = Figure(size = (1000, 600))
# Main axis
ax = Axis(
fig[1, 1],
xlabel = "Time (hr)",
ylabel = "Ligand Concentration (nM)",
yscale = log10,
title = "The Four Phases of TMDD",
)
# Representative profile (moderate dose to show all phases clearly)
pop_schematic = [Subject(id = 1, events = DosageRegimen(200.0, time = 0, cmt = 1))]
obstimes_schematic = [0.001; 0.01:0.01:0.99; 1:0.5:49.5; 50:5:195; 200:20:800]
sim_schematic =
simobs(tmdd_model, pop_schematic, peletier_params, obstimes = obstimes_schematic)
df_schematic = DataFrame(sim_schematic)
lines!(ax, df_schematic.time, max.(df_schematic.L, 1e-4), linewidth = 3, color = :steelblue)
# Phase boundaries (approximate for 200 nM dose)
vlines!(ax, [1], color = :gray, linestyle = :dash, alpha = 0.6)
vlines!(ax, [50], color = :gray, linestyle = :dash, alpha = 0.6)
vlines!(ax, [200], color = :gray, linestyle = :dash, alpha = 0.6)
# Phase labels - positioned in the middle of each phase region
text!(ax, 0.3, 40, text = "A", fontsize = 22, font = :bold, color = :darkred)
text!(ax, 15, 40, text = "B", fontsize = 22, font = :bold, color = :darkgreen)
text!(ax, 100, 40, text = "C", fontsize = 22, font = :bold, color = :darkorange)
text!(ax, 500, 40, text = "D", fontsize = 22, font = :bold, color = :darkblue)
# Reference lines
hlines!(ax, [R0], color = :gray, linestyle = :dot, alpha = 0.5)
text!(ax, 750, R0 * 1.5, text = "R₀", fontsize = 11, color = :gray)
hlines!(ax, [KD], color = :gray, linestyle = :dot, alpha = 0.5)
text!(ax, 750, KD * 0.5, text = "Kᴅ", fontsize = 11, color = :gray)
# Inset for Phase A close-up
ax_inset = Axis(
fig[1, 1],
width = Relative(0.35),
height = Relative(0.35),
halign = 0.95,
valign = 0.95,
xlabel = "Time (hr)",
ylabel = "L (nM)",
title = "Phase A Close-up",
titlesize = 12,
xlabelsize = 10,
ylabelsize = 10,
xticklabelsize = 9,
yticklabelsize = 9,
limits = (0, 2, 150, 220),
)
# Filter data for early timepoints
df_early = filter(row -> row.time <= 2, df_schematic)
lines!(ax_inset, df_early.time, df_early.L, linewidth = 2, color = :steelblue)
# Mark the rapid decline
scatter!(ax_inset, [0.001], [200.0], markersize = 8, color = :red)
text!(ax_inset, 0.15, 210, text = "L₀", fontsize = 10, color = :red)
# Add box around inset region in main plot
# (visual indicator that this is zoomed)
vspan!(ax, 0, 2, color = (:lightblue, 0.15))
# Legend at bottom showing phase descriptions
leg_elements = [
LineElement(color = :darkred, linewidth = 3),
LineElement(color = :darkgreen, linewidth = 3),
LineElement(color = :darkorange, linewidth = 3),
LineElement(color = :darkblue, linewidth = 3),
]
leg_labels = [
"A: Rapid Binding (seconds-minutes)",
"B: Target Saturated (hours-days)",
"C: Transition (days)",
"D: Terminal (days-weeks)",
]
Legend(
fig[2, 1],
leg_elements,
leg_labels,
orientation = :horizontal,
tellheight = true,
tellwidth = false,
framevisible = false,
labelsize = 11,
)
rowsize!(fig.layout, 2, Auto())
fig4.2 Phase A: Rapid Binding (“The Lightning Marriage”)
Immediately after dosing, free ligand rapidly binds to available receptor.
What happens:
- Drug meets target at very high rate
- Complex forms nearly instantaneously
- Free receptor drops from \(R_0\) to near zero
- Free ligand drops by approximately \(R_0\)
Duration: \(\tau_A \sim \frac{1}{k_{on}(L_0 - R_0)}\)
Observable: Sharp initial concentration drop
Parameters identified: \(k_{on}\), \(R_0\)
Show/Hide Code
# Close-up of Phase A
obstimes_phaseA = 0.001:0.005:2.0
pop_phaseA = [
Subject(id = i, events = DosageRegimen(conc, time = 0, cmt = 1)) for
(i, conc) in enumerate(initial_concs)
]
sim_phaseA = simobs(tmdd_model, pop_phaseA, peletier_params, obstimes = obstimes_phaseA)
df_phaseA = DataFrame(sim_phaseA)
dose_label_df = DataFrame(id = string.(1:4), Dose = dose_labels)
df_phaseA = innerjoin(df_phaseA, dose_label_df, on = :id)
fig = Figure(size = (900, 400))
# Ligand concentration
ax1 = Axis(
fig[1, 1],
xlabel = "Time (hr)",
ylabel = "Free Ligand (nM)",
title = "Phase A: Ligand Dynamics",
yscale = log10,
)
for (i, (dose, color)) in enumerate(zip(dose_labels, [:red, :blue, :green, :purple]))
df_dose = filter(row -> row.Dose == dose, df_phaseA)
lines!(
ax1,
df_dose.time,
max.(df_dose.L, 0.01),
linewidth = 2,
color = color,
label = dose,
)
end
hlines!(ax1, [R0], color = :gray, linestyle = :dash, alpha = 0.6)
text!(ax1, 1.8, R0 * 1.3, text = "R₀", fontsize = 10, color = :gray)
# Receptor concentration
ax2 = Axis(
fig[1, 2],
xlabel = "Time (hr)",
ylabel = "Free Receptor (nM)",
title = "Phase A: Receptor Dynamics",
)
for (i, (dose, color)) in enumerate(zip(dose_labels, [:red, :blue, :green, :purple]))
df_dose = filter(row -> row.Dose == dose, df_phaseA)
lines!(ax2, df_dose.time, df_dose.R, linewidth = 2, color = color, label = dose)
end
hlines!(ax2, [R0], color = :gray, linestyle = :dash, alpha = 0.6)
text!(ax2, 1.8, R0 * 0.9, text = "R₀", fontsize = 10, color = :gray)
Legend(fig[1, 3], ax1, "L₀", framevisible = false)
figAt the end of Phase A, the system reaches quasi-equilibrium:
- \(\bar{L} \approx L_0 - R_0\) (if \(L_0 > R_0\))
- \(\bar{R} \approx 0\) (receptor nearly fully occupied)
- \(\bar{RL} \approx R_0\) (complex equals original receptor)
This is why Phase A duration depends on \(k_{on}(L_0 - R_0)\).
4.3 Phase B: Target Saturated (“The Slow Burn”)
Once the target is saturated, the drug elimination becomes linear.
What happens:
- Target fully occupied (\(RL \approx R_{tot}\))
- Linear elimination dominates
- Complex slowly eliminated via internalization
- New receptor synthesized is immediately captured
Observable: Parallel lines on semi-log plot (dose-independent slope)
Slope: \(\lambda_B \approx k_{el}\) (linear elimination rate)
Parameters identified: \(k_{el}\), \(k_{syn}\) (through receptor dynamics)
Show/Hide Code
# Phase B visualization
obstimes_phaseB = 0.1:1:300
sim_phaseB = simobs(tmdd_model, pop_phaseA, peletier_params, obstimes = obstimes_phaseB)
df_phaseB = DataFrame(sim_phaseB)
df_phaseB = innerjoin(df_phaseB, dose_label_df, on = :id)
fig = Figure(size = (700, 500))
ax = Axis(
fig[1, 1],
xlabel = "Time (hr)",
ylabel = "Free Ligand (nM)",
title = "Phase B: Target-Saturated Elimination",
yscale = log10,
limits = (0, 300, 1, 10000),
)
for (i, (dose, color)) in enumerate(zip(dose_labels, [:red, :blue, :green, :purple]))
df_dose = filter(row -> row.Dose == dose, df_phaseB)
lines!(
ax,
df_dose.time,
max.(df_dose.L, 0.01),
linewidth = 2,
color = color,
label = dose,
)
end
# Add reference line for expected slope
t_ref = [50, 200]
L_ref = initial_concs[4] * 0.3 .* exp.(-peletier_params.tvKel .* (t_ref .- 50))
lines!(
ax,
t_ref,
L_ref,
linewidth = 2,
linestyle = :dash,
color = :black,
label = "slope = kel",
)
# Reference lines
hlines!(ax, [R0], color = :gray, linestyle = :dot, alpha = 0.5)
text!(ax, 280, R0 * 1.3, text = "R₀", fontsize = 10, color = :gray)
Legend(fig[1, 2], ax, framevisible = false)
fig4.4 Phase C: Transition (“The Turning Point”)
As ligand concentration decreases and approaches \(K_D\), the system transitions from saturated to unsaturated.
What happens:
- Target begins to recover from suppression
- Target-mediated clearance increases (mixed-order kinetics)
- Concentration drops through \(K_D\) range
- Both elimination pathways are active
Observable: Increased decline rate, inflection in profile
Concentration range: \(0.1 K_D < L < 10 K_D\)
Parameters identified: \(K_D\), \(k_{off}\)
Show/Hide Code
# Extended simulation to show Phase C
obstimes_phaseC = [0.1:1:100; 110:10:500; 550:50:1500]
sim_phaseC = simobs(tmdd_model, pop_phaseA, peletier_params, obstimes = obstimes_phaseC)
df_phaseC = DataFrame(sim_phaseC)
df_phaseC = innerjoin(df_phaseC, dose_label_df, on = :id)
fig = Figure(size = (900, 400))
# Ligand
ax1 = Axis(
fig[1, 1],
xlabel = "Time (hr)",
ylabel = "Free Ligand (nM)",
title = "Ligand Through Phase C",
yscale = log10,
limits = (0, 1500, 0.001, 10000),
)
for (i, (dose, color)) in enumerate(zip(dose_labels, [:red, :blue, :green, :purple]))
df_dose = filter(row -> row.Dose == dose, df_phaseC)
lines!(
ax1,
df_dose.time,
max.(df_dose.L, 1e-4),
linewidth = 2,
color = color,
label = dose,
)
end
hlines!(ax1, [KD], color = :gray, linestyle = :dash, alpha = 0.6)
text!(ax1, 1400, KD * 2, text = "Kᴅ", fontsize = 10, color = :gray)
hlines!(ax1, [10 * KD], color = :gray, linestyle = :dot, alpha = 0.4)
hlines!(ax1, [0.1 * KD], color = :gray, linestyle = :dot, alpha = 0.4)
text!(ax1, 1400, 10 * KD * 1.5, text = "10×Kᴅ", fontsize = 9, color = :gray)
text!(ax1, 1400, 0.1 * KD * 0.5, text = "0.1×Kᴅ", fontsize = 9, color = :gray)
# Receptor recovery
ax2 = Axis(
fig[1, 2],
xlabel = "Time (hr)",
ylabel = "Free Receptor (nM)",
title = "Receptor Recovery in Phase C",
)
for (i, (dose, color)) in enumerate(zip(dose_labels, [:red, :blue, :green, :purple]))
df_dose = filter(row -> row.Dose == dose, df_phaseC)
lines!(ax2, df_dose.time, df_dose.R, linewidth = 2, color = color, label = dose)
end
hlines!(ax2, [R0], color = :gray, linestyle = :dash, alpha = 0.6)
text!(ax2, 1400, R0 * 0.9, text = "R₀", fontsize = 10, color = :gray)
Legend(fig[1, 3], ax1, "L₀", framevisible = false)
fig4.5 Phase D: Terminal (“The Long Goodbye”)
At low ligand concentrations, the terminal phase is reached.
What happens:
- Ligand concentration well below \(K_D\)
- System returns toward baseline
- Terminal elimination dominated by slowest process
- Often internalization-limited
Observable: Linear terminal slope on semi-log plot
Slope: \(\lambda_D \approx k_{int}\) (internalization rate)
Parameters identified: \(k_{int}\), \(K_M\)
Show/Hide Code
# Full simulation including terminal phase
obstimes_full = [0.1:1:100; 110:10:500; 550:50:2500]
sim_full = simobs(tmdd_model, pop_phaseA, peletier_params, obstimes = obstimes_full)
df_full = DataFrame(sim_full)
df_full = innerjoin(df_full, dose_label_df, on = :id)
fig = Figure(size = (700, 500))
ax = Axis(
fig[1, 1],
xlabel = "Time (hr)",
ylabel = "Free Ligand (nM)",
title = "Phase D: Terminal Elimination",
yscale = log10,
limits = (0, 2500, 1e-4, 10000),
)
for (i, (dose, color)) in enumerate(zip(dose_labels, [:red, :blue, :green, :purple]))
df_dose = filter(row -> row.Dose == dose, df_full)
lines!(
ax,
df_dose.time,
max.(df_dose.L, 1e-5),
linewidth = 2,
color = color,
label = dose,
)
end
# Reference lines
hlines!(ax, [R0], color = :gray, linestyle = :dot, alpha = 0.5)
text!(ax, 2400, R0 * 1.5, text = "R₀", fontsize = 10, color = :gray)
hlines!(ax, [KD], color = :gray, linestyle = :dot, alpha = 0.5)
text!(ax, 2400, KD * 0.5, text = "Kᴅ", fontsize = 10, color = :gray)
# Add terminal slope reference (kint)
t_term = [1500, 2400]
L_term = 0.5 * exp.(-peletier_params.tvKint .* (t_term .- 1500))
lines!(
ax,
t_term,
L_term,
linewidth = 2,
linestyle = :dash,
color = :black,
label = "slope = kint",
)
Legend(fig[1, 2], ax, framevisible = false)
figThe terminal slope in TMDD is typically determined by the internalization rate (\(k_{int}\)), not the linear elimination rate (\(k_{el}\)). This is a critical distinction from the Michaelis-Menten approximation, which predicts a different terminal slope.
4.6 Multi-Dose Visualization
Let’s reproduce the key figure from Peletier & Gabrielsson (2012) showing all four doses together:
Show/Hide Code
fig = Figure(size = (1100, 400))
# Left: Full semi-log view
ax1 = Axis(
fig[1, 1],
xlabel = "Time (hr)",
ylabel = "Free Ligand (nM)",
title = "Semi-log Scale",
yscale = log10,
limits = (0, 2500, 1e-4, 10000),
)
for (i, (dose, color)) in enumerate(zip(dose_labels, [:red, :blue, :green, :purple]))
df_dose = filter(row -> row.Dose == dose, df_full)
lines!(
ax1,
df_dose.time,
max.(df_dose.L, 1e-5),
linewidth = 2,
color = color,
label = dose,
)
end
hlines!(ax1, [R0], color = :gray, linestyle = :dot, alpha = 0.5, label = "R₀")
hlines!(ax1, [KD], color = :gray, linestyle = :dot, alpha = 0.5, label = "Kᴅ")
# Middle: Linear scale view (early time)
df_linear = filter(row -> row.time <= 600, df_full)
ax2 = Axis(
fig[1, 2],
xlabel = "Time (hr)",
ylabel = "Free Ligand (nM)",
title = "Linear Scale",
)
for (i, (dose, color)) in enumerate(zip(dose_labels, [:red, :blue, :green, :purple]))
df_dose = filter(row -> row.Dose == dose, df_linear)
lines!(ax2, df_dose.time, df_dose.L, linewidth = 2, color = color, label = dose)
end
# Right: Close-up of Phase A
ax3 = Axis(
fig[1, 3],
xlabel = "Time (hr)",
ylabel = "Free Ligand (nM)",
title = "Phase A Close-up",
limits = (0, 2, nothing, nothing),
)
for (i, (dose, color)) in enumerate(zip(dose_labels, [:red, :blue, :green, :purple]))
df_dose = filter(row -> row.Dose == dose, df_phaseA)
lines!(ax3, df_dose.time, df_dose.L, linewidth = 2, color = color, label = dose)
end
hlines!(ax3, [R0], color = :gray, linestyle = :dot, alpha = 0.5)
Legend(fig[1, 4], ax1, "L₀", framevisible = false)
fig4.7 Receptor Dynamics: The Target’s Story
The receptor dynamics reveal crucial information about target engagement:
Show/Hide Code
fig = Figure(size = (1100, 400))
# Left: Free receptor
ax1 = Axis(
fig[1, 1],
xlabel = "Time (hr)",
ylabel = "Free Receptor (nM)",
title = "Free Receptor (R)",
limits = (0, 2500, nothing, nothing),
)
for (i, (dose, color)) in enumerate(zip(dose_labels, [:red, :blue, :green, :purple]))
df_dose = filter(row -> row.Dose == dose, df_full)
lines!(ax1, df_dose.time, df_dose.R, linewidth = 2, color = color, label = dose)
end
hlines!(ax1, [R0], color = :gray, linestyle = :dash, alpha = 0.6)
text!(ax1, 2300, R0 * 1.05, text = "R₀", fontsize = 10, color = :gray)
# Middle: Complex
ax2 = Axis(fig[1, 2], xlabel = "Time (hr)", ylabel = "Complex (nM)", title = "Complex (RL)")
for (i, (dose, color)) in enumerate(zip(dose_labels, [:red, :blue, :green, :purple]))
df_dose = filter(row -> row.Dose == dose, df_full)
lines!(ax2, df_dose.time, df_dose.RL, linewidth = 2, color = color, label = dose)
end
hlines!(ax2, [R0], color = :gray, linestyle = :dash, alpha = 0.6)
text!(ax2, 2300, R0 * 1.05, text = "R₀", fontsize = 10, color = :gray)
# Right: Total receptor
ax3 = Axis(
fig[1, 3],
xlabel = "Time (hr)",
ylabel = "Total Receptor (nM)",
title = "Total Receptor (Rtot = R + RL)",
)
for (i, (dose, color)) in enumerate(zip(dose_labels, [:red, :blue, :green, :purple]))
df_dose = filter(row -> row.Dose == dose, df_full)
lines!(ax3, df_dose.time, df_dose.R_total, linewidth = 2, color = color, label = dose)
end
hlines!(ax3, [R0], color = :gray, linestyle = :dash, alpha = 0.6)
text!(ax3, 2300, R0 * 1.05, text = "R₀", fontsize = 10, color = :gray)
hlines!(ax3, [Rstar], color = :gray, linestyle = :dot, alpha = 0.6)
text!(ax3, 2300, Rstar * 0.95, text = "R*", fontsize = 10, color = :gray)
Legend(fig[1, 4], ax1, "L₀", framevisible = false)
figThe total receptor \(R_{tot}\) follows a characteristic “Γ curve”:
- Initially jumps to \(R_0\) (all bound as complex)
- Rises toward \(R^* = k_{syn}/k_{int}\) while ligand is present
- Returns to \(R_0\) after washout
The maximum receptor pool \(R^*\) represents the balance between synthesis and internalization, which exceeds \(R_0\) when \(k_{int} < k_{deg}\) (as is often the case for mAbs).
4.8 Log-Scale Receptor Dynamics
The receptor dynamics on log scale reveal bi-exponential behavior:
Show/Hide Code
fig = Figure(size = (900, 400))
# Free receptor on log scale
ax1 = Axis(
fig[1, 1],
xlabel = "Time (hr)",
ylabel = "Free Receptor (nM)",
title = "Free Receptor (log scale)",
yscale = log10,
limits = (0, 2500, 1e-3, 200),
)
for (i, (dose, color)) in enumerate(zip(dose_labels, [:red, :blue, :green, :purple]))
df_dose = filter(row -> row.Dose == dose, df_full)
lines!(
ax1,
df_dose.time,
max.(df_dose.R, 1e-4),
linewidth = 2,
color = color,
label = dose,
)
end
hlines!(ax1, [R0], color = :gray, linestyle = :dash, alpha = 0.6)
text!(ax1, 2300, R0 * 1.3, text = "R₀", fontsize = 10, color = :gray)
# Total receptor on log scale
ax2 = Axis(
fig[1, 2],
xlabel = "Time (hr)",
ylabel = "Total Receptor (nM)",
title = "Total Receptor (log scale)",
yscale = log10,
limits = (0, 2500, 10, 500),
)
for (i, (dose, color)) in enumerate(zip(dose_labels, [:red, :blue, :green, :purple]))
df_dose = filter(row -> row.Dose == dose, df_full)
lines!(ax2, df_dose.time, df_dose.R_total, linewidth = 2, color = color, label = dose)
end
hlines!(ax2, [R0], color = :gray, linestyle = :dash, alpha = 0.6)
text!(ax2, 2300, R0 * 1.2, text = "R₀", fontsize = 10, color = :gray)
hlines!(ax2, [Rstar], color = :gray, linestyle = :dot, alpha = 0.6)
text!(ax2, 2300, Rstar * 0.9, text = "R*", fontsize = 10, color = :gray)
Legend(fig[1, 3], ax1, "L₀", framevisible = false)
fig4.9 High Dose vs Low Dose: When Phases Disappear
The characteristic four-phase profile requires \(L_0 > R_0\). At low doses, some phases may be absent or difficult to observe:
Show/Hide Code
# Extended dose range including sub-R0 doses
extended_concs = [10.0, 40.0, 82.0, 200.0, 667.0, 2000.0, 6000.0, 12000.0] # nM
extended_labels =
["10 nM", "40 nM", "82 nM", "200 nM", "667 nM", "2000 nM", "6000 nM", "12000 nM"]
pop_extended = [
Subject(id = i, events = DosageRegimen(conc, time = 0, cmt = 1)) for
(i, conc) in enumerate(extended_concs)
]
sim_extended = simobs(tmdd_model, pop_extended, peletier_params, obstimes = obstimes_full)
df_extended = DataFrame(sim_extended)
extended_label_df = DataFrame(id = string.(1:8), Dose = extended_labels)
df_extended = innerjoin(df_extended, extended_label_df, on = :id)
fig = Figure(size = (800, 500))
ax = Axis(
fig[1, 1],
xlabel = "Time (hr)",
ylabel = "Free Ligand (nM)",
title = "Dose-Dependent TMDD Profiles (8 doses)",
yscale = log10,
limits = (0, 2500, 1e-4, 20000),
)
colors = cgrad(:viridis, 8, categorical = true)
for (i, dose) in enumerate(extended_labels)
df_dose = filter(row -> row.Dose == dose, df_extended)
lines!(
ax,
df_dose.time,
max.(df_dose.L, 1e-5),
linewidth = 2,
color = colors[i],
label = dose,
)
end
hlines!(ax, [R0], color = :gray, linestyle = :dash, alpha = 0.6)
text!(ax, 2300, R0 * 1.5, text = "R₀", fontsize = 10, color = :gray)
hlines!(ax, [KD], color = :gray, linestyle = :dot, alpha = 0.5)
text!(ax, 2300, KD * 0.5, text = "Kᴅ", fontsize = 10, color = :gray)
Legend(fig[1, 2], ax, "L₀", framevisible = false)
fig- Low doses (\(L_0 < R_0\)): Most ligand immediately bound; appears bi-exponential
- Near R₀ (\(L_0 \approx R_0\)): Brief Phase B, rapid transition
- High doses (\(L_0 >> R_0\)): Extended Phase B, clear four-phase structure
For clinical dose selection, this framework helps predict which profiles will show TMDD signatures.
4.10 Parameter Identification from the Profile
Each phase provides information about specific TMDD parameters:
| Phase | Observable Feature | Parameters Identified |
|---|---|---|
| A | Duration of rapid decline | \(k_{on}\), \(R_0\) |
| B | Slope during saturation | \(k_{el}\) (linear clearance) |
| C | Transition point, curvature | \(K_D\), \(k_{off}\) |
| D | Terminal slope | \(k_{int}\), \(K_M\) |
Show/Hide Code
fig = Figure(size = (900, 550))
df_single = filter(row -> row.Dose == "2000 nM", df_full)
ax = Axis(
fig[1, 1],
xlabel = "Time (hr)",
ylabel = "Free Ligand (nM)",
title = "Parameter Identification from TMDD Profile",
yscale = log10,
limits = (0, 2500, 1e-4, 5000),
)
lines!(ax, df_single.time, max.(df_single.L, 1e-5), linewidth = 3, color = :steelblue)
# Phase A annotation
bracket!(
ax,
Point2f(0, log10(1800)),
Point2f(0.5, log10(1800)),
orientation = :down,
width = 20,
color = :darkred,
)
text!(
ax,
0.25,
1200,
text = "kon, R₀",
fontsize = 11,
color = :darkred,
align = (:center, :top),
)
# Phase B annotation - slope line
t_B = [10, 200]
L_B = 1500 * exp.(-peletier_params.tvKel .* (t_B .- 10))
lines!(ax, t_B, L_B, linewidth = 2, linestyle = :dash, color = :darkgreen)
text!(
ax,
80,
800,
text = "slope = kel",
fontsize = 11,
color = :darkgreen,
rotation = -0.15,
)
# Phase C annotation - KD reference
vspan!(ax, 300, 700, color = (:darkorange, 0.1))
text!(
ax,
500,
100,
text = "KD, koff\nregion",
fontsize = 11,
color = :darkorange,
align = (:center, :center),
)
# Phase D annotation - terminal slope
t_D = [1200, 2400]
L_D = 0.3 * exp.(-peletier_params.tvKint .* (t_D .- 1200))
lines!(ax, t_D, L_D, linewidth = 2, linestyle = :dash, color = :darkblue)
text!(
ax,
1800,
0.15,
text = "slope = kint",
fontsize = 11,
color = :darkblue,
rotation = -0.05,
)
# Reference lines
hlines!(ax, [R0], color = :gray, linestyle = :dot, alpha = 0.5)
text!(ax, 2400, R0 * 1.5, text = "R₀", fontsize = 10, color = :gray)
hlines!(ax, [KD], color = :gray, linestyle = :dot, alpha = 0.5)
text!(ax, 2400, KD * 0.5, text = "Kᴅ", fontsize = 10, color = :gray)
fig5 Part II: Constant-Rate Infusion
Constant-rate infusion provides additional insights into TMDD dynamics, particularly steady-state behavior and washout kinetics.
5.1 Steady-State Analysis
At steady state during infusion, all time derivatives equal zero. The steady-state concentrations depend on the infusion rate \(I\) (nM/hr):
Show/Hide Code
# Model with infusion support
tmdd_infusion = @model begin
@param begin
tvKel ∈ RealDomain(lower = 0)
tvKon ∈ RealDomain(lower = 0)
tvKoff ∈ RealDomain(lower = 0)
tvKsyn ∈ RealDomain(lower = 0)
tvKdeg ∈ RealDomain(lower = 0)
tvKint ∈ RealDomain(lower = 0)
end
@pre begin
Kel = tvKel
Kon = tvKon
Koff = tvKoff
Ksyn = tvKsyn
Kdeg = tvKdeg
Kint = tvKint
end
@init begin
Receptor = Ksyn / Kdeg
end
@dynamics begin
Ligand' = -Kel * Ligand - Kon * Ligand * Receptor + Koff * Complex
Receptor' = Ksyn - Kdeg * Receptor - Kon * Ligand * Receptor + Koff * Complex
Complex' = Kon * Ligand * Receptor - Koff * Complex - Kint * Complex
end
@derived begin
L = @. Ligand
R = @. Receptor
RL = @. Complex
end
end
# Simulate various infusion rates to steady state
infusion_rates = 10.0 .^ range(-2, 2, length = 30) # nM/hr
steady_state_L = Float64[]
steady_state_R = Float64[]
for rate in infusion_rates
# Long infusion to reach steady state
pop_inf = [
Subject(
id = 1,
events = DosageRegimen(rate * 5000, time = 0, duration = 5000, cmt = 1),
),
]
sim_inf = simobs(tmdd_infusion, pop_inf, peletier_params, obstimes = [4900])
push!(steady_state_L, sim_inf[1].observations.L[1])
push!(steady_state_R, sim_inf[1].observations.R[1])
end
fig = Figure(size = (800, 400))
ax1 = Axis(
fig[1, 1],
xlabel = "Infusion Rate (nM/hr)",
ylabel = "Steady-State Concentration (nM)",
title = "Steady-State vs Infusion Rate",
xscale = log10,
yscale = log10,
)
lines!(ax1, infusion_rates, steady_state_L, linewidth = 2, color = :blue, label = "Lss")
lines!(ax1, infusion_rates, steady_state_R, linewidth = 2, color = :red, label = "Rss")
hlines!(ax1, [R0], color = :gray, linestyle = :dash, alpha = 0.6)
text!(ax1, 0.02, R0 * 0.7, text = "R₀", fontsize = 10, color = :gray)
hlines!(ax1, [KD], color = :gray, linestyle = :dot, alpha = 0.5)
text!(ax1, 0.02, KD * 0.5, text = "Kᴅ", fontsize = 10, color = :gray)
Legend(fig[1, 2], ax1, framevisible = false)
fig- At low infusion rates: \(L_{ss} \approx 0\), \(R_{ss} \approx R_0\) (target not suppressed)
- At high infusion rates: \(L_{ss}\) increases linearly, \(R_{ss} \rightarrow 0\) (target suppressed)
- The transition occurs around the infusion rate that produces \(L_{ss} \approx K_D\)
5.2 Build-up Dynamics
The approach to steady state during infusion shows characteristic TMDD behavior:
Show/Hide Code
# Infusion build-up at different rates
infusion_rates_buildup = [0.1, 0.5, 2.0, 10.0] # nM/hr
infusion_labels = ["0.1 nM/hr", "0.5 nM/hr", "2.0 nM/hr", "10.0 nM/hr"]
obstimes_inf = 0:10:3000
fig = Figure(size = (900, 400))
ax1 = Axis(
fig[1, 1],
xlabel = "Time (hr)",
ylabel = "Free Ligand (nM)",
title = "Ligand Build-up During Infusion",
yscale = log10,
limits = (0, 3000, 0.001, 1000),
)
ax2 = Axis(
fig[1, 2],
xlabel = "Time (hr)",
ylabel = "Free Receptor (nM)",
title = "Receptor Suppression During Infusion",
)
colors = [:red, :blue, :green, :purple]
for (i, (rate, label, color)) in
enumerate(zip(infusion_rates_buildup, infusion_labels, colors))
pop_inf = [
Subject(
id = 1,
events = DosageRegimen(rate * 3000, time = 0, duration = 3000, cmt = 1),
),
]
sim_inf = simobs(tmdd_infusion, pop_inf, peletier_params, obstimes = obstimes_inf)
df_inf = DataFrame(sim_inf)
lines!(
ax1,
df_inf.time,
max.(df_inf.L, 1e-4),
linewidth = 2,
color = color,
label = label,
)
lines!(ax2, df_inf.time, df_inf.R, linewidth = 2, color = color, label = label)
end
hlines!(ax1, [KD], color = :gray, linestyle = :dash, alpha = 0.6)
text!(ax1, 2800, KD * 0.5, text = "Kᴅ", fontsize = 10, color = :gray)
hlines!(ax2, [R0], color = :gray, linestyle = :dash, alpha = 0.6)
text!(ax2, 2800, R0 * 0.95, text = "R₀", fontsize = 10, color = :gray)
Legend(fig[1, 3], ax1, "Rate", framevisible = false)
fig5.3 Washout Dynamics
After stopping infusion, the washout dynamics resemble post-bolus behavior:
Show/Hide Code
# Infusion followed by washout
rate = 5.0 # nM/hr
infusion_duration = 1000 # hr
pop_washout = [
Subject(
id = 1,
events = DosageRegimen(
rate * infusion_duration,
time = 0,
duration = infusion_duration,
cmt = 1,
),
),
]
obstimes_washout = [0:10:1000; 1010:10:2000; 2050:50:4000]
sim_washout =
simobs(tmdd_infusion, pop_washout, peletier_params, obstimes = obstimes_washout)
df_washout = DataFrame(sim_washout)
fig = Figure(size = (900, 400))
ax1 = Axis(
fig[1, 1],
xlabel = "Time (hr)",
ylabel = "Free Ligand (nM)",
title = "Ligand: Infusion and Washout",
yscale = log10,
limits = (0, 4000, 0.0001, 100),
)
lines!(ax1, df_washout.time, max.(df_washout.L, 1e-5), linewidth = 2, color = :steelblue)
vlines!(ax1, [infusion_duration], color = :gray, linestyle = :dash, alpha = 0.6)
text!(
ax1,
infusion_duration + 50,
30,
text = "Stop\ninfusion",
fontsize = 10,
color = :gray,
)
hlines!(ax1, [KD], color = :gray, linestyle = :dot, alpha = 0.5)
text!(ax1, 3800, KD * 0.5, text = "Kᴅ", fontsize = 10, color = :gray)
ax2 = Axis(
fig[1, 2],
xlabel = "Time (hr)",
ylabel = "Free Receptor (nM)",
title = "Receptor: Suppression and Recovery",
)
lines!(ax2, df_washout.time, df_washout.R, linewidth = 2, color = :firebrick)
vlines!(ax2, [infusion_duration], color = :gray, linestyle = :dash, alpha = 0.6)
hlines!(ax2, [R0], color = :gray, linestyle = :dash, alpha = 0.6)
text!(ax2, 3800, R0 * 0.95, text = "R₀", fontsize = 10, color = :gray)
fig6 Part III: Comparison with Michaelis-Menten Approximation
The Michaelis-Menten (MM) quasi-steady-state approximation is commonly used to simplify TMDD models. Let’s compare it with the full model.
6.1 Michaelis-Menten Model
# MM approximation model
mm_model = @model begin
@param begin
tvKel ∈ RealDomain(lower = 0)
tvVmax ∈ RealDomain(lower = 0) # Maximum elimination rate
tvKM ∈ RealDomain(lower = 0) # Michaelis constant
end
@pre begin
Kel = tvKel
Vmax = tvVmax
KM = tvKM
end
@dynamics begin
Ligand' = -Kel * Ligand - Vmax * Ligand / (KM + Ligand)
end
@derived begin
L = @. Ligand
end
end
# Convert full TMDD parameters to MM equivalent
# Vmax = kint * Rtot ≈ kint * R0 (assuming Rtot ≈ R0 at high L)
# KM = (koff + kint) / kon
Vmax_equiv = peletier_params.tvKint * R0
KM_equiv = KM
mm_params = (tvKel = peletier_params.tvKel, tvVmax = Vmax_equiv, tvKM = KM_equiv)
println("MM Approximation Parameters:")
println(" Vmax = $(round(Vmax_equiv, digits=3)) nM/hr")
println(" KM = $(round(KM_equiv, digits=3)) nM")MM Approximation Parameters:
Vmax = 0.246 nM/hr
KM = 0.294 nM
6.2 Full Model vs MM Approximation
Show/Hide Code
# Simulate both models at same dose
dose_compare = 2000.0 # nM
pop_compare = [Subject(id = 1, events = DosageRegimen(dose_compare, time = 0, cmt = 1))]
sim_full_compare =
simobs(tmdd_model, pop_compare, peletier_params, obstimes = obstimes_full)
sim_mm = simobs(mm_model, pop_compare, mm_params, obstimes = obstimes_full)
df_full_compare = DataFrame(sim_full_compare)
df_mm = DataFrame(sim_mm)
fig = Figure(size = (900, 500))
# Full time course
ax1 = Axis(
fig[1, 1],
xlabel = "Time (hr)",
ylabel = "Free Ligand (nM)",
title = "Full Model vs MM Approximation",
yscale = log10,
limits = (0, 2500, 1e-4, 5000),
)
lines!(
ax1,
df_full_compare.time,
max.(df_full_compare.L, 1e-5),
linewidth = 3,
color = :steelblue,
label = "Full TMDD",
)
lines!(
ax1,
df_mm.time,
max.(df_mm.L, 1e-5),
linewidth = 2,
linestyle = :dash,
color = :red,
label = "MM Approx",
)
hlines!(ax1, [KD], color = :gray, linestyle = :dot, alpha = 0.5)
text!(ax1, 2400, KD * 0.5, text = "Kᴅ", fontsize = 10, color = :gray)
hlines!(ax1, [KM], color = :gray, linestyle = :dot, alpha = 0.5)
text!(ax1, 2400, KM * 2, text = "Kᴍ", fontsize = 10, color = :gray)
Legend(fig[1, 2], ax1, framevisible = false)
# Early time close-up (Phase A)
ax2 = Axis(
fig[2, 1],
xlabel = "Time (hr)",
ylabel = "Free Ligand (nM)",
title = "Phase A: MM Misses Rapid Binding",
limits = (0, 5, 1000, 2200),
)
df_full_early = filter(row -> row.time <= 5, df_full_compare)
df_mm_early = filter(row -> row.time <= 5, df_mm)
lines!(
ax2,
df_full_early.time,
df_full_early.L,
linewidth = 3,
color = :steelblue,
label = "Full TMDD",
)
lines!(
ax2,
df_mm_early.time,
df_mm_early.L,
linewidth = 2,
linestyle = :dash,
color = :red,
label = "MM Approx",
)
# Terminal phase close-up
ax3 = Axis(
fig[2, 2],
xlabel = "Time (hr)",
ylabel = "Free Ligand (nM)",
title = "Phase D: Different Terminal Slopes",
yscale = log10,
limits = (1000, 2500, 1e-4, 10),
)
df_full_late = filter(row -> row.time >= 1000, df_full_compare)
df_mm_late = filter(row -> row.time >= 1000, df_mm)
lines!(
ax3,
df_full_late.time,
max.(df_full_late.L, 1e-5),
linewidth = 3,
color = :steelblue,
label = "Full TMDD",
)
lines!(
ax3,
df_mm_late.time,
max.(df_mm_late.L, 1e-5),
linewidth = 2,
linestyle = :dash,
color = :red,
label = "MM Approx",
)
# Add slope reference lines
t_term = [1500, 2400]
L_full_term = 0.3 * exp.(-peletier_params.tvKint .* (t_term .- 1500))
L_mm_term = 0.3 * exp.(-(peletier_params.tvKel + Vmax_equiv / KM_equiv) .* (t_term .- 1500))
lines!(ax3, t_term, L_full_term, linewidth = 1.5, linestyle = :dot, color = :steelblue)
lines!(ax3, t_term, max.(L_mm_term, 1e-5), linewidth = 1.5, linestyle = :dot, color = :red)
fig6.3 When MM Works (and When It Doesn’t)
| Aspect | Full TMDD | MM Approximation |
|---|---|---|
| Phase A | Captures rapid binding | Cannot represent |
| Phase B | ✓ Similar | ✓ Similar |
| Phase C | ✓ Similar | ✓ Similar (if \(K_M \approx K_D\)) |
| Phase D | Slope \(\approx k_{int}\) | Slope \(\approx k_{el} + V_{max}/K_M\) |
| Terminal t½ | Determined by slowest process | Often overestimated |
The MM approximation may:
- Miss rapid initial binding (Phase A) - affects early timepoints
- Predict incorrect terminal slope - affects extrapolation
- Bias \(K_M\) estimates when fitted to data spanning all phases
The full model is preferred when:
- Early timepoints are available and important
- Terminal phase is observed and informs dosing
- Receptor dynamics are of interest
7 Summary: The TMDD Fingerprint
The four-phase framework provides a powerful lens for understanding TMDD:
7.1 Key Takeaways
Phase A (Rapid Binding): Duration ~\(1/(k_{on}(L_0 - R_0))\); reveals \(k_{on}\), \(R_0\)
Phase B (Target Saturated): Slope ~\(k_{el}\); parallel lines across doses
Phase C (Transition): Occurs around \(L \sim K_D\); reveals binding affinity
Phase D (Terminal): Slope ~\(k_{int}\); often internalization-limited
7.2 When to Suspect TMDD in Your Data
7.3 Quick Reference Card
| Parameter | Typical Phase | How to Identify |
|---|---|---|
| \(k_{on}\) | A | Duration of rapid binding |
| \(R_0\) | A, C | Initial drop magnitude |
| \(k_{el}\) | B | Slope during saturation |
| \(k_{syn}\) | B, C | Receptor recovery rate |
| \(K_D\) | C | Transition concentration |
| \(k_{off}\) | C | Derived from \(K_D\) and \(k_{on}\) |
| \(k_{int}\) | D | Terminal slope |
| \(K_M\) | C, D | Curvature through transition |