feat: initial release
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-03 10:00:00 +02:00
commit af4ee19703
617 changed files with 191195 additions and 0 deletions
+565
View File
@@ -0,0 +1,565 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package integrate
import (
"math"
"testing"
linalg "sourcedock.dev/petrbalvin/tensor/linalg"
)
// boxMesh3D builds the structured tetrahedralisation of the unit box
// with m cells per side and returns the mesh plus the list of
// boundary vertices, in mesh order.
func boxMesh3D(t *testing.T, m int) (*TetraMesh3D, []int) {
t.Helper()
mesh, err := BoxTetraMesh3D(0, 0, 0, 1, 1, 1, m, m, m)
if err != nil {
t.Fatalf("BoxTetraMesh3D: %v", err)
}
boundary := make([]int, 0, 6*(m+1)*(m+1))
for k := range m + 1 {
for j := range m + 1 {
for i := range m + 1 {
if i == 0 || i == m || j == 0 || j == m || k == 0 || k == m {
boundary = append(boundary, (k*(m+1)+j)*(m+1)+i)
}
}
}
}
return mesh, boundary
}
// TestTetraStiffnessReference pins the element stiffness matrix
// against the hand-computed 4x4 for the reference tetrahedron
// (0,0,0), (1,0,0), (0,1,0), (0,0,1): with κ = 1 the matrix is
// κ/6·[[3,−1,−1,−1],[−1,1,0,0],[−1,0,1,0],[−1,0,0,1]].
func TestTetraStiffnessReference(t *testing.T) {
hand := [4][4]float64{
{3, -1, -1, -1},
{-1, 1, 0, 0},
{-1, 0, 1, 0},
{-1, 0, 0, 1},
}
k := tetraStiffness(0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1)
for i := range 4 {
for j := range 4 {
want := hand[i][j] / 6
if math.Abs(k[i][j]-want) > 1e-15 {
t.Fatalf("K[%d][%d] = %.17g, want %.17g", i, j, k[i][j], want)
}
}
}
// The conductivity scales the matrix, nothing else.
k2 := tetraStiffness(0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 2.5)
for i := range 4 {
for j := range 4 {
if math.Abs(k2[i][j]-2.5*k[i][j]) > 1e-15 {
t.Fatalf("K[%d][%d] did not scale with κ", i, j)
}
}
}
// A tetrahedron scaled by two in every direction: the basis
// gradients halve and the volume grows eightfold, so each entry
// doubles.
ks := tetraStiffness(0, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0, 2, 1)
for i := range 4 {
for j := range 4 {
if math.Abs(ks[i][j]-2*k[i][j]) > 1e-14 {
t.Fatalf("scaled K[%d][%d] = %.17g, want %.17g", i, j, ks[i][j], 2*k[i][j])
}
}
}
// The matrix is symmetric with positive diagonals and zero row
// sums off the constant mode: the P1 rigid-body mode has no
// stiffness.
for i := range 4 {
sum := 0.0
for j := range 4 {
if math.Abs(k[i][j]-k[j][i]) > 1e-15 {
t.Fatalf("K[%d][%d] != K[%d][%d]", i, j, j, i)
}
sum += k[i][j]
}
if math.Abs(sum) > 1e-14 {
t.Fatalf("row %d sums to %.3g, want 0", i, sum)
}
}
}
// TestBoxTetraMesh3DStructure pins the structured mesher: the vertex
// and tetrahedron counts, exact corner coordinates, positive
// orientation everywhere, unit total volume, and the boundary face
// count of the box surface.
func TestBoxTetraMesh3DStructure(t *testing.T) {
m, n, p := 3, 2, 4
mesh, err := BoxTetraMesh3D(0.5, -1, 2, 1.5, 1, 2, m, n, p)
if err != nil {
t.Fatalf("BoxTetraMesh3D: %v", err)
}
if mesh.Vertices3() != (m+1)*(n+1)*(p+1) {
t.Fatalf("vertex count %d, want %d", mesh.Vertices3(), (m+1)*(n+1)*(p+1))
}
if mesh.Tetrahedra4() != 6*m*n*p {
t.Fatalf("tetrahedron count %d, want %d", mesh.Tetrahedra4(), 6*m*n*p)
}
// Exact corner coordinates of the box.
at := func(i, j, k int) int { return (k*(n+1)+j)*(m+1) + i }
checkCorner := func(label string, i, j, k int, want [3]float64) {
t.Helper()
v := 3 * at(i, j, k)
for d := range 3 {
if mesh.Vertices[v+d] != want[d] {
t.Fatalf("%s = (%g, %g, %g), want (%g, %g, %g)",
label, mesh.Vertices[v], mesh.Vertices[v+1], mesh.Vertices[v+2], want[0], want[1], want[2])
}
}
}
checkCorner("origin", 0, 0, 0, [3]float64{0.5, -1, 2})
checkCorner("far corner", m, n, p, [3]float64{2, 0, 4})
// Every tetrahedron positively oriented, and the volumes sum to
// the box volume: 1.5 · 1 · 2 = 3.
total := 0.0
cell := 3.0 / float64(6*m*n*p)
for t4 := range mesh.Tetrahedra4() {
a, b, c, d := int(mesh.Tetrahedra[4*t4]), int(mesh.Tetrahedra[4*t4+1]), int(mesh.Tetrahedra[4*t4+2]), int(mesh.Tetrahedra[4*t4+3])
s6 := signedTetraVolume(
mesh.Vertices[3*a], mesh.Vertices[3*a+1], mesh.Vertices[3*a+2],
mesh.Vertices[3*b], mesh.Vertices[3*b+1], mesh.Vertices[3*b+2],
mesh.Vertices[3*c], mesh.Vertices[3*c+1], mesh.Vertices[3*c+2],
mesh.Vertices[3*d], mesh.Vertices[3*d+1], mesh.Vertices[3*d+2])
if s6 <= 0 {
t.Fatalf("tetrahedron %d has signed volume %g", t4, s6/6)
}
if d := math.Abs(s6/6 - cell); d > 1e-12 {
t.Fatalf("tetrahedron %d has volume %.6g, want %.6g", t4, s6/6, cell)
}
total += s6 / 6
}
if math.Abs(total-3) > 1e-12 {
t.Fatalf("total volume %.6g, want 3", total)
}
// The box surface carries two triangles per unit square face.
faces := mesh.BoundaryFaces()
if len(faces) != 3*2*2*(m*n+n*p+m*p) {
t.Fatalf("boundary face triples %d, want %d", len(faces)/3, 2*2*(m*n+n*p+m*p))
}
// Every listed face holds three distinct vertices, all on the box
// surface.
onSurface := func(v int) bool {
i := v % (m + 1)
j := (v / (m + 1)) % (n + 1)
k := v / ((m + 1) * (n + 1))
return i == 0 || i == m || j == 0 || j == n || k == 0 || k == p
}
for q := 0; q < len(faces); q += 3 {
if faces[q] == faces[q+1] || faces[q] == faces[q+2] || faces[q+1] == faces[q+2] {
t.Fatalf("boundary face [%d %d %d] repeats a vertex", faces[q], faces[q+1], faces[q+2])
}
for r := range 3 {
if !onSurface(faces[q+r]) {
t.Fatalf("boundary face vertex %d is interior", faces[q+r])
}
}
}
}
func TestBoxTetraMesh3DRefusals(t *testing.T) {
if _, err := BoxTetraMesh3D(0, 0, 0, 1, 1, 1, 0, 2, 2); err == nil {
t.Fatal("a zero cell count was accepted")
}
if _, err := BoxTetraMesh3D(0, 0, 0, 1, -1, 1, 2, 2, 2); err == nil {
t.Fatal("a negative extent was accepted")
}
if _, err := BoxTetraMesh3D(math.NaN(), 0, 0, 1, 1, 1, 2, 2, 2); err == nil {
t.Fatal("a NaN origin was accepted")
}
if _, err := BoxTetraMesh3D(0, 0, 0, math.Inf(1), 1, 1, 2, 2, 2); err == nil {
t.Fatal("an infinite extent was accepted")
}
}
// TestTetraMesh3DRefusals pins the construction contract: shapes,
// dtypes, ranges, finiteness, and the refusal of degenerate and
// inverted tetrahedra with the offending coordinates named.
func TestTetraMesh3DRefusals(t *testing.T) {
good := []float64{0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1}
if _, err := NewTetraMesh3D(floatsToArrayFEM(t, good, 4, 3), floatsToArrayFEM(t, []float64{0, 1, 2, 3}, 1, 4)); err == nil || !stringsContains(err, "integers") {
t.Fatalf("a float tetrahedron table: %v", err)
}
if _, err := NewTetraMesh3D(floatsToArrayFEM(t, good[:9], 3, 3), intsToArrayFEM(t, []int64{0, 1, 2, 3}, 1, 4)); err == nil || !stringsContains(err, "at least four vertices") {
t.Fatalf("three vertices: %v", err)
}
if _, err := NewTetraMesh3D(floatsToArrayFEM(t, good, 4, 3), intsToArrayFEM(t, []int64{}, 0, 4)); err == nil || !stringsContains(err, "must not be empty") {
t.Fatalf("an empty tetrahedron table: %v", err)
}
if _, err := NewTetraMesh3D(floatsToArrayFEM(t, good, 4, 3), intsToArrayFEM(t, []int64{0, 1, 2, 9}, 1, 4)); err == nil || !stringsContains(err, "out of range") {
t.Fatalf("an out-of-range index: %v", err)
}
bad := append([]float64{}, good...)
bad[0] = math.NaN()
if _, err := NewTetraMesh3D(floatsToArrayFEM(t, bad, 4, 3), intsToArrayFEM(t, []int64{0, 1, 2, 3}, 1, 4)); err == nil || !stringsContains(err, "not finite") {
t.Fatalf("a NaN coordinate: %v", err)
}
// Degenerate: four coplanar points.
degenerate := []float64{0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0}
_, err := NewTetraMesh3D(floatsToArrayFEM(t, degenerate, 4, 3), intsToArrayFEM(t, []int64{0, 1, 2, 3}, 1, 4))
if err == nil || !stringsContains(err, "degenerate") {
t.Fatalf("a coplanar tetrahedron: %v", err)
}
// Inverted: the reference tetrahedron with its last two vertices
// swapped; the message names the coordinates.
inverted := []float64{0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0}
_, err = NewTetraMesh3D(floatsToArrayFEM(t, inverted, 4, 3), intsToArrayFEM(t, []int64{0, 1, 2, 3}, 1, 4))
if err == nil || !stringsContains(err, "inverted") {
t.Fatalf("an inverted tetrahedron: %v", err)
}
if indexOf(err.Error(), "-0.1666") < 0 {
t.Fatalf("the inverted message should name the negative signed volume: %v", err)
}
if !stringsContains(err, "(1, 0, 0)") {
t.Fatalf("the inverted message should name the coordinates: %v", err)
}
// Wrong vertex table shape.
if _, err := NewTetraMesh3D(floatsToArrayFEM(t, good[:8], 4, 2), intsToArrayFEM(t, []int64{0, 1, 2, 3}, 1, 4)); err == nil || !stringsContains(err, "three columns") {
t.Fatalf("a two-column vertex table: %v", err)
}
if _, err := NewTetraMesh3D(floatsToArrayFEM(t, good, 4, 3), intsToArrayFEM(t, []int64{0, 1, 2}, 1, 3)); err == nil || !stringsContains(err, "four columns") {
t.Fatalf("a three-column tetrahedron table: %v", err)
}
}
// TestSolvePoissonFEM3DConvergence runs the manufactured solution
// u = sin(πx)·sin(πy)·sin(πz) on the unit box, driven by
// f = 3π²·u: the P1 nodal error must keep the O(h²) rate, roughly
// quadrupling per mesh doubling, exactly as the two-dimensional solve
// pins.
func TestSolvePoissonFEM3DConvergence(t *testing.T) {
solution := func(x, y, z float64) float64 {
return math.Sin(math.Pi*x) * math.Sin(math.Pi*y) * math.Sin(math.Pi*z)
}
source := func(x, y, z float64) float64 { return 3 * math.Pi * math.Pi * solution(x, y, z) }
previous := 0.0
for _, m := range []int{4, 8, 16} {
mesh, boundary := boxMesh3D(t, m)
values := make([]float64, len(boundary))
for p, node := range boundary {
values[p] = solution(mesh.Vertices[3*node], mesh.Vertices[3*node+1], mesh.Vertices[3*node+2])
}
u, err := SolvePoissonFEM3D(mesh, source, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values})
if err != nil {
t.Fatalf("SolvePoissonFEM3D(m=%d): %v", m, err)
}
worst := 0.0
for i := range mesh.Vertices3() {
d := math.Abs(u.FloatAt(i) - solution(mesh.Vertices[3*i], mesh.Vertices[3*i+1], mesh.Vertices[3*i+2]))
if d > worst {
worst = d
}
}
t.Logf("m=%2d: max nodal error %.3g", m, worst)
if previous > 0 && previous/worst < 2.5 {
t.Fatalf("m=%d: refinement ratio %.2f, want the O(h²) rate (previous %.3g, now %.3g)",
m, previous/worst, previous, worst)
}
previous = worst
}
}
// TestSolvePoissonFEM3DVariableKappa mirrors the two-dimensional
// variable-conductivity pin on the axis a centroid typo once
// corrupted: with κ = 1 + y the conductivity sample each element sees
// comes from its own y centroid, and the P1 convergence rate must
// survive the varying coefficient.
func TestSolvePoissonFEM3DVariableKappa(t *testing.T) {
solution := func(x, y, z float64) float64 {
return math.Sin(math.Pi*x) * math.Sin(math.Pi*y) * math.Sin(math.Pi*z)
}
kappaF := func(x, y, z float64) float64 { return 1 + y }
uy := func(x, y, z float64) float64 {
return math.Pi * math.Sin(math.Pi*x) * math.Cos(math.Pi*y) * math.Sin(math.Pi*z)
}
source := func(x, y, z float64) float64 {
return 3*math.Pi*math.Pi*(1+y)*solution(x, y, z) - uy(x, y, z)
}
previous := 0.0
for _, m := range []int{4, 8, 16} {
mesh, boundary := boxMesh3D(t, m)
values := make([]float64, len(boundary))
for p, node := range boundary {
values[p] = solution(mesh.Vertices[3*node], mesh.Vertices[3*node+1], mesh.Vertices[3*node+2])
}
u, err := SolvePoissonFEM3D(mesh, source, FEMPoisson3DOptions{KappaFunc: kappaF, DirichletNodes: boundary, DirichletValues: values})
if err != nil {
t.Fatalf("SolvePoissonFEM3D(m=%d): %v", m, err)
}
worst := 0.0
for i := range mesh.Vertices3() {
d := math.Abs(u.FloatAt(i) - solution(mesh.Vertices[3*i], mesh.Vertices[3*i+1], mesh.Vertices[3*i+2]))
if d > worst {
worst = d
}
}
t.Logf("m=%2d: max nodal error %.3g", m, worst)
if previous > 0 && previous/worst < 2.5 {
t.Fatalf("m=%d: refinement ratio %.2f, want the O(h²) rate (previous %.3g, now %.3g)",
m, previous/worst, previous, worst)
}
previous = worst
}
}
// TestSolvePoissonFEM3DPatchLinear is the patch test: a linear field
// lies in the P1 space, so with f = 0 and the boundary lifted the
// interior solution must equal the field to machine precision.
func TestSolvePoissonFEM3DPatchLinear(t *testing.T) {
mesh, boundary := boxMesh3D(t, 6)
field := func(x, y, z float64) float64 { return 1 + 2*x - 3*y + 4*z }
values := make([]float64, len(boundary))
for p, node := range boundary {
values[p] = field(mesh.Vertices[3*node], mesh.Vertices[3*node+1], mesh.Vertices[3*node+2])
}
u, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values})
if err != nil {
t.Fatalf("SolvePoissonFEM3D: %v", err)
}
worst := 0.0
for i := range mesh.Vertices3() {
d := math.Abs(u.FloatAt(i) - field(mesh.Vertices[3*i], mesh.Vertices[3*i+1], mesh.Vertices[3*i+2]))
if d > worst {
worst = d
}
}
if worst > 1e-11 {
t.Fatalf("linear patch test error %.3g, want machine precision", worst)
}
}
// TestSolvePoissonFEM3DNeumannNatural pins the natural boundary: a
// constant field with f = 0 satisfies the homogeneous Neumann
// condition everywhere, so pinning the constant at a single vertex
// must reproduce it across the whole mesh.
func TestSolvePoissonFEM3DNeumannNatural(t *testing.T) {
mesh, _ := boxMesh3D(t, 5)
u, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{4}})
if err != nil {
t.Fatalf("SolvePoissonFEM3D: %v", err)
}
for i := range mesh.Vertices3() {
if math.Abs(u.FloatAt(i)-4) > 1e-10 {
t.Fatalf("node %d: solution %.12g, want the constant 4", i, u.FloatAt(i))
}
}
}
// TestSolvePoissonFEM3DNeumannFlux pins the boundary-face integrals:
// u = (x²+y²+z²)/2 has −Δu = −3 and the flux κ∂u/∂n = 1 on the three
// faces at x = 1, y = 1 and z = 1 (0 on the coordinate planes), so
// prescribing those fluxes with a single pinned vertex must
// reproduce the quadratic field to the accuracy of the edge-midpoint
// face rule, improving as the mesh refines.
func TestSolvePoissonFEM3DNeumannFlux(t *testing.T) {
field := func(x, y, z float64) float64 { return (x*x + y*y + z*z) / 2 }
previous := 0.0
for _, m := range []int{8, 16} {
mesh, err := BoxTetraMesh3D(0, 0, 0, 1, 1, 1, m, m, m)
if err != nil {
t.Fatalf("BoxTetraMesh3D: %v", err)
}
var faces []int
bf := mesh.BoundaryFaces()
for p := 0; p < len(bf); p += 3 {
f := bf[p : p+3]
mx := (mesh.Vertices[3*f[0]] + mesh.Vertices[3*f[1]] + mesh.Vertices[3*f[2]]) / 3
my := (mesh.Vertices[3*f[0]+1] + mesh.Vertices[3*f[1]+1] + mesh.Vertices[3*f[2]+1]) / 3
mz := (mesh.Vertices[3*f[0]+2] + mesh.Vertices[3*f[1]+2] + mesh.Vertices[3*f[2]+2]) / 3
if mx == 1 || my == 1 || mz == 1 {
faces = append(faces, f[0], f[1], f[2])
}
}
flux := func(x, y, z float64) float64 {
if x == 1 || y == 1 || z == 1 {
return 1
}
return 0
}
u, err := SolvePoissonFEM3D(mesh, func(float64, float64, float64) float64 { return -3 },
FEMPoisson3DOptions{
Kappa: 1,
DirichletNodes: []int{0},
DirichletValues: []float64{0},
NeumannFaces: faces,
NeumannFlux: flux,
})
if err != nil {
t.Fatalf("m=%d: %v", m, err)
}
worst := 0.0
for i := range mesh.Vertices3() {
d := math.Abs(u.FloatAt(i) - field(mesh.Vertices[3*i], mesh.Vertices[3*i+1], mesh.Vertices[3*i+2]))
if d > worst {
worst = d
}
}
t.Logf("m=%2d: max nodal error %.3g", m, worst)
if previous > 0 && previous/worst < 1.3 {
t.Fatalf("m=%d: refinement ratio %.2f, want the face-rule error to shrink under refinement", m, previous/worst)
}
previous = worst
}
}
// TestSolvePoissonFEM3DOrderings runs the manufactured-solution solve
// under every ordering the factor offers: the ordering changes the
// fill, never the answer.
func TestSolvePoissonFEM3DOrderings(t *testing.T) {
solution := func(x, y, z float64) float64 {
return math.Sin(math.Pi*x) * math.Sin(math.Pi*y) * math.Sin(math.Pi*z)
}
mesh, boundary := boxMesh3D(t, 5)
values := make([]float64, len(boundary))
for p, node := range boundary {
values[p] = solution(mesh.Vertices[3*node], mesh.Vertices[3*node+1], mesh.Vertices[3*node+2])
}
source := func(x, y, z float64) float64 { return 3 * math.Pi * math.Pi * solution(x, y, z) }
reference, err := SolvePoissonFEM3D(mesh, source, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values})
if err != nil {
t.Fatalf("SolvePoissonFEM3D(natural): %v", err)
}
for _, ordering := range []linalg.SparseOrdering{
linalg.SparseOrderingReverseCuthillMcKee,
linalg.SparseOrderingMinimumDegree,
} {
u, err := SolvePoissonFEM3D(mesh, source, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values, Ordering: ordering})
if err != nil {
t.Fatalf("SolvePoissonFEM3D(%d): %v", ordering, err)
}
for i := range mesh.Vertices3() {
if math.Abs(u.FloatAt(i)-reference.FloatAt(i)) > 1e-9 {
t.Fatalf("ordering %d: node %d differs from the natural run", ordering, i)
}
}
}
}
func TestSolvePoissonFEM3DRefusals(t *testing.T) {
mesh, boundary := boxMesh3D(t, 3)
zero := make([]float64, len(boundary))
if _, err := SolvePoissonFEM3D(nil, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{0}}); err == nil || !stringsContains(err, "nil") {
t.Fatalf("a nil mesh: %v", err)
}
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 0, DirichletNodes: boundary, DirichletValues: zero}); err == nil || !stringsContains(err, "positive") {
t.Fatalf("zero conductivity: %v", err)
}
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: []int{0, 1}, DirichletValues: []float64{1}}); err == nil {
t.Fatal("a Dirichlet length mismatch was accepted")
}
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1}); err == nil || !stringsContains(err, "purely Neumann") {
t.Fatalf("a purely Neumann problem: %v", err)
}
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: []int{99}, DirichletValues: []float64{1}}); err == nil || !stringsContains(err, "out of range") {
t.Fatalf("an out-of-range Dirichlet node: %v", err)
}
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{math.NaN()}}); err == nil || !stringsContains(err, "not finite") {
t.Fatalf("a NaN Dirichlet value: %v", err)
}
// Neumann face tables: not triples, out-of-range and repeated
// vertices.
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{0}, NeumannFaces: []int{0, 1, 2, 3}}); err == nil || !stringsContains(err, "triples") {
t.Fatalf("a Neumann face count not divisible by three: %v", err)
}
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{0}, NeumannFaces: []int{0, 1, 77}}); err == nil || !stringsContains(err, "out-of-range") {
t.Fatalf("an out-of-range Neumann vertex: %v", err)
}
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{0}, NeumannFaces: []int{0, 0, 1}}); err == nil || !stringsContains(err, "repeats") {
t.Fatalf("a degenerate Neumann face: %v", err)
}
// A KappaFunc returning a non-positive conductivity names the
// tetrahedron.
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{
KappaFunc: func(float64, float64, float64) float64 { return -1 },
DirichletNodes: []int{0},
DirichletValues: []float64{0},
}); err == nil || !stringsContains(err, "positive") {
t.Fatalf("a non-positive KappaFunc value: %v", err)
}
// A KappaFunc returning an infinite conductivity names the
// tetrahedron the way the constant field's gate names itself.
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{
KappaFunc: func(float64, float64, float64) float64 { return math.Inf(1) },
DirichletNodes: []int{0},
DirichletValues: []float64{0},
}); err == nil || !stringsContains(err, "positive") {
t.Fatalf("an infinite KappaFunc value: %v", err)
}
// A non-finite source value refuses the solve: it used to land in
// the load and publish an all-NaN solution with a nil error.
if _, err := SolvePoissonFEM3D(mesh, func(x, y, z float64) float64 { return math.NaN() },
FEMPoisson3DOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: zero}); err == nil || !stringsContains(err, "non-finite") {
t.Fatalf("a NaN source value: %v", err)
}
// A non-finite Neumann flux refuses the solve the same way.
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{
Kappa: 1,
DirichletNodes: []int{0},
DirichletValues: []float64{0},
NeumannFaces: []int{0, 1, 2},
NeumannFlux: func(x, y, z float64) float64 { return math.Inf(1) },
}); err == nil || !stringsContains(err, "non-finite") {
t.Fatalf("an infinite Neumann flux: %v", err)
}
// An ordering that does not exist.
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: zero, Ordering: linalg.SparseOrdering(7)}); err == nil {
t.Fatal("an unknown ordering was accepted")
}
// A hand-built mesh with a degenerate tetrahedron is refused by
// the solver, which checks the volume itself.
hollow := &TetraMesh3D{
Vertices: []float64{0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0},
Tetrahedra: []int64{0, 1, 2, 3},
}
if _, err := SolvePoissonFEM3D(hollow, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{0}}); err == nil || !stringsContains(err, "degenerate") {
t.Fatalf("a hand-built degenerate mesh: %v", err)
}
}
// TestSolvePoissonFEM3DDuplicateDirichletNode pins the three-dimensional
// side of the same rule as the two-dimensional test: a node listed
// twice keeps its last value and is recorded once, so the repeated
// listing answers what the single listing with that value answers.
func TestSolvePoissonFEM3DDuplicateDirichletNode(t *testing.T) {
solution := func(x, y, z float64) float64 {
return math.Sin(math.Pi*x) * math.Sin(math.Pi*y) * math.Sin(math.Pi*z)
}
source := func(x, y, z float64) float64 { return 3 * math.Pi * math.Pi * solution(x, y, z) }
mesh, boundary := boxMesh3D(t, 2)
values := make([]float64, len(boundary))
for p, node := range boundary {
values[p] = solution(mesh.Vertices[3*node], mesh.Vertices[3*node+1], mesh.Vertices[3*node+2])
}
const extra = 0.5
nodes := append(append([]int(nil), boundary...), boundary[1])
dupValues := append(append([]float64(nil), values...), values[1]+extra)
u, err := SolvePoissonFEM3D(mesh, source, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: nodes, DirichletValues: dupValues})
if err != nil {
t.Fatalf("SolvePoissonFEM3D with a repeated node: %v", err)
}
single := append([]float64(nil), values...)
single[1] += extra
want, err := SolvePoissonFEM3D(mesh, source, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: single})
if err != nil {
t.Fatalf("SolvePoissonFEM3D with the node once: %v", err)
}
if got := u.FloatAt(boundary[1]); math.Abs(got-(values[1]+extra)) > 1e-12 {
t.Fatalf("the repeated node answered %g, want the last prescribed value %g", got, values[1]+extra)
}
worst := 0.0
for i := range mesh.Vertices3() {
worst = math.Max(worst, math.Abs(u.FloatAt(i)-want.FloatAt(i)))
}
if worst > 1e-12 {
t.Fatalf("the repeated listing differs from the single listing by %g, want the node recorded once", worst)
}
}