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