Skip to content

Commit 408b4e3

Browse files
authored
Merge pull request #56 from dj-sciops/update-lfp
fix: major refactor and update methods in LFP processing
2 parents 5794067 + fe177be commit 408b4e3

3 files changed

Lines changed: 124 additions & 108 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
Observes [Semantic Versioning](https://semver.org/spec/v2.0.0.html) standard and
44
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/) convention.
55

6+
7+
## [0.6.0] - 2025-05-31
8+
9+
- Fix - Major refactor and fix methods in `ephys.LFP`
10+
611
## [0.5.1] - 2025-04-23
712

813
- Add - `execution_duration` to `LFP` table

element_array_ephys/ephys_no_curation.py

Lines changed: 118 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ class Trace(dj.Part):
239239
-> master
240240
-> probe.ElectrodeConfig.Electrode
241241
---
242-
lfp : blob@datajoint-blob
242+
lfp : blob@datajoint-blob # uV
243243
"""
244244

245245
@property
@@ -252,143 +252,154 @@ def key_source(self):
252252
)
253253

254254
def make(self, key):
255+
"""Compute broadband LFP signals for each electrode.
256+
257+
Args:
258+
key (dict): EphysSession primary key.
259+
260+
Raises:
261+
ValueError: If the trace duration is not within the expected range.
262+
OSError: If there is an error when loading the file.
263+
264+
Logic:
265+
- Fetch the probe information for the given ephys session.
266+
- Fetch the electrode configuration for the given probe.
267+
- Fetch the raw data files for the given ephys session.
268+
- Check for missing files or short trace durations in min
269+
- Design notch filter to remove powerline noise that contaminates the LFP
270+
- Downsample the signal with `decimate` and apply an anti-aliasing FIR filter
271+
"""
255272
execution_time = datetime.now(timezone.utc)
256273

274+
# Define constants
257275
TARGET_SAMPLING_RATE = 2500 # Hz
258276
POWERLINE_NOISE_FREQ = 60 # Hz
259-
LFP_DURATION = 30 # minutes
277+
MAX_DURATION_MINUTES = 30 # Minutes
260278

279+
# Check if the trace duration is within the expected range
261280
duration = (key["end_time"] - key["start_time"]).total_seconds() / 60 # minutes
262281
assert (
263-
duration <= LFP_DURATION
264-
), f"LFP sessions cannot exceeds {LFP_DURATION} minutes in duration."
282+
duration <= MAX_DURATION_MINUTES
283+
), f"LFP session duration {duration} min > max session duration {MAX_DURATION_MINUTES} min"
265284

285+
# Fetch the raw data files for the given ephys session
266286
query = (
267287
EphysRawFile
268288
& f"file_time BETWEEN '{key['start_time']}' AND '{key['end_time']}'"
269289
)
270290
if not query:
271-
logger.info(
272-
f"No raw data file found. Skip populating ephys.LFP for <{key}>"
273-
)
274-
else:
275-
logger.info(f"Populating ephys.LFP for <{key}>")
291+
logger.info(f"No raw data file found. Skipping LFP for <{key}>")
292+
return
276293

277-
# Get probe info
278-
probe_info = (EphysSessionProbe & key).fetch1()
279-
probe_type = (probe.Probe & {"probe": probe_info["probe"]}).fetch1(
280-
"probe_type"
281-
)
294+
logger.info(f"Populating ephys.LFP for <{key}>")
282295

283-
electrode_query = probe.ElectrodeConfig.Electrode & (
284-
probe.ElectrodeConfig & {"probe_type": probe_type}
285-
)
286-
287-
# Filter for used electrodes. If probe_info["used_electrodes"] is None, it means all electrodes were used.
288-
if probe_info["used_electrodes"]:
289-
electrode_query &= (
290-
f'electrode IN {tuple(probe_info["used_electrodes"])}'
291-
)
296+
# Fetch the probe information for the given ephys session
297+
probe_info = (EphysSessionProbe & key).fetch1()
298+
probe_type = (probe.Probe & {"probe": probe_info["probe"]}).fetch1("probe_type")
299+
electrode_query = probe.ElectrodeConfig.Electrode & (
300+
probe.ElectrodeConfig & {"probe_type": probe_type}
301+
)
292302

293-
header = {}
294-
lfp_concat = np.array([], dtype=np.float64)
303+
# Fetch the electrode configuration for the given probe
304+
# Filter for used electrodes. If probe_info["used_electrodes"] is None, it means all electrodes were used.
305+
if probe_info["used_electrodes"]:
306+
electrode_query &= f"electrode IN {tuple(probe_info['used_electrodes'])}"
295307

296-
for file_relpath in query.fetch("file_path", order_by="file_time"):
297-
file = find_full_path(get_ephys_root_data_dir(), file_relpath)
308+
header = {}
309+
lfp_concat = []
298310

299-
try:
300-
data = intanrhdreader.load_file(file)
301-
except OSError:
302-
raise OSError(f"OS error occurred when loading file {file.name}")
311+
# Iterate over the raw data files for the given ephys session to load the data
312+
for file_relpath in query.fetch("file_path", order_by="file_time"):
313+
file = find_full_path(get_ephys_root_data_dir(), file_relpath)
314+
try:
315+
data = intanrhdreader.load_file(file)
316+
except OSError:
317+
raise OSError(f"OS error occurred when loading file {file.name}")
318+
319+
if not header:
320+
header = data.pop("header")
321+
lfp_sampling_rate = header["sample_rate"]
322+
powerline_noise_freq = (
323+
header["notch_filter_frequency"] or POWERLINE_NOISE_FREQ
324+
) # in Hz
325+
326+
# Calculate downsampling factor
327+
true_ratio = lfp_sampling_rate / TARGET_SAMPLING_RATE
328+
downsample_factor = int(np.round(true_ratio))
329+
330+
# Check if the ratio is within 1% of an integer (1% tolerance)
331+
if not np.isclose(true_ratio, downsample_factor, rtol=0.01, atol=1e-8):
332+
raise ValueError(
333+
f"Downsampling factor {true_ratio} is too far from an integer. Check LFP sampling rates."
334+
)
303335

304-
if not header:
305-
header = data.pop("header")
306-
lfp_sampling_rate = header["sample_rate"]
307-
powerline_noise_freq = (
308-
header["notch_filter_frequency"] or POWERLINE_NOISE_FREQ
309-
) # in Hz
310-
downsample_factor = int(lfp_sampling_rate / TARGET_SAMPLING_RATE)
336+
# Get LFP indices (row index of the LFP matrix to be used)
337+
lfp_indices = np.array(electrode_query.fetch("channel_idx"), dtype=int)
338+
port_indices = np.array(
339+
[
340+
ind
341+
for ind, ch in enumerate(data["amplifier_channels"])
342+
if ch["port_prefix"] == probe_info["port_id"]
343+
]
344+
)
345+
lfp_indices = np.sort(port_indices[lfp_indices])
311346

312-
# Get LFP indices (row index of the LFP matrix to be used)
313-
lfp_indices = np.array(
314-
electrode_query.fetch("channel_idx"), dtype=int
315-
)
316-
port_indices = np.array(
317-
[
318-
ind
319-
for ind, ch in enumerate(data["amplifier_channels"])
320-
if ch["port_prefix"] == probe_info["port_id"]
321-
]
322-
)
323-
lfp_indices = np.sort(port_indices[lfp_indices])
347+
self.insert1({**key, "lfp_sampling_rate": TARGET_SAMPLING_RATE})
324348

325-
self.insert1(
326-
{
327-
**key,
328-
"lfp_sampling_rate": TARGET_SAMPLING_RATE,
329-
}
330-
)
349+
# Get LFP channels
350+
channels = np.array(
351+
[
352+
ch["native_channel_name"]
353+
for ch in data["amplifier_channels"]
354+
if ch["port_prefix"]
355+
]
356+
)[lfp_indices]
331357

332-
channels = np.array(
333-
[
334-
ch["native_channel_name"]
335-
for ch in data["amplifier_channels"]
336-
if ch["port_prefix"]
337-
]
338-
)[lfp_indices]
358+
# Get channel to electrode mapping
359+
electrode_df = electrode_query.fetch(format="frame").reset_index()
360+
channel_to_electrode_map = dict(
361+
zip(electrode_df["channel_idx"], electrode_df["electrode"])
362+
)
339363

340-
electrode_df = electrode_query.fetch(format="frame").reset_index()
364+
channel_to_electrode_map = {
365+
f'{probe_info["port_id"]}-{int(channel):03d}': electrode
366+
for channel, electrode in channel_to_electrode_map.items()
367+
}
341368

342-
channel_to_electrode_map = dict(
343-
zip(electrode_df["channel_idx"], electrode_df["electrode"])
344-
)
369+
lfps = data.pop("amplifier_data")[lfp_indices]
370+
lfp_concat.append(lfps)
345371

346-
channel_to_electrode_map = {
347-
f'{probe_info["port_id"]}-{int(channel):03d}': electrode
348-
for channel, electrode in channel_to_electrode_map.items()
349-
}
372+
full_lfp = np.hstack(lfp_concat)
350373

351-
lfps = data.pop("amplifier_data")[lfp_indices]
352-
lfp_concat = (
353-
lfps if lfp_concat.size == 0 else np.hstack((lfp_concat, lfps))
354-
)
355-
del data
374+
# Check if the trace duration is within the expected range
375+
trace_duration = full_lfp.shape[1] / lfp_sampling_rate / 60 # in min
376+
if abs(trace_duration - duration) > 0.5:
377+
raise ValueError(
378+
f"Trace duration mismatch: expected {duration}, got {trace_duration} min"
379+
)
356380

357-
# Check for missing files or short trace durations in min
358-
trace_duration = lfp_concat.shape[1] / lfp_sampling_rate / 60 # in min
359-
if trace_duration != duration:
360-
raise ValueError(
361-
f"Trace length ({trace_duration} min) is less than session duration"
362-
)
381+
# Design notch filter
382+
notch_b, notch_a = signal.iirnotch(
383+
w0=powerline_noise_freq, Q=30, fs=lfp_sampling_rate
384+
)
363385

364-
# Single insert in loop to mitigate potential memory issue.
365-
for ch, lfp in zip(channels, lfp_concat):
366-
# Powerline noise removal
367-
b_notch, a_notch = signal.iirnotch(
368-
w0=powerline_noise_freq, Q=30, fs=TARGET_SAMPLING_RATE
369-
)
370-
lfp = signal.filtfilt(b_notch, a_notch, lfp)
386+
for ch_idx, raw_lfp in enumerate(full_lfp):
371387

372-
# Lowpass filter
373-
b_butter, a_butter = signal.butter(
374-
N=4, Wn=1000, btype="lowpass", fs=TARGET_SAMPLING_RATE
375-
)
376-
lfp = signal.filtfilt(b_butter, a_butter, lfp)
388+
# Apply notch filter
389+
lfp = signal.filtfilt(notch_b, notch_a, raw_lfp)
377390

378-
# Downsample the signal
379-
lfp = lfp[::downsample_factor]
391+
# Downsample the signal with `decimate`
392+
lfp = signal.decimate(lfp, downsample_factor, ftype="fir", zero_phase=True)
380393

381-
self.Trace.insert1(
382-
{
383-
**key,
384-
"electrode_config_hash": electrode_df["electrode_config_hash"][
385-
0
386-
],
387-
"probe_type": electrode_df["probe_type"][0],
388-
"electrode": channel_to_electrode_map[ch],
389-
"lfp": lfp,
390-
}
391-
)
394+
self.Trace.insert1(
395+
{
396+
**key,
397+
"electrode_config_hash": electrode_df["electrode_config_hash"][0],
398+
"probe_type": electrode_df["probe_type"][0],
399+
"electrode": channel_to_electrode_map[ch_idx],
400+
"lfp": lfp,
401+
}
402+
)
392403

393404
self.update1(
394405
{

element_array_ephys/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Package metadata."""
22

3-
__version__ = "0.5.1"
3+
__version__ = "0.6.0"

0 commit comments

Comments
 (0)