# coding: utf-8
"Base classes for mapped domains (single patch)."
import copy
import inspect
import logging
from abc import ABCMeta, abstractmethod
import cunumpy as xp
import h5py
from pyvista import Plotter, StructuredGrid
from scipy.sparse import csc_matrix, kron
from scipy.sparse.linalg import splu, spsolve
import struphy.bsplines.bsplines as bsp
from struphy.geometry import evaluation_kernels, transform_kernels
from struphy.kernel_arguments.pusher_args_kernels import DomainArguments
from struphy.linear_algebra import linalg_kron
from struphy.utils.utils import __class_with_params_repr_no_defaults__, all_class_params_are_default, all_subclasses
logger = logging.getLogger("struphy")
[docs]
class DomainMeta(ABCMeta):
def __iter__(cls):
return iter(all_subclasses(cls))
[docs]
class Domain(metaclass=DomainMeta):
r"""
Abstract base class for parametric domains in plasma simulations (single patch).
The Domain class represents a computational domain through a parametric mapping from a logical unit cube
to a physical region. This supports both analytical mappings (cylindrical, toroidal, Shafranov) and
spline-based isogeometric analysis (IGA) mappings.
Mathematical Background
-----------------------
The (physical) domain :math:`\Omega \subset \mathbb R^3` is an open subset of :math:`\mathbb R^3`,
defined by a diffeomorphism
.. math::
F:(0, 1)^3 \to \Omega\,,\qquad \boldsymbol{\eta} \mapsto F(\boldsymbol \eta) = \mathbf x\,,
mapping points :math:`\boldsymbol{\eta} \in (0, 1)^3 = \hat\Omega` of the (logical)
unit cube to physical points :math:`\mathbf x \in \Omega`.
The corresponding Jacobian matrix :math:`DF:\hat\Omega \to \mathbb R^{3\times 3}`,
volume element :math:`\sqrt g: \hat\Omega \to \mathbb R`, and metric tensor
:math:`G:\hat\Omega \to \mathbb R^{3\times 3}` are defined by
.. math::
DF_{i,j} = \frac{\partial F_i}{\partial \eta_j}\,,\qquad \sqrt g = |\textnormal{det}(DF)|\,,\qquad G = DF^\top DF\,.
Only right-handed mappings (:math:`\textnormal{det}(DF) > 0`) are admitted.
Attributes
----------
kind_map : int
Mapping type identifier:
- 0-9: Spline (IGA) mappings
- 10-19: Analytical mappings with cubic domain boundary
- 20-29: Cylinder and torus analytical mappings
- 30-39: Shafranov mappings (tokamak equilibrium)
params : dict
Mapping parameters as a dictionary for reference.
params_numpy : ndarray
Mapping parameters as a 1D numpy array for efficient computation.
pole : bool
Whether the mapping has one polar singularity point.
periodic_eta3 : bool
Whether the domain is periodic in the :math:`\eta_3` direction.
cx, cy, cz : ndarray
Control points for spline mapping components :math:`F_x`, :math:`F_y`, :math:`F_z` (3D arrays).
num_elements : tuple[int]
Number of elements in each logical direction (for spline mappings).
degree : tuple[int]
B-spline degree in each direction (for spline mappings).
spl_kind : tuple[bool]
Spline type in each direction: True for periodic, False for clamped (for spline mappings).
NbaseN : list[int]
Number of basis functions in each direction.
T : list[ndarray]
Knot vectors for each direction.
indN : list[ndarray]
Global indices of non-vanishing splines per element.
Methods
-------
__call__(*etas, change_out_order, squeeze_out, remove_outside, identity_map)
Evaluate the physical coordinates from logical coordinates using mapping F.
jacobian(*etas, transposed, change_out_order, squeeze_out, remove_outside)
Evaluate the Jacobian matrix DF at logical coordinates.
jacobian_det(*etas, squeeze_out, remove_outside)
Evaluate the Jacobian determinant (volume element) at logical coordinates.
jacobian_inv(*etas, transposed, change_out_order, squeeze_out, remove_outside)
Evaluate the inverse Jacobian matrix at logical coordinates.
metric_tensor(*etas, change_out_order, squeeze_out, remove_outside)
Evaluate the metric tensor G = DF^T * DF.
pull_back_0form(field, *etas, remove_outside)
Pull back scalar fields (0-forms) from physical to logical space.
push_forward_1form(field, *etas, remove_outside)
Push forward vector fields (1-forms) from logical to physical space.
transform(input_field, trans_type, *etas, remove_outside)
General transformation between different field representations.
Notes
-----
This is an abstract base class. Concrete implementations should be created in the
`struphy.geometry.domains` module and specify the mapping via the `kind_map` property
and mapping parameters.
The logical coordinates (eta1, eta2, eta3) must lie in (0, 1)^3. Points outside this
range are typically flagged with value -1 in outputs, or optionally removed.
Examples
--------
Concrete domain implementations can be created and used as follows:
>>> domain = Cuboid() # Simple cubic domain
>>> x = domain(0.5, 0.5, 0.5) # Evaluate mapping at logical coordinates
>>> J = domain.jacobian(0.5, 0.5, 0.5) # Evaluate Jacobian matrix
>>> detJ = domain.jacobian_det(0.5, 0.5, 0.5) # Volume element
"""
def __init__(
self,
num_elements: tuple[int] = None,
degree: tuple[int] = None,
spl_kind: tuple[bool] = None,
):
if num_elements is None or degree is None or spl_kind is None:
assert self.kind_map >= 10, "Spline mappings must define num_elements, degree and spl_kind."
num_elements = (1, 1, 1)
degree = (1, 1, 1)
spl_kind = (True, True, True)
# create IGA attributes
self._num_elements = num_elements
self._degree = degree
self._spl_kind = spl_kind
self._NbaseN = [
num_elements + degree - kind * degree for num_elements, degree, kind in zip(num_elements, degree, spl_kind)
]
el_b = [xp.linspace(0.0, 1.0, num_elements + 1) for num_elements in num_elements]
self._T = [bsp.make_knots(el_b, degree, kind) for el_b, degree, kind in zip(el_b, degree, spl_kind)]
self._indN = [
(xp.indices((num_elements, degree + 1))[1] + xp.arange(num_elements)[:, None]) % NbaseN
for num_elements, degree, NbaseN in zip(num_elements, degree, self._NbaseN)
]
# extend to 3d for 2d IGA mappings
if 0 < self.kind_map < 10:
self._num_elements = (*self._num_elements, 0)
self._degree = (*self._degree, 0)
self._NbaseN = self._NbaseN + [0]
self._T = self._T + [xp.zeros((1,), dtype=float)]
self._indN = self._indN + [xp.zeros((1, 1), dtype=int)]
# create dummy attributes for analytical mappings
if self.kind_map >= 10:
self._cx = xp.zeros((1, 1, 1), dtype=float)
self._cy = xp.zeros((1, 1, 1), dtype=float)
self._cz = xp.zeros((1, 1, 1), dtype=float)
self._transformation_ids = {
"pull": 0,
"push": 1,
"tran": 2,
}
# keys for performing pull-backs and push-forwards
dict_pullpush = {
"0": 0,
"3": 1,
"1": 10,
"2": 11,
"v": 12,
}
# keys for performing transformation
dict_tran = {
"0_to_3": 0,
"3_to_0": 1,
"1_to_2": 10,
"2_to_1": 11,
"norm_to_v": 12,
"norm_to_1": 13,
"norm_to_2": 14,
"v_to_1": 15,
"v_to_2": 16,
"1_to_v": 17,
"2_to_v": 18,
}
self._dict_transformations = {
"pull": dict_pullpush,
"push": dict_pullpush,
"tran": dict_tran,
}
self._args_domain = DomainArguments(
self.kind_map,
self.params_numpy,
xp.array(self.degree),
self.T[0],
self.T[1],
self.T[2],
self.indN[0],
self.indN[1],
self.indN[2],
self.cx.copy(), # make sure we don't have stride = 0
self.cy.copy(), # make sure we don't have stride = 0
self.cz.copy(), # make sure we don't have stride = 0
)
def _build_args_domain(self):
"""Build runtime mapping arguments used by compiled evaluation kernels."""
return DomainArguments(
self.kind_map,
self.params_numpy,
xp.array(self.degree),
self.T[0],
self.T[1],
self.T[2],
self.indN[0],
self.indN[1],
self.indN[2],
self.cx.copy(), # make sure we don't have stride = 0
self.cy.copy(), # make sure we don't have stride = 0
self.cz.copy(), # make sure we don't have stride = 0
)
def _can_build_args_domain(self):
required_attrs = (
"_kind_map",
"_degree",
"_T",
"_indN",
"_cx",
"_cy",
"_cz",
)
return all(hasattr(self, attr) for attr in required_attrs)
def _rebuild_args_domain(self):
if self._can_build_args_domain():
self._args_domain = self._build_args_domain()
else:
self._args_domain = None
def __deepcopy__(self, memo):
cls = self.__class__
result = cls.__new__(cls)
memo[id(self)] = result
for key, value in self.__dict__.items():
if key == "_args_domain":
continue
setattr(result, key, copy.deepcopy(value, memo))
result._rebuild_args_domain()
return result
def __getstate__(self):
state = self.__dict__.copy()
state.pop("_args_domain", None)
return state
def __setstate__(self, state):
self.__dict__.update(state)
self._args_domain = None
self._rebuild_args_domain()
[docs]
def __repr__(self):
out = f"{self.__class__.__name__}(\n"
for k, v in self.params.items():
out += " " * 4
out += f"{k}={v},\n"
out += ")"
return out
def __repr_no_defaults__(self):
return __class_with_params_repr_no_defaults__(self)
@property
def is_default(self):
return all_class_params_are_default(self)
@property
def kind_map(self) -> int:
"""Integer defining the mapping:
* <=9: spline mappings
* >=10 and <=19: analytical mappings with cubic domain boundary
* >=20 and <=29: analytical cylinder and torus mappings
* >=30 and <=39: Shafranov mappings (cylinder)"""
if not hasattr(self, "_kind_map"):
raise AttributeError("Must set 'self.kind_map' for mappings.")
return self._kind_map
@kind_map.setter
def kind_map(self, new):
assert isinstance(new, int)
self._kind_map = new
@property
def params(self) -> dict:
"""Mapping parameters passed to __init__() of the class in domains.py, as dictionary."""
if not hasattr(self, "_params"):
self._params = {}
return self._params
@params.setter
def params(self, new):
assert isinstance(new, dict)
if "self" in new:
new.pop("self")
if "__class__" in new:
new.pop("__class__")
self._params = new
@property
def params_numpy(self) -> xp.ndarray:
"""Mapping parameters as numpy array (can be empty)."""
if not hasattr(self, "_params_numpy"):
self._params_numpy = xp.array([0], dtype=float)
return self._params_numpy
@params_numpy.setter
def params_numpy(self, new):
assert isinstance(new, xp.ndarray)
assert new.ndim == 1
self._params_numpy = new
@property
def pole(self) -> bool:
"""Bool; True if mapping has one polar point."""
if not hasattr(self, "_pole"):
self._pole = False
return self._pole
@pole.setter
def pole(self, new):
assert isinstance(new, bool)
self._pole = new
@property
def periodic_eta3(self) -> bool:
r"""Bool; True if mapping is periodic in :math:`\eta_3` coordinate."""
if not hasattr(self, "_periodic_eta3"):
raise AttributeError("Must specify whether mapping is periodic in eta3.")
return self._periodic_eta3
@periodic_eta3.setter
def periodic_eta3(self, new):
assert isinstance(new, bool)
self._periodic_eta3 = new
@property
def cx(self):
"""3d array of control points for first mapping component :math:`F_x`."""
return self._cx
@property
def cy(self):
"""3d array of control points for second mapping component :math:`F_y`."""
return self._cy
@property
def cz(self):
"""3d array of control points for third mapping component :math:`F_z`."""
return self._cz
@property
def num_elements(self):
"""List of number of elements in each direction."""
return self._num_elements
@property
def degree(self):
"""List of spline degrees in each direction."""
return self._degree
@property
def spl_kind(self):
"""List of spline type (True=periodic, False=clamped) in each direction."""
return self._spl_kind
@property
def NbaseN(self):
"""List of number of basis functions for N-splines in each direction."""
return self._NbaseN
@property
def T(self):
"""List of knot vectors for N-splines in each direction."""
return self._T
@property
def indN(self):
"""Global indices of non-vanishing splines in each element. Can be accessed via (element index, local spline index)."""
return self._indN
@property
def args_domain(self):
"""Object for all parameters needed for evaluation of metric coefficients."""
if getattr(self, "_args_domain", None) is None:
self._rebuild_args_domain()
if self._args_domain is None:
raise AttributeError("DomainArguments are not available because the domain state is incomplete.")
return self._args_domain
@property
def dict_transformations(self):
"""Dictionary of str->int for pull, push and transformation functions."""
return self._dict_transformations
[docs]
def __call__(
self,
*etas,
change_out_order=False,
squeeze_out=False,
remove_outside=True,
identity_map=False,
):
r"""
Evaluates the mapping :math:`F : (0, 1)^3 \to \mathbb R^3,\, \boldsymbol \eta \mapsto \mathbf x`.
Logical coordinates outside of :math:`(0, 1)^3` are evaluated to -1.
The type of evaluation depends on the shape of the input ``etas``.
Parameters
----------
*etas : array-like | tuple
Logical coordinates at which to evaluate. Two cases are possible:
1. 2d numpy array, where coordinates are taken from eta1 = etas[:, 0], eta2 = etas[:, 1], etc. (like markers).
2. list/tuple (eta1, eta2, ...), where eta1, eta2, ... can be float or array-like of various shapes.
change_out_order : bool
If True, the axis corresponding to x, y, z coordinates in the output array is the last one, otherwise the first one.
squeeze_out : bool
Whether to remove singleton dimensions in output array.
remove_outside : bool
If True, logical coordinates outside of (0, 1)^3 are NOT evaluated to -1 and are removed in the output array.
identity_map : bool
If True, not the mapping F, but the identity map (0, 1)^3 --> (0, 1)^3 is evaluated
Returns
-------
out : ndarray | float
The Cartesian coordinates corresponding to the given logical ones.
"""
if identity_map:
which = -1
else:
which = 0
return self._evaluate_metric_coefficient(
*etas,
which=which,
change_out_order=change_out_order,
squeeze_out=squeeze_out,
remove_outside=remove_outside,
)
[docs]
def jacobian(
self,
*etas,
transposed=False,
change_out_order=False,
squeeze_out=False,
remove_outside=True,
):
r"""
Evaluates the Jacobian matrix :math:`DF : (0, 1)^3 \to \mathbb R^{3 \times 3}`.
Logical coordinates outside of :math:`(0, 1)^3` are evaluated to -1.
Parameters
----------
transposed : bool
If True, the transposed Jacobian matrix is evaluated.
change_out_order : bool
If True, the axes corresponding to the 3x3 entries in the output array are the last two, otherwise the first two.
squeeze_out : bool
Whether to remove singleton dimensions in output array.
remove_outside : bool
If True, logical coordinates outside of (0, 1)^3 are NOT evaluated to -1 and are removed in the output array.
Returns
-------
out : ndarray | float
The Jacobian matrix evaluated at given logical coordinates.
"""
return self._evaluate_metric_coefficient(
*etas,
which=1,
change_out_order=change_out_order,
squeeze_out=squeeze_out,
transposed=transposed,
remove_outside=remove_outside,
)
[docs]
def jacobian_det(
self,
*etas,
squeeze_out=False,
remove_outside=True,
):
r"""
Evaluates the Jacobian determinant :math:`\sqrt g : (0, 1)^3 \to \mathbb R^+` (only right-handed mappings allowed).
Logical coordinates outside of :math:`(0, 1)^3` are evaluated to -1.
Parameters
----------
*etas : array-like | tuple
Logical coordinates at which to evaluate. Two cases are possible:
1. 2d numpy array, where coordinates are taken from eta1 = etas[:, 0], eta2 = etas[:, 1], etc. (like markers).
2. list/tuple (eta1, eta2, ...), where eta1, eta2, ... can be float or array-like of various shapes.
squeeze_out : bool
Whether to remove singleton dimensions in output array.
remove_outside : bool
If True, logical coordinates outside of (0, 1)^3 are NOT evaluated to -1 and are removed in the output array.
Returns
-------
out : ndarray | float
The Jacobian determinant evaluated at given logical coordinates.
"""
return self._evaluate_metric_coefficient(
*etas,
which=2,
squeeze_out=squeeze_out,
remove_outside=remove_outside,
)
[docs]
def jacobian_inv(
self,
*etas,
transposed=False,
change_out_order=False,
squeeze_out=False,
remove_outside=True,
avoid_round_off=True,
):
r"""
Evaluates the inverse Jacobian matrix :math:`DF^{-1} : (0, 1)^3 \to \mathbb R^{3 \times 3}`.
Logical coordinates outside of :math:`(0, 1)^3` are evaluated to -1.
Parameters
----------
*etas : array-like | tuple
Logical coordinates at which to evaluate. Two cases are possible:
1. 2d numpy array, where coordinates are taken from eta1 = etas[:, 0], eta2 = etas[:, 1], etc. (like markers).
2. list/tuple (eta1, eta2, ...), where eta1, eta2, ... can be float or array-like of various shapes.
transposed : bool
If True, the transposed Jacobian matrix is evaluated.
change_out_order : bool
If True, the axes corresponding to the 3x3 entries in the output array are the last two, otherwise the first two.
squeeze_out : bool
Whether to remove singleton dimensions in output array.
remove_outside : bool
If True, logical coordinates outside of (0, 1)^3 are NOT evaluated to -1 and are removed in the output array.
avoid_round_off : bool
Whether to manually set exact zeros in arrays.
Returns
-------
out : ndarray | float
The inverse Jacobian matrix evaluated at given logical coordinates.
"""
return self._evaluate_metric_coefficient(
*etas,
which=3,
change_out_order=change_out_order,
squeeze_out=squeeze_out,
transposed=transposed,
remove_outside=remove_outside,
avoid_round_off=avoid_round_off,
)
[docs]
def metric(
self,
*etas,
transposed=False,
change_out_order=False,
squeeze_out=False,
remove_outside=True,
avoid_round_off=True,
):
r"""
Evaluates the metric tensor :math:`G: (0, 1)^3 \to \mathbb R^{3\times 3}`.
Logical coordinates outside of :math:`(0, 1)^3` are evaluated to -1.
Parameters
----------
*etas : array-like | tuple
Logical coordinates at which to evaluate. Two cases are possible:
1. 2d numpy array, where coordinates are taken from eta1 = etas[:, 0], eta2 = etas[:, 1], etc. (like markers).
2. list/tuple (eta1, eta2, ...), where eta1, eta2, ... can be float or array-like of various shapes.
transposed : bool
If True, the transposed Jacobian matrix is evaluated.
change_out_order : bool
If True, the axes corresponding to the 3x3 entries in the output array are the last two, otherwise the first two.
squeeze_out : bool
Whether to remove singleton dimensions in output array.
remove_outside : bool
If True, logical coordinates outside of (0, 1)^3 are NOT evaluated to -1 and are removed in the output array.
avoid_round_off : bool
Whether to manually set exact zeros in arrays.
Returns
-------
out : ndarray | float
The metric tensor evaluated at given logical coordinates.
"""
return self._evaluate_metric_coefficient(
*etas,
which=4,
change_out_order=change_out_order,
squeeze_out=squeeze_out,
transposed=transposed,
remove_outside=remove_outside,
avoid_round_off=avoid_round_off,
)
[docs]
def metric_inv(
self,
*etas,
transposed=False,
change_out_order=False,
squeeze_out=False,
remove_outside=True,
avoid_round_off=True,
):
r"""
Evaluates the inverse metric tensor :math:`G^{-1}: (0, 1)^3 \to \mathbb R^{3\times 3}`.
Logical coordinates outside of :math:`(0, 1)^3` are evaluated to -1.
Parameters
----------
*etas : array-like | tuple
Logical coordinates at which to evaluate. Two cases are possible:
1. 2d numpy array, where coordinates are taken from eta1 = etas[:, 0], eta2 = etas[:, 1], etc. (like markers).
2. list/tuple (eta1, eta2, ...), where eta1, eta2, ... can be float or array-like of various shapes.
transposed : bool
If True, the transposed Jacobian matrix is evaluated.
change_out_order : bool
If True, the axes corresponding to the 3x3 entries in the output array are the last two, otherwise the first two.
squeeze_out : bool
Whether to remove singleton dimensions in output array.
remove_outside : bool
If True, logical coordinates outside of (0, 1)^3 are NOT evaluated to -1 and are removed in the output array.
avoid_round_off : bool
Whether to manually set exact zeros in arrays.
Returns
-------
out : ndarray | float
The inverse metric tensor evaluated at given logical coordinates.
"""
return self._evaluate_metric_coefficient(
*etas,
which=5,
change_out_order=change_out_order,
squeeze_out=squeeze_out,
transposed=transposed,
remove_outside=remove_outside,
avoid_round_off=avoid_round_off,
)
[docs]
def pull(
self,
a,
*etas,
flat_eval=False,
kind="0",
a_kwargs={},
change_out_order=False,
squeeze_out=False,
remove_outside=True,
coordinates="physical",
):
"""Pull-back of a Cartesian scalar/vector field to a differential p-form.
Parameters
----------
a : callable | list | tuple | array-like
The function a(x, y, z) resp. [a_x(x, y, z), a_y(x, y, z), a_z(x, y, z)] to be pulled.
*etas : array-like | tuple
Logical coordinates at which to evaluate. Two cases are possible:
1. 2d numpy array, where coordinates are taken from eta1 = etas[:, 0], eta2 = etas[:, 1], etc. (like markers).
2. list/tuple (eta1, eta2, ...), where eta1, eta2, ... can be float or array-like of various shapes.
flat_eval : bool
Allows to perform flat evaluation when len(etas) == 3 with 1D arrays of same size.
kind : str
Which pull-back to apply, '0', '1', '2', '3' or 'v'.
a_kwargs : dict
Keyword arguments passed to parameter "a" if "a" is a callable: is called as a(*etas, **a_kwargs).
change_out_order : bool
If True, the axes corresponding to the 3 components in the output array are the last two, otherwise the first two.
squeeze_out : bool
Whether to remove singleton dimensions in output array.
remove_outside : bool
If True, logical coordinates outside of (0, 1)^3 are NOT evaluated to -1 and are removed in the output array.
coordinates : str
In which coordinates the input "a" is given (in case of callables). "physical" : a = a(x, y, z).
"logical" : a = a(eta1, eta2, eta3).
Returns
-------
out : ndarray | float
Pullback of Cartesian vector/scalar field to p-form evaluated at given logical coordinates.
"""
return self._pull_push_transform(
"pull",
a,
kind,
*etas,
flat_eval=flat_eval,
change_out_order=change_out_order,
squeeze_out=squeeze_out,
remove_outside=remove_outside,
coordinates=coordinates,
a_kwargs=a_kwargs,
)
[docs]
def push(
self,
a,
*etas,
flat_eval=False,
kind="0",
a_kwargs={},
change_out_order=False,
squeeze_out=False,
remove_outside=True,
):
"""Pushforward of a differential p-form to a Cartesian scalar/vector field.
Parameters
-----------
a : callable | list | tuple | array-like
The function a(e1, e2, e3) resp. [a_1(e1, e2, e3), a_2(e1, e2, e3), a_3(e1, e2, e3)] to be pushed.
*etas : array-like | tuple
Logical coordinates at which to evaluate. Two cases are possible:
1. 2d numpy array, where coordinates are taken from eta1 = etas[:, 0], eta2 = etas[:, 1], etc. (like markers).
2. list/tuple (eta1, eta2, ...), where eta1, eta2, ... can be float or array-like of various shapes.
flat_eval : bool
Allows to perform flat evaluation when len(etas) == 3 with 1D arrays of same size.
kind : str
Which pushforward to apply, '0', '1', '2', '3' or 'v'.
a_kwargs : dict
Keyword arguments passed to parameter "a" if "a" is a callable: is called as a(*etas, **a_kwargs).
change_out_order : bool
If True, the axes corresponding to the 3 components in the output array are the last two, otherwise the first two.
squeeze_out : bool
Whether to remove singleton dimensions in output array.
remove_outside : bool
If True, logical coordinates outside of (0, 1)^3 are NOT evaluated to -1 and are removed in the output array.
Returns
-------
out : ndarray | float
Pushforward of p-form to Cartesian vector/scalar field evaluated at given logical coordinates.
"""
return self._pull_push_transform(
"push",
a,
kind,
*etas,
flat_eval=flat_eval,
change_out_order=change_out_order,
squeeze_out=squeeze_out,
remove_outside=remove_outside,
a_kwargs=a_kwargs,
)
[docs]
def transform(
self,
a,
*etas,
flat_eval=False,
kind="0_to_3",
a_kwargs={},
change_out_order=False,
squeeze_out=False,
remove_outside=True,
):
"""Transformation between different differential p-forms and/or vector fields.
Parameters
-----------
a : callable | list | tuple | array-like
The function a(e1, e2, e3) resp. [a_1(e1, e2, e3), a_2(e1, e2, e3), a_3(e1, e2, e3)] to be transformed.
*etas : array-like | tuple
Logical coordinates at which to evaluate. Two cases are possible:
1. 2d numpy array, where coordinates are taken from eta1 = etas[:, 0], eta2 = etas[:, 1], etc. (like markers).
2. list/tuple (eta1, eta2, ...), where eta1, eta2, ... can be float or array-like of various shapes.
flat_eval : bool
Allows to perform flat evaluation when len(etas) == 3 with 1D arrays of same size.
kind : str
Which transformation to apply, such as '0_to_3' for example, see dict_transformations['tran'] for all options.
a_kwargs : dict
Keyword arguments passed to parameter "a" if "a" is a callable: is called as a(*etas, **a_kwargs).
change_out_order : bool
If True, the axes corresponding to the 3 components in the output array are the last two, otherwise the first two.
squeeze_out : bool
Whether to remove singleton dimensions in output array.
remove_outside : bool
If True, logical coordinates outside of (0, 1)^3 are NOT evaluated to -1 and are removed in the output array.
Returns
-------
out : ndarray | float
Transformed p-form evaluated at given logical coordinates.
Notes
-----
Possible choices for kind are '0_to_3', '3_to_0', '1_to_2', '2_to_1', 'norm_to_v', 'norm_to_1', 'norm_to_2', 'v_to_1', 'v_to_2', '1_to_v' and '2_to_v'.
"""
return self._pull_push_transform(
"tran",
a,
kind,
*etas,
flat_eval=flat_eval,
change_out_order=change_out_order,
squeeze_out=squeeze_out,
remove_outside=remove_outside,
a_kwargs=a_kwargs,
)
# ========================
# private methods :
# ========================
# ================================
def _evaluate_metric_coefficient(self, *etas, which=0, **kwargs):
"""Evaluates metric coefficients. Logical coordinates outside of :math:`(0, 1)^3` are evaluated to -1 for markers evaluation.
Parameters
----------
*etas : array-like | tuple
Logical coordinates at which to evaluate. Two cases are possible:
1. 2d numpy array, where coordinates are taken from eta1 = etas[:, 0], eta2 = etas[:, 1], etc. (like markers).
2. list/tuple (eta1, eta2, ...), where eta1, eta2, ... can be float or array-like of various shapes.
which : int
Which metric coefficients to be evaluated (0 : F, 1 : DF, 2 : det(DF), 3 : DF^(-1), 4 : G, 5 : G^(-1)).
**kwargs
Addtional boolean keyword arguments (transposed, change_out_order, squeeze_out, remove_outside, avoid_round_off).
Returns
-------
out : ndarray | float
The metric coefficient evaluated at the given logical coordinates.
"""
# set default values
transposed = kwargs.get("transposed", False)
change_out_order = kwargs.get("change_out_order", False)
squeeze_out = kwargs.get("squeeze_out", True)
remove_outside = kwargs.get("remove_outside", False)
avoid_round_off = kwargs.get("avoid_round_off", True)
# markers evaluation
if len(etas) == 1:
markers = etas[0]
# to keep C-ordering the (3, 3)-part is in the last indices
out = xp.empty((markers.shape[0], 3, 3), dtype=float)
n_inside = evaluation_kernels.kernel_evaluate_pic(
markers,
which,
self.args_domain,
out,
remove_outside,
avoid_round_off,
)
# move the (3, 3)-part to front
out = xp.transpose(out, axes=(1, 2, 0))
# remove holes
out = out[:, :, :n_inside]
if transposed:
out = xp.transpose(out, axes=(1, 0, 2))
# change size of "out" depending on which metric coeff has been evaluated
if which == 0 or which == -1:
out = out[:, 0, :]
if change_out_order:
out = xp.transpose(out, axes=(1, 0))
elif which == 2:
out = out[0, 0, :]
else:
if change_out_order:
out = xp.transpose(out, axes=(2, 0, 1))
# tensor-product/slice evaluation
else:
E1, E2, E3, is_sparse_meshgrid = Domain.prepare_eval_pts(
etas[0],
etas[1],
etas[2],
flat_eval=False,
)
# to keep C-ordering the (3, 3)-part is in the last indices
out = xp.empty(
(E1.shape[0], E2.shape[1], E3.shape[2], 3, 3),
dtype=float,
)
evaluation_kernels.kernel_evaluate(
E1,
E2,
E3,
which,
self.args_domain,
out,
is_sparse_meshgrid,
avoid_round_off,
)
# move the (3, 3)-part to front
out = xp.transpose(out, axes=(3, 4, 0, 1, 2))
if transposed:
out = xp.transpose(out, axes=(1, 0, 2, 3, 4))
if which == 0:
out = out[:, 0, :, :, :]
if change_out_order:
out = xp.transpose(out, axes=(1, 2, 3, 0))
elif which == 2:
out = out[0, 0, :, :, :]
else:
if change_out_order:
out = xp.transpose(out, axes=(2, 3, 4, 0, 1))
# remove singleton dimensions for slice evaluation
if squeeze_out:
out = out.squeeze()
# remove all "dimensions" for point-wise evaluation
if out.ndim == 0:
out = out.item()
if isinstance(out, float):
return out
else:
return out.copy()
# ================================
def _pull_push_transform(self, which, a, kind_fun, *etas, flat_eval=False, **kwargs):
"""Evaluates metric coefficients. Logical coordinates outside of :math:`(0, 1)^3` are evaluated to -1 for markers evaluation.
Parameters
----------
which : str
Which transformation to apply (one of "pull", "push" or "tran").
a : callable | list | tuple | array-like
The function/values to be transformed.
kind_fun : str
The kind of transformation (e.g. "0" or "1" in case of which="pull").
*etas : array-like| tuple
Logical coordinates at which to evaluate. Two cases are possible:
1. 2d numpy array, where coordinates are taken from eta1 = etas[:, 0], eta2 = etas[:, 1], etc. (like markers).
2. list/tuple (eta1, eta2, ...), where eta1, eta2, ... can be float or array-like of various shapes.
flat_eval : bool
Allows to perform flat evaluation when len(etas) == 3 with 1D arrays of same size.
**kwargs
Addtional keyword arguments (coordinates, change_out_order, squeeze_out, remove_outside, a_kwargs).
Returns
-------
out : ndarray | float
4D or 2D (for flat eval) array holding the metric coefficient (first index),
evaluated at the given logical coordinates (last three indices).
"""
# set default values
coordinates = kwargs.get("coordinates", "logical")
change_out_order = kwargs.get("change_out_order", False)
squeeze_out = kwargs.get("squeeze_out", True)
remove_outside = kwargs.get("remove_outside", False)
a_kwargs = kwargs.get("a_kwargs", {})
# kind of transformation
kind_int = self.dict_transformations[which][kind_fun]
# markers evaluation
if len(etas) == 1 or flat_eval:
if flat_eval:
assert len(etas) == 3
assert etas[0].shape == etas[1].shape == etas[2].shape
assert etas[0].ndim == 1
markers = xp.stack(etas, axis=1)
else:
markers = etas[0]
# coordinates (:, 3) and argument evaluation (without holes)
if callable(a):
if coordinates == "logical":
A = Domain.prepare_arg(
a,
self(
markers,
change_out_order=True,
remove_outside=remove_outside,
identity_map=True,
),
)
else:
A = Domain.prepare_arg(
a,
self(markers, change_out_order=True, remove_outside=remove_outside),
)
elif isinstance(a, (list, tuple)):
if callable(a[0]):
if coordinates == "logical":
A = Domain.prepare_arg(
a,
self(
markers,
change_out_order=True,
remove_outside=remove_outside,
identity_map=True,
),
)
else:
A = Domain.prepare_arg(
a,
self(markers, change_out_order=True, remove_outside=remove_outside),
)
else:
A = Domain.prepare_arg(a, markers)
else:
A = Domain.prepare_arg(a, markers)
# check if A includes holes or not
if A.shape[0] == markers.shape[0]:
A_has_holes = True
else:
A_has_holes = False
# call evaluation kernel
out = xp.empty((markers.shape[0], 3), dtype=float)
# make sure we don't have stride = 0
A = A.copy()
n_inside = transform_kernels.kernel_pullpush_pic(
A,
markers,
self._transformation_ids[which],
kind_int,
self.args_domain,
out,
remove_outside,
)
# move the (3, 3)-part to front
out = xp.transpose(out, axes=(1, 0))
# remove holes
out = out[:, :n_inside]
# check if A has correct shape
if not A_has_holes and remove_outside:
assert A.shape[0] == out.shape[1]
# change output order
if kind_int < 10:
out = out[0, :]
else:
if change_out_order:
out = xp.transpose(out, axes=(1, 0))
# tensor-product/slice evaluation
else:
# convert evaluation points to 3d array of appropriate shape
E1, E2, E3, is_sparse_meshgrid = Domain.prepare_eval_pts(
etas[0],
etas[1],
etas[2],
flat_eval=False,
)
# convert input to be transformed (a) to 4d array of appropriate shape
if coordinates == "logical":
A = Domain.prepare_arg(
a,
E1,
E2,
E3,
is_sparse_meshgrid=is_sparse_meshgrid,
a_kwargs=a_kwargs,
)
else:
X = self(E1, E2, E3)
A = Domain.prepare_arg(a, X[0], X[1], X[2], a_kwargs=a_kwargs)
# call evaluation kernel
out = xp.empty(
(E1.shape[0], E2.shape[1], E3.shape[2], 3),
dtype=float,
)
transform_kernels.kernel_pullpush(
A,
E1,
E2,
E3,
self._transformation_ids[which],
kind_int,
self.args_domain,
is_sparse_meshgrid,
out,
)
# move the (3, 3)-part to front
out = xp.transpose(out, axes=(3, 0, 1, 2))
# change output order
if kind_int < 10:
out = out[0, :, :, :]
else:
if change_out_order:
out = xp.transpose(out, axes=(1, 2, 3, 0))
# remove singleton dimensions for slice evaluation
if squeeze_out:
out = out.squeeze()
# remove all "dimensions" for point-wise evaluation
if out.ndim == 0:
out = out.item()
if isinstance(out, float):
return out
else:
return out.copy()
# ========================
# static methods :
# ========================
# ================================
[docs]
@staticmethod
def prepare_eval_pts(x, y, z, flat_eval=False):
"""Broadcasts evaluation point sets to 3d arrays of correct shape.
Parameters
----------
x, y, z : float | int | list | array-like
Evaluation point sets.
flat_eval : bool
Whether to do a flat evaluation, i.e. f([e11, e12], [e21, e22]) = [f(e11, e21), f(e12, e22)].
Returns
-------
E1, E2, E3 : array-like
3d arrays of correct shape for evaluation.
is_sparse_meshgrid : bool
Whether arguments fit sparse_meshgrid shape.
"""
is_sparse_meshgrid = False
# flat evaluation (works only if all arguments are 1d numpy arrays/lists of equal length!)
if flat_eval:
# convert list type data to numpy array:
if isinstance(x, list):
arg_x = xp.array(x)
elif isinstance(x, xp.ndarray):
arg_x = x
else:
raise ValueError("Input x must be a 1d list or numpy array")
if isinstance(y, list):
arg_y = xp.array(y)
elif isinstance(y, xp.ndarray):
arg_y = y
else:
raise ValueError("Input y must be a 1d list or numpy array")
if isinstance(z, list):
arg_z = xp.array(z)
elif isinstance(z, xp.ndarray):
arg_z = z
else:
raise ValueError("Input z must be a 1d list or numpy array")
assert arg_x.ndim == arg_y.ndim == arg_z.ndim == 1
assert arg_x.size == arg_y.size == arg_z.size
E1 = arg_x[:, None, None]
E2 = arg_y[:, None, None]
E3 = arg_z[:, None, None]
# Make sure we don't have stride 0
return E1.copy(), E2.copy(), E3.copy(), is_sparse_meshgrid
# non-flat evaluation (broadcast to 3d arrays)
else:
# convert list type data to numpy array:
if isinstance(x, float):
arg_x = xp.array([x])
elif isinstance(x, int):
arg_x = xp.array([float(x)])
elif isinstance(x, list):
arg_x = xp.array(x)
elif isinstance(x, xp.ndarray):
arg_x = x.copy()
else:
raise ValueError(f"data type {type(x)} not supported")
if isinstance(y, float):
arg_y = xp.array([y])
elif isinstance(y, int):
arg_y = xp.array([float(y)])
elif isinstance(y, list):
arg_y = xp.array(y)
elif isinstance(y, xp.ndarray):
arg_y = y.copy()
else:
raise ValueError(f"data type {type(y)} not supported")
if isinstance(z, float):
arg_z = xp.array([z])
elif isinstance(z, int):
arg_z = xp.array([float(z)])
elif isinstance(z, list):
arg_z = xp.array(z)
elif isinstance(z, xp.ndarray):
arg_z = z.copy()
else:
raise ValueError(f"data type {type(z)} not supported")
# tensor-product for given three 1D arrays
if arg_x.ndim == 1 and arg_y.ndim == 1 and arg_z.ndim == 1:
E1, E2, E3 = xp.meshgrid(arg_x, arg_y, arg_z, indexing="ij")
# given xy-plane at point z:
elif arg_x.ndim == 2 and arg_y.ndim == 2 and arg_z.size == 1:
E1 = arg_x[:, :, None]
E2 = arg_y[:, :, None]
E3 = arg_z * xp.ones(E1.shape)
# given xz-plane at point y:
elif arg_x.ndim == 2 and arg_y.size == 1 and arg_z.ndim == 2:
E1 = arg_x[:, None, :]
E2 = arg_y * xp.ones(E1.shape)
E3 = arg_z[:, None, :]
# given yz-plane at point x:
elif arg_x.size == 1 and arg_y.ndim == 2 and arg_z.ndim == 2:
E2 = arg_y[None, :, :]
E3 = arg_z[None, :, :]
E1 = arg_x * xp.ones(E2.shape)
# given three 3D arrays
elif arg_x.ndim == 3 and arg_y.ndim == 3 and arg_z.ndim == 3:
# Distinguish if input coordinates are from sparse or dense meshgrid.
# Sparse: arg_x.shape = (n1, 1, 1), arg_y.shape = (1, n2, 1), arg_z.shape = (1, 1, n3)
# Dense : arg_x.shape = (n1, n2, n3), arg_y.shape = (n1, n2, n3) arg_z.shape = (n1, n2, n3)
E1, E2, E3 = arg_x, arg_y, arg_z
# `arg_x` `arg_y` `arg_z` are all sparse meshgrids.
if (
arg_x.shape[1] == 1
and arg_x.shape[2] == 1
and arg_y.shape[0] == 1
and arg_y.shape[2] == 1
and arg_z.shape[0] == 1
and arg_z.shape[1] == 1
):
is_sparse_meshgrid = True
# one of `arg_x` `arg_y` `arg_z` is a dense meshgrid.(i.e., all are dense meshgrid) Process each point as default.
else:
raise ValueError("Argument dimensions not supported")
# Make sure we don't have stride 0
return E1.copy(), E2.copy(), E3.copy(), is_sparse_meshgrid
# ================================
[docs]
@staticmethod
def prepare_arg(a_in, *Xs, is_sparse_meshgrid=False, a_kwargs={}):
"""Broadcasts argument to be pulled, pushed or transformed to array of correct shape (2d for markers, 4d else).
Parameters
----------
a_in : callable | list | tuple | array-like
The argument to be pulled, pushed or transformed.
*Xs : array-like | tuple
The evaluation point sets. Obtained from prepare_eval_pts function.
is_sparse_meshgrid : bool
Whether arguments fit sparse_meshgrid shape. Obtained from prepare_eval_pts function.
a_kwargs : dict
Keyword arguments passed to parameter "a_in" if "a_in" is a callable: is called as a_in(*etas, **a_kwargs).
Returns
-------
a_out : array-like
The 2d/4d array suitable for evaluation kernels.
"""
if len(Xs) == 1:
flat_eval = True
else:
flat_eval = False
# float (point-wise, scalar function)
if isinstance(a_in, float):
a_out = xp.array([[[[a_in]]]])
# single callable:
# scalar function -> must return a 3d array for 3d evaluation points
# vector-valued function -> must return a 4d array of shape (3,:,:,:)
elif callable(a_in):
if flat_eval:
a_out = a_in(Xs[0][:, 0], Xs[0][:, 1], Xs[0][:, 2], **a_kwargs)
if a_out.ndim == 1:
a_out = a_out[None, :]
else:
if is_sparse_meshgrid:
a_out = a_in(
*xp.meshgrid(Xs[0][:, 0, 0], Xs[1][0, :, 0], Xs[2][0, 0, :], indexing="ij"),
**a_kwargs,
)
else:
a_out = a_in(*Xs, **a_kwargs)
# case of Field.__call__
if isinstance(a_out, list):
a_out = xp.array(a_out)
if a_out.ndim == 3:
a_out = a_out[None, :, :, :]
# list/tuple of length 1 or 3 containing:
# callable(s) that must return 3d array(s) for 3d evaluation points
# 1d array(s) (flat_eval=True)
# 3d array(s) (flat eval=False)
elif isinstance(a_in, (list, tuple)):
assert len(a_in) == 1 or len(a_in) == 3
a_out = []
for component in a_in:
if callable(component):
if flat_eval:
a_out += [
component(
Xs[0][:, 0],
Xs[0][:, 1],
Xs[0][:, 2],
**a_kwargs,
),
]
else:
if is_sparse_meshgrid:
a_out += [
component(
*xp.meshgrid(
Xs[0][:, 0, 0],
Xs[1][0, :, 0],
Xs[2][0, 0, :],
indexing="ij",
),
**a_kwargs,
),
]
else:
a_out += [component(*Xs, **a_kwargs)]
elif isinstance(component, xp.ndarray):
if flat_eval:
assert component.ndim == 1, logger.info(f"{component.ndim =}")
else:
assert component.ndim == 3, logger.info(f"{component.ndim =}")
a_out += [component]
elif isinstance(component, float):
a_out += [xp.array([component])[:, None, None]]
a_out = xp.array(a_out, dtype=float)
# numpy array:
# 1d array (flat_eval=True and scalar input or flat_eval=False and length 1 (scalar) or length 3 (vector))
# 2d array (flat_eval=True and vector-valued input of shape (3,:))
# 3d array (flat_eval=False and scalar input)
# 4d array (flat_eval=False and vector-valued input of shape (3,:,:,:))
elif isinstance(a_in, xp.ndarray):
if flat_eval:
if a_in.ndim == 1:
a_out = a_in[None, :]
elif a_in.ndim == 2:
a_out = a_in[:, :]
else:
raise ValueError(
"Input array a_in must be either 1d (scalar) or \
2d (vector-valued, shape (3,:)) for flat evaluation!",
)
else:
# point-wise evaluation for scalar (len=1) or vector (len=3) input
if a_in.ndim == 1:
assert a_in.size == 1 or a_in.size == 3
a_out = a_in[:, None, None, None]
# tensor-product evaluation (scalar)
elif a_in.ndim == 3:
a_out = a_in[None, :, :, :]
# tensor-product evaluation (vector)
elif a_in.ndim == 4:
a_out = a_in[:, :, :, :]
else:
raise ValueError(
"Input array a_in must be either 3d (scalar) or \
4d (vector-valued, shape (3,:,:,:)) for non-flat evaluation!",
)
else:
raise TypeError(
"Argument a must be either a float OR a list/tuple of 1 or 3 callable(s)/numpy array(s)/float(s) \
OR a single numpy array OR a single callable!",
)
# make sure that output array is 2d and of shape (:, 1) or (:, 3) for flat evaluation
if flat_eval:
assert a_out.ndim == 2
assert a_out.shape[0] == 1 or a_out.shape[0] == 3
a_out = xp.ascontiguousarray(xp.transpose(a_out, axes=(1, 0))).copy() # Make sure we don't have stride 0
# make sure that output array is 4d and of shape (:,:,:, 1) or (:,:,:, 3) for tensor-product/slice evaluation
else:
assert a_out.ndim == 4
assert a_out.shape[0] == 1 or a_out.shape[0] == 3
a_out = xp.ascontiguousarray(
xp.transpose(a_out, axes=(1, 2, 3, 0)),
).copy() # Make sure we don't have stride 0
return a_out
# ================================
[docs]
def get_params_numpy(self) -> xp.ndarray:
"""Convert parameter dict into numpy array."""
params_numpy = []
for k, v in self.params.items():
params_numpy.append(v)
return xp.array(params_numpy)
[docs]
def create_geometry_mesh(
self,
nx: int = 32,
ny: int = 32,
nz: int = 32,
):
"""Create a PyVista mesh with geometry
Returns
-------
pyvista.StructuredGrid
"""
grids_log = [
xp.linspace(1e-6, 1.0, nx),
xp.linspace(0.0, 1.0, ny),
xp.linspace(0.0, 1.0, nz),
]
tmp = self(*grids_log)
grids_phy = [tmp[0], tmp[1], tmp[2]]
# Create PyVista structured grid
mesh = StructuredGrid(grids_phy[0], grids_phy[1], grids_phy[2])
return mesh
[docs]
def show_3d(
self,
nx: int = 32,
ny: int = 32,
nz: int = 32,
):
"""Show the 3D geometry using PyVista."""
mesh = self.create_geometry_mesh(nx, ny, nz)
plotter = Plotter()
plotter.add_mesh(mesh, show_edges=True)
plotter.show()
[docs]
def export_geometry(self, filename: str):
"""Save the geometry to a VTK file.
Parameters
----------
filename : str
The name of the file to save the geometry to. Supported formats include .vts, .vtk, .vtp
"""
from vtk import vtkGeometryFilter, vtkXMLPolyDataWriter
mesh = self.create_geometry_mesh()
if filename.endswith(".vts"):
mesh.save(filename, binary=True)
elif filename.endswith(".vtp"):
# Extract the external surface (Geometry Filter)
geom_filter = vtkGeometryFilter()
geom_filter.SetInputData(mesh)
geom_filter.Update()
# Write as PolyData (.vtp)
writer = vtkXMLPolyDataWriter()
writer.SetFileName(filename)
writer.SetInputData(geom_filter.GetOutput())
writer.Write()
else:
raise ValueError("Unsupported file format. Supported formats are .vts, .vtk, .vtp")
[docs]
def show(
self,
logical=False,
grid_info=None,
markers=None,
marker_coords="logical",
show_control_pts=False,
figsize=(12, 5),
save_dir=None,
):
"""Plots isolines (and control point in case on spline mappings) of the 2D physical domain for eta3 = 0.
Markers can be plotted as well (optional).
Parameters
----------
logical : bool
Whether to plot the physical domain (False) or logical domain (True).
plane : str
Which physical coordinates to plot (xy, xz or yz) in case of logical=False.
grid_info : array-like
Information about the grid. If not given, the domain is shown with high resolution. If given, can be either
* a list of # of elements [Nel1, Nel2, (Nel3)], OR
* a 2d array with information about MPI decomposition.
markers : array-like
Markers to be plotted. Can be of shape (Np, 3) or (:, Np, 3). For the former, all markers are plotted with the same color. For the latter, with different colors (are interpreted as orbits in time).
marker_coords : bool
Whether the marker coordinates are logical or physical.
save_dir : str
If given, the figure is saved according the given directory.
"""
import matplotlib.pyplot as plt
is_not_cube = self.kind_map < 10 or self.kind_map > 19
torus_mappings = (
"Tokamak",
"GVECunit",
"DESCunit",
"IGAPolarTorus",
"HollowTorus",
)
# plot domain without MPI decomposition and high resolution
if grid_info is None:
e1 = xp.linspace(0.0, 1.0, 16)
e2 = xp.linspace(0.0, 1.0, 65)
if logical:
E1, E2 = xp.meshgrid(e1, e2, indexing="ij")
X = xp.stack((E1, E2), axis=0)
else:
XYZ = self(e1, e2, 0.0, squeeze_out=True)
X = XYZ[0]
if self.__class__.__name__ in torus_mappings:
Y = XYZ[2]
else:
Y = XYZ[1]
fig = plt.figure(figsize=figsize)
ax = fig.add_subplot(1, 2, 1)
# eta1-isolines
for i in range(e1.size):
ax.plot(X[i, :], Y[i, :], "tab:blue", alpha=0.5, zorder=0)
# eta2-isolines
for j in range(e2.size - int(is_not_cube)):
ax.plot(X[:, j], Y[:, j], "tab:blue", alpha=0.5, zorder=0)
ax.scatter(X[0, 0], Y[0, 0], 20, "red", zorder=10)
if is_not_cube:
ax.scatter(X[0, 32], Y[0, 32], 20, "red", zorder=10)
tstr = ""
for key, val in self.params.items():
if key not in {"cx", "cy", "cz"}:
tstr += key + ": " + str(val) + "\n"
ax.set_title(self.__class__.__name__ + " at $\\eta_3=0$")
ax.text(
0.01,
0.99,
tstr,
ha="left",
va="top",
transform=ax.transAxes,
)
# top view
e3 = xp.linspace(0.0, 1.0, 65)
if logical:
E1, E2 = xp.meshgrid(e1, e2, indexing="ij")
X = xp.stack((E1, E2), axis=0)
else:
theta_0 = self(e1, 0.0, e3, squeeze_out=True)
theta_pi = self(e1, 0.5, e3, squeeze_out=True)
X_0 = theta_0[0]
X_pi = theta_pi[0]
if self.__class__.__name__ in torus_mappings:
Z_0 = theta_0[1]
Z_pi = theta_pi[1]
else:
Z_0 = theta_0[2]
Z_pi = theta_pi[2]
ax2 = fig.add_subplot(1, 2, 2)
# eta1-isolines
for i in range(e1.size):
ax2.plot(X_0[i, :], Z_0[i, :], "tab:blue", alpha=0.5, zorder=0)
# eta3-isolines
for j in range(e2.size):
ax2.plot(X_0[:, j], Z_0[:, j], "tab:blue", alpha=0.5, zorder=0)
if is_not_cube:
# eta1-isolines
for i in range(e1.size):
ax2.plot(
X_pi[i, :],
Z_pi[i, :],
"tab:blue",
alpha=0.5,
zorder=0,
)
# eta3-isolines
for j in range(e2.size):
ax2.plot(
X_pi[:, j],
Z_pi[:, j],
"tab:blue",
alpha=0.5,
zorder=0,
)
# magnetic axis
ax2.plot(X_0[0, :], Z_0[0, :], "tab:red", alpha=1.0, zorder=10)
ax2.plot(X_pi[0, :], Z_pi[0, :], "tab:red", alpha=1.0, zorder=10)
if self.__class__.__name__ in torus_mappings:
ylab = "y"
else:
ylab = "z"
ax2.set_xlabel("x")
ax2.set_ylabel(ylab)
ax2.set_title("top view")
ax2.axis("equal")
# coordinates
# e3 = [0., .25, .5, .75]
# x, y, z = self(e1, e2, e3)
# R = xp.sqrt(x**2 + y**2)
# fig = plt.figure(figsize=(13, 13))
# for n in range(4):
# plt.subplot(2, 2, n + 1)
# plt.contourf(R[:, :, n], z[:, :, n], x[:, :, n])
# plt.title(f'x at {e3[n] = }')
# plt.colorbar()
# fig = plt.figure(figsize=(13, 13))
# for n in range(4):
# plt.subplot(2, 2, n + 1)
# plt.contourf(R[:, :, n], z[:, :, n], y[:, :, n])
# plt.title(f'y at {e3[n] = }')
# plt.colorbar()
# fig = plt.figure(figsize=(13, 13))
# for n in range(4):
# plt.subplot(2, 2, n + 1)
# plt.contourf(R[:, :, n], z[:, :, n], z[:, :, n])
# plt.title(f'z at {e3[n] = }')
# plt.colorbar()
# plot domain according to given grid [nel1, nel2, (nel3)]
elif isinstance(grid_info, list):
assert len(grid_info) > 1
e1 = xp.linspace(0.0, 1.0, grid_info[0] + 1)
e2 = xp.linspace(0.0, 1.0, grid_info[1] + 1)
fig = plt.figure(figsize=figsize)
ax = fig.add_subplot(1, 1, 1)
if logical:
E1, E2 = xp.meshgrid(e1, e2, indexing="ij")
# eta1-isolines
for i in range(e1.size):
ax.plot(E1[i, :], E2[i, :], "tab:blue", alpha=0.5)
# eta2-isolines
for j in range(e2.size):
ax.plot(E1[:, j], E2[:, j], "tab:blue", alpha=0.5)
else:
X = self(e1, e2, 0.0, squeeze_out=True)
# plot xz-plane for torus mappings, xy-plane else
if self.__class__.__name__ in torus_mappings:
co1, co2 = 0, 2
else:
co1, co2 = 0, 1
# eta1-isolines
for i in range(e1.size):
ax.plot(X[co1, i, :], X[co2, i, :], "tab:blue", alpha=0.5)
# eta2-isolines
for j in range(e2.size):
ax.plot(X[co1, :, j], X[co2, :, j], "tab:blue", alpha=0.5)
# plot domain with MPI decomposition
elif isinstance(grid_info, xp.ndarray):
assert grid_info.ndim == 2
assert grid_info.shape[1] > 5
fig = plt.figure(figsize=figsize)
ax = fig.add_subplot(1, 1, 1)
for i in range(grid_info.shape[0]):
e1 = xp.linspace(
grid_info[i, 0],
grid_info[i, 1],
int(
grid_info[i, 2],
)
+ 1,
)
e2 = xp.linspace(
grid_info[i, 3],
grid_info[i, 4],
int(
grid_info[i, 5],
)
+ 1,
)
if logical:
E1, E2 = xp.meshgrid(e1, e2, indexing="ij")
# eta1-isolines
first_line = ax.plot(
E1[0, :],
E2[0, :],
label="rank=" + str(i),
alpha=0.25,
)
for j in range(e1.size):
ax.plot(
E1[j, :],
E2[j, :],
color=first_line[0].get_color(),
alpha=0.25,
)
# eta2-isolines
for k in range(e2.size):
ax.plot(
E1[:, k],
E2[:, k],
color=first_line[0].get_color(),
alpha=0.25,
)
else:
X = self(e1, e2, 0.0, squeeze_out=True)
# plot xz-plane for torus mappings, xy-plane else
if self.__class__.__name__ in torus_mappings:
co1, co2 = 0, 2
else:
co1, co2 = 0, 1
# eta1-isolines
first_line = ax.plot(
X[co1, 0, :],
X[co2, 0, :],
label="rank=" + str(i),
alpha=0.25,
)
for j in range(e1.size):
ax.plot(
X[co1, j, :],
X[co2, j, :],
color=first_line[0].get_color(),
alpha=0.25,
)
# eta2-isolines
for k in range(e2.size):
ax.plot(
X[co1, :, k],
X[co2, :, k],
color=first_line[0].get_color(),
alpha=0.25,
)
else:
raise ValueError("given grid_info is not supported!")
# plot control points in case of IGA mappings
if not logical and self.kind_map < 10 and show_control_pts:
if self.__class__.__name__ == "GVECunit" or self.__class__.__name__ == "DESCunit":
Yc = self.cz[:, :, 0].flatten()
else:
Yc = self.cy[:, :, 0].flatten()
ax.scatter(self.cx[:, :, 0].flatten(), Yc, s=1, color="b")
# plot given markers
if markers is not None:
assert not (logical and marker_coords != "logical")
if self.__class__.__name__ in torus_mappings:
co1, co2 = 0, 2
else:
co1, co2 = 0, 1
# no time series: plot all markers with the same color
if markers.ndim == 2:
if not logical and marker_coords == "logical":
tmp = markers.copy() # TODO: needed for eta3=0
tmp[:, 2] = 0.0 # TODO: needed for eta3=0
X = self(tmp, remove_outside=True)
else:
X = (
markers[:, 0].copy(),
markers[
:,
1,
].copy(),
markers[:, 2].copy(),
)
ax.scatter(X[co1], X[co2], s=1, color="b")
# time series: plot markers with different colors
elif markers.ndim == 3:
colors = ["k", "m", "b", "g", "r", "c", "y"]
for i in range(markers.shape[1]):
if not logical and marker_coords == "logical":
# TODO: needed for eta3=0
tmp = markers[:, i, :].copy()
tmp[:, 2] = 0.0 # TODO: needed for eta3 = 0
X = self(tmp, remove_outside=True, squeeze_out=True)
else:
X = (
markers[:, i, 0].copy(),
markers[
:,
i,
1,
].copy(),
markers[:, i, 2].copy(),
)
# ax.scatter(X[co1], X[co2], s=2, color=colors[i%len(colors)])
ax.scatter(X[co1], X[co2], s=2)
ax.axis("equal")
if isinstance(grid_info, xp.ndarray):
plt.legend()
if self.__class__.__name__ in torus_mappings:
ylab = "z"
else:
ylab = "y"
ax.set_xlabel("x")
ax.set_ylabel(ylab)
if save_dir is not None:
plt.savefig(save_dir, bbox_inches="tight")
else:
plt.show()
def to_dict(self) -> dict:
return {
"type": self.__class__.__name__,
"params": self.params,
}
@classmethod
def from_dict(cls, dct):
from struphy.geometry.utilities import get_domain_by_name
name = dct["type"]
domain_cls = get_domain_by_name(name)
return domain_cls(**dct["params"])
[docs]
def __eq__(self, other: "Domain") -> bool:
assert isinstance(other, Domain), f"Cannot compare Domain with {type(other)}."
return self.to_dict() == other.to_dict()
[docs]
class Spline(Domain):
r"""3D IGA spline mapping.
.. math::
F: (\eta_1, \eta_2, \eta_3) \mapsto (x, y, z) \textnormal{ as } \left\{\begin{aligned}
x &= \sum_{ijk} c^x_{ijk} N_i(\eta_1) N_j(\eta_2) N_k(\eta_3)\,,
y &= \sum_{ijk} c^y_{ijk} N_i(\eta_1) N_j(\eta_2) N_k(\eta_3)\,,
z &= \sum_{ijk} c^z_{ijk} N_i(\eta_1) N_j(\eta_2) N_k(\eta_3)\,.
\end{aligned}\right.
"""
def __init__(
self,
num_elements: tuple[int] = (8, 24, 6),
degree: tuple[int] = (2, 3, 1),
spl_kind: tuple[bool] = (False, True, True),
cx: xp.ndarray | None = None,
cy: xp.ndarray | None = None,
cz: xp.ndarray | None = None,
):
self.kind_map = 0
# get default control points from default GVEC equilibrium
if cx is None or cy is None or cz is None:
from struphy.fields_background.equils import GVECequilibrium
mhd_equil = GVECequilibrium()
cx = mhd_equil.domain.cx
cx = mhd_equil.domain.cy
cx = mhd_equil.domain.cz
# assign control points
self._cx = cx
self._cy = cy
self._cz = cz
# check dimensions
assert self.cx.ndim == 3
assert self.cy.ndim == 3
assert self.cz.ndim == 3
# make sure that control points are compatible with given spline data
expected_shape = tuple([num_elements[n] + (not spl_kind[n]) * degree[n] for n in range(3)])
assert self.cx.shape == expected_shape
assert self.cy.shape == expected_shape
assert self.cz.shape == expected_shape
# identify polar singularity at eta1=0
if xp.all(self.cx[0, :, 0] == self.cx[0, 0, 0]):
self.pole = True
else:
self.pole = False
self.periodic_eta3 = spl_kind[-1]
# base class
super().__init__(num_elements=num_elements, degree=degree, spl_kind=spl_kind)
[docs]
class PoloidalSpline(Domain):
r"""Base class for all mappings that use a 2D spline representation
:math:`S:(\eta_1, \eta_2) \to (R, Z) \in \mathbb R^2` in the poloidal plane:
.. math::
S: (\eta_1, \eta_2) \mapsto (R, Z) \textnormal{ as } \left\{\begin{aligned}
R &= \sum_{ij} c^R_{ij} N_i(\eta_1) N_j(\eta_2) \,,
Z &= \sum_{ij} c^Z_{ij} N_i(\eta_1) N_j(\eta_2) \,.
\end{aligned}\right.
The full map :math:`F: [0, 1]^3 \to \Omega` is obtained by defining :math:`(R, Z, \eta_3) \mapsto (x, y, z)` in the child class.
"""
def __init__(
self,
num_elements: tuple[int] = (8, 24),
degree: tuple[int] = (2, 3),
spl_kind: tuple[bool] = (False, True),
cx: xp.ndarray = None,
cy: xp.ndarray = None,
):
# get default control points
if cx is None or cy is None:
def X(eta1, eta2):
return eta1 * xp.cos(2 * xp.pi * eta2) + 3.0
def Y(eta1, eta2):
return eta1 * xp.sin(2 * xp.pi * eta2)
cx, cy = interp_mapping(num_elements, degree, spl_kind, X, Y)
# make sure that control points at pole are all the same (eta1=0 there)
cx[0] = 3.0
cy[0] = 0.0
# set control point properties
self._cx = cx
self._cy = cy
# make sure that control points are 2D
assert self.cx.ndim == 2
assert self.cy.ndim == 2
# make sure that control points are compatible with given spline data
expected_shape = tuple([num_elements[n] + (not spl_kind[n]) * degree[n] for n in range(2)])
assert self.cx.shape == expected_shape
assert self.cy.shape == expected_shape
# identify polar singularity at eta1=0
if xp.all(self.cx[0, :] == self.cx[0, 0]):
self.pole = True
else:
self.pole = False
# reshape control points to 3D
self._cx = self.cx[:, :, None]
self._cy = self.cy[:, :, None]
self._cz = xp.zeros((1, 1, 1), dtype=float)
# init base class
super().__init__(num_elements=num_elements, degree=degree, spl_kind=spl_kind)
[docs]
class PoloidalSplineStraight(PoloidalSpline):
r"""Cylinder where the poloidal planes are described by a 2D IGA-spline mapping.
.. math::
F: (R, Z, \eta_3) \mapsto (x, y, z) \textnormal{ as } \left\{\begin{aligned}
x &= R \,,
y &= Z \,,
z &= L_z\eta_3\,.
\end{aligned}\right.
"""
def __init__(
self,
num_elements: tuple[int] = (8, 24),
degree: tuple[int] = (2, 3),
spl_kind: tuple[bool] = (False, True),
cx: xp.ndarray = None,
cy: xp.ndarray = None,
Lz: float = 4.0,
):
self.kind_map = 1
# get default control points
if cx is None or cy is None:
def X(eta1, eta2):
return eta1 * xp.cos(2 * xp.pi * eta2)
def Y(eta1, eta2):
return eta1 * xp.sin(2 * xp.pi * eta2)
cx, cy = interp_mapping(num_elements, degree, spl_kind, X, Y)
# make sure that control points at pole are all 0 (eta1=0 there)
cx[0] = 0.0
cy[0] = 0.0
self.params_numpy = xp.array([Lz])
self.periodic_eta3 = False
# init base class
super().__init__(num_elements=num_elements, degree=degree, spl_kind=spl_kind, cx=cx, cy=cy)
[docs]
class PoloidalSplineTorus(PoloidalSpline):
r"""Torus where the poloidal planes are described by a 2D IGA-spline mapping.
.. math::
F: (R, Z, \eta_3) \mapsto (x, y, z) \textnormal{ as } \left\{\begin{aligned}
x &= R \cos(2\pi\eta_3) \,,
y &= R \sin(- 2\pi\eta_3) \,,
z &= Z \,.
\end{aligned}\right.
Parameters
----------
num_elements : tuple[int]
Number of elements in each poloidal direction.
degree : tuple[int]
Spline degree in each poloidal direction.
spl_kind : tuple[bool]
Kind of spline in each poloidal direction (True=periodic, False=clamped).
cx, cy : xp.ndarray
Control points (spline coefficients) of the poloidal mapping.
If None, a default square-to-disc mapping of radius 1 centered around (x, y) = (3, 0) is interpolated.
tor_period : int
The toroidal angle is between [0, 2*pi/tor_period).
"""
def __init__(
self,
num_elements: tuple[int] = (8, 24),
degree: tuple[int] = (2, 3),
spl_kind: tuple[bool] = (False, True),
cx: xp.ndarray = None,
cy: xp.ndarray = None,
tor_period: int = 3,
):
# use setters for mapping attributes
self.kind_map = 2
self.params_numpy = xp.array([float(tor_period)])
self.periodic_eta3 = True
# get default control points
if cx is None or cy is None:
def X(eta1, eta2):
return eta1 * xp.cos(2 * xp.pi * eta2) + 3.0
def Y(eta1, eta2):
return eta1 * xp.sin(2 * xp.pi * eta2)
cx, cy = interp_mapping(num_elements, degree, spl_kind, X, Y)
# make sure that control points at pole are all 0 (eta1=0 there)
cx[0] = 3.0
cy[0] = 0.0
# init base class
super().__init__(
num_elements=num_elements,
degree=degree,
spl_kind=spl_kind,
cx=cx,
cy=cy,
)
[docs]
def interp_mapping(num_elements, degree, spl_kind, X, Y, Z=None):
r"""Interpolates the mapping :math:`F: (0, 1)^3 \to \mathbb R^3` on the given spline space.
Parameters
-----------
num_elements, degree, spl_kind : array-like
Defining the spline space.
X, Y : callable
Either X(eta1, eta2) in 2D or X(eta1, eta2, eta3) in 3D.
Z : callable
3rd mapping component Z(eta1, eta2, eta3) in 3D.
Returns
--------
cx, cy (, cz) : array-like
The control points.
"""
# number of basis functions
NbaseN = [
num_elements + degree - kind * degree for num_elements, degree, kind in zip(num_elements, degree, spl_kind)
]
# element boundaries
el_b = [xp.linspace(0.0, 1.0, num_elements + 1) for num_elements in num_elements]
# spline knot vectors
T = [bsp.make_knots(el_b, degree, kind) for el_b, degree, kind in zip(el_b, degree, spl_kind)]
# greville points
I_pts = [bsp.greville(T, degree, kind) for T, degree, kind in zip(T, degree, spl_kind)]
# 1D interpolation matrices
I_mat = [
csc_matrix(bsp.collocation_matrix(T, degree, I_pts, kind))
for T, degree, I_pts, kind in zip(T, degree, I_pts, spl_kind)
]
# 2D interpolation
if len(num_elements) == 2:
I = kron(I_mat[0], I_mat[1], format="csc")
I_pts = xp.meshgrid(I_pts[0], I_pts[1], indexing="ij")
cx = spsolve(I, X(I_pts[0], I_pts[1]).flatten()).reshape(
NbaseN[0],
NbaseN[1],
)
cy = spsolve(I, Y(I_pts[0], I_pts[1]).flatten()).reshape(
NbaseN[0],
NbaseN[1],
)
return cx, cy
# 3D interpolation
elif len(num_elements) == 3:
I_LU = [splu(mat) for mat in I_mat]
x_size = X(I_pts[0], I_pts[1], I_pts[2])
y_size = Y(I_pts[0], I_pts[1], I_pts[2])
z_size = Z(I_pts[0], I_pts[1], I_pts[2])
cx = linalg_kron.kron_lusolve_3d(I_LU, x_size)
cy = linalg_kron.kron_lusolve_3d(I_LU, y_size)
cz = linalg_kron.kron_lusolve_3d(I_LU, z_size)
return cx, cy, cz
else:
logger.info("wrong number of elements")
return 0.0
[docs]
def spline_interpolation_nd(degree: list, spl_kind: list, grids_1d: list, values: xp.ndarray):
"""n-dimensional tensor-product spline interpolation with discrete input.
The interpolation points are passed as a list of 1d arrays, each array with increasing entries g[0]=0 < g[1] < ...
The last element must be g[-1] = 1 for clamped interpolation and g[-1] < 1 for periodic interpolation.
Parameters
-----------
degree : list[int]
Spline degree.
grids_1d : list[array]
Interpolation points in [0, 1].
spl_kind : list[bool]
True: periodic splines, False: clamped splines.
values: array
Function values at interpolation points. values.shape = (grid1.size, ..., gridn.size).
Returns
--------
coeffs : xp.array
spline coefficients as nd array.
T : list[array]
Knot vector of spline interpolant.
indN : list[array]
Global indices of non-vanishing splines in each element. Can be accessed via (element, local index).
"""
T = []
indN = []
I_mat = []
I_LU = []
for sh, x_grid, p_i, kind_i in zip(values.shape, grids_1d, degree, spl_kind):
assert isinstance(x_grid, xp.ndarray)
assert sh == x_grid.size
assert (
xp.all(
xp.roll(x_grid, 1)[1:] < x_grid[1:],
)
and x_grid[-1] > x_grid[-2]
)
assert x_grid[0] == 0.0
if kind_i:
assert x_grid[-1] < 1.0, "Interpolation points must be <1 for periodic interpolation."
breaks = xp.ones(x_grid.size + 1)
if p_i % 2 == 0:
breaks[1:-1] = (x_grid[1:] + xp.roll(x_grid, 1)[1:]) / 2.0
breaks[0] = 0.0
else:
breaks[:-1] = x_grid
else:
assert (
xp.abs(
x_grid[-1] - 1.0,
)
< 1e-14
), "Interpolation points must include x=1 for clamped interpolation."
# dimension of the 1d spline spaces: dim = breaks.size - 1 + degree = x_grid.size
if p_i == 1:
breaks = x_grid
elif p_i % 2 == 0:
breaks = x_grid[p_i // 2 - 1 : -p_i // 2].copy()
else:
breaks = x_grid[(p_i - 1) // 2 : -(p_i - 1) // 2].copy()
# cells must be in interval [0, 1]
breaks[0] = 0.0
breaks[-1] = 1.0
# breaks = xp.linspace(0., 1., x_grid.size - (not kind_i)*p_i + 1)
T += [bsp.make_knots(breaks, p_i, periodic=kind_i)]
indN += [
(xp.indices((breaks.size - 1, p_i + 1))[1] + xp.arange(breaks.size - 1)[:, None]) % x_grid.size,
]
I_mat += [bsp.collocation_matrix(T[-1], p_i, x_grid, periodic=kind_i)]
I_LU += [splu(csc_matrix(I_mat[-1]))]
# dimension check
for I, x_grid in zip(I_mat, grids_1d):
assert I.shape[0] == x_grid.size
assert I.shape[0] == I.shape[1]
# solve system
if len(degree) == 1:
return I_LU[0].solve(values), T, indN
if len(degree) == 2:
return linalg_kron.kron_lusolve_2d(I_LU, values), T, indN
elif len(degree) == 3:
return linalg_kron.kron_lusolve_3d(I_LU, values), T, indN
else:
raise AssertionError("Only dimensions < 4 are supported.")