Source code for torx.equilibrium.flare_m

"""Contains FLARE equilibrium."""
from pathlib import Path
import numpy as np
import xarray as xr
from typing import Sequence

from ._flare_coilset_loader_m import load_coilsets
from .check_equilibrium_m import FlareEquiType
from .equilibrium_m import EquilibriumBaseClass

from torx.units import Quantity
from torx.arrays import make_xarray
from torx.units import check_units
from torx.decorators import autodoc_function, autogrid_method

from torx.grid import Grid2D
from torx.fileio import filepath_resolver

def _add_phi_doc(func):
    """Append phi documentation to a function's docstring."""
    phi_doc = """
    Other Parameters
    ----------------
    phi_index : int or float, optional
        Plane index. Converted to a toroidal angle for FLARE in radians 
        based on the grid resolution and whether only the first field period 
        is used.
    phi : float, optional
        Physical toroidal angle in radians. If only the first field
        period is simulated, the angle is mapped into 
        [0, 2*pi/num_field_periods).

    Exactly one of phi_index or phi must be provided.
    """
    if func.__doc__:
        func.__doc__ += phi_doc
    return func

_active_flare_equilibrium = None

@autodoc_function
def free_active_flare_equilibrium():
    """
    Free the shared FLARE model held by the active FlareEquilibrium.

    Helper function which is safe to call even if no FlareEquilibrium is 
    currently loaded. Checks if there is an active FLARE equilibrium loaded in 
    memory and if so, frees the model.
    """
    if _active_flare_equilibrium is not None:
        _active_flare_equilibrium.free()

[docs] class FlareEquilibrium(EquilibriumBaseClass): """ Class for equilibria generated by the FLARE tool [2]_. The FLARE code is a magnetic mesh generator that is integrated within a suite of tools for the analysis of the magnetic geometry in toroidal fusion devices [1]_. References ---------- .. [1] H. Frerichs 2024 Nucl. Fusion 64 106034, https://doi.org/10.1088/1741-4326/ad7303 .. [2] https://hfrerichs.gitlab.io/flare """
[docs] def __init__( self, filepath: Path=Path("./"), rhomin: float=0.0, rhomax: float=1.0, B0: Quantity=Quantity("1T"), L_ref: float=1.0, equi_flare_type: str="", boundary_flare_type: str="", rho_flare_type: str="", flare_rho_dirpath: str="", flare_rho_prefix: str="", rho_symms: int=1, flare_equi3d_path_mgrid: Path=Path(""), flare_equi3d_path_axis: Path=Path(""), flare_equi3d_path_bfield: Path=Path(""), flare_equi3d_path_hint: Path=Path(""), Rmin: float=0.0, Rmax: float=1.0, Zmin: float=-1.0, Zmax: float=1.0, n_points_phi: int=1, only_first_field_period: bool=False, amplitudes: list=[15000, 15000, 15000, 15000, 15000], bspline_data: str="magnetic_field", spline_order: int=3, num_field_periods: int=5, group: int=-1, bmax: float=5.0, bnd_paths: list=[], **kwargs ): """ Initialize the FlareEquilibrium. Compatible with mgrid, coilset and HINT FLARE equilibrium types. """ global _active_flare_equilibrium if ( _active_flare_equilibrium is not None and not _active_flare_equilibrium._freed ): raise RuntimeError( "Another FlareEquilibrium is already loaded from the " "shared FLARE model. Free up the loaded model before " "allocating a new one." ) from torx.specializations.flare import import_flare self._model, self._bfield, self._analysis = import_flare() self._freed = False _active_flare_equilibrium = self # Legacy correction if equi_flare_type == "equi3d": equi_flare_type = "equi3d_mgrid" filepath = Path(filepath) paths = { "axis": flare_equi3d_path_axis, "bfield": flare_equi3d_path_bfield, "hint": flare_equi3d_path_hint, "mgrid": flare_equi3d_path_mgrid, } paths = { key: p if (p := Path(path)).is_absolute() else filepath / p for key, path in paths.items() } self.__equi_name__ = "FlareEquilibrium" self.filepath = filepath self.rhomin = rhomin self.rhomax = rhomax self.B0 = B0 self.equi_flare_type = equi_flare_type self.boundary_flare_type = boundary_flare_type self.rho_flare_type = rho_flare_type self.flare_rho_dirpath = flare_rho_dirpath self.flare_rho_prefix = flare_rho_prefix self.rho_symms = rho_symms self._norm_length = Quantity(L_ref, "m") self.flare_equi3d_path_mgrid = paths["mgrid"] self.flare_equi3d_path_axis = paths["axis"] self.flare_equi3d_path_bfield = paths["bfield"] self.flare_equi3d_path_hint = paths["hint"] self.Rmin = Rmin self.Rmax = Rmax self.Zmin = Zmin self.Zmax = Zmax self.n_points_phi = n_points_phi self.only_first_field_period = only_first_field_period self.num_field_periods = num_field_periods self.amplitudes = amplitudes self.bspline_data = bspline_data self.spline_order = spline_order self.group = group self.bmax = bmax self.bnd_paths = bnd_paths if self.boundary_flare_type == "kisslinger": boundary_path = Path(self.bnd_paths[0]).parents[0] if not boundary_path.is_absolute(): boundary_path = self.filepath / boundary_path boundary = self._model.boundary_config(str(boundary_path)) if FlareEquiType(equi_flare_type) == FlareEquiType.equi3d_mgrid: flare_model = self._model.Equi3d_Mgrid(str(paths["mgrid"]), amplitudes, bspline_data, spline_order ) elif FlareEquiType(equi_flare_type) == FlareEquiType.equi3d_coilset: flare_model = load_coilsets(paths["bfield"]) elif FlareEquiType(equi_flare_type) == FlareEquiType.equi3d_hint: flare_model = self._model.Equi3d_Hint(str(paths["hint"]), group, bmax) else: raise NotImplementedError(f"{equi_flare_type} not implemented.") if self.boundary_flare_type == "kisslinger": self._model.init_equi3d_parallax(flare_model, str(paths["axis"]), boundary=boundary) else: self._model.init_equi3d_parallax(flare_model, str(paths["axis"])) self._norm_length = Quantity(self.axis_r_flare, "m") self.Z0 = Quantity(self.axis_z_flare, "m") self._norm_magfield = Quantity(self.Btor0, "T")
def free(self): """ Free the underlying FLARE model memory. Checks if there is an existing model first, so is safe to call multiple times. The FLARE model is a single global resource, so this must not be called while another `FlareEquilibrium` built from the same model is still in use. """ global _active_flare_equilibrium if not self._freed: self._model.free() self._freed = True if _active_flare_equilibrium is self: _active_flare_equilibrium = None def __enter__(self): """Return self for use as a context manager.""" return self def __exit__(self, exc_type, exc_value, traceback): """Free the underlying FLARE model memory on context exit.""" self.free() def _read_phi(self, **kwargs): """Read, scale and convert phi to radians for FLARE.""" if self._freed: raise RuntimeError( "This FlareEquilibrium's FLARE model has already been " "freed and can no longer be used." ) phi_index = kwargs.get("phi_index", None) phi = kwargs.get("phi", None) if (phi_index is None) == (phi is None): raise TypeError( "Exactly one of 'phi_index' or 'phi' must be provided." ) if phi_index is not None: if self.only_first_field_period: phi = 2 * np.pi / (self.num_field_periods * self.n_points_phi) \ * phi_index else: phi = 2 * np.pi / self.n_points_phi * phi_index elif phi is not None: if self.only_first_field_period: phi = phi % (2 * np.pi / self.num_field_periods) else: phi = phi % (2 * np.pi) return phi def _denormalize_flare(self, r_norm, z_norm): """Denormalize lengths for FLARE functions.""" r_values = r_norm * self.axis_r_flare z_values = z_norm * self.axis_r_flare + self.axis_z_flare return r_values, z_values def _normalize_flare(self, r_values, z_values): """Normalize lengths from FLARE function inputs.""" r_norm = r_values / self.axis_r_flare z_norm = (z_values - self.axis_z_flare) / self.axis_r_flare return r_norm, z_norm def axis_r(self, **kwargs): """Magnetic axis radial position in non-normalized units.""" phi = self._read_phi(**kwargs) axis = self._bfield.f2py.analysis.magnetic_axis(phi) return axis[0] def axis_z(self, **kwargs): """Magnetic axis vertical position in non-normalized units.""" phi = self._read_phi(**kwargs) axis = self._bfield.f2py.analysis.magnetic_axis(phi) return axis[1] def Btor_axis(self, **kwargs): """Return the toroidal magnetic field strength on axis.""" phi = self._read_phi(**kwargs) return self._bfield.eval(self.axis_r_flare, 0.0, phi)[2] @property def axis_r_flare(self): """Unnormalized magnetic axis radial position on first plane.""" return self.axis_r(phi_index = 0) @property def axis_z_flare(self): """Unnormalized magnetic axis vertical position on first plane.""" return self.axis_z(phi_index = 0) @property def Btor0(self): """Toroidal magnetic field strength on axis for first plane.""" return self.Btor_axis(phi_index = 0) @property def axis_r_norm(self): """Magnetic axis radial position in normalized units.""" return make_xarray(1.0) @property def axis_z_norm(self): """Magnetic axis vertical position in normalized units.""" return make_xarray(0.0) @property def axis_phi(self): """Magnetic axis toroidal position.""" return make_xarray(0.0) @property def B0(self) -> Quantity: """Magnetic field normalization.""" return self._norm_magfield @B0.setter def B0(self, value: Quantity): """Set the magnetic field normalization.""" check_units(value, {"[mass]":1, "[time]":-2, "[current]":-1}, "B0") self._norm_magfield = value @property def R0(self) -> Quantity: """Length normalization.""" return self._norm_length @R0.setter def R0(self, value: Quantity): """Set length normalization.""" check_units(value, {"[length]":1}, "R0") self._norm_length = value @autogrid_method def _magfield_all_components_flare(self, r_norm, z_norm, **kwargs) \ -> xr.DataArray: """ Evaluate the magnetic field using FLARE on the given points. Returns all three magnetic field components from FLARE. Compatible with single values, structured grids and unstructured grids. """ phi = self._read_phi(**kwargs) if (isinstance(r_norm, xr.DataArray) and "points" in r_norm.dims) or kwargs.get("is_unstructured"): return self._magfield_unstructured(r_norm, z_norm, phi=phi) else: return self._magfield_structured(r_norm, z_norm, phi=phi) @autogrid_method def _magfield_unstructured(self, r_norm, z_norm, **kwargs) -> xr.DataArray: """B components evaluated for unstructured arrays from FLARE.""" phi = kwargs["phi"] r_norm = self.convert_length_to_normalized(r_norm) z_norm = self.convert_length_to_normalized(z_norm) R_size = r_norm.size R_flare, Z_flare = self._denormalize_flare(r_norm, z_norm) phi_vec = np.full(R_size, phi) B_flare = self._bfield.eval(R_flare, Z_flare, phi_vec) / self.Btor0 B_flare = np.asarray(B_flare) B_flare = Quantity(B_flare, "T") return xr.DataArray( B_flare, attrs={"norm": self.B0}, dims=("points", "component"), coords={ "component": ["Br", "Bz", "Bphi"] }, ) @autogrid_method def _magfield_structured(self, r_norm, z_norm, **kwargs) -> xr.DataArray: """B components evaluated for structured arrays or values from FLARE.""" phi = kwargs["phi"] if isinstance(r_norm, xr.DataArray): r_norm = self.convert_length_to_normalized(r_norm) z_norm = self.convert_length_to_normalized(z_norm) r_norm = np.atleast_1d(r_norm) z_norm = np.atleast_1d(z_norm) Nr, Nz = r_norm.size, z_norm.size R_vals, Z_vals = self._denormalize_flare(r_norm, z_norm) R_flare = R_vals[:, None] Z_flare = Z_vals[None, :] R_flare, Z_flare = np.broadcast_arrays(R_flare, Z_flare) phi_grid = np.full(R_flare.shape, phi) B_flare = self._bfield.eval(R_flare.ravel(), Z_flare.ravel(), phi_grid.ravel() ) \ / self.Btor0 B_flare = np.asarray(B_flare) if B_flare.ndim == 1: B_flare = B_flare[None, :] B_flare = B_flare.reshape(Nr, Nz, B_flare.shape[-1]).transpose(1,0,2) B_flare = Quantity(B_flare, "T") return xr.DataArray( B_flare, attrs={"norm": self.B0}, dims=("Z", "R", "component"), coords={"component": ["Br", "Bz", "Bphi"]}, ) @autogrid_method @_add_phi_doc def magfield_component_r(self, r_norm, z_norm, **kwargs) \ -> xr.DataArray: """ Return the radial component of the FLARE magnetic field B. Parameters ---------- r_norm : xr.DataArray or float Normalized radial coordinates. Can be a float, structured data or unstructured data. z_norm : xr.DataArray or float Normalized vertical coordinates. Can be a float, structured data or unstructured data. """ all_components = self._magfield_all_components_flare( r_norm, z_norm, **kwargs) component = all_components.sel(component="Br").drop_vars("component") return component.squeeze(drop=True) @autogrid_method @_add_phi_doc def magfield_component_z(self, r_norm, z_norm, **kwargs) \ -> xr.DataArray: """ Return the vertical component of the FLARE magnetic field B. Parameters ---------- r_norm : xr.DataArray or float Normalized radial coordinates. Can be a float, structured data or unstructured data. z_norm : xr.DataArray or float Normalized vertical coordinates. Can be a float, structured data or unstructured data. """ all_components = self._magfield_all_components_flare( r_norm, z_norm, **kwargs) component = all_components.sel(component="Bz").drop_vars("component") return component.squeeze(drop=True) @autogrid_method @_add_phi_doc def magfield_component_toroidal(self, r_norm, z_norm, **kwargs) \ -> xr.DataArray: """ Return the toroidal component of the FLARE magnetic field B. Parameters ---------- r_norm : xr.DataArray or float Normalized radial coordinates. Can be a float, structured data or unstructured data. z_norm : xr.DataArray or float Normalized vertical coordinates. Can be a float, structured data or unstructured data. """ all_components = self._magfield_all_components_flare( r_norm, z_norm, **kwargs) component = all_components.sel(component="Bphi").drop_vars("component") return component.squeeze(drop=True) def get_boundary_polygon(self, **kwargs): """ Return the boundary polygon. Todo ---- Not yet implemented. """ raise NotImplementedError def normalized_flux_surface_label(self, r_norm, z_norm, **kwargs): """ Return the normalized flux surface label. Todo ---- Not yet implemented. """ raise NotImplementedError def make_trace_eq(self): """ Not supported for FlareEquilibrium. Raises ------ NotImplementedError Always. Use ``torx.analysis.flare_fieldline_trace`` instead, which calls the FLARE backend directly. """ raise NotImplementedError( "FastTracer is not supported for FlareEquilibrium. " "Use torx.analysis.flare_fieldline_trace to trace field lines " "via the FLARE backend." )