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/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