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
+42 -27
View File
@@ -8,51 +8,66 @@ import (
)
func TestOccultationStationEnvelopeEqualTimeBranches(t *testing.T) {
// 同时间戳平台是 JD 量化的产物:平台内的点与边都必须保留,只有真折返才切段。
for _, test := range []struct {
name string
times []int
unit time.Duration
segments int
}{
{"leading plateau", []int{0, 0, 0, 1, 2}, 1},
{"interior plateau", []int{0, 1, 1, 1, 2}, 2},
{"trailing plateau", []int{0, 1, 1, 1}, 1},
{"entire plateau", []int{0, 0, 0}, 0},
{"reverse branches", []int{2, 1, 1, 1, 0}, 2},
{"ordinary fold", []int{0, 1, 2, 1, 0}, 2},
{"leading plateau", []int{0, 0, 0, 1, 2}, time.Second, 1},
{"interior plateau", []int{0, 1, 1, 1, 2}, time.Second, 1},
{"trailing plateau", []int{0, 1, 1, 1}, time.Second, 1},
{"entire plateau", []int{0, 0, 0}, time.Second, 0},
{"reverse branches", []int{2, 1, 1, 1, 0}, time.Second, 1},
{"ordinary fold", []int{0, 1, 2, 1, 0}, time.Second, 2},
// Saturn 2025-01-05:时间折返的顶点恰好落进同一量化格。
{"plateau at fold", []int{0, 1, 2, 2, 1, 0}, time.Second, 2},
// 平台出口只晚 1.5 µs:亚量化偏移不能越过出口点。
{"tight plateau exit", []int{0, 4, 4, 4, 5}, 375 * time.Nanosecond, 1},
} {
t.Run(test.name, func(t *testing.T) {
points := make([]OccultationPathPoint, len(test.times))
for index, seconds := range test.times {
points[index] = OccultationPathPoint{Time: time.Unix(int64(seconds), 0), Longitude: float64(index)}
for index, value := range test.times {
points[index] = OccultationPathPoint{
Time: time.Unix(1000, 0).Add(time.Duration(value) * test.unit), Longitude: float64(index),
}
}
segments := occultationStationSplitEnvelopeAtTimeFolds(points)
if len(segments) != test.segments {
t.Fatalf("segments=%d, want %d", len(segments), test.segments)
}
if test.segments == 0 {
return
}
kept := make([]bool, len(points))
edges := map[[2]int]bool{}
for _, segment := range segments {
for index := 1; index < len(segment); index++ {
if !segment[index].Time.After(segment[index-1].Time) {
t.Fatalf("non-increasing segment: %+v", segment)
for index, point := range segment {
// 平台点只允许亚量化(< 1 ms)的时间调整,位置不变。
original := points[int(point.Longitude)]
if delta := point.Time.Sub(original.Time); delta < -time.Millisecond || delta > time.Millisecond {
t.Fatalf("point %v moved by %v", point.Longitude, delta)
}
kept[int(point.Longitude)] = true
if index > 0 {
if !point.Time.After(segment[index-1].Time) {
t.Fatalf("non-increasing segment: %+v", segment)
}
first, second := int(segment[index-1].Longitude), int(point.Longitude)
if first > second {
first, second = second, first
}
edges[[2]int{first, second}] = true
}
}
}
// Every edge with elapsed time must survive the split unchanged.
for index := 1; index < len(points); index++ {
first, second := points[index-1], points[index]
if first.Time.Equal(second.Time) {
continue
for index := range points {
if !kept[index] {
t.Fatalf("point %d dropped: %+v", index, segments)
}
if first.Time.After(second.Time) {
first, second = second, first
}
found := false
for _, segment := range segments {
for offset := 1; offset < len(segment); offset++ {
found = found || segment[offset-1] == first && segment[offset] == second
}
}
if !found {
t.Fatalf("lost monotone edge %d", index)
if index > 0 && !edges[[2]int{index - 1, index}] {
t.Fatalf("lost edge %d-%d: %+v", index-1, index, segments)
}
}
})