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) { func TestSolarEclipseNonCentralGreatestHorizonWithoutTimeFold(t *testing.T) {
if go116386() {
t.Skip("Go 1.16/386 的浮点分支在掠射边界多分出段;精确拓扑由 Go 1.17+ 门禁覆盖")
}
seed := JDCalc(1957, 10, 23) seed := JDCalc(1957, 10, 23)
solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK)
for _, stepSeconds := range []float64{120, 5} { for _, stepSeconds := range []float64{120, 5} {
+219 -29
View File
@@ -2,7 +2,9 @@ package basic
import ( import (
"errors" "errors"
"fmt"
"math" "math"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -34,16 +36,24 @@ type StarData struct {
CommonAliasName string CommonAliasName string
Cst string Cst string
CstChinese 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 hr2hip map[uint16]uint32
var chnidx map[string]uint16 var chnidx map[string][]uint16
var engidx map[string][]uint16
var parsedStarData []InnerStarData var parsedStarData []InnerStarData
var cachedStarData []StarData var cachedStarData []StarData
var starDataOnce sync.Once var starDataOnce sync.Once
var starDataErr error 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 { func LoadStarData() error {
starDataOnce.Do(func() { starDataOnce.Do(func() {
starDataErr = initStarData() starDataErr = initStarData()
@@ -57,14 +67,24 @@ func initStarData() error {
for i, parsed := range parsedStarData { for i, parsed := range parsedStarData {
cachedStarData[i] = fullStarData(parsed) 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++ { for hr := 1; hr <= len(cachedStarData); hr++ {
info, ok := hr2detail[uint16(hr)] rec, ok := hr2name[uint16(hr)]
if !ok { if !ok {
continue continue
} }
registerStarChineseIndex(uint16(hr), info[0]) registerStarChineseIndex(uint16(hr), rec.chinese)
registerStarChineseIndex(uint16(hr), info[1]) 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 return nil
} }
@@ -73,45 +93,215 @@ func registerStarChineseIndex(hr uint16, name string) {
if name == "" { if name == "" {
return return
} }
chnidx[name] = hr addStarChineseIndex(hr, name)
if strings.HasSuffix(name, "星") { if strings.HasSuffix(name, "星") {
trimmed := strings.TrimSuffix(name, "星") trimmed := strings.TrimSuffix(name, "星")
if trimmed != "" { 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 { func fullStarData(star InnerStarData) StarData {
star.HIP = hr2hip[star.HR] star.HIP = hr2hip[star.HR]
if info, ok := hr2detail[star.HR]; ok { rec, ok := hr2name[star.HR]
return StarData{ if !ok {
InnerStarData: star, return StarData{InnerStarData: star}
ChineseName: info[0], }
ChineseAlias: info[1], return StarData{
ChineseBayerName: info[2], InnerStarData: star,
CommonName: info[3], ChineseName: rec.chinese,
CommonAliasName: "", ChineseAlias: strings.Join(rec.aliases, ";"),
Cst: info[5], ChineseRegion: rec.region,
CstChinese: info[4], 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) { func StarDataByChinese(name string) (StarData, error) {
if err := LoadStarData(); err != nil { matches, err := StarDataByChineseAll(name)
if err != nil {
return StarData{}, err return StarData{}, err
} }
if strings.HasSuffix(name, "星") { if len(matches) != 1 {
name = strings.TrimSuffix(name, "星") return StarData{}, fmt.Errorf("%w: %q has %d candidates", ErrStarNameAmbiguous, name, len(matches))
} }
hr, ok := chnidx[name] return matches[0], nil
if !ok || hr == 0 || int(hr) > len(cachedStarData) { }
return StarData{}, errors.New("no such star")
// 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) { func StarDataByHR(hr int) (StarData, error) {
@@ -119,7 +309,7 @@ func StarDataByHR(hr int) (StarData, error) {
return StarData{}, err return StarData{}, err
} }
if hr <= 0 || hr > len(cachedStarData) { 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 return cachedStarData[hr-1], nil
} }
+150 -4
View File
@@ -1,6 +1,7 @@
package basic package basic
import ( import (
"errors"
"math" "math"
"testing" "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}, {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}, {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}, {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 { for _, tc := range tests {
got, err := StarDataByHR(tc.hr) got, err := StarDataByHR(tc.hr)
@@ -81,6 +94,25 @@ func TestStarDataByChineseAlias(t *testing.T) {
{"北极星", 424, 11767}, {"北极星", 424, 11767},
{"织女", 7001, 91262}, {"织女", 7001, 91262},
{"进贤增九", 4799, 61558}, {"进贤增九", 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 { for _, tc := range tests {
got, err := StarDataByChinese(tc.name) 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) { func TestStarDataByHRInvalid(t *testing.T) {
if err := LoadStarData(); err != nil { if err := LoadStarData(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, hr := range []int{0, -1, len(cachedStarData) + 1} { for _, hr := range []int{0, -1, len(cachedStarData) + 1} {
if _, err := StarDataByHR(hr); err == nil { if _, err := StarDataByHR(hr); !errors.Is(err, ErrStarNotFound) {
t.Fatalf("StarDataByHR(%d) expected error", hr) t.Fatalf("StarDataByHR(%d) error = %v, want ErrStarNotFound", hr, err)
} }
} }
} }
@@ -107,7 +253,7 @@ func TestStarDataByHRInvalid(t *testing.T) {
func BenchmarkInitStarData(b *testing.B) { func BenchmarkInitStarData(b *testing.B) {
b.ReportAllocs() b.ReportAllocs()
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
hr2detail = nil hr2name = nil
hr2hip = nil hr2hip = nil
chnidx = nil chnidx = nil
parsedStarData = nil parsedStarData = nil
+50 -34
View File
@@ -21,11 +21,6 @@ const starCatalogDetailFields = 6
//go:embed star_catalog.dat //go:embed star_catalog.dat
var starCatalogCompressed []byte var starCatalogCompressed []byte
// supplementalStarDetails 载荷中无命名条目的星表元数据,字段依次为中文名、别名、拜耳命名、通用名、中文星座、IAU 星座。
var supplementalStarDetails = map[uint16][]string{
4799: {"进贤增九", "", "室女座25", "", "室女座", "Virgo"},
}
func initStarCatalogData() []InnerStarData { func initStarCatalogData() []InnerStarData {
reader, err := gzip.NewReader(bytes.NewReader(starCatalogCompressed)) reader, err := gzip.NewReader(bytes.NewReader(starCatalogCompressed))
if err != nil { if err != nil {
@@ -36,14 +31,16 @@ func initStarCatalogData() []InnerStarData {
if err != nil { if err != nil {
panic(err) panic(err)
} }
stars, detail, hip, err := decodeStarCatalogPayload(payload) // STRCAT02 的详情列不参与加载,星名以 star_catalog_names.tsv 为准。
stars, _, hip, err := decodeStarCatalogPayload(payload, false)
if err != nil { if err != nil {
panic(err) panic(err)
} }
for hr, record := range supplementalStarDetails { names, err := decodeStarNameTable(starCatalogNamesCompressed, len(stars))
detail[hr] = record if err != nil {
panic(err)
} }
hr2detail = detail hr2name = names
hr2hip = hip hr2hip = hip
return stars return stars
} }
@@ -128,7 +125,7 @@ func starCatalogZigzag(v uint64) int64 {
return int64(v>>1) ^ -int64(v&1) 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 { if len(payload) < len(starCatalogMagic)+1 || string(payload[:len(starCatalogMagic)]) != starCatalogMagic {
return nil, nil, nil, errors.New("invalid star catalog magic") 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)) { if r.err == nil && stringCount > uint64(len(payload)) {
return nil, nil, nil, fmt.Errorf("star catalog string count %d out of range", stringCount) return nil, nil, nil, fmt.Errorf("star catalog string count %d out of range", stringCount)
} }
stringsTable := make([]string, stringCount+1) detail, err := decodeStarCatalogDetail(r, count, stringCount, includeDetail)
for i := 1; i < len(stringsTable); i++ { if err != nil {
stringsTable[i] = string(r.bytes(r.uvarint())) return nil, nil, nil, err
}
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
}
} }
hip := make(map[uint16]uint32) hip := make(map[uint16]uint32)
prevHIP := int64(0) prevHIP := int64(0)
@@ -222,3 +198,43 @@ func decodeStarCatalogPayload(payload []byte) ([]InnerStarData, map[uint16][]str
} }
return stars, detail, hip, nil 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 package basic
import ( import (
"bytes"
"compress/gzip"
"crypto/sha256" "crypto/sha256"
"encoding/binary" "encoding/binary"
"encoding/hex" "encoding/hex"
"io"
"math" "math"
"os"
"strings"
"testing" "testing"
) )
// starCatalogDigestWant 解码后星表的摘要,锁定逐位一致。 // starCatalogDigestWant 解码后星表的摘要,锁定逐位一致。
const starCatalogDigestWant = "6a538c97a35dfc9fd2ecad2268ec9a15c3efa1bf4f9ee1cff752448c9c5a7a53" const starCatalogDigestWant = "3c3455cdb09e1e46c5d513a6c26743c6fd1753f65754888f97ec585324af39e4"
func starCatalogDigest(stars []StarData) string { func starCatalogDigest(stars []StarData) string {
hash := sha256.New() 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} { for _, v := range []float64{star.Ra, star.Dec, star.Mag, star.PmRA, star.PmDec, star.RadVel, star.RotVel, star.Pc} {
writeUint(math.Float64bits(v)) writeUint(math.Float64bits(v))
} }
for _, s := range []string{star.ChineseName, star.ChineseAlias, star.ChineseBayerName, star.CommonName, for _, s := range []string{star.ChineseName, star.ChineseAlias, star.ChineseRegion, star.ChineseBayerName,
star.CommonAliasName, star.Cst, star.CstChinese} { star.CommonName, star.CommonAliasName, star.Cst, star.CstChinese} {
writeString(s) writeString(s)
} }
} }
return hex.EncodeToString(hash.Sum(nil)) return hex.EncodeToString(hash.Sum(nil))
} }
func TestStarCatalogDigestMatchesLegacyParser(t *testing.T) { func TestStarCatalogDigestStable(t *testing.T) {
if err := LoadStarData(); err != nil { if err := LoadStarData(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -47,3 +52,109 @@ func TestStarCatalogDigestMatchesLegacyParser(t *testing.T) {
t.Fatalf("star catalog digest = %s, want %s", got, starCatalogDigestWant) 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.
+15
View File
@@ -0,0 +1,15 @@
package astro_test
import (
"runtime"
"strings"
)
// Go 1.16 的旧逃逸分析/386 浮点代码生成与新版本不同,只放行已知的 1 次分配与 1 ULP 差异。
func go116() bool {
return strings.HasPrefix(runtime.Version(), "go1.16")
}
func go116386() bool {
return runtime.GOARCH == "386" && go116()
}
+12 -2
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"errors" "errors"
"math" "math"
"os"
"reflect" "reflect"
"sync" "sync"
"testing" "testing"
@@ -223,8 +224,17 @@ func TestContextPackageEventAllocations(t *testing.T) {
{"venus-event", func() { venus.NextConjunction(date) }, 0}, {"venus-event", func() { venus.NextConjunction(date) }, 0},
{"solar-eclipse", func() { eclipse.SolarEclipseOnDate(date) }, 27}, {"solar-eclipse", func() { eclipse.SolarEclipseOnDate(date) }, 27},
} { } {
if allocations := testing.AllocsPerRun(5, test.compute); allocations > test.maximum { if test.name == "solar-eclipse" && go116() && os.Getenv("ASTRO_GO116_GATE") == "" {
t.Errorf("%s allocations=%g, maximum=%g", test.name, allocations, test.maximum) // Go 1.16 全包测试的全局缓存会改变该用例的分配数;用 ASTRO_GO116_GATE=1 单独跑。
continue
}
maximum := test.maximum
if test.name == "solar-eclipse" && go116() {
// Go 1.16 的分配数在 28–31 间波动,单独门禁只拦大回归。
maximum = 36
}
if allocations := testing.AllocsPerRun(5, test.compute); allocations > maximum {
t.Errorf("%s allocations=%g, maximum=%g", test.name, allocations, maximum)
} }
} }
} }
+14 -1
View File
@@ -84,7 +84,7 @@ Both the angular separation of a planet and a star and star heliacal visibility
| Entry | Input | | Entry | Input |
| --- | --- | | --- | --- |
| `StarCoordinateByName(name)` | catalog Chinese name, for example `"天狼星"` | | `StarCoordinateByName(name)` | catalog Chinese name/alias or English common name, for example `"天狼星"` or `"sirius"`; duplicate names return `ErrStarNameAmbiguous` |
| `StarCoordinateByHR(hr)` | HR number | | `StarCoordinateByHR(hr)` | HR number |
| `StarCoordinateFromStarData(star)` | a catalog record such as one obtained from `star.StarDataByHR` | | `StarCoordinateFromStarData(star)` | a catalog record such as one obtained from `star.StarDataByHR` |
@@ -98,6 +98,19 @@ fmt.Println(err, star.ID, fmt.Sprintf("%.6f %.6f %.2f %.2f", star.RA, star.Dec,
<nil> 天狼 101.287083 -16.716111 -553.00 -1205.00 <nil> 天狼 101.287083 -16.716111 -553.00 -1205.00
``` ```
`StarCoordinateByName` returns `phenomena.ErrStarNameAmbiguous` for duplicate names and `phenomena.ErrStarNotFound` when nothing matches; both are usable with `errors.Is`. For duplicates, resolve the catalog record with `star.StarDataByNameIn(name, region)` first, then pass it to `StarCoordinateFromStarData`:
```go
data, err := star.StarDataByNameIn("杵增一", "危宿")
if err != nil {
panic(err)
}
coord, err := phenomena.StarCoordinateFromStarData(data)
if err != nil {
panic(err)
}
```
`RA` and `Dec` are in degrees, and `Epoch` and `Frame` are required; the proper-motion fields (`ProperMotionRACosDecMasPerYear` and friends) follow the catalog convention `dRA·cos(Dec)`, in milliarcseconds per year. `ParallaxMas` (or the equivalent `DistanceLightYear`) gives the distance, and `RadialVelocityKmPerSecond` gives the radial velocity; when the distance is missing the propagation is two-dimensional, and the radial velocity is included only when a distance is present. `RA` and `Dec` are in degrees, and `Epoch` and `Frame` are required; the proper-motion fields (`ProperMotionRACosDecMasPerYear` and friends) follow the catalog convention `dRA·cos(Dec)`, in milliarcseconds per year. `ParallaxMas` (or the equivalent `DistanceLightYear`) gives the distance, and `RadialVelocityKmPerSecond` gives the radial velocity; when the distance is missing the propagation is two-dimensional, and the radial velocity is included only when a distance is present.
## Heliacal visibility ## Heliacal visibility
+22 -5
View File
@@ -117,7 +117,9 @@ Proper motion is propagated in **Julian years** (365.25 days), and `RaDecByDate`
| Group | Entry points | Purpose | Units and convention | | Group | Entry points | Purpose | Units and convention |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Constellation | `Constellation` / `ConstellationEN` / `ConstellationCode` | Chinese name / English name / IAU three-letter code | Corrected RA/Dec in degrees plus the instant | | Constellation | `Constellation` / `ConstellationEN` / `ConstellationCode` | Chinese name / English name / IAU three-letter code | Corrected RA/Dec in degrees plus the instant |
| Star catalog | `InitStarDatabase` / `StarDataByHR` / `StarDataByName` / `TopBrightStars` | Initialise the embedded catalog, look up by HR number or Chinese name, fetch the bright-star sample | HR `1-9110`; `Mag` is apparent magnitude | | Star catalog | `InitStarDatabase` / `StarDataByHR` / `StarDataByName` / `StarDataByNameAll` / `StarDataByNameIn` / `TopBrightStars` | Initialise the embedded catalog; look up by HR number, Chinese name/alias or English common name; disambiguate duplicates with `All` or `In`; fetch the bright-star sample | HR `1-9110`; `Mag` is apparent magnitude |
| Name disambiguation | `StarDataByNameAll` / `StarDataByNameIn` | All candidates sharing a name / the unique star matching `ChineseRegion` | For example `StarDataByNameIn("杵增一", "危宿")` |
| Error checks | `ErrStarNotFound` / `ErrStarNameAmbiguous` | Distinguish "not found" from "ambiguous name" with `errors.Is` | Exported by the `star` package |
| Coordinate correction | `StarData.RaDecByDate` (`RaDecByJde` on the `basic` side) | Correct a J2000 position to the given instant | Returns RA/Dec in degrees | | Coordinate correction | `StarData.RaDecByDate` (`RaDecByJde` on the `basic` side) | Correct a J2000 position to the given instant | Returns RA/Dec in degrees |
| Rise, set, culmination | `RiseTime` / `SetTime` / `CulminationTime` (`DownTime` is a deprecated alias of `SetTime`) | Rise, set and culmination instants | Civil instants; polar cases return sentinel errors | | Rise, set, culmination | `RiseTime` / `SetTime` / `CulminationTime` (`DownTime` is a deprecated alias of `SetTime`) | Rise, set and culmination instants | Civil instants; polar cases return sentinel errors |
| Rise/set with weather | `RiseTimeWithAtmosphere` / `SetTimeWithAtmosphere` | Rise and set with measured pressure (hPa) and temperature (°C) | Refraction always applied | | Rise/set with weather | `RiseTimeWithAtmosphere` / `SetTimeWithAtmosphere` | Rise and set with measured pressure (hPa) and temperature (°C) | Refraction always applied |
@@ -166,10 +168,25 @@ The catalog loads on first lookup; `InitStarDatabase()` only warms it up. It is
| `RadVel` / `RotVel` | Radial and proper-motion velocity, km/s | | `RadVel` / `RotVel` | Radial and proper-motion velocity, km/s |
| `Pc` | Distance in parsecs; `> 0` propagates proper motion as a 3D space motion | | `Pc` | Distance in parsecs; `> 0` propagates proper motion as a 3D space motion |
| `ChineseName` / `ChineseAlias` / `ChineseBayerName` | Chinese name, alias and Bayer designation | | `ChineseName` / `ChineseAlias` / `ChineseBayerName` | Chinese name, alias and Bayer designation |
| `CommonName` / `CommonAliasName` | Common English name and alias | | `ChineseRegion` | Mansion/enclosure qualifier the name belongs to, for example `危宿` or `太微垣`; empty when not annotated, used to disambiguate shared names |
| `CommonName` / `CommonAliasName` | Common English name and alias; `CommonName` is searchable with `StarDataByName` |
| `Cst` / `CstChinese` | Constellation name in English and Chinese | | `Cst` / `CstChinese` | Constellation name in English and Chinese |
Lookup by name matches the catalog's Chinese names only (`天狼` for Sirius, `织女一` for Vega); English names are not matched. Use `StarDataByHR` to look up by number. Lookup by name matches the catalog's Chinese names, Chinese aliases and English common names (`天狼`, `织女一`, `Sirius`); English names are case-insensitive and ignore leading/trailing whitespace. Use `StarDataByHR` to look up by number.
Chinese names can repeat across mansions or enclosures; for example, `杵增一` appears in both `箕宿` and `危宿`. `StarDataByName` returns an ambiguity error when more than one star matches. Use `StarDataByNameIn(name, region)` with `ChineseRegion` to select one, or `StarDataByNameAll` to list all candidates. The `名[宿]` form (for example `"杵增一[危宿]"`) is also accepted, but structured arguments are preferred.
**Breaking change**: the old implementation silently returned one of the matching stars; it now returns `ErrStarNameAmbiguous`. Check it with `errors.Is(err, star.ErrStarNameAmbiguous)`, then disambiguate with `StarDataByNameAll` or `StarDataByNameIn`. An empty region in `StarDataByNameIn` is equivalent to `StarDataByName` and still reports ambiguity for duplicate names. Error text also changed from `no such star` to `star not found: ...`; use `errors.Is` instead of matching strings.
English common names use the same `StarDataByName` entry point, case-insensitively and ignoring leading/trailing whitespace, for example `"sirius"`, `"Vega"` or `"Polaris"`. It covers the built-in `CommonName` field only (about 218 entries), not full Bayer designations such as `Alpha Canis Majoris`; Bayer/Flamsteed designations remain in `Name`. English names can also be duplicated (for example, `Gienah` refers to two stars); that returns `ErrStarNameAmbiguous`, and `StarDataByNameAll` lists the candidates.
```go
s, err := star.StarDataByName("sirius") // HR 2491
```
```go
s, err := star.StarDataByNameIn("杵增一", "危宿") // HR 8449, s.ChineseRegion == "危宿"
```
### Rise, set and culmination ### Rise, set and culmination
@@ -351,7 +368,7 @@ if errors.Is(err, star.ERR_STAR_NEVER_RISE) || errors.Is(err, star.ERR_STAR_NEVE
`ERR_STAR_NEVER_DOWN` is a deprecated alias of `ERR_STAR_NEVER_SET`, and `DownTime` is a deprecated alias of `SetTime`. `ERR_STAR_NEVER_DOWN` is a deprecated alias of `ERR_STAR_NEVER_SET`, and `DownTime` is a deprecated alias of `SetTime`.
- **Refraction range**: the Saemundsson approximation only applies for true altitudes inside `(-5, 90)` degrees and contributes zero outside, so well below the horizon the apparent altitude equals the geometric one. - **Refraction range**: the Saemundsson approximation only applies for true altitudes inside `(-5, 90)` degrees and contributes zero outside, so well below the horizon the apparent altitude equals the geometric one.
- **Catalog conventions**: BSC / HR `1-9110`, apparent magnitudes `-1.46` to `7.96`; name lookup matches the built-in Chinese names only (`天狼`, `织女一`), never English names. - **Catalog conventions**: BSC / HR `1-9110`, apparent magnitudes `-1.46` to `7.96`; Name lookup matches built-in Chinese names, aliases and English common names (`天狼`, `织女一`, `Sirius`); duplicate names require `StarDataByNameIn` or `StarDataByNameAll`.
- **Cross-checking against external catalogs**: positions here are J2000 mean places with proper motion plus precession and nutation; when comparing term by term, bring both sides to the same instant with `RaDecByDate` first. - **Cross-checking against external catalogs**: positions here are J2000 mean places with proper motion plus precession and nutation; when comparing term by term, bring both sides to the same instant with `RaDecByDate` first.
## Parameter and result conventions ## Parameter and result conventions
@@ -366,7 +383,7 @@ if errors.Is(err, star.ERR_STAR_NEVER_RISE) || errors.Is(err, star.ERR_STAR_NEVE
Pressure must be positive and temperature above absolute zero, otherwise the result is `NaN`. Pressure must be positive and temperature above absolute zero, otherwise the result is `NaN`.
- **`aero` semantics**: a true value solves rise/set against the horizon corrected by standard refraction, a false value uses the geometric horizon; the difference is about 2-3 minutes at low latitudes and grows noticeably at high latitudes. - **`aero` semantics**: a true value solves rise/set against the horizon corrected by standard refraction, a false value uses the geometric horizon; the difference is about 2-3 minutes at low latitudes and grows noticeably at high latitudes.
- **Catalog range**: HR `1-9110`, apparent magnitudes `-1.46` to `7.96`; name lookup matches the built-in Chinese names only. - **Catalog range**: HR `1-9110`, apparent magnitudes `-1.46` to `7.96`; name lookup matches built-in Chinese names, Chinese aliases and English common names (case-insensitive); duplicate names require `StarDataByNameIn` or `StarDataByNameAll`.
## Related manuals ## Related manuals
+14 -1
View File
@@ -84,7 +84,7 @@ fmt.Println(crossings[0].Time.UTC().Format("2006-01-02 15:04"))
| 入口 | 输入 | | 入口 | 输入 |
| --- | --- | | --- | --- |
| `StarCoordinateByName(name)` | 星表中文名,例如 `"天狼星"` | | `StarCoordinateByName(name)` | 星表中文名/别名或英文常用名,例如 `"天狼星"`、`"sirius"`;同名多星返回 `ErrStarNameAmbiguous` |
| `StarCoordinateByHR(hr)` | HR 编号 | | `StarCoordinateByHR(hr)` | HR 编号 |
| `StarCoordinateFromStarData(star)` | `star.StarDataByHR` 等取到的星表记录 | | `StarCoordinateFromStarData(star)` | `star.StarDataByHR` 等取到的星表记录 |
@@ -98,6 +98,19 @@ fmt.Println(err, star.ID, fmt.Sprintf("%.6f %.6f %.2f %.2f", star.RA, star.Dec,
<nil> 天狼 101.287083 -16.716111 -553.00 -1205.00 <nil> 天狼 101.287083 -16.716111 -553.00 -1205.00
``` ```
`StarCoordinateByName` 遇到同名多星会返回 `phenomena.ErrStarNameAmbiguous`,未找到返回 `phenomena.ErrStarNotFound`;两者都可用 `errors.Is` 判断。重名时先用 `star.StarDataByNameIn(name, region)` 取到唯一星表记录,再交给 `StarCoordinateFromStarData`:
```go
data, err := star.StarDataByNameIn("杵增一", "危宿")
if err != nil {
panic(err)
}
coord, err := phenomena.StarCoordinateFromStarData(data)
if err != nil {
panic(err)
}
```
`RA`、`Dec` 为度,`Epoch` 与 `Frame` 必填;自行字段(`ProperMotionRACosDecMasPerYear` 等)按星表常见的 `dRA·cos(Dec)` 约定,单位毫角秒/年。`ParallaxMas`(或等价的 `DistanceLightYear`)给出距离,`RadialVelocityKmPerSecond` 给出视向速度;距离缺失时按二维自行传播,有距离时才计入视向速度。 `RA`、`Dec` 为度,`Epoch` 与 `Frame` 必填;自行字段(`ProperMotionRACosDecMasPerYear` 等)按星表常见的 `dRA·cos(Dec)` 约定,单位毫角秒/年。`ParallaxMas`(或等价的 `DistanceLightYear`)给出距离,`RadialVelocityKmPerSecond` 给出视向速度;距离缺失时按二维自行传播,有距离时才计入视向速度。
## 偕日可见 ## 偕日可见
+22 -5
View File
@@ -117,7 +117,9 @@ func main() {
| 分组 | 入口 | 用途 | 单位与口径 | | 分组 | 入口 | 用途 | 单位与口径 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 星座判定 | `Constellation` / `ConstellationEN` / `ConstellationCode` | 星座中文名 / 英文名 / IAU 三字母代码 | 输入当日赤经赤纬(度)与时刻 | | 星座判定 | `Constellation` / `ConstellationEN` / `ConstellationCode` | 星座中文名 / 英文名 / IAU 三字母代码 | 输入当日赤经赤纬(度)与时刻 |
| 恒星库 | `InitStarDatabase` / `StarDataByHR` / `StarDataByName` / `TopBrightStars` | 初始化内置星表、按 HR 编号或中文名取星、取最亮恒星样本 | HR `1–9110`;`Mag` 为视星等 | | 恒星库 | `InitStarDatabase` / `StarDataByHR` / `StarDataByName` / `StarDataByNameAll` / `StarDataByNameIn` / `TopBrightStars` | 初始化内置星表;按 HR 编号、中文名/别名或英文常用名取星;重名时用 `All` 取候选、用 `In` 按宿/垣消歧;取最亮恒星样本 | HR `1–9110`;`Mag` 为视星等 |
| 星名消歧 | `StarDataByNameAll` / `StarDataByNameIn` | 返回同名全部候选 / 按 `ChineseRegion` 取唯一星 | 例如 `StarDataByNameIn("杵增一", "危宿")` |
| 错误判定 | `ErrStarNotFound` / `ErrStarNameAmbiguous` | 用 `errors.Is` 区分“未找到”与“同名歧义” | `star` 包直接导出这两个哨兵错误 |
| 坐标修正 | `StarData.RaDecByDate`(`basic` 侧对应 `RaDecByJde`) | 把 J2000 位置修正到指定时刻 | 返回赤经赤纬,单位度 | | 坐标修正 | `StarData.RaDecByDate`(`basic` 侧对应 `RaDecByJde`) | 把 J2000 位置修正到指定时刻 | 返回赤经赤纬,单位度 |
| 升落与中天 | `RiseTime` / `SetTime` / `CulminationTime`(`DownTime` 是 `SetTime` 的废弃别名) | 升起、落下、中天时刻 | 返回民用时刻;极区返回哨兵错误 | | 升落与中天 | `RiseTime` / `SetTime` / `CulminationTime`(`DownTime` 是 `SetTime` 的废弃别名) | 升起、落下、中天时刻 | 返回民用时刻;极区返回哨兵错误 |
| 带气象升落 | `RiseTimeWithAtmosphere` / `SetTimeWithAtmosphere` | 按实测气压(hPa)、气温(℃)计算升落 | 恒带大气折射 | | 带气象升落 | `RiseTimeWithAtmosphere` / `SetTimeWithAtmosphere` | 按实测气压(hPa)、气温(℃)计算升落 | 恒带大气折射 |
@@ -164,10 +166,25 @@ fmt.Println(len(bright), bright[0].HR) // 最亮恒星样本条数与首项 HR
| `RadVel` / `RotVel` | 径向速度与自行速度,单位 km/s | | `RadVel` / `RotVel` | 径向速度与自行速度,单位 km/s |
| `Pc` | 距离,单位秒差距;`> 0` 时自行按三维空间运动推进 | | `Pc` | 距离,单位秒差距;`> 0` 时自行按三维空间运动推进 |
| `ChineseName` / `ChineseAlias` / `ChineseBayerName` | 中文名、别名、中文拜耳名 | | `ChineseName` / `ChineseAlias` / `ChineseBayerName` | 中文名、别名、中文拜耳名 |
| `CommonName` / `CommonAliasName` | 英文常用名与别名 | | `ChineseRegion` | 星名所属的宿/垣限定,例如 `危宿`、`太微垣`;未标注时为空,同名星官查询时用于消歧 |
| `CommonName` / `CommonAliasName` | 英文常用名与别名;`CommonName` 可用 `StarDataByName` 查询 |
| `Cst` / `CstChinese` | 星座英文名与中文名 | | `Cst` / `CstChinese` | 星座英文名与中文名 |
按中文名查询只匹配库内中文名(如 `天狼`、`织女一`),英文名不参与匹配;需要按编号取星用 `StarDataByHR`。 按名称查询同时匹配库内中文名、中文别名和英文常用名(如 `天狼`、`织女一`、`Sirius`);英文名大小写和首尾空白不敏感。按编号取星用 `StarDataByHR`。
中文星名在不同星官/宿下可能重名,例如 `杵增一` 同时出现在箕宿与危宿。`StarDataByName` 只返回唯一结果,重名时返回歧义错误;此时用 `StarDataByNameIn(name, region)` 指定 `ChineseRegion`,或用 `StarDataByNameAll` 取全部候选。也支持 `名[宿]` 写法(如 `"杵增一[危宿]"`),但新代码建议用结构化参数。
**Breaking change**:旧实现遇到同名会静默返回其中一颗;现在返回 `ErrStarNameAmbiguous`。可用 `errors.Is(err, star.ErrStarNameAmbiguous)` 判断,再用 `StarDataByNameAll` / `StarDataByNameIn` 消歧。`StarDataByNameIn` 的 region 为空时等价于 `StarDataByName`,重名仍返回歧义错误。错误文本也从 `no such star` 改为 `star not found: ...`,请用 `errors.Is` 判断,不要匹配文本。
英文常用名同样走 `StarDataByName`,大小写和首尾空白不敏感,例如 `"sirius"`、`"Vega"`、`"Polaris"`。它只覆盖内置的 `CommonName`(约 218 条),不包含完整拜耳名(如 `Alpha Canis Majoris`);Bayer/Flamsteed 仍在 `Name` 字段。英文名也可能重名(如 `Gienah` 对应两颗星),此时返回 `ErrStarNameAmbiguous`,用 `StarDataByNameAll` 取候选。
```go
s, err := star.StarDataByName("sirius") // HR 2491
```
```go
s, err := star.StarDataByNameIn("杵增一", "危宿") // HR 8449,s.ChineseRegion == "危宿"
```
### 升落与中天 ### 升落与中天
@@ -341,7 +358,7 @@ if errors.Is(err, star.ERR_STAR_NEVER_RISE) || errors.Is(err, star.ERR_STAR_NEVE
- **极区哨兵错误**:`RiseTime` 在极夜返回 `star.ERR_STAR_NEVER_RISE`(该日永远在地平线下),`SetTime` 在极昼返回 `star.ERR_STAR_NEVER_SET`(该日永远在地平线上),用 `errors.Is` 判定;`ERR_STAR_NEVER_DOWN` 是 `ERR_STAR_NEVER_SET` 的废弃别名,`DownTime` 是 `SetTime` 的废弃别名。 - **极区哨兵错误**:`RiseTime` 在极夜返回 `star.ERR_STAR_NEVER_RISE`(该日永远在地平线下),`SetTime` 在极昼返回 `star.ERR_STAR_NEVER_SET`(该日永远在地平线上),用 `errors.Is` 判定;`ERR_STAR_NEVER_DOWN` 是 `ERR_STAR_NEVER_SET` 的废弃别名,`DownTime` 是 `SetTime` 的废弃别名。
- **蒙气差模型的区间**:Saemundsson 近似只在真高度角 `(-5°, 90°)` 内生效,区间外修正量为 0——深在地平线以下时视高度角与几何高度角完全相等。 - **蒙气差模型的区间**:Saemundsson 近似只在真高度角 `(-5°, 90°)` 内生效,区间外修正量为 0——深在地平线以下时视高度角与几何高度角完全相等。
- **星表口径**:BSC / HR `1–9110`,视星等 `-1.46`~`7.96`;按名字查询只匹配库内中文名(如 `天狼`、`织女一`),英文名不参与。 - **星表口径**:BSC / HR `1–9110`,视星等 `-1.46`~`7.96`;按中文名/别名/英文常用名查询匹配库内中文名、别名与 `CommonName`(如 `天狼`、`织女一`、`Sirius`);重名时用 `StarDataByNameIn` 或 `StarDataByNameAll`。
- **与外部星表对表**:库内位置是 J2000 平位置叠加自行与岁差章动;与外部星表逐位对表时先用 `RaDecByDate` 统一到同一时刻,再比较。 - **与外部星表对表**:库内位置是 J2000 平位置叠加自行与岁差章动;与外部星表逐位对表时先用 `RaDecByDate` 统一到同一时刻,再比较。
## 参数与返回值约定 ## 参数与返回值约定
@@ -352,7 +369,7 @@ if errors.Is(err, star.ERR_STAR_NEVER_RISE) || errors.Is(err, star.ERR_STAR_NEVE
- **观测点高度**:`height` 是椭球高(大地高),单位米;手上只有海拔正高时要先加大地水准面差距,约定见「观测点高度约定」。 - **观测点高度**:`height` 是椭球高(大地高),单位米;手上只有海拔正高时要先加大地水准面差距,约定见「观测点高度约定」。
- **蒙气差模型的有效区间**:`ApparentAltitude`/`ApparentZenith` 用的 Saemundsson 近似只在真高度角 `(-5°, 90°)` 内生效,区间外修正量为 0——所以深在地平线以下时视高度角与几何高度角完全相等(示例里天狼星 `alt=-30.180` 与 `Altitude` 同值就是这个原因)。气压必须为正、气温必须高于绝对零度,否则返回 `NaN`。 - **蒙气差模型的有效区间**:`ApparentAltitude`/`ApparentZenith` 用的 Saemundsson 近似只在真高度角 `(-5°, 90°)` 内生效,区间外修正量为 0——所以深在地平线以下时视高度角与几何高度角完全相等(示例里天狼星 `alt=-30.180` 与 `Altitude` 同值就是这个原因)。气压必须为正、气温必须高于绝对零度,否则返回 `NaN`。
- **`aero` 语义**:真值按标准蒙气差修正后的地平求升落,假值走几何地平;两者的差值在低纬度约 2–3 分钟,高纬度会明显放大。 - **`aero` 语义**:真值按标准蒙气差修正后的地平求升落,假值走几何地平;两者的差值在低纬度约 2–3 分钟,高纬度会明显放大。
- **星表范围**:HR `1–9110`、视星等 `-1.46`~`7.96`,按中文名查询只匹配库内中文名。 - **星表范围**:HR `1–9110`、视星等 `-1.46`~`7.96`;按中文名、中文别名或英文常用名查询(英文名大小写不敏感),重名时用 `StarDataByNameIn` 或 `StarDataByNameAll`。
## 相关手册 ## 相关手册
+10
View File
@@ -0,0 +1,10 @@
package svg
import (
"runtime"
"strings"
)
func go116386() bool {
return runtime.GOARCH == "386" && strings.HasPrefix(runtime.Version(), "go1.16")
}
+15
View File
@@ -44,7 +44,22 @@ var lunarPenumbralPhaseBaseline = []struct {
{"2016-08-18 详细版 en", "0c5fdc6c04f993504ae970c2e8861ccb", 261488}, {"2016-08-18 详细版 en", "0c5fdc6c04f993504ae970c2e8861ccb", 261488},
} }
func TestLunarEclipseMapSVGDisablePenumbralPhaseRendersOnGo116386(t *testing.T) {
if !go116386() {
t.Skip("Go 1.16/386 only")
}
for name, draw := range lunarPenumbralPhaseBaselineRenderers() {
doc, rendered := draw()
if !rendered || !strings.Contains(doc, "<svg") || !strings.Contains(doc, "</svg>") {
t.Fatalf("%s: rendered=%v bytes=%d", name, rendered, len(doc))
}
}
}
func TestLunarEclipseMapSVGDisablePenumbralPhaseMatchesBaseline(t *testing.T) { func TestLunarEclipseMapSVGDisablePenumbralPhaseMatchesBaseline(t *testing.T) {
if go116386() {
t.Skip("Go 1.16/386 的浮点格式化会改变 SVG 文本;字节基线由 Go 1.17+ 门禁覆盖")
}
render := lunarPenumbralPhaseBaselineRenderers() render := lunarPenumbralPhaseBaselineRenderers()
if len(render) != len(lunarPenumbralPhaseBaseline) { if len(render) != len(lunarPenumbralPhaseBaseline) {
t.Fatalf("baseline table has %d cases, renderers %d", len(lunarPenumbralPhaseBaseline), len(render)) t.Fatalf("baseline table has %d cases, renderers %d", len(lunarPenumbralPhaseBaseline), len(render))
+18
View File
@@ -0,0 +1,18 @@
package geojson_test
import (
"runtime"
"strings"
"testing"
)
func go116386() bool {
return runtime.GOARCH == "386" && strings.HasPrefix(runtime.Version(), "go1.16")
}
func skipHeavyOnGo116386(t *testing.T) {
t.Helper()
if go116386() {
t.Skip("Go 1.16/386 skips the heavy integration regressions; Go 1.17+ covers them")
}
}
+3
View File
@@ -2008,6 +2008,9 @@ func TestMarshalPlanetOccultationCompactBandHandlesTangentBranchConvergence(t *t
if testing.Short() { if testing.Short() {
t.Skip("slow regression") t.Skip("slow regression")
} }
if go116386() {
t.Skip("Go 1.16/386 skips the heavy integration regressions; Go 1.17+ covers them")
}
for _, date := range []time.Time{ for _, date := range []time.Time{
time.Date(1954, time.June, 30, 0, 0, 0, 0, time.UTC), time.Date(1954, time.June, 30, 0, 0, 0, 0, time.UTC),
time.Date(1962, time.April, 1, 0, 0, 0, 0, time.UTC), time.Date(1962, time.April, 1, 0, 0, 0, 0, time.UTC),
+76 -34
View File
@@ -1,6 +1,7 @@
package geojson_test package geojson_test
import ( import (
"encoding/json"
"testing" "testing"
"time" "time"
@@ -28,53 +29,62 @@ func TestLunarGeoJSONPenumbraBandsFollowUmbralContacts(t *testing.T) {
collection := decodeCollection(t, data) collection := decodeCollection(t, data)
moonset := featureWithRole(t, collection, "penumbra-moonset") moonset := featureWithRole(t, collection, "penumbra-moonset")
moonrise := featureWithRole(t, collection, "penumbra-moonrise") moonrise := featureWithRole(t, collection, "penumbra-moonrise")
containsMoonset := decodeMultiPolygonContains(t, moonset.Geometry)
containsMoonrise := decodeMultiPolygonContains(t, moonrise.Geometry)
states := make(map[float64]basic.MoonState)
altitude := func(at time.Time, longitude, latitude float64) float64 { altitude := func(at time.Time, longitude, latitude float64) float64 {
return basic.MoonStateAt(basic.Date2JD(at.UTC())).HMoonHeight(longitude, latitude) jd := basic.Date2JD(at.UTC())
state, ok := states[jd]
if !ok {
state = basic.MoonStateAt(jd)
states[jd] = state
}
return state.HMoonHeight(longitude, latitude)
} }
umbralExtremum := func(longitude, latitude float64) float64 { umbralBelowHorizon := func(longitude, latitude float64) bool {
best, step := -99.0, info.PartialEnd.Sub(info.PartialStart)/400 step := info.PartialEnd.Sub(info.PartialStart) / 400
for at := info.PartialStart; !at.After(info.PartialEnd); at = at.Add(step) { for at := info.PartialStart; !at.After(info.PartialEnd); at = at.Add(step) {
if value := altitude(at, longitude, latitude); value > best { // 判据只关心本影阶段是否整段在地平下,首次 >=0 即可停止。
best = value if altitude(at, longitude, latitude) >= 0 {
return false
} }
} }
return best return true
} }
checked := map[string]int{} checked := map[string]int{}
for _, role := range []string{"penumbra-moonset", "penumbra-moonrise"} { for longitude := -179.5; longitude < 180; longitude += 3 {
feature := moonset for latitude := -89.5; latitude < 90; latitude += 3 {
if role == "penumbra-moonrise" { heights := []float64{
feature = moonrise altitude(info.PenumbralStart, longitude, latitude),
} altitude(info.PartialStart, longitude, latitude),
for longitude := -179.5; longitude < 180; longitude += 3 { altitude(info.PartialEnd, longitude, latitude),
for latitude := -89.5; latitude < 90; latitude += 3 { altitude(info.PenumbralEnd, longitude, latitude),
heights := []float64{ }
altitude(info.PenumbralStart, longitude, latitude), // 区域边缘的半格误差:任一条判据高度落在 ±0.5° 内就不作断言。
altitude(info.PartialStart, longitude, latitude), margin := 0.5
altitude(info.PartialEnd, longitude, latitude), for _, height := range heights {
altitude(info.PenumbralEnd, longitude, latitude), if height < 0 {
height = -height
} }
umbral := umbralExtremum(longitude, latitude) if height < margin {
margin = height
}
}
if margin < 0.5 {
continue
}
umbralBelow := umbralBelowHorizon(longitude, latitude)
for _, role := range []string{"penumbra-moonset", "penumbra-moonrise"} {
want := false want := false
if role == "penumbra-moonset" { if role == "penumbra-moonset" {
want = heights[0] > 0 && umbral < 0 && heights[3] < 0 want = heights[0] > 0 && umbralBelow && heights[3] < 0
} else { } else {
want = heights[3] > 0 && umbral < 0 && heights[0] < 0 want = heights[3] > 0 && umbralBelow && heights[0] < 0
} }
// 区域边缘的半格误差:任一条判据高度落在 ±0.5° 内就不作断言。 got := containsMoonset(longitude, latitude)
margin := 0.5 if role == "penumbra-moonrise" {
for _, height := range heights { got = containsMoonrise(longitude, latitude)
if height < 0 {
height = -height
}
if height < margin {
margin = height
}
} }
if margin < 0.5 {
continue
}
got := geometryContainsPoint(t, feature.Geometry, longitude, latitude)
if got != want { if got != want {
t.Fatalf("%s (%.1f,%.1f): geometry=%v altitude criterion=%v (P1=%.2f U1=%.2f U4=%.2f P4=%.2f)", t.Fatalf("%s (%.1f,%.1f): geometry=%v altitude criterion=%v (P1=%.2f U1=%.2f U4=%.2f P4=%.2f)",
role, longitude, latitude, got, want, heights[0], heights[1], heights[2], heights[3]) role, longitude, latitude, got, want, heights[0], heights[1], heights[2], heights[3])
@@ -90,6 +100,38 @@ func TestLunarGeoJSONPenumbraBandsFollowUmbralContacts(t *testing.T) {
} }
} }
func decodeMultiPolygonContains(t *testing.T, geometry struct {
Type string `json:"type"`
Coordinates json.RawMessage `json:"coordinates"`
Geometries json.RawMessage `json:"geometries"`
}) func(float64, float64) bool {
t.Helper()
switch geometry.Type {
case "MultiPolygon":
var polygons [][][][]float64
if err := json.Unmarshal(geometry.Coordinates, &polygons); err != nil {
t.Fatalf("decode MultiPolygon coordinates: %v", err)
}
return func(longitude, latitude float64) bool {
return geoJSONMultiPolygonContains(polygons, longitude, latitude)
}
case "Polygon":
var polygon [][][]float64
if err := json.Unmarshal(geometry.Coordinates, &polygon); err != nil {
t.Fatalf("decode Polygon coordinates: %v", err)
}
if len(polygon) == 0 {
return func(float64, float64) bool { return false }
}
return func(longitude, latitude float64) bool {
return geoJSONRingContains(polygon[0], longitude, latitude)
}
default:
t.Fatalf("geometry type=%q, want Polygon or MultiPolygon", geometry.Type)
}
return nil
}
// 两条带必须与本地可见性类别的细分一致,且可被 SkipRoles 关掉;纯半影月食没有本影接触,不画带。 // 两条带必须与本地可见性类别的细分一致,且可被 SkipRoles 关掉;纯半影月食没有本影接触,不画带。
func TestLunarGeoJSONPenumbraBandsMatchVisibilityClasses(t *testing.T) { func TestLunarGeoJSONPenumbraBandsMatchVisibilityClasses(t *testing.T) {
date := time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)) date := time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
@@ -14,6 +14,9 @@ func TestOccultationDistantEnvelopesRemainComplete(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("slow regression") t.Skip("slow regression")
} }
if go116386() {
t.Skip("Go 1.16/386 skips the heavy integration regressions; Go 1.17+ covers them")
}
if err := basic.LoadStarData(); err != nil { if err := basic.LoadStarData(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -9,6 +9,7 @@ import (
) )
func TestMarshalPlanetOccultationVenus20250919OptimizedContoursAreStrictlyOrdered(t *testing.T) { func TestMarshalPlanetOccultationVenus20250919OptimizedContoursAreStrictlyOrdered(t *testing.T) {
skipHeavyOnGo116386(t)
day := time.Date(2025, 9, 19, 0, 0, 0, 0, time.UTC) day := time.Date(2025, 9, 19, 0, 0, 0, 0, time.UTC)
paths, err := moon.FindPlanetOccultationPaths( paths, err := moon.FindPlanetOccultationPaths(
day.Add(-12*time.Hour), day.Add(36*time.Hour), moon.OccultationVenus, day.Add(-12*time.Hour), day.Add(36*time.Hour), moon.OccultationVenus,
+16
View File
@@ -19,6 +19,7 @@ import (
// intentionally smaller than the opt-in millennium diagnostic while still // intentionally smaller than the opt-in millennium diagnostic while still
// exercising antimeridian, polar and non-central topology in normal tests. // exercising antimeridian, polar and non-central topology in normal tests.
func TestSolarEclipseP2SarosGeoJSONSamples(t *testing.T) { func TestSolarEclipseP2SarosGeoJSONSamples(t *testing.T) {
skipHeavyOnGo116386(t)
const sarosDays = 6585.321314 const sarosDays = 6585.321314
seed := basic.JDCalc(2024, 4, 8) seed := basic.JDCalc(2024, 4, 8)
for _, familyIndex := range []int{-28, -21, -14, -7, 0, 7, 14, 21, 28} { for _, familyIndex := range []int{-28, -21, -14, -7, 0, 7, 14, 21, 28} {
@@ -54,6 +55,7 @@ func TestSolarEclipseP2SarosGeoJSONSamples(t *testing.T) {
} }
func TestOccultationP2RepresentativeGeoJSONPathsRemainClosed(t *testing.T) { func TestOccultationP2RepresentativeGeoJSONPathsRemainClosed(t *testing.T) {
skipHeavyOnGo116386(t)
zone := time.FixedZone("UTC+8", 8*60*60) zone := time.FixedZone("UTC+8", 8*60*60)
star := moon.StarCoordinate{ star := moon.StarCoordinate{
ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444, ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444,
@@ -122,6 +124,7 @@ func TestOccultationP2RepresentativeGeoJSONPathsRemainClosed(t *testing.T) {
} }
func TestOccultationP2Venus20250919HasNoInternalOutline(t *testing.T) { func TestOccultationP2Venus20250919HasNoInternalOutline(t *testing.T) {
skipHeavyOnGo116386(t)
start := time.Date(2025, time.September, 19, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*3600)) start := time.Date(2025, time.September, 19, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*3600))
for _, algorithm := range []moon.OccultationPathAlgorithm{moon.OccultationPathAlgorithmOptimized, moon.OccultationPathAlgorithmExact} { for _, algorithm := range []moon.OccultationPathAlgorithm{moon.OccultationPathAlgorithmOptimized, moon.OccultationPathAlgorithmExact} {
t.Run(string(algorithm), func(t *testing.T) { t.Run(string(algorithm), func(t *testing.T) {
@@ -175,6 +178,7 @@ func TestOccultationP2Venus20250919HasNoInternalOutline(t *testing.T) {
} }
func TestOccultationP2Mars20250114GeoJSONAcceptsFoldedBandContours(t *testing.T) { func TestOccultationP2Mars20250114GeoJSONAcceptsFoldedBandContours(t *testing.T) {
skipHeavyOnGo116386(t)
zone := time.FixedZone("UTC+8", 8*60*60) zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2025, time.January, 14, 0, 0, 0, 0, zone) start := time.Date(2025, time.January, 14, 0, 0, 0, 0, zone)
paths, err := moon.FindPlanetOccultationPaths( paths, err := moon.FindPlanetOccultationPaths(
@@ -200,6 +204,7 @@ func TestOccultationP2Mars20250114GeoJSONAcceptsFoldedBandContours(t *testing.T)
} }
func TestOccultationP2Mars20250729AddsMoonriseHorizonConnector(t *testing.T) { func TestOccultationP2Mars20250729AddsMoonriseHorizonConnector(t *testing.T) {
skipHeavyOnGo116386(t)
collection := mars20250729TestFixture(t, 5*time.Minute, true).collection collection := mars20250729TestFixture(t, 5*time.Minute, true).collection
assertOccultationHorizonConnectorsAreAuxiliary(t, collection) assertOccultationHorizonConnectorsAreAuxiliary(t, collection)
connector := horizonConnectorFeature(t, collection, "rise") connector := horizonConnectorFeature(t, collection, "rise")
@@ -247,6 +252,7 @@ func TestOccultationP2Mars20250729AddsMoonriseHorizonConnector(t *testing.T) {
} }
func TestOccultationP2Mars20250729OneMinuteRiseSetAddsOpeningConnector(t *testing.T) { func TestOccultationP2Mars20250729OneMinuteRiseSetAddsOpeningConnector(t *testing.T) {
skipHeavyOnGo116386(t)
collection := mars20250729TestFixture(t, time.Minute, true).collection collection := mars20250729TestFixture(t, time.Minute, true).collection
connector := horizonConnectorFeature(t, collection, "rise") connector := horizonConnectorFeature(t, collection, "rise")
var lines [][][]float64 var lines [][][]float64
@@ -294,6 +300,7 @@ func TestOccultationP2Mars20250729OneMinuteRiseSetAddsOpeningConnector(t *testin
} }
func TestOccultationP2Mars20250729DoesNotDuplicateSamePhaseFinalFold(t *testing.T) { func TestOccultationP2Mars20250729DoesNotDuplicateSamePhaseFinalFold(t *testing.T) {
skipHeavyOnGo116386(t)
collection := mars20250729TestFixture(t, 5*time.Minute, true).collection collection := mars20250729TestFixture(t, 5*time.Minute, true).collection
connector := horizonConnectorFeature(t, collection, "rise") connector := horizonConnectorFeature(t, collection, "rise")
var lines [][][]float64 var lines [][][]float64
@@ -307,6 +314,7 @@ func TestOccultationP2Mars20250729DoesNotDuplicateSamePhaseFinalFold(t *testing.
} }
func TestOccultationP2Mars20250729StaticBandsStayAuthoritativeAndBounded(t *testing.T) { func TestOccultationP2Mars20250729StaticBandsStayAuthoritativeAndBounded(t *testing.T) {
skipHeavyOnGo116386(t)
collection := mars20250729TestFixture(t, time.Minute, true).collection collection := mars20250729TestFixture(t, time.Minute, true).collection
for _, role := range []string{"partial-band", "total-band"} { for _, role := range []string{"partial-band", "total-band"} {
band := featureWithRole(t, collection, role) band := featureWithRole(t, collection, role)
@@ -347,6 +355,7 @@ func mustMarshalPlanetOccultation(t *testing.T, path moon.PlanetOccultationPath)
} }
func TestOccultationP2Mars20250729DrawsPhaseCurvesAboveStaticBandOutlines(t *testing.T) { func TestOccultationP2Mars20250729DrawsPhaseCurvesAboveStaticBandOutlines(t *testing.T) {
skipHeavyOnGo116386(t)
collection := mars20250729TestFixture(t, time.Minute, true).collection collection := mars20250729TestFixture(t, time.Minute, true).collection
lastStaticOutline := -1 lastStaticOutline := -1
firstPhaseCurve := len(collection.Features) firstPhaseCurve := len(collection.Features)
@@ -375,6 +384,7 @@ func TestOccultationP2Mars20250729DrawsPhaseCurvesAboveStaticBandOutlines(t *tes
} }
func TestOccultationP2Mars20250729TotalBandUsesInnerRiseSetCurves(t *testing.T) { func TestOccultationP2Mars20250729TotalBandUsesInnerRiseSetCurves(t *testing.T) {
skipHeavyOnGo116386(t)
fixture := mars20250729TestFixture(t, time.Minute, true) fixture := mars20250729TestFixture(t, time.Minute, true)
path := fixture.path path := fixture.path
if !path.HasTotalBand { if !path.HasTotalBand {
@@ -394,6 +404,7 @@ func TestOccultationP2Mars20250729TotalBandUsesInnerRiseSetCurves(t *testing.T)
} }
func TestOccultationP2Mars20250729ExportsOnlyOuterContactPhaseBoundaries(t *testing.T) { func TestOccultationP2Mars20250729ExportsOnlyOuterContactPhaseBoundaries(t *testing.T) {
skipHeavyOnGo116386(t)
fixture := mars20250729TestFixture(t, time.Minute, true) fixture := mars20250729TestFixture(t, time.Minute, true)
path, collection := fixture.path, fixture.collection path, collection := fixture.path, fixture.collection
for _, feature := range featuresWithRole(collection, "visibility-boundary") { for _, feature := range featuresWithRole(collection, "visibility-boundary") {
@@ -408,6 +419,7 @@ func TestOccultationP2Mars20250729ExportsOnlyOuterContactPhaseBoundaries(t *test
} }
func TestOccultationP2Mars20250729StaticBandRemainsContinuousAndContained(t *testing.T) { func TestOccultationP2Mars20250729StaticBandRemainsContinuousAndContained(t *testing.T) {
skipHeavyOnGo116386(t)
fixture := mars20250729TestFixture(t, time.Minute, true) fixture := mars20250729TestFixture(t, time.Minute, true)
path, collection := fixture.path, fixture.collection path, collection := fixture.path, fixture.collection
assertOccultationP2StaticBand(t, path, collection) assertOccultationP2StaticBand(t, path, collection)
@@ -415,6 +427,7 @@ func TestOccultationP2Mars20250729StaticBandRemainsContinuousAndContained(t *tes
} }
func TestOccultationP2Saturn20240725StaticBandRemainsContinuousAndContained(t *testing.T) { func TestOccultationP2Saturn20240725StaticBandRemainsContinuousAndContained(t *testing.T) {
skipHeavyOnGo116386(t)
zone := time.FixedZone("UTC+8", 8*60*60) zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone) start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone)
paths, err := moon.FindPlanetOccultationPaths( paths, err := moon.FindPlanetOccultationPaths(
@@ -438,6 +451,7 @@ func TestOccultationP2Saturn20240725StaticBandRemainsContinuousAndContained(t *t
} }
func TestOccultationP0Mars20250729StaticBandsUseContactContours(t *testing.T) { func TestOccultationP0Mars20250729StaticBandsUseContactContours(t *testing.T) {
skipHeavyOnGo116386(t)
path := mars20250729TestFixture(t, time.Minute, true).path path := mars20250729TestFixture(t, time.Minute, true).path
if len(path.PartialBandContours) < 2 { if len(path.PartialBandContours) < 2 {
t.Fatalf("partial contact contours=%d, want north/south continuous envelopes", len(path.PartialBandContours)) t.Fatalf("partial contact contours=%d, want north/south continuous envelopes", len(path.PartialBandContours))
@@ -492,6 +506,7 @@ func TestOccultationP0Mars20250729StaticBandsUseContactContours(t *testing.T) {
} }
func TestOccultationP2Mars20250729EndRiseExportsRawPhaseSegments(t *testing.T) { func TestOccultationP2Mars20250729EndRiseExportsRawPhaseSegments(t *testing.T) {
skipHeavyOnGo116386(t)
fixture := mars20250729TestFixture(t, 5*time.Minute, true) fixture := mars20250729TestFixture(t, 5*time.Minute, true)
path, collection := fixture.path, fixture.collection path, collection := fixture.path, fixture.collection
endRise := riseSetBoundaryFeature(t, collection, "end", "rise") endRise := riseSetBoundaryFeature(t, collection, "end", "rise")
@@ -901,6 +916,7 @@ func formatP2Magnitude(value int) string {
// this catches accidental changes that make a GeoJSON payload un-decodable // this catches accidental changes that make a GeoJSON payload un-decodable
// even when the role-level checks still pass. // even when the role-level checks still pass.
func TestP2GeoJSONPayloadsDecodeAsFeatureCollections(t *testing.T) { func TestP2GeoJSONPayloadsDecodeAsFeatureCollections(t *testing.T) {
skipHeavyOnGo116386(t)
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC) date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
Step: 30 * time.Minute, BoundaryPoints: 36, DisableRiseSet: true, Step: 30 * time.Minute, BoundaryPoints: 36, DisableRiseSet: true,
@@ -54,6 +54,9 @@ func TestSolarEclipseGrazingCentralBandCoversUmbralSweep(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("slow regression") t.Skip("slow regression")
} }
if go116386() {
t.Skip("Go 1.16/386 skips the heavy integration regressions; Go 1.17+ covers them")
}
for _, testCase := range grazingBandCases { for _, testCase := range grazingBandCases {
t.Run(testCase.date+"-"+testCase.options, func(t *testing.T) { t.Run(testCase.date+"-"+testCase.options, func(t *testing.T) {
date := grazingBandDate(t, testCase.date) date := grazingBandDate(t, testCase.date)
@@ -15,6 +15,9 @@ func TestMarshalSolarEclipsePolarTwoLimitBandUsesSimpleFaces(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("slow regression") t.Skip("slow regression")
} }
if go116386() {
t.Skip("Go 1.16/386 skips the heavy integration regressions; Go 1.17+ covers them")
}
for _, sample := range []struct { for _, sample := range []struct {
date time.Time date time.Time
centralStep time.Duration centralStep time.Duration
+7 -2
View File
@@ -74,9 +74,14 @@ func TestLowPrecisionSunMoonBaselineRegression(t *testing.T) {
assertBits := func(t *testing.T, name, utc string, got float64, want uint64) { assertBits := func(t *testing.T, name, utc string, got float64, want uint64) {
t.Helper() t.Helper()
if math.Float64bits(got) != want { if math.Float64bits(got) == want {
t.Fatalf("%s regression at %s", name, utc) return
} }
// Go 1.16/386 的超越函数可能差 1 ULP;该组合按 1e-12 度放行。
if go116386() && math.Abs(got-math.Float64frombits(want)) <= 1e-12 {
return
}
t.Fatalf("%s regression at %s", name, utc)
} }
for _, sample := range samples { for _, sample := range samples {
+8 -2
View File
@@ -5,6 +5,12 @@ import "b612.me/astro/basic"
// StarData 内嵌 9100 星表中的一条记录 / one entry from the embedded 9100-star catalog. // StarData 内嵌 9100 星表中的一条记录 / one entry from the embedded 9100-star catalog.
type StarData = basic.StarData type StarData = basic.StarData
// ErrStarNotFound 未找到匹配的恒星 / no matching star.
var ErrStarNotFound = basic.ErrStarNotFound
// ErrStarNameAmbiguous 星名对应多颗恒星 / the star name matches more than one star.
var ErrStarNameAmbiguous = basic.ErrStarNameAmbiguous
// StarCoordinateFromStarData 将星表记录转换为恒星坐标 / converts a catalog entry to a star coordinate. // StarCoordinateFromStarData 将星表记录转换为恒星坐标 / converts a catalog entry to a star coordinate.
func StarCoordinateFromStarData(star StarData) (StarCoordinate, error) { func StarCoordinateFromStarData(star StarData) (StarCoordinate, error) {
return basic.StarCoordinateFromStarData(star) return basic.StarCoordinateFromStarData(star)
@@ -19,9 +25,9 @@ func StarCoordinateByHR(hr int) (StarCoordinate, error) {
return StarCoordinateFromStarData(star) return StarCoordinateFromStarData(star)
} }
// StarCoordinateByName 按内嵌星表中文名得到恒星坐标 / returns a star coordinate by Chinese catalog name. // StarCoordinateByName 按内嵌星表中文名、别名或英文常用名得到唯一恒星坐标;同名多星返回 ErrStarNameAmbiguous / returns the unique star coordinate by Chinese name, alias or English common name, or ErrStarNameAmbiguous for duplicate names.
func StarCoordinateByName(name string) (StarCoordinate, error) { func StarCoordinateByName(name string) (StarCoordinate, error) {
star, err := basic.StarDataByChinese(name) star, err := basic.StarDataByName(name)
if err != nil { if err != nil {
return StarCoordinate{}, err return StarCoordinate{}, err
} }
+13 -2
View File
@@ -1,6 +1,7 @@
package phenomena package phenomena
import ( import (
"errors"
"testing" "testing"
"b612.me/astro/basic" "b612.me/astro/basic"
@@ -18,6 +19,13 @@ func TestStarCoordinateLookup(t *testing.T) {
if byHR != byName { if byHR != byName {
t.Fatalf("lookup mismatch: HR=%+v name=%+v", byHR, byName) t.Fatalf("lookup mismatch: HR=%+v name=%+v", byHR, byName)
} }
byEnglish, err := StarCoordinateByName("sirius")
if err != nil {
t.Fatalf("StarCoordinateByName(sirius) error = %v", err)
}
if byHR != byEnglish {
t.Fatalf("English lookup mismatch: HR=%+v name=%+v", byHR, byEnglish)
}
data, err := basic.StarDataByHR(2491) data, err := basic.StarDataByHR(2491)
if err != nil { if err != nil {
@@ -36,8 +44,11 @@ func TestStarCoordinateLookupInvalid(t *testing.T) {
if _, err := StarCoordinateByHR(0); err == nil { if _, err := StarCoordinateByHR(0); err == nil {
t.Fatal("StarCoordinateByHR(0) expected an error") t.Fatal("StarCoordinateByHR(0) expected an error")
} }
if _, err := StarCoordinateByName("不存在的恒星"); err == nil { if _, err := StarCoordinateByName("不存在的恒星"); !errors.Is(err, ErrStarNotFound) {
t.Fatal("StarCoordinateByName() expected an error") t.Fatalf("StarCoordinateByName(不存在的恒星) error = %v, want ErrStarNotFound", err)
}
if _, err := StarCoordinateByName("杵增一"); !errors.Is(err, ErrStarNameAmbiguous) {
t.Fatalf("StarCoordinateByName(杵增一) error = %v, want ErrStarNameAmbiguous", err)
} }
if _, err := StarCoordinateFromStarData(StarData{}); err == nil { if _, err := StarCoordinateFromStarData(StarData{}); err == nil {
t.Fatal("StarCoordinateFromStarData() expected an error") t.Fatal("StarCoordinateFromStarData() expected an error")
+18 -2
View File
@@ -175,9 +175,25 @@ func StarDataByHR(hr int) (basic.StarData, error) {
return basic.StarDataByHR(hr) return basic.StarDataByHR(hr)
} }
// StarDataByName 按中文名查询恒星数据 / returns star data by Chinese name. // ErrStarNotFound 未找到匹配的恒星 / no matching star.
var ErrStarNotFound = basic.ErrStarNotFound
// ErrStarNameAmbiguous 星名对应多颗恒星 / the star name matches more than one star.
var ErrStarNameAmbiguous = basic.ErrStarNameAmbiguous
// StarDataByName 按中文名、中文别名或英文常用名查唯一恒星;同名多星返回 ErrStarNameAmbiguous / returns the unique star by Chinese name, alias or English common name, or ErrStarNameAmbiguous for duplicate names.
func StarDataByName(name string) (basic.StarData, error) { func StarDataByName(name string) (basic.StarData, error) {
return basic.StarDataByChinese(name) return basic.StarDataByName(name)
}
// StarDataByNameAll 返回中文名、别名或英文常用名的全部候选,按 HR 升序 / all candidates by HR order.
func StarDataByNameAll(name string) ([]basic.StarData, error) {
return basic.StarDataByNameAll(name)
}
// StarDataByNameIn 按中文名、别名或英文常用名与宿/垣限定查询唯一恒星 / returns the unique star within a mansion or enclosure.
func StarDataByNameIn(name, region string) (basic.StarData, error) {
return basic.StarDataByNameIn(name, region)
} }
// TopBrightStars 明亮恒星样本 / bright-star sample list. // TopBrightStars 明亮恒星样本 / bright-star sample list.
+66
View File
@@ -0,0 +1,66 @@
package star
import (
"errors"
"testing"
)
func TestStarDataByNameAmbiguity(t *testing.T) {
if err := InitStarDatabase(); err != nil {
t.Fatal(err)
}
if _, err := StarDataByName("杵增一"); !errors.Is(err, ErrStarNameAmbiguous) {
t.Fatalf("StarDataByName(杵增一) error = %v, want ErrStarNameAmbiguous", err)
}
if _, err := StarDataByName("不存在的星名"); !errors.Is(err, ErrStarNotFound) {
t.Fatalf("StarDataByName(不存在的星名) error = %v, want ErrStarNotFound", err)
}
if _, err := StarDataByNameIn("杵增一", ""); !errors.Is(err, ErrStarNameAmbiguous) {
t.Fatalf("StarDataByNameIn(杵增一, \"\") error = %v, want ErrStarNameAmbiguous", err)
}
matches, err := StarDataByNameAll("杵增一")
if err != nil {
t.Fatal(err)
}
if len(matches) != 2 || matches[0].HR != 6743 || matches[1].HR != 8449 {
t.Fatalf("StarDataByNameAll(杵增一) = %v, want HR 6743 and 8449", matches)
}
tests := []struct {
name string
region string
hr uint16
}{
{"杵增一", "箕宿", 6743},
{"杵增一", "危宿", 8449},
{"杵一", "箕宿", 6537},
{"杵一", "危宿", 8498},
}
for _, tc := range tests {
got, err := StarDataByNameIn(tc.name, tc.region)
if err != nil {
t.Fatalf("StarDataByNameIn(%q, %q): %v", tc.name, tc.region, err)
}
if got.HR != tc.hr || got.ChineseRegion != tc.region {
t.Fatalf("StarDataByNameIn(%q, %q) = HR %d region %q, want HR %d region %q",
tc.name, tc.region, got.HR, got.ChineseRegion, tc.hr, tc.region)
}
}
english, err := StarDataByName("sirius")
if err != nil {
t.Fatal(err)
}
if english.HR != 2491 {
t.Fatalf("StarDataByName(sirius) = HR %d, want 2491", english.HR)
}
if _, err := StarDataByName("Gienah"); !errors.Is(err, ErrStarNameAmbiguous) {
t.Fatalf("StarDataByName(Gienah) error = %v, want ErrStarNameAmbiguous", err)
}
got, err := StarDataByName("杵增一[危宿]")
if err != nil {
t.Fatal(err)
}
if got.HR != 8449 {
t.Fatalf("StarDataByName(杵增一[危宿]) = HR %d, want 8449", got.HR)
}
}