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