Skip to content

Commit 70b8974

Browse files
committed
initial period as initial assignment
1 parent d236993 commit 70b8974

File tree

2 files changed

+118
-16
lines changed

2 files changed

+118
-16
lines changed

petab/v2/converters.py

Lines changed: 115 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,9 @@ def convert(self) -> Problem:
200200
return self._new_problem
201201

202202
def _convert_experiment(self, experiment: Experiment) -> None:
203-
"""Convert a single experiment to SBML events."""
203+
"""
204+
Convert a single experiment to SBML events or initial assignments.
205+
"""
204206
model = self._model
205207
experiment.sort_periods()
206208
has_preequilibration = experiment.has_preequilibration
@@ -215,7 +217,13 @@ def _convert_experiment(self, experiment: Experiment) -> None:
215217
"model."
216218
)
217219
add_sbml_parameter(model, id_=exp_ind_id, constant=False, value=0)
218-
kept_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+
219227
for i_period, period in enumerate(experiment.sorted_periods):
220228
if period.is_preequilibration:
221229
# pre-equilibration cannot be represented in SBML,
@@ -231,21 +239,80 @@ def _convert_experiment(self, experiment: Experiment) -> None:
231239
# or the only non-equilibration period (handled above)
232240
continue
233241

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
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
238249
# single-period experiments.
250+
if i_period == 0:
251+
exp_ind = 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, 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+
)
239266

240-
ev = self._create_period_start_event(
241-
experiment=experiment,
242-
i_period=i_period,
243-
period=period,
244-
)
245-
self._create_event_assignments_for_period(
246-
ev,
247-
self._new_problem.get_changes_for_period(period),
248-
)
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+
# combine all changes into a single piecewise expression
287+
if expr_cond_pairs := [
288+
(target_value, sp.Symbol(exp_ind) > 0.5)
289+
for exp_ind, target_value in changes
290+
if target_value != default
291+
]:
292+
# only create the initial assignment if there is
293+
# actually something to change
294+
expr = sp.Piecewise(
295+
*expr_cond_pairs,
296+
(default, True),
297+
)
298+
299+
# Create a new initial assignment if necessary, otherwise
300+
# overwrite the existing one.
301+
if ia is None:
302+
ia = model.createInitialAssignment()
303+
ia.setSymbol(target_id)
304+
305+
set_math(ia, expr)
306+
free_symbols_in_assignments |= expr.free_symbols
307+
308+
# the target value may depend on parameters that are only
309+
# introduced in the PEtab parameter table - those need
310+
# to be added to the model
311+
for sym in free_symbols_in_assignments:
312+
if model.getElementBySId(sym.name) is None:
313+
add_sbml_parameter(
314+
model, id_=sym.name, constant=True, value=0
315+
)
249316

250317
if len(kept_periods) > 2:
251318
raise AssertionError("Expected at most two periods to be kept.")
@@ -261,6 +328,39 @@ def _convert_experiment(self, experiment: Experiment) -> None:
261328

262329
experiment.periods = kept_periods
263330

331+
@staticmethod
332+
def _initial_value_from_element(target: libsbml.SBase) -> sp.Basic:
333+
# use the initial value of the target as default
334+
if target is None:
335+
raise ValueError("`target` is None.")
336+
337+
if target.getTypeCode() == libsbml.SBML_COMPARTMENT:
338+
return sp.Float(target.getSize())
339+
340+
if target.getTypeCode() == libsbml.SBML_SPECIES:
341+
if target.getHasOnlySubstanceUnits():
342+
# amount-based -> return amount
343+
if target.isSetInitialAmount():
344+
return sp.Float(target.getInitialAmount())
345+
return sp.Float(target.getInitialConcentration()) * sp.Symbol(
346+
target.getCompartment()
347+
)
348+
# concentration-based -> return concentration
349+
if target.isSetInitialConcentration():
350+
return sp.Float(target.getInitialConcentration())
351+
352+
return sp.Float(target.getInitialAmount()) / sp.Symbol(
353+
target.getCompartment()
354+
)
355+
356+
if target.getTypeCode() == libsbml.SBML_PARAMETER:
357+
return sp.Float(target.getValue())
358+
359+
raise NotImplementedError(
360+
"Cannot create initial assignment for unsupported SBML "
361+
f"entity type {target.getTypeCode()}."
362+
)
363+
264364
def _create_period_start_event(
265365
self, experiment: Experiment, i_period: int, period: ExperimentPeriod
266366
) -> libsbml.Event:

tests/v2/test_converters.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,10 @@ def test_simulate_experiment_to_events():
197197
# construct PEtab test problem
198198
problem = Problem()
199199
problem.model = SbmlModel.from_antimony(ant_model1)
200+
problem.add_condition("c0", comp1=10)
200201
problem.add_condition("c1", comp1=20)
201202
problem.add_condition("c2", comp2=4, s1c_comp2=5, s3a_comp2=16)
202-
problem.add_experiment("e1", 1, "c1", 5, "c2")
203+
problem.add_experiment("e1", 0, "c0", 1, "c1", 5, "c2")
203204
problem.assert_valid()
204205

205206
# convert PEtab experiments to SBML events and simulate in BasiCO
@@ -210,6 +211,7 @@ def test_simulate_experiment_to_events():
210211
"_petab_experiment_indicator_e1"
211212
).setValue(1)
212213
sbml_actual = converted.model.to_sbml_str()
214+
print(converted.model.to_antimony())
213215
basico.load_model(sbml_actual)
214216
df_actual = basico.run_time_course(values=timepoints)
215217

0 commit comments

Comments
 (0)