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,651 @@
|
|
|
1
|
+
"""Agnostic dataset and experiment exploration.
|
|
2
|
+
|
|
3
|
+
Lightweight introspection for both pre-load discovery results
|
|
4
|
+
(:class:`datakit.Dataset`) and post-load materialized outputs
|
|
5
|
+
(``pandas.DataFrame``). Output uses ``rich`` when available and falls
|
|
6
|
+
back to plain indented text otherwise.
|
|
7
|
+
|
|
8
|
+
Quick start::
|
|
9
|
+
|
|
10
|
+
from mesofield.datakit import explore, Dataset
|
|
11
|
+
|
|
12
|
+
# Pre-load: inspect a discovered Dataset (or directory path)
|
|
13
|
+
explore("path/to/experiment")
|
|
14
|
+
explore(Dataset.from_directory(root))
|
|
15
|
+
|
|
16
|
+
# Post-load: inspect a materialized DataFrame or pickle / HDF5
|
|
17
|
+
explore("path/to/dataset.pkl")
|
|
18
|
+
explore(materialized_df)
|
|
19
|
+
|
|
20
|
+
# Programmatic access (no printing)
|
|
21
|
+
report = explore(dataset, print_output=False)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Union
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
import pandas as pd
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Report dataclasses
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class TagSummary:
|
|
41
|
+
"""Per-source statistics for a discovered Dataset."""
|
|
42
|
+
|
|
43
|
+
tag: str
|
|
44
|
+
file_count: int
|
|
45
|
+
coverage_pct: float
|
|
46
|
+
extensions: tuple[str, ...]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class DatasetInventoryReport:
|
|
51
|
+
"""Pre-load overview of a :class:`datakit.Dataset` inventory."""
|
|
52
|
+
|
|
53
|
+
roots: tuple[str, ...]
|
|
54
|
+
n_subjects: int
|
|
55
|
+
n_sessions: int
|
|
56
|
+
n_tasks: int
|
|
57
|
+
has_task_level: bool
|
|
58
|
+
subjects: tuple[str, ...]
|
|
59
|
+
sessions: tuple[str, ...]
|
|
60
|
+
tasks: tuple[str, ...]
|
|
61
|
+
tags: tuple[TagSummary, ...]
|
|
62
|
+
n_total_files: int
|
|
63
|
+
coverage_matrix: dict[str, dict[str, float]] # tag -> {subject: pct}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class ColumnInfo:
|
|
68
|
+
"""Type and structure information for a single materialized column."""
|
|
69
|
+
|
|
70
|
+
source: str
|
|
71
|
+
feature: str
|
|
72
|
+
dtype: str
|
|
73
|
+
detail: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class MaterializedReport:
|
|
78
|
+
"""Post-load overview of a materialized dataset DataFrame."""
|
|
79
|
+
|
|
80
|
+
shape: tuple[int, int]
|
|
81
|
+
index_names: tuple[str, ...]
|
|
82
|
+
index_counts: dict[str, int]
|
|
83
|
+
n_sources: int
|
|
84
|
+
n_features: int
|
|
85
|
+
memory_mb: float
|
|
86
|
+
sources: tuple[str, ...]
|
|
87
|
+
source_features: dict[str, tuple[str, ...]]
|
|
88
|
+
columns: tuple[ColumnInfo, ...]
|
|
89
|
+
coverage: dict[str, float]
|
|
90
|
+
hierarchy: dict[str, Any]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Value inspection helpers
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _describe_value(val: object) -> str:
|
|
99
|
+
if val is None or (isinstance(val, float) and np.isnan(val)):
|
|
100
|
+
return "-"
|
|
101
|
+
if isinstance(val, np.ndarray):
|
|
102
|
+
return f"ndarray {val.dtype} {val.shape}"
|
|
103
|
+
if isinstance(val, pd.DataFrame):
|
|
104
|
+
return f"DataFrame {val.shape[0]}x{val.shape[1]}"
|
|
105
|
+
if isinstance(val, pd.Series):
|
|
106
|
+
return f"Series len={len(val)}"
|
|
107
|
+
if isinstance(val, dict):
|
|
108
|
+
n = len(val)
|
|
109
|
+
keys_preview = ", ".join(list(val.keys())[:3])
|
|
110
|
+
if n > 3:
|
|
111
|
+
keys_preview += ", ..."
|
|
112
|
+
return f"dict({n}) [{keys_preview}]"
|
|
113
|
+
if isinstance(val, (list, tuple)):
|
|
114
|
+
return f"{type(val).__name__} len={len(val)}"
|
|
115
|
+
return type(val).__name__
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _inspect_column(series: pd.Series) -> str:
|
|
119
|
+
non_null = series.dropna()
|
|
120
|
+
if non_null.empty:
|
|
121
|
+
return "all null"
|
|
122
|
+
sample = non_null.iloc[0]
|
|
123
|
+
desc = _describe_value(sample)
|
|
124
|
+
if len(non_null) > 1:
|
|
125
|
+
other_desc = _describe_value(non_null.iloc[-1])
|
|
126
|
+
if other_desc != desc:
|
|
127
|
+
desc += " (varies)"
|
|
128
|
+
return desc
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Inventory exploration (pre-load)
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def explore_inventory(dataset: Any) -> DatasetInventoryReport:
|
|
137
|
+
"""Analyse a :class:`datakit.Dataset` without loading any files."""
|
|
138
|
+
from .core import Dataset
|
|
139
|
+
|
|
140
|
+
if not isinstance(dataset, Dataset):
|
|
141
|
+
raise TypeError(f"Expected datakit.Dataset, got {type(dataset).__name__}")
|
|
142
|
+
|
|
143
|
+
inv = dataset.inventory
|
|
144
|
+
idx = inv.index
|
|
145
|
+
subjects = tuple(dataset.subjects)
|
|
146
|
+
sessions = tuple(dataset.sessions)
|
|
147
|
+
has_task = dataset.has_task_level
|
|
148
|
+
tasks: tuple[str, ...] = ()
|
|
149
|
+
if has_task and idx.nlevels >= 3:
|
|
150
|
+
tasks = tuple(sorted(idx.get_level_values(2).dropna().unique().tolist()))
|
|
151
|
+
|
|
152
|
+
# Per-tag summaries
|
|
153
|
+
tag_summaries: list[TagSummary] = []
|
|
154
|
+
coverage_matrix: dict[str, dict[str, float]] = {}
|
|
155
|
+
total_files = 0
|
|
156
|
+
|
|
157
|
+
for tag in sorted(inv.columns):
|
|
158
|
+
col = inv[tag]
|
|
159
|
+
count = int(col.notna().sum())
|
|
160
|
+
total_files += count
|
|
161
|
+
coverage_pct = round(float(col.notna().mean()) * 100.0, 1) if len(col) else 0.0
|
|
162
|
+
|
|
163
|
+
exts = sorted({Path(str(v)).suffix.lower() for v in col.dropna().tolist() if str(v)})
|
|
164
|
+
exts_tuple = tuple(e for e in exts if e)
|
|
165
|
+
|
|
166
|
+
tag_summaries.append(
|
|
167
|
+
TagSummary(
|
|
168
|
+
tag=tag,
|
|
169
|
+
file_count=count,
|
|
170
|
+
coverage_pct=coverage_pct,
|
|
171
|
+
extensions=exts_tuple,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if subjects:
|
|
176
|
+
subj_cov: dict[str, float] = {}
|
|
177
|
+
for subj in subjects:
|
|
178
|
+
try:
|
|
179
|
+
sub_slice = inv.xs(subj, level=0)[tag]
|
|
180
|
+
subj_cov[subj] = round(float(sub_slice.notna().mean()) * 100.0, 1)
|
|
181
|
+
except KeyError:
|
|
182
|
+
subj_cov[subj] = 0.0
|
|
183
|
+
coverage_matrix[tag] = subj_cov
|
|
184
|
+
|
|
185
|
+
roots = tuple(str(r) for r in dataset.roots) or ("<in-memory>",)
|
|
186
|
+
|
|
187
|
+
return DatasetInventoryReport(
|
|
188
|
+
roots=roots,
|
|
189
|
+
n_subjects=len(subjects),
|
|
190
|
+
n_sessions=len(sessions),
|
|
191
|
+
n_tasks=len(tasks),
|
|
192
|
+
has_task_level=has_task,
|
|
193
|
+
subjects=subjects,
|
|
194
|
+
sessions=sessions,
|
|
195
|
+
tasks=tasks,
|
|
196
|
+
tags=tuple(tag_summaries),
|
|
197
|
+
n_total_files=total_files,
|
|
198
|
+
coverage_matrix=coverage_matrix,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Materialized exploration (post-load)
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def explore_materialized(dataset: pd.DataFrame) -> MaterializedReport:
|
|
208
|
+
"""Analyse a materialized dataset DataFrame."""
|
|
209
|
+
if not isinstance(dataset, pd.DataFrame):
|
|
210
|
+
raise TypeError(f"Expected pandas DataFrame, got {type(dataset).__name__}")
|
|
211
|
+
|
|
212
|
+
shape = dataset.shape
|
|
213
|
+
idx = dataset.index
|
|
214
|
+
raw_names = tuple(idx.names) if hasattr(idx, "names") else ()
|
|
215
|
+
index_names: tuple[str, ...] = tuple(
|
|
216
|
+
str(n) if n is not None else f"level_{i}" for i, n in enumerate(raw_names)
|
|
217
|
+
)
|
|
218
|
+
index_counts: dict[str, int] = {}
|
|
219
|
+
for i, label in enumerate(index_names):
|
|
220
|
+
index_counts[str(label)] = int(idx.get_level_values(i).nunique())
|
|
221
|
+
|
|
222
|
+
cols = dataset.columns
|
|
223
|
+
if isinstance(cols, pd.MultiIndex) and cols.nlevels >= 2:
|
|
224
|
+
sources = tuple(sorted(cols.get_level_values(0).unique().tolist()))
|
|
225
|
+
src_features: dict[str, tuple[str, ...]] = {}
|
|
226
|
+
for src in sources:
|
|
227
|
+
feats = sorted(
|
|
228
|
+
cols.get_level_values(1)[cols.get_level_values(0) == src].unique().tolist()
|
|
229
|
+
)
|
|
230
|
+
src_features[src] = tuple(feats)
|
|
231
|
+
n_features = int(cols.get_level_values(1).nunique())
|
|
232
|
+
else:
|
|
233
|
+
sources = ()
|
|
234
|
+
src_features = {}
|
|
235
|
+
n_features = len(cols)
|
|
236
|
+
|
|
237
|
+
memory_mb = round(int(dataset.memory_usage(deep=True).sum()) / (1024 * 1024), 2)
|
|
238
|
+
|
|
239
|
+
col_infos: list[ColumnInfo] = []
|
|
240
|
+
for col_key in cols:
|
|
241
|
+
if isinstance(col_key, tuple):
|
|
242
|
+
src, feat = str(col_key[0]), str(col_key[1])
|
|
243
|
+
else:
|
|
244
|
+
src, feat = "", str(col_key)
|
|
245
|
+
series = dataset[col_key]
|
|
246
|
+
dt = str(series.dtype)
|
|
247
|
+
detail = _inspect_column(series) if dt == "object" else dt
|
|
248
|
+
col_infos.append(ColumnInfo(source=src, feature=feat, dtype=dt, detail=detail))
|
|
249
|
+
|
|
250
|
+
coverage: dict[str, float] = {}
|
|
251
|
+
if isinstance(cols, pd.MultiIndex) and cols.nlevels >= 2:
|
|
252
|
+
for src in sources:
|
|
253
|
+
src_cols = dataset.xs(src, axis=1, level=0, drop_level=True)
|
|
254
|
+
non_null = src_cols.notna().any(axis=1).mean() # type: ignore[call-overload]
|
|
255
|
+
coverage[src] = round(float(non_null) * 100.0, 1)
|
|
256
|
+
else:
|
|
257
|
+
for c in cols:
|
|
258
|
+
coverage[str(c)] = round(float(dataset[c].notna().mean()) * 100.0, 1)
|
|
259
|
+
|
|
260
|
+
hierarchy: dict[str, Any] = {}
|
|
261
|
+
for row_key in idx:
|
|
262
|
+
parts = row_key if isinstance(row_key, tuple) else (row_key,)
|
|
263
|
+
node = hierarchy
|
|
264
|
+
for part in parts:
|
|
265
|
+
key = str(part)
|
|
266
|
+
node = node.setdefault(key, {})
|
|
267
|
+
|
|
268
|
+
return MaterializedReport(
|
|
269
|
+
shape=shape,
|
|
270
|
+
index_names=index_names,
|
|
271
|
+
index_counts=index_counts,
|
|
272
|
+
n_sources=len(sources),
|
|
273
|
+
n_features=n_features,
|
|
274
|
+
memory_mb=memory_mb,
|
|
275
|
+
sources=sources,
|
|
276
|
+
source_features=src_features,
|
|
277
|
+
columns=tuple(col_infos),
|
|
278
|
+
coverage=coverage,
|
|
279
|
+
hierarchy=hierarchy,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
# Rendering
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
_HAS_RICH: bool | None = None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _rich_available() -> bool:
|
|
291
|
+
global _HAS_RICH
|
|
292
|
+
if _HAS_RICH is None:
|
|
293
|
+
try:
|
|
294
|
+
import rich # noqa: F401
|
|
295
|
+
|
|
296
|
+
_HAS_RICH = True
|
|
297
|
+
except ImportError:
|
|
298
|
+
_HAS_RICH = False
|
|
299
|
+
return _HAS_RICH
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _render_inventory_rich(report: DatasetInventoryReport) -> str:
|
|
303
|
+
from io import StringIO
|
|
304
|
+
|
|
305
|
+
from rich.console import Console
|
|
306
|
+
from rich.panel import Panel
|
|
307
|
+
from rich.table import Table
|
|
308
|
+
from rich.tree import Tree
|
|
309
|
+
|
|
310
|
+
buf = StringIO()
|
|
311
|
+
console = Console(file=buf, force_terminal=True, width=120)
|
|
312
|
+
|
|
313
|
+
overview = (
|
|
314
|
+
f"[bold]Root(s):[/bold] {', '.join(report.roots)}\n"
|
|
315
|
+
f"[bold]Subjects:[/bold] {report.n_subjects} "
|
|
316
|
+
f"[bold]Sessions:[/bold] {report.n_sessions} "
|
|
317
|
+
f"[bold]Tasks:[/bold] {report.n_tasks} "
|
|
318
|
+
f"[bold]Files:[/bold] {report.n_total_files}\n"
|
|
319
|
+
f"[bold]Task-level index:[/bold] {report.has_task_level}"
|
|
320
|
+
)
|
|
321
|
+
console.print(Panel(overview, title="Dataset Inventory", border_style="blue"))
|
|
322
|
+
|
|
323
|
+
tree = Tree("[bold]Subjects[/bold]")
|
|
324
|
+
for subj in report.subjects:
|
|
325
|
+
subj_node = tree.add(f"[cyan]{subj}[/cyan]")
|
|
326
|
+
for ses in report.sessions:
|
|
327
|
+
ses_node = subj_node.add(f"[green]{ses}[/green]")
|
|
328
|
+
for task in report.tasks:
|
|
329
|
+
ses_node.add(f"[dim]{task}[/dim]")
|
|
330
|
+
console.print(tree)
|
|
331
|
+
|
|
332
|
+
table = Table(title="Source Tags", show_lines=False)
|
|
333
|
+
table.add_column("Tag", style="bold")
|
|
334
|
+
table.add_column("Files", justify="right")
|
|
335
|
+
table.add_column("Coverage %", justify="right")
|
|
336
|
+
table.add_column("Extensions")
|
|
337
|
+
for ts in report.tags:
|
|
338
|
+
cov_style = (
|
|
339
|
+
"green" if ts.coverage_pct >= 80 else ("yellow" if ts.coverage_pct >= 50 else "red")
|
|
340
|
+
)
|
|
341
|
+
table.add_row(
|
|
342
|
+
ts.tag,
|
|
343
|
+
str(ts.file_count),
|
|
344
|
+
f"[{cov_style}]{ts.coverage_pct:.0f}%[/{cov_style}]",
|
|
345
|
+
", ".join(ts.extensions) if ts.extensions else "-",
|
|
346
|
+
)
|
|
347
|
+
console.print(table)
|
|
348
|
+
|
|
349
|
+
if len(report.subjects) > 1 and report.coverage_matrix:
|
|
350
|
+
cov_table = Table(title="Coverage by Subject", show_lines=True)
|
|
351
|
+
cov_table.add_column("Subject", style="bold")
|
|
352
|
+
for ts in report.tags:
|
|
353
|
+
cov_table.add_column(ts.tag, justify="center", max_width=8)
|
|
354
|
+
for subj in report.subjects:
|
|
355
|
+
cells: list[str] = []
|
|
356
|
+
for ts in report.tags:
|
|
357
|
+
pct = report.coverage_matrix.get(ts.tag, {}).get(subj, 0)
|
|
358
|
+
style = "green" if pct >= 80 else ("yellow" if pct >= 50 else "red")
|
|
359
|
+
cells.append(f"[{style}]{pct:.0f}%[/{style}]")
|
|
360
|
+
cov_table.add_row(subj, *cells)
|
|
361
|
+
console.print(cov_table)
|
|
362
|
+
|
|
363
|
+
return buf.getvalue()
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _render_inventory_plain(report: DatasetInventoryReport) -> str:
|
|
367
|
+
lines: list[str] = []
|
|
368
|
+
lines.append("=" * 60)
|
|
369
|
+
lines.append("DATASET INVENTORY")
|
|
370
|
+
lines.append("=" * 60)
|
|
371
|
+
lines.append(f" Root(s): {', '.join(report.roots)}")
|
|
372
|
+
lines.append(f" Subjects: {report.n_subjects}")
|
|
373
|
+
lines.append(f" Sessions: {report.n_sessions}")
|
|
374
|
+
lines.append(f" Tasks: {report.n_tasks}")
|
|
375
|
+
lines.append(f" Files: {report.n_total_files}")
|
|
376
|
+
lines.append(f" Task-level: {report.has_task_level}")
|
|
377
|
+
lines.append("")
|
|
378
|
+
lines.append("STRUCTURE")
|
|
379
|
+
lines.append("-" * 40)
|
|
380
|
+
for subj in report.subjects:
|
|
381
|
+
lines.append(f" {subj}")
|
|
382
|
+
for ses in report.sessions:
|
|
383
|
+
lines.append(f" {ses}")
|
|
384
|
+
for task in report.tasks:
|
|
385
|
+
lines.append(f" {task}")
|
|
386
|
+
lines.append("")
|
|
387
|
+
lines.append("SOURCE TAGS")
|
|
388
|
+
lines.append("-" * 40)
|
|
389
|
+
lines.append(f" {'Tag':<25} {'Files':>5} {'Coverage':>8} Extensions")
|
|
390
|
+
for ts in report.tags:
|
|
391
|
+
ext_str = ", ".join(ts.extensions) if ts.extensions else "-"
|
|
392
|
+
lines.append(
|
|
393
|
+
f" {ts.tag:<25} {ts.file_count:>5} {ts.coverage_pct:>7.0f}% {ext_str}"
|
|
394
|
+
)
|
|
395
|
+
return "\n".join(lines)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _build_hierarchy_tree(
|
|
399
|
+
node: Any, tree: dict[str, Any], depth: int, level_names: tuple[str, ...]
|
|
400
|
+
) -> None:
|
|
401
|
+
styles = ["cyan", "green", "dim"]
|
|
402
|
+
style = styles[depth] if depth < len(styles) else ""
|
|
403
|
+
for key, children in sorted(tree.items()):
|
|
404
|
+
child_node = node.add(f"[{style}]{key}[/{style}]" if style else key)
|
|
405
|
+
if isinstance(children, dict) and children:
|
|
406
|
+
_build_hierarchy_tree(child_node, children, depth + 1, level_names)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _render_materialized_rich(report: MaterializedReport) -> str:
|
|
410
|
+
from io import StringIO
|
|
411
|
+
|
|
412
|
+
from rich.console import Console
|
|
413
|
+
from rich.panel import Panel
|
|
414
|
+
from rich.table import Table
|
|
415
|
+
from rich.tree import Tree
|
|
416
|
+
|
|
417
|
+
buf = StringIO()
|
|
418
|
+
console = Console(file=buf, force_terminal=True, width=120)
|
|
419
|
+
|
|
420
|
+
idx_parts = ", ".join(f"{k}={v}" for k, v in report.index_counts.items())
|
|
421
|
+
overview = (
|
|
422
|
+
f"[bold]Shape:[/bold] {report.shape[0]} rows x {report.shape[1]} columns\n"
|
|
423
|
+
f"[bold]Index:[/bold] ({idx_parts})\n"
|
|
424
|
+
f"[bold]Sources:[/bold] {report.n_sources} "
|
|
425
|
+
f"[bold]Features:[/bold] {report.n_features} "
|
|
426
|
+
f"[bold]Memory:[/bold] {report.memory_mb:.1f} MB"
|
|
427
|
+
)
|
|
428
|
+
console.print(Panel(overview, title="Materialized Dataset", border_style="blue"))
|
|
429
|
+
|
|
430
|
+
tree = Tree("[bold]Sources[/bold]")
|
|
431
|
+
for src in report.sources:
|
|
432
|
+
src_node = tree.add(f"[cyan]{src}[/cyan]")
|
|
433
|
+
for feat in report.source_features.get(src, ()):
|
|
434
|
+
src_node.add(f"[dim]{feat}[/dim]")
|
|
435
|
+
console.print(tree)
|
|
436
|
+
|
|
437
|
+
type_table = Table(title="Column Types", show_lines=False)
|
|
438
|
+
type_table.add_column("Source", style="bold")
|
|
439
|
+
type_table.add_column("Feature")
|
|
440
|
+
type_table.add_column("dtype")
|
|
441
|
+
type_table.add_column("Detail")
|
|
442
|
+
for ci in report.columns:
|
|
443
|
+
type_table.add_row(ci.source, ci.feature, ci.dtype, ci.detail)
|
|
444
|
+
console.print(type_table)
|
|
445
|
+
|
|
446
|
+
cov_table = Table(title="Source Coverage", show_lines=False)
|
|
447
|
+
cov_table.add_column("Source", style="bold")
|
|
448
|
+
cov_table.add_column("Available %", justify="right")
|
|
449
|
+
for src in report.sources:
|
|
450
|
+
pct = report.coverage.get(src, 0)
|
|
451
|
+
style = "green" if pct >= 80 else ("yellow" if pct >= 50 else "red")
|
|
452
|
+
cov_table.add_row(src, f"[{style}]{pct:.0f}%[/{style}]")
|
|
453
|
+
console.print(cov_table)
|
|
454
|
+
|
|
455
|
+
if report.hierarchy:
|
|
456
|
+
idx_tree = Tree("[bold]Index Hierarchy[/bold]")
|
|
457
|
+
_build_hierarchy_tree(idx_tree, report.hierarchy, 0, report.index_names)
|
|
458
|
+
console.print(idx_tree)
|
|
459
|
+
|
|
460
|
+
return buf.getvalue()
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _render_materialized_plain(report: MaterializedReport) -> str:
|
|
464
|
+
lines: list[str] = []
|
|
465
|
+
lines.append("=" * 60)
|
|
466
|
+
lines.append("MATERIALIZED DATASET")
|
|
467
|
+
lines.append("=" * 60)
|
|
468
|
+
idx_parts = ", ".join(f"{k}={v}" for k, v in report.index_counts.items())
|
|
469
|
+
lines.append(f" Shape: {report.shape[0]} rows x {report.shape[1]} columns")
|
|
470
|
+
lines.append(f" Index: ({idx_parts})")
|
|
471
|
+
lines.append(f" Sources: {report.n_sources}")
|
|
472
|
+
lines.append(f" Features: {report.n_features}")
|
|
473
|
+
lines.append(f" Memory: {report.memory_mb:.1f} MB")
|
|
474
|
+
lines.append("")
|
|
475
|
+
lines.append("STRUCTURE")
|
|
476
|
+
lines.append("-" * 40)
|
|
477
|
+
for src in report.sources:
|
|
478
|
+
feats = report.source_features.get(src, ())
|
|
479
|
+
lines.append(f" {src}")
|
|
480
|
+
for f in feats:
|
|
481
|
+
lines.append(f" {f}")
|
|
482
|
+
lines.append("")
|
|
483
|
+
lines.append("COLUMN TYPES")
|
|
484
|
+
lines.append("-" * 40)
|
|
485
|
+
lines.append(f" {'Source':<20} {'Feature':<20} {'dtype':<12} Detail")
|
|
486
|
+
for ci in report.columns:
|
|
487
|
+
lines.append(
|
|
488
|
+
f" {ci.source:<20} {ci.feature:<20} {ci.dtype:<12} {ci.detail}"
|
|
489
|
+
)
|
|
490
|
+
lines.append("")
|
|
491
|
+
lines.append("SOURCE COVERAGE")
|
|
492
|
+
lines.append("-" * 40)
|
|
493
|
+
for src in report.sources:
|
|
494
|
+
pct = report.coverage.get(src, 0)
|
|
495
|
+
bar_n = int(pct / 5)
|
|
496
|
+
bar = "#" * bar_n + "." * (20 - bar_n)
|
|
497
|
+
lines.append(f" {src:<20} [{bar}] {pct:.0f}%")
|
|
498
|
+
return "\n".join(lines)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _render(report: DatasetInventoryReport | MaterializedReport) -> str:
|
|
502
|
+
if isinstance(report, DatasetInventoryReport):
|
|
503
|
+
return (
|
|
504
|
+
_render_inventory_rich(report)
|
|
505
|
+
if _rich_available()
|
|
506
|
+
else _render_inventory_plain(report)
|
|
507
|
+
)
|
|
508
|
+
return (
|
|
509
|
+
_render_materialized_rich(report)
|
|
510
|
+
if _rich_available()
|
|
511
|
+
else _render_materialized_plain(report)
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
# ---------------------------------------------------------------------------
|
|
516
|
+
# Public API — single discoverable namespace
|
|
517
|
+
# ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class _Explore:
|
|
521
|
+
"""Callable namespace for dataset and experiment exploration.
|
|
522
|
+
|
|
523
|
+
Use ``explore(target)`` for auto-dispatch, or pick a specific entry
|
|
524
|
+
point via dot notation:
|
|
525
|
+
|
|
526
|
+
- ``explore(target)`` — auto-dispatch on a Dataset, DataFrame, or path
|
|
527
|
+
- ``explore.inventory(dataset)`` — pre-load inventory report
|
|
528
|
+
- ``explore.materialized(df)`` — post-load DataFrame report
|
|
529
|
+
- ``explore.path(p)`` — load and report from a directory / .pkl / .h5
|
|
530
|
+
- ``explore.render(report)`` — format a report as a string
|
|
531
|
+
|
|
532
|
+
Report dataclasses are also exposed for type hints:
|
|
533
|
+
``explore.DatasetInventoryReport``, ``explore.MaterializedReport``,
|
|
534
|
+
``explore.ColumnInfo``, ``explore.TagSummary``.
|
|
535
|
+
"""
|
|
536
|
+
|
|
537
|
+
# Report dataclasses (exposed for type hints + isinstance checks)
|
|
538
|
+
DatasetInventoryReport = DatasetInventoryReport
|
|
539
|
+
MaterializedReport = MaterializedReport
|
|
540
|
+
ColumnInfo = ColumnInfo
|
|
541
|
+
TagSummary = TagSummary
|
|
542
|
+
|
|
543
|
+
def __call__(
|
|
544
|
+
self,
|
|
545
|
+
target: Any,
|
|
546
|
+
*,
|
|
547
|
+
print_output: bool = True,
|
|
548
|
+
hdf_key: str = "dataset",
|
|
549
|
+
) -> DatasetInventoryReport | MaterializedReport:
|
|
550
|
+
"""Explore the structure of a Dataset or materialized DataFrame.
|
|
551
|
+
|
|
552
|
+
Parameters
|
|
553
|
+
----------
|
|
554
|
+
target
|
|
555
|
+
One of:
|
|
556
|
+
- :class:`datakit.Dataset`
|
|
557
|
+
- ``pandas.DataFrame`` (materialized result)
|
|
558
|
+
- ``Path`` or ``str`` to a directory (experiment root),
|
|
559
|
+
``.pkl``, or ``.h5`` / ``.hdf5`` file
|
|
560
|
+
print_output
|
|
561
|
+
If ``True`` (default), print a formatted summary to stdout.
|
|
562
|
+
hdf_key
|
|
563
|
+
HDF5 key used when ``target`` is an ``.h5`` file.
|
|
564
|
+
"""
|
|
565
|
+
from .core import Dataset
|
|
566
|
+
|
|
567
|
+
if isinstance(target, Dataset):
|
|
568
|
+
report: DatasetInventoryReport | MaterializedReport = explore_inventory(target)
|
|
569
|
+
elif isinstance(target, pd.DataFrame):
|
|
570
|
+
report = explore_materialized(target)
|
|
571
|
+
elif isinstance(target, (str, Path)):
|
|
572
|
+
report = self.path(Path(target), hdf_key=hdf_key, print_output=False)
|
|
573
|
+
else:
|
|
574
|
+
raise TypeError(f"Unsupported target type: {type(target).__name__}")
|
|
575
|
+
|
|
576
|
+
if print_output:
|
|
577
|
+
print(_render(report))
|
|
578
|
+
return report
|
|
579
|
+
|
|
580
|
+
def inventory(
|
|
581
|
+
self, dataset: Any, *, print_output: bool = False
|
|
582
|
+
) -> DatasetInventoryReport:
|
|
583
|
+
"""Build a pre-load :class:`DatasetInventoryReport` from a Dataset."""
|
|
584
|
+
report = explore_inventory(dataset)
|
|
585
|
+
if print_output:
|
|
586
|
+
print(_render(report))
|
|
587
|
+
return report
|
|
588
|
+
|
|
589
|
+
def materialized(
|
|
590
|
+
self, dataset: pd.DataFrame, *, print_output: bool = False
|
|
591
|
+
) -> MaterializedReport:
|
|
592
|
+
"""Build a post-load :class:`MaterializedReport` from a DataFrame."""
|
|
593
|
+
report = explore_materialized(dataset)
|
|
594
|
+
if print_output:
|
|
595
|
+
print(_render(report))
|
|
596
|
+
return report
|
|
597
|
+
|
|
598
|
+
def path(
|
|
599
|
+
self,
|
|
600
|
+
path: Union[str, Path],
|
|
601
|
+
*,
|
|
602
|
+
hdf_key: str = "dataset",
|
|
603
|
+
print_output: bool = False,
|
|
604
|
+
) -> DatasetInventoryReport | MaterializedReport:
|
|
605
|
+
"""Build a report from a directory, ``.pkl``, or ``.h5`` / ``.hdf5`` file."""
|
|
606
|
+
from .core import Dataset
|
|
607
|
+
|
|
608
|
+
p = Path(path).expanduser().resolve()
|
|
609
|
+
|
|
610
|
+
if p.is_dir():
|
|
611
|
+
report: DatasetInventoryReport | MaterializedReport = explore_inventory(
|
|
612
|
+
Dataset.from_directory(p)
|
|
613
|
+
)
|
|
614
|
+
else:
|
|
615
|
+
if not p.is_file():
|
|
616
|
+
raise FileNotFoundError(f"Path does not exist: {p}")
|
|
617
|
+
suffix = p.suffix.lower()
|
|
618
|
+
if suffix == ".pkl":
|
|
619
|
+
df = pd.read_pickle(p)
|
|
620
|
+
elif suffix in (".h5", ".hdf5"):
|
|
621
|
+
df = pd.read_hdf(p, key=hdf_key)
|
|
622
|
+
else:
|
|
623
|
+
raise ValueError(
|
|
624
|
+
f"Unsupported file type: {suffix} "
|
|
625
|
+
"(expected directory, .pkl, .h5, or .hdf5)"
|
|
626
|
+
)
|
|
627
|
+
if not isinstance(df, pd.DataFrame):
|
|
628
|
+
raise TypeError(f"Expected DataFrame, got {type(df).__name__}")
|
|
629
|
+
report = explore_materialized(df)
|
|
630
|
+
|
|
631
|
+
if print_output:
|
|
632
|
+
print(_render(report))
|
|
633
|
+
return report
|
|
634
|
+
|
|
635
|
+
@staticmethod
|
|
636
|
+
def render(report: DatasetInventoryReport | MaterializedReport) -> str:
|
|
637
|
+
"""Render a report as a formatted string (rich if available, else plain)."""
|
|
638
|
+
return _render(report)
|
|
639
|
+
|
|
640
|
+
def __repr__(self) -> str:
|
|
641
|
+
return (
|
|
642
|
+
"<datakit.explore — call as explore(target); see "
|
|
643
|
+
"explore.inventory, explore.materialized, explore.path, explore.render>"
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
explore = _Explore()
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
__all__ = ["explore"]
|
|
651
|
+
|