Skip to content

Commit 3156df2

Browse files
authored
v2: Basic support for multiple models (#418)
Related to #392. * Let v2.Problem have a list of models * Support constructing v2.Problem from files with multiple models * Move some validators to Annotated * Add some TODOs.
1 parent f636167 commit 3156df2

File tree

5 files changed

+115
-52
lines changed

5 files changed

+115
-52
lines changed

petab/v2/C.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@
3838
#: Replicate ID column in the measurement table
3939
REPLICATE_ID = "replicateId"
4040

41+
#: The model ID column in the measurement table
42+
MODEL_ID = "modelId"
43+
4144
#: Mandatory columns of measurement table
4245
MEASUREMENT_DF_REQUIRED_COLS = [
4346
OBSERVABLE_ID,
@@ -52,6 +55,7 @@
5255
NOISE_PARAMETERS,
5356
DATASET_ID,
5457
REPLICATE_ID,
58+
MODEL_ID,
5559
]
5660

5761
#: Measurement table columns

petab/v2/converters.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ def __init__(self, problem: Problem, default_priority: float = None):
7171
To ensure that the PEtab condition-start-events are executed before
7272
any other events, all events should have a priority set.
7373
"""
74+
if len(problem.models) > 1:
75+
# https://github.com/PEtab-dev/libpetab-python/issues/392
76+
raise NotImplementedError(
77+
"Only single-model PEtab problems are supported."
78+
)
7479
if not isinstance(problem.model, SbmlModel):
7580
raise ValueError("Only SBML models are supported.")
7681

petab/v2/core.py

Lines changed: 89 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,15 @@ def _valid_petab_id(v: str) -> str:
110110
return v
111111

112112

113+
def _valid_petab_id_or_none(v: str) -> str:
114+
"""Field validator for optional PEtab IDs."""
115+
if not v:
116+
return None
117+
if not is_valid_identifier(v):
118+
raise ValueError(f"Invalid ID: {v}")
119+
return v
120+
121+
113122
class ParameterScale(str, Enum):
114123
"""Parameter scales.
115124
@@ -687,10 +696,18 @@ class Measurement(BaseModel):
687696
experiment.
688697
"""
689698

699+
#: The model ID.
700+
model_id: Annotated[
701+
str | None, BeforeValidator(_valid_petab_id_or_none)
702+
] = Field(alias=C.MODEL_ID, default=None)
690703
#: The observable ID.
691-
observable_id: str = Field(alias=C.OBSERVABLE_ID)
704+
observable_id: Annotated[str, BeforeValidator(_valid_petab_id)] = Field(
705+
alias=C.OBSERVABLE_ID
706+
)
692707
#: The experiment ID.
693-
experiment_id: str | None = Field(alias=C.EXPERIMENT_ID, default=None)
708+
experiment_id: Annotated[
709+
str | None, BeforeValidator(_valid_petab_id_or_none)
710+
] = Field(alias=C.EXPERIMENT_ID, default=None)
694711
#: The time point of the measurement in time units as defined in the model.
695712
time: Annotated[float, AfterValidator(_is_finite_or_pos_inf)] = Field(
696713
alias=C.TIME
@@ -728,17 +745,6 @@ def convert_nan_to_none(cls, v, info: ValidationInfo):
728745
return cls.model_fields[info.field_name].default
729746
return v
730747

731-
@field_validator("observable_id", "experiment_id")
732-
@classmethod
733-
def _validate_id(cls, v, info: ValidationInfo):
734-
if not v:
735-
if info.field_name == "experiment_id":
736-
return None
737-
raise ValueError("ID must not be empty.")
738-
if not is_valid_identifier(v):
739-
raise ValueError(f"Invalid ID: {v}")
740-
return v
741-
742748
@field_validator(
743749
"observable_parameters", "noise_parameters", mode="before"
744750
)
@@ -775,6 +781,9 @@ def from_df(
775781
if df is None:
776782
return cls()
777783

784+
if C.MODEL_ID in df.columns:
785+
df[C.MODEL_ID] = df[C.MODEL_ID].apply(_convert_nan_to_none)
786+
778787
measurements = [
779788
Measurement(
780789
**row.to_dict(),
@@ -868,7 +877,9 @@ class Parameter(BaseModel):
868877
"""Parameter definition."""
869878

870879
#: Parameter ID.
871-
id: str = Field(alias=C.PARAMETER_ID)
880+
id: Annotated[str, BeforeValidator(_valid_petab_id)] = Field(
881+
alias=C.PARAMETER_ID
882+
)
872883
#: Lower bound.
873884
lb: Annotated[float | None, BeforeValidator(_convert_nan_to_none)] = Field(
874885
alias=C.LOWER_BOUND, default=None
@@ -901,15 +912,6 @@ class Parameter(BaseModel):
901912
validate_assignment=True,
902913
)
903914

904-
@field_validator("id")
905-
@classmethod
906-
def _validate_id(cls, v):
907-
if not v:
908-
raise ValueError("ID must not be empty.")
909-
if not is_valid_identifier(v):
910-
raise ValueError(f"Invalid ID: {v}")
911-
return v
912-
913915
@field_validator("prior_parameters", mode="before")
914916
@classmethod
915917
def _validate_prior_parameters(
@@ -1067,20 +1069,20 @@ class Problem:
10671069
10681070
A PEtab parameter estimation problem as defined by
10691071
1070-
- model
1071-
- condition table
1072-
- experiment table
1073-
- measurement table
1074-
- parameter table
1075-
- observable table
1076-
- mapping table
1072+
- models
1073+
- condition tables
1074+
- experiment tables
1075+
- measurement tables
1076+
- parameter tables
1077+
- observable tables
1078+
- mapping tables
10771079
10781080
See also :doc:`petab:v2/documentation_data_format`.
10791081
"""
10801082

10811083
def __init__(
10821084
self,
1083-
model: Model = None,
1085+
models: list[Model] = None,
10841086
condition_tables: list[ConditionTable] = None,
10851087
experiment_tables: list[ExperimentTable] = None,
10861088
observable_tables: list[ObservableTable] = None,
@@ -1092,7 +1094,7 @@ def __init__(
10921094
from ..v2.lint import default_validation_tasks
10931095

10941096
self.config = config
1095-
self.model: Model | None = model
1097+
self.models: list[Model] = models or []
10961098
self.validation_tasks: list[ValidationTask] = (
10971099
default_validation_tasks.copy()
10981100
)
@@ -1210,13 +1212,6 @@ def get_path(filename):
12101212
f"{yaml_config[C.FORMAT_VERSION]}."
12111213
)
12121214

1213-
if len(yaml_config[C.MODEL_FILES]) > 1:
1214-
raise ValueError(
1215-
"petab.v2.Problem.from_yaml() can only be used for "
1216-
"yaml files comprising a single model. "
1217-
"Consider using "
1218-
"petab.v2.CompositeProblem.from_yaml() instead."
1219-
)
12201215
config = ProblemConfig(
12211216
**yaml_config, base_path=base_path, filepath=yaml_file
12221217
)
@@ -1225,19 +1220,14 @@ def get_path(filename):
12251220
for f in config.parameter_files
12261221
]
12271222

1228-
if len(config.model_files or []) > 1:
1229-
# TODO https://github.com/PEtab-dev/libpetab-python/issues/6
1230-
raise NotImplementedError(
1231-
"Support for multiple models is not yet implemented."
1232-
)
1233-
model = None
1234-
if config.model_files:
1235-
model_id, model_info = next(iter(config.model_files.items()))
1236-
model = model_factory(
1223+
models = [
1224+
model_factory(
12371225
get_path(model_info.location),
12381226
model_info.language,
12391227
model_id=model_id,
12401228
)
1229+
for model_id, model_info in (config.model_files or {}).items()
1230+
]
12411231

12421232
measurement_tables = (
12431233
[
@@ -1283,7 +1273,7 @@ def get_path(filename):
12831273

12841274
return Problem(
12851275
config=config,
1286-
model=model,
1276+
models=models,
12871277
condition_tables=condition_tables,
12881278
experiment_tables=experiment_tables,
12891279
observable_tables=observable_tables,
@@ -1316,6 +1306,7 @@ def from_dfs(
13161306
model: The underlying model
13171307
config: The PEtab problem configuration
13181308
"""
1309+
# TODO: do we really need this?
13191310

13201311
observable_table = ObservableTable.from_df(observable_df)
13211312
condition_table = ConditionTable.from_df(condition_df)
@@ -1325,7 +1316,7 @@ def from_dfs(
13251316
parameter_table = ParameterTable.from_df(parameter_df)
13261317

13271318
return Problem(
1328-
model=model,
1319+
models=[model],
13291320
condition_tables=[condition_table],
13301321
experiment_tables=[experiment_table],
13311322
observable_tables=[observable_table],
@@ -1391,6 +1382,39 @@ def get_problem(problem: str | Path | Problem) -> Problem:
13911382
"or a PEtab problem object."
13921383
)
13931384

1385+
@property
1386+
def model(self) -> Model | None:
1387+
"""The model of the problem.
1388+
1389+
This is a convenience property for `Problem`s with only one single
1390+
model.
1391+
1392+
:return:
1393+
The model of the problem, or None if no model is defined.
1394+
:raises:
1395+
ValueError: If the problem has more than one model defined.
1396+
"""
1397+
if len(self.models) == 1:
1398+
return self.models[0]
1399+
1400+
if len(self.models) == 0:
1401+
return None
1402+
1403+
raise ValueError(
1404+
"Problem contains more than one model. "
1405+
"Use `Problem.models` to access all models."
1406+
)
1407+
1408+
@model.setter
1409+
def model(self, value: Model):
1410+
"""Set the model of the problem.
1411+
1412+
This is a convenience setter for `Problem`s with only one single
1413+
model. This will replace any existing models in the problem with the
1414+
provided model.
1415+
"""
1416+
self.models = [value]
1417+
13941418
@property
13951419
def condition_df(self) -> pd.DataFrame | None:
13961420
"""Combined condition tables as DataFrame."""
@@ -1745,6 +1769,7 @@ def validate(
17451769
)
17461770

17471771
validation_results = ValidationResultList()
1772+
17481773
if self.config and self.config.extensions:
17491774
extensions = ",".join(self.config.extensions.keys())
17501775
validation_results.append(
@@ -1756,6 +1781,19 @@ def validate(
17561781
)
17571782
)
17581783

1784+
if len(self.models) > 1:
1785+
# TODO https://github.com/PEtab-dev/libpetab-python/issues/392
1786+
# We might just want to split the problem into multiple
1787+
# problems, one for each model, and then validate each
1788+
# problem separately.
1789+
validation_results.append(
1790+
ValidationIssue(
1791+
ValidationIssueSeverity.WARNING,
1792+
"Problem contains multiple models. "
1793+
"Validation is not yet fully supported.",
1794+
)
1795+
)
1796+
17591797
for task in validation_tasks or self.validation_tasks:
17601798
try:
17611799
cur_result = task.run(self)
@@ -2043,7 +2081,7 @@ def model_dump(self, **kwargs) -> dict[str, Any]:
20432081
used for serialization. The output of this function may change
20442082
without notice.
20452083
2046-
The output includes all PEtab tables, but not the model itself.
2084+
The output includes all PEtab tables, but not the models.
20472085
20482086
See `pydantic.BaseModel.model_dump <https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_dump>`__
20492087
for details.

petab/v2/lint.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,10 @@ def run(self, problem: Problem) -> ValidationIssue | None:
769769
return None
770770

771771

772+
# TODO: check that Measurements model IDs match the available ones
773+
# https://github.com/PEtab-dev/libpetab-python/issues/392
774+
775+
772776
def get_valid_parameters_for_parameter_table(
773777
problem: Problem,
774778
) -> set[str]:

tests/v2/test_core.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
UPPER_BOUND,
2929
)
3030
from petab.v2.core import *
31+
from petab.v2.models.sbml_model import SbmlModel
3132
from petab.v2.petab1to2 import petab1to2
3233

3334
example_dir_fujita = Path(__file__).parents[2] / "doc/example/example_Fujita"
@@ -335,10 +336,16 @@ def test_problem_from_yaml_multiple_files():
335336
yaml_config = """
336337
format_version: 2.0.0
337338
parameter_files: []
339+
model_files:
340+
model1:
341+
location: model1.xml
342+
language: sbml
343+
model2:
344+
location: model2.xml
345+
language: sbml
338346
condition_files: [conditions1.tsv, conditions2.tsv]
339347
measurement_files: [measurements1.tsv, measurements2.tsv]
340348
observable_files: [observables1.tsv, observables2.tsv]
341-
model_files: {}
342349
experiment_files: [experiments1.tsv, experiments2.tsv]
343350
"""
344351
with tempfile.TemporaryDirectory() as tmpdir:
@@ -347,6 +354,10 @@ def test_problem_from_yaml_multiple_files():
347354
f.write(yaml_config)
348355

349356
for i in (1, 2):
357+
SbmlModel.from_antimony("a = 1;").to_file(
358+
Path(tmpdir, f"model{i}.xml")
359+
)
360+
350361
problem = Problem()
351362
problem.add_condition(f"condition{i}", parameter1=i)
352363
petab.write_condition_df(
@@ -375,6 +386,7 @@ def test_problem_from_yaml_multiple_files():
375386
petab_problem2 = petab.Problem.from_yaml(yaml_config, base_path=tmpdir)
376387

377388
for petab_problem in (petab_problem1, petab_problem2):
389+
assert len(petab_problem.models) == 2
378390
assert petab_problem.measurement_df.shape[0] == 2
379391
assert petab_problem.observable_df.shape[0] == 2
380392
assert petab_problem.condition_df.shape[0] == 2

0 commit comments

Comments
 (0)