Files
2026-09-28 09:34:28 +02:00

389 lines
15 KiB
Go
Raw Permalink 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"
"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))
for e, n := range count {
if n == 1 {
sets = append(sets, e)
}
}
// 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])
}
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
}