Files
astro/basic/occultation_rise_set.go
T

1056 lines
41 KiB
Go
Raw Normal View History

package basic
import (
"math"
"time"
)
const (
occultationRiseSetBoundaryPoints = 180
occultationRiseSetDerivativeStepDays = 5.0 / 86400.0
// A polar phase fold can create or remove a root branch inside one coarse
// rise/set interval. Refine only those topology-changing intervals rather
// than lowering the global sampling step for every event.
occultationRiseSetAdaptiveMaximumPasses = 1
occultationRiseSetAdaptiveMinimumStepDays = 5.0 / 86400.0
occultationRiseSetAdaptiveMaximumIntervals = 64
occultationRiseSetFoldRootToleranceDays = 1e-10
occultationRiseSetJunctionDerivativeTolerance = 1e-6
// Beyond this point, recomputing the small vector state costs less than growing every per-TT map.
occultationRiseSetStateCacheMaximumEntries = 8
// 48 h 窗口按 1 分钟采样需要 2880 个时刻、每个时刻三个上下文(中心/前/后),
// 4096 个槽位会在事件中途反复整表清空并重算精确星历。
// A 48 h window sampled every minute needs 2880 instants with three contexts each
// (center/before/after); 4096 slots cleared the whole table repeatedly mid-event and
// recomputed the exact ephemerides it had just dropped.
occultationRiseSetEvaluationCacheMaximumEntries = 16384
occultationRiseSetTimeEpsilonDays = 1e-8
occultationRiseSetTargetSpacingKM = 100.0
occultationTopocentricEarthRadiusKM = 6378.14
occultationTopocentricEarthPolarRadiusKM = 6356.755
)
// 站心赤经赤纬的历史公式使用固定地平视差常数;保留该尺度可使向量算法与既有结果数值等价。
// The legacy topocentric RA/Dec formula uses a fixed horizontal-parallax constant; retaining that scale keeps the vector implementation numerically equivalent.
var occultationLegacyParallaxRadiusKM = math.Sin(0.0024427777777*rad) * occultationPathAstronomicalUnitKM
type occultationRiseSetBody struct {
direction occultationPathVector
positionKM occultationPathVector
distanceKM float64
}
type occultationRiseSetContext struct {
tt float64
siderealDegrees float64
moonRA float64
moonDec float64
moon occultationRiseSetBody
target occultationRiseSetBody
targetRadiusKM float64
internalContact bool
valid bool
states map[occultationRiseSetStateKey]occultationRiseSetState
}
type occultationRiseSetStateKey struct {
longitude uint64
latitude uint64
}
type occultationRiseSetState struct {
// contactMetric is selected for the context that reads the cached state.
// The two underlying metrics are retained so external and internal contact
// contexts can share the expensive topocentric geometry without sharing the
// wrong contact equation.
contactMetric float64
externalContactMetric float64
internalContactMetric float64
separationSquared float64
moonAltitude float64
valid bool
}
type occultationRiseSetEvaluation struct {
tt float64
center occultationRiseSetContext
before occultationRiseSetContext
after occultationRiseSetContext
}
type occultationRiseSetCurveKey struct {
phase RiseSetPhase
direction RiseSetDirection
}
type occultationRiseSetTrack struct {
segments [][]OccultationPathPoint
}
type occultationRiseSetContextFunc func(float64) occultationRiseSetContext
// occultationRiseSetEvaluationCache keeps ephemeris contexts and their
// center/before/after derivative bundle at the event level. Root refinements
// revisit the same TT values many times, especially while joining branches.
type occultationRiseSetEvaluationCache struct {
contextAt occultationRiseSetContextFunc
candidateContextAt occultationRiseSetContextFunc
contexts map[uint64]occultationRiseSetContext
candidateContexts map[uint64]occultationRiseSetContext
evaluations map[uint64]occultationRiseSetEvaluation
candidateEvaluations map[uint64]occultationRiseSetEvaluation
}
func newOccultationRiseSetEvaluationCache(contextAt occultationRiseSetContextFunc) *occultationRiseSetEvaluationCache {
return newOccultationRiseSetEvaluationCacheWithCandidate(contextAt, nil)
}
func newOccultationRiseSetEvaluationCacheWithCandidate(
contextAt, candidateContextAt occultationRiseSetContextFunc,
) *occultationRiseSetEvaluationCache {
return &occultationRiseSetEvaluationCache{
contextAt: contextAt,
candidateContextAt: candidateContextAt,
contexts: make(map[uint64]occultationRiseSetContext),
candidateContexts: make(map[uint64]occultationRiseSetContext),
evaluations: make(map[uint64]occultationRiseSetEvaluation),
candidateEvaluations: make(map[uint64]occultationRiseSetEvaluation),
}
}
func (cache *occultationRiseSetEvaluationCache) context(tt float64) occultationRiseSetContext {
key := math.Float64bits(tt)
if context, ok := cache.contexts[key]; ok {
return context
}
context := cache.contextAt(tt)
if len(cache.contexts) >= occultationRiseSetEvaluationCacheMaximumEntries {
clearOccultationRiseSetContexts(cache.contexts)
clearOccultationRiseSetEvaluations(cache.evaluations)
}
cache.contexts[key] = context
return context
}
func (cache *occultationRiseSetEvaluationCache) candidateContext(tt float64) occultationRiseSetContext {
if cache.candidateContextAt == nil {
return cache.context(tt)
}
key := math.Float64bits(tt)
if context, ok := cache.candidateContexts[key]; ok {
return context
}
context := cache.candidateContextAt(tt)
if len(cache.candidateContexts) >= occultationRiseSetEvaluationCacheMaximumEntries {
clearOccultationRiseSetContexts(cache.candidateContexts)
clearOccultationRiseSetEvaluations(cache.candidateEvaluations)
}
cache.candidateContexts[key] = context
return context
}
func (cache *occultationRiseSetEvaluationCache) evaluation(tt float64) occultationRiseSetEvaluation {
return cache.evaluationAt(tt, false)
}
func (cache *occultationRiseSetEvaluationCache) candidateEvaluation(tt float64) occultationRiseSetEvaluation {
return cache.evaluationAt(tt, true)
}
func (cache *occultationRiseSetEvaluationCache) candidateOnly() *occultationRiseSetEvaluationCache {
if cache == nil || cache.candidateContextAt == nil {
return cache
}
return &occultationRiseSetEvaluationCache{
contextAt: cache.candidateContextAt,
contexts: cache.candidateContexts,
evaluations: cache.candidateEvaluations,
}
}
func (cache *occultationRiseSetEvaluationCache) evaluationAt(tt float64, candidate bool) occultationRiseSetEvaluation {
key := math.Float64bits(tt)
evaluations := cache.evaluations
context := cache.context
if candidate && cache.candidateContextAt != nil {
evaluations = cache.candidateEvaluations
context = cache.candidateContext
}
if evaluation, ok := evaluations[key]; ok {
return evaluation
}
evaluation := occultationRiseSetEvaluation{
tt: tt,
center: context(tt),
before: context(tt - occultationRiseSetDerivativeStepDays),
after: context(tt + occultationRiseSetDerivativeStepDays),
}
if len(evaluations) >= occultationRiseSetEvaluationCacheMaximumEntries {
clearOccultationRiseSetEvaluations(evaluations)
if candidate {
clearOccultationRiseSetContexts(cache.candidateContexts)
} else {
clearOccultationRiseSetContexts(cache.contexts)
}
}
evaluations[key] = evaluation
return evaluation
}
func clearOccultationRiseSetContexts(values map[uint64]occultationRiseSetContext) {
for key := range values {
delete(values, key)
}
}
func clearOccultationRiseSetEvaluations(values map[uint64]occultationRiseSetEvaluation) {
for key := range values {
delete(values, key)
}
}
func newOccultationRiseSetContext(
tt, moonRA, moonDec, moonDistanceKM,
targetRA, targetDec, targetDistanceKM, targetRadiusKM float64,
) occultationRiseSetContext {
moon := newOccultationRiseSetBody(moonRA, moonDec, moonDistanceKM)
target := newOccultationRiseSetBody(targetRA, targetDec, targetDistanceKM)
return occultationRiseSetContext{
tt: tt,
siderealDegrees: ApparentSiderealTime(TT2UT1(tt)) * 15,
moonRA: moonRA,
moonDec: moonDec,
moon: moon,
target: target,
targetRadiusKM: targetRadiusKM,
valid: finite(moonRA) && finite(moonDec) && finite(moonDistanceKM) && moonDistanceKM > 0 &&
finite(targetRA) && finite(targetDec) && finite(targetDistanceKM) && targetDistanceKM >= 0 &&
finite(targetRadiusKM) && targetRadiusKM >= 0,
states: make(map[occultationRiseSetStateKey]occultationRiseSetState),
}
}
func (context occultationRiseSetContext) withInternalContact() occultationRiseSetContext {
context.internalContact = true
return context
}
func newOccultationRiseSetContextFromVectors(
tt float64,
moonXYZ, targetXYZ [3]float64,
targetAtFiniteDistance bool,
targetRadiusKM float64,
) occultationRiseSetContext {
moon, moonRA, moonDec, moonOK := occultationRiseSetBodyFromVector(moonXYZ, true)
target, _, _, targetOK := occultationRiseSetBodyFromVector(targetXYZ, targetAtFiniteDistance)
return occultationRiseSetContext{
tt: tt,
siderealDegrees: ApparentSiderealTime(TT2UT1(tt)) * 15,
moonRA: moonRA,
moonDec: moonDec,
moon: moon,
target: target,
targetRadiusKM: targetRadiusKM,
valid: moonOK && targetOK && finite(targetRadiusKM) && targetRadiusKM >= 0,
states: make(map[occultationRiseSetStateKey]occultationRiseSetState),
}
}
func occultationRiseSetBodyFromVector(
xyz [3]float64,
atFiniteDistance bool,
) (occultationRiseSetBody, float64, float64, bool) {
vector := occultationPathVector{x: xyz[0], y: xyz[1], z: xyz[2]}
distanceKM := occultationPathNorm(vector)
if !finite(distanceKM) || distanceKM <= 0 {
return occultationRiseSetBody{}, 0, 0, false
}
direction := occultationPathScale(vector, 1/distanceKM)
ra := normalizeRA(math.Atan2(direction.y, direction.x) / rad)
dec := math.Asin(math.Max(-1, math.Min(1, direction.z))) / rad
body := occultationRiseSetBody{direction: direction}
if atFiniteDistance {
body.positionKM = vector
body.distanceKM = distanceKM
}
return body, ra, dec, finite(ra) && finite(dec)
}
func newOccultationRiseSetBody(ra, dec, distanceKM float64) occultationRiseSetBody {
direction := occultationPathRaDecVector(ra, dec, 1)
body := occultationRiseSetBody{direction: direction, distanceKM: distanceKM}
if distanceKM > 0 {
body.positionKM = occultationPathScale(direction, distanceKM)
}
return body
}
func (context occultationRiseSetContext) stateAt(longitude, latitude float64) occultationRiseSetState {
key := occultationRiseSetStateKey{math.Float64bits(longitude), math.Float64bits(latitude)}
if state, ok := context.states[key]; ok {
return state.withContactMetric(context.internalContact)
}
if !context.valid {
return context.storeState(key, occultationRiseSetState{})
}
observerParallaxKM, observerDistanceKM, zenith := occultationRiseSetObserverVectors(
context.siderealDegrees, longitude, latitude,
)
moonDirection := occultationRiseSetTopocentricDirection(context.moon, observerParallaxKM)
targetDirection := occultationRiseSetTopocentricDirection(context.target, observerParallaxKM)
moonDistanceKM := occultationRiseSetTopocentricDistance(context.moon, observerDistanceKM)
if !finite(moonDistanceKM) || moonDistanceKM <= moonEquatorialRadiusKM {
return context.storeState(key, occultationRiseSetState{})
}
moonRadius := angularSemidiameterArcsec(moonEquatorialRadiusKM, moonDistanceKM) / 3600
targetRadius := 0.0
if context.targetRadiusKM > 0 {
targetDistanceKM := occultationRiseSetTopocentricDistance(context.target, observerDistanceKM)
if !finite(targetDistanceKM) || targetDistanceKM <= context.targetRadiusKM {
return context.storeState(key, occultationRiseSetState{})
}
targetRadius = angularSemidiameterArcsec(context.targetRadiusKM, targetDistanceKM) / 3600
}
cosSeparation := math.Max(-1, math.Min(1, occultationPathDot(moonDirection, targetDirection)))
separationRad := math.Acos(cosSeparation)
separation := separationRad / rad
moonAltitude := math.Asin(math.Max(-1, math.Min(1, occultationPathDot(moonDirection, zenith)))) / rad
contactState := movingDiskContactState{
separation: separation,
occultingOuterRadius: moonRadius,
occultingInnerRadius: moonRadius,
targetRadius: targetRadius,
valid: movingDiskContactStateValid(
separation, moonRadius, moonRadius, targetRadius,
),
}
externalContactMetric := contactState.externalContactGap()
internalContactMetric := contactState.internalContactGap()
return context.storeState(key, occultationRiseSetState{
externalContactMetric: externalContactMetric,
internalContactMetric: internalContactMetric,
separationSquared: 2 - 2*cosSeparation,
moonAltitude: moonAltitude,
valid: contactState.valid && finite(moonAltitude),
})
}
// storeState 记住一个站点状态;表满时整表清空后复用,避免为每个解分配新 map。
func (context occultationRiseSetContext) storeState(
key occultationRiseSetStateKey,
state occultationRiseSetState,
) occultationRiseSetState {
if context.states != nil {
// Newton steps reuse a few nearby stations, not the full history.
// Keep the small map allocation and recycle it as the solve moves.
if len(context.states) >= occultationRiseSetStateCacheMaximumEntries {
for cachedKey := range context.states {
delete(context.states, cachedKey)
}
}
context.states[key] = state
}
return state.withContactMetric(context.internalContact)
}
func (state occultationRiseSetState) withContactMetric(internal bool) occultationRiseSetState {
if internal {
state.contactMetric = state.internalContactMetric
} else {
state.contactMetric = state.externalContactMetric
}
return state
}
func (context occultationRiseSetContext) moonHorizonResidual(longitude, latitude float64) (float64, bool) {
if !context.valid || context.moon.distanceKM <= 0 {
return 0, false
}
latitudeRad := latitude * rad
theta := (context.siderealDegrees + longitude) * rad
sinLatitude, cosLatitude := math.Sincos(latitudeRad)
sinTheta, cosTheta := math.Sincos(theta)
moonDotZenith := cosLatitude*(context.moon.positionKM.x*cosTheta+context.moon.positionKM.y*sinTheta) +
context.moon.positionKM.z*sinLatitude
polarRatio := occultationTopocentricEarthPolarRadiusKM / occultationTopocentricEarthRadiusKM
shapeDotZenith := math.Sqrt(cosLatitude*cosLatitude + polarRatio*polarRatio*sinLatitude*sinLatitude)
residual := moonDotZenith - occultationLegacyParallaxRadiusKM*shapeDotZenith
return residual, finite(residual)
}
func occultationRiseSetObserverVectors(
siderealDegrees, longitude, latitude float64,
) (occultationPathVector, occultationPathVector, occultationPathVector) {
latitudeRad := latitude * rad
theta := (siderealDegrees + longitude) * rad
sinLatitude, cosLatitude := math.Sincos(latitudeRad)
sinTheta, cosTheta := math.Sincos(theta)
polarRatio := occultationTopocentricEarthPolarRadiusKM / occultationTopocentricEarthRadiusKM
u := math.Atan(polarRatio * math.Tan(latitudeRad))
sinU, cosU := math.Sincos(u)
shape := occultationPathVector{
x: cosU * cosTheta,
y: cosU * sinTheta,
z: polarRatio * sinU,
}
zenith := occultationPathVector{x: cosLatitude * cosTheta, y: cosLatitude * sinTheta, z: sinLatitude}
return occultationPathScale(shape, occultationLegacyParallaxRadiusKM),
occultationPathScale(shape, occultationTopocentricEarthRadiusKM), zenith
}
func occultationRiseSetTopocentricDirection(
body occultationRiseSetBody,
observerKM occultationPathVector,
) occultationPathVector {
if body.distanceKM <= 0 {
return body.direction
}
return occultationPathUnit(occultationPathSub(body.positionKM, observerKM))
}
func occultationRiseSetTopocentricDistance(
body occultationRiseSetBody,
observerKM occultationPathVector,
) float64 {
if body.distanceKM <= 0 {
return math.Inf(1)
}
return occultationPathNorm(occultationPathSub(body.positionKM, observerKM))
}
func occultationRiseSetCurves(
startTT, endTT, greatestTT float64,
options OccultationPathOptions,
location *time.Location,
contextAt occultationRiseSetContextFunc,
) []OccultationRiseSetCurve {
cache := newOccultationRiseSetEvaluationCache(contextAt)
return occultationRiseSetCurvesWithCache(startTT, endTT, greatestTT, options, location, cache)
}
func occultationRiseSetCurvesWithCache(
startTT, endTT, greatestTT float64,
options OccultationPathOptions,
location *time.Location,
cache *occultationRiseSetEvaluationCache,
) []OccultationRiseSetCurve {
curves, _, _ := occultationRiseSetCurvesWithRecoveryReport(
startTT, endTT, greatestTT, options, location, cache,
)
return curves
}
// occultationRiseSetCurvesWithRecoveryReport 额外返回折点补根重建前的相位图和重建候选,
// 便于调用方核验“窗口内部未成对端点”判据;正常路径只使用第一个返回值。
// occultationRiseSetCurvesWithRecoveryReport also returns the phase graph before fold
// recovery and the rebuilt candidate, so callers can verify the interior-endpoint test;
// the normal path uses the first result only.
func occultationRiseSetCurvesWithRecoveryReport(
startTT, endTT, greatestTT float64,
options OccultationPathOptions,
location *time.Location,
cache *occultationRiseSetEvaluationCache,
) ([]OccultationRiseSetCurve, []OccultationRiseSetCurve, []OccultationRiseSetCurve) {
if options.DisableRiseSet || startTT == 0 || endTT == 0 || endTT <= startTT {
return nil, nil, nil
}
if cache == nil {
return nil, nil, nil
}
step := options.RiseSetStep
if step <= 0 {
step = 5 * time.Minute
}
stepDays := float64(step) / float64(24*time.Hour)
times := occultationPathSampleTimes(startTT, endTT, greatestTT, stepDays)
keys := []occultationRiseSetCurveKey{
{RiseSetPhaseStart, RiseSetDirectionRise},
{RiseSetPhaseStart, RiseSetDirectionSet},
{RiseSetPhaseGreatest, RiseSetDirectionRise},
{RiseSetPhaseGreatest, RiseSetDirectionSet},
{RiseSetPhaseEnd, RiseSetDirectionRise},
{RiseSetPhaseEnd, RiseSetDirectionSet},
}
// The ordinary grid is intentionally retained for performance. Near a
// grazing phase junction a root branch may exist for less than one grid
// interval, so evaluate only intervals whose root topology changes and
// bisect those locally. This recovers the missing branch without doubling
// the cost of every rise/set curve.
samples := make([]occultationRiseSetSampledPoints, 0, len(times))
evaluate := func(tt float64, foldRecovery bool) occultationRiseSetSampledPoints {
return occultationRiseSetSampledPoints{
tt: tt,
points: cache.candidateEvaluation(tt).pointsAt(
occultationRiseSetBoundaryPoints, location, foldRecovery,
),
}
}
pointSource := cache.candidateEvaluation(startTT).center.targetRadiusKM <= 0
for _, tt := range times {
samples = append(samples, evaluate(tt, false))
}
buildCurves := func(samples []occultationRiseSetSampledPoints) []OccultationRiseSetCurve {
tracks := make(map[occultationRiseSetCurveKey][]*occultationRiseSetTrack, len(keys))
curves := make([]OccultationRiseSetCurve, 0, len(keys))
for _, sample := range samples {
for _, key := range keys {
if sample.adaptive && key.phase != RiseSetPhaseGreatest {
continue
}
tracks[key] = appendOccultationRiseSetSamples(tracks[key], sample.points[key], stepDays)
}
}
for _, key := range keys {
segments := make([][]OccultationPathPoint, 0, len(tracks[key]))
for _, track := range tracks[key] {
for _, segment := range track.segments {
if len(segment) >= 1 {
segments = append(segments, segment)
}
}
}
if len(segments) > 0 {
curves = append(curves, OccultationRiseSetCurve{
Phase: key.phase, Direction: key.direction, Segments: segments,
})
}
}
completeOccultationRiseSetCurveEndpoints(curves, stepDays, location, cache)
return curves
}
for pass := 0; pointSource && pass < occultationRiseSetAdaptiveMaximumPasses; pass++ {
if len(samples) < 2 {
break
}
intervals := make([]int, 0, occultationRiseSetAdaptiveMaximumIntervals)
for index := 0; index+1 < len(samples) && len(intervals) < occultationRiseSetAdaptiveMaximumIntervals; index++ {
left, right := samples[index], samples[index+1]
if right.tt <= left.tt || right.tt-left.tt <= occultationRiseSetAdaptiveMinimumStepDays {
continue
}
if !occultationRiseSetSamplesChangeTopology(left.points, right.points, keys) {
continue
}
// Refine only when the interval hides an additional branch at its
// interior. A simple 0->1 appearance is already represented by the
// endpoint sample; refining every disappearance interval can alter
// branch assignment for polar events whose midpoint has no root.
midpoint := evaluate((left.tt+right.tt)/2, false)
hiddenBranch := false
for _, key := range keys {
if len(midpoint.points[key]) > len(left.points[key]) &&
len(midpoint.points[key]) > len(right.points[key]) {
hiddenBranch = true
break
}
}
if hiddenBranch {
intervals = append(intervals, index)
}
}
if len(intervals) == 0 {
break
}
refined := make([]occultationRiseSetSampledPoints, 0, len(samples)+len(intervals))
intervalSet := make(map[int]bool, len(intervals))
for _, index := range intervals {
intervalSet[index] = true
}
for index, sample := range samples {
refined = append(refined, sample)
if !intervalSet[index] || index+1 >= len(samples) {
continue
}
midpoint := evaluate((sample.tt+samples[index+1].tt)/2, false)
midpoint.adaptive = true
refined = append(refined, midpoint)
}
samples = refined
}
firstTT, lastTT := times[0], times[len(times)-1]
curves := buildCurves(samples)
baseCurves := curves
recoveredCurves := occultationRiseSetFoldRecoveryCandidate(samples, buildCurves, evaluate, curves, firstTT, lastTT)
if recoveredCurves != nil &&
occultationRiseSetUnclosedEndpointCount(recoveredCurves, true, firstTT, lastTT) <
occultationRiseSetUnclosedEndpointCount(baseCurves, true, firstTT, lastTT) {
return recoveredCurves, baseCurves, recoveredCurves
}
return baseCurves, baseCurves, recoveredCurves
}
// occultationRiseSetFoldRecoveryCandidate 在窗口内部仍有未成对端点时用折点补根重建相位图。
// 没有窗口内部未成对端点时直接返回 nil:查询窗口边界上的裁剪端点没有任何折点可以配对,
// 为它们重跑整条流水线只会被丢弃。
// occultationRiseSetFoldRecoveryCandidate rebuilds the phase graph with fold roots while
// interior endpoints are still unpaired, and returns nil when none is: an endpoint clipped
// by the query window has no fold to pair with, so rebuilding for it is discarded work.
func occultationRiseSetFoldRecoveryCandidate(
samples []occultationRiseSetSampledPoints,
buildCurves func([]occultationRiseSetSampledPoints) []OccultationRiseSetCurve,
evaluate func(float64, bool) occultationRiseSetSampledPoints,
base []OccultationRiseSetCurve,
firstTT, lastTT float64,
) []OccultationRiseSetCurve {
if occultationRiseSetUnclosedEndpointCount(base, true, firstTT, lastTT) == 0 {
return nil
}
// A polar tangent fold dropped by the sign scan leaves the two branches of a
// phase unjoined. Events that already close keep their existing sampling and
// endpoints byte-identical.
recovered := make([]occultationRiseSetSampledPoints, len(samples))
for index, sample := range samples {
recovered[index] = evaluate(sample.tt, true)
recovered[index].adaptive = sample.adaptive
}
return buildCurves(recovered)
}
type occultationRiseSetSampledPoints struct {
tt float64
adaptive bool
points map[occultationRiseSetCurveKey][]OccultationPathPoint
}
func occultationRiseSetSamplesChangeTopology(
left, right map[occultationRiseSetCurveKey][]OccultationPathPoint,
keys []occultationRiseSetCurveKey,
) bool {
for _, key := range keys {
leftCount := len(left[key])
rightCount := len(right[key])
if leftCount != rightCount {
return leftCount > 0 || rightCount > 0
}
}
return false
}
func (evaluation occultationRiseSetEvaluation) pointsAt(
boundaryPoints int,
location *time.Location,
foldRecovery bool,
) map[occultationRiseSetCurveKey][]OccultationPathPoint {
result := make(map[occultationRiseSetCurveKey][]OccultationPathPoint, 6)
if !evaluation.center.valid || !evaluation.before.valid || !evaluation.after.valid {
return result
}
gst := ApparentSiderealTime(TT2UT1(evaluation.tt)) * 15
centerLongitude := normalizeLongitude(evaluation.center.moonRA - gst)
centerLatitude := evaluation.center.moonDec
appendRoots := func(greatest bool) {
// Keep the historical geographic refinement for ordinary latitudes; it
// is both cheaper and preserves the established endpoint network. A
// polar root needs the station-corrected horizon parameterization because
// its geographic Jacobian becomes singular and can switch siblings.
valueAt := func(angle float64) (float64, bool) {
longitude, latitude := riseSetHorizonPoint(centerLongitude, centerLatitude, angle)
state := evaluation.center.stateAt(longitude, latitude)
if !state.valid {
return 0, false
}
if greatest {
return evaluation.separationDerivative(longitude, latitude), true
}
return state.contactMetric, true
}
foldTolerance := 0.0
if foldRecovery && !greatest {
foldTolerance = riseSetFoldRootResidualToleranceDeg
}
roots := riseSetCyclicRootsWithFoldTolerance(boundaryPoints, foldTolerance, valueAt)
polar := false
for _, angle := range roots {
_, latitude := riseSetHorizonPoint(centerLongitude, centerLatitude, angle)
if math.Abs(latitude) >= 70 {
polar = true
break
}
}
var stationHorizonRoots []float64
if polar {
stationValueAt := func(angle float64) (float64, bool) {
longitude, latitude, ok := occultationRiseSetHorizonPointFromContext(evaluation.center, angle)
if !ok {
return 0, false
}
state := evaluation.center.stateAt(longitude, latitude)
if !state.valid {
return 0, false
}
if greatest {
return evaluation.separationDerivative(longitude, latitude), true
}
return state.contactMetric, true
}
stationHorizonRoots = riseSetCyclicRootsWithFoldTolerance(boundaryPoints, foldTolerance, stationValueAt)
}
for _, angle := range roots {
var longitude, latitude float64
var ok bool
longitude, latitude = riseSetHorizonPoint(centerLongitude, centerLatitude, angle)
if math.Abs(latitude) >= 70 {
stationAngle, found := occultationRiseSetClosestHorizonAngle(angle, stationHorizonRoots)
if !found {
continue
}
// `angle` is the continuation parameter on the station-corrected
// horizon. Do not refine this root in longitude/latitude: near a
// polar fold that 2-D Newton system can converge to its sibling.
longitude, latitude, ok = occultationRiseSetHorizonPointFromContext(evaluation.center, stationAngle)
} else {
longitude, latitude, ok = riseSetRefineGeographicRoot(
longitude, latitude,
func(lon, lat float64) (float64, float64, bool) {
state := evaluation.center.stateAt(lon, lat)
if !state.valid {
return 0, 0, false
}
first := state.contactMetric
if greatest {
first = evaluation.separationDerivative(lon, lat)
}
return first, state.moonAltitude, finite(first)
},
)
}
if !ok {
continue
}
point, key, valid := evaluation.classify(longitude, latitude, greatest, location)
if !valid || occultationRiseSetPointExists(result[key], point) {
continue
}
result[key] = append(result[key], point)
}
}
appendRoots(false)
appendRoots(true)
return result
}
func occultationRiseSetClosestHorizonAngle(target float64, candidates []float64) (float64, bool) {
closest := 0.0
distance := math.Inf(1)
for _, candidate := range candidates {
current := riseSetAngularDistance(target, candidate)
if current < distance {
closest, distance = candidate, current
}
}
return closest, finite(distance) && distance <= math.Pi/6
}
func (evaluation occultationRiseSetEvaluation) classify(
longitude, latitude float64,
greatest bool,
location *time.Location,
) (OccultationPathPoint, occultationRiseSetCurveKey, bool) {
state := evaluation.center.stateAt(longitude, latitude)
altitudeDerivative := evaluation.moonAltitudeDerivative(longitude, latitude)
if !state.valid || !finite(altitudeDerivative) || math.Abs(altitudeDerivative) < 1e-8 {
return OccultationPathPoint{}, occultationRiseSetCurveKey{}, false
}
direction := RiseSetDirectionSet
if altitudeDerivative > 0 {
direction = RiseSetDirectionRise
}
phase := RiseSetPhaseGreatest
if greatest {
if state.contactMetric > 1e-7 || evaluation.separationSecondDerivative(longitude, latitude) <= 0 {
return OccultationPathPoint{}, occultationRiseSetCurveKey{}, false
}
} else {
contactDerivative := evaluation.contactDerivative(longitude, latitude)
if !finite(contactDerivative) || math.Abs(contactDerivative) < 1e-8 {
return OccultationPathPoint{}, occultationRiseSetCurveKey{}, false
}
phase = RiseSetPhaseEnd
if contactDerivative < 0 {
phase = RiseSetPhaseStart
}
}
return OccultationPathPoint{
Time: occultationTTToLocation(evaluation.tt, location),
Longitude: longitude, Latitude: latitude, MoonAltitude: state.moonAltitude,
}, occultationRiseSetCurveKey{phase: phase, direction: direction}, true
}
// occultationSiderealRatePerDay 是视恒星时的角速率(弧度/日),用于观测者与天顶矢量的时间导数。
const occultationSiderealRatePerDay = 2 * math.Pi * 1.00273790935
// occultationRiseSetBodyVelocity 由前后时刻的体位置给出速度(千米/日);点源(距离为零)返回零。
func occultationRiseSetBodyVelocity(before, after occultationRiseSetBody, stepDays float64) occultationPathVector {
if stepDays <= 0 || before.distanceKM <= 0 || after.distanceKM <= 0 {
return occultationPathVector{}
}
return occultationPathScale(occultationPathSub(after.positionKM, before.positionKM), 1/(2*stepDays))
}
// occultationRiseSetDirectionRate 给出单位方向的时间导数 du/dt = (v − u(u·v))/|p|。
func occultationRiseSetDirectionRate(position, velocity, direction occultationPathVector) occultationPathVector {
norm := occultationPathNorm(position)
if norm <= 0 {
return occultationPathVector{}
}
radial := occultationPathScale(direction, occultationPathDot(direction, velocity))
return occultationPathScale(occultationPathSub(velocity, radial), 1/norm)
}
// occultationRiseSetRadiusRate 给出视半径 asin(R/d) 的解析时间导数(度/日),d 为站心距离。
func occultationRiseSetRadiusRate(
body occultationRiseSetBody,
observer, direction, velocity occultationPathVector,
radiusKM float64,
) (float64, float64, bool) {
if body.distanceKM <= 0 || radiusKM <= 0 {
return 0, 0, true
}
position := occultationPathSub(body.positionKM, observer)
distance := occultationPathNorm(position)
if distance <= radiusKM {
return 0, 0, false
}
ratio := radiusKM / distance
sinRadius := math.Max(-1, math.Min(1, ratio))
cosRadius := math.Sqrt(math.Max(0, 1-sinRadius*sinRadius))
if cosRadius <= 1e-12 {
return 0, 0, false
}
return math.Asin(sinRadius) / rad,
-(ratio / distance) * occultationPathDot(velocity, direction) / cosRadius / rad, true
}
// contactRateAt 用体位置的前后差分给出接触度量的解析时间导数(度/日):与 contactDerivative 的中心差分同口径,
// 但没有差分噪声,且不需要为前后时刻各求一次站心几何。
func (evaluation occultationRiseSetEvaluation) contactRateAt(longitude, latitude float64) float64 {
center := evaluation.center
observer, observerDistance, _ := occultationRiseSetObserverVectors(center.siderealDegrees, longitude, latitude)
observerVelocity := occultationPathCross(occultationPathVector{z: occultationSiderealRatePerDay}, observer)
moonPosition := occultationPathSub(center.moon.positionKM, observer)
targetPosition := occultationPathSub(center.target.positionKM, observer)
moonDirection := occultationRiseSetTopocentricDirection(center.moon, observer)
targetDirection := occultationRiseSetTopocentricDirection(center.target, observer)
moonVelocity := occultationPathSub(
occultationRiseSetBodyVelocity(evaluation.before.moon, evaluation.after.moon, occultationRiseSetDerivativeStepDays),
observerVelocity,
)
targetVelocity := occultationPathSub(
occultationRiseSetBodyVelocity(evaluation.before.target, evaluation.after.target, occultationRiseSetDerivativeStepDays),
observerVelocity,
)
if center.target.distanceKM <= 0 {
// 点源目标没有站心视差:视线方向就是地心视方向,速率取单位矢量的前后差分。
// 零位置减观测者会得到日尺度的虚假速率,掩带边界因此无法加密。
targetPosition = targetDirection
targetVelocity = occultationPathScale(
occultationPathSub(evaluation.after.target.direction, evaluation.before.target.direction),
1/(2*occultationRiseSetDerivativeStepDays),
)
}
moonDirectionRate := occultationRiseSetDirectionRate(moonPosition, moonVelocity, moonDirection)
targetDirectionRate := occultationRiseSetDirectionRate(targetPosition, targetVelocity, targetDirection)
cosSeparation := math.Max(-1, math.Min(1, occultationPathDot(moonDirection, targetDirection)))
sinSeparation := math.Sqrt(math.Max(0, 1-cosSeparation*cosSeparation))
if sinSeparation <= 1e-12 {
return math.NaN()
}
separationRate := -(occultationPathDot(moonDirectionRate, targetDirection) +
occultationPathDot(moonDirection, targetDirectionRate)) / sinSeparation / rad
if occultationRiseSetTopocentricDistance(center.moon, observerDistance) <= 0 {
return math.NaN()
}
moonRadius, moonRadiusRate, moonOK := occultationRiseSetRadiusRate(
center.moon, observer, moonDirection, moonVelocity, moonEquatorialRadiusKM,
)
targetRadius, targetRadiusRate, targetOK := occultationRiseSetRadiusRate(
center.target, observer, targetDirection, targetVelocity, center.targetRadiusKM,
)
if !moonOK || !targetOK {
return math.NaN()
}
if center.internalContact {
if moonRadius >= targetRadius {
return separationRate - (moonRadiusRate - targetRadiusRate)
}
return separationRate + (moonRadiusRate - targetRadiusRate)
}
return separationRate - moonRadiusRate - targetRadiusRate
}
// moonAltitudeRateAt 给出月球几何高度角的解析时间导数(度/日)。
func (evaluation occultationRiseSetEvaluation) moonAltitudeRateAt(longitude, latitude float64) float64 {
center := evaluation.center
observer, _, zenith := occultationRiseSetObserverVectors(center.siderealDegrees, longitude, latitude)
spin := occultationPathVector{z: occultationSiderealRatePerDay}
observerVelocity := occultationPathCross(spin, observer)
moonPosition := occultationPathSub(center.moon.positionKM, observer)
moonDirection := occultationRiseSetTopocentricDirection(center.moon, observer)
moonVelocity := occultationPathSub(
occultationRiseSetBodyVelocity(evaluation.before.moon, evaluation.after.moon, occultationRiseSetDerivativeStepDays),
observerVelocity,
)
moonDirectionRate := occultationRiseSetDirectionRate(moonPosition, moonVelocity, moonDirection)
sinAltitude := math.Max(-1, math.Min(1, occultationPathDot(moonDirection, zenith)))
cosAltitude := math.Sqrt(math.Max(0, 1-sinAltitude*sinAltitude))
if cosAltitude <= 1e-12 {
return math.NaN()
}
return (occultationPathDot(moonDirectionRate, zenith) +
occultationPathDot(moonDirection, occultationPathCross(spin, zenith))) / cosAltitude / rad
}
func (evaluation occultationRiseSetEvaluation) contactDerivative(longitude, latitude float64) float64 {
before := evaluation.before.stateAt(longitude, latitude)
after := evaluation.after.stateAt(longitude, latitude)
if !before.valid || !after.valid {
return math.NaN()
}
return (after.contactMetric - before.contactMetric) / (2 * occultationRiseSetDerivativeStepDays)
}
func (evaluation occultationRiseSetEvaluation) contactSecondDerivative(longitude, latitude float64) float64 {
before := evaluation.before.stateAt(longitude, latitude)
center := evaluation.center.stateAt(longitude, latitude)
after := evaluation.after.stateAt(longitude, latitude)
if !before.valid || !center.valid || !after.valid {
return math.NaN()
}
stepSquared := occultationRiseSetDerivativeStepDays * occultationRiseSetDerivativeStepDays
return (after.contactMetric - 2*center.contactMetric + before.contactMetric) / stepSquared
}
func (evaluation occultationRiseSetEvaluation) separationDerivative(longitude, latitude float64) float64 {
before := evaluation.before.stateAt(longitude, latitude)
after := evaluation.after.stateAt(longitude, latitude)
if !before.valid || !after.valid {
return math.NaN()
}
return (after.separationSquared - before.separationSquared) / (2 * occultationRiseSetDerivativeStepDays)
}
func (evaluation occultationRiseSetEvaluation) separationSecondDerivative(longitude, latitude float64) float64 {
before := evaluation.before.stateAt(longitude, latitude)
center := evaluation.center.stateAt(longitude, latitude)
after := evaluation.after.stateAt(longitude, latitude)
if !before.valid || !center.valid || !after.valid {
return math.NaN()
}
stepSquared := occultationRiseSetDerivativeStepDays * occultationRiseSetDerivativeStepDays
return (after.separationSquared - 2*center.separationSquared + before.separationSquared) / stepSquared
}
func (evaluation occultationRiseSetEvaluation) moonAltitudeDerivative(longitude, latitude float64) float64 {
before := evaluation.before.stateAt(longitude, latitude)
after := evaluation.after.stateAt(longitude, latitude)
if !before.valid || !after.valid {
return math.NaN()
}
return (after.moonAltitude - before.moonAltitude) / (2 * occultationRiseSetDerivativeStepDays)
}
func (evaluation occultationRiseSetEvaluation) moonAltitudeSecondDerivative(longitude, latitude float64) float64 {
before := evaluation.before.stateAt(longitude, latitude)
center := evaluation.center.stateAt(longitude, latitude)
after := evaluation.after.stateAt(longitude, latitude)
if !before.valid || !center.valid || !after.valid {
return math.NaN()
}
stepSquared := occultationRiseSetDerivativeStepDays * occultationRiseSetDerivativeStepDays
return (after.moonAltitude - 2*center.moonAltitude + before.moonAltitude) / stepSquared
}
func occultationRiseSetPointExists(points []OccultationPathPoint, candidate OccultationPathPoint) bool {
for _, point := range points {
if occultationPathDistanceKM(point, candidate) < 0.01 {
return true
}
}
return false
}
// occultationRiseSetBranchChanged is stricter than the coarse solar-path
// splitter for short occultation segments. A polar rise/set root can move
// hundreds of kilometres in nearly the same timestamp when two unrelated
// horizon branches are paired. Distances up to 500 km remain valid for the
// sampled fold attachments; a larger sub-second jump is rejected as a branch
// change before adaptive refinement can reconnect it.
func occultationRiseSetBranchChanged(distanceKM, deltaDays float64) bool {
if !finite(distanceKM) || !finite(deltaDays) {
return true
}
if distanceKM <= 500 {
return false
}
seconds := deltaDays * 86400
// Equal-time endpoints can only be joined when they are already spatially
// close. A larger jump in the sub-second interval is a different horizon
// root, not a fast-moving physical branch.
if seconds <= 0 {
return true
}
return seconds < 1 && distanceKM/seconds > 10
}
// occultationRiseSetWindowEdgeToleranceDays 判定端点是否落在查询窗口边界上。
// 窗口边界样本经过民用时往返的误差在微秒量级,远小于任何采样步长。
const occultationRiseSetWindowEdgeToleranceDays = 1e-6
// occultationRiseSetUnclosedEndpointCount 统计未被其他相位曲线端点共享的端点数量,
// 这是“相位图是否闭合”的判据。interiorOnly 时跳过窗口裁剪端点:它们没有任何折点
// 可以配对,只反映查询窗口而不是几何缺陷。
// occultationRiseSetUnclosedEndpointCount counts the endpoints that no other phase
// curve endpoint shares, the closure test for the phase graph. With interiorOnly,
// endpoints clipped by the query window are skipped: no fold can pair them, so they
// describe the window rather than the geometry.
func occultationRiseSetUnclosedEndpointCount(
curves []OccultationRiseSetCurve,
interiorOnly bool,
firstTT, lastTT float64,
) int {
unclosed := 0
for ci, curve := range curves {
if curve.Phase == RiseSetPhaseGreatest {
continue
}
for si, segment := range curve.Segments {
if len(segment) < 2 {
continue
}
for _, endpoint := range []OccultationPathPoint{segment[0], segment[len(segment)-1]} {
if interiorOnly && occultationRiseSetEndpointClipped(endpoint, firstTT, lastTT) {
continue
}
shared := false
for oi, other := range curves {
if other.Phase == RiseSetPhaseGreatest {
continue
}
for os, points := range other.Segments {
if (ci == oi && si == os) || len(points) < 2 {
continue
}
for _, point := range []OccultationPathPoint{points[0], points[len(points)-1]} {
dt := point.Time.Sub(endpoint.Time)
if dt >= -time.Second && dt <= time.Second &&
occultationPathDistanceKM(endpoint, point) < 0.01 {
shared = true
}
}
}
}
if !shared {
unclosed++
}
}
}
}
return unclosed
}
func occultationRiseSetEndpointClipped(endpoint OccultationPathPoint, firstTT, lastTT float64) bool {
tt := occultationTimeToTT(endpoint.Time)
return tt <= firstTT+occultationRiseSetWindowEdgeToleranceDays ||
tt >= lastTT-occultationRiseSetWindowEdgeToleranceDays
}