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