drbx 2.0.0.dev0__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.
Files changed (106) hide show
  1. drbx/__init__.py +20 -0
  2. drbx/__main__.py +5 -0
  3. drbx/cli.py +586 -0
  4. drbx/config/__init__.py +20 -0
  5. drbx/config/boutinp.py +556 -0
  6. drbx/config/model.py +34 -0
  7. drbx/config/normalization.py +66 -0
  8. drbx/data/atomic_rates/__init__.py +1 -0
  9. drbx/data/atomic_rates/iz_AMJUEL_H.x_2.1.5.json +213 -0
  10. drbx/data/atomic_rates/iz_AMJUEL_H.x_2.3.9a.json +213 -0
  11. drbx/data/atomic_rates/rec_AMJUEL_H.x_2.1.8.json +213 -0
  12. drbx/data/atomic_rates/rec_AMJUEL_H.x_2.3.13a.json +213 -0
  13. drbx/geometry/__init__.py +207 -0
  14. drbx/geometry/embedding.py +56 -0
  15. drbx/geometry/essos_import.py +1385 -0
  16. drbx/geometry/fci_geometry.py +4622 -0
  17. drbx/geometry/fci_maps.py +85 -0
  18. drbx/geometry/island_divertor.py +291 -0
  19. drbx/geometry/metric_tensor.py +99 -0
  20. drbx/geometry/open_slab.py +150 -0
  21. drbx/geometry/rotating_ellipse.py +253 -0
  22. drbx/geometry/shifted_torus.py +225 -0
  23. drbx/geometry/stellarator.py +287 -0
  24. drbx/geometry/vmec_extender_import.py +499 -0
  25. drbx/geometry/vmec_jax_import.py +306 -0
  26. drbx/linear/__init__.py +37 -0
  27. drbx/linear/dispersion.py +138 -0
  28. drbx/linear/eigen.py +91 -0
  29. drbx/native/__init__.py +224 -0
  30. drbx/native/array_backend.py +64 -0
  31. drbx/native/deck_runner.py +779 -0
  32. drbx/native/electromagnetic.py +250 -0
  33. drbx/native/expression.py +173 -0
  34. drbx/native/fci.py +295 -0
  35. drbx/native/fci_2_field_rhs.py +182 -0
  36. drbx/native/fci_4_field_rhs.py +1267 -0
  37. drbx/native/fci_boundaries.py +2494 -0
  38. drbx/native/fci_differentiable_case.py +304 -0
  39. drbx/native/fci_drb_EB_rhs.py +1243 -0
  40. drbx/native/fci_drb_rhs.py +190 -0
  41. drbx/native/fci_halo.py +1575 -0
  42. drbx/native/fci_helpers.py +350 -0
  43. drbx/native/fci_model.py +294 -0
  44. drbx/native/fci_neutral.py +139 -0
  45. drbx/native/fci_operators.py +4081 -0
  46. drbx/native/fci_sharding.py +597 -0
  47. drbx/native/fci_sheath_recycling.py +206 -0
  48. drbx/native/fci_time_integrator.py +96 -0
  49. drbx/native/fci_vorticity.py +198 -0
  50. drbx/native/fluid_1d.py +330 -0
  51. drbx/native/hasegawa_wakatani.py +196 -0
  52. drbx/native/limiters.py +57 -0
  53. drbx/native/mesh.py +238 -0
  54. drbx/native/metrics.py +234 -0
  55. drbx/native/neutrals/__init__.py +58 -0
  56. drbx/native/neutrals/atomic_rates.py +134 -0
  57. drbx/native/neutrals/detachment_sol_model.py +221 -0
  58. drbx/native/neutrals/reactions.py +164 -0
  59. drbx/native/neutrals/recycling_sol_model.py +197 -0
  60. drbx/native/sol_flux_tube.py +133 -0
  61. drbx/native/stellarator_turbulence.py +343 -0
  62. drbx/native/transport.py +134 -0
  63. drbx/native/units.py +32 -0
  64. drbx/native/vorticity.py +252 -0
  65. drbx/runtime/__init__.py +53 -0
  66. drbx/runtime/artifacts.py +161 -0
  67. drbx/runtime/memory.py +144 -0
  68. drbx/runtime/output.py +374 -0
  69. drbx/runtime/paths.py +9 -0
  70. drbx/runtime/performance.py +161 -0
  71. drbx/runtime/run_config.py +184 -0
  72. drbx/runtime/scheduler.py +99 -0
  73. drbx/runtime/state.py +40 -0
  74. drbx/validation/__init__.py +424 -0
  75. drbx/validation/autodiff_diffusion.py +329 -0
  76. drbx/validation/autodiff_diffusion_uncertainty.py +235 -0
  77. drbx/validation/diverted_tokamak_movie.py +558 -0
  78. drbx/validation/essos_fieldline_import_campaign.py +181 -0
  79. drbx/validation/essos_imported_artifact_audit.py +535 -0
  80. drbx/validation/essos_imported_drb_movie_campaign.py +2826 -0
  81. drbx/validation/essos_imported_fci_campaign.py +5241 -0
  82. drbx/validation/essos_imported_pytree_campaign.py +406 -0
  83. drbx/validation/essos_vmec_closed_field_campaign.py +314 -0
  84. drbx/validation/essos_vmec_closed_field_transient_campaign.py +629 -0
  85. drbx/validation/essos_vmec_fieldline_surface_campaign.py +620 -0
  86. drbx/validation/fluid_1d_mms_convergence.py +250 -0
  87. drbx/validation/geometry_lineouts.py +136 -0
  88. drbx/validation/geometry_slices.py +178 -0
  89. drbx/validation/publication_plotting.py +91 -0
  90. drbx/validation/stellarator_drb_pytree_campaign.py +621 -0
  91. drbx/validation/stellarator_fci_geometry_campaign.py +200 -0
  92. drbx/validation/stellarator_fci_operator_campaign.py +304 -0
  93. drbx/validation/stellarator_fci_suite_campaign.py +264 -0
  94. drbx/validation/stellarator_metric_mms_campaign.py +289 -0
  95. drbx/validation/stellarator_neutral_physics_campaign.py +255 -0
  96. drbx/validation/stellarator_sheath_recycling_campaign.py +331 -0
  97. drbx/validation/stellarator_sol_showcase.py +628 -0
  98. drbx/validation/stellarator_vorticity_campaign.py +304 -0
  99. drbx/validation/vmec_extender_edge_field_campaign.py +260 -0
  100. drbx/validation/vmec_extender_sol_smoke_campaign.py +365 -0
  101. drbx-2.0.0.dev0.dist-info/METADATA +380 -0
  102. drbx-2.0.0.dev0.dist-info/RECORD +106 -0
  103. drbx-2.0.0.dev0.dist-info/WHEEL +5 -0
  104. drbx-2.0.0.dev0.dist-info/entry_points.txt +2 -0
  105. drbx-2.0.0.dev0.dist-info/licenses/LICENSE +21 -0
  106. drbx-2.0.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,350 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal, Sequence
4
+
5
+ import jax
6
+ import jax.numpy as jnp
7
+
8
+ from ..geometry.fci_geometry import FciGeometry3D, HaloLayout3D, LocalDomain3D
9
+
10
+
11
+ def _as_float64_array(value: jnp.ndarray, name: str) -> jnp.ndarray:
12
+ """Normalize a generic 3D array to float64."""
13
+
14
+ array = jnp.asarray(value, dtype=jnp.float64)
15
+ if array.ndim != 3:
16
+ raise ValueError(f"{name} must be 3D, got {array.shape}")
17
+ return array
18
+
19
+
20
+ def _as_optional_boundary_plane(value: jnp.ndarray | float | None, name: str) -> jnp.ndarray | None:
21
+ """Normalize a boundary-plane payload for the global/reference path."""
22
+
23
+ if value is None:
24
+ return None
25
+ array = jnp.asarray(value, dtype=jnp.float64)
26
+ if array.ndim not in (0, 2):
27
+ raise ValueError(f"{name} must be scalar or 2D, got {array.shape}")
28
+ return array
29
+
30
+
31
+ def _as_face_flux_array(value: jnp.ndarray, name: str) -> jnp.ndarray:
32
+ """Normalize a face-aligned 3D array without deciding global vs local layout."""
33
+
34
+ array = jnp.asarray(value, dtype=jnp.float64)
35
+ if array.ndim != 3:
36
+ raise ValueError(f"{name} must be 3D, got {array.shape}")
37
+ return array
38
+
39
+
40
+ def _as_int_face_array(value: jnp.ndarray, name: str) -> jnp.ndarray:
41
+ """Normalize a 3D integer face array."""
42
+
43
+ array = jnp.asarray(value, dtype=jnp.int32)
44
+ if array.ndim != 3:
45
+ raise ValueError(f"{name} must be 3D, got {array.shape}")
46
+ return array
47
+
48
+
49
+ def _as_bool_face_array(value: jnp.ndarray, name: str) -> jnp.ndarray:
50
+ """Normalize a 3D boolean face array."""
51
+
52
+ array = jnp.asarray(value, dtype=bool)
53
+ if array.ndim != 3:
54
+ raise ValueError(f"{name} must be 3D, got {array.shape}")
55
+ return array
56
+
57
+
58
+ def _as_int_stencil_array(value: jnp.ndarray, name: str) -> jnp.ndarray:
59
+ """Normalize a 2D integer stencil array."""
60
+
61
+ array = jnp.asarray(value, dtype=jnp.int32)
62
+ if array.ndim != 2:
63
+ raise ValueError(f"{name} must be 2D, got {array.shape}")
64
+ return array
65
+
66
+
67
+ def _as_weight_stencil_array(value: jnp.ndarray, name: str) -> jnp.ndarray:
68
+ """Normalize a 2D floating-point stencil weight array."""
69
+
70
+ array = jnp.asarray(value, dtype=jnp.float64)
71
+ if array.ndim != 2:
72
+ raise ValueError(f"{name} must be 2D, got {array.shape}")
73
+ return array
74
+
75
+
76
+ def _as_coordinate_derivative_weight_array(value: jnp.ndarray, name: str) -> jnp.ndarray:
77
+ """Normalize the fixed coordinate-derivative weight tensor."""
78
+
79
+ array = jnp.asarray(value, dtype=jnp.float64)
80
+ if array.shape != (3, 2, 4):
81
+ raise ValueError(f"{name} must have shape (3, 2, 4), got {array.shape}")
82
+ return array
83
+
84
+
85
+ def _as_wall_face_array(value: jnp.ndarray | float, n_faces: int, name: str) -> jnp.ndarray:
86
+ """Normalize a per-wall-face scalar payload."""
87
+
88
+ array = jnp.asarray(value, dtype=jnp.float64)
89
+ if array.ndim == 0:
90
+ return jnp.broadcast_to(array, (int(n_faces),))
91
+ if array.ndim != 1 or array.shape != (int(n_faces),):
92
+ raise ValueError(f"{name} must be scalar or shape {(int(n_faces),)}, got {array.shape}")
93
+ return array
94
+
95
+
96
+ def _as_coordinate_face_tuple(
97
+ value: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray],
98
+ geometry: FciGeometry3D,
99
+ name: str,
100
+ ) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
101
+ """Validate global face tuples against a global active-cell geometry."""
102
+
103
+ if len(value) != 3:
104
+ raise ValueError(f"{name} must be a tuple of three face arrays")
105
+ x_shape = (geometry.shape[0] + 1, geometry.shape[1], geometry.shape[2])
106
+ y_shape = (geometry.shape[0], geometry.shape[1] + 1, geometry.shape[2])
107
+ z_shape = (geometry.shape[0], geometry.shape[1], geometry.shape[2] + 1)
108
+ x_value = jnp.asarray(value[0], dtype=jnp.float64)
109
+ y_value = jnp.asarray(value[1], dtype=jnp.float64)
110
+ z_value = jnp.asarray(value[2], dtype=jnp.float64)
111
+ if x_value.shape != x_shape or y_value.shape != y_shape or z_value.shape != z_shape:
112
+ raise ValueError(
113
+ f"{name} face arrays must have shapes x={x_shape}, y={y_shape}, z={z_shape}; "
114
+ f"got x={x_value.shape}, y={y_value.shape}, z={z_value.shape}"
115
+ )
116
+ return x_value, y_value, z_value
117
+
118
+
119
+ def _normalize_axis_flags(
120
+ value: Sequence[bool],
121
+ name: str,
122
+ ) -> tuple[bool, bool, bool]:
123
+ """Normalize a 3-axis boolean flag tuple."""
124
+
125
+ if len(value) != 3:
126
+ raise ValueError(f"{name} must have length 3, got {value}")
127
+ return tuple(bool(axis) for axis in value)
128
+
129
+
130
+ def _axis_regular_lower_x_face(values: jnp.ndarray) -> jnp.ndarray:
131
+ """Global/reference helper for the lower-x axis-regular face estimate."""
132
+
133
+ if values.ndim != 3:
134
+ raise ValueError(f"axis-regular lower x reconstruction requires a 3D field, got {values.shape}")
135
+ if values.shape[1] % 2:
136
+ raise ValueError("axis-regular lower-rho mapping requires an even poloidal grid")
137
+ half_turn = values.shape[1] // 2
138
+ return 0.5 * (values[0] + jnp.roll(values[0], shift=-half_turn, axis=0))
139
+
140
+
141
+ def _axis_name(axis: int) -> str:
142
+ axis = int(axis)
143
+ if axis == 0:
144
+ return "x"
145
+ if axis == 1:
146
+ return "y"
147
+ if axis == 2:
148
+ return "z"
149
+ raise ValueError(f"axis must be 0, 1, or 2, got {axis}")
150
+
151
+
152
+ def _validate_axis(axis: int) -> int:
153
+ axis = int(axis)
154
+ if axis < 0 or axis > 2:
155
+ raise ValueError(f"axis must be 0, 1, or 2, got {axis}")
156
+ return axis
157
+
158
+
159
+ def _local_cell_halo_array(value: jnp.ndarray, layout: HaloLayout3D, name: str) -> jnp.ndarray:
160
+ """Validate a local cell-centered halo array."""
161
+
162
+ array = jnp.asarray(value, dtype=jnp.float64)
163
+ if array.shape != layout.cell_halo_shape:
164
+ raise ValueError(
165
+ f"{name} must have shape {layout.cell_halo_shape}, got {array.shape}"
166
+ )
167
+ return array
168
+
169
+
170
+ def _local_owned_cell_array(value: jnp.ndarray, layout: HaloLayout3D, name: str) -> jnp.ndarray:
171
+ """Validate a local owned-cell array."""
172
+
173
+ array = jnp.asarray(value, dtype=jnp.float64)
174
+ if array.shape != layout.owned_shape:
175
+ raise ValueError(f"{name} must have shape {layout.owned_shape}, got {array.shape}")
176
+ return array
177
+
178
+
179
+ def _local_face_halo_array(value: jnp.ndarray, layout: HaloLayout3D, axis: int, name: str) -> jnp.ndarray:
180
+ """Validate a halo-padded local face array."""
181
+
182
+ axis = _validate_axis(axis)
183
+ array = jnp.asarray(value, dtype=jnp.float64)
184
+ expected_shape = layout.face_halo_shape(axis)
185
+ if array.shape != expected_shape:
186
+ raise ValueError(f"{name} must have shape {expected_shape}, got {array.shape}")
187
+ return array
188
+
189
+
190
+ def _local_control_face_array(value: jnp.ndarray, layout: HaloLayout3D, axis: int, name: str) -> jnp.ndarray:
191
+ """Validate a local owned/control-face array."""
192
+
193
+ axis = _validate_axis(axis)
194
+ array = jnp.asarray(value, dtype=jnp.float64)
195
+ expected_shape = layout.face_control_shape(axis)
196
+ if array.shape != expected_shape:
197
+ raise ValueError(f"{name} must have shape {expected_shape}, got {array.shape}")
198
+ return array
199
+
200
+
201
+ def _local_coordinate_face_tuple(
202
+ value: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray],
203
+ layout: HaloLayout3D,
204
+ name: str,
205
+ *,
206
+ region: Literal["control", "halo_face"] = "control",
207
+ ) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
208
+ """Validate a local coordinate-face tuple against a halo layout."""
209
+
210
+ if len(value) != 3:
211
+ raise ValueError(f"{name} must be a tuple of three face arrays")
212
+ validators = (
213
+ _local_control_face_array if region == "control" else _local_face_halo_array
214
+ )
215
+ x_value = validators(value[0], layout, 0, f"{name}.x")
216
+ y_value = validators(value[1], layout, 1, f"{name}.y")
217
+ z_value = validators(value[2], layout, 2, f"{name}.z")
218
+ return x_value, y_value, z_value
219
+
220
+
221
+ def local_side_plane_shape(layout: HaloLayout3D, axis: int) -> tuple[int, int]:
222
+ """Return the owned-cell side-plane shape for a local boundary payload."""
223
+
224
+ axis = _validate_axis(axis)
225
+ nx, ny, nz = layout.owned_shape
226
+ if axis == 0:
227
+ return ny, nz
228
+ if axis == 1:
229
+ return nx, nz
230
+ return nx, ny
231
+
232
+
233
+ def _as_local_side_plane_array(
234
+ value: jnp.ndarray | float,
235
+ layout: HaloLayout3D,
236
+ axis: int,
237
+ name: str,
238
+ *,
239
+ dtype=jnp.float64,
240
+ ) -> jnp.ndarray:
241
+ """Validate a local side-plane payload used by ghost-fill boundary helpers."""
242
+
243
+ axis = _validate_axis(axis)
244
+ array = jnp.asarray(value, dtype=dtype)
245
+ expected_shape = local_side_plane_shape(layout, axis)
246
+ if array.ndim == 0:
247
+ return jnp.broadcast_to(array, expected_shape)
248
+ if array.shape != expected_shape:
249
+ raise ValueError(f"{name} must have shape {expected_shape}, got {array.shape}")
250
+ return array
251
+
252
+
253
+ def _as_local_wall_array(
254
+ value: jnp.ndarray,
255
+ max_wall_faces: int,
256
+ trailing_shape: tuple[int, ...],
257
+ name: str,
258
+ *,
259
+ dtype=jnp.float64,
260
+ ) -> jnp.ndarray:
261
+ """Validate a padded local wall payload with an arbitrary trailing shape."""
262
+
263
+ array = jnp.asarray(value, dtype=dtype)
264
+ expected_shape = (int(max_wall_faces),) + tuple(int(v) for v in trailing_shape)
265
+ if array.shape != expected_shape:
266
+ raise ValueError(f"{name} must have shape {expected_shape}, got {array.shape}")
267
+ return array
268
+
269
+
270
+ def _as_local_wall_int_array(
271
+ value: jnp.ndarray,
272
+ max_wall_faces: int,
273
+ name: str,
274
+ ) -> jnp.ndarray:
275
+ """Validate a padded local wall integer vector."""
276
+
277
+ return _as_local_wall_array(value, max_wall_faces, (), name, dtype=jnp.int32)
278
+
279
+
280
+ def _as_local_wall_bool_array(
281
+ value: jnp.ndarray,
282
+ max_wall_faces: int,
283
+ name: str,
284
+ ) -> jnp.ndarray:
285
+ """Validate a padded local wall boolean vector."""
286
+
287
+ return _as_local_wall_array(value, max_wall_faces, (), name, dtype=bool)
288
+
289
+
290
+ def _as_local_wall_stencil_index_array(
291
+ value: jnp.ndarray,
292
+ max_wall_faces: int,
293
+ stencil_width: int,
294
+ name: str,
295
+ ) -> jnp.ndarray:
296
+ """Validate local wall stencil indices against the halo field."""
297
+
298
+ array = jnp.asarray(value, dtype=jnp.int32)
299
+ expected_shape = (int(max_wall_faces), int(stencil_width))
300
+ if array.shape != expected_shape:
301
+ raise ValueError(f"{name} must have shape {expected_shape}, got {array.shape}")
302
+ return array
303
+
304
+
305
+ def _as_local_wall_stencil_weight_array(
306
+ value: jnp.ndarray,
307
+ max_wall_faces: int,
308
+ stencil_width: int,
309
+ name: str,
310
+ ) -> jnp.ndarray:
311
+ """Validate local wall stencil weights."""
312
+
313
+ array = jnp.asarray(value, dtype=jnp.float64)
314
+ expected_shape = (int(max_wall_faces), int(stencil_width))
315
+ if array.shape != expected_shape:
316
+ raise ValueError(f"{name} must have shape {expected_shape}, got {array.shape}")
317
+ return array
318
+
319
+
320
+ def local_physical_side_active(
321
+ domain: LocalDomain3D,
322
+ axis: int,
323
+ side: Literal["lower", "upper"],
324
+ ) -> bool | jnp.ndarray:
325
+ """Return runtime physical-side ownership for a local boundary payload.
326
+
327
+ The result is a Python boolean for an undecomposed axis and a traced JAX
328
+ boolean inside an SPMD context with a configured mesh axis. It is true
329
+ only on the runtime global side whose side kind is ``SIDE_PHYSICAL``.
330
+ """
331
+
332
+ axis = _validate_axis(axis)
333
+ if side not in ("lower", "upper"):
334
+ raise ValueError(f"side must be 'lower' or 'upper', got {side!r}")
335
+ if side == "lower":
336
+ return domain.runtime_has_physical_lower(axis)
337
+ return domain.runtime_has_physical_upper(axis)
338
+
339
+
340
+ def _local_side_mask(
341
+ domain: LocalDomain3D,
342
+ layout: HaloLayout3D,
343
+ axis: int,
344
+ side: Literal["lower", "upper"],
345
+ ) -> jnp.ndarray:
346
+ """Build a boolean mask for the physical side-plane payload."""
347
+
348
+ active = local_physical_side_active(domain, axis, side)
349
+ shape = local_side_plane_shape(layout, axis)
350
+ return jnp.broadcast_to(jnp.asarray(active, dtype=bool), shape)
@@ -0,0 +1,294 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import fields, replace as dataclass_replace
4
+ from typing import Any, Callable, TypeVar
5
+
6
+ import jax
7
+ import jax.numpy as jnp
8
+
9
+ from ..geometry.fci_geometry import HaloLayout3D
10
+
11
+
12
+ FciFieldBundleT = TypeVar("FciFieldBundleT", bound="FciFieldBundle")
13
+ FciModelStateT = TypeVar("FciModelStateT", bound="FciModelState")
14
+
15
+
16
+ def update_halo_owned_slice(
17
+ field_halo: jax.Array,
18
+ field_owned: jax.Array,
19
+ layout: HaloLayout3D,
20
+ ) -> jax.Array:
21
+ """Overwrite the owned interior of a halo field and preserve its halos."""
22
+
23
+ if not isinstance(layout, HaloLayout3D):
24
+ raise TypeError("layout must be a HaloLayout3D instance")
25
+ field_halo = jnp.asarray(field_halo)
26
+ field_owned = jnp.asarray(field_owned)
27
+ if field_halo.shape != layout.cell_halo_shape:
28
+ raise ValueError(
29
+ "field_halo must have shape "
30
+ f"{layout.cell_halo_shape}, got {field_halo.shape}"
31
+ )
32
+ if field_owned.shape != layout.owned_shape:
33
+ raise ValueError(
34
+ f"field_owned must have shape {layout.owned_shape}, "
35
+ f"got {field_owned.shape}"
36
+ )
37
+ return field_halo.at[layout.owned_slices_cell].set(field_owned)
38
+
39
+
40
+ def inject_owned_field_to_halo(
41
+ field_owned: jax.Array,
42
+ layout: HaloLayout3D,
43
+ fill_value: object = 0.0,
44
+ ) -> jax.Array:
45
+ """Allocate a halo field, fill it, and insert an owned field in its interior."""
46
+
47
+ if not isinstance(layout, HaloLayout3D):
48
+ raise TypeError("layout must be a HaloLayout3D instance")
49
+ field_owned = jnp.asarray(field_owned)
50
+ if field_owned.shape != layout.owned_shape:
51
+ raise ValueError(
52
+ f"field_owned must have shape {layout.owned_shape}, "
53
+ f"got {field_owned.shape}"
54
+ )
55
+ field_halo = jnp.full(
56
+ layout.cell_halo_shape,
57
+ fill_value,
58
+ dtype=field_owned.dtype,
59
+ )
60
+ return field_halo.at[layout.owned_slices_cell].set(field_owned)
61
+
62
+
63
+ def inject_owned_vector_field_to_halo(
64
+ field_owned: jax.Array,
65
+ layout: HaloLayout3D,
66
+ fill_value: object = 0.0,
67
+ ) -> jax.Array:
68
+ """Insert an owned vector/trailing-component field into a halo field.
69
+
70
+ The first three axes are the spatial axes and must have
71
+ ``layout.owned_shape``. Any remaining axes are preserved verbatim. This
72
+ lets one halo exchange carry all vector components in a single collective.
73
+ """
74
+
75
+ if not isinstance(layout, HaloLayout3D):
76
+ raise TypeError("layout must be a HaloLayout3D instance")
77
+ field_owned = jnp.asarray(field_owned)
78
+ if field_owned.ndim < 4 or field_owned.shape[:3] != layout.owned_shape:
79
+ raise ValueError(
80
+ "field_owned must have spatial shape layout.owned_shape and at least "
81
+ "one trailing component axis; "
82
+ f"got {field_owned.shape}, expected prefix {layout.owned_shape}"
83
+ )
84
+
85
+ trailing_shape = tuple(field_owned.shape[3:])
86
+ trailing = (slice(None),) * len(trailing_shape)
87
+ field_halo = jnp.full(
88
+ layout.cell_halo_shape + trailing_shape,
89
+ fill_value,
90
+ dtype=field_owned.dtype,
91
+ )
92
+ return field_halo.at[layout.owned_slices_cell + trailing].set(field_owned)
93
+
94
+
95
+ def extract_owned_field_from_halo(
96
+ field_halo: jax.Array,
97
+ layout: HaloLayout3D,
98
+ ) -> jax.Array:
99
+ """Extract the owned interior from a halo-padded field."""
100
+
101
+ if not isinstance(layout, HaloLayout3D):
102
+ raise TypeError("layout must be a HaloLayout3D instance")
103
+ field_halo = jnp.asarray(field_halo)
104
+ if field_halo.shape != layout.cell_halo_shape:
105
+ raise ValueError(
106
+ "field_halo must have shape "
107
+ f"{layout.cell_halo_shape}, got {field_halo.shape}"
108
+ )
109
+ return field_halo[layout.owned_slices_cell]
110
+
111
+
112
+ def assert_matching_field_names(
113
+ lhs: FciFieldBundle,
114
+ rhs: FciFieldBundle,
115
+ ) -> None:
116
+ """Require two named field bundles to have identical field ordering."""
117
+
118
+ if not isinstance(lhs, FciFieldBundle) or not isinstance(rhs, FciFieldBundle):
119
+ raise TypeError("lhs and rhs must be FciFieldBundle instances")
120
+ lhs_names = lhs.field_names()
121
+ rhs_names = rhs.field_names()
122
+ if lhs_names != rhs_names:
123
+ raise ValueError(
124
+ "field bundles must have matching field names and order; "
125
+ f"got lhs={lhs_names}, rhs={rhs_names}"
126
+ )
127
+
128
+
129
+ def inject_owned_state_to_halo(
130
+ state_owned: FciModelStateT,
131
+ layout: HaloLayout3D,
132
+ fill_value: object = 0.0,
133
+ ) -> FciModelStateT:
134
+ """Convert an owned-shaped model state to a halo-shaped model state."""
135
+
136
+ if not isinstance(layout, HaloLayout3D):
137
+ raise TypeError("layout must be a HaloLayout3D instance")
138
+ if not isinstance(state_owned, FciModelState):
139
+ raise TypeError("state_owned must be an FciModelState instance")
140
+ state_owned.assert_field_shape(layout.owned_shape)
141
+ return state_owned.map_fields(
142
+ lambda field: inject_owned_field_to_halo(field, layout, fill_value)
143
+ )
144
+
145
+
146
+ def update_state_halo_owned_slices(
147
+ state_halo: FciModelStateT,
148
+ state_owned: FciModelStateT,
149
+ layout: HaloLayout3D,
150
+ ) -> FciModelStateT:
151
+ """Refresh the owned interiors of all fields in a halo-shaped state."""
152
+
153
+ if not isinstance(layout, HaloLayout3D):
154
+ raise TypeError("layout must be a HaloLayout3D instance")
155
+ if not isinstance(state_halo, FciModelState):
156
+ raise TypeError("state_halo must be an FciModelState instance")
157
+ if not isinstance(state_owned, FciModelState):
158
+ raise TypeError("state_owned must be an FciModelState instance")
159
+ if type(state_halo) is not type(state_owned):
160
+ raise TypeError(
161
+ "state_halo and state_owned must have matching concrete state types; "
162
+ f"got {type(state_halo).__name__} and {type(state_owned).__name__}"
163
+ )
164
+ assert_matching_field_names(state_halo, state_owned)
165
+ state_halo.assert_field_shape(layout.cell_halo_shape)
166
+ state_owned.assert_field_shape(layout.owned_shape)
167
+ owned_by_name = dict(state_owned.field_items())
168
+ return state_halo.replace(
169
+ **{
170
+ name: update_halo_owned_slice(
171
+ getattr(state_halo, name),
172
+ owned_by_name[name],
173
+ layout,
174
+ )
175
+ for name in state_halo.field_names()
176
+ }
177
+ )
178
+
179
+
180
+ def extract_owned_state_from_halo(
181
+ state_halo: FciModelStateT,
182
+ layout: HaloLayout3D,
183
+ ) -> FciModelStateT:
184
+ """Extract an owned-shaped model state from a halo-shaped state."""
185
+
186
+ if not isinstance(layout, HaloLayout3D):
187
+ raise TypeError("layout must be a HaloLayout3D instance")
188
+ if not isinstance(state_halo, FciModelState):
189
+ raise TypeError("state_halo must be an FciModelState instance")
190
+ state_halo.assert_field_shape(layout.cell_halo_shape)
191
+ return state_halo.map_fields(
192
+ lambda field: extract_owned_field_from_halo(field, layout)
193
+ )
194
+
195
+
196
+ @jax.tree_util.register_pytree_node_class
197
+ class FciFieldBundle:
198
+ """Generic named-field PyTree bundle.
199
+
200
+ Subclasses should be frozen dataclasses whose init fields are the named
201
+ bundle leaves. Leaves may be arbitrary PyTree-compatible objects, not only
202
+ arrays; this supports state-shaped boundary-condition bundles as well as
203
+ model states.
204
+ """
205
+
206
+ def _field_names(self) -> tuple[str, ...]:
207
+ return tuple(field.name for field in fields(self) if field.init)
208
+
209
+ def _field_values(self) -> tuple[Any, ...]:
210
+ return tuple(getattr(self, name) for name in self._field_names())
211
+
212
+ def tree_flatten(self):
213
+ return self._field_values(), self._field_names()
214
+
215
+ @classmethod
216
+ def tree_unflatten(cls, aux_data, children):
217
+ return cls(**dict(zip(aux_data, children)))
218
+
219
+ def field_names(self) -> tuple[str, ...]:
220
+ return self._field_names()
221
+
222
+ def field_values(self) -> tuple[Any, ...]:
223
+ return self._field_values()
224
+
225
+ def field_items(self) -> tuple[tuple[str, Any], ...]:
226
+ return tuple((name, getattr(self, name)) for name in self._field_names())
227
+
228
+ def replace(self: FciFieldBundleT, **updates: object) -> FciFieldBundleT:
229
+ return dataclass_replace(self, **updates)
230
+
231
+ def map_fields(
232
+ self: FciFieldBundleT,
233
+ fn: Callable[[Any], Any],
234
+ ) -> FciFieldBundleT:
235
+ return self.replace(**{name: fn(value) for name, value in self.field_items()})
236
+
237
+
238
+ @jax.tree_util.register_pytree_node_class
239
+ class FciModelState(FciFieldBundle):
240
+ """Array-valued named-field base for FCI model states.
241
+
242
+ Persistent states and RHS bundles should subclass this class. The generic
243
+ named-field and PyTree behavior is inherited from ``FciFieldBundle``;
244
+ these methods add state-specific array algebra.
245
+ """
246
+
247
+ def zeros_like(self: FciModelStateT) -> FciModelStateT:
248
+ return self.map_fields(jnp.zeros_like)
249
+
250
+ def assert_field_shape(
251
+ self,
252
+ expected_shape: tuple[int, ...],
253
+ ) -> None:
254
+ """Raise if any array-valued state field has the wrong shape."""
255
+
256
+ expected_shape = tuple(int(size) for size in expected_shape)
257
+ for name, value in self.field_items():
258
+ shape = jnp.asarray(value).shape
259
+ if shape != expected_shape:
260
+ raise ValueError(
261
+ f"FciModelState field {name!r} must have shape "
262
+ f"{expected_shape}, got {shape}"
263
+ )
264
+
265
+ def axpy(
266
+ self: FciModelStateT,
267
+ other: FciModelStateT,
268
+ *,
269
+ scale: float,
270
+ ) -> FciModelStateT:
271
+ if type(self) is not type(other):
272
+ raise TypeError(
273
+ f"axpy requires matching state types, got {type(self).__name__} and {type(other).__name__}"
274
+ )
275
+ return self.replace(
276
+ **{
277
+ name: value + scale * getattr(other, name)
278
+ for name, value in self.field_items()
279
+ }
280
+ )
281
+
282
+
283
+ __all__ = [
284
+ "FciFieldBundle",
285
+ "FciModelState",
286
+ "assert_matching_field_names",
287
+ "extract_owned_field_from_halo",
288
+ "extract_owned_state_from_halo",
289
+ "inject_owned_field_to_halo",
290
+ "inject_owned_vector_field_to_halo",
291
+ "inject_owned_state_to_halo",
292
+ "update_halo_owned_slice",
293
+ "update_state_halo_owned_slices",
294
+ ]