feat: 扩展天文计算能力
- 新增日食、月食、本地可见性、中心线、半影区域、SVG 图示与沙罗周期信息 - 新增行星冲合、留、方照、物理星历、视直径、相位、亮肢角、轨道节点等计算 - 新增木星伽利略卫星位置、现象与接触事件计算 - 新增恒星星表、星座判定、自行修正与观测辅助能力 - 新增 coord、formula、orbit、sundial、lite/sun、lite/moon 等扩展包 - 完善农历年号、月相英文别名、视差角、大气质量、折射、日晷与双星计算 - 增加 NASA、JPL Horizons、IMCCE 等回归测试数据与基线测试 - 重构基础算法文件组织,补充大量公开 API 注释和语义回归测试 - 更新中文和英文 README,补充示例、精度说明、SVG 配图
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user