Source code for torx.equilibrium.raw_numerical_m

"""Contains the RawNumericalEquilibrium class for preprocessing equilibria."""
import numpy as np
from scipy.interpolate import RectBivariateSpline
from pathlib import Path
import warnings

from .numerical_m import NumericalEquilibrium
from torx.units import Quantity
from torx.arrays import make_xarray, pad_array_with_coords

from torx.geometry import Polygon2D
from torx.decorators import autodoc_class

[docs] @autodoc_class class RawNumericalEquilibrium(NumericalEquilibrium): """ A NumericalEquilibrium in an uninitialized state for preprocessing. Allows setting raw SI data before normalization. Once normalize() is called the equilibrium is fully initialized and can be used as a NumericalEquilibrium. The equilibrium cannot be used for field evaluations before normalization. """
[docs] @classmethod def create_empty(cls): """ Create an empty uninitialized equilibrium for preprocessing. Returns ------- RawNumericalEquilibrium An empty uninitialized equilibrium. """ obj = cls.__new__(cls) obj._is_normalized = False obj._is_filtered = False obj._is_padded = False obj._flipped_Z = False obj._norm_length = None obj._norm_magfield = None obj.poloidal_field_factor = +1.0 obj.axis_r = None obj.axis_z = None obj.x_point_r = None obj.x_point_z = None obj._axis_Btor = None obj._axis_Btor_units = None obj._spline_basis_r = None obj._spline_basis_z = None obj._psi_axis = None obj._psi_separatrix = None obj._psi_data = None obj.psi_interpolator = None obj._b_pol_interpolator = None obj._rho_min = None obj._rho_max = None obj.divertor_polygon = None obj.exclusion_polygon = None obj.flux_limit_polygons = {} obj.flux_limit_rho_min = {} obj.flux_limit_rho_max = {} obj.raw = None return obj
[docs] @classmethod def from_dict(cls, equi_dict: dict): """ Create a RawNumericalEquilibrium from a standardized dictionary. Parameters ---------- equi_dict : dict Dictionary with standardized keys: - axis_r: float, magnetic axis R in meters - axis_z: float, magnetic axis Z in meters - axis_Btor: float, toroidal field on axis in Tesla - axis_Btor_units: str, units of axis_Btor - psi_axis: float, poloidal flux on axis in Weber - psi_separatrix: float, poloidal flux on separatrix in Weber - spline_basis_r: np.ndarray, R grid vector in meters - spline_basis_z: np.ndarray, Z grid vector in meters - psi_data: np.ndarray, poloidal flux on grid in Weber - x_point_r: float, optional, x-point R in meters - x_point_z: float, optional, x-point Z in meters Returns ------- RawNumericalEquilibrium """ obj = cls.create_empty() axis_r_units = equi_dict.get("axis_r_units", "m") x_point_units = equi_dict.get("x_point_units", axis_r_units) obj.poloidal_field_factor = float(equi_dict.get("poloidal_field_factor", 1.0)) obj._norm_length = Quantity(equi_dict["axis_r"], axis_r_units) obj._axis_Btor_units = equi_dict.get("axis_Btor_units", "T") obj.axis_r = make_xarray(equi_dict["axis_r"], norm=Quantity(axis_r_units)) obj.axis_z = make_xarray(equi_dict["axis_z"], norm=Quantity(axis_r_units)) obj.x_point_r = ( make_xarray(equi_dict["x_point_r"], norm=Quantity(x_point_units)) if equi_dict.get("x_point_r") is not None else None ) obj.x_point_z = ( make_xarray(equi_dict["x_point_z"], norm=Quantity(x_point_units)) if equi_dict.get("x_point_z") is not None else None ) obj._axis_Btor = float(equi_dict["axis_Btor"]) obj.psi_axis = equi_dict["psi_axis"] obj.psi_separatrix = equi_dict["psi_separatrix"] obj.spline_basis_r = equi_dict["spline_basis_r"] obj.spline_basis_z = equi_dict["spline_basis_z"] obj.psi_data = equi_dict["psi_data"] obj._rho_min = float(equi_dict["rho_min"]) \ if equi_dict.get("rho_min") is not None else None obj._rho_max = float(equi_dict["rho_max"]) \ if equi_dict.get("rho_max") is not None else None obj.divertor_polygon = equi_dict.get("divertor_polygon") obj.exclusion_polygon = equi_dict.get("exclusion_polygon") obj.flux_limit_polygons = equi_dict.get("flux_limit_polygons", {}) obj.flux_limit_rho_min = equi_dict.get("flux_limit_rho_min", {}) obj.flux_limit_rho_max = equi_dict.get("flux_limit_rho_max", {}) obj.raw = equi_dict.get("raw") return obj
[docs] @classmethod def initialize_from_equi_file(cls, equi_file: Path): """ Create a RawNumericalEquilibrium from a NetCDF equilibrium file. Parameters ---------- equi_file : Path Path to the equilibrium NetCDF file. Returns ------- RawNumericalEquilibrium """ from .io import read_netcdf_equilibrium assert equi_file.exists() and equi_file.suffix == ".nc" obj = cls(read_netcdf_equilibrium(equi_file), equi_file) obj._is_normalized = True obj._is_filtered = True obj._is_padded = True return obj
[docs] def write_to_equi_file( self, file_path: Path, description: str, comment: str = "", allow_overwrite: bool = False, ordering: str = "Fortran", ): """ Write the equilibrium to a NetCDF file. The equilibrium must be normalized before writing. Call normalize(B0) first. Parameters ---------- file_path : Path Output path, must have .nc suffix. description : str Human-readable description written to the file header. comment : str, optional Additional comment written to the file header. allow_overwrite : bool, optional If False, raises FileExistsError if file_path exists. ordering : str, optional Array ordering, "Fortran" or "C". Default is "Fortran". """ self._check_initialized() super().write_to_equi_file( file_path=file_path, description=description, comment=comment, allow_overwrite=allow_overwrite, ordering=ordering, )
[docs] def to_numerical(self) -> NumericalEquilibrium: """ Convert to a NumericalEquilibrium instance. The equilibrium must be normalized before conversion. """ from .numerical_m import NumericalEquilibrium self._check_initialized() equi_dict = { "axis_r": float(np.asarray(self.axis_r)), "axis_r_units": str(self.R0.units), "axis_z": float(np.asarray(self.axis_z)), "axis_Btor": self._axis_Btor, "axis_Btor_units": self._axis_Btor_units, "x_point_r": float(np.asarray(self.x_point_r)) if self.x_point_r is not None else None, "x_point_z": float(np.asarray(self.x_point_z)) if self.x_point_z is not None else None, "x_point_units": str(self.R0.units), "psi_axis": self._psi_axis, "psi_separatrix": self._psi_separatrix, "rho_min": self._rho_min, "rho_max": self._rho_max, "spline_basis_r": np.asarray(self.spline_basis_r), "spline_basis_z": np.asarray(self.spline_basis_z), "psi_data": self._psi_data, "poloidal_field_factor": self.poloidal_field_factor, "divertor_polygon": self.divertor_polygon, "exclusion_polygon": self.exclusion_polygon, "flux_limit_polygons": self.flux_limit_polygons, "flux_limit_rho_min": self.flux_limit_rho_min, "flux_limit_rho_max": self.flux_limit_rho_max, } return NumericalEquilibrium(equi_dict, self.filepath if hasattr(self, "filepath") else Path("."))
def _check_initialized(self): """Check that the equilibrium is normalized before use.""" assert self._is_normalized, ( "RawNumericalEquilibrium must be normalized before use. " "Call normalize() first." ) if not self._is_filtered: warnings.warn( "RawNumericalEquilibrium has not been filtered. " "Consider calling apply_psi_filter() before use.", UserWarning, stacklevel=2, ) if not self._is_padded: warnings.warn( "RawNumericalEquilibrium has not been padded." "Consider calling pad_psi() before use.", UserWarning, stacklevel=2, ) @property def _not_initialized(self): """ Return if the equilibrium has not been initialized. This means that neither functions normalize, filter and pad have been called. Useful to determine if the equilibrium is still "raw" and not "heavily" processed in any means. """ return (not self._is_normalized) \ and (not self._is_filtered) \ and (not self._is_padded)
[docs] def add_flux_limit_polygon( self, name: str, polygon: Polygon2D, rho_min: float = None, rho_max: float = None, ): """ Add a flux limiting polygon with optional rho limits. Exactly one of rho_min or rho_max must be supplied. Parameters ---------- name : str Name of the polygon. polygon : Polygon2D The polygon to add. rho_min : float, optional Minimum flux surface label inside the polygon. rho_max : float, optional Maximum flux surface label inside the polygon. """ assert not (rho_min is None and rho_max is None), \ "Must supply at least one flux limit per polygon" assert rho_min is None or rho_max is None, \ "Should only apply one flux limit per polygon" self.flux_limit_polygons[name] = polygon self.flux_limit_rho_min[name] = rho_min self.flux_limit_rho_max[name] = rho_max
[docs] def pad_psi( self, left: int = 0, right: int = 0, bottom: int = 0, top: int = 0, ) -> None: """ Pad the psi grid and its coordinate vectors. Parameters ---------- left, right, bottom, top: Number of points to add on each side. """ if self._is_padded: warnings.warn( "Cannot pad an already padded equilibrium - skipping.", UserWarning, stacklevel=2, ) return self.spline_basis_r, self.spline_basis_z, self.psi_data = pad_array_with_coords( x_coords=self.spline_basis_r, y_coords=self.spline_basis_z, array=self.psi_data, left=left, right=right, bottom=bottom, top=top, ) self._is_padded = True
[docs] def upsample_psi(self, n_samples: int | tuple): """ Upsample the psi grid to a higher resolution. Interpolates the existing psi profile to a higher resolution grid. Can help to localize numerical noise. Parameters ---------- n_samples : int or tuple of (int, int) Number of sample points. If an int, the same number of points is used in both R and Z directions. If a tuple, the first element is the number of R points and the second is the number of Z points. """ if isinstance(n_samples, tuple): n_r, n_z = n_samples else: n_r = n_samples n_z = n_samples # Skip resampling if grid already matches requested size current_n_r = len(self.spline_basis_r) current_n_z = len(self.spline_basis_z) if n_r == current_n_r and n_z == current_n_z: return r_samples = np.linspace( float(np.min(self.spline_basis_r)), float(np.max(self.spline_basis_r)), num=n_r, ) z_samples = np.linspace( float(np.min(self.spline_basis_z)), float(np.max(self.spline_basis_z)), num=n_z, ) new_psi = self.psi_interpolator(r_samples, z_samples, grid=True).T self.spline_basis_r = r_samples self.spline_basis_z = z_samples self.psi_data = new_psi
[docs] def apply_psi_filter(self, filter_func: callable, **filter_kwargs): """ Apply a filter function to psi_data. Parameters ---------- filter_func : callable Filter function with signature ``filter_func(psi_data, **kwargs) -> np.ndarray``. **filter_kwargs Keyword arguments passed directly to ``filter_func``. Examples -------- >>> equi.apply_psi_filter(fourier_gaussian, filter_strength=10.0, edge_pad=100) >>> equi.apply_psi_filter(gaussian_filter, sigma=2.0) """ if self._is_filtered: warnings.warn( "Cannot filter an already filtered equilibrium - skipping.", UserWarning, stacklevel=2, ) return self._psi_data_unfiltered = self._psi_data.copy() self.psi_interpolator_unfiltered = RectBivariateSpline( self.spline_basis_r, self.spline_basis_z, self._psi_data_unfiltered.T ) self.psi_data = filter_func(self.psi_data, **filter_kwargs) self._is_filtered = True
[docs] def normalize(self, R0: Quantity = None, B0: Quantity = None): """ Normalize the equilibrium from physical to normalized units. Divides all length quantities by R0 and sets the magnetic field normalization to B0. If not provided, R0 defaults to the magnetic axis R position and B0 to the on-axis toroidal field. Parameters ---------- R0 : Quantity, optional Normalization length. Defaults to magnetic axis R position. If a plain float is given, meters are assumed. B0 : Quantity, optional Normalization magnetic field. Defaults to on-axis toroidal field. If a plain float is given, Tesla are assumed. """ if self._is_normalized: warnings.warn( "Cannot normalize an already normalized equilibrium - skipping.", UserWarning, stacklevel=2, ) return if R0 is None: R0 = Quantity(self.axis_r.values, self.axis_r.norm) elif not isinstance(R0, Quantity): R0 = Quantity(R0, "m") if B0 is None: B0 = Quantity(self._axis_Btor, self._axis_Btor_units or "T") elif not isinstance(B0, Quantity): B0 = Quantity(B0, "T") self.R0 = R0 self.B0 = B0 self._norm_length = R0 self._norm_magfield = B0 R0_val = R0.m # NOTE: No need to scale axis or x point coords since those # have auto normalized properties stored anyway. Only # spline basis and polygons are required. self.spline_basis_r = self.spline_basis_r / R0_val self.spline_basis_z = self.spline_basis_z / R0_val if self.divertor_polygon is not None: self.divertor_polygon.x_points = self.divertor_polygon.x_points / R0_val self.divertor_polygon.y_points = self.divertor_polygon.y_points / R0_val if self.exclusion_polygon is not None: self.exclusion_polygon.x_points = self.exclusion_polygon.x_points / R0_val self.exclusion_polygon.y_points = self.exclusion_polygon.y_points / R0_val for polygon in self.flux_limit_polygons.values(): polygon.x_points = polygon.x_points / R0_val polygon.y_points = polygon.y_points / R0_val self.psi_interpolator = RectBivariateSpline( self.spline_basis_r, self.spline_basis_z, self._psi_data.T ) self._b_pol_interpolator = self._build_b_pol_interpolator() self._is_normalized = True
@property def poloidal_magnetic_field_interpolator(self) -> RectBivariateSpline: """Interpolator of the poloidal magnetic field magnitude.""" return self._b_pol_interpolator @NumericalEquilibrium.psi_axis.setter def psi_axis(self, value): """Poloidal flux at magnetic axis.""" self._psi_axis = None if value is None else float(value) @NumericalEquilibrium.psi_separatrix.setter def psi_separatrix(self, value): """Poloidal flux at separatrix.""" self._psi_separatrix = None if value is None else float(value) @property def rho_min(self): """Minimum flux surface label.""" if self._is_normalized: return super().rho_min return self._rho_min @rho_min.setter def rho_min(self, value): self._rho_min = None if value is None else float(value) @property def rho_max(self): """Maximum flux surface label.""" if self._is_normalized: return super().rho_max return self._rho_max @rho_max.setter def rho_max(self, value): self._rho_max = None if value is None else float(value) @property def axis_Btor(self): """On-axis toroidal magnetic field.""" if self._is_normalized: return super().axis_Btor return self._axis_Btor @axis_Btor.setter def axis_Btor(self, value): """Set the on-axis toroidal field as a plain float in Tesla.""" self._axis_Btor = None if value is None else float(value) @property def axis_r_norm(self): """Magnetic axis radial position in normalized units.""" self._check_initialized() return super().axis_r_norm @property def axis_z_norm(self): """Magnetic axis vertical position in normalized units.""" self._check_initialized() return super().axis_z_norm @property def psi_data(self): """Raw poloidal flux data on the grid in Weber.""" return self._psi_data @psi_data.setter def psi_data(self, value: np.ndarray): if self._spline_basis_r is None or self._spline_basis_z is None: raise RuntimeError( "Cannot set psi_data before spline_basis_r and " "spline_basis_z are set." ) self._psi_data = np.asarray(value, dtype=float) self.psi_interpolator = RectBivariateSpline( self.spline_basis_r, self.spline_basis_z, self._psi_data.T ) self._b_pol_interpolator = self._build_b_pol_interpolator() @property def R0(self) -> Quantity: """Length normalization.""" return self._norm_length @R0.setter def R0(self, value: Quantity): self._norm_length = value @property def B0(self) -> Quantity: """Magnetic field normalization.""" return self._norm_magfield @B0.setter def B0(self, value: Quantity): self._norm_magfield = value @property def psi_units(self): """Units of the poloidal flux.""" return "Wb" @property def spline_basis_r(self): """R grid vector in meters (before normalization) or normalized units.""" return self._spline_basis_r @spline_basis_r.setter def spline_basis_r(self, value: np.ndarray): value = np.asarray(value, dtype=float) assert np.all(np.diff(value) > 0), \ "spline_basis_r must be strictly increasing" self._spline_basis_r = value @property def spline_basis_z(self): """Z grid vector in meters (before normalization) or normalized units.""" return self._spline_basis_z @spline_basis_z.setter def spline_basis_z(self, value: np.ndarray): value = np.asarray(value, dtype=float) assert np.all(np.diff(value) > 0), \ "spline_basis_z must be strictly increasing" self._spline_basis_z = value