Skip to content

Commit 81d8742

Browse files
FFroehlichclaude
andcommitted
assignments_to_experiments: fix lost switches, dropped changes and scope gaps
- A switch exactly at the start of a later period was lost when that period did not change any symbol of the assignment, e.g. `piecewise(0, time < 10, 1)` with a period at t = 10, or `time <= 0` after preequilibration. A later period now also sets the value when it differs from the value at the end of the previous period. - The condition changes of every symbol of a converted assignment were dropped, even when the model still used the symbol elsewhere, e.g. a dose that also enters a reaction. They are now only dropped if nothing else (the model, an observable, a condition change) refers to the symbol. - Time-dependent initial assignments were converted into switches, but SBML evaluates them once, at the start of the simulation. They now stay in the model. - At preequilibration, which starts the experiment, a value that referred to model parameters outside the parameter table failed the lint. Those parameters now take their model values, as at the first period. - An assignment to a species or compartment crashed with an AttributeError; it now raises NotImplementedError. - A problem without time-dependent assignments was still changed: an experiment that only preequilibrates got an empty period at t = 0. `test_assignments_to_experiments` and `_unused_condition_id` used an initial assignment to mean a switching rule and now use an assignment rule. `_validate` relied on an estimated parameter with a time-dependent initial assignment; it now uses a ramp whose generated slope parameter collides with an observable parameter. The converted benchmark problems (Brannmark_JBC2010, Fujita_SciSignal2010, Isensee_JCB2018, Raimundez_PCB2020) are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 50cac56 commit 81d8742

2 files changed

Lines changed: 278 additions & 32 deletions

File tree

‎petab/v2/converters.py‎

Lines changed: 90 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -757,9 +757,9 @@ def assignments_to_experiments(problem: Problem) -> Problem:
757757
if all of them were replaced, since a period's time overrides the
758758
model's initial time.
759759
760-
**Scope:** conversion is supported for SBML assignment rules that depend
761-
on time through the conditions of piecewise functions, and at most
762-
linearly through their values, where
760+
**Scope:** conversion is supported for SBML assignment rules to
761+
parameters that depend on time through the conditions of piecewise
762+
functions, and at most linearly through their values, where
763763
764764
* every such condition is an inequality that can be re-arranged
765765
explicitly into `t </<=/>/>= ...`,
@@ -781,7 +781,11 @@ def assignments_to_experiments(problem: Problem) -> Problem:
781781
entity instead, with the rate rule `d target/dt = <target>_slope`, and
782782
each period sets both the target and its slope; an assignment that only
783783
switches between constant values stays a constant parameter. Anything else
784-
raises a :class:`NotImplementedError`.
784+
raises a :class:`NotImplementedError`. Initial assignments stay in the
785+
model, since they are evaluated once, at the start of the simulation.
786+
787+
Condition changes of the replaced assignments' symbols are dropped, unless
788+
the model, an observable or another change still refers to them.
785789
786790
.. warning::
787791
@@ -807,25 +811,29 @@ def assignments_to_experiments(problem: Problem) -> Problem:
807811
problem = deepcopy(problem)
808812
sbml_model = problem.model.sbml_model
809813

814+
# Initial assignments are evaluated once, at the start of the
815+
# simulation, so they never switch and stay in the model.
816+
time_dependent = []
817+
for rule in sbml_model.getListOfRules():
818+
if not rule.isAssignment():
819+
continue
820+
expr = sbml_math_to_sympy(rule)
821+
if any(pw.has(TimeSymbol) for pw in expr.atoms(sp.Piecewise)):
822+
time_dependent.append((rule, expr))
823+
810824
experiments = list(problem.experiments)
811825
if any(m.experiment_id is None for m in problem.measurements):
812826
default_experiment = Experiment(id=DEFAULT_EXPERIMENT_ID)
813827
experiments.append(default_experiment)
814828
else:
815829
default_experiment = None
816-
droppable = _add_start_periods(experiments)
830+
droppable = _add_start_periods(experiments) if time_dependent else set()
817831

818832
# (experiment, period start time, target ID, target value)
819833
additions = []
820834
replaced_symbols = set()
821835
converted_ids = []
822-
for rule in [
823-
*(r for r in sbml_model.getListOfRules() if r.isAssignment()),
824-
*sbml_model.getListOfInitialAssignments(),
825-
]:
826-
expr = sbml_math_to_sympy(rule)
827-
if not any(pw.has(TimeSymbol) for pw in expr.atoms(sp.Piecewise)):
828-
continue
836+
for rule, expr in time_dependent:
829837
assignment = _PiecewiseAssignment(rule, expr, problem)
830838
assignment.replace_in_model()
831839
for experiment in experiments:
@@ -846,7 +854,9 @@ def assignments_to_experiments(problem: Problem) -> Problem:
846854
stacklevel=2,
847855
)
848856

849-
# replace the changes that are covered by the new conditions
857+
# replace the changes that are covered by the new conditions, unless
858+
# something else still refers to their targets
859+
replaced_symbols -= _referenced_ids(problem, additions)
850860
_drop_changes(problem, experiments, replaced_symbols)
851861
_add_conditions(problem, additions)
852862

@@ -893,6 +903,11 @@ def __init__(
893903
self.problem = problem
894904
self.sbml_model = problem.model.sbml_model
895905
self.target_id = rule.getId()
906+
if self.sbml_model.getParameter(self.target_id) is None:
907+
raise NotImplementedError(
908+
"Only assignments to parameters are supported, but "
909+
f"`{self.target_id}` is not a parameter."
910+
)
896911
self.expr = expr
897912
(self.time,) = expr.atoms(TimeSymbol)
898913
self.symbol_ids = {str(s) for s in expr.free_symbols}
@@ -977,6 +992,8 @@ def changes(
977992
changes = []
978993
periods = experiment.sorted_periods
979994
overrides = {}
995+
# the value at the end of the previous period
996+
held = None
980997
for i_period, period in enumerate(periods):
981998
period_changes = self.problem.get_changes_for_period(period)
982999
overrides |= {c.target_id: c.target_value for c in period_changes}
@@ -991,8 +1008,14 @@ def changes(
9911008
# the value at time 0, which respects whether a condition
9921009
# holds for a switch at time 0, held there: a ramp does not
9931010
# run during preequilibration
994-
value = self.expr.subs(subs).subs(self.time, 0)
995-
changes.append((period.time, self.target_id, value))
1011+
held = self.expr.subs(subs).subs(self.time, 0)
1012+
changes.append(
1013+
(
1014+
period.time,
1015+
self.target_id,
1016+
self._with_model_values(held),
1017+
)
1018+
)
9961019
if self.ramps:
9971020
changes.append((period.time, self.slope_id, sp.S.Zero))
9981021
continue
@@ -1024,31 +1047,38 @@ def changes(
10241047
or {str(s) for s in self.default.free_symbols}
10251048
& self.parameter_ids
10261049
):
1027-
# at the start of an experiment, a change may only refer
1028-
# to parameter table symbols; other symbols are
1029-
# constant model parameters, at their values
1030-
start_value = start_value.subs(
1031-
{
1032-
s: self._model_value(s)
1033-
for s in start_value.free_symbols - {self.time}
1034-
if str(s) not in self.parameter_ids
1035-
}
1050+
changes += self._start(
1051+
period.time, self._with_model_values(start_value)
10361052
)
1037-
changes += self._start(period.time, start_value)
10381053
elif (
10391054
{c.target_id for c in period_changes} & self.symbol_ids
10401055
# preequilibration held the slope at zero
10411056
or (self.ramps and periods[i_period - 1].is_preequilibration)
1057+
# a switch at the start of the period
1058+
or not (start_value - held).is_zero
10421059
):
10431060
changes += self._start(period.time, start_value)
10441061
for t, value in switch_values:
10451062
changes += self._start(t, value)
1063+
held = previous
10461064
return changes
10471065

10481066
def _model_value(self, symbol: sp.Symbol) -> float:
10491067
"""The value of the model parameter ``symbol``."""
10501068
return self.sbml_model.getParameter(str(symbol)).getValue()
10511069

1070+
def _with_model_values(self, value: sp.Expr) -> sp.Expr:
1071+
"""``value`` for a change at the start of an experiment, which may
1072+
only refer to parameter table symbols: the other symbols are constant
1073+
model parameters, at their values."""
1074+
return value.subs(
1075+
{
1076+
s: self._model_value(s)
1077+
for s in value.free_symbols - {self.time}
1078+
if str(s) not in self.parameter_ids
1079+
}
1080+
)
1081+
10521082
def _switching_times(self, subs: dict, experiment_id: str) -> list[float]:
10531083
"""The switching times, for the symbol values ``subs``."""
10541084
times = []
@@ -1109,6 +1139,41 @@ def _add_start_periods(experiments: list[Experiment]) -> set[int]:
11091139
return droppable
11101140

11111141

1142+
def _referenced_ids(
1143+
problem: Problem,
1144+
additions: list[tuple[Experiment, float, str, sp.Expr]],
1145+
) -> set[str]:
1146+
"""The IDs that the math of the model, the observables, or the condition
1147+
changes, including those in ``additions``, refer to."""
1148+
ids = set()
1149+
for element in problem.model.sbml_model.getListOfAllElements():
1150+
if not (hasattr(element, "isSetMath") and element.isSetMath()):
1151+
continue
1152+
nodes = [element.getMath()]
1153+
while nodes:
1154+
node = nodes.pop()
1155+
if node.getType() == libsbml.AST_NAME:
1156+
ids.add(node.getName())
1157+
nodes += [node.getChild(i) for i in range(node.getNumChildren())]
1158+
expressions = [
1159+
*(o.formula for o in problem.observables),
1160+
*(o.noise_formula for o in problem.observables),
1161+
*(value for *_, value in additions),
1162+
]
1163+
ids |= {
1164+
str(s)
1165+
for expression in expressions
1166+
if expression is not None
1167+
for s in expression.free_symbols
1168+
}
1169+
ids |= {
1170+
str(s)
1171+
for table in problem.condition_tables
1172+
for s in table.free_symbols
1173+
}
1174+
return ids
1175+
1176+
11121177
def _drop_changes(
11131178
problem: Problem, experiments: list[Experiment], target_ids: set[str]
11141179
) -> None:

0 commit comments

Comments
 (0)