2bf8478639
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
1690 lines
66 KiB
Go
1690 lines
66 KiB
Go
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
|
|
}
|