lfpack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lfpack/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """lfpack — LFP codec for Neuropixels recordings.
2
+
3
+ Lossy encode/decode pipeline: decimation → Cadzow denoising → adaptive SVD
4
+ → wavelet-packet thresholding → HDF5 storage.
5
+ """
6
+
7
+ from lfpack._core import ( # noqa: F401
8
+ LFPackReader,
9
+ LFPCompressed,
10
+ compress,
11
+ compress_bin_to_h5,
12
+ compress_pipeline,
13
+ compress_to_h5,
14
+ decompress,
15
+ merge_h5,
16
+ run_cadzow_checkpoint,
17
+ )
lfpack/_core.py ADDED
@@ -0,0 +1,1228 @@
1
+ """
2
+ LFP compression via Cadzow denoising, adaptive SVD, and wavelet-packet thresholding.
3
+
4
+ Two-stage lossy codec for local-field-potential (LFP) recordings:
5
+
6
+ Stage 1 – Adaptive SVD (epsilon threshold)
7
+ Rank r is selected as r = #{k : sv[k] > epsilon × sigma_noise}, where sigma_noise
8
+ is the median of the lower half of non-trivial singular values. This adapts the
9
+ rank to the signal content of each snippet rather than using a fixed value.
10
+
11
+ Stage 2 – Wavelet-packet thresholding (alpha multiplier)
12
+ Each of the r temporal row-vectors Vh[k, :] is thresholded independently using
13
+ tau_k = alpha × sigma_noise / sv[k], applied to db4 level-5 wavelet-packet
14
+ coefficients. Larger singular values (stronger spatial modes) use a lower
15
+ threshold, preserving more of their temporal detail.
16
+
17
+ Recommended defaults (epsilon=150, alpha=28) give CR ≈ 600–1500 with
18
+ median RMSE < 5 µV on Cadzow-denoised IBL NP1/NP2 recordings.
19
+
20
+ Typical usage
21
+ -------------
22
+ >>> compressed = compress(snippet)
23
+ >>> reconstructed = decompress(compressed)
24
+
25
+ >>> reconstructed, compressed = compress_pipeline(raw_lfp, h=probe_header)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import dataclasses
31
+ import json as _json
32
+ import os
33
+ from pathlib import Path
34
+
35
+ import neuropixel
36
+ import numpy as np
37
+ import pywt
38
+ import scipy.signal # noqa: F401
39
+ import spikeglx as _spikeglx
40
+ from ibldsp import cadzow as _cadzow
41
+ from tqdm import tqdm
42
+
43
+ _WP_WAVELET = "db4"
44
+ _WP_MAXLEVEL = 5
45
+ # HDF5 format version used for all files written by lfpack.
46
+ # ("earliest", "v110") produces files readable by any HDF5 >= 1.10 (2017).
47
+ # lfpack only uses gzip+shuffle datasets, groups, and scalar/string attributes —
48
+ # all available since HDF5 1.8 — so v110 is a safe, stable ceiling.
49
+ _H5_LIBVER = ("earliest", "v110")
50
+
51
+
52
+ @dataclasses.dataclass
53
+ class LFPCompressed:
54
+ """Compressed representation of an (nc, ns) LFP matrix.
55
+
56
+ Attributes
57
+ ----------
58
+ U_scaled : ndarray (nc, r), float32
59
+ Left singular vectors scaled by their singular values: U[:, :r] * sv[:r].
60
+ Vh_hat : ndarray (r, n_wp_slots), float32
61
+ WP-domain leaf-node coefficients (alpha > 0) or time-domain rows (alpha == 0).
62
+ ns_original : int
63
+ Output time samples after trimming guard bands.
64
+ epsilon : float
65
+ SVD threshold multiplier used during compression.
66
+ alpha : float
67
+ WP threshold multiplier used during compression.
68
+ cr_svd : float
69
+ SVD-only compression ratio nc*ns / (r*(nc+ns)) — what CR would be without WP.
70
+ cr_wp : float
71
+ WP compression ratio r*ns / n_kept (1.0 when alpha=0).
72
+ cr_total : float
73
+ True compression ratio nc*ns / (r*nc + n_kept): original floats / stored floats.
74
+ left_overlap : int
75
+ Guard-band samples to trim at left after inverse WP (0 when no overlap context).
76
+ ns_extended : int
77
+ Total time samples the WP coefficients represent (0 → use ns_original).
78
+ """
79
+
80
+ U_scaled: np.ndarray
81
+ Vh_hat: np.ndarray
82
+ ns_original: int
83
+ epsilon: float
84
+ alpha: float
85
+ cr_svd: float
86
+ cr_wp: float
87
+ cr_total: float
88
+ left_overlap: int = 0
89
+ ns_extended: int = 0
90
+
91
+
92
+ def _svd_noise_floor(sv: np.ndarray) -> float:
93
+ """Median of the lower half of non-trivial singular values.
94
+
95
+ Restricts to sv > 0.01% of max before computing the median so that
96
+ dead/zeroed channels do not pull the noise floor to zero.
97
+ """
98
+ sv_nz = sv[sv > sv[0] * 1e-4]
99
+ tail = sv_nz[sv_nz.size // 2 :] if sv_nz.size else sv
100
+ return float(np.nanmedian(tail)) if tail.size else float(sv[0])
101
+
102
+
103
+ def _count_wp_slots(ns: int) -> int:
104
+ """Total number of leaf wavelet-packet coefficients for a signal of length *ns*."""
105
+ wp = pywt.WaveletPacket(data=np.zeros(ns), wavelet=_WP_WAVELET, maxlevel=_WP_MAXLEVEL)
106
+ return sum(len(node.data) for node in wp.get_level(_WP_MAXLEVEL, "natural"))
107
+
108
+
109
+ def compress(
110
+ data: np.ndarray,
111
+ epsilon: float = 150.0,
112
+ alpha: float = 28.0,
113
+ ) -> LFPCompressed:
114
+ """
115
+ Compress an LFP snippet using adaptive SVD and wavelet-packet thresholding.
116
+
117
+ Parameters
118
+ ----------
119
+ data : ndarray of shape (nc, ns)
120
+ LFP data matrix, float32 or float64. Rows are channels, columns are time.
121
+ epsilon : float
122
+ SVD threshold multiplier. rank = #{k : sv[k] > epsilon × sigma_noise}.
123
+ Default 150.
124
+ alpha : float
125
+ WP threshold multiplier per component: tau_k = alpha × sigma_noise / sv[k].
126
+ Set to 0 to skip wavelet-packet stage. Default 28.
127
+
128
+ Returns
129
+ -------
130
+ LFPCompressed
131
+ """
132
+ nc, ns = data.shape
133
+ x = data.astype(np.float64)
134
+ U, sv, Vh = np.linalg.svd(x, full_matrices=False)
135
+
136
+ sigma_noise = _svd_noise_floor(sv)
137
+ r = max(1, int(np.sum(sv > epsilon * sigma_noise)))
138
+ # cr_svd: what the CR would be with SVD alone (time-domain Vh, no WP)
139
+ cr_svd = float(nc * ns) / (r * (nc + ns))
140
+
141
+ n_wp_slots = _count_wp_slots(ns)
142
+ if alpha == 0.0:
143
+ Vh_hat = Vh[:r, :].copy()
144
+ n_kept = r * ns # all time-domain samples retained
145
+ else:
146
+ Vh_hat = np.zeros((r, n_wp_slots))
147
+ n_kept = 0
148
+ for k in range(r):
149
+ tau_k = alpha * sigma_noise / (sv[k] + 1e-40)
150
+ wp = pywt.WaveletPacket(data=Vh[k], wavelet=_WP_WAVELET, maxlevel=_WP_MAXLEVEL)
151
+ nodes = wp.get_level(_WP_MAXLEVEL, "natural")
152
+ offset = 0
153
+ for node in nodes:
154
+ mask = np.abs(node.data) >= tau_k
155
+ n_kept += int(mask.sum())
156
+ node_len = len(node.data)
157
+ Vh_hat[k, offset : offset + node_len] = node.data * mask
158
+ offset += node_len
159
+
160
+ # cr_wp: how much WP thresholding compresses the Vh rows (1.0 when alpha=0)
161
+ cr_wp = float(r * ns) / max(n_kept, 1)
162
+ # cr_total: true compression ratio — original floats / (U_scaled + non-zero Vh coefficients)
163
+ cr_total = float(nc * ns) / (r * nc + n_kept)
164
+
165
+ return LFPCompressed(
166
+ U_scaled=(U[:, :r] * sv[:r]).astype(np.float32),
167
+ Vh_hat=Vh_hat.astype(np.float32),
168
+ ns_original=ns,
169
+ epsilon=epsilon,
170
+ alpha=alpha,
171
+ cr_svd=cr_svd,
172
+ cr_wp=cr_wp,
173
+ cr_total=cr_total,
174
+ left_overlap=0,
175
+ ns_extended=ns,
176
+ )
177
+
178
+
179
+ def _reconstruct_vh_from_wp(Vh_hat_wp: np.ndarray, ns_extended: int, r: int) -> np.ndarray:
180
+ """Inverse WP transform: flat leaf-node coefficient array → time-domain rows.
181
+
182
+ Parameters
183
+ ----------
184
+ Vh_hat_wp : ndarray (r, n_wp_slots), float32
185
+ ns_extended : int
186
+ Signal length the WP tree was built from.
187
+ r : int
188
+ Number of rows.
189
+
190
+ Returns
191
+ -------
192
+ ndarray (r, ns_extended), float64
193
+ """
194
+ wp_ref = pywt.WaveletPacket(data=np.zeros(ns_extended), wavelet=_WP_WAVELET, maxlevel=_WP_MAXLEVEL)
195
+ node_sizes = [len(n.data) for n in wp_ref.get_level(_WP_MAXLEVEL, "natural")]
196
+
197
+ Vh_time = np.zeros((r, ns_extended), dtype=np.float64)
198
+ for k in range(r):
199
+ wp = pywt.WaveletPacket(data=np.zeros(ns_extended), wavelet=_WP_WAVELET, maxlevel=_WP_MAXLEVEL)
200
+ nodes = wp.get_level(_WP_MAXLEVEL, "natural")
201
+ offset = 0
202
+ for i, node in enumerate(nodes):
203
+ sz = node_sizes[i]
204
+ node.data = Vh_hat_wp[k, offset : offset + sz].astype(np.float64)
205
+ offset += sz
206
+ Vh_time[k] = wp.reconstruct(update=True)[:ns_extended]
207
+ return Vh_time
208
+
209
+
210
+ def decompress(compressed: LFPCompressed, bin_channels: int = 1) -> np.ndarray:
211
+ """
212
+ Reconstruct LFP data from a compressed representation.
213
+
214
+ Parameters
215
+ ----------
216
+ compressed : LFPCompressed
217
+ bin_channels : int
218
+ Number of adjacent channels to sum together (spatial binning). ``1``
219
+ means no binning. Must evenly divide ``nc`` or trailing channels are
220
+ silently dropped. When > 1, the full ``(nc, ns)`` array is never
221
+ materialised; only the binned ``(nc // bin_channels, ns)`` result is.
222
+
223
+ Returns
224
+ -------
225
+ ndarray of shape (nc // bin_channels, ns_original), float32
226
+ """
227
+ r = compressed.U_scaled.shape[1]
228
+ ns = compressed.ns_original
229
+ ns_ext = compressed.ns_extended if compressed.ns_extended > 0 else ns
230
+
231
+ lo = compressed.left_overlap
232
+ if compressed.alpha == 0.0:
233
+ Vh_time = compressed.Vh_hat[:, lo : lo + ns].astype(np.float64)
234
+ else:
235
+ Vh_time_ext = _reconstruct_vh_from_wp(compressed.Vh_hat, ns_ext, r)
236
+ Vh_time = Vh_time_ext[:, lo : lo + ns]
237
+
238
+ if bin_channels > 1:
239
+ # Sum U_scaled rows in groups before the matrix multiply so the result
240
+ # is (nc_binned, ns) rather than (nc, ns) — no large intermediate.
241
+ nc = compressed.U_scaled.shape[0]
242
+ nc_binned = nc // bin_channels
243
+ U = compressed.U_scaled[: nc_binned * bin_channels].astype(np.float64)
244
+ U_binned = U.reshape(nc_binned, bin_channels, r).sum(axis=1)
245
+ x_hat = U_binned @ Vh_time
246
+ else:
247
+ x_hat = compressed.U_scaled.astype(np.float64) @ Vh_time
248
+ return np.nan_to_num(x_hat, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32)
249
+
250
+
251
+ def compress_pipeline(
252
+ data: np.ndarray,
253
+ h: dict | None = None,
254
+ epsilon: float = 150.0,
255
+ alpha: float = 28.0,
256
+ fs: float = 250.0,
257
+ cadzow_rank: int = 5,
258
+ cadzow_niter: int = 1,
259
+ cadzow_fmax: float = 100.0,
260
+ ) -> tuple[np.ndarray, LFPCompressed]:
261
+ """
262
+ Full LFP compression pipeline: Cadzow denoise → SVD-adapt → WP threshold.
263
+
264
+ Parameters
265
+ ----------
266
+ data : ndarray of shape (nc, ns), float32
267
+ Raw LFP data at *fs* Hz. Rows are channels, columns are time samples.
268
+ h : dict or None
269
+ Neuropixel probe header containing 'x' and 'y' channel coordinates.
270
+ Defaults to the first *nc* channels of the NP1 (version 1) geometry.
271
+ Pass `neuropixel.trace_header(version=2)` for NP2 probes.
272
+ epsilon : float
273
+ SVD-adapt threshold multiplier. Default 150.
274
+ alpha : float
275
+ WP threshold multiplier. Default 28.
276
+ fs : float
277
+ LFP sampling rate [Hz]. Default 250.
278
+ cadzow_rank : int
279
+ Spatial de-rank applied by the Cadzow denoiser. Default 5.
280
+ cadzow_niter : int
281
+ Number of Cadzow iterations. Default 1.
282
+ cadzow_fmax : float
283
+ Maximum frequency passed to the Cadzow denoiser [Hz]. Default 100.
284
+
285
+ Returns
286
+ -------
287
+ reconstructed : ndarray of shape (nc, ns), float32
288
+ Denoised and compressed–reconstructed LFP.
289
+ compressed : LFPCompressed
290
+ Compression metadata and per-stage compression ratios.
291
+ """
292
+ nc = data.shape[0]
293
+ if h is None:
294
+ _h = neuropixel.trace_header(version=1)
295
+ h = {k: v[:nc] for k, v in _h.items()}
296
+
297
+ denoised = _cadzow.cadzow_denoiser(
298
+ data,
299
+ h=h,
300
+ fs=fs,
301
+ rank=cadzow_rank,
302
+ niter=cadzow_niter,
303
+ fmax=cadzow_fmax,
304
+ )
305
+ compressed = compress(denoised, epsilon=epsilon, alpha=alpha)
306
+ return decompress(compressed), compressed
307
+
308
+
309
+ # ── Chunk sizes for the full-recording pipeline ───────────────────────────────
310
+ # Cadzow: processed window = 768 = 3 × 256, FFT-optimal. Halo of 64 samples
311
+ # on each side is discarded after denoising, so the written chunk is 640 samples.
312
+ # Compress: 2048 = 2^11 samples per HDF5 chunk; 128-sample guard band on each
313
+ # side covers the db4 level-5 wavelet reconstruction support (~217 samples).
314
+ _CADZOW_CHUNK = 640 # written chunk size = processed window − 2 × halo
315
+ _CADZOW_HALO = 64 # halo each side → processed = 640 + 128 = 768 = 3 × 256
316
+ _COMPRESS_CHUNK = 2048
317
+ _COMPRESS_OVERLAP = 128
318
+
319
+
320
+ def _cadzow_worker(job):
321
+ """Process one time-chunk for run_cadzow_checkpoint (module-level for pickling)."""
322
+ import numpy as np
323
+ from ibldsp import cadzow as _cadzow_proc
324
+
325
+ data = np.lib.format.open_memmap(job["data_path"], mode="r")
326
+ out = np.lib.format.open_memmap(job["out_path"], mode="r+")
327
+
328
+ ci, chunk, halo, ns = job["ci"], job["chunk"], job["halo"], job["ns"]
329
+ i0_w = ci * chunk
330
+ i1_w = min(i0_w + chunk, ns)
331
+ i0_r = max(0, i0_w - halo)
332
+ i1_r = min(ns, i1_w + halo)
333
+ left_halo = i0_w - i0_r
334
+
335
+ snippet = np.asarray(data[i0_r:i1_r, :], dtype=np.float32).T # (nc, processed)
336
+ denoised = _cadzow_proc.cadzow_denoiser(
337
+ snippet,
338
+ h=job["h"],
339
+ fs=job["fs"],
340
+ rank=job["rank"],
341
+ niter=job["niter"],
342
+ fmax=job["fmax"],
343
+ nswx=job["nswx"],
344
+ ovx=job["ovx"],
345
+ gap_threshold=job["gap_threshold"],
346
+ ppca_k=job["ppca_k"],
347
+ n_jobs=1,
348
+ )
349
+ out[i0_w:i1_w, :] = denoised[:, left_halo : left_halo + (i1_w - i0_w)].T
350
+ out.flush()
351
+ return ci
352
+
353
+
354
+ def run_cadzow_checkpoint(
355
+ data,
356
+ out_npy,
357
+ h=None,
358
+ fs=250.0,
359
+ chunk=_CADZOW_CHUNK,
360
+ halo=_CADZOW_HALO,
361
+ rank=5,
362
+ niter=1,
363
+ fmax=None,
364
+ nswx=64,
365
+ ovx=32,
366
+ gap_threshold=2.0,
367
+ ppca_k=2.0,
368
+ n_jobs=4,
369
+ ):
370
+ """
371
+ Cadzow-denoise a decimated LFP array in overlapping chunks and save a contiguous checkpoint.
372
+
373
+ Each chunk of `chunk` samples is read with a `halo`-sample context on each side,
374
+ denoised, then only the central `chunk` samples are written. The output .npy
375
+ contains no guard bands and can be memory-mapped directly.
376
+
377
+ Parameters
378
+ ----------
379
+ data : ndarray (ns, nc), float32
380
+ Decimated LFP at `fs` Hz, time-first so time slices are contiguous on disk.
381
+ out_npy : path-like
382
+ Output .npy file path. Shape (ns, nc), float32, time-first.
383
+ h : dict or None
384
+ Probe header with keys 'x' and 'y'. Defaults to NP1 geometry for nc channels.
385
+ fs : float
386
+ Sampling rate [Hz]. Default 250.
387
+ chunk : int
388
+ Written chunk size (samples). chunk + 2*halo must be FFT-optimal.
389
+ Default 640 → processed window = 640 + 2×64 = 768 = 3 × 256.
390
+ halo : int
391
+ Context halo each side (samples). Default 64.
392
+ rank : int
393
+ Cadzow SVD rank. Default 5.
394
+ niter : int
395
+ Number of Cadzow iterations. Default 1.
396
+ fmax : float or None
397
+ Max frequency for Cadzow [Hz]. None → Nyquist. Default None.
398
+ nswx : int
399
+ Cadzow channel-window width. Default 64.
400
+ ovx : int
401
+ Cadzow channel-window overlap. Default 32 (50% of nswx).
402
+ gap_threshold : float
403
+ Adaptive-rank gap threshold. Default 2.0.
404
+ ppca_k : float
405
+ PPCA outlier-suppression threshold. Default 2.0.
406
+ n_jobs : int
407
+ Number of chunks processed in parallel via ProcessPoolExecutor. Each
408
+ worker subprocess calls cadzow_denoiser with n_jobs=1 and writes its
409
+ result directly to the output memmap. Default 4.
410
+
411
+ Returns
412
+ -------
413
+ ndarray (ns, nc), float32 — also written to out_npy.
414
+ """
415
+ from concurrent.futures import ProcessPoolExecutor, as_completed
416
+
417
+ ns, nc = data.shape
418
+ if h is None:
419
+ _h = neuropixel.trace_header(version=1)
420
+ h = {k: v[:nc] for k, v in _h.items()}
421
+
422
+ out_npy = Path(out_npy)
423
+
424
+ # Create the output .npy file up-front so workers can open it with mode='r+'
425
+ out_mm = np.lib.format.open_memmap(str(out_npy), mode="w+", dtype=np.float32, shape=(ns, nc))
426
+ del out_mm # flush header + allocation; workers re-open independently
427
+
428
+ # Workers need a file path, not an array. Memmaps expose .filename; otherwise
429
+ # save a temp file so every subprocess can re-open the data without IPC copies.
430
+ _tmp_input = None
431
+ if hasattr(data, "filename"):
432
+ data_path = str(data.filename)
433
+ else:
434
+ _tmp_input = out_npy.with_suffix(".tmp_input.npy")
435
+ np.save(_tmp_input, data)
436
+ data_path = str(_tmp_input)
437
+
438
+ n_chunks = int(np.ceil(ns / chunk))
439
+ shared = dict(
440
+ data_path=data_path,
441
+ out_path=str(out_npy),
442
+ ns=ns,
443
+ chunk=chunk,
444
+ halo=halo,
445
+ h=h,
446
+ fs=fs,
447
+ rank=rank,
448
+ niter=niter,
449
+ fmax=fmax,
450
+ nswx=nswx,
451
+ ovx=ovx,
452
+ gap_threshold=gap_threshold,
453
+ ppca_k=ppca_k,
454
+ )
455
+ jobs = [{**shared, "ci": ci} for ci in range(n_chunks)]
456
+
457
+ from tqdm import tqdm
458
+
459
+ n_workers = os.cpu_count() if n_jobs == -1 else n_jobs
460
+ ctx = __import__("multiprocessing").get_context("spawn")
461
+ with ProcessPoolExecutor(max_workers=n_workers, mp_context=ctx) as pool:
462
+ futures = {pool.submit(_cadzow_worker, job): job["ci"] for job in jobs}
463
+ with tqdm(total=n_chunks, desc="Cadzow", unit="chunk") as pbar:
464
+ for fut in as_completed(futures):
465
+ fut.result() # re-raise any worker exception immediately
466
+ pbar.update(1)
467
+
468
+ if _tmp_input is not None:
469
+ _tmp_input.unlink()
470
+
471
+ return np.load(out_npy, mmap_mode="r")
472
+
473
+
474
+ def _compress_chunk_worker(args):
475
+ """Compress one chunk (module-level for pickling by ProcessPoolExecutor)."""
476
+ npy_path, i0_r, i1_r, n_w, left_ov, epsilon, alpha = args
477
+ data = np.load(npy_path, mmap_mode="r")
478
+ snippet = np.asarray(data[i0_r:i1_r, :], dtype=np.float32).T
479
+ c = compress(snippet, epsilon=epsilon, alpha=alpha)
480
+ reconstructed = decompress(c)
481
+ rmse = float(
482
+ np.sqrt(
483
+ np.mean(
484
+ (
485
+ snippet[:, left_ov : left_ov + n_w].astype(np.float64)
486
+ - reconstructed[:, left_ov : left_ov + n_w].astype(np.float64)
487
+ )
488
+ ** 2
489
+ )
490
+ )
491
+ )
492
+ flat = c.Vh_hat.ravel()
493
+ vh_idx = np.flatnonzero(flat).astype(np.int32)
494
+ return {
495
+ "U_scaled": c.U_scaled,
496
+ "vh_indices": vh_idx,
497
+ "vh_values": flat[vh_idx],
498
+ "vh_shape": c.Vh_hat.shape,
499
+ "ns_original": n_w,
500
+ "ns_extended": snippet.shape[1],
501
+ "left_overlap": left_ov,
502
+ "epsilon": epsilon,
503
+ "alpha": alpha,
504
+ "cr_svd": c.cr_svd,
505
+ "cr_wp": c.cr_wp,
506
+ "cr_total": c.cr_total,
507
+ "rmse": rmse,
508
+ }
509
+
510
+
511
+ def compress_to_h5(
512
+ cadzow_npy,
513
+ out_h5,
514
+ recording,
515
+ scale=0,
516
+ sglx_meta=None,
517
+ h=None,
518
+ chunk=_COMPRESS_CHUNK,
519
+ overlap=_COMPRESS_OVERLAP,
520
+ epsilon=150.0,
521
+ alpha=28.0,
522
+ fs=250.0,
523
+ t0_sync=None,
524
+ fs_sync=None,
525
+ n_jobs=4,
526
+ ):
527
+ """
528
+ Compress a Cadzow-denoised .npy into a single HDF5 archive of LFPCompressed chunks.
529
+
530
+ Each written chunk of `chunk` samples is extended by `overlap` samples on each side
531
+ before SVD + wavelet-packet compression. Only the central `chunk` columns of Vh_hat
532
+ are stored, eliminating wavelet-reconstruction boundary artefacts. Decompressed
533
+ chunks are concatenated without overlap during reading.
534
+
535
+ HDF5 layout
536
+ -----------
537
+ /<recording>/<scale_str>/meta attrs: nc, ns_total, fs, compress_chunk,
538
+ compress_overlap, epsilon, alpha,
539
+ sglx_meta (JSON), geometry_x, geometry_y
540
+ /<recording>/<scale_str>/chunks/<i>/ datasets: U_scaled (nc, r),
541
+ vh_indices (n_kept,) int32,
542
+ vh_values (n_kept,) float32
543
+ attrs: ns_original, ns_extended, left_overlap,
544
+ vh_shape, epsilon, alpha, cr_svd, cr_wp,
545
+ cr_total, rmse
546
+
547
+ where <scale_str> = f'{scale:02d}', e.g. '00', '01', … Multiple recordings and/or
548
+ scales can coexist in a single file; merging two files is a plain group copy.
549
+
550
+ Files are written with the module-level ``_H5_LIBVER`` constant (currently
551
+ ``("earliest", "v110")``), which pins the HDF5 format to features available
552
+ since HDF5 1.10 (2017) and makes the output readable by any modern HDF5
553
+ installation without version negotiation.
554
+
555
+ Parameters
556
+ ----------
557
+ cadzow_npy : path-like
558
+ Path to the (ns, nc) float32 Cadzow checkpoint (time-first).
559
+ out_h5 : path-like
560
+ Output HDF5 file (created or overwritten).
561
+ recording : str
562
+ Unique key for this recording (e.g. a probe-insertion UUID). Top-level HDF5
563
+ group name; allows multiple recordings to coexist in one file.
564
+ scale : int
565
+ Resolution level (zero-padded to two digits in the path). 0 = base resolution.
566
+ Default 0.
567
+ sglx_meta : dict or None
568
+ Original spikeglx metadata (sr.meta). Stored verbatim as JSON.
569
+ h : dict or None
570
+ Probe header. Defaults to NP1 geometry for nc channels.
571
+ chunk : int
572
+ Written chunk size (samples). Default 2048 = 2^11.
573
+ overlap : int
574
+ Guard-band samples each side. Default 128.
575
+ epsilon : float
576
+ SVD threshold multiplier. Default 150.
577
+ alpha : float
578
+ WP threshold multiplier. Default 28.
579
+ fs : float
580
+ Sampling rate [Hz] written into metadata. Default 250.
581
+ """
582
+ import h5py
583
+
584
+ data = np.load(cadzow_npy, mmap_mode="r") # (ns, nc) time-first
585
+ ns, nc = data.shape
586
+ if h is None:
587
+ _h = neuropixel.trace_header(version=1)
588
+ h = {k: v[:nc] for k, v in _h.items()}
589
+
590
+ out_h5 = Path(out_h5)
591
+ n_chunks = int(np.ceil(ns / chunk))
592
+ total_cr = 0.0
593
+
594
+ from tqdm import tqdm
595
+
596
+ jobs = []
597
+ for ci in range(n_chunks):
598
+ i0_w = ci * chunk
599
+ i1_w = min(i0_w + chunk, ns)
600
+ n_w = i1_w - i0_w
601
+ i0_r = max(0, i0_w - overlap)
602
+ i1_r = min(ns, i1_w + overlap)
603
+ jobs.append((str(cadzow_npy), i0_r, i1_r, n_w, i0_w - i0_r, epsilon, alpha))
604
+
605
+ root = f"{recording}/{scale:02d}"
606
+ with h5py.File(out_h5, "w", libver=_H5_LIBVER) as f:
607
+ mg = f.create_group(f"{root}/meta")
608
+ mg.attrs["nc"] = nc
609
+ mg.attrs["ns_total"] = ns
610
+ mg.attrs["fs"] = fs
611
+ mg.attrs["compress_chunk"] = chunk
612
+ mg.attrs["compress_overlap"] = overlap
613
+ mg.attrs["epsilon"] = epsilon
614
+ mg.attrs["alpha"] = alpha
615
+ mg.attrs["sglx_meta"] = _json.dumps(sglx_meta or {})
616
+ mg.attrs["geometry_x"] = h["x"].astype(np.float32)
617
+ mg.attrs["geometry_y"] = h["y"].astype(np.float32)
618
+ mg.attrs["t0_sync"] = float(t0_sync) if t0_sync is not None else np.nan
619
+ mg.attrs["fs_sync"] = float(fs_sync) if fs_sync is not None else np.nan
620
+
621
+ cg = f.create_group(f"{root}/chunks")
622
+ from joblib import Parallel, delayed
623
+
624
+ results = Parallel(n_jobs=n_jobs, backend="loky")(
625
+ delayed(_compress_chunk_worker)(job) for job in tqdm(jobs, desc="Compress", unit="chunk")
626
+ )
627
+ for ci, r in enumerate(results):
628
+ grp = cg.create_group(str(ci))
629
+ grp.create_dataset("U_scaled", data=r["U_scaled"], compression="gzip", shuffle=True)
630
+ grp.create_dataset("vh_indices", data=r["vh_indices"], compression="gzip", shuffle=True)
631
+ grp.create_dataset("vh_values", data=r["vh_values"], compression="gzip", shuffle=True)
632
+ grp.attrs["vh_shape"] = r["vh_shape"]
633
+ grp.attrs["ns_original"] = r["ns_original"]
634
+ grp.attrs["ns_extended"] = r["ns_extended"]
635
+ grp.attrs["left_overlap"] = r["left_overlap"]
636
+ grp.attrs["epsilon"] = r["epsilon"]
637
+ grp.attrs["alpha"] = r["alpha"]
638
+ grp.attrs["cr_svd"] = r["cr_svd"]
639
+ grp.attrs["cr_wp"] = r["cr_wp"]
640
+ grp.attrs["cr_total"] = r["cr_total"]
641
+ grp.attrs["rmse"] = r["rmse"]
642
+ total_cr += r["cr_total"]
643
+
644
+ print(f"Saved {out_h5} mean CR={total_cr / n_chunks:.0f}")
645
+ return out_h5
646
+
647
+
648
+ def _transcopy_group(src_group, dst_group):
649
+ """Recursively copy an HDF5 group by reading and re-writing each dataset.
650
+
651
+ Unlike h5py's built-in copy(), this transcodes every dataset through NumPy,
652
+ so the destination format is fully controlled by how dst_group's file was
653
+ opened — independent of the source file's HDF5 version.
654
+ """
655
+ import h5py
656
+
657
+ for key, val in src_group.attrs.items():
658
+ dst_group.attrs[key] = val
659
+ for name, item in src_group.items():
660
+ if isinstance(item, h5py.Group):
661
+ _transcopy_group(item, dst_group.require_group(name))
662
+ else:
663
+ kw = {}
664
+ if item.chunks and all(c <= s for c, s in zip(item.chunks, item.shape)):
665
+ kw["chunks"] = item.chunks
666
+ if item.compression:
667
+ kw["compression"] = item.compression
668
+ kw["compression_opts"] = item.compression_opts
669
+ if item.shuffle:
670
+ kw["shuffle"] = item.shuffle
671
+ ds = dst_group.create_dataset(name, data=item[()], **kw)
672
+ for key, val in item.attrs.items():
673
+ ds.attrs[key] = val
674
+
675
+
676
+ def merge_h5(src_files, dst_h5, recording_map=None):
677
+ """
678
+ Merge multiple per-recording HDF5 files into one multi-recording archive.
679
+
680
+ Each source file must contain exactly one top-level recording group (the
681
+ layout produced by ``compress_to_h5`` and ``compress_bin_to_h5``). The
682
+ entire group is copied verbatim — no re-compression is performed.
683
+
684
+ Parameters
685
+ ----------
686
+ src_files : sequence of path-like
687
+ Source HDF5 files, one recording per file.
688
+ dst_h5 : path-like
689
+ Output multi-recording HDF5 file (always created fresh).
690
+ recording_map : dict mapping path-like to str, optional
691
+ Override the recording name for specific source files. Keys are
692
+ matched by resolved absolute path. Files absent from the map retain
693
+ their original top-level group name.
694
+
695
+ Returns
696
+ -------
697
+ Path
698
+ Resolved path to *dst_h5*.
699
+
700
+ Raises
701
+ ------
702
+ ValueError
703
+ If a source file contains more than one top-level group, or if two
704
+ source files would resolve to the same recording name.
705
+ """
706
+ import h5py
707
+
708
+ resolved_map = {Path(k).resolve(): v for k, v in (recording_map or {}).items()}
709
+
710
+ # Build the plan before touching dst_h5 so errors surface early.
711
+ plan = []
712
+ for src in src_files:
713
+ src_path = Path(src).resolve()
714
+ with h5py.File(src_path, "r") as f:
715
+ keys = list(f.keys())
716
+ if len(keys) != 1:
717
+ raise ValueError(
718
+ f"{src_path.name} has {len(keys)} top-level groups {keys}; "
719
+ "merge_h5 requires exactly one recording per source file"
720
+ )
721
+ recording = resolved_map.get(src_path, keys[0])
722
+ plan.append((src_path, recording, keys[0]))
723
+
724
+ seen: set = set()
725
+ dupes: list = []
726
+ for _, recording, _ in plan:
727
+ if recording in seen:
728
+ dupes.append(recording)
729
+ else:
730
+ seen.add(recording)
731
+ if dupes:
732
+ raise ValueError(f"Duplicate recording name(s): {sorted(set(dupes))}")
733
+
734
+ dst_h5 = Path(dst_h5)
735
+ with h5py.File(dst_h5, "w", libver=_H5_LIBVER) as dst:
736
+ for src_path, recording, src_key in tqdm(plan, desc=dst_h5.stem, unit="PID"):
737
+ with h5py.File(src_path, "r") as src:
738
+ _transcopy_group(src[src_key], dst.require_group(recording))
739
+
740
+ return dst_h5.resolve()
741
+
742
+
743
+ def compress_bin_to_h5(
744
+ bin_file,
745
+ out_h5,
746
+ recording=None,
747
+ q=10,
748
+ h=None,
749
+ cadzow_checkpoint_file=None,
750
+ cadzow_kwargs=None,
751
+ channel_labels=None,
752
+ epsilon=150.0,
753
+ alpha=28.0,
754
+ n_jobs=4,
755
+ chunk=_COMPRESS_CHUNK,
756
+ overlap=_COMPRESS_OVERLAP,
757
+ highpass_cutoff=2.0,
758
+ car=True,
759
+ fig_dir=None,
760
+ t0_sync=None,
761
+ fs_sync=None,
762
+ ):
763
+ """
764
+ Full pipeline: raw LFP binary → decimate → Cadzow denoise → SVD+WP compress → HDF5.
765
+
766
+ Decimation uses ibldsp.voltage.resample_denoise_lfp_cbin (FIR anti-aliasing). Cadzow
767
+ denoising is performed inside each decimation worker when *cadzow_kwargs* is provided.
768
+ An intermediate float32 checkpoint (.npy) is always written — either to the path given
769
+ by *cadzow_checkpoint_file* or to a sibling temp file that is deleted after the HDF5 is
770
+ finalised. If the checkpoint file already exists its contents are used directly, skipping
771
+ the expensive decimate+denoise step.
772
+
773
+ Bad channels are detected automatically (via ibldsp.voltage.detect_bad_channels_cbin)
774
+ before decimation unless *channel_labels* is supplied or the checkpoint already exists.
775
+ Detected bad channels are interpolated by resample_denoise_lfp_cbin before SVD, which
776
+ prevents incoherent channels from collapsing the noise-floor estimate and inflating rank.
777
+
778
+ Parameters
779
+ ----------
780
+ bin_file : path-like
781
+ SpikeGLX LFP binary (.cbin or .bin). The .meta file must be in the same directory.
782
+ out_h5 : path-like
783
+ Output HDF5 file (created or overwritten).
784
+ recording : str or None
785
+ Unique key for this recording (e.g. a probe-insertion UUID). Stored as the
786
+ top-level HDF5 group; multiple recordings can coexist in one file.
787
+ Defaults to the stem of bin_file when None.
788
+ q : int
789
+ Decimation factor. Default 10 (2500 → 250 Hz).
790
+ h : dict or None
791
+ Probe header with keys 'x' and 'y'. Defaults to NP1 geometry for nc channels.
792
+ cadzow_checkpoint_file : path-like or None
793
+ Path for the intermediate Cadzow .npy checkpoint (ns_lf, nc) float32.
794
+ If None a temporary file is written next to out_h5 and deleted afterwards.
795
+ If the file already exists the decimate+Cadzow step is skipped entirely.
796
+ cadzow_kwargs : dict or None
797
+ Forwarded to resample_denoise_lfp_cbin as cadzow_kwargs; keys match
798
+ ibldsp.cadzow.cadzow_denoiser parameters (rank, niter, fmax, nswx, ovx,
799
+ gap_threshold, ppca_k). Default None disables Cadzow (pure decimation).
800
+ channel_labels : np.ndarray or None
801
+ Per-channel quality labels (0=good, 1=dead, 2=noisy, 3=outside brain).
802
+ If None and the checkpoint does not exist, labels are auto-detected via
803
+ ibldsp.voltage.detect_bad_channels_cbin. Pass an array of zeros to skip
804
+ detection explicitly.
805
+ epsilon : float
806
+ SVD threshold multiplier. Default 150.
807
+ alpha : float
808
+ WP threshold multiplier. Default 28.
809
+ n_jobs : int
810
+ Parallel workers for the decimate+Cadzow stage. Default 4.
811
+ chunk : int
812
+ Compress chunk size in decimated samples. Default 2048.
813
+ overlap : int
814
+ SVD guard-band samples each side. Default 128.
815
+ highpass_cutoff : float or None
816
+ 3rd-order Butterworth zero-phase highpass corner [Hz] applied before decimation.
817
+ Default 2.0 Hz. None disables the filter.
818
+ car : bool
819
+ Apply median common-average reference before decimation. Default True.
820
+ fig_dir : path-like or None
821
+ If set, a bad-channel diagnostic figure is saved to this directory after detection.
822
+ Uses ibldsp.plots.show_channels_labels on a single mid-recording batch.
823
+ Filename: ``bad_channels_{bin_file.stem}.png``. Default None (no figure).
824
+
825
+ Returns
826
+ -------
827
+ Path
828
+ Path to the output HDF5 file.
829
+ """
830
+ from ibldsp.voltage import detect_bad_channels_cbin, resample_denoise_lfp_cbin
831
+
832
+ bin_file = Path(bin_file)
833
+ out_h5 = Path(out_h5)
834
+ if recording is None:
835
+ recording = bin_file.stem
836
+ n_jobs = os.cpu_count() if n_jobs == -1 else n_jobs
837
+
838
+ sr = _spikeglx.Reader(bin_file)
839
+ nc = sr.nc - sr.nsync
840
+ fs_lf = sr.fs / q
841
+ sglx_meta = sr.meta
842
+
843
+ if h is None:
844
+ _h = neuropixel.trace_header(version=1)
845
+ h = {k: v[:nc] for k, v in _h.items()}
846
+
847
+ # Determine checkpoint path
848
+ if cadzow_checkpoint_file is None:
849
+ cadzow_npy = out_h5.with_suffix(".cadzow_tmp.npy")
850
+ delete_checkpoint = True
851
+ else:
852
+ cadzow_npy = Path(cadzow_checkpoint_file)
853
+ delete_checkpoint = False
854
+
855
+ # Stage 1: decimate (+ optional Cadzow) → float32 checkpoint
856
+ if cadzow_npy.exists():
857
+ print(f"Using existing Cadzow checkpoint {cadzow_npy}")
858
+ else:
859
+ if channel_labels is None:
860
+ print("Detecting bad channels …")
861
+ channel_labels, xfeats_med = detect_bad_channels_cbin(sr, return_features=True)
862
+ n_bad = int(np.sum(channel_labels != 0))
863
+ print(f" {n_bad} / {nc} channels flagged (labels: {np.unique(channel_labels, return_counts=True)})")
864
+ if fig_dir is not None:
865
+ import matplotlib.pyplot as plt
866
+ from ibldsp.plots import show_channels_labels
867
+
868
+ batch_dur = 1e4 / sr.fs
869
+ t_mid = (sr.rl - batch_dur) / 2
870
+ sl = slice(int(t_mid * sr.fs), int((t_mid + batch_dur) * sr.fs))
871
+ raw_batch = sr[sl, :nc].T
872
+ fig, _ = show_channels_labels(raw_batch, sr.fs, channel_labels, xfeats_med, psd_hf_threshold=1.4)
873
+ fig.suptitle(bin_file.stem, fontsize=9)
874
+ fig_path = Path(fig_dir).joinpath(f"bad_channels_{bin_file.stem}.png")
875
+ fig.savefig(fig_path, dpi=150)
876
+ plt.close(fig)
877
+ print(f" Channel labels figure → {fig_path}")
878
+ resample_denoise_lfp_cbin(
879
+ bin_file,
880
+ q=q,
881
+ output=cadzow_npy,
882
+ dtype=np.float32,
883
+ channel_labels=channel_labels,
884
+ highpass_cutoff=highpass_cutoff,
885
+ car=car,
886
+ cadzow_kwargs=cadzow_kwargs,
887
+ n_jobs=n_jobs,
888
+ )
889
+
890
+ # Stage 2: compress checkpoint → HDF5
891
+ compress_to_h5(
892
+ cadzow_npy,
893
+ out_h5,
894
+ recording=recording,
895
+ sglx_meta=sglx_meta,
896
+ h=h,
897
+ chunk=chunk,
898
+ overlap=overlap,
899
+ epsilon=epsilon,
900
+ alpha=alpha,
901
+ fs=fs_lf,
902
+ t0_sync=t0_sync,
903
+ fs_sync=fs_sync,
904
+ n_jobs=n_jobs,
905
+ )
906
+
907
+ if delete_checkpoint:
908
+ cadzow_npy.unlink()
909
+
910
+ return out_h5
911
+
912
+
913
+ class LFPackReader(_spikeglx.Reader):
914
+ """
915
+ Drop-in spikeglx.Reader for HDF5-packed LFP-compressed files.
916
+
917
+ Chunks are decompressed on demand. No sync trace is available; read() with
918
+ sync=True returns None as the second element. Data is returned in volts (float32)
919
+ in the same (n_samples, n_channels) convention as spikeglx.Reader.
920
+
921
+ The HDF5 layout is /<recording>/<scale_str>/meta and /<recording>/<scale_str>/chunks/.
922
+ A file may contain multiple recordings and/or multiple scale levels. When a file
923
+ contains exactly one recording the key is auto-detected; otherwise pass recording=.
924
+
925
+ Parameters
926
+ ----------
927
+ h5_file : path-like
928
+ HDF5 archive produced by compress_to_h5.
929
+ recording : str or None
930
+ Recording key (top-level group name). Auto-detected when the file contains
931
+ exactly one recording; raises ValueError for multi-recording files.
932
+ scale : int
933
+ Resolution level to open. 0 = base (full LFP rate). Default 0.
934
+ bin_channels : int
935
+ Number of adjacent channels to sum together on every read. ``1``
936
+ (default) means no binning. When set, ``nc``, ``shape``, and
937
+ ``geometry`` all reflect the binned dimension, and slicing
938
+ (``sr[0:2500, :]``) returns ``(n_samples, nc // bin_channels)``
939
+ without any extra arguments.
940
+
941
+ Examples
942
+ --------
943
+ >>> sr = LFPackReader('lf_compressed.h5')
944
+ >>> sr[0:2500, :] # (2500, nc)
945
+ >>> sr4 = LFPackReader('lf_compressed.h5', bin_channels=4)
946
+ >>> sr4[0:2500, :] # (2500, nc // 4)
947
+ >>> sr4.nc # nc // 4
948
+ >>> sr4.shape # (ns, nc // 4)
949
+ >>> sr4.geometry['y'].shape # (nc // 4,)
950
+ """
951
+
952
+ def __init__(self, h5_file, recording=None, scale=0, bin_channels=1):
953
+ import h5py
954
+
955
+ self._h5_file = Path(h5_file)
956
+ self._h5 = None
957
+ self._raw = None # is_open sentinel (None → closed)
958
+ self._geometry = None
959
+ self.ignore_warnings = False
960
+ self.file_bin = self._h5_file
961
+ self.file_meta_data = None
962
+ self.meta = None # None → base-class properties fall back to _nc/_fs/_ns
963
+ self.dtype = np.dtype("float32")
964
+ self.ch_file = None
965
+ self._bin_channels = bin_channels
966
+
967
+ with h5py.File(self._h5_file, "r") as f:
968
+ if "meta" in f: # legacy single-recording format (no recording/scale hierarchy)
969
+ self._root = None
970
+ else:
971
+ root_keys = list(f.keys())
972
+ if recording is None:
973
+ if len(root_keys) == 1:
974
+ recording = root_keys[0]
975
+ else:
976
+ raise ValueError(f"Multiple recordings in file, specify recording= from: {root_keys}")
977
+ elif recording not in f:
978
+ raise KeyError(f"Recording '{recording}' not found. Available: {root_keys}")
979
+ self._root = f"{recording}/{scale:02d}"
980
+ meta_path = f"{self._root}/meta" if self._root else "meta"
981
+ chunks_path = f"{self._root}/chunks" if self._root else "chunks"
982
+ attrs = f[meta_path].attrs
983
+ self._nc = int(attrs["nc"])
984
+ self._ns = int(attrs["ns_total"])
985
+ self._fs = float(attrs["fs"])
986
+ _v = attrs.get("fs_sync", np.nan)
987
+ self._fs_sync = float(_v) if not np.isnan(_v) else None
988
+ _v = attrs.get("t0_sync", np.nan)
989
+ self._t0_sync = float(_v) if not np.isnan(_v) else None
990
+ self._compress_chunk = int(attrs["compress_chunk"])
991
+ self._n_chunks = len(f[chunks_path])
992
+ self.sglx_meta = _json.loads(attrs["sglx_meta"])
993
+ self._geometry = {
994
+ "x": attrs["geometry_x"][:].astype(np.float32),
995
+ "y": attrs["geometry_y"][:].astype(np.float32),
996
+ }
997
+
998
+ self._nsync = 0
999
+ # Data is already in volts; s2v = 1.0 for all channels.
1000
+ self.channel_conversion_sample2v = {"samples": np.ones(self._nc, dtype=np.float32)}
1001
+ self.open()
1002
+
1003
+ @property
1004
+ def bin_channels(self):
1005
+ """Number of adjacent channels summed on every read (1 = no binning)."""
1006
+ return self._bin_channels
1007
+
1008
+ @bin_channels.setter
1009
+ def bin_channels(self, value):
1010
+ self._bin_channels = int(value)
1011
+
1012
+ @property
1013
+ def nc(self):
1014
+ """Number of output channels (raw nc // bin_channels)."""
1015
+ return self._nc // self._bin_channels
1016
+
1017
+ @property
1018
+ def geometry(self):
1019
+ """Probe geometry averaged over each bin group.
1020
+
1021
+ When ``bin_channels == 1`` this is identical to ``geometry_full``.
1022
+ Use ``geometry_full`` to always get the raw per-electrode positions.
1023
+
1024
+ Returns
1025
+ -------
1026
+ dict with keys 'x' and 'y', each an ndarray of shape (nc,).
1027
+ """
1028
+ if self._bin_channels == 1:
1029
+ return self._geometry
1030
+ n = self._bin_channels
1031
+ nc_binned = self._nc // n
1032
+ return {k: self._geometry[k][: nc_binned * n].reshape(nc_binned, n).mean(axis=1) for k in ("x", "y")}
1033
+
1034
+ @geometry.setter
1035
+ def geometry(self, value):
1036
+ # spikeglx.Reader base class assigns self.geometry = None in some paths;
1037
+ # route those writes to the private backing store.
1038
+ self._geometry = value
1039
+
1040
+ @property
1041
+ def geometry_full(self):
1042
+ """Full per-electrode probe geometry, independent of ``bin_channels``.
1043
+
1044
+ Includes a ``'binned_channel_index'`` field mapping each raw channel to its
1045
+ corresponding output channel index (``raw_channel // bin_channels``).
1046
+
1047
+ Returns
1048
+ -------
1049
+ dict with keys 'x', 'y', and 'binned_channel_index', each an ndarray of shape (nc_raw,).
1050
+ """
1051
+ n = self._bin_channels
1052
+ binned_channel_index = np.arange(self._nc, dtype=np.int32) // n
1053
+ return {**self._geometry, "binned_channel_index": binned_channel_index}
1054
+
1055
+ @staticmethod
1056
+ def recordings(h5_file):
1057
+ """List recording keys at the root of an H5 file written by compress_to_h5.
1058
+
1059
+ Parameters
1060
+ ----------
1061
+ h5_file : path-like
1062
+
1063
+ Returns
1064
+ -------
1065
+ list of str
1066
+ """
1067
+ import h5py
1068
+
1069
+ with h5py.File(h5_file, "r") as f:
1070
+ if "meta" in f: # legacy format
1071
+ return []
1072
+ return list(f.keys())
1073
+
1074
+ @staticmethod
1075
+ def scales(h5_file, recording):
1076
+ """List scale indices available for a recording.
1077
+
1078
+ Parameters
1079
+ ----------
1080
+ h5_file : path-like
1081
+ recording : str
1082
+
1083
+ Returns
1084
+ -------
1085
+ list of int
1086
+ """
1087
+ import h5py
1088
+
1089
+ with h5py.File(h5_file, "r") as f:
1090
+ if recording not in f:
1091
+ raise KeyError(f"Recording '{recording}' not found")
1092
+ return sorted(int(k) for k in f[recording].keys() if k.isdigit())
1093
+
1094
+ def open(self):
1095
+ import h5py
1096
+
1097
+ self._h5 = h5py.File(self._h5_file, "r")
1098
+ self._raw = True # non-None sentinel so base-class is_open returns True
1099
+
1100
+ def close(self):
1101
+ if self._h5 is not None:
1102
+ self._h5.close()
1103
+ self._h5 = None
1104
+ self._raw = None
1105
+
1106
+ @property
1107
+ def is_open(self):
1108
+ return self._h5 is not None
1109
+
1110
+ @property
1111
+ def is_mtscomp(self):
1112
+ return False
1113
+
1114
+ @property
1115
+ def t0(self):
1116
+ """Session-clock time in seconds at LFP sample 0. NaN when no sync data."""
1117
+ return self._t0_sync if self._t0_sync is not None else np.nan
1118
+
1119
+ @property
1120
+ def fs(self):
1121
+ """LFP sample rate in Hz, sync-corrected when sync data is present."""
1122
+ return self._fs_sync if self._fs_sync is not None else self._fs
1123
+
1124
+ @property
1125
+ def times(self):
1126
+ """Session-clock time in seconds for every LFP sample (ns,)."""
1127
+ t0 = self._t0_sync if self._t0_sync is not None else 0.0
1128
+ return t0 + np.arange(self._ns) / self.fs
1129
+
1130
+ @property
1131
+ def ns(self):
1132
+ return self._ns
1133
+
1134
+ def read(self, nsel=slice(0, 10000), csel=slice(None), sync=True, bin_channels=None):
1135
+ """
1136
+ Decompress and return a sample range.
1137
+
1138
+ Parameters
1139
+ ----------
1140
+ nsel : slice or int
1141
+ Sample selection (Python slice convention).
1142
+ csel : slice or array-like
1143
+ Channel selection applied after spatial binning.
1144
+ sync : bool
1145
+ If True returns (data, None); no sync trace in compressed files.
1146
+ bin_channels : int or None
1147
+ Number of adjacent channels to sum together. ``None`` uses
1148
+ ``self.bin_channels``. ``csel`` indexes into the binned channels.
1149
+
1150
+ Returns
1151
+ -------
1152
+ data : ndarray (n_samples, nc // bin_channels), float32, volts
1153
+ sync : None (only when sync=True)
1154
+ """
1155
+ if bin_channels is None:
1156
+ bin_channels = self._bin_channels
1157
+ if not self.is_open:
1158
+ raise IOError("Reader not open; call open() first.")
1159
+
1160
+ if isinstance(nsel, int):
1161
+ first_sample, last_sample = nsel, nsel + 1
1162
+ else:
1163
+ first_sample = nsel.start if nsel.start is not None else 0
1164
+ last_sample = nsel.stop if nsel.stop is not None else self._ns
1165
+ first_sample = max(0, first_sample)
1166
+ last_sample = min(self._ns, last_sample)
1167
+
1168
+ chunk = self._compress_chunk
1169
+ first_chunk = first_sample // chunk
1170
+ last_chunk = (last_sample - 1) // chunk
1171
+
1172
+ pieces = []
1173
+ for ci in range(first_chunk, last_chunk + 1):
1174
+ chunk_path = f"{self._root}/chunks/{ci}" if self._root else f"chunks/{ci}"
1175
+ grp = self._h5[chunk_path]
1176
+ ns_orig = int(grp.attrs["ns_original"])
1177
+ # Reconstruct dense Vh_hat from sparse storage
1178
+ vh_shape = tuple(int(x) for x in grp.attrs["vh_shape"])
1179
+ Vh_hat = np.zeros(vh_shape, dtype=np.float32)
1180
+ Vh_hat.ravel()[grp["vh_indices"][:]] = grp["vh_values"][:]
1181
+ c = LFPCompressed(
1182
+ U_scaled=grp["U_scaled"][:],
1183
+ Vh_hat=Vh_hat,
1184
+ ns_original=ns_orig,
1185
+ epsilon=float(grp.attrs["epsilon"]),
1186
+ alpha=float(grp.attrs["alpha"]),
1187
+ cr_svd=float(grp.attrs["cr_svd"]),
1188
+ cr_wp=float(grp.attrs["cr_wp"]),
1189
+ cr_total=float(grp.attrs["cr_total"]),
1190
+ left_overlap=int(grp.attrs.get("left_overlap", 0)),
1191
+ ns_extended=int(grp.attrs.get("ns_extended", ns_orig)),
1192
+ )
1193
+ pieces.append(decompress(c, bin_channels=bin_channels)) # (nc[_binned], ns_chunk_i)
1194
+
1195
+ full = np.concatenate(pieces, axis=1) # (nc[_binned], total_samples)
1196
+ start = first_sample - first_chunk * chunk
1197
+ data = full[:, start : start + (last_sample - first_sample)] # (nc[_binned], n_req)
1198
+
1199
+ # Transpose to spikeglx convention (n_samples, nc[_binned])
1200
+ data = data.T.astype(np.float32)
1201
+ if not (isinstance(csel, slice) and csel == slice(None)):
1202
+ data = data[:, csel]
1203
+
1204
+ if sync:
1205
+ return data, None
1206
+ return data
1207
+
1208
+ def read_samples(self, first_sample=0, last_sample=10000, channels=None, bin_channels=None):
1209
+ """
1210
+ Read and decompress a sample range with optional spatial binning.
1211
+
1212
+ Parameters
1213
+ ----------
1214
+ first_sample : int
1215
+ last_sample : int
1216
+ channels : slice or array-like or None
1217
+ Channel selection applied after binning. ``None`` selects all.
1218
+ bin_channels : int or None
1219
+ Number of adjacent channels to sum together. ``None`` uses
1220
+ ``self.bin_channels``. Valid values: 1, 2, 4, 6, 8, 12.
1221
+
1222
+ Returns
1223
+ -------
1224
+ ndarray (n_samples, nc // bin_channels), float32, volts
1225
+ """
1226
+ if channels is None:
1227
+ channels = slice(None)
1228
+ return self.read(slice(first_sample, last_sample), channels, bin_channels=bin_channels)
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: lfpack
3
+ Version: 0.1.0
4
+ Summary: LFP codec — lossy compression of local-field-potential recordings via adaptive SVD and wavelet-packet thresholding
5
+ Project-URL: Homepage, https://github.com/int-brain-lab/lfpack
6
+ Project-URL: Bug Tracker, https://github.com/int-brain-lab/lfpack/issues
7
+ Author: The International Brain Laboratory
8
+ License: MIT
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: h5py>=3.0
11
+ Requires-Dist: ibl-neuropixel
12
+ Requires-Dist: numpy
13
+ Requires-Dist: pywavelets
14
+ Requires-Dist: scipy
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest; extra == 'dev'
17
+ Requires-Dist: pytest-cov; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # lfpack — LFP codec for Neuropixels recordings
21
+
22
+ <p align="center">
23
+ <img src="docs/figures/logo.png" alt="lfpack logo" width="300"/>
24
+ </p>
25
+
26
+ Lossy codec for local-field-potential (LFP) recordings from Neuropixels probes.
27
+ Achieves **>100× compression** with median RMSE < 25 µV via an 8-stage pipeline
28
+ (bad-channel detection → dephasing → highpass → interpolation → CAR → decimation → Cadzow → adaptive SVD + wavelet-packet thresholding).
29
+
30
+ ```bash
31
+ pip install lfpack
32
+ ```
33
+
34
+ ## Documentation
35
+
36
+ Full documentation is at **https://int-brain-lab.github.io/lfpack/**.
37
+
38
+ | Section | Contents |
39
+ | --- | --- |
40
+ | [Tutorial](https://int-brain-lab.github.io/lfpack/tutorials/first-compression.html) | End-to-end compression and decompression of a recording |
41
+ | [How-To: binned reads](https://int-brain-lab.github.io/lfpack/how-to/binned-reads.html) | Memory-efficient channel-binned access |
42
+ | [How-To: multi-recording files](https://int-brain-lab.github.io/lfpack/how-to/multi-recording.html) | Combining multiple recordings in one HDF5 file |
43
+ | [API reference](https://int-brain-lab.github.io/lfpack/reference/) | Full public API (`compress_bin_to_h5`, `LFPackReader`, …) |
44
+ | [HDF5 format](https://int-brain-lab.github.io/lfpack/reference/hdf5-layout.html) | On-disk layout specification |
45
+ | [Pipeline explanation](https://int-brain-lab.github.io/lfpack/explanation/pipeline.html) | Stage-by-stage description of the compression pipeline |
46
+ | [SVD+WP benchmark](https://int-brain-lab.github.io/lfpack/explanation/benchmark.html) | RMSE, SNR, and compression-ratio results across 11 insertions |
@@ -0,0 +1,5 @@
1
+ lfpack/__init__.py,sha256=CtnK_qkmpQMxE-h0eb5yMibDkQZUCLlbQXHt3BfBO94,405
2
+ lfpack/_core.py,sha256=a8YkeOM-HdThNhx6JTHXs6mNS8ZQKm2dUAtYZO_4xYw,45501
3
+ lfpack-0.1.0.dist-info/METADATA,sha256=BCSYYZI0o5yFVF6i72AFCI__pAI7a7mSozaUhlVZR30,2255
4
+ lfpack-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
5
+ lfpack-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any