16c62a97d5
- 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试
1056 lines
41 KiB
Go
1056 lines
41 KiB
Go
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
|
||
}
|