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
+184
View File
@@ -0,0 +1,184 @@
package basic
import (
"math"
"testing"
)
func TestMovingDiskEventEngineSampleTimesKeepAnchorsAndBudget(t *testing.T) {
engine := movingDiskEventEngine{maxSampleCount: 7, reserveAnchorSlot: true, uniformOverflow: true}
times, step := engine.sampleTimes(0, 1, 0.37, 0.01)
if len(times) != 7 {
t.Fatalf("sample count = %d, want 7", len(times))
}
if times[0] != 0 || times[len(times)-1] != 1 {
t.Fatalf("sample endpoints = %.6f, %.6f, want 0 and 1", times[0], times[len(times)-1])
}
foundGreatest := false
for index, value := range times {
if index > 0 && value <= times[index-1] {
t.Fatalf("sample times are not strictly increasing: %v", times)
}
if math.Abs(value-0.37) <= 1e-12 {
foundGreatest = true
}
}
if !foundGreatest {
t.Fatalf("sample times omitted greatest: %v", times)
}
if step <= 0 {
t.Fatalf("effective step = %v, want positive", step)
}
}
func TestMovingDiskEventEngineWindowUsesCandidateAndExactPredicates(t *testing.T) {
engine := movingDiskEventEngine{
searchSpanDays: 1,
rangeStepDays: 0.1,
rootToleranceDays: 1e-9,
}
candidateCalls, exactCalls := 0, 0
candidate := func(tt float64) bool {
candidateCalls++
return tt >= -0.35 && tt <= 0.42
}
exact := func(tt float64) bool {
exactCalls++
return tt >= -0.3 && tt <= 0.4
}
start, end, ok := engine.window(0, -1, 1, candidate, exact)
if !ok {
t.Fatal("window returned not found")
}
if math.Abs(start+0.3) > 2e-8 || math.Abs(end-0.4) > 2e-8 {
t.Fatalf("window = %.12f..%.12f, want -0.3..0.4", start, end)
}
if candidateCalls == 0 || exactCalls == 0 {
t.Fatalf("candidate/exact calls = %d/%d, want both", candidateCalls, exactCalls)
}
}
func TestMovingDiskEventEngineGreatestFindsMinimumImpact(t *testing.T) {
engine := movingDiskEventEngine{greatestSpanDays: 0.75}
got := engine.greatest(0.1, -1, 1, func(tt float64) (float64, bool) {
return (tt - 0.23) * (tt - 0.23), true
}, 56)
if math.Abs(got-0.23) > 1e-8 {
t.Fatalf("greatest = %.12f, want 0.23", got)
}
}
func TestMovingDiskContactStateUsesSolarOuterAndInnerRadii(t *testing.T) {
state := movingDiskContactState{
separation: 1.0,
occultingOuterRadius: 0.6,
occultingInnerRadius: 0.4,
targetRadius: 0.5,
valid: true,
}
if got := state.externalContactGap(); math.Abs(got+0.1) > 1e-12 {
t.Fatalf("external gap = %.12f, want -0.1", got)
}
if got := state.internalContactGap(); math.Abs(got-0.9) > 1e-12 {
t.Fatalf("internal gap = %.12f, want 0.9", got)
}
if !movingDiskContactStateValid(1, 0.6, 0.4, 0.5) {
t.Fatal("finite disk state was rejected")
}
if movingDiskContactStateValid(math.NaN(), 0.6, 0.4, 0.5) {
t.Fatal("NaN disk state was accepted")
}
}
func TestMovingDiskEventEngineContactRootWalksBothDirections(t *testing.T) {
engine := movingDiskEventEngine{}
metric := func(tt float64) (float64, bool) {
return (tt + 0.3) * (tt - 0.4), true
}
for _, test := range []struct {
name string
direction float64
want float64
}{
{name: "forward", direction: 1, want: 0.4},
{name: "backward", direction: -1, want: -0.3},
} {
got, ok := engine.contactRoot(0, test.direction, 0.05, 1, 1e-9, metric, 64)
if !ok || math.Abs(got-test.want) > 2e-8 {
t.Errorf("%s contact root = %.12f, ok=%v, want %.3f", test.name, got, ok, test.want)
}
}
}
func TestMovingDiskContactEvaluatorCachesValidAndInvalidStates(t *testing.T) {
calls := 0
evaluator := newMovingDiskContactEvaluator(func(tt float64) (movingDiskContactState, bool) {
calls++
if tt < 0 {
return movingDiskContactState{}, false
}
state := movingDiskContactState{
separation: 1,
occultingOuterRadius: 0.6,
occultingInnerRadius: 0.6,
targetRadius: 0.2,
valid: true,
}
return state, true
})
if _, ok := evaluator.gap(0.25, false); !ok {
t.Fatal("first valid evaluation failed")
}
if _, ok := evaluator.gap(0.25, true); !ok {
t.Fatal("cached valid evaluation failed")
}
if _, ok := evaluator.gap(-0.25, false); ok {
t.Fatal("invalid evaluation was accepted")
}
if _, ok := evaluator.gap(-0.25, true); ok {
t.Fatal("cached invalid evaluation was accepted")
}
if calls != 2 {
t.Fatalf("evaluation calls = %d, want 2 after valid/invalid cache hits", calls)
}
}
func TestMovingDiskContactEvaluatorPrimeAvoidsCallback(t *testing.T) {
calls := 0
evaluator := newMovingDiskContactEvaluator(func(tt float64) (movingDiskContactState, bool) {
calls++
return movingDiskContactState{}, false
})
state := movingDiskContactState{
separation: 1,
occultingOuterRadius: 0.6,
occultingInnerRadius: 0.6,
targetRadius: 0.2,
valid: true,
}
evaluator.prime(0.5, state, true)
value, ok := evaluator.gap(0.5, false)
if !ok || math.Abs(value-0.2) > 1e-12 {
t.Fatalf("primed external gap = %.12f, ok=%v", value, ok)
}
if calls != 0 {
t.Fatalf("callback calls = %d, want 0 for primed TT", calls)
}
}
func TestMovingDiskContactCacheClearsAtBoundedCapacity(t *testing.T) {
cache := &movingDiskContactCache{maxEntries: 2}
state := movingDiskContactState{valid: true}
cache.store(1, state, true)
cache.store(2, state, true)
cache.store(3, state, true)
if len(cache.entries) != 1 {
t.Fatalf("cache size = %d, want 1 after bounded clear", len(cache.entries))
}
if _, _, hit := cache.lookup(1); hit {
t.Fatal("old entry survived bounded cache clear")
}
if _, _, hit := cache.lookup(3); !hit {
t.Fatal("new entry missing after bounded cache clear")
}
}