astro/eclipse/solar_test.go
starainrt 3ffdbe0034
feat: 扩展天文计算能力
- 新增日食、月食、本地可见性、中心线、半影区域、SVG 图示与沙罗周期信息
- 新增行星冲合、留、方照、物理星历、视直径、相位、亮肢角、轨道节点等计算
- 新增木星伽利略卫星位置、现象与接触事件计算
- 新增恒星星表、星座判定、自行修正与观测辅助能力
- 新增 coord、formula、orbit、sundial、lite/sun、lite/moon 等扩展包
- 完善农历年号、月相英文别名、视差角、大气质量、折射、日晷与双星计算
- 增加 NASA、JPL Horizons、IMCCE 等回归测试数据与基线测试
- 重构基础算法文件组织,补充大量公开 API 注释和语义回归测试
- 更新中文和英文 README,补充示例、精度说明、SVG 配图
2026-05-01 22:38:44 +08:00

305 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package eclipse
import (
"math"
"testing"
"time"
)
func TestSolarEclipseLocalDayBoundsRespectDST(t *testing.T) {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
t.Skipf("tzdata unavailable: %v", err)
}
testCases := []struct {
name string
date time.Time
wantDuration time.Duration
}{
{
name: "spring forward 2025-03-09",
date: time.Date(2025, 3, 9, 8, 0, 0, 0, loc),
wantDuration: 23 * time.Hour,
},
{
name: "fall back 2025-11-02",
date: time.Date(2025, 11, 2, 8, 0, 0, 0, loc),
wantDuration: 25 * time.Hour,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
dayStart, dayMid, dayEnd := solarEclipseLocalDayBounds(tc.date)
if dayStart.Hour() != 0 || dayStart.Minute() != 0 || dayStart.Second() != 0 {
t.Fatalf("dayStart should be local midnight, got %v", dayStart)
}
if dayMid.Hour() != 12 || dayMid.Minute() != 0 || dayMid.Second() != 0 {
t.Fatalf("dayMid should be local noon, got %v", dayMid)
}
if dayEnd.Hour() != 0 || dayEnd.Minute() != 0 || dayEnd.Second() != 0 {
t.Fatalf("dayEnd should be next local midnight, got %v", dayEnd)
}
if got := dayEnd.Sub(dayStart); got != tc.wantDuration {
t.Fatalf("day length mismatch: got %v want %v", got, tc.wantDuration)
}
})
}
}
func TestSolarEclipseOnDateByLocalDay(t *testing.T) {
loc := time.FixedZone("UTC+14", 14*3600)
testCases := []struct {
name string
date time.Time
want bool
}{
{
name: "day before no eclipse",
date: time.Date(2024, 4, 8, 12, 0, 0, 0, loc),
want: false,
},
{
name: "local event day overlaps",
date: time.Date(2024, 4, 9, 12, 0, 0, 0, loc),
want: true,
},
{
name: "day after no eclipse",
date: time.Date(2024, 4, 10, 12, 0, 0, 0, loc),
want: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
info, ok := SolarEclipseOnDate(tc.date)
if ok != tc.want {
t.Fatalf("SolarEclipseOnDate(%v) got %v want %v", tc.date, ok, tc.want)
}
if !ok {
return
}
if info.Type != SolarEclipseTotal {
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, SolarEclipseTotal)
}
if info.GreatestEclipse.Location() != loc {
t.Fatalf("greatest eclipse location mismatch: got %q want %q", info.GreatestEclipse.Location(), loc)
}
if info.PartialBeginOnEarth.Day() != 9 || info.PartialEndOnEarth.Day() != 9 {
t.Fatalf("unexpected local date span: begin=%v end=%v", info.PartialBeginOnEarth, info.PartialEndOnEarth)
}
})
}
}
func TestSolarEclipseSearchSemantics(t *testing.T) {
loc := time.FixedZone("CST", 8*3600)
current := ClosestSolarEclipseNASABulletinSplitK(time.Date(2024, 4, 8, 12, 0, 0, 0, loc))
if current.Type != SolarEclipseTotal {
t.Fatalf("unexpected current eclipse type: got %s want %s", current.Type, SolarEclipseTotal)
}
assertSameSolarEclipse(t, "ClosestSolarEclipse(default)", ClosestSolarEclipse(current.GreatestEclipse), current, time.Second)
last := LastSolarEclipseNASABulletinSplitK(current.GreatestEclipse)
assertSameSolarEclipse(t, "LastSolarEclipseNASABulletinSplitK(current.GreatestEclipse)", last, current, time.Second)
closest := ClosestSolarEclipseNASABulletinSplitK(current.GreatestEclipse)
assertSameSolarEclipse(t, "ClosestSolarEclipseNASABulletinSplitK(current.GreatestEclipse)", closest, current, time.Second)
next := NextSolarEclipseNASABulletinSplitK(current.GreatestEclipse)
if !next.GreatestEclipse.After(current.GreatestEclipse) {
t.Fatalf("NextSolarEclipseNASABulletinSplitK should be strictly future: current=%v next=%v", current.GreatestEclipse, next.GreatestEclipse)
}
if next.Type != SolarEclipseAnnular {
t.Fatalf("unexpected next eclipse type: got %s want %s", next.Type, SolarEclipseAnnular)
}
wantNext := ClosestSolarEclipseNASABulletinSplitK(time.Date(2024, 10, 2, 12, 0, 0, 0, loc))
assertSameSolarEclipse(t, "NextSolarEclipseNASABulletinSplitK(current.GreatestEclipse)", next, wantNext, time.Second)
}
func TestSolarEclipseInfoKeepsLocation(t *testing.T) {
loc := time.FixedZone("UTC+08", 8*3600)
testCases := []struct {
name string
calc func(time.Time) SolarEclipseInfo
}{
{name: "nasa", calc: ClosestSolarEclipseNASABulletinSplitK},
{name: "iau", calc: ClosestSolarEclipseIAUSingleK},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
info := tc.calc(time.Date(2024, 10, 2, 12, 0, 0, 0, loc))
if info.Type != SolarEclipseAnnular {
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, SolarEclipseAnnular)
}
for _, item := range []struct {
name string
tm time.Time
}{
{name: "GreatestEclipse", tm: info.GreatestEclipse},
{name: "PartialBeginOnEarth", tm: info.PartialBeginOnEarth},
{name: "PartialEndOnEarth", tm: info.PartialEndOnEarth},
{name: "CentralBeginOnEarth", tm: info.CentralBeginOnEarth},
{name: "CentralEndOnEarth", tm: info.CentralEndOnEarth},
} {
if item.tm.Location() != loc {
t.Fatalf("%s location mismatch: got %q want %q", item.name, item.tm.Location(), loc)
}
}
})
}
}
func TestSolarEclipseIAUSingleKRemainsAvailable(t *testing.T) {
date := time.Date(2024, 4, 8, 12, 0, 0, 0, time.UTC)
defaultInfo := ClosestSolarEclipse(date)
iauInfo := ClosestSolarEclipseIAUSingleK(date)
if defaultInfo.Type != SolarEclipseTotal || iauInfo.Type != SolarEclipseTotal {
t.Fatalf("unexpected eclipse types: default=%s iau=%s", defaultInfo.Type, iauInfo.Type)
}
assertSolarTimeClose(t, "GreatestEclipse", iauInfo.GreatestEclipse, defaultInfo.GreatestEclipse, time.Second)
if !(iauInfo.PathWidthKM > defaultInfo.PathWidthKM) {
t.Fatalf("expected IAU path width > NASA path width: iau=%.6f nasa=%.6f", iauInfo.PathWidthKM, defaultInfo.PathWidthKM)
}
if !(iauInfo.Magnitude > defaultInfo.Magnitude) {
t.Fatalf("expected IAU magnitude > NASA magnitude: iau=%.9f nasa=%.9f", iauInfo.Magnitude, defaultInfo.Magnitude)
}
}
func TestSolarEclipseAgainstNASAUTBaseline(t *testing.T) {
testCases := []struct {
name string
date time.Time
wantType SolarEclipseType
wantGreatest time.Time
wantGamma float64
wantMagnitude float64
wantLongitude float64
wantLatitude float64
wantPathWidthKM float64
wantCentrality SolarEclipseCentrality
}{
{
name: "2023-04-20 hybrid",
date: time.Date(2023, 4, 20, 0, 0, 0, 0, time.UTC),
wantType: SolarEclipseHybrid,
wantGreatest: time.Date(2023, 4, 20, 4, 16, 43, 0, time.UTC),
wantGamma: -0.3952,
wantMagnitude: 1.0132,
wantLongitude: 125.8,
wantLatitude: -9.6,
wantPathWidthKM: 49.0,
wantCentrality: SolarEclipseCentralTwoLimits,
},
{
name: "2024-04-08 total",
date: time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC),
wantType: SolarEclipseTotal,
wantGreatest: time.Date(2024, 4, 8, 18, 17, 15, 0, time.UTC),
wantGamma: 0.3431,
wantMagnitude: 1.0566,
wantLongitude: -104.1,
wantLatitude: 25.3,
wantPathWidthKM: 197.5,
wantCentrality: SolarEclipseCentralTwoLimits,
},
{
name: "2024-10-02 annular",
date: time.Date(2024, 10, 2, 0, 0, 0, 0, time.UTC),
wantType: SolarEclipseAnnular,
wantGreatest: time.Date(2024, 10, 2, 18, 44, 59, 0, time.UTC),
wantGamma: -0.3509,
wantMagnitude: 0.9326,
wantLongitude: -114.5,
wantLatitude: -22.0,
wantPathWidthKM: 266.5,
wantCentrality: SolarEclipseCentralTwoLimits,
},
{
name: "2025-03-29 partial",
date: time.Date(2025, 3, 29, 0, 0, 0, 0, time.UTC),
wantType: SolarEclipsePartial,
wantGreatest: time.Date(2025, 3, 29, 10, 47, 21, 0, time.UTC),
wantGamma: 1.0405,
wantMagnitude: 0.9376,
wantLongitude: -77.1,
wantLatitude: 61.1,
wantPathWidthKM: 0.0,
wantCentrality: SolarEclipseNonCentral,
},
}
const (
// 这里对 UT 使用稍宽容差,因为 `sun` 层会把 TT 转成 UT
// 与 NASA 页面公开的 ΔT 口径存在数秒级差异;几何本身已在 basic 层用 TT 锁定。
timeTolerance = 8 * time.Second
gammaTolerance = 5e-4
magnitudeTolerance = 5e-4
coordinateTolerance = 0.1
pathWidthTolerance = 5.0
)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assertSameSolarEclipse(
t,
"ClosestSolarEclipse(default)",
ClosestSolarEclipse(tc.date),
ClosestSolarEclipseNASABulletinSplitK(tc.date),
time.Second,
)
info := ClosestSolarEclipse(tc.date)
if info.Type != tc.wantType {
t.Fatalf("type mismatch: got %s want %s", info.Type, tc.wantType)
}
if info.Centrality != tc.wantCentrality {
t.Fatalf("centrality mismatch: got %s want %s", info.Centrality, tc.wantCentrality)
}
assertSolarTimeClose(t, "GreatestEclipse", info.GreatestEclipse, tc.wantGreatest, timeTolerance)
assertSolarFloatClose(t, "Gamma", info.Gamma, tc.wantGamma, gammaTolerance)
assertSolarFloatClose(t, "Magnitude", info.Magnitude, tc.wantMagnitude, magnitudeTolerance)
assertSolarFloatClose(t, "GreatestLongitude", info.GreatestLongitude, tc.wantLongitude, coordinateTolerance)
assertSolarFloatClose(t, "GreatestLatitude", info.GreatestLatitude, tc.wantLatitude, coordinateTolerance)
assertSolarFloatClose(t, "PathWidthKM", info.PathWidthKM, tc.wantPathWidthKM, pathWidthTolerance)
})
}
}
func assertSameSolarEclipse(t *testing.T, name string, got, want SolarEclipseInfo, tolerance time.Duration) {
t.Helper()
if got.Type != want.Type {
t.Fatalf("%s type mismatch: got %s want %s", name, got.Type, want.Type)
}
assertSolarTimeClose(t, name+".GreatestEclipse", got.GreatestEclipse, want.GreatestEclipse, tolerance)
}
func assertSolarTimeClose(t *testing.T, name string, got, want time.Time, tolerance time.Duration) {
t.Helper()
diff := got.Sub(want)
if diff < 0 {
diff = -diff
}
if diff > tolerance {
t.Fatalf("%s mismatch: got %v want %v diff=%v", name, got, want, diff)
}
}
func assertSolarFloatClose(t *testing.T, name string, got, want, tolerance float64) {
t.Helper()
if math.Abs(got-want) > tolerance {
t.Fatalf("%s mismatch: got %.6f want %.6f diff=%.6f", name, got, want, math.Abs(got-want))
}
}