From 3c4f7ff4fa87075ed5a08bd04e0b0a54a6e7da02 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Mon, 16 Dec 2024 16:57:23 +0100 Subject: [PATCH 01/12] observables --- petab/v2/core.py | 109 ++++++++++++++++++++++++++++++++++++++++++ petab/v2/problem.py | 6 +++ tests/v2/test_core.py | 20 ++++++++ 3 files changed, 135 insertions(+) create mode 100644 petab/v2/core.py create mode 100644 tests/v2/test_core.py diff --git a/petab/v2/core.py b/petab/v2/core.py new file mode 100644 index 00000000..cdf22f21 --- /dev/null +++ b/petab/v2/core.py @@ -0,0 +1,109 @@ +"""Types around the PEtab object model.""" +from __future__ import annotations + +from enum import Enum +from pathlib import Path + +import numpy as np +import pandas as pd +import sympy as sp +from pydantic import ( + BaseModel, + Field, + ValidationInfo, + field_validator, +) + +from ..v1.lint import is_valid_identifier +from ..v1.math import sympify_petab +from . import C + + +class ObservableTransformation(str, Enum): + LIN = C.LIN + LOG = C.LOG + LOG10 = C.LOG10 + + +class NoiseDistribution(str, Enum): + NORMAL = C.NORMAL + LAPLACE = C.LAPLACE + + +class Observable(BaseModel): + id: str = Field(alias=C.OBSERVABLE_ID) + name: str | None = Field(alias=C.OBSERVABLE_NAME, default=None) + formula: sp.Basic | None = Field(alias=C.OBSERVABLE_FORMULA, default=None) + transformation: ObservableTransformation = Field( + alias=C.OBSERVABLE_TRANSFORMATION, default=ObservableTransformation.LIN + ) + noise_formula: sp.Basic | None = Field(alias=C.NOISE_FORMULA, default=None) + noise_distribution: NoiseDistribution = Field( + alias=C.NOISE_DISTRIBUTION, default=NoiseDistribution.NORMAL + ) + + @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( + "name", + "formula", + "noise_formula", + "noise_formula", + "noise_distribution", + "transformation", + mode="before", + ) + @classmethod + def convert_nan_to_none(cls, v, info: ValidationInfo): + if isinstance(v, float) and np.isnan(v): + return cls.model_fields[info.field_name].default + return v + + @field_validator("formula", "noise_formula", mode="before") + @classmethod + def sympify(cls, v): + if v is None or isinstance(v, sp.Basic): + return v + if isinstance(v, float) and np.isnan(v): + return None + + return sympify_petab(v) + + class Config: + populate_by_name = True + arbitrary_types_allowed = True + + +class ObservablesTable(BaseModel): + observables: list[Observable] + + @classmethod + def from_dataframe(cls, df: pd.DataFrame) -> ObservablesTable: + if df is None: + return cls(observables=[]) + + observables = [ + Observable(**row.to_dict()) + for _, row in df.reset_index().iterrows() + ] + + return cls(observables=observables) + + def to_dataframe(self) -> pd.DataFrame: + return pd.DataFrame(self.model_dump()["observables"]) + + @classmethod + def from_tsv(cls, file_path: str | Path) -> ObservablesTable: + df = pd.read_csv(file_path, sep="\t") + return cls.from_dataframe(df) + + def to_tsv(self, file_path: str | Path) -> None: + df = self.to_dataframe() + df.to_csv(file_path, sep="\t", index=False) diff --git a/petab/v2/problem.py b/petab/v2/problem.py index 32684d0b..662edde4 100644 --- a/petab/v2/problem.py +++ b/petab/v2/problem.py @@ -93,6 +93,12 @@ def __init__( ) self.config = config + from .core import Observable, ObservablesTable + + self.observables: list[Observable] = ObservablesTable.from_dataframe( + self.observable_df + ) + def __str__(self): model = f"with model ({self.model})" if self.model else "without model" diff --git a/tests/v2/test_core.py b/tests/v2/test_core.py new file mode 100644 index 00000000..f5987f16 --- /dev/null +++ b/tests/v2/test_core.py @@ -0,0 +1,20 @@ +import tempfile +from pathlib import Path + +from petab.v2.core import ObservablesTable + + +def test_observables_table(): + file = ( + Path(__file__).parents[2] + / "doc/example/example_Fujita/Fujita_observables.tsv" + ) + + # read-write-read round trip + observables = ObservablesTable.from_tsv(file) + + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_file = Path(tmp_dir) / "observables.tsv" + observables.to_tsv(tmp_file) + observables2 = ObservablesTable.from_tsv(tmp_file) + assert observables == observables2 From 3d61574d3d0e49857ac492e7233134fe1bddfd67 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Mon, 16 Dec 2024 21:26:48 +0100 Subject: [PATCH 02/12] conditions --- petab/v2/core.py | 180 ++++++++++++++++++++++++++++++++++++++++++ petab/v2/petab1to2.py | 2 +- petab/v2/problem.py | 17 +++- tests/v2/test_core.py | 22 ++++-- 4 files changed, 212 insertions(+), 9 deletions(-) diff --git a/petab/v2/core.py b/petab/v2/core.py index cdf22f21..2839e643 100644 --- a/petab/v2/core.py +++ b/petab/v2/core.py @@ -20,17 +20,29 @@ class ObservableTransformation(str, Enum): + """Observable transformation types. + + Observable transformations as used in the PEtab observables table. + """ + LIN = C.LIN LOG = C.LOG LOG10 = C.LOG10 class NoiseDistribution(str, Enum): + """Noise distribution types. + + Noise distributions as used in the PEtab observables table. + """ + NORMAL = C.NORMAL LAPLACE = C.LAPLACE class Observable(BaseModel): + """Observable definition.""" + id: str = Field(alias=C.OBSERVABLE_ID) name: str | None = Field(alias=C.OBSERVABLE_NAME, default=None) formula: sp.Basic | None = Field(alias=C.OBSERVABLE_FORMULA, default=None) @@ -82,6 +94,8 @@ class Config: class ObservablesTable(BaseModel): + """PEtab observables table.""" + observables: list[Observable] @classmethod @@ -107,3 +121,169 @@ def from_tsv(cls, file_path: str | Path) -> ObservablesTable: def to_tsv(self, file_path: str | Path) -> None: df = self.to_dataframe() df.to_csv(file_path, sep="\t", index=False) + + +class OperationType(str, Enum): + """Operation types for model changes in the PEtab conditions table.""" + + # TODO update names + SET_CURRENT_VALUE = "setCurrentValue" + SET_RATE = "setRate" + SET_ASSIGNMENT = "setAssignment" + CONSTANT = "constant" + INITIAL = "initial" + ... + + +class Change(BaseModel): + """A change to the model or model state. + + A change to the model or model state, corresponding to an individual + row of the PEtab conditions table. + """ + + target_id: str = Field(alias=C.TARGET_ID) + operation_type: OperationType = Field(alias=C.VALUE_TYPE) + target_value: sp.Basic = Field(alias=C.TARGET_VALUE) + + class Config: + populate_by_name = True + arbitrary_types_allowed = True + use_enum_values = True + + @field_validator("target_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("target_value", mode="before") + @classmethod + def sympify(cls, v): + if v is None or isinstance(v, sp.Basic): + return v + if isinstance(v, float) and np.isnan(v): + return None + + return sympify_petab(v) + + +class ChangeSet(BaseModel): + """A set of changes to the model or model state. + + A set of simultaneously occuring changes to the model or model state, + corresponding to a perturbation of the underlying system. This corresponds + to all rows of the PEtab conditions table with the same condition ID. + """ + + id: str = Field(alias=C.CONDITION_ID) + changes: list[Change] + + class Config: + populate_by_name = 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 + + +class ConditionsTable(BaseModel): + """PEtab conditions table.""" + + conditions: list[ChangeSet] + + @classmethod + def from_dataframe(cls, df: pd.DataFrame) -> ConditionsTable: + if df is None: + return cls(conditions=[]) + + conditions = [] + for condition_id, sub_df in df.groupby(C.CONDITION_ID): + changes = [Change(**row.to_dict()) for _, row in sub_df.iterrows()] + conditions.append(ChangeSet(id=condition_id, changes=changes)) + + return cls(conditions=conditions) + + def to_dataframe(self) -> pd.DataFrame: + records = [ + {C.CONDITION_ID: condition.id, **change.model_dump()} + for condition in self.conditions + for change in condition.changes + ] + return pd.DataFrame(records) + + @classmethod + def from_tsv(cls, file_path: str | Path) -> ConditionsTable: + df = pd.read_csv(file_path, sep="\t") + return cls.from_dataframe(df) + + def to_tsv(self, file_path: str | Path) -> None: + df = self.to_dataframe() + df.to_csv(file_path, sep="\t", index=False) + + +class ExperimentPeriod(BaseModel): + """A period of a timecourse defined by a start time and a set changes. + + This corresponds to a row of the PEtab experiments table. + """ + + start: float = Field(alias=C.TIME) + conditions: list[ChangeSet] + + class Config: + populate_by_name = True + + +class Experiment(BaseModel): + """An experiment or a timecourse defined by an ID and a set of different + periods. + + Corresponds to a group of rows of the PEtab experiments table with the same + experiment ID. + """ + + id: str = Field(alias=C.EXPERIMENT_ID) + periods: list[ExperimentPeriod] + + class Config: + populate_by_name = True + arbitrary_types_allowed = True + + +class ExperimentsTable(BaseModel): + """PEtab experiments table.""" + + experiments: list[Experiment] + + @classmethod + def from_dataframe(cls, df: pd.DataFrame) -> ExperimentsTable: + if df is None: + return cls(experiments=[]) + + experiments = [ + Experiment(**row.to_dict()) + for _, row in df.reset_index().iterrows() + ] + + return cls(experiments=experiments) + + def to_dataframe(self) -> pd.DataFrame: + return pd.DataFrame(self.model_dump()["experiments"]) + + @classmethod + def from_tsv(cls, file_path: str | Path) -> ExperimentsTable: + df = pd.read_csv(file_path, sep="\t") + return cls.from_dataframe(df) + + def to_tsv(self, file_path: str | Path) -> None: + df = self.to_dataframe() + df.to_csv(file_path, sep="\t", index=False) diff --git a/petab/v2/petab1to2.py b/petab/v2/petab1to2.py index dc1b2b8c..c163a246 100644 --- a/petab/v2/petab1to2.py +++ b/petab/v2/petab1to2.py @@ -293,7 +293,7 @@ def v1v2_condition_df( id_vars=[v1.C.CONDITION_ID], var_name=v2.C.TARGET_ID, value_name=v2.C.TARGET_VALUE, - ) + ).dropna(subset=[v2.C.TARGET_VALUE]) if condition_df.empty: # This happens if there weren't any condition-specific changes diff --git a/petab/v2/problem.py b/petab/v2/problem.py index 662edde4..7c0793c3 100644 --- a/petab/v2/problem.py +++ b/petab/v2/problem.py @@ -93,11 +93,22 @@ def __init__( ) self.config = config - from .core import Observable, ObservablesTable + from .core import ( + ChangeSet, + ConditionsTable, + Observable, + ObservablesTable, + ) + + self.observables_table: ObservablesTable = ( + ObservablesTable.from_dataframe(self.observable_df) + ) + self.observables: list[Observable] = self.observables_table.observables - self.observables: list[Observable] = ObservablesTable.from_dataframe( - self.observable_df + self.conditions_table: ConditionsTable = ( + ConditionsTable.from_dataframe(self.condition_df) ) + self.conditions: list[ChangeSet] = self.conditions_table.conditions def __str__(self): model = f"with model ({self.model})" if self.model else "without model" diff --git a/tests/v2/test_core.py b/tests/v2/test_core.py index f5987f16..76933a1c 100644 --- a/tests/v2/test_core.py +++ b/tests/v2/test_core.py @@ -1,14 +1,14 @@ import tempfile from pathlib import Path -from petab.v2.core import ObservablesTable +from petab.v2.core import ConditionsTable, ObservablesTable +from petab.v2.petab1to2 import petab1to2 + +example_dir_fujita = Path(__file__).parents[2] / "doc/example/example_Fujita" def test_observables_table(): - file = ( - Path(__file__).parents[2] - / "doc/example/example_Fujita/Fujita_observables.tsv" - ) + file = example_dir_fujita / "Fujita_observables.tsv" # read-write-read round trip observables = ObservablesTable.from_tsv(file) @@ -18,3 +18,15 @@ def test_observables_table(): observables.to_tsv(tmp_file) observables2 = ObservablesTable.from_tsv(tmp_file) assert observables == observables2 + + +def test_conditions_table(): + with tempfile.TemporaryDirectory() as tmp_dir: + petab1to2(example_dir_fujita / "Fujita.yaml", tmp_dir) + file = Path(tmp_dir, "Fujita_experimentalCondition.tsv") + # read-write-read round trip + conditions = ConditionsTable.from_tsv(file) + tmp_file = Path(tmp_dir) / "conditions.tsv" + conditions.to_tsv(tmp_file) + conditions2 = ConditionsTable.from_tsv(tmp_file) + assert conditions == conditions2 From 5c5aa5ca6a8233e24287b0aa9b76df5fb3f9e812 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Thu, 19 Dec 2024 14:39:03 +0100 Subject: [PATCH 03/12] getitem --- petab/v2/core.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/petab/v2/core.py b/petab/v2/core.py index 2839e643..fbdb3fcd 100644 --- a/petab/v2/core.py +++ b/petab/v2/core.py @@ -98,6 +98,13 @@ class ObservablesTable(BaseModel): observables: list[Observable] + def __getitem__(self, observable_id: str) -> Observable: + """Get an observable by ID.""" + for observable in self.observables: + if observable.id == observable_id: + return observable + raise KeyError(f"Observable ID {observable_id} not found") + @classmethod def from_dataframe(cls, df: pd.DataFrame) -> ObservablesTable: if df is None: @@ -200,6 +207,13 @@ class ConditionsTable(BaseModel): conditions: list[ChangeSet] + def __getitem__(self, condition_id: str) -> ChangeSet: + """Get a condition by ID.""" + for condition in self.conditions: + if condition.id == condition_id: + return condition + raise KeyError(f"Condition ID {condition_id} not found") + @classmethod def from_dataframe(cls, df: pd.DataFrame) -> ConditionsTable: if df is None: From 70f283ed89f5f5be13fc10599e89c87579522012 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Thu, 19 Dec 2024 22:51:30 +0100 Subject: [PATCH 04/12] Measurements --- petab/v2/core.py | 120 ++++++++++++++++++++++++++++++++++++++++++-- petab/v2/problem.py | 20 ++++++++ 2 files changed, 135 insertions(+), 5 deletions(-) diff --git a/petab/v2/core.py b/petab/v2/core.py index fbdb3fcd..a8297324 100644 --- a/petab/v2/core.py +++ b/petab/v2/core.py @@ -279,14 +279,27 @@ class ExperimentsTable(BaseModel): experiments: list[Experiment] @classmethod - def from_dataframe(cls, df: pd.DataFrame) -> ExperimentsTable: + def from_dataframe( + cls, df: pd.DataFrame, conditions: ConditionsTable = None + ) -> ExperimentsTable: if df is None: return cls(experiments=[]) - experiments = [ - Experiment(**row.to_dict()) - for _, row in df.reset_index().iterrows() - ] + if conditions is None: + conditions = {} + + experiments = [] + for experiment_id, cur_exp_df in df.groupby(C.EXPERIMENT_ID): + periods = [] + for time, cur_period_df in cur_exp_df.groupby(C.TIME): + period_conditions = [ + conditions[row[C.CONDITION_ID]] + for _, row in cur_period_df.iterrows() + ] + periods.append( + ExperimentPeriod(start=time, conditions=period_conditions) + ) + experiments.append(Experiment(id=experiment_id, periods=periods)) return cls(experiments=experiments) @@ -301,3 +314,100 @@ def from_tsv(cls, file_path: str | Path) -> ExperimentsTable: def to_tsv(self, file_path: str | Path) -> None: df = self.to_dataframe() df.to_csv(file_path, sep="\t", index=False) + + +class Measurement(BaseModel): + """A measurement. + + A measurement of an observable at a specific time point in a specific + experiment. + """ + + # TODO: ID vs object + observable_id: str = Field(alias=C.OBSERVABLE_ID) + experiment_id: str | None = Field(alias=C.EXPERIMENT_ID, default=None) + time: float = Field(alias=C.TIME) + measurement: float = Field(alias=C.MEASUREMENT) + observable_parameters: list[sp.Basic] = Field( + alias=C.OBSERVABLE_PARAMETERS, default_factory=list + ) + noise_parameters: list[sp.Basic] = Field( + alias=C.NOISE_PARAMETERS, default_factory=list + ) + + class Config: + populate_by_name = True + arbitrary_types_allowed = True + + @field_validator( + "experiment_id", + "observable_parameters", + "noise_parameters", + mode="before", + ) + @classmethod + def convert_nan_to_none(cls, v, info: ValidationInfo): + if isinstance(v, float) and np.isnan(v): + 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" + ) + @classmethod + def sympify_list(cls, v): + if isinstance(v, float) and np.isnan(v): + return [] + if isinstance(v, str): + v = v.split(C.PARAMETER_SEPARATOR) + else: + v = [v] + return [sympify_petab(x) for x in v] + + +class MeasurementTable(BaseModel): + """PEtab measurement table.""" + + measurements: list[Measurement] + + @classmethod + def from_dataframe( + cls, + df: pd.DataFrame, + observables_table: ObservablesTable, + experiments_table: ExperimentsTable, + ) -> MeasurementTable: + if df is None: + return cls(measurements=[]) + + measurements = [ + Measurement( + **row.to_dict(), + ) + for _, row in df.reset_index().iterrows() + ] + + return cls(measurements=measurements) + + def to_dataframe(self) -> pd.DataFrame: + return pd.DataFrame(self.model_dump()["measurements"]) + + @classmethod + def from_tsv(cls, file_path: str | Path) -> MeasurementTable: + df = pd.read_csv(file_path, sep="\t") + return cls.from_dataframe(df) + + def to_tsv(self, file_path: str | Path) -> None: + df = self.to_dataframe() + df.to_csv(file_path, sep="\t", index=False) diff --git a/petab/v2/problem.py b/petab/v2/problem.py index 7c0793c3..b88b7b39 100644 --- a/petab/v2/problem.py +++ b/petab/v2/problem.py @@ -96,6 +96,9 @@ def __init__( from .core import ( ChangeSet, ConditionsTable, + Experiment, + ExperimentsTable, + MeasurementTable, Observable, ObservablesTable, ) @@ -110,6 +113,23 @@ def __init__( ) self.conditions: list[ChangeSet] = self.conditions_table.conditions + self.experiments_table: ExperimentsTable = ( + ExperimentsTable.from_dataframe( + self.experiment_df, self.conditions_table + ) + ) + self.experiments: list[Experiment] = self.experiments_table.experiments + + self.measurement_table: MeasurementTable = ( + MeasurementTable.from_dataframe( + self.measurement_df, + observables_table=self.observables_table, + experiments_table=self.experiments_table, + ) + ) + + # TODO: measurements, parameters, visualization, mapping + def __str__(self): model = f"with model ({self.model})" if self.model else "without model" From e2a98d32fcd446d895c42aa5829ca7663f56c5f4 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Thu, 19 Dec 2024 23:03:34 +0100 Subject: [PATCH 05/12] mapping,parameters --- petab/v2/core.py | 126 ++++++++++++++++++++++++++++++++++++++++++++ petab/v2/problem.py | 10 +++- 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/petab/v2/core.py b/petab/v2/core.py index a8297324..93dda5e9 100644 --- a/petab/v2/core.py +++ b/petab/v2/core.py @@ -30,6 +30,17 @@ class ObservableTransformation(str, Enum): LOG10 = C.LOG10 +class ParameterScale(str, Enum): + """Parameter scales. + + Parameter scales as used in the PEtab parameters table. + """ + + LIN = C.LIN + LOG = C.LOG + LOG10 = C.LOG10 + + class NoiseDistribution(str, Enum): """Noise distribution types. @@ -411,3 +422,118 @@ def from_tsv(cls, file_path: str | Path) -> MeasurementTable: def to_tsv(self, file_path: str | Path) -> None: df = self.to_dataframe() df.to_csv(file_path, sep="\t", index=False) + + +class Mapping(BaseModel): + """Mapping PEtab entities to model entities.""" + + petab_id: str = Field(alias=C.PETAB_ENTITY_ID) + model_id: str = Field(alias=C.MODEL_ENTITY_ID) + + class Config: + populate_by_name = True + + @field_validator( + "petab_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 + + +class MappingTable(BaseModel): + """PEtab mapping table.""" + + mappings: list[Mapping] + + @classmethod + def from_dataframe(cls, df: pd.DataFrame) -> MappingTable: + if df is None: + return cls(mappings=[]) + + mappings = [ + Mapping(**row.to_dict()) for _, row in df.reset_index().iterrows() + ] + + return cls(mappings=mappings) + + def to_dataframe(self) -> pd.DataFrame: + return pd.DataFrame(self.model_dump()["mappings"]) + + @classmethod + def from_tsv(cls, file_path: str | Path) -> MappingTable: + df = pd.read_csv(file_path, sep="\t") + return cls.from_dataframe(df) + + def to_tsv(self, file_path: str | Path) -> None: + df = self.to_dataframe() + df.to_csv(file_path, sep="\t", index=False) + + +class Parameter(BaseModel): + """Parameter definition.""" + + id: str = Field(alias=C.PARAMETER_ID) + lb: float | None = Field(alias=C.LOWER_BOUND, default=None) + ub: float | None = Field(alias=C.UPPER_BOUND, default=None) + nominal_value: float | None = Field(alias=C.NOMINAL_VALUE, default=None) + scale: ParameterScale = Field( + alias=C.PARAMETER_SCALE, default=ParameterScale.LIN + ) + estimate: bool = Field(alias=C.ESTIMATE, default=True) + # TODO priors + + class Config: + populate_by_name = True + arbitrary_types_allowed = True + use_enum_values = 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("lb", "ub", "nominal_value") + @classmethod + def convert_nan_to_none(cls, v): + if isinstance(v, float) and np.isnan(v): + return None + return v + + +class ParameterTable(BaseModel): + """PEtab parameter table.""" + + parameters: list[Parameter] + + @classmethod + def from_dataframe(cls, df: pd.DataFrame) -> ParameterTable: + if df is None: + return cls(parameters=[]) + + parameters = [ + Parameter(**row.to_dict()) + for _, row in df.reset_index().iterrows() + ] + + return cls(parameters=parameters) + + def to_dataframe(self) -> pd.DataFrame: + return pd.DataFrame(self.model_dump()["parameters"]) + + @classmethod + def from_tsv(cls, file_path: str | Path) -> ParameterTable: + df = pd.read_csv(file_path, sep="\t") + return cls.from_dataframe(df) + + def to_tsv(self, file_path: str | Path) -> None: + df = self.to_dataframe() + df.to_csv(file_path, sep="\t", index=False) diff --git a/petab/v2/problem.py b/petab/v2/problem.py index b88b7b39..6fff34ce 100644 --- a/petab/v2/problem.py +++ b/petab/v2/problem.py @@ -98,9 +98,11 @@ def __init__( ConditionsTable, Experiment, ExperimentsTable, + MappingTable, MeasurementTable, Observable, ObservablesTable, + ParameterTable, ) self.observables_table: ObservablesTable = ( @@ -128,7 +130,13 @@ def __init__( ) ) - # TODO: measurements, parameters, visualization, mapping + self.mapping_table: MappingTable = MappingTable.from_dataframe( + self.mapping_df + ) + self.parameter_table: ParameterTable = ParameterTable.from_dataframe( + self.parameter_df + ) + # TODO: visualization table def __str__(self): model = f"with model ({self.model})" if self.model else "without model" From b1ed7941209f14d426bed85faf8e834fdcbc972b Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Fri, 20 Dec 2024 16:54:03 +0100 Subject: [PATCH 06/12] store ids --- petab/v2/core.py | 104 +++++++++++++++++++++++++++++++++++++------- petab/v2/problem.py | 6 +-- 2 files changed, 89 insertions(+), 21 deletions(-) diff --git a/petab/v2/core.py b/petab/v2/core.py index 93dda5e9..a11aa20c 100644 --- a/petab/v2/core.py +++ b/petab/v2/core.py @@ -18,6 +18,27 @@ from ..v1.math import sympify_petab from . import C +__all__ = [ + "Observable", + "ObservablesTable", + "ObservableTransformation", + "NoiseDistribution", + "Change", + "ChangeSet", + "ConditionsTable", + "OperationType", + "ExperimentPeriod", + "Experiment", + "ExperimentsTable", + "Measurement", + "MeasurementTable", + "Mapping", + "MappingTable", + "Parameter", + "ParameterScale", + "ParameterTable", +] + class ObservableTransformation(str, Enum): """Observable transformation types. @@ -51,6 +72,44 @@ class NoiseDistribution(str, Enum): LAPLACE = C.LAPLACE +class ObjectivePriorType(str, Enum): + """Objective prior types. + + Objective prior types as used in the PEtab parameters table. + """ + + NORMAL = C.NORMAL + LAPLACE = C.LAPLACE + UNIFORM = C.UNIFORM + LOG_NORMAL = C.LOG_NORMAL + LOG_LAPLACE = C.LOG_LAPLACE + PARAMETER_SCALE_NORMAL = C.PARAMETER_SCALE_NORMAL + PARAMETER_SCALE_LAPLACE = C.PARAMETER_SCALE_LAPLACE + PARAMETER_SCALE_UNIFORM = C.PARAMETER_SCALE_UNIFORM + + +assert set(C.PRIOR_TYPES) == {e.value for e in ObjectivePriorType}, ( + "ObjectivePriorType enum does not match C.PRIOR_TYPES: " + f"{set(C.PRIOR_TYPES)} vs { {e.value for e in ObjectivePriorType} }" +) + + +class InitializationPriorType(str, Enum): + """Initialization prior types. + + Initialization prior types as used in the PEtab parameters table. + """ + + NORMAL = C.NORMAL + LAPLACE = C.LAPLACE + UNIFORM = C.UNIFORM + LOG_NORMAL = C.LOG_NORMAL + LOG_LAPLACE = C.LOG_LAPLACE + PARAMETER_SCALE_NORMAL = C.PARAMETER_SCALE_NORMAL + PARAMETER_SCALE_LAPLACE = C.PARAMETER_SCALE_LAPLACE + PARAMETER_SCALE_UNIFORM = C.PARAMETER_SCALE_UNIFORM + + class Observable(BaseModel): """Observable definition.""" @@ -148,6 +207,9 @@ class OperationType(str, Enum): SET_CURRENT_VALUE = "setCurrentValue" SET_RATE = "setRate" SET_ASSIGNMENT = "setAssignment" + ADD_TO_RATE = "addToRate" + ADD_TO_ASSIGNMENT = "addToAssignment" + NO_CHANGE = "noChange" CONSTANT = "constant" INITIAL = "initial" ... @@ -192,7 +254,7 @@ def sympify(cls, v): class ChangeSet(BaseModel): """A set of changes to the model or model state. - A set of simultaneously occuring changes to the model or model state, + A set of simultaneously occurring changes to the model or model state, corresponding to a perturbation of the underlying system. This corresponds to all rows of the PEtab conditions table with the same condition ID. """ @@ -262,11 +324,21 @@ class ExperimentPeriod(BaseModel): """ start: float = Field(alias=C.TIME) - conditions: list[ChangeSet] + condition_ids: list[str] = Field(alias=C.CONDITION_ID) class Config: populate_by_name = True + @field_validator("condition_ids") + @classmethod + def validate_id(cls, v): + for condition_id in v: + if not condition_id: + raise ValueError("ID must not be empty.") + if not is_valid_identifier(condition_id): + raise ValueError(f"Invalid ID: {condition_id}") + return v + class Experiment(BaseModel): """An experiment or a timecourse defined by an ID and a set of different @@ -283,6 +355,15 @@ class Config: populate_by_name = True arbitrary_types_allowed = 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 + class ExperimentsTable(BaseModel): """PEtab experiments table.""" @@ -290,25 +371,19 @@ class ExperimentsTable(BaseModel): experiments: list[Experiment] @classmethod - def from_dataframe( - cls, df: pd.DataFrame, conditions: ConditionsTable = None - ) -> ExperimentsTable: + def from_dataframe(cls, df: pd.DataFrame) -> ExperimentsTable: if df is None: return cls(experiments=[]) - if conditions is None: - conditions = {} - experiments = [] for experiment_id, cur_exp_df in df.groupby(C.EXPERIMENT_ID): periods = [] for time, cur_period_df in cur_exp_df.groupby(C.TIME): - period_conditions = [ - conditions[row[C.CONDITION_ID]] - for _, row in cur_period_df.iterrows() - ] + period_conditions = list(cur_period_df[C.CONDITION_ID]) periods.append( - ExperimentPeriod(start=time, conditions=period_conditions) + ExperimentPeriod( + start=time, condition_ids=period_conditions + ) ) experiments.append(Experiment(id=experiment_id, periods=periods)) @@ -334,7 +409,6 @@ class Measurement(BaseModel): experiment. """ - # TODO: ID vs object observable_id: str = Field(alias=C.OBSERVABLE_ID) experiment_id: str | None = Field(alias=C.EXPERIMENT_ID, default=None) time: float = Field(alias=C.TIME) @@ -396,8 +470,6 @@ class MeasurementTable(BaseModel): def from_dataframe( cls, df: pd.DataFrame, - observables_table: ObservablesTable, - experiments_table: ExperimentsTable, ) -> MeasurementTable: if df is None: return cls(measurements=[]) diff --git a/petab/v2/problem.py b/petab/v2/problem.py index 6fff34ce..38c1ae6d 100644 --- a/petab/v2/problem.py +++ b/petab/v2/problem.py @@ -116,17 +116,13 @@ def __init__( self.conditions: list[ChangeSet] = self.conditions_table.conditions self.experiments_table: ExperimentsTable = ( - ExperimentsTable.from_dataframe( - self.experiment_df, self.conditions_table - ) + ExperimentsTable.from_dataframe(self.experiment_df) ) self.experiments: list[Experiment] = self.experiments_table.experiments self.measurement_table: MeasurementTable = ( MeasurementTable.from_dataframe( self.measurement_df, - observables_table=self.observables_table, - experiments_table=self.experiments_table, ) ) From 53e0d8ada1f52bcfeaecddeb2dad8e1d202caeb4 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Fri, 20 Dec 2024 17:03:26 +0100 Subject: [PATCH 07/12] .. --- petab/v2/problem.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/petab/v2/problem.py b/petab/v2/problem.py index 38c1ae6d..654d5093 100644 --- a/petab/v2/problem.py +++ b/petab/v2/problem.py @@ -640,20 +640,6 @@ def get_optimization_to_simulation_parameter_mapping(self, **kwargs): ) ) - def create_parameter_df(self, **kwargs) -> pd.DataFrame: - """Create a new PEtab parameter table - - See :py:func:`create_parameter_df`. - """ - return parameters.create_parameter_df( - model=self.model, - condition_df=self.condition_df, - observable_df=self.observable_df, - measurement_df=self.measurement_df, - mapping_df=self.mapping_df, - **kwargs, - ) - def sample_parameter_startpoints(self, n_starts: int = 100, **kwargs): """Create 2D array with starting points for optimization From fd2b3726c7e4eede42b8f1f0ae55da699000d2fa Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Wed, 12 Mar 2025 09:28:58 +0100 Subject: [PATCH 08/12] single condition per timepoint --- petab/v2/core.py | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/petab/v2/core.py b/petab/v2/core.py index a11aa20c..983c7238 100644 --- a/petab/v2/core.py +++ b/petab/v2/core.py @@ -324,20 +324,19 @@ class ExperimentPeriod(BaseModel): """ start: float = Field(alias=C.TIME) - condition_ids: list[str] = Field(alias=C.CONDITION_ID) + condition_id: str = Field(alias=C.CONDITION_ID) class Config: populate_by_name = True - @field_validator("condition_ids") + @field_validator("condition_id") @classmethod - def validate_id(cls, v): - for condition_id in v: - if not condition_id: - raise ValueError("ID must not be empty.") - if not is_valid_identifier(condition_id): - raise ValueError(f"Invalid ID: {condition_id}") - return v + def validate_id(cls, condition_id): + if not condition_id: + raise ValueError("ID must not be empty.") + if not is_valid_identifier(condition_id): + raise ValueError(f"Invalid ID: {condition_id}") + return condition_id class Experiment(BaseModel): @@ -377,14 +376,12 @@ def from_dataframe(cls, df: pd.DataFrame) -> ExperimentsTable: experiments = [] for experiment_id, cur_exp_df in df.groupby(C.EXPERIMENT_ID): - periods = [] - for time, cur_period_df in cur_exp_df.groupby(C.TIME): - period_conditions = list(cur_period_df[C.CONDITION_ID]) - periods.append( - ExperimentPeriod( - start=time, condition_ids=period_conditions - ) + periods = [ + ExperimentPeriod( + start=row[C.TIME], condition_id=row[C.CONDITION_ID] ) + for _, row in cur_exp_df.iterrows() + ] experiments.append(Experiment(id=experiment_id, periods=periods)) return cls(experiments=experiments) From 10502bdfd059b1ed81a04271fbbf56a427090616 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Wed, 12 Mar 2025 09:57:48 +0100 Subject: [PATCH 09/12] Add operators --- petab/v2/core.py | 134 ++++++++++++++++++++++++++++++++++++------ tests/v2/test_core.py | 47 ++++++++++++++- 2 files changed, 163 insertions(+), 18 deletions(-) diff --git a/petab/v2/core.py b/petab/v2/core.py index 983c7238..5aaaafc0 100644 --- a/petab/v2/core.py +++ b/petab/v2/core.py @@ -12,6 +12,7 @@ Field, ValidationInfo, field_validator, + model_validator, ) from ..v1.lint import is_valid_identifier @@ -199,19 +200,26 @@ def to_tsv(self, file_path: str | Path) -> None: df = self.to_dataframe() df.to_csv(file_path, sep="\t", index=False) + def __add__(self, other: Observable) -> ObservablesTable: + """Add an observable to the table.""" + if not isinstance(other, Observable): + raise TypeError("Can only add Observable to ObservablesTable") + return ObservablesTable(observables=self.observables + [other]) + + def __iadd__(self, other: Observable) -> ObservablesTable: + """Add an observable to the table in place.""" + if not isinstance(other, Observable): + raise TypeError("Can only add Observable to ObservablesTable") + self.observables.append(other) + return self + class OperationType(str, Enum): """Operation types for model changes in the PEtab conditions table.""" # TODO update names SET_CURRENT_VALUE = "setCurrentValue" - SET_RATE = "setRate" - SET_ASSIGNMENT = "setAssignment" - ADD_TO_RATE = "addToRate" - ADD_TO_ASSIGNMENT = "addToAssignment" NO_CHANGE = "noChange" - CONSTANT = "constant" - INITIAL = "initial" ... @@ -222,23 +230,24 @@ class Change(BaseModel): row of the PEtab conditions table. """ - target_id: str = Field(alias=C.TARGET_ID) + target_id: str | None = Field(alias=C.TARGET_ID, default=None) operation_type: OperationType = Field(alias=C.VALUE_TYPE) - target_value: sp.Basic = Field(alias=C.TARGET_VALUE) + target_value: sp.Basic | None = Field(alias=C.TARGET_VALUE, default=None) class Config: populate_by_name = True arbitrary_types_allowed = True use_enum_values = True - @field_validator("target_id") + @model_validator(mode="before") @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 + def validate_id(cls, data: dict): + if data.get("operation_type") != OperationType.NO_CHANGE: + target_id = data.get("target_id") + + if not is_valid_identifier(target_id): + raise ValueError(f"Invalid ID: {target_id}") + return data @field_validator("target_value", mode="before") @classmethod @@ -274,11 +283,24 @@ def validate_id(cls, v): raise ValueError(f"Invalid ID: {v}") return v + def __add__(self, other: Change) -> ChangeSet: + """Add a change to the set.""" + if not isinstance(other, Change): + raise TypeError("Can only add Change to ChangeSet") + return ChangeSet(id=self.id, changes=self.changes + [other]) + + def __iadd__(self, other: Change) -> ChangeSet: + """Add a change to the set in place.""" + if not isinstance(other, Change): + raise TypeError("Can only add Change to ChangeSet") + self.changes.append(other) + return self + class ConditionsTable(BaseModel): """PEtab conditions table.""" - conditions: list[ChangeSet] + conditions: list[ChangeSet] = [] def __getitem__(self, condition_id: str) -> ChangeSet: """Get a condition by ID.""" @@ -316,6 +338,19 @@ def to_tsv(self, file_path: str | Path) -> None: df = self.to_dataframe() df.to_csv(file_path, sep="\t", index=False) + def __add__(self, other: ChangeSet) -> ConditionsTable: + """Add a condition to the table.""" + if not isinstance(other, ChangeSet): + raise TypeError("Can only add ChangeSet to ConditionsTable") + return ConditionsTable(conditions=self.conditions + [other]) + + def __iadd__(self, other: ChangeSet) -> ConditionsTable: + """Add a condition to the table in place.""" + if not isinstance(other, ChangeSet): + raise TypeError("Can only add ChangeSet to ConditionsTable") + self.conditions.append(other) + return self + class ExperimentPeriod(BaseModel): """A period of a timecourse defined by a start time and a set changes. @@ -348,7 +383,7 @@ class Experiment(BaseModel): """ id: str = Field(alias=C.EXPERIMENT_ID) - periods: list[ExperimentPeriod] + periods: list[ExperimentPeriod] = [] class Config: populate_by_name = True @@ -363,6 +398,19 @@ def validate_id(cls, v): raise ValueError(f"Invalid ID: {v}") return v + def __add__(self, other: ExperimentPeriod) -> Experiment: + """Add a period to the experiment.""" + if not isinstance(other, ExperimentPeriod): + raise TypeError("Can only add ExperimentPeriod to Experiment") + return Experiment(id=self.id, periods=self.periods + [other]) + + def __iadd__(self, other: ExperimentPeriod) -> Experiment: + """Add a period to the experiment in place.""" + if not isinstance(other, ExperimentPeriod): + raise TypeError("Can only add ExperimentPeriod to Experiment") + self.periods.append(other) + return self + class ExperimentsTable(BaseModel): """PEtab experiments table.""" @@ -398,6 +446,19 @@ def to_tsv(self, file_path: str | Path) -> None: df = self.to_dataframe() df.to_csv(file_path, sep="\t", index=False) + def __add__(self, other: Experiment) -> ExperimentsTable: + """Add an experiment to the table.""" + if not isinstance(other, Experiment): + raise TypeError("Can only add Experiment to ExperimentsTable") + return ExperimentsTable(experiments=self.experiments + [other]) + + def __iadd__(self, other: Experiment) -> ExperimentsTable: + """Add an experiment to the table in place.""" + if not isinstance(other, Experiment): + raise TypeError("Can only add Experiment to ExperimentsTable") + self.experiments.append(other) + return self + class Measurement(BaseModel): """A measurement. @@ -492,6 +553,19 @@ def to_tsv(self, file_path: str | Path) -> None: df = self.to_dataframe() df.to_csv(file_path, sep="\t", index=False) + def __add__(self, other: Measurement) -> MeasurementTable: + """Add a measurement to the table.""" + if not isinstance(other, Measurement): + raise TypeError("Can only add Measurement to MeasurementTable") + return MeasurementTable(measurements=self.measurements + [other]) + + def __iadd__(self, other: Measurement) -> MeasurementTable: + """Add a measurement to the table in place.""" + if not isinstance(other, Measurement): + raise TypeError("Can only add Measurement to MeasurementTable") + self.measurements.append(other) + return self + class Mapping(BaseModel): """Mapping PEtab entities to model entities.""" @@ -542,6 +616,19 @@ def to_tsv(self, file_path: str | Path) -> None: df = self.to_dataframe() df.to_csv(file_path, sep="\t", index=False) + def __add__(self, other: Mapping) -> MappingTable: + """Add a mapping to the table.""" + if not isinstance(other, Mapping): + raise TypeError("Can only add Mapping to MappingTable") + return MappingTable(mappings=self.mappings + [other]) + + def __iadd__(self, other: Mapping) -> MappingTable: + """Add a mapping to the table in place.""" + if not isinstance(other, Mapping): + raise TypeError("Can only add Mapping to MappingTable") + self.mappings.append(other) + return self + class Parameter(BaseModel): """Parameter definition.""" @@ -606,3 +693,16 @@ def from_tsv(cls, file_path: str | Path) -> ParameterTable: def to_tsv(self, file_path: str | Path) -> None: df = self.to_dataframe() df.to_csv(file_path, sep="\t", index=False) + + def __add__(self, other: Parameter) -> ParameterTable: + """Add a parameter to the table.""" + if not isinstance(other, Parameter): + raise TypeError("Can only add Parameter to ParameterTable") + return ParameterTable(parameters=self.parameters + [other]) + + def __iadd__(self, other: Parameter) -> ParameterTable: + """Add a parameter to the table in place.""" + if not isinstance(other, Parameter): + raise TypeError("Can only add Parameter to ParameterTable") + self.parameters.append(other) + return self diff --git a/tests/v2/test_core.py b/tests/v2/test_core.py index 76933a1c..1dfd5a85 100644 --- a/tests/v2/test_core.py +++ b/tests/v2/test_core.py @@ -1,7 +1,15 @@ import tempfile from pathlib import Path -from petab.v2.core import ConditionsTable, ObservablesTable +from petab.v2.core import ( + Change, + ChangeSet, + ConditionsTable, + Experiment, + ExperimentPeriod, + ObservablesTable, + OperationType, +) from petab.v2.petab1to2 import petab1to2 example_dir_fujita = Path(__file__).parents[2] / "doc/example/example_Fujita" @@ -30,3 +38,40 @@ def test_conditions_table(): conditions.to_tsv(tmp_file) conditions2 = ConditionsTable.from_tsv(tmp_file) assert conditions == conditions2 + + +def test_experiment_add_periods(): + """Test operators for Experiment""" + exp = Experiment(id="exp1") + assert exp.periods == [] + + p1 = ExperimentPeriod(start=0, condition_id="p1") + p2 = ExperimentPeriod(start=1, condition_id="p2") + p3 = ExperimentPeriod(start=2, condition_id="p3") + exp += p1 + exp += p2 + + assert exp.periods == [p1, p2] + + exp2 = exp + p3 + assert exp2.periods == [p1, p2, p3] + assert exp.periods == [p1, p2] + + +def test_conditions_table_add_changeset(): + conditions_table = ConditionsTable() + assert conditions_table.conditions == [] + + c1 = ChangeSet( + id="condition1", + changes=[Change(operation_type=OperationType.NO_CHANGE)], + ) + c2 = ChangeSet( + id="condition2", + changes=[Change(operation_type=OperationType.NO_CHANGE)], + ) + + conditions_table += c1 + conditions_table += c2 + + assert conditions_table.conditions == [c1, c2] From 35e409873841abfc95bdbc1596cd2fb0a40e0850 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Wed, 12 Mar 2025 10:15:40 +0100 Subject: [PATCH 10/12] valueType/operationType --- petab/v2/C.py | 27 ++++++++++----------------- petab/v2/core.py | 9 ++++++--- petab/v2/petab1to2.py | 6 ++---- petab/v2/problem.py | 4 ++-- tests/v2/test_problem.py | 12 ++++++------ 5 files changed, 26 insertions(+), 32 deletions(-) diff --git a/petab/v2/C.py b/petab/v2/C.py index 0a406fac..77ce8141 100644 --- a/petab/v2/C.py +++ b/petab/v2/C.py @@ -130,30 +130,23 @@ CONDITION_NAME = "conditionName" #: Column in the condition table with the ID of an entity that is changed TARGET_ID = "targetId" -#: Column in the condition table with the type of value that is changed -VALUE_TYPE = "valueType" +#: Column in the condition table with the operation type +OPERATION_TYPE = "operationType" #: Column in the condition table with the new value of the target entity TARGET_VALUE = "targetValue" -# value types: -VT_CONSTANT = "constant" -VT_INITIAL = "initial" -VT_RATE = "rate" -VT_ASSIGNMENT = "assignment" -VT_RELATIVE_RATE = "relativeRate" -VT_RELATIVE_ASSIGNMENT = "relativeAssignment" -VALUE_TYPES = [ - VT_CONSTANT, - VT_INITIAL, - VT_RATE, - VT_ASSIGNMENT, - VT_RELATIVE_RATE, - VT_RELATIVE_ASSIGNMENT, +# opeartion types: +OT_CUR_VAL = "setCurrentValue" +OT_NO_CHANGE = "noChange" + +OPERATION_TYPES = [ + OT_CUR_VAL, + OT_NO_CHANGE, ] CONDITION_DF_COLS = [ CONDITION_ID, TARGET_ID, - VALUE_TYPE, + OPERATION_TYPE, TARGET_VALUE, ] diff --git a/petab/v2/core.py b/petab/v2/core.py index 5aaaafc0..c8135766 100644 --- a/petab/v2/core.py +++ b/petab/v2/core.py @@ -231,7 +231,7 @@ class Change(BaseModel): """ target_id: str | None = Field(alias=C.TARGET_ID, default=None) - operation_type: OperationType = Field(alias=C.VALUE_TYPE) + operation_type: OperationType = Field(alias=C.OPERATION_TYPE) target_value: sp.Basic | None = Field(alias=C.TARGET_VALUE, default=None) class Config: @@ -242,8 +242,11 @@ class Config: @model_validator(mode="before") @classmethod def validate_id(cls, data: dict): - if data.get("operation_type") != OperationType.NO_CHANGE: - target_id = data.get("target_id") + if ( + data.get("operation_type", data.get(C.OPERATION_TYPE)) + != C.OT_NO_CHANGE + ): + target_id = data.get("target_id", data.get(C.TARGET_ID)) if not is_valid_identifier(target_id): raise ValueError(f"Invalid ID: {target_id}") diff --git a/petab/v2/petab1to2.py b/petab/v2/petab1to2.py index c163a246..7f675db0 100644 --- a/petab/v2/petab1to2.py +++ b/petab/v2/petab1to2.py @@ -301,7 +301,7 @@ def v1v2_condition_df( columns=[ v2.C.CONDITION_ID, v2.C.TARGET_ID, - v2.C.VALUE_TYPE, + v2.C.OPERATION_TYPE, v2.C.TARGET_VALUE, ] ) @@ -320,7 +320,5 @@ def v1v2_condition_df( f"Unable to determine value type {target} in the condition " "table." ) - condition_df[v2.C.VALUE_TYPE] = condition_df[v2.C.TARGET_ID].apply( - lambda x: v2.C.VT_INITIAL if x in initial else v2.C.VT_CONSTANT - ) + condition_df[v2.C.OPERATION_TYPE] = v2.C.OT_CUR_VAL return condition_df diff --git a/petab/v2/problem.py b/petab/v2/problem.py index 654d5093..8960ee25 100644 --- a/petab/v2/problem.py +++ b/petab/v2/problem.py @@ -796,10 +796,10 @@ def add_condition( { CONDITION_ID: id_, TARGET_ID: target_id, - VALUE_TYPE: value_type, + OPERATION_TYPE: op_type, TARGET_VALUE: target_value, } - for target_id, (value_type, target_value) in kwargs.items() + for target_id, (op_type, target_value) in kwargs.items() ] # TODO: is the condition name supported in v2? if name is not None: diff --git a/tests/v2/test_problem.py b/tests/v2/test_problem.py index dadc3a7c..04e394ad 100644 --- a/tests/v2/test_problem.py +++ b/tests/v2/test_problem.py @@ -16,13 +16,13 @@ NOMINAL_VALUE, OBSERVABLE_FORMULA, OBSERVABLE_ID, + OPERATION_TYPE, + OT_CUR_VAL, PARAMETER_ID, PETAB_ENTITY_ID, TARGET_ID, TARGET_VALUE, UPPER_BOUND, - VALUE_TYPE, - VT_CONSTANT, ) @@ -73,7 +73,7 @@ def test_problem_from_yaml_multiple_files(): for i in (1, 2): problem = Problem() - problem.add_condition(f"condition{i}", parameter1=(VT_CONSTANT, i)) + problem.add_condition(f"condition{i}", parameter1=(OT_CUR_VAL, i)) petab.write_condition_df( problem.condition_df, Path(tmpdir, f"conditions{i}.tsv") ) @@ -109,14 +109,14 @@ def test_problem_from_yaml_multiple_files(): def test_modify_problem(): """Test modifying a problem via the API.""" problem = Problem() - problem.add_condition("condition1", parameter1=(VT_CONSTANT, 1)) - problem.add_condition("condition2", parameter2=(VT_CONSTANT, 2)) + problem.add_condition("condition1", parameter1=(OT_CUR_VAL, 1)) + problem.add_condition("condition2", parameter2=(OT_CUR_VAL, 2)) exp_condition_df = pd.DataFrame( data={ CONDITION_ID: ["condition1", "condition2"], TARGET_ID: ["parameter1", "parameter2"], - VALUE_TYPE: [VT_CONSTANT, VT_CONSTANT], + OPERATION_TYPE: [OT_CUR_VAL, OT_CUR_VAL], TARGET_VALUE: [1.0, 2.0], } ) From ac77767c9391a856c3eae40e775643417e92cc74 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Wed, 12 Mar 2025 15:58:20 +0100 Subject: [PATCH 11/12] Apply suggestions from code review Co-authored-by: Dilan Pathirana <59329744+dilpath@users.noreply.github.com> --- petab/v2/C.py | 2 +- petab/v2/core.py | 50 +++++++++++++++++++++---------------------- petab/v2/problem.py | 22 +++++++++---------- tests/v2/test_core.py | 7 ++---- 4 files changed, 38 insertions(+), 43 deletions(-) diff --git a/petab/v2/C.py b/petab/v2/C.py index 77ce8141..617977c1 100644 --- a/petab/v2/C.py +++ b/petab/v2/C.py @@ -134,7 +134,7 @@ OPERATION_TYPE = "operationType" #: Column in the condition table with the new value of the target entity TARGET_VALUE = "targetValue" -# opeartion types: +# operation types: OT_CUR_VAL = "setCurrentValue" OT_NO_CHANGE = "noChange" diff --git a/petab/v2/core.py b/petab/v2/core.py index c8135766..491b0efe 100644 --- a/petab/v2/core.py +++ b/petab/v2/core.py @@ -144,7 +144,7 @@ def validate_id(cls, v): mode="before", ) @classmethod - def convert_nan_to_none(cls, v, info: ValidationInfo): + def convert_nan_to_default(cls, v, info: ValidationInfo): if isinstance(v, float) and np.isnan(v): return cls.model_fields[info.field_name].default return v @@ -177,7 +177,7 @@ def __getitem__(self, observable_id: str) -> Observable: raise KeyError(f"Observable ID {observable_id} not found") @classmethod - def from_dataframe(cls, df: pd.DataFrame) -> ObservablesTable: + def from_df(cls, df: pd.DataFrame) -> ObservablesTable: if df is None: return cls(observables=[]) @@ -188,16 +188,16 @@ def from_dataframe(cls, df: pd.DataFrame) -> ObservablesTable: return cls(observables=observables) - def to_dataframe(self) -> pd.DataFrame: + def to_df(self) -> pd.DataFrame: return pd.DataFrame(self.model_dump()["observables"]) @classmethod def from_tsv(cls, file_path: str | Path) -> ObservablesTable: df = pd.read_csv(file_path, sep="\t") - return cls.from_dataframe(df) + return cls.from_df(df) def to_tsv(self, file_path: str | Path) -> None: - df = self.to_dataframe() + df = self.to_df() df.to_csv(file_path, sep="\t", index=False) def __add__(self, other: Observable) -> ObservablesTable: @@ -313,7 +313,7 @@ def __getitem__(self, condition_id: str) -> ChangeSet: raise KeyError(f"Condition ID {condition_id} not found") @classmethod - def from_dataframe(cls, df: pd.DataFrame) -> ConditionsTable: + def from_df(cls, df: pd.DataFrame) -> ConditionsTable: if df is None: return cls(conditions=[]) @@ -324,7 +324,7 @@ def from_dataframe(cls, df: pd.DataFrame) -> ConditionsTable: return cls(conditions=conditions) - def to_dataframe(self) -> pd.DataFrame: + def to_df(self) -> pd.DataFrame: records = [ {C.CONDITION_ID: condition.id, **change.model_dump()} for condition in self.conditions @@ -335,10 +335,10 @@ def to_dataframe(self) -> pd.DataFrame: @classmethod def from_tsv(cls, file_path: str | Path) -> ConditionsTable: df = pd.read_csv(file_path, sep="\t") - return cls.from_dataframe(df) + return cls.from_df(df) def to_tsv(self, file_path: str | Path) -> None: - df = self.to_dataframe() + df = self.to_df() df.to_csv(file_path, sep="\t", index=False) def __add__(self, other: ChangeSet) -> ConditionsTable: @@ -421,7 +421,7 @@ class ExperimentsTable(BaseModel): experiments: list[Experiment] @classmethod - def from_dataframe(cls, df: pd.DataFrame) -> ExperimentsTable: + def from_df(cls, df: pd.DataFrame) -> ExperimentsTable: if df is None: return cls(experiments=[]) @@ -437,16 +437,16 @@ def from_dataframe(cls, df: pd.DataFrame) -> ExperimentsTable: return cls(experiments=experiments) - def to_dataframe(self) -> pd.DataFrame: + def to_df(self) -> pd.DataFrame: return pd.DataFrame(self.model_dump()["experiments"]) @classmethod def from_tsv(cls, file_path: str | Path) -> ExperimentsTable: df = pd.read_csv(file_path, sep="\t") - return cls.from_dataframe(df) + return cls.from_df(df) def to_tsv(self, file_path: str | Path) -> None: - df = self.to_dataframe() + df = self.to_df() df.to_csv(file_path, sep="\t", index=False) def __add__(self, other: Experiment) -> ExperimentsTable: @@ -528,7 +528,7 @@ class MeasurementTable(BaseModel): measurements: list[Measurement] @classmethod - def from_dataframe( + def from_df( cls, df: pd.DataFrame, ) -> MeasurementTable: @@ -544,16 +544,16 @@ def from_dataframe( return cls(measurements=measurements) - def to_dataframe(self) -> pd.DataFrame: + def to_df(self) -> pd.DataFrame: return pd.DataFrame(self.model_dump()["measurements"]) @classmethod def from_tsv(cls, file_path: str | Path) -> MeasurementTable: df = pd.read_csv(file_path, sep="\t") - return cls.from_dataframe(df) + return cls.from_df(df) def to_tsv(self, file_path: str | Path) -> None: - df = self.to_dataframe() + df = self.to_df() df.to_csv(file_path, sep="\t", index=False) def __add__(self, other: Measurement) -> MeasurementTable: @@ -597,7 +597,7 @@ class MappingTable(BaseModel): mappings: list[Mapping] @classmethod - def from_dataframe(cls, df: pd.DataFrame) -> MappingTable: + def from_df(cls, df: pd.DataFrame) -> MappingTable: if df is None: return cls(mappings=[]) @@ -607,16 +607,16 @@ def from_dataframe(cls, df: pd.DataFrame) -> MappingTable: return cls(mappings=mappings) - def to_dataframe(self) -> pd.DataFrame: + def to_df(self) -> pd.DataFrame: return pd.DataFrame(self.model_dump()["mappings"]) @classmethod def from_tsv(cls, file_path: str | Path) -> MappingTable: df = pd.read_csv(file_path, sep="\t") - return cls.from_dataframe(df) + return cls.from_df(df) def to_tsv(self, file_path: str | Path) -> None: - df = self.to_dataframe() + df = self.to_df() df.to_csv(file_path, sep="\t", index=False) def __add__(self, other: Mapping) -> MappingTable: @@ -674,7 +674,7 @@ class ParameterTable(BaseModel): parameters: list[Parameter] @classmethod - def from_dataframe(cls, df: pd.DataFrame) -> ParameterTable: + def from_df(cls, df: pd.DataFrame) -> ParameterTable: if df is None: return cls(parameters=[]) @@ -685,16 +685,16 @@ def from_dataframe(cls, df: pd.DataFrame) -> ParameterTable: return cls(parameters=parameters) - def to_dataframe(self) -> pd.DataFrame: + def to_df(self) -> pd.DataFrame: return pd.DataFrame(self.model_dump()["parameters"]) @classmethod def from_tsv(cls, file_path: str | Path) -> ParameterTable: df = pd.read_csv(file_path, sep="\t") - return cls.from_dataframe(df) + return cls.from_df(df) def to_tsv(self, file_path: str | Path) -> None: - df = self.to_dataframe() + df = self.to_df() df.to_csv(file_path, sep="\t", index=False) def __add__(self, other: Parameter) -> ParameterTable: diff --git a/petab/v2/problem.py b/petab/v2/problem.py index 8960ee25..86d82af4 100644 --- a/petab/v2/problem.py +++ b/petab/v2/problem.py @@ -105,31 +105,29 @@ def __init__( ParameterTable, ) - self.observables_table: ObservablesTable = ( - ObservablesTable.from_dataframe(self.observable_df) + self.observables_table: ObservablesTable = ObservablesTable.from_df( + self.observable_df ) self.observables: list[Observable] = self.observables_table.observables - self.conditions_table: ConditionsTable = ( - ConditionsTable.from_dataframe(self.condition_df) + self.conditions_table: ConditionsTable = ConditionsTable.from_df( + self.condition_df ) self.conditions: list[ChangeSet] = self.conditions_table.conditions - self.experiments_table: ExperimentsTable = ( - ExperimentsTable.from_dataframe(self.experiment_df) + self.experiments_table: ExperimentsTable = ExperimentsTable.from_df( + self.experiment_df ) self.experiments: list[Experiment] = self.experiments_table.experiments - self.measurement_table: MeasurementTable = ( - MeasurementTable.from_dataframe( - self.measurement_df, - ) + self.measurement_table: MeasurementTable = MeasurementTable.from_df( + self.measurement_df, ) - self.mapping_table: MappingTable = MappingTable.from_dataframe( + self.mapping_table: MappingTable = MappingTable.from_df( self.mapping_df ) - self.parameter_table: ParameterTable = ParameterTable.from_dataframe( + self.parameter_table: ParameterTable = ParameterTable.from_df( self.parameter_df ) # TODO: visualization table diff --git a/tests/v2/test_core.py b/tests/v2/test_core.py index 1dfd5a85..a7eae851 100644 --- a/tests/v2/test_core.py +++ b/tests/v2/test_core.py @@ -15,10 +15,8 @@ example_dir_fujita = Path(__file__).parents[2] / "doc/example/example_Fujita" -def test_observables_table(): +def test_observables_table_round_trip(): file = example_dir_fujita / "Fujita_observables.tsv" - - # read-write-read round trip observables = ObservablesTable.from_tsv(file) with tempfile.TemporaryDirectory() as tmp_dir: @@ -28,11 +26,10 @@ def test_observables_table(): assert observables == observables2 -def test_conditions_table(): +def test_conditions_table_round_trip(): with tempfile.TemporaryDirectory() as tmp_dir: petab1to2(example_dir_fujita / "Fujita.yaml", tmp_dir) file = Path(tmp_dir, "Fujita_experimentalCondition.tsv") - # read-write-read round trip conditions = ConditionsTable.from_tsv(file) tmp_file = Path(tmp_dir) / "conditions.tsv" conditions.to_tsv(tmp_file) From 7e021ce51c585eb60b09cd84c7d2b5362c5177da Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Wed, 12 Mar 2025 17:12:18 +0100 Subject: [PATCH 12/12] .. --- petab/v2/core.py | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/petab/v2/core.py b/petab/v2/core.py index 491b0efe..1f826788 100644 --- a/petab/v2/core.py +++ b/petab/v2/core.py @@ -1,4 +1,5 @@ """Types around the PEtab object model.""" + from __future__ import annotations from enum import Enum @@ -73,10 +74,10 @@ class NoiseDistribution(str, Enum): LAPLACE = C.LAPLACE -class ObjectivePriorType(str, Enum): - """Objective prior types. +class PriorType(str, Enum): + """Prior types. - Objective prior types as used in the PEtab parameters table. + Prior types as used in the PEtab parameters table. """ NORMAL = C.NORMAL @@ -89,28 +90,17 @@ class ObjectivePriorType(str, Enum): PARAMETER_SCALE_UNIFORM = C.PARAMETER_SCALE_UNIFORM +#: Objective prior types as used in the PEtab parameters table. +ObjectivePriorType = PriorType +#: Initialization prior types as used in the PEtab parameters table. +InitializationPriorType = PriorType + assert set(C.PRIOR_TYPES) == {e.value for e in ObjectivePriorType}, ( "ObjectivePriorType enum does not match C.PRIOR_TYPES: " f"{set(C.PRIOR_TYPES)} vs { {e.value for e in ObjectivePriorType} }" ) -class InitializationPriorType(str, Enum): - """Initialization prior types. - - Initialization prior types as used in the PEtab parameters table. - """ - - NORMAL = C.NORMAL - LAPLACE = C.LAPLACE - UNIFORM = C.UNIFORM - LOG_NORMAL = C.LOG_NORMAL - LOG_LAPLACE = C.LOG_LAPLACE - PARAMETER_SCALE_NORMAL = C.PARAMETER_SCALE_NORMAL - PARAMETER_SCALE_LAPLACE = C.PARAMETER_SCALE_LAPLACE - PARAMETER_SCALE_UNIFORM = C.PARAMETER_SCALE_UNIFORM - - class Observable(BaseModel): """Observable definition."""