Files
astro/eclipse/saros_extended_test.go
T
b612 2bf8478639 feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
2026-09-17 12:27:40 +08:00

206 lines
7.8 KiB
Go

package eclipse
import (
"fmt"
"math"
"testing"
"time"
"b612.me/astro/basic"
)
func TestSarosNumberAgainstAllCatalogAnchors(t *testing.T) {
for phase, anchors := range [][]sarosMagic{solarSarosAnchors[:], lunarSarosAnchors[:]} {
for index, magic := range anchors {
anchor := decodeSarosMagic(magic, phase+index)
k, ok := sarosLunation(basic.JDECalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day)), phase)
if !ok {
t.Fatalf("invalid catalog anchor: %+v", anchor)
}
for member := 1; member <= int(anchor.Count); member++ {
got := sarosNumber(k+(member-1)*sarosCycleLunations, phase)
if got != int(anchor.Series) {
t.Fatalf("phase=%d series=%d member=%d got series=%d", phase, anchor.Series, member, got)
}
}
}
}
}
func TestExtendedSarosUsesPrecomputedSeries(t *testing.T) {
got := ClosestSolarEclipse(time.Date(3288, 11, 15, 12, 0, 0, 0, time.UTC))
if !got.HasSaros || got.Saros != (SarosInfo{Series: 202, Member: 1, Count: 71}) {
t.Fatalf("unexpected precomputed solar Saros: has=%v info=%+v", got.HasSaros, got.Saros)
}
next := ClosestSolarEclipse(time.Date(3306, 11, 28, 12, 0, 0, 0, time.UTC))
if !next.HasSaros || next.Saros != (SarosInfo{Series: 202, Member: 2, Count: 71}) {
t.Fatalf("unexpected second precomputed member: %+v", next.Saros)
}
previous := ClosestSolarEclipse(time.Date(3288, 10, 17, 12, 0, 0, 0, time.UTC))
assertSarosInfo(t, previous.HasSaros, previous.Saros, 164, 67, 80)
}
func TestExtendedSarosSpansAreComplete(t *testing.T) {
for phase, spans := range [][]sarosSpan{solarSarosExtended[:], lunarSarosExtended[:]} {
seen := make(map[int]bool)
for i, span := range spans {
if span.First > span.Last || (span.Last-span.First)%sarosCycleLunations != 0 || span.Member < 1 || span.Member > span.Count {
t.Fatalf("phase=%d invalid span: %+v", phase, span)
}
if i == 0 || spans[i-1].Series != span.Series {
if span.Member != 1 || (i > 0 && spans[i-1].Series > span.Series) {
t.Fatalf("phase=%d invalid series start: %+v", phase, span)
}
} else {
previous := spans[i-1]
nextMember := previous.Member + (previous.Last-previous.First)/sarosCycleLunations + 1
if span.First <= previous.Last+sarosCycleLunations || (span.First-previous.First)%sarosCycleLunations != 0 || span.Member != nextMember || span.Count != previous.Count {
t.Fatalf("phase=%d invalid gap: previous=%+v current=%+v", phase, previous, span)
}
for k := previous.Last + sarosCycleLunations; k < span.First; k += sarosCycleLunations {
if info, ok := matchSarosSpans(spans, k); ok {
t.Fatalf("phase=%d missing return k=%d matched %+v", phase, k, info)
}
}
}
lastMember := span.Member + (span.Last-span.First)/sarosCycleLunations
if (i == len(spans)-1 || spans[i+1].Series != span.Series) && lastMember != span.Count {
t.Fatalf("phase=%d incomplete series: %+v", phase, span)
}
for k := span.First; k <= span.Last; k += sarosCycleLunations {
if seen[k] || sarosNumber(k, phase) != span.Series {
t.Fatalf("phase=%d duplicated or misnumbered return: k=%d span=%+v", phase, k, span)
}
seen[k] = true
want := SarosInfo{Series: span.Series, Member: span.Member + (k-span.First)/sarosCycleLunations, Count: span.Count}
if got, ok := matchSarosSpans(spans, k); !ok || got != want {
t.Fatalf("phase=%d k=%d got=%+v has=%v want=%+v", phase, k, got, ok, want)
}
}
}
}
}
func TestExtendedSarosMatchesLiveSeries(t *testing.T) {
for _, tc := range []struct {
phase int
series int
}{
{phase: 0, series: -39},
{phase: 0, series: 181},
{phase: 0, series: 202},
{phase: 0, series: 293},
{phase: 1, series: -27},
{phase: 1, series: 0},
{phase: 1, series: 4}, // This series contains three missing returns.
{phase: 1, series: 202},
} {
t.Run(fmt.Sprintf("phase%d/series%d", tc.phase, tc.series), func(t *testing.T) {
spans := solarSarosExtended[:]
if tc.phase == 1 {
spans = lunarSarosExtended[:]
}
found := false
for _, span := range spans {
if span.Series != tc.series {
continue
}
found = true
for _, k := range []int{span.First, span.First + (span.Last-span.First)/sarosCycleLunations/2*sarosCycleLunations, span.Last} {
want, _ := matchSarosSpans(spans, k)
got, ok := extrapolateSaros(k, tc.phase)
if !ok || got != want {
t.Fatalf("k=%d live=%+v has=%v table=%+v", k, got, ok, want)
}
// 锚定序列的总成员数由锚表决定,扩展表只提供成员位置。
if anchor, anchored := sarosAnchorRangeFor(tc.phase, want.Series); anchored {
want.Count = anchor.count
}
tt, exists, valid := sarosEclipse(k, tc.phase)
if !exists || !valid {
t.Fatalf("k=%d missing eclipse: exists=%v valid=%v", k, exists, valid)
}
if got, ok := extendedSarosInfo(tt, tc.phase); !ok || got != want {
t.Fatalf("k=%d tt=%f dispatched=%+v has=%v want=%+v", k, tt, got, ok, want)
}
}
}
if !found {
t.Fatal("missing test series")
}
})
}
}
func TestExtendedSarosRangeAndInvalidInput(t *testing.T) {
if sarosExtendedStartTT != timeToTTJDE(time.Date(-3000, 1, 1, 0, 0, 0, 0, time.UTC)) || sarosExtendedEndTT != timeToTTJDE(time.Date(6001, 1, 1, 0, 0, 0, 0, time.UTC)) {
t.Fatal("precomputed range must include both end years")
}
for phase, spans := range [][]sarosSpan{solarSarosExtended[:], lunarSarosExtended[:]} {
for _, tt := range []float64{sarosExtendedStartTT, sarosExtendedEndTT - 1.0/86400} {
k, _ := sarosLunation(tt, phase)
want, wantOK := matchSarosSpans(spans, k)
got, ok := extendedSarosInfo(tt, phase)
if got != want || ok != wantOK {
t.Fatalf("phase=%d boundary lookup got=%+v/%v want=%+v/%v", phase, got, ok, want, wantOK)
}
}
for _, tt := range []float64{math.NaN(), math.Inf(1), math.Inf(-1), math.MaxFloat64} {
if got, ok := extendedSarosInfo(tt, phase); ok || got != (SarosInfo{}) {
t.Fatalf("phase=%d invalid epoch returned %+v/%v", phase, got, ok)
}
}
}
if got, ok := extrapolateSaros(-56340, 1); ok || got != (SarosInfo{}) {
t.Fatalf("missing lunar return produced metadata: %+v/%v", got, ok)
}
}
// 直映缓存只复用结果:命中与未命中都必须与未缓存求值逐字段相同,外推结果也必须与
// 逐字重写的未缓存扫描一致。
func TestSarosEclipseMemoMatchesUncachedEvaluation(t *testing.T) {
for _, phase := range []int{0, 1} {
for _, k := range []int{-57901, -56340, 15941, 31551, 19004, 34391, 120000} {
for attempt := 0; attempt < 2; attempt++ {
maximum, exists, valid := sarosEclipse(k, phase)
wantMaximum, wantExists, wantValid := sarosEclipseUncached(k, phase)
if maximum != wantMaximum || exists != wantExists || valid != wantValid {
t.Fatalf("phase=%d k=%d attempt=%d cached=%v/%v/%v uncached=%v/%v/%v",
phase, k, attempt, maximum, exists, valid, wantMaximum, wantExists, wantValid)
}
}
}
for _, k := range []int{-56340, 120000} {
for attempt := 0; attempt < 2; attempt++ {
got, ok := extrapolateSaros(k, phase)
want, wantOK := sarosExtrapolationUncachedForTest(k, phase)
if ok != wantOK || got != want {
t.Fatalf("phase=%d k=%d attempt=%d memoized=%+v/%v uncached scan=%+v/%v",
phase, k, attempt, got, ok, want, wantOK)
}
}
}
}
}
func sarosExtrapolationUncachedForTest(k, phase int) (SarosInfo, bool) {
info := SarosInfo{Series: sarosNumber(k, phase)}
for offset := -sarosExtrapolationWindow; offset <= sarosExtrapolationWindow; offset++ {
_, exists, valid := sarosEclipseUncached(k+offset*sarosCycleLunations, phase)
if !valid || (offset == 0 && !exists) {
return SarosInfo{}, false
}
if !exists {
continue
}
if offset == -sarosExtrapolationWindow || offset == sarosExtrapolationWindow {
return SarosInfo{}, false
}
info.Count++
if offset <= 0 {
info.Member++
}
}
return info, true
}