Skip to content

Commit b7becbc

Browse files
committed
Refactor Epochs class and update plotting for new API
Refactored the Epochs class to use a dictionary of epochs indexed by epoch index, added properties for empty epochs, and improved overlap checking and warnings. Updated the to_numpy method to support flexible sampling rates and interpolation, and improved baseline correction error handling. Modified plot_epochs and its helpers to work with the new Epochs API, and updated the pupil_size_and_epoching tutorial to use the new sample data and API.
1 parent 9106967 commit b7becbc

3 files changed

Lines changed: 395 additions & 287 deletions

File tree

pyneon/epochs.py

Lines changed: 141 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,16 @@
1-
import warnings
1+
from warnings import warn
22
from numbers import Number
33
from typing import Literal, Optional
44

55
import matplotlib.pyplot as plt
66
import numpy as np
77
import pandas as pd
8+
from functools import cached_property
89

910
from .events import Events
1011
from .stream import Stream
1112
from .vis import plot_epochs
12-
13-
14-
def _check_overlap(epochs_info: pd.DataFrame) -> bool:
15-
"""
16-
Emits warnings if any adjacent epochs overlap in time.
17-
"""
18-
epochs_info = epochs_info.sort_values("t_ref")
19-
overlap = False
20-
overlap_epochs = []
21-
for i in range(1, epochs_info.shape[0]):
22-
# Check if the current epoch overlaps with the previous epoch
23-
if (
24-
epochs_info["t_ref"].iloc[i] - epochs_info["t_before"].iloc[i]
25-
< epochs_info["t_ref"].iloc[i - 1] + epochs_info["t_after"].iloc[i - 1]
26-
):
27-
overlap_epochs.append((i - 1, i))
28-
overlap = True
29-
if overlap:
30-
warnings.warn(
31-
f"The following epochs overlap in time:\n{overlap_epochs}", RuntimeWarning
32-
)
33-
return overlap
34-
13+
from .utils.doc_decorators import fill_doc
3514

3615
class Epochs:
3716
"""
@@ -42,11 +21,11 @@ class Epochs:
4221
source : Stream or Events
4322
Data to create epochs from.
4423
epochs_info : pandas.DataFrame, shape (n_epochs, 4)
45-
DataFrame containing epoch information with the following columns:
24+
DataFrame containing epoch information with the following columns (time in ns):
4625
47-
``t_ref``: Reference time of the epoch, in nanoseconds.\n
48-
``t_before``: Time before the reference time to start the epoch, in nanoseconds.\n
49-
``t_after``: Time after the reference time to end the epoch, in nanoseconds.\n
26+
``t_ref``: Reference time of the epoch.\n
27+
``t_before``: Time before the reference time to start the epoch.\n
28+
``t_after``: Time after the reference time to end the epoch.\n
5029
``description``: Description or label associated with the epoch.
5130
5231
Must not have empty values.
@@ -64,14 +43,13 @@ class Epochs:
6443
Attributes
6544
----------
6645
epochs_info : pandas.DataFrame
67-
The supplied epochs information DataFrame.
68-
data : pandas.DataFrame
69-
Annotated data with epoch information. In addition to the original data columns,
70-
the following columns are added:
46+
The supplied epochs information DataFrame with additional columns:
7147
72-
``epoch index`` (Int64): ID of the epoch the data belongs to.
48+
``t_start``: Start time of the epoch (``t_ref - t_before``).\n
49+
``t_end``: End time of the epoch (``t_ref + t_after``).
7350
74-
If epochs overlap, data annotations are always overwritten by the latest epoch.
51+
source : Stream or Events
52+
The source data used to create epochs.
7553
"""
7654

7755
def __init__(self, source: Stream | Events, epochs_info: pd.DataFrame):
@@ -80,32 +58,88 @@ def __init__(self, source: Stream | Events, epochs_info: pd.DataFrame):
8058

8159
epochs_info = epochs_info.sort_values("t_ref").reset_index(drop=True)
8260
epochs_info.index.name = "epoch index"
61+
epochs_info["t_start"] = epochs_info["t_ref"] - epochs_info["t_before"]
62+
epochs_info["t_end"] = epochs_info["t_ref"] + epochs_info["t_after"]
8363

8464
# Set columns to appropriate data types (check if columns are present along the way)
8565
epochs_info = epochs_info.astype(
8666
{
8767
"t_ref": "int64",
8868
"t_before": "int64",
8969
"t_after": "int64",
70+
"t_start": "int64",
71+
"t_end": "int64",
9072
"description": "str",
9173
}
9274
)
9375
self.epochs_info = epochs_info
94-
95-
if isinstance(source, Stream):
96-
self.source_class = Stream
97-
self.is_uniformly_sampled = source.is_uniformly_sampled
98-
self.sf = source.sampling_freq_effective
99-
elif isinstance(source, Events):
100-
self.source_class = Events
101-
self.is_uniformly_sampled = None
102-
self.sf = None
103-
104-
# Create epochs
105-
self.annot = _annotate_epochs(source, epochs_info)
76+
self.source = source.copy()
77+
self._check_overlap()
10678

10779
def __len__(self):
10880
return self.epochs_info.shape[0]
81+
82+
def _check_overlap(self) -> list[tuple[int, int] | None]:
83+
overlap_epochs = []
84+
for i in range(1, self.epochs_info.shape[0]):
85+
# Check if the current epoch overlaps with the previous epoch
86+
if (
87+
self.epochs_info["t_ref"].iloc[i] - self.epochs_info["t_before"].iloc[i]
88+
< self.epochs_info["t_ref"].iloc[i - 1] + self.epochs_info["t_after"].iloc[i - 1]
89+
):
90+
overlap_epochs.append((i - 1, i))
91+
if overlap_epochs:
92+
warn(
93+
f"The following epochs overlap in time:\n{overlap_epochs}", RuntimeWarning
94+
)
95+
return overlap_epochs
96+
97+
@cached_property
98+
def epochs(self) -> dict[int, Stream | Events | None]:
99+
"""
100+
Dictionary of epochs indexed by epoch index. Each epoch contains
101+
data cropped from the source between ``t_start`` and ``t_end``.
102+
If no data is found for an epoch, its value is ``None``.
103+
104+
Returns
105+
-------
106+
dict of int to Stream or Events or None
107+
Dictionary mapping epoch indices to their corresponding data.
108+
"""
109+
epochs = {}
110+
empty_epochs = []
111+
for epoch_index in self.epochs_info.index:
112+
t_ref = self.epochs_info.at[epoch_index, "t_ref"]
113+
t_start = self.epochs_info.at[epoch_index, "t_start"]
114+
t_end = self.epochs_info.at[epoch_index, "t_end"]
115+
try:
116+
epoch = self.source.crop(t_start, t_end, by="timestamp", inplace=False)
117+
ts = epoch.ts if isinstance(epoch, Stream) else epoch.start_ts
118+
epoch.data["epoch time [ns]"] = ts - t_ref
119+
epochs[int(epoch_index)] = epoch
120+
except ValueError:
121+
empty_epochs.append(int(epoch_index))
122+
epochs[int(epoch_index)] = None
123+
if empty_epochs:
124+
warn(
125+
f"No data found for epoch(s): {empty_epochs}.", RuntimeWarning
126+
)
127+
return epochs
128+
129+
@property
130+
def empty_epochs(self) -> list[int]:
131+
"""Indices of epochs that contain no data.
132+
133+
Returns
134+
-------
135+
list of int
136+
List of epoch indices that are empty.
137+
"""
138+
return [
139+
int(epoch_index)
140+
for epoch_index, epoch in self.epochs.items()
141+
if epoch is None
142+
]
109143

110144
@property
111145
def t_ref(self) -> np.ndarray:
@@ -146,7 +180,7 @@ def is_equal_length(self) -> bool:
146180
@property
147181
def has_overlap(self) -> bool:
148182
"""Whether any adjacent epochs overlap."""
149-
return _check_overlap(self.epochs)
183+
return self._check_overlap() != []
150184

151185
def plot(
152186
self,
@@ -188,100 +222,97 @@ def plot(
188222
)
189223
return fig_ax
190224

225+
@fill_doc
191226
def to_numpy(
192227
self,
193228
column_names: str | list[str] = "all",
229+
sampling_rate: Optional[Number] = None,
230+
float_kind: str | int = "linear",
231+
other_kind: str | int = "nearest",
194232
) -> tuple[np.ndarray, dict]:
195233
"""
196234
Converts epochs into a 3D array with dimensions (n_epochs, n_channels, n_times).
197235
Acts similarly as :meth:`mne.Epochs.get_data`.
198-
Requires the epoch to be created from a uniformly-sampled :class:`pyneon.Stream`.
236+
Requires the epoch to be created from a :class:`pyneon.Stream`.
199237
200238
Parameters
201239
----------
202240
column_names : str or list of str, optional
203-
Column names to include in the NumPy array. If 'all', all columns are included.
204-
Only columns that can be converted to int or float can be included.
205-
Default is 'all'.
241+
Column names to include in the NumPy array. If "all", all columns are included.
242+
Only numerical columns can be included.
243+
Default to "all".
244+
sampling_rate : numbers.Number, optional
245+
Desired sampling rate in Hz for the output NumPy array.
246+
If None, the nominal sampling rate of the source Stream is used.
247+
Defaults to None.
248+
%(interp_kwargs)s
206249
207250
Returns
208251
-------
209-
numpy_epochs : numpy.ndarray
252+
numpy.ndarray
210253
NumPy array of shape (n_epochs, n_channels, n_times).
211-
212254
info : dict
213255
A dictionary containing:
214256
215257
"column_ids": List of provided column names.\n
216258
"t_rel": The common time grid, in nanoseconds.\n
217259
"nan_flag": Boolean indicating whether NaN values were found in the data.
218-
219-
Notes
220-
-----
221-
- The time grid (``t_rel``) is in nanoseconds.
222-
- If `NaN` values are present after interpolation, they are noted in ``nan_flag``.
223260
"""
224-
if self.source_class != Stream or not self.is_uniformly_sampled:
261+
if not isinstance(self.source, Stream):
262+
raise TypeError("The source must be a Stream to convert to NumPy array.")
263+
if not self.is_equal_length:
225264
raise ValueError(
226-
"The source must be a uniformly-sampled Stream to convert to NumPy array."
265+
"Epochs must have equal length (t_before and t_after) to convert to NumPy array."
227266
)
228-
if not self.is_equal_length:
229-
raise ValueError("Epochs must have equal length to convert to NumPy array.")
230-
231-
t_before = self.t_before[0]
232-
t_after = self.t_after[0]
233-
234-
times = np.linspace(
235-
-t_before, t_after, int((t_before + t_after) * self.sf * 1e-9) + 1
267+
sf = (
268+
self.source.sampling_freq_nominal
269+
if sampling_rate is None
270+
else sampling_rate
236271
)
237-
n_times = len(times)
238272

273+
# Check if column names (str or list) are all in the source columns
239274
if column_names == "all":
240-
columns = self.columns.to_list()
241-
else:
242-
columns = [column_names] if isinstance(column_names, str) else column_names
243-
for col in columns:
244-
if col not in self.columns:
245-
raise ValueError(f"Column '{col}' doesn't exist in the data.")
246-
247-
n_columns = len(columns)
248-
249-
# Initialize the NumPy array
250-
# MNE convention: (n_epochs, n_channels, n_times)
251-
epochs_np = np.full((len(self), n_columns, n_times - 2), np.nan)
275+
column_names = self.source.columns.to_list()
276+
if isinstance(column_names, str):
277+
column_names = [column_names]
278+
for col in column_names:
279+
if col not in self.source.columns:
280+
raise ValueError(f"Column '{col}' not found in source Stream.")
281+
282+
epoch_times = np.arange(
283+
-self.epochs_info["t_before"].iloc[0],
284+
self.epochs_info["t_after"].iloc[0],
285+
step=int(1e9 / sf),
286+
dtype="int64",
287+
)
252288

253289
# Interpolate each epoch onto the common time grid
254-
for i, epoch in self.epochs.iterrows():
255-
epoch_data = epoch["data"].copy()
256-
epoch_time = epoch_data["epoch time"].to_numpy()
257-
for j, col in enumerate(columns):
258-
y = epoch_data[col].to_numpy()
259-
interp_values = np.interp(
260-
times, epoch_time, y, left=np.nan, right=np.nan
261-
)
262-
interp_values = interp_values[1:-1] # Exclude the first and last values
263-
epochs_np[i, j, :] = interp_values
264-
265-
# check if there are any NaN values in the data
266-
nan_flag = np.isnan(epochs_np).any()
267-
if nan_flag:
268-
warnings.warn("NaN values were found in the data.", RuntimeWarning)
269-
270-
# Return an object holding the column ids, times, and data
290+
epochs_np = np.full((len(self), len(column_names), len(epoch_times)), np.nan)
291+
for i, row in self.epochs_info.iterrows():
292+
t_ref = row["t_ref"]
293+
new_ts = epoch_times + t_ref
294+
epoch_data = self.source.interpolate(
295+
new_ts,
296+
float_kind=float_kind,
297+
other_kind=other_kind,
298+
inplace=False,
299+
).data[column_names]
300+
epochs_np[i, :, :] = epoch_data.to_numpy().T
301+
271302
info = {
272-
"column_ids": columns,
273-
"epoch_times": times[1:-1] * 1e-9, # Convert to seconds
274-
"nan_flag": nan_flag,
303+
"epoch_times": epoch_times,
304+
"column_names": column_names,
305+
"nan_flag": np.isnan(epochs_np).any(),
275306
}
276-
307+
277308
return epochs_np, info
278309

279310
def baseline_correction(
280311
self,
281312
baseline: tuple[Number | None, Number | None] = (None, 0),
282313
method: str = "mean",
283314
inplace: bool = True,
284-
) -> Optional[pd.DataFrame]:
315+
) -> dict[int, Stream | Events | None] | None:
285316
"""
286317
Perform baseline correction on epochs.
287318
@@ -312,10 +343,8 @@ def baseline_correction(
312343
The baseline-corrected data (same shape & dtypes as original data).
313344
314345
"""
315-
if self.source_class != Stream:
316-
raise ValueError(
317-
"Baseline correction is only supported for epochs created from a Stream."
318-
)
346+
if not isinstance(self.source, Stream):
347+
raise TypeError("Baseline correction requires the source to be a Stream.")
319348

320349
def _fit_and_subtract(epoch_df: pd.DataFrame, chan_cols: list[str]) -> None:
321350
"""In-place mean or linear detrend on *one* epoch DF."""
@@ -329,7 +358,7 @@ def _fit_and_subtract(epoch_df: pd.DataFrame, chan_cols: list[str]) -> None:
329358
mask = (t_rel_sec >= t_min) & (t_rel_sec <= t_max)
330359

331360
if not mask.any():
332-
warnings.warn(
361+
warn(
333362
"Baseline window is empty for at least one epoch.",
334363
RuntimeWarning,
335364
)
@@ -349,13 +378,13 @@ def _fit_and_subtract(epoch_df: pd.DataFrame, chan_cols: list[str]) -> None:
349378
or np.any(np.isnan(t_base))
350379
or np.any(np.isnan(y))
351380
):
352-
warnings.warn(
381+
warn(
353382
f"Skipping linear baseline correction for '{col}' due to insufficient or invalid data.",
354383
RuntimeWarning,
355384
)
356385
continue
357386
if np.all(t_base == t_base[0]):
358-
warnings.warn(
387+
warn(
359388
f"Skipping linear baseline correction for '{col}' due to constant timestamps.",
360389
RuntimeWarning,
361390
)
@@ -398,7 +427,7 @@ def _fit_and_subtract(epoch_df: pd.DataFrame, chan_cols: list[str]) -> None:
398427
return data_copy
399428

400429

401-
def _annotate_epochs(source: Stream | Events, epochs_info: pd.DataFrame) -> dict:
430+
def annotate_epochs(source: Stream | Events, epochs_info: pd.DataFrame) -> dict:
402431
"""
403432
Create index-wise annotations of epoch indices for the source data.
404433
"""
@@ -426,7 +455,7 @@ def _annotate_epochs(source: Stream | Events, epochs_info: pd.DataFrame) -> dict
426455
annot[idx].append(i)
427456

428457
if empty_epochs:
429-
warnings.warn(f"No data found for epoch(s): {empty_epochs}.", RuntimeWarning)
458+
warn(f"No data found for epoch(s): {empty_epochs}.", RuntimeWarning)
430459

431460
return annot
432461

0 commit comments

Comments
 (0)