2bf8478639
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
274 lines
9.9 KiB
Go
274 lines
9.9 KiB
Go
package basic
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type solarEclipseBaseline struct {
|
|
name string
|
|
jde float64
|
|
|
|
expectedType SolarEclipseType
|
|
expectedCentrality SolarEclipseCentrality
|
|
expectedGreatestTT float64
|
|
expectedGamma float64
|
|
expectedMagnitude float64
|
|
expectedLongitude float64
|
|
expectedLatitude float64
|
|
expectedPathWidth float64
|
|
}
|
|
|
|
func TestSolarEclipseAgainstNASABaseline(t *testing.T) {
|
|
// NASA GSFC Solar Eclipse Search Engine, Besselian Elements pages:
|
|
// - 2023 Apr 20 hybrid
|
|
// - 2024 Apr 08 total
|
|
// - 2024 Oct 02 annular
|
|
// - 2025 Mar 29 partial
|
|
testCases := []solarEclipseBaseline{
|
|
{
|
|
name: "2023-04-20 hybrid",
|
|
jde: JDECalc(2023, 4, 20),
|
|
expectedType: SolarEclipseHybrid,
|
|
expectedCentrality: SolarEclipseCentralTwoLimits,
|
|
expectedGreatestTT: solarEclipseTTJDE(2023, time.April, 20, 4, 17, 56),
|
|
expectedGamma: -0.3952,
|
|
expectedMagnitude: 1.0132,
|
|
expectedLongitude: 125.8,
|
|
expectedLatitude: -9.6,
|
|
expectedPathWidth: 49.0,
|
|
},
|
|
{
|
|
name: "2024-04-08 total",
|
|
jde: JDECalc(2024, 4, 8),
|
|
expectedType: SolarEclipseTotal,
|
|
expectedCentrality: SolarEclipseCentralTwoLimits,
|
|
expectedGreatestTT: solarEclipseTTJDE(2024, time.April, 8, 18, 18, 29),
|
|
expectedGamma: 0.3431,
|
|
expectedMagnitude: 1.0566,
|
|
expectedLongitude: -104.1,
|
|
expectedLatitude: 25.3,
|
|
expectedPathWidth: 197.5,
|
|
},
|
|
{
|
|
name: "2024-10-02 annular",
|
|
jde: JDECalc(2024, 10, 2),
|
|
expectedType: SolarEclipseAnnular,
|
|
expectedCentrality: SolarEclipseCentralTwoLimits,
|
|
expectedGreatestTT: solarEclipseTTJDE(2024, time.October, 2, 18, 46, 13),
|
|
expectedGamma: -0.3509,
|
|
expectedMagnitude: 0.9326,
|
|
expectedLongitude: -114.5,
|
|
expectedLatitude: -22.0,
|
|
expectedPathWidth: 266.5,
|
|
},
|
|
{
|
|
name: "2025-03-29 partial",
|
|
jde: JDECalc(2025, 3, 29),
|
|
expectedType: SolarEclipsePartial,
|
|
expectedCentrality: SolarEclipseNonCentral,
|
|
expectedGreatestTT: solarEclipseTTJDE(2025, time.March, 29, 10, 48, 36),
|
|
expectedGamma: 1.0405,
|
|
expectedMagnitude: 0.9376,
|
|
expectedLongitude: -77.1,
|
|
expectedLatitude: 61.1,
|
|
expectedPathWidth: 0,
|
|
},
|
|
}
|
|
|
|
const (
|
|
timeToleranceDays = 2.0 / 86400.0
|
|
gammaTolerance = 5e-4
|
|
magnitudeTolerance = 5e-4
|
|
coordinateTolerance = 0.1
|
|
pathWidthTolerance = 5.0
|
|
)
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := SolarEclipse(tc.jde)
|
|
|
|
if result.Type != tc.expectedType {
|
|
t.Fatalf("Type mismatch: got %s want %s", result.Type, tc.expectedType)
|
|
}
|
|
if result.Centrality != tc.expectedCentrality {
|
|
t.Fatalf("Centrality mismatch: got %s want %s", result.Centrality, tc.expectedCentrality)
|
|
}
|
|
|
|
assertSolarEclipseJDEClose(t, "GreatestEclipse", result.GreatestEclipse, tc.expectedGreatestTT, timeToleranceDays)
|
|
assertSolarEclipseFloatClose(t, "Gamma", result.Gamma, tc.expectedGamma, gammaTolerance)
|
|
assertSolarEclipseFloatClose(t, "Magnitude", result.Magnitude, tc.expectedMagnitude, magnitudeTolerance)
|
|
assertSolarEclipseFloatClose(t, "GreatestLongitude", result.GreatestLongitude, tc.expectedLongitude, coordinateTolerance)
|
|
assertSolarEclipseFloatClose(t, "GreatestLatitude", result.GreatestLatitude, tc.expectedLatitude, coordinateTolerance)
|
|
assertSolarEclipseFloatClose(t, "PathWidthKM", result.PathWidthKM, tc.expectedPathWidth, pathWidthTolerance)
|
|
|
|
if result.HasPartial && !(result.PartialBeginOnEarth < result.GreatestEclipse && result.GreatestEclipse < result.PartialEndOnEarth) {
|
|
t.Fatalf(
|
|
"partial contact order invalid: begin=%.12f greatest=%.12f end=%.12f",
|
|
result.PartialBeginOnEarth, result.GreatestEclipse, result.PartialEndOnEarth,
|
|
)
|
|
}
|
|
if result.HasCentral && !(result.CentralBeginOnEarth < result.GreatestEclipse && result.GreatestEclipse < result.CentralEndOnEarth) {
|
|
t.Fatalf(
|
|
"central contact order invalid: begin=%.12f greatest=%.12f end=%.12f",
|
|
result.CentralBeginOnEarth, result.GreatestEclipse, result.CentralEndOnEarth,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSolarEclipseBesselGeometryCacheSeparatesExactAndCandidate(t *testing.T) {
|
|
seed := JDECalc(2024, 4, 8)
|
|
solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK)
|
|
tt := solver.newMoonJDE + 0.125
|
|
exactMoon, exactAxis, exactSun := solver.besselGeometryAt(tt)
|
|
exactMoonAgain, exactAxisAgain, exactSunAgain := solver.besselGeometryAt(tt)
|
|
if exactMoon != exactMoonAgain || exactAxis != exactAxisAgain || exactSun != exactSunAgain {
|
|
t.Fatal("exact Bessel cache changed the geometry on a repeated lookup")
|
|
}
|
|
if len(solver.besselGeometryCache) != 1 {
|
|
t.Fatalf("exact Bessel cache size=%d, want 1", len(solver.besselGeometryCache))
|
|
}
|
|
|
|
candidateSolver := solver.withLocalEphemeris()
|
|
candidateMoon, candidateAxis, candidateSun, ok := candidateSolver.besselGeometryCandidateAt(tt)
|
|
if !ok {
|
|
t.Fatal("candidate Bessel geometry unavailable inside local ephemeris")
|
|
}
|
|
candidateMoonAgain, candidateAxisAgain, candidateSunAgain, okAgain := candidateSolver.besselGeometryCandidateAt(tt)
|
|
if !okAgain || candidateMoon != candidateMoonAgain || candidateAxis != candidateAxisAgain || candidateSun != candidateSunAgain {
|
|
t.Fatal("candidate Bessel cache changed the geometry on a repeated lookup")
|
|
}
|
|
if len(solver.besselGeometryCache) != 1 {
|
|
t.Fatalf("exact Bessel cache size changed after candidate lookup=%d", len(solver.besselGeometryCache))
|
|
}
|
|
if len(solver.besselCandidateCache) != 1 {
|
|
t.Fatalf("candidate Bessel cache size=%d, want 1", len(solver.besselCandidateCache))
|
|
}
|
|
}
|
|
|
|
func TestSolarEclipseBesselGeometryCacheIsBounded(t *testing.T) {
|
|
cache := make(map[uint64]solarEclipseBesselGeometryCacheEntry)
|
|
entry := solarEclipseBesselGeometryCacheEntry{valid: true}
|
|
for index := 0; index < solarEclipseBesselGeometryCacheMaximumEntries+17; index++ {
|
|
storeSolarEclipseBesselGeometry(cache, uint64(index), entry)
|
|
}
|
|
if len(cache) > solarEclipseBesselGeometryCacheMaximumEntries {
|
|
t.Fatalf("Bessel cache size=%d, want <=%d", len(cache), solarEclipseBesselGeometryCacheMaximumEntries)
|
|
}
|
|
if _, ok := cache[uint64(solarEclipseBesselGeometryCacheMaximumEntries+16)]; !ok {
|
|
t.Fatal("latest Bessel cache entry was evicted")
|
|
}
|
|
}
|
|
|
|
func BenchmarkSolarEclipseBesselGeometryCache(b *testing.B) {
|
|
solver := newSolarEclipseSolver(
|
|
CalcMoonSHByJDE(JDECalc(2024, 4, 8), 0),
|
|
SolarEclipseModelNASABulletinSplitK,
|
|
)
|
|
tt := solver.newMoonJDE + 0.125
|
|
// Prime one exact entry so the benchmark measures the repeated lookup
|
|
// path used by contact and topology refinements.
|
|
solver.besselGeometryAt(tt)
|
|
b.ReportAllocs()
|
|
b.ResetTimer()
|
|
for index := 0; index < b.N; index++ {
|
|
solver.besselGeometryAt(tt)
|
|
}
|
|
}
|
|
|
|
func BenchmarkSolarEclipseRepresentativePath(b *testing.B) {
|
|
seed := JDECalc(2024, 4, 8)
|
|
options := SolarEclipsePathOptions{StepDays: 5.0 / 1440.0, TargetSpacingKM: 200}
|
|
b.ReportAllocs()
|
|
for index := 0; index < b.N; index++ {
|
|
SolarEclipseCentralPath(seed, options)
|
|
}
|
|
}
|
|
|
|
func TestSolarEclipseDefaultUsesNASABulletinSplitK(t *testing.T) {
|
|
jde := JDECalc(2024, 4, 8)
|
|
defaultResult := SolarEclipse(jde)
|
|
nasaResult := SolarEclipseNASABulletinSplitK(jde)
|
|
iauResult := SolarEclipseIAUSingleK(jde)
|
|
|
|
if defaultResult.Model != SolarEclipseModelNASABulletinSplitK {
|
|
t.Fatalf("default model mismatch: got %s want %s", defaultResult.Model, SolarEclipseModelNASABulletinSplitK)
|
|
}
|
|
|
|
assertSolarEclipseJDEClose(t, "GreatestEclipse", defaultResult.GreatestEclipse, nasaResult.GreatestEclipse, 1e-12)
|
|
assertSolarEclipseFloatClose(t, "Gamma", defaultResult.Gamma, nasaResult.Gamma, 1e-12)
|
|
assertSolarEclipseFloatClose(t, "Magnitude", defaultResult.Magnitude, nasaResult.Magnitude, 1e-12)
|
|
assertSolarEclipseFloatClose(t, "PathWidthKM", defaultResult.PathWidthKM, nasaResult.PathWidthKM, 1e-12)
|
|
|
|
if math.Abs(defaultResult.PathWidthKM-iauResult.PathWidthKM) < 0.5 {
|
|
t.Fatalf(
|
|
"default model should not collapse to IAU Single-K: default=%.6f iau=%.6f",
|
|
defaultResult.PathWidthKM, iauResult.PathWidthKM,
|
|
)
|
|
}
|
|
if !(iauResult.PathWidthKM > defaultResult.PathWidthKM) {
|
|
t.Fatalf(
|
|
"IAU Single-K should produce a wider total path than NASA Split-K here: iau=%.6f default=%.6f",
|
|
iauResult.PathWidthKM, defaultResult.PathWidthKM,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestSolarEclipseNoEvent(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
calc func(float64) SolarEclipseResult
|
|
}{
|
|
{name: "default", calc: SolarEclipse},
|
|
{name: "nasa", calc: SolarEclipseNASABulletinSplitK},
|
|
{name: "iau", calc: SolarEclipseIAUSingleK},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := tc.calc(JDECalc(2023, 5, 15))
|
|
if result.Type != SolarEclipseNone {
|
|
t.Fatalf("Type mismatch: got %s want %s", result.Type, SolarEclipseNone)
|
|
}
|
|
if result.HasPartial || result.HasCentral || result.HasAnnular || result.HasTotal || result.HasHybrid {
|
|
t.Fatalf("unexpected eclipse flags: %+v", result)
|
|
}
|
|
if result.PartialBeginOnEarth != 0 || result.PartialEndOnEarth != 0 || result.CentralBeginOnEarth != 0 || result.CentralEndOnEarth != 0 {
|
|
t.Fatalf("expected no contact times, got %+v", result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func BenchmarkSolarEclipseGlobal(b *testing.B) {
|
|
seed := JDECalc(2010, 1, 15)
|
|
b.ReportAllocs()
|
|
for iteration := 0; iteration < b.N; iteration++ {
|
|
result := SolarEclipse(seed)
|
|
if !result.HasCentral {
|
|
b.Fatal("expected a central eclipse")
|
|
}
|
|
}
|
|
}
|
|
|
|
func solarEclipseTTJDE(year int, month time.Month, day, hour, minute, second int) float64 {
|
|
return Date2JDE(time.Date(year, month, day, hour, minute, second, 0, time.UTC))
|
|
}
|
|
|
|
func assertSolarEclipseJDEClose(t *testing.T, name string, got, want, tolerance float64) {
|
|
t.Helper()
|
|
if math.Abs(got-want) > tolerance {
|
|
t.Fatalf("%s mismatch: got %.12f want %.12f", name, got, want)
|
|
}
|
|
}
|
|
|
|
func assertSolarEclipseFloatClose(t *testing.T, name string, got, want, tolerance float64) {
|
|
t.Helper()
|
|
if math.Abs(got-want) > tolerance {
|
|
t.Fatalf("%s mismatch: got %.9f want %.9f", name, got, want)
|
|
}
|
|
}
|