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

198 lines
8.2 KiB
Go

package occultationgeo
import (
"errors"
"testing"
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/geodata"
)
// 分裂轮廓场景的接触线网互不连续(离扫掠边界上千公里),无法充当见证;该分支的
// 权威性来自"单分量 + 同一批瞬时可见面并集"的构造契约,此处把这条契约钉住。
func TestSplitContourBandUsesSingleComponentSweepContract(t *testing.T) {
path := saturn20250105Path(t)
if len(path.PartialBandContours) <= 2 {
t.Fatalf("contours=%d, want the split-contour branch input", len(path.PartialBandContours))
}
swept, err := footprintSweepPolygons(
path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit,
)
if err != nil {
t.Fatal(err)
}
if len(swept) != 1 {
t.Fatalf("sweep components=%d, want the single-component contract", len(swept))
}
if occultationSweepWitnessedByContactContours(swept, path.PartialBandContours) {
t.Fatal("fragmented contact contours unexpectedly witness the sweep; revisit the split-contour branch")
}
polygons, authoritative, err := VisibleBandPolygonsFromContours(
path.PartialBandFootprints, path.PartialBandContours,
path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves,
)
if err != nil || !authoritative || len(polygons) != 1 {
t.Fatalf("split-contour band polygons=%d authoritative=%v err=%v", len(polygons), authoritative, err)
}
}
func TestHorizonConnectorSegmentsFiltersBranchesOutsideLimits(t *testing.T) {
footprints, curves := horizonConnectorTestOpeningFootprint()
if got := HorizonConnectorSegments(footprints, curves); len(got) != 1 {
t.Fatalf("connectors without limits=%d, want one baseline closure", len(got))
}
nearLimit := []basic.OccultationPathPoint{
{Longitude: 0, Latitude: 0},
{Longitude: 0, Latitude: 3},
}
if got := HorizonConnectorSegments(footprints, curves, nearLimit); len(got) != 1 {
t.Fatalf("connectors with an attached limit=%d, want the closure kept", len(got))
}
farLimit := []basic.OccultationPathPoint{
{Longitude: 60, Latitude: 0},
{Longitude: 60, Latitude: 3},
}
if got := HorizonConnectorSegments(footprints, curves, farLimit); len(got) != 0 {
t.Fatalf("connectors with a detached limit=%d, want the branch filtered out", len(got))
}
}
func TestHorizonConnectorSegmentsKeepsSamePhaseBranchOpeningWithLimits(t *testing.T) {
eventTime := horizonConnectorTestEventTime()
contactStart := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 0}
contactEnd := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 1}
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.2},
{Time: eventTime, Longitude: 1, Latitude: -0.2},
contactStart,
}},
}
for _, test := range []struct {
name string
second basic.OccultationPathPoint
wantCount int
}{
{name: "separated-branch", second: basic.OccultationPathPoint{Time: eventTime, Longitude: 0.3, Latitude: 0.5}, wantCount: 1},
{name: "same-phase-fold", second: basic.OccultationPathPoint{Time: eventTime, Longitude: 0.02, Latitude: 0.5}, wantCount: 0},
} {
t.Run(test.name, func(t *testing.T) {
curves := []basic.OccultationRiseSetCurve{{
Phase: basic.RiseSetPhaseStart, Direction: basic.RiseSetDirectionRise,
Segments: [][]basic.OccultationPathPoint{
{{Time: eventTime, Longitude: 0, Latitude: 0.5}, {Time: eventTime, Longitude: -2, Latitude: 0.5}},
{test.second, {Time: eventTime, Longitude: -2, Latitude: 0.6}},
},
}}
if got := HorizonConnectorSegments([]basic.OccultationFootprint{footprint}, curves); len(got) != test.wantCount {
t.Fatalf("connectors=%d, want %d", len(got), test.wantCount)
}
})
}
}
// 通用并集失败后恢复阶梯必须真正重试,而不是直接返回未合并的面。
func TestOccultationRetryOpenSweepUnionRecoversUnion(t *testing.T) {
footprint := basic.OccultationFootprint{
Closed: true,
Polygons: [][]basic.OccultationPathPoint{{
{Longitude: 0, Latitude: 0}, {Longitude: 1, Latitude: 0},
{Longitude: 1, Latitude: 1}, {Longitude: 0, Latitude: 1}, {Longitude: 0, Latitude: 0},
}},
}
broken := [][]geodata.GeoPoint{{{Longitude: 0, Latitude: 0}, {Longitude: 1, Latitude: 1}}}
if _, err := geodata.UnionPolygons(broken); err == nil {
t.Skip("primary union no longer fails on a two-point ring")
}
original := errors.New("primary union failed")
merged, err := occultationRetryOpenSweepUnion(
broken, []basic.OccultationFootprint{footprint}, nil, nil, original,
)
if err != nil {
t.Fatalf("recovery ladder returned the primary error: %v", err)
}
if len(merged) != 1 || len(merged[0]) < 4 {
t.Fatalf("recovered polygons=%d, want the closed instantaneous face", len(merged))
}
if _, err := occultationRetryOpenSweepUnion(broken, nil, nil, nil, original); !errors.Is(err, original) {
t.Fatalf("ladder without usable faces returned %v, want the primary error", err)
}
}
// 逐边修复已把残差压小时必须保留细化结果,不能退回未修复的 child。
func TestConstrainPolygonsWithinKeepsPartialEdgeRepair(t *testing.T) {
// 凹槽父带:子环顶点都在父带内,只有跨越凹槽的长边落在外面,顶点吸附无从下手。
parent := [][]geodata.GeoPoint{{
{Longitude: -2, Latitude: -3}, {Longitude: 2, Latitude: -3},
{Longitude: 2, Latitude: 2}, {Longitude: 1.2, Latitude: 2},
{Longitude: 1.2, Latitude: -0.2}, {Longitude: -1.2, Latitude: -0.2},
{Longitude: -1.2, Latitude: 2}, {Longitude: -2, Latitude: 2},
{Longitude: -2, Latitude: -3},
}}
child := [][]geodata.GeoPoint{{
{Longitude: -1.5, Latitude: 0.2}, {Longitude: 1.5, Latitude: 0.2},
{Longitude: 1.5, Latitude: 0.4}, {Longitude: -1.5, Latitude: 0.4},
{Longitude: -1.5, Latitude: 0.2},
}}
initial := geodata.SphericalPolygonsPathMissDistanceKM(parent, child, true)
if initial <= 10 {
t.Fatalf("test child misses the parent by only %.3f km, want an edge-only breach", initial)
}
repaired := ConstrainPolygonsWithin(parent, differentialCopyPolygons(child))
repairedMiss := geodata.SphericalPolygonsPathMissDistanceKM(parent, repaired, true)
if repairedMiss >= initial*0.9 {
t.Fatalf("edge repair did not substantially reduce the breach: %.3f km -> %.3f km", initial, repairedMiss)
}
if len(repaired) != 1 || len(repaired[0]) <= len(child[0]) {
t.Fatalf("repaired rings=%d points=%d, want the densified ring", len(repaired), len(repaired[0]))
}
discarded := constrainPolygonsWithinReference(parent, differentialCopyPolygons(child))
if miss := geodata.SphericalPolygonsPathMissDistanceKM(parent, discarded, true); miss != initial {
t.Fatalf("reference implementation unexpectedly repaired the child to %.3f km", miss)
}
}
func horizonConnectorTestEventTime() time.Time {
return time.Date(2025, time.July, 29, 0, 0, 0, 0, time.UTC)
}
func horizonConnectorTestOpeningFootprint() ([]basic.OccultationFootprint, []basic.OccultationRiseSetCurve) {
eventTime := horizonConnectorTestEventTime()
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}
footprints := []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}},
},
}}
return footprints, curves
}