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/__init__.py +61 -0
- fullplot/cli.py +63 -0
- fullplot/fullplot.py +1647 -0
- fullplot/hdf5.py +48 -0
- fullplot/maps.py +708 -0
- fullplot/themes.py +176 -0
- fullplot-0.0.1.dist-info/METADATA +119 -0
- fullplot-0.0.1.dist-info/RECORD +10 -0
- fullplot-0.0.1.dist-info/WHEEL +4 -0
- fullplot-0.0.1.dist-info/entry_points.txt +3 -0
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
|
+
]
|