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