Files
astro/internal/occultationgeo/footprint_sweep.go
T

873 lines
28 KiB
Go
Raw Normal View History

package occultationgeo
import (
"fmt"
"math"
"b612.me/astro/basic"
"b612.me/astro/internal/geodata"
)
func footprintClosedSweepPolygons(
footprints []basic.OccultationFootprint,
) [][]geodata.GeoPoint {
polygons := make([][]geodata.GeoPoint, 0, len(footprints))
var previous []geodata.GeoPoint
for _, footprint := range footprints {
if !footprint.Closed {
previous = nil
continue
}
ring, ok := footprintClosedSweepRing(footprint)
if !ok {
previous = nil
continue
}
ring = resampleClosedFootprintRing(ring, closedFootprintSweepPoints)
if len(ring) < 4 {
previous = nil
continue
}
if len(previous) == 0 {
previous = ring
continue
}
ring = alignClosedFootprintRing(previous, ring)
if footprintClosedSweepMaximumStep(previous, ring) > closedFootprintSweepMaxStepKM {
previous = ring
continue
}
polygons = append(polygons, footprintClosedSweepCells(previous, ring)...)
previous = ring
}
return polygons
}
func footprintClosedSweepRing(
footprint basic.OccultationFootprint,
) ([]geodata.GeoPoint, bool) {
boundary := openFootprintRing(geodata.JoinPolylineSegments(footprintGeoBoundaries(footprint)))
if len(boundary) >= 4 {
return boundary, true
}
for _, source := range footprint.Polygons {
if len(source) < 4 || occultationInteriorPolygon(source, footprint.InteriorPolygons) {
continue
}
ring := make([]geodata.GeoPoint, len(source))
for index, point := range source {
ring[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
ring = openFootprintRing(ring)
if len(ring) >= 4 {
return ring, true
}
}
return nil, false
}
func resampleClosedFootprintRing(
ring []geodata.GeoPoint,
count int,
) []geodata.GeoPoint {
ring = openFootprintRing(ring)
if count <= 0 || len(ring) <= count {
return append([]geodata.GeoPoint(nil), ring...)
}
result := make([]geodata.GeoPoint, count)
for index := range result {
result[index] = ring[index*len(ring)/count]
}
return result
}
func alignClosedFootprintRing(
previous, current []geodata.GeoPoint,
) []geodata.GeoPoint {
if len(previous) == 0 || len(current) == 0 || len(previous) != len(current) {
return append([]geodata.GeoPoint(nil), current...)
}
bestShift := 0
bestReversed := false
bestScore := math.Inf(1)
for _, reversed := range []bool{false, true} {
candidate := append([]geodata.GeoPoint(nil), current...)
if reversed {
reverseGeoPointRing(candidate)
}
for shift := 0; shift < len(candidate); shift++ {
score := closedFootprintRingAlignmentScore(previous, candidate, shift)
if score < bestScore {
bestScore = score
bestShift = shift
bestReversed = reversed
}
}
}
aligned := append([]geodata.GeoPoint(nil), current...)
if bestReversed {
reverseGeoPointRing(aligned)
}
return rotateClosedFootprintRing(aligned, bestShift)
}
func reverseGeoPointRing(points []geodata.GeoPoint) {
for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 {
points[left], points[right] = points[right], points[left]
}
}
func closedFootprintRingAlignmentScore(
previous, current []geodata.GeoPoint,
shift int,
) float64 {
samples := 16
if len(previous) < samples {
samples = len(previous)
}
score := 0.0
for sample := 0; sample < samples; sample++ {
index := sample * len(previous) / samples
score += geoDistanceKM(previous[index], current[(index+shift)%len(current)])
}
return score
}
func rotateClosedFootprintRing(
points []geodata.GeoPoint,
shift int,
) []geodata.GeoPoint {
result := make([]geodata.GeoPoint, len(points))
for index := range result {
result[index] = points[(index+shift)%len(points)]
}
return result
}
func footprintClosedSweepMaximumStep(
first, second []geodata.GeoPoint,
) float64 {
count := len(first)
if len(second) < count {
count = len(second)
}
maximum := 0.0
for index := 0; index < count; index++ {
maximum = math.Max(maximum, geoDistanceKM(first[index], second[index]))
}
return maximum
}
func footprintClosedSweepCells(
first, second []geodata.GeoPoint,
) [][]geodata.GeoPoint {
count := len(first)
if len(second) < count {
count = len(second)
}
polygons := make([][]geodata.GeoPoint, 0, count)
for index := 0; index < count; index++ {
next := (index + 1) % count
polygon := []geodata.GeoPoint{
first[index],
second[index],
second[next],
first[next],
first[index],
}
if math.Abs(geoRingArea(polygon)) <= 1e-10 {
continue
}
polygons = append(polygons, polygon)
}
return polygons
}
// footprintSweepSampleProbes 采样开放接触弧上的可见边界点作为覆盖见证探针。
func footprintSweepSampleProbes(footprints []basic.OccultationFootprint) [][]geodata.GeoPoint {
probes := make([][]geodata.GeoPoint, 0, len(footprints)*16)
for _, footprint := range footprints {
// Closed footprints are added separately as instantaneous caps. They do
// not belong to the open-boundary sweep, so checking them here would
// reject every valid sweep that contains a horizon-closed sample.
if footprint.Closed {
continue
}
for _, boundary := range footprint.Boundaries {
step := len(boundary) / 16
if step < 1 {
step = 1
}
for index := 0; index < len(boundary); index += step {
point := geodata.GeoPoint{Longitude: boundary[index].Longitude, Latitude: boundary[index].Latitude}
probes = append(probes, []geodata.GeoPoint{point})
}
}
}
return probes
}
func footprintSweepCoversSamples(
polygons [][]geodata.GeoPoint,
footprints []basic.OccultationFootprint,
) bool {
if len(polygons) == 0 {
return false
}
probes := footprintSweepSampleProbes(footprints)
// At a high-latitude open/closed transition the sweep edge can coincide
// with a source tangent sample within floating-point error. A sub-kilometre
// tolerance accepts that shared physical edge without admitting a real
// branch gap. Ordinary-latitude stellar bands retain the strict check so a
// genuinely missing center-line segment cannot be hidden by the tolerance.
toleranceKM := 0.0
minimumAbsoluteLatitude := 90.0
allNorthern, allSouthern := true, true
for _, footprint := range footprints {
for _, boundary := range footprint.Boundaries {
for _, point := range boundary {
minimumAbsoluteLatitude = math.Min(minimumAbsoluteLatitude, math.Abs(point.Latitude))
allNorthern = allNorthern && point.Latitude >= 0
allSouthern = allSouthern && point.Latitude <= 0
}
}
}
if minimumAbsoluteLatitude >= 40 && (allNorthern || allSouthern) {
toleranceKM = 1
}
return geodata.SphericalPolygonsContainPathsWithinKM(polygons, probes, true, toleranceKM)
}
// footprintVisibleUnionPolygons returns the sampled, horizon-closed visible
// area without replacing it by open contact-arc ribbons. It is used as a mask
// audit for polar sweeps: the source polygons are the only representation that
// carries the instantaneous Moon-above-horizon closure.
func footprintVisibleUnionPolygons(
footprints []basic.OccultationFootprint,
) [][]geodata.GeoPoint {
visibleFill := occultationVisibleFootprintFillOnly(footprints)
inputs := visibleFill
inputs = append(inputs, footprintStaticInteriorPolygons(footprints)...)
inputs = usableOccultationPolygons(inputs)
if len(inputs) == 0 {
return nil
}
merged, err := geodata.UnionPolygons(inputs)
if err != nil || len(merged) == 0 {
// The spherical union can reject one numerically open edge at a horizon
// transition even though the input footprints form a single temporal
// ribbon. Run the bounded touching merge on the usable source rings so a
// sub-60 km seam does not leak out as one polygon per time sample.
return mergeTouchingVisiblePolygons(inputs)
}
return mergeTouchingVisiblePolygons(merged)
}
func usableOccultationPolygons(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint {
result := make([][]geodata.GeoPoint, 0, len(polygons))
for _, polygon := range polygons {
open := openFootprintRing(polygon)
if len(open) < 3 || math.Abs(geoRingArea(open)) <= 1e-12 {
continue
}
result = append(result, polygon)
}
return result
}
// occultationUnitBounds 是环的三维单位向量包围盒(已含大圆弧外凸余量)。
type occultationUnitBounds struct {
minX, minY, minZ float64
maxX, maxY, maxZ float64
}
func occultationUnitVector(point geodata.GeoPoint) (float64, float64, float64) {
latitude := point.Latitude * math.Pi / 180
longitude := point.Longitude * math.Pi / 180
cosLatitude := math.Cos(latitude)
return cosLatitude * math.Cos(longitude), cosLatitude * math.Sin(longitude), math.Sin(latitude)
}
func occultationPolygonUnitBounds(polygon []geodata.GeoPoint) occultationUnitBounds {
bounds := occultationUnitBounds{
minX: math.Inf(1), minY: math.Inf(1), minZ: math.Inf(1),
maxX: math.Inf(-1), maxY: math.Inf(-1), maxZ: math.Inf(-1),
}
vectors := make([][3]float64, 0, len(polygon))
minimumCosine := 1.0
for _, point := range polygon {
x, y, z := occultationUnitVector(point)
vectors = append(vectors, [3]float64{x, y, z})
}
for index, vector := range vectors {
next := vectors[(index+1)%len(vectors)]
if cosine := vector[0]*next[0] + vector[1]*next[1] + vector[2]*next[2]; cosine < minimumCosine {
minimumCosine = cosine
}
}
// 大圆弧中点会凸出端点坐标,按最长弧的半角放大包围盒,保证下界仍然成立。
scale := 1.0
if half := math.Sqrt(math.Max(0, (1+minimumCosine)/2)); half > 1e-9 {
scale = 1 / half
}
for _, vector := range vectors {
for axis, value := range vector {
high, low := value, value
if value > 0 {
high = value * scale
} else {
low = value * scale
}
switch axis {
case 0:
bounds.minX, bounds.maxX = math.Min(bounds.minX, low), math.Max(bounds.maxX, high)
case 1:
bounds.minY, bounds.maxY = math.Min(bounds.minY, low), math.Max(bounds.maxY, high)
default:
bounds.minZ, bounds.maxZ = math.Min(bounds.minZ, low), math.Max(bounds.maxZ, high)
}
}
}
return bounds
}
// occultationUnitBoundsNear 报告两包围盒的弦距下界是否可能小于 limitKM。
func occultationUnitBoundsNear(first, second occultationUnitBounds, limitKM float64) bool {
dx := math.Max(0, math.Max(first.minX-second.maxX, second.minX-first.maxX))
dy := math.Max(0, math.Max(first.minY-second.maxY, second.minY-first.maxY))
dz := math.Max(0, math.Max(first.minZ-second.maxZ, second.minZ-first.maxZ))
// 大圆距离不小于弦长,因此弦距下界可以作为 60 km 近邻判定的必要条件。
return EarthRadiusKM*math.Sqrt(dx*dx+dy*dy+dz*dz) <= limitKM
}
func mergeTouchingVisiblePolygons(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint {
const (
// Boolean operations on adjacent five-minute footprints can leave
// several-kilometre numerical gaps even though the temporal samples
// overlap. Keep a modest 60 km ceiling for the rare last-gap transition;
// larger separations remain disconnected physical components and are not
// bridged.
touchingDistanceKM = 60.0
minimumBridgeHalfDeg = 0.01
)
if len(polygons) < 2 {
return polygons
}
bounds := make([]occultationUnitBounds, len(polygons))
for index, polygon := range polygons {
bounds[index] = occultationPolygonUnitBounds(polygon)
}
for len(polygons) > 1 {
firstIndex, secondIndex := -1, -1
var touching, leftTouch, rightTouch geodata.GeoPoint
for first := 0; first < len(polygons) && firstIndex < 0; first++ {
for second := first + 1; second < len(polygons) && firstIndex < 0; second++ {
if !occultationUnitBoundsNear(bounds[first], bounds[second], touchingDistanceKM) {
continue
}
for _, left := range polygons[first] {
for _, right := range polygons[second] {
if geoDistanceKM(left, right) > touchingDistanceKM {
continue
}
firstIndex, secondIndex = first, second
leftTouch, rightTouch = left, right
touching = geodata.GeoPoint{
Longitude: left.Longitude + math.Remainder(right.Longitude-left.Longitude, 360)/2,
Latitude: (left.Latitude + right.Latitude) / 2,
}
break
}
if firstIndex >= 0 {
break
}
}
}
}
if firstIndex < 0 || secondIndex < 0 {
break
}
bridgeHalfDeg := minimumBridgeHalfDeg
if gap := geoDistanceKM(leftTouch, rightTouch) / EarthRadiusKM * 180 / math.Pi; gap/2+0.002 > bridgeHalfDeg {
bridgeHalfDeg = gap/2 + 0.002
}
bridge := []geodata.GeoPoint{
{Longitude: touching.Longitude - bridgeHalfDeg, Latitude: touching.Latitude - bridgeHalfDeg},
{Longitude: touching.Longitude + bridgeHalfDeg, Latitude: touching.Latitude - bridgeHalfDeg},
{Longitude: touching.Longitude + bridgeHalfDeg, Latitude: touching.Latitude + bridgeHalfDeg},
{Longitude: touching.Longitude - bridgeHalfDeg, Latitude: touching.Latitude + bridgeHalfDeg},
}
leftPolygon := append([]geodata.GeoPoint(nil), polygons[firstIndex]...)
rightPolygon := append([]geodata.GeoPoint(nil), polygons[secondIndex]...)
for index, point := range leftPolygon {
if point == leftTouch {
leftPolygon[index] = touching
break
}
}
for index, point := range rightPolygon {
if point == rightTouch {
rightPolygon[index] = touching
break
}
}
pair, err := geodata.UnionPolygons([][]geodata.GeoPoint{
leftPolygon, rightPolygon, bridge,
})
if err != nil || len(pair) != 1 {
pair, err = geodata.UnionPolygons([][]geodata.GeoPoint{
leftPolygon, rightPolygon,
})
}
if err != nil || len(pair) != 1 {
break
}
next := make([][]geodata.GeoPoint, 0, len(polygons)-1)
nextBounds := make([]occultationUnitBounds, 0, len(polygons)-1)
for index, polygon := range polygons {
if index == firstIndex {
next = append(next, pair[0])
nextBounds = append(nextBounds, occultationPolygonUnitBounds(pair[0]))
continue
}
if index == secondIndex {
continue
}
next = append(next, polygon)
nextBounds = append(nextBounds, bounds[index])
}
polygons, bounds = next, nextBounds
}
return polygons
}
func footprintSweepNeedsHorizonClipping(
swept, visibleUnion [][]geodata.GeoPoint,
footprints []basic.OccultationFootprint,
) bool {
if len(swept) == 0 || len(visibleUnion) == 0 {
return false
}
// A single continuous open sweep against a fragmented horizon union is a
// topological disagreement, not a small metric residual. The caller must
// use the continuous contact/phase linework in this case; avoid measuring
// every edge of all sampled fragments against the sweep because that exact
// spherical distance check dominates compact polar events.
if len(swept) == 1 && len(visibleUnion) > 1 {
return true
}
// A disconnected open sweep is the common polar-fold failure mode: its
// endpoint ribbons can bridge across a cap even though the horizon-closed
// source remains split into separate visible faces.
if len(swept) > 1 && len(visibleUnion) > len(swept) {
return true
}
if len(swept) > 1 {
for _, footprint := range footprints {
for _, boundary := range footprint.Boundaries {
for _, point := range boundary {
if math.Abs(point.Latitude) >= 70 {
return true
}
}
}
}
}
// Probe vertices and edge midpoints of the smooth sweep against the
// horizon-closed union. A miss larger than a small numerical tolerance means
// the ribbon has crossed into the below-horizon complement.
if !geodata.SphericalPolygonsContainPathsWithinKM(visibleUnion, swept, true, 20) {
return true
}
probes := make([][]geodata.GeoPoint, 0, 2048)
for _, footprint := range footprints {
for _, source := range footprint.Polygons {
if len(source) < 3 {
continue
}
ring := make([]geodata.GeoPoint, len(source))
for index, point := range source {
ring[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
for _, probe := range occultationVisibleFootprintProbes(source, ring) {
probes = append(probes, []geodata.GeoPoint{probe})
}
}
}
probes = limitOccultationCoveragePaths(probes, 2048)
if len(probes) > 0 && !geodata.SphericalPolygonsContainPathsWithinKM(swept, probes, false, 25) {
return true
}
// The opposite miss means the open ribbon dropped a legitimate visible
// footprint lobe. Keep the horizon-closed union in that case as well; a
// static band must contain every sampled instantaneous witness.
return !geodata.SphericalPolygonsContainPathsWithinKM(swept, visibleUnion, true, 25)
}
func footprintBoundariesAvailable(footprints []basic.OccultationFootprint) bool {
if len(footprints) == 0 {
return false
}
for _, footprint := range footprints {
if len(footprint.Boundaries) == 0 {
return false
}
}
return true
}
func footprintOpenSweepPolygons(footprints []basic.OccultationFootprint) ([][]geodata.GeoPoint, error) {
return footprintOpenSweepPolygonsWithTransitions(footprints, true)
}
func footprintOpenSweepPolygonsWithoutTransitions(
footprints []basic.OccultationFootprint,
) ([][]geodata.GeoPoint, error) {
return footprintOpenSweepPolygonsWithTransitions(footprints, false)
}
func footprintOpenSweepPolygonsWithTransitions(
footprints []basic.OccultationFootprint,
includeTransitions bool,
) ([][]geodata.GeoPoint, error) {
polygons := make([][]geodata.GeoPoint, 0, 2)
for start := 0; start < len(footprints); {
for start < len(footprints) && footprints[start].Closed {
start++
}
if start == len(footprints) {
break
}
end := start
for end < len(footprints) && !footprints[end].Closed {
end++
}
samples := make([]geodata.OpenBoundarySweepSample, 0, end-start+2)
if includeTransitions && start > 0 {
boundary, ok := footprintTransitionBoundary(footprints[start-1], footprints[start])
if ok {
samples = append(samples, geodata.OpenBoundarySweepSample{Boundaries: [][]geodata.GeoPoint{boundary}})
}
}
for index := start; index < end; index++ {
samples = append(samples, geodata.OpenBoundarySweepSample{
Boundaries: footprintGeoBoundaries(footprints[index]),
})
}
if includeTransitions && end < len(footprints) {
boundary, ok := footprintTransitionBoundary(footprints[end], footprints[end-1])
if ok {
samples = append(samples, geodata.OpenBoundarySweepSample{Boundaries: [][]geodata.GeoPoint{boundary}})
}
}
group, err := geodata.OpenBoundarySweep(samples)
if err != nil && includeTransitions {
// At an open/closed transition the exact transition arc can be
// numerically coincident with the first ribbon edge. Retry the same
// physical run without that synthetic endpoint; the sampled open arcs
// still provide both endpoint tracks and avoid a false diagonal cap.
bareSamples := make([]geodata.OpenBoundarySweepSample, 0, end-start)
for index := start; index < end; index++ {
bareSamples = append(bareSamples, geodata.OpenBoundarySweepSample{
Boundaries: footprintGeoBoundaries(footprints[index]),
})
}
group, err = geodata.OpenBoundarySweep(bareSamples)
}
if err != nil {
return nil, err
}
polygons = append(polygons, group...)
start = end
}
if len(polygons) == 0 {
return nil, fmt.Errorf("open footprint samples contain no usable sweep")
}
return polygons, nil
}
func footprintGeoBoundaries(footprint basic.OccultationFootprint) [][]geodata.GeoPoint {
boundaries := make([][]geodata.GeoPoint, len(footprint.Boundaries))
for boundaryIndex, source := range footprint.Boundaries {
boundary := make([]geodata.GeoPoint, len(source))
for pointIndex, point := range source {
boundary[pointIndex] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
boundaries[boundaryIndex] = boundary
}
return boundaries
}
func footprintTransitionBoundary(
closed, adjacent basic.OccultationFootprint,
) ([]geodata.GeoPoint, bool) {
closedRing := openFootprintRing(geodata.JoinPolylineSegments(footprintGeoBoundaries(closed)))
adjacentArc := openFootprintRing(geodata.JoinPolylineSegments(footprintGeoBoundaries(adjacent)))
if len(closedRing) < 3 || len(adjacentArc) < 2 {
return nil, false
}
start := nearestFootprintPointIndex(closedRing, adjacentArc[0])
end := nearestFootprintPointIndex(closedRing, adjacentArc[len(adjacentArc)-1])
if start == end {
forward := footprintOpenedRing(closedRing, start, 1)
backward := footprintOpenedRing(closedRing, start, -1)
if footprintArcMatchScore(backward, adjacentArc) < footprintArcMatchScore(forward, adjacentArc) {
return backward, true
}
return forward, true
}
forward := footprintRingArc(closedRing, start, end, 1)
backward := footprintRingArc(closedRing, start, end, -1)
if footprintArcMatchScore(backward, adjacentArc) < footprintArcMatchScore(forward, adjacentArc) {
return backward, true
}
return forward, true
}
func nearestFootprintPointIndex(points []geodata.GeoPoint, target geodata.GeoPoint) int {
nearest := 0
distance := math.Inf(1)
for index, point := range points {
candidate := geoDistanceKM(point, target)
if candidate < distance {
nearest, distance = index, candidate
}
}
return nearest
}
func footprintOpenedRing(points []geodata.GeoPoint, start, direction int) []geodata.GeoPoint {
result := make([]geodata.GeoPoint, len(points))
for index := range result {
position := (start + direction*index) % len(points)
if position < 0 {
position += len(points)
}
result[index] = points[position]
}
return result
}
func footprintRingArc(points []geodata.GeoPoint, start, end, direction int) []geodata.GeoPoint {
arc := make([]geodata.GeoPoint, 1, len(points)+1)
arc[0] = points[start]
index := start
for step := 1; step <= len(points); step++ {
index = (index + direction + len(points)) % len(points)
arc = append(arc, points[index])
if index == end {
return arc
}
}
return arc
}
func footprintArcMatchScore(candidate, reference []geodata.GeoPoint) float64 {
if len(candidate) < 2 || len(reference) < 2 {
return math.Inf(1)
}
const samples = 17
score := 0.0
for index := 0; index < samples; index++ {
candidateIndex := index * (len(candidate) - 1) / (samples - 1)
referenceIndex := index * (len(reference) - 1) / (samples - 1)
score += geoDistanceKM(candidate[candidateIndex], reference[referenceIndex])
}
return score
}
func openFootprintRing(points []geodata.GeoPoint) []geodata.GeoPoint {
if len(points) > 1 && geodata.SameGeoPoint(points[0], points[len(points)-1]) {
return points[:len(points)-1]
}
return points
}
func footprintClosedPolygons(footprints []basic.OccultationFootprint) [][]geodata.GeoPoint {
var polygons [][]geodata.GeoPoint
for _, footprint := range footprints {
if !footprint.Closed || len(footprint.Boundaries) == 0 {
continue
}
polygons = append(polygons, footprintPolygons([]basic.OccultationFootprint{footprint})...)
}
return polygons
}
func footprintStaticInteriorPolygons(footprints []basic.OccultationFootprint) [][]geodata.GeoPoint {
var polygons [][]geodata.GeoPoint
for _, footprint := range footprints {
if footprint.Closed {
continue
}
for _, source := range footprint.InteriorPolygons {
if len(source) < 3 || !occultationStaticInteriorPolygon(source, footprint.InteriorPolygons) {
continue
}
center := geodata.GeoPoint{Longitude: source[0].Longitude, Latitude: source[0].Latitude}
polygon, ok := footprintStaticBoundaryBridge(center, footprint.Boundaries)
if !ok {
polygon = make([]geodata.GeoPoint, len(source))
for index, point := range source {
polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
}
// A footprint can briefly collapse to a tangent point at a
// visibility transition. Its bridge then repeats the same vertex and
// is not a polygon; passing it to UnionPolygons aborts the entire band.
if len(polygon) >= 3 && math.Abs(geoRingArea(polygon)) > 1e-12 {
polygons = append(polygons, polygon)
}
centerCap := geodata.SphericalCircle(
center,
staticCenterCapRadiusKM/EarthRadiusKM*180/math.Pi,
staticCenterCapPoints,
)
if len(centerCap) >= 3 {
centerCap = append(centerCap, centerCap[0])
polygons = append(polygons, centerCap)
}
}
}
return polygons
}
func footprintStaticBoundaryBridge(
center geodata.GeoPoint,
boundaries [][]basic.OccultationPathPoint,
) ([]geodata.GeoPoint, bool) {
nearestDistance := math.Inf(1)
var nearestStart, nearestEnd geodata.GeoPoint
for _, boundary := range boundaries {
for index := 1; index < len(boundary); index++ {
start := geodata.GeoPoint{
Longitude: boundary[index-1].Longitude,
Latitude: boundary[index-1].Latitude,
}
end := geodata.GeoPoint{
Longitude: boundary[index].Longitude,
Latitude: boundary[index].Latitude,
}
distance := geoPointSegmentDistanceKM(center, start, end)
if distance < nearestDistance {
nearestDistance = distance
nearestStart = start
nearestEnd = end
}
}
}
if !finiteGeo(nearestDistance) {
return nil, false
}
return []geodata.GeoPoint{center, nearestStart, nearestEnd, center}, true
}
func footprintPolygons(footprints []basic.OccultationFootprint) [][]geodata.GeoPoint {
var polygons [][]geodata.GeoPoint
for _, footprint := range footprints {
for _, source := range footprint.Polygons {
if occultationInteriorPolygon(source, footprint.InteriorPolygons) {
continue
}
polygon := make([]geodata.GeoPoint, len(source))
for index, point := range source {
polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
polygons = append(polygons, polygon)
}
}
return polygons
}
func geoPointSegmentDistanceKM(point, start, end geodata.GeoPoint) float64 {
latitude := point.Latitude * math.Pi / 180
scaleX := math.Cos(latitude) * EarthRadiusKM * math.Pi / 180
scaleY := EarthRadiusKM * math.Pi / 180
x := func(value geodata.GeoPoint) float64 {
return math.Remainder(value.Longitude-point.Longitude, 360) * scaleX
}
y := func(value geodata.GeoPoint) 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 geoRingArea(polygon []geodata.GeoPoint) float64 {
if len(polygon) < 3 {
return 0
}
longitudes := make([]float64, len(polygon))
longitudes[0] = polygon[0].Longitude
for index := 1; index < len(polygon); index++ {
longitudes[index] = longitudes[index-1] + math.Remainder(
polygon[index].Longitude-longitudes[index-1], 360,
)
}
area := 0.0
for index, point := range polygon {
next := polygon[(index+1)%len(polygon)]
area += longitudes[index]*next.Latitude - longitudes[(index+1)%len(polygon)]*point.Latitude
}
return area / 2
}
func finiteGeo(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
func continuousRanges(count int, changed func(int) bool) []SampleRange {
if count == 0 {
return nil
}
ranges := make([]SampleRange, 0, 2)
start := 0
for index := 1; index < count; index++ {
if !changed(index) {
continue
}
ranges = append(ranges, SampleRange{Start: start, End: index})
start = index
}
return append(ranges, SampleRange{Start: start, End: count})
}
// BoundaryBranchChanged 判断两个相邻样本是否距离过大,无法属于同一物理支路。
// BoundaryBranchChanged reports whether two adjacent samples are too far apart to be one physical branch.
func BoundaryBranchChanged(first, second basic.OccultationPathPoint) bool {
distance := DistanceKM(first, second)
if distance <= BoundaryBranchJumpKM {
return false
}
duration := math.Abs(second.Time.Sub(first.Time).Seconds())
return duration == 0 || distance/duration > BoundaryBranchSpeedKMPerSecond
}
// DistanceKM 返回两个边界样本之间的最短球面距离。
// DistanceKM returns the shortest spherical surface distance between two boundary samples.
func DistanceKM(first, second basic.OccultationPathPoint) float64 {
return geoDistanceKM(
geodata.GeoPoint{Longitude: first.Longitude, Latitude: first.Latitude},
geodata.GeoPoint{Longitude: second.Longitude, Latitude: second.Latitude},
)
}
func geoDistanceKM(first, second geodata.GeoPoint) float64 {
firstLatitude := first.Latitude * math.Pi / 180
secondLatitude := second.Latitude * math.Pi / 180
deltaLatitude := secondLatitude - firstLatitude
deltaLongitude := math.Remainder((second.Longitude-first.Longitude)*math.Pi/180, 2*math.Pi)
haversine := math.Sin(deltaLatitude/2)*math.Sin(deltaLatitude/2) +
math.Cos(firstLatitude)*math.Cos(secondLatitude)*math.Sin(deltaLongitude/2)*math.Sin(deltaLongitude/2)
return 2 * EarthRadiusKM * math.Asin(math.Sqrt(math.Min(1, haversine)))
}