Skip to content

Commit 6f0891e

Browse files
dweindldilpath
andauthored
Update ExperimentsToEventsConverter to changed initialization semantics (#443)
Due to PEtab-dev/PEtab#645 ... * ... the first period of a PEtab experiment has to be applied before initial assignments are evaluated. That means, the changes have to be implemented as initial assignments instead of event assignments at the initial timepoint (or any pre-existing initial assignments would have to be included in the event assignment). * ... for any subsequent periods, the event assignments need to be modified. Compartment-size changes in PEtab no longer follow the SBML event assignment semantics. That means, we need event assignments for all concentration-based species inside such a compartment to preserve concentrations instead of amounts. Closes #452. --------- Co-authored-by: Dilan Pathirana <59329744+dilpath@users.noreply.github.com>
1 parent 86e0290 commit 6f0891e

File tree

3 files changed

+404
-43
lines changed

3 files changed

+404
-43
lines changed

petab/v2/converters.py

Lines changed: 242 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from copy import deepcopy
77

88
import libsbml
9+
import sympy as sp
910
from sbmlmath import sbml_math_to_sympy, set_math
1011

1112
from .core import (
@@ -19,24 +20,26 @@
1920
from .models._sbml_utils import add_sbml_parameter, check
2021
from .models.sbml_model import SbmlModel
2122

22-
__all__ = ["ExperimentsToEventsConverter"]
23+
__all__ = ["ExperimentsToSbmlConverter"]
2324

2425

25-
class ExperimentsToEventsConverter:
26-
"""Convert PEtab experiments to SBML events.
26+
class ExperimentsToSbmlConverter:
27+
"""Convert PEtab experiments to SBML.
2728
2829
For an SBML-model-based PEtab problem, this class converts the PEtab
29-
experiments to events as far as possible.
30+
experiments to initial assignments and events as far as possible.
3031
3132
If the model already contains events, PEtab events are added with a higher
3233
priority than the existing events to guarantee that PEtab condition changes
3334
are applied before any pre-existing assignments.
35+
This requires that all event priorities in the original model are numeric
36+
constants.
3437
3538
The PEtab problem must not contain any identifiers starting with
3639
``_petab``.
3740
38-
All periods and condition changes that are represented by events
39-
will be removed from the condition table.
41+
All periods and condition changes that are represented by initial
42+
assignments or events will be removed from the condition table.
4043
Each experiment will have at most one period with a start time of ``-inf``
4144
and one period with a finite start time. The associated changes with
4245
these periods are only the pre-equilibration indicator
@@ -92,9 +95,8 @@ def __init__(self, problem: Problem, default_priority: float = None):
9295
self._default_priority = default_priority
9396
self._preprocess()
9497

95-
def _get_experiment_indicator_condition_id(
96-
self, experiment_id: str
97-
) -> str:
98+
@staticmethod
99+
def _get_experiment_indicator_condition_id(experiment_id: str) -> str:
98100
"""Get the condition ID for the experiment indicator parameter."""
99101
return f"_petab_experiment_condition_{experiment_id}"
100102

@@ -198,7 +200,9 @@ def convert(self) -> Problem:
198200
return self._new_problem
199201

200202
def _convert_experiment(self, experiment: Experiment) -> None:
201-
"""Convert a single experiment to SBML events."""
203+
"""
204+
Convert a single experiment to SBML events or initial assignments.
205+
"""
202206
model = self._model
203207
experiment.sort_periods()
204208
has_preequilibration = experiment.has_preequilibration
@@ -213,8 +217,14 @@ def _convert_experiment(self, experiment: Experiment) -> None:
213217
"model."
214218
)
215219
add_sbml_parameter(model, id_=exp_ind_id, constant=False, value=0)
216-
kept_periods = []
217-
for i_period, period in enumerate(experiment.periods):
220+
kept_periods: list[ExperimentPeriod] = []
221+
# Collect values for initial assignments for the different experiments.
222+
# All expressions must be combined into a single initial assignment
223+
# per target.
224+
# target_id -> [(experiment_indicator, target_value), ...]
225+
period0_assignments: dict[str, list[tuple[str, sp.Basic]]] = {}
226+
227+
for i_period, period in enumerate(experiment.sorted_periods):
218228
if period.is_preequilibration:
219229
# pre-equilibration cannot be represented in SBML,
220230
# so we need to keep this period in the Problem.
@@ -229,18 +239,84 @@ def _convert_experiment(self, experiment: Experiment) -> None:
229239
# or the only non-equilibration period (handled above)
230240
continue
231241

232-
ev = self._create_period_start_event(
233-
experiment=experiment,
234-
i_period=i_period,
235-
period=period,
236-
)
237-
self._create_event_assignments_for_period(
238-
ev,
239-
[
240-
self._new_problem[condition_id]
241-
for condition_id in period.condition_ids
242-
],
243-
)
242+
# Encode the period changes in the SBML model as events
243+
# that trigger at the start of the period or,
244+
# for the first period, as initial assignments.
245+
# Initial assignments are required for the first period,
246+
# because other initial assignments may depend on
247+
# the changed values.
248+
# Additionally, tools that don't support events can still handle
249+
# single-period experiments.
250+
if i_period == 0:
251+
exp_ind_id = self.get_experiment_indicator(experiment.id)
252+
for change in self._new_problem.get_changes_for_period(period):
253+
period0_assignments.setdefault(
254+
change.target_id, []
255+
).append((exp_ind_id, change.target_value))
256+
else:
257+
ev = self._create_period_start_event(
258+
experiment=experiment,
259+
i_period=i_period,
260+
period=period,
261+
)
262+
self._create_event_assignments_for_period(
263+
ev,
264+
self._new_problem.get_changes_for_period(period),
265+
)
266+
267+
# Create initial assignments for the first period
268+
if period0_assignments:
269+
free_symbols_in_assignments = set()
270+
for target_id, changes in period0_assignments.items():
271+
# The initial value might only be changed for a subset of
272+
# experiments. We need to keep the original initial value
273+
# for all other experiments.
274+
275+
# Is there an initial assignment for this target already?
276+
# If not, fall back to the initial value of the target.
277+
if (
278+
ia := model.getInitialAssignmentBySymbol(target_id)
279+
) is not None:
280+
default = sbml_math_to_sympy(ia.getMath())
281+
else:
282+
# use the initial value of the target as default
283+
target = model.getElementBySId(target_id)
284+
default = self._initial_value_from_element(target)
285+
286+
# Only create the initial assignment if there is
287+
# actually something to change.
288+
if expr_cond_pairs := [
289+
(target_value, sp.Symbol(exp_ind) > 0.5)
290+
for exp_ind, target_value in changes
291+
if target_value != default
292+
]:
293+
# Unlike events, we can't have different initial
294+
# assignments for different experiments, so we need to
295+
# combine all changes into a single piecewise
296+
# expression.
297+
298+
expr = sp.Piecewise(
299+
*expr_cond_pairs,
300+
(default, True),
301+
)
302+
303+
# Create a new initial assignment if necessary, otherwise
304+
# overwrite the existing one.
305+
if ia is None:
306+
ia = model.createInitialAssignment()
307+
ia.setSymbol(target_id)
308+
309+
set_math(ia, expr)
310+
free_symbols_in_assignments |= expr.free_symbols
311+
312+
# the target value may depend on parameters that are only
313+
# introduced in the PEtab parameter table - those need
314+
# to be added to the model
315+
for sym in free_symbols_in_assignments:
316+
if model.getElementBySId(sym.name) is None:
317+
add_sbml_parameter(
318+
model, id_=sym.name, constant=True, value=0
319+
)
244320

245321
if len(kept_periods) > 2:
246322
raise AssertionError("Expected at most two periods to be kept.")
@@ -256,6 +332,46 @@ def _convert_experiment(self, experiment: Experiment) -> None:
256332

257333
experiment.periods = kept_periods
258334

335+
@staticmethod
336+
def _initial_value_from_element(target: libsbml.SBase) -> sp.Basic:
337+
"""Get the initial value of an SBML element.
338+
339+
The value of the size attribute of compartments,
340+
the initial concentration or amount of species (amount for
341+
`hasOnlySubstanceUnits=true`, concentration otherwise), and
342+
the value of parameters, not considering any initial assignment
343+
constructs.
344+
"""
345+
if target is None:
346+
raise ValueError("`target` is None.")
347+
348+
if target.getTypeCode() == libsbml.SBML_COMPARTMENT:
349+
return sp.Float(target.getSize())
350+
351+
if target.getTypeCode() == libsbml.SBML_SPECIES:
352+
if target.getHasOnlySubstanceUnits():
353+
# amount-based -> return amount
354+
if target.isSetInitialAmount():
355+
return sp.Float(target.getInitialAmount())
356+
return sp.Float(target.getInitialConcentration()) * sp.Symbol(
357+
target.getCompartment()
358+
)
359+
# concentration-based -> return concentration
360+
if target.isSetInitialConcentration():
361+
return sp.Float(target.getInitialConcentration())
362+
363+
return sp.Float(target.getInitialAmount()) / sp.Symbol(
364+
target.getCompartment()
365+
)
366+
367+
if target.getTypeCode() == libsbml.SBML_PARAMETER:
368+
return sp.Float(target.getValue())
369+
370+
raise NotImplementedError(
371+
"Cannot create initial assignment for unsupported SBML "
372+
f"entity type {target.getTypeCode()}."
373+
)
374+
259375
def _create_period_start_event(
260376
self, experiment: Experiment, i_period: int, period: ExperimentPeriod
261377
) -> libsbml.Event:
@@ -326,33 +442,120 @@ def get_experiment_indicator(experiment_id: str) -> str:
326442

327443
@staticmethod
328444
def _create_event_assignments_for_period(
329-
event: libsbml.Event, conditions: list[Condition]
445+
event: libsbml.Event, changes: list[Change]
330446
) -> None:
331-
"""Create an event assignments for a given period."""
332-
for condition in conditions:
333-
for change in condition.changes:
334-
ExperimentsToEventsConverter._change_to_event_assignment(
335-
change, event
447+
"""Create event assignments for a given period.
448+
449+
Converts PEtab ``Change``s to equivalent SBML event assignments.
450+
451+
Note that the SBML event assignment formula is not necessarily the same
452+
as the `targetValue` in PEtab.
453+
In SBML, concentrations are treated as derived quantities.
454+
Therefore, changing the size of a compartment will update the
455+
concentrations of all contained concentration-based species.
456+
In PEtab, such a change would not automatically update the species
457+
concentrations, but only the compartment size.
458+
459+
Therefore, to correctly implement a PEtab change of a compartment size
460+
in SBML, we need to compensate for the automatic update of species
461+
concentrations by adding event assignments for all contained
462+
concentration-based species.
463+
464+
:param event: The SBML event to which the assignments should be added.
465+
:param changes: The PEtab condition changes that are to be applied
466+
at the start of the period.
467+
"""
468+
_add_assignment = ExperimentsToSbmlConverter._add_assignment
469+
sbml_model = event.getModel()
470+
# collect IDs of compartments that are changed in this period
471+
changed_compartments = {
472+
change.target_id
473+
for change in changes
474+
if sbml_model.getElementBySId(change.target_id) is not None
475+
and sbml_model.getElementBySId(change.target_id).getTypeCode()
476+
== libsbml.SBML_COMPARTMENT
477+
}
478+
479+
for change in changes:
480+
sbml_target = sbml_model.getElementBySId(change.target_id)
481+
482+
if sbml_target is None:
483+
raise ValueError(
484+
f"Cannot create event assignment for change of "
485+
f"`{change.target_id}`: No such entity in the SBML model."
336486
)
337487

488+
target_type = sbml_target.getTypeCode()
489+
if target_type == libsbml.SBML_COMPARTMENT:
490+
# handle the actual compartment size change
491+
_add_assignment(event, change.target_id, change.target_value)
492+
493+
# Changing a compartment size affects all contained
494+
# concentration-based species - we need to add event
495+
# assignments for those to compensate for the automatic
496+
# update of their concentrations.
497+
# The event assignment will set the concentration to
498+
# new_conc = assigned_amount / new_volume
499+
# = assigned_conc * old_volume / new_volume
500+
# <=> assigned_conc = new_conc * new_volume / old_volume
501+
# Therefore, the event assignment is not just `new_conc`,
502+
# but `new_conc * new_volume / old_volume`.
503+
504+
# concentration-based species in the changed compartment
505+
conc_species = [
506+
species.getId()
507+
for species in sbml_model.getListOfSpecies()
508+
if species.getCompartment() == change.target_id
509+
and not species.getHasOnlySubstanceUnits()
510+
]
511+
for species_id in conc_species:
512+
if species_change := next(
513+
(c for c in changes if c.target_id == species_id), None
514+
):
515+
# there is an explicit change for this species
516+
# in this period
517+
new_conc = species_change.target_value
518+
else:
519+
# no explicit change, use the pre-event concentration
520+
new_conc = sp.Symbol(species_id)
521+
522+
_add_assignment(
523+
event,
524+
species_id,
525+
# new_conc * new_volume / old_volume
526+
new_conc
527+
* change.target_value
528+
/ sp.Symbol(change.target_id),
529+
)
530+
elif (
531+
target_type != libsbml.SBML_SPECIES
532+
or sbml_target.getCompartment() not in changed_compartments
533+
or sbml_target.getHasOnlySubstanceUnits() is True
534+
):
535+
# Handle any changes other than compartments and
536+
# concentration-based species inside resized compartments
537+
# that we already handled above.
538+
# Those translate directly to event assignments.
539+
_add_assignment(event, change.target_id, change.target_value)
540+
338541
@staticmethod
339-
def _change_to_event_assignment(
340-
change: Change, event: libsbml.Event
542+
def _add_assignment(
543+
event: libsbml.Event, target_id: str, target_value: sp.Basic
341544
) -> None:
342-
"""Convert a PEtab ``Change`` to an SBML event assignment."""
545+
"""Add a single event assignment to the given event
546+
and apply any necessary changes to the model."""
343547
sbml_model = event.getModel()
344-
345548
ea = event.createEventAssignment()
346-
ea.setVariable(change.target_id)
347-
set_math(ea, change.target_value)
549+
ea.setVariable(target_id)
550+
set_math(ea, target_value)
348551

349552
# target needs const=False, and target may not exist yet
350553
# (e.g., in case of output parameters added in the observable
351554
# table)
352-
target = sbml_model.getElementBySId(change.target_id)
555+
target = sbml_model.getElementBySId(target_id)
353556
if target is None:
354557
add_sbml_parameter(
355-
sbml_model, id_=change.target_id, constant=False, value=0
558+
sbml_model, id_=target_id, constant=False, value=0
356559
)
357560
else:
358561
# We can safely change the `constant` attribute of the target.
@@ -362,7 +565,7 @@ def _change_to_event_assignment(
362565
# the target value may depend on parameters that are only
363566
# introduced in the PEtab parameter table - those need
364567
# to be added to the model
365-
for sym in change.target_value.free_symbols:
568+
for sym in target_value.free_symbols:
366569
if sbml_model.getElementBySId(sym.name) is None:
367570
add_sbml_parameter(
368571
sbml_model, id_=sym.name, constant=True, value=0

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ maintainers = [
3737
[project.optional-dependencies]
3838
tests = [
3939
"antimony>=2.14.0",
40+
"copasi-basico>=0.85",
4041
"pysb",
4142
"pytest",
4243
"pytest-cov",

0 commit comments

Comments
 (0)