Files
tensor/integrate/fem2d_test.go
T

506 lines
20 KiB
Go
Raw Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package integrate
import (
"math"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
linalg "sourcedock.dev/petrbalvin/tensor/linalg"
)
// gridMesh builds the structured triangulation of the unit square
// with m cells per side, two triangles per cell, and returns the mesh
// plus the list of boundary vertices in the order (bottom row, top
// row, left column, right column), duplicates removed.
func gridMesh(t *testing.T, m int) (*TriangleMesh2D, []int) {
t.Helper()
mesh, err := GridTriangleMesh2D(0, 0, 1, 1, m, m)
if err != nil {
t.Fatalf("GridTriangleMesh2D: %v", err)
}
boundary := make([]int, 0, 4*m)
for i := range m + 1 {
boundary = append(boundary, i, m*(m+1)+i)
}
for j := 1; j < m; j++ {
boundary = append(boundary, j*(m+1), j*(m+1)+m)
}
return mesh, boundary
}
func TestSolvePoissonFEM2DConvergence(t *testing.T) {
// The manufactured solution u = sin(πx)·sin(πy) on the unit
// square drives f = 2π²·u; with the boundary lifted the P1 error
// must halve twice when the mesh is refined, the O(h²) the
// piecewise-linear theory promises.
solution := func(x, y float64) float64 { return math.Sin(math.Pi*x) * math.Sin(math.Pi*y) }
source := func(x, y float64) float64 { return 2 * math.Pi * math.Pi * solution(x, y) }
previous := 0.0
for _, m := range []int{8, 16, 32} {
mesh, boundary := gridMesh(t, m)
values := make([]float64, len(boundary))
for p, node := range boundary {
values[p] = solution(mesh.Vertices[2*node], mesh.Vertices[2*node+1])
}
u, err := SolvePoissonFEM2D(mesh, source, FEMPoissonOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values})
if err != nil {
t.Fatalf("SolvePoissonFEM2D(m=%d): %v", m, err)
}
worst := 0.0
for i := range mesh.Vertices2() {
if d := math.Abs(u.FloatAt(i) - solution(mesh.Vertices[2*i], mesh.Vertices[2*i+1])); 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)
}
if m == 32 && worst > 2e-3 {
t.Fatalf("m=32: error %.3g too large for the asymptotic range", worst)
}
previous = worst
}
}
// TestSolvePoissonFEM2DLinearExactness is the patch test the P1
// elements must pass without compromise: a linear field lies in the
// approximation space, so with f = 0 and the boundary lifted the
// interior solution must equal the field to machine precision.
func TestSolvePoissonFEM2DLinearExactness(t *testing.T) {
mesh, boundary := gridMesh(t, 12)
field := func(x, y float64) float64 { return 1 + 2*x - 3*y }
values := make([]float64, len(boundary))
for p, node := range boundary {
values[p] = field(mesh.Vertices[2*node], mesh.Vertices[2*node+1])
}
u, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values})
if err != nil {
t.Fatalf("SolvePoissonFEM2D: %v", err)
}
worst := 0.0
for i := range mesh.Vertices2() {
if d := math.Abs(u.FloatAt(i) - field(mesh.Vertices[2*i], mesh.Vertices[2*i+1])); d > worst {
worst = d
}
}
if worst > 1e-12 {
t.Fatalf("linear patch test error %.3g, want machine precision", worst)
}
}
// TestSolvePoissonFEM2DNeumannNatural 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 TestSolvePoissonFEM2DNeumannNatural(t *testing.T) {
mesh, _ := gridMesh(t, 10)
u, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{5}})
if err != nil {
t.Fatalf("SolvePoissonFEM2D: %v", err)
}
for i := range mesh.Vertices2() {
if math.Abs(u.FloatAt(i)-5) > 1e-10 {
t.Fatalf("node %d: solution %.12g, want the constant 5", i, u.FloatAt(i))
}
}
}
// TestSolvePoissonFEM2DOrderings runs the manufactured-solution solve
// under every ordering the factor offers: the ordering changes the
// fill, never the answer.
func TestSolvePoissonFEM2DOrderings(t *testing.T) {
solution := func(x, y float64) float64 { return math.Sin(math.Pi*x) * math.Sin(math.Pi*y) }
mesh, boundary := gridMesh(t, 10)
values := make([]float64, len(boundary))
for p, node := range boundary {
values[p] = solution(mesh.Vertices[2*node], mesh.Vertices[2*node+1])
}
source := func(x, y float64) float64 { return 2 * math.Pi * math.Pi * solution(x, y) }
reference, err := SolvePoissonFEM2D(mesh, source, FEMPoissonOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values})
if err != nil {
t.Fatalf("SolvePoissonFEM2D(natural): %v", err)
}
for _, ordering := range []linalg.SparseOrdering{
linalg.SparseOrderingReverseCuthillMcKee,
linalg.SparseOrderingMinimumDegree,
} {
u, err := SolvePoissonFEM2D(mesh, source, FEMPoissonOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values, Ordering: ordering})
if err != nil {
t.Fatalf("SolvePoissonFEM2D(%d): %v", ordering, err)
}
for i := range mesh.Vertices2() {
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 TestSolvePoissonFEM2DRefusals(t *testing.T) {
mesh, boundary := gridMesh(t, 5)
// Degenerate triangle: three collinear vertices.
if _, err := NewTriangleMesh2D(
floatsToArrayFEM(t, []float64{0, 0, 1, 0, 2, 0}, 3, 2),
intsToArrayFEM(t, []int64{0, 1, 2}, 1, 3)); err == nil || !stringsContains(err, "degenerate") {
t.Fatalf("a degenerate triangle: %v", err)
}
// Triangle index out of range.
if _, err := NewTriangleMesh2D(
floatsToArrayFEM(t, []float64{0, 0, 1, 0, 0, 1}, 3, 2),
intsToArrayFEM(t, []int64{0, 1, 3}, 1, 3)); err == nil || !stringsContains(err, "out of range") {
t.Fatalf("out of range index: %v", err)
}
// A float triangle table: the triangles must be integer indices.
if _, err := NewTriangleMesh2D(
floatsToArrayFEM(t, []float64{0, 0, 1, 0, 0, 1}, 3, 2),
floatsToArrayFEM(t, []float64{0, 1, 2}, 1, 3)); err == nil || !stringsContains(err, "integers") {
t.Fatalf("a float triangle table: %v", err)
}
// Dirichlet node out of range and a length mismatch.
if _, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{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 := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{Kappa: 1, DirichletNodes: []int{0, 1}, DirichletValues: []float64{1}}); err == nil {
t.Fatal("a Dirichlet length mismatch was accepted")
}
// Non-positive conductivity.
if _, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{Kappa: 0, DirichletNodes: boundary, DirichletValues: make([]float64, len(boundary))}); err == nil || !stringsContains(err, "positive") {
t.Fatalf("zero conductivity: %v", err)
}
// Non-finite Dirichlet value.
if _, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{math.NaN()}}); err == nil || !stringsContains(err, "finite") {
t.Fatalf("a NaN Dirichlet value: %v", err)
}
// A NaN vertex coordinate in the mesh table.
if _, err := NewTriangleMesh2D(
floatsToArrayFEM(t, []float64{math.NaN(), 0, 1, 0, 0, 1}, 3, 2),
intsToArrayFEM(t, []int64{0, 1, 2}, 1, 3)); err == nil || !stringsContains(err, "not finite") {
t.Fatalf("a NaN vertex coordinate: %v", err)
}
// An odd number of Neumann edge indices: no complete pairs.
if _, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{0}, NeumannEdges: []int{0, 1, 2}}); err == nil || !stringsContains(err, "pairs") {
t.Fatalf("an odd Neumann edge count: %v", err)
}
// A degenerate Neumann edge a == b.
if _, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{0}, NeumannEdges: []int{3, 3}}); err == nil || !stringsContains(err, "valid vertex pair") {
t.Fatalf("a degenerate Neumann edge: %v", err)
}
// A Neumann edge index out of range.
if _, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{0}, NeumannEdges: []int{0, 999}}); err == nil || !stringsContains(err, "valid vertex pair") {
t.Fatalf("an out of range Neumann edge: %v", err)
}
// A KappaFunc returning a non-positive conductivity names the
// triangle instead of assembling a singular stiffness matrix.
if _, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{
KappaFunc: func(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 triangle
// the way the constant field's gate names itself, instead of
// surfacing as a factorisation failure far from the cause.
if _, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{
KappaFunc: func(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 := SolvePoissonFEM2D(mesh, func(x, y float64) float64 { return math.NaN() },
FEMPoissonOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: make([]float64, len(boundary))}); 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 := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{
Kappa: 1,
DirichletNodes: boundary,
DirichletValues: make([]float64, len(boundary)),
NeumannEdges: []int{0, mesh.Vertices2() - 1},
NeumannFlux: func(x, y 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 := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: make([]float64, len(boundary)), Ordering: linalg.SparseOrdering(7)}); err == nil {
t.Fatal("an unknown ordering was accepted")
}
}
// TestSolvePoissonFEM2DIsDeterministic solves the same problem twice
// and requires bit-identical nodal values, the contract every Tensor
// entry point carries.
func TestSolvePoissonFEM2DIsDeterministic(t *testing.T) {
solution := func(x, y float64) float64 { return math.Sin(math.Pi*x) * math.Sin(math.Pi*y) }
mesh, boundary := gridMesh(t, 10)
values := make([]float64, len(boundary))
for p, node := range boundary {
values[p] = solution(mesh.Vertices[2*node], mesh.Vertices[2*node+1])
}
source := func(x, y float64) float64 { return 2 * math.Pi * math.Pi * solution(x, y) }
u1, err := SolvePoissonFEM2D(mesh, source, FEMPoissonOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values})
if err != nil {
t.Fatalf("first solve: %v", err)
}
u2, err := SolvePoissonFEM2D(mesh, source, FEMPoissonOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values})
if err != nil {
t.Fatalf("second solve: %v", err)
}
for i := range mesh.Vertices2() {
if u1.FloatAt(i) != u2.FloatAt(i) {
t.Fatalf("node %d differs: %.17g vs %.17g", i, u1.FloatAt(i), u2.FloatAt(i))
}
}
}
func stringsContains(err error, fragment string) bool {
return err != nil && len(err.Error()) >= len(fragment) && indexOf(err.Error(), fragment) >= 0
}
func indexOf(s, fragment string) int {
for i := 0; i+len(fragment) <= len(s); i++ {
if s[i:i+len(fragment)] == fragment {
return i
}
}
return -1
}
func floatsToArrayFEM(t *testing.T, vals []float64, shape ...int) *core.Array {
t.Helper()
a, err := core.FromFloats(vals, shape...)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
return a
}
func intsToArrayFEM(t *testing.T, vals []int64, shape ...int) *core.Array {
t.Helper()
a, err := core.FromInts(vals, shape...)
if err != nil {
t.Fatalf("FromInts: %v", err)
}
return a
}
// TestSolvePoissonFEM2DNeumannFlux pins the boundary-edge integrals:
// u = (x²+y²)/2 has −Δu = −2 and the flux κ∂u/∂n = 1 along the right
// and top edges' outward normals (0 along the bottom and left), so
// prescribing those fluxes with a single pinned vertex must
// reproduce the quadratic field. The midpoint edge rule is
// first-order consistent, so the error must halve with the mesh.
func TestSolvePoissonFEM2DNeumannFlux(t *testing.T) {
field := func(x, y float64) float64 { return (x*x + y*y) / 2 }
previous := 0.0
for _, m := range []int{10, 20} {
mesh, err := GridTriangleMesh2D(0, 0, 1, 1, m, m)
if err != nil {
t.Fatalf("GridTriangleMesh2D: %v", err)
}
// Boundary edges: pairs of neighbouring boundary vertices.
var edges []int
at := func(i, j int) int { return j*(m+1) + i }
for j := range m {
edges = append(edges, at(j, 0), at(j+1, 0)) // bottom: flux 0
edges = append(edges, at(j, m), at(j+1, m)) // top: flux 1
edges = append(edges, at(m, j), at(m, j+1)) // right: flux 1
edges = append(edges, at(0, j), at(0, j+1)) // left: flux 0
}
flux := func(x, y float64) float64 {
if x == 1 || y == 1 {
return 1
}
return 0
}
u, err := SolvePoissonFEM2D(mesh, func(float64, float64) float64 { return -2 },
FEMPoissonOptions{
Kappa: 1,
DirichletNodes: []int{at(0, 0)},
DirichletValues: []float64{0},
NeumannEdges: edges,
NeumannFlux: flux,
})
if err != nil {
t.Fatalf("m=%d: %v", m, err)
}
worst := 0.0
for i := range mesh.Vertices2() {
x := mesh.Vertices[2*i]
y := mesh.Vertices[2*i+1]
if d := math.Abs(u.FloatAt(i) - field(x, y)); d > worst {
worst = d
}
}
t.Logf("m=%2d: max nodal error %.3g", m, worst)
if previous > 0 && previous/worst < 1.4 {
t.Fatalf("m=%d: refinement ratio %.2f, want the first-order flux rate", m, previous/worst)
}
if m == 20 && worst > 5e-3 {
t.Fatalf("m=20: error %.3g too large", worst)
}
previous = worst
}
}
// TestSolvePoissonFEM2DVariableKappa runs the manufactured solution
// with a spatially varying conductivity evaluated at the element
// centroids: f must carry the analytic divergence terms, and the
// P1 convergence rate must survive the varying coefficient.
func TestSolvePoissonFEM2DVariableKappa(t *testing.T) {
sin, cos := math.Pi, math.Pi
u := func(x, y float64) float64 { return math.Sin(sin*x) * math.Sin(sin*y) }
kappaF := func(x, y float64) float64 { return 1 + x*y }
ux := func(x, y float64) float64 { return cos * math.Cos(cos*x) * math.Sin(cos*y) }
uy := func(x, y float64) float64 { return cos * math.Sin(cos*x) * math.Cos(cos*y) }
lap := func(x, y float64) float64 { return -2 * math.Pi * math.Pi * u(x, y) }
source := func(x, y float64) float64 {
k := kappaF(x, y)
return -(y*ux(x, y) + x*uy(x, y) + k*lap(x, y))
}
previous := 0.0
for _, m := range []int{8, 16, 32} {
mesh, boundary := gridMesh(t, m)
values := make([]float64, len(boundary))
for p, node := range boundary {
values[p] = u(mesh.Vertices[2*node], mesh.Vertices[2*node+1])
}
uk, err := SolvePoissonFEM2D(mesh, source,
FEMPoissonOptions{KappaFunc: kappaF, DirichletNodes: boundary, DirichletValues: values})
if err != nil {
t.Fatalf("SolvePoissonFEM2D(m=%d): %v", m, err)
}
worst := 0.0
for i := range mesh.Vertices2() {
if d := math.Abs(uk.FloatAt(i) - u(mesh.Vertices[2*i], mesh.Vertices[2*i+1])); 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", m, previous/worst)
}
previous = worst
}
}
// TestTriangleMesh2DBoundaryEdges pins the boundary-edge detection:
// the m by n grid carries exactly 2(m+n) boundary edges, every one of
// them with both endpoints on the boundary vertex ring.
func TestTriangleMesh2DBoundaryEdges(t *testing.T) {
mesh, err := GridTriangleMesh2D(0, 0, 1, 1, 5, 3)
if err != nil {
t.Fatalf("GridTriangleMesh2D: %v", err)
}
edges := mesh.BoundaryEdges()
if len(edges) != 2*2*(5+3) {
t.Fatalf("boundary edge count %d, want %d", len(edges), 2*(5+3))
}
onBoundary := func(v int) bool {
i := v % 6
j := v / 6
return i == 0 || i == 5 || j == 0 || j == 3
}
for p := 0; p < len(edges); p += 2 {
if !onBoundary(edges[p]) || !onBoundary(edges[p+1]) {
t.Fatalf("edge [%d,%d] is not on the boundary", edges[p], edges[p+1])
}
}
// The generator's vertex positions are exact.
mesh2, err := GridTriangleMesh2D(-1, 2, 2, 4, 2, 2)
if err != nil {
t.Fatalf("GridTriangleMesh2D: %v", err)
}
if mesh2.Vertices[0] != -1 || mesh2.Vertices[1] != 2 {
t.Fatalf("vertex 0 = [%g %g], want [-1 2]", mesh2.Vertices[0], mesh2.Vertices[1])
}
if mesh2.Vertices[2*(2*3+2)] != 1 || mesh2.Vertices[2*(2*3+2)+1] != 6 {
t.Fatalf("vertex (2,2) = [%g %g], want [1 6]",
mesh2.Vertices[2*(2*3+2)], mesh2.Vertices[2*(2*3+2)+1])
}
if _, err := GridTriangleMesh2D(0, 0, 1, 1, 0, 3); err == nil {
t.Fatal("a zero cell count was accepted")
}
if _, err := GridTriangleMesh2D(0, 0, -1, 1, 2, 2); err == nil {
t.Fatal("a negative extent was accepted")
}
}
// TestTriangleMesh2DBoundaryEdgesAreMeshEdges pins the pair contract of
// BoundaryEdges: every returned pair must be an edge the mesh actually
// carries, and the pairs must come out sorted, which a sort of the flat
// index list cannot deliver (it interleaves unrelated endpoints).
func TestTriangleMesh2DBoundaryEdgesAreMeshEdges(t *testing.T) {
mesh, err := GridTriangleMesh2D(0, 0, 1, 1, 1, 1)
if err != nil {
t.Fatalf("GridTriangleMesh2D: %v", err)
}
edges := mesh.BoundaryEdges()
// The square's four sides: (0,1), (0,2), (1,3), (2,3) in sorted
// pair order.
want := []int{0, 1, 0, 2, 1, 3, 2, 3}
if len(edges) != len(want) {
t.Fatalf("boundary edge count %d, want %d", len(edges)/2, len(want)/2)
}
for p := 0; p < len(edges); p += 2 {
if edges[p] > edges[p+1] {
t.Fatalf("edge [%d,%d] is not sorted as a pair", edges[p], edges[p+1])
}
}
for p := range len(want) {
if edges[p] != want[p] {
t.Fatalf("boundary edges %v, want %v", edges, want)
}
}
}
2026-09-03 10:00:00 +02:00
// TestSolvePoissonFEM2DDuplicateDirichletNode pins the documented rule
// for a node listed more than once: the last value is the prescribed
// one and the node enters the assembled system exactly once, so the
// repeated listing answers what the single listing with that value
// answers. Recording it twice appends a second unit row at the same
// coordinate, which the sparse conversion merges by summing, so the
// node's diagonal doubles and the solve halves its prescribed value.
func TestSolvePoissonFEM2DDuplicateDirichletNode(t *testing.T) {
solution := func(x, y float64) float64 { return math.Sin(math.Pi*x) * math.Sin(math.Pi*y) }
source := func(x, y float64) float64 { return 2 * math.Pi * math.Pi * solution(x, y) }
mesh, boundary := gridMesh(t, 4)
values := make([]float64, len(boundary))
for p, node := range boundary {
values[p] = solution(mesh.Vertices[2*node], mesh.Vertices[2*node+1])
}
// The list names the second boundary node again at the end, with a
// different value: the last one wins and the node stays single.
const extra = 0.5
nodes := append(append([]int(nil), boundary...), boundary[1])
dupValues := append(append([]float64(nil), values...), values[1]+extra)
u, err := SolvePoissonFEM2D(mesh, source, FEMPoissonOptions{Kappa: 1, DirichletNodes: nodes, DirichletValues: dupValues})
if err != nil {
t.Fatalf("SolvePoissonFEM2D with a repeated node: %v", err)
}
single := append([]float64(nil), values...)
single[1] += extra
want, err := SolvePoissonFEM2D(mesh, source, FEMPoissonOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: single})
if err != nil {
t.Fatalf("SolvePoissonFEM2D 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.Vertices2() {
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)
}
}