feat: 扩展天文计算能力

- 新增日食、月食、本地可见性、中心线、半影区域、SVG 图示与沙罗周期信息
- 新增行星冲合、留、方照、物理星历、视直径、相位、亮肢角、轨道节点等计算
- 新增木星伽利略卫星位置、现象与接触事件计算
- 新增恒星星表、星座判定、自行修正与观测辅助能力
- 新增 coord、formula、orbit、sundial、lite/sun、lite/moon 等扩展包
- 完善农历年号、月相英文别名、视差角、大气质量、折射、日晷与双星计算
- 增加 NASA、JPL Horizons、IMCCE 等回归测试数据与基线测试
- 重构基础算法文件组织,补充大量公开 API 注释和语义回归测试
- 更新中文和英文 README,补充示例、精度说明、SVG 配图
This commit is contained in:
2026-05-01 22:38:44 +08:00
parent 98ff574495
commit 3ffdbe0034
365 changed files with 63589 additions and 17508 deletions
+22
View File
@@ -0,0 +1,22 @@
// Package orbit propagates lightweight Sun-centered two-body conic orbits.
//
// Supported input styles:
// - classical elliptic elements: A/E/I/Omega/W/M0 at EpochJD
// - perihelion form: Q/E/I/Omega/W/TpJD for high-eccentricity, parabolic,
// or hyperbolic comet-like trajectories
//
// All input angles are in degrees. EpochJD and TpJD are TT/TDB Julian days.
// Returned geometric positions do not include perturbations beyond the supplied
// elements. Astrometric results include down-leg light-time correction.
// Apparent results follow this repository's existing planet semantics:
// astrometric position plus nutation/of-date coordinate conversion, without a
// full external aberration model. The package also provides observer-facing
// altitude/azimuth/hour-angle and rise/transit/set helpers built on top of the
// apparent topocentric coordinates. Rise/set helpers return package-local
// sentinel errors ERR_ORBIT_NEVER_RISE / ERR_ORBIT_NEVER_SET, matching the
// convention used by other public observation packages in this repository.
//
// In addition, the package includes a small classical visual-binary helper
// based on the standard P/T/e/a/i/Omega/omega element set, returning apparent
// position angle and separation on the sky.
package orbit
+15
View File
@@ -0,0 +1,15 @@
package orbit
import (
"time"
"b612.me/astro/basic"
)
// AsteroidMagnitudeHG 小行星 H-G 模型视星等 / asteroid apparent magnitude using the H-G model.
//
// absoluteMagnitude 为绝对星等 HslopeParameter 为斜率参数 G。
// absoluteMagnitude is the absolute magnitude H, and slopeParameter is the slope parameter G.
func AsteroidMagnitudeHG(date time.Time, elements Elements, absoluteMagnitude, slopeParameter float64) float64 {
return basic.OrbitAsteroidMagnitudeHG(ttJulianDay(date), toBasicElements(elements), absoluteMagnitude, slopeParameter)
}
+59
View File
@@ -0,0 +1,59 @@
package orbit
import (
"math"
"testing"
"time"
"b612.me/astro/basic"
)
func TestAsteroidMagnitudeHGMatchesDirectFormula(t *testing.T) {
elements := sampleObservationElements()
date := time.Date(2025, 11, 21, 20, 0, 0, 0, time.UTC)
absoluteMagnitude := 3.34
slopeParameter := 0.12
got := AsteroidMagnitudeHG(date, elements, absoluteMagnitude, slopeParameter)
if math.IsNaN(got) || math.IsInf(got, 0) {
t.Fatalf("magnitude should be finite: %.18f", got)
}
sunDistance := SunDistance(date, elements)
earthDistance := EarthDistance(date, elements)
phaseAngle := PhaseAngle(date, elements)
want := absoluteMagnitude + 5*math.Log10(sunDistance*earthDistance) - 2.5*math.Log10(hgSlopeBlendTest(phaseAngle, slopeParameter))
if math.Abs(got-want) > 1e-12 {
t.Fatalf("H-G magnitude mismatch: got %.15f want %.15f", got, want)
}
}
func TestAsteroidMagnitudeHGShiftsWithAbsoluteMagnitude(t *testing.T) {
elements := sampleObservationElements()
date := time.Date(2025, 11, 21, 20, 0, 0, 0, time.UTC)
baseMagnitude := AsteroidMagnitudeHG(date, elements, 3.34, 0.12)
shiftedMagnitude := AsteroidMagnitudeHG(date, elements, 4.34, 0.12)
if math.Abs((shiftedMagnitude-baseMagnitude)-1) > 1e-12 {
t.Fatalf("H shift should move magnitude by exactly 1: base=%.15f shifted=%.15f", baseMagnitude, shiftedMagnitude)
}
}
func TestAsteroidMagnitudeHGInvalidInputReturnsNaN(t *testing.T) {
elements := sampleObservationElements()
date := time.Date(2025, 11, 21, 20, 0, 0, 0, time.UTC)
if !math.IsNaN(AsteroidMagnitudeHG(date, elements, math.NaN(), 0.12)) {
t.Fatalf("NaN H should produce NaN result")
}
if !math.IsNaN(basic.OrbitAsteroidMagnitudeHG(math.NaN(), toBasicElements(elements), 3.34, 0.12)) {
t.Fatalf("NaN jd should produce NaN result")
}
}
func hgSlopeBlendTest(phaseAngle, slopeParameter float64) float64 {
tanHalf := math.Tan((phaseAngle * math.Pi / 180) / 2)
phi1 := math.Exp(-3.33 * math.Pow(tanHalf, 0.63))
phi2 := math.Exp(-1.87 * math.Pow(tanHalf, 1.22))
return (1-slopeParameter)*phi1 + slopeParameter*phi2
}
+277
View File
@@ -0,0 +1,277 @@
package orbit
import (
"errors"
"time"
"b612.me/astro/basic"
)
var (
ERR_ORBIT_NEVER_RISE = errors.New("ERROR:轨道目标今日永远在地平线下!")
ERR_ORBIT_NEVER_SET = errors.New("ERROR:轨道目标今日永远在地平线上!")
)
// Elements 日心二体圆锥曲线根数,参考系为 J2000 平黄道/平春分点。
// EpochJD 与 TpJD 使用 TT/TDB 对应的儒略日。
//
// 经典椭圆根数:A/E/I/Omega/W/M0
// 近日点形式:Q/E/I/Omega/W/TpJD
//
// 线性 rates 仅作用于经典椭圆根数,单位均为每天变化量。
type Elements struct {
EpochJD float64 // 历元儒略日(TT/TDB / epoch Julian day in TT/TDB.
A float64 // 半长径,单位 AU / semi-major axis in AU.
E float64 // 离心率 / eccentricity.
I float64 // 轨道倾角,单位度 / inclination in degrees.
Omega float64 // 升交点黄经,单位度 / longitude of ascending node in degrees.
W float64 // 近日点幅角,单位度 / argument of perihelion in degrees.
M0 float64 // 历元平近点角,单位度 / mean anomaly at epoch in degrees.
Q float64 // 近日点距离,单位 AU / perihelion distance in AU.
TpJD float64 // 近日点通过时刻(TT/TDB JD / perihelion passage time in TT/TDB Julian day.
ADot float64 // 半长径日变化,单位 AU/day / daily rate of A.
EDot float64 // 离心率日变化,单位 1/day / daily rate of E.
IDot float64 // 倾角日变化,单位 deg/day / daily rate of I.
OmegaDot float64 // 升交点黄经日变化,单位 deg/day / daily rate of Omega.
WDot float64 // 近日点幅角日变化,单位 deg/day / daily rate of W.
MDot float64 // 平近点角日变化,单位 deg/day / daily rate of M.
}
// EclipticPosition 黄道球坐标结果,Lon/Lat 单位度,Distance 单位 AU。
type EclipticPosition struct {
Lon float64
Lat float64
Distance float64
}
// EquatorialPosition 赤道球坐标结果,RA/Dec 单位度,Distance 单位 AU。
type EquatorialPosition struct {
RA float64
Dec float64
Distance float64
}
// MeanMotion 平均角速度 / mean motion.
//
// 返回平均角速度,单位度/日;对抛物线和双曲线轨道返回 `NaN`。
func MeanMotion(elements Elements) float64 {
return basic.OrbitMeanMotion(toBasicElements(elements))
}
// MeanAnomaly 平近点角 / mean anomaly.
//
// 返回给定时刻的平近点角,单位度;对抛物线和双曲线轨道返回 `NaN`。
func MeanAnomaly(date time.Time, elements Elements) float64 {
return basic.OrbitMeanAnomaly(ttJulianDay(date), toBasicElements(elements))
}
// TrueAnomaly 真近点角 / true anomaly.
//
// 返回给定时刻的真近点角,单位度。
func TrueAnomaly(date time.Time, elements Elements) float64 {
return basic.OrbitTrueAnomaly(ttJulianDay(date), toBasicElements(elements))
}
// HeliocentricEclipticJ2000 日心 J2000 平黄道坐标 / heliocentric J2000 ecliptic coordinates.
//
// 返回黄经、黄纬和距离;角度单位度,距离单位 AU。
func HeliocentricEclipticJ2000(date time.Time, elements Elements) EclipticPosition {
lon, lat, distance := basic.OrbitHeliocentricEclipticJ2000(ttJulianDay(date), toBasicElements(elements))
return EclipticPosition{Lon: lon, Lat: lat, Distance: distance}
}
// HeliocentricEcliptic 日心历元黄道坐标 / heliocentric ecliptic coordinates of date.
//
// 返回历元黄经、黄纬和距离;角度单位度,距离单位 AU。
func HeliocentricEcliptic(date time.Time, elements Elements) EclipticPosition {
lon, lat, distance := basic.OrbitHeliocentricEcliptic(ttJulianDay(date), toBasicElements(elements))
return EclipticPosition{Lon: lon, Lat: lat, Distance: distance}
}
// GeocentricEclipticJ2000 地心 J2000 平黄道坐标 / geocentric J2000 ecliptic coordinates.
//
// 返回黄经、黄纬和距离;角度单位度,距离单位 AU。
func GeocentricEclipticJ2000(date time.Time, elements Elements) EclipticPosition {
lon, lat, distance := basic.OrbitGeocentricEclipticJ2000(ttJulianDay(date), toBasicElements(elements))
return EclipticPosition{Lon: lon, Lat: lat, Distance: distance}
}
// GeocentricEcliptic 地心历元黄道坐标 / geocentric ecliptic coordinates of date.
//
// 返回历元黄经、黄纬和距离;角度单位度,距离单位 AU。
func GeocentricEcliptic(date time.Time, elements Elements) EclipticPosition {
lon, lat, distance := basic.OrbitGeocentricEcliptic(ttJulianDay(date), toBasicElements(elements))
return EclipticPosition{Lon: lon, Lat: lat, Distance: distance}
}
// GeocentricEquatorialJ2000 地心 J2000 平赤道坐标 / geocentric J2000 equatorial coordinates.
//
// 返回赤经、赤纬和距离;角度单位度,距离单位 AU。
func GeocentricEquatorialJ2000(date time.Time, elements Elements) EquatorialPosition {
ra, dec, distance := basic.OrbitGeocentricEquatorialJ2000(ttJulianDay(date), toBasicElements(elements))
return EquatorialPosition{RA: ra, Dec: dec, Distance: distance}
}
// GeocentricEquatorial 地心历元平赤道坐标 / geocentric equatorial coordinates of date.
//
// 返回历元赤经、赤纬和距离;角度单位度,距离单位 AU。
func GeocentricEquatorial(date time.Time, elements Elements) EquatorialPosition {
ra, dec, distance := basic.OrbitGeocentricEquatorial(ttJulianDay(date), toBasicElements(elements))
return EquatorialPosition{RA: ra, Dec: dec, Distance: distance}
}
// AstrometricGeocentricEquatorialJ2000 地心测算 J2000 赤道坐标 / astrometric geocentric J2000 equatorial coordinates.
//
// 返回加入光行时修正后的地心 J2000 赤经、赤纬和距离;角度单位度,距离单位 AU。
func AstrometricGeocentricEquatorialJ2000(date time.Time, elements Elements) EquatorialPosition {
ra, dec, distance := basic.OrbitAstrometricGeocentricEquatorialJ2000(ttJulianDay(date), toBasicElements(elements))
return EquatorialPosition{RA: ra, Dec: dec, Distance: distance}
}
// ApparentGeocentricEcliptic 地心视黄道坐标 / apparent geocentric ecliptic coordinates.
//
// 返回加入光行时与章动修正后的地心视黄经、黄纬和距离;角度单位度,距离单位 AU。
func ApparentGeocentricEcliptic(date time.Time, elements Elements) EclipticPosition {
lon, lat, distance := basic.OrbitApparentGeocentricEcliptic(ttJulianDay(date), toBasicElements(elements))
return EclipticPosition{Lon: lon, Lat: lat, Distance: distance}
}
// ApparentGeocentricEquatorial 地心视赤道坐标 / apparent geocentric equatorial coordinates.
//
// 返回加入光行时与章动修正后的地心视赤经、赤纬和距离;角度单位度,距离单位 AU。
func ApparentGeocentricEquatorial(date time.Time, elements Elements) EquatorialPosition {
ra, dec, distance := basic.OrbitApparentGeocentricEquatorial(ttJulianDay(date), toBasicElements(elements))
return EquatorialPosition{RA: ra, Dec: dec, Distance: distance}
}
// ApparentTopocentricEquatorial 站心视赤道坐标 / apparent topocentric equatorial coordinates.
//
// 返回加入光行时、章动和站心修正后的视赤经、赤纬和距离;
// `observerLon` 东经为正,`observerLat` 北纬为正,`observerHeight` 单位米。
func ApparentTopocentricEquatorial(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) EquatorialPosition {
ra, dec, distance := basic.OrbitApparentTopocentricEquatorial(ttJulianDay(date), observerLon, observerLat, observerHeight, toBasicElements(elements))
return EquatorialPosition{RA: ra, Dec: dec, Distance: distance}
}
// Altitude 视高度角 / apparent altitude.
//
// 返回目标在观测者所在地的视高度角,单位度;经度东正西负,纬度北正南负,海拔单位米。
func Altitude(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) float64 {
jde := basic.Date2JDE(date)
return basic.OrbitHeight(jde, observerLon, observerLat, observationTimezone(date), observerHeight, toBasicElements(elements))
}
// Zenith 天顶距 / zenith distance.
//
// 返回目标在观测者所在地的天顶距,单位度。
func Zenith(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) float64 {
return 90 - Altitude(date, elements, observerLon, observerLat, observerHeight)
}
// Azimuth 视方位角 / apparent azimuth.
//
// 返回目标在观测者所在地的视方位角,按正北为 0°、向东增加。
func Azimuth(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) float64 {
jde := basic.Date2JDE(date)
return basic.OrbitAzimuth(jde, observerLon, observerLat, observationTimezone(date), observerHeight, toBasicElements(elements))
}
// HourAngle 站心视时角 / topocentric hour angle.
//
// 返回目标在观测者所在地的站心视时角,单位度。
func HourAngle(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) float64 {
jde := basic.Date2JDE(date)
return basic.OrbitHourAngle(jde, observerLon, observerLat, observationTimezone(date), observerHeight, toBasicElements(elements))
}
// CulminationTime 中天时刻 / culmination time.
//
// 返回目标在给定当地日期内的中天时刻,结果保持输入 `date` 的时区。
func CulminationTime(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) time.Time {
if date.Hour() > 12 {
date = date.Add(-12 * time.Hour)
}
timezone := observationTimezone(date)
jde := basic.Date2JDE(date)
calcJde := basic.OrbitCulminationTime(jde, observerLon, observerLat, timezone, observerHeight, toBasicElements(elements)) - timezone/24.0
return basic.JDE2DateByZone(calcJde, date.Location(), false)
}
// RiseTime 升起时刻 / rise time.
//
// 返回目标在给定当地日期内的升起时刻;`aero=true` 时加入标准大气折射修正。
func RiseTime(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64, aero bool) (time.Time, error) {
var aeroFloat float64
if aero {
aeroFloat = 1
}
if date.Hour() > 12 {
date = date.Add(-12 * time.Hour)
}
timezone := observationTimezone(date)
jde := basic.Date2JDE(date)
calcJde, err := basic.OrbitRiseTime(jde, observerLon, observerLat, timezone, aeroFloat, observerHeight, toBasicElements(elements))
return orbitRiseSetResult(date, calcJde, err)
}
// SetTime 落下时刻 / set time.
//
// 返回目标在给定当地日期内的落下时刻;`aero=true` 时加入标准大气折射修正。
func SetTime(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64, aero bool) (time.Time, error) {
var aeroFloat float64
if aero {
aeroFloat = 1
}
if date.Hour() > 12 {
date = date.Add(-12 * time.Hour)
}
timezone := observationTimezone(date)
jde := basic.Date2JDE(date)
calcJde, err := basic.OrbitSetTime(jde, observerLon, observerLat, timezone, aeroFloat, observerHeight, toBasicElements(elements))
return orbitRiseSetResult(date, calcJde, err)
}
func orbitRiseSetResult(date time.Time, jde float64, err error) (time.Time, error) {
if err != nil {
switch {
case errors.Is(err, basic.ErrNeverRise):
return time.Time{}, ERR_ORBIT_NEVER_RISE
case errors.Is(err, basic.ErrNeverSet):
return time.Time{}, ERR_ORBIT_NEVER_SET
default:
return time.Time{}, err
}
}
return basic.JDE2DateByZone(jde, date.Location(), true), nil
}
func observationTimezone(date time.Time) float64 {
_, loc := date.Zone()
return float64(loc) / 3600.0
}
func ttJulianDay(date time.Time) float64 {
jdeUTC := basic.Date2JDE(date.UTC())
return basic.TD2UT(jdeUTC, true)
}
func toBasicElements(elements Elements) basic.OrbitElements {
return basic.OrbitElements{
EpochJD: elements.EpochJD,
A: elements.A,
E: elements.E,
I: elements.I,
Omega: elements.Omega,
W: elements.W,
M0: elements.M0,
Q: elements.Q,
TpJD: elements.TpJD,
ADot: elements.ADot,
EDot: elements.EDot,
IDot: elements.IDot,
OmegaDot: elements.OmegaDot,
WDot: elements.WDot,
MDot: elements.MDot,
}
}
+544
View File
@@ -0,0 +1,544 @@
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
}
+19
View File
@@ -0,0 +1,19 @@
package orbit
import (
"time"
"b612.me/astro/basic"
)
// ParallacticAngle 轨道目标视差角(天顶方向角) / orbit-target parallactic angle.
//
// 返回轨道目标在观测者所在地的视差角,单位度;`observerLon` 东经为正,`observerLat` 北纬为正,`observerHeight` 单位米。
func ParallacticAngle(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) float64 {
position := ApparentTopocentricEquatorial(date, elements, observerLon, observerLat, observerHeight)
return basic.ParallacticAngleByHourAngle(
HourAngle(date, elements, observerLon, observerLat, observerHeight),
position.Dec,
observerLat,
)
}
+25
View File
@@ -0,0 +1,25 @@
package orbit
import (
"math"
"testing"
"time"
"b612.me/astro/basic"
)
func TestParallacticAngleMatchesHourAngleForm(t *testing.T) {
elements := sampleObservationElements()
date := time.Date(2025, 11, 21, 20, 0, 0, 0, time.FixedZone("CST", 8*3600))
got := ParallacticAngle(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
position := ApparentTopocentricEquatorial(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
want := basic.ParallacticAngleByHourAngle(
HourAngle(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters),
position.Dec,
shanghaiLat,
)
if math.Abs(got-want) > 1e-12 {
t.Fatalf("parallactic angle mismatch: got %.15f want %.15f", got, want)
}
}
+55
View File
@@ -0,0 +1,55 @@
package orbit
import (
"time"
"b612.me/astro/basic"
)
// EarthDistance 地心距离 / Earth distance.
//
// 返回轨道目标在 date 对应绝对时刻到地球的几何距离,单位 AU。
// Returns the geometric distance from the orbiting target to Earth at the instant represented by date, in astronomical units.
func EarthDistance(date time.Time, elements Elements) float64 {
return basic.OrbitEarthDistance(ttJulianDay(date), toBasicElements(elements))
}
// SunDistance 日心距离 / Sun distance.
//
// 返回轨道目标在 date 对应绝对时刻到太阳的几何距离,单位 AU。
// Returns the geometric distance from the orbiting target to the Sun at the instant represented by date, in astronomical units.
func SunDistance(date time.Time, elements Elements) float64 {
return basic.OrbitSunDistance(ttJulianDay(date), toBasicElements(elements))
}
// Elongation 日距角 / elongation.
//
// 返回轨道目标与太阳在地心视方向上的角距,单位度。
// Returns the apparent geocentric angular separation between the target and the Sun, in degrees.
func Elongation(date time.Time, elements Elements) float64 {
return basic.OrbitElongation(ttJulianDay(date), toBasicElements(elements))
}
// PhaseAngle 相位角 / phase angle.
//
// 返回轨道目标的相位角,单位度。
// Returns the phase angle of the orbiting target, in degrees.
func PhaseAngle(date time.Time, elements Elements) float64 {
return basic.OrbitPhaseAngle(ttJulianDay(date), toBasicElements(elements))
}
// IlluminatedFraction 被照亮比例 / illuminated fraction.
//
// 返回轨道目标被太阳照亮的可见比例,范围通常为 [0, 1]。
// Returns the illuminated fraction of the target, typically in the range [0, 1].
func IlluminatedFraction(date time.Time, elements Elements) float64 {
return basic.OrbitIlluminatedFraction(ttJulianDay(date), toBasicElements(elements))
}
// Phase 相位 / phase.
//
// 返回轨道目标被照亮比例,是 IlluminatedFraction 的别名。
// Returns the illuminated fraction of the target and is an alias of IlluminatedFraction.
func Phase(date time.Time, elements Elements) float64 {
return IlluminatedFraction(date, elements)
}
+80
View File
@@ -0,0 +1,80 @@
package orbit
import (
"math"
"testing"
"time"
"b612.me/astro/basic"
)
func TestDistancePhaseAndElongationHelpers(t *testing.T) {
elements := sampleObservationElements()
date := time.Date(2025, 11, 21, 20, 0, 0, 0, time.UTC)
sunDistance := SunDistance(date, elements)
earthDistance := EarthDistance(date, elements)
phaseAngle := PhaseAngle(date, elements)
illuminatedFraction := IlluminatedFraction(date, elements)
phase := Phase(date, elements)
elongation := Elongation(date, elements)
for name, value := range map[string]float64{
"sunDistance": sunDistance,
"earthDistance": earthDistance,
"phaseAngle": phaseAngle,
"illuminatedFraction": illuminatedFraction,
"phase": phase,
"elongation": elongation,
} {
if math.IsNaN(value) || math.IsInf(value, 0) {
t.Fatalf("%s is not finite: %.18f", name, value)
}
}
if math.Abs(sunDistance-HeliocentricEclipticJ2000(date, elements).Distance) > 1e-12 {
t.Fatalf("sun distance mismatch: got %.15f want %.15f", sunDistance, HeliocentricEclipticJ2000(date, elements).Distance)
}
if math.Abs(earthDistance-GeocentricEclipticJ2000(date, elements).Distance) > 1e-12 {
t.Fatalf("earth distance mismatch: got %.15f want %.15f", earthDistance, GeocentricEclipticJ2000(date, elements).Distance)
}
if phaseAngle < 0 || phaseAngle > 180 {
t.Fatalf("phase angle out of range: %.12f", phaseAngle)
}
if elongation < 0 || elongation > 180 {
t.Fatalf("elongation out of range: %.12f", elongation)
}
if illuminatedFraction < 0 || illuminatedFraction > 1 {
t.Fatalf("illuminated fraction out of range: %.12f", illuminatedFraction)
}
if math.Abs(phase-illuminatedFraction) > 1e-12 {
t.Fatalf("phase alias mismatch: phase=%.15f illuminated=%.15f", phase, illuminatedFraction)
}
wantIlluminatedFraction := (1 + math.Cos(phaseAngle*math.Pi/180)) / 2
if math.Abs(illuminatedFraction-wantIlluminatedFraction) > 1e-12 {
t.Fatalf("illuminated fraction mismatch: got %.15f want %.15f", illuminatedFraction, wantIlluminatedFraction)
}
jd := ttJulianDay(date)
wantPhaseAngle := math.Acos(clampUnitLocal((sunDistance*sunDistance+earthDistance*earthDistance-basic.EarthAway(jd)*basic.EarthAway(jd))/(2*sunDistance*earthDistance))) * 180 / math.Pi
if math.Abs(phaseAngle-wantPhaseAngle) > 1e-12 {
t.Fatalf("phase angle mismatch: got %.15f want %.15f", phaseAngle, wantPhaseAngle)
}
objectLon, objectLat, _ := basic.OrbitApparentGeocentricEcliptic(jd, toBasicElements(elements))
wantElongation := basic.StarAngularSeparation(objectLon, objectLat, basic.HSunApparentLo(jd), basic.HSunTrueBo(jd))
if math.Abs(elongation-wantElongation) > 1e-12 {
t.Fatalf("elongation mismatch: got %.15f want %.15f", elongation, wantElongation)
}
}
func clampUnitLocal(value float64) float64 {
if value > 1 {
return 1
}
if value < -1 {
return -1
}
return value
}
+395
View File
@@ -0,0 +1,395 @@
[
{
"name": "1 Ceres",
"elements": {
"form": "classical",
"epoch_jd": 2461000.5,
"a": 2.765615651508659,
"e": 0.07957631994408416,
"i": 10.58788658206854,
"omega": 80.24963090816965,
"w": 73.29975464616518,
"m0": 231.5397330043706,
"q": 2.545538135581839,
"tp_jd": 2461599.9493352976
},
"samples": [
{
"jd_tt": 2460990.5,
"heliocentric_j2000": {
"vector": {
"x": 2.7546829728687694,
"y": 0.8343321246385441,
"z": -0.4810715723416197
},
"lon": 16.850391840049628,
"lat": -9.488687283018647,
"distance": 2.918187491051976
},
"geocentric_equatorial_j2000": {
"vector": {
"x": 2.098448855732867,
"y": 0.2765612430501996,
"z": -0.40438095368063115
},
"ra": 7.5079229100978795,
"dec": -10.816164216111586,
"distance": 2.15487764779899
},
"astrometric_geocentric_j2000": {
"ra": 7.505002122,
"dec": -10.817447632,
"distance": 2.15492170559517
},
"apparent_geocentric_equatorial": {
"ra": 7.838020217,
"dec": -10.673347455,
"distance": 2.15492170559517
},
"apparent_topocentric_equatorial": {
"ra": 7.837674151,
"dec": -10.673752839,
"distance": 2.15496025225155
}
},
{
"jd_tt": 2461000.5,
"heliocentric_j2000": {
"vector": {
"x": 2.7207773669545783,
"y": 0.9258007933098376,
"z": -0.4719296786590631
},
"lon": 18.791934390117596,
"lat": -9.325201370855929,
"distance": 2.912465314990834
},
"geocentric_equatorial_j2000": {
"vector": {
"x": 2.2055042090018193,
"y": 0.2636713299209019,
"z": -0.40000461545736027
},
"ra": 6.81743953217303,
"dec": -10.20864380803651,
"distance": 2.256939316537817
},
"astrometric_geocentric_j2000": {
"ra": 6.814605578,
"dec": -10.209892827,
"distance": 2.25699149110405
},
"apparent_geocentric_equatorial": {
"ra": 7.14766941,
"dec": -10.065882123,
"distance": 2.25699149110405
},
"apparent_topocentric_equatorial": {
"ra": 7.147503566,
"dec": -10.06627157,
"distance": 2.25703110081311
}
},
{
"jd_tt": 2461010.5,
"heliocentric_j2000": {
"vector": {
"x": 2.6836129422802006,
"y": 1.016160036159696,
"z": -0.46222249732860976
},
"lon": 20.739357985174653,
"lat": -9.150488572539503,
"distance": 2.906545936347569
},
"geocentric_equatorial_j2000": {
"vector": {
"x": 2.3251040726214547,
"y": 0.2732926026815544,
"z": -0.3852445278690892
},
"ra": 6.703783294551308,
"dec": -9.344636553398681,
"distance": 2.3725958655983557
},
"astrometric_geocentric_j2000": {
"ra": 6.701034638,
"dec": -9.345865933,
"distance": 2.37265515129761
},
"apparent_geocentric_equatorial": {
"ra": 7.034036636,
"dec": -9.201999141,
"distance": 2.37265515129761
},
"apparent_topocentric_equatorial": {
"ra": 7.034030569,
"dec": -9.202381645,
"distance": 2.37269457352367
}
}
]
},
{
"name": "67P/Churyumov-Gerasimenko",
"elements": {
"form": "perihelion",
"epoch_jd": 2457305.5,
"a": 3.462249489765068,
"e": 0.6409081306555051,
"i": 7.040294906760007,
"omega": 50.13557380441372,
"w": 12.79824973415729,
"m0": 8.859927418758764,
"q": 1.243265641416762,
"tp_jd": 2457247.5886578634
},
"samples": [
{
"jd_tt": 2457302.5,
"heliocentric_j2000": {
"vector": {
"x": -0.44736312243503584,
"y": 1.3294945731365628,
"z": 0.14764861907531163
},
"lon": 108.5976295096627,
"lat": 6.008658491961936,
"distance": 1.4104927146317499
},
"geocentric_equatorial_j2000": {
"vector": {
"x": -1.4206430954009188,
"y": 0.9517230985297835,
"z": 0.573561897291833
},
"ra": 146.18091487102365,
"dec": 18.542577604555,
"distance": 1.8036010952564991
},
"astrometric_geocentric_j2000": {
"ra": 146.177637875,
"dec": 18.543527558,
"distance": 1.80345365811439
},
"apparent_geocentric_equatorial": {
"ra": 146.391451143,
"dec": 18.470048764,
"distance": 1.80345365811439
},
"apparent_topocentric_equatorial": {
"ra": 146.391734042,
"dec": 18.469744604,
"distance": 1.80341257294518
}
},
{
"jd_tt": 2457305.5,
"heliocentric_j2000": {
"vector": {
"x": -0.5017310686400781,
"y": 1.3275699495234567,
"z": 0.152649965956737
},
"lon": 110.7031793586661,
"lat": 6.139092596808116,
"distance": 1.427402552969211
},
"geocentric_equatorial_j2000": {
"vector": {
"x": -1.4611087016401392,
"y": 0.9023240336447644,
"z": 0.557599441264857
},
"ra": 148.30213164181953,
"dec": 17.988638601349397,
"distance": 1.8055316216007882
},
"astrometric_geocentric_j2000": {
"ra": 148.298961102,
"dec": 17.989615379,
"distance": 1.80538344566263
},
"apparent_geocentric_equatorial": {
"ra": 148.511491107,
"dec": 17.914447384,
"distance": 1.80538344566263
},
"apparent_topocentric_equatorial": {
"ra": 148.511755523,
"dec": 17.914132198,
"distance": 1.80534237316295
}
},
{
"jd_tt": 2457308.5,
"heliocentric_j2000": {
"vector": {
"x": -0.5556399345906312,
"y": 1.3244296294882558,
"z": 0.15751155658917912
},
"lon": 112.759583399658,
"lat": 6.258484723237018,
"distance": 1.4448735137850046
},
"geocentric_equatorial_j2000": {
"vector": {
"x": -1.4985392156952093,
"y": 0.8525430514933955,
"z": 0.5413194289602604
},
"ra": 150.36375633106715,
"dec": 17.43103407307659,
"distance": 1.8070628544120833
},
"astrometric_geocentric_j2000": {
"ra": 150.360689152,
"dec": 17.432034237,
"distance": 1.80691407029411
},
"apparent_geocentric_equatorial": {
"ra": 150.571964767,
"dec": 17.355316459,
"distance": 1.80691407029411
},
"apparent_topocentric_equatorial": {
"ra": 150.572209619,
"dec": 17.354990081,
"distance": 1.80687301681841
}
}
]
},
{
"name": "2I/Borisov",
"elements": {
"form": "perihelion",
"epoch_jd": 2458853.5,
"a": -0.8514922551937886,
"e": 3.356475782676596,
"i": 44.05264247909138,
"omega": 308.1477292269942,
"w": 209.1236864378081,
"m0": 34.4294703072178,
"q": 2.006520878500843,
"tp_jd": 2458826.052845906
},
"samples": [
{
"jd_tt": 2458850.5,
"heliocentric_j2000": {
"vector": {
"x": -1.7363690847272428,
"y": 0.4589931677148906,
"z": -1.046798758397838
},
"lon": 165.19307054029713,
"lat": -30.23563412365004,
"distance": 2.0788073424415088
},
"geocentric_equatorial_j2000": {
"vector": {
"x": -1.5528116450695653,
"y": -0.048782364868371925,
"z": -1.1620525609838885
},
"ra": 181.79938415148987,
"dec": -36.79593117726959,
"distance": 1.9400953272133343
},
"astrometric_geocentric_j2000": {
"ra": 181.794499707,
"dec": -36.791578751,
"distance": 1.93991794203543
},
"apparent_geocentric_equatorial": {
"ra": 182.050662544,
"dec": -36.897936693,
"distance": 1.93991794203543
},
"apparent_topocentric_equatorial": {
"ra": 182.049859235,
"dec": -36.898977263,
"distance": 1.93990895938059
}
},
{
"jd_tt": 2458853.5,
"heliocentric_j2000": {
"vector": {
"x": -1.7464221562202638,
"y": 0.39850001719285283,
"z": -1.0905979058432556
},
"lon": 167.14626996978888,
"lat": -31.334179643312435,
"distance": 2.0971877368679785
},
"geocentric_equatorial_j2000": {
"vector": {
"x": -1.5116026074234332,
"y": -0.07658934700802209,
"z": -1.2218471796678956
},
"ra": 182.9005618986015,
"dec": -38.91313542054592,
"distance": 1.9451783726195466
},
"astrometric_geocentric_j2000": {
"ra": 182.895514379,
"dec": -38.909003103,
"distance": 1.94499498382429
},
"apparent_geocentric_equatorial": {
"ra": 183.153933054,
"dec": -39.015229603,
"distance": 1.94499498382429
},
"apparent_topocentric_equatorial": {
"ra": 183.153072954,
"dec": -39.016264727,
"distance": 1.94498787902557
}
},
{
"jd_tt": 2458856.5,
"heliocentric_j2000": {
"vector": {
"x": -1.755971115425502,
"y": 0.33789176190312686,
"z": -1.1340822947167577
},
"lon": 169.108022386651,
"lat": -32.38322224771691,
"distance": 2.1174862578445954
},
"geocentric_equatorial_j2000": {
"vector": {
"x": -1.4705582463869695,
"y": -0.10218073705642658,
"z": -1.280337066440961
},
"ra": 183.97476956512006,
"dec": -40.97603824184195,
"distance": 1.9524972375767293
},
"astrometric_geocentric_j2000": {
"ra": 183.969546783,
"dec": -40.972126814,
"distance": 1.95230808330254
},
"apparent_geocentric_equatorial": {
"ra": 184.230549155,
"dec": -41.078223369,
"distance": 1.95230808330254
},
"apparent_topocentric_equatorial": {
"ra": 184.22963003,
"dec": -41.079248355,
"distance": 1.95230282791187
}
}
]
}
]
+158
View File
@@ -0,0 +1,158 @@
package orbit
import (
"math"
"time"
)
const visualBinaryDeg = 180 / math.Pi
const visualBinaryRad = math.Pi / 180
// VisualBinaryElements 视双星轨道要素,采用《天文算法》第 55 章的经典口径。
type VisualBinaryElements struct {
PeriodYears float64 // 周期 P,单位平太阳年 / orbital period in mean solar years.
PeriastronYear float64 // 过近星点时刻 T,采用带小数的年 / epoch of periastron as a decimal year.
Eccentricity float64 // 离心率 e / eccentricity.
SemiMajorAxis float64 // 半长轴 a,单位角秒 / semi-major axis in arcseconds.
Inclination float64 // 倾角 i,单位度 / inclination in degrees.
AscendingNode float64 // 升交点位置角 Ω,单位度 / position angle of ascending node in degrees.
PeriastronArgument float64 // 近星点角距 ω,单位度 / argument of periastron in degrees.
}
// VisualBinaryPosition 视双星在天球上的计算结果。
type VisualBinaryPosition struct {
Year float64 // 计算使用的小数年 / decimal year used for the evaluation.
MeanAnomaly float64 // 平近点角 M,单位度 / mean anomaly in degrees.
EccentricAnomaly float64 // 偏近点角 E,单位度 / eccentric anomaly in degrees.
TrueAnomaly float64 // 真近点角 v,单位度 / true anomaly in degrees.
Radius float64 // 径矢 r,单位角秒 / radius vector in arcseconds.
PositionAngle float64 // 位置角 θ,北为 0°、东为 90° / position angle, north through east.
Separation float64 // 角距离 ρ,单位角秒 / apparent separation in arcseconds.
}
// VisualBinary 视双星位置 / visual binary position.
//
// 输入时刻会先换算为 UTC 小数年,再按经典视轨道公式求解。
// The input instant is converted to a UTC decimal year before evaluation.
func VisualBinary(date time.Time, elements VisualBinaryElements) VisualBinaryPosition {
return VisualBinaryByYear(decimalYearUTC(date), elements)
}
// VisualBinaryByYear 视双星位置(按小数年) / visual binary position by decimal year.
//
// 返回给定小数年对应的视双星位置角和角距离。
func VisualBinaryByYear(year float64, elements VisualBinaryElements) VisualBinaryPosition {
if !validVisualBinaryElements(year, elements) {
return invalidVisualBinaryPosition(year)
}
meanAnomaly := normalize360Local(360 / elements.PeriodYears * (year - elements.PeriastronYear))
eccentricAnomaly, ok := solveVisualBinaryEccentricAnomaly(meanAnomaly*visualBinaryRad, elements.Eccentricity)
if !ok {
return invalidVisualBinaryPosition(year)
}
sinE, cosE := math.Sincos(eccentricAnomaly)
radius := elements.SemiMajorAxis * (1 - elements.Eccentricity*cosE)
trueAnomaly := math.Atan2(
math.Sqrt(1-elements.Eccentricity*elements.Eccentricity)*sinE,
cosE-elements.Eccentricity,
) * visualBinaryDeg
u := (trueAnomaly + elements.PeriastronArgument) * visualBinaryRad
sinU, cosU := math.Sincos(u)
cosI := math.Cos(elements.Inclination * visualBinaryRad)
thetaMinusNode := math.Atan2(sinU*cosI, cosU) * visualBinaryDeg
positionAngle := normalize360Local(thetaMinusNode + elements.AscendingNode)
separation := radius * math.Hypot(cosU, sinU*cosI)
return VisualBinaryPosition{
Year: year,
MeanAnomaly: meanAnomaly,
EccentricAnomaly: normalize360Local(eccentricAnomaly * visualBinaryDeg),
TrueAnomaly: normalize360Local(trueAnomaly),
Radius: radius,
PositionAngle: positionAngle,
Separation: separation,
}
}
func decimalYearUTC(date time.Time) float64 {
date = date.UTC()
year := date.Year()
start := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(year+1, time.January, 1, 0, 0, 0, 0, time.UTC)
return float64(year) + float64(date.Sub(start))/float64(end.Sub(start))
}
func validVisualBinaryElements(year float64, elements VisualBinaryElements) bool {
if !isFiniteLocal(year) ||
!isFiniteLocal(elements.PeriodYears) ||
!isFiniteLocal(elements.PeriastronYear) ||
!isFiniteLocal(elements.Eccentricity) ||
!isFiniteLocal(elements.SemiMajorAxis) ||
!isFiniteLocal(elements.Inclination) ||
!isFiniteLocal(elements.AscendingNode) ||
!isFiniteLocal(elements.PeriastronArgument) {
return false
}
return elements.PeriodYears > 0 &&
elements.SemiMajorAxis > 0 &&
elements.Eccentricity >= 0 &&
elements.Eccentricity < 1
}
func invalidVisualBinaryPosition(year float64) VisualBinaryPosition {
nan := math.NaN()
return VisualBinaryPosition{
Year: year,
MeanAnomaly: nan,
EccentricAnomaly: nan,
TrueAnomaly: nan,
Radius: nan,
PositionAngle: nan,
Separation: nan,
}
}
func solveVisualBinaryEccentricAnomaly(meanAnomalyRad, eccentricity float64) (float64, bool) {
if !isFiniteLocal(meanAnomalyRad) || !isFiniteLocal(eccentricity) || eccentricity < 0 || eccentricity >= 1 {
return math.NaN(), false
}
if meanAnomalyRad > math.Pi {
meanAnomalyRad -= 2 * math.Pi
} else if meanAnomalyRad < -math.Pi {
meanAnomalyRad += 2 * math.Pi
}
eccentricAnomaly := meanAnomalyRad
if eccentricity >= 0.8 {
eccentricAnomaly = math.Pi
if meanAnomalyRad < 0 {
eccentricAnomaly = -math.Pi
}
}
for i := 0; i < 32; i++ {
sinE, cosE := math.Sincos(eccentricAnomaly)
delta := (eccentricAnomaly - eccentricity*sinE - meanAnomalyRad) / (1 - eccentricity*cosE)
eccentricAnomaly -= delta
if math.Abs(delta) < 1e-14 {
return eccentricAnomaly, true
}
}
return eccentricAnomaly, true
}
func normalize360Local(value float64) float64 {
value = math.Mod(value, 360)
if value < 0 {
value += 360
}
return value
}
func isFiniteLocal(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
+126
View File
@@ -0,0 +1,126 @@
package orbit
import (
"math"
"testing"
"time"
)
func TestVisualBinaryMatchesMeeusEtaCoronaeBorealisExample(t *testing.T) {
elements := VisualBinaryElements{
PeriodYears: 41.623,
PeriastronYear: 1934.008,
Eccentricity: 0.2763,
SemiMajorAxis: 0.907,
Inclination: 59.025,
AscendingNode: 23.717,
PeriastronArgument: 219.907,
}
got := VisualBinaryByYear(1980.0, elements)
if math.Abs(got.MeanAnomaly-37.788) > 0.001 {
t.Fatalf("mean anomaly mismatch: got %.6f want %.6f", got.MeanAnomaly, 37.788)
}
if math.Abs(got.EccentricAnomaly-49.897) > 0.001 {
t.Fatalf("eccentric anomaly mismatch: got %.6f want %.6f", got.EccentricAnomaly, 49.897)
}
if math.Abs(got.Radius-0.74557) > 1e-5 {
t.Fatalf("radius mismatch: got %.6f want %.6f", got.Radius, 0.74557)
}
if math.Abs(got.TrueAnomaly-63.416) > 0.001 {
t.Fatalf("true anomaly mismatch: got %.6f want %.6f", got.TrueAnomaly, 63.416)
}
if angleDiffAbs(got.PositionAngle, 318.4) > 0.05 {
t.Fatalf("position angle mismatch: got %.6f want %.6f", got.PositionAngle, 318.4)
}
if math.Abs(got.Separation-0.411) > 0.002 {
t.Fatalf("separation mismatch: got %.6f want %.6f", got.Separation, 0.411)
}
}
func TestVisualBinaryMatchesMeeusGammaVirginisTable(t *testing.T) {
elements := VisualBinaryElements{
PeriodYears: 168.68,
PeriastronYear: 2005.13,
Eccentricity: 0.885,
SemiMajorAxis: 3.697,
Inclination: 148.0,
AscendingNode: 36.9,
PeriastronArgument: 256.5,
}
cases := []struct {
year float64
angle float64
separation float64
}{
{1980.0, 296.65, 3.78},
{1984.0, 293.10, 3.43},
{1988.0, 288.70, 3.04},
{1992.0, 282.89, 2.60},
{1996.0, 274.41, 2.08},
{2000.0, 259.34, 1.45},
{2004.0, 208.67, 0.59},
{2008.0, 35.54, 1.04},
{2012.0, 12.72, 1.87},
}
for _, tc := range cases {
got := VisualBinaryByYear(tc.year, elements)
if angleDiffAbs(got.PositionAngle, tc.angle) > 0.12 {
t.Fatalf("year %.1f position angle mismatch: got %.6f want %.6f", tc.year, got.PositionAngle, tc.angle)
}
if math.Abs(got.Separation-tc.separation) > 0.03 {
t.Fatalf("year %.1f separation mismatch: got %.6f want %.6f", tc.year, got.Separation, tc.separation)
}
}
}
func TestVisualBinaryDateWrapperMatchesYearWrapper(t *testing.T) {
elements := VisualBinaryElements{
PeriodYears: 41.623,
PeriastronYear: 1934.008,
Eccentricity: 0.2763,
SemiMajorAxis: 0.907,
Inclination: 59.025,
AscendingNode: 23.717,
PeriastronArgument: 219.907,
}
date := time.Date(1980, time.January, 1, 0, 0, 0, 0, time.UTC)
fromDate := VisualBinary(date, elements)
fromYear := VisualBinaryByYear(1980.0, elements)
if angleDiffAbs(fromDate.PositionAngle, fromYear.PositionAngle) > 1e-12 {
t.Fatalf("position angle wrapper mismatch: got %.12f want %.12f", fromDate.PositionAngle, fromYear.PositionAngle)
}
if math.Abs(fromDate.Separation-fromYear.Separation) > 1e-12 {
t.Fatalf("separation wrapper mismatch: got %.12f want %.12f", fromDate.Separation, fromYear.Separation)
}
}
func TestVisualBinaryInvalidInputReturnsNaN(t *testing.T) {
got := VisualBinaryByYear(2000, VisualBinaryElements{
PeriodYears: 0,
PeriastronYear: 2005.13,
Eccentricity: 0.885,
SemiMajorAxis: 3.697,
Inclination: 148.0,
AscendingNode: 36.9,
PeriastronArgument: 256.5,
})
for name, value := range map[string]float64{
"mean": got.MeanAnomaly,
"eccentric": got.EccentricAnomaly,
"true": got.TrueAnomaly,
"radius": got.Radius,
"angle": got.PositionAngle,
"separation": got.Separation,
} {
if !math.IsNaN(value) {
t.Fatalf("%s should be NaN for invalid input, got %.12f", name, value)
}
}
}