feat: 引入可切换星历模型并扩展天象观测链路
- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算 - 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询 - 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出 - 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解 - 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤 - 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持 - 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理 - 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+45
@@ -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
|
||||
|
Reference in New Issue
Block a user