Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions petab/v2/C.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
#: Replicate ID column in the measurement table
REPLICATE_ID = "replicateId"

#: The model ID column in the measurement table
MODEL_ID = "modelId"

#: Mandatory columns of measurement table
MEASUREMENT_DF_REQUIRED_COLS = [
OBSERVABLE_ID,
Expand All @@ -52,6 +55,7 @@
NOISE_PARAMETERS,
DATASET_ID,
REPLICATE_ID,
MODEL_ID,
]

#: Measurement table columns
Expand Down
5 changes: 5 additions & 0 deletions petab/v2/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ def __init__(self, problem: Problem, default_priority: float = None):
To ensure that the PEtab condition-start-events are executed before
any other events, all events should have a priority set.
"""
if len(problem.models) > 1:
# https://github.com/PEtab-dev/libpetab-python/issues/392
raise NotImplementedError(
"Only single-model PEtab problems are supported."
)
if not isinstance(problem.model, SbmlModel):
raise ValueError("Only SBML models are supported.")

Expand Down
140 changes: 89 additions & 51 deletions petab/v2/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ def _valid_petab_id(v: str) -> str:
return v


def _valid_petab_id_or_none(v: str) -> str:
"""Field validator for optional PEtab IDs."""
if not v:
return None
if not is_valid_identifier(v):
raise ValueError(f"Invalid ID: {v}")
return v


class ParameterScale(str, Enum):
"""Parameter scales.

Expand Down Expand Up @@ -687,10 +696,18 @@ class Measurement(BaseModel):
experiment.
"""

#: The model ID.
model_id: Annotated[
str | None, BeforeValidator(_valid_petab_id_or_none)
] = Field(alias=C.MODEL_ID, default=None)
#: The observable ID.
observable_id: str = Field(alias=C.OBSERVABLE_ID)
observable_id: Annotated[str, BeforeValidator(_valid_petab_id)] = Field(
alias=C.OBSERVABLE_ID
)
#: The experiment ID.
experiment_id: str | None = Field(alias=C.EXPERIMENT_ID, default=None)
experiment_id: Annotated[
str | None, BeforeValidator(_valid_petab_id_or_none)
] = Field(alias=C.EXPERIMENT_ID, default=None)
#: The time point of the measurement in time units as defined in the model.
time: Annotated[float, AfterValidator(_is_finite_or_pos_inf)] = Field(
alias=C.TIME
Expand Down Expand Up @@ -728,17 +745,6 @@ def convert_nan_to_none(cls, v, info: ValidationInfo):
return cls.model_fields[info.field_name].default
return v

@field_validator("observable_id", "experiment_id")
@classmethod
def _validate_id(cls, v, info: ValidationInfo):
if not v:
if info.field_name == "experiment_id":
return None
raise ValueError("ID must not be empty.")
if not is_valid_identifier(v):
raise ValueError(f"Invalid ID: {v}")
return v

@field_validator(
"observable_parameters", "noise_parameters", mode="before"
)
Expand Down Expand Up @@ -775,6 +781,9 @@ def from_df(
if df is None:
return cls()

if C.MODEL_ID in df.columns:
df[C.MODEL_ID] = df[C.MODEL_ID].apply(_convert_nan_to_none)

measurements = [
Measurement(
**row.to_dict(),
Expand Down Expand Up @@ -868,7 +877,9 @@ class Parameter(BaseModel):
"""Parameter definition."""

#: Parameter ID.
id: str = Field(alias=C.PARAMETER_ID)
id: Annotated[str, BeforeValidator(_valid_petab_id)] = Field(
alias=C.PARAMETER_ID
)
#: Lower bound.
lb: Annotated[float | None, BeforeValidator(_convert_nan_to_none)] = Field(
alias=C.LOWER_BOUND, default=None
Expand Down Expand Up @@ -901,15 +912,6 @@ class Parameter(BaseModel):
validate_assignment=True,
)

@field_validator("id")
@classmethod
def _validate_id(cls, v):
if not v:
raise ValueError("ID must not be empty.")
if not is_valid_identifier(v):
raise ValueError(f"Invalid ID: {v}")
return v

@field_validator("prior_parameters", mode="before")
@classmethod
def _validate_prior_parameters(
Expand Down Expand Up @@ -1067,20 +1069,20 @@ class Problem:

A PEtab parameter estimation problem as defined by

- model
- condition table
- experiment table
- measurement table
- parameter table
- observable table
- mapping table
- models
- condition tables
- experiment tables
- measurement tables
- parameter tables
- observable tables
- mapping tables

See also :doc:`petab:v2/documentation_data_format`.
"""

def __init__(
self,
model: Model = None,
models: list[Model] = None,
condition_tables: list[ConditionTable] = None,
experiment_tables: list[ExperimentTable] = None,
observable_tables: list[ObservableTable] = None,
Expand All @@ -1092,7 +1094,7 @@ def __init__(
from ..v2.lint import default_validation_tasks

self.config = config
self.model: Model | None = model
self.models: list[Model] = models or []
self.validation_tasks: list[ValidationTask] = (
default_validation_tasks.copy()
)
Expand Down Expand Up @@ -1210,13 +1212,6 @@ def get_path(filename):
f"{yaml_config[C.FORMAT_VERSION]}."
)

if len(yaml_config[C.MODEL_FILES]) > 1:
raise ValueError(
"petab.v2.Problem.from_yaml() can only be used for "
"yaml files comprising a single model. "
"Consider using "
"petab.v2.CompositeProblem.from_yaml() instead."
)
config = ProblemConfig(
**yaml_config, base_path=base_path, filepath=yaml_file
)
Expand All @@ -1225,19 +1220,14 @@ def get_path(filename):
for f in config.parameter_files
]

if len(config.model_files or []) > 1:
# TODO https://github.com/PEtab-dev/libpetab-python/issues/6
raise NotImplementedError(
"Support for multiple models is not yet implemented."
)
model = None
if config.model_files:
model_id, model_info = next(iter(config.model_files.items()))
model = model_factory(
models = [
model_factory(
get_path(model_info.location),
model_info.language,
model_id=model_id,
)
for model_id, model_info in (config.model_files or {}).items()
]

measurement_tables = (
[
Expand Down Expand Up @@ -1283,7 +1273,7 @@ def get_path(filename):

return Problem(
config=config,
model=model,
models=models,
condition_tables=condition_tables,
experiment_tables=experiment_tables,
observable_tables=observable_tables,
Expand Down Expand Up @@ -1316,6 +1306,7 @@ def from_dfs(
model: The underlying model
config: The PEtab problem configuration
"""
# TODO: do we really need this?

observable_table = ObservableTable.from_df(observable_df)
condition_table = ConditionTable.from_df(condition_df)
Expand All @@ -1325,7 +1316,7 @@ def from_dfs(
parameter_table = ParameterTable.from_df(parameter_df)

return Problem(
model=model,
models=[model],
condition_tables=[condition_table],
experiment_tables=[experiment_table],
observable_tables=[observable_table],
Expand Down Expand Up @@ -1391,6 +1382,39 @@ def get_problem(problem: str | Path | Problem) -> Problem:
"or a PEtab problem object."
)

@property
def model(self) -> Model | None:
"""The model of the problem.

This is a convenience property for `Problem`s with only one single
model.

:return:
The model of the problem, or None if no model is defined.
:raises:
ValueError: If the problem has more than one model defined.
"""
if len(self.models) == 1:
return self.models[0]

if len(self.models) == 0:
return None

raise ValueError(
"Problem contains more than one model. "
"Use `Problem.models` to access all models."
)

@model.setter
def model(self, value: Model):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps change the name to make it clear that this modifies the Problem, e.g. set_model?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

model is a property. This function here is usually invoked via problem.model = some_model, it cannot be invoked as problem.model(some_model). I think the assignment should make it clear that this modifies the object.

"""Set the model of the problem.

This is a convenience setter for `Problem`s with only one single
model. This will replace any existing models in the problem with the
provided model.
"""
self.models = [value]

@property
def condition_df(self) -> pd.DataFrame | None:
"""Combined condition tables as DataFrame."""
Expand Down Expand Up @@ -1745,6 +1769,7 @@ def validate(
)

validation_results = ValidationResultList()

if self.config and self.config.extensions:
extensions = ",".join(self.config.extensions.keys())
validation_results.append(
Expand All @@ -1756,6 +1781,19 @@ def validate(
)
)

if len(self.models) > 1:
# TODO https://github.com/PEtab-dev/libpetab-python/issues/392
# We might just want to split the problem into multiple
# problems, one for each model, and then validate each
# problem separately.
validation_results.append(
ValidationIssue(
ValidationIssueSeverity.WARNING,
"Problem contains multiple models. "
"Validation is not yet fully supported.",
)
)

for task in validation_tasks or self.validation_tasks:
try:
cur_result = task.run(self)
Expand Down Expand Up @@ -2043,7 +2081,7 @@ def model_dump(self, **kwargs) -> dict[str, Any]:
used for serialization. The output of this function may change
without notice.

The output includes all PEtab tables, but not the model itself.
The output includes all PEtab tables, but not the models.

See `pydantic.BaseModel.model_dump <https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_dump>`__
for details.
Expand Down
4 changes: 4 additions & 0 deletions petab/v2/lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,10 @@ def run(self, problem: Problem) -> ValidationIssue | None:
return None


# TODO: check that Measurements model IDs match the available ones
# https://github.com/PEtab-dev/libpetab-python/issues/392


def get_valid_parameters_for_parameter_table(
problem: Problem,
) -> set[str]:
Expand Down
14 changes: 13 additions & 1 deletion tests/v2/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
UPPER_BOUND,
)
from petab.v2.core import *
from petab.v2.models.sbml_model import SbmlModel
from petab.v2.petab1to2 import petab1to2

example_dir_fujita = Path(__file__).parents[2] / "doc/example/example_Fujita"
Expand Down Expand Up @@ -335,10 +336,16 @@ def test_problem_from_yaml_multiple_files():
yaml_config = """
format_version: 2.0.0
parameter_files: []
model_files:
model1:
location: model1.xml
language: sbml
model2:
location: model2.xml
language: sbml
condition_files: [conditions1.tsv, conditions2.tsv]
measurement_files: [measurements1.tsv, measurements2.tsv]
observable_files: [observables1.tsv, observables2.tsv]
model_files: {}
experiment_files: [experiments1.tsv, experiments2.tsv]
"""
with tempfile.TemporaryDirectory() as tmpdir:
Expand All @@ -347,6 +354,10 @@ def test_problem_from_yaml_multiple_files():
f.write(yaml_config)

for i in (1, 2):
SbmlModel.from_antimony("a = 1;").to_file(
Path(tmpdir, f"model{i}.xml")
)

problem = Problem()
problem.add_condition(f"condition{i}", parameter1=i)
petab.write_condition_df(
Expand Down Expand Up @@ -375,6 +386,7 @@ def test_problem_from_yaml_multiple_files():
petab_problem2 = petab.Problem.from_yaml(yaml_config, base_path=tmpdir)

for petab_problem in (petab_problem1, petab_problem2):
assert len(petab_problem.models) == 2
assert petab_problem.measurement_df.shape[0] == 2
assert petab_problem.observable_df.shape[0] == 2
assert petab_problem.condition_df.shape[0] == 2
Expand Down
Loading