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
+88 -1
View File
@@ -27,6 +27,43 @@ func TestSolarSarosInfoAgainstNASAExamples(t *testing.T) {
})
}
func TestSolarSarosInfoDerivedOutsideCatalogRange(t *testing.T) {
for _, tc := range []struct {
name string
date time.Time
}{
{name: "before catalog", date: time.Date(-3200, time.January, 1, 12, 0, 0, 0, time.UTC)},
{name: "after catalog", date: time.Date(3288, time.November, 15, 12, 0, 0, 0, time.UTC)},
} {
t.Run(tc.name, func(t *testing.T) {
info := ClosestSolarEclipse(tc.date)
if !info.HasSaros || info.Saros.Verified {
t.Fatalf("expected provisional solar Saros: has=%v saros=%+v", info.HasSaros, info.Saros)
}
if info.Saros.Member < 1 || info.Saros.Member > info.Saros.Count || info.Saros.Count < 1 {
t.Fatalf("invalid provisional solar Saros range: %+v", info.Saros)
}
})
}
}
func TestSarosInfoDerivedAtFarEpochs(t *testing.T) {
for _, tc := range []struct {
name string
date time.Time
}{
{name: "solar 6000 BCE", date: time.Date(-6000, time.January, 1, 12, 0, 0, 0, time.UTC)},
{name: "solar 8000 BCE", date: time.Date(-8000, time.January, 1, 12, 0, 0, 0, time.UTC)},
} {
t.Run(tc.name, func(t *testing.T) {
info := ClosestSolarEclipse(tc.date)
if !info.HasSaros || info.Saros.Verified || info.Saros.Member < 1 || info.Saros.Member > info.Saros.Count {
t.Fatalf("expected far-epoch provisional solar Saros: has=%v info=%+v", info.HasSaros, info.Saros)
}
})
}
}
func TestLocalSolarSarosMatchesGlobal(t *testing.T) {
date := time.Date(2009, 7, 22, 12, 0, 0, 0, time.FixedZone("CST", 8*3600))
global := ClosestSolarEclipse(date)
@@ -79,6 +116,26 @@ func TestLunarSarosInfoAgainstNASAExamples(t *testing.T) {
})
}
func TestLunarSarosInfoDerivedOutsideCatalogRange(t *testing.T) {
for _, tc := range []struct {
name string
date time.Time
}{
{name: "before catalog", date: time.Date(-3200, time.January, 1, 12, 0, 0, 0, time.UTC)},
{name: "after catalog", date: time.Date(3300, time.January, 1, 12, 0, 0, 0, time.UTC)},
} {
t.Run(tc.name, func(t *testing.T) {
info := ClosestLunarEclipse(tc.date)
if !info.HasSaros || info.Saros.Verified {
t.Fatalf("expected provisional lunar Saros: has=%v saros=%+v", info.HasSaros, info.Saros)
}
if info.Saros.Member < 1 || info.Saros.Member > info.Saros.Count || info.Saros.Count < 1 {
t.Fatalf("invalid provisional lunar Saros range: %+v", info.Saros)
}
})
}
}
func TestLunarSarosShallowFirstMemberChauvenet(t *testing.T) {
info := ClosestLunarEclipseChauvenet(time.Date(-780, 12, 13, 12, 0, 0, 0, time.UTC))
assertSarosInfo(t, info.HasSaros, info.Saros, 61, 1, 78)
@@ -139,6 +196,9 @@ func assertSarosInfo(t *testing.T, has bool, got SarosInfo, wantSeries, wantMemb
wantCount,
)
}
if !got.Verified {
t.Fatalf("expected authoritative Saros metadata: got %+v", got)
}
}
func assertSarosAnchorTable(t *testing.T, anchors []sarosMagic, seriesBase int) {
@@ -155,7 +215,7 @@ func assertSarosAnchorTable(t *testing.T, anchors []sarosMagic, seriesBase int)
t.Fatalf("series not strictly increasing: prev=%d current=%d", lastSeries, series)
}
lastSeries = series
if anchor.Count == 0 || int(anchor.Count) >= sarosWalkLimit {
if anchor.Count == 0 || int(anchor.Count) > sarosExtrapolationWindow {
t.Fatalf("unexpected anchor count for series %d: %d", series, anchor.Count)
}
dateKey := [3]int{int(anchor.Year), int(anchor.Month), int(anchor.Day)}
@@ -195,3 +255,30 @@ func assertSarosHeadOverrides(t *testing.T, overrides []sarosHeadOverride, ancho
}
}
}
func TestLunarSarosOverrideHeadIsAuthoritative(t *testing.T) {
// 月食 4 号在锚表与 override 里各有一个头,相差 9 个沙罗周期(−2646-10-06 与
// −2483-01-12 / MemberOffset +2)。两套格点同时生效时,相隔 127 年的两场月食都报
// Series=4、Member=3、Verified=true(重复成员号)。override 必须是唯一权威:窗口外
// 的那场按文档只报"没有沙罗信息",而不是另一套编号。
// Lunar series 4 has two heads nine cycles apart (-2646-10-06 in the anchor table and
// -2483-01-12 with MemberOffset +2 as an override). With both lattices live, two
// eclipses 127 years apart both reported Series=4, Member=3, Verified=true. The
// override must be the only authority: the out-of-window eclipse reports no Saros
// metadata instead of a second numbering.
anchored := ClosestLunarEclipse(time.Date(-2483, time.January, 11, 0, 0, 0, 0, time.UTC))
if !anchored.HasSaros {
t.Fatal("the override head eclipse must keep its Saros metadata")
}
if anchored.Saros.Series != 4 || anchored.Saros.Member != 3 || !anchored.Saros.Verified {
t.Fatalf("override head eclipse Saros=%+v, want series 4 member 3 verified", anchored.Saros)
}
earlier := ClosestLunarEclipse(time.Date(-2610, time.October, 27, 0, 0, 0, 0, time.UTC))
if earlier.HasSaros &&
earlier.Saros.Series == anchored.Saros.Series &&
earlier.Saros.Member == anchored.Saros.Member {
t.Fatalf("two eclipses 127 years apart share series %d member %d",
earlier.Saros.Series, earlier.Saros.Member)
}
}