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 (
@@ -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
0 commit comments