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
+120
View File
@@ -0,0 +1,120 @@
package catalog
import (
"bytes"
"encoding/binary"
"os"
"strings"
"testing"
"testing/fstest"
)
func TestStellariumBadFiles(t *testing.T) {
data := mustFixture(t)
bigEndianMagic := make([]byte, 4)
binary.BigEndian.PutUint32(bigEndianMagic, stellariumMagic)
cases := map[string][]byte{
"truncated": data[:len(data)-7],
"trailing": append(append([]byte(nil), data...), 0),
"bad magic": append([]byte{1, 2, 3, 4}, data[4:]...),
"short head": data[:20],
"big endian": append(bigEndianMagic, data[4:]...),
"bad version": append(append(append([]byte(nil), data[:8]...), 1, 0, 0, 0), data[12:]...),
}
for name, input := range cases {
if _, err := ReadStellariumStars(bytes.NewReader(input), ReadOptions{Strict: true}); err == nil {
t.Fatalf("%s: strict read should fail", name)
}
}
loose, err := ReadStellariumStars(bytes.NewReader(cases["truncated"]), ReadOptions{})
if err != nil || loose.Skipped() != 1 || loose.Len() == 0 {
t.Fatalf("loose truncated read = %d entries, %d skipped, %v", loose.Len(), loose.Skipped(), err)
}
}
func TestStellariumMaxMagnitude(t *testing.T) {
data := mustFixture(t)
dir := fstest.MapFS{"stars_0_0v0_18.cat": &fstest.MapFile{Data: data}}
all, err := ReadStellariumDir(dir, StellariumOptions{}, ReadOptions{Strict: true})
if err != nil {
t.Fatal(err)
}
bright, err := ReadStellariumDir(dir, StellariumOptions{MaxMagnitude: 0.5}, ReadOptions{Strict: true})
if err != nil {
t.Fatal(err)
}
if bright.Len() == 0 || bright.Len() >= all.Len() {
t.Fatalf("MaxMagnitude 0.5 kept %d of %d", bright.Len(), all.Len())
}
for _, entry := range bright.Entries() {
if entry.Magnitude > 0.5 {
t.Fatalf("%s V %.2f exceeds limit", entry.ID, entry.Magnitude)
}
}
// 文件头 mag_min = -2.00,阈值更亮时整片跳过。
none, err := ReadStellariumDir(dir, StellariumOptions{MaxMagnitude: -3}, ReadOptions{Strict: true})
if err != nil || none.Len() != 0 {
t.Fatalf("whole shard should be skipped: %d, %v", none.Len(), err)
}
}
func TestCrossIDBadLength(t *testing.T) {
data := mustFixture(t)
crossID, err := os.ReadFile("testdata/cross-id.cat")
if err != nil {
t.Fatal(err)
}
dir := fstest.MapFS{
"stars_0_0v0_18.cat": &fstest.MapFile{Data: data},
"cross-id.cat": &fstest.MapFile{Data: crossID[:len(crossID)-3]},
}
if _, err := ReadStellariumDir(dir, StellariumOptions{CrossID: true}, ReadOptions{Strict: true}); err == nil {
t.Fatal("cross-id length not a multiple of 21 should fail in strict mode")
}
}
func TestCDSBadLines(t *testing.T) {
if _, err := ReadHipparcos2007(strings.NewReader(" 1234 garbage\n"), ReadOptions{Strict: true}); err == nil {
t.Fatal("short hip2 line should fail in strict mode")
}
loose, err := ReadHipparcos2007(strings.NewReader(" 1234 garbage\n"), ReadOptions{})
if err != nil || loose.Len() != 0 || loose.Skipped() != 1 {
t.Fatalf("loose hip2 = %d entries, %d skipped, %v", loose.Len(), loose.Skipped(), err)
}
}
// hipParallaxLine 造一条最小可解析的 hip2 定宽记录,parallax 为 43..50 列的原始文本。
func hipParallaxLine(parallax string) string {
line := []byte(strings.Repeat(" ", 160))
put := func(value string, from, to int) { copy(line[from:to], value) }
put(" 42", 0, 6)
put(" 1.00000000", 15, 28)
put(" -0.20000000", 29, 42)
put(parallax, 43, 50)
put(" -1.00000", 51, 59)
put(" -2.00000", 60, 68)
put(" -1.4600", 129, 136)
put(" 0.000", 152, 158)
return string(line)
}
func TestHipparcosNonFiniteParallax(t *testing.T) {
for _, parallax := range []string{" +Inf", " -Inf"} {
input := strings.NewReader(hipParallaxLine(parallax) + "\n")
if _, err := ReadHipparcos2007(input, ReadOptions{Strict: true}); err == nil {
t.Fatalf("parallax %q: strict mode should reject a non-finite parallax", parallax)
}
loose, err := ReadHipparcos2007(strings.NewReader(hipParallaxLine(parallax)+"\n"), 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 := ReadHipparcos2007(strings.NewReader(hipParallaxLine(" -1.0")+"\n"), 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("HIP 42"); !ok || entry.Coordinate.ParallaxMas != 0 {
t.Fatalf("finite negative parallax should become 0: %v %v", entry.Coordinate.ParallaxMas, ok)
}
}
+165
View File
@@ -0,0 +1,165 @@
package catalog
import (
"math"
"sort"
"strings"
)
// Near 返回星表历元下的锥形邻域,按角距由近到远 / cone search at catalog epochs, nearest first.
func (c *Catalog) Near(ra, dec, radius float64) []Entry {
if c == nil || !finite(ra) || !finite(dec) || !finite(radius) || radius < 0 || dec < -90 || dec > 90 {
return nil
}
ra = normalizeRA(ra)
// 锥面不含天极时,小圆的 RA 半宽恰为 asin(sin r / cos δ),且 |δ|+r < 90 保证比值 < 1;含天极时整带扫描。
halfWidth := 180.0
if math.Abs(dec)+radius < 90 {
halfWidth = math.Asin(math.Sin(radius*math.Pi/180)/math.Cos(dec*math.Pi/180)) * 180 / math.Pi
}
type hit struct {
index int
distance float64
}
hits := make([]hit, 0)
collect := func(indexes []int) {
for _, index := range indexes {
coordinate := c.entries[index].Coordinate
if distance := angularDistanceDeg(ra, dec, coordinate.RA, coordinate.Dec); distance <= radius+1e-12 {
hits = append(hits, hit{index: index, distance: distance})
}
}
}
for band := decBand(dec - radius); band <= decBand(dec+radius); band++ {
indexes := c.bands[band]
if halfWidth >= 180 {
collect(indexes)
continue
}
low, high := ra-halfWidth-1e-9, ra+halfWidth+1e-9
collect(c.raRange(indexes, math.Max(low, 0), math.Min(high, 360)))
if low < 0 {
collect(c.raRange(indexes, low+360, 360))
}
if high > 360 {
collect(c.raRange(indexes, 0, high-360))
}
}
sort.SliceStable(hits, func(i, j int) bool { return hits[i].distance < hits[j].distance })
result := make([]Entry, len(hits))
for i, item := range hits {
result[i] = cloneEntry(c.entries[item.index])
}
return result
}
// raRange 返回带内 RA ∈ [low, high] 的下标切片;带内已按 RA 升序。
func (c *Catalog) raRange(indexes []int, low, high float64) []int {
start := sort.Search(len(indexes), func(i int) bool { return c.entries[indexes[i]].Coordinate.RA >= low })
end := sort.Search(len(indexes), func(i int) bool { return c.entries[indexes[i]].Coordinate.RA > high })
if start >= end {
return nil
}
return indexes[start:end]
}
// Band 返回赤纬带内的记录 / records in a declination band.
func (c *Catalog) Band(decMin, decMax float64) []Entry {
if c == nil || !finite(decMin) || !finite(decMax) || decMin > decMax || decMax < -90 || decMin > 90 {
return nil
}
if decMin < -90 {
decMin = -90
}
if decMax > 90 {
decMax = 90
}
result := make([]Entry, 0)
for band := decBand(decMin); band <= decBand(decMax); band++ {
for _, index := range c.bands[band] {
entry := c.entries[index]
if entry.Coordinate.Dec >= decMin && entry.Coordinate.Dec <= decMax {
result = append(result, cloneEntry(entry))
}
}
}
return result
}
// EclipticBandFilter 创建按 J2000 平黄道纬度过滤的函数 / build a mean-ecliptic latitude filter.
func EclipticBandFilter(halfWidthDeg float64) func(Entry) bool {
if !finite(halfWidthDeg) || halfWidthDeg < 0 {
return func(Entry) bool { return false }
}
if halfWidthDeg >= 90 {
return func(Entry) bool { return true }
}
const obliquity = 23.439291111
return func(entry Entry) bool {
ra := entry.Coordinate.RA * math.Pi / 180
dec := entry.Coordinate.Dec * math.Pi / 180
eps := obliquity * math.Pi / 180
beta := math.Asin(math.Sin(dec)*math.Cos(eps) - math.Cos(dec)*math.Sin(eps)*math.Sin(ra))
return math.Abs(beta*180/math.Pi) <= halfWidthDeg
}
}
// MagnitudeFilter 创建按 V 星等过滤的函数 / build a V-magnitude filter.
func MagnitudeFilter(maxV float64) func(Entry) bool {
if !finite(maxV) {
return func(Entry) bool { return false }
}
return func(entry Entry) bool {
value, ok := VMagnitude(entry)
return ok && value <= maxV
}
}
// VMagnitude 将支持的波段换算为 V / convert supported bands to V magnitude.
func VMagnitude(entry Entry) (float64, bool) {
if !finite(entry.Magnitude) {
return 0, false
}
switch strings.ToUpper(string(entry.Band)) {
case "V":
return entry.Magnitude, true
case "G":
if !finite(entry.BPRP) || entry.BPRP <= -0.5 || entry.BPRP >= 5.0 {
return 0, false
}
x := entry.BPRP
gMinusV := -0.02704 + 0.01424*x - 0.2156*x*x + 0.01426*x*x*x
return entry.Magnitude - gMinusV, true
default:
return 0, false
}
}
func angularDistanceDeg(ra1, dec1, ra2, dec2 float64) float64 {
const rad = math.Pi / 180
lat1, lat2 := dec1*rad, dec2*rad
dra := math.Abs((ra1 - ra2) * rad)
if dra > math.Pi {
dra = 2*math.Pi - dra
}
value := math.Sin(lat1)*math.Sin(lat2) + math.Cos(lat1)*math.Cos(lat2)*math.Cos(dra)
if value > 1 {
value = 1
}
if value < -1 {
value = -1
}
return math.Acos(value) / rad
}
func normalizeRA(value float64) float64 {
value = math.Mod(value, 360)
if value < 0 {
value += 360
}
return value
}
func finite(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
+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)
}
}
+244
View File
@@ -0,0 +1,244 @@
package catalog
import (
"bufio"
"fmt"
"io"
"math"
"strings"
"time"
"b612.me/astro/basic"
)
// ReadHipparcos2007 读取 Hipparcos 定宽星表 / read the Hipparcos fixed-width catalog.
func ReadHipparcos2007(r io.Reader, options ReadOptions) (*Catalog, error) {
catalog := newCatalog()
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 2*1024*1024)
lineNumber := 0
for scanner.Scan() {
lineNumber++
line := scanner.Bytes()
if isIgnorableFixedLine(line) {
continue
}
entry, err := hipparcosEntry(line, lineNumber)
if err != nil {
if options.Strict {
return nil, err
}
catalog.skipped++
continue
}
if err := addEntry(catalog, entry, lineNumber, options); err != nil {
return nil, err
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
catalog.finalize()
return catalog, nil
}
func hipparcosEntry(line []byte, lineNumber int) (Entry, error) {
hip, present, err := fixedInt(line, 0, 6)
if err != nil || !present || hip <= 0 {
return Entry{}, fixedError(lineNumber, "invalid HIP")
}
ra, present, err := fixedFloat(line, 15, 28)
if err != nil || !present {
return Entry{}, fixedError(lineNumber, "invalid RArad")
}
dec, present, err := fixedFloat(line, 29, 42)
if err != nil || !present {
return Entry{}, fixedError(lineNumber, "invalid DErad")
}
parallax, _, err := fixedFloat(line, 43, 50)
if err != nil {
return Entry{}, fixedError(lineNumber, "invalid Plx")
}
pmRA, _, err := fixedFloat(line, 51, 59)
if err != nil {
return Entry{}, fixedError(lineNumber, "invalid pmRA")
}
pmDec, _, err := fixedFloat(line, 60, 68)
if err != nil {
return Entry{}, fixedError(lineNumber, "invalid pmDE")
}
magnitude, magnitudePresent, err := fixedFloat(line, 129, 136)
if err != nil {
return Entry{}, fixedError(lineNumber, "invalid Hpmag")
}
bv, bvPresent, err := fixedFloat(line, 152, 158)
if err != nil {
return Entry{}, fixedError(lineNumber, "invalid B-V")
}
id := fmt.Sprintf("HIP %d", hip)
// 非正视差按无视差处理;非有限值原样保留,交给 Validate 拒收。
if finite(parallax) && parallax <= 0 {
parallax = 0
}
if !magnitudePresent {
magnitude = math.NaN()
}
if !bvPresent {
bv = math.NaN()
}
return Entry{
ID: id,
Coordinate: parseCoordinate(id, angularFromRadians(ra), angularFromRadians(dec), pmRA, pmDec, parallax, 0,
julianYearTime(1991.25), basic.CoordinateFrameICRS),
Magnitude: magnitude,
Band: BandHp,
BPRP: math.NaN(),
BV: bv,
}, nil
}
// ReadBSC5 读取 BSC5 定宽星表 / read the BSC5 fixed-width catalog.
func ReadBSC5(r io.Reader, options ReadOptions) (*Catalog, error) {
catalog := newCatalog()
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 2*1024*1024)
lineNumber := 0
for scanner.Scan() {
lineNumber++
line := scanner.Bytes()
if isIgnorableFixedLine(line) {
continue
}
entry, present, err := bsc5Entry(line, lineNumber)
if err != nil {
if options.Strict {
return nil, err
}
catalog.skipped++
continue
}
if !present {
catalog.skipped++
continue
}
if err := addEntry(catalog, entry, lineNumber, options); err != nil {
return nil, err
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
catalog.finalize()
return catalog, nil
}
func bsc5Entry(line []byte, lineNumber int) (Entry, bool, error) {
hr, present, err := fixedInt(line, 0, 4)
if err != nil {
return Entry{}, false, fixedError(lineNumber, "invalid HR")
}
if !present || hr <= 0 {
return Entry{}, false, nil
}
name, _ := fixedField(line, 4, 14)
hd, _, err := fixedInt(line, 25, 31)
if err != nil {
return Entry{}, false, fixedError(lineNumber, "invalid HD")
}
sao, _, err := fixedInt(line, 31, 37)
if err != nil {
return Entry{}, false, fixedError(lineNumber, "invalid SAO")
}
raHour, raHourPresent, err := fixedInt(line, 75, 77)
if err != nil {
return Entry{}, false, fixedError(lineNumber, "invalid RA hour")
}
raMinute, raMinutePresent, err := fixedInt(line, 77, 79)
if err != nil {
return Entry{}, false, fixedError(lineNumber, "invalid RA minute")
}
raSecond, raSecondPresent, err := fixedFloat(line, 79, 83)
if err != nil {
return Entry{}, false, fixedError(lineNumber, "invalid RA second")
}
decSign, decSignPresent := fixedField(line, 83, 84)
decDegree, decDegreePresent, err := fixedInt(line, 84, 86)
if err != nil {
return Entry{}, false, fixedError(lineNumber, "invalid Dec degree")
}
decMinute, decMinutePresent, err := fixedInt(line, 86, 88)
if err != nil {
return Entry{}, false, fixedError(lineNumber, "invalid Dec minute")
}
decSecond, decSecondPresent, err := fixedInt(line, 88, 90)
if err != nil {
return Entry{}, false, fixedError(lineNumber, "invalid Dec second")
}
if !raHourPresent || !raMinutePresent || !raSecondPresent || !decSignPresent || !decDegreePresent || !decMinutePresent || !decSecondPresent {
return Entry{}, false, nil
}
ra := (float64(raHour) + float64(raMinute)/60 + raSecond/3600) * 15
dec := float64(decDegree) + float64(decMinute)/60 + float64(decSecond)/3600
if strings.HasPrefix(decSign, "-") {
dec = -dec
}
vmag, vmPresent, err := fixedFloat(line, 102, 107)
if err != nil {
return Entry{}, false, fixedError(lineNumber, "invalid Vmag")
}
bv, bvPresent, err := fixedFloat(line, 109, 114)
if err != nil {
return Entry{}, false, fixedError(lineNumber, "invalid B-V")
}
pmRA, _, err := fixedFloat(line, 148, 154)
if err != nil {
return Entry{}, false, fixedError(lineNumber, "invalid pmRA")
}
pmDec, _, err := fixedFloat(line, 154, 160)
if err != nil {
return Entry{}, false, fixedError(lineNumber, "invalid pmDE")
}
parallax, _, err := fixedFloat(line, 161, 166)
if err != nil {
return Entry{}, false, fixedError(lineNumber, "invalid parallax")
}
radial, _, err := fixedInt(line, 166, 170)
if err != nil {
return Entry{}, false, fixedError(lineNumber, "invalid radial velocity")
}
id := fmt.Sprintf("HR %d", hr)
aliases := make([]string, 0, 3)
if name != "" {
aliases = append(aliases, strings.Join(strings.Fields(name), " "))
}
if hd > 0 {
aliases = append(aliases, fmt.Sprintf("HD %d", hd))
}
if sao > 0 {
aliases = append(aliases, fmt.Sprintf("SAO %d", sao))
}
if !vmPresent {
vmag = math.NaN()
}
if !bvPresent {
bv = math.NaN()
}
// 非正视差按无视差处理;非有限值原样保留,交给 Validate 拒收。
if finite(parallax) && parallax <= 0 {
parallax = 0
}
return Entry{
ID: id, Aliases: aliases,
Coordinate: parseCoordinate(id, ra, dec, pmRA*1000, pmDec*1000, parallax*1000, float64(radial), time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), basic.CoordinateFrameJ2000),
Magnitude: vmag, Band: BandV, BPRP: math.NaN(), BV: bv,
}, true, nil
}
func isIgnorableFixedLine(line []byte) bool {
trimmed := strings.TrimSpace(string(line))
return trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "//")
}
func fixedError(line int, reason string) error {
return fmt.Errorf("record %d: %s", line, reason)
}
+141
View File
@@ -0,0 +1,141 @@
package catalog
import (
"math"
"os"
"strings"
"testing"
"time"
"b612.me/astro/basic"
)
// propagateToJ2000 按线性空间运动把记录推到 J2000.0,返回赤经赤纬(度)。
func propagateToJ2000(entry Entry) (float64, float64) {
c := entry.Coordinate
j2000 := basic.JD2DateByZone(2451545.0, time.UTC, false)
years := j2000.Sub(c.Epoch).Hours() / 24 / 365.25
ra := c.RA * math.Pi / 180
dec := c.Dec * math.Pi / 180
p := [3]float64{math.Cos(dec) * math.Cos(ra), math.Cos(dec) * math.Sin(ra), math.Sin(dec)}
east := [3]float64{-math.Sin(ra), math.Cos(ra), 0}
north := [3]float64{-math.Sin(dec) * math.Cos(ra), -math.Sin(dec) * math.Sin(ra), math.Cos(dec)}
masToRad := math.Pi / 180 / 3600 / 1000
var q [3]float64
for i := range q {
q[i] = p[i] + (east[i]*c.ProperMotionRACosDecMasPerYear+north[i]*c.ProperMotionDecMasPerYear)*masToRad*years
}
return math.Mod(math.Atan2(q[1], q[0])*180/math.Pi+360, 360), math.Atan2(q[2], math.Hypot(q[0], q[1])) * 180 / math.Pi
}
func separationMas(ra1, dec1, ra2, dec2 float64) float64 {
dra := (ra1 - ra2) * math.Cos(dec1*math.Pi/180)
if dra > 180 {
dra -= 360
}
if dra < -180 {
dra += 360
}
return math.Hypot(dra, dec1-dec2) * 3600 * 1000
}
func readFixtureCatalog(t *testing.T, name string, read func(*os.File) (*Catalog, error)) *Catalog {
t.Helper()
file, err := os.Open("testdata/" + name)
if err != nil {
t.Fatal(err)
}
defer file.Close()
catalog, err := read(file)
if err != nil {
t.Fatal(err)
}
return catalog
}
func TestCrossFormatPositionsAtJ2000(t *testing.T) {
stellarium := readFixtureCatalog(t, "stars_0_0v0_18.cat", func(f *os.File) (*Catalog, error) {
return ReadStellariumStars(f, ReadOptions{Strict: true})
})
hip := readFixtureCatalog(t, "hip2_sample.dat", func(f *os.File) (*Catalog, error) {
return ReadHipparcos2007(f, ReadOptions{Strict: true})
})
bsc := readFixtureCatalog(t, "bsc5_sample.dat", func(f *os.File) (*Catalog, error) {
return ReadBSC5(f, ReadOptions{})
})
if bsc.Skipped() != 1 {
t.Fatalf("BSC5 skipped = %d, want the record without coordinates", bsc.Skipped())
}
stars := map[string]string{"HIP 32349": "HR 2491", "HIP 91262": "HR 7001", "HIP 24436": "HR 1713", "HIP 21421": "HR 1457", "HIP 11767": "HR 424"}
for hipID, hrID := range stars {
h, ok := hip.Lookup(hipID)
if !ok {
t.Fatalf("hip2 missing %s", hipID)
}
b, ok := bsc.Lookup(hrID)
if !ok {
t.Fatalf("BSC5 missing %s", hrID)
}
hra, hdec := propagateToJ2000(h)
bra, bdec := propagateToJ2000(b)
// BSC5 只给到 0.1 s 与 1″,实测最大约 0.75″。
if d := separationMas(hra, hdec, bra, bdec); d > 1000 {
t.Fatalf("%s hip2 vs BSC5 = %.1f mas", hipID, d)
}
if s, ok := stellarium.Lookup(hipID); ok {
sra, sdec := propagateToJ2000(s)
// 两者同源于 Hipparcos 2007,实测 ≤ 0.5 mas。
if d := separationMas(hra, hdec, sra, sdec); d > 2 {
t.Fatalf("%s hip2 vs Stellarium = %.1f mas", hipID, d)
}
}
}
}
func TestCDSFieldConventions(t *testing.T) {
hip := readFixtureCatalog(t, "hip2_sample.dat", func(f *os.File) (*Catalog, error) {
return ReadHipparcos2007(f, ReadOptions{Strict: true})
})
sirius, _ := hip.Lookup("HIP 32349")
if math.Abs(sirius.Coordinate.ParallaxMas-379.21) > 1e-6 || math.Abs(sirius.Coordinate.ProperMotionRACosDecMasPerYear+546.01) > 1e-6 || sirius.Band != "Hp" {
t.Fatalf("hip2 Sirius = %#v", sirius)
}
bsc := readFixtureCatalog(t, "bsc5_sample.dat", func(f *os.File) (*Catalog, error) {
return ReadBSC5(f, ReadOptions{})
})
vega, ok := bsc.Lookup("alp lyr")
if !ok {
vega, ok = bsc.Lookup("HD 172167")
}
if !ok || math.Abs(vega.Coordinate.ParallaxMas-123) > 1e-6 || vega.Coordinate.RadialVelocityKmPerSecond != -14 || vega.Band != "V" {
t.Fatalf("BSC5 Vega = %#v", vega)
}
if math.Abs(vega.Coordinate.ProperMotionRACosDecMasPerYear-202) > 1e-6 {
t.Fatalf("BSC5 pmRA must already include cos(dec): %.3f", vega.Coordinate.ProperMotionRACosDecMasPerYear)
}
}
func TestCommonNames(t *testing.T) {
base := readFixtureCatalog(t, "stars_0_0v0_18.cat", func(f *os.File) (*Catalog, error) {
return ReadStellariumStars(f, ReadOptions{Strict: true})
})
names := "# comment\n 32349|_(\"Sirius\") 2\n91262|_(\"Vega\") 2\n32349|_(\"天狼\") 1\n24436|Rigel\n"
named, err := base.WithNames(strings.NewReader(names), ReadOptions{Strict: true})
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"sirius", "天狼", "VEGA", "Rigel"} {
if _, ok := named.Lookup(name); !ok {
t.Fatalf("lookup %q failed", name)
}
}
if entry, _ := named.Lookup("Sirius"); entry.ID != "HIP 32349" || entry.Magnitude > -1 {
t.Fatalf("Sirius = %s V %.2f", entry.ID, entry.Magnitude)
}
if _, ok := base.Lookup("Sirius"); ok {
t.Fatal("WithNames must not modify the receiver")
}
if _, err := base.WithNames(strings.NewReader("32349|_(\"broken\n"), ReadOptions{Strict: true}); err == nil {
t.Fatal("unterminated name should fail in strict mode")
}
}
+314
View File
@@ -0,0 +1,314 @@
package catalog
import (
"bufio"
"encoding/csv"
"errors"
"fmt"
"io"
"math"
"strings"
)
// ReadCSV 读取 CSV/TSV 星表 / read a CSV or TSV catalog.
func ReadCSV(r io.Reader, columns ColumnMap, options ReadOptions) (*Catalog, error) {
buffered := bufio.NewReaderSize(r, 64*1024)
header, headerLine, delimiter, err := readCSVHeader(buffered, options.Delimiter)
if err != nil {
return nil, err
}
indexes := buildColumnIndexes(header, columns)
if columns.ID == "" || columns.RA == "" || columns.Dec == "" || indexes.id < 0 || indexes.ra < 0 || indexes.dec < 0 {
return nil, fmt.Errorf("header line %d: required ID/RA/Dec column missing", headerLine)
}
if columns.EpochColumn != "" && indexes.epoch < 0 {
return nil, fmt.Errorf("header line %d: epoch column %q missing", headerLine, columns.EpochColumn)
}
catalog := newCatalog()
source := &csvLineSource{r: buffered}
reader := csv.NewReader(source)
reader.Comma = delimiter
reader.Comment = '#'
reader.FieldsPerRecord = -1
reader.ReuseRecord = true
for {
fields, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
var parseErr *csv.ParseError
if !errors.As(err, &parseErr) {
return nil, err
}
if options.Strict {
return nil, fmt.Errorf("line %d: %w", parseErr.Line+headerLine, err)
}
catalog.skipped++
continue
}
// 引号内换行是记录跨越的行数,从当前行回推即得记录首行。
line := source.line + headerLine
for _, field := range fields {
line -= strings.Count(field, "\n")
}
first := strings.TrimSpace(fields[0])
if (len(fields) == 1 && first == "") || strings.HasPrefix(first, "#") || strings.HasPrefix(first, "//") ||
isDelimitedSeparator(fields) || isDelimitedUnits(fields, indexes) {
continue
}
entry, parseErr := csvEntry(fields, indexes, columns, line)
if parseErr != nil {
if options.Strict {
return nil, parseErr
}
catalog.skipped++
continue
}
if err := addEntry(catalog, entry, line, options); err != nil {
return nil, err
}
}
catalog.finalize()
return catalog, nil
}
// csvLineSource 每次 Read 至多交付一行,line 为已开始交付的行数;csv.Reader 只在需要时才读,因此 line 即其当前所在行。
type csvLineSource struct {
r *bufio.Reader
pending []byte
err error
line int
midLine bool
}
func (s *csvLineSource) Read(p []byte) (int, error) {
if len(s.pending) == 0 {
if s.err != nil {
return 0, s.err
}
chunk, err := s.r.ReadSlice('\n')
if err != nil && err != bufio.ErrBufferFull {
s.err = err
}
if len(chunk) == 0 {
return 0, s.err
}
if !s.midLine {
s.line++
}
s.midLine = chunk[len(chunk)-1] != '\n'
s.pending = chunk
}
n := copy(p, s.pending)
s.pending = s.pending[n:]
return n, nil
}
// readCSVHeader 跳过注释与空行读出表头;未指定分隔符时按表头里 Tab 与逗号的多少判定。
func readCSVHeader(r *bufio.Reader, delimiter rune) ([]string, int, rune, error) {
lineNumber := 0
for {
line, err := r.ReadString('\n')
if line == "" && err != nil {
if err == io.EOF {
return nil, 0, 0, fmt.Errorf("missing header")
}
return nil, 0, 0, err
}
lineNumber++
if lineNumber == 1 {
line = strings.TrimPrefix(line, "\ufeff")
}
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "//") {
if err != nil {
return nil, 0, 0, fmt.Errorf("missing header")
}
continue
}
if delimiter == 0 {
delimiter = ','
if strings.Count(line, "\t") > strings.Count(line, ",") {
delimiter = '\t'
}
}
reader := csv.NewReader(strings.NewReader(line))
reader.Comma = delimiter
reader.FieldsPerRecord = -1
fields, parseErr := reader.Read()
if parseErr != nil {
return nil, 0, 0, fmt.Errorf("header line %d: %w", lineNumber, parseErr)
}
return fields, lineNumber, delimiter, nil
}
}
func isDelimitedSeparator(fields []string) bool {
if len(fields) == 0 {
return true
}
for _, field := range fields {
value := strings.TrimSpace(field)
if value == "---" {
return true
}
}
return false
}
func isDelimitedUnits(fields []string, indexes columnIndexes) bool {
if indexes.ra < 0 || indexes.dec < 0 || indexes.id < 0 {
return false
}
ra := strings.ToLower(csvField(fields, indexes.ra))
dec := strings.ToLower(csvField(fields, indexes.dec))
if ra == "" || dec == "" || (strings.ContainsAny(ra, ".0123456789") && strings.ContainsAny(dec, ".0123456789")) {
return false
}
units := []string{"deg", "rad", "mas", "mag", "km/s", "arcsec", "hour", "hours"}
for _, value := range append([]string{ra, dec}, strings.ToLower(csvField(fields, indexes.id))) {
for _, unit := range units {
if strings.Contains(value, unit) {
return true
}
}
}
return false
}
type columnIndexes struct {
id, ra, dec, pmRA, pmDec, parallax, radial, magnitude, bprp, bv, epoch int
}
func buildColumnIndexes(header []string, columns ColumnMap) columnIndexes {
lookup := make(map[string]int, len(header))
for i, name := range header {
lookup[normalizeColumn(name)] = i
}
indexes := columnIndexes{
id: -1, ra: -1, dec: -1, pmRA: -1, pmDec: -1, parallax: -1, radial: -1, magnitude: -1, bprp: -1, bv: -1, epoch: -1,
}
set := func(name string) int {
if name == "" {
return -1
}
index, ok := lookup[normalizeColumn(name)]
if !ok {
return -1
}
return index
}
indexes.id, indexes.ra, indexes.dec = set(columns.ID), set(columns.RA), set(columns.Dec)
indexes.pmRA, indexes.pmDec, indexes.parallax = set(columns.PMRA), set(columns.PMDec), set(columns.Parallax)
indexes.radial, indexes.magnitude, indexes.bprp = set(columns.RadialVelocity), set(columns.Magnitude), set(columns.BPRP)
indexes.bv, indexes.epoch = set(columns.BV), set(columns.EpochColumn)
return indexes
}
func normalizeColumn(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func csvField(fields []string, index int) string {
if index < 0 || index >= len(fields) {
return ""
}
return strings.TrimSpace(fields[index])
}
func csvEntry(fields []string, indexes columnIndexes, columns ColumnMap, line int) (Entry, error) {
if columns.ID == "" || columns.RA == "" || columns.Dec == "" || indexes.id < 0 || indexes.ra < 0 || indexes.dec < 0 {
return Entry{}, fmt.Errorf("line %d: required ID/RA/Dec column missing", line)
}
id := strings.TrimSpace(csvField(fields, indexes.id))
if id == "" {
return Entry{}, fmt.Errorf("line %d: missing ID", line)
}
if columns.IDPrefix != "" {
id = columns.IDPrefix + id
}
ra, raPresent, err := parseFloat(csvField(fields, indexes.ra))
if err != nil {
return Entry{}, fmt.Errorf("line %d: invalid RA: %w", line, err)
}
if !raPresent {
return Entry{}, fmt.Errorf("line %d: missing RA", line)
}
dec, decPresent, err := parseFloat(csvField(fields, indexes.dec))
if err != nil {
return Entry{}, fmt.Errorf("line %d: invalid Dec: %w", line, err)
}
if !decPresent {
return Entry{}, fmt.Errorf("line %d: missing Dec", line)
}
if columns.AnglesInRadians {
ra = angularFromRadians(ra)
dec = angularFromRadians(dec)
}
pmRA, _, err := optionalFloat(fields, indexes.pmRA)
if err != nil {
return Entry{}, fmt.Errorf("line %d: invalid PMRA: %w", line, err)
}
pmDec, _, err := optionalFloat(fields, indexes.pmDec)
if err != nil {
return Entry{}, fmt.Errorf("line %d: invalid PMDec: %w", line, err)
}
if !columns.PMRAIncludesCosDec {
pmRA *= math.Cos(dec * math.Pi / 180)
}
parallax, _, err := optionalFloat(fields, indexes.parallax)
if err != nil {
return Entry{}, fmt.Errorf("line %d: invalid parallax: %w", line, err)
}
radial, _, err := optionalFloat(fields, indexes.radial)
if err != nil {
return Entry{}, fmt.Errorf("line %d: invalid radial velocity: %w", line, err)
}
magnitude, magnitudePresent, err := optionalFloat(fields, indexes.magnitude)
if err != nil {
return Entry{}, fmt.Errorf("line %d: invalid magnitude: %w", line, err)
}
bprp, bprpPresent, err := optionalFloat(fields, indexes.bprp)
if err != nil {
return Entry{}, fmt.Errorf("line %d: invalid BP-RP: %w", line, err)
}
bv, bvPresent, err := optionalFloat(fields, indexes.bv)
if err != nil {
return Entry{}, fmt.Errorf("line %d: invalid B-V: %w", line, err)
}
epoch := columns.Epoch
if columns.EpochColumn != "" {
year, present, epochErr := optionalFloat(fields, indexes.epoch)
if epochErr != nil {
return Entry{}, fmt.Errorf("line %d: invalid epoch: %w", line, epochErr)
}
if present {
epoch = julianYearTime(year)
}
}
if epoch.IsZero() {
return Entry{}, fmt.Errorf("line %d: epoch is required", line)
}
if !magnitudePresent {
magnitude = math.NaN()
}
if !bprpPresent {
bprp = math.NaN()
}
if !bvPresent {
bv = math.NaN()
}
return Entry{
ID: id, Coordinate: parseCoordinate(id, ra, dec, pmRA, pmDec, parallax, radial, epoch, columns.Frame),
Magnitude: magnitude, Band: columns.Band, BPRP: bprp, BV: bv,
}, nil
}
func optionalFloat(fields []string, index int) (float64, bool, error) {
if index < 0 {
return 0, false, nil
}
return parseFloat(csvField(fields, index))
}
+2
View File
@@ -0,0 +1,2 @@
// Package catalog 读取外部恒星星表并输出可直接用于搜索的恒星坐标 / external star catalogs as searchable star coordinates.
package catalog
+23
View File
@@ -0,0 +1,23 @@
package catalog
import "io"
// WithNames 返回并入名称文件后的新星表;每行 `HIP 号或 Gaia 号|名称`,
// 名称可写作 `α_And` 或 `_("Sirius") 2`。原星表不变。
//
// WithNames returns a new catalog with names merged from an `id|name` file.
// Names may be written as `α_And` or `_("Sirius") 2`. The receiver is unchanged.
func (c *Catalog) WithNames(r io.Reader, read ReadOptions) (*Catalog, error) {
named := newCatalog()
if c != nil {
for _, entry := range c.entries {
named.add(cloneEntry(entry))
}
named.skipped = c.skipped
}
if err := applyNameLines(r, named, "names", read); err != nil {
return nil, err
}
named.finalize()
return named, nil
}
+54
View File
@@ -0,0 +1,54 @@
package catalog
import (
"fmt"
"math/rand"
"sort"
"testing"
"b612.me/astro/basic"
)
func TestNearMatchesBruteForce(t *testing.T) {
random := rand.New(rand.NewSource(1))
catalog := newCatalog()
for i := 0; i < 4000; i++ {
ra := random.Float64() * 360
dec := random.Float64()*180 - 90
if i < 40 {
// 贴近 RA 0/360 与天极的记录。
ra = []float64{0, 359.999, 0.001, 180}[i%4]
dec = []float64{0, 89.9, -89.9, 45}[i/4%4]
}
id := fmt.Sprintf("S %d", i)
catalog.add(Entry{ID: id, Coordinate: parseCoordinate(id, ra, dec, 0, 0, 0, 0, julianYearTime(2000), basic.CoordinateFrameICRS)})
}
catalog.finalize()
queries := [][3]float64{
{0, 0, 1}, {359.9, 0, 0.5}, {0.05, 60, 3}, {180, 89.5, 2}, {10, -88, 5},
{270, 30, 20}, {90, -45, 60}, {0, 0, 120}, {359.999, 89.9, 0.2},
{0, 0, 60}, {30, 20, 65}, {200, -10, 75}, {0, 0, 180}, {10, 10, 200},
}
for _, query := range queries {
got := catalog.Near(query[0], query[1], query[2])
var want []string
for _, entry := range catalog.entries {
if angularDistanceDeg(normalizeRA(query[0]), query[1], entry.Coordinate.RA, entry.Coordinate.Dec) <= query[2]+1e-12 {
want = append(want, entry.ID)
}
}
ids := make([]string, len(got))
for i, entry := range got {
ids[i] = entry.ID
if i > 0 && angularDistanceDeg(query[0], query[1], got[i-1].Coordinate.RA, got[i-1].Coordinate.Dec) >
angularDistanceDeg(query[0], query[1], entry.Coordinate.RA, entry.Coordinate.Dec) {
t.Fatalf("query %v: result not sorted by distance", query)
}
}
sort.Strings(ids)
sort.Strings(want)
if fmt.Sprint(ids) != fmt.Sprint(want) {
t.Fatalf("query %v: got %d, want %d", query, len(ids), len(want))
}
}
}
+164
View File
@@ -0,0 +1,164 @@
package catalog
import (
"fmt"
"math"
"strconv"
"strings"
"time"
"b612.me/astro/basic"
)
// GaiaDR3Columns 返回 Gaia DR3 归档导出的列映射 / column map for Gaia DR3 archive exports.
func GaiaDR3Columns() ColumnMap {
return ColumnMap{
ID: "source_id", RA: "ra", Dec: "dec", PMRA: "pmra", PMDec: "pmdec",
Parallax: "parallax", RadialVelocity: "radial_velocity", Magnitude: "phot_g_mean_mag",
BPRP: "bp_rp", Band: BandG, Epoch: julianYearTime(2016.0), Frame: basic.CoordinateFrameICRS,
IDPrefix: "Gaia DR3 ", PMRAIncludesCosDec: true,
}
}
// Hipparcos2007Columns 返回 VizieR 导出的 Hipparcos 2007 新归算 CSV 列映射,角度已换算为度;原始 hip2.dat 用 ReadHipparcos2007 /
// column map for VizieR CSV exports of Hipparcos 2007 in degrees; use ReadHipparcos2007 for raw hip2.dat.
func Hipparcos2007Columns() ColumnMap {
return ColumnMap{
ID: "HIP", RA: "RArad", Dec: "DErad", PMRA: "pmRA", PMDec: "pmDE",
Parallax: "Plx", Magnitude: "Hpmag", BV: "B-V", Band: BandHp,
Epoch: julianYearTime(1991.25), Frame: basic.CoordinateFrameICRS, IDPrefix: "HIP ",
PMRAIncludesCosDec: true, AnglesInRadians: false,
}
}
func julianYearTime(year float64) time.Time {
return basic.JD2DateByZone(2451545.0+(year-2000.0)*365.25, time.UTC, false)
}
func parseMissing(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "nan", "--", "null":
return true
default:
return false
}
}
func parseFloat(value string) (float64, bool, error) {
if parseMissing(value) {
return 0, false, nil
}
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil {
return 0, true, err
}
return parsed, true, nil
}
func parseInt(value string) (int64, bool, error) {
if parseMissing(value) {
return 0, false, nil
}
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil {
return 0, true, err
}
return parsed, true, nil
}
func parseUint(value string) (uint64, bool, error) {
if parseMissing(value) {
return 0, false, nil
}
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
if err != nil {
return 0, true, err
}
return parsed, true, nil
}
func normalizedFrame(frame basic.CoordinateFrame) basic.CoordinateFrame {
if frame == "" {
return basic.CoordinateFrameICRS
}
return frame
}
func addEntry(c *Catalog, entry Entry, record int, options ReadOptions) error {
if entry.ID == "" {
return recordFailure(c, options, record, "missing ID")
}
if err := entry.Coordinate.Validate(); err != nil {
return recordFailure(c, options, record, err.Error())
}
if options.Filter != nil && !options.Filter(entry) {
return nil
}
c.add(entry)
return nil
}
func recordFailure(c *Catalog, options ReadOptions, record int, reason interface{}) error {
if options.Strict {
return fmt.Errorf("record %d: %v", record, reason)
}
c.skipped++
return nil
}
func parseCoordinate(id string, ra, dec, pmRA, pmDec, parallax, radial float64, epoch time.Time, frame basic.CoordinateFrame) basic.StarCoordinate {
if ra == 360 || ra == -0 {
ra = 0
} else {
ra = normalizeRA(ra)
}
// 负视差是测量噪声,按无视差处理;非有限值原样保留,交给 Validate 拒收。
if finite(parallax) && parallax < 0 {
parallax = 0
}
return basic.StarCoordinate{
ID: id,
RA: ra,
Dec: dec,
Epoch: epoch,
Frame: normalizedFrame(frame),
ProperMotionRACosDecMasPerYear: pmRA,
ProperMotionDecMasPerYear: pmDec,
ParallaxMas: parallax,
RadialVelocityKmPerSecond: radial,
}
}
func fixedFloat(line []byte, start, end int) (float64, bool, error) {
value, ok := fixedField(line, start, end)
if !ok {
return 0, false, nil
}
return parseFloat(value)
}
func fixedInt(line []byte, start, end int) (int64, bool, error) {
value, ok := fixedField(line, start, end)
if !ok {
return 0, false, nil
}
return parseInt(value)
}
func fixedField(line []byte, start, end int) (string, bool) {
if start < 0 || end < start || start >= len(line) || end > len(line) {
return "", false
}
return strings.TrimSpace(string(line[start:end])), true
}
func checkFinite(value float64) error {
if !finite(value) {
return fmt.Errorf("non-finite value %v", value)
}
return nil
}
func angularFromRadians(value float64) float64 {
return value * 180 / math.Pi
}
+41
View File
@@ -0,0 +1,41 @@
package catalog
import (
"math"
"os"
"testing"
"time"
"b612.me/astro/basic"
)
func TestCatalogCoordinatesFeedSearches(t *testing.T) {
bsc := readFixtureCatalog(t, "bsc5_sample.dat", func(f *os.File) (*Catalog, error) {
return ReadBSC5(f, ReadOptions{})
})
target, ok := bsc.Lookup("HR 4799")
if !ok {
t.Fatal("HR 4799 missing")
}
location := time.FixedZone("CST", 8*3600)
best, err := basic.FindBestStarOccultations(
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
target.Coordinate, basic.OccultationSearchOptions{MaxEvents: 1})
if err != nil || len(best) != 1 {
t.Fatalf("occultation = %d events, %v", len(best), err)
}
if d := best[0].Greatest.Sub(time.Date(2025, 6, 5, 20, 2, 7, 700000000, location)).Seconds(); math.Abs(d) > 5 {
t.Fatalf("greatest off by %.1f s", d)
}
sirius, ok := bsc.Lookup("HR 2491")
if !ok {
t.Fatal("HR 2491 missing")
}
startJD := basic.Date2JD(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
events := basic.FindStarHeliacalEventsWithInstance(sirius.Coordinate, sirius.Magnitude, startJD, startJD+366, 31.2, 30.0, basic.HeliacalOptions{}, nil)
if len(events) == 0 {
t.Fatal("no heliacal events for Sirius at Cairo")
}
}
+443
View File
@@ -0,0 +1,443 @@
package catalog
import (
"bufio"
"encoding/binary"
"errors"
"fmt"
"io"
"io/fs"
"math"
"sort"
"strconv"
"strings"
"time"
"b612.me/astro/basic"
)
// StellariumOptions 控制 Stellarium 附加文件 / Stellarium side-file options.
type StellariumOptions struct {
MaxMagnitude float64 // 0 表示不限,可为负 / 0 means unlimited; may be negative
Names bool // 读 name.fab 与 extra_name.fab / read name files
CrossID bool // 读 cross-id.cat / read cross identifiers
}
const stellariumMagic uint32 = 0x835F040A
// ReadStellariumStars 读取单个 Stellarium 星表分片 / read one Stellarium star shard.
func ReadStellariumStars(r io.Reader, read ReadOptions) (*Catalog, error) {
return readStellariumStars(r, StellariumOptions{}, read)
}
func readStellariumStars(r io.Reader, stellarium StellariumOptions, read ReadOptions) (*Catalog, error) {
reader := bufio.NewReaderSize(r, 64*1024)
var headerBytes [28]byte
if _, err := io.ReadFull(reader, headerBytes[:]); err != nil {
return nil, fmt.Errorf("stellarium header: %w", err)
}
if binary.LittleEndian.Uint32(headerBytes[0:4]) != stellariumMagic {
if binary.BigEndian.Uint32(headerBytes[0:4]) == stellariumMagic {
return nil, fmt.Errorf("stellarium big-endian files are unsupported")
}
return nil, fmt.Errorf("stellarium header: invalid magic")
}
typeID := binary.LittleEndian.Uint32(headerBytes[4:8])
major := binary.LittleEndian.Uint32(headerBytes[8:12])
minor := binary.LittleEndian.Uint32(headerBytes[12:16])
level := binary.LittleEndian.Uint32(headerBytes[16:20])
magMin := int32(binary.LittleEndian.Uint32(headerBytes[20:24]))
epochJD := math.Float32frombits(binary.LittleEndian.Uint32(headerBytes[24:28]))
if major > 0 {
return nil, fmt.Errorf("stellarium header: unsupported major version %d", major)
}
if typeID > 2 {
return nil, fmt.Errorf("stellarium header: unsupported record type %d", typeID)
}
if level > 8 {
return nil, fmt.Errorf("stellarium header: unsupported level %d", level)
}
if epochJD != 2457389.0 {
return nil, fmt.Errorf("stellarium header: unsupported epoch %.7g", epochJD)
}
if stellarium.MaxMagnitude != 0 && float64(magMin)/1000 > stellarium.MaxMagnitude {
catalog := newCatalog()
catalog.finalize()
return catalog, nil
}
zoneCount := uint64(20)
for i := uint32(0); i < level; i++ {
zoneCount *= 4
}
zoneCount++
counts := make([]uint32, zoneCount)
var countBytes [4]byte
for i := range counts {
if _, err := io.ReadFull(reader, countBytes[:]); err != nil {
return nil, fmt.Errorf("stellarium counts: %w", err)
}
counts[i] = binary.LittleEndian.Uint32(countBytes[:])
}
catalog := newCatalog()
recordSize := map[uint32]int{0: 48, 1: 32, 2: 16}[typeID]
recordNumber := 0
buffer := make([]byte, recordSize)
epoch := basic.JD2DateByZone(float64(epochJD), time.UTC, false)
for _, count := range counts {
for i := uint32(0); i < count; i++ {
recordNumber++
if _, err := io.ReadFull(reader, buffer); err != nil {
if read.Strict {
return nil, fmt.Errorf("record %d: truncated record: %w", recordNumber, err)
}
catalog.skipped++
catalog.finalize()
return catalog, nil
}
entry, err := stellariumEntry(buffer, typeID, epoch, recordNumber)
if err != nil {
if read.Strict {
return nil, err
}
catalog.skipped++
continue
}
if stellarium.MaxMagnitude != 0 && (!finite(entry.Magnitude) || entry.Magnitude > stellarium.MaxMagnitude) {
continue
}
if err := addEntry(catalog, entry, recordNumber, read); err != nil {
return nil, err
}
}
}
var trailing [1]byte
if n, err := reader.Read(trailing[:]); n != 0 || err != io.EOF {
if read.Strict {
if err == nil {
return nil, fmt.Errorf("stellarium file has trailing bytes")
}
return nil, fmt.Errorf("stellarium file length: %w", err)
}
catalog.skipped++
}
_ = minor
catalog.finalize()
return catalog, nil
}
func stellariumEntry(record []byte, typeID uint32, epoch time.Time, recordNumber int) (Entry, error) {
switch typeID {
case 0:
return stellariumStar1(record, epoch, recordNumber)
case 1:
return stellariumStar2(record, epoch, recordNumber)
case 2:
return stellariumStar3(record, epoch, recordNumber)
default:
return Entry{}, fmt.Errorf("record %d: unsupported record type %d", recordNumber, typeID)
}
}
func stellariumStar1(record []byte, epoch time.Time, recordNumber int) (Entry, error) {
gaiaID := int64(binary.LittleEndian.Uint64(record[0:8]))
x := [3]float64{
float64(int32(binary.LittleEndian.Uint32(record[8:12]))) / 2e9,
float64(int32(binary.LittleEndian.Uint32(record[12:16]))) / 2e9,
float64(int32(binary.LittleEndian.Uint32(record[16:20]))) / 2e9,
}
dx := [3]float64{
float64(int32(binary.LittleEndian.Uint32(record[20:24]))) / 1000,
float64(int32(binary.LittleEndian.Uint32(record[24:28]))) / 1000,
float64(int32(binary.LittleEndian.Uint32(record[28:32]))) / 1000,
}
norm := math.Sqrt(x[0]*x[0] + x[1]*x[1] + x[2]*x[2])
if !finite(norm) || norm == 0 {
return Entry{}, fmt.Errorf("record %d: zero position vector", recordNumber)
}
ra := normalizeRA(math.Atan2(x[1], x[0]) * 180 / math.Pi)
dec := math.Asin(clamp(x[2]/norm, -1, 1)) * 180 / math.Pi
alpha := ra * math.Pi / 180
delta := dec * math.Pi / 180
pmRA := -math.Sin(alpha)*dx[0] + math.Cos(alpha)*dx[1]
pmDec := -math.Sin(delta)*math.Cos(alpha)*dx[0] - math.Sin(delta)*math.Sin(alpha)*dx[1] + math.Cos(delta)*dx[2]
radialPM := math.Cos(delta)*math.Cos(alpha)*dx[0] + math.Cos(delta)*math.Sin(alpha)*dx[1] + math.Sin(delta)*dx[2]
if !finite(radialPM) || math.Abs(radialPM) > 1 {
return Entry{}, fmt.Errorf("record %d: non-tangential proper motion", recordNumber)
}
bv := float64(int16(binary.LittleEndian.Uint16(record[32:34]))) / 1000
vmag := float64(int16(binary.LittleEndian.Uint16(record[34:36]))) / 1000
parallax := float64(binary.LittleEndian.Uint16(record[36:38])) * 0.02
radial := float64(int16(binary.LittleEndian.Uint16(record[40:42]))) / 10
hipPacked := uint32(record[45]) | uint32(record[46])<<8 | uint32(record[47])<<16
hip := hipPacked >> 5
componentNumber := hipPacked & 31
component := componentName(componentNumber)
id := ""
aliases := make([]string, 0, 1)
if hip > 0 {
id = fmt.Sprintf("HIP %d", hip)
if gaiaID > 0 {
aliases = append(aliases, fmt.Sprintf("Gaia DR3 %d", gaiaID))
}
} else if gaiaID > 0 {
id = fmt.Sprintf("Gaia DR3 %d", gaiaID)
}
if id == "" {
return Entry{}, fmt.Errorf("record %d: missing HIP and Gaia ID", recordNumber)
}
return Entry{ID: id, Aliases: aliases, Component: component, Coordinate: parseCoordinate(id, ra, dec, pmRA, pmDec, parallax, radial, epoch, basic.CoordinateFrameICRS), Magnitude: vmag, Band: BandV, BPRP: math.NaN(), BV: bv}, nil
}
func stellariumStar2(record []byte, epoch time.Time, recordNumber int) (Entry, error) {
gaiaID := int64(binary.LittleEndian.Uint64(record[0:8]))
ra := float64(int32(binary.LittleEndian.Uint32(record[8:12]))) / 3600000
dec := float64(int32(binary.LittleEndian.Uint32(record[12:16]))) / 3600000
pmRA := float64(int32(binary.LittleEndian.Uint32(record[16:20]))) / 1000
pmDec := float64(int32(binary.LittleEndian.Uint32(record[20:24]))) / 1000
pmRA *= math.Cos(dec * math.Pi / 180)
bv := float64(int16(binary.LittleEndian.Uint16(record[24:26]))) / 1000
vmag := float64(int16(binary.LittleEndian.Uint16(record[26:28]))) / 1000
parallax := float64(binary.LittleEndian.Uint16(record[28:30])) / 100
if gaiaID <= 0 {
return Entry{}, fmt.Errorf("record %d: missing Gaia ID", recordNumber)
}
id := fmt.Sprintf("Gaia DR3 %d", gaiaID)
return Entry{ID: id, Coordinate: parseCoordinate(id, ra, dec, pmRA, pmDec, parallax, 0, epoch, basic.CoordinateFrameICRS), Magnitude: vmag, Band: BandV, BPRP: math.NaN(), BV: bv}, nil
}
func stellariumStar3(record []byte, epoch time.Time, recordNumber int) (Entry, error) {
raValue := uint32(record[8]) | uint32(record[9])<<8 | uint32(record[10])<<16
decValue := uint32(record[11]) | uint32(record[12])<<8 | uint32(record[13])<<16
ra := float64(raValue) * 0.1 / 3600
dec := (float64(decValue) - 90*36000) * 0.1 / 3600
vmag := (float64(record[15])*20 + 16000) / 1000
bv := float64(record[14])*0.025 - 1
gaiaID := int64(binary.LittleEndian.Uint64(record[0:8]))
if gaiaID <= 0 {
return Entry{}, fmt.Errorf("record %d: missing Gaia ID", recordNumber)
}
id := fmt.Sprintf("Gaia DR3 %d", gaiaID)
return Entry{ID: id, Coordinate: parseCoordinate(id, ra, dec, 0, 0, 0, 0, epoch, basic.CoordinateFrameICRS), Magnitude: vmag, Band: BandV, BPRP: math.NaN(), BV: bv}, nil
}
func clamp(value, low, high float64) float64 {
if value < low {
return low
}
if value > high {
return high
}
return value
}
func componentName(value uint32) string {
if value == 0 {
return ""
}
if value <= 26 {
return string(rune('A' + value - 1))
}
return strconv.FormatUint(uint64(value), 10)
}
// ReadStellariumDir 读取 Stellarium 星表目录 / read a Stellarium catalog directory.
func ReadStellariumDir(dir fs.FS, stellarium StellariumOptions, read ReadOptions) (*Catalog, error) {
entries, err := fs.ReadDir(dir, ".")
if err != nil {
return nil, err
}
shards := make([]string, 0)
for _, entry := range entries {
if !entry.IsDir() && isStellariumShard(entry.Name()) {
shards = append(shards, entry.Name())
}
}
sort.Strings(shards)
merged := newCatalog()
for _, name := range shards {
file, openErr := dir.Open(name)
if openErr != nil {
return nil, openErr
}
part, readErr := readStellariumStars(file, stellarium, read)
closeErr := file.Close()
if readErr != nil {
return nil, fmt.Errorf("%s: %w", name, readErr)
}
if closeErr != nil {
return nil, closeErr
}
merged = merged.Merge(part)
}
if stellarium.Names {
for _, name := range []string{"name.fab", "extra_name.fab"} {
if err := addFabAliases(dir, merged, name, read); err != nil {
return nil, err
}
}
}
if stellarium.CrossID {
if err := addCrossIDAliases(dir, merged, "cross-id.cat", read); err != nil {
return nil, err
}
}
merged.finalize()
return merged, nil
}
func isStellariumShard(name string) bool {
if !strings.HasPrefix(name, "stars_") || !strings.HasSuffix(name, ".cat") {
return false
}
rest := strings.TrimPrefix(strings.TrimSuffix(name, ".cat"), "stars_")
if rest == "" || rest[0] < '0' || rest[0] > '9' {
return false
}
return strings.Contains(rest, "_")
}
func addFabAliases(dir fs.FS, catalog *Catalog, name string, read ReadOptions) error {
file, err := dir.Open(name)
if err != nil {
if errorsIsNotExist(err) {
return nil
}
return err
}
defer file.Close()
return applyNameLines(file, catalog, name, read)
}
// applyNameLines 读 `id|名称` 行并把名称并入对应记录的别名。
func applyNameLines(r io.Reader, catalog *Catalog, label string, read ReadOptions) error {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 2*1024*1024)
index := buildCatalogNumberIndex(catalog)
lineNumber := 0
for scanner.Scan() {
lineNumber++
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
continue
}
parts := strings.SplitN(line, "|", 2)
id, err := uint64(0), error(nil)
if len(parts) == 2 {
id, err = strconv.ParseUint(strings.TrimSpace(parts[0]), 10, 64)
}
alias := ""
if len(parts) == 2 && err == nil {
alias = nameField(parts[1])
}
if alias == "" {
if read.Strict {
return fmt.Errorf("%s line %d: expected id|name", label, lineNumber)
}
catalog.skipped++
continue
}
for _, i := range index.lookup(id, 0) {
catalog.entries[i].Aliases = append(catalog.entries[i].Aliases, alias)
}
}
return scanner.Err()
}
// nameField 取名称字段:`_("Sirius") 2` 取引号内文字,否则把 `_` 换成空格。
func nameField(field string) string {
field = strings.TrimSpace(field)
if strings.HasPrefix(field, `_("`) {
end := strings.Index(field[3:], `")`)
if end < 0 {
return ""
}
return strings.TrimSpace(field[3 : 3+end])
}
return strings.TrimSpace(strings.ReplaceAll(field, "_", " "))
}
func addCrossIDAliases(dir fs.FS, catalog *Catalog, name string, read ReadOptions) error {
file, err := dir.Open(name)
if err != nil {
if errorsIsNotExist(err) {
return nil
}
return err
}
defer file.Close()
reader := bufio.NewReaderSize(file, 64*1024)
index := buildCatalogNumberIndex(catalog)
var record [21]byte
recordNumber := 0
for {
n, readErr := io.ReadFull(reader, record[:])
if readErr == io.EOF && n == 0 {
break
}
recordNumber++
if readErr != nil {
if read.Strict {
return fmt.Errorf("%s record %d: length is not 21 bytes", name, recordNumber)
}
catalog.skipped++
break
}
id := binary.LittleEndian.Uint64(record[0:8])
component := uint32(record[8])
aliases := make([]string, 0, 3)
if value := int32(binary.LittleEndian.Uint32(record[9:13])); value != 0 {
aliases = append(aliases, fmt.Sprintf("SAO %d", value))
}
if value := int32(binary.LittleEndian.Uint32(record[13:17])); value != 0 {
aliases = append(aliases, fmt.Sprintf("HD %d", value))
}
if value := int32(binary.LittleEndian.Uint32(record[17:21])); value != 0 {
aliases = append(aliases, fmt.Sprintf("HR %d", value))
}
for _, i := range index.lookup(id, component) {
catalog.entries[i].Aliases = append(catalog.entries[i].Aliases, aliases...)
}
}
return nil
}
type catalogNumberIndex struct {
hip, gaia map[uint64][]int
hipComponents map[string][]int
}
func buildCatalogNumberIndex(catalog *Catalog) catalogNumberIndex {
index := catalogNumberIndex{hip: make(map[uint64][]int), gaia: make(map[uint64][]int), hipComponents: make(map[string][]int)}
for i, entry := range catalog.entries {
if strings.HasPrefix(entry.ID, "HIP ") {
value, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(entry.ID, "HIP ")), 10, 64)
if err == nil {
index.hip[value] = append(index.hip[value], i)
index.hipComponents[fmt.Sprintf("%d\x00%s", value, entry.Component)] = append(index.hipComponents[fmt.Sprintf("%d\x00%s", value, entry.Component)], i)
}
} else if strings.HasPrefix(entry.ID, "Gaia DR3 ") {
value, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(entry.ID, "Gaia DR3 ")), 10, 64)
if err == nil {
index.gaia[value] = append(index.gaia[value], i)
}
}
}
return index
}
func (index catalogNumberIndex) lookup(id uint64, component uint32) []int {
if id <= 120416 {
if component == 0 {
return index.hip[id]
}
return index.hipComponents[fmt.Sprintf("%d\x00%s", id, componentName(component))]
}
if component != 0 {
return nil
}
return index.gaia[id]
}
func errorsIsNotExist(err error) bool {
return errors.Is(err, fs.ErrNotExist)
}
+7
View File
@@ -0,0 +1,7 @@
92 NOVA 1572 B Cas *
424 1Alp UMiBD+88 8 8890 308 907 1477 Alp UMi 012233.7+884626023148.7+891551123.28 26.46 2.02 +0.60 +0.38 +0.31 F7:Ib-II v+0.038-0.015 +.007-017SBO 17 6.8 18.4AB 5*
1457 87Alp TauBD+16 629 29139 94027 168I 3321 Alp Tau 043010.9+161830043555.2+163033180.97-20.25 0.85 +1.54 +1.90 +0.94 K5+III v+0.063-0.190 +.048+054SB < 17 9.8 121.7AC 6*
1713 19Bet OriBD-08 1063 34085131907 194I 3823 1882 050943.9-081901051432.3-081206209.24-25.25 0.12 -0.03 -0.66 -0.02 B8Ia: e 0.000-0.001 +.013+021SB 33 6.5 9.5AB 4*
2491 9Alp CMaBD-16 1591 48915151881 257I 5423 064044.6-163444064508.9-164258227.22-08.88-1.46 0.00 -0.05 -0.03 A1Vm -0.553-1.205 +.375-008SBO 13 10.3 11.2AB 4*
7001 3Alp LyrBD+38 3238 172167 67174 699I 11510 Alp Lyr 183333.1+384126183656.3+384701 67.44 19.24 0.03 0.00 -0.01 -0.03 A0Va +0.202+0.286 +.123-014V 15 10.4 62.8AB 5*
4799 25 VirBD-05 3535 1097041388731324 123138.2-051651123647.4-054955296.26 56.85 5.87 +0.07 +0.09 A3V -0.028-0.018 -006V
Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
24436|Σ_668
24436|β_555
32349|AGC_1
32349|LEC_1
32349|HL_3
32349|β_1411
91262|Σ_II_9
91262|MBA_2
91262|MET_11
91262|H_5_39
91262|SMR_33
+5
View File
@@ -0,0 +1,5 @@
11767 5 0 1 0.6622851337 1.5579531082 7.54 44.48 -11.85 0.09 0.10 0.11 0.11 0.13 127 1.08 2 0.0 0 2.1077 0.0021 0.014 1 0.636 0.003 0.700 1.02 1.23 1.04 -0.42 -2.11 1.01 1.67 -0.26 -0.93 1.06 -0.06 1.42 0.64 2.87 1.00
21421 1 0 1 1.2039281519 0.2881496983 48.94 63.45 -188.94 0.64 0.43 0.77 0.84 0.65 61 0.18 0 2.3 0 1.0024 0.0026 0.010 0 1.538 0.008 1.670 1.14 -1.03 1.06 -0.55 0.35 1.07 0.05 0.14 0.06 1.21 0.17 -0.33 0.53 -1.05 1.00
24436 5 0 1 1.3724302998 -0.1431456148 3.78 1.31 0.50 0.30 0.25 0.34 0.40 0.30 73 12.27 0 0.0 0 0.1930 0.0015 0.014 1 -0.030 0.600 0.030 1.06 -0.07 1.17 -0.54 0.61 1.00 0.19 1.79 0.08 1.00 2.42 3.61 -0.20 -0.46 1.00
32349 0 4 1 1.7678185359 -0.2916993748 379.21 -546.01 -1223.07 1.21 1.04 1.58 1.33 1.24 47 0.00 0 0.0 0 -1.0876 0.0024 0.040 0 0.009 0.007 -0.020 0.83 -0.04 0.96 -0.21 0.32 0.68 0.46 -0.12 0.13 0.90 -0.20 0.62 -0.14 -0.16 1.00
91262 1 0 1 4.8735545728 0.6768909262 130.23 200.94 286.23 0.26 0.28 0.36 0.32 0.40 98 -0.14 0 1.8 0 0.0868 0.0021 0.014 2 -0.001 0.005 -0.010 1.01 0.00 1.00 0.24 0.08 1.11 -0.02 -0.09 1.14 1.00 -0.09 -0.18 0.76 -0.08 1.00
+6
View File
@@ -0,0 +1,6 @@
32349|α_CMa
32349|9_CMa
91262|α_Lyr
91262|3_Lyr
24436|β_Ori
24436|19_Ori
Binary file not shown.
+242
View File
@@ -0,0 +1,242 @@
package catalog
import (
"math"
"sort"
"strings"
"time"
"b612.me/astro/basic"
)
// Entry 一条外挂星表记录 / one external catalog record.
type Entry struct {
ID string
Aliases []string
Coordinate basic.StarCoordinate
Magnitude float64
Band Band
BPRP float64
BV float64
Component string
}
// Band 星等波段 / photometric band.
type Band string
const (
// BandV Johnson V 波段 / Johnson V band.
BandV Band = "V"
// BandG Gaia G 波段 / Gaia G band.
BandG Band = "G"
// BandHp Hipparcos Hp 波段 / Hipparcos Hp band.
BandHp Band = "Hp"
)
// ColumnMap 描述分隔文本的列与单位 / delimited-text columns and units.
type ColumnMap struct {
ID, RA, Dec, PMRA, PMDec, Parallax, RadialVelocity, Magnitude, BPRP, BV string
PMRAIncludesCosDec bool
Band Band
Epoch time.Time
EpochColumn string
Frame basic.CoordinateFrame
IDPrefix string
AnglesInRadians bool
}
// ReadOptions 控制读取严格性和入选过滤 / read strictness and admission filtering.
type ReadOptions struct {
Strict bool
Delimiter rune
Filter func(Entry) bool
}
// Catalog 是不可变的外挂星表容器 / immutable external-star catalog.
type Catalog struct {
entries []Entry
lookup map[string]int
// bands 按 0.5° 赤纬分带,带内按 RA 升序存放记录下标。
bands [decBandCount][]int
skipped int
}
func newCatalog() *Catalog {
return &Catalog{lookup: make(map[string]int)}
}
func (c *Catalog) add(entry Entry) {
entry.ID = strings.TrimSpace(entry.ID)
entry.Aliases = uniqueStrings(entry.Aliases)
c.entries = append(c.entries, entry)
}
// dropDuplicates 同主 ID 同分量只保留首条,其余计入跳过数。
func (c *Catalog) dropDuplicates() {
seen := make(map[string]struct{}, len(c.entries))
kept := c.entries[:0]
for _, entry := range c.entries {
if normalizeKey(entry.ID) != "" {
key := entryKey(entry)
if _, exists := seen[key]; exists {
c.skipped++
continue
}
seen[key] = struct{}{}
}
kept = append(kept, entry)
}
c.entries = kept
}
func (c *Catalog) finalize() {
c.dropDuplicates()
c.lookup = make(map[string]int, len(c.entries)*2)
c.bands = [decBandCount][]int{}
for i := range c.entries {
entry := &c.entries[i]
entry.Aliases = uniqueStrings(entry.Aliases)
if key := normalizeKey(entry.ID); key != "" {
if _, exists := c.lookup[key]; !exists {
c.lookup[key] = i
}
}
for _, alias := range entry.Aliases {
key := normalizeKey(alias)
if key != "" {
if _, exists := c.lookup[key]; !exists {
c.lookup[key] = i
}
}
}
band := decBand(entry.Coordinate.Dec)
c.bands[band] = append(c.bands[band], i)
}
for band := range c.bands {
if len(c.bands[band]) < 2 {
continue
}
sort.SliceStable(c.bands[band], func(i, j int) bool {
return c.entries[c.bands[band][i]].Coordinate.RA < c.entries[c.bands[band][j]].Coordinate.RA
})
}
}
// Len 返回记录数 / record count.
func (c *Catalog) Len() int {
if c == nil {
return 0
}
return len(c.entries)
}
// Entries 返回记录快照 / record snapshot.
func (c *Catalog) Entries() []Entry {
if c == nil {
return nil
}
entries := make([]Entry, len(c.entries))
copy(entries, c.entries)
for i := range entries {
entries[i].Aliases = append([]string(nil), entries[i].Aliases...)
}
return entries
}
// Lookup 按主 ID 或别名精确查找,同 ID 多分量时返回最先读入者 / exact lookup by ID or alias; first-read component wins.
func (c *Catalog) Lookup(id string) (Entry, bool) {
if c == nil {
return Entry{}, false
}
i, ok := c.lookup[normalizeKey(id)]
if !ok {
return Entry{}, false
}
entry := c.entries[i]
entry.Aliases = append([]string(nil), entry.Aliases...)
return entry, true
}
// Skipped 返回宽松模式跳过的记录数 / number of skipped records.
func (c *Catalog) Skipped() int {
if c == nil {
return 0
}
return c.skipped
}
// Merge 合并两个星表,后者覆盖同主 ID 同分量记录 / merge catalogs; right-hand records win per ID and component.
func (c *Catalog) Merge(other *Catalog) *Catalog {
merged := newCatalog()
if c != nil {
for _, entry := range c.entries {
merged.add(cloneEntry(entry))
}
merged.skipped += c.skipped
}
if other != nil {
positions := make(map[string]int, len(merged.entries))
for i := range merged.entries {
positions[entryKey(merged.entries[i])] = i
}
for _, entry := range other.entries {
key := entryKey(entry)
if i, ok := positions[key]; ok {
aliases := append(merged.entries[i].Aliases, entry.Aliases...)
merged.entries[i] = cloneEntry(entry)
merged.entries[i].Aliases = uniqueStrings(aliases)
} else {
positions[key] = len(merged.entries)
merged.add(cloneEntry(entry))
}
}
merged.skipped += other.skipped
}
merged.finalize()
return merged
}
// entryKey 以主 ID 与分量区分记录,双星各分量共用主 ID。
func entryKey(entry Entry) string {
return normalizeKey(entry.ID) + "\x00" + entry.Component
}
func cloneEntry(entry Entry) Entry {
entry.Aliases = append([]string(nil), entry.Aliases...)
return entry
}
func normalizeKey(value string) string {
return strings.ToLower(strings.Join(strings.Fields(value), " "))
}
func uniqueStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ")
if value == "" {
continue
}
key := normalizeKey(value)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, value)
}
return result
}
const decBandCount = 360
func decBand(dec float64) int {
band := int(math.Floor((dec + 90) * 2))
if band < 0 {
return 0
}
if band >= decBandCount {
return decBandCount - 1
}
return band
}