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
+87
View File
@@ -0,0 +1,87 @@
package basic
import (
"math"
"testing"
"time"
)
func TestOccultationStationCacheRecyclingPreservesContactModel(t *testing.T) {
context := newOccultationRiseSetContext(2451545, 10, 5, 384000, 10.5, 5.25, 1e9, 60000)
uncached := context
uncached.states = nil
for pass := 0; pass < 2; pass++ {
for index := 0; index < 4*occultationRiseSetStateCacheMaximumEntries; index++ {
lon, lat := float64(index*7-110), float64(index-40)
for _, total := range []bool{false, true} {
actual, reference := context, uncached
if total {
actual, reference = actual.withInternalContact(), reference.withInternalContact()
}
if got, want := actual.stateAt(lon, lat), reference.stateAt(lon, lat); got != want {
t.Fatalf("cached station differs: got=%+v want=%+v", got, want)
}
}
if len(context.states) > occultationRiseSetStateCacheMaximumEntries {
t.Fatal("station cache exceeded bound")
}
}
}
}
func TestOccultationEnvelopePredictionRequiresExactCorrection(t *testing.T) {
start := time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)
events, err := FindBestPlanetOccultations(start, start.Add(48*time.Hour), OccultationSaturn, OccultationSearchOptions{})
if err != nil || len(events) != 1 {
t.Fatalf("events=%d err=%v", len(events), err)
}
tt := occultationTimeToTT(events[0].Greatest)
config, _ := planetOccultationConfigFor(OccultationSaturn)
event := newPlanetOccultationEventCache(config)
event.prepareLocalEphemeris(tt)
north, _, width, ok := occultationPathLimitsAndWidthForFrame(tt, event.outerFrameAt)
if !ok {
t.Fatal("missing limit seed")
}
seed := occultationPathPointFromVector(tt, north, width, time.UTC)
sample, ok := occultationStationCorrectEnvelopePoint(tt, seed, event.riseSetContextAt, false, time.UTC)
if !ok {
t.Fatal("missing exact envelope seed")
}
model := occultationStationEnvelopeModel{kind: occultationStationContactEnvelope}
exact := newOccultationRiseSetEvaluationCache(event.riseSetContextAt)
state, ok := occultationStationEnvelopeArcStateAt(sample.point, tt, exact, model)
if !ok {
t.Fatal("missing envelope tangent")
}
predictor := state.coordinates
for index := range predictor {
predictor[index] += 0.2 * state.tangent[index]
}
want, _, ok := occultationStationCorrectEnvelopeArc(predictor, state.tangent, tt, exact, model, width, time.UTC)
if !ok {
t.Fatal("exact correction failed")
}
for _, invalid := range []bool{false, true} {
candidateCalls := 0
cache := newOccultationRiseSetEvaluationCacheWithCandidate(event.riseSetContextAt, func(at float64) occultationRiseSetContext {
candidateCalls++
if invalid {
return occultationRiseSetContext{}
}
return event.candidateRiseSetContextAt(at)
})
got, _, ok := occultationStationCorrectEnvelopeArc(predictor, state.tangent, tt, cache, model, width, time.UTC)
if !ok || candidateCalls == 0 {
t.Fatalf("candidate correction ok=%v calls=%d invalid=%v", ok, candidateCalls, invalid)
}
if distance := occultationPathDistanceKM(want.point, got.point); distance > 0.005 {
t.Fatalf("candidate displaced exact root by %v km", distance)
}
evaluation := exact.evaluation(centerTimeTT(got.point.Time))
residual, ok := model.residual(evaluation, got.point.Longitude, got.point.Latitude)
if !ok || math.Abs(residual[0]) > model.valueTolerance() || math.Abs(residual[1]) > model.derivativeTolerance() {
t.Fatalf("accepted inexact root %v", residual)
}
}
}