feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// Advection in one space dimension, the transport siblings of
|
||||
// IntegrateHeat1D and IntegrateWave1D: u_t + a·u_x = 0 and the
|
||||
// advection-diffusion equation u_t + a·u_x = D·u_xx. Transport is
|
||||
// where the cheap stencils fail in public: the first-order upwind
|
||||
// flux is monotone but smears a front every step it takes, and the
|
||||
// centred flux that heat uses is oscillatory or worse here. The
|
||||
// middle ground is a flux-limited scheme: an upwind flux whose face
|
||||
// value is raised toward the third-order one by a slope limiter that
|
||||
// switches itself off across discontinuities, keeping the scheme
|
||||
// total variation diminishing.
|
||||
//
|
||||
// The limiter is Koren's third-order one, φ(θ) = max(0, min(2θ,
|
||||
// (2+θ)/3, 2)), stated here as the choice. The face value is the
|
||||
// Sweby flux-limited form u_upwind + ½φ(θ)·(1−|ν|)·Δ, with ν =
|
||||
// |a|·dt/dx the CFL number and Δ the forward difference across the
|
||||
// face: the (1−|ν|) factor is what makes the explicit update total
|
||||
// variation diminishing in the Sweby sense for CFL ≤ 1, and the
|
||||
// third-order branch of φ is what keeps smooth profiles sharp (a
|
||||
// marginally small overshoot past the strict Sweby bound survives as
|
||||
// roundoff-scale noise). The grid convention is the house one: u0
|
||||
// carries the interior cells with dx = L/(n+1), and boundL, boundR
|
||||
// are the fixed values of the ghost cells on the ends. The face
|
||||
// adjacent to the inflow end takes its prescribed value directly
|
||||
// (first order there); on every other face, including the outflow
|
||||
// end, the limiter runs at full strength with the prescribed ghost
|
||||
// value entering its smoothness ratio, and a window too short for a
|
||||
// three-cell stencil forces first order.
|
||||
|
||||
// korenSlope returns the Koren-limited normalised slope φ(θ) for the
|
||||
// smoothness ratio θ: the three branches are the Sweby bounds with
|
||||
// the third-order diagonal (2+θ)/3.
|
||||
func korenSlope(theta float64) float64 {
|
||||
phi := min(2*theta, (2+theta)/3, 2)
|
||||
if phi < 0 {
|
||||
return 0
|
||||
}
|
||||
return phi
|
||||
}
|
||||
|
||||
// advectFaceFlux returns the numerical flux a·u_face across the face
|
||||
// between cells ul (left) and ur (right), with the second neighbours
|
||||
// ull (left of ul) and urr (right of ur) feeding the limiter and nu
|
||||
// the CFL number feeding the time factor. The wind decides the side
|
||||
// the face value is reconstructed from; with limited false the flux
|
||||
// is plain first-order upwind.
|
||||
func advectFaceFlux(a, ull, ul, ur, urr, nu float64, limited bool) float64 {
|
||||
d := ur - ul
|
||||
if a >= 0 {
|
||||
if !limited || d == 0 {
|
||||
return a * ul
|
||||
}
|
||||
theta := (ul - ull) / d
|
||||
return a * (ul + 0.5*korenSlope(theta)*(1-nu)*d)
|
||||
}
|
||||
if !limited || d == 0 {
|
||||
return a * ur
|
||||
}
|
||||
theta := (ur - urr) / d
|
||||
return a * (ur - 0.5*korenSlope(theta)*(1-nu)*d)
|
||||
}
|
||||
|
||||
// advectStep advances dst from src by one explicit step of width h
|
||||
// of the conservative update u −= (h/dx)·(F₊ − F₋), the faces built
|
||||
// from src with the ghost values boundL and boundR on the ends. dst
|
||||
// and src must not alias. Face j sits between cell j−1 and cell j,
|
||||
// so face 0 borders the left ghost and face n the right one.
|
||||
func advectStep(dst, src []float64, a, dx, h, boundL, boundR float64, limited bool, faces []float64) {
|
||||
n := len(src)
|
||||
lambda := h / dx
|
||||
nu := math.Abs(a) * lambda
|
||||
for j := range n + 1 {
|
||||
switch {
|
||||
case j == 0:
|
||||
// The inflow face takes the prescribed ghost value; on an
|
||||
// outflow left end the upwind cell is cell 0, whose
|
||||
// limited face value reaches one cell into the interior.
|
||||
if a >= 0 {
|
||||
faces[j] = a * boundL
|
||||
} else if n < 2 || !limited {
|
||||
faces[j] = a * src[0]
|
||||
} else {
|
||||
faces[j] = advectFaceFlux(a, boundL, boundL, src[0], src[1], nu, true)
|
||||
}
|
||||
case j == n:
|
||||
if a >= 0 {
|
||||
if n < 2 || !limited {
|
||||
faces[j] = a * src[n-1]
|
||||
} else {
|
||||
faces[j] = advectFaceFlux(a, src[n-2], src[n-1], boundR, boundR, nu, true)
|
||||
}
|
||||
} else {
|
||||
faces[j] = a * boundR
|
||||
}
|
||||
default:
|
||||
ull := boundL
|
||||
if j >= 2 {
|
||||
ull = src[j-2]
|
||||
}
|
||||
urr := boundR
|
||||
if j <= n-2 {
|
||||
urr = src[j+1]
|
||||
}
|
||||
faces[j] = advectFaceFlux(a, ull, src[j-1], src[j], urr, nu, limited)
|
||||
}
|
||||
}
|
||||
for i := range n {
|
||||
dst[i] = src[i] - lambda*(faces[i+1]-faces[i])
|
||||
}
|
||||
}
|
||||
|
||||
// advectValidate checks the shared input contract of the transport
|
||||
// solvers and returns the cell count. The grid, step bound, sample
|
||||
// and finiteness gates are pdeValidate's; transport adds a finite
|
||||
// speed and finite ghost values, and enforces the CFL budget
|
||||
// |a|·dt/dx ≤ 1 the explicit update needs, exactly like the wave
|
||||
// solver enforces its own.
|
||||
func advectValidate(name string, u0 *core.Array, a, dx, tFinal, dt float64, samples int, boundL, boundR float64) (int, error) {
|
||||
n, err := pdeValidate(name, u0, dx, tFinal, dt, samples)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if math.IsNaN(a) || math.IsInf(a, 0) {
|
||||
return 0, base.Errf("%s: the transport speed must be finite, got %g", name, a)
|
||||
}
|
||||
if math.IsNaN(boundL) || math.IsInf(boundL, 0) || math.IsNaN(boundR) || math.IsInf(boundR, 0) {
|
||||
return 0, base.Errf("%s: the ghost values must be finite, got %g and %g", name, boundL, boundR)
|
||||
}
|
||||
cfl := math.Abs(a * dt / dx)
|
||||
if cfl > 1 {
|
||||
return 0, base.Errf("%s: CFL violated, |a·dt/dx| = %.3g > 1", name, cfl)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// IntegrateAdvection1D evolves u_t + a·u_x = 0 over the grid of u0
|
||||
// from t = 0 to tFinal in equal steps of at most dt, and returns the
|
||||
// (samples, n) array of interior states evenly spaced in time,
|
||||
// endpoints included, exactly like IntegrateHeat1D. The flux is the
|
||||
// Koren-limited upwind one described at the top of the file: total
|
||||
// variation diminishing under CFL ≤ 1, third-order at smooth faces
|
||||
// and first order next to the inflow boundary, so a front is carried
|
||||
// sharply where plain upwind would smear it away.
|
||||
func IntegrateAdvection1D(u0 *core.Array, a, dx, tFinal, dt float64, samples int, boundL, boundR float64) (*core.Array, error) {
|
||||
const name = "IntegrateAdvection1D"
|
||||
n, err := advectValidate(name, u0, a, dx, tFinal, dt, samples, boundL, boundR)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return advectRun(name, u0, a, dx, tFinal, dt, samples, boundL, boundR, n, true)
|
||||
}
|
||||
|
||||
// IntegrateUpwindAdvection1D evolves the same equation with the
|
||||
// plain first-order upwind flux: monotone under CFL ≤ 1 (no new
|
||||
// extrema, ever) and diffuse, the baseline the limited scheme is
|
||||
// measured against. The return contract mirrors IntegrateAdvection1D.
|
||||
func IntegrateUpwindAdvection1D(u0 *core.Array, a, dx, tFinal, dt float64, samples int, boundL, boundR float64) (*core.Array, error) {
|
||||
const name = "IntegrateUpwindAdvection1D"
|
||||
n, err := advectValidate(name, u0, a, dx, tFinal, dt, samples, boundL, boundR)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return advectRun(name, u0, a, dx, tFinal, dt, samples, boundL, boundR, n, false)
|
||||
}
|
||||
|
||||
// advectRun is the shared stepping loop of the two pure-transport
|
||||
// solvers: fixed steps on the pdeSchedule grid, states sampled every
|
||||
// steps/(samples−1) steps with the final state forced into the last
|
||||
// sample.
|
||||
func advectRun(name string, u0 *core.Array, a, dx, tFinal, dt float64, samples int, boundL, boundR float64, n int, limited bool) (*core.Array, error) {
|
||||
u := make([]float64, n)
|
||||
for i := range n {
|
||||
u[i] = u0.FloatAt(i)
|
||||
}
|
||||
steps, h := pdeSchedule(tFinal, dt, samples)
|
||||
every := steps / (samples - 1)
|
||||
out := core.New(core.Float, samples, n)
|
||||
into := out.RawFloats()
|
||||
copy(into, u)
|
||||
written := 1
|
||||
faces := make([]float64, n+1)
|
||||
scratch := make([]float64, n)
|
||||
for s := 1; s <= steps; s++ {
|
||||
advectStep(scratch, u, a, dx, h, boundL, boundR, limited, faces)
|
||||
u, scratch = scratch, u
|
||||
if s%every == 0 && written < samples {
|
||||
copy(into[written*n:(written+1)*n], u)
|
||||
written++
|
||||
}
|
||||
}
|
||||
copy(into[(samples-1)*n:], u)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IntegrateAdvectionDiffusion1D evolves u_t + a·u_x = D·u_xx over the
|
||||
// grid of u0 with the Dirichlet ghost values boundL and boundR. Each
|
||||
// step combines the Koren-limited advection flux, advanced
|
||||
// explicitly, with the Crank-Nicolson second-difference diffusion the
|
||||
// heat solver runs through the shared tridiagonal solve, so the
|
||||
// composition is first order in time and second order in space and
|
||||
// the diffusion side is unconditionally stable. The explicit
|
||||
// advection still answers for its own CFL budget |a|·dt/dx ≤ 1 and
|
||||
// the step is refused past it. With a = 0 the scheme reduces exactly
|
||||
// to IntegrateHeat1D.
|
||||
func IntegrateAdvectionDiffusion1D(u0 *core.Array, a, kappa, dx, tFinal, dt float64, samples int, boundL, boundR float64) (*core.Array, error) {
|
||||
const name = "IntegrateAdvectionDiffusion1D"
|
||||
n, err := advectValidate(name, u0, a, dx, tFinal, dt, samples, boundL, boundR)
|
||||
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)
|
||||
}
|
||||
u := make([]float64, n)
|
||||
for i := range n {
|
||||
u[i] = u0.FloatAt(i)
|
||||
}
|
||||
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 implicit left side I − r/2·A is a constant of the scheme,
|
||||
// built once exactly as IntegrateHeat1D builds it, strictly
|
||||
// diagonally dominant for every positive r like the heat system.
|
||||
for i := range n {
|
||||
diag[i] = 1 + r
|
||||
if i < n-1 {
|
||||
lower[i] = -r / 2
|
||||
upper[i] = -r / 2
|
||||
}
|
||||
}
|
||||
// The trapezoidal weight and the elimination scratch are constants
|
||||
// of one solve: every step refills the same right side and the
|
||||
// solution is written straight into the working state.
|
||||
half := r / 2
|
||||
var tri triScratch
|
||||
triSized(&tri, n, n)
|
||||
|
||||
every := steps / (samples - 1)
|
||||
out := core.New(core.Float, samples, n)
|
||||
into := out.RawFloats()
|
||||
copy(into, u)
|
||||
written := 1
|
||||
faces := make([]float64, n+1)
|
||||
advected := make([]float64, n)
|
||||
rhs := tri.rhs
|
||||
for s := 1; s <= steps; s++ {
|
||||
// Explicit advection sub-step on the limited fluxes.
|
||||
advectStep(advected, u, a, dx, h, boundL, boundR, true, faces)
|
||||
// Crank-Nicolson diffusion sub-step on the advected state,
|
||||
// the Dirichlet neighbours entering as known data on both
|
||||
// sides, in the heat solver's own arithmetic.
|
||||
for i := range n {
|
||||
um, up := boundL, boundR
|
||||
if i > 0 {
|
||||
um = advected[i-1]
|
||||
}
|
||||
if i < n-1 {
|
||||
up = advected[i+1]
|
||||
}
|
||||
rhs[i] = advected[i] + half*(um-2*advected[i]+up)
|
||||
if i == 0 {
|
||||
rhs[i] += half * boundL
|
||||
}
|
||||
if i == n-1 {
|
||||
rhs[i] += half * boundR
|
||||
}
|
||||
}
|
||||
if serr := base.TriSolve(u, tri.cp, tri.dp, lower, diag, upper, rhs); serr != nil {
|
||||
return nil, base.Errf("%s: %w", name, serr)
|
||||
}
|
||||
if s%every == 0 && written < samples {
|
||||
copy(into[written*n:(written+1)*n], u)
|
||||
written++
|
||||
}
|
||||
}
|
||||
copy(into[(samples-1)*n:], u)
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user