// Copyright (c) 2026 Petr Balvín (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) } }