159 lines
5.8 KiB
Go
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)
|
|
}
|