Files
tensor/integrate/fem3d_test.go
T
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

566 lines
23 KiB
Go
Raw 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"
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)
}
}