Skip to content

Commit dfadf25

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 1c3d7b9 commit dfadf25

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
@@ -115,6 +116,24 @@ def _convert_nan_to_none(v):
115116
return v
116117

117118

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

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

269360
@classmethod
270361
def from_tsv(
@@ -274,9 +365,21 @@ def from_tsv(
274365
df = pd.read_csv(_generate_path(file_path, base_path), sep="\t")
275366
return cls.from_df(df, rel_path=file_path, base_path=base_path)
276367

277-
def to_tsv(self, file_path: str | Path | None = None) -> None:
278-
"""Write the table to a TSV file."""
279-
df = self.to_df()
368+
def to_tsv(
369+
self,
370+
file_path: str | Path | None = None,
371+
*,
372+
columns: TableColumns | str | None = None,
373+
) -> None:
374+
"""Write the table to a TSV file.
375+
376+
:param file_path:
377+
The file to write to. If ``None``, the path is generated from
378+
:attr:`rel_path` and :attr:`base_path`.
379+
:param columns:
380+
The columns to include. See :meth:`to_df`.
381+
"""
382+
df = self.to_df(columns=columns)
280383
df.to_csv(
281384
file_path or _generate_path(self.rel_path, self.base_path),
282385
sep="\t",
@@ -418,8 +521,13 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> ObservableTable:
418521
]
419522
return cls(observables, **kwargs)
420523

421-
def to_df(self) -> pd.DataFrame:
524+
def _to_df(self) -> pd.DataFrame:
422525
"""Convert the ObservableTable to a DataFrame."""
526+
if not self.observables:
527+
return pd.DataFrame(columns=self.standard_columns()).set_index(
528+
[C.OBSERVABLE_ID]
529+
)
530+
423531
records = self.model_dump(by_alias=True)["elements"]
424532
for record in records:
425533
obs = record[C.OBSERVABLE_FORMULA]
@@ -542,7 +650,26 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> ConditionTable:
542650

543651
return cls(conditions, **kwargs)
544652

545-
def to_df(self) -> pd.DataFrame:
653+
@classmethod
654+
def standard_columns(cls) -> list[str]:
655+
"""The standard columns of this table, in the default order."""
656+
return [C.CONDITION_ID, *super().standard_columns()]
657+
658+
@classmethod
659+
def required_columns(cls) -> list[str]:
660+
"""The columns that are always included in this table."""
661+
return [C.CONDITION_ID, *super().required_columns()]
662+
663+
@classmethod
664+
def _row_class(cls) -> type[BaseModel]:
665+
return Change
666+
667+
def _rows(self) -> Iterable[BaseModel]:
668+
return chain.from_iterable(
669+
condition.changes for condition in self.conditions
670+
)
671+
672+
def _to_df(self) -> pd.DataFrame:
546673
"""Convert the ConditionTable to a DataFrame."""
547674
records = [
548675
{C.CONDITION_ID: condition.id, **change.model_dump(by_alias=True)}
@@ -558,7 +685,7 @@ def to_df(self) -> pd.DataFrame:
558685
return (
559686
pd.DataFrame(records)
560687
if records
561-
else pd.DataFrame(columns=C.CONDITION_DF_REQUIRED_COLS)
688+
else pd.DataFrame(columns=self.standard_columns())
562689
)
563690

564691
@property
@@ -706,7 +833,26 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> ExperimentTable:
706833

707834
return cls(experiments, **kwargs)
708835

709-
def to_df(self) -> pd.DataFrame:
836+
@classmethod
837+
def standard_columns(cls) -> list[str]:
838+
"""The standard columns of this table, in the default order."""
839+
return [C.EXPERIMENT_ID, C.TIME, C.CONDITION_ID]
840+
841+
@classmethod
842+
def required_columns(cls) -> list[str]:
843+
"""The columns that are always included in this table."""
844+
return cls.standard_columns()
845+
846+
@classmethod
847+
def _row_class(cls) -> type[BaseModel]:
848+
return ExperimentPeriod
849+
850+
def _rows(self) -> Iterable[BaseModel]:
851+
return chain.from_iterable(
852+
experiment.periods for experiment in self.experiments
853+
)
854+
855+
def _to_df(self) -> pd.DataFrame:
710856
"""Convert the ExperimentTable to a DataFrame."""
711857
records = [
712858
{
@@ -721,7 +867,7 @@ def to_df(self) -> pd.DataFrame:
721867
return (
722868
pd.DataFrame(records)
723869
if records
724-
else pd.DataFrame(columns=C.EXPERIMENT_DF_REQUIRED_COLS)
870+
else pd.DataFrame(columns=self.standard_columns())
725871
)
726872

727873

@@ -742,7 +888,9 @@ class Measurement(BaseModel):
742888
)
743889
#: The experiment ID.
744890
experiment_id: Annotated[
745-
str | None, BeforeValidator(_valid_petab_id_or_none)
891+
str | None,
892+
BeforeValidator(_valid_petab_id_or_none),
893+
_REQUIRED_COLUMN,
746894
] = Field(alias=C.EXPERIMENT_ID, default=None)
747895
#: The time point of the measurement in time units as defined in the model.
748896
time: Annotated[float, AfterValidator(_is_finite_or_pos_inf)] = Field(
@@ -826,8 +974,11 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> MeasurementTable:
826974

827975
return cls(measurements, **kwargs)
828976

829-
def to_df(self) -> pd.DataFrame:
977+
def _to_df(self) -> pd.DataFrame:
830978
"""Convert the MeasurementTable to a DataFrame."""
979+
if not self.measurements:
980+
return pd.DataFrame(columns=self.standard_columns())
981+
831982
records = self.model_dump(by_alias=True)["elements"]
832983
for record in records:
833984
record[C.OBSERVABLE_PARAMETERS] = C.PARAMETER_SEPARATOR.join(
@@ -848,9 +999,9 @@ class Mapping(BaseModel):
848999
alias=C.PETAB_ENTITY_ID
8491000
)
8501001
#: Model entity ID.
851-
model_id: Annotated[str | None, BeforeValidator(_convert_nan_to_none)] = (
852-
Field(alias=C.MODEL_ENTITY_ID, default=None)
853-
)
1002+
model_id: Annotated[
1003+
str | None, BeforeValidator(_convert_nan_to_none), _REQUIRED_COLUMN
1004+
] = Field(alias=C.MODEL_ENTITY_ID, default=None)
8541005
#: Arbitrary name
8551006
name: Annotated[str | None, BeforeValidator(_convert_nan_to_none)] = Field(
8561007
alias=C.NAME, default=None
@@ -897,12 +1048,12 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> MappingTable:
8971048
]
8981049
return cls(mappings, **kwargs)
8991050

900-
def to_df(self) -> pd.DataFrame:
1051+
def _to_df(self) -> pd.DataFrame:
9011052
"""Convert the MappingTable to a DataFrame."""
9021053
res = (
9031054
pd.DataFrame(self.model_dump(by_alias=True)["elements"])
9041055
if self.mappings
905-
else pd.DataFrame(columns=C.MAPPING_DF_REQUIRED_COLS)
1056+
else pd.DataFrame(columns=self.standard_columns())
9061057
)
9071058
return res.set_index([C.PETAB_ENTITY_ID])
9081059

@@ -928,6 +1079,10 @@ class Parameter(BaseModel):
9281079
id: Annotated[str, BeforeValidator(_valid_petab_id)] = Field(
9291080
alias=C.PARAMETER_ID
9301081
)
1082+
#: Parameter name.
1083+
name: Annotated[str | None, BeforeValidator(_convert_nan_to_none)] = Field(
1084+
alias=C.PARAMETER_NAME, default=None
1085+
)
9311086
#: Lower bound.
9321087
lb: Annotated[float | None, BeforeValidator(_convert_nan_to_none)] = Field(
9331088
alias=C.LOWER_BOUND, default=None
@@ -942,7 +1097,9 @@ class Parameter(BaseModel):
9421097
float | Literal["array"] | None, BeforeValidator(_convert_nan_to_none)
9431098
] = Field(alias=C.NOMINAL_VALUE, default=None)
9441099
#: Is the parameter to be estimated?
945-
estimate: bool = Field(alias=C.ESTIMATE, default=True)
1100+
estimate: Annotated[bool, _REQUIRED_COLUMN] = Field(
1101+
alias=C.ESTIMATE, default=True
1102+
)
9461103
#: Type of parameter prior distribution.
9471104
prior_distribution: Annotated[
9481105
PriorDistribution | None, BeforeValidator(_convert_nan_to_none)
@@ -1108,11 +1265,14 @@ def from_df(cls, df: pd.DataFrame, **kwargs) -> ParameterTable:
11081265

11091266
return cls(parameters, **kwargs)
11101267

1111-
def to_df(self) -> pd.DataFrame:
1268+
def _to_df(self) -> pd.DataFrame:
11121269
"""Convert the ParameterTable to a DataFrame."""
1113-
return pd.DataFrame(
1114-
self.model_dump(by_alias=True)["elements"]
1115-
).set_index([C.PARAMETER_ID])
1270+
res = (
1271+
pd.DataFrame(self.model_dump(by_alias=True)["elements"])
1272+
if self.parameters
1273+
else pd.DataFrame(columns=self.standard_columns())
1274+
)
1275+
return res.set_index([C.PARAMETER_ID])
11161276

11171277
@property
11181278
def n_estimated(self) -> int:
@@ -1422,7 +1582,12 @@ def get_problem(problem: str | Path | Problem) -> Problem:
14221582
"or a PEtab problem object."
14231583
)
14241584

1425-
def to_files(self, base_path: str | Path | None) -> None:
1585+
def to_files(
1586+
self,
1587+
base_path: str | Path | None,
1588+
*,
1589+
columns: TableColumns | str | None = None,
1590+
) -> None:
14261591
"""Write the PEtab problem to files.
14271592
14281593
Writes the model, condition, experiment, measurement, parameter,
@@ -1436,6 +1601,9 @@ def to_files(self, base_path: str | Path | None) -> None:
14361601
The base path the yaml file and tables will be written to.
14371602
If ``None``, the `base_path` of the individual tables and
14381603
:obj:`Problem.config.base_path` will be used.
1604+
:param columns:
1605+
The columns to include in the tables.
1606+
See :meth:`BaseTable.to_df`.
14391607
"""
14401608
config = copy.deepcopy(self.config) or ProblemConfig(
14411609
format_version="2.0.0"
@@ -1490,7 +1658,8 @@ def to_files(self, base_path: str | Path | None) -> None:
14901658
table.to_tsv(
14911659
_generate_path(
14921660
table.rel_path, base_path or table.base_path
1493-
)
1661+
),
1662+
columns=columns,
14941663
)
14951664

14961665
config.to_yaml(
@@ -2569,6 +2738,7 @@ def model_dump(self, **kwargs) -> dict[str, Any]:
25692738
'parameters': [{'estimate': 'true',
25702739
'id': 'par',
25712740
'lb': 0.0,
2741+
'name': None,
25722742
'nominal_value': None,
25732743
'prior_distribution': '',
25742744
'prior_parameters': '',

0 commit comments

Comments
 (0)