Files
tensor/internal/core/besselmod.go
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

301 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package core
import "math"
// Modified Bessel functions of the first and second kind, the workhorses
// of heat conduction, waveguides and filtered noise. I₀ and I₁ use the
// polynomial fits below 3.75 (Abramowitz & Stegun 9.8.1 and 9.8.2) and
// their integral representations beyond, K₀ and K₁ integrate their
// kernels directly, and integer orders follow by the stable direction
// of the recurrence: upward for I, downward for K.
// All are element-wise over real arrays; ints and float32 promote.
// BesselI0 returns the modified Bessel function of the first kind of
// order zero, I₀(x), of each element. I₀ is even with I₀(0) = 1.
func BesselI0(a *Array) (*Array, error) {
return a.realFunc("BesselI0", besselI0)
}
// BesselI1 returns the modified Bessel function of the first kind of
// order one, I₁(x), of each element. I₁ is odd with I₁(0) = 0.
func BesselI1(a *Array) (*Array, error) {
return a.realFunc("BesselI1", besselI1)
}
// BesselK0 returns the modified Bessel function of the second kind of
// order zero, K₀(x), of each element, defined for x > 0. K₀ diverges
// logarithmically at 0 and returns +Inf there.
func BesselK0(a *Array) (*Array, error) {
return a.realFunc("BesselK0", besselK0)
}
// BesselK1 returns the modified Bessel function of the second kind of
// order one, K₁(x), of each element, defined for x > 0. K₁ diverges
// like 1/x at 0 and returns +Inf there.
func BesselK1(a *Array) (*Array, error) {
return a.realFunc("BesselK1", besselK1)
}
// BesselIn returns the modified Bessel function of the first kind of
// integer order n, Iₙ(x), of each element. The recurrence runs in its
// stable direction: orders well below |x| climb upward from the
// quadrature I₀ and I₁ at O(n) cost, and orders comparable to or above
// it run Miller's downward algorithm, which is where the upward climb
// would amplify the parasitic K component the seeds carry. The
// downward walk starts above the turning point at order |x|, so it
// costs O(n + |x|) steps: for an order far below a large argument that
// is the difference between a microsecond and minutes, which is why
// the climb exists.
func BesselIn(n int, a *Array) (*Array, error) {
if n < 0 {
return nil, errf("BesselIn: order must be ≥ 0, got %d", n)
}
return a.realFunc("BesselIn", func(x float64) float64 {
if n == 0 {
return besselI0(x)
}
if n == 1 {
return besselI1(x)
}
// The relative contamination the climb suffers grows roughly
// like e^{n²/x}, and the seeds it climbs from are only as good
// as besselI0 and besselI1 are there: below 3.75 those are
// polynomial fits worth about 2.5e-8, so a short climb over a
// small argument multiplies a weak seed by the condition number
// and lands coarser than the downward walk, which renormalises
// through the same seed once. The climb is therefore taken only
// where its seeds are strong, from besselIUpwardMinX up, and the
// split follows n² ≤ 4x inside that: above it the downward walk
// is both accurate and affordable, since the order is then
// comparable to the argument and O(n + |x|) steps is the
// answer's own price.
ax := math.Abs(x)
if ax >= besselIUpwardMinX && float64(n)*float64(n) <= 4*ax {
return besselIUpward(n, x)
}
return besselInMiller(n, x)
})
}
// besselIUpwardMinX is the argument from which the upward climb beats
// Miller's downward walk on accuracy as well as on cost: the asymptotic
// I₀ and I₁ it climbs from are near full precision there, while the
// polynomial pair below 3.75 is not. Miller's own cost is O(n + |x|), so
// staying with it under this bound costs nothing that matters.
const besselIUpwardMinX = 12
// besselIUpward climbs I₀ and I₁ to order n by the upward recurrence
// Iₖ₊₁ = Iₖ₋₁ − (2k/x)·Iₖ (the modified kind carries the minus sign
// where J carries a plus), the stable direction while the order stays
// well below |x|: the parasitic K component the seeds carry decays
// relative to I there, whereas near and above the argument the same
// recurrence amplifies it, which is what besselInMiller's downward walk
// avoids. The quadrature seeds carry the absolute scale and the parity,
// so a negative argument needs no extra handling. The caller guarantees
// x ≠ 0, 2 ≤ n, n² ≤ 4|x| and |x| ≥ besselIUpwardMinX.
func besselIUpward(n int, x float64) float64 {
i0, i1 := besselI0(x), besselI1(x)
// The quadrature overflows to Inf once |x| passes about 714, where
// the true value overflows too: report that instead of letting the
// recurrence fold Inf - Inf into NaN. The sign follows the parity
// Iₙ(−x) = (−1)ⁿ Iₙ(x).
if math.IsInf(i0, 0) || math.IsInf(i1, 0) {
if x < 0 && n%2 == 1 {
return math.Inf(-1)
}
return math.Inf(1)
}
for k := 1; k < n; k++ {
i0, i1 = i1, i0-2*float64(k)/x*i1
}
return i1
}
// besselInMiller evaluates I_n by the downward Miller recurrence. The
// upward recurrence for I is unstable once the order passes the
// argument (the terms subtract nearly equal neighbours), while the
// downward direction amplifies no rounding. The arbitrary seed scale
// is removed against the exact I_0 at the end.
func besselInMiller(n int, x float64) float64 {
if x == 0 {
if n == 0 {
return 1
}
return 0
}
// |x| keeps the start above n for negative arguments too: a start
// at or below n would never pass the order on the way down and
// renormalise against a garbage seed.
start := n + int(math.Abs(x)) + 40
jp, j := 0.0, 1.0 // j_{k+1}, j_k, seeded at k = start
jn := 0.0
for k := start; k >= 1; k-- {
if k == n {
jn = j
}
// Downward recurrence: j_{k−1} = j_{k+1} + 2k/x·j_k.
jp, j = j, jp+2*float64(k)/x*j
if aj := math.Abs(j); aj > 1e200 {
// I grows monotonically down the walk, and the unscaled
// seed overflows for tiny x or high orders whose true
// value is representable; a common rescale cancels in the
// final ratio.
jp /= aj
j /= aj
jn /= aj
}
}
// j now holds the unscaled I_0. The quotient is formed before the
// multiplication: above about x = 300 both walk values pass 1e100,
// and the product I₀·jₙ overflows even though the answer, which is
// the quotient times the seed scale, is an ordinary number. Taking
// the product first turned every such evaluation into +Inf.
return besselI0(x) * (jn / j)
}
// BesselKn returns the modified Bessel function of the second kind of
// integer order n, Kₙ(x), of each element, by the upward recurrence
// Kₙ₊₁ = Kₙ₋₁ + 2n/x·Kₙ from K₀ and K₁, which is the stable direction
// for the second-kind functions. K is defined for x > 0 at every
// order: outside that domain the answer is not order-dependent but
// undefined, so any element that is NaN or non-positive is an error
// naming the element, the same contract BesselY enforces at one
// point, never a silent NaN or a spurious +Inf.
func BesselKn(n int, a *Array) (*Array, error) {
if n < 0 {
return nil, errf("BesselKn: order must be ≥ 0, got %d", n)
}
if a.dt == Complex {
return nil, errf("BesselKn: complex arrays are not supported")
}
for i := range a.Len() {
if x := a.floatAt(i); math.IsNaN(x) || x <= 0 {
return nil, errf("BesselKn: the argument must be positive, got %g at element %d", x, i)
}
}
return a.realFunc("BesselKn", func(x float64) float64 {
k0, k1 := besselK0(x), besselK1(x)
if n == 0 {
return k0
}
if n == 1 {
return k1
}
km := k0
k := k1
for j := 1; j < n; j++ {
km, k = k, km+2*float64(j)/x*k
}
return k
})
}
// besselI0 evaluates I₀: the standard 3.75-piecewise polynomial fit
// below, and the exact integral representation I₀ = (1/π)∫₀^π
// e^(x·cos θ) dθ above, where the polynomial's accuracy degrades.
func besselI0(x float64) float64 {
ax := math.Abs(x)
if ax < 3.75 {
t := x / 3.75
t2 := t * t
return 1 + t2*(3.5156229+t2*(3.0899424+t2*(1.2067492+t2*(0.2659732+t2*(0.0360768+t2*0.0045813)))))
}
return quadratureBesselI(x, false)
}
// besselI1 evaluates I₁: the polynomial fit below 3.75, the integral
// representation I₁ = (1/π)∫₀^π e^(x·cos θ)·cos θ dθ above. The odd
// parity comes out of the quadrature by itself; the small branch
// applies the sign of x explicitly.
func besselI1(x float64) float64 {
ax := math.Abs(x)
var r float64
if ax < 3.75 {
t := x / 3.75
t2 := t * t
r = ax * (0.5 + t2*(0.87890594+t2*(0.51498869+t2*(0.15084934+t2*(0.02658733+t2*(0.00301532+t2*0.00032411))))))
if x < 0 {
r = -r
}
return r
}
return quadratureBesselI(x, true)
}
// quadratureBesselI evaluates I₀ (weight false) or I₁ (weight true)
// through the exact integral representations over θ ∈ [0, π] by
// composite Simpson with 2048 intervals, which resolves the peak at
// θ = 0 for any practical |x|.
func quadratureBesselI(x float64, weightCos bool) float64 {
const intervals = 2048
h := math.Pi / float64(intervals)
kern := func(th float64) float64 {
v := math.Exp(x * math.Cos(th))
if weightCos {
v *= math.Cos(th)
}
return v
}
sum := kern(0) + kern(math.Pi)
for i := 1; i < intervals; i++ {
w := 2.0
if i%2 == 1 {
w = 4.0
}
sum += w * kern(float64(i)*h)
}
return sum * h / (3 * math.Pi)
}
// besselK0 evaluates K₀(x) = ∫₀^∞ e^(−x·cosh t) dt by composite
// Simpson quadrature over a truncated domain. The tail beyond the
// cutoff falls below 1e-100 for every x > 0, and the integrand is
// smooth, so the quadrature carries roughly ten significant digits.
// That is the deliberate trade: a provably correct entry-level K
// without hand-typed approximation coefficients.
func besselK0(x float64) float64 {
if x <= 0 {
return math.Inf(1)
}
return integrateCoshKernel(x, func(_ float64, coshU float64) float64 {
return math.Exp(-x * coshU)
})
}
// besselK1 evaluates K₁(x) = ∫₀^∞ e^(−x·cosh t)·cosh t dt, the
// derivative partner of K₀, by the same quadrature.
func besselK1(x float64) float64 {
if x <= 0 {
return math.Inf(1)
}
return integrateCoshKernel(x, func(_ float64, coshU float64) float64 {
return math.Exp(-x*coshU) * coshU
})
}
// integrateCoshKernel integrates ∫₀^∞ kernel(u; x) du where the
// kernel carries the factor e^(−x·cosh u). The cutoff follows from
// requiring the tail e^(−x·e^U/2) below 1e-100; for large x the
// result legitimately underflows to zero like the true value.
func integrateCoshKernel(x float64, kernel func(float64, float64) float64) float64 {
const intervals = 2001
u := math.Log(200/x) + 2
if u < 2 {
u = 2
}
h := u / float64(intervals)
sum := kernel(0, 1) + kernel(u, math.Cosh(u))
for i := 1; i < intervals; i++ {
tu := float64(i) * h
w := 2.0
if i%2 == 1 {
w = 4.0
}
sum += w * kernel(tu, math.Cosh(tu))
}
return sum * h / 3
}