// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT package integrate import ( "math" "testing" "sourcedock.dev/petrbalvin/tensor/internal/core" ) // TestHeatEigenmodeDecay pins the analytic solution: the first sine // eigenmode decays as exp(−κ·π²·t/L²). func TestHeatEigenmodeDecay(t *testing.T) { const ( n = 49 L = 1.0 kappa = 0.1 ) dx := L / float64(n+1) u0 := make([]float64, n) for i := range n { u0[i] = math.Sin(math.Pi * float64(i+1) * dx / L) } u0Arr, _ := core.FromFloats(u0, n) const tFinal = 1.0 states, err := IntegrateHeat1D(u0Arr, kappa, dx, tFinal, 0.002, 3, 0, 0) if err != nil { t.Fatalf("IntegrateHeat1D: %v", err) } decay := math.Exp(-kappa * math.Pi * math.Pi * tFinal / (L * L)) final := states.Shape()[0]*n - n for i := range n { got := states.FloatAt(final + i) want := u0[i] * decay if math.Abs(got-want) > 5e-4*decay { t.Fatalf("u[%d] = %.8f, eigenmode says %.8f", i, got, want) } } } // TestHeatConservesConstantWithZeroBounds pins the fixed-point: a // constant field with equal Dirichlet bounds never moves. func TestHeatConservesConstantWithZeroBounds(t *testing.T) { const n = 20 u0 := make([]float64, n) for i := range n { u0[i] = 2.5 } u0Arr, _ := core.FromFloats(u0, n) states, err := IntegrateHeat1D(u0Arr, 1.0, 0.1, 1.0, 0.05, 5, 2.5, 2.5) if err != nil { t.Fatalf("IntegrateHeat1D: %v", err) } last := (states.Shape()[0] - 1) * n for i := range n { if math.Abs(states.FloatAt(last+i)-2.5) > 1e-12 { t.Fatalf("constant field drifted: u[%d] = %.12f", i, states.FloatAt(last+i)) } } } // TestWaveStandingFrequency pins a standing wave: the fundamental mode // u(x, t) = sin(πx)·cos(πct) must return to (minus) itself after half // a period. func TestWaveStandingFrequency(t *testing.T) { const ( n = 99 L = 1.0 c = 1.0 ) dx := L / float64(n+1) u0 := make([]float64, n) for i := range n { u0[i] = math.Sin(math.Pi * float64(i+1) * dx / L) } u0Arr, _ := core.FromFloats(u0, n) v0, _ := core.FromFloats(make([]float64, n), n) // Half period of the fundamental: T1/2 = L/c. states, err := IntegrateWave1D(u0Arr, v0, c, dx, 1.0, 0.001, 2) if err != nil { t.Fatalf("IntegrateWave1D: %v", err) } last := (states.Shape()[0] - 1) * n for i := range n { got := states.FloatAt(last + i) want := -u0[i] if math.Abs(got-want) > 2e-3 { t.Fatalf("standing wave off after half period: u[%d] = %.6f, want %.6f", i, got, want) } } } // TestWaveEnergyBand pins Verlet's bounded energy over many periods. func TestWaveEnergyBand(t *testing.T) { const ( n = 79 L = 1.0 c = 1.0 ) dx := L / float64(n+1) u0 := make([]float64, n) for i := range n { u0[i] = math.Sin(math.Pi*float64(i+1)*dx/L) + 0.3*math.Sin(3*math.Pi*float64(i+1)*dx/L) } u0Arr, _ := core.FromFloats(u0, n) v0, _ := core.FromFloats(make([]float64, n), n) states, err := IntegrateWave1D(u0Arr, v0, c, dx, 10.0, 0.002, 11) if err != nil { t.Fatalf("IntegrateWave1D: %v", err) } energy := func(row int) float64 { e := 0.0 for i := range n { e += states.FloatAt(row*n+i) * states.FloatAt(row*n+i) } return e } e0 := energy(0) for row := 1; row < 11; row++ { e := energy(row) if math.Abs(e-e0) > 1e-3*e0 { t.Fatalf("energy drifted: row %d has %.8f vs %.8f", row, e, e0) } } } // TestPDEErrors pins the input gates. func TestPDEErrors(t *testing.T) { u, _ := core.FromFloats([]float64{1, 2, 3}, 3) if _, err := IntegrateHeat1D(u, -1, 0.1, 1, 0.01, 2, 0, 0); err == nil { t.Error("negative diffusivity accepted") } if _, err := IntegrateHeat1D(u, 1, 0.1, 1, 0.01, 1, 0, 0); err == nil { t.Error("one sample accepted") } v, _ := core.FromFloats([]float64{1, 2}, 2) if _, err := IntegrateWave1D(u, v, 1, 0.1, 1, 0.01, 2); err == nil { t.Error("velocity shape mismatch accepted") } if _, err := IntegrateWave1D(u, u, 1, 0.1, 1, 0.2, 2); err == nil { t.Error("CFL violation accepted") } }