Files
astro/basic/occultation_rise_set.go
T
b612 16c62a97d5 feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持
- 改进日月食、月掩、行星事件及路径边界计算
- 完善恒星三维自行与动态距离传播
- 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具
- 整理中英文手册、示例资源及回归测试
2026-09-23 18:55:12 +08:00

1056 lines
41 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}