044f2f4381
- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算 - 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询 - 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出 - 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解 - 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤 - 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持 - 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理 - 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
727 lines
25 KiB
Go
727 lines
25 KiB
Go
package orbit
|
|
|
|
import (
|
|
"encoding/json"
|
|
"math"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"b612.me/astro/basic"
|
|
marspkg "b612.me/astro/mars"
|
|
)
|
|
|
|
const orbitAngleToleranceDeg = 0.02
|
|
const orbitDistanceToleranceAU = 3e-4
|
|
const orbitVectorToleranceAU = 3e-4
|
|
const orbitAstrometricToleranceDeg = 0.02
|
|
const orbitAstrometricDistanceToleranceAU = 3e-4
|
|
const shanghaiLon = 121.4737
|
|
const shanghaiLat = 31.2304
|
|
const shanghaiHeightMeters = 20.0
|
|
|
|
type baselineElements struct {
|
|
Form string `json:"form"`
|
|
EpochJD float64 `json:"epoch_jd"`
|
|
A float64 `json:"a"`
|
|
E float64 `json:"e"`
|
|
I float64 `json:"i"`
|
|
Omega float64 `json:"omega"`
|
|
W float64 `json:"w"`
|
|
M0 float64 `json:"m0"`
|
|
Q float64 `json:"q"`
|
|
TpJD float64 `json:"tp_jd"`
|
|
}
|
|
|
|
type baselineVector struct {
|
|
X float64 `json:"x"`
|
|
Y float64 `json:"y"`
|
|
Z float64 `json:"z"`
|
|
}
|
|
|
|
type baselineHeliocentric struct {
|
|
Vector baselineVector `json:"vector"`
|
|
Lon float64 `json:"lon"`
|
|
Lat float64 `json:"lat"`
|
|
Distance float64 `json:"distance"`
|
|
}
|
|
|
|
type baselineGeocentric struct {
|
|
Vector baselineVector `json:"vector"`
|
|
RA float64 `json:"ra"`
|
|
Dec float64 `json:"dec"`
|
|
Distance float64 `json:"distance"`
|
|
}
|
|
|
|
type baselineObservation struct {
|
|
RA float64 `json:"ra"`
|
|
Dec float64 `json:"dec"`
|
|
Distance float64 `json:"distance"`
|
|
}
|
|
|
|
type baselineSample struct {
|
|
JDTT float64 `json:"jd_tt"`
|
|
Heliocentric baselineHeliocentric `json:"heliocentric_j2000"`
|
|
Geocentric baselineGeocentric `json:"geocentric_equatorial_j2000"`
|
|
AstrometricGeocentric baselineObservation `json:"astrometric_geocentric_j2000"`
|
|
ApparentGeocentric baselineObservation `json:"apparent_geocentric_equatorial"`
|
|
ApparentTopocentric baselineObservation `json:"apparent_topocentric_equatorial"`
|
|
}
|
|
|
|
type baselineObject struct {
|
|
Name string `json:"name"`
|
|
Elements baselineElements `json:"elements"`
|
|
Samples []baselineSample `json:"samples"`
|
|
}
|
|
|
|
func TestGeometricOrbitMatchesJPLBaseline(t *testing.T) {
|
|
objects := loadOrbitBaseline(t)
|
|
|
|
var maxHelioVectorDiffAU float64
|
|
var maxHelioLonDiffDeg float64
|
|
var maxHelioLatDiffDeg float64
|
|
var maxGeoVectorDiffAU float64
|
|
var maxGeoRADiffDeg float64
|
|
var maxGeoDecDiffDeg float64
|
|
var maxGeoDistanceDiffAU float64
|
|
|
|
for _, object := range objects {
|
|
elements := elementsFromBaseline(object)
|
|
basicElements := toBasicElements(elements)
|
|
|
|
for _, sample := range object.Samples {
|
|
date := basic.JD2DateByZone(basic.TT2UTC(sample.JDTT), 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.JD2DateByZone(basic.TT2UTC(sample.JDTT), 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 date.Equal(time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC)) {
|
|
continue
|
|
}
|
|
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 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.Date2JD(date)
|
|
_, offsetSeconds := date.Zone()
|
|
timezone := float64(offsetSeconds) / 3600.0
|
|
siderealLongitude := normalize360(basic.ApparentSiderealTime(basic.UTC2UT1(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
|
|
}
|
|
|
|
func TestStateWithOptionsZeroMatchesState(t *testing.T) {
|
|
elements := Elements{
|
|
EpochJD: 2451545, A: 2.7, E: 0.08, I: 10, Omega: 80, W: 73, M0: 231,
|
|
}
|
|
for _, jd := range []float64{2451545, 2452000, 2449000} {
|
|
wantPos, wantVel, wantErr := State(elements, jd)
|
|
gotPos, gotVel, gotErr := StateWithOptions(elements, jd, PropagationOptions{})
|
|
if gotPos != wantPos || gotVel != wantVel || gotErr != wantErr {
|
|
t.Fatalf("jd %.1f: got %v %v %v, want %v %v %v", jd, gotPos, gotVel, gotErr, wantPos, wantVel, wantErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
type staticPerturbationEngine struct{}
|
|
|
|
func (staticPerturbationEngine) State(target, center Body, jdTDB float64) (pos, vel [3]float64, err error) {
|
|
if target == center {
|
|
return pos, vel, nil
|
|
}
|
|
if center != 0 {
|
|
return pos, vel, ErrPerturbationEngine
|
|
}
|
|
switch target {
|
|
case 10:
|
|
return pos, vel, nil
|
|
case 5:
|
|
pos[0] = 778000000
|
|
return pos, vel, nil
|
|
default:
|
|
return pos, vel, ErrPerturbationEngine
|
|
}
|
|
}
|
|
|
|
func (staticPerturbationEngine) Coverage(target Body) (start, end float64, ok bool) {
|
|
return 0, 3000000, true
|
|
}
|
|
|
|
func TestStateWithOptionsPerturbationChangesState(t *testing.T) {
|
|
elements := Elements{EpochJD: 2451545, A: 1.5, E: 0.02, I: 3, Omega: 20, W: 30, M0: 10}
|
|
plain, _, err := State(elements, elements.EpochJD+30)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
perturbed, perturbedVelocity, err := StateWithOptions(elements, elements.EpochJD+30, PropagationOptions{
|
|
Perturbers: []Body{5},
|
|
Engine: staticPerturbationEngine{},
|
|
Step: 2,
|
|
Tolerance: 1e-10,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if perturbed == plain {
|
|
t.Fatal("perturbation did not change the state")
|
|
}
|
|
if !finite(vectorNorm(perturbed)) || !finite(vectorNorm(perturbedVelocity)) || vectorNorm(perturbedVelocity) > 1e7 {
|
|
t.Fatalf("perturbed state is unstable: position=%v velocity=%v", perturbed, perturbedVelocity)
|
|
}
|
|
}
|
|
|
|
type countingPerturbationEngine struct {
|
|
calls map[Body]int
|
|
}
|
|
|
|
func (engine *countingPerturbationEngine) State(target, center Body, jdTDB float64) (pos, vel [3]float64, err error) {
|
|
if target == center {
|
|
return pos, vel, nil
|
|
}
|
|
engine.calls[target]++
|
|
if center != 0 {
|
|
return pos, vel, ErrPerturbationEngine
|
|
}
|
|
switch target {
|
|
case 10:
|
|
return pos, vel, nil
|
|
case 5:
|
|
pos[0] = 778000000
|
|
return pos, vel, nil
|
|
default:
|
|
return pos, vel, ErrPerturbationEngine
|
|
}
|
|
}
|
|
|
|
func (*countingPerturbationEngine) Coverage(target Body) (start, end float64, ok bool) {
|
|
return 0, 3000000, true
|
|
}
|
|
|
|
func TestStateWithOptionsCachesRepeatedSamples(t *testing.T) {
|
|
elements := Elements{EpochJD: 2451545, A: 1.5, E: 0.02, I: 3, Omega: 20, W: 30, M0: 10}
|
|
provider := &countingPerturbationEngine{calls: make(map[Body]int)}
|
|
_, _, err := StateWithOptions(elements, elements.EpochJD+1, PropagationOptions{
|
|
Perturbers: []Body{5},
|
|
Engine: provider,
|
|
Step: 1,
|
|
Tolerance: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, body := range []Body{5, 10} {
|
|
if got, want := provider.calls[body], 5; got != want {
|
|
t.Fatalf("body %d provider calls = %d, want %d", body, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStateWithOptionsDeduplicatesPerturbers(t *testing.T) {
|
|
elements := Elements{EpochJD: 2451545, A: 1.5, E: 0.02, I: 3, Omega: 20, W: 30, M0: 10}
|
|
provider := &countingPerturbationEngine{calls: make(map[Body]int)}
|
|
singlePos, singleVel, err := StateWithOptions(elements, elements.EpochJD+10, PropagationOptions{
|
|
Perturbers: []Body{5},
|
|
Engine: provider,
|
|
Step: 2,
|
|
Tolerance: 1e-8,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
duplicatePos, duplicateVel, err := StateWithOptions(elements, elements.EpochJD+10, PropagationOptions{
|
|
Perturbers: []Body{5, 5, 10, 0},
|
|
Engine: provider,
|
|
Step: 2,
|
|
Tolerance: 1e-8,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if duplicatePos != singlePos || duplicateVel != singleVel {
|
|
t.Fatalf("duplicate perturbers changed state: %v %v versus %v %v", duplicatePos, duplicateVel, singlePos, singleVel)
|
|
}
|
|
}
|
|
|
|
func TestStateWithOptionsRejectsNonFiniteTarget(t *testing.T) {
|
|
elements := Elements{EpochJD: 2451545, A: 1.5, E: 0.02, I: 3, Omega: 20, W: 30, M0: 10}
|
|
if _, _, err := StateWithOptions(elements, math.NaN(), PropagationOptions{}); err != ErrInvalidPropagationElements {
|
|
t.Fatalf("NaN target error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEphemerisEngineSunPerturberKeepsTwoBodyPath(t *testing.T) {
|
|
elements := Elements{EpochJD: 2451545, A: 1.5, E: 0.02, I: 3, Omega: 20, W: 30, M0: 10}
|
|
engine := NewEphemerisEngine(Body(12345), elements, PropagationOptions{Perturbers: []Body{10}})
|
|
gotPos, gotVel, err := engine.State(Body(12345), 10, elements.EpochJD+10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantPos, wantVel, err := State(elements, elements.EpochJD+10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gotPos != wantPos || gotVel != wantVel {
|
|
t.Fatalf("Sun-only perturbation changed two-body state: %v %v versus %v %v", gotPos, gotVel, wantPos, wantVel)
|
|
}
|
|
}
|
|
|
|
func TestStateWithOptionsZeroMatchesExistingPositionMath(t *testing.T) {
|
|
cases := []Elements{
|
|
{EpochJD: 2451545, A: 2.7, E: 0.08, I: 10, Omega: 80, W: 73, M0: 231},
|
|
{Q: 0.9, E: 1, I: 30, Omega: 40, W: 50, TpJD: 2451545},
|
|
{Q: 1.2, E: 1.05, I: 30, Omega: 40, W: 50, TpJD: 2451545},
|
|
}
|
|
for _, elements := range cases {
|
|
jd := 2451545.0 + 20
|
|
got, _, err := StateWithOptions(elements, jd, PropagationOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
basicVector := basic.OrbitHeliocentricXYZJ2000(jd, toBasicElements(elements))
|
|
epsilon := basic.EclipticObliquity(2451545, false) * math.Pi / 180
|
|
want := [3]float64{
|
|
basicVector[0] * 149597870.7,
|
|
(basicVector[1]*math.Cos(epsilon) - basicVector[2]*math.Sin(epsilon)) * 149597870.7,
|
|
(basicVector[1]*math.Sin(epsilon) + basicVector[2]*math.Cos(epsilon)) * 149597870.7,
|
|
}
|
|
for index := range got {
|
|
if math.Abs(got[index]-want[index]) > 1e-6 {
|
|
t.Fatalf("elements %+v component %d = %.12f, want %.12f", elements, index, got[index], want[index])
|
|
}
|
|
}
|
|
}
|
|
}
|