Skip to content

Commit 4bc8a03

Browse files
committed
v2: Add experiments->events converter
Add functionality to convert PEtab v2 experiments/conditions to SBML events. This should make it easier to implement v2 support in other tools.
1 parent 70ef57b commit 4bc8a03

File tree

4 files changed

+432
-0
lines changed

4 files changed

+432
-0
lines changed

doc/modules.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ API Reference
3232
petab.v1.yaml
3333
petab.v2
3434
petab.v2.C
35+
petab.v2.converters
3536
petab.v2.core
3637
petab.v2.experiments
3738
petab.v2.lint

petab/v2/converters.py

Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
"""Conversion of PEtab problems."""
2+
3+
from __future__ import annotations
4+
5+
import warnings
6+
from copy import deepcopy
7+
from math import inf
8+
9+
import libsbml
10+
from sbmlmath import sbml_math_to_sympy, set_math
11+
12+
from .core import Change, Condition, Experiment, ExperimentPeriod
13+
from .models._sbml_utils import add_sbml_parameter, check
14+
from .models.sbml_model import SbmlModel
15+
from .problem import Problem
16+
17+
__all__ = ["ExperimentsToEventsConverter"]
18+
19+
20+
class ExperimentsToEventsConverter:
21+
"""Convert PEtab experiments to SBML events.
22+
23+
For an SBML-model-based PEtab problem, this class converts the PEtab
24+
experiments to events as far as possible.
25+
26+
If the model already contains events, PEtab events are added with a higher
27+
priority than the existing events to guarantee that PEtab condition changes
28+
are applied before any pre-existing assignments.
29+
30+
The PEtab problem must not contain any identifiers starting with
31+
``_petab``.
32+
33+
All periods and condition changes that are represented by events
34+
will be removed from the condition table.
35+
Each experiment will have at most one period with a start time of ``-inf``
36+
and one period with a finite start time. The associated changes with
37+
these periods are only the steady-state pre-simulation indicator
38+
(if necessary), and the experiment indicator parameter.
39+
"""
40+
41+
#: ID of the parameter that indicates whether the model is in
42+
# the steady-state pre-simulation phase (1) or not (0).
43+
PRE_SIM_INDICATOR = "_petab_pre_simulation_indicator"
44+
45+
def __init__(self, problem: Problem):
46+
"""Initialize the converter.
47+
48+
:param problem: The PEtab problem to convert.
49+
This will not be modified.
50+
"""
51+
if not isinstance(problem.model, SbmlModel):
52+
raise ValueError("Only SBML models are supported.")
53+
54+
self._original_problem = problem
55+
self._new_problem = deepcopy(self._original_problem)
56+
57+
self._model = self._new_problem.model.sbml_model
58+
self._presim_indicator = self.PRE_SIM_INDICATOR
59+
60+
# The maximum event priority that was found in the unprocessed model.
61+
self._max_event_priority = None
62+
# The priority that will be used for the PEtab events.
63+
self._petab_event_priority = None
64+
65+
self._preprocess()
66+
67+
def _preprocess(self):
68+
"""Check whether we can handle the given problem and store some model
69+
information."""
70+
model = self._model
71+
if model.getLevel() < 3:
72+
# try to upgrade the SBML model
73+
if not model.getSBMLDocument().setLevelAndVersion(3, 2):
74+
raise ValueError(
75+
"Cannot handle SBML models with SBML level < 3, "
76+
"because they do not support initial values for event "
77+
"triggers and automatic upconversion failed."
78+
)
79+
80+
# Collect event priorities
81+
event_priorities = {
82+
ev.getId() or str(ev): sbml_math_to_sympy(ev.getPriority())
83+
for ev in model.getListOfEvents()
84+
if ev.getPriority() and ev.getPriority().getMath() is not None
85+
}
86+
87+
# Check for non-constant event priorities and track the maximum
88+
# priority used so far.
89+
for e, priority in event_priorities.items():
90+
if priority.free_symbols:
91+
# We'd need to find the maximum priority of all events,
92+
# which is challenging/impossible to do in general.
93+
raise NotImplementedError(
94+
f"Event `{e}` has a non-constant priority: {priority}. "
95+
"This is currently not supported."
96+
)
97+
self._max_event_priority = max(
98+
self._max_event_priority or 0, float(priority)
99+
)
100+
101+
self._petab_event_priority = (
102+
self._max_event_priority + 1
103+
if self._max_event_priority is not None
104+
else None
105+
)
106+
107+
for event in model.getListOfEvents():
108+
# Check for undefined event priorities and warn
109+
if (prio := event.getPriority()) and prio.getMath() is None:
110+
warnings.warn(
111+
f"Event `{event.getId()}` has no priority set. "
112+
"Make sure that this event cannot trigger at the time of "
113+
"a PEtab condition change, otherwise the behavior is "
114+
"undefined.",
115+
stacklevel=1,
116+
)
117+
118+
# Check for useValuesFromTrigger time
119+
if event.getUseValuesFromTriggerTime():
120+
# Non-PEtab-condition-change events must be executed *after*
121+
# PEtab condition changes have been applied, based on the
122+
# updated model state. This would be violated by
123+
# useValuesFromTriggerTime=true.
124+
warnings.warn(
125+
f"Event `{event.getId()}` has "
126+
"`useValuesFromTriggerTime=true'. "
127+
"Make sure that this event cannot trigger at the time of "
128+
"a PEtab condition change, or consider changing "
129+
"`useValuesFromTriggerTime' to `false'. Otherwise "
130+
"simulation results may be incorrect.",
131+
stacklevel=1,
132+
)
133+
134+
def convert(self) -> Problem:
135+
"""Convert the PEtab experiments to SBML events.
136+
137+
:return: The converted PEtab problem.
138+
"""
139+
140+
self._add_presimulation_indicator()
141+
142+
problem = self._new_problem
143+
for experiment in problem.experiment_table.experiments:
144+
self._convert_experiment(problem, experiment)
145+
146+
self._add_indicators_to_conditions(problem)
147+
148+
validation_results = problem.validate()
149+
validation_results.log()
150+
151+
return problem
152+
153+
def _convert_experiment(self, problem: Problem, experiment: Experiment):
154+
"""Convert a single experiment to SBML events."""
155+
model = self._model
156+
experiment.sort_periods()
157+
has_presimulation = (
158+
len(experiment.periods) and experiment.periods[0].time == -inf
159+
)
160+
161+
# add experiment indicator
162+
exp_ind_id = self.get_experiment_indicator(experiment.id)
163+
if model.getElementBySId(exp_ind_id) is not None:
164+
raise AssertionError(
165+
f"Entity with ID {exp_ind_id} exists already."
166+
)
167+
add_sbml_parameter(model, id_=exp_ind_id, constant=False, value=0)
168+
kept_periods = []
169+
for i_period, period in enumerate(experiment.periods):
170+
# check for non-zero initial times of the first period
171+
if (i_period == int(has_presimulation)) and period.time != 0:
172+
# TODO: we could address that by offsetting all occurrences of
173+
# the SBML time in the model (except for the newly added
174+
# events triggers). Or we better just leave it to the
175+
# simulator -- we anyways keep the first period in the
176+
# returned Problem.
177+
raise NotImplementedError(
178+
"Cannot represent non-zero initial time in SBML."
179+
)
180+
181+
if period.time == -inf:
182+
# steady-state pre-simulation cannot be represented in SBML,
183+
# so we need to keep this period in the Problem.
184+
kept_periods.append(period)
185+
elif i_period == int(has_presimulation):
186+
# we always keep the first non-presimulation period
187+
# to set the indicator parameters
188+
kept_periods.append(period)
189+
elif not period.changes:
190+
# no condition, no changes, no need for an event,
191+
# no need to keep the period unless it's the initial
192+
# steady-state simulation or the only non-presimulation
193+
# period (handled above)
194+
continue
195+
196+
ev = self._create_period_begin_event(
197+
experiment=experiment,
198+
i_period=i_period,
199+
period=period,
200+
)
201+
self._create_event_assignments_for_period(
202+
ev,
203+
[
204+
problem.condition_table[condition_id]
205+
for condition_id in period.condition_ids
206+
],
207+
)
208+
209+
if len(kept_periods) > 2:
210+
raise AssertionError("Expected at most two periods to be kept.")
211+
212+
# add conditions that set the indicator parameters
213+
for period in kept_periods:
214+
period.condition_ids = [
215+
f"_petab_experiment_condition_{experiment.id}",
216+
"_petab_steady_state_pre_simulation"
217+
if period.time == -inf
218+
else "_petab_no_steady_state_pre_simulation",
219+
]
220+
221+
experiment.periods = kept_periods
222+
223+
def _create_period_begin_event(
224+
self, experiment: Experiment, i_period: int, period: ExperimentPeriod
225+
) -> libsbml.Event:
226+
"""Create an event that triggers at the beginning of a period."""
227+
228+
# TODO: for now, add separate events for each experiment x period,
229+
# this could be optimized to reuse events
230+
231+
ev = self._model.createEvent()
232+
check(ev.setId(f"_petab_event_{experiment.id}_{i_period}"))
233+
check(ev.setUseValuesFromTriggerTime(True))
234+
trigger = ev.createTrigger()
235+
check(trigger.setInitialValue(False)) # may trigger at t=0
236+
check(trigger.setPersistent(True))
237+
if self._petab_event_priority is not None:
238+
priority = ev.createPriority()
239+
set_math(priority, self._petab_event_priority)
240+
241+
exp_ind_id = self.get_experiment_indicator(experiment.id)
242+
243+
if period.time == -inf:
244+
trig_math = libsbml.parseL3Formula(
245+
f"({exp_ind_id} == 1) && ({self._presim_indicator} == 1)"
246+
)
247+
else:
248+
trig_math = libsbml.parseL3Formula(
249+
f"({exp_ind_id} == 1) && ({self._presim_indicator} != 1) "
250+
f"&& (time >= {period.time})"
251+
)
252+
check(trigger.setMath(trig_math))
253+
254+
return ev
255+
256+
def _add_presimulation_indicator(
257+
self,
258+
) -> None:
259+
"""Add an indicator parameter for the steady-state presimulation to
260+
the SBML model."""
261+
par_id = self._presim_indicator
262+
if self._model.getElementBySId(par_id) is not None:
263+
raise AssertionError(f"Entity with ID {par_id} exists already.")
264+
265+
# add the pre-steady-state indicator parameter
266+
add_sbml_parameter(self._model, id_=par_id, value=0, constant=False)
267+
268+
@staticmethod
269+
def get_experiment_indicator(experiment_id: str) -> str:
270+
"""The ID of the experiment indicator parameter.
271+
272+
The experiment indicator parameter is used to identify the
273+
experiment in the SBML model. It is a parameter that is set
274+
to 1 for the current experiment and 0 for all other
275+
experiments. The parameter is used in the event trigger
276+
to determine when the event should be triggered.
277+
278+
:param experiment_id: The ID of the experiment for which to create
279+
the experiment indicator parameter ID.
280+
"""
281+
return f"_petab_experiment_indicator_{experiment_id}"
282+
283+
@staticmethod
284+
def _create_event_assignments_for_period(
285+
event: libsbml.Event, conditions: list[Condition]
286+
) -> None:
287+
"""Create an event assignments for a given period."""
288+
for condition in conditions:
289+
for change in condition.changes:
290+
ExperimentsToEventsConverter._change_to_event_assignment(
291+
change, event
292+
)
293+
294+
@staticmethod
295+
def _change_to_event_assignment(change: Change, event: libsbml.Event):
296+
"""Convert a PEtab ``Change`` to an SBML event assignment."""
297+
sbml_model = event.getModel()
298+
299+
ea = event.createEventAssignment()
300+
ea.setVariable(change.target_id)
301+
set_math(ea, change.target_value)
302+
303+
# target needs const=False, and target may not exist yet
304+
# (e.g., in case of output parameters added in the observable
305+
# table)
306+
target = sbml_model.getElementBySId(change.target_id)
307+
if target is None:
308+
add_sbml_parameter(
309+
sbml_model, id_=change.target_id, constant=False, value=0
310+
)
311+
else:
312+
# TODO: can that break models??
313+
target.setConstant(False)
314+
315+
# the target value may depend on parameters that are only
316+
# introduced in the PEtab parameter table - those need
317+
# to be added to the model
318+
for sym in change.target_value.free_symbols:
319+
if sbml_model.getElementBySId(sym.name) is None:
320+
add_sbml_parameter(
321+
sbml_model, id_=sym.name, constant=True, value=0
322+
)
323+
324+
def _add_indicators_to_conditions(self, problem: Problem) -> None:
325+
"""After converting the experiments to events, add the indicator
326+
parameters for the presimulation period and for the different
327+
experiments to the remaining conditions.
328+
Then remove all other conditions."""
329+
330+
# create conditions for indicator parameters
331+
problem.condition_table.conditions.append(
332+
Condition(
333+
id="_petab_steady_state_pre_simulation",
334+
changes=[
335+
Change(target_id=self._presim_indicator, target_value=1)
336+
],
337+
)
338+
)
339+
problem.condition_table.conditions.append(
340+
Condition(
341+
id="_petab_no_steady_state_pre_simulation",
342+
changes=[
343+
Change(target_id=self._presim_indicator, target_value=0)
344+
],
345+
)
346+
)
347+
# add conditions for the experiment indicators
348+
for experiment in problem.experiment_table.experiments:
349+
problem.condition_table.conditions.append(
350+
Condition(
351+
id=f"_petab_experiment_condition_{experiment.id}",
352+
changes=[
353+
Change(
354+
target_id=self.get_experiment_indicator(
355+
experiment.id
356+
),
357+
target_value=1,
358+
)
359+
],
360+
)
361+
)
362+
363+
# All changes have been encoded in event assignments and can be
364+
# removed. Only keep the conditions setting our indicators.
365+
problem.condition_table.conditions = [
366+
condition
367+
for condition in problem.condition_table.conditions
368+
if condition.id.startswith("_petab")
369+
]

0 commit comments

Comments
 (0)