Skip to content

Commit 346f40e

Browse files
dweindlclaude
andcommitted
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 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` and make `to_df` work for empty parameter, observable, and measurement tables. Closes #523. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 539333b commit 346f40e

5 files changed

Lines changed: 527 additions & 26 deletions

File tree

‎doc/modules.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,5 @@ API Reference
3838
petab.v2.extensions
3939
petab.v2.lint
4040
petab.v2.models
41+
petab.v2.options
4142
petab.v2.petab1to2

‎petab/v2/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@
3737
)
3838
from .lint import lint_problem # noqa: F401
3939
from .models import MODEL_TYPE_PYSB, MODEL_TYPE_SBML, Model # noqa: F401
40+
from .options import *

‎petab/v2/core.py‎

Lines changed: 196 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
import tempfile
99
import traceback
1010
from abc import abstractmethod
11-
from collections.abc import Sequence
11+
from collections.abc import Iterable, Sequence
1212
from enum import StrEnum
1313
from itertools import chain
14-
from math import nan
14+
from math import isnan, nan
1515
from numbers import Number
1616
from pathlib import Path
1717
from typing import (
@@ -54,6 +54,7 @@
5454
from ..versions import parse_version
5555
from . import C, get_observable_df
5656
from .extensions import ExtensionConfig, parse_extension_config
57+
from .options import TableColumns, get_options
5758

5859
if TYPE_CHECKING:
5960
from ..v2.lint import ValidationResultList, ValidationTask
@@ -123,6 +124,24 @@ def _reset_index(df: pd.DataFrame) -> pd.DataFrame:
123124
return df.reset_index(drop=all(name is None for name in df.index.names))
124125

125126

127+
class _RequiredColumn:
128+
"""Marks a field whose column is required, even if it has a default."""
129+
130+
131+
#: Field annotation for columns that are required, even if they have a
132+
#: default value or may contain empty values.
133+
_REQUIRED_COLUMN = _RequiredColumn()
134+
135+
136+
def _is_empty(v) -> bool:
137+
"""Check if a serialized table value is empty."""
138+
return (
139+
v is None
140+
or (isinstance(v, str) and v == "")
141+
or (isinstance(v, float) and isnan(v))
142+
)
143+
144+
126145
def _valid_petab_id(v: str) -> str:
127146
"""Field validator for PEtab IDs."""
128147
if not v:
@@ -270,9 +289,81 @@ def __getitem__(self, id_: str) -> T:
270289
def from_df(cls, df: pd.DataFrame, **kwargs) -> BaseTable[T]:
271290
"""Create a table from a DataFrame."""
272291

292+
def to_df(
293+
self, *, columns: TableColumns | str | None = None
294+
) -> pd.DataFrame:
295+
"""Convert the table to a DataFrame.
296+
297+
:param columns:
298+
The columns to include. See :class:`TableColumns`.
299+
If ``None``, :attr:`Options.table_columns` is used
300+
(see :func:`get_options`).
301+
"""
302+
if columns is None:
303+
columns = get_options().table_columns
304+
columns = TableColumns(columns)
305+
306+
df = self._to_df()
307+
if columns == TableColumns.ALL:
308+
return df
309+
310+
required = set(self.required_columns())
311+
drop = [
312+
col
313+
for col in df.columns
314+
if col not in required and df[col].map(_is_empty).all()
315+
]
316+
if columns == TableColumns.NON_DEFAULT:
317+
drop.extend(
318+
col
319+
for col in self._default_columns()
320+
if col in df.columns
321+
and col not in required
322+
and col not in drop
323+
)
324+
return df.drop(columns=drop)
325+
273326
@abstractmethod
274-
def to_df(self) -> pd.DataFrame:
275-
"""Convert the table to a DataFrame."""
327+
def _to_df(self) -> pd.DataFrame:
328+
"""Convert the table to a DataFrame with all columns."""
329+
330+
@classmethod
331+
def standard_columns(cls) -> list[str]:
332+
"""The standard columns of this table, in the default order."""
333+
return [
334+
field.alias or name
335+
for name, field in cls._row_class().model_fields.items()
336+
]
337+
338+
@classmethod
339+
def required_columns(cls) -> list[str]:
340+
"""The columns that are always included in this table."""
341+
return [
342+
field.alias or name
343+
for name, field in cls._row_class().model_fields.items()
344+
if field.is_required() or _REQUIRED_COLUMN in field.metadata
345+
]
346+
347+
@classmethod
348+
def _row_class(cls) -> type[BaseModel]:
349+
"""The class corresponding to a single table row."""
350+
return cls._element_class()
351+
352+
def _rows(self) -> Iterable[BaseModel]:
353+
"""The objects corresponding to the individual table rows."""
354+
return self.elements
355+
356+
def _default_columns(self) -> list[str]:
357+
"""Get the standard columns that have the default value in all rows."""
358+
rows = list(self._rows())
359+
result = []
360+
for name, field in self._row_class().model_fields.items():
361+
if field.is_required():
362+
continue
363+
default = field.get_default(call_default_factory=True)
364+
if all(getattr(row, name) == default for row in rows):
365+
result.append(field.alias or name)
366+
return result
276367

277368
@classmethod
278369
def from_tsv(
@@ -282,9 +373,21 @@ def from_tsv(
282373
df = pd.read_csv(_generate_path(file_path, base_path), sep="\t")
283374
return cls.from_df(df, rel_path=file_path, base_path=base_path)
284375

285-
def to_tsv(self, file_path: str | Path | None = None) -> None:
286-
"""Write the table to a TSV file."""
287-
df = self.to_df()
376+
def to_tsv(
377+
self,
378+
file_path: str | Path | None = None,
379+
*,
380+
columns: TableColumns | str | None = None,
381+
) -> None:
382+
"""Write the table to a TSV file.
383+
384+
:param file_path:
385+
The file to write to. If ``None``, the path is generated from
386+
:attr:`rel_path` and :attr:`base_path`.
387+
:param columns:
388+
The columns to include. See :meth:`to_df`.
389+
"""
390+
df = self.to_df(columns=columns)
288391
df.to_csv(
289392
file_path or _generate_path(self.rel_path, self.base_path),
290393
sep="\t",
@@ -426,8 +529,13 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> ObservableTable:
426529
]
427530
return cls(observables, **kwargs)
428531

429-
def to_df(self) -> pd.DataFrame:
532+
def _to_df(self) -> pd.DataFrame:
430533
"""Convert the ObservableTable to a DataFrame."""
534+
if not self.observables:
535+
return pd.DataFrame(columns=self.standard_columns()).set_index(
536+
[C.OBSERVABLE_ID]
537+
)
538+
431539
records = self.model_dump(by_alias=True)["elements"]
432540
for record in records:
433541
obs = record[C.OBSERVABLE_FORMULA]
@@ -550,7 +658,26 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> ConditionTable:
550658

551659
return cls(conditions, **kwargs)
552660

553-
def to_df(self) -> pd.DataFrame:
661+
@classmethod
662+
def standard_columns(cls) -> list[str]:
663+
"""The standard columns of this table, in the default order."""
664+
return [C.CONDITION_ID, *super().standard_columns()]
665+
666+
@classmethod
667+
def required_columns(cls) -> list[str]:
668+
"""The columns that are always included in this table."""
669+
return [C.CONDITION_ID, *super().required_columns()]
670+
671+
@classmethod
672+
def _row_class(cls) -> type[BaseModel]:
673+
return Change
674+
675+
def _rows(self) -> Iterable[BaseModel]:
676+
return chain.from_iterable(
677+
condition.changes for condition in self.conditions
678+
)
679+
680+
def _to_df(self) -> pd.DataFrame:
554681
"""Convert the ConditionTable to a DataFrame."""
555682
records = [
556683
{C.CONDITION_ID: condition.id, **change.model_dump(by_alias=True)}
@@ -566,7 +693,7 @@ def to_df(self) -> pd.DataFrame:
566693
return (
567694
pd.DataFrame(records)
568695
if records
569-
else pd.DataFrame(columns=C.CONDITION_DF_REQUIRED_COLS)
696+
else pd.DataFrame(columns=self.standard_columns())
570697
)
571698

572699
@property
@@ -714,7 +841,26 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> ExperimentTable:
714841

715842
return cls(experiments, **kwargs)
716843

717-
def to_df(self) -> pd.DataFrame:
844+
@classmethod
845+
def standard_columns(cls) -> list[str]:
846+
"""The standard columns of this table, in the default order."""
847+
return [C.EXPERIMENT_ID, C.TIME, C.CONDITION_ID]
848+
849+
@classmethod
850+
def required_columns(cls) -> list[str]:
851+
"""The columns that are always included in this table."""
852+
return cls.standard_columns()
853+
854+
@classmethod
855+
def _row_class(cls) -> type[BaseModel]:
856+
return ExperimentPeriod
857+
858+
def _rows(self) -> Iterable[BaseModel]:
859+
return chain.from_iterable(
860+
experiment.periods for experiment in self.experiments
861+
)
862+
863+
def _to_df(self) -> pd.DataFrame:
718864
"""Convert the ExperimentTable to a DataFrame."""
719865
records = [
720866
{
@@ -729,7 +875,7 @@ def to_df(self) -> pd.DataFrame:
729875
return (
730876
pd.DataFrame(records)
731877
if records
732-
else pd.DataFrame(columns=C.EXPERIMENT_DF_REQUIRED_COLS)
878+
else pd.DataFrame(columns=self.standard_columns())
733879
)
734880

735881

@@ -750,7 +896,9 @@ class Measurement(BaseModel):
750896
)
751897
#: The experiment ID.
752898
experiment_id: Annotated[
753-
str | None, BeforeValidator(_valid_petab_id_or_none)
899+
str | None,
900+
BeforeValidator(_valid_petab_id_or_none),
901+
_REQUIRED_COLUMN,
754902
] = Field(alias=C.EXPERIMENT_ID, default=None)
755903
#: The time point of the measurement in time units as defined in the model.
756904
time: Annotated[float, AfterValidator(_is_finite_or_pos_inf)] = Field(
@@ -835,8 +983,11 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> MeasurementTable:
835983

836984
return cls(measurements, **kwargs)
837985

838-
def to_df(self) -> pd.DataFrame:
986+
def _to_df(self) -> pd.DataFrame:
839987
"""Convert the MeasurementTable to a DataFrame."""
988+
if not self.measurements:
989+
return pd.DataFrame(columns=self.standard_columns())
990+
840991
records = self.model_dump(by_alias=True)["elements"]
841992
for record in records:
842993
record[C.OBSERVABLE_PARAMETERS] = C.PARAMETER_SEPARATOR.join(
@@ -857,9 +1008,9 @@ class Mapping(BaseModel):
8571008
alias=C.PETAB_ENTITY_ID
8581009
)
8591010
#: Model entity ID.
860-
model_id: Annotated[str | None, BeforeValidator(_convert_nan_to_none)] = (
861-
Field(alias=C.MODEL_ENTITY_ID, default=None)
862-
)
1011+
model_id: Annotated[
1012+
str | None, BeforeValidator(_convert_nan_to_none), _REQUIRED_COLUMN
1013+
] = Field(alias=C.MODEL_ENTITY_ID, default=None)
8631014
#: Arbitrary name
8641015
name: Annotated[str | None, BeforeValidator(_convert_nan_to_none)] = Field(
8651016
alias=C.NAME, default=None
@@ -906,12 +1057,12 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> MappingTable:
9061057
]
9071058
return cls(mappings, **kwargs)
9081059

909-
def to_df(self) -> pd.DataFrame:
1060+
def _to_df(self) -> pd.DataFrame:
9101061
"""Convert the MappingTable to a DataFrame."""
9111062
res = (
9121063
pd.DataFrame(self.model_dump(by_alias=True)["elements"])
9131064
if self.mappings
914-
else pd.DataFrame(columns=C.MAPPING_DF_REQUIRED_COLS)
1065+
else pd.DataFrame(columns=self.standard_columns())
9151066
)
9161067
return res.set_index([C.PETAB_ENTITY_ID])
9171068

@@ -937,6 +1088,10 @@ class Parameter(BaseModel):
9371088
id: Annotated[str, BeforeValidator(_valid_petab_id)] = Field(
9381089
alias=C.PARAMETER_ID
9391090
)
1091+
#: Parameter name.
1092+
name: Annotated[str | None, BeforeValidator(_convert_nan_to_none)] = Field(
1093+
alias=C.PARAMETER_NAME, default=None
1094+
)
9401095
#: Lower bound.
9411096
lb: Annotated[float | None, BeforeValidator(_convert_nan_to_none)] = Field(
9421097
alias=C.LOWER_BOUND, default=None
@@ -951,7 +1106,9 @@ class Parameter(BaseModel):
9511106
float | Literal["array"] | None, BeforeValidator(_convert_nan_to_none)
9521107
] = Field(alias=C.NOMINAL_VALUE, default=None)
9531108
#: Is the parameter to be estimated?
954-
estimate: bool = Field(alias=C.ESTIMATE, default=True)
1109+
estimate: Annotated[bool, _REQUIRED_COLUMN] = Field(
1110+
alias=C.ESTIMATE, default=True
1111+
)
9551112
#: Type of parameter prior distribution.
9561113
prior_distribution: Annotated[
9571114
PriorDistribution | None, BeforeValidator(_convert_nan_to_none)
@@ -1117,11 +1274,14 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> ParameterTable:
11171274

11181275
return cls(parameters, **kwargs)
11191276

1120-
def to_df(self) -> pd.DataFrame:
1277+
def _to_df(self) -> pd.DataFrame:
11211278
"""Convert the ParameterTable to a DataFrame."""
1122-
return pd.DataFrame(
1123-
self.model_dump(by_alias=True)["elements"]
1124-
).set_index([C.PARAMETER_ID])
1279+
res = (
1280+
pd.DataFrame(self.model_dump(by_alias=True)["elements"])
1281+
if self.parameters
1282+
else pd.DataFrame(columns=self.standard_columns())
1283+
)
1284+
return res.set_index([C.PARAMETER_ID])
11251285

11261286
@property
11271287
def n_estimated(self) -> int:
@@ -1431,7 +1591,12 @@ def get_problem(problem: str | Path | Problem) -> Problem:
14311591
"or a PEtab problem object."
14321592
)
14331593

1434-
def to_files(self, base_path: str | Path | None) -> None:
1594+
def to_files(
1595+
self,
1596+
base_path: str | Path | None,
1597+
*,
1598+
columns: TableColumns | str | None = None,
1599+
) -> None:
14351600
"""Write the PEtab problem to files.
14361601
14371602
Writes the model, condition, experiment, measurement, parameter,
@@ -1445,6 +1610,9 @@ def to_files(self, base_path: str | Path | None) -> None:
14451610
The base path the yaml file and tables will be written to.
14461611
If ``None``, the `base_path` of the individual tables and
14471612
:obj:`Problem.config.base_path` will be used.
1613+
:param columns:
1614+
The columns to include in the tables.
1615+
See :meth:`BaseTable.to_df`.
14481616
"""
14491617
config = copy.deepcopy(self.config) or ProblemConfig(
14501618
format_version="2.0.0"
@@ -1499,7 +1667,8 @@ def to_files(self, base_path: str | Path | None) -> None:
14991667
table.to_tsv(
15001668
_generate_path(
15011669
table.rel_path, base_path or table.base_path
1502-
)
1670+
),
1671+
columns=columns,
15031672
)
15041673

15051674
config.to_yaml(
@@ -2578,6 +2747,7 @@ def model_dump(self, **kwargs) -> dict[str, Any]:
25782747
'parameters': [{'estimate': 'true',
25792748
'id': 'par',
25802749
'lb': 0.0,
2750+
'name': None,
25812751
'nominal_value': None,
25822752
'prior_distribution': '',
25832753
'prior_parameters': '',

0 commit comments

Comments
 (0)