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

388 lines
16 KiB
Go

package occultationgeo
import (
"math"
"testing"
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/geodata"
)
func TestHorizonConnectorTrimsSamplesOutsideExactEndpoints(t *testing.T) {
for _, offset := range []float64{0, 177, -182} {
var points []basic.OccultationPathPoint
for _, longitude := range []float64{1, 0, 2, 3, 5, 4} {
points = append(points, basic.OccultationPathPoint{Longitude: math.Remainder(longitude+offset, 360), Latitude: 0})
}
trimmed := trimHorizonConnectorSamples(points)
want := []basic.OccultationPathPoint{points[0], points[2], points[3], points[5]}
if len(trimmed) != len(want) {
t.Fatalf("offset=%g retained %d points, want %d", offset, len(trimmed), len(want))
}
for i := range want {
if trimmed[i] != want[i] {
t.Fatalf("offset=%g vertex %d changed: got %+v want %+v", offset, i, trimmed[i], want[i])
}
}
}
}
func TestHorizonConnectorSegmentsUsesHorizonClosureAndPhaseEndpoints(t *testing.T) {
eventTime := time.Date(2025, time.July, 29, 0, 0, 0, 0, time.UTC)
contactStart := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 0}
contactEnd := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 2}
phaseStart := basic.OccultationPathPoint{Time: eventTime.Add(time.Second), Longitude: -0.1, Latitude: 2}
phaseGreatest := basic.OccultationPathPoint{Time: eventTime.Add(-time.Second), Longitude: -0.1, Latitude: 0}
footprint := basic.OccultationFootprint{
Boundaries: [][]basic.OccultationPathPoint{{
contactStart,
{Time: eventTime, Longitude: 4, Latitude: 1},
contactEnd,
}},
Polygons: [][]basic.OccultationPathPoint{{
contactStart,
{Time: eventTime, Longitude: 4, Latitude: 1},
contactEnd,
{Time: eventTime, Longitude: 1, Latitude: 2},
{Time: eventTime, Longitude: 1, Latitude: 1},
{Time: eventTime, Longitude: 1, Latitude: 0},
contactStart,
}},
}
curves := []basic.OccultationRiseSetCurve{
{
Phase: basic.RiseSetPhaseStart, Direction: basic.RiseSetDirectionRise,
Segments: [][]basic.OccultationPathPoint{{phaseStart, {Time: eventTime.Add(time.Minute), Longitude: -1, Latitude: 3}}},
},
{
Phase: basic.RiseSetPhaseGreatest, Direction: basic.RiseSetDirectionRise,
Segments: [][]basic.OccultationPathPoint{{phaseGreatest, {Time: eventTime.Add(time.Minute), Longitude: -1, Latitude: -1}}},
},
}
connectors := HorizonConnectorSegments([]basic.OccultationFootprint{footprint}, curves)
if len(connectors) != 1 {
t.Fatalf("connectors=%d, want one", len(connectors))
}
points := connectors[0].Points
if len(points) <= 4 {
t.Fatalf("connector points=%d, want a densified horizon closure", len(points))
}
if points[0] != phaseStart || points[len(points)-1] != phaseGreatest {
t.Fatalf("connector endpoints=(%+v, %+v), want phase endpoints (%+v, %+v)",
points[0], points[len(points)-1], phaseStart, phaseGreatest)
}
for _, point := range points {
if point.Longitude == 4 {
t.Fatal("connector contains the contact-limb detour")
}
}
}
func TestSmoothHorizonConnectorJunctionsRemovesShortEndpointCorner(t *testing.T) {
points := []basic.OccultationPathPoint{
{Longitude: -49.9687087, Latitude: 22.7599834},
{Longitude: -49.7995044, Latitude: 22.6531000},
{Longitude: -49.8520050, Latitude: 22.4586340},
{Longitude: -49.9043580, Latitude: 22.2641500},
}
cleaned := smoothHorizonConnectorJunctions(points)
if len(cleaned) != len(points)-1 {
t.Fatalf("cleaned point count=%d, want %d: %#v", len(cleaned), len(points)-1, cleaned)
}
if cleaned[0] != points[0] || cleaned[len(cleaned)-1] != points[len(points)-1] {
t.Fatalf("connector endpoints changed: first=%+v last=%+v", cleaned[0], cleaned[len(cleaned)-1])
}
}
func TestSmoothHorizonConnectorJunctionsKeepsBroadBend(t *testing.T) {
points := []basic.OccultationPathPoint{
{Longitude: 0, Latitude: 0},
{Longitude: 0.2, Latitude: 0.1},
{Longitude: 1.0, Latitude: 0.8},
{Longitude: 2.0, Latitude: 1.8},
}
cleaned := smoothHorizonConnectorJunctions(points)
if len(cleaned) != len(points) {
t.Fatalf("broad physical bend was changed: got %d points, want %d", len(cleaned), len(points))
}
}
func TestHorizonConnectorSegmentsSmoothsExactEndpointJunctions(t *testing.T) {
cases := []struct {
name string
planet basic.OccultationPlanet
start time.Time
target time.Time
}{
{"venus-1027", basic.OccultationVenus, time.Date(1027, time.March, 13, 0, 0, 0, 0, time.UTC), time.Date(1027, time.March, 13, 22, 11, 50, 833970457, time.UTC)},
{"saturn-2629", basic.OccultationSaturn, time.Date(2629, time.December, 30, 0, 0, 0, 0, time.UTC), time.Date(2629, time.December, 30, 18, 28, 18, 614121973, time.UTC)},
}
for _, sample := range cases {
t.Run(sample.name, func(t *testing.T) {
paths, err := basic.FindPlanetOccultationPaths(sample.start.Add(-12*time.Hour), sample.start.Add(36*time.Hour), sample.planet, basic.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
DisableFootprints: true, Algorithm: basic.OccultationPathAlgorithmExact,
})
if err != nil || len(paths) == 0 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v", len(paths), err)
}
path := paths[0]
for _, candidate := range paths[1:] {
if absDuration(candidate.Greatest.Time.Sub(sample.target)) < absDuration(path.Greatest.Time.Sub(sample.target)) {
path = candidate
}
}
curvesByBand := []struct {
name string
footprints []basic.OccultationFootprint
curves []basic.OccultationRiseSetCurve
}{
{"partial", path.PartialBandFootprints, path.RiseSetCurves},
{"total", path.TotalBandFootprints, path.TotalRiseSetCurves},
}
for _, band := range curvesByBand {
for connectorIndex, connector := range HorizonConnectorSegments(band.footprints, band.curves) {
for index := 1; index+1 < len(connector.Points); index++ {
first, middle, last := connector.Points[index-1], connector.Points[index], connector.Points[index+1]
turn := 180 - occultationTurnAngleDegrees(
geodata.GeoPoint{Longitude: first.Longitude, Latitude: first.Latitude},
geodata.GeoPoint{Longitude: middle.Longitude, Latitude: middle.Latitude},
geodata.GeoPoint{Longitude: last.Longitude, Latitude: last.Latitude},
)
deviation := occultationProjectedPointLineDistanceKM(
geodata.GeoPoint{Longitude: middle.Longitude, Latitude: middle.Latitude},
geodata.GeoPoint{Longitude: first.Longitude, Latitude: first.Latitude},
geodata.GeoPoint{Longitude: last.Longitude, Latitude: last.Latitude},
)
if turn >= 55 && deviation >= 5 && deviation <= 25 &&
DistanceKM(first, middle) <= 75 && DistanceKM(middle, last) <= 75 && DistanceKM(first, last) <= 55 {
t.Fatalf("%s connector %d retains short endpoint corner at %d: turn=%.1f deviation=%.1f points=%+v/%+v/%+v", band.name, connectorIndex, index, turn, deviation, first, middle, last)
}
}
}
}
})
}
}
func TestHorizonConnectorSegmentsSkipsSamePhaseFoldBranches(t *testing.T) {
eventTime := time.Date(2025, time.July, 29, 0, 0, 0, 0, time.UTC)
contactStart := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 0}
contactEnd := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 1}
terminal := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 0.5}
footprint := basic.OccultationFootprint{
Boundaries: [][]basic.OccultationPathPoint{{
contactStart,
{Time: eventTime, Longitude: 2, Latitude: 0.5},
contactEnd,
}},
Polygons: [][]basic.OccultationPathPoint{{
contactStart,
{Time: eventTime, Longitude: 2, Latitude: 0.5},
contactEnd,
{Time: eventTime, Longitude: 1, Latitude: 1},
{Time: eventTime, Longitude: 1, Latitude: 0},
contactStart,
}},
}
curves := []basic.OccultationRiseSetCurve{{
Phase: basic.RiseSetPhaseEnd, Direction: basic.RiseSetDirectionRise,
Segments: [][]basic.OccultationPathPoint{
{terminal, {Time: eventTime.Add(time.Minute), Longitude: 1, Latitude: 1}},
{terminal, {Time: eventTime.Add(time.Minute), Longitude: -1, Latitude: 1}},
},
}}
connectors := HorizonConnectorSegments([]basic.OccultationFootprint{footprint}, curves)
if len(connectors) != 0 {
t.Fatalf("connectors=%d, want no duplicate terminal closure", len(connectors))
}
}
func TestHorizonConnectorSegmentsSkipsCoincidentDifferentPhaseEndpoints(t *testing.T) {
eventTime := time.Date(2025, time.July, 29, 0, 0, 0, 0, time.UTC)
contactStart := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 0}
contactEnd := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 1}
shared := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 0.5}
footprint := basic.OccultationFootprint{
Boundaries: [][]basic.OccultationPathPoint{{
contactStart,
{Time: eventTime, Longitude: 2, Latitude: 0.5},
contactEnd,
}},
Polygons: [][]basic.OccultationPathPoint{{
contactStart,
{Time: eventTime, Longitude: 2, Latitude: 0.5},
contactEnd,
{Time: eventTime, Longitude: 1, Latitude: 1},
shared,
{Time: eventTime, Longitude: 1, Latitude: 0},
contactStart,
}},
}
curves := []basic.OccultationRiseSetCurve{
{Phase: basic.RiseSetPhaseStart, Direction: basic.RiseSetDirectionRise,
Segments: [][]basic.OccultationPathPoint{{shared, {Time: eventTime.Add(time.Minute), Longitude: -1, Latitude: 1}}}},
{Phase: basic.RiseSetPhaseGreatest, Direction: basic.RiseSetDirectionRise,
Segments: [][]basic.OccultationPathPoint{{shared, {Time: eventTime.Add(time.Minute), Longitude: -1, Latitude: -1}}}},
}
if connectors := HorizonConnectorSegments([]basic.OccultationFootprint{footprint}, curves); len(connectors) != 0 {
t.Fatalf("connectors=%d, want no zero-length phase pinch loop", len(connectors))
}
}
func TestHorizonConnectorSegmentsClosesSamePhaseSeparatedBranchOpening(t *testing.T) {
eventTime := time.Date(2025, time.July, 29, 0, 0, 0, 0, time.UTC)
contactStart := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 0}
contactEnd := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 3}
branchStart := basic.OccultationPathPoint{Time: eventTime, Longitude: -0.05, Latitude: 0}
branchEnd := basic.OccultationPathPoint{Time: eventTime, Longitude: -0.05, Latitude: 3}
footprint := basic.OccultationFootprint{
Boundaries: [][]basic.OccultationPathPoint{{
contactStart,
{Time: eventTime, Longitude: 2, Latitude: 1.5},
contactEnd,
}},
Polygons: [][]basic.OccultationPathPoint{{
contactStart,
{Time: eventTime, Longitude: 2, Latitude: 1.5},
contactEnd,
{Time: eventTime, Longitude: 1, Latitude: 3},
{Time: eventTime, Longitude: 1, Latitude: 0},
contactStart,
}},
}
curves := []basic.OccultationRiseSetCurve{{
Phase: basic.RiseSetPhaseStart, Direction: basic.RiseSetDirectionRise,
Segments: [][]basic.OccultationPathPoint{
{branchStart, {Time: eventTime.Add(time.Minute), Longitude: -1, Latitude: -1}},
{branchEnd, {Time: eventTime.Add(time.Minute), Longitude: -1, Latitude: 4}},
},
}}
connectors := HorizonConnectorSegments([]basic.OccultationFootprint{footprint}, curves)
if len(connectors) != 1 {
t.Fatalf("connectors=%d, want one same-phase branch-opening closure", len(connectors))
}
points := connectors[0].Points
if len(points) <= 4 {
t.Fatalf("connector points=%d, want a densified horizon closure", len(points))
}
if !sameTestOccultationPoint(points[0], branchEnd) || !sameTestOccultationPoint(points[len(points)-1], branchStart) {
t.Fatalf("connector endpoints=(%+v, %+v), want same-phase branch endpoints (%+v, %+v)",
points[0], points[len(points)-1], branchEnd, branchStart)
}
}
func TestHorizonConnectorSegmentsClosesShortTemporalHorizonGap(t *testing.T) {
startTime := time.Date(2023, time.September, 21, 10, 1, 49, 0, time.UTC)
endTime := startTime.Add(time.Minute)
start := basic.OccultationPathPoint{Time: startTime, Longitude: 10, Latitude: 20}
end := basic.OccultationPathPoint{Time: endTime, Longitude: 10.2, Latitude: 20.1}
farStart := basic.OccultationPathPoint{Time: startTime, Longitude: 30, Latitude: 20}
farEnd := basic.OccultationPathPoint{Time: endTime, Longitude: 30, Latitude: 20}
footprints := []basic.OccultationFootprint{
{
Time: startTime,
Boundaries: [][]basic.OccultationPathPoint{{start, farStart}},
Polygons: [][]basic.OccultationPathPoint{{
start, farStart,
{Time: startTime, Longitude: 30, Latitude: 19},
{Time: startTime, Longitude: 10, Latitude: 19}, start,
}},
},
{
Time: endTime,
Boundaries: [][]basic.OccultationPathPoint{{end, farEnd}},
Polygons: [][]basic.OccultationPathPoint{{
end, farEnd,
{Time: endTime, Longitude: 30, Latitude: 19},
{Time: endTime, Longitude: 10.2, Latitude: 19}, end,
}},
},
}
curves := []basic.OccultationRiseSetCurve{
{
Phase: basic.RiseSetPhaseStart, Direction: basic.RiseSetDirectionSet,
Segments: [][]basic.OccultationPathPoint{{
{Time: startTime.Add(-10 * time.Minute), Longitude: 0, Latitude: 30}, start,
}},
},
{
Phase: basic.RiseSetPhaseEnd, Direction: basic.RiseSetDirectionSet,
Segments: [][]basic.OccultationPathPoint{{
end, {Time: endTime.Add(10 * time.Minute), Longitude: 0, Latitude: 30},
}},
},
}
if connectors := HorizonConnectorSegments(footprints, curves); len(connectors) != 0 {
t.Fatalf("finite-disk connectors=%d, want no point-source temporal connector", len(connectors))
}
connectors := StarHorizonConnectorSegments(footprints, curves)
if len(connectors) != 1 {
t.Fatalf("connectors=%d, want one temporal horizon connector", len(connectors))
}
points := connectors[0].Points
if len(points) < 2 || points[0] != start || points[len(points)-1] != end {
t.Fatalf("connector endpoints=%+v -> %+v, want %+v -> %+v",
points[0], points[len(points)-1], start, end)
}
for index := 1; index < len(points); index++ {
if !points[index].Time.After(points[index-1].Time) {
t.Fatalf("connector time is not strictly increasing at %d", index)
}
}
}
func TestHorizonConnectorSegmentsMars20250729OneMinuteRiseSetStepKeepsOpeningClosure(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)
}
connectors := HorizonConnectorSegments(paths[0].PartialBandFootprints, paths[0].RiseSetCurves)
foundOpening := false
for _, connector := range connectors {
if connector.Direction != basic.RiseSetDirectionRise || len(connector.Points) < 2 {
continue
}
first := connector.Points[0]
last := connector.Points[len(connector.Points)-1]
if !mars20250729OpeningFoldEndpoint(first) || !mars20250729OpeningFoldEndpoint(last) {
continue
}
chord := DistanceKM(first, last)
if chord < 250 || chord > 650 {
t.Fatalf("opening connector chord %.1f km, want the missing same-phase branch gap", chord)
}
foundOpening = true
break
}
if !foundOpening {
t.Fatal("2025-07-29 Mars one-minute rise/set curves are missing the opening horizon closure")
}
}
func sameTestOccultationPoint(first, second basic.OccultationPathPoint) bool {
return first.Time.Equal(second.Time) &&
first.Longitude == second.Longitude &&
first.Latitude == second.Latitude
}
func mars20250729OpeningFoldEndpoint(point basic.OccultationPathPoint) bool {
return point.Longitude >= -128.5 && point.Longitude <= -126 &&
point.Latitude >= -48.5 && point.Latitude <= -42
}