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.
Files changed (111) hide show
  1. docs/_static/custom.css +40 -0
  2. docs/_static/favicon.png +0 -0
  3. docs/_static/logo.png +0 -0
  4. docs/api/index.md +70 -0
  5. docs/conf.py +200 -0
  6. docs/developer_guide.md +303 -0
  7. docs/index.md +25 -0
  8. docs/tutorial.md +4 -0
  9. docs/user_guide.md +172 -0
  10. examples/teensy_pulse_generator.py +320 -0
  11. experiments/pipeline_demo/experiment.json +24 -0
  12. experiments/pipeline_demo/hardware.yaml +23 -0
  13. experiments/pipeline_demo/procedure.py +50 -0
  14. experiments/two_cam_demo/experiment.json +24 -0
  15. experiments/two_cam_demo/hardware.yaml +58 -0
  16. experiments/two_cam_demo/load_dataset.py +213 -0
  17. experiments/two_cam_demo/procedure.py +87 -0
  18. external/video-codecs/openh264-1.8.0-win64.dll +0 -0
  19. mesofield/__init__.py +45 -0
  20. mesofield/__main__.py +11 -0
  21. mesofield/_version.py +24 -0
  22. mesofield/base.py +750 -0
  23. mesofield/cli/__init__.py +57 -0
  24. mesofield/cli/_richhelp.py +100 -0
  25. mesofield/cli/acquire.py +254 -0
  26. mesofield/cli/datakit.py +165 -0
  27. mesofield/cli/process.py +376 -0
  28. mesofield/cli/rig.py +108 -0
  29. mesofield/cli/tools.py +347 -0
  30. mesofield/config.py +751 -0
  31. mesofield/data/__init__.py +23 -0
  32. mesofield/data/batch.py +633 -0
  33. mesofield/data/manager.py +388 -0
  34. mesofield/data/writer.py +289 -0
  35. mesofield/datakit/__init__.py +44 -0
  36. mesofield/datakit/__main__.py +35 -0
  37. mesofield/datakit/_utils/_logger.py +5 -0
  38. mesofield/datakit/_version.py +141 -0
  39. mesofield/datakit/config.py +50 -0
  40. mesofield/datakit/core.py +783 -0
  41. mesofield/datakit/datamodel.py +200 -0
  42. mesofield/datakit/discover.py +124 -0
  43. mesofield/datakit/explore.py +651 -0
  44. mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
  45. mesofield/datakit/profile.py +535 -0
  46. mesofield/datakit/shell.py +83 -0
  47. mesofield/datakit/sources/__init__.py +65 -0
  48. mesofield/datakit/sources/analysis/mesomap.py +194 -0
  49. mesofield/datakit/sources/analysis/mesoscope.py +77 -0
  50. mesofield/datakit/sources/analysis/pupil.py +246 -0
  51. mesofield/datakit/sources/behavior/__init__.py +0 -0
  52. mesofield/datakit/sources/behavior/dataqueue.py +281 -0
  53. mesofield/datakit/sources/behavior/psychopy.py +364 -0
  54. mesofield/datakit/sources/behavior/treadmill.py +323 -0
  55. mesofield/datakit/sources/behavior/wheel.py +277 -0
  56. mesofield/datakit/sources/camera/mesoscope.py +32 -0
  57. mesofield/datakit/sources/camera/metadata_json.py +130 -0
  58. mesofield/datakit/sources/camera/pupil.py +28 -0
  59. mesofield/datakit/sources/camera/suite2p.py +547 -0
  60. mesofield/datakit/sources/register.py +204 -0
  61. mesofield/datakit/sources/session/config.py +130 -0
  62. mesofield/datakit/sources/session/notes.py +63 -0
  63. mesofield/datakit/sources/session/timestamps.py +58 -0
  64. mesofield/datakit/timeline.py +306 -0
  65. mesofield/devices/__init__.py +42 -0
  66. mesofield/devices/base.py +498 -0
  67. mesofield/devices/base_camera.py +295 -0
  68. mesofield/devices/cameras.py +740 -0
  69. mesofield/devices/daq.py +151 -0
  70. mesofield/devices/encoder.py +384 -0
  71. mesofield/devices/mocks.py +275 -0
  72. mesofield/devices/psychopy_device.py +455 -0
  73. mesofield/devices/subprocesses/__init__.py +0 -0
  74. mesofield/devices/subprocesses/psychopy.py +133 -0
  75. mesofield/devices/treadmill.py +318 -0
  76. mesofield/engines.py +380 -0
  77. mesofield/gui/Mesofield_icon.png +0 -0
  78. mesofield/gui/__init__.py +76 -0
  79. mesofield/gui/config_wizard.py +724 -0
  80. mesofield/gui/controller.py +535 -0
  81. mesofield/gui/dynamic_controller.py +78 -0
  82. mesofield/gui/maingui.py +427 -0
  83. mesofield/gui/mdagui.py +285 -0
  84. mesofield/gui/qt_device_adapter.py +109 -0
  85. mesofield/gui/speedplotter.py +152 -0
  86. mesofield/gui/theme.py +445 -0
  87. mesofield/gui/tiff_viewer.py +1050 -0
  88. mesofield/gui/viewer.py +691 -0
  89. mesofield/hardware.py +549 -0
  90. mesofield/playback.py +1298 -0
  91. mesofield/processing/__init__.py +12 -0
  92. mesofield/processing/runner.py +237 -0
  93. mesofield/processors/__init__.py +13 -0
  94. mesofield/processors/base.py +287 -0
  95. mesofield/processors/frame_mean.py +19 -0
  96. mesofield/protocols.py +378 -0
  97. mesofield/scaffold/__init__.py +34 -0
  98. mesofield/scaffold/experiment.py +400 -0
  99. mesofield/scaffold/rigs.py +121 -0
  100. mesofield/signals.py +85 -0
  101. mesofield/utils/__init__.py +0 -0
  102. mesofield/utils/_logger.py +156 -0
  103. mesofield/utils/retrofit.py +309 -0
  104. mesofield/utils/utils.py +217 -0
  105. mesofield-0.3.2b0.dist-info/METADATA +178 -0
  106. mesofield-0.3.2b0.dist-info/RECORD +111 -0
  107. mesofield-0.3.2b0.dist-info/WHEEL +5 -0
  108. mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
  109. mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
  110. mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
  111. 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"
@@ -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()}")