feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user