@@ -110,6 +110,15 @@ def _valid_petab_id(v: str) -> str:
110
110
return v
111
111
112
112
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
+
113
122
class ParameterScale (str , Enum ):
114
123
"""Parameter scales.
115
124
@@ -687,10 +696,18 @@ class Measurement(BaseModel):
687
696
experiment.
688
697
"""
689
698
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 )
690
703
#: 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
+ )
692
707
#: 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 )
694
711
#: The time point of the measurement in time units as defined in the model.
695
712
time : Annotated [float , AfterValidator (_is_finite_or_pos_inf )] = Field (
696
713
alias = C .TIME
@@ -728,17 +745,6 @@ def convert_nan_to_none(cls, v, info: ValidationInfo):
728
745
return cls .model_fields [info .field_name ].default
729
746
return v
730
747
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
-
742
748
@field_validator (
743
749
"observable_parameters" , "noise_parameters" , mode = "before"
744
750
)
@@ -775,6 +781,9 @@ def from_df(
775
781
if df is None :
776
782
return cls ()
777
783
784
+ if C .MODEL_ID in df .columns :
785
+ df [C .MODEL_ID ] = df [C .MODEL_ID ].apply (_convert_nan_to_none )
786
+
778
787
measurements = [
779
788
Measurement (
780
789
** row .to_dict (),
@@ -868,7 +877,9 @@ class Parameter(BaseModel):
868
877
"""Parameter definition."""
869
878
870
879
#: 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
+ )
872
883
#: Lower bound.
873
884
lb : Annotated [float | None , BeforeValidator (_convert_nan_to_none )] = Field (
874
885
alias = C .LOWER_BOUND , default = None
@@ -901,15 +912,6 @@ class Parameter(BaseModel):
901
912
validate_assignment = True ,
902
913
)
903
914
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
-
913
915
@field_validator ("prior_parameters" , mode = "before" )
914
916
@classmethod
915
917
def _validate_prior_parameters (
@@ -1067,20 +1069,20 @@ class Problem:
1067
1069
1068
1070
A PEtab parameter estimation problem as defined by
1069
1071
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
1077
1079
1078
1080
See also :doc:`petab:v2/documentation_data_format`.
1079
1081
"""
1080
1082
1081
1083
def __init__ (
1082
1084
self ,
1083
- model : Model = None ,
1085
+ models : list [ Model ] = None ,
1084
1086
condition_tables : list [ConditionTable ] = None ,
1085
1087
experiment_tables : list [ExperimentTable ] = None ,
1086
1088
observable_tables : list [ObservableTable ] = None ,
@@ -1092,7 +1094,7 @@ def __init__(
1092
1094
from ..v2 .lint import default_validation_tasks
1093
1095
1094
1096
self .config = config
1095
- self .model : Model | None = model
1097
+ self .models : list [ Model ] = models or []
1096
1098
self .validation_tasks : list [ValidationTask ] = (
1097
1099
default_validation_tasks .copy ()
1098
1100
)
@@ -1210,13 +1212,6 @@ def get_path(filename):
1210
1212
f"{ yaml_config [C .FORMAT_VERSION ]} ."
1211
1213
)
1212
1214
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
- )
1220
1215
config = ProblemConfig (
1221
1216
** yaml_config , base_path = base_path , filepath = yaml_file
1222
1217
)
@@ -1225,19 +1220,14 @@ def get_path(filename):
1225
1220
for f in config .parameter_files
1226
1221
]
1227
1222
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 (
1237
1225
get_path (model_info .location ),
1238
1226
model_info .language ,
1239
1227
model_id = model_id ,
1240
1228
)
1229
+ for model_id , model_info in (config .model_files or {}).items ()
1230
+ ]
1241
1231
1242
1232
measurement_tables = (
1243
1233
[
@@ -1283,7 +1273,7 @@ def get_path(filename):
1283
1273
1284
1274
return Problem (
1285
1275
config = config ,
1286
- model = model ,
1276
+ models = models ,
1287
1277
condition_tables = condition_tables ,
1288
1278
experiment_tables = experiment_tables ,
1289
1279
observable_tables = observable_tables ,
@@ -1316,6 +1306,7 @@ def from_dfs(
1316
1306
model: The underlying model
1317
1307
config: The PEtab problem configuration
1318
1308
"""
1309
+ # TODO: do we really need this?
1319
1310
1320
1311
observable_table = ObservableTable .from_df (observable_df )
1321
1312
condition_table = ConditionTable .from_df (condition_df )
@@ -1325,7 +1316,7 @@ def from_dfs(
1325
1316
parameter_table = ParameterTable .from_df (parameter_df )
1326
1317
1327
1318
return Problem (
1328
- model = model ,
1319
+ models = [ model ] ,
1329
1320
condition_tables = [condition_table ],
1330
1321
experiment_tables = [experiment_table ],
1331
1322
observable_tables = [observable_table ],
@@ -1391,6 +1382,39 @@ def get_problem(problem: str | Path | Problem) -> Problem:
1391
1382
"or a PEtab problem object."
1392
1383
)
1393
1384
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
+
1394
1418
@property
1395
1419
def condition_df (self ) -> pd .DataFrame | None :
1396
1420
"""Combined condition tables as DataFrame."""
@@ -1745,6 +1769,7 @@ def validate(
1745
1769
)
1746
1770
1747
1771
validation_results = ValidationResultList ()
1772
+
1748
1773
if self .config and self .config .extensions :
1749
1774
extensions = "," .join (self .config .extensions .keys ())
1750
1775
validation_results .append (
@@ -1756,6 +1781,19 @@ def validate(
1756
1781
)
1757
1782
)
1758
1783
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
+
1759
1797
for task in validation_tasks or self .validation_tasks :
1760
1798
try :
1761
1799
cur_result = task .run (self )
@@ -2043,7 +2081,7 @@ def model_dump(self, **kwargs) -> dict[str, Any]:
2043
2081
used for serialization. The output of this function may change
2044
2082
without notice.
2045
2083
2046
- The output includes all PEtab tables, but not the model itself .
2084
+ The output includes all PEtab tables, but not the models .
2047
2085
2048
2086
See `pydantic.BaseModel.model_dump <https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_dump>`__
2049
2087
for details.
0 commit comments