2bf8478639
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
647 lines
23 KiB
Go
647 lines
23 KiB
Go
package occultationgeo
|
|
|
|
import (
|
|
"math"
|
|
"sort"
|
|
"strconv"
|
|
"time"
|
|
|
|
"b612.me/astro/basic"
|
|
"b612.me/astro/internal/geodata"
|
|
)
|
|
|
|
const (
|
|
horizonConnectorMatchDistanceKM = 180.0
|
|
horizonConnectorMinimumLengthKM = 35.0
|
|
// Footprint timelines are sampled at five-minute cadence while rise/set
|
|
// endpoints are solved independently. Allow the nearest sparse sample to
|
|
// be about one minute away without accepting a different event branch.
|
|
horizonConnectorMatchWindow = 90 * time.Second
|
|
horizonConnectorDensifyEdgeKM = 25.0
|
|
// 同相位端点在 5 km 以内属于同一极区折叠:显示曲线已由 StitchedRiseSetCurveSegments
|
|
// 缝合,再输出连接段会在折点处多出一条悬空线,因此这类配对只用于否决该边界的连接段。
|
|
horizonConnectorSamePhaseEndpointDistanceKM = 5.0
|
|
// A same-phase opening/closing fold can expose two branches of one sampled
|
|
// phase curve. It is eligible for a connector only while the footprint
|
|
// endpoints remain within this bound.
|
|
horizonConnectorSamePhaseBranchDistanceKM = 1200.0
|
|
horizonConnectorBoundaryShiftToleranceKM = 25.0
|
|
// A contact-phase pinch can give two different phase curves the same
|
|
// geographic horizon endpoint. There is no missing horizon interval to
|
|
// draw in that case; retaining the footprint's closure produces a small
|
|
// out-and-back loop instead of a boundary segment.
|
|
horizonConnectorDegenerateEndpointDistanceKM = 0.01
|
|
// A start and end phase can meet the same rise/set horizon branch at
|
|
// slightly different instants. The missing boundary is then the short H=0
|
|
// trajectory between those exact endpoints, not an instantaneous footprint
|
|
// closure.
|
|
horizonConnectorTemporalWindow = 2 * time.Minute
|
|
horizonConnectorTemporalDistanceKM = 250.0
|
|
)
|
|
|
|
// HorizonConnector 是在可见接触弧两端之间连接的同时刻月升或月落边界段。
|
|
// HorizonConnector is a same-instant moonrise/moonset boundary segment that
|
|
// connects adjacent local-phase curves at the visible footprint opening or
|
|
// closing. It is not itself one of the start/greatest/end phase curves.
|
|
type HorizonConnector struct {
|
|
Direction basic.RiseSetDirection
|
|
Points []basic.OccultationPathPoint
|
|
}
|
|
|
|
type horizonConnectorEndpoint struct {
|
|
point basic.OccultationPathPoint
|
|
phase basic.RiseSetPhase
|
|
direction basic.RiseSetDirection
|
|
curveIndex int
|
|
segmentIndex int
|
|
atStart bool
|
|
index int
|
|
}
|
|
|
|
type horizonConnectorMatch struct {
|
|
endpoint horizonConnectorEndpoint
|
|
metric float64
|
|
}
|
|
|
|
type horizonConnectorCandidate struct {
|
|
connector HorizonConnector
|
|
metric float64
|
|
}
|
|
|
|
// HorizonConnectorSegments 仅提取端点同时与公开升落阶段端点重合的地平闭合段。
|
|
// HorizonConnectorSegments extracts only the horizon closures whose open
|
|
// contact-limb endpoints tie two sampled rise/set phase endpoints together.
|
|
// Ordinary per-instant horizon closures are intentionally ignored so compact
|
|
// bands do not render as many parallel horizon stripes. Supplied north/south
|
|
// limits filter out closures that belong to a branch outside the exported
|
|
// limit envelope.
|
|
func HorizonConnectorSegments(
|
|
footprints []basic.OccultationFootprint,
|
|
curves []basic.OccultationRiseSetCurve,
|
|
limits ...[]basic.OccultationPathPoint,
|
|
) []HorizonConnector {
|
|
return horizonConnectorSegments(footprints, curves, false, limits...)
|
|
}
|
|
|
|
// StarHorizonConnectorSegments 额外闭合点光源起止阶段在同一月球地平支路上相遇时的短时间缺口。
|
|
// StarHorizonConnectorSegments additionally closes a short temporal gap where
|
|
// a point-source start and end phase meet the same lunar-horizon branch at
|
|
// nearby instants. Finite-disk planet contacts retain same-instant connectors.
|
|
func StarHorizonConnectorSegments(
|
|
footprints []basic.OccultationFootprint,
|
|
curves []basic.OccultationRiseSetCurve,
|
|
limits ...[]basic.OccultationPathPoint,
|
|
) []HorizonConnector {
|
|
return horizonConnectorSegments(footprints, curves, true, limits...)
|
|
}
|
|
func horizonConnectorSegments(
|
|
footprints []basic.OccultationFootprint,
|
|
curves []basic.OccultationRiseSetCurve,
|
|
includeTemporal bool,
|
|
limits ...[]basic.OccultationPathPoint,
|
|
) []HorizonConnector {
|
|
endpoints := horizonConnectorEndpoints(curves)
|
|
if len(endpoints) < 2 || len(footprints) == 0 {
|
|
return nil
|
|
}
|
|
best := make(map[string]horizonConnectorCandidate)
|
|
for _, footprint := range footprints {
|
|
if footprint.Closed {
|
|
continue
|
|
}
|
|
for boundaryIndex, boundary := range footprint.Boundaries {
|
|
if len(boundary) < 2 {
|
|
continue
|
|
}
|
|
firstPoint, lastPoint := boundary[0], boundary[len(boundary)-1]
|
|
if DistanceKM(firstPoint, lastPoint) < horizonConnectorMinimumLengthKM {
|
|
continue
|
|
}
|
|
firstMatches := horizonConnectorEndpointMatches(firstPoint, endpoints)
|
|
lastMatches := horizonConnectorEndpointMatches(lastPoint, endpoints)
|
|
first, last, matched := selectHorizonConnectorMatch(firstMatches, lastMatches)
|
|
if !matched {
|
|
continue
|
|
}
|
|
points := horizonClosurePoints(footprint, boundaryIndex)
|
|
if len(points) < 2 {
|
|
continue
|
|
}
|
|
samePhaseFold := first.endpoint.phase == last.endpoint.phase &&
|
|
DistanceKM(first.endpoint.point, last.endpoint.point) <= horizonConnectorSamePhaseEndpointDistanceKM
|
|
if samePhaseFold {
|
|
// Same-phase fold branches are stitched into one display curve by
|
|
// StitchedRiseSetCurveSegments. Emitting the sampled horizon suffix
|
|
// here would create a second dangling purple stroke at the fold.
|
|
continue
|
|
}
|
|
// The sparse footprint time can sit a fraction of a second between
|
|
// the independently refined phase-curve endpoints. Use those
|
|
// authoritative endpoints so the rendered lines meet exactly while
|
|
// retaining the already-computed horizon arc between them.
|
|
points[0] = last.endpoint.point
|
|
points[len(points)-1] = first.endpoint.point
|
|
points = trimHorizonConnectorSamples(points)
|
|
points = smoothHorizonConnectorJunctions(points)
|
|
if horizonConnectorIsDegenerate(points) {
|
|
continue
|
|
}
|
|
if len(points) <= 8 {
|
|
points = densifyHorizonConnectorPoints(points, horizonConnectorDensifyEdgeKM)
|
|
}
|
|
targetTime := first.endpoint.point.Time
|
|
if last.endpoint.point.Time.Before(targetTime) {
|
|
targetTime = last.endpoint.point.Time
|
|
}
|
|
firstIndex, lastIndex := first.endpoint.index, last.endpoint.index
|
|
if firstIndex > lastIndex {
|
|
firstIndex, lastIndex = lastIndex, firstIndex
|
|
}
|
|
key := string(first.endpoint.direction) + "/" +
|
|
targetTime.UTC().Round(time.Second).Format(time.RFC3339) + "/" +
|
|
strconv.Itoa(firstIndex) + "/" + strconv.Itoa(lastIndex)
|
|
metric := first.metric + last.metric + absDuration(first.endpoint.point.Time.Sub(last.endpoint.point.Time)).Seconds()
|
|
if current, ok := best[key]; !ok || metric < current.metric {
|
|
best[key] = horizonConnectorCandidate{
|
|
connector: HorizonConnector{Direction: first.endpoint.direction, Points: points},
|
|
metric: metric,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if includeTemporal {
|
|
for _, candidate := range temporalHorizonConnectorCandidates(footprints, endpoints) {
|
|
if current, ok := best[candidate.key]; !ok || candidate.value.metric < current.metric {
|
|
best[candidate.key] = candidate.value
|
|
}
|
|
}
|
|
}
|
|
if len(best) == 0 {
|
|
return nil
|
|
}
|
|
keys := make([]string, 0, len(best))
|
|
for key := range best {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
result := make([]HorizonConnector, 0, len(keys))
|
|
for _, key := range keys {
|
|
result = append(result, best[key].connector)
|
|
}
|
|
return horizonConnectorsWithinLimits(result, limits)
|
|
}
|
|
|
|
// horizonConnectorsWithinLimits 丢弃整段都远离导出南北限的连接段(换支伪影)。
|
|
func horizonConnectorsWithinLimits(
|
|
connectors []HorizonConnector,
|
|
limits [][]basic.OccultationPathPoint,
|
|
) []HorizonConnector {
|
|
if len(connectors) == 0 || len(limits) == 0 {
|
|
return connectors
|
|
}
|
|
filtered := make([]HorizonConnector, 0, len(connectors))
|
|
for _, connector := range connectors {
|
|
if horizonConnectorTouchesLimits(connector.Points, limits) {
|
|
filtered = append(filtered, connector)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func horizonConnectorTouchesLimits(
|
|
points []basic.OccultationPathPoint,
|
|
limits [][]basic.OccultationPathPoint,
|
|
) bool {
|
|
hasLimit := false
|
|
for _, limit := range limits {
|
|
if len(limit) < 2 {
|
|
continue
|
|
}
|
|
hasLimit = true
|
|
for index := 1; index < len(limit); index++ {
|
|
start := geodata.GeoPoint{Longitude: limit[index-1].Longitude, Latitude: limit[index-1].Latitude}
|
|
end := geodata.GeoPoint{Longitude: limit[index].Longitude, Latitude: limit[index].Latitude}
|
|
for _, point := range points {
|
|
if geoPointSegmentDistanceKM(
|
|
geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude},
|
|
start, end,
|
|
) <= horizonConnectorMatchDistanceKM {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// 限线为空时没有可用的过滤依据,保留原结果。
|
|
return !hasLimit
|
|
}
|
|
|
|
// smoothHorizonConnectorJunctions removes a short numerical corner where an
|
|
// exact phase endpoint replaces the nearest sparse horizon sample. The source
|
|
// arc is still the lunar-altitude-zero boundary; deleting only the offending
|
|
// interior sample preserves both exact endpoints and time order, while the
|
|
// caller's densification restores the display spacing. Real connector bends
|
|
// span a longer arc or have a larger deviation and are left unchanged.
|
|
func smoothHorizonConnectorJunctions(points []basic.OccultationPathPoint) []basic.OccultationPathPoint {
|
|
if len(points) < 4 {
|
|
return points
|
|
}
|
|
result := append([]basic.OccultationPathPoint(nil), points...)
|
|
const (
|
|
maximumAdjacentEdgeKM = 140.0
|
|
maximumChordKM = 140.0
|
|
minimumTurnDegrees = 55.0
|
|
minimumDeviationKM = 5.0
|
|
maximumDeviationKM = 25.0
|
|
)
|
|
for pass := 0; pass < 2; pass++ {
|
|
changed := false
|
|
for index := 1; index+1 < len(result); index++ {
|
|
// Only the first/last interior sample can be the sparse sample
|
|
// replaced by an independently refined phase endpoint. Interior
|
|
// bends belong to the physical H=0 trajectory and must remain.
|
|
if index != 1 && index+2 != len(result) {
|
|
continue
|
|
}
|
|
first, middle, last := result[index-1], result[index], result[index+1]
|
|
if DistanceKM(first, middle) > maximumAdjacentEdgeKM ||
|
|
DistanceKM(middle, last) > maximumAdjacentEdgeKM ||
|
|
DistanceKM(first, last) > maximumChordKM {
|
|
continue
|
|
}
|
|
turn := 180 - occultationTurnAngleDegrees(
|
|
geodata.GeoPoint{Longitude: first.Longitude, Latitude: first.Latitude},
|
|
geodata.GeoPoint{Longitude: middle.Longitude, Latitude: middle.Latitude},
|
|
geodata.GeoPoint{Longitude: last.Longitude, Latitude: last.Latitude},
|
|
)
|
|
if turn < minimumTurnDegrees {
|
|
continue
|
|
}
|
|
deviation := occultationProjectedPointLineDistanceKM(
|
|
geodata.GeoPoint{Longitude: middle.Longitude, Latitude: middle.Latitude},
|
|
geodata.GeoPoint{Longitude: first.Longitude, Latitude: first.Latitude},
|
|
geodata.GeoPoint{Longitude: last.Longitude, Latitude: last.Latitude},
|
|
)
|
|
if deviation < minimumDeviationKM || deviation > maximumDeviationKM {
|
|
continue
|
|
}
|
|
result = append(result[:index], result[index+1:]...)
|
|
changed = true
|
|
break
|
|
}
|
|
if !changed {
|
|
break
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func horizonConnectorIsDegenerate(points []basic.OccultationPathPoint) bool {
|
|
return len(points) >= 2 &&
|
|
DistanceKM(points[0], points[len(points)-1]) <= horizonConnectorDegenerateEndpointDistanceKM
|
|
}
|
|
|
|
type keyedHorizonConnectorCandidate struct {
|
|
key string
|
|
value horizonConnectorCandidate
|
|
}
|
|
|
|
func temporalHorizonConnectorCandidates(
|
|
footprints []basic.OccultationFootprint,
|
|
endpoints []horizonConnectorEndpoint,
|
|
) []keyedHorizonConnectorCandidate {
|
|
result := make([]keyedHorizonConnectorCandidate, 0, 2)
|
|
for firstIndex, first := range endpoints {
|
|
if first.phase != basic.RiseSetPhaseStart && first.phase != basic.RiseSetPhaseEnd {
|
|
continue
|
|
}
|
|
for secondIndex := firstIndex + 1; secondIndex < len(endpoints); secondIndex++ {
|
|
second := endpoints[secondIndex]
|
|
if first.phase == second.phase || first.direction != second.direction ||
|
|
(second.phase != basic.RiseSetPhaseStart && second.phase != basic.RiseSetPhaseEnd) {
|
|
continue
|
|
}
|
|
start, end := first, second
|
|
if end.point.Time.Before(start.point.Time) {
|
|
start, end = end, start
|
|
}
|
|
duration := end.point.Time.Sub(start.point.Time)
|
|
distance := DistanceKM(start.point, end.point)
|
|
if duration <= time.Second || duration > horizonConnectorTemporalWindow ||
|
|
distance <= 1 || distance > horizonConnectorTemporalDistanceKM {
|
|
continue
|
|
}
|
|
points, metric, ok := temporalHorizonConnectorPoints(footprints, start.point, end.point)
|
|
if !ok {
|
|
continue
|
|
}
|
|
points = densifyHorizonConnectorPoints(points, horizonConnectorDensifyEdgeKM)
|
|
key := "temporal/" + string(start.direction) + "/" +
|
|
start.point.Time.UTC().Round(time.Second).Format(time.RFC3339) + "/" +
|
|
end.point.Time.UTC().Round(time.Second).Format(time.RFC3339)
|
|
result = append(result, keyedHorizonConnectorCandidate{
|
|
key: key,
|
|
value: horizonConnectorCandidate{
|
|
connector: HorizonConnector{Direction: start.direction, Points: points},
|
|
metric: metric,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func temporalHorizonConnectorPoints(
|
|
footprints []basic.OccultationFootprint,
|
|
start, end basic.OccultationPathPoint,
|
|
) ([]basic.OccultationPathPoint, float64, bool) {
|
|
if !start.Time.Before(end.Time) {
|
|
return nil, 0, false
|
|
}
|
|
startAnchor := temporalHorizonEndpointAnchorDistance(footprints, start)
|
|
endAnchor := temporalHorizonEndpointAnchorDistance(footprints, end)
|
|
if startAnchor > horizonConnectorMatchDistanceKM || endAnchor > horizonConnectorMatchDistanceKM {
|
|
return nil, 0, false
|
|
}
|
|
points := []basic.OccultationPathPoint{start}
|
|
for _, footprint := range footprints {
|
|
if footprint.Closed || !footprint.Time.After(start.Time) || !footprint.Time.Before(end.Time) {
|
|
continue
|
|
}
|
|
fraction := float64(footprint.Time.Sub(start.Time)) / float64(end.Time.Sub(start.Time))
|
|
target := interpolateOccultationGeoPoint(
|
|
geodata.GeoPoint{Longitude: start.Longitude, Latitude: start.Latitude},
|
|
geodata.GeoPoint{Longitude: end.Longitude, Latitude: end.Latitude}, fraction,
|
|
)
|
|
candidate, distance, ok := temporalHorizonBoundaryEndpointNear(
|
|
footprint, basic.OccultationPathPoint{Longitude: target.Longitude, Latitude: target.Latitude},
|
|
)
|
|
if !ok || distance > horizonConnectorMatchDistanceKM {
|
|
continue
|
|
}
|
|
points = append(points, candidate)
|
|
}
|
|
points = append(points, end)
|
|
sort.SliceStable(points[1:len(points)-1], func(first, second int) bool {
|
|
return points[first+1].Time.Before(points[second+1].Time)
|
|
})
|
|
points = deduplicateTemporalHorizonConnectorPoints(points)
|
|
return points, startAnchor + endAnchor, len(points) >= 2
|
|
}
|
|
|
|
func temporalHorizonEndpointAnchorDistance(
|
|
footprints []basic.OccultationFootprint,
|
|
endpoint basic.OccultationPathPoint,
|
|
) float64 {
|
|
minimum := math.Inf(1)
|
|
for _, footprint := range footprints {
|
|
if footprint.Closed || absDuration(footprint.Time.Sub(endpoint.Time)) > horizonConnectorMatchWindow {
|
|
continue
|
|
}
|
|
_, distance, ok := temporalHorizonBoundaryEndpointNear(footprint, endpoint)
|
|
if ok {
|
|
minimum = math.Min(minimum, distance)
|
|
}
|
|
}
|
|
return minimum
|
|
}
|
|
|
|
func temporalHorizonBoundaryEndpointNear(
|
|
footprint basic.OccultationFootprint,
|
|
target basic.OccultationPathPoint,
|
|
) (basic.OccultationPathPoint, float64, bool) {
|
|
best := basic.OccultationPathPoint{}
|
|
minimum := math.Inf(1)
|
|
for _, boundary := range footprint.Boundaries {
|
|
if len(boundary) < 2 {
|
|
continue
|
|
}
|
|
for _, candidate := range []basic.OccultationPathPoint{boundary[0], boundary[len(boundary)-1]} {
|
|
if distance := DistanceKM(candidate, target); distance < minimum {
|
|
best, minimum = candidate, distance
|
|
}
|
|
}
|
|
}
|
|
return best, minimum, minimum < math.Inf(1)
|
|
}
|
|
|
|
func deduplicateTemporalHorizonConnectorPoints(
|
|
points []basic.OccultationPathPoint,
|
|
) []basic.OccultationPathPoint {
|
|
if len(points) < 2 {
|
|
return points
|
|
}
|
|
result := make([]basic.OccultationPathPoint, 0, len(points))
|
|
for _, point := range points {
|
|
if len(result) > 0 && (!point.Time.After(result[len(result)-1].Time) ||
|
|
DistanceKM(point, result[len(result)-1]) <= 0.001) {
|
|
continue
|
|
}
|
|
result = append(result, point)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func densifyHorizonConnectorPoints(
|
|
points []basic.OccultationPathPoint,
|
|
maximumEdgeKM float64,
|
|
) []basic.OccultationPathPoint {
|
|
if len(points) < 2 || maximumEdgeKM <= 0 {
|
|
return points
|
|
}
|
|
result := make([]basic.OccultationPathPoint, 0, len(points)*2)
|
|
for index, point := range points {
|
|
result = append(result, point)
|
|
if index+1 >= len(points) {
|
|
continue
|
|
}
|
|
next := points[index+1]
|
|
distance := DistanceKM(point, next)
|
|
steps := int(math.Ceil(distance / maximumEdgeKM))
|
|
if steps < 2 {
|
|
continue
|
|
}
|
|
for step := 1; step < steps; step++ {
|
|
fraction := float64(step) / float64(steps)
|
|
middle := interpolateOccultationGeoPoint(
|
|
geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude},
|
|
geodata.GeoPoint{Longitude: next.Longitude, Latitude: next.Latitude},
|
|
fraction,
|
|
)
|
|
result = append(result, basic.OccultationPathPoint{
|
|
Time: point.Time.Add(time.Duration(float64(next.Time.Sub(point.Time)) * fraction)),
|
|
Longitude: middle.Longitude,
|
|
Latitude: middle.Latitude,
|
|
MoonAltitude: point.MoonAltitude + (next.MoonAltitude-point.MoonAltitude)*fraction,
|
|
WidthKM: point.WidthKM + (next.WidthKM-point.WidthKM)*fraction,
|
|
})
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func selectHorizonConnectorMatch(
|
|
firstMatches, lastMatches []horizonConnectorMatch,
|
|
) (horizonConnectorMatch, horizonConnectorMatch, bool) {
|
|
bestMetric := 0.0
|
|
var bestFirst, bestLast horizonConnectorMatch
|
|
found := false
|
|
for _, first := range firstMatches {
|
|
for _, last := range lastMatches {
|
|
if first.endpoint.index == last.endpoint.index ||
|
|
first.endpoint.direction != last.endpoint.direction ||
|
|
absDuration(first.endpoint.point.Time.Sub(last.endpoint.point.Time)) > horizonConnectorMatchWindow {
|
|
continue
|
|
}
|
|
if first.endpoint.phase == last.endpoint.phase {
|
|
if !samePhaseHorizonConnectorAllowed(first.endpoint, last.endpoint) {
|
|
continue
|
|
}
|
|
}
|
|
metric := first.metric + last.metric +
|
|
absDuration(first.endpoint.point.Time.Sub(last.endpoint.point.Time)).Seconds()
|
|
if !found || metric < bestMetric {
|
|
bestFirst, bestLast, bestMetric, found = first, last, metric, true
|
|
}
|
|
}
|
|
}
|
|
return bestFirst, bestLast, found
|
|
}
|
|
|
|
// horizonClosurePoints returns the closing lunar-horizon arc stored after an
|
|
// open contact-limb boundary in the corresponding visible polygon. The two
|
|
// endpoint matches are deliberately performed on Boundaries by the caller:
|
|
// those are the contact-phase points, while this suffix is the physical arc
|
|
// that must be drawn between them.
|
|
func horizonClosurePoints(
|
|
footprint basic.OccultationFootprint,
|
|
boundaryIndex int,
|
|
) []basic.OccultationPathPoint {
|
|
if boundaryIndex < 0 || boundaryIndex >= len(footprint.Boundaries) ||
|
|
boundaryIndex >= len(footprint.Polygons) {
|
|
return nil
|
|
}
|
|
boundary := footprint.Boundaries[boundaryIndex]
|
|
polygon := footprint.Polygons[boundaryIndex]
|
|
if len(boundary) < 2 || len(polygon) <= len(boundary) {
|
|
return nil
|
|
}
|
|
// Footprint construction copies the contact-limb boundary verbatim before
|
|
// appending its horizon closure. Reject a mismatched polygon rather than
|
|
// slicing an unrelated ring by position.
|
|
for index := range boundary {
|
|
if DistanceKM(boundary[index], polygon[index]) > horizonConnectorBoundaryShiftToleranceKM {
|
|
return nil
|
|
}
|
|
}
|
|
points := make([]basic.OccultationPathPoint, 0, 1+len(polygon)-len(boundary))
|
|
points = append(points, boundary[len(boundary)-1])
|
|
for _, point := range polygon[len(boundary):] {
|
|
if DistanceKM(points[len(points)-1], point) <= 1e-6 {
|
|
continue
|
|
}
|
|
points = append(points, point)
|
|
}
|
|
if len(points) < 2 || DistanceKM(points[len(points)-1], boundary[0]) > horizonConnectorBoundaryShiftToleranceKM {
|
|
return nil
|
|
}
|
|
points[len(points)-1] = boundary[0]
|
|
return points
|
|
}
|
|
|
|
func horizonConnectorEndpoints(curves []basic.OccultationRiseSetCurve) []horizonConnectorEndpoint {
|
|
endpoints := make([]horizonConnectorEndpoint, 0, len(curves)*4)
|
|
for curveIndex, curve := range curves {
|
|
for segmentIndex, segment := range curve.Segments {
|
|
if len(segment) == 0 {
|
|
continue
|
|
}
|
|
endpoints = append(endpoints, horizonConnectorEndpoint{
|
|
point: segment[0], phase: curve.Phase, direction: curve.Direction,
|
|
curveIndex: curveIndex, segmentIndex: segmentIndex, atStart: true, index: len(endpoints),
|
|
})
|
|
if len(segment) > 1 {
|
|
endpoints = append(endpoints, horizonConnectorEndpoint{
|
|
point: segment[len(segment)-1], phase: curve.Phase, direction: curve.Direction,
|
|
curveIndex: curveIndex, segmentIndex: segmentIndex, atStart: false, index: len(endpoints),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return endpoints
|
|
}
|
|
|
|
// samePhaseHorizonConnectorAllowed 允许同相位的独立支路缺口配对;≤5 km 的折叠端点也
|
|
// 允许进入选择,以便否决该边界上的其他配对(真正的丢弃发生在 samePhaseFold)。
|
|
func samePhaseHorizonConnectorAllowed(
|
|
first, last horizonConnectorEndpoint,
|
|
) bool {
|
|
if first.curveIndex != last.curveIndex || first.segmentIndex == last.segmentIndex ||
|
|
first.atStart != last.atStart {
|
|
return false
|
|
}
|
|
return DistanceKM(first.point, last.point) <= horizonConnectorSamePhaseBranchDistanceKM
|
|
}
|
|
|
|
// Exact phase endpoints can lie inside the sparse footprint horizon arc.
|
|
// Keep only the source samples between them, in their original order.
|
|
func trimHorizonConnectorSamples(points []basic.OccultationPathPoint) []basic.OccultationPathPoint {
|
|
if len(points) < 3 || DistanceKM(points[0], points[len(points)-1]) > horizonConnectorSamePhaseBranchDistanceKM {
|
|
return points
|
|
}
|
|
first, last := points[0], points[len(points)-1]
|
|
const radians = math.Pi / 180
|
|
sinLat, cosLat := math.Sincos(first.Latitude * radians)
|
|
components := func(point basic.OccultationPathPoint) (float64, float64, float64) {
|
|
latitude, longitude := point.Latitude*radians, (point.Longitude-first.Longitude)*radians
|
|
east := math.Cos(latitude) * math.Sin(longitude)
|
|
north := cosLat*math.Sin(latitude) - sinLat*math.Cos(latitude)*math.Cos(longitude)
|
|
radial := sinLat*math.Sin(latitude) + cosLat*math.Cos(latitude)*math.Cos(longitude)
|
|
return east, north, radial
|
|
}
|
|
east, north, radial := components(last)
|
|
length := math.Hypot(east, north)
|
|
if length <= 1e-12 {
|
|
return points
|
|
}
|
|
east, north = east/length, north/length
|
|
total := math.Atan2(length, radial)
|
|
result := make([]basic.OccultationPathPoint, 1, len(points))
|
|
result[0] = first
|
|
previous := 0.0
|
|
for _, point := range points[1 : len(points)-1] {
|
|
x, y, z := components(point)
|
|
progress := math.Atan2(x*east+y*north, z)
|
|
if progress > previous && progress < total {
|
|
result = append(result, point)
|
|
previous = progress
|
|
}
|
|
}
|
|
return append(result, last)
|
|
}
|
|
|
|
func horizonConnectorEndpointMatches(
|
|
point basic.OccultationPathPoint,
|
|
endpoints []horizonConnectorEndpoint,
|
|
) []horizonConnectorMatch {
|
|
matches := make([]horizonConnectorMatch, 0, len(endpoints))
|
|
for _, endpoint := range endpoints {
|
|
deltaTime := absDuration(point.Time.Sub(endpoint.point.Time))
|
|
if deltaTime > horizonConnectorMatchWindow {
|
|
continue
|
|
}
|
|
distance := DistanceKM(point, endpoint.point)
|
|
if distance > horizonConnectorMatchDistanceKM {
|
|
continue
|
|
}
|
|
metric := distance + deltaTime.Seconds()*5
|
|
matches = append(matches, horizonConnectorMatch{endpoint: endpoint, metric: metric})
|
|
}
|
|
sort.SliceStable(matches, func(first, second int) bool {
|
|
return matches[first].metric < matches[second].metric
|
|
})
|
|
if len(matches) == 0 {
|
|
return nil
|
|
}
|
|
return matches
|
|
}
|