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

316 lines
11 KiB
Go

package geodata
import (
"math"
"testing"
)
func TestPolylineSegmentsTreatsExactAntimeridianAsOneMeridian(t *testing.T) {
segments := PolylineSegments([]GeoPoint{
{Longitude: -180, Latitude: 10},
{Longitude: 180, Latitude: 20},
}, ClipView{Projection: ProjectionEquirectangular})
if len(segments) != 1 || len(segments[0]) != 2 {
t.Fatalf("exact-antimeridian line segments = %#v", segments)
}
if segments[0][0].Longitude != segments[0][1].Longitude {
t.Fatalf("exact-antimeridian line spans %.1f degrees",
math.Abs(segments[0][1].Longitude-segments[0][0].Longitude))
}
}
func TestPolylineSegmentsInterpolatesAntimeridianOnSphere(t *testing.T) {
segments := PolylineSegments([]GeoPoint{
{Longitude: 170, Latitude: 80},
{Longitude: -170, Latitude: 80},
}, ClipView{Projection: ProjectionEquirectangular})
if len(segments) != 2 {
t.Fatalf("high-latitude antimeridian line segments = %d, want 2", len(segments))
}
firstCrossing := segments[0][len(segments[0])-1]
secondCrossing := segments[1][0]
if firstCrossing.Longitude != 180 || secondCrossing.Longitude != -180 {
t.Fatalf("unexpected antimeridian crossings: %+v %+v", firstCrossing, secondCrossing)
}
if math.Abs(firstCrossing.Latitude-secondCrossing.Latitude) > 1e-10 {
t.Fatalf("split crossings disagree in latitude: %.12f != %.12f",
firstCrossing.Latitude, secondCrossing.Latitude)
}
// The shorter great-circle arc bends poleward. Linear longitude/latitude
// interpolation would incorrectly leave the crossing at exactly 80 degrees.
if firstCrossing.Latitude <= 80.1 || firstCrossing.Latitude >= 81 {
t.Fatalf("great-circle crossing latitude = %.9f, want (80.1, 81)", firstCrossing.Latitude)
}
}
func TestPolarPolylineClippingUsesSphericalEquatorIntersection(t *testing.T) {
segments := PolylineSegments([]GeoPoint{
{Longitude: 0, Latitude: -10},
{Longitude: 90, Latitude: 80},
}, ClipView{Projection: ProjectionNorthPolar})
if len(segments) != 1 || len(segments[0]) != 2 {
t.Fatalf("north-polar clipped line = %#v", segments)
}
crossing := segments[0][0]
if math.Abs(crossing.Latitude) > 1e-10 {
t.Fatalf("equator crossing latitude = %.12f", crossing.Latitude)
}
// The great circle crosses near 2 degrees, not at the 45 degree
// arithmetic interpolation used by planar clipping.
if crossing.Longitude < 1 || crossing.Longitude > 3 {
t.Fatalf("spherical equator crossing longitude = %.9f, want (1, 3)", crossing.Longitude)
}
}
func TestPolygonFragmentsDropsExactAntimeridianZeroAreaDuplicate(t *testing.T) {
fragments := PolygonFragments([]GeoPoint{
{Longitude: -180, Latitude: 15},
{Longitude: 180, Latitude: 14},
{Longitude: 150, Latitude: 13},
{Longitude: 150, Latitude: -12},
{Longitude: 180, Latitude: -11},
{Longitude: -180, Latitude: -10},
}, ClipView{Projection: ProjectionEquirectangular})
if len(fragments) != 1 {
t.Fatalf("exact-antimeridian polygon produced %d fragments, want 1", len(fragments))
}
if math.Abs(signedPolygonArea(fragments[0])) < 1e-12 {
t.Fatal("exact-antimeridian polygon fragment has zero area")
}
}
func TestPolygonFragmentsClosesPoleEnclosingRingsAtEquirectangularMapEdge(t *testing.T) {
for _, test := range []struct {
name string
boundaryLatitude float64
insideLatitude float64
outsideLatitude float64
}{
{name: "north", boundaryLatitude: 70, insideLatitude: 89, outsideLatitude: -89},
{name: "south", boundaryLatitude: -70, insideLatitude: -89, outsideLatitude: 89},
} {
t.Run(test.name, func(t *testing.T) {
polygon := []GeoPoint{
{Longitude: -135, Latitude: test.boundaryLatitude},
{Longitude: -45, Latitude: test.boundaryLatitude},
{Longitude: 45, Latitude: test.boundaryLatitude},
{Longitude: 135, Latitude: test.boundaryLatitude},
{Longitude: -135, Latitude: test.boundaryLatitude},
}
fragments := PolygonFragments(polygon, ClipView{Projection: ProjectionEquirectangular})
if len(fragments) == 0 {
t.Fatal("pole-enclosing polygon produced no fragments")
}
for _, longitude := range []float64{-150, -90, 0, 90, 150} {
if !polygonFragmentsContain(fragments, longitude, test.insideLatitude) {
t.Fatalf("pole cap does not contain %.0f, %.0f", longitude, test.insideLatitude)
}
if polygonFragmentsContain(fragments, longitude, test.outsideLatitude) {
t.Fatalf("pole cap incorrectly contains %.0f, %.0f", longitude, test.outsideLatitude)
}
}
})
}
}
func TestPolygonFragmentsPoleClosurePreservesAntimeridianSeam(t *testing.T) {
for _, test := range []struct {
name string
poleLatitude float64
boundary float64
}{
{name: "north", poleLatitude: 90, boundary: 50},
{name: "south", poleLatitude: -90, boundary: -50},
} {
t.Run(test.name, func(t *testing.T) {
polygon := []GeoPoint{
{Longitude: 150, Latitude: test.boundary},
{Longitude: 180, Latitude: 5},
{Longitude: 120, Latitude: -5},
{Longitude: 30, Latitude: 15},
{Longitude: -60, Latitude: 25},
{Longitude: -150, Latitude: 35},
}
if test.poleLatitude < 0 {
polygon = mirrorGeoPointLatitudes(polygon)
}
fragments := PolygonFragments(polygon, ClipView{Projection: ProjectionEquirectangular})
if len(fragments) == 0 || len(fragments) > 2 {
t.Fatalf("pole polygon fragments=%d, want one or two map-edge fragments", len(fragments))
}
for _, longitude := range []float64{149, 151} {
latitude := 60.0
if test.poleLatitude < 0 {
latitude = -60
}
if !polygonFragmentsContain(fragments, longitude, latitude) {
t.Fatalf("pole closure lost %.0f, %.0f beside its internal seam", longitude, latitude)
}
}
})
}
}
func TestPolygonFragmentsPoleClosureAnchorsInteriorStartAtAntimeridian(t *testing.T) {
// This ring winds around the north pole but starts in the Bering Sea. The
// pole closure must use the map seam, not a meridian through that start
// vertex.
fragments := PolygonFragments([]GeoPoint{
{Longitude: -172, Latitude: 50},
{Longitude: -100, Latitude: 70},
{Longitude: 0, Latitude: 80},
{Longitude: 100, Latitude: 70},
{Longitude: 188, Latitude: 50},
}, ClipView{Projection: ProjectionEquirectangular})
if len(fragments) == 0 {
t.Fatal("pole-winding ring produced no fragments")
}
for _, fragment := range fragments {
for index, first := range fragment {
second := fragment[(index+1)%len(fragment)]
if math.Abs(first.Longitude-second.Longitude) <= 1e-9 &&
math.Abs(first.Latitude-second.Latitude) > 20 &&
math.Abs(first.Longitude) < 179.999 {
t.Fatalf("interior pole closure at %.3f: %+v -> %+v", first.Longitude, first, second)
}
}
}
}
func TestPolygonFragmentsPoleClosureDoesNotDependOnVertexDensity(t *testing.T) {
base := []GeoPoint{
{Longitude: -135, Latitude: 70},
{Longitude: -45, Latitude: 70},
{Longitude: 45, Latitude: -60},
{Longitude: 135, Latitude: -60},
}
dense := []GeoPoint{
{Longitude: -135, Latitude: 70},
{Longitude: -45, Latitude: 70},
{Longitude: 45, Latitude: -60},
{Longitude: 55, Latitude: -60},
{Longitude: 65, Latitude: -60},
{Longitude: 75, Latitude: -60},
{Longitude: 85, Latitude: -60},
{Longitude: 95, Latitude: -60},
{Longitude: 105, Latitude: -60},
{Longitude: 115, Latitude: -60},
{Longitude: 125, Latitude: -60},
{Longitude: 135, Latitude: -60},
}
for _, test := range []struct {
name string
base []GeoPoint
dense []GeoPoint
}{
{name: "north", base: base, dense: dense},
{name: "south", base: mirrorGeoPointLatitudes(base), dense: mirrorGeoPointLatitudes(dense)},
} {
t.Run(test.name, func(t *testing.T) {
baseFragments := PolygonFragments(test.base, ClipView{Projection: ProjectionEquirectangular})
denseFragments := PolygonFragments(test.dense, ClipView{Projection: ProjectionEquirectangular})
for _, point := range []GeoPoint{
{Longitude: 0, Latitude: 89},
{Longitude: 0, Latitude: -89},
} {
baseContains := polygonFragmentsContain(baseFragments, point.Longitude, point.Latitude)
denseContains := polygonFragmentsContain(denseFragments, point.Longitude, point.Latitude)
if denseContains != baseContains {
t.Fatalf("densifying a boundary changed containment at %.0f, %.0f: base=%v dense=%v",
point.Longitude, point.Latitude, baseContains, denseContains)
}
}
})
}
}
func TestPolygonFragmentsPolarIndentationPreservesInterior(t *testing.T) {
// A polar boundary crosses the map seam three times. Closing through the
// lowest crossing fills the excluded indentation on the east side.
polygon := []GeoPoint{
{Longitude: -172, Latitude: 50}, {Longitude: -179, Latitude: 54},
{Longitude: 175, Latitude: 60}, {Longitude: 173, Latitude: 65},
{Longitude: 175, Latitude: 70}, {Longitude: -178, Latitude: 75},
{Longitude: -140, Latitude: 79}, {Longitude: -70, Latitude: 68},
{Longitude: 0, Latitude: 15}, {Longitude: 90, Latitude: 0},
{Longitude: 140, Latitude: -23}, {Longitude: 175, Latitude: 30},
{Longitude: -179, Latitude: 40},
}
for _, sign := range []float64{1, -1} {
points := append([]GeoPoint(nil), polygon...)
for i := range points {
points[i].Latitude *= sign
}
for offset := range points {
rotated := append(append([]GeoPoint(nil), points[offset:]...), points[:offset]...)
for _, reverse := range []bool{false, true} {
if reverse {
for i, j := 0, len(rotated)-1; i < j; i, j = i+1, j-1 {
rotated[i], rotated[j] = rotated[j], rotated[i]
}
}
fragments := PolygonFragments(rotated, ClipView{Projection: ProjectionEquirectangular})
for _, point := range []GeoPoint{
{Longitude: 179, Latitude: sign * 65}, {Longitude: 175, Latitude: sign * 65},
{Longitude: 170, Latitude: sign * 65}, {Longitude: -179, Latitude: sign * 65},
{Longitude: 179, Latitude: sign * 85}, {Longitude: 0, Latitude: sign * 85},
} {
want := SphericalPolygonsContainPoints([][]GeoPoint{rotated}, []GeoPoint{point})[0]
if got := polygonFragmentsContain(fragments, point.Longitude, point.Latitude); got != want {
t.Fatalf("sign=%g offset=%d reversed=%v point=%+v: split=%v sphere=%v", sign, offset, reverse, point, got, want)
}
}
}
}
}
}
func mirrorGeoPointLatitudes(points []GeoPoint) []GeoPoint {
mirrored := make([]GeoPoint, len(points))
for index, point := range points {
mirrored[index] = GeoPoint{Longitude: point.Longitude, Latitude: -point.Latitude}
}
return mirrored
}
func polygonFragmentsContain(fragments [][]GeoPoint, longitude, latitude float64) bool {
for _, polygon := range fragments {
inside := false
for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 {
a, b := polygon[previous], polygon[current]
if (a.Latitude > latitude) == (b.Latitude > latitude) {
continue
}
intersection := a.Longitude +
(latitude-a.Latitude)*(b.Longitude-a.Longitude)/(b.Latitude-a.Latitude)
if intersection > longitude {
inside = !inside
}
}
if inside {
return true
}
}
return false
}
func TestClosePoleEnclosingPolygonRequiresSingleWinding(t *testing.T) {
open := []GeoPoint{
{Longitude: 0, Latitude: 0},
{Longitude: 1, Latitude: 0},
{Longitude: 1, Latitude: 1},
}
if closed := closePoleEnclosingPolygon(open); len(closed) != len(open) {
t.Fatalf("a ring without pole winding gained %d closure points", len(closed)-len(open))
}
winding := []GeoPoint{
{Longitude: -135, Latitude: 70},
{Longitude: -45, Latitude: 70},
{Longitude: 45, Latitude: 70},
{Longitude: 135, Latitude: 70},
}
if closed := closePoleEnclosingPolygon(winding); len(closed) <= len(winding) {
t.Fatalf("a single-winding ring kept %d points, want a pole closure", len(closed))
}
}