from collections.abc import Callable
import numpy as np
from numpy.typing import NDArray
from scipy.constants import mu_0
from scipy.sparse import csc_matrix
from scipy.sparse.linalg import factorized
from .bindings import flux_circular_filament, gs_operator_order4
_DDX_CENTRAL_ORDER4 = np.array(
[
(-2, 1 / 12),
(-1, -2 / 3),
(1, 2 / 3),
(2, -1 / 12),
]
)
_DDX_FWD_ORDER4 = np.array(
[
(0, -25 / 12),
(1, 4),
(2, -3),
(3, 4 / 3),
(4, -1 / 4),
]
)
_DDX_BWD_ORDER4 = -_DDX_FWD_ORDER4
def gradient_order4(z: NDArray, xmesh: NDArray, ymesh: NDArray) -> tuple[NDArray, NDArray]:
nx, ny = z.shape
assert nx >= 6 and ny >= 6, "gradient_order4 requires each grid dimension to have at least 6 points"
dx = xmesh[1][0] - xmesh[0][0]
dy = ymesh[0][1] - ymesh[0][0]
assert np.all(
np.abs(np.diff(xmesh[:, 0]) - dx) / dx < 1e-6
), "This method is only implemented for a regular grid"
assert np.all(
np.abs(np.diff(ymesh[0, :]) - dy) / dy < 1e-6
), "This method is only implemented for a regular grid"
accumulator_dtype = np.result_type(z, np.float64)
dzdx = np.zeros(z.shape, dtype=accumulator_dtype)
for offs, w in _DDX_CENTRAL_ORDER4:
start = int(2 + offs)
end = int(nx - 2 + offs)
dzdx[2:-2, :] += w * z[start:end, :] / dx left_rows = np.arange(2)
for offs, w in _DDX_FWD_ORDER4:
dzdx[0:2, :] += w * z[left_rows + int(offs), :] / dx right_rows = np.arange(nx - 2, nx)
for offs, w in _DDX_BWD_ORDER4:
dzdx[-2:, :] += w * z[right_rows + int(offs), :] / dx
dzdy = np.zeros(z.shape, dtype=accumulator_dtype)
for offs, w in _DDX_CENTRAL_ORDER4:
start = int(2 + offs)
end = int(ny - 2 + offs)
dzdy[:, 2:-2] += w * z[:, start:end] / dy bottom_cols = np.arange(2)
for offs, w in _DDX_FWD_ORDER4:
dzdy[:, 0:2] += w * z[:, bottom_cols + int(offs)] / dy top_cols = np.arange(ny - 2, ny)
for offs, w in _DDX_BWD_ORDER4:
dzdy[:, -2:] += w * z[:, top_cols + int(offs)] / dy
return dzdx, dzdy
def calc_flux_density_from_flux(psi: NDArray, rmesh: NDArray, zmesh: NDArray) -> tuple[NDArray, NDArray]:
assert not np.any(rmesh <= 0.0), "rmesh must be strictly positive"
dpsidr, dpsidz = gradient_order4(psi, rmesh, zmesh)
r_inv = rmesh**-1
br = -r_inv * dpsidz / (2.0 * np.pi) bz = r_inv * dpsidr / (2.0 * np.pi)
return (br, bz)
def flux_solver(grids: tuple[NDArray, NDArray]) -> Callable[[NDArray], NDArray]:
_ = _check_regular(grids, min_points=7)
rgrid, zgrid = grids
nr = rgrid.size
nz = zgrid.size
vals, rows, cols = gs_operator_order4(*grids)
operator = csc_matrix((vals, (rows, cols)), shape=(nr * nz, nr * nz))
return factorized(operator)
def _validate_flux_mesh_inputs(
grids: tuple[NDArray, NDArray],
meshes: tuple[NDArray, NDArray],
current_density: NDArray,
tol: float = 1e-6,
) -> None:
rgrid, zgrid = grids
rmesh, zmesh = meshes
expected_shape = (rgrid.size, zgrid.size)
transposed_shape = (zgrid.size, rgrid.size)
if (
rmesh.shape != expected_shape
or zmesh.shape != expected_shape
or current_density.shape != expected_shape
):
assert not (
rgrid.size != zgrid.size
and rmesh.shape == transposed_shape
and zmesh.shape == transposed_shape
and current_density.shape == transposed_shape
), "meshes and current_density appear transposed; use np.meshgrid(..., indexing='ij')"
raise AssertionError(f"meshes and current_density must all have shape {expected_shape}")
r_axis_matches = np.allclose(rmesh[:, 0], rgrid, rtol=tol, atol=tol)
z_axis_matches = np.allclose(zmesh[0, :], zgrid, rtol=tol, atol=tol)
assert (
r_axis_matches and z_axis_matches
), "meshes must be consistent with grids and use np.meshgrid(..., indexing='ij')"
def solve_flux_axisymmetric(
grids: tuple[NDArray, NDArray],
meshes: tuple[NDArray, NDArray],
current_density: NDArray,
solver: Callable[[NDArray], NDArray] | None = None,
) -> NDArray:
_ = _check_regular(grids, min_points=7)
_validate_flux_mesh_inputs(grids, meshes, current_density)
solver = solver or flux_solver(grids)
dr, dz = _check_regular(grids, min_points=7) area = dr * dz rmesh, zmesh = meshes assert not (
np.any(current_density[0, :] != 0.0)
or np.any(current_density[-1, :] != 0.0)
or np.any(current_density[:, 0] != 0.0)
or np.any(current_density[:, -1] != 0.0)
), "current_density must be zero on the finite-difference boundary"
nonzero_inds = np.where(current_density != 0.0)
current_density_nonzero = np.ascontiguousarray(current_density[nonzero_inds]) rmesh_nonzero = np.ascontiguousarray(rmesh[nonzero_inds]) zmesh_nonzero = np.ascontiguousarray(zmesh[nonzero_inds]) rhs = -(2.0 * np.pi * mu_0) * rmesh * current_density ifil = (area * current_density_nonzero).flatten() rfil = rmesh_nonzero.flatten()
zfil = zmesh_nonzero.flatten()
for s in [[0, ...], [-1, ...], [..., 0], [..., -1]]: rhs[s[0], s[1]] = flux_circular_filament(ifil, rfil, zfil, rmesh[s[0], s[1]], zmesh[s[0], s[1]])
psi = solver(rhs.flatten()).reshape(rmesh.shape)
return psi
def _check_regular(grids: tuple[NDArray, NDArray], tol=1e-6, min_points: int = 2) -> tuple[float, float]:
rgrid, zgrid = grids
assert (
rgrid.size >= min_points and zgrid.size >= min_points
), f"rgrid and zgrid must each have at least {min_points} points"
assert not np.any(rgrid <= 0.0), "rgrid must be strictly positive"
drs = np.diff(rgrid)
dzs = np.diff(zgrid)
assert not np.any(drs <= 0.0), "rgrid must be strictly increasing"
assert not np.any(dzs <= 0.0), "zgrid must be strictly increasing"
drmean = float(np.mean(drs))
dzmean = float(np.mean(dzs))
assert np.all(np.abs(drs - drmean) / drmean < tol), "rgrid must be regular"
assert np.all(np.abs(dzs - dzmean) / dzmean < tol), "zgrid must be regular"
return drmean, dzmean
__all__ = [
"gradient_order4",
"calc_flux_density_from_flux",
"flux_solver",
"solve_flux_axisymmetric",
]