237 lines
9.3 KiB
Go
237 lines
9.3 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package integrate
|
|
|
|
import (
|
|
"math"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
|
)
|
|
|
|
// Regression pins: the event detector's blind first step, watch
|
|
// values that signed themselves across zero, PDE parameters that were
|
|
// only half guarded, the step recorder's rounded endpoint, and a
|
|
// cubature budget the dimension powers could switch off.
|
|
|
|
// TestEventFirstAcceptedStep: the detector seeded its comparison from
|
|
// the END of the first accepted step, so a crossing inside that step
|
|
// went unnoticed; the seed is now the watch value at the step's start
|
|
// state, and the crossing is refined like any other.
|
|
func TestEventFirstAcceptedStep(t *testing.T) {
|
|
zero := func(now float64, y *core.Array) (*core.Array, error) {
|
|
return mustFloats(t, []float64{0}, 1), nil
|
|
}
|
|
cross := func(now float64, y *core.Array) (float64, error) {
|
|
return now - 0.0005, nil
|
|
}
|
|
hits, _, err := IntegrateODEEvents(zero, 0, 1, mustFloats(t, []float64{1}, 1),
|
|
[]ODEWatch{{Function: cross}}, ODEOptions{RelTol: 1e-12, AbsTol: 1e-14})
|
|
if err != nil {
|
|
t.Fatalf("IntegrateODEEvents: %v", err)
|
|
}
|
|
if len(hits) != 1 {
|
|
t.Fatalf("hits = %d, want exactly the crossing near 0.0005", len(hits))
|
|
}
|
|
if math.Abs(hits[0].Time-0.0005) > 1e-9 {
|
|
t.Fatalf("hit at %.14g, want 0.0005", hits[0].Time)
|
|
}
|
|
}
|
|
|
|
// TestEventWatchNonFiniteRefused: a NaN from a watch compared false in
|
|
// the sign test and manufactured a crossing (or swallowed one); a
|
|
// non-finite watch value is now an error naming the value.
|
|
func TestEventWatchNonFiniteRefused(t *testing.T) {
|
|
zero := func(now float64, y *core.Array) (*core.Array, error) {
|
|
return mustFloats(t, []float64{0}, 1), nil
|
|
}
|
|
calls := 0
|
|
nanFirst := func(now float64, y *core.Array) (float64, error) {
|
|
calls++
|
|
if calls == 1 {
|
|
return math.NaN(), nil
|
|
}
|
|
return -1, nil
|
|
}
|
|
_, _, err := IntegrateODEEvents(zero, 0, 1, mustFloats(t, []float64{1}, 1),
|
|
[]ODEWatch{{Function: nanFirst}}, ODEOptions{})
|
|
if err == nil || !strings.Contains(err.Error(), "non-finite") {
|
|
t.Fatalf("a NaN watch value: err = %v", err)
|
|
}
|
|
infLater := func(now float64, y *core.Array) (float64, error) {
|
|
if now > 0.2 {
|
|
return math.Inf(-1), nil
|
|
}
|
|
return -1, nil
|
|
}
|
|
_, _, err = IntegrateODEEvents(zero, 0, 1, mustFloats(t, []float64{1}, 1),
|
|
[]ODEWatch{{Function: infLater}}, ODEOptions{})
|
|
if err == nil || !strings.Contains(err.Error(), "non-finite") {
|
|
t.Fatalf("an infinite watch value: err = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestGridMeshFiniteExtents: a NaN or Inf extent passed the old
|
|
// positivity test (a NaN compares false against <= 0) and laid out a
|
|
// mesh of non-finite vertices.
|
|
func TestGridMeshFiniteExtents(t *testing.T) {
|
|
for name, extents := range map[string][2]float64{
|
|
"NaN width": {math.NaN(), 1},
|
|
"NaN height": {1, math.NaN()},
|
|
"Inf width": {math.Inf(1), 1},
|
|
"Inf height": {1, math.Inf(-1)},
|
|
} {
|
|
mesh, err := GridTriangleMesh2D(0, 0, extents[0], extents[1], 2, 2)
|
|
if err == nil || !strings.Contains(err.Error(), "finite") {
|
|
t.Fatalf("%s: err = %v, mesh = %v", name, err, mesh != nil)
|
|
}
|
|
}
|
|
// A valid grid still builds.
|
|
if _, err := GridTriangleMesh2D(0, 0, 1, 1, 2, 2); err != nil {
|
|
t.Fatalf("a valid grid: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestFEMConstantKappaNonFinite: a +Inf constant conductivity slipped
|
|
// through the positivity test and died mid-factorisation.
|
|
func TestFEMConstantKappaNonFinite(t *testing.T) {
|
|
mesh, err := GridTriangleMesh2D(0, 0, 1, 1, 4, 4)
|
|
if err != nil {
|
|
t.Fatalf("GridTriangleMesh2D: %v", err)
|
|
}
|
|
opts := FEMPoissonOptions{Kappa: math.Inf(1), DirichletNodes: []int{0}, DirichletValues: []float64{0}}
|
|
if _, err := SolvePoissonFEM2D(mesh, nil, opts); err == nil || !strings.Contains(err.Error(), "positive") {
|
|
t.Fatalf("a +Inf constant conductivity: err = %v", err)
|
|
}
|
|
// With the conductivity field set, a non-finite placeholder for
|
|
// the constant is refused all the same.
|
|
opts.KappaFunc = func(x, y float64) float64 { return 1 }
|
|
if _, err := SolvePoissonFEM2D(mesh, nil, opts); err == nil || !strings.Contains(err.Error(), "positive") {
|
|
t.Fatalf("a +Inf placeholder conductivity beside KappaFunc: err = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestPDEParameterNonFiniteRefusals walks the solvers' numeric
|
|
// parameters: each one used to slip a NaN or Inf past a comparison
|
|
// that reads false against NaN and publish an all-NaN history.
|
|
func TestPDEParameterNonFiniteRefusals(t *testing.T) {
|
|
u1, v1 := mustFloats(t, []float64{0, 1, 0, 1, 0}, 5), mustFloats(t, []float64{0, 0, 0, 0, 0}, 5)
|
|
u2 := mustFloats(t, []float64{0, 1, 0, 0, 1, 0, 0, 1, 0}, 3, 3)
|
|
cases := []struct {
|
|
name string
|
|
run func() (*core.Array, error)
|
|
}{
|
|
{"Heat1D kappa +Inf", func() (*core.Array, error) {
|
|
return IntegrateHeat1D(u1, math.Inf(1), 0.1, 0.1, 0.01, 2, 0, 0)
|
|
}},
|
|
{"Heat1D kappa NaN", func() (*core.Array, error) {
|
|
return IntegrateHeat1D(u1, math.NaN(), 0.1, 0.1, 0.01, 2, 0, 0)
|
|
}},
|
|
{"Heat1D NaN bound", func() (*core.Array, error) {
|
|
return IntegrateHeat1D(u1, 1, 0.1, 0.1, 0.01, 2, math.NaN(), 0)
|
|
}},
|
|
{"Heat1D Inf bound", func() (*core.Array, error) {
|
|
return IntegrateHeat1D(u1, 1, 0.1, 0.1, 0.01, 2, 0, math.Inf(1))
|
|
}},
|
|
{"Wave1D c NaN", func() (*core.Array, error) {
|
|
return IntegrateWave1D(u1, v1, math.NaN(), 0.1, 0.1, 0.01, 2)
|
|
}},
|
|
{"Wave1D c Inf", func() (*core.Array, error) {
|
|
return IntegrateWave1D(u1, v1, math.Inf(1), 0.1, 0.1, 0.01, 2)
|
|
}},
|
|
{"Wave1D v0 NaN", func() (*core.Array, error) {
|
|
return IntegrateWave1D(u1, mustFloats(t, []float64{0, math.NaN(), 0, 0, 0}, 5), 1, 0.1, 0.1, 0.01, 2)
|
|
}},
|
|
{"Heat2D kappa +Inf", func() (*core.Array, error) {
|
|
return IntegrateHeat2D(u2, math.Inf(1), 0.1, 0.1, 0.1, 0.01, 2, 0, 0, 0, 0)
|
|
}},
|
|
{"Heat2D NaN boundary", func() (*core.Array, error) {
|
|
return IntegrateHeat2D(u2, 1, 0.1, 0.1, 0.1, 0.01, 2, 0, 0, math.NaN(), 0)
|
|
}},
|
|
{"Heat2D Inf boundary", func() (*core.Array, error) {
|
|
return IntegrateHeat2D(u2, 1, 0.1, 0.1, 0.1, 0.01, 2, 0, 0, 0, math.Inf(1))
|
|
}},
|
|
{"Wave2D v0 NaN", func() (*core.Array, error) {
|
|
return IntegrateWave2D(u2, mustFloats(t, []float64{0, 0, 0, 0, math.NaN(), 0, 0, 0, 0}, 3, 3), 1, 0.1, 0.1, 0.1, 0.01, 2)
|
|
}},
|
|
}
|
|
for _, c := range cases {
|
|
if _, err := c.run(); err == nil || !strings.Contains(err.Error(), "finite") && !strings.Contains(err.Error(), "positive") {
|
|
t.Fatalf("%s: err = %v, want a finite/positive refusal", c.name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestODEStepsEndpointExact: the recorder's last time was the run's
|
|
// accumulated t+h, a few ulps off t1; it is now t1 exactly.
|
|
func TestODEStepsEndpointExact(t *testing.T) {
|
|
decayF := func(now float64, y *core.Array) (*core.Array, error) {
|
|
return core.MulF(y, -1), nil
|
|
}
|
|
times, states, err := IntegrateODESteps(decayF, 0, 0.3, mustFloats(t, []float64{1}, 1), ODEOptions{})
|
|
if err != nil {
|
|
t.Fatalf("IntegrateODESteps: %v", err)
|
|
}
|
|
if last := times[len(times)-1]; last != 0.3 {
|
|
t.Fatalf("last recorded time = %.17g, want 0.3 exactly", last)
|
|
}
|
|
// The pinned endpoint still closes on the analytic curve.
|
|
if last := states[len(states)-1].FloatAt(0); math.Abs(last-math.Exp(-0.3)) > 1e-6 {
|
|
t.Fatalf("y(0.3) = %.14g, want %.14g", last, math.Exp(-0.3))
|
|
}
|
|
// Across magnitudes the run's own boundary misses t1 by whole
|
|
// ulps (t0 = 1e16 has an ulp of 2 and the span is 2): the endpoint
|
|
// is pinned regardless, and the recorded state there is the
|
|
// answer IntegrateODE itself returns for t1.
|
|
flat := func(now float64, y *core.Array) (*core.Array, error) {
|
|
return core.FromFloats([]float64{y.FloatAt(0)}, 1)
|
|
}
|
|
const (
|
|
big = 1e16
|
|
span = 2.0
|
|
)
|
|
times, states, err = IntegrateODESteps(flat, big, big+span, mustFloats(t, []float64{3}, 1), ODEOptions{})
|
|
if err != nil {
|
|
t.Fatalf("IntegrateODESteps across magnitudes: %v", err)
|
|
}
|
|
if last := times[len(times)-1]; last != big+span {
|
|
t.Fatalf("last recorded time = %.17g, want %.17g exactly", last, big+span)
|
|
}
|
|
if got := states[len(states)-1].FloatAt(0); got != 3 {
|
|
t.Fatalf("state at the endpoint = %g, want the constant 3", got)
|
|
}
|
|
}
|
|
|
|
// TestCubatureDimensionPowerSaturates: past the twenties the straight
|
|
// int powers wrapped, the bisection cost went negative and every
|
|
// budget check with it; the powers now saturate and a saturated power
|
|
// reads as above the budget.
|
|
func TestCubatureDimensionPowerSaturates(t *testing.T) {
|
|
lower := make([]float64, 25)
|
|
upper := make([]float64, 25)
|
|
for i := range upper {
|
|
upper[i] = 1
|
|
}
|
|
f := func(x []float64) float64 { return 1 }
|
|
// A small budget is refused on the single-bisection cost alone.
|
|
if _, err := IntegrateND(f, lower, upper, CubatureOptions{MaxEvals: 1024}); err == nil || !strings.Contains(err.Error(), "budget") {
|
|
t.Fatalf("d = 25 under a 1024-evaluation budget: err = %v", err)
|
|
}
|
|
// A budget of MaxInt used to walk the powers straight into the
|
|
// wrap and then evaluate the 5^25-point root box: the saturated
|
|
// computation refuses it before the first evaluation.
|
|
if _, err := IntegrateND(f, lower, upper, CubatureOptions{MaxEvals: math.MaxInt}); err == nil || !strings.Contains(err.Error(), "budget") {
|
|
t.Fatalf("d = 25 under a MaxInt budget: err = %v", err)
|
|
}
|
|
// A sane dimension and budget still integrate.
|
|
got, err := IntegrateND(f, lower[:3], upper[:3], CubatureOptions{})
|
|
if err != nil {
|
|
t.Fatalf("d = 3 under the default budget: %v", err)
|
|
}
|
|
if math.Abs(got-1) > 1e-10 {
|
|
t.Fatalf("integral of 1 over the unit cube = %.17g, want 1", got)
|
|
}
|
|
}
|