diff --git a/CHANGELOG.md b/CHANGELOG.md index 371f0e3c..fa524e4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ Use `petab.v1.` instead, e.g., `import petab.v1 as petab`. `import petab` no longer imports `petab.v1`. (by @dweindl in https://github.com/PEtab-dev/libpetab-python/pull/538) +* `petab.v2.Parameter` has a new `name` field for the `parameterName` column, + which was previously stored as an extra attribute `parameterName`. + `BaseTable` subclasses now implement `_to_df` instead of `to_df`. + (by @dweindl in https://github.com/PEtab-dev/libpetab-python/pull/543) **Fixes** * `get_model_for_condition` no longer turns rate-rule targets that are @@ -37,6 +41,9 @@ * `petab.v2.ProblemConfig.to_yaml` no longer writes empty optional entries (`condition_files`, `experiment_files`, `mapping_files`, `extensions`) (by @dweindl in https://github.com/PEtab-dev/libpetab-python/pull/547) +* `to_df` no longer fails for empty v2 parameter, observable and measurement + tables + (by @dweindl in https://github.com/PEtab-dev/libpetab-python/pull/543) **Features** * `petab1to2` and `petab_files_1to2` can skip validation via `validate=False` @@ -44,6 +51,11 @@ * Added `petab.v2.C.PROBLEM_CONFIG_REQUIRED_KEYS` and `petab.v2.C.PROBLEM_CONFIG_OPTIONAL_KEYS` (by @dweindl in https://github.com/PEtab-dev/libpetab-python/pull/547) +* PEtab v2 tables can omit empty columns or columns at their default values + via `to_df(columns=...)`, `to_tsv(columns=...)` and + `Problem.to_files(columns=...)`, or globally via `petab.v2.set_options` and + `petab.v2.option_context` + (by @dweindl in https://github.com/PEtab-dev/libpetab-python/pull/543) ## 0.9 series diff --git a/doc/modules.rst b/doc/modules.rst index 08e150a3..66bdcc37 100644 --- a/doc/modules.rst +++ b/doc/modules.rst @@ -38,4 +38,5 @@ API Reference petab.v2.extensions petab.v2.lint petab.v2.models + petab.v2.options petab.v2.petab1to2 diff --git a/petab/v2/C.py b/petab/v2/C.py index 625f128d..e4575465 100644 --- a/petab/v2/C.py +++ b/petab/v2/C.py @@ -45,8 +45,8 @@ MEASUREMENT_DF_REQUIRED_COLS = [ OBSERVABLE_ID, EXPERIMENT_ID, - MEASUREMENT, TIME, + MEASUREMENT, ] #: Optional columns of measurement table @@ -91,13 +91,13 @@ PARAMETER_ID, LOWER_BOUND, UPPER_BOUND, + NOMINAL_VALUE, ESTIMATE, ] #: Optional columns of parameter table PARAMETER_DF_OPTIONAL_COLS = [ PARAMETER_NAME, - NOMINAL_VALUE, PRIOR_DISTRIBUTION, PRIOR_PARAMETERS, ] diff --git a/petab/v2/__init__.py b/petab/v2/__init__.py index e67f2dea..f6c36ca3 100644 --- a/petab/v2/__init__.py +++ b/petab/v2/__init__.py @@ -37,3 +37,4 @@ ) from .lint import lint_problem # noqa: F401 from .models import MODEL_TYPE_PYSB, MODEL_TYPE_SBML, Model # noqa: F401 +from .options import * diff --git a/petab/v2/core.py b/petab/v2/core.py index d7a36a3d..28e4a8df 100644 --- a/petab/v2/core.py +++ b/petab/v2/core.py @@ -18,6 +18,7 @@ TYPE_CHECKING, Annotated, Any, + ClassVar, Literal, Self, TypeVar, @@ -40,6 +41,7 @@ field_validator, model_validator, ) +from pydantic.fields import FieldInfo from .._utils import _generate_path from ..v1 import ( @@ -54,6 +56,7 @@ from ..versions import parse_version from . import C, get_observable_df from .extensions import ExtensionConfig, parse_extension_config +from .options import TableColumns, get_options if TYPE_CHECKING: from ..v2.lint import ValidationResultList, ValidationTask @@ -123,6 +126,14 @@ def _reset_index(df: pd.DataFrame) -> pd.DataFrame: return df.reset_index(drop=all(name is None for name in df.index.names)) +def _column_name(name: str, field: FieldInfo) -> str: + """Get the table column name for a model field. + + Fields without an alias (e.g., ``Condition.changes``) use their name. + """ + return field.alias or name + + def _valid_petab_id(v: str) -> str: """Field validator for PEtab IDs.""" if not v: @@ -239,6 +250,9 @@ class BaseTable[T: BaseModel](BaseModel): #: This is usually the directory of the PEtab YAML file. base_path: AnyUrl | Path | None = Field(exclude=True, default=None) + #: Columns that are always included, even if empty. + _required_columns: ClassVar[list[str]] = [] + def __init__(self, elements: list[T] | None = None, **kwargs) -> None: """Initialize the BaseTable with a list of elements.""" if elements is None: @@ -270,9 +284,52 @@ def __getitem__(self, id_: str) -> T: def from_df(cls, df: pd.DataFrame, **kwargs) -> BaseTable[T]: """Create a table from a DataFrame.""" + def to_df( + self, *, columns: TableColumns | str | None = None + ) -> pd.DataFrame: + """Convert the table to a DataFrame. + + :param columns: + The columns to include. See :class:`TableColumns`. + If ``None``, :attr:`Options.table_columns` is used + (see :func:`get_options`). + """ + if columns is None: + columns = get_options().table_columns + columns = TableColumns(columns) + + df = self._to_df() + if columns == TableColumns.ALL: + return df + + drop = (df.isna() | df.eq("")).all() + if columns == TableColumns.NON_DEFAULT: + drop |= df.columns.isin(self._default_columns()) + drop &= ~df.columns.isin(self._required_columns) + return df.loc[:, ~drop] + @abstractmethod - def to_df(self) -> pd.DataFrame: - """Convert the table to a DataFrame.""" + def _to_df(self) -> pd.DataFrame: + """Convert the table to a DataFrame with all columns.""" + + @classmethod + def _standard_columns(cls) -> list[str]: + """The standard columns of this table, in the default order.""" + return [ + _column_name(name, field) + for name, field in cls._element_class().model_fields.items() + ] + + def _default_columns(self) -> list[str]: + """Get the standard columns that have the default value in all rows.""" + result = [] + for name, field in self._element_class().model_fields.items(): + if field.is_required(): + continue + default = field.get_default(call_default_factory=True) + if all(getattr(row, name) == default for row in self.elements): + result.append(_column_name(name, field)) + return result @classmethod def from_tsv( @@ -282,9 +339,21 @@ def from_tsv( df = pd.read_csv(_generate_path(file_path, base_path), sep="\t") return cls.from_df(df, rel_path=file_path, base_path=base_path) - def to_tsv(self, file_path: str | Path | None = None) -> None: - """Write the table to a TSV file.""" - df = self.to_df() + def to_tsv( + self, + file_path: str | Path | None = None, + *, + columns: TableColumns | str | None = None, + ) -> None: + """Write the table to a TSV file. + + :param file_path: + The file to write to. If ``None``, the path is generated from + :attr:`rel_path` and :attr:`base_path`. + :param columns: + The columns to include. See :meth:`to_df`. + """ + df = self.to_df(columns=columns) df.to_csv( file_path or _generate_path(self.rel_path, self.base_path), sep="\t", @@ -408,6 +477,8 @@ def _sympify_id_list(cls, v): class ObservableTable(BaseTable[Observable]): """PEtab observable table.""" + _required_columns: ClassVar[list[str]] = C.OBSERVABLE_DF_REQUIRED_COLS + @property def observables(self) -> list[Observable]: """List of observables.""" @@ -426,8 +497,13 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> ObservableTable: ] return cls(observables, **kwargs) - def to_df(self) -> pd.DataFrame: + def _to_df(self) -> pd.DataFrame: """Convert the ObservableTable to a DataFrame.""" + if not self.observables: + return pd.DataFrame(columns=self._standard_columns()).set_index( + [C.OBSERVABLE_ID] + ) + records = self.model_dump(by_alias=True)["elements"] for record in records: obs = record[C.OBSERVABLE_FORMULA] @@ -532,6 +608,8 @@ def __iadd__(self, other: Change) -> Self: class ConditionTable(BaseTable[Condition]): """PEtab condition table.""" + _required_columns: ClassVar[list[str]] = C.CONDITION_DF_REQUIRED_COLS + @property def conditions(self) -> list[Condition]: """List of conditions.""" @@ -550,7 +628,11 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> ConditionTable: return cls(conditions, **kwargs) - def to_df(self) -> pd.DataFrame: + @classmethod + def _standard_columns(cls) -> list[str]: + return C.CONDITION_DF_COLS + + def _to_df(self) -> pd.DataFrame: """Convert the ConditionTable to a DataFrame.""" records = [ {C.CONDITION_ID: condition.id, **change.model_dump(by_alias=True)} @@ -566,7 +648,7 @@ def to_df(self) -> pd.DataFrame: return ( pd.DataFrame(records) if records - else pd.DataFrame(columns=C.CONDITION_DF_REQUIRED_COLS) + else pd.DataFrame(columns=self._standard_columns()) ) @property @@ -682,6 +764,8 @@ def sort_periods(self) -> None: class ExperimentTable(BaseTable[Experiment]): """PEtab experiment table.""" + _required_columns: ClassVar[list[str]] = C.EXPERIMENT_DF_REQUIRED_COLS + @property def experiments(self) -> list[Experiment]: """List of experiments.""" @@ -714,7 +798,11 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> ExperimentTable: return cls(experiments, **kwargs) - def to_df(self) -> pd.DataFrame: + @classmethod + def _standard_columns(cls) -> list[str]: + return C.EXPERIMENT_DF_REQUIRED_COLS + + def _to_df(self) -> pd.DataFrame: """Convert the ExperimentTable to a DataFrame.""" records = [ { @@ -729,7 +817,7 @@ def to_df(self) -> pd.DataFrame: return ( pd.DataFrame(records) if records - else pd.DataFrame(columns=C.EXPERIMENT_DF_REQUIRED_COLS) + else pd.DataFrame(columns=self._standard_columns()) ) @@ -811,6 +899,8 @@ def _sympify_list(cls, v): class MeasurementTable(BaseTable[Measurement]): """PEtab measurement table.""" + _required_columns: ClassVar[list[str]] = C.MEASUREMENT_DF_REQUIRED_COLS + @property def measurements(self) -> list[Measurement]: """List of measurements.""" @@ -835,8 +925,11 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> MeasurementTable: return cls(measurements, **kwargs) - def to_df(self) -> pd.DataFrame: + def _to_df(self) -> pd.DataFrame: """Convert the MeasurementTable to a DataFrame.""" + if not self.measurements: + return pd.DataFrame(columns=self._standard_columns()) + records = self.model_dump(by_alias=True)["elements"] for record in records: record[C.OBSERVABLE_PARAMETERS] = C.PARAMETER_SEPARATOR.join( @@ -890,6 +983,8 @@ def _validate(self) -> Self: class MappingTable(BaseTable[Mapping]): """PEtab mapping table.""" + _required_columns: ClassVar[list[str]] = C.MAPPING_DF_REQUIRED_COLS + @property def mappings(self) -> list[Mapping]: """List of mappings.""" @@ -906,12 +1001,12 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> MappingTable: ] return cls(mappings, **kwargs) - def to_df(self) -> pd.DataFrame: + def _to_df(self) -> pd.DataFrame: """Convert the MappingTable to a DataFrame.""" res = ( pd.DataFrame(self.model_dump(by_alias=True)["elements"]) if self.mappings - else pd.DataFrame(columns=C.MAPPING_DF_REQUIRED_COLS) + else pd.DataFrame(columns=self._standard_columns()) ) return res.set_index([C.PETAB_ENTITY_ID]) @@ -937,6 +1032,10 @@ class Parameter(BaseModel): id: Annotated[str, BeforeValidator(_valid_petab_id)] = Field( alias=C.PARAMETER_ID ) + #: Parameter name. + name: Annotated[str | None, BeforeValidator(_convert_nan_to_none)] = Field( + alias=C.PARAMETER_NAME, default=None + ) #: Lower bound. lb: Annotated[float | None, BeforeValidator(_convert_nan_to_none)] = Field( alias=C.LOWER_BOUND, default=None @@ -1099,6 +1198,8 @@ def prior_dist(self) -> Distribution | None: class ParameterTable(BaseTable[Parameter]): """PEtab parameter table.""" + _required_columns: ClassVar[list[str]] = C.PARAMETER_DF_REQUIRED_COLS + @property def parameters(self) -> list[Parameter]: """List of parameters.""" @@ -1117,11 +1218,14 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> ParameterTable: return cls(parameters, **kwargs) - def to_df(self) -> pd.DataFrame: + def _to_df(self) -> pd.DataFrame: """Convert the ParameterTable to a DataFrame.""" - return pd.DataFrame( - self.model_dump(by_alias=True)["elements"] - ).set_index([C.PARAMETER_ID]) + res = ( + pd.DataFrame(self.model_dump(by_alias=True)["elements"]) + if self.parameters + else pd.DataFrame(columns=self._standard_columns()) + ) + return res.set_index([C.PARAMETER_ID]) @property def n_estimated(self) -> int: @@ -1431,7 +1535,12 @@ def get_problem(problem: str | Path | Problem) -> Problem: "or a PEtab problem object." ) - def to_files(self, base_path: str | Path | None) -> None: + def to_files( + self, + base_path: str | Path | None, + *, + columns: TableColumns | str | None = None, + ) -> None: """Write the PEtab problem to files. Writes the model, condition, experiment, measurement, parameter, @@ -1445,6 +1554,9 @@ def to_files(self, base_path: str | Path | None) -> None: The base path the yaml file and tables will be written to. If ``None``, the `base_path` of the individual tables and :obj:`Problem.config.base_path` will be used. + :param columns: + The columns to include in the tables. + See :meth:`BaseTable.to_df`. """ config = copy.deepcopy(self.config) or ProblemConfig( format_version="2.0.0" @@ -1499,7 +1611,8 @@ def to_files(self, base_path: str | Path | None) -> None: table.to_tsv( _generate_path( table.rel_path, base_path or table.base_path - ) + ), + columns=columns, ) config.to_yaml( @@ -2578,6 +2691,7 @@ def model_dump(self, **kwargs) -> dict[str, Any]: 'parameters': [{'estimate': 'true', 'id': 'par', 'lb': 0.0, + 'name': None, 'nominal_value': None, 'prior_distribution': '', 'prior_parameters': '', diff --git a/petab/v2/options.py b/petab/v2/options.py new file mode 100644 index 00000000..81743c1f --- /dev/null +++ b/petab/v2/options.py @@ -0,0 +1,105 @@ +"""Options controlling the behavior of the PEtab v2 API. + +Options can be set globally via :func:`set_options`, or temporarily via +the :func:`option_context` context manager: + +>>> import petab.v2 as petab +>>> petab.get_options().table_columns + +>>> with petab.option_context(table_columns="non_empty"): +... petab.get_options().table_columns + +>>> petab.get_options().table_columns + +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, replace +from enum import StrEnum + +__all__ = [ + "Options", + "TableColumns", + "get_options", + "option_context", + "set_options", +] + + +class TableColumns(StrEnum): + """Columns to include when converting PEtab tables to DataFrames. + + Required columns, and any columns containing any + non-default values, are always included. + """ + + #: All standard columns and all additional columns. + ALL = "all" + #: Only columns that have a non-empty value in at least one row. + NON_EMPTY = "non_empty" + #: Only non-empty columns that differ from the default value in at least + #: one row. + NON_DEFAULT = "non_default" + + +@dataclass(frozen=True) +class Options: + """Options controlling the behavior of the PEtab v2 API.""" + + #: Columns to include when converting tables to DataFrames or files. + #: See :class:`TableColumns`. + table_columns: TableColumns = TableColumns.ALL + + def __post_init__(self): + object.__setattr__( + self, "table_columns", TableColumns(self.table_columns) + ) + + +_global_options = Options() +# Temporary overrides set via `option_context`. +# A ContextVar keeps them local to the current thread / async task. +_context_options: ContextVar[Options | None] = ContextVar( + "petab_v2_options", default=None +) + + +def get_options() -> Options: + """Get the currently active options.""" + return _context_options.get() or _global_options + + +def set_options(**kwargs) -> None: + """Set options globally. + + Options set inside an active :func:`option_context` only apply until + that context is exited. + + :param kwargs: Options to set. See :class:`Options`. + """ + global _global_options + + if (current := _context_options.get()) is not None: + _context_options.set(replace(current, **kwargs)) + else: + _global_options = replace(_global_options, **kwargs) + + +@contextmanager +def option_context(**kwargs) -> Iterator[Options]: + """Temporarily set options. + + The previous options are restored when the context is exited. + + :param kwargs: Options to set. See :class:`Options`. + :return: The options active inside the context. + """ + token = _context_options.set(replace(get_options(), **kwargs)) + try: + yield get_options() + finally: + _context_options.reset(token) diff --git a/tests/v2/test_core.py b/tests/v2/test_core.py index 1f13e411..d63752eb 100644 --- a/tests/v2/test_core.py +++ b/tests/v2/test_core.py @@ -383,6 +383,7 @@ def test_parameter(): id="k1", estimate=True, lb=1, ub=2, prior_parameters=[1, 2] ).model_dump() == { "id": "k1", + "name": None, "lb": 1.0, "ub": 2.0, "nominal_value": None, @@ -394,6 +395,7 @@ def test_parameter(): id="k1", estimate=False, nominal_value="8" ).model_dump() == { "id": "k1", + "name": None, "lb": None, "ub": None, "nominal_value": 8.0, @@ -1339,3 +1341,193 @@ def test_validate_multi_model(): result = problem.validate() assert result.has_errors() assert any("duplicate IDs: ['c_dup']" in r.message for r in result), result + + +def _all_columns(df: pd.DataFrame) -> list[str]: + """Get the column names of a table DataFrame, including the index.""" + if isinstance(df.index, pd.RangeIndex): + return list(df.columns) + return [*df.index.names, *df.columns] + + +def test_table_columns(): + """Test selecting columns when converting tables to DataFrames.""" + observables = ObservableTable( + [ + Observable(id="obs1", formula="x", noise_formula="1"), + Observable( + id="obs2", + formula="y", + noise_formula="1", + noise_distribution="laplace", + ), + ] + ) + estimated = ParameterTable( + [ + Parameter(id="p1", lb=1, ub=2, name="P1"), + Parameter(id="p2", lb=1, ub=2), + ] + ) + fixed = ParameterTable( + [Parameter(id="p3", estimate=False, nominal_value=1)] + ) + measurements = MeasurementTable( + [Measurement(observable_id="obs1", time=1, measurement=2)] + ) + tables = [observables, estimated, fixed, measurements] + + for table in tables: + df = table.to_df(columns="all") + assert _all_columns(df) == table._standard_columns() + + # Required columns are kept even if empty or at their default + # (nominalValue, lowerBound, upperBound, experimentId, estimate). + # noiseDistribution differs from its default in one row. + expected = [ + [*C.OBSERVABLE_DF_REQUIRED_COLS, C.NOISE_DISTRIBUTION], + [ + C.PARAMETER_ID, + C.PARAMETER_NAME, + *C.PARAMETER_DF_REQUIRED_COLS[1:], + ], + C.PARAMETER_DF_REQUIRED_COLS, + C.MEASUREMENT_DF_REQUIRED_COLS, + ] + for mode in ("non_empty", "non_default"): + for table, exp in zip(tables, expected, strict=True): + assert _all_columns(table.to_df(columns=mode)) == exp + + # all noise distributions at their default + observables.observables[1].noise_distribution = "normal" + assert _all_columns(observables.to_df(columns="non_empty")) == [ + *C.OBSERVABLE_DF_REQUIRED_COLS, + C.NOISE_DISTRIBUTION, + ] + assert ( + _all_columns(observables.to_df(columns="non_default")) + == C.OBSERVABLE_DF_REQUIRED_COLS + ) + + +@pytest.mark.parametrize( + "table_cls,required", + [ + (ConditionTable, C.CONDITION_DF_REQUIRED_COLS), + (ExperimentTable, C.EXPERIMENT_DF_REQUIRED_COLS), + (MeasurementTable, C.MEASUREMENT_DF_REQUIRED_COLS), + (ObservableTable, C.OBSERVABLE_DF_REQUIRED_COLS), + (ParameterTable, C.PARAMETER_DF_REQUIRED_COLS), + (MappingTable, C.MAPPING_DF_REQUIRED_COLS), + ], +) +def test_empty_table_columns(table_cls, required): + """Empty tables contain the standard or required columns.""" + table = table_cls() + df = table.to_df(columns="all") + assert _all_columns(df) == table._standard_columns() + for mode in ("non_empty", "non_default"): + df = table.to_df(columns=mode) + assert _all_columns(df) == required + + +def test_options(): + """Test setting options globally and temporarily.""" + problem = Problem() + problem.add_observable("obs1", "x", noise_formula="1") + all_cols = list(problem.observable_df.columns) + assert C.OBSERVABLE_NAME in all_cols + + assert petab.get_options().table_columns == petab.TableColumns.ALL + with petab.option_context(table_columns="non_default") as options: + assert options.table_columns == petab.TableColumns.NON_DEFAULT + assert list(problem.observable_df.columns) == [ + C.OBSERVABLE_FORMULA, + C.NOISE_FORMULA, + ] + # an explicit argument takes precedence + df = problem.observable_tables[0].to_df(columns="all") + assert list(df.columns) == all_cols + # set_options inside a context only applies to that context + petab.set_options(table_columns="non_empty") + assert petab.get_options().table_columns == "non_empty" + assert petab.get_options().table_columns == petab.TableColumns.ALL + assert list(problem.observable_df.columns) == all_cols + + try: + petab.set_options(table_columns="non_empty") + assert petab.get_options().table_columns == "non_empty" + assert list(problem.observable_df.columns) == [ + C.OBSERVABLE_FORMULA, + C.NOISE_FORMULA, + C.NOISE_DISTRIBUTION, + ] + finally: + petab.set_options(table_columns="all") + + with pytest.raises(ValueError): + petab.set_options(table_columns="invalid") + assert petab.get_options().table_columns == petab.TableColumns.ALL + + +@pytest.mark.parametrize( + "table_cls,content", + [ + ( + ObservableTable, + "observableId\tobservableFormula\tnoiseFormula\nobs1\tx\t1\n", + ), + ( + ParameterTable, + ( + "parameterId\tparameterName\tlowerBound\tupperBound\t" + "nominalValue\testimate\np1\tP1\t0\t10\t1\ttrue\n" + ), + ), + ( + MeasurementTable, + "observableId\texperimentId\ttime\tmeasurement\nobs1\te1\t0\t1\n", + ), + ( + ConditionTable, + "conditionId\ttargetId\ttargetValue\nc1\tx\t1\n", + ), + ( + ExperimentTable, + "experimentId\ttime\tconditionId\ne1\t0\tc1\n", + ), + ( + MappingTable, + "petabEntityId\tmodelEntityId\np1\t1p\n", + ), + ], +) +def test_minimal_table_round_trip(tmp_path, table_cls, content): + """Writing minimal tables with `non_default` preserves their columns.""" + in_file = tmp_path / "in.tsv" + in_file.write_text(content) + out_file = tmp_path / "out.tsv" + + table = table_cls.from_tsv(in_file) + table.to_tsv(out_file, columns="non_default") + + assert out_file.read_text().split("\n")[0] == content.split("\n")[0] + assert_frame_equal(table_cls.from_tsv(out_file).to_df(), table.to_df()) + + +def test_problem_to_files_columns(tmp_path): + """Test selecting columns in `Problem.to_files`.""" + problem = Problem() + problem.add_observable("obs1", "x", noise_formula="1") + problem.observable_tables[0].rel_path = "observables.tsv" + problem.config = ProblemConfig( + format_version="2.0.0", filepath="problem.yaml" + ) + + problem.to_files(tmp_path, columns="non_default") + df = pd.read_csv(tmp_path / "observables.tsv", sep="\t") + assert list(df.columns) == [ + C.OBSERVABLE_ID, + C.OBSERVABLE_FORMULA, + C.NOISE_FORMULA, + ]