Files
tensor/integrate/pde2d.go
T

424 lines
16 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"
"sync"
"sourcedock.dev/petrbalvin/tensor/internal/engine"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// Two-dimensional evolution equations on the rectangle, the
// higher-rank siblings of IntegrateHeat1D and IntegrateWave1D. The
// grid is the rank-2 shape of the initial state: row r samples
// y = r·dy, column c samples x = c·dx, the boundary ring is held
// fixed, and the interior carries the dynamics.
// pde2dValidate checks the shared rectangle arguments and returns the
// grid shape.
func pde2dValidate(name string, u0 *core.Array, dx, dy, tFinal, dt float64, samples int) (rows, cols int, err error) {
if u0.NDim() != 2 {
return 0, 0, base.Errf("%s: the initial state must be rank 2, got shape %s", name, base.ShapeText(u0.Shape()))
}
if u0.Dtype() == core.Complex {
return 0, 0, base.Errf("%s: complex initial states are not supported", name)
}
rows, cols = u0.Shape()[0], u0.Shape()[1]
if rows < 3 || cols < 3 {
return 0, 0, base.Errf("%s: the grid must be at least 3×3 to hold interior points, got %d×%d", name, rows, cols)
}
if !(dx > 0) || !(dy > 0) {
return 0, 0, base.Errf("%s: the spacings must be positive, got %g and %g", name, dx, dy)
}
if !(tFinal > 0) || !(dt > 0) {
return 0, 0, base.Errf("%s: tFinal and dt must be positive, got %g and %g", name, tFinal, dt)
}
// As in the 1-D validation: a dt this far below tFinal cannot be
// honoured, and the wrapped step count would disable the bound
// silently.
if tFinal/dt > 1e12 {
return 0, 0, base.Errf("%s: dt = %g asks for more than 1e12 steps over %g", name, dt, tFinal)
}
if samples < 2 {
return 0, 0, base.Errf("%s: at least two samples are needed, got %d", name, samples)
}
// A non-finite entry flows through the stencils and the solvers'
// zero-pivot guards compare false against NaN, so it is refused
// up front.
for i := range u0.Len() {
if v := u0.FloatAt(i); math.IsNaN(v) || math.IsInf(v, 0) {
return 0, 0, base.Errf("%s: the initial state holds the non-finite value %g at %d", name, v, i)
}
}
return rows, cols, nil
}
// fromSlice wraps a float slice of known length as an array.
func fromSlice(vals []float64, n int) *core.Array {
out := core.New(core.Float, n)
copy(out.RawFloats(), vals[:n])
return out
}
// IntegrateHeat2D evolves u_t = κ·Δu on the rectangle by
// Peaceman-Rachford alternating direction implicit steps: one
// half-step implicit in x against an explicit y neighbour sum, one
// implicit in y against an explicit x neighbour sum, each row and
// column a tridiagonal solve through SolveTridiagonal. The scheme is
// second order in space and time and unconditionally stable, so no
// step-size refusal stands between the caller and a coarse first
// look. The boundary ring is held at the four constant edge values.
// The return is a (samples × rows × cols) array: the initial state,
// then the state after each stored interval, the final state forced
// into the last sample.
func IntegrateHeat2D(u0 *core.Array, kappa, dx, dy, tFinal, dt float64, samples int,
boundBottom, boundTop, boundLeft, boundRight float64) (*core.Array, error) {
const name = "IntegrateHeat2D"
rows, cols, err := pde2dValidate(name, u0, dx, dy, tFinal, dt, samples)
if err != nil {
return nil, err
}
if !(kappa > 0) || math.IsInf(kappa, 0) {
return nil, base.Errf("%s: the diffusivity must be positive, got %g", name, kappa)
}
// The four boundary constants enter the explicit neighbour sums
// every step: a non-finite one would flow through the stencils and
// the solves and publish an all-NaN history with no error.
if math.IsNaN(boundBottom) || math.IsInf(boundBottom, 0) ||
math.IsNaN(boundTop) || math.IsInf(boundTop, 0) ||
math.IsNaN(boundLeft) || math.IsInf(boundLeft, 0) ||
math.IsNaN(boundRight) || math.IsInf(boundRight, 0) {
return nil, base.Errf("%s: the boundary values must be finite, got bottom %g, top %g, left %g, right %g",
name, boundBottom, boundTop, boundLeft, boundRight)
}
steps, h := pdeSchedule(tFinal, dt, samples)
rx := kappa * h / (2 * dx * dx) // the implicit half-step weight in x
ry := kappa * h / (2 * dy * dy) // ...and in y
u := make([]float64, rows*cols)
copy(u, denseFloats(u0))
every := steps / (samples - 1)
out := core.New(core.Float, samples, rows, cols)
into := out.RawFloats()
// Sample 0 is the initial state exactly, before any ring
// enforcement touches the working state.
copy(into, u)
written := 1
// The working state's horizontal ring rows carry the boundary
// constants from here on, so the stencils read their up and down
// neighbours straight out of the payload: a ring slot holds exactly
// the value the boundary test it replaces would have substituted.
// The step loop below rewrites both rows after every half-step,
// so the invariant holds for every step, the first included.
for c := range cols {
u[c] = boundBottom
u[(rows-1)*cols+c] = boundTop
}
star := make([]float64, rows*cols)
var stepErr error
var errMu sync.Mutex
// The implicit diagonals are constants of the scheme, one set per
// orientation, built once with exactly the values the solves read.
lowerX := make([]float64, cols-3)
upperX := make([]float64, cols-3)
diagX := make([]float64, cols-2)
for i := range lowerX {
lowerX[i] = -rx
upperX[i] = -rx
}
for i := range diagX {
diagX[i] = 1 + 2*rx
}
lowerY := make([]float64, rows-3)
upperY := make([]float64, rows-3)
diagY := make([]float64, rows-2)
for i := range lowerY {
lowerY[i] = -ry
upperY[i] = -ry
}
for i := range diagY {
diagY[i] = 1 + 2*ry
}
// Per-solve line scratch, one set per pdeStepFloor lines of each
// orientation. engine.ParallelMin invokes a sweep closure once per
// chunk; in parallel the chunk starts differ by at least
// pdeStepFloor, so two concurrent chunks never share a set, and a
// sweep the engine runs inline reuses set zero serially.
triX := make([]triScratch, (rows-2-1)/pdeStepFloor+1)
triY := make([]triScratch, (cols-2-1)/pdeStepFloor+1)
for i := range triX {
triSized(&triX[i], cols-2, cols)
}
for i := range triY {
triSized(&triY[i], rows-2, rows)
}
for s := 1; s <= steps; s++ {
// Half-step one: implicit in x along every interior row. Rows are
// independent (a row's solve reads its own and its neighbours'
// values and writes the star row), so the sweep is partitioned
// over them; each chunk carries its own right-hand side and
// solve scratch, and the arithmetic of a row is untouched.
engine.ParallelMin(rows-2, pdeStepFloor, func(rs, re int) {
tri := &triX[rs/pdeStepFloor]
rhs := tri.rhs
for r := rs + 1; r < re+1; r++ {
// The row's own values and its two neighbours are slices
// of the payload, so the stencil reads them by offset
// instead of rebuilding the flat index per element. The
// horizontal ring rows hold the boundary constants, so
// the neighbours need no edge test.
row := u[r*cols : (r+1)*cols]
up := u[(r+1)*cols : (r+2)*cols]
down := u[(r-1)*cols : r*cols]
for c := range cols {
rhs[c] = row[c] + ry*(up[c]-2*row[c]+down[c])
}
// Boundary neighbours of the implicit solve: the row's
// own left and right ring values enter the right side.
rhs[1] += rx * boundLeft
rhs[cols-2] += rx * boundRight
// The elimination writes the row's interior straight
// into the star state; the ring values follow.
dst := star[r*cols+1 : r*cols+cols-1]
if serr := base.TriSolve(dst, tri.cp, tri.dp, lowerX, diagX, upperX, rhs[1:cols-1]); serr != nil {
errMu.Lock()
if stepErr == nil {
stepErr = base.Errf("%s: %w", name, serr)
}
errMu.Unlock()
return
}
star[r*cols] = boundLeft
star[r*cols+cols-1] = boundRight
}
})
if stepErr != nil {
return nil, stepErr
}
for c := range cols {
star[c] = boundBottom
star[(rows-1)*cols+c] = boundTop
}
// Half-step two: implicit in y down every interior column,
// with the explicit x neighbours from the star state. Columns are
// independent the same way the rows were, and the walk takes them
// two at a time: the star state's vertical ring columns hold the
// boundary constants, so both lanes read their neighbours
// straight out of the payload, and a pair shares its loads, so
// each line of the state is fetched once per pair instead of
// three times across three column passes.
engine.ParallelMin(cols-2, pdeStepFloor, func(cs, ce int) {
tri := &triY[cs/pdeStepFloor]
rhs := tri.rhs
aux := tri.aux
dst := tri.dst
for c := cs + 1; c < ce+1; c += 2 {
if c+1 < ce+1 {
// Columns c and c+1: one pass over the rows builds
// both right sides, the shared neighbour loads in
// registers.
for r := range rows {
base := r * cols
l := star[base+c-1]
wm := star[base+c]
e := star[base+c+1]
rhs[r] = wm + rx*(e-2*wm+l)
aux[r] = e + rx*(star[base+c+2]-2*e+wm)
}
rhs[1] += ry * boundBottom
rhs[rows-2] += ry * boundTop
if serr := base.TriSolve(dst, tri.cp, tri.dp, lowerY, diagY, upperY, rhs[1:rows-1]); serr != nil {
errMu.Lock()
if stepErr == nil {
stepErr = base.Errf("%s: %w", name, serr)
}
errMu.Unlock()
return
}
u[c] = boundBottom
u[(rows-1)*cols+c] = boundTop
// The column's interior comes straight out of the
// solve's payload: the values are the ones the
// accessor read.
for r := 1; r < rows-1; r++ {
u[r*cols+c] = dst[r-1]
}
aux[1] += ry * boundBottom
aux[rows-2] += ry * boundTop
if serr := base.TriSolve(dst, tri.cp, tri.dp, lowerY, diagY, upperY, aux[1:rows-1]); serr != nil {
errMu.Lock()
if stepErr == nil {
stepErr = base.Errf("%s: %w", name, serr)
}
errMu.Unlock()
return
}
u[c+1] = boundBottom
u[(rows-1)*cols+c+1] = boundTop
for r := 1; r < rows-1; r++ {
u[r*cols+c+1] = dst[r-1]
}
continue
}
// The odd tail column, when the chunk ends on one.
for r := range rows {
base := r * cols
wm := star[base+c]
rhs[r] = wm + rx*(star[base+c+1]-2*wm+star[base+c-1])
}
rhs[1] += ry * boundBottom
rhs[rows-2] += ry * boundTop
if serr := base.TriSolve(dst, tri.cp, tri.dp, lowerY, diagY, upperY, rhs[1:rows-1]); serr != nil {
errMu.Lock()
if stepErr == nil {
stepErr = base.Errf("%s: %w", name, serr)
}
errMu.Unlock()
return
}
u[c] = boundBottom
u[(rows-1)*cols+c] = boundTop
for r := 1; r < rows-1; r++ {
u[r*cols+c] = dst[r-1]
}
}
})
if stepErr != nil {
return nil, stepErr
}
for r := range rows {
u[r*cols] = boundLeft
u[r*cols+cols-1] = boundRight
}
if s%every == 0 && written < samples {
copy(into[written*rows*cols:(written+1)*rows*cols], u)
written++
}
}
copy(into[(samples-1)*rows*cols:], u)
return out, nil
}
// IntegrateWave2D evolves u_tt = c²·Δu on the rectangle with the
// boundary ring held at zero, by the explicit central-difference
// stencil from the velocity Verlet family the 1-D wave solver uses:
// second order in space and time, with the CFL budget
// c·dt·sqrt(1/dx² + 1/dy²) ≤ 1 enforced as an error, because the
// explicit stencil has no honest answer past it. The return contract
// mirrors IntegrateHeat2D.
func IntegrateWave2D(u0, v0 *core.Array, c, dx, dy, tFinal, dt float64, samples int) (*core.Array, error) {
const name = "IntegrateWave2D"
rows, cols, err := pde2dValidate(name, u0, dx, dy, tFinal, dt, samples)
if err != nil {
return nil, err
}
if v0.NDim() != 2 || v0.Shape()[0] != rows || v0.Shape()[1] != cols {
return nil, base.Errf("%s: the velocity must be rank 2 on the same grid, got shape %s", name, base.ShapeText(v0.Shape()))
}
if v0.Dtype() == core.Complex {
return nil, base.Errf("%s: complex velocities are not supported", name)
}
// As for u0: a non-finite velocity enters the Taylor start and
// poisons the three-level stencil without an error.
for i := range v0.Len() {
if v := v0.FloatAt(i); math.IsNaN(v) || math.IsInf(v, 0) {
return nil, base.Errf("%s: the velocity holds the non-finite value %g at %d", name, v, i)
}
}
if !(c > 0) {
return nil, base.Errf("%s: the wave speed must be positive, got %g", name, c)
}
cfl := c * dt * math.Sqrt(1/(dx*dx)+1/(dy*dy))
if cfl > 1 {
return nil, base.Errf("%s: the CFL number %g exceeds 1 (c = %g, dt = %g, dx = %g, dy = %g); the explicit stencil is unstable there",
name, cfl, c, dt, dx, dy)
}
steps, h := pdeSchedule(tFinal, dt, samples)
lapW := (c * h) * (c * h)
// The stencil's spacings are constants of the grid; squaring them
// once per step is the same product the element loop evaluated.
dx2 := dx * dx
dy2 := dy * dy
prev := make([]float64, rows*cols) // u^{s-1}
cur := make([]float64, rows*cols) // u^{s}
next := make([]float64, rows*cols) // u^{s+1}, the buffer being written
copy(cur, denseFloats(u0))
every := steps / (samples - 1)
out := core.New(core.Float, samples, rows, cols)
into := out.RawFloats()
// Slot 0 is the initial state at t = 0; slot j is the state after
// j·every steps, at the published time j·tFinal/(samples-1), exactly
// like IntegrateHeat2D.
copy(into, cur)
// From there on the boundary ring is held at zero, so clear it in the
// working buffer the initial state came in (the other two are born
// zeroed): otherwise a recycled buffer would carry the caller's ring
// back into the stencil every third step.
for r := range rows {
cur[r*cols], cur[r*cols+cols-1] = 0, 0
}
for c := range cols {
cur[c], cur[(rows-1)*cols+c] = 0, 0
}
written := 1
// The three buffers cycle through (prev, cur, next), so every stencil
// reads u^{s-1} and u^s while writing u^{s+1} into a third buffer: no
// point is ever overwritten before its neighbours have read it. The
// first iteration is the Taylor start
// u¹ = u⁰ + h·v⁰ + h²c²/2·Δu⁰, second order, so the three-level
// stencil starts honest, and it reads the untouched u⁰ (the initial
// velocity and the Laplacian of u⁰), not a half-updated state.
for s := 1; s <= steps; s++ {
first := s == 1
for r := 1; r < rows-1; r++ {
// The row's neighbours are the slices above and below it, so
// the stencil reads them by offset instead of rebuilding the
// flat index per element. The terms keep their order.
mid := cur[r*cols : (r+1)*cols]
up := cur[(r+1)*cols : (r+2)*cols]
down := cur[(r-1)*cols : r*cols]
if first {
// The Taylor start reads the untouched u⁰ and its
// initial velocity, not a half-updated state.
for cc := 1; cc < cols-1; cc++ {
i := r*cols + cc
lap := (down[cc]-2*mid[cc]+up[cc])/dy2 +
(mid[cc-1]-2*mid[cc]+mid[cc+1])/dx2
next[i] = mid[cc] + h*v0.FloatAt(i) + 0.5*lapW*lap
}
continue
}
for cc := 1; cc < cols-1; cc++ {
i := r*cols + cc
lap := (down[cc]-2*mid[cc]+up[cc])/dy2 +
(mid[cc-1]-2*mid[cc]+mid[cc+1])/dx2
next[i] = 2*mid[cc] - prev[i] + lapW*lap
}
}
prev, cur, next = cur, next, prev
if s%every == 0 && written < samples {
copy(into[written*rows*cols:(written+1)*rows*cols], cur)
written++
}
}
copy(into[(samples-1)*rows*cols:], cur)
return out, nil
}
// pdeStepFloor is the per-worker floor for a 2-D step sweep. One item is
// one tridiagonal elimination over a stencil row, measured at roughly
// half a microsecond on a 32-line grid, so a worker needs about eight
// of them before the split pays for its spawn. The floor is a constant
// for that reason, not a length-scaled budget. The line scratch is
// indexed by start/pdeStepFloor on the same constant: parallel chunk
// starts differ by at least the floor, so concurrent chunks never share
// a scratch set.
const pdeStepFloor = 8