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 }