Source code for scpn_fusion.core.runaway_kinetic_diagnostics

# 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 — Runaway Kinetic Diagnostics
"""Conservation and residual diagnostics for full kinetic trajectories."""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np

from scpn_fusion.core.runaway_kinetic_grid import FloatArray
from scpn_fusion.core.runaway_kinetic_operator import (
    RunawayKineticGeometry,
    RunawayKineticTendencies,
)


[docs] @dataclass(frozen=True) class RunawayKineticBudget: """Phase-space-integrated rate from every declared operator component.""" radial_transport: float electric_acceleration: float collisional_drag_diffusion: float pitch_scattering: float cross_diffusion: float synchrotron_loss: float bremsstrahlung_loss: float avalanche_generation: float external_source: float total: float
[docs] def weighted_relative_l2( actual: FloatArray, expected: FloatArray, weight: FloatArray, *, floor: float = 1.0, ) -> float: """Return a finite weighted relative L2 error with an explicit floor.""" lhs = np.asarray(actual, dtype=np.float64) rhs = np.asarray(expected, dtype=np.float64) weights = np.asarray(weight, dtype=np.float64) if lhs.shape != rhs.shape or lhs.shape != weights.shape: raise ValueError("actual, expected and weight must have identical shapes") if floor <= 0.0 or not np.isfinite(floor): raise ValueError("floor must be finite and positive") numerator = np.sqrt(np.sum(weights * (lhs - rhs) ** 2)) denominator = max(float(np.sqrt(np.sum(weights * rhs**2))), floor) return float(numerator / denominator)
[docs] def integrated_budget( tendencies: RunawayKineticTendencies, geometry: RunawayKineticGeometry, ) -> RunawayKineticBudget: """Integrate every tendency without hiding canceling contributions.""" weight = geometry.cell_measure def integrate(values: FloatArray) -> float: return float(np.sum(values * weight)) return RunawayKineticBudget( radial_transport=integrate(tendencies.radial_transport), electric_acceleration=integrate(tendencies.electric_acceleration), collisional_drag_diffusion=integrate(tendencies.collisional_drag_diffusion), pitch_scattering=integrate(tendencies.pitch_scattering), cross_diffusion=integrate(tendencies.cross_diffusion), synchrotron_loss=integrate(tendencies.synchrotron_loss), bremsstrahlung_loss=integrate(tendencies.bremsstrahlung_loss), avalanche_generation=integrate(tendencies.avalanche_generation), external_source=integrate(tendencies.external_source), total=integrate(tendencies.total), )
[docs] def interval_residual( previous: FloatArray, current: FloatArray, tendency_at_current: FloatArray, geometry: RunawayKineticGeometry, dt_s: float, ) -> float: """Check a backward-Euler/implicit interval against its full tendency.""" if not np.isfinite(dt_s) or dt_s <= 0.0: raise ValueError("dt_s must be finite and positive") finite_difference = (np.asarray(current) - np.asarray(previous)) / dt_s return weighted_relative_l2( np.asarray(tendency_at_current), np.asarray(finite_difference), geometry.cell_measure, )
__all__ = [ "RunawayKineticBudget", "integrated_budget", "interval_residual", "weighted_relative_l2", ]