16c62a97d5
- 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试
309 lines
11 KiB
Go
309 lines
11 KiB
Go
package eclipse
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
"time"
|
|
|
|
"b612.me/astro/basic"
|
|
)
|
|
|
|
// 本文件独立解析锚表与扩展表:成员号、唯一性、连续性与成员总数都由测试自己推导,
|
|
const (
|
|
sarosTestLunationEpoch = 2451550.09765
|
|
sarosTestSynodicMonthDays = 29.530588853
|
|
sarosTestAnchorYearOffset = 3000
|
|
sarosTestAnchorCountMask = 0x7f
|
|
sarosTestAnchorDayMask = 0x1f
|
|
sarosTestAnchorMonthMask = 0x0f
|
|
sarosTestAnchorYearMask = 0x1fff
|
|
sarosTestAnchorDayShift = 7
|
|
sarosTestAnchorMonthShift = 12
|
|
sarosTestAnchorYearShift = 16
|
|
sarosTestAnchorMemberInterval = 223
|
|
)
|
|
|
|
type sarosTestWindow struct {
|
|
headTT float64
|
|
count int
|
|
offset int
|
|
}
|
|
|
|
func sarosTestWindows(t *testing.T, phase int) map[int]sarosTestWindow {
|
|
t.Helper()
|
|
anchors := solarSarosAnchors[:]
|
|
overrides := solarSarosHeadOverrides[:]
|
|
base := 0
|
|
if phase == 1 {
|
|
anchors = lunarSarosAnchors[:]
|
|
overrides = lunarSarosHeadOverrides[:]
|
|
base = 1
|
|
}
|
|
windows := make(map[int]sarosTestWindow, len(anchors)+len(overrides))
|
|
for index, magic := range anchors {
|
|
value := uint32(magic)
|
|
headTT := basic.JDCalc(
|
|
int((value>>sarosTestAnchorYearShift)&sarosTestAnchorYearMask)-sarosTestAnchorYearOffset,
|
|
int((value>>sarosTestAnchorMonthShift)&sarosTestAnchorMonthMask),
|
|
float64((value>>sarosTestAnchorDayShift)&sarosTestAnchorDayMask),
|
|
)
|
|
if math.IsNaN(headTT) {
|
|
t.Fatalf("invalid anchor head for series %d", base+index)
|
|
}
|
|
windows[base+index] = sarosTestWindow{
|
|
headTT: headTT,
|
|
count: int(value & sarosTestAnchorCountMask),
|
|
}
|
|
}
|
|
for _, override := range overrides {
|
|
headTT := basic.JDCalc(int(override.HeadYear), int(override.HeadMonth), float64(override.HeadDay))
|
|
if math.IsNaN(headTT) {
|
|
t.Fatalf("invalid override head for series %d", override.Series)
|
|
}
|
|
windows[int(override.Series)] = sarosTestWindow{
|
|
headTT: headTT,
|
|
count: int(override.Count),
|
|
offset: int(override.MemberOffset),
|
|
}
|
|
}
|
|
return windows
|
|
}
|
|
|
|
func sarosTestReturnTT(k, phase int) float64 {
|
|
return sarosTestLunationEpoch + (float64(k)+float64(phase)/2)*sarosTestSynodicMonthDays
|
|
}
|
|
|
|
func sarosTestMember(window sarosTestWindow, ttJDE float64) int {
|
|
return int(math.Round((ttJDE-window.headTT)/(float64(sarosTestAnchorMemberInterval)*sarosTestSynodicMonthDays))) +
|
|
1 + window.offset
|
|
}
|
|
|
|
func sarosTestSpans(phase int) []sarosSpan {
|
|
if phase == 1 {
|
|
return lunarSarosExtended[:]
|
|
}
|
|
return solarSarosExtended[:]
|
|
}
|
|
|
|
func sarosTestInfo(ttJDE float64, phase int) (SarosInfo, bool) {
|
|
if phase == 0 {
|
|
return solarSarosInfo(ttJDE)
|
|
}
|
|
return lunarSarosInfo(ttJDE)
|
|
}
|
|
|
|
// 扩展表只对窗口内的时刻生效;窗口外的序列行由外推路径接管。
|
|
func sarosTestInsideTable(ttJDE float64) bool {
|
|
return ttJDE >= sarosExtendedStartTT && ttJDE < sarosExtendedEndTT
|
|
}
|
|
|
|
// 锚表窗口、扩展表成员号与公开查询必须一致:成员号唯一、连续,总数等于锚表的 Count,
|
|
// 序列号与扩展表相同;目录窗口之外的成员只报"没有沙罗信息"。
|
|
func TestSarosAnchorWindowsMatchExtensionSpans(t *testing.T) {
|
|
for _, phase := range []int{0, 1} {
|
|
name := "solar"
|
|
if phase == 1 {
|
|
name = "lunar"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
windows := sarosTestWindows(t, phase)
|
|
reported := make(map[int]map[int]int)
|
|
for _, span := range sarosTestSpans(phase) {
|
|
if span.First > span.Last || (span.Last-span.First)%sarosTestAnchorMemberInterval != 0 {
|
|
t.Fatalf("malformed span %+v", span)
|
|
}
|
|
window, anchored := windows[span.Series]
|
|
for index, k := 0, span.First; k <= span.Last; k, index = k+sarosTestAnchorMemberInterval, index+1 {
|
|
ttJDE := sarosTestReturnTT(k, phase)
|
|
if !sarosTestInsideTable(ttJDE) {
|
|
continue
|
|
}
|
|
spanMember := span.Member + index
|
|
want := SarosInfo{Series: span.Series, Member: spanMember, Count: span.Count}
|
|
if anchored {
|
|
want.Member = sarosTestMember(window, ttJDE)
|
|
want.Count = window.count
|
|
if want.Member < 1 || want.Member > window.count {
|
|
want = SarosInfo{}
|
|
}
|
|
}
|
|
// 扩展表派发本身就要采用锚表的成员总数;协调会再覆盖一次,所以单独钉住输入侧。
|
|
provisional, provisionalOK := extendedSarosInfo(ttJDE, phase)
|
|
if !provisionalOK || provisional.Series != span.Series || provisional.Member != spanMember {
|
|
t.Fatalf("series %d k=%d extension dispatch=%+v/%v, want member %d",
|
|
span.Series, k, provisional, provisionalOK, spanMember)
|
|
}
|
|
wantProvisionalCount := span.Count
|
|
if anchored {
|
|
wantProvisionalCount = window.count
|
|
}
|
|
if provisional.Count != wantProvisionalCount {
|
|
t.Fatalf("series %d k=%d extension count=%d, want %d", span.Series, k, provisional.Count, wantProvisionalCount)
|
|
}
|
|
info, ok := reconciledSarosInfo(extendedSarosInfo, ttJDE, phase)
|
|
if ok != (want != SarosInfo{}) || info.Series != want.Series ||
|
|
info.Member != want.Member || info.Count != want.Count {
|
|
t.Fatalf("series %d k=%d dispatch=%+v/%v, independently derived %+v", span.Series, k, info, ok, want)
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
// 唯一性与连续性按测试自己推导的成员号记账,覆盖窗口内每个回次。
|
|
if reported[span.Series] == nil {
|
|
reported[span.Series] = make(map[int]int)
|
|
}
|
|
if previous, seen := reported[span.Series][want.Member]; seen {
|
|
t.Fatalf("series %d member %d reported for both k=%d and k=%d", span.Series, want.Member, previous, k)
|
|
}
|
|
reported[span.Series][want.Member] = k
|
|
// 序列头、尾与中间各抽一点走公开入口,锚表匹配器与扩展表必须给出同一成员。
|
|
if index != 0 && index != 1 && k != span.Last && k != span.Last-sarosTestAnchorMemberInterval {
|
|
continue
|
|
}
|
|
public, publicOK := sarosTestInfo(ttJDE, phase)
|
|
wantPublic := SarosInfo{Series: want.Series, Member: want.Member, Count: want.Count, Verified: anchored}
|
|
if !publicOK || public != wantPublic {
|
|
t.Fatalf("series %d k=%d public=%+v/%v, want %+v", span.Series, k, public, publicOK, wantPublic)
|
|
}
|
|
}
|
|
}
|
|
for series, members := range reported {
|
|
minimum, maximum := 1<<30, -1<<30
|
|
for member := range members {
|
|
if member < minimum {
|
|
minimum = member
|
|
}
|
|
if member > maximum {
|
|
maximum = member
|
|
}
|
|
}
|
|
if maximum-minimum+1 != len(members) {
|
|
t.Fatalf("series %d members are not consecutive: %d..%d with %d entries",
|
|
series, minimum, maximum, len(members))
|
|
}
|
|
window, anchored := windows[series]
|
|
if !anchored {
|
|
continue
|
|
}
|
|
if minimum < 1 || maximum > window.count {
|
|
t.Fatalf("series %d covers member %d..%d outside the anchor window 1..%d",
|
|
series, minimum, maximum, window.count)
|
|
}
|
|
if minimum == 1 && maximum == window.count {
|
|
continue
|
|
}
|
|
// 4 号头部连续三个回次缺测,最后一个正是目录成员 1;其余序列必须铺满窗口。
|
|
// Lunar series 4 catalog member 1 sits on a return the library's lunar
|
|
// detection cannot resolve; every other anchored series must be complete.
|
|
if phase == 1 && series == 4 && minimum == 2 && maximum == window.count {
|
|
continue
|
|
}
|
|
t.Fatalf("series %d covers member %d..%d of the anchor window 1..%d",
|
|
series, minimum, maximum, window.count)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// override 声明的成员区间必须真的可达:首成员与末成员都要按格点报出,且标注为已核验。
|
|
func TestSarosOverrideWindowsCoverDeclaredMemberRange(t *testing.T) {
|
|
for _, phase := range []int{0, 1} {
|
|
overrides := solarSarosHeadOverrides[:]
|
|
if phase == 1 {
|
|
overrides = lunarSarosHeadOverrides[:]
|
|
}
|
|
cycleDays := float64(sarosTestAnchorMemberInterval) * sarosTestSynodicMonthDays
|
|
for _, override := range overrides {
|
|
headTT := basic.JDCalc(int(override.HeadYear), int(override.HeadMonth), float64(override.HeadDay))
|
|
for _, member := range []int{1, int(override.Count)} {
|
|
ttJDE := headTT + float64(member-1-int(override.MemberOffset))*cycleDays
|
|
info, ok := sarosTestInfo(ttJDE, phase)
|
|
want := SarosInfo{
|
|
Series: int(override.Series),
|
|
Member: member,
|
|
Count: int(override.Count),
|
|
Verified: true,
|
|
}
|
|
if !ok || info != want {
|
|
t.Fatalf("phase=%d series %d member %d = %+v/%v, want %+v",
|
|
phase, override.Series, member, info, ok, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 锚表 Count 的裁剪只作用于协调输入:扩展表派发结果与"原始跨度 + 协调"必须逐字段相同。
|
|
func TestSarosAnchorCountClipKeepsReconciledResult(t *testing.T) {
|
|
for _, phase := range []int{0, 1} {
|
|
spans := sarosTestSpans(phase)
|
|
for _, span := range spans {
|
|
for index, k := 0, span.First; k <= span.Last; k, index = k+sarosTestAnchorMemberInterval, index+1 {
|
|
ttJDE := sarosTestReturnTT(k, phase)
|
|
if !sarosTestInsideTable(ttJDE) {
|
|
continue
|
|
}
|
|
if index != 0 && k != span.Last {
|
|
continue
|
|
}
|
|
raw, rawOK := matchSarosSpans(spans, k)
|
|
want, wantOK := reconcileSarosSpan(raw, ttJDE, phase)
|
|
if !rawOK {
|
|
wantOK = false
|
|
}
|
|
got, ok := reconciledSarosInfo(extendedSarosInfo, ttJDE, phase)
|
|
if ok != wantOK || got != want {
|
|
t.Fatalf("phase=%d k=%d dispatch=%+v/%v, raw reconcile=%+v/%v", phase, k, got, ok, want, wantOK)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Anchored series keep the catalog numbering, so a provisional span may no
|
|
// longer present a second "member 1" for the same series.
|
|
func TestLunarSaros_ProvisionalSpanDoesNotDuplicateAnchoredMember(t *testing.T) {
|
|
head, ok := eclipseOnDateForTest(-1926, 2, 2)
|
|
if !ok {
|
|
t.Skip("anchored series head is not an eclipse in this build")
|
|
}
|
|
if head.Saros.Series != 22 || head.Saros.Member != 1 {
|
|
t.Fatalf("series 22 head = %d/%d, want 22/1", head.Saros.Series, head.Saros.Member)
|
|
}
|
|
earlier, ok := eclipseOnDateForTest(-1944, 1, 22)
|
|
if !ok {
|
|
t.Fatal("expected a lunar eclipse one Saros before the anchored head")
|
|
}
|
|
if earlier.HasSaros && earlier.Saros.Series == head.Saros.Series && earlier.Saros.Member == head.Saros.Member {
|
|
t.Fatalf("provisional span repeats series %d member %d", earlier.Saros.Series, earlier.Saros.Member)
|
|
}
|
|
}
|
|
|
|
// Modern catalog values must keep matching NASA's published member numbers.
|
|
func TestLunarSaros_ModernMembersUnchanged(t *testing.T) {
|
|
cases := []struct {
|
|
year, month, day int
|
|
series, member int
|
|
}{
|
|
{2000, 7, 16, 129, 37},
|
|
{2018, 7, 27, 129, 38},
|
|
{2022, 11, 8, 136, 20},
|
|
{2025, 9, 7, 128, 41},
|
|
}
|
|
for _, testCase := range cases {
|
|
info, ok := eclipseOnDateForTest(testCase.year, testCase.month, testCase.day)
|
|
if !ok {
|
|
t.Fatalf("missing lunar eclipse on %04d-%02d-%02d", testCase.year, testCase.month, testCase.day)
|
|
}
|
|
if info.Saros.Series != testCase.series || info.Saros.Member != testCase.member {
|
|
t.Fatalf("%04d-%02d-%02d saros = %d/%d, want %d/%d",
|
|
testCase.year, testCase.month, testCase.day,
|
|
info.Saros.Series, info.Saros.Member, testCase.series, testCase.member)
|
|
}
|
|
}
|
|
}
|
|
|
|
func eclipseOnDateForTest(year, month, day int) (LunarEclipseInfo, bool) {
|
|
return LunarEclipseOnDate(time.Date(year, time.Month(month), day, 12, 0, 0, 0, time.UTC))
|
|
}
|