16c62a97d5
- 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试
1705 lines
67 KiB
Go
1705 lines
67 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(TT2UT1(sample.tt)) * 15
|
||
first[index] = occultationPathPointFromVectorWithMoonSidereal(
|
||
sample.tt, sample.first, 0, sample.moon, siderealDegrees, location,
|
||
)
|
||
second[index] = occultationPathPointFromVectorWithMoonSidereal(
|
||
sample.tt, sample.second, 0, sample.moon, siderealDegrees, location,
|
||
)
|
||
}
|
||
first, second = occultationPathDeduplicateBoundaryPoints(first, second)
|
||
return occultationPathOrientBoundarySamples(first, second, greatestTT)
|
||
}
|
||
|
||
type occultationPathBoundaryPairSample struct {
|
||
tt float64
|
||
first, second occultationPathVector
|
||
moon occultationPathVector
|
||
}
|
||
|
||
func appendOccultationPathBoundaryPairSegment(
|
||
samples []occultationPathBoundaryPairSample,
|
||
start, end occultationPathBoundaryPairSample,
|
||
frameAt occultationPathFrameFunc,
|
||
targetSpacingKM float64,
|
||
limitsAt func(float64, occultationPathFrameFunc) (occultationPathVector, occultationPathVector, occultationPathVector, bool),
|
||
useFiniteArcExtrema bool,
|
||
depth int,
|
||
) []occultationPathBoundaryPairSample {
|
||
if useFiniteArcExtrema {
|
||
end = occultationPathRepairContourBoundaryPair(start, end, frameAt, limitsAt)
|
||
}
|
||
end = occultationPathOrientBoundaryPair(start, end)
|
||
spacing := occultationPathBoundaryPairDirectSpacing(start, end)
|
||
if useFiniteArcExtrema {
|
||
// The public maps use Web Mercator. Near a pole a physically short
|
||
// ground-track step can span a much larger projected x/y distance, so
|
||
// a ground-distance-only sampler renders visible corners and notches.
|
||
// Refine only finite-disk contact contours using the same projection
|
||
// metric that the map consumes; ordinary event/path sampling remains
|
||
// on the cheaper physical-distance criterion.
|
||
projectedSpacing := occultationPathBoundaryPairWebMercatorSpacing(start, end)
|
||
projectedTarget := occultationPathBoundaryPairWebMercatorTarget(start, end, targetSpacingKM)
|
||
if projectedSpacing > projectedTarget {
|
||
spacing = math.Max(spacing, projectedSpacing*targetSpacingKM/projectedTarget)
|
||
}
|
||
}
|
||
if depth >= occultationPathMaxAdaptiveDepth || spacing <= targetSpacingKM {
|
||
return append(samples, end)
|
||
}
|
||
midTT := (start.tt + end.tt) / 2
|
||
if midTT <= start.tt || midTT >= end.tt {
|
||
return append(samples, end)
|
||
}
|
||
first, second, moon, ok := occultationPathContourBoundaryPairAt(
|
||
midTT, frameAt, limitsAt, useFiniteArcExtrema, &start,
|
||
)
|
||
if !ok {
|
||
return append(samples, end)
|
||
}
|
||
mid := occultationPathBoundaryPairSample{tt: midTT, first: first, second: second, moon: moon}
|
||
samples = appendOccultationPathBoundaryPairSegment(samples, start, mid, frameAt, targetSpacingKM, limitsAt, useFiniteArcExtrema, depth+1)
|
||
return appendOccultationPathBoundaryPairSegment(
|
||
samples, samples[len(samples)-1], end, frameAt, targetSpacingKM, limitsAt, useFiniteArcExtrema, depth+1,
|
||
)
|
||
}
|
||
|
||
const (
|
||
occultationPathWebMercatorRadiusKM = 6378.1366
|
||
occultationPathWebMercatorMaxLat = 85.05112878
|
||
)
|
||
|
||
func occultationPathBoundaryPairWebMercatorSpacing(
|
||
first, second occultationPathBoundaryPairSample,
|
||
) float64 {
|
||
return math.Max(
|
||
occultationPathWebMercatorPointSpacing(first.tt, first.first, second.tt, second.first),
|
||
occultationPathWebMercatorPointSpacing(first.tt, first.second, second.tt, second.second),
|
||
)
|
||
}
|
||
|
||
func occultationPathBoundaryPairWebMercatorTarget(
|
||
first, second occultationPathBoundaryPairSample,
|
||
targetSpacingKM float64,
|
||
) float64 {
|
||
if targetSpacingKM <= 0 {
|
||
return targetSpacingKM
|
||
}
|
||
_, firstStartLatitude := occultationPathGeodetic(first.tt, first.first)
|
||
_, firstEndLatitude := occultationPathGeodetic(second.tt, second.first)
|
||
_, secondStartLatitude := occultationPathGeodetic(first.tt, first.second)
|
||
_, secondEndLatitude := occultationPathGeodetic(second.tt, second.second)
|
||
latitude := math.Max(
|
||
math.Max(math.Abs(firstStartLatitude), math.Abs(firstEndLatitude)),
|
||
math.Max(math.Abs(secondStartLatitude), math.Abs(secondEndLatitude)),
|
||
) * math.Pi / 180
|
||
// Longitude is stretched by sec(latitude) in Web Mercator. Keep the
|
||
// projected contour step near the physical 40 km target at low latitudes,
|
||
// but cap it at roughly 10 km around the polar part of this event.
|
||
return targetSpacingKM * math.Max(0.75, math.Cos(latitude))
|
||
}
|
||
|
||
func occultationPathWebMercatorPointSpacing(
|
||
firstTT float64,
|
||
firstVector occultationPathVector,
|
||
secondTT float64,
|
||
secondVector occultationPathVector,
|
||
) float64 {
|
||
firstLongitude, firstLatitude := occultationPathGeodetic(firstTT, firstVector)
|
||
secondLongitude, secondLatitude := occultationPathGeodetic(secondTT, secondVector)
|
||
firstLatitude = math.Max(-occultationPathWebMercatorMaxLat, math.Min(occultationPathWebMercatorMaxLat, firstLatitude))
|
||
secondLatitude = math.Max(-occultationPathWebMercatorMaxLat, math.Min(occultationPathWebMercatorMaxLat, secondLatitude))
|
||
longitudeDelta := math.Remainder(secondLongitude-firstLongitude, 360) * math.Pi / 180
|
||
firstY := math.Log(math.Tan(math.Pi/4 + firstLatitude*math.Pi/360))
|
||
secondY := math.Log(math.Tan(math.Pi/4 + secondLatitude*math.Pi/360))
|
||
latitudeDelta := secondY - firstY
|
||
return occultationPathWebMercatorRadiusKM * math.Hypot(longitudeDelta, latitudeDelta)
|
||
}
|
||
|
||
func occultationPathContourBoundaryPairAt(
|
||
tt float64,
|
||
frameAt occultationPathFrameFunc,
|
||
limitsAt func(float64, occultationPathFrameFunc) (occultationPathVector, occultationPathVector, occultationPathVector, bool),
|
||
useFiniteArcExtrema bool,
|
||
previous *occultationPathBoundaryPairSample,
|
||
) (occultationPathVector, occultationPathVector, occultationPathVector, bool) {
|
||
first, second, moon, ok := limitsAt(tt, frameAt)
|
||
if !ok {
|
||
if useFiniteArcExtrema && previous != nil {
|
||
return previous.first, previous.second, previous.moon, true
|
||
}
|
||
return occultationPathVector{}, occultationPathVector{}, occultationPathVector{}, false
|
||
}
|
||
candidate := occultationPathBoundaryPairSample{tt: tt, first: first, second: second, moon: moon}
|
||
if !useFiniteArcExtrema || previous == nil {
|
||
return candidate.first, candidate.second, candidate.moon, true
|
||
}
|
||
candidate = occultationPathOrientBoundaryPair(*previous, candidate)
|
||
if occultationPathBoundaryPairDirectSpacing(*previous, candidate) <= occultationPathBoundarySpacingKM {
|
||
return candidate.first, candidate.second, candidate.moon, true
|
||
}
|
||
if frame, frameOK := frameAt(tt); frameOK && frame.targetRadius != 0 {
|
||
if first, second, ok := occultationPathContinueFiniteBoundaryPair(
|
||
frame, tt, *previous,
|
||
); ok {
|
||
return first, second, frame.moon, true
|
||
}
|
||
}
|
||
return candidate.first, candidate.second, candidate.moon, true
|
||
}
|
||
|
||
func occultationPathContinueFiniteBoundaryPair(
|
||
frame occultationPathFrame,
|
||
tt float64,
|
||
previous occultationPathBoundaryPairSample,
|
||
) (occultationPathVector, occultationPathVector, bool) {
|
||
intervals := occultationPathBoundaryThetaIntervals(frame)
|
||
if len(intervals) == 0 {
|
||
return occultationPathVector{}, occultationPathVector{}, false
|
||
}
|
||
currentRotation := occultationPathEarthRotationAt(tt)
|
||
previousRotation := occultationPathEarthRotationAt(previous.tt)
|
||
nearest := func(target occultationPathVector) (occultationPathVector, bool) {
|
||
targetFixed := occultationPathEarthFixedVectorWithRotation(target, previousRotation)
|
||
bestDistance := math.Inf(1)
|
||
var best occultationPathVector
|
||
for _, interval := range intervals {
|
||
const samples = 48
|
||
bestIndex := -1
|
||
for index := 0; index <= samples; index++ {
|
||
theta := interval.left + (interval.right-interval.left)*float64(index)/samples
|
||
point, _, ok := occultationPathBoundaryVector(frame, theta)
|
||
if !ok {
|
||
continue
|
||
}
|
||
fixed := occultationPathEarthFixedVectorWithRotation(point, currentRotation)
|
||
distance := occultationPathNorm(occultationPathSub(fixed, targetFixed))
|
||
if distance < bestDistance {
|
||
bestDistance, best, bestIndex = distance, point, index
|
||
}
|
||
}
|
||
if bestIndex < 0 {
|
||
continue
|
||
}
|
||
leftIndex := math.Max(float64(bestIndex-1), 0)
|
||
rightIndex := math.Min(float64(bestIndex+1), samples)
|
||
left := interval.left + (interval.right-interval.left)*leftIndex/samples
|
||
right := interval.left + (interval.right-interval.left)*rightIndex/samples
|
||
const goldenRatio = 0.6180339887498949
|
||
x1 := right - goldenRatio*(right-left)
|
||
x2 := left + goldenRatio*(right-left)
|
||
distanceAt := func(theta float64) (float64, occultationPathVector, bool) {
|
||
point, _, ok := occultationPathBoundaryVector(frame, theta)
|
||
if !ok {
|
||
return math.Inf(1), occultationPathVector{}, false
|
||
}
|
||
fixed := occultationPathEarthFixedVectorWithRotation(point, currentRotation)
|
||
return occultationPathNorm(occultationPathSub(fixed, targetFixed)), point, true
|
||
}
|
||
f1, _, _ := distanceAt(x1)
|
||
f2, _, _ := distanceAt(x2)
|
||
for iteration := 0; iteration < 24; iteration++ {
|
||
if f1 > f2 {
|
||
left = x1
|
||
x1, f1 = x2, f2
|
||
x2 = left + goldenRatio*(right-left)
|
||
f2, _, _ = distanceAt(x2)
|
||
} else {
|
||
right = x2
|
||
x2, f2 = x1, f1
|
||
x1 = right - goldenRatio*(right-left)
|
||
f1, _, _ = distanceAt(x1)
|
||
}
|
||
}
|
||
distance, point, ok := distanceAt((left + right) / 2)
|
||
if ok && distance < bestDistance {
|
||
bestDistance, best = distance, point
|
||
}
|
||
}
|
||
return best, finite(bestDistance)
|
||
}
|
||
first, firstOK := nearest(previous.first)
|
||
second, secondOK := nearest(previous.second)
|
||
if !firstOK || !secondOK || occultationPathNorm(occultationPathSub(first, second)) < 1 {
|
||
return occultationPathVector{}, occultationPathVector{}, false
|
||
}
|
||
return first, second, true
|
||
}
|
||
|
||
func occultationPathRepairContourBoundaryPair(
|
||
previous, current occultationPathBoundaryPairSample,
|
||
frameAt occultationPathFrameFunc,
|
||
limitsAt func(float64, occultationPathFrameFunc) (occultationPathVector, occultationPathVector, occultationPathVector, bool),
|
||
) occultationPathBoundaryPairSample {
|
||
current = occultationPathOrientBoundaryPair(previous, current)
|
||
if occultationPathBoundaryPairDirectSpacing(previous, current) <= occultationPathBoundarySpacingKM {
|
||
return current
|
||
}
|
||
if frame, ok := frameAt(current.tt); ok && frame.targetRadius != 0 {
|
||
if first, second, ok := occultationPathContinueFiniteBoundaryPair(
|
||
frame, current.tt, previous,
|
||
); ok {
|
||
continued := occultationPathOrientBoundaryPair(previous, occultationPathBoundaryPairSample{
|
||
tt: current.tt, first: first, second: second, moon: frame.moon,
|
||
})
|
||
if occultationPathBoundaryPairDirectSpacing(previous, continued) <=
|
||
occultationPathBoundaryPairDirectSpacing(previous, current) {
|
||
return continued
|
||
}
|
||
}
|
||
if _, _, _, finiteOK := limitsAt(current.tt, frameAt); !finiteOK {
|
||
return previous
|
||
}
|
||
}
|
||
_ = limitsAt
|
||
return current
|
||
}
|
||
|
||
func occultationPathOrientBoundaryPair(
|
||
previous, current occultationPathBoundaryPairSample,
|
||
) occultationPathBoundaryPairSample {
|
||
direct, swapped := occultationPathBoundaryPairSpacings(previous, current)
|
||
if swapped < direct {
|
||
current.first, current.second = current.second, current.first
|
||
}
|
||
return current
|
||
}
|
||
|
||
func occultationPathBoundaryPairDirectSpacing(first, second occultationPathBoundaryPairSample) float64 {
|
||
direct, _ := occultationPathBoundaryPairSpacings(first, second)
|
||
return direct
|
||
}
|
||
|
||
func occultationPathBoundaryPairSpacings(first, second occultationPathBoundaryPairSample) (float64, float64) {
|
||
firstRotation := occultationPathEarthRotationAt(first.tt)
|
||
secondRotation := occultationPathEarthRotationAt(second.tt)
|
||
firstA := occultationPathEarthFixedVectorWithRotation(first.first, firstRotation)
|
||
firstB := occultationPathEarthFixedVectorWithRotation(first.second, firstRotation)
|
||
secondA := occultationPathEarthFixedVectorWithRotation(second.first, secondRotation)
|
||
secondB := occultationPathEarthFixedVectorWithRotation(second.second, secondRotation)
|
||
direct := math.Max(
|
||
occultationPathNorm(occultationPathSub(secondA, firstA)),
|
||
occultationPathNorm(occultationPathSub(secondB, firstB)),
|
||
)
|
||
swapped := math.Max(
|
||
occultationPathNorm(occultationPathSub(secondB, firstA)),
|
||
occultationPathNorm(occultationPathSub(secondA, firstB)),
|
||
)
|
||
return direct, swapped
|
||
}
|
||
|
||
func occultationPathCrossTrackLimitsForFrame(
|
||
tt float64,
|
||
frameAt occultationPathFrameFunc,
|
||
) (occultationPathVector, occultationPathVector, occultationPathVector, bool) {
|
||
frame, ok := frameAt(tt)
|
||
before, beforeOK := frameAt(tt - occultationPathVelocityStepDays)
|
||
after, afterOK := frameAt(tt + occultationPathVelocityStepDays)
|
||
if !ok || !beforeOK || !afterOK {
|
||
return occultationPathVector{}, occultationPathVector{}, occultationPathVector{}, false
|
||
}
|
||
vx := after.moonProjectionX() - before.moonProjectionX()
|
||
vy := after.moonProjectionY() - before.moonProjectionY()
|
||
if math.Hypot(vx, vy) <= 1e-12 {
|
||
return occultationPathVector{}, occultationPathVector{}, occultationPathVector{}, false
|
||
}
|
||
|
||
// 复用日食中心线构造:取基准面中垂直于运动方向的两条影锥母线。点源掠过阶段将缺失母线限制到可见角度区间;有限目标使用最近可见区间两端,直到两条横向母线分别与地球相交。
|
||
// Match the solar-eclipse central-path construction: take the two shadow generators perpendicular to motion in the fundamental plane. During a grazing point-source phase, clamp a missing generator to the visible-angle interval. For a finite target, use both ends of the nearest visible interval until the two cross-track generators intersect Earth independently.
|
||
theta := math.Atan2(vx, -vy)
|
||
first, _, firstOK := occultationPathBoundaryVector(frame, theta)
|
||
second, _, secondOK := occultationPathBoundaryVector(frame, theta+math.Pi)
|
||
if frame.targetRadius == 0 {
|
||
if !firstOK {
|
||
first, firstOK = occultationPathBoundaryAtNearestPointSourceTheta(frame, theta)
|
||
}
|
||
if !secondOK {
|
||
second, secondOK = occultationPathBoundaryAtNearestPointSourceTheta(frame, theta+math.Pi)
|
||
}
|
||
if !firstOK || !secondOK {
|
||
return occultationPathVector{}, occultationPathVector{}, occultationPathVector{}, false
|
||
}
|
||
return first, second, frame.moon, true
|
||
}
|
||
intervals := occultationPathBoundaryThetaIntervals(frame)
|
||
interval, intervalOK := occultationPathNearestThetaInterval(intervals, theta)
|
||
if firstOK && secondOK {
|
||
if intervalOK && occultationPathAngleDistance(interval.left, theta) > occultationPathAngleDistance(interval.left, theta+math.Pi) {
|
||
first, second = second, first
|
||
}
|
||
return first, second, frame.moon, true
|
||
}
|
||
if !intervalOK {
|
||
return occultationPathVector{}, occultationPathVector{}, occultationPathVector{}, false
|
||
}
|
||
first, _, firstOK = occultationPathBoundaryVector(frame, interval.left)
|
||
second, _, secondOK = occultationPathBoundaryVector(frame, interval.right)
|
||
if !firstOK || !secondOK {
|
||
return occultationPathVector{}, occultationPathVector{}, occultationPathVector{}, false
|
||
}
|
||
return first, second, frame.moon, true
|
||
}
|
||
|
||
// occultationPathContourCrossTrackLimitsForFrame is used only for the static
|
||
// contact-contour band. A finite target may expose one long visible
|
||
// contact-cone arc; its horizon endpoints are closure points, not necessarily
|
||
// the two cross-track sides of the band. Scan that arc for the physical sides,
|
||
// while retaining the normal paired-limit fallback for grazing/degenerate
|
||
// samples.
|
||
func occultationPathContourCrossTrackLimitsForFrame(
|
||
tt float64,
|
||
frameAt occultationPathFrameFunc,
|
||
) (occultationPathVector, occultationPathVector, occultationPathVector, bool) {
|
||
frame, ok := frameAt(tt)
|
||
if !ok || frame.targetRadius == 0 {
|
||
return occultationPathCrossTrackLimitsForFrame(tt, frameAt)
|
||
}
|
||
before, beforeOK := frameAt(tt - occultationPathVelocityStepDays)
|
||
after, afterOK := frameAt(tt + occultationPathVelocityStepDays)
|
||
if !beforeOK || !afterOK {
|
||
return occultationPathCrossTrackLimitsForFrame(tt, frameAt)
|
||
}
|
||
first, second, _, extremaOK := occultationPathFiniteCrossTrackExtrema(tt, frame, before, after)
|
||
if extremaOK {
|
||
return first, second, frame.moon, true
|
||
}
|
||
return occultationPathCrossTrackLimitsForFrame(tt, frameAt)
|
||
}
|
||
|
||
func occultationPathAngleDistance(first, second float64) float64 {
|
||
return math.Abs(math.Remainder(first-second, 2*math.Pi))
|
||
}
|
||
|
||
func occultationPathBoundaryAtNearestPointSourceTheta(
|
||
frame occultationPathFrame,
|
||
theta float64,
|
||
) (occultationPathVector, bool) {
|
||
_, tangentTheta, tangentOK := occultationPathBoundaryTangent(frame)
|
||
if !tangentOK {
|
||
return occultationPathVector{}, false
|
||
}
|
||
left, right, intervalOK := occultationPathBoundaryThetaInterval(frame, tangentTheta)
|
||
if !intervalOK {
|
||
return occultationPathVector{}, false
|
||
}
|
||
middle := (left + right) / 2
|
||
theta += 2 * math.Pi * math.Round((middle-theta)/(2*math.Pi))
|
||
if theta < left {
|
||
theta = left
|
||
} else if theta > right {
|
||
theta = right
|
||
}
|
||
point, _, ok := occultationPathBoundaryVector(frame, theta)
|
||
return point, ok
|
||
}
|
||
|
||
type occultationPathThetaInterval struct {
|
||
left, right float64
|
||
}
|
||
|
||
func occultationPathNearestThetaInterval(
|
||
intervals []occultationPathThetaInterval,
|
||
theta float64,
|
||
) (occultationPathThetaInterval, bool) {
|
||
var closest occultationPathThetaInterval
|
||
closestDistance := math.Inf(1)
|
||
for _, interval := range intervals {
|
||
middle := (interval.left + interval.right) / 2
|
||
firstDelta := math.Abs(math.Remainder(theta-middle, 2*math.Pi))
|
||
secondDelta := math.Abs(math.Remainder(theta+math.Pi-middle, 2*math.Pi))
|
||
distance := math.Min(firstDelta, secondDelta)
|
||
if distance < closestDistance {
|
||
closest = interval
|
||
closestDistance = distance
|
||
}
|
||
}
|
||
if !finite(closestDistance) {
|
||
return occultationPathThetaInterval{}, false
|
||
}
|
||
return closest, true
|
||
}
|
||
|
||
func occultationPathBoundaryThetaIntervals(frame occultationPathFrame) []occultationPathThetaInterval {
|
||
if frame.boundary != nil {
|
||
frame.boundary.boundaryOnce.Do(func() { occultationPathBoundaryPrecompute(frame) })
|
||
return frame.boundary.intervals
|
||
}
|
||
return occultationPathBoundaryThetaIntervalsUncached(frame)
|
||
}
|
||
|
||
// occultationPathBoundaryThetaIntervalsUncached 是可见 θ 区间扫描本体;调用方通过 frame 级缓存复用结果。
|
||
// occultationPathBoundaryThetaIntervalsUncached is the visible-theta scan itself; callers reuse it through the frame cache.
|
||
func occultationPathBoundaryThetaIntervalsUncached(frame occultationPathFrame) []occultationPathThetaInterval {
|
||
step := 2 * math.Pi / float64(occultationPathBoundaryScanPoints)
|
||
discriminants := make([]float64, occultationPathBoundaryScanPoints)
|
||
occultationPathBoundaryFillGrid(frame, discriminants)
|
||
return occultationPathBoundaryThetaIntervalsFromGrid(frame, step, discriminants)
|
||
}
|
||
|
||
// occultationPathBoundaryFillGrid 计算整圈 720 点判别式网格,失败点记为 -Inf。
|
||
// occultationPathBoundaryFillGrid evaluates the 720-point discriminant grid, marking failures as -Inf.
|
||
func occultationPathBoundaryFillGrid(frame occultationPathFrame, discriminants []float64) {
|
||
step := 2 * math.Pi / float64(occultationPathBoundaryScanPoints)
|
||
for index := range discriminants {
|
||
discriminant, _, _, ok := occultationPathBoundaryLine(frame, step*float64(index))
|
||
if !ok {
|
||
discriminant = math.Inf(-1)
|
||
}
|
||
discriminants[index] = discriminant
|
||
}
|
||
}
|
||
|
||
// occultationPathBoundaryThetaIntervalsFromGrid 在已算好的网格上恢复可见 θ 区间。
|
||
// occultationPathBoundaryThetaIntervalsFromGrid recovers the visible theta intervals from a prepared grid.
|
||
func occultationPathBoundaryThetaIntervalsFromGrid(
|
||
frame occultationPathFrame,
|
||
step float64,
|
||
discriminants []float64,
|
||
) []occultationPathThetaInterval {
|
||
intervals := make([]occultationPathThetaInterval, 0, 2)
|
||
for index, value := range discriminants {
|
||
previous := discriminants[(index+len(discriminants)-1)%len(discriminants)]
|
||
next := discriminants[(index+1)%len(discriminants)]
|
||
if value < previous || value < next {
|
||
continue
|
||
}
|
||
theta := occultationPathRefineBoundaryMaximum(frame, step*float64(index), step)
|
||
discriminant, b, scale, ok := occultationPathBoundaryLine(frame, theta)
|
||
tolerance := 1e-12 * math.Max(scale, 1)
|
||
if !ok || b >= 0 || discriminant < -tolerance {
|
||
continue
|
||
}
|
||
left, right, intervalOK := occultationPathBoundaryThetaInterval(frame, theta)
|
||
if intervalOK {
|
||
intervals = append(intervals, occultationPathThetaInterval{left: left, right: right})
|
||
}
|
||
}
|
||
return intervals
|
||
}
|
||
|
||
func occultationPathRefineBoundaryMaximum(frame occultationPathFrame, center, step float64) float64 {
|
||
left := center - step
|
||
right := center + step
|
||
const goldenRatio = 0.6180339887498949
|
||
x1 := right - goldenRatio*(right-left)
|
||
x2 := left + goldenRatio*(right-left)
|
||
f1, _, _, _ := occultationPathBoundaryLine(frame, x1)
|
||
f2, _, _, _ := occultationPathBoundaryLine(frame, x2)
|
||
for iteration := 0; iteration < 40; iteration++ {
|
||
if f1 < f2 {
|
||
left = x1
|
||
x1, f1 = x2, f2
|
||
x2 = left + goldenRatio*(right-left)
|
||
f2, _, _, _ = occultationPathBoundaryLine(frame, x2)
|
||
} else {
|
||
right = x2
|
||
x2, f2 = x1, f1
|
||
x1 = right - goldenRatio*(right-left)
|
||
f1, _, _, _ = occultationPathBoundaryLine(frame, x1)
|
||
}
|
||
}
|
||
return (left + right) / 2
|
||
}
|
||
|
||
func occultationPathOrientBoundarySamples(
|
||
first, second []OccultationPathPoint,
|
||
greatestTT float64,
|
||
) ([]OccultationPathPoint, []OccultationPathPoint) {
|
||
if len(first) == 0 || len(first) != len(second) {
|
||
return first, second
|
||
}
|
||
nearest := 0
|
||
nearestDelta := math.Inf(1)
|
||
for index := range first {
|
||
delta := math.Abs(centerTimeTT(first[index].Time) - greatestTT)
|
||
if delta < nearestDelta {
|
||
nearest = index
|
||
nearestDelta = delta
|
||
}
|
||
}
|
||
if first[nearest].Latitude >= second[nearest].Latitude {
|
||
return first, second
|
||
}
|
||
return second, first
|
||
}
|
||
|
||
func occultationPathCenterSamplesForFrame(
|
||
startTT, endTT, greatestTT float64,
|
||
frameAt occultationPathFrameFunc,
|
||
options OccultationPathOptions,
|
||
location *time.Location,
|
||
) ([]OccultationPathPoint, error) {
|
||
stepDays := float64(options.Step) / float64(24*time.Hour)
|
||
times := occultationPathSampleTimes(startTT, endTT, greatestTT, stepDays)
|
||
points := make([]OccultationPathPoint, 0, len(times))
|
||
for _, tt := range times {
|
||
point, ok := occultationPathCenterPointForFrame(tt, frameAt, location)
|
||
if ok && (len(points) == 0 || point.Time.After(points[len(points)-1].Time)) {
|
||
points = append(points, point)
|
||
}
|
||
}
|
||
if options.TargetSpacingKM > 0 {
|
||
return refineOccultationPathSpacingForFrame(points, frameAt, options.TargetSpacingKM, location)
|
||
}
|
||
return points, nil
|
||
}
|
||
|
||
func refineOccultationPathSpacingForFrame(
|
||
points []OccultationPathPoint,
|
||
frameAt occultationPathFrameFunc,
|
||
targetSpacingKM float64,
|
||
location *time.Location,
|
||
) ([]OccultationPathPoint, error) {
|
||
if len(points) < 2 || targetSpacingKM <= 0 {
|
||
return points, nil
|
||
}
|
||
refined := make([]OccultationPathPoint, 0, len(points))
|
||
refined = append(refined, points[0])
|
||
widthAt := func(tt float64) (float64, bool) {
|
||
_, _, width, ok := occultationPathLimitsAndWidthForFrame(tt, frameAt)
|
||
return width, ok
|
||
}
|
||
for i := 1; i < len(points); i++ {
|
||
segmentStart := len(refined) - 1
|
||
var err error
|
||
refined, err = appendOccultationPathSegmentForFrame(refined, points[i-1], points[i], frameAt, targetSpacingKM, location, 0)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
refineOccultationPathWidths(refined[segmentStart:], widthAt)
|
||
}
|
||
return refined, nil
|
||
}
|
||
|
||
func appendOccultationPathSegmentForFrame(
|
||
points []OccultationPathPoint,
|
||
start, end OccultationPathPoint,
|
||
frameAt occultationPathFrameFunc,
|
||
targetSpacingKM float64,
|
||
location *time.Location,
|
||
depth int,
|
||
) ([]OccultationPathPoint, error) {
|
||
distance := occultationPathDistanceKM(start, end)
|
||
if distance <= targetSpacingKM {
|
||
if len(points) >= occultationPathMaxSampleCount {
|
||
return nil, ErrOccultationPathSamplingLimit
|
||
}
|
||
return append(points, end), nil
|
||
}
|
||
if depth >= occultationPathMaxAdaptiveDepth || len(points) >= occultationPathMaxSampleCount {
|
||
return nil, ErrOccultationPathSamplingLimit
|
||
}
|
||
midTT := (centerTimeTT(start.Time) + centerTimeTT(end.Time)) / 2
|
||
midTime := occultationTTToLocation(midTT, location)
|
||
if !midTime.After(start.Time) || !midTime.Before(end.Time) {
|
||
return append(points, end), nil
|
||
}
|
||
mid, ok := occultationPathCenterPointForFrameWithoutWidth(midTT, frameAt, location)
|
||
if !ok {
|
||
return append(points, end), nil
|
||
}
|
||
mid.WidthKM = (start.WidthKM + end.WidthKM) / 2
|
||
var err error
|
||
points, err = appendOccultationPathSegmentForFrame(points, start, mid, frameAt, targetSpacingKM, location, depth+1)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return appendOccultationPathSegmentForFrame(points, mid, end, frameAt, targetSpacingKM, location, depth+1)
|
||
}
|
||
|
||
func planetOccultationPathFrameAt(tt float64, config planetOccultationConfig) (occultationPathFrame, bool) {
|
||
return planetOccultationContactPathFrameAt(tt, config, false)
|
||
}
|
||
|
||
func planetOccultationTotalPathFrameAt(tt float64, config planetOccultationConfig) (occultationPathFrame, bool) {
|
||
return planetOccultationContactPathFrameAt(tt, config, true)
|
||
}
|
||
|
||
func planetOccultationContactPathFrameAt(tt float64, config planetOccultationConfig, total bool) (occultationPathFrame, bool) {
|
||
state := planetOccultationEphemerisStateAt(tt, config)
|
||
return planetOccultationContactPathFrameFromState(state, config, total)
|
||
}
|
||
|
||
func planetOccultationEphemerisStateAt(tt float64, config planetOccultationConfig) planetOccultationEphemerisState {
|
||
moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, -1)
|
||
moonDistance := HMoonAwayN(tt, -1)
|
||
planetRA, planetDec, planetDistanceAU := planetOccultationApparentPositionAndDistanceN(tt, config, -1)
|
||
planetDistance := planetDistanceAU * occultationPathAstronomicalUnitKM
|
||
return planetOccultationEphemerisState{
|
||
moonRA: moonRA, moonDec: moonDec, moonDistanceKM: moonDistance,
|
||
planetRA: planetRA, planetDec: planetDec, planetDistanceKM: planetDistance,
|
||
valid: finite(moonRA) && finite(moonDec) && finite(moonDistance) && moonDistance > 0 &&
|
||
finite(planetRA) && finite(planetDec) && finite(planetDistance) && planetDistance > 0,
|
||
}
|
||
}
|
||
|
||
func planetOccultationApparentPositionAndDistanceN(
|
||
tt float64,
|
||
config planetOccultationConfig,
|
||
n int,
|
||
) (float64, float64, float64) {
|
||
position, _, distanceAU := planetApparentGeocentricPositionAndDistanceN(config.planetIndex, tt, n)
|
||
ra, dec := planetApparentRaDecFromLoBo(tt, position.lo, position.bo)
|
||
return ra, dec, distanceAU
|
||
}
|
||
|
||
func planetOccultationContactPathFrameFromState(
|
||
state planetOccultationEphemerisState,
|
||
config planetOccultationConfig,
|
||
total bool,
|
||
) (occultationPathFrame, bool) {
|
||
if !state.valid || state.planetDistanceKM <= state.moonDistanceKM {
|
||
return occultationPathFrame{}, false
|
||
}
|
||
moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM)
|
||
target := occultationPathRaDecVector(state.planetRA, state.planetDec, state.planetDistanceKM)
|
||
moonToTarget := occultationPathSub(target, moon)
|
||
moonToTargetDistance := occultationPathNorm(moonToTarget)
|
||
moonRadius := math.Asin(moonEquatorialRadiusKM / state.moonDistanceKM)
|
||
moonRadiusKM := occultationPathNorm(moon) * math.Sin(moonRadius)
|
||
contactRadiusKM := moonRadiusKM + config.equatorialRadiusKM
|
||
if total {
|
||
contactRadiusKM = moonRadiusKM - config.equatorialRadiusKM
|
||
}
|
||
if moonToTargetDistance <= math.Abs(contactRadiusKM) {
|
||
return occultationPathFrame{}, false
|
||
}
|
||
axis := occultationPathUnit(occultationPathScale(moonToTarget, -1))
|
||
north := occultationPathVector{z: 1}
|
||
first := occultationPathCross(north, axis)
|
||
if occultationPathNorm(first) < 1e-12 {
|
||
first = occultationPathCross(occultationPathVector{x: 1}, axis)
|
||
}
|
||
first = occultationPathUnit(first)
|
||
second := occultationPathUnit(occultationPathCross(axis, first))
|
||
return occultationPathFrame{
|
||
moon: moon,
|
||
axis: axis,
|
||
first: first,
|
||
second: second,
|
||
moonRadius: moonRadius,
|
||
targetRadius: math.Asin(contactRadiusKM / moonToTargetDistance),
|
||
}, true
|
||
}
|
||
|
||
func (cache *planetOccultationEventCache) stateAt(tt float64) planetOccultationEphemerisState {
|
||
key := math.Float64bits(tt)
|
||
if state, ok := cache.states[key]; ok {
|
||
return state
|
||
}
|
||
if len(cache.states) >= planetOccultationEventCacheMaximumEntries {
|
||
for cachedKey := range cache.states {
|
||
delete(cache.states, cachedKey)
|
||
}
|
||
for cachedKey := range cache.outerFrames {
|
||
delete(cache.outerFrames, cachedKey)
|
||
}
|
||
for cachedKey := range cache.totalFrames {
|
||
delete(cache.totalFrames, cachedKey)
|
||
}
|
||
}
|
||
var state planetOccultationEphemerisState
|
||
interpolated := false
|
||
if cache.local != nil && cache.local.dense {
|
||
state, interpolated = cache.local.stateAt(tt)
|
||
}
|
||
if !interpolated {
|
||
state = planetOccultationEphemerisStateAt(tt, cache.config)
|
||
}
|
||
cache.states[key] = state
|
||
return state
|
||
}
|
||
|
||
func (cache *planetOccultationEventCache) prepareLocalEphemeris(center float64) {
|
||
if cache.local == nil {
|
||
cache.local = newPlanetOccultationLocalEphemeris(center, cache.config)
|
||
}
|
||
}
|
||
|
||
func (cache *planetOccultationEventCache) candidateFrameAt(tt float64) (occultationPathFrame, bool) {
|
||
return cache.candidateFrameAtKind(tt, false)
|
||
}
|
||
|
||
func (cache *planetOccultationEventCache) candidateTotalFrameAt(tt float64) (occultationPathFrame, bool) {
|
||
return cache.candidateFrameAtKind(tt, true)
|
||
}
|
||
|
||
func (cache *planetOccultationEventCache) candidateFrameAtKind(tt float64, total bool) (occultationPathFrame, bool) {
|
||
if cache.local != nil {
|
||
if state, ok := cache.local.stateAt(tt); ok {
|
||
return planetOccultationContactPathFrameFromState(state, cache.config, total)
|
||
}
|
||
}
|
||
return cache.frameAt(tt, total)
|
||
}
|
||
|
||
func (cache *planetOccultationEventCache) outerFrameAt(tt float64) (occultationPathFrame, bool) {
|
||
return cache.frameAt(tt, false)
|
||
}
|
||
|
||
func (cache *planetOccultationEventCache) totalFrameAt(tt float64) (occultationPathFrame, bool) {
|
||
return cache.frameAt(tt, true)
|
||
}
|
||
|
||
func (cache *planetOccultationEventCache) frameAt(tt float64, total bool) (occultationPathFrame, bool) {
|
||
key := math.Float64bits(tt)
|
||
frames := cache.outerFrames
|
||
if total {
|
||
frames = cache.totalFrames
|
||
}
|
||
if entry, ok := frames[key]; ok {
|
||
return entry.frame, entry.ok
|
||
}
|
||
frame, ok := planetOccultationContactPathFrameFromState(cache.stateAt(tt), cache.config, total)
|
||
if ok {
|
||
frame.boundary = &occultationPathBoundaryCache{}
|
||
}
|
||
frames[key] = planetOccultationFrameCacheEntry{frame: frame, ok: ok}
|
||
return frame, ok
|
||
}
|
||
|
||
func (cache *planetOccultationEventCache) riseSetContextAt(tt float64) occultationRiseSetContext {
|
||
state := cache.stateAt(tt)
|
||
return newOccultationRiseSetContext(
|
||
tt, state.moonRA, state.moonDec, state.moonDistanceKM,
|
||
state.planetRA, state.planetDec, state.planetDistanceKM, cache.config.equatorialRadiusKM,
|
||
)
|
||
}
|
||
|
||
func (cache *planetOccultationEventCache) candidateRiseSetContextAt(tt float64) occultationRiseSetContext {
|
||
var context occultationRiseSetContext
|
||
if cache.local != nil {
|
||
moonXYZ, targetXYZ, ok := cache.local.vectorsAt(tt)
|
||
if ok {
|
||
context = newOccultationRiseSetContextFromVectors(
|
||
tt,
|
||
moonXYZ,
|
||
targetXYZ,
|
||
true,
|
||
cache.config.equatorialRadiusKM,
|
||
)
|
||
} else {
|
||
context = cache.riseSetContextAt(tt)
|
||
}
|
||
} else {
|
||
context = cache.riseSetContextAt(tt)
|
||
}
|
||
return context
|
||
}
|
||
|
||
func (cache *planetOccultationEventCache) totalRiseSetContextAt(tt float64) occultationRiseSetContext {
|
||
return cache.riseSetContextAt(tt).withInternalContact()
|
||
}
|
||
|
||
func (cache *planetOccultationEventCache) candidateTotalRiseSetContextAt(tt float64) occultationRiseSetContext {
|
||
return cache.candidateRiseSetContextAt(tt).withInternalContact()
|
||
}
|
||
|
||
func occultationPathFrameHasBoundary(frame occultationPathFrame) bool {
|
||
_, _, ok := occultationPathBoundaryTangent(frame)
|
||
return ok
|
||
}
|
||
|
||
func occultationPathBoundaryEndpointForFrame(
|
||
tt float64,
|
||
frameAt occultationPathFrameFunc,
|
||
location *time.Location,
|
||
direction int,
|
||
) occultationPathEndpoint {
|
||
if _, ok := frameAt(tt); !ok {
|
||
return occultationPathEndpoint{}
|
||
}
|
||
for offset := 0; offset <= 3; offset++ {
|
||
candidateTT := tt + float64(direction*offset)*0.5/86400.0
|
||
frame, ok := frameAt(candidateTT)
|
||
if !ok {
|
||
continue
|
||
}
|
||
vector, _, valid := occultationPathBoundaryTangent(frame)
|
||
if valid {
|
||
return occultationPathEndpoint{point: occultationPathPointFromVector(candidateTT, vector, 0, location), valid: true}
|
||
}
|
||
}
|
||
return occultationPathEndpoint{}
|
||
}
|
||
|
||
func occultationPathCenterPointForFrame(
|
||
tt float64,
|
||
frameAt occultationPathFrameFunc,
|
||
location *time.Location,
|
||
) (OccultationPathPoint, bool) {
|
||
frame, ok := frameAt(tt)
|
||
if !ok {
|
||
return OccultationPathPoint{}, false
|
||
}
|
||
point, _, ok := occultationEarthLineIntersection(frame.moon, frame.axis)
|
||
if !ok {
|
||
return OccultationPathPoint{}, false
|
||
}
|
||
width := 0.0
|
||
if _, _, tangentWidth, limitsOK := occultationPathLimitsAndWidthForFrame(tt, frameAt); limitsOK {
|
||
width = tangentWidth
|
||
}
|
||
return occultationPathPointFromVectorWithMoon(tt, point, width, frame.moon, location), true
|
||
}
|
||
|
||
func occultationPathCenterPointForFrameWithoutWidth(
|
||
tt float64,
|
||
frameAt occultationPathFrameFunc,
|
||
location *time.Location,
|
||
) (OccultationPathPoint, bool) {
|
||
frame, ok := frameAt(tt)
|
||
if !ok {
|
||
return OccultationPathPoint{}, false
|
||
}
|
||
point, _, ok := occultationEarthLineIntersection(frame.moon, frame.axis)
|
||
if !ok {
|
||
return OccultationPathPoint{}, false
|
||
}
|
||
return occultationPathPointFromVectorWithMoon(tt, point, 0, frame.moon, location), true
|
||
}
|
||
|
||
func occultationPathBoundaryPointForFrame(
|
||
tt float64,
|
||
frameAt occultationPathFrameFunc,
|
||
location *time.Location,
|
||
) (OccultationPathPoint, bool) {
|
||
frame, ok := frameAt(tt)
|
||
if !ok {
|
||
return OccultationPathPoint{}, false
|
||
}
|
||
point, _, ok := occultationPathBoundaryTangent(frame)
|
||
if !ok {
|
||
return OccultationPathPoint{}, false
|
||
}
|
||
north, south, limitsOK := occultationPathScannedLimitsAtFrame(tt, frame)
|
||
return occultationPathPointFromVector(tt, point, occultationPathBoundaryWidth(north, south, limitsOK), location), true
|
||
}
|
||
|
||
func occultationPathTrackPointForFrame(
|
||
tt float64,
|
||
frameAt occultationPathFrameFunc,
|
||
location *time.Location,
|
||
) (OccultationPathPoint, bool) {
|
||
frame, ok := frameAt(tt)
|
||
if !ok {
|
||
return OccultationPathPoint{}, false
|
||
}
|
||
point, ok := occultationPathTrackReference(frame)
|
||
if !ok {
|
||
return OccultationPathPoint{}, false
|
||
}
|
||
width := 0.0
|
||
if _, _, candidate, limitsOK := occultationPathLimitsAndWidthForFrame(tt, frameAt); limitsOK {
|
||
width = candidate
|
||
}
|
||
return occultationPathPointFromVectorWithMoon(tt, point, width, frame.moon, location), true
|
||
}
|
||
|
||
func occultationPathLimitsAndWidthForFrame(
|
||
tt float64,
|
||
frameAt occultationPathFrameFunc,
|
||
) (occultationPathVector, occultationPathVector, float64, bool) {
|
||
frame, ok := frameAt(tt)
|
||
if !ok {
|
||
return occultationPathVector{}, occultationPathVector{}, 0, false
|
||
}
|
||
beforeFrame, beforeOK := frameAt(tt - occultationPathVelocityStepDays)
|
||
afterFrame, afterOK := frameAt(tt + occultationPathVelocityStepDays)
|
||
if !beforeOK || !afterOK {
|
||
north, south, scannedOK := occultationPathScannedLimitsForFrame(tt, frame)
|
||
return north, south, occultationPathBoundaryWidth(north, south, scannedOK), scannedOK
|
||
}
|
||
|
||
vx := afterFrame.moonProjectionX() - beforeFrame.moonProjectionX()
|
||
vy := afterFrame.moonProjectionY() - beforeFrame.moonProjectionY()
|
||
speed := math.Hypot(vx, vy)
|
||
if speed <= 1e-12 {
|
||
north, south, scannedOK := occultationPathScannedLimitsForFrame(tt, frame)
|
||
return north, south, occultationPathBoundaryWidth(north, south, scannedOK), scannedOK
|
||
}
|
||
planeCrossTrack := occultationPathUnit(occultationPathAdd(
|
||
occultationPathScale(frame.first, -vy/speed),
|
||
occultationPathScale(frame.second, vx/speed),
|
||
))
|
||
|
||
var centerFixed, groundCrossTrack occultationPathVector
|
||
groundWidthOK := false
|
||
center, centerOK := occultationPathTrackReference(frame)
|
||
before, beforeCenterOK := occultationPathTrackReference(beforeFrame)
|
||
after, afterCenterOK := occultationPathTrackReference(afterFrame)
|
||
centerRotation := occultationPathEarthRotation{}
|
||
if centerOK && beforeCenterOK && afterCenterOK {
|
||
centerRotation = occultationPathEarthRotationAt(tt)
|
||
centerFixed = occultationPathEarthFixedVectorWithRotation(center, centerRotation)
|
||
beforeFixed := occultationPathEarthFixedVector(tt-occultationPathVelocityStepDays, before)
|
||
afterFixed := occultationPathEarthFixedVector(tt+occultationPathVelocityStepDays, after)
|
||
polarRatioSquared := occultationPathEarthPolarRatio * occultationPathEarthPolarRatio
|
||
normal := occultationPathUnit(occultationPathVector{x: centerFixed.x, y: centerFixed.y, z: centerFixed.z / polarRatioSquared})
|
||
track := occultationPathSub(afterFixed, beforeFixed)
|
||
track = occultationPathSub(track, occultationPathScale(normal, occultationPathDot(track, normal)))
|
||
if occultationPathNorm(track) > 1e-12 {
|
||
groundCrossTrack = occultationPathUnit(occultationPathCross(normal, occultationPathUnit(track)))
|
||
groundWidthOK = true
|
||
}
|
||
}
|
||
|
||
minimumOffset := math.Inf(1)
|
||
maximumOffset := math.Inf(-1)
|
||
minimumGroundOffset := math.Inf(1)
|
||
maximumGroundOffset := math.Inf(-1)
|
||
var minimumPoint, maximumPoint occultationPathVector
|
||
var minimumGroundPoint, maximumGroundPoint occultationPathVector
|
||
consider := func(point occultationPathVector) {
|
||
offset := occultationPathDot(point, planeCrossTrack)
|
||
if offset < minimumOffset {
|
||
minimumOffset = offset
|
||
minimumPoint = point
|
||
}
|
||
if offset > maximumOffset {
|
||
maximumOffset = offset
|
||
maximumPoint = point
|
||
}
|
||
if groundWidthOK {
|
||
fixed := occultationPathEarthFixedVectorWithRotation(point, centerRotation)
|
||
groundOffset := occultationPathDot(occultationPathSub(fixed, centerFixed), groundCrossTrack)
|
||
if groundOffset < minimumGroundOffset {
|
||
minimumGroundOffset = groundOffset
|
||
minimumGroundPoint = point
|
||
}
|
||
if groundOffset > maximumGroundOffset {
|
||
maximumGroundOffset = groundOffset
|
||
maximumGroundPoint = point
|
||
}
|
||
}
|
||
}
|
||
if tangentPoint, tangentTheta, tangentOK := occultationPathBoundaryTangent(frame); tangentOK {
|
||
consider(tangentPoint)
|
||
if leftTheta, rightTheta, intervalOK := occultationPathBoundaryThetaInterval(frame, tangentTheta); intervalOK {
|
||
const intervalSamples = 128
|
||
for i := 0; i <= intervalSamples; i++ {
|
||
theta := leftTheta + (rightTheta-leftTheta)*float64(i)/intervalSamples
|
||
if point, _, pointOK := occultationPathBoundaryVector(frame, theta); pointOK {
|
||
consider(point)
|
||
}
|
||
}
|
||
// 末样本的等差写法与 rightTheta 相差 1 ULP:掠射时该 ULP 会把端点的判别式推成负值,
|
||
// 端点处的横向极值就被当成"无地面交点"丢掉,故端点原值必须补采一次。
|
||
if point, _, pointOK := occultationPathBoundaryVector(frame, rightTheta); pointOK {
|
||
consider(point)
|
||
}
|
||
}
|
||
}
|
||
for i := 0; i < occultationPathBoundaryScanPoints; i++ {
|
||
point, _, pointOK := occultationPathBoundaryVector(frame, 2*math.Pi*float64(i)/float64(occultationPathBoundaryScanPoints))
|
||
if !pointOK {
|
||
continue
|
||
}
|
||
consider(point)
|
||
}
|
||
if !finite(minimumOffset) || !finite(maximumOffset) {
|
||
return occultationPathVector{}, occultationPathVector{}, 0, false
|
||
}
|
||
width := maximumOffset - minimumOffset
|
||
if groundWidthOK && finite(minimumGroundOffset) && finite(maximumGroundOffset) {
|
||
width = maximumGroundOffset - minimumGroundOffset
|
||
// 有限目标会把月影打开或收束成圆锥;接近地平线时其影面投影可能折叠,使全球投影极值在远处地平线交点间跳变。地面轨迹极值仍位于掩带的同一物理侧。
|
||
// A finite target opens or closes the lunar shadow into a cone. Near the horizon its shadow-plane projection can fold, causing the global projected extremum to jump between distant horizon intersections. Ground-track extrema remain on the same physical sides of the band.
|
||
if frame.targetRadius != 0 {
|
||
return maximumGroundPoint, minimumGroundPoint, width, true
|
||
}
|
||
}
|
||
minimumLatitude := occultationPathGeodeticLatitude(minimumPoint)
|
||
maximumLatitude := occultationPathGeodeticLatitude(maximumPoint)
|
||
if maximumLatitude >= minimumLatitude {
|
||
return maximumPoint, minimumPoint, width, true
|
||
}
|
||
return minimumPoint, maximumPoint, width, true
|
||
}
|
||
|
||
// occultationPathFiniteCrossTrackExtrema scans the complete Earth-intersecting
|
||
// contact-cone arc and returns its physical cross-track extrema. For a finite
|
||
// target the visible arc can be much longer than the two horizon endpoints;
|
||
// those endpoints are only the moonrise/moonset closure, not the band sides.
|
||
func occultationPathFiniteCrossTrackExtrema(
|
||
tt float64,
|
||
frame, beforeFrame, afterFrame occultationPathFrame,
|
||
) (occultationPathVector, occultationPathVector, float64, bool) {
|
||
vx := afterFrame.moonProjectionX() - beforeFrame.moonProjectionX()
|
||
vy := afterFrame.moonProjectionY() - beforeFrame.moonProjectionY()
|
||
speed := math.Hypot(vx, vy)
|
||
if speed <= 1e-12 {
|
||
return occultationPathVector{}, occultationPathVector{}, 0, false
|
||
}
|
||
planeCrossTrack := occultationPathUnit(occultationPathAdd(
|
||
occultationPathScale(frame.first, -vy/speed),
|
||
occultationPathScale(frame.second, vx/speed),
|
||
))
|
||
|
||
var centerFixed, groundCrossTrack occultationPathVector
|
||
groundWidthOK := false
|
||
center, centerOK := occultationPathTrackReference(frame)
|
||
before, beforeCenterOK := occultationPathTrackReference(beforeFrame)
|
||
after, afterCenterOK := occultationPathTrackReference(afterFrame)
|
||
centerRotation := occultationPathEarthRotation{}
|
||
if centerOK && beforeCenterOK && afterCenterOK {
|
||
centerRotation = occultationPathEarthRotationAt(tt)
|
||
centerFixed = occultationPathEarthFixedVectorWithRotation(center, centerRotation)
|
||
beforeFixed := occultationPathEarthFixedVector(
|
||
tt-occultationPathVelocityStepDays, before,
|
||
)
|
||
afterFixed := occultationPathEarthFixedVector(
|
||
tt+occultationPathVelocityStepDays, after,
|
||
)
|
||
polarRatioSquared := occultationPathEarthPolarRatio * occultationPathEarthPolarRatio
|
||
normal := occultationPathUnit(occultationPathVector{
|
||
x: centerFixed.x, y: centerFixed.y, z: centerFixed.z / polarRatioSquared,
|
||
})
|
||
track := occultationPathSub(afterFixed, beforeFixed)
|
||
track = occultationPathSub(track, occultationPathScale(normal, occultationPathDot(track, normal)))
|
||
if occultationPathNorm(track) > 1e-12 {
|
||
groundCrossTrack = occultationPathUnit(occultationPathCross(normal, occultationPathUnit(track)))
|
||
groundWidthOK = true
|
||
}
|
||
}
|
||
|
||
minimumOffset, maximumOffset := math.Inf(1), math.Inf(-1)
|
||
minimumGroundOffset, maximumGroundOffset := math.Inf(1), math.Inf(-1)
|
||
var minimumPoint, maximumPoint occultationPathVector
|
||
var minimumGroundPoint, maximumGroundPoint occultationPathVector
|
||
consider := func(point occultationPathVector) {
|
||
offset := occultationPathDot(point, planeCrossTrack)
|
||
if offset < minimumOffset {
|
||
minimumOffset, minimumPoint = offset, point
|
||
}
|
||
if offset > maximumOffset {
|
||
maximumOffset, maximumPoint = offset, point
|
||
}
|
||
if groundWidthOK {
|
||
fixed := occultationPathEarthFixedVectorWithRotation(point, centerRotation)
|
||
groundOffset := occultationPathDot(
|
||
occultationPathSub(fixed, centerFixed), groundCrossTrack,
|
||
)
|
||
if groundOffset < minimumGroundOffset {
|
||
minimumGroundOffset, minimumGroundPoint = groundOffset, point
|
||
}
|
||
if groundOffset > maximumGroundOffset {
|
||
maximumGroundOffset, maximumGroundPoint = groundOffset, point
|
||
}
|
||
}
|
||
}
|
||
if tangentPoint, tangentTheta, tangentOK := occultationPathBoundaryTangent(frame); tangentOK {
|
||
consider(tangentPoint)
|
||
if leftTheta, rightTheta, intervalOK := occultationPathBoundaryThetaInterval(frame, tangentTheta); intervalOK {
|
||
for index := 0; index <= 128; index++ {
|
||
theta := leftTheta + (rightTheta-leftTheta)*float64(index)/128
|
||
if point, _, pointOK := occultationPathBoundaryVector(frame, theta); pointOK {
|
||
consider(point)
|
||
}
|
||
}
|
||
// 末样本的等差写法与 rightTheta 相差 1 ULP:掠射时该 ULP 会把端点的判别式推成负值,
|
||
// 端点处的极值就被当成"无地面交点"丢掉,故端点原值必须补采一次。
|
||
if point, _, pointOK := occultationPathBoundaryVector(frame, rightTheta); pointOK {
|
||
consider(point)
|
||
}
|
||
}
|
||
}
|
||
for _, interval := range occultationPathBoundaryThetaIntervals(frame) {
|
||
for index := 0; index <= 128; index++ {
|
||
theta := interval.left + (interval.right-interval.left)*float64(index)/128
|
||
if point, _, pointOK := occultationPathBoundaryVector(frame, theta); pointOK {
|
||
consider(point)
|
||
}
|
||
}
|
||
// 与 occultationPathLimitsAndWidthForFrame 同因:等差末样本与 interval.right 差 1 ULP,
|
||
// 掠射时会把端点判别式推成负值而丢掉该处极值,端点原值必须补采一次。
|
||
if point, _, pointOK := occultationPathBoundaryVector(frame, interval.right); pointOK {
|
||
consider(point)
|
||
}
|
||
}
|
||
if !finite(minimumOffset) || !finite(maximumOffset) {
|
||
return occultationPathVector{}, occultationPathVector{}, 0, false
|
||
}
|
||
if groundWidthOK && finite(minimumGroundOffset) && finite(maximumGroundOffset) {
|
||
width := maximumGroundOffset - minimumGroundOffset
|
||
if width > 0 {
|
||
return maximumGroundPoint, minimumGroundPoint, width, true
|
||
}
|
||
}
|
||
minimumLatitude := occultationPathGeodeticLatitude(minimumPoint)
|
||
maximumLatitude := occultationPathGeodeticLatitude(maximumPoint)
|
||
width := maximumOffset - minimumOffset
|
||
if maximumLatitude >= minimumLatitude {
|
||
return maximumPoint, minimumPoint, width, width > 0
|
||
}
|
||
return minimumPoint, maximumPoint, width, width > 0
|
||
}
|
||
|
||
func occultationPathScannedLimitsForFrame(
|
||
tt float64,
|
||
frame occultationPathFrame,
|
||
) (occultationPathVector, occultationPathVector, bool) {
|
||
return occultationPathScannedLimitsAtFrame(tt, frame)
|
||
}
|
||
|
||
func occultationPathCrossTrackWidthForFrame(
|
||
tt float64,
|
||
frameAt occultationPathFrameFunc,
|
||
) (float64, bool) {
|
||
first, second, _, ok := occultationPathCrossTrackLimitsForFrame(tt, frameAt)
|
||
if !ok {
|
||
return 0, false
|
||
}
|
||
width := occultationPathNorm(occultationPathSub(first, second))
|
||
return width, finite(width) && width > 0
|
||
}
|