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,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
+