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.
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .eggs/
7
+ *.egg
8
+ .venv/
9
+ venv/
10
+ .coverage
11
+ htmlcov/
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+ src/*/_version.py
@@ -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,4 @@
1
+ from msw_open_ephys import run_cli
2
+
3
+ if __name__ == "__main__":
4
+ run_cli()
@@ -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