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

1705 lines
67 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"
"sort"
"time"
)
const (
planetOccultationPathMaxTemporalSamples = 5000
// A single dense path evaluates several independent temporal grids and
// contour refinements. Keep all exact frames for that event so the shared
// ephemeris is not recomputed every time the bounded cache rolls over.
planetOccultationEventCacheMaximumEntries = 16384
)
type occultationPathFrameFunc func(float64) (occultationPathFrame, bool)
type planetOccultationEphemerisState struct {
moonRA, moonDec float64
moonDistanceKM float64
planetRA, planetDec float64
planetDistanceKM float64
valid bool
}
type planetOccultationFrameCacheEntry struct {
frame occultationPathFrame
ok bool
}
type planetOccultationEventCache struct {
config planetOccultationConfig
states map[uint64]planetOccultationEphemerisState
outerFrames map[uint64]planetOccultationFrameCacheEntry
totalFrames map[uint64]planetOccultationFrameCacheEntry
riseSetCache *occultationRiseSetEvaluationCache
totalRiseSetCache *occultationRiseSetEvaluationCache
local *planetOccultationLocalEphemeris
}
func newPlanetOccultationEventCache(config planetOccultationConfig) *planetOccultationEventCache {
cache := &planetOccultationEventCache{
config: config,
states: make(map[uint64]planetOccultationEphemerisState),
outerFrames: make(map[uint64]planetOccultationFrameCacheEntry),
totalFrames: make(map[uint64]planetOccultationFrameCacheEntry),
}
cache.riseSetCache = newOccultationRiseSetEvaluationCacheWithCandidate(
cache.riseSetContextAt,
cache.candidateRiseSetContextAt,
)
cache.totalRiseSetCache = newOccultationRiseSetEvaluationCacheWithCandidate(
cache.totalRiseSetContextAt,
cache.candidateTotalRiseSetContextAt,
)
return cache
}
// FindPlanetOccultationPaths 搜索有限盘面行星月掩的全球外接触和内接触掩带。
// 查询窗口按全球几何掩甚点选择事件;端点容差 10 ms 与数值根精度一致。求解成功时,每条路径扩展到完整全球起止点。
// FindPlanetOccultationPaths searches the global outer- and inner-contact footprints of one finite-disk planet.
// The query window selects events by global geometric greatest, with a 10 ms endpoint tolerance matching the numerical root precision. Each returned path expands to its complete global start and end when solved.
func FindPlanetOccultationPaths(start, end time.Time, planet OccultationPlanet,
options OccultationPathOptions) ([]PlanetOccultationPath, error) {
if err := validateOccultationTimeRange(start, end); err != nil {
return nil, err
}
if err := planet.Validate(); err != nil {
return nil, err
}
if err := options.Validate(); err != nil {
return nil, err
}
config, _ := planetOccultationConfigFor(planet)
options = normalizeOccultationPathOptions(options)
startTT := occultationTimeToTT(start)
endTT := occultationTimeToTT(end)
candidateStartTT := startTT - occultationPathSearchSpanDays
candidateEndTT := endTT + occultationPathSearchSpanDays
candidates := planetOccultationCandidateGreatestTimes(
candidateStartTT, candidateEndTT, planetOccultationDefaultStepDays, config, nil, 0,
)
paths := make([]PlanetOccultationPath, 0, len(candidates))
for _, seedTT := range candidates {
path, ok, err := planetOccultationPathAtSeed(seedTT, config, options, start, end)
if err != nil {
return nil, err
}
if !ok {
continue
}
if len(paths) > 0 && math.Abs(paths[len(paths)-1].Greatest.Time.Sub(path.Greatest.Time).Seconds()) <= 60 {
continue
}
paths = append(paths, path)
}
sort.SliceStable(paths, func(i, j int) bool {
return paths[i].Greatest.Time.Before(paths[j].Greatest.Time)
})
return paths, nil
}
func planetOccultationPathAtSeed(
seedTT float64,
config planetOccultationConfig,
options OccultationPathOptions,
selectionStart, selectionEnd time.Time,
) (PlanetOccultationPath, bool, error) {
location := selectionStart.Location()
cache := newPlanetOccultationEventCache(config)
cache.prepareLocalEphemeris(seedTT)
frameAt := cache.outerFrameAt
totalFrameAt := cache.totalFrameAt
candidateFrameAt := cache.candidateFrameAt
candidateTotalFrameAt := cache.candidateTotalFrameAt
searchStart := seedTT - occultationPathSearchSpanDays
searchEnd := seedTT + occultationPathSearchSpanDays
outerStart, outerEnd, ok := occultationPathWindowWithCandidateFrames(
seedTT, searchStart, searchEnd, candidateFrameAt, frameAt, false,
occultationPathFrameHasBoundary,
)
if !ok {
return PlanetOccultationPath{}, false, nil
}
centerStart, centerEnd, hasCenter := occultationPathWindowWithCandidateFrames(
seedTT, searchStart, searchEnd, candidateFrameAt, frameAt, true,
occultationPathFrameHasBoundary,
)
// Use the local interpolated ephemeris to predict the maximum first, then
// run the exact golden-section search in a bounded neighbourhood. Global
// markers stay identical to event-only queries in both path branches.
candidateGreatestTT := occultationPathGreatestForFrame(seedTT, outerStart, outerEnd, candidateFrameAt)
exactSearchMarginDays := 0.10
exactStart := math.Max(outerStart, candidateGreatestTT-exactSearchMarginDays)
exactEnd := math.Min(outerEnd, candidateGreatestTT+exactSearchMarginDays)
if exactEnd <= exactStart {
exactStart, exactEnd = outerStart, outerEnd
}
greatestTT := occultationPathGreatestForFrame(candidateGreatestTT, exactStart, exactEnd, frameAt)
greatest, greatestOK := occultationPathCenterPointForFrame(greatestTT, frameAt, location)
if !greatestOK && hasCenter {
greatestTT = math.Max(centerStart, math.Min(centerEnd, greatestTT))
greatest, greatestOK = occultationPathCenterPointForFrame(greatestTT, frameAt, location)
}
if !greatestOK {
// Non-central events have no Earth intersection with the shadow axis.
// Greatest is the ellipsoid point nearest to that axis, not an outer
// contact tangent. The tangent fallback can place Greatest outside the
// total band for grazing finite-disk occultations.
greatest, greatestOK = occultationPathTrackPointForFrame(greatestTT, frameAt, location)
}
if !greatestOK {
return PlanetOccultationPath{}, false, nil
}
if !occultationTimeInSelectionWindow(greatest.Time, selectionStart, selectionEnd) {
return PlanetOccultationPath{}, false, nil
}
_, _, greatestWidth, greatestWidthOK := occultationPathLimitsAndWidthForFrame(greatestTT, frameAt)
if !greatestWidthOK || greatestWidth <= 0 {
return PlanetOccultationPath{}, false, nil
}
// 仅有边界的事件没有影轴与椭球交点;原回退点使用纬度极值弦宽,全掩带使用下方的地面横向宽度。统一两种接触带宽度定义,使有限盘面内外接触宽度可比较。
// Boundary-only events do not have an axis/ellipsoid intersection. Their fallback point used to carry a latitude-extrema chord width, while total bands used the ground cross-track width below. Keep both contact bands on the same width definition so finite-disk inner/outer widths are comparable.
greatest.WidthKM = greatestWidth
totalStartTT, totalEndTT, hasTotal := occultationPathWindowWithCandidateFrames(
seedTT, searchStart, searchEnd, candidateTotalFrameAt, totalFrameAt, false,
occultationPathFrameHasBoundary,
)
hasTotal = hasTotal && greatestTT >= totalStartTT && greatestTT <= totalEndTT
if planetOccultationPathTemporalSampleCount(
outerStart, outerEnd, centerStart, centerEnd, hasCenter,
totalStartTT, totalEndTT, hasTotal, greatestTT, options,
) > planetOccultationPathMaxTemporalSamples {
return PlanetOccultationPath{}, false, ErrOccultationPathSamplingLimit
}
if occultationPathEstimatedPointCount(
outerStart, outerEnd, centerStart, centerEnd, hasCenter,
totalStartTT, totalEndTT, hasTotal, greatestTT, options,
) > occultationPathMaxOutputPointCount {
return PlanetOccultationPath{}, false, ErrOccultationPathSamplingLimit
}
start := occultationPathBoundaryEndpointForFrame(outerStart, frameAt, location, 1)
end := occultationPathBoundaryEndpointForFrame(outerEnd, frameAt, location, -1)
if !start.valid || !end.valid {
return PlanetOccultationPath{}, false, nil
}
exactFrameAt, exactTotalFrameAt := frameAt, totalFrameAt
if options.Algorithm != OccultationPathAlgorithmExact {
optimized := newPlanetOccultationEventCache(config)
optimized.preparePathEphemeris(seedTT, options.Algorithm)
if optimized.local.dense {
cache = optimized
frameAt, totalFrameAt = cache.outerFrameAt, cache.totalFrameAt
}
}
centerLine, northern, southern, err := planetOccultationPathSamples(
outerStart, outerEnd, centerStart, centerEnd, hasCenter, greatestTT, frameAt, options, location,
)
if err != nil {
return PlanetOccultationPath{}, false, err
}
if cache.local.dense {
correctOccultationCenterWidths(centerLine, exactFrameAt)
}
path := PlanetOccultationPath{
Planet: config.planet,
TargetID: config.planet.String(),
Start: start.point,
Greatest: greatest,
End: end.point,
Complete: outerStart > searchStart && outerEnd < searchEnd,
CenterLine: centerLine,
NorthernLimit: occultationPathWithEndpoints(start.point, end.point, northern),
SouthernLimit: occultationPathWithEndpoints(start.point, end.point, southern),
Step: options.Step,
TargetSpacingKM: options.TargetSpacingKM,
}
// The static visible fill is evaluated from these limit tracks. Keep it on
// the same station-centred contact equation as the contact contours and
// rise/set curves; otherwise one event mixes geocentric limits with
// topocentric contours and the selected envelope can miss a branch.
path.NorthernLimit = occultationStationCorrectLimitSeries(
path.NorthernLimit, frameAt, cache.riseSetContextAt, false, location,
)
path.SouthernLimit = occultationStationCorrectLimitSeries(
path.SouthernLimit, frameAt, cache.riseSetContextAt, false, location,
)
path.GreatestLimitSeparationKM, _ = occultationPathLimitSeparations(
path.NorthernLimit, path.SouthernLimit, greatestTT,
)
if !options.DisableFootprints {
path.PartialFootprints = planetOccultationFootprints(
outerStart, outerEnd, greatestTT, frameAt, options, location,
)
}
path.RiseSetCurves = occultationRiseSetCurvesWithCache(
outerStart, outerEnd, greatestTT, options, location, cache.riseSetCache,
)
path.GreatestTimeContours = occultationGreatestTimeContours(
occultationGreatestTimeLevels(options, outerStart, outerEnd), outerStart, outerEnd, cache.riseSetCache,
[][]OccultationPathPoint{path.CenterLine, path.NorthernLimit, path.SouthernLimit},
true, location,
)
partialContourTimes := occultationRiseSetEndpointTimes(path.RiseSetCurves)
if options.DisableFootprints {
path.PartialBandFootprints, partialContourTimes = planetOccultationBandFootprintsWithContourTimes(
outerStart, outerEnd, greatestTT, frameAt, location, partialContourTimes,
)
}
if len(path.PartialFootprints) > 0 {
path.PartialFootprints = occultationStationCorrectFootprintEdges(
path.PartialFootprints, frameAt, cache.riseSetContextAt, false, location,
)
}
if len(path.PartialBandFootprints) > 0 {
path.PartialBandFootprints = occultationStationCorrectFootprintEdges(
path.PartialBandFootprints, frameAt, cache.riseSetContextAt, false, location,
)
}
path.PartialBandContours = occultationContactBandContoursWithAdditionalTimes(
start.point, end.point, outerStart, outerEnd, greatestTT, frameAt, options, location, partialContourTimes,
)
// The geocentric cone supplies the contour topology and time samples; the
// station oracle corrects each fixed-time contact onto the same topocentric
// equation used by rise/set curves. Keep the geocentric seed when a fixed
// time has no station root yet (normally the short endpoint sliver).
path.PartialBandContours = occultationStationCorrectContours(
path.PartialBandContours, path.RiseSetCurves, cache.riseSetCache, location,
)
path.PartialVisibilityContours = occultationStationVisibilityEnvelopeContours(
path.RiseSetCurves, cache.riseSetCache, location,
)
if options.DisableFootprints && options.IncludeFootprintTimeline {
path.PartialFootprints = planetOccultationTimelineFootprints(
outerStart, outerEnd, greatestTT, frameAt, options, location,
)
}
if !hasTotal {
return path, true, nil
}
totalStart := occultationPathBoundaryEndpointForFrame(totalStartTT, exactTotalFrameAt, location, 1)
totalEnd := occultationPathBoundaryEndpointForFrame(totalEndTT, exactTotalFrameAt, location, -1)
_, _, totalWidth, totalWidthOK := occultationPathLimitsAndWidthForFrame(greatestTT, exactTotalFrameAt)
if !totalStart.valid || !totalEnd.valid || !totalWidthOK || totalWidth <= 0 {
return path, true, nil
}
// The global extrema used for a boundary-only footprint can select
// different tangent branches for outer and inner cones. At greatest, the
// physical width is the paired cross-track separation on the same moving
// track; use it for the finite target before applying the public invariant.
if crossTrackWidth, ok := occultationPathCrossTrackWidthForFrame(greatestTT, exactTotalFrameAt); ok {
totalWidth = crossTrackWidth
}
if totalWidth >= greatestWidth {
if crossTrackWidth, ok := occultationPathCrossTrackWidthForFrame(greatestTT, exactFrameAt); ok && crossTrackWidth > 0 {
greatestWidth = crossTrackWidth
path.Greatest.WidthKM = crossTrackWidth
}
}
if totalWidth >= greatestWidth {
totalWidth = math.Nextafter(greatestWidth, 0)
}
contourStepDays := occultationPathContourStepDays(options)
totalNorthern, totalSouthern := occultationPathBoundarySamplesForFrame(
totalStartTT, totalEndTT, greatestTT, totalFrameAt, contourStepDays, location,
)
if len(totalNorthern) == 0 || len(totalSouthern) == 0 {
return path, true, nil
}
path.HasTotalBand = true
path.TotalStart = totalStart.point
path.TotalEnd = totalEnd.point
path.TotalComplete = totalStartTT > searchStart && totalEndTT < searchEnd
path.NorthernTotalLimit = occultationPathWithEndpoints(totalStart.point, totalEnd.point, totalNorthern)
path.SouthernTotalLimit = occultationPathWithEndpoints(totalStart.point, totalEnd.point, totalSouthern)
path.NorthernTotalLimit = occultationStationCorrectLimitSeries(
path.NorthernTotalLimit, totalFrameAt, cache.riseSetContextAt, true, location,
)
path.SouthernTotalLimit = occultationStationCorrectLimitSeries(
path.SouthernTotalLimit, totalFrameAt, cache.riseSetContextAt, true, location,
)
_, _ = occultationPathLimitSeparations(path.NorthernTotalLimit, path.SouthernTotalLimit, greatestTT)
path.TotalRiseSetCurves = occultationRiseSetCurvesWithCache(
totalStartTT, totalEndTT, greatestTT, options, location, cache.totalRiseSetCache,
)
totalContourTimes := occultationRiseSetEndpointTimes(path.TotalRiseSetCurves)
if !options.DisableFootprints {
path.TotalFootprints = planetOccultationFootprints(
totalStartTT, totalEndTT, greatestTT, totalFrameAt, options, location,
)
}
if options.DisableFootprints {
path.TotalBandFootprints, totalContourTimes = planetOccultationBandFootprintsWithContourTimes(
totalStartTT, totalEndTT, greatestTT, totalFrameAt, location, totalContourTimes,
)
}
if len(path.TotalFootprints) > 0 {
path.TotalFootprints = occultationStationCorrectFootprintEdges(
path.TotalFootprints, totalFrameAt, cache.riseSetContextAt, true, location,
)
}
if len(path.TotalBandFootprints) > 0 {
path.TotalBandFootprints = occultationStationCorrectFootprintEdges(
path.TotalBandFootprints, totalFrameAt, cache.riseSetContextAt, true, location,
)
}
path.TotalBandContours = occultationContactBandContoursWithAdditionalTimes(
totalStart.point, totalEnd.point, totalStartTT, totalEndTT, greatestTT,
totalFrameAt, options, location, totalContourTimes,
)
path.TotalBandContours = occultationStationCorrectContours(
path.TotalBandContours, path.TotalRiseSetCurves, cache.totalRiseSetCache, location,
)
path.TotalVisibilityContours = occultationStationVisibilityEnvelopeContours(
path.TotalRiseSetCurves, cache.totalRiseSetCache, location,
)
if options.DisableFootprints && options.IncludeFootprintTimeline {
path.TotalFootprints = planetOccultationTimelineFootprints(
totalStartTT, totalEndTT, greatestTT, totalFrameAt, options, location,
)
}
path.GreatestTotalWidthKM = totalWidth
return path, true, nil
}
func planetOccultationPathTemporalSampleCount(
outerStartTT, outerEndTT float64,
centerStartTT, centerEndTT float64,
hasCenter bool,
totalStartTT, totalEndTT float64,
hasTotal bool,
greatestTT float64,
options OccultationPathOptions,
) int {
stepDays := float64(options.Step) / float64(24*time.Hour)
count := len(occultationPathSampleTimes(outerStartTT, outerEndTT, greatestTT, stepDays))
if !options.DisableFootprints || options.IncludeFootprintTimeline {
count += len(occultationPathSampleTimesWithLimit(
outerStartTT, outerEndTT, greatestTT, planetOccultationFootprintSampleStepDays(options), planetOccultationFootprintMaxSamples,
))
} else {
count += len(planetOccultationBandSampleTimes(
outerStartTT, outerEndTT, greatestTT, planetOccultationBandSampleStepDays(), planetOccultationBandMaxSamples,
))
}
if options.DisableFootprints && options.IncludeFootprintTimeline {
count += len(planetOccultationBandSampleTimes(
outerStartTT, outerEndTT, greatestTT, planetOccultationBandSampleStepDays(), planetOccultationBandMaxSamples,
))
}
if hasCenter {
count += len(occultationPathSampleTimes(centerStartTT, centerEndTT, greatestTT, stepDays))
}
contourStepDays := occultationPathContourStepDays(options)
count += 2 * len(occultationPathSampleTimes(outerStartTT, outerEndTT, greatestTT, contourStepDays))
if hasTotal {
count += len(occultationPathSampleTimes(totalStartTT, totalEndTT, greatestTT, stepDays))
count += 2 * len(occultationPathSampleTimes(totalStartTT, totalEndTT, greatestTT, contourStepDays))
if !options.DisableFootprints || options.IncludeFootprintTimeline {
count += len(occultationPathSampleTimesWithLimit(
totalStartTT, totalEndTT, greatestTT, planetOccultationFootprintSampleStepDays(options), planetOccultationFootprintMaxSamples,
))
} else {
count += len(planetOccultationBandSampleTimes(
totalStartTT, totalEndTT, greatestTT, planetOccultationBandSampleStepDays(), planetOccultationBandMaxSamples,
))
}
if options.DisableFootprints && options.IncludeFootprintTimeline {
count += len(planetOccultationBandSampleTimes(
totalStartTT, totalEndTT, greatestTT, planetOccultationBandSampleStepDays(), planetOccultationBandMaxSamples,
))
}
}
return count
}
func occultationPathWindowForFrame(
seedTT, startTT, endTT float64,
frameAt occultationPathFrameFunc,
center bool,
) (float64, float64, bool) {
predicate := func(tt float64) bool {
frame, ok := frameAt(tt)
if !ok {
return false
}
if center {
_, _, ok = occultationEarthLineIntersection(frame.moon, frame.axis)
return ok
}
return occultationPathFrameHasBoundary(frame)
}
engine := occultationMovingDiskEngine()
return engine.window(seedTT, startTT, endTT, predicate, predicate)
}
func occultationPathGreatestForFrame(seedTT, startTT, endTT float64, frameAt occultationPathFrameFunc) float64 {
return occultationPathGreatestForFrameIterations(seedTT, startTT, endTT, frameAt, 56)
}
func occultationPathGreatestForFrameIterations(
seedTT, startTT, endTT float64,
frameAt occultationPathFrameFunc,
iterations int,
) float64 {
return occultationMovingDiskEngine().greatest(
seedTT, startTT, endTT,
func(tt float64) (float64, bool) {
frame, ok := frameAt(tt)
if !ok {
return 0, false
}
return math.Hypot(frame.moonProjectionX(), frame.moonProjectionY()), true
}, iterations,
)
}
func planetOccultationPathSamples(
outerStartTT, outerEndTT float64,
centerStartTT, centerEndTT float64,
hasCenter bool,
greatestTT float64,
frameAt occultationPathFrameFunc,
options OccultationPathOptions,
location *time.Location,
) ([]OccultationPathPoint, []OccultationPathPoint, []OccultationPathPoint, error) {
var centerLine []OccultationPathPoint
if hasCenter {
var err error
centerLine, err = occultationPathCenterSamplesForFrame(centerStartTT, centerEndTT, greatestTT, frameAt, options, location)
if err != nil {
return nil, nil, nil, err
}
}
contourStepDays := occultationPathContourStepDays(options)
northern, southern := occultationPathBoundarySamplesForFrame(
outerStartTT, outerEndTT, greatestTT, frameAt, contourStepDays, location,
)
return centerLine, northern, southern, nil
}
func occultationPathBoundarySamplesForFrame(
startTT, endTT, greatestTT float64,
frameAt occultationPathFrameFunc,
stepDays float64,
location *time.Location,
) ([]OccultationPathPoint, []OccultationPathPoint) {
times := occultationPathSampleTimes(startTT, endTT, greatestTT, stepDays)
return occultationPathBoundarySamplesAtTimesForFrame(
times, greatestTT, frameAt, location, occultationPathContourSpacingKM, false,
)
}
func occultationPathBoundaryContourSamplesForFrame(
startTT, endTT, greatestTT float64,
frameAt occultationPathFrameFunc,
stepDays float64,
location *time.Location,
additionalTimes []float64,
) ([]OccultationPathPoint, []OccultationPathPoint) {
times := occultationPathSampleTimes(startTT, endTT, greatestTT, stepDays)
for _, tt := range additionalTimes {
if !finite(tt) || tt < startTT || tt > endTT {
continue
}
times = append(times, tt)
}
sort.Float64s(times)
times = uniqueOccultationPathTimes(times)
return occultationPathBoundarySamplesAtTimesForFrame(
times, greatestTT, frameAt, location, occultationPathContourSpacingKM, true,
)
}
func occultationPathBoundarySamplesAtTimesForFrame(
times []float64,
greatestTT float64,
frameAt occultationPathFrameFunc,
location *time.Location,
targetSpacingKM float64,
useFiniteArcExtrema bool,
) ([]OccultationPathPoint, []OccultationPathPoint) {
limitsAt := occultationPathCrossTrackLimitsForFrame
if useFiniteArcExtrema {
limitsAt = occultationPathContourCrossTrackLimitsForFrame
}
samples := make([]occultationPathBoundaryPairSample, 0, len(times))
for _, tt := range times {
firstVector, secondVector, moon, ok := occultationPathContourBoundaryPairAt(
tt, frameAt, limitsAt, useFiniteArcExtrema, nil,
)
if !ok {
continue
}
sample := occultationPathBoundaryPairSample{tt: tt, first: firstVector, second: secondVector, moon: moon}
if len(samples) == 0 {
samples = append(samples, sample)
continue
}
samples = appendOccultationPathBoundaryPairSegment(
samples, samples[len(samples)-1], sample, frameAt, targetSpacingKM, limitsAt, useFiniteArcExtrema, 0,
)
}
first := make([]OccultationPathPoint, len(samples))
second := make([]OccultationPathPoint, len(samples))
for index, sample := range samples {
siderealDegrees := ApparentSiderealTime(TT2UT1(sample.tt)) * 15
first[index] = occultationPathPointFromVectorWithMoonSidereal(
sample.tt, sample.first, 0, sample.moon, siderealDegrees, location,
)
second[index] = occultationPathPointFromVectorWithMoonSidereal(
sample.tt, sample.second, 0, sample.moon, siderealDegrees, location,
)
}
first, second = occultationPathDeduplicateBoundaryPoints(first, second)
return occultationPathOrientBoundarySamples(first, second, greatestTT)
}
type occultationPathBoundaryPairSample struct {
tt float64
first, second occultationPathVector
moon occultationPathVector
}
func appendOccultationPathBoundaryPairSegment(
samples []occultationPathBoundaryPairSample,
start, end occultationPathBoundaryPairSample,
frameAt occultationPathFrameFunc,
targetSpacingKM float64,
limitsAt func(float64, occultationPathFrameFunc) (occultationPathVector, occultationPathVector, occultationPathVector, bool),
useFiniteArcExtrema bool,
depth int,
) []occultationPathBoundaryPairSample {
if useFiniteArcExtrema {
end = occultationPathRepairContourBoundaryPair(start, end, frameAt, limitsAt)
}
end = occultationPathOrientBoundaryPair(start, end)
spacing := occultationPathBoundaryPairDirectSpacing(start, end)
if useFiniteArcExtrema {
// The public maps use Web Mercator. Near a pole a physically short
// ground-track step can span a much larger projected x/y distance, so
// a ground-distance-only sampler renders visible corners and notches.
// Refine only finite-disk contact contours using the same projection
// metric that the map consumes; ordinary event/path sampling remains
// on the cheaper physical-distance criterion.
projectedSpacing := occultationPathBoundaryPairWebMercatorSpacing(start, end)
projectedTarget := occultationPathBoundaryPairWebMercatorTarget(start, end, targetSpacingKM)
if projectedSpacing > projectedTarget {
spacing = math.Max(spacing, projectedSpacing*targetSpacingKM/projectedTarget)
}
}
if depth >= occultationPathMaxAdaptiveDepth || spacing <= targetSpacingKM {
return append(samples, end)
}
midTT := (start.tt + end.tt) / 2
if midTT <= start.tt || midTT >= end.tt {
return append(samples, end)
}
first, second, moon, ok := occultationPathContourBoundaryPairAt(
midTT, frameAt, limitsAt, useFiniteArcExtrema, &start,
)
if !ok {
return append(samples, end)
}
mid := occultationPathBoundaryPairSample{tt: midTT, first: first, second: second, moon: moon}
samples = appendOccultationPathBoundaryPairSegment(samples, start, mid, frameAt, targetSpacingKM, limitsAt, useFiniteArcExtrema, depth+1)
return appendOccultationPathBoundaryPairSegment(
samples, samples[len(samples)-1], end, frameAt, targetSpacingKM, limitsAt, useFiniteArcExtrema, depth+1,
)
}
const (
occultationPathWebMercatorRadiusKM = 6378.1366
occultationPathWebMercatorMaxLat = 85.05112878
)
func occultationPathBoundaryPairWebMercatorSpacing(
first, second occultationPathBoundaryPairSample,
) float64 {
return math.Max(
occultationPathWebMercatorPointSpacing(first.tt, first.first, second.tt, second.first),
occultationPathWebMercatorPointSpacing(first.tt, first.second, second.tt, second.second),
)
}
func occultationPathBoundaryPairWebMercatorTarget(
first, second occultationPathBoundaryPairSample,
targetSpacingKM float64,
) float64 {
if targetSpacingKM <= 0 {
return targetSpacingKM
}
_, firstStartLatitude := occultationPathGeodetic(first.tt, first.first)
_, firstEndLatitude := occultationPathGeodetic(second.tt, second.first)
_, secondStartLatitude := occultationPathGeodetic(first.tt, first.second)
_, secondEndLatitude := occultationPathGeodetic(second.tt, second.second)
latitude := math.Max(
math.Max(math.Abs(firstStartLatitude), math.Abs(firstEndLatitude)),
math.Max(math.Abs(secondStartLatitude), math.Abs(secondEndLatitude)),
) * math.Pi / 180
// Longitude is stretched by sec(latitude) in Web Mercator. Keep the
// projected contour step near the physical 40 km target at low latitudes,
// but cap it at roughly 10 km around the polar part of this event.
return targetSpacingKM * math.Max(0.75, math.Cos(latitude))
}
func occultationPathWebMercatorPointSpacing(
firstTT float64,
firstVector occultationPathVector,
secondTT float64,
secondVector occultationPathVector,
) float64 {
firstLongitude, firstLatitude := occultationPathGeodetic(firstTT, firstVector)
secondLongitude, secondLatitude := occultationPathGeodetic(secondTT, secondVector)
firstLatitude = math.Max(-occultationPathWebMercatorMaxLat, math.Min(occultationPathWebMercatorMaxLat, firstLatitude))
secondLatitude = math.Max(-occultationPathWebMercatorMaxLat, math.Min(occultationPathWebMercatorMaxLat, secondLatitude))
longitudeDelta := math.Remainder(secondLongitude-firstLongitude, 360) * math.Pi / 180
firstY := math.Log(math.Tan(math.Pi/4 + firstLatitude*math.Pi/360))
secondY := math.Log(math.Tan(math.Pi/4 + secondLatitude*math.Pi/360))
latitudeDelta := secondY - firstY
return occultationPathWebMercatorRadiusKM * math.Hypot(longitudeDelta, latitudeDelta)
}
func occultationPathContourBoundaryPairAt(
tt float64,
frameAt occultationPathFrameFunc,
limitsAt func(float64, occultationPathFrameFunc) (occultationPathVector, occultationPathVector, occultationPathVector, bool),
useFiniteArcExtrema bool,
previous *occultationPathBoundaryPairSample,
) (occultationPathVector, occultationPathVector, occultationPathVector, bool) {
first, second, moon, ok := limitsAt(tt, frameAt)
if !ok {
if useFiniteArcExtrema && previous != nil {
return previous.first, previous.second, previous.moon, true
}
return occultationPathVector{}, occultationPathVector{}, occultationPathVector{}, false
}
candidate := occultationPathBoundaryPairSample{tt: tt, first: first, second: second, moon: moon}
if !useFiniteArcExtrema || previous == nil {
return candidate.first, candidate.second, candidate.moon, true
}
candidate = occultationPathOrientBoundaryPair(*previous, candidate)
if occultationPathBoundaryPairDirectSpacing(*previous, candidate) <= occultationPathBoundarySpacingKM {
return candidate.first, candidate.second, candidate.moon, true
}
if frame, frameOK := frameAt(tt); frameOK && frame.targetRadius != 0 {
if first, second, ok := occultationPathContinueFiniteBoundaryPair(
frame, tt, *previous,
); ok {
return first, second, frame.moon, true
}
}
return candidate.first, candidate.second, candidate.moon, true
}
func occultationPathContinueFiniteBoundaryPair(
frame occultationPathFrame,
tt float64,
previous occultationPathBoundaryPairSample,
) (occultationPathVector, occultationPathVector, bool) {
intervals := occultationPathBoundaryThetaIntervals(frame)
if len(intervals) == 0 {
return occultationPathVector{}, occultationPathVector{}, false
}
currentRotation := occultationPathEarthRotationAt(tt)
previousRotation := occultationPathEarthRotationAt(previous.tt)
nearest := func(target occultationPathVector) (occultationPathVector, bool) {
targetFixed := occultationPathEarthFixedVectorWithRotation(target, previousRotation)
bestDistance := math.Inf(1)
var best occultationPathVector
for _, interval := range intervals {
const samples = 48
bestIndex := -1
for index := 0; index <= samples; index++ {
theta := interval.left + (interval.right-interval.left)*float64(index)/samples
point, _, ok := occultationPathBoundaryVector(frame, theta)
if !ok {
continue
}
fixed := occultationPathEarthFixedVectorWithRotation(point, currentRotation)
distance := occultationPathNorm(occultationPathSub(fixed, targetFixed))
if distance < bestDistance {
bestDistance, best, bestIndex = distance, point, index
}
}
if bestIndex < 0 {
continue
}
leftIndex := math.Max(float64(bestIndex-1), 0)
rightIndex := math.Min(float64(bestIndex+1), samples)
left := interval.left + (interval.right-interval.left)*leftIndex/samples
right := interval.left + (interval.right-interval.left)*rightIndex/samples
const goldenRatio = 0.6180339887498949
x1 := right - goldenRatio*(right-left)
x2 := left + goldenRatio*(right-left)
distanceAt := func(theta float64) (float64, occultationPathVector, bool) {
point, _, ok := occultationPathBoundaryVector(frame, theta)
if !ok {
return math.Inf(1), occultationPathVector{}, false
}
fixed := occultationPathEarthFixedVectorWithRotation(point, currentRotation)
return occultationPathNorm(occultationPathSub(fixed, targetFixed)), point, true
}
f1, _, _ := distanceAt(x1)
f2, _, _ := distanceAt(x2)
for iteration := 0; iteration < 24; iteration++ {
if f1 > f2 {
left = x1
x1, f1 = x2, f2
x2 = left + goldenRatio*(right-left)
f2, _, _ = distanceAt(x2)
} else {
right = x2
x2, f2 = x1, f1
x1 = right - goldenRatio*(right-left)
f1, _, _ = distanceAt(x1)
}
}
distance, point, ok := distanceAt((left + right) / 2)
if ok && distance < bestDistance {
bestDistance, best = distance, point
}
}
return best, finite(bestDistance)
}
first, firstOK := nearest(previous.first)
second, secondOK := nearest(previous.second)
if !firstOK || !secondOK || occultationPathNorm(occultationPathSub(first, second)) < 1 {
return occultationPathVector{}, occultationPathVector{}, false
}
return first, second, true
}
func occultationPathRepairContourBoundaryPair(
previous, current occultationPathBoundaryPairSample,
frameAt occultationPathFrameFunc,
limitsAt func(float64, occultationPathFrameFunc) (occultationPathVector, occultationPathVector, occultationPathVector, bool),
) occultationPathBoundaryPairSample {
current = occultationPathOrientBoundaryPair(previous, current)
if occultationPathBoundaryPairDirectSpacing(previous, current) <= occultationPathBoundarySpacingKM {
return current
}
if frame, ok := frameAt(current.tt); ok && frame.targetRadius != 0 {
if first, second, ok := occultationPathContinueFiniteBoundaryPair(
frame, current.tt, previous,
); ok {
continued := occultationPathOrientBoundaryPair(previous, occultationPathBoundaryPairSample{
tt: current.tt, first: first, second: second, moon: frame.moon,
})
if occultationPathBoundaryPairDirectSpacing(previous, continued) <=
occultationPathBoundaryPairDirectSpacing(previous, current) {
return continued
}
}
if _, _, _, finiteOK := limitsAt(current.tt, frameAt); !finiteOK {
return previous
}
}
_ = limitsAt
return current
}
func occultationPathOrientBoundaryPair(
previous, current occultationPathBoundaryPairSample,
) occultationPathBoundaryPairSample {
direct, swapped := occultationPathBoundaryPairSpacings(previous, current)
if swapped < direct {
current.first, current.second = current.second, current.first
}
return current
}
func occultationPathBoundaryPairDirectSpacing(first, second occultationPathBoundaryPairSample) float64 {
direct, _ := occultationPathBoundaryPairSpacings(first, second)
return direct
}
func occultationPathBoundaryPairSpacings(first, second occultationPathBoundaryPairSample) (float64, float64) {
firstRotation := occultationPathEarthRotationAt(first.tt)
secondRotation := occultationPathEarthRotationAt(second.tt)
firstA := occultationPathEarthFixedVectorWithRotation(first.first, firstRotation)
firstB := occultationPathEarthFixedVectorWithRotation(first.second, firstRotation)
secondA := occultationPathEarthFixedVectorWithRotation(second.first, secondRotation)
secondB := occultationPathEarthFixedVectorWithRotation(second.second, secondRotation)
direct := math.Max(
occultationPathNorm(occultationPathSub(secondA, firstA)),
occultationPathNorm(occultationPathSub(secondB, firstB)),
)
swapped := math.Max(
occultationPathNorm(occultationPathSub(secondB, firstA)),
occultationPathNorm(occultationPathSub(secondA, firstB)),
)
return direct, swapped
}
func occultationPathCrossTrackLimitsForFrame(
tt float64,
frameAt occultationPathFrameFunc,
) (occultationPathVector, occultationPathVector, occultationPathVector, bool) {
frame, ok := frameAt(tt)
before, beforeOK := frameAt(tt - occultationPathVelocityStepDays)
after, afterOK := frameAt(tt + occultationPathVelocityStepDays)
if !ok || !beforeOK || !afterOK {
return occultationPathVector{}, occultationPathVector{}, occultationPathVector{}, false
}
vx := after.moonProjectionX() - before.moonProjectionX()
vy := after.moonProjectionY() - before.moonProjectionY()
if math.Hypot(vx, vy) <= 1e-12 {
return occultationPathVector{}, occultationPathVector{}, occultationPathVector{}, false
}
// 复用日食中心线构造:取基准面中垂直于运动方向的两条影锥母线。点源掠过阶段将缺失母线限制到可见角度区间;有限目标使用最近可见区间两端,直到两条横向母线分别与地球相交。
// Match the solar-eclipse central-path construction: take the two shadow generators perpendicular to motion in the fundamental plane. During a grazing point-source phase, clamp a missing generator to the visible-angle interval. For a finite target, use both ends of the nearest visible interval until the two cross-track generators intersect Earth independently.
theta := math.Atan2(vx, -vy)
first, _, firstOK := occultationPathBoundaryVector(frame, theta)
second, _, secondOK := occultationPathBoundaryVector(frame, theta+math.Pi)
if frame.targetRadius == 0 {
if !firstOK {
first, firstOK = occultationPathBoundaryAtNearestPointSourceTheta(frame, theta)
}
if !secondOK {
second, secondOK = occultationPathBoundaryAtNearestPointSourceTheta(frame, theta+math.Pi)
}
if !firstOK || !secondOK {
return occultationPathVector{}, occultationPathVector{}, occultationPathVector{}, false
}
return first, second, frame.moon, true
}
intervals := occultationPathBoundaryThetaIntervals(frame)
interval, intervalOK := occultationPathNearestThetaInterval(intervals, theta)
if firstOK && secondOK {
if intervalOK && occultationPathAngleDistance(interval.left, theta) > occultationPathAngleDistance(interval.left, theta+math.Pi) {
first, second = second, first
}
return first, second, frame.moon, true
}
if !intervalOK {
return occultationPathVector{}, occultationPathVector{}, occultationPathVector{}, false
}
first, _, firstOK = occultationPathBoundaryVector(frame, interval.left)
second, _, secondOK = occultationPathBoundaryVector(frame, interval.right)
if !firstOK || !secondOK {
return occultationPathVector{}, occultationPathVector{}, occultationPathVector{}, false
}
return first, second, frame.moon, true
}
// occultationPathContourCrossTrackLimitsForFrame is used only for the static
// contact-contour band. A finite target may expose one long visible
// contact-cone arc; its horizon endpoints are closure points, not necessarily
// the two cross-track sides of the band. Scan that arc for the physical sides,
// while retaining the normal paired-limit fallback for grazing/degenerate
// samples.
func occultationPathContourCrossTrackLimitsForFrame(
tt float64,
frameAt occultationPathFrameFunc,
) (occultationPathVector, occultationPathVector, occultationPathVector, bool) {
frame, ok := frameAt(tt)
if !ok || frame.targetRadius == 0 {
return occultationPathCrossTrackLimitsForFrame(tt, frameAt)
}
before, beforeOK := frameAt(tt - occultationPathVelocityStepDays)
after, afterOK := frameAt(tt + occultationPathVelocityStepDays)
if !beforeOK || !afterOK {
return occultationPathCrossTrackLimitsForFrame(tt, frameAt)
}
first, second, _, extremaOK := occultationPathFiniteCrossTrackExtrema(tt, frame, before, after)
if extremaOK {
return first, second, frame.moon, true
}
return occultationPathCrossTrackLimitsForFrame(tt, frameAt)
}
func occultationPathAngleDistance(first, second float64) float64 {
return math.Abs(math.Remainder(first-second, 2*math.Pi))
}
func occultationPathBoundaryAtNearestPointSourceTheta(
frame occultationPathFrame,
theta float64,
) (occultationPathVector, bool) {
_, tangentTheta, tangentOK := occultationPathBoundaryTangent(frame)
if !tangentOK {
return occultationPathVector{}, false
}
left, right, intervalOK := occultationPathBoundaryThetaInterval(frame, tangentTheta)
if !intervalOK {
return occultationPathVector{}, false
}
middle := (left + right) / 2
theta += 2 * math.Pi * math.Round((middle-theta)/(2*math.Pi))
if theta < left {
theta = left
} else if theta > right {
theta = right
}
point, _, ok := occultationPathBoundaryVector(frame, theta)
return point, ok
}
type occultationPathThetaInterval struct {
left, right float64
}
func occultationPathNearestThetaInterval(
intervals []occultationPathThetaInterval,
theta float64,
) (occultationPathThetaInterval, bool) {
var closest occultationPathThetaInterval
closestDistance := math.Inf(1)
for _, interval := range intervals {
middle := (interval.left + interval.right) / 2
firstDelta := math.Abs(math.Remainder(theta-middle, 2*math.Pi))
secondDelta := math.Abs(math.Remainder(theta+math.Pi-middle, 2*math.Pi))
distance := math.Min(firstDelta, secondDelta)
if distance < closestDistance {
closest = interval
closestDistance = distance
}
}
if !finite(closestDistance) {
return occultationPathThetaInterval{}, false
}
return closest, true
}
func occultationPathBoundaryThetaIntervals(frame occultationPathFrame) []occultationPathThetaInterval {
if frame.boundary != nil {
frame.boundary.boundaryOnce.Do(func() { occultationPathBoundaryPrecompute(frame) })
return frame.boundary.intervals
}
return occultationPathBoundaryThetaIntervalsUncached(frame)
}
// occultationPathBoundaryThetaIntervalsUncached 是可见 θ 区间扫描本体;调用方通过 frame 级缓存复用结果。
// occultationPathBoundaryThetaIntervalsUncached is the visible-theta scan itself; callers reuse it through the frame cache.
func occultationPathBoundaryThetaIntervalsUncached(frame occultationPathFrame) []occultationPathThetaInterval {
step := 2 * math.Pi / float64(occultationPathBoundaryScanPoints)
discriminants := make([]float64, occultationPathBoundaryScanPoints)
occultationPathBoundaryFillGrid(frame, discriminants)
return occultationPathBoundaryThetaIntervalsFromGrid(frame, step, discriminants)
}
// occultationPathBoundaryFillGrid 计算整圈 720 点判别式网格,失败点记为 -Inf。
// occultationPathBoundaryFillGrid evaluates the 720-point discriminant grid, marking failures as -Inf.
func occultationPathBoundaryFillGrid(frame occultationPathFrame, discriminants []float64) {
step := 2 * math.Pi / float64(occultationPathBoundaryScanPoints)
for index := range discriminants {
discriminant, _, _, ok := occultationPathBoundaryLine(frame, step*float64(index))
if !ok {
discriminant = math.Inf(-1)
}
discriminants[index] = discriminant
}
}
// occultationPathBoundaryThetaIntervalsFromGrid 在已算好的网格上恢复可见 θ 区间。
// occultationPathBoundaryThetaIntervalsFromGrid recovers the visible theta intervals from a prepared grid.
func occultationPathBoundaryThetaIntervalsFromGrid(
frame occultationPathFrame,
step float64,
discriminants []float64,
) []occultationPathThetaInterval {
intervals := make([]occultationPathThetaInterval, 0, 2)
for index, value := range discriminants {
previous := discriminants[(index+len(discriminants)-1)%len(discriminants)]
next := discriminants[(index+1)%len(discriminants)]
if value < previous || value < next {
continue
}
theta := occultationPathRefineBoundaryMaximum(frame, step*float64(index), step)
discriminant, b, scale, ok := occultationPathBoundaryLine(frame, theta)
tolerance := 1e-12 * math.Max(scale, 1)
if !ok || b >= 0 || discriminant < -tolerance {
continue
}
left, right, intervalOK := occultationPathBoundaryThetaInterval(frame, theta)
if intervalOK {
intervals = append(intervals, occultationPathThetaInterval{left: left, right: right})
}
}
return intervals
}
func occultationPathRefineBoundaryMaximum(frame occultationPathFrame, center, step float64) float64 {
left := center - step
right := center + step
const goldenRatio = 0.6180339887498949
x1 := right - goldenRatio*(right-left)
x2 := left + goldenRatio*(right-left)
f1, _, _, _ := occultationPathBoundaryLine(frame, x1)
f2, _, _, _ := occultationPathBoundaryLine(frame, x2)
for iteration := 0; iteration < 40; iteration++ {
if f1 < f2 {
left = x1
x1, f1 = x2, f2
x2 = left + goldenRatio*(right-left)
f2, _, _, _ = occultationPathBoundaryLine(frame, x2)
} else {
right = x2
x2, f2 = x1, f1
x1 = right - goldenRatio*(right-left)
f1, _, _, _ = occultationPathBoundaryLine(frame, x1)
}
}
return (left + right) / 2
}
func occultationPathOrientBoundarySamples(
first, second []OccultationPathPoint,
greatestTT float64,
) ([]OccultationPathPoint, []OccultationPathPoint) {
if len(first) == 0 || len(first) != len(second) {
return first, second
}
nearest := 0
nearestDelta := math.Inf(1)
for index := range first {
delta := math.Abs(centerTimeTT(first[index].Time) - greatestTT)
if delta < nearestDelta {
nearest = index
nearestDelta = delta
}
}
if first[nearest].Latitude >= second[nearest].Latitude {
return first, second
}
return second, first
}
func occultationPathCenterSamplesForFrame(
startTT, endTT, greatestTT float64,
frameAt occultationPathFrameFunc,
options OccultationPathOptions,
location *time.Location,
) ([]OccultationPathPoint, error) {
stepDays := float64(options.Step) / float64(24*time.Hour)
times := occultationPathSampleTimes(startTT, endTT, greatestTT, stepDays)
points := make([]OccultationPathPoint, 0, len(times))
for _, tt := range times {
point, ok := occultationPathCenterPointForFrame(tt, frameAt, location)
if ok && (len(points) == 0 || point.Time.After(points[len(points)-1].Time)) {
points = append(points, point)
}
}
if options.TargetSpacingKM > 0 {
return refineOccultationPathSpacingForFrame(points, frameAt, options.TargetSpacingKM, location)
}
return points, nil
}
func refineOccultationPathSpacingForFrame(
points []OccultationPathPoint,
frameAt occultationPathFrameFunc,
targetSpacingKM float64,
location *time.Location,
) ([]OccultationPathPoint, error) {
if len(points) < 2 || targetSpacingKM <= 0 {
return points, nil
}
refined := make([]OccultationPathPoint, 0, len(points))
refined = append(refined, points[0])
widthAt := func(tt float64) (float64, bool) {
_, _, width, ok := occultationPathLimitsAndWidthForFrame(tt, frameAt)
return width, ok
}
for i := 1; i < len(points); i++ {
segmentStart := len(refined) - 1
var err error
refined, err = appendOccultationPathSegmentForFrame(refined, points[i-1], points[i], frameAt, targetSpacingKM, location, 0)
if err != nil {
return nil, err
}
refineOccultationPathWidths(refined[segmentStart:], widthAt)
}
return refined, nil
}
func appendOccultationPathSegmentForFrame(
points []OccultationPathPoint,
start, end OccultationPathPoint,
frameAt occultationPathFrameFunc,
targetSpacingKM float64,
location *time.Location,
depth int,
) ([]OccultationPathPoint, error) {
distance := occultationPathDistanceKM(start, end)
if distance <= targetSpacingKM {
if len(points) >= occultationPathMaxSampleCount {
return nil, ErrOccultationPathSamplingLimit
}
return append(points, end), nil
}
if depth >= occultationPathMaxAdaptiveDepth || len(points) >= occultationPathMaxSampleCount {
return nil, ErrOccultationPathSamplingLimit
}
midTT := (centerTimeTT(start.Time) + centerTimeTT(end.Time)) / 2
midTime := occultationTTToLocation(midTT, location)
if !midTime.After(start.Time) || !midTime.Before(end.Time) {
return append(points, end), nil
}
mid, ok := occultationPathCenterPointForFrameWithoutWidth(midTT, frameAt, location)
if !ok {
return append(points, end), nil
}
mid.WidthKM = (start.WidthKM + end.WidthKM) / 2
var err error
points, err = appendOccultationPathSegmentForFrame(points, start, mid, frameAt, targetSpacingKM, location, depth+1)
if err != nil {
return nil, err
}
return appendOccultationPathSegmentForFrame(points, mid, end, frameAt, targetSpacingKM, location, depth+1)
}
func planetOccultationPathFrameAt(tt float64, config planetOccultationConfig) (occultationPathFrame, bool) {
return planetOccultationContactPathFrameAt(tt, config, false)
}
func planetOccultationTotalPathFrameAt(tt float64, config planetOccultationConfig) (occultationPathFrame, bool) {
return planetOccultationContactPathFrameAt(tt, config, true)
}
func planetOccultationContactPathFrameAt(tt float64, config planetOccultationConfig, total bool) (occultationPathFrame, bool) {
state := planetOccultationEphemerisStateAt(tt, config)
return planetOccultationContactPathFrameFromState(state, config, total)
}
func planetOccultationEphemerisStateAt(tt float64, config planetOccultationConfig) planetOccultationEphemerisState {
moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, -1)
moonDistance := HMoonAwayN(tt, -1)
planetRA, planetDec, planetDistanceAU := planetOccultationApparentPositionAndDistanceN(tt, config, -1)
planetDistance := planetDistanceAU * occultationPathAstronomicalUnitKM
return planetOccultationEphemerisState{
moonRA: moonRA, moonDec: moonDec, moonDistanceKM: moonDistance,
planetRA: planetRA, planetDec: planetDec, planetDistanceKM: planetDistance,
valid: finite(moonRA) && finite(moonDec) && finite(moonDistance) && moonDistance > 0 &&
finite(planetRA) && finite(planetDec) && finite(planetDistance) && planetDistance > 0,
}
}
func planetOccultationApparentPositionAndDistanceN(
tt float64,
config planetOccultationConfig,
n int,
) (float64, float64, float64) {
position, _, distanceAU := planetApparentGeocentricPositionAndDistanceN(config.planetIndex, tt, n)
ra, dec := planetApparentRaDecFromLoBo(tt, position.lo, position.bo)
return ra, dec, distanceAU
}
func planetOccultationContactPathFrameFromState(
state planetOccultationEphemerisState,
config planetOccultationConfig,
total bool,
) (occultationPathFrame, bool) {
if !state.valid || state.planetDistanceKM <= state.moonDistanceKM {
return occultationPathFrame{}, false
}
moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM)
target := occultationPathRaDecVector(state.planetRA, state.planetDec, state.planetDistanceKM)
moonToTarget := occultationPathSub(target, moon)
moonToTargetDistance := occultationPathNorm(moonToTarget)
moonRadius := math.Asin(moonEquatorialRadiusKM / state.moonDistanceKM)
moonRadiusKM := occultationPathNorm(moon) * math.Sin(moonRadius)
contactRadiusKM := moonRadiusKM + config.equatorialRadiusKM
if total {
contactRadiusKM = moonRadiusKM - config.equatorialRadiusKM
}
if moonToTargetDistance <= math.Abs(contactRadiusKM) {
return occultationPathFrame{}, false
}
axis := occultationPathUnit(occultationPathScale(moonToTarget, -1))
north := occultationPathVector{z: 1}
first := occultationPathCross(north, axis)
if occultationPathNorm(first) < 1e-12 {
first = occultationPathCross(occultationPathVector{x: 1}, axis)
}
first = occultationPathUnit(first)
second := occultationPathUnit(occultationPathCross(axis, first))
return occultationPathFrame{
moon: moon,
axis: axis,
first: first,
second: second,
moonRadius: moonRadius,
targetRadius: math.Asin(contactRadiusKM / moonToTargetDistance),
}, true
}
func (cache *planetOccultationEventCache) stateAt(tt float64) planetOccultationEphemerisState {
key := math.Float64bits(tt)
if state, ok := cache.states[key]; ok {
return state
}
if len(cache.states) >= planetOccultationEventCacheMaximumEntries {
for cachedKey := range cache.states {
delete(cache.states, cachedKey)
}
for cachedKey := range cache.outerFrames {
delete(cache.outerFrames, cachedKey)
}
for cachedKey := range cache.totalFrames {
delete(cache.totalFrames, cachedKey)
}
}
var state planetOccultationEphemerisState
interpolated := false
if cache.local != nil && cache.local.dense {
state, interpolated = cache.local.stateAt(tt)
}
if !interpolated {
state = planetOccultationEphemerisStateAt(tt, cache.config)
}
cache.states[key] = state
return state
}
func (cache *planetOccultationEventCache) prepareLocalEphemeris(center float64) {
if cache.local == nil {
cache.local = newPlanetOccultationLocalEphemeris(center, cache.config)
}
}
func (cache *planetOccultationEventCache) candidateFrameAt(tt float64) (occultationPathFrame, bool) {
return cache.candidateFrameAtKind(tt, false)
}
func (cache *planetOccultationEventCache) candidateTotalFrameAt(tt float64) (occultationPathFrame, bool) {
return cache.candidateFrameAtKind(tt, true)
}
func (cache *planetOccultationEventCache) candidateFrameAtKind(tt float64, total bool) (occultationPathFrame, bool) {
if cache.local != nil {
if state, ok := cache.local.stateAt(tt); ok {
return planetOccultationContactPathFrameFromState(state, cache.config, total)
}
}
return cache.frameAt(tt, total)
}
func (cache *planetOccultationEventCache) outerFrameAt(tt float64) (occultationPathFrame, bool) {
return cache.frameAt(tt, false)
}
func (cache *planetOccultationEventCache) totalFrameAt(tt float64) (occultationPathFrame, bool) {
return cache.frameAt(tt, true)
}
func (cache *planetOccultationEventCache) frameAt(tt float64, total bool) (occultationPathFrame, bool) {
key := math.Float64bits(tt)
frames := cache.outerFrames
if total {
frames = cache.totalFrames
}
if entry, ok := frames[key]; ok {
return entry.frame, entry.ok
}
frame, ok := planetOccultationContactPathFrameFromState(cache.stateAt(tt), cache.config, total)
if ok {
frame.boundary = &occultationPathBoundaryCache{}
}
frames[key] = planetOccultationFrameCacheEntry{frame: frame, ok: ok}
return frame, ok
}
func (cache *planetOccultationEventCache) riseSetContextAt(tt float64) occultationRiseSetContext {
state := cache.stateAt(tt)
return newOccultationRiseSetContext(
tt, state.moonRA, state.moonDec, state.moonDistanceKM,
state.planetRA, state.planetDec, state.planetDistanceKM, cache.config.equatorialRadiusKM,
)
}
func (cache *planetOccultationEventCache) candidateRiseSetContextAt(tt float64) occultationRiseSetContext {
var context occultationRiseSetContext
if cache.local != nil {
moonXYZ, targetXYZ, ok := cache.local.vectorsAt(tt)
if ok {
context = newOccultationRiseSetContextFromVectors(
tt,
moonXYZ,
targetXYZ,
true,
cache.config.equatorialRadiusKM,
)
} else {
context = cache.riseSetContextAt(tt)
}
} else {
context = cache.riseSetContextAt(tt)
}
return context
}
func (cache *planetOccultationEventCache) totalRiseSetContextAt(tt float64) occultationRiseSetContext {
return cache.riseSetContextAt(tt).withInternalContact()
}
func (cache *planetOccultationEventCache) candidateTotalRiseSetContextAt(tt float64) occultationRiseSetContext {
return cache.candidateRiseSetContextAt(tt).withInternalContact()
}
func occultationPathFrameHasBoundary(frame occultationPathFrame) bool {
_, _, ok := occultationPathBoundaryTangent(frame)
return ok
}
func occultationPathBoundaryEndpointForFrame(
tt float64,
frameAt occultationPathFrameFunc,
location *time.Location,
direction int,
) occultationPathEndpoint {
if _, ok := frameAt(tt); !ok {
return occultationPathEndpoint{}
}
for offset := 0; offset <= 3; offset++ {
candidateTT := tt + float64(direction*offset)*0.5/86400.0
frame, ok := frameAt(candidateTT)
if !ok {
continue
}
vector, _, valid := occultationPathBoundaryTangent(frame)
if valid {
return occultationPathEndpoint{point: occultationPathPointFromVector(candidateTT, vector, 0, location), valid: true}
}
}
return occultationPathEndpoint{}
}
func occultationPathCenterPointForFrame(
tt float64,
frameAt occultationPathFrameFunc,
location *time.Location,
) (OccultationPathPoint, bool) {
frame, ok := frameAt(tt)
if !ok {
return OccultationPathPoint{}, false
}
point, _, ok := occultationEarthLineIntersection(frame.moon, frame.axis)
if !ok {
return OccultationPathPoint{}, false
}
width := 0.0
if _, _, tangentWidth, limitsOK := occultationPathLimitsAndWidthForFrame(tt, frameAt); limitsOK {
width = tangentWidth
}
return occultationPathPointFromVectorWithMoon(tt, point, width, frame.moon, location), true
}
func occultationPathCenterPointForFrameWithoutWidth(
tt float64,
frameAt occultationPathFrameFunc,
location *time.Location,
) (OccultationPathPoint, bool) {
frame, ok := frameAt(tt)
if !ok {
return OccultationPathPoint{}, false
}
point, _, ok := occultationEarthLineIntersection(frame.moon, frame.axis)
if !ok {
return OccultationPathPoint{}, false
}
return occultationPathPointFromVectorWithMoon(tt, point, 0, frame.moon, location), true
}
func occultationPathBoundaryPointForFrame(
tt float64,
frameAt occultationPathFrameFunc,
location *time.Location,
) (OccultationPathPoint, bool) {
frame, ok := frameAt(tt)
if !ok {
return OccultationPathPoint{}, false
}
point, _, ok := occultationPathBoundaryTangent(frame)
if !ok {
return OccultationPathPoint{}, false
}
north, south, limitsOK := occultationPathScannedLimitsAtFrame(tt, frame)
return occultationPathPointFromVector(tt, point, occultationPathBoundaryWidth(north, south, limitsOK), location), true
}
func occultationPathTrackPointForFrame(
tt float64,
frameAt occultationPathFrameFunc,
location *time.Location,
) (OccultationPathPoint, bool) {
frame, ok := frameAt(tt)
if !ok {
return OccultationPathPoint{}, false
}
point, ok := occultationPathTrackReference(frame)
if !ok {
return OccultationPathPoint{}, false
}
width := 0.0
if _, _, candidate, limitsOK := occultationPathLimitsAndWidthForFrame(tt, frameAt); limitsOK {
width = candidate
}
return occultationPathPointFromVectorWithMoon(tt, point, width, frame.moon, location), true
}
func occultationPathLimitsAndWidthForFrame(
tt float64,
frameAt occultationPathFrameFunc,
) (occultationPathVector, occultationPathVector, float64, bool) {
frame, ok := frameAt(tt)
if !ok {
return occultationPathVector{}, occultationPathVector{}, 0, false
}
beforeFrame, beforeOK := frameAt(tt - occultationPathVelocityStepDays)
afterFrame, afterOK := frameAt(tt + occultationPathVelocityStepDays)
if !beforeOK || !afterOK {
north, south, scannedOK := occultationPathScannedLimitsForFrame(tt, frame)
return north, south, occultationPathBoundaryWidth(north, south, scannedOK), scannedOK
}
vx := afterFrame.moonProjectionX() - beforeFrame.moonProjectionX()
vy := afterFrame.moonProjectionY() - beforeFrame.moonProjectionY()
speed := math.Hypot(vx, vy)
if speed <= 1e-12 {
north, south, scannedOK := occultationPathScannedLimitsForFrame(tt, frame)
return north, south, occultationPathBoundaryWidth(north, south, scannedOK), scannedOK
}
planeCrossTrack := occultationPathUnit(occultationPathAdd(
occultationPathScale(frame.first, -vy/speed),
occultationPathScale(frame.second, vx/speed),
))
var centerFixed, groundCrossTrack occultationPathVector
groundWidthOK := false
center, centerOK := occultationPathTrackReference(frame)
before, beforeCenterOK := occultationPathTrackReference(beforeFrame)
after, afterCenterOK := occultationPathTrackReference(afterFrame)
centerRotation := occultationPathEarthRotation{}
if centerOK && beforeCenterOK && afterCenterOK {
centerRotation = occultationPathEarthRotationAt(tt)
centerFixed = occultationPathEarthFixedVectorWithRotation(center, centerRotation)
beforeFixed := occultationPathEarthFixedVector(tt-occultationPathVelocityStepDays, before)
afterFixed := occultationPathEarthFixedVector(tt+occultationPathVelocityStepDays, after)
polarRatioSquared := occultationPathEarthPolarRatio * occultationPathEarthPolarRatio
normal := occultationPathUnit(occultationPathVector{x: centerFixed.x, y: centerFixed.y, z: centerFixed.z / polarRatioSquared})
track := occultationPathSub(afterFixed, beforeFixed)
track = occultationPathSub(track, occultationPathScale(normal, occultationPathDot(track, normal)))
if occultationPathNorm(track) > 1e-12 {
groundCrossTrack = occultationPathUnit(occultationPathCross(normal, occultationPathUnit(track)))
groundWidthOK = true
}
}
minimumOffset := math.Inf(1)
maximumOffset := math.Inf(-1)
minimumGroundOffset := math.Inf(1)
maximumGroundOffset := math.Inf(-1)
var minimumPoint, maximumPoint occultationPathVector
var minimumGroundPoint, maximumGroundPoint occultationPathVector
consider := func(point occultationPathVector) {
offset := occultationPathDot(point, planeCrossTrack)
if offset < minimumOffset {
minimumOffset = offset
minimumPoint = point
}
if offset > maximumOffset {
maximumOffset = offset
maximumPoint = point
}
if groundWidthOK {
fixed := occultationPathEarthFixedVectorWithRotation(point, centerRotation)
groundOffset := occultationPathDot(occultationPathSub(fixed, centerFixed), groundCrossTrack)
if groundOffset < minimumGroundOffset {
minimumGroundOffset = groundOffset
minimumGroundPoint = point
}
if groundOffset > maximumGroundOffset {
maximumGroundOffset = groundOffset
maximumGroundPoint = point
}
}
}
if tangentPoint, tangentTheta, tangentOK := occultationPathBoundaryTangent(frame); tangentOK {
consider(tangentPoint)
if leftTheta, rightTheta, intervalOK := occultationPathBoundaryThetaInterval(frame, tangentTheta); intervalOK {
const intervalSamples = 128
for i := 0; i <= intervalSamples; i++ {
theta := leftTheta + (rightTheta-leftTheta)*float64(i)/intervalSamples
if point, _, pointOK := occultationPathBoundaryVector(frame, theta); pointOK {
consider(point)
}
}
// 末样本的等差写法与 rightTheta 相差 1 ULP:掠射时该 ULP 会把端点的判别式推成负值,
// 端点处的横向极值就被当成"无地面交点"丢掉,故端点原值必须补采一次。
if point, _, pointOK := occultationPathBoundaryVector(frame, rightTheta); pointOK {
consider(point)
}
}
}
for i := 0; i < occultationPathBoundaryScanPoints; i++ {
point, _, pointOK := occultationPathBoundaryVector(frame, 2*math.Pi*float64(i)/float64(occultationPathBoundaryScanPoints))
if !pointOK {
continue
}
consider(point)
}
if !finite(minimumOffset) || !finite(maximumOffset) {
return occultationPathVector{}, occultationPathVector{}, 0, false
}
width := maximumOffset - minimumOffset
if groundWidthOK && finite(minimumGroundOffset) && finite(maximumGroundOffset) {
width = maximumGroundOffset - minimumGroundOffset
// 有限目标会把月影打开或收束成圆锥;接近地平线时其影面投影可能折叠,使全球投影极值在远处地平线交点间跳变。地面轨迹极值仍位于掩带的同一物理侧。
// A finite target opens or closes the lunar shadow into a cone. Near the horizon its shadow-plane projection can fold, causing the global projected extremum to jump between distant horizon intersections. Ground-track extrema remain on the same physical sides of the band.
if frame.targetRadius != 0 {
return maximumGroundPoint, minimumGroundPoint, width, true
}
}
minimumLatitude := occultationPathGeodeticLatitude(minimumPoint)
maximumLatitude := occultationPathGeodeticLatitude(maximumPoint)
if maximumLatitude >= minimumLatitude {
return maximumPoint, minimumPoint, width, true
}
return minimumPoint, maximumPoint, width, true
}
// occultationPathFiniteCrossTrackExtrema scans the complete Earth-intersecting
// contact-cone arc and returns its physical cross-track extrema. For a finite
// target the visible arc can be much longer than the two horizon endpoints;
// those endpoints are only the moonrise/moonset closure, not the band sides.
func occultationPathFiniteCrossTrackExtrema(
tt float64,
frame, beforeFrame, afterFrame occultationPathFrame,
) (occultationPathVector, occultationPathVector, float64, bool) {
vx := afterFrame.moonProjectionX() - beforeFrame.moonProjectionX()
vy := afterFrame.moonProjectionY() - beforeFrame.moonProjectionY()
speed := math.Hypot(vx, vy)
if speed <= 1e-12 {
return occultationPathVector{}, occultationPathVector{}, 0, false
}
planeCrossTrack := occultationPathUnit(occultationPathAdd(
occultationPathScale(frame.first, -vy/speed),
occultationPathScale(frame.second, vx/speed),
))
var centerFixed, groundCrossTrack occultationPathVector
groundWidthOK := false
center, centerOK := occultationPathTrackReference(frame)
before, beforeCenterOK := occultationPathTrackReference(beforeFrame)
after, afterCenterOK := occultationPathTrackReference(afterFrame)
centerRotation := occultationPathEarthRotation{}
if centerOK && beforeCenterOK && afterCenterOK {
centerRotation = occultationPathEarthRotationAt(tt)
centerFixed = occultationPathEarthFixedVectorWithRotation(center, centerRotation)
beforeFixed := occultationPathEarthFixedVector(
tt-occultationPathVelocityStepDays, before,
)
afterFixed := occultationPathEarthFixedVector(
tt+occultationPathVelocityStepDays, after,
)
polarRatioSquared := occultationPathEarthPolarRatio * occultationPathEarthPolarRatio
normal := occultationPathUnit(occultationPathVector{
x: centerFixed.x, y: centerFixed.y, z: centerFixed.z / polarRatioSquared,
})
track := occultationPathSub(afterFixed, beforeFixed)
track = occultationPathSub(track, occultationPathScale(normal, occultationPathDot(track, normal)))
if occultationPathNorm(track) > 1e-12 {
groundCrossTrack = occultationPathUnit(occultationPathCross(normal, occultationPathUnit(track)))
groundWidthOK = true
}
}
minimumOffset, maximumOffset := math.Inf(1), math.Inf(-1)
minimumGroundOffset, maximumGroundOffset := math.Inf(1), math.Inf(-1)
var minimumPoint, maximumPoint occultationPathVector
var minimumGroundPoint, maximumGroundPoint occultationPathVector
consider := func(point occultationPathVector) {
offset := occultationPathDot(point, planeCrossTrack)
if offset < minimumOffset {
minimumOffset, minimumPoint = offset, point
}
if offset > maximumOffset {
maximumOffset, maximumPoint = offset, point
}
if groundWidthOK {
fixed := occultationPathEarthFixedVectorWithRotation(point, centerRotation)
groundOffset := occultationPathDot(
occultationPathSub(fixed, centerFixed), groundCrossTrack,
)
if groundOffset < minimumGroundOffset {
minimumGroundOffset, minimumGroundPoint = groundOffset, point
}
if groundOffset > maximumGroundOffset {
maximumGroundOffset, maximumGroundPoint = groundOffset, point
}
}
}
if tangentPoint, tangentTheta, tangentOK := occultationPathBoundaryTangent(frame); tangentOK {
consider(tangentPoint)
if leftTheta, rightTheta, intervalOK := occultationPathBoundaryThetaInterval(frame, tangentTheta); intervalOK {
for index := 0; index <= 128; index++ {
theta := leftTheta + (rightTheta-leftTheta)*float64(index)/128
if point, _, pointOK := occultationPathBoundaryVector(frame, theta); pointOK {
consider(point)
}
}
// 末样本的等差写法与 rightTheta 相差 1 ULP:掠射时该 ULP 会把端点的判别式推成负值,
// 端点处的极值就被当成"无地面交点"丢掉,故端点原值必须补采一次。
if point, _, pointOK := occultationPathBoundaryVector(frame, rightTheta); pointOK {
consider(point)
}
}
}
for _, interval := range occultationPathBoundaryThetaIntervals(frame) {
for index := 0; index <= 128; index++ {
theta := interval.left + (interval.right-interval.left)*float64(index)/128
if point, _, pointOK := occultationPathBoundaryVector(frame, theta); pointOK {
consider(point)
}
}
// 与 occultationPathLimitsAndWidthForFrame 同因:等差末样本与 interval.right 差 1 ULP,
// 掠射时会把端点判别式推成负值而丢掉该处极值,端点原值必须补采一次。
if point, _, pointOK := occultationPathBoundaryVector(frame, interval.right); pointOK {
consider(point)
}
}
if !finite(minimumOffset) || !finite(maximumOffset) {
return occultationPathVector{}, occultationPathVector{}, 0, false
}
if groundWidthOK && finite(minimumGroundOffset) && finite(maximumGroundOffset) {
width := maximumGroundOffset - minimumGroundOffset
if width > 0 {
return maximumGroundPoint, minimumGroundPoint, width, true
}
}
minimumLatitude := occultationPathGeodeticLatitude(minimumPoint)
maximumLatitude := occultationPathGeodeticLatitude(maximumPoint)
width := maximumOffset - minimumOffset
if maximumLatitude >= minimumLatitude {
return maximumPoint, minimumPoint, width, width > 0
}
return minimumPoint, maximumPoint, width, width > 0
}
func occultationPathScannedLimitsForFrame(
tt float64,
frame occultationPathFrame,
) (occultationPathVector, occultationPathVector, bool) {
return occultationPathScannedLimitsAtFrame(tt, frame)
}
func occultationPathCrossTrackWidthForFrame(
tt float64,
frameAt occultationPathFrameFunc,
) (float64, bool) {
first, second, _, ok := occultationPathCrossTrackLimitsForFrame(tt, frameAt)
if !ok {
return 0, false
}
width := occultationPathNorm(occultationPathSub(first, second))
return width, finite(width) && width > 0
}