msw-open-ephys 3.1.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.
- msw_open_ephys-3.1.0/.gitignore +15 -0
- msw_open_ephys-3.1.0/LICENSE +31 -0
- msw_open_ephys-3.1.0/PKG-INFO +125 -0
- msw_open_ephys-3.1.0/README.md +61 -0
- msw_open_ephys-3.1.0/VERSION +1 -0
- msw_open_ephys-3.1.0/pyproject.toml +135 -0
- msw_open_ephys-3.1.0/src/msw_open_ephys/__init__.py +20 -0
- msw_open_ephys-3.1.0/src/msw_open_ephys/__main__.py +4 -0
- msw_open_ephys-3.1.0/src/msw_open_ephys/_version.py +24 -0
- msw_open_ephys-3.1.0/src/msw_open_ephys/cli/__init__.py +73 -0
- msw_open_ephys-3.1.0/src/msw_open_ephys/cli/commands.py +119 -0
- msw_open_ephys-3.1.0/src/msw_open_ephys/cli/parser.py +172 -0
- msw_open_ephys-3.1.0/src/msw_open_ephys/controller.py +103 -0
- msw_open_ephys-3.1.0/src/msw_open_ephys/host.py +130 -0
- msw_open_ephys-3.1.0/src/msw_open_ephys/py.typed +0 -0
- msw_open_ephys-3.1.0/src/msw_open_ephys/session.py +172 -0
- msw_open_ephys-3.1.0/tests/conftest.py +0 -0
- msw_open_ephys-3.1.0/tests/test_smoke.py +51 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Copyright (c) 2024-present Lars B. Rollik. All rights reserved.
|
|
2
|
+
|
|
3
|
+
Permission is granted, free of charge, to use, copy, modify, and distribute
|
|
4
|
+
this software and associated documentation files (the "Software") for
|
|
5
|
+
non-commercial research or academic purposes only, subject to the following
|
|
6
|
+
conditions:
|
|
7
|
+
|
|
8
|
+
1. This copyright notice and permission notice must be included in all copies
|
|
9
|
+
or substantial portions of the Software.
|
|
10
|
+
|
|
11
|
+
2. Any publication, presentation, or product that uses or builds upon the
|
|
12
|
+
Software must give clear attribution to the original work and its authors.
|
|
13
|
+
|
|
14
|
+
3. Commercial use is prohibited without a separate written commercial licence
|
|
15
|
+
agreement with the copyright holder. Commercial use means incorporation of
|
|
16
|
+
the Software into anything for which fees or other compensation are charged
|
|
17
|
+
or received, including commercial products and commercial services.
|
|
18
|
+
|
|
19
|
+
4. No patent licence, express or implied, is granted under these terms. Any
|
|
20
|
+
use that would require a patent licence from the copyright holder requires
|
|
21
|
+
a separate written agreement.
|
|
22
|
+
|
|
23
|
+
5. Redistribution, in source or binary form, is permitted only for
|
|
24
|
+
non-commercial purposes and must retain this notice unmodified.
|
|
25
|
+
|
|
26
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
27
|
+
IMPLIED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM,
|
|
28
|
+
DAMAGES, OR OTHER LIABILITY ARISING FROM, OUT OF, OR IN CONNECTION WITH THE
|
|
29
|
+
SOFTWARE OR THE USE OR DEALINGS IN THE SOFTWARE.
|
|
30
|
+
|
|
31
|
+
For commercial or patent licensing enquiries contact: lars@rollik.me
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: msw-open-ephys
|
|
3
|
+
Version: 3.1.0
|
|
4
|
+
Summary: Remote control for Open Ephys GUI v1+ — session naming and recording management
|
|
5
|
+
Project-URL: Homepage, https://github.com/murineshiftwork/msw-open-ephys
|
|
6
|
+
Project-URL: Issue Tracker, https://github.com/murineshiftwork/msw-open-ephys/issues
|
|
7
|
+
Author-email: "Lars B. Rollik" <lars@rollik.me>
|
|
8
|
+
License: Copyright (c) 2024-present Lars B. Rollik. All rights reserved.
|
|
9
|
+
|
|
10
|
+
Permission is granted, free of charge, to use, copy, modify, and distribute
|
|
11
|
+
this software and associated documentation files (the "Software") for
|
|
12
|
+
non-commercial research or academic purposes only, subject to the following
|
|
13
|
+
conditions:
|
|
14
|
+
|
|
15
|
+
1. This copyright notice and permission notice must be included in all copies
|
|
16
|
+
or substantial portions of the Software.
|
|
17
|
+
|
|
18
|
+
2. Any publication, presentation, or product that uses or builds upon the
|
|
19
|
+
Software must give clear attribution to the original work and its authors.
|
|
20
|
+
|
|
21
|
+
3. Commercial use is prohibited without a separate written commercial licence
|
|
22
|
+
agreement with the copyright holder. Commercial use means incorporation of
|
|
23
|
+
the Software into anything for which fees or other compensation are charged
|
|
24
|
+
or received, including commercial products and commercial services.
|
|
25
|
+
|
|
26
|
+
4. No patent licence, express or implied, is granted under these terms. Any
|
|
27
|
+
use that would require a patent licence from the copyright holder requires
|
|
28
|
+
a separate written agreement.
|
|
29
|
+
|
|
30
|
+
5. Redistribution, in source or binary form, is permitted only for
|
|
31
|
+
non-commercial purposes and must retain this notice unmodified.
|
|
32
|
+
|
|
33
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
34
|
+
IMPLIED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM,
|
|
35
|
+
DAMAGES, OR OTHER LIABILITY ARISING FROM, OUT OF, OR IN CONNECTION WITH THE
|
|
36
|
+
SOFTWARE OR THE USE OR DEALINGS IN THE SOFTWARE.
|
|
37
|
+
|
|
38
|
+
For commercial or patent licensing enquiries contact: lars@rollik.me
|
|
39
|
+
License-File: LICENSE
|
|
40
|
+
Keywords: electrophysiology,neuroscience,open-ephys,recording,remote-control
|
|
41
|
+
Classifier: Development Status :: 4 - Beta
|
|
42
|
+
Classifier: Intended Audience :: Science/Research
|
|
43
|
+
Classifier: License :: Free for non-commercial use
|
|
44
|
+
Classifier: Operating System :: OS Independent
|
|
45
|
+
Classifier: Programming Language :: Python :: 3
|
|
46
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
47
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
48
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
49
|
+
Classifier: Topic :: Scientific/Engineering
|
|
50
|
+
Requires-Python: >=3.10
|
|
51
|
+
Requires-Dist: msw-plugin-api>=0.1.0
|
|
52
|
+
Requires-Dist: requests
|
|
53
|
+
Requires-Dist: rich
|
|
54
|
+
Provides-Extra: dev
|
|
55
|
+
Requires-Dist: commitizen; extra == 'dev'
|
|
56
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
57
|
+
Requires-Dist: pre-commit; extra == 'dev'
|
|
58
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
59
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
60
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
61
|
+
Provides-Extra: docs
|
|
62
|
+
Requires-Dist: mkdocs-material; extra == 'docs'
|
|
63
|
+
Description-Content-Type: text/markdown
|
|
64
|
+
|
|
65
|
+
# msw-open-ephys
|
|
66
|
+
|
|
67
|
+
Remote control for [Open Ephys GUI](https://open-ephys.org/gui) v1+ — session naming, recording management, and metadata writing for MSW acquisition workflows.
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install msw-open-ephys
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Or editable from the repo:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install -e external/msw-open-ephys
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## CLI
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
oe-remote status --remote-ip 172.24.242.168
|
|
85
|
+
oe-remote preview --remote-ip 172.24.242.168
|
|
86
|
+
oe-remote record --remote-ip 172.24.242.168 --subject m1099 \
|
|
87
|
+
--acquisition-extension ephys_multi_behavior \
|
|
88
|
+
--session-extension pxi
|
|
89
|
+
oe-remote record --remote-ip 172.24.242.168 --subject m1099 \
|
|
90
|
+
--session-extension intan_ttl --child @last
|
|
91
|
+
oe-remote stop --remote-ip 172.24.242.168
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Session modes
|
|
95
|
+
|
|
96
|
+
| Mode | When | OE records to |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| Standalone | no `--acquisition-extension`, no `--child` | `remote/{subject}/{subject__dt__session}/` |
|
|
99
|
+
| Parent | `--acquisition-extension` set | `remote/{subject}/{subject__dt__acq}/{subject__dt__session}/` |
|
|
100
|
+
| Child | `--child ACQ_PATH` or `--child @last` | `remote/{acq_path}/{subject__dt__session}/` |
|
|
101
|
+
|
|
102
|
+
`--child @last` reuses the acquisition path cached by the most recent parent or record command.
|
|
103
|
+
|
|
104
|
+
## Python API
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from msw_open_ephys.controller import OEController
|
|
108
|
+
from msw_open_ephys.session import Session
|
|
109
|
+
|
|
110
|
+
oe = OEController(ip="172.24.242.168")
|
|
111
|
+
oe.preview()
|
|
112
|
+
oe.configure_recording(parent_directory=r"E:\OE_DATA", base_text="m1099/acq/session")
|
|
113
|
+
oe.record()
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Integration with MSW
|
|
117
|
+
|
|
118
|
+
`msw run --parent openephys` calls the OE REST API directly via `murineshiftwork.hardware.parent_session` (uses `open-ephys-python-tools`, not this package). This package provides the **`oe-remote` CLI** used to start the acquisition side before launching MSW tasks.
|
|
119
|
+
|
|
120
|
+
Set `open_ephys_url` in the setup YAML so MSW can attach automatically:
|
|
121
|
+
|
|
122
|
+
```yaml
|
|
123
|
+
# msw_configs/setups/setup-npxb.yaml
|
|
124
|
+
open_ephys_url: 172.24.242.168
|
|
125
|
+
```
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# msw-open-ephys
|
|
2
|
+
|
|
3
|
+
Remote control for [Open Ephys GUI](https://open-ephys.org/gui) v1+ — session naming, recording management, and metadata writing for MSW acquisition workflows.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install msw-open-ephys
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or editable from the repo:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install -e external/msw-open-ephys
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## CLI
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
oe-remote status --remote-ip 172.24.242.168
|
|
21
|
+
oe-remote preview --remote-ip 172.24.242.168
|
|
22
|
+
oe-remote record --remote-ip 172.24.242.168 --subject m1099 \
|
|
23
|
+
--acquisition-extension ephys_multi_behavior \
|
|
24
|
+
--session-extension pxi
|
|
25
|
+
oe-remote record --remote-ip 172.24.242.168 --subject m1099 \
|
|
26
|
+
--session-extension intan_ttl --child @last
|
|
27
|
+
oe-remote stop --remote-ip 172.24.242.168
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Session modes
|
|
31
|
+
|
|
32
|
+
| Mode | When | OE records to |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| Standalone | no `--acquisition-extension`, no `--child` | `remote/{subject}/{subject__dt__session}/` |
|
|
35
|
+
| Parent | `--acquisition-extension` set | `remote/{subject}/{subject__dt__acq}/{subject__dt__session}/` |
|
|
36
|
+
| Child | `--child ACQ_PATH` or `--child @last` | `remote/{acq_path}/{subject__dt__session}/` |
|
|
37
|
+
|
|
38
|
+
`--child @last` reuses the acquisition path cached by the most recent parent or record command.
|
|
39
|
+
|
|
40
|
+
## Python API
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from msw_open_ephys.controller import OEController
|
|
44
|
+
from msw_open_ephys.session import Session
|
|
45
|
+
|
|
46
|
+
oe = OEController(ip="172.24.242.168")
|
|
47
|
+
oe.preview()
|
|
48
|
+
oe.configure_recording(parent_directory=r"E:\OE_DATA", base_text="m1099/acq/session")
|
|
49
|
+
oe.record()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Integration with MSW
|
|
53
|
+
|
|
54
|
+
`msw run --parent openephys` calls the OE REST API directly via `murineshiftwork.hardware.parent_session` (uses `open-ephys-python-tools`, not this package). This package provides the **`oe-remote` CLI** used to start the acquisition side before launching MSW tasks.
|
|
55
|
+
|
|
56
|
+
Set `open_ephys_url` in the setup YAML so MSW can attach automatically:
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
# msw_configs/setups/setup-npxb.yaml
|
|
60
|
+
open_ephys_url: 172.24.242.168
|
|
61
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.1.0
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "msw-open-ephys"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Lars B. Rollik", email = "lars@rollik.me" }
|
|
10
|
+
]
|
|
11
|
+
description = "Remote control for Open Ephys GUI v1+ — session naming and recording management"
|
|
12
|
+
readme = { file = "README.md", content-type = "text/markdown" }
|
|
13
|
+
license = { file = "LICENSE" }
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
|
+
keywords = [
|
|
16
|
+
"open-ephys", "neuroscience", "electrophysiology", "recording", "remote-control",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Intended Audience :: Science/Research",
|
|
21
|
+
"License :: Free for non-commercial use",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Topic :: Scientific/Engineering",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"requests",
|
|
31
|
+
"rich",
|
|
32
|
+
"msw-plugin-api>=0.1.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
dev = [
|
|
37
|
+
"commitizen",
|
|
38
|
+
"pytest>=8",
|
|
39
|
+
"pytest-cov",
|
|
40
|
+
"pre-commit",
|
|
41
|
+
"ruff",
|
|
42
|
+
"mypy",
|
|
43
|
+
]
|
|
44
|
+
docs = [
|
|
45
|
+
"mkdocs-material",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[project.scripts]
|
|
49
|
+
oe-remote = "msw_open_ephys:run_cli"
|
|
50
|
+
|
|
51
|
+
[project.entry-points."msw.cli"]
|
|
52
|
+
oe = "msw_open_ephys.cli:register"
|
|
53
|
+
|
|
54
|
+
[project.entry-points."msw.host"]
|
|
55
|
+
openephys = "msw_open_ephys.host:OpenEphysHostSession"
|
|
56
|
+
|
|
57
|
+
[project.urls]
|
|
58
|
+
Homepage = "https://github.com/murineshiftwork/msw-open-ephys"
|
|
59
|
+
"Issue Tracker" = "https://github.com/murineshiftwork/msw-open-ephys/issues"
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Build
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
[tool.hatch.version]
|
|
66
|
+
source = "vcs"
|
|
67
|
+
fallback-version = "3.0.0"
|
|
68
|
+
|
|
69
|
+
[tool.hatch.build.hooks.vcs]
|
|
70
|
+
version-file = "src/msw_open_ephys/_version.py"
|
|
71
|
+
|
|
72
|
+
[tool.hatch.build.targets.wheel]
|
|
73
|
+
packages = ["src/msw_open_ephys"]
|
|
74
|
+
|
|
75
|
+
[tool.hatch.build.targets.sdist]
|
|
76
|
+
include = [
|
|
77
|
+
"/src/msw_open_ephys",
|
|
78
|
+
"/tests",
|
|
79
|
+
"/README.md",
|
|
80
|
+
"/LICENSE",
|
|
81
|
+
"/pyproject.toml",
|
|
82
|
+
"/VERSION",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Commitizen
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
[tool.commitizen]
|
|
90
|
+
name = "cz_conventional_commits"
|
|
91
|
+
version_provider = "commitizen"
|
|
92
|
+
version = "3.1.0"
|
|
93
|
+
version_files = ["VERSION"]
|
|
94
|
+
tag_format = "v$version"
|
|
95
|
+
update_changelog_on_bump = false
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Pytest
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
[tool.pytest.ini_options]
|
|
102
|
+
testpaths = ["tests"]
|
|
103
|
+
addopts = "--cov=src/msw_open_ephys --cov-report=term-missing --maxfail=5"
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Ruff
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
[tool.ruff]
|
|
110
|
+
line-length = 88
|
|
111
|
+
indent-width = 4
|
|
112
|
+
src = ["src"]
|
|
113
|
+
|
|
114
|
+
[tool.ruff.lint]
|
|
115
|
+
select = ["E", "W", "F", "I", "UP", "B", "SIM", "PTH", "TCH", "PYI", "YTT", "N"]
|
|
116
|
+
ignore = []
|
|
117
|
+
fixable = ["ALL"]
|
|
118
|
+
|
|
119
|
+
[tool.ruff.format]
|
|
120
|
+
quote-style = "double"
|
|
121
|
+
indent-style = "space"
|
|
122
|
+
docstring-code-format = true
|
|
123
|
+
|
|
124
|
+
[tool.ruff.lint.pydocstyle]
|
|
125
|
+
convention = "numpy"
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Mypy
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
[tool.mypy]
|
|
132
|
+
python_version = "3.10"
|
|
133
|
+
warn_return_any = true
|
|
134
|
+
warn_unused_ignores = true
|
|
135
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from msw_open_ephys.cli.parser import parse_args
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_cli(args=None):
|
|
7
|
+
"""Entry point for the ``oe-remote`` CLI."""
|
|
8
|
+
if args is None:
|
|
9
|
+
args = sys.argv[1:]
|
|
10
|
+
|
|
11
|
+
if args and not isinstance(args[0], str):
|
|
12
|
+
args = list(args[0])
|
|
13
|
+
|
|
14
|
+
parsed = parse_args(args if args else ["-h"])
|
|
15
|
+
|
|
16
|
+
if "func" not in parsed:
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
func = parsed.pop("func")
|
|
20
|
+
func(**parsed)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '3.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (3, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from msw_open_ephys.cli.commands import cmd_preview, cmd_record, cmd_status, cmd_stop
|
|
6
|
+
from msw_open_ephys.cli.parser import (
|
|
7
|
+
_RECORD_DESCRIPTION,
|
|
8
|
+
_add_connection,
|
|
9
|
+
_add_metadata,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register(subparsers: Any) -> None:
|
|
14
|
+
"""Register the ``oe`` subcommand group with the MSW CLI.
|
|
15
|
+
|
|
16
|
+
Called automatically via the ``msw.cli`` entry-point group.
|
|
17
|
+
Adds: msw oe status | preview | record | stop
|
|
18
|
+
"""
|
|
19
|
+
import argparse
|
|
20
|
+
|
|
21
|
+
class _Fmt(
|
|
22
|
+
argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter
|
|
23
|
+
):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
oe = subparsers.add_parser(
|
|
27
|
+
"oe",
|
|
28
|
+
help="Open Ephys GUI remote control",
|
|
29
|
+
formatter_class=_Fmt,
|
|
30
|
+
)
|
|
31
|
+
sub = oe.add_subparsers(metavar="command", dest="oe_command")
|
|
32
|
+
sub.required = True
|
|
33
|
+
|
|
34
|
+
p = sub.add_parser(
|
|
35
|
+
"status",
|
|
36
|
+
formatter_class=_Fmt,
|
|
37
|
+
help="Print current GUI mode (IDLE / ACQUIRE / RECORD)",
|
|
38
|
+
)
|
|
39
|
+
_add_connection(p)
|
|
40
|
+
p.set_defaults(func=cmd_status)
|
|
41
|
+
|
|
42
|
+
p = sub.add_parser(
|
|
43
|
+
"preview",
|
|
44
|
+
formatter_class=_Fmt,
|
|
45
|
+
help="Start acquisition (ACQUIRE mode, no recording)",
|
|
46
|
+
)
|
|
47
|
+
_add_connection(p)
|
|
48
|
+
p.set_defaults(func=cmd_preview)
|
|
49
|
+
|
|
50
|
+
p = sub.add_parser(
|
|
51
|
+
"record",
|
|
52
|
+
formatter_class=_Fmt,
|
|
53
|
+
description=_RECORD_DESCRIPTION,
|
|
54
|
+
help="Configure paths and start recording",
|
|
55
|
+
)
|
|
56
|
+
_add_connection(p)
|
|
57
|
+
_add_metadata(p)
|
|
58
|
+
p.set_defaults(func=cmd_record)
|
|
59
|
+
|
|
60
|
+
p = sub.add_parser(
|
|
61
|
+
"stop", formatter_class=_Fmt, help="Stop acquisition / recording (IDLE mode)"
|
|
62
|
+
)
|
|
63
|
+
_add_connection(p)
|
|
64
|
+
p.set_defaults(func=cmd_stop)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def run_cli() -> None:
|
|
68
|
+
from msw_open_ephys.cli.parser import parse_args
|
|
69
|
+
|
|
70
|
+
args = parse_args()
|
|
71
|
+
func = args.pop("func", None)
|
|
72
|
+
if func:
|
|
73
|
+
func(**args)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from rich import get_console
|
|
6
|
+
from rich.logging import RichHandler
|
|
7
|
+
|
|
8
|
+
from msw_open_ephys.controller import OEController
|
|
9
|
+
from msw_open_ephys.session import Session
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Logging
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def setup_logging(debug: bool = False) -> None:
|
|
17
|
+
level = "DEBUG" if debug else "INFO"
|
|
18
|
+
logger = logging.getLogger()
|
|
19
|
+
if logger.handlers:
|
|
20
|
+
return
|
|
21
|
+
logger.setLevel(getattr(logging, level))
|
|
22
|
+
handler = RichHandler(
|
|
23
|
+
console=get_console(),
|
|
24
|
+
level=level,
|
|
25
|
+
enable_link_path=False,
|
|
26
|
+
markup=True,
|
|
27
|
+
rich_tracebacks=True,
|
|
28
|
+
)
|
|
29
|
+
handler.setFormatter(logging.Formatter("%(message)s", datefmt="%H:%M:%S"))
|
|
30
|
+
logger.addHandler(handler)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Commands
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_status(ip: str, port: int, debug: bool = False, **_) -> None:
|
|
39
|
+
setup_logging(debug)
|
|
40
|
+
oe = OEController(ip=ip, port=port)
|
|
41
|
+
logging.info(f"Open Ephys status: {oe.status}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cmd_preview(ip: str, port: int, debug: bool = False, **_) -> None:
|
|
45
|
+
setup_logging(debug)
|
|
46
|
+
oe = OEController(ip=ip, port=port)
|
|
47
|
+
oe.preview()
|
|
48
|
+
logging.info(f"Open Ephys status: {oe.status}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_stop(ip: str, port: int, debug: bool = False, **_) -> None:
|
|
52
|
+
setup_logging(debug)
|
|
53
|
+
oe = OEController(ip=ip, port=port)
|
|
54
|
+
oe.stop()
|
|
55
|
+
logging.info(f"Open Ephys status: {oe.status}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_record(
|
|
59
|
+
ip: str,
|
|
60
|
+
port: int,
|
|
61
|
+
subject: str,
|
|
62
|
+
local_path: str,
|
|
63
|
+
remote_path: str,
|
|
64
|
+
session_extension: str,
|
|
65
|
+
acquisition_extension: str = "",
|
|
66
|
+
is_child_session_to: str = "",
|
|
67
|
+
debug: bool = False,
|
|
68
|
+
**_,
|
|
69
|
+
) -> None:
|
|
70
|
+
setup_logging(debug)
|
|
71
|
+
|
|
72
|
+
is_child_session_to = Session.resolve_child(is_child_session_to)
|
|
73
|
+
|
|
74
|
+
session = Session(
|
|
75
|
+
subject=subject,
|
|
76
|
+
session_extension=session_extension,
|
|
77
|
+
acquisition_extension=acquisition_extension,
|
|
78
|
+
local_path=local_path,
|
|
79
|
+
remote_path=remote_path,
|
|
80
|
+
ip=ip,
|
|
81
|
+
port=port,
|
|
82
|
+
is_child_session_to=is_child_session_to,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
logging.info(f"Session: {session.session_name}")
|
|
86
|
+
if session.acquisition_extension:
|
|
87
|
+
logging.info(f"Acquisition: {session.acquisition_name}")
|
|
88
|
+
logging.info(f"Records to: {session.main_session_folder}")
|
|
89
|
+
logging.info(f"Local path: {session.local_path_full}")
|
|
90
|
+
logging.debug(f"parent_directory: {session.parent_directory}")
|
|
91
|
+
logging.debug(f"base_text: {session.base_text}")
|
|
92
|
+
|
|
93
|
+
oe = OEController(ip=ip, port=port)
|
|
94
|
+
oe.preview()
|
|
95
|
+
|
|
96
|
+
oe.configure_recording(session.parent_directory, session.base_text)
|
|
97
|
+
logging.debug(f"Recording confirmed: {oe.recording}")
|
|
98
|
+
|
|
99
|
+
time.sleep(1)
|
|
100
|
+
oe.record()
|
|
101
|
+
time.sleep(1)
|
|
102
|
+
|
|
103
|
+
if oe.status != OEController.MODE_RECORD:
|
|
104
|
+
logging.error("Open Ephys did not enter RECORD mode — aborting metadata write.")
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
# Create local directories and write metadata
|
|
108
|
+
session.local_path_full.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
(session.local_path_full / session.session_name).mkdir(parents=True, exist_ok=True)
|
|
110
|
+
|
|
111
|
+
meta = session.metadata(oe_state=oe.recording)
|
|
112
|
+
with session.metadata_file.open("w") as f:
|
|
113
|
+
json.dump(meta, f, indent=4, sort_keys=True)
|
|
114
|
+
logging.info(f"Metadata: {session.metadata_file}")
|
|
115
|
+
|
|
116
|
+
# Always cache so --child @last works after any record command
|
|
117
|
+
session.save_to_cache()
|
|
118
|
+
print("\nSESSION PATH (use with --child for further sessions):")
|
|
119
|
+
print(f" {session._cache_path}\n")
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
from msw_open_ephys.cli.commands import cmd_preview, cmd_record, cmd_status, cmd_stop
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class _Fmt(
|
|
7
|
+
argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter
|
|
8
|
+
):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_DEFAULT_IP = "localhost"
|
|
13
|
+
_DEFAULT_PORT = 37497
|
|
14
|
+
_DEFAULT_LOCAL_PATH = "/mnt/fastdata/data"
|
|
15
|
+
_DEFAULT_REMOTE_PATH = r"E:\\OE_DATA\\LBR\\"
|
|
16
|
+
|
|
17
|
+
_RECORD_DESCRIPTION = """\
|
|
18
|
+
Configure recording paths and start an Open Ephys recording.
|
|
19
|
+
|
|
20
|
+
Three modes are selected automatically based on the arguments provided:
|
|
21
|
+
|
|
22
|
+
Standalone (no --acquisition-extension, no --child)
|
|
23
|
+
Use when ephys is the only session and no children are expected.
|
|
24
|
+
Records to: {remote-path}/{subject}/{subject__dt__session-ext}/Record Node/
|
|
25
|
+
|
|
26
|
+
oe-remote record --subject mouse1 --session-extension pxi
|
|
27
|
+
|
|
28
|
+
Parent (--acquisition-extension is set, no --child)
|
|
29
|
+
Use when starting a multi-modal acquisition that will contain multiple
|
|
30
|
+
sessions. Creates a named acquisition folder and records the first
|
|
31
|
+
session inside it. Caches the acquisition path for --child @last.
|
|
32
|
+
Records to: {remote-path}/{subject}/{subject__dt__acq-ext}/
|
|
33
|
+
{subject__dt__session-ext}/Record Node/
|
|
34
|
+
|
|
35
|
+
oe-remote record --subject mouse1 \\
|
|
36
|
+
--acquisition-extension ephys_multi_behavior \\
|
|
37
|
+
--session-extension pxi
|
|
38
|
+
|
|
39
|
+
Child (--child <acq_path> or --child @last)
|
|
40
|
+
Use when adding a session to an already-started parent acquisition.
|
|
41
|
+
Pass the acquisition folder path explicitly or use @last to reuse the
|
|
42
|
+
most recently cached path.
|
|
43
|
+
Records to: {remote-path}/{acq_path}/{subject__dt__session-ext}/Record Node/
|
|
44
|
+
|
|
45
|
+
oe-remote record --subject mouse1 \\
|
|
46
|
+
--session-extension intan_ttl \\
|
|
47
|
+
--child @last
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _add_connection(parser: argparse.ArgumentParser) -> None:
|
|
52
|
+
g = parser.add_argument_group("Connection")
|
|
53
|
+
g.add_argument(
|
|
54
|
+
"-ip",
|
|
55
|
+
"--remote-ip",
|
|
56
|
+
dest="ip",
|
|
57
|
+
default=_DEFAULT_IP,
|
|
58
|
+
help="Open Ephys GUI host IP",
|
|
59
|
+
)
|
|
60
|
+
g.add_argument(
|
|
61
|
+
"-port",
|
|
62
|
+
"--remote-port",
|
|
63
|
+
dest="port",
|
|
64
|
+
type=int,
|
|
65
|
+
default=_DEFAULT_PORT,
|
|
66
|
+
help="Open Ephys HTTP API port",
|
|
67
|
+
)
|
|
68
|
+
g.add_argument(
|
|
69
|
+
"-d",
|
|
70
|
+
"--debug",
|
|
71
|
+
dest="debug",
|
|
72
|
+
action="store_true",
|
|
73
|
+
default=False,
|
|
74
|
+
help="Enable debug logging",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _add_metadata(parser: argparse.ArgumentParser) -> None:
|
|
79
|
+
g = parser.add_argument_group("Metadata / paths")
|
|
80
|
+
g.add_argument(
|
|
81
|
+
"--local-path",
|
|
82
|
+
dest="local_path",
|
|
83
|
+
default=_DEFAULT_LOCAL_PATH,
|
|
84
|
+
help="Local base data directory",
|
|
85
|
+
)
|
|
86
|
+
g.add_argument(
|
|
87
|
+
"--remote-path",
|
|
88
|
+
dest="remote_path",
|
|
89
|
+
default=_DEFAULT_REMOTE_PATH,
|
|
90
|
+
help="Remote base data directory (as seen by Open Ephys GUI)",
|
|
91
|
+
)
|
|
92
|
+
g.add_argument(
|
|
93
|
+
"--subject",
|
|
94
|
+
dest="subject",
|
|
95
|
+
default="_test_oe_controller",
|
|
96
|
+
help="Subject identifier",
|
|
97
|
+
)
|
|
98
|
+
g.add_argument(
|
|
99
|
+
"--session-extension",
|
|
100
|
+
dest="session_extension",
|
|
101
|
+
required=True,
|
|
102
|
+
help="Session label appended to subject__datetime (e.g. pxi, intan_ttl)",
|
|
103
|
+
)
|
|
104
|
+
g.add_argument(
|
|
105
|
+
"--acquisition-extension",
|
|
106
|
+
dest="acquisition_extension",
|
|
107
|
+
default="",
|
|
108
|
+
help="Acquisition label for the parent folder (enables parent mode). "
|
|
109
|
+
"Example: ephys_multi_behavior",
|
|
110
|
+
)
|
|
111
|
+
g.add_argument(
|
|
112
|
+
"--child",
|
|
113
|
+
"--is-child-session-to",
|
|
114
|
+
dest="is_child_session_to",
|
|
115
|
+
default="",
|
|
116
|
+
metavar="ACQ_PATH",
|
|
117
|
+
help="Add this session under an existing acquisition folder. "
|
|
118
|
+
"Pass subject/acquisition_name or '@last' to use the most recently "
|
|
119
|
+
"cached path.",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
124
|
+
parser = argparse.ArgumentParser(
|
|
125
|
+
prog="oe-remote",
|
|
126
|
+
description="Remote control for Open Ephys GUI v1+",
|
|
127
|
+
formatter_class=_Fmt,
|
|
128
|
+
)
|
|
129
|
+
sub = parser.add_subparsers(metavar="command", dest="command")
|
|
130
|
+
sub.required = True
|
|
131
|
+
|
|
132
|
+
# status
|
|
133
|
+
p = sub.add_parser(
|
|
134
|
+
"status",
|
|
135
|
+
formatter_class=_Fmt,
|
|
136
|
+
help="Print current GUI mode (IDLE / ACQUIRE / RECORD)",
|
|
137
|
+
)
|
|
138
|
+
_add_connection(p)
|
|
139
|
+
p.set_defaults(func=cmd_status)
|
|
140
|
+
|
|
141
|
+
# preview
|
|
142
|
+
p = sub.add_parser(
|
|
143
|
+
"preview",
|
|
144
|
+
formatter_class=_Fmt,
|
|
145
|
+
help="Start acquisition (ACQUIRE mode, no recording)",
|
|
146
|
+
)
|
|
147
|
+
_add_connection(p)
|
|
148
|
+
p.set_defaults(func=cmd_preview)
|
|
149
|
+
|
|
150
|
+
# record
|
|
151
|
+
p = sub.add_parser(
|
|
152
|
+
"record",
|
|
153
|
+
formatter_class=_Fmt,
|
|
154
|
+
description=_RECORD_DESCRIPTION,
|
|
155
|
+
help="Configure paths and start recording",
|
|
156
|
+
)
|
|
157
|
+
_add_connection(p)
|
|
158
|
+
_add_metadata(p)
|
|
159
|
+
p.set_defaults(func=cmd_record)
|
|
160
|
+
|
|
161
|
+
# stop
|
|
162
|
+
p = sub.add_parser(
|
|
163
|
+
"stop", formatter_class=_Fmt, help="Stop acquisition / recording (IDLE mode)"
|
|
164
|
+
)
|
|
165
|
+
_add_connection(p)
|
|
166
|
+
p.set_defaults(func=cmd_stop)
|
|
167
|
+
|
|
168
|
+
return parser
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_args(args=None) -> dict:
|
|
172
|
+
return build_parser().parse_args(args).__dict__
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class OEController:
|
|
8
|
+
"""HTTP controller for Open Ephys GUI v1+ (port 37497 by default).
|
|
9
|
+
|
|
10
|
+
Request format mirrors open_ephys.control.OpenEphysHTTPServer:
|
|
11
|
+
uses data=json.dumps(payload) rather than json=payload, which is
|
|
12
|
+
what the OE HTTP server expects.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
MODE_IDLE = "IDLE"
|
|
16
|
+
MODE_ACQUIRE = "ACQUIRE"
|
|
17
|
+
MODE_RECORD = "RECORD"
|
|
18
|
+
|
|
19
|
+
def __init__(self, ip: str = "localhost", port: int = 37497):
|
|
20
|
+
self.ip = ip
|
|
21
|
+
self.port = port
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def _base(self) -> str:
|
|
25
|
+
return f"http://{self.ip}:{self.port}/api"
|
|
26
|
+
|
|
27
|
+
def _put(self, endpoint: str, payload: dict) -> dict:
|
|
28
|
+
r = requests.put(
|
|
29
|
+
self._base + endpoint,
|
|
30
|
+
data=json.dumps(payload),
|
|
31
|
+
)
|
|
32
|
+
r.raise_for_status()
|
|
33
|
+
return r.json() # type: ignore[no-any-return]
|
|
34
|
+
|
|
35
|
+
def _get(self, endpoint: str) -> dict:
|
|
36
|
+
r = requests.get(self._base + endpoint)
|
|
37
|
+
r.raise_for_status()
|
|
38
|
+
return r.json() # type: ignore[no-any-return]
|
|
39
|
+
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
# Mode control
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def status(self) -> str:
|
|
46
|
+
return str(self._get("/status")["mode"])
|
|
47
|
+
|
|
48
|
+
def preview(self) -> None:
|
|
49
|
+
self._put("/status", {"mode": self.MODE_ACQUIRE})
|
|
50
|
+
|
|
51
|
+
def stop(self) -> None:
|
|
52
|
+
self._put("/status", {"mode": self.MODE_IDLE})
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
# Recording configuration
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def recording(self) -> dict:
|
|
60
|
+
return self._get("/recording")
|
|
61
|
+
|
|
62
|
+
def configure_recording(self, parent_directory: str, base_text: str) -> None:
|
|
63
|
+
"""Configure recording path on all Record Nodes.
|
|
64
|
+
|
|
65
|
+
parent_directory is set per Record Node (the only call that affects
|
|
66
|
+
nodes already in the signal chain). base_text carries the full
|
|
67
|
+
subdirectory hierarchy as a forward-slash path and is set globally.
|
|
68
|
+
OE creates all levels in base_text when recording starts.
|
|
69
|
+
"""
|
|
70
|
+
# base_text and text fields are global settings
|
|
71
|
+
self._put(
|
|
72
|
+
"/recording",
|
|
73
|
+
{
|
|
74
|
+
"base_text": base_text,
|
|
75
|
+
"prepend_text": "",
|
|
76
|
+
"append_text": "",
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# parent_directory must be set per node for existing Record Nodes
|
|
81
|
+
for node in self.recording.get("record_nodes", []):
|
|
82
|
+
self._put(
|
|
83
|
+
f"/recording/{node['node_id']}",
|
|
84
|
+
{"parent_directory": parent_directory},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
# Record with sync wait
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def _nodes_synchronized(self) -> bool:
|
|
92
|
+
for node in self.recording.get("record_nodes", []):
|
|
93
|
+
if not node.get("is_synchronized", False):
|
|
94
|
+
return False
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
def record(self, poll_interval: float = 0.5) -> None:
|
|
98
|
+
while not self._nodes_synchronized():
|
|
99
|
+
print(
|
|
100
|
+
f"[{time.strftime('%H:%M:%S')}] Waiting for streams to synchronize..."
|
|
101
|
+
)
|
|
102
|
+
time.sleep(poll_interval)
|
|
103
|
+
self._put("/status", {"mode": self.MODE_RECORD})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""OpenEphysHostSession — MSW host-session plugin for Open Ephys GUI v1+.
|
|
2
|
+
|
|
3
|
+
Registered under the ``msw.host`` entry-point group as ``openephys``.
|
|
4
|
+
MSW discovers and instantiates this class via
|
|
5
|
+
``make_host_session("openephys", url=...)``.
|
|
6
|
+
|
|
7
|
+
The session is started externally via ``oe-remote record`` before ``msw run``.
|
|
8
|
+
``attach()`` reads the current recording state from the OE HTTP API and returns
|
|
9
|
+
the acquisition context. ``start()`` and ``stop()`` are intentional no-ops —
|
|
10
|
+
lifecycle control is handled by ``oe-remote``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
from msw_plugin_api import HostSessionInfo
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_HOST_RE = re.compile(r"(?:https?://)?([^:/]+)")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_host(url: str) -> str:
|
|
26
|
+
m = _HOST_RE.match(url.strip())
|
|
27
|
+
return m.group(1) if m else url
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OpenEphysHostSession:
|
|
31
|
+
"""Reads acquisition context from a running Open Ephys GUI process.
|
|
32
|
+
|
|
33
|
+
Expects ``base_text`` set by ``oe-remote record`` to be a 3-part path::
|
|
34
|
+
|
|
35
|
+
subject / acquisition_name / oe_session_name
|
|
36
|
+
|
|
37
|
+
Returns ``None`` from ``attach()`` when OE is unreachable or base_text
|
|
38
|
+
is not yet set by oe-remote.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
name = "openephys"
|
|
42
|
+
|
|
43
|
+
def __init__(self, url: str = "localhost", require_recording: bool = False) -> None:
|
|
44
|
+
self._host = _parse_host(url)
|
|
45
|
+
self._require_recording = require_recording
|
|
46
|
+
self.fail_reason: str = ""
|
|
47
|
+
|
|
48
|
+
def attach(self, **kwargs: object) -> HostSessionInfo | None:
|
|
49
|
+
try:
|
|
50
|
+
from msw_open_ephys.controller import OEController
|
|
51
|
+
except ImportError:
|
|
52
|
+
self.fail_reason = (
|
|
53
|
+
"msw-open-ephys not installed (pip install msw-open-ephys)"
|
|
54
|
+
)
|
|
55
|
+
log.error("OpenEphys: %s", self.fail_reason)
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
gui = OEController(ip=self._host)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
status = gui.status
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
self.fail_reason = f"cannot reach {self._host} — {exc}"
|
|
64
|
+
log.error("OpenEphys: %s", self.fail_reason)
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
if self._require_recording and status != "RECORD":
|
|
68
|
+
self.fail_reason = f"status={status!r} (not RECORD)"
|
|
69
|
+
log.info("OpenEphys: %s — no host session attached", self.fail_reason)
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
rec = gui.recording
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
self.fail_reason = f"GET /api/recording failed — {exc}"
|
|
76
|
+
log.error("OpenEphys: %s", self.fail_reason)
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
log.debug("OpenEphys /api/recording: %s", rec)
|
|
80
|
+
|
|
81
|
+
base = (rec.get("base_text") or "").strip("/")
|
|
82
|
+
parts = [p for p in base.split("/") if p]
|
|
83
|
+
log.debug("OpenEphys base_text=%r -> parts=%s", base, parts)
|
|
84
|
+
|
|
85
|
+
if len(parts) < 2:
|
|
86
|
+
self.fail_reason = (
|
|
87
|
+
f"base_text {base!r} has <2 parts — "
|
|
88
|
+
f"run oe-remote record before msw run, "
|
|
89
|
+
f"or use --link-to ACQUISITION_NAME"
|
|
90
|
+
)
|
|
91
|
+
log.error("OpenEphys: %s", self.fail_reason)
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
acq_segment = parts[1]
|
|
95
|
+
try:
|
|
96
|
+
from murineshiftwork.namespace.paths import get_msw_builder
|
|
97
|
+
|
|
98
|
+
_b = get_msw_builder()
|
|
99
|
+
acquisition_name = _b.build_path(
|
|
100
|
+
"session", _b.extract_level_values("session", acq_segment)
|
|
101
|
+
)
|
|
102
|
+
except ImportError:
|
|
103
|
+
acquisition_name = acq_segment
|
|
104
|
+
except ValueError:
|
|
105
|
+
self.fail_reason = (
|
|
106
|
+
f"base_text segment {acq_segment!r} is not a valid MSW session name "
|
|
107
|
+
f"(full base_text: {base!r}) — check oe-remote naming convention"
|
|
108
|
+
)
|
|
109
|
+
log.error("OpenEphys: %s", self.fail_reason)
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
record_nodes = rec.get("record_nodes") or []
|
|
113
|
+
parent_dir = record_nodes[0].get("parent_directory", "") if record_nodes else ""
|
|
114
|
+
|
|
115
|
+
return HostSessionInfo(
|
|
116
|
+
backend="openephys",
|
|
117
|
+
acquisition_name=acquisition_name,
|
|
118
|
+
subject=parts[0],
|
|
119
|
+
parent_directory=parent_dir,
|
|
120
|
+
extra={
|
|
121
|
+
"oe_session_name": parts[2] if len(parts) > 2 else "",
|
|
122
|
+
"status": status,
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def start(self) -> None:
|
|
127
|
+
"""No-op: OE recording is started via oe-remote record before msw run."""
|
|
128
|
+
|
|
129
|
+
def stop(self) -> None:
|
|
130
|
+
"""No-op: OE recording lifecycle is managed by oe-remote."""
|
|
File without changes
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Session path and metadata management for Open Ephys recordings.
|
|
2
|
+
|
|
3
|
+
Three session modes, selected by arguments:
|
|
4
|
+
|
|
5
|
+
Standalone (no --acquisition-extension, no --child)
|
|
6
|
+
OE records to: {remote_path}/{subject__dt__session_ext}/Record Node/
|
|
7
|
+
Local metadata: {local_path}/{subject}/{subject__dt__session_ext}/
|
|
8
|
+
|
|
9
|
+
Parent (--acquisition-extension is set, no --child)
|
|
10
|
+
OE records to: {remote_path}/{subject__dt__acq_ext}/
|
|
11
|
+
{subject__dt__session_ext}/Record Node/
|
|
12
|
+
Local metadata: {local_path}/{subject}/{subject__dt__acq_ext}/
|
|
13
|
+
Caches acquisition_name for --child @last.
|
|
14
|
+
|
|
15
|
+
Child (--child <acq_name> or --child @last)
|
|
16
|
+
OE records to: {remote_path}/{acq_name}/{subject__dt__session_ext}/Record Node/
|
|
17
|
+
Local metadata: {local_path}/{subject}/{acq_name}/
|
|
18
|
+
|
|
19
|
+
Remote path design
|
|
20
|
+
------------------
|
|
21
|
+
The OE GUI only creates the final leaf of parent_directory (cannot create
|
|
22
|
+
intermediate dirs). To work around this, remote_path is always used as
|
|
23
|
+
parent_directory on the Record Node (it already exists), and the full
|
|
24
|
+
subdirectory hierarchy is written into base_text using forward slashes.
|
|
25
|
+
OE accepts forward-slash paths in base_text on Windows and creates all
|
|
26
|
+
levels correctly.
|
|
27
|
+
|
|
28
|
+
parent_directory = remote_path (always, set per Record Node)
|
|
29
|
+
base_text = acq_name/session_name (set globally via /api/recording)
|
|
30
|
+
|
|
31
|
+
The cache stores acquisition_name (no subject prefix) so --child @last
|
|
32
|
+
maps directly to the base_text prefix for child sessions.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import time
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
from msw_open_ephys._version import __version__ as _pkg_version
|
|
39
|
+
|
|
40
|
+
METADATA_VERSION: str = _pkg_version
|
|
41
|
+
|
|
42
|
+
_SESSION_CACHE = Path.home() / ".cache" / "oe-remote" / "last_session"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Session:
|
|
46
|
+
"""Encapsulates all naming, path, and metadata logic for one recording."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
subject: str,
|
|
51
|
+
session_extension: str,
|
|
52
|
+
local_path: str,
|
|
53
|
+
remote_path: str,
|
|
54
|
+
ip: str,
|
|
55
|
+
port: int,
|
|
56
|
+
acquisition_extension: str = "",
|
|
57
|
+
is_child_session_to: str = "",
|
|
58
|
+
):
|
|
59
|
+
self.subject = subject
|
|
60
|
+
self.session_extension = session_extension
|
|
61
|
+
self.acquisition_extension = acquisition_extension
|
|
62
|
+
self.local_path = Path(local_path)
|
|
63
|
+
self.remote_path = remote_path.rstrip("/\\")
|
|
64
|
+
self.ip = ip
|
|
65
|
+
self.port = port
|
|
66
|
+
self.is_child_session_to = is_child_session_to
|
|
67
|
+
|
|
68
|
+
self.dt = time.strftime("%Y%m%d_%H%M%S")
|
|
69
|
+
self.session_name = f"{subject}__{self.dt}__{session_extension}"
|
|
70
|
+
self.acquisition_name = (
|
|
71
|
+
f"{subject}__{self.dt}__{acquisition_extension}"
|
|
72
|
+
if acquisition_extension
|
|
73
|
+
else self.session_name
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
self._compute_paths()
|
|
77
|
+
|
|
78
|
+
def _compute_paths(self) -> None:
|
|
79
|
+
# parent_directory is always the top-level remote path (known to exist).
|
|
80
|
+
# base_text carries the full subdirectory hierarchy with forward slashes;
|
|
81
|
+
# OE creates all levels when recording starts.
|
|
82
|
+
self.parent_directory = self.remote_path
|
|
83
|
+
|
|
84
|
+
if self.is_child_session_to:
|
|
85
|
+
# Case 3 – child
|
|
86
|
+
self.local_path_full = (
|
|
87
|
+
self.local_path / self.subject / self.is_child_session_to
|
|
88
|
+
)
|
|
89
|
+
self.base_text = f"{self.is_child_session_to}/{self.session_name}"
|
|
90
|
+
self.main_session_folder = self.base_text
|
|
91
|
+
self._cache_path = self.is_child_session_to
|
|
92
|
+
|
|
93
|
+
elif self.acquisition_extension:
|
|
94
|
+
# Case 2 – parent
|
|
95
|
+
self.local_path_full = (
|
|
96
|
+
self.local_path / self.subject / self.acquisition_name
|
|
97
|
+
)
|
|
98
|
+
self.base_text = (
|
|
99
|
+
f"{self.subject}/{self.acquisition_name}/{self.session_name}"
|
|
100
|
+
)
|
|
101
|
+
self.main_session_folder = self.base_text
|
|
102
|
+
self._cache_path = self.acquisition_name
|
|
103
|
+
|
|
104
|
+
else:
|
|
105
|
+
# Case 1 – standalone
|
|
106
|
+
self.local_path_full = self.local_path / self.subject / self.session_name
|
|
107
|
+
self.base_text = f"{self.subject}/{self.session_name}"
|
|
108
|
+
self.main_session_folder = self.session_name
|
|
109
|
+
self._cache_path = self.session_name
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def metadata_file(self) -> Path:
|
|
113
|
+
return self.local_path_full / f"{self.session_name}.settings.ephys.json"
|
|
114
|
+
|
|
115
|
+
def metadata(self, oe_state: dict | None = None) -> dict:
|
|
116
|
+
"""Full metadata dict for the .settings.ephys.json file.
|
|
117
|
+
|
|
118
|
+
All keys from metadata_version 2 are preserved for backward compatibility.
|
|
119
|
+
Branch on ``metadata_version`` to handle format differences.
|
|
120
|
+
"""
|
|
121
|
+
return {
|
|
122
|
+
# --- Version ---
|
|
123
|
+
"metadata_version": METADATA_VERSION,
|
|
124
|
+
"version": METADATA_VERSION, # legacy key (was 2)
|
|
125
|
+
# --- Identity ---
|
|
126
|
+
"subject": self.subject,
|
|
127
|
+
"datetime": self.dt,
|
|
128
|
+
"acquisition_extension": self.acquisition_extension,
|
|
129
|
+
"session_extension": self.session_extension,
|
|
130
|
+
"acquisition_name": self.acquisition_name,
|
|
131
|
+
"session_name": self.session_extension, # legacy: stored extension string
|
|
132
|
+
"full_session_name": self.session_name, # full subject__dt__ext string
|
|
133
|
+
"full_acquisition_name": self.main_session_folder, # legacy alias
|
|
134
|
+
"main_session_folder": self.main_session_folder,
|
|
135
|
+
"acquisition_task_name": self.acquisition_extension, # legacy alias
|
|
136
|
+
"is_child_session_to": self.is_child_session_to,
|
|
137
|
+
# --- Local paths ---
|
|
138
|
+
"local_path": str(self.local_path),
|
|
139
|
+
"local_path_full": str(self.local_path_full),
|
|
140
|
+
"metadata_file": str(self.metadata_file),
|
|
141
|
+
# --- Remote / OE recording settings ---
|
|
142
|
+
"remote_ip": self.ip,
|
|
143
|
+
"remote_port": self.port,
|
|
144
|
+
"remote_path": self.remote_path,
|
|
145
|
+
"parent_directory": self.parent_directory,
|
|
146
|
+
"base_text": self.base_text,
|
|
147
|
+
"prepend_text": "",
|
|
148
|
+
"append_text": "",
|
|
149
|
+
"create_new_dir": True,
|
|
150
|
+
# --- OE live state snapshot (populated after recording starts) ---
|
|
151
|
+
"oe_settings": oe_state or {},
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
# Session cache — always written so --child @last works after any record
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def save_to_cache(self) -> None:
|
|
159
|
+
_SESSION_CACHE.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
_SESSION_CACHE.write_text(self._cache_path)
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def resolve_child(value: str) -> str:
|
|
164
|
+
"""Expand '@last' to the cached path, or pass value through."""
|
|
165
|
+
if value != "@last":
|
|
166
|
+
return value
|
|
167
|
+
if not _SESSION_CACHE.exists():
|
|
168
|
+
raise FileNotFoundError(
|
|
169
|
+
"No cached session found. Run any 'record' command first, "
|
|
170
|
+
"or pass the acquisition name explicitly to --child."
|
|
171
|
+
)
|
|
172
|
+
return _SESSION_CACHE.read_text().strip()
|
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Smoke tests — verify package imports and basic structure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_controller_importable() -> None:
|
|
7
|
+
from msw_open_ephys.controller import OEController
|
|
8
|
+
|
|
9
|
+
oe = OEController(ip="localhost", port=37497)
|
|
10
|
+
assert oe._base == "http://localhost:37497/api"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_session_importable() -> None:
|
|
14
|
+
from msw_open_ephys.session import Session
|
|
15
|
+
|
|
16
|
+
s = Session(
|
|
17
|
+
subject="m01",
|
|
18
|
+
session_extension="pxi",
|
|
19
|
+
local_path="/tmp/data",
|
|
20
|
+
remote_path="E:\\OE_DATA",
|
|
21
|
+
ip="localhost",
|
|
22
|
+
port=37497,
|
|
23
|
+
)
|
|
24
|
+
assert s.subject == "m01"
|
|
25
|
+
assert s.session_name.startswith("m01__")
|
|
26
|
+
assert s.session_name.endswith("__pxi")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_host_session_importable() -> None:
|
|
30
|
+
from msw_plugin_api import HostSessionProtocol
|
|
31
|
+
|
|
32
|
+
from msw_open_ephys.host import OpenEphysHostSession
|
|
33
|
+
|
|
34
|
+
client = OpenEphysHostSession(url="localhost")
|
|
35
|
+
assert isinstance(client, HostSessionProtocol)
|
|
36
|
+
assert client.name == "openephys"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_cli_register_callable() -> None:
|
|
40
|
+
from msw_open_ephys.cli import register
|
|
41
|
+
|
|
42
|
+
assert callable(register)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_entry_points_declared() -> None:
|
|
46
|
+
from importlib.metadata import entry_points
|
|
47
|
+
|
|
48
|
+
host_eps = {ep.name for ep in entry_points(group="msw.host")}
|
|
49
|
+
cli_eps = {ep.name for ep in entry_points(group="msw.cli")}
|
|
50
|
+
assert "openephys" in host_eps
|
|
51
|
+
assert "oe" in cli_eps
|