from .._scarabee import DiffusionCrossSection
import numpy as np
from typing import Optional, Union
[docs]class NodalFlux1D:
[docs] def __init__(
self,
x_min: float,
x_max: float,
keff: float,
xs: DiffusionCrossSection,
avg_flx: np.ndarray,
j_neg: np.ndarray,
j_pos: np.ndarray,
):
"""
Performs the 1D nodal diffusion calculation using the Nodal Expansion
Method (NEM) with reference currents and average fluxes. This
represents the transverse integrated homogeneous nodal flux solution.
The primary use of this class is for computing the homogeneous flux
that is required to define discontinuity factors for generalized
equivalence theory.
Parameters
----------
x_min : float
Position of the negative surface of the node.
x_max : float
Position of the positive surface of the node.
keff : float
Multiplication factor from reference Sn calculation.
xs : DiffusionCrossSection
Diffusion cross sections homogenized for the node.
avg_flx : np.ndarray
Average flux in each group within the node from reference Sn
calculation.
j_neg : np.ndarray
Reference net current in each group on the negative boundary
from the reference calculation.
j_pos : np.ndarray
Reference net current in each group on the positive boundary
from the reference calculation.
"""
if x_min >= x_max:
raise ValueError("The value of x_min must be < x_max.")
self.x_min = x_min
self.x_max = x_max
dx = x_max - x_min # Only needed locally to compute coefficients
if keff <= 0.0 or 2.0 <= keff:
raise ValueError("The value of keff must be in the interval (0, 2).")
self.ngroups = xs.ngroups
# Checks for all arrays
if avg_flx.ndim != 1:
raise ValueError("The array avg_flux must be 1D.")
if j_neg.ndim != 1:
raise ValueError("The array j_neg must be 1D.")
if j_pos.ndim != 1:
raise ValueError("The array j_pos must be 1D.")
if avg_flx.size != xs.ngroups:
raise ValueError(
"The length of avg_flux does not agree with number of groups in xs."
)
if j_neg.size != xs.ngroups:
raise ValueError(
"The length of j_neg does not agree with number of groups in xs."
)
if j_pos.size != xs.ngroups:
raise ValueError(
"The length of j_neg does not agree with number of groups in xs."
)
# Perform the static nodal calculation for all groups
NG = self.ngroups
Na = NG * 4 # number of a coefficients to solve for
invs_dx = 1.0 / dx
A = np.zeros((Na, Na)) # Matrix to hold all coefficients
b = np.zeros((Na)) # Results array
# Load the coefficient matrix and results array
j = 0 # Matrix row
for g in range(NG):
Dg = xs.D(g)
Dg_dx = Dg * invs_dx
chi_g_keff = xs.chi(g) / keff
# Each group has 4 equations. See reference [1].
# Eq 2.54
A[j, g * 4 + 0] += xs.Er(g) / 12.0 # a1
A[j, g * 4 + 2] -= xs.Er(g) / 120.0 # a3
A[j, g * 4 + 2] -= 0.5 * Dg_dx * invs_dx # a3
for gg in range(NG):
A[j, gg * 4 + 0] -= chi_g_keff * xs.vEf(gg) / 12.0 # a1
A[j, gg * 4 + 2] += chi_g_keff * xs.vEf(gg) / 120.0 # a3
if gg == g:
continue
A[j, gg * 4 + 0] -= xs.Es(gg, g) / 12.0 # a1
A[j, gg * 4 + 2] += xs.Es(gg, g) / 120.0 # a3
b[j] = 0.0
j += 1
# Eq 2.55
A[j, g * 4 + 1] += xs.Er(g) / 20.0 # a2
A[j, g * 4 + 3] -= xs.Er(g) / 700.0 # a4
A[j, g * 4 + 3] -= 0.2 * Dg_dx * invs_dx # a4
for gg in range(NG):
A[j, gg * 4 + 1] -= chi_g_keff * xs.vEf(gg) / 20.0 # a2
A[j, gg * 4 + 3] += chi_g_keff * xs.vEf(gg) / 700.0 # a4
if gg == g:
continue
A[j, gg * 4 + 1] -= xs.Es(gg, g) / 20.0 # a2
A[j, gg * 4 + 3] += xs.Es(gg, g) / 700.0 # a4
b[j] = 0.0
j += 1
# Eq 2.57 and 2.58 have an error in [1]. They are both missing a
# factor of 3 on the a2 term. The correct form can be found from
# Lawrence in [2].
# Eq 2.57
A[j, g * 4 + 0] -= Dg_dx
A[j, g * 4 + 1] += 3.0 * Dg_dx
A[j, g * 4 + 2] -= 0.5 * Dg_dx
A[j, g * 4 + 3] += 0.2 * Dg_dx
b[j] = j_neg[g]
j += 1
# Eq 2.58
A[j, g * 4 + 0] -= Dg_dx
A[j, g * 4 + 1] -= 3.0 * Dg_dx
A[j, g * 4 + 2] -= 0.5 * Dg_dx
A[j, g * 4 + 3] -= 0.2 * Dg_dx
b[j] = j_pos[g]
j += 1
a_tmp = np.linalg.solve(A, b)
self.a = np.zeros((NG, 5))
for g in range(NG):
self.a[g, 0] = avg_flx[g]
self.a[g, 1:] = a_tmp[g * 4 : g * 4 + 4]
[docs] def __call__(
self, x: float | np.ndarray, g: int | None = None
) -> float | np.ndarray:
"""
Evaluates the nodal flux at position x in group g.
Parameters
----------
x : float | np.ndarray
Position or array of positions for the flux evaluation.
g : int | None
Energy group for the flux evaluation. If none, array for all groups is returned.
Returns
-------
float | np.ndarray
Nodal flux in group g, or in all groups
"""
if g is not None and g < 0:
raise RuntimeError("Group index g must be >= 0.")
if g is not None and g >= self.ngroups:
raise RuntimeError("Group index g is out of range.")
# Commented out to permit use with an array
"""
if x < self.x_min:
raise RuntimeError("x value is less than lower boundary.")
if x > self.x_max:
raise RuntimeError("x value is greater than upper boundary.")
"""
u = ((x - self.x_min) / (self.x_max - self.x_min)) - 0.5
u2 = u * u
f1 = u
f2 = 3.0 * u2 - 0.25
f3 = u * (u - 0.5) * (u + 0.5)
f4 = (u2 - (1.0 / 20.0)) * (u - 0.5) * (u + 0.5)
if g is None:
flx = (
self.a[:, 0]
+ self.a[:, 1] * f1
+ self.a[:, 2] * f2
+ self.a[:, 3] * f3
+ self.a[:, 4] * f4
)
else:
flx = (
self.a[g, 0]
+ self.a[g, 1] * f1
+ self.a[g, 2] * f2
+ self.a[g, 3] * f3
+ self.a[g, 4] * f4
)
return flx
[docs] def pos_surf_flux(self, g: Optional[int] = None) -> Union[np.ndarray, float]:
"""
Evaluates the nodal flux at the positive surface in group g. If g is
None, an array with the surface flux in all groups is returned.
Parameters
----------
g : int, optional
Energy group for the flux evaluation. Default is None.
Returns
-------
np.ndarray or float
Nodal flux on the positive surface
"""
if g is None:
return self.a[:, 0] + 0.5 * self.a[:, 1] + 0.5 * self.a[:, 2]
if g < 0:
raise RuntimeError("Group index g must be >= 0.")
if g >= self.ngroups:
raise RuntimeError("Group index g is out of range.")
return self.a[g, 0] + 0.5 * self.a[g, 1] + 0.5 * self.a[g, 2]
[docs] def neg_surf_flux(self, g: Optional[int] = None) -> Union[np.ndarray, float]:
"""
Evaluates the nodal flux at the negative surface in group g. If g is
None, an array with the surface flux in all groups is returned.
Parameters
----------
g : int, optional
Energy group for the flux evaluation. Default is None.
Returns
-------
np.ndarray or float
Nodal flux on the negative surface
"""
if g is None:
return self.a[:, 0] - 0.5 * self.a[:, 1] + 0.5 * self.a[:, 2]
if g < 0:
raise RuntimeError("Group index g must be >= 0.")
if g >= self.ngroups:
raise RuntimeError("Group index g is out of range.")
return self.a[g, 0] - 0.5 * self.a[g, 1] + 0.5 * self.a[g, 2]
[docs]class NodalFlux2D:
[docs] def __init__(
self,
dx: float,
dy: float,
keff: float,
xs: DiffusionCrossSection,
avg_flx: np.ndarray,
j_x_neg: np.ndarray,
j_x_pos: np.ndarray,
j_y_neg: np.ndarray,
j_y_pos: np.ndarray,
):
"""
Performs the 2D nodal diffusion calculation using the Nodal Expansion
Method (NEM) with reference currents and average fluxes. This
represents the homogeneous nodal flux solution. The primary use of this
class is for computing the homogeneous flux that is required to define
corner discontinuity factors for flux reconstruction.
By default, the coupled x-y terms are neglected, as this requires
knowledge of the average corner fluxes based on the adjacent nodes. Due
to this restriction, the coefficients for the coupled x-y terms must be
computed separately. Examine the source code in
NEMDriver::fit_node_recon_params_corners for an example of how this
should be done. It follows the ANOVA-HDMR Decomposition method [3].
Parameters
----------
dx : float
Width of the node along the x axis.
dy : float
Width of the node along the y axis.
keff : float
Multiplication factor from reference Sn calculation.
xs : DiffusionCrossSection
Diffusion cross sections homogenized for the node.
avg_flx : np.ndarray
Average flux in each group within the node from reference Sn
calculation.
j_x_neg : np.ndarray
Reference net current in each group on the negative x boundary
from the reference calculation.
j_x_pos : np.ndarray
Reference net current in each group on the positive x boundary
from the reference calculation.
j_y_neg : np.ndarray
Reference net current in each group on the negative y boundary
from the reference calculation.
j_y_pos : np.ndarray
Reference net current in each group on the positive y boundary
from the reference calculation.
Attributes
----------
dx : float
Width along the x direction.
dy : float
Width along the y direction.
keff : float
Multiplication factor to use for the node.
ngroups : int
Number of energy groups.
phi_0 : np.ndarray
Average flux in the node for each group.
flux_x : NodalFlux1D
Expansion of the nodal flux along the x axis.
flux_y : NodalFlux1D
Expansion of the nodal flux along the y axis.
"""
self.keff = keff
self.ngroups = xs.ngroups
NG = self.ngroups
self.phi_0 = avg_flx # f0
self.eps = np.zeros(NG)
# fx
self.ax0 = np.zeros(NG)
self.ax1 = np.zeros(NG)
self.ax2 = np.zeros(NG)
self.bx1 = np.zeros(NG)
self.bx2 = np.zeros(NG)
# fy
self.ay0 = np.zeros(NG)
self.ay1 = np.zeros(NG)
self.ay2 = np.zeros(NG)
self.by1 = np.zeros(NG)
self.by2 = np.zeros(NG)
# fxy
self.cxy11 = np.zeros(NG)
self.cxy12 = np.zeros(NG)
self.cxy21 = np.zeros(NG)
self.cxy22 = np.zeros(NG)
self.invs_dx = 1.0 / dx
self.invs_dy = 1.0 / dy
self.zeta_x = np.zeros(NG)
self.zeta_y = np.zeros(NG)
self.flux_x = NodalFlux1D(
-0.5 * dx, 0.5 * dx, self.keff, xs, avg_flx, j_x_neg, j_x_pos
)
self.flux_y = NodalFlux1D(
-0.5 * dy, 0.5 * dy, self.keff, xs, avg_flx, j_y_neg, j_y_pos
)
self._initialize_params_no_cross_terms(xs, j_x_pos, j_x_neg, j_y_pos, j_y_neg)
@property
def dx(self) -> float:
return 1.0 / self.invs_dx
@property
def dy(self) -> float:
return 1.0 / self.invs_dy
def _initialize_params_no_cross_terms(
self, xs: DiffusionCrossSection, Jxp, Jxm, Jyp, Jym
) -> None:
sinhc = lambda x: np.sinh(x) / x
for g in range(self.ngroups):
D = xs.D(g)
Er = xs.Er(g)
self.eps[g] = np.sqrt(Er / D)
flx_avg = self.phi_0[g]
flx_xp = self.flux_x.pos_surf_flux(g)
flx_xm = self.flux_x.neg_surf_flux(g)
flx_yp = self.flux_y.pos_surf_flux(g)
flx_ym = self.flux_y.neg_surf_flux(g)
# Initial base matrix for finding fx, fy, and fz coefficients
M = np.array(
[
[0.0, 0.0, 1.0, 1.0],
[0.0, 0.0, -1.0, 1.0],
[0.0, 0.0, 1.0, 3.0],
[0.0, 0.0, 1.0, -3.0],
]
)
b = np.zeros(4)
# Determine fx coefficients
zeta_x = 0.5 * self.eps[g] * self.dx
M[0, 0] = np.cosh(zeta_x) - sinhc(zeta_x)
M[0, 1] = np.sinh(zeta_x)
M[1, 0] = M[0, 0]
M[1, 1] = -M[0, 1]
M[2, 0] = zeta_x * np.sinh(zeta_x)
M[2, 1] = zeta_x * np.cosh(zeta_x)
M[3, 0] = -M[2, 0]
M[3, 1] = M[2, 1]
b[0] = flx_xp - flx_avg
b[1] = flx_xm - flx_avg
b[2] = -0.5 * Jxp[g] * self.dx / D
b[3] = -0.5 * Jxm[g] * self.dx / D
fu_coeffs = np.linalg.solve(M, b)
self.ax1[g] = fu_coeffs[0]
self.ax2[g] = fu_coeffs[1]
self.bx1[g] = fu_coeffs[2]
self.bx2[g] = fu_coeffs[3]
self.ax0[g] = -self.ax1[g] * sinhc(zeta_x)
self.zeta_x[g] = zeta_x
# Determine fy coefficients
zeta_y = 0.5 * self.eps[g] * self.dy
M[0, 0] = np.cosh(zeta_y) - sinhc(zeta_y)
M[0, 1] = np.sinh(zeta_y)
M[1, 0] = M[0, 0]
M[1, 1] = -M[0, 1]
M[2, 0] = zeta_y * np.sinh(zeta_y)
M[2, 1] = zeta_y * np.cosh(zeta_y)
M[3, 0] = -M[2, 0]
M[3, 1] = M[2, 1]
b[0] = flx_yp - flx_avg
b[1] = flx_ym - flx_avg
b[2] = -0.5 * Jyp[g] * self.dy / D
b[3] = -0.5 * Jym[g] * self.dy / D
fu_coeffs = np.linalg.solve(M, b)
self.ay1[g] = fu_coeffs[0]
self.ay2[g] = fu_coeffs[1]
self.by1[g] = fu_coeffs[2]
self.by2[g] = fu_coeffs[3]
self.ay0[g] = -self.ay1[g] * sinhc(zeta_y)
self.zeta_y[g] = zeta_y
[docs] def __call__(self, x: float, y: float) -> np.ndarray:
"""
Evaluates the nodal flux in all groups at the position (x,y). The node
is centered at (0,0), so the x value must be in the range [-dx/2, dx/2]
and the y value must be in the range [-dy/2, dy/2].
Parameters
----------
x : float
x position in the node.
y : float
y position in the node.
Returns
-------
np.ndarray
Nodal flux in all groups.
"""
return self.phi_0 + self.fx(x) + self.fy(y) + self.fxy(x, y)
def flux_xy_no_cross(self, x, y) -> np.ndarray:
return self.phi_0 + self.fx(x) + self.fy(y)
def fx(self, x) -> np.ndarray:
return (
self.ax0
+ self.ax1 * np.cosh(self.eps * x)
+ self.ax2 * np.sinh(self.eps * x)
+ self.bx1 * self.p1(2.0 * x * self.invs_dx)
+ self.bx2 * self.p2(2.0 * x * self.invs_dx)
)
def fy(self, y) -> np.ndarray:
return (
self.ay0
+ self.ay1 * np.cosh(self.eps * y)
+ self.ay2 * np.sinh(self.eps * y)
+ self.by1 * self.p1(2.0 * y * self.invs_dy)
+ self.by2 * self.p2(2.0 * y * self.invs_dy)
)
def fxy(self, x, y) -> np.ndarray:
x *= 2.0 * self.invs_dx
y *= 2.0 * self.invs_dy
p1x = self.p1(x)
p2x = self.p2(x)
p1y = self.p1(y)
p2y = self.p2(y)
return (
self.cxy11 * p1x * p1y
+ self.cxy12 * p1x * p2y
+ self.cxy21 * p2x * p1y
+ self.cxy22 * p2x * p2y
)
def p1(self, xi) -> float:
return xi
def p2(self, xi) -> float:
return 0.5 * (3.0 * xi * xi - 1.0)
# REFERENCES
#
# [1] S. Machach, “Étude des techniques d’équivalence nodale appliquées aux
# modèles de réflecteurs dans les réacteurs à eau pressurisée,”
# Polytechnique Montréal, 2022.
#
# [2] R. D. Lawrence, “Progress in nodal methods for the solution of the
# neutron diffusion and transport equations,” Prog. Nucl. Energ., vol. 17,
# no. 3, pp. 271–301, 1986, doi: 10.1016/0149-1970(86)90034-x.
#
# [3] P. M. Bokov, D. Botes, R. H. Prinsloo, and D. I. Tomašević, “A
# Multigroup Homogeneous Flux Reconstruction Method Based on the
# ANOVA-HDMR Decomposition,” Nucl. Sci. Eng., vol. 197, no. 2,
# pp. 308–332, 2023, doi: 10.1080/00295639.2022.2108654.