nxr-convert 0.2.0__tar.gz
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.
- nxr_convert-0.2.0/LICENSE +21 -0
- nxr_convert-0.2.0/PKG-INFO +178 -0
- nxr_convert-0.2.0/README.md +147 -0
- nxr_convert-0.2.0/pyproject.toml +52 -0
- nxr_convert-0.2.0/src/nxr_convert/__init__.py +7 -0
- nxr_convert-0.2.0/src/nxr_convert/_zarr_compat.py +44 -0
- nxr_convert-0.2.0/src/nxr_convert/atlas/__init__.py +14 -0
- nxr_convert-0.2.0/src/nxr_convert/atlas/atlas_store.py +64 -0
- nxr_convert-0.2.0/src/nxr_convert/atlas/bad_segments.py +21 -0
- nxr_convert-0.2.0/src/nxr_convert/atlas/bands.py +110 -0
- nxr_convert-0.2.0/src/nxr_convert/atlas/build.py +391 -0
- nxr_convert-0.2.0/src/nxr_convert/atlas/cli.py +95 -0
- nxr_convert-0.2.0/src/nxr_convert/atlas/compute.mjs +53 -0
- nxr_convert-0.2.0/src/nxr_convert/atlas/default_subject.py +362 -0
- nxr_convert-0.2.0/src/nxr_convert/atlas/frames.py +280 -0
- nxr_convert-0.2.0/src/nxr_convert/atlas/hcp.py +130 -0
- nxr_convert-0.2.0/src/nxr_convert/atlas/joint.py +93 -0
- nxr_convert-0.2.0/src/nxr_convert/atlas/ladder.py +268 -0
- nxr_convert-0.2.0/src/nxr_convert/atlas/nxr_store.py +165 -0
- nxr_convert-0.2.0/src/nxr_convert/atlas/reduce.py +170 -0
- nxr_convert-0.2.0/src/nxr_convert/atlas/rollup.py +46 -0
- nxr_convert-0.2.0/src/nxr_convert/atlas/scalars.py +167 -0
- nxr_convert-0.2.0/src/nxr_convert/atlas/trees.py +139 -0
- nxr_convert-0.2.0/src/nxr_convert/atlas/vectors.py +262 -0
- nxr_convert-0.2.0/src/nxr_convert/cli.py +265 -0
- nxr_convert-0.2.0/src/nxr_convert/convert.py +293 -0
- nxr_convert-0.2.0/src/nxr_convert/crud.py +912 -0
- nxr_convert-0.2.0/src/nxr_convert/db.py +449 -0
- nxr_convert-0.2.0/src/nxr_convert/entities.py +82 -0
- nxr_convert-0.2.0/src/nxr_convert/events.py +236 -0
- nxr_convert-0.2.0/src/nxr_convert/fibers.py +162 -0
- nxr_convert-0.2.0/src/nxr_convert/grid.py +75 -0
- nxr_convert-0.2.0/src/nxr_convert/inverse.py +265 -0
- nxr_convert-0.2.0/src/nxr_convert/matio.py +29 -0
- nxr_convert-0.2.0/src/nxr_convert/mesh_health.py +181 -0
- nxr_convert-0.2.0/src/nxr_convert/model.sql +1139 -0
- nxr_convert-0.2.0/src/nxr_convert/mri.py +430 -0
- nxr_convert-0.2.0/src/nxr_convert/naming.py +121 -0
- nxr_convert-0.2.0/src/nxr_convert/protocol.py +78 -0
- nxr_convert-0.2.0/src/nxr_convert/py.typed +0 -0
- nxr_convert-0.2.0/src/nxr_convert/raw.py +62 -0
- nxr_convert-0.2.0/src/nxr_convert/results_map.py +86 -0
- nxr_convert-0.2.0/src/nxr_convert/sensors.py +97 -0
- nxr_convert-0.2.0/src/nxr_convert/sources/__init__.py +68 -0
- nxr_convert-0.2.0/src/nxr_convert/sources/model.py +164 -0
- nxr_convert-0.2.0/src/nxr_convert/sources/protocol_db.py +26 -0
- nxr_convert-0.2.0/src/nxr_convert/sources/protocol_mat.py +430 -0
- nxr_convert-0.2.0/src/nxr_convert/sources/walk.py +65 -0
- nxr_convert-0.2.0/src/nxr_convert/subject.py +266 -0
- nxr_convert-0.2.0/src/nxr_convert/surface.py +215 -0
- nxr_convert-0.2.0/src/nxr_convert/timefreq.py +116 -0
- nxr_convert-0.2.0/src/nxr_convert/timeseries.py +152 -0
- nxr_convert-0.2.0/src/nxr_convert/winding.py +55 -0
- nxr_convert-0.2.0/src/nxr_convert/writer_lock.py +330 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Diellor Basha
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nxr-convert
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Convert Brainstorm MEG/EEG protocols into nxr datastores (SQLite rows + Zarr v3 arrays) for the Cortical Flow desktop app.
|
|
5
|
+
Keywords: brainstorm,meg,eeg,neuroimaging,zarr,cortical-flow
|
|
6
|
+
Author: Diellor Basha
|
|
7
|
+
Author-email: Diellor Basha <diellorbasha@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
19
|
+
Requires-Dist: h5py>=3.16.0
|
|
20
|
+
Requires-Dist: numpy>=2.5.2
|
|
21
|
+
Requires-Dist: pymatreader>=1.2.3
|
|
22
|
+
Requires-Dist: scipy>=1.18.0
|
|
23
|
+
Requires-Dist: zarr>=3
|
|
24
|
+
Requires-Dist: nibabel>=5.4.2 ; extra == 'atlas'
|
|
25
|
+
Requires-Python: >=3.12
|
|
26
|
+
Project-URL: Homepage, https://corticalflow.app
|
|
27
|
+
Project-URL: Documentation, https://corticalflow.app/docs
|
|
28
|
+
Project-URL: Source, https://github.com/neurodynamics-xr/nxr-convert
|
|
29
|
+
Provides-Extra: atlas
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# nxr-convert
|
|
33
|
+
|
|
34
|
+
Converts a [Brainstorm](https://neuroimage.usc.edu/brainstorm/) protocol (MEG/EEG recordings, cortical surfaces, MRI
|
|
35
|
+
volumes, head models, inverse kernels, source maps, fibres and connectomes) into an **nxr datastore**: a folder that the
|
|
36
|
+
[Cortical Flow](https://corticalflow.app) desktop app opens.
|
|
37
|
+
|
|
38
|
+
Version **0.2.0**, which writes **database schema 49**. Each dataset's `dataset.sqlite` carries the schema number as
|
|
39
|
+
`PRAGMA user_version`. The app opens only databases with the version it was built for, and the converter opens only its
|
|
40
|
+
own version and refuses any other. Use a converter release that matches your desktop app (0.2.x for Cortical Flow 0.2).
|
|
41
|
+
|
|
42
|
+
## What it writes
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
<datastore>/
|
|
46
|
+
<dataset>/
|
|
47
|
+
dataset.sqlite the dataset's database: one row per subject, surface, recording, kernel, …
|
|
48
|
+
<subject>.nxr.zarr/ one Zarr v3 store per subject; the bytes (positions, faces, timeseries, kernels …)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The rows are written first, and every `zarr.json` is then rewritten from the rows, so the store describes itself. A
|
|
52
|
+
Brainstorm **condition** becomes a *session* of the subject. The protocol's default anatomy (`@default_subject`) becomes
|
|
53
|
+
the dataset's **template** subject, and `Group_analysis` becomes its **group** subject.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
Python 3.12 or newer. With [uv](https://docs.astral.sh/uv/):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv tool install git+https://github.com/neurodynamics-xr/nxr-convert
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
or with [pipx](https://pipx.pypa.io/):
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pipx install git+https://github.com/neurodynamics-xr/nxr-convert
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Check the install:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
nxr-convert --version # nxr-convert 0.2.0 (database schema 49)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The `atlas` commands also need the `atlas` extra (`nibabel`), plus Node.js and the nxr-compute Node binding
|
|
76
|
+
(`NXR_COMPUTE`). Install the extra with `uv tool install "nxr-convert[atlas] @ git+https://github.com/neurodynamics-xr/nxr-convert"`.
|
|
77
|
+
The conversion commands below need neither.
|
|
78
|
+
|
|
79
|
+
## Use with the Cortical Flow desktop app
|
|
80
|
+
|
|
81
|
+
The desktop app's **Import** runs this converter. It looks for one in this order:
|
|
82
|
+
|
|
83
|
+
1. `NXR_CONVERT_CMD`, an explicit command (for example `uv run --project /path/to/nxr-convert nxr-convert`);
|
|
84
|
+
2. a development checkout of the app's monorepo, run through `uv`;
|
|
85
|
+
3. `nxr-convert` on `PATH`;
|
|
86
|
+
4. `~/.local/bin/nxr-convert` (`nxr-convert.exe` on Windows), where `uv tool install` and `pipx install` put it — found
|
|
87
|
+
even when the app was started from the Dock or Finder and did not inherit your shell's `PATH`.
|
|
88
|
+
|
|
89
|
+
If none is found, the Import page shows a setup card. For an install elsewhere, set `NXR_CONVERT_CMD` to its full path.
|
|
90
|
+
|
|
91
|
+
## Commands
|
|
92
|
+
|
|
93
|
+
Every command prints its progress as JSON lines on stdout, one object per event keyed by `stage`. The app parses this
|
|
94
|
+
output. A failure ends with a `{"stage": "error", "message": …}` line and exit status 1.
|
|
95
|
+
|
|
96
|
+
### Datasets
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
nxr-convert dataset create <datastore> <name> [--source-tool brainstorm] [--source-protocol P] [--source-path S]
|
|
100
|
+
nxr-convert dataset delete <datastore> <name>
|
|
101
|
+
nxr-convert dataset recover <datastore>/<name> # roll back unfinished writes, finish deletions, rewrite the store
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`dataset create` makes `<datastore>/<name>/dataset.sqlite` from the schema. It also creates the datastore folder if
|
|
105
|
+
it does not exist yet.
|
|
106
|
+
|
|
107
|
+
### Reading a protocol
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
nxr-convert list <protocol> # its subjects and conditions, as JSON
|
|
111
|
+
nxr-convert scan <protocol> [--prefer protocol.mat|sqlite|walk] [--out view.json] [--summary]
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Converting
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# one whole subject as one composition: every session, MRI volumes, source maps, fibres, connectomes
|
|
118
|
+
nxr-convert subject <protocol> --subject <name> --dataset <datastore>/<dataset>
|
|
119
|
+
|
|
120
|
+
# one condition as a session of a subject (the subject is created if it is absent)
|
|
121
|
+
nxr-convert convert <protocol> --subject <name> --condition <condition> --dataset <datastore>/<dataset> \
|
|
122
|
+
[--surface tess_*.mat] [--extra-surface tess_*.mat …] [--recording data_*.mat] [--channel-file F] \
|
|
123
|
+
[--no-gain] [--all-surfaces] [--include-imported] [--no-raw]
|
|
124
|
+
|
|
125
|
+
# the protocol's default anatomy as the TEMPLATE subject, and its group results as the GROUP subject
|
|
126
|
+
nxr-convert template <protocol> --dataset <datastore>/<dataset> [--surface tess_*.mat …]
|
|
127
|
+
nxr-convert group <protocol> --dataset <datastore>/<dataset>
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`<protocol>` is the Brainstorm protocol folder, the one that holds `anat/` and `data/`.
|
|
131
|
+
|
|
132
|
+
### Adding to or removing from a subject
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
nxr-convert surface --dataset D --subject S --file tess_*.mat [tess_*.mat …] [--bst-root <protocol>]
|
|
136
|
+
nxr-convert mri --dataset D --subject S --anat <protocol>/anat/<subject> [--only NAME …]
|
|
137
|
+
nxr-convert inverse --dataset D --subject S --file results_*KERNEL*.mat --session C --bst-root <protocol> \
|
|
138
|
+
[--channels-id ID] [--surface-id ID] [--forward-id ID] [--name N] [--channel-file F]
|
|
139
|
+
nxr-convert remove --dataset D --subject S [--node <store path>] # the subject, or one node of it
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### The atlas (optional)
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
nxr-convert atlas default-subject <datastore>/<dataset> --templates <FreeSurfer subjects dir> […]
|
|
146
|
+
nxr-convert atlas build <datastore>/<dataset> --subject S [--replace] […]
|
|
147
|
+
nxr-convert atlas reduce <datastore>/<dataset>
|
|
148
|
+
nxr-convert atlas info <datastore>/<dataset>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Run `nxr-convert <command> --help` for every flag.
|
|
152
|
+
|
|
153
|
+
## One writer per dataset
|
|
154
|
+
|
|
155
|
+
Each command that writes a dataset first takes its **writer lock**, `<datastore>/<dataset>/.writer.lock`. The lock is a
|
|
156
|
+
small JSON file (`pid`, `host`, `program`, `started_utc`) created atomically. If another live process holds the lock
|
|
157
|
+
(the desktop app, or a second conversion), the command is refused, and the error names the holder. A lock left by a
|
|
158
|
+
process that has died on the same machine is taken over. A lock held by another host is never broken; remove it by hand
|
|
159
|
+
if that host is gone. When the desktop app runs the converter during its own import, it already holds the lock and
|
|
160
|
+
passes it on through `NXR_WRITER_LOCK_HELD`. The lock is advisory, so do not edit a dataset by other means while a
|
|
161
|
+
conversion runs.
|
|
162
|
+
|
|
163
|
+
## Development
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
uv sync
|
|
167
|
+
uv run pytest -q
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Tests that need a real Brainstorm protocol skip unless `NXR_TEST_PROTOCOL` points at one (Brainstorm's
|
|
171
|
+
`TutorialAuditory`).
|
|
172
|
+
|
|
173
|
+
This repository is a published snapshot. The converter is developed inside the Cortical Flow monorepo, and its
|
|
174
|
+
database schema (`src/nxr_convert/model.sql`) is a copy of the app's schema.
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# nxr-convert
|
|
2
|
+
|
|
3
|
+
Converts a [Brainstorm](https://neuroimage.usc.edu/brainstorm/) protocol (MEG/EEG recordings, cortical surfaces, MRI
|
|
4
|
+
volumes, head models, inverse kernels, source maps, fibres and connectomes) into an **nxr datastore**: a folder that the
|
|
5
|
+
[Cortical Flow](https://corticalflow.app) desktop app opens.
|
|
6
|
+
|
|
7
|
+
Version **0.2.0**, which writes **database schema 49**. Each dataset's `dataset.sqlite` carries the schema number as
|
|
8
|
+
`PRAGMA user_version`. The app opens only databases with the version it was built for, and the converter opens only its
|
|
9
|
+
own version and refuses any other. Use a converter release that matches your desktop app (0.2.x for Cortical Flow 0.2).
|
|
10
|
+
|
|
11
|
+
## What it writes
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
<datastore>/
|
|
15
|
+
<dataset>/
|
|
16
|
+
dataset.sqlite the dataset's database: one row per subject, surface, recording, kernel, …
|
|
17
|
+
<subject>.nxr.zarr/ one Zarr v3 store per subject; the bytes (positions, faces, timeseries, kernels …)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The rows are written first, and every `zarr.json` is then rewritten from the rows, so the store describes itself. A
|
|
21
|
+
Brainstorm **condition** becomes a *session* of the subject. The protocol's default anatomy (`@default_subject`) becomes
|
|
22
|
+
the dataset's **template** subject, and `Group_analysis` becomes its **group** subject.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
Python 3.12 or newer. With [uv](https://docs.astral.sh/uv/):
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv tool install git+https://github.com/neurodynamics-xr/nxr-convert
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
or with [pipx](https://pipx.pypa.io/):
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pipx install git+https://github.com/neurodynamics-xr/nxr-convert
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Check the install:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
nxr-convert --version # nxr-convert 0.2.0 (database schema 49)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The `atlas` commands also need the `atlas` extra (`nibabel`), plus Node.js and the nxr-compute Node binding
|
|
45
|
+
(`NXR_COMPUTE`). Install the extra with `uv tool install "nxr-convert[atlas] @ git+https://github.com/neurodynamics-xr/nxr-convert"`.
|
|
46
|
+
The conversion commands below need neither.
|
|
47
|
+
|
|
48
|
+
## Use with the Cortical Flow desktop app
|
|
49
|
+
|
|
50
|
+
The desktop app's **Import** runs this converter. It looks for one in this order:
|
|
51
|
+
|
|
52
|
+
1. `NXR_CONVERT_CMD`, an explicit command (for example `uv run --project /path/to/nxr-convert nxr-convert`);
|
|
53
|
+
2. a development checkout of the app's monorepo, run through `uv`;
|
|
54
|
+
3. `nxr-convert` on `PATH`;
|
|
55
|
+
4. `~/.local/bin/nxr-convert` (`nxr-convert.exe` on Windows), where `uv tool install` and `pipx install` put it — found
|
|
56
|
+
even when the app was started from the Dock or Finder and did not inherit your shell's `PATH`.
|
|
57
|
+
|
|
58
|
+
If none is found, the Import page shows a setup card. For an install elsewhere, set `NXR_CONVERT_CMD` to its full path.
|
|
59
|
+
|
|
60
|
+
## Commands
|
|
61
|
+
|
|
62
|
+
Every command prints its progress as JSON lines on stdout, one object per event keyed by `stage`. The app parses this
|
|
63
|
+
output. A failure ends with a `{"stage": "error", "message": …}` line and exit status 1.
|
|
64
|
+
|
|
65
|
+
### Datasets
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
nxr-convert dataset create <datastore> <name> [--source-tool brainstorm] [--source-protocol P] [--source-path S]
|
|
69
|
+
nxr-convert dataset delete <datastore> <name>
|
|
70
|
+
nxr-convert dataset recover <datastore>/<name> # roll back unfinished writes, finish deletions, rewrite the store
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`dataset create` makes `<datastore>/<name>/dataset.sqlite` from the schema. It also creates the datastore folder if
|
|
74
|
+
it does not exist yet.
|
|
75
|
+
|
|
76
|
+
### Reading a protocol
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
nxr-convert list <protocol> # its subjects and conditions, as JSON
|
|
80
|
+
nxr-convert scan <protocol> [--prefer protocol.mat|sqlite|walk] [--out view.json] [--summary]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Converting
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# one whole subject as one composition: every session, MRI volumes, source maps, fibres, connectomes
|
|
87
|
+
nxr-convert subject <protocol> --subject <name> --dataset <datastore>/<dataset>
|
|
88
|
+
|
|
89
|
+
# one condition as a session of a subject (the subject is created if it is absent)
|
|
90
|
+
nxr-convert convert <protocol> --subject <name> --condition <condition> --dataset <datastore>/<dataset> \
|
|
91
|
+
[--surface tess_*.mat] [--extra-surface tess_*.mat …] [--recording data_*.mat] [--channel-file F] \
|
|
92
|
+
[--no-gain] [--all-surfaces] [--include-imported] [--no-raw]
|
|
93
|
+
|
|
94
|
+
# the protocol's default anatomy as the TEMPLATE subject, and its group results as the GROUP subject
|
|
95
|
+
nxr-convert template <protocol> --dataset <datastore>/<dataset> [--surface tess_*.mat …]
|
|
96
|
+
nxr-convert group <protocol> --dataset <datastore>/<dataset>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`<protocol>` is the Brainstorm protocol folder, the one that holds `anat/` and `data/`.
|
|
100
|
+
|
|
101
|
+
### Adding to or removing from a subject
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
nxr-convert surface --dataset D --subject S --file tess_*.mat [tess_*.mat …] [--bst-root <protocol>]
|
|
105
|
+
nxr-convert mri --dataset D --subject S --anat <protocol>/anat/<subject> [--only NAME …]
|
|
106
|
+
nxr-convert inverse --dataset D --subject S --file results_*KERNEL*.mat --session C --bst-root <protocol> \
|
|
107
|
+
[--channels-id ID] [--surface-id ID] [--forward-id ID] [--name N] [--channel-file F]
|
|
108
|
+
nxr-convert remove --dataset D --subject S [--node <store path>] # the subject, or one node of it
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### The atlas (optional)
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
nxr-convert atlas default-subject <datastore>/<dataset> --templates <FreeSurfer subjects dir> […]
|
|
115
|
+
nxr-convert atlas build <datastore>/<dataset> --subject S [--replace] […]
|
|
116
|
+
nxr-convert atlas reduce <datastore>/<dataset>
|
|
117
|
+
nxr-convert atlas info <datastore>/<dataset>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Run `nxr-convert <command> --help` for every flag.
|
|
121
|
+
|
|
122
|
+
## One writer per dataset
|
|
123
|
+
|
|
124
|
+
Each command that writes a dataset first takes its **writer lock**, `<datastore>/<dataset>/.writer.lock`. The lock is a
|
|
125
|
+
small JSON file (`pid`, `host`, `program`, `started_utc`) created atomically. If another live process holds the lock
|
|
126
|
+
(the desktop app, or a second conversion), the command is refused, and the error names the holder. A lock left by a
|
|
127
|
+
process that has died on the same machine is taken over. A lock held by another host is never broken; remove it by hand
|
|
128
|
+
if that host is gone. When the desktop app runs the converter during its own import, it already holds the lock and
|
|
129
|
+
passes it on through `NXR_WRITER_LOCK_HELD`. The lock is advisory, so do not edit a dataset by other means while a
|
|
130
|
+
conversion runs.
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
uv sync
|
|
136
|
+
uv run pytest -q
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Tests that need a real Brainstorm protocol skip unless `NXR_TEST_PROTOCOL` points at one (Brainstorm's
|
|
140
|
+
`TutorialAuditory`).
|
|
141
|
+
|
|
142
|
+
This repository is a published snapshot. The converter is developed inside the Cortical Flow monorepo, and its
|
|
143
|
+
database schema (`src/nxr_convert/model.sql`) is a copy of the app's schema.
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "nxr-convert"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Convert Brainstorm MEG/EEG protocols into nxr datastores (SQLite rows + Zarr v3 arrays) for the Cortical Flow desktop app."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
keywords = ["brainstorm", "meg", "eeg", "neuroimaging", "zarr", "cortical-flow"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Intended Audience :: Science/Research",
|
|
12
|
+
"Operating System :: OS Independent",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Topic :: Scientific/Engineering",
|
|
18
|
+
"Topic :: Scientific/Engineering :: Medical Science Apps.",
|
|
19
|
+
]
|
|
20
|
+
authors = [
|
|
21
|
+
{ name = "Diellor Basha", email = "diellorbasha@gmail.com" }
|
|
22
|
+
]
|
|
23
|
+
requires-python = ">=3.12"
|
|
24
|
+
dependencies = [
|
|
25
|
+
"h5py>=3.16.0",
|
|
26
|
+
"numpy>=2.5.2",
|
|
27
|
+
"pymatreader>=1.2.3",
|
|
28
|
+
"scipy>=1.18.0",
|
|
29
|
+
"zarr>=3",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://corticalflow.app"
|
|
34
|
+
Documentation = "https://corticalflow.app/docs"
|
|
35
|
+
Source = "https://github.com/neurodynamics-xr/nxr-convert"
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["uv_build>=0.11.7,<0.12.0"]
|
|
39
|
+
build-backend = "uv_build"
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=9.1.1",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
nxr-convert = "nxr_convert.cli:main"
|
|
48
|
+
|
|
49
|
+
[project.optional-dependencies]
|
|
50
|
+
atlas = [
|
|
51
|
+
"nibabel>=5.4.2",
|
|
52
|
+
]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""nxr-convert — Brainstorm protocols into nxr datastores (the Cortical Flow desktop app's importer)."""
|
|
2
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
__version__ = _version("nxr-convert")
|
|
6
|
+
except PackageNotFoundError: # run from a source tree that was never installed
|
|
7
|
+
__version__ = "0+unknown"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Filesystem-compatibility shim for zarr-python's LocalStore.
|
|
2
|
+
|
|
3
|
+
zarr >= 3.3 makes exclusive node creation atomic via a HARDLINK
|
|
4
|
+
(``_safe_move``: ``os.link`` then ``unlink``) — which raises
|
|
5
|
+
``OSError(EOPNOTSUPP/EPERM)`` on filesystems without hardlinks (exFAT is the
|
|
6
|
+
live case: an external drive chosen as the datastore, 2026-08-13). Upstream
|
|
7
|
+
has no fallback as of 3.3.0 (nor on main), so every write into such a
|
|
8
|
+
datastore crashes on the FIRST node.
|
|
9
|
+
|
|
10
|
+
The fallback below keeps the semantics that matter here: ``FileExistsError``
|
|
11
|
+
when the destination exists (what ``os.link`` guaranteed), then a plain
|
|
12
|
+
``os.replace``. The existence check is not atomic against a concurrent
|
|
13
|
+
creator — acceptable by construction: nxr-convert is the datastore's SINGLE
|
|
14
|
+
writer (the app's convert service refuses two subprocesses, and the CLI is
|
|
15
|
+
one process).
|
|
16
|
+
|
|
17
|
+
Imported for its side effect by ``store`` — every writer passes through it.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import errno
|
|
22
|
+
import os
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import zarr.storage._local as _local
|
|
26
|
+
|
|
27
|
+
_HARDLINK_ERRNOS = {errno.EOPNOTSUPP, errno.ENOTSUP, errno.EPERM, errno.EXDEV, 45}
|
|
28
|
+
|
|
29
|
+
_orig_safe_move = _local._safe_move
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _safe_move_with_fallback(src: Path, dst: Path) -> None:
|
|
33
|
+
try:
|
|
34
|
+
_orig_safe_move(src, dst)
|
|
35
|
+
except OSError as e:
|
|
36
|
+
if e.errno not in _HARDLINK_ERRNOS:
|
|
37
|
+
raise
|
|
38
|
+
if os.path.exists(dst):
|
|
39
|
+
os.unlink(src)
|
|
40
|
+
raise FileExistsError(str(dst)) from e
|
|
41
|
+
os.replace(src, dst)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_local._safe_move = _safe_move_with_fallback
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""The DYADIC ATLAS producer (D127–D137; ported from nsp's ``nsp/atlas``, D135).
|
|
2
|
+
|
|
3
|
+
Measurements on the cortex — static maps (PET SUVR), MEG source activity, fibres — reduced onto a bookkeeping
|
|
4
|
+
tree of the cortex (the app's surface ladder, bit-exact) and of time (frames of ``frame_s``, doubling), stored
|
|
5
|
+
as MERGEABLE sums at the leaves only and rolled up by arithmetic (``code >> k``, ``frame >> m``).
|
|
6
|
+
|
|
7
|
+
nxr-convert atlas default-subject <dataset> --templates <dir> the dataset's @default_subject (D127), one composition
|
|
8
|
+
nxr-convert atlas build <dataset> --subject S the subject-side rows + its atlas (D128/D129)
|
|
9
|
+
nxr-convert atlas reduce <dataset> the group sums, on the default subject (D130/D131)
|
|
10
|
+
|
|
11
|
+
There is no atlas format: an atlas is measurement rows on tile partitions over plain arrays (``atlas_store``). Frames and
|
|
12
|
+
the vector heat method run on nxr-compute's Node binding (``compute.mjs``; the addon the app pins). The newer
|
|
13
|
+
DYNAMICS atlas of nsp (grid · placement · engine · spectrum …) is not ported (schema 49 §5).
|
|
14
|
+
"""
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""THE ATLAS AS ROWS (D129–D134): there is no atlas format. An atlas is MEASUREMENTS ON TILE PARTITIONS (D68), array-backed
|
|
2
|
+
(D96) on plain arrays under the subject's ``atlas/`` folder — the folder holds only arrays, and what they are is the
|
|
3
|
+
measurement rows that name them (``array_path``). The default atlas's DEFINITIONS (depth, frame, bands, Levi-Civita
|
|
4
|
+
levels, trajectory, the gauge) are the ``atlas`` block of the default subject's tile SET (``params_json``).
|
|
5
|
+
|
|
6
|
+
<subject>/atlas/trees/<tree>/{codes, w} a tree's leaf codes (the partition's array) and leaf areas
|
|
7
|
+
<subject>/atlas/trees/<tree>/lc/L<k>/{centre,omega,adjacency} explicit per level (path-dependent: not rows)
|
|
8
|
+
<subject>/atlas/spatial/<tree>/maps/<field>/{w,s1,s2,n,min,max}, grad/{…}, grad_lc/L<k>/{re,im}
|
|
9
|
+
<subject>/atlas/time/<recording>__<kernel>/samples, <tree>/{power,env,envmax | tensor/…, tensor_lc/…, trajectory/…}
|
|
10
|
+
<default subject>/atlas/spatial/group/maps/<kind>/…, per_subject/mean the group sums (``reduce``)
|
|
11
|
+
<default subject>/atlas/time/<task>__<method>__<orientation>/group/…, per_subject/density
|
|
12
|
+
|
|
13
|
+
Each array name maps to a MEASURE (``ATLAS_MEASURES``, the app's ``measurements.ts``), whose reduction says how it merges
|
|
14
|
+
across members, leaves and frames (``MERGE``).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
DEFAULT_SUBJECT = "@default_subject"
|
|
24
|
+
DEFAULT_BANDS = [(1.0 * 2 ** b, 2.0 * 2 ** b) for b in range(6)]
|
|
25
|
+
|
|
26
|
+
#: an atlas array (relative to its statistics group) → the app's measure (``atlas.ts`` / ``measurements.ts``)
|
|
27
|
+
ATLAS_MEASURES = {
|
|
28
|
+
"w": "weight", "s1": "sum", "s2": "sum_square", "n": "count", "min": "min", "max": "max",
|
|
29
|
+
"power": "power_sum", "env": "envelope_sum", "envmax": "envelope_max", "samples": "count",
|
|
30
|
+
"grad/w": "gradient_weight", "grad/c1": "gradient_1", "grad/c2": "gradient_2", "grad/cn": "gradient_n",
|
|
31
|
+
"grad/c11": "gradient_11", "grad/c22": "gradient_22", "grad/c12": "gradient_12", "grad/mag": "gradient_magnitude",
|
|
32
|
+
"tensor/t11": "tensor_11", "tensor/t22": "tensor_22", "tensor/tnn": "tensor_nn", "tensor/t12r": "tensor_12_re",
|
|
33
|
+
"tensor/t12i": "tensor_12_im", "tensor/t1nr": "tensor_1n_re", "tensor/t1ni": "tensor_1n_im", "tensor/t2nr": "tensor_2n_re",
|
|
34
|
+
"tensor/t2ni": "tensor_2n_im", "tensor/total": "tensor_total",
|
|
35
|
+
}
|
|
36
|
+
#: how a measure merges (its reduction in the app's ``measure`` dictionary): sums add, extremes take the extreme
|
|
37
|
+
MERGE = {m: ("max" if m in ("max", "envelope_max") else "min" if m == "min" else "sum") for m in ATLAS_MEASURES.values()}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def merge_of(rel: str) -> str | None:
|
|
41
|
+
"""How the array at ``rel`` (relative to its statistics group) merges — None for an explicit array."""
|
|
42
|
+
m = ATLAS_MEASURES.get(rel)
|
|
43
|
+
return MERGE[m] if m else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def default_subject(ds) -> dict | None:
|
|
47
|
+
"""The dataset's DEFAULT SUBJECT (D127): the template named ``@default_subject``, else its template."""
|
|
48
|
+
return (ds.db.one("SELECT * FROM subject WHERE dataset_id = ? AND name = ? AND status = 'complete'", ds.id, DEFAULT_SUBJECT)
|
|
49
|
+
or ds.template())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def definition(ds) -> dict[str, Any]:
|
|
53
|
+
"""The dataset's default atlas DEFINITIONS — the ``atlas`` block of the default subject's tile set (``{}`` when none)."""
|
|
54
|
+
d = default_subject(ds)
|
|
55
|
+
if d is None:
|
|
56
|
+
return {}
|
|
57
|
+
row = ds.db.one("SELECT params_json FROM selection WHERE subject_id = ? AND type = 'set' AND member_of_id IS NULL AND "
|
|
58
|
+
"json_extract(params_json, '$.atlas.default') = 1", d["id"])
|
|
59
|
+
return (json.loads(row["params_json"]) or {}).get("atlas", {}) if row and row["params_json"] else {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def put(sub, path: str, data, **_ignored) -> None:
|
|
63
|
+
"""A plain atlas array at the subject's store path (laid out canonically, no attributes: the rows say what it is)."""
|
|
64
|
+
sub.write_array(path, np.ascontiguousarray(np.asarray(data)))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""THE SAMPLE MASK the atlas excludes: a recording's BAD SEGMENTS are a Selection (D143) — ``<rec>_bad_segments``, spans on
|
|
2
|
+
its time Line, written by the converter from Brainstorm's extended events labelled ``bad*`` (``events.export_events``).
|
|
3
|
+
Every sample inside a span carries no weight, so the atlas's sums — and the ``samples`` count per frame — are over good
|
|
4
|
+
samples only, and stay mergeable."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .nxr_store import Recording, SubjectStore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def sample_mask(store: SubjectStore, rec: Recording) -> tuple[np.ndarray, dict]:
|
|
13
|
+
"""[n] bool, True = a good sample: outside every span of the recording's bad segments."""
|
|
14
|
+
start, stop = store.bad_spans(rec)
|
|
15
|
+
good = np.ones(rec.n_samples, bool)
|
|
16
|
+
i0 = np.clip(np.floor((start - rec.origin) * rec.sfreq).astype(np.int64), 0, rec.n_samples)
|
|
17
|
+
i1 = np.clip(np.ceil((stop - rec.origin) * rec.sfreq).astype(np.int64), 0, rec.n_samples)
|
|
18
|
+
for a, b in zip(i0, i1):
|
|
19
|
+
good[a:b] = False
|
|
20
|
+
return good, {"source": "selection" if len(start) else "none", "segments": int(len(start)),
|
|
21
|
+
"bad_fraction": float(1 - good.mean()) if rec.n_samples else 0.0}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The filter bank of the tower of cycles — one complex analytic FIR per level, so chunked filtering is EXACT.
|
|
3
|
+
|
|
4
|
+
A level L of the tower holds the band [1/P(L), 2/P(L)) Hz, P(L) = 86 400·2^L s (``band_hz``; nsp's dynamics-atlas
|
|
5
|
+
``grid`` names the levels, and only these two lines of it are needed here — the dynamics atlas is not ported, D135). Its filter is the band's
|
|
6
|
+
gain of the octave power partition (``scalars.band_gains``: cos/sin crossovers half an octave wide,
|
|
7
|
+
squared gains summing to one across the bank), realised as a finite impulse response:
|
|
8
|
+
|
|
9
|
+
h_L[k], k = −H … H the analytic (positive-frequency) gain, inverse-transformed, cut to ±H samples
|
|
10
|
+
and tapered by a Hann window. H = ``cycles`` periods of the band's lowest
|
|
11
|
+
passed frequency (its lower edge less the crossover), so the response is local
|
|
12
|
+
in time at that band's own scale.
|
|
13
|
+
|
|
14
|
+
Because h_L is finite, filtering a block padded by H on each side gives exactly the samples a whole-record
|
|
15
|
+
filter would (overlap-save): the chunked engine and a whole-record pass agree to float rounding. The cut
|
|
16
|
+
and the taper make the partition approximate (``partition_error`` measures it); the exactness is not.
|
|
17
|
+
|
|
18
|
+
A sample within H of a record edge or of a bad sample is not clean for that band (``clean``).
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import math
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
from scipy import fft as sfft
|
|
27
|
+
from scipy.signal import fftconvolve
|
|
28
|
+
|
|
29
|
+
from .scalars import band_gains
|
|
30
|
+
|
|
31
|
+
DAY_S = 86400.0 # time level 0: one day (nsp's tower of cycles)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def period_s(level: int) -> float:
|
|
35
|
+
"""The period of a level-L cycle (s): 86 400·2^L."""
|
|
36
|
+
return DAY_S * 2.0 ** level
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def band_hz(level: int) -> tuple[float, float]:
|
|
40
|
+
"""The octave band of level L: [1/P(L), 2/P(L)) Hz."""
|
|
41
|
+
lo = 1.0 / period_s(level)
|
|
42
|
+
return lo, 2.0 * lo
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
CYCLES = 4.0
|
|
46
|
+
CROSSOVER_OCT = 0.25 # scalars.band_gains: half the crossover width, in octaves
|
|
47
|
+
DEFAULT_LEVELS = tuple(range(-22, -15)) # −22 … −16: 0.76 – 97 Hz
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def levels_for(sfreq: float, f_min: float = 0.5, f_max: float | None = None) -> list[int]:
|
|
51
|
+
"""The tower levels whose whole band (crossover included) lies in [f_min, min(f_max, Nyquist)]."""
|
|
52
|
+
top = min(f_max or math.inf, sfreq / 2.0)
|
|
53
|
+
out = []
|
|
54
|
+
for L in range(-40, 20):
|
|
55
|
+
lo, hi = band_hz(L)
|
|
56
|
+
if lo * 2 ** -CROSSOVER_OCT >= f_min and hi * 2 ** CROSSOVER_OCT <= top:
|
|
57
|
+
out.append(L)
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Filter:
|
|
63
|
+
level: int
|
|
64
|
+
band_hz: tuple[float, float]
|
|
65
|
+
half: int # H: the filter spans samples −H … H
|
|
66
|
+
h: np.ndarray # complex128 [2H + 1]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def analytic_fir(level: int, sfreq: float, levels=DEFAULT_LEVELS, cycles: float = CYCLES) -> Filter:
|
|
70
|
+
"""The complex analytic FIR of ``level`` in the power partition over ``levels``."""
|
|
71
|
+
levels = sorted(levels, reverse=True) # ascending frequency (band_gains' order)
|
|
72
|
+
bands = [band_hz(L) for L in levels]
|
|
73
|
+
lo = band_hz(level)[0] * 2 ** -CROSSOVER_OCT
|
|
74
|
+
H = int(math.ceil(cycles / lo * sfreq))
|
|
75
|
+
n = 1 << int(math.ceil(math.log2(8 * (2 * H + 1))))
|
|
76
|
+
f = sfft.fftfreq(n, 1.0 / sfreq)
|
|
77
|
+
g = band_gains(np.abs(f), bands)[levels.index(level)] * 2.0 * (f > 0)
|
|
78
|
+
full = sfft.ifft(g)
|
|
79
|
+
k = np.arange(-H, H + 1)
|
|
80
|
+
h = full[k % n] * np.hanning(2 * H + 3)[1:-1]
|
|
81
|
+
return Filter(level, band_hz(level), H, h)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def response(filt: Filter, freqs: np.ndarray, sfreq: float) -> np.ndarray:
|
|
85
|
+
"""The realised complex gain of the FIR at ``freqs`` (Hz)."""
|
|
86
|
+
k = np.arange(-filt.half, filt.half + 1)
|
|
87
|
+
return np.exp(-2j * np.pi * np.outer(np.asarray(freqs) / sfreq, k)) @ filt.h
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def partition_error(filters: list[Filter], sfreq: float, n: int = 512) -> float:
|
|
91
|
+
"""max |Σ |H_L(f)|²/4 − 1| over the bank's inner range (the analytic gain is 2 on positive frequencies)."""
|
|
92
|
+
lo = min(F.band_hz[0] for F in filters) * 2 ** CROSSOVER_OCT
|
|
93
|
+
hi = max(F.band_hz[1] for F in filters) * 2 ** -CROSSOVER_OCT
|
|
94
|
+
f = np.geomspace(lo, hi, n)
|
|
95
|
+
tot = sum(np.abs(response(F, f, sfreq)) ** 2 for F in filters) / 4.0
|
|
96
|
+
return float(np.max(np.abs(tot - 1.0)))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def filter_block(x: np.ndarray, filt: Filter) -> np.ndarray:
|
|
100
|
+
"""Filter a block padded by H samples on each side ([channels, T + 2H], zeros outside the record):
|
|
101
|
+
returns the T analytic samples of its interior (exact overlap-save)."""
|
|
102
|
+
return fftconvolve(np.asarray(x), filt.h[None, :], mode="valid", axes=-1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def clean(good: np.ndarray, half: int) -> np.ndarray:
|
|
106
|
+
"""Samples whose ±H neighbourhood lies inside the record and holds no bad sample."""
|
|
107
|
+
bad = np.r_[np.ones(half, bool), ~np.asarray(good, bool), np.ones(half, bool)].astype(np.int64)
|
|
108
|
+
c = np.r_[0, np.cumsum(bad)]
|
|
109
|
+
w = 2 * half + 1
|
|
110
|
+
return (c[w:] - c[:-w]) == 0
|