feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// Turnkey PDE evolution in one space dimension: the two
|
||||
// equations half of physics reduces to, wrapped on machinery the
|
||||
// library already owns. The heat equation runs Crank-Nicolson (the
|
||||
// unconditionally stable trapezoidal rule) through the shared
|
||||
// tridiagonal solver; the wave equation runs velocity Verlet on the
|
||||
// second-order form, kick-drift-kick like the Hamiltonian integrator
|
||||
// it is. Both return the trajectory sampled on a time grid,
|
||||
// IntegrateODEPath-style.
|
||||
|
||||
// pdeValidate checks the shared input contract and returns the grid
|
||||
// size.
|
||||
func pdeValidate(name string, u0 *core.Array, dx, tFinal, dt float64, samples int) (int, error) {
|
||||
if u0.NDim() != 1 || u0.Len() == 0 {
|
||||
return 0, base.Errf("%s: the initial condition must be a non-empty rank-1 array, got shape %s",
|
||||
name, base.ShapeText(u0.Shape()))
|
||||
}
|
||||
if u0.Dtype() == core.Complex {
|
||||
return 0, base.Errf("%s: complex states are not supported", name)
|
||||
}
|
||||
if !(dx > 0) {
|
||||
return 0, base.Errf("%s: the grid spacing must be positive, got %g", name, dx)
|
||||
}
|
||||
if !(tFinal > 0) {
|
||||
return 0, base.Errf("%s: the integration time must be positive, got %g", name, tFinal)
|
||||
}
|
||||
if !(dt > 0) {
|
||||
return 0, base.Errf("%s: the time step must be positive, got %g", name, dt)
|
||||
}
|
||||
// A dt far below tFinal/1e12 cannot be honoured: the step count
|
||||
// would leave the int range on some platforms and wrap on others,
|
||||
// and the silently larger step would run past the wave equation's
|
||||
// CFL check, which runs on the requested dt.
|
||||
if tFinal/dt > 1e12 {
|
||||
return 0, base.Errf("%s: dt = %g asks for more than 1e12 steps over %g", name, dt, tFinal)
|
||||
}
|
||||
if samples < 2 {
|
||||
return 0, base.Errf("%s: at least two samples are needed, got %d", name, samples)
|
||||
}
|
||||
// A non-finite entry would flow through the stencil and the
|
||||
// tridiagonal solve's zero-pivot checks compare false against NaN,
|
||||
// publishing an all-NaN history with no error.
|
||||
for i := range u0.Len() {
|
||||
if v := u0.FloatAt(i); math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return 0, base.Errf("%s: the initial condition holds the non-finite value %g at %d", name, v, i)
|
||||
}
|
||||
}
|
||||
return u0.Len(), nil
|
||||
}
|
||||
|
||||
// pdeSchedule picks the step count and the actual step size for a
|
||||
// requested dt. The count is rounded up to a multiple of the sampling
|
||||
// interval, so every published time j·tFinal/(samples−1) is a step
|
||||
// boundary and the last step lands on tFinal exactly: the returned
|
||||
// samples are the evenly spaced interior states the documentation
|
||||
// promises, not the states at multiples of dt.
|
||||
func pdeSchedule(tFinal, dt float64, samples int) (steps int, h float64) {
|
||||
steps = max(int(math.Ceil(tFinal/dt)), samples-1)
|
||||
if rem := steps % (samples - 1); rem != 0 {
|
||||
steps += samples - 1 - rem
|
||||
}
|
||||
return steps, tFinal / float64(steps)
|
||||
}
|
||||
|
||||
// IntegrateHeat1D evolves u_t = κ·u_xx over [0, L] discretised by the
|
||||
// interior grid of u0 (n = u0.Len(), dx = L/(n+1)), from t = 0 to
|
||||
// tFinal in equal steps of at most dt, holding the boundary values
|
||||
// boundL and boundR (Dirichlet). It returns the (samples, n) array of
|
||||
// interior states evenly spaced in time, endpoints included. Crank-
|
||||
// Nicolson is stable for any dt; accuracy wants dt of a few dx²/κ.
|
||||
func IntegrateHeat1D(u0 *core.Array, kappa, dx, tFinal, dt float64, samples int, boundL, boundR float64) (*core.Array, error) {
|
||||
const name = "IntegrateHeat1D"
|
||||
n, err := pdeValidate(name, u0, dx, 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 boundary values enter the right side every step: a non-finite
|
||||
// one would flow through the stencil and the solve and publish an
|
||||
// all-NaN history with no error.
|
||||
if math.IsNaN(boundL) || math.IsInf(boundL, 0) || math.IsNaN(boundR) || math.IsInf(boundR, 0) {
|
||||
return nil, base.Errf("%s: the boundary values must be finite, got %g and %g", name, boundL, boundR)
|
||||
}
|
||||
u := make([]float64, n)
|
||||
copy(u, denseFloats(u0))
|
||||
// Crank-Nicolson: (I − r/2·A)uⁿ⁺¹ = (I + r/2·A)uⁿ with A the
|
||||
// second-difference stencil and r = κ·h/dx² for the step h the
|
||||
// schedule actually takes; the Dirichlet neighbours enter the right
|
||||
// side through the stencil ends.
|
||||
steps, h := pdeSchedule(tFinal, dt, samples)
|
||||
r := kappa * h / (dx * dx)
|
||||
lower := make([]float64, n-1)
|
||||
diag := make([]float64, n)
|
||||
upper := make([]float64, n-1)
|
||||
// The left side carries I − r/2·A: the diagonal gains r (A's −2
|
||||
// times −r/2) and the off-diagonals stay −r/2. The system is
|
||||
// strictly diagonally dominant for every positive r, so the
|
||||
// elimination's pivots stay finite and non-zero.
|
||||
for i := range n {
|
||||
diag[i] = 1 + r
|
||||
if i < n-1 {
|
||||
lower[i] = -r / 2
|
||||
upper[i] = -r / 2
|
||||
}
|
||||
}
|
||||
|
||||
every := steps / (samples - 1)
|
||||
out := make([]float64, samples*n)
|
||||
copy(out, u)
|
||||
written := 1
|
||||
// The right side and the elimination scratch are constants of one
|
||||
// solve: every step refills the same buffers, the kernel reads its
|
||||
// inputs without touching them, and the solution is written
|
||||
// straight into the working state the samples copy from.
|
||||
var tri triScratch
|
||||
triSized(&tri, n, n)
|
||||
rhs := tri.rhs
|
||||
// The trapezoidal weight is a constant of the scheme: one division
|
||||
// by two, the same value the expression inside the loop carried.
|
||||
half := r / 2
|
||||
for s := 1; s <= steps; s++ {
|
||||
for i := range n {
|
||||
um, up := boundL, boundR
|
||||
if i > 0 {
|
||||
um = u[i-1]
|
||||
}
|
||||
if i < n-1 {
|
||||
up = u[i+1]
|
||||
}
|
||||
rhs[i] = u[i] + half*(um-2*u[i]+up)
|
||||
}
|
||||
// The implicit side's boundary neighbours move across as known
|
||||
// data: the first and last rows only, in that order.
|
||||
rhs[0] += half * boundL
|
||||
rhs[n-1] += half * boundR
|
||||
if err := base.TriSolve(u, tri.cp, tri.dp, lower, diag, upper, rhs); err != nil {
|
||||
return nil, base.Errf("%s: %w", name, err)
|
||||
}
|
||||
if s%every == 0 && written < samples {
|
||||
copy(out[written*n:(written+1)*n], u)
|
||||
written++
|
||||
}
|
||||
}
|
||||
// The final state is the last sample whatever the grid remainder.
|
||||
copy(out[(samples-1)*n:], u)
|
||||
return core.FromFloats(out, samples, n)
|
||||
}
|
||||
|
||||
// IntegrateWave1D evolves u_tt = c²·u_xx over [0, L] with the grid of
|
||||
// u0 (dx = L/(n+1), Dirichlet ends held at zero) and the initial
|
||||
// velocity v0, by velocity Verlet with fixed step dt. The CFL budget
|
||||
// |c·dt/dx| ≤ 1 is a genuine stability requirement and is enforced as
|
||||
// an error. The return contract mirrors IntegrateHeat1D.
|
||||
func IntegrateWave1D(u0, v0 *core.Array, c, dx, tFinal, dt float64, samples int) (*core.Array, error) {
|
||||
const name = "IntegrateWave1D"
|
||||
n, err := pdeValidate(name, u0, dx, tFinal, dt, samples)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v0.NDim() != 1 || v0.Len() != n {
|
||||
return nil, base.Errf("%s: the initial velocity must match the state shape, got %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 flows through the Verlet kick
|
||||
// and poisons the trajectory without an error.
|
||||
for i := range n {
|
||||
if v := v0.FloatAt(i); math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return nil, base.Errf("%s: the initial velocity holds the non-finite value %g at %d", name, v, i)
|
||||
}
|
||||
}
|
||||
// The CFL ratio compares false against 1 when it is NaN, so a
|
||||
// non-finite speed is refused before the budget test.
|
||||
if math.IsNaN(c) || math.IsInf(c, 0) {
|
||||
return nil, base.Errf("%s: the wave speed must be finite, got %g", name, c)
|
||||
}
|
||||
cfl := math.Abs(c * dt / dx)
|
||||
if cfl > 1 {
|
||||
return nil, base.Errf("%s: CFL violated, |c·dt/dx| = %.3g > 1", name, cfl)
|
||||
}
|
||||
u := make([]float64, n)
|
||||
v := make([]float64, n)
|
||||
copy(u, denseFloats(u0))
|
||||
copy(v, denseFloats(v0))
|
||||
// The stencil's constants: the products are the ones the element
|
||||
// loop evaluated, built once per run.
|
||||
cc := c * c
|
||||
dx2 := dx * dx
|
||||
accel := func(dst, us []float64) {
|
||||
// The two ends take the fixed zero neighbour the boundaries
|
||||
// impose; the interior runs the same stencil over the real
|
||||
// neighbours, so the two tests leave the element loop. Every
|
||||
// term keeps the order the uniform loop evaluated.
|
||||
end := func(i int) {
|
||||
um, up := 0.0, 0.0
|
||||
if i > 0 {
|
||||
um = us[i-1]
|
||||
}
|
||||
if i < n-1 {
|
||||
up = us[i+1]
|
||||
}
|
||||
dst[i] = cc * (um - 2*us[i] + up) / dx2
|
||||
}
|
||||
end(0)
|
||||
for i := 1; i < n-1; i++ {
|
||||
dst[i] = cc * (us[i-1] - 2*us[i] + us[i+1]) / dx2
|
||||
}
|
||||
if n > 1 {
|
||||
end(n - 1)
|
||||
}
|
||||
}
|
||||
// Kick-drift-kick: the modified energy stays within (c·dt/dx)²/8
|
||||
// of the true one, which is why the wave equation keeps its shape.
|
||||
// The schedule's step never exceeds dt, so the CFL check above
|
||||
// bounds this one too.
|
||||
steps, h := pdeSchedule(tFinal, dt, samples)
|
||||
every := steps / (samples - 1)
|
||||
out := make([]float64, samples*n)
|
||||
copy(out, u)
|
||||
written := 1
|
||||
a := make([]float64, n)
|
||||
for s := 1; s <= steps; s++ {
|
||||
accel(a, u)
|
||||
for i := range n {
|
||||
v[i] += 0.5 * h * a[i]
|
||||
}
|
||||
for i := range n {
|
||||
u[i] += h * v[i]
|
||||
}
|
||||
accel(a, u)
|
||||
for i := range n {
|
||||
v[i] += 0.5 * h * a[i]
|
||||
}
|
||||
if s%every == 0 && written < samples {
|
||||
copy(out[written*n:(written+1)*n], u)
|
||||
written++
|
||||
}
|
||||
}
|
||||
copy(out[(samples-1)*n:], u)
|
||||
return core.FromFloats(out, samples, n)
|
||||
}
|
||||
Reference in New Issue
Block a user