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 }