From 337c959efe4ca0ca798e804062fe77cd1b786e5d Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Wed, 14 Oct 2026 20:17:31 +0200 Subject: [PATCH 1/2] Add options to omit empty or default columns from v2 tables Converting PEtab v2 tables to DataFrames or TSV files currently always includes all standard columns, even if they are empty in every row. Add `TableColumns` modes `all` (default, current behavior), `non_empty`, and `non_default` to select the columns to include. Required columns (`C.*_DF_REQUIRED_COLS`) are always included. The mode can be passed to `to_df`, `to_tsv`, and `Problem.to_files`, or set via `petab.v2.set_options` or temporarily via `petab.v2.option_context`, which also applies to `Problem.*_df`. Also add the `parameterName` column as `Parameter.name`, add `nominalValue` to the required parameter table columns as in the spec, and make `to_df` work for empty parameter, observable, and measurement tables. Closes #523. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 +++ doc/modules.rst | 1 + petab/v2/C.py | 4 +- petab/v2/__init__.py | 1 + petab/v2/core.py | 152 ++++++++++++++++++++++++++++----- petab/v2/options.py | 104 +++++++++++++++++++++++ tests/v2/test_core.py | 192 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 445 insertions(+), 21 deletions(-) create mode 100644 petab/v2/options.py 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..b406a964 --- /dev/null +++ b/petab/v2/options.py @@ -0,0 +1,104 @@ +"""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 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, + ] From 6611e0e2acc06b1f5fa80d9d6a8390bcb792fd23 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Thu, 15 Oct 2026 13:08:15 +0200 Subject: [PATCH 2/2] Apply suggestion from @dilpath Co-authored-by: Dilan Pathirana <59329744+dilpath@users.noreply.github.com> --- petab/v2/options.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/petab/v2/options.py b/petab/v2/options.py index b406a964..81743c1f 100644 --- a/petab/v2/options.py +++ b/petab/v2/options.py @@ -33,7 +33,8 @@ class TableColumns(StrEnum): """Columns to include when converting PEtab tables to DataFrames. - Required columns are always included. + Required columns, and any columns containing any + non-default values, are always included. """ #: All standard columns and all additional columns.