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
+45
View File
@@ -0,0 +1,45 @@
package spk
import (
"math"
"os"
"testing"
"b612.me/astro/internal/ephem"
"b612.me/astro/planet/internal/semianalytic"
)
// SPK 只给几何状态,实例应走通用矢量视位置链;与按 DE441 拟合的内置表相比只剩表的拟合误差。
func TestSPKInstanceApparentMatchesSemianalytic(t *testing.T) {
path := de441Path()
if _, err := os.Stat(path); err != nil {
t.Skip("DE441 file not available; set ASTRO_DE441_BSP")
}
file, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
inst := ephem.NewInstance(file)
worst := 0.0
for _, jd := range []float64{2451545.0, 2460409.25, 2470000.5} {
for _, b := range []ephem.Body{ephem.Sun, ephem.Moon, ephem.Mercury, ephem.Venus, ephem.Mars, ephem.Jupiter, ephem.Saturn, ephem.Uranus, ephem.Neptune} {
got, gd, _, err := inst.Apparent(b, jd)
if err != nil {
t.Fatalf("jd %.1f body %d: %v", jd, b, err)
}
want, wd, _, err := semianalytic.Ephemeris{}.Apparent(b, jd)
if err != nil {
t.Fatal(err)
}
c := got[0]*want[0] + got[1]*want[1] + got[2]*want[2]
arcsec := math.Acos(math.Min(1, c)) * 206264.806
worst = math.Max(worst, arcsec)
// 内置表的几何距离取自快速档,外行星可差数万千米。
if arcsec > 1.5 || math.Abs(gd-wd)/wd > 5e-5 {
t.Errorf("jd %.1f body %d: %.4f\" dist rel %.2e", jd, b, arcsec, (gd-wd)/wd)
}
}
}
t.Logf("worst %.4f\"", worst)
}
+84
View File
@@ -0,0 +1,84 @@
// 被测契约:畸形阶数与非有限系数报 ErrInvalidFile 而不是 panic 或返回 NaN;无关段的覆盖缺口不阻断有效路径。
package spk
import (
"encoding/binary"
"errors"
"math"
"testing"
"b612.me/astro/internal/ephem"
)
func type21Record(maxdim, kqmax1, kq int) []float64 {
record := make([]float64, 4*maxdim+12)
record[0] = float64(maxdim)
for index := 0; index < maxdim; index++ {
record[2+index] = 1
}
record[4*maxdim+8] = float64(kqmax1)
for component := 0; component < 3; component++ {
record[4*maxdim+9+component] = float64(kq)
}
return record
}
func TestType21RejectsInconsistentOrders(t *testing.T) {
cases := []struct{ maxdim, kqmax1, kq int }{
{15, 3, 15},
{15, 27, 1},
{15, 17, 1},
{15, 5, 5},
}
for _, c := range cases {
_, _, err := evaluateType21(0, type21Record(c.maxdim, c.kqmax1, c.kq))
if !errors.Is(err, ErrInvalidFile) {
t.Errorf("maxdim=%d kqmax1=%d kq=%d: err = %v, want %v", c.maxdim, c.kqmax1, c.kq, err, ErrInvalidFile)
}
}
if _, _, err := evaluateType21(0, type21Record(15, 16, 15)); err != nil {
t.Errorf("maximal valid orders: err = %v", err)
}
}
func TestType21RejectsNonFiniteRecord(t *testing.T) {
record := type21Record(15, 3, 1)
record[15+2] = math.NaN()
if _, _, err := evaluateType21(0, record); !errors.Is(err, ErrInvalidFile) {
t.Errorf("err = %v, want %v", err, ErrInvalidFile)
}
}
func TestType2RejectsNonFiniteCoefficient(t *testing.T) {
path := writeTestSPK(t, binary.LittleEndian, "LTL-IEEE",
testSPKSegment{target: Mars, center: Sun, frame: 1, typ: 2, start: 0, end: 10, words: type2Words(0, 10, []float64{5, 5, math.NaN(), 20, 30})},
)
file, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
if _, _, err := file.State(Mars, Sun, spkJ2000+5/spkSecondsDay); !errors.Is(err, ErrInvalidFile) {
t.Errorf("err = %v, want %v", err, ErrInvalidFile)
}
}
func TestUnrelatedSegmentCoverageDoesNotBlockDirectPath(t *testing.T) {
path := writeTestSPK(t, binary.LittleEndian, "LTL-IEEE",
testSPKSegment{target: Mars, center: Sun, frame: 1, typ: 2, start: 0, end: 10, words: type2Words(0, 10, []float64{5, 5, 10, 20, 30})},
testSPKSegment{target: Sun, center: SSB, frame: 1, typ: 2, start: 100, end: 110, words: type2Words(100, 10, []float64{105, 5, 1, 2, 3})},
)
file, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
pos, _, err := file.State(Mars, Sun, spkJ2000+5/spkSecondsDay)
if err != nil {
t.Fatalf("Mars→Sun err = %v", err)
}
assertVector(t, pos, [3]float64{10, 20, 30})
if _, _, err := file.State(Mars, SSB, spkJ2000+5/spkSecondsDay); err != ephem.ErrOutOfRange {
t.Errorf("Mars→SSB err = %v, want %v", err, ephem.ErrOutOfRange)
}
}
+844
View File
@@ -0,0 +1,844 @@
package spk
import (
"encoding/binary"
"errors"
"fmt"
"math"
"os"
"sync"
"b612.me/astro/internal/ephem"
)
const (
dafRecordBytes = 1024
dafFileHeadSize = 1024
dafND = 2
dafNI = 6
dafSummaryWords = dafND + (dafNI+1)/2
dafSummaryBytes = dafSummaryWords * 8
dafHeaderWords = 3
dafWordBytes = 8
spkJ2000 = 2451545.0
spkSecondsDay = 86400.0
spkCacheRecords = 16
)
var (
// ErrInvalidFile 无效 SPK 文件 / invalid SPK file.
ErrInvalidFile = errors.New("spk: invalid DAF/SPK file")
// ErrUnsupportedFrame 不支持的参考框架 / unsupported reference frame.
ErrUnsupportedFrame = errors.New("spk: unsupported reference frame")
// ErrUnsupportedType 不支持的段类型 / unsupported segment type.
ErrUnsupportedType = errors.New("spk: unsupported SPK data type")
// ErrClosed 文件已关闭 / file is closed.
ErrClosed = errors.New("spk: file is closed")
errInvalidDAF = ErrInvalidFile
errClosed = ErrClosed
)
// Body NAIF 天体编号 / NAIF body identifier.
type Body = ephem.Body
const (
// SSB 太阳系质心 / solar-system barycenter.
SSB = ephem.SSB
// Mercury 水星 / Mercury.
Mercury = ephem.Mercury
// Venus 金星 / Venus.
Venus = ephem.Venus
// EMB 地月质心 / Earth-Moon barycenter.
EMB = ephem.EMB
// Mars 火星 / Mars.
Mars = ephem.Mars
// Jupiter 木星 / Jupiter.
Jupiter = ephem.Jupiter
// Saturn 土星 / Saturn.
Saturn = ephem.Saturn
// Uranus 天王星 / Uranus.
Uranus = ephem.Uranus
// Neptune 海王星 / Neptune.
Neptune = ephem.Neptune
// Sun 太阳 / the Sun.
Sun = ephem.Sun
// Moon 月球 / the Moon.
Moon = ephem.Moon
// Earth 地球 / Earth.
Earth = ephem.Earth
)
// Segment 描述一个支持的 SPK 段 / describes a supported SPK segment.
type Segment struct {
Target Body
Center Body
Frame int
Type int
Start float64
End float64
}
type summarySegment struct {
target Body
center Body
frame int
dataType int
startET float64
endET float64
startWord int64
endWord int64
initET float64
interval float64
recordSize int
records int
maxDim int
distanceScale float64
timeScale float64
initJD float64
initFR float64
epochStartWord int64
directoryCount int
cacheMu sync.Mutex
cache map[int][]float64
cacheOrder []int
owner *File
}
// File 只读 SPK 文件 / read-only SPK file.
type File struct {
mu sync.RWMutex
file *os.File
order binary.ByteOrder
segments []*summarySegment
known map[Body]bool
closed bool
size int64
}
// Open 打开并解析 SPK 文件 / opens and indexes an SPK file.
func Open(path string) (*File, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
spk := &File{file: f, known: make(map[Body]bool)}
stat, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, err
}
spk.size = stat.Size()
if err := spk.index(); err != nil {
_ = f.Close()
return nil, err
}
return spk, nil
}
// Load Open 的别名 / alias for Open.
func Load(path string) (*File, error) { return Open(path) }
// Close 关闭文件 / closes the file.
func (s *File) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil
}
s.closed = true
return s.file.Close()
}
// Segments 返回支持段的快照 / returns a snapshot of supported segments.
func (s *File) Segments() []Segment {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]Segment, 0, len(s.segments))
for _, segment := range s.segments {
result = append(result, Segment{
Target: segment.target,
Center: segment.center,
Frame: segment.frame,
Type: segment.dataType,
Start: spkJ2000 + segment.startET/spkSecondsDay,
End: spkJ2000 + segment.endET/spkSecondsDay,
})
}
return result
}
// Coverage 返回天体覆盖的 TDB 儒略日范围 / returns the body's TDB Julian-date coverage.
func (s *File) Coverage(target Body) (start, end float64, ok bool) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.closed {
return 0, 0, false
}
for _, segment := range s.segments {
if segment.target != target || segment.frame != 1 {
continue
}
segmentStart := spkJ2000 + segment.startET/spkSecondsDay
segmentEnd := spkJ2000 + segment.endET/spkSecondsDay
if !ok || segmentStart < start {
start = segmentStart
}
if !ok || segmentEnd > end {
end = segmentEnd
}
ok = true
}
return start, end, ok
}
// Bodies 返回文件中出现的天体编号 / returns body identifiers present in the file.
func (s *File) Bodies() []Body {
s.mu.RLock()
defer s.mu.RUnlock()
if s.closed {
return nil
}
result := make([]Body, 0, len(s.known))
for body := range s.known {
result = append(result, body)
}
return result
}
// State 返回目标相对中心的 ICRF 状态,速度为 km/日 / returns an ICRF state, with velocity in km/day.
func (s *File) State(target, center Body, jdTDB float64) (pos, vel [3]float64, err error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.closed {
return pos, vel, errClosed
}
if math.IsNaN(jdTDB) || math.IsInf(jdTDB, 0) {
return pos, vel, ephem.ErrOutOfRange
}
if target == center {
return pos, vel, nil
}
return s.composeState(target, center, jdTDB)
}
func (s *File) index() error {
header := make([]byte, dafFileHeadSize)
if _, err := s.file.ReadAt(header, 0); err != nil {
return err
}
if string(header[:8]) != "DAF/SPK " {
return errInvalidDAF
}
order, err := byteOrderFor(string(header[88:96]))
if err != nil {
return err
}
s.order = order
if int32Value(s.order, header, 8) != dafND || int32Value(s.order, header, 12) != dafNI {
return fmt.Errorf("%w: ND/NI must be 2/6", errInvalidDAF)
}
first := int(int32Value(s.order, header, 76))
last := int(int32Value(s.order, header, 80))
if first < 2 || last < 2 || int64(first)*dafRecordBytes > s.size || int64(last)*dafRecordBytes > s.size {
return fmt.Errorf("%w: invalid summary record range", errInvalidDAF)
}
seen := make(map[int]bool)
previous := 0
supported := 0
for record := first; record != 0; {
if seen[record] {
return fmt.Errorf("%w: cyclic summary record chain", errInvalidDAF)
}
seen[record] = true
data, err := s.readRecord(record)
if err != nil {
return err
}
nextValue := float64Value(s.order, data[0:8])
previousValue := float64Value(s.order, data[8:16])
countValue := float64Value(s.order, data[16:24])
if !validInteger(nextValue, 0, s.size/dafRecordBytes) || previousValue != float64(previous) || !validInteger(countValue, 0, (dafRecordBytes-24)/dafSummaryBytes) {
return fmt.Errorf("%w: invalid summary controls", errInvalidDAF)
}
next, count := int(nextValue), int(countValue)
if next == 0 && record != last {
return fmt.Errorf("%w: final summary disagrees with backward pointer", errInvalidDAF)
}
for index := 0; index < count; index++ {
base := dafHeaderWords*8 + index*dafSummaryBytes
segment, err := s.parseSummary(data[base : base+dafSummaryBytes])
if err != nil {
return err
}
if segment.frame == 1 && (segment.dataType == 2 || segment.dataType == 3 || segment.dataType == 20 || segment.dataType == 21) {
if err := segment.loadMeta(s); err != nil {
return err
}
supported++
} else {
continue
}
s.segments = append(s.segments, segment)
s.known[segment.target] = true
s.known[segment.center] = true
}
previous, record = record, next
}
if supported == 0 {
return fmt.Errorf("%w: no supported J2000 segments", errInvalidDAF)
}
return nil
}
func (s *File) parseSummary(data []byte) (*summarySegment, error) {
if len(data) != dafSummaryBytes {
return nil, errInvalidDAF
}
segment := &summarySegment{
startET: float64Value(s.order, data[0:8]),
endET: float64Value(s.order, data[8:16]),
target: Body(int32Value(s.order, data, 16)),
center: Body(int32Value(s.order, data, 20)),
frame: int(int32Value(s.order, data, 24)),
dataType: int(int32Value(s.order, data, 28)),
startWord: int64(int32Value(s.order, data, 32)),
endWord: int64(int32Value(s.order, data, 36)),
cache: make(map[int][]float64),
owner: s,
}
if !finite(segment.startET) || !finite(segment.endET) || segment.endET < segment.startET || segment.startWord < 1 || segment.endWord < segment.startWord || segment.endWord > s.size/dafWordBytes {
return nil, fmt.Errorf("%w: invalid segment summary", errInvalidDAF)
}
return segment, nil
}
func (segment *summarySegment) loadMeta(s *File) error {
if segment.endWord-segment.startWord+1 < 4 {
return fmt.Errorf("%w: segment trailer is missing", errInvalidDAF)
}
if segment.dataType == 20 {
trailer, err := s.readWords(segment.endWord-6, 7)
if err != nil {
return err
}
if !finitePositive(trailer[0]) || !finitePositive(trailer[1]) || !finite(trailer[2]) || math.Abs(trailer[3]) >= 1 || !finitePositive(trailer[4]) || !validInteger(trailer[5], 6, segment.endWord-segment.startWord) || !validInteger(trailer[6], 1, segment.endWord-segment.startWord) {
return fmt.Errorf("%w: invalid type 20 trailer", errInvalidDAF)
}
segment.distanceScale = trailer[0]
segment.timeScale = trailer[1]
segment.initJD = trailer[2]
segment.initFR = trailer[3]
segment.interval = trailer[4]
segment.recordSize = int(trailer[5])
segment.records = int(trailer[6])
wordCount := segment.endWord - segment.startWord - 6
if int64(segment.recordSize)*int64(segment.records) != wordCount || segment.recordSize < 6 || segment.recordSize%3 != 0 {
return fmt.Errorf("%w: invalid type 20 record size", errInvalidDAF)
}
return nil
}
if segment.dataType == 21 {
trailer, err := s.readWords(segment.endWord-1, 2)
if err != nil {
return err
}
if !validInteger(trailer[0], 1, 25) || !validInteger(trailer[1], 1, segment.endWord-segment.startWord) {
return fmt.Errorf("%w: invalid type 21 trailer", errInvalidDAF)
}
segment.maxDim = int(trailer[0])
segment.recordSize = 4*segment.maxDim + 11
segment.records = int(trailer[1])
segment.epochStartWord = segment.startWord + int64(segment.recordSize*segment.records)
segment.directoryCount = segment.records / 100
wordCount := segment.endWord - segment.startWord + 1
want := int64(segment.recordSize*segment.records + segment.records + segment.directoryCount + 2)
if want != wordCount {
return fmt.Errorf("%w: invalid type 21 segment size", errInvalidDAF)
}
return nil
}
trailerWords := segment.endWord - 3
trailer, err := s.readWords(trailerWords, 4)
if err != nil {
return err
}
segment.initET = trailer[0]
segment.interval = trailer[1]
wordCount := segment.endWord - segment.startWord - 3
if !finite(segment.initET) || !finitePositive(segment.interval) || !validInteger(trailer[2], 5, wordCount) || !validInteger(trailer[3], 1, wordCount) {
return fmt.Errorf("%w: invalid type %d trailer", errInvalidDAF, segment.dataType)
}
segment.recordSize = int(trailer[2])
segment.records = int(trailer[3])
if int64(segment.recordSize)*int64(segment.records) != wordCount {
return fmt.Errorf("%w: invalid type %d trailer", errInvalidDAF, segment.dataType)
}
components := 3
if segment.dataType == 3 {
components = 6
}
if (segment.recordSize-2)%components != 0 || (segment.recordSize-2)/components < 1 {
return fmt.Errorf("%w: invalid type %d record size %d", errInvalidDAF, segment.dataType, segment.recordSize)
}
return nil
}
func chosenSegment(segment *summarySegment) (*summarySegment, error) {
if segment == nil {
return nil, ephem.ErrOutOfRange
}
if segment.frame != 1 {
return nil, ErrUnsupportedFrame
}
if segment.dataType != 2 && segment.dataType != 3 && segment.dataType != 20 && segment.dataType != 21 {
return nil, ErrUnsupportedType
}
return segment, nil
}
type stateEdge struct {
to Body
segment *summarySegment
reverse bool
}
func (s *File) composeState(target, center Body, jdTDB float64) (pos, vel [3]float64, err error) {
if !s.known[target] || !s.known[center] {
return pos, vel, ephem.ErrUnsupportedBody
}
// 一次倒序扫描求出每个目标在 jdTDB 处覆盖它的最后一段,后加载的段优先。
chosen := make(map[Body]*summarySegment)
for index := len(s.segments) - 1; index >= 0; index-- {
segment := s.segments[index]
if _, ok := chosen[segment.target]; ok {
continue
}
if jdTDB >= spkJ2000+segment.startET/spkSecondsDay && jdTDB <= spkJ2000+segment.endET/spkSecondsDay {
chosen[segment.target] = segment
}
}
graph := make(map[Body][]stateEdge)
selected := make(map[Body]bool)
for index := len(s.segments) - 1; index >= 0; index-- {
segment := s.segments[index]
if selected[segment.target] {
continue
}
current, selectErr := chosenSegment(chosen[segment.target])
if selectErr != nil {
// 覆盖缺口留到 BFS 结束再报:别的路径仍可能连通。
if selectErr != ephem.ErrOutOfRange && (segment.target == target || segment.target == center) {
return pos, vel, selectErr
}
continue
}
segment = current
selected[segment.target] = true
graph[segment.target] = append(graph[segment.target], stateEdge{to: segment.center, segment: segment})
graph[segment.center] = append(graph[segment.center], stateEdge{to: segment.target, segment: segment, reverse: true})
}
type node struct {
body Body
pos [3]float64
vel [3]float64
}
queue := []node{{body: target}}
visited := map[Body]bool{target: true}
for head := 0; head < len(queue); head++ {
current := queue[head]
if current.body == center {
return current.pos, current.vel, nil
}
for _, edge := range graph[current.body] {
if visited[edge.to] {
continue
}
edgePos, edgeVel, edgeErr := edge.segment.state(jdTDB)
if edgeErr != nil {
return pos, vel, edgeErr
}
if edge.reverse {
negateVector(&edgePos)
negateVector(&edgeVel)
}
addVector(&edgePos, current.pos)
addVector(&edgeVel, current.vel)
visited[edge.to] = true
queue = append(queue, node{body: edge.to, pos: edgePos, vel: edgeVel})
}
}
return pos, vel, ephem.ErrOutOfRange
}
func (segment *summarySegment) state(jdTDB float64) (pos, vel [3]float64, err error) {
if segment.dataType == 20 {
return segment.stateType20(jdTDB)
}
if segment.dataType == 21 {
return segment.stateType21(jdTDB)
}
et := (jdTDB - spkJ2000) * spkSecondsDay
index := int(math.Floor((et - segment.initET) / segment.interval))
if index < 0 {
index = 0
}
if index >= segment.records {
index = segment.records - 1
}
if jdTDB < spkJ2000+segment.startET/spkSecondsDay || jdTDB > spkJ2000+segment.endET/spkSecondsDay {
return pos, vel, ephem.ErrOutOfRange
}
record, err := segment.readRecord(index)
if err != nil {
return pos, vel, err
}
if !finite(record[0]) || !finitePositive(record[1]) || !finiteAll(record[2:]) {
return pos, vel, errInvalidDAF
}
x := (et - record[0]) / record[1]
tolerance := math.Abs(math.Nextafter(jdTDB, math.Inf(1))-jdTDB) * spkSecondsDay / record[1]
if x < -1-tolerance || x > 1+tolerance {
return pos, vel, ephem.ErrOutOfRange
}
x = math.Max(-1, math.Min(1, x))
components := 3
if segment.dataType == 3 {
components = 6
}
coefficientCount := (segment.recordSize - 2) / components
for component := 0; component < 3; component++ {
value, derivative := chebyshev(record[2+component*coefficientCount:2+(component+1)*coefficientCount], x)
pos[component] = value
if segment.dataType == 2 {
vel[component] = derivative / record[1] * spkSecondsDay
} else {
velocityStart := 2 + (component+3)*coefficientCount
vel[component], _ = chebyshev(record[velocityStart:velocityStart+coefficientCount], x)
vel[component] *= spkSecondsDay
}
}
return pos, vel, nil
}
func (segment *summarySegment) stateType20(jdTDB float64) (pos, vel [3]float64, err error) {
if jdTDB < spkJ2000+segment.startET/spkSecondsDay || jdTDB > spkJ2000+segment.endET/spkSecondsDay {
return pos, vel, ephem.ErrOutOfRange
}
et := (jdTDB - spkJ2000) * spkSecondsDay
init := (segment.initJD - spkJ2000 + segment.initFR) * spkSecondsDay
interval := segment.interval * spkSecondsDay
if !finitePositive(interval) {
return pos, vel, errInvalidDAF
}
index := int(math.Floor((et - init) / interval))
if index < 0 {
index = 0
}
if index >= segment.records {
index = segment.records - 1
}
record, err := segment.readRecord(index)
if err != nil {
return pos, vel, err
}
nterms := segment.recordSize / 3
coefficientCount := nterms - 1
if len(record) != segment.recordSize || coefficientCount < 1 || !finiteAll(record) {
return pos, vel, errInvalidDAF
}
recbeg := (segment.initJD - spkJ2000 + float64(index)*segment.interval) * spkSecondsDay
radius := interval / 2
mid := recbeg + segment.initFR*spkSecondsDay + radius
x := (et - mid) / radius
if !finite(x) || x < -1-1e-12 || x > 1+1e-12 {
return pos, vel, ephem.ErrOutOfRange
}
x = math.Max(-1, math.Min(1, x))
velocityScale := segment.distanceScale / segment.timeScale
for component := 0; component < 3; component++ {
base := component * nterms
coefficients := record[base : base+coefficientCount]
value, _ := chebyshev(coefficients, x)
integral := chebyshevIntegral(coefficients, x)
pos[component] = record[base+coefficientCount]*segment.distanceScale + radius*integral*velocityScale
vel[component] = value * velocityScale * spkSecondsDay
}
return pos, vel, nil
}
func (segment *summarySegment) stateType21(jdTDB float64) (pos, vel [3]float64, err error) {
if jdTDB < spkJ2000+segment.startET/spkSecondsDay || jdTDB > spkJ2000+segment.endET/spkSecondsDay {
return pos, vel, ephem.ErrOutOfRange
}
et := (jdTDB - spkJ2000) * spkSecondsDay
epochs, err := segment.owner.readWords(segment.epochStartWord, segment.records)
if err != nil {
return pos, vel, err
}
index := 0
for index < len(epochs) && epochs[index] < et {
index++
}
if index >= len(epochs) {
index = len(epochs) - 1
}
record, err := segment.readRecord(index)
if err != nil {
return pos, vel, err
}
maxdim := segment.maxDim
record = append([]float64{float64(maxdim)}, record...)
pos, vel, err = evaluateType21(et, record)
for component := range vel {
vel[component] *= spkSecondsDay
}
return pos, vel, err
}
func evaluateType21(et float64, record []float64) (statePos, stateVel [3]float64, err error) {
if len(record) < 72 {
return statePos, stateVel, errInvalidDAF
}
maxdim := int(math.Round(record[0]))
if maxdim < 15 || maxdim > 25 || len(record) != 4*maxdim+12 || !finiteAll(record) {
return statePos, stateVel, errInvalidDAF
}
tl := record[1]
g := record[2 : 2+maxdim]
refPos := [3]float64{record[maxdim+2], record[maxdim+4], record[maxdim+6]}
refVel := [3]float64{record[maxdim+3], record[maxdim+5], record[maxdim+7]}
kqmax1 := int(math.Round(record[4*maxdim+8]))
kq := [3]int{int(math.Round(record[4*maxdim+9])), int(math.Round(record[4*maxdim+10])), int(math.Round(record[4*maxdim+11]))}
// 差分表与步长表各只有 MAXDIM 项,权重表只有 KQMAX1+1 项。
if kqmax1 < 3 || kqmax1 > maxdim+1 {
return statePos, stateVel, errInvalidDAF
}
for _, value := range g[:kqmax1-2] {
if !finite(value) || value == 0 {
return statePos, stateVel, errInvalidDAF
}
}
for _, value := range kq {
if value < 1 || value > kqmax1-1 {
return statePos, stateVel, errInvalidDAF
}
}
delta := et - tl
tp := delta
mq2 := kqmax1 - 2
ks := kqmax1 - 1
fc := make([]float64, kqmax1+1)
wc := make([]float64, kqmax1)
weights := make([]float64, kqmax1+2)
for j := 1; j <= mq2; j++ {
fc[j] = tp / g[j-1]
wc[j-1] = delta / g[j-1]
tp = delta + g[j-1]
}
for j := 1; j <= kqmax1; j++ {
weights[j-1] = 1 / float64(j)
}
jx := 0
ks1 := ks - 1
for ks >= 2 {
jx++
for j := 1; j <= jx; j++ {
weights[j+ks-1] = fc[j]*weights[j+ks1-1] - wc[j-1]*weights[j+ks-1]
}
ks = ks1
ks1--
}
for component := 0; component < 3; component++ {
sum := 0.0
for j := kq[component]; j >= 1; j-- {
sum += record[8+(component+1)*maxdim+j-1] * weights[j+ks-1]
}
statePos[component] = refPos[component] + delta*(refVel[component]+delta*sum)
}
for j := 1; j <= jx; j++ {
weights[j+ks-1] = fc[j]*weights[j+ks1-1] - wc[j-1]*weights[j+ks-1]
}
ks--
for component := 0; component < 3; component++ {
sum := 0.0
for j := kq[component]; j >= 1; j-- {
sum += record[8+(component+1)*maxdim+j-1] * weights[j+ks-1]
}
stateVel[component] = refVel[component] + delta*sum
}
return statePos, stateVel, nil
}
func chebyshevIntegral(coefficients []float64, x float64) float64 {
degree := len(coefficients) - 1
if degree < 0 {
return 0
}
s2 := 2 * x
a2 := coefficients[0]
if len(coefficients) >= 3 {
a2 -= coefficients[2] * 0.5
}
adegree1, adegree2 := 0.0, 0.0
if degree >= 2 {
adegree1 = coefficients[degree-1] * 0.5 / float64(degree)
}
if degree >= 1 {
adegree2 = coefficients[degree] * 0.5 / float64(degree+1)
}
f2, f1 := 0.0, 0.0
f0 := a2
if degree > 0 {
f0 = adegree2
}
z2, z1, z0 := 0.0, 0.0, f0
w2, w1, w0 := 0.0, 0.0, 0.0
for index := len(coefficients); index > 1; index-- {
var coefficient float64
switch {
case index == 2:
coefficient = a2
case index < len(coefficients):
coefficient = (coefficients[index-2] - coefficients[index]) * 0.5 / float64(index-1)
default:
coefficient = adegree1
}
f2, f1 = f1, f0
f0 = coefficient + (s2*f1 - f2)
z2, z1 = z1, z0
z0 = coefficient - z2
w2, w1 = w1, w0
w0 = coefficients[index-1] + (s2*w1 - w2)
}
c0 := z1
return c0 + x*f0 - f1
}
func (segment *summarySegment) readRecord(index int) ([]float64, error) {
if index < 0 || index >= segment.records {
return nil, ephem.ErrOutOfRange
}
segment.cacheMu.Lock()
if record, ok := segment.cache[index]; ok {
copyRecord := append([]float64(nil), record...)
segment.cacheMu.Unlock()
return copyRecord, nil
}
segment.cacheMu.Unlock()
record, err := segment.owner.readWords(segment.startWord+int64(index*segment.recordSize), segment.recordSize)
if err != nil {
return nil, err
}
segment.cacheMu.Lock()
defer segment.cacheMu.Unlock()
if previous, ok := segment.cache[index]; ok {
return append([]float64(nil), previous...), nil
}
if len(segment.cacheOrder) >= spkCacheRecords {
delete(segment.cache, segment.cacheOrder[0])
segment.cacheOrder = segment.cacheOrder[1:]
}
segment.cache[index] = record
segment.cacheOrder = append(segment.cacheOrder, index)
return append([]float64(nil), record...), nil
}
func chebyshev(coefficients []float64, x float64) (value, derivative float64) {
t0, t1 := 1.0, x
d0, d1 := 0.0, 1.0
for index, coefficient := range coefficients {
switch index {
case 0:
value += coefficient
case 1:
value += coefficient * t1
derivative += coefficient
default:
t2 := 2*x*t1 - t0
d2 := 2*t1 + 2*x*d1 - d0
value += coefficient * t2
derivative += coefficient * d2
t0, t1 = t1, t2
d0, d1 = d1, d2
}
}
return value, derivative
}
func (s *File) readRecord(record int) ([]byte, error) {
if record < 1 || int64(record)*dafRecordBytes > s.size {
return nil, errInvalidDAF
}
data := make([]byte, dafRecordBytes)
if _, err := s.file.ReadAt(data, int64(record-1)*dafRecordBytes); err != nil {
return nil, err
}
return data, nil
}
func (s *File) readWords(startWord int64, count int) ([]float64, error) {
if startWord < 1 || count < 1 || int64(count) > s.size/dafWordBytes || startWord > s.size/dafWordBytes-int64(count)+1 {
return nil, errInvalidDAF
}
data := make([]byte, count*dafWordBytes)
if _, err := s.file.ReadAt(data, (startWord-1)*dafWordBytes); err != nil {
return nil, err
}
words := make([]float64, count)
for index := range words {
words[index] = float64Value(s.order, data[index*dafWordBytes:(index+1)*dafWordBytes])
}
return words, nil
}
func byteOrderFor(format string) (binary.ByteOrder, error) {
switch format {
case "LTL-IEEE":
return binary.LittleEndian, nil
case "BIG-IEEE":
return binary.BigEndian, nil
default:
return nil, fmt.Errorf("%w: binary format %q", errInvalidDAF, format)
}
}
func int32Value(order binary.ByteOrder, data []byte, offset int) int32 {
return int32(order.Uint32(data[offset : offset+4]))
}
func float64Value(order binary.ByteOrder, data []byte) float64 {
return math.Float64frombits(order.Uint64(data))
}
func finitePositive(value float64) bool {
return value > 0 && finite(value)
}
func finite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) }
func finiteAll(values []float64) bool {
for _, value := range values {
if !finite(value) {
return false
}
}
return true
}
func validInteger(value float64, minimum, maximum int64) bool {
return finite(value) && value >= float64(minimum) && value <= float64(maximum) && value == math.Trunc(value)
}
func addVector(dst *[3]float64, src [3]float64) {
for index := range dst {
dst[index] += src[index]
}
}
func negateVector(value *[3]float64) {
for index := range value {
value[index] = -value[index]
}
}
var _ ephem.Engine = (*File)(nil)
+441
View File
@@ -0,0 +1,441 @@
package spk
import (
"bufio"
"encoding/binary"
"math"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"b612.me/astro/internal/ephem"
)
// de441Path 真实 DE441 核文件路径,环境变量 ASTRO_DE441_BSP 优先。
func de441Path() string {
if path := os.Getenv("ASTRO_DE441_BSP"); path != "" {
return path
}
return "/tmp/de441data/de441_part-2.bsp"
}
type testSPKSegment struct {
target Body
center Body
frame int
typ int
start float64
end float64
words []float64
}
func TestType2StateCoverageAndBoundary(t *testing.T) {
path := writeTestSPK(t, binary.LittleEndian, "LTL-IEEE", testSPKSegment{
target: Mars,
center: Sun,
frame: 1,
typ: 2,
start: -10,
end: 10,
words: type2Words(-10, 10,
[]float64{-5, 5, 10, 2.5, 0, 0, 20, 0},
[]float64{5, 5, 20, 2.5, 0, 0, 40, 0},
),
})
spk, err := Open(path)
if err != nil {
t.Fatalf("open type21: %+v", err)
}
defer spk.Close()
start, end, ok := spk.Coverage(Mars)
if !ok || math.Abs(start-(spkJ2000-10/spkSecondsDay)) > 1e-12 || math.Abs(end-(spkJ2000+10/spkSecondsDay)) > 1e-12 {
t.Fatalf("coverage = %.15f..%.15f/%v", start, end, ok)
}
pos, vel, err := spk.State(Mars, Sun, spkJ2000-5/spkSecondsDay)
if err != nil {
t.Fatal(err)
}
assertVector(t, pos, [3]float64{10, 0, 20})
assertVector(t, vel, [3]float64{43200, 0, 0})
pos, _, err = spk.State(Mars, Sun, spkJ2000+10/spkSecondsDay)
if err != nil {
t.Fatal(err)
}
assertVector(t, pos, [3]float64{22.5, 0, 40})
_, _, err = spk.State(Mars, Sun, spkJ2000+11/spkSecondsDay)
if err != ephem.ErrOutOfRange {
t.Fatalf("out-of-range error = %v", err)
}
}
func TestType3BigEndianAndStateComposition(t *testing.T) {
path := writeTestSPK(t, binary.BigEndian, "BIG-IEEE",
testSPKSegment{
target: Earth,
center: EMB,
frame: 1,
typ: 3,
start: -10,
end: 10,
words: type3Words(0, 10, []float64{1, 2, 3}, []float64{0.1, 0.2, 0.3}),
},
testSPKSegment{
target: EMB,
center: SSB,
frame: 1,
typ: 2,
start: -10,
end: 10,
words: type2Words(-10, 20, []float64{0, 10, 10, 20, 30}),
},
)
spk, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer spk.Close()
pos, vel, err := spk.State(Earth, SSB, spkJ2000)
if err != nil {
t.Fatal(err)
}
assertVector(t, pos, [3]float64{11, 22, 33})
assertVector(t, vel, [3]float64{0.1 * spkSecondsDay, 0.2 * spkSecondsDay, 0.3 * spkSecondsDay})
pos, vel, err = spk.State(SSB, Earth, spkJ2000)
if err != nil {
t.Fatal(err)
}
assertVector(t, pos, [3]float64{-11, -22, -33})
assertVector(t, vel, [3]float64{-0.1 * spkSecondsDay, -0.2 * spkSecondsDay, -0.3 * spkSecondsDay})
}
func TestType20State(t *testing.T) {
intervalDays := 2 / spkSecondsDay
record := []float64{1, 10, 1, 20, 1, 30}
path := writeTestSPK(t, binary.LittleEndian, "LTL-IEEE", testSPKSegment{
target: Mars,
center: Sun,
frame: 1,
typ: 20,
start: -1,
end: 1,
words: append(record, []float64{1, 1, spkJ2000, 0, intervalDays, 6, 1}...),
})
spk, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer spk.Close()
pos, vel, err := spk.State(Mars, Sun, spkJ2000+1/spkSecondsDay)
if err != nil {
t.Fatal(err)
}
assertVector(t, pos, [3]float64{10, 20, 30})
assertVector(t, vel, [3]float64{spkSecondsDay, spkSecondsDay, spkSecondsDay})
}
func TestType21State(t *testing.T) {
maxdim := 15
full := make([]float64, 4*maxdim+12)
full[0] = 15
full[1] = 0
for index := 0; index < maxdim; index++ {
full[2+index] = 1
}
full[maxdim+2] = 10
full[maxdim+4] = 20
full[maxdim+6] = 30
full[maxdim+3] = 1
full[maxdim+5] = 2
full[maxdim+7] = 3
full[8+maxdim] = 1
full[8+2*maxdim] = 1
full[8+3*maxdim] = 1
full[4*maxdim+8] = 3
full[4*maxdim+9] = 1
full[4*maxdim+10] = 1
full[4*maxdim+11] = 1
words := append(append([]float64(nil), full[1:]...), 2, 15, 1)
path := writeTestSPK(t, binary.LittleEndian, "LTL-IEEE", testSPKSegment{
target: Mars,
center: Sun,
frame: 1,
typ: 21,
start: 0,
end: 2,
words: words,
})
spk, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer spk.Close()
pos, vel, err := spk.State(Mars, Sun, spkJ2000+2/spkSecondsDay)
if err != nil {
t.Fatal(err)
}
assertVectorTolerance(t, pos, [3]float64{14, 26, 38}, 1e-3)
assertVectorTolerance(t, vel, [3]float64{3 * spkSecondsDay, 4 * spkSecondsDay, 5 * spkSecondsDay}, 2)
}
func TestLaterSegmentHasPriorityAndClose(t *testing.T) {
path := writeTestSPK(t, binary.LittleEndian, "LTL-IEEE",
testSPKSegment{target: Venus, center: Sun, frame: 1, typ: 2, start: -1, end: 1, words: type2Words(-1, 2, []float64{0, 1, 1, 0, 0})},
testSPKSegment{target: Venus, center: Sun, frame: 1, typ: 2, start: -1, end: 1, words: type2Words(-1, 2, []float64{0, 2, 2, 0, 0})},
)
spk, err := Load(path)
if err != nil {
t.Fatal(err)
}
pos, _, err := spk.State(Venus, Sun, spkJ2000)
if err != nil {
t.Fatal(err)
}
assertVector(t, pos, [3]float64{2, 0, 0})
if err := spk.Close(); err != nil {
t.Fatal(err)
}
if _, _, err := spk.State(Venus, Sun, spkJ2000); err != errClosed {
t.Fatalf("closed state error = %v", err)
}
}
func TestOpenRejectsUnsupportedSPK(t *testing.T) {
path := filepath.Join(t.TempDir(), "bad.bsp")
if err := os.WriteFile(path, []byte("not an SPK"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := Open(path); err == nil {
t.Fatal("invalid SPK was accepted")
}
}
func type2Words(init, interval float64, records ...[]float64) []float64 {
if len(records) == 0 || len(records[0]) < 5 || (len(records[0])-2)%3 != 0 {
panic("invalid type2 test record")
}
recordSize := len(records[0])
words := make([]float64, 0, recordSize*len(records)+4)
for _, record := range records {
if len(record) != recordSize {
panic("type2 record sizes differ")
}
words = append(words, record...)
}
words = append(words, init, interval, float64(recordSize), float64(len(records)))
return words
}
func type3Words(mid, radius float64, position, velocity []float64) []float64 {
words := []float64{mid, radius}
words = append(words, position...)
words = append(words, velocity...)
words = append(words, mid-radius, radius*2, 2+6, 1)
return words
}
func writeTestSPK(t *testing.T, order binary.ByteOrder, format string, segments ...testSPKSegment) string {
t.Helper()
const summaryRecord = 2
const nameRecord = 3
const firstDataRecord = 4
wordsPerRecord := int64(dafRecordBytes / dafWordBytes)
dataWord := int64(firstDataRecord-1)*wordsPerRecord + 1
type indexedSegment struct {
testSPKSegment
startWord int64
endWord int64
}
indexed := make([]indexedSegment, len(segments))
for index, segment := range segments {
indexed[index] = indexedSegment{testSPKSegment: segment, startWord: dataWord}
dataWord += int64(len(segment.words))
indexed[index].endWord = dataWord - 1
}
dataRecords := int((dataWord-1+wordsPerRecord-1)/wordsPerRecord) + 1
fileData := make([]byte, dataRecords*dafRecordBytes)
copy(fileData[0:8], []byte("DAF/SPK "))
order.PutUint32(fileData[8:12], uint32(dafND))
order.PutUint32(fileData[12:16], uint32(dafNI))
copy(fileData[16:28], []byte("test SPK"))
order.PutUint32(fileData[76:80], summaryRecord)
order.PutUint32(fileData[80:84], summaryRecord)
order.PutUint32(fileData[84:88], uint32(dataWord))
copy(fileData[88:96], []byte(format))
summaryOffset := (summaryRecord - 1) * dafRecordBytes
putF64(order, fileData[summaryOffset:summaryOffset+8], 0)
putF64(order, fileData[summaryOffset+8:summaryOffset+16], 0)
putF64(order, fileData[summaryOffset+16:summaryOffset+24], float64(len(indexed)))
_ = nameRecord
for index, segment := range indexed {
offset := summaryOffset + 24 + index*dafSummaryBytes
putF64(order, fileData[offset:offset+8], segment.start)
putF64(order, fileData[offset+8:offset+16], segment.end)
putI32(order, fileData[offset+16:offset+20], int32(segment.target))
putI32(order, fileData[offset+20:offset+24], int32(segment.center))
putI32(order, fileData[offset+24:offset+28], int32(segment.frame))
putI32(order, fileData[offset+28:offset+32], int32(segment.typ))
putI32(order, fileData[offset+32:offset+36], int32(segment.startWord))
putI32(order, fileData[offset+36:offset+40], int32(segment.endWord))
for word, value := range segment.words {
putF64(order, fileData[(segment.startWord-1+int64(word))*dafWordBytes:(segment.startWord+int64(word))*dafWordBytes], value)
}
}
path := filepath.Join(t.TempDir(), "test.bsp")
if err := os.WriteFile(path, fileData, 0o600); err != nil {
t.Fatal(err)
}
return path
}
func putF64(order binary.ByteOrder, destination []byte, value float64) {
order.PutUint64(destination, math.Float64bits(value))
}
func putI32(order binary.ByteOrder, destination []byte, value int32) {
order.PutUint32(destination, uint32(value))
}
func assertVector(t *testing.T, got, want [3]float64) {
assertVectorTolerance(t, got, want, 1e-5)
}
func assertVectorTolerance(t *testing.T, got, want [3]float64, tolerance float64) {
t.Helper()
for index := range got {
if math.Abs(got[index]-want[index]) > tolerance {
t.Fatalf("vector[%d] = %.12f, want %.12f", index, got[index], want[index])
}
}
}
// TestDE441AccuracyVsSkyfield 验收测试:读取真实 DE441 文件,对照 skyfield / acceptance test: read real DE441, compare with skyfield.
func TestDE441AccuracyVsSkyfield(t *testing.T) {
spkPath := de441Path()
if _, err := os.Stat(spkPath); os.IsNotExist(err) {
t.Skip("DE441 file not available; set ASTRO_DE441_BSP")
}
refPath := filepath.Join("testdata", "de441_ref.csv")
spk, err := Open(spkPath)
if err != nil {
t.Fatal(err)
}
defer spk.Close()
f, err := os.Open(refPath)
if err != nil {
t.Fatal(err)
}
defer f.Close()
var maxPosErr, maxVelErr float64
lineNum := 0
scanner := bufio.NewScanner(f)
for scanner.Scan() {
lineNum++
parts := strings.Split(scanner.Text(), ",")
if len(parts) != 8 {
continue
}
code, _ := strconv.Atoi(parts[0])
jd, _ := strconv.ParseFloat(parts[1], 64)
refPos := [3]float64{}
refVel := [3]float64{}
for i := 0; i < 3; i++ {
refPos[i], _ = strconv.ParseFloat(parts[2+i], 64)
refVel[i], _ = strconv.ParseFloat(parts[5+i], 64)
}
pos, vel, err := spk.State(Body(code), Sun, jd)
if err != nil {
t.Fatalf("line %d: State(%d, Sun, %.1f) error: %v", lineNum, code, jd, err)
}
for i := 0; i < 3; i++ {
dPos := math.Abs(pos[i] - refPos[i])
dVel := math.Abs(vel[i] - refVel[i])
if dPos > maxPosErr {
maxPosErr = dPos
}
if dVel > maxVelErr {
maxVelErr = dVel
}
}
}
// 验收标准:位置 < 100 m,速度 < 0.1 mm/s / acceptance: position < 100 m, velocity < 0.1 mm/s.
if maxPosErr > 0.1 {
t.Errorf("max position error = %.6f km (> 100 m)", maxPosErr)
}
if maxVelErr > 0.01 { // 0.1 mm/s = 0.01 km/d
t.Errorf("max velocity error = %.9f km/d (> 0.1 mm/s)", maxVelErr)
}
t.Logf("max errors: position %.3e km, velocity %.3e km/d", maxPosErr, maxVelErr)
}
func TestType20IntegralConstantVelocity(t *testing.T) {
if got, want := chebyshevIntegral([]float64{2}, 0.25), 0.5; got != want {
t.Fatalf("integral = %.17g, want %.17g", got, want)
}
}
func TestType21SyntheticRecord(t *testing.T) {
maxdim := 15
record := make([]float64, 4*maxdim+12)
record[0] = float64(maxdim)
record[1] = 0
for index := 0; index < maxdim; index++ {
record[2+index] = 1
}
record[maxdim+2] = 10
record[maxdim+4] = 20
record[maxdim+6] = 30
record[maxdim+3] = 1
record[maxdim+5] = 2
record[maxdim+7] = 3
record[4*maxdim+8] = 3
record[4*maxdim+9] = 1
record[4*maxdim+10] = 1
record[4*maxdim+11] = 1
for component := 0; component < 3; component++ {
record[8+(component+1)*maxdim] = 1
}
gotPos, gotVel, err := evaluateType21(2, record)
if err != nil {
t.Fatal(err)
}
if gotPos != [3]float64{14, 26, 38} || gotVel != [3]float64{3, 4, 5} {
t.Fatalf("state = %v, %v", gotPos, gotVel)
}
}
func TestHorizonsType21CeresAgainstCSPICE(t *testing.T) {
path := filepath.Join("testdata", "ceres.bsp")
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Skip("Horizons Ceres SPK is not available")
}
file, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
tests := []struct {
jd float64
pos [3]float64
vel [3]float64
}{
{2461041.5, [3]float64{381172803.95163149, 202285004.74382541, 17811912.880624987}, [3]float64{-8.4262244193126747 * spkSecondsDay, 12.764612139351195 * spkSecondsDay, 7.7363904450095804 * spkSecondsDay}},
{2461042.5, [3]float64{380442435.16916716, 203386620.60955107, 18480226.044434257}, [3]float64{-8.480449226845364 * spkSecondsDay, 12.735725686923541 * spkSecondsDay, 7.7338055274662123 * spkSecondsDay}},
{2461043.5, [3]float64{379707384.25882173, 204485733.05701393, 19148311.683061387}, [3]float64{-8.5346068958188273 * spkSecondsDay, 12.706662654284424 * spkSecondsDay, 7.7311236408537631 * spkSecondsDay}},
}
for _, test := range tests {
pos, vel, stateErr := file.State(Body(20000001), Sun, test.jd)
if stateErr != nil {
t.Fatal(stateErr)
}
assertVectorTolerance(t, pos, test.pos, 1e-6)
assertVectorTolerance(t, vel, test.vel, 1e-6)
}
}
BIN
View File
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
1,2451545.000000,-19461726.355853721,-59927967.773480386,-29992772.679831419,3196367.296468922403,-736963.944484227919,-725165.666795568075
1,2455197.500000,7615348.314506317,40349559.970724382,20764230.250799619,-4994527.427748897113,573399.385201107943,824151.435103199328
1,2460409.250000,-59095102.744904339,-12208835.836776096,-397061.253618861,-80157.589996882845,-3500633.117726195138,-1861734.035449085291
1,2470000.500000,-58423379.476459160,-21095218.873128951,-5219094.863423973,577984.675783590879,-3315398.069772230461,-1831093.688917552819
1,2488069.500000,35715235.692744091,-45902347.318416558,-28220925.763680972,2657160.074483643752,2349521.284594707657,980587.628848225228
2,2451545.000000,-107456494.052190557,-6922528.774882653,3686187.045620657,119396.680928361806,-2766339.512497388292,-1252094.584890355356
2,2455197.500000,7980400.349791029,-98757537.297539294,-44937823.562456690,2997233.027447930072,261290.997697278799,-72100.389786119573
2,2460409.250000,104033519.902185202,-26158564.735811118,-18352357.632673483,855986.988639243646,2649988.735226617660,1138248.387975588674
2,2470000.500000,-68287385.053574130,-78212941.874442697,-30880999.369566523,2327074.364669556729,-1699613.802507086657,-912090.491982413805
2,2488069.500000,102852993.216383740,33439527.000706431,8554308.473454434,-961949.957846128964,2585099.317701762542,1224659.527287552366
4,2451545.000000,208048140.641832381,209619.173358767,-5529162.313239297,100454.898522694188,2066550.598105826415,945144.452117909910
4,2455197.500000,-109157269.549356803,196726101.683266968,93181300.810612798,-1791776.105491590220,-709366.836053605075,-276972.610298371932
4,2460409.250000,152691947.616703689,-126257132.256598845,-62030955.482422978,1496812.112169315806,1577887.720354233636,683358.677785717067
4,2470000.500000,107630427.253999338,-162708449.197838932,-77528699.089063302,1876069.888584472705,1158239.285938726505,480780.601375605213
4,2488069.500000,91203205.631215408,188672444.573695242,84102402.739245221,-1836876.649216917576,913745.130979382899,468367.767875669757
5,2451545.000000,598567583.597928882,409386350.116554618,160894347.123006970,-683409.976292214124,879854.211247629719,393786.926219645888
5,2455197.500000,674546437.387645483,-291887651.141048610,-141534852.950989753,475266.914842320664,989687.270147228497,412637.771498839953
5,2460409.250000,435947579.421117783,563448248.670763493,230897554.998261392,-931739.974492124049,645272.707379632047,299261.391436264093
5,2470000.500000,-538636165.009431601,531267619.259489059,240803282.070978582,-844445.853253175621,-664204.002779098344,-264128.109617178678
5,2488069.500000,-803903147.176483989,-132064236.644811392,-37056634.120138355,174569.878011514316,-974561.869908771943,-421903.878750392585
6,2451545.000000,958385124.219723463,923715658.081787348,340300865.572902262,-642126.700622105272,527832.834165259846,245629.459105411050
6,2455197.500000,-1415832919.845060587,13356405.577122003,66480591.272885621,-67221.690704686960,-773182.582146253320,-316438.760555763729
6,2460409.250000,1370501763.601192951,-421096086.748522639,-232938609.442695707,228454.155326918553,731091.306874577655,292130.717300834775
6,2470000.500000,841483890.628529906,-1122639452.952589512,-500061931.231320977,642654.884433073457,444128.590101163660,155757.490514981735
6,2488069.500000,-1369282076.754732370,-447490210.113554776,-125815618.898983285,220805.838523651590,-730244.951046228060,-311279.220554805885
7,2451545.000000,2158975019.759225368,-1870910802.754762411,-849968734.846995473,400638.893986218667,368310.601333641505,155643.038094210089
7,2455197.500000,2997435943.653147697,-194589174.388139933,-127611353.968718126,40958.326044318790,513239.595447454543,224198.539512870426
7,2460409.250000,1789526966.016673088,2136360112.069344521,910337186.673249483,-471282.549542022636,302221.967622562137,139025.602688870422
7,2470000.500000,-2690167831.288533211,437221284.444555461,229510953.261675596,-110923.075897150295,-556571.606659970246,-242188.063348572468
7,2488069.500000,2822174470.747951508,911931163.314849496,359554751.563923001,-197081.774681179988,481963.416306072671,213875.698730149888
8,2451545.000000,2515046428.529101849,-3437774104.132060051,-1469713457.826672316,385853.937104861659,249592.869656585477,92573.436759929973
8,2455197.500000,3712593238.298039436,-2305923479.038077354,-1036254439.833165526,261633.561518639035,365043.385950069467,142918.714684926119
8,2460409.250000,4466604507.374163628,-163850563.700708121,-178261172.000086695,19352.899797939608,437790.884244481393,178719.607153477846
8,2470000.500000,2527937114.220953465,3422784599.666448116,1338029333.502541065,-390204.538922515116,245177.586645644769,110050.696193157419
8,2488069.500000,-4347198940.227345467,1100540332.667645454,558700600.743709564,-130479.251811205046,-417745.715501652157,-167755.317465597502
301,2451545.000000,-26790642.061618209,132490700.504490972,57480615.983234145,-2518622.958077146206,-491109.695287754876,-213988.565447278350
301,2455197.500000,-26413263.099680163,133102337.661037028,57708299.928026907,-2664399.190042089205,-448878.810475415608,-203923.795661982935
301,2460409.250000,-141274051.615975320,-44769623.371007383,-19403911.675677348,770024.379791575717,-2163063.666139910463,-928279.042105002794
301,2470000.500000,52633568.632847480,-130780085.628255159,-56678008.516612209,2320825.419408109970,870837.598782564630,369012.737071698008
301,2488069.500000,-23887291.531281494,133377636.959700242,57798398.255998142,-2617892.707621465903,-459650.174950940651,-207580.434036122402
399,2451545.000000,-26499033.677430496,132757417.338345051,57556718.470540732,-2574224.070085791871,-433559.719480311556,-187954.024564559892
399,2455197.500000,-26331886.663379308,132783019.476163641,57564916.131770104,-2572895.233377746306,-431404.139418599079,-186901.856015971338
399,2460409.250000,-141614561.775911272,-44875366.929020450,-19451994.188401155,798726.151406769990,-2241899.490074577276,-971954.226801195182
399,2470000.500000,52344709.055075817,-131003495.311840236,-56779941.691634409,2375381.176934503950,803200.719235604280,348196.328601448564
399,2488069.500000,-23547771.928055670,133241770.144845873,57731796.154016882,-2581876.036687009037,-386210.308926678495,-167170.899428247882
1 1 2451545.000000 -19461726.355853721 -59927967.773480386 -29992772.679831419 3196367.296468922403 -736963.944484227919 -725165.666795568075
2 1 2455197.500000 7615348.314506317 40349559.970724382 20764230.250799619 -4994527.427748897113 573399.385201107943 824151.435103199328
3 1 2460409.250000 -59095102.744904339 -12208835.836776096 -397061.253618861 -80157.589996882845 -3500633.117726195138 -1861734.035449085291
4 1 2470000.500000 -58423379.476459160 -21095218.873128951 -5219094.863423973 577984.675783590879 -3315398.069772230461 -1831093.688917552819
5 1 2488069.500000 35715235.692744091 -45902347.318416558 -28220925.763680972 2657160.074483643752 2349521.284594707657 980587.628848225228
6 2 2451545.000000 -107456494.052190557 -6922528.774882653 3686187.045620657 119396.680928361806 -2766339.512497388292 -1252094.584890355356
7 2 2455197.500000 7980400.349791029 -98757537.297539294 -44937823.562456690 2997233.027447930072 261290.997697278799 -72100.389786119573
8 2 2460409.250000 104033519.902185202 -26158564.735811118 -18352357.632673483 855986.988639243646 2649988.735226617660 1138248.387975588674
9 2 2470000.500000 -68287385.053574130 -78212941.874442697 -30880999.369566523 2327074.364669556729 -1699613.802507086657 -912090.491982413805
10 2 2488069.500000 102852993.216383740 33439527.000706431 8554308.473454434 -961949.957846128964 2585099.317701762542 1224659.527287552366
11 4 2451545.000000 208048140.641832381 209619.173358767 -5529162.313239297 100454.898522694188 2066550.598105826415 945144.452117909910
12 4 2455197.500000 -109157269.549356803 196726101.683266968 93181300.810612798 -1791776.105491590220 -709366.836053605075 -276972.610298371932
13 4 2460409.250000 152691947.616703689 -126257132.256598845 -62030955.482422978 1496812.112169315806 1577887.720354233636 683358.677785717067
14 4 2470000.500000 107630427.253999338 -162708449.197838932 -77528699.089063302 1876069.888584472705 1158239.285938726505 480780.601375605213
15 4 2488069.500000 91203205.631215408 188672444.573695242 84102402.739245221 -1836876.649216917576 913745.130979382899 468367.767875669757
16 5 2451545.000000 598567583.597928882 409386350.116554618 160894347.123006970 -683409.976292214124 879854.211247629719 393786.926219645888
17 5 2455197.500000 674546437.387645483 -291887651.141048610 -141534852.950989753 475266.914842320664 989687.270147228497 412637.771498839953
18 5 2460409.250000 435947579.421117783 563448248.670763493 230897554.998261392 -931739.974492124049 645272.707379632047 299261.391436264093
19 5 2470000.500000 -538636165.009431601 531267619.259489059 240803282.070978582 -844445.853253175621 -664204.002779098344 -264128.109617178678
20 5 2488069.500000 -803903147.176483989 -132064236.644811392 -37056634.120138355 174569.878011514316 -974561.869908771943 -421903.878750392585
21 6 2451545.000000 958385124.219723463 923715658.081787348 340300865.572902262 -642126.700622105272 527832.834165259846 245629.459105411050
22 6 2455197.500000 -1415832919.845060587 13356405.577122003 66480591.272885621 -67221.690704686960 -773182.582146253320 -316438.760555763729
23 6 2460409.250000 1370501763.601192951 -421096086.748522639 -232938609.442695707 228454.155326918553 731091.306874577655 292130.717300834775
24 6 2470000.500000 841483890.628529906 -1122639452.952589512 -500061931.231320977 642654.884433073457 444128.590101163660 155757.490514981735
25 6 2488069.500000 -1369282076.754732370 -447490210.113554776 -125815618.898983285 220805.838523651590 -730244.951046228060 -311279.220554805885
26 7 2451545.000000 2158975019.759225368 -1870910802.754762411 -849968734.846995473 400638.893986218667 368310.601333641505 155643.038094210089
27 7 2455197.500000 2997435943.653147697 -194589174.388139933 -127611353.968718126 40958.326044318790 513239.595447454543 224198.539512870426
28 7 2460409.250000 1789526966.016673088 2136360112.069344521 910337186.673249483 -471282.549542022636 302221.967622562137 139025.602688870422
29 7 2470000.500000 -2690167831.288533211 437221284.444555461 229510953.261675596 -110923.075897150295 -556571.606659970246 -242188.063348572468
30 7 2488069.500000 2822174470.747951508 911931163.314849496 359554751.563923001 -197081.774681179988 481963.416306072671 213875.698730149888
31 8 2451545.000000 2515046428.529101849 -3437774104.132060051 -1469713457.826672316 385853.937104861659 249592.869656585477 92573.436759929973
32 8 2455197.500000 3712593238.298039436 -2305923479.038077354 -1036254439.833165526 261633.561518639035 365043.385950069467 142918.714684926119
33 8 2460409.250000 4466604507.374163628 -163850563.700708121 -178261172.000086695 19352.899797939608 437790.884244481393 178719.607153477846
34 8 2470000.500000 2527937114.220953465 3422784599.666448116 1338029333.502541065 -390204.538922515116 245177.586645644769 110050.696193157419
35 8 2488069.500000 -4347198940.227345467 1100540332.667645454 558700600.743709564 -130479.251811205046 -417745.715501652157 -167755.317465597502
36 301 2451545.000000 -26790642.061618209 132490700.504490972 57480615.983234145 -2518622.958077146206 -491109.695287754876 -213988.565447278350
37 301 2455197.500000 -26413263.099680163 133102337.661037028 57708299.928026907 -2664399.190042089205 -448878.810475415608 -203923.795661982935
38 301 2460409.250000 -141274051.615975320 -44769623.371007383 -19403911.675677348 770024.379791575717 -2163063.666139910463 -928279.042105002794
39 301 2470000.500000 52633568.632847480 -130780085.628255159 -56678008.516612209 2320825.419408109970 870837.598782564630 369012.737071698008
40 301 2488069.500000 -23887291.531281494 133377636.959700242 57798398.255998142 -2617892.707621465903 -459650.174950940651 -207580.434036122402
41 399 2451545.000000 -26499033.677430496 132757417.338345051 57556718.470540732 -2574224.070085791871 -433559.719480311556 -187954.024564559892
42 399 2455197.500000 -26331886.663379308 132783019.476163641 57564916.131770104 -2572895.233377746306 -431404.139418599079 -186901.856015971338
43 399 2460409.250000 -141614561.775911272 -44875366.929020450 -19451994.188401155 798726.151406769990 -2241899.490074577276 -971954.226801195182
44 399 2470000.500000 52344709.055075817 -131003495.311840236 -56779941.691634409 2375381.176934503950 803200.719235604280 348196.328601448564
45 399 2488069.500000 -23547771.928055670 133241770.144845873 57731796.154016882 -2581876.036687009037 -386210.308926678495 -167170.899428247882