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/maps.py
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
import datetime as _datetime
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
import h5py
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from fullplot.hdf5 import dataset_names, hdf5_filename, safe_group_name
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_STRING_DTYPE = h5py.string_dtype(encoding="utf-8")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FullPlotMapError(Exception):
|
|
20
|
+
"""Base class for FullPlot map-generation errors."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MapGenerationError(FullPlotMapError, RuntimeError):
|
|
24
|
+
"""A generated map failed during evaluation, output discovery, or resume."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MapOutputError(FullPlotMapError, TypeError):
|
|
28
|
+
"""A map evaluate() function returned invalid outputs."""
|
|
29
|
+
_RESERVED_GROUP_NAMES = {"axes", "outputs", "status"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Axis:
|
|
34
|
+
"""One independent variable for a generated HDF5 map.
|
|
35
|
+
|
|
36
|
+
An ``Axis`` defines one input direction of a tabulated map generated by
|
|
37
|
+
:func:`generate_map`. The axis ``name`` is important: it becomes the
|
|
38
|
+
keyword passed to the map-generation ``evaluate`` function and the input
|
|
39
|
+
name that must later be supplied to ``Map.from_hdf5``.
|
|
40
|
+
|
|
41
|
+
For example, an axis named ``"pressure"`` means that ``generate_map``
|
|
42
|
+
will call ``evaluate(pressure=...)`` and that the map is later loaded with
|
|
43
|
+
``inputs={"pressure": some_state}``.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
name:
|
|
48
|
+
Public input name for this axis. It must be a non-empty string and
|
|
49
|
+
cannot contain ``"/"`` because it is also used as an HDF5 dataset name.
|
|
50
|
+
values:
|
|
51
|
+
Strictly increasing one-dimensional grid values in physical units.
|
|
52
|
+
units:
|
|
53
|
+
Optional units string stored in the HDF5 file for user inspection.
|
|
54
|
+
FullPlot does not perform unit conversion.
|
|
55
|
+
spacing:
|
|
56
|
+
How interpolation should treat this axis. Supported values are
|
|
57
|
+
``"linear"``, ``"log"``, and ``"values"``. ``"log"`` stores physical
|
|
58
|
+
values but tells ``Map`` to interpolate versus ``log(value)``.
|
|
59
|
+
|
|
60
|
+
Notes
|
|
61
|
+
-----
|
|
62
|
+
Use an ``Axis`` for a quantity that should vary across the map. Use
|
|
63
|
+
``constants`` in :func:`generate_map` for quantities that should be passed
|
|
64
|
+
to every ``evaluate`` call but are fixed for the generated map.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
name: str
|
|
68
|
+
values: np.ndarray
|
|
69
|
+
units: str = ""
|
|
70
|
+
spacing: str = "values"
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def linear(
|
|
74
|
+
cls,
|
|
75
|
+
name: str,
|
|
76
|
+
start: float,
|
|
77
|
+
stop: float,
|
|
78
|
+
count: int,
|
|
79
|
+
units: str = "",
|
|
80
|
+
) -> "Axis":
|
|
81
|
+
"""Create an evenly spaced axis using ``np.linspace``.
|
|
82
|
+
|
|
83
|
+
The returned axis includes both ``start`` and ``stop`` and has
|
|
84
|
+
``spacing="linear"``. Use this for inputs that vary smoothly with
|
|
85
|
+
the physical value, such as temperature, mixture ratio, or area ratio.
|
|
86
|
+
"""
|
|
87
|
+
return cls(
|
|
88
|
+
name=name,
|
|
89
|
+
values=np.linspace(start, stop, count, dtype=float),
|
|
90
|
+
units=units,
|
|
91
|
+
spacing="linear",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def log(
|
|
96
|
+
cls,
|
|
97
|
+
name: str,
|
|
98
|
+
start: float,
|
|
99
|
+
stop: float,
|
|
100
|
+
count: int,
|
|
101
|
+
units: str = "",
|
|
102
|
+
) -> "Axis":
|
|
103
|
+
"""Create a logarithmically spaced axis using ``np.geomspace``.
|
|
104
|
+
|
|
105
|
+
The stored axis values are still physical values, not their logarithms.
|
|
106
|
+
During lookup, ``Map`` applies ``log`` to both the stored axis values
|
|
107
|
+
and the runtime input value before interpolation. ``start`` and
|
|
108
|
+
``stop`` must be positive. Use this for pressure or other quantities
|
|
109
|
+
spanning several orders of magnitude.
|
|
110
|
+
"""
|
|
111
|
+
return cls(
|
|
112
|
+
name=name,
|
|
113
|
+
values=np.geomspace(start, stop, count, dtype=float),
|
|
114
|
+
units=units,
|
|
115
|
+
spacing="log",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def values(
|
|
120
|
+
cls,
|
|
121
|
+
name: str,
|
|
122
|
+
values,
|
|
123
|
+
units: str = "",
|
|
124
|
+
) -> "Axis":
|
|
125
|
+
"""Create an axis from explicit grid values.
|
|
126
|
+
|
|
127
|
+
Use this when the grid should follow measured points, hand-picked
|
|
128
|
+
breakpoints, map speed lines, or any other non-uniform spacing. The
|
|
129
|
+
values must be finite, one-dimensional, and strictly increasing after
|
|
130
|
+
validation.
|
|
131
|
+
"""
|
|
132
|
+
return cls(
|
|
133
|
+
name=name,
|
|
134
|
+
values=np.asarray(values, dtype=float),
|
|
135
|
+
units=units,
|
|
136
|
+
spacing="values",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _validate_hdf5_name(name: str, label: str) -> None:
|
|
142
|
+
if not isinstance(name, str) or not name.strip():
|
|
143
|
+
raise ValueError(f"{label} names must be non-empty strings.")
|
|
144
|
+
|
|
145
|
+
if "/" in name:
|
|
146
|
+
raise ValueError(f"{label} name '{name}' cannot contain '/'.")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _validate_axis(axis: Axis) -> None:
|
|
150
|
+
_validate_hdf5_name(axis.name, "Axis")
|
|
151
|
+
|
|
152
|
+
values = np.asarray(axis.values, dtype=float)
|
|
153
|
+
|
|
154
|
+
if axis.spacing not in {"linear", "log", "values"}:
|
|
155
|
+
raise ValueError(f"Axis '{axis.name}' has unsupported spacing '{axis.spacing}'.")
|
|
156
|
+
|
|
157
|
+
if values.ndim != 1:
|
|
158
|
+
raise ValueError(f"Axis '{axis.name}' must be one-dimensional.")
|
|
159
|
+
|
|
160
|
+
if len(values) < 2:
|
|
161
|
+
raise ValueError(f"Axis '{axis.name}' requires at least two points.")
|
|
162
|
+
|
|
163
|
+
if not np.all(np.isfinite(values)):
|
|
164
|
+
raise ValueError(f"Axis '{axis.name}' values must all be finite.")
|
|
165
|
+
|
|
166
|
+
if np.any(np.diff(values) <= 0.0):
|
|
167
|
+
raise ValueError(f"Axis '{axis.name}' must be strictly increasing.")
|
|
168
|
+
|
|
169
|
+
if axis.spacing == "log" and np.any(values <= 0.0):
|
|
170
|
+
raise ValueError(f"Log axis '{axis.name}' must contain only positive values.")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _shape_from_axes(axes: list[Axis]) -> tuple[int, ...]:
|
|
174
|
+
return tuple(len(axis.values) for axis in axes)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _inputs_from_index(axes: list[Axis], index: tuple[int, ...], constants: dict) -> dict[str, float]:
|
|
178
|
+
inputs = dict(constants)
|
|
179
|
+
|
|
180
|
+
for axis, i in zip(axes, index):
|
|
181
|
+
inputs[axis.name] = float(axis.values[i])
|
|
182
|
+
|
|
183
|
+
return inputs
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _json_default(value):
|
|
187
|
+
if isinstance(value, np.ndarray):
|
|
188
|
+
return value.tolist()
|
|
189
|
+
|
|
190
|
+
if isinstance(value, np.generic):
|
|
191
|
+
return value.item()
|
|
192
|
+
|
|
193
|
+
return repr(value)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _json_roundtrip(value):
|
|
197
|
+
return json.loads(json.dumps(value, default=_json_default))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _json_attr(attrs, name: str, default):
|
|
201
|
+
if name not in attrs:
|
|
202
|
+
return default
|
|
203
|
+
|
|
204
|
+
value = attrs[name]
|
|
205
|
+
|
|
206
|
+
if isinstance(value, bytes):
|
|
207
|
+
value = value.decode("utf-8")
|
|
208
|
+
|
|
209
|
+
if isinstance(value, str):
|
|
210
|
+
return json.loads(value)
|
|
211
|
+
|
|
212
|
+
return value
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _validate_scalar_outputs(
|
|
217
|
+
values: dict,
|
|
218
|
+
outputs: list[str] | tuple[str, ...] | None = None,
|
|
219
|
+
) -> dict[str, float]:
|
|
220
|
+
if not isinstance(values, dict):
|
|
221
|
+
raise MapOutputError("evaluate() must return a dictionary of scalar outputs.")
|
|
222
|
+
|
|
223
|
+
scalar_outputs: dict[str, float] = {}
|
|
224
|
+
|
|
225
|
+
for output_name, value in values.items():
|
|
226
|
+
try:
|
|
227
|
+
_validate_hdf5_name(output_name, "Output")
|
|
228
|
+
except ValueError as exc:
|
|
229
|
+
raise MapOutputError(str(exc)) from exc
|
|
230
|
+
|
|
231
|
+
if output_name in _RESERVED_GROUP_NAMES:
|
|
232
|
+
raise MapOutputError(f"evaluate() output name '{output_name}' is reserved.")
|
|
233
|
+
|
|
234
|
+
if isinstance(value, dict):
|
|
235
|
+
raise MapOutputError(
|
|
236
|
+
f"evaluate() output '{output_name}' must be a scalar number. "
|
|
237
|
+
"Nested dictionaries and vector outputs are not supported. "
|
|
238
|
+
"Return individual scalar entries instead."
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if isinstance(value, (str, bytes, bool, np.bool_)):
|
|
242
|
+
raise MapOutputError(f"evaluate() output '{output_name}' must be numeric.")
|
|
243
|
+
|
|
244
|
+
array = np.asarray(value)
|
|
245
|
+
|
|
246
|
+
if array.shape != ():
|
|
247
|
+
raise MapOutputError(
|
|
248
|
+
f"evaluate() output '{output_name}' must be a scalar number. "
|
|
249
|
+
"Lists, tuples, arrays, and vector outputs are not supported."
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
scalar_outputs[output_name] = float(array)
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
raise MapOutputError(f"evaluate() output '{output_name}' must be numeric.") from exc
|
|
256
|
+
|
|
257
|
+
if outputs is not None:
|
|
258
|
+
missing = [
|
|
259
|
+
output_name
|
|
260
|
+
for output_name in outputs
|
|
261
|
+
if output_name not in scalar_outputs
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
if missing:
|
|
265
|
+
raise MapOutputError(f"evaluate() did not return required outputs: {missing}")
|
|
266
|
+
|
|
267
|
+
scalar_outputs = {
|
|
268
|
+
output_name: scalar_outputs[output_name]
|
|
269
|
+
for output_name in outputs
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return scalar_outputs
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _write_axis_dataset(axes_group: h5py.Group, axis: Axis) -> None:
|
|
276
|
+
dataset = axes_group.create_dataset(
|
|
277
|
+
axis.name,
|
|
278
|
+
data=np.asarray(axis.values, dtype=float),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
dataset.attrs["name"] = axis.name
|
|
282
|
+
dataset.attrs["units"] = axis.units
|
|
283
|
+
dataset.attrs["spacing"] = axis.spacing
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _create_group(
|
|
287
|
+
file: h5py.File,
|
|
288
|
+
group: str,
|
|
289
|
+
axes: list[Axis],
|
|
290
|
+
output_names: list[str],
|
|
291
|
+
constants: dict,
|
|
292
|
+
metadata: dict,
|
|
293
|
+
fill_value: float,
|
|
294
|
+
compression: str | None,
|
|
295
|
+
compression_opts,
|
|
296
|
+
) -> h5py.Group:
|
|
297
|
+
map_group = file.create_group(safe_group_name(group))
|
|
298
|
+
|
|
299
|
+
map_group.attrs["kind"] = "map"
|
|
300
|
+
map_group.attrs["name"] = group
|
|
301
|
+
map_group.attrs["map_format"] = "fullflow-map-v3"
|
|
302
|
+
map_group.attrs["created_utc"] = _datetime.datetime.now(_datetime.UTC).isoformat()
|
|
303
|
+
map_group.attrs["axis_order"] = json.dumps([axis.name for axis in axes])
|
|
304
|
+
map_group.attrs["output_names"] = json.dumps(output_names)
|
|
305
|
+
map_group.attrs["constants"] = json.dumps(constants, default=_json_default)
|
|
306
|
+
map_group.attrs["metadata"] = json.dumps(metadata, default=_json_default)
|
|
307
|
+
|
|
308
|
+
axes_group = map_group.create_group("axes")
|
|
309
|
+
outputs_group = map_group.create_group("outputs")
|
|
310
|
+
status_group = map_group.create_group("status")
|
|
311
|
+
|
|
312
|
+
for axis in axes:
|
|
313
|
+
_write_axis_dataset(axes_group, axis)
|
|
314
|
+
|
|
315
|
+
shape = _shape_from_axes(axes)
|
|
316
|
+
|
|
317
|
+
if compression is None:
|
|
318
|
+
compression_opts = None
|
|
319
|
+
|
|
320
|
+
for output_name in output_names:
|
|
321
|
+
outputs_group.create_dataset(
|
|
322
|
+
output_name,
|
|
323
|
+
shape=shape,
|
|
324
|
+
dtype="f8",
|
|
325
|
+
chunks=True,
|
|
326
|
+
compression=compression,
|
|
327
|
+
compression_opts=compression_opts,
|
|
328
|
+
fillvalue=fill_value,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
status_group.create_dataset(
|
|
332
|
+
"success",
|
|
333
|
+
shape=shape,
|
|
334
|
+
dtype="?",
|
|
335
|
+
chunks=True,
|
|
336
|
+
compression=compression,
|
|
337
|
+
compression_opts=compression_opts,
|
|
338
|
+
fillvalue=False,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
status_group.create_dataset(
|
|
342
|
+
"message",
|
|
343
|
+
shape=shape,
|
|
344
|
+
dtype=_STRING_DTYPE,
|
|
345
|
+
chunks=True,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return map_group
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _check_existing_group(
|
|
352
|
+
map_group: h5py.Group,
|
|
353
|
+
axes: list[Axis],
|
|
354
|
+
outputs: list[str] | tuple[str, ...] | None,
|
|
355
|
+
constants: dict,
|
|
356
|
+
metadata: dict,
|
|
357
|
+
) -> list[str]:
|
|
358
|
+
shape = _shape_from_axes(axes)
|
|
359
|
+
|
|
360
|
+
if "axes" not in map_group or not isinstance(map_group["axes"], h5py.Group):
|
|
361
|
+
raise MapGenerationError("Existing map group is not a fullflow-map-v3 group and cannot be resumed.")
|
|
362
|
+
|
|
363
|
+
if "outputs" not in map_group or not isinstance(map_group["outputs"], h5py.Group):
|
|
364
|
+
raise MapGenerationError("Existing map group is missing required group 'outputs'.")
|
|
365
|
+
|
|
366
|
+
if "status" not in map_group or not isinstance(map_group["status"], h5py.Group):
|
|
367
|
+
raise MapGenerationError("Existing map group is missing required group 'status'.")
|
|
368
|
+
|
|
369
|
+
axes_group = map_group["axes"]
|
|
370
|
+
outputs_group = map_group["outputs"]
|
|
371
|
+
status_group = map_group["status"]
|
|
372
|
+
|
|
373
|
+
for axis in axes:
|
|
374
|
+
if axis.name not in axes_group:
|
|
375
|
+
raise MapGenerationError(f"Existing map group is missing axis '{axis.name}'.")
|
|
376
|
+
|
|
377
|
+
axis_values = np.asarray(axes_group[axis.name][()], dtype=float)
|
|
378
|
+
|
|
379
|
+
if not np.array_equal(axis_values, np.asarray(axis.values, dtype=float)):
|
|
380
|
+
raise MapGenerationError(f"Existing map axis '{axis.name}' does not match requested axis.")
|
|
381
|
+
|
|
382
|
+
axis_order = json.loads(map_group.attrs.get("axis_order", "[]"))
|
|
383
|
+
|
|
384
|
+
if axis_order and axis_order != [axis.name for axis in axes]:
|
|
385
|
+
raise MapGenerationError("Existing map axis order does not match requested axis order.")
|
|
386
|
+
|
|
387
|
+
for axis in axes:
|
|
388
|
+
existing_spacing = axes_group[axis.name].attrs.get("spacing", "values")
|
|
389
|
+
if isinstance(existing_spacing, bytes):
|
|
390
|
+
existing_spacing = existing_spacing.decode("utf-8")
|
|
391
|
+
if str(existing_spacing).lower() != str(axis.spacing).lower():
|
|
392
|
+
raise MapGenerationError(
|
|
393
|
+
f"Existing map axis '{axis.name}' has spacing {existing_spacing!r}; "
|
|
394
|
+
f"requested spacing {axis.spacing!r}."
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
existing_constants = _json_attr(map_group.attrs, "constants", {})
|
|
398
|
+
requested_constants = _json_roundtrip(constants)
|
|
399
|
+
if existing_constants != requested_constants:
|
|
400
|
+
raise MapGenerationError(
|
|
401
|
+
"Existing map constants do not match requested constants. "
|
|
402
|
+
"Use overwrite=True to regenerate the map with different constants."
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
existing_metadata = _json_attr(map_group.attrs, "metadata", {})
|
|
406
|
+
requested_metadata = _json_roundtrip(metadata)
|
|
407
|
+
if existing_metadata != requested_metadata:
|
|
408
|
+
raise MapGenerationError(
|
|
409
|
+
"Existing map metadata does not match requested metadata. "
|
|
410
|
+
"Use overwrite=True to regenerate the map with different metadata."
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
output_names = list(outputs) if outputs is not None else dataset_names(outputs_group, set())
|
|
414
|
+
|
|
415
|
+
if not output_names:
|
|
416
|
+
raise MapGenerationError("Existing map group does not contain any output datasets.")
|
|
417
|
+
|
|
418
|
+
missing = [
|
|
419
|
+
output_name
|
|
420
|
+
for output_name in output_names
|
|
421
|
+
if output_name not in outputs_group
|
|
422
|
+
]
|
|
423
|
+
|
|
424
|
+
if missing:
|
|
425
|
+
raise MapGenerationError(f"Existing map group is missing requested outputs: {missing}")
|
|
426
|
+
|
|
427
|
+
for output_name in output_names:
|
|
428
|
+
if outputs_group[output_name].shape != shape:
|
|
429
|
+
raise MapGenerationError(
|
|
430
|
+
f"Existing output '{output_name}' has shape {outputs_group[output_name].shape}; "
|
|
431
|
+
f"expected {shape}."
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
if "success" not in status_group:
|
|
435
|
+
raise MapGenerationError("Existing map group is missing required dataset 'status/success'.")
|
|
436
|
+
|
|
437
|
+
if "message" not in status_group:
|
|
438
|
+
raise MapGenerationError("Existing map group is missing required dataset 'status/message'.")
|
|
439
|
+
|
|
440
|
+
if status_group["success"].shape != shape:
|
|
441
|
+
raise MapGenerationError("Existing status/success shape does not match requested map axes.")
|
|
442
|
+
|
|
443
|
+
return output_names
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _discover_outputs(
|
|
447
|
+
axes: list[Axis],
|
|
448
|
+
constants: dict,
|
|
449
|
+
evaluate: Callable,
|
|
450
|
+
outputs: list[str] | tuple[str, ...] | None,
|
|
451
|
+
raise_errors: bool,
|
|
452
|
+
) -> tuple[list[str], tuple[int, ...], dict[str, float], list[tuple[tuple[int, ...], str]]]:
|
|
453
|
+
shape = _shape_from_axes(axes)
|
|
454
|
+
failures: list[tuple[tuple[int, ...], str]] = []
|
|
455
|
+
|
|
456
|
+
for index in np.ndindex(shape):
|
|
457
|
+
inputs = _inputs_from_index(axes, index, constants)
|
|
458
|
+
|
|
459
|
+
try:
|
|
460
|
+
values = _validate_scalar_outputs(evaluate(**inputs), outputs)
|
|
461
|
+
return list(values.keys()), index, values, failures
|
|
462
|
+
except MapOutputError:
|
|
463
|
+
raise
|
|
464
|
+
except Exception as exc:
|
|
465
|
+
if raise_errors:
|
|
466
|
+
raise
|
|
467
|
+
|
|
468
|
+
failures.append((index, f"{type(exc).__name__}: {exc}"))
|
|
469
|
+
|
|
470
|
+
raise MapGenerationError("Could not discover map outputs because every evaluated point failed.")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _write_failure(
|
|
474
|
+
map_group: h5py.Group,
|
|
475
|
+
index: tuple[int, ...],
|
|
476
|
+
output_names: list[str],
|
|
477
|
+
fill_value: float,
|
|
478
|
+
failure_message: str,
|
|
479
|
+
) -> None:
|
|
480
|
+
outputs_group = map_group["outputs"]
|
|
481
|
+
status_group = map_group["status"]
|
|
482
|
+
|
|
483
|
+
for output_name in output_names:
|
|
484
|
+
outputs_group[output_name][index] = fill_value
|
|
485
|
+
|
|
486
|
+
status_group["success"][index] = False
|
|
487
|
+
status_group["message"][index] = failure_message
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def generate_map(
|
|
491
|
+
filename: str | Path,
|
|
492
|
+
axes: list[Axis] | tuple[Axis, ...],
|
|
493
|
+
evaluate: Callable,
|
|
494
|
+
group: str = "map",
|
|
495
|
+
outputs: list[str] | tuple[str, ...] | None = None,
|
|
496
|
+
constants: dict | None = None,
|
|
497
|
+
metadata: dict | None = None,
|
|
498
|
+
resume: bool = True,
|
|
499
|
+
overwrite: bool = False,
|
|
500
|
+
fill_value: float = np.nan,
|
|
501
|
+
compression: str | None = "gzip",
|
|
502
|
+
compression_opts=None,
|
|
503
|
+
flush_every: int = 25,
|
|
504
|
+
raise_errors: bool = False,
|
|
505
|
+
) -> str:
|
|
506
|
+
"""Generate a FullPlot HDF5 map compatible with FullFlow Map.from_hdf5().
|
|
507
|
+
|
|
508
|
+
The generated file can be loaded by FullFlow ``Map.from_hdf5`` or any reader that follows the same HDF5 map layout. ``generate_map`` evaluates a Python function on every
|
|
509
|
+
combination of axis values and writes one HDF5 dataset per scalar output.
|
|
510
|
+
|
|
511
|
+
Naming rules
|
|
512
|
+
------------
|
|
513
|
+
Axis names are the input names. If an axis is named ``"pressure"``,
|
|
514
|
+
``evaluate`` is called with ``pressure=...`` and the map is later loaded
|
|
515
|
+
with ``inputs={"pressure": some_state}``. Output names come from either the
|
|
516
|
+
``outputs`` argument or the keys returned by ``evaluate``.
|
|
517
|
+
|
|
518
|
+
Parameters
|
|
519
|
+
----------
|
|
520
|
+
filename:
|
|
521
|
+
Output HDF5 filename. The ``.h5`` extension is added when omitted.
|
|
522
|
+
axes:
|
|
523
|
+
Independent variables to sweep. The output dataset shape follows this
|
|
524
|
+
order. For example, axes with lengths ``(30, 50)`` create outputs with
|
|
525
|
+
shape ``(30, 50)``.
|
|
526
|
+
evaluate:
|
|
527
|
+
Callable invoked as ``evaluate(**inputs)`` at every grid point.
|
|
528
|
+
``inputs`` contains one value for each axis plus every entry in
|
|
529
|
+
``constants``. It must return a flat ``dict[str, scalar_number]``.
|
|
530
|
+
Tuples, lists, arrays, nested dictionaries, strings, and booleans are
|
|
531
|
+
rejected as map outputs. Return separate scalar keys instead, such as
|
|
532
|
+
``{"Y_H2O": ..., "Y_CO2": ...}``.
|
|
533
|
+
group:
|
|
534
|
+
HDF5 group name for this map, such as ``"products_tp"``.
|
|
535
|
+
outputs:
|
|
536
|
+
Optional list of output names to require and store. If omitted, output
|
|
537
|
+
names are discovered from the first successful ``evaluate`` call. If
|
|
538
|
+
provided, each listed name must be returned by ``evaluate``; extra
|
|
539
|
+
returned keys are ignored.
|
|
540
|
+
constants:
|
|
541
|
+
Fixed keyword arguments passed to every ``evaluate`` call but not swept
|
|
542
|
+
as axes. Constants are stored in the map group's HDF5 attributes, not as
|
|
543
|
+
``/axes`` datasets. Use constants for values like a fixed mixture ratio.
|
|
544
|
+
metadata:
|
|
545
|
+
Extra user metadata stored as JSON in the map group's attributes.
|
|
546
|
+
resume:
|
|
547
|
+
If ``True`` and the map group already exists, reuse successful grid
|
|
548
|
+
points from ``status/success`` and only evaluate missing or failed
|
|
549
|
+
points. The existing axes and requested outputs must match.
|
|
550
|
+
overwrite:
|
|
551
|
+
If ``True`` and the map group already exists, delete that group and
|
|
552
|
+
regenerate it from scratch. This takes priority over ``resume``. Use
|
|
553
|
+
this when you changed axes, outputs, constants, metadata, or the
|
|
554
|
+
equations inside ``evaluate`` and want a clean file.
|
|
555
|
+
fill_value:
|
|
556
|
+
Value written to output datasets for failed points when
|
|
557
|
+
``raise_errors=False``. The default is ``np.nan``.
|
|
558
|
+
compression, compression_opts:
|
|
559
|
+
HDF5 compression options for output and status datasets.
|
|
560
|
+
flush_every:
|
|
561
|
+
Flush the file to disk after this many evaluated points. Use a smaller
|
|
562
|
+
value for safer long runs or a larger value for slightly faster writes.
|
|
563
|
+
raise_errors:
|
|
564
|
+
If ``True``, immediately raise the original exception from
|
|
565
|
+
``evaluate`` and stop generation. If ``False``, failed points are
|
|
566
|
+
recorded in ``status/message``, ``status/success`` is set to ``False``,
|
|
567
|
+
and outputs are filled with ``fill_value``.
|
|
568
|
+
|
|
569
|
+
Returns
|
|
570
|
+
-------
|
|
571
|
+
str
|
|
572
|
+
Normalized HDF5 filename that was written.
|
|
573
|
+
"""
|
|
574
|
+
filename = hdf5_filename(filename)
|
|
575
|
+
axes = list(axes)
|
|
576
|
+
constants = {} if constants is None else dict(constants)
|
|
577
|
+
metadata = {} if metadata is None else dict(metadata)
|
|
578
|
+
|
|
579
|
+
if not axes:
|
|
580
|
+
raise ValueError("At least one axis is required.")
|
|
581
|
+
|
|
582
|
+
axis_names = [axis.name for axis in axes]
|
|
583
|
+
|
|
584
|
+
if len(axis_names) != len(set(axis_names)):
|
|
585
|
+
raise ValueError("Axis names must be unique.")
|
|
586
|
+
|
|
587
|
+
if set(axis_names) & set(constants):
|
|
588
|
+
raise ValueError("Axis names cannot also be used as constant input names.")
|
|
589
|
+
|
|
590
|
+
for axis in axes:
|
|
591
|
+
_validate_axis(axis)
|
|
592
|
+
|
|
593
|
+
if outputs is not None:
|
|
594
|
+
outputs = list(outputs)
|
|
595
|
+
|
|
596
|
+
if not outputs:
|
|
597
|
+
raise ValueError("outputs must contain at least one output name when provided.")
|
|
598
|
+
|
|
599
|
+
if len(outputs) != len(set(outputs)):
|
|
600
|
+
raise ValueError("Output names must be unique.")
|
|
601
|
+
|
|
602
|
+
for output_name in outputs:
|
|
603
|
+
_validate_hdf5_name(output_name, "Output")
|
|
604
|
+
|
|
605
|
+
if output_name in _RESERVED_GROUP_NAMES:
|
|
606
|
+
raise ValueError(f"Output name '{output_name}' is reserved.")
|
|
607
|
+
|
|
608
|
+
group = group.strip("/") or "map"
|
|
609
|
+
group_path = safe_group_name(group)
|
|
610
|
+
|
|
611
|
+
with h5py.File(filename, "a") as file:
|
|
612
|
+
file.attrs["fullflow_format"] = "fullflow-simple-hdf5"
|
|
613
|
+
file.attrs["fullflow_schema_version"] = 3
|
|
614
|
+
if "created_utc" not in file.attrs:
|
|
615
|
+
file.attrs["created_utc"] = _datetime.datetime.now(_datetime.UTC).isoformat()
|
|
616
|
+
file.attrs["updated_utc"] = _datetime.datetime.now(_datetime.UTC).isoformat()
|
|
617
|
+
if group_path in file:
|
|
618
|
+
if overwrite:
|
|
619
|
+
del file[group_path]
|
|
620
|
+
elif not resume:
|
|
621
|
+
raise FileExistsError(
|
|
622
|
+
f"HDF5 group '{group_path}' already exists in '{filename}'. "
|
|
623
|
+
"Use resume=True or overwrite=True."
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
if group_path in file:
|
|
627
|
+
map_group = file[group_path]
|
|
628
|
+
output_names = _check_existing_group(map_group, axes, outputs, constants, metadata)
|
|
629
|
+
|
|
630
|
+
else:
|
|
631
|
+
if outputs is None:
|
|
632
|
+
output_names, first_index, first_values, failures = _discover_outputs(
|
|
633
|
+
axes,
|
|
634
|
+
constants,
|
|
635
|
+
evaluate,
|
|
636
|
+
outputs,
|
|
637
|
+
raise_errors,
|
|
638
|
+
)
|
|
639
|
+
else:
|
|
640
|
+
output_names = list(outputs)
|
|
641
|
+
first_index = None
|
|
642
|
+
first_values = None
|
|
643
|
+
failures = []
|
|
644
|
+
|
|
645
|
+
map_group = _create_group(
|
|
646
|
+
file,
|
|
647
|
+
group,
|
|
648
|
+
axes,
|
|
649
|
+
output_names,
|
|
650
|
+
constants,
|
|
651
|
+
metadata,
|
|
652
|
+
fill_value,
|
|
653
|
+
compression,
|
|
654
|
+
compression_opts,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
for index, failure_message in failures:
|
|
658
|
+
_write_failure(map_group, index, output_names, fill_value, failure_message)
|
|
659
|
+
|
|
660
|
+
if first_index is not None and first_values is not None:
|
|
661
|
+
for output_name, value in first_values.items():
|
|
662
|
+
map_group["outputs"][output_name][first_index] = value
|
|
663
|
+
|
|
664
|
+
map_group["status/success"][first_index] = True
|
|
665
|
+
map_group["status/message"][first_index] = ""
|
|
666
|
+
|
|
667
|
+
shape = _shape_from_axes(axes)
|
|
668
|
+
status_group = map_group["status"]
|
|
669
|
+
outputs_group = map_group["outputs"]
|
|
670
|
+
counter = 0
|
|
671
|
+
|
|
672
|
+
for index in np.ndindex(shape):
|
|
673
|
+
if resume and bool(status_group["success"][index]):
|
|
674
|
+
continue
|
|
675
|
+
|
|
676
|
+
inputs = _inputs_from_index(axes, index, constants)
|
|
677
|
+
|
|
678
|
+
try:
|
|
679
|
+
values = _validate_scalar_outputs(evaluate(**inputs), output_names)
|
|
680
|
+
|
|
681
|
+
for output_name, value in values.items():
|
|
682
|
+
outputs_group[output_name][index] = value
|
|
683
|
+
|
|
684
|
+
status_group["success"][index] = True
|
|
685
|
+
status_group["message"][index] = ""
|
|
686
|
+
|
|
687
|
+
except MapOutputError:
|
|
688
|
+
raise
|
|
689
|
+
except Exception as exc:
|
|
690
|
+
if raise_errors:
|
|
691
|
+
raise
|
|
692
|
+
|
|
693
|
+
_write_failure(
|
|
694
|
+
map_group,
|
|
695
|
+
index,
|
|
696
|
+
output_names,
|
|
697
|
+
fill_value,
|
|
698
|
+
f"{type(exc).__name__}: {exc}",
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
counter += 1
|
|
702
|
+
|
|
703
|
+
if flush_every and counter % flush_every == 0:
|
|
704
|
+
file.flush()
|
|
705
|
+
|
|
706
|
+
file.flush()
|
|
707
|
+
|
|
708
|
+
return filename
|