Source code for torx.grid.grid_3d_m

"""Defines 3D grids made up of collections of 2D grids."""
from abc import ABC
import numpy as np
import xarray as xr
from pathlib import Path
from typing import Optional, Sequence, Union

from .grid_2d_m import Grid2D
from torx.units import Quantity
from torx.arrays import combine_obj_list
from torx.units import check_units
from torx.decorators import autodoc_class
from functools import cached_property

[docs] @autodoc_class class Grid3D(ABC): """Represents a 3D grid, composed of an array of 2D grids at each phi."""
[docs] def __init__(self, grids: xr.DataArray, plane_indices: Optional[Sequence[int]] = None): """ Initialize the 3D grid. Parameters ---------- grids : xr.DataArray Array holding the Grid2D of every plane. plane_indices : Sequence[int], optional Index of every plane in the simulation it belongs to. Only needed when the array holds a part of the planes, since the position of a plane is used otherwise. """ self._assert_no_padding(grids) self._set_plane(grids, plane_indices) self.grid_array = grids self.name = 'Grid3D'
@staticmethod def _assert_no_padding(grid_array: xr.DataArray) -> None: """ Check that no plane of a 3D grid holds padding. Parameters ---------- grid_array : xr.DataArray Array of Grid2D objects, one for each plane. Raises ------ ValueError If the 2D grid of any plane holds padding. """ for index in np.ndindex(grid_array.shape): if grid_array.values[index].has_padding(): raise ValueError( f"The 2D grid of plane {index[0]} holds NaN padding. " "Planes of a 3D grid must be built from the points of " "their own plane only, see Grid3D.from_rz." ) @staticmethod def _set_plane(grid_array: xr.DataArray, plane_indices: Optional[Sequence[int]]) -> None: """ Tell the 2D grid of every plane which plane of a simulation it is. Parameters ---------- grid_array : xr.DataArray Array of Grid2D objects, one for each plane. plane_indices : Sequence[int] or None Index of every plane in the simulation it belongs to. The position of a plane in the array is used when None is given, which is only correct if the array holds all planes of the simulation. Raises ------ ValueError If an index is given for a different number of planes. """ if plane_indices is None: plane_indices = range(grid_array.size) elif len(plane_indices) != grid_array.size: raise ValueError( f"Got {len(plane_indices)} plane indices " f"for {grid_array.size} planes." ) phi_values = grid_array.coords["phi"].values for position, grid in enumerate(grid_array.values.flat): grid.phi_index = int(plane_indices[position]) grid.phi = phi_values[position]
[docs] @classmethod def from_rz(cls, r_unstructured, z_unstructured): """Create the 3D grid from xarrays of unstructured R and Z grids.""" assert isinstance(r_unstructured, xr.DataArray) \ and isinstance(z_unstructured, xr.DataArray), \ "Unstructured grids must be Xarray DataArrays!" # Helper function to remove NaNs and build a 2D grid for each plane. def _build_2d_grid_no_nans(r_plane, z_plane): valid_mask = ~np.isnan(r_plane) & ~np.isnan(z_plane) r_clean = r_plane[valid_mask] z_clean = z_plane[valid_mask] return Grid2D(r_clean, z_clean) grids = xr.apply_ufunc(_build_2d_grid_no_nans, r_unstructured, z_unstructured, input_core_dims=[["dim_RZ"], ["dim_RZ"]], output_core_dims=[[]], vectorize=True, output_dtypes=[object]) return cls(grids)
[docs] @classmethod def from_multigrid_files( cls, dirpath: Path, planes: Optional[Union[int, Sequence[int]]] = None, staggered: bool = False ): """ Create a 3D grid object from a directory of multigrid files. Parameters ---------- dirpath : str Path to the directory containing multigrid files. planes : list or None List of plane indices to load. If None, all planes are loaded. """ from torx.specializations.grillix import grid_2d_from_multigrid_file # Find all files matching the pattern path_list = sorted(list(dirpath.glob("multigrids_plane*.nc"))) if not path_list: raise FileNotFoundError( "Error: No files matching 'multigrids_plane*.nc' " "were found in the directory." ) all_filepaths = np.atleast_1d(path_list) all_phi = np.linspace(0, 2*np.pi, len(all_filepaths), endpoint=False) # Filter files based on planes if planes is None: phi = all_phi plane_indices = None filepaths = xr.DataArray( all_filepaths, dims="phi" ) else: planes = np.atleast_1d(planes) phi = all_phi[planes] filepaths = xr.DataArray( all_filepaths[planes], dims="phi" ) # The position of a plane is not its index in the simulation # when only a part of the planes is loaded. plane_indices = planes grids = xr.apply_ufunc(grid_2d_from_multigrid_file, filepaths, staggered, input_core_dims=[[], []], output_core_dims=[[]], vectorize=True, output_dtypes=[object]).assign_coords(phi=phi) return cls(grids, plane_indices=plane_indices)
@property def grid_array(self): """Return the arrays of values in the grid.""" return self._grid_array @grid_array.setter def grid_array(self, value): """ Set the arrays of values in the grid. Everything derived from the 2D grids is dropped, since it describes the planes which were held before. """ self._grid_array = value self.clear_cached_properties() def _map_over_planes(self, function, dtype) -> xr.DataArray: """ Apply a function to the 2D grid of every plane. Parameters ---------- function : callable Function taking the Grid2D of a plane and returning a value. dtype : type Data type of the returned values. Returns ------- xr.DataArray Result for every plane, along the dimension of the planes. """ values = np.empty(self._grid_array.shape, dtype=dtype) for index in np.ndindex(self._grid_array.shape): values[index] = function(self._grid_array.values[index]) return self._grid_array.copy(data=values) @cached_property def npoints(self) -> xr.DataArray: """Number of grid points in each plane.""" return self._map_over_planes(lambda grid: grid.size, int) @cached_property def shape(self) -> xr.DataArray: """Shape of the structured grid of each plane.""" return self._map_over_planes(lambda grid: grid.shape, object) @cached_property def r_u(self) -> xr.DataArray: """Unstructured R values.""" return combine_obj_list(self.grid_array.values, "r_u", "phi", self.grid_array.coords["phi"].values) @cached_property def z_u(self) -> xr.DataArray: """Unstructured Z values.""" return combine_obj_list(self.grid_array.values, "z_u", "phi", self.grid_array.coords["phi"].values) @cached_property def r_s(self) -> xr.DataArray: """Structured R values.""" return combine_obj_list(self.grid_array.values, "r_s", "phi", self.grid_array.coords["phi"].values) @cached_property def z_s(self) -> xr.DataArray: """Structured Z values.""" return combine_obj_list(self.grid_array.values, "z_s", "phi", self.grid_array.coords["phi"].values)
[docs] def clear_cached_properties(self): """Clear all attributes decorated with @cached_property.""" # We iterate over the class members to find cached_property descriptors cls = self.__class__ for name, value in cls.__dict__.items(): if isinstance(value, cached_property): # If the property has been computed, # it exists in the instance __dict__ if name in self.__dict__: delattr(self, name)
def __getattr__(self, name): """ Give access to the attributes of the array of 2D grids. The name of a dimension or coordinate gives the coordinate itself, so that grid.phi gives the phi value of every plane. Any other name is looked up on the array of 2D grids, which is what makes grid.sel and grid.isel work. """ # Private names are never passed on. if name.startswith("_"): raise AttributeError(f"'Grid3D' object has no attribute '{name}'") # This is only called when the normal lookup fails, so the array of # 2D grids has to be read from the instance itself. grid_array = self.__dict__.get("_grid_array") if grid_array is None: raise AttributeError(f"'Grid3D' object has no attribute '{name}'") if name in grid_array.dims or name in grid_array.coords: return grid_array.coords[name] try: return getattr(grid_array, name) except AttributeError: raise AttributeError( f"'Grid3D' object has no attribute '{name}'" ) from None
[docs] def __getitem__(self, key): """ Return the coordinate of a dimension of the 3D grid. Parameters ---------- key : str Name of a dimension or coordinate, such as 'phi'. Returns ------- xr.DataArray Value of the coordinate for every plane. """ grid_array = self._grid_array if isinstance(key, str) and \ (key in grid_array.dims or key in grid_array.coords): return grid_array.coords[key] raise KeyError(key)
[docs] def set_R0(self, value: Quantity): """Set the normalized radius for all of the 2D grids.""" check_units(value, {"[length]":1}, "R0") for grid in self._grid_array.values.flat: grid.R0 = value
[docs] def sel_phi(self, phi_value=0.0) -> Grid2D: """ Return the 2D grid of the plane at the given phi value. The grid is the one held by the 3D grid, so the index arrays it has built already are kept when the same plane is selected again. Parameters ---------- phi_value : float Value of the phi coordinate of the wanted plane. Returns ------- Grid2D Grid of the selected plane. """ return self.sel(phi=phi_value).item()
[docs] def isel_phi(self, phi_index=0) -> Grid2D: """ Return the 2D grid of the plane at the given phi index. The grid is the one held by the 3D grid, so the index arrays it has built already are kept when the same plane is selected again. Parameters ---------- phi_index : int Index of the wanted plane along phi. Returns ------- Grid2D Grid of the selected plane. """ return self.isel(phi=phi_index).item()
[docs] def isel_planes(self, phi_indices: Sequence[int]) -> "Grid3D": """ Return a 3D grid holding the planes at the given phi indices. The 2D grids are the ones held by this grid, so the index arrays they have built already are kept. Parameters ---------- phi_indices : Sequence[int] Positions along phi of the wanted planes. Returns ------- Grid3D Grid of the selected planes, which keep the index of the plane of the simulation they describe. """ planes = self.isel(phi=list(phi_indices)) return type(self)( planes, plane_indices=[grid.phi_index for grid in planes.values.flat] )
[docs] def perpendicular_gradient(self, array: xr.DataArray) -> xr.DataArray: """Return the perpendicular gradient of the given array as a vector.""" # Rename dimensions if necessary. if 'planes' in array.dims: array = array.rename({'planes': 'phi'}) if 'npoints_max' in array.dims: array = array.rename({'npoints_max': 'points'}) # Check that the array has the expected dimensions. assert ("points" in array.dims and "phi" in array.dims), \ "The array must have dimensions 'points' and 'phi'." # Make sure to keep the staggered attribute if it exists. has_staggered = "staggered" in array.attrs if has_staggered: staggered_val = array.attrs["staggered"] # Helper function that applies the perp grad to a single 2D grid. # It pads the resulting grad with NaNs for vectorized computation. def _plane_perp_grad(grid_2d_obj, data_slice, n_points): if hasattr(grid_2d_obj, 'item'): grid_2d_obj = grid_2d_obj.item() # Must set points dim or perpendicular_gradient fails. array_2d_valid = xr.DataArray(data_slice, dims=['points']) grad_2d = grid_2d_obj.perpendicular_gradient(array_2d_valid) n = grad_2d.sizes["points"] if n < n_points: grad_2d = grad_2d.pad( points=(0, n_points - n), constant_values=np.nan ) return grad_2d # Chunk beforehand to avoid dask issues with objects. max_points = int(self.npoints.max()) grid_array = self.grid_array.chunk({"phi": 1}) # Use xr.apply_ufunc to calculate the gradient plane by plane. gradient = xr.apply_ufunc( _plane_perp_grad, grid_array, array, max_points, input_core_dims=[[], ['points'], []], output_core_dims=[['points', 'vector']], exclude_dims=set(['points']), vectorize=True, dask="parallelized", output_dtypes=[np.float64], keep_attrs=True, dask_gufunc_kwargs={ "output_sizes": { "points": max_points, "vector": 3 } } ) # If the input array had a "staggered" attribute, preserve it. if has_staggered: gradient.attrs["staggered"] = staggered_val return gradient