Source code for torx.grid.grid_2d_m

"""Grid that provides common functionality for different 2D grid types."""
from abc import ABC
from functools import cached_property
import numpy as np
import xarray as xr
import pandas as pd
from scipy.interpolate import griddata
from scipy.spatial import KDTree

from torx.units import Quantity
from torx.units import check_units
from torx.vector import poloidal_vector
from torx.decorators import AutoDocClass

[docs] class Grid2D(ABC, AutoDocClass): """2D regular grid on a poloidal cross section.""" # Plane of the simulation this grid describes, set by the 3D grid it is # part of. Both stay None for a grid which is not part of a 3D grid. phi_index = None phi = None # Arrays holding the coordinates of the points of the grid. Only these # can hold the NaN padding used to align the planes of a 3D grid. COORDINATE_ARRAYS = ("r_u", "z_u", "r_s", "z_s")
[docs] def __init__(self, r_unstructured, z_unstructured, norm: Quantity=Quantity("1m"), init_matrices=False): """ Create a grid with the unstructured (R,Z) coordinates provided. Accepts optionally a filepath to read in GRILLIX trunk data. Accepts optionally a length quantity to normalize the grid. """ self.r_u, self.r_s, r_spacing, r_size = \ self.basis_from_unstructured_array(r_unstructured) self.z_u, self.z_s, z_spacing, z_size = \ self.basis_from_unstructured_array(z_unstructured) self.r_u = xr.DataArray(self.r_u, dims="points") self.r_s = xr.DataArray(self.r_s, dims="R") self.z_u = xr.DataArray(self.z_u, dims="points") self.z_s = xr.DataArray(self.z_s, dims="Z") assert(r_size == z_size), \ "Expected equal sized coordinate arrays but found " \ f"{r_size} != {z_size}" self.size = r_size self.shape = (self.r_s.size, self.z_s.size) self.r_spacing, self.z_spacing = r_spacing, z_spacing if np.isclose(r_spacing, z_spacing): self.spacing = r_spacing self._norm = norm self._parallel_length_scale = None self._perpendicular_length_scale = None self._flipped_z = False # Built on the first filled vector_to_matrix only, since its nearest # neighbor search over the whole matrix is expensive for large grids self.filled_point_index = None if init_matrices: self.setup_vector_to_matrix() self.setup_filled_vector_to_matrix() self.setup_perpendicular_gradient() self._vector_to_matrix_initialized = True self._perpendicular_gradient_initialized = True else: self._vector_to_matrix_initialized = False self._perpendicular_gradient_initialized = False
@property def R0(self): """Normalized radius.""" return self._norm @R0.setter def R0(self, value: Quantity): """Set the normalized radius.""" check_units(value, {"[length]":1}, "R0") self._norm = value def has_padding(self) -> bool: """ Check whether the grid contains NaN padding. Returns ------- bool True if any coordinate array of the grid contains a NaN. """ return any(np.isnan(getattr(self, name)).any() for name in self.COORDINATE_ARRAYS)
[docs] @staticmethod def basis_from_unstructured_array(x_unstructured): """Find the spacing and unique values of unstructured coordinates.""" x_unstructured = np.array(x_unstructured) assert x_unstructured.ndim == 1 x_structured = np.unique(x_unstructured) x_structured = x_structured[~np.isnan(x_structured)] spacing = np.mean(np.diff(x_structured)) assert np.allclose( np.diff(x_structured), spacing ), "Error: basis vector is not equally spaced." return x_unstructured, x_structured, spacing, x_unstructured.size
def setup_vector_to_matrix(self): """Calculate arrays to improve the performance of vector to matrix.""" tricolumn_data = np.column_stack( (self.r_u, self.z_u, np.arange(start=0, stop=self.size, dtype=int)) ) pd_dataframe = pd.DataFrame(tricolumn_data, columns=["x", "y", "z"]) # Makes a 2D array of indices shaped_data = pd_dataframe.pivot_table( values="z", index="y", columns="x", dropna=False ) # Flatten array to give a vector flattened_data = np.array(shaped_data).flatten() # Replaces NaN with zero (will return the z[0] element in this place) self.sort_indices = np.nan_to_num(flattened_data).astype("int") # Add NaN vector to z[self.sort_indices] to replace z[0] with NaN self.nan_vector = np.zeros_like(flattened_data) self.nan_vector[np.isnan(flattened_data)] = np.nan forward_sort = flattened_data[ np.logical_not(np.isnan(self.nan_vector)) ].astype(int) assert forward_sort.size == self.size self.reverse_sort_indices = np.zeros_like(forward_sort) self.reverse_sort_indices[forward_sort] = np.arange(forward_sort.size) def setup_filled_vector_to_matrix(self): """ Calculate the array to perform a filled vector to matrix. Calculates the 'filled point index' array which can be used to index an unstructured array and return a filled 2D array, using nearest neighbor interpolation for off-grid points. """ point_index = xr.DataArray(np.arange(self.size), dims="points") r_mesh, z_mesh = np.meshgrid(self.r_s, self.z_s) self.filled_point_index = griddata( ((self.r_u, self.z_u)), point_index, (r_mesh, z_mesh), method="nearest" ).astype(int) @cached_property def _tree_point_index(self) -> np.ndarray: """ Return the grid index of every point of the k-d tree. Grid points with non-finite coordinates are left out of the tree. The grid size is appended, so that the index the tree returns for a missing neighbor maps to the grid size. """ finite = np.isfinite(self.r_u.values) & np.isfinite(self.z_u.values) return np.append(np.flatnonzero(finite), self.size) @cached_property def _kd_tree(self) -> KDTree: """ Return the k-d tree of the grid points. Built on first use and shared by all nearest neighbor searches on the grid. It is private, so that displaying the grid with rich does not build it. """ points = self._tree_point_index[:-1] return KDTree(np.column_stack((self.r_u.values[points], self.z_u.values[points]))) def nearest_neighbors( self, r_query: np.ndarray, z_query: np.ndarray, n_neighbors: int, **query_kwargs, ) -> tuple[np.ndarray, np.ndarray]: """ Return the nearest grid points of query points. The grid points are searched with a k-d tree, which is built on the first search and then kept, so that the memory needed grows with the number of query points only, not with the size of the grid. Grid points with non-finite coordinates are ignored. Where fewer than n_neighbors grid points are found, for example for query points with non-finite coordinates, the missing neighbors are returned at infinite distance with the index self.size, as in KDTree.query. Parameters ---------- r_query : np.ndarray Radial coordinates of the query points. z_query : np.ndarray Vertical coordinates of the query points, same shape as r_query. n_neighbors : int Number of nearest grid points to return. **query_kwargs Passed on to scipy.spatial.KDTree.query, for example the norm p or distance_upper_bound. Returns ------- distance : np.ndarray Distances of the nearest grid points in ascending order, shape r_query.shape + (n_neighbors,). index : np.ndarray Grid indices of the nearest grid points, of the same shape. """ r_query = np.asarray(r_query, dtype=float) z_query = np.asarray(z_query, dtype=float) distance = np.full(r_query.shape + (n_neighbors,), np.inf) index = np.full(distance.shape, self.size) # The k-d tree rejects non-finite query points finite = np.isfinite(r_query) & np.isfinite(z_query) finite_distance, tree_index = self._kd_tree.query( np.column_stack((r_query[finite], z_query[finite])), k=n_neighbors, **query_kwargs, ) distance[finite] = np.reshape(finite_distance, (-1, n_neighbors)) index[finite] = np.reshape(self._tree_point_index[tree_index], (-1, n_neighbors)) return distance, index def points_within_grid( self, r_test: np.ndarray, z_test: np.ndarray, expansion: float = 1.01, ) -> np.ndarray: """ Return whether sample points lie within the grid. A sample point lies within the grid if at least four grid points are closer to it than spacing * expansion in both R and Z. Sample points with non-finite coordinates do not lie within the grid, and grid points with non-finite coordinates are ignored. This tests whether there is grid around each point, not whether it lies exactly within the boundary of the grid, unlike Polygon2D.points_inside. Parameters ---------- r_test : np.ndarray Radial coordinates of the sample points. z_test : np.ndarray Vertical coordinates of the sample points, same shape as r_test. expansion : float, optional Factor widening the search distance beyond one grid spacing. Returns ------- np.ndarray Boolean array of the shape of r_test, True for points within the grid. """ # At least four grid points are close enough in both R and Z if the # fourth nearest one is, in the maximum norm. Neighbors beyond the # search distance are returned at infinite distance. search_distance = self.spacing * expansion distance, _ = self.nearest_neighbors( r_test, z_test, 4, p=np.inf, distance_upper_bound=search_distance, ) return distance[..., -1] < search_distance def nearest_non_collinear_neighbors( self, r_query: np.ndarray, z_query: np.ndarray, n_candidates: int = 16, ) -> np.ndarray: """ Return the nearest grid points of query points for a gradient stencil. For every query point, returns the nearest and second nearest grid point and the nearest grid point which is not collinear with these two. Grid points at equal distance are taken in the order the k-d tree returns them, since any of them gives a valid stencil. Three points count as collinear if the sine of the angle between the edges from the nearest one to the other two is below 1e-8, so that the test does not depend on the grid spacing. Parameters ---------- r_query : np.ndarray Radial coordinates of the query points, shape (n_query,). z_query : np.ndarray Vertical coordinates of the query points, shape (n_query,). n_candidates : int, optional Number of nearest grid points searched first for a non-collinear one. The search is widened up to the whole grid where none is found. Returns ------- np.ndarray The grid indices of the nearest, the second nearest and the nearest non-collinear grid point, shape (3, n_query). Where all grid points are collinear, the third nearest one is returned instead. Query points with non-finite coordinates get the first three grid points, since nothing is nearest to them. """ r_query = np.asarray(r_query, dtype=float) z_query = np.asarray(z_query, dtype=float) finite = np.isfinite(r_query) & np.isfinite(z_query) grid_points = np.column_stack((self.r_u.values, self.z_u.values)) n_points = self._kd_tree.n k = min(max(n_candidates, 3), n_points) while True: _, index = self.nearest_neighbors( r_query[finite], z_query[finite], k) # Offsets of the second nearest and the candidate points from # the nearest one, which span a triangle of zero area if # collinear offset = grid_points[index] - grid_points[index[:, :1]] det = offset[:, 1:2, 0] * offset[:, 2:, 1] \ - offset[:, 1:2, 1] * offset[:, 2:, 0] # Dividing the area by the edge lengths gives the sine of the # angle between the edges, which does not scale with the grid length = np.hypot(offset[..., 0], offset[..., 1]) non_collinear = np.abs(det) > 1e-8 * length[:, 1:2] * length[:, 2:] if k == n_points or np.all(np.any(non_collinear, axis=-1)): break k = min(2 * k, n_points) first_non_collinear = 2 + np.argmax(non_collinear, axis=-1) neighbors = np.tile(np.arange(3)[:, None], r_query.size) neighbors[:, finite] = ( index[:, 0], index[:, 1], index[np.arange(index.shape[0]), first_non_collinear], ) return neighbors def setup_perpendicular_gradient(self): """ Calculate array to create a perpendicular gradient operator on the grid. Create neighbor index arrays for left, right, up and down neighbors that can be used to evaluate the perpendicular gradient """ point_index = xr.DataArray(np.arange(self.size), dims="points") # The idea is to simply store the left, right, up, down neighbors for # the structured (R, Z) data and then convert it back to the # unstructured form. point_matrix = self.vector_to_matrix(point_index) points = point_matrix.values right_matrix = point_matrix.copy() left_matrix = point_matrix.copy() up_matrix = point_matrix.copy() down_matrix = point_matrix.copy() # We need to take care of the boundaries which are given by NaN # neighbors. Here we set the index to the point itself which can be # used straightforwardly in a forward stencil. The matrix has the # dims (Z, R). right_matrix.values[:, :-1] = np.where( np.isnan(points[:, 1:]), points[:, :-1], points[:, 1:]) left_matrix.values[:, 1:] = np.where( np.isnan(points[:, :-1]), points[:, 1:], points[:, :-1]) up_matrix.values[:-1, :] = np.where( np.isnan(points[1:, :]), points[:-1, :], points[1:, :]) down_matrix.values[1:, :] = np.where( np.isnan(points[:-1, :]), points[1:, :], points[:-1, :]) self.right_index = self.matrix_to_vector(right_matrix).astype(int) self.left_index = self.matrix_to_vector(left_matrix).astype(int) self.up_index = self.matrix_to_vector(up_matrix).astype(int) self.down_index = self.matrix_to_vector(down_matrix).astype(int) def vector_to_matrix( self, unstructured_data, filled: bool=False, allow_rechunk: bool=True ): """ Convert an array from the unstructured to structured representation. Converts tricolumn data 'z(..., points, ....)' to matrix data 'z(..., R, Z, ...)' If unstructured_data is supplied as an xarray, the 'points' dimension is shaped. If unstructured_data is supplied as a numpy array, it can only be 1D """ if not isinstance(unstructured_data, xr.DataArray): assert unstructured_data.ndim == 1 unstructured_data = xr.DataArray(unstructured_data, dims="points") assert unstructured_data.sizes["points"] == self.size, \ ("Unstructured data not the same size as input data. " f"Unstructured data size: {unstructured_data.sizes['points']}, " f"Input data size: {self.size}.") if not self._vector_to_matrix_initialized: self.setup_vector_to_matrix() self._vector_to_matrix_initialized = True if filled and self.filled_point_index is None: self.setup_filled_vector_to_matrix() structured_data = xr.apply_ufunc( _stack, unstructured_data, kwargs=dict( filled=filled, sort_indices=self.sort_indices, nan_vector=self.nan_vector, filled_point_index=self.filled_point_index, shape=(self.z_s.size, self.r_s.size), ), input_core_dims=[ ["points"], ], output_core_dims=[["Z", "R"]], keep_attrs=True, vectorize=True, dask="parallelized", dask_gufunc_kwargs=dict( output_sizes={"Z": self.z_s.size, "R": self.r_s.size}, allow_rechunk=allow_rechunk, ), output_dtypes=[np.float64], ).assign_coords(R=self.r_s, Z=self.z_s) return structured_data def matrix_to_vector(self, structured_data, allow_rechunk: bool = True): """ Convert an array from the structured to unstructured representation. Converts matrix data 'z(..., R, Z, ...)' to tricolumn data 'z(..., points, ....)' """ if not isinstance(structured_data, xr.DataArray): assert structured_data.ndim == 2 structured_data = xr.DataArray(structured_data, dims=("Z", "R")) assert structured_data.sizes["R"] == self.r_s.size assert structured_data.sizes["Z"] == self.z_s.size if not self._vector_to_matrix_initialized: self.setup_vector_to_matrix() self._vector_to_matrix_initialized = True unstructured_data = xr.apply_ufunc( _flatten, structured_data, kwargs=dict( nan_vector=self.nan_vector, reverse_sort_indices=self.reverse_sort_indices, ), input_core_dims=[ ["Z", "R"], ], output_core_dims=[["points"]], keep_attrs=True, vectorize=True, dask="parallelized", dask_gufunc_kwargs=dict( output_sizes={"points": self.size}, allow_rechunk=allow_rechunk ), output_dtypes=[np.float64], ) return unstructured_data def perpendicular_gradient(self, array: xr.DataArray): """Return the perpendicular gradient of the given array as a vector.""" grad_r = self._gradient_R(array) grad_z = self._gradient_Z(array) return poloidal_vector(grad_r, grad_z, dims=array.dims, attrs=grad_r.attrs) def perpendicular_gradient_component(self, array: xr.DataArray, \ component="R"): """ Return a specific component of the perpendicular gradient. Component can either be a string specifying "R" or "Z", or alternatively an xarray DataArray representing a vector where to project the gradient to. """ if isinstance(component, str): if component == "R": return self._gradient_R(array) elif component == "Z": return self._gradient_Z(array) else: raise RuntimeError(f"Component {component} is not available " \ "for perpendicular gradient.") elif isinstance(component, xr.DataArray): assert "vector" in component.dims, \ "Component should be a vector with dimension vector " \ "in the DataArray." grad_r = self._gradient_R(array) grad_z = self._gradient_Z(array) grad_proj = component.sel(vector="eR") * grad_r \ + component.sel(vector="eZ") * grad_z if ("norm" in array.attrs and "norm" in component.attrs): grad_proj.attrs["norm"] = component.norm * grad_r.norm return grad_proj def radial_perpendicular_gradient(self, array: xr.DataArray, equi): """Return the radial component of the perpendicular gradient.""" e_rho = equi.magfield_vector_radial(self.r_u, self.z_u, normalize=True) return self.perpendicular_gradient_component(array, e_rho) def _gradient_R(self, array: xr.DataArray): """ Return the R component of the perpendicular gradient. Base method to calculate the R component of the perpendicular gradient, utilizing the neighbor index arrays created in the setup routine. Uses second order central differences in the interior and first order forward/backward differences at the boundaries. Works on structured or unstructured data. The latter is preferred for speed. """ if not self._vector_to_matrix_initialized: self.setup_vector_to_matrix() self._vector_to_matrix_initialized = True if not self._perpendicular_gradient_initialized: self.setup_perpendicular_gradient() self._perpendicular_gradient_initialized = True if not "points" in array.dims: assert ("R" in array.dims and "Z" in array.dims), \ "Neither dimension points, R or Z were found in the array." # If array given in structured R, Z, we convert to unstructured # points, perform the calculation and convert back in the end array = self.matrix_to_vector(array) convert_to_RZ = True else: convert_to_RZ = False grad_r = (array.isel({"points":self.right_index}) \ - array.isel({"points":self.left_index})) \ * 0.5 / self.r_spacing # Apply correction at edge point to achieve first order forward stencil edge = (self.right_index == self.right_index.points) \ | (self.left_index == self.left_index.points) edge = xr.DataArray(edge, dims="points") grad_r *= 1 + edge # If no norm attribute given, assume normalized units if "norm" in array.attrs: grad_r.attrs["norm"] = array.norm / self.R0 if convert_to_RZ: return self.vector_to_matrix(grad_r) else: return grad_r def _gradient_Z(self, array: xr.DataArray): """ Return the Z component of the perpendicular gradient. Base method to calculate the Z component of the perpendicular gradient, utilizing the neighbor index arrays created in the setup routine. Uses second order central differences in the interior and first order forward/backward differences at the boundaries. Works on structured or unstructured data. The latter is preferred for speed. """ if not self._vector_to_matrix_initialized: self.setup_vector_to_matrix() self._vector_to_matrix_initialized = True if not self._perpendicular_gradient_initialized: self.setup_perpendicular_gradient() self._perpendicular_gradient_initialized = True if not "points" in array.dims: assert ("R" in array.dims and "Z" in array.dims), \ "Neither dimension points, R or Z were found in the array." array = self.matrix_to_vector(array) convert_to_RZ = True else: convert_to_RZ = False grad_z = (array.isel({"points":self.up_index}) \ - array.isel({"points":self.down_index})) \ * 0.5 / self.z_spacing # Apply correction at edge point to achieve first order forward stencil edge = (self.up_index == self.up_index.points) \ | (self.down_index == self.down_index.points) edge = xr.DataArray(edge, dims="points") grad_z *= 1 + edge # If no norm attribute given, assume normalized units if "norm" in array.attrs: grad_z.attrs["norm"] = array.norm / self.R0 if convert_to_RZ: return self.vector_to_matrix(grad_z) else: return grad_z def get_unstructured_index(self, rval, zval): """Return index of r_u and z_u that correspond to rval and zval.""" dist2 = (self.r_u - rval)**2 + (self.z_u - zval)**2 ui = np.argmin(dist2.data).item() return ui def coords_like(self, array: xr.DataArray): """Return (R,Z) coordinates that fit the dims of the given array.""" assert(isinstance(array, xr.DataArray)), \ "Array must be of type xarray DataArray." if("R" in array.dims and "Z" in array.dims): r_norm = self.r_s z_norm = self.z_s elif("points" in array.dims): r_norm = self.r_u z_norm = self.z_u else: r_norm = None z_norm = None return r_norm, z_norm
def _stack(unstructured_data, filled, sort_indices, nan_vector, filled_point_index, shape): """ Return the inner shaping loop for vector_to_matrix. The matrix has the given shape (n_Z, n_R). filled_point_index is only used if filled is True. """ if filled: return unstructured_data[filled_point_index] return (unstructured_data[sort_indices] + nan_vector).reshape(shape) def _flatten(input_array, nan_vector, reverse_sort_indices): """Return the inner flatten loop for matrix_to_vector.""" return input_array.flatten()[np.logical_not(np.isnan(nan_vector))][ reverse_sort_indices ]