Skip to content

Commit 0735774

Browse files
committed
Update to changed change semantics
1 parent 004be14 commit 0735774

File tree

3 files changed

+271
-23
lines changed

3 files changed

+271
-23
lines changed

petab/v2/converters.py

Lines changed: 115 additions & 23 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 (
@@ -31,6 +32,8 @@ class ExperimentsToEventsConverter:
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``.
@@ -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

@@ -214,7 +216,7 @@ def _convert_experiment(self, experiment: Experiment) -> None:
214216
)
215217
add_sbml_parameter(model, id_=exp_ind_id, constant=False, value=0)
216218
kept_periods = []
217-
for i_period, period in enumerate(experiment.periods):
219+
for i_period, period in enumerate(experiment.sorted_periods):
218220
if period.is_preequilibration:
219221
# pre-equilibration cannot be represented in SBML,
220222
# so we need to keep this period in the Problem.
@@ -229,17 +231,20 @@ def _convert_experiment(self, experiment: Experiment) -> None:
229231
# or the only non-equilibration period (handled above)
230232
continue
231233

234+
# TODO: If this is the first period, we may want to implement
235+
# the changes as initialAssignments instead of events.
236+
# That way, we don't need to worry about any event priorities
237+
# and tools that don't support event priorities can still handle
238+
# single-period experiments.
239+
232240
ev = self._create_period_start_event(
233241
experiment=experiment,
234242
i_period=i_period,
235243
period=period,
236244
)
237245
self._create_event_assignments_for_period(
238246
ev,
239-
[
240-
self._new_problem[condition_id]
241-
for condition_id in period.condition_ids
242-
],
247+
self._new_problem.get_changes_for_period(period),
243248
)
244249

245250
if len(kept_periods) > 2:
@@ -326,33 +331,120 @@ def get_experiment_indicator(experiment_id: str) -> str:
326331

327332
@staticmethod
328333
def _create_event_assignments_for_period(
329-
event: libsbml.Event, conditions: list[Condition]
334+
event: libsbml.Event, changes: list[Change]
330335
) -> 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
336+
"""Create event assignments for a given period.
337+
338+
Converts PEtab ``Change``s to equivalent SBML event assignments.
339+
340+
Note that the SBML event assignment formula is not necessarily the same
341+
as the `targetValue` in PEtab.
342+
In SBML, concentrations are treated as derived quantities.
343+
Therefore, changing the size of a compartment will update the
344+
concentrations of all contained concentration-based species.
345+
In PEtab, such a change would not automatically update the species
346+
concentrations, but only the compartment size.
347+
348+
Therefore, to correctly implement a PEtab change of a compartment size
349+
in SBML, we need to compensate for the automatic update of species
350+
concentrations by adding event assignments for all contained
351+
concentration-based species.
352+
353+
:param event: The SBML event to which the assignments should be added.
354+
:param changes: The PEtab condition changes that are to be applied
355+
at the start of the period.
356+
"""
357+
_add_assignment = ExperimentsToEventsConverter._add_assignment
358+
sbml_model = event.getModel()
359+
# collect IDs of compartments that are changed in this period
360+
changed_compartments = {
361+
change.target_id
362+
for change in changes
363+
if sbml_model.getElementBySId(change.target_id) is not None
364+
and sbml_model.getElementBySId(change.target_id).getTypeCode()
365+
== libsbml.SBML_COMPARTMENT
366+
}
367+
368+
for change in changes:
369+
sbml_target = sbml_model.getElementBySId(change.target_id)
370+
371+
if sbml_target is None:
372+
raise ValueError(
373+
f"Cannot create event assignment for change of "
374+
f"`{change.target_id}`: No such entity in the SBML model."
336375
)
337376

377+
target_type = sbml_target.getTypeCode()
378+
if target_type == libsbml.SBML_COMPARTMENT:
379+
# handle the actual compartment size change
380+
_add_assignment(event, change.target_id, change.target_value)
381+
382+
# Changing a compartment size affects all contained
383+
# concentration-based species - we need to add event
384+
# assignments for those to compensate for the automatic
385+
# update of their concentrations.
386+
# The event assignment will set the concentration to
387+
# new_conc = assigned_amount / new_volume
388+
# = assigned_conc * old_volume / new_volume
389+
# <=> assigned_conc = new_conc * new_volume / old_volume
390+
# Therefore, the event assignment is not just `new_conc`,
391+
# but `new_conc * new_volume / old_volume`.
392+
393+
# concentration-based species in the changed compartment
394+
conc_species = [
395+
species.getId()
396+
for species in sbml_model.getListOfSpecies()
397+
if species.getCompartment() == change.target_id
398+
and not species.getHasOnlySubstanceUnits()
399+
]
400+
for species_id in conc_species:
401+
if species_change := next(
402+
(c for c in changes if c.target_id == species_id), None
403+
):
404+
# there is an explicit change for this species
405+
# in this period
406+
new_conc = species_change.target_value
407+
else:
408+
# no explicit change, use the pre-event concentration
409+
new_conc = sp.Symbol(species_id)
410+
411+
_add_assignment(
412+
event,
413+
species_id,
414+
# new_conc * new_volume / old_volume
415+
new_conc
416+
* change.target_value
417+
/ sp.Symbol(change.target_id),
418+
)
419+
elif (
420+
target_type != libsbml.SBML_SPECIES
421+
or sbml_target.getCompartment() not in changed_compartments
422+
or sbml_target.getHasOnlySubstanceUnits() is True
423+
):
424+
# Handle any changes other than compartments and
425+
# concentration-based species inside resized compartments
426+
# that we were already handled above.
427+
# Those translate directly to event assignments.
428+
_add_assignment(event, change.target_id, change.target_value)
429+
338430
@staticmethod
339-
def _change_to_event_assignment(
340-
change: Change, event: libsbml.Event
431+
def _add_assignment(
432+
event: libsbml.Event, target_id: str, target_value: sp.Basic
341433
) -> None:
342-
"""Convert a PEtab ``Change`` to an SBML event assignment."""
434+
"""Add a single event assignment to the given event
435+
and apply any necessary changes to the model."""
343436
sbml_model = event.getModel()
344-
345437
ea = event.createEventAssignment()
346-
ea.setVariable(change.target_id)
347-
set_math(ea, change.target_value)
438+
ea.setVariable(target_id)
439+
set_math(ea, target_value)
348440

349441
# target needs const=False, and target may not exist yet
350442
# (e.g., in case of output parameters added in the observable
351443
# table)
352-
target = sbml_model.getElementBySId(change.target_id)
444+
target = sbml_model.getElementBySId(target_id)
353445
if target is None:
354446
add_sbml_parameter(
355-
sbml_model, id_=change.target_id, constant=False, value=0
447+
sbml_model, id_=target_id, constant=False, value=0
356448
)
357449
else:
358450
# We can safely change the `constant` attribute of the target.
@@ -362,7 +454,7 @@ def _change_to_event_assignment(
362454
# the target value may depend on parameters that are only
363455
# introduced in the PEtab parameter table - those need
364456
# to be added to the model
365-
for sym in change.target_value.free_symbols:
457+
for sym in target_value.free_symbols:
366458
if sbml_model.getElementBySId(sym.name) is None:
367459
add_sbml_parameter(
368460
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",

tests/v2/test_converters.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from math import inf
22

3+
import pandas as pd
4+
35
from petab.v2 import Change, Condition, Experiment, ExperimentPeriod, Problem
46
from petab.v2.converters import ExperimentsToEventsConverter
57
from petab.v2.models.sbml_model import SbmlModel
@@ -74,3 +76,156 @@ def test_experiments_to_events_converter():
7476
],
7577
),
7678
]
79+
80+
81+
def test_simulate_experiment_to_events():
82+
"""
83+
Convert PEtab experiment to SBML events and compare BasiCO simulation
84+
results.
85+
"""
86+
import basico
87+
88+
# the basic model for the PEtab problem
89+
ant_model1 = """
90+
compartment comp1 = 10
91+
compartment comp2 = 2
92+
# concentration-based species
93+
species s1c_comp1 in comp1 = 1
94+
species s1c_comp2 in comp2 = 2
95+
species s2c_comp1 in comp1 = 3
96+
species s2c_comp2 in comp2 = 4
97+
# amount-based species
98+
# (note: the initial values are concentrations nonetheless)
99+
substanceOnly species s3a_comp1 in comp1 = 5
100+
substanceOnly species s3a_comp2 in comp2 = 6
101+
substanceOnly species s4a_comp1 in comp1 = 7
102+
substanceOnly species s4a_comp2 in comp2 = 8
103+
104+
# something dynamic
105+
some_species in comp1 = 0
106+
some_species' = 1
107+
108+
# set time-derivatives, otherwise BasiCO won't include them in the result
109+
s1c_comp1' = 0
110+
s1c_comp2' = 0
111+
s2c_comp1' = 0
112+
s2c_comp2' = 0
113+
s3a_comp1' = 0
114+
s3a_comp2' = 0
115+
s4a_comp1' = 0
116+
s4a_comp2' = 0
117+
"""
118+
119+
# append events, equivalent to the expected PEtab conversion result
120+
ant_model_expected = (
121+
ant_model1
122+
+ """
123+
# resize compartment
124+
# The size of comp1 should be set to 20, the concentrations of the
125+
# contained concentration-based species and the amounts of the amount-based
126+
# species should remain unchanged. comp2 and everything therein is
127+
# unaffected.
128+
# I.e., post-event:
129+
# s1c_comp1 = 1, s2c_comp1 = 3, s3a_comp1 = 5, s4a_comp1 = 7
130+
at time >= 1:
131+
comp1 = 20,
132+
s1c_comp1 = s1c_comp1 * 20 / comp1,
133+
s2c_comp1 = s2c_comp1 * 20 / comp1;
134+
135+
# resize compartment *and* reassign concentration
136+
# The size of comp2 should be set to 4, the concentration/amount of
137+
# s1c_comp2/s3a_comp2 should be set to the given values,
138+
# the amounts for amount-based and concentrations for concentration-based
139+
# other species in comp2 should remain unchanged.
140+
# I.e., post-event:
141+
# comp2 = 4
142+
# s1c_comp2 = 5, s3a_comp2 = 16,
143+
# s2c_comp2 = 4 (unchanged), s4a_comp2 = 8 (unchanged)
144+
# The post-event concentrations of concentration-based species are
145+
# (per SBML):
146+
# new_conc = assigned_amount / new_volume
147+
# = assigned_conc * old_volume / new_volume
148+
# <=> assigned_conc = new_conc * new_volume / old_volume
149+
# The post-event amounts of amount-based species are:
150+
# new_amount = assigned_amount (independent of volume change)
151+
at time >= 5:
152+
comp2 = 4,
153+
s3a_comp2 = 16,
154+
s1c_comp2 = 5 * 4 / comp2,
155+
s2c_comp2 = s2c_comp2 * 4 / comp2;
156+
"""
157+
)
158+
159+
# simulate expected model in BasiCO
160+
sbml_expected = SbmlModel.from_antimony(ant_model_expected).to_sbml_str()
161+
basico.load_model(sbml_expected)
162+
# output timepoints (initial, pre-/post-event, ...)
163+
timepoints = [0, 0.9, 1.1, 4.9, 5.1, 10]
164+
# Simulation will return all species as concentrations
165+
df_expected = basico.run_time_course(values=timepoints)
166+
# fmt: off
167+
assert (
168+
df_expected
169+
== pd.DataFrame(
170+
{'Values[some_species]': {0.0: 0.0, 0.9: 0.9,
171+
1.1: 1.0999999999999996, 4.9: 4.9,
172+
5.1: 5.100000000000001, 10.0: 10.0},
173+
's1c_comp1': {0.0: 1.0, 0.9: 1.0, 1.1: 1.0, 4.9: 1.0, 5.1: 1.0,
174+
10.0: 1.0},
175+
's2c_comp1': {0.0: 3.0, 0.9: 3.0, 1.1: 3.0, 4.9: 3.0, 5.1: 3.0,
176+
10.0: 3.0},
177+
's3a_comp1': {0.0: 5.0, 0.9: 5.0, 1.1: 2.5, 4.9: 2.5, 5.1: 2.5,
178+
10.0: 2.5},
179+
's4a_comp1': {0.0: 7.0, 0.9: 7.0, 1.1: 3.5, 4.9: 3.5, 5.1: 3.5,
180+
10.0: 3.5},
181+
's1c_comp2': {0.0: 2.0, 0.9: 2.0, 1.1: 2.0, 4.9: 2.0, 5.1: 5.0,
182+
10.0: 5.0},
183+
's2c_comp2': {0.0: 4.0, 0.9: 4.0, 1.1: 4.0, 4.9: 4.0, 5.1: 4.0,
184+
10.0: 4.0},
185+
's3a_comp2': {0.0: 6.0, 0.9: 6.0, 1.1: 6.0, 4.9: 6.0, 5.1: 4.0,
186+
10.0: 4.0},
187+
's4a_comp2': {0.0: 8.0, 0.9: 8.0, 1.1: 8.0, 4.9: 8.0, 5.1: 4.0,
188+
10.0: 4.0},
189+
'Compartments[comp1]': {0.0: 10.0, 0.9: 10.0, 1.1: 20.0,
190+
4.9: 20.0, 5.1: 20.0, 10.0: 20.0},
191+
'Compartments[comp2]': {0.0: 2.0, 0.9: 2.0, 1.1: 2.0, 4.9: 2.0,
192+
5.1: 4.0, 10.0: 4.0}}
193+
)
194+
).all().all()
195+
# fmt: on
196+
197+
# construct PEtab test problem
198+
problem = Problem()
199+
problem.model = SbmlModel.from_antimony(ant_model1)
200+
problem.add_condition("c1", comp1=20)
201+
problem.add_condition("c2", comp2=4, s1c_comp2=5, s3a_comp2=16)
202+
problem.add_experiment("e1", 1, "c1", 5, "c2")
203+
problem.assert_valid()
204+
205+
# convert PEtab experiments to SBML events and simulate in BasiCO
206+
converter = ExperimentsToEventsConverter(problem)
207+
converted = converter.convert()
208+
# set experiment indicator to simulate experiment "e1"
209+
converted.model.sbml_model.getParameter(
210+
"_petab_experiment_indicator_e1"
211+
).setValue(1)
212+
sbml_actual = converted.model.to_sbml_str()
213+
basico.load_model(sbml_actual)
214+
df_actual = basico.run_time_course(values=timepoints)
215+
216+
# compare results
217+
with pd.option_context(
218+
"display.max_rows",
219+
None,
220+
"display.max_columns",
221+
None,
222+
"display.width",
223+
None,
224+
):
225+
print("Expected:")
226+
print(df_expected)
227+
print("Actual:")
228+
print(df_actual)
229+
230+
for col in df_expected.columns:
231+
assert (df_expected[col] == df_actual[col]).all()

0 commit comments

Comments
 (0)