fullplot 2.0.0__py3-none-any.whl → 2.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 CHANGED
@@ -29,7 +29,7 @@ from fullplot.maps import (
29
29
  generate_map,
30
30
  )
31
31
 
32
- __version__ = "2.0.0"
32
+ __version__ = "2.0.1"
33
33
 
34
34
  __all__ = [
35
35
  "__version__",
fullplot/fullplot.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import builtins
4
4
  import itertools
5
+ import json
5
6
  import posixpath
6
7
  from contextlib import nullcontext
7
8
  from dataclasses import dataclass
@@ -1275,6 +1276,33 @@ def _to_numpy_index(value):
1275
1276
  return value
1276
1277
 
1277
1278
 
1279
+ def _dataset_array(dataset):
1280
+ """Read native values, with a narrow fallback for old FullFlow array histories."""
1281
+ raw = np.asarray(dataset[()])
1282
+ if (dataset.file.attrs.get("fullflow_format") != "fullflow-simple-hdf5"
1283
+ or raw.ndim != 1 or raw.dtype.kind not in "OSU" or not raw.size):
1284
+ return raw
1285
+ try:
1286
+ values = [None if not value else np.asarray(json.loads(value)) for value in raw]
1287
+ present = [value for value in values if value is not None]
1288
+ if not present or any(value.ndim == 0 or value.dtype.kind not in "biufc"
1289
+ or value.shape != present[0].shape for value in present):
1290
+ return raw
1291
+ dtype = np.result_type(*(value.dtype for value in present), float)
1292
+ return np.stack([np.full(present[0].shape, np.nan, dtype=dtype)
1293
+ if value is None else value for value in values])
1294
+ except (ValueError, TypeError):
1295
+ return raw
1296
+
1297
+
1298
+ def _time_dimension(dataset, array):
1299
+ if "time_axis" in dataset.attrs:
1300
+ return int(dataset.attrs["time_axis"])
1301
+ if dataset.ndim == 1 and array.ndim > 1 and dataset.dtype.kind in "OSU":
1302
+ return 0
1303
+ return None
1304
+
1305
+
1278
1306
  def _apply_slice_for_lines(array: np.ndarray, axis: int, slice_spec) -> tuple[np.ndarray, int]:
1279
1307
  array = np.asarray(array)
1280
1308
 
@@ -1510,14 +1538,16 @@ class H5File:
1510
1538
  def read(self, name: str):
1511
1539
  """Read one dataset by absolute path, relative path, or unique short name.
1512
1540
 
1513
- The returned object is the raw NumPy value from ``h5py``. Use
1541
+ Native datasets retain their NumPy values. Legacy FullFlow JSON array
1542
+ histories are decoded when every sample has the same numeric shape. Use
1514
1543
  ``trace()`` or ``time()`` when the dataset should become a FullPlot
1515
1544
  object with validation and metadata.
1516
1545
  """
1517
1546
 
1518
1547
  with h5py.File(self.filename, "r") as h5:
1519
1548
  path = self._resolve_dataset_path(h5, name)
1520
- return h5[path][()]
1549
+ dataset = h5[path]
1550
+ return dataset[()] if dataset.shape == () else _dataset_array(dataset)
1521
1551
 
1522
1552
  def time(self, name: str = "time") -> TimeAxis:
1523
1553
  """Read a one-dimensional HDF5 dataset as a shared ``TimeAxis``.
@@ -1659,7 +1689,7 @@ class H5File:
1659
1689
  x=None,
1660
1690
  y2=None,
1661
1691
  slice=None,
1662
- axis: int = -1,
1692
+ axis: int | None = None,
1663
1693
  labels=None,
1664
1694
  y2labels=None,
1665
1695
  xlabel: str | None = None,
@@ -1902,6 +1932,7 @@ class H5File:
1902
1932
  dpi: int = 200,
1903
1933
  show: bool = True,
1904
1934
  figsize=(8, 6),
1935
+ orientation: str | None = None,
1905
1936
  ):
1906
1937
  """
1907
1938
  Plot a 2D dataset as a map.
@@ -1934,35 +1965,8 @@ class H5File:
1934
1965
  theme = check_theme(theme)
1935
1966
 
1936
1967
  with h5py.File(self.filename, "r") as h5:
1937
- z_array, z_name = self._read_map_z(h5, z)
1938
-
1939
- z_array = _apply_slice_for_map(z_array, slice)
1940
-
1941
- if z_array.ndim != 2:
1942
- raise PlotDataError(
1943
- f"map requires 2D data after slicing. "
1944
- f"Selected data has shape {z_array.shape}."
1945
- )
1946
-
1947
- rows, cols = z_array.shape
1948
-
1949
- x_array, x_name = self._read_map_axis(
1950
- h5=h5,
1951
- value=x,
1952
- default=np.arange(cols),
1953
- default_name="Column Index",
1954
- valid_lengths=(cols, cols + 1),
1955
- axis_name="x",
1956
- )
1957
-
1958
- y_array, y_name = self._read_map_axis(
1959
- h5=h5,
1960
- value=y,
1961
- default=np.arange(rows),
1962
- default_name="Row Index",
1963
- valid_lengths=(rows, rows + 1),
1964
- axis_name="y",
1965
- )
1968
+ x_array, y_array, z_array, x_name, y_name, z_name = self._field_data(
1969
+ h5, z, x, y, slice, orientation)
1966
1970
 
1967
1971
  fig, ax = plt.subplots(figsize=figsize)
1968
1972
  apply_theme(fig, ax, theme)
@@ -2026,6 +2030,87 @@ class H5File:
2026
2030
 
2027
2031
  return fig, ax
2028
2032
 
2033
+ def _field_data(self, h5, z, x, y, slice_spec, orientation):
2034
+ if orientation not in (None, "xy", "yx"):
2035
+ raise PlotDataError("orientation must be 'xy', 'yx', or None.")
2036
+ data, name = self._read_map_z(h5, z)
2037
+ time_axis = None
2038
+ time_path = None
2039
+ if isinstance(z, str):
2040
+ dataset = h5[self._resolve_dataset_path(h5, z)]
2041
+ time_axis = _time_dimension(dataset, data)
2042
+ time_path = dataset.attrs.get("time_dataset")
2043
+ if time_path is None and time_axis is not None and "time" in dataset.parent:
2044
+ time_path = dataset.parent["time"].name
2045
+ normalized = _normalize_slice_spec(slice_spec, data.ndim)
2046
+ remaining = [axis for axis in range(data.ndim)
2047
+ if not _is_integer_index(normalized.get(axis, builtins.slice(None)))]
2048
+ time_axis = remaining.index(time_axis) if time_axis in remaining else None
2049
+ data = _apply_slice_for_map(data, slice_spec)
2050
+ if data.ndim != 2:
2051
+ raise PlotDataError(f"Field plotting requires 2D data after slicing; got {data.shape}.")
2052
+ if x is None and time_axis is not None and time_path is not None:
2053
+ x = time_path
2054
+ x_values, x_name = self._read_map_axis(h5, x, None, "Column Index", None, "x")
2055
+ y_values, y_name = self._read_map_axis(h5, y, None, "Row Index", None, "y")
2056
+ candidates = []
2057
+ for candidate in ("yx", "xy"):
2058
+ rows, cols = data.shape if candidate == "yx" else data.shape[::-1]
2059
+ if ((x_values is None or len(x_values) in (cols, cols + 1))
2060
+ and (y_values is None or len(y_values) in (rows, rows + 1))):
2061
+ candidates.append(candidate)
2062
+ hint = None
2063
+ if time_axis is not None and time_path is not None:
2064
+ for coordinate, is_x in ((x, True), (y, False)):
2065
+ if isinstance(coordinate, str) and h5[self._resolve_dataset_path(h5, coordinate)] == h5[time_path]:
2066
+ hint = "xy" if (time_axis == 0) == is_x else "yx"
2067
+ if not candidates:
2068
+ raise PlotDataError(f"Coordinate lengths do not match field shape {data.shape}.")
2069
+ if orientation is None:
2070
+ if hint is not None:
2071
+ orientation = hint
2072
+ elif len(candidates) == 1:
2073
+ orientation = candidates[0]
2074
+ elif x is None and y is None:
2075
+ orientation = "yx"
2076
+ else:
2077
+ raise PlotDataError("Ambiguous field orientation; specify orientation='xy' (x first) or 'yx' (y first).")
2078
+ if orientation not in candidates:
2079
+ raise PlotDataError(f"Coordinate lengths do not match field shape {data.shape} and orientation {orientation!r}.")
2080
+ if orientation == "xy":
2081
+ data = data.T
2082
+ if x_values is None:
2083
+ x_values = np.arange(data.shape[1])
2084
+ if y_values is None:
2085
+ y_values = np.arange(data.shape[0])
2086
+ return x_values, y_values, data, x_name, y_name, name
2087
+
2088
+ def surface(self, z, x=None, y=None, slice=None, *, orientation=None,
2089
+ xlabel=None, ylabel=None, zlabel=None, title=None, theme="dark",
2090
+ save=None, dpi=200, show=False, figsize=(9, 6)):
2091
+ """Plot a numerical 3D surface using the same axes and orientation as map()."""
2092
+ theme = check_theme(theme)
2093
+ with h5py.File(self.filename, "r") as h5:
2094
+ xs, ys, values, x_name, y_name, z_name = self._field_data(
2095
+ h5, z, x, y, slice, orientation)
2096
+ if len(xs) == values.shape[1] + 1:
2097
+ xs = (xs[:-1] + xs[1:]) / 2
2098
+ if len(ys) == values.shape[0] + 1:
2099
+ ys = (ys[:-1] + ys[1:]) / 2
2100
+ xx, yy = np.meshgrid(xs, ys)
2101
+ fig = plt.figure(figsize=figsize)
2102
+ ax = fig.add_subplot(111, projection="3d")
2103
+ apply_theme(fig, ax, theme)
2104
+ ax.plot_surface(xx, yy, values, cmap=theme_settings(theme)["map_cmap"])
2105
+ ax.set_xlabel(xlabel or x_name)
2106
+ ax.set_ylabel(ylabel or y_name)
2107
+ ax.set_zlabel(zlabel or z_name)
2108
+ if title is not None:
2109
+ ax.set_title(title)
2110
+ fig.tight_layout()
2111
+ self._save_and_show(fig, save, dpi, show)
2112
+ return fig, ax
2113
+
2029
2114
  def _resolve_dataset_path(self, h5: h5py.File, selector: str) -> str:
2030
2115
  if not isinstance(selector, str):
2031
2116
  raise DatasetNotFoundError(f"Dataset selector must be a string, got {type(selector)}.")
@@ -2239,7 +2324,10 @@ class H5File:
2239
2324
  path = self._resolve_dataset_path(h5, selector)
2240
2325
  base_label = _basename(path)
2241
2326
 
2242
- array = np.asarray(h5[path][()])
2327
+ array = _dataset_array(h5[path])
2328
+ if axis is None:
2329
+ time_axis = _time_dimension(h5[path], array)
2330
+ axis = -1 if time_axis is None else time_axis
2243
2331
 
2244
2332
  if not _is_numeric_dtype(array.dtype):
2245
2333
  raise PlotDataError(f"Dataset {path!r} is not numeric.")
@@ -2321,7 +2409,7 @@ class H5File:
2321
2409
 
2322
2410
  for selector in z:
2323
2411
  path = self._resolve_dataset_path(h5, selector)
2324
- array = np.asarray(h5[path][()])
2412
+ array = _dataset_array(h5[path])
2325
2413
 
2326
2414
  if not _is_numeric_dtype(array.dtype):
2327
2415
  raise PlotDataError(f"Dataset {path!r} is not numeric.")
@@ -2343,7 +2431,7 @@ class H5File:
2343
2431
 
2344
2432
  z_path = self._resolve_dataset_path(h5, z)
2345
2433
  z_name = _basename(z_path)
2346
- z_array = np.asarray(h5[z_path][()])
2434
+ z_array = _dataset_array(h5[z_path])
2347
2435
 
2348
2436
  if not _is_numeric_dtype(z_array.dtype):
2349
2437
  raise PlotDataError("z must be numeric.")
@@ -2356,8 +2444,12 @@ class H5File:
2356
2444
 
2357
2445
  if isinstance(value, str):
2358
2446
  path = self._resolve_dataset_path(h5, value)
2359
- array = np.asarray(h5[path][()])
2447
+ array = _dataset_array(h5[path])
2360
2448
  name = _basename(path)
2449
+ if array.ndim == 2 and _time_dimension(h5[path], array) == 0:
2450
+ if not len(array) or not np.array_equal(array, np.broadcast_to(array[0], array.shape), equal_nan=True):
2451
+ raise PlotDataError(f"{axis_name} coordinate history must be constant over time.")
2452
+ array = array[0]
2361
2453
  else:
2362
2454
  array = np.asarray(value)
2363
2455
  name = default_name
@@ -2368,7 +2460,7 @@ class H5File:
2368
2460
  if not _is_numeric_dtype(array.dtype):
2369
2461
  raise PlotDataError(f"{axis_name} must be numeric.")
2370
2462
 
2371
- if len(array) not in valid_lengths:
2463
+ if valid_lengths is not None and len(array) not in valid_lengths:
2372
2464
  valid_text = " or ".join(str(length) for length in valid_lengths)
2373
2465
  raise PlotDataError(
2374
2466
  f"{axis_name} has length {len(array)}, but expected length {valid_text}."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fullplot
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: HDF5 plotting, trace processing, linearization reading, and map generation for engineering data.
5
5
  Keywords: hdf5,plotting,engineering,simulation,test-data,linearization,state-space,map-generation,scientific-computing
6
6
  Author: Saaketh Ramoju
@@ -1079,6 +1079,91 @@ run.map(
1079
1079
 
1080
1080
  ---
1081
1081
 
1082
+ ## Time-dependent numerical fields and 3D surfaces
1083
+
1084
+ A distributed simulation may record `mass_distribution`, `temperature`,
1085
+ `heat_flux`, or `aero_forces` as multidimensional HDF5 datasets. FullPlot can
1086
+ read these directly; it does not require FullFlow at plotting time.
1087
+
1088
+ For FullFlow 4.0.3 output, numeric field histories have time on axis 0:
1089
+
1090
+ | Field | Dataset shape | Interpretation |
1091
+ | --- | --- | --- |
1092
+ | Mass or temperature | `(time, station)` | One scalar per station per timestep |
1093
+ | Aerodynamic forces | `(time, station, 3)` | Three force components per station |
1094
+ | Fixed final field | `(station,)` or `(station, 3)` | No time axis |
1095
+
1096
+ FullFlow's `State(np.ndarray)` and `StateArray` are saved identically as
1097
+ numerical fields. The HDF5 field has a `time_axis` attribute and a reference to
1098
+ the time dataset. FullPlot uses this information where available, so you do not
1099
+ need to transpose time-first arrays just to make a heat map.
1100
+
1101
+ ### Plot a FullFlow field
1102
+
1103
+ ```python
1104
+ import fullplot as fplt
1105
+
1106
+ run = fplt.open("2thermal_cells.h5").at("Thermal_Cells/transient")
1107
+ field = "components/Nose_Thermal/temperature"
1108
+ position = "components/Nose_Thermal/stations"
1109
+
1110
+ run.map(
1111
+ z=field,
1112
+ x="time",
1113
+ y=position,
1114
+ xlabel="Time [s]",
1115
+ ylabel="Axial Position [m]",
1116
+ zlabel="Temperature [K]",
1117
+ )
1118
+
1119
+ run.plot(
1120
+ y=field,
1121
+ x="time",
1122
+ slice={1: 3}, # temperature at station index 3
1123
+ xlabel="Time [s]",
1124
+ ylabel="Temperature [K]",
1125
+ )
1126
+
1127
+ run.surface(
1128
+ z=field,
1129
+ x="time",
1130
+ y=position,
1131
+ xlabel="Time [s]",
1132
+ ylabel="Axial Position [m]",
1133
+ zlabel="Temperature [K]",
1134
+ )
1135
+ ```
1136
+
1137
+ `map()` renders the third variable as color; `surface()` draws a true 3D
1138
+ Matplotlib surface. Both accept `slice=`, `save=`, `show=`, and the usual title
1139
+ and label arguments. `surface()` returns the figure and 3D axes, not a
1140
+ browser-based interactive Plotly object. For `(time, station, component)`, use
1141
+ `slice={2: 0}` to select a force component before plotting.
1142
+
1143
+ ### Axis selection and compatibility
1144
+
1145
+ For a 2D field, `x` and `y` must match its two coordinate dimensions (center or
1146
+ edge arrays are supported where appropriate). FullPlot uses coordinate
1147
+ lengths and available `time_axis`/`time_dataset` metadata to determine the
1148
+ orientation. If both orientations remain possible, provide
1149
+ `orientation="xy"` for data indexed `[x, y]` or `orientation="yx"` for `[y, x]`.
1150
+ A fixed station field recorded on every step may be reduced automatically to
1151
+ its unchanged one-dimensional coordinate array. For generic HDF5 data without
1152
+ axis hints, the established row/column behavior remains available.
1153
+
1154
+ `plot()` selects the time dimension automatically if present in the metadata;
1155
+ for other multidimensional data, supply `axis=` or `slice=` as needed. Numeric
1156
+ JSON-string array histories from older FullFlow HDF5 exports can also be read
1157
+ with `read()` and displayed without a conversion script. Unrelated text fields
1158
+ remain text.
1159
+
1160
+ The standalone example
1161
+ [`15field_results.py`](examples/hdf5_plotting/15field_results.py) creates a
1162
+ numeric HDF5 field and makes a heat map, a trace, and a 3D surface. You can
1163
+ also pass a FullFlow thermal example's HDF5 file as its first argument.
1164
+
1165
+ ---
1166
+
1082
1167
  ## Map generation
1083
1168
 
1084
1169
  FullPlot can generate simple rectangular-grid HDF5 maps.
@@ -1228,6 +1313,7 @@ examples/
1228
1313
  * log axes and log color scales,
1229
1314
  * multidimensional line traces,
1230
1315
  * multidimensional slices,
1316
+ * native time-first field histories and 3D surface plots,
1231
1317
  * light theme plots,
1232
1318
  * module-level API calls,
1233
1319
  * saving figures.
@@ -0,0 +1,12 @@
1
+ fullplot/__init__.py,sha256=x9ww_yzw0-khKIap3ItryWS8Ddouj4LSU3Myz5ZUXfE,1091
2
+ fullplot/cli.py,sha256=u9OpNtKL82YLwJ3xrNgs0B3OsXjYGHFs7TgnydPBj7k,2591
3
+ fullplot/fullplot.py,sha256=0Bkh5FEwAj0JchxcO949erpz_OEV23vPBPFhmwo7d_E,87213
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.1.dist-info/licenses/LICENSE,sha256=ixuiBLtpoK3iv89l7ylKkg9rs2GzF9ukPH7ynZYzK5s,35148
9
+ fullplot-2.0.1.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
10
+ fullplot-2.0.1.dist-info/entry_points.txt,sha256=4jALe-r2n_VzCxE1P4AsajIpUdkPq56IZWWOlZqPrq8,48
11
+ fullplot-2.0.1.dist-info/METADATA,sha256=ahkfDRQxLlrVMc4XbigFSqLQXUTGrbBTji640aVwHdg,37329
12
+ fullplot-2.0.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.13.6
2
+ Generator: uv 0.13.7
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,12 +0,0 @@
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,,