Files
tensor/integrate/symplectic.go
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

130 lines
5.3 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"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// Symplectic integration for separable Hamiltonian systems, where the
// energy splits as H(q, p) = T(p) + V(q): Newtonian mechanics, N-body
// gravity, molecular dynamics. The adaptive Runge-Kutta pair that
// drives IntegrateODE is accurate per step but dissipates energy
// systematically, so a two-hundred-period orbit spirals inward or
// outward; the leapfrog structure below is symplectic, which means it
// conserves a shadow Hamiltonian exactly and keeps the true energy
// oscillating in a bounded band forever. That long-time fidelity, not
// per-step accuracy, is what separates integrators for celestial
// mechanics and molecular dynamics from general-purpose ones.
//
// The scheme is velocity Verlet, a kick-drift-kick leapfrog of second
// order: half a momentum kick, a full position drift, another half
// kick with the force at the new position. Unit masses are assumed
// (p is the velocity); scale the momentum by the masses beforehand or
// fold them into the acceleration.
// IntegrateVerlet integrates a separable Hamiltonian system with unit
// masses over an even time grid: accel returns the acceleration
// −∂V/∂q at a position, q0 and p0 are the initial position and
// momentum (velocity), and steps fixes the number of equal steps, so
// the i-th returned pair sits at t0 + i·h with h = (t1−t0)/steps.
// positions[0] is q0 and momenta[0] is p0. The state may be float64
// or float32, read through per-element accessors, and the returned
// arrays are float64. The step size stays fixed by design:
// adaptivity would destroy the symplectic property the method exists
// for. A negative or zero-length acceleration vector, a mismatched
// pair, a complex or int state, or an empty state is an error.
func IntegrateVerlet(accel func(q *core.Array) (*core.Array, error),
t0, t1 float64, q0, p0 *core.Array, steps int) (positions, momenta []*core.Array, err error) {
if steps <= 0 {
return nil, nil, base.Errf("IntegrateVerlet: steps must be ≥ 1, got %d", steps)
}
if q0.Dtype() == core.Complex || p0.Dtype() == core.Complex {
return nil, nil, base.Errf("IntegrateVerlet: complex states are not supported")
}
// The whole integer class follows Int into the standing refusal:
// bool and the narrow widths carry a discrete state, which has no
// place in a continuous integrator, and the wording is Int's own.
if integerState(q0.Dtype()) || integerState(p0.Dtype()) {
return nil, nil, base.Errf("IntegrateVerlet: int states cannot integrate, use float or float32 states")
}
if q0.NDim() != 1 || p0.NDim() != 1 || q0.Len() != p0.Len() {
return nil, nil, base.Errf("IntegrateVerlet: position and momentum must be vectors of equal length, got %s and %s",
base.ShapeText(q0.Shape()), base.ShapeText(p0.Shape()))
}
n := q0.Len()
if n == 0 {
return nil, nil, base.Errf("IntegrateVerlet: the state must not be empty")
}
// Read the initial state element-wise: RawFloats backs float64
// payloads only, so a float32 state would come through as nil.
// A non-finite entry is refused up front: it would propagate
// through every kick and drift silently.
q := make([]float64, n)
p := make([]float64, n)
for i := range n {
q[i] = q0.FloatAt(i)
p[i] = p0.FloatAt(i)
if math.IsNaN(q[i]) || math.IsInf(q[i], 0) {
return nil, nil, base.Errf("IntegrateVerlet: q0 holds the non-finite value %g at %d", q[i], i)
}
if math.IsNaN(p[i]) || math.IsInf(p[i], 0) {
return nil, nil, base.Errf("IntegrateVerlet: p0 holds the non-finite value %g at %d", p[i], i)
}
}
a := make([]float64, n)
// One cached read-only view serves every acceleration call: the
// position slice is the run's own buffer, stable for the whole
// integration, so the wrapper is built once per run.
var views odeViews
eval := func(x []float64, out []float64) error {
v, err := accel(views.of(x))
if err != nil {
return base.Errf("IntegrateVerlet: %w", err)
}
if v.NDim() != 1 || v.Len() != n {
return base.Errf("IntegrateVerlet: accel returned shape %s, want a vector of length %d",
base.ShapeText(v.Shape()), n)
}
readVector(out, v)
// Like RK4: a non-finite acceleration would flow through the
// kicks silently, and the published trajectory would be NaN
// with a nil error.
for i := range n {
if math.IsNaN(out[i]) || math.IsInf(out[i], 0) {
return base.Errf("IntegrateVerlet: accel returned the non-finite value %g at coordinate %d", out[i], i)
}
}
return nil
}
if err := eval(q, a); err != nil {
return nil, nil, err
}
h := (t1 - t0) / float64(steps)
positions = make([]*core.Array, steps+1)
momenta = make([]*core.Array, steps+1)
positions[0] = arrayFromVector(q)
momenta[0] = arrayFromVector(p)
for s := 1; s <= steps; s++ {
// Kick, drift, kick: two half kicks bracket the drift, so the
// force is evaluated once per step.
for i := range n {
p[i] += h / 2 * a[i]
q[i] += h * p[i]
}
if err := eval(q, a); err != nil {
return nil, nil, err
}
for i := range n {
p[i] += h / 2 * a[i]
}
positions[s] = arrayFromVector(q)
momenta[s] = arrayFromVector(p)
}
return positions, momenta, nil
}