Source code for plasmapy.analysis.nullpoint

"""
Functionality to find and analyze 3D magnetic null points.

.. attention::

   |expect-api-changes|
"""

__all__ = [
    "MultipleNullPointWarning",
    "NonZeroDivergence",
    "NullPoint",
    "NullPointError",
    "NullPointWarning",
    "Point",
    "null_point_find",
    "trilinear_approx",
    "uniform_null_point_find",
]

import warnings
from collections.abc import Callable

import numpy as np

# Declare Constants & global variables
_EQUALITY_ATOL = 1e-10
_MAX_RECURSION_LEVEL = 10
global _recursion_level
_recursion_level = 0


[docs] class NullPointError(Exception): """ A class for handling the exceptions of the null point finder functionality. .. note:: This functionality is still under development and the API may change in future releases. """
[docs] class NullPointWarning(UserWarning): """ A class for handling the warnings of the null point finder functionality. .. note:: This functionality is still under development and the API may change in future releases. """
[docs] class NonZeroDivergence(NullPointError): # noqa: N818 """ A class for handling the exception raised by passing in a magnetic field that violates the zero divergence constraint. .. note:: This functionality is still under development and the API may change in future releases. """ def __init__(self) -> None: super().__init__( "The divergence constraint does not hold for the provided magnetic field." )
[docs] class MultipleNullPointWarning(NullPointWarning): """ A class for handling the warning raised by passing in a magnetic field grid that may contain multiple null points in close proximity due to low resolution. .. note:: This functionality is still under development and the API may change in future releases. """
[docs] class Point: """ Abstract class for defining a point in 3D space. .. note:: This functionality is still under development and the API may change in future releases. """ def __init__(self, loc) -> None: self._loc = loc
[docs] def get_loc(self): r""" Returns the coordinates of the point object. """ return self._loc
loc = property(get_loc)
[docs] class NullPoint(Point): """ A class for defining a null point in 3D space. .. note:: This functionality is still under development and the API may change in future releases. """ def __init__(self, null_loc, classification) -> None: super().__init__(null_loc) self._classification = classification
[docs] def get_classification(self): r""" Returns the type of the null point object. """ return self._classification
def __eq__(self, point): r""" Returns `True` if two null point objects have the same coordinates. `False` otherwise. """ d = np.sqrt( (self.loc[0] - point.loc[0]) ** 2 + (self.loc[1] - point.loc[1]) ** 2 + (self.loc[2] - point.loc[2]) ** 2 ) return np.isclose(d, 0, atol=_EQUALITY_ATOL) classification = property(get_classification)
def _vector_space( x_arr=None, y_arr=None, z_arr=None, x_range=(0, 1), y_range=(0, 1), z_range=(0, 1), u_arr=None, v_arr=None, w_arr=None, func=(lambda x, y, z: [x, y, z]), precision=(0.05, 0.05, 0.05), ): r""" Returns a vector space in the form of a multi-dimensional array. Parameters ---------- x_arr : |array_like| The array representing the coordinates in the x-dimension. If not given, then range values are used to construct a uniform array on that interval. y_arr : |array_like| The array representing the coordinates in the y-dimension. If not given, then range values are used to construct a uniform array on that interval. z_arr : |array_like| The array representing the coordinates in the z-dimension. If not given, then range values are used to construct a uniform array on that interval. x_range : |array_like|, default: ``[0, 1]`` A 1 by 2 array containing the range of x-values for the vector spaces. y_range : |array_like|, default: ``[0, 1]`` A 1 by 2 array containing the range of y-values for the vector spaces. z_range : |array_like|, default: ``[0, 1]`` A 1 by 2 array containing the range of z-values for the vector spaces. u_arr : |array_like| A 3D array containing the x-component of the vector values for the vector space. If not given, the vector values are generated over the vector space using the function func. v_arr : |array_like| A 3D array containing the y-component of the vector values for the vector space. If not given, the vector values are generated over the vector space using the function func. w_arr : |array_like| A 3D array containing the z-component of the vector values for the vector space. If not given, the vector values are generated over the vector space using the function func. func : function A function that takes in 3 arguments, respectively representing an :math:`(x, y, z)` coordinate of a point and returns the vector value for that point in the form of a 1 by 3 array. precision : |array_like|, default: ``[0.05, 0.05, 0.05]`` A 1 by 3 array containing the approximate precision values for each dimension, in the case where uniform arrays are being used. Returns ------- ndarray A 1 by 3 array with the first element containing the coordinates, the second element containing the vector values, and the third element containing the delta values for each dimension. """ # Constructing the Meshgrid if x_arr is not None and y_arr is not None and z_arr is not None: x, y, z = np.meshgrid( x_arr, y_arr, z_arr, indexing="ij", ) dx = np.diff(x_arr) dy = np.diff(y_arr) dz = np.diff(z_arr) else: x_den = int(np.around((x_range[1] - x_range[0]) / precision[0]) + 1) y_den = int(np.around((y_range[1] - y_range[0]) / precision[1]) + 1) z_den = int(np.around((z_range[1] - z_range[0]) / precision[2]) + 1) dx = np.diff(np.linspace(x_range[0], x_range[1], x_den)) dy = np.diff(np.linspace(y_range[0], y_range[1], y_den)) dz = np.diff(np.linspace(z_range[0], z_range[1], z_den)) x, y, z = np.meshgrid( np.linspace(x_range[0], x_range[1], x_den), np.linspace(y_range[0], y_range[1], y_den), np.linspace(z_range[0], z_range[1], z_den), indexing="ij", ) # Calculating the vector values if u_arr is not None and v_arr is not None and w_arr is not None: u = u_arr v = v_arr w = w_arr else: u, v, w = func(x, y, z) return np.array([x, y, z]), np.array([u, v, w]), [dx, dy, dz] def _trilinear_coeff_cal(vspace, cell): r""" Return the coefficients for the trilinear approximation function on a given grid cell in a given vector space. Parameters ---------- vspace: |array_like| The vector space as constructed by the vector_space function which is a 1 by 3 array with the first element containing the coordinates, the second element containing the vector values, and the third element containing the delta values for each dimension. cell: |array_like| of integers A grid cell, represented by a 1 by 3 array of integers, which correspond to a grid cell in the vector space. Returns ------- ndarray Returns a 1 by 3 array with the first element containing the coefficients for the trilinear approximation function for the x-component of the vector space, the second element containing the coefficients for the trilinear approximation function for the y-component of the vector space, and the third element containing the coefficients for the trilinear approximation function for the z-component of the vector space. """ u, v, w = vspace[1] deltax, deltay, deltaz = vspace[2] f000 = cell f001 = [cell[0], cell[1], cell[2] + 1] f010 = [cell[0], cell[1] + 1, cell[2]] f011 = [cell[0], cell[1] + 1, cell[2] + 1] f100 = [cell[0] + 1, cell[1], cell[2]] f101 = [cell[0] + 1, cell[1], cell[2] + 1] f110 = [cell[0] + 1, cell[1] + 1, cell[2]] f111 = [cell[0] + 1, cell[1] + 1, cell[2] + 1] x0 = float(vspace[0][0][f000[0]][f000[1]][f000[2]]) x1 = float(x0 + deltax[cell[0]]) y0 = float(vspace[0][1][f000[0]][f000[1]][f000[2]]) y1 = float(y0 + deltay[cell[1]]) z0 = float(vspace[0][2][f000[0]][f000[1]][f000[2]]) z1 = float(z0 + deltaz[cell[2]]) A = np.array( [ [1, x0, y0, z0, x0 * y0, x0 * z0, y0 * z0, x0 * y0 * z0], [1, x1, y0, z0, x1 * y0, x1 * z0, y0 * z0, x1 * y0 * z0], [1, x0, y1, z0, x0 * y1, x0 * z0, y1 * z0, x0 * y1 * z0], [1, x1, y1, z0, x1 * y1, x1 * z0, y1 * z0, x1 * y1 * z0], [1, x0, y0, z1, x0 * y0, x0 * z1, y0 * z1, x0 * y0 * z1], [1, x1, y0, z1, x1 * y0, x1 * z1, y0 * z1, x1 * y0 * z1], [1, x0, y1, z1, x0 * y1, x0 * z1, y1 * z1, x0 * y1 * z1], [1, x1, y1, z1, x1 * y1, x1 * z1, y1 * z1, x1 * y1 * z1], ] ) sx = np.array( [ [u[f000[0]][f000[1]][f000[2]]], [u[f100[0]][f100[1]][f100[2]]], [u[f010[0]][f010[1]][f010[2]]], [u[f110[0]][f110[1]][f110[2]]], [u[f001[0]][f001[1]][f001[2]]], [u[f101[0]][f101[1]][f101[2]]], [u[f011[0]][f011[1]][f011[2]]], [u[f111[0]][f111[1]][f111[2]]], ] ) sy = np.array( [ [v[f000[0]][f000[1]][f000[2]]], [v[f100[0]][f100[1]][f100[2]]], [v[f010[0]][f010[1]][f010[2]]], [v[f110[0]][f110[1]][f110[2]]], [v[f001[0]][f001[1]][f001[2]]], [v[f101[0]][f101[1]][f101[2]]], [v[f011[0]][f011[1]][f011[2]]], [v[f111[0]][f111[1]][f111[2]]], ] ) sz = np.array( [ [w[f000[0]][f000[1]][f000[2]]], [w[f100[0]][f100[1]][f100[2]]], [w[f010[0]][f010[1]][f010[2]]], [w[f110[0]][f110[1]][f110[2]]], [w[f001[0]][f001[1]][f001[2]]], [w[f101[0]][f101[1]][f101[2]]], [w[f011[0]][f011[1]][f011[2]]], [w[f111[0]][f111[1]][f111[2]]], ] ) ax, bx, cx, dx, ex, fx, gx, hx = np.linalg.solve(A, sx).reshape(1, 8)[0] ay, by, cy, dy, ey, fy, gy, hy = np.linalg.solve(A, sy).reshape(1, 8)[0] az, bz, cz, dz, ez, fz, gz, hz = np.linalg.solve(A, sz).reshape(1, 8)[0] return np.array( [ [ax, bx, cx, dx, ex, fx, gx, hx], [ay, by, cy, dy, ey, fy, gy, hy], [az, bz, cz, dz, ez, fz, gz, hz], ] )
[docs] def trilinear_approx(vspace, cell): r""" Return a function whose input is a coordinate within a given grid cell and returns the trilinearly approximated vector value at that particular coordinate in that grid cell. .. note:: This functionality is still under development and the API may change in future releases. Parameters ---------- vspace: |array_like| The vector space as constructed by the vector_space function which is a 1 by 3 array with the first element containing the coordinates, the second element containing the vector values, and the third element containing the delta values for each dimension. cell: |array_like| of integers A grid cell, represented by a 1 by 3 array of integers, which correspond to a grid cell in the vector space. Returns ------- function A function whose input is a coordinate within a given grid cell and returns the trilinearly approximated vector value at that particular coordinate in that grid cell. """ # Calculating coefficients ax, bx, cx, dx, ex, fx, gx, hx = _trilinear_coeff_cal(vspace, cell)[0] ay, by, cy, dy, ey, fy, gy, hy = _trilinear_coeff_cal(vspace, cell)[1] az, bz, cz, dz, ez, fz, gz, hz = _trilinear_coeff_cal(vspace, cell)[2] def approx_func(xInput, yInput, zInput): Bx = ( ax + bx * xInput + cx * yInput + dx * zInput + ex * xInput * yInput + fx * xInput * zInput + gx * yInput * zInput + hx * xInput * yInput * zInput ) By = ( ay + by * xInput + cy * yInput + dy * zInput + ey * xInput * yInput + fy * xInput * zInput + gy * yInput * zInput + hy * xInput * yInput * zInput ) Bz = ( az + bz * xInput + cz * yInput + dz * zInput + ez * xInput * yInput + fz * xInput * zInput + gz * yInput * zInput + hz * xInput * yInput * zInput ) return np.array([Bx, By, Bz]) return approx_func
def _trilinear_jacobian(vspace, cell): r""" Returns a function whose input is a coordinate within a given grid cell and returns the trilinearly approximated jacobian matrix for that particular coordinate in that grid cell. Parameters ---------- vspace: |array_like| The vector space as constructed by the vector_space function which is a 1 by 3 array with the first element containing the coordinates, the second element containing the vector values, and the third element containing the delta values for each dimension. cell: |array_like| of integers A grid cell, represented by a 1 by 3 array of integers, which correspond to a grid cell in the vector space. Returns ------- A function whose input is a coordinate within a given grid cell and returns the trilinearly approximated jacobian matrix for that particular coordinate in that grid cell. """ # Calculating coefficients ax, bx, cx, dx, ex, fx, gx, hx = _trilinear_coeff_cal(vspace, cell)[0] ay, by, cy, dy, ey, fy, gy, hy = _trilinear_coeff_cal(vspace, cell)[1] az, bz, cz, dz, ez, fz, gz, hz = _trilinear_coeff_cal(vspace, cell)[2] def jacobian_func(xInput, yInput, zInput): dBxdx = bx + ex * yInput + fx * zInput + hx * yInput * zInput dBxdy = cx + ex * xInput + gx * zInput + hx * xInput * yInput dBxdz = dx + fx * xInput + gx * yInput + hx * xInput * yInput dBydx = by + ey * yInput + fy * zInput + hy * yInput * zInput dBydy = cy + ey * xInput + gy * zInput + hy * xInput * yInput dBydz = dy + fy * xInput + gy * yInput + hy * xInput * yInput dBzdx = bz + ez * yInput + fz * zInput + hz * yInput * zInput dBzdy = cz + ez * xInput + gz * zInput + hz * xInput * yInput dBzdz = dz + fz * xInput + gz * yInput + hz * xInput * yInput def get_scalar(v: float | np.ndarray): """ If a `float`, return the argument. If a `numpy.ndarray` with a single element, return that element. """ if not hasattr(v, "__len__"): return v if len(v) != 1: raise ValueError("Unable to get scalar from multi-element array.") return v[0] # type: ignore[index] return np.array( [ get_scalar(dBxdx), get_scalar(dBxdy), get_scalar(dBxdz), get_scalar(dBydx), get_scalar(dBydy), get_scalar(dBydz), get_scalar(dBzdx), get_scalar(dBzdy), get_scalar(dBzdz), ] ).reshape(3, 3) return jacobian_func def _reduction(vspace, cell): r""" Return a true or false based on whether a grid cell passes the reduction phase, meaning that they potentially contain a null point. Parameters ---------- vspace: |array_like| The vector space as constructed by the vector_space function which is a 1 by 3 array with the first element containing the coordinates, the second element containing the vector values, and the third element containing the delta values for each dimension. cell: |array_like| of integers A grid cell, represented by a 1 by 3 array of integers, which correspond to a grid cell in the vector space. Returns ------- bool `True` if a grid cell passes the reduction phase. `False`, otherwise. Notes ----- Depending on the grid resolution, a cell containing more than one null point may not pass reduction, so it would not be detected. """ u, v, w = vspace[1] f000 = cell f001 = [cell[0], cell[1], cell[2] + 1] f010 = [cell[0], cell[1] + 1, cell[2]] f011 = [cell[0], cell[1] + 1, cell[2] + 1] f100 = [cell[0] + 1, cell[1], cell[2]] f101 = [cell[0] + 1, cell[1], cell[2] + 1] f110 = [cell[0] + 1, cell[1] + 1, cell[2]] f111 = [cell[0] + 1, cell[1] + 1, cell[2] + 1] corners = [f000, f001, f010, f011, f100, f101, f110, f111] passX = False passY = False passZ = False # Check reduction criteria sign_x = np.sign(u[cell[0]][cell[1]][cell[2]]) sign_y = np.sign(v[cell[0]][cell[1]][cell[2]]) sign_z = np.sign(w[cell[0]][cell[1]][cell[2]]) for point in corners: if ( u[point[0]][point[1]][point[2]] == 0 or np.sign(u[point[0]][point[1]][point[2]]) != sign_x ): passX = True if ( v[point[0]][point[1]][point[2]] == 0 or np.sign(v[point[0]][point[1]][point[2]]) != sign_y ): passY = True if ( w[point[0]][point[1]][point[2]] == 0 or np.sign(w[point[0]][point[1]][point[2]]) != sign_z ): passZ = True return passX and passY and passZ def _bilinear_root(a1, b1, c1, d1, a2, b2, c2, d2): # noqa: C901, PLR0911, PLR0912 r""" Return the roots of a pair of bilinear equations of the following format. .. math:: a_1 + b_1 x + c_1 y + d_1 x y = 0 \\ a_2 + b_2 x + c_2 y + d_2 x y = 0 Parameters ---------- a1: float b1: float c1: float d1: float a2: float b2: float c2: float d2: float Returns ------- roots : |array_like| of floats A 1 by 2 array containing the two roots. """ m1 = np.array([[a1, a2], [c1, c2]]) m2 = np.array([[a1, a2], [d1, d2]]) m3 = np.array([[b1, b2], [c1, c2]]) m4 = np.array([[b1, b2], [d1, d2]]) a = np.linalg.det(m4) b = np.linalg.det(m2) + np.linalg.det(m3) c = np.linalg.det(m1) if np.isclose(a, 0, atol=_EQUALITY_ATOL): if np.isclose(b, 0, atol=_EQUALITY_ATOL): return np.array([]) x1 = (-1.0 * c) / b x2 = (-1.0 * c) / b else: if (b**2 - 4.0 * a * c) < 0: return np.array([]) x1 = (-1.0 * b + (b**2 - 4.0 * a * c) ** 0.5) / (2.0 * a) x2 = (-1.0 * b - (b**2 - 4.0 * a * c) ** 0.5) / (2.0 * a) y1 = None y2 = None if not (np.isclose((c1 + d1 * x1), 0, atol=_EQUALITY_ATOL)): y1 = (-a1 - b1 * x1) / (c1 + d1 * x1) elif not (np.isclose((c2 + d2 * x1), 0, atol=_EQUALITY_ATOL)): y1 = (-a2 - b2 * x1) / (c2 + d2 * x1) if not (np.isclose((c1 + d1 * x2), 0, atol=_EQUALITY_ATOL)): y2 = (-a1 - b1 * x2) / (c1 + d1 * x2) elif not (np.isclose((c2 + d2 * x2), 0, atol=_EQUALITY_ATOL)): y2 = (-a2 - b2 * x2) / (c2 + d2 * x2) if y1 is y2 is None: return np.array([]) elif y1 is None: return np.array([(x2, y2)]) elif y2 is None: return np.array([(x1, y1)]) else: d = np.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2) if np.isclose(d, 0, atol=_EQUALITY_ATOL): return np.array([(x1, y1)]) else: return np.array([(x1, y1), (x2, y2)]) def _trilinear_analysis(vspace, cell): # noqa: C901, PLR0915 r""" Return a true or false value based on whether a grid cell which has passed the reduction step, contains a null point, using trilinear analysis. Parameters ---------- vspace: |array_like| The vector space as constructed by the vector_space function which is a 1 by 3 array with the first element containing the coordinates, the second element containing the vector values, and the third element containing the delta values for each dimension. cell: |array_like| of integers A grid cell, represented by a 1 by 3 array of integers, which correspond to a grid cell in the vector space. Returns ------- bool `True` if a grid cell contains a null point using trilinear analysis. `False`, otherwise. Raises ------ This function does not raise any exceptions. Warns ----- :`UserWarning` If there is a possible lack of grid resolution, so that a grid cell may contain more than one null point. """ # Critical Cell Corners f000 = cell f111 = [cell[0] + 1, cell[1] + 1, cell[2] + 1] # Calculating coefficients ax, bx, cx, dx, ex, fx, gx, hx = _trilinear_coeff_cal(vspace, cell)[0] ay, by, cy, dy, ey, fy, gy, hy = _trilinear_coeff_cal(vspace, cell)[1] az, bz, cz, dz, ez, fz, gz, hz = _trilinear_coeff_cal(vspace, cell)[2] # Initial Position of the cell corner initial = np.array( [ vspace[0][0][f000[0]][f000[1]][f000[2]], vspace[0][1][f000[0]][f000[1]][f000[2]], vspace[0][2][f000[0]][f000[1]][f000[2]], ] ) # Arrays holding the endpoints BxByEndpoints = [] BxBzEndpoints = [] ByBzEndpoints = [] # Check if the null point already exists in root list def is_root_in_list(root, arr) -> bool: for r in arr: x_close = np.isclose(root[0], r[0], atol=_EQUALITY_ATOL) y_close = np.isclose(root[1], r[1], atol=_EQUALITY_ATOL) z_close = np.isclose(root[2], r[2], atol=_EQUALITY_ATOL) if x_close and y_close and z_close: return True return False # Front Surface yConst1 = vspace[0][1][f000[0]][f000[1]][ f000[2] ] # y-coordinate of the front surface # Bx=By=0 Curve Endpoint root_list = _bilinear_root( ax + cx * yConst1, bx + ex * yConst1, dx + gx * yConst1, fx + hx * yConst1, ay + cy * yConst1, by + ey * yConst1, dy + gy * yConst1, fy + hy * yConst1, ) BxByEndpoints.extend( [ (root[0], yConst1, root[1]) for root in root_list if not is_root_in_list((root[0], yConst1, root[1]), BxByEndpoints) ] ) # Bx=Bz=0 Curve Endpoint root_list = _bilinear_root( ax + cx * yConst1, bx + ex * yConst1, dx + gx * yConst1, fx + hx * yConst1, az + cz * yConst1, bz + ez * yConst1, dz + gz * yConst1, fz + hz * yConst1, ) BxBzEndpoints.extend( [ (root[0], yConst1, root[1]) for root in root_list if not is_root_in_list((root[0], yConst1, root[1]), BxBzEndpoints) ] ) # By=Bz=0 Curve Endpoint root_list = _bilinear_root( ay + cy * yConst1, by + ey * yConst1, dy + gy * yConst1, fy + hy * yConst1, az + cz * yConst1, bz + ez * yConst1, dz + gz * yConst1, fz + hz * yConst1, ) ByBzEndpoints.extend( [ (root[0], yConst1, root[1]) for root in root_list if not is_root_in_list((root[0], yConst1, root[1]), ByBzEndpoints) ] ) # Back Surface yConst2 = vspace[0][1][f111[0]][f111[1]][ f111[2] ] # y-coordinate of the front surface # Bx=By=0 Curve Endpoint root_list = _bilinear_root( ax + cx * yConst2, bx + ex * yConst2, dx + gx * yConst2, fx + hx * yConst2, ay + cy * yConst2, by + ey * yConst2, dy + gy * yConst2, fy + hy * yConst2, ) BxByEndpoints.extend( [ (root[0], yConst2, root[1]) for root in root_list if not is_root_in_list((root[0], yConst2, root[1]), BxByEndpoints) ] ) # Bx=Bz=0 Curve Endpoint root_list = _bilinear_root( ax + cx * yConst2, bx + ex * yConst2, dx + gx * yConst2, fx + hx * yConst2, az + cz * yConst2, bz + ez * yConst2, dz + gz * yConst2, fz + hz * yConst2, ) BxBzEndpoints.extend( [ (root[0], yConst2, root[1]) for root in root_list if not is_root_in_list((root[0], yConst2, root[1]), BxBzEndpoints) ] ) # By=Bz=0 Curve Endpoint root_list = _bilinear_root( ay + cy * yConst2, by + ey * yConst2, dy + gy * yConst2, fy + hy * yConst2, az + cz * yConst2, bz + ez * yConst2, dz + gz * yConst2, fz + hz * yConst2, ) ByBzEndpoints.extend( [ (root[0], yConst2, root[1]) for root in root_list if not is_root_in_list((root[0], yConst2, root[1]), ByBzEndpoints) ] ) # Right Surface xConst1 = vspace[0][0][f111[0]][f111[1]][f111[2]] # Bx=By=0 Curve Endpoint root_list = _bilinear_root( ax + bx * xConst1, cx + ex * xConst1, dx + fx * xConst1, gx + hx * xConst1, ay + by * xConst1, cy + ey * xConst1, dy + fy * xConst1, gy + hy * xConst1, ) BxByEndpoints.extend( [ (xConst1, root[0], root[1]) for root in root_list if not is_root_in_list((xConst1, root[0], root[1]), BxByEndpoints) ] ) # Bx=BZ=0 Curve Endpoint root_list = _bilinear_root( ax + bx * xConst1, cx + ex * xConst1, dx + fx * xConst1, gx + hx * xConst1, az + bz * xConst1, cz + ez * xConst1, dz + fz * xConst1, gz + hz * xConst1, ) BxBzEndpoints.extend( [ (xConst1, root[0], root[1]) for root in root_list if not is_root_in_list((xConst1, root[0], root[1]), BxBzEndpoints) ] ) # By=Bz=0 Curve Endpoint root_list = _bilinear_root( ay + by * xConst1, cy + ey * xConst1, dy + fy * xConst1, gy + hy * xConst1, az + bz * xConst1, cz + ez * xConst1, dz + fz * xConst1, gz + hz * xConst1, ) ByBzEndpoints.extend( [ (xConst1, root[0], root[1]) for root in root_list if not is_root_in_list((xConst1, root[0], root[1]), ByBzEndpoints) ] ) # Left Surface xConst2 = vspace[0][0][f000[0]][f000[1]][f000[2]] # Bx=By=0 Curve Endpoint root_list = _bilinear_root( ax + bx * xConst2, cx + ex * xConst2, dx + fx * xConst2, gx + hx * xConst2, ay + by * xConst2, cy + ey * xConst2, dy + fy * xConst2, gy + hy * xConst2, ) BxByEndpoints.extend( [ (xConst2, root[0], root[1]) for root in root_list if not is_root_in_list((xConst2, root[0], root[1]), BxByEndpoints) ] ) # Bx=BZ=0 Curve Endpoint root_list = _bilinear_root( ax + bx * xConst2, cx + ex * xConst2, dx + fx * xConst2, gx + hx * xConst2, az + bz * xConst2, cz + ez * xConst2, dz + fz * xConst2, gz + hz * xConst2, ) BxBzEndpoints.extend( [ (xConst2, root[0], root[1]) for root in root_list if not is_root_in_list((xConst2, root[0], root[1]), BxBzEndpoints) ] ) # By=Bz=0 Curve Endpoint root_list = _bilinear_root( ay + by * xConst2, cy + ey * xConst2, dy + fy * xConst2, gy + hy * xConst2, az + bz * xConst2, cz + ez * xConst2, dz + fz * xConst2, gz + hz * xConst2, ) ByBzEndpoints.extend( [ (xConst2, root[0], root[1]) for root in root_list if not is_root_in_list((xConst2, root[0], root[1]), ByBzEndpoints) ] ) # Up Surface zConst1 = vspace[0][2][f111[0]][f111[1]][f111[2]] # Bx=By=0 Curve Endpoint root_list = _bilinear_root( ax + dx * zConst1, bx + fx * zConst1, cx + gx * zConst1, ex + hx * zConst1, ay + dy * zConst1, by + fy * zConst1, cy + gy * zConst1, ey + hy * zConst1, ) BxByEndpoints.extend( [ (root[0], root[1], zConst1) for root in root_list if not is_root_in_list((root[0], root[1], zConst1), BxByEndpoints) ] ) # Bx=Bz=0 Curve Endpoint root_list = _bilinear_root( ax + dx * zConst1, bx + fx * zConst1, cx + gx * zConst1, ex + hx * zConst1, az + dz * zConst1, bz + fz * zConst1, cz + gz * zConst1, ez + hz * zConst1, ) BxBzEndpoints.extend( [ (root[0], root[1], zConst1) for root in root_list if not is_root_in_list((root[0], root[1], zConst1), BxBzEndpoints) ] ) # By=Bz=0 Curve Endpoint root_list = _bilinear_root( ay + dy * zConst1, by + fy * zConst1, cy + gy * zConst1, ey + hy * zConst1, az + dz * zConst1, bz + fz * zConst1, cz + gz * zConst1, ez + hz * zConst1, ) ByBzEndpoints.extend( [ (root[0], root[1], zConst1) for root in root_list if not is_root_in_list((root[0], root[1], zConst1), ByBzEndpoints) ] ) # Down Surface zConst2 = vspace[0][2][f000[0]][f000[1]][f000[2]] # Bx=By=0 Curve Endpoint root_list = _bilinear_root( ax + dx * zConst2, bx + fx * zConst2, cx + gx * zConst2, ex + hx * zConst2, ay + dy * zConst2, by + fy * zConst2, cy + gy * zConst2, ey + hy * zConst2, ) BxByEndpoints.extend( [ (root[0], root[1], zConst2) for root in root_list if not is_root_in_list((root[0], root[1], zConst2), BxByEndpoints) ] ) # Bx=Bz=0 Curve Endpoint root_list = _bilinear_root( ax + dx * zConst2, bx + fx * zConst2, cx + gx * zConst2, ex + hx * zConst2, az + dz * zConst2, bz + fz * zConst2, cz + gz * zConst2, ez + hz * zConst2, ) BxBzEndpoints.extend( [ (root[0], root[1], zConst2) for root in root_list if not is_root_in_list((root[0], root[1], zConst2), BxBzEndpoints) ] ) # By=Bz=0 Curve Endpoint root_list = _bilinear_root( ay + dy * zConst2, by + fy * zConst2, cy + gy * zConst2, ey + hy * zConst2, az + dz * zConst2, bz + fz * zConst2, cz + gz * zConst2, ez + hz * zConst2, ) ByBzEndpoints.extend( [ (root[0], root[1], zConst2) for root in root_list if not is_root_in_list((root[0], root[1], zConst2), ByBzEndpoints) ] ) xbound = vspace[0][0][f111[0]][f111[1]][f111[2]] ybound = vspace[0][1][f111[0]][f111[1]][f111[2]] zbound = vspace[0][2][f111[0]][f111[1]][f111[2]] def bound(epoint): """ Checks if the endpoints are located in or on the cube. """ is_x_in_bound = ( initial[0] < epoint[0] or np.isclose(initial[0], epoint[0], atol=_EQUALITY_ATOL) ) and (epoint[0] < xbound or np.isclose(epoint[0], xbound, atol=_EQUALITY_ATOL)) is_y_in_bound = ( initial[1] < epoint[1] or np.isclose(initial[1], epoint[1], atol=_EQUALITY_ATOL) ) and (epoint[1] < ybound or np.isclose(epoint[1], ybound, atol=_EQUALITY_ATOL)) is_z_in_bound = ( initial[2] < epoint[2] or np.isclose(initial[2], epoint[2], atol=_EQUALITY_ATOL) ) and (epoint[2] < zbound or np.isclose(epoint[2], zbound, atol=_EQUALITY_ATOL)) return is_x_in_bound and is_y_in_bound and is_z_in_bound BxByEndpoints = list(filter(bound, BxByEndpoints)) BxBzEndpoints = list(filter(bound, BxBzEndpoints)) ByBzEndpoints = list(filter(bound, ByBzEndpoints)) tlApprox = trilinear_approx(vspace, cell) # Check on the Surfaces for p in BxByEndpoints: if np.linalg.norm(tlApprox(p[0], p[1], p[2])) < _EQUALITY_ATOL: # print(cell) # print(tlApprox(p[0], p[1], p[2])) return True for p in BxBzEndpoints: if np.linalg.norm(tlApprox(p[0], p[1], p[2])) < _EQUALITY_ATOL: # print(cell) # print(tlApprox(p[0], p[1], p[2])) return True for p in ByBzEndpoints: if np.linalg.norm(tlApprox(p[0], p[1], p[2])) < _EQUALITY_ATOL: # print(cell) # print(tlApprox(p[0], p[1], p[2])) return True # Check Grid Resolution if len(BxByEndpoints) == len(BxBzEndpoints) == len(ByBzEndpoints) == 0: warnings.warn( "Multiple null points suspected. Trilinear method may not work as intended.", MultipleNullPointWarning, ) return False if len(BxByEndpoints) != 2 or len(BxBzEndpoints) != 2 or len(ByBzEndpoints) != 2: return False def endpoint_sign_check(curve_endpoints, curve_name: str): if curve_name == "x": index = 0 elif curve_name == "y": index = 1 elif curve_name == "z": index = 2 first_endpoint = tlApprox( curve_endpoints[0][0], curve_endpoints[0][1], curve_endpoints[0][2] )[index] second_endpoint = tlApprox( curve_endpoints[1][0], curve_endpoints[1][1], curve_endpoints[1][2] )[index] if np.isclose(first_endpoint, 0, atol=_EQUALITY_ATOL) or np.isclose( second_endpoint, 0, atol=_EQUALITY_ATOL ): return True return np.sign(first_endpoint) * np.sign(second_endpoint) <= 0 opposite_sign_z = endpoint_sign_check(BxByEndpoints, "z") opposite_sign_y = endpoint_sign_check(BxBzEndpoints, "y") opposite_sign_x = endpoint_sign_check(ByBzEndpoints, "x") return bool(opposite_sign_x and opposite_sign_y and opposite_sign_z) def _locate_null_point(vspace, cell, n, err): r""" Return the coordinates of a null point within a given grid cell in a vector space using the Newton-Rapshon method. Multiple initial positions are tried until either one converges inside a the grid cell, or the maximum iteration is reached. If neither occurs, more starting positions are tried, by breaking up the cell into 8 smaller sub-grid cells, until one starting position does converge or stop inside the grid cell. This process is repeated a finite amount of times, after which the function returns `None`. Parameters ---------- vspace : |array_like| The vector space as constructed by the vector_space function which is a 1 by 3 array with the first element containing the coordinates, the second element containing the vector values, and the third element containing the delta values for each dimension. cell : |array_like| of integers A grid cell, represented by a 1 by 3 array of integers, which correspond to a grid cell in the vector space. n : int The maximum number of times the iterative step of the Newton-Raphson method is repeated. err : float The threshold/error that determines if convergence has occurred using the Newton-Raphson method. Returns ------- |array_like| of floats A 1 by 3 array containing the converged coordinates of the null point. NoneType `None` if the coordinates of the null point could not be converged at a point inside the grid cell. Warns ----- :`UserWarning` If the maximum number of iteration has been reached, but convergence has not occurred. """ global _recursion_level # noqa: PLW0602 # Calculating the Jacobian and trilinear approximation functions for the cell tlApprox = trilinear_approx(vspace, cell) jcb = _trilinear_jacobian(vspace, cell) # Calculating the deltas deltax, deltay, deltaz = vspace[2] deltax = deltax[cell[0]] deltay = deltay[cell[1]] deltaz = deltaz[cell[2]] f000 = cell f001 = [cell[0], cell[1], cell[2] + 1] f010 = [cell[0], cell[1] + 1, cell[2]] f011 = [cell[0], cell[1] + 1, cell[2] + 1] f100 = [cell[0] + 1, cell[1], cell[2]] f101 = [cell[0] + 1, cell[1], cell[2] + 1] f110 = [cell[0] + 1, cell[1] + 1, cell[2]] f111 = [cell[0] + 1, cell[1] + 1, cell[2] + 1] corners = [f000, f001, f010, f011, f100, f101, f110, f111] # Critical Coordinates pos_000 = np.array( [ vspace[0][0][f000[0]][f000[1]][f000[2]], vspace[0][1][f000[0]][f000[1]][f000[2]], vspace[0][2][f000[0]][f000[1]][f000[2]], ] ) pos_111 = np.array( [ vspace[0][0][f111[0]][f111[1]][f111[2]], vspace[0][1][f111[0]][f111[1]][f111[2]], vspace[0][2][f111[0]][f111[1]][f111[2]], ] ) def in_bound(pos): """ Checks if the estimated position is located inside the cube. """ pos = pos.reshape(1, 3)[0] is_x_in_bound = ( np.isclose(pos_000[0], pos[0], atol=_EQUALITY_ATOL) or pos_000[0] < pos[0] ) and ( np.isclose(pos[0], pos_111[0], atol=_EQUALITY_ATOL) or pos[0] < pos_111[0] ) is_y_in_bound = ( np.isclose(pos_000[1], pos[1], atol=_EQUALITY_ATOL) or pos_000[1] < pos[1] ) and ( np.isclose(pos[1], pos_111[1], atol=_EQUALITY_ATOL) or pos[1] < pos_111[1] ) is_z_in_bound = ( np.isclose(pos_000[2], pos[2], atol=_EQUALITY_ATOL) or pos_000[2] < pos[2] ) and ( np.isclose(pos[2], pos_111[2], atol=_EQUALITY_ATOL) or pos[2] < pos_111[2] ) return is_x_in_bound and is_y_in_bound and is_z_in_bound starting_pos = [] # Adding the Corners starting_pos = [ [ vspace[0][0][point[0]][point[1]][point[2]], vspace[0][1][point[0]][point[1]][point[2]], vspace[0][2][point[0]][point[1]][point[2]], ] for point in corners ] # Adding the Mid Point starting_pos.append( [ vspace[0][0][f000[0]][f000[1]][f000[2]] + deltax / 2.0, vspace[0][1][f000[0]][f000[1]][f000[2]] + deltay / 2.0, vspace[0][2][f000[0]][f000[1]][f000[2]] + deltaz / 2.0, ] ) # Newton Iteration for x0 in starting_pos: x0 = np.array(x0) # noqa: PLW2901 x0 = x0.reshape(3, 1) # noqa: PLW2901 for _i in range(n): # noqa: B007 locx = tlApprox(x0[0], x0[1], x0[2])[0] locy = tlApprox(x0[0], x0[1], x0[2])[1] locz = tlApprox(x0[0], x0[1], x0[2])[2] Bx0 = np.array([locx, locy, locz]) Bx0 = Bx0.reshape(3, 1) prev_norm = np.linalg.norm(x0) # Too many null points if the determinant of the Jacobian is zero if np.isclose( np.linalg.det(jcb(x0[0], x0[1], x0[2])), 0, atol=_EQUALITY_ATOL ): warnings.warn( "Multiple null points suspected. Trilinear method may not work as intended.", MultipleNullPointWarning, ) if ( np.isclose(locx, 0, atol=_EQUALITY_ATOL) and np.isclose(locy, 0, atol=_EQUALITY_ATOL) and np.isclose(locz, 0, atol=_EQUALITY_ATOL) ): return x0 else: break # Adjust position x0 = np.subtract( # noqa: PLW2901 x0, np.matmul(np.linalg.inv(jcb(x0[0], x0[1], x0[2])), Bx0) ) norm = np.linalg.norm(x0) if np.abs((norm - prev_norm) / (prev_norm + 1e-10)) < err and in_bound(x0): return x0 if in_bound(x0): warnings.warn("Max Iterations Reached without Convergence") if ( np.isclose(locx, 0, atol=_EQUALITY_ATOL) and np.isclose(locy, 0, atol=_EQUALITY_ATOL) and np.isclose(locz, 0, atol=_EQUALITY_ATOL) ): return x0 warnings.warn("Various starting points did not locate possible null point.") # Generate new starting points localized into 8 small cells? return None def _classify_null_point(vspace, cell, loc): r""" Return the coordinates of a null point within a given grid cell in a vector space using the Newton-Rapshon method. Multiple initial positions are tried until either one converges inside a the grid cell, or the maximum iteration is reached. If neither occurs, more starting positions are tried, by breaking up the cell into 8 smaller sub-grid cells, until one starting position does converge or stop inside the grid cell. This process is repeated a finite amount of times, after which the function returns `None`. Parameters ---------- vspace: |array_like| The vector space as constructed by the vector_space function which is a 1 by 3 array with the first element containing the coordinates, the second element containing the vector values, and the third element containing the delta values for each dimension. cell: |array_like| of integers A grid cell, represented by a 1 by 3 array of integers, which correspond to a grid cell in the vector space. Returns ------- str A string describing the null point type. Raises ------ NonZeroDivergence If the divergence of the given vector space is not sufficiently close to zero at the null point. Notes ----- This method is described by :cite:t:`parnell:1996`. """ jcb = _trilinear_jacobian(vspace, cell) M = jcb(loc[0], loc[1], loc[2]) if not np.isclose(np.trace(M), 0, atol=_EQUALITY_ATOL): raise NonZeroDivergence eigen_vals, eigen_vectors = np.linalg.eig(M) # using the notation from Parnell et al. (1996) R = -1.0 * np.linalg.det(M) Q = -0.5 * np.trace(np.matmul(M, M)) discriminant = (Q**3 / 27.0) + (R**2 / 4.0) determinant = -1.0 * R if np.isclose(discriminant, 0, atol=_EQUALITY_ATOL): if np.allclose(M, M.T, atol=_EQUALITY_ATOL): # Checking if M is symmetric null_point_type = "Proper radial null" else: null_point_type = ( "Anti-parallel lines with null plane OR Planes of parabolae with null line" if np.isclose(determinant, 0, atol=_EQUALITY_ATOL) else "Critical spiral null" ) elif discriminant < 0: if np.allclose(M, M.T, atol=_EQUALITY_ATOL): null_point_type = ( "Continuous potential X-points" if np.isclose(determinant, 0, atol=_EQUALITY_ATOL) else "Improper radial null" ) elif np.isclose(determinant, 0, atol=_EQUALITY_ATOL): null_point_type = "Continuous X-points" else: null_point_type = "Skewed improper null" elif np.isclose(determinant, 0, atol=_EQUALITY_ATOL): null_point_type = "Continuous concentric ellipses" else: null_point_type = "Spiral null" return null_point_type def _vspace_iterator(vspace, maxiter=500, err: float = 1e-10): r""" Returns an array of null point objects, representing the null points of the given vector space. Parameters ---------- vspace : |array_like| The vector space as constructed by the ``_vector_space`` function which is a 1 by 3 array with the first element containing the coordinates, the second element containing the vector values, and the third element containing the delta values for each dimension. maxiter : int, default: 500 The maximum iterations of the Newton-Raphson method. err : float, default: ``1e-10`` The threshold/error that determines if convergence has occurred using the Newton-Raphson method. Returns ------- |array_like| of `~plasmapy.analysis.nullpoint.NullPoint` An array of `~plasmapy.analysis.nullpoint.NullPoint` objects representing the null points of the given vector space. """ nullpoints = [] for i in range(len(vspace[0][0]) - 1): for j in range(len(vspace[0][0][0]) - 1): for k in range(len(vspace[0][0][0][0]) - 1): if _reduction(vspace, [i, j, k]) and _trilinear_analysis( vspace, [i, j, k] ): loc = _locate_null_point(vspace, [i, j, k], maxiter, err) if loc is not None: null_type = _classify_null_point(vspace, [i, j, k], loc) p = NullPoint(loc, null_type) if p not in nullpoints: nullpoints.append(p) return nullpoints
[docs] def null_point_find( x_arr=None, y_arr=None, z_arr=None, u_arr=None, v_arr=None, w_arr=None, maxiter=500, err: float = 1e-10, ): r""" Returns an array of `~plasmapy.analysis.nullpoint.NullPoint` object, representing the null points of the given vector space. .. note:: This functionality is still under development and the API may change in future releases. Parameters ---------- x_arr: |array_like| The array representing the coordinates in the x-dimension. If not given, then range values are used to construct a uniform array on that interval. y_arr: |array_like| The array representing the coordinates in the y-dimension. If not given, then range values are used to construct a uniform array on that interval. z_arr: |array_like| The array representing the coordinates in the z-dimension. If not given, then range values are used to construct a uniform array on that interval. u_arr: |array_like| A 3D array containing the x-component of the vector values for the vector space. If not given, the vector values are generated over the vector space using the function func. v_arr: |array_like| A 3D array containing the y-component of the vector values for the vector space. If not given, the vector values are generated over the vector space using the function func. w_arr: |array_like| A 3D array containing the z-component of the vector values for the vector space. If not given, the vector values are generated over the vector space using the function func. maxiter: int, default: 500 The maximum iterations of the Newton-Raphson method. err: float, default: ``1e-10`` The threshold/error that determines if convergence has occurred using the Newton-Raphson method. Returns ------- |array_like| of `~plasmapy.analysis.nullpoint.NullPoint` An array of `~plasmapy.analysis.nullpoint.NullPoint` objects representing the null points of the given vector space. Notes ----- This method is described by :cite:t:`haynes:2007`. """ # Constructing the vspace vspace = _vector_space( x_arr, y_arr, z_arr, None, None, None, u_arr, v_arr, w_arr, None, None, ) return _vspace_iterator(vspace, maxiter, err)
[docs] def uniform_null_point_find( x_range, y_range, z_range, func: Callable, precision=(0.05, 0.05, 0.05), maxiter=500, err: float = 1e-10, ): r""" Return an array of `~plasmapy.analysis.nullpoint.NullPoint` objects, representing the null points of the given vector space. .. note:: This functionality is still under development and the API may change in future releases. Parameters ---------- x_range: |array_like|, default: ``[0, 1]`` A 1 by 2 array containing the range of x-values for the vector spaces. y_range: |array_like|, default: ``[0, 1]`` A 1 by 2 array containing the range of y-values for the vector spaces. z_range: |array_like|, default: ``[0, 1]`` A 1 by 2 array containing the range of z-values for the vector spaces. func: function A function that takes in 3 arguments, respectively representing an :math:`(x, y, z)` coordinate of a point and returns the vector value for that point in the form of a 1 by 3 array. precision: |array_like|, default: ``[0.05, 0.05, 0.05]`` A 1 by 3 array containing the approximate precision values for each dimension, in the case where uniform arrays are being used. Returns ------- |array_like| of `~plasmapy.analysis.nullpoint.NullPoint` An array of `~plasmapy.analysis.nullpoint.NullPoint` objects representing the null points of the given vector space. Notes ----- This method is described by :cite:t:`haynes:2007`. """ vspace = _vector_space( None, None, None, x_range, y_range, z_range, None, None, None, func, precision, ) return _vspace_iterator(vspace, maxiter, err)