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
+147
View File
@@ -0,0 +1,147 @@
package geojson_test
import (
"encoding/json"
"testing"
"time"
"b612.me/astro"
"b612.me/astro/geojson"
"b612.me/astro/mercury"
"b612.me/astro/venus"
)
type transitCollection struct {
TimeScale string `json:"time_scale"`
Features []struct {
Properties map[string]interface{} `json:"properties"`
Geometry struct {
Type string `json:"type"`
Coordinates json.RawMessage `json:"coordinates"`
} `json:"geometry"`
} `json:"features"`
}
func decodeTransit(t *testing.T, data []byte) (transitCollection, map[string]int) {
t.Helper()
var collection transitCollection
if err := json.Unmarshal(data, &collection); err != nil {
t.Fatal(err)
}
roles := map[string]int{}
for _, item := range collection.Features {
if item.Properties["event"] != "planet-transit" {
t.Fatalf("unexpected event %v", item.Properties["event"])
}
roles[item.Properties["role"].(string)]++
}
return collection, roles
}
// 2032-11-13 水星凌日:等时线四个接触俱全,区域与地平线成对输出,凌甚点落在太阳直射点。
func TestMarshalMercuryTransit(t *testing.T) {
info := mercury.ClosestTransit(time.Date(2032, 11, 13, 0, 0, 0, 0, time.UTC))
data, err := geojson.MarshalMercuryTransit(info, geojson.PlanetTransitOptions{
IsochroneStep: 20 * time.Second,
TimeMarkers: &geojson.TimeMarkerOptions{Step: 30 * time.Minute},
})
if err != nil {
t.Fatal(err)
}
collection, roles := decodeTransit(t, data)
for _, role := range []string{
"visible-at-c1", "c1-horizon", "visible-at-c4", "c4-horizon",
"transit-entire", "transit-at-sunrise", "transit-at-sunset",
"c1-isochrone", "c2-isochrone", "c3-isochrone", "c4-isochrone", "greatest",
} {
if roles[role] != 1 {
t.Fatalf("role %s count = %d, want 1", role, roles[role])
}
}
if roles["time-marker"] < 8 {
t.Fatalf("time markers = %d, want one every 30 min over ~4.4 h", roles["time-marker"])
}
if collection.TimeScale != "" {
t.Fatal("UTC export must not carry time_scale")
}
for _, item := range collection.Features {
role := item.Properties["role"]
switch role {
case "c1-isochrone", "c2-isochrone", "c3-isochrone", "c4-isochrone":
var lines [][][]float64
if err := json.Unmarshal(item.Geometry.Coordinates, &lines); err != nil {
t.Fatal(err)
}
times := item.Properties["times"].([]interface{})
if len(times) != len(lines) || len(lines) == 0 {
t.Fatalf("%s: times %d do not align with %d lines", role, len(times), len(lines))
}
for index, line := range lines {
if len(times[index].([]interface{})) != len(line) {
t.Fatalf("%s line %d: times length mismatch", role, index)
}
}
case "greatest":
if item.Properties["internal_start"] == nil || item.Properties["minimum_separation_arcsec"] == nil {
t.Fatal("greatest point must carry contact metadata")
}
}
}
}
// 默认不出等时线;SkipRoles 跳过全部分区后只剩地平线、可见区与凌甚点。
func TestMarshalVenusTransitDefaultsAndSkip(t *testing.T) {
info := venus.ClosestTransit(time.Date(2012, 6, 6, 0, 0, 0, 0, time.UTC))
data, err := geojson.MarshalVenusTransit(info, geojson.PlanetTransitOptions{})
if err != nil {
t.Fatal(err)
}
_, roles := decodeTransit(t, data)
if roles["c1-isochrone"] != 0 || roles["transit-entire"] != 1 {
t.Fatalf("default roles = %v", roles)
}
data, err = geojson.MarshalVenusTransit(info, geojson.PlanetTransitOptions{
SkipRoles: []string{"transit-entire", "transit-at-sunrise", "transit-at-sunset"},
})
if err != nil {
t.Fatal(err)
}
_, roles = decodeTransit(t, data)
if len(roles) != 5 {
t.Fatalf("skipped region roles = %v", roles)
}
}
func TestMarshalPlanetTransitValidation(t *testing.T) {
info := mercury.ClosestTransit(time.Date(2019, 11, 11, 0, 0, 0, 0, time.UTC))
if _, err := geojson.MarshalMercuryTransit(mercury.TransitInfo{}, geojson.PlanetTransitOptions{}); err == nil {
t.Fatal("invalid transit must be rejected")
}
if _, err := geojson.MarshalMercuryTransit(info, geojson.PlanetTransitOptions{IsochroneStep: time.Millisecond}); err == nil {
t.Fatal("sub-second isochrone step must be rejected")
}
swapped := info
swapped.Start, swapped.End = info.End, info.Start
if _, err := geojson.MarshalMercuryTransit(swapped, geojson.PlanetTransitOptions{}); err == nil {
t.Fatal("unordered contacts must be rejected")
}
inconsistent := info
inconsistent.HasInternal = false
if _, err := geojson.MarshalMercuryTransit(inconsistent, geojson.PlanetTransitOptions{IsochroneStep: time.Minute}); err == nil {
t.Fatal("internal contact times without HasInternal must be rejected")
}
if _, err := geojson.MarshalMercuryTransit(info, geojson.PlanetTransitOptions{
TimeMarkers: &geojson.TimeMarkerOptions{TimeScale: astro.TimeScaleUT1, Location: time.FixedZone("CST", 8*3600)},
}); err == nil {
t.Fatal("UT1 with a non-UTC location must be rejected")
}
data, err := geojson.MarshalMercuryTransit(info, geojson.PlanetTransitOptions{
TimeMarkers: &geojson.TimeMarkerOptions{TimeScale: astro.TimeScaleUT1},
})
if err != nil {
t.Fatal(err)
}
if collection, _ := decodeTransit(t, data); collection.TimeScale != "UT1" {
t.Fatal("UT1 export must carry time_scale")
}
}