feat: 新增月掩与日月食地理绘图并提升观测计算精度

- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算
- 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出
- 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口
- 扩展日食中心线、南北界及偏食足迹采样,支持极区投影
- 修正站心时角、月出月落、月球视半径、折射和恒星自行计算
- 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
This commit is contained in:
2026-08-06 12:00:56 +08:00
parent 25dc7ac0bc
commit 9ee2163cc7
137 changed files with 21770 additions and 1746 deletions
+104 -2
View File
@@ -3,6 +3,7 @@ package basic
import (
"math"
"testing"
"time"
)
func TestSolarEclipseCentralPathMatchesGlobalGreatest(t *testing.T) {
@@ -128,6 +129,9 @@ func TestSolarEclipsePartialFootprintsIncludeGreatest(t *testing.T) {
if !foundGreatest {
t.Fatalf("partial footprints should include greatest eclipse JDE %.12f", global.GreatestEclipse)
}
if footprints.Footprints[0].Closed {
t.Fatal("grazing first footprint must remain open for horizon closure")
}
}
func TestSolarEclipsePartialFootprintsWorkForPartialOnlyEclipse(t *testing.T) {
@@ -147,6 +151,101 @@ func TestSolarEclipsePartialFootprintsWorkForPartialOnlyEclipse(t *testing.T) {
}
}
func TestSolarEclipseShadowContactsAgainstNASA2012Baseline(t *testing.T) {
result := SolarEclipsePartialFootprints(
solarEclipseUTToTTJDE(time.Date(2012, 5, 20, 0, 0, 0, 0, time.UTC)),
SolarEclipsePartialFootprintOptions{
StepDays: 30.0 / 1440.0,
BoundaryPoints: 72,
CentralShadowStepDays: 10.0 / 1440.0,
},
)
baseline := []struct {
name string
point SolarEclipsePathPoint
want time.Time
}{
{"P1", result.P1, time.Date(2012, 5, 20, 20, 56, 7, 0, time.UTC)},
{"P4", result.P4, time.Date(2012, 5, 21, 2, 49, 21, 500000000, time.UTC)},
{"U1", result.U1, time.Date(2012, 5, 20, 22, 6, 16, 600000000, time.UTC)},
{"U2", result.U2, time.Date(2012, 5, 20, 22, 11, 46, 400000000, time.UTC)},
{"U3", result.U3, time.Date(2012, 5, 21, 1, 33, 42, 800000000, time.UTC)},
{"U4", result.U4, time.Date(2012, 5, 21, 1, 39, 11, 200000000, time.UTC)},
}
for _, contact := range baseline {
if contact.point.JDE == 0 {
t.Fatalf("%s contact is absent", contact.name)
}
assertLocalSolarEclipseJDEClose(
t,
contact.name,
contact.point.JDE,
solarEclipseUTToTTJDE(contact.want),
3*time.Second,
)
if math.Abs(contact.point.SunAltitude) > 0.01 {
t.Fatalf("%s Sun altitude = %.9f degrees, want horizon contact", contact.name, contact.point.SunAltitude)
}
}
if result.P2.JDE != 0 || result.P3.JDE != 0 {
t.Fatalf("2012 eclipse unexpectedly has P2/P3 contacts: P2=%+v P3=%+v", result.P2, result.P3)
}
if len(result.CentralShadowFootprints) == 0 {
t.Fatal("expected sampled central-shadow footprints")
}
if math.Abs(result.CentralShadowStepDays-10.0/1440.0) > 1e-12 {
t.Fatalf("central shadow step = %.12f days, want ten minutes", result.CentralShadowStepDays)
}
for _, footprint := range result.CentralShadowFootprints {
if len(footprint.Boundaries) == 0 {
t.Fatalf("central-shadow footprint at %.12f has no boundary", footprint.JDE)
}
}
}
func TestSolarEclipseShadowContactsIncludeP2P3WhenPenumbraEntersEarthDisk(t *testing.T) {
result := SolarEclipsePartialFootprints(
solarEclipseUTToTTJDE(time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC)),
SolarEclipsePartialFootprintOptions{StepDays: 30.0 / 1440.0, BoundaryPoints: 36},
)
for name, contacts := range map[string][]SolarEclipsePathPoint{
"penumbral": {result.P1, result.P2, result.P3, result.P4},
"central": {result.U1, result.U2, result.U3, result.U4},
} {
for index, contact := range contacts {
if contact.JDE == 0 {
t.Fatalf("%s contact %d is absent", name, index)
}
if index > 0 && !(contacts[index-1].JDE < contact.JDE) {
t.Fatalf("%s contacts out of order at %d: %.12f >= %.12f", name, index, contacts[index-1].JDE, contact.JDE)
}
}
}
if !(result.P1.JDE < result.U1.JDE && result.U4.JDE < result.P4.JDE) {
t.Fatalf("central shadow contacts must lie inside partial phase: P1=%v U1=%v U4=%v P4=%v",
result.P1.JDE, result.U1.JDE, result.U4.JDE, result.P4.JDE)
}
if result.CentralShadowFootprints != nil || result.CentralShadowStepDays != 0 {
t.Fatal("central-shadow footprints must remain disabled by default")
}
}
func TestSolarEclipsePartialBoundarySegmentsRemainClosedAcrossAntimeridian(t *testing.T) {
samples := []solarEclipsePartialBoundarySample{
{point: SolarEclipsePathPoint{Longitude: 170, Latitude: 20}, ok: true},
{point: SolarEclipsePathPoint{Longitude: -170, Latitude: 25}, ok: true},
{point: SolarEclipsePathPoint{Longitude: -160, Latitude: 10}, ok: true},
{point: SolarEclipsePathPoint{Longitude: 160, Latitude: 5}, ok: true},
}
boundaries, closed := solarEclipsePartialBoundarySegments(samples)
if !closed {
t.Fatal("complete spherical boundary must remain closed after antimeridian splitting")
}
if len(boundaries) < 2 {
t.Fatalf("expected antimeridian split, got %d boundary segment(s)", len(boundaries))
}
}
func TestSolarEclipsePartialFootprintsNoEvent(t *testing.T) {
footprints := SolarEclipsePartialFootprints(JDECalc(2023, 5, 15), SolarEclipsePartialFootprintOptions{})
@@ -180,8 +279,11 @@ func assertSolarEclipseFootprintClosedFlag(t *testing.T, footprint SolarEclipseP
if !footprint.Closed {
return
}
if len(footprint.Boundaries) != 1 {
t.Fatalf("closed footprint should have one boundary: got %d", len(footprint.Boundaries))
if len(footprint.Boundaries) == 0 {
t.Fatal("closed footprint has no boundaries")
}
if len(footprint.Boundaries) > 1 {
return
}
boundary := footprint.Boundaries[0]
if len(boundary) < 2 {