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,57 @@
1
+ """Mesofield command-line interface.
2
+
3
+ The CLI is organized into a small set of day-to-day acquisition commands at
4
+ the top level, plus four command groups:
5
+
6
+ \b
7
+ mesofield launch | init | playback | viewer acquisition workflow
8
+ mesofield rig ... manage canonical hardware.yaml rigs
9
+ mesofield datakit ... build / explore / profile / inspect datasets
10
+ mesofield process ... batch-process & convert recorded data
11
+ mesofield tools ... setup, export, and diagnostic utilities
12
+
13
+ Run ``mesofield <cmd> --help`` (or ``mesofield <group> --help``) for details.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+
20
+ import click
21
+
22
+ # Disable debugger warning about the use of frozen modules
23
+ os.environ.setdefault("PYDEVD_DISABLE_FILE_VALIDATION", "1")
24
+
25
+ from ._richhelp import RichGroup
26
+ from .acquire import init, launch, playback, viewer
27
+ from .datakit import datakit
28
+ from .process import process
29
+ from .rig import rig
30
+ from .tools import tools
31
+
32
+
33
+ @click.group(cls=RichGroup)
34
+ def cli():
35
+ """Mesofield Command Line Interface."""
36
+
37
+
38
+ # Set the free-standing acquisition commands (launch/init/playback/viewer)
39
+ # apart from the command groups in the help tree.
40
+ cli.loose_command_heading = "acquisition workflow"
41
+
42
+
43
+ # --- Top-level acquisition commands ---
44
+ cli.add_command(launch)
45
+ cli.add_command(init)
46
+ cli.add_command(playback)
47
+ cli.add_command(viewer)
48
+
49
+ # --- Command groups ---
50
+ cli.add_command(rig)
51
+ cli.add_command(datakit)
52
+ cli.add_command(process)
53
+ cli.add_command(tools)
54
+
55
+
56
+ if __name__ == "__main__":
57
+ cli()
@@ -0,0 +1,100 @@
1
+ """Rich-rendered help for the Mesofield CLI.
2
+
3
+ :class:`RichGroup` is a drop-in :class:`click.Group` that replaces the plain
4
+ "Commands:" listing with a colored ``rich`` tree, so the command groupings
5
+ are visible at a glance. Everything else (usage line, description, options)
6
+ is left to Click's native formatter.
7
+
8
+ Subgroups render their own commands as a flat tree; the root group nests one
9
+ level deep so ``mesofield --help`` shows the whole map. Degrades gracefully
10
+ to Click's default rendering if ``rich`` is unavailable or output is being
11
+ captured oddly.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import io
17
+ import shutil
18
+ import sys
19
+
20
+ import click
21
+
22
+
23
+ def _short(cmd: click.Command) -> str:
24
+ return cmd.get_short_help_str(limit=70)
25
+
26
+
27
+ def _add_leaves(node, items) -> None:
28
+ """Add ``(name, command)`` leaves to ``node``, name-padded for alignment."""
29
+ width = max((len(name) for name, _ in items), default=0)
30
+ for name, cmd in items:
31
+ node.add(f"[green]{name.ljust(width)}[/green] [dim]{_short(cmd)}[/dim]")
32
+
33
+
34
+ class RichGroup(click.Group):
35
+ """Click group that lists its subcommands as a ``rich`` tree.
36
+
37
+ Set :attr:`loose_command_heading` on an instance to bucket non-group
38
+ commands under a labeled node (used by the root group to set the
39
+ free-standing acquisition commands apart from the command groups).
40
+ """
41
+
42
+ loose_command_heading: str | None = None
43
+
44
+ def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
45
+ commands = []
46
+ for name in self.list_commands(ctx):
47
+ cmd = self.get_command(ctx, name)
48
+ if cmd is None or getattr(cmd, "hidden", False):
49
+ continue
50
+ commands.append((name, cmd))
51
+ if not commands:
52
+ return
53
+
54
+ try:
55
+ rendered = self._render_tree(ctx, commands)
56
+ except Exception:
57
+ super().format_commands(ctx, formatter)
58
+ return
59
+
60
+ formatter.write("\n")
61
+ formatter.write(rendered)
62
+ formatter.write("\n")
63
+
64
+ def _render_tree(self, ctx: click.Context, commands) -> str:
65
+ from rich.console import Console
66
+ from rich.tree import Tree
67
+
68
+ groups = [(n, c) for n, c in commands if isinstance(c, click.Group)]
69
+ leaves = [(n, c) for n, c in commands if not isinstance(c, click.Group)]
70
+
71
+ tree = Tree(f"[bold]{ctx.command_path}[/bold]", guide_style="dim")
72
+
73
+ if leaves:
74
+ if groups and self.loose_command_heading:
75
+ bucket = tree.add(f"[bold blue]{self.loose_command_heading}[/bold blue]")
76
+ _add_leaves(bucket, leaves)
77
+ else:
78
+ _add_leaves(tree, leaves)
79
+
80
+ for name, group in groups:
81
+ branch = tree.add(f"[bold cyan]{name}[/bold cyan] [dim]{_short(group)}[/dim]")
82
+ sub = [
83
+ (sn, sc)
84
+ for sn in group.list_commands(ctx)
85
+ if (sc := group.get_command(ctx, sn)) and not getattr(sc, "hidden", False)
86
+ ]
87
+ if sub:
88
+ _add_leaves(branch, sub)
89
+
90
+ is_tty = bool(getattr(sys.stdout, "isatty", lambda: False)())
91
+ width = shutil.get_terminal_size((100, 24)).columns
92
+ buf = io.StringIO()
93
+ console = Console(
94
+ file=buf,
95
+ force_terminal=is_tty,
96
+ no_color=not is_tty,
97
+ width=max(40, width),
98
+ )
99
+ console.print(tree)
100
+ return buf.getvalue().rstrip("\n")
@@ -0,0 +1,254 @@
1
+ """Acquisition-workflow commands: launch, init, playback, viewer.
2
+
3
+ These are the top-level day-to-day entry points and are attached directly to
4
+ the root ``mesofield`` group (no subgroup prefix).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from pathlib import Path
11
+
12
+ import click
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # launch
17
+ # ---------------------------------------------------------------------------
18
+
19
+
20
+ @click.command()
21
+ @click.argument('config', type=click.Path(), required=False, default=None)
22
+ def launch(config):
23
+ """Launch the Mesofield acquisition interface.
24
+
25
+ CONFIG is an optional path to an experiment JSON config file.
26
+ When omitted, Mesofield opens in a default state and the
27
+ Configuration Wizard is shown for hot-loading configs.
28
+ """
29
+ import time
30
+
31
+ from PyQt6.QtWidgets import QApplication, QSplashScreen
32
+ from PyQt6.QtGui import QPixmap, QPainter, QFont
33
+ from PyQt6.QtCore import Qt
34
+ from PyQt6.QtGui import QColor, QRadialGradient
35
+ from PyQt6.QtGui import QIcon
36
+
37
+ from mesofield.gui.maingui import MainWindow
38
+ from mesofield.gui import theme
39
+ from mesofield.base import Procedure, load_procedure_from_config
40
+
41
+ app = QApplication([])
42
+ theme.apply_theme(app)
43
+ window_icon = QIcon(os.path.join(os.path.dirname(os.path.dirname(__file__)), "gui", "Mesofield_icon.png"))
44
+ app.setWindowIcon(window_icon)
45
+
46
+ # ====================== Splash Screen with ASCII Art ========================= """
47
+
48
+ # Font: Sub-Zero; character width: Full, Character Height: Fitted
49
+ # https://patorjk.com/software/taag/#p=display&h=0&v=1&f=Sub-Zero&t=Mesofield
50
+ ascii = r"""
51
+ __ __ ______ ______ ______ ______ __ ____ __ _____
52
+ /\ "-./ \ /\ ___\ /\ ___\ /\ __ \ /\ ___\ /\ \ /\ ___\ /\ \ /\ __-.
53
+ \ \ \-./\ \ \ \ __\ \ \___ \ \ \ \/\ \ \ \ __\ \ \ \ \ \ __\ \ \ \____ \ \ \/\ \
54
+ \ \_\ \ \_\ \ \_____\ \/\_____\ \ \_____\ \ \_\ \ \_\ \ \_____\ \ \_____\ \ \____-
55
+ \/_/ \/_/ \/_____/ \/_____/ \/_____/ \/_/ \/_/ \/_____/ \/_____/ \/____/
56
+
57
+ ------------------------- Mesofield Acquisition Interface ---------------------------------
58
+ """
59
+
60
+ # Create a transparent pixmap
61
+ pixmap = QPixmap(1100, 210)
62
+ pixmap.fill(Qt.GlobalColor.transparent)
63
+
64
+ # Build a radial gradient: dark center that fades out at the edges
65
+ center = pixmap.rect().center()
66
+ radius = max(pixmap.width(), pixmap.height()) / 2
67
+ gradient = QRadialGradient(center.x(), center.y(), radius)
68
+ gradient.setColorAt(0.0, QColor(1, 25, 5)) # solid dark center
69
+ gradient.setColorAt(0.7, QColor(10, 15, 0, 200)) # keep dark until 80%
70
+ gradient.setColorAt(1.0, QColor(0, 0, 0, 0)) # fully transparent edges
71
+
72
+ painter = QPainter(pixmap)
73
+ # Fill entire pixmap with the gradient block
74
+ painter.fillRect(pixmap.rect(), gradient)
75
+
76
+ # Draw the ASCII art on top
77
+ painter.setPen(Qt.GlobalColor.green)
78
+ painter.setFont(QFont("Courier", 12))
79
+ painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, ascii)
80
+ painter.end()
81
+
82
+ splash = QSplashScreen(pixmap)
83
+
84
+ splash.show()
85
+ app.processEvents() # ensure the splash appears
86
+
87
+ time.sleep(0.5) # give the splash screen a moment to show :)
88
+ procedure = load_procedure_from_config(config) if config else Procedure(config)
89
+
90
+ mesofield = MainWindow(procedure)
91
+ mesofield.show()
92
+ splash.finish(mesofield)
93
+ app.exec()
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # init
98
+ # ---------------------------------------------------------------------------
99
+
100
+
101
+ def _resolve_init_hardware(rig, hardware):
102
+ """Resolve the ``hardware`` argument for :func:`scaffold_experiment`.
103
+
104
+ Returns a ``Path`` (copy a canonical rig file) or ``"dev"`` / ``"blank"``.
105
+ ``--hardware`` wins over ``--rig``; with neither, an interactive picker
106
+ over the rig store plus the ``dev`` / ``blank`` built-ins is shown.
107
+ """
108
+ from mesofield.scaffold import rigs
109
+
110
+ if hardware:
111
+ return Path(hardware)
112
+ if rig:
113
+ if rig in ("dev", "blank"):
114
+ return rig
115
+ try:
116
+ return rigs._resolve_existing(rig)
117
+ except FileNotFoundError as exc:
118
+ click.secho(str(exc), fg="red")
119
+ raise SystemExit(1)
120
+
121
+ choices = rigs.list_rigs() + ["dev", "blank"]
122
+ click.echo("Select a hardware configuration for this experiment:")
123
+ for name in rigs.list_rigs():
124
+ click.echo(f" {name} (canonical rig)")
125
+ click.echo(" dev (mock devices -- runs without hardware)")
126
+ click.echo(" blank (fill-out template)")
127
+ picked = click.prompt(
128
+ "Rig", type=click.Choice(choices), default="blank", show_choices=False
129
+ )
130
+ if picked in ("dev", "blank"):
131
+ return picked
132
+ return rigs.rig_path(picked)
133
+
134
+
135
+ @click.command('init')
136
+ @click.argument('directory', type=click.Path())
137
+ @click.option('--name', default=None,
138
+ help='Experiment protocol name (default: directory basename uppercased).')
139
+ @click.option('--force', is_flag=True,
140
+ help='Overwrite an existing non-empty directory.')
141
+ @click.option('--rig', default=None,
142
+ help="Canonical rig to copy hardware.yaml from "
143
+ "(or 'dev'/'blank'). Skips the interactive picker.")
144
+ @click.option('--hardware', default=None, type=click.Path(exists=True, dir_okay=False),
145
+ help='Explicit hardware.yaml file to copy in (overrides --rig).')
146
+ def init(directory, name, force, rig, hardware):
147
+ """Scaffold a new mesofield experiment in DIRECTORY.
148
+
149
+ Generates `experiment.json`, `hardware.yaml`, `procedure.py`, and a
150
+ `devices/` subdirectory with an annotated thermal-sensor example.
151
+
152
+ The `hardware.yaml` is chosen interactively: a canonical rig from this
153
+ machine's rig store (see `mesofield rig`), `dev` (mock devices, runs
154
+ without hardware), or `blank` (a fill-out template). Use --rig/--hardware
155
+ to skip the prompt.
156
+ """
157
+ from mesofield.scaffold import scaffold_experiment
158
+
159
+ hardware_choice = _resolve_init_hardware(rig, hardware)
160
+ try:
161
+ out = scaffold_experiment(
162
+ Path(directory), name=name, force=force, hardware=hardware_choice,
163
+ )
164
+ except FileExistsError as exc:
165
+ click.secho(str(exc), fg="red")
166
+ raise SystemExit(1)
167
+ click.secho(f"Scaffolded experiment at {out}", fg="green")
168
+ click.echo("Next steps:")
169
+ click.echo(f" 1. cd {out}")
170
+ if hardware_choice == "dev":
171
+ click.echo(" 2. python procedure.py # runs the mock acquisition")
172
+ click.echo(f" 3. open data/sub-SUBJ01/ses-01/manifest.json")
173
+ else:
174
+ click.echo(" 2. review hardware.yaml # confirm it matches this rig")
175
+ click.echo(" 3. python procedure.py # runs the acquisition")
176
+ click.echo("Read the generated README.md for customization tips.")
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # playback
181
+ # ---------------------------------------------------------------------------
182
+
183
+
184
+ @click.command()
185
+ @click.argument('experiment_dir')
186
+ @click.option('--speed', default=1.0, show_default=True, help='Playback speed multiplier')
187
+ @click.option('--loop/--no-loop', default=False, show_default=True, help='Loop playback when finished')
188
+ def playback(experiment_dir: str, speed: float, loop: bool):
189
+ """Launch Mesofield in playback mode for a recorded experiment."""
190
+
191
+ from mesofield.playback import (
192
+ discover_playback_context,
193
+ discover_playback_sessions,
194
+ launch_playback_app,
195
+ )
196
+
197
+ sessions = discover_playback_sessions(Path(experiment_dir))
198
+ context = discover_playback_context(Path(experiment_dir), speed=speed, loop=loop)
199
+ launch_playback_app(context, browser_sessions=sessions)
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # viewer
204
+ # ---------------------------------------------------------------------------
205
+
206
+
207
+ @click.command()
208
+ @click.argument('config', type=click.Path(exists=True, dir_okay=False), required=False, default=None)
209
+ def viewer(config):
210
+ """Launch the standalone TIFF ROI viewer.
211
+
212
+ CONFIG is an optional path to an ``experiment.json``. When provided, the
213
+ viewer's "Open TIFF…" dialog opens in that experiment's data directory
214
+ (``<experiment>/data`` if it exists, otherwise the JSON's parent dir).
215
+ Hardware is NOT initialized — this is a read-only inspection tool.
216
+ """
217
+ import json
218
+ from PyQt6.QtWidgets import QApplication
219
+ from PyQt6.QtGui import QIcon
220
+ from mesofield.data.proc.analysis import TiffViewer
221
+
222
+ initial_dir = ""
223
+ if config:
224
+ cfg_path = Path(config).resolve()
225
+ try:
226
+ with open(cfg_path) as f:
227
+ data = json.load(f)
228
+ except Exception:
229
+ data = {}
230
+ # Prefer an explicit save_dir from the config; fall back to
231
+ # <experiment>/data, then the JSON's parent directory.
232
+ save_dir = data.get('save_dir') if isinstance(data, dict) else None
233
+ candidates = []
234
+ if save_dir:
235
+ candidates.append(Path(save_dir))
236
+ candidates.append(Path(save_dir) / 'data')
237
+ candidates.append(cfg_path.parent / 'data')
238
+ candidates.append(cfg_path.parent)
239
+ for c in candidates:
240
+ if c and c.exists():
241
+ initial_dir = str(c)
242
+ break
243
+
244
+ app = QApplication([])
245
+ from mesofield.gui import theme
246
+ theme.apply_theme(app)
247
+ icon_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "gui", "Mesofield_icon.png")
248
+ if os.path.exists(icon_path):
249
+ app.setWindowIcon(QIcon(icon_path))
250
+
251
+ win = TiffViewer(initial_dir=initial_dir or None)
252
+ win.resize(1100, 800)
253
+ win.show()
254
+ app.exec()
@@ -0,0 +1,165 @@
1
+ """``mesofield datakit`` — build, explore, profile, and inspect datasets.
2
+
3
+ A thin Click surface over :mod:`mesofield.datakit`. Each command maps to a
4
+ public datakit API:
5
+
6
+ \b
7
+ build -> Dataset.from_directory(...).save(...)
8
+ explore -> datakit.explore(target)
9
+ profile -> datakit.profile_materialized(pkl)
10
+ inspect -> datakit.inspect_sources(Dataset)
11
+ shell -> datakit.open_shell(target)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ import click
19
+
20
+ from ._richhelp import RichGroup
21
+
22
+
23
+ @click.group('datakit', cls=RichGroup)
24
+ def datakit():
25
+ """Build, explore, profile, and inspect materialized datasets."""
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # build
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ @datakit.command('build')
34
+ @click.argument('input_path', type=click.Path(exists=True, file_okay=False, dir_okay=True))
35
+ @click.option('--output', '-o', 'output_path', type=click.Path(), default=None,
36
+ help='Output file path (default: <experiment>/processed/YYMMDD_dataset_mvp.<fmt>)')
37
+ @click.option('--tags', '-t', multiple=True, default=None,
38
+ help='Source tags to include (repeatable; default: all configured tags)')
39
+ @click.option('--format', '-f', 'fmt', type=click.Choice(['h5', 'parquet', 'csv', 'pickle']),
40
+ default='h5', show_default=True, help='Output format')
41
+ @click.option('--progress', is_flag=True, help='Show a progress bar during materialization')
42
+ @click.option('--shell', 'open_shell_after', is_flag=True,
43
+ help='Drop into an IPython session after building')
44
+ def build(input_path, output_path, tags, fmt, progress, open_shell_after):
45
+ """Build a materialized dataset from an experiment directory.
46
+
47
+ Discovers the BIDS hierarchy under INPUT_PATH, loads all registered
48
+ data sources, and writes a single dataset file.
49
+ """
50
+ from mesofield.datakit import Dataset
51
+
52
+ ds = Dataset.from_directory(
53
+ Path(input_path),
54
+ sources=list(tags) if tags else None,
55
+ )
56
+ if output_path is None:
57
+ from datetime import datetime
58
+ stem = datetime.now().strftime("%y%m%d") + "_dataset_mvp"
59
+ ext = {"h5": ".h5", "parquet": ".parquet", "csv": ".csv", "pickle": ".pkl"}[fmt]
60
+ output_path = Path(input_path) / "processed" / (stem + ext)
61
+ result_path = ds.save(
62
+ Path(output_path),
63
+ format={"h5": "hdf5", "parquet": "parquet", "csv": "csv", "pickle": "pickle"}[fmt],
64
+ strict=True,
65
+ progress=progress,
66
+ )
67
+ click.secho(f"Dataset saved to {result_path}", fg="green")
68
+ if open_shell_after:
69
+ from mesofield.datakit import open_shell
70
+ open_shell(result_path)
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # explore
75
+ # ---------------------------------------------------------------------------
76
+
77
+
78
+ @datakit.command('explore')
79
+ @click.argument('target', type=click.Path(exists=True))
80
+ @click.option('--hdf-key', default='dataset', show_default=True,
81
+ help='HDF5 key to read when TARGET is an .h5/.hdf5 file.')
82
+ def explore(target, hdf_key):
83
+ """Print a structural report for a dataset.
84
+
85
+ TARGET may be an experiment directory (pre-load inventory report), or a
86
+ materialized ``.pkl`` / ``.h5`` file (post-load DataFrame report).
87
+ """
88
+ from mesofield.datakit import explore as explore_fn
89
+
90
+ explore_fn(Path(target), print_output=True, hdf_key=hdf_key)
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # profile
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ @datakit.command('profile')
99
+ @click.argument('pickle_path', type=click.Path(exists=True, dir_okay=False))
100
+ @click.option('--json', 'json_path', type=click.Path(), default=None,
101
+ help='Write a JSON report to this path.')
102
+ @click.option('--verbose', is_flag=True,
103
+ help='Print the detailed per-column breakdown instead of just the summary.')
104
+ @click.option('--top-cells', default=20, show_default=True,
105
+ help='Number of largest individual cells to include.')
106
+ def profile(pickle_path, json_path, verbose, top_cells):
107
+ """Profile the memory / storage footprint of a materialized dataset.
108
+
109
+ PICKLE_PATH is a materialized ``.pkl`` file produced by ``datakit build``.
110
+ """
111
+ from mesofield.datakit import profile_materialized
112
+
113
+ report = profile_materialized(Path(pickle_path), top_n_cells=top_cells)
114
+ click.echo(report.verbose(top_n_cells=top_cells) if verbose else report.summary())
115
+ if json_path:
116
+ out = report.to_json(json_path)
117
+ click.secho(f"\nWrote JSON report to: {out}", fg="green")
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # inspect
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ @datakit.command('inspect')
126
+ @click.argument('input_path', type=click.Path(exists=True, file_okay=False, dir_okay=True))
127
+ @click.option('--tags', '-t', multiple=True, default=None,
128
+ help='Source tags to report (repeatable; default: all discovered tags)')
129
+ def inspect(input_path, tags):
130
+ """Print per-source coverage for an experiment directory.
131
+
132
+ Discovers the BIDS hierarchy under INPUT_PATH and reports how many rows
133
+ carry each registered source (present / total / missing / coverage),
134
+ without loading any file payloads.
135
+ """
136
+ from mesofield.datakit import Dataset, inspect_sources
137
+
138
+ ds = Dataset.from_directory(Path(input_path))
139
+ summary = inspect_sources(ds, sources=list(tags) if tags else None)
140
+ if summary.empty:
141
+ click.secho("No registered sources found in the inventory.", fg="yellow")
142
+ return
143
+ click.echo(summary.to_string())
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # shell
148
+ # ---------------------------------------------------------------------------
149
+
150
+
151
+ @datakit.command('shell')
152
+ @click.argument('target', type=click.Path(exists=True), required=False, default=None)
153
+ @click.option('--hdf-key', default='dataset', show_default=True,
154
+ help='HDF5 key to read when TARGET is an .h5/.hdf5 file.')
155
+ def shell(target, hdf_key):
156
+ """Open an IPython shell pre-loaded with a datakit object.
157
+
158
+ TARGET may be an experiment directory (seeds ``dataset`` + ``inventory``)
159
+ or a materialized ``.pkl`` / ``.h5`` file (seeds ``df``). Omit TARGET for
160
+ a bare datakit shell. Each session also exposes ``datakit``, ``explore``,
161
+ and a pre-computed ``report``.
162
+ """
163
+ from mesofield.datakit import open_shell
164
+
165
+ open_shell(Path(target) if target else None, hdf_key=hdf_key)