Skip to content

Commit 08da490

Browse files
FFroehlichclaude
andauthored
assignments_to_experiments: convert values that ramp with time, via a rate rule
A condition change is applied once at the start of its period, with `time` set to the period's start, so a value that changes with time within a period cannot be a condition change on a constant target, and such assignments were rejected. Fujita_SciSignal2010's EGF input is one: `piecewise(EGF_rate * time + EGF_0, time <= EGF_end, 0)`, steps in training and ramps in its held-out data. A value may now change linearly with time. The target then becomes a differential entity, with the rate rule `d target/dt = <target>_slope` and a new constant parameter for the slope, and each period sets both the target's value at the period start and its slope. During preequilibration the value is held at its value at time 0 with slope zero, and the first period after preequilibration sets the slope again. An assignment that only switches between constant values converts exactly as before, as a constant parameter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MNJePorYs9zmPYMam6g4EP
1 parent 93e8bf9 commit 08da490

2 files changed

Lines changed: 213 additions & 13 deletions

File tree

‎petab/v2/converters.py‎

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -674,7 +674,8 @@ def assignments_to_experiments(problem: Problem) -> Problem:
674674
:data:`DEFAULT_EXPERIMENT_ID`, if needed.
675675
676676
**Scope:** conversion is supported for SBML assignment rules that depend
677-
on time only through the conditions of piecewise functions, where
677+
on time through the conditions of piecewise functions, and at most
678+
linearly through their values, where
678679
679680
* every such condition is an inequality that can be re-arranged
680681
explicitly into `t </<=/>/>= ...`,
@@ -688,7 +689,13 @@ def assignments_to_experiments(problem: Problem) -> Problem:
688689
given at different times (`d1 * piecewise(0, t < t1, 1) + d2 *
689690
piecewise(0, t < t2, 1)`), phases one after another as several pieces or
690691
nested piecewise functions (`piecewise(b0, t < t1, piecewise(b1, t < t2,
691-
b2))`). Each switch that changes the value becomes a period. Anything else
692+
b2))`). Each switch that changes the value becomes a period. A value may
693+
also change linearly with time within a period (a ramp: `piecewise(r * t
694+
+ a, t <= t_end, 0)`). A condition change is applied once at the start of
695+
its period, so the target of such an assignment becomes a differential
696+
entity instead, with the rate rule `d target/dt = <target>_slope`, and
697+
each period sets both the target and its slope; an assignment that only
698+
switches between constant values stays a constant parameter. Anything else
692699
raises a :class:`NotImplementedError`.
693700
694701
.. warning::
@@ -767,10 +774,27 @@ def assignments_to_experiments(problem: Problem) -> Problem:
767774
)
768775
model_values = {
769776
s: sbml_model.getParameter(str(s)).getValue()
770-
for s in default.free_symbols
777+
for s in default.free_symbols - {time}
771778
}
772779
target = sbml_model.getParameter(target_id)
773-
target.setValue(float(default.subs(model_values)))
780+
# A value that changes with time within a period (a ramp) cannot be a
781+
# condition change on a constant target, which is applied once at
782+
# the start of its period. The target becomes a differential entity
783+
# instead, `d target/dt = <target>_slope`, and each period sets both.
784+
ramps = expr.xreplace({c: sp.Dummy() for c in switches}).has(time)
785+
slope_id = None
786+
if ramps:
787+
slope_id = f"{target_id}_slope"
788+
while sbml_model.getElementBySId(slope_id) is not None:
789+
slope_id += "_"
790+
slope = sbml_model.createParameter()
791+
slope.setId(slope_id)
792+
slope.setConstant(False)
793+
slope.setValue(float(sp.diff(default, time).subs(model_values)))
794+
rate_rule = sbml_model.createRateRule()
795+
rate_rule.setVariable(target_id)
796+
rate_rule.setMath(libsbml.parseL3Formula(slope_id))
797+
target.setValue(float(default.subs(time, 0).subs(model_values)))
774798
target.setConstant(False)
775799
assignment.removeFromParentAndDelete()
776800
symbol_ids = {str(s) for s in expr.free_symbols}
@@ -802,11 +826,16 @@ def assignments_to_experiments(problem: Problem) -> Problem:
802826

803827
if period.is_preequilibration:
804828
# the value at time 0, which respects whether a
805-
# condition holds for a switch at time 0
829+
# condition holds for a switch at time 0, held there:
830+
# a ramp does not run during preequilibration
806831
value = expr.subs(subs).subs(time, 0)
807832
additions.append(
808833
(experiment, period.time, target_id, value)
809834
)
835+
if ramps:
836+
additions.append(
837+
(experiment, period.time, slope_id, sp.S.Zero)
838+
)
810839
continue
811840

812841
t_end = periods[i + 1].time if i + 1 < len(periods) else sp.oo
@@ -832,14 +861,27 @@ def assignments_to_experiments(problem: Problem) -> Problem:
832861
for _, value in switch_values
833862
)
834863
)
864+
after_preequilibration = (
865+
i > 0 and periods[i - 1].is_preequilibration
866+
)
835867
if not redundant and (
836-
i == 0 or {c.target_id for c in changes} & symbol_ids
868+
i == 0
869+
or {c.target_id for c in changes} & symbol_ids
870+
# preequilibration held the slope at zero
871+
or (ramps and after_preequilibration)
837872
):
838-
additions.append(
839-
(experiment, period.time, target_id, start_value)
873+
additions += _changes(
874+
experiment,
875+
period.time,
876+
target_id,
877+
start_value,
878+
time,
879+
slope_id,
840880
)
841881
for t, value in switch_values:
842-
additions.append((experiment, t, target_id, value))
882+
additions += _changes(
883+
experiment, t, target_id, value, time, slope_id
884+
)
843885

844886
if converted_ids and any(e.has_preequilibration for e in experiments):
845887
warnings.warn(
@@ -913,6 +955,16 @@ def assignments_to_experiments(problem: Problem) -> Problem:
913955
return problem
914956

915957

958+
def _changes(experiment, t, target_id, value, time, slope_id):
959+
"""The changes that start ``value`` at time ``t``: the target set to the
960+
value at ``t``, and, for a target that ramps, its slope set to the
961+
value's rate of change."""
962+
changes = [(experiment, t, target_id, value.subs(time, t))]
963+
if slope_id is not None:
964+
changes.append((experiment, t, slope_id, sp.diff(value, time)))
965+
return changes
966+
967+
916968
def _value_from(
917969
expr: sp.Expr,
918970
switches: dict,
@@ -972,7 +1024,12 @@ def _supported_time_switches(
9721024
raise unsupported
9731025
t_switch = sp.solve(condition.gts - condition.lts, time)[0]
9741026
switches[condition] = (t_switch, slope)
975-
# time enters only through the conditions
976-
if expr.xreplace({c: sp.Dummy() for c in switches}).has(time):
977-
raise unsupported
1027+
# outside the conditions, time enters at most linearly: the value may
1028+
# ramp, at a rate that does not itself change with time
1029+
rest = expr.xreplace({c: sp.Dummy() for c in switches})
1030+
if rest.has(time) and sp.diff(rest, time).has(time):
1031+
raise NotImplementedError(
1032+
"Only values that change at most linearly with time are "
1033+
f"supported, but the assignment to `{target_id}` is `{expr}`."
1034+
)
9781035
return switches

‎tests/v2/test_conversion.py‎

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
from pathlib import Path
33

4+
import libsbml
45
import numpy as np
56
import pandas as pd
67
import pytest
@@ -888,7 +889,8 @@ def test_assignments_to_experiments_preequilibration():
888889
("piecewise(0, time == 10, 1)", "single"),
889890
("piecewise(0, time < 10 && time > 5, 1)", "single"),
890891
("piecewise(0, time^2 < 10, 1)", "single"),
891-
("piecewise(time, time < 10, 1)", "single"),
892+
("piecewise(time^2, time < 10, 1)", "linearly"),
893+
("piecewise(exp(time), time < 10, 1)", "linearly"),
892894
("piecewise(0, time < t_dose, 1)", "evaluate to a real number"),
893895
("piecewise(0, time < A, 1)", "constant model parameters"),
894896
("A * piecewise(0, time < 10, 1)", "constant model parameters"),
@@ -1036,6 +1038,147 @@ def test_assignments_to_experiments_condition_specific_doses():
10361038
}
10371039

10381040

1041+
def _rate_rule(problem, target_id):
1042+
rule = problem.model.sbml_model.getRateRuleByVariable(target_id)
1043+
return None if rule is None else libsbml.formulaToL3String(rule.getMath())
1044+
1045+
1046+
def _changes_by_period(problem):
1047+
"""{(experiment ID, time): {target ID: target value}}"""
1048+
conditions = problem.condition_df.set_index(v2.C.CONDITION_ID)
1049+
out = {}
1050+
for _, row in problem.experiment_df.iterrows():
1051+
change = conditions.loc[[row[v2.C.CONDITION_ID]]]
1052+
out.setdefault((row[v2.C.EXPERIMENT_ID], row[v2.C.TIME]), {}).update(
1053+
{
1054+
r[v2.C.TARGET_ID]: float(r[v2.C.TARGET_VALUE])
1055+
for _, r in change.iterrows()
1056+
}
1057+
)
1058+
return out
1059+
1060+
1061+
def test_assignments_to_experiments_ramp():
1062+
"""Test that a value changing linearly with time becomes a rate rule
1063+
whose slope each period sets."""
1064+
problem = Problem()
1065+
ant_model = """
1066+
model conversion
1067+
species A, B;
1068+
A = 10;
1069+
B = 0;
1070+
r = 0.5; a = 1;
1071+
k1 := piecewise(r * time + a, time <= 10, 0);
1072+
k2 = 0.5;
1073+
R1: A -> B; k1 * A;
1074+
R2: B -> A; k2 * B;
1075+
end
1076+
"""
1077+
problem.model = SbmlModel.from_antimony(ant_model)
1078+
problem.add_observable("obs_a", formula="A", noise_formula=1.0)
1079+
problem.add_measurement("obs_a", time=20.0, measurement=5.0)
1080+
problem.add_parameter("k2", estimate=True, lb=1e-3, ub=1e3)
1081+
1082+
converted = assignments_to_experiments(problem)
1083+
1084+
sbml = converted.model.sbml_model
1085+
# the ramp's start and slope are the model's own initial values
1086+
assert _rate_rule(converted, "k1") == "k1_slope"
1087+
assert sbml.getParameter("k1").getValue() == 1.0
1088+
assert sbml.getParameter("k1_slope").getValue() == 0.5
1089+
# at the switch, the target and its slope drop to zero
1090+
assert _changes_by_period(converted) == {
1091+
(DEFAULT_EXPERIMENT_ID, 10.0): {"k1": 0.0, "k1_slope": 0.0}
1092+
}
1093+
1094+
1095+
def test_assignments_to_experiments_condition_specific_ramps():
1096+
"""Test pulses and ramps whose start, rate and end are set by the
1097+
condition table, as in Fujita_SciSignal2010."""
1098+
problem = Problem()
1099+
ant_model = """
1100+
model conversion
1101+
species A, B;
1102+
A = 10;
1103+
B = 0;
1104+
EGF_0 = 0; EGF_rate = 0; EGF_end = 0;
1105+
k1 := piecewise(EGF_rate * time + EGF_0, time <= EGF_end, 0);
1106+
k2 = 0.5;
1107+
R1: A -> B; k1 * A;
1108+
R2: B -> A; k2 * B;
1109+
end
1110+
"""
1111+
problem.model = SbmlModel.from_antimony(ant_model)
1112+
# a pulse: a constant dose withdrawn at t = 60
1113+
problem.add_condition("pulse", EGF_0=1.0, EGF_rate=0.0, EGF_end=60.0)
1114+
# a ramp: growing from zero at 0.01 per unit time until t = 3600
1115+
problem.add_condition("ramp", EGF_0=0.0, EGF_rate=0.01, EGF_end=3600.0)
1116+
for c in ("pulse", "ramp"):
1117+
problem.add_experiment(f"experiment____{c}", 0, c)
1118+
problem.add_measurement(
1119+
"obs_a",
1120+
experiment_id=f"experiment____{c}",
1121+
time=30.0,
1122+
measurement=5.0,
1123+
)
1124+
problem.add_observable("obs_a", formula="A", noise_formula=1.0)
1125+
problem.add_parameter("k2", estimate=True, lb=1e-3, ub=1e3)
1126+
1127+
converted = assignments_to_experiments(problem)
1128+
1129+
assert _rate_rule(converted, "k1") == "k1_slope"
1130+
assert _changes_by_period(converted) == {
1131+
("experiment____pulse", 0.0): {"k1": 1.0, "k1_slope": 0.0},
1132+
("experiment____pulse", 60.0): {"k1": 0.0, "k1_slope": 0.0},
1133+
("experiment____ramp", 0.0): {"k1": 0.0, "k1_slope": 0.01},
1134+
("experiment____ramp", 3600.0): {"k1": 0.0, "k1_slope": 0.0},
1135+
}
1136+
1137+
1138+
def test_assignments_to_experiments_ramp_preequilibration():
1139+
"""Test that a ramp is held at its value at time 0 during
1140+
preequilibration and starts at time 0."""
1141+
problem = Problem()
1142+
ant_model = """
1143+
model conversion
1144+
species A, B;
1145+
A = 10;
1146+
B = 0;
1147+
r = 0;
1148+
k1 := piecewise(r * time + 1, time <= 10, 0);
1149+
k2 = 0.5;
1150+
R1: A -> B; k1 * A;
1151+
R2: B -> A; k2 * B;
1152+
end
1153+
"""
1154+
problem.model = SbmlModel.from_antimony(ant_model)
1155+
problem.add_condition("pre", r=0.0)
1156+
problem.add_condition("main", r=2.0)
1157+
problem.add_experiment(
1158+
"experiment__pre___main", v2.C.TIME_PREEQUILIBRATION, "pre", 0, "main"
1159+
)
1160+
problem.add_observable("obs_a", formula="A", noise_formula=1.0)
1161+
problem.add_measurement(
1162+
"obs_a",
1163+
experiment_id="experiment__pre___main",
1164+
time=20.0,
1165+
measurement=5.0,
1166+
)
1167+
problem.add_parameter("k2", estimate=True, lb=1e-3, ub=1e3)
1168+
1169+
with pytest.warns(UserWarning, match="during preequilibration"):
1170+
converted = assignments_to_experiments(problem)
1171+
1172+
assert _changes_by_period(converted) == {
1173+
("experiment__pre___main", v2.C.TIME_PREEQUILIBRATION): {
1174+
"k1": 1.0,
1175+
"k1_slope": 0.0,
1176+
},
1177+
("experiment__pre___main", 0.0): {"k1": 1.0, "k1_slope": 2.0},
1178+
("experiment__pre___main", 10.0): {"k1": 0.0, "k1_slope": 0.0},
1179+
}
1180+
1181+
10391182
@pytest.mark.parametrize("t_switch", [0, 10])
10401183
@pytest.mark.parametrize(
10411184
"condition, holds_at_switch, holds_before",

0 commit comments

Comments
 (0)