Files
tensor/integrate/fem3d.go
T

553 lines
22 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 (
"fmt"
"math"
"slices"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
linalg "sourcedock.dev/petrbalvin/tensor/linalg"
)
// The finite element groundwork for second-order problems in three
// dimensions, the volumetric sibling of the triangular surface in
// fem2d.go: piecewise-linear (P1) elements on a conforming
// tetrahedral mesh, the stiffness matrix assembled per tetrahedron
// from the gradient-of-basis formula over the element's edge vectors,
// the load integrated per element with a collapsed Gauss rule,
// Dirichlet values eliminated by lifting, Neumann fluxes integrated
// on prescribed boundary faces, and the reduced system handed to the
// same sparse Cholesky factorisation the two-dimensional path uses.
// TetraMesh3D carries a conforming tetrahedral mesh: vertex
// coordinates as x,y,z triples and tetrahedra as quadruples of vertex
// indices in positive orientation, meaning the signed volume
// (b−a)·((c−a)×(d−a)) of every stored tetrahedron is positive. A
// tetrahedron with zero volume or negative orientation does matter
// and is refused at construction.
type TetraMesh3D struct {
// Vertices holds x,y,z for every vertex: three entries per vertex.
Vertices []float64
// Tetrahedra holds four vertex indices per tetrahedron.
Tetrahedra []int64
}
// NewTetraMesh3D builds a mesh from a vertex table with three columns
// and a tetrahedron table with four columns of vertex indices.
// Indices must lie in range, every coordinate must be finite, and a
// degenerate (zero-volume) or inverted (negative-orientation)
// tetrahedron is an error naming the element and its vertices: its
// stiffness contribution is undefined.
func NewTetraMesh3D(vertices *core.Array, tetrahedra *core.Array) (*TetraMesh3D, error) {
const name = "NewTetraMesh3D"
if vertices.Dtype() == core.Complex || tetrahedra.Dtype() == core.Complex {
return nil, base.Errf("%s: complex mesh data is not supported", name)
}
if vertices.NDim() != 2 || vertices.Shape()[1] != 3 {
return nil, base.Errf("%s: the vertex table must be rank 2 with three columns, got shape %s",
name, base.ShapeText(vertices.Shape()))
}
if tetrahedra.Dtype() != core.Int {
return nil, base.Errf("%s: the tetrahedron table must hold integers, got %s", name, tetrahedra.Dtype())
}
if tetrahedra.NDim() != 2 || tetrahedra.Shape()[1] != 4 {
return nil, base.Errf("%s: the tetrahedron table must be rank 2 with four columns, got shape %s",
name, base.ShapeText(tetrahedra.Shape()))
}
n := vertices.Shape()[0]
m := tetrahedra.Shape()[0]
if n < 4 {
return nil, base.Errf("%s: a mesh needs at least four vertices, got %d", name, n)
}
if m == 0 {
// An empty tetrahedron 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 tetrahedron table must not be empty", name)
}
mesh := &TetraMesh3D{Vertices: make([]float64, 3*n), Tetrahedra: make([]int64, 4*m)}
for i := range 3 * 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 q := range 4 * m {
idx := tetrahedra.RawInts()[q]
if idx < 0 || idx >= int64(n) {
return nil, base.Errf("%s: tetrahedron vertex index %d out of range for %d vertices", name, idx, n)
}
mesh.Tetrahedra[q] = idx
}
// Orientation and volume are checked where the caller can name the
// tetrahedron and its vertices, not mid-assembly. Both messages
// carry the coordinates, so a mis-ordered table can be fixed
// without reopening a mesh debugger.
for t := range m {
a, b, c, d := int(mesh.Tetrahedra[4*t]), int(mesh.Tetrahedra[4*t+1]), int(mesh.Tetrahedra[4*t+2]), int(mesh.Tetrahedra[4*t+3])
ax, ay, az := mesh.Vertices[3*a], mesh.Vertices[3*a+1], mesh.Vertices[3*a+2]
bx, by, bz := mesh.Vertices[3*b], mesh.Vertices[3*b+1], mesh.Vertices[3*b+2]
cx, cy, cz := mesh.Vertices[3*c], mesh.Vertices[3*c+1], mesh.Vertices[3*c+2]
dx, dy, dz := mesh.Vertices[3*d], mesh.Vertices[3*d+1], mesh.Vertices[3*d+2]
signed6 := signedTetraVolume(ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz)
at := func(v int) string {
return fmt.Sprintf("(%g, %g, %g)", mesh.Vertices[3*v], mesh.Vertices[3*v+1], mesh.Vertices[3*v+2])
}
verts := fmt.Sprintf("vertices %d %s, %d %s, %d %s, %d %s", a, at(a), b, at(b), c, at(c), d, at(d))
if signed6 == 0 {
return nil, base.Errf("%s: tetrahedron %d is degenerate (zero volume), %s", name, t, verts)
}
if signed6 < 0 {
return nil, base.Errf("%s: tetrahedron %d is inverted (signed volume %g), %s", name, t, signed6/6, verts)
}
}
return mesh, nil
}
// Vertices3 returns the vertex count.
func (m *TetraMesh3D) Vertices3() int { return len(m.Vertices) / 3 }
// Tetrahedra4 returns the tetrahedron count.
func (m *TetraMesh3D) Tetrahedra4() int { return len(m.Tetrahedra) / 4 }
// signedTetraVolume returns six times the signed volume of the
// tetrahedron (a, b, c, d): positive for the orientation the mesh
// stores, negative when the last two vertices are swapped, zero when
// the four points are coplanar.
func signedTetraVolume(ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz float64) float64 {
u := [3]float64{bx - ax, by - ay, bz - az}
v := [3]float64{cx - ax, cy - ay, cz - az}
w := [3]float64{dx - ax, dy - ay, dz - az}
cross := [3]float64{v[1]*w[2] - v[2]*w[1], v[2]*w[0] - v[0]*w[2], v[0]*w[1] - v[1]*w[0]}
return u[0]*cross[0] + u[1]*cross[1] + u[2]*cross[2]
}
// BoundaryFaces returns the mesh's boundary faces as flat triples of
// vertex indices: a face belongs to the boundary when exactly one
// tetrahedron carries it. The triples are sorted lexicographically,
// so the result is a pure function of the mesh.
func (m *TetraMesh3D) BoundaryFaces() []int {
count := make(map[[3]int]int, len(m.Tetrahedra))
key := func(a, b, c int) [3]int {
if a > b {
a, b = b, a
}
if b > c {
b, c = c, b
}
if a > b {
a, b = b, a
}
return [3]int{a, b, c}
}
for t := 0; t < m.Tetrahedra4(); t++ {
a, b, c, d := int(m.Tetrahedra[4*t]), int(m.Tetrahedra[4*t+1]), int(m.Tetrahedra[4*t+2]), int(m.Tetrahedra[4*t+3])
count[key(a, b, c)]++
count[key(a, b, d)]++
count[key(a, c, d)]++
count[key(b, c, d)]++
}
sets := make([][3]int, 0, len(count))
for f, n := range count {
if n == 1 {
sets = append(sets, f)
}
}
slices.SortFunc(sets, func(x, y [3]int) int {
for k := range 3 {
if x[k] != y[k] {
return x[k] - y[k]
}
}
return 0
})
faces := make([]int, 0, 3*len(sets))
for _, f := range sets {
faces = append(faces, f[0], f[1], f[2])
}
return faces
}
// BoxTetraMesh3D builds the structured tetrahedralisation of the
// axis-aligned box [x0, x0+width] × [y0, y0+height] × [z0, z0+depth]
// with m by n by p cells, six tetrahedra per cell (the Kuhn
// subdivision along the cell diagonal, oriented positively). m, n and
// p must all be positive. The subdivision is conforming across cell
// faces, which makes the mesher the first port of call for tests and
// for boxes in general.
func BoxTetraMesh3D(x0, y0, z0, width, height, depth float64, m, n, p int) (*TetraMesh3D, error) {
const name = "BoxTetraMesh3D"
if m <= 0 || n <= 0 || p <= 0 {
return nil, base.Errf("%s: the cell counts must be positive, got %d by %d by %d", name, m, n, p)
}
// The same guard the triangle mesher applies: 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) || !(depth > 0) ||
math.IsInf(width, 0) || math.IsInf(height, 0) || math.IsInf(depth, 0) ||
math.IsNaN(x0) || math.IsInf(x0, 0) || math.IsNaN(y0) || math.IsInf(y0, 0) || math.IsNaN(z0) || math.IsInf(z0, 0) {
return nil, base.Errf("%s: the extents must be finite and positive and the origin finite, got origin (%g, %g, %g), extents %g by %g by %g",
name, x0, y0, z0, width, height, depth)
}
vertices := make([]float64, 3*(m+1)*(n+1)*(p+1))
for k := range p + 1 {
for j := range n + 1 {
for i := range m + 1 {
v := 3 * ((k*(n+1)+j)*(m+1) + i)
vertices[v] = x0 + width*float64(i)/float64(m)
vertices[v+1] = y0 + height*float64(j)/float64(n)
vertices[v+2] = z0 + depth*float64(k)/float64(p)
}
}
}
at := func(i, j, k int) int64 { return int64((k*(n+1)+j)*(m+1) + i) }
// The six Kuhn paths from one cell corner to the opposite one,
// given as axis orders. An odd permutation reaches the far corner
// with negative orientation, so its last two vertices swap.
perms := [6][3]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}}
tetrahedra := make([]int64, 0, 6*m*n*p)
for k := range p {
for j := range n {
for i := range m {
for _, pm := range perms {
// The path walks from the cell corner to the far
// corner, each vertex one axis-step beyond the
// previous one.
ox := [4]int{i, i, i, i}
oy := [4]int{j, j, j, j}
oz := [4]int{k, k, k, k}
for s := range 3 {
ox[s+1], oy[s+1], oz[s+1] = ox[s], oy[s], oz[s]
switch pm[s] {
case 0:
ox[s+1]++
case 1:
oy[s+1]++
default:
oz[s+1]++
}
}
odd := 0
for s1 := range 3 {
for s2 := s1 + 1; s2 < 3; s2++ {
if pm[s1] > pm[s2] {
odd++
}
}
}
v := [4]int64{at(ox[0], oy[0], oz[0]), at(ox[1], oy[1], oz[1]), at(ox[2], oy[2], oz[2]), at(ox[3], oy[3], oz[3])}
if odd%2 == 1 {
v[2], v[3] = v[3], v[2]
}
tetrahedra = append(tetrahedra, v[0], v[1], v[2], v[3])
}
}
}
}
return &TetraMesh3D{Vertices: vertices, Tetrahedra: tetrahedra}, nil
}
// tetraGradients returns the gradients of the four P1 basis functions
// on the tetrahedron (a, b, c, d) and its volume. The gradients are
// the columns of the inverse of the edge matrix whose rows are the
// vectors from d to a, b and c, which is the standard
// gradient-of-basis formula over the element's edge vectors.
func tetraGradients(ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz float64) (g [4][3]float64, volume float64) {
// Rows of the edge matrix relative to d.
r0 := [3]float64{ax - dx, ay - dy, az - dz}
r1 := [3]float64{bx - dx, by - dy, bz - dz}
r2 := [3]float64{cx - dx, cy - dy, cz - dz}
// Cofactors of the edge matrix; the inverse is their transpose
// over the determinant, so column j of the inverse is row j of the
// cofactor matrix over det.
c00 := r1[1]*r2[2] - r1[2]*r2[1]
c01 := -(r1[0]*r2[2] - r1[2]*r2[0])
c02 := r1[0]*r2[1] - r1[1]*r2[0]
c10 := -(r0[1]*r2[2] - r0[2]*r2[1])
c11 := r0[0]*r2[2] - r0[2]*r2[0]
c12 := -(r0[0]*r2[1] - r0[1]*r2[0])
c20 := r0[1]*r1[2] - r0[2]*r1[1]
c21 := -(r0[0]*r1[2] - r0[2]*r1[0])
c22 := r0[0]*r1[1] - r0[1]*r1[0]
det := r0[0]*c00 + r0[1]*c01 + r0[2]*c02
g[0] = [3]float64{c00 / det, c01 / det, c02 / det}
g[1] = [3]float64{c10 / det, c11 / det, c12 / det}
g[2] = [3]float64{c20 / det, c21 / det, c22 / det}
for i := range 3 {
for k := range 3 {
g[3][k] -= g[i][k]
}
}
volume = math.Abs(signedTetraVolume(ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz)) / 6
return g, volume
}
// tetraStiffness returns the P1 stiffness matrix of one tetrahedron:
// K[i][j] = κ·V·(∇λᵢ·∇λⱼ), the gradient-of-basis formula integrated
// over the element, where the gradients are constant on a linear
// element.
func tetraStiffness(ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz, kappa float64) [4][4]float64 {
g, volume := tetraGradients(ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz)
var k [4][4]float64
for i := range 4 {
for j := range 4 {
k[i][j] = kappa * volume * (g[i][0]*g[j][0] + g[i][1]*g[j][1] + g[i][2]*g[j][2])
}
}
return k
}
// FEMPoisson3DOptions carries the data SolvePoissonFEM3D needs beside
// the mesh and the source: the conductivity, the prescribed boundary
// values, and the optional flux boundary.
type FEMPoisson3DOptions 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 tetrahedron centroids and must be positive
// there for every element; a non-positive value names the element.
KappaFunc func(x, y, z 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
// NeumannFaces lists boundary faces as flat triples of vertex
// indices and NeumannFlux gives the flux κ∂u/∂n along each face's
// outward normal: each face's integral is built from the degree-2
// edge-midpoint rule, a third of area·flux at each edge midpoint
// shared by that edge's two vertices. A nil flux means zero.
NeumannFaces []int
NeumannFlux func(x, y, z 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
}
// SolvePoissonFEM3D solves −∇·(κ∇u) = f on the tetrahedral mesh with
// piecewise-linear elements: the stiffness matrix is assembled per
// tetrahedron (the conductivity evaluated at the centroids when it
// varies), the load is integrated per tetrahedron with the 3×3×3
// collapsed Gauss rule (exact through degree 5; the centroid lump
// does not hold the O(h²) rate on the structured Kuhn mesh), Neumann
// fluxes are integrated on their boundary faces with the degree-2
// edge-midpoint rule, and Dirichlet values are eliminated by lifting.
// f may be nil for the homogeneous equation. The error contract
// mirrors SolvePoissonFEM2D.
func SolvePoissonFEM3D(mesh *TetraMesh3D, f func(x, y, z float64) float64, opts FEMPoisson3DOptions) (*core.Array, error) {
const name = "SolvePoissonFEM3D"
if mesh == nil {
return nil, base.Errf("%s: the mesh must not be nil", name)
}
// The same conductivity gate as the two-dimensional solve: with
// KappaFunc nil the constant 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.
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)
}
n := mesh.Vertices3()
// The Dirichlet nodes as a dense marker with their prescribed
// values, exactly as the two-dimensional solve carries them: the
// lifting and the unit rows 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.NeumannFaces)%3 != 0 {
return nil, base.Errf("%s: %d Neumann face indices, want triples", name, len(opts.NeumannFaces))
}
for p := 0; p < len(opts.NeumannFaces); p += 3 {
for _, v := range opts.NeumannFaces[p : p+3] {
if v < 0 || v >= n {
return nil, base.Errf("%s: Neumann face [%d %d %d] holds the out-of-range vertex %d",
name, opts.NeumannFaces[p], opts.NeumannFaces[p+1], opts.NeumannFaces[p+2], v)
}
}
if opts.NeumannFaces[p] == opts.NeumannFaces[p+1] ||
opts.NeumannFaces[p] == opts.NeumannFaces[p+2] ||
opts.NeumannFaces[p+1] == opts.NeumannFaces[p+2] {
return nil, base.Errf("%s: Neumann face [%d %d %d] repeats a vertex",
name, opts.NeumannFaces[p], opts.NeumannFaces[p+1], opts.NeumannFaces[p+2])
}
}
// Assembly: sixteen entries per tetrahedron, symmetric by
// construction, with the conductivity evaluated at the centroid
// when it varies.
entries := make([]float64, 0, 16*mesh.Tetrahedra4())
rows := make([]int, 0, 16*mesh.Tetrahedra4())
cols := make([]int, 0, 16*mesh.Tetrahedra4())
load := make([]float64, n)
// The collapsed Gauss rule's abscissae and weights are constants of
// the scheme: built once here, not per tetrahedron.
gl := [3]float64{(1 - math.Sqrt(3.0/5)) / 2, 0.5, (1 + math.Sqrt(3.0/5)) / 2}
gw := [3]float64{5.0 / 18, 4.0 / 9, 5.0 / 18}
for t := 0; t < mesh.Tetrahedra4(); t++ {
a, b, c, d := int(mesh.Tetrahedra[4*t]), int(mesh.Tetrahedra[4*t+1]), int(mesh.Tetrahedra[4*t+2]), int(mesh.Tetrahedra[4*t+3])
ax, ay, az := mesh.Vertices[3*a], mesh.Vertices[3*a+1], mesh.Vertices[3*a+2]
bx, by, bz := mesh.Vertices[3*b], mesh.Vertices[3*b+1], mesh.Vertices[3*b+2]
cx, cy, cz := mesh.Vertices[3*c], mesh.Vertices[3*c+1], mesh.Vertices[3*c+2]
dx, dy, dz := mesh.Vertices[3*d], mesh.Vertices[3*d+1], mesh.Vertices[3*d+2]
volume := math.Abs(signedTetraVolume(ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz)) / 6
if volume == 0 {
return nil, base.Errf("%s: tetrahedron %d is degenerate (zero volume)", name, t)
}
kappa := opts.Kappa
if opts.KappaFunc != nil {
kappa = opts.KappaFunc((ax+bx+cx+dx)/4, (ay+by+cy+dy)/4, (az+bz+cz+dz)/4)
if !(kappa > 0) || math.IsNaN(kappa) || math.IsInf(kappa, 0) {
return nil, base.Errf("%s: the conductivity at tetrahedron %d is %g, want positive", name, t, kappa)
}
}
k := tetraStiffness(ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz, kappa)
nodes := [4]int{a, b, c, d}
for i := range 4 {
for j := range 4 {
rows = append(rows, nodes[i])
cols = append(cols, nodes[j])
entries = append(entries, k[i][j])
}
}
// The load on this element, integrated with the 3×3×3
// collapsed Gauss rule: λ weights follow the Duffy collapse
// toward vertex a, and the Jacobian of the map from the unit
// cube is (1−r)²(1−s)·6V.
if f != nil {
for ir := range 3 {
for is := range 3 {
for it := range 3 {
r, s, t := gl[ir], gl[is], gl[it]
la := (1 - r) * (1 - s) * (1 - t)
lb := (1 - r) * (1 - s) * t
lc := (1 - r) * s
ld := r
x := la*ax + lb*bx + lc*cx + ld*dx
y := la*ay + lb*by + lc*cy + ld*dy
z := la*az + lb*bz + lc*cz + ld*dz
w := gw[ir] * gw[is] * gw[it] * (1 - r) * (1 - r) * (1 - s) * 6 * volume
fv := f(x, y, z)
// 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 tetrahedron %d", name, fv, t)
}
load[a] += w * fv * la
load[b] += w * fv * lb
load[c] += w * fv * lc
load[d] += w * fv * ld
}
}
}
}
}
// Neumann fluxes: the degree-2 edge-midpoint rule on every listed
// face, a third of area·flux at each edge midpoint into that
// edge's two vertices.
if len(opts.NeumannFaces) > 0 && opts.NeumannFlux != nil {
for p := 0; p < len(opts.NeumannFaces); p += 3 {
a, b, c := opts.NeumannFaces[p], opts.NeumannFaces[p+1], opts.NeumannFaces[p+2]
ax, ay, az := mesh.Vertices[3*a], mesh.Vertices[3*a+1], mesh.Vertices[3*a+2]
bx, by, bz := mesh.Vertices[3*b], mesh.Vertices[3*b+1], mesh.Vertices[3*b+2]
cx, cy, cz := mesh.Vertices[3*c], mesh.Vertices[3*c+1], mesh.Vertices[3*c+2]
u := [3]float64{bx - ax, by - ay, bz - az}
v := [3]float64{cx - ax, cy - ay, cz - az}
cross := [3]float64{u[1]*v[2] - u[2]*v[1], u[2]*v[0] - u[0]*v[2], u[0]*v[1] - u[1]*v[0]}
area := math.Sqrt(cross[0]*cross[0]+cross[1]*cross[1]+cross[2]*cross[2]) / 2
w := area / 3
// A non-finite flux lands in the load like a non-finite
// source, so the same refusal answers it, naming the face.
fab := w * opts.NeumannFlux((ax+bx)/2, (ay+by)/2, (az+bz)/2)
fbc := w * opts.NeumannFlux((bx+cx)/2, (by+cy)/2, (bz+cz)/2)
fca := w * opts.NeumannFlux((cx+ax)/2, (cy+ay)/2, (cz+az)/2)
for _, fv := range []float64{fab, fbc, fca} {
if math.IsNaN(fv) || math.IsInf(fv, 0) {
return nil, base.Errf("%s: the Neumann flux returned a non-finite value on face [%d %d %d]", name, a, b, c)
}
}
load[a] += fab/2 + fca/2
load[b] += fab/2 + fbc/2
load[c] += fbc/2 + fca/2
}
}
// Dirichlet lifting: the known boundary values move to the right
// hand side, then their rows and columns leave the system as unit
// rows, exactly as in the two-dimensional solve.
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)
}
factor, err := linalg.NewSparseCholesky(coo, opts.Ordering)
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)
}