Skip to content

Commit 0432d73

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 73b680c commit 0432d73

5 files changed

Lines changed: 525 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, E402
3939
from .models import MODEL_TYPE_PYSB, MODEL_TYPE_SBML, Model # noqa: F401, E402
40+
from .options import * # noqa: F403, F401, E402

‎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 Enum
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 (
@@ -55,6 +55,7 @@
5555
from ..versions import parse_version
5656
from . import C, get_observable_df
5757
from .extensions import ExtensionConfig, parse_extension_config
58+
from .options import TableColumns, get_options
5859

5960
if TYPE_CHECKING:
6061
from ..v2.lint import ValidationResultList, ValidationTask
@@ -116,6 +117,24 @@ def _convert_nan_to_none(v):
116117
return v
117118

118119

120+
class _RequiredColumn:
121+
"""Marks a field whose column is required, even if it has a default."""
122+
123+
124+
#: Field annotation for columns that are required, even if they have a
125+
#: default value or may contain empty values.
126+
_REQUIRED_COLUMN = _RequiredColumn()
127+
128+
129+
def _is_empty(v) -> bool:
130+
"""Check if a serialized table value is empty."""
131+
return (
132+
v is None
133+
or (isinstance(v, str) and v == "")
134+
or (isinstance(v, float) and isnan(v))
135+
)
136+
137+
119138
def _valid_petab_id(v: str) -> str:
120139
"""Field validator for PEtab IDs."""
121140
if not v:
@@ -264,11 +283,83 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> BaseTable[T]:
264283
"""Create a table from a DataFrame."""
265284
pass
266285

286+
def to_df(
287+
self, *, columns: TableColumns | str | None = None
288+
) -> pd.DataFrame:
289+
"""Convert the table to a DataFrame.
290+
291+
:param columns:
292+
The columns to include. See :class:`TableColumns`.
293+
If ``None``, :attr:`Options.table_columns` is used
294+
(see :func:`get_options`).
295+
"""
296+
if columns is None:
297+
columns = get_options().table_columns
298+
columns = TableColumns(columns)
299+
300+
df = self._to_df()
301+
if columns == TableColumns.ALL:
302+
return df
303+
304+
required = set(self.required_columns())
305+
drop = [
306+
col
307+
for col in df.columns
308+
if col not in required and df[col].map(_is_empty).all()
309+
]
310+
if columns == TableColumns.NON_DEFAULT:
311+
drop.extend(
312+
col
313+
for col in self._default_columns()
314+
if col in df.columns
315+
and col not in required
316+
and col not in drop
317+
)
318+
return df.drop(columns=drop)
319+
267320
@abstractmethod
268-
def to_df(self) -> pd.DataFrame:
269-
"""Convert the table to a DataFrame."""
321+
def _to_df(self) -> pd.DataFrame:
322+
"""Convert the table to a DataFrame with all columns."""
270323
pass
271324

325+
@classmethod
326+
def standard_columns(cls) -> list[str]:
327+
"""The standard columns of this table, in the default order."""
328+
return [
329+
field.alias or name
330+
for name, field in cls._row_class().model_fields.items()
331+
]
332+
333+
@classmethod
334+
def required_columns(cls) -> list[str]:
335+
"""The columns that are always included in this table."""
336+
return [
337+
field.alias or name
338+
for name, field in cls._row_class().model_fields.items()
339+
if field.is_required() or _REQUIRED_COLUMN in field.metadata
340+
]
341+
342+
@classmethod
343+
def _row_class(cls) -> type[BaseModel]:
344+
"""The class corresponding to a single table row."""
345+
return cls._element_class()
346+
347+
def _rows(self) -> Iterable[BaseModel]:
348+
"""The objects corresponding to the individual table rows."""
349+
return self.elements
350+
351+
def _default_columns(self) -> list[str]:
352+
"""Get the standard columns that have the default value in all rows."""
353+
rows = list(self._rows())
354+
result = []
355+
for name, field in self._row_class().model_fields.items():
356+
if field.is_required():
357+
continue
358+
default = field.get_default(call_default_factory=True)
359+
if all(getattr(row, name) == default for row in rows):
360+
result.append(field.alias or name)
361+
return result
362+
272363
@classmethod
273364
def from_tsv(
274365
cls, file_path: str | Path, base_path: str | Path | None = None
@@ -277,9 +368,21 @@ def from_tsv(
277368
df = pd.read_csv(_generate_path(file_path, base_path), sep="\t")
278369
return cls.from_df(df, rel_path=file_path, base_path=base_path)
279370

280-
def to_tsv(self, file_path: str | Path = None) -> None:
281-
"""Write the table to a TSV file."""
282-
df = self.to_df()
371+
def to_tsv(
372+
self,
373+
file_path: str | Path = None,
374+
*,
375+
columns: TableColumns | str | None = None,
376+
) -> None:
377+
"""Write the table to a TSV file.
378+
379+
:param file_path:
380+
The file to write to. If ``None``, the path is generated from
381+
:attr:`rel_path` and :attr:`base_path`.
382+
:param columns:
383+
The columns to include. See :meth:`to_df`.
384+
"""
385+
df = self.to_df(columns=columns)
283386
df.to_csv(
284387
file_path or _generate_path(self.rel_path, self.base_path),
285388
sep="\t",
@@ -421,8 +524,13 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> ObservableTable:
421524
]
422525
return cls(observables, **kwargs)
423526

424-
def to_df(self) -> pd.DataFrame:
527+
def _to_df(self) -> pd.DataFrame:
425528
"""Convert the ObservableTable to a DataFrame."""
529+
if not self.observables:
530+
return pd.DataFrame(columns=self.standard_columns()).set_index(
531+
[C.OBSERVABLE_ID]
532+
)
533+
426534
records = self.model_dump(by_alias=True)["elements"]
427535
for record in records:
428536
obs = record[C.OBSERVABLE_FORMULA]
@@ -545,7 +653,26 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> ConditionTable:
545653

546654
return cls(conditions, **kwargs)
547655

548-
def to_df(self) -> pd.DataFrame:
656+
@classmethod
657+
def standard_columns(cls) -> list[str]:
658+
"""The standard columns of this table, in the default order."""
659+
return [C.CONDITION_ID, *super().standard_columns()]
660+
661+
@classmethod
662+
def required_columns(cls) -> list[str]:
663+
"""The columns that are always included in this table."""
664+
return [C.CONDITION_ID, *super().required_columns()]
665+
666+
@classmethod
667+
def _row_class(cls) -> type[BaseModel]:
668+
return Change
669+
670+
def _rows(self) -> Iterable[BaseModel]:
671+
return chain.from_iterable(
672+
condition.changes for condition in self.conditions
673+
)
674+
675+
def _to_df(self) -> pd.DataFrame:
549676
"""Convert the ConditionTable to a DataFrame."""
550677
records = [
551678
{C.CONDITION_ID: condition.id, **change.model_dump(by_alias=True)}
@@ -561,7 +688,7 @@ def to_df(self) -> pd.DataFrame:
561688
return (
562689
pd.DataFrame(records)
563690
if records
564-
else pd.DataFrame(columns=C.CONDITION_DF_REQUIRED_COLS)
691+
else pd.DataFrame(columns=self.standard_columns())
565692
)
566693

567694
@property
@@ -709,7 +836,26 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> ExperimentTable:
709836

710837
return cls(experiments, **kwargs)
711838

712-
def to_df(self) -> pd.DataFrame:
839+
@classmethod
840+
def standard_columns(cls) -> list[str]:
841+
"""The standard columns of this table, in the default order."""
842+
return [C.EXPERIMENT_ID, C.TIME, C.CONDITION_ID]
843+
844+
@classmethod
845+
def required_columns(cls) -> list[str]:
846+
"""The columns that are always included in this table."""
847+
return cls.standard_columns()
848+
849+
@classmethod
850+
def _row_class(cls) -> type[BaseModel]:
851+
return ExperimentPeriod
852+
853+
def _rows(self) -> Iterable[BaseModel]:
854+
return chain.from_iterable(
855+
experiment.periods for experiment in self.experiments
856+
)
857+
858+
def _to_df(self) -> pd.DataFrame:
713859
"""Convert the ExperimentTable to a DataFrame."""
714860
records = [
715861
{
@@ -724,7 +870,7 @@ def to_df(self) -> pd.DataFrame:
724870
return (
725871
pd.DataFrame(records)
726872
if records
727-
else pd.DataFrame(columns=C.EXPERIMENT_DF_REQUIRED_COLS)
873+
else pd.DataFrame(columns=self.standard_columns())
728874
)
729875

730876

@@ -745,7 +891,9 @@ class Measurement(BaseModel):
745891
)
746892
#: The experiment ID.
747893
experiment_id: Annotated[
748-
str | None, BeforeValidator(_valid_petab_id_or_none)
894+
str | None,
895+
BeforeValidator(_valid_petab_id_or_none),
896+
_REQUIRED_COLUMN,
749897
] = Field(alias=C.EXPERIMENT_ID, default=None)
750898
#: The time point of the measurement in time units as defined in the model.
751899
time: Annotated[float, AfterValidator(_is_finite_or_pos_inf)] = Field(
@@ -829,8 +977,11 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> MeasurementTable:
829977

830978
return cls(measurements, **kwargs)
831979

832-
def to_df(self) -> pd.DataFrame:
980+
def _to_df(self) -> pd.DataFrame:
833981
"""Convert the MeasurementTable to a DataFrame."""
982+
if not self.measurements:
983+
return pd.DataFrame(columns=self.standard_columns())
984+
834985
records = self.model_dump(by_alias=True)["elements"]
835986
for record in records:
836987
record[C.OBSERVABLE_PARAMETERS] = C.PARAMETER_SEPARATOR.join(
@@ -851,9 +1002,9 @@ class Mapping(BaseModel):
8511002
alias=C.PETAB_ENTITY_ID
8521003
)
8531004
#: Model entity ID.
854-
model_id: Annotated[str | None, BeforeValidator(_convert_nan_to_none)] = (
855-
Field(alias=C.MODEL_ENTITY_ID, default=None)
856-
)
1005+
model_id: Annotated[
1006+
str | None, BeforeValidator(_convert_nan_to_none), _REQUIRED_COLUMN
1007+
] = Field(alias=C.MODEL_ENTITY_ID, default=None)
8571008
#: Arbitrary name
8581009
name: Annotated[str | None, BeforeValidator(_convert_nan_to_none)] = Field(
8591010
alias=C.NAME, default=None
@@ -900,12 +1051,12 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> MappingTable:
9001051
]
9011052
return cls(mappings, **kwargs)
9021053

903-
def to_df(self) -> pd.DataFrame:
1054+
def _to_df(self) -> pd.DataFrame:
9041055
"""Convert the MappingTable to a DataFrame."""
9051056
res = (
9061057
pd.DataFrame(self.model_dump(by_alias=True)["elements"])
9071058
if self.mappings
908-
else pd.DataFrame(columns=C.MAPPING_DF_REQUIRED_COLS)
1059+
else pd.DataFrame(columns=self.standard_columns())
9091060
)
9101061
return res.set_index([C.PETAB_ENTITY_ID])
9111062

@@ -931,6 +1082,10 @@ class Parameter(BaseModel):
9311082
id: Annotated[str, BeforeValidator(_valid_petab_id)] = Field(
9321083
alias=C.PARAMETER_ID
9331084
)
1085+
#: Parameter name.
1086+
name: Annotated[str | None, BeforeValidator(_convert_nan_to_none)] = Field(
1087+
alias=C.PARAMETER_NAME, default=None
1088+
)
9341089
#: Lower bound.
9351090
lb: Annotated[float | None, BeforeValidator(_convert_nan_to_none)] = Field(
9361091
alias=C.LOWER_BOUND, default=None
@@ -945,7 +1100,9 @@ class Parameter(BaseModel):
9451100
float | Literal["array"] | None, BeforeValidator(_convert_nan_to_none)
9461101
] = Field(alias=C.NOMINAL_VALUE, default=None)
9471102
#: Is the parameter to be estimated?
948-
estimate: bool = Field(alias=C.ESTIMATE, default=True)
1103+
estimate: Annotated[bool, _REQUIRED_COLUMN] = Field(
1104+
alias=C.ESTIMATE, default=True
1105+
)
9491106
#: Type of parameter prior distribution.
9501107
prior_distribution: Annotated[
9511108
PriorDistribution | None, BeforeValidator(_convert_nan_to_none)
@@ -1111,11 +1268,14 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> ParameterTable:
11111268

11121269
return cls(parameters, **kwargs)
11131270

1114-
def to_df(self) -> pd.DataFrame:
1271+
def _to_df(self) -> pd.DataFrame:
11151272
"""Convert the ParameterTable to a DataFrame."""
1116-
return pd.DataFrame(
1117-
self.model_dump(by_alias=True)["elements"]
1118-
).set_index([C.PARAMETER_ID])
1273+
res = (
1274+
pd.DataFrame(self.model_dump(by_alias=True)["elements"])
1275+
if self.parameters
1276+
else pd.DataFrame(columns=self.standard_columns())
1277+
)
1278+
return res.set_index([C.PARAMETER_ID])
11191279

11201280
@property
11211281
def n_estimated(self) -> int:
@@ -1423,7 +1583,12 @@ def get_problem(problem: str | Path | Problem) -> Problem:
14231583
"or a PEtab problem object."
14241584
)
14251585

1426-
def to_files(self, base_path: str | Path | None) -> None:
1586+
def to_files(
1587+
self,
1588+
base_path: str | Path | None,
1589+
*,
1590+
columns: TableColumns | str | None = None,
1591+
) -> None:
14271592
"""Write the PEtab problem to files.
14281593
14291594
Writes the model, condition, experiment, measurement, parameter,
@@ -1437,6 +1602,9 @@ def to_files(self, base_path: str | Path | None) -> None:
14371602
The base path the yaml file and tables will be written to.
14381603
If ``None``, the `base_path` of the individual tables and
14391604
:obj:`Problem.config.base_path` will be used.
1605+
:param columns:
1606+
The columns to include in the tables.
1607+
See :meth:`BaseTable.to_df`.
14401608
"""
14411609
config = copy.deepcopy(self.config) or ProblemConfig(
14421610
format_version="2.0.0"
@@ -1491,7 +1659,8 @@ def to_files(self, base_path: str | Path | None) -> None:
14911659
table.to_tsv(
14921660
_generate_path(
14931661
table.rel_path, base_path or table.base_path
1494-
)
1662+
),
1663+
columns=columns,
14951664
)
14961665

14971666
config.to_yaml(
@@ -2359,6 +2528,7 @@ def model_dump(self, **kwargs) -> dict[str, Any]:
23592528
'parameters': [{'estimate': 'true',
23602529
'id': 'par',
23612530
'lb': 0.0,
2531+
'name': None,
23622532
'nominal_value': None,
23632533
'prior_distribution': '',
23642534
'prior_parameters': '',

0 commit comments

Comments
 (0)