@@ -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+
113122class 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.
0 commit comments