"""Polygon sets forming Poincare structures."""
import numpy as np
import xarray as xr
from scipy.interpolate import LinearNDInterpolator, CloughTocher2DInterpolator
from torx.decorators import AutoDocClass
from .ordered_polygon_2d_m import OrderedPolygon2D
[docs]
class PoincarePolygonSet(AutoDocClass):
"""
Set of ordered polygon objects constructed from Poincare data.
Allows computing a radial coordinate based on the polygon areas.
"""
[docs]
def __init__(self, poincare_data: xr.DataArray,
phi_array: xr.DataArray, start_idx: int=0, k: int=20,
n_interp: int=0, resample_method: str="spline",
make_simple: bool=False, sort: bool=True,
is_separatrix: int=None):
"""
Initialize the set from Poincare data from PARALLAX.
The polygons are used to define flux surfaces and compute
a radial coordinate based on their average areas.
Parameters
----------
poincare_data : xarray.DataArray
4D array with dimensions (dim_surf, dim_turn, dim_plane, dim_xy)
containing the Poincare points from PARALLAX.
phi_array : xarray.DataArray
Array of toroidal angles corresponding to each plane.
start_idx : int, optional
Index of the first point to include.
k : int, optional
Number of nearest neighbors for polygon construction.
n_interp : int, optional
Number of points to interpolate along polygon edges.
resample_method : {'spline', 'pchip'}, optional
Polygon resampling scheme (see OrderedPolygon2D.resample).
'pchip' is recommended for scrape-off layer surfaces, as it
adds no self-intersections at thin fingers. Neither smooths;
use smooth_surfaces after construction.
make_simple : bool, optional
Remove self-intersection loops from each polygon (outer
scrape-off layer fingers doubling back).
sort : bool, optional
Sort surfaces by increasing mean area. Disable to preserve
the input order (for example when surface 0 is the magnetic
axis and is_separatrix refers to a fixed input index).
is_separatrix : int, optional
Surface index (into the current, possibly sorted, ordering)
whose mean area normalizes rho to 1. Defaults to the
outermost surface.
"""
assert(type(poincare_data) == xr.DataArray), \
"Poincare data must be Xarray DataArray."
required_dims = {"dim_surf", "dim_turn", "dim_plane", "dim_xy"}
assert required_dims.issubset(poincare_data.dims), \
f"Expected dims {required_dims}, got {poincare_data.dims}"
self.n_surfaces = poincare_data.sizes["dim_surf"]
self.n_turns = poincare_data.sizes["dim_turn"]
self.n_planes = poincare_data.sizes["dim_plane"]
assert(poincare_data.sizes["dim_xy"] == 2), \
"Poincare data must have dim_xy of size 2."
# TODO: Store the vertices as a numeric DataArray with dims
# (dim_surf, dim_plane, dim_vertex, dim_xy) instead of an object
# array of OrderedPolygon2D. Area, centroid, mean area, rho
# normalization, orientation and smoothing would then vectorize,
# and the set could be written to netCDF/zarr and chunked with
# dask. This needs a uniform vertex count, so require n_interp > 0
# when make_simple is True (or NaN-pad with a mask). Ordering,
# self-intersection splitting and resampling stay per-polygon
# loops. Keep a polygon(s, p) accessor for existing callers.
# Create OrderedPolygon2D objects from Poincare data
polygon_data = xr.apply_ufunc(
OrderedPolygon2D.from_2d_array,
poincare_data,
start_idx, k, n_interp, resample_method,
make_simple,
input_core_dims=[['dim_turn', 'dim_xy'],
[], [], [], [], []],
output_core_dims=[[]],
vectorize=True,
output_dtypes=[object]
)
mean_area = self._mean_area(polygon_data)
# Sort everything according to mean area
if sort:
sort_indices = mean_area.argsort()
polygon_data = polygon_data.isel(dim_surf=sort_indices)
poincare_data = poincare_data.isel(dim_surf=sort_indices)
mean_area = mean_area.isel(dim_surf=sort_indices)
self.polygon_data = polygon_data
self.poincare_data = poincare_data
self.mean_area = mean_area
self._is_separatrix = is_separatrix
self.phi_array = phi_array
# Compute normalized radial coordinate rho
self._normalize()
self._rho_interpolators = None
@staticmethod
def _mean_area(polygon_data: xr.DataArray) -> xr.DataArray:
"""Return the plane-averaged signed area of each polygon."""
polygon_area = xr.apply_ufunc(
lambda p: p.signed_area(),
polygon_data,
input_core_dims=[[]],
output_core_dims=[[]],
vectorize=True,
output_dtypes=[float]
)
return polygon_area.mean(dim='dim_plane')
def _normalize(self):
"""Set rho_data as the mean area normalized at the separatrix."""
if self._is_separatrix is None:
norm = self.mean_area.isel(dim_surf=-1)
else:
norm = self.mean_area.isel(dim_surf=self._is_separatrix)
self.rho_data = self.mean_area / norm
def smooth_surfaces(self, smooth: float, rho_threshold: float=1.0,
surfaces=None):
"""
Spline-smooth the ragged outer (scrape-off layer) surfaces.
Replaces each selected surface's polygon in every plane by a
smoothing periodic spline with the same number of points (see
OrderedPolygon2D.interpolate), then recomputes the mean-area rho
label. This corrects spiky field-line-traced surfaces while
preserving their area. Inner/closed surfaces are left untouched.
Parameters
----------
smooth : float
Dimensionless smoothing strength: the allowed RMS distance of
the spline from the points as a fraction of the polygon's
equivalent radius sqrt(|A| / pi). Typically 0.005 to 0.05;
a value of 0 or less does nothing.
rho_threshold : float, optional
Smooth every surface with rho greater than this (default 1.0,
i.e. the scrape-off layer only). Ignored when ``surfaces`` is
given.
surfaces : sequence of int, optional
Explicit surface indices to smooth, overriding rho_threshold.
Returns
-------
PoincarePolygonSet
``self``, for chaining.
"""
if smooth <= 0.0:
return self
if surfaces is None:
rho = self.rho_data.values
surfaces = [s for s in range(self.n_surfaces)
if rho[s] > rho_threshold]
for s in surfaces:
for ip in range(self.n_planes):
polygon = self.polygon_data.isel(
dim_surf=s, dim_plane=ip).item()
polygon.resample(len(polygon.x_points), "spline", smooth)
self.mean_area = self._mean_area(self.polygon_data)
self._normalize()
self.reset_interpolator()
return self
[docs]
def __getitem__(self, key: int | tuple) -> object:
"""
Get item(s) from the polygon set using indices.
[i_plane, i_surf, i_point, i_coord].
"""
# Normalize key to tuple and check that it has 1-4 elements
if not isinstance(key, tuple):
key = (key,)
if not 1 <= len(key) <= 4:
raise IndexError(
"Indexing requires 1-4 indices: "
"[i_plane, i_surf, i_point, i_coord]."
)
# Normalize the key to a 4-tuple (pad with slices as needed)
# Think of slice(None) as ":", it works as a "pass-through" operator
# The alternative would be to do many else if statements
key = key + (slice(None),) * (4 - len(key))
i_plane, i_surf, i_point, i_coord = key
sliced = self.polygon_data.isel(dim_plane=i_plane, dim_surf=i_surf)
# If fully indexed return a scalar
if all(isinstance(i, (int, np.integer)) for i in key):
return sliced.item()[i_point, i_coord]
# Determine output core dimensions based on key
output_core_dims = []
# If i_point is a slice, the first output dimension is 'dim_point'
if isinstance(i_point, slice):
output_core_dims.append('dim_turn')
# If i_coord is a slice, the second output dimension is 'dim_coord'
if isinstance(i_coord, slice):
output_core_dims.append('dim_xy')
def _extract_polygon_slice(poly: OrderedPolygon2D, i_point, i_coord):
if i_coord == slice(None):
return poly[i_point]
else:
return poly[i_point, i_coord]
# when there are slices, use apply_ufunc to extract the data
return xr.apply_ufunc(
_extract_polygon_slice,
sliced,
i_point,
i_coord,
input_core_dims=[[], [], []],
output_core_dims=[output_core_dims],
vectorize=True,
output_dtypes=[np.float64]
)
[docs]
@classmethod
def from_file(cls, filename:str, start_idx: int=0, k: int=20,
n_interp=0, resample_method: str="spline",
make_simple: bool=False, sort: bool=True,
is_separatrix: int=None):
"""Initialize from a file produced with PARALLAX diagnose_poincare."""
poincare = xr.open_dataset(filename)
return cls(poincare.poincare_data, poincare.phi_array,
start_idx, k, n_interp, resample_method, make_simple,
sort, is_separatrix)
[docs]
@classmethod
def from_torx_poincare(cls, starting_points, n_turns):
"""Initialize from torx field line tracing."""
# Placeholder - implement field line tracing
raise NotImplementedError("Field line tracing not implemented yet.")
@property
def rho_interp(self):
"""
Lazy-loaded interpolator for rho(R, Z, phi).
Returns an xarray of interpolators for each plane.
"""
if self._rho_interpolators is None:
# Create interpolators for each plane
def _plane_interpolator(poincare_data, phi, rho_data):
R_data = poincare_data[:,:,0].flatten()
Z_data = poincare_data[:,:,1].flatten()
rho_data = np.broadcast_to(rho_data[:, np.newaxis],
poincare_data[:,:,0].shape).flatten()
coords = np.column_stack([R_data, Z_data])
return CloughTocher2DInterpolator(coords, rho_data)
self._rho_interpolators = xr.apply_ufunc(
_plane_interpolator,
self.poincare_data,
self.phi_array,
self.rho_data,
# Separate along dim_plane
input_core_dims=[["dim_surf", "dim_turn", "dim_xy"],
[], ["dim_surf"]],
output_core_dims=[[]],
vectorize=True,
output_dtypes=[object],
)
return self._rho_interpolators
def rho(self, R, Z, phi):
"""Return interpolated rho(R, Z, phi)."""
# Find closest phi
phi_closest = abs(self.phi_array-phi).argmin(dim='dim_plane')
# Get interpolator for that plane
interp = self.rho_interp.isel(dim_plane=phi_closest).item()
# Return interpolated value
return interp(R, Z)
def reset_interpolator(self):
"""Reset the interpolator."""
self._rho_interpolators = None
def to_lineout_set(self, rho_i):
"""Convert the polygon set with radial index rho_i to a LineoutSet."""
from torx.analysis.lineouts import Lineout, LineoutSet
lineout_x = self[:,rho_i,:,0].T
lineout_y = self[:,rho_i,:,1].T
num_planes = lineout_x.sizes['dim_plane']
lineout_objects = np.empty(num_planes, dtype=object)
for i in range(num_planes):
x_data = lineout_x.isel(dim_plane=i).values
y_data = lineout_y.isel(dim_plane=i).values
lineout = Lineout(x_data, y_data)
lineout_objects[i] = lineout
ds_lineouts = xr.DataArray(
lineout_objects,
dims="phi",
)
return LineoutSet(ds_lineouts)