mesofield 0.3.2b0__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.
- docs/_static/custom.css +40 -0
- docs/_static/favicon.png +0 -0
- docs/_static/logo.png +0 -0
- docs/api/index.md +70 -0
- docs/conf.py +200 -0
- docs/developer_guide.md +303 -0
- docs/index.md +25 -0
- docs/tutorial.md +4 -0
- docs/user_guide.md +172 -0
- examples/teensy_pulse_generator.py +320 -0
- experiments/pipeline_demo/experiment.json +24 -0
- experiments/pipeline_demo/hardware.yaml +23 -0
- experiments/pipeline_demo/procedure.py +50 -0
- experiments/two_cam_demo/experiment.json +24 -0
- experiments/two_cam_demo/hardware.yaml +58 -0
- experiments/two_cam_demo/load_dataset.py +213 -0
- experiments/two_cam_demo/procedure.py +87 -0
- external/video-codecs/openh264-1.8.0-win64.dll +0 -0
- mesofield/__init__.py +45 -0
- mesofield/__main__.py +11 -0
- mesofield/_version.py +24 -0
- mesofield/base.py +750 -0
- mesofield/cli/__init__.py +57 -0
- mesofield/cli/_richhelp.py +100 -0
- mesofield/cli/acquire.py +254 -0
- mesofield/cli/datakit.py +165 -0
- mesofield/cli/process.py +376 -0
- mesofield/cli/rig.py +108 -0
- mesofield/cli/tools.py +347 -0
- mesofield/config.py +751 -0
- mesofield/data/__init__.py +23 -0
- mesofield/data/batch.py +633 -0
- mesofield/data/manager.py +388 -0
- mesofield/data/writer.py +289 -0
- mesofield/datakit/__init__.py +44 -0
- mesofield/datakit/__main__.py +35 -0
- mesofield/datakit/_utils/_logger.py +5 -0
- mesofield/datakit/_version.py +141 -0
- mesofield/datakit/config.py +50 -0
- mesofield/datakit/core.py +783 -0
- mesofield/datakit/datamodel.py +200 -0
- mesofield/datakit/discover.py +124 -0
- mesofield/datakit/explore.py +651 -0
- mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
- mesofield/datakit/profile.py +535 -0
- mesofield/datakit/shell.py +83 -0
- mesofield/datakit/sources/__init__.py +65 -0
- mesofield/datakit/sources/analysis/mesomap.py +194 -0
- mesofield/datakit/sources/analysis/mesoscope.py +77 -0
- mesofield/datakit/sources/analysis/pupil.py +246 -0
- mesofield/datakit/sources/behavior/__init__.py +0 -0
- mesofield/datakit/sources/behavior/dataqueue.py +281 -0
- mesofield/datakit/sources/behavior/psychopy.py +364 -0
- mesofield/datakit/sources/behavior/treadmill.py +323 -0
- mesofield/datakit/sources/behavior/wheel.py +277 -0
- mesofield/datakit/sources/camera/mesoscope.py +32 -0
- mesofield/datakit/sources/camera/metadata_json.py +130 -0
- mesofield/datakit/sources/camera/pupil.py +28 -0
- mesofield/datakit/sources/camera/suite2p.py +547 -0
- mesofield/datakit/sources/register.py +204 -0
- mesofield/datakit/sources/session/config.py +130 -0
- mesofield/datakit/sources/session/notes.py +63 -0
- mesofield/datakit/sources/session/timestamps.py +58 -0
- mesofield/datakit/timeline.py +306 -0
- mesofield/devices/__init__.py +42 -0
- mesofield/devices/base.py +498 -0
- mesofield/devices/base_camera.py +295 -0
- mesofield/devices/cameras.py +740 -0
- mesofield/devices/daq.py +151 -0
- mesofield/devices/encoder.py +384 -0
- mesofield/devices/mocks.py +275 -0
- mesofield/devices/psychopy_device.py +455 -0
- mesofield/devices/subprocesses/__init__.py +0 -0
- mesofield/devices/subprocesses/psychopy.py +133 -0
- mesofield/devices/treadmill.py +318 -0
- mesofield/engines.py +380 -0
- mesofield/gui/Mesofield_icon.png +0 -0
- mesofield/gui/__init__.py +76 -0
- mesofield/gui/config_wizard.py +724 -0
- mesofield/gui/controller.py +535 -0
- mesofield/gui/dynamic_controller.py +78 -0
- mesofield/gui/maingui.py +427 -0
- mesofield/gui/mdagui.py +285 -0
- mesofield/gui/qt_device_adapter.py +109 -0
- mesofield/gui/speedplotter.py +152 -0
- mesofield/gui/theme.py +445 -0
- mesofield/gui/tiff_viewer.py +1050 -0
- mesofield/gui/viewer.py +691 -0
- mesofield/hardware.py +549 -0
- mesofield/playback.py +1298 -0
- mesofield/processing/__init__.py +12 -0
- mesofield/processing/runner.py +237 -0
- mesofield/processors/__init__.py +13 -0
- mesofield/processors/base.py +287 -0
- mesofield/processors/frame_mean.py +19 -0
- mesofield/protocols.py +378 -0
- mesofield/scaffold/__init__.py +34 -0
- mesofield/scaffold/experiment.py +400 -0
- mesofield/scaffold/rigs.py +121 -0
- mesofield/signals.py +85 -0
- mesofield/utils/__init__.py +0 -0
- mesofield/utils/_logger.py +156 -0
- mesofield/utils/retrofit.py +309 -0
- mesofield/utils/utils.py +217 -0
- mesofield-0.3.2b0.dist-info/METADATA +178 -0
- mesofield-0.3.2b0.dist-info/RECORD +111 -0
- mesofield-0.3.2b0.dist-info/WHEEL +5 -0
- mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
- mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
- mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
- scripts/bench_frame_processor.py +103 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized logging for Mesofield using loguru.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from mesofield.utils._logger import get_logger
|
|
6
|
+
logger = get_logger("MyClass")
|
|
7
|
+
logger.info("Hello world")
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import logging
|
|
12
|
+
import sys
|
|
13
|
+
from os import PathLike
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from loguru import logger
|
|
18
|
+
|
|
19
|
+
_configured = False
|
|
20
|
+
_log_dir: Optional[Path] = None
|
|
21
|
+
|
|
22
|
+
# Default extra so the format string always resolves {extra[logger_name]}
|
|
23
|
+
logger.configure(extra={"logger_name": "mesofield"})
|
|
24
|
+
|
|
25
|
+
_CONSOLE_FORMAT = (
|
|
26
|
+
"<green>{time:HH:mm:ss}</green> | "
|
|
27
|
+
"<level>{level: <8}</level> | "
|
|
28
|
+
"[{extra[logger_name]}] <cyan>{file.name}:{line}</cyan> --> {message}"
|
|
29
|
+
)
|
|
30
|
+
_FILE_FORMAT = "{time:HH:mm:ss} | {level: <8} | [{extra[logger_name]}] {file.name}:{line} --> {message}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InterceptHandler(logging.Handler):
|
|
34
|
+
"""Route stdlib logging records through loguru."""
|
|
35
|
+
|
|
36
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
37
|
+
try:
|
|
38
|
+
level = logger.level(record.levelname).name
|
|
39
|
+
except ValueError:
|
|
40
|
+
level = record.levelno
|
|
41
|
+
|
|
42
|
+
frame, depth = logging.currentframe(), 0
|
|
43
|
+
while frame.f_code.co_filename in (logging.__file__, __file__):
|
|
44
|
+
frame = frame.f_back
|
|
45
|
+
depth += 1
|
|
46
|
+
|
|
47
|
+
logger.opt(depth=depth, exception=record.exc_info).log(
|
|
48
|
+
level, record.getMessage()
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def install_excepthook() -> None:
|
|
53
|
+
"""Log uncaught exceptions through loguru with full traceback diagnostics."""
|
|
54
|
+
|
|
55
|
+
def _handle(exc_type, exc_value, exc_traceback):
|
|
56
|
+
if issubclass(exc_type, KeyboardInterrupt):
|
|
57
|
+
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
|
58
|
+
return
|
|
59
|
+
logger.opt(exception=(exc_type, exc_value, exc_traceback)).error(
|
|
60
|
+
"Uncaught exception"
|
|
61
|
+
)
|
|
62
|
+
if _log_dir is not None:
|
|
63
|
+
print(f"Uncaught exception logged to {_log_dir / 'mesofield.log'}")
|
|
64
|
+
|
|
65
|
+
sys.excepthook = _handle
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def setup_logging(log_dir: Optional[str] = None, level: str = "INFO") -> None:
|
|
69
|
+
global _configured, _log_dir
|
|
70
|
+
if _configured:
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
if log_dir:
|
|
74
|
+
_log_dir = Path(log_dir)
|
|
75
|
+
else:
|
|
76
|
+
project_root = Path(__file__).resolve().parent.parent
|
|
77
|
+
_log_dir = project_root / "logs"
|
|
78
|
+
|
|
79
|
+
_log_dir.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
|
|
81
|
+
logger.remove()
|
|
82
|
+
|
|
83
|
+
logger.add(
|
|
84
|
+
sys.stderr,
|
|
85
|
+
format=_CONSOLE_FORMAT,
|
|
86
|
+
level=level.upper(),
|
|
87
|
+
colorize=True,
|
|
88
|
+
diagnose=True,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
logger.add(
|
|
92
|
+
_log_dir / "mesofield.log",
|
|
93
|
+
format=_FILE_FORMAT,
|
|
94
|
+
level="DEBUG",
|
|
95
|
+
rotation="00:00",
|
|
96
|
+
retention="7 days",
|
|
97
|
+
encoding="utf-8",
|
|
98
|
+
diagnose=True,
|
|
99
|
+
backtrace=True,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Route all stdlib logging through loguru
|
|
103
|
+
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
|
|
104
|
+
|
|
105
|
+
# Suppress noisy third-party stdlib loggers before they reach loguru
|
|
106
|
+
for lib in ("matplotlib", "asyncio", "traitlets", "pymmcore_plus", "pymmcore-plus",
|
|
107
|
+
"ipykernel"):
|
|
108
|
+
logging.getLogger(lib).setLevel(logging.WARNING)
|
|
109
|
+
|
|
110
|
+
install_excepthook()
|
|
111
|
+
_configured = True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def log_this_fr(func):
|
|
115
|
+
"""Decorator that logs entry, exit, and exceptions of the function."""
|
|
116
|
+
|
|
117
|
+
@functools.wraps(func)
|
|
118
|
+
def wrapper(*args, **kwargs):
|
|
119
|
+
_log = get_logger(func.__module__)
|
|
120
|
+
_log.debug(f"Entering {func.__qualname__} args={args!r}, kwargs={kwargs!r}")
|
|
121
|
+
try:
|
|
122
|
+
result = func(*args, **kwargs)
|
|
123
|
+
_log.debug(f"Exiting {func.__qualname__} returned {result!r}")
|
|
124
|
+
return result
|
|
125
|
+
except Exception:
|
|
126
|
+
_log.exception(f"Exception in {func.__qualname__}")
|
|
127
|
+
raise
|
|
128
|
+
|
|
129
|
+
return wrapper
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_logger(name: str):
|
|
133
|
+
if not _configured:
|
|
134
|
+
setup_logging()
|
|
135
|
+
return logger.bind(logger_name=name)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def hyperlink(path: str | PathLike[str], text: str) -> str:
|
|
139
|
+
"""Return an OSC-8 terminal hyperlink with custom display text.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
path: Local filesystem path or URI target.
|
|
143
|
+
text: Link label shown in the log message.
|
|
144
|
+
"""
|
|
145
|
+
target = str(path)
|
|
146
|
+
if not target:
|
|
147
|
+
return text
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
if target.startswith(("file://", "http://", "https://")):
|
|
151
|
+
uri = target
|
|
152
|
+
else:
|
|
153
|
+
uri = Path(target).expanduser().resolve(strict=False).as_uri()
|
|
154
|
+
return f"\033]8;;{uri}\033\\{text}\033]8;;\033\\"
|
|
155
|
+
except Exception:
|
|
156
|
+
return text
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Reconstruct AcquisitionManifests for legacy mesofield sessions.
|
|
2
|
+
|
|
3
|
+
Mesofield only started writing a `mesokit_schema.AcquisitionManifest` once
|
|
4
|
+
the `Procedure._write_acquisition_manifest` hook landed. Sessions acquired
|
|
5
|
+
before that have no manifest, which means downstream tools (datakit,
|
|
6
|
+
databench) cannot ingest them through the contract path.
|
|
7
|
+
|
|
8
|
+
This module walks a BIDS-laid-out session directory (or an experiment
|
|
9
|
+
root containing many sessions) and synthesizes a manifest from what is
|
|
10
|
+
already on disk:
|
|
11
|
+
|
|
12
|
+
- subject / session from the BIDS path (`sub-X/ses-Y/`)
|
|
13
|
+
- task from the filename suffix (`..._task-Z_...`)
|
|
14
|
+
- per-producer `output_path`, `bids_type`, `file_type` from the file tree
|
|
15
|
+
- `started_at` / `ended_at` from the session's `*_timestamps.csv`
|
|
16
|
+
- frame-metadata sidecars (`*_frame_metadata.json`) attached to their tiff
|
|
17
|
+
|
|
18
|
+
Fields that the legacy filesystem does not carry (hardware calibration,
|
|
19
|
+
mesofield_version, per-producer sampling_rate) are written as their
|
|
20
|
+
empty/default values. The manifest still gates ingest, but downstream
|
|
21
|
+
analyses that need calibration will need it filled in by hand.
|
|
22
|
+
|
|
23
|
+
Multi-task sessions get one manifest per task, written as
|
|
24
|
+
`manifest_task-<T>.json`; single-task sessions write `manifest.json`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import csv
|
|
30
|
+
import re
|
|
31
|
+
from collections import defaultdict
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Iterator, Optional
|
|
35
|
+
|
|
36
|
+
from mesokit_schema import (
|
|
37
|
+
AcquisitionManifest,
|
|
38
|
+
ProducerEntry,
|
|
39
|
+
SessionIdentity,
|
|
40
|
+
TimeBasis,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_BIDS_FILE_RE = re.compile(
|
|
45
|
+
r"^(?P<timestamp>\d{8}_\d{6})"
|
|
46
|
+
r"_sub-(?P<subject>[^_]+)"
|
|
47
|
+
r"_ses-(?P<session>[^_]+)"
|
|
48
|
+
r"_task-(?P<task>[^_]+)"
|
|
49
|
+
r"_(?P<suffix>[^.]+)"
|
|
50
|
+
r"\.(?P<extension>.+)$"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Filenames whose BIDS suffix marks them as session metadata, not producer data.
|
|
54
|
+
_SESSION_LEVEL_SUFFIXES: frozenset[str] = frozenset({
|
|
55
|
+
"configuration", "notes", "timestamps",
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
_SIDECAR_TAIL = "_frame_metadata.json"
|
|
59
|
+
|
|
60
|
+
# Default values for fields the legacy filesystem doesn't carry.
|
|
61
|
+
LEGACY_VERSION = "legacy"
|
|
62
|
+
LEGACY_CLOCK_SOURCE = "wall_unix_s"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def discover_sessions(root: Path) -> Iterator[Path]:
|
|
66
|
+
"""Yield session directories (`.../sub-X/ses-Y/`) under `root`.
|
|
67
|
+
|
|
68
|
+
Accepts either a session dir, an experiment root containing `data/`,
|
|
69
|
+
or any ancestor that contains BIDS-laid-out sessions.
|
|
70
|
+
"""
|
|
71
|
+
root = Path(root).resolve()
|
|
72
|
+
if _looks_like_session(root):
|
|
73
|
+
yield root
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
# Common layouts: <exp>/data/sub-*/ses-* or <exp>/sub-*/ses-*
|
|
77
|
+
for pattern in ("data/sub-*/ses-*", "sub-*/ses-*", "*/sub-*/ses-*"):
|
|
78
|
+
matches = sorted(root.glob(pattern))
|
|
79
|
+
if matches:
|
|
80
|
+
for path in matches:
|
|
81
|
+
if _looks_like_session(path):
|
|
82
|
+
yield path
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _looks_like_session(path: Path) -> bool:
|
|
87
|
+
return (
|
|
88
|
+
path.is_dir()
|
|
89
|
+
and path.name.startswith("ses-")
|
|
90
|
+
and path.parent.name.startswith("sub-")
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def synthesize_manifests(session_dir: Path) -> dict[str, AcquisitionManifest]:
|
|
95
|
+
"""Build one manifest per task found under `session_dir`.
|
|
96
|
+
|
|
97
|
+
Returns a {task: AcquisitionManifest} mapping. Single-task sessions
|
|
98
|
+
return a one-entry dict; the caller decides the filename.
|
|
99
|
+
"""
|
|
100
|
+
session_dir = Path(session_dir).resolve()
|
|
101
|
+
subject = session_dir.parent.name.removeprefix("sub-")
|
|
102
|
+
session = session_dir.name.removeprefix("ses-")
|
|
103
|
+
|
|
104
|
+
files = [p for p in session_dir.rglob("*") if p.is_file()]
|
|
105
|
+
sidecars = _index_sidecars(files)
|
|
106
|
+
|
|
107
|
+
# Partition producer files by task.
|
|
108
|
+
by_task: dict[str, list[tuple[Path, re.Match[str]]]] = defaultdict(list)
|
|
109
|
+
for path in files:
|
|
110
|
+
m = _BIDS_FILE_RE.match(path.name)
|
|
111
|
+
if not m:
|
|
112
|
+
continue
|
|
113
|
+
suffix = m.group("suffix")
|
|
114
|
+
if suffix in _SESSION_LEVEL_SUFFIXES:
|
|
115
|
+
continue
|
|
116
|
+
if path.name.endswith(_SIDECAR_TAIL):
|
|
117
|
+
continue
|
|
118
|
+
by_task[m.group("task")].append((path, m))
|
|
119
|
+
|
|
120
|
+
if not by_task:
|
|
121
|
+
return {}
|
|
122
|
+
|
|
123
|
+
out: dict[str, AcquisitionManifest] = {}
|
|
124
|
+
for task, entries in by_task.items():
|
|
125
|
+
# Session-level files are written per-task too (``..._task-<T>_timestamps.csv``),
|
|
126
|
+
# so resolve them against *this* task rather than grabbing the first match --
|
|
127
|
+
# otherwise every task's manifest inherits one arbitrary task's timing/config.
|
|
128
|
+
timestamps_path = _find_session_file(files, "timestamps", task)
|
|
129
|
+
started_at, ended_at = (
|
|
130
|
+
_read_timestamps(timestamps_path) if timestamps_path else (None, None)
|
|
131
|
+
)
|
|
132
|
+
config_path = _find_session_file(files, "configuration", task)
|
|
133
|
+
config = _read_configuration(config_path) if config_path else {}
|
|
134
|
+
fallback_started_at = started_at or _earliest_mtime(
|
|
135
|
+
[path for path, _ in entries]
|
|
136
|
+
)
|
|
137
|
+
producers = [
|
|
138
|
+
_producer_for(path, match, session_dir, sidecars)
|
|
139
|
+
for path, match in sorted(entries, key=lambda pair: pair[0].name)
|
|
140
|
+
]
|
|
141
|
+
out[task] = AcquisitionManifest(
|
|
142
|
+
mesofield_version=LEGACY_VERSION,
|
|
143
|
+
acquisition_complete=True,
|
|
144
|
+
started_at=fallback_started_at,
|
|
145
|
+
ended_at=ended_at,
|
|
146
|
+
session=SessionIdentity(
|
|
147
|
+
subject=subject,
|
|
148
|
+
session=session,
|
|
149
|
+
task=task,
|
|
150
|
+
experimenter=config.get("experimenter"),
|
|
151
|
+
protocol=config.get("protocol"),
|
|
152
|
+
),
|
|
153
|
+
producers=producers,
|
|
154
|
+
)
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def manifest_filename(task: str, multi_task: bool) -> str:
|
|
159
|
+
"""Decide the on-disk filename for a synthesized manifest."""
|
|
160
|
+
return f"manifest_task-{task}.json" if multi_task else "manifest.json"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# Helpers
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _index_sidecars(files: list[Path]) -> dict[Path, Path]:
|
|
168
|
+
"""Map each data file to its sibling `_frame_metadata.json` sidecar, if any.
|
|
169
|
+
|
|
170
|
+
Writers name the sidecar by appending ``_frame_metadata.json`` to the data
|
|
171
|
+
file's *full* name including extension (see ``writer.FRAME_MD_FILENAME``),
|
|
172
|
+
so stripping the tail yields the data file's exact name -- e.g.
|
|
173
|
+
``foo.ome.tiff_frame_metadata.json`` -> ``foo.ome.tiff``. Match that name
|
|
174
|
+
directly; the previous ``base + "."`` prefix test required a trailing dot
|
|
175
|
+
that never exists and so attached no sidecars at all.
|
|
176
|
+
"""
|
|
177
|
+
index: dict[Path, Path] = {}
|
|
178
|
+
for path in files:
|
|
179
|
+
if not path.name.endswith(_SIDECAR_TAIL):
|
|
180
|
+
continue
|
|
181
|
+
base = path.name[: -len(_SIDECAR_TAIL)]
|
|
182
|
+
for candidate in path.parent.iterdir():
|
|
183
|
+
if (
|
|
184
|
+
candidate.is_file()
|
|
185
|
+
and candidate != path
|
|
186
|
+
and (
|
|
187
|
+
candidate.name == base
|
|
188
|
+
or candidate.name.startswith(base + ".")
|
|
189
|
+
)
|
|
190
|
+
):
|
|
191
|
+
index[candidate] = path
|
|
192
|
+
break
|
|
193
|
+
return index
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _find_session_file(
|
|
197
|
+
files: list[Path], suffix: str, task: Optional[str] = None
|
|
198
|
+
) -> Optional[Path]:
|
|
199
|
+
"""Locate a session-level file by its BIDS suffix (e.g. 'timestamps').
|
|
200
|
+
|
|
201
|
+
Session-level files carry a ``_task-<T>_`` token like everything else, so
|
|
202
|
+
when *task* is given prefer the file written for that task. Fall back to a
|
|
203
|
+
lone file of that suffix only when there is exactly one (covers sessions
|
|
204
|
+
whose task token differs slightly from the producer files).
|
|
205
|
+
"""
|
|
206
|
+
matches: list[tuple[Path, re.Match[str]]] = []
|
|
207
|
+
for path in files:
|
|
208
|
+
m = _BIDS_FILE_RE.match(path.name)
|
|
209
|
+
if m and m.group("suffix") == suffix:
|
|
210
|
+
matches.append((path, m))
|
|
211
|
+
if not matches:
|
|
212
|
+
return None
|
|
213
|
+
if task is not None:
|
|
214
|
+
for path, m in matches:
|
|
215
|
+
if m.group("task") == task:
|
|
216
|
+
return path
|
|
217
|
+
if len(matches) == 1:
|
|
218
|
+
return matches[0][0]
|
|
219
|
+
return None
|
|
220
|
+
return matches[0][0]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _read_timestamps(path: Path) -> tuple[Optional[datetime], Optional[datetime]]:
|
|
224
|
+
"""Return (start, stop) UTC-aware datetimes from a legacy timestamps.csv.
|
|
225
|
+
|
|
226
|
+
The file is written by `DataSaver.save_timestamps` with columns
|
|
227
|
+
``device_id, started, stopped`` (older revisions used ``id, start_time,
|
|
228
|
+
stop_time`` — both are accepted). The first row is the procedure-level
|
|
229
|
+
timing; subsequent rows are per-device. Legacy values may be
|
|
230
|
+
naive-local; we convert to UTC via `astimezone`.
|
|
231
|
+
"""
|
|
232
|
+
with open(path, "r", newline="", encoding="utf-8") as fh:
|
|
233
|
+
reader = csv.DictReader(fh)
|
|
234
|
+
for row in reader:
|
|
235
|
+
start = row.get("started") or row.get("start_time")
|
|
236
|
+
stop = row.get("stopped") or row.get("stop_time")
|
|
237
|
+
return _parse_dt(start), _parse_dt(stop)
|
|
238
|
+
return None, None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _read_configuration(path: Path) -> dict[str, str]:
|
|
242
|
+
"""Pull a flat `{Parameter: Value}` dict from a session configuration.csv."""
|
|
243
|
+
out: dict[str, str] = {}
|
|
244
|
+
with open(path, "r", newline="", encoding="utf-8") as fh:
|
|
245
|
+
reader = csv.DictReader(fh)
|
|
246
|
+
for row in reader:
|
|
247
|
+
key = (row.get("Parameter") or "").strip()
|
|
248
|
+
val = (row.get("Value") or "").strip()
|
|
249
|
+
if key:
|
|
250
|
+
out[key] = val
|
|
251
|
+
return out
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _parse_dt(raw: Optional[str]) -> Optional[datetime]:
|
|
255
|
+
if not raw:
|
|
256
|
+
return None
|
|
257
|
+
raw = raw.strip()
|
|
258
|
+
try:
|
|
259
|
+
dt = datetime.fromisoformat(raw)
|
|
260
|
+
except ValueError:
|
|
261
|
+
return None
|
|
262
|
+
if dt.tzinfo is None:
|
|
263
|
+
# Treat naive as system-local and convert to UTC.
|
|
264
|
+
dt = dt.astimezone(timezone.utc)
|
|
265
|
+
return dt
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _earliest_mtime(files: list[Path]) -> datetime:
|
|
269
|
+
"""Fallback session start time when timestamps.csv is missing."""
|
|
270
|
+
if not files:
|
|
271
|
+
return datetime.now(timezone.utc)
|
|
272
|
+
earliest = min(p.stat().st_mtime for p in files)
|
|
273
|
+
return datetime.fromtimestamp(earliest, tz=timezone.utc)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _producer_for(
|
|
277
|
+
path: Path,
|
|
278
|
+
match: re.Match[str],
|
|
279
|
+
session_dir: Path,
|
|
280
|
+
sidecars: dict[Path, Path],
|
|
281
|
+
) -> ProducerEntry:
|
|
282
|
+
rel = path.resolve().relative_to(session_dir.resolve())
|
|
283
|
+
bids_type = rel.parent.name if str(rel.parent) != "." else None
|
|
284
|
+
sidecar_rel: Optional[str] = None
|
|
285
|
+
if path in sidecars:
|
|
286
|
+
sidecar_rel = str(sidecars[path].resolve().relative_to(session_dir.resolve()))
|
|
287
|
+
suffix = match.group("suffix")
|
|
288
|
+
return ProducerEntry(
|
|
289
|
+
device_id=suffix,
|
|
290
|
+
device_type=_guess_device_type(suffix, match.group("extension")),
|
|
291
|
+
data_type=suffix,
|
|
292
|
+
bids_type=bids_type,
|
|
293
|
+
file_type=match.group("extension"),
|
|
294
|
+
output_path=str(rel),
|
|
295
|
+
metadata_path=sidecar_rel,
|
|
296
|
+
sampling_rate_hz=None,
|
|
297
|
+
time_basis=TimeBasis(clock_source=LEGACY_CLOCK_SOURCE),
|
|
298
|
+
calibration={},
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _guess_device_type(suffix: str, extension: str) -> str:
|
|
303
|
+
if extension.endswith(("tiff", "tif", "mp4", "avi")):
|
|
304
|
+
return "camera"
|
|
305
|
+
if any(token in suffix for token in ("encoder", "wheel", "treadmill")):
|
|
306
|
+
return "encoder"
|
|
307
|
+
if "nidaq" in suffix:
|
|
308
|
+
return "nidaq"
|
|
309
|
+
return "device"
|
mesofield/utils/utils.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Miscellaneous diagnostic helpers.
|
|
2
|
+
|
|
3
|
+
These utilities are not imported by the runtime; they are convenience
|
|
4
|
+
functions for use in ad-hoc scripts, notebooks, and the embedded IPython
|
|
5
|
+
console. ``nidaqmx`` is imported lazily inside the NI-DAQ helpers so this
|
|
6
|
+
module loads cleanly on machines without NI-DAQmx installed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import serial.tools.list_ports # pip install pyserial
|
|
10
|
+
import requests
|
|
11
|
+
import pymmcore_plus
|
|
12
|
+
from useq import MDASequence
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_arduino_connection():
|
|
17
|
+
try:
|
|
18
|
+
arduino = serial.Serial('COM4', 9600)
|
|
19
|
+
arduino.close()
|
|
20
|
+
print("Arduino connection successful!")
|
|
21
|
+
except serial.SerialException:
|
|
22
|
+
print("Failed to connect to Arduino on COM4.")
|
|
23
|
+
|
|
24
|
+
#TODO Save config for each session
|
|
25
|
+
#TODO Add a button to save the current configuration to a JSON file
|
|
26
|
+
#TODO Auto-fps calculation based on the number of frames and duration
|
|
27
|
+
def get_fps(mmc: pymmcore_plus.CMMCorePlus):
|
|
28
|
+
## There is a problem when this function is called more than once in succession, freezing the program
|
|
29
|
+
"""
|
|
30
|
+
Calculate the frames per second (FPS) based on the number of frames and duration of an MDA sequence.
|
|
31
|
+
- num_frames = num_trials × trial_time(5 seconds) × framerate (45 fps)
|
|
32
|
+
- num_trials = num_frames / (trial_time * framerate) (255 frames for a 5 seconds trial at 45 fps)
|
|
33
|
+
- Total duration = num_frames / framerate or num_trials * trial_time
|
|
34
|
+
- num_frames = num_trials × trial_time(5 seconds) × framerate (45 fps)
|
|
35
|
+
|
|
36
|
+
"""
|
|
37
|
+
from pymmcore_plus import Metadata
|
|
38
|
+
core = mmc
|
|
39
|
+
frames = 120
|
|
40
|
+
core.run_mda(
|
|
41
|
+
MDASequence(time_plan={"interval": 0, "loops": frames}),
|
|
42
|
+
block=True
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
duration = core.mda._time_elapsed()
|
|
46
|
+
fps = frames / duration
|
|
47
|
+
return fps
|
|
48
|
+
|
|
49
|
+
### Utility functions for querying serial ports and USB IDs ###
|
|
50
|
+
|
|
51
|
+
def load_metadata_from_json(json_file_path) -> pd.DataFrame:
|
|
52
|
+
"""load metadata from a JSON file as a Pandas Dataframe."""
|
|
53
|
+
try:
|
|
54
|
+
metadata_df = pd.read_json(json_file_path)
|
|
55
|
+
return metadata_df
|
|
56
|
+
except Exception as e:
|
|
57
|
+
print(f"Error loading metadata from JSON file: {e}")
|
|
58
|
+
return pd.DataFrame()
|
|
59
|
+
|
|
60
|
+
def list_serial_ports():
|
|
61
|
+
"""
|
|
62
|
+
List all available serial ports on the system
|
|
63
|
+
"""
|
|
64
|
+
# Get a list of all available serial ports
|
|
65
|
+
ports = serial.tools.list_ports.comports()
|
|
66
|
+
|
|
67
|
+
# Check if there are any ports available
|
|
68
|
+
if not ports:
|
|
69
|
+
print("No serial ports found.")
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
# Print details of each available serial port
|
|
73
|
+
for port in ports:
|
|
74
|
+
print(f"Device: {port.device}, Description: {port.description}, HWID: {port.hwid}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def download_usb_ids():
|
|
79
|
+
"""
|
|
80
|
+
Download the USB IDs file from the internet resources
|
|
81
|
+
"""
|
|
82
|
+
url = "http://www.linux-usb.org/usb.ids"
|
|
83
|
+
response = requests.get(url)
|
|
84
|
+
if response.status_code == 200:
|
|
85
|
+
return response.text
|
|
86
|
+
else:
|
|
87
|
+
print("Failed to download USB IDs")
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def parse_usb_ids(usb_ids_content):
|
|
91
|
+
"""
|
|
92
|
+
Parse the USB IDs file and return a dictionary of vendor IDs and product IDs
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
vendor_ids = {}
|
|
96
|
+
current_vendor_id = ""
|
|
97
|
+
for line in usb_ids_content.splitlines():
|
|
98
|
+
if not line or line.startswith("#") or line.startswith("\t\t"):
|
|
99
|
+
continue
|
|
100
|
+
if line.startswith("\t"):
|
|
101
|
+
product_id, product_name = line.strip().split(" ", 1)
|
|
102
|
+
vendor_ids[current_vendor_id]["products"][product_id] = product_name
|
|
103
|
+
else:
|
|
104
|
+
vendor_id, vendor_name = line.split(" ", 1)
|
|
105
|
+
current_vendor_id = vendor_id
|
|
106
|
+
vendor_ids[vendor_id] = {"name": vendor_name, "products": {}}
|
|
107
|
+
return vendor_ids
|
|
108
|
+
|
|
109
|
+
def identify_device(vendor_id, product_id, usb_ids):
|
|
110
|
+
"""
|
|
111
|
+
Identify the vendor and product of a USB device using the USB IDs file
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
vendor = usb_ids.get(vendor_id, {}).get("name", "Unknown Vendor")
|
|
115
|
+
product = usb_ids.get(vendor_id, {}).get("products", {}).get(product_id, "Unknown Product")
|
|
116
|
+
return vendor, product
|
|
117
|
+
|
|
118
|
+
def list_serial_ports_with_vendors(usb_ids):
|
|
119
|
+
"""List serial ports and resolve their vendor / product names.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
usb_ids: Dictionary produced by :func:`parse_usb_ids`.
|
|
123
|
+
"""
|
|
124
|
+
ports = serial.tools.list_ports.comports()
|
|
125
|
+
if not ports:
|
|
126
|
+
print("No serial ports found.")
|
|
127
|
+
return
|
|
128
|
+
for port in ports:
|
|
129
|
+
hwid_parts = port.hwid.split()
|
|
130
|
+
vid_pid = [part for part in hwid_parts if part.startswith("VID:PID=")]
|
|
131
|
+
if vid_pid:
|
|
132
|
+
vid, pid = vid_pid[0].replace("VID:PID=", "").split(":")
|
|
133
|
+
vendor, product = identify_device(vid, pid, usb_ids)
|
|
134
|
+
print(f"Device: {port.device}, Description: {port.description}, Vendor: {vendor}, Product: {product}")
|
|
135
|
+
else:
|
|
136
|
+
print(f"Device: {port.device}, Description: {port.description}, HWID: {port.hwid}")
|
|
137
|
+
|
|
138
|
+
### NI-DAQ utility functions ###
|
|
139
|
+
|
|
140
|
+
def list_nidaq_devices():
|
|
141
|
+
"""List all connected NI-DAQ devices."""
|
|
142
|
+
import nidaqmx # lazy: not all rigs have NI-DAQmx installed
|
|
143
|
+
system = nidaqmx.system.System.local()
|
|
144
|
+
return [device.name for device in system.devices]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def read_analog_input(device_name, channel='ai0'):
|
|
148
|
+
"""Read a single analog input from a specified channel."""
|
|
149
|
+
import nidaqmx
|
|
150
|
+
with nidaqmx.Task() as task:
|
|
151
|
+
task.ai_channels.add_ai_voltage_chan(f"{device_name}/{channel}")
|
|
152
|
+
return task.read()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_nidaq_connection(device_name):
|
|
156
|
+
"""Test connection to a specified NI-DAQ device."""
|
|
157
|
+
import nidaqmx
|
|
158
|
+
try:
|
|
159
|
+
with nidaqmx.Task() as task:
|
|
160
|
+
task.ai_channels.add_ai_voltage_chan(f"{device_name}/ai0")
|
|
161
|
+
return True
|
|
162
|
+
except nidaqmx.DaqError:
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
### MMCore configuration-check utility functions (check settings of devices connected to Micro-Manager via loaded configuration.cfg file) ###
|
|
167
|
+
|
|
168
|
+
def sanity_check(mmc):
|
|
169
|
+
"""
|
|
170
|
+
Perform a sanity check to ensure that the Core is properly
|
|
171
|
+
initialized and that the camera is connected.
|
|
172
|
+
|
|
173
|
+
This function lists:
|
|
174
|
+
- the loaded devices
|
|
175
|
+
- configuration groups
|
|
176
|
+
- current MMCore configuration settings
|
|
177
|
+
- camera settings.
|
|
178
|
+
|
|
179
|
+
"""
|
|
180
|
+
print("Sanity Check:")
|
|
181
|
+
print("=============")
|
|
182
|
+
|
|
183
|
+
# ==== List all devices loaded by Micro-Manager ==== #
|
|
184
|
+
devices = mmc.getLoadedDevices()
|
|
185
|
+
print("Loaded Devices:")
|
|
186
|
+
for device in devices:
|
|
187
|
+
print(f" - {device}: {mmc.getDeviceDescription(device)}")
|
|
188
|
+
|
|
189
|
+
# ==== Display configuration groups ==== #
|
|
190
|
+
config_groups = mmc.getAvailableConfigGroups()
|
|
191
|
+
print("\nConfiguration Groups:")
|
|
192
|
+
for group in config_groups:
|
|
193
|
+
print(f" - {group}")
|
|
194
|
+
configs = mmc.getAvailableConfigs(group)
|
|
195
|
+
for config in configs:
|
|
196
|
+
print(f" - {config}")
|
|
197
|
+
|
|
198
|
+
# ==== Display current configuration ==== #
|
|
199
|
+
print("\nCurrent Configuration:")
|
|
200
|
+
# Get the current configuration settings for each group
|
|
201
|
+
for group in config_groups:
|
|
202
|
+
print(f" - {group}: {mmc.getCurrentConfig(group)}")
|
|
203
|
+
|
|
204
|
+
# ==== Display camera settings ==== #
|
|
205
|
+
camera_device = mmc.getCameraDevice()
|
|
206
|
+
if camera_device:
|
|
207
|
+
print(f"\nCamera Device: {camera_device}")
|
|
208
|
+
print(f" - Exposure: {mmc.getExposure()} ms")
|
|
209
|
+
print(f" - Pixel Size: {mmc.getPixelSizeUm()} um")
|
|
210
|
+
else:
|
|
211
|
+
print("\nNo camera device found.")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
print("\nOther Information:")
|
|
215
|
+
print(f" - Image Width: {mmc.getImageWidth()}")
|
|
216
|
+
print(f" - Image Height: {mmc.getImageHeight()}")
|
|
217
|
+
print(f" - Bit Depth: {mmc.getImageBitDepth()}")
|