Files
astro/basic/occultation_planet_path.go
T

1690 lines
66 KiB
Go
Raw Normal View History

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(TD2UT(sample.tt, false)) * 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)
}
}
}
}
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)
}
}
}
}
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)
}
}
}
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
}