predictor/internal/numerics/spherical.go
2026-08-03 22:11:10 +09:00

159 lines
5.8 KiB
Go

package numerics
import "math"
// Spherical kinematics for trajectory integration.
//
// Positions are carried as GeoVec (degrees) because constraints, path recording
// and the API all speak latitude and longitude. Motion, however, is expressed
// as a velocity in metres per second and applied by rotating the position
// vector along a great circle. Nothing in this file forms a longitude
// derivative, which is what removes the polar singularity: dLng/dt carries a
// 1/cos(lat) factor that reached 1.46e12 deg/s at 90 degrees and made any
// fixed-step integrator meaningless there.
//
// Consequences worth knowing:
// - A step that reaches a pole continues down the far side, and longitude
// picks up 180 degrees on its own. No special case, no threshold latitude.
// - Latitude cannot leave [-90, 90] by construction, because it is read back
// from a unit vector rather than accumulated.
const (
degToRad = math.Pi / 180
radToDeg = 180 / math.Pi
)
// Rate is a velocity in the local horizontal frame at a point: metres per
// second toward east and north, plus metres per second upward.
type Rate struct {
East float64
North float64
Vertical float64
}
// AddRate sums two rates componentwise. Rates compose linearly; positions do
// not, which is why they are advanced with GeoStep instead.
func AddRate(a, b Rate) Rate {
return Rate{East: a.East + b.East, North: a.North + b.North, Vertical: a.Vertical + b.Vertical}
}
// basis returns the earth-centred unit vectors at (lat, lng): outward radial,
// east and north.
//
// All three are unit length at every latitude, the poles included. What happens
// at a pole is not a degeneracy but a genuine ambiguity: east and north there
// depend on which meridian the longitude names. That matches the data — NCEP
// resolves the GFS pole row per longitude for exactly this reason, so any
// choice yields the same physical vector.
func basis(latDeg, lngDeg float64) (radial, east, north [3]float64) {
sinLat, cosLat := math.Sincos(latDeg * degToRad)
sinLng, cosLng := math.Sincos(lngDeg * degToRad)
radial = [3]float64{cosLat * cosLng, cosLat * sinLng, sinLat}
east = [3]float64{-sinLng, cosLng, 0}
north = [3]float64{-sinLat * cosLng, -sinLat * sinLng, cosLat}
return radial, east, north
}
// toGeo reads a position back off a unit vector.
func toGeo(v [3]float64, altitude float64) GeoVec {
return GeoVec{
Lat: math.Asin(math.Max(-1, math.Min(1, v[2]))) * radToDeg,
Lng: PyMod(math.Atan2(v[1], v[0])*radToDeg, 360),
Altitude: altitude,
}
}
// GeoStep advances a position by rate over dt seconds along a great circle.
// dt may be negative, which travels the same arc in the opposite direction.
//
// It is exact for a constant rate: the position vector is rotated in the plane
// it spans with the direction of travel. Since that direction is orthogonal to
// the radial by construction, the result stays on the unit sphere without
// renormalisation.
func GeoStep(y GeoVec, rate Rate, dt float64) GeoVec {
altitude := y.Altitude + rate.Vertical*dt
speed := math.Hypot(rate.East, rate.North)
if speed == 0 {
return GeoVec{Lat: y.Lat, Lng: y.Lng, Altitude: altitude}
}
radial, east, north := basis(y.Lat, y.Lng)
var tangent [3]float64
for i := range tangent {
tangent[i] = (rate.East*east[i] + rate.North*north[i]) / speed
}
// Angle subtended at the earth's centre by the arc travelled.
angle := speed * dt / (EarthRadius + y.Altitude)
sinA, cosA := math.Sincos(angle)
var out [3]float64
for i := range out {
out[i] = radial[i]*cosA + tangent[i]*sinA
}
return toGeo(out, altitude)
}
// GreatCircleMetres is the surface distance between two positions, ignoring
// altitude.
//
// Uses the chord rather than acos(dot): for nearby points acos loses most of
// its significant digits, and these distances are checked to sub-millimetre
// tolerances in tests.
func GreatCircleMetres(a, b GeoVec) float64 {
ra, _, _ := basis(a.Lat, a.Lng)
rb, _, _ := basis(b.Lat, b.Lng)
var chordSq float64
for i := range ra {
d := ra[i] - rb[i]
chordSq += d * d
}
return 2 * EarthRadius * math.Asin(math.Min(1, math.Sqrt(chordSq)/2))
}
// RateField returns the rate of change of state at (t, y). The rate is
// direction-independent; the integrator applies the sign of dt for reverse-time
// integration.
type RateField func(t float64, y GeoVec) Rate
// RK4Step performs one classical Runge-Kutta-4 step along the sphere.
//
// The four stage rates are combined as earth-centred vectors rather than as
// local east/north pairs. That distinction matters: the local frame rotates
// between stages, and near a pole it rotates fast enough that averaging
// components directly would reintroduce the very error this formulation exists
// to remove. The combined velocity is then read back in the frame at the
// starting point — which also discards the small radial component averaging
// introduces — and applied as a single great-circle step.
func RK4Step(t float64, y GeoVec, dt float64, f RateField) GeoVec {
half := dt / 2
k1 := f(t, y)
y2 := GeoStep(y, k1, half)
k2 := f(t+half, y2)
y3 := GeoStep(y, k2, half)
k3 := f(t+half, y3)
y4 := GeoStep(y, k3, dt)
k4 := f(t+dt, y4)
points := [4]GeoVec{y, y2, y3, y4}
rates := [4]Rate{k1, k2, k3, k4}
weights := [4]float64{1.0 / 6, 1.0 / 3, 1.0 / 3, 1.0 / 6}
var vx, vy, vz, vertical float64
for i := range points {
_, east, north := basis(points[i].Lat, points[i].Lng)
w := weights[i]
vx += w * (rates[i].East*east[0] + rates[i].North*north[0])
vy += w * (rates[i].East*east[1] + rates[i].North*north[1])
vz += w * (rates[i].East*east[2] + rates[i].North*north[2])
vertical += w * rates[i].Vertical
}
_, east0, north0 := basis(y.Lat, y.Lng)
mean := Rate{
East: vx*east0[0] + vy*east0[1] + vz*east0[2],
North: vx*north0[0] + vy*north0[1] + vz*north0[2],
Vertical: vertical,
}
return GeoStep(y, mean, dt)
}