feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// heatMode builds sin(πx)·sin(πy) on an (n+2)×(n+2) grid over [0,1]²
|
||||
// including the zero boundary ring, the lowest interior mode.
|
||||
func heatMode(t *testing.T, n int) *core.Array {
|
||||
t.Helper()
|
||||
vals := make([]float64, (n+2)*(n+2))
|
||||
for r := range n + 2 {
|
||||
for c := range n + 2 {
|
||||
vals[r*(n+2)+c] = math.Sin(math.Pi*float64(c)/float64(n+1)) *
|
||||
math.Sin(math.Pi*float64(r)/float64(n+1))
|
||||
}
|
||||
}
|
||||
a, err := core.FromFloats(vals, n+2, n+2)
|
||||
if err != nil {
|
||||
t.Fatalf("FromFloats: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// TestIntegrateHeat2DModeDecay checks the ADI solver on the lowest
|
||||
// mode: with zero boundaries the amplitude decays like
|
||||
// exp(−κ·2π²·t), and the scheme's O(dt²+h²) error must stay inside a
|
||||
// one percent band on a 32-interior grid.
|
||||
func TestIntegrateHeat2DModeDecay(t *testing.T) {
|
||||
const n = 32
|
||||
u0 := heatMode(t, n)
|
||||
const kappa, dt, tFinal = 1.0, 0.005, 0.1
|
||||
history, err := IntegrateHeat2D(u0, kappa, 1.0/float64(n+1), 1.0/float64(n+1),
|
||||
tFinal, dt, 2, 0, 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateHeat2D: %v", err)
|
||||
}
|
||||
if history.Shape()[0] != 2 || history.Shape()[1] != n+2 {
|
||||
t.Fatalf("shape %v, want [%d %d %d]", history.Shape(), 2, n+2, n+2)
|
||||
}
|
||||
final := history.Shape()[0] - 1
|
||||
// The interior peak of the final state versus the exact decay.
|
||||
peak := 0.0
|
||||
for r := 1; r <= n; r++ {
|
||||
for c := 1; c <= n; c++ {
|
||||
if v := history.FloatAt(final*(n+2)*(n+2) + r*(n+2) + c); v > peak {
|
||||
peak = v
|
||||
}
|
||||
}
|
||||
}
|
||||
want := math.Exp(-kappa * 2 * math.Pi * math.Pi * tFinal)
|
||||
if math.Abs(peak-want) > 0.01 {
|
||||
t.Fatalf("final peak %.5g, want %.5g", peak, want)
|
||||
}
|
||||
// The boundary ring is held at zero.
|
||||
for c := range n + 2 {
|
||||
if history.FloatAt(final*(n+2)*(n+2)+c) != 0 ||
|
||||
history.FloatAt(final*(n+2)*(n+2)+(n+1)*(n+2)+c) != 0 {
|
||||
t.Fatalf("boundary ring moved at column %d", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateWave2DStandingWave checks the explicit solver on the
|
||||
// lowest standing mode with zero initial velocity against the closed
|
||||
// form of the leapfrog itself. The mode is an exact eigenfunction of the
|
||||
// five-point Laplacian, with eigenvalue mu, and the discrete
|
||||
// characteristic of the scheme is cos theta = 1 - (c*h)²·mu/2, so after
|
||||
// s steps the state is cos(s·theta)·u0. The run below takes 400 steps of
|
||||
// period/400, so its amplitude is cos(400·theta) = -0.4155, not 1: the
|
||||
// mode has not returned to its start at this time.
|
||||
func TestIntegrateWave2DStandingWave(t *testing.T) {
|
||||
const n = 32
|
||||
u0 := heatMode(t, n) // sin(πx)sin(πy) with zero boundary ring
|
||||
v0 := core.New(core.Float, n+2, n+2)
|
||||
const c = 1.0
|
||||
dx := 1.0 / float64(n+1)
|
||||
period := math.Sqrt2 / (c * math.Pi)
|
||||
const steps = 400 // samples = 2 and dt = period/400 force this many steps
|
||||
dt := period / float64(steps)
|
||||
history, err := IntegrateWave2D(u0, v0, c, dx, dx, period, dt, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateWave2D: %v", err)
|
||||
}
|
||||
h := period / float64(steps)
|
||||
// The discrete eigenvalue of the (1,1) mode, mu = 8/dx²·sin²(π·dx/2)
|
||||
// on this square grid, and the phase 400 steps accumulate.
|
||||
sine := math.Sin(math.Pi * dx / 2)
|
||||
mu := 8 / (dx * dx) * sine * sine
|
||||
amp := math.Cos(float64(steps) * math.Acos(1-0.5*(c*h)*(c*h)*mu))
|
||||
final := history.Shape()[0] - 1
|
||||
worst := 0.0
|
||||
for r := range n + 2 {
|
||||
for cc := range n + 2 {
|
||||
i := final*(n+2)*(n+2) + r*(n+2) + cc
|
||||
if e := math.Abs(history.FloatAt(i) - amp*u0.FloatAt(r*(n+2)+cc)); e > worst {
|
||||
worst = e
|
||||
}
|
||||
}
|
||||
}
|
||||
if worst > 1e-11 {
|
||||
t.Fatalf("after %.0f steps the worst deviation from cos(%.6f)·u0 is %.4g, want the leapfrog characteristic",
|
||||
float64(steps), amp, worst)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateWave2DCFLRefusal checks the stability budget: a step
|
||||
// past the CFL limit is an error, not a silent blow-up.
|
||||
func TestIntegrateWave2DCFLRefusal(t *testing.T) {
|
||||
const n = 32
|
||||
u0 := heatMode(t, n)
|
||||
v0 := core.New(core.Float, n+2, n+2)
|
||||
dx := 1.0 / float64(n+1)
|
||||
// c·dt·sqrt(1/dx²+1/dy²) = 1·0.05·45.25 ≈ 2.26 > 1.
|
||||
if _, err := IntegrateWave2D(u0, v0, 1, dx, dx, 0.05, 0.05, 2); err == nil {
|
||||
t.Fatal("a CFL-violating step accepted")
|
||||
}
|
||||
if _, err := IntegrateWave2D(u0, core.New(core.Float, 3, 3), 1, dx, dx, 0.05, 0.001, 2); err == nil {
|
||||
t.Fatal("mismatched velocity grid accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// pde2dAnisoMode builds the (p, q) discrete Dirichlet eigenmode of the
|
||||
// five-point Laplacian on a rows×cols grid: sin(π·p·c/(cols−1)) ·
|
||||
// sin(π·q·r/(rows−1)), which vanishes on all four boundary lines.
|
||||
func pde2dAnisoMode(t *testing.T, rows, cols, p, q int) *core.Array {
|
||||
t.Helper()
|
||||
vals := make([]float64, rows*cols)
|
||||
for r := range rows {
|
||||
for c := range cols {
|
||||
vals[r*cols+c] = math.Sin(math.Pi*float64(p)*float64(c)/float64(cols-1)) *
|
||||
math.Sin(math.Pi*float64(q)*float64(r)/float64(rows-1))
|
||||
}
|
||||
}
|
||||
a, err := core.FromFloats(vals, rows, cols)
|
||||
if err != nil {
|
||||
t.Fatalf("FromFloats: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// anisoMu returns the dimensionless eigenvalues of the undivided second
|
||||
// difference along each axis for the (p, q) mode above.
|
||||
func anisoMu(rows, cols, p, q int) (mx, my float64) {
|
||||
return 4 * math.Pow(math.Sin(math.Pi*float64(p)/2/float64(cols-1)), 2),
|
||||
4 * math.Pow(math.Sin(math.Pi*float64(q)/2/float64(rows-1)), 2)
|
||||
}
|
||||
|
||||
// TestIntegrateWave2DAnisotropicGrid pins the explicit solver on a grid
|
||||
// whose spacings differ between the axes, the case the square-grid tests
|
||||
// cannot see. The (1, 2) mode is an exact eigenfunction of the
|
||||
// five-point Laplacian with eigenvalue λx + λy, where λx carries dx and
|
||||
// λy carries dy, so the leapfrog state after s steps is cos(s·θ)·u0 with
|
||||
// cos θ = 1 − (c·h)²·(λx + λy)/2. A stencil that divides the y
|
||||
// neighbours by dx² instead of dy², or the reverse, moves those
|
||||
// eigenvalues and the amplitude with them.
|
||||
func TestIntegrateWave2DAnisotropicGrid(t *testing.T) {
|
||||
const (
|
||||
rows, cols = 26, 34
|
||||
dx, dy = 0.02, 0.05
|
||||
c = 1.0
|
||||
steps = 60
|
||||
)
|
||||
if dx == dy {
|
||||
t.Fatal("the case needs spacings that differ between the axes")
|
||||
}
|
||||
mx, my := anisoMu(rows, cols, 1, 2)
|
||||
lx, ly := mx/(dx*dx), my/(dy*dy)
|
||||
dt := 0.5 / (c * math.Sqrt(1/(dx*dx)+1/(dy*dy))) // CFL = 1/2
|
||||
tFinal := dt * float64(steps)
|
||||
u0 := pde2dAnisoMode(t, rows, cols, 1, 2)
|
||||
v0 := core.New(core.Float, rows, cols)
|
||||
history, err := IntegrateWave2D(u0, v0, c, dx, dy, tFinal, dt, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateWave2D: %v", err)
|
||||
}
|
||||
h := tFinal / float64(steps)
|
||||
amp := math.Cos(float64(steps) * math.Acos(1-0.5*(c*h)*(c*h)*(lx+ly)))
|
||||
if math.Abs(amp) < 0.1 {
|
||||
t.Fatalf("the run decays to %.3g; the case needs an amplitude the comparison can see", amp)
|
||||
}
|
||||
final := history.Shape()[0] - 1
|
||||
worst := 0.0
|
||||
for i := range rows * cols {
|
||||
worst = math.Max(worst, math.Abs(history.FloatAt(final*rows*cols+i)-amp*u0.FloatAt(i)))
|
||||
}
|
||||
if worst > 1e-11 {
|
||||
t.Fatalf("after %d steps the worst deviation from cos(θ·%d)·u0 is %.4g (amplitude %.4g), want the leapfrog characteristic",
|
||||
steps, steps, worst, amp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateHeat2DAnisotropicGrid pins the ADI solver the same way:
|
||||
// on an eigenmode the two half steps compose into one amplification
|
||||
// factor per step, (1 − rx·μx)(1 − ry·μy)/((1 + rx·μx)(1 + ry·μy)),
|
||||
// with rx = κ·h/(2·dx²) and ry = κ·h/(2·dy²) against the dimensionless
|
||||
// second-difference eigenvalues. Exchanging the two spacings moves the
|
||||
// factor by orders of magnitude, so a mislabelled axis cannot pass.
|
||||
func TestIntegrateHeat2DAnisotropicGrid(t *testing.T) {
|
||||
const (
|
||||
rows, cols = 26, 34
|
||||
dx, dy = 0.02, 0.05
|
||||
kappa = 1.0
|
||||
steps = 10
|
||||
)
|
||||
if dx == dy {
|
||||
t.Fatal("the case needs spacings that differ between the axes")
|
||||
}
|
||||
mx, my := anisoMu(rows, cols, 1, 2)
|
||||
h := 0.0044
|
||||
tFinal := h * float64(steps)
|
||||
u0 := pde2dAnisoMode(t, rows, cols, 1, 2)
|
||||
history, err := IntegrateHeat2D(u0, kappa, dx, dy, tFinal, h, 2, 0, 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateHeat2D: %v", err)
|
||||
}
|
||||
rx := kappa * h / (2 * dx * dx)
|
||||
ry := kappa * h / (2 * dy * dy)
|
||||
amp := math.Pow((1-rx*mx)*(1-ry*my)/((1+rx*mx)*(1+ry*my)), float64(steps))
|
||||
if math.Abs(amp) < 0.05 {
|
||||
t.Fatalf("the run decays to %.3g; the case needs an amplitude the comparison can see", amp)
|
||||
}
|
||||
final := history.Shape()[0] - 1
|
||||
worst := 0.0
|
||||
for i := range rows * cols {
|
||||
worst = math.Max(worst, math.Abs(history.FloatAt(final*rows*cols+i)-amp*u0.FloatAt(i)))
|
||||
}
|
||||
if worst > 1e-12 {
|
||||
t.Fatalf("after %d steps the worst deviation from the ADI amplification %.6g·u0 is %.4g, want the anisotropic factor",
|
||||
steps, amp, worst)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user