Source code for scpn_fusion.core.runaway_kinetic_grid

# SPDX-License-Identifier: AGPL-3.0-or-later
# Commercial license available
# © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
# © Code 2020–2026 Miroslav Šotek. All rights reserved.
# ORCID: 0009-0009-3560-0851
# Contact: www.anulum.li | protoscience@anulum.li
# SCPN Fusion Core — Conservative Runaway Kinetic Grid
"""Physical radius-momentum-pitch grids for runaway kinetic evolution."""

from __future__ import annotations

from dataclasses import dataclass
from typing import cast

import numpy as np
from numpy.typing import NDArray


FloatArray = NDArray[np.float64]


def _strict_faces(name: str, values: FloatArray, *, lower: float | None = None) -> FloatArray:
    faces = np.asarray(values, dtype=np.float64)
    if faces.ndim != 1 or faces.size < 2:
        raise ValueError(f"{name} must be a one-dimensional face array with at least two entries")
    if not np.all(np.isfinite(faces)):
        raise ValueError(f"{name} contains a non-finite coordinate")
    if np.any(np.diff(faces) <= 0.0):
        raise ValueError(f"{name} must be strictly increasing")
    if lower is not None and faces[0] < lower:
        raise ValueError(f"{name} starts below {lower}")
    result = np.array(faces, dtype=np.float64, copy=True)
    result.setflags(write=False)
    return result


[docs] @dataclass(frozen=True) class RunawayKineticGrid: """Tensor-product grid whose three axes are all evolved physical axes. The distribution layout is ``(radius, pitch, momentum)``. Faces are stored explicitly so the same grid can be reconstructed from a DREAM HDF5 output without inventing projected axes. """ radius_faces_m: FloatArray pitch_faces: FloatArray momentum_faces_mc: FloatArray def __post_init__(self) -> None: """Validate and freeze all phase-space cell-face coordinates.""" radius = _strict_faces("radius_faces_m", self.radius_faces_m, lower=0.0) pitch = _strict_faces("pitch_faces", self.pitch_faces) momentum = _strict_faces("momentum_faces_mc", self.momentum_faces_mc, lower=0.0) if pitch[0] < -1.0 or pitch[-1] > 1.0: raise ValueError("pitch_faces must stay within [-1, 1]") object.__setattr__(self, "radius_faces_m", radius) object.__setattr__(self, "pitch_faces", pitch) object.__setattr__(self, "momentum_faces_mc", momentum) @property def nr(self) -> int: """Number of evolved radial cells.""" return self.radius_faces_m.size - 1 @property def nxi(self) -> int: """Number of evolved pitch cells.""" return self.pitch_faces.size - 1 @property def np(self) -> int: """Number of evolved momentum cells.""" return self.momentum_faces_mc.size - 1 @property def shape(self) -> tuple[int, int, int]: """Distribution shape in radius-pitch-momentum order.""" return (self.nr, self.nxi, self.np) @property def radius_m(self) -> FloatArray: """Radial cell centres.""" return 0.5 * (self.radius_faces_m[1:] + self.radius_faces_m[:-1]) @property def pitch(self) -> FloatArray: """Pitch cell centres.""" return 0.5 * (self.pitch_faces[1:] + self.pitch_faces[:-1]) @property def momentum_mc(self) -> FloatArray: """Momentum cell centres in units of electron rest momentum.""" return 0.5 * (self.momentum_faces_mc[1:] + self.momentum_faces_mc[:-1]) @property def radial_shell_measure_m2(self) -> FloatArray: """Cylindrical radial measure ``(r_f[i+1]^2-r_f[i]^2)/2``.""" return 0.5 * (self.radius_faces_m[1:] ** 2 - self.radius_faces_m[:-1] ** 2) @property def momentum_shell_measure(self) -> FloatArray: """Relativistic spherical momentum measure ``(p_f^3)/3``.""" return (self.momentum_faces_mc[1:] ** 3 - self.momentum_faces_mc[:-1] ** 3) / 3.0 @property def phase_space_cell_measure(self) -> FloatArray: """Axisymmetric cylindrical phase-space measure without ``4π²R``. The omitted constant cancels from normalized moments and conservation residuals. Geometry imported from DREAM can instead supply its exact bounce-averaged cell volumes to the operator. """ return cast( FloatArray, self.radial_shell_measure_m2[:, None, None] * np.diff(self.pitch_faces)[None, :, None] * (2.0 * np.pi * self.momentum_shell_measure)[None, None, :], )
[docs] def require_state(self, name: str, values: FloatArray) -> FloatArray: """Validate and copy a finite tensor defined on every physical cell.""" state = np.asarray(values, dtype=np.float64) if state.shape != self.shape: raise ValueError(f"{name} must have shape {self.shape}, got {state.shape}") if not np.all(np.isfinite(state)): raise ValueError(f"{name} contains a non-finite value") return np.array(state, dtype=np.float64, copy=True)
__all__ = ["FloatArray", "RunawayKineticGrid"]