Files
astro/eclipse/lunar_local_test.go
T
b612 16c62a97d5 feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持
- 改进日月食、月掩、行星事件及路径边界计算
- 完善恒星三维自行与动态距离传播
- 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具
- 整理中英文手册、示例资源及回归测试
2026-09-23 18:55:12 +08:00

460 lines
19 KiB
Go

package eclipse
import (
"testing"
"time"
)
func TestLocalLunarEclipseOnDateByLocalDay(t *testing.T) {
loc := time.FixedZone("CDT", -5*3600)
lon, lat, height := -87.65, 41.85, 0.0
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 := LocalLunarEclipseOnDate(tc.date, lon, lat, height)
if ok != tc.want {
t.Fatalf("LocalLunarEclipseOnDate(%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 TestLocalLunarEclipseVisibilityFilter(t *testing.T) {
chicagoLoc := time.FixedZone("CDT", -5*3600)
chicagoDate := time.Date(2023, 10, 28, 12, 0, 0, 0, chicagoLoc)
chicagoLon, chicagoLat, chicagoHeight := -87.65, 41.85, 0.0
geometricInfo, geometricOK := GeometricLocalLunarEclipseOnDate(chicagoDate, chicagoLon, chicagoLat, chicagoHeight)
if !geometricOK {
t.Fatalf("expected geometric local eclipse on date")
}
if geometricInfo.Type != LunarEclipsePartial {
t.Fatalf("unexpected geometric eclipse type: got %s want %s", geometricInfo.Type, LunarEclipsePartial)
}
if geometricInfo.VisibleAtMaximum {
t.Fatalf("expected geometric eclipse to be below horizon at maximum: %+v", geometricInfo)
}
visibleInfo, visibleOK := LocalLunarEclipseOnDate(chicagoDate, chicagoLon, chicagoLat, chicagoHeight)
if visibleOK {
t.Fatalf("expected visible filter to reject invisible eclipse, got %+v", visibleInfo)
}
londonDate := time.Date(2025, 3, 14, 12, 0, 0, 0, time.UTC)
londonInfo, londonOK := LocalLunarEclipseOnDate(londonDate, -0.1278, 51.5074, 0)
if !londonOK {
t.Fatalf("expected visible local eclipse in London")
}
if londonInfo.VisibleAtMaximum {
t.Fatalf("expected London maximum to be below horizon, got %+v", londonInfo)
}
}
func TestLocalLunarEclipseSearchSemantics(t *testing.T) {
loc := time.UTC
lon, lat, height := -0.1278, 51.5074, 0.0
current := ClosestLocalLunarEclipseDanjon(time.Date(2025, 3, 14, 12, 0, 0, 0, loc), lon, lat, height)
if current.Type != LunarEclipseTotal {
t.Fatalf("unexpected current eclipse type: got %s want %s", current.Type, LunarEclipseTotal)
}
assertSameLocalLunarEclipse(t, "ClosestLocalLunarEclipse(default)", ClosestLocalLunarEclipse(current.Maximum, lon, lat, height), current, time.Second)
last := LastLocalLunarEclipseDanjon(current.Maximum, lon, lat, height)
assertSameLocalLunarEclipse(t, "LastLocalLunarEclipseDanjon(current.Maximum)", last, current, time.Second)
closest := ClosestLocalLunarEclipseDanjon(current.Maximum, lon, lat, height)
assertSameLocalLunarEclipse(t, "ClosestLocalLunarEclipseDanjon(current.Maximum)", closest, current, time.Second)
next := NextLocalLunarEclipseDanjon(current.Maximum, lon, lat, height)
if !next.Maximum.After(current.Maximum) {
t.Fatalf("NextLocalLunarEclipseDanjon 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, 7, 18, 11, 49, 0, loc)
assertTimeClose(t, "next.Maximum", next.Maximum, wantNextMax, 2*time.Minute)
}
func TestLocalLunarEclipseSearchBeyondFiveYears(t *testing.T) {
loc := time.FixedZone("CDT", -5*3600)
lon, lat, height := -87.65, 41.85, 0.0
current := ClosestLocalLunarEclipseDanjon(time.Date(2025, 3, 14, 12, 0, 0, 0, loc), lon, lat, height)
if current.Type != LunarEclipseTotal {
t.Fatalf("unexpected current eclipse type: got %s want %s", current.Type, LunarEclipseTotal)
}
next := NextLocalLunarEclipseDanjon(current.Maximum, lon, lat, height)
if next.Type == LunarEclipseNone || next.Maximum.IsZero() {
t.Fatalf("expected a future visible local lunar eclipse beyond the old 60-lunation window")
}
if !next.Maximum.After(current.Maximum) {
t.Fatalf("expected strictly future local lunar eclipse: current=%v next=%v", current.Maximum, next.Maximum)
}
}
func TestLocalTotalLunarEclipseSearch(t *testing.T) {
loc := time.FixedZone("CDT", -5*3600)
lon, lat, height := -87.65, 41.85, 0.0
date := time.Date(2025, 3, 13, 0, 0, 0, 0, loc)
next, ok := NextLocalTotalLunarEclipse(date, lon, lat, height)
if !ok {
t.Fatal("expected to find next local total lunar eclipse")
}
if next.Type != LunarEclipseTotal || !next.HasTotal {
t.Fatalf("unexpected next total lunar eclipse: %+v", next)
}
assertTimeClose(t, "NextLocalTotalLunarEclipse", next.Maximum, time.Date(2025, 3, 14, 1, 58, 47, 0, loc), 2*time.Minute)
last, ok := LastLocalTotalLunarEclipse(next.Maximum, lon, lat, height)
if !ok {
t.Fatal("expected to find previous local total lunar eclipse")
}
if last.Type != LunarEclipseTotal || !last.HasTotal {
t.Fatalf("unexpected last total lunar eclipse: %+v", last)
}
assertTimeClose(t, "LastLocalTotalLunarEclipse", last.Maximum, next.Maximum, time.Second)
}
func TestLocalTotalLunarEclipseClosest(t *testing.T) {
loc := time.FixedZone("CDT", -5*3600)
lon, lat, height := -87.65, 41.85, 0.0
date := time.Date(2025, 3, 14, 0, 0, 0, 0, loc)
info, ok := ClosestLocalTotalLunarEclipse(date, lon, lat, height)
if !ok {
t.Fatal("expected to find closest local total lunar eclipse")
}
if info.Type != LunarEclipseTotal || !info.HasTotal {
t.Fatalf("unexpected closest total lunar eclipse: %+v", info)
}
assertTimeClose(t, "ClosestLocalTotalLunarEclipse", info.Maximum, time.Date(2025, 3, 14, 1, 58, 47, 0, loc), 2*time.Minute)
}
func TestLocalTotalLunarEclipseVisibleRequiresTotalPhaseVisibility(t *testing.T) {
info, ok := LocalLunarEclipseOnDate(time.Date(2025, 3, 14, 12, 0, 0, 0, time.UTC), -0.1278, 51.5074, 0)
if !ok {
t.Fatalf("expected visible local eclipse in London")
}
if info.Type != LunarEclipseTotal || !info.HasTotal {
t.Fatalf("unexpected eclipse type: %+v", info)
}
if !localLunarEclipseVisible(info) {
t.Fatalf("expected some phase to be visible")
}
if localTotalLunarEclipseVisible(info) {
t.Fatalf("expected total phase below horizon to be rejected")
}
}
func TestLocalLunarEclipseInfoKeepsLocation(t *testing.T) {
loc := time.FixedZone("JST", 9*3600)
lon, lat, height := 139.6917, 35.6895, 1234.0
testCases := []struct {
name string
calc func(time.Time, float64, float64, float64) LocalLunarEclipseInfo
}{
{name: "danjon", calc: ClosestLocalLunarEclipseDanjon},
{name: "chauvenet", calc: ClosestLocalLunarEclipseChauvenet},
}
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), lon, lat, height)
if info.Type != LunarEclipsePartial {
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, LunarEclipsePartial)
}
if info.Longitude != lon || info.Latitude != lat || info.Height != height {
t.Fatalf("observer metadata mismatch: got (%f,%f,%f)", info.Longitude, info.Latitude, info.Height)
}
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)
}
}
})
}
}
func TestLocalLunarEclipseChauvenetRemainsAvailable(t *testing.T) {
date := time.Date(2025, 3, 14, 12, 0, 0, 0, time.FixedZone("CDT", -5*3600))
lon, lat, height := -87.65, 41.85, 0.0
defaultInfo := ClosestLocalLunarEclipse(date, lon, lat, height)
chauvenetInfo := ClosestLocalLunarEclipseChauvenet(date, lon, lat, height)
assertFloatClose(t, "Chauvenet.PenumbralMagnitude", chauvenetInfo.PenumbralMagnitude, 2.285436461, 1e-6)
assertFloatClose(t, "Chauvenet.UmbralMagnitude", chauvenetInfo.UmbralMagnitude, 1.182806541, 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 TestLocalLunarEclipseAgainstNASABaseline(t *testing.T) {
testCases := []struct {
name string
date time.Time
lon float64
lat float64
height float64
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
wantMoonAltitude float64
}{
{
name: "2023-10-29 tokyo partial",
date: time.Date(2023, 10, 29, 12, 0, 0, 0, time.FixedZone("JST", 9*3600)),
lon: 139.6917,
lat: 35.6895,
height: 0,
wantType: LunarEclipsePartial,
wantPenumbralMag: 1.1181,
wantUmbralMag: 0.122,
wantPenumbralStart: time.Date(2023, 10, 29, 03, 01, 43, 0, time.FixedZone("JST", 9*3600)),
wantPartialStart: time.Date(2023, 10, 29, 04, 35, 18, 0, time.FixedZone("JST", 9*3600)),
wantMaximum: time.Date(2023, 10, 29, 05, 14, 06, 0, time.FixedZone("JST", 9*3600)),
wantPartialEnd: time.Date(2023, 10, 29, 05, 52, 53, 0, time.FixedZone("JST", 9*3600)),
wantPenumbralEnd: time.Date(2023, 10, 29, 07, 26, 19, 0, time.FixedZone("JST", 9*3600)),
wantMoonAltitude: 9.1,
},
{
name: "2025-03-14 chicago total",
date: time.Date(2025, 3, 14, 12, 0, 0, 0, time.FixedZone("CDT", -5*3600)),
lon: -87.65,
lat: 41.85,
height: 0,
wantType: LunarEclipseTotal,
wantPenumbralMag: 2.2595,
wantUmbralMag: 1.1784,
wantPenumbralStart: time.Date(2025, 3, 13, 22, 57, 28, 0, time.FixedZone("CDT", -5*3600)),
wantPartialStart: time.Date(2025, 3, 14, 0, 9, 40, 0, time.FixedZone("CDT", -5*3600)),
wantTotalStart: time.Date(2025, 3, 14, 1, 26, 6, 0, time.FixedZone("CDT", -5*3600)),
wantMaximum: time.Date(2025, 3, 14, 1, 58, 41, 0, time.FixedZone("CDT", -5*3600)),
wantTotalEnd: time.Date(2025, 3, 14, 2, 31, 26, 0, time.FixedZone("CDT", -5*3600)),
wantPartialEnd: time.Date(2025, 3, 14, 3, 47, 56, 0, time.FixedZone("CDT", -5*3600)),
wantPenumbralEnd: time.Date(2025, 3, 14, 5, 0, 9, 0, time.FixedZone("CDT", -5*3600)),
wantMoonAltitude: 48.2,
},
}
const timeTolerance = 2 * time.Minute
const umbralMagnitudeTolerance = 0.02
const penumbralMagnitudeTolerance = 0.1
const altitudeTolerance = 1.5
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
info := ClosestLocalLunarEclipse(tc.date, tc.lon, tc.lat, tc.height)
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)
assertFloatClose(t, "MoonAltitude", info.MoonAltitude, tc.wantMoonAltitude, altitudeTolerance)
})
}
}
func TestLocalPenumbralLunarEclipseKeepsNegativeUmbralMagnitude(t *testing.T) {
cdt := time.FixedZone("CDT", -5*3600)
info := ClosestLocalLunarEclipse(time.Date(2024, 3, 25, 2, 0, 0, 0, cdt), -87.65, 41.85, 0)
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)
}
}
// TestLocalLunarEclipseVisibilityClass 固定站点自己的食内可见性八分类;rise-and-set 与 interrupted
// 是静态地平圈几何推不出来的,penumbra-moonrise / penumbra-moonset 是本影整段在地平线下的细分。
func TestLocalLunarEclipseVisibilityClass(t *testing.T) {
date := time.Date(2029, 1, 1, 12, 0, 0, 0, time.FixedZone("CST", 8*3600))
previousDay := time.Date(2028, 12, 31, 12, 0, 0, 0, time.FixedZone("CST", 8*3600))
testCases := []struct {
name string
date time.Time
lon float64
lat float64
want LocalLunarEclipseVisibility
}{
{name: "mid latitude stays up", lon: 107.25, lat: 30.25, want: LocalLunarEclipseFull},
{name: "moonset inside eclipse", lon: -161.25, lat: 3.25, want: LocalLunarEclipseMoonset},
{name: "moonrise inside eclipse", lon: 20.25, lat: -2.75, want: LocalLunarEclipseMoonrise},
{name: "south polar lens", lon: 108.75, lat: -62.75, want: LocalLunarEclipseRiseAndSet},
{name: "reported station", lon: 108.729001, lat: -59.937452, want: LocalLunarEclipseRiseAndSet},
{name: "arctic midday dip", lon: -71.25, lat: 64.75, want: LocalLunarEclipseInterrupted},
{name: "far side never up", lon: -178.5, lat: -86.5, want: LocalLunarEclipseInvisible},
// 只看到半影:本影阶段整段在地平线下,月出/月落仍落在食内。
{name: "penumbra only moonrise", lon: -69.25, lat: 61.25, want: LocalLunarEclipsePenumbraMoonrise},
{name: "penumbra only moonset", date: previousDay, lon: -179.25, lat: -61.25, want: LocalLunarEclipsePenumbraMoonset},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
at := testCase.date
if at.IsZero() {
at = date
}
geometric, geometricOK := GeometricLocalLunarEclipseOnDate(at, testCase.lon, testCase.lat, 0)
if !geometricOK {
t.Fatal("expected a geometric lunar eclipse on the local date")
}
if geometric.Visibility != testCase.want {
t.Fatalf("visibility=%s want %s", geometric.Visibility, testCase.want)
}
_, visibleOK := LocalLunarEclipseOnDate(at, testCase.lon, testCase.lat, 0)
if wantVisible := testCase.want != LocalLunarEclipseInvisible; visibleOK != wantVisible {
t.Fatalf("visible filter ok=%v want %v", visibleOK, wantVisible)
}
})
}
}
func TestLocalLunarEclipsePolarWindowScansBetweenContacts(t *testing.T) {
date := time.Date(1800, 4, 9, 0, 0, 0, 0, time.UTC)
info, ok := GeometricLocalLunarEclipseOnDate(date, 121, 82, 0)
if !ok {
t.Fatal("expected a geometric lunar eclipse")
}
if info.Visibility != LocalLunarEclipseRiseAndSet {
t.Fatalf("visibility=%s, want %s", info.Visibility, LocalLunarEclipseRiseAndSet)
}
if _, ok := LocalLunarEclipseOnDate(date, 121, 82, 0); !ok {
t.Fatal("the short above-horizon interval must make the eclipse visible")
}
}
func assertSameLocalLunarEclipse(t *testing.T, name string, got, want LocalLunarEclipseInfo, 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)
}
// 纯半影月食没有本影接触,不把任何站点降级成 penumbra-moonrise / penumbra-moonset。
func TestLocalLunarEclipsePenumbralOnlyKeepsOldClasses(t *testing.T) {
date := time.Date(2020, 1, 11, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
info, ok := LunarEclipseOnDate(date)
if !ok || info.HasPartial {
t.Fatalf("expected a purely penumbral eclipse, HasPartial=%v ok=%v", info.HasPartial, ok)
}
seen := map[LocalLunarEclipseVisibility]int{}
for longitude := -179.5; longitude < 180; longitude += 5 {
for latitude := -89.5; latitude < 90; latitude += 5 {
local, found := GeometricLocalLunarEclipseOnDate(date, longitude, latitude, 0)
if !found {
continue
}
seen[local.Visibility]++
}
}
if seen[LocalLunarEclipsePenumbraMoonrise] != 0 || seen[LocalLunarEclipsePenumbraMoonset] != 0 {
t.Fatalf("purely penumbral eclipse produced penumbra-only classes: %v", seen)
}
if seen[LocalLunarEclipseMoonrise] == 0 || seen[LocalLunarEclipseMoonset] == 0 {
t.Fatalf("purely penumbral eclipse lost its moonrise/moonset sites: %v", seen)
}
}
// 极区掠射:U1、食甚、U4 三点都可能在地平下,但本影区间中途仍有短暂窗口高于地平;这类站点确实
// 见到本影,必须留在 moonrise/moonset,只有本影区间极值也在地平下才算"仅见半影"。
func TestLocalLunarEclipseKeepsUmbralGrazingWindow(t *testing.T) {
date := time.Date(1932, 3, 22, 0, 0, 0, 0, time.UTC)
for _, witness := range []struct {
name string
lon, lat float64
want LocalLunarEclipseVisibility
}{
{"本影窗口 +0.0006 度", -91.8, -88.7675, LocalLunarEclipseMoonrise},
{"U1 已在地平上", -91.75, -88.75, LocalLunarEclipseMoonrise},
{"U1 高度 +0.21 度(陡边界)", -75.5, 6.5, LocalLunarEclipseMoonset},
{"本影区间极值 -0.005 度", -90.0, -88.65, LocalLunarEclipsePenumbraMoonset},
} {
local, ok := GeometricLocalLunarEclipseOnDate(date, witness.lon, witness.lat, 0)
if !ok {
t.Fatalf("%s: 本影几何月食缺失", witness.name)
}
if local.Visibility != witness.want {
t.Fatalf("%s (%.4f,%.4f) visibility=%s want %s",
witness.name, witness.lon, witness.lat, local.Visibility, witness.want)
}
}
}