88import tempfile
99import traceback
1010from abc import abstractmethod
11- from collections .abc import Sequence
11+ from collections .abc import Iterable , Sequence
1212from enum import Enum
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 (
5555from ..versions import parse_version
5656from . import C , get_observable_df
5757from .extensions import ExtensionConfig , parse_extension_config
58+ from .options import TableColumns , get_options
5859
5960if 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+
119138def _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