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,783 @@
1
+ """Core orchestration for datakit.
2
+
3
+ Provides :class:`Dataset` — the user-facing entry point that wraps a
4
+ discovered file inventory and materializes it via the source registry.
5
+
6
+ Failure policy:
7
+ - ``materialize(strict=True)`` (default) raises on the first error with
8
+ ``(subject, session, task, source, path)`` context.
9
+ - ``materialize(strict=False)`` continues past errors and (with
10
+ ``return_errors=True``) returns a long-format error DataFrame.
11
+ - :meth:`validate` runs every cell, never raises, and returns the same
12
+ long-format DataFrame.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import traceback as _tb
18
+ from collections.abc import Sequence as SequenceABC
19
+ from pathlib import Path
20
+ from typing import Any, Iterable, Mapping, Optional, Sequence, Tuple, Union
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+ from ._utils._logger import get_logger
26
+ from ._version import build_meta as _build_meta
27
+ from .config import settings
28
+ from .datamodel import LoadedStream, Manifest, ManifestEntry
29
+ from .discover import discover_manifest
30
+ from .sources import SOURCE_REGISTRY
31
+ from .sources.register import LoadContext
32
+
33
+ logger = get_logger(__name__)
34
+
35
+ PathLike = Union[str, Path]
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Inventory construction
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ def _entries_to_inventory(
44
+ entries: Iterable[ManifestEntry],
45
+ *,
46
+ root: Path,
47
+ prefer_processed: bool = True,
48
+ include_task_level: bool | None = None,
49
+ ) -> pd.DataFrame:
50
+ """Flatten manifest entries into a wide DataFrame indexed by subject/session[/task]."""
51
+ entries = list(entries)
52
+ subject_name, session_name, task_name = settings.dataset.index_names
53
+
54
+ if include_task_level is None:
55
+ include_task = any(e.task is not None for e in entries)
56
+ else:
57
+ include_task = bool(include_task_level)
58
+
59
+ index_names = [subject_name, session_name] + ([task_name] if include_task else [])
60
+
61
+ if not entries:
62
+ return pd.DataFrame(index=pd.MultiIndex.from_tuples([], names=index_names))
63
+
64
+ records: dict[tuple, dict[str, str]] = {}
65
+ origins: dict[tuple, dict[str, str]] = {}
66
+ for entry in entries:
67
+ key = (entry.subject, entry.session, entry.task) if include_task else (entry.subject, entry.session)
68
+ records.setdefault(key, {})
69
+ origins.setdefault(key, {})
70
+ resolved = str(root / entry.path)
71
+ current_origin = origins[key].get(entry.tag)
72
+ if prefer_processed and current_origin == "processed" and entry.origin != "processed":
73
+ continue
74
+ records[key][entry.tag] = resolved
75
+ origins[key][entry.tag] = entry.origin
76
+
77
+ keys = sorted(records.keys(), key=lambda k: tuple("" if v is None else str(v) for v in k))
78
+ index = pd.MultiIndex.from_tuples(keys, names=index_names)
79
+ return pd.DataFrame([records[k] for k in keys], index=index)
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Topological sort + experiment-window resolution
84
+ # ---------------------------------------------------------------------------
85
+
86
+
87
+ def _toposort(active: Sequence[str]) -> list[str]:
88
+ """Order ``active`` so each tag follows its declared dependencies."""
89
+ active_set = set(active)
90
+ state: dict[str, int] = {} # 0=visiting, 1=done
91
+ order: list[str] = []
92
+
93
+ def visit(tag: str, stack: tuple[str, ...]) -> None:
94
+ if state.get(tag) == 1:
95
+ return
96
+ if state.get(tag) == 0:
97
+ raise RuntimeError(f"Cycle in source 'requires' graph: {' -> '.join(stack + (tag,))}")
98
+ state[tag] = 0
99
+ cls_ = SOURCE_REGISTRY.get(tag)
100
+ for dep in (getattr(cls_, "requires", ()) or ()):
101
+ if dep in active_set:
102
+ visit(dep, stack + (tag,))
103
+ state[tag] = 1
104
+ order.append(tag)
105
+
106
+ for tag in active:
107
+ visit(tag, ())
108
+ return order
109
+
110
+
111
+ def _experiment_window(frame: Optional[pd.DataFrame]) -> Optional[tuple[float, float]]:
112
+ if frame is None or "device_id" not in frame.columns:
113
+ return None
114
+ time_col = settings.timeline.queue_column
115
+ if time_col not in frame.columns:
116
+ return None
117
+ devices = frame["device_id"].astype(str)
118
+ mask = pd.Series(False, index=frame.index)
119
+ for pat in settings.timeline.window_device_patterns:
120
+ mask |= devices.str.contains(pat, case=False, na=False, regex=False)
121
+ if not mask.any():
122
+ return None
123
+ times = pd.to_numeric(frame.loc[mask, time_col], errors="coerce").dropna()
124
+ if times.empty:
125
+ return None
126
+ return float(times.iloc[0]), float(times.iloc[-1])
127
+
128
+
129
+ def _make_context(
130
+ idx: tuple,
131
+ inventory_row: Mapping[str, Any],
132
+ deps: Mapping[str, LoadedStream | None],
133
+ ) -> LoadContext:
134
+ dq = deps.get("dataqueue")
135
+ dq_frame = dq.value if dq is not None and isinstance(dq.value, pd.DataFrame) else None
136
+ return LoadContext(
137
+ subject=str(idx[0]),
138
+ session=str(idx[1]) if len(idx) > 1 else "unknown",
139
+ task=str(idx[2]) if len(idx) > 2 else None,
140
+ inventory_row=dict(inventory_row),
141
+ dependencies=dict(deps),
142
+ master_timeline=np.asarray(dq.t, dtype=np.float64) if dq is not None else None,
143
+ experiment_window=_experiment_window(dq_frame),
144
+ dataqueue_frame=dq_frame,
145
+ dataqueue_meta=dq.meta if dq is not None else None,
146
+ )
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Payload flattening
151
+ # ---------------------------------------------------------------------------
152
+
153
+
154
+ def _series_to_cell(s: pd.Series) -> Any:
155
+ if s.empty:
156
+ return np.nan
157
+ if len(s) == 1:
158
+ return s.iloc[0]
159
+ try:
160
+ if int(s.nunique(dropna=False)) <= 1:
161
+ return s.iloc[0]
162
+ except TypeError:
163
+ pass
164
+ return s.to_numpy()
165
+
166
+
167
+ def _flatten(stream: LoadedStream) -> dict[tuple[str, str], Any]:
168
+ """Flatten a stream payload + per-cell meta into ``{(tag, feature): value}``."""
169
+ tag = stream.tag
170
+ payload = stream.value
171
+ cells: dict[tuple[str, str], Any]
172
+ if isinstance(payload, pd.DataFrame):
173
+ cells = {(tag, str(c)): _series_to_cell(payload[c]) for c in payload.columns}
174
+ elif isinstance(payload, pd.Series):
175
+ cells = {(tag, "values"): _series_to_cell(payload)}
176
+ elif isinstance(payload, (np.ndarray, list, tuple)):
177
+ cells = {(tag, "values"): np.asarray(payload)}
178
+ elif isinstance(payload, dict):
179
+ cells = {(tag, str(k)): v for k, v in payload.items()}
180
+ else:
181
+ cells = {(tag, "value"): payload}
182
+
183
+ meta = stream.meta or {}
184
+ scope = meta.get(settings.dataset.scope_key)
185
+ if scope in (settings.dataset.session_scope, settings.dataset.experiment_scope):
186
+ return cells
187
+ skip = {
188
+ settings.dataset.scope_key,
189
+ settings.sources.meta_camera_key,
190
+ settings.sources.meta_timeseries_key,
191
+ settings.sources.meta_source_key,
192
+ settings.sources.meta_interval_key,
193
+ }
194
+ meta_dict = {k: v for k, v in meta.items() if k not in skip}
195
+ if meta_dict:
196
+ cells[(tag, "meta")] = meta_dict
197
+ return cells
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Dataset
202
+ # ---------------------------------------------------------------------------
203
+
204
+
205
+ _RECORD_COLS = ["status", "path", "error_type", "message", "traceback"]
206
+
207
+
208
+ def _norm(value: Optional[Union[str, Sequence[str]]]) -> Optional[set[str]]:
209
+ if value is None:
210
+ return None
211
+ if isinstance(value, str):
212
+ return {value}
213
+ if isinstance(value, SequenceABC):
214
+ return {str(v) for v in value}
215
+ raise TypeError(f"Expected str or sequence, got {type(value).__name__}")
216
+
217
+
218
+ class Dataset:
219
+ """Discovery + materialization for a BIDS-style experiment hierarchy."""
220
+
221
+ def __init__(
222
+ self,
223
+ inventory: pd.DataFrame,
224
+ *,
225
+ sources: Optional[Iterable[str]] = None,
226
+ roots: Sequence[PathLike] = (),
227
+ ) -> None:
228
+ if not isinstance(inventory, pd.DataFrame):
229
+ raise TypeError("inventory must be a pandas DataFrame")
230
+ if not isinstance(inventory.index, pd.MultiIndex):
231
+ raise ValueError("inventory must have a MultiIndex (Subject, Session[, Task])")
232
+ n = inventory.index.nlevels
233
+ if n < 2 or n > 3:
234
+ raise ValueError(f"inventory must have 2 or 3 index levels; got {n}")
235
+
236
+ inv = inventory.copy()
237
+ inv.index.set_names(list(settings.dataset.index_names[:n]), inplace=True)
238
+ self._inventory = inv
239
+
240
+ if sources is None:
241
+ tags = tuple(t for t in inv.columns if t in SOURCE_REGISTRY)
242
+ else:
243
+ tags = tuple(sources)
244
+ unknown = [t for t in tags if t not in SOURCE_REGISTRY]
245
+ if unknown:
246
+ raise KeyError(f"Unknown source tag(s): {sorted(unknown)}")
247
+ self._sources = tags
248
+ self._roots = tuple(Path(r) for r in roots)
249
+ self._notes: list[str] = []
250
+
251
+ # ---- Construction -------------------------------------------------
252
+
253
+ @classmethod
254
+ def from_directory(
255
+ cls,
256
+ root: Union[PathLike, Sequence[PathLike]],
257
+ *,
258
+ sources: Optional[Iterable[str]] = None,
259
+ prefer_processed: bool = True,
260
+ include_task_level: bool | None = True,
261
+ ) -> "Dataset":
262
+ """Discover one or more experiment roots and build a Dataset.
263
+
264
+ Pass a single path or a sequence of paths; multiple roots are
265
+ concatenated row-wise.
266
+ """
267
+ roots = [root] if isinstance(root, (str, Path)) else list(root)
268
+ if not roots:
269
+ raise ValueError("at least one root is required")
270
+ frames: list[pd.DataFrame] = []
271
+ resolved: list[Path] = []
272
+ for r in roots:
273
+ manifest = discover_manifest(Path(r).expanduser())
274
+ frames.append(_entries_to_inventory(
275
+ manifest.entries,
276
+ root=manifest.root,
277
+ prefer_processed=prefer_processed,
278
+ include_task_level=include_task_level,
279
+ ))
280
+ resolved.append(manifest.root)
281
+ combined = pd.concat(frames, sort=False).sort_index() if len(frames) > 1 else frames[0]
282
+ return cls(combined, sources=sources, roots=resolved)
283
+
284
+ # ---- Properties ---------------------------------------------------
285
+
286
+ @property
287
+ def inventory(self) -> pd.DataFrame:
288
+ return self._inventory.copy()
289
+
290
+ @property
291
+ def sources(self) -> Tuple[str, ...]:
292
+ return self._sources
293
+
294
+ @property
295
+ def roots(self) -> Tuple[Path, ...]:
296
+ return self._roots
297
+
298
+ @property
299
+ def subjects(self) -> list[str]:
300
+ return sorted(self._inventory.index.get_level_values(0).unique().tolist())
301
+
302
+ @property
303
+ def sessions(self) -> list[str]:
304
+ return sorted(self._inventory.index.get_level_values(1).unique().tolist())
305
+
306
+ @property
307
+ def has_task_level(self) -> bool:
308
+ return self._inventory.index.nlevels >= 3
309
+
310
+ @property
311
+ def notes(self) -> list[str]:
312
+ """Free-form notes attached to this dataset."""
313
+ return list(self._notes)
314
+
315
+ def add_note(self, note: str) -> "Dataset":
316
+ """Append a free-form note; persisted via ``df.attrs['datakit_notes']``."""
317
+ if not isinstance(note, str):
318
+ raise TypeError("note must be a string")
319
+ self._notes.append(note)
320
+ return self
321
+
322
+ @property
323
+ def meta(self) -> dict:
324
+ """Provenance metadata for the running datakit package.
325
+
326
+ The same dictionary is attached to the materialized DataFrame as
327
+ ``df.attrs["datakit"]`` so it persists through pickle round-trips.
328
+ """
329
+ return _build_meta()
330
+
331
+ def __repr__(self) -> str:
332
+ return (
333
+ f"Dataset(rows={len(self._inventory)}, sources={len(self._sources)}, "
334
+ f"task_level={self.has_task_level})"
335
+ )
336
+
337
+ # ---- Filtering ----------------------------------------------------
338
+
339
+ def _row_mask(
340
+ self,
341
+ subject: Optional[set[str]],
342
+ session: Optional[set[str]],
343
+ task: Optional[set[str]],
344
+ ) -> pd.Series:
345
+ inv = self._inventory
346
+ mask = pd.Series(True, index=inv.index)
347
+ for level, want in enumerate((subject, session, task)):
348
+ if want is None or level >= inv.index.nlevels:
349
+ continue
350
+ vals = inv.index.get_level_values(level).astype(str).isin({str(v) for v in want})
351
+ mask = mask & pd.Series(vals, index=inv.index)
352
+ return mask
353
+
354
+ def include(
355
+ self,
356
+ *,
357
+ subject: Optional[Union[str, Sequence[str]]] = None,
358
+ session: Optional[Union[str, Sequence[str]]] = None,
359
+ task: Optional[Union[str, Sequence[str]]] = None,
360
+ source: Optional[Union[str, Sequence[str]]] = None,
361
+ ) -> "Dataset":
362
+ """Keep only rows/sources matching the given filters (AND-combined).
363
+
364
+ Each keyword accepts either a single string or a sequence of strings;
365
+ ``None`` (the default) means "no constraint on this axis". All
366
+ provided filters are combined with logical AND, so adding a keyword
367
+ narrows the result. Returns a new ``Dataset`` — the original is
368
+ unchanged, so calls chain naturally.
369
+
370
+ Examples
371
+ --------
372
+ >>> ds.include(subject="STREHAB07") # one subject
373
+ >>> ds.include(subject=["STREHAB07", "STREHAB08"]) # multiple subjects
374
+ >>> ds.include(session="ses-05", task="task-widefield")
375
+ >>> ds.include(source=["dataqueue", "treadmill"]) # drop other sources
376
+ >>> ds.include(subject="STREHAB07").include(task="task-movies") # chain
377
+ """
378
+ mask = self._row_mask(_norm(subject), _norm(session), _norm(task))
379
+ srcs = _norm(source)
380
+ new_sources = tuple(t for t in self._sources if t in srcs) if srcs is not None else self._sources
381
+ return Dataset(self._inventory.loc[mask].copy(), sources=new_sources, roots=self._roots)
382
+
383
+ def exclude(
384
+ self,
385
+ *,
386
+ subject: Optional[Union[str, Sequence[str]]] = None,
387
+ session: Optional[Union[str, Sequence[str]]] = None,
388
+ task: Optional[Union[str, Sequence[str]]] = None,
389
+ source: Optional[Union[str, Sequence[str]]] = None,
390
+ ) -> "Dataset":
391
+ """Drop rows/sources matching the given filters.
392
+
393
+ Like :meth:`include`, every keyword accepts a string or a sequence
394
+ of strings, and combining keywords narrows what gets removed.
395
+ Behavior depends on which axes are provided:
396
+
397
+ - **source only** — drop those source columns globally.
398
+ - **row axes only** (subject/session/task) — drop matching rows.
399
+ - **both** — NaN out only the listed source columns within matching
400
+ rows; rows and other sources are preserved.
401
+
402
+ Examples
403
+ --------
404
+ >>> ds.exclude(subject="STREHAB07") # drop a subject
405
+ >>> ds.exclude(source="psychopy") # drop a source globally
406
+ >>> ds.exclude(session=["ses-01", "ses-02"]) # drop multiple sessions
407
+ >>> ds.exclude(subject="STREHAB07", source="pupil_dlc")
408
+ ... # blank pupil_dlc only for STREHAB07; other rows/sources untouched
409
+ """
410
+ subj, ses, tsk, srcs = _norm(subject), _norm(session), _norm(task), _norm(source)
411
+ has_row = any(s is not None for s in (subj, ses, tsk))
412
+ mask = self._row_mask(subj, ses, tsk)
413
+ new_inv = self._inventory.copy()
414
+ new_sources = self._sources
415
+ if srcs is None:
416
+ new_inv = new_inv.loc[~mask].copy()
417
+ elif not has_row:
418
+ new_sources = tuple(t for t in self._sources if t not in srcs)
419
+ else:
420
+ cols = [c for c in new_inv.columns if c in srcs]
421
+ if cols:
422
+ new_inv.loc[mask, cols] = np.nan
423
+ return Dataset(new_inv, sources=new_sources, roots=self._roots)
424
+
425
+ def head(self, n: int = 3) -> "Dataset":
426
+ """Return a new ``Dataset`` containing only the first ``n`` rows.
427
+
428
+ Convenience for quick tests; equivalent to slicing the inventory
429
+ with ``.iloc[:n]`` while preserving sources and roots.
430
+ """
431
+ if not isinstance(n, int) or n < 0:
432
+ raise ValueError(f"n must be a non-negative int; got {n!r}")
433
+ return Dataset(self._inventory.iloc[:n].copy(), sources=self._sources, roots=self._roots)
434
+
435
+ def select(
436
+ self,
437
+ subject: str,
438
+ session: str,
439
+ task: Optional[str] = None,
440
+ ) -> "Dataset":
441
+ """Return a new ``Dataset`` containing exactly one inventory row.
442
+
443
+ Positional shorthand for ``include(subject=..., session=..., task=...)``
444
+ intended for the common "give me this one cell" use case. Unlike
445
+ :meth:`include`, all arguments must be single strings — for
446
+ multi-value or partial filtering use :meth:`include` directly:
447
+
448
+ >>> ds.select("STREHAB07", "ses-05", "task-widefield") # one cell
449
+ >>> ds.include(subject="STREHAB07", session="ses-05") # all tasks for that session
450
+
451
+ Raises ``KeyError`` if no row matches and ``ValueError`` if more
452
+ than one row matches (e.g. when ``task`` is omitted on a
453
+ task-level inventory and multiple tasks exist for the session).
454
+ """
455
+ for name, value in (("subject", subject), ("session", session), ("task", task)):
456
+ if value is not None and not isinstance(value, str):
457
+ raise TypeError(
458
+ f"select(): {name} must be a string; got {type(value).__name__}. "
459
+ "Use Dataset.include() for multi-value filtering."
460
+ )
461
+ ds = self.include(subject=subject, session=session, task=task)
462
+ n = len(ds._inventory)
463
+ if n == 0:
464
+ key = (subject, session) if task is None else (subject, session, task)
465
+ raise KeyError(f"No inventory entry for {key}")
466
+ if n > 1:
467
+ key = (subject, session) if task is None else (subject, session, task)
468
+ raise ValueError(
469
+ f"select() matched {n} rows for {key}; "
470
+ "narrow with task=... or use Dataset.include() instead."
471
+ )
472
+ return ds
473
+
474
+ # ---- Materialize / validate --------------------------------------
475
+
476
+ def _iter_cells(self, *, strict: bool, progress: bool) -> tuple[
477
+ list[dict[tuple[str, str], Any]], list[dict[str, Any]]
478
+ ]:
479
+ order = _toposort(self._sources)
480
+ n_levels = self._inventory.index.nlevels
481
+ index_names = list(settings.dataset.index_names)[:n_levels]
482
+
483
+ rows: list[dict[tuple[str, str], Any]] = []
484
+ records: list[dict[str, Any]] = []
485
+
486
+ bar = None
487
+ if progress:
488
+ try:
489
+ from tqdm import tqdm
490
+ bar = tqdm(total=len(self._inventory) * len(order), unit="load")
491
+ except ImportError:
492
+ pass
493
+
494
+ for idx, row in self._inventory.iterrows():
495
+ if not isinstance(idx, tuple):
496
+ idx = (idx,)
497
+ inv_row = row.to_dict()
498
+ row_cells: dict[tuple[str, str], Any] = {}
499
+ cell_streams: dict[str, LoadedStream | None] = {}
500
+
501
+ base_record: dict[str, Any] = {name: idx[i] if i < len(idx) else None
502
+ for i, name in enumerate(index_names)}
503
+
504
+ for tag in order:
505
+ cls_ = SOURCE_REGISTRY[tag]
506
+ rec = {**base_record, "Source": tag, "path": None,
507
+ "error_type": None, "message": None, "traceback": None}
508
+
509
+ path_value = inv_row.get(tag)
510
+ if path_value is None or (isinstance(path_value, float) and pd.isna(path_value)):
511
+ rec["status"] = "missing"
512
+ records.append(rec)
513
+ cell_streams[tag] = None
514
+ if bar: bar.update(1)
515
+ continue
516
+
517
+ deps = {req: cell_streams.get(req)
518
+ for req in (getattr(cls_, "requires", ()) or ())}
519
+ ctx = _make_context(idx, inv_row, deps)
520
+ path_str = str(path_value)
521
+ rec["path"] = path_str
522
+
523
+ try:
524
+ stream = cls_().load(Path(path_str), context=ctx)
525
+ except Exception as exc: # noqa: BLE001
526
+ if strict:
527
+ if bar: bar.close()
528
+ raise RuntimeError(
529
+ f"Error loading source '{tag}' for {idx} at '{path_str}': "
530
+ f"{type(exc).__name__}: {exc}"
531
+ ) from exc
532
+ rec["status"] = "error"
533
+ rec["error_type"] = type(exc).__name__
534
+ rec["message"] = str(exc)
535
+ rec["traceback"] = _tb.format_exc()
536
+ records.append(rec)
537
+ cell_streams[tag] = None
538
+ if bar: bar.update(1)
539
+ continue
540
+
541
+ cell_streams[tag] = stream
542
+ row_cells.update(_flatten(stream))
543
+ rec["status"] = "ok"
544
+ records.append(rec)
545
+ if bar: bar.update(1)
546
+
547
+ rows.append(row_cells)
548
+
549
+ if bar: bar.close()
550
+ return rows, records
551
+
552
+ def validate(self, *, progress: bool = False) -> pd.DataFrame:
553
+ """Run every (cell, source); report status without raising."""
554
+ _, records = self._iter_cells(strict=False, progress=progress)
555
+ n_levels = self._inventory.index.nlevels
556
+ cols = list(settings.dataset.index_names)[:n_levels] + ["Source"] + _RECORD_COLS
557
+ return pd.DataFrame(records, columns=cols)
558
+
559
+ def materialize(
560
+ self,
561
+ *,
562
+ strict: bool = True,
563
+ return_errors: bool = False,
564
+ progress: bool = False,
565
+ ) -> Union[pd.DataFrame, tuple[pd.DataFrame, pd.DataFrame]]:
566
+ """Build the materialized DataFrame.
567
+
568
+ With ``strict=True`` (default) the first error is raised with full
569
+ ``(subject, session, task, source, path)`` context. With
570
+ ``strict=False`` failed cells are blanked; pass ``return_errors=True``
571
+ to also receive the long-format error frame produced by
572
+ :meth:`validate`.
573
+ """
574
+ rows, records = self._iter_cells(strict=strict, progress=progress)
575
+ idx = self._inventory.index
576
+
577
+ all_cols: list[tuple[str, str]] = []
578
+ seen: set[tuple[str, str]] = set()
579
+ for r in rows:
580
+ for c in r.keys():
581
+ if c not in seen:
582
+ seen.add(c)
583
+ all_cols.append(c)
584
+
585
+ if not all_cols:
586
+ df = pd.DataFrame(index=idx,
587
+ columns=pd.MultiIndex.from_tuples([], names=["Source", "Feature"]))
588
+ else:
589
+ data = {c: [r.get(c, np.nan) for r in rows] for c in all_cols}
590
+ df = pd.DataFrame(data, index=idx)
591
+ df.columns = pd.MultiIndex.from_tuples(all_cols, names=["Source", "Feature"])
592
+
593
+ # Embed provenance metadata so pickled artefacts can be traced back to
594
+ # the exact datakit revision that produced them. ``DataFrame.attrs``
595
+ # round-trips through ``to_pickle``/``read_pickle``.
596
+ df.attrs["datakit"] = _build_meta()
597
+ df.attrs["datakit_sources"] = list(self._sources)
598
+ df.attrs["datakit_roots"] = [str(r) for r in self._roots]
599
+ df.attrs["datakit_notes"] = list(self._notes)
600
+
601
+ if return_errors:
602
+ n_levels = idx.nlevels
603
+ cols = list(settings.dataset.index_names)[:n_levels] + ["Source"] + _RECORD_COLS
604
+ return df, pd.DataFrame(records, columns=cols)
605
+ return df
606
+
607
+ # ---- Persistence --------------------------------------------------
608
+
609
+ def save(
610
+ self,
611
+ path: PathLike,
612
+ *,
613
+ format: Optional[str] = None,
614
+ strict: bool = True,
615
+ progress: bool = False,
616
+ hdf_key: str = "dataset",
617
+ ) -> Path:
618
+ """Materialize and write to disk. Pickle by default; HDF5 via
619
+ ``format="hdf5"`` or a ``.h5``/``.hdf5`` suffix."""
620
+ out = Path(path).expanduser()
621
+ suffix = out.suffix.lower()
622
+ fmt = (format or ("hdf5" if suffix in {".h5", ".hdf5"} else "pickle")).lower()
623
+ df = self.materialize(strict=strict, progress=progress)
624
+ out.parent.mkdir(parents=True, exist_ok=True)
625
+ if fmt == "pickle":
626
+ df.to_pickle(out)
627
+ elif fmt == "hdf5":
628
+ df.to_hdf(out, key=hdf_key, mode="w", format="fixed")
629
+ else:
630
+ raise ValueError(f"Unsupported format: {fmt!r}")
631
+ return out
632
+
633
+
634
+ # ---------------------------------------------------------------------------
635
+ # Top-level convenience API
636
+ # ---------------------------------------------------------------------------
637
+
638
+
639
+ def load(
640
+ root: Union[PathLike, Sequence[PathLike]],
641
+ *,
642
+ sources: Optional[Iterable[str]] = None,
643
+ prefer_processed: bool = True,
644
+ include_task_level: bool | None = True,
645
+ progress: bool = True,
646
+ strict: bool = True,
647
+ return_errors: bool = False,
648
+ ) -> Union[pd.DataFrame, tuple[pd.DataFrame, pd.DataFrame]]:
649
+ """One-shot discovery + materialization.
650
+
651
+ Equivalent to ``Dataset.from_directory(root, ...).materialize(...)``.
652
+ Use :meth:`Dataset.from_directory` directly when you need to filter
653
+ (``.include`` / ``.exclude``) before materializing.
654
+ """
655
+ ds = Dataset.from_directory(
656
+ root,
657
+ sources=sources,
658
+ prefer_processed=prefer_processed,
659
+ include_task_level=include_task_level,
660
+ )
661
+ return ds.materialize(strict=strict, progress=progress, return_errors=return_errors)
662
+
663
+
664
+ def load_dataset(
665
+ path: PathLike,
666
+ *,
667
+ hdf_key: str = "dataset",
668
+ ) -> pd.DataFrame:
669
+ """Load a previously materialized dataset back into a DataFrame.
670
+
671
+ The consumer-side inverse of :meth:`Dataset.save`. Reads a ``.pkl`` /
672
+ ``.pickle`` (pandas pickle) or ``.h5`` / ``.hdf5`` (HDF5) artefact and
673
+ returns the materialized ``pandas.DataFrame`` — including the
674
+ ``df.attrs`` provenance metadata embedded at save time.
675
+
676
+ Raises
677
+ ------
678
+ FileNotFoundError
679
+ If ``path`` does not exist.
680
+ ValueError
681
+ If the file extension is not a supported dataset format.
682
+ """
683
+ p = Path(path).expanduser().resolve()
684
+ if not p.is_file():
685
+ raise FileNotFoundError(f"Dataset file not found: {p}")
686
+ suffix = p.suffix.lower()
687
+ if suffix in (".pkl", ".pickle"):
688
+ df = pd.read_pickle(p)
689
+ elif suffix in (".h5", ".hdf5"):
690
+ df = pd.read_hdf(p, key=hdf_key)
691
+ else:
692
+ raise ValueError(
693
+ f"Unsupported dataset file type: {suffix!r} "
694
+ "(expected .pkl, .pickle, .h5, or .hdf5)"
695
+ )
696
+ if not isinstance(df, pd.DataFrame):
697
+ raise TypeError(f"Expected a DataFrame, got {type(df).__name__}")
698
+ return df
699
+
700
+
701
+ def load_path(tag: str, path: PathLike) -> LoadedStream:
702
+ """Ad-hoc single-file load via the registered source for ``tag``.
703
+
704
+ Builds a minimal :class:`LoadContext` so sources without ``requires``
705
+ or sibling-path lookups can be exercised directly. Sources declaring
706
+ dependencies will receive ``None`` for them in ``context.dependencies``
707
+ and must either degrade gracefully or raise.
708
+ """
709
+ if tag not in SOURCE_REGISTRY:
710
+ raise KeyError(f"Unknown source tag: {tag!r}")
711
+ cls_ = SOURCE_REGISTRY[tag]
712
+ p = Path(path)
713
+ ctx = LoadContext(
714
+ subject="unknown",
715
+ session="unknown",
716
+ task=None,
717
+ inventory_row={tag: str(p)},
718
+ dependencies={},
719
+ )
720
+ return cls_().load(p, context=ctx)
721
+
722
+
723
+ def inspect_sources(
724
+ inventory_or_dataset: Union["Dataset", pd.DataFrame],
725
+ sources: Optional[Iterable[str]] = None,
726
+ ) -> pd.DataFrame:
727
+ """Return a per-source coverage summary for an inventory.
728
+
729
+ The returned DataFrame is indexed by source tag with columns
730
+ ``present``, ``total``, ``missing``, and ``coverage`` (fraction of rows
731
+ with a non-null path). Accepts either a :class:`Dataset` or a raw
732
+ inventory DataFrame.
733
+
734
+ When ``sources`` is omitted, every registered tag found in the
735
+ inventory's columns is reported.
736
+ """
737
+ if isinstance(inventory_or_dataset, Dataset):
738
+ inv = inventory_or_dataset.inventory
739
+ elif isinstance(inventory_or_dataset, pd.DataFrame):
740
+ inv = inventory_or_dataset
741
+ else:
742
+ raise TypeError(
743
+ "inspect_sources expects a Dataset or DataFrame; got "
744
+ f"{type(inventory_or_dataset).__name__}"
745
+ )
746
+
747
+ if sources is None:
748
+ tags = [c for c in inv.columns if c in SOURCE_REGISTRY]
749
+ else:
750
+ tags = list(sources)
751
+
752
+ total = len(inv)
753
+ rows: list[dict[str, Any]] = []
754
+ for tag in tags:
755
+ if tag not in inv.columns:
756
+ rows.append({
757
+ "source": tag,
758
+ "present": 0,
759
+ "total": total,
760
+ "missing": total,
761
+ "coverage": 0.0,
762
+ })
763
+ continue
764
+ present = int(inv[tag].notna().sum())
765
+ rows.append({
766
+ "source": tag,
767
+ "present": present,
768
+ "total": total,
769
+ "missing": total - present,
770
+ "coverage": (present / total) if total else 0.0,
771
+ })
772
+ return pd.DataFrame(rows).set_index("source")
773
+
774
+
775
+ __all__ = [
776
+ "Dataset",
777
+ "LoadContext",
778
+ "LoadedStream",
779
+ "load",
780
+ "load_dataset",
781
+ "load_path",
782
+ "inspect_sources",
783
+ ]