Piecewise-linear intermittent androgen suppression
The Hirata piecewise-linear model of intermittent androgen suppression for one of three patient types.
- Name:
piecewise_linear_ias - Field:
biology/oncology - Formalism: ordinary differential equations
- References: Padmanabhan, Meskin, and Al Moustafa 2021; Hirata, Bruchovsky, and Aihara 2010
Factory
@register_model(
field='biology/oncology',
tags=('hormone therapy', 'ode', 'prostate cancer', 'piecewise linear', 'events', 'intermittent'),
references=(BOOK, 'Hirata, Bruchovsky, and Aihara 2010'),
defaults={'until': 1500.0, 'dt': 5.0},
)
def piecewise_linear_ias(patient: int = 1, *, h0: float = 4.0, h1: float = 10.0) -> System:
"""Return the Hirata piecewise-linear model of intermittent androgen suppression for one of three patient types.
The androgen-dependent, adapted, and mutated cells grow and change into each other linearly, with one set of
rates during therapy and one off therapy, and the therapy switches on when the PSA, the sum of the cells, rises
through `h1` and off when it falls through `h0`. The rates are the book's daily factors, `w = omega - 1` on the
diagonal and `w = omega` off it. Patient 1 does not relapse, patient 2 relapses late, and patient 3 relapses,
once the mutated cells outgrow the therapy. The book only states that therapy starts above 10 ng/mL, so the
therapy stops below 4 ng/mL and is on at the start when the PSA exceeds `h1`. The cells are in units of the PSA,
ng/mL, and time is in days.
Args:
patient:
The patient type, 1, 2, or 3, which sets the rates and the initial cells.
h0:
The PSA below which the therapy stops.
h1:
The PSA above which the therapy starts.
Returns:
system:
The model, with the cells `tumour.AD`, `tumour.AIR`, and `tumour.AII`, the therapy `tumour.u`, and the
switches as `on` and `off` events.
Raises:
ValidationError:
The patient type is not 1, 2, or 3.
Examples:
>>> from skcomplex.models.biology.oncology import piecewise_linear_ias
>>> piecewise_linear_ias(patient=3).elements[0].AD
11.02
"""
if patient not in PATIENT_FACTORS:
error_msg = f'The patient type must be 1, 2, or 3, got {patient!r}.'
raise ValidationError(error_msg)
factors = PATIENT_FACTORS[patient]
on11, on21, on22, on31, on32, on33 = factors['on']
off11, off12, off22, off33 = factors['off']
AD, AIR, AII = factors['cells']
tumour = PiecewiseLinearProstateElement(
'tumour',
AD=AD,
AIR=AIR,
AII=AII,
u=float(h1 < AD + AIR + AII),
w11_on=on11 - 1,
w21_on=on21,
w22_on=on22 - 1,
w31_on=on31,
w32_on=on32,
w33_on=on33 - 1,
w11_off=off11 - 1,
w12_off=off12,
w22_off=off22 - 1,
w33_off=off33 - 1,
)
switches = _get_switches(
lambda s: s.target.x1 * s.target.AD + s.target.x2 * s.target.AIR + s.target.x3 * s.target.AII,
h0,
h1,
)
return System(elements=[tumour], interactions=switches)
Components
class PiecewiseLinearProstateElement(Element):
"""Androgen-dependent, reversibly and irreversibly independent prostate cancer cells under intermittent therapy.
During therapy, `u = 1`, the cells follow `dAD/dt = w11_on AD`, `dAIR/dt = w21_on AD + w22_on AIR`, and
`dAII/dt = w31_on AD + w32_on AIR + w33_on AII`. Off therapy they follow `dAD/dt = w11_off AD + w12_off AIR`,
`dAIR/dt = w22_off AIR`, and `dAII/dt = w33_off AII`. Switches on the PSA `x1 AD + x2 AIR + x3 AII` set the
therapy `u`.
Args:
label:
The label of the tumour.
visual:
How the element is drawn, or `None` for `default_visual_`.
AD:
The initial androgen-dependent cells.
AIR:
The initial reversibly independent cells.
AII:
The initial irreversibly independent cells.
u:
The initial therapy, 1 on and 0 off.
w11_on:
The net growth rate of the dependent cells during therapy.
w21_on:
The rate at which dependent cells adapt during therapy.
w22_on:
The net growth rate of the adapted cells during therapy.
w31_on:
The rate at which dependent cells mutate during therapy.
w32_on:
The rate at which adapted cells mutate during therapy.
w33_on:
The net growth rate of the mutated cells during therapy.
w11_off:
The net growth rate of the dependent cells off therapy.
w12_off:
The rate at which adapted cells return to dependence off therapy.
w22_off:
The net growth rate of the adapted cells off therapy.
w33_off:
The net growth rate of the mutated cells off therapy.
x1:
The PSA per dependent cell.
x2:
The PSA per adapted cell.
x3:
The PSA per mutated cell.
Examples:
>>> from skcomplex.models.biology.oncology import PiecewiseLinearProstateElement
>>> PiecewiseLinearProstateElement('tumour').u
1.0
"""
def __init__(
self,
label: str,
visual: VisualSpec | None = None,
*,
AD: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 17.84,
AIR: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.778,
AII: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.382,
u: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
w11_on: Annotated[float, ParameterSpec()] = -0.043844,
w21_on: Annotated[float, ParameterSpec()] = 0.00130716,
w22_on: Annotated[float, ParameterSpec()] = -0.004282,
w31_on: Annotated[float, ParameterSpec()] = 0.0,
w32_on: Annotated[float, ParameterSpec()] = 0.0,
w33_on: Annotated[float, ParameterSpec()] = 0.00181,
w11_off: Annotated[float, ParameterSpec()] = 0.00223,
w12_off: Annotated[float, ParameterSpec()] = 0.1,
w22_off: Annotated[float, ParameterSpec()] = 0.00392,
w33_off: Annotated[float, ParameterSpec()] = -0.2,
x1: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
x2: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
x3: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
) -> None:
super().__init__(label, visual=visual)
self.AD = AD
self.AIR = AIR
self.AII = AII
self.u = u
self.w11_on = w11_on
self.w21_on = w21_on
self.w22_on = w22_on
self.w31_on = w31_on
self.w32_on = w32_on
self.w33_on = w33_on
self.w11_off = w11_off
self.w12_off = w12_off
self.w22_off = w22_off
self.w33_off = w33_off
self.x1 = x1
self.x2 = x2
self.x3 = x3
def _get_laws(self, s: SimpleNamespace) -> list[BaseLaw]:
"""Return the linear growth and transitions of each mode and the switching of the therapy.
Args:
s:
The namespace of the symbols the laws can use.
Returns:
laws:
The laws of the component.
"""
on, off = s.u, 1 - s.u
return [
RateLaw(s.AD, on * s.w11_on * s.AD + off * (s.w11_off * s.AD + s.w12_off * s.AIR)),
RateLaw(s.AIR, on * (s.w21_on * s.AD + s.w22_on * s.AIR) + off * s.w22_off * s.AIR),
RateLaw(s.AII, on * (s.w31_on * s.AD + s.w32_on * s.AIR + s.w33_on * s.AII) + off * s.w33_off * s.AII),
]
class TherapySwitchInteraction(CrossingInteraction):
"""Switch that sets the therapy state of a tumour to a value when a signal crosses a threshold in a direction.
A pair of switches, one that sets the therapy to 1 when the signal rises through a high threshold and one that
sets it to 0 when the signal falls through a low one, is the relay with hysteresis of intermittent therapy. Every
switch is an event labelled by the interaction.
Args:
label:
The label of the interaction and of its events.
target:
The path of the tumour.
signal:
A function of the namespace that returns the signal, such as the PSA of the tumour.
threshold:
The value of the signal at the switch.
value:
The value of the therapy state after the switch.
state:
The name of the therapy state.
direction:
The direction of the crossing, 1 for a rising and -1 for a falling signal.
Raises:
ValidationError:
The direction is not -1, 0, or 1, or the signal is not a function.
Examples:
>>> from skcomplex.models.biology.oncology import TherapySwitchInteraction
>>> switch = TherapySwitchInteraction('on', target='tumour', signal=lambda s: s.target.AD, threshold=15.0)
>>> switch.check().threshold
15.0
"""
def __init__(
self,
label: str,
*,
target: Annotated[str, RoleSpec()],
signal: Callable[[SimpleNamespace], object],
threshold: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
value: float = 1.0,
state: str = 'u',
direction: int = 1,
) -> None:
super().__init__(
label,
target=target,
guard=self._get_guard,
reset={state: self._get_value},
direction=direction,
)
self.signal = signal
self.threshold = threshold
self.value = value
self.state = state
def _get_guard(self, s: SimpleNamespace) -> Expression:
"""Return the signal above the threshold."""
return self.signal(s) - s.threshold
def _get_value(self, s: SimpleNamespace) -> float:
"""Return the value of the therapy state after the switch."""
return self.value
Equations
tumour.AD: diff(AD_tumour(t), t) == w11_on_tumour*AD_tumour(t)*u_tumour(t) - (w11_off_tumour*AD_tumour(t) + w12_off_tumour*AIR_tumour(t))*(u_tumour(t) - 1)
tumour.AIR: diff(AIR_tumour(t), t) == -w22_off_tumour*(u_tumour(t) - 1)*AIR_tumour(t) + (w21_on_tumour*AD_tumour(t) + w22_on_tumour*AIR_tumour(t))*u_tumour(t)
tumour.AII: diff(AII_tumour(t), t) == -w33_off_tumour*(u_tumour(t) - 1)*AII_tumour(t) + (w31_on_tumour*AD_tumour(t) + w33_on_tumour*AII_tumour(t) + w32_on_tumour*AIR_tumour(t))*u_tumour(t)
tumour.u: diff(u_tumour(t), t) == 0