@@ -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 {
0 commit comments