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 +17 -0
- lfpack/_core.py +1228 -0
- lfpack-0.1.0.dist-info/METADATA +46 -0
- lfpack-0.1.0.dist-info/RECORD +5 -0
- lfpack-0.1.0.dist-info/WHEEL +4 -0
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,,
|