From 498d20791926d81ad542e20e26a2fd91c67a5ab4 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Mon, 12 May 2025 14:06:38 +0200 Subject: [PATCH 01/14] v2: Add experiments->events converter Add functionality to convert PEtab v2 experiments/conditions to SBML events. This should make it easier to implement v2 support in other tools. --- doc/modules.rst | 1 + petab/v2/converters.py | 369 +++++++++++++++++++++++++++++++++ petab/v2/core.py | 10 + petab/v2/models/_sbml_utils.py | 52 +++++ 4 files changed, 432 insertions(+) create mode 100644 petab/v2/converters.py create mode 100644 petab/v2/models/_sbml_utils.py diff --git a/doc/modules.rst b/doc/modules.rst index 627ba9d8..6dacba5a 100644 --- a/doc/modules.rst +++ b/doc/modules.rst @@ -32,6 +32,7 @@ API Reference petab.v1.yaml petab.v2 petab.v2.C + petab.v2.converters petab.v2.core petab.v2.experiments petab.v2.lint diff --git a/petab/v2/converters.py b/petab/v2/converters.py new file mode 100644 index 00000000..cfcaeb1f --- /dev/null +++ b/petab/v2/converters.py @@ -0,0 +1,369 @@ +"""Conversion of PEtab problems.""" + +from __future__ import annotations + +import warnings +from copy import deepcopy +from math import inf + +import libsbml +from sbmlmath import sbml_math_to_sympy, set_math + +from .core import Change, Condition, Experiment, ExperimentPeriod +from .models._sbml_utils import add_sbml_parameter, check +from .models.sbml_model import SbmlModel +from .problem import Problem + +__all__ = ["ExperimentsToEventsConverter"] + + +class ExperimentsToEventsConverter: + """Convert PEtab experiments to SBML events. + + For an SBML-model-based PEtab problem, this class converts the PEtab + experiments to events as far as possible. + + If the model already contains events, PEtab events are added with a higher + priority than the existing events to guarantee that PEtab condition changes + are applied before any pre-existing assignments. + + The PEtab problem must not contain any identifiers starting with + ``_petab``. + + All periods and condition changes that are represented by events + will be removed from the condition table. + Each experiment will have at most one period with a start time of ``-inf`` + and one period with a finite start time. The associated changes with + these periods are only the steady-state pre-simulation indicator + (if necessary), and the experiment indicator parameter. + """ + + #: ID of the parameter that indicates whether the model is in + # the steady-state pre-simulation phase (1) or not (0). + PRE_SIM_INDICATOR = "_petab_pre_simulation_indicator" + + def __init__(self, problem: Problem): + """Initialize the converter. + + :param problem: The PEtab problem to convert. + This will not be modified. + """ + if not isinstance(problem.model, SbmlModel): + raise ValueError("Only SBML models are supported.") + + self._original_problem = problem + self._new_problem = deepcopy(self._original_problem) + + self._model = self._new_problem.model.sbml_model + self._presim_indicator = self.PRE_SIM_INDICATOR + + # The maximum event priority that was found in the unprocessed model. + self._max_event_priority = None + # The priority that will be used for the PEtab events. + self._petab_event_priority = None + + self._preprocess() + + def _preprocess(self): + """Check whether we can handle the given problem and store some model + information.""" + model = self._model + if model.getLevel() < 3: + # try to upgrade the SBML model + if not model.getSBMLDocument().setLevelAndVersion(3, 2): + raise ValueError( + "Cannot handle SBML models with SBML level < 3, " + "because they do not support initial values for event " + "triggers and automatic upconversion failed." + ) + + # Collect event priorities + event_priorities = { + ev.getId() or str(ev): sbml_math_to_sympy(ev.getPriority()) + for ev in model.getListOfEvents() + if ev.getPriority() and ev.getPriority().getMath() is not None + } + + # Check for non-constant event priorities and track the maximum + # priority used so far. + for e, priority in event_priorities.items(): + if priority.free_symbols: + # We'd need to find the maximum priority of all events, + # which is challenging/impossible to do in general. + raise NotImplementedError( + f"Event `{e}` has a non-constant priority: {priority}. " + "This is currently not supported." + ) + self._max_event_priority = max( + self._max_event_priority or 0, float(priority) + ) + + self._petab_event_priority = ( + self._max_event_priority + 1 + if self._max_event_priority is not None + else None + ) + + for event in model.getListOfEvents(): + # Check for undefined event priorities and warn + if (prio := event.getPriority()) and prio.getMath() is None: + warnings.warn( + f"Event `{event.getId()}` has no priority set. " + "Make sure that this event cannot trigger at the time of " + "a PEtab condition change, otherwise the behavior is " + "undefined.", + stacklevel=1, + ) + + # Check for useValuesFromTrigger time + if event.getUseValuesFromTriggerTime(): + # Non-PEtab-condition-change events must be executed *after* + # PEtab condition changes have been applied, based on the + # updated model state. This would be violated by + # useValuesFromTriggerTime=true. + warnings.warn( + f"Event `{event.getId()}` has " + "`useValuesFromTriggerTime=true'. " + "Make sure that this event cannot trigger at the time of " + "a PEtab condition change, or consider changing " + "`useValuesFromTriggerTime' to `false'. Otherwise " + "simulation results may be incorrect.", + stacklevel=1, + ) + + def convert(self) -> Problem: + """Convert the PEtab experiments to SBML events. + + :return: The converted PEtab problem. + """ + + self._add_presimulation_indicator() + + problem = self._new_problem + for experiment in problem.experiment_table.experiments: + self._convert_experiment(problem, experiment) + + self._add_indicators_to_conditions(problem) + + validation_results = problem.validate() + validation_results.log() + + return problem + + def _convert_experiment(self, problem: Problem, experiment: Experiment): + """Convert a single experiment to SBML events.""" + model = self._model + experiment.sort_periods() + has_presimulation = ( + len(experiment.periods) and experiment.periods[0].time == -inf + ) + + # add experiment indicator + exp_ind_id = self.get_experiment_indicator(experiment.id) + if model.getElementBySId(exp_ind_id) is not None: + raise AssertionError( + f"Entity with ID {exp_ind_id} exists already." + ) + add_sbml_parameter(model, id_=exp_ind_id, constant=False, value=0) + kept_periods = [] + for i_period, period in enumerate(experiment.periods): + # check for non-zero initial times of the first period + if (i_period == int(has_presimulation)) and period.time != 0: + # TODO: we could address that by offsetting all occurrences of + # the SBML time in the model (except for the newly added + # events triggers). Or we better just leave it to the + # simulator -- we anyways keep the first period in the + # returned Problem. + raise NotImplementedError( + "Cannot represent non-zero initial time in SBML." + ) + + if period.time == -inf: + # steady-state pre-simulation cannot be represented in SBML, + # so we need to keep this period in the Problem. + kept_periods.append(period) + elif i_period == int(has_presimulation): + # we always keep the first non-presimulation period + # to set the indicator parameters + kept_periods.append(period) + elif not period.changes: + # no condition, no changes, no need for an event, + # no need to keep the period unless it's the initial + # steady-state simulation or the only non-presimulation + # period (handled above) + continue + + ev = self._create_period_begin_event( + experiment=experiment, + i_period=i_period, + period=period, + ) + self._create_event_assignments_for_period( + ev, + [ + problem.condition_table[condition_id] + for condition_id in period.condition_ids + ], + ) + + if len(kept_periods) > 2: + raise AssertionError("Expected at most two periods to be kept.") + + # add conditions that set the indicator parameters + for period in kept_periods: + period.condition_ids = [ + f"_petab_experiment_condition_{experiment.id}", + "_petab_steady_state_pre_simulation" + if period.time == -inf + else "_petab_no_steady_state_pre_simulation", + ] + + experiment.periods = kept_periods + + def _create_period_begin_event( + self, experiment: Experiment, i_period: int, period: ExperimentPeriod + ) -> libsbml.Event: + """Create an event that triggers at the beginning of a period.""" + + # TODO: for now, add separate events for each experiment x period, + # this could be optimized to reuse events + + ev = self._model.createEvent() + check(ev.setId(f"_petab_event_{experiment.id}_{i_period}")) + check(ev.setUseValuesFromTriggerTime(True)) + trigger = ev.createTrigger() + check(trigger.setInitialValue(False)) # may trigger at t=0 + check(trigger.setPersistent(True)) + if self._petab_event_priority is not None: + priority = ev.createPriority() + set_math(priority, self._petab_event_priority) + + exp_ind_id = self.get_experiment_indicator(experiment.id) + + if period.time == -inf: + trig_math = libsbml.parseL3Formula( + f"({exp_ind_id} == 1) && ({self._presim_indicator} == 1)" + ) + else: + trig_math = libsbml.parseL3Formula( + f"({exp_ind_id} == 1) && ({self._presim_indicator} != 1) " + f"&& (time >= {period.time})" + ) + check(trigger.setMath(trig_math)) + + return ev + + def _add_presimulation_indicator( + self, + ) -> None: + """Add an indicator parameter for the steady-state presimulation to + the SBML model.""" + par_id = self._presim_indicator + if self._model.getElementBySId(par_id) is not None: + raise AssertionError(f"Entity with ID {par_id} exists already.") + + # add the pre-steady-state indicator parameter + add_sbml_parameter(self._model, id_=par_id, value=0, constant=False) + + @staticmethod + def get_experiment_indicator(experiment_id: str) -> str: + """The ID of the experiment indicator parameter. + + The experiment indicator parameter is used to identify the + experiment in the SBML model. It is a parameter that is set + to 1 for the current experiment and 0 for all other + experiments. The parameter is used in the event trigger + to determine when the event should be triggered. + + :param experiment_id: The ID of the experiment for which to create + the experiment indicator parameter ID. + """ + return f"_petab_experiment_indicator_{experiment_id}" + + @staticmethod + def _create_event_assignments_for_period( + event: libsbml.Event, conditions: list[Condition] + ) -> None: + """Create an event assignments for a given period.""" + for condition in conditions: + for change in condition.changes: + ExperimentsToEventsConverter._change_to_event_assignment( + change, event + ) + + @staticmethod + def _change_to_event_assignment(change: Change, event: libsbml.Event): + """Convert a PEtab ``Change`` to an SBML event assignment.""" + sbml_model = event.getModel() + + ea = event.createEventAssignment() + ea.setVariable(change.target_id) + set_math(ea, change.target_value) + + # target needs const=False, and target may not exist yet + # (e.g., in case of output parameters added in the observable + # table) + target = sbml_model.getElementBySId(change.target_id) + if target is None: + add_sbml_parameter( + sbml_model, id_=change.target_id, constant=False, value=0 + ) + else: + # TODO: can that break models?? + target.setConstant(False) + + # the target value may depend on parameters that are only + # introduced in the PEtab parameter table - those need + # to be added to the model + for sym in change.target_value.free_symbols: + if sbml_model.getElementBySId(sym.name) is None: + add_sbml_parameter( + sbml_model, id_=sym.name, constant=True, value=0 + ) + + def _add_indicators_to_conditions(self, problem: Problem) -> None: + """After converting the experiments to events, add the indicator + parameters for the presimulation period and for the different + experiments to the remaining conditions. + Then remove all other conditions.""" + + # create conditions for indicator parameters + problem.condition_table.conditions.append( + Condition( + id="_petab_steady_state_pre_simulation", + changes=[ + Change(target_id=self._presim_indicator, target_value=1) + ], + ) + ) + problem.condition_table.conditions.append( + Condition( + id="_petab_no_steady_state_pre_simulation", + changes=[ + Change(target_id=self._presim_indicator, target_value=0) + ], + ) + ) + # add conditions for the experiment indicators + for experiment in problem.experiment_table.experiments: + problem.condition_table.conditions.append( + Condition( + id=f"_petab_experiment_condition_{experiment.id}", + changes=[ + Change( + target_id=self.get_experiment_indicator( + experiment.id + ), + target_value=1, + ) + ], + ) + ) + + # All changes have been encoded in event assignments and can be + # removed. Only keep the conditions setting our indicators. + problem.condition_table.conditions = [ + condition + for condition in problem.condition_table.conditions + if condition.id.startswith("_petab") + ] diff --git a/petab/v2/core.py b/petab/v2/core.py index a847b196..19ba4aab 100644 --- a/petab/v2/core.py +++ b/petab/v2/core.py @@ -114,6 +114,8 @@ class NoiseDistribution(str, Enum): LOG_NORMAL = C.LOG_NORMAL #: Log-Laplace distribution LOG_LAPLACE = C.LOG_LAPLACE + #: Log10-Normal + LOG10_NORMAL = C.LOG10_NORMAL class PriorDistribution(str, Enum): @@ -553,6 +555,14 @@ def __iadd__(self, other: ExperimentPeriod) -> Experiment: self.periods.append(other) return self + def has_steady_state_presimulation(self) -> bool: + """Check if the experiment has a steady-state pre-simulation.""" + return any(period.time == -np.inf for period in self.periods) + + def sort_periods(self) -> None: + """Sort the periods of the experiment by time.""" + self.periods.sort(key=lambda period: period.time) + class ExperimentTable(BaseModel): """PEtab experiments table.""" diff --git a/petab/v2/models/_sbml_utils.py b/petab/v2/models/_sbml_utils.py new file mode 100644 index 00000000..dda01ad2 --- /dev/null +++ b/petab/v2/models/_sbml_utils.py @@ -0,0 +1,52 @@ +"""Private utility functions for SBML handling.""" + +import libsbml + +retval_to_str = { + getattr(libsbml, attr): attr + for attr in ( + "LIBSBML_DUPLICATE_OBJECT_ID", + "LIBSBML_INDEX_EXCEEDS_SIZE", + "LIBSBML_INVALID_ATTRIBUTE_VALUE", + "LIBSBML_INVALID_OBJECT", + "LIBSBML_INVALID_XML_OPERATION", + "LIBSBML_LEVEL_MISMATCH", + "LIBSBML_NAMESPACES_MISMATCH", + "LIBSBML_OPERATION_FAILED", + "LIBSBML_UNEXPECTED_ATTRIBUTE", + "LIBSBML_PKG_UNKNOWN", + "LIBSBML_PKG_VERSION_MISMATCH", + "LIBSBML_PKG_CONFLICTED_VERSION", + ) +} + + +def check(res: int): + """Check the return value of a libsbml function that returns a status code. + + :param res: The return value to check. + :raises RuntimeError: If the return value indicates an error. + """ + if res != libsbml.LIBSBML_OPERATION_SUCCESS: + raise RuntimeError(f"libsbml error: {retval_to_str.get(res, res)}") + + +def add_sbml_parameter( + model: libsbml.Model, + id_: str = None, + value: float = None, + constant: bool = None, +) -> libsbml.Parameter: + """Add a parameter to the SBML model.""" + param = model.createParameter() + + if id_ is not None: + param.setId(id_) + + if value is not None: + param.setValue(value) + + if constant is not None: + param.setConstant(constant) + + return param From 0deb4327f7d980bbd861b1cf542c3363a6e1c0be Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Wed, 2 Jul 2025 19:30:06 +0200 Subject: [PATCH 02/14] Apply suggestions from code review Co-authored-by: Dilan Pathirana <59329744+dilpath@users.noreply.github.com> --- petab/v2/converters.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/petab/v2/converters.py b/petab/v2/converters.py index cfcaeb1f..d2684c52 100644 --- a/petab/v2/converters.py +++ b/petab/v2/converters.py @@ -260,7 +260,9 @@ def _add_presimulation_indicator( the SBML model.""" par_id = self._presim_indicator if self._model.getElementBySId(par_id) is not None: - raise AssertionError(f"Entity with ID {par_id} exists already.") + raise ValueError( + f"Entity with ID {par_id} already exists in the SBML model." + ) # add the pre-steady-state indicator parameter add_sbml_parameter(self._model, id_=par_id, value=0, constant=False) @@ -273,7 +275,7 @@ def get_experiment_indicator(experiment_id: str) -> str: experiment in the SBML model. It is a parameter that is set to 1 for the current experiment and 0 for all other experiments. The parameter is used in the event trigger - to determine when the event should be triggered. + to determine whether the event should be triggered. :param experiment_id: The ID of the experiment for which to create the experiment indicator parameter ID. From e660f606010b8856b4b5bddd3c8a984385abdf90 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Wed, 9 Jul 2025 16:20:44 +0200 Subject: [PATCH 03/14] preeq --- petab/v2/converters.py | 51 +++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/petab/v2/converters.py b/petab/v2/converters.py index d2684c52..670e42f2 100644 --- a/petab/v2/converters.py +++ b/petab/v2/converters.py @@ -34,13 +34,13 @@ class ExperimentsToEventsConverter: will be removed from the condition table. Each experiment will have at most one period with a start time of ``-inf`` and one period with a finite start time. The associated changes with - these periods are only the steady-state pre-simulation indicator + these periods are only the pre-equilibration indicator (if necessary), and the experiment indicator parameter. """ #: ID of the parameter that indicates whether the model is in - # the steady-state pre-simulation phase (1) or not (0). - PRE_SIM_INDICATOR = "_petab_pre_simulation_indicator" + # the pre-equilibration phase (1) or not (0). + PREEQ_INDICATOR = "_petab_preequilibration_indicator" def __init__(self, problem: Problem): """Initialize the converter. @@ -55,7 +55,7 @@ def __init__(self, problem: Problem): self._new_problem = deepcopy(self._original_problem) self._model = self._new_problem.model.sbml_model - self._presim_indicator = self.PRE_SIM_INDICATOR + self._preeq_indicator = self.PREEQ_INDICATOR # The maximum event priority that was found in the unprocessed model. self._max_event_priority = None @@ -137,7 +137,7 @@ def convert(self) -> Problem: :return: The converted PEtab problem. """ - self._add_presimulation_indicator() + self._add_preequilibration_indicator() problem = self._new_problem for experiment in problem.experiment_table.experiments: @@ -154,7 +154,7 @@ def _convert_experiment(self, problem: Problem, experiment: Experiment): """Convert a single experiment to SBML events.""" model = self._model experiment.sort_periods() - has_presimulation = ( + has_preequilibration = ( len(experiment.periods) and experiment.periods[0].time == -inf ) @@ -168,7 +168,7 @@ def _convert_experiment(self, problem: Problem, experiment: Experiment): kept_periods = [] for i_period, period in enumerate(experiment.periods): # check for non-zero initial times of the first period - if (i_period == int(has_presimulation)) and period.time != 0: + if (i_period == int(has_preequilibration)) and period.time != 0: # TODO: we could address that by offsetting all occurrences of # the SBML time in the model (except for the newly added # events triggers). Or we better just leave it to the @@ -179,18 +179,17 @@ def _convert_experiment(self, problem: Problem, experiment: Experiment): ) if period.time == -inf: - # steady-state pre-simulation cannot be represented in SBML, + # pre-equilibration cannot be represented in SBML, # so we need to keep this period in the Problem. kept_periods.append(period) - elif i_period == int(has_presimulation): - # we always keep the first non-presimulation period + elif i_period == int(has_preequilibration): + # we always keep the first non-pre-equilibration period # to set the indicator parameters kept_periods.append(period) elif not period.changes: # no condition, no changes, no need for an event, - # no need to keep the period unless it's the initial - # steady-state simulation or the only non-presimulation - # period (handled above) + # no need to keep the period unless it's the pre-equilibration + # or the only non-equilibration period (handled above) continue ev = self._create_period_begin_event( @@ -213,9 +212,9 @@ def _convert_experiment(self, problem: Problem, experiment: Experiment): for period in kept_periods: period.condition_ids = [ f"_petab_experiment_condition_{experiment.id}", - "_petab_steady_state_pre_simulation" + "_petab_preequilibration" if period.time == -inf - else "_petab_no_steady_state_pre_simulation", + else "_petab_no_preequilibration", ] experiment.periods = kept_periods @@ -242,23 +241,23 @@ def _create_period_begin_event( if period.time == -inf: trig_math = libsbml.parseL3Formula( - f"({exp_ind_id} == 1) && ({self._presim_indicator} == 1)" + f"({exp_ind_id} == 1) && ({self._preeq_indicator} == 1)" ) else: trig_math = libsbml.parseL3Formula( - f"({exp_ind_id} == 1) && ({self._presim_indicator} != 1) " + f"({exp_ind_id} == 1) && ({self._preeq_indicator} != 1) " f"&& (time >= {period.time})" ) check(trigger.setMath(trig_math)) return ev - def _add_presimulation_indicator( + def _add_preequilibration_indicator( self, ) -> None: - """Add an indicator parameter for the steady-state presimulation to - the SBML model.""" - par_id = self._presim_indicator + """Add an indicator parameter for the pre-equilibration to the SBML + model.""" + par_id = self._preeq_indicator if self._model.getElementBySId(par_id) is not None: raise ValueError( f"Entity with ID {par_id} already exists in the SBML model." @@ -325,24 +324,24 @@ def _change_to_event_assignment(change: Change, event: libsbml.Event): def _add_indicators_to_conditions(self, problem: Problem) -> None: """After converting the experiments to events, add the indicator - parameters for the presimulation period and for the different + parameters for the pre-equilibration period and for the different experiments to the remaining conditions. Then remove all other conditions.""" # create conditions for indicator parameters problem.condition_table.conditions.append( Condition( - id="_petab_steady_state_pre_simulation", + id="_petab_preequilibration", changes=[ - Change(target_id=self._presim_indicator, target_value=1) + Change(target_id=self._preeq_indicator, target_value=1) ], ) ) problem.condition_table.conditions.append( Condition( - id="_petab_no_steady_state_pre_simulation", + id="_petab_no_preequilibration", changes=[ - Change(target_id=self._presim_indicator, target_value=0) + Change(target_id=self._preeq_indicator, target_value=0) ], ) ) From e7c34f925df8ff1ba1a38b8dba6fba959e57ec82 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Thu, 10 Jul 2025 11:23:24 +0200 Subject: [PATCH 04/14] .. --- petab/v2/converters.py | 67 ++++++++++++++++++++++++------------------ 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/petab/v2/converters.py b/petab/v2/converters.py index 670e42f2..fd9d9b40 100644 --- a/petab/v2/converters.py +++ b/petab/v2/converters.py @@ -4,11 +4,11 @@ import warnings from copy import deepcopy -from math import inf import libsbml from sbmlmath import sbml_math_to_sympy, set_math +from .C import TIME_PREEQUILIBRATION from .core import Change, Condition, Experiment, ExperimentPeriod from .models._sbml_utils import add_sbml_parameter, check from .models.sbml_model import SbmlModel @@ -64,6 +64,12 @@ def __init__(self, problem: Problem): self._preprocess() + def _get_experiment_indicator_condition_id( + self, experiment_id: str + ) -> str: + """Get the condition ID for the experiment indicator parameter.""" + return f"_petab_experiment_condition_{experiment_id}" + def _preprocess(self): """Check whether we can handle the given problem and store some model information.""" @@ -74,7 +80,7 @@ def _preprocess(self): raise ValueError( "Cannot handle SBML models with SBML level < 3, " "because they do not support initial values for event " - "triggers and automatic upconversion failed." + "triggers and automatic upconversion of the model failed." ) # Collect event priorities @@ -139,23 +145,23 @@ def convert(self) -> Problem: self._add_preequilibration_indicator() - problem = self._new_problem - for experiment in problem.experiment_table.experiments: - self._convert_experiment(problem, experiment) + for experiment in self._new_problem.experiment_table.experiments: + self._convert_experiment(experiment) - self._add_indicators_to_conditions(problem) + self._add_indicators_to_conditions() - validation_results = problem.validate() + validation_results = self._new_problem.validate() validation_results.log() - return problem + return self._new_problem - def _convert_experiment(self, problem: Problem, experiment: Experiment): + def _convert_experiment(self, experiment: Experiment): """Convert a single experiment to SBML events.""" model = self._model experiment.sort_periods() has_preequilibration = ( - len(experiment.periods) and experiment.periods[0].time == -inf + len(experiment.periods) + and experiment.periods[0].time == TIME_PREEQUILIBRATION ) # add experiment indicator @@ -175,10 +181,12 @@ def _convert_experiment(self, problem: Problem, experiment: Experiment): # simulator -- we anyways keep the first period in the # returned Problem. raise NotImplementedError( - "Cannot represent non-zero initial time in SBML." + f"The initial simulation time for experiment " + f"`{experiment.id}` is nonzero: `{period.time}`. " + "This cannot be represented in SBML." ) - if period.time == -inf: + if period.time == TIME_PREEQUILIBRATION: # pre-equilibration cannot be represented in SBML, # so we need to keep this period in the Problem. kept_periods.append(period) @@ -192,7 +200,7 @@ def _convert_experiment(self, problem: Problem, experiment: Experiment): # or the only non-equilibration period (handled above) continue - ev = self._create_period_begin_event( + ev = self._create_period_start_event( experiment=experiment, i_period=i_period, period=period, @@ -200,7 +208,7 @@ def _convert_experiment(self, problem: Problem, experiment: Experiment): self._create_event_assignments_for_period( ev, [ - problem.condition_table[condition_id] + self._new_problem.condition_table[condition_id] for condition_id in period.condition_ids ], ) @@ -211,15 +219,15 @@ def _convert_experiment(self, problem: Problem, experiment: Experiment): # add conditions that set the indicator parameters for period in kept_periods: period.condition_ids = [ - f"_petab_experiment_condition_{experiment.id}", + self._get_experiment_indicator_condition_id(experiment.id), "_petab_preequilibration" - if period.time == -inf + if period.time == TIME_PREEQUILIBRATION else "_petab_no_preequilibration", ] experiment.periods = kept_periods - def _create_period_begin_event( + def _create_period_start_event( self, experiment: Experiment, i_period: int, period: ExperimentPeriod ) -> libsbml.Event: """Create an event that triggers at the beginning of a period.""" @@ -239,7 +247,7 @@ def _create_period_begin_event( exp_ind_id = self.get_experiment_indicator(experiment.id) - if period.time == -inf: + if period.time == TIME_PREEQUILIBRATION: trig_math = libsbml.parseL3Formula( f"({exp_ind_id} == 1) && ({self._preeq_indicator} == 1)" ) @@ -322,11 +330,12 @@ def _change_to_event_assignment(change: Change, event: libsbml.Event): sbml_model, id_=sym.name, constant=True, value=0 ) - def _add_indicators_to_conditions(self, problem: Problem) -> None: + def _add_indicators_to_conditions(self) -> None: """After converting the experiments to events, add the indicator parameters for the pre-equilibration period and for the different experiments to the remaining conditions. Then remove all other conditions.""" + problem = self._new_problem # create conditions for indicator parameters problem.condition_table.conditions.append( @@ -347,17 +356,19 @@ def _add_indicators_to_conditions(self, problem: Problem) -> None: ) # add conditions for the experiment indicators for experiment in problem.experiment_table.experiments: + cond_id = self._get_experiment_indicator_condition_id( + experiment.id + ) + changes = [ + Change( + target_id=self.get_experiment_indicator(experiment.id), + target_value=1, + ) + ] problem.condition_table.conditions.append( Condition( - id=f"_petab_experiment_condition_{experiment.id}", - changes=[ - Change( - target_id=self.get_experiment_indicator( - experiment.id - ), - target_value=1, - ) - ], + id=cond_id, + changes=changes, ) ) From c9dec6519dd9dc28c3f8f71cc719f7745c400f13 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Thu, 10 Jul 2025 11:40:08 +0200 Subject: [PATCH 05/14] .. --- petab/v2/converters.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/petab/v2/converters.py b/petab/v2/converters.py index fd9d9b40..becee667 100644 --- a/petab/v2/converters.py +++ b/petab/v2/converters.py @@ -41,6 +41,12 @@ class ExperimentsToEventsConverter: #: ID of the parameter that indicates whether the model is in # the pre-equilibration phase (1) or not (0). PREEQ_INDICATOR = "_petab_preequilibration_indicator" + #: The condition ID of the condition that sets the + #: pre-equilibration indicator to 1. + CONDITION_ID_PREEQ_ON = "_petab_preequilibration_on" + #: The condition ID of the condition that sets the + #: pre-equilibration indicator to 0. + CONDITION_ID_PREEQ_OFF = "_petab_preequilibration_off" def __init__(self, problem: Problem): """Initialize the converter. @@ -220,9 +226,9 @@ def _convert_experiment(self, experiment: Experiment): for period in kept_periods: period.condition_ids = [ self._get_experiment_indicator_condition_id(experiment.id), - "_petab_preequilibration" + self.CONDITION_ID_PREEQ_ON if period.time == TIME_PREEQUILIBRATION - else "_petab_no_preequilibration", + else self.CONDITION_ID_PREEQ_OFF, ] experiment.periods = kept_periods @@ -230,7 +236,7 @@ def _convert_experiment(self, experiment: Experiment): def _create_period_start_event( self, experiment: Experiment, i_period: int, period: ExperimentPeriod ) -> libsbml.Event: - """Create an event that triggers at the beginning of a period.""" + """Create an event that triggers at the start of a period.""" # TODO: for now, add separate events for each experiment x period, # this could be optimized to reuse events @@ -340,7 +346,7 @@ def _add_indicators_to_conditions(self) -> None: # create conditions for indicator parameters problem.condition_table.conditions.append( Condition( - id="_petab_preequilibration", + id=self.CONDITION_ID_PREEQ_ON, changes=[ Change(target_id=self._preeq_indicator, target_value=1) ], @@ -348,7 +354,7 @@ def _add_indicators_to_conditions(self) -> None: ) problem.condition_table.conditions.append( Condition( - id="_petab_no_preequilibration", + id=self.CONDITION_ID_PREEQ_OFF, changes=[ Change(target_id=self._preeq_indicator, target_value=0) ], From 86e67b608ec38e6b595e6666d58c2dc4476a375a Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Thu, 10 Jul 2025 11:45:08 +0200 Subject: [PATCH 06/14] Update petab/v2/core.py Co-authored-by: Dilan Pathirana <59329744+dilpath@users.noreply.github.com> --- petab/v2/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/petab/v2/core.py b/petab/v2/core.py index 19ba4aab..871aea78 100644 --- a/petab/v2/core.py +++ b/petab/v2/core.py @@ -555,8 +555,8 @@ def __iadd__(self, other: ExperimentPeriod) -> Experiment: self.periods.append(other) return self - def has_steady_state_presimulation(self) -> bool: - """Check if the experiment has a steady-state pre-simulation.""" + def has_preequilibration(self) -> bool: + """Check if the experiment has preequilibration enabled.""" return any(period.time == -np.inf for period in self.periods) def sort_periods(self) -> None: From 83b2f803075db32f5a5e71ff0dd5375e3a02c3ab Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Thu, 10 Jul 2025 13:43:23 +0200 Subject: [PATCH 07/14] .. --- petab/v2/converters.py | 22 +++++++++++----------- petab/v2/core.py | 8 +++++++- petab/v2/models/_sbml_utils.py | 5 ++--- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/petab/v2/converters.py b/petab/v2/converters.py index becee667..0dbe524e 100644 --- a/petab/v2/converters.py +++ b/petab/v2/converters.py @@ -8,7 +8,6 @@ import libsbml from sbmlmath import sbml_math_to_sympy, set_math -from .C import TIME_PREEQUILIBRATION from .core import Change, Condition, Experiment, ExperimentPeriod from .models._sbml_utils import add_sbml_parameter, check from .models.sbml_model import SbmlModel @@ -165,16 +164,16 @@ def _convert_experiment(self, experiment: Experiment): """Convert a single experiment to SBML events.""" model = self._model experiment.sort_periods() - has_preequilibration = ( - len(experiment.periods) - and experiment.periods[0].time == TIME_PREEQUILIBRATION - ) + has_preequilibration = experiment.has_preequilibration # add experiment indicator exp_ind_id = self.get_experiment_indicator(experiment.id) if model.getElementBySId(exp_ind_id) is not None: - raise AssertionError( - f"Entity with ID {exp_ind_id} exists already." + raise ValueError( + f"The model has entity with ID `{exp_ind_id}`. " + "IDs starting with `petab_` are reserved for " + f"{self.__class__.__name__} and should not be used in the " + "model." ) add_sbml_parameter(model, id_=exp_ind_id, constant=False, value=0) kept_periods = [] @@ -192,7 +191,7 @@ def _convert_experiment(self, experiment: Experiment): "This cannot be represented in SBML." ) - if period.time == TIME_PREEQUILIBRATION: + if period.is_preequilibration: # pre-equilibration cannot be represented in SBML, # so we need to keep this period in the Problem. kept_periods.append(period) @@ -227,7 +226,7 @@ def _convert_experiment(self, experiment: Experiment): period.condition_ids = [ self._get_experiment_indicator_condition_id(experiment.id), self.CONDITION_ID_PREEQ_ON - if period.time == TIME_PREEQUILIBRATION + if period.is_preequilibration else self.CONDITION_ID_PREEQ_OFF, ] @@ -253,7 +252,7 @@ def _create_period_start_event( exp_ind_id = self.get_experiment_indicator(experiment.id) - if period.time == TIME_PREEQUILIBRATION: + if period.is_preequilibration: trig_math = libsbml.parseL3Formula( f"({exp_ind_id} == 1) && ({self._preeq_indicator} == 1)" ) @@ -324,7 +323,8 @@ def _change_to_event_assignment(change: Change, event: libsbml.Event): sbml_model, id_=change.target_id, constant=False, value=0 ) else: - # TODO: can that break models?? + # We can safely change the `constant` attribute of the target. + # "Constant" does not imply "boundary condition" in SBML. target.setConstant(False) # the target value may depend on parameters that are only diff --git a/petab/v2/core.py b/petab/v2/core.py index 871aea78..193be335 100644 --- a/petab/v2/core.py +++ b/petab/v2/core.py @@ -521,6 +521,11 @@ def _validate_ids(cls, condition_ids): raise ValueError(f"Invalid {C.CONDITION_ID}: `{condition_id}'") return condition_ids + @property + def is_preequilibration(self) -> bool: + """Check if this period is a preequilibration period.""" + return self.time == C.TIME_PREEQUILIBRATION + class Experiment(BaseModel): """An experiment or a timecourse defined by an ID and a set of different @@ -555,9 +560,10 @@ def __iadd__(self, other: ExperimentPeriod) -> Experiment: self.periods.append(other) return self + @property def has_preequilibration(self) -> bool: """Check if the experiment has preequilibration enabled.""" - return any(period.time == -np.inf for period in self.periods) + return any(period.is_preequilibration for period in self.periods) def sort_periods(self) -> None: """Sort the periods of the experiment by time.""" diff --git a/petab/v2/models/_sbml_utils.py b/petab/v2/models/_sbml_utils.py index dda01ad2..0b651516 100644 --- a/petab/v2/models/_sbml_utils.py +++ b/petab/v2/models/_sbml_utils.py @@ -33,15 +33,14 @@ def check(res: int): def add_sbml_parameter( model: libsbml.Model, - id_: str = None, + id_: str, value: float = None, constant: bool = None, ) -> libsbml.Parameter: """Add a parameter to the SBML model.""" param = model.createParameter() - if id_ is not None: - param.setId(id_) + param.setId(id_) if value is not None: param.setValue(value) From 5ef79813ceb41587c787ec22fce0d4e7d89e90ae Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Thu, 10 Jul 2025 13:49:58 +0200 Subject: [PATCH 08/14] check --- petab/v2/models/_sbml_utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/petab/v2/models/_sbml_utils.py b/petab/v2/models/_sbml_utils.py index 0b651516..cbccde2b 100644 --- a/petab/v2/models/_sbml_utils.py +++ b/petab/v2/models/_sbml_utils.py @@ -40,12 +40,12 @@ def add_sbml_parameter( """Add a parameter to the SBML model.""" param = model.createParameter() - param.setId(id_) + check(param.setId(id_)) if value is not None: - param.setValue(value) + check(param.setValue(value)) if constant is not None: - param.setConstant(constant) + check(param.setConstant(constant)) return param From 1eafa48e66017799dbba55a945ac1d9359ec8bc7 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Thu, 10 Jul 2025 14:08:57 +0200 Subject: [PATCH 09/14] default_priority --- petab/v2/converters.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/petab/v2/converters.py b/petab/v2/converters.py index 0dbe524e..d8a42a14 100644 --- a/petab/v2/converters.py +++ b/petab/v2/converters.py @@ -47,11 +47,21 @@ class ExperimentsToEventsConverter: #: pre-equilibration indicator to 0. CONDITION_ID_PREEQ_OFF = "_petab_preequilibration_off" - def __init__(self, problem: Problem): + def __init__(self, problem: Problem, default_priority: float = None): """Initialize the converter. :param problem: The PEtab problem to convert. This will not be modified. + :param default_priority: The priority value to apply to any events that + preexist in the model and do not have a priority set. + + In SBML, for event assignments that are to be applied at the same + simulation time, the order of event execution is determined by the + priority of the respective events. + If no priority is set, the order is undefined. + See SBML specs for details. + To ensure that the PEtab condition-start-events are executed before + any other events, all events should have a priority set. """ if not isinstance(problem.model, SbmlModel): raise ValueError("Only SBML models are supported.") @@ -66,7 +76,7 @@ def __init__(self, problem: Problem): self._max_event_priority = None # The priority that will be used for the PEtab events. self._petab_event_priority = None - + self._default_priority = default_priority self._preprocess() def _get_experiment_indicator_condition_id( @@ -88,6 +98,18 @@ def _preprocess(self): "triggers and automatic upconversion of the model failed." ) + # Apply default priority to all events that do not have a priority + if self._default_priority is not None: + for event in model.getListOfEvents(): + if ( + not event.getPriority() + or event.getPriority().getMath() is None + ): + priority = event.createPriority() + priority.setMath( + libsbml.parseL3Formula(str(self._default_priority)) + ) + # Collect event priorities event_priorities = { ev.getId() or str(ev): sbml_math_to_sympy(ev.getPriority()) @@ -122,7 +144,9 @@ def _preprocess(self): f"Event `{event.getId()}` has no priority set. " "Make sure that this event cannot trigger at the time of " "a PEtab condition change, otherwise the behavior is " - "undefined.", + "undefined. To avoid this warning, see the " + "`default_priority` parameter of " + f"{self.__class__.__name__}.", stacklevel=1, ) From c7f46b291c96ed0daa2ec3da12df1014a7ef952d Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Fri, 18 Jul 2025 11:01:24 +0200 Subject: [PATCH 10/14] "simplify" triggers --- petab/v2/converters.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/petab/v2/converters.py b/petab/v2/converters.py index d8a42a14..0eb114d2 100644 --- a/petab/v2/converters.py +++ b/petab/v2/converters.py @@ -276,13 +276,19 @@ def _create_period_start_event( exp_ind_id = self.get_experiment_indicator(experiment.id) + # Create trigger expressions + # Since handling of == and !=, and distinguishing < and <= + # (and > and >=), is a bit tricky in terms of root-finding, + # we use these slightly more convoluted expressions. + # (assuming that the indicator parameters are {0, 1}) if period.is_preequilibration: trig_math = libsbml.parseL3Formula( - f"({exp_ind_id} == 1) && ({self._preeq_indicator} == 1)" + f"({exp_ind_id} > 0.5) && ({self._preeq_indicator} > 0.5)" ) else: trig_math = libsbml.parseL3Formula( - f"({exp_ind_id} == 1) && ({self._preeq_indicator} != 1) " + f"({exp_ind_id} > 0.5) " + f"&& ({self._preeq_indicator} < 0.5) " f"&& (time >= {period.time})" ) check(trigger.setMath(trig_math)) From 90e48dd6ca1773f478ea99ec74b7d6a8040662bd Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Fri, 18 Jul 2025 11:10:29 +0200 Subject: [PATCH 11/14] fix warnings --- petab/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/petab/__init__.py b/petab/__init__.py index dd30d186..031ca811 100644 --- a/petab/__init__.py +++ b/petab/__init__.py @@ -26,7 +26,7 @@ def __getattr__(name): return importlib.import_module("petab.v1") if name == "v2": return importlib.import_module("petab.v2") - if name != "__path__": + if name not in ("__path__", "__all__"): warn( f"Accessing `petab.{name}` is deprecated and will be removed in " f"the next major release. Please use `petab.v1.{name}` instead.", @@ -37,7 +37,7 @@ def __getattr__(name): def v1getattr(name, module): - if name != "__path__": + if name not in ("__path__", "__all__"): warn( f"Accessing `petab.{name}` is deprecated and will be removed in " f"the next major release. Please use `petab.v1.{name}` instead.", From 2ca8d130f6a44edc4663faf0c9e95d37f42a7c77 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Fri, 18 Jul 2025 11:11:12 +0200 Subject: [PATCH 12/14] start time --- petab/v2/converters.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/petab/v2/converters.py b/petab/v2/converters.py index 0eb114d2..903681c1 100644 --- a/petab/v2/converters.py +++ b/petab/v2/converters.py @@ -202,19 +202,6 @@ def _convert_experiment(self, experiment: Experiment): add_sbml_parameter(model, id_=exp_ind_id, constant=False, value=0) kept_periods = [] for i_period, period in enumerate(experiment.periods): - # check for non-zero initial times of the first period - if (i_period == int(has_preequilibration)) and period.time != 0: - # TODO: we could address that by offsetting all occurrences of - # the SBML time in the model (except for the newly added - # events triggers). Or we better just leave it to the - # simulator -- we anyways keep the first period in the - # returned Problem. - raise NotImplementedError( - f"The initial simulation time for experiment " - f"`{experiment.id}` is nonzero: `{period.time}`. " - "This cannot be represented in SBML." - ) - if period.is_preequilibration: # pre-equilibration cannot be represented in SBML, # so we need to keep this period in the Problem. From 799b906d3d1049dcf7a8a620be7ee59def302cd9 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Fri, 18 Jul 2025 14:09:30 +0200 Subject: [PATCH 13/14] fixup --- petab/v2/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/petab/v2/converters.py b/petab/v2/converters.py index 903681c1..4ee50691 100644 --- a/petab/v2/converters.py +++ b/petab/v2/converters.py @@ -210,7 +210,7 @@ def _convert_experiment(self, experiment: Experiment): # we always keep the first non-pre-equilibration period # to set the indicator parameters kept_periods.append(period) - elif not period.changes: + elif not period.condition_ids: # no condition, no changes, no need for an event, # no need to keep the period unless it's the pre-equilibration # or the only non-equilibration period (handled above) From 79ee8756c545d1b9c3c0c5d6a9d7617e170a14c6 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Sat, 19 Jul 2025 12:22:25 +0200 Subject: [PATCH 14/14] test --- petab/v2/__init__.py | 1 + petab/v2/converters.py | 12 ++++-- petab/v2/problem.py | 2 +- tests/v2/test_converters.py | 76 +++++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 tests/v2/test_converters.py diff --git a/petab/v2/__init__.py b/petab/v2/__init__.py index 4f8d28ea..68069010 100644 --- a/petab/v2/__init__.py +++ b/petab/v2/__init__.py @@ -37,6 +37,7 @@ models, # noqa: F401, E402 ) from .conditions import * # noqa: F403, F401, E402 +from .core import * # noqa: F401, E402 from .experiments import ( # noqa: F401, E402 get_experiment_df, write_experiment_df, diff --git a/petab/v2/converters.py b/petab/v2/converters.py index 4ee50691..f0736087 100644 --- a/petab/v2/converters.py +++ b/petab/v2/converters.py @@ -40,9 +40,11 @@ class ExperimentsToEventsConverter: #: ID of the parameter that indicates whether the model is in # the pre-equilibration phase (1) or not (0). PREEQ_INDICATOR = "_petab_preequilibration_indicator" + #: The condition ID of the condition that sets the #: pre-equilibration indicator to 1. CONDITION_ID_PREEQ_ON = "_petab_preequilibration_on" + #: The condition ID of the condition that sets the #: pre-equilibration indicator to 0. CONDITION_ID_PREEQ_OFF = "_petab_preequilibration_off" @@ -69,7 +71,7 @@ def __init__(self, problem: Problem, default_priority: float = None): self._original_problem = problem self._new_problem = deepcopy(self._original_problem) - self._model = self._new_problem.model.sbml_model + self._model: libsbml.Model = self._new_problem.model.sbml_model self._preeq_indicator = self.PREEQ_INDICATOR # The maximum event priority that was found in the unprocessed model. @@ -85,7 +87,7 @@ def _get_experiment_indicator_condition_id( """Get the condition ID for the experiment indicator parameter.""" return f"_petab_experiment_condition_{experiment_id}" - def _preprocess(self): + def _preprocess(self) -> None: """Check whether we can handle the given problem and store some model information.""" model = self._model @@ -184,7 +186,7 @@ def convert(self) -> Problem: return self._new_problem - def _convert_experiment(self, experiment: Experiment): + def _convert_experiment(self, experiment: Experiment) -> None: """Convert a single experiment to SBML events.""" model = self._model experiment.sort_periods() @@ -323,7 +325,9 @@ def _create_event_assignments_for_period( ) @staticmethod - def _change_to_event_assignment(change: Change, event: libsbml.Event): + def _change_to_event_assignment( + change: Change, event: libsbml.Event + ) -> None: """Convert a PEtab ``Change`` to an SBML event assignment.""" sbml_model = event.getModel() diff --git a/petab/v2/problem.py b/petab/v2/problem.py index a191942f..97684241 100644 --- a/petab/v2/problem.py +++ b/petab/v2/problem.py @@ -838,7 +838,7 @@ def validate( ) validation_results = ValidationResultList() - if self.config.extensions: + if self.config and self.config.extensions: extensions = ",".join(self.config.extensions.keys()) validation_results.append( ValidationIssue( diff --git a/tests/v2/test_converters.py b/tests/v2/test_converters.py new file mode 100644 index 00000000..76ba6a86 --- /dev/null +++ b/tests/v2/test_converters.py @@ -0,0 +1,76 @@ +from math import inf + +from petab.v2 import Change, Condition, Experiment, ExperimentPeriod, Problem +from petab.v2.converters import ExperimentsToEventsConverter +from petab.v2.models.sbml_model import SbmlModel + + +def test_experiments_to_events_converter(): + """Test the ExperimentsToEventsConverter.""" + ant_model = """ + species X = 0 + X' = 1 + """ + problem = Problem() + problem.model = SbmlModel.from_antimony(ant_model) + problem.add_condition("c1", X=1) + problem.add_condition("c2", X=2) + problem.add_experiment("e1", -inf, "c1", 10, "c2") + + converter = ExperimentsToEventsConverter(problem) + converted = converter.convert() + assert converted.validate().has_errors() is False + + assert isinstance(converted.model, SbmlModel) + sbml_model = converted.model.sbml_model + + assert sbml_model.getNumEvents() == 2 + assert converted.condition_table.conditions == [ + Condition( + id="_petab_preequilibration_on", + changes=[ + Change( + target_id="_petab_preequilibration_indicator", + target_value=1, + ) + ], + ), + Condition( + id="_petab_preequilibration_off", + changes=[ + Change( + target_id="_petab_preequilibration_indicator", + target_value=0, + ) + ], + ), + Condition( + id="_petab_experiment_condition_e1", + changes=[ + Change( + target_id="_petab_experiment_indicator_e1", target_value=1 + ) + ], + ), + ] + assert converted.experiment_table.experiments == [ + Experiment( + id="e1", + periods=[ + ExperimentPeriod( + time=-inf, + condition_ids=[ + "_petab_experiment_condition_e1", + "_petab_preequilibration_on", + ], + ), + ExperimentPeriod( + time=10.0, + condition_ids=[ + "_petab_experiment_condition_e1", + "_petab_preequilibration_off", + ], + ), + ], + ), + ]