497 lines
14 KiB
Go
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,
|
||
|
|
}
|
||
|
|
}
|