fullplot 0.1.1__py3-none-any.whl → 2.0.0__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 +5 -11
- fullplot/fullplot.py +29 -0
- fullplot/linearization.py +335 -0
- fullplot/maps.py +15 -0
- {fullplot-0.1.1.dist-info → fullplot-2.0.0.dist-info}/METADATA +227 -5
- fullplot-2.0.0.dist-info/RECORD +12 -0
- {fullplot-0.1.1.dist-info → fullplot-2.0.0.dist-info}/WHEEL +1 -1
- fullplot-0.1.1.dist-info/RECORD +0 -11
- {fullplot-0.1.1.dist-info → fullplot-2.0.0.dist-info}/entry_points.txt +0 -0
- {fullplot-0.1.1.dist-info → fullplot-2.0.0.dist-info}/licenses/LICENSE +0 -0
fullplot/__init__.py
CHANGED
|
@@ -1,25 +1,16 @@
|
|
|
1
|
-
"""FullPlot public package interface.
|
|
2
|
-
|
|
3
|
-
FullPlot is a small engineering-data plotting package centered on HDF5 files,
|
|
4
|
-
reusable one-dimensional ``Trace`` objects, shared ``TimeAxis`` objects, and
|
|
5
|
-
rectangular-grid map generation. Most users import the package as ``fullplot``
|
|
6
|
-
or ``fplt`` and use the names re-exported here instead of importing from the
|
|
7
|
-
internal modules directly.
|
|
8
|
-
"""
|
|
1
|
+
"""FullPlot public package interface."""
|
|
9
2
|
|
|
10
3
|
from fullplot.cli import main
|
|
11
4
|
from fullplot.fullplot import (
|
|
12
5
|
AmbiguousDatasetError,
|
|
13
6
|
DatasetNotFoundError,
|
|
14
7
|
FullPlotError,
|
|
15
|
-
H5File,
|
|
16
8
|
PlotDataError,
|
|
17
9
|
TimeAxis,
|
|
18
10
|
Trace,
|
|
19
11
|
VALID_TRACE_ROLES,
|
|
20
12
|
list,
|
|
21
13
|
map,
|
|
22
|
-
open,
|
|
23
14
|
plot,
|
|
24
15
|
read,
|
|
25
16
|
show,
|
|
@@ -29,6 +20,7 @@ from fullplot.fullplot import (
|
|
|
29
20
|
values,
|
|
30
21
|
write_traces,
|
|
31
22
|
)
|
|
23
|
+
from fullplot.linearization import H5File, Linearization, LinearizationError, open
|
|
32
24
|
from fullplot.maps import (
|
|
33
25
|
Axis,
|
|
34
26
|
FullPlotMapError,
|
|
@@ -37,7 +29,7 @@ from fullplot.maps import (
|
|
|
37
29
|
generate_map,
|
|
38
30
|
)
|
|
39
31
|
|
|
40
|
-
__version__ = "0.
|
|
32
|
+
__version__ = "2.0.0"
|
|
41
33
|
|
|
42
34
|
__all__ = [
|
|
43
35
|
"__version__",
|
|
@@ -45,6 +37,8 @@ __all__ = [
|
|
|
45
37
|
"DatasetNotFoundError",
|
|
46
38
|
"AmbiguousDatasetError",
|
|
47
39
|
"PlotDataError",
|
|
40
|
+
"LinearizationError",
|
|
41
|
+
"Linearization",
|
|
48
42
|
"TimeAxis",
|
|
49
43
|
"Trace",
|
|
50
44
|
"VALID_TRACE_ROLES",
|
fullplot/fullplot.py
CHANGED
|
@@ -414,6 +414,20 @@ class TimeAxis:
|
|
|
414
414
|
def __getitem__(self, item):
|
|
415
415
|
return self.values[item]
|
|
416
416
|
|
|
417
|
+
def __str__(self) -> str:
|
|
418
|
+
"""Return a compact summary of the active time range."""
|
|
419
|
+
active_label = "sample" if self.count == 1 else "samples"
|
|
420
|
+
window = ""
|
|
421
|
+
if self.has_window:
|
|
422
|
+
start, stop = self.window_range
|
|
423
|
+
start_text = "start" if start is None else f"{start:g}"
|
|
424
|
+
stop_text = "end" if stop is None else f"{stop:g}"
|
|
425
|
+
window = f", window={start_text} to {stop_text}"
|
|
426
|
+
return (
|
|
427
|
+
f"TimeAxis({self.name!r}, {self.count}/{self.raw_count} {active_label}, "
|
|
428
|
+
f"range={self.tmin:g} to {self.tmax:g}, zero={self.zero:g}{window})"
|
|
429
|
+
)
|
|
430
|
+
|
|
417
431
|
class Trace:
|
|
418
432
|
"""Prepared one-dimensional engineering data series.
|
|
419
433
|
|
|
@@ -490,6 +504,17 @@ class Trace:
|
|
|
490
504
|
self.role = _normalize_trace_role(role)
|
|
491
505
|
self.attrs = {} if attrs is None else dict(attrs)
|
|
492
506
|
|
|
507
|
+
def __str__(self) -> str:
|
|
508
|
+
"""Return a compact summary without printing the underlying arrays."""
|
|
509
|
+
finite_count = int(np.count_nonzero(self.finite))
|
|
510
|
+
count = len(self.x)
|
|
511
|
+
sample_label = "sample" if count == 1 else "samples"
|
|
512
|
+
return (
|
|
513
|
+
f"Trace({self.name!r}, role={self.role!r}, {count} {sample_label}, "
|
|
514
|
+
f"x={self.tmin:g} to {self.tmax:g}, "
|
|
515
|
+
f"finite={finite_count}/{count})"
|
|
516
|
+
)
|
|
517
|
+
|
|
493
518
|
@property
|
|
494
519
|
def x(self) -> np.ndarray:
|
|
495
520
|
"""Current x-values, including shared alignment and windowing."""
|
|
@@ -1314,6 +1339,10 @@ class H5File:
|
|
|
1314
1339
|
self.filename = str(self.filename)
|
|
1315
1340
|
self.root = _clean_root(self.root)
|
|
1316
1341
|
|
|
1342
|
+
def __str__(self) -> str:
|
|
1343
|
+
"""Return the selected file and HDF5 root."""
|
|
1344
|
+
return f"H5File({self.filename!r}, root={self.root!r})"
|
|
1345
|
+
|
|
1317
1346
|
def at(self, group: str) -> "H5File":
|
|
1318
1347
|
"""
|
|
1319
1348
|
Return a new H5File scoped to a specific HDF5 group.
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Read FullFlow state-space linearizations from HDF5 files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from math import isclose
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import h5py
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from .fullplot import FullPlotError, H5File as _H5File, open as _open
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LinearizationError(FullPlotError):
|
|
17
|
+
"""An HDF5 linearization is missing, ambiguous, or malformed."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Linearization:
|
|
22
|
+
"""One saved continuous-time state-space model and its operating point."""
|
|
23
|
+
|
|
24
|
+
A: np.ndarray
|
|
25
|
+
B: np.ndarray
|
|
26
|
+
C: np.ndarray
|
|
27
|
+
D: np.ndarray
|
|
28
|
+
x0: np.ndarray
|
|
29
|
+
xdot0: np.ndarray
|
|
30
|
+
u0: np.ndarray
|
|
31
|
+
y0: np.ndarray
|
|
32
|
+
states: tuple[str, ...]
|
|
33
|
+
inputs: tuple[str, ...]
|
|
34
|
+
outputs: tuple[str, ...]
|
|
35
|
+
time: float
|
|
36
|
+
diagnostics: dict[str, Any]
|
|
37
|
+
model_options: dict[str, Any]
|
|
38
|
+
solver: str
|
|
39
|
+
run: str
|
|
40
|
+
network: str
|
|
41
|
+
path: str
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str:
|
|
44
|
+
"""Return a compact summary suitable for interactive printing."""
|
|
45
|
+
nx = len(self.states)
|
|
46
|
+
nu = len(self.inputs)
|
|
47
|
+
ny = len(self.outputs)
|
|
48
|
+
identity = " / ".join(
|
|
49
|
+
item for item in (self.network, self.solver, self.run) if item
|
|
50
|
+
)
|
|
51
|
+
title = "Linearization" if not identity else f"Linearization: {identity}"
|
|
52
|
+
dimensions = ", ".join((
|
|
53
|
+
f"{nx} state{'s' if nx != 1 else ''}",
|
|
54
|
+
f"{nu} input{'s' if nu != 1 else ''}",
|
|
55
|
+
f"{ny} output{'s' if ny != 1 else ''}",
|
|
56
|
+
))
|
|
57
|
+
return "\n".join((
|
|
58
|
+
title,
|
|
59
|
+
f" Time: {self.time:g} s",
|
|
60
|
+
f" Dimensions: {dimensions}",
|
|
61
|
+
f" Matrices: A{self.A.shape}, B{self.B.shape}, "
|
|
62
|
+
f"C{self.C.shape}, D{self.D.shape}",
|
|
63
|
+
f" Path: {self.path}",
|
|
64
|
+
))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _decode(value: Any) -> Any:
|
|
68
|
+
if isinstance(value, bytes):
|
|
69
|
+
return value.decode("utf-8")
|
|
70
|
+
if isinstance(value, np.generic):
|
|
71
|
+
return _decode(value.item())
|
|
72
|
+
if isinstance(value, np.ndarray):
|
|
73
|
+
return [_decode(item) for item in value.tolist()]
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _labels(group: h5py.Group, name: str) -> tuple[str, ...]:
|
|
78
|
+
if name not in group or not isinstance(group[name], h5py.Dataset):
|
|
79
|
+
raise LinearizationError(f"Missing {name!r} labels in {group.name}.")
|
|
80
|
+
values = group[name][()]
|
|
81
|
+
if np.asarray(values).ndim != 1:
|
|
82
|
+
raise LinearizationError(f"{group.name}/{name} must be a 1D label array.")
|
|
83
|
+
decoded = tuple(_decode(value) for value in values)
|
|
84
|
+
if any(not isinstance(value, str) for value in decoded):
|
|
85
|
+
raise LinearizationError(f"{group.name}/{name} contains non-string labels.")
|
|
86
|
+
return decoded
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _array(group: h5py.Group, name: str, shape: tuple[int, ...]) -> np.ndarray:
|
|
90
|
+
if name not in group or not isinstance(group[name], h5py.Dataset):
|
|
91
|
+
raise LinearizationError(f"Missing dataset {group.name}/{name}.")
|
|
92
|
+
value = np.asarray(group[name][()])
|
|
93
|
+
if value.shape != shape or not np.issubdtype(value.dtype, np.number):
|
|
94
|
+
raise LinearizationError(
|
|
95
|
+
f"{group.name}/{name} must be a numeric array with shape {shape}; "
|
|
96
|
+
f"found {value.shape}."
|
|
97
|
+
)
|
|
98
|
+
return value.copy()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _metadata(group: h5py.Group, name: str) -> dict[str, Any]:
|
|
102
|
+
if name not in group:
|
|
103
|
+
return {}
|
|
104
|
+
node = group[name]
|
|
105
|
+
if not isinstance(node, h5py.Group):
|
|
106
|
+
raise LinearizationError(f"{group.name}/{name} must be a group.")
|
|
107
|
+
result = {key: _decode(value) for key, value in node.attrs.items()}
|
|
108
|
+
for key, item in node.items():
|
|
109
|
+
if isinstance(item, h5py.Dataset):
|
|
110
|
+
result[key] = _decode(item[()])
|
|
111
|
+
elif name == "model_options" and isinstance(item, h5py.Group):
|
|
112
|
+
option_name = _decode(item.attrs.get("name", key))
|
|
113
|
+
selected = _decode(item.attrs.get("option", ""))
|
|
114
|
+
result[option_name] = selected or None
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _identity(path: str) -> tuple[str, str, str]:
|
|
119
|
+
segments = path.strip("/").split("/")
|
|
120
|
+
if "linearization" not in segments:
|
|
121
|
+
return "", "", ""
|
|
122
|
+
i = segments.index("linearization")
|
|
123
|
+
network = "/".join(segments[:i])
|
|
124
|
+
solver = segments[i + 1] if i + 1 < len(segments) else ""
|
|
125
|
+
run = ""
|
|
126
|
+
if "runs" in segments:
|
|
127
|
+
j = segments.index("runs") + 1
|
|
128
|
+
end = segments.index("points") if "points" in segments else len(segments)
|
|
129
|
+
run = "/".join(segments[j:end])
|
|
130
|
+
return network, solver, run
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _is_result(group: h5py.Group) -> bool:
|
|
134
|
+
return isinstance(group, h5py.Group) and all(name in group for name in ("A", "B", "C", "D"))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _read_result(group: h5py.Group) -> Linearization:
|
|
138
|
+
if not _is_result(group):
|
|
139
|
+
raise LinearizationError(
|
|
140
|
+
f"No base linearization is stored in {group.name}. "
|
|
141
|
+
"Specify time= or times= to read saved transient points."
|
|
142
|
+
)
|
|
143
|
+
network, solver, run = _identity(group.name)
|
|
144
|
+
states = _labels(group, "states")
|
|
145
|
+
inputs = _labels(group, "inputs")
|
|
146
|
+
outputs = _labels(group, "outputs")
|
|
147
|
+
nx, nu, ny = len(states), len(inputs), len(outputs)
|
|
148
|
+
if "time" not in group or not isinstance(group["time"], h5py.Dataset):
|
|
149
|
+
raise LinearizationError(f"Missing operating time in {group.name}.")
|
|
150
|
+
time_value = np.asarray(group["time"][()])
|
|
151
|
+
if time_value.shape != () or not np.issubdtype(time_value.dtype, np.number):
|
|
152
|
+
raise LinearizationError(f"{group.name}/time must be numeric and scalar.")
|
|
153
|
+
return Linearization(
|
|
154
|
+
A=_array(group, "A", (nx, nx)),
|
|
155
|
+
B=_array(group, "B", (nx, nu)),
|
|
156
|
+
C=_array(group, "C", (ny, nx)),
|
|
157
|
+
D=_array(group, "D", (ny, nu)),
|
|
158
|
+
x0=_array(group, "x0", (nx,)),
|
|
159
|
+
xdot0=_array(group, "xdot0", (nx,)),
|
|
160
|
+
u0=_array(group, "u0", (nu,)),
|
|
161
|
+
y0=_array(group, "y0", (ny,)),
|
|
162
|
+
states=states,
|
|
163
|
+
inputs=inputs,
|
|
164
|
+
outputs=outputs,
|
|
165
|
+
time=float(time_value),
|
|
166
|
+
diagnostics=_metadata(group, "diagnostics"),
|
|
167
|
+
model_options=_metadata(group, "model_options"),
|
|
168
|
+
solver=solver,
|
|
169
|
+
run=run,
|
|
170
|
+
network=network,
|
|
171
|
+
path=group.name,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _has_points(group: h5py.Group) -> bool:
|
|
176
|
+
if not isinstance(group, h5py.Group):
|
|
177
|
+
return False
|
|
178
|
+
points = group.get("points")
|
|
179
|
+
return isinstance(points, h5py.Group) and any(
|
|
180
|
+
_is_result(item) for item in points.values() if isinstance(item, h5py.Group)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _runs(root: h5py.Group) -> list[h5py.Group]:
|
|
185
|
+
if (_is_result(root) or _has_points(root)) and "/points/" not in root.name:
|
|
186
|
+
return [root]
|
|
187
|
+
found: list[h5py.Group] = []
|
|
188
|
+
|
|
189
|
+
def visit(name: str, item: h5py.Group | h5py.Dataset):
|
|
190
|
+
if not (_is_result(item) or _has_points(item)):
|
|
191
|
+
return
|
|
192
|
+
path = item.name
|
|
193
|
+
if "/linearization/" in path and "/runs/" in path and "/points/" not in path:
|
|
194
|
+
found.append(item)
|
|
195
|
+
|
|
196
|
+
root.visititems(visit)
|
|
197
|
+
return sorted(found, key=lambda group: group.name)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _missing_scoped_run(root: h5py.Group, solver: str | None):
|
|
201
|
+
raise LinearizationError(f"Scoped linearization {root.name} does not match solver={solver!r}.")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _select_run(root: h5py.Group, solver: str | None, run: str) -> h5py.Group:
|
|
205
|
+
runs = [group for group in _runs(root) if (not solver or _identity(group.name)[1] == solver)]
|
|
206
|
+
if (_is_result(root) or _has_points(root)) and "/points/" not in root.name:
|
|
207
|
+
return runs[0] if runs else _missing_scoped_run(root, solver)
|
|
208
|
+
matching = [group for group in runs if _identity(group.name)[2] == run]
|
|
209
|
+
if not matching and run == "base" and len(runs) == 1:
|
|
210
|
+
matching = runs
|
|
211
|
+
runs = matching
|
|
212
|
+
if not runs:
|
|
213
|
+
raise LinearizationError(
|
|
214
|
+
f"No linearization for solver={solver!r}, run={run!r} under {root.name}. "
|
|
215
|
+
"Call linearizations() to inspect available results."
|
|
216
|
+
)
|
|
217
|
+
if len(runs) != 1:
|
|
218
|
+
paths = ", ".join(group.name for group in runs)
|
|
219
|
+
raise LinearizationError(f"Multiple linearizations match; use .at() to select one: {paths}")
|
|
220
|
+
return runs[0]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _point(run: h5py.Group, time: float) -> h5py.Group:
|
|
224
|
+
points = run.get("points")
|
|
225
|
+
matches: list[h5py.Group] = []
|
|
226
|
+
available: list[float] = []
|
|
227
|
+
if isinstance(points, h5py.Group):
|
|
228
|
+
for item in points.values():
|
|
229
|
+
if not isinstance(item, h5py.Group) or "time" not in item:
|
|
230
|
+
continue
|
|
231
|
+
value = float(item["time"][()])
|
|
232
|
+
available.append(value)
|
|
233
|
+
if isclose(value, time, rel_tol=1e-10, abs_tol=1e-12):
|
|
234
|
+
matches.append(item)
|
|
235
|
+
if len(matches) == 1:
|
|
236
|
+
return matches[0]
|
|
237
|
+
if len(matches) > 1:
|
|
238
|
+
raise LinearizationError(f"Multiple saved points match t={time:g} under {run.name}.")
|
|
239
|
+
formatted = ", ".join(f"{value:g}" for value in sorted(available)) or "none"
|
|
240
|
+
raise LinearizationError(f"No linearization saved at t={time:g}. Available point times: {formatted}.")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class H5File(_H5File):
|
|
244
|
+
"""FullPlot HDF5 handle with support for FullFlow linearization records."""
|
|
245
|
+
|
|
246
|
+
def at(self, group: str) -> H5File:
|
|
247
|
+
scoped = super().at(group)
|
|
248
|
+
return H5File(scoped.filename, scoped.root)
|
|
249
|
+
|
|
250
|
+
def linearization(
|
|
251
|
+
self,
|
|
252
|
+
solver: str | None = None,
|
|
253
|
+
run: str = "base",
|
|
254
|
+
time: float | None = None,
|
|
255
|
+
times: list[float] | tuple[float, ...] | None = None,
|
|
256
|
+
) -> Linearization | dict[float, Linearization]:
|
|
257
|
+
"""Read one model, or a time-keyed dictionary of saved transient models.
|
|
258
|
+
|
|
259
|
+
With no time, reads the run's base result rather than the last point.
|
|
260
|
+
A scoped result group can be read directly with ``.linearization()``.
|
|
261
|
+
"""
|
|
262
|
+
if solver not in (None, "steady_state", "transient"):
|
|
263
|
+
raise ValueError("solver must be 'steady_state' or 'transient'.")
|
|
264
|
+
if time is not None and times is not None:
|
|
265
|
+
raise ValueError("Specify time or times, not both.")
|
|
266
|
+
if times is not None and not times:
|
|
267
|
+
raise ValueError("times must not be empty.")
|
|
268
|
+
with h5py.File(self.filename, "r") as h5:
|
|
269
|
+
root = h5[self.root]
|
|
270
|
+
if _is_result(root) and "/points/" in root.name:
|
|
271
|
+
if time is not None or times is not None:
|
|
272
|
+
raise ValueError("Already scoped to a point; omit time and times.")
|
|
273
|
+
if solver and _identity(root.name)[1] != solver:
|
|
274
|
+
raise LinearizationError("The selected point does not match solver=.")
|
|
275
|
+
return _read_result(root)
|
|
276
|
+
selected = _select_run(root, solver, run)
|
|
277
|
+
if time is not None:
|
|
278
|
+
return _read_result(_point(selected, float(time)))
|
|
279
|
+
if times is not None:
|
|
280
|
+
results: dict[float, Linearization] = {}
|
|
281
|
+
for requested in times:
|
|
282
|
+
key = float(requested)
|
|
283
|
+
if key in results:
|
|
284
|
+
raise ValueError(f"Duplicate requested time: {key:g}.")
|
|
285
|
+
results[key] = _read_result(_point(selected, key))
|
|
286
|
+
return results
|
|
287
|
+
return _read_result(selected)
|
|
288
|
+
|
|
289
|
+
def linearizations(self, print_output: bool = True) -> list[dict[str, Any]]:
|
|
290
|
+
"""List available linearization runs and their saved operating times."""
|
|
291
|
+
with h5py.File(self.filename, "r") as h5:
|
|
292
|
+
root = h5[self.root]
|
|
293
|
+
found = []
|
|
294
|
+
for group in _runs(root):
|
|
295
|
+
network, solver, run = _identity(group.name)
|
|
296
|
+
points = group.get("points")
|
|
297
|
+
point_times = []
|
|
298
|
+
if isinstance(points, h5py.Group):
|
|
299
|
+
for point in points.values():
|
|
300
|
+
if isinstance(point, h5py.Group) and "time" in point:
|
|
301
|
+
point_times.append(float(point["time"][()]))
|
|
302
|
+
reference = group
|
|
303
|
+
if not _is_result(group):
|
|
304
|
+
reference = min(
|
|
305
|
+
(item for item in points.values() if _is_result(item)),
|
|
306
|
+
key=lambda item: float(item["time"][()]),
|
|
307
|
+
)
|
|
308
|
+
found.append({
|
|
309
|
+
"network": network,
|
|
310
|
+
"solver": solver,
|
|
311
|
+
"run": run,
|
|
312
|
+
"path": group.name,
|
|
313
|
+
"time": float(group["time"][()]) if _is_result(group) else None,
|
|
314
|
+
"states": len(_labels(reference, "states")),
|
|
315
|
+
"inputs": len(_labels(reference, "inputs")),
|
|
316
|
+
"outputs": len(_labels(reference, "outputs")),
|
|
317
|
+
"times": sorted(point_times),
|
|
318
|
+
})
|
|
319
|
+
if print_output:
|
|
320
|
+
for item in found:
|
|
321
|
+
print(f"{item['network']} / {item['solver']} / {item['run']}")
|
|
322
|
+
base_time = f"t={item['time']:g}" if item["time"] is not None else "not saved"
|
|
323
|
+
print(f" Base: {base_time}, {item['states']} states, "
|
|
324
|
+
f"{item['inputs']} inputs, {item['outputs']} outputs")
|
|
325
|
+
if item["times"]:
|
|
326
|
+
print(" Points: " + ", ".join(f"{t:g}" for t in item["times"]))
|
|
327
|
+
if not found:
|
|
328
|
+
print("No FullFlow linearization results found.")
|
|
329
|
+
return found
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def open(filename: str | Path, root: str = "/") -> H5File:
|
|
333
|
+
"""Open an HDF5 file with the standard FullPlot API and linearization support."""
|
|
334
|
+
handle = _open(filename, root=root)
|
|
335
|
+
return H5File(handle.filename, handle.root)
|
fullplot/maps.py
CHANGED
|
@@ -69,6 +69,21 @@ class Axis:
|
|
|
69
69
|
units: str = ""
|
|
70
70
|
spacing: str = "values"
|
|
71
71
|
|
|
72
|
+
def __str__(self) -> str:
|
|
73
|
+
"""Return a compact summary of the map axis."""
|
|
74
|
+
values = np.asarray(self.values)
|
|
75
|
+
if values.ndim != 1 or values.size == 0:
|
|
76
|
+
value_summary = f"shape={values.shape}"
|
|
77
|
+
else:
|
|
78
|
+
units = f" {self.units}" if self.units else ""
|
|
79
|
+
point_label = "point" if values.size == 1 else "points"
|
|
80
|
+
value_summary = (
|
|
81
|
+
f"{values.size} {point_label}, {values[0]:g} to {values[-1]:g}{units}"
|
|
82
|
+
)
|
|
83
|
+
return (
|
|
84
|
+
f"Axis({self.name!r}, {value_summary}, spacing={self.spacing!r})"
|
|
85
|
+
)
|
|
86
|
+
|
|
72
87
|
@classmethod
|
|
73
88
|
def linear(
|
|
74
89
|
cls,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fullplot
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary:
|
|
5
|
-
Keywords: hdf5,plotting,engineering,simulation,test-data,map-generation,scientific-computing
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: HDF5 plotting, trace processing, linearization reading, and map generation for engineering data.
|
|
5
|
+
Keywords: hdf5,plotting,engineering,simulation,test-data,linearization,state-space,map-generation,scientific-computing
|
|
6
6
|
Author: Saaketh Ramoju
|
|
7
7
|
Author-email: Saaketh Ramoju <skramoju@gmail.com>
|
|
8
8
|
License-Expression: GPL-3.0-only
|
|
@@ -30,6 +30,7 @@ Project-URL: Issues, https://github.com/saakethramoju/FullPlot/issues
|
|
|
30
30
|
Project-URL: Changelog, https://github.com/saakethramoju/FullPlot/blob/main/CHANGELOG.md
|
|
31
31
|
Project-URL: Documentation, https://github.com/saakethramoju/FullPlot#readme
|
|
32
32
|
Project-URL: PyPI, https://pypi.org/project/fullplot/
|
|
33
|
+
Provides-Extra: examples
|
|
33
34
|
Description-Content-Type: text/markdown
|
|
34
35
|
|
|
35
36
|
# FullPlot
|
|
@@ -193,6 +194,220 @@ settings = run.values("metadata")
|
|
|
193
194
|
|
|
194
195
|
---
|
|
195
196
|
|
|
197
|
+
## Reading FullFlow linearizations
|
|
198
|
+
|
|
199
|
+
FullPlot can read **previously calculated** FullFlow steady-state and transient linearization records. The result is a labeled `Linearization` object containing the state-space matrices and the operating point at which they were computed.
|
|
200
|
+
|
|
201
|
+
A continuous-time linear model takes the form
|
|
202
|
+
|
|
203
|
+
```math
|
|
204
|
+
\Delta\dot{x}=A\Delta x+B\Delta u
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```math
|
|
208
|
+
\Delta y=C\Delta x+D\Delta u
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
where:
|
|
212
|
+
|
|
213
|
+
- `x`: physical dynamic state vector (for example stored mass or energy),
|
|
214
|
+
- `u`: selected input vector (for example valve area),
|
|
215
|
+
- `y`: selected output vector (for example chamber pressure),
|
|
216
|
+
- `A`: how state changes affect the state derivatives,
|
|
217
|
+
- `B`: how input changes affect the state derivatives,
|
|
218
|
+
- `C`: how state changes affect outputs,
|
|
219
|
+
- `D`: how input changes directly affect outputs.
|
|
220
|
+
|
|
221
|
+
`\Delta` denotes a deviation from the saved operating point. For a non-equilibrium transient point, the model also needs the saved baseline derivative:
|
|
222
|
+
|
|
223
|
+
```math
|
|
224
|
+
\dot{x}\approx\dot{x}_0+A(x-x_0)+B(u-u_0)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
```math
|
|
228
|
+
y\approx y_0+C(x-x_0)+D(u-u_0)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
FullPlot **does not create these matrices**. It reads them from FullFlow's linearization HDF5 output, without needing the original nonlinear network or property-model packages.
|
|
232
|
+
|
|
233
|
+
### Discover available results
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
import fullplot as fplt
|
|
237
|
+
|
|
238
|
+
file = fplt.open("Linearization.h5")
|
|
239
|
+
info = file.linearizations()
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`linearizations()` prints a summary of each available run and returns a list of dictionaries. Each record includes `network`, `solver`, `run`, `path`, `time`, matrix dimensions (`states`, `inputs`, `outputs`), and saved transient point times (`times`). When only transient points were saved, `time` is `None` because there is no separate base result.
|
|
243
|
+
|
|
244
|
+
For programmatic discovery without printing:
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
info = file.linearizations(print_output=False)
|
|
248
|
+
|
|
249
|
+
for run in info:
|
|
250
|
+
print(run["solver"], run["time"], run["times"])
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Read one linearization
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
steady = file.linearization(solver="steady_state")
|
|
257
|
+
|
|
258
|
+
print(steady.A)
|
|
259
|
+
print(steady.B)
|
|
260
|
+
print(steady.C)
|
|
261
|
+
print(steady.D)
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Read the final/base transient result with:
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
final = file.linearization(solver="transient")
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Omitting `time` selects the **base result**, not the latest point stored in `points/`. If only saved points are present, specify `time=` or `times=`. If the file contains both steady-state and transient records, select `solver=` explicitly. If only one record is available, it can be inferred.
|
|
271
|
+
|
|
272
|
+
### Read transient operating points
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
point = file.linearization(solver="transient", time=0.1)
|
|
276
|
+
|
|
277
|
+
print(point.time)
|
|
278
|
+
print(point.A.shape)
|
|
279
|
+
print(point.states)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Read several points at once:
|
|
283
|
+
|
|
284
|
+
```python
|
|
285
|
+
points = file.linearization(
|
|
286
|
+
solver="transient",
|
|
287
|
+
times=[0.1, 0.3],
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
for requested_time, linear in points.items():
|
|
291
|
+
print(requested_time, linear.A, linear.D)
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
`times=` returns an ordinary dictionary keyed by the **requested** times, even if HDF5 stores `0.3` as `0.30000000000000004`. Times are matched within a narrow floating-point tolerance. If a time does not exist, FullPlot reports the available point times. If multiple points match the same time, it rejects the ambiguous selection.
|
|
295
|
+
|
|
296
|
+
Saved points can have **different numbers of states, inputs, or outputs**. FullPlot returns each result independently; it does not attempt to concatenate incompatible matrices.
|
|
297
|
+
|
|
298
|
+
### Scope into a run
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
run = file.at("/Water_Flow/linearization/transient/runs/base")
|
|
302
|
+
|
|
303
|
+
point = run.linearization(time=0.1)
|
|
304
|
+
final = run.linearization()
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
A handle scoped directly to a point group can read that point without a time argument:
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
point = file.at(
|
|
311
|
+
"/Water_Flow/linearization/transient/runs/base/points/0"
|
|
312
|
+
).linearization()
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
If several networks contain matching records, `.linearization()` raises an ambiguity error instead of choosing one arbitrarily. Use `.at()` to select the desired network or run. For non-base model-option runs, scope to the run group or supply its HDF5 run path (such as `run="Pump_Model/polytropic"`). A single unambiguous non-base run can also be selected automatically.
|
|
316
|
+
|
|
317
|
+
### Data available in `Linearization`
|
|
318
|
+
|
|
319
|
+
| Attribute | Description |
|
|
320
|
+
| --- | --- |
|
|
321
|
+
| `A`, `B`, `C`, `D` | NumPy state-space matrices |
|
|
322
|
+
| `x0`, `xdot0`, `u0`, `y0` | Nominal states, state rates, inputs, and outputs |
|
|
323
|
+
| `states`, `inputs`, `outputs` | Ordered Python string tuples corresponding to matrix rows and columns |
|
|
324
|
+
| `time` | Operating-point time |
|
|
325
|
+
| `diagnostics` | Saved condition numbers and other numerical diagnostics |
|
|
326
|
+
| `model_options` | Saved FullFlow model-option metadata |
|
|
327
|
+
| `network`, `solver`, `run`, `path` | Source identification and HDF5 path |
|
|
328
|
+
|
|
329
|
+
Dimensions are checked on reading:
|
|
330
|
+
|
|
331
|
+
```math
|
|
332
|
+
A\in\mathbb R^{n_x\times n_x},\quad B\in\mathbb R^{n_x\times n_u},\quad
|
|
333
|
+
C\in\mathbb R^{n_y\times n_x},\quad D\in\mathbb R^{n_y\times n_u}.
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
FullPlot validates the baseline-vector lengths and requires compatible label counts. HDF5 byte strings are decoded to regular Python strings. Incorrect or missing datasets raise `LinearizationError`.
|
|
337
|
+
|
|
338
|
+
### Using the matrices
|
|
339
|
+
|
|
340
|
+
Matrix labels determine the meaning of each row and column:
|
|
341
|
+
|
|
342
|
+
```python
|
|
343
|
+
linear = file.linearization(solver="transient", time=0.1)
|
|
344
|
+
|
|
345
|
+
state_index = linear.states.index("Pipe 1.mass_flow")
|
|
346
|
+
input_index = linear.inputs.index("Pipe 1.cross_sectional_area")
|
|
347
|
+
|
|
348
|
+
sensitivity = linear.B[state_index, input_index]
|
|
349
|
+
print(sensitivity)
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
For a small deviation from the nominal operating point:
|
|
353
|
+
|
|
354
|
+
```python
|
|
355
|
+
import numpy as np
|
|
356
|
+
|
|
357
|
+
dx = np.zeros(len(linear.states))
|
|
358
|
+
du = np.zeros(len(linear.inputs))
|
|
359
|
+
du[0] = 1e-6
|
|
360
|
+
|
|
361
|
+
xdot = linear.xdot0 + linear.A @ dx + linear.B @ du
|
|
362
|
+
y = linear.y0 + linear.C @ dx + linear.D @ du
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
These are **local predictions**, not a substitute for the nonlinear model away from the operating point. FullPlot reads the saved arrays; it does not provide built-in eigenvalue analysis, controller tuning, state-space integration, linearization heat maps, or matrix-coefficient trace extraction.
|
|
366
|
+
|
|
367
|
+
### FullFlow HDF5 format
|
|
368
|
+
|
|
369
|
+
The reader supports the current FullFlow per-run and per-point layout:
|
|
370
|
+
|
|
371
|
+
```text
|
|
372
|
+
Network/
|
|
373
|
+
linearization/
|
|
374
|
+
steady_state/
|
|
375
|
+
runs/
|
|
376
|
+
base/
|
|
377
|
+
A, B, C, D
|
|
378
|
+
x0, xdot0, u0, y0
|
|
379
|
+
states, inputs, outputs, time
|
|
380
|
+
diagnostics/, model_options/
|
|
381
|
+
transient/
|
|
382
|
+
runs/
|
|
383
|
+
base/
|
|
384
|
+
A, B, C, D
|
|
385
|
+
x0, xdot0, u0, y0
|
|
386
|
+
states, inputs, outputs, time
|
|
387
|
+
diagnostics/, model_options/
|
|
388
|
+
points/
|
|
389
|
+
0/
|
|
390
|
+
A, B, C, D, ...
|
|
391
|
+
time
|
|
392
|
+
1/
|
|
393
|
+
A, B, C, D, ...
|
|
394
|
+
time
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Reading a saved linearization is **not** the same as calculating one from a normal simulation-history file. Ordinary result traces alone cannot determine the original system equations or their derivatives. Matrix generation remains FullFlow's responsibility.
|
|
398
|
+
|
|
399
|
+
### Linearization examples
|
|
400
|
+
|
|
401
|
+
Run the small demo generator, then the two reading examples:
|
|
402
|
+
|
|
403
|
+
```bash
|
|
404
|
+
python examples/linearization/0generate_linearization_data.py
|
|
405
|
+
python examples/linearization/1inspect_linearization.py
|
|
406
|
+
python examples/linearization/2read_transient_points.py
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
The demo uses a tiny, self-contained HDF5 file in FullFlow's export format. Replace its filename with your own FullFlow result to inspect real networks. No FullFlow dependency is needed for these reading examples.
|
|
410
|
+
|
|
196
411
|
## Core public API
|
|
197
412
|
|
|
198
413
|
Most users only need these names:
|
|
@@ -213,6 +428,7 @@ fplt.Trace
|
|
|
213
428
|
fplt.TimeAxis
|
|
214
429
|
fplt.Axis
|
|
215
430
|
fplt.generate_map
|
|
431
|
+
fplt.Linearization
|
|
216
432
|
```
|
|
217
433
|
|
|
218
434
|
The main classes are:
|
|
@@ -223,6 +439,7 @@ The main classes are:
|
|
|
223
439
|
| `Trace` | Reusable one-dimensional x/y data object. Used for raw data, filtered data, generated limits, commands, and derived traces. |
|
|
224
440
|
| `TimeAxis` | Shared shiftable and windowable time basis. Several traces can share one axis and follow the same alignment/window. |
|
|
225
441
|
| `Axis` | Independent variable definition for rectangular-grid map generation. |
|
|
442
|
+
| `Linearization` | Labeled FullFlow state-space matrices and their saved operating point. |
|
|
226
443
|
|
|
227
444
|
Important exceptions are:
|
|
228
445
|
|
|
@@ -235,6 +452,7 @@ Important exceptions are:
|
|
|
235
452
|
| `FullPlotMapError` | Base map-generation exception. |
|
|
236
453
|
| `MapGenerationError` | Map generation failed during evaluation, resume, or file layout validation. |
|
|
237
454
|
| `MapOutputError` | The map `evaluate(...)` function returned invalid outputs. |
|
|
455
|
+
| `LinearizationError` | A saved FullFlow linearization is missing, ambiguous, or malformed. |
|
|
238
456
|
|
|
239
457
|
---
|
|
240
458
|
|
|
@@ -988,11 +1206,12 @@ The CLI does not create plots. Use the Python API for plotting so scripts can co
|
|
|
988
1206
|
|
|
989
1207
|
## Examples
|
|
990
1208
|
|
|
991
|
-
The repository includes
|
|
1209
|
+
The repository includes four example folders:
|
|
992
1210
|
|
|
993
1211
|
```text
|
|
994
1212
|
examples/
|
|
995
1213
|
├── hdf5_plotting/
|
|
1214
|
+
├── linearization/
|
|
996
1215
|
├── maps/
|
|
997
1216
|
└── traces/
|
|
998
1217
|
```
|
|
@@ -1037,6 +1256,9 @@ examples/
|
|
|
1037
1256
|
* output discovery,
|
|
1038
1257
|
* HDF5 layout inspection.
|
|
1039
1258
|
|
|
1259
|
+
`examples/linearization/` demonstrates discovering and reading saved FullFlow
|
|
1260
|
+
steady-state and transient state-space models.
|
|
1261
|
+
|
|
1040
1262
|
---
|
|
1041
1263
|
|
|
1042
1264
|
## Themes
|
|
@@ -1098,7 +1320,7 @@ This is deliberate. The package is meant to be a simple bridge between HDF5 engi
|
|
|
1098
1320
|
|
|
1099
1321
|
---
|
|
1100
1322
|
|
|
1101
|
-
|
|
1323
|
+
## Documentation
|
|
1102
1324
|
|
|
1103
1325
|
Full documentation:
|
|
1104
1326
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
fullplot/__init__.py,sha256=B-KeVjURXVJgn14OEodd7F2Hbj5gIV-o5qf80q0cMxs,1091
|
|
2
|
+
fullplot/cli.py,sha256=u9OpNtKL82YLwJ3xrNgs0B3OsXjYGHFs7TgnydPBj7k,2591
|
|
3
|
+
fullplot/fullplot.py,sha256=uWgeKfB4DLJ4NdNZRGtrX-Pxk8tZ1gt6jOAMLt7ECmU,81859
|
|
4
|
+
fullplot/hdf5.py,sha256=P3EaLWdAKQSWqwrdM_Q3eqUNycGsmrA5zND0G7KreRw,2363
|
|
5
|
+
fullplot/linearization.py,sha256=quM2RiqMSFtZ60JwFU_jVkSDjtp2xD8o2dvT2s_UwmI,13391
|
|
6
|
+
fullplot/maps.py,sha256=TSNp-ITxjCp4yMRLcrxpad3_T7sJvBvKlumI0-6Jr8k,24928
|
|
7
|
+
fullplot/themes.py,sha256=xZR44k29dCNGMwUk2CpkWLkp88mBlC7cK7O9FWmue6o,6220
|
|
8
|
+
fullplot-2.0.0.dist-info/licenses/LICENSE,sha256=ixuiBLtpoK3iv89l7ylKkg9rs2GzF9ukPH7ynZYzK5s,35148
|
|
9
|
+
fullplot-2.0.0.dist-info/WHEEL,sha256=3NO1oYsF7mANFUWVdyEQLM-zmecczWWEmmF5rEbXrWk,80
|
|
10
|
+
fullplot-2.0.0.dist-info/entry_points.txt,sha256=4jALe-r2n_VzCxE1P4AsajIpUdkPq56IZWWOlZqPrq8,48
|
|
11
|
+
fullplot-2.0.0.dist-info/METADATA,sha256=5Fug8vnZqxSzKdchc5YhpccOjiTUvrmpZ2H4cv3TRzg,34104
|
|
12
|
+
fullplot-2.0.0.dist-info/RECORD,,
|
fullplot-0.1.1.dist-info/RECORD
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
fullplot/__init__.py,sha256=hmctlgTH7-BzOtf8VQ2AAsNRX79--2i6tqHdabPwv80,1323
|
|
2
|
-
fullplot/cli.py,sha256=u9OpNtKL82YLwJ3xrNgs0B3OsXjYGHFs7TgnydPBj7k,2591
|
|
3
|
-
fullplot/fullplot.py,sha256=69mSqa858r37cRHyUW9MPiJpcPfnMYa__wGcEqCrqA8,80609
|
|
4
|
-
fullplot/hdf5.py,sha256=P3EaLWdAKQSWqwrdM_Q3eqUNycGsmrA5zND0G7KreRw,2363
|
|
5
|
-
fullplot/maps.py,sha256=xDUApvB-IYKtuyKiExZtx8G3XGPWc-uACsFerl0gDlQ,24322
|
|
6
|
-
fullplot/themes.py,sha256=xZR44k29dCNGMwUk2CpkWLkp88mBlC7cK7O9FWmue6o,6220
|
|
7
|
-
fullplot-0.1.1.dist-info/licenses/LICENSE,sha256=ixuiBLtpoK3iv89l7ylKkg9rs2GzF9ukPH7ynZYzK5s,35148
|
|
8
|
-
fullplot-0.1.1.dist-info/WHEEL,sha256=e4_1dyBeezi8ZjfxrZ3bnVOxFDa3ksqVqH0jTHkUZ3k,81
|
|
9
|
-
fullplot-0.1.1.dist-info/entry_points.txt,sha256=4jALe-r2n_VzCxE1P4AsajIpUdkPq56IZWWOlZqPrq8,48
|
|
10
|
-
fullplot-0.1.1.dist-info/METADATA,sha256=M3-YzwUOcZQZs65Jccr_Rx0kodM-EcbT0F4DHOUPoLI,26361
|
|
11
|
-
fullplot-0.1.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|