Skip to content

Commit e0c77ee

Browse files
committed
Refactor epochs/events API and improve doc consistency
1 parent 7f367f2 commit e0c77ee

12 files changed

Lines changed: 259 additions & 155 deletions

File tree

pyneon/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
install_import_hook("pyneon")
77

88
from .dataset import Dataset
9-
from .epochs import Epochs, construct_times_df, events_to_times_df
9+
from .epochs import Epochs, construct_epochs_info, events_to_epochs_info
1010
from .events import Events
1111
from .recording import Recording
1212
from .stream import Stream
@@ -20,6 +20,6 @@
2020
"Events",
2121
"Epochs",
2222
"Video",
23-
"construct_times_df",
24-
"events_to_times_df",
23+
"construct_epochs_info",
24+
"events_to_epochs_info",
2525
]

pyneon/epochs.py

Lines changed: 56 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,18 @@
1111
from .vis import plot_epochs
1212

1313

14-
def _check_overlap(times_df: pd.DataFrame) -> bool:
14+
def _check_overlap(epochs_info: pd.DataFrame) -> bool:
1515
"""
1616
Emits warnings if any adjacent epochs overlap in time.
1717
"""
18-
times_df = times_df.sort_values("t_ref")
18+
epochs_info = epochs_info.sort_values("t_ref")
1919
overlap = False
2020
overlap_epochs = []
21-
for i in range(1, times_df.shape[0]):
21+
for i in range(1, epochs_info.shape[0]):
2222
# Check if the current epoch overlaps with the previous epoch
2323
if (
24-
times_df["t_ref"].iloc[i] - times_df["t_before"].iloc[i]
25-
< times_df["t_ref"].iloc[i - 1] + times_df["t_after"].iloc[i - 1]
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]
2626
):
2727
overlap_epochs.append((i - 1, i))
2828
overlap = True
@@ -41,7 +41,7 @@ class Epochs:
4141
----------
4242
source : Stream or Events
4343
Data to create epochs from.
44-
times_df : pandas.DataFrame, shape (n_epochs, 4), optional
44+
epochs_info : pandas.DataFrame, shape (n_epochs, 4)
4545
DataFrame containing epoch information with the following columns:
4646
4747
``t_ref``: Reference time of the epoch, in nanoseconds.\n
@@ -63,41 +63,34 @@ class Epochs:
6363
6464
Attributes
6565
----------
66-
epochs : pandas.DataFrame
67-
DataFrame containing epoch information with the following columns:
68-
69-
``t_ref`` (int64): Reference time of the epoch, in nanoseconds.\n
70-
``t_before`` (int64): Time before the reference time to start the epoch, in nanoseconds.\n
71-
``t_after`` (int64): Time after the reference time to end the epoch, in nanoseconds.\n
72-
``description`` (str): Description or label associated with the epoch.\n
73-
``data`` (object): DataFrame containing the data for each epoch.
66+
epochs_info : pandas.DataFrame
67+
The supplied epochs information DataFrame.
7468
data : pandas.DataFrame
7569
Annotated data with epoch information. In addition to the original data columns,
7670
the following columns are added:
7771
78-
``epoch index`` (Int32): ID of the epoch the data belongs to.\n
79-
``epoch time`` (Int64): Time relative to the epoch reference time, in nanoseconds.\n
80-
``epoch description`` (str): Description or label associated with the epoch.
72+
``epoch index`` (Int64): ID of the epoch the data belongs to.
8173
8274
If epochs overlap, data annotations are always overwritten by the latest epoch.
8375
"""
8476

85-
def __init__(self, source: Stream | Events, times_df: pd.DataFrame):
86-
if times_df.isnull().values.any():
87-
raise ValueError("times_df should not have any empty values")
77+
def __init__(self, source: Stream | Events, epochs_info: pd.DataFrame):
78+
if epochs_info.empty or epochs_info.isnull().values.any():
79+
raise ValueError("epochs_info must not be empty or contain NaN values.")
80+
81+
epochs_info = epochs_info.sort_values("t_ref").reset_index(drop=True)
82+
epochs_info.index.name = "epoch index"
8883

89-
# Sort by t_ref
90-
assert times_df.shape[0] > 0, "times_df must have at least one row"
91-
times_df = times_df.sort_values("t_ref").reset_index(drop=True)
9284
# Set columns to appropriate data types (check if columns are present along the way)
93-
times_df = times_df.astype(
85+
epochs_info = epochs_info.astype(
9486
{
9587
"t_ref": "int64",
9688
"t_before": "int64",
9789
"t_after": "int64",
9890
"description": "str",
9991
}
10092
)
93+
self.epochs_info = epochs_info
10194

10295
if isinstance(source, Stream):
10396
self.source_class = Stream
@@ -109,30 +102,30 @@ def __init__(self, source: Stream | Events, times_df: pd.DataFrame):
109102
self.sf = None
110103

111104
# Create epochs
112-
self.epochs, self.data = _create_epochs(source, times_df)
105+
self.data = _annotate_epochs(source, epochs_info)
113106

114107
def __len__(self):
115-
return self.epochs.shape[0]
108+
return self.epochs_info.shape[0]
116109

117110
@property
118111
def t_ref(self) -> np.ndarray:
119112
"""The reference time for each epoch in UTC nanoseconds."""
120-
return self.epochs["t_ref"].to_numpy()
113+
return self.epochs_info["t_ref"].to_numpy()
121114

122115
@property
123116
def t_before(self) -> np.ndarray:
124117
"""The time before the reference time for each epoch in nanoseconds."""
125-
return self.epochs["t_before"].to_numpy()
118+
return self.epochs_info["t_before"].to_numpy()
126119

127120
@property
128121
def t_after(self) -> np.ndarray:
129122
"""The time after the reference time for each epoch in nanoseconds."""
130-
return self.epochs["t_after"].to_numpy()
123+
return self.epochs_info["t_after"].to_numpy()
131124

132125
@property
133126
def description(self) -> np.ndarray:
134127
"""The description or label for each epoch."""
135-
return self.epochs["description"].to_numpy()
128+
return self.epochs_info["description"].to_numpy()
136129

137130
@property
138131
def columns(self) -> pd.Index:
@@ -405,67 +398,46 @@ def _fit_and_subtract(epoch_df: pd.DataFrame, chan_cols: list[str]) -> None:
405398
return data_copy
406399

407400

408-
def _create_epochs(
409-
source: Stream | Events, times_df: pd.DataFrame
410-
) -> tuple[pd.DataFrame, pd.DataFrame]:
401+
def _annotate_epochs(
402+
source: Stream | Events, epochs_info: pd.DataFrame
403+
) -> list[list[int]]:
411404
"""
412-
Create epochs DataFrame and annotate the data with epoch information.
405+
Create timestamp-wise annotations of epoch indices for the source data.
413406
"""
414-
_check_overlap(times_df)
415-
416-
data = source.data.copy()
417-
data["epoch index"] = pd.Series(dtype="Int32")
418-
data["epoch time"] = pd.Series(dtype="Int64")
419-
data["epoch description"] = pd.Series(dtype="str")
420-
421-
# check for source type
422-
if isinstance(source, Stream):
423-
ts = source.ts
424-
elif isinstance(source, Events):
425-
ts = source.start_ts
426-
else:
427-
raise ValueError("Source must be a Stream or Events.")
407+
# _check_overlap(epochs_info)
428408

429-
epochs = times_df.copy().reset_index(drop=True)
430-
epochs["data"] = pd.Series(dtype="object")
409+
# Timestamps from the source
410+
ts = source.ts if isinstance(source, Stream) else source.start_ts
411+
annot = [[] for _ in range(len(ts))]
431412

432413
# Iterate over each event time to create epochs
433-
for i, row in times_df.iterrows():
434-
t_ref_i, t_before_i, t_after_i, description_i = row[
435-
["t_ref", "t_before", "t_after", "description"]
436-
].to_list()
414+
for i, row in epochs_info.iterrows():
415+
t_ref_i, t_before_i, t_after_i = row[["t_ref", "t_before", "t_after"]].to_list()
437416

438417
start_time = t_ref_i - t_before_i
439418
end_time = t_ref_i + t_after_i
440419
mask = np.logical_and(ts >= start_time, ts <= end_time)
441420

442421
if not mask.any():
443422
warnings.warn(f"No data found for epoch {i}.", RuntimeWarning)
444-
epochs.at[i, "epoch data"] = pd.DataFrame()
445423
continue
446424

447-
data.loc[mask, "epoch index"] = i
448-
data.loc[mask, "epoch description"] = str(description_i)
449-
data.loc[mask, "epoch time"] = (
450-
data.loc[mask].index.to_numpy() - t_ref_i
451-
).astype("int64")
425+
# Append the epoch index to the list for each matching row
426+
for sub_list in annot[mask]:
427+
sub_list.append(i)
452428

453-
local_data = data.loc[mask].copy()
454-
local_data.drop(columns=["epoch index", "epoch description"], inplace=True)
455-
epochs.at[i, "data"] = local_data
429+
return annot
456430

457-
return epochs, data
458431

459-
460-
def events_to_times_df(
432+
def events_to_epochs_info(
461433
events: "Events",
462434
t_before: Number,
463435
t_after: Number,
464436
t_unit: Literal["s", "ms", "us", "ns"] = "s",
465437
event_name: str | list[str] = "all",
466438
) -> pd.DataFrame:
467439
"""
468-
Construct a ``times_df`` DataFrame suitable for creating epochs from event data.
440+
Construct a ``epochs_info`` DataFrame suitable for creating epochs from event data.
469441
For "simple" ``events`` (blinks, fixations, saccades), all events are used.
470442
For more complex ``events`` (e.g., from "events.csv", or concatenated events),
471443
the user can specify which events to include by a ``name`` column.
@@ -491,9 +463,9 @@ def events_to_times_df(
491463
pandas.DataFrame
492464
DataFrame with columns: ``t_ref``, ``t_before``, ``t_after``, ``description`` (all in ns).
493465
"""
466+
t_ref = events.start_ts
494467
if events.type in ["blinks", "fixations", "saccades"]:
495468
description = events.type[:-1] # Remove the 's' at the end
496-
t_ref = events.start_ts
497469
else:
498470
if "name" not in events.data.columns:
499471
raise ValueError(
@@ -502,30 +474,24 @@ def events_to_times_df(
502474

503475
names = events.data["name"].astype(str)
504476
if event_name == "all":
505-
t_ref = events.data.index.to_numpy()
506477
description = names.to_numpy()
507478
else:
508-
if isinstance(event_name, str):
509-
event_name = [event_name]
510-
mask = names.isin(event_name)
511-
if not mask.any():
512-
raise ValueError(f"No events found matching names: {event_name}")
513-
filtered_data = events.data[mask]
514-
t_ref = filtered_data.index.to_numpy()
515-
description = filtered_data["name"].to_numpy()
479+
matching_events = events.filter_by_name(event_name)
480+
t_ref = matching_events.start_ts
481+
description = matching_events["name"].to_numpy()
516482

517-
times_df = construct_times_df(
483+
epochs_info = construct_epochs_info(
518484
t_ref,
519485
t_before,
520486
t_after,
521487
description,
522-
"ns",
523-
t_unit,
488+
t_ref_unit="ns",
489+
t_other_unit=t_unit,
524490
)
525-
return times_df
491+
return epochs_info
526492

527493

528-
def construct_times_df(
494+
def construct_epochs_info(
529495
t_ref: np.ndarray,
530496
t_before: np.ndarray | Number,
531497
t_after: np.ndarray | Number,
@@ -535,7 +501,7 @@ def construct_times_df(
535501
global_t_ref: int = 0,
536502
) -> pd.DataFrame:
537503
"""
538-
Handles the construction of the ``times_df`` DataFrame for creating epochs. It populates
504+
Handles the construction of the ``epochs_info`` DataFrame for creating epochs. It populates
539505
single values for `t_before`, `t_after`, and `description` to match the length of `t_ref`.
540506
and converts all times to UTC timestamps in nanoseconds.
541507
@@ -572,10 +538,8 @@ def construct_times_df(
572538
DataFrame with columns: ``t_ref``, ``t_before``, ``t_after``, ``description`` (all in ns).
573539
"""
574540

575-
if n_epoch := len(t_ref) == 0:
541+
if (n_epoch := len(t_ref)) == 0:
576542
raise ValueError("t_ref must not be empty")
577-
else:
578-
n_epoch = len(t_ref)
579543

580544
time_factors = {"s": 1e9, "ms": 1e6, "us": 1e3, "ns": 1}
581545

@@ -586,28 +550,30 @@ def construct_times_df(
586550
if isinstance(x, np.ndarray):
587551
# Ensure it's the same length as t_ref
588552
if len(x) != n_epoch:
589-
raise ValueError(f"{name} must have the same length as t_ref")
553+
raise ValueError(
554+
f"{name} must have the same length as t_ref ({n_epoch}), got {len(x)}"
555+
)
590556
elif isinstance(x, (Number, str)):
591557
x = np.repeat(x, n_epoch)
592558
else:
593559
raise ValueError(f"{name} must be a single value or a numpy array")
594560

595561
# Construct the event times DataFrame
596562
# Do rounding as they should be timestamps already
597-
times_df = pd.DataFrame(
563+
epochs_info = pd.DataFrame(
598564
{
599565
"t_ref": t_ref * time_factors[t_ref_unit] + global_t_ref,
600566
"t_before": t_before * time_factors[t_other_unit],
601567
"t_after": t_after * time_factors[t_other_unit],
602568
"description": description,
603569
}
604570
)
605-
times_df = times_df.astype(
571+
epochs_info = epochs_info.astype(
606572
{
607573
"t_ref": "int64",
608574
"t_before": "int64",
609575
"t_after": "int64",
610576
"description": "str",
611577
}
612578
)
613-
return times_df
579+
return epochs_info

0 commit comments

Comments
 (0)