Files
tensor/integrate/fem2d.go
T

389 lines
15 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"
"slices"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
linalg "sourcedock.dev/petrbalvin/tensor/linalg"
)
// The finite element surface for second-order problems on general
// two-dimensional domains: piecewise-linear (P1) elements on a
// conforming triangular mesh, the stiffness matrix assembled straight
// into the sparse triple format, Dirichlet values eliminated by
// lifting, Neumann boundaries free of charge, and the reduced system
// handed to the sparse Cholesky factorisation the direct-solvers
// surface provides.
// TriangleMesh2D carries a conforming triangular mesh: vertex
// coordinates as x,y pairs and triangles as triples of vertex
// indices. The orientation of a triangle does not matter; a triangle
// with zero area does and is refused at construction.
type TriangleMesh2D struct {
// Vertices holds x,y for every vertex: two entries per vertex.
Vertices []float64
// Triangles holds three vertex indices per triangle.
Triangles []int64
}
// NewTriangleMesh2D builds a mesh from a vertex table with two
// columns and a triangle table with three columns of vertex indices.
// Indices must lie in range and a degenerate triangle (three
// collinear vertices) is an error: its stiffness contribution is
// undefined.
func NewTriangleMesh2D(vertices *core.Array, triangles *core.Array) (*TriangleMesh2D, error) {
const name = "NewTriangleMesh2D"
if vertices.Dtype() == core.Complex || triangles.Dtype() == core.Complex {
return nil, base.Errf("%s: complex mesh data is not supported", name)
}
if vertices.NDim() != 2 || vertices.Shape()[1] != 2 {
return nil, base.Errf("%s: the vertex table must be rank 2 with two columns, got shape %s", name, base.ShapeText(vertices.Shape()))
}
if triangles.Dtype() != core.Int {
return nil, base.Errf("%s: the triangle table must hold integers, got %s", name, triangles.Dtype())
}
if triangles.NDim() != 2 || triangles.Shape()[1] != 3 {
return nil, base.Errf("%s: the triangle table must be rank 2 with three columns, got shape %s", name, base.ShapeText(triangles.Shape()))
}
n := vertices.Shape()[0]
m := triangles.Shape()[0]
if n < 3 {
return nil, base.Errf("%s: a mesh needs at least three vertices, got %d", name, n)
}
if m == 0 {
// An empty triangle table would surface deep in the sparse
// factorisation on the zero rows of the free nodes, far from
// the mesh that caused it.
return nil, base.Errf("%s: the triangle table must not be empty", name)
}
mesh := &TriangleMesh2D{Vertices: make([]float64, 2*n), Triangles: make([]int64, 3*m)}
for i := range 2 * n {
v := vertices.FloatAt(i)
if math.IsNaN(v) || math.IsInf(v, 0) {
return nil, base.Errf("%s: vertex coordinate %d is not finite", name, i)
}
mesh.Vertices[i] = v
}
for p := range 3 * m {
idx := triangles.RawInts()[p]
if idx < 0 || idx >= int64(n) {
return nil, base.Errf("%s: triangle vertex index %d out of range for %d vertices", name, idx, n)
}
mesh.Triangles[p] = idx
}
// A triangle with zero area carries no stiffness: refuse it here
// where the caller can name the triangle, not mid-assembly.
for t := range m {
a, b, c := mesh.Triangles[3*t], mesh.Triangles[3*t+1], mesh.Triangles[3*t+2]
ax, ay := mesh.Vertices[2*a], mesh.Vertices[2*a+1]
bx, by := mesh.Vertices[2*b], mesh.Vertices[2*b+1]
cx, cy := mesh.Vertices[2*c], mesh.Vertices[2*c+1]
if area := math.Abs((bx-ax)*(cy-ay)-(cx-ax)*(by-ay)) / 2; area == 0 {
return nil, base.Errf("%s: triangle %d is degenerate (zero area)", name, t)
}
}
return mesh, nil
}
// Vertices2 returns the vertex count.
func (m *TriangleMesh2D) Vertices2() int { return len(m.Vertices) / 2 }
// Triangles3 returns the triangle count.
func (m *TriangleMesh2D) Triangles3() int { return len(m.Triangles) / 3 }
// BoundaryEdges returns the mesh's boundary edges as flat pairs of
// vertex indices: an edge belongs to the boundary when exactly one
// triangle carries it. The pairs are sorted, so the result is a pure
// function of the mesh.
func (m *TriangleMesh2D) BoundaryEdges() []int {
count := make(map[[2]int]int, len(m.Triangles))
key := func(a, b int) [2]int {
if a < b {
return [2]int{a, b}
}
return [2]int{b, a}
}
for t := 0; t < m.Triangles3(); t++ {
a, b, c := int(m.Triangles[3*t]), int(m.Triangles[3*t+1]), int(m.Triangles[3*t+2])
count[key(a, b)]++
count[key(b, c)]++
count[key(c, a)]++
}
sets := make([][2]int, 0, len(count))
2026-09-03 10:00:00 +02:00
for e, n := range count {
if n == 1 {
sets = append(sets, e)
2026-09-03 10:00:00 +02:00
}
}
// The pairs sort as pairs, never as one flat index list: a flat sort
// interleaves the endpoints of unrelated edges and hands back pairs
// the mesh does not carry.
slices.SortFunc(sets, func(x, y [2]int) int {
if x[0] != y[0] {
return x[0] - y[0]
}
return x[1] - y[1]
})
edges := make([]int, 0, 2*len(sets))
for _, e := range sets {
edges = append(edges, e[0], e[1])
}
2026-09-03 10:00:00 +02:00
return edges
}
// GridTriangleMesh2D builds the structured triangulation of the
// axis-aligned rectangle [x0, x0+width] × [y0, y0+height] with m by n
// cells, two triangles per cell. m and n must both be positive.
func GridTriangleMesh2D(x0, y0, width, height float64, m, n int) (*TriangleMesh2D, error) {
const name = "GridTriangleMesh2D"
if m <= 0 || n <= 0 {
return nil, base.Errf("%s: the cell counts must be positive, got %d by %d", name, m, n)
}
// The same guard NewTriangleMesh2D applies to its vertex table: a
// non-finite extent or origin would lay out vertices at NaN or Inf
// and only surface mid-factorisation, far from the cause.
if !(width > 0) || !(height > 0) || math.IsInf(width, 0) || math.IsInf(height, 0) ||
math.IsNaN(x0) || math.IsInf(x0, 0) || math.IsNaN(y0) || math.IsInf(y0, 0) {
return nil, base.Errf("%s: the extents must be finite and positive and the origin finite, got origin (%g, %g), extents %g by %g",
name, x0, y0, width, height)
}
vertices := make([]float64, 2*(m+1)*(n+1))
for j := range n + 1 {
for i := range m + 1 {
vertices[2*(j*(m+1)+i)] = x0 + width*float64(i)/float64(m)
vertices[2*(j*(m+1)+i)+1] = y0 + height*float64(j)/float64(n)
}
}
at := func(i, j int) int64 { return int64(j*(m+1) + i) }
triangles := make([]int64, 0, 6*m*n)
for j := range n {
for i := range m {
triangles = append(triangles,
at(i, j), at(i+1, j), at(i+1, j+1),
at(i, j), at(i+1, j+1), at(i, j+1))
}
}
return &TriangleMesh2D{Vertices: vertices, Triangles: triangles}, nil
}
// FEMPoissonOptions carries the data SolvePoissonFEM2D needs beside
// the mesh and the source: the conductivity, the prescribed boundary
// values, and the optional flux boundary.
type FEMPoissonOptions struct {
// Kappa is the constant conductivity when KappaFunc is nil. It
// must be positive.
Kappa float64
// KappaFunc, when set, gives the conductivity at a point. It is
// evaluated at the triangle centroids and must be positive there
// for every triangle; a non-positive value names the triangle.
KappaFunc func(x, y float64) float64
// DirichletNodes lists the vertices with prescribed values and
// DirichletValues the values in the same order. The nodes leave
// the system with their rows and columns; at least one is
// required, because a purely Neumann problem has no unique
// solution.
DirichletNodes []int
DirichletValues []float64
// NeumannEdges lists boundary edges as flat pairs of vertex
// indices and NeumannFlux gives the flux κ∂u/∂n along each edge's
// outward normal: each edge receives half of length·flux at its
// midpoint into both endpoints. A nil flux means zero.
NeumannEdges []int
NeumannFlux func(x, y float64) float64
// Ordering selects the fill-reducing permutation for the sparse
// Cholesky factorisation. The zero value is the natural order;
// meshes usually want SparseOrderingReverseCuthillMcKee.
Ordering linalg.SparseOrdering
}
// SolvePoissonFEM2D solves −∇·(κ∇u) = f on the mesh with
// piecewise-linear elements: the stiffness matrix is assembled per
// triangle (the conductivity evaluated at the centroids when it
// varies), the load is lumped at the vertices from f at the
// centroids, Neumann fluxes are integrated along their edges, and
// Dirichlet values are eliminated by lifting. f may be nil for the
// homogeneous equation.
func SolvePoissonFEM2D(mesh *TriangleMesh2D, f func(x, y float64) float64, opts FEMPoissonOptions) (*core.Array, error) {
const name = "SolvePoissonFEM2D"
if mesh == nil {
return nil, base.Errf("%s: the mesh must not be nil", name)
}
n := mesh.Vertices2()
// With KappaFunc nil the constant conductivity is the value used,
// so it must be positive and finite; with the field set the
// constant is a placeholder, but a non-finite one is still refused
// rather than silently ignored.
if opts.KappaFunc == nil {
if !(opts.Kappa > 0) || math.IsInf(opts.Kappa, 0) {
return nil, base.Errf("%s: the conductivity must be positive, got %g", name, opts.Kappa)
}
} else if math.IsNaN(opts.Kappa) || math.IsInf(opts.Kappa, 0) {
return nil, base.Errf("%s: the conductivity must be positive, got %g", name, opts.Kappa)
}
if len(opts.DirichletNodes) != len(opts.DirichletValues) {
return nil, base.Errf("%s: %d Dirichlet nodes but %d values", name, len(opts.DirichletNodes), len(opts.DirichletValues))
}
if len(opts.DirichletNodes) == 0 {
return nil, base.Errf("%s: a purely Neumann problem has no unique solution; prescribe at least one Dirichlet value", name)
}
// The Dirichlet nodes as a dense marker with their prescribed
// values: the lifting and the unit rows below each visit every
// assembled entry, and a marker answers those visits in constant
// time where a set of nodes answered with a hash. A node listed
// twice keeps its last value and appears once, as it did in the
// set; the appended order does not reach the assembled system,
// whose coordinate entries the sparse conversion sorts and merges
// by coordinate.
dirichletMark := make([]bool, n)
dirichletVal := make([]float64, n)
dirichletNodes := make([]int, 0, len(opts.DirichletNodes))
for p, d := range opts.DirichletNodes {
if d < 0 || d >= n {
return nil, base.Errf("%s: Dirichlet node %d out of range for %d vertices", name, d, n)
}
v := opts.DirichletValues[p]
if math.IsNaN(v) || math.IsInf(v, 0) {
return nil, base.Errf("%s: Dirichlet value at node %d is not finite", name, d)
}
if !dirichletMark[d] {
dirichletNodes = append(dirichletNodes, d)
}
dirichletMark[d] = true
dirichletVal[d] = v
}
if len(opts.NeumannEdges)%2 != 0 {
return nil, base.Errf("%s: %d Neumann edge indices, want pairs", name, len(opts.NeumannEdges))
}
for p := 0; p < len(opts.NeumannEdges); p += 2 {
a, b := opts.NeumannEdges[p], opts.NeumannEdges[p+1]
if a < 0 || a >= n || b < 0 || b >= n || a == b {
return nil, base.Errf("%s: Neumann edge [%d,%d] is not a valid vertex pair", name, a, b)
}
}
// Assembly: nine entries per triangle, symmetric by construction;
// the load is lumped one third of the triangle area to each of
// its vertices, with the conductivity evaluated at the centroid
// when it varies.
entries := make([]float64, 0, 9*mesh.Triangles3())
rows := make([]int, 0, 9*mesh.Triangles3())
cols := make([]int, 0, 9*mesh.Triangles3())
load := make([]float64, n)
for t := 0; t < mesh.Triangles3(); t++ {
a, b, c := int(mesh.Triangles[3*t]), int(mesh.Triangles[3*t+1]), int(mesh.Triangles[3*t+2])
ax, ay := mesh.Vertices[2*a], mesh.Vertices[2*a+1]
bx, by := mesh.Vertices[2*b], mesh.Vertices[2*b+1]
cx, cy := mesh.Vertices[2*c], mesh.Vertices[2*c+1]
area := math.Abs((bx-ax)*(cy-ay)-(cx-ax)*(by-ay)) / 2
kappa := opts.Kappa
if opts.KappaFunc != nil {
kappa = opts.KappaFunc((ax+bx+cx)/3, (ay+by+cy)/3)
if !(kappa > 0) || math.IsNaN(kappa) || math.IsInf(kappa, 0) {
return nil, base.Errf("%s: the conductivity at triangle %d is %g, want positive", name, t, kappa)
}
}
// The gradient basis: b are the y differences, c the x
// differences, and K = κ/(4A)·(b⊗b + c⊗c).
bb := [3]float64{by - cy, cy - ay, ay - by}
cc := [3]float64{cx - bx, ax - cx, bx - ax}
nodes := [3]int{a, b, c}
for i := range 3 {
for j := range 3 {
v := kappa * (bb[i]*bb[j] + cc[i]*cc[j]) / (4 * area)
rows = append(rows, nodes[i])
cols = append(cols, nodes[j])
entries = append(entries, v)
}
}
if f != nil {
fv := f((ax+bx+cx)/3, (ay+by+cy)/3)
// A non-finite source value would flow into the load and the
// solve would publish an all-NaN solution with a nil error,
// the breach every other integrator here refuses up front.
if math.IsNaN(fv) || math.IsInf(fv, 0) {
return nil, base.Errf("%s: the source returned the non-finite value %g at triangle %d", name, fv, t)
}
contribution := area / 3 * fv
load[a] += contribution
load[b] += contribution
load[c] += contribution
}
}
// Neumann fluxes: half of length·flux into each endpoint of every
// listed edge, the flux evaluated at the edge midpoint.
if len(opts.NeumannEdges) > 0 && opts.NeumannFlux != nil {
for p := 0; p < len(opts.NeumannEdges); p += 2 {
a, b := opts.NeumannEdges[p], opts.NeumannEdges[p+1]
ax, ay := mesh.Vertices[2*a], mesh.Vertices[2*a+1]
bx, by := mesh.Vertices[2*b], mesh.Vertices[2*b+1]
length := math.Hypot(bx-ax, by-ay)
fv := opts.NeumannFlux((ax+bx)/2, (ay+by)/2)
// A non-finite flux lands in the load like a non-finite
// source, so the same refusal answers it.
if math.IsNaN(fv) || math.IsInf(fv, 0) {
return nil, base.Errf("%s: the Neumann flux returned the non-finite value %g on edge [%d, %d]", name, fv, a, b)
}
flux := length / 2 * fv
load[a] += flux
load[b] += flux
}
}
// Dirichlet lifting: the known boundary values move to the right
// hand side, then their rows and columns leave the system as
// unit rows.
for p, i := range rows {
if j := cols[p]; dirichletMark[j] {
load[i] -= entries[p] * dirichletVal[j]
}
}
keptRows := make([]int64, 0, len(rows))
keptCols := make([]int64, 0, len(rows))
keptVals := make([]float64, 0, len(rows))
for p := range rows {
i, j := rows[p], cols[p]
if dirichletMark[i] || dirichletMark[j] {
continue
}
keptRows = append(keptRows, int64(i))
keptCols = append(keptCols, int64(j))
keptVals = append(keptVals, entries[p])
}
for _, d := range dirichletNodes {
keptRows = append(keptRows, int64(d))
keptCols = append(keptCols, int64(d))
keptVals = append(keptVals, 1)
load[d] = dirichletVal[d]
}
indices, err := core.FromInts(pairInts(keptRows, keptCols), len(keptVals), 2)
if err != nil {
return nil, base.Errf("%s: %w", name, err)
}
coo, err := core.NewSparseCOO(indices, fromSlice(keptVals, len(keptVals)), []int{n, n})
if err != nil {
return nil, base.Errf("%s: %w", name, err)
}
order := opts.Ordering
factor, err := linalg.NewSparseCholesky(coo, order)
if err != nil {
return nil, base.Errf("%s: %w", name, err)
}
rhs := core.New(core.Float, []int{n}...)
copy(rhs.RawFloats(), load)
return factor.Solve(rhs)
}
// pairInts interleaves row and column indices into the index table
// the sparse coordinate format expects.
func pairInts(rows, cols []int64) []int64 {
out := make([]int64, 2*len(rows))
for p := range rows {
out[2*p] = rows[p]
out[2*p+1] = cols[p]
}
return out
}