Source code for jrystal.sbt.pysbt

# Copyright 2025 Garena Online Private Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import numpy as np
from scipy.special import gamma, spherical_jn
from scipy.fft import ifft, rfft, irfft

# usefull constants
PI = np.pi
TPI = 2.0 * np.pi
II = 1.0j


[docs] class pyNumSBT(object): r''' Numerically perform spherical Bessel transform (SBT) in :math:`O(Nln(N))` time based on the algorithm proposed by J. Talman. Talman, J. Computer Physics Communications 2009, 180, 332-338. For a function "f(r)" defined numerically on a LOGARITHMIC radial grid "r", the SBT for the function gives .. math:: g(k) = \sqrt{2\over\pi} \int_0^\infty j_l(kr) f(r) r^2 dr (c1) and the inverse SBT (iSBT) gives .. math:: f(r) = \sqrt{2\over\pi} \int_0^\infty j_l(kr) g(k) k^2 dk (c2) ''' def __init__(self, rr, kmax: float = 500, lmax: int = 10): self.rr = np.asarray(rr, dtype=float) # the real-space logarithmic radial grid self.nr = self.rr.size # No. of grid points self.nr2 = 2 * self.nr self.sbt_init(rr, kmax) self.lmax = lmax self.sbt_mltb(self.lmax)
[docs] def sbt_init(self, rr, kmax: float): r''' Initialize the real-space grid (rr) and momentum-space grid (kk). .. math:: \rho = \ln(rr) \kappa = \ln(kk) The algorithm by Talman requries .. math:: \Delta\kappa = \Delta\rho ''' self.r_min = rr[0] self.r_max = rr[-1] self.rho_min = np.log(self.r_min) self.rho_max = np.log(self.r_max) # \Delta\rho = \ln(rr[1] - rr[0]) self.drho = (self.rho_max - self.rho_min) / (self.nr - 1) self.kappa_max = np.log(kmax) self.kappa_min = self.kappa_max - (self.rho_max - self.rho_min) # the reciprocal-space (momentum-space) logarithmic grid self.kk = np.exp(self.kappa_min) * np.exp(np.arange(self.nr) * self.drho) self.k_min = self.kk[0] self.k_max = self.kk[-1] self.rr3 = self.rr**3 self.kk3 = self.kk**3 # r values for the extended mesh as discussed in Sec.4 of Talman paper. self.rr_ext = self.r_min * np.exp(np.arange(-self.nr, 0) * self.drho) # the multiply factor as in Talman paper after Eq.(32) self.rr15 = np.zeros((2, self.nr), dtype=float) self.rr15[0] = self.rr_ext**1.5 / self.r_min**1.5 # the extended r-mesh self.rr15[1] = self.rr**1.5 / self.r_min**1.5 # the normal r-mesh # \Delta\rho \times \Delta t = \frac{2\pi}{N} # as in Talman paper after Eq.(30), where N = 2 * nr as discussed in # Sec. 4 self.dt = TPI / (self.nr2 * self.drho) # the post-division factor (1 / k^1.5) as in Eq. (32) of Talman paper. # Note that the factor is (1 / r^1.5) for inverse-SBT, as a result, we # omit the r_min^1.5 / k_min^1.5 here. self.post_div_fac = np.exp(-np.arange(self.nr) * self.drho * 1.5) # Simpson integration of a function on the logarithmic radial grid. # # Setup weights for simpson integration on radial grid any radial integral # can then be evaluated by just summing all radial grid points with the # weights SI # # \int dr = \sum_i w(i) * f(i) self.simp_wht_rr = np.zeros_like(self.rr) self.simp_wht_kk = np.zeros_like(self.kk) for ii in range(self.nr - 1, 1, -2): self.simp_wht_rr[ii] = self.drho * self.rr[ii] / 3. \ + self.simp_wht_rr[ii] self.simp_wht_rr[ii - 1] = self.drho * self.rr[ii - 1] * 4. / 3. self.simp_wht_rr[ii - 2] = self.drho * self.rr[ii - 2] / 3. self.simp_wht_kk[ii] = self.drho * self.kk[ii] / 3. + self.simp_wht_kk[ii] self.simp_wht_kk[ii - 1] = self.drho * self.kk[ii - 1] * 4. / 3. self.simp_wht_kk[ii - 2] = self.drho * self.kk[ii - 2] / 3.
[docs] def sbt_mltb(self, lmax_in: int): ''' construct the M_l(t) table according to Eq. (15), (16) and (24) of Talman paper. Note that Talman paper use Stirling's approximaton to evaluate the Gamma function, whereas I just call scipy.special.gamma here. ''' if lmax_in > self.lmax: lmax = lmax_in self.lmax = lmax else: lmax = self.lmax tt = np.arange(self.nr) * self.dt # M_lt1 is quantity defined by Eq. (12) self.M_lt1 = np.zeros((lmax + 1, self.nr), dtype=complex) # Eq. (15) in Talman paper # self.M_lt1[0]=gamma(0.5 - II*tt) * np.sin(0.5*PI*(0.5 - II*tt)) / self.nr # Eq. (19) and Eq. (15) in Talman paper are equivalent, while the former # is more stable for larger tt self.M_lt1[0] = np.sqrt(np.pi / 2) * np.exp( II * ( np.log(gamma(0.5 - II * tt)).imag # + np.log(np.sin(0.5*PI*(0.5 - II*tt))).imag - np.arctan(np.tanh(PI * tt / 2)) ) ) / self.nr self.M_lt1[0, 0] /= 2.0 self.M_lt1[0] *= np.exp(II * tt * (self.kappa_min + self.rho_min)) # Eq. (16) in Talman paper phi = np.arctan(np.tanh(PI * tt / 2)) - np.arctan(2 * tt) self.M_lt1[1] = self.M_lt1[0] * np.exp(2 * II * phi) # Eq. (24) in Talman paper for ll in range(1, lmax): phi_l = np.arctan(2 * tt / (2 * ll + 1)) self.M_lt1[ll + 1] = np.exp(-2 * II * phi_l) * self.M_lt1[ll - 1] ll = np.arange(lmax + 1) # Eq. (35) in Talman paper xx = np.exp(self.rho_min + self.kappa_min + np.arange(self.nr2) * self.drho) # M_lt2 is just the Fourier transform of spherical Bessel function j_l self.M_lt2 = ifft( spherical_jn(ll[:, None], xx[None, :]), axis=1 ).conj()[:, :self.nr + 1]
[docs] def run( self, ff, l: int = 0, direction: int = 1, norm: bool = False, np_in: int = 0, return_rr: bool = False, include_zero: bool = False, ): ''' Perform SBT or inverse-SBT. Input parapeters: ff: the function defined on the logarithmic radial grid l: the "l" as in the underscript of "j_l(kr)" of Eq. (c1) and (c2) direction: 1 for forward SBT and -1 for inverse SBT norm: whether to multiply the prefactor \sqrt{2\over\pi} in Eq. (c1) and (c2). If False, then subsequent applicaton of SBT and iSBT will yield the original data scaled by a factor of 2/pi. np_in: the asymptotic bahavior of ff when r -> 0 ff(r\to 0) \approx r^{np_in + l} include_zero: the SBT does not include the k = 0, i.e. self.kk.min() != 0, term by default. ''' assert l <= self.lmax, \ "lmax = {} smaller than l = {}! Increase lmax!".format(self.lmax, l) ff = np.asarray(ff, dtype=float) gg = np.zeros_like(ff) r2c_in = np.zeros(self.nr2, dtype=float) r2c_out = np.zeros(self.nr + 1, dtype=complex) c2r_in = np.zeros(self.nr + 1, dtype=complex) c2r_out = np.zeros(self.nr2, dtype=float) # The prefactor as in Eq. (c1) and (c2) of the Class docstring. sqrt_2_over_pi = np.sqrt(2 / PI) if norm else 1.0 if direction == 1: rmin = self.r_min kmin = self.k_min C = ff[0] / self.r_min**(np_in + l) elif direction == -1: rmin = self.k_min kmin = self.r_min C = ff[0] / self.k_min**(np_in + l) else: raise ValueError("Use direction=1/-1 for forward- and inverse-SBT!") # SBT for LARGE k values extend the input to the doubled mesh, # extrapolating the input as C r^(np_in + l) # Step 1 in the procedure after Eq. (32) of Talman paper r2c_in[:self.nr] = C * self.rr_ext**(np_in + l) * self.rr15[0] r2c_in[self.nr:] = ff * self.rr15[1] # Step 2 in the procedure after Eq. (32) of Talman paper r2c_out = rfft(r2c_in) # Step 3 in the procedure after Eq. (32) of Talman paper tmp1 = np.zeros(self.nr2, dtype=complex) tmp1[:self.nr] = r2c_out[:self.nr].conj() * self.M_lt1[l] # Step 4 and 5 in the procedure after Eq. (32) of Talman paper tmp2 = ifft(tmp1) * self.nr2 gg = (rmin / kmin)**1.5 * tmp2[self.nr:].real * self.post_div_fac * sqrt_2_over_pi # obtain the SMALL k results in the array c2r_out if direction == 1: r2c_in[:self.nr] = self.rr3 * ff else: r2c_in[:self.nr] = self.kk3 * ff r2c_in[self.nr:] = 0.0 r2c_out = rfft(r2c_in) c2r_in = r2c_out.conj() * self.M_lt2[l] * sqrt_2_over_pi c2r_out = irfft(c2r_in) * self.nr2 c2r_out[:self.nr] *= self.drho # compare the minimum difference between large and small k as described # in the paragraph above Eq. (39) of Talman paper. gdiff = np.abs(gg - c2r_out[:self.nr]) minloc = np.argmin(gdiff) gg[:minloc + 1] = c2r_out[:minloc + 1] # include k = 0 in the SBT and r = 0 in the i-SBT. if include_zero: if direction == 1: gg0 = sqrt_2_over_pi * spherical_jn(l, 0) * np.sum( self.simp_wht_rr * self.rr**2 * ff ) else: gg0 = sqrt_2_over_pi * spherical_jn(l, 0) * np.sum( self.simp_wht_kk * self.kk**2 * ff ) if return_rr: if direction == 1: return (np.r_[0, self.kk], np.r_[gg0, gg]) if include_zero else (self.kk, gg) else: return (np.r_[0, self.rr], np.r_[gg0, gg]) if include_zero else (self.rr, gg) else: return np.r_[gg0, gg] if include_zero else gg
[docs] def run_int( self, ff, l: int = 0, direction: int = 1, norm: bool = False, return_rr: bool = False, include_zero: bool = False, ): ''' Perform SBT or inverse-SBT by numerically integrating Eq. (c1) and (c2). Input parapeters: ff: the function defined on the logarithmic radial grid l: the "l" as in the underscript of "j_l(kr)" of Eq. (c1) and (c2) direction: 1 for forward SBT and -1 for inverse SBT norm: whether to multiply the prefactor \sqrt{2\over\pi} in Eq. (c1) and (c2). If False, then subsequent applicaton of SBT and iSBT will yield the original data scaled by a factor of 2/pi. include_zero: the SBT does not include the k = 0, i.e. self.kk.min() != 0, term by default. ''' kr = self.rr[:, None] * self.kk[None, :] jl_kr = spherical_jn(l, kr) ff = np.asarray(ff, dtype=float) # The prefactor as in Eq. (c1) and (c2) of the Class docstring. sqrt_2_over_pi = np.sqrt(2 / PI) if norm else 1.0 if direction == 1: gg = sqrt_2_over_pi * np.sum( jl_kr * (self.rr**2 * ff * self.simp_wht_rr)[:, None], axis=0 ) # include k = 0 in the SBT and r = 0 in the i-SBT. if include_zero: gg0 = sqrt_2_over_pi * spherical_jn(l, 0) * np.sum( self.simp_wht_rr * self.rr**2 * ff ) elif direction == -1: gg = sqrt_2_over_pi * np.sum( jl_kr * (self.kk**2 * ff * self.simp_wht_kk)[None, :], axis=1 ) # include k = 0 in the SBT and r = 0 in the i-SBT. if include_zero: gg0 = sqrt_2_over_pi * spherical_jn(l, 0) * np.sum( self.simp_wht_kk * self.kk**2 * ff ) else: raise ValueError("Use direction=1/-1 for forward- and inverse-SBT!") if return_rr: if direction == 1: return (np.r_[0, self.kk], np.r_[gg0, gg]) if include_zero else (self.kk, gg) else: return (np.r_[0, self.rr], np.r_[gg0, gg]) if include_zero else (self.rr, gg) else: return np.r_[gg0, gg] if include_zero else gg