astro/orbit/orbit_test.go
starainrt 3ffdbe0034
feat: 扩展天文计算能力
- 新增日食、月食、本地可见性、中心线、半影区域、SVG 图示与沙罗周期信息
- 新增行星冲合、留、方照、物理星历、视直径、相位、亮肢角、轨道节点等计算
- 新增木星伽利略卫星位置、现象与接触事件计算
- 新增恒星星表、星座判定、自行修正与观测辅助能力
- 新增 coord、formula、orbit、sundial、lite/sun、lite/moon 等扩展包
- 完善农历年号、月相英文别名、视差角、大气质量、折射、日晷与双星计算
- 增加 NASA、JPL Horizons、IMCCE 等回归测试数据与基线测试
- 重构基础算法文件组织,补充大量公开 API 注释和语义回归测试
- 更新中文和英文 README,补充示例、精度说明、SVG 配图
2026-05-01 22:38:44 +08:00

545 lines
19 KiB
Go

package orbit
import (
"encoding/json"
"math"
"os"
"testing"
"time"
"b612.me/astro/basic"
marspkg "b612.me/astro/mars"
)
const orbitAngleToleranceDeg = 0.02
const orbitDistanceToleranceAU = 3e-4
const orbitVectorToleranceAU = 3e-4
const orbitAstrometricToleranceDeg = 0.02
const orbitAstrometricDistanceToleranceAU = 3e-4
const shanghaiLon = 121.4737
const shanghaiLat = 31.2304
const shanghaiHeightMeters = 20.0
type baselineElements struct {
Form string `json:"form"`
EpochJD float64 `json:"epoch_jd"`
A float64 `json:"a"`
E float64 `json:"e"`
I float64 `json:"i"`
Omega float64 `json:"omega"`
W float64 `json:"w"`
M0 float64 `json:"m0"`
Q float64 `json:"q"`
TpJD float64 `json:"tp_jd"`
}
type baselineVector struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
}
type baselineHeliocentric struct {
Vector baselineVector `json:"vector"`
Lon float64 `json:"lon"`
Lat float64 `json:"lat"`
Distance float64 `json:"distance"`
}
type baselineGeocentric struct {
Vector baselineVector `json:"vector"`
RA float64 `json:"ra"`
Dec float64 `json:"dec"`
Distance float64 `json:"distance"`
}
type baselineObservation struct {
RA float64 `json:"ra"`
Dec float64 `json:"dec"`
Distance float64 `json:"distance"`
}
type baselineSample struct {
JDTT float64 `json:"jd_tt"`
Heliocentric baselineHeliocentric `json:"heliocentric_j2000"`
Geocentric baselineGeocentric `json:"geocentric_equatorial_j2000"`
AstrometricGeocentric baselineObservation `json:"astrometric_geocentric_j2000"`
ApparentGeocentric baselineObservation `json:"apparent_geocentric_equatorial"`
ApparentTopocentric baselineObservation `json:"apparent_topocentric_equatorial"`
}
type baselineObject struct {
Name string `json:"name"`
Elements baselineElements `json:"elements"`
Samples []baselineSample `json:"samples"`
}
func TestGeometricOrbitMatchesJPLBaseline(t *testing.T) {
objects := loadOrbitBaseline(t)
var maxHelioVectorDiffAU float64
var maxHelioLonDiffDeg float64
var maxHelioLatDiffDeg float64
var maxGeoVectorDiffAU float64
var maxGeoRADiffDeg float64
var maxGeoDecDiffDeg float64
var maxGeoDistanceDiffAU float64
for _, object := range objects {
elements := elementsFromBaseline(object)
basicElements := toBasicElements(elements)
for _, sample := range object.Samples {
date := basic.JDE2DateByZone(basic.TD2UT(sample.JDTT, false), time.UTC, false)
helVector := basic.OrbitHeliocentricXYZJ2000(sample.JDTT, basicElements)
helVectorDiff := vectorDiffAU(helVector, sample.Heliocentric.Vector)
if helVectorDiff > maxHelioVectorDiffAU {
maxHelioVectorDiffAU = helVectorDiff
}
if helVectorDiff > orbitVectorToleranceAU {
t.Fatalf("%s helio vector mismatch at JD %.1f: diff=%.9f AU", object.Name, sample.JDTT, helVectorDiff)
}
hel := HeliocentricEclipticJ2000(date, elements)
lonDiff := angleDiffAbs(hel.Lon, sample.Heliocentric.Lon)
if lonDiff > maxHelioLonDiffDeg {
maxHelioLonDiffDeg = lonDiff
}
latDiff := math.Abs(hel.Lat - sample.Heliocentric.Lat)
if latDiff > maxHelioLatDiffDeg {
maxHelioLatDiffDeg = latDiff
}
if lonDiff > orbitAngleToleranceDeg {
t.Fatalf("%s helio lon mismatch at JD %.1f: got %.9f want %.9f", object.Name, sample.JDTT, hel.Lon, sample.Heliocentric.Lon)
}
if latDiff > orbitAngleToleranceDeg {
t.Fatalf("%s helio lat mismatch at JD %.1f: got %.9f want %.9f", object.Name, sample.JDTT, hel.Lat, sample.Heliocentric.Lat)
}
if distanceDiff := math.Abs(hel.Distance - sample.Heliocentric.Distance); distanceDiff > orbitDistanceToleranceAU {
t.Fatalf("%s helio distance mismatch at JD %.1f: got %.9f want %.9f", object.Name, sample.JDTT, hel.Distance, sample.Heliocentric.Distance)
}
geo := GeocentricEquatorialJ2000(date, elements)
geoVector := equatorialVectorAU(geo)
geoVectorDiff := baselineVectorDiffAU(geoVector, sample.Geocentric.Vector)
if geoVectorDiff > maxGeoVectorDiffAU {
maxGeoVectorDiffAU = geoVectorDiff
}
if geoVectorDiff > orbitVectorToleranceAU {
t.Fatalf("%s geo vector mismatch at JD %.1f: diff=%.9f AU", object.Name, sample.JDTT, geoVectorDiff)
}
raDiff := angleDiffAbs(geo.RA, sample.Geocentric.RA)
if raDiff > maxGeoRADiffDeg {
maxGeoRADiffDeg = raDiff
}
decDiff := math.Abs(geo.Dec - sample.Geocentric.Dec)
if decDiff > maxGeoDecDiffDeg {
maxGeoDecDiffDeg = decDiff
}
distanceDiff := math.Abs(geo.Distance - sample.Geocentric.Distance)
if distanceDiff > maxGeoDistanceDiffAU {
maxGeoDistanceDiffAU = distanceDiff
}
if raDiff > orbitAngleToleranceDeg {
t.Fatalf("%s geo RA mismatch at JD %.1f: got %.9f want %.9f", object.Name, sample.JDTT, geo.RA, sample.Geocentric.RA)
}
if decDiff > orbitAngleToleranceDeg {
t.Fatalf("%s geo Dec mismatch at JD %.1f: got %.9f want %.9f", object.Name, sample.JDTT, geo.Dec, sample.Geocentric.Dec)
}
if distanceDiff > orbitDistanceToleranceAU {
t.Fatalf("%s geo distance mismatch at JD %.1f: got %.9f want %.9f", object.Name, sample.JDTT, geo.Distance, sample.Geocentric.Distance)
}
}
}
t.Logf("orbit geometric max diff: helVec=%.9fAU helLon=%.6fdeg helLat=%.6fdeg geoVec=%.9fAU geoRA=%.6fdeg geoDec=%.6fdeg geoDist=%.9fAU",
maxHelioVectorDiffAU, maxHelioLonDiffDeg, maxHelioLatDiffDeg,
maxGeoVectorDiffAU, maxGeoRADiffDeg, maxGeoDecDiffDeg, maxGeoDistanceDiffAU)
}
func TestAstrometricGeocentricMatchesJPLBaseline(t *testing.T) {
maxRADiff, maxDecDiff, maxDistanceDiff := runObservationBaseline(
t,
"astrometric",
func(date time.Time, elements Elements) EquatorialPosition {
return AstrometricGeocentricEquatorialJ2000(date, elements)
},
func(sample baselineSample) baselineObservation {
return sample.AstrometricGeocentric
},
)
t.Logf("orbit astrometric max diff: RA=%.6fdeg Dec=%.6fdeg Dist=%.9fAU", maxRADiff, maxDecDiff, maxDistanceDiff)
}
func TestApparentGeocentricMatchesJPLBaseline(t *testing.T) {
maxRADiff, maxDecDiff, maxDistanceDiff := runObservationBaseline(
t,
"geocentric apparent",
func(date time.Time, elements Elements) EquatorialPosition {
return ApparentGeocentricEquatorial(date, elements)
},
func(sample baselineSample) baselineObservation {
return sample.ApparentGeocentric
},
)
t.Logf("orbit geocentric apparent max diff: RA=%.6fdeg Dec=%.6fdeg Dist=%.9fAU", maxRADiff, maxDecDiff, maxDistanceDiff)
}
func TestApparentTopocentricMatchesJPLBaseline(t *testing.T) {
maxRADiff, maxDecDiff, maxDistanceDiff := runObservationBaseline(
t,
"topocentric apparent",
func(date time.Time, elements Elements) EquatorialPosition {
return ApparentTopocentricEquatorial(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
},
func(sample baselineSample) baselineObservation {
return sample.ApparentTopocentric
},
)
t.Logf("orbit topocentric apparent max diff: RA=%.6fdeg Dec=%.6fdeg Dist=%.9fAU", maxRADiff, maxDecDiff, maxDistanceDiff)
}
func runObservationBaseline(
t *testing.T,
label string,
gotFn func(time.Time, Elements) EquatorialPosition,
wantFn func(baselineSample) baselineObservation,
) (maxRADiff, maxDecDiff, maxDistanceDiff float64) {
t.Helper()
objects := loadOrbitBaseline(t)
for _, object := range objects {
elements := elementsFromBaseline(object)
for _, sample := range object.Samples {
date := basic.JDE2DateByZone(basic.TD2UT(sample.JDTT, false), time.UTC, false)
got := gotFn(date, elements)
want := wantFn(sample)
raDiff := angleDiffAbs(got.RA, want.RA)
if raDiff > maxRADiff {
maxRADiff = raDiff
}
decDiff := math.Abs(got.Dec - want.Dec)
if decDiff > maxDecDiff {
maxDecDiff = decDiff
}
distanceDiff := math.Abs(got.Distance - want.Distance)
if distanceDiff > maxDistanceDiff {
maxDistanceDiff = distanceDiff
}
if raDiff > orbitAstrometricToleranceDeg {
t.Fatalf("%s %s RA mismatch at JD %.1f: got %.9f want %.9f", object.Name, label, sample.JDTT, got.RA, want.RA)
}
if decDiff > orbitAstrometricToleranceDeg {
t.Fatalf("%s %s Dec mismatch at JD %.1f: got %.9f want %.9f", object.Name, label, sample.JDTT, got.Dec, want.Dec)
}
if distanceDiff > orbitAstrometricDistanceToleranceAU {
t.Fatalf("%s %s distance mismatch at JD %.1f: got %.9f want %.9f", object.Name, label, sample.JDTT, got.Distance, want.Distance)
}
}
}
return maxRADiff, maxDecDiff, maxDistanceDiff
}
func TestSecularRatesReduceMarsDriftAgainstVSOP(t *testing.T) {
withRates := Elements{
EpochJD: 2451545.0,
A: 1.52371034,
E: 0.09339410,
I: 1.84969142,
Omega: 49.55953891,
W: -23.94362959 - 49.55953891,
M0: -4.55343205 - (-23.94362959),
ADot: 0.00001847 / 36525.0,
EDot: 0.00007882 / 36525.0,
IDot: -0.00813131 / 36525.0,
OmegaDot: -0.29257343 / 36525.0,
WDot: (0.44441088 - (-0.29257343)) / 36525.0,
MDot: (19140.30268499 - 0.44441088) / 36525.0,
}
static := withRates
static.ADot, static.EDot, static.IDot, static.OmegaDot, static.WDot, static.MDot = 0, 0, 0, 0, 0, 0
cases := []time.Time{
time.Date(1900, 1, 1, 0, 0, 0, 0, time.UTC),
time.Date(1950, 1, 1, 0, 0, 0, 0, time.UTC),
time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
time.Date(2050, 1, 1, 0, 0, 0, 0, time.UTC),
}
for _, date := range cases {
wantRA, wantDec := marspkg.ApparentRaDec(date)
dynamic := ApparentGeocentricEquatorial(date, withRates)
stale := ApparentGeocentricEquatorial(date, static)
dynamicError := angleDiffAbs(dynamic.RA, wantRA) + math.Abs(dynamic.Dec-wantDec)
staticError := angleDiffAbs(stale.RA, wantRA) + math.Abs(stale.Dec-wantDec)
if dynamicError > staticError+1e-9 {
t.Fatalf("%s dynamic elements should improve Mars drift: dynamic=%.9f static=%.9f", date.Format("2006-01-02"), dynamicError, staticError)
}
if date.Equal(time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC)) {
continue
}
if staticError/dynamicError < 4 {
t.Fatalf("%s Mars drift improvement too small: dynamic=%.9f static=%.9f", date.Format("2006-01-02"), dynamicError, staticError)
}
}
}
func TestApparentTopocentricFiniteAndReasonable(t *testing.T) {
elements := Elements{
EpochJD: 2461000.5,
A: 2.765615651508659,
E: 0.07957631994408416,
I: 10.58788658206854,
Omega: 80.24963090816965,
W: 73.29975464616518,
M0: 231.5397330043706,
}
date := time.Date(2025, 11, 21, 0, 0, 0, 0, time.UTC)
geo := ApparentGeocentricEquatorial(date, elements)
top := ApparentTopocentricEquatorial(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
if math.IsNaN(top.RA) || math.IsNaN(top.Dec) || math.IsNaN(top.Distance) {
t.Fatalf("topocentric result contains NaN: %+v", top)
}
if top.Distance <= 0 {
t.Fatalf("unexpected topocentric distance: %.12f", top.Distance)
}
if math.Abs(top.Distance-geo.Distance) > 5e-5 {
t.Fatalf("topocentric distance shift unexpectedly large: geo=%.12f top=%.12f", geo.Distance, top.Distance)
}
if angleDiffAbs(top.RA, geo.RA) > 1 || math.Abs(top.Dec-geo.Dec) > 1 {
t.Fatalf("topocentric shift unexpectedly large: geo=%+v top=%+v", geo, top)
}
if angleDiffAbs(top.RA, geo.RA) == 0 && math.Abs(top.Dec-geo.Dec) == 0 {
t.Fatalf("topocentric correction should not be identically zero")
}
}
func TestMeanMotionAndAnomaliesAreFinite(t *testing.T) {
elements := Elements{
EpochJD: 2461000.5,
A: 2.765615651508659,
E: 0.07957631994408416,
I: 10.58788658206854,
Omega: 80.24963090816965,
W: 73.29975464616518,
M0: 231.5397330043706,
}
date := time.Date(2025, 11, 21, 0, 0, 0, 0, time.UTC)
meanMotion := MeanMotion(elements)
if math.IsNaN(meanMotion) || math.IsInf(meanMotion, 0) || meanMotion <= 0 {
t.Fatalf("invalid mean motion: %.18f", meanMotion)
}
meanAnomaly := MeanAnomaly(date, elements)
trueAnomaly := TrueAnomaly(date, elements)
for name, value := range map[string]float64{"mean": meanAnomaly, "true": trueAnomaly} {
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || value >= 360 {
t.Fatalf("%s anomaly out of range: %.18f", name, value)
}
}
}
func TestObservationHelpersMatchTopocentricCoordinates(t *testing.T) {
elements := sampleObservationElements()
date := time.Date(2025, 11, 21, 20, 0, 0, 0, time.FixedZone("CST", 8*3600))
altitude := Altitude(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
zenith := Zenith(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
azimuth := Azimuth(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
hourAngle := HourAngle(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
topocentric := ApparentTopocentricEquatorial(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
for name, value := range map[string]float64{
"altitude": altitude,
"zenith": zenith,
"azimuth": azimuth,
"hourAngle": hourAngle,
"ra": topocentric.RA,
"dec": topocentric.Dec,
} {
if math.IsNaN(value) || math.IsInf(value, 0) {
t.Fatalf("%s is not finite: %.18f", name, value)
}
}
jde := basic.Date2JDE(date)
_, offsetSeconds := date.Zone()
timezone := float64(offsetSeconds) / 3600.0
siderealLongitude := normalize360(basic.ApparentSiderealTime(jde-timezone/24.0)*15 + shanghaiLon)
wantHourAngle := normalize360(siderealLongitude - topocentric.RA)
if angleDiffAbs(hourAngle, wantHourAngle) > 1e-9 {
t.Fatalf("hour angle mismatch: got %.12f want %.12f", hourAngle, wantHourAngle)
}
wantAltitude := math.Asin(
math.Sin(shanghaiLat*math.Pi/180)*math.Sin(topocentric.Dec*math.Pi/180)+
math.Cos(topocentric.Dec*math.Pi/180)*math.Cos(shanghaiLat*math.Pi/180)*math.Cos(wantHourAngle*math.Pi/180),
) * 180 / math.Pi
if math.Abs(altitude-wantAltitude) > 1e-9 {
t.Fatalf("altitude mismatch: got %.12f want %.12f", altitude, wantAltitude)
}
wantZenith := 90 - wantAltitude
if math.Abs(zenith-wantZenith) > 1e-9 {
t.Fatalf("zenith mismatch: got %.12f want %.12f", zenith, wantZenith)
}
wantAzimuth := sphericalAzimuthFromHourAngle(wantHourAngle, topocentric.Dec, shanghaiLat)
if angleDiffAbs(azimuth, wantAzimuth) > 1e-9 {
t.Fatalf("azimuth mismatch: got %.12f want %.12f", azimuth, wantAzimuth)
}
}
func TestCulminationTimeMaximizesAltitude(t *testing.T) {
elements := sampleObservationElements()
date := time.Date(2025, 11, 21, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
culmination := CulminationTime(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
before := Altitude(culmination.Add(-5*time.Minute), elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
at := Altitude(culmination, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
after := Altitude(culmination.Add(5*time.Minute), elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
if at < before || at < after {
t.Fatalf("culmination should maximize altitude: before=%.9f at=%.9f after=%.9f", before, at, after)
}
if angleDiffAbs(HourAngle(culmination, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters), 0) > 0.02 {
t.Fatalf("culmination hour angle should be near zero")
}
}
func TestRiseSetTimesReachStandardAltitude(t *testing.T) {
elements := sampleObservationElements()
date := time.Date(2025, 11, 21, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
rise, err := RiseTime(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters, true)
if err != nil {
t.Fatalf("rise time failed: %v", err)
}
set, err := SetTime(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters, true)
if err != nil {
t.Fatalf("set time failed: %v", err)
}
culmination := CulminationTime(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
targetAltitude := basic.StandardAltitudePlanet(1, shanghaiHeightMeters, shanghaiLat)
riseAltitude := Altitude(rise, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
setAltitude := Altitude(set, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
if math.Abs(riseAltitude-targetAltitude) > 0.03 {
t.Fatalf("rise altitude mismatch: got %.9f want %.9f", riseAltitude, targetAltitude)
}
if math.Abs(setAltitude-targetAltitude) > 0.03 {
t.Fatalf("set altitude mismatch: got %.9f want %.9f", setAltitude, targetAltitude)
}
if !rise.Before(culmination) {
t.Fatalf("rise should precede culmination: rise=%s culmination=%s", rise, culmination)
}
if !culmination.Before(set) {
t.Fatalf("culmination should precede set: culmination=%s set=%s", culmination, set)
}
}
func loadOrbitBaseline(t *testing.T) []baselineObject {
t.Helper()
data, err := os.ReadFile("testdata/orbit_baseline.json")
if err != nil {
t.Fatalf("read baseline: %v", err)
}
var objects []baselineObject
if err := json.Unmarshal(data, &objects); err != nil {
t.Fatalf("decode baseline: %v", err)
}
return objects
}
func elementsFromBaseline(object baselineObject) Elements {
if object.Elements.Form == "perihelion" {
return Elements{
E: object.Elements.E,
I: object.Elements.I,
Omega: object.Elements.Omega,
W: object.Elements.W,
Q: object.Elements.Q,
TpJD: object.Elements.TpJD,
}
}
return Elements{
EpochJD: object.Elements.EpochJD,
A: object.Elements.A,
E: object.Elements.E,
I: object.Elements.I,
Omega: object.Elements.Omega,
W: object.Elements.W,
M0: object.Elements.M0,
}
}
func vectorDiffAU(vector basic.Vector3, want baselineVector) float64 {
dx := vector[0] - want.X
dy := vector[1] - want.Y
dz := vector[2] - want.Z
return math.Sqrt(dx*dx + dy*dy + dz*dz)
}
func baselineVectorDiffAU(got, want baselineVector) float64 {
dx := got.X - want.X
dy := got.Y - want.Y
dz := got.Z - want.Z
return math.Sqrt(dx*dx + dy*dy + dz*dz)
}
func angleDiffAbs(got, want float64) float64 {
diff := math.Abs(got - want)
if diff > 180 {
diff = 360 - diff
}
return diff
}
func equatorialVectorAU(position EquatorialPosition) baselineVector {
raRad := position.RA * math.Pi / 180
decRad := position.Dec * math.Pi / 180
cosDec := math.Cos(decRad)
return baselineVector{
X: position.Distance * cosDec * math.Cos(raRad),
Y: position.Distance * cosDec * math.Sin(raRad),
Z: position.Distance * math.Sin(decRad),
}
}
func sampleObservationElements() Elements {
return Elements{
EpochJD: 2461000.5,
A: 2.765615651508659,
E: 0.07957631994408416,
I: 10.58788658206854,
Omega: 80.24963090816965,
W: 73.29975464616518,
M0: 231.5397330043706,
}
}
func normalize360(value float64) float64 {
value = math.Mod(value, 360)
if value < 0 {
value += 360
}
return value
}
func sphericalAzimuthFromHourAngle(hourAngle, dec, lat float64) float64 {
tanAzimuth := math.Sin(hourAngle*math.Pi/180) / (math.Cos(hourAngle*math.Pi/180)*math.Sin(lat*math.Pi/180) - math.Tan(dec*math.Pi/180)*math.Cos(lat*math.Pi/180))
azimuth := math.Atan(tanAzimuth) * 180 / math.Pi
if azimuth < 0 {
if hourAngle/15 < 12 {
return azimuth + 360
}
return azimuth + 180
}
if hourAngle/15 < 12 {
return azimuth + 180
}
return azimuth
}