feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOccultationRiseSetVectorStateMatchesLegacyTopocentricState(t *testing.T) {
|
||||
planetConfig, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn occultation config is unavailable")
|
||||
}
|
||||
planetTT := occultationTimeToTT(time.Date(2024, time.August, 21, 2, 41, 36, 0, time.UTC))
|
||||
planetState := planetOccultationEphemerisStateAt(planetTT, planetConfig)
|
||||
|
||||
star := StarCoordinate{
|
||||
ID: "parallax-test", RA: 247.3516666666667, Dec: -26.431944444444444,
|
||||
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000,
|
||||
ProperMotionRACosDecMasPerYear: -10, ProperMotionDecMasPerYear: -20, ParallaxMas: 24,
|
||||
}
|
||||
starTT := occultationTimeToTT(time.Date(2026, time.February, 11, 12, 0, 0, 0, time.UTC))
|
||||
starState := starOccultationEphemerisStateAt(starTT, star)
|
||||
|
||||
contexts := []struct {
|
||||
name string
|
||||
tt float64
|
||||
moonRA, moonDec, moonDistanceKM float64
|
||||
targetRA, targetDec, targetDistanceKM float64
|
||||
targetRadiusKM float64
|
||||
}{
|
||||
{
|
||||
name: "planet", tt: planetTT,
|
||||
moonRA: planetState.moonRA, moonDec: planetState.moonDec, moonDistanceKM: planetState.moonDistanceKM,
|
||||
targetRA: planetState.planetRA, targetDec: planetState.planetDec, targetDistanceKM: planetState.planetDistanceKM,
|
||||
targetRadiusKM: planetConfig.equatorialRadiusKM,
|
||||
},
|
||||
{
|
||||
name: "finite-distance-star", tt: starTT,
|
||||
moonRA: starState.moonRA, moonDec: starState.moonDec, moonDistanceKM: starState.moonDistanceKM,
|
||||
targetRA: starState.starRA, targetDec: starState.starDec, targetDistanceKM: starState.starDistanceKM,
|
||||
},
|
||||
{
|
||||
name: "infinite-distance-star", tt: starTT,
|
||||
moonRA: starState.moonRA, moonDec: starState.moonDec, moonDistanceKM: starState.moonDistanceKM,
|
||||
targetRA: starState.starRA, targetDec: starState.starDec,
|
||||
},
|
||||
}
|
||||
locations := []Observer{
|
||||
{Longitude: 0, Latitude: 0},
|
||||
{Longitude: 115.4, Latitude: 32.9},
|
||||
{Longitude: -73.9857, Latitude: 40.7484},
|
||||
{Longitude: 179.9, Latitude: 80},
|
||||
{Longitude: -120, Latitude: -70},
|
||||
}
|
||||
|
||||
for _, test := range contexts {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
context := newOccultationRiseSetContext(
|
||||
test.tt, test.moonRA, test.moonDec, test.moonDistanceKM,
|
||||
test.targetRA, test.targetDec, test.targetDistanceKM, test.targetRadiusKM,
|
||||
)
|
||||
for _, observer := range locations {
|
||||
got := context.stateAt(observer.Longitude, observer.Latitude)
|
||||
want := legacyOccultationRiseSetStateAt(
|
||||
test.tt, test.moonRA, test.moonDec, test.moonDistanceKM,
|
||||
test.targetRA, test.targetDec, test.targetDistanceKM, test.targetRadiusKM,
|
||||
observer.Longitude, observer.Latitude,
|
||||
)
|
||||
if got.valid != want.valid {
|
||||
t.Fatalf("observer %.4f %.4f valid=%t, want %t", observer.Longitude, observer.Latitude, got.valid, want.valid)
|
||||
}
|
||||
if !got.valid {
|
||||
continue
|
||||
}
|
||||
assertOccultationRiseSetStateClose(t, "contact metric", got.contactMetric, want.contactMetric, 2e-10)
|
||||
assertOccultationRiseSetStateClose(t, "separation squared", got.separationSquared, want.separationSquared, 2e-13)
|
||||
assertOccultationRiseSetStateClose(t, "moon altitude", got.moonAltitude, want.moonAltitude, 2e-10)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationRiseSetContextFromVectorsMatchesRADecContext(t *testing.T) {
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn occultation config is unavailable")
|
||||
}
|
||||
tt := occultationTimeToTT(time.Date(2025, time.January, 5, 17, 0, 0, 0, time.UTC))
|
||||
state := planetOccultationEphemerisStateAt(tt, config)
|
||||
moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM)
|
||||
target := occultationPathRaDecVector(state.planetRA, state.planetDec, state.planetDistanceKM)
|
||||
vectorContext := newOccultationRiseSetContextFromVectors(
|
||||
tt,
|
||||
[3]float64{moon.x, moon.y, moon.z},
|
||||
[3]float64{target.x, target.y, target.z},
|
||||
true,
|
||||
config.equatorialRadiusKM,
|
||||
)
|
||||
raDecContext := newOccultationRiseSetContext(
|
||||
tt, state.moonRA, state.moonDec, state.moonDistanceKM,
|
||||
state.planetRA, state.planetDec, state.planetDistanceKM, config.equatorialRadiusKM,
|
||||
)
|
||||
for _, observer := range []Observer{
|
||||
{Longitude: 0, Latitude: 0},
|
||||
{Longitude: 115.4, Latitude: 32.9},
|
||||
{Longitude: -45, Latitude: 82},
|
||||
{Longitude: 170, Latitude: -70},
|
||||
} {
|
||||
got := vectorContext.stateAt(observer.Longitude, observer.Latitude)
|
||||
want := raDecContext.stateAt(observer.Longitude, observer.Latitude)
|
||||
if got.valid != want.valid {
|
||||
t.Fatalf("observer %.3f %.3f valid=%t, want %t", observer.Longitude, observer.Latitude, got.valid, want.valid)
|
||||
}
|
||||
if !got.valid {
|
||||
continue
|
||||
}
|
||||
assertOccultationRiseSetStateClose(t, "contact metric", got.contactMetric, want.contactMetric, 1e-12)
|
||||
assertOccultationRiseSetStateClose(t, "separation squared", got.separationSquared, want.separationSquared, 1e-15)
|
||||
assertOccultationRiseSetStateClose(t, "moon altitude", got.moonAltitude, want.moonAltitude, 1e-12)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationRiseSetMoonHorizonResidualMatchesVectorAltitude(t *testing.T) {
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn occultation config is unavailable")
|
||||
}
|
||||
tt := occultationTimeToTT(time.Date(2025, time.January, 5, 17, 0, 0, 0, time.UTC))
|
||||
state := planetOccultationEphemerisStateAt(tt, config)
|
||||
context := newOccultationRiseSetContext(
|
||||
tt, state.moonRA, state.moonDec, state.moonDistanceKM,
|
||||
state.planetRA, state.planetDec, state.planetDistanceKM, config.equatorialRadiusKM,
|
||||
)
|
||||
for _, observer := range []Observer{
|
||||
{Longitude: 0, Latitude: 0},
|
||||
{Longitude: 115.4, Latitude: 32.9},
|
||||
{Longitude: -45, Latitude: 82},
|
||||
{Longitude: 170, Latitude: -70},
|
||||
} {
|
||||
got, valid := context.moonHorizonResidual(observer.Longitude, observer.Latitude)
|
||||
observerParallax, _, zenith := occultationRiseSetObserverVectors(
|
||||
context.siderealDegrees, observer.Longitude, observer.Latitude,
|
||||
)
|
||||
topocentricMoon := occultationPathSub(context.moon.positionKM, observerParallax)
|
||||
want := occultationPathDot(topocentricMoon, zenith)
|
||||
if !valid || math.Abs(got-want) > 1e-8 {
|
||||
t.Fatalf("observer %.3f %.3f horizon residual=%.15g valid=%t, want %.15g",
|
||||
observer.Longitude, observer.Latitude, got, valid, want)
|
||||
}
|
||||
altitude := context.stateAt(observer.Longitude, observer.Latitude).moonAltitude * rad
|
||||
if difference := math.Abs(got/occultationPathNorm(topocentricMoon) - math.Sin(altitude)); difference > 1e-14 {
|
||||
t.Fatalf("observer %.3f %.3f normalized horizon residual differs by %.3g",
|
||||
observer.Longitude, observer.Latitude, difference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationRiseSetEvaluationCacheSeparatesCandidateAndExactContexts(t *testing.T) {
|
||||
var exactCalls, candidateCalls int
|
||||
contextAt := func(tt float64) occultationRiseSetContext {
|
||||
return newOccultationRiseSetContext(tt, 10, 5, 384000, 10.5, 5.25, 1e9, 0)
|
||||
}
|
||||
cache := newOccultationRiseSetEvaluationCacheWithCandidate(
|
||||
func(tt float64) occultationRiseSetContext {
|
||||
exactCalls++
|
||||
return contextAt(tt)
|
||||
},
|
||||
func(tt float64) occultationRiseSetContext {
|
||||
candidateCalls++
|
||||
return contextAt(tt)
|
||||
},
|
||||
)
|
||||
tt := occultationTimeToTT(time.Date(2025, time.January, 5, 17, 0, 0, 0, time.UTC))
|
||||
cache.candidateEvaluation(tt)
|
||||
cache.candidateEvaluation(tt)
|
||||
if exactCalls != 0 || candidateCalls != 3 {
|
||||
t.Fatalf("candidate evaluation calls exact=%d candidate=%d, want 0/3", exactCalls, candidateCalls)
|
||||
}
|
||||
cache.evaluation(tt)
|
||||
cache.evaluation(tt)
|
||||
if exactCalls != 3 || candidateCalls != 3 {
|
||||
t.Fatalf("exact evaluation calls exact=%d candidate=%d, want 3/3", exactCalls, candidateCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func legacyOccultationRiseSetStateAt(
|
||||
tt, moonRA, moonDec, moonDistanceKM,
|
||||
targetRA, targetDec, targetDistanceKM, targetRadiusKM,
|
||||
longitude, latitude float64,
|
||||
) occultationRiseSetState {
|
||||
siderealDegrees := ApparentSiderealTime(TD2UT(tt, false)) * 15
|
||||
observer := Observer{Longitude: longitude, Latitude: latitude}
|
||||
moonTopocentricRA, moonTopocentricDec := topocentricRaDecWithSidereal(
|
||||
moonRA, moonDec, latitude, longitude, siderealDegrees,
|
||||
moonDistanceKM/occultationPathAstronomicalUnitKM, 0,
|
||||
)
|
||||
moonTopocentricRA = normalizeRA(moonTopocentricRA)
|
||||
targetTopocentricRA, targetTopocentricDec := targetRA, targetDec
|
||||
if targetDistanceKM > 0 {
|
||||
targetTopocentricRA, targetTopocentricDec = topocentricRaDecWithSidereal(
|
||||
targetRA, targetDec, latitude, longitude, siderealDegrees,
|
||||
targetDistanceKM/occultationPathAstronomicalUnitKM, 0,
|
||||
)
|
||||
targetTopocentricRA = normalizeRA(targetTopocentricRA)
|
||||
}
|
||||
moonTopocentricDistanceKM := topocentricDistanceKMWithSidereal(
|
||||
moonRA, moonDec, moonDistanceKM, observer, siderealDegrees,
|
||||
)
|
||||
if !finite(moonTopocentricDistanceKM) || moonTopocentricDistanceKM <= moonEquatorialRadiusKM {
|
||||
return occultationRiseSetState{}
|
||||
}
|
||||
moonRadius := angularSemidiameterArcsec(moonEquatorialRadiusKM, moonTopocentricDistanceKM) / 3600
|
||||
targetRadius := 0.0
|
||||
if targetRadiusKM > 0 {
|
||||
targetTopocentricDistanceKM := topocentricDistanceKMWithSidereal(
|
||||
targetRA, targetDec, targetDistanceKM, observer, siderealDegrees,
|
||||
)
|
||||
if !finite(targetTopocentricDistanceKM) || targetTopocentricDistanceKM <= targetRadiusKM {
|
||||
return occultationRiseSetState{}
|
||||
}
|
||||
targetRadius = angularSemidiameterArcsec(targetRadiusKM, targetTopocentricDistanceKM) / 3600
|
||||
}
|
||||
separation := angularSeparationDegrees(
|
||||
moonTopocentricRA, moonTopocentricDec, targetTopocentricRA, targetTopocentricDec,
|
||||
)
|
||||
separationRad := separation * rad
|
||||
moonAltitude := occultationAltitudeWithSidereal(
|
||||
siderealDegrees, observer, moonTopocentricRA, moonTopocentricDec,
|
||||
)
|
||||
return occultationRiseSetState{
|
||||
contactMetric: separation - moonRadius - targetRadius,
|
||||
separationSquared: 2 - 2*math.Cos(separationRad),
|
||||
moonAltitude: moonAltitude,
|
||||
valid: finite(separation) && finite(moonRadius) && finite(targetRadius) && finite(moonAltitude),
|
||||
}
|
||||
}
|
||||
|
||||
func assertOccultationRiseSetStateClose(t *testing.T, name string, got, want, tolerance float64) {
|
||||
t.Helper()
|
||||
if difference := math.Abs(got - want); difference > tolerance {
|
||||
t.Fatalf("%s=%.15g, want %.15g (difference %.3g, tolerance %.3g)", name, got, want, difference, tolerance)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user