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

1332 lines
52 KiB
Go

package occultationgeo
import (
"math"
"reflect"
"testing"
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/geodata"
)
func TestOccultationVisibleFootprintFillOnlyMatchesFullFill(t *testing.T) {
visible := []basic.OccultationPathPoint{
{Longitude: 0, Latitude: 0, MoonAltitude: -1},
{Longitude: 2, Latitude: 0, MoonAltitude: 1},
{Longitude: 2, Latitude: 2, MoonAltitude: 1},
{Longitude: 0, Latitude: 2, MoonAltitude: -1},
}
interior := []basic.OccultationPathPoint{
{Longitude: 1.2, Latitude: 0.5, MoonAltitude: 1},
{Longitude: 1.8, Latitude: 0.5, MoonAltitude: 1},
{Longitude: 1.5, Latitude: 1.5, MoonAltitude: 1},
}
polar := []basic.OccultationPathPoint{
{Longitude: 0, Latitude: 80, MoonAltitude: 1},
{Longitude: 120, Latitude: 80, MoonAltitude: 1},
{Longitude: -120, Latitude: 80, MoonAltitude: 1},
}
for _, footprints := range [][]basic.OccultationFootprint{
nil,
{{Polygons: [][]basic.OccultationPathPoint{visible, interior, visible[:2]}, InteriorPolygons: [][]basic.OccultationPathPoint{interior}}},
{{Polygons: [][]basic.OccultationPathPoint{polar}}},
} {
want, _ := occultationVisibleFillAndCoverage(footprints)
if got := occultationVisibleFootprintFillOnly(footprints); !reflect.DeepEqual(got, want) {
t.Fatalf("fill-only changed the horizon-clipped source: got=%v want=%v", got, want)
}
}
}
func TestContinuousBoundaryRangesRetainsEndpointSingletons(t *testing.T) {
start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
points := []basic.OccultationPathPoint{
{Time: start, Longitude: 0, Latitude: 10},
{Time: start.Add(time.Second), Longitude: 30, Latitude: 10},
{Time: start.Add(2 * time.Second), Longitude: 30.1, Latitude: 10},
{Time: start.Add(3 * time.Second), Longitude: 0, Latitude: 10},
}
ranges := ContinuousBoundaryRanges(points)
want := []SampleRange{{Start: 0, End: 1}, {Start: 1, End: 3}, {Start: 3, End: 4}}
if len(ranges) != len(want) {
t.Fatalf("range count = %d, want %d: %#v", len(ranges), len(want), ranges)
}
for index := range want {
if ranges[index] != want[index] {
t.Fatalf("range %d = %#v, want %#v", index, ranges[index], want[index])
}
}
}
func TestStitchedRiseSetCurveSegmentsJoinsSharedFoldEndpoint(t *testing.T) {
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, time.UTC)
fold := basic.OccultationPathPoint{Time: start.Add(2 * time.Minute), Longitude: 1, Latitude: 1}
curve := basic.OccultationRiseSetCurve{
Phase: basic.RiseSetPhaseEnd,
Direction: basic.RiseSetDirectionRise,
Segments: [][]basic.OccultationPathPoint{
{
{Time: start, Longitude: 0, Latitude: 0},
fold,
},
{
{Time: start.Add(time.Minute), Longitude: 0, Latitude: 2},
fold,
},
},
}
segments := StitchedRiseSetCurveSegments(curve)
if len(segments) != 1 {
t.Fatalf("stitched segment count=%d, want one continuous display segment", len(segments))
}
line := segments[0]
if len(line) != 3 {
t.Fatalf("stitched point count=%d, want the shared fold once", len(line))
}
if line[1] != fold {
t.Fatalf("middle point=%+v, want shared fold %+v", line[1], fold)
}
}
func TestDistanceKMUsesShortestAntimeridianArc(t *testing.T) {
first := basic.OccultationPathPoint{Longitude: 179.9}
second := basic.OccultationPathPoint{Longitude: -179.9}
if distance := DistanceKM(first, second); distance > 25 {
t.Fatalf("antimeridian distance = %.1f km, want shortest arc", distance)
}
}
func TestValidateRiseSetCurvesRejectsPolarSubsecondBranchJump(t *testing.T) {
start := time.Date(2025, time.January, 5, 0, 0, 0, 0, time.UTC)
curve := basic.OccultationRiseSetCurve{
Phase: basic.RiseSetPhaseStart,
Direction: basic.RiseSetDirectionSet,
Segments: [][]basic.OccultationPathPoint{{
{Time: start, Longitude: -12.591916, Latitude: 81.864617},
{Time: start.Add(40 * time.Millisecond), Longitude: 22.206242, Latitude: 79.125287},
}},
}
if err := ValidateRiseSetCurves([]basic.OccultationRiseSetCurve{curve}, start, start.Add(time.Hour)); err == nil {
t.Fatal("ValidateRiseSetCurves accepted a polar subsecond branch jump")
}
}
func TestPairedBoundaryPolygonsDoNotBridgeBranchChanges(t *testing.T) {
start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
first := []basic.OccultationPathPoint{
{Time: start, Longitude: 0, Latitude: 10},
{Time: start.Add(time.Minute), Longitude: 1, Latitude: 10},
{Time: start.Add(2 * time.Minute), Longitude: 90, Latitude: 10},
{Time: start.Add(3 * time.Minute), Longitude: 91, Latitude: 10},
}
second := []basic.OccultationPathPoint{
{Time: start, Longitude: 0, Latitude: -10},
{Time: start.Add(time.Minute), Longitude: 1, Latitude: -10},
{Time: start.Add(2 * time.Minute), Longitude: 90, Latitude: -10},
{Time: start.Add(3 * time.Minute), Longitude: 91, Latitude: -10},
}
polygons := PairedBoundaryPolygons(first, second)
if len(polygons) != 2 {
t.Fatalf("polygon count = %d, want two cells separated at the branch change", len(polygons))
}
if polygons[0][0].Longitude != 0 || polygons[0][1].Longitude != 1 ||
polygons[1][0].Longitude != 90 || polygons[1][1].Longitude != 91 {
t.Fatalf("unexpected paired cells: %#v", polygons)
}
}
func TestPairedBoundaryPolygonsSuppressOneSidedBranchInterval(t *testing.T) {
start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
first := []basic.OccultationPathPoint{
{Time: start, Longitude: 0, Latitude: 10},
{Time: start.Add(time.Minute), Longitude: 1, Latitude: 10},
{Time: start.Add(2 * time.Minute), Longitude: 2, Latitude: 10},
{Time: start.Add(3 * time.Minute), Longitude: 90, Latitude: 10},
{Time: start.Add(4 * time.Minute), Longitude: 91, Latitude: 10},
}
second := []basic.OccultationPathPoint{
{Time: start, Longitude: 0, Latitude: -10},
{Time: start.Add(time.Minute), Longitude: 1, Latitude: -10},
{Time: start.Add(2 * time.Minute), Longitude: 90, Latitude: -10},
{Time: start.Add(3 * time.Minute), Longitude: 91, Latitude: -10},
{Time: start.Add(4 * time.Minute), Longitude: 92, Latitude: -10},
}
polygons := PairedBoundaryPolygons(first, second)
if len(polygons) != 2 {
t.Fatalf("polygon count = %d, want two valid cells around the one-sided branch interval", len(polygons))
}
if polygons[0][0].Longitude != 0 || polygons[0][1].Longitude != 1 ||
polygons[1][0].Longitude != 90 || polygons[1][1].Longitude != 91 {
t.Fatalf("unexpected polygons: %#v", polygons)
}
}
func TestRemoveTinyPolygonComponentsKeepsComparableBranches(t *testing.T) {
polygons := [][]geodata.GeoPoint{
{{Longitude: 0, Latitude: 0}, {Longitude: 10, Latitude: 0}, {Longitude: 10, Latitude: 10}, {Longitude: 0, Latitude: 10}},
{{Longitude: 20, Latitude: 0}, {Longitude: 30, Latitude: 0}, {Longitude: 30, Latitude: 10}, {Longitude: 20, Latitude: 10}},
{{Longitude: 179.99, Latitude: 0}, {Longitude: -179.99, Latitude: 0}, {Longitude: -179.99, Latitude: 1}, {Longitude: 179.99, Latitude: 1}},
{{Longitude: 0, Latitude: 0}, {Longitude: 0.01, Latitude: 0}, {Longitude: 0.01, Latitude: 0.01}},
}
filtered := RemoveTinyPolygonComponents(polygons)
if len(filtered) != 3 {
t.Fatalf("filtered component count = %d, want two large and one dateline component", len(filtered))
}
}
func TestFootprintTransitionBoundaryHandlesCoincidentNearestSample(t *testing.T) {
when := time.Date(2025, time.January, 5, 0, 12, 17, 0, time.UTC)
closed := basic.OccultationFootprint{
Time: when,
Closed: true,
Boundaries: [][]basic.OccultationPathPoint{{
{Longitude: -86.16, Latitude: 30.50},
{Longitude: -86.10, Latitude: 30.56},
{Longitude: -86.04, Latitude: 30.50},
{Longitude: -86.10, Latitude: 30.44},
}},
}
open := basic.OccultationFootprint{
Time: when.Add(-50 * time.Millisecond),
Boundaries: [][]basic.OccultationPathPoint{{
{Longitude: -86.12, Latitude: 30.51},
{Longitude: -86.10, Latitude: 30.56},
{Longitude: -86.04, Latitude: 30.50},
{Longitude: -86.10, Latitude: 30.44},
{Longitude: -86.12, Latitude: 30.49},
}},
}
boundary, ok := footprintTransitionBoundary(closed, open)
if !ok {
t.Fatal("coincident nearest sample did not produce a transition boundary")
}
if len(boundary) < len(closed.Boundaries[0])-1 {
t.Fatalf("transition boundary points=%d, want the long physical arc", len(boundary))
}
}
func TestRemoveOccultationHairpinsKeepsMainOutline(t *testing.T) {
points := []geodata.GeoPoint{
{Longitude: -86.3, Latitude: 31.0},
{Longitude: -86.16, Latitude: 30.61},
{Longitude: -85.97, Latitude: 30.55},
{Longitude: -85.74, Latitude: 30.24},
{Longitude: -86.07, Latitude: 30.43},
{Longitude: -86.18, Latitude: 30.47},
{Longitude: -86.4, Latitude: 29.1},
}
cleaned := removeOccultationHairpins(points, 35, 25, 12)
if len(cleaned) != 4 {
t.Fatalf("cleaned point count=%d, want the main outline and hairpin join: %#v", len(cleaned), cleaned)
}
if cleaned[0] != points[0] || cleaned[len(cleaned)-1] != points[len(points)-1] {
t.Fatalf("hairpin cleanup changed main outline endpoints: %#v", cleaned)
}
}
func TestSmoothOccultationHairpinsKeepsShortDisplayEdges(t *testing.T) {
points := []geodata.GeoPoint{
{Longitude: -137.238657, Latitude: -75.841965},
{Longitude: -135.756780, Latitude: -76.038909},
{Longitude: -134.234342, Latitude: -76.226631},
{Longitude: -133.715901, Latitude: -76.650415},
{Longitude: -133.164148, Latitude: -77.073078},
{Longitude: -134.361125, Latitude: -77.215854},
}
smoothed := smoothOccultationHairpins(points, 180, 25, 16, 50)
maximumEdge := 0.0
maximumDetour := 0.0
for index := 1; index < len(smoothed); index++ {
maximumEdge = math.Max(maximumEdge, geoDistanceKM(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]))
}
if maximumEdge > 50.01 {
t.Fatalf("smoothed maximum edge=%.1f km, want <=50 km", maximumEdge)
}
if maximumDetour > 25 {
t.Fatalf("smoothed maximum local detour=%.1f km, want <=25 km", maximumDetour)
}
}
func TestSmoothOccultationPolarWobblesRemovesShortLatitudeSeam(t *testing.T) {
points := []geodata.GeoPoint{
{Longitude: 10.19691, Latitude: 80.96484},
{Longitude: 9.18809, Latitude: 81.05666},
{Longitude: 8.61181, Latitude: 80.98198},
{Longitude: 7.76426, Latitude: 81.01962},
}
smoothed := smoothOccultationPolarWobbles(points)
if len(smoothed) != len(points) || smoothed[0] != points[0] || smoothed[len(smoothed)-1] != points[len(points)-1] {
t.Fatalf("polar wobble smoothing changed endpoints or point count: %#v", smoothed)
}
for index := 1; index+1 < len(smoothed); index++ {
firstDelta := math.Remainder(smoothed[index].Longitude-smoothed[index-1].Longitude, 360)
secondDelta := math.Remainder(smoothed[index+1].Longitude-smoothed[index].Longitude, 360)
if firstDelta*secondDelta <= 0 {
t.Fatalf("polar seam retains longitude reversal at %d: %#v", index, smoothed)
}
firstLatitudeDelta := smoothed[index].Latitude - smoothed[index-1].Latitude
secondLatitudeDelta := smoothed[index+1].Latitude - smoothed[index].Latitude
if firstLatitudeDelta*secondLatitudeDelta < 0 {
t.Fatalf("polar seam retains latitude reversal at %d: %#v", index, smoothed)
}
}
}
func TestSmoothOccultationOrdinaryWobblesHandlesHighLatitudeArc(t *testing.T) {
points := []geodata.GeoPoint{
{Longitude: -30.0, Latitude: 80.000},
{Longitude: -29.8, Latitude: 80.020},
{Longitude: -29.6, Latitude: 80.011},
{Longitude: -29.4, Latitude: 80.031},
{Longitude: -29.2, Latitude: 80.021},
{Longitude: -29.0, Latitude: 80.041},
{Longitude: -28.8, Latitude: 80.032},
{Longitude: -28.6, Latitude: 80.052},
{Longitude: -28.4, Latitude: 80.043},
}
smoothed := smoothOccultationOrdinaryWobbles(points)
if len(smoothed) != len(points) {
t.Fatalf("high-latitude smoothing changed point count: got %d want %d", len(smoothed), len(points))
}
if smoothed[0] != points[0] || smoothed[len(smoothed)-1] != points[len(points)-1] {
t.Fatal("high-latitude smoothing changed arc endpoints")
}
changed := false
for index := 1; index+1 < len(points); index++ {
if geoDistanceKM(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 !changed {
t.Fatal("high-latitude smoothing did not remove the synthetic local wobble")
}
}
func TestDensifyOccultationPolygonsIncludesClosingEdge(t *testing.T) {
points := []geodata.GeoPoint{
{Longitude: 0, Latitude: 0},
{Longitude: 1, Latitude: 0},
{Longitude: 1, Latitude: 1},
{Longitude: 0, Latitude: 1},
}
densified := densifyOccultationPolygons([][]geodata.GeoPoint{points}, 50)
ring := densified[0]
maximumEdge := geoDistanceKM(ring[len(ring)-1], ring[0])
if maximumEdge > 50.01 {
t.Fatalf("closing edge=%.1f km, want <=50 km", maximumEdge)
}
}
func TestVisibleBandPolygons20250105SaturnExcludesInvisiblePolarCap(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone)
paths, err := basic.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), basic.OccultationSaturn,
basic.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
t.Logf("total footprints=%d contours=%d northern=%d southern=%d curves=%d", len(path.TotalBandFootprints), len(path.TotalBandContours), len(path.NorthernTotalLimit), len(path.SouthernTotalLimit), len(path.TotalRiseSetCurves))
for index, contour := range path.TotalBandContours {
if len(contour) == 0 {
continue
}
t.Logf("contour %d len=%d first=(%.4f,%.4f) last=(%.4f,%.4f)", index, len(contour), contour[0].Longitude, contour[0].Latitude, contour[len(contour)-1].Longitude, contour[len(contour)-1].Latitude)
}
visibleSite := []geodata.GeoPoint{{Longitude: -20, Latitude: 81.95}}
visibleFootprints := 0
for _, footprint := range path.PartialBandFootprints {
for _, source := range footprint.Polygons {
polygon := make([]geodata.GeoPoint, len(source))
for index, point := range source {
polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
if geodata.SphericalPolygonsContainPaths([][]geodata.GeoPoint{polygon}, [][]geodata.GeoPoint{visibleSite}, false) {
visibleFootprints++
}
}
}
if visibleFootprints == 0 {
t.Fatal("visible polar site is absent from every instantaneous footprint")
}
polygons, authoritative, err := VisibleBandPolygons(
path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves,
)
if err != nil {
t.Fatalf("VisibleBandPolygons: %v", err)
}
if !authoritative {
t.Fatal("VisibleBandPolygons fell back to the footprint sweep")
}
if geodata.SphericalPolygonsContainPaths(polygons, [][]geodata.GeoPoint{{
{Longitude: -20, Latitude: 82.10},
}}, false) {
t.Fatal("visible band contains the polar site whose occultation remains below the lunar horizon")
}
for _, site := range []geodata.GeoPoint{
{Longitude: -20, Latitude: 81.95},
{Longitude: -20, Latitude: 81.70},
} {
if !geodata.SphericalPolygonsContainPaths(polygons, [][]geodata.GeoPoint{{site}}, false) {
t.Fatalf("visible band excludes the site %.2f, %.2f whose occultation is above the lunar horizon", site.Longitude, site.Latitude)
}
}
}
func TestVisibleBandPolygons20250105SaturnEnvelopeCandidates(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone)
paths, err := basic.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), basic.OccultationSaturn,
basic.OccultationPathOptions{
// Keep this legacy fallback regression on its original exact input.
// The current analytic output is exercised for both branches below.
Algorithm: basic.OccultationPathAlgorithmExact,
Step: 20 * time.Minute, TargetSpacingKM: 900,
DisableFootprints: true, IncludeFootprintTimeline: true,
FootprintTimelineStep: 5 * time.Minute, RiseSetStep: time.Minute,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
final, _, err := VisibleBandPolygonsFromContours(
path.PartialBandFootprints, path.PartialBandContours,
path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves,
)
if err != nil {
t.Fatalf("VisibleBandPolygonsFromContours: %v", err)
}
sweep, err := footprintSweepPolygons(
path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit,
)
if err != nil {
t.Fatalf("footprintSweepPolygons: %v", err)
}
visibleUnion := footprintVisibleUnionPolygons(path.PartialBandFootprints)
direct, _ := DirectVisibleBandPolygons(path.PartialBandFootprints)
contourLines := occultationContactContourBoundaryLines(path.PartialBandContours)
phase, _ := occultationPreferredPhaseBand(
true, path.PartialBandFootprints, path.RiseSetCurves,
path.NorthernLimit, path.SouthernLimit, PairedBoundaryPolygons(path.NorthernLimit, path.SouthernLimit),
)
for name, polygons := range map[string][][]geodata.GeoPoint{
"final": final, "sweep": sweep, "visible-union": visibleUnion, "direct": direct,
"contours": contourLines, "phase": phase,
} {
t.Logf("%s polygons=%d points=%d roughness=%v", name, len(polygons),
occultationTestPolygonPointCount(polygons), occultationTestEnvelopeRoughness(polygons))
}
// 首尾端帽由瞬时足迹的端帽/桥接构造而来,本就有折角,不属于普通纬度平滑的检查范围。
// 接触弧端点(地平线切点)邻域同样按构造排除:可见带在那里转入月球地平线闭合弧,
// 度量到的是切点转折的采样弦高,不是普通纬度抖动。
horizons := occultationTestFootprintHorizonPoints(path.PartialBandFootprints)
roughness := occultationTestEnvelopeRoughnessExcludingHorizon(
occultationTestTrimRingTail(final, occultationTestEnvelopeCapPoints),
horizons, occultationTestEnvelopeHorizonWindowKM,
)
t.Logf("asserted final roughness=%v horizons=%d", roughness, len(horizons))
if roughness["bottom"] > 6 || roughness["back"] > 10 {
t.Fatalf("smoothed Saturn ordinary-latitude envelope remains rough: %v", roughness)
}
if below, checked := occultationTestBelowHorizonVertices(path.PartialBandFootprints); below != 0 || checked < 1000 {
t.Fatalf("band footprints hold %d of %d vertices below the lunar horizon", below, checked)
}
for _, polygon := range final {
for index := 1; index < len(polygon); index++ {
if distance := geoDistanceKM(polygon[index-1], polygon[index]); distance > BoundaryBranchJumpKM {
t.Fatalf("band ring spans a %.1f km edge", distance)
}
}
}
}
// occultationTestEnvelopeCapPoints 是平滑度断言从环尾剔除的端帽顶点数。
const occultationTestEnvelopeCapPoints = 48
// occultationTestTrimRingTail 返回去掉环尾端帽窗口后的副本。
func occultationTestTrimRingTail(polygons [][]geodata.GeoPoint, capPoints int) [][]geodata.GeoPoint {
trimmed := make([][]geodata.GeoPoint, 0, len(polygons))
for _, polygon := range polygons {
if len(polygon) <= 2*capPoints+4 {
continue
}
trimmed = append(trimmed, polygon[:len(polygon)-capPoints])
}
return trimmed
}
func TestVisibleBandPolygonsSaturnAnalyticAlgorithmBranches(t *testing.T) {
start := time.Date(2025, time.January, 5, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*3600))
for _, algorithm := range []basic.OccultationPathAlgorithm{basic.OccultationPathAlgorithmExact, basic.OccultationPathAlgorithmOptimized} {
t.Run(string(algorithm), func(t *testing.T) {
paths, err := basic.FindPlanetOccultationPaths(start, start.Add(24*time.Hour), basic.OccultationSaturn,
basic.OccultationPathOptions{
Algorithm: algorithm, Step: 20 * time.Minute, TargetSpacingKM: 900,
DisableFootprints: true, IncludeFootprintTimeline: true,
FootprintTimelineStep: 5 * time.Minute, RiseSetStep: time.Minute,
})
if err != nil || len(paths) != 1 {
t.Fatalf("paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
if len(path.PartialVisibilityContours) == 0 {
t.Fatal("missing analytic visibility contours")
}
polygons, authoritative, err := VisibleBandPolygonsFromAnalyticContours(
path.PartialBandFootprints, path.PartialBandContours, path.PartialVisibilityContours,
path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves,
)
if err != nil || !authoritative || len(polygons) != 1 {
t.Fatalf("analytic polygons=%d authoritative=%v err=%v", len(polygons), authoritative, err)
}
roughness := occultationTestEnvelopeRoughness(polygons)
if roughness["bottom"] > 6 || roughness["back"] > 10 {
t.Fatalf("analytic Saturn envelope remains rough: %v", roughness)
}
})
}
}
func TestSmoothOccultationOrdinaryWobblesPreservesEndpointsAndBoundedMotion(t *testing.T) {
ring := []geodata.GeoPoint{
{Longitude: -82.0, Latitude: -4.0},
{Longitude: -81.0, Latitude: -5.5},
{Longitude: -80.0, Latitude: -7.0},
{Longitude: -79.0, Latitude: -6.6},
{Longitude: -78.0, Latitude: -7.5},
{Longitude: -77.0, Latitude: -8.0},
{Longitude: -76.0, Latitude: -7.8},
{Longitude: -75.0, Latitude: -7.0},
}
cleaned := smoothOccultationOrdinaryWobbles(ring)
if cleaned[0] != ring[0] || cleaned[len(cleaned)-1] != ring[len(ring)-1] {
t.Fatalf("ordinary smoothing changed open endpoints: %#v", cleaned)
}
for index, point := range cleaned {
if move := geoDistanceKM(point, ring[index]); move > 20.001 {
t.Fatalf("point %d moved %.2f km, want <=20 km", index, move)
}
}
}
func occultationTestPolygonPointCount(polygons [][]geodata.GeoPoint) int {
count := 0
for _, polygon := range polygons {
count += len(polygon)
}
return count
}
func occultationTestEnvelopeRoughness(polygons [][]geodata.GeoPoint) map[string]float64 {
return occultationTestEnvelopeRoughnessExcludingHorizon(polygons, nil, 0)
}
// occultationTestEnvelopeHorizonWindowKM 是粗糙度度量跳过地平线切点邻域的半径:
// 可见带在切点处从接触包络转入月球地平线闭合弧,那里的折角是构造边界而不是普通纬度抖动。
const occultationTestEnvelopeHorizonWindowKM = 20.0
// occultationTestEnvelopeRoughnessExcludingHorizon 跳过起点或终点落在切点邻域内的五采样窗口。
func occultationTestEnvelopeRoughnessExcludingHorizon(
polygons [][]geodata.GeoPoint,
horizons []geodata.GeoPoint,
windowKM float64,
) map[string]float64 {
result := map[string]float64{"bottom": 0, "back": 0, "polar": 0}
for _, polygon := range polygons {
for index := 2; index+2 < len(polygon); index++ {
point := polygon[index]
region := ""
switch {
case point.Latitude < 5:
region = "bottom"
case point.Longitude < -75 && point.Latitude >= 25 && point.Latitude <= 45:
region = "back"
case point.Latitude > 75:
region = "polar"
default:
continue
}
if occultationTestWindowTouchesHorizon(polygon, index, horizons, windowKM) {
continue
}
deviation := occultationTestProjectedPointLineDistanceKM(
point, polygon[index-2], polygon[index+2],
)
result[region] = math.Max(result[region], deviation)
}
}
return result
}
func occultationTestWindowTouchesHorizon(
polygon []geodata.GeoPoint,
index int,
horizons []geodata.GeoPoint,
windowKM float64,
) bool {
if windowKM <= 0 || len(horizons) == 0 {
return false
}
for offset := -2; offset <= 2; offset++ {
for _, horizon := range horizons {
if geoDistanceKM(polygon[index+offset], horizon) <= windowKM {
return true
}
}
}
return false
}
// occultationTestFootprintHorizonPoints 返回接触弧两端的地平线切点,作为"地平线切点邻域"的构造锚点。
func occultationTestFootprintHorizonPoints(footprints []basic.OccultationFootprint) []geodata.GeoPoint {
points := make([]geodata.GeoPoint, 0, len(footprints)*2)
for _, footprint := range footprints {
if footprint.Closed {
continue
}
for _, boundary := range footprint.Boundaries {
if len(boundary) == 0 {
continue
}
for _, index := range []int{0, len(boundary) - 1} {
points = append(points, geodata.GeoPoint{
Longitude: boundary[index].Longitude,
Latitude: boundary[index].Latitude,
})
}
}
}
return points
}
// occultationTestBelowHorizonVertices 统计足迹边界、闭合多边形与修复面中位于月球地平线以下的顶点数。
func occultationTestBelowHorizonVertices(footprints []basic.OccultationFootprint) (below, checked int) {
for _, footprint := range footprints {
for _, rings := range [][][]basic.OccultationPathPoint{
footprint.Boundaries, footprint.Polygons, footprint.InteriorPolygons,
} {
for _, ring := range rings {
for _, point := range ring {
checked++
if point.MoonAltitude < 0 {
below++
}
}
}
}
}
return below, checked
}
func occultationTestProjectedPointLineDistanceKM(point, first, last geodata.GeoPoint) float64 {
project := func(value geodata.GeoPoint) (float64, float64) {
const radiusKM = 6378.1366
latitude := math.Max(-85.05112878, math.Min(85.05112878, value.Latitude)) * math.Pi / 180
return radiusKM * value.Longitude * math.Pi / 180,
radiusKM * math.Log(math.Tan(math.Pi/4+latitude/2))
}
px, py := project(point)
ax, ay := project(first)
bx, by := project(last)
dx, dy := bx-ax, by-ay
if lengthSquared := dx*dx + dy*dy; lengthSquared > 0 {
fraction := ((px-ax)*dx + (py-ay)*dy) / lengthSquared
fraction = math.Max(0, math.Min(1, fraction))
return math.Hypot(px-(ax+fraction*dx), py-(ay+fraction*dy))
}
return math.Hypot(px-ax, py-ay)
}
func TestVisibleBandPolygons20250729MarsStitchesEndRiseFold(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
paths, err := basic.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), basic.OccultationMars,
basic.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: 5 * time.Minute,
DisableFootprints: true,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
if len(path.CenterLine) != 0 {
t.Fatalf("center-line points=%d, want non-central path", len(path.CenterLine))
}
if len(path.RiseSetCurves) != 3 {
t.Fatalf("rise/set curve count=%d, want three rising phase curves", len(path.RiseSetCurves))
}
endCurve := path.RiseSetCurves[2]
if len(endCurve.Segments) < 2 {
t.Fatalf("end-rise segment count=%d, want the polar fold branches", len(endCurve.Segments))
}
polygons, authoritative, err := VisibleBandPolygons(
path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves,
)
if err != nil {
t.Fatalf("VisibleBandPolygons: %v", err)
}
if !authoritative {
t.Fatal("VisibleBandPolygons fell back to the footprint sweep")
}
if len(polygons) != 1 {
t.Fatalf("visible band polygon count=%d, want one continuous polar band", len(polygons))
}
for footprintIndex, footprint := range path.PartialBandFootprints {
for polygonIndex, source := range footprint.Polygons {
for pointIndex, point := range source {
if point.MoonAltitude < -1e-6 && geodata.SphericalPolygonsContainPaths(
polygons, [][]geodata.GeoPoint{{{Longitude: point.Longitude, Latitude: point.Latitude}}}, false,
) {
t.Fatalf("visible band contains below-horizon footprint %d polygon %d point %d", footprintIndex, polygonIndex, pointIndex)
}
}
}
}
if miss := geodata.SphericalPolygonsPathMissDistanceKM(polygons, [][]geodata.GeoPoint{{
{Longitude: path.PartialBandFootprints[len(path.PartialBandFootprints)/2].Polygons[0][0].Longitude,
Latitude: path.PartialBandFootprints[len(path.PartialBandFootprints)/2].Polygons[0][0].Latitude},
}}, true); miss > 112.5 {
t.Fatalf("visible band misses a source footprint vertex by %.1f km", miss)
}
if !geodata.SphericalPolygonsContainPaths(polygons, [][]geodata.GeoPoint{{
{Longitude: -110, Latitude: -75},
}}, false) {
t.Fatal("visible band misses the eastern footprint-sweep region")
}
for polygonIndex, polygon := range polygons {
maximumDetour := 0.0
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)
if detour > maximumDetour {
maximumDetour = detour
}
firstLongitude := first.Longitude - middle.Longitude
firstLatitude := first.Latitude - middle.Latitude
lastLongitude := last.Longitude - middle.Longitude
lastLatitude := last.Latitude - middle.Latitude
firstLength := math.Hypot(firstLongitude, firstLatitude)
lastLength := math.Hypot(lastLongitude, lastLatitude)
if firstLength <= 1e-12 || lastLength <= 1e-12 {
continue
}
cosine := (firstLongitude*lastLongitude + firstLatitude*lastLatitude) /
(firstLength * lastLength)
cosine = math.Max(-1, math.Min(1, cosine))
minimumTurn = math.Min(minimumTurn, math.Acos(cosine)*180/math.Pi)
}
if maximumDetour > 35 {
t.Fatalf("visible band polygon %d retains %.1f km local hairpin", polygonIndex, maximumDetour)
}
if minimumTurn < 30 {
t.Fatalf("visible band polygon %d retains a %.1f degree numerical corner", polygonIndex, minimumTurn)
}
}
}
func TestVisibleBandPolygonsFromContours20250729MarsUsesAnalyticBoundaryNetwork(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
paths, err := basic.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), basic.OccultationMars,
basic.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
DisableFootprints: true, IncludeFootprintTimeline: true,
FootprintTimelineStep: 5 * time.Minute,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
polygons, authoritative, err := VisibleBandPolygonsFromAnalyticContours(
path.PartialBandFootprints, path.PartialBandContours, path.PartialVisibilityContours,
path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves,
)
if err != nil {
t.Fatalf("VisibleBandPolygonsFromContours: %v", err)
}
if !authoritative || len(polygons) != 1 {
t.Fatalf("visible band authoritative=%v rings=%d, want one authoritative ring", authoritative, len(polygons))
}
partialBoundary := occultationVisibleBoundaryLinesFromBase(
occultationContactContourBoundaryLines(path.PartialBandContours),
occultationStaticBandCurves(path.RiseSetCurves),
occultationContactContourBoundaryLines(path.PartialVisibilityContours),
)
partialBoundary = append(partialBoundary, occultationHorizonConnectorBoundaryLines(
HorizonConnectorSegments(path.PartialBandFootprints, path.RiseSetCurves,
path.NorthernLimit, path.SouthernLimit),
)...)
assertOccultationPolygonUsesOnlyBoundaryNetwork(t, "partial", polygons, partialBoundary, 1)
phaseLines := make([][]geodata.GeoPoint, 0, len(path.RiseSetCurves)*2)
for _, curve := range path.RiseSetCurves {
phaseLines = append(phaseLines, occultationCurveBoundaryLines(curve)...)
}
if !geodata.SphericalPolygonsContainPathsWithinKM(polygons, phaseLines, false, 1) {
miss := geodata.SphericalPolygonsPathMissDistanceKM(polygons, phaseLines, false)
t.Fatalf("static partial band misses a physical rise/set phase curve by %.3f km, want <=1 km", miss)
}
assertOccultationPolarExtremeSmooth(t, "partial", polygons)
if !geodata.SphericalPolygonsContainPaths(polygons, [][]geodata.GeoPoint{{
{Longitude: -110, Latitude: -75},
}}, false) {
t.Fatal("static band misses the eastern event-time footprint")
}
totalPolygons, totalAuthoritative, err := VisibleTotalBandPolygonsFromAnalyticContours(
path.TotalBandFootprints, path.TotalBandContours, path.TotalVisibilityContours,
path.NorthernTotalLimit, path.SouthernTotalLimit, path.TotalRiseSetCurves,
)
if err != nil {
t.Fatalf("VisibleTotalBandPolygonsFromContours: %v", err)
}
if !totalAuthoritative || len(totalPolygons) != 1 {
t.Fatalf("total visible band authoritative=%v rings=%d, want one authoritative ring", totalAuthoritative, len(totalPolygons))
}
totalBoundary := occultationVisibleBoundaryLinesFromBase(
occultationContactContourBoundaryLines(path.TotalBandContours),
occultationStaticBandCurves(path.TotalRiseSetCurves),
occultationContactContourBoundaryLines(path.TotalVisibilityContours),
)
totalBoundary = append(totalBoundary, occultationHorizonConnectorBoundaryLines(
HorizonConnectorSegments(path.TotalBandFootprints, path.TotalRiseSetCurves,
path.NorthernTotalLimit, path.SouthernTotalLimit),
)...)
assertOccultationPolygonUsesOnlyBoundaryNetwork(t, "total", totalPolygons, totalBoundary, 1)
totalPhaseLines := make([][]geodata.GeoPoint, 0, len(path.TotalRiseSetCurves)*2)
for _, curve := range path.TotalRiseSetCurves {
totalPhaseLines = append(totalPhaseLines, occultationCurveBoundaryLines(curve)...)
}
if !geodata.SphericalPolygonsContainPathsWithinKM(totalPolygons, totalPhaseLines, false, 1) {
miss := geodata.SphericalPolygonsPathMissDistanceKM(totalPolygons, totalPhaseLines, false)
t.Fatalf("static total band misses a physical rise/set phase curve by %.3f km, want <=1 km", miss)
}
assertOccultationPolarExtremeSmooth(t, "total", totalPolygons)
if miss := geodata.SphericalPolygonsPathMissDistanceKM(polygons, totalPolygons, true); miss > 10 {
t.Fatalf("total band extends %.1f km beyond the partial band", miss)
}
}
func TestVisibleBandPolygons20250105SaturnUsesCompleteAnalyticBoundaryNetwork(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone)
paths, err := basic.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), basic.OccultationSaturn,
basic.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
DisableFootprints: true,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
partial, partialAuthoritative, err := VisibleBandPolygonsFromAnalyticContours(
path.PartialBandFootprints, path.PartialBandContours, path.PartialVisibilityContours,
path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves,
)
if err != nil || !partialAuthoritative || len(partial) != 1 {
t.Fatalf("partial polygons=%d authoritative=%v err=%v, want one analytic polygon", len(partial), partialAuthoritative, err)
}
total, totalAuthoritative, err := VisibleTotalBandPolygonsFromAnalyticContours(
path.TotalBandFootprints, path.TotalBandContours, path.TotalVisibilityContours,
path.NorthernTotalLimit, path.SouthernTotalLimit, path.TotalRiseSetCurves,
)
if err != nil || !totalAuthoritative || len(total) != 1 {
t.Fatalf("total polygons=%d authoritative=%v err=%v, want one analytic polygon", len(total), totalAuthoritative, err)
}
for _, band := range []struct {
name string
polygons [][]geodata.GeoPoint
contacts [][]basic.OccultationPathPoint
visibility [][]basic.OccultationPathPoint
curves []basic.OccultationRiseSetCurve
}{
{name: "partial", polygons: partial, contacts: path.PartialBandContours, visibility: path.PartialVisibilityContours, curves: path.RiseSetCurves},
{name: "total", polygons: total, contacts: path.TotalBandContours, visibility: path.TotalVisibilityContours, curves: path.TotalRiseSetCurves},
} {
boundary := occultationVisibleBoundaryLinesFromBase(
occultationContactContourBoundaryLines(band.contacts),
occultationStaticBandCurves(band.curves),
occultationContactContourBoundaryLines(band.visibility),
)
if band.name == "partial" {
boundary = append(boundary, occultationHorizonConnectorBoundaryLines(
HorizonConnectorSegments(path.PartialBandFootprints, band.curves,
path.NorthernLimit, path.SouthernLimit),
)...)
} else {
boundary = append(boundary, occultationHorizonConnectorBoundaryLines(
HorizonConnectorSegments(path.TotalBandFootprints, band.curves,
path.NorthernTotalLimit, path.SouthernTotalLimit),
)...)
}
assertOccultationPolygonUsesOnlyBoundaryNetwork(t, band.name, band.polygons, boundary, 1)
phaseLines := make([][]geodata.GeoPoint, 0, len(band.curves)*2)
for _, curve := range band.curves {
phaseLines = append(phaseLines, occultationCurveBoundaryLines(curve)...)
}
if !geodata.SphericalPolygonsContainPathsWithinKM(band.polygons, phaseLines, false, 1) {
t.Fatalf("%s misses a rise/set phase by %.3f km", band.name,
geodata.SphericalPolygonsPathMissDistanceKM(band.polygons, phaseLines, false))
}
}
if miss := geodata.SphericalPolygonsPathMissDistanceKM(partial, total, true); miss > 10 {
t.Fatalf("total band extends %.1f km beyond the partial band", miss)
}
}
func assertOccultationPolygonUsesOnlyBoundaryNetwork(
t *testing.T,
name string,
polygons, lines [][]geodata.GeoPoint,
maximumDistanceKM float64,
) {
t.Helper()
maximum := 0.0
maximumPoint := geodata.GeoPoint{}
for _, polygon := range polygons {
for _, point := range polygon {
distance := math.Inf(1)
for _, line := range lines {
for index := 1; index < len(line); index++ {
distance = math.Min(distance, occultationTestProjectedPointLineDistanceKM(
point, line[index-1], line[index],
))
}
}
if distance > maximum {
maximum, maximumPoint = distance, point
}
}
}
if maximum > maximumDistanceKM {
t.Fatalf("%s boundary leaves the analytic contact/phase network by %.3f km at %+v, want <=%.1f km",
name, maximum, maximumPoint, maximumDistanceKM)
}
}
func assertOccultationPolarExtremeSmooth(
t *testing.T,
name string,
polygons [][]geodata.GeoPoint,
) {
t.Helper()
minimumLatitude := math.Inf(1)
for _, polygon := range polygons {
for _, point := range polygon {
minimumLatitude = math.Min(minimumLatitude, point.Latitude)
}
}
if minimumLatitude > -75 {
return
}
minimumTurn := 180.0
maximumDeviation := 0.0
minimumTurnPoint := geodata.GeoPoint{}
for _, polygon := range polygons {
for index := 2; index+2 < len(polygon); index++ {
if polygon[index].Latitude > minimumLatitude+0.15 {
continue
}
turn := occultationTurnAngleDegrees(
polygon[index-1], polygon[index], polygon[index+1],
)
if turn < minimumTurn {
minimumTurn = turn
minimumTurnPoint = polygon[index]
}
maximumDeviation = math.Max(maximumDeviation, occultationProjectedPointLineDistanceKM(
polygon[index], polygon[index-2], polygon[index+2],
))
}
}
if minimumTurn < 155 {
t.Fatalf("%s polar phase junction turn=%.2f degrees at %+v, want >=155", name, minimumTurn, minimumTurnPoint)
}
if maximumDeviation > 10 {
t.Fatalf("%s polar phase junction deviation=%.2f km, want <=10 km", name, maximumDeviation)
}
}
func TestOccultationPhaseBoundaryCycleExcludesGreatestInteriorLine(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
paths, err := basic.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), basic.OccultationMars,
basic.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
DisableFootprints: true,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
contourFill, _ := occultationContourFillAndCoverage(
true, path.NorthernLimit, path.SouthernLimit, nil,
)
cycles, ok := occultationPhaseBoundaryPolygons(
path.RiseSetCurves,
HorizonConnectorSegments(
path.PartialBandFootprints, path.RiseSetCurves,
path.NorthernLimit, path.SouthernLimit,
),
contourFill,
)
if !ok || len(cycles) != 1 {
t.Fatalf("phase boundary cycles=%d ok=%v, want one closed Mars outer cycle", len(cycles), ok)
}
greatest := [][]geodata.GeoPoint{{
{Longitude: path.Greatest.Longitude, Latitude: path.Greatest.Latitude},
}}
if !geodata.SphericalPolygonsContainPaths(cycles, greatest, false) {
t.Fatal("greatest point is outside the explicit start/end phase cycle")
}
if below := geodata.SphericalPolygonsPathMissDistanceKM(
cycles, contourFill, true,
); below > 150 {
t.Fatalf("phase cycle misses contact envelope by %.1f km", below)
}
phaseLines := make([][]geodata.GeoPoint, 0, len(path.RiseSetCurves)*2)
for _, curve := range path.RiseSetCurves {
if curve.Phase == basic.RiseSetPhaseGreatest {
continue
}
phaseLines = append(phaseLines, occultationCurveBoundaryLines(curve)...)
}
for _, connector := range HorizonConnectorSegments(
path.PartialBandFootprints, path.RiseSetCurves,
path.NorthernLimit, path.SouthernLimit,
) {
phaseLines = append(phaseLines, occultationPathGeoLine(connector.Points))
}
for pointIndex, point := range cycles[0] {
if pointIndex == len(cycles[0])-1 && geodata.SameGeoPoint(point, cycles[0][0]) {
continue
}
minimum := math.Inf(1)
for _, line := range phaseLines {
for _, candidate := range line {
minimum = math.Min(minimum, geoDistanceKM(point, candidate))
}
}
if minimum > 1 {
t.Fatalf("cycle vertex %d is %.1f km away from start/end/connector phase data", pointIndex, minimum)
}
}
}
func TestRemoveOccultationPolarKinksRemovesShortAxisReversals(t *testing.T) {
points := []geodata.GeoPoint{
{Longitude: -135.15725570344884, Latitude: -78.65055730057036},
{Longitude: -135.02235091247215, Latitude: -78.70666016247198},
{Longitude: -134.7363589035614, Latitude: -78.69259306299674},
{Longitude: -134.45107516883687, Latitude: -78.67825217368357},
}
cleaned := removeOccultationPolarKinks(points)
if len(cleaned) >= len(points) {
t.Fatalf("cleaned point count=%d, want fewer than %d", len(cleaned), len(points))
}
if cleaned[0] != points[0] || cleaned[len(cleaned)-1] != points[len(points)-1] {
t.Fatalf("cleaned endpoints changed: %#v", cleaned)
}
}
func TestConstrainPolygonsWithinRepairsSmallChildBreach(t *testing.T) {
parent := [][]geodata.GeoPoint{{
{Longitude: 0, Latitude: 0},
{Longitude: 2, Latitude: 0},
{Longitude: 2, Latitude: 2},
{Longitude: 0, Latitude: 2},
{Longitude: 0, Latitude: 0},
}}
child := [][]geodata.GeoPoint{{
{Longitude: 0.5, Latitude: 0.5},
{Longitude: 1.5, Latitude: 0.5},
{Longitude: 2.0005, Latitude: 0.0005},
{Longitude: 1.5, Latitude: 1.5},
{Longitude: 0.5, Latitude: 1.5},
{Longitude: 0.5, Latitude: 0.5},
}}
repaired := ConstrainPolygonsWithin(parent, child)
if geodata.SphericalPolygonsPathMissDistanceKM(parent, repaired, true) > 0 {
t.Fatal("small child breach remains outside parent")
}
if len(repaired[0]) != len(child[0]) {
t.Fatalf("repair changed ring point count from %d to %d", len(child[0]), len(repaired[0]))
}
}
func TestConstrainPolygonsWithinLeavesLargeBreachUntouched(t *testing.T) {
parent := [][]geodata.GeoPoint{{
{Longitude: 0, Latitude: 0},
{Longitude: 1, Latitude: 0},
{Longitude: 1, Latitude: 1},
{Longitude: 0, Latitude: 1},
{Longitude: 0, Latitude: 0},
}}
child := [][]geodata.GeoPoint{{
{Longitude: 0.2, Latitude: 0.2},
{Longitude: 4, Latitude: 0.2},
{Longitude: 0.2, Latitude: 0.8},
{Longitude: 0.2, Latitude: 0.2},
}}
repaired := ConstrainPolygonsWithin(parent, child)
if miss := geodata.SphericalPolygonsPathMissDistanceKM(parent, repaired, true); miss <= 0 {
t.Fatal("large child breach was unexpectedly constrained")
}
}
func TestVisibleBandPolygons20250729MarsTotalRiseSetRemovesPolarBacktrack(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
paths, err := basic.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), basic.OccultationMars,
basic.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
DisableFootprints: true,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
if len(path.TotalRiseSetCurves) == 0 {
t.Fatal("Mars path is missing inner-contact total rise/set curves")
}
polygons, authoritative, err := VisibleTotalBandPolygons(
path.TotalBandFootprints, path.NorthernTotalLimit, path.SouthernTotalLimit, path.TotalRiseSetCurves,
)
if err != nil {
t.Fatalf("VisibleBandPolygons: %v", err)
}
if !authoritative {
t.Fatal("total band fell back to footprint-sweep geometry instead of inner-contact rise/set boundaries")
}
if len(polygons) != 1 {
t.Fatalf("total polygon count=%d, want one continuous band", len(polygons))
}
for polygonIndex, polygon := range polygons {
maximumEdge := 0.0
for startIndex := 0; startIndex < len(polygon); startIndex++ {
arcLength := 0.0
limit := startIndex + 16
if limit >= len(polygon) {
limit = len(polygon) - 1
}
for endIndex := startIndex + 1; endIndex <= limit; endIndex++ {
edge := geoDistanceKM(polygon[endIndex-1], polygon[endIndex])
arcLength += edge
if edge > maximumEdge {
maximumEdge = edge
}
if endIndex < startIndex+3 {
continue
}
closure := geoDistanceKM(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)
}
}
}
if maximumEdge > 175 {
t.Fatalf("total polygon %d retains a %.1f km display chord", polygonIndex, maximumEdge)
}
}
}
func TestVisibleBandPolygons20250729MarsTotalSmoothingPreservesGeometry(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
paths, err := basic.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), basic.OccultationMars,
basic.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
DisableFootprints: true, IncludeFootprintTimeline: true,
FootprintTimelineStep: 5 * time.Minute,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
rawTotal, authoritative, err := visibleBandPolygons(
path.TotalBandFootprints, path.NorthernTotalLimit, path.SouthernTotalLimit,
path.TotalBandContours, path.TotalRiseSetCurves, true,
)
if err != nil || !authoritative {
t.Fatalf("raw total band authoritative=%v err=%v, want authoritative geometry", authoritative, err)
}
rawTotal = normalizeOccultationBandOutput(rawTotal)
smoothedTotal := roundOccultationTotalBandJunctions(rawTotal)
if shrink := geodata.SphericalPolygonsPathMissDistanceKM(smoothedTotal, rawTotal, true); shrink > 1 {
t.Fatalf("wide-shoulder repair excludes %.1f km of the source total ring", shrink)
}
secondPass := roundOccultationTotalBandJunctions(smoothedTotal)
if change := math.Max(
geodata.SphericalPolygonsPathMissDistanceKM(smoothedTotal, secondPass, true),
geodata.SphericalPolygonsPathMissDistanceKM(secondPass, smoothedTotal, true),
); change > 0.05 {
t.Fatalf("total-band smoothing is not idempotent: second pass changed %.3f km", change)
}
for polygonIndex, polygon := range smoothedTotal {
for pointIndex := 2; pointIndex+2 < len(polygon); pointIndex++ {
if deviation := occultationTestProjectedPointLineDistanceKM(
polygon[pointIndex], polygon[pointIndex-2], polygon[pointIndex+2],
); deviation > 35 {
t.Fatalf("total polygon %d retains %.1f km local roughness at point %d",
polygonIndex, deviation, pointIndex)
}
}
}
partial, partialAuthoritative, err := VisibleBandPolygonsFromContours(
path.PartialBandFootprints, path.PartialBandContours,
path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves,
)
if err != nil || !partialAuthoritative {
t.Fatalf("partial band authoritative=%v err=%v, want authoritative geometry", partialAuthoritative, err)
}
rawPartial, _, err := visibleBandPolygons(
path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit,
path.PartialBandContours, path.RiseSetCurves, false,
)
if err != nil {
t.Fatalf("raw partial band: %v", err)
}
rawPartial = normalizeOccultationBandOutput(rawPartial)
partialChangeKM := math.Max(
geodata.SphericalPolygonsPathMissDistanceKM(rawPartial, partial, true),
geodata.SphericalPolygonsPathMissDistanceKM(partial, rawPartial, true),
)
if partialChangeKM > 0.05 {
t.Fatalf("total-only wide-shoulder path changed partial geometry by %.3f km", partialChangeKM)
}
}
func TestVisibleBandPolygons20250729MarsTotalRiseSetStaysInsidePartialBand(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
paths, err := basic.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), basic.OccultationMars,
basic.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
DisableFootprints: true,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
partial, authoritative, err := VisibleBandPolygons(
path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves,
)
if err != nil {
t.Fatalf("partial VisibleBandPolygons: %v", err)
}
if !authoritative {
t.Fatal("partial visible band fell back to the footprint sweep")
}
total, authoritative, err := VisibleTotalBandPolygons(
path.TotalBandFootprints, path.NorthernTotalLimit, path.SouthernTotalLimit, path.TotalRiseSetCurves,
)
if err != nil {
t.Fatalf("total VisibleBandPolygons: %v", err)
}
if !authoritative {
t.Fatal("total visible band fell back to the footprint sweep")
}
greatest := [][]geodata.GeoPoint{{
{Longitude: path.Greatest.Longitude, Latitude: path.Greatest.Latitude},
}}
if !geodata.SphericalPolygonsContainPaths(total, greatest, false) {
miss := geodata.SphericalPolygonsPathMissDistanceKM(total, greatest, false)
t.Fatalf("total band misses greatest point by %.1f km", miss)
}
if miss := geodata.SphericalPolygonsPathMissDistanceKM(partial, total, true); miss > 10 {
t.Fatalf("total band extends %.1f km outside the partial band", miss)
}
}
func TestOccultationVisibleBandLineworkInputsPreferContoursWhenAvailable(t *testing.T) {
visibleFill := [][]geodata.GeoPoint{{
{Longitude: -20, Latitude: 81.5},
{Longitude: -19, Latitude: 81.5},
{Longitude: -19, Latitude: 82.0},
{Longitude: -20, Latitude: 82.0},
}}
contourFill := [][]geodata.GeoPoint{{
{Longitude: -20.5, Latitude: 81.6},
{Longitude: -19.5, Latitude: 81.6},
{Longitude: -19.5, Latitude: 81.9},
{Longitude: -20.5, Latitude: 81.9},
}}
coveragePaths := [][]geodata.GeoPoint{{
{Longitude: -20.25, Latitude: 81.75},
}}
for _, strongPolarSmoothing := range []bool{false, true} {
label := "partial"
if strongPolarSmoothing {
label = "total"
}
selectionFill, selectedCoverage, curveCoveragePaths, visibleFillCoveragePaths :=
occultationVisibleBandLineworkInputs(
true, strongPolarSmoothing, visibleFill, contourFill, coveragePaths, nil,
)
expectedSelection := append(append([][]geodata.GeoPoint(nil), contourFill...), visibleFill...)
if !geoPointCollectionsEqual(selectionFill, expectedSelection) {
t.Fatalf("%s selection fill = %#v, want contour plus footprint witnesses %#v", label, selectionFill, expectedSelection)
}
expectedCoverage := coveragePaths
if !geoPointCollectionsEqual(selectedCoverage, expectedCoverage) {
t.Fatalf("%s coverage paths = %#v, want original footprint probes %#v", label, selectedCoverage, expectedCoverage)
}
if len(curveCoveragePaths) != 0 {
t.Fatalf("%s curve coverage count=%d, want zero with no curves", label, len(curveCoveragePaths))
}
if !geoPointCollectionsEqual(visibleFillCoveragePaths, coveragePaths) {
t.Fatalf("%s visible-fill coverage = %#v, want original footprint probes %#v",
label, visibleFillCoveragePaths, coveragePaths)
}
}
}
func geoPointCollectionsEqual(first, second [][]geodata.GeoPoint) bool {
if len(first) != len(second) {
return false
}
for index := range first {
if len(first[index]) != len(second[index]) {
return false
}
for pointIndex := range first[index] {
if first[index][pointIndex] != second[index][pointIndex] {
return false
}
}
}
return true
}