feat: 扩展天文计算能力
- 新增日食、月食、本地可见性、中心线、半影区域、SVG 图示与沙罗周期信息 - 新增行星冲合、留、方照、物理星历、视直径、相位、亮肢角、轨道节点等计算 - 新增木星伽利略卫星位置、现象与接触事件计算 - 新增恒星星表、星座判定、自行修正与观测辅助能力 - 新增 coord、formula、orbit、sundial、lite/sun、lite/moon 等扩展包 - 完善农历年号、月相英文别名、视差角、大气质量、折射、日晷与双星计算 - 增加 NASA、JPL Horizons、IMCCE 等回归测试数据与基线测试 - 重构基础算法文件组织,补充大量公开 API 注释和语义回归测试 - 更新中文和英文 README,补充示例、精度说明、SVG 配图
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,15 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"b612.me/astro/basic"
|
||||
)
|
||||
|
||||
// AsteroidMagnitudeHG 小行星 H-G 模型视星等 / asteroid apparent magnitude using the H-G model.
|
||||
//
|
||||
// absoluteMagnitude 为绝对星等 H,slopeParameter 为斜率参数 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)
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+395
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user