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

263 lines
9.4 KiB
Go

package solarclosure
import (
"math"
"testing"
"b612.me/astro/internal/geodata"
)
var testSubsolar = geodata.GeoPoint{Longitude: 12, Latitude: 34}
func testTerminator() []geodata.GeoPoint {
return Terminator(testSubsolar)
}
// testFootprint 取地平圈上一段采样点当开放边界,两端擦地点各外扩两个采样点。
func testFootprint() Footprint {
circle := testTerminator()
return Footprint{
Boundaries: [][]geodata.GeoPoint{append([]geodata.GeoPoint(nil), circle[100:131]...)},
HorizonEnds: []geodata.GeoPoint{circle[98], circle[133]},
Subsolar: testSubsolar,
}
}
func angleFromSubsolar(point geodata.GeoPoint) float64 {
cosine := math.Sin(testSubsolar.Latitude*math.Pi/180)*math.Sin(point.Latitude*math.Pi/180) +
math.Cos(testSubsolar.Latitude*math.Pi/180)*math.Cos(point.Latitude*math.Pi/180)*
math.Cos((point.Longitude-testSubsolar.Longitude)*math.Pi/180)
return math.Acos(math.Max(-1, math.Min(1, cosine))) * 180 / math.Pi
}
func TestTerminatorPointsStayOnHorizonCircle(t *testing.T) {
circle := testTerminator()
if len(circle) != 360 {
t.Fatalf("terminator points=%d, want 360", len(circle))
}
for index, point := range circle {
if angle := angleFromSubsolar(point); math.Abs(angle-90) > 1e-9 {
t.Fatalf("point %d is %.12f degrees from the subsolar point, want 90", index, angle)
}
}
}
func TestHorizonEndsPairsBothEndsByBoundaryStart(t *testing.T) {
circle := testTerminator()
for _, test := range []struct {
name string
ends []geodata.GeoPoint
want []geodata.GeoPoint
}{
{name: "in order", ends: []geodata.GeoPoint{circle[98], circle[133]},
want: []geodata.GeoPoint{circle[98], circle[133]}},
{name: "reversed", ends: []geodata.GeoPoint{circle[133], circle[98]},
want: []geodata.GeoPoint{circle[98], circle[133]}},
} {
footprint := testFootprint()
footprint.HorizonEnds = test.ends
got := HorizonEnds(footprint)
if len(got) != 2 {
t.Fatalf("%s: ends=%d, want 2", test.name, len(got))
}
for index := range got {
if got[index] != test.want[index] {
t.Fatalf("%s: ends[%d]=%v, want %v", test.name, index, got[index], test.want[index])
}
}
}
}
func TestHorizonEndsRejectsIncompleteInput(t *testing.T) {
circle := testTerminator()
base := testFootprint()
for _, test := range []struct {
name string
footprint Footprint
}{
{name: "no ends", footprint: Footprint{Boundaries: base.Boundaries}},
{name: "one end", footprint: Footprint{Boundaries: base.Boundaries,
HorizonEnds: []geodata.GeoPoint{circle[98]}}},
{name: "three ends", footprint: Footprint{Boundaries: base.Boundaries,
HorizonEnds: []geodata.GeoPoint{circle[98], circle[133], circle[134]}}},
{name: "no boundaries", footprint: Footprint{HorizonEnds: base.HorizonEnds}},
{name: "empty first segment", footprint: Footprint{
Boundaries: [][]geodata.GeoPoint{{}}, HorizonEnds: base.HorizonEnds}},
} {
if ends := HorizonEnds(test.footprint); ends != nil {
t.Fatalf("%s: ends=%v, want nil", test.name, ends)
}
if ExactHorizon(test.footprint) {
t.Fatalf("%s: ExactHorizon=true, want false", test.name)
}
}
if !ExactHorizon(base) {
t.Fatal("ExactHorizon=false for a footprint carrying two grazing points")
}
}
func TestRingClosesOpenBoundaryAtGrazingPoints(t *testing.T) {
footprint := testFootprint()
curve := Curve(footprint)
if len(curve) != 31 {
t.Fatalf("curve points=%d, want 31", len(curve))
}
ends := HorizonEnds(footprint)
ring, boundary, ok := Ring(footprint, true)
if !ok {
t.Fatal("Ring reported an unusable boundary")
}
if len(ring) <= len(curve) {
t.Fatalf("ring points=%d, want more than the %d boundary points", len(ring), len(curve))
}
for index, point := range curve {
if ring[index] != point {
t.Fatalf("ring[%d]=%v, want the boundary point %v", index, ring[index], point)
}
}
if ring[len(curve)] != ends[1] {
t.Fatalf("ring[%d]=%v, want the trailing grazing point %v", len(curve), ring[len(curve)], ends[1])
}
if ring[len(ring)-1] != ends[0] {
t.Fatalf("ring ends at %v, want the leading grazing point %v", ring[len(ring)-1], ends[0])
}
if len(boundary) != len(curve)+2 || boundary[0] != ends[0] ||
boundary[len(boundary)-1] != ends[1] {
t.Fatalf("boundary=%d points starting %v ending %v", len(boundary), boundary[0],
boundary[len(boundary)-1])
}
for index, point := range boundary[1 : len(boundary)-1] {
if point != curve[index] {
t.Fatalf("boundary[%d]=%v, want %v", index+1, point, curve[index])
}
}
for index, point := range ring {
if angle := angleFromSubsolar(point); math.Abs(angle-90) > 1e-9 {
t.Fatalf("ring[%d] is %.12f degrees from the subsolar point, want 90", index, angle)
}
}
}
func TestRingFallsBackToSampledTerminatorArc(t *testing.T) {
footprint := testFootprint()
curve := Curve(footprint)
for _, test := range []struct {
name string
input Footprint
exact bool
}{
{name: "no grazing points", input: Footprint{
Boundaries: footprint.Boundaries, Subsolar: testSubsolar}, exact: true},
{name: "approximate requested", input: footprint, exact: false},
} {
ring, boundary, ok := Ring(test.input, test.exact)
if !ok {
t.Fatalf("%s: Ring reported an unusable boundary", test.name)
}
if len(ring) <= len(curve) || ring[len(ring)-1] != curve[0] {
t.Fatalf("%s: ring closes at %v, want the boundary start %v",
test.name, ring[len(ring)-1], curve[0])
}
for index, point := range curve {
if ring[index] != point {
t.Fatalf("%s: ring[%d]=%v, want %v", test.name, index, ring[index], point)
}
}
if len(boundary) != len(curve) {
t.Fatalf("%s: boundary points=%d, want the %d boundary points", test.name, len(boundary), len(curve))
}
for index, point := range ring {
if angle := angleFromSubsolar(point); math.Abs(angle-90) > 1e-9 {
t.Fatalf("%s: ring[%d] is %.12f degrees from the subsolar point, want 90",
test.name, index, angle)
}
}
}
}
func TestRingDegenerateInputs(t *testing.T) {
circle := testTerminator()
closed := []geodata.GeoPoint{circle[10], circle[40], circle[70], circle[10]}
for _, test := range []struct {
name string
footprint Footprint
wantOK bool
wantRing int
}{
{name: "no boundary", footprint: Footprint{Closed: true}, wantOK: false},
{name: "closed triangle", footprint: Footprint{Boundaries: [][]geodata.GeoPoint{
{circle[10], circle[40], circle[70]}}, Closed: true}, wantOK: true, wantRing: 3},
{name: "closed with repeated point", footprint: Footprint{
Boundaries: [][]geodata.GeoPoint{closed}, Closed: true}, wantOK: true, wantRing: 3},
{name: "closed segment", footprint: Footprint{Boundaries: [][]geodata.GeoPoint{
{circle[10], circle[40]}}, Closed: true}, wantOK: false},
{name: "single open point", footprint: Footprint{Boundaries: [][]geodata.GeoPoint{
{circle[10]}}}, wantOK: true, wantRing: 1},
{name: "open segment without grazing points", footprint: Footprint{
Boundaries: [][]geodata.GeoPoint{{circle[10], circle[40]}},
Subsolar: testSubsolar}, wantOK: true},
} {
ring, _, ok := Ring(test.footprint, true)
if ok != test.wantOK {
t.Fatalf("%s: ok=%v, want %v", test.name, ok, test.wantOK)
}
if test.wantRing > 0 && len(ring) != test.wantRing {
t.Fatalf("%s: ring points=%d, want %d", test.name, len(ring), test.wantRing)
}
}
}
func TestHorizonRingAcceptsPrejoinedCurve(t *testing.T) {
footprint := testFootprint()
curve := Curve(footprint)
ring, boundary := HorizonRing(footprint, curve)
ends := HorizonEnds(footprint)
if len(boundary) != len(curve)+2 || boundary[0] != ends[0] ||
boundary[len(boundary)-1] != ends[1] {
t.Fatalf("boundary=%d points, want the leading and trailing grazing points around %d points",
len(boundary), len(curve))
}
if len(ring) < len(curve)+2 || ring[len(curve)] != ends[1] || ring[len(ring)-1] != ends[0] {
t.Fatalf("ring=%d points, want the grazing points closing %d boundary points",
len(ring), len(curve))
}
}
func TestBandPolygonsSelectsFaceCoveredByFootprint(t *testing.T) {
box := [][2]geodata.GeoPoint{
{{Longitude: 0, Latitude: 0}, {Longitude: 10, Latitude: 0}},
{{Longitude: 10, Latitude: 0}, {Longitude: 10, Latitude: 10}},
{{Longitude: 10, Latitude: 10}, {Longitude: 0, Latitude: 10}},
{{Longitude: 0, Latitude: 10}, {Longitude: 0, Latitude: 0}},
}
contours := make([][]geodata.GeoPoint, 0, len(box))
for _, edge := range box {
contours = append(contours, []geodata.GeoPoint{edge[0], edge[1]})
}
inside := []geodata.GeoPoint{
{Longitude: 4, Latitude: 4}, {Longitude: 6, Latitude: 4},
{Longitude: 6, Latitude: 6}, {Longitude: 4, Latitude: 6},
}
footprints := []Footprint{{Boundaries: [][]geodata.GeoPoint{inside}, Closed: true}}
polygons, ok := BandPolygons(contours, nil, footprints, true, SnapDistanceKM)
if !ok || len(polygons) == 0 {
t.Fatalf("band polygons unavailable: ok=%v count=%d", ok, len(polygons))
}
probes := []geodata.GeoPoint{{Longitude: 5, Latitude: 5}, {Longitude: 1, Latitude: 9}}
contained := geodata.SphericalPolygonsContainPoints(polygons, probes)
if len(contained) != 2 || !contained[0] || !contained[1] {
t.Fatalf("containment=%v, want the whole box face selected", contained)
}
outside := geodata.SphericalPolygonsContainPoints(polygons,
[]geodata.GeoPoint{{Longitude: 15, Latitude: 5}})
if outside[0] {
t.Fatal("a point outside the boundary network was selected")
}
}
func TestBandPolygonsRejectsEmptyBoundaryNetwork(t *testing.T) {
if polygons, ok := BandPolygons(nil, nil, nil, true, SnapDistanceKM); ok || polygons != nil {
t.Fatalf("polygons=%d ok=%v, want no polygons", len(polygons), ok)
}
}