"""Classes for tracing magnetic field lines."""
import warnings
import xarray as xr
import numpy as np
from typing import Union, List, Callable, Sequence
from scipy.integrate import solve_ivp
from scipy.integrate._ivp.ivp import OdeResult
from scipy.interpolate import InterpolatedUnivariateSpline
from .vector_field_tracer_m import VectorFieldTracer
from torx.decorators import autodoc_class, autodoc_function
from torx.arrays import make_xarray
[docs]
@autodoc_class
class MagneticFieldTracer(VectorFieldTracer):
"""
Allows to trace along a magnetic fieldline.
Toroidal angle is denoted as phi. Poloidal angle as theta.
"""
[docs]
def __init__(self, equi, max_flux_surface_wander: float=1e-3):
"""Initialize the magnetic field tracer."""
# Only for NumericalEquilibrium the init of the parent class can be
# called with a the discrete magnetic vector field
if type(equi).__name__ == "NumericalEquilibrium":
r_norm, z_norm = equi.spline_basis_r, equi.spline_basis_z
vec_field = equi.magfield_vector(r_norm, z_norm)
super().__init__(vec_field)
self.equi = equi
self.max_flux_surface_wander = max_flux_surface_wander
[docs]
def toroidal_integration(
self,
r_initial: float,
z_initial: float,
phi_initial: float,
phi_to_max: float,
tolerance: float=1e-4,
method: str="DOP853",
direction: str="fwd",
check_out_of_domain: bool=False,
check_out_of_bounds: bool=False,
events: Union[List[Callable], Callable]=None,
**integrator_kwargs
) -> OdeResult:
"""
Perform an integration with phi as the independent variable.
Returns the (x,y) positions of the trace, as well as the fieldline
length.
"""
self.direction = direction
self._check_initial_state(r_initial, z_initial, phi_initial)
# Handle events and check format of the provided events
if check_out_of_domain:
events = self._add_event(events, self._out_of_domain)
if check_out_of_bounds:
events = self._add_event(events, self._out_of_bounds)
solution = solve_ivp(
fun=self.toroidal_integration_equation,
t_span=[phi_initial, phi_initial + self._dir_fac * phi_to_max],
y0=np.array([r_initial, z_initial, 0.0]),
events=events,
dense_output=True,
rtol=tolerance,
atol=tolerance,
method=method,
**integrator_kwargs,
)
if not np.isnan(self.max_flux_surface_wander):
self.check_flux_surface_wander(solution)
return solution
[docs]
def toroidal_integration_equation(self, phi, state):
"""
Equation that is required to integrate toroidally around the torus.
The fieldline length is returned as the third element of the state
vector.
"""
r_norm = state[0]
z_norm = state[1]
b_r, b_z, b_t, jacobian = self.equi._magfield_scalars(r_norm,
z_norm, phi)
d_state = np.zeros_like(state)
d_state[0] = b_r / b_t * jacobian
d_state[1] = b_z / b_t * jacobian
d_state[2] = (
np.sqrt(b_r * b_r / (b_t * b_t) + b_z * b_z / (b_t * b_t) + 1.0) \
* jacobian
)
return d_state
[docs]
def poloidal_integration(
self,
r_initial: float,
z_initial: float,
theta_max: float,
tolerance: float=1e-4,
method: str="DOP853",
check_out_of_domain: bool=False,
check_out_of_bounds: bool=False,
events: Union[List[Callable], Callable]=None,
**integrator_kwargs,
) -> OdeResult:
"""
Perform an integration with theta as the independent variable.
Valid only for the confined region.
Returns the (x,y) positions of the trace, as well as the fieldline
length.
"""
self._check_initial_state(r_initial, z_initial, 0.0)
# Handle events and check format of the provided events
if check_out_of_domain:
events = self._add_event(events, self._out_of_domain)
if check_out_of_bounds:
events = self._add_event(events, self._out_of_bounds)
# Make sure we are in the closed field line region
rho_initial = self.equi.normalized_flux_surface_label(r_initial,
z_initial).values
if rho_initial >= 1.0:
raise RuntimeError(
"Cannot perform poloidal integration for open fieldlines." \
+f"x={r_initial}, y= {z_initial}, rho={rho_initial}"
)
solution = solve_ivp(
fun=self.poloidal_integration_equation,
t_span=[0.0, theta_max],
y0=np.array([r_initial, z_initial, 0.0]),
events=events,
dense_output=True,
rtol=tolerance,
atol=tolerance,
method=method,
**integrator_kwargs,
)
self.check_flux_surface_wander(solution)
return solution
[docs]
def poloidal_integration_equation(self, _, state):
"""
Equation that is required to integrate poloidally around the torus.
The fieldline length is returned as the third element of the state
vector.
"""
r_norm = state[0]
z_norm = state[1]
b_r = self.equi.magfield_component_r(r_norm, z_norm)
b_z = self.equi.magfield_component_z(r_norm, z_norm)
theta = self.theta(r_norm, z_norm)
radius = self.minor_radius(r_norm, z_norm)
# Magnetic field component along the integration contour (theta)
b_along_fs = -b_r * np.sin(theta) + b_z * np.cos(theta)
# First check magnetic field obtained
b_pol = np.sqrt(b_r**2 + b_z**2)
# Magnetic field component across the integration contour (radial)
b_across_fs = b_r * np.cos(theta) + b_z * np.sin(theta)
b_pol_check = np.sqrt(b_across_fs**2 + b_along_fs**2)
assert np.isclose(b_pol_check / b_pol, 1.0
), f"Poloidal field on curve was {b_pol_check}, should be {b_pol}!"
# After check passed, calculate increments
d_state = np.zeros_like(state)
# The increments dR and dZ are given by simply integrating the
# equations for the magnetic field line (4.1.6) in [1] on
# the poloidal curve.
# [1] D'haeseleer, Flux coordinates and magnetic field structure
# NOTE: The poloidal line element is r*dtheta.
d_state[0] = radius * b_r / np.abs(b_along_fs)
d_state[1] = radius * b_z / np.abs(b_along_fs)
# The increment in symmetry angle is given by eq. (24) in [2].
# [2] Ribeiro, Conformal Tokamak Geometry for Turbulence Computations
# NOTE: Here we return the product q*dtheta_s. Since the contravariant
# component of the poloidal field is used, a factor 1/r enters the
# denominator.
# NOTE: The current I used in the formula is I = B_tor * R = B_tor_axis
# since we set B_tor = B_tor_axis / R per definition. We use the
# call to the toroidal field with axis coordinates since this
# returns the field with the same normalization as used above.
current = self.equi.magfield_component_toroidal(self.equi.axis_r_norm,
self.equi.axis_z_norm)
d_state[2] = current * np.abs(radius / b_along_fs / r_norm**2)
return d_state
[docs]
def check_flux_surface_wander(self, solution):
"""
Check the maximum deviation from the flux-surface during trace.
Throws an error if it exceeds the threshold.
"""
rho_initial = self.equi.normalized_flux_surface_label(
solution.y[0][0], solution.y[1][0]
).values
dense_output = solution.sol(
np.linspace(np.min(solution.t), np.max(solution.t), 1000)
)
rho_trace = self.equi.normalized_flux_surface_label(
dense_output[0], dense_output[1],
).values
flux_surface_wander = np.abs(rho_trace - rho_initial)
assert (
np.max(flux_surface_wander) < self.max_flux_surface_wander
), (
f"Flux surface wander exceeded threshold "
f"({np.max(flux_surface_wander):3.2e}"
f" > {self.max_flux_surface_wander:3.2e}) "
"Try decreasing the tolerance of the integrator, "
"or increasing the max_flux_surface_wander"
)
[docs]
def find_neighboring_points(
self, r_initial, z_initial, n_toroidal_planes: int=16
):
"""
Find the neighbors in both directions of an array of sample points.
Although this loop should be trivially parallel, dask parallelism
doesn't seem to work here.
"""
r_initial = np.atleast_1d(r_initial)
z_initial = np.atleast_1d(z_initial)
assert r_initial.shape == z_initial.shape
assert (
r_initial.ndim == 1 and z_initial.ndim == 1
), f"Should provide r and z as 1D arrays"
forward_trace = np.zeros((r_initial.size, 3))
reverse_trace = np.zeros((r_initial.size, 3))
print("Tracing", end=" ")
for i in range(r_initial.size):
print(f"{i}/{r_initial.size}", end=", ")
r_in, z_in = r_initial[i], z_initial[i]
forward_trace[i, :] = self.toroidal_integration(
r_in,
z_in,
+2.0 * np.pi / n_toroidal_planes,
).y[:, -1]
reverse_trace[i, :] = self.toroidal_integration(
r_in,
z_in,
-2.0 * np.pi / n_toroidal_planes,
).y[:, -1]
print("Done")
return forward_trace, reverse_trace
[docs]
def trace_reverse(self, r_initial, z_initial, n_phi_total, phi_initial = 0,
n_phi=1):
"""
Trace field lines back for a given amount of toroidal planes.
Starts at r,z initial and returns the new points r,z reverse.
"""
r_reverse = np.zeros(r_initial.size)
z_reverse = np.zeros(r_initial.size)
for i in range(r_initial.size):
r_in, z_in = r_initial[i], z_initial[i]
sol = self.toroidal_integration(
r_in,
z_in,
phi_initial,
(-2.0 * np.pi / n_phi_total) * n_phi,
)
r_reverse[i] = sol.y[0, -1]
z_reverse[i] = sol.y[1, -1]
return r_reverse, z_reverse
[docs]
def minor_radius(self, r_norm, z_norm):
"""Minor radius defined as Cartesian distance to magnetic axis."""
return np.sqrt((r_norm - self.equi.axis_r_norm.values)**2 +
(z_norm - self.equi.axis_z_norm.values)**2)
[docs]
def theta(self, r_norm, z_norm):
"""
Poloidal angle from the magnetic axis.
Note
----
The zero point is the outboard midplane, and theta ranges
from [-pi, pi].
"""
return np.arctan2(z_norm - self.equi.axis_z_norm.values,
r_norm - self.equi.axis_r_norm.values)
def _out_of_bounds(self, _, state):
"""
Check if integrator state is out of bounds.
Return 1 if the state of the integrator is inside the boundary
polygon, otherwise -1
"""
r, z = state[0], state[1]
boundary_polygon = self.equi.get_boundary_polygon()
return 1 if boundary_polygon.point_inside(r, z) else -1
_out_of_bounds.terminal = True
_out_of_bounds.direction = -1
@autodoc_class
class NormalTracer(MagneticFieldTracer):
"""Finds a line which is continuously normal to the poloidal field."""
def toroidal_integration_equation(self, _, state):
"""
Equation that is required to integrate toroidally around the torus.
The fieldline length is returned as the third element of the state
vector.
"""
r_norm, z_norm = state[0], state[1]
b_r, b_z, b_t, jacobian = (
self.equi.magfield_component_r(r_norm, z_norm),
self.equi.magfield_component_z(r_norm, z_norm),
self.equi.magfield_component_toroidal(r_norm, z_norm),
self.equi.jacobian(r_norm, z_norm),
)
d_state = np.zeros_like(state)
d_state[0] = -b_z / b_t * jacobian
d_state[1] = +b_r / b_t * jacobian
d_state[2] = 0
return d_state
def check_flux_surface_wander(self, solution):
"""
Check the maximum deviation from the flux-surface during trace.
Since we're perpendicularly tracing, we necessarily will go off the
flux surface.
"""
pass
[docs]
@autodoc_function
def polar_to_cart_chord(equi, rho, theta, max_radius=1.0, n_samples=1000):
"""Convert from polar to cartesian coordinates."""
xaxis = np.array(equi.axis_r_norm)
yaxis = np.array(equi.axis_z_norm)
radius = np.linspace(0, max_radius, n_samples)
r_norm = radius * np.cos(theta) + xaxis
z_norm = radius * np.sin(theta) + yaxis
rho_values = equi.normalized_flux_surface_label(r_norm, z_norm)
# NOTE: We manually set the first entry to zero due to possible very small
# inaccuracies that might lead to finite rho at axis
rho_values[0] = 0.0
xsol, ysol, rsol = [], [], []
for rhoval in np.atleast_1d(rho):
roots = InterpolatedUnivariateSpline(radius,
rho_values - rhoval).roots()
if len(roots) == 0:
print(f"rho value {rhoval} not found. Skipping")
continue
elif len(roots) > 1:
raise RuntimeError(
"Multiple roots found. Set max radius to ensure that rho is " \
"monotonic"
)
xsol.append(roots[0] * np.cos(theta) + xaxis)
ysol.append(roots[0] * np.sin(theta) + yaxis)
rsol.append(rhoval)
return np.array(xsol), np.array(ysol), np.array(rsol)
[docs]
def flare_fieldline_trace(
equi,
r_initial: float,
z_initial: float,
phi_initial: float=0.0,
direction: int=1,
step_size: float=0,
nsteps: int=16,
stop_at_boundary: bool=True,
coordinates: str="cylindrical",
angular_units: str="rad",
normalize: bool=True,
):
"""
Trace a magnetic field line using the FLARE field-line tracer.
This is a wrapper around ``flare.analysis.fieldline_trace`` with a more
convenient interface for the FLARE equilibrium. The initial point is
specified by ``(r_initial, z_initial, phi_initial)``.
If ``normalize=True`` (default), ``r_initial`` and ``z_initial`` are
assumed to be given as normalized coordinates and are converted to
the coordinates expected by FLARE (real space) before tracing.
The tracing direction is specified by ``direction``:
- ``+1`` for forward tracing
- ``-1`` for backward tracing
Parameters
----------
equi : FlareEquilibrium
Equilibrium providing the FLARE backend and the normalization used
to convert between normalized and real-space coordinates.
r_initial : float
Initial radial coordinate of the field line.
z_initial : float
Initial vertical coordinate of the field line.
phi_initial : float, default=0.0
Initial toroidal angle in radians.
direction : int, default=1
Tracing direction: ``+1`` forward, ``-1`` backward.
step_size : float, default=0
Integration step size forwarded to the FLARE backend.
nsteps : int, default=16
Number of integration steps.
stop_at_boundary : bool, default=True
Whether to stop the trace when it reaches the boundary.
coordinates : str, default="cylindrical"
Coordinate system forwarded to the FLARE backend.
angular_units : str, default="rad"
Angular units forwarded to the FLARE backend.
normalize : bool, default=True
If True, treat the initial point as normalized coordinates and
return the traced coordinates in normalized units.
Returns
-------
tuple[numpy.ndarray, numpy.ndarray, int]
The traced coordinates ``(R, Z, phi)``, the field-line length, and
the FLARE exit code.
Note
----
This function forwards most tracing options directly to the FLARE
backend. See the FLARE documentation for the detailed meaning of
``step_size``, ``coordinates``, ``angular_units``, and the returned
``exit_code``.
"""
from torx.specializations.flare import import_flare
analysis = import_flare()[2]
if normalize:
r_initial, z_initial = equi._denormalize_flare(r_initial, z_initial)
x0 = np.array([r_initial, z_initial, phi_initial], dtype=float)
coords, fieldline_length, exit_code = analysis.fieldline_trace(
x0,
idir=direction,
ds=step_size,
nsteps=nsteps,
stop_at_boundary=stop_at_boundary,
coordinates=coordinates,
angular_units=angular_units,
)
if normalize:
coords[0], coords[1] = equi._normalize_flare(coords[0], coords[1])
return coords, fieldline_length, exit_code
def _bidirectional_toroidal_tracer_flare(
equi,
r_initial: float,
z_initial: float,
phi_initial: float,
n_turns: int,
n_planes: int
):
"""
Trace a single field line using the FLARE backend.
Returns
-------
tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, int, int]
Arrays `(R, Z, phi, s)` of shape `(2 * n_turns * n_planes + 1,)`,
followed by backward/forward status codes.
"""
n_steps_per_direction = n_turns * n_planes
dphi = 2.0 * np.pi / n_planes
xyz_bwd, s_bwd, istat_bwd = flare_fieldline_trace(
equi,
r_initial=r_initial,
z_initial=z_initial,
phi_initial=phi_initial,
direction=-1,
step_size=dphi,
nsteps=n_steps_per_direction,
coordinates="cylindrical",
angular_units="rad",
)
xyz_fwd, s_fwd, istat_fwd = flare_fieldline_trace(
equi,
r_initial=r_initial,
z_initial=z_initial,
phi_initial=phi_initial,
direction=1,
step_size=dphi,
nsteps=n_steps_per_direction,
coordinates="cylindrical",
angular_units="rad",
)
R_bwd, Z_bwd, phi_bwd = xyz_bwd
R_fwd, Z_fwd, phi_fwd = xyz_fwd
# Reverse backward branch so the full trace runs from
# phi_min -> phi_initial -> phi_max.
R_bwd = R_bwd[::-1]
Z_bwd = Z_bwd[::-1]
phi_bwd = phi_bwd[::-1]
s_bwd = s_bwd[::-1]
# Re-center arc length so the initial point has s = 0.
s_bwd = s_bwd - s_bwd[-1]
s_fwd = s_fwd - s_fwd[0]
# Combine both branches, skipping duplicate initial point
R = np.concatenate([R_bwd, R_fwd[1:]])
Z = np.concatenate([Z_bwd, Z_fwd[1:]])
phi = np.concatenate([phi_bwd, phi_fwd[1:]])
s = np.concatenate([s_bwd, s_fwd[1:]])
return R, Z, phi, s, istat_bwd, istat_fwd
def _bidirectional_toroidal_tracer(
tracer,
r_initial: float,
z_initial: float,
phi_initial: float,
n_turns: int,
n_planes: int,
):
"""
Trace a single field line using the standard MagneticFieldTracer backend.
Returns
-------
tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, bool, str | None]
Arrays `(R, Z, phi, s)`, a success flag, and an error message.
"""
n_steps = n_turns * n_planes
dphi = 2.0 * np.pi / n_planes
t_eval_fwd = phi_initial + dphi * np.arange(1, n_steps + 1)
t_eval_bwd = phi_initial - dphi * np.arange(1, n_steps + 1)
t_eval_fwd[-1] = phi_initial + n_turns * 2.0 * np.pi
t_eval_bwd[-1] = phi_initial - n_turns * 2.0 * np.pi
y0 = np.array([r_initial, z_initial, 0.0])
needs_dense = not np.isnan(tracer.max_flux_surface_wander)
# Stop early if R leaves the physically valid range.
def r_nonpositive(_, state):
return state[0]
r_nonpositive.terminal = True
r_nonpositive.direction = -1
r_escape_threshold = 100.0 * max(abs(r_initial), 1.0)
def r_escaped(_, state):
return r_escape_threshold - state[0]
r_escaped.terminal = True
r_escaped.direction = -1
ivp_kw = dict(
rtol=1e-4, atol=1e-4, method="DOP853",
dense_output=needs_dense,
events=[r_nonpositive, r_escaped],
)
phi = np.concatenate([t_eval_bwd[::-1], [phi_initial], t_eval_fwd])
n_total = 2 * n_steps + 1
result_fwd = solve_ivp(
fun=tracer.toroidal_integration_equation,
t_span=[phi_initial, phi_initial + n_turns * 2.0 * np.pi],
y0=y0,
t_eval=t_eval_fwd,
**ivp_kw,
)
if result_fwd.status != 0:
message = (
"Terminated: R left valid range" if result_fwd.status == 1
else result_fwd.message
)
return (
np.full(n_total, np.nan),
np.full(n_total, np.nan),
phi,
np.full(n_total, np.nan),
False,
message,
)
result_bwd = solve_ivp(
fun=tracer.toroidal_integration_equation,
t_span=[phi_initial, phi_initial - n_turns * 2.0 * np.pi],
y0=y0,
t_eval=t_eval_bwd,
**ivp_kw,
)
if result_bwd.status != 0:
message = (
"Terminated: R left valid range" if result_bwd.status == 1
else result_bwd.message
)
return (
np.full(n_total, np.nan),
np.full(n_total, np.nan),
phi,
np.full(n_total, np.nan),
False,
message,
)
if needs_dense:
tracer.check_flux_surface_wander(result_fwd)
tracer.check_flux_surface_wander(result_bwd)
# Backward branch is reversed so index 0 is furthest back. Arc length is
# negative (phi decreasing), so negate to get positive distances from start.
R = np.concatenate([result_bwd.y[0, ::-1], [r_initial], result_fwd.y[0, :]])
Z = np.concatenate([result_bwd.y[1, ::-1], [z_initial], result_fwd.y[1, :]])
s = np.concatenate([-result_bwd.y[2, ::-1], [0.0], result_fwd.y[2, :]])
return R, Z, phi, s, True, None
[docs]
def bidirectional_toroidal_trace(
equi,
r_initial: float | Sequence[float],
z_initial: float | Sequence[float],
phi_initial: float,
n_turns: int = 2,
n_planes: int = 50,
max_flux_surface_wander: float = np.nan,
verbosity: int = 0,
) -> tuple[xr.DataArray, xr.DataArray, xr.DataArray, xr.DataArray]:
"""
Trace magnetic field lines in both toroidal directions.
For each initial point `(r_initial, z_initial)` at toroidal angle
`phi_initial`, the field line is traced backward and forward for
`n_turns` full toroidal turns. Sampling is performed at `n_planes`
equally spaced toroidal planes per turn.
If `equi` is a FlareEquilibrium instance, the FLARE backend is used.
Otherwise the standard `MagneticFieldTracer` backend is used.
Parameters
----------
equi
Equilibrium object.
r_initial : float or Sequence[float]
Initial radial coordinate(s).
z_initial : float or Sequence[float]
Initial vertical coordinate(s). Must have the same length as
`r_initial`.
phi_initial : float
Initial toroidal angle in radians.
n_turns : int, default=2
Number of full toroidal turns to trace in each direction.
n_planes : int, default=50
Number of sampling planes per turn.
max_flux_surface_wander : float, default is NaN
Passed to `MagneticFieldTracer` for non-FLARE equilibria.
verbosity : int, default=0
Verbosity level.
Returns
-------
tuple[
xarray.DataArray, xarray.DataArray,
xarray.DataArray, xarray.DataArray,
]
`R`, `Z`, `phi`, and `s`, each with dimensions `("line", "plane")`.
The `s` array is the field-line length centered such that the initial
point has value zero, with negative values on the backward branch and
positive values on the forward branch.
The returned `phi` array is in radians.
"""
from torx.equilibrium import FlareEquilibrium
n_turns = int(n_turns)
n_planes = int(n_planes)
if n_turns < 1:
raise ValueError("n_turns must be >= 1")
if n_planes < 1:
raise ValueError("n_planes must be >= 1")
r_initial = np.atleast_1d(np.asarray(r_initial, dtype=float))
z_initial = np.atleast_1d(np.asarray(z_initial, dtype=float))
if len(r_initial) != len(z_initial):
raise ValueError("r_initial and z_initial must have the same length")
n_field_lines = len(r_initial)
n_steps_total = 2 * n_turns * n_planes
shape = (n_field_lines, n_steps_total + 1)
use_flare = isinstance(equi, FlareEquilibrium)
backend = "FLARE" if use_flare else "standard"
R_data = np.full(shape, np.nan)
Z_data = np.full(shape, np.nan)
phi_data = np.full(shape, np.nan)
s_data = np.full(shape, np.nan)
tracer = None
if not use_flare:
tracer = MagneticFieldTracer(
equi,
max_flux_surface_wander=max_flux_surface_wander,
)
for l in range(n_field_lines):
if verbosity >= 1:
print(f"field line {l + 1} / {n_field_lines}")
if use_flare:
R, Z, phi, s, istat_bwd, istat_fwd = (
_bidirectional_toroidal_tracer_flare(
equi=equi,
r_initial=r_initial[l],
z_initial=z_initial[l],
phi_initial=phi_initial,
n_turns=n_turns,
n_planes=n_planes,
)
)
if istat_bwd != 0:
print(
f"Backward trace error for field line {l + 1}: "
f"istat = {istat_bwd}"
)
if istat_fwd != 0:
print(
f"Forward trace error for field line {l + 1}: "
f"istat = {istat_fwd}"
)
else:
R, Z, phi, s, success, message = _bidirectional_toroidal_tracer(
tracer=tracer,
r_initial=r_initial[l],
z_initial=z_initial[l],
phi_initial=phi_initial,
n_turns=n_turns,
n_planes=n_planes,
)
if not success:
if "R left valid range" in message:
warnings.warn(
f"Field line {l + 1} / {n_field_lines}: "
f"R left valid range before completing "
f"{n_turns} turns."
)
else:
warnings.warn(
f"Field line {l + 1} / {n_field_lines} trace "
f"failed: {message}"
)
R_data[l, :] = R
Z_data[l, :] = Z
phi_data[l, :] = phi
s_data[l, :] = s
attrs = {
"n_turns": n_turns,
"n_planes": n_planes,
"backend": backend,
}
dims = ("line", "plane")
coords = {
"line": np.arange(shape[0]),
"plane": np.arange(shape[1]),
}
arrays = (R_data, Z_data, phi_data, s_data)
names = ("R", "Z", "phi", "s")
R_da, Z_da, phi_da, s_da = tuple(
make_xarray(
array,
name=name,
dims=dims,
coords=coords,
attrs=attrs.copy(),
)
for array, name in zip(arrays, names, strict=True)
)
return R_da, Z_da, phi_da, s_da