Skip to content
Open
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
50 changes: 24 additions & 26 deletions petab/v2/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
BeforeValidator,
ConfigDict,
Field,
SerializeAsAny,
ValidationInfo,
field_serializer,
field_validator,
Expand All @@ -53,6 +54,7 @@
from ..v1.yaml import get_path_prefix
from ..versions import parse_version
from . import C, get_observable_df
from .extensions import ExtensionConfig

if TYPE_CHECKING:
from ..v2.lint import ValidationResultList, ValidationTask
Expand Down Expand Up @@ -2492,13 +2494,6 @@ class ModelFile(BaseModel):
)


class ExtensionConfig(BaseModel):
"""The configuration of a PEtab extension."""

version: str
config: dict


class ProblemConfig(BaseModel):
"""The PEtab problem configuration."""

Expand Down Expand Up @@ -2541,8 +2536,8 @@ class ProblemConfig(BaseModel):
# Absolute or relative to `base_path`.
mapping_files: list[AnyUrl | Path] = []

#: Extensions used by the problem.
extensions: list[ExtensionConfig] | dict = {}
#: Extensions used by the problem, keyed by extension ID.
extensions: dict[str, SerializeAsAny[ExtensionConfig]] = {}

model_config = ConfigDict(
validate_assignment=True,
Expand All @@ -2553,23 +2548,26 @@ class ProblemConfig(BaseModel):
def _parse_extensions(cls, v):
"""Parse extensions dict and convert known extensions to their specific
config classes."""
if isinstance(v, dict):
parsed_extensions = {}
for ext_name, ext_config in v.items():
if ext_name == C.EXT_ID_SCIML:
parsed_extensions[ext_name] = (
ext_config
if isinstance(ext_config, SciMLConfig)
else SciMLConfig(**ext_config)
)
else:
parsed_extensions[ext_name] = (
ext_config
if isinstance(ext_config, ExtensionConfig)
else ExtensionConfig(**ext_config)
)
return parsed_extensions
return v
if not isinstance(v, dict):
raise ValueError(
"extensions must be a dict of extension ID to extension "
f"config, got {type(v)}."
)
parsed_extensions = {}
for ext_name, ext_config in v.items():
if ext_name == C.EXT_ID_SCIML:
parsed_extensions[ext_name] = (
ext_config
if isinstance(ext_config, SciMLConfig)
else SciMLConfig(**ext_config)
)
else:
parsed_extensions[ext_name] = (
ext_config
if isinstance(ext_config, ExtensionConfig)
else ExtensionConfig(**ext_config)
)
return parsed_extensions

# convert parameter_file to list
@field_validator(
Expand Down
15 changes: 15 additions & 0 deletions petab/v2/extensions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from pydantic import BaseModel, ConfigDict

__all__ = ["ExtensionConfig"]


class ExtensionConfig(BaseModel):
"""The configuration of a PEtab extension."""

#: The extension's semantic version.
version: str
#: Whether the extension is required for the mathematical
#: interpretation of the problem.
required: bool

model_config = ConfigDict(extra="allow", validate_assignment=True)
9 changes: 7 additions & 2 deletions petab/v2/extensions/sciml.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
pass

from .. import C
from . import ExtensionConfig

__all__ = [
"Hybridization",
Expand Down Expand Up @@ -136,11 +137,15 @@ class NeuralNetConfig(BaseModel):
)


class SciMLConfig(BaseModel):
class SciMLConfig(ExtensionConfig):
"""The extended configuration of a PEtab SciML problem."""

#: The PEtab SciML format version.
version: str = "0.1.0"
#: Whether the extension is required for the mathematical
#: interpretation of the problem. Defaults to ``True`` since a SciML
#: problem's hybrid ODE/ML model is virtually always load-bearing.
required: bool = True
#: The paths to the array data files.
array_files: list[AnyUrl | Path] = []
#: The paths to the hybridization tables.
Expand All @@ -155,7 +160,7 @@ class SciMLConfig(BaseModel):

def to_yaml(self) -> dict:
"""Return a YAML-serializable dict with Paths converted to strings."""
from . import C
from .. import C

d = self.model_dump(by_alias=True)
for key in ("array_files", "hybridization_files"):
Expand Down
79 changes: 79 additions & 0 deletions tests/v2/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
UPPER_BOUND,
)
from petab.v2.core import *
from petab.v2.core import ExtensionConfig
from petab.v2.lint import ValidationIssueSeverity
from petab.v2.models.sbml_model import SbmlModel
from petab.v2.petab1to2 import petab1to2

Expand Down Expand Up @@ -595,6 +597,83 @@ def test_problem_config_paths():
# see also https://github.com/pydantic/pydantic/issues/8575


def test_problem_config_generic_extension():
"""A generic (non-sciml) extension is parsed per the PEtab v2 schema:
`version` and `required` at the top level, plus arbitrary
extension-specific keys alongside them."""
pc = ProblemConfig(
parameter_files=["parameters.tsv"],
measurement_files=["measurements.tsv"],
observable_files=["observables.tsv"],
extensions={
"my_ext": {
"version": "1.0.0",
"required": False,
"some_key": "some_value",
}
},
)
ext = pc.extensions["my_ext"]
assert isinstance(ext, ExtensionConfig)
assert ext.version == "1.0.0"
assert ext.required is False
assert ext.some_key == "some_value"

dumped = pc.model_dump(by_alias=True)["extensions"]["my_ext"]
assert dumped == {
"version": "1.0.0",
"required": False,
"some_key": "some_value",
}


def test_problem_config_extensions_rejects_non_dict():
"""`extensions` must be a dict keyed by extension ID (see #474) -- a
list is not a valid PEtab v2 problem configuration."""
with pytest.raises(ValidationError):
ProblemConfig(
parameter_files=["parameters.tsv"],
measurement_files=["measurements.tsv"],
observable_files=["observables.tsv"],
extensions=[{"version": "1.0.0", "required": False}],
)


def test_validate_unsupported_extension_severity():
"""libpetab-python doesn't mathematically interpret extensions, so an
unsupported extension only ever produces a WARNING (that the problem
can't be fully linted) -- regardless of `required`. Rejecting a problem
that uses an unsupported `required` extension is up to the consumer
(e.g. a simulator) that actually interprets it."""
problem = Problem()
problem.model = SbmlModel.from_antimony("""
model m
species A;
A = 1;
k1 = 1;
R1: A -> ; k1 * A;
end
""")
problem.add_observable("obs_A", "A", noise_formula="1")
problem.add_parameter(
"k1", estimate=True, lb=1e-5, ub=1e5, nominal_value=1
)
problem.add_measurement("obs_A", time=1, measurement=1, experiment_id="")
assert problem.validate() == []

for required in (False, True):
problem.config = ProblemConfig(
extensions={"my_ext": {"version": "1.0.0", "required": required}}
)
results = problem.validate()
assert not results.has_errors()
assert any(
r.level == ValidationIssueSeverity.WARNING
and "my_ext" in r.message
for r in results
)


def test_get_changes_for_period():
"""Test getting changes for a specific period."""
problem = Problem()
Expand Down
20 changes: 20 additions & 0 deletions tests/v2/test_sciml.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,26 @@ def test_lint():
assert problem.validate() == []


def test_sciml_config_yaml_round_trip(tmp_path):
"""The `sciml` extension config, once written to YAML via
`ProblemConfig.to_yaml()`, is schema-valid and can be read back.
"""
from petab.v1.yaml import load_yaml, validate_yaml_syntax

problem = _get_test_problem()
yaml_path = tmp_path / "problem.yaml"
problem.config.to_yaml(yaml_path)

yaml_config = load_yaml(yaml_path)
validate_yaml_syntax(yaml_config)
assert yaml_config["extensions"]["sciml"]["required"] is True

reloaded_config = ProblemConfig(**yaml_config, base_path=tmp_path)
sciml_config = reloaded_config.extensions["sciml"]
assert isinstance(sciml_config, SciMLConfig)
assert sciml_config.required is True


def test_lint_equinox_network_format():
"""Linter accepts non-YAML formats without reading the network file."""
problem = _get_test_problem()
Expand Down