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,1385 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import os
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+ import jax.numpy as jnp
12
+
13
+ from .fci_maps import FciMaps
14
+ from .metric_tensor import MetricTensor3D
15
+
16
+
17
+ ESSOS_LANDREMAN_QA_RELATIVE_JSON = Path("examples/input_files/ESSOS_biot_savart_LandremanPaulQA.json")
18
+ ESSOS_LANDREMAN_QA_RELATIVE_WOUT = Path("examples/input_files/wout_LandremanPaul2021_QA_reactorScale_lowres.nc")
19
+ _PRIVATE_DEFAULT_ESSOS_ROOT = Path.home() / "local" / "ESSOS"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class EssosFieldLineBundle:
24
+ """Field and field-line arrays exported from an ESSOS tracing run.
25
+
26
+ The bundle deliberately stores arrays, not ESSOS objects. This keeps
27
+ `drbx` independent of ESSOS at runtime after the import/export step.
28
+ """
29
+
30
+ trajectories_xyz: np.ndarray
31
+ times: np.ndarray
32
+ initial_xyz: np.ndarray
33
+ poincare_r: np.ndarray
34
+ poincare_z: np.ndarray
35
+ poincare_time: np.ndarray
36
+ poincare_section: np.ndarray
37
+ poincare_line_index: np.ndarray
38
+ field_sample_xyz: np.ndarray
39
+ field_sample_b_xyz: np.ndarray
40
+ coil_gamma_xyz: np.ndarray
41
+ coil_currents: np.ndarray
42
+ metadata: dict[str, Any]
43
+
44
+ @property
45
+ def n_field_lines(self) -> int:
46
+ return int(self.trajectories_xyz.shape[0])
47
+
48
+ @property
49
+ def n_times(self) -> int:
50
+ return int(self.trajectories_xyz.shape[1])
51
+
52
+ @property
53
+ def poincare_point_count(self) -> int:
54
+ return int(self.poincare_r.size)
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class EssosImportedFciGeometry:
59
+ """VMEC-shaped FCI geometry whose field-line maps are exported from ESSOS."""
60
+
61
+ coordinates_x: jnp.ndarray
62
+ coordinates_y: jnp.ndarray
63
+ coordinates_z: jnp.ndarray
64
+ minor_radius: jnp.ndarray
65
+ toroidal_angle: jnp.ndarray
66
+ poloidal_angle: jnp.ndarray
67
+ magnetic_field_magnitude: jnp.ndarray
68
+ connection_length: jnp.ndarray
69
+ adjacent_step_length: jnp.ndarray | None
70
+ target_exit_length: jnp.ndarray | None
71
+ forward_target_exit_length: jnp.ndarray | None
72
+ backward_target_exit_length: jnp.ndarray | None
73
+ metric: MetricTensor3D
74
+ maps: FciMaps
75
+ metadata: dict[str, Any]
76
+
77
+ @property
78
+ def shape(self) -> tuple[int, int, int]:
79
+ return tuple(int(value) for value in self.minor_radius.shape)
80
+
81
+
82
+ def resolve_essos_landreman_qa_json(path: str | Path | None = None, *, essos_root: str | Path | None = None) -> Path:
83
+ """Resolve the Landreman-Paul QA coil JSON from an ESSOS checkout."""
84
+
85
+ if path is not None:
86
+ resolved = Path(path)
87
+ else:
88
+ root = Path(essos_root) if essos_root is not None else Path(os.environ.get("DRBX_ESSOS_ROOT", _PRIVATE_DEFAULT_ESSOS_ROOT))
89
+ resolved = root / ESSOS_LANDREMAN_QA_RELATIVE_JSON
90
+ if not resolved.exists():
91
+ raise FileNotFoundError(
92
+ "ESSOS Landreman-Paul QA coil JSON was not found. Pass coil_json_path "
93
+ "or set DRBX_ESSOS_ROOT to an ESSOS checkout containing "
94
+ f"{ESSOS_LANDREMAN_QA_RELATIVE_JSON}."
95
+ )
96
+ return resolved
97
+
98
+
99
+ def resolve_essos_landreman_qa_wout(path: str | Path | None = None, *, essos_root: str | Path | None = None) -> Path:
100
+ """Resolve the matching Landreman-Paul QA VMEC wout file."""
101
+
102
+ if path is not None:
103
+ resolved = Path(path)
104
+ else:
105
+ root = Path(essos_root) if essos_root is not None else Path(os.environ.get("DRBX_ESSOS_ROOT", _PRIVATE_DEFAULT_ESSOS_ROOT))
106
+ resolved = root / ESSOS_LANDREMAN_QA_RELATIVE_WOUT
107
+ if not resolved.exists():
108
+ raise FileNotFoundError(
109
+ "ESSOS Landreman-Paul QA VMEC wout file was not found. Pass vmec_wout_path "
110
+ "or set DRBX_ESSOS_ROOT to an ESSOS checkout containing "
111
+ f"{ESSOS_LANDREMAN_QA_RELATIVE_WOUT}."
112
+ )
113
+ return resolved
114
+
115
+
116
+ def essos_runtime_available(*, essos_root: str | Path | None = None) -> bool:
117
+ """Return whether ESSOS can be imported by the optional adapter."""
118
+
119
+ try:
120
+ _import_essos_modules(essos_root=essos_root)
121
+ except (ImportError, ModuleNotFoundError, AttributeError):
122
+ return False
123
+ return True
124
+
125
+
126
+ def trace_essos_coil_field_lines(
127
+ *,
128
+ coil_json_path: str | Path | None = None,
129
+ essos_root: str | Path | None = None,
130
+ r_min: float = 1.21,
131
+ r_max: float = 1.40,
132
+ n_field_lines: int = 8,
133
+ maxtime: float = 1000.0,
134
+ times_to_trace: int = 6000,
135
+ trace_tolerance: float = 1.0e-8,
136
+ poincare_shifts: tuple[float, ...] = (0.0, float(np.pi / 2.0)),
137
+ field_sample_count: int = 256,
138
+ ) -> EssosFieldLineBundle:
139
+ """Trace coil-produced field lines with ESSOS and export arrays.
140
+
141
+ ESSOS owns the magnetic-field object, adaptive field-line integration, and
142
+ Poincare root extraction. `drbx` only normalizes the resulting arrays
143
+ into a stable import bundle.
144
+ """
145
+
146
+ resolved_coil_json = resolve_essos_landreman_qa_json(coil_json_path, essos_root=essos_root)
147
+ modules = _import_essos_modules(essos_root=essos_root if essos_root is not None else resolved_coil_json.parents[2])
148
+
149
+ import jax
150
+ import jax.numpy as jnp
151
+ import matplotlib.pyplot as plt
152
+
153
+ coils = modules["Coils_from_json"](str(resolved_coil_json))
154
+ field = modules["BiotSavart"](coils)
155
+
156
+ r0 = jnp.linspace(float(r_min), float(r_max), int(n_field_lines))
157
+ z0 = jnp.zeros(int(n_field_lines))
158
+ phi0 = jnp.zeros(int(n_field_lines))
159
+ initial_xyz = jnp.array([r0 * jnp.cos(phi0), r0 * jnp.sin(phi0), z0]).T
160
+
161
+ tracing = jax.block_until_ready(
162
+ modules["Tracing"](
163
+ field=field,
164
+ model="FieldLineAdaptative",
165
+ initial_conditions=initial_xyz,
166
+ maxtime=float(maxtime),
167
+ times_to_trace=int(times_to_trace),
168
+ atol=float(trace_tolerance),
169
+ rtol=float(trace_tolerance),
170
+ )
171
+ )
172
+ trajectories_xyz = np.asarray(tracing.trajectories[:, :, :3], dtype=np.float64)
173
+ times = np.asarray(tracing.times, dtype=np.float64)
174
+ initial_xyz_np = np.asarray(initial_xyz, dtype=np.float64)
175
+
176
+ fig, axis = plt.subplots(figsize=(2.0, 2.0))
177
+ plotting_data = tracing.poincare_plot(
178
+ shifts=[jnp.asarray(value) for value in poincare_shifts],
179
+ ax=axis,
180
+ show=False,
181
+ s=0.5,
182
+ )
183
+ plt.close(fig)
184
+ poincare = _flatten_essos_poincare_data(
185
+ plotting_data,
186
+ n_field_lines=int(n_field_lines),
187
+ shifts=tuple(float(value) for value in poincare_shifts),
188
+ )
189
+
190
+ flat_trajectory = trajectories_xyz.reshape((-1, 3))
191
+ sample_count = min(int(field_sample_count), int(flat_trajectory.shape[0]))
192
+ sample_indices = np.linspace(0, flat_trajectory.shape[0] - 1, sample_count, dtype=int)
193
+ field_sample_xyz = flat_trajectory[sample_indices]
194
+ field_sample_b_xyz = _sample_essos_field(field, field_sample_xyz)
195
+
196
+ metadata = {
197
+ "source": "ESSOS",
198
+ "coil_json_file": resolved_coil_json.name,
199
+ "field_model": "essos.fields.BiotSavart",
200
+ "tracing_model": "essos.dynamics.Tracing(FieldLineAdaptative)",
201
+ "poincare_method": "essos.dynamics.Tracing.poincare_plot",
202
+ "n_field_lines": int(n_field_lines),
203
+ "times_to_trace": int(times_to_trace),
204
+ "maxtime": float(maxtime),
205
+ "trace_tolerance": float(trace_tolerance),
206
+ "r_min": float(r_min),
207
+ "r_max": float(r_max),
208
+ "poincare_shifts": [float(value) for value in poincare_shifts],
209
+ }
210
+ return EssosFieldLineBundle(
211
+ trajectories_xyz=trajectories_xyz,
212
+ times=times,
213
+ initial_xyz=initial_xyz_np,
214
+ poincare_r=poincare["r"],
215
+ poincare_z=poincare["z"],
216
+ poincare_time=poincare["time"],
217
+ poincare_section=poincare["section"],
218
+ poincare_line_index=poincare["line_index"],
219
+ field_sample_xyz=field_sample_xyz,
220
+ field_sample_b_xyz=field_sample_b_xyz,
221
+ coil_gamma_xyz=np.asarray(coils.gamma, dtype=np.float64),
222
+ coil_currents=np.asarray(coils.currents, dtype=np.float64),
223
+ metadata=metadata,
224
+ )
225
+
226
+
227
+ def load_essos_coil_field_axis(
228
+ *,
229
+ coil_json_path: str | Path | None = None,
230
+ essos_root: str | Path | None = None,
231
+ ) -> tuple[float, float]:
232
+ """Return the magnetic-axis location reported by the imported ESSOS coil field."""
233
+
234
+ resolved_coil_json = resolve_essos_landreman_qa_json(coil_json_path, essos_root=essos_root)
235
+ modules = _import_essos_modules(essos_root=essos_root if essos_root is not None else resolved_coil_json.parents[2])
236
+ coils = modules["Coils_from_json"](str(resolved_coil_json))
237
+ field = modules["BiotSavart"](coils)
238
+ return float(field.r_axis), float(field.z_axis)
239
+
240
+
241
+ def load_essos_vmec_field_axis(
242
+ *,
243
+ vmec_wout_path: str | Path | None = None,
244
+ essos_root: str | Path | None = None,
245
+ ) -> tuple[float, float]:
246
+ """Return the magnetic-axis location in an ESSOS VMEC field object."""
247
+
248
+ resolved_wout = resolve_essos_landreman_qa_wout(vmec_wout_path, essos_root=essos_root)
249
+ modules = _import_essos_modules(essos_root=essos_root if essos_root is not None else resolved_wout.parents[2])
250
+ vmec = modules["Vmec"](str(resolved_wout))
251
+ return float(vmec.r_axis), float(vmec.z_axis)
252
+
253
+
254
+ def trace_essos_coil_initial_conditions(
255
+ initial_xyz: np.ndarray,
256
+ *,
257
+ coil_json_path: str | Path | None = None,
258
+ essos_root: str | Path | None = None,
259
+ current_sign: float = 1.0,
260
+ maxtime: float = 1000.0,
261
+ times_to_trace: int = 6000,
262
+ trace_tolerance: float = 1.0e-8,
263
+ ) -> np.ndarray:
264
+ """Trace arbitrary Cartesian seed points through the optional ESSOS coil field."""
265
+
266
+ resolved_coil_json = resolve_essos_landreman_qa_json(coil_json_path, essos_root=essos_root)
267
+ modules = _import_essos_modules(essos_root=essos_root if essos_root is not None else resolved_coil_json.parents[2])
268
+ return _trace_essos_initial_conditions(
269
+ modules=modules,
270
+ resolved_coil_json=resolved_coil_json,
271
+ initial_xyz=np.asarray(initial_xyz, dtype=np.float64),
272
+ current_sign=float(current_sign),
273
+ maxtime=float(maxtime),
274
+ times_to_trace=int(times_to_trace),
275
+ trace_tolerance=float(trace_tolerance),
276
+ )
277
+
278
+
279
+ def trace_essos_vmec_initial_conditions(
280
+ initial_stp: np.ndarray,
281
+ *,
282
+ vmec_wout_path: str | Path | None = None,
283
+ essos_root: str | Path | None = None,
284
+ maxtime: float = 1000.0,
285
+ times_to_trace: int = 6000,
286
+ trace_tolerance: float = 1.0e-8,
287
+ ) -> np.ndarray:
288
+ """Trace VMEC-coordinate seed points through the optional ESSOS VMEC field."""
289
+
290
+ resolved_wout = resolve_essos_landreman_qa_wout(vmec_wout_path, essos_root=essos_root)
291
+ modules = _import_essos_modules(essos_root=essos_root if essos_root is not None else resolved_wout.parents[2])
292
+ return _trace_essos_vmec_initial_conditions(
293
+ modules=modules,
294
+ resolved_wout=resolved_wout,
295
+ initial_stp=np.asarray(initial_stp, dtype=np.float64),
296
+ maxtime=float(maxtime),
297
+ times_to_trace=int(times_to_trace),
298
+ trace_tolerance=float(trace_tolerance),
299
+ )
300
+
301
+
302
+ def build_essos_imported_fci_geometry(
303
+ *,
304
+ coil_json_path: str | Path | None = None,
305
+ vmec_wout_path: str | Path | None = None,
306
+ essos_root: str | Path | None = None,
307
+ map_source: str = "coil",
308
+ nx: int = 6,
309
+ ny: int = 8,
310
+ nz: int = 16,
311
+ rho_min: float = 0.10,
312
+ rho_max: float = 0.46,
313
+ maxtime: float = 140.0,
314
+ times_to_trace: int = 768,
315
+ trace_tolerance: float = 1.0e-8,
316
+ ) -> EssosImportedFciGeometry:
317
+ """Build FCI maps from ESSOS tracing on a VMEC-shaped QA seed grid.
318
+
319
+ ``map_source`` selects the field-line-map semantics:
320
+
321
+ - ``"coil"`` traces the imported coil field and keeps its open-field masks;
322
+ - ``"vmec"`` builds surface-preserving VMEC-coordinate maps with closed
323
+ field-line masks;
324
+ - ``"hybrid"`` uses VMEC-coordinate map coordinates with coil-derived
325
+ boundary masks, connection lengths, and magnetic-field modulation.
326
+
327
+ The external field implementation owns the field evaluation. `drbx`
328
+ provides only the logical-grid conversion needed by the native FCI,
329
+ sheath/recycling, neutral, and PyTree RHS kernels.
330
+ """
331
+
332
+ if nx < 2 or ny < 2 or nz < 4:
333
+ raise ValueError("ESSOS imported FCI geometry requires nx >= 2, ny >= 2, and nz >= 4")
334
+ map_source = _normalize_essos_map_source(map_source)
335
+ resolved_coil_json = resolve_essos_landreman_qa_json(coil_json_path, essos_root=essos_root)
336
+ modules = _import_essos_modules(essos_root=essos_root if essos_root is not None else resolved_coil_json.parents[2])
337
+
338
+ coils = modules["Coils_from_json"](str(resolved_coil_json))
339
+ field = modules["BiotSavart"](coils)
340
+ axis_major_radius = float(field.r_axis)
341
+ axis_vertical = float(field.z_axis)
342
+
343
+ resolved_vmec_wout = resolve_essos_landreman_qa_wout(vmec_wout_path, essos_root=essos_root)
344
+ coordinates = build_essos_vmec_scaled_qa_coordinates(
345
+ resolved_vmec_wout,
346
+ nx=int(nx),
347
+ ny=int(ny),
348
+ nz=int(nz),
349
+ rho_min=float(rho_min),
350
+ rho_max=float(rho_max),
351
+ axis_major_radius=axis_major_radius,
352
+ axis_vertical=axis_vertical,
353
+ )
354
+ rho_1d = coordinates["rho_1d"]
355
+ phi_1d = coordinates["phi_1d"]
356
+ theta_1d = coordinates["theta_1d"]
357
+ rho = coordinates["rho"]
358
+ phi = coordinates["phi"]
359
+ theta = coordinates["theta"]
360
+ coordinates_x = coordinates["x"]
361
+ coordinates_y = coordinates["y"]
362
+ coordinates_z = coordinates["z"]
363
+ initial_xyz = np.stack([coordinates_x, coordinates_y, coordinates_z], axis=-1).reshape((-1, 3))
364
+ start_phi = phi.reshape(-1)
365
+ dphi = float(2.0 * np.pi / float(ny))
366
+ start_y_index = np.broadcast_to(np.arange(int(ny), dtype=int)[None, :, None], (int(nx), int(ny), int(nz))).reshape(-1)
367
+ coil_data: dict[str, Any] | None = None
368
+ if map_source in {"coil", "hybrid"}:
369
+ coil_data = _build_essos_coil_fci_map_data(
370
+ modules=modules,
371
+ resolved_coil_json=resolved_coil_json,
372
+ field=field,
373
+ initial_xyz=initial_xyz,
374
+ start_phi=start_phi,
375
+ start_y_index=start_y_index,
376
+ dphi=dphi,
377
+ shape=(int(nx), int(ny), int(nz)),
378
+ coordinates_x=coordinates_x,
379
+ coordinates_y=coordinates_y,
380
+ coordinates_z=coordinates_z,
381
+ maxtime=float(maxtime),
382
+ times_to_trace=int(times_to_trace),
383
+ trace_tolerance=float(trace_tolerance),
384
+ )
385
+ vmec_data: dict[str, Any] | None = None
386
+ if map_source in {"vmec", "hybrid"}:
387
+ vmec_data = _build_essos_vmec_fci_map_data(
388
+ modules=modules,
389
+ resolved_wout=resolved_vmec_wout,
390
+ coordinates=coordinates,
391
+ shape=(int(nx), int(ny), int(nz)),
392
+ dphi=dphi,
393
+ )
394
+
395
+ if map_source == "coil":
396
+ assert coil_data is not None
397
+ maps = coil_data["maps"]
398
+ bmag = coil_data["bmag"]
399
+ connection_length = coil_data["connection_length"]
400
+ adjacent_step_length = coil_data["adjacent_step_length"]
401
+ target_exit_length = coil_data["target_exit_length"]
402
+ forward_target_exit_length = coil_data["forward_target_exit_length"]
403
+ backward_target_exit_length = coil_data["backward_target_exit_length"]
404
+ field_model = "essos.fields.BiotSavart"
405
+ tracing_model = "essos.dynamics.Tracing(FieldLineAdaptative)"
406
+ elif map_source == "vmec":
407
+ assert vmec_data is not None
408
+ maps = vmec_data["maps"]
409
+ bmag = vmec_data["bmag"]
410
+ connection_length = vmec_data["connection_length"]
411
+ adjacent_step_length = vmec_data["connection_length"]
412
+ target_exit_length = np.full_like(connection_length, np.nan, dtype=np.float64)
413
+ forward_target_exit_length = np.full_like(connection_length, np.nan, dtype=np.float64)
414
+ backward_target_exit_length = np.full_like(connection_length, np.nan, dtype=np.float64)
415
+ field_model = "essos.fields.Vmec"
416
+ tracing_model = "vmec_coordinate_rk4_map"
417
+ else:
418
+ assert coil_data is not None
419
+ assert vmec_data is not None
420
+ maps = FciMaps(
421
+ forward_x=vmec_data["maps"].forward_x,
422
+ forward_z=vmec_data["maps"].forward_z,
423
+ backward_x=vmec_data["maps"].backward_x,
424
+ backward_z=vmec_data["maps"].backward_z,
425
+ forward_boundary=coil_data["maps"].forward_boundary,
426
+ backward_boundary=coil_data["maps"].backward_boundary,
427
+ dphi=dphi,
428
+ )
429
+ bmag = coil_data["bmag"]
430
+ connection_length = coil_data["connection_length"]
431
+ adjacent_step_length = vmec_data["connection_length"]
432
+ target_exit_length = coil_data["target_exit_length"]
433
+ forward_target_exit_length = coil_data["forward_target_exit_length"]
434
+ backward_target_exit_length = coil_data["backward_target_exit_length"]
435
+ field_model = "hybrid: VMEC map coordinates with Biot-Savart |B| and target masks"
436
+ tracing_model = "vmec_coordinate_rk4_map + essos.dynamics.Tracing(FieldLineAdaptative) masks"
437
+
438
+ forward_boundary = np.asarray(maps.forward_boundary, dtype=bool)
439
+ backward_boundary = np.asarray(maps.backward_boundary, dtype=bool)
440
+ metric = _metric_from_coordinates(
441
+ coordinates_x,
442
+ coordinates_y,
443
+ coordinates_z,
444
+ s_1d=rho_1d,
445
+ phi_1d=phi_1d,
446
+ theta_1d=theta_1d,
447
+ Bxy=bmag,
448
+ )
449
+ forward_boundary_fraction = float(np.mean(forward_boundary))
450
+ backward_boundary_fraction = float(np.mean(backward_boundary))
451
+ return EssosImportedFciGeometry(
452
+ coordinates_x=jnp.asarray(coordinates_x, dtype=jnp.float64),
453
+ coordinates_y=jnp.asarray(coordinates_y, dtype=jnp.float64),
454
+ coordinates_z=jnp.asarray(coordinates_z, dtype=jnp.float64),
455
+ minor_radius=jnp.asarray(rho, dtype=jnp.float64),
456
+ toroidal_angle=jnp.asarray(phi, dtype=jnp.float64),
457
+ poloidal_angle=jnp.asarray(theta, dtype=jnp.float64),
458
+ magnetic_field_magnitude=jnp.asarray(bmag, dtype=jnp.float64),
459
+ connection_length=jnp.asarray(connection_length, dtype=jnp.float64),
460
+ adjacent_step_length=jnp.asarray(adjacent_step_length, dtype=jnp.float64),
461
+ target_exit_length=jnp.asarray(target_exit_length, dtype=jnp.float64),
462
+ forward_target_exit_length=jnp.asarray(forward_target_exit_length, dtype=jnp.float64),
463
+ backward_target_exit_length=jnp.asarray(backward_target_exit_length, dtype=jnp.float64),
464
+ metric=metric,
465
+ maps=maps,
466
+ metadata={
467
+ "geometry_family": "essos_imported_vmec_qa_fci",
468
+ "source": "ESSOS",
469
+ "coil_json_file": resolved_coil_json.name,
470
+ "vmec_wout_file": resolved_vmec_wout.name,
471
+ "coordinate_model": "scaled_vmec_fourier_flux_surfaces",
472
+ **coordinates["metadata"],
473
+ "map_source": map_source,
474
+ "field_model": field_model,
475
+ "tracing_model": tracing_model,
476
+ "nx": int(nx),
477
+ "ny": int(ny),
478
+ "nz": int(nz),
479
+ "rho_min": float(rho_min),
480
+ "rho_max": float(rho_max),
481
+ "axis_major_radius": float(axis_major_radius),
482
+ "axis_vertical": float(axis_vertical),
483
+ "maxtime": float(maxtime),
484
+ "times_to_trace": int(times_to_trace),
485
+ "trace_tolerance": float(trace_tolerance),
486
+ "coil_trace_current_sign": float(coil_data["current_sign"]) if coil_data is not None else 0.0,
487
+ "vmec_map_theta_step_count": int(vmec_data["theta_step_count"]) if vmec_data is not None else 0,
488
+ "forward_boundary_fraction": forward_boundary_fraction,
489
+ "backward_boundary_fraction": backward_boundary_fraction,
490
+ },
491
+ )
492
+
493
+
494
+ def _normalize_essos_map_source(map_source: str) -> str:
495
+ normalized = str(map_source).strip().lower().replace("-", "_")
496
+ aliases = {
497
+ "essos": "coil",
498
+ "essos_coil": "coil",
499
+ "coil_map": "coil",
500
+ "vmec_map": "vmec",
501
+ "hybrid_map": "hybrid",
502
+ }
503
+ normalized = aliases.get(normalized, normalized)
504
+ if normalized not in {"coil", "vmec", "hybrid"}:
505
+ raise ValueError("map_source must be one of 'coil', 'vmec', or 'hybrid'")
506
+ return normalized
507
+
508
+
509
+ def _build_essos_coil_fci_map_data(
510
+ *,
511
+ modules: dict[str, Any],
512
+ resolved_coil_json: Path,
513
+ field: Any,
514
+ initial_xyz: np.ndarray,
515
+ start_phi: np.ndarray,
516
+ start_y_index: np.ndarray,
517
+ dphi: float,
518
+ shape: tuple[int, int, int],
519
+ coordinates_x: np.ndarray,
520
+ coordinates_y: np.ndarray,
521
+ coordinates_z: np.ndarray,
522
+ maxtime: float,
523
+ times_to_trace: int,
524
+ trace_tolerance: float,
525
+ ) -> dict[str, Any]:
526
+ import jax
527
+ import jax.numpy as local_jnp
528
+
529
+ nx, ny, nz = shape
530
+ forward_trajectories = _trace_essos_initial_conditions(
531
+ modules=modules,
532
+ resolved_coil_json=resolved_coil_json,
533
+ initial_xyz=initial_xyz,
534
+ current_sign=1.0,
535
+ maxtime=maxtime,
536
+ times_to_trace=times_to_trace,
537
+ trace_tolerance=trace_tolerance,
538
+ )
539
+ if _median_toroidal_advance(forward_trajectories) < 0.0:
540
+ forward_current_sign = -1.0
541
+ forward_trajectories = _trace_essos_initial_conditions(
542
+ modules=modules,
543
+ resolved_coil_json=resolved_coil_json,
544
+ initial_xyz=initial_xyz,
545
+ current_sign=forward_current_sign,
546
+ maxtime=maxtime,
547
+ times_to_trace=times_to_trace,
548
+ trace_tolerance=trace_tolerance,
549
+ )
550
+ else:
551
+ forward_current_sign = 1.0
552
+ backward_trajectories = _trace_essos_initial_conditions(
553
+ modules=modules,
554
+ resolved_coil_json=resolved_coil_json,
555
+ initial_xyz=initial_xyz,
556
+ current_sign=-forward_current_sign,
557
+ maxtime=maxtime,
558
+ times_to_trace=times_to_trace,
559
+ trace_tolerance=trace_tolerance,
560
+ )
561
+ forward_endpoint, forward_length, forward_crossed = _interpolate_trajectories_at_toroidal_plane(
562
+ forward_trajectories,
563
+ target_phi=start_phi + dphi,
564
+ )
565
+ backward_endpoint, backward_length, backward_crossed = _interpolate_trajectories_at_toroidal_plane(
566
+ backward_trajectories,
567
+ target_phi=start_phi - dphi,
568
+ )
569
+ forward_x, forward_z, forward_boundary = _cartesian_to_structured_surface_indices(
570
+ forward_endpoint,
571
+ crossed=forward_crossed,
572
+ target_y_index=(start_y_index + 1) % int(ny),
573
+ coordinates_x=coordinates_x,
574
+ coordinates_y=coordinates_y,
575
+ coordinates_z=coordinates_z,
576
+ )
577
+ backward_x, backward_z, backward_boundary = _cartesian_to_structured_surface_indices(
578
+ backward_endpoint,
579
+ crossed=backward_crossed,
580
+ target_y_index=(start_y_index - 1) % int(ny),
581
+ coordinates_x=coordinates_x,
582
+ coordinates_y=coordinates_y,
583
+ coordinates_z=coordinates_z,
584
+ )
585
+ b_xyz = np.asarray(jax.vmap(field.B)(local_jnp.asarray(initial_xyz, dtype=local_jnp.float64)), dtype=np.float64)
586
+ bmag = np.linalg.norm(b_xyz, axis=1).reshape(shape)
587
+ raw_forward_exit_length = _structured_exit_length_from_trajectories(
588
+ forward_trajectories,
589
+ coordinates_x=coordinates_x,
590
+ coordinates_y=coordinates_y,
591
+ coordinates_z=coordinates_z,
592
+ )
593
+ raw_backward_exit_length = _structured_exit_length_from_trajectories(
594
+ backward_trajectories,
595
+ coordinates_x=coordinates_x,
596
+ coordinates_y=coordinates_y,
597
+ coordinates_z=coordinates_z,
598
+ )
599
+ forward_exit_length = _mask_exit_length_to_boundary(
600
+ raw_forward_exit_length,
601
+ forward_boundary,
602
+ ).reshape(shape)
603
+ backward_exit_length = _mask_exit_length_to_boundary(
604
+ raw_backward_exit_length,
605
+ backward_boundary,
606
+ ).reshape(shape)
607
+ exit_length = _combine_bidirectional_exit_lengths(
608
+ forward_exit_length,
609
+ backward_exit_length,
610
+ )
611
+ adjacent_length = 0.5 * (forward_length + backward_length).reshape(shape)
612
+ connection_length = np.where(np.isfinite(exit_length), exit_length, adjacent_length)
613
+ maps = FciMaps(
614
+ forward_x=jnp.asarray(forward_x.reshape(shape), dtype=jnp.float64),
615
+ forward_z=jnp.asarray(forward_z.reshape(shape), dtype=jnp.float64),
616
+ backward_x=jnp.asarray(backward_x.reshape(shape), dtype=jnp.float64),
617
+ backward_z=jnp.asarray(backward_z.reshape(shape), dtype=jnp.float64),
618
+ forward_boundary=jnp.asarray(forward_boundary.reshape(shape)),
619
+ backward_boundary=jnp.asarray(backward_boundary.reshape(shape)),
620
+ dphi=dphi,
621
+ )
622
+ return {
623
+ "maps": maps,
624
+ "bmag": bmag,
625
+ "connection_length": connection_length,
626
+ "adjacent_step_length": adjacent_length,
627
+ "target_exit_length": exit_length,
628
+ "forward_target_exit_length": forward_exit_length,
629
+ "backward_target_exit_length": backward_exit_length,
630
+ "current_sign": float(forward_current_sign),
631
+ }
632
+
633
+
634
+ def _build_essos_vmec_fci_map_data(
635
+ *,
636
+ modules: dict[str, Any],
637
+ resolved_wout: Path,
638
+ coordinates: dict[str, Any],
639
+ shape: tuple[int, int, int],
640
+ dphi: float,
641
+ ) -> dict[str, Any]:
642
+ import jax
643
+ import jax.numpy as local_jnp
644
+
645
+ nx, ny, nz = shape
646
+ vmec = modules["Vmec"](str(resolved_wout))
647
+ s = np.broadcast_to(np.asarray(coordinates["s_1d"], dtype=np.float64)[:, None, None], shape).reshape(-1)
648
+ theta = np.asarray(coordinates["theta"], dtype=np.float64).reshape(-1)
649
+ phi = np.asarray(coordinates["phi"], dtype=np.float64).reshape(-1)
650
+ x_index = np.broadcast_to(np.arange(nx, dtype=np.float64)[:, None, None], shape).reshape(-1)
651
+ step_count = max(12, min(48, int(2 * ny)))
652
+ forward_theta = _integrate_vmec_theta_to_toroidal_offset(
653
+ vmec,
654
+ s=s,
655
+ theta=theta,
656
+ phi=phi,
657
+ delta_phi=float(dphi),
658
+ step_count=step_count,
659
+ )
660
+ backward_theta = _integrate_vmec_theta_to_toroidal_offset(
661
+ vmec,
662
+ s=s,
663
+ theta=theta,
664
+ phi=phi,
665
+ delta_phi=-float(dphi),
666
+ step_count=step_count,
667
+ )
668
+ forward_z = np.mod(forward_theta, 2.0 * np.pi) / (2.0 * np.pi) * float(nz)
669
+ backward_z = np.mod(backward_theta, 2.0 * np.pi) / (2.0 * np.pi) * float(nz)
670
+ finite = np.isfinite(forward_z) & np.isfinite(backward_z) & (s >= 0.0) & (s <= 1.0)
671
+ forward_boundary = ~finite
672
+ backward_boundary = ~finite
673
+ initial_stp = np.stack([s, theta, phi], axis=-1)
674
+ bmag = np.asarray(jax.vmap(vmec.AbsB)(local_jnp.asarray(initial_stp, dtype=local_jnp.float64)), dtype=np.float64).reshape(shape)
675
+ forward_length = _vmec_map_step_length(
676
+ coordinates=coordinates,
677
+ x_index=x_index,
678
+ y_index=(np.broadcast_to(np.arange(ny, dtype=int)[None, :, None], shape).reshape(-1) + 1) % ny,
679
+ z_index=forward_z,
680
+ )
681
+ backward_length = _vmec_map_step_length(
682
+ coordinates=coordinates,
683
+ x_index=x_index,
684
+ y_index=(np.broadcast_to(np.arange(ny, dtype=int)[None, :, None], shape).reshape(-1) - 1) % ny,
685
+ z_index=backward_z,
686
+ )
687
+ connection_length = 0.5 * (forward_length + backward_length).reshape(shape)
688
+ maps = FciMaps(
689
+ forward_x=jnp.asarray(x_index.reshape(shape), dtype=jnp.float64),
690
+ forward_z=jnp.asarray(forward_z.reshape(shape), dtype=jnp.float64),
691
+ backward_x=jnp.asarray(x_index.reshape(shape), dtype=jnp.float64),
692
+ backward_z=jnp.asarray(backward_z.reshape(shape), dtype=jnp.float64),
693
+ forward_boundary=jnp.asarray(forward_boundary.reshape(shape)),
694
+ backward_boundary=jnp.asarray(backward_boundary.reshape(shape)),
695
+ dphi=float(dphi),
696
+ )
697
+ return {
698
+ "maps": maps,
699
+ "bmag": bmag,
700
+ "connection_length": connection_length,
701
+ "theta_step_count": int(step_count),
702
+ }
703
+
704
+
705
+ def _integrate_vmec_theta_to_toroidal_offset(
706
+ vmec: Any,
707
+ *,
708
+ s: np.ndarray,
709
+ theta: np.ndarray,
710
+ phi: np.ndarray,
711
+ delta_phi: float,
712
+ step_count: int,
713
+ ) -> np.ndarray:
714
+ import jax
715
+ import jax.numpy as local_jnp
716
+
717
+ s_jax = local_jnp.asarray(s, dtype=local_jnp.float64)
718
+ theta_jax = local_jnp.asarray(theta, dtype=local_jnp.float64)
719
+ phi_jax = local_jnp.asarray(phi, dtype=local_jnp.float64)
720
+ h = float(delta_phi) / float(step_count)
721
+
722
+ def rhs(theta_value: jax.Array, phi_value: jax.Array) -> jax.Array:
723
+ points = local_jnp.stack([s_jax, theta_value, phi_value], axis=-1)
724
+ b_contra = jax.vmap(vmec.B_contravariant)(points)
725
+ b_phi = local_jnp.where(local_jnp.abs(b_contra[:, 2]) > 1.0e-30, b_contra[:, 2], 1.0e-30)
726
+ return b_contra[:, 1] / b_phi
727
+
728
+ def body(_: int, carry: tuple[jax.Array, jax.Array]) -> tuple[jax.Array, jax.Array]:
729
+ theta_value, phi_value = carry
730
+ k1 = rhs(theta_value, phi_value)
731
+ k2 = rhs(theta_value + 0.5 * h * k1, phi_value + 0.5 * h)
732
+ k3 = rhs(theta_value + 0.5 * h * k2, phi_value + 0.5 * h)
733
+ k4 = rhs(theta_value + h * k3, phi_value + h)
734
+ next_theta = theta_value + (h / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
735
+ return next_theta, phi_value + h
736
+
737
+ integrate = jax.jit(lambda theta0, phi0: jax.lax.fori_loop(0, int(step_count), body, (theta0, phi0))[0])
738
+ return np.asarray(integrate(theta_jax, phi_jax), dtype=np.float64)
739
+
740
+
741
+ def _vmec_map_step_length(
742
+ *,
743
+ coordinates: dict[str, Any],
744
+ x_index: np.ndarray,
745
+ y_index: np.ndarray,
746
+ z_index: np.ndarray,
747
+ ) -> np.ndarray:
748
+ current = np.stack(
749
+ [
750
+ np.asarray(coordinates["x"], dtype=np.float64).reshape(-1),
751
+ np.asarray(coordinates["y"], dtype=np.float64).reshape(-1),
752
+ np.asarray(coordinates["z"], dtype=np.float64).reshape(-1),
753
+ ],
754
+ axis=-1,
755
+ )
756
+ endpoint = _sample_structured_coordinates(
757
+ np.asarray(coordinates["x"], dtype=np.float64),
758
+ np.asarray(coordinates["y"], dtype=np.float64),
759
+ np.asarray(coordinates["z"], dtype=np.float64),
760
+ x_index=x_index,
761
+ y_index=y_index,
762
+ z_index=z_index,
763
+ )
764
+ length = np.linalg.norm(endpoint - current, axis=1)
765
+ return np.where(np.isfinite(length), length, 0.0)
766
+
767
+
768
+ def _sample_structured_coordinates(
769
+ coordinates_x: np.ndarray,
770
+ coordinates_y: np.ndarray,
771
+ coordinates_z: np.ndarray,
772
+ *,
773
+ x_index: np.ndarray,
774
+ y_index: np.ndarray,
775
+ z_index: np.ndarray,
776
+ ) -> np.ndarray:
777
+ nx, ny, nz = coordinates_x.shape
778
+ x0 = np.clip(np.floor(x_index).astype(int), 0, nx - 1)
779
+ x1 = np.clip(x0 + 1, 0, nx - 1)
780
+ y = np.mod(y_index.astype(int), ny)
781
+ z = np.mod(z_index, float(nz))
782
+ z0 = np.floor(z).astype(int) % nz
783
+ z1 = (z0 + 1) % nz
784
+ wx = np.clip(x_index - x0.astype(np.float64), 0.0, 1.0)
785
+ wz = z - np.floor(z)
786
+ result = []
787
+ for values in (coordinates_x, coordinates_y, coordinates_z):
788
+ f00 = values[x0, y, z0]
789
+ f10 = values[x1, y, z0]
790
+ f01 = values[x0, y, z1]
791
+ f11 = values[x1, y, z1]
792
+ result.append((1.0 - wx) * (1.0 - wz) * f00 + wx * (1.0 - wz) * f10 + (1.0 - wx) * wz * f01 + wx * wz * f11)
793
+ return np.stack(result, axis=-1)
794
+
795
+
796
+ def save_essos_field_line_bundle_npz(bundle: EssosFieldLineBundle, path: str | Path) -> Path:
797
+ """Write a portable ESSOS field-line import bundle."""
798
+
799
+ resolved = Path(path)
800
+ resolved.parent.mkdir(parents=True, exist_ok=True)
801
+ np.savez_compressed(
802
+ resolved,
803
+ trajectories_xyz=bundle.trajectories_xyz.astype(np.float32),
804
+ times=bundle.times.astype(np.float64),
805
+ initial_xyz=bundle.initial_xyz.astype(np.float32),
806
+ poincare_r=bundle.poincare_r.astype(np.float32),
807
+ poincare_z=bundle.poincare_z.astype(np.float32),
808
+ poincare_time=bundle.poincare_time.astype(np.float32),
809
+ poincare_section=bundle.poincare_section.astype(np.float32),
810
+ poincare_line_index=bundle.poincare_line_index.astype(np.int32),
811
+ field_sample_xyz=bundle.field_sample_xyz.astype(np.float32),
812
+ field_sample_b_xyz=bundle.field_sample_b_xyz.astype(np.float32),
813
+ coil_gamma_xyz=bundle.coil_gamma_xyz.astype(np.float32),
814
+ coil_currents=bundle.coil_currents.astype(np.float64),
815
+ )
816
+ return resolved
817
+
818
+
819
+ def load_essos_field_line_bundle_npz(path: str | Path, *, metadata: dict[str, Any] | None = None) -> EssosFieldLineBundle:
820
+ """Load an ESSOS field-line import bundle produced by `drbx`."""
821
+
822
+ with np.load(Path(path)) as data:
823
+ return EssosFieldLineBundle(
824
+ trajectories_xyz=np.asarray(data["trajectories_xyz"], dtype=np.float64),
825
+ times=np.asarray(data["times"], dtype=np.float64),
826
+ initial_xyz=np.asarray(data["initial_xyz"], dtype=np.float64),
827
+ poincare_r=np.asarray(data["poincare_r"], dtype=np.float64),
828
+ poincare_z=np.asarray(data["poincare_z"], dtype=np.float64),
829
+ poincare_time=np.asarray(data["poincare_time"], dtype=np.float64),
830
+ poincare_section=np.asarray(data["poincare_section"], dtype=np.float64),
831
+ poincare_line_index=np.asarray(data["poincare_line_index"], dtype=np.int32),
832
+ field_sample_xyz=np.asarray(data["field_sample_xyz"], dtype=np.float64),
833
+ field_sample_b_xyz=np.asarray(data["field_sample_b_xyz"], dtype=np.float64),
834
+ coil_gamma_xyz=np.asarray(data["coil_gamma_xyz"], dtype=np.float64),
835
+ coil_currents=np.asarray(data["coil_currents"], dtype=np.float64),
836
+ metadata={} if metadata is None else dict(metadata),
837
+ )
838
+
839
+
840
+ def _import_essos_modules(*, essos_root: str | Path | None = None) -> dict[str, Any]:
841
+ import jax
842
+
843
+ jax.config.update("jax_enable_x64", True)
844
+ if essos_root is not None:
845
+ root = Path(essos_root)
846
+ else:
847
+ root = Path(os.environ.get("DRBX_ESSOS_ROOT", _PRIVATE_DEFAULT_ESSOS_ROOT))
848
+ if root.exists():
849
+ root_text = str(root)
850
+ if root_text not in sys.path:
851
+ sys.path.insert(0, root_text)
852
+ coils_module = importlib.import_module("essos.coils")
853
+ fields_module = importlib.import_module("essos.fields")
854
+ dynamics_module = importlib.import_module("essos.dynamics")
855
+ if os.environ.get("DRBX_ESSOS_PROGRESS") != "1" and hasattr(dynamics_module, "NoProgressMeter"):
856
+ dynamics_module.TqdmProgressMeter = dynamics_module.NoProgressMeter
857
+ return {
858
+ # Newer ESSOS branches replace the module-level loader with a
859
+ # classmethod; accept either.
860
+ "Coils_from_json": getattr(coils_module, "Coils_from_json", None)
861
+ or coils_module.Coils.from_json,
862
+ "BiotSavart": fields_module.BiotSavart,
863
+ "Vmec": fields_module.Vmec,
864
+ "Tracing": dynamics_module.Tracing,
865
+ }
866
+
867
+
868
+ def _trace_essos_initial_conditions(
869
+ *,
870
+ modules: dict[str, Any],
871
+ resolved_coil_json: Path,
872
+ initial_xyz: np.ndarray,
873
+ current_sign: float,
874
+ maxtime: float,
875
+ times_to_trace: int,
876
+ trace_tolerance: float,
877
+ ) -> np.ndarray:
878
+ import jax
879
+ import jax.numpy as local_jnp
880
+
881
+ coils = modules["Coils_from_json"](str(resolved_coil_json))
882
+ if current_sign < 0.0:
883
+ coils.dofs_currents = -coils.dofs_currents
884
+ field = modules["BiotSavart"](coils)
885
+ tracing = jax.block_until_ready(
886
+ modules["Tracing"](
887
+ field=field,
888
+ model="FieldLineAdaptative",
889
+ initial_conditions=local_jnp.asarray(initial_xyz, dtype=local_jnp.float64),
890
+ maxtime=float(maxtime),
891
+ times_to_trace=int(times_to_trace),
892
+ atol=float(trace_tolerance),
893
+ rtol=float(trace_tolerance),
894
+ )
895
+ )
896
+ return np.asarray(tracing.trajectories[:, :, :3], dtype=np.float64)
897
+
898
+
899
+ def _trace_essos_vmec_initial_conditions(
900
+ *,
901
+ modules: dict[str, Any],
902
+ resolved_wout: Path,
903
+ initial_stp: np.ndarray,
904
+ maxtime: float,
905
+ times_to_trace: int,
906
+ trace_tolerance: float,
907
+ ) -> np.ndarray:
908
+ import jax
909
+ import jax.numpy as local_jnp
910
+
911
+ vmec = modules["Vmec"](str(resolved_wout))
912
+ tracing = jax.block_until_ready(
913
+ modules["Tracing"](
914
+ field=vmec,
915
+ model="FieldLineAdaptative",
916
+ initial_conditions=local_jnp.asarray(initial_stp, dtype=local_jnp.float64),
917
+ maxtime=float(maxtime),
918
+ times_to_trace=int(times_to_trace),
919
+ atol=float(trace_tolerance),
920
+ rtol=float(trace_tolerance),
921
+ )
922
+ )
923
+ return np.asarray(tracing.trajectories[:, :, :3], dtype=np.float64)
924
+
925
+
926
+ def _median_toroidal_advance(trajectories_xyz: np.ndarray) -> float:
927
+ phi = np.unwrap(np.arctan2(trajectories_xyz[:, :, 1], trajectories_xyz[:, :, 0]), axis=1)
928
+ return float(np.nanmedian(phi[:, -1] - phi[:, 0]))
929
+
930
+
931
+ def _interpolate_trajectories_at_toroidal_plane(
932
+ trajectories_xyz: np.ndarray,
933
+ *,
934
+ target_phi: np.ndarray,
935
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
936
+ endpoints = np.full((trajectories_xyz.shape[0], 3), np.nan, dtype=np.float64)
937
+ lengths = np.full(trajectories_xyz.shape[0], np.nan, dtype=np.float64)
938
+ crossed = np.zeros(trajectories_xyz.shape[0], dtype=bool)
939
+ for index, trajectory in enumerate(trajectories_xyz):
940
+ phi = np.unwrap(np.arctan2(trajectory[:, 1], trajectory[:, 0]))
941
+ arc_length = np.concatenate(
942
+ [
943
+ np.zeros(1, dtype=np.float64),
944
+ np.cumsum(np.linalg.norm(np.diff(trajectory, axis=0), axis=1)),
945
+ ]
946
+ )
947
+ if phi[-1] < phi[0]:
948
+ phi = phi[::-1]
949
+ trajectory = trajectory[::-1]
950
+ arc_length = arc_length[-1] - arc_length[::-1]
951
+ target = float(target_phi[index])
952
+ if target < phi[0] or target > phi[-1] or not np.all(np.isfinite(phi)):
953
+ continue
954
+ endpoints[index, 0] = np.interp(target, phi, trajectory[:, 0])
955
+ endpoints[index, 1] = np.interp(target, phi, trajectory[:, 1])
956
+ endpoints[index, 2] = np.interp(target, phi, trajectory[:, 2])
957
+ lengths[index] = np.interp(target, phi, arc_length)
958
+ crossed[index] = True
959
+ return endpoints, lengths, crossed
960
+
961
+
962
+ def _cartesian_to_annular_indices(
963
+ points_xyz: np.ndarray,
964
+ *,
965
+ crossed: np.ndarray,
966
+ axis_major_radius: float,
967
+ axis_vertical: float,
968
+ rho_min: float,
969
+ rho_max: float,
970
+ nx: int,
971
+ nz: int,
972
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
973
+ major = np.sqrt(points_xyz[:, 0] ** 2 + points_xyz[:, 1] ** 2)
974
+ vertical = points_xyz[:, 2]
975
+ radial_offset = major - float(axis_major_radius)
976
+ vertical_offset = vertical - float(axis_vertical)
977
+ rho = np.sqrt(radial_offset * radial_offset + vertical_offset * vertical_offset)
978
+ theta = np.mod(np.arctan2(vertical_offset, radial_offset), 2.0 * np.pi)
979
+ x_index = (rho - float(rho_min)) / max(float(rho_max - rho_min), 1.0e-30) * float(nx - 1)
980
+ z_index = theta / (2.0 * np.pi) * float(nz)
981
+ boundary = (~crossed) | (~np.isfinite(x_index)) | (x_index < 0.0) | (x_index > float(nx - 1))
982
+ x_index = np.where(boundary, 0.0, x_index)
983
+ z_index = np.where(boundary | (~np.isfinite(z_index)), 0.0, z_index)
984
+ return x_index, z_index, boundary
985
+
986
+
987
+ def build_essos_vmec_scaled_qa_coordinates(
988
+ wout_path: Path,
989
+ *,
990
+ nx: int,
991
+ ny: int,
992
+ nz: int,
993
+ rho_min: float,
994
+ rho_max: float,
995
+ axis_major_radius: float,
996
+ axis_vertical: float,
997
+ ) -> dict[str, Any]:
998
+ """Evaluate scaled Landreman-Paul QA VMEC Fourier surfaces on a logical grid."""
999
+
1000
+ from netCDF4 import Dataset
1001
+
1002
+ with Dataset(wout_path) as dataset:
1003
+ xm = np.asarray(dataset.variables["xm"][:], dtype=np.float64)
1004
+ xn = np.asarray(dataset.variables["xn"][:], dtype=np.float64)
1005
+ rmnc = np.asarray(dataset.variables["rmnc"][:], dtype=np.float64)
1006
+ zmns = np.asarray(dataset.variables["zmns"][:], dtype=np.float64)
1007
+ nfp = int(np.asarray(dataset.variables["nfp"][:]).reshape(()))
1008
+
1009
+ if rmnc.ndim != 2 or zmns.shape != rmnc.shape:
1010
+ raise ValueError("VMEC wout Fourier arrays must have shape (ns, mnmax)")
1011
+
1012
+ rho_1d = np.linspace(float(rho_min), float(rho_max), int(nx))
1013
+ phi_1d = np.linspace(0.0, 2.0 * np.pi, int(ny), endpoint=False)
1014
+ theta_1d = np.linspace(0.0, 2.0 * np.pi, int(nz), endpoint=False)
1015
+ rho, phi, theta = np.meshgrid(rho_1d, phi_1d, theta_1d, indexing="ij")
1016
+
1017
+ ns = int(rmnc.shape[0])
1018
+ s_full = np.linspace(0.0, 1.0, ns)
1019
+ normalized_radius = (rho_1d - float(rho_min)) / max(float(rho_max - rho_min), 1.0e-30)
1020
+ normalized_radius = (float(rho_min) / max(float(rho_max), 1.0e-30)) + (
1021
+ 1.0 - float(rho_min) / max(float(rho_max), 1.0e-30)
1022
+ ) * normalized_radius
1023
+ s_requested = np.clip(normalized_radius * normalized_radius, 0.0, 1.0)
1024
+
1025
+ rmnc_shells = np.empty((int(nx), rmnc.shape[1]), dtype=np.float64)
1026
+ zmns_shells = np.empty_like(rmnc_shells)
1027
+ for mode_index in range(rmnc.shape[1]):
1028
+ rmnc_shells[:, mode_index] = np.interp(s_requested, s_full, rmnc[:, mode_index])
1029
+ zmns_shells[:, mode_index] = np.interp(s_requested, s_full, zmns[:, mode_index])
1030
+
1031
+ raw_axis_major = float(rmnc[0, 0])
1032
+ raw_axis_vertical = float(zmns[0, 0])
1033
+ scale = float(axis_major_radius) / max(abs(raw_axis_major), 1.0e-30)
1034
+ phase = xm[None, None, None, :] * theta[..., None] - xn[None, None, None, :] * phi[..., None]
1035
+ raw_major = np.sum(rmnc_shells[:, None, None, :] * np.cos(phase), axis=-1)
1036
+ raw_vertical = np.sum(zmns_shells[:, None, None, :] * np.sin(phase), axis=-1)
1037
+ major = float(axis_major_radius) + scale * (raw_major - raw_axis_major)
1038
+ vertical = float(axis_vertical) + scale * (raw_vertical - raw_axis_vertical)
1039
+ x = major * np.cos(phi)
1040
+ y = major * np.sin(phi)
1041
+ z = vertical
1042
+
1043
+ edge_major = major[-1]
1044
+ edge_vertical = vertical[-1]
1045
+ mean_edge_major_by_phi = np.mean(edge_major, axis=1)
1046
+ nonaxisymmetric_major_rms = float(np.std(mean_edge_major_by_phi) / max(abs(float(np.mean(mean_edge_major_by_phi))), 1.0e-30))
1047
+ edge_extent = np.sqrt((edge_major - np.mean(edge_major, axis=1, keepdims=True)) ** 2 + edge_vertical**2)
1048
+ poloidal_extent_rms = float(np.sqrt(np.mean(edge_extent * edge_extent)))
1049
+
1050
+ return {
1051
+ "rho_1d": rho_1d,
1052
+ "s_1d": s_requested,
1053
+ "phi_1d": phi_1d,
1054
+ "theta_1d": theta_1d,
1055
+ "rho": rho,
1056
+ "phi": phi,
1057
+ "theta": theta,
1058
+ "major": major,
1059
+ "vertical": vertical,
1060
+ "x": x,
1061
+ "y": y,
1062
+ "z": z,
1063
+ "metadata": {
1064
+ "vmec_nfp": int(nfp),
1065
+ "vmec_ns": ns,
1066
+ "vmec_mnmax": int(rmnc.shape[1]),
1067
+ "vmec_raw_axis_major_radius": raw_axis_major,
1068
+ "vmec_raw_axis_vertical": raw_axis_vertical,
1069
+ "vmec_to_essos_length_scale": scale,
1070
+ "vmec_s_min": float(np.min(s_requested)),
1071
+ "vmec_s_max": float(np.max(s_requested)),
1072
+ "surface_nonaxisymmetric_major_rms": nonaxisymmetric_major_rms,
1073
+ "surface_poloidal_extent_rms": poloidal_extent_rms,
1074
+ },
1075
+ }
1076
+
1077
+
1078
+ def _cartesian_to_structured_surface_indices(
1079
+ points_xyz: np.ndarray,
1080
+ *,
1081
+ crossed: np.ndarray,
1082
+ target_y_index: np.ndarray,
1083
+ coordinates_x: np.ndarray,
1084
+ coordinates_y: np.ndarray,
1085
+ coordinates_z: np.ndarray,
1086
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
1087
+ nx, ny, nz = coordinates_x.shape
1088
+ x_index = np.zeros(points_xyz.shape[0], dtype=np.float64)
1089
+ z_index = np.zeros(points_xyz.shape[0], dtype=np.float64)
1090
+ boundary = np.ones(points_xyz.shape[0], dtype=bool)
1091
+
1092
+ plane_spacing = _structured_plane_spacing(coordinates_x, coordinates_y, coordinates_z)
1093
+ for index, point in enumerate(points_xyz):
1094
+ if not bool(crossed[index]) or not np.all(np.isfinite(point)):
1095
+ continue
1096
+ y_index = int(target_y_index[index]) % ny
1097
+ plane = np.column_stack(
1098
+ [
1099
+ coordinates_x[:, y_index, :].reshape(-1),
1100
+ coordinates_y[:, y_index, :].reshape(-1),
1101
+ coordinates_z[:, y_index, :].reshape(-1),
1102
+ ]
1103
+ )
1104
+ distance_squared = np.sum((plane - point[None, :]) ** 2, axis=1)
1105
+ nearest = int(np.argmin(distance_squared))
1106
+ nearest_distance = float(np.sqrt(distance_squared[nearest]))
1107
+ radial_index = nearest // nz
1108
+ poloidal_index = nearest % nz
1109
+ x_index[index] = float(radial_index)
1110
+ z_index[index] = float(poloidal_index)
1111
+ max_distance = 2.75 * float(plane_spacing[y_index])
1112
+ boundary[index] = (
1113
+ nearest_distance > max_distance
1114
+ or radial_index <= 0
1115
+ or radial_index >= nx - 1
1116
+ )
1117
+ if boundary[index]:
1118
+ x_index[index] = 0.0
1119
+ z_index[index] = 0.0
1120
+ return x_index, z_index, boundary
1121
+
1122
+
1123
+ def _structured_plane_spacing(coordinates_x: np.ndarray, coordinates_y: np.ndarray, coordinates_z: np.ndarray) -> np.ndarray:
1124
+ coords = np.stack([coordinates_x, coordinates_y, coordinates_z], axis=-1)
1125
+ radial_spacing = np.linalg.norm(np.diff(coords, axis=0), axis=-1)
1126
+ poloidal_spacing = np.linalg.norm(np.diff(np.concatenate([coords, coords[:, :, :1, :]], axis=2), axis=2), axis=-1)
1127
+ spacing = []
1128
+ for y_index in range(coords.shape[1]):
1129
+ values = np.concatenate([radial_spacing[:, y_index, :].reshape(-1), poloidal_spacing[:, y_index, :].reshape(-1)])
1130
+ finite = values[np.isfinite(values) & (values > 0.0)]
1131
+ spacing.append(float(np.median(finite)) if finite.size else 1.0)
1132
+ return np.asarray(spacing, dtype=np.float64)
1133
+
1134
+
1135
+ def _annular_exit_length_from_trajectories(
1136
+ trajectories_xyz: np.ndarray,
1137
+ *,
1138
+ axis_major_radius: float,
1139
+ axis_vertical: float,
1140
+ rho_min: float,
1141
+ rho_max: float,
1142
+ ) -> np.ndarray:
1143
+ major = np.sqrt(trajectories_xyz[:, :, 0] ** 2 + trajectories_xyz[:, :, 1] ** 2)
1144
+ vertical = trajectories_xyz[:, :, 2]
1145
+ rho = np.sqrt((major - float(axis_major_radius)) ** 2 + (vertical - float(axis_vertical)) ** 2)
1146
+ tolerance = 1.0e-10 * max(float(rho_max - rho_min), 1.0)
1147
+ outside = (rho < float(rho_min) - tolerance) | (rho > float(rho_max) + tolerance) | (~np.isfinite(rho))
1148
+ arc_length = np.concatenate(
1149
+ [
1150
+ np.zeros((trajectories_xyz.shape[0], 1), dtype=np.float64),
1151
+ np.cumsum(np.linalg.norm(np.diff(trajectories_xyz, axis=1), axis=2), axis=1),
1152
+ ],
1153
+ axis=1,
1154
+ )
1155
+ first_exit = np.argmax(outside, axis=1)
1156
+ has_exit = np.any(outside, axis=1)
1157
+ return np.where(has_exit, arc_length[np.arange(trajectories_xyz.shape[0]), first_exit], arc_length[:, -1])
1158
+
1159
+
1160
+ def _structured_exit_length_from_trajectories(
1161
+ trajectories_xyz: np.ndarray,
1162
+ *,
1163
+ coordinates_x: np.ndarray,
1164
+ coordinates_y: np.ndarray,
1165
+ coordinates_z: np.ndarray,
1166
+ ) -> np.ndarray:
1167
+ nx, ny, nz = coordinates_x.shape
1168
+ coords = np.stack([coordinates_x, coordinates_y, coordinates_z], axis=-1)
1169
+ radial_min = np.nanmin(np.sqrt(coordinates_x[0] * coordinates_x[0] + coordinates_y[0] * coordinates_y[0]))
1170
+ radial_max = np.nanmax(np.sqrt(coordinates_x[-1] * coordinates_x[-1] + coordinates_y[-1] * coordinates_y[-1]))
1171
+ vertical_min = np.nanmin(coordinates_z[-1])
1172
+ vertical_max = np.nanmax(coordinates_z[-1])
1173
+ padding = max(float(np.nanmedian(_structured_plane_spacing(coordinates_x, coordinates_y, coordinates_z))), 1.0e-6)
1174
+ arc_length = np.concatenate(
1175
+ [
1176
+ np.zeros((trajectories_xyz.shape[0], 1), dtype=np.float64),
1177
+ np.cumsum(np.linalg.norm(np.diff(trajectories_xyz, axis=1), axis=2), axis=1),
1178
+ ],
1179
+ axis=1,
1180
+ )
1181
+ major = np.sqrt(trajectories_xyz[:, :, 0] ** 2 + trajectories_xyz[:, :, 1] ** 2)
1182
+ vertical = trajectories_xyz[:, :, 2]
1183
+ broad_exit = (
1184
+ (major < radial_min - padding)
1185
+ | (major > radial_max + padding)
1186
+ | (vertical < vertical_min - padding)
1187
+ | (vertical > vertical_max + padding)
1188
+ | (~np.isfinite(major))
1189
+ | (~np.isfinite(vertical))
1190
+ )
1191
+ first_exit = np.argmax(broad_exit, axis=1)
1192
+ has_exit = np.any(broad_exit, axis=1)
1193
+ if nx * ny * nz <= 4096:
1194
+ flat_coords = coords.reshape((-1, 3))
1195
+ max_distance = 3.0 * padding
1196
+ for line_index, trajectory in enumerate(trajectories_xyz):
1197
+ if has_exit[line_index]:
1198
+ continue
1199
+ stride = max(int(trajectory.shape[0] // 48), 1)
1200
+ sampled = trajectory[::stride]
1201
+ distances = np.sqrt(np.min(np.sum((sampled[:, None, :] - flat_coords[None, :, :]) ** 2, axis=-1), axis=1))
1202
+ exit_candidates = np.flatnonzero(distances > max_distance)
1203
+ if exit_candidates.size:
1204
+ first_exit[line_index] = min(int(exit_candidates[0]) * stride, trajectory.shape[0] - 1)
1205
+ has_exit[line_index] = True
1206
+ return np.where(has_exit, arc_length[np.arange(trajectories_xyz.shape[0]), first_exit], np.nan)
1207
+
1208
+
1209
+ def _combine_bidirectional_exit_lengths(
1210
+ forward_exit_length: np.ndarray,
1211
+ backward_exit_length: np.ndarray,
1212
+ ) -> np.ndarray:
1213
+ """Return the shortest finite target-exit length from either traced direction."""
1214
+
1215
+ forward = np.asarray(forward_exit_length, dtype=np.float64)
1216
+ backward = np.asarray(backward_exit_length, dtype=np.float64)
1217
+ if forward.shape != backward.shape:
1218
+ raise ValueError(
1219
+ "Forward and backward target-exit length arrays must have the same shape."
1220
+ )
1221
+ combined = np.full(forward.shape, np.nan, dtype=np.float64)
1222
+ forward_finite = np.isfinite(forward)
1223
+ backward_finite = np.isfinite(backward)
1224
+ both = forward_finite & backward_finite
1225
+ combined[both] = np.minimum(forward[both], backward[both])
1226
+ only_forward = forward_finite & ~backward_finite
1227
+ only_backward = backward_finite & ~forward_finite
1228
+ combined[only_forward] = forward[only_forward]
1229
+ combined[only_backward] = backward[only_backward]
1230
+ return combined
1231
+
1232
+
1233
+ def _mask_exit_length_to_boundary(exit_length: np.ndarray, boundary: np.ndarray) -> np.ndarray:
1234
+ """Keep target-exit lengths only where an FCI direction reaches a boundary."""
1235
+
1236
+ length = np.asarray(exit_length, dtype=np.float64)
1237
+ mask = np.asarray(boundary, dtype=bool)
1238
+ if length.shape != mask.shape:
1239
+ raise ValueError(
1240
+ "Target-exit length and boundary mask shapes must match: "
1241
+ f"length={length.shape}, boundary={mask.shape}."
1242
+ )
1243
+ return np.where(mask & np.isfinite(length), length, np.nan)
1244
+
1245
+
1246
+ def _metric_from_coordinates(
1247
+ x_cart: np.ndarray,
1248
+ y_cart: np.ndarray,
1249
+ z_cart: np.ndarray,
1250
+ *,
1251
+ s_1d: np.ndarray,
1252
+ phi_1d: np.ndarray,
1253
+ theta_1d: np.ndarray,
1254
+ Bxy: np.ndarray,
1255
+ ) -> MetricTensor3D:
1256
+ ds = float(s_1d[1] - s_1d[0]) if s_1d.size > 1 else 1.0
1257
+ dphi = float(phi_1d[1] - phi_1d[0]) if phi_1d.size > 1 else 2.0 * np.pi
1258
+ dtheta = float(theta_1d[1] - theta_1d[0]) if theta_1d.size > 1 else 2.0 * np.pi
1259
+ edge_order = 2 if min(x_cart.shape) > 2 else 1
1260
+
1261
+ derivs = []
1262
+ for coords in (x_cart, y_cart, z_cart):
1263
+ derivs.append(np.gradient(coords, ds, dphi, dtheta, edge_order=edge_order))
1264
+
1265
+ r_s = np.stack([derivs[0][0], derivs[1][0], derivs[2][0]], axis=-1)
1266
+ r_phi = np.stack([derivs[0][1], derivs[1][1], derivs[2][1]], axis=-1)
1267
+ r_theta = np.stack([derivs[0][2], derivs[1][2], derivs[2][2]], axis=-1)
1268
+ cov = np.empty(x_cart.shape + (3, 3), dtype=np.float64)
1269
+ basis = (r_s, r_phi, r_theta)
1270
+ for i, left in enumerate(basis):
1271
+ for j, right in enumerate(basis):
1272
+ cov[..., i, j] = np.sum(left * right, axis=-1)
1273
+ determinant = np.linalg.det(cov)
1274
+ regularization = np.maximum(1.0e-12, 1.0e-11 * np.nanmax(np.abs(determinant)))
1275
+ bad = determinant <= regularization
1276
+ if np.any(bad):
1277
+ cov[bad] = cov[bad] + np.eye(3) * regularization
1278
+ contrav = np.linalg.inv(cov)
1279
+ jacobian = np.sqrt(np.maximum(np.linalg.det(cov), regularization))
1280
+ return MetricTensor3D(
1281
+ dx=jnp.asarray(np.full_like(x_cart, ds), dtype=jnp.float64),
1282
+ dy=jnp.asarray(np.full_like(x_cart, dphi), dtype=jnp.float64),
1283
+ dz=jnp.asarray(np.full_like(x_cart, dtheta), dtype=jnp.float64),
1284
+ J=jnp.asarray(jacobian, dtype=jnp.float64),
1285
+ Bxy=jnp.asarray(Bxy, dtype=jnp.float64),
1286
+ g11=jnp.asarray(contrav[..., 0, 0], dtype=jnp.float64),
1287
+ g22=jnp.asarray(contrav[..., 1, 1], dtype=jnp.float64),
1288
+ g33=jnp.asarray(contrav[..., 2, 2], dtype=jnp.float64),
1289
+ g12=jnp.asarray(contrav[..., 0, 1], dtype=jnp.float64),
1290
+ g13=jnp.asarray(contrav[..., 0, 2], dtype=jnp.float64),
1291
+ g23=jnp.asarray(contrav[..., 1, 2], dtype=jnp.float64),
1292
+ g_11=jnp.asarray(cov[..., 0, 0], dtype=jnp.float64),
1293
+ g_22=jnp.asarray(cov[..., 1, 1], dtype=jnp.float64),
1294
+ g_33=jnp.asarray(cov[..., 2, 2], dtype=jnp.float64),
1295
+ g_12=jnp.asarray(cov[..., 0, 1], dtype=jnp.float64),
1296
+ g_13=jnp.asarray(cov[..., 0, 2], dtype=jnp.float64),
1297
+ g_23=jnp.asarray(cov[..., 1, 2], dtype=jnp.float64),
1298
+ )
1299
+
1300
+
1301
+ def _annular_metric_tensor(
1302
+ *,
1303
+ rho: np.ndarray,
1304
+ major: np.ndarray,
1305
+ bmag: np.ndarray,
1306
+ drho: float,
1307
+ dphi: float,
1308
+ dtheta: float,
1309
+ ) -> MetricTensor3D:
1310
+ zeros = np.zeros_like(rho)
1311
+ safe_rho = np.maximum(rho, 1.0e-8)
1312
+ safe_major = np.maximum(major, 1.0e-8)
1313
+ jacobian = safe_major * safe_rho
1314
+ return MetricTensor3D(
1315
+ dx=jnp.asarray(np.full_like(rho, float(drho)), dtype=jnp.float64),
1316
+ dy=jnp.asarray(np.full_like(rho, float(dphi)), dtype=jnp.float64),
1317
+ dz=jnp.asarray(np.full_like(rho, float(dtheta)), dtype=jnp.float64),
1318
+ J=jnp.asarray(jacobian, dtype=jnp.float64),
1319
+ Bxy=jnp.asarray(bmag, dtype=jnp.float64),
1320
+ g11=jnp.asarray(np.ones_like(rho), dtype=jnp.float64),
1321
+ g22=jnp.asarray(1.0 / (safe_major * safe_major), dtype=jnp.float64),
1322
+ g33=jnp.asarray(1.0 / (safe_rho * safe_rho), dtype=jnp.float64),
1323
+ g12=jnp.asarray(zeros, dtype=jnp.float64),
1324
+ g13=jnp.asarray(zeros, dtype=jnp.float64),
1325
+ g23=jnp.asarray(zeros, dtype=jnp.float64),
1326
+ g_11=jnp.asarray(np.ones_like(rho), dtype=jnp.float64),
1327
+ g_22=jnp.asarray(safe_major * safe_major, dtype=jnp.float64),
1328
+ g_33=jnp.asarray(safe_rho * safe_rho, dtype=jnp.float64),
1329
+ g_12=jnp.asarray(zeros, dtype=jnp.float64),
1330
+ g_13=jnp.asarray(zeros, dtype=jnp.float64),
1331
+ g_23=jnp.asarray(zeros, dtype=jnp.float64),
1332
+ )
1333
+
1334
+
1335
+ def _flatten_essos_poincare_data(
1336
+ plotting_data: list[tuple[Any, Any, Any]],
1337
+ *,
1338
+ n_field_lines: int,
1339
+ shifts: tuple[float, ...],
1340
+ ) -> dict[str, np.ndarray]:
1341
+ r_values: list[np.ndarray] = []
1342
+ z_values: list[np.ndarray] = []
1343
+ time_values: list[np.ndarray] = []
1344
+ section_values: list[np.ndarray] = []
1345
+ line_values: list[np.ndarray] = []
1346
+ for index, (r_slice, z_slice, time_slice) in enumerate(plotting_data):
1347
+ r_array = np.asarray(r_slice, dtype=np.float64).reshape(-1)
1348
+ z_array = np.asarray(z_slice, dtype=np.float64).reshape(-1)
1349
+ time_array = np.asarray(time_slice, dtype=np.float64).reshape(-1)
1350
+ finite = np.isfinite(r_array) & np.isfinite(z_array) & np.isfinite(time_array)
1351
+ r_array = r_array[finite]
1352
+ z_array = z_array[finite]
1353
+ time_array = time_array[finite]
1354
+ if r_array.size == 0:
1355
+ continue
1356
+ shift_index = min(index // max(int(n_field_lines), 1), max(len(shifts) - 1, 0))
1357
+ line_index = index % max(int(n_field_lines), 1)
1358
+ r_values.append(r_array)
1359
+ z_values.append(z_array)
1360
+ time_values.append(time_array)
1361
+ section_values.append(np.full(r_array.shape, shifts[shift_index], dtype=np.float64))
1362
+ line_values.append(np.full(r_array.shape, line_index, dtype=np.int32))
1363
+ if not r_values:
1364
+ return {
1365
+ "r": np.empty(0, dtype=np.float64),
1366
+ "z": np.empty(0, dtype=np.float64),
1367
+ "time": np.empty(0, dtype=np.float64),
1368
+ "section": np.empty(0, dtype=np.float64),
1369
+ "line_index": np.empty(0, dtype=np.int32),
1370
+ }
1371
+ return {
1372
+ "r": np.concatenate(r_values),
1373
+ "z": np.concatenate(z_values),
1374
+ "time": np.concatenate(time_values),
1375
+ "section": np.concatenate(section_values),
1376
+ "line_index": np.concatenate(line_values),
1377
+ }
1378
+
1379
+
1380
+ def _sample_essos_field(field: Any, points_xyz: np.ndarray) -> np.ndarray:
1381
+ import jax
1382
+ import jax.numpy as jnp
1383
+
1384
+ points = jnp.asarray(points_xyz, dtype=jnp.float64)
1385
+ return np.asarray(jax.vmap(field.B)(points), dtype=np.float64)