- 新增日食、月食、本地可见性、中心线、半影区域、SVG 图示与沙罗周期信息 - 新增行星冲合、留、方照、物理星历、视直径、相位、亮肢角、轨道节点等计算 - 新增木星伽利略卫星位置、现象与接触事件计算 - 新增恒星星表、星座判定、自行修正与观测辅助能力 - 新增 coord、formula、orbit、sundial、lite/sun、lite/moon 等扩展包 - 完善农历年号、月相英文别名、视差角、大气质量、折射、日晷与双星计算 - 增加 NASA、JPL Horizons、IMCCE 等回归测试数据与基线测试 - 重构基础算法文件组织,补充大量公开 API 注释和语义回归测试 - 更新中文和英文 README,补充示例、精度说明、SVG 配图
346 lines
13 KiB
Go
346 lines
13 KiB
Go
package eclipse
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestLunarEclipseLocalDayBoundsRespectDST(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 := lunarEclipseLocalDayBounds(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 TestLunarEclipseOnDateByLocalDay(t *testing.T) {
|
|
loc := time.FixedZone("UTC-05", -5*3600)
|
|
|
|
testCases := []struct {
|
|
name string
|
|
date time.Time
|
|
want bool
|
|
}{
|
|
{
|
|
name: "day before no eclipse",
|
|
date: time.Date(2025, 3, 12, 12, 0, 0, 0, loc),
|
|
want: false,
|
|
},
|
|
{
|
|
name: "local start day overlaps",
|
|
date: time.Date(2025, 3, 13, 12, 0, 0, 0, loc),
|
|
want: true,
|
|
},
|
|
{
|
|
name: "local end day overlaps",
|
|
date: time.Date(2025, 3, 14, 12, 0, 0, 0, loc),
|
|
want: true,
|
|
},
|
|
{
|
|
name: "day after no eclipse",
|
|
date: time.Date(2025, 3, 15, 12, 0, 0, 0, loc),
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
info, ok := LunarEclipseOnDate(tc.date)
|
|
if ok != tc.want {
|
|
t.Fatalf("LunarEclipseOnDate(%v) got %v want %v", tc.date, ok, tc.want)
|
|
}
|
|
if !ok {
|
|
return
|
|
}
|
|
if info.Type != LunarEclipseTotal {
|
|
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, LunarEclipseTotal)
|
|
}
|
|
if info.Maximum.Location() != loc {
|
|
t.Fatalf("maximum location mismatch: got %q want %q", info.Maximum.Location(), loc)
|
|
}
|
|
if info.PenumbralStart.Day() != 13 || info.PenumbralEnd.Day() != 14 {
|
|
t.Fatalf("unexpected local date span: start=%v end=%v", info.PenumbralStart, info.PenumbralEnd)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLunarEclipseSearchSemantics(t *testing.T) {
|
|
loc := time.FixedZone("CST", 8*3600)
|
|
current := ClosestLunarEclipseDanjon(time.Date(2025, 3, 14, 12, 0, 0, 0, loc))
|
|
if current.Type != LunarEclipseTotal {
|
|
t.Fatalf("unexpected current eclipse type: got %s want %s", current.Type, LunarEclipseTotal)
|
|
}
|
|
assertSameEclipse(t, "ClosestLunarEclipse(default)", ClosestLunarEclipse(current.Maximum), current, time.Second)
|
|
|
|
last := LastLunarEclipseDanjon(current.Maximum)
|
|
assertSameEclipse(t, "LastLunarEclipseDanjon(current.Maximum)", last, current, time.Second)
|
|
|
|
closest := ClosestLunarEclipseDanjon(current.Maximum)
|
|
assertSameEclipse(t, "ClosestLunarEclipseDanjon(current.Maximum)", closest, current, time.Second)
|
|
|
|
next := NextLunarEclipseDanjon(current.Maximum)
|
|
if !next.Maximum.After(current.Maximum) {
|
|
t.Fatalf("NextLunarEclipseDanjon should be strictly future: current=%v next=%v", current.Maximum, next.Maximum)
|
|
}
|
|
if next.Type != LunarEclipseTotal {
|
|
t.Fatalf("unexpected next eclipse type: got %s want %s", next.Type, LunarEclipseTotal)
|
|
}
|
|
|
|
wantNextMax := time.Date(2025, 9, 8, 2, 12, 58, 0, loc)
|
|
assertTimeClose(t, "next.Maximum", next.Maximum, wantNextMax, 2*time.Minute)
|
|
}
|
|
|
|
func TestLunarEclipseInfoKeepsLocation(t *testing.T) {
|
|
loc := time.FixedZone("UTC+08", 8*3600)
|
|
testCases := []struct {
|
|
name string
|
|
calc func(time.Time) LunarEclipseInfo
|
|
}{
|
|
{name: "danjon", calc: ClosestLunarEclipseDanjon},
|
|
{name: "chauvenet", calc: ClosestLunarEclipseChauvenet},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
info := tc.calc(time.Date(2023, 10, 29, 12, 0, 0, 0, loc))
|
|
|
|
if info.Type != LunarEclipsePartial {
|
|
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, LunarEclipsePartial)
|
|
}
|
|
|
|
for _, item := range []struct {
|
|
name string
|
|
tm time.Time
|
|
}{
|
|
{name: "PenumbralStart", tm: info.PenumbralStart},
|
|
{name: "PartialStart", tm: info.PartialStart},
|
|
{name: "Maximum", tm: info.Maximum},
|
|
{name: "PartialEnd", tm: info.PartialEnd},
|
|
{name: "PenumbralEnd", tm: info.PenumbralEnd},
|
|
} {
|
|
if item.tm.Location() != loc {
|
|
t.Fatalf("%s location mismatch: got %q want %q", item.name, item.tm.Location(), loc)
|
|
}
|
|
}
|
|
for _, point := range info.ContactPoints {
|
|
if point.Time.Location() != loc {
|
|
t.Fatalf("contact %s location mismatch: got %q want %q", point.Label, point.Time.Location(), loc)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLunarEclipseContactPoints(t *testing.T) {
|
|
loc := time.FixedZone("UTC+08", 8*3600)
|
|
info := ClosestLunarEclipse(time.Date(2026, 3, 3, 12, 0, 0, 0, loc))
|
|
if info.Type != LunarEclipseTotal {
|
|
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, LunarEclipseTotal)
|
|
}
|
|
if got, want := len(info.ContactPoints), 6; got != want {
|
|
t.Fatalf("contact point count = %d, want %d", got, want)
|
|
}
|
|
|
|
points := make(map[string]LunarEclipseContactPoint, len(info.ContactPoints))
|
|
for _, point := range info.ContactPoints {
|
|
points[point.Label] = point
|
|
}
|
|
u1 := points["U1"]
|
|
assertFloatClose(t, "U1.ContactPositionAngle", u1.ContactPositionAngle, 96.181711, 1e-3)
|
|
assertFloatClose(t, "U1.ContactClockwiseAngle", u1.ContactClockwiseAngle, 263.818289, 1e-3)
|
|
u2 := points["U2"]
|
|
assertFloatClose(t, "U2.ContactPositionAngle", u2.ContactPositionAngle, 243.025171, 1e-3)
|
|
assertFloatClose(t, "U2.MoonCenterPositionAngle", u2.MoonCenterPositionAngle, 243.025171, 1e-3)
|
|
}
|
|
|
|
func TestLunarEclipseChauvenetRemainsAvailable(t *testing.T) {
|
|
date := time.Date(2025, 3, 14, 12, 0, 0, 0, time.UTC)
|
|
defaultInfo := ClosestLunarEclipse(date)
|
|
chauvenetInfo := ClosestLunarEclipseChauvenet(date)
|
|
|
|
assertFloatClose(t, "Chauvenet.PenumbralMagnitude", chauvenetInfo.PenumbralMagnitude, 2.285431290, 1e-6)
|
|
assertFloatClose(t, "Chauvenet.UmbralMagnitude", chauvenetInfo.UmbralMagnitude, 1.182811712, 1e-6)
|
|
|
|
if !(chauvenetInfo.PenumbralMagnitude > defaultInfo.PenumbralMagnitude) {
|
|
t.Fatalf("expected Chauvenet penumbral magnitude > Danjon: chauvenet=%.6f danjon=%.6f", chauvenetInfo.PenumbralMagnitude, defaultInfo.PenumbralMagnitude)
|
|
}
|
|
if !(chauvenetInfo.PenumbralStart.Before(defaultInfo.PenumbralStart) && chauvenetInfo.PenumbralEnd.After(defaultInfo.PenumbralEnd)) {
|
|
t.Fatalf("expected Chauvenet penumbral span to be wider: chauvenet=(%v,%v) danjon=(%v,%v)", chauvenetInfo.PenumbralStart, chauvenetInfo.PenumbralEnd, defaultInfo.PenumbralStart, defaultInfo.PenumbralEnd)
|
|
}
|
|
}
|
|
|
|
func TestLunarEclipseDefaultFallsBackForUltraShallowPenumbralEdge(t *testing.T) {
|
|
date := time.Date(-780, 12, 13, 12, 0, 0, 0, time.UTC)
|
|
|
|
defaultInfo := ClosestLunarEclipse(date)
|
|
danjonInfo := ClosestLunarEclipseDanjon(date)
|
|
chauvenetInfo := ClosestLunarEclipseChauvenet(date)
|
|
|
|
if defaultInfo.Type != LunarEclipsePenumbral {
|
|
t.Fatalf("default type mismatch: got %s want %s", defaultInfo.Type, LunarEclipsePenumbral)
|
|
}
|
|
if !defaultInfo.HasSaros || defaultInfo.Saros.Series != 61 || defaultInfo.Saros.Member != 1 || defaultInfo.Saros.Count != 78 {
|
|
t.Fatalf("default shallow Saros mismatch: got has=%v saros=%+v", defaultInfo.HasSaros, defaultInfo.Saros)
|
|
}
|
|
if danjonInfo.Maximum.Equal(defaultInfo.Maximum) {
|
|
t.Fatalf("default fallback should differ from explicit Danjon in this edge case: default=%v danjon=%v", defaultInfo.Maximum, danjonInfo.Maximum)
|
|
}
|
|
if !defaultInfo.Maximum.Equal(chauvenetInfo.Maximum) {
|
|
t.Fatalf("default fallback should reuse Chauvenet edge event timing: default=%v chauvenet=%v", defaultInfo.Maximum, chauvenetInfo.Maximum)
|
|
}
|
|
if !(defaultInfo.PenumbralMagnitude > 0 && defaultInfo.PenumbralMagnitude <= lunarEclipseDefaultFallbackMaxPenumbralMagnitude) {
|
|
t.Fatalf("default fallback penumbral magnitude out of narrow edge range: %.9f", defaultInfo.PenumbralMagnitude)
|
|
}
|
|
}
|
|
|
|
func TestLunarEclipseAgainstNASABaseline(t *testing.T) {
|
|
// NASA GSFC lunar eclipse catalog / plot pages:
|
|
// - 2023 Oct 28 partial: LE2023Oct28P.pdf
|
|
// - 2025 Mar 14 total: LE2025Mar14T.pdf
|
|
testCases := []struct {
|
|
name string
|
|
date time.Time
|
|
wantType LunarEclipseType
|
|
wantPenumbralMag float64
|
|
wantUmbralMag float64
|
|
wantPenumbralStart time.Time
|
|
wantPartialStart time.Time
|
|
wantTotalStart time.Time
|
|
wantMaximum time.Time
|
|
wantTotalEnd time.Time
|
|
wantPartialEnd time.Time
|
|
wantPenumbralEnd time.Time
|
|
}{
|
|
{
|
|
name: "2023-10-28 partial",
|
|
date: time.Date(2023, 10, 28, 0, 0, 0, 0, time.UTC),
|
|
wantType: LunarEclipsePartial,
|
|
wantPenumbralMag: 1.1181,
|
|
wantUmbralMag: 0.122,
|
|
wantPenumbralStart: time.Date(2023, 10, 28, 18, 1, 43, 0, time.UTC),
|
|
wantPartialStart: time.Date(2023, 10, 28, 19, 35, 18, 0, time.UTC),
|
|
wantMaximum: time.Date(2023, 10, 28, 20, 14, 6, 0, time.UTC),
|
|
wantPartialEnd: time.Date(2023, 10, 28, 20, 52, 53, 0, time.UTC),
|
|
wantPenumbralEnd: time.Date(2023, 10, 28, 22, 26, 19, 0, time.UTC),
|
|
},
|
|
{
|
|
name: "2025-03-14 total",
|
|
date: time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
|
|
wantType: LunarEclipseTotal,
|
|
wantPenumbralMag: 2.2595,
|
|
wantUmbralMag: 1.1784,
|
|
wantPenumbralStart: time.Date(2025, 3, 14, 3, 57, 28, 0, time.UTC),
|
|
wantPartialStart: time.Date(2025, 3, 14, 5, 9, 40, 0, time.UTC),
|
|
wantTotalStart: time.Date(2025, 3, 14, 6, 26, 6, 0, time.UTC),
|
|
wantMaximum: time.Date(2025, 3, 14, 6, 58, 41, 0, time.UTC),
|
|
wantTotalEnd: time.Date(2025, 3, 14, 7, 31, 26, 0, time.UTC),
|
|
wantPartialEnd: time.Date(2025, 3, 14, 8, 47, 56, 0, time.UTC),
|
|
wantPenumbralEnd: time.Date(2025, 3, 14, 10, 0, 9, 0, time.UTC),
|
|
},
|
|
}
|
|
|
|
const timeTolerance = 2 * time.Minute
|
|
const umbralMagnitudeTolerance = 0.02
|
|
const penumbralMagnitudeTolerance = 0.1
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
assertSameEclipse(t, "ClosestLunarEclipse(default)", ClosestLunarEclipse(tc.date), ClosestLunarEclipseDanjon(tc.date), time.Second)
|
|
info := ClosestLunarEclipse(tc.date)
|
|
if info.Type != tc.wantType {
|
|
t.Fatalf("type mismatch: got %s want %s", info.Type, tc.wantType)
|
|
}
|
|
|
|
assertFloatClose(t, "PenumbralMagnitude", info.PenumbralMagnitude, tc.wantPenumbralMag, penumbralMagnitudeTolerance)
|
|
assertFloatClose(t, "UmbralMagnitude", info.UmbralMagnitude, tc.wantUmbralMag, umbralMagnitudeTolerance)
|
|
|
|
assertTimeClose(t, "PenumbralStart", info.PenumbralStart, tc.wantPenumbralStart, timeTolerance)
|
|
assertTimeClose(t, "PartialStart", info.PartialStart, tc.wantPartialStart, timeTolerance)
|
|
assertTimeClose(t, "TotalStart", info.TotalStart, tc.wantTotalStart, timeTolerance)
|
|
assertTimeClose(t, "Maximum", info.Maximum, tc.wantMaximum, timeTolerance)
|
|
assertTimeClose(t, "TotalEnd", info.TotalEnd, tc.wantTotalEnd, timeTolerance)
|
|
assertTimeClose(t, "PartialEnd", info.PartialEnd, tc.wantPartialEnd, timeTolerance)
|
|
assertTimeClose(t, "PenumbralEnd", info.PenumbralEnd, tc.wantPenumbralEnd, timeTolerance)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPenumbralLunarEclipseKeepsNegativeUmbralMagnitude(t *testing.T) {
|
|
info := ClosestLunarEclipse(time.Date(2024, 3, 25, 0, 0, 0, 0, time.UTC))
|
|
if info.Type != LunarEclipsePenumbral {
|
|
t.Fatalf("type mismatch: got %s want %s", info.Type, LunarEclipsePenumbral)
|
|
}
|
|
if !(info.UmbralMagnitude < 0) {
|
|
t.Fatalf("expected negative umbral magnitude for penumbral eclipse, got %.12f", info.UmbralMagnitude)
|
|
}
|
|
if !(info.PenumbralMagnitude > 0) {
|
|
t.Fatalf("expected positive penumbral magnitude, got %.12f", info.PenumbralMagnitude)
|
|
}
|
|
}
|
|
|
|
func assertSameEclipse(t *testing.T, name string, got, want LunarEclipseInfo, tolerance time.Duration) {
|
|
t.Helper()
|
|
if got.Type != want.Type {
|
|
t.Fatalf("%s type mismatch: got %s want %s", name, got.Type, want.Type)
|
|
}
|
|
assertTimeClose(t, name+".Maximum", got.Maximum, want.Maximum, tolerance)
|
|
}
|
|
|
|
func assertTimeClose(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 assertFloatClose(t *testing.T, name string, got, want, tolerance float64) {
|
|
t.Helper()
|
|
diff := got - want
|
|
if diff < 0 {
|
|
diff = -diff
|
|
}
|
|
if diff > tolerance {
|
|
t.Fatalf("%s mismatch: got %.6f want %.6f diff=%.6f", name, got, want, diff)
|
|
}
|
|
}
|