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.
Files changed (54) hide show
  1. nxr_convert-0.2.0/LICENSE +21 -0
  2. nxr_convert-0.2.0/PKG-INFO +178 -0
  3. nxr_convert-0.2.0/README.md +147 -0
  4. nxr_convert-0.2.0/pyproject.toml +52 -0
  5. nxr_convert-0.2.0/src/nxr_convert/__init__.py +7 -0
  6. nxr_convert-0.2.0/src/nxr_convert/_zarr_compat.py +44 -0
  7. nxr_convert-0.2.0/src/nxr_convert/atlas/__init__.py +14 -0
  8. nxr_convert-0.2.0/src/nxr_convert/atlas/atlas_store.py +64 -0
  9. nxr_convert-0.2.0/src/nxr_convert/atlas/bad_segments.py +21 -0
  10. nxr_convert-0.2.0/src/nxr_convert/atlas/bands.py +110 -0
  11. nxr_convert-0.2.0/src/nxr_convert/atlas/build.py +391 -0
  12. nxr_convert-0.2.0/src/nxr_convert/atlas/cli.py +95 -0
  13. nxr_convert-0.2.0/src/nxr_convert/atlas/compute.mjs +53 -0
  14. nxr_convert-0.2.0/src/nxr_convert/atlas/default_subject.py +362 -0
  15. nxr_convert-0.2.0/src/nxr_convert/atlas/frames.py +280 -0
  16. nxr_convert-0.2.0/src/nxr_convert/atlas/hcp.py +130 -0
  17. nxr_convert-0.2.0/src/nxr_convert/atlas/joint.py +93 -0
  18. nxr_convert-0.2.0/src/nxr_convert/atlas/ladder.py +268 -0
  19. nxr_convert-0.2.0/src/nxr_convert/atlas/nxr_store.py +165 -0
  20. nxr_convert-0.2.0/src/nxr_convert/atlas/reduce.py +170 -0
  21. nxr_convert-0.2.0/src/nxr_convert/atlas/rollup.py +46 -0
  22. nxr_convert-0.2.0/src/nxr_convert/atlas/scalars.py +167 -0
  23. nxr_convert-0.2.0/src/nxr_convert/atlas/trees.py +139 -0
  24. nxr_convert-0.2.0/src/nxr_convert/atlas/vectors.py +262 -0
  25. nxr_convert-0.2.0/src/nxr_convert/cli.py +265 -0
  26. nxr_convert-0.2.0/src/nxr_convert/convert.py +293 -0
  27. nxr_convert-0.2.0/src/nxr_convert/crud.py +912 -0
  28. nxr_convert-0.2.0/src/nxr_convert/db.py +449 -0
  29. nxr_convert-0.2.0/src/nxr_convert/entities.py +82 -0
  30. nxr_convert-0.2.0/src/nxr_convert/events.py +236 -0
  31. nxr_convert-0.2.0/src/nxr_convert/fibers.py +162 -0
  32. nxr_convert-0.2.0/src/nxr_convert/grid.py +75 -0
  33. nxr_convert-0.2.0/src/nxr_convert/inverse.py +265 -0
  34. nxr_convert-0.2.0/src/nxr_convert/matio.py +29 -0
  35. nxr_convert-0.2.0/src/nxr_convert/mesh_health.py +181 -0
  36. nxr_convert-0.2.0/src/nxr_convert/model.sql +1139 -0
  37. nxr_convert-0.2.0/src/nxr_convert/mri.py +430 -0
  38. nxr_convert-0.2.0/src/nxr_convert/naming.py +121 -0
  39. nxr_convert-0.2.0/src/nxr_convert/protocol.py +78 -0
  40. nxr_convert-0.2.0/src/nxr_convert/py.typed +0 -0
  41. nxr_convert-0.2.0/src/nxr_convert/raw.py +62 -0
  42. nxr_convert-0.2.0/src/nxr_convert/results_map.py +86 -0
  43. nxr_convert-0.2.0/src/nxr_convert/sensors.py +97 -0
  44. nxr_convert-0.2.0/src/nxr_convert/sources/__init__.py +68 -0
  45. nxr_convert-0.2.0/src/nxr_convert/sources/model.py +164 -0
  46. nxr_convert-0.2.0/src/nxr_convert/sources/protocol_db.py +26 -0
  47. nxr_convert-0.2.0/src/nxr_convert/sources/protocol_mat.py +430 -0
  48. nxr_convert-0.2.0/src/nxr_convert/sources/walk.py +65 -0
  49. nxr_convert-0.2.0/src/nxr_convert/subject.py +266 -0
  50. nxr_convert-0.2.0/src/nxr_convert/surface.py +215 -0
  51. nxr_convert-0.2.0/src/nxr_convert/timefreq.py +116 -0
  52. nxr_convert-0.2.0/src/nxr_convert/timeseries.py +152 -0
  53. nxr_convert-0.2.0/src/nxr_convert/winding.py +55 -0
  54. 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