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

982 lines
33 KiB
Go

package basic
import (
"math"
"sort"
"time"
)
const (
planetOccultationFootprintBoundaryPoints = 180
planetOccultationHorizonPoints = 360
planetOccultationTimelineBoundaryPoints = 180
planetOccultationTimelineHorizonPoints = 180
planetOccultationTimelineTargetSpacingKM = 300.0
planetOccultationFootprintMaxSamples = 360
planetOccultationFootprintTargetStep = time.Minute
planetOccultationBandBoundaryPoints = 360
planetOccultationBandHorizonPoints = 360
planetOccultationBandMaxSamples = 128
planetOccultationBandTargetStep = 5 * time.Minute
planetOccultationBandTargetSpacingKM = 50.0
planetOccultationBandAdaptiveMaxDepth = 12
planetOccultationCenterCapRadiusKM = 60.0
planetOccultationCenterCapPoints = 16
// 二分到 1e-9 度(约 0.1 毫米)即停,保证插入的切点位于地平线内侧。
planetOccultationFootprintHorizonToleranceDeg = 1e-9
planetOccultationFootprintHorizonSearchSteps = 48
)
type planetOccultationFootprintSample struct {
point OccultationPathPoint
theta float64
ok bool
}
func planetOccultationFootprints(
startTT, endTT, greatestTT float64,
frameAt occultationPathFrameFunc,
options OccultationPathOptions,
location *time.Location,
) []PlanetOccultationFootprint {
return planetOccultationFootprintsWithSampling(
startTT, endTT, greatestTT, frameAt, location,
planetOccultationFootprintSampleStepDays(options), planetOccultationFootprintMaxSamples,
planetOccultationFootprintBoundaryPoints, planetOccultationHorizonPoints, 0, false,
)
}
func planetOccultationTimelineFootprints(
startTT, endTT, greatestTT float64,
frameAt occultationPathFrameFunc,
options OccultationPathOptions,
location *time.Location,
) []PlanetOccultationFootprint {
return planetOccultationFootprintsWithSampling(
startTT, endTT, greatestTT, frameAt, location,
planetOccultationFootprintSampleStepDays(options), planetOccultationFootprintMaxSamples,
planetOccultationTimelineBoundaryPoints, planetOccultationTimelineHorizonPoints,
planetOccultationTimelineTargetSpacingKM, false,
)
}
func planetOccultationBandFootprints(
startTT, endTT, greatestTT float64,
frameAt occultationPathFrameFunc,
location *time.Location,
additionalTimes []float64,
) []PlanetOccultationFootprint {
times := planetOccultationBandSampleTimesWithAdditionalTimes(
startTT, endTT, greatestTT, additionalTimes,
)
return planetOccultationFootprintsAtTimes(
times, frameAt, location,
planetOccultationBandBoundaryPoints, planetOccultationBandHorizonPoints,
planetOccultationBandTargetSpacingKM, 150,
)
}
func planetOccultationBandFootprintsWithContourTimes(
startTT, endTT, greatestTT float64,
frameAt occultationPathFrameFunc,
location *time.Location,
additionalTimes []float64,
) ([]PlanetOccultationFootprint, []float64) {
contourTimes := occultationAppendSampleTimes(nil, additionalTimes...)
footprints := planetOccultationBandFootprints(
startTT, endTT, greatestTT, frameAt, location, contourTimes,
)
transitionTimes := occultationFootprintTransitionTimes(footprints)
if len(transitionTimes) == 0 {
return footprints, contourTimes
}
contourTimes = occultationAppendSampleTimes(contourTimes, transitionTimes...)
// The first pass already contains the complete five-minute grid and all
// rise/set contour times. The second pass used to recompute every one of
// those expensive finite-disk footprints just to add the handful of
// visibility-transition instants. Evaluate only those new instants, merge
// them into the existing grid, then run the same endpoint refinement.
transitionFootprints := planetOccultationFootprintsAtTimes(
transitionTimes, frameAt, location,
planetOccultationBandBoundaryPoints, planetOccultationBandHorizonPoints,
planetOccultationBandTargetSpacingKM, 150,
)
footprints = mergePlanetOccultationFootprintsByExactTime(
footprints, transitionFootprints,
)
footprints = refinePlanetOccultationFootprintTransitions(
footprints, frameAt, location,
planetOccultationBandBoundaryPoints, planetOccultationBandHorizonPoints,
planetOccultationBandTargetSpacingKM, 150,
)
return footprints, contourTimes
}
func mergePlanetOccultationFootprintsByExactTime(
base, extra []PlanetOccultationFootprint,
) []PlanetOccultationFootprint {
if len(extra) == 0 {
return base
}
result := make([]PlanetOccultationFootprint, 0, len(base)+len(extra))
result = append(result, base...)
result = append(result, extra...)
sort.SliceStable(result, func(i, j int) bool {
return result[i].Time.Before(result[j].Time)
})
const tolerance = time.Second
for index := 1; index < len(result); {
if result[index].Closed == result[index-1].Closed &&
result[index].Time.Sub(result[index-1].Time) <= tolerance {
result = append(result[:index], result[index+1:]...)
continue
}
index++
}
return result
}
func planetOccultationBandSampleTimesWithAdditionalTimes(
startTT, endTT, greatestTT float64,
additionalTimes []float64,
) []float64 {
times := planetOccultationBandSampleTimes(
startTT, endTT, greatestTT, planetOccultationBandSampleStepDays(), planetOccultationBandMaxSamples,
)
for _, tt := range additionalTimes {
if tt >= startTT && tt <= endTT && finite(tt) {
times = append(times, tt)
}
}
sort.Float64s(times)
return uniqueOccultationPathTimes(times)
}
func planetOccultationBandSampleStepDays() float64 {
return float64(planetOccultationBandTargetStep) / float64(24*time.Hour)
}
func planetOccultationFootprintsWithSampling(
startTT, endTT, greatestTT float64,
frameAt occultationPathFrameFunc,
location *time.Location,
stepDays float64,
maxSamples, boundaryPoints, horizonPoints int,
targetSpacingKM float64,
refineContacts bool,
) []PlanetOccultationFootprint {
times := occultationPathSampleTimesWithLimit(startTT, endTT, greatestTT, stepDays, maxSamples)
if refineContacts {
times = planetOccultationBandSampleTimes(startTT, endTT, greatestTT, stepDays, maxSamples)
}
return planetOccultationFootprintsAtTimes(
times, frameAt, location, boundaryPoints, horizonPoints, targetSpacingKM, 0,
)
}
func planetOccultationFootprintsAtTimes(
times []float64,
frameAt occultationPathFrameFunc,
location *time.Location,
boundaryPoints, horizonPoints int,
targetSpacingKM, maximumEndpointStepKM float64,
) []PlanetOccultationFootprint {
footprints := make([]PlanetOccultationFootprint, 0, len(times))
for _, tt := range times {
footprint, ok := planetOccultationFootprintAtWithResolution(
tt, frameAt, location, boundaryPoints, horizonPoints, targetSpacingKM,
)
if ok {
footprints = append(footprints, footprint)
}
}
return refinePlanetOccultationFootprintTransitions(
footprints, frameAt, location, boundaryPoints, horizonPoints, targetSpacingKM, maximumEndpointStepKM,
)
}
func refinePlanetOccultationFootprintTransitions(
footprints []PlanetOccultationFootprint,
frameAt occultationPathFrameFunc,
location *time.Location,
boundaryPoints, horizonPoints int,
targetSpacingKM float64,
maximumEndpointStepKM float64,
) []PlanetOccultationFootprint {
if len(footprints) < 2 || maximumEndpointStepKM <= 0 {
return footprints
}
const transitionTolerance = 100 * time.Millisecond
result := make([]PlanetOccultationFootprint, 0, len(footprints)+4)
result = append(result, footprints[0])
for index := 1; index < len(footprints); index++ {
left, right := footprints[index-1], footprints[index]
if left.Closed != right.Closed && right.Time.Sub(left.Time) > transitionTolerance {
leftTT := occultationTimeToTT(left.Time)
rightTT := occultationTimeToTT(right.Time)
leftClosed := left.Closed
for rightTT-leftTT > float64(transitionTolerance)/float64(24*time.Hour) {
middleTT := (leftTT + rightTT) / 2
middle, ok := planetOccultationFootprintAtWithResolution(
middleTT, frameAt, location, boundaryPoints, horizonPoints, targetSpacingKM,
)
if !ok {
break
}
if middle.Closed == leftClosed {
leftTT = middleTT
left = middle
} else {
rightTT = middleTT
right = middle
}
}
result = appendPlanetOccultationFootprintEndpointRefinement(
result, left, frameAt, location,
boundaryPoints, horizonPoints, targetSpacingKM, maximumEndpointStepKM, 0,
)
if right.Time.After(result[len(result)-1].Time) {
result = append(result, right)
}
result = appendPlanetOccultationFootprintEndpointRefinement(
result, footprints[index], frameAt, location,
boundaryPoints, horizonPoints, targetSpacingKM, maximumEndpointStepKM, 0,
)
continue
}
result = appendPlanetOccultationFootprintEndpointRefinement(
result, footprints[index], frameAt, location,
boundaryPoints, horizonPoints, targetSpacingKM, maximumEndpointStepKM, 0,
)
}
return deduplicateClosedPlanetOccultationFootprints(result, time.Second)
}
func deduplicateClosedPlanetOccultationFootprints(
footprints []PlanetOccultationFootprint,
tolerance time.Duration,
) []PlanetOccultationFootprint {
if len(footprints) < 2 || tolerance <= 0 {
return footprints
}
result := footprints[:1]
for _, footprint := range footprints[1:] {
previous := result[len(result)-1]
if previous.Closed && footprint.Closed && footprint.Time.Sub(previous.Time) <= tolerance {
continue
}
result = append(result, footprint)
}
return result
}
func appendPlanetOccultationFootprintEndpointRefinement(
result []PlanetOccultationFootprint,
right PlanetOccultationFootprint,
frameAt occultationPathFrameFunc,
location *time.Location,
boundaryPoints, horizonPoints int,
targetSpacingKM, maximumEndpointStepKM float64,
depth int,
) []PlanetOccultationFootprint {
left := result[len(result)-1]
if left.Closed || maximumEndpointStepKM <= 0 || depth >= 8 ||
(left.Closed == right.Closed && occultationFootprintEndpointStepKM(left, right) <= maximumEndpointStepKM) {
if right.Time.After(left.Time) {
return append(result, right)
}
return result
}
middleTT := (occultationTimeToTT(left.Time) + occultationTimeToTT(right.Time)) / 2
middle, ok := planetOccultationFootprintAtWithResolution(
middleTT, frameAt, location, boundaryPoints, horizonPoints, targetSpacingKM,
)
if !ok || middle.Closed != left.Closed || !middle.Time.After(left.Time) || !right.Time.After(middle.Time) {
return append(result, right)
}
result = appendPlanetOccultationFootprintEndpointRefinement(
result, middle, frameAt, location, boundaryPoints, horizonPoints,
targetSpacingKM, maximumEndpointStepKM, depth+1,
)
return appendPlanetOccultationFootprintEndpointRefinement(
result, right, frameAt, location, boundaryPoints, horizonPoints,
targetSpacingKM, maximumEndpointStepKM, depth+1,
)
}
func occultationFootprintEndpointStepKM(first, second PlanetOccultationFootprint) float64 {
if len(first.Boundaries) != 1 || len(second.Boundaries) != 1 ||
len(first.Boundaries[0]) < 2 || len(second.Boundaries[0]) < 2 {
return math.Inf(1)
}
firstBoundary := first.Boundaries[0]
secondBoundary := second.Boundaries[0]
keep := math.Max(
occultationPathDistanceKM(firstBoundary[0], secondBoundary[0]),
occultationPathDistanceKM(firstBoundary[len(firstBoundary)-1], secondBoundary[len(secondBoundary)-1]),
)
reverse := math.Max(
occultationPathDistanceKM(firstBoundary[0], secondBoundary[len(secondBoundary)-1]),
occultationPathDistanceKM(firstBoundary[len(firstBoundary)-1], secondBoundary[0]),
)
return math.Min(keep, reverse)
}
func occultationRiseSetEndpointTimes(curves []OccultationRiseSetCurve) []float64 {
times := make([]float64, 0, 2*len(curves))
for _, curve := range curves {
for _, segment := range curve.Segments {
if len(segment) < 2 {
continue
}
times = append(times,
occultationTimeToTT(segment[0].Time),
occultationTimeToTT(segment[len(segment)-1].Time),
)
}
}
sort.Float64s(times)
const tolerance = float64(time.Second) / float64(24*time.Hour)
result := times[:0]
counts := make([]int, 0, len(times))
for _, tt := range times {
if len(result) == 0 || tt-result[len(result)-1] > tolerance {
result = append(result, tt)
counts = append(counts, 1)
continue
}
index := len(result) - 1
counts[index]++
result[index] += (tt - result[index]) / float64(counts[index])
}
return result
}
func occultationFootprintTransitionTimes(footprints []PlanetOccultationFootprint) []float64 {
if len(footprints) < 2 {
return nil
}
times := make([]float64, 0, 2)
for index := 1; index < len(footprints); index++ {
previous, current := footprints[index-1], footprints[index]
if previous.Closed == current.Closed {
continue
}
times = append(times, (occultationTimeToTT(previous.Time)+occultationTimeToTT(current.Time))/2)
}
sort.Float64s(times)
return uniqueOccultationPathTimes(times)
}
func occultationAppendSampleTimes(base []float64, extras ...float64) []float64 {
if len(extras) == 0 {
return append([]float64(nil), base...)
}
times := append(append([]float64(nil), base...), extras...)
sort.Float64s(times)
return uniqueOccultationPathTimes(times)
}
func planetOccultationBandSampleTimes(
startTT, endTT, greatestTT, stepDays float64,
maxSamples int,
) []float64 {
contactOffsets := [...]float64{
1.0 / 60, 1.0 / 20, 0.1, 0.2, 0.4, 0.7, 1.2, 2, 3, 3.5, 4, 4.5,
}
contactSamples := len(contactOffsets)
baseLimit := maxSamples - 2*contactSamples
if baseLimit < 3 {
baseLimit = 3
}
times := occultationPathSampleTimesWithLimit(startTT, endTT, greatestTT, stepDays, baseLimit)
duration := endTT - startTT
if duration <= 0 {
return times
}
contactStep := math.Min(stepDays, duration/2)
for _, multiplier := range contactOffsets {
offset := math.Min(contactStep*multiplier, duration/2)
times = append(times, startTT+offset, endTT-offset)
}
sort.Float64s(times)
return uniqueOccultationPathTimes(times)
}
func planetOccultationFootprintSampleStepDays(options OccultationPathOptions) float64 {
if options.IncludeFootprintTimeline {
step := options.FootprintTimelineStep
if step <= 0 {
step = 5 * time.Minute
}
return float64(step) / float64(24*time.Hour)
}
step := options.Step
if step <= 0 || step > planetOccultationFootprintTargetStep {
step = planetOccultationFootprintTargetStep
}
return float64(step) / float64(24*time.Hour)
}
func planetOccultationFootprintAt(
tt float64,
frameAt occultationPathFrameFunc,
location *time.Location,
) (PlanetOccultationFootprint, bool) {
return planetOccultationFootprintAtWithResolution(
tt, frameAt, location,
planetOccultationFootprintBoundaryPoints, planetOccultationHorizonPoints, 0,
)
}
func planetOccultationFootprintAtWithResolution(
tt float64,
frameAt occultationPathFrameFunc,
location *time.Location,
boundaryPoints, horizonPoints int,
targetSpacingKM float64,
) (PlanetOccultationFootprint, bool) {
frame, ok := frameAt(tt)
if !ok {
return PlanetOccultationFootprint{}, false
}
siderealDegrees := ApparentSiderealTime(TD2UT(tt, false)) * 15
samples := make([]planetOccultationFootprintSample, boundaryPoints)
for index := range samples {
theta := 2 * math.Pi * float64(index) / float64(len(samples))
samples[index] = planetOccultationFootprintSampleAt(
tt, theta, frame, siderealDegrees, location,
)
}
if targetSpacingKM > 0 {
samples = refinePlanetOccultationFootprintSamples(
tt, frame, siderealDegrees, location, samples, targetSpacingKM,
)
}
samples = appendPlanetOccultationFootprintHorizonCrossings(
tt, frame, siderealDegrees, location, samples,
)
segments, closed := planetOccultationFootprintSegments(samples)
polygons := make([][]OccultationPathPoint, 0, len(segments))
interiorPolygons := make([][]OccultationPathPoint, 0)
for _, segment := range segments {
if len(segment) < 2 {
continue
}
polygon := append([]OccultationPathPoint(nil), segment...)
if closed {
polygon = append(polygon, polygon[0])
} else {
polygon = append(polygon, planetOccultationHorizonArc(
tt, frame, segment, location, horizonPoints,
)...)
}
if len(polygon) >= 4 {
polygons = append(polygons, polygon)
}
if anchor, anchorOK := planetOccultationFootprintAnchor(tt, frame, segment, location); anchorOK &&
!planetOccultationPathRingContains(polygon, anchor.Longitude, anchor.Latitude) {
// A sampled open cone can leave a small numerical gap between the
// visible boundary ring and the axis point. Keep the physical
// centerline covered with a small cap instead of allowing the
// greatest marker to fall into the partial annulus.
repairs := planetOccultationCenterRepair(
anchor, polygon, planetOccultationCenterCapRadiusKM,
planetOccultationCenterCapPoints,
)
polygons = append(polygons, repairs...)
interiorPolygons = append(interiorPolygons, repairs...)
}
}
if len(polygons) == 0 {
return PlanetOccultationFootprint{}, false
}
return PlanetOccultationFootprint{
Time: occultationTTToLocation(tt, location),
Polygons: polygons,
InteriorPolygons: interiorPolygons,
Boundaries: segments,
Closed: closed,
}, true
}
func planetOccultationFootprintSampleAt(
tt, theta float64,
frame occultationPathFrame,
siderealDegrees float64,
location *time.Location,
) planetOccultationFootprintSample {
sample := planetOccultationFootprintSample{theta: theta}
vector, _, valid := occultationPathBoundaryVector(frame, theta)
if !valid {
return sample
}
// frame.moon already contains the geocentric lunar vector for tt;
// avoid recomputing the full lunar ephemeris for every boundary point.
sample.point = occultationPathPointFromVectorWithMoonSidereal(
tt, vector, 0, frame.moon, siderealDegrees, location,
)
sample.ok = true
return sample
}
func refinePlanetOccultationFootprintSamples(
tt float64,
frame occultationPathFrame,
siderealDegrees float64,
location *time.Location,
samples []planetOccultationFootprintSample,
targetSpacingKM float64,
) []planetOccultationFootprintSample {
if len(samples) < 2 || targetSpacingKM <= 0 {
return samples
}
result := make([]planetOccultationFootprintSample, 0, len(samples))
for index, left := range samples {
right := samples[(index+1)%len(samples)]
if index == len(samples)-1 {
right.theta += 2 * math.Pi
}
result = append(result, left)
result = append(result, refinePlanetOccultationFootprintInterval(
tt, frame, siderealDegrees, location,
left, right, targetSpacingKM, 0,
)...)
}
return result
}
func refinePlanetOccultationFootprintInterval(
tt float64,
frame occultationPathFrame,
siderealDegrees float64,
location *time.Location,
left, right planetOccultationFootprintSample,
targetSpacingKM float64,
depth int,
) []planetOccultationFootprintSample {
if depth >= planetOccultationBandAdaptiveMaxDepth {
return nil
}
if left.ok && right.ok && occultationPathDistanceKM(left.point, right.point) <= occultationFootprintAdaptiveSpacingKM(left.point, right.point, targetSpacingKM) {
return nil
}
middle := planetOccultationFootprintSampleAt(
tt, (left.theta+right.theta)/2, frame, siderealDegrees, location,
)
if !left.ok && !right.ok && !middle.ok {
return nil
}
result := refinePlanetOccultationFootprintInterval(
tt, frame, siderealDegrees, location,
left, middle, targetSpacingKM, depth+1,
)
result = append(result, middle)
return append(result, refinePlanetOccultationFootprintInterval(
tt, frame, siderealDegrees, location,
middle, right, targetSpacingKM, depth+1,
)...)
}
// occultationFootprintAdaptiveSpacingKM tightens the contact-limb sampling at
// high latitude. A fixed spherical spacing looks like a long straight chord
// after Web Mercator multiplies longitude by sec(latitude); the physical limb
// is unchanged, but the rendered edge loses its curvature. The cosine floor
// bounds the extra work near the poles while leaving equatorial events on the
// existing spacing budget.
func occultationFootprintAdaptiveSpacingKM(
first, second OccultationPathPoint,
targetSpacingKM float64,
) float64 {
if targetSpacingKM <= 0 {
return targetSpacingKM
}
latitude := math.Max(math.Abs(first.Latitude), math.Abs(second.Latitude)) * math.Pi / 180
scale := math.Max(0.5, math.Cos(latitude))
return targetSpacingKM * scale
}
// planetOccultationFootprintSampleVisible 报告样本是否位于月球地平线以上;没有边界解的样本同样不可见。
func planetOccultationFootprintSampleVisible(sample planetOccultationFootprintSample) bool {
return sample.ok && sample.point.MoonAltitude >= 0
}
// appendPlanetOccultationFootprintHorizonCrossings 在相邻可见/不可见样本之间插入地平线切点,环绕接缝同样处理。
func appendPlanetOccultationFootprintHorizonCrossings(
tt float64,
frame occultationPathFrame,
siderealDegrees float64,
location *time.Location,
samples []planetOccultationFootprintSample,
) []planetOccultationFootprintSample {
if len(samples) < 2 {
return samples
}
// 不跨越地平线的足迹直接复用样本切片,避免每次装配都复制一份。
straddles := false
for index, left := range samples {
right := samples[(index+1)%len(samples)]
if (left.point.MoonAltitude >= 0) != (right.point.MoonAltitude >= 0) {
straddles = true
break
}
}
if !straddles {
return samples
}
result := make([]planetOccultationFootprintSample, 0, len(samples)+4)
for index, left := range samples {
right := samples[(index+1)%len(samples)]
if index+1 == len(samples) {
right.theta += 2 * math.Pi
}
result = append(result, left)
if crossing, ok := planetOccultationFootprintHorizonCrossing(
tt, frame, siderealDegrees, location, left, right,
); ok {
result = append(result, crossing)
}
}
return result
}
// planetOccultationFootprintHorizonCrossing 二分相邻样本的月球高度过零点,只返回非负高度的样本。
func planetOccultationFootprintHorizonCrossing(
tt float64,
frame occultationPathFrame,
siderealDegrees float64,
location *time.Location,
left, right planetOccultationFootprintSample,
) (planetOccultationFootprintSample, bool) {
if !left.ok || !right.ok || !finite(left.point.MoonAltitude) || !finite(right.point.MoonAltitude) {
return planetOccultationFootprintSample{}, false
}
if (left.point.MoonAltitude >= 0) == (right.point.MoonAltitude >= 0) {
return planetOccultationFootprintSample{}, false
}
above := left
if right.point.MoonAltitude >= 0 {
above = right
}
for step := 0; step < planetOccultationFootprintHorizonSearchSteps; step++ {
middle := planetOccultationFootprintSampleAt(
tt, (left.theta+right.theta)/2, frame, siderealDegrees, location,
)
if !middle.ok {
break
}
if middle.point.MoonAltitude >= 0 {
above = middle
left = middle
} else {
right = middle
}
if math.Abs(above.point.MoonAltitude) <= planetOccultationFootprintHorizonToleranceDeg {
break
}
}
return above, true
}
func planetOccultationFootprintSegments(
samples []planetOccultationFootprintSample,
) ([][]OccultationPathPoint, bool) {
segments := make([][]OccultationPathPoint, 0, 2)
current := make([]OccultationPathPoint, 0, len(samples))
allVisible := len(samples) > 0
for _, sample := range samples {
if !planetOccultationFootprintSampleVisible(sample) {
allVisible = false
if len(current) > 0 {
segments = append(segments, current)
current = nil
}
continue
}
current = append(current, sample.point)
}
if len(current) > 0 {
segments = append(segments, current)
}
if len(segments) > 1 &&
planetOccultationFootprintSampleVisible(samples[0]) &&
planetOccultationFootprintSampleVisible(samples[len(samples)-1]) {
first := segments[0]
last := segments[len(segments)-1]
merged := make([]OccultationPathPoint, 0, len(last)+len(first))
merged = append(merged, last...)
merged = append(merged, first...)
segments[0] = merged
segments = segments[:len(segments)-1]
}
return segments, allVisible && len(segments) == 1
}
func planetOccultationHorizonArc(
tt float64,
frame occultationPathFrame,
segment []OccultationPathPoint,
location *time.Location,
pointCount int,
) []OccultationPathPoint {
if len(segment) < 2 {
return nil
}
circle := planetOccultationHorizonCircle(tt, frame, location, pointCount)
if len(circle) == 0 {
return []OccultationPathPoint{segment[0]}
}
from, to := segment[len(segment)-1], segment[0]
unitAt := func(point OccultationPathPoint) occultationPathVector {
fixed := occultationStationSurfaceVector(point.Longitude, point.Latitude)
return occultationPathVector{x: fixed.x / occultationPathEarthEquatorialRadiusKM,
y: fixed.y / occultationPathEarthEquatorialRadiusKM,
z: fixed.z / (occultationPathEarthEquatorialRadiusKM * occultationPathEarthPolarRatio)}
}
moonFixed := occultationPathEarthFixedVector(tt, frame.moon)
normal := occultationPathUnit(occultationPathVector{x: moonFixed.x, y: moonFixed.y, z: moonFixed.z / occultationPathEarthPolarRatio})
first := unitAt(circle[0])
first = occultationPathUnit(occultationPathSub(first, occultationPathScale(normal, occultationPathDot(first, normal))))
second := occultationPathCross(normal, first)
angleAt := func(point OccultationPathPoint) float64 {
value := unitAt(point)
return math.Atan2(occultationPathDot(value, second), occultationPathDot(value, first))
}
// Only interior samples belong to the closure. Rounding either endpoint
// to its nearest circle sample can extend the arc beyond the contact limb.
startAngle := angleAt(from)
delta := math.Remainder(angleAt(to)-startAngle, 2*math.Pi)
spacing := 2 * math.Pi / float64(len(circle))
direction := 1
index := int(math.Floor(startAngle/spacing)) + 1
if delta < 0 {
direction = -1
index = int(math.Ceil(startAngle/spacing)) - 1
}
arc := make([]OccultationPathPoint, 0, int(math.Abs(delta)/spacing)+1)
for angle := float64(index) * spacing; math.Abs(angle-startAngle) < math.Abs(delta); angle += float64(direction) * spacing {
wrapped := (index%len(circle) + len(circle)) % len(circle)
point := circle[wrapped]
// 地平线弧顶点按契约取非负高度,解析圆的模型残差不是地平线以下的几何。
point.MoonAltitude = math.Max(0, point.MoonAltitude)
arc = append(arc, point)
index += direction
}
if len(arc) == 0 && math.Abs(delta) > 1e-14 {
// Even a sub-sample arc needs an interior vertex to close a thin
// visible crescent. Evaluate its midpoint on the same small circle.
centerDistance := occultationPathDot(unitAt(circle[0]), normal)
radius := math.Sqrt(math.Max(0, 1-centerDistance*centerDistance))
angle := startAngle + delta/2
unit := occultationPathAdd(occultationPathScale(normal, centerDistance),
occultationPathScale(occultationPathAdd(occultationPathScale(first, math.Cos(angle)),
occultationPathScale(second, math.Sin(angle))), radius))
fixed := occultationPathScale(unit, occultationPathEarthEquatorialRadiusKM)
fixed.z *= occultationPathEarthPolarRatio
middle := from
middle.Longitude, middle.Latitude = occultationStationGeodetic(fixed)
middle.MoonAltitude = 0
arc = append(arc, middle)
}
return append(arc, to)
}
func planetOccultationFootprintAnchor(
tt float64,
frame occultationPathFrame,
segment []OccultationPathPoint,
location *time.Location,
) (OccultationPathPoint, bool) {
if center, _, ok := occultationEarthLineIntersection(frame.moon, frame.axis); ok {
anchor := occultationPathPointFromVectorWithMoon(
tt, center, 0, frame.moon, location,
)
if anchor.MoonAltitude >= 0 {
return anchor, true
}
}
if len(segment) == 0 {
return OccultationPathPoint{}, false
}
anchor := segment[0]
for _, point := range segment[1:] {
if point.MoonAltitude > anchor.MoonAltitude {
anchor = point
}
}
return anchor, true
}
func planetOccultationCenterCap(
center OccultationPathPoint,
radiusKM float64,
pointCount int,
) []OccultationPathPoint {
if pointCount < 3 || radiusKM <= 0 {
return nil
}
const degreesPerRadian = 180 / math.Pi
latitudeRadians := center.Latitude / degreesPerRadian
cosine := math.Cos(latitudeRadians)
if math.Abs(cosine) < 1e-6 {
cosine = 1e-6
}
latitudeOffset := radiusKM / occultationPathEarthEquatorialRadiusKM * degreesPerRadian
longitudeOffset := latitudeOffset / cosine
cap := make([]OccultationPathPoint, pointCount+1)
for index := 0; index < pointCount; index++ {
angle := 2 * math.Pi * float64(index) / float64(pointCount)
cap[index] = center
cap[index].Longitude = normalizeLongitude(center.Longitude + longitudeOffset*math.Cos(angle))
cap[index].Latitude = math.Max(-90, math.Min(90, center.Latitude+latitudeOffset*math.Sin(angle)))
cap[index].MoonAltitude = center.MoonAltitude
cap[index].WidthKM = 0
}
cap[pointCount] = cap[0]
return cap
}
func planetOccultationCenterRepair(
center OccultationPathPoint,
ring []OccultationPathPoint,
radiusKM float64,
pointCount int,
) [][]OccultationPathPoint {
cap := planetOccultationCenterCap(center, radiusKM, pointCount)
if len(ring) < 2 {
return [][]OccultationPathPoint{cap}
}
nearestIndex := 0
nearestDistance := math.Inf(1)
for index := 1; index < len(ring); index++ {
distance := planetOccultationPointSegmentDistanceKM(
center, ring[index-1], ring[index],
)
if distance < nearestDistance {
nearestDistance = distance
nearestIndex = index - 1
}
}
nextIndex := (nearestIndex + 1) % len(ring)
bridge := []OccultationPathPoint{
center,
ring[nearestIndex],
ring[nextIndex],
center,
}
return [][]OccultationPathPoint{cap, bridge}
}
func planetOccultationPointSegmentDistanceKM(
point, start, end OccultationPathPoint,
) float64 {
latitude := point.Latitude * math.Pi / 180
scaleX := math.Cos(latitude) * occultationPathEarthEquatorialRadiusKM * math.Pi / 180
scaleY := occultationPathEarthEquatorialRadiusKM * math.Pi / 180
x := func(value OccultationPathPoint) float64 {
return math.Remainder(value.Longitude-point.Longitude, 360) * scaleX
}
y := func(value OccultationPathPoint) float64 {
return (value.Latitude - point.Latitude) * scaleY
}
startX, startY := x(start), y(start)
endX, endY := x(end), y(end)
deltaX, deltaY := endX-startX, endY-startY
fraction := 0.0
if lengthSquared := deltaX*deltaX + deltaY*deltaY; lengthSquared > 0 {
fraction = math.Max(0, math.Min(1,
-(startX*deltaX+startY*deltaY)/lengthSquared,
))
}
return math.Hypot(startX+fraction*deltaX, startY+fraction*deltaY)
}
func planetOccultationPathRingContains(
ring []OccultationPathPoint,
longitude, latitude float64,
) bool {
inside := false
for current, previous := 0, len(ring)-1; current < len(ring); previous, current = current, current+1 {
currentLongitude := math.Remainder(ring[current].Longitude-longitude, 360)
previousLongitude := math.Remainder(ring[previous].Longitude-longitude, 360)
if math.Abs(currentLongitude-previousLongitude) > 180 {
if currentLongitude < previousLongitude {
currentLongitude += 360
} else {
previousLongitude += 360
}
}
currentLatitude := ring[current].Latitude
previousLatitude := ring[previous].Latitude
if (currentLatitude > latitude) == (previousLatitude > latitude) {
continue
}
intersection := previousLongitude +
(latitude-previousLatitude)*(currentLongitude-previousLongitude)/
(currentLatitude-previousLatitude)
if intersection > 0 {
inside = !inside
}
}
return inside
}
// planetOccultationHorizonCircle returns the exact lunar horizon on the
// reference ellipsoid. In unit-ellipsoid coordinates, tangent points satisfy
// both |u|=1 and scaledMoon dot u=1, so the horizon is a small circle.
func planetOccultationHorizonCircle(
tt float64,
frame occultationPathFrame,
location *time.Location,
count int,
) []OccultationPathPoint {
if count < 3 {
return nil
}
equatorialRadius := occultationPathEarthEquatorialRadiusKM
polarRadius := equatorialRadius * occultationPathEarthPolarRatio
scaledMoon := occultationPathVector{
x: frame.moon.x / equatorialRadius,
y: frame.moon.y / equatorialRadius,
z: frame.moon.z / polarRadius,
}
distance := occultationPathNorm(scaledMoon)
if distance <= 1 || !finite(distance) {
return nil
}
centerDirection := occultationPathScale(scaledMoon, 1/distance)
reference := occultationPathVector{z: 1}
if math.Abs(centerDirection.z) > 0.9 {
reference = occultationPathVector{x: 1}
}
firstAxis := occultationPathUnit(occultationPathCross(reference, centerDirection))
secondAxis := occultationPathCross(centerDirection, firstAxis)
centerDistance := 1 / distance
circleRadius := math.Sqrt(math.Max(0, 1-centerDistance*centerDistance))
siderealDegrees := ApparentSiderealTime(TD2UT(tt, false)) * 15
points := make([]OccultationPathPoint, count)
for index := range points {
angle := 2 * math.Pi * float64(index) / float64(count)
unitPoint := occultationPathAdd(
occultationPathScale(centerDirection, centerDistance),
occultationPathScale(
occultationPathAdd(
occultationPathScale(firstAxis, math.Cos(angle)),
occultationPathScale(secondAxis, math.Sin(angle)),
),
circleRadius,
),
)
vector := occultationPathVector{
x: equatorialRadius * unitPoint.x,
y: equatorialRadius * unitPoint.y,
z: polarRadius * unitPoint.z,
}
points[index] = occultationPathPointFromVectorWithMoonSidereal(
tt, vector, 0, frame.moon, siderealDegrees, location,
)
}
return points
}