effusive 0.1.1__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.
Files changed (48) hide show
  1. effusive/__init__.py +1 -0
  2. effusive/assets/__init__.py +28 -0
  3. effusive/assets/aperture.svg +1 -0
  4. effusive/assets/audio-waveform.svg +1 -0
  5. effusive/assets/circle-check.svg +4 -0
  6. effusive/assets/circle-x.svg +5 -0
  7. effusive/assets/cpu.svg +1 -0
  8. effusive/assets/hard-drive.svg +1 -0
  9. effusive/assets/layers.svg +1 -0
  10. effusive/assets/pause.svg +1 -0
  11. effusive/assets/play.svg +1 -0
  12. effusive/assets/sliders-horizontal.svg +21 -0
  13. effusive/assets/square.svg +1 -0
  14. effusive/assets/triangle-alert.svg +5 -0
  15. effusive/bids.py +390 -0
  16. effusive/cli.py +42 -0
  17. effusive/config.py +534 -0
  18. effusive/default_config.toml +63 -0
  19. effusive/matlab_worker.py +424 -0
  20. effusive/motor_controller.py +356 -0
  21. effusive/napari/__init__.py +1 -0
  22. effusive/napari/commands.py +592 -0
  23. effusive/napari/components.py +94 -0
  24. effusive/napari/console.py +191 -0
  25. effusive/napari/controls.py +545 -0
  26. effusive/napari/crop.py +379 -0
  27. effusive/napari/debug.py +365 -0
  28. effusive/napari/panels/__init__.py +31 -0
  29. effusive/napari/panels/common.py +158 -0
  30. effusive/napari/panels/data.py +153 -0
  31. effusive/napari/panels/metadata.py +582 -0
  32. effusive/napari/panels/processing.py +356 -0
  33. effusive/napari/panels/sequence.py +337 -0
  34. effusive/napari/panels/stack.py +261 -0
  35. effusive/napari/panels/system.py +497 -0
  36. effusive/napari/plugin.py +70 -0
  37. effusive/napari/runtime.py +462 -0
  38. effusive/napari/stack.py +1000 -0
  39. effusive/napari/theme.py +460 -0
  40. effusive/napari/udp_control.py +511 -0
  41. effusive/napari/widget.py +797 -0
  42. effusive/napari/worker.py +466 -0
  43. effusive/napari.yaml +10 -0
  44. effusive/shared_memory.py +584 -0
  45. effusive-0.1.1.dist-info/METADATA +68 -0
  46. effusive-0.1.1.dist-info/RECORD +48 -0
  47. effusive-0.1.1.dist-info/WHEEL +4 -0
  48. effusive-0.1.1.dist-info/entry_points.txt +6 -0
effusive/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Effusive python package for the napari viewer."""
@@ -0,0 +1,28 @@
1
+ """Effusive assets module.
2
+
3
+ Contains SVG icons and other static assets used by the application.
4
+ """
5
+
6
+ from pathlib import Path
7
+
8
+
9
+ def load_svg(name: str) -> str:
10
+ """Load an SVG icon by name.
11
+
12
+ Parameters
13
+ ----------
14
+ name : str
15
+ Name of the SVG file (without .svg extension).
16
+
17
+ Returns
18
+ -------
19
+ str
20
+ Contents of the SVG file.
21
+
22
+ Raises
23
+ ------
24
+ FileNotFoundError
25
+ If the SVG file does not exist.
26
+ """
27
+ svg_path = Path(__file__).parent / f"{name}.svg"
28
+ return svg_path.read_text()
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m14.31 8 5.74 9.94"/><path d="M9.69 8h11.48"/><path d="m7.38 12 5.74-9.94"/><path d="M9.69 16 3.95 6.06"/><path d="M14.31 16H2.83"/><path d="m16.62 12-5.74 9.94"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-audio-waveform-icon lucide-audio-waveform"><path d="M2 13a2 2 0 0 0 2-2V7a2 2 0 0 1 4 0v13a2 2 0 0 0 4 0V4a2 2 0 0 1 4 0v13a2 2 0 0 0 4 0v-4a2 2 0 0 1 2-2"/></svg>
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2
+ <circle cx="12" cy="12" r="10"/>
3
+ <path d="m9 12 2 2 4-4"/>
4
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2
+ <circle cx="12" cy="12" r="10"/>
3
+ <path d="m15 9-6 6"/>
4
+ <path d="m9 9 6 6"/>
5
+ </svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cpu-icon lucide-cpu"><path d="M12 20v2"/><path d="M12 2v2"/><path d="M17 20v2"/><path d="M17 2v2"/><path d="M2 12h2"/><path d="M2 17h2"/><path d="M2 7h2"/><path d="M20 12h2"/><path d="M20 17h2"/><path d="M20 7h2"/><path d="M7 20v2"/><path d="M7 2v2"/><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="8" y="8" width="8" height="8" rx="1"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 16h.01"/><path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M21.946 12.013H2.054"/><path d="M6 16h.01"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"/></svg>
@@ -0,0 +1,21 @@
1
+ <svg
2
+ xmlns="http://www.w3.org/2000/svg"
3
+ width="24"
4
+ height="24"
5
+ viewBox="0 0 24 24"
6
+ fill="none"
7
+ stroke="currentColor"
8
+ stroke-width="2"
9
+ stroke-linecap="round"
10
+ stroke-linejoin="round"
11
+ >
12
+ <path d="M10 5H3" />
13
+ <path d="M12 19H3" />
14
+ <path d="M14 3v4" />
15
+ <path d="M16 17v4" />
16
+ <path d="M21 12h-9" />
17
+ <path d="M21 19h-5" />
18
+ <path d="M21 5h-7" />
19
+ <path d="M8 10v4" />
20
+ <path d="M8 12H3" />
21
+ </svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/></svg>
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2
+ <path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/>
3
+ <path d="M12 9v4"/>
4
+ <path d="M12 17h.01"/>
5
+ </svg>
effusive/bids.py ADDED
@@ -0,0 +1,390 @@
1
+ """Helpers for Effusive's BIDS-like storage mode.
2
+
3
+ This module centralises validation and filename construction for the planned
4
+ fUSI-BIDS layout so the napari UI and later MATLAB runtime code can share the
5
+ same naming rules.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from pathlib import Path
13
+
14
+ import platformdirs
15
+
16
+ FUSI_DATATYPE = "fusi"
17
+ """Datatype folder for regular recordings."""
18
+
19
+ ANGIO_DATATYPE = "angio"
20
+ """Datatype folder for z-stack exports."""
21
+
22
+ FUSI_RECORDING_SUFFIXES = ("iq", "pwd", "rf", "rftimestamps", "seq.mat")
23
+ """Basename suffixes reserved by one fUSI recording stem."""
24
+
25
+ VALID_BIDS_LABEL_RE = re.compile(r"^[A-Za-z0-9]+$")
26
+ """Allowed entity-label pattern for the v1 BIDS-like mode."""
27
+
28
+ BIDS_META_SIDECAR_PATH = (
29
+ Path(platformdirs.user_cache_dir("effusive")) / "cf_bids_meta.json"
30
+ )
31
+ """Per-recording metadata sidecar for live updates to the MATLAB worker.
32
+
33
+ Written by Python at record-start; read by `cfPrepareStorageSpecForSave` to pick up
34
+ subject/session/task/acq/proc/run values that may have changed since VSX launched.
35
+ """
36
+
37
+
38
+ def write_bids_meta_sidecar(
39
+ task: str,
40
+ acq: str,
41
+ proc: str,
42
+ run: int,
43
+ subject: str = "",
44
+ session: str = "",
45
+ ) -> None:
46
+ """Write per-recording BIDS metadata to the shared sidecar file.
47
+
48
+ Parameters
49
+ ----------
50
+ task : str
51
+ Task label without the `task-` prefix.
52
+ acq : str
53
+ Acquisition label without the `acq-` prefix.
54
+ proc : str
55
+ Processing label without the `proc-` prefix.
56
+ run : int
57
+ Exact run index to use for the next recording.
58
+ subject : str, optional
59
+ Subject label without the `sub-` prefix.
60
+ session : str, optional
61
+ Session label without the `ses-` prefix.
62
+ """
63
+ BIDS_META_SIDECAR_PATH.parent.mkdir(parents=True, exist_ok=True)
64
+ BIDS_META_SIDECAR_PATH.write_text(
65
+ json.dumps(
66
+ {
67
+ "task": task,
68
+ "acq": acq,
69
+ "proc": proc,
70
+ "run": int(run),
71
+ "subject": subject,
72
+ "session": session,
73
+ }
74
+ )
75
+ )
76
+
77
+
78
+ def validate_bids_label(value: str, field_name: str, required: bool = False) -> str:
79
+ """Validate one BIDS entity label.
80
+
81
+ Parameters
82
+ ----------
83
+ value : str
84
+ Raw entity-label text.
85
+ field_name : str
86
+ Human-readable field name used in error messages.
87
+ required : bool, default: False
88
+ Whether an empty value should be rejected.
89
+
90
+ Returns
91
+ -------
92
+ str
93
+ Stripped label text when valid.
94
+
95
+ Raises
96
+ ------
97
+ ValueError
98
+ Raised when the value is empty for a required field or contains
99
+ characters outside `[A-Za-z0-9]+`.
100
+ """
101
+ cleaned = value.strip()
102
+ if not cleaned:
103
+ if required:
104
+ raise ValueError(f"{field_name} is required.")
105
+ return ""
106
+ if VALID_BIDS_LABEL_RE.fullmatch(cleaned) is None:
107
+ raise ValueError(f"{field_name} must match [A-Za-z0-9]+.")
108
+ return cleaned
109
+
110
+
111
+ def required_bids_fields(datatype: str) -> tuple[str, ...]:
112
+ """Return the required entity names for a datatype.
113
+
114
+ Parameters
115
+ ----------
116
+ datatype : str
117
+ Recording datatype, typically `fusi` or `angio`.
118
+
119
+ Returns
120
+ -------
121
+ tuple of str
122
+ Required entity names for the requested datatype.
123
+ """
124
+ if datatype == FUSI_DATATYPE:
125
+ return ("subject", "session", "task")
126
+ if datatype == ANGIO_DATATYPE:
127
+ return ("subject", "session")
128
+ raise ValueError(f"Unsupported BIDS datatype: {datatype}.")
129
+
130
+
131
+ def validate_bids_entities(
132
+ *,
133
+ datatype: str,
134
+ subject: str,
135
+ session: str,
136
+ task: str = "",
137
+ acq: str = "",
138
+ proc: str = "",
139
+ ) -> dict[str, str]:
140
+ """Validate a set of entity values for one datatype.
141
+
142
+ Parameters
143
+ ----------
144
+ datatype : str
145
+ Recording datatype, typically `fusi` or `angio`.
146
+ subject : str
147
+ Subject label without the `sub-` prefix.
148
+ session : str
149
+ Session label without the `ses-` prefix.
150
+ task : str, optional
151
+ Task label without the `task-` prefix.
152
+ acq : str, optional
153
+ Acquisition label without the `acq-` prefix.
154
+ proc : str, optional
155
+ Processing label without the `proc-` prefix.
156
+
157
+ Returns
158
+ -------
159
+ dict of str to str
160
+ Validated entity values keyed by entity name.
161
+
162
+ Raises
163
+ ------
164
+ ValueError
165
+ Raised when any required or optional label is invalid.
166
+ """
167
+ required = set(required_bids_fields(datatype))
168
+ return {
169
+ "subject": validate_bids_label(subject, "Subject", "subject" in required),
170
+ "session": validate_bids_label(session, "Session", "session" in required),
171
+ "task": validate_bids_label(task, "Task", "task" in required),
172
+ "acq": validate_bids_label(acq, "Acq", False),
173
+ "proc": validate_bids_label(proc, "Proc", False),
174
+ }
175
+
176
+
177
+ def format_run(run: int) -> str:
178
+ """Format a run index for the BIDS-like entity string.
179
+
180
+ Parameters
181
+ ----------
182
+ run : int
183
+ Positive run index.
184
+
185
+ Returns
186
+ -------
187
+ str
188
+ Zero-padded run label body such as `01`.
189
+
190
+ Raises
191
+ ------
192
+ ValueError
193
+ Raised when `run` is not positive.
194
+ """
195
+ if run < 1:
196
+ raise ValueError("Run must be >= 1.")
197
+ return f"{run:02d}"
198
+
199
+
200
+ def build_bids_dir(root: Path, subject: str, session: str, datatype: str) -> Path:
201
+ """Build the datatype directory for one BIDS-like recording.
202
+
203
+ Parameters
204
+ ----------
205
+ root : Path
206
+ Storage root directory.
207
+ subject : str
208
+ Subject label without the `sub-` prefix.
209
+ session : str
210
+ Session label without the `ses-` prefix.
211
+ datatype : str
212
+ Datatype folder name, typically `fusi` or `angio`.
213
+
214
+ Returns
215
+ -------
216
+ Path
217
+ Datatype directory path below `root`.
218
+ """
219
+ subject_label = validate_bids_label(subject, "Subject", required=True)
220
+ session_label = validate_bids_label(session, "Session", required=True)
221
+ return Path(root) / f"sub-{subject_label}" / f"ses-{session_label}" / datatype
222
+
223
+
224
+ def build_bids_stem(
225
+ *,
226
+ subject: str,
227
+ session: str,
228
+ task: str = "",
229
+ acq: str = "",
230
+ run: int | None = None,
231
+ proc: str = "",
232
+ datatype: str = FUSI_DATATYPE,
233
+ ) -> str:
234
+ """Build a BIDS-like filename stem without suffix or extension.
235
+
236
+ Parameters
237
+ ----------
238
+ subject : str
239
+ Subject label without the `sub-` prefix.
240
+ session : str
241
+ Session label without the `ses-` prefix.
242
+ task : str, optional
243
+ Task label without the `task-` prefix.
244
+ acq : str, optional
245
+ Acquisition label without the `acq-` prefix.
246
+ run : int, optional
247
+ Run index. When omitted, the stem is built without a `run-` entity.
248
+ proc : str, optional
249
+ Processing label without the `proc-` prefix.
250
+ datatype : str, default: `fusi`
251
+ Recording datatype, used only to validate required fields.
252
+
253
+ Returns
254
+ -------
255
+ str
256
+ Filename stem up to, but not including, the suffix.
257
+ """
258
+ entities = validate_bids_entities(
259
+ datatype=datatype,
260
+ subject=subject,
261
+ session=session,
262
+ task=task,
263
+ acq=acq,
264
+ proc=proc,
265
+ )
266
+ parts = [f"sub-{entities['subject']}", f"ses-{entities['session']}"]
267
+ if entities["task"]:
268
+ parts.append(f"task-{entities['task']}")
269
+ if entities["acq"]:
270
+ parts.append(f"acq-{entities['acq']}")
271
+ if entities["proc"]:
272
+ parts.append(f"proc-{entities['proc']}")
273
+ if run is not None:
274
+ parts.append(f"run-{format_run(run)}")
275
+ return "_".join(parts)
276
+
277
+
278
+ def check_bids_run_collision(
279
+ root: Path,
280
+ *,
281
+ datatype: str,
282
+ subject: str,
283
+ session: str,
284
+ task: str = "",
285
+ acq: str = "",
286
+ run: int,
287
+ proc: str = "",
288
+ ) -> bool:
289
+ """Return True if any file with the expected BIDS stem already exists.
290
+
291
+ Parameters
292
+ ----------
293
+ root : Path
294
+ Storage root directory.
295
+ datatype : str
296
+ Datatype folder name, typically `fusi` or `angio`.
297
+ subject : str
298
+ Subject label without the `sub-` prefix.
299
+ session : str
300
+ Session label without the `ses-` prefix.
301
+ task : str, optional
302
+ Task label without the `task-` prefix.
303
+ acq : str, optional
304
+ Acquisition label without the `acq-` prefix.
305
+ run : int
306
+ Manual run index to check for collision.
307
+ proc : str, optional
308
+ Processing label without the `proc-` prefix.
309
+
310
+ Returns
311
+ -------
312
+ bool
313
+ True when at least one file matching `<stem>_*` exists.
314
+ """
315
+ bids_dir = build_bids_dir(root, subject, session, datatype)
316
+ stem = build_bids_stem(
317
+ subject=subject,
318
+ session=session,
319
+ task=task,
320
+ acq=acq,
321
+ run=run,
322
+ proc=proc,
323
+ datatype=datatype,
324
+ )
325
+ if not bids_dir.is_dir():
326
+ return False
327
+
328
+ if datatype == FUSI_DATATYPE:
329
+ return any(
330
+ (bids_dir / f"{stem}_{suffix}").exists()
331
+ for suffix in FUSI_RECORDING_SUFFIXES
332
+ )
333
+
334
+ return any(bids_dir.glob(f"{stem}_*"))
335
+
336
+
337
+ def build_bids_preview_path(
338
+ root: Path,
339
+ *,
340
+ datatype: str,
341
+ subject: str,
342
+ session: str,
343
+ task: str = "",
344
+ acq: str = "",
345
+ run: int | None = None,
346
+ proc: str = "",
347
+ suffix: str = "pwd",
348
+ extension: str = ".ext",
349
+ ) -> Path:
350
+ """Build a full preview path for UI display.
351
+
352
+ Parameters
353
+ ----------
354
+ root : Path
355
+ Storage root directory.
356
+ datatype : str
357
+ Datatype folder name, typically `fusi` or `angio`.
358
+ subject : str
359
+ Subject label without the `sub-` prefix.
360
+ session : str
361
+ Session label without the `ses-` prefix.
362
+ task : str, optional
363
+ Task label without the `task-` prefix.
364
+ acq : str, optional
365
+ Acquisition label without the `acq-` prefix.
366
+ run : int, optional
367
+ Run index for the preview stem.
368
+ proc : str, optional
369
+ Processing label without the `proc-` prefix.
370
+ suffix : str, default: `pwd`
371
+ Output suffix token without the leading underscore.
372
+ extension : str, default: `.ext`
373
+ Filename extension shown in the preview.
374
+
375
+ Returns
376
+ -------
377
+ Path
378
+ Full preview path including one representative filename.
379
+ """
380
+ bids_dir = build_bids_dir(root, subject, session, datatype)
381
+ stem = build_bids_stem(
382
+ subject=subject,
383
+ session=session,
384
+ task=task,
385
+ acq=acq,
386
+ run=run,
387
+ proc=proc,
388
+ datatype=datatype,
389
+ )
390
+ return bids_dir / f"{stem}_{suffix}{extension}"
effusive/cli.py ADDED
@@ -0,0 +1,42 @@
1
+ """Effusive command-line entry point.
2
+
3
+ Opens a napari viewer with live B-mode and PDI layers and a control dock widget. The
4
+ MATLAB worker subprocess is not started automatically: click the play button in the
5
+ sidebar after configuring acquisition parameters.
6
+
7
+ Usage:
8
+
9
+ ```bash
10
+ effusive
11
+ ```
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import napari
17
+ from qtpy.QtWidgets import QApplication
18
+
19
+ from effusive import config as cf_config
20
+ from effusive.napari import worker as worker_module
21
+ from effusive.napari.plugin import make_main_widget
22
+
23
+
24
+ def main() -> None:
25
+ """Run the Effusive napari viewer."""
26
+ viewer = napari.Viewer(title="Effusive")
27
+ widget = make_main_widget(viewer)
28
+ viewer.window.add_dock_widget(widget, name="Effusive", area="right")
29
+
30
+ # widget._config is kept live by command handlers and panel connections;
31
+ # saving it here requires no Qt object access so it works even after teardown.
32
+ app = QApplication.instance()
33
+ assert app is not None
34
+ app.aboutToQuit.connect(lambda: cf_config.save_config(widget._config))
35
+
36
+ napari.run()
37
+
38
+ worker_module.stop_acquisition(widget, from_close=True)
39
+
40
+
41
+ if __name__ == "__main__":
42
+ main()