feat: 引入可切换星历模型并扩展天象观测链路

- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算
- 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询
- 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出
- 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解
- 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤
- 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持
- 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理
- 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
This commit is contained in:
2026-10-15 23:54:15 +08:00
parent 16c62a97d5
commit 044f2f4381
674 changed files with 62983 additions and 8396 deletions
+4 -3
View File
@@ -8,9 +8,10 @@
// All input angles are in degrees. EpochJD and TpJD are TT/TDB Julian days.
// Returned geometric positions do not include perturbations beyond the supplied
// elements. Astrometric results include down-leg light-time correction.
// Apparent results follow this repository's existing planet semantics:
// astrometric position plus nutation/of-date coordinate conversion, without a
// full external aberration model. The package also provides observer-facing
// Optional planetary perturbations are available through PropagationOptions and
// StateWithOptions; a zero-value options value preserves the two-body path.
// Apparent results add annual aberration, nutation, and of-date coordinate
// conversion to the astrometric position. The package also provides observer-facing
// altitude/azimuth/hour-angle and rise/transit/set helpers built on top of the
// apparent topocentric coordinates. Rise/set helpers return package-local
// sentinel errors ERR_ORBIT_NEVER_RISE / ERR_ORBIT_NEVER_SET, matching the
+8 -8
View File
@@ -15,8 +15,8 @@ var (
// Elements 日心二体圆锥曲线根数 / heliocentric two-body conic elements.
// 参考系为 J2000 平黄道/平春分点。
// The reference frame is the J2000 mean ecliptic and mean equinox.
// EpochJD 与 TpJD 使用 TT/TDB 对应的儒略日。
// EpochJD and TpJD are Julian days on the TT/TDB scale.
// EpochJD 与 TpJD 使用 TT/TDB 对应的儒略日;摄动传播时经典根数必须给出非零 EpochJD。
// EpochJD and TpJD are Julian days on the TT/TDB scale; perturbed propagation of classical elements requires a non-zero EpochJD.
//
// 经典椭圆根数:A/E/I/Omega/W/M0
// 近日点形式:Q/E/I/Omega/W/TpJD
@@ -151,8 +151,8 @@ func AstrometricGeocentricEquatorialJ2000(date time.Time, elements Elements) Equ
// ApparentGeocentricEcliptic 地心视黄道坐标 / apparent geocentric ecliptic coordinates.
//
// 返回加入光行时与章动修正后的地心视黄经、黄纬和距离;角度单位度,距离单位 AU。
// Returns apparent geocentric ecliptic longitude, latitude, and distance after light-time and nutation corrections. Angles are in degrees and distance is in AU.
// 返回加入光行时、周年光行差与章动修正后的地心视黄经、黄纬和距离;角度单位度,距离单位 AU。
// Returns apparent geocentric ecliptic longitude, latitude, and distance after light-time, annual aberration, and nutation corrections. Angles are in degrees and distance is in AU.
func ApparentGeocentricEcliptic(date time.Time, elements Elements) EclipticPosition {
lon, lat, distance := basic.OrbitApparentGeocentricEcliptic(ttJulianDay(date), toBasicElements(elements))
return EclipticPosition{Lon: lon, Lat: lat, Distance: distance}
@@ -160,8 +160,8 @@ func ApparentGeocentricEcliptic(date time.Time, elements Elements) EclipticPosit
// ApparentGeocentricEquatorial 地心视赤道坐标 / apparent geocentric equatorial coordinates.
//
// 返回加入光行时与章动修正后的地心视赤经、赤纬和距离;角度单位度,距离单位 AU。
// Returns apparent geocentric right ascension, declination, and distance after light-time and nutation corrections. Angles are in degrees and distance is in AU.
// 返回加入光行时、周年光行差与章动修正后的地心视赤经、赤纬和距离;角度单位度,距离单位 AU。
// Returns apparent geocentric right ascension, declination, and distance after light-time, annual aberration, and nutation corrections. Angles are in degrees and distance is in AU.
func ApparentGeocentricEquatorial(date time.Time, elements Elements) EquatorialPosition {
ra, dec, distance := basic.OrbitApparentGeocentricEquatorial(ttJulianDay(date), toBasicElements(elements))
return EquatorialPosition{RA: ra, Dec: dec, Distance: distance}
@@ -169,9 +169,9 @@ func ApparentGeocentricEquatorial(date time.Time, elements Elements) EquatorialP
// ApparentTopocentricEquatorial 站心视赤道坐标 / apparent topocentric equatorial coordinates.
//
// 返回加入光行时、章动和站心修正后的视赤经、赤纬和距离;
// 返回加入光行时、周年光行差、章动和站心修正后的视赤经、赤纬和距离;
// `observerLon` 东经为正,`observerLat` 北纬为正,`observerHeight` 单位米。
// Returns apparent topocentric right ascension, declination, and distance after light-time, nutation, and topocentric corrections.
// Returns apparent topocentric right ascension, declination, and distance after light-time, annual aberration, nutation, and topocentric corrections.
func ApparentTopocentricEquatorial(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) EquatorialPosition {
ra, dec, distance := basic.OrbitApparentTopocentricEquatorial(ttJulianDay(date), observerLon, observerLat, observerHeight, toBasicElements(elements))
return EquatorialPosition{RA: ra, Dec: dec, Distance: distance}
+185 -3
View File
@@ -275,12 +275,12 @@ func TestSecularRatesReduceMarsDriftAgainstVSOP(t *testing.T) {
dynamicError := angleDiffAbs(dynamic.RA, wantRA) + math.Abs(dynamic.Dec-wantDec)
staticError := angleDiffAbs(stale.RA, wantRA) + math.Abs(stale.Dec-wantDec)
if dynamicError > staticError+1e-9 {
t.Fatalf("%s dynamic elements should improve Mars drift: dynamic=%.9f static=%.9f", date.Format("2006-01-02"), dynamicError, staticError)
}
if date.Equal(time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC)) {
continue
}
if dynamicError > staticError+1e-9 {
t.Fatalf("%s dynamic elements should improve Mars drift: dynamic=%.9f static=%.9f", date.Format("2006-01-02"), dynamicError, staticError)
}
if staticError/dynamicError < 4 {
t.Fatalf("%s Mars drift improvement too small: dynamic=%.9f static=%.9f", date.Format("2006-01-02"), dynamicError, staticError)
}
@@ -542,3 +542,185 @@ func sphericalAzimuthFromHourAngle(hourAngle, dec, lat float64) float64 {
}
return azimuth
}
func TestStateWithOptionsZeroMatchesState(t *testing.T) {
elements := Elements{
EpochJD: 2451545, A: 2.7, E: 0.08, I: 10, Omega: 80, W: 73, M0: 231,
}
for _, jd := range []float64{2451545, 2452000, 2449000} {
wantPos, wantVel, wantErr := State(elements, jd)
gotPos, gotVel, gotErr := StateWithOptions(elements, jd, PropagationOptions{})
if gotPos != wantPos || gotVel != wantVel || gotErr != wantErr {
t.Fatalf("jd %.1f: got %v %v %v, want %v %v %v", jd, gotPos, gotVel, gotErr, wantPos, wantVel, wantErr)
}
}
}
type staticPerturbationEngine struct{}
func (staticPerturbationEngine) State(target, center Body, jdTDB float64) (pos, vel [3]float64, err error) {
if target == center {
return pos, vel, nil
}
if center != 0 {
return pos, vel, ErrPerturbationEngine
}
switch target {
case 10:
return pos, vel, nil
case 5:
pos[0] = 778000000
return pos, vel, nil
default:
return pos, vel, ErrPerturbationEngine
}
}
func (staticPerturbationEngine) Coverage(target Body) (start, end float64, ok bool) {
return 0, 3000000, true
}
func TestStateWithOptionsPerturbationChangesState(t *testing.T) {
elements := Elements{EpochJD: 2451545, A: 1.5, E: 0.02, I: 3, Omega: 20, W: 30, M0: 10}
plain, _, err := State(elements, elements.EpochJD+30)
if err != nil {
t.Fatal(err)
}
perturbed, perturbedVelocity, err := StateWithOptions(elements, elements.EpochJD+30, PropagationOptions{
Perturbers: []Body{5},
Engine: staticPerturbationEngine{},
Step: 2,
Tolerance: 1e-10,
})
if err != nil {
t.Fatal(err)
}
if perturbed == plain {
t.Fatal("perturbation did not change the state")
}
if !finite(vectorNorm(perturbed)) || !finite(vectorNorm(perturbedVelocity)) || vectorNorm(perturbedVelocity) > 1e7 {
t.Fatalf("perturbed state is unstable: position=%v velocity=%v", perturbed, perturbedVelocity)
}
}
type countingPerturbationEngine struct {
calls map[Body]int
}
func (engine *countingPerturbationEngine) State(target, center Body, jdTDB float64) (pos, vel [3]float64, err error) {
if target == center {
return pos, vel, nil
}
engine.calls[target]++
if center != 0 {
return pos, vel, ErrPerturbationEngine
}
switch target {
case 10:
return pos, vel, nil
case 5:
pos[0] = 778000000
return pos, vel, nil
default:
return pos, vel, ErrPerturbationEngine
}
}
func (*countingPerturbationEngine) Coverage(target Body) (start, end float64, ok bool) {
return 0, 3000000, true
}
func TestStateWithOptionsCachesRepeatedSamples(t *testing.T) {
elements := Elements{EpochJD: 2451545, A: 1.5, E: 0.02, I: 3, Omega: 20, W: 30, M0: 10}
provider := &countingPerturbationEngine{calls: make(map[Body]int)}
_, _, err := StateWithOptions(elements, elements.EpochJD+1, PropagationOptions{
Perturbers: []Body{5},
Engine: provider,
Step: 1,
Tolerance: 1,
})
if err != nil {
t.Fatal(err)
}
for _, body := range []Body{5, 10} {
if got, want := provider.calls[body], 5; got != want {
t.Fatalf("body %d provider calls = %d, want %d", body, got, want)
}
}
}
func TestStateWithOptionsDeduplicatesPerturbers(t *testing.T) {
elements := Elements{EpochJD: 2451545, A: 1.5, E: 0.02, I: 3, Omega: 20, W: 30, M0: 10}
provider := &countingPerturbationEngine{calls: make(map[Body]int)}
singlePos, singleVel, err := StateWithOptions(elements, elements.EpochJD+10, PropagationOptions{
Perturbers: []Body{5},
Engine: provider,
Step: 2,
Tolerance: 1e-8,
})
if err != nil {
t.Fatal(err)
}
duplicatePos, duplicateVel, err := StateWithOptions(elements, elements.EpochJD+10, PropagationOptions{
Perturbers: []Body{5, 5, 10, 0},
Engine: provider,
Step: 2,
Tolerance: 1e-8,
})
if err != nil {
t.Fatal(err)
}
if duplicatePos != singlePos || duplicateVel != singleVel {
t.Fatalf("duplicate perturbers changed state: %v %v versus %v %v", duplicatePos, duplicateVel, singlePos, singleVel)
}
}
func TestStateWithOptionsRejectsNonFiniteTarget(t *testing.T) {
elements := Elements{EpochJD: 2451545, A: 1.5, E: 0.02, I: 3, Omega: 20, W: 30, M0: 10}
if _, _, err := StateWithOptions(elements, math.NaN(), PropagationOptions{}); err != ErrInvalidPropagationElements {
t.Fatalf("NaN target error = %v", err)
}
}
func TestEphemerisEngineSunPerturberKeepsTwoBodyPath(t *testing.T) {
elements := Elements{EpochJD: 2451545, A: 1.5, E: 0.02, I: 3, Omega: 20, W: 30, M0: 10}
engine := NewEphemerisEngine(Body(12345), elements, PropagationOptions{Perturbers: []Body{10}})
gotPos, gotVel, err := engine.State(Body(12345), 10, elements.EpochJD+10)
if err != nil {
t.Fatal(err)
}
wantPos, wantVel, err := State(elements, elements.EpochJD+10)
if err != nil {
t.Fatal(err)
}
if gotPos != wantPos || gotVel != wantVel {
t.Fatalf("Sun-only perturbation changed two-body state: %v %v versus %v %v", gotPos, gotVel, wantPos, wantVel)
}
}
func TestStateWithOptionsZeroMatchesExistingPositionMath(t *testing.T) {
cases := []Elements{
{EpochJD: 2451545, A: 2.7, E: 0.08, I: 10, Omega: 80, W: 73, M0: 231},
{Q: 0.9, E: 1, I: 30, Omega: 40, W: 50, TpJD: 2451545},
{Q: 1.2, E: 1.05, I: 30, Omega: 40, W: 50, TpJD: 2451545},
}
for _, elements := range cases {
jd := 2451545.0 + 20
got, _, err := StateWithOptions(elements, jd, PropagationOptions{})
if err != nil {
t.Fatal(err)
}
basicVector := basic.OrbitHeliocentricXYZJ2000(jd, toBasicElements(elements))
epsilon := basic.EclipticObliquity(2451545, false) * math.Pi / 180
want := [3]float64{
basicVector[0] * 149597870.7,
(basicVector[1]*math.Cos(epsilon) - basicVector[2]*math.Sin(epsilon)) * 149597870.7,
(basicVector[1]*math.Sin(epsilon) + basicVector[2]*math.Cos(epsilon)) * 149597870.7,
}
for index := range got {
if math.Abs(got[index]-want[index]) > 1e-6 {
t.Fatalf("elements %+v component %d = %.12f, want %.12f", elements, index, got[index], want[index])
}
}
}
}
+565
View File
@@ -0,0 +1,565 @@
package orbit
import (
"errors"
"fmt"
"math"
"sync"
"b612.me/astro/basic"
"b612.me/astro/internal/ephem"
)
const (
propagationAU = 149597870.7
propagationGaussianK = 0.01720209895
propagationSunGM = propagationGaussianK * propagationGaussianK * propagationAU * propagationAU * propagationAU
)
var (
// ErrInvalidPropagationElements 根数不能用于传播 / orbital elements cannot be propagated.
ErrInvalidPropagationElements = errors.New("orbit: invalid propagation elements")
// ErrPerturbationEngine 摄动传播缺少天体星历 / perturbation propagation requires an ephemeris.
ErrPerturbationEngine = errors.New("orbit: perturbation ephemeris is not configured")
// ErrUnsupportedPerturber 摄动天体未知或重复计入 / perturbing body is unknown or counted twice.
ErrUnsupportedPerturber = errors.New("orbit: unsupported perturbing body")
)
// maxPropagationNodes 单次传播允许的网格步数上限 / upper bound on grid steps of one propagation.
const maxPropagationNodes = 1 << 30
// Body NAIF 天体编号 / NAIF body identifier.
type Body = ephem.Body
// StateProvider 提供摄动天体状态 / supplies perturbing-body states.
type StateProvider = ephem.Engine
// PropagationOptions 可选摄动传播参数;零值保持纯二体 / optional perturbation propagation parameters; zero preserves two-body propagation.
type PropagationOptions struct {
Perturbers []Body // 摄动天体 / perturbing bodies.
Engine StateProvider // 摄动星历 / perturbation ephemeris.
Context StateProvider // 摄动星历别名 / alternate perturbation ephemeris.
Step float64 // 最大步长,单位日 / maximum step in days.
Tolerance float64 // 步长加倍的相对误差阈值 / relative step-doubling error threshold.
}
// State 计算日心 J2000 平黄道根数的二体状态,输出 ICRF km 与 km/日 / computes a two-body state from heliocentric J2000 ecliptic elements, returning ICRF km and km/day.
func State(elements Elements, jdTDB float64) (pos, vel [3]float64, err error) {
return twoBodyState(elements, jdTDB)
}
// StateWithOptions 按选项传播根数;零值与 State 完全一致 / propagates elements with optional perturbations; zero options exactly match State.
func StateWithOptions(elements Elements, jdTDB float64, options PropagationOptions) (pos, vel [3]float64, err error) {
if !finite(jdTDB) {
return pos, vel, ErrInvalidPropagationElements
}
perturbers, err := normalizePerturbers(options.Perturbers)
if err != nil {
return pos, vel, err
}
if len(perturbers) == 0 {
return twoBodyState(elements, jdTDB)
}
run, err := newPropagationRun(elements, perturbers, options)
if err != nil {
return pos, vel, err
}
return run.state(jdTDB, nil)
}
// propagationRun 固定一次传播的根数、天体与步长网格。
type propagationRun struct {
elements Elements
perturbers []Body
provider StateProvider
step float64
tolerance float64
epoch float64
start propagationCheckpoint
}
type propagationCheckpoint struct {
pos [3]float64
vel [3]float64
}
// propagationCheckpointStride 每隔多少个网格点缓存一次状态。
const propagationCheckpointStride = 32
// propagationCheckpoints 引擎缓存的网格状态,下标 j 对应 epoch±j·stride·step。
type propagationCheckpoints struct {
forward []propagationCheckpoint
backward []propagationCheckpoint
}
func newPropagationRun(elements Elements, perturbers []Body, options PropagationOptions) (*propagationRun, error) {
provider := options.Engine
if provider == nil {
provider = options.Context
}
if provider == nil {
return nil, ErrPerturbationEngine
}
epoch := propagationEpoch(elements)
// 经典根数漏填 EpochJD 时为 0,会从 JD 0 起积分数千年,按非法根数拒绝。
if !finite(epoch) || epoch == 0 {
return nil, ErrInvalidPropagationElements
}
pos, vel, err := twoBodyState(elements, epoch)
if err != nil {
return nil, err
}
step := options.Step
if !(step > 0) || !finite(step) {
step = 0.5
}
tolerance := options.Tolerance
if !(tolerance > 0) || !finite(tolerance) {
tolerance = 1e-6
}
return &propagationRun{
elements: elements,
perturbers: perturbers,
provider: provider,
step: step,
tolerance: tolerance,
epoch: epoch,
start: propagationCheckpoint{pos: pos, vel: vel},
}, nil
}
// propagationEpoch 积分起点:近日点形式给出 EpochJD 时取其为密切历元,否则用 TpJD / integration start: the osculating EpochJD when a perihelion-form set provides one, otherwise TpJD.
func propagationEpoch(elements Elements) float64 {
if usesPerihelionForm(elements) && (elements.EpochJD == 0 || !finite(elements.EpochJD)) {
return elements.TpJD
}
return elements.EpochJD
}
// state 先沿固定网格积分到不越过目标的最后一个格点,再积分余量;结果只取决于目标历元,与 cache 内容无关 / integrates along a fixed grid to the last node short of the target, then the remainder; the result depends only on the target, never on cache contents.
func (run *propagationRun) state(target float64, cache *propagationCheckpoints) (pos, vel [3]float64, err error) {
if target == run.epoch {
return run.start.pos, run.start.vel, nil
}
direction := 1.0
if target < run.epoch {
direction = -1
}
span := math.Abs(target - run.epoch)
count := math.Floor(span / run.step)
if count > maxPropagationNodes {
return pos, vel, ErrInvalidPropagationElements
}
index := int(count)
for index > 0 && math.Abs(run.node(index, direction)-run.epoch) > span {
index--
}
current, known := run.start, 0
var stored *[]propagationCheckpoint
if cache != nil {
stored = &cache.forward
if direction < 0 {
stored = &cache.backward
}
if len(*stored) == 0 {
*stored = append(*stored, run.start)
}
slot := index / propagationCheckpointStride
if slot > len(*stored)-1 {
slot = len(*stored) - 1
}
known = slot * propagationCheckpointStride
current = (*stored)[slot]
}
session := newPropagationSession(run.provider)
for k := known; k < index; k++ {
current.pos, current.vel, err = run.integrate(current.pos, current.vel, run.node(k, direction), run.node(k+1, direction), session)
if err != nil {
return current.pos, current.vel, err
}
if stored != nil && (k+1)%propagationCheckpointStride == 0 && len(*stored) == (k+1)/propagationCheckpointStride {
*stored = append(*stored, current)
}
}
from := run.node(index, direction)
if from == target {
return current.pos, current.vel, nil
}
return run.integrate(current.pos, current.vel, from, target, session)
}
func (run *propagationRun) node(index int, direction float64) float64 {
return run.epoch + direction*float64(index)*run.step
}
// integrate 自适应子步积分 [from, to],终点精确落在 to / adaptively integrates [from, to], landing exactly on to.
func (run *propagationRun) integrate(pos, vel [3]float64, from, to float64, session *propagationSession) (outPos, outVel [3]float64, err error) {
outPos, outVel = pos, vel
current := from
for current != to {
dt := to - current
var used float64
outPos, outVel, used, err = propagationAdaptiveStep(outPos, outVel, current, dt, run.elements, run.perturbers, session, run.tolerance)
if err != nil {
return outPos, outVel, err
}
if used == dt {
current = to
} else {
current += used
}
}
return outPos, outVel, nil
}
func propagationAdaptiveStep(pos, vel [3]float64, jd, dt float64, elements Elements, perturbers []Body, session *propagationSession, tolerance float64) (outPos, outVel [3]float64, used float64, err error) {
trial := dt
lastError := math.Inf(1)
for attempts := 0; attempts < 20; attempts++ {
fullPos, fullVel, fullErr := propagationRK4(pos, vel, jd, trial, elements, perturbers, session)
if fullErr != nil {
return pos, vel, trial, fullErr
}
halfPos, halfVel, halfErr := propagationRK4(pos, vel, jd, trial/2, elements, perturbers, session)
if halfErr != nil {
return pos, vel, trial, halfErr
}
halfPos, halfVel, halfErr = propagationRK4(halfPos, halfVel, jd+trial/2, trial/2, elements, perturbers, session)
if halfErr != nil {
return pos, vel, trial, halfErr
}
error := 0.0
for index := range fullPos {
positionScale := math.Max(1, math.Max(math.Abs(fullPos[index]), math.Abs(halfPos[index])))
velocityScale := math.Max(1, math.Max(math.Abs(fullVel[index]), math.Abs(halfVel[index])))
error = math.Max(error, math.Abs(fullPos[index]-halfPos[index])/(15*positionScale))
error = math.Max(error, math.Abs(fullVel[index]-halfVel[index])/(15*velocityScale))
}
lastError = error
if error <= tolerance || math.Abs(trial) <= 1e-8 {
return halfPos, halfVel, trial, nil
}
trial /= 2
}
return pos, vel, trial, fmt.Errorf("orbit: propagation step did not converge (dt=%g error=%g tolerance=%g)", trial, lastError, tolerance)
}
type propagationSession struct {
provider StateProvider
states map[propagationStateKey]propagationSample
}
type propagationStateKey struct {
body Body
jd uint64
}
type propagationSample struct {
pos [3]float64
vel [3]float64
err error
}
func newPropagationSession(provider StateProvider) *propagationSession {
return &propagationSession{
provider: provider,
states: make(map[propagationStateKey]propagationSample),
}
}
func (session *propagationSession) state(body Body, jd float64) (pos, vel [3]float64, err error) {
key := propagationStateKey{body: body, jd: math.Float64bits(jd)}
if sample, ok := session.states[key]; ok {
return sample.pos, sample.vel, sample.err
}
pos, vel, err = session.provider.State(body, ephem.SSB, jd)
session.states[key] = propagationSample{pos: pos, vel: vel, err: err}
return pos, vel, err
}
// EphemerisEngine 根数传播的只读星历引擎,结果与 StateWithOptions 逐位一致且不受查询顺序影响 / read-only engine over propagated elements.
type EphemerisEngine struct {
target Body
elements Elements
options PropagationOptions
setupErr error
run *propagationRun
mu sync.Mutex
cache propagationCheckpoints
}
// NewEphemerisEngine 创建根数传播星历引擎;选项无效时 State 返回对应错误 / creates an ephemeris engine backed by propagated elements; invalid options surface from State.
func NewEphemerisEngine(target Body, elements Elements, options PropagationOptions) *EphemerisEngine {
engine := &EphemerisEngine{target: target, elements: elements}
options.Perturbers, engine.setupErr = normalizePerturbers(options.Perturbers)
engine.options = options
if engine.setupErr == nil && len(options.Perturbers) != 0 {
engine.run, engine.setupErr = newPropagationRun(elements, options.Perturbers, options)
}
return engine
}
// Target 返回传播目标 / returns the propagated target.
func (engine *EphemerisEngine) Target() Body { return engine.target }
// Elements 返回传播根数 / returns the propagation elements.
func (engine *EphemerisEngine) Elements() Elements { return engine.elements }
// State 返回传播目标相对指定中心的状态 / returns propagated target state relative to a center.
func (engine *EphemerisEngine) State(target, center Body, jdTDB float64) (pos, vel [3]float64, err error) {
if engine == nil || target != engine.target {
if target == center {
return pos, vel, nil
}
return pos, vel, ephem.ErrUnsupportedBody
}
if target == center {
return pos, vel, nil
}
if center != ephem.Sun && center != ephem.SSB {
return pos, vel, ephem.ErrUnsupportedBody
}
if engine.setupErr != nil {
return pos, vel, engine.setupErr
}
if !finite(jdTDB) {
return pos, vel, ErrInvalidPropagationElements
}
if engine.run == nil {
pos, vel, err = twoBodyState(engine.elements, jdTDB)
} else {
engine.mu.Lock()
pos, vel, err = engine.run.state(jdTDB, &engine.cache)
engine.mu.Unlock()
}
if err != nil {
return pos, vel, err
}
if center == ephem.Sun {
return pos, vel, nil
}
provider := engine.options.Engine
if provider == nil {
provider = engine.options.Context
}
if provider == nil {
return pos, vel, ErrPerturbationEngine
}
sunPos, sunVel, sunErr := provider.State(ephem.Sun, ephem.SSB, jdTDB)
if sunErr != nil {
return pos, vel, sunErr
}
for index := range pos {
pos[index] += sunPos[index]
vel[index] += sunVel[index]
}
return pos, vel, nil
}
// Coverage 返回传播引擎的数学覆盖范围 / returns the mathematical coverage of the propagator.
func (engine *EphemerisEngine) Coverage(target Body) (start, end float64, ok bool) {
if engine == nil || target != engine.target {
return 0, 0, false
}
return math.Inf(-1), math.Inf(1), true
}
func propagationRK4(pos, vel [3]float64, jd, dt float64, elements Elements, perturbers []Body, session *propagationSession) ([3]float64, [3]float64, error) {
p1, v1, err := propagationDerivative(pos, vel, jd, elements, perturbers, session)
if err != nil {
return pos, vel, err
}
p2 := vectorAdd(pos, vectorScale(p1, dt/2))
v2 := vectorAdd(vel, vectorScale(v1, dt/2))
p2d, v2d, err := propagationDerivative(p2, v2, jd+dt/2, elements, perturbers, session)
if err != nil {
return pos, vel, err
}
p3 := vectorAdd(pos, vectorScale(p2d, dt/2))
v3 := vectorAdd(vel, vectorScale(v2d, dt/2))
p3d, v3d, err := propagationDerivative(p3, v3, jd+dt/2, elements, perturbers, session)
if err != nil {
return pos, vel, err
}
p4 := vectorAdd(pos, vectorScale(p3d, dt))
v4 := vectorAdd(vel, vectorScale(v3d, dt))
p4d, v4d, err := propagationDerivative(p4, v4, jd+dt, elements, perturbers, session)
if err != nil {
return pos, vel, err
}
return vectorAdd(pos, vectorScale(vectorAdd(vectorAdd(p1, vectorScale(p2d, 2)), vectorAdd(vectorScale(p3d, 2), p4d)), dt/6)), vectorAdd(vel, vectorScale(vectorAdd(vectorAdd(v1, vectorScale(v2d, 2)), vectorAdd(vectorScale(v3d, 2), v4d)), dt/6)), nil
}
func propagationDerivative(pos, vel [3]float64, jd float64, elements Elements, perturbers []Body, session *propagationSession) ([3]float64, [3]float64, error) {
radius := vectorNorm(pos)
if !(radius > 0) || !finite(radius) {
return vel, [3]float64{}, ErrInvalidPropagationElements
}
acc := vectorScale(pos, -propagationSunGM/math.Pow(radius, 3))
sunPos, _, err := session.state(ephem.Sun, jd)
if err != nil {
return vel, acc, err
}
for _, body := range perturbers {
if body == ephem.Sun || body == ephem.SSB {
continue
}
bodyPos, _, bodyErr := session.state(body, jd)
if bodyErr != nil {
return vel, acc, bodyErr
}
relBody := vectorSub(bodyPos, sunPos)
toBody := vectorSub(relBody, pos)
mu := perturbingGM(body)
if vectorNorm(relBody) == 0 || vectorNorm(toBody) == 0 {
return vel, acc, ErrInvalidPropagationElements
}
acc = vectorAdd(acc, vectorScale(toBody, mu/math.Pow(vectorNorm(toBody), 3)))
acc = vectorAdd(acc, vectorScale(relBody, -mu/math.Pow(vectorNorm(relBody), 3)))
}
return vel, acc, nil
}
func perturbingGM(body Body) float64 {
const secondPerDay = 86400.0
var gm float64
switch body {
case ephem.Mercury:
gm = 22031.86855
case ephem.Venus:
gm = 324858.592
case ephem.Earth:
gm = 398600.435436
case ephem.Moon:
gm = 4902.800066
case ephem.EMB:
gm = 398600.435436 + 4902.800066
case ephem.Mars:
gm = 42828.375214
case ephem.Jupiter:
gm = 126686534.911
case ephem.Saturn:
gm = 37931207.8
case ephem.Uranus:
gm = 5793966.0
case ephem.Neptune:
gm = 6835100.0
}
return gm * secondPerDay * secondPerDay
}
// normalizePerturbers 去重并跳过太阳与 SSB;未知天体或 EMB 与地球/月球同时出现时报错 / dedups and skips Sun and SSB; rejects unknown bodies and EMB combined with Earth or Moon.
func normalizePerturbers(perturbers []Body) ([]Body, error) {
if len(perturbers) == 0 {
return nil, nil
}
seen := make(map[Body]struct{}, len(perturbers))
result := make([]Body, 0, len(perturbers))
for _, body := range perturbers {
if body == ephem.Sun || body == ephem.SSB {
continue
}
if perturbingGM(body) == 0 {
return nil, fmt.Errorf("%w: %d", ErrUnsupportedPerturber, body)
}
if _, ok := seen[body]; ok {
continue
}
seen[body] = struct{}{}
result = append(result, body)
}
_, hasEMB := seen[ephem.EMB]
_, hasEarth := seen[ephem.Earth]
_, hasMoon := seen[ephem.Moon]
if hasEMB && (hasEarth || hasMoon) {
return nil, fmt.Errorf("%w: EMB already includes Earth and Moon", ErrUnsupportedPerturber)
}
return result, nil
}
func twoBodyState(elements Elements, jd float64) (pos, vel [3]float64, err error) {
if !finite(jd) {
return pos, vel, ErrInvalidPropagationElements
}
basicElements := toBasicElements(elements)
trueAnomaly := basic.OrbitTrueAnomaly(jd, basicElements)
if math.IsNaN(trueAnomaly) {
return pos, vel, ErrInvalidPropagationElements
}
resolved := elementsAt(elements, jd)
eccentricity := resolved.E
semiLatus := resolved.A * (1 - eccentricity*eccentricity)
if usesPerihelionForm(elements) {
semiLatus = resolved.Q * (1 + eccentricity)
}
if !(semiLatus > 0) || !finite(semiLatus) {
return pos, vel, ErrInvalidPropagationElements
}
nu := trueAnomaly * math.Pi / 180
mu := propagationGaussianK * propagationGaussianK
radius := semiLatus / (1 + eccentricity*math.Cos(nu))
if !(radius > 0) || !finite(radius) {
return pos, vel, ErrInvalidPropagationElements
}
x := radius * math.Cos(nu)
y := radius * math.Sin(nu)
speedScale := math.Sqrt(mu / semiLatus)
vx := -speedScale * math.Sin(nu)
vy := speedScale * (eccentricity + math.Cos(nu))
posAU, velAU := rotatePerifocal(x, y, vx, vy, resolved)
return rotateEquatorial(posAU, propagationAU), rotateEquatorial(velAU, propagationAU), nil
}
func elementsAt(elements Elements, jd float64) Elements {
if usesPerihelionForm(elements) || !finite(elements.EpochJD) {
return elements
}
delta := jd - elements.EpochJD
elements.A += elements.ADot * delta
elements.E += elements.EDot * delta
elements.I += elements.IDot * delta
elements.Omega += elements.OmegaDot * delta
elements.W += elements.WDot * delta
return elements
}
func usesPerihelionForm(elements Elements) bool { return elements.Q > 0 && finite(elements.TpJD) }
func rotatePerifocal(x, y, vx, vy float64, elements Elements) ([3]float64, [3]float64) {
omega := elements.Omega * math.Pi / 180
argument := elements.W * math.Pi / 180
inclination := elements.I * math.Pi / 180
sinOmega, cosOmega := math.Sincos(omega)
sinArgument, cosArgument := math.Sincos(argument)
sinInclination, cosInclination := math.Sincos(inclination)
basisX := [3]float64{cosOmega*cosArgument - sinOmega*sinArgument*cosInclination, sinOmega*cosArgument + cosOmega*sinArgument*cosInclination, sinArgument * sinInclination}
basisY := [3]float64{-cosOmega*sinArgument - sinOmega*cosArgument*cosInclination, -sinOmega*sinArgument + cosOmega*cosArgument*cosInclination, cosArgument * sinInclination}
return vectorAdd(vectorScale(basisX, x), vectorScale(basisY, y)), vectorAdd(vectorScale(basisX, vx), vectorScale(basisY, vy))
}
var sinJ2000Obliquity, cosJ2000Obliquity = math.Sincos(basic.EclipticObliquity(2451545.0, false) * math.Pi / 180)
func rotateEquatorial(value [3]float64, scale float64) [3]float64 {
sinEps, cosEps := sinJ2000Obliquity, cosJ2000Obliquity
return [3]float64{value[0] * scale, (value[1]*cosEps - value[2]*sinEps) * scale, (value[1]*sinEps + value[2]*cosEps) * scale}
}
func vectorAdd(left, right [3]float64) [3]float64 {
return [3]float64{left[0] + right[0], left[1] + right[1], left[2] + right[2]}
}
func vectorSub(left, right [3]float64) [3]float64 {
return [3]float64{left[0] - right[0], left[1] - right[1], left[2] - right[2]}
}
func vectorScale(value [3]float64, scale float64) [3]float64 {
return [3]float64{value[0] * scale, value[1] * scale, value[2] * scale}
}
func vectorNorm(value [3]float64) float64 {
return math.Sqrt(value[0]*value[0] + value[1]*value[1] + value[2]*value[2])
}
func finite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) }
var _ StateProvider = (*EphemerisEngine)(nil)
+147
View File
@@ -0,0 +1,147 @@
package orbit
import (
"errors"
"math"
"testing"
)
// movingPerturbationEngine 提供随时间变化的太阳与行星位置 / supplies time-varying Sun and planet positions.
type movingPerturbationEngine struct{}
func (movingPerturbationEngine) State(target, center Body, jdTDB float64) (pos, vel [3]float64, err error) {
if target == center {
return pos, vel, nil
}
if center != 0 {
return pos, vel, ErrPerturbationEngine
}
angle := func(period float64) float64 { return 2 * math.Pi * (jdTDB - 2451545) / period }
switch target {
case 10:
phase := angle(4332.6)
pos = [3]float64{-742000 * math.Cos(phase), -742000 * math.Sin(phase), 0}
case 3:
phase := angle(365.25)
pos = [3]float64{149597870 * math.Cos(phase), 149597870 * math.Sin(phase), 0}
case 5:
phase := angle(4332.6)
pos = [3]float64{778000000 * math.Cos(phase), 778000000 * math.Sin(phase), 0}
default:
return pos, vel, ErrPerturbationEngine
}
return pos, vel, nil
}
func (movingPerturbationEngine) Coverage(target Body) (start, end float64, ok bool) {
return 0, 3000000, true
}
func TestEphemerisEngineMatchesFreshPropagationInAnyOrder(t *testing.T) {
elements := Elements{EpochJD: 2451545, A: 1.5, E: 0.02, I: 3, Omega: 20, W: 30, M0: 10}
options := PropagationOptions{Perturbers: []Body{5, 3}, Engine: movingPerturbationEngine{}, Step: 0.7, Tolerance: 1e-9}
queries := []float64{2451545 + 400.3, 2451545 + 12.25, 2451545 - 90.1, 2451545 + 400.3, 2451545 + 33.6, 2451545 - 3.5, 2451545, 2451545 + 1000}
engine := NewEphemerisEngine(Body(12345), elements, options)
for _, jd := range queries {
gotPos, gotVel, err := engine.State(Body(12345), 10, jd)
if err != nil {
t.Fatal(err)
}
wantPos, wantVel, err := StateWithOptions(elements, jd, options)
if err != nil {
t.Fatal(err)
}
if gotPos != wantPos || gotVel != wantVel {
t.Fatalf("jd %.2f: engine %v %v, fresh %v %v", jd, gotPos, gotVel, wantPos, wantVel)
}
}
// 逆序查询同样逐位一致 / reverse order also matches bit for bit.
reverse := NewEphemerisEngine(Body(12345), elements, options)
for index := len(queries) - 1; index >= 0; index-- {
gotPos, _, err := reverse.State(Body(12345), 10, queries[index])
if err != nil {
t.Fatal(err)
}
wantPos, _, _ := StateWithOptions(elements, queries[index], options)
if gotPos != wantPos {
t.Fatalf("reverse jd %.2f: engine %v, fresh %v", queries[index], gotPos, wantPos)
}
}
}
func TestEphemerisEngineKeepsConstructionElements(t *testing.T) {
elements := Elements{EpochJD: 2451545, A: 1.5, E: 0.02, I: 3, Omega: 20, W: 30, M0: 10}
options := PropagationOptions{Perturbers: []Body{5}, Engine: movingPerturbationEngine{}, Step: 1}
engine := NewEphemerisEngine(Body(12345), elements, options)
if _, _, err := engine.State(Body(12345), 10, 2451545+50); err != nil {
t.Fatal(err)
}
options.Perturbers[0] = 3
got := engine.Elements()
got.A = 9
gotPos, _, err := engine.State(Body(12345), 10, 2451545+80)
if err != nil {
t.Fatal(err)
}
wantPos, _, _ := StateWithOptions(elements, 2451545+80, PropagationOptions{Perturbers: []Body{5}, Engine: movingPerturbationEngine{}, Step: 1})
if gotPos != wantPos || engine.Elements() != elements || engine.Target() != Body(12345) {
t.Fatalf("engine state changed after caller mutation: %v versus %v", gotPos, wantPos)
}
}
func TestStateWithOptionsPerihelionFormStartsAtOsculatingEpoch(t *testing.T) {
elements := Elements{EpochJD: 2451545 + 120, Q: 0.6, E: 0.9, I: 30, Omega: 40, W: 50, TpJD: 2451545}
options := PropagationOptions{Perturbers: []Body{5}, Engine: movingPerturbationEngine{}, Step: 1, Tolerance: 1e-10}
gotPos, gotVel, err := StateWithOptions(elements, elements.EpochJD, options)
if err != nil {
t.Fatal(err)
}
wantPos, wantVel, err := State(elements, elements.EpochJD)
if err != nil {
t.Fatal(err)
}
if gotPos != wantPos || gotVel != wantVel {
t.Fatalf("osculating epoch state %v %v, want two-body %v %v", gotPos, gotVel, wantPos, wantVel)
}
// 未给 EpochJD 时仍以 TpJD 为起点 / without EpochJD the integration still starts at TpJD.
noEpoch := elements
noEpoch.EpochJD = 0
gotPos, _, err = StateWithOptions(noEpoch, noEpoch.TpJD, options)
if err != nil {
t.Fatal(err)
}
wantPos, _, _ = State(noEpoch, noEpoch.TpJD)
if gotPos != wantPos {
t.Fatalf("TpJD start state %v, want %v", gotPos, wantPos)
}
}
func TestStateWithOptionsRejectsUnsupportedPerturbers(t *testing.T) {
elements := Elements{EpochJD: 2451545, A: 1.5, E: 0.02, I: 3, Omega: 20, W: 30, M0: 10}
for _, perturbers := range [][]Body{{5, 9}, {2000001}, {3, 399}, {301, 3}} {
_, _, err := StateWithOptions(elements, elements.EpochJD+5, PropagationOptions{Perturbers: perturbers, Engine: movingPerturbationEngine{}})
if !errors.Is(err, ErrUnsupportedPerturber) {
t.Fatalf("perturbers %v error = %v, want ErrUnsupportedPerturber", perturbers, err)
}
engine := NewEphemerisEngine(Body(12345), elements, PropagationOptions{Perturbers: perturbers, Engine: movingPerturbationEngine{}})
if _, _, err := engine.State(Body(12345), 10, elements.EpochJD+5); !errors.Is(err, ErrUnsupportedPerturber) {
t.Fatalf("engine perturbers %v error = %v, want ErrUnsupportedPerturber", perturbers, err)
}
}
plain, _, _ := State(elements, elements.EpochJD+30)
withEMB, _, err := StateWithOptions(elements, elements.EpochJD+30, PropagationOptions{Perturbers: []Body{3}, Engine: movingPerturbationEngine{}, Tolerance: 1e-10})
if err != nil {
t.Fatal(err)
}
if withEMB == plain {
t.Fatal("EMB perturbation did not change the state")
}
}
func TestStateWithOptionsRejectsMissingClassicalEpoch(t *testing.T) {
elements := Elements{A: 1.5, E: 0.02, I: 3, Omega: 20, W: 30, M0: 10}
_, _, err := StateWithOptions(elements, 2451545, PropagationOptions{Perturbers: []Body{3}, Engine: movingPerturbationEngine{}})
if !errors.Is(err, ErrInvalidPropagationElements) {
t.Fatalf("error = %v, want ErrInvalidPropagationElements", err)
}
}