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.
- docs/_static/custom.css +40 -0
- docs/_static/favicon.png +0 -0
- docs/_static/logo.png +0 -0
- docs/api/index.md +70 -0
- docs/conf.py +200 -0
- docs/developer_guide.md +303 -0
- docs/index.md +25 -0
- docs/tutorial.md +4 -0
- docs/user_guide.md +172 -0
- examples/teensy_pulse_generator.py +320 -0
- experiments/pipeline_demo/experiment.json +24 -0
- experiments/pipeline_demo/hardware.yaml +23 -0
- experiments/pipeline_demo/procedure.py +50 -0
- experiments/two_cam_demo/experiment.json +24 -0
- experiments/two_cam_demo/hardware.yaml +58 -0
- experiments/two_cam_demo/load_dataset.py +213 -0
- experiments/two_cam_demo/procedure.py +87 -0
- external/video-codecs/openh264-1.8.0-win64.dll +0 -0
- mesofield/__init__.py +45 -0
- mesofield/__main__.py +11 -0
- mesofield/_version.py +24 -0
- mesofield/base.py +750 -0
- mesofield/cli/__init__.py +57 -0
- mesofield/cli/_richhelp.py +100 -0
- mesofield/cli/acquire.py +254 -0
- mesofield/cli/datakit.py +165 -0
- mesofield/cli/process.py +376 -0
- mesofield/cli/rig.py +108 -0
- mesofield/cli/tools.py +347 -0
- mesofield/config.py +751 -0
- mesofield/data/__init__.py +23 -0
- mesofield/data/batch.py +633 -0
- mesofield/data/manager.py +388 -0
- mesofield/data/writer.py +289 -0
- mesofield/datakit/__init__.py +44 -0
- mesofield/datakit/__main__.py +35 -0
- mesofield/datakit/_utils/_logger.py +5 -0
- mesofield/datakit/_version.py +141 -0
- mesofield/datakit/config.py +50 -0
- mesofield/datakit/core.py +783 -0
- mesofield/datakit/datamodel.py +200 -0
- mesofield/datakit/discover.py +124 -0
- mesofield/datakit/explore.py +651 -0
- mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
- mesofield/datakit/profile.py +535 -0
- mesofield/datakit/shell.py +83 -0
- mesofield/datakit/sources/__init__.py +65 -0
- mesofield/datakit/sources/analysis/mesomap.py +194 -0
- mesofield/datakit/sources/analysis/mesoscope.py +77 -0
- mesofield/datakit/sources/analysis/pupil.py +246 -0
- mesofield/datakit/sources/behavior/__init__.py +0 -0
- mesofield/datakit/sources/behavior/dataqueue.py +281 -0
- mesofield/datakit/sources/behavior/psychopy.py +364 -0
- mesofield/datakit/sources/behavior/treadmill.py +323 -0
- mesofield/datakit/sources/behavior/wheel.py +277 -0
- mesofield/datakit/sources/camera/mesoscope.py +32 -0
- mesofield/datakit/sources/camera/metadata_json.py +130 -0
- mesofield/datakit/sources/camera/pupil.py +28 -0
- mesofield/datakit/sources/camera/suite2p.py +547 -0
- mesofield/datakit/sources/register.py +204 -0
- mesofield/datakit/sources/session/config.py +130 -0
- mesofield/datakit/sources/session/notes.py +63 -0
- mesofield/datakit/sources/session/timestamps.py +58 -0
- mesofield/datakit/timeline.py +306 -0
- mesofield/devices/__init__.py +42 -0
- mesofield/devices/base.py +498 -0
- mesofield/devices/base_camera.py +295 -0
- mesofield/devices/cameras.py +740 -0
- mesofield/devices/daq.py +151 -0
- mesofield/devices/encoder.py +384 -0
- mesofield/devices/mocks.py +275 -0
- mesofield/devices/psychopy_device.py +455 -0
- mesofield/devices/subprocesses/__init__.py +0 -0
- mesofield/devices/subprocesses/psychopy.py +133 -0
- mesofield/devices/treadmill.py +318 -0
- mesofield/engines.py +380 -0
- mesofield/gui/Mesofield_icon.png +0 -0
- mesofield/gui/__init__.py +76 -0
- mesofield/gui/config_wizard.py +724 -0
- mesofield/gui/controller.py +535 -0
- mesofield/gui/dynamic_controller.py +78 -0
- mesofield/gui/maingui.py +427 -0
- mesofield/gui/mdagui.py +285 -0
- mesofield/gui/qt_device_adapter.py +109 -0
- mesofield/gui/speedplotter.py +152 -0
- mesofield/gui/theme.py +445 -0
- mesofield/gui/tiff_viewer.py +1050 -0
- mesofield/gui/viewer.py +691 -0
- mesofield/hardware.py +549 -0
- mesofield/playback.py +1298 -0
- mesofield/processing/__init__.py +12 -0
- mesofield/processing/runner.py +237 -0
- mesofield/processors/__init__.py +13 -0
- mesofield/processors/base.py +287 -0
- mesofield/processors/frame_mean.py +19 -0
- mesofield/protocols.py +378 -0
- mesofield/scaffold/__init__.py +34 -0
- mesofield/scaffold/experiment.py +400 -0
- mesofield/scaffold/rigs.py +121 -0
- mesofield/signals.py +85 -0
- mesofield/utils/__init__.py +0 -0
- mesofield/utils/_logger.py +156 -0
- mesofield/utils/retrofit.py +309 -0
- mesofield/utils/utils.py +217 -0
- mesofield-0.3.2b0.dist-info/METADATA +178 -0
- mesofield-0.3.2b0.dist-info/RECORD +111 -0
- mesofield-0.3.2b0.dist-info/WHEEL +5 -0
- mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
- mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
- mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
- 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
|
+
]
|