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

497 lines
14 KiB
Go

package basic
import (
"math"
"sort"
)
// movingDiskEventEngine contains the time-domain part shared by solar
// eclipses and lunar occultations. Geometry remains in the caller: a solar
// adapter evaluates the Bessel projection, while an occultation adapter
// evaluates the Earth-vector frame. Keeping that boundary explicit is what
// lets the solar implementation remain the numerical baseline.
type movingDiskEventEngine struct {
maxSampleCount int
searchSpanDays float64
rangeStepDays float64
rootToleranceDays float64
greatestSpanDays float64
greatestToleranceDays float64
reserveAnchorSlot bool
uniformOverflow bool
indexedSampleTimes bool
}
// movingDiskContactState is the dimensionless circular-disk model shared by
// solar eclipse and lunar occultation adapters. The radii and separation may
// be radians, degrees, or arcseconds, but all four values must use the same
// unit. Solar eclipses provide distinct outer and inner occulting radii;
// finite-planet occultations normally use the same lunar radius for both.
type movingDiskContactState struct {
separation float64
occultingOuterRadius float64
occultingInnerRadius float64
targetRadius float64
valid bool
}
// movingDiskEventCacheMaximumEntries is shared by event-local caches so a
// dense path cannot grow without bound in native or TinyGo/WASM execution.
const movingDiskEventCacheMaximumEntries = 2048
const movingDiskContactCacheMaximumEntries = movingDiskEventCacheMaximumEntries
// movingDiskContactCache stores the disk state for one event evaluation. The
// valid bit is part of the entry so failed ephemeris evaluations are cached as
// well; otherwise a pair of external/internal roots can repeat the same
// invalid star or planet calculation indefinitely. Entries are keyed by the
// exact TT bits because root refinement intentionally revisits exact endpoints.
type movingDiskContactCache struct {
entries map[uint64]movingDiskContactCacheEntry
maxEntries int
}
type movingDiskContactCacheEntry struct {
state movingDiskContactState
ok bool
}
func newMovingDiskContactCache() *movingDiskContactCache {
return &movingDiskContactCache{
entries: make(map[uint64]movingDiskContactCacheEntry),
maxEntries: movingDiskContactCacheMaximumEntries,
}
}
func (cache *movingDiskContactCache) lookup(tt float64) (movingDiskContactState, bool, bool) {
if cache == nil || cache.entries == nil {
return movingDiskContactState{}, false, false
}
entry, ok := cache.entries[math.Float64bits(tt)]
if !ok {
return movingDiskContactState{}, false, false
}
return entry.state, entry.ok, true
}
func (cache *movingDiskContactCache) store(tt float64, state movingDiskContactState, ok bool) {
if cache == nil {
return
}
if cache.entries == nil {
cache.entries = make(map[uint64]movingDiskContactCacheEntry)
}
limit := cache.maxEntries
if limit <= 0 {
limit = movingDiskContactCacheMaximumEntries
}
key := math.Float64bits(tt)
if _, exists := cache.entries[key]; !exists && len(cache.entries) >= limit {
// Contact roots are local to one event and are naturally clustered in
// time. Clearing the bounded table is cheaper and more predictable
// than maintaining an eviction list in TinyGo/WASM.
for key := range cache.entries {
delete(cache.entries, key)
}
}
cache.entries[key] = movingDiskContactCacheEntry{state: state, ok: ok}
}
// movingDiskContactEvaluator adapts an event-specific geometry calculation to
// the shared contact root engine. Its callback must be pure for a given TT;
// all observer/configuration values belong to the evaluator closure.
type movingDiskContactEvaluator struct {
evaluate func(float64) (movingDiskContactState, bool)
cache *movingDiskContactCache
}
func newMovingDiskContactEvaluator(
evaluate func(float64) (movingDiskContactState, bool),
) *movingDiskContactEvaluator {
return &movingDiskContactEvaluator{
evaluate: evaluate,
cache: newMovingDiskContactCache(),
}
}
func (evaluator *movingDiskContactEvaluator) stateAt(tt float64) (movingDiskContactState, bool) {
if evaluator == nil || evaluator.evaluate == nil || !finiteMovingDiskValue(tt) {
return movingDiskContactState{}, false
}
if state, ok, hit := evaluator.cache.lookup(tt); hit {
return state, ok
}
state, ok := evaluator.evaluate(tt)
if !ok {
state = movingDiskContactState{}
}
evaluator.cache.store(tt, state, ok)
return state, ok
}
// prime inserts a state already computed by the caller. Greatest-point
// evaluation often precedes both external and internal contact roots; priming
// avoids evaluating that same TT a second time while preserving the callback
// as the source of truth for all other samples.
func (evaluator *movingDiskContactEvaluator) prime(
tt float64,
state movingDiskContactState,
ok bool,
) {
if evaluator == nil || !finiteMovingDiskValue(tt) {
return
}
evaluator.cache.store(tt, state, ok)
}
func (evaluator *movingDiskContactEvaluator) gap(tt float64, internal bool) (float64, bool) {
state, ok := evaluator.stateAt(tt)
if !ok || !state.valid {
return 0, false
}
if internal {
return state.internalContactGap(), true
}
return state.externalContactGap(), true
}
func (state movingDiskContactState) externalContactGap() float64 {
if !state.valid {
return math.NaN()
}
return state.separation - state.occultingOuterRadius - state.targetRadius
}
func (state movingDiskContactState) internalContactGap() float64 {
if !state.valid {
return math.NaN()
}
return state.separation - math.Abs(state.occultingInnerRadius-state.targetRadius)
}
func movingDiskContactStateValid(separation, outerRadius, innerRadius, targetRadius float64) bool {
return finiteMovingDiskValue(separation) && separation >= 0 &&
finiteMovingDiskValue(outerRadius) && outerRadius > 0 &&
finiteMovingDiskValue(innerRadius) && innerRadius > 0 &&
finiteMovingDiskValue(targetRadius) && targetRadius >= 0
}
func finiteMovingDiskValue(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
func (engine movingDiskEventEngine) sampleTimes(
start, end, greatest, requestedStep float64,
) ([]float64, float64) {
if end < start {
start, end = end, start
}
maximum := engine.maxSampleCount
if maximum < 3 {
maximum = 3
}
duration := end - start
if duration <= 0 {
return []float64{start}, requestedStep
}
step := requestedStep
if step <= 0 || math.IsNaN(step) || math.IsInf(step, 0) {
step = duration
}
baseSampleCount := int(math.Ceil(duration/step)) + 1
if engine.reserveAnchorSlot {
baseSampleCount++
}
if baseSampleCount > maximum && engine.uniformOverflow {
interiorCount := maximum - 3
if interiorCount < 0 {
interiorCount = 0
}
bounded := make([]float64, 0, maximum)
bounded = append(bounded, start, greatest, end)
for index := 1; index <= interiorCount; index++ {
bounded = append(bounded, start+duration*float64(index)/float64(interiorCount+1))
}
sort.Float64s(bounded)
return movingDiskUniqueTimes(bounded), step
}
if baseSampleCount > maximum {
step = duration / float64(maximum-1)
}
times := []float64{start, greatest, end}
if engine.indexedSampleTimes {
for index := 1; ; index++ {
current := start + float64(index)*step
if current >= end {
break
}
times = append(times, current)
}
} else {
for current := start + step; current < end; current += step {
times = append(times, current)
}
}
sort.Float64s(times)
return movingDiskUniqueTimes(times), step
}
func (engine movingDiskEventEngine) window(
seed, start, end float64,
candidateAt, exactAt func(float64) bool,
) (float64, float64, bool) {
if exactAt == nil {
return 0, 0, false
}
left := start
right := end
if engine.searchSpanDays > 0 {
left = math.Max(left, seed-engine.searchSpanDays)
right = math.Min(right, seed+engine.searchSpanDays)
}
if right <= left {
return 0, 0, false
}
if candidateAt == nil {
candidateAt = exactAt
}
step := engine.rangeStepDays
if step <= 0 || math.IsNaN(step) || math.IsInf(step, 0) {
step = right - left
}
first := math.NaN()
previous := left
if candidateAt(previous) && exactAt(previous) {
first = previous
}
if math.IsNaN(first) {
for current := left + step; current <= right; current += step {
current = math.Min(current, right)
if candidateAt(current) {
first = engine.refineTransition(previous, current, exactAt, false)
break
}
previous = current
}
}
if math.IsNaN(first) {
return 0, 0, false
}
last := first
previous = first
for current := first + step; current <= right; current += step {
current = math.Min(current, right)
if !candidateAt(current) {
last = engine.refineTransition(previous, current, exactAt, true)
return first, last, true
}
last = current
previous = current
}
return first, right, true
}
func (engine movingDiskEventEngine) refineTransition(
left, right float64,
predicate func(float64) bool,
trueToFalse bool,
) float64 {
leftOK := predicate(left)
tolerance := engine.rootToleranceDays
if tolerance <= 0 || math.IsNaN(tolerance) || math.IsInf(tolerance, 0) {
tolerance = 1e-10
}
for iteration := 0; iteration < 48 && math.Abs(right-left) > tolerance; iteration++ {
middle := (left + right) / 2
middleOK := predicate(middle)
if trueToFalse {
if middleOK {
left = middle
} else {
right = middle
}
continue
}
if middleOK {
right = middle
} else {
left = middle
}
}
if trueToFalse {
return left
}
if leftOK {
return left
}
return right
}
// greatest returns the minimum impact value in the event-local interval.
// The callback returns (impact, valid); invalid states are treated as +Inf.
func (engine movingDiskEventEngine) greatest(
seed, start, end float64,
impactAt func(float64) (float64, bool),
iterations int,
) float64 {
if impactAt == nil {
return seed
}
left := start
right := end
span := engine.greatestSpanDays
if span > 0 {
left = math.Max(left, seed-span)
right = math.Min(right, seed+span)
}
if right <= left {
return seed
}
if iterations <= 0 {
iterations = 56
}
const goldenRatio = 0.6180339887498949
x1 := right - goldenRatio*(right-left)
x2 := left + goldenRatio*(right-left)
f1 := movingDiskImpact(impactAt, x1)
f2 := movingDiskImpact(impactAt, x2)
for iteration := 0; iteration < iterations &&
(engine.greatestToleranceDays <= 0 || right-left > engine.greatestToleranceDays); iteration++ {
if f1 > f2 {
left = x1
x1, f1 = x2, f2
x2 = left + goldenRatio*(right-left)
f2 = movingDiskImpact(impactAt, x2)
continue
}
right = x2
x2, f2 = x1, f1
x1 = right - goldenRatio*(right-left)
f1 = movingDiskImpact(impactAt, x1)
}
return (left + right) / 2
}
// contactRoot finds one external or internal disk-contact root by walking
// away from greatest and then bisecting the first valid sign change. The
// callback may reject an ephemeris sample; rejected samples are skipped while
// searching, but an invalid value inside a confirmed bisection bracket aborts
// that root rather than inventing a crossing.
func (engine movingDiskEventEngine) contactRoot(
greatest, direction, stepDays, spanDays, tolerance float64,
metric func(float64) (float64, bool),
iterations int,
) (float64, bool) {
if metric == nil || (direction != -1 && direction != 1) ||
stepDays <= 0 || spanDays <= 0 || tolerance <= 0 {
return 0, false
}
nearTT := greatest
nearValue, nearOK := metric(nearTT)
if !nearOK || !finiteMovingDiskValue(nearValue) || nearValue > 0 {
return 0, false
}
maxSteps := int(math.Ceil(spanDays / stepDays))
if maxSteps < 1 {
maxSteps = 1
}
for index := 1; index <= maxSteps; index++ {
farTT := greatest + direction*float64(index)*stepDays
farValue, farOK := metric(farTT)
if !farOK || !finiteMovingDiskValue(farValue) {
continue
}
if farValue < 0 {
nearTT, nearValue = farTT, farValue
continue
}
return movingDiskContactBracketRoot(
nearTT, farTT, nearValue, farValue, metric, tolerance, iterations,
)
}
return 0, false
}
func movingDiskContactBracketRoot(
left, right, leftValue, rightValue float64,
metric func(float64) (float64, bool),
tolerance float64,
iterations int,
) (float64, bool) {
if left > right {
left, right = right, left
leftValue, rightValue = rightValue, leftValue
}
if !finiteMovingDiskValue(leftValue) || !finiteMovingDiskValue(rightValue) ||
leftValue*rightValue > 0 {
return 0, false
}
if leftValue == 0 {
return left, true
}
if rightValue == 0 {
return right, true
}
if iterations <= 0 {
iterations = 64
}
for index := 0; index < iterations && right-left > tolerance; index++ {
middle := (left + right) / 2
middleValue, ok := metric(middle)
if !ok || !finiteMovingDiskValue(middleValue) {
return 0, false
}
if leftValue*middleValue <= 0 {
right, rightValue = middle, middleValue
} else {
left, leftValue = middle, middleValue
}
}
return (left + right) / 2, true
}
func movingDiskImpact(impactAt func(float64) (float64, bool), tt float64) float64 {
value, ok := impactAt(tt)
if !ok || math.IsNaN(value) || math.IsInf(value, 0) {
return math.Inf(1)
}
return value
}
func movingDiskUniqueTimes(times []float64) []float64 {
if len(times) < 2 {
return times
}
unique := times[:1]
for _, current := range times[1:] {
if math.Abs(current-unique[len(unique)-1]) <= 1e-10 {
continue
}
unique = append(unique, current)
}
return unique
}
func solarEclipseMovingDiskEngine() movingDiskEventEngine {
return movingDiskEventEngine{
maxSampleCount: solarEclipsePathMaxSampleCount,
rootToleranceDays: solarEclipseShadowContactToleranceDays,
greatestToleranceDays: localSolarEclipseGreatestTolerance,
}
}
func occultationMovingDiskEngine() movingDiskEventEngine {
return movingDiskEventEngine{
maxSampleCount: occultationPathMaxSampleCount,
searchSpanDays: occultationPathSearchSpanDays,
rangeStepDays: occultationPathRangeStepDays,
rootToleranceDays: occultationPathRootToleranceDays,
greatestSpanDays: 0.75,
reserveAnchorSlot: true,
uniformOverflow: true,
indexedSampleTimes: true,
}
}