Skip to content

Commit 96e40eb

Browse files
committed
v2: Basic support for multiple models
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 d140230 commit 96e40eb

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
@@ -691,10 +700,18 @@ class Measurement(BaseModel):
691700
experiment.
692701
"""
693702

703+
#: The model ID.
704+
model_id: Annotated[
705+
str | None, BeforeValidator(_valid_petab_id_or_none)
706+
] = Field(alias=C.MODEL_ID, default=None)
694707
#: The observable ID.
695-
observable_id: str = Field(alias=C.OBSERVABLE_ID)
708+
observable_id: Annotated[str, BeforeValidator(_valid_petab_id)] = Field(
709+
alias=C.OBSERVABLE_ID
710+
)
696711
#: The experiment ID.
697-
experiment_id: str | None = Field(alias=C.EXPERIMENT_ID, default=None)
712+
experiment_id: Annotated[
713+
str | None, BeforeValidator(_valid_petab_id_or_none)
714+
] = Field(alias=C.EXPERIMENT_ID, default=None)
698715
#: The time point of the measurement in time units as defined in the model.
699716
time: Annotated[float, AfterValidator(_is_finite_or_pos_inf)] = Field(
700717
alias=C.TIME
@@ -732,17 +749,6 @@ def convert_nan_to_none(cls, v, info: ValidationInfo):
732749
return cls.model_fields[info.field_name].default
733750
return v
734751

735-
@field_validator("observable_id", "experiment_id")
736-
@classmethod
737-
def _validate_id(cls, v, info: ValidationInfo):
738-
if not v:
739-
if info.field_name == "experiment_id":
740-
return None
741-
raise ValueError("ID must not be empty.")
742-
if not is_valid_identifier(v):
743-
raise ValueError(f"Invalid ID: {v}")
744-
return v
745-
746752
@field_validator(
747753
"observable_parameters", "noise_parameters", mode="before"
748754
)
@@ -777,6 +783,9 @@ def from_df(
777783
if df is None:
778784
return cls(measurements=[])
779785

786+
if C.MODEL_ID in df.columns:
787+
df[C.MODEL_ID] = df[C.MODEL_ID].apply(_convert_nan_to_none)
788+
780789
measurements = [
781790
Measurement(
782791
**row.to_dict(),
@@ -916,7 +925,9 @@ class Parameter(BaseModel):
916925
"""Parameter definition."""
917926

918927
#: Parameter ID.
919-
id: str = Field(alias=C.PARAMETER_ID)
928+
id: Annotated[str, BeforeValidator(_valid_petab_id)] = Field(
929+
alias=C.PARAMETER_ID
930+
)
920931
#: Lower bound.
921932
lb: Annotated[float | None, BeforeValidator(_convert_nan_to_none)] = Field(
922933
alias=C.LOWER_BOUND, default=None
@@ -949,15 +960,6 @@ class Parameter(BaseModel):
949960
validate_assignment=True,
950961
)
951962

952-
@field_validator("id")
953-
@classmethod
954-
def _validate_id(cls, v):
955-
if not v:
956-
raise ValueError("ID must not be empty.")
957-
if not is_valid_identifier(v):
958-
raise ValueError(f"Invalid ID: {v}")
959-
return v
960-
961963
@field_validator("prior_parameters", mode="before")
962964
@classmethod
963965
def _validate_prior_parameters(
@@ -1147,20 +1149,20 @@ class Problem:
11471149
11481150
A PEtab parameter estimation problem as defined by
11491151
1150-
- model
1151-
- condition table
1152-
- experiment table
1153-
- measurement table
1154-
- parameter table
1155-
- observable table
1156-
- mapping table
1152+
- models
1153+
- condition tables
1154+
- experiment tables
1155+
- measurement tables
1156+
- parameter tables
1157+
- observable tables
1158+
- mapping tables
11571159
11581160
See also :doc:`petab:v2/documentation_data_format`.
11591161
"""
11601162

11611163
def __init__(
11621164
self,
1163-
model: Model = None,
1165+
models: list[Model] = None,
11641166
condition_tables: list[ConditionTable] = None,
11651167
experiment_tables: list[ExperimentTable] = None,
11661168
observable_tables: list[ObservableTable] = None,
@@ -1172,7 +1174,7 @@ def __init__(
11721174
from ..v2.lint import default_validation_tasks
11731175

11741176
self.config = config
1175-
self.model: Model | None = model
1177+
self.models: list[Model] = models or []
11761178
self.validation_tasks: list[ValidationTask] = (
11771179
default_validation_tasks.copy()
11781180
)
@@ -1300,13 +1302,6 @@ def get_path(filename):
13001302
f"{yaml_config[C.FORMAT_VERSION]}."
13011303
)
13021304

1303-
if len(yaml_config[C.MODEL_FILES]) > 1:
1304-
raise ValueError(
1305-
"petab.v2.Problem.from_yaml() can only be used for "
1306-
"yaml files comprising a single model. "
1307-
"Consider using "
1308-
"petab.v2.CompositeProblem.from_yaml() instead."
1309-
)
13101305
config = ProblemConfig(
13111306
**yaml_config, base_path=base_path, filepath=yaml_file
13121307
)
@@ -1315,19 +1310,14 @@ def get_path(filename):
13151310
for f in config.parameter_files
13161311
]
13171312

1318-
if len(config.model_files or []) > 1:
1319-
# TODO https://github.com/PEtab-dev/libpetab-python/issues/6
1320-
raise NotImplementedError(
1321-
"Support for multiple models is not yet implemented."
1322-
)
1323-
model = None
1324-
if config.model_files:
1325-
model_id, model_info = next(iter(config.model_files.items()))
1326-
model = model_factory(
1313+
models = [
1314+
model_factory(
13271315
get_path(model_info.location),
13281316
model_info.language,
13291317
model_id=model_id,
13301318
)
1319+
for model_id, model_info in (config.model_files or {}).items()
1320+
]
13311321

13321322
measurement_tables = (
13331323
[
@@ -1373,7 +1363,7 @@ def get_path(filename):
13731363

13741364
return Problem(
13751365
config=config,
1376-
model=model,
1366+
models=models,
13771367
condition_tables=condition_tables,
13781368
experiment_tables=experiment_tables,
13791369
observable_tables=observable_tables,
@@ -1406,6 +1396,7 @@ def from_dfs(
14061396
model: The underlying model
14071397
config: The PEtab problem configuration
14081398
"""
1399+
# TODO: do we really need this?
14091400

14101401
observable_table = ObservableTable.from_df(observable_df)
14111402
condition_table = ConditionTable.from_df(condition_df)
@@ -1415,7 +1406,7 @@ def from_dfs(
14151406
parameter_table = ParameterTable.from_df(parameter_df)
14161407

14171408
return Problem(
1418-
model=model,
1409+
models=[model],
14191410
condition_tables=[condition_table],
14201411
experiment_tables=[experiment_table],
14211412
observable_tables=[observable_table],
@@ -1481,6 +1472,39 @@ def get_problem(problem: str | Path | Problem) -> Problem:
14811472
"or a PEtab problem object."
14821473
)
14831474

1475+
@property
1476+
def model(self) -> Model | None:
1477+
"""The model of the problem.
1478+
1479+
This is a convenience property for `Problem`s with only one single
1480+
model.
1481+
1482+
:return:
1483+
The model of the problem, or None if no model is defined.
1484+
:raises:
1485+
ValueError: If the problem has more than one model defined.
1486+
"""
1487+
if len(self.models) == 1:
1488+
return self.models[0]
1489+
1490+
if len(self.models) == 0:
1491+
return None
1492+
1493+
raise ValueError(
1494+
"Problem contains more than one model. "
1495+
"Use `Problem.models` to access all models."
1496+
)
1497+
1498+
@model.setter
1499+
def model(self, value: Model):
1500+
"""Set the model of the problem.
1501+
1502+
This is a convenience setter for `Problem`s with only one single
1503+
model. This will replace any existing models in the problem with the
1504+
provided model.
1505+
"""
1506+
self.models = [value]
1507+
14841508
@property
14851509
def condition_df(self) -> pd.DataFrame | None:
14861510
"""Combined condition tables as DataFrame."""
@@ -1854,6 +1878,7 @@ def validate(
18541878
)
18551879

18561880
validation_results = ValidationResultList()
1881+
18571882
if self.config and self.config.extensions:
18581883
extensions = ",".join(self.config.extensions.keys())
18591884
validation_results.append(
@@ -1865,6 +1890,19 @@ def validate(
18651890
)
18661891
)
18671892

1893+
if len(self.models) > 1:
1894+
# TODO https://github.com/PEtab-dev/libpetab-python/issues/392
1895+
# We might just want to split the problem into multiple
1896+
# problems, one for each model, and then validate each
1897+
# problem separately.
1898+
validation_results.append(
1899+
ValidationIssue(
1900+
ValidationIssueSeverity.WARNING,
1901+
"Problem contains multiple models. "
1902+
"Validation is not yet fully supported.",
1903+
)
1904+
)
1905+
18681906
for task in validation_tasks or self.validation_tasks:
18691907
try:
18701908
cur_result = task.run(self)
@@ -2154,7 +2192,7 @@ def model_dump(self, **kwargs) -> dict[str, Any]:
21542192
used for serialization. The output of this function may change
21552193
without notice.
21562194
2157-
The output includes all PEtab tables, but not the model itself.
2195+
The output includes all PEtab tables, but not the models.
21582196
21592197
See `pydantic.BaseModel.model_dump <https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_dump>`__
21602198
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)