feat: 引入可切换星历模型并扩展天象观测链路

- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算
- 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询
- 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出
- 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解
- 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤
- 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持
- 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理
- 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
This commit is contained in:
2026-10-15 23:54:15 +08:00
parent 16c62a97d5
commit 044f2f4381
674 changed files with 62983 additions and 8396 deletions
+13 -18
View File
@@ -86,7 +86,7 @@ func smoothOccultationOrdinaryWobbles(points []geodata.GeoPoint) []geodata.GeoPo
y += weights[offset+2] * projected[1]
}
candidate := occultationUnprojectedPoint(x/35, y/35)
move := geoDistanceKM(original[index], candidate)
move := geodata.EquatorialDistanceKM(original[index], candidate)
if move > maximumMoveKM {
continue
}
@@ -109,20 +109,15 @@ func smoothOccultationOrdinaryWobbles(points []geodata.GeoPoint) []geodata.GeoPo
func occultationProjectedPoint(point geodata.GeoPoint) [2]float64 {
latitude := math.Max(-85.05112878, math.Min(85.05112878, point.Latitude)) * math.Pi / 180
return [2]float64{
EarthRadiusKM * point.Longitude * math.Pi / 180,
EarthRadiusKM * math.Log(math.Tan(math.Pi/4+latitude/2)),
geodata.EarthEquatorialRadiusKM * point.Longitude * math.Pi / 180,
geodata.EarthEquatorialRadiusKM * math.Log(math.Tan(math.Pi/4+latitude/2)),
}
}
func occultationUnprojectedPoint(pointX, pointY float64) geodata.GeoPoint {
longitude := pointX / EarthRadiusKM * 180 / math.Pi
longitude = math.Mod(longitude+180, 360)
if longitude < 0 {
longitude += 360
}
return geodata.GeoPoint{
Longitude: longitude - 180,
Latitude: (2*math.Atan(math.Exp(pointY/EarthRadiusKM)) - math.Pi/2) * 180 / math.Pi,
Longitude: geodata.NormalizeLongitude(pointX / geodata.EarthEquatorialRadiusKM * 180 / math.Pi),
Latitude: (2*math.Atan(math.Exp(pointY/geodata.EarthEquatorialRadiusKM)) - math.Pi/2) * 180 / math.Pi,
}
}
@@ -533,7 +528,7 @@ func roundOccultationBandJunctionRing(
continue
}
previous, next := result[index-1], result[index+1]
if occultationProjectedEdgeDistanceKM(previous, next) > maximumChordKM {
if geodata.WebMercatorDistanceKM(previous, next) > maximumChordKM {
continue
}
turn := occultationTurnAngleDegrees(previous, point, next)
@@ -716,9 +711,9 @@ func removeOccultationPolarKinks(points []geodata.GeoPoint) []geodata.GeoPoint {
for index := 1; index+1 < len(result); index++ {
first, middle, last := result[index-1], result[index], result[index+1]
if math.Abs(middle.Latitude) < minimumPolarLatitudeDegrees ||
occultationProjectedEdgeDistanceKM(first, middle) > maximumAdjacentEdgeKM ||
occultationProjectedEdgeDistanceKM(middle, last) > maximumAdjacentEdgeKM ||
occultationProjectedEdgeDistanceKM(first, last) > maximumEndpointChordKM {
geodata.WebMercatorDistanceKM(first, middle) > maximumAdjacentEdgeKM ||
geodata.WebMercatorDistanceKM(middle, last) > maximumAdjacentEdgeKM ||
geodata.WebMercatorDistanceKM(first, last) > maximumEndpointChordKM {
continue
}
// A reversal in either projected axis is required. A merely curved
@@ -896,20 +891,20 @@ func occultationCurveCoverageProbes(
next = len(segment) - 1
}
first, second := segment[index], segment[next]
deltaLongitude := normalizeGeoLongitude(second.Longitude - first.Longitude)
deltaLongitude := geodata.NormalizeLongitude(second.Longitude - first.Longitude)
deltaLatitude := second.Latitude - first.Latitude
length := math.Hypot(deltaLongitude, deltaLatitude)
if length <= 1e-12 {
continue
}
midpoint := geodata.GeoPoint{
Longitude: normalizeGeoLongitude(first.Longitude + deltaLongitude/2),
Longitude: geodata.NormalizeLongitude(first.Longitude + deltaLongitude/2),
Latitude: first.Latitude + deltaLatitude/2,
}
for _, offset := range []float64{0.00005, 0.0005, 0.002} {
for _, side := range []float64{-1, 1} {
candidates = append(candidates, geodata.GeoPoint{
Longitude: normalizeGeoLongitude(midpoint.Longitude - side*deltaLatitude*offset/length),
Longitude: geodata.NormalizeLongitude(midpoint.Longitude - side*deltaLatitude*offset/length),
Latitude: midpoint.Latitude + side*deltaLongitude*offset/length,
})
}
@@ -925,7 +920,7 @@ func occultationCurveCoverageProbes(
}
duplicate := false
for _, existing := range probes {
if len(existing) > 0 && geoDistanceKM(existing[0], probe) < 1 {
if len(existing) > 0 && geodata.EquatorialDistanceKM(existing[0], probe) < 1 {
duplicate = true
break
}
+20 -46
View File
@@ -216,7 +216,7 @@ func occultationPhaseBoundaryCyclesForEdges(
validEdges := make([]bool, len(edges))
nodeFor := func(point geodata.GeoPoint) int {
for index := range nodes {
if geoDistanceKM(nodes[index].point, point) <= curveBoundaryJoinDistanceKM {
if geodata.EquatorialDistanceKM(nodes[index].point, point) <= curveBoundaryJoinDistanceKM {
return index
}
}
@@ -294,7 +294,7 @@ func occultationPhaseBoundaryCyclesForEdges(
reverseGeoPointRing(next)
}
nextRing := append([]geodata.GeoPoint(nil), ring...)
if len(nextRing) > 0 && geoDistanceKM(nextRing[len(nextRing)-1], next[0]) <= curveBoundaryJoinDistanceKM {
if len(nextRing) > 0 && geodata.EquatorialDistanceKM(nextRing[len(nextRing)-1], next[0]) <= curveBoundaryJoinDistanceKM {
next[0] = nextRing[len(nextRing)-1]
}
nextRing = append(nextRing, next[1:]...)
@@ -500,7 +500,7 @@ func curveBoundaryEndpointsMatch(
}
func curveBoundaryEndpointMatch(first geodata.GeoPoint, firstTime time.Time, second geodata.GeoPoint, secondTime time.Time) bool {
return geoDistanceKM(first, second) <= curveBoundaryJoinDistanceKM &&
return geodata.EquatorialDistanceKM(first, second) <= curveBoundaryJoinDistanceKM &&
!firstTime.IsZero() && !secondTime.IsZero() && absDuration(firstTime.Sub(secondTime)) <= curveBoundaryJoinTime
}
@@ -596,7 +596,7 @@ func densifyOccultationPolygons(
continue
}
next := polygon[index+1]
distance := occultationProjectedEdgeDistanceKM(point, next)
distance := geodata.WebMercatorDistanceKM(point, next)
steps := int(math.Ceil(distance / maximumEdgeKM))
if steps < 2 {
continue
@@ -610,7 +610,7 @@ func densifyOccultationPolygons(
// fallback can still render one long closing chord.
if len(polygon) > 2 && !geodata.SameGeoPoint(polygon[0], polygon[len(polygon)-1]) {
first, last := polygon[0], polygon[len(polygon)-1]
distance := occultationProjectedEdgeDistanceKM(last, first)
distance := geodata.WebMercatorDistanceKM(last, first)
steps := int(math.Ceil(distance / maximumEdgeKM))
if steps >= 2 {
for step := 1; step < steps; step++ {
@@ -623,24 +623,6 @@ func densifyOccultationPolygons(
return result
}
// occultationProjectedEdgeDistanceKM measures an edge in the Web Mercator
// chart used by the GeoJSON/OpenLayers consumer, while retaining the shortest
// wrapped longitude. At high latitude a small spherical edge expands strongly
// in this chart; using only the great-circle distance leaves visible polygon
// chords even though the source contact contour is densely sampled.
func occultationProjectedEdgeDistanceKM(first, second geodata.GeoPoint) float64 {
const maxLatitude = 85.05112878
clampLatitude := func(value float64) float64 {
return math.Max(-maxLatitude, math.Min(maxLatitude, value))
}
longitude := math.Remainder(second.Longitude-first.Longitude, 360) * math.Pi / 180
firstLatitude := clampLatitude(first.Latitude) * math.Pi / 180
secondLatitude := clampLatitude(second.Latitude) * math.Pi / 180
firstY := math.Log(math.Tan(math.Pi/4 + firstLatitude/2))
secondY := math.Log(math.Tan(math.Pi/4 + secondLatitude/2))
return EarthRadiusKM * math.Hypot(longitude, secondY-firstY)
}
func interpolateOccultationGeoPoint(
first, second geodata.GeoPoint,
fraction float64,
@@ -747,7 +729,7 @@ func interpolateOccultationHorizonPoint(
}
point := first
point.Time = first.Time.Add(time.Duration(float64(second.Time.Sub(first.Time)) * fraction))
point.Longitude = normalizeGeoLongitude(first.Longitude + fraction*deltaLongitude)
point.Longitude = geodata.NormalizeLongitude(first.Longitude + fraction*deltaLongitude)
point.Latitude = first.Latitude + fraction*(second.Latitude-first.Latitude)
point.MoonAltitude = 0
point.WidthKM = first.WidthKM + fraction*(second.WidthKM-first.WidthKM)
@@ -837,13 +819,13 @@ func occultationVisibleFootprintProbes(
continue
}
midpoint := geodata.GeoPoint{
Longitude: normalizeGeoLongitude(first.Longitude + deltaLongitude/2),
Longitude: geodata.NormalizeLongitude(first.Longitude + deltaLongitude/2),
Latitude: first.Latitude + deltaLatitude/2,
}
for _, offset := range []float64{0.0002, 0.002, 0.01, 0.03} {
for _, side := range []float64{-1, 1} {
edgeCandidates = append(edgeCandidates, geodata.GeoPoint{
Longitude: normalizeGeoLongitude(midpoint.Longitude - side*deltaLatitude*offset/length),
Longitude: geodata.NormalizeLongitude(midpoint.Longitude - side*deltaLatitude*offset/length),
Latitude: midpoint.Latitude + side*deltaLongitude*offset/length,
})
}
@@ -922,15 +904,7 @@ func limitOccultationCoveragePaths(
func sameOccultationPoint(first, second basic.OccultationPathPoint) bool {
return math.Abs(first.Latitude-second.Latitude) <= 1e-10 &&
math.Abs(normalizeGeoLongitude(first.Longitude-second.Longitude)) <= 1e-10
}
func normalizeGeoLongitude(value float64) float64 {
value = math.Mod(value+180, 360)
if value < 0 {
value += 360
}
return value - 180
math.Abs(geodata.NormalizeLongitude(first.Longitude-second.Longitude)) <= 1e-10
}
func occultationPolarFootprintProbes(
@@ -1007,11 +981,11 @@ func removeOccultationHairpins(points []geodata.GeoPoint, maximumClosureKM, mini
arcLength := 0.0
best := -1
for end := start + 1; end <= limit; end++ {
arcLength += geoDistanceKM(result[end-1], result[end])
arcLength += geodata.EquatorialDistanceKM(result[end-1], result[end])
if end < start+3 {
continue
}
closure := geoDistanceKM(result[start], result[end])
closure := geodata.EquatorialDistanceKM(result[start], result[end])
if closure <= maximumClosureKM && arcLength-closure >= minimumDetourKM {
best = end
}
@@ -1053,11 +1027,11 @@ func smoothOccultationHairpins(
best := -1
bestDetour := minimumDetourKM
for end := start + 1; end <= limit; end++ {
arcLength += geoDistanceKM(result[end-1], result[end])
arcLength += geodata.EquatorialDistanceKM(result[end-1], result[end])
if end < start+3 {
continue
}
closure := geoDistanceKM(result[start], result[end])
closure := geodata.EquatorialDistanceKM(result[start], result[end])
detour := arcLength - closure
if closure <= maximumClosureKM && detour >= bestDetour {
best = end
@@ -1068,7 +1042,7 @@ func smoothOccultationHairpins(
continue
}
first, last := result[start], result[best]
steps := int(math.Ceil(geoDistanceKM(first, last) / maximumEdgeKM))
steps := int(math.Ceil(geodata.EquatorialDistanceKM(first, last) / maximumEdgeKM))
if steps < 1 {
steps = 1
}
@@ -1108,7 +1082,7 @@ func removeOccultationSharpCorners(
changed := false
for index := 1; index+1 < len(result); index++ {
first, middle, last := result[index-1], result[index], result[index+1]
if geoDistanceKM(first, last) > maximumChordKM {
if geodata.EquatorialDistanceKM(first, last) > maximumChordKM {
continue
}
if occultationTurnAngleDegrees(first, middle, last) >= minimumAngleDegrees {
@@ -1142,7 +1116,7 @@ func removeOccultationPolarSharpCorners(
for index := 1; index+1 < len(result); index++ {
first, middle, last := result[index-1], result[index], result[index+1]
if math.Abs(middle.Latitude) < minimumLatitudeDegrees ||
geoDistanceKM(first, last) > maximumChordKM ||
geodata.EquatorialDistanceKM(first, last) > maximumChordKM ||
occultationTurnAngleDegrees(first, middle, last) >= minimumAngleDegrees {
continue
}
@@ -1170,7 +1144,7 @@ func smoothOccultationPolarCorners(points []geodata.GeoPoint) []geodata.GeoPoint
for index := 1; index+1 < len(result); index++ {
first, middle, last := result[index-1], result[index], result[index+1]
if math.Abs(middle.Latitude) < 70 ||
occultationProjectedEdgeDistanceKM(first, last) > 140 ||
geodata.WebMercatorDistanceKM(first, last) > 140 ||
occultationTurnAngleDegrees(first, middle, last) >= 165 {
continue
}
@@ -1180,7 +1154,7 @@ func smoothOccultationPolarCorners(points []geodata.GeoPoint) []geodata.GeoPoint
if !longitudeReversal && !latitudeReversal {
continue
}
steps := int(math.Ceil(occultationProjectedEdgeDistanceKM(first, last) / 40))
steps := int(math.Ceil(geodata.WebMercatorDistanceKM(first, last) / 40))
if steps < 2 {
steps = 2
}
@@ -1233,7 +1207,7 @@ func smoothOccultationPolarWobbles(points []geodata.GeoPoint) []geodata.GeoPoint
first, last := result[start], result[end]
if math.Abs(first.Latitude) < minimumPolarLatitudeDegrees ||
math.Abs(last.Latitude) < minimumPolarLatitudeDegrees ||
occultationProjectedEdgeDistanceKM(first, last) > maximumEndpointSpanKM {
geodata.WebMercatorDistanceKM(first, last) > maximumEndpointSpanKM {
continue
}
longitudeDirection := 0.0
@@ -1271,7 +1245,7 @@ func smoothOccultationPolarWobbles(points []geodata.GeoPoint) []geodata.GeoPoint
for index := start + 1; index < end; index++ {
fraction := float64(index-start) / float64(windowPoints)
baseline := interpolateOccultationGeoPoint(first, last, fraction)
if geoDistanceKM(result[index], baseline) > maximumDeviationKM {
if geodata.EquatorialDistanceKM(result[index], baseline) > maximumDeviationKM {
valid = false
break
}
+27 -11
View File
@@ -11,7 +11,6 @@ import (
// 掩星边界连续性判定的几何门限 / geometric thresholds for occultation-boundary continuity checks.
const (
EarthRadiusKM = 6378.1366
BoundaryBranchJumpKM = 750.0
// BoundaryBranchSpeedKMPerSecond 是判定"物理上不可能的支路跳变"的地面速度门限:
// 掩星边界随月球影子移动,地面速度上限约 1.1 km/s,取 2 km/s 留约两倍余量。
@@ -287,7 +286,7 @@ func nearestPolygonVertex(
distance := math.Inf(1)
for _, polygon := range polygons {
for _, candidate := range polygon {
value := geoDistanceKM(point, candidate)
value := geodata.EquatorialDistanceKM(point, candidate)
if value < distance {
nearest, distance = candidate, value
}
@@ -327,7 +326,7 @@ func nearestPolygonBoundaryPoint(
))
}
candidate := interpolateOccultationGeoPoint(start, end, fraction)
value := geoDistanceKM(point, candidate)
value := geodata.EquatorialDistanceKM(point, candidate)
if value < distance {
nearest, distance = candidate, value
}
@@ -742,12 +741,21 @@ func visibleBandPolygonsFromAnalyticContours(
}
return footprintFill
}
var selectionFill [][]geodata.GeoPoint
var selectionFill, footprintSelection [][]geodata.GeoPoint
if pointSource || len(visibilityContours) == 0 {
// Geocentric limit strips can extend beyond the station-corrected
// stellar envelope or omit a finite-disk horizon extremum. Use visible
// footprints as witnesses; the analytic linework supplies every edge.
selectionFill = getFootprintFill()
if pointSource && len(visibilityContours) == 0 {
// 密集恒星时间轴含大量重叠环;连续界限用于选面,逐环仍用于覆盖见证;界限条带选面失败时退回逐环。
footprintSelection = selectionFill
if limit := occultationLimitVisibleFillPolygons(northern, southern); len(limit) > 0 {
selectionFill = limit
} else if union := footprintVisibleUnionPolygons(footprints); len(union) > 0 {
selectionFill = union
}
}
}
if len(selectionFill) == 0 {
selectionFill = occultationLimitVisibleFillPolygons(northern, southern)
@@ -779,13 +787,21 @@ func visibleBandPolygonsFromAnalyticContours(
if pointSource && len(visibilityContours) == 0 {
initialCoverage = phaseLines
}
polygons, err := polygonize(initialCoverage)
if err != nil && len(phaseLines) > 0 {
// A multi-branch rise/set network can contain several closed faces with
// identical physical junctions. If fill-only selection chooses the
// adjacent face, require every exported phase line as a coverage witness
// and retry without changing the boundary network or snap tolerance.
polygons, err = polygonize(phaseLines)
selectFaces := func() ([][]geodata.GeoPoint, error) {
polygons, err := polygonize(initialCoverage)
if err != nil && len(phaseLines) > 0 {
// A multi-branch rise/set network can contain several closed faces with
// identical physical junctions. If fill-only selection chooses the
// adjacent face, require every exported phase line as a coverage witness
// and retry without changing the boundary network or snap tolerance.
polygons, err = polygonize(phaseLines)
}
return polygons, err
}
polygons, err := selectFaces()
if err != nil && len(footprintSelection) > 0 {
selectionFill = footprintSelection
polygons, err = selectFaces()
}
if err != nil {
if fallback, ok := fallbackVisible(); ok {
+15 -15
View File
@@ -238,13 +238,13 @@ func TestSmoothOccultationHairpinsKeepsShortDisplayEdges(t *testing.T) {
maximumEdge := 0.0
maximumDetour := 0.0
for index := 1; index < len(smoothed); index++ {
maximumEdge = math.Max(maximumEdge, geoDistanceKM(smoothed[index-1], smoothed[index]))
maximumEdge = math.Max(maximumEdge, geodata.EquatorialDistanceKM(smoothed[index-1], smoothed[index]))
}
for index := 1; index+1 < len(smoothed); index++ {
maximumDetour = math.Max(maximumDetour,
geoDistanceKM(smoothed[index-1], smoothed[index])+
geoDistanceKM(smoothed[index], smoothed[index+1])-
geoDistanceKM(smoothed[index-1], smoothed[index+1]))
geodata.EquatorialDistanceKM(smoothed[index-1], smoothed[index])+
geodata.EquatorialDistanceKM(smoothed[index], smoothed[index+1])-
geodata.EquatorialDistanceKM(smoothed[index-1], smoothed[index+1]))
}
if maximumEdge > 50.01 {
t.Fatalf("smoothed maximum edge=%.1f km, want <=50 km", maximumEdge)
@@ -300,10 +300,10 @@ func TestSmoothOccultationOrdinaryWobblesHandlesHighLatitudeArc(t *testing.T) {
}
changed := false
for index := 1; index+1 < len(points); index++ {
if geoDistanceKM(points[index], smoothed[index]) > 0.001 {
if geodata.EquatorialDistanceKM(points[index], smoothed[index]) > 0.001 {
changed = true
if geoDistanceKM(points[index], smoothed[index]) > 20 {
t.Fatalf("high-latitude smoothing moved point %d by %.2f km", index, geoDistanceKM(points[index], smoothed[index]))
if geodata.EquatorialDistanceKM(points[index], smoothed[index]) > 20 {
t.Fatalf("high-latitude smoothing moved point %d by %.2f km", index, geodata.EquatorialDistanceKM(points[index], smoothed[index]))
}
}
}
@@ -321,7 +321,7 @@ func TestDensifyOccultationPolygonsIncludesClosingEdge(t *testing.T) {
}
densified := densifyOccultationPolygons([][]geodata.GeoPoint{points}, 50)
ring := densified[0]
maximumEdge := geoDistanceKM(ring[len(ring)-1], ring[0])
maximumEdge := geodata.EquatorialDistanceKM(ring[len(ring)-1], ring[0])
if maximumEdge > 50.01 {
t.Fatalf("closing edge=%.1f km, want <=50 km", maximumEdge)
}
@@ -449,7 +449,7 @@ func TestVisibleBandPolygons20250105SaturnEnvelopeCandidates(t *testing.T) {
}
for _, polygon := range final {
for index := 1; index < len(polygon); index++ {
if distance := geoDistanceKM(polygon[index-1], polygon[index]); distance > BoundaryBranchJumpKM {
if distance := geodata.EquatorialDistanceKM(polygon[index-1], polygon[index]); distance > BoundaryBranchJumpKM {
t.Fatalf("band ring spans a %.1f km edge", distance)
}
}
@@ -519,7 +519,7 @@ func TestSmoothOccultationOrdinaryWobblesPreservesEndpointsAndBoundedMotion(t *t
t.Fatalf("ordinary smoothing changed open endpoints: %#v", cleaned)
}
for index, point := range cleaned {
if move := geoDistanceKM(point, ring[index]); move > 20.001 {
if move := geodata.EquatorialDistanceKM(point, ring[index]); move > 20.001 {
t.Fatalf("point %d moved %.2f km, want <=20 km", index, move)
}
}
@@ -585,7 +585,7 @@ func occultationTestWindowTouchesHorizon(
}
for offset := -2; offset <= 2; offset++ {
for _, horizon := range horizons {
if geoDistanceKM(polygon[index+offset], horizon) <= windowKM {
if geodata.EquatorialDistanceKM(polygon[index+offset], horizon) <= windowKM {
return true
}
}
@@ -716,7 +716,7 @@ func TestVisibleBandPolygons20250729MarsStitchesEndRiseFold(t *testing.T) {
minimumTurn := 180.0
for index := 1; index+1 < len(polygon); index++ {
first, middle, last := polygon[index-1], polygon[index], polygon[index+1]
detour := geoDistanceKM(first, middle) + geoDistanceKM(middle, last) - geoDistanceKM(first, last)
detour := geodata.EquatorialDistanceKM(first, middle) + geodata.EquatorialDistanceKM(middle, last) - geodata.EquatorialDistanceKM(first, last)
if detour > maximumDetour {
maximumDetour = detour
}
@@ -1027,7 +1027,7 @@ func TestOccultationPhaseBoundaryCycleExcludesGreatestInteriorLine(t *testing.T)
minimum := math.Inf(1)
for _, line := range phaseLines {
for _, candidate := range line {
minimum = math.Min(minimum, geoDistanceKM(point, candidate))
minimum = math.Min(minimum, geodata.EquatorialDistanceKM(point, candidate))
}
}
if minimum > 1 {
@@ -1135,7 +1135,7 @@ func TestVisibleBandPolygons20250729MarsTotalRiseSetRemovesPolarBacktrack(t *tes
limit = len(polygon) - 1
}
for endIndex := startIndex + 1; endIndex <= limit; endIndex++ {
edge := geoDistanceKM(polygon[endIndex-1], polygon[endIndex])
edge := geodata.EquatorialDistanceKM(polygon[endIndex-1], polygon[endIndex])
arcLength += edge
if edge > maximumEdge {
maximumEdge = edge
@@ -1143,7 +1143,7 @@ func TestVisibleBandPolygons20250729MarsTotalRiseSetRemovesPolarBacktrack(t *tes
if endIndex < startIndex+3 {
continue
}
closure := geoDistanceKM(polygon[startIndex], polygon[endIndex])
closure := geodata.EquatorialDistanceKM(polygon[startIndex], polygon[endIndex])
if closure <= 180 && arcLength-closure >= 35 {
t.Fatalf("total polygon %d retains a large polar backtrack: start=%d end=%d closure=%.1f km detour=%.1f km",
polygonIndex, startIndex, endIndex, closure, arcLength-closure)
+4 -11
View File
@@ -91,15 +91,8 @@ func densifyRiseSetSegment(
}
func projectedGeoPointDistanceKM(first, second basic.OccultationPathPoint) float64 {
const maxLatitude = 85.05112878
const radiusKM = 6378.1366
clampLatitude := func(value float64) float64 {
return math.Max(-maxLatitude, math.Min(maxLatitude, value))
}
longitude := math.Remainder(second.Longitude-first.Longitude, 360) * math.Pi / 180
firstLatitude := clampLatitude(first.Latitude) * math.Pi / 180
secondLatitude := clampLatitude(second.Latitude) * math.Pi / 180
firstY := math.Log(math.Tan(math.Pi/4 + firstLatitude/2))
secondY := math.Log(math.Tan(math.Pi/4 + secondLatitude/2))
return radiusKM * math.Hypot(longitude, secondY-firstY)
return geodata.WebMercatorDistanceKM(
geodata.GeoPoint{Longitude: first.Longitude, Latitude: first.Latitude},
geodata.GeoPoint{Longitude: second.Longitude, Latitude: second.Latitude},
)
}
+1 -1
View File
@@ -142,7 +142,7 @@ func TestSamePhaseHorizonConnectorAllowedMatchesReferenceRule(t *testing.T) {
}
last := horizonConnectorEndpoint{
point: basic.OccultationPathPoint{
Longitude: distanceKM / (EarthRadiusKM * math.Pi / 180),
Longitude: distanceKM / (geodata.EarthEquatorialRadiusKM * math.Pi / 180),
},
curveIndex: 3, segmentIndex: 1, atStart: true,
}
+5 -5
View File
@@ -39,8 +39,8 @@ func DirectVisibleBandPolygons(
}
if len(samples) > 0 {
previous := samples[len(samples)-1]
direct := geoDistanceKM(previous.a, boundary[0]) + geoDistanceKM(previous.b, boundary[len(boundary)-1])
swapped := geoDistanceKM(previous.a, boundary[len(boundary)-1]) + geoDistanceKM(previous.b, boundary[0])
direct := geodata.EquatorialDistanceKM(previous.a, boundary[0]) + geodata.EquatorialDistanceKM(previous.b, boundary[len(boundary)-1])
swapped := geodata.EquatorialDistanceKM(previous.a, boundary[len(boundary)-1]) + geodata.EquatorialDistanceKM(previous.b, boundary[0])
if swapped < direct {
reverseGeoPoints(boundary)
}
@@ -140,7 +140,7 @@ func removeDirectProjectedSharpCorners(
changed := false
for index := 1; index+1 < len(result); index++ {
first, middle, last := result[index-1], result[index], result[index+1]
if geoDistanceKM(first, last) > maximumChordKM {
if geodata.EquatorialDistanceKM(first, last) > maximumChordKM {
continue
}
scale := math.Cos(middle.Latitude * math.Pi / 180)
@@ -281,7 +281,7 @@ func reverseGeoPoints(points []geodata.GeoPoint) {
}
func appendDirectRingPoint(ring *[]geodata.GeoPoint, point geodata.GeoPoint) {
if len(*ring) > 0 && geoDistanceKM((*ring)[len(*ring)-1], point) <= 1e-6 {
if len(*ring) > 0 && geodata.EquatorialDistanceKM((*ring)[len(*ring)-1], point) <= 1e-6 {
return
}
*ring = append(*ring, point)
@@ -299,7 +299,7 @@ func densifyDirectRing(ring []geodata.GeoPoint, maximumEdgeKM float64) []geodata
}
next := ring[index+1]
steps := int(math.Ceil(math.Max(
geoDistanceKM(point, next), occultationProjectedEdgeDistanceKM(point, next),
geodata.EquatorialDistanceKM(point, next), geodata.WebMercatorDistanceKM(point, next),
) / maximumEdgeKM))
if steps < 2 {
continue
+11 -21
View File
@@ -128,7 +128,7 @@ func closedFootprintRingAlignmentScore(
score := 0.0
for sample := 0; sample < samples; sample++ {
index := sample * len(previous) / samples
score += geoDistanceKM(previous[index], current[(index+shift)%len(current)])
score += geodata.EquatorialDistanceKM(previous[index], current[(index+shift)%len(current)])
}
return score
}
@@ -153,7 +153,7 @@ func footprintClosedSweepMaximumStep(
}
maximum := 0.0
for index := 0; index < count; index++ {
maximum = math.Max(maximum, geoDistanceKM(first[index], second[index]))
maximum = math.Max(maximum, geodata.EquatorialDistanceKM(first[index], second[index]))
}
return maximum
}
@@ -337,7 +337,7 @@ func occultationUnitBoundsNear(first, second occultationUnitBounds, limitKM floa
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
return geodata.EarthEquatorialRadiusKM*math.Sqrt(dx*dx+dy*dy+dz*dz) <= limitKM
}
func mergeTouchingVisiblePolygons(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint {
@@ -367,7 +367,7 @@ func mergeTouchingVisiblePolygons(polygons [][]geodata.GeoPoint) [][]geodata.Geo
}
for _, left := range polygons[first] {
for _, right := range polygons[second] {
if geoDistanceKM(left, right) > touchingDistanceKM {
if geodata.EquatorialDistanceKM(left, right) > touchingDistanceKM {
continue
}
firstIndex, secondIndex = first, second
@@ -388,7 +388,7 @@ func mergeTouchingVisiblePolygons(polygons [][]geodata.GeoPoint) [][]geodata.Geo
break
}
bridgeHalfDeg := minimumBridgeHalfDeg
if gap := geoDistanceKM(leftTouch, rightTouch) / EarthRadiusKM * 180 / math.Pi; gap/2+0.002 > bridgeHalfDeg {
if gap := geodata.EquatorialDistanceKM(leftTouch, rightTouch) / geodata.EarthEquatorialRadiusKM * 180 / math.Pi; gap/2+0.002 > bridgeHalfDeg {
bridgeHalfDeg = gap/2 + 0.002
}
bridge := []geodata.GeoPoint{
@@ -629,7 +629,7 @@ func nearestFootprintPointIndex(points []geodata.GeoPoint, target geodata.GeoPoi
nearest := 0
distance := math.Inf(1)
for index, point := range points {
candidate := geoDistanceKM(point, target)
candidate := geodata.EquatorialDistanceKM(point, target)
if candidate < distance {
nearest, distance = index, candidate
}
@@ -672,7 +672,7 @@ func footprintArcMatchScore(candidate, reference []geodata.GeoPoint) float64 {
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])
score += geodata.EquatorialDistanceKM(candidate[candidateIndex], reference[referenceIndex])
}
return score
}
@@ -721,7 +721,7 @@ func footprintStaticInteriorPolygons(footprints []basic.OccultationFootprint) []
}
centerCap := geodata.SphericalCircle(
center,
staticCenterCapRadiusKM/EarthRadiusKM*180/math.Pi,
staticCenterCapRadiusKM/geodata.EarthEquatorialRadiusKM*180/math.Pi,
staticCenterCapPoints,
)
if len(centerCap) >= 3 {
@@ -782,8 +782,8 @@ func footprintPolygons(footprints []basic.OccultationFootprint) [][]geodata.GeoP
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
scaleX := math.Cos(latitude) * geodata.EarthEquatorialRadiusKM * math.Pi / 180
scaleY := geodata.EarthEquatorialRadiusKM * math.Pi / 180
x := func(value geodata.GeoPoint) float64 {
return math.Remainder(value.Longitude-point.Longitude, 360) * scaleX
}
@@ -855,18 +855,8 @@ func BoundaryBranchChanged(first, second basic.OccultationPathPoint) bool {
// DistanceKM 返回两个边界样本之间的最短球面距离。
// DistanceKM returns the shortest spherical surface distance between two boundary samples.
func DistanceKM(first, second basic.OccultationPathPoint) float64 {
return geoDistanceKM(
return geodata.EquatorialDistanceKM(
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)))
}
+2 -2
View File
@@ -22,7 +22,7 @@ func mergeTouchingVisiblePolygonsReference(polygons [][]geodata.GeoPoint) [][]ge
for second := first + 1; second < len(polygons) && firstIndex < 0; second++ {
for _, left := range polygons[first] {
for _, right := range polygons[second] {
if geoDistanceKM(left, right) > touchingDistanceKM {
if geodata.EquatorialDistanceKM(left, right) > touchingDistanceKM {
continue
}
firstIndex, secondIndex = first, second
@@ -43,7 +43,7 @@ func mergeTouchingVisiblePolygonsReference(polygons [][]geodata.GeoPoint) [][]ge
break
}
bridgeHalfDeg := minimumBridgeHalfDeg
if gap := geoDistanceKM(leftTouch, rightTouch) / EarthRadiusKM * 180 / math.Pi; gap/2+0.002 > bridgeHalfDeg {
if gap := geodata.EquatorialDistanceKM(leftTouch, rightTouch) / geodata.EarthEquatorialRadiusKM * 180 / math.Pi; gap/2+0.002 > bridgeHalfDeg {
bridgeHalfDeg = gap/2 + 0.002
}
bridge := []geodata.GeoPoint{
+1 -1
View File
@@ -1274,7 +1274,7 @@ func occultationCoverageProbeExists(
probe geodata.GeoPoint,
) bool {
for _, existing := range coveragePaths {
if geoDistanceKM(existing[0], probe) < 10 {
if geodata.EquatorialDistanceKM(existing[0], probe) < 10 {
return true
}
}