Files
tensor/internal/core/orthopoly_test.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

202 lines
7.1 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"
"testing"
)
// TestOrthogonalPolynomials checks H, L, T and U against their closed
// forms for the first degrees.
func TestOrthogonalPolynomials(t *testing.T) {
x := mustFloats(t, []float64{-0.7, 0, 0.4, 1.1})
cases := []struct {
name string
n int
want func(v float64) float64
}{
{"H0", 0, func(v float64) float64 { return 1 }},
{"H1", 1, func(v float64) float64 { return 2 * v }},
{"H2", 2, func(v float64) float64 { return 4*v*v - 2 }},
{"H3", 3, func(v float64) float64 { return 8*v*v*v - 12*v }},
{"T0", 0, func(v float64) float64 { return 1 }},
{"T2", 2, func(v float64) float64 { return 2*v*v - 1 }},
{"T3", 3, func(v float64) float64 { return 4*v*v*v - 3*v }},
{"U0", 0, func(v float64) float64 { return 1 }},
{"U1", 1, func(v float64) float64 { return 2 * v }},
{"U2", 2, func(v float64) float64 { return 4*v*v - 1 }},
}
for _, tc := range cases {
var got *Array
var err error
switch {
case tc.name[0] == 'H' && len(tc.name) == 2:
got, err = Hermite(tc.n, x)
case tc.name[0] == 'T':
got, err = ChebyshevT(tc.n, x)
default:
got, err = ChebyshevU(tc.n, x)
}
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
for i := range x.Len() {
want := tc.want(x.FloatAt(i))
if math.Abs(got.FloatAt(i)-want) > 1e-12*(1+math.Abs(want)) {
t.Fatalf("%s(%v) = %v, want %v", tc.name, x.FloatAt(i), got.FloatAt(i), want)
}
}
}
}
// TestLaguerrePolynomials checks L against closed forms and the
// orthogonality weight sanity at a sample point.
func TestLaguerrePolynomials(t *testing.T) {
x := mustFloats(t, []float64{-0.3, 0, 0.6, 2.1})
closed := []struct {
n int
alpha float64
want func(v float64) float64
}{
{0, 0, func(v float64) float64 { return 1 }},
{1, 0, func(v float64) float64 { return 1 - v }},
{2, 0, func(v float64) float64 { return 1 - 2*v + v*v/2 }},
{2, 1, func(v float64) float64 { return 3 - 3*v + v*v/2 }},
}
for _, tc := range closed {
l, err := Laguerre(tc.n, tc.alpha, x)
if err != nil {
t.Fatalf("Laguerre(%d, %v): %v", tc.n, tc.alpha, err)
}
for i := range x.Len() {
want := tc.want(x.FloatAt(i))
if math.Abs(l.FloatAt(i)-want) > 1e-12*(1+math.Abs(want)) {
t.Fatalf("L_%d^%v(%v) = %v, want %v", tc.n, tc.alpha, x.FloatAt(i), l.FloatAt(i), want)
}
}
}
if _, err := Laguerre(-1, 0, x); err == nil {
t.Fatal("expected an error for a negative degree")
}
}
// TestSphericalHarmonicReal checks the real harmonics against the
// complex ones under the standard convention: both branches carry the
// (−1)^m phase, Y_real = √2·(−1)^m·Re(Y_l^m) for m > 0 and
// √2·(−1)^m·Im(Y_l^|m|) for m < 0.
func TestSphericalHarmonicReal(t *testing.T) {
theta := mustFloats(t, []float64{0.4, 1.2, 2.5})
phi := mustFloats(t, []float64{0.3, -0.8, 2.1})
pos, err := SphericalHarmonicReal(2, 1, theta, phi)
if err != nil {
t.Fatalf("SphericalHarmonicReal(+1): %v", err)
}
pos2, err := SphericalHarmonicReal(3, 2, theta, phi)
if err != nil {
t.Fatalf("SphericalHarmonicReal(+2): %v", err)
}
neg, err := SphericalHarmonicReal(2, -1, theta, phi)
if err != nil {
t.Fatalf("SphericalHarmonicReal(−1): %v", err)
}
cx, err := SphericalHarmonic(2, 1, theta, phi)
if err != nil {
t.Fatalf("SphericalHarmonic: %v", err)
}
cx2, err := SphericalHarmonic(3, 2, theta, phi)
if err != nil {
t.Fatalf("SphericalHarmonic(3, 2): %v", err)
}
root2 := math.Sqrt2
for i := range theta.Len() {
z := cx.ComplexAt(i)
// m = 1 is odd, so the standard real form negates the real
// part; m = 2, even, keeps it.
if math.Abs(pos.FloatAt(i)+root2*real(z)) > 1e-12 {
t.Fatalf("real m=1 [%d] = %v, want %v", i, pos.FloatAt(i), -root2*real(z))
}
z2 := cx2.ComplexAt(i)
if math.Abs(pos2.FloatAt(i)-root2*real(z2)) > 1e-12 {
t.Fatalf("real m=2 [%d] = %v, want %v", i, pos2.FloatAt(i), root2*real(z2))
}
if math.Abs(neg.FloatAt(i)+root2*imag(z)) > 1e-12 {
t.Fatalf("real m=−1 [%d] = %v, want %v", i, neg.FloatAt(i), -root2*imag(z))
}
}
// Pinned standard values against the external Cartesian forms:
// Y_real(1, 1) = √(3/(4π))·x/r, so it is +√(3/(4π)) at (θ, φ) =
// (π/2, 0) and 0 at (π/2, π/2); Y_real(1, −1) = √(3/(4π))·y/r is
// +√(3/(4π)) at (π/2, π/2) and 0 at (π/2, 0); Y_real(2, 1) =
// √(15/(4π))·xz/r² at (π/4, 0) is √(15/(4π))·cos(π/4)·sin(π/4).
eq := mustFloats(t, []float64{math.Pi / 2})
halfPi := mustFloats(t, []float64{math.Pi / 2})
zero := mustFloats(t, []float64{0})
y11, err := SphericalHarmonicReal(1, -1, eq, halfPi)
if err != nil {
t.Fatalf("SphericalHarmonicReal(1, −1): %v", err)
}
if want := math.Sqrt(3 / (4 * math.Pi)); math.Abs(y11.FloatAt(0)-want) > 1e-12 {
t.Fatalf("Y_real(1, −1)(π/2, π/2) = %.16g, want %.16g", y11.FloatAt(0), want)
}
y11z, err := SphericalHarmonicReal(1, -1, eq, zero)
if err != nil {
t.Fatalf("SphericalHarmonicReal(1, −1) at φ=0: %v", err)
}
if v := y11z.FloatAt(0); math.Abs(v) > 1e-12 {
t.Fatalf("Y_real(1, −1)(π/2, 0) = %v, want 0", v)
}
y11p, err := SphericalHarmonicReal(1, 1, eq, zero)
if err != nil {
t.Fatalf("SphericalHarmonicReal(1, 1): %v", err)
}
if want := math.Sqrt(3 / (4 * math.Pi)); math.Abs(y11p.FloatAt(0)-want) > 1e-12 {
t.Fatalf("Y_real(1, 1)(π/2, 0) = %.16g, want +%.16g", y11p.FloatAt(0), want)
}
y11q, err := SphericalHarmonicReal(1, 1, eq, halfPi)
if err != nil {
t.Fatalf("SphericalHarmonicReal(1, 1) at φ=π/2: %v", err)
}
if v := y11q.FloatAt(0); math.Abs(v) > 1e-12 {
t.Fatalf("Y_real(1, 1)(π/2, π/2) = %v, want 0", v)
}
qr := mustFloats(t, []float64{math.Pi / 4})
y21, err := SphericalHarmonicReal(2, 1, qr, zero)
if err != nil {
t.Fatalf("SphericalHarmonicReal(2, 1): %v", err)
}
if want := math.Sqrt(15/(4*math.Pi)) * math.Cos(math.Pi/4) * math.Sin(math.Pi/4); math.Abs(y21.FloatAt(0)-want) > 1e-12 {
t.Fatalf("Y_real(2, 1)(π/4, 0) = %.16g, want %.16g", y21.FloatAt(0), want)
}
}
// TestAiry checks the Airy pair against tabulated values at x = 0,
// ±1 and the NaN contract outside the series window.
func TestAiry(t *testing.T) {
x := mustFloats(t, []float64{0, 1, -1, 3.5})
ai, bi, err := Airy(x)
if err != nil {
t.Fatalf("Airy: %v", err)
}
wantAI := []float64{0.3550280538878172, 0.13529241631288142, 0.5355608832928901, 0.002584098786988065}
wantBI := []float64{0.6149266274460007, 1.2074235949528713, 0.1039973894969447, 33.05550674355921}
for i := range 4 {
if math.Abs(ai.FloatAt(i)-wantAI[i]) > 1e-12*(1+math.Abs(wantAI[i])) {
t.Fatalf("Ai(%v) = %.16g, want %.16g", x.FloatAt(i), ai.FloatAt(i), wantAI[i])
}
if math.Abs(bi.FloatAt(i)-wantBI[i]) > 5e-10*(1+math.Abs(wantBI[i])) {
t.Fatalf("Bi(%v) = %.16g, want %.16g", x.FloatAt(i), bi.FloatAt(i), wantBI[i])
}
}
// Outside the series window the contract is NaN, not silence.
out := mustFloats(t, []float64{9})
ai9, bi9, err := Airy(out)
if err != nil {
t.Fatalf("Airy(9): %v", err)
}
if !math.IsNaN(ai9.FloatAt(0)) || !math.IsNaN(bi9.FloatAt(0)) {
t.Fatalf("outside |x| ≤ 8 expected NaN, got %v and %v", ai9.FloatAt(0), bi9.FloatAt(0))
}
}