Skip to content

Commit d9182de

Browse files
FFroehlichclaude
andcommitted
assignments_to_experiments: evaluate a condition change once, at the start of its period
The converter kept a condition change's value as an expression and substituted it wherever a switch used it, so the value was re-evaluated at the switch with the values at that time. A condition change is evaluated once, at the start of its period, and all changes of a period simultaneously, with the values from before the period. E.g., with `d1 = b1 + 0.5` at t = 2 and `b1 = 2` at t = 3, a switch to `d1` at t = 5 used 2.5 instead of 1.7; likewise with `b1 = 2` in the same condition as `d1`. A change's value is now evaluated at the start of its period: symbols set by an earlier condition take that value, other constant model parameters their model values, and parameter table symbols stay. A change of a symbol of the assignment whose value still refers to anything else, e.g. a species, is rejected, since its value is not known there. Found by differential testing against simulations of the original and the converted problem. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 520d565 commit d9182de

2 files changed

Lines changed: 101 additions & 1 deletion

File tree

‎petab/v2/converters.py‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1004,12 +1004,32 @@ def changes(
10041004
held = None
10051005
for i_period, period in enumerate(periods):
10061006
period_changes = self.problem.get_changes_for_period(period)
1007-
overrides |= {c.target_id: c.target_value for c in period_changes}
1007+
# a condition's changes are evaluated once, at the start of their
1008+
# period, all with the values from before the period
1009+
overrides |= {
1010+
c.target_id: self._at_period_start(c.target_value, overrides)
1011+
for c in period_changes
1012+
}
10081013
subs = self.fixed_values | {
10091014
s: overrides[str(s)]
10101015
for s in self.expr.free_symbols
10111016
if str(s) in overrides
10121017
}
1018+
if unknown := sorted(
1019+
{
1020+
str(x)
1021+
for s in self.expr.free_symbols
1022+
if str(s) in overrides
1023+
for x in overrides[str(s)].free_symbols
1024+
}
1025+
- self.parameter_ids
1026+
):
1027+
raise NotImplementedError(
1028+
"Only condition changes whose values are known at the "
1029+
"start of their period are supported, but in experiment "
1030+
f"`{experiment.id}`, the changes of the symbols of the "
1031+
f"assignment to `{self.target_id}` refer to {unknown}."
1032+
)
10131033
times = self._switching_times(subs, experiment.id)
10141034

10151035
if period.is_preequilibration:
@@ -1075,6 +1095,29 @@ def _model_value(self, symbol: sp.Symbol) -> float:
10751095
"""The value of the model parameter ``symbol``."""
10761096
return self.sbml_model.getParameter(str(symbol)).getValue()
10771097

1098+
def _at_period_start(self, value: sp.Expr, overrides: dict) -> sp.Expr:
1099+
"""The ``value`` of a change, evaluated at the start of its period.
1100+
1101+
Symbols that a condition set before take that value (``overrides``),
1102+
and the other constant model parameters outside the parameter table
1103+
their model values. Parameter table symbols stay, since no condition
1104+
sets them; any other symbol stays as well, since its value is not
1105+
known here.
1106+
"""
1107+
if value is None:
1108+
return value
1109+
known = {}
1110+
for s in value.free_symbols:
1111+
if str(s) in overrides:
1112+
known[s] = overrides[str(s)]
1113+
elif (
1114+
str(s) not in self.parameter_ids
1115+
and (p := self.sbml_model.getParameter(str(s))) is not None
1116+
and p.getConstant()
1117+
):
1118+
known[s] = sp.Float(p.getValue())
1119+
return value.xreplace(known)
1120+
10781121
def _with_model_values(self, value: sp.Expr) -> sp.Expr:
10791122
"""``value`` for a change at the start of an experiment, which may
10801123
only refer to parameter table symbols: the other symbols are constant

‎tests/v2/test_conversion.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1734,3 +1734,60 @@ def test_assignments_to_experiments_slope_id_taken():
17341734
0.0: {"k1": 0.0, "k1_slope_1": 1.0},
17351735
10.0: {"k1": 1.0, "k1_slope_1": 0.0},
17361736
}
1737+
1738+
1739+
@pytest.mark.parametrize(
1740+
"conditions",
1741+
[
1742+
# the changes of a condition are simultaneous: `d1` takes the old `b1`
1743+
[(2.0, {"b1": 2.0, "d1": "b1 + 0.5"})],
1744+
# a change is evaluated once: `d1` keeps the `b1` of its period
1745+
[(2.0, {"d1": "b1 + 0.5"}), (3.0, {"b1": 2.0})],
1746+
],
1747+
)
1748+
def test_assignments_to_experiments_change_evaluated_at_period_start(
1749+
conditions,
1750+
):
1751+
"""Test that a condition change's value is evaluated at the start of its
1752+
period, with the values from before the period, also when a later switch
1753+
uses it."""
1754+
problem = Problem()
1755+
problem.model = _switch_model(
1756+
"piecewise(b0, time < 5, d1)", extra="b0 = 0.3; b1 = 1.2; d1 = 1;"
1757+
)
1758+
args = [0, []]
1759+
for i, (t, changes) in enumerate(conditions):
1760+
problem.add_condition(f"c{i}", **changes)
1761+
args += [t, f"c{i}"]
1762+
problem.add_experiment("e1", *args)
1763+
problem.add_observable("obs_a", formula="A", noise_formula=1.0)
1764+
problem.add_measurement(
1765+
"obs_a", experiment_id="e1", time=20.0, measurement=1.0
1766+
)
1767+
1768+
converted = assignments_to_experiments(problem)
1769+
1770+
(switch,) = (p for p in converted["e1"].periods if p.time == 5.0)
1771+
(change,) = converted.get_changes_for_period(switch)
1772+
assert change.target_id == "k1"
1773+
# 1.2 + 0.5, not 2.0 + 0.5
1774+
assert float(change.target_value) == pytest.approx(1.7)
1775+
1776+
1777+
def test_assignments_to_experiments_change_refers_to_species():
1778+
"""Test that a change of a symbol of the assignment whose value refers to
1779+
a species, which is not known at the start of the period, is
1780+
rejected."""
1781+
problem = Problem()
1782+
problem.model = _switch_model(
1783+
"piecewise(0, time < 5, d1)", extra="d1 = 1;"
1784+
)
1785+
problem.add_condition("c1", d1="A")
1786+
problem.add_experiment("e1", 0, [], 2, "c1")
1787+
problem.add_observable("obs_a", formula="A", noise_formula=1.0)
1788+
problem.add_measurement(
1789+
"obs_a", experiment_id="e1", time=20.0, measurement=1.0
1790+
)
1791+
1792+
with pytest.raises(NotImplementedError, match=r"refer to \['A'\]"):
1793+
assignments_to_experiments(problem)

0 commit comments

Comments
 (0)