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