feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/tools"
|
||||
)
|
||||
|
||||
// 本文件锁定掩星恒星坐标的距离契约:无距离走二维、给光年或视差走三维。
|
||||
|
||||
func occStar3DTestCoordinate(ra, dec float64) StarCoordinate {
|
||||
return StarCoordinate{
|
||||
ID: "3D contract",
|
||||
RA: ra,
|
||||
Dec: dec,
|
||||
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
|
||||
Frame: CoordinateFrameJ2000,
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarCoordinateDistanceGateKeepsTwoDimensions(t *testing.T) {
|
||||
star := occStar3DTestCoordinate(189.1975, -5.831944444444)
|
||||
star.ProperMotionRACosDecMasPerYear = -28
|
||||
star.ProperMotionDecMasPerYear = -18
|
||||
star.RadialVelocityKmPerSecond = -45
|
||||
if got := star.parallaxMas(); got != 0 {
|
||||
t.Fatalf("parallaxMas() = %v, want 0 without any distance", got)
|
||||
}
|
||||
tt := occultationTimeToTT(time.Date(2050, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
gotRA, gotDec, gotDistance := starProperMotionRaDec(tt, star, star.RA, star.Dec, starEpochDistanceAU(star.parallaxMas()))
|
||||
if gotDistance != 0 {
|
||||
t.Fatalf("no-distance propagation reported distance %v, want 0", gotDistance)
|
||||
}
|
||||
cosDec := math.Cos(star.Dec * math.Pi / 180)
|
||||
years := (tt - occultationTimeToTT(star.Epoch)) / 365.25
|
||||
wantRA := star.RA + years*star.ProperMotionRACosDecMasPerYear/(3600000*cosDec)
|
||||
wantDec := star.Dec + years*star.ProperMotionDecMasPerYear/3600000
|
||||
if gotRA != wantRA || gotDec != wantDec {
|
||||
t.Fatalf("no-distance propagation = %.12f %.12f, want 2D %.12f %.12f", gotRA, gotDec, wantRA, wantDec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarCoordinateLightYearDistanceMatchesParallax(t *testing.T) {
|
||||
const lightYears = 10
|
||||
byLightYear := func() StarCoordinate {
|
||||
star := occStar3DTestCoordinate(224.366667, -21.415556)
|
||||
star.ProperMotionRACosDecMasPerYear = 1045
|
||||
star.ProperMotionDecMasPerYear = -1729
|
||||
star.RadialVelocityKmPerSecond = 20
|
||||
star.DistanceLightYear = lightYears
|
||||
return star
|
||||
}()
|
||||
byParallax := byLightYear
|
||||
byParallax.DistanceLightYear = 0
|
||||
byParallax.ParallaxMas = byLightYear.parallaxMas()
|
||||
if byParallax.ParallaxMas <= 0 || byParallax.ParallaxMas > 400 {
|
||||
t.Fatalf("derived parallax = %v mas, want a positive sub-arcsecond value", byParallax.ParallaxMas)
|
||||
}
|
||||
tt := occultationTimeToTT(time.Date(2050, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
lightRA, lightDec := starApparentRaDecGeocentric(tt, byLightYear)
|
||||
parallaxRA, parallaxDec := starApparentRaDecGeocentric(tt, byParallax)
|
||||
if lightRA != parallaxRA || lightDec != parallaxDec {
|
||||
t.Fatalf("light-year path = %.12f %.12f, want bit-identical to parallax path %.12f %.12f",
|
||||
lightRA, lightDec, parallaxRA, parallaxDec)
|
||||
}
|
||||
if separation := starSepArcsec(lightRA, lightDec, byLightYear.RA, byLightYear.Dec); separation < 60 {
|
||||
t.Fatalf("proper motion over 50 years moved the star only %.6f arcsec", separation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarCoordinateParallaxTakesPriorityOverLightYear(t *testing.T) {
|
||||
star := occStar3DTestCoordinate(120, 15)
|
||||
star.DistanceLightYear = 10
|
||||
if got, want := star.parallaxMas(), 1000/tools.DistanceToParsecs(10, tools.DistanceLightYear); math.Abs(got-want) > 1e-9 {
|
||||
t.Fatalf("parallaxMas() = %.12f, want %.12f derived from light-years", got, want)
|
||||
}
|
||||
star.ParallaxMas = 25
|
||||
if got := star.parallaxMas(); got != 25 {
|
||||
t.Fatalf("parallaxMas() = %v, want the explicit 25 mas to win", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarCoordinateRadialVelocityContract(t *testing.T) {
|
||||
base := occStar3DTestCoordinate(120, 15)
|
||||
for _, velocity := range []float64{0, -500, 500, starRadialVelocityLimitKmPerSecond} {
|
||||
star := base
|
||||
star.RadialVelocityKmPerSecond = velocity
|
||||
if err := star.Validate(); err != nil {
|
||||
t.Fatalf("radial velocity %v rejected: %v", velocity, err)
|
||||
}
|
||||
}
|
||||
for _, velocity := range []float64{-5000, 5000, math.NaN(), math.Inf(1), math.Inf(-1)} {
|
||||
star := base
|
||||
star.RadialVelocityKmPerSecond = velocity
|
||||
if err := star.Validate(); err == nil {
|
||||
t.Fatalf("radial velocity %v accepted, want a contract error", velocity)
|
||||
}
|
||||
}
|
||||
for _, distance := range []float64{-1, math.NaN(), math.Inf(1)} {
|
||||
star := base
|
||||
star.DistanceLightYear = distance
|
||||
if err := star.Validate(); err == nil {
|
||||
t.Fatalf("light-year distance %v accepted, want a contract error", distance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarCoordinateThreeDimensionsShiftsOccultationTimingWithinBudget(t *testing.T) {
|
||||
// HR 5568 于 2026-01-13 的一次真实全掩;同一站、同一窗口下比较三种口径的真实接触时刻。
|
||||
star := occStar3DTestCoordinate(224.366667, -21.415556)
|
||||
star.ProperMotionRACosDecMasPerYear = 1045
|
||||
star.ProperMotionDecMasPerYear = -1729
|
||||
star.ParallaxMas = 173
|
||||
star.RadialVelocityKmPerSecond = 20
|
||||
noRadial := star
|
||||
noRadial.RadialVelocityKmPerSecond = 0
|
||||
noDistance := star
|
||||
noDistance.ParallaxMas = 0
|
||||
|
||||
const longitude, latitude = 97.471, -45.720
|
||||
start := time.Date(2026, 1, 12, 18, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2026, 1, 13, 6, 0, 0, 0, time.UTC)
|
||||
|
||||
event := func(c StarCoordinate) StarOccultationInfo {
|
||||
t.Helper()
|
||||
events, err := FindStarOccultations(start, end, c, longitude, latitude, 0, OccultationSearchOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("FindStarOccultations: %v", err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("event count = %d, want 1", len(events))
|
||||
}
|
||||
return events[0]
|
||||
}
|
||||
// 无距离确实走的是二维分支,否则下面的对照没有意义。
|
||||
if noDistance.parallaxMas() != 0 || star.parallaxMas() != 173 {
|
||||
t.Fatalf("parallax gate = %v / %v, want 0 for the 2D branch and 173 for the 3D branch",
|
||||
noDistance.parallaxMas(), star.parallaxMas())
|
||||
}
|
||||
unlimited := event(star)
|
||||
limited := event(noDistance)
|
||||
if unlimited.Type != OccultationTotal || limited.Type != OccultationTotal {
|
||||
t.Fatalf("event types = %v / %v, want both total", unlimited.Type, limited.Type)
|
||||
}
|
||||
|
||||
milliseconds := func(a, b time.Time) float64 { return math.Abs(a.Sub(b).Seconds()) * 1000 }
|
||||
// 纯空间运动项:只把径向速度清零,几何仍为三维。这是本轮三维改动引入的那一项。
|
||||
// 实测约 14 ms(路径距离也随径向项变化后由 8.6 ms 升到 14 ms),阈值留约 1.8 倍余量,
|
||||
// 目的是抓口径回退,不是卡精度指标。
|
||||
if got := milliseconds(unlimited.Immersion, event(noRadial).Immersion); got > 25 {
|
||||
t.Fatalf("space-motion term shifts immersion by %.1f ms, want the 25 ms budget respected", got)
|
||||
}
|
||||
// 距离有无会额外启用站心视差修正,属于既有几何而非本轮改动,量级单独记录。
|
||||
fmt.Printf("空间运动项 %.1f ms;距离项 %.1f ms\n",
|
||||
milliseconds(unlimited.Immersion, event(noRadial).Immersion),
|
||||
milliseconds(unlimited.Immersion, limited.Immersion))
|
||||
}
|
||||
|
||||
func TestStarCoordinateTwoAndThreeDimensionsAreDistinctGeometries(t *testing.T) {
|
||||
// 径向速度反号必须让三维结果分居两侧,且二维分支确实给出不同的角距,
|
||||
// 否则"三维对二维"的对照就是空保证。
|
||||
star := occStar3DTestCoordinate(224.366667, -21.415556)
|
||||
star.ProperMotionRACosDecMasPerYear = 1045
|
||||
star.ProperMotionDecMasPerYear = -1729
|
||||
star.ParallaxMas = 173
|
||||
star.RadialVelocityKmPerSecond = -100
|
||||
away := star
|
||||
away.RadialVelocityKmPerSecond = 100
|
||||
noDistance := star
|
||||
noDistance.ParallaxMas = 0
|
||||
|
||||
tt := occultationTimeToTT(time.Date(2026, 1, 13, 0, 14, 7, 0, time.UTC))
|
||||
observer := Observer{Longitude: 97.471, Latitude: -45.720}
|
||||
approaching := starMoonSeparationArcsec(tt, star, observer)
|
||||
receding := starMoonSeparationArcsec(tt, away, observer)
|
||||
twoDimension := starMoonSeparationArcsec(tt, noDistance, observer)
|
||||
if math.Abs(approaching-receding) < 0.005 {
|
||||
t.Fatalf("opposite radial velocities separate by only %.6f arcsec, want a measurable space-motion term",
|
||||
math.Abs(approaching-receding))
|
||||
}
|
||||
if math.Abs(twoDimension-approaching) < 0.05 {
|
||||
t.Fatalf("2D separation %.6f arcsec is indistinguishable from the 3D one %.6f", twoDimension, approaching)
|
||||
}
|
||||
fmt.Printf("同一时刻角距:三维 rv=-100 为 %.4f\",二维为 %.4f\",三维 rv=+100 为 %.4f\"\n",
|
||||
approaching, twoDimension, receding)
|
||||
}
|
||||
|
||||
func TestStarCoordinatePathDistanceAcceptsLightYears(t *testing.T) {
|
||||
// 光年与等价视差必须进入同一套路径几何,不能一个当无穷远。
|
||||
byParallax := occStar3DTestCoordinate(224.366667, -21.415556)
|
||||
byParallax.ParallaxMas = 173
|
||||
byLightYear := byParallax
|
||||
byLightYear.ParallaxMas = 0
|
||||
byLightYear.DistanceLightYear = (1000.0 / 173) * tools.AstronomicalUnitKilometers * (648000 / math.Pi) / tools.LightYearKilometers
|
||||
|
||||
tt := occultationTimeToTT(time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC))
|
||||
fromParallax := starOccultationEphemerisStateAt(tt, byParallax)
|
||||
fromLightYear := starOccultationEphemerisStateAt(tt, byLightYear)
|
||||
if fromParallax.starDistanceKM <= 0 {
|
||||
t.Fatal("parallax input should yield a finite path distance")
|
||||
}
|
||||
// 两种等价输入只允许差一个浮点往返的量级。
|
||||
if relative := math.Abs(fromLightYear.starDistanceKM-fromParallax.starDistanceKM) / fromParallax.starDistanceKM; relative > 1e-12 {
|
||||
t.Fatalf("path distance = %g km from light-years, want %g km from the equivalent parallax (relative %.3g)",
|
||||
fromLightYear.starDistanceKM, fromParallax.starDistanceKM, relative)
|
||||
}
|
||||
kept := newStarOccultationLocalEphemeris(tt, byLightYear)
|
||||
if relative := math.Abs(kept.starDistanceKM()-fromParallax.starDistanceKM) / fromParallax.starDistanceKM; relative > 1e-12 {
|
||||
t.Fatalf("local ephemeris distance = %g km, want %g km (relative %.3g)", kept.starDistanceKM(), fromParallax.starDistanceKM, relative)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarCoordinatePathGeometryUsesPropagatedDistance(t *testing.T) {
|
||||
// 路径与本地星历必须用当日的距离,而不是历元距离。1 pc、径向 -100 km/s,1000 年后
|
||||
// 距离缩短约 10%,两种口径给出的路径距离必须能区分开。
|
||||
star := occStar3DTestCoordinate(189.1975, -5.831944444444)
|
||||
star.ParallaxMas = 1000
|
||||
star.RadialVelocityKmPerSecond = -100
|
||||
epochDistanceKM := 206264806.247 / star.ParallaxMas * occultationPathAstronomicalUnitKM
|
||||
|
||||
for _, years := range []float64{26, 1000, 3000} {
|
||||
tt := occultationTimeToTT(star.Epoch) + years*365.25
|
||||
state := starOccultationEphemerisStateAt(tt, star)
|
||||
if !state.valid {
|
||||
t.Fatalf("ephemeris state invalid at %.0f years", years)
|
||||
}
|
||||
_, _, distanceAU := starApparentRaDecDistanceGeocentric(tt, star)
|
||||
want := distanceAU * occultationPathAstronomicalUnitKM
|
||||
if math.Abs(state.starDistanceKM-want) > want*1e-12 {
|
||||
t.Fatalf("path distance at %.0f years = %g km, want the propagated %g km", years, state.starDistanceKM, want)
|
||||
}
|
||||
if years >= 1000 && math.Abs(state.starDistanceKM-epochDistanceKM) < want*0.01 {
|
||||
t.Fatalf("path distance at %.0f years matches the epoch distance, so the radial term is still dropped", years)
|
||||
}
|
||||
if got := newStarOccultationLocalEphemeris(tt, star).starDistanceKM(); math.Abs(got-want) > want*1e-12 {
|
||||
t.Fatalf("local ephemeris distance at %.0f years = %g km, want %g km", years, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 径向速度为零时两种口径必须重合,否则说明引入了与运动无关的偏移。
|
||||
star.RadialVelocityKmPerSecond = 0
|
||||
still := starOccultationEphemerisStateAt(occultationTimeToTT(star.Epoch)+1000*365.25, star)
|
||||
if math.Abs(still.starDistanceKM-epochDistanceKM) > epochDistanceKM*1e-9 {
|
||||
t.Fatalf("path distance without radial motion = %g km, want the epoch %g km", still.starDistanceKM, epochDistanceKM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStarCoordinateOptimizedEphemerisKeepsFiniteDistance 固定优化分支(密集星历)也带当日距离:
|
||||
// 节点按每个采样时刻的推进距离装配,状态回读若丢掉它,有限距离会悄悄退化成无穷远框架,
|
||||
// 而且 exact 与 optimized 两条分支会在同一颗带视差恒星上给出不同的帧。
|
||||
func TestStarCoordinateOptimizedEphemerisKeepsFiniteDistance(t *testing.T) {
|
||||
star := occStar3DTestCoordinate(189.1975, -5.831944444444)
|
||||
star.ProperMotionRACosDecMasPerYear = -28
|
||||
star.ProperMotionDecMasPerYear = -18
|
||||
star.ParallaxMas = 768.5
|
||||
star.RadialVelocityKmPerSecond = -30
|
||||
|
||||
cst := time.FixedZone("CST", 8*3600)
|
||||
start := time.Date(2025, 6, 5, 0, 0, 0, 0, cst)
|
||||
center := occultationTimeToTT(start.Add(12 * time.Hour))
|
||||
_, _, distanceAU := starApparentRaDecDistanceGeocentric(center, star)
|
||||
want := distanceAU * occultationPathAstronomicalUnitKM
|
||||
|
||||
cache := newStarOccultationEventCache(star)
|
||||
cache.preparePathEphemeris(center, OccultationPathAlgorithmOptimized)
|
||||
if cache.local == nil || !cache.local.dense {
|
||||
t.Fatalf("dense ephemeris not selected, the optimized branch is not exercised")
|
||||
}
|
||||
if got := cache.local.starDistanceKM(); math.Abs(got-want) > want*1e-9 {
|
||||
t.Fatalf("optimized ephemeris distance = %g km, want the propagated %g km", got, want)
|
||||
}
|
||||
state, ok := cache.local.stateAt(center)
|
||||
if !ok {
|
||||
t.Fatal("optimized state unavailable")
|
||||
}
|
||||
if math.Abs(state.starDistanceKM-want) > want*1e-6 {
|
||||
t.Fatalf("optimized state distance = %g km, want the propagated %g km", state.starDistanceKM, want)
|
||||
}
|
||||
denseFrame, ok := starOccultationPathFrameFromState(state)
|
||||
if !ok {
|
||||
t.Fatal("optimized frame unavailable")
|
||||
}
|
||||
exactFrame, ok := starOccultationPathFrameFromState(starOccultationEphemerisStateAt(center, star))
|
||||
if !ok {
|
||||
t.Fatal("exact frame unavailable")
|
||||
}
|
||||
if angle := occultationPathNorm(occultationPathCross(denseFrame.axis, exactFrame.axis)) * 180 / math.Pi * 3600 * 1000; angle > 1e-6 {
|
||||
t.Fatalf("optimized and exact frame axes differ by %.3e mas, want only the dense table's own error", angle)
|
||||
}
|
||||
|
||||
// 距离未知时仍按 0 上报,几何保持无穷远,不要退化成"1 km 处"的假视差。
|
||||
unknown := star
|
||||
unknown.ParallaxMas = 0
|
||||
unknownCache := newStarOccultationEventCache(unknown)
|
||||
unknownCache.preparePathEphemeris(center, OccultationPathAlgorithmOptimized)
|
||||
if unknownCache.local == nil {
|
||||
t.Fatal("local ephemeris missing for the distance-free star")
|
||||
}
|
||||
if got := unknownCache.local.starDistanceKM(); got != 0 {
|
||||
t.Fatalf("distance-free ephemeris distance = %g, want 0", got)
|
||||
}
|
||||
if state, ok := unknownCache.local.stateAt(center); !ok || state.starDistanceKM != 0 {
|
||||
t.Fatalf("distance-free state distance = %g (ok=%v), want 0", state.starDistanceKM, ok)
|
||||
}
|
||||
|
||||
// 路径层:两条分支必须给出同一条路径。
|
||||
options := OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}
|
||||
end := start.Add(24 * time.Hour)
|
||||
exactOptions, optimizedOptions := options, options
|
||||
exactOptions.Algorithm = OccultationPathAlgorithmExact
|
||||
optimizedOptions.Algorithm = OccultationPathAlgorithmOptimized
|
||||
exactPaths, err := FindStarOccultationPaths(start, end, star, exactOptions)
|
||||
if err != nil || len(exactPaths) == 0 {
|
||||
t.Fatalf("exact path: %v (paths=%d)", err, len(exactPaths))
|
||||
}
|
||||
optimizedPaths, err := FindStarOccultationPaths(start, end, star, optimizedOptions)
|
||||
if err != nil || len(optimizedPaths) == 0 {
|
||||
t.Fatalf("optimized path: %v (paths=%d)", err, len(optimizedPaths))
|
||||
}
|
||||
exact, optimized := exactPaths[0], optimizedPaths[0]
|
||||
for _, moment := range []struct {
|
||||
name string
|
||||
exact, optimiz time.Time
|
||||
}{
|
||||
{"start", exact.Start.Time, optimized.Start.Time},
|
||||
{"greatest", exact.Greatest.Time, optimized.Greatest.Time},
|
||||
{"end", exact.End.Time, optimized.End.Time},
|
||||
} {
|
||||
if delta := math.Abs(moment.exact.Sub(moment.optimiz).Seconds()); delta > 1e-3 {
|
||||
t.Fatalf("%s differs by %.6f s between the two ephemeris branches", moment.name, delta)
|
||||
}
|
||||
}
|
||||
if delta := math.Abs(exact.Greatest.WidthKM - optimized.Greatest.WidthKM); delta > 1e-3 {
|
||||
t.Fatalf("greatest width differs by %.6f km between the two ephemeris branches", delta)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user