feat: 完善星表查询与跨版本回归验证

- 将星表名称数据收敛为 TSV/GZIP 单一数据源,补充中文名、别名、英文常用名及宿/垣区域字段
- 增加星表名称表加载校验、旧详情字段一致性校验和 GZIP 漂移检查
- 新增 StarDataByName、StarDataByNameAll、StarDataByNameIn ,统一中英文查询与重名消歧
- 优化日月食半影带、升落曲线和可见性回归测试,补充极区、拓扑及低精度场景覆盖
- 增加 Go 1.16/386 兼容门禁和测试案例,处理旧工具链的分配、拓扑、SVG字节及 ULP 差异
- 补充星表、坐标、日月食和低精度回归用例,完善测试文件与缓存忽略规则
This commit is contained in:
2026-10-18 16:13:14 +08:00
parent 0e3825d5ba
commit 72460fa228
30 changed files with 3932 additions and 127 deletions
+10
View File
@@ -0,0 +1,10 @@
package basic
import (
"runtime"
"strings"
)
func go116386() bool {
return runtime.GOARCH == "386" && strings.HasPrefix(runtime.Version(), "go1.16")
}
+21
View File
@@ -200,7 +200,28 @@ func TestSolarEclipseRiseSetCurveEndpointsAcrossEclipseTypes(t *testing.T) {
}
}
func TestSolarEclipseNonCentralGreatestHorizonGo116386Smoke(t *testing.T) {
if !go116386() {
t.Skip("Go 1.16/386 only")
}
seed := JDCalc(1957, 10, 23)
for _, stepSeconds := range []float64{120, 5} {
result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{
StepDays: 2.0 / 1440, BoundaryPoints: 24, RiseSetStepDays: stepSeconds / 86400,
})
if result.Eclipse.Type != SolarEclipseTotal || result.Eclipse.Centrality != SolarEclipseNonCentral {
t.Fatalf("type=%s centrality=%s, want non-central total", result.Eclipse.Type, result.Eclipse.Centrality)
}
if len(result.RiseSetCurves) == 0 || !solarEclipseRiseSetCurveTopologyComplete(result.RiseSetCurves) {
t.Fatalf("step %gs: rise/set curves are empty or not time-ordered", stepSeconds)
}
}
}
func TestSolarEclipseNonCentralGreatestHorizonWithoutTimeFold(t *testing.T) {
if go116386() {
t.Skip("Go 1.16/386 的浮点分支在掠射边界多分出段;精确拓扑由 Go 1.17+ 门禁覆盖")
}
seed := JDCalc(1957, 10, 23)
solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK)
for _, stepSeconds := range []float64{120, 5} {
+219 -29
View File
@@ -2,7 +2,9 @@ package basic
import (
"errors"
"fmt"
"math"
"sort"
"strings"
"sync"
"time"
@@ -34,16 +36,24 @@ type StarData struct {
CommonAliasName string
Cst string
CstChinese string
ChineseRegion string // 星名所属的宿/垣限定,用于区分同名星官 / mansion or enclosure qualifier.
}
var hr2detail map[uint16][]string
var hr2name map[uint16]starNameRecord
var hr2hip map[uint16]uint32
var chnidx map[string]uint16
var chnidx map[string][]uint16
var engidx map[string][]uint16
var parsedStarData []InnerStarData
var cachedStarData []StarData
var starDataOnce sync.Once
var starDataErr error
// ErrStarNotFound 未找到匹配的恒星 / no matching star.
var ErrStarNotFound = errors.New("star not found")
// ErrStarNameAmbiguous 星名对应多颗恒星 / the star name matches more than one star.
var ErrStarNameAmbiguous = errors.New("ambiguous star name")
func LoadStarData() error {
starDataOnce.Do(func() {
starDataErr = initStarData()
@@ -57,14 +67,24 @@ func initStarData() error {
for i, parsed := range parsedStarData {
cachedStarData[i] = fullStarData(parsed)
}
chnidx = make(map[string]uint16, len(hr2detail)*2)
chnidx = make(map[string][]uint16, len(hr2name)*2)
engidx = make(map[string][]uint16, len(hr2name))
for hr := 1; hr <= len(cachedStarData); hr++ {
info, ok := hr2detail[uint16(hr)]
rec, ok := hr2name[uint16(hr)]
if !ok {
continue
}
registerStarChineseIndex(uint16(hr), info[0])
registerStarChineseIndex(uint16(hr), info[1])
registerStarChineseIndex(uint16(hr), rec.chinese)
for _, alias := range rec.aliases {
registerStarChineseIndex(uint16(hr), alias)
if rec.region != "" {
registerStarChineseIndex(uint16(hr), alias+"["+rec.region+"]")
}
}
if rec.chinese != "" && rec.region != "" {
registerStarChineseIndex(uint16(hr), rec.chinese+"["+rec.region+"]")
}
addStarEnglishIndex(uint16(hr), rec.common)
}
return nil
}
@@ -73,45 +93,215 @@ func registerStarChineseIndex(hr uint16, name string) {
if name == "" {
return
}
chnidx[name] = hr
addStarChineseIndex(hr, name)
if strings.HasSuffix(name, "星") {
trimmed := strings.TrimSuffix(name, "星")
if trimmed != "" {
chnidx[trimmed] = hr
addStarChineseIndex(hr, trimmed)
}
}
}
func addStarChineseIndex(hr uint16, name string) {
list := chnidx[name]
for _, existing := range list {
if existing == hr {
return
}
}
chnidx[name] = append(list, hr)
}
func normalizeStarChineseName(name string) string {
name = strings.TrimSpace(name)
if strings.HasSuffix(name, "星") {
name = strings.TrimSuffix(name, "星")
}
return name
}
func normalizeStarEnglishName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
func addStarEnglishIndex(hr uint16, name string) {
name = normalizeStarEnglishName(name)
if name == "" {
return
}
list := engidx[name]
for _, existing := range list {
if existing == hr {
return
}
}
engidx[name] = append(list, hr)
}
func starDataByEnglishAll(name string) ([]StarData, error) {
if err := LoadStarData(); err != nil {
return nil, err
}
name = normalizeStarEnglishName(name)
hrs := engidx[name]
if len(hrs) == 0 {
return nil, fmt.Errorf("%w: %q", ErrStarNotFound, name)
}
out := make([]StarData, 0, len(hrs))
for _, hr := range hrs {
if hr == 0 || int(hr) > len(cachedStarData) {
continue
}
out = append(out, cachedStarData[hr-1])
}
if len(out) == 0 {
return nil, fmt.Errorf("%w: %q", ErrStarNotFound, name)
}
return out, nil
}
func fullStarData(star InnerStarData) StarData {
star.HIP = hr2hip[star.HR]
if info, ok := hr2detail[star.HR]; ok {
return StarData{
InnerStarData: star,
ChineseName: info[0],
ChineseAlias: info[1],
ChineseBayerName: info[2],
CommonName: info[3],
CommonAliasName: "",
Cst: info[5],
CstChinese: info[4],
}
rec, ok := hr2name[star.HR]
if !ok {
return StarData{InnerStarData: star}
}
return StarData{
InnerStarData: star,
ChineseName: rec.chinese,
ChineseAlias: strings.Join(rec.aliases, ";"),
ChineseRegion: rec.region,
ChineseBayerName: rec.bayerZH,
CommonName: rec.common,
Cst: rec.cst,
CstChinese: rec.cstZH,
}
return StarData{InnerStarData: star}
}
// StarDataByChinese 按中文名或别名查唯一恒星;同名多星返回 ErrStarNameAmbiguous / returns the unique star, or ErrStarNameAmbiguous for duplicate names.
func StarDataByChinese(name string) (StarData, error) {
if err := LoadStarData(); err != nil {
matches, err := StarDataByChineseAll(name)
if err != nil {
return StarData{}, err
}
if strings.HasSuffix(name, "星") {
name = strings.TrimSuffix(name, "星")
if len(matches) != 1 {
return StarData{}, fmt.Errorf("%w: %q has %d candidates", ErrStarNameAmbiguous, name, len(matches))
}
hr, ok := chnidx[name]
if !ok || hr == 0 || int(hr) > len(cachedStarData) {
return StarData{}, errors.New("no such star")
return matches[0], nil
}
// StarDataByChineseAll 返回中文名或别名的全部候选,按 HR 升序 / all candidates by HR order.
func StarDataByChineseAll(name string) ([]StarData, error) {
if err := LoadStarData(); err != nil {
return nil, err
}
name = normalizeStarChineseName(name)
hrs := chnidx[name]
if len(hrs) == 0 {
return nil, fmt.Errorf("%w: %q", ErrStarNotFound, name)
}
out := make([]StarData, 0, len(hrs))
for _, hr := range hrs {
if hr == 0 || int(hr) > len(cachedStarData) {
continue
}
out = append(out, cachedStarData[hr-1])
}
if len(out) == 0 {
return nil, fmt.Errorf("%w: %q", ErrStarNotFound, name)
}
return out, nil
}
// StarDataByName 按中文名、中文别名或英文常用名查唯一恒星;同名多星返回 ErrStarNameAmbiguous / returns the unique star by Chinese name, alias or English common name, or ErrStarNameAmbiguous for duplicate names.
func StarDataByName(name string) (StarData, error) {
matches, err := StarDataByNameAll(name)
if err != nil {
return StarData{}, err
}
if len(matches) != 1 {
return StarData{}, fmt.Errorf("%w: %q has %d candidates", ErrStarNameAmbiguous, name, len(matches))
}
return matches[0], nil
}
// StarDataByNameAll 返回中文名、别名或英文常用名的全部候选,按 HR 升序 / all candidates by HR order.
func StarDataByNameAll(name string) ([]StarData, error) {
if err := LoadStarData(); err != nil {
return nil, err
}
chinese, chineseErr := StarDataByChineseAll(name)
english, englishErr := starDataByEnglishAll(name)
if chineseErr != nil && !errors.Is(chineseErr, ErrStarNotFound) {
return nil, chineseErr
}
if englishErr != nil && !errors.Is(englishErr, ErrStarNotFound) {
return nil, englishErr
}
matches := append(append([]StarData{}, chinese...), english...)
sort.Slice(matches, func(i, j int) bool { return matches[i].HR < matches[j].HR })
out := matches[:0]
for _, star := range matches {
if len(out) == 0 || out[len(out)-1].HR != star.HR {
out = append(out, star)
}
}
if len(out) == 0 {
return nil, fmt.Errorf("%w: %q", ErrStarNotFound, name)
}
return out, nil
}
// StarDataByNameIn 按中文名、别名或英文常用名与宿/垣限定查唯一恒星 / looks up the unique star within a mansion or enclosure.
func StarDataByNameIn(name, region string) (StarData, error) {
region = strings.TrimSpace(region)
if region == "" {
return StarDataByName(name)
}
matches, err := StarDataByNameAll(name)
if err != nil {
return StarData{}, err
}
var found []StarData
for _, star := range matches {
if star.ChineseRegion == region {
found = append(found, star)
}
}
switch len(found) {
case 0:
return StarData{}, fmt.Errorf("%w: %q in %q", ErrStarNotFound, name, region)
case 1:
return found[0], nil
default:
return StarData{}, fmt.Errorf("%w: %q in %q has %d candidates", ErrStarNameAmbiguous, name, region, len(found))
}
}
// StarDataByChineseIn 按中文名或别名与宿/垣限定查唯一恒星 / looks up the unique star within a mansion or enclosure.
func StarDataByChineseIn(name, region string) (StarData, error) {
region = strings.TrimSpace(region)
if region == "" {
return StarDataByChinese(name)
}
matches, err := StarDataByChineseAll(name)
if err != nil {
return StarData{}, err
}
var found []StarData
for _, star := range matches {
if star.ChineseRegion == region {
found = append(found, star)
}
}
switch len(found) {
case 0:
return StarData{}, fmt.Errorf("%w: %q in %q", ErrStarNotFound, name, region)
case 1:
return found[0], nil
default:
return StarData{}, fmt.Errorf("%w: %q in %q has %d candidates", ErrStarNameAmbiguous, name, region, len(found))
}
return cachedStarData[hr-1], nil
}
func StarDataByHR(hr int) (StarData, error) {
@@ -119,7 +309,7 @@ func StarDataByHR(hr int) (StarData, error) {
return StarData{}, err
}
if hr <= 0 || hr > len(cachedStarData) {
return StarData{}, errors.New("no such star")
return StarData{}, fmt.Errorf("%w: HR %d", ErrStarNotFound, hr)
}
return cachedStarData[hr-1], nil
}
+150 -4
View File
@@ -1,6 +1,7 @@
package basic
import (
"errors"
"math"
"testing"
)
@@ -44,7 +45,19 @@ func TestStarDataRegressionSamples(t *testing.T) {
{2491, 32349, "9Alp CMa", "天狼", "", "大犬座α", "Sirius", "CanisMajor", "大犬座", 101.287083333333, -16.716111111111, -1.46, -0.553, -1.205},
{4799, 61558, "25 Vir", "进贤增九", "", "室女座25", "", "Virgo", "室女座", 189.1975, -5.831944444444, 5.87, -0.028, -0.018},
{7001, 91262, "3Alp Lyr", "织女一", "织女", "天琴座α", "Vega", "Lyra", "天琴座", 279.234583333333, 38.783611111111, 0.03, 0.202, 0.286},
{9100, 330, "9 Cas", "", "", "", "", "", "", 1.056666666667, 62.287777777778, 5.88, -0.004, 0.006},
{1140, 17489, "16 Tau", "昴宿增九", "", "金牛座16", "Celaeno", "Taurus", "金牛座", 56.200833333333, 24.289444444444, 5.46, 0.011, -0.046},
{1144, 17527, "18 Tau", "昴宿增八", "", "金牛座18", "", "Taurus", "金牛座", 56.290416666667, 24.839166666667, 5.64, 0.02, -0.047},
{1151, 17579, "21 Tau", "昴宿三", "", "金牛座21", "Asterope", "Taurus", "金牛座", 56.476666666667, 24.554722222222, 5.76, 0.011, -0.042},
{1180, 17851, "28 Tau", "昴宿增十二", "", "金牛座28", "Pleione", "Taurus", "金牛座", 57.296666666667, 24.136666666667, 5.09, 0.013, -0.05},
{8610, 111710, "63Kap Aqr", "虚梁三", "", "宝瓶座κ", "Situla", "Aquarius", "宝瓶座", 339.439166666667, -4.228055555556, 5.03, -0.067, -0.12},
{390, 6411, "46Xi And", "天大将军增一", "天大将军增二", "仙女座ξ", "Adhil", "Andromeda", "仙女座", 20.585, 45.528888888889, 4.88, 0.032, 0.009},
{2585, 33485, "16 Lyn", "座旗二", "", "天猫座16", "", "Lynx", "天猫座", 104.404583333333, 45.094166666667, 4.90, -0.021, -0.004},
{1412, 20894, "78The2Tau", "毕宿增十三", "", "金牛座θ2", "", "Taurus", "金牛座", 67.165416666667, 15.870833333333, 3.40, 0.103, -0.025},
{5533, 72487, "38 Boo", "玄戈增二", "", "牧夫座38", "Merga", "Bootes", "牧夫座", 222.327916666667, 46.116111111111, 5.74, -0.002, -0.076},
{6008, 79043, "7Kap Her", "天市右垣三", "", "武仙座κ", "Marsic", "Hercules", "武仙座", 242.01875, 17.046944444444, 5.00, -0.032, -0.007},
{7735, 99675, "31 Cyg", "天津增三十八", "", "天鹅座ο1", "", "Cygnus", "天鹅座", 303.407916666667, 46.741388888889, 3.79, 0.004, 0.003},
{8560, 111043, "Del2Gru", "鹤十一", "", "天鹤座δ2", "", "Grus", "天鹤座", 337.439583333333, -43.749444444444, 4.11, -0.007, 0},
{9109, 399, "", "", "", "", "", "", "", 1.233333333333, 26.648888888889, 6.25, 0.11, -0.012},
}
for _, tc := range tests {
got, err := StarDataByHR(tc.hr)
@@ -81,6 +94,25 @@ func TestStarDataByChineseAlias(t *testing.T) {
{"北极星", 424, 11767},
{"织女", 7001, 91262},
{"进贤增九", 4799, 61558},
{"昴宿增九", 1140, 17489},
{"昴宿增八", 1144, 17527},
{"昴宿三", 1151, 17579},
{"昴宿增十二", 1180, 17851},
{"虚梁三", 8610, 111710},
{"壁宿增十", 4, 476},
{"刍藁增二", 681, 10826},
{"云雨增七", 9004, 117245},
{"王良增十", 9085, 124},
{"天大将军增二", 390, 6411},
{"座旗二", 2585, 33485},
{"天街一", 1388, 20641},
{"毕宿六", 1411, 20885},
{"毕宿增十三", 1412, 20894},
{"天市右垣三", 6008, 79043},
{"玄戈增二", 5533, 72487},
{"天津三", 7730, 99639},
{"天津增三十八", 7735, 99675},
{"鹤十一", 8560, 111043},
}
for _, tc := range tests {
got, err := StarDataByChinese(tc.name)
@@ -93,13 +125,127 @@ func TestStarDataByChineseAlias(t *testing.T) {
}
}
func TestStarDataByChineseAmbiguity(t *testing.T) {
if err := LoadStarData(); err != nil {
t.Fatal(err)
}
if _, err := StarDataByChinese("杵增一"); !errors.Is(err, ErrStarNameAmbiguous) {
t.Fatalf("StarDataByChinese(杵增一) error = %v, want ErrStarNameAmbiguous", err)
}
if _, err := StarDataByChinese("不存在的星名"); !errors.Is(err, ErrStarNotFound) {
t.Fatalf("StarDataByChinese(不存在的星名) error = %v, want ErrStarNotFound", err)
}
if got, err := StarDataByChinese(" 天狼星 "); err != nil || got.HR != 2491 {
t.Fatalf("StarDataByChinese with whitespace = HR %d err %v, want HR 2491", got.HR, err)
}
if got, err := StarDataByChineseIn(" 杵增一 ", " 危宿 "); err != nil || got.HR != 8449 {
t.Fatalf("StarDataByChineseIn with whitespace = HR %d err %v, want HR 8449", got.HR, err)
}
if _, err := StarDataByChineseIn("杵增一", ""); !errors.Is(err, ErrStarNameAmbiguous) {
t.Fatalf("StarDataByChineseIn(杵增一, \"\") error = %v, want ErrStarNameAmbiguous", err)
}
if got, err := StarDataByChineseIn("天狼", ""); err != nil || got.HR != 2491 {
t.Fatalf("StarDataByChineseIn(天狼, \"\") = HR %d err %v, want HR 2491", got.HR, err)
}
matches, err := StarDataByChineseAll("杵增一")
if err != nil {
t.Fatal(err)
}
if len(matches) != 2 || matches[0].HR != 6743 || matches[1].HR != 8449 {
t.Fatalf("StarDataByChineseAll(杵增一) = %v, want HR 6743 and 8449", matches)
}
tests := []struct {
name string
region string
hr uint16
}{
{"杵增一", "箕宿", 6743},
{"杵增一", "危宿", 8449},
{"杵一", "箕宿", 6537},
{"杵一", "危宿", 8498},
{"柱一", "毕宿", 1605},
{"柱一", "角宿", 5260},
{"太子", "太微垣", 4527},
{"太子", "紫微垣", 5735},
}
for _, tc := range tests {
got, err := StarDataByChineseIn(tc.name, tc.region)
if err != nil {
t.Fatalf("StarDataByChineseIn(%q, %q): %v", tc.name, tc.region, err)
}
if got.HR != tc.hr || got.ChineseRegion != tc.region {
t.Fatalf("StarDataByChineseIn(%q, %q) = HR %d region %q, want HR %d region %q",
tc.name, tc.region, got.HR, got.ChineseRegion, tc.hr, tc.region)
}
}
for _, tc := range []struct {
name string
hr uint16
}{
{"杵增一[危宿]", 8449},
{"杵增一[箕宿]", 6743},
} {
got, err := StarDataByChinese(tc.name)
if err != nil {
t.Fatalf("StarDataByChinese(%q): %v", tc.name, err)
}
if got.HR != tc.hr {
t.Fatalf("StarDataByChinese(%q) = HR %d, want %d", tc.name, got.HR, tc.hr)
}
}
}
func TestStarDataByNameCombinesChineseAndEnglish(t *testing.T) {
if err := LoadStarData(); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
hr uint16
}{
{"Sirius", 2491},
{"sirius", 2491},
{" SIRIUS ", 2491},
{"Vega", 7001},
{"Polaris", 424},
{"Adhil", 390},
{"天狼", 2491},
{"天狼星", 2491},
}
for _, tc := range tests {
got, err := StarDataByName(tc.name)
if err != nil {
t.Fatalf("StarDataByName(%q): %v", tc.name, err)
}
if got.HR != tc.hr {
t.Fatalf("StarDataByName(%q) = HR %d, want %d", tc.name, got.HR, tc.hr)
}
}
if _, err := StarDataByName("not-a-star"); !errors.Is(err, ErrStarNotFound) {
t.Fatalf("StarDataByName(not-a-star) error = %v, want ErrStarNotFound", err)
}
if _, err := StarDataByName("Gienah"); !errors.Is(err, ErrStarNameAmbiguous) {
t.Fatalf("StarDataByName(Gienah) error = %v, want ErrStarNameAmbiguous", err)
}
matches, err := StarDataByNameAll("gienah")
if err != nil {
t.Fatal(err)
}
if len(matches) != 2 || matches[0].HR != 4662 || matches[1].HR != 7949 {
t.Fatalf("StarDataByNameAll(gienah) = %v, want HR 4662 and 7949", matches)
}
if got, err := StarDataByNameIn("sirius", ""); err != nil || got.HR != 2491 {
t.Fatalf("StarDataByNameIn(sirius, \"\") = HR %d err %v, want HR 2491", got.HR, err)
}
}
func TestStarDataByHRInvalid(t *testing.T) {
if err := LoadStarData(); err != nil {
t.Fatal(err)
}
for _, hr := range []int{0, -1, len(cachedStarData) + 1} {
if _, err := StarDataByHR(hr); err == nil {
t.Fatalf("StarDataByHR(%d) expected error", hr)
if _, err := StarDataByHR(hr); !errors.Is(err, ErrStarNotFound) {
t.Fatalf("StarDataByHR(%d) error = %v, want ErrStarNotFound", hr, err)
}
}
}
@@ -107,7 +253,7 @@ func TestStarDataByHRInvalid(t *testing.T) {
func BenchmarkInitStarData(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
hr2detail = nil
hr2name = nil
hr2hip = nil
chnidx = nil
parsedStarData = nil
+50 -34
View File
@@ -21,11 +21,6 @@ const starCatalogDetailFields = 6
//go:embed star_catalog.dat
var starCatalogCompressed []byte
// supplementalStarDetails 载荷中无命名条目的星表元数据,字段依次为中文名、别名、拜耳命名、通用名、中文星座、IAU 星座。
var supplementalStarDetails = map[uint16][]string{
4799: {"进贤增九", "", "室女座25", "", "室女座", "Virgo"},
}
func initStarCatalogData() []InnerStarData {
reader, err := gzip.NewReader(bytes.NewReader(starCatalogCompressed))
if err != nil {
@@ -36,14 +31,16 @@ func initStarCatalogData() []InnerStarData {
if err != nil {
panic(err)
}
stars, detail, hip, err := decodeStarCatalogPayload(payload)
// STRCAT02 的详情列不参与加载,星名以 star_catalog_names.tsv 为准。
stars, _, hip, err := decodeStarCatalogPayload(payload, false)
if err != nil {
panic(err)
}
for hr, record := range supplementalStarDetails {
detail[hr] = record
names, err := decodeStarNameTable(starCatalogNamesCompressed, len(stars))
if err != nil {
panic(err)
}
hr2detail = detail
hr2name = names
hr2hip = hip
return stars
}
@@ -128,7 +125,7 @@ func starCatalogZigzag(v uint64) int64 {
return int64(v>>1) ^ -int64(v&1)
}
func decodeStarCatalogPayload(payload []byte) ([]InnerStarData, map[uint16][]string, map[uint16]uint32, error) {
func decodeStarCatalogPayload(payload []byte, includeDetail bool) ([]InnerStarData, map[uint16][]string, map[uint16]uint32, error) {
if len(payload) < len(starCatalogMagic)+1 || string(payload[:len(starCatalogMagic)]) != starCatalogMagic {
return nil, nil, nil, errors.New("invalid star catalog magic")
}
@@ -179,30 +176,9 @@ func decodeStarCatalogPayload(payload []byte) ([]InnerStarData, map[uint16][]str
if r.err == nil && stringCount > uint64(len(payload)) {
return nil, nil, nil, fmt.Errorf("star catalog string count %d out of range", stringCount)
}
stringsTable := make([]string, stringCount+1)
for i := 1; i < len(stringsTable); i++ {
stringsTable[i] = string(r.bytes(r.uvarint()))
}
indexes := make([][starCatalogDetailFields]uint64, count)
for k := 0; k < starCatalogDetailFields; k++ {
for i := range indexes {
indexes[i][k] = r.uvarint()
}
}
detail := make(map[uint16][]string)
for i, record := range indexes {
hasValue := false
values := make([]string, starCatalogDetailFields)
for k, index := range record {
if index >= uint64(len(stringsTable)) {
return nil, nil, nil, fmt.Errorf("detail string index out of range: hr=%d index=%d", i+1, index)
}
values[k] = stringsTable[index]
hasValue = hasValue || index != 0
}
if hasValue {
detail[uint16(i+1)] = values
}
detail, err := decodeStarCatalogDetail(r, count, stringCount, includeDetail)
if err != nil {
return nil, nil, nil, err
}
hip := make(map[uint16]uint32)
prevHIP := int64(0)
@@ -222,3 +198,43 @@ func decodeStarCatalogPayload(payload []byte) ([]InnerStarData, map[uint16][]str
}
return stars, detail, hip, nil
}
func decodeStarCatalogDetail(r *starCatalogReader, count, stringCount uint64, includeDetail bool) (map[uint16][]string, error) {
if !includeDetail {
for i := uint64(1); i <= stringCount; i++ {
r.bytes(r.uvarint())
}
for k := 0; k < starCatalogDetailFields; k++ {
for i := 0; i < int(count); i++ {
r.uvarint()
}
}
return nil, nil
}
stringsTable := make([]string, stringCount+1)
for i := 1; i < len(stringsTable); i++ {
stringsTable[i] = string(r.bytes(r.uvarint()))
}
indexes := make([][starCatalogDetailFields]uint64, count)
for k := 0; k < starCatalogDetailFields; k++ {
for i := range indexes {
indexes[i][k] = r.uvarint()
}
}
detail := make(map[uint16][]string)
for i, record := range indexes {
hasValue := false
values := make([]string, starCatalogDetailFields)
for k, index := range record {
if index >= uint64(len(stringsTable)) {
return nil, fmt.Errorf("detail string index out of range: hr=%d index=%d", i+1, index)
}
values[k] = stringsTable[index]
hasValue = hasValue || index != 0
}
if hasValue {
detail[uint16(i+1)] = values
}
}
return detail, nil
}
+115 -4
View File
@@ -1,15 +1,20 @@
package basic
import (
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"io"
"math"
"os"
"strings"
"testing"
)
// starCatalogDigestWant 解码后星表的摘要,锁定逐位一致。
const starCatalogDigestWant = "6a538c97a35dfc9fd2ecad2268ec9a15c3efa1bf4f9ee1cff752448c9c5a7a53"
const starCatalogDigestWant = "3c3455cdb09e1e46c5d513a6c26743c6fd1753f65754888f97ec585324af39e4"
func starCatalogDigest(stars []StarData) string {
hash := sha256.New()
@@ -31,15 +36,15 @@ func starCatalogDigest(stars []StarData) string {
for _, v := range []float64{star.Ra, star.Dec, star.Mag, star.PmRA, star.PmDec, star.RadVel, star.RotVel, star.Pc} {
writeUint(math.Float64bits(v))
}
for _, s := range []string{star.ChineseName, star.ChineseAlias, star.ChineseBayerName, star.CommonName,
star.CommonAliasName, star.Cst, star.CstChinese} {
for _, s := range []string{star.ChineseName, star.ChineseAlias, star.ChineseRegion, star.ChineseBayerName,
star.CommonName, star.CommonAliasName, star.Cst, star.CstChinese} {
writeString(s)
}
}
return hex.EncodeToString(hash.Sum(nil))
}
func TestStarCatalogDigestMatchesLegacyParser(t *testing.T) {
func TestStarCatalogDigestStable(t *testing.T) {
if err := LoadStarData(); err != nil {
t.Fatal(err)
}
@@ -47,3 +52,109 @@ func TestStarCatalogDigestMatchesLegacyParser(t *testing.T) {
t.Fatalf("star catalog digest = %s, want %s", got, starCatalogDigestWant)
}
}
func TestStarCatalogNamesGzipMatchesSource(t *testing.T) {
source, err := os.ReadFile("star_catalog_names.tsv")
if err != nil {
t.Fatal(err)
}
reader, err := gzip.NewReader(bytes.NewReader(starCatalogNamesCompressed))
if err != nil {
t.Fatal(err)
}
decompressed, err := io.ReadAll(reader)
reader.Close()
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(source, decompressed) {
t.Fatal("star_catalog_names.tsv.gz is stale; regenerate it from star_catalog_names.tsv")
}
}
// starNameLegacyCorrections 旧 .dat 详情与单一表的已知差异;新增名称不在其中。
var starNameLegacyCorrections = map[uint16]map[string]bool{
390: {"bayer": true, "chinese": true, "cst": true, "cstzh": true},
565: {"chinese": true},
585: {"chinese": true},
1140: {"bayer": true, "chinese": true, "cst": true, "cstzh": true},
1151: {"bayer": true, "chinese": true},
1180: {"bayer": true, "chinese": true},
1324: {"chinese": true},
1387: {"chinese": true},
1412: {"chinese": true},
1656: {"chinese": true},
2585: {"bayer": true, "cst": true, "cstzh": true},
2590: {"chinese": true},
2714: {"chinese": true},
2878: {"chinese": true},
3314: {"chinese": true},
5226: {"chinese": true},
5396: {"chinese": true},
5533: {"bayer": true, "chinese": true, "cst": true, "cstzh": true},
6008: {"bayer": true, "chinese": true, "cst": true, "cstzh": true},
6554: {"chinese": true},
6636: {"chinese": true},
7735: {"chinese": true},
8313: {"chinese": true},
8556: {"chinese": true},
8610: {"bayer": true, "chinese": true, "cst": true, "cstzh": true},
8982: {"chinese": true},
}
func TestStarCatalogNameTableMatchesLegacyDetails(t *testing.T) {
if err := LoadStarData(); err != nil {
t.Fatal(err)
}
for hr, rec := range hr2name {
for field, value := range map[string]string{
"chinese": rec.chinese,
"aliases": strings.Join(rec.aliases, ";"),
"region": rec.region,
} {
if strings.ContainsAny(value, "[]") {
t.Fatalf("HR %d %s has bracket qualifier %q; use the region column", hr, field, value)
}
}
}
reader, err := gzip.NewReader(bytes.NewReader(starCatalogCompressed))
if err != nil {
t.Fatal(err)
}
payload, err := io.ReadAll(reader)
reader.Close()
if err != nil {
t.Fatal(err)
}
_, detail, _, err := decodeStarCatalogPayload(payload, true)
if err != nil {
t.Fatal(err)
}
for hr, info := range detail {
rec, ok := hr2name[hr]
if !ok {
t.Fatalf("HR %d has legacy details but no row in star_catalog_names.tsv", hr)
}
checks := []struct {
field string
old string
new string
}{
{"chinese", info[0], rec.chinese},
{"aliases", info[1], strings.Join(rec.aliases, ";")},
{"bayer", info[2], rec.bayerZH},
{"common", info[3], rec.common},
{"cstzh", info[4], rec.cstZH},
{"cst", info[5], rec.cst},
}
for _, check := range checks {
if check.old == "" || check.old == check.new {
continue
}
if starNameLegacyCorrections[hr][check.field] {
continue
}
t.Fatalf("HR %d %s: legacy %q, table %q", hr, check.field, check.old, check.new)
}
}
}
+108
View File
@@ -0,0 +1,108 @@
package basic
import (
"bytes"
"compress/gzip"
_ "embed"
"errors"
"fmt"
"io/ioutil"
"strconv"
"strings"
)
// star_catalog_names.tsv.gz 由同名 .tsv 压缩生成,测试校验两者一致。
//
//go:embed star_catalog_names.tsv.gz
var starCatalogNamesCompressed []byte
// starCatalogNameHeader 单一星名表的列顺序,生成端与加载端必须一致。
var starCatalogNameHeader = []string{"hr", "chinese", "aliases", "bayer_zh", "common", "cst", "cst_zh", "region"}
type starNameRecord struct {
chinese string
aliases []string
bayerZH string
common string
cst string
cstZH string
region string
}
func decodeStarNameTable(compressed []byte, starCount int) (map[uint16]starNameRecord, error) {
reader, err := gzip.NewReader(bytes.NewReader(compressed))
if err != nil {
return nil, fmt.Errorf("open star name table: %w", err)
}
defer reader.Close()
text, err := ioutil.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("read star name table: %w", err)
}
lines := strings.Split(string(text), "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" {
return nil, errors.New("empty star name table")
}
header := strings.Split(strings.TrimSuffix(lines[0], "\r"), "\t")
if len(header) != len(starCatalogNameHeader) {
return nil, fmt.Errorf("star name header has %d columns, want %d", len(header), len(starCatalogNameHeader))
}
for i, want := range starCatalogNameHeader {
if header[i] != want {
return nil, fmt.Errorf("star name header column %d = %q, want %q", i+1, header[i], want)
}
}
names := make(map[uint16]starNameRecord)
for index, line := range lines[1:] {
line = strings.TrimSuffix(line, "\r")
if line == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) != len(starCatalogNameHeader) {
return nil, fmt.Errorf("star name line %d has %d columns, want %d", index+2, len(fields), len(starCatalogNameHeader))
}
hr, err := strconv.ParseUint(fields[0], 10, 16)
if err != nil || hr == 0 || int(hr) > starCount {
return nil, fmt.Errorf("star name line %d has invalid HR %q", index+2, fields[0])
}
key := uint16(hr)
if _, ok := names[key]; ok {
return nil, fmt.Errorf("star name line %d repeats HR %d", index+2, key)
}
chinese := fields[1]
region := fields[7]
rec := starNameRecord{
chinese: chinese,
aliases: splitStarAliases(fields[2]),
bayerZH: fields[3],
common: fields[4],
cst: fields[5],
cstZH: fields[6],
region: region,
}
if chinese == "" && len(rec.aliases) == 0 && rec.bayerZH == "" && rec.common == "" &&
rec.cst == "" && rec.cstZH == "" && rec.region == "" {
continue
}
names[key] = rec
}
if len(names) == 0 {
return nil, errors.New("empty star name table")
}
return names, nil
}
func splitStarAliases(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
parts := strings.FieldsFunc(value, func(r rune) bool { return r == ';' || r == ';' })
out := make([]string, 0, len(parts))
for _, part := range parts {
if part = strings.TrimSpace(part); part != "" {
out = append(out, part)
}
}
return out
}
File diff suppressed because it is too large Load Diff
Binary file not shown.