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
@@ -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+
126145def _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