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
+256
View File
@@ -0,0 +1,256 @@
package catalog
import (
"math"
"os"
"strings"
"testing"
"testing/fstest"
"b612.me/astro/basic"
)
func TestReadCSVAndFilters(t *testing.T) {
input := "source_id,ra,dec,pmra,pmdec,parallax,radial_velocity,phot_g_mean_mag,bp_rp\n" +
"1,360,0,1,2,-3,,5,1\n" +
"2,1,91,1,2,3,0,6,1\n"
catalog, err := ReadCSV(strings.NewReader(input), GaiaDR3Columns(), ReadOptions{})
if err != nil {
t.Fatal(err)
}
if catalog.Len() != 1 || catalog.Skipped() != 1 {
t.Fatalf("len/skipped = %d/%d", catalog.Len(), catalog.Skipped())
}
entry, ok := catalog.Lookup("gaia dr3 1")
if !ok || entry.Coordinate.RA != 0 || entry.Coordinate.ParallaxMas != 0 || entry.Coordinate.RadialVelocityKmPerSecond != 0 {
t.Fatalf("unexpected entry: %#v", entry)
}
if !math.IsNaN(entry.BV) {
t.Fatalf("missing B-V = %v", entry.BV)
}
if value, ok := VMagnitude(entry); !ok || math.Abs(value-5.21414) > 1e-4 {
t.Fatalf("V magnitude = %v, %v", value, ok)
}
}
func TestReadCSVStreamsQuotedNewlinesAndComments(t *testing.T) {
input := "\ufeff# exported\n\nid,ra,dec,name\n" +
"a,10,20,\"two\nlines\"\n" +
"# note\n// note\n" +
"b,350,-20,plain\n"
columns := ColumnMap{ID: "id", RA: "ra", Dec: "dec", Epoch: julianYearTime(2000), Frame: basic.CoordinateFrameICRS}
catalog, err := ReadCSV(strings.NewReader(input), columns, ReadOptions{})
if err != nil {
t.Fatal(err)
}
if catalog.Len() != 2 || catalog.Skipped() != 0 {
t.Fatalf("len/skipped = %d/%d", catalog.Len(), catalog.Skipped())
}
_, err = ReadCSV(strings.NewReader("id,ra,dec\na,1,2\nb,x\"y,3\n"), columns, ReadOptions{Strict: true})
if err == nil || !strings.Contains(err.Error(), "line 3") {
t.Fatalf("strict parse error = %v", err)
}
}
func TestReadCSVReportsRecordStartLine(t *testing.T) {
columns := ColumnMap{ID: "id", RA: "ra", Dec: "dec", Epoch: julianYearTime(2000), Frame: basic.CoordinateFrameICRS}
cases := []struct {
input string
want string
}{
{"id,ra,dec,name\na,1,2,\"x\ny\nz\"\n,3,4,q\n", "line 5:"},
{"# c\n\nid,ra,dec,name\r\na,1,2,\"x\r\ny\"\r\n# note\r\n\r\nb,bad,4,q\r\n", "line 8:"},
{"id,ra,dec,name\n\"m\nn\",1,2,p\n\"k\nj\",bad,3,q\n", "line 4:"},
{"id,ra,dec,name\na,1,2," + strings.Repeat("w", 200000) + "\nb,1,2,\"s\nt\"\n,1,2,q\n", "line 5:"},
}
for _, c := range cases {
_, err := ReadCSV(strings.NewReader(c.input), columns, ReadOptions{Strict: true})
if err == nil || !strings.HasPrefix(err.Error(), c.want) {
t.Errorf("err = %v, want prefix %q", err, c.want)
}
}
}
func TestCSVStrictAndCosineConvention(t *testing.T) {
columns := ColumnMap{ID: "id", RA: "ra", Dec: "dec", PMRA: "pmra", PMDec: "pmdec", Epoch: GaiaDR3Columns().Epoch, Frame: basic.CoordinateFrameICRS}
withoutCos, err := ReadCSV(strings.NewReader("id,ra,dec,pmra,pmdec\npolar,0,80,10,2\n"), columns, ReadOptions{})
if err != nil {
t.Fatal(err)
}
columns.PMRAIncludesCosDec = true
withCos, err := ReadCSV(strings.NewReader("id,ra,dec,pmra,pmdec\npolar,0,80,1.7364818,2\n"), columns, ReadOptions{})
if err != nil {
t.Fatal(err)
}
if math.Abs(withoutCos.Entries()[0].Coordinate.ProperMotionRACosDecMasPerYear-withCos.Entries()[0].Coordinate.ProperMotionRACosDecMasPerYear) > 1e-5 {
t.Fatal("pmRA cosine convention differs")
}
_, err = ReadCSV(strings.NewReader("id,ra,dec\nx,0,91\n"), columns, ReadOptions{Strict: true})
if err == nil || !strings.Contains(err.Error(), "record 2") {
t.Fatalf("strict error = %v", err)
}
}
func TestCatalogSearchAndMerge(t *testing.T) {
base := newCatalog()
base.add(Entry{ID: "A", Aliases: []string{"Alpha"}, Coordinate: basic.StarCoordinate{ID: "A", RA: 359.9, Dec: 0, Epoch: GaiaDR3Columns().Epoch, Frame: basic.CoordinateFrameICRS}})
base.finalize()
other := newCatalog()
other.add(Entry{ID: "A", Aliases: []string{"Beta"}, Coordinate: base.entries[0].Coordinate})
other.finalize()
merged := base.Merge(other)
if _, ok := merged.Lookup("beta"); !ok {
t.Fatal("merged alias missing")
}
if got := merged.Near(0, 0, 1); len(got) != 1 {
t.Fatalf("near across zero = %d", len(got))
}
if got := merged.Band(-1, 1); len(got) != 1 {
t.Fatalf("band = %d", len(got))
}
}
func TestMergeKeepsComponents(t *testing.T) {
coordinate := basic.StarCoordinate{ID: "HIP 1", RA: 10, Dec: 0, Epoch: GaiaDR3Columns().Epoch, Frame: basic.CoordinateFrameICRS}
first := newCatalog()
first.add(Entry{ID: "HIP 1", Component: "A", Magnitude: 1, Coordinate: coordinate})
first.finalize()
second := newCatalog()
second.add(Entry{ID: "HIP 1", Component: "B", Magnitude: 8, Coordinate: coordinate})
second.finalize()
merged := first.Merge(second)
if merged.Len() != 2 {
t.Fatalf("merged len = %d", merged.Len())
}
if entry, ok := merged.Lookup("HIP 1"); !ok || entry.Component != "A" {
t.Fatalf("lookup component = %q", entry.Component)
}
}
func TestReadStellariumFixture(t *testing.T) {
file, err := os.Open("testdata/stars_0_0v0_18.cat")
if err != nil {
t.Fatal(err)
}
defer file.Close()
catalog, err := ReadStellariumStars(file, ReadOptions{Strict: true})
if err != nil {
t.Fatal(err)
}
checks := map[string][4]float64{
"HIP 32349": {101.284621, -16.721552, -546.06, -1223.15},
"HIP 91262": {279.235881, 38.784961, 0, 0},
"HIP 24436": {78.634473, -8.201636, 0, 0},
}
for id, want := range checks {
entry, ok := catalog.Lookup(id)
if !ok {
t.Fatalf("missing %s", id)
}
got := entry.Coordinate
if math.Abs(got.RA-want[0]) > 1e-6 || math.Abs(got.Dec-want[1]) > 1e-6 {
t.Fatalf("%s position = %.9f %.9f", id, got.RA, got.Dec)
}
if want[2] != 0 && math.Abs(got.ProperMotionRACosDecMasPerYear-want[2]) > 0.02 {
t.Fatalf("%s pmRA = %.6f", id, got.ProperMotionRACosDecMasPerYear)
}
}
}
func TestCrossIDLengthAndStellariumDir(t *testing.T) {
fixture := func(name string) []byte {
data, err := os.ReadFile("testdata/" + name)
if err != nil {
t.Fatal(err)
}
return data
}
catalog, err := ReadStellariumDir(fstest.MapFS{
"stars_0_0v0_18.cat": &fstest.MapFile{Data: fixture("stars_0_0v0_18.cat")},
"name.fab": &fstest.MapFile{Data: fixture("name.fab")},
"extra_name.fab": &fstest.MapFile{Data: fixture("extra_name.fab")},
"cross-id.cat": &fstest.MapFile{Data: fixture("cross-id.cat")},
}, StellariumOptions{Names: true, CrossID: true}, ReadOptions{Strict: true})
if err != nil {
t.Fatal(err)
}
entry, ok := catalog.Lookup("HIP 32349")
if !ok || !contains(entry.Aliases, "SAO 151881") || !contains(entry.Aliases, "α CMa") {
t.Fatalf("aliases missing: %#v", entry.Aliases)
}
}
func contains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func mustFixture(t *testing.T) []byte {
t.Helper()
data, err := os.ReadFile("testdata/stars_0_0v0_18.cat")
if err != nil {
t.Fatal(err)
}
return data
}
func TestReadCSVRejectsNonFiniteMotion(t *testing.T) {
header := "source_id,ra,dec,pmra,pmdec,parallax,radial_velocity,phot_g_mean_mag,bp_rp\n"
for _, row := range []string{"1,10,0,1,2,3,+Inf,5,1\n", "1,10,0,1,2,3,-Inf,5,1\n", "1,10,0,1,2,+Inf,0,5,1\n", "1,10,0,1,2,3,1e6,5,1\n"} {
if _, err := ReadCSV(strings.NewReader(header+row), GaiaDR3Columns(), ReadOptions{Strict: true}); err == nil {
t.Errorf("strict read accepted %q", row)
}
catalog, err := ReadCSV(strings.NewReader(header+row), GaiaDR3Columns(), ReadOptions{})
if err != nil || catalog.Len() != 0 || catalog.Skipped() != 1 {
t.Errorf("lenient read of %q: len %d skipped %d err %v, want the record skipped", row, catalog.Len(), catalog.Skipped(), err)
}
}
}
func TestReadCSVDropsDuplicateIDs(t *testing.T) {
input := "source_id,ra,dec,pmra,pmdec,parallax,radial_velocity,phot_g_mean_mag,bp_rp\n" +
"1,10,5,0,0,1,,5,1\n" +
"1,20,6,0,0,1,,6,1\n" +
"2,30,7,0,0,1,,7,1\n"
catalog, err := ReadCSV(strings.NewReader(input), GaiaDR3Columns(), ReadOptions{})
if err != nil {
t.Fatal(err)
}
if catalog.Len() != 2 || catalog.Skipped() != 1 {
t.Fatalf("len/skipped = %d/%d", catalog.Len(), catalog.Skipped())
}
entry, ok := catalog.Lookup("gaia dr3 1")
if !ok || entry.Coordinate.RA != 10 {
t.Fatalf("lookup kept %#v", entry)
}
if near := catalog.Near(20, 6, 0.5); len(near) != 0 {
t.Fatalf("dropped duplicate still reachable through Near: %#v", near)
}
}
func TestCSVNonFiniteParallax(t *testing.T) {
columns := ColumnMap{ID: "id", RA: "ra", Dec: "dec", Parallax: "plx",
Epoch: julianYearTime(2000), Frame: basic.CoordinateFrameICRS}
for _, parallax := range []string{"+Inf", "-Inf"} {
input := "id,ra,dec,plx\nx,10,20," + parallax + "\n"
if _, err := ReadCSV(strings.NewReader(input), columns, ReadOptions{Strict: true}); err == nil {
t.Fatalf("parallax %q: strict mode should reject a non-finite parallax", parallax)
}
loose, err := ReadCSV(strings.NewReader(input), columns, ReadOptions{})
if err != nil || loose.Len() != 0 || loose.Skipped() != 1 {
t.Fatalf("parallax %q: loose = %d entries, %d skipped, %v", parallax, loose.Len(), loose.Skipped(), err)
}
}
// 有限的负视差按无视差处理,仍然入库。
fine, err := ReadCSV(strings.NewReader("id,ra,dec,plx\nx,10,20,-1.5\n"), columns, ReadOptions{Strict: true})
if err != nil || fine.Len() != 1 {
t.Fatalf("finite negative parallax should load: len=%d err=%v", fine.Len(), err)
}
if entry, ok := fine.Lookup("x"); !ok || entry.Coordinate.ParallaxMas != 0 {
t.Fatalf("finite negative parallax should become 0: %v %v", entry.Coordinate.ParallaxMas, ok)
}
}