agnimhd 0.1.0__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.
agnimhd/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """AGNI -- Analysis of Global Normal-modes in Ideal MHD.
2
+
3
+ A differentiable finite-n ideal MHD stability solver.
4
+
5
+ AGNI solves ideal MHD stability from a **variational principle**: it discretizes
6
+ the energy functional rather than the force operator. That gives a generalized
7
+ symmetric eigenvalue problem ``A x = lambda B x`` with ``B`` (the kinetic/mass
8
+ matrix) symmetric positive definite. ``B`` is Cholesky-factored to reduce this to
9
+ a standard symmetric eigenvalue problem. Everything agnimhd returns is the
10
+ squared growth rate ``gamma^2 = -lambda`` of the most negative eigenvalue
11
+ ``lambda``: **positive means unstable**.
12
+
13
+ The package depends on ``jax``, ``numpy``, ``scipy`` and ``matfree``.
14
+ :func:`from_desc` imports DESC when it is called; nothing else does. Other
15
+ equilibrium codes fill an :class:`EquilibriumData` directly; see
16
+ ``docs/interface.md``.
17
+ """
18
+
19
+ from .adapters import from_desc
20
+ from .basis import Basis, DiffMat
21
+ from .config import AssemblyConfig, SolverConfig
22
+ from .equilibrium import FORMAT_VERSION, EquilibriumData
23
+ from .objective import (
24
+ eigenpair,
25
+ growth_rate,
26
+ growth_rate_and_grad,
27
+ growth_rate_of,
28
+ solve,
29
+ )
30
+ from .sources import load
31
+
32
+ __all__ = [
33
+ "AssemblyConfig",
34
+ "Basis",
35
+ "DiffMat",
36
+ "EquilibriumData",
37
+ "FORMAT_VERSION",
38
+ "SolverConfig",
39
+ "eigenpair",
40
+ "from_desc",
41
+ "growth_rate",
42
+ "growth_rate_and_grad",
43
+ "growth_rate_of",
44
+ "load",
45
+ "solve",
46
+ "__version__",
47
+ ]
48
+
49
+ try: # written by setuptools_scm at build or install time
50
+ from ._version import __version__
51
+ except ImportError: # a bare checkout on sys.path
52
+ __version__ = "0+unknown"
agnimhd/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,10 @@
1
+ """Adapters from equilibrium codes to :class:`agnimhd.EquilibriumData`.
2
+
3
+ Each adapter imports its equilibrium code lazily, inside the function, so the
4
+ package itself stays free of those dependencies: ``agnimhd`` installs and runs
5
+ without DESC; ``from_desc`` only works when DESC is importable.
6
+ """
7
+
8
+ from .desc import from_desc
9
+
10
+ __all__ = ["from_desc"]
@@ -0,0 +1,139 @@
1
+ """DESC -> :class:`agnimhd.EquilibriumData`.
2
+
3
+ ``from_desc`` takes a DESC ``Equilibrium`` or the path of a DESC ``.h5`` file,
4
+ evaluates the contract fields on the PEST nodes of a :class:`~agnimhd.Basis` and
5
+ returns the ``EquilibriumData`` together with the ``DiffMat`` on the same nodes.
6
+ DESC is imported inside the function only.
7
+ """
8
+
9
+ import numpy as np
10
+
11
+ from ..equilibrium import EquilibriumData
12
+
13
+ __all__ = ["desc_equilibrium", "from_desc", "is_desc_file"]
14
+
15
+ #: DESC compute key -> EquilibriumData field.
16
+ KEY_MAP = {
17
+ "g_rr|PEST": "g_rr",
18
+ "g_rv|PEST": "g_rv",
19
+ "g_rp|PEST": "g_rp",
20
+ "g_vv|PEST": "g_vv",
21
+ "g_vp|PEST": "g_vp",
22
+ "g_pp|PEST": "g_pp",
23
+ "g^rr": "g_sup_rr",
24
+ "sqrt(g)_PEST": "sqrtg",
25
+ "(sqrt(g)_PEST_r)|PEST": "sqrtg_r",
26
+ "(sqrt(g)_PEST_v)|PEST": "sqrtg_v",
27
+ "(sqrt(g)_PEST_p)|PEST": "sqrtg_p",
28
+ "J^zeta": "J_sup_zeta",
29
+ "|J|": "abs_J",
30
+ "iota": "iota",
31
+ "psi_r": "psi_r",
32
+ "psi_rr": "psi_rr",
33
+ "p": "p",
34
+ "p_r": "p_r",
35
+ "J x grad(rho)": "J_cross_grad_rho",
36
+ "(B*grad) grad(rho)": "B_dot_grad_grad_rho",
37
+ }
38
+
39
+
40
+ def is_desc_file(path):
41
+ """True if ``path`` is an HDF5 file written by DESC (``desc.*`` class tag)."""
42
+ try:
43
+ import h5py
44
+
45
+ with h5py.File(path, "r") as f:
46
+ return (
47
+ str(f.attrs.get("__class__", f.get("__class__", [b""])[()])).find(
48
+ "desc."
49
+ )
50
+ >= 0
51
+ )
52
+ except Exception: # not HDF5, no h5py, or no class tag
53
+ return False
54
+
55
+
56
+ def desc_equilibrium(eq):
57
+ """``eq`` itself, or the last equilibrium in the DESC ``.h5`` file ``eq``."""
58
+ if not isinstance(eq, str):
59
+ return eq
60
+ from desc.io import load
61
+
62
+ eq = load(eq)
63
+ return eq[-1] if isinstance(eq, (list, tuple)) or hasattr(eq, "__getitem__") else eq
64
+
65
+
66
+ def from_desc(eq, basis, family=0, density=False, coarse=None):
67
+ """Evaluate a DESC equilibrium on the PEST nodes of ``basis``.
68
+
69
+ Parameters
70
+ ----------
71
+ eq : desc.equilibrium.Equilibrium or str
72
+ A DESC ``Equilibrium``, or the path of a DESC ``.h5`` file (the last
73
+ equilibrium of a family is used).
74
+ basis : agnimhd.Basis
75
+ Nodes and derivative matrices; see ``docs/options.md``.
76
+ family : int
77
+ Toroidal mode family of the returned ``DiffMat``, ``n = family + k NFP``
78
+ (:meth:`~agnimhd.Basis.nodes_and_diffmat`). The geometry does not depend
79
+ on it: another family's ``DiffMat`` is
80
+ ``basis.nodes_and_diffmat(eq_data.NFP, family=x)[1]``.
81
+ density : bool
82
+ Store DESC's ``ni`` on the nodes, normalized to its maximum (ones if the
83
+ equilibrium has no density profile), as ``eq_data.density``: the mass
84
+ weighting every solver then uses.
85
+ coarse : Basis, optional
86
+ ``basis.coarse(n_theta, n_zeta)``: also return the coarse level of
87
+ ``eigensolver="jd"``, the equilibrium on its nodes as
88
+ :meth:`~agnimhd.Basis.coarse_level` packs it for
89
+ ``growth_rate(..., coarse=coarse)``.
90
+
91
+ Returns
92
+ -------
93
+ eq_data : EquilibriumData
94
+ diffmat : DiffMat
95
+ Differentiation matrices on exactly the nodes the geometry was
96
+ evaluated at. Use these two together.
97
+ coarse : tuple, only if ``coarse`` is given
98
+ """
99
+ from desc.grid import Grid
100
+
101
+ eq = desc_equilibrium(eq)
102
+ n_rho, n_theta, n_zeta = basis.n_rho, basis.n_theta, basis.n_zeta
103
+ nodes, diffmat = basis.nodes_and_diffmat(eq.NFP, family)
104
+ rho, theta, zeta = (np.asarray(nodes[k]) for k in ("rho", "theta", "zeta"))
105
+ R, T, Z = np.meshgrid(rho, theta, zeta, indexing="ij") # rho-major
106
+ pest = np.stack([R.ravel(), T.ravel(), Z.ravel()], axis=-1)
107
+ rtz = eq.map_coordinates(
108
+ pest,
109
+ inbasis=("rho", "theta_PEST", "zeta"),
110
+ outbasis=("rho", "theta", "zeta"),
111
+ period=(np.inf, 2 * np.pi, np.inf),
112
+ tol=1e-12,
113
+ maxiter=50,
114
+ )
115
+ keys = list(KEY_MAP) + ["a"] + (["ni"] if density else [])
116
+ data = eq.compute(keys, grid=Grid(rtz))
117
+ n = n_rho * n_theta * n_zeta
118
+ fields = {
119
+ dst: np.asarray(data[src]).reshape(n, -1).squeeze()
120
+ for src, dst in KEY_MAP.items()
121
+ }
122
+ if density:
123
+ ni = np.asarray(data["ni"]).reshape(-1)
124
+ ok = np.isfinite(ni).any() and np.nanmax(ni) > 0
125
+ ni = np.nan_to_num(ni / np.nanmax(ni), nan=1.0) if ok else np.ones_like(ni)
126
+ fields["density"] = ni
127
+ eq_data = EquilibriumData(
128
+ n_rho=n_rho,
129
+ n_theta=n_theta,
130
+ n_zeta=n_zeta,
131
+ NFP=int(eq.NFP),
132
+ Psi=float(np.asarray(eq.Psi)),
133
+ a=float(np.asarray(data["a"]).reshape(-1)[0]),
134
+ **fields,
135
+ )
136
+ if coarse is None:
137
+ return eq_data, diffmat
138
+ eq_coarse = from_desc(eq, coarse, density=density)[0]
139
+ return eq_data, diffmat, basis.coarse_level(eq_coarse, family)
@@ -0,0 +1,355 @@
1
+ """``AgniStability``: agnimhd's growth rate as a DESC optimization objective.
2
+
3
+ Imports DESC at module import, so ``import agnimhd`` never loads this file; use
4
+ ``from agnimhd.adapters.desc_objective import AgniStability``. Inside DESC's
5
+ optimizer (e.g. ``ProximalProjection``) the equilibrium is re-solved every step
6
+ and the chain rule through force balance gives d(gamma^2)/d(boundary, profiles).
7
+ """
8
+
9
+ import jax
10
+ import numpy as np
11
+ from desc.backend import jnp
12
+ from desc.compute.utils import _compute as compute_fun
13
+ from desc.compute.utils import get_profiles, get_transforms
14
+ from desc.grid import Grid, LinearGrid, QuadratureGrid
15
+ from desc.objectives.objective_funs import _Objective
16
+ from jax.experimental import io_callback
17
+
18
+ from ..assemble import keep_indices, operator_dtype
19
+ from ..backend import errorif
20
+ from ..config import AssemblyConfig, SolverConfig
21
+ from ..equilibrium import EquilibriumData
22
+ from ..objective import growth_rate_of
23
+ from .desc import KEY_MAP
24
+
25
+ __all__ = ["AgniStability"]
26
+
27
+ #: Flux functions: computed on a LinearGrid at the PEST rho values (it has the
28
+ #: quadrature weights the PEST grid lacks) and copied onto the PEST nodes.
29
+ FLUX_KEYS = [
30
+ "iota",
31
+ "iota_r",
32
+ "iota_den",
33
+ "iota_den_r",
34
+ "iota_num",
35
+ "iota_num_r",
36
+ "iota_num current",
37
+ "iota_num_r current",
38
+ "iota_num vacuum",
39
+ "iota_num_r vacuum",
40
+ "psi_r",
41
+ "psi_rr",
42
+ "p",
43
+ "p_r",
44
+ ]
45
+
46
+
47
+ class _WarmStart:
48
+ """The last converged eigenvector, on the host, between DESC's calls.
49
+
50
+ ``read`` and ``write`` are called from inside DESC's jitted compute through
51
+ ``io_callback``: the read feeds the solve and the write needs its result,
52
+ so within a call they run before and after it, and between calls the
53
+ device finishes one program, callbacks included, before the next starts.
54
+ A vector that is not finite is not kept.
55
+ """
56
+
57
+ def __init__(self, n, dtype):
58
+ self.vector_spec = jax.ShapeDtypeStruct((n,), dtype)
59
+ self.v = np.zeros(n, dtype)
60
+ self.valid = False
61
+ self.gamma2 = 0.0
62
+ self.reads = self.hits = 0 # calls, and calls that started from a kept vector
63
+
64
+ def __deepcopy__(self, memo):
65
+ """DESC's optimizers copy the objective; the copies share this cache, so
66
+ the original sees the solves made through them and a copy does not
67
+ recompile (the store is static to DESC's jit, by identity)."""
68
+ return self
69
+
70
+ def read(self):
71
+ """``((vector, valid), gamma^2)`` as traced arrays."""
72
+ flag = jax.ShapeDtypeStruct((), bool)
73
+ g = jax.ShapeDtypeStruct((), np.float64)
74
+ v, valid, gamma2 = io_callback(self._read, (self.vector_spec, flag, g))
75
+ return (v, valid), gamma2
76
+
77
+ def _read(self):
78
+ self.reads += 1
79
+ self.hits += self.valid
80
+ return self.v, np.bool_(self.valid), np.float64(self.gamma2)
81
+
82
+ def write(self, v, gamma2):
83
+ """Keep the converged eigenvector and its ``gamma^2`` for the next call."""
84
+ io_callback(self._write, None, v, gamma2)
85
+
86
+ def _write(self, v, gamma2):
87
+ v = np.asarray(v)
88
+ if np.isfinite(v).all() and np.isfinite(gamma2):
89
+ self.v, self.gamma2, self.valid = v, float(gamma2), True
90
+
91
+
92
+ class AgniStability(_Objective):
93
+ """Finite-n ideal MHD squared growth rate ``gamma^2 = -lambda`` from agnimhd.
94
+
95
+ Positive means unstable; the default target is 0. The PEST nodes are
96
+ mapped to DESC's ``theta`` at the current parameters on every call, so the
97
+ objective and its gradient follow the equilibrium as it moves. ``a`` is
98
+ computed on a ``QuadratureGrid``. With ``eigensolver="jd"`` the equilibrium
99
+ is also evaluated on the coarse level's nodes at every call, and that level
100
+ seeds and deflates the solve; no gradient flows through it. With
101
+ ``density=True`` the kinetic energy is weighted by DESC's ``ni`` normalized
102
+ to its maximum, as ``from_desc(..., density=True)``, on both levels.
103
+ """
104
+
105
+ _coordinates = ""
106
+ _units = "(dimensionless)"
107
+ _print_value_fmt = "finite-n gamma^2 (agnimhd): "
108
+ _static_attrs = _Objective._static_attrs + [
109
+ "_basis",
110
+ "_family",
111
+ "_assembly",
112
+ "_solver",
113
+ "_coarse_basis",
114
+ "_density",
115
+ "_warm_start",
116
+ "_sigma_factor",
117
+ "_sigma_floor",
118
+ "_warm", # the host-side store: static to DESC's jit, by identity
119
+ ]
120
+
121
+ def __init__(
122
+ self,
123
+ eq,
124
+ basis,
125
+ family=0,
126
+ assembly=None,
127
+ solver=None,
128
+ coarse=None,
129
+ density=False,
130
+ warm_start=False,
131
+ sigma_factor=None,
132
+ sigma_floor=1e-7,
133
+ target=None,
134
+ bounds=None,
135
+ weight=1.0,
136
+ name="agni finite-n",
137
+ ):
138
+ """Store the choices; nodes and DESC transforms are made in :meth:`build`.
139
+
140
+ Parameters
141
+ ----------
142
+ eq : desc.equilibrium.Equilibrium
143
+ basis : agnimhd.Basis
144
+ family : int
145
+ Toroidal mode family ``n = family + k NFP`` whose ``gamma^2`` is
146
+ returned (:meth:`agnimhd.Basis.families`); one objective per family.
147
+ assembly, solver : AssemblyConfig, SolverConfig, optional
148
+ coarse : agnimhd.Basis, optional
149
+ ``eigensolver="jd"`` only: the coarse level,
150
+ ``basis.coarse(n_theta, n_zeta)``; default ``basis.coarse()``.
151
+ density : bool
152
+ Weight the kinetic energy with ``ni / max(ni)``; needs
153
+ ``eq.electron_density``.
154
+ warm_start : bool
155
+ ``eigensolver="jd"`` only: start each solve from the previous
156
+ call's converged eigenvector, kept on the host. The solve still
157
+ runs to ``jd_tol`` at every call, so the value and the derivative
158
+ are those of a converged vector; at the same point (DESC's
159
+ Jacobian after its value) the start is already converged and JD
160
+ stops within a round or two. The first call starts from the coarse
161
+ level as usual.
162
+ sigma_factor : float, optional
163
+ Needs ``warm_start``. Adapt the shift of the JD solve: after the
164
+ first call, ``sigma = sigma_factor * gamma^2`` of the previous call
165
+ (at least ``sigma_floor``) instead of the fixed ``solver.sigma``. JD
166
+ needs fewer rounds the closer the shift is to ``gamma^2``: measured at
167
+ ``gamma^2 = 3.8e-6``, 894 rounds at ``sigma = 1e-3``, 295 at 3e-5.
168
+ The shift must stay above the largest ``gamma^2``; a solve whose
169
+ ``gamma^2`` ends within a factor 1.3 of its shift (the optimizer's
170
+ trial points can raise ``gamma^2`` by a factor 3 or more) is redone at
171
+ ``solver.sigma``. ``2`` is a good factor.
172
+ sigma_floor : float
173
+ Lower bound of the adapted shift.
174
+ target, bounds, weight, name
175
+ As for every DESC objective; the default target is 0.
176
+ """
177
+ if target is None and bounds is None:
178
+ target = 0.0
179
+ self._basis = basis
180
+ self._family = int(family)
181
+ self._assembly = assembly or AssemblyConfig()
182
+ self._solver = solver or SolverConfig()
183
+ self._density = bool(density)
184
+ errorif(
185
+ self._density and eq.electron_density is None,
186
+ ValueError,
187
+ "density=True needs a density profile, eq.electron_density.",
188
+ )
189
+ self._warm_start = bool(warm_start)
190
+ errorif(
191
+ self._warm_start and self._solver.eigensolver != "jd",
192
+ ValueError,
193
+ 'warm_start=True needs eigensolver="jd" (the dense solvers factor '
194
+ "the matrix and gain nothing from a start vector).",
195
+ )
196
+ errorif(
197
+ sigma_factor is not None and not self._warm_start,
198
+ ValueError,
199
+ "sigma_factor needs warm_start=True (it uses the kept gamma^2)",
200
+ )
201
+ errorif(
202
+ sigma_factor is not None and sigma_factor <= 1,
203
+ ValueError,
204
+ "sigma_factor must exceed 1: the shift lies above gamma^2",
205
+ )
206
+ self._sigma_factor = sigma_factor
207
+ self._sigma_floor = float(sigma_floor)
208
+ self._warm = None
209
+ self._coarse_basis = None
210
+ if self._solver.eigensolver == "jd":
211
+ coarse = basis.coarse() if coarse is None else coarse
212
+ errorif(
213
+ coarse != basis.coarse(coarse.n_theta, coarse.n_zeta),
214
+ ValueError,
215
+ "coarse must be basis.coarse(n_theta, n_zeta): the same radial "
216
+ "nodes, map, mpol and ntor on fewer angular nodes.",
217
+ )
218
+ self._coarse_basis = coarse
219
+ super().__init__(
220
+ things=eq,
221
+ target=target,
222
+ bounds=bounds,
223
+ weight=weight,
224
+ normalize=False,
225
+ normalize_target=False,
226
+ name=name,
227
+ )
228
+
229
+ def build(self, use_jit=True, verbose=1):
230
+ """Fixed PEST nodes, DiffMat and DESC transforms (and the coarse level's)."""
231
+ eq = self.things[0]
232
+ rho, diffmat, nodes = self._pest_nodes(self._basis)
233
+ flux_grid = LinearGrid(rho=rho, M=eq.M_grid, N=eq.N_grid, NFP=eq.NFP)
234
+ quad = QuadratureGrid(eq.L_grid, eq.M_grid, eq.N_grid, eq.NFP)
235
+ self._dim_f = 1
236
+ self._constants = {
237
+ "diffmat": diffmat,
238
+ "nodes": nodes,
239
+ "flux_grid": flux_grid,
240
+ "flux_transforms": get_transforms(FLUX_KEYS, obj=eq, grid=flux_grid),
241
+ "flux_profiles": get_profiles(FLUX_KEYS, obj=eq, grid=flux_grid),
242
+ "a_transforms": get_transforms(["a"], obj=eq, grid=quad),
243
+ "a_profiles": get_profiles(["a"], obj=eq, grid=quad),
244
+ "quad_weights": 1.0, # scalar objective: no grid weights
245
+ }
246
+ if self._coarse_basis is not None: # same radial nodes: same flux grid
247
+ c = self._constants
248
+ c["coarse_nodes"] = self._pest_nodes(self._coarse_basis)[2]
249
+ eq_c = self._equilibrium_data(eq.params_dict, c, coarse=True)
250
+ _, c["coarse_diffmat"], c["coarse_transfer"] = self._basis.coarse_level(
251
+ eq_c, self._family
252
+ )
253
+ if self._warm_start and self._warm is None: # a rebuilt copy keeps the store
254
+ b = self._basis
255
+ n = int(keep_indices(b.n_rho, b.n_theta, b.n_zeta).size)
256
+ self._warm = _WarmStart(n, operator_dtype(self._assembly, diffmat))
257
+ super().build(use_jit=use_jit, verbose=verbose)
258
+
259
+ def _pest_nodes(self, basis):
260
+ """``(rho, diffmat, nodes)``: ``basis``' PEST nodes, rho-major, with
261
+ DESC's unique-rho index maps."""
262
+ eq = self.things[0]
263
+ nodes, diffmat = basis.nodes_and_diffmat(eq.NFP, self._family)
264
+ rho, theta, zeta = (np.asarray(nodes[k]) for k in ("rho", "theta", "zeta"))
265
+ R, T, Z = np.meshgrid(rho, theta, zeta, indexing="ij")
266
+ pest = np.stack([R.ravel(), T.ravel(), Z.ravel()], axis=-1)
267
+ _, u_idx, i_idx = np.unique(pest[:, 0], return_index=True, return_inverse=True)
268
+ nodes = {"pest": pest, "u_idx": u_idx, "i_idx": i_idx}
269
+ return rho, diffmat, {k: jnp.asarray(v) for k, v in nodes.items()}
270
+
271
+ def compute(self, params, constants=None):
272
+ """``gamma^2`` at ``params``, differentiable with respect to them."""
273
+ c = constants or self._constants
274
+ coarse = None
275
+ if self._coarse_basis is not None:
276
+ eq_c = self._equilibrium_data(params, c, coarse=True)
277
+ coarse = (eq_c, c["coarse_diffmat"], c["coarse_transfer"])
278
+ warm, guess, sigma = self._warm, None, None
279
+ if warm is not None:
280
+ guess, last = warm.read()
281
+ if self._sigma_factor is not None: # the first call: the fixed sigma
282
+ kept = guess[1] & (last > 0)
283
+ adapted = jnp.maximum(self._sigma_factor * last, self._sigma_floor)
284
+ sigma = jnp.where(kept, adapted, self._solver.sigma)
285
+ gamma2 = growth_rate_of(
286
+ params,
287
+ lambda p: self._equilibrium_data(p, c),
288
+ c["diffmat"],
289
+ self._assembly,
290
+ self._solver,
291
+ v_guess=guess,
292
+ coarse=coarse,
293
+ on_vector=None if warm is None else warm.write,
294
+ sigma=sigma,
295
+ )
296
+ return jnp.atleast_1d(gamma2)
297
+
298
+ def _equilibrium_data(self, params, c, coarse=False):
299
+ """``params -> EquilibriumData`` through DESC's compute functions, on
300
+ the PEST nodes of the basis or, with ``coarse``, of the coarse level."""
301
+ eq = self.things[0]
302
+ nodes = c["coarse_nodes"] if coarse else c["nodes"]
303
+ rtz = eq.map_coordinates(
304
+ nodes["pest"],
305
+ inbasis=("rho", "theta_PEST", "zeta"),
306
+ outbasis=("rho", "theta", "zeta"),
307
+ period=(jnp.inf, 2 * jnp.pi, jnp.inf),
308
+ tol=1e-12,
309
+ maxiter=50,
310
+ params=params,
311
+ )
312
+ grid = Grid(
313
+ rtz,
314
+ coordinates="rtz",
315
+ sort=False,
316
+ jitable=True,
317
+ _unique_rho_idx=nodes["u_idx"],
318
+ _inverse_rho_idx=nodes["i_idx"],
319
+ )
320
+ a = compute_fun(
321
+ eq,
322
+ ["a"],
323
+ params=params,
324
+ transforms=c["a_transforms"],
325
+ profiles=c["a_profiles"],
326
+ )["a"]
327
+ flux = compute_fun(
328
+ eq,
329
+ FLUX_KEYS,
330
+ params=params,
331
+ transforms=c["flux_transforms"],
332
+ profiles=c["flux_profiles"],
333
+ data={"a": a},
334
+ )
335
+ data = {"a": a}
336
+ for k in FLUX_KEYS:
337
+ data[k] = grid.copy_data_from_other(
338
+ flux[k], c["flux_grid"], surface_label="rho"
339
+ )
340
+ keys = list(KEY_MAP) + ["ni"] * self._density
341
+ data = eq.compute(
342
+ keys, grid=grid, params=params, data=data, override_grid=False
343
+ )
344
+ basis = self._coarse_basis if coarse else self._basis
345
+ return EquilibriumData(
346
+ n_rho=basis.n_rho,
347
+ n_theta=basis.n_theta,
348
+ n_zeta=basis.n_zeta,
349
+ NFP=eq.NFP,
350
+ Psi=params["Psi"],
351
+ a=a,
352
+ density=data["ni"] / jnp.max(data["ni"]) if self._density else None,
353
+ validate=False,
354
+ **{v: data[k] for k, v in KEY_MAP.items()},
355
+ )