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