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

442 lines
13 KiB
Go

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)
}
}