88import tempfile
99import traceback
1010from abc import abstractmethod
11- from collections .abc import Sequence
11+ from collections .abc import Iterable , Sequence
1212from enum import StrEnum
1313from itertools import chain
14- from math import nan
14+ from math import isnan , nan
1515from numbers import Number
1616from pathlib import Path
1717from typing import (
5454from ..versions import parse_version
5555from . import C , get_observable_df
5656from .extensions import ExtensionConfig , parse_extension_config
57+ from .options import TableColumns , get_options
5758
5859if 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+
118137def _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