66from copy import deepcopy
77
88import libsbml
9+ import sympy as sp
910from sbmlmath import sbml_math_to_sympy , set_math
1011
1112from .core import (
1920from .models ._sbml_utils import add_sbml_parameter , check
2021from .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
0 commit comments