Repository: kutaslab/fitgrid

Issue:
transition away from library level constants to parameters
I don't want to use `EPOCH_ID`, `TIME`, or `CHANNELS` as top level constants as an interface.

Instead of doing:

```python
fitgrid.EPOCH_ID = 'epoch_id'
```

the user should be able to do:

```python
epochs = fitgrid.epochs_from_hdf('file.h5', epoch_id='epoch_id')
```

and `epoch_id` would become an object parameter. 

Same for `TIME` and `CHANNELS`.

Discussion:

Patch:
diff --git a/fitgrid/__init__.py b/fitgrid/__init__.py
index 9a2b55e..f2e45fa 100644
--- a/fitgrid/__init__.py
+++ b/fitgrid/__init__.py
@@ -6,46 +6,4 @@ from .io import (
     epochs_from_feather,
 )
 from .models import run_model, lm, lmer
-from . import utils
-
-#: default epoch identifier
-EPOCH_ID = 'Epoch_idx'
-
-#: default time identifier
-TIME = 'Time'
-
-#: default channel list
-CHANNELS = [
-    'lle',
-    'lhz',
-    'MiPf',
-    'LLPf',
-    'RLPf',
-    'LMPf',
-    'RMPf',
-    'LDFr',
-    'RDFr',
-    'LLFr',
-    'RLFr',
-    'LMFr',
-    'RMFr',
-    'LMCe',
-    'RMCe',
-    'MiCe',
-    'MiPa',
-    'LDCe',
-    'RDCe',
-    'LDPa',
-    'RDPa',
-    'LMOc',
-    'RMOc',
-    'LLTe',
-    'RLTe',
-    'LLOc',
-    'RLOc',
-    'MiOc',
-    'A2',
-    'HEOG',
-    'rle',
-    'rhz',
-]
+from . import utils, defaults
diff --git a/fitgrid/defaults.py b/fitgrid/defaults.py
new file mode 100644
index 0000000..a150744
--- /dev/null
+++ b/fitgrid/defaults.py
@@ -0,0 +1,41 @@
+#: default epoch identifier
+EPOCH_ID = 'Epoch_idx'
+
+#: default time identifier
+TIME = 'Time'
+
+#: default channel list
+CHANNELS = [
+    'lle',
+    'lhz',
+    'MiPf',
+    'LLPf',
+    'RLPf',
+    'LMPf',
+    'RMPf',
+    'LDFr',
+    'RDFr',
+    'LLFr',
+    'RLFr',
+    'LMFr',
+    'RMFr',
+    'LMCe',
+    'RMCe',
+    'MiCe',
+    'MiPa',
+    'LDCe',
+    'RDCe',
+    'LDPa',
+    'RDPa',
+    'LMOc',
+    'RMOc',
+    'LLTe',
+    'RLTe',
+    'LLOc',
+    'RLOc',
+    'MiOc',
+    'A2',
+    'HEOG',
+    'rle',
+    'rhz',
+]
diff --git a/fitgrid/epochs.py b/fitgrid/epochs.py
index f252bc9..8c71cb5 100644
--- a/fitgrid/epochs.py
+++ b/fitgrid/epochs.py
@@ -22,12 +22,7 @@ class Epochs:
 
     """
 
-    def __init__(self, epochs_table, channels='default'):
-
-        from . import EPOCH_ID, TIME, CHANNELS
-
-        if channels == 'default':
-            channels = CHANNELS
+    def __init__(self, epochs_table, time, epoch_id, channels):
 
         # channels must be a list of strings
         if not isinstance(channels, list) or not all(
@@ -47,7 +42,7 @@ class Epochs:
             raise FitGridError('epochs_table must be a Pandas DataFrame.')
 
         # these index columns are required for consistency checks
-        for item in (EPOCH_ID, TIME):
+        for item in (epoch_id, time):
             if item not in epochs_table.index.names:
                 raise FitGridError(
                     f'{item} must be a column in the epochs table index.'
@@ -61,11 +56,11 @@ class Epochs:
 
         # make our own copy so we are immune to modification to original table
         table = (
-            epochs_table.copy().reset_index().set_index(EPOCH_ID).sort_index()
+            epochs_table.copy().reset_index().set_index(epoch_id).sort_index()
         )
-        assert table.index.names == [EPOCH_ID]
+        assert table.index.names == [epoch_id]
 
-        snapshots = table.groupby(TIME)
+        snapshots = table.groupby(time)
 
         # check that snapshots across epochs have equal index by transitivity
         prev_group = None
@@ -74,7 +69,7 @@ class Epochs:
                 if not prev_group.index.equals(cur_group.index):
                     raise FitGridError(
                         f'Snapshot {idx} differs from '
-                        f'previous snapshot in {EPOCH_ID} index:\n'
+                        f'previous snapshot in {epoch_id} index:\n'
                         f'Current snapshot\'s indices:\n'
                         f'{cur_group.index}\n'
                         f'Previous snapshot\'s indices:\n'
@@ -83,16 +78,18 @@ class Epochs:
             prev_group = cur_group
 
         if not prev_group.index.is_unique:
-            dupes = tools.get_index_duplicates_table(table, EPOCH_ID)
+            dupes = tools.get_index_duplicates_table(table, epoch_id)
             raise FitGridError(
-                f'Duplicate values in {EPOCH_ID} index not allowed:\n{dupes}'
+                f'Duplicate values in {epoch_id} index not allowed:\n{dupes}'
             )
 
         # checks passed, set instance variables
+        self.time = time
+        self.epoch_id = epoch_id
         self.channels = channels
         self.table = table
         self._snapshots = snapshots
-        self._epoch_index = tools.get_first_group(snapshots).index.copy()
+        self.epoch_index = tools.get_first_group(snapshots).index.copy()
 
     def distances(self):
         """Return scaled Euclidean distances of epochs from the "mean" epoch.
@@ -107,15 +104,13 @@ class Epochs:
         Distances are scaled by dividing by the max.
         """
 
-        from . import EPOCH_ID, TIME
-
-        table = self.table.reset_index().set_index([EPOCH_ID, TIME])[
+        table = self.table.reset_index().set_index([self.epoch_id, self.time])[
             self.channels
         ]
 
         n_channels = len(table.columns)
-        n_epochs = len(table.index.unique(level=EPOCH_ID))
-        n_samples = len(table.index.unique(level=TIME))
+        n_epochs = len(table.index.unique(level=self.epoch_id))
+        n_samples = len(table.index.unique(level=self.time))
 
         assert table.values.size == n_channels * n_epochs * n_samples
         values = table.values.reshape(n_epochs, n_samples, n_channels)
@@ -129,7 +124,7 @@ class Epochs:
         # first n_samples is axis 1, then n_channels, leaving epochs
         distances_arr = l2_norm(l2_norm(diff))
         distances_arr_scaled = distances_arr / distances_arr.max()
-        distances = pd.Series(distances_arr_scaled, index=self._epoch_index)
+        distances = pd.Series(distances_arr_scaled, index=self.epoch_index)
 
         return distances
 
diff --git a/fitgrid/fake_data.py b/fitgrid/fake_data.py
index b8c2dba..c7926f8 100644
--- a/fitgrid/fake_data.py
+++ b/fitgrid/fake_data.py
@@ -2,14 +2,21 @@ import numpy as np
 import pandas as pd
 
 from .epochs import Epochs
+from . import defaults
 
 
-def generate(n_epochs=10, n_samples=100, n_categories=2, n_channels=32):
+def generate(
+    n_epochs=10,
+    n_samples=100,
+    n_categories=2,
+    n_channels=32,
+    time=defaults.TIME,
+    epoch_id=defaults.EPOCH_ID,
+):
     """Return Epochs object with fake EEG data.
 
     Parameters
     ----------
-
     n_epochs : int
         number of epochs per category to be generated
     n_samples : int
@@ -18,16 +25,19 @@ def generate(n_epochs=10, n_samples=100, n_categories=2, n_channels=32):
         number of levels of the categorical variable
     n_channels : int
         number of time series representing EEG channels
+    time : str, defaults to defaults.TIME
+        time column name
+    epoch_id : str, defaults to defaults.EPOCH_ID
+        epoch identifier column name
+
 
     Returns
     -------
-
     epochs : Epochs object
         Epochs object containing simulated EEG data.
 
     Notes
     -----
-
     ``n_epochs`` and ``n_categories`` interact in the sense that ``n_epochs``
     epochs are generated for each level of the categorical variable. In other
     words, the true number of epochs in the generated data is equal to
@@ -37,22 +47,22 @@ def generate(n_epochs=10, n_samples=100, n_categories=2, n_channels=32):
     = 2`` produces 20 epochs, 10 per category.
     """
 
-    df, channels = _generate(n_epochs, n_samples, n_categories, n_channels)
-    return Epochs(df, channels)
+    df, channels = _generate(
+        n_epochs, n_samples, n_categories, n_channels, time, epoch_id
+    )
+    return Epochs(df, time=time, epoch_id=epoch_id, channels=channels)
 
 
-def _generate(n_epochs, n_samples, n_categories, n_channels):
+def _generate(n_epochs, n_samples, n_categories, n_channels, time, epoch_id):
     """Return Pandas DataFrame with fake EEG data, and a list of channels."""
 
-    from . import EPOCH_ID, TIME
-
     total = n_epochs * n_samples * n_categories
 
     categories = np.array([f'cat{i}' for i in range(n_categories)])
 
     indices = {
-        EPOCH_ID: np.repeat(np.arange(n_epochs * n_categories), n_samples),
-        TIME: np.tile(np.arange(n_samples), n_epochs * n_categories),
+        epoch_id: np.repeat(np.arange(n_epochs * n_categories), n_samples),
+        time: np.tile(np.arange(n_samples), n_epochs * n_categories),
     }
 
     predictors = {
@@ -68,6 +78,6 @@ def _generate(n_epochs, n_samples, n_categories, n_channels):
 
     data = {**indices, **predictors, **eeg}
 
-    df = pd.DataFrame(data).set_index([EPOCH_ID, TIME]).sort_index()
+    df = pd.DataFrame(data).set_index([epoch_id, time]).sort_index()
 
     return df, channels
diff --git a/fitgrid/fitgrid.py b/fitgrid/fitgrid.py
index 3c69615..2e357e2 100644
--- a/fitgrid/fitgrid.py
+++ b/fitgrid/fitgrid.py
@@ -8,110 +8,6 @@ from .errors import FitGridError
 from . import tools
 
 
-def add_epoch_index(temp, epoch_index):
-    """We assume that temp is in long form, the columns are channels, and the
-    first index level is TIME."""
-
-    from . import TIME
-
-    # first index level must be TIME
-    assert temp.index.names[0] == TIME
-
-    # temp should be long form, columns have single level (channels hopefully)
-    assert not isinstance(temp.columns, pd.core.index.MultiIndex)
-
-    # we can only handle 2- or 3-dimensional
-    assert temp.index.nlevels in (2, 3)
-
-    for i in range(1, temp.index.nlevels):
-        level = temp.index.levels[i]
-        # if a level looks like it was automatically created by Pandas,
-        # we replace it with the epoch_index
-        if (
-            isinstance(level, pd.RangeIndex)
-            and len(level) == len(epoch_index)
-            and level._start == 0
-            and level._step == 1
-            and level._stop == len(epoch_index)
-        ):
-            temp.index.set_levels(epoch_index, level=i, inplace=True)
-            temp.index.rename(epoch_index.name, level=i, inplace=True)
-
-    return temp
-
-
-def expand_series_or_df(temp):
-    """Expand a DataFrame that has Series or DataFrames for values."""
-
-    columns = (
-        pd.concat(temp[channel].tolist(), keys=temp.index) for channel in temp
-    )
-    # concatenate columns, channel names are top level columns indices
-    result = pd.concat(columns, axis=1, keys=temp.columns)
-
-    # stack to achieve long form if columns have multiple levels
-    if isinstance(result.columns, pd.core.indexes.multi.MultiIndex):
-        return result.stack()
-    return result
-
-
-def _expand(temp, epoch_index):
-    """Expand the values in the grid if possible, return frame or grid."""
-
-    tester = temp.iloc[0, 0]
-
-    # no processing needed
-    if np.isscalar(tester):
-        return temp
-
-    # familiar types, expand them
-    if isinstance(tester, pd.Series) or isinstance(tester, pd.DataFrame):
-        # want single index level
-        # can get more if original DataFrame had a multiindex
-        # in Epochs we ensure that only EPOCH_ID is in the index for groupby
-        if tester.index.nlevels > 1:
-            raise NotImplementedError(
-                f'index should have one level, have {tester.index.nlevels} '
-                f'instead: {tester.index.names}'
-            )
-        return expand_series_or_df(temp)
-
-    # array-like, try converting to array and then Series/DataFrame
-    if isinstance(tester, tuple) or isinstance(tester, list):
-        array_form = np.array(tester)
-        if array_form.ndim == 1:
-            temp = temp.applymap(lambda x: pd.Series(np.array(x)))
-        elif array_form.ndim == 2:
-            temp = temp.applymap(lambda x: pd.DataFrame(np.array(x)))
-        else:
-            raise NotImplementedError(
-                'Cannot use elements with dim > 2,'
-                f'element has ndim = {array_form.ndim}.'
-            )
-        temp_expanded = expand_series_or_df(temp)
-        temp_epoch_index = add_epoch_index(temp_expanded, epoch_index)
-        return temp_epoch_index
-
-    # array, try converting to Series/DataFrame
-    if isinstance(tester, np.ndarray):
-        if tester.ndim == 1:
-            temp = temp.applymap(lambda x: pd.Series(x))
-        elif tester.ndim == 2:
-            temp = temp.applymap(lambda x: pd.DataFrame(x))
-        else:
-            raise NotImplementedError(
-                'Cannot use elements with dim > 2,'
-                f'element has ndim = {tester.ndim}.'
-            )
-        temp_expanded = expand_series_or_df(temp)
-        temp_epoch_index = add_epoch_index(temp_expanded, epoch_index)
-        return temp_epoch_index
-
-    # catchall for all types we don't handle explicitly
-    # statsmodels objects, dicts, methods all go here
-    return FitGrid(temp, epoch_index)
-
-
 class FitGrid:
     """Hold rERP fit objects.
 
@@ -153,7 +49,7 @@ class FitGrid:
     throws a warning and should in future be replaced with a KeyError.
     """
 
-    def __init__(self, _grid, epoch_index):
+    def __init__(self, _grid, epoch_index, time):
 
         # check no duplicate column names
         names = list(_grid.columns)
@@ -161,7 +57,8 @@ class FitGrid:
         if deduped_names != names:
             raise FitGridError('Duplicate column names not allowed.')
         self._grid = _grid
-        self._epoch_index = epoch_index
+        self.epoch_index = epoch_index
+        self.time = time
         self.tester = _grid.iloc[0, 0]
         self.channels = list(_grid.columns)
 
@@ -212,7 +109,7 @@ class FitGrid:
         time = check_slicer_component(time)
         channels = check_slicer_component(channels)
         subgrid = self._grid.loc[time, channels].copy()
-        return self.__class__(subgrid, self._epoch_index)
+        return self.__class__(subgrid, self.epoch_index, self.time)
 
     @lru_cache()
     def __getattr__(self, name):
@@ -227,7 +124,7 @@ class FitGrid:
             raise AttributeError(f'No such attribute: {name}.')
 
         temp = self._grid.applymap(lambda x: getattr(x, name))
-        return _expand(temp, self._epoch_index)
+        return self._expand(temp)
 
     def __call__(self, *args, **kwargs):
         """Broadcast method calling in the grid.
@@ -243,7 +140,7 @@ class FitGrid:
 
         # if we are not callable, we'll get an appropriate exception
         temp = self._grid.applymap(lambda x: x(*args, **kwargs))
-        return _expand(temp, self._epoch_index)
+        return self._expand(temp)
 
     def __dir__(self):
 
@@ -276,9 +173,111 @@ class FitGrid:
         """
 
         with open(filename, 'wb') as file:
-            kernel = self._grid, self._epoch_index
+            kernel = self._grid, self.epoch_index, self.time
             pickle.dump(kernel, file, protocol=pickle.HIGHEST_PROTOCOL)
 
+    def expand_series_or_df(self, temp):
+        """Expand a DataFrame that has Series or DataFrames for values."""
+
+        columns = (
+            pd.concat(temp[channel].tolist(), keys=temp.index)
+            for channel in temp
+        )
+        # concatenate columns, channel names are top level columns indices
+        result = pd.concat(columns, axis=1, keys=temp.columns)
+
+        # stack to achieve long form if columns have multiple levels
+        if isinstance(result.columns, pd.core.indexes.multi.MultiIndex):
+            return result.stack()
+        return result
+
+    def _expand(self, temp):
+        """Expand the values in the grid if possible, return frame or grid."""
+
+        tester = temp.iloc[0, 0]
+
+        # no processing needed
+        if np.isscalar(tester):
+            return temp
+
+        # familiar types, expand them
+        if isinstance(tester, pd.Series) or isinstance(tester, pd.DataFrame):
+            # want single index level
+            # can get more if original DataFrame had a multiindex
+            # in Epochs we ensure that only EPOCH_ID is in the index for
+            # groupby
+            if tester.index.nlevels > 1:
+                raise NotImplementedError(
+                    f'index should have one level, have {tester.index.nlevels}'
+                    f' instead: {tester.index.names}'
+                )
+            return self.expand_series_or_df(temp)
+
+        # array-like, try converting to array and then Series/DataFrame
+        if isinstance(tester, tuple) or isinstance(tester, list):
+            array_form = np.array(tester)
+            if array_form.ndim == 1:
+                temp = temp.applymap(lambda x: pd.Series(np.array(x)))
+            elif array_form.ndim == 2:
+                temp = temp.applymap(lambda x: pd.DataFrame(np.array(x)))
+            else:
+                raise NotImplementedError(
+                    'Cannot use elements with dim > 2,'
+                    f'element has ndim = {array_form.ndim}.'
+                )
+            temp_expanded = self.expand_series_or_df(temp)
+            temp_epoch_index = self.add_epoch_index(temp_expanded)
+            return temp_epoch_index
+
+        # array, try converting to Series/DataFrame
+        if isinstance(tester, np.ndarray):
+            if tester.ndim == 1:
+                temp = temp.applymap(lambda x: pd.Series(x))
+            elif tester.ndim == 2:
+                temp = temp.applymap(lambda x: pd.DataFrame(x))
+            else:
+                raise NotImplementedError(
+                    'Cannot use elements with dim > 2,'
+                    f'element has ndim = {tester.ndim}.'
+                )
+            temp_expanded = self.expand_series_or_df(temp)
+            temp_epoch_index = self.add_epoch_index(temp_expanded)
+            return temp_epoch_index
+
+        # catchall for all types we don't handle explicitly
+        # statsmodels objects, dicts, methods all go here
+        return FitGrid(temp, self.epoch_index, self.time)
+
+    def add_epoch_index(self, temp):
+        """We assume that temp is in long form, the columns are channels, and the
+        first index level is time."""
+
+        # first index level is time
+        assert temp.index.names[0] == self.time
+
+        # temp should be long form, columns have single level (channels
+        # hopefully)
+        assert not isinstance(temp.columns, pd.core.index.MultiIndex)
+
+        # we can only handle 2- or 3-dimensional
+        assert temp.index.nlevels in (2, 3)
+
+        for i in range(1, temp.index.nlevels):
+            level = temp.index.levels[i]
+            # if a level looks like it was automatically created by Pandas,
+            # we replace it with the epoch_index
+            if (
+                isinstance(level, pd.RangeIndex)
+                and len(level) == len(self.epoch_index)
+                and level._start == 0
+                and level._step == 1
+                and level._stop == len(self.epoch_index)
+            ):
+                temp.index.set_levels(self.epoch_index, level=i, inplace=True)
+                temp.index.rename(self.epoch_index.name, level=i, inplace=True)
+
+        return temp
+
 
 class LMFitGrid(FitGrid):
     def __dir__(self):
diff --git a/fitgrid/io.py b/fitgrid/io.py
index 7dc8133..8abb10d 100644
--- a/fitgrid/io.py
+++ b/fitgrid/io.py
@@ -8,9 +8,10 @@ from statsmodels.regression.linear_model import (
 from .epochs import Epochs
 from .fitgrid import FitGrid, LMFitGrid, LMERFitGrid
 from .errors import FitGridError
+from . import defaults
 
 
-def epochs_from_hdf(hdf_filename, key=None, channels='default'):
+def epochs_from_hdf(hdf_filename, key, time, epoch_id, channels):
     """Construct Epochs object from an HDF5 file containing an epochs table.
 
     The HDF5 file should contain columns with names defined by EPOCH_ID and
@@ -33,25 +34,30 @@ def epochs_from_hdf(hdf_filename, key=None, channels='default'):
         an Epochs object with the data
     """
 
-    from . import EPOCH_ID, TIME
+    if None in (time, epoch_id, channels):
+        raise FitGridError(
+            'Please provide `time`, `epoch_id`, and `channels` parameters.'
+            ' You can use the defaults, for example:\n'
+            'time=fitgrid.defaults.TIME'
+        )
 
     df = pd.read_hdf(hdf_filename, key=key)
 
     # time and epoch id already present in index
-    if EPOCH_ID in df.index.names and TIME in df.index.names:
-        return Epochs(df, channels)
+    if epoch_id in df.index.names and time in df.index.names:
+        return Epochs(df, time=time, epoch_id=epoch_id, channels=channels)
 
     # time and epoch id present in columns, set index
-    if EPOCH_ID in df.columns and TIME in df.columns:
-        df.set_index([EPOCH_ID, TIME], inplace=True)
-        return Epochs(df, channels)
+    if epoch_id in df.columns and time in df.columns:
+        df.set_index([epoch_id, time], inplace=True)
+        return Epochs(df, time=time, epoch_id=epoch_id, channels=channels)
 
     raise FitGridError(
-        f'Dataset has to contain {EPOCH_ID} and {TIME} as columns or indices.'
+        f'Dataset has to contain {epoch_id} and {time} as columns or indices.'
     )
 
 
-def epochs_from_dataframe(dataframe, channels='default'):
+def epochs_from_dataframe(dataframe, time, epoch_id, channels):
     """Construct Epochs object from a Pandas DataFrame epochs table.
 
     The DataFrame should contain columns with names defined by EPOCH_ID and
@@ -69,10 +75,10 @@ def epochs_from_dataframe(dataframe, channels='default'):
     epochs : Epochs
         an Epochs object with the data
     """
-    return Epochs(dataframe, channels)
+    return Epochs(dataframe, time=time, epoch_id=epoch_id, channels=channels)
 
 
-def epochs_from_feather(filename, channels='default'):
+def epochs_from_feather(filename, time, epoch_id, channels):
     """Construct Epochs object from a Feather file containing an epochs table.
 
     The file should contain columns with names defined by EPOCH_ID and TIME.
@@ -92,17 +98,15 @@ def epochs_from_feather(filename, channels='default'):
 
     check_pandas_pyarrow_versions()
 
-    from . import EPOCH_ID, TIME
-
     df = pd.read_feather(filename)
 
     # time and epoch id present in columns, set index
-    if EPOCH_ID in df.columns and TIME in df.columns:
-        df.set_index([EPOCH_ID, TIME], inplace=True)
-        return Epochs(df, channels)
+    if epoch_id in df.columns and time in df.columns:
+        df.set_index([epoch_id, time], inplace=True)
+        return Epochs(df, time=time, epoch_id=epoch_id, channels=channels)
 
     raise FitGridError(
-        f'Dataset has to contain {EPOCH_ID} and {TIME} as columns or indices.'
+        f'Dataset has to contain {epoch_id} and {time} as columns or indices.'
     )
 
 
@@ -123,16 +127,16 @@ def load_grid(filename):
     from pymer4 import Lmer
 
     with open(filename, 'rb') as file:
-        _grid, _epoch_index = pickle.load(file)
+        _grid, epoch_index, time = pickle.load(file)
 
     tester = _grid.iloc[0, 0]
 
     if isinstance(tester, (RegressionResults, RegressionResultsWrapper)):
-        return LMFitGrid(_grid, _epoch_index)
+        return LMFitGrid(_grid, epoch_index, time)
     elif isinstance(tester, Lmer):
-        return LMERFitGrid(_grid, _epoch_index)
+        return LMERFitGrid(_grid, epoch_index, time)
     else:
-        return FitGrid(_grid, _epoch_index)
+        return FitGrid(_grid, epoch_index, time)
 
 
 def check_pandas_pyarrow_versions():
diff --git a/fitgrid/models.py b/fitgrid/models.py
index 5fcda27..f6cf67b 100644
--- a/fitgrid/models.py
+++ b/fitgrid/models.py
@@ -89,13 +89,11 @@ def run_model(epochs, function, channels=None, parallel=False, n_cores=4):
     _grid = _run_model(
         epochs, function, channels=channels, parallel=parallel, n_cores=n_cores
     )
-    return FitGrid(_grid, epochs._epoch_index)
+    return FitGrid(_grid, epochs.epoch_index, epochs.time)
 
 
 def _run_model(epochs, function, channels=None, parallel=False, n_cores=4):
 
-    from . import TIME
-
     if channels is None:
         channels = epochs.channels
 
@@ -116,7 +114,7 @@ def _run_model(epochs, function, channels=None, parallel=False, n_cores=4):
         results = map(processor, groups)
 
     grid = pd.concat(results, axis=1).T
-    grid.index.name = TIME
+    grid.index.name = epochs.time
     return grid  # dataframe, not FitGrid
 
 
@@ -166,7 +164,7 @@ def lm(epochs, LHS=None, RHS=None, parallel=False, n_cores=4, eval_env=4):
         n_cores=n_cores,
     )
 
-    return LMFitGrid(_grid, epochs._epoch_index)
+    return LMFitGrid(_grid, epochs.epoch_index, epochs.time)
 
 
 def lmer_single(
@@ -275,4 +273,4 @@ def lmer(
         epochs, function, channels=LHS, parallel=parallel, n_cores=n_cores
     )
 
-    return LMERFitGrid(_grid, epochs._epoch_index)
+    return LMERFitGrid(_grid, epochs.epoch_index, epochs.time)
diff --git a/fitgrid/utils/lmer.py b/fitgrid/utils/lmer.py
index 6b20c84..96994a0 100644
--- a/fitgrid/utils/lmer.py
+++ b/fitgrid/utils/lmer.py
@@ -424,16 +424,19 @@ def get_lmer_dfbetas(epochs, factor, **kwargs):
 
     """
 
-    from fitgrid import EPOCH_ID, TIME
-
     # get the factor levels
-    table = epochs.table.reset_index().set_index([EPOCH_ID, TIME])
+    table = epochs.table.reset_index().set_index(
+        [epochs.epoch_id, epochs.time]
+    )
     levels = table[factor].unique()
 
     # produce epochs tables with each level left out
     looo_epochs = (
         fitgrid.epochs_from_dataframe(
-            table[table[factor] != level], channels=epochs.channels
+            table[table[factor] != level],
+            time=epochs.time,
+            epoch_id=epochs.epoch_id,
+            channels=epochs.channels,
         )
         for level in levels
     )


Test patch:
diff --git a/tests/test_epochs.py b/tests/test_epochs.py
index c726f01..649ae74 100644
--- a/tests/test_epochs.py
+++ b/tests/test_epochs.py
@@ -2,7 +2,7 @@ import pytest
 import numpy as np
 import pandas as pd
 from .context import fitgrid
-from fitgrid import fake_data
+from fitgrid import fake_data, defaults
 from fitgrid.errors import FitGridError
 from fitgrid.epochs import Epochs
 
@@ -10,24 +10,46 @@ from fitgrid.epochs import Epochs
 def test_epochs_unequal_snapshots():
 
     epochs_table, channels = fake_data._generate(
-        n_epochs=10, n_samples=100, n_categories=2, n_channels=32
+        n_epochs=10,
+        n_samples=100,
+        n_categories=2,
+        n_channels=32,
+        time=defaults.TIME,
+        epoch_id=defaults.EPOCH_ID,
     )
 
     epochs_table.drop(epochs_table.index[42], inplace=True)
     with pytest.raises(FitGridError) as error:
-        Epochs(epochs_table, channels)
+        Epochs(
+            epochs_table,
+            time=defaults.TIME,
+            epoch_id=defaults.EPOCH_ID,
+            channels=channels,
+        )
     assert 'differs from previous snapshot' in str(error.value)
 
 
 def test_raises_error_on_duplicate_channels():
 
-    epochs_table, channels = fitgrid.fake_data._generate(10, 100, 2, 32)
+    epochs_table, channels = fitgrid.fake_data._generate(
+        n_epochs=10,
+        n_samples=100,
+        n_categories=2,
+        n_channels=32,
+        time=defaults.TIME,
+        epoch_id=defaults.EPOCH_ID,
+    )
     dupe_channel = channels[0]
     dupe_column = epochs_table[dupe_channel]
     bad_epochs_table = pd.concat([epochs_table, dupe_column], axis=1)
 
     with pytest.raises(FitGridError) as error:
-        fitgrid.epochs_from_dataframe(bad_epochs_table, channels)
+        fitgrid.epochs_from_dataframe(
+            bad_epochs_table,
+            time=defaults.TIME,
+            epoch_id=defaults.EPOCH_ID,
+            channels=channels,
+        )
 
     assert "Duplicate column names" in str(error.value)
 
@@ -35,40 +57,66 @@ def test_raises_error_on_duplicate_channels():
 def test__raises_error_on_epoch_index_mismatch():
     """Bad: all epochs have the same shape, but indices differ."""
 
-    from fitgrid import TIME
-
     # strategy: generate epochs, but insert meaningless time index
     epochs_table, channels = fake_data._generate(
-        n_epochs=10, n_samples=100, n_categories=2, n_channels=32
+        n_epochs=10,
+        n_samples=100,
+        n_categories=2,
+        n_channels=32,
+        time=defaults.TIME,
+        epoch_id=defaults.EPOCH_ID,
     )
 
     # blow up index to misalign epochs and time
     bad_index = np.arange(len(epochs_table))
-    epochs_table.index.set_levels(levels=bad_index, level=TIME, inplace=True)
-    epochs_table.index.set_labels(labels=bad_index, level=TIME, inplace=True)
+    epochs_table.index.set_levels(
+        levels=bad_index, level=defaults.TIME, inplace=True
+    )
+    epochs_table.index.set_labels(
+        labels=bad_index, level=defaults.TIME, inplace=True
+    )
 
     # now time index is equal to row number in the table overall
     with pytest.raises(FitGridError) as error:
-        Epochs(epochs_table, channels)
+        Epochs(
+            epochs_table,
+            time=defaults.TIME,
+            epoch_id=defaults.EPOCH_ID,
+            channels=channels,
+        )
 
     assert 'differs from previous snapshot' in str(error.value)
 
 
 def test_multiple_indices_end_up_EPOCH_ID():
 
-    from fitgrid import EPOCH_ID, TIME
-
     epochs_table, channels = fake_data._generate(
-        n_epochs=10, n_samples=100, n_categories=2, n_channels=32
+        n_epochs=10,
+        n_samples=100,
+        n_categories=2,
+        n_channels=32,
+        time=defaults.TIME,
+        epoch_id=defaults.EPOCH_ID,
     )
     epochs_table.reset_index(inplace=True)
-    epochs_table.set_index([EPOCH_ID, TIME, 'categorical'], inplace=True)
+    epochs_table.set_index(
+        [defaults.EPOCH_ID, defaults.TIME, 'categorical'], inplace=True
+    )
 
-    epochs = Epochs(epochs_table, channels)
+    epochs = Epochs(
+        epochs_table,
+        time=defaults.TIME,
+        epoch_id=defaults.EPOCH_ID,
+        channels=channels,
+    )
     # internal table has EPOCH_ID in index
-    assert epochs.table.index.names == [EPOCH_ID]
+    assert epochs.table.index.names == [defaults.EPOCH_ID]
     # input table is not altered
-    assert epochs_table.index.names == [EPOCH_ID, TIME, 'categorical']
+    assert epochs_table.index.names == [
+        defaults.EPOCH_ID,
+        defaults.TIME,
+        'categorical',
+    ]
 
 
 def test_smoke_plot_averages():
diff --git a/tests/test_fake_data.py b/tests/test_fake_data.py
index e5e738d..2636a11 100644
--- a/tests/test_fake_data.py
+++ b/tests/test_fake_data.py
@@ -12,21 +12,28 @@ def test__generate():
         n_epochs=n_epochs,
         n_categories=n_categories,
         n_channels=n_channels,
+        time=fg.defaults.TIME,
+        epoch_id=fg.defaults.EPOCH_ID,
     )
 
     # check index columns
-    assert fg.EPOCH_ID, fg.TIME == table.index.names
+    assert fg.defaults.EPOCH_ID, fg.defaults.TIME == table.index.names
 
     # test general shape
     assert len(table) == n_samples * n_categories * n_epochs
 
     # test uniqueness within columns
-    assert len(table.index.unique(fg.TIME)) == n_samples
-    assert len(table.index.unique(fg.EPOCH_ID)) == n_epochs * n_categories
+    assert len(table.index.unique(fg.defaults.TIME)) == n_samples
+    assert (
+        len(table.index.unique(fg.defaults.EPOCH_ID))
+        == n_epochs * n_categories
+    )
     assert len(table['categorical'].unique()) == n_categories
 
     # test uniqueness of groups (multiple columns)
-    epochs_and_cat = table.groupby([fg.EPOCH_ID, 'categorical']).size()
+    epochs_and_cat = table.groupby(
+        [fg.defaults.EPOCH_ID, 'categorical']
+    ).size()
     assert len(epochs_and_cat) == n_epochs * n_categories
     assert (epochs_and_cat == n_samples).all()
 
@@ -34,7 +41,7 @@ def test__generate():
     categories = table.groupby('categorical')
     assert len(categories) == n_categories
     for i, category in categories:
-        assert len(category.index.unique(fg.EPOCH_ID)) == n_epochs
+        assert len(category.index.unique(fg.defaults.EPOCH_ID)) == n_epochs
 
     # test number of columns (2 predictors + n_channels)
     assert len(channels) == n_channels
diff --git a/tests/test_fitgrid.py b/tests/test_fitgrid.py
index 982336a..74a7d97 100644
--- a/tests/test_fitgrid.py
+++ b/tests/test_fitgrid.py
@@ -6,7 +6,7 @@ import os
 from .context import fitgrid, tpath
 from fitgrid.errors import FitGridError
 from fitgrid.fitgrid import FitGrid, LMFitGrid, LMERFitGrid
-from fitgrid import tools
+from fitgrid import tools, defaults
 
 
 def test__correct_channels_in_fitgrid():
@@ -64,13 +64,20 @@ def test__epoch_id_substitution():
     See github.com/kutaslab/fitgrid/issues/25.
     """
 
-    from fitgrid import EPOCH_ID
-
     # create data with unusual index (shifted by 5)
-    data, channels = fitgrid.fake_data._generate(10, 100, 2, 32)
+    data, channels = fitgrid.fake_data._generate(
+        n_epochs=10,
+        n_samples=100,
+        n_categories=2,
+        n_channels=32,
+        time=defaults.TIME,
+        epoch_id=defaults.EPOCH_ID,
+    )
     unusual_index = np.arange(20) + 5
-    data.index.set_levels(unusual_index, level=EPOCH_ID, inplace=True)
-    epochs = fitgrid.epochs_from_dataframe(data, channels)
+    data.index.set_levels(unusual_index, level=defaults.EPOCH_ID, inplace=True)
+    epochs = fitgrid.epochs_from_dataframe(
+        data, time=defaults.TIME, epoch_id=defaults.EPOCH_ID, channels=channels
+    )
 
     # remember epoch_index
     epoch_index = tools.get_first_group(epochs._snapshots).index
@@ -84,7 +91,7 @@ def test__epoch_id_substitution():
     resid_pearson = grid.resid_pearson
     assert resid_pearson.index.levels[1].equals(epoch_index)
     assert (resid_pearson.index.levels[1] == epoch_index).all()
-    assert resid_pearson.index.names[1] == EPOCH_ID
+    assert resid_pearson.index.names[1] == defaults.EPOCH_ID
 
     # now we retrieve cooks_d and expect that EPOCH_ID is correct and named
     influence = grid.get_influence()
@@ -93,13 +100,13 @@ def test__epoch_id_substitution():
     cooks_d = influence.cooks_distance
     assert cooks_d.index.levels[2].equals(epoch_index)
     assert (cooks_d.index.levels[2] == epoch_index).all()
-    assert cooks_d.index.names[2] == EPOCH_ID
+    assert cooks_d.index.names[2] == defaults.EPOCH_ID
 
     # one additional level
     cov_ratio = influence.cov_ratio
     assert cov_ratio.index.levels[1].equals(epoch_index)
     assert (cov_ratio.index.levels[1] == epoch_index).all()
-    assert cov_ratio.index.names[1] == EPOCH_ID
+    assert cov_ratio.index.names[1] == defaults.EPOCH_ID
 
 
 def test__slicing():
@@ -166,7 +173,7 @@ def test_grid_with_duplicate_channels():
         [grid._grid, grid._grid['channel0']], axis=1
     )
     with pytest.raises(FitGridError) as error:
-        FitGrid(_grid_with_duplicate_channels, grid._epoch_index)
+        FitGrid(_grid_with_duplicate_channels, grid.epoch_index, grid.time)
 
     assert "Duplicate column names" in str(error.value)
 
diff --git a/tests/test_io.py b/tests/test_io.py
index dbad375..31d6445 100644
--- a/tests/test_io.py
+++ b/tests/test_io.py
@@ -2,12 +2,19 @@ import pytest
 import os
 from pathlib import Path
 from .context import fitgrid, tpath
+from fitgrid import defaults
 
 
 def test__epochs_from_hdf(tpath):
     TEST_FILE = Path.joinpath(tpath, 'data', 'fake_epochs.h5')
     channels = [f'channel{i}' for i in range(32)]
-    fitgrid.epochs_from_hdf(TEST_FILE, channels=channels)
+    fitgrid.epochs_from_hdf(
+        TEST_FILE,
+        key=None,
+        time=defaults.TIME,
+        epoch_id=defaults.EPOCH_ID,
+        channels=channels,
+    )
 
 
 def test__epochs_from_dataframe_good_data():
@@ -16,9 +23,19 @@ def test__epochs_from_dataframe_good_data():
     # this is a helper function, all further testing of Epochs creation is
     # done in test_epochs.py
     table, channels = fitgrid.fake_data._generate(
-        n_epochs=10, n_samples=100, n_categories=2, n_channels=32
+        n_epochs=10,
+        n_samples=100,
+        n_categories=2,
+        n_channels=32,
+        time=defaults.TIME,
+        epoch_id=defaults.EPOCH_ID,
+    )
+    fitgrid.epochs_from_dataframe(
+        table,
+        time=defaults.TIME,
+        epoch_id=defaults.EPOCH_ID,
+        channels=channels,
     )
-    fitgrid.epochs_from_dataframe(table, channels)
 
 
 @pytest.mark.skipif(
@@ -27,7 +44,12 @@ def test__epochs_from_dataframe_good_data():
 def test__epochs_from_feather(tpath):
     TEST_FILE = Path.joinpath(tpath, 'data', 'fake_epochs.feather')
     channels = [f'channel{i}' for i in range(32)]
-    fitgrid.epochs_from_feather(TEST_FILE, channels=channels)
+    fitgrid.epochs_from_feather(
+        TEST_FILE,
+        time=defaults.TIME,
+        epoch_id=defaults.EPOCH_ID,
+        channels=channels,
+    )
 
 
 # fitgrid.load_grid is tested in test_fitgrid.py
diff --git a/tests/test_utils_lmer.py b/tests/test_utils_lmer.py
index 9abed1f..c1b86a1 100644
--- a/tests/test_utils_lmer.py
+++ b/tests/test_utils_lmer.py
@@ -5,6 +5,7 @@ import pandas as pd
 from matplotlib import pyplot as plt
 from .context import fitgrid, tpath
 from fitgrid.utils import lmer as fgu
+from fitgrid import defaults
 
 # pytest evaluates tpath to the local tests directory
 
@@ -106,7 +107,12 @@ def test_fit_lmers(tpath, epochs_f, interval, LHS, RHS):
 
     """
     epochs = get_epochs(tpath=tpath, epochs_f=epochs_f, interval=interval)
-    fg_epochs = fitgrid.epochs_from_dataframe(epochs)
+    fg_epochs = fitgrid.epochs_from_dataframe(
+        epochs,
+        time=defaults.TIME,
+        epoch_id=defaults.EPOCH_ID,
+        channels=defaults.CHANNELS,
+    )
     lmer_coefs = fgu.fit_lmers(fg_epochs, LHS, RHS, parallel=True, n_cores=2)
 
 
@@ -157,7 +163,9 @@ def test_get_lmer_dfbetas(tpath):
     expected = pd.read_csv(TEST_DFBETAS, index_col=0).T
 
     table = pd.read_csv(TEST_EPOCHS).set_index(['Epoch_idx', 'Time'])
-    epochs = fitgrid.epochs_from_dataframe(table, channels=['channel0'])
+    epochs = fitgrid.epochs_from_dataframe(
+        table, channels=['channel0'], time='Time', epoch_id='Epoch_idx'
+    )
     dfbetas = fitgrid.utils.lmer.get_lmer_dfbetas(
         epochs, 'categorical', RHS='continuous + (continuous | categorical)'
     )