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
mesofield/cli/tools.py ADDED
@@ -0,0 +1,347 @@
1
+ """``mesofield tools`` — setup, export, and diagnostic utilities.
2
+
3
+ Lower-frequency helpers: native-driver installation, hardware export,
4
+ framerate benchmarking, the PsychoPy test harness, and a dev config shell.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from pathlib import Path
11
+
12
+ import click
13
+
14
+ from ._richhelp import RichGroup
15
+
16
+
17
+ @click.group('tools', cls=RichGroup)
18
+ def tools():
19
+ """Setup, export, and diagnostic utilities."""
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # install-drivers
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ @tools.command('install-drivers')
28
+ @click.option('--mm-dir', 'mm_dir', default=None,
29
+ help='Explicit path to the Micro-Manager root directory. '
30
+ 'Auto-detected from pymmcore-plus when omitted.')
31
+ @click.option('--keep-zip/--no-keep-zip', default=False, show_default=True,
32
+ help='Keep the downloaded zip file after extraction.')
33
+ def install_drivers(mm_dir, keep_zip):
34
+ """Download Thorlabs Scientific Camera SDK and install native DLLs into Micro-Manager.
35
+
36
+ This command performs the following steps:
37
+
38
+ \b
39
+ 1. Locate (or install) the Micro-Manager device adapters via pymmcore-plus.
40
+ 2. Download the Thorlabs Scientific Camera Interfaces SDK.
41
+ 3. Extract the SDK into mesofield/external/drivers/.
42
+ 4. Copy the 64-bit native DLLs into the Micro-Manager root directory.
43
+ """
44
+ import shutil
45
+ import zipfile
46
+
47
+ THORLABS_SDK_URL = (
48
+ "https://media.thorlabs.com/contentassets/"
49
+ "039fcbaaafa0457eb2901466cf0b9489/"
50
+ "scientific_camera_interfaces_windows-2.1.zip"
51
+ "?v=1116040458"
52
+ )
53
+ # Relative path inside the extracted zip that contains the 64-bit DLLs
54
+ DLL_SUBPATH = Path(
55
+ "Scientific Camera Interfaces",
56
+ "SDK",
57
+ "Native Toolkit",
58
+ "dlls",
59
+ "Native_64_lib",
60
+ )
61
+
62
+ EXTERNAL_DRIVERS_DIR = (
63
+ Path(__file__).resolve().parent.parent / "external" / "drivers"
64
+ )
65
+
66
+ # ---- Step 1: Resolve the Micro-Manager root directory ----
67
+ if mm_dir is not None:
68
+ mm_root = Path(mm_dir)
69
+ else:
70
+ mm_root = _resolve_micromanager_root()
71
+
72
+ if not mm_root.is_dir():
73
+ click.secho(f"ERROR: Micro-Manager directory does not exist: {mm_root}", fg="red")
74
+ raise SystemExit(1)
75
+
76
+ click.echo(f"Micro-Manager root: {mm_root}")
77
+
78
+ # ---- Step 2: Download the Thorlabs SDK zip ----
79
+ EXTERNAL_DRIVERS_DIR.mkdir(parents=True, exist_ok=True)
80
+ zip_dest = EXTERNAL_DRIVERS_DIR / "scientific_camera_interfaces_windows-2.1.zip"
81
+
82
+ if zip_dest.exists():
83
+ click.echo(f"Zip already present at {zip_dest}, skipping download.")
84
+ else:
85
+ click.echo("Downloading Thorlabs Scientific Camera Interfaces SDK …")
86
+ try:
87
+ _download_with_progress(THORLABS_SDK_URL, zip_dest)
88
+ except Exception as exc:
89
+ click.secho(f"Download failed: {exc}", fg="red")
90
+ raise SystemExit(1)
91
+
92
+ # ---- Step 3: Extract into external/drivers/ ----
93
+ extract_dir = EXTERNAL_DRIVERS_DIR / "scientific_camera_interfaces"
94
+ if extract_dir.exists():
95
+ click.echo(f"Extraction folder already exists at {extract_dir}, skipping extraction.")
96
+ else:
97
+ click.echo(f"Extracting SDK to {extract_dir} …")
98
+ try:
99
+ with zipfile.ZipFile(zip_dest, "r") as zf:
100
+ zf.extractall(extract_dir)
101
+ except zipfile.BadZipFile as exc:
102
+ click.secho(f"Bad zip file: {exc}", fg="red")
103
+ raise SystemExit(1)
104
+
105
+ # ---- Step 4: Copy 64-bit native DLLs into Micro-Manager root ----
106
+ dll_source = extract_dir / DLL_SUBPATH
107
+ if not dll_source.is_dir():
108
+ # The zip might have a single top-level folder; search for a match
109
+ candidates = list(extract_dir.rglob("Native_64_lib"))
110
+ if candidates:
111
+ dll_source = candidates[0]
112
+ else:
113
+ click.secho(
114
+ f"ERROR: Could not locate Native_64_lib inside the extracted archive.\n"
115
+ f"Expected at: {dll_source}",
116
+ fg="red",
117
+ )
118
+ raise SystemExit(1)
119
+
120
+ dll_files = list(dll_source.glob("*.dll"))
121
+ if not dll_files:
122
+ click.secho(f"WARNING: No .dll files found in {dll_source}", fg="yellow")
123
+ raise SystemExit(1)
124
+
125
+ click.echo(f"Copying {len(dll_files)} DLL(s) from {dll_source} → {mm_root}")
126
+ for dll in dll_files:
127
+ dest = mm_root / dll.name
128
+ shutil.copy2(dll, dest)
129
+ click.echo(f" ✓ {dll.name}")
130
+
131
+ # ---- Cleanup ----
132
+ if not keep_zip and zip_dest.exists():
133
+ zip_dest.unlink()
134
+ click.echo("Removed downloaded zip file.")
135
+
136
+ click.secho("\nThorlabs Scientific Camera DLLs installed successfully.", fg="green")
137
+
138
+
139
+ def _resolve_micromanager_root() -> Path:
140
+ """Locate the Micro-Manager installation via pymmcore-plus.
141
+
142
+ Falls back to running ``mmcore install`` when no installation is found.
143
+ """
144
+ import subprocess
145
+ import sys
146
+
147
+ try:
148
+ from pymmcore_plus import find_micromanager
149
+ mm_path = find_micromanager()
150
+ if mm_path:
151
+ return Path(mm_path)
152
+ except ImportError:
153
+ click.secho(
154
+ "pymmcore-plus is not installed. Install it first:\n"
155
+ " pip install pymmcore-plus",
156
+ fg="red",
157
+ )
158
+ raise SystemExit(1)
159
+ except Exception:
160
+ pass # fall through to mmcore install
161
+
162
+ # No existing installation – offer to install device adapters
163
+ click.echo("No Micro-Manager installation detected.")
164
+ if click.confirm("Run 'mmcore install' to install Micro-Manager device adapters?", default=True):
165
+ subprocess.check_call([sys.executable, "-m", "pymmcore_plus", "install"])
166
+ # Re-resolve after install
167
+ try:
168
+ from pymmcore_plus import find_micromanager
169
+ mm_path = find_micromanager()
170
+ if mm_path:
171
+ return Path(mm_path)
172
+ except Exception:
173
+ pass
174
+
175
+ click.secho("ERROR: Could not locate a Micro-Manager installation.", fg="red")
176
+ raise SystemExit(1)
177
+
178
+
179
+ def _download_with_progress(url: str, dest: Path, chunk_size: int = 1024 * 64):
180
+ """Download *url* to *dest* with a simple progress indicator."""
181
+ import urllib.request
182
+
183
+ req = urllib.request.Request(url, headers={"User-Agent": "mesofield-installer/1.0"})
184
+ with urllib.request.urlopen(req) as resp:
185
+ total = int(resp.headers.get("Content-Length", 0))
186
+ downloaded = 0
187
+ with open(dest, "wb") as fh:
188
+ while True:
189
+ chunk = resp.read(chunk_size)
190
+ if not chunk:
191
+ break
192
+ fh.write(chunk)
193
+ downloaded += len(chunk)
194
+ if total:
195
+ pct = downloaded * 100 // total
196
+ click.echo(f"\r {pct:3d}% ({downloaded // 1024:,} KB)", nl=False)
197
+ if total:
198
+ click.echo() # newline after progress
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # export-hardware
203
+ # ---------------------------------------------------------------------------
204
+
205
+
206
+ @tools.command('export-hardware')
207
+ @click.argument('procedure_path', type=click.Path(exists=True, dir_okay=False))
208
+ @click.option('--output', '-o', default=None, type=click.Path(),
209
+ help='Output path for the hardware.yaml '
210
+ '(default: hardware.yaml beside the procedure file).')
211
+ def export_hardware(procedure_path, output):
212
+ """Export a scripted procedure's hardware to a reusable hardware.yaml rig file.
213
+
214
+ PROCEDURE_PATH is a procedure.py whose `define_hardware` builds devices in
215
+ Python. The devices are instantiated and serialized into a `type:`-tagged
216
+ hardware.yaml that can later be loaded the normal file-based way.
217
+ """
218
+ from mesofield.base import load_procedure_from_config
219
+
220
+ out = output or os.path.join(
221
+ os.path.dirname(os.path.abspath(procedure_path)), "hardware.yaml"
222
+ )
223
+ procedure = load_procedure_from_config(procedure_path)
224
+ procedure.hardware.to_yaml(out)
225
+ click.secho(f"Exported hardware configuration to {out}", fg="green")
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # fps
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ @tools.command('fps')
234
+ @click.option('--params', default='hardware.yaml', help='Path to the config file')
235
+ def fps(params):
236
+ """Measure camera framerate and estimate dataset file sizes."""
237
+ import json
238
+ from tqdm import tqdm
239
+ import numpy as np
240
+ import datetime
241
+ from useq import MDAEvent, MDASequence
242
+ from pymmcore_plus import CMMCorePlus
243
+ from pymmcore_plus.metadata import FrameMetaV1
244
+ from mesofield.config import ExperimentConfig
245
+
246
+ frame_metadata: FrameMetaV1 = None
247
+
248
+ config = ExperimentConfig(params)
249
+ config.hardware.initialize(config)
250
+
251
+ # measure over a fixed number of frames to get fps
252
+ num_frames = 300
253
+ mmc: CMMCorePlus = config.hardware.ThorCam.core
254
+ sequence = MDASequence(time_plan={"interval": 0, "loops": num_frames})
255
+
256
+ # ask user for desired duration (in seconds)
257
+ duration = float(input("Enter duration in seconds for file‐size estimate: "))
258
+ num_animals = int(input("Enter number of animals: "))
259
+ num_sessions = int(input("Enter number of sessions: "))
260
+
261
+ times = []
262
+ pbar = tqdm(total=num_frames, desc="Acquiring frames")
263
+ img_size = 0
264
+
265
+ @mmc.mda.events.frameReady.connect
266
+ def new_frame(img: np.ndarray, event: MDAEvent, metadata: dict):
267
+
268
+ nonlocal img_size
269
+ nonlocal frame_metadata
270
+ # frame timestamps
271
+ frame_time_str = metadata['camera_metadata']['TimeReceivedByCore']
272
+ times.append(datetime.datetime.fromisoformat(frame_time_str))
273
+
274
+ # single instance of frame metadata for printing:
275
+ if frame_metadata is None:
276
+ frame_metadata = metadata
277
+
278
+ # record single image size once
279
+ if img_size == 0:
280
+ img_size = img.nbytes
281
+ pbar.update(1)
282
+
283
+ # run acquisition
284
+ mmc.run_mda(sequence, block=True)
285
+ pbar.close()
286
+
287
+ # compute fps
288
+ deltas = [(t2 - t1).total_seconds() for t1, t2 in zip(times[:-1], times[1:])]
289
+ fps_value = 1 / np.mean(deltas)
290
+
291
+ # estimate file size for the user‐specified duration
292
+ estimated_frames = int(fps_value * duration)
293
+ estimated_bytes = img_size * estimated_frames
294
+ estimated_gb = estimated_bytes / (1024**3)
295
+ total_gbs = estimated_gb * num_animals * num_sessions
296
+ summary = {
297
+ "Camera Device": mmc.getCameraDevice(),
298
+ "Exposure (ms)": mmc.getExposure(),
299
+ "Camera Metadata": frame_metadata["camera_metadata"],
300
+ "Measured FPS": round(fps_value, 2),
301
+ "Duration (s)": duration,
302
+ "Frames": estimated_frames,
303
+ "Individual TIFF Stack Size (MB)": round(estimated_gb * 1024, 2),
304
+ "Animals": num_animals,
305
+ "Sessions": num_sessions,
306
+ "Total Estimated Size (GB)": round(total_gbs, 2)
307
+ }
308
+
309
+ print(json.dumps(summary, indent=4))
310
+
311
+
312
+ # ---------------------------------------------------------------------------
313
+ # psychopy
314
+ # ---------------------------------------------------------------------------
315
+
316
+
317
+ @tools.command('psychopy')
318
+ def psychopy():
319
+ """Launch the PsychoPy test harness GUI (development tool)."""
320
+ import sys
321
+ from PyQt6.QtWidgets import QApplication
322
+ import tests.test_psychopy as test_psychopy
323
+ from mesofield.gui import theme
324
+
325
+ app = QApplication(sys.argv)
326
+ theme.apply_theme(app)
327
+ gui = test_psychopy.DillPsychopyGui()
328
+ gui.show()
329
+ sys.exit(app.exec())
330
+
331
+
332
+ # ---------------------------------------------------------------------------
333
+ # config-shell
334
+ # ---------------------------------------------------------------------------
335
+
336
+
337
+ @tools.command('config-shell')
338
+ @click.option('--yaml_path', default='tests/dev.yaml', help='Path to the YAML config file')
339
+ @click.option('--json_path', default='tests/devsub.json', help='Path to the JSON config file')
340
+ def config_shell(yaml_path, json_path):
341
+ """Load an IPython terminal with an ExperimentConfig in a dev configuration."""
342
+ from mesofield.config import ExperimentConfig
343
+ from IPython import embed
344
+
345
+ config = ExperimentConfig(yaml_path)
346
+ config.load_json(json_path)
347
+ embed(header='Mesofield ExperimentConfig Terminal. Type `config.` + TAB ', local={'config': config})