fullplot 0.0.1__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.
fullplot/fullplot.py ADDED
@@ -0,0 +1,1647 @@
1
+ from __future__ import annotations
2
+
3
+ import builtins
4
+ import itertools
5
+ import posixpath
6
+ from contextlib import nullcontext
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import h5py
12
+ import numpy as np
13
+ import matplotlib.pyplot as plt
14
+ from matplotlib.colors import LogNorm
15
+ from scipy.signal import butter, medfilt, savgol_filter, sosfiltfilt
16
+
17
+ from fullplot.hdf5 import hdf5_filename, safe_group_name
18
+ from fullplot.themes import (
19
+ apply_theme,
20
+ check_theme,
21
+ grid_kwargs,
22
+ style_colorbar,
23
+ style_legend,
24
+ theme_colors,
25
+ theme_settings,
26
+ )
27
+
28
+
29
+ class FullPlotError(Exception):
30
+ """Base exception for FullPlot errors."""
31
+
32
+
33
+ class DatasetNotFoundError(FullPlotError):
34
+ """Raised when a dataset or group cannot be found."""
35
+
36
+
37
+ class AmbiguousDatasetError(FullPlotError):
38
+ """Raised when a short dataset or group name matches more than one object."""
39
+
40
+
41
+ class PlotDataError(FullPlotError):
42
+ """Raised when selected data cannot be plotted."""
43
+
44
+
45
+ @dataclass
46
+ class LineSeries:
47
+ data: np.ndarray
48
+ label: str
49
+ length: int
50
+ x: np.ndarray | None = None
51
+ role: str = "data"
52
+
53
+
54
+ def _as_1d_numeric_array(values, name: str) -> np.ndarray:
55
+ array = np.asarray(values, dtype=float)
56
+
57
+ if array.ndim != 1:
58
+ raise PlotDataError(f"{name} must be one-dimensional.")
59
+
60
+ if array.size == 0:
61
+ raise PlotDataError(f"{name} cannot be empty.")
62
+
63
+ if not np.all(np.isfinite(array)):
64
+ raise PlotDataError(f"{name} must contain only finite values.")
65
+
66
+ return array
67
+
68
+
69
+ def _odd_window_count(count: int, minimum: int = 3) -> int:
70
+ count = int(count)
71
+ count = max(count, minimum)
72
+
73
+ if count % 2 == 0:
74
+ count += 1
75
+
76
+ return count
77
+
78
+
79
+ def _trace_window_count(x: np.ndarray, window, minimum: int = 3) -> int:
80
+ if window is None:
81
+ raise ValueError("filter window must be provided for this filter.")
82
+
83
+ if isinstance(window, (int, np.integer)):
84
+ return _odd_window_count(int(window), minimum=minimum)
85
+
86
+ window = float(window)
87
+
88
+ if window <= 0.0:
89
+ raise ValueError("filter window must be positive.")
90
+
91
+ if x.size < 2:
92
+ return _odd_window_count(1, minimum=minimum)
93
+
94
+ dx = np.median(np.diff(x))
95
+
96
+ if dx <= 0.0 or not np.isfinite(dx):
97
+ raise ValueError("time-based filter windows require a strictly increasing x array.")
98
+
99
+ return _odd_window_count(round(window / dx), minimum=minimum)
100
+
101
+
102
+ def _line_style_for_role(role: str, theme: str, color, linewidth: float) -> dict:
103
+ role = str(role or "data").lower().strip()
104
+
105
+ if role == "redline":
106
+ return {"color": "#ff3333", "linestyle": "--", "linewidth": linewidth}
107
+
108
+ if role == "blueline":
109
+ return {"color": "#3399ff", "linestyle": "--", "linewidth": linewidth}
110
+
111
+ if role in {"yellowline", "amberline", "warning"}:
112
+ return {"color": "#ffcc00", "linestyle": "--", "linewidth": linewidth}
113
+
114
+ if role == "greenline":
115
+ return {"color": "#00cc66", "linestyle": "--", "linewidth": linewidth}
116
+
117
+ if role == "filtered":
118
+ return {"color": color, "linestyle": "--", "linewidth": linewidth}
119
+
120
+ if role == "command":
121
+ return {"color": color, "linestyle": "-", "linewidth": linewidth, "drawstyle": "steps-post"}
122
+
123
+ return {"color": color, "linestyle": "-", "linewidth": linewidth}
124
+
125
+
126
+ @dataclass
127
+ class Trace:
128
+ """A prepared one-dimensional line of data.
129
+
130
+ A Trace is intentionally generic. It can represent simulation output, test
131
+ data, a filtered signal, a command, a redline, a blueline, or any other
132
+ one-dimensional data series. FullPlot uses it for overlaying data from
133
+ different files and for generated lines that do not already exist in HDF5.
134
+ """
135
+
136
+ x: np.ndarray
137
+ y: np.ndarray
138
+ name: str = "trace"
139
+ role: str = "data"
140
+ attrs: dict[str, Any] | None = None
141
+
142
+ def __post_init__(self):
143
+ self.x = _as_1d_numeric_array(self.x, "Trace x")
144
+ self.y = _as_1d_numeric_array(self.y, "Trace y")
145
+
146
+ if self.x.shape != self.y.shape:
147
+ raise PlotDataError(
148
+ f"Trace x and y must have the same length. "
149
+ f"Received {len(self.x)} and {len(self.y)}."
150
+ )
151
+
152
+ self.name = str(self.name)
153
+ self.role = str(self.role or "data")
154
+ self.attrs = {} if self.attrs is None else dict(self.attrs)
155
+
156
+ @property
157
+ def time(self) -> np.ndarray:
158
+ return self.x
159
+
160
+ @property
161
+ def value(self) -> np.ndarray:
162
+ return self.y
163
+
164
+ @classmethod
165
+ def from_arrays(cls, name: str, x, y, role: str = "data", **attrs) -> "Trace":
166
+ return cls(x=np.asarray(x, dtype=float), y=np.asarray(y, dtype=float), name=name, role=role, attrs=attrs)
167
+
168
+ @classmethod
169
+ def constant(cls, name: str, x, y: float, role: str = "data", **attrs) -> "Trace":
170
+ x_array = _as_1d_numeric_array(x, "Trace x")
171
+ return cls(x=x_array, y=np.full_like(x_array, float(y), dtype=float), name=name, role=role, attrs=attrs)
172
+
173
+ @classmethod
174
+ def from_points(cls, name: str, points, x=None, mode: str = "linear", role: str = "data", **attrs) -> "Trace":
175
+ points = np.asarray(points, dtype=float)
176
+
177
+ if points.ndim != 2 or points.shape[1] != 2:
178
+ raise PlotDataError("points must be an array-like sequence of (x, y) pairs.")
179
+
180
+ xp = points[:, 0]
181
+ yp = points[:, 1]
182
+ order = np.argsort(xp)
183
+ xp = xp[order]
184
+ yp = yp[order]
185
+
186
+ if x is None:
187
+ x_array = xp
188
+ else:
189
+ x_array = _as_1d_numeric_array(x, "Trace x")
190
+
191
+ mode = str(mode).lower().strip()
192
+
193
+ if mode == "linear":
194
+ y_array = np.interp(x_array, xp, yp)
195
+ elif mode in {"previous", "step", "steps-post", "hold"}:
196
+ indices = np.searchsorted(xp, x_array, side="right") - 1
197
+ indices = np.clip(indices, 0, len(yp) - 1)
198
+ y_array = yp[indices]
199
+ else:
200
+ raise ValueError("mode must be 'linear' or 'previous'.")
201
+
202
+ return cls(x=x_array, y=y_array, name=name, role=role, attrs=attrs)
203
+
204
+ @classmethod
205
+ def from_function(cls, name: str, x, function, role: str = "data", **attrs) -> "Trace":
206
+ x_array = _as_1d_numeric_array(x, "Trace x")
207
+ y_array = np.asarray(function(x_array), dtype=float)
208
+ return cls(x=x_array, y=y_array, name=name, role=role, attrs=attrs)
209
+
210
+ def copy(self, name: str | None = None, role: str | None = None, **attrs) -> "Trace":
211
+ merged_attrs = dict(self.attrs)
212
+ merged_attrs.update(attrs)
213
+ return Trace(
214
+ x=self.x.copy(),
215
+ y=self.y.copy(),
216
+ name=self.name if name is None else name,
217
+ role=self.role if role is None else role,
218
+ attrs=merged_attrs,
219
+ )
220
+
221
+ def window(self, start: float | None = None, stop: float | None = None, name: str | None = None) -> "Trace":
222
+ mask = np.ones_like(self.x, dtype=bool)
223
+
224
+ if start is not None:
225
+ mask &= self.x >= float(start)
226
+
227
+ if stop is not None:
228
+ mask &= self.x <= float(stop)
229
+
230
+ return Trace(x=self.x[mask], y=self.y[mask], name=self.name if name is None else name, role=self.role, attrs=self.attrs)
231
+
232
+ def filter(self, method: str = "moving_average", window=None, order: int = 2, cutoff: float | None = None, name: str | None = None) -> "Trace":
233
+ method = str(method).lower().strip().replace("-", "_").replace(" ", "_")
234
+
235
+ if method in {"moving_average", "mean", "average"}:
236
+ count = _trace_window_count(self.x, window, minimum=1)
237
+ kernel = np.ones(count, dtype=float) / count
238
+ y = np.convolve(self.y, kernel, mode="same")
239
+
240
+ elif method in {"median", "median_filter"}:
241
+ count = _trace_window_count(self.x, window, minimum=3)
242
+ y = medfilt(self.y, kernel_size=count)
243
+
244
+ elif method in {"savgol", "savitzky_golay", "savitzky-golay"}:
245
+ count = _trace_window_count(self.x, window, minimum=int(order) + 2)
246
+ if count <= order:
247
+ count = _odd_window_count(order + 2, minimum=order + 2)
248
+ if count > len(self.y):
249
+ count = _odd_window_count(len(self.y) if len(self.y) % 2 else len(self.y) - 1, minimum=3)
250
+ y = savgol_filter(self.y, window_length=count, polyorder=int(order), mode="interp")
251
+
252
+ elif method in {"lowpass", "low_pass"}:
253
+ if cutoff is None:
254
+ raise ValueError("lowpass filtering requires cutoff in Hz or cycles per x-unit.")
255
+ if self.x.size < 2:
256
+ raise PlotDataError("lowpass filtering requires at least two samples.")
257
+ dx = np.median(np.diff(self.x))
258
+ if dx <= 0.0 or not np.isfinite(dx):
259
+ raise ValueError("lowpass filtering requires a strictly increasing x array.")
260
+ fs = 1.0 / dx
261
+ normalized_cutoff = float(cutoff) / (0.5 * fs)
262
+ if not 0.0 < normalized_cutoff < 1.0:
263
+ raise ValueError("lowpass cutoff must be between 0 and the Nyquist frequency.")
264
+ sos = butter(N=4, Wn=normalized_cutoff, btype="lowpass", output="sos")
265
+ y = sosfiltfilt(sos, self.y)
266
+
267
+ else:
268
+ raise ValueError("Unsupported filter method. Use moving_average, median, savgol, or lowpass.")
269
+
270
+ attrs = dict(self.attrs)
271
+ attrs.update({"filter": method, "window": window, "order": order, "cutoff": cutoff, "source": self.name})
272
+
273
+ return Trace(
274
+ x=self.x.copy(),
275
+ y=np.asarray(y, dtype=float),
276
+ name=(self.name + " filtered") if name is None else name,
277
+ role="filtered",
278
+ attrs=attrs,
279
+ )
280
+
281
+ def scale(self, factor: float, name: str | None = None) -> "Trace":
282
+ return Trace(x=self.x.copy(), y=self.y * float(factor), name=self.name if name is None else name, role=self.role, attrs=self.attrs)
283
+
284
+ def offset(self, value: float, name: str | None = None) -> "Trace":
285
+ return Trace(x=self.x.copy(), y=self.y + float(value), name=self.name if name is None else name, role=self.role, attrs=self.attrs)
286
+
287
+ def derivative(self, name: str | None = None) -> "Trace":
288
+ return Trace(x=self.x.copy(), y=np.gradient(self.y, self.x), name=(self.name + " derivative") if name is None else name, role=self.role, attrs=self.attrs)
289
+
290
+ def resample(self, x, name: str | None = None) -> "Trace":
291
+ x_array = _as_1d_numeric_array(x, "resample x")
292
+ return Trace(x=x_array, y=np.interp(x_array, self.x, self.y), name=self.name if name is None else name, role=self.role, attrs=self.attrs)
293
+
294
+ def _binary_operation(self, other, operation, symbol: str) -> "Trace":
295
+ if isinstance(other, Trace):
296
+ other_y = other.resample(self.x).y
297
+ name = f"{self.name} {symbol} {other.name}"
298
+ else:
299
+ other_y = other
300
+ name = f"{self.name} {symbol} {other}"
301
+
302
+ return Trace(x=self.x.copy(), y=operation(self.y, other_y), name=name, role="derived", attrs={"left": self.name, "operation": symbol})
303
+
304
+ def __add__(self, other):
305
+ return self._binary_operation(other, np.add, "+")
306
+
307
+ def __sub__(self, other):
308
+ return self._binary_operation(other, np.subtract, "-")
309
+
310
+ def __mul__(self, other):
311
+ return self._binary_operation(other, np.multiply, "*")
312
+
313
+ def __truediv__(self, other):
314
+ return self._binary_operation(other, np.divide, "/")
315
+
316
+
317
+ def _is_numeric_dtype(dtype) -> bool:
318
+ try:
319
+ return np.issubdtype(dtype, np.number)
320
+ except TypeError:
321
+ return False
322
+
323
+
324
+ def _shape_string(shape) -> str:
325
+ if shape == ():
326
+ return "scalar"
327
+
328
+ return str(shape)
329
+
330
+
331
+ def _clean_root(root: str) -> str:
332
+ root = str(root).strip()
333
+
334
+ if root == "":
335
+ root = "/"
336
+
337
+ if not root.startswith("/"):
338
+ root = "/" + root
339
+
340
+ root = posixpath.normpath(root)
341
+
342
+ if root == ".":
343
+ root = "/"
344
+
345
+ return root
346
+
347
+
348
+ def _join_h5(root: str, name: str) -> str:
349
+ root = _clean_root(root)
350
+ name = str(name).strip()
351
+
352
+ if name.startswith("/"):
353
+ return posixpath.normpath(name)
354
+
355
+ if root == "/":
356
+ return posixpath.normpath("/" + name)
357
+
358
+ return posixpath.normpath(root + "/" + name)
359
+
360
+
361
+ def _basename(path: str) -> str:
362
+ path = str(path).rstrip("/")
363
+
364
+ if path == "":
365
+ return ""
366
+
367
+ return path.split("/")[-1]
368
+
369
+
370
+ def _relative_path(path: str, root: str) -> str:
371
+ path = _clean_root(path)
372
+ root = _clean_root(root)
373
+
374
+ if root == "/":
375
+ return path.lstrip("/")
376
+
377
+ prefix = root.rstrip("/") + "/"
378
+
379
+ if path.startswith(prefix):
380
+ return path[len(prefix):]
381
+
382
+ return path
383
+
384
+
385
+ def _normalize_name(name: str) -> str:
386
+ name = str(name).lower().strip()
387
+
388
+ for char in [" ", "\t", "\n", "\r", "_", "-", "[", "]", "(", ")", "{", "}", ".", ",", "/"]:
389
+ name = name.replace(char, "")
390
+
391
+ return name
392
+
393
+
394
+ def _as_list(value) -> builtins.list:
395
+ if value is None:
396
+ return []
397
+
398
+ if isinstance(value, (builtins.list, tuple)):
399
+ return builtins.list(value)
400
+
401
+ return [value]
402
+
403
+
404
+ def _decode_scalar(value):
405
+ if isinstance(value, bytes):
406
+ return value.decode(errors="replace")
407
+
408
+ if isinstance(value, np.generic):
409
+ return value.item()
410
+
411
+ return value
412
+
413
+
414
+ def _check_scale(scale: str) -> str:
415
+ scale = str(scale).lower().strip()
416
+
417
+ if scale not in ("linear", "log"):
418
+ raise ValueError("scale must be either 'linear' or 'log'.")
419
+
420
+ return scale
421
+
422
+
423
+ def _check_log_positive(values, name: str):
424
+ values = np.asarray(values)
425
+
426
+ if values.size == 0:
427
+ raise PlotDataError(f"{name} is empty and cannot be plotted on a log scale.")
428
+
429
+ finite_values = values[np.isfinite(values)]
430
+
431
+ if finite_values.size == 0:
432
+ raise PlotDataError(f"{name} has no finite values and cannot be plotted on a log scale.")
433
+
434
+ if np.any(finite_values <= 0.0):
435
+ raise PlotDataError(f"{name} contains zero or negative values and cannot be plotted on a log scale.")
436
+
437
+
438
+ def _apply_plot_scales(ax, ax2, x_array, left_series, right_series, xscale, yscale, y2scale):
439
+ xscale = _check_scale(xscale)
440
+ yscale = _check_scale(yscale)
441
+ y2scale = _check_scale(y2scale)
442
+
443
+ all_series = builtins.list(left_series) + builtins.list(right_series)
444
+
445
+ if xscale == "log":
446
+ if x_array is not None:
447
+ _check_log_positive(x_array, "x")
448
+
449
+ for series in all_series:
450
+ if series.x is not None:
451
+ _check_log_positive(series.x, f"x for {series.label}")
452
+
453
+ if yscale == "log":
454
+ for series in left_series:
455
+ _check_log_positive(series.data, series.label)
456
+
457
+ if y2scale == "log":
458
+ for series in right_series:
459
+ _check_log_positive(series.data, series.label)
460
+
461
+ ax.set_xscale(xscale)
462
+ ax.set_yscale(yscale)
463
+
464
+ if ax2 is not None:
465
+ ax2.set_yscale(y2scale)
466
+
467
+
468
+ def _normalize_axis(axis: int, ndim: int) -> int:
469
+ axis = int(axis)
470
+
471
+ if ndim <= 0:
472
+ raise PlotDataError("Cannot select an axis from scalar data.")
473
+
474
+ if axis < 0:
475
+ axis += ndim
476
+
477
+ if axis < 0 or axis >= ndim:
478
+ raise PlotDataError(f"axis {axis} is out of range for array with {ndim} dimensions.")
479
+
480
+ return axis
481
+
482
+
483
+ def _is_integer_index(value) -> bool:
484
+ return isinstance(value, (int, np.integer))
485
+
486
+
487
+ def _normalize_slice_spec(slice_spec, ndim: int) -> dict[int, object]:
488
+ if slice_spec is None:
489
+ return {}
490
+
491
+ if not isinstance(slice_spec, dict):
492
+ raise PlotDataError("slice must be a dictionary such as {0: 3} or {2: (0, 10)}.")
493
+
494
+ normalized = {}
495
+
496
+ for axis, value in slice_spec.items():
497
+ axis = int(axis)
498
+
499
+ if axis < 0:
500
+ axis += ndim
501
+
502
+ if axis < 0 or axis >= ndim:
503
+ raise PlotDataError(f"slice axis {axis} is out of range for array with {ndim} dimensions.")
504
+
505
+ normalized[axis] = value
506
+
507
+ return normalized
508
+
509
+
510
+ def _to_numpy_index(value):
511
+ if isinstance(value, builtins.slice):
512
+ return value
513
+
514
+ if isinstance(value, tuple) or isinstance(value, builtins.list):
515
+ if len(value) == 2:
516
+ return builtins.slice(value[0], value[1])
517
+
518
+ if len(value) == 3:
519
+ return builtins.slice(value[0], value[1], value[2])
520
+
521
+ raise PlotDataError("slice tuple/list values must have length 2 or 3.")
522
+
523
+ return value
524
+
525
+
526
+ def _apply_slice_for_lines(array: np.ndarray, axis: int, slice_spec) -> tuple[np.ndarray, int]:
527
+ array = np.asarray(array)
528
+
529
+ if array.ndim == 0:
530
+ raise PlotDataError("Cannot line-plot scalar data.")
531
+
532
+ original_axis = _normalize_axis(axis, array.ndim)
533
+ normalized_slice = _normalize_slice_spec(slice_spec, array.ndim)
534
+
535
+ if original_axis in normalized_slice and _is_integer_index(normalized_slice[original_axis]):
536
+ raise PlotDataError("The plotted axis cannot also be removed with an integer slice.")
537
+
538
+ indexer = [builtins.slice(None)] * array.ndim
539
+
540
+ for slice_axis, value in normalized_slice.items():
541
+ indexer[slice_axis] = _to_numpy_index(value)
542
+
543
+ sliced = array[tuple(indexer)]
544
+
545
+ removed_before_axis = 0
546
+
547
+ for slice_axis, value in normalized_slice.items():
548
+ if slice_axis < original_axis and _is_integer_index(value):
549
+ removed_before_axis += 1
550
+
551
+ new_axis = original_axis - removed_before_axis
552
+
553
+ return np.asarray(sliced), new_axis
554
+
555
+
556
+ def _apply_slice_for_map(array: np.ndarray, slice_spec) -> np.ndarray:
557
+ array = np.asarray(array)
558
+
559
+ if slice_spec is None:
560
+ return array
561
+
562
+ normalized_slice = _normalize_slice_spec(slice_spec, array.ndim)
563
+ indexer = [builtins.slice(None)] * array.ndim
564
+
565
+ for slice_axis, value in normalized_slice.items():
566
+ indexer[slice_axis] = _to_numpy_index(value)
567
+
568
+ return np.asarray(array[tuple(indexer)])
569
+
570
+
571
+ @dataclass
572
+ class H5File:
573
+ filename: str | Path
574
+ root: str = "/"
575
+
576
+ def __post_init__(self):
577
+ self.filename = str(self.filename)
578
+ self.root = _clean_root(self.root)
579
+
580
+ def at(self, group: str) -> "H5File":
581
+ """
582
+ Return a new H5File scoped to a specific HDF5 group.
583
+
584
+ Example
585
+ -------
586
+
587
+ file = fplt.open("run.h5")
588
+ run = file.at("/Pipe_Network/transient/runs/base")
589
+ run.plot(x="time", y="tracks/Pipe_Mass_Flow_[kg_s]")
590
+ """
591
+
592
+ with h5py.File(self.filename, "r") as h5:
593
+ group_path = self._resolve_group_path(h5, group)
594
+
595
+ return H5File(self.filename, group_path)
596
+
597
+ def tree(self, max_depth: int | None = None, print_output: bool = True) -> str:
598
+ """
599
+ Print the HDF5 tree under the current root.
600
+ """
601
+
602
+ with h5py.File(self.filename, "r") as h5:
603
+ if self.root not in h5:
604
+ raise DatasetNotFoundError(f"Root path {self.root!r} was not found in {self.filename!r}.")
605
+
606
+ lines = [f"{Path(self.filename).name}:{self.root}"]
607
+
608
+ root_object = h5[self.root]
609
+
610
+ if isinstance(root_object, h5py.Dataset):
611
+ lines.append(self._dataset_tree_line(root_object, prefix="└── "))
612
+ else:
613
+ self._append_tree_lines(
614
+ lines=lines,
615
+ group=root_object,
616
+ prefix="",
617
+ depth=0,
618
+ max_depth=max_depth,
619
+ )
620
+
621
+ text = "\n".join(lines)
622
+
623
+ if print_output:
624
+ print(text)
625
+
626
+ return text
627
+
628
+ def list(self, print_output: bool = True) -> str:
629
+ """
630
+ Print numeric and non-numeric datasets under the current root.
631
+ """
632
+
633
+ with h5py.File(self.filename, "r") as h5:
634
+ paths = self._dataset_paths(h5)
635
+
636
+ one_d = []
637
+ two_d = []
638
+ multi_d = []
639
+ scalars = []
640
+ non_numeric = []
641
+
642
+ for path in paths:
643
+ dataset = h5[path]
644
+ rel = _relative_path(path, self.root)
645
+ shape = dataset.shape
646
+ dtype = dataset.dtype
647
+
648
+ if not _is_numeric_dtype(dtype):
649
+ non_numeric.append((rel, shape, dtype))
650
+ continue
651
+
652
+ if shape == ():
653
+ scalars.append((rel, shape, dtype))
654
+ elif len(shape) == 1:
655
+ one_d.append((rel, shape, dtype))
656
+ elif len(shape) == 2:
657
+ two_d.append((rel, shape, dtype))
658
+ else:
659
+ multi_d.append((rel, shape, dtype))
660
+
661
+ lines = [f"{Path(self.filename).name}:{self.root}", ""]
662
+
663
+ if one_d:
664
+ lines.append("1D numeric datasets:")
665
+ lines.extend(self._format_dataset_rows(one_d))
666
+ lines.append("")
667
+
668
+ if two_d:
669
+ lines.append("2D numeric datasets:")
670
+ lines.extend(self._format_dataset_rows(two_d))
671
+ lines.append("")
672
+
673
+ if multi_d:
674
+ lines.append("3D+ numeric datasets:")
675
+ lines.extend(self._format_dataset_rows(multi_d))
676
+ lines.append("")
677
+
678
+ if scalars:
679
+ lines.append("Scalar numeric datasets:")
680
+ lines.extend(self._format_dataset_rows(scalars))
681
+ lines.append("")
682
+
683
+ if non_numeric:
684
+ lines.append("Non-numeric datasets:")
685
+ lines.extend(self._format_dataset_rows(non_numeric))
686
+ lines.append("")
687
+
688
+ text = "\n".join(lines).rstrip()
689
+
690
+ if print_output:
691
+ print(text)
692
+
693
+ return text
694
+
695
+ def values(self, group: str | None = None, print_output: bool = True) -> dict[str, Any]:
696
+ """
697
+ Read scalar datasets under the current root or under a selected group.
698
+ """
699
+
700
+ with h5py.File(self.filename, "r") as h5:
701
+ if group is None:
702
+ root = self.root
703
+ else:
704
+ root = self._resolve_group_path(h5, group)
705
+
706
+ values = {}
707
+
708
+ if isinstance(h5[root], h5py.Dataset):
709
+ dataset = h5[root]
710
+
711
+ if dataset.shape == ():
712
+ values[_basename(root)] = _decode_scalar(dataset[()])
713
+
714
+ else:
715
+ paths = self._dataset_paths(h5, root=root)
716
+
717
+ for path in paths:
718
+ dataset = h5[path]
719
+
720
+ if dataset.shape == ():
721
+ rel = _relative_path(path, root)
722
+ values[rel] = _decode_scalar(dataset[()])
723
+
724
+ if print_output:
725
+ if values:
726
+ width = max(len(name) for name in values)
727
+
728
+ for name, value in values.items():
729
+ print(f"{name:<{width}} {value}")
730
+ else:
731
+ print("No scalar datasets found.")
732
+
733
+ return values
734
+
735
+ def read(self, name: str):
736
+ """
737
+ Read a dataset by absolute path, relative path, or unique short name.
738
+ """
739
+
740
+ with h5py.File(self.filename, "r") as h5:
741
+ path = self._resolve_dataset_path(h5, name)
742
+ return h5[path][()]
743
+
744
+ def traces(self) -> list[str]:
745
+ """Return numeric one-dimensional dataset paths under the current root."""
746
+
747
+ with h5py.File(self.filename, "r") as h5:
748
+ names = []
749
+
750
+ for path in self._dataset_paths(h5):
751
+ dataset = h5[path]
752
+
753
+ if dataset.shape != () and len(dataset.shape) == 1 and _is_numeric_dtype(dataset.dtype):
754
+ names.append(_relative_path(path, self.root))
755
+
756
+ return names
757
+
758
+ def trace(
759
+ self,
760
+ y,
761
+ x=None,
762
+ slice=None,
763
+ axis: int = -1,
764
+ name: str | None = None,
765
+ role: str = "data",
766
+ ) -> Trace:
767
+ """Read one HDF5 dataset as a reusable Trace."""
768
+
769
+ if isinstance(y, Trace):
770
+ return y.copy(name=name, role=role)
771
+
772
+ with h5py.File(self.filename, "r") as h5:
773
+ x_array = None
774
+
775
+ if x is not None:
776
+ x_path = self._resolve_dataset_path(h5, x)
777
+ x_array = np.asarray(h5[x_path][()])
778
+
779
+ if x_array.ndim != 1:
780
+ raise PlotDataError("x must be a 1D dataset.")
781
+
782
+ if not _is_numeric_dtype(x_array.dtype):
783
+ raise PlotDataError("x must be numeric.")
784
+
785
+ series = self._build_line_series(
786
+ h5=h5,
787
+ selector=y,
788
+ axis=axis,
789
+ slice_spec=slice,
790
+ )
791
+
792
+ if len(series) != 1:
793
+ raise PlotDataError(
794
+ f"trace() expected one 1D trace, but {len(series)} traces were produced. "
795
+ "Use plot() directly for multidimensional trace expansion."
796
+ )
797
+
798
+ series_item = series[0]
799
+
800
+ if x_array is None:
801
+ x_array = np.arange(series_item.length, dtype=float)
802
+
803
+ if len(x_array) != series_item.length:
804
+ raise PlotDataError(
805
+ f"Trace {series_item.label!r} has length {series_item.length}, "
806
+ f"but x has length {len(x_array)}."
807
+ )
808
+
809
+ return Trace(
810
+ x=x_array,
811
+ y=series_item.data,
812
+ name=series_item.label if name is None else name,
813
+ role=role,
814
+ attrs={"source_file": self.filename, "source_root": self.root, "source_dataset": str(y)},
815
+ )
816
+
817
+ def _series_from_selector(self, h5: h5py.File, selector, axis: int, slice_spec) -> builtins.list[LineSeries]:
818
+ if isinstance(selector, Trace):
819
+ return [
820
+ LineSeries(
821
+ data=selector.y,
822
+ label=selector.name,
823
+ length=len(selector.y),
824
+ x=selector.x,
825
+ role=selector.role,
826
+ )
827
+ ]
828
+
829
+ return self._build_line_series(
830
+ h5=h5,
831
+ selector=selector,
832
+ axis=axis,
833
+ slice_spec=slice_spec,
834
+ )
835
+
836
+ def plot(
837
+ self,
838
+ y=None,
839
+ x=None,
840
+ y2=None,
841
+ slice=None,
842
+ axis: int = -1,
843
+ labels=None,
844
+ y2labels=None,
845
+ xlabel: str | None = None,
846
+ ylabel: str | None = None,
847
+ y2label: str | None = None,
848
+ title: str | None = None,
849
+ xscale: str = "linear",
850
+ yscale: str = "linear",
851
+ y2scale: str = "linear",
852
+ legend: bool = True,
853
+ legend_location: str = "best",
854
+ grid: bool = True,
855
+ theme: str = "dark",
856
+ save: str | Path | None = None,
857
+ dpi: int = 200,
858
+ show: bool = True,
859
+ figsize=(9, 5),
860
+ linewidth: float = 1.6,
861
+ ):
862
+ """Plot HDF5 datasets and/or Trace objects."""
863
+
864
+ theme = check_theme(theme)
865
+
866
+ if y is None and y2 is None:
867
+ raise PlotDataError("plot requires y, y2, or both.")
868
+
869
+ with h5py.File(self.filename, "r") if self.filename else nullcontext(None) as h5:
870
+ x_array = None
871
+ x_name = None
872
+
873
+ if x is not None:
874
+ if isinstance(x, Trace):
875
+ x_array = x.y
876
+ x_name = x.name
877
+ else:
878
+ if h5 is None:
879
+ x_array = np.asarray(x)
880
+ x_name = "x"
881
+ else:
882
+ x_path = self._resolve_dataset_path(h5, x)
883
+ x_name = _basename(x_path)
884
+ x_array = np.asarray(h5[x_path][()])
885
+
886
+ if x_array.ndim != 1:
887
+ raise PlotDataError("x must be a 1D dataset, array, or Trace.")
888
+
889
+ if not _is_numeric_dtype(x_array.dtype):
890
+ raise PlotDataError("x must be numeric.")
891
+
892
+ left_series = []
893
+ right_series = []
894
+
895
+ for selector in _as_list(y):
896
+ left_series.extend(
897
+ self._series_from_selector(
898
+ h5=h5,
899
+ selector=selector,
900
+ axis=axis,
901
+ slice_spec=slice,
902
+ )
903
+ )
904
+
905
+ for selector in _as_list(y2):
906
+ right_series.extend(
907
+ self._series_from_selector(
908
+ h5=h5,
909
+ selector=selector,
910
+ axis=axis,
911
+ slice_spec=slice,
912
+ )
913
+ )
914
+
915
+ all_series = left_series + right_series
916
+
917
+ if not all_series:
918
+ raise PlotDataError("No y data was selected.")
919
+
920
+ dataset_series = [series for series in all_series if series.x is None]
921
+
922
+ if x_array is None and dataset_series:
923
+ x_length = dataset_series[0].length
924
+ x_array = np.arange(x_length)
925
+ x_name = "Index"
926
+ elif x_array is not None:
927
+ x_length = len(x_array)
928
+ else:
929
+ x_length = None
930
+ x_name = "x"
931
+
932
+ if x_array is not None:
933
+ for series in dataset_series:
934
+ if series.length != x_length:
935
+ raise PlotDataError(
936
+ f"Trace {series.label!r} has length {series.length}, "
937
+ f"but x has length {x_length}."
938
+ )
939
+
940
+ for series in all_series:
941
+ if series.x is not None:
942
+ if len(series.x) != series.length:
943
+ raise PlotDataError(f"Trace {series.label!r} has mismatched x and y lengths.")
944
+ if not _is_numeric_dtype(series.x.dtype):
945
+ raise PlotDataError(f"Trace {series.label!r} has non-numeric x data.")
946
+
947
+ self._apply_user_labels(left_series, labels, "labels")
948
+ self._apply_user_labels(right_series, y2labels, "y2labels")
949
+
950
+ fig, ax = plt.subplots(figsize=figsize)
951
+
952
+ ax2 = None
953
+
954
+ if right_series:
955
+ ax2 = ax.twinx()
956
+
957
+ apply_theme(fig, [ax, ax2], theme)
958
+
959
+ colors = itertools.cycle(theme_colors(theme))
960
+
961
+ for series in left_series:
962
+ color = next(colors)
963
+ x_plot = series.x if series.x is not None else x_array
964
+ style = _line_style_for_role(series.role, theme, color, linewidth)
965
+ ax.plot(x_plot, series.data, label=series.label, **style)
966
+
967
+ if ax2 is not None:
968
+ for series in right_series:
969
+ color = next(colors)
970
+ x_plot = series.x if series.x is not None else x_array
971
+ style = _line_style_for_role(series.role, theme, color, linewidth)
972
+ ax2.plot(x_plot, series.data, label=series.label, **style)
973
+
974
+ if xlabel is None:
975
+ xlabel = x_name
976
+
977
+ if ylabel is None:
978
+ if len(left_series) == 1 and not right_series:
979
+ ylabel = left_series[0].label
980
+ else:
981
+ ylabel = "Value"
982
+
983
+ ax.set_xlabel(xlabel)
984
+ ax.set_ylabel(ylabel)
985
+
986
+ if ax2 is not None:
987
+ if y2label is None:
988
+ if len(right_series) == 1:
989
+ y2label = right_series[0].label
990
+ else:
991
+ y2label = "Value"
992
+
993
+ ax2.set_ylabel(y2label)
994
+
995
+ if title is not None:
996
+ ax.set_title(title)
997
+
998
+ _apply_plot_scales(
999
+ ax=ax,
1000
+ ax2=ax2,
1001
+ x_array=x_array,
1002
+ left_series=left_series,
1003
+ right_series=right_series,
1004
+ xscale=xscale,
1005
+ yscale=yscale,
1006
+ y2scale=y2scale,
1007
+ )
1008
+
1009
+ if grid:
1010
+ ax.grid(True, **grid_kwargs(theme))
1011
+ else:
1012
+ ax.grid(False)
1013
+
1014
+ if legend:
1015
+ handles, legend_labels = ax.get_legend_handles_labels()
1016
+
1017
+ if ax2 is not None:
1018
+ handles2, legend_labels2 = ax2.get_legend_handles_labels()
1019
+ handles += handles2
1020
+ legend_labels += legend_labels2
1021
+
1022
+ if handles:
1023
+ legend_object = ax.legend(handles, legend_labels, loc=legend_location)
1024
+ style_legend(legend_object, theme)
1025
+
1026
+ fig.tight_layout()
1027
+
1028
+ self._save_and_show(fig=fig, save=save, dpi=dpi, show=show)
1029
+
1030
+ if ax2 is not None:
1031
+ return fig, (ax, ax2)
1032
+
1033
+ return fig, ax
1034
+
1035
+ def map(
1036
+ self,
1037
+ z,
1038
+ x=None,
1039
+ y=None,
1040
+ slice=None,
1041
+ xlabel: str | None = None,
1042
+ ylabel: str | None = None,
1043
+ zlabel: str | None = None,
1044
+ title: str | None = None,
1045
+ xscale: str = "linear",
1046
+ yscale: str = "linear",
1047
+ zscale: str = "linear",
1048
+ grid: bool = False,
1049
+ theme: str = "dark",
1050
+ save: str | Path | None = None,
1051
+ dpi: int = 200,
1052
+ show: bool = True,
1053
+ figsize=(8, 6),
1054
+ ):
1055
+ """
1056
+ Plot a 2D dataset as a map.
1057
+
1058
+ Parameters
1059
+ ----------
1060
+ z:
1061
+ One of the following:
1062
+
1063
+ - A 2D dataset name/path.
1064
+ - A multidimensional dataset reduced to 2D with slice.
1065
+ - A list of 1D dataset names/paths, which will be stacked into
1066
+ a 2D array. This is useful for heat maps built from several
1067
+ separate time histories.
1068
+
1069
+ x:
1070
+ Optional 1D x-axis dataset or array-like values.
1071
+
1072
+ y:
1073
+ Optional 1D y-axis dataset or array-like values.
1074
+
1075
+ slice:
1076
+ Optional integer-index slicing for 3D+ arrays.
1077
+
1078
+ xscale, yscale, zscale:
1079
+ Axis and color scale options. Use "linear" or "log". A log scale
1080
+ only changes the displayed scale; it does not transform stored data.
1081
+ """
1082
+
1083
+ theme = check_theme(theme)
1084
+
1085
+ with h5py.File(self.filename, "r") as h5:
1086
+ z_array, z_name = self._read_map_z(h5, z)
1087
+
1088
+ z_array = _apply_slice_for_map(z_array, slice)
1089
+
1090
+ if z_array.ndim != 2:
1091
+ raise PlotDataError(
1092
+ f"map requires 2D data after slicing. "
1093
+ f"Selected data has shape {z_array.shape}."
1094
+ )
1095
+
1096
+ rows, cols = z_array.shape
1097
+
1098
+ x_array, x_name = self._read_map_axis(
1099
+ h5=h5,
1100
+ value=x,
1101
+ default=np.arange(cols),
1102
+ default_name="Column Index",
1103
+ valid_lengths=(cols, cols + 1),
1104
+ axis_name="x",
1105
+ )
1106
+
1107
+ y_array, y_name = self._read_map_axis(
1108
+ h5=h5,
1109
+ value=y,
1110
+ default=np.arange(rows),
1111
+ default_name="Row Index",
1112
+ valid_lengths=(rows, rows + 1),
1113
+ axis_name="y",
1114
+ )
1115
+
1116
+ fig, ax = plt.subplots(figsize=figsize)
1117
+ apply_theme(fig, ax, theme)
1118
+
1119
+ xscale = _check_scale(xscale)
1120
+ yscale = _check_scale(yscale)
1121
+ zscale = _check_scale(zscale)
1122
+
1123
+ if xscale == "log":
1124
+ _check_log_positive(x_array, "x")
1125
+
1126
+ if yscale == "log":
1127
+ _check_log_positive(y_array, "y")
1128
+
1129
+ norm = None
1130
+
1131
+ if zscale == "log":
1132
+ _check_log_positive(z_array, "z")
1133
+ norm = LogNorm()
1134
+
1135
+ ax.set_xscale(xscale)
1136
+ ax.set_yscale(yscale)
1137
+
1138
+ cmap = theme_settings(theme)["map_cmap"]
1139
+
1140
+ mesh = ax.pcolormesh(
1141
+ x_array,
1142
+ y_array,
1143
+ z_array,
1144
+ shading="auto",
1145
+ cmap=cmap,
1146
+ norm=norm,
1147
+ )
1148
+ colorbar = fig.colorbar(mesh, ax=ax)
1149
+ style_colorbar(colorbar, theme)
1150
+
1151
+ if xlabel is None:
1152
+ xlabel = x_name
1153
+
1154
+ if ylabel is None:
1155
+ ylabel = y_name
1156
+
1157
+ if zlabel is None:
1158
+ zlabel = z_name
1159
+
1160
+ ax.set_xlabel(xlabel)
1161
+ ax.set_ylabel(ylabel)
1162
+ colorbar.set_label(zlabel)
1163
+
1164
+ if title is not None:
1165
+ ax.set_title(title)
1166
+
1167
+ if grid:
1168
+ ax.grid(True, **grid_kwargs(theme))
1169
+ else:
1170
+ ax.grid(False)
1171
+
1172
+ fig.tight_layout()
1173
+
1174
+ self._save_and_show(fig=fig, save=save, dpi=dpi, show=show)
1175
+
1176
+ return fig, ax
1177
+
1178
+ def _resolve_dataset_path(self, h5: h5py.File, selector: str) -> str:
1179
+ if not isinstance(selector, str):
1180
+ raise DatasetNotFoundError(f"Dataset selector must be a string, got {type(selector)}.")
1181
+
1182
+ selector = selector.strip()
1183
+
1184
+ if selector == "":
1185
+ raise DatasetNotFoundError("Empty dataset selector.")
1186
+
1187
+ direct_path = _join_h5(self.root, selector)
1188
+
1189
+ if direct_path in h5:
1190
+ if isinstance(h5[direct_path], h5py.Dataset):
1191
+ return direct_path
1192
+
1193
+ raise DatasetNotFoundError(f"{direct_path!r} exists, but it is not a dataset.")
1194
+
1195
+ paths = self._dataset_paths(h5)
1196
+
1197
+ exact_matches = []
1198
+
1199
+ for path in paths:
1200
+ basename = _basename(path)
1201
+ relative = _relative_path(path, self.root)
1202
+
1203
+ if selector == basename or selector == relative:
1204
+ exact_matches.append(path)
1205
+
1206
+ if len(exact_matches) == 1:
1207
+ return exact_matches[0]
1208
+
1209
+ if len(exact_matches) > 1:
1210
+ self._raise_ambiguous(selector, exact_matches)
1211
+
1212
+ normalized_selector = _normalize_name(selector)
1213
+ normalized_matches = []
1214
+
1215
+ for path in paths:
1216
+ basename = _basename(path)
1217
+ relative = _relative_path(path, self.root)
1218
+
1219
+ if (
1220
+ _normalize_name(basename) == normalized_selector
1221
+ or _normalize_name(relative) == normalized_selector
1222
+ ):
1223
+ normalized_matches.append(path)
1224
+
1225
+ if len(normalized_matches) == 1:
1226
+ return normalized_matches[0]
1227
+
1228
+ if len(normalized_matches) > 1:
1229
+ self._raise_ambiguous(selector, normalized_matches)
1230
+
1231
+ raise DatasetNotFoundError(
1232
+ f"Could not find dataset {selector!r} under {self.root!r} in {self.filename!r}."
1233
+ )
1234
+
1235
+ def _resolve_group_path(self, h5: h5py.File, selector: str) -> str:
1236
+ selector = str(selector).strip()
1237
+
1238
+ if selector == "":
1239
+ raise DatasetNotFoundError("Empty group selector.")
1240
+
1241
+ direct_path = _join_h5(self.root, selector)
1242
+
1243
+ if direct_path in h5:
1244
+ if isinstance(h5[direct_path], h5py.Group):
1245
+ return direct_path
1246
+
1247
+ raise DatasetNotFoundError(f"{direct_path!r} exists, but it is not a group.")
1248
+
1249
+ groups = self._group_paths(h5)
1250
+
1251
+ matches = []
1252
+
1253
+ for path in groups:
1254
+ basename = _basename(path)
1255
+ relative = _relative_path(path, self.root)
1256
+
1257
+ if selector == basename or selector == relative:
1258
+ matches.append(path)
1259
+
1260
+ if len(matches) == 1:
1261
+ return matches[0]
1262
+
1263
+ if len(matches) > 1:
1264
+ self._raise_ambiguous(selector, matches)
1265
+
1266
+ raise DatasetNotFoundError(
1267
+ f"Could not find group {selector!r} under {self.root!r} in {self.filename!r}."
1268
+ )
1269
+
1270
+ def _dataset_paths(self, h5: h5py.File, root: str | None = None) -> builtins.list[str]:
1271
+ if root is None:
1272
+ root = self.root
1273
+
1274
+ root = _clean_root(root)
1275
+
1276
+ if root not in h5:
1277
+ raise DatasetNotFoundError(f"Root path {root!r} was not found in {self.filename!r}.")
1278
+
1279
+ root_object = h5[root]
1280
+ paths = []
1281
+
1282
+ if isinstance(root_object, h5py.Dataset):
1283
+ return [root]
1284
+
1285
+ def visit(name, object_):
1286
+ if isinstance(object_, h5py.Dataset):
1287
+ if root == "/":
1288
+ paths.append("/" + name)
1289
+ else:
1290
+ paths.append(root.rstrip("/") + "/" + name)
1291
+
1292
+ root_object.visititems(visit)
1293
+ paths.sort()
1294
+
1295
+ return paths
1296
+
1297
+ def _group_paths(self, h5: h5py.File) -> builtins.list[str]:
1298
+ if self.root not in h5:
1299
+ raise DatasetNotFoundError(f"Root path {self.root!r} was not found in {self.filename!r}.")
1300
+
1301
+ root_object = h5[self.root]
1302
+ paths = []
1303
+
1304
+ if isinstance(root_object, h5py.Group):
1305
+ paths.append(self.root)
1306
+
1307
+ def visit(name, object_):
1308
+ if isinstance(object_, h5py.Group):
1309
+ if self.root == "/":
1310
+ paths.append("/" + name)
1311
+ else:
1312
+ paths.append(self.root.rstrip("/") + "/" + name)
1313
+
1314
+ root_object.visititems(visit)
1315
+
1316
+ paths.sort()
1317
+
1318
+ return paths
1319
+
1320
+ def _raise_ambiguous(self, selector: str, matches: builtins.list[str]):
1321
+ lines = [f"Dataset/group name {selector!r} is ambiguous. Matches:"]
1322
+
1323
+ for path in matches:
1324
+ lines.append(f" - {path}")
1325
+
1326
+ lines.append("Use a full path or scope the file with file.at(...).")
1327
+
1328
+ raise AmbiguousDatasetError("\n".join(lines))
1329
+
1330
+ def _dataset_tree_line(self, dataset: h5py.Dataset, prefix: str) -> str:
1331
+ name = _basename(dataset.name)
1332
+ shape = _shape_string(dataset.shape)
1333
+ dtype = dataset.dtype
1334
+
1335
+ return f"{prefix}{name} {shape} {dtype}"
1336
+
1337
+ def _append_tree_lines(
1338
+ self,
1339
+ lines: builtins.list[str],
1340
+ group: h5py.Group,
1341
+ prefix: str,
1342
+ depth: int,
1343
+ max_depth: int | None,
1344
+ ):
1345
+ if max_depth is not None and depth >= max_depth:
1346
+ return
1347
+
1348
+ items = sorted(group.items(), key=lambda item: item[0])
1349
+
1350
+ for index, (name, object_) in enumerate(items):
1351
+ is_last = index == len(items) - 1
1352
+ branch = "└── " if is_last else "├── "
1353
+ next_prefix = prefix + (" " if is_last else "│ ")
1354
+
1355
+ if isinstance(object_, h5py.Dataset):
1356
+ shape = _shape_string(object_.shape)
1357
+ dtype = object_.dtype
1358
+ lines.append(f"{prefix}{branch}{name} {shape} {dtype}")
1359
+ else:
1360
+ lines.append(f"{prefix}{branch}{name}/")
1361
+ self._append_tree_lines(
1362
+ lines=lines,
1363
+ group=object_,
1364
+ prefix=next_prefix,
1365
+ depth=depth + 1,
1366
+ max_depth=max_depth,
1367
+ )
1368
+
1369
+ def _format_dataset_rows(self, rows: builtins.list[tuple[str, Any, Any]]) -> builtins.list[str]:
1370
+ if not rows:
1371
+ return []
1372
+
1373
+ width = max(len(row[0]) for row in rows)
1374
+ formatted = []
1375
+
1376
+ for name, shape, dtype in rows:
1377
+ formatted.append(f" {name:<{width}} {_shape_string(shape):<16} {dtype}")
1378
+
1379
+ return formatted
1380
+
1381
+ def _build_line_series(
1382
+ self,
1383
+ h5: h5py.File,
1384
+ selector: str,
1385
+ axis: int,
1386
+ slice_spec,
1387
+ ) -> builtins.list[LineSeries]:
1388
+ path = self._resolve_dataset_path(h5, selector)
1389
+ base_label = _basename(path)
1390
+
1391
+ array = np.asarray(h5[path][()])
1392
+
1393
+ if not _is_numeric_dtype(array.dtype):
1394
+ raise PlotDataError(f"Dataset {path!r} is not numeric.")
1395
+
1396
+ array, plot_axis = _apply_slice_for_lines(array, axis=axis, slice_spec=slice_spec)
1397
+
1398
+ if array.ndim == 0:
1399
+ raise PlotDataError(f"Dataset {path!r} became scalar after slicing.")
1400
+
1401
+ plot_axis = _normalize_axis(plot_axis, array.ndim)
1402
+ length = array.shape[plot_axis]
1403
+
1404
+ moved = np.moveaxis(array, plot_axis, -1)
1405
+
1406
+ if moved.ndim == 1:
1407
+ return [
1408
+ LineSeries(
1409
+ data=np.asarray(moved),
1410
+ label=base_label,
1411
+ length=length,
1412
+ )
1413
+ ]
1414
+
1415
+ trace_shape = moved.shape[:-1]
1416
+ flattened = moved.reshape((-1, moved.shape[-1]))
1417
+ series = []
1418
+
1419
+ for trace_index, trace in enumerate(flattened):
1420
+ multi_index = np.unravel_index(trace_index, trace_shape)
1421
+
1422
+ if len(multi_index) == 1:
1423
+ label = f"{base_label}[{multi_index[0]}]"
1424
+ else:
1425
+ label = f"{base_label}{multi_index}"
1426
+
1427
+ series.append(
1428
+ LineSeries(
1429
+ data=np.asarray(trace),
1430
+ label=label,
1431
+ length=length,
1432
+ )
1433
+ )
1434
+
1435
+ return series
1436
+
1437
+ def _apply_user_labels(self, series: builtins.list[LineSeries], labels, label_name: str):
1438
+ if labels is None:
1439
+ return
1440
+
1441
+ labels = _as_list(labels)
1442
+
1443
+ if len(labels) != len(series):
1444
+ raise PlotDataError(
1445
+ f"{label_name} must contain exactly {len(series)} labels. "
1446
+ f"Received {len(labels)}."
1447
+ )
1448
+
1449
+ for series_item, label in zip(series, labels):
1450
+ series_item.label = str(label)
1451
+
1452
+ def _save_and_show(self, fig, save, dpi: int, show: bool):
1453
+ if save is not None:
1454
+ fig.savefig(
1455
+ save,
1456
+ dpi=dpi,
1457
+ bbox_inches="tight",
1458
+ facecolor=fig.get_facecolor(),
1459
+ )
1460
+
1461
+ if show:
1462
+ plt.show()
1463
+
1464
+ def _read_map_z(self, h5: h5py.File, z):
1465
+ if isinstance(z, (builtins.list, tuple)):
1466
+ arrays = []
1467
+
1468
+ if len(z) == 0:
1469
+ raise PlotDataError("z cannot be an empty list.")
1470
+
1471
+ for selector in z:
1472
+ path = self._resolve_dataset_path(h5, selector)
1473
+ array = np.asarray(h5[path][()])
1474
+
1475
+ if not _is_numeric_dtype(array.dtype):
1476
+ raise PlotDataError(f"Dataset {path!r} is not numeric.")
1477
+
1478
+ if array.ndim != 1:
1479
+ raise PlotDataError(
1480
+ f"When z is a list, each selected dataset must be 1D. "
1481
+ f"Dataset {path!r} has shape {array.shape}."
1482
+ )
1483
+
1484
+ arrays.append(array)
1485
+
1486
+ lengths = {len(array) for array in arrays}
1487
+
1488
+ if len(lengths) != 1:
1489
+ raise PlotDataError("All z datasets must have the same length to form a heat map.")
1490
+
1491
+ return np.vstack(arrays), "Stacked Values"
1492
+
1493
+ z_path = self._resolve_dataset_path(h5, z)
1494
+ z_name = _basename(z_path)
1495
+ z_array = np.asarray(h5[z_path][()])
1496
+
1497
+ if not _is_numeric_dtype(z_array.dtype):
1498
+ raise PlotDataError("z must be numeric.")
1499
+
1500
+ return z_array, z_name
1501
+
1502
+ def _read_map_axis(self, h5: h5py.File, value, default, default_name: str, valid_lengths, axis_name: str):
1503
+ if value is None:
1504
+ return default, default_name
1505
+
1506
+ if isinstance(value, str):
1507
+ path = self._resolve_dataset_path(h5, value)
1508
+ array = np.asarray(h5[path][()])
1509
+ name = _basename(path)
1510
+ else:
1511
+ array = np.asarray(value)
1512
+ name = default_name
1513
+
1514
+ if array.ndim != 1:
1515
+ raise PlotDataError(f"{axis_name} must be 1D.")
1516
+
1517
+ if not _is_numeric_dtype(array.dtype):
1518
+ raise PlotDataError(f"{axis_name} must be numeric.")
1519
+
1520
+ if len(array) not in valid_lengths:
1521
+ valid_text = " or ".join(str(length) for length in valid_lengths)
1522
+ raise PlotDataError(
1523
+ f"{axis_name} has length {len(array)}, but expected length {valid_text}."
1524
+ )
1525
+
1526
+ return array, name
1527
+
1528
+
1529
+ def open(filename: str | Path, root: str = "/") -> H5File:
1530
+ return H5File(filename=filename, root=root)
1531
+
1532
+
1533
+ def tree(filename: str | Path, root: str = "/", **kwargs) -> str:
1534
+ return open(filename, root=root).tree(**kwargs)
1535
+
1536
+
1537
+ def list_h5(filename: str | Path, root: str = "/", **kwargs) -> str:
1538
+ return open(filename, root=root).list(**kwargs)
1539
+
1540
+
1541
+ def read(filename: str | Path, name: str, root: str = "/"):
1542
+ return open(filename, root=root).read(name)
1543
+
1544
+
1545
+ def values(filename: str | Path, root: str = "/", group: str | None = None, **kwargs):
1546
+ return open(filename, root=root).values(group=group, **kwargs)
1547
+
1548
+
1549
+ def trace(filename: str | Path, y, x=None, root: str = "/", **kwargs) -> Trace:
1550
+ return open(filename, root=root).trace(y=y, x=x, **kwargs)
1551
+
1552
+
1553
+ def _looks_like_trace_collection(value) -> bool:
1554
+ if isinstance(value, Trace):
1555
+ return True
1556
+
1557
+ if isinstance(value, (builtins.list, tuple)) and value:
1558
+ return all(isinstance(item, Trace) for item in value)
1559
+
1560
+ return False
1561
+
1562
+
1563
+ def plot(filename_or_traces=None, y=None, x=None, root: str = "/", **kwargs):
1564
+ """Plot either an HDF5 dataset selection or prepared Trace objects."""
1565
+
1566
+ if _looks_like_trace_collection(filename_or_traces) and y is None:
1567
+ return H5File(filename="", root="/").plot(y=filename_or_traces, x=x, **kwargs)
1568
+
1569
+ if filename_or_traces is None:
1570
+ raise TypeError("plot() requires either a filename or Trace object(s).")
1571
+
1572
+ return open(filename_or_traces, root=root).plot(y=y, x=x, **kwargs)
1573
+
1574
+
1575
+ def map(filename: str | Path, z, root: str = "/", **kwargs):
1576
+ return open(filename, root=root).map(z=z, **kwargs)
1577
+
1578
+
1579
+ def write_traces(filename: str | Path, traces, group: str = "traces", overwrite: bool = True) -> str:
1580
+ """Write one or more Trace objects to a simple HDF5 trace group."""
1581
+
1582
+ traces = _as_list(traces)
1583
+
1584
+ if not traces:
1585
+ raise ValueError("write_traces() requires at least one Trace.")
1586
+
1587
+ for item in traces:
1588
+ if not isinstance(item, Trace):
1589
+ raise TypeError("write_traces() only accepts Trace objects.")
1590
+
1591
+ output = hdf5_filename(filename)
1592
+
1593
+ with h5py.File(output, "a") as h5:
1594
+ root_group = h5.require_group(safe_group_name(group))
1595
+
1596
+ for item in traces:
1597
+ name = safe_group_name(item.name)
1598
+
1599
+ if name in root_group:
1600
+ if overwrite:
1601
+ del root_group[name]
1602
+ else:
1603
+ raise ValueError(f"Trace group {group}/{name} already exists.")
1604
+
1605
+ trace_group = root_group.create_group(name)
1606
+ trace_group.create_dataset("x", data=item.x)
1607
+ trace_group.create_dataset("y", data=item.y)
1608
+ trace_group.attrs["name"] = item.name
1609
+ trace_group.attrs["role"] = item.role
1610
+
1611
+ for key, value in item.attrs.items():
1612
+ try:
1613
+ trace_group.attrs[str(key)] = value
1614
+ except TypeError:
1615
+ trace_group.attrs[str(key)] = str(value)
1616
+
1617
+ return output
1618
+
1619
+
1620
+ def show():
1621
+ """Show all currently open matplotlib figures."""
1622
+
1623
+ plt.show()
1624
+
1625
+
1626
+ list = list_h5
1627
+
1628
+
1629
+
1630
+ __all__ = [
1631
+ "FullPlotError",
1632
+ "DatasetNotFoundError",
1633
+ "AmbiguousDatasetError",
1634
+ "PlotDataError",
1635
+ "Trace",
1636
+ "H5File",
1637
+ "open",
1638
+ "tree",
1639
+ "list",
1640
+ "read",
1641
+ "values",
1642
+ "trace",
1643
+ "plot",
1644
+ "map",
1645
+ "write_traces",
1646
+ "show",
1647
+ ]