2026-08-06 12:00:56 +08:00
package basic
import (
"math"
"sort"
"time"
)
2026-09-17 12:27:40 +08:00
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
)
2026-08-06 12:00:56 +08:00
type occultationPathFrameFunc func ( float64 ) ( occultationPathFrame , bool )
2026-09-17 12:27:40 +08:00
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
}
2026-08-06 12:00:56 +08:00
// 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 {
2026-09-17 12:27:40 +08:00
path , ok , err := planetOccultationPathAtSeed ( seedTT , config , options , start , end )
2026-08-06 12:00:56 +08:00
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 ,
2026-09-17 12:27:40 +08:00
selectionStart , selectionEnd time . Time ,
2026-08-06 12:00:56 +08:00
) ( PlanetOccultationPath , bool , error ) {
2026-09-17 12:27:40 +08:00
location := selectionStart . Location ()
cache := newPlanetOccultationEventCache ( config )
cache . prepareLocalEphemeris ( seedTT )
frameAt := cache . outerFrameAt
totalFrameAt := cache . totalFrameAt
candidateFrameAt := cache . candidateFrameAt
candidateTotalFrameAt := cache . candidateTotalFrameAt
2026-08-06 12:00:56 +08:00
searchStart := seedTT - occultationPathSearchSpanDays
searchEnd := seedTT + occultationPathSearchSpanDays
2026-09-17 12:27:40 +08:00
outerStart , outerEnd , ok := occultationPathWindowWithCandidateFrames (
seedTT , searchStart , searchEnd , candidateFrameAt , frameAt , false ,
occultationPathFrameHasBoundary ,
)
2026-08-06 12:00:56 +08:00
if ! ok {
return PlanetOccultationPath {}, false , nil
}
2026-09-17 12:27:40 +08:00
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 )
2026-08-06 12:00:56 +08:00
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 {
2026-09-17 12:27:40 +08:00
// 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 )
2026-08-06 12:00:56 +08:00
}
if ! greatestOK {
return PlanetOccultationPath {}, false , nil
}
2026-09-17 12:27:40 +08:00
if ! occultationTimeInSelectionWindow ( greatest . Time , selectionStart , selectionEnd ) {
return PlanetOccultationPath {}, false , nil
}
2026-08-06 12:00:56 +08:00
_ , _ , 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
2026-09-17 12:27:40 +08:00
totalStartTT , totalEndTT , hasTotal := occultationPathWindowWithCandidateFrames (
seedTT , searchStart , searchEnd , candidateTotalFrameAt , totalFrameAt , false ,
occultationPathFrameHasBoundary ,
2026-08-06 12:00:56 +08:00
)
hasTotal = hasTotal && greatestTT >= totalStartTT && greatestTT <= totalEndTT
if planetOccultationPathTemporalSampleCount (
outerStart , outerEnd , centerStart , centerEnd , hasCenter ,
totalStartTT , totalEndTT , hasTotal , greatestTT , options ,
) > planetOccultationPathMaxTemporalSamples {
return PlanetOccultationPath {}, false , ErrOccultationPathSamplingLimit
}
2026-09-17 12:27:40 +08:00
if occultationPathEstimatedPointCount (
outerStart , outerEnd , centerStart , centerEnd , hasCenter ,
totalStartTT , totalEndTT , hasTotal , greatestTT , options ,
) > occultationPathMaxOutputPointCount {
return PlanetOccultationPath {}, false , ErrOccultationPathSamplingLimit
}
2026-08-06 12:00:56 +08:00
start := occultationPathBoundaryEndpointForFrame ( outerStart , frameAt , location , 1 )
end := occultationPathBoundaryEndpointForFrame ( outerEnd , frameAt , location , - 1 )
if ! start . valid || ! end . valid {
return PlanetOccultationPath {}, false , nil
}
2026-09-17 12:27:40 +08:00
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
}
}
2026-08-06 12:00:56 +08:00
centerLine , northern , southern , err := planetOccultationPathSamples (
outerStart , outerEnd , centerStart , centerEnd , hasCenter , greatestTT , frameAt , options , location ,
)
if err != nil {
return PlanetOccultationPath {}, false , err
}
2026-09-17 12:27:40 +08:00
if cache . local . dense {
correctOccultationCenterWidths ( centerLine , exactFrameAt )
}
2026-08-06 12:00:56 +08:00
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 ,
}
2026-09-17 12:27:40 +08:00
// 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 ,
2026-08-06 12:00:56 +08:00
)
2026-09-17 12:27:40 +08:00
// 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 ,
)
}
2026-08-06 12:00:56 +08:00
if ! hasTotal {
return path , true , nil
}
2026-09-17 12:27:40 +08:00
totalStart := occultationPathBoundaryEndpointForFrame ( totalStartTT , exactTotalFrameAt , location , 1 )
totalEnd := occultationPathBoundaryEndpointForFrame ( totalEndTT , exactTotalFrameAt , location , - 1 )
_ , _ , totalWidth , totalWidthOK := occultationPathLimitsAndWidthForFrame ( greatestTT , exactTotalFrameAt )
2026-08-06 12:00:56 +08:00
if ! totalStart . valid || ! totalEnd . valid || ! totalWidthOK || totalWidth <= 0 {
return path , true , nil
}
2026-09-17 12:27:40 +08:00
// 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 )
2026-08-06 12:00:56 +08:00
totalNorthern , totalSouthern := occultationPathBoundarySamplesForFrame (
2026-09-17 12:27:40 +08:00
totalStartTT , totalEndTT , greatestTT , totalFrameAt , contourStepDays , location ,
2026-08-06 12:00:56 +08:00
)
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 )
2026-09-17 12:27:40 +08:00
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 ,
2026-08-06 12:00:56 +08:00
)
2026-09-17 12:27:40 +08:00
if options . DisableFootprints && options . IncludeFootprintTimeline {
path . TotalFootprints = planetOccultationTimelineFootprints (
totalStartTT , totalEndTT , greatestTT , totalFrameAt , options , location ,
)
}
2026-08-06 12:00:56 +08:00
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 ))
2026-09-17 12:27:40 +08:00
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 ,
))
}
2026-08-06 12:00:56 +08:00
if hasCenter {
count += len ( occultationPathSampleTimes ( centerStartTT , centerEndTT , greatestTT , stepDays ))
}
2026-09-17 12:27:40 +08:00
contourStepDays := occultationPathContourStepDays ( options )
count += 2 * len ( occultationPathSampleTimes ( outerStartTT , outerEndTT , greatestTT , contourStepDays ))
2026-08-06 12:00:56 +08:00
if hasTotal {
count += len ( occultationPathSampleTimes ( totalStartTT , totalEndTT , greatestTT , stepDays ))
2026-09-17 12:27:40 +08:00
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 ,
))
}
2026-08-06 12:00:56 +08:00
}
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 )
}
2026-09-17 12:27:40 +08:00
engine := occultationMovingDiskEngine ()
return engine . window ( seedTT , startTT , endTT , predicate , predicate )
2026-08-06 12:00:56 +08:00
}
func occultationPathGreatestForFrame ( seedTT , startTT , endTT float64 , frameAt occultationPathFrameFunc ) float64 {
2026-09-17 12:27:40 +08:00
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 ,
)
2026-08-06 12:00:56 +08:00
}
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
}
}
2026-09-17 12:27:40 +08:00
contourStepDays := occultationPathContourStepDays ( options )
2026-08-06 12:00:56 +08:00
northern , southern := occultationPathBoundarySamplesForFrame (
2026-09-17 12:27:40 +08:00
outerStartTT , outerEndTT , greatestTT , frameAt , contourStepDays , location ,
2026-08-06 12:00:56 +08:00
)
return centerLine , northern , southern , nil
}
func occultationPathBoundarySamplesForFrame (
startTT , endTT , greatestTT float64 ,
frameAt occultationPathFrameFunc ,
2026-09-17 12:27:40 +08:00
stepDays float64 ,
2026-08-06 12:00:56 +08:00
location * time . Location ,
) ([] OccultationPathPoint , [] OccultationPathPoint ) {
times := occultationPathSampleTimes ( startTT , endTT , greatestTT , stepDays )
2026-09-17 12:27:40 +08:00
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
}
2026-08-06 12:00:56 +08:00
samples := make ([] occultationPathBoundaryPairSample , 0 , len ( times ))
for _ , tt := range times {
2026-09-17 12:27:40 +08:00
firstVector , secondVector , moon , ok := occultationPathContourBoundaryPairAt (
tt , frameAt , limitsAt , useFiniteArcExtrema , nil ,
)
2026-08-06 12:00:56 +08:00
if ! ok {
continue
}
2026-09-17 12:27:40 +08:00
sample := occultationPathBoundaryPairSample { tt : tt , first : firstVector , second : secondVector , moon : moon }
2026-08-06 12:00:56 +08:00
if len ( samples ) == 0 {
samples = append ( samples , sample )
continue
}
2026-09-17 12:27:40 +08:00
samples = appendOccultationPathBoundaryPairSegment (
samples , samples [ len ( samples ) - 1 ], sample , frameAt , targetSpacingKM , limitsAt , useFiniteArcExtrema , 0 ,
)
2026-08-06 12:00:56 +08:00
}
first := make ([] OccultationPathPoint , len ( samples ))
second := make ([] OccultationPathPoint , len ( samples ))
for index , sample := range samples {
2026-09-17 12:27:40 +08:00
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 )
2026-08-06 12:00:56 +08:00
return occultationPathOrientBoundarySamples ( first , second , greatestTT )
}
type occultationPathBoundaryPairSample struct {
tt float64
first , second occultationPathVector
2026-09-17 12:27:40 +08:00
moon occultationPathVector
2026-08-06 12:00:56 +08:00
}
func appendOccultationPathBoundaryPairSegment (
samples [] occultationPathBoundaryPairSample ,
start , end occultationPathBoundaryPairSample ,
frameAt occultationPathFrameFunc ,
2026-09-17 12:27:40 +08:00
targetSpacingKM float64 ,
limitsAt func ( float64 , occultationPathFrameFunc ) ( occultationPathVector , occultationPathVector , occultationPathVector , bool ),
useFiniteArcExtrema bool ,
2026-08-06 12:00:56 +08:00
depth int ,
) [] occultationPathBoundaryPairSample {
2026-09-17 12:27:40 +08:00
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 {
2026-08-06 12:00:56 +08:00
return append ( samples , end )
}
midTT := ( start . tt + end . tt ) / 2
2026-09-17 12:27:40 +08:00
if midTT <= start . tt || midTT >= end . tt {
return append ( samples , end )
}
first , second , moon , ok := occultationPathContourBoundaryPairAt (
midTT , frameAt , limitsAt , useFiniteArcExtrema , & start ,
)
2026-08-06 12:00:56 +08:00
if ! ok {
return append ( samples , end )
}
2026-09-17 12:27:40 +08:00
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 ))
2026-08-06 12:00:56 +08:00
}
2026-09-17 12:27:40 +08:00
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 )
2026-08-06 12:00:56 +08:00
direct := math . Max (
occultationPathNorm ( occultationPathSub ( secondA , firstA )),
occultationPathNorm ( occultationPathSub ( secondB , firstB )),
)
swapped := math . Max (
occultationPathNorm ( occultationPathSub ( secondB , firstA )),
occultationPathNorm ( occultationPathSub ( secondA , firstB )),
)
2026-09-17 12:27:40 +08:00
return direct , swapped
2026-08-06 12:00:56 +08:00
}
func occultationPathCrossTrackLimitsForFrame (
tt float64 ,
frameAt occultationPathFrameFunc ,
2026-09-17 12:27:40 +08:00
) ( occultationPathVector , occultationPathVector , occultationPathVector , bool ) {
2026-08-06 12:00:56 +08:00
frame , ok := frameAt ( tt )
before , beforeOK := frameAt ( tt - occultationPathVelocityStepDays )
after , afterOK := frameAt ( tt + occultationPathVelocityStepDays )
if ! ok || ! beforeOK || ! afterOK {
2026-09-17 12:27:40 +08:00
return occultationPathVector {}, occultationPathVector {}, occultationPathVector {}, false
2026-08-06 12:00:56 +08:00
}
vx := after . moonProjectionX () - before . moonProjectionX ()
vy := after . moonProjectionY () - before . moonProjectionY ()
if math . Hypot ( vx , vy ) <= 1e-12 {
2026-09-17 12:27:40 +08:00
return occultationPathVector {}, occultationPathVector {}, occultationPathVector {}, false
2026-08-06 12:00:56 +08:00
}
// 复用日食中心线构造:取基准面中垂直于运动方向的两条影锥母线。点源掠过阶段将缺失母线限制到可见角度区间;有限目标使用最近可见区间两端,直到两条横向母线分别与地球相交。
// 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 {
2026-09-17 12:27:40 +08:00
return occultationPathVector {}, occultationPathVector {}, occultationPathVector {}, false
2026-08-06 12:00:56 +08:00
}
2026-09-17 12:27:40 +08:00
return first , second , frame . moon , true
2026-08-06 12:00:56 +08:00
}
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
}
2026-09-17 12:27:40 +08:00
return first , second , frame . moon , true
2026-08-06 12:00:56 +08:00
}
if ! intervalOK {
2026-09-17 12:27:40 +08:00
return occultationPathVector {}, occultationPathVector {}, occultationPathVector {}, false
2026-08-06 12:00:56 +08:00
}
first , _ , firstOK = occultationPathBoundaryVector ( frame , interval . left )
second , _ , secondOK = occultationPathBoundaryVector ( frame , interval . right )
if ! firstOK || ! secondOK {
2026-09-17 12:27:40 +08:00
return occultationPathVector {}, occultationPathVector {}, occultationPathVector {}, false
2026-08-06 12:00:56 +08:00
}
2026-09-17 12:27:40 +08:00
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 )
2026-08-06 12:00:56 +08:00
}
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 {
2026-09-17 12:27:40 +08:00
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 {
2026-08-06 12:00:56 +08:00
step := 2 * math . Pi / float64 ( occultationPathBoundaryScanPoints )
discriminants := make ([] float64 , occultationPathBoundaryScanPoints )
2026-09-17 12:27:40 +08:00
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 )
2026-08-06 12:00:56 +08:00
for index := range discriminants {
discriminant , _ , _ , ok := occultationPathBoundaryLine ( frame , step * float64 ( index ))
if ! ok {
discriminant = math . Inf ( - 1 )
}
discriminants [ index ] = discriminant
}
2026-09-17 12:27:40 +08:00
}
2026-08-06 12:00:56 +08:00
2026-09-17 12:27:40 +08:00
// occultationPathBoundaryThetaIntervalsFromGrid 在已算好的网格上恢复可见 θ 区间。
// occultationPathBoundaryThetaIntervalsFromGrid recovers the visible theta intervals from a prepared grid.
func occultationPathBoundaryThetaIntervalsFromGrid (
frame occultationPathFrame ,
step float64 ,
discriminants [] float64 ,
) [] occultationPathThetaInterval {
2026-08-06 12:00:56 +08:00
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 )
2026-09-17 12:27:40 +08:00
if ok && ( len ( points ) == 0 || point . Time . After ( points [ len ( points ) - 1 ]. Time )) {
2026-08-06 12:00:56 +08:00
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
2026-09-17 12:27:40 +08:00
midTime := occultationTTToLocation ( midTT , location )
if ! midTime . After ( start . Time ) || ! midTime . Before ( end . Time ) {
return append ( points , end ), nil
}
2026-08-06 12:00:56 +08:00
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 ) {
2026-09-17 12:27:40 +08:00
state := planetOccultationEphemerisStateAt ( tt , config )
return planetOccultationContactPathFrameFromState ( state , config , total )
}
func planetOccultationEphemerisStateAt ( tt float64 , config planetOccultationConfig ) planetOccultationEphemerisState {
2026-08-06 12:00:56 +08:00
moonRA , moonDec := HMoonGeocentricApparentRaDecN ( tt , - 1 )
moonDistance := HMoonAwayN ( tt , - 1 )
2026-09-17 12:27:40 +08:00
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 {
2026-08-06 12:00:56 +08:00
return occultationPathFrame {}, false
}
2026-09-17 12:27:40 +08:00
moon := occultationPathRaDecVector ( state . moonRA , state . moonDec , state . moonDistanceKM )
target := occultationPathRaDecVector ( state . planetRA , state . planetDec , state . planetDistanceKM )
2026-08-06 12:00:56 +08:00
moonToTarget := occultationPathSub ( target , moon )
moonToTargetDistance := occultationPathNorm ( moonToTarget )
2026-09-17 12:27:40 +08:00
moonRadius := math . Asin ( moonEquatorialRadiusKM / state . moonDistanceKM )
2026-08-06 12:00:56 +08:00
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
}
2026-09-17 12:27:40 +08:00
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 ()
}
2026-08-06 12:00:56 +08:00
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
}
2026-09-17 12:27:40 +08:00
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
}
2026-08-06 12:00:56 +08:00
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 )
2026-09-17 12:27:40 +08:00
centerRotation := occultationPathEarthRotation {}
2026-08-06 12:00:56 +08:00
if centerOK && beforeCenterOK && afterCenterOK {
2026-09-17 12:27:40 +08:00
centerRotation = occultationPathEarthRotationAt ( tt )
centerFixed = occultationPathEarthFixedVectorWithRotation ( center , centerRotation )
2026-08-06 12:00:56 +08:00
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 {
2026-09-17 12:27:40 +08:00
fixed := occultationPathEarthFixedVectorWithRotation ( point , centerRotation )
2026-08-06 12:00:56 +08:00
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
}
}
2026-09-17 12:27:40 +08:00
minimumLatitude := occultationPathGeodeticLatitude ( minimumPoint )
maximumLatitude := occultationPathGeodeticLatitude ( maximumPoint )
2026-08-06 12:00:56 +08:00
if maximumLatitude >= minimumLatitude {
return maximumPoint , minimumPoint , width , true
}
return minimumPoint , maximumPoint , width , true
}
2026-09-17 12:27:40 +08:00
// 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
}
2026-08-06 12:00:56 +08:00
func occultationPathScannedLimitsForFrame (
tt float64 ,
frame occultationPathFrame ,
) ( occultationPathVector , occultationPathVector , bool ) {
return occultationPathScannedLimitsAtFrame ( tt , frame )
}
2026-09-17 12:27:40 +08:00
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
}