1- import warnings
1+ from warnings import warn
22from numbers import Number
33from typing import Literal , Optional
44
55import matplotlib .pyplot as plt
66import numpy as np
77import pandas as pd
8+ from functools import cached_property
89
910from .events import Events
1011from .stream import Stream
1112from .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
3615class 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