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,306 @@
1
+ """Shared, data-agnostic timeline utilities backed by the dataqueue master clock.
2
+
3
+ Expects a dataqueue.csv DataSource loader. The dataqueue file contains this structure:
4
+
5
+ ```python
6
+ ['queue_elapsed', 'packet_ts', 'device_ts', 'device_id', 'payload']
7
+ queue_elapsed packet_ts ... device_id payload
8
+ 0 976929.429661 2025-07-08 16:58:03.627918 ... encoder_COM3 0
9
+ 1 976929.439462 2025-07-08 16:58:03.637494 ... encoder_COM3 0
10
+ 2 976929.470421 2025-07-08 16:58:03.668444 ... encoder_COM3 0
11
+ 3 976929.503343 2025-07-08 16:58:03.701397 ... encoder_COM3 0
12
+ 4 976929.503767 2025-07-08 16:58:03.701888 ... encoder_COM3 0
13
+ ```
14
+
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Callable, ClassVar, Iterable, Optional, Sequence, Union
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+
26
+ from ._utils._logger import get_logger
27
+ from mesofield.datakit.config import settings
28
+
29
+ logger = get_logger(__name__)
30
+
31
+ DeviceSelector = Union[str, Sequence[str], Callable[[pd.Series], pd.Series]]
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class TimelineSlice:
36
+ """Lightweight view over a subset of the dataqueue.
37
+
38
+ It simply holds the filtered rows plus the selector that produced them so
39
+ callers can reapply the same logic or log what slice they were analysing.
40
+ """
41
+
42
+ rows: pd.DataFrame
43
+ selector: Optional[DeviceSelector] = None
44
+
45
+ def queue_elapsed(self) -> pd.Series:
46
+ queue_col = settings.timeline.queue_column
47
+ column = self.rows.get(queue_col)
48
+ if column is None:
49
+ return pd.Series(dtype=np.float64)
50
+
51
+ series = pd.to_numeric(column, errors="coerce")
52
+ return series.dropna().reset_index(drop=True).astype(np.float64)
53
+
54
+ def packet_absolute(self) -> pd.Series:
55
+ """Return packet_ts as UTC ISO8601 strings."""
56
+ column = self.rows.get("packet_ts")
57
+ if column is None:
58
+ return pd.Series(dtype=str)
59
+ parsed = pd.to_datetime(column, errors="coerce", utc=True)
60
+ valid_mask = parsed.notna()
61
+ result = pd.Series(index=self.rows.index, dtype=str)
62
+ result.loc[valid_mask] = parsed.loc[valid_mask].dt.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
63
+ return result.reset_index(drop=True)
64
+
65
+
66
+ class DataqueueIndex:
67
+ """Cacheable facade over a session's dataqueue CSV.
68
+
69
+ The class deliberately avoids hard-coding knowledge of specific hardware.
70
+ Consumers supply selectors or parsing callbacks that describe which device
71
+ streams they are interested in. The index exposes common utilities for
72
+ retrieving filtered views and fitting time mappings onto the shared clock.
73
+ """
74
+
75
+ _cache: ClassVar[dict[Path, Optional["DataqueueIndex"]]] = {}
76
+
77
+ def __init__(self, directory: Path, dataqueue_path: Path, dataqueue: pd.DataFrame):
78
+ self._directory = directory
79
+ self._dataqueue_path = dataqueue_path
80
+ self._frame = dataqueue
81
+ queue_col = settings.timeline.queue_column
82
+ self._queue_all = self._prepare_queue_series(dataqueue.get(queue_col))
83
+
84
+ # ------------------------------------------------------------------
85
+ # Construction helpers
86
+ # ------------------------------------------------------------------
87
+ @classmethod
88
+ def for_directory(cls, directory: Optional[Path]) -> Optional["DataqueueIndex"]:
89
+ if directory is None:
90
+ return None
91
+
92
+ resolved = Path(directory).resolve()
93
+ if resolved in cls._cache:
94
+ return cls._cache[resolved]
95
+
96
+ dq_path = cls._find_dataqueue(resolved)
97
+ if dq_path is None:
98
+ cls._cache[resolved] = None
99
+ return None
100
+
101
+ frame = cls._load_dataqueue(dq_path)
102
+ if frame is None or frame.empty:
103
+ cls._cache[resolved] = None
104
+ return None
105
+
106
+ index = cls(resolved, dq_path, frame)
107
+ cls._cache[resolved] = index
108
+ logger.debug(
109
+ "DataqueueIndex initialised",
110
+ extra={
111
+ "phase": "dataqueue_index_init",
112
+ "directory": str(resolved),
113
+ "dataqueue": str(dq_path),
114
+ "rows": int(len(frame)),
115
+ },
116
+ )
117
+ return index
118
+
119
+ @classmethod
120
+ def from_path(cls, dataqueue_path: Path) -> "DataqueueIndex":
121
+ resolved = Path(dataqueue_path).resolve()
122
+ frame = cls._load_dataqueue(resolved)
123
+ if frame is None or frame.empty:
124
+ raise FileNotFoundError(f"DataqueueIndex: no readable dataqueue at {resolved}")
125
+ return cls(resolved.parent, resolved, frame)
126
+
127
+ @staticmethod
128
+ def _find_dataqueue(directory: Path) -> Optional[Path]:
129
+ candidates = sorted(directory.glob(settings.timeline.dataqueue_glob))
130
+ return candidates[0] if candidates else None
131
+
132
+ @staticmethod
133
+ def _load_dataqueue(path: Path) -> Optional[pd.DataFrame]:
134
+ try:
135
+ frame = pd.read_csv(path, low_memory=False)
136
+ except Exception as exc: # pragma: no cover - defensive
137
+ logger.warning(
138
+ "DataqueueIndex: failed to read dataqueue",
139
+ extra={"phase": "dataqueue_index_load", "path": str(path), "error": repr(exc)},
140
+ )
141
+ return None
142
+
143
+ queue_col = settings.timeline.queue_column
144
+ if queue_col not in frame.columns:
145
+ logger.warning(
146
+ "DataqueueIndex: dataqueue missing queue_elapsed column",
147
+ extra={"phase": "dataqueue_index_load", "path": str(path)},
148
+ )
149
+ return None
150
+
151
+ frame = frame.copy()
152
+ frame[queue_col] = pd.to_numeric(frame[queue_col], errors="coerce")
153
+ frame.dropna(subset=[queue_col], inplace=True)
154
+ frame.reset_index(drop=True, inplace=True)
155
+ return frame
156
+
157
+ @staticmethod
158
+ def _prepare_queue_series(series: Optional[pd.Series]) -> pd.Series:
159
+ if series is None:
160
+ return pd.Series(dtype=np.float64)
161
+ prepared = pd.to_numeric(series, errors="coerce").dropna().reset_index(drop=True)
162
+ return prepared.astype(np.float64)
163
+
164
+ # ------------------------------------------------------------------
165
+ # Public accessors
166
+ # ------------------------------------------------------------------
167
+ @property
168
+ def source_path(self) -> Path:
169
+ return self._dataqueue_path
170
+
171
+ @property
172
+ def directory(self) -> Path:
173
+ return self._directory
174
+
175
+ def dataframe(self) -> pd.DataFrame:
176
+ """Return a shallow copy of the underlying dataqueue frame."""
177
+
178
+ return self._frame.copy()
179
+
180
+ def device_ids(self) -> Sequence[str]:
181
+ if "device_id" not in self._frame.columns:
182
+ return ()
183
+ ids = self._frame["device_id"].dropna().astype(str)
184
+ # Preserve order of first appearance
185
+ return tuple(dict.fromkeys(ids))
186
+
187
+ def queue_series(self, selector: Optional[DeviceSelector] = None) -> pd.Series:
188
+ """Return queue_elapsed samples filtered by an optional device selector."""
189
+
190
+ if selector is None:
191
+ return self._queue_all.copy()
192
+
193
+ mask = self._resolve_selector(selector)
194
+ if mask is None:
195
+ return pd.Series(dtype=np.float64)
196
+ queue_col = settings.timeline.queue_column
197
+ series = pd.to_numeric(self._frame.loc[mask, queue_col], errors="coerce")
198
+ series = series.dropna().reset_index(drop=True)
199
+ return series.astype(np.float64)
200
+
201
+ def slice(self, selector: DeviceSelector) -> TimelineSlice:
202
+ """Return a filtered subset of the dataqueue rows matching the selector."""
203
+
204
+ mask = self._resolve_selector(selector)
205
+ if mask is None:
206
+ rows = self._frame.iloc[0:0].copy()
207
+ else:
208
+ rows = self._frame.loc[mask].copy()
209
+ rows.reset_index(drop=True, inplace=True)
210
+ return TimelineSlice(rows=rows, selector=selector)
211
+
212
+ def absolute_for_device(self, device_pattern: str) -> Optional[tuple[np.ndarray, np.ndarray]]:
213
+ """Return (elapsed_s, absolute_iso) arrays for devices matching pattern, or None if empty."""
214
+ device_slice = self.slice(
215
+ lambda ids: ids.str.contains(device_pattern, case=False, na=False, regex=False)
216
+ )
217
+ elapsed = device_slice.queue_elapsed().to_numpy(dtype=np.float64)
218
+ absolute = device_slice.packet_absolute().to_numpy(dtype=str)
219
+ if elapsed.size == 0 or absolute.size == 0:
220
+ return None
221
+ origin = elapsed[0] if elapsed.size else 0.0
222
+ return elapsed - origin, absolute
223
+
224
+ # ------------------------------------------------------------------
225
+ # General utilities
226
+ # ------------------------------------------------------------------
227
+ @staticmethod
228
+ def fit_affine(x: Iterable[float], y: Iterable[float]) -> tuple[float, float, float]:
229
+ """Fit an affine transform y ≈ a * x + b and return (a, b, r²)."""
230
+
231
+ x_arr = np.asarray(list(x), dtype=np.float64)
232
+ y_arr = np.asarray(list(y), dtype=np.float64)
233
+
234
+ if x_arr.size < 2 or y_arr.size < 2:
235
+ raise ValueError("At least two points are required to fit an affine transform")
236
+
237
+ X = np.vstack([x_arr, np.ones_like(x_arr)]).T
238
+ slope, intercept = np.linalg.lstsq(X, y_arr, rcond=None)[0]
239
+
240
+ predicted = slope * x_arr + intercept
241
+ residual = y_arr - predicted
242
+ ss_res = float(np.sum(residual ** 2))
243
+ ss_tot = float(np.sum((y_arr - np.mean(y_arr)) ** 2)) if len(y_arr) > 1 else 0.0
244
+ r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else float("nan")
245
+
246
+ return float(slope), float(intercept), float(r2)
247
+
248
+ @staticmethod
249
+ def relative(series: Iterable[float]) -> np.ndarray:
250
+ """Produce a series relative to its first finite value."""
251
+
252
+ values = np.asarray(list(series), dtype=np.float64)
253
+ finite = values[np.isfinite(values)]
254
+ if finite.size == 0:
255
+ return values
256
+ origin = float(finite[0])
257
+ return values - origin
258
+
259
+ @staticmethod
260
+ def fix_32bit_wraparound(values: Iterable[int]) -> np.ndarray:
261
+ """Correct unsigned 32-bit wraparound for monotonically increasing counters.
262
+
263
+ A wrap is only registered when the backward step exceeds half the
264
+ counter range (2**31). Small backward steps caused by clock jitter,
265
+ duplicate or slightly out-of-order samples are left alone — otherwise
266
+ every µs of jitter would falsely add 2**32 (~4295 s) to the tail of
267
+ the series.
268
+ """
269
+
270
+ arr = np.asarray(list(values), dtype=np.int64)
271
+ if arr.size == 0:
272
+ return arr
273
+
274
+ wrap_value = 2 ** 32
275
+ wrap_threshold = 2 ** 31
276
+ corrected = arr.copy()
277
+ offset = 0
278
+ for idx in range(1, len(arr)):
279
+ if arr[idx - 1] - arr[idx] > wrap_threshold:
280
+ offset += wrap_value
281
+ corrected[idx] = arr[idx] + offset
282
+ return corrected
283
+
284
+ # ------------------------------------------------------------------
285
+ # Internal helpers
286
+ # ------------------------------------------------------------------
287
+ def _resolve_selector(self, selector: DeviceSelector) -> Optional[pd.Series]:
288
+ if "device_id" not in self._frame.columns:
289
+ return None
290
+
291
+ device_ids = self._frame["device_id"].astype(str)
292
+
293
+ if callable(selector):
294
+ raw_mask = selector(device_ids)
295
+ if isinstance(raw_mask, pd.Series):
296
+ mask = raw_mask.reindex(device_ids.index, fill_value=False)
297
+ else:
298
+ mask = pd.Series(raw_mask, index=device_ids.index)
299
+ elif isinstance(selector, str):
300
+ mask = device_ids == selector
301
+ else:
302
+ candidates = [str(item) for item in selector]
303
+ mask = device_ids.isin(candidates)
304
+
305
+ mask = mask.fillna(False).astype(bool)
306
+ return mask
@@ -0,0 +1,42 @@
1
+ from .base import BaseDevice, BaseDataProducer, BaseSerialDevice
2
+
3
+ # Hardware-specific devices require rig-only dependencies (nidaqmx,
4
+ # pymmcore-plus, pyserial, etc.). Import them lazily so analysis-only
5
+ # environments can still reach the base classes.
6
+ try:
7
+ from .daq import Nidaq
8
+ except ImportError:
9
+ Nidaq = None # type: ignore[assignment,misc]
10
+
11
+ try:
12
+ from .cameras import MMCamera, OpenCVCamera
13
+ except ImportError:
14
+ MMCamera = None # type: ignore[assignment,misc]
15
+ OpenCVCamera = None # type: ignore[assignment,misc]
16
+
17
+ try:
18
+ from .encoder import SerialWorker
19
+ except ImportError:
20
+ SerialWorker = None # type: ignore[assignment,misc]
21
+
22
+ try:
23
+ from .treadmill import EncoderSerialInterface
24
+ except ImportError:
25
+ EncoderSerialInterface = None # type: ignore[assignment,misc]
26
+
27
+ try:
28
+ from .psychopy_device import PsychoPyDevice
29
+ except ImportError:
30
+ PsychoPyDevice = None # type: ignore[assignment,misc]
31
+
32
+ __all__ = [
33
+ "BaseDevice",
34
+ "BaseDataProducer",
35
+ "BaseSerialDevice",
36
+ "Nidaq",
37
+ "MMCamera",
38
+ "OpenCVCamera",
39
+ "SerialWorker",
40
+ "EncoderSerialInterface",
41
+ "PsychoPyDevice",
42
+ ]