-
Notifications
You must be signed in to change notification settings - Fork 109
[WIP] Additional exx kernels: Voxel averaged and Wigner-Seitz truncation #1282
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
2db40bc
2d8936e
e2135a3
aba4d00
e92b7d8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| using FastGaussQuadrature | ||
|
|
||
| # | ||
| # Interaction models | ||
| # | ||
|
|
@@ -10,6 +12,7 @@ Available models: | |
| - [`ShortRangeCoulomb`](@ref): erfc(μr)/r | ||
| - [`LongRangeCoulomb`](@ref): erf(μr)/r | ||
| - [`SphericallyTruncatedCoulomb`](@ref): θ(R-r)/r | ||
| - [`WignerSeitzTruncatedCoulomb`](@ref): χ(r)/r where χ(r)=1 inside Wigner-Seitz cell, otherwise 0. | ||
|
|
||
| If an interaction model features a singularity, that requires some special treatment, | ||
| the following are available: | ||
|
|
@@ -26,7 +29,7 @@ Base.Broadcast.broadcastable(k::InteractionKernel) = Ref(k) | |
| # eval_probe_charge_integral(::InteractionKernel, α) | ||
| # Should return ∫_{BZ} kernel(q) * e^(-α * q^2) dq | ||
| # This is needed for the ProbeCharge regularisation. Note, that no factor 1/Γ | ||
| # where Γ is BZ volume) is used. | ||
| # (where Γ is BZ volume) is used. | ||
| # _compute_kernel_fourier(::InteractionKernel, basis, qpt, q) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not clear why this needs to exist. I don't think this file should know about the existence of basis at all?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also eval takes |p|^2 but this one takes p, is that correct?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also it's nice to have default methods (maybe unoptimized), so if one can be written in terms of the other in general maybe do that?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Not sure what you mean with default method? |
||
| # The single q-point version of compute_kernel_fourier | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. there's compute_ and eval_, should all these be merged?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (we really need to have a unified API for localized functions that can be evaluated in real or reciprocal space)
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. also should we have a eval_kernel_real? This is often useful for debug purposes (can be left as TODO if you don't feel like doing it)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I added a TODO for eval_kernel_real. |
||
|
|
||
|
|
@@ -80,37 +83,11 @@ function _compute_kernel_fourier(k::LongRangeCoulomb, basis, qpt, q) | |
| _compute_kernel_fourier(k, k.regularization, basis, qpt, q) | ||
| end | ||
|
|
||
|
|
||
| # | ||
| # Evaluation of interaction kernels | ||
| # | ||
|
|
||
|
|
||
| """ | ||
| Spherically truncated Coulomb interaction: θ(Rcut-r)/r | ||
| If Rcut is nothing, it uses `Rcut = cbrt(3Ω / (4π))` where `Ω` is the unit cell volume. | ||
|
|
||
| ## References | ||
| - [J. Spencer, A. Alavi. Phys. Rev. B **77**, 193110 (2008)](https://doi.org/10.1103/PhysRevB.77.193110) | ||
| """ | ||
| @kwdef struct SphericallyTruncatedCoulomb{T} <: InteractionKernel | ||
| Rcut::T = nothing | ||
| end | ||
| function eval_kernel_fourier(k::SphericallyTruncatedCoulomb, Gsq::T) where {T} | ||
| 4T(π) / Gsq * (1 - cos(T(k.Rcut) * sqrt(Gsq))) | ||
| end | ||
| function _compute_kernel_fourier(k::SphericallyTruncatedCoulomb, basis, qpt, q) | ||
| # TODO: This is a bit hackish as the parameter needs to be re-computed every kernel | ||
| # evaluation. Cleaner would be to move this further up in the call hierarchy, | ||
| # such that compute_kernel_fourier is never called without Rcut being set to | ||
| # not nothing | ||
| Ω = basis.model.unit_cell_volume | ||
| Rcut = @something k.Rcut cbrt(3Ω/(4π)) | ||
| kRcut = SphericallyTruncatedCoulomb(Rcut) | ||
|
|
||
| # Use ReplaceSingularity regularisation to explicitly set as the G==0 | ||
| # component the exact limit of the kernel for G->0 | ||
| _compute_kernel_fourier(kRcut, ReplaceSingularity(2π*Rcut^2), basis, qpt, q) | ||
| end | ||
| @doc raw""" | ||
| Returns the Fourier-space Coulomb kernel for momentum transfer `q`, | ||
| evaluated only on the spherical cutoff |G+q|² < 2Ecut (not the full cubic FFT grid). | ||
|
|
@@ -152,6 +129,143 @@ function compute_kernel_fourier(kernel::InteractionKernel, basis::PlaneWaveBasis | |
| end | ||
|
|
||
|
|
||
| """ | ||
| Spherically truncated Coulomb interaction: θ(Rcut-r)/r | ||
| If Rcut is nothing, it uses `Rcut = cbrt(3Ω / (4π))` where `Ω` is the unit cell volume. | ||
|
|
||
| ## References | ||
| - [J. Spencer, A. Alavi. Phys. Rev. B **77**, 193110 (2008)](https://doi.org/10.1103/PhysRevB.77.193110) | ||
| """ | ||
| @kwdef struct SphericallyTruncatedCoulomb{T} <: InteractionKernel | ||
| Rcut::T = nothing | ||
| end | ||
| function eval_kernel_fourier(k::SphericallyTruncatedCoulomb, Gsq::T) where {T} | ||
| 4T(π) / Gsq * (1 - cos(T(k.Rcut) * sqrt(Gsq))) | ||
| end | ||
| function _compute_kernel_fourier(k::SphericallyTruncatedCoulomb, basis, qpt, q) | ||
| # TODO: This is a bit hackish as the parameter needs to be re-computed every kernel | ||
| # evaluation. Cleaner would be to move this further up in the call hierarchy, | ||
| # such that compute_kernel_fourier is never called without Rcut being set to | ||
| # not nothing | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah we have this pattern for the terms (one struct specifies and the other is instantiated with the basis), it's very annoying. Let's cross this bridge when we actually need it.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ok |
||
| Ω = basis.model.unit_cell_volume | ||
| Rcut = @something k.Rcut cbrt(3Ω/(4π)) | ||
| kRcut = SphericallyTruncatedCoulomb(Rcut) | ||
|
|
||
| # Use ReplaceSingularity regularisation to explicitly set as the G==0 | ||
| # component the exact limit of the kernel for G->0 | ||
| _compute_kernel_fourier(kRcut, ReplaceSingularity(2π*Rcut^2), basis, qpt, q) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same as above, I don't like this replacesingularity business just to compensate for the fact that the implementation above is unstable (it mixes physics with a purely numerical issue). As with the exponential, define a stable (cos(x)-1)/x function
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ok. add a TODO. |
||
| end | ||
|
|
||
|
|
||
| """ | ||
| Truncate Coulomb interaction at the Wigner-Seitz cell boundary. | ||
|
|
||
| Computational approach: We expand 1/r = erfc(ωr)/r + erf(ωr)/r and chose ω such that the | ||
| short-range part erfc(ωr)/r is virtually unaffected by the truncation. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is not actually true (it would be if epsilon was machine precision). I would instead frame this as an error balance. First introduce the actual ewald-like computational scheme : we split 1/r into a short-range, singular at 0 part and a long-range non-singular at 0 part. We compute the short-range analytically, replacing the BZ by the whole space (making an error exp(-omega Rin)). We compute the long-range, non-singular at 0, in Fourier space, by an FFT on the grid we have (note we could possibly do this on another grid), incurring an error exp(-G/omega). Balancing the two to optimize the error, we get the expression you wrote. Is this correct or am I missing something? |
||
|
|
||
| First the inradius R_in of the Wigner-Seitz cell is calculated. | ||
|
|
||
| By chosing ω = sqrt(-log(ε))/R_in we can be sure that erfc(ω*R_in) < ε. | ||
|
|
||
| At the same time the long-range part needs to be representable on the Fourier grid | ||
| which implies G_Nyquist >= -2 log(ε) / R_in (see Appendix A.1 in reference). | ||
| Hence we simply define ε through the given grid via ε = exp(-G_Nyquist*R_in/2). | ||
|
|
||
| The short-range contribution is then given by the analytical expression | ||
| 4π/G^2*(1-exp(-G^2/(4ω^2))) | ||
| while the long-range contributiom is obtained through an FFT of the real-space function | ||
| erf(ωr)/r | ||
| to reciprocal space. | ||
|
|
||
| ## Reference | ||
| - [R. Sundararaman, T. A. Arias. Phys. Rev. B **87**, 165122 (2013)](https://doi.org/10.1103/PhysRevB.87.165122) | ||
| """ | ||
| struct WignerSeitzTruncatedCoulomb <: InteractionKernel end | ||
| @views function _compute_kernel_fourier(k::WignerSeitzTruncatedCoulomb, | ||
| basis::PlaneWaveBasis{T}, qpt, q) where {T} | ||
| model = basis.model | ||
| NG = length(qpt.G_vectors) | ||
| kernel_fourier = zeros(T, NG) | ||
| q = qpt.coordinate | ||
|
|
||
| # === Calculate inradius R_in of Wigner-Seitz cell === | ||
|
|
||
| # R_in is largest possible R_in = (sum_i n_i * a*i) / 2 with integers n_i | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we have this logic in the construction of the plane wave basis already?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. otherwise it's a generic thing you can maybe move out somewhere (possibly in common/)? |
||
| # and |R_in| <= a_min where a_min is the length of the smallest lattice vector. | ||
| # The inequality allows to restrict n_i by exploiting Cauchy-Schwarz, leading | ||
| # to |n_i| <= a_min * |b_i| / 2π where b_i are reciprocal lattice vectors. | ||
|
|
||
| L_min = minimum(norm, eachcol(model.lattice)) | ||
| n_bounds = zeros(Int, 3) | ||
| for i in 1:3 | ||
| b_vec = model.recip_lattice[:, i] | ||
| b_len = norm(b_vec) | ||
| N = (L_min * b_len) / (2π) | ||
| n_bounds[i] = ceil(Int, N) | ||
| end | ||
| nx, ny, nz = n_bounds # in case of a cubic cell nx=ny=nz=1 | ||
|
|
||
| # finally compute R_in | ||
| R_in = T(Inf) | ||
| for ix in -nx:nx, iy in -ny:ny, iz in -nz:nz | ||
| ix == 0 && iy == 0 && iz == 0 && continue | ||
| R = model.lattice * [ix, iy, iz] | ||
| d = norm(R) / 2 # distance from origin to perpendicular bisector plane = |R|/2 | ||
| R_in = min(R_in, d) | ||
| end | ||
|
|
||
| # Nyquist frequency of FFT grid | ||
| G_Nyquist = minimum(basis.fft_size[d] / 2 * norm(model.recip_lattice[:, d]) for d in 1:3) | ||
|
|
||
| ε = exp(-0.5*G_Nyquist*R_in) # required: G_Nyquist >= -2*log(ε)/R_in (Appendix A.1 in paper) | ||
| ω = sqrt(-log(ε)) / R_in # range separation parameter | ||
| ε_actual = erfc(ω*R_in) | ||
| if ε_actual > 1e-8 | ||
| @warn "Coarse grid for Wigner-Seitz truncation. Effective error: $ε_actual" | ||
| end | ||
|
|
||
| # == FFT of long-range term erf(ωr)/r restricted to Wigner-Seitz cell === | ||
|
|
||
| r_vectors = DFTK.r_vectors(basis) | ||
| V_lr_real = zeros(Complex{T}, basis.fft_size...) | ||
| for idx in CartesianIndices(V_lr_real) | ||
| r_frac = r_vectors[idx] | ||
| r_centered = r_frac .- round.(r_frac) # MIC | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. write it out explicitly (I had to think to get what it was referring to) |
||
| r_cart = model.lattice * r_centered | ||
| d_min = norm(r_cart) | ||
| for dx in -nx:nx, dy in -ny:ny, dz in -nz:nz # Check neighbors for non-orthorhombic cells | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have to say I don't understand what's going on here, why isn't this just erf(r)/r for r in r_vectors?, maybe factor out the geometry stuff to another function? |
||
| dx == 0 && dy == 0 && dz == 0 && continue | ||
| r_shifted = r_centered - T[dx, dy, dz] | ||
| d = norm(model.lattice * r_shifted) | ||
| d_min = min(d_min, d) | ||
| end | ||
| if d_min > sqrt(eps(T)) | ||
| V_lr_real[idx] = erf(ω * d_min) / d_min | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. as above, define a erf(x)/x function?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hm I think we should have a general way of doing f(x)/x stably and AD-friendly, this is coming up pretty frequently (also with @Technici4n in the spherical harmonics stuff)
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah but of course it's my old friend the divided difference (high order in the case of f(x)/x^l). We have a general solution in https://github.com/xuequan818/MatrixFuns.jl but it might be a bit overkill...?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (the reason I want to get this right is otherwise it will come up to bite us in the ass with AD)
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I want to do things like but of course this screws up AD at 0. We can have a cutoff point and switch to a finite order taylor expansion. This is ugly and getting good precision of higher derivatives is annoying, but I'm not sure what else to do. |
||
| else | ||
| V_lr_real[idx] = 2*ω / sqrt(T(π)) | ||
| end | ||
| V_lr_real[idx] *= exp(-im * 2*T(π) * dot(q, r_frac)) # add phase e^{-2πiqr} | ||
| end | ||
| kernel_fourier_lr = real.(fft(basis, qpt, V_lr_real)) | ||
| kernel_fourier_lr .*= sqrt(model.unit_cell_volume) | ||
|
|
||
| # === Analytic short-range term + long-range term === | ||
|
|
||
| for (iG, G) in enumerate(to_cpu(qpt.G_vectors)) | ||
| G_cart = model.recip_lattice * (G+q) | ||
| Gnorm2 = sum(abs2, G_cart) | ||
| found_singularity = (iG==1 && iszero(q)) | ||
| Rcut = cbrt(basis.model.unit_cell_volume*3/4/π) | ||
| if !found_singularity | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why don't you just write the condition here? Also why not iszero(G_cart)?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (also same comment as above, hopefully we can avoid this special casing) |
||
| kernel_fourier[iG] = 4T(π) / Gnorm2 * (1 - exp(-Gnorm2/(4ω^2))) + kernel_fourier_lr[iG] | ||
| else | ||
| kernel_fourier[iG] = T(π)/ω^2 + kernel_fourier_lr[iG] | ||
| end | ||
| end | ||
| kernel_fourier | ||
| end | ||
|
|
||
|
|
||
| """ | ||
| Probe charge Ewald method for treating the Coulomb singularity. | ||
|
|
||
|
|
@@ -225,3 +339,137 @@ end | |
| end | ||
| kernel_fourier | ||
| end | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Running out of time today, note to self to resume the review from here. |
||
|
|
||
| """ | ||
| Calculates the average of the Coulomb kernel K(G+q) over the Brillouin zone voxel associated | ||
| with each grid point. It is particularly well suited for highly anisotropic cells. | ||
|
|
||
| Since the Coulomb kernel is not necessarily given by ``K(G+q)=1/(G+q)^2`` the | ||
| following approach is used: | ||
| - G+q=0 (Singularity): Uses an exact mathematical reduction of the volume integral | ||
| ``∫ 1/(G+q)^2 dV`` to a smooth surface integral over the voxel faces (surface reduction). | ||
| Then a high-order Gaussian quadrature is used to calculate ``∫ (K(G+q) - 1/(G+q)^2) dV``. | ||
| - G+q≠0 (Smooth): Uses high-order Gaussian quadrature for ``∫ K(G+q) dV`` | ||
|
|
||
| It is conceptually equivalent to the HFMEANPOT flag in VASP but uses improved integration | ||
| techniques to calcualte the average in the voxel. | ||
|
|
||
| # Arguments | ||
| - `N_quadrature_points::Int`: The number of Gauss-Legendre quadrature points used per dimension. | ||
| Defaults to 12. For highly anisotropic cells or rigorous Thermodynamic Limit (TDL) extrapolations, | ||
| it is advisable to check if higher values (e.g., to 18 or 24) eliminate numerical noise. | ||
|
|
||
| ## Reference | ||
| J. Chem. Phys. 160, 051101 (2024) (doi.org/10.1063/5.0182729) | ||
| """ | ||
| @kwdef struct VoxelAveraged | ||
| n_quadrature_points = 12 | ||
| end | ||
| @views function _compute_kernel_fourier(kernel, regularization::VoxelAveraged, | ||
| basis::PlaneWaveBasis{T}, qpt, q) where {T} | ||
| model = basis.model | ||
| q = qpt.coordinate | ||
|
|
||
| # Get kgrid_size | ||
| if isnothing(basis.kgrid) | ||
| kgrid_size = Vec3{Int}(1, 1, 1) | ||
| elseif basis.kgrid isa AbstractVector | ||
| kgrid_size = Vec3{Int}(basis.kgrid) | ||
| elseif basis.kgrid isa MonkhorstPack | ||
| kgrid_size = Vec3{Int}(basis.kgrid.kgrid_size) | ||
| else | ||
| @error "Cannot determine kgrid_size for VoxelAveraged Coulomb model." | ||
| end | ||
|
|
||
| # Define Voxels as reciprocal cell deivided by k-mesh | ||
| voxel_basis = model.recip_lattice * Diagonal(1 ./ Vec3{T}(kgrid_size)) | ||
| voxel_vol = abs(det(voxel_basis)) | ||
|
|
||
| # get Gauss-Legendre nodes and weights | ||
| nodes_std, weights_std = gausslegendre(regularization.n_quadrature_points) | ||
| # Scale from [-1, 1] to [-0.5, 0.5] | ||
| nodes = T.(nodes_std ./ 2) | ||
| weights = T.(weights_std ./ 2) | ||
|
|
||
| kernel_fourier = zeros(T, length(qpt.G_vectors)) | ||
|
|
||
| for (iG, G) in enumerate(to_cpu(qpt.G_vectors)) | ||
| G_cart = model.recip_lattice * (G+q) | ||
|
|
||
| found_singularity = (iG==1 && iszero(q)) | ||
|
|
||
| if found_singularity | ||
| # === At Singularity (G+q=0) use surface reduction method === | ||
|
|
||
| # We only do that for the 4π/(G+q)^2 kernel, hence the quadrature below | ||
| # covers the rest if kernel_fourier is different from Coulomb(). | ||
|
|
||
| # Transforms volume integral ∫ 1/G^2 dV to surface integral Σ h * ∫ 1/G^2 dS | ||
| integral = zero(T) | ||
| for i in 1:3 | ||
| u_i = voxel_basis[:, i] | ||
| u_j = voxel_basis[:, mod1(i+1, 3)] | ||
| u_k = voxel_basis[:, mod1(i+2, 3)] | ||
|
|
||
| # Height of the face from origin | ||
| normal = cross(u_j, u_k) | ||
| area_norm = norm(normal) | ||
|
|
||
| # Calculate distance h from center to face. | ||
| # Since face is at u_i/2, h = |(u_i/2) . n| | ||
| h = abs(dot(u_i, normal)) / (2 * area_norm) | ||
|
|
||
| # Integrate 1/G^2 over the face parallelogram | ||
| face_integral = zero(T) | ||
| for (wa, a) in zip(weights, nodes) | ||
| for (wb, b) in zip(weights, nodes) | ||
| # Parametrization of the face: r = u_i/2 + a*u_j + b*u_k | ||
| r_vec = 0.5f0 * u_i + a * u_j + b * u_k | ||
| r_sq = dot(r_vec, r_vec) | ||
| face_integral += wa * wb / r_sq | ||
| end | ||
| end | ||
|
|
||
| # Add contribution: 2 faces * h * Area * Mean(1/r^2) | ||
| # Area factor (area_norm) comes from the Jacobian. | ||
| integral += 2 * h * area_norm * face_integral | ||
| end | ||
|
|
||
| kernel_fourier[iG] = 4T(π) * (integral / voxel_vol) | ||
| end | ||
|
|
||
| # === Use smooth 3D Gaussian Quadrature === | ||
| integral = zero(T) | ||
| for (wx, x) in zip(weights, nodes) | ||
| for (wy, y) in zip(weights, nodes) | ||
| for (wz, z) in zip(weights, nodes) | ||
| # q vector inside voxel | ||
| q_local = x * voxel_basis[:, 1] + | ||
| y * voxel_basis[:, 2] + | ||
| z * voxel_basis[:, 3] | ||
|
|
||
| G_total = G_cart + q_local | ||
| Gsq = dot(G_total, G_total) | ||
|
|
||
| # switch temporarily to BigFloat | ||
| Gsq_big = BigFloat(Gsq) | ||
| val_big = eval_kernel_fourier(kernel, Gsq_big) | ||
|
|
||
| # At singularity, already captured the 4π/(G+q)^2 contribution above: subtract | ||
| if found_singularity | ||
| val_big -= 4π/Gsq_big | ||
| end | ||
|
|
||
| # back to type T | ||
| val = T(val_big) | ||
|
|
||
| integral += wx * wy * wz * val | ||
| end | ||
| end | ||
| end | ||
| kernel_fourier[iG] += integral | ||
| end | ||
|
|
||
| kernel_fourier | ||
| end | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
no comma after singularity (germanism)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
features a singularity -> is long-range