feat: 新增月掩与日月食地理绘图并提升观测计算精度
- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算 - 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出 - 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口 - 扩展日食中心线、南北界及偏食足迹采样,支持极区投影 - 修正站心时角、月出月落、月球视半径、折射和恒星自行计算 - 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPlanetOccultationFiniteDiskExpandsOuterAndContractsTotalPath(t *testing.T) {
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn occultation config is unavailable")
|
||||
}
|
||||
tt := occultationTimeToTT(time.Date(2024, time.August, 21, 2, 41, 36, 0, time.UTC))
|
||||
frameAt := func(tt float64) (occultationPathFrame, bool) {
|
||||
return planetOccultationPathFrameAt(tt, config)
|
||||
}
|
||||
_, _, finiteWidth, finiteOK := occultationPathLimitsAndWidthForFrame(tt, frameAt)
|
||||
if !finiteOK {
|
||||
t.Fatal("finite-disk path limits are unavailable")
|
||||
}
|
||||
pointFrameAt := func(tt float64) (occultationPathFrame, bool) {
|
||||
frame, valid := planetOccultationPathFrameAt(tt, config)
|
||||
frame.targetRadius = 0
|
||||
return frame, valid
|
||||
}
|
||||
_, _, pointWidth, pointOK := occultationPathLimitsAndWidthForFrame(tt, pointFrameAt)
|
||||
if !pointOK {
|
||||
t.Fatal("point-source comparison limits are unavailable")
|
||||
}
|
||||
if finiteWidth <= pointWidth {
|
||||
t.Fatalf("finite-disk outer width = %.6f km, want greater than point-source width %.6f km", finiteWidth, pointWidth)
|
||||
}
|
||||
if finiteWidth-pointWidth < 1 {
|
||||
t.Fatalf("finite-disk expansion = %.6f km, want a measurable planetary-radius contribution", finiteWidth-pointWidth)
|
||||
}
|
||||
innerFrameAt := func(tt float64) (occultationPathFrame, bool) {
|
||||
return planetOccultationTotalPathFrameAt(tt, config)
|
||||
}
|
||||
_, _, totalWidth, totalOK := occultationPathLimitsAndWidthForFrame(tt, innerFrameAt)
|
||||
if !totalOK {
|
||||
t.Fatal("finite-disk total-occultation limits are unavailable")
|
||||
}
|
||||
if totalWidth >= pointWidth {
|
||||
t.Fatalf("finite-disk total width = %.6f km, want less than point-source width %.6f km", totalWidth, pointWidth)
|
||||
}
|
||||
if pointWidth-totalWidth < 1 {
|
||||
t.Fatalf("finite-disk contraction = %.6f km, want a measurable planetary-radius contribution", pointWidth-totalWidth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationConesUseTwoSphereCommonTangents(t *testing.T) {
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn occultation config is unavailable")
|
||||
}
|
||||
tt := occultationTimeToTT(time.Date(2025, time.February, 1, 4, 0, 48, 0, time.UTC))
|
||||
outer, ok := planetOccultationPathFrameAt(tt, config)
|
||||
if !ok {
|
||||
t.Fatal("Saturn outer-contact cone is unavailable")
|
||||
}
|
||||
inner, ok := planetOccultationTotalPathFrameAt(tt, config)
|
||||
if !ok {
|
||||
t.Fatal("Saturn inner-contact cone is unavailable")
|
||||
}
|
||||
|
||||
planetRA, planetDec := config.apparentRaDecN(tt, -1)
|
||||
planetDistance := config.earthDistanceN(tt, -1) * occultationPathAstronomicalUnitKM
|
||||
target := occultationPathRaDecVector(planetRA, planetDec, planetDistance)
|
||||
moonToTargetDistance := occultationPathNorm(occultationPathSub(target, outer.moon))
|
||||
moonRadiusKM := occultationPathNorm(outer.moon) * math.Sin(outer.moonRadius)
|
||||
wantOuter := math.Asin((moonRadiusKM + config.equatorialRadiusKM) / moonToTargetDistance)
|
||||
wantInner := math.Asin((moonRadiusKM - config.equatorialRadiusKM) / moonToTargetDistance)
|
||||
if difference := math.Abs(outer.targetRadius - wantOuter); difference > 1e-15 {
|
||||
t.Fatalf("outer-contact cone angle = %.15g rad, want %.15g (difference %.3g)", outer.targetRadius, wantOuter, difference)
|
||||
}
|
||||
if difference := math.Abs(inner.targetRadius - wantInner); difference > 1e-15 {
|
||||
t.Fatalf("inner-contact cone angle = %.15g rad, want %.15g (difference %.3g)", inner.targetRadius, wantInner, difference)
|
||||
}
|
||||
for _, contact := range []struct {
|
||||
name string
|
||||
frame occultationPathFrame
|
||||
}{
|
||||
{name: "outer", frame: outer},
|
||||
{name: "inner", frame: inner},
|
||||
} {
|
||||
origin, direction, rayOK := occultationPathBoundaryRay(contact.frame, 0.73)
|
||||
if !rayOK {
|
||||
t.Fatalf("%s-contact boundary ray is unavailable", contact.name)
|
||||
}
|
||||
moonNormal := occultationPathSub(origin, contact.frame.moon)
|
||||
if difference := math.Abs(occultationPathNorm(moonNormal) - moonRadiusKM); difference > 1e-6 {
|
||||
t.Fatalf("%s-contact lunar tangency radius differs by %.9f km", contact.name, difference)
|
||||
}
|
||||
if residual := math.Abs(occultationPathDot(moonNormal, direction)); residual > 1e-6 {
|
||||
t.Fatalf("%s-contact ray/lunar-radius dot product = %.9f km", contact.name, residual)
|
||||
}
|
||||
targetParameter := occultationPathDot(occultationPathSub(target, origin), direction)
|
||||
targetTangent := occultationPathAdd(origin, occultationPathScale(direction, targetParameter))
|
||||
targetNormal := occultationPathSub(targetTangent, target)
|
||||
if difference := math.Abs(occultationPathNorm(targetNormal) - config.equatorialRadiusKM); difference > 1e-5 {
|
||||
t.Fatalf("%s-contact planetary tangency radius differs by %.9f km", contact.name, difference)
|
||||
}
|
||||
if residual := math.Abs(occultationPathDot(targetNormal, direction)); residual > 1e-5 {
|
||||
t.Fatalf("%s-contact ray/planet-radius dot product = %.9f km", contact.name, residual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationInnerConeUsesSignedTargetRadius(t *testing.T) {
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn occultation config is unavailable")
|
||||
}
|
||||
tt := occultationTimeToTT(time.Date(2025, time.February, 1, 4, 0, 48, 0, time.UTC))
|
||||
frame, ok := planetOccultationTotalPathFrameAt(tt, config)
|
||||
if !ok {
|
||||
t.Fatal("Saturn inner-contact cone is unavailable")
|
||||
}
|
||||
|
||||
for index := 0; index < occultationPathBoundaryScanPoints; index++ {
|
||||
theta := 2 * math.Pi * float64(index) / float64(occultationPathBoundaryScanPoints)
|
||||
want, _, wantOK := occultationPathBoundaryVector(frame, theta)
|
||||
if !wantOK {
|
||||
continue
|
||||
}
|
||||
discriminant, _, scale, lineOK := occultationPathBoundaryLine(frame, theta)
|
||||
if !lineOK || discriminant < 0 {
|
||||
continue
|
||||
}
|
||||
got, _, gotOK := occultationPathBoundaryIntersection(frame, theta, 1e-12*math.Max(scale, 1))
|
||||
if !gotOK {
|
||||
t.Fatalf("signed inner-cone intersection is unavailable at theta %.9f", theta)
|
||||
}
|
||||
if difference := occultationPathNorm(occultationPathSub(got, want)); difference > 1e-6 {
|
||||
t.Fatalf("inner-cone intersection differs by %.6f km at theta %.9f", difference, theta)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("no comparable Saturn inner-cone boundary point found")
|
||||
}
|
||||
|
||||
func TestOccultationPathBoundaryTangentFindsBetweenSamples(t *testing.T) {
|
||||
const boundaryRadiusKM = 1737.4
|
||||
theta := math.Pi / float64(occultationPathBoundaryScanPoints)
|
||||
offset := occultationPathEarthEquatorialRadiusKM + boundaryRadiusKM - 0.01
|
||||
moon := occultationPathVector{
|
||||
x: 384000,
|
||||
y: -offset * math.Cos(theta),
|
||||
z: -offset * math.Sin(theta),
|
||||
}
|
||||
frame := occultationPathFrame{
|
||||
moon: moon,
|
||||
axis: occultationPathVector{x: -1},
|
||||
first: occultationPathVector{y: 1},
|
||||
second: occultationPathVector{z: 1},
|
||||
moonRadius: math.Asin(boundaryRadiusKM / occultationPathNorm(moon)),
|
||||
}
|
||||
for _, sampledTheta := range []float64{0, 2 * math.Pi / float64(occultationPathBoundaryScanPoints)} {
|
||||
if _, _, ok := occultationPathBoundaryVector(frame, sampledTheta); ok {
|
||||
t.Fatalf("fixture is not narrower than the old sample spacing at theta %.9f", sampledTheta)
|
||||
}
|
||||
}
|
||||
point, tangentTheta, ok := occultationPathBoundaryTangent(frame)
|
||||
if !ok {
|
||||
t.Fatal("continuous boundary tangency was not found between scan points")
|
||||
}
|
||||
if math.Abs(tangentTheta-theta) > 5e-5 {
|
||||
t.Fatalf("tangent theta = %.9f, want %.9f", tangentTheta, theta)
|
||||
}
|
||||
polarRatioSquared := occultationPathEarthPolarRatio * occultationPathEarthPolarRatio
|
||||
ellipsoidResidual := point.x*point.x + point.y*point.y + point.z*point.z/polarRatioSquared -
|
||||
occultationPathEarthEquatorialRadiusKM*occultationPathEarthEquatorialRadiusKM
|
||||
if math.Abs(ellipsoidResidual) > 1e-3 {
|
||||
t.Fatalf("tangent point ellipsoid residual = %.9f", ellipsoidResidual)
|
||||
}
|
||||
frameAt := func(float64) (occultationPathFrame, bool) { return frame, true }
|
||||
if _, _, centerOK := occultationEarthLineIntersection(frame.moon, frame.axis); centerOK {
|
||||
t.Fatal("synthetic center line unexpectedly intersects Earth")
|
||||
}
|
||||
north, south, width, limitsOK := occultationPathLimitsAndWidthForFrame(2451545, frameAt)
|
||||
if !limitsOK {
|
||||
t.Fatal("boundary-only event did not produce path limits")
|
||||
}
|
||||
if separation := occultationPathNorm(occultationPathSub(north, south)); separation <= 1e-6 {
|
||||
t.Fatalf("boundary-only path limits collapsed to one point: separation=%.12f km", separation)
|
||||
}
|
||||
if width <= 0 {
|
||||
t.Fatalf("boundary-only path width = %.12f km, want positive", width)
|
||||
}
|
||||
greatest, greatestOK := occultationPathBoundaryPointForFrame(2451545, frameAt, time.UTC)
|
||||
if !greatestOK {
|
||||
t.Fatal("boundary-only event did not produce a greatest surface point")
|
||||
}
|
||||
if greatest.WidthKM <= 0 {
|
||||
t.Fatalf("boundary-only greatest width = %.12f km, want positive", greatest.WidthKM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationSaturnLimitsRemainContinuous(t *testing.T) {
|
||||
location := time.FixedZone("UTC+8", 8*3600)
|
||||
paths, err := FindPlanetOccultationPaths(
|
||||
time.Date(2025, time.February, 1, 0, 0, 0, 0, location),
|
||||
time.Date(2025, time.February, 2, 0, 0, 0, 0, location),
|
||||
OccultationSaturn,
|
||||
OccultationPathOptions{Step: 2 * time.Minute},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("FindPlanetOccultationPaths() error = %v", err)
|
||||
}
|
||||
if len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() returned %d paths, want 1", len(paths))
|
||||
}
|
||||
|
||||
for _, limit := range []struct {
|
||||
name string
|
||||
points []OccultationPathPoint
|
||||
}{
|
||||
{name: "outer northern", points: paths[0].NorthernLimit},
|
||||
{name: "outer southern", points: paths[0].SouthernLimit},
|
||||
{name: "total northern", points: paths[0].NorthernTotalLimit},
|
||||
{name: "total southern", points: paths[0].SouthernTotalLimit},
|
||||
} {
|
||||
for index := 1; index < len(limit.points); index++ {
|
||||
distance := occultationPathDistanceKM(limit.points[index-1], limit.points[index])
|
||||
if distance > 1000 {
|
||||
t.Fatalf("%s limit jumps %.1f km between %v and %v", limit.name, distance,
|
||||
limit.points[index-1].Time, limit.points[index].Time)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefinedPlanetOccultationCenterLineRespectsWidthTolerance(t *testing.T) {
|
||||
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), OccultationSaturn,
|
||||
OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 50},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn occultation config is unavailable")
|
||||
}
|
||||
frameAt := func(tt float64) (occultationPathFrame, bool) {
|
||||
return planetOccultationPathFrameAt(tt, config)
|
||||
}
|
||||
for index, point := range paths[0].CenterLine {
|
||||
exact, pointOK := occultationPathCenterPointForFrame(centerTimeTT(point.Time), frameAt, time.UTC)
|
||||
if !pointOK {
|
||||
t.Fatalf("exact center point %d is unavailable", index)
|
||||
}
|
||||
if difference := math.Abs(point.WidthKM - exact.WidthKM); difference > occultationPathWidthToleranceKM {
|
||||
t.Fatalf("center point %d width differs from exact value by %.9f km: got %.9f want %.9f",
|
||||
index, difference, point.WidthKM, exact.WidthKM)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationSaturnLimitsDoNotDependOnStep(t *testing.T) {
|
||||
location := time.FixedZone("UTC+8", 8*3600)
|
||||
start := time.Date(2024, time.August, 21, 0, 0, 0, 0, location)
|
||||
end := time.Date(2024, time.August, 22, 0, 0, 0, 0, location)
|
||||
fine := findSinglePlanetOccultationPath(t, start, end, 30*time.Second)
|
||||
coarse := findSinglePlanetOccultationPath(t, start, end, 2*time.Minute)
|
||||
|
||||
for _, limits := range []struct {
|
||||
name string
|
||||
fine, coarse []OccultationPathPoint
|
||||
}{
|
||||
{name: "outer northern", fine: fine.NorthernLimit, coarse: coarse.NorthernLimit},
|
||||
{name: "outer southern", fine: fine.SouthernLimit, coarse: coarse.SouthernLimit},
|
||||
{name: "total northern", fine: fine.NorthernTotalLimit, coarse: coarse.NorthernTotalLimit},
|
||||
{name: "total southern", fine: fine.SouthernTotalLimit, coarse: coarse.SouthernTotalLimit},
|
||||
} {
|
||||
assertOccultationPathCommonSamplesEqual(t, limits.name, limits.fine, limits.coarse)
|
||||
for index := 1; index+1 < len(limits.coarse); index++ {
|
||||
paired := coarse.SouthernLimit
|
||||
if strings.HasPrefix(limits.name, "total") {
|
||||
paired = coarse.SouthernTotalLimit
|
||||
}
|
||||
if strings.HasSuffix(limits.name, "southern") {
|
||||
continue
|
||||
}
|
||||
if distance := occultationPathDistanceKM(limits.coarse[index], paired[index]); distance < 0.001 {
|
||||
t.Fatalf("%s and southern limit collapse at %v", limits.name, limits.coarse[index].Time)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findSinglePlanetOccultationPath(t *testing.T, start, end time.Time, step time.Duration) PlanetOccultationPath {
|
||||
t.Helper()
|
||||
paths, err := FindPlanetOccultationPaths(start, end, OccultationSaturn, OccultationPathOptions{Step: step})
|
||||
if err != nil {
|
||||
t.Fatalf("FindPlanetOccultationPaths(step=%v) error = %v", step, err)
|
||||
}
|
||||
if len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths(step=%v) returned %d paths, want 1", step, len(paths))
|
||||
}
|
||||
if !paths[0].HasTotalBand {
|
||||
t.Fatalf("FindPlanetOccultationPaths(step=%v) has no total band", step)
|
||||
}
|
||||
return paths[0]
|
||||
}
|
||||
|
||||
func assertOccultationPathCommonSamplesEqual(t *testing.T, name string, fine, coarse []OccultationPathPoint) {
|
||||
t.Helper()
|
||||
matched := 0
|
||||
fineIndex := 0
|
||||
for _, coarsePoint := range coarse[1 : len(coarse)-1] {
|
||||
for fineIndex+1 < len(fine) && fine[fineIndex].Time.Before(coarsePoint.Time.Add(-20*time.Millisecond)) {
|
||||
fineIndex++
|
||||
}
|
||||
nearest := -1
|
||||
nearestDelta := math.Inf(1)
|
||||
for candidateIndex := fineIndex - 2; candidateIndex <= fineIndex+2; candidateIndex++ {
|
||||
if candidateIndex < 0 || candidateIndex >= len(fine) {
|
||||
continue
|
||||
}
|
||||
delta := math.Abs(fine[candidateIndex].Time.Sub(coarsePoint.Time).Seconds())
|
||||
if delta < nearestDelta {
|
||||
nearest = candidateIndex
|
||||
nearestDelta = delta
|
||||
}
|
||||
}
|
||||
if nearest < 0 || nearestDelta > 0.00001 {
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
if distance := occultationPathDistanceKM(fine[nearest], coarsePoint); distance > 5 {
|
||||
t.Fatalf("%s differs by %.1f km at common time %v (sample delta %.6f s)",
|
||||
name, distance, coarsePoint.Time, nearestDelta)
|
||||
}
|
||||
}
|
||||
if matched < 10 {
|
||||
t.Fatalf("%s compared only %d common samples, want at least 10", name, matched)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user