Files
tensor/integrate/pdeadvect_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

388 lines
13 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 integrate
import (
"math"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// advectGrid builds the interior cells of [0, 1] with n cells and the
// matching cell centres.
func advectGrid(n int) (dx float64, centres []float64) {
dx = 1 / float64(n+1)
centres = make([]float64, n)
for i := range n {
centres[i] = float64(i+1) * dx
}
return dx, centres
}
// advectL1 returns the L1 error of the final sample against exact.
func advectL1(t *testing.T, states *core.Array, n int, exact func(x float64) float64) float64 {
t.Helper()
last := (states.Shape()[0] - 1) * n
_, centres := advectGrid(n)
sum := 0.0
for i := range n {
sum += math.Abs(states.FloatAt(last+i) - exact(centres[i]))
}
return sum / float64(n)
}
// TestAdvectionUpwindMonotone pins the discrete maximum principle of
// both schemes: a monotone profile transported at CFL 0.9 stays
// monotone and inside its initial range, sample after sample.
func TestAdvectionUpwindMonotone(t *testing.T) {
for _, limited := range []bool{false, true} {
name := "upwind"
if limited {
name = "Koren"
}
n := 96
dx, centres := advectGrid(n)
u0 := make([]float64, n)
for i := range n {
u0[i] = 1 - centres[i]
}
u0Arr, err := core.FromFloats(u0, n)
if err != nil {
t.Fatal(err)
}
cfl := 0.9
a := 1.0
dt := cfl * dx
run := func() (*core.Array, error) {
if limited {
return IntegrateAdvection1D(u0Arr, a, dx, 0.3, dt, 4, 1, 0)
}
return IntegrateUpwindAdvection1D(u0Arr, a, dx, 0.3, dt, 4, 1, 0)
}
states, err := run()
if err != nil {
t.Fatalf("%s: %v", name, err)
}
rows := states.Shape()[0]
for r := range rows {
prev := math.Inf(1)
for i := range n {
v := states.FloatAt(r*n + i)
if v < -1e-12 || v > 1+1e-12 {
t.Fatalf("%s: sample %d cell %d left the range [0, 1]: %g", name, r, i, v)
}
if v > prev+1e-12 {
t.Fatalf("%s: sample %d stops being non-increasing at cell %d: %g after %g",
name, r, i, v, prev)
}
prev = v
}
}
// The transported ramp also keeps its shape: the last sample
// tracks the shifted exact ramp to within the scheme's smear.
last := (rows - 1) * n
shift := 0.3
for i := range n {
x := centres[i] - shift
want := 1.0
if x > 0 {
want = 1 - x
}
if d := math.Abs(states.FloatAt(last+i) - want); d > 0.02 {
t.Fatalf("%s: ramp cell %d: %g, want %.6g", name, i, states.FloatAt(last+i), want)
}
}
}
}
// TestAdvectionSquareWaveLimiterBeatsUpwind pins the limiter's reason
// for being: a square pulse carried at CFL 0.9 comes out visibly
// sharper under the Koren flux than under plain upwind, measured as
// the L1 error ratio against the exact shifted pulse.
func TestAdvectionSquareWaveLimiterBeatsUpwind(t *testing.T) {
n := 256
dx, centres := advectGrid(n)
u0 := make([]float64, n)
for i := range n {
u0[i] = 0.0
if centres[i] >= 0.3 && centres[i] <= 0.7 {
u0[i] = 1.0
}
}
u0Arr, err := core.FromFloats(u0, n)
if err != nil {
t.Fatal(err)
}
a := 1.0
cfl := 0.9
dt := cfl * dx
const tFinal = 0.2
upwind, err := IntegrateUpwindAdvection1D(u0Arr, a, dx, tFinal, dt, 2, 0, 0)
if err != nil {
t.Fatalf("upwind: %v", err)
}
koren, err := IntegrateAdvection1D(u0Arr, a, dx, tFinal, dt, 2, 0, 0)
if err != nil {
t.Fatalf("Koren: %v", err)
}
exact := func(x float64) float64 {
if x >= 0.5 && x <= 0.9 {
return 1.0
}
return 0.0
}
errUpwind := advectL1(t, upwind, n, exact)
errKoren := advectL1(t, koren, n, exact)
t.Logf("square pulse: upwind L1 %.4g, Koren L1 %.4g, ratio %.2f", errUpwind, errKoren, errUpwind/errKoren)
if errUpwind/errKoren < 2.0 {
t.Fatalf("limiter advantage %.2f, want at least 2x over upwind", errUpwind/errKoren)
}
// Both stay monotone in the sense that matters for a pulse: no
// undershoot below the initial range.
last := n
for i := range n {
for _, states := range []*core.Array{upwind, koren} {
if v := states.FloatAt(last + i); v < -1e-12 || v > 1+1e-12 {
t.Fatalf("scheme left the pulse range: %g at cell %d", v, i)
}
}
}
}
// TestAdvectionSmoothTransportOrder pins the documented orders: at
// fixed CFL 0.9 the upwind L1 error halves as the grid halves (first
// order) and the Koren error improves second order or better (measured
// ratio past 3 at every pair), staying below the upwind error
// throughout. Three grid levels separate the two rates: a pair alone
// cannot tell a second-order Koren from a degraded one.
func TestAdvectionSmoothTransportOrder(t *testing.T) {
a := 1.0
cfl := 0.9
const tFinal = 0.3
gaussian := func(x float64) float64 { return math.Exp(-math.Pow((x-0.35)/0.1, 2)) }
previousUpwind, previousKoren := 0.0, 0.0
for _, n := range []int{100, 200, 400} {
dx, centres := advectGrid(n)
u0 := make([]float64, n)
for i := range n {
u0[i] = gaussian(centres[i])
}
u0Arr, err := core.FromFloats(u0, n)
if err != nil {
t.Fatal(err)
}
dt := cfl * dx / math.Abs(a)
upwind, err := IntegrateUpwindAdvection1D(u0Arr, a, dx, tFinal, dt, 2, 0, 0)
if err != nil {
t.Fatalf("upwind n=%d: %v", n, err)
}
koren, err := IntegrateAdvection1D(u0Arr, a, dx, tFinal, dt, 2, 0, 0)
if err != nil {
t.Fatalf("Koren n=%d: %v", n, err)
}
shift := a * tFinal
exact := func(x float64) float64 { return gaussian(x - shift) }
eUpwind := advectL1(t, upwind, n, exact)
eKoren := advectL1(t, koren, n, exact)
t.Logf("n=%3d: upwind L1 %.3g, Koren L1 %.3g", n, eUpwind, eKoren)
if eKoren > eUpwind {
t.Fatalf("n=%d: Koren error %.3g above upwind %.3g", n, eKoren, eUpwind)
}
if previousUpwind > 0 {
if r := previousUpwind / eUpwind; r < 1.5 || r > 3.0 {
t.Fatalf("n=%d: upwind refinement ratio %.2f, want about 2", n, r)
}
if r := previousKoren / eKoren; r < 3.0 || r > 6.0 {
t.Fatalf("n=%d: Koren refinement ratio %.2f, want the second-order rate the limiter carries", n, r)
}
}
previousUpwind, previousKoren = eUpwind, eKoren
}
}
// TestAdvectionDiffusionMatchesHeatWhenAZero pins the reduction: with
// a = 0 the advection-diffusion solver performs exactly the
// Crank-Nicolson steps of IntegrateHeat1D, so the two histories agree
// to the last bit.
func TestAdvectionDiffusionMatchesHeatWhenAZero(t *testing.T) {
n := 32
dx := 1 / float64(n+1)
u0 := make([]float64, n)
for i := range n {
u0[i] = math.Sin(math.Pi * float64(i+1) * dx)
}
u0Arr, err := core.FromFloats(u0, n)
if err != nil {
t.Fatal(err)
}
heat, err := IntegrateHeat1D(u0Arr, 0.05, dx, 0.5, 0.004, 3, 0, 0)
if err != nil {
t.Fatalf("IntegrateHeat1D: %v", err)
}
adv, err := IntegrateAdvectionDiffusion1D(u0Arr, 0, 0.05, dx, 0.5, 0.004, 3, 0, 0)
if err != nil {
t.Fatalf("IntegrateAdvectionDiffusion1D: %v", err)
}
for i := range heat.Len() {
if heat.FloatAt(i) != adv.FloatAt(i) {
t.Fatalf("sample %d differs: heat %.17g, advection-diffusion %.17g",
i, heat.FloatAt(i), adv.FloatAt(i))
}
}
}
// TestAdvectionDiffusionConvergence pins the documented orders of the
// combination: the split step is first order in time and second order
// in space, so the coupled refinement at fixed CFL shows the two
// mixed, an L1 error shrinking by roughly 2.5 to 3 per halving. The
// exact solution is the drifting heat kernel
// sqrt(w0/w)·exp(−(x−x0−at)²/w), w = w0 + 4Dt, whose boundary values
// are zero to well below the measured errors.
func TestAdvectionDiffusionConvergence(t *testing.T) {
const (
a = 0.3
probD = 0.005
x0 = 0.3
tFinal = 0.6
)
exact := func(t, x float64) float64 {
w := 0.01 + 4*probD*t
return math.Sqrt(0.01/w) * math.Exp(-math.Pow(x-x0-a*t, 2)/w)
}
run := func(n int, cfl float64) float64 {
dx := 1 / float64(n+1)
u0 := make([]float64, n)
for i := range n {
u0[i] = exact(0, float64(i+1)*dx)
}
u0Arr, err := core.FromFloats(u0, n)
if err != nil {
t.Fatal(err)
}
states, err := IntegrateAdvectionDiffusion1D(u0Arr, a, probD, dx, tFinal, cfl*dx/a, 2, 0, 0)
if err != nil {
t.Fatalf("n=%d: %v", n, err)
}
return advectL1(t, states, n, func(x float64) float64 { return exact(tFinal, x) })
}
for _, cfl := range []float64{0.9, 0.3} {
previous := 0.0
for _, n := range []int{64, 128, 256} {
e := run(n, cfl)
t.Logf("CFL %.2f n=%3d: L1 %.4g", cfl, n, e)
if previous > 0 {
if r := previous / e; r < 2.2 || r > 3.7 {
t.Fatalf("CFL %.2f: refinement ratio %.2f at n=%d, want the mixed time-space rate about 2.7",
cfl, r, n)
}
}
previous = e
}
}
}
func TestAdvectionCFLRefusal(t *testing.T) {
n := 10
dx := 1 / float64(n+1)
u0, _ := core.FromFloats(make([]float64, n), n)
// CFL = 1.5 for a = 1.
dt := 1.5 * dx
if _, err := IntegrateAdvection1D(u0, 1, dx, 0.1, dt, 2, 0, 0); err == nil || !stringsContains(err, "CFL violated") {
t.Fatalf("Koren CFL violation: %v", err)
}
if _, err := IntegrateUpwindAdvection1D(u0, -1, dx, 0.1, dt, 2, 0, 0); err == nil || !stringsContains(err, "CFL violated") {
t.Fatalf("upwind CFL violation: %v", err)
}
if _, err := IntegrateAdvectionDiffusion1D(u0, 1, 0.01, dx, 0.1, dt, 2, 0, 0); err == nil || !stringsContains(err, "CFL violated") {
t.Fatalf("advection-diffusion CFL violation: %v", err)
}
// The boundary value at the CFL edge is accepted.
if _, err := IntegrateAdvection1D(u0, 1, dx, 0.1, dx, 2, 0, 0); err != nil {
t.Fatalf("CFL = 1 refused: %v", err)
}
}
func TestAdvectionErrors(t *testing.T) {
if _, err := IntegrateAdvection1D(mustFloats(t, []float64{1, 2, 3}, 3, 1), 1, 0.1, 1, 0.01, 2, 0, 0); err == nil || !stringsContains(err, "rank-1") {
t.Fatalf("a rank-2 initial state: %v", err)
}
if _, err := IntegrateAdvection1D(mustFloats(t, []float64{1, 2, 3}), math.NaN(), 0.1, 1, 0.01, 2, 0, 0); err == nil || !stringsContains(err, "finite") {
t.Fatalf("a NaN speed: %v", err)
}
if _, err := IntegrateAdvection1D(mustFloats(t, []float64{1, 2, 3}), 1, 0.1, 1, 0.01, 2, math.Inf(1), 0); err == nil || !stringsContains(err, "finite") {
t.Fatalf("an infinite ghost value: %v", err)
}
if _, err := IntegrateUpwindAdvection1D(mustFloats(t, []float64{1, 2, 3}), 1, -0.1, 1, 0.01, 2, 0, 0); err == nil || !stringsContains(err, "positive") {
t.Fatalf("a negative spacing: %v", err)
}
if _, err := IntegrateAdvection1D(mustFloats(t, []float64{1, 2, 3}), 1, 0.1, 1, 0.01, 1, 0, 0); err == nil || !stringsContains(err, "two samples") {
t.Fatalf("one sample: %v", err)
}
if _, err := IntegrateAdvection1D(mustFloats(t, []float64{math.NaN()}), 1, 0.1, 1, 0.01, 2, 0, 0); err == nil || !stringsContains(err, "non-finite") {
t.Fatalf("a NaN initial cell: %v", err)
}
if _, err := IntegrateAdvectionDiffusion1D(mustFloats(t, []float64{1, 2, 3}), 1, 0, 0.1, 1, 0.01, 2, 0, 0); err == nil || !stringsContains(err, "diffusivity") {
t.Fatalf("zero diffusivity: %v", err)
}
// A single interior cell is refused by the limiter's stencil need
// only for the limited boundary faces, so n = 1 must still run on
// the upwind path with first order there.
if _, err := IntegrateUpwindAdvection1D(mustFloats(t, []float64{0.5}), 1, 0.1, 0.05, 0.05, 2, 1, 0); err != nil {
t.Fatalf("a single-cell upwind run: %v", err)
}
}
// TestAdvectionLeftwardTransport pins the mirror branch: with a < 0
// the inflow is the right boundary, and both schemes must transport a
// monotone leftward ramp without new extrema, with the ghost value
// feeding in from the right.
func TestAdvectionLeftwardTransport(t *testing.T) {
n := 96
dx, centres := advectGrid(n)
u0 := make([]float64, n)
for i := range n {
u0[i] = centres[i]
}
u0Arr, err := core.FromFloats(u0, n)
if err != nil {
t.Fatal(err)
}
a := -1.0
dt := 0.9 * dx
for _, limited := range []bool{false, true} {
name := "upwind"
run := func() (*core.Array, error) {
if limited {
name = "Koren"
return IntegrateAdvection1D(u0Arr, a, dx, 0.2, dt, 3, 0, 1)
}
return IntegrateUpwindAdvection1D(u0Arr, a, dx, 0.2, dt, 3, 0, 1)
}
states, err := run()
if err != nil {
t.Fatalf("%s: %v", name, err)
}
for r := range states.Shape()[0] {
for i := range n {
v := states.FloatAt(r*n + i)
if v < -1e-9 || v > 1+1e-9 {
t.Fatalf("%s: sample %d cell %d left the range: %g", name, r, i, v)
}
if i > 0 && states.FloatAt(r*n+i) < states.FloatAt(r*n+i-1)-1e-9 {
t.Fatalf("%s: sample %d grows a new extremum at cell %d: %g after %g", name, r, i, v, states.FloatAt(r*n+i-1))
}
}
}
// The exact ramp is x + 0.2, cut at the inflow value 1.
last := (states.Shape()[0] - 1) * n
for i := range n {
want := math.Min(centres[i]+0.2, 1)
if d := math.Abs(states.FloatAt(last+i) - want); d > 0.02 {
t.Fatalf("%s: cell %d: %.6g, want %.6g", name, i, states.FloatAt(last+i), want)
}
}
}
}