feat: 完善日月食与月掩几何链路并扩展历法接口

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