Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
16c62a97d5
|
|||
|
1f31a9b5b5
|
|||
|
2bf8478639
|
|||
|
9ee2163cc7
|
|||
|
25dc7ac0bc
|
|||
|
a8e7513683
|
|||
|
c8dd777a7b
|
|||
|
46b555cd49
|
|||
|
be3af3884c
|
|||
|
34ff6a36ae
|
+79
-1815
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,94 @@
|
||||
// Package astro
|
||||
// Package astro 提供进程级时标配置与时标换算 / process-wide time-scale configuration and conversions.
|
||||
package astro
|
||||
|
||||
import "b612.me/astro/basic"
|
||||
|
||||
// DeltaT 返回当前的 TT−UT1(ΔT)模型 / the active TT−UT1 (DeltaT) model.
|
||||
func DeltaT() func(float64, bool) float64 {
|
||||
return basic.GetDeltaTFn()
|
||||
}
|
||||
|
||||
// SetDeltaT 替换 ΔT 模型(入参为数值与“该数值是否为儒略日”,为假时是十进制年),传 nil 恢复默认 / replaces the DeltaT model; nil restores the default.
|
||||
func SetDeltaT(deltaT func(float64, bool) float64) {
|
||||
basic.SetDeltaTFn(deltaT)
|
||||
}
|
||||
|
||||
// DefaultDeltaT 返回内置默认 ΔT 模型 / the built-in default DeltaT model.
|
||||
func DefaultDeltaT() func(float64, bool) float64 {
|
||||
return basic.DefaultDeltaTv2
|
||||
}
|
||||
|
||||
// TTMinusUTC 返回当前 TT−UTC 覆盖函数,未设置时为 nil / current TT−UTC override, or nil.
|
||||
func TTMinusUTC() func(float64) float64 {
|
||||
return basic.GetTTMinusUTCFn()
|
||||
}
|
||||
|
||||
// SetTTMinusUTC 替换 TT−UTC 模型,nil 恢复内置表与政策 / overrides TT−UTC; nil restores the built-in table and policy.
|
||||
func SetTTMinusUTC(ttMinusUTC func(float64) float64) {
|
||||
basic.SetTTMinusUTCFn(ttMinusUTC)
|
||||
}
|
||||
|
||||
// DefaultTTMinusUTC 返回内置闰秒表模型,忽略覆盖与未来政策 / built-in leap-table model, ignoring overrides and future policy.
|
||||
func DefaultTTMinusUTC() func(float64) float64 {
|
||||
return basic.TTMinusUTCSecondsDefault
|
||||
}
|
||||
|
||||
// TimeScaleFuturePolicy 民用时标换算政策,与输出用的 TimeScale 无关 / civil-time conversion policy, separate from output TimeScale.
|
||||
type TimeScaleFuturePolicy = basic.TimeScaleFuturePolicy
|
||||
|
||||
const (
|
||||
// TimeScaleLeapSecond 默认按 ΔT 越限施加整数秒校正,不代表闰秒公告 / default integer-second corrections driven by extrapolated ΔT, not announcements.
|
||||
TimeScaleLeapSecond = basic.TimeScaleLeapSecond
|
||||
// TimeScaleAssumeUT1Tracking 窗口外固定末端 DUT1,平滑跟随 UT1 / holds the last observed DUT1 beyond the window.
|
||||
TimeScaleAssumeUT1Tracking = basic.TimeScaleAssumeUT1Tracking
|
||||
// TimeScaleFreezeUTCOffset 假设 UTC 偏移冻结在精确窗口末端 / freezes the UTC offset at the window end.
|
||||
TimeScaleFreezeUTCOffset = basic.TimeScaleFreezeUTCOffset
|
||||
// TimeScaleLeapHour 按 ΔT 越限施加整小时校正,仅作情景演算 / applies hour-sized corrections as a scenario assumption.
|
||||
TimeScaleLeapHour = basic.TimeScaleLeapHour
|
||||
// TimeScaleUT1Civil 全时轴民用时标等同 UT1,TT−UTC 覆盖仍优先 / uses UT1 as civil time everywhere unless TT−UTC is overridden.
|
||||
TimeScaleUT1Civil = basic.TimeScaleUT1Civil
|
||||
)
|
||||
|
||||
// SetTimeScaleFuturePolicy 设置与 basic 共享的民用时标换算政策 / sets the civil-time conversion policy shared with basic.
|
||||
func SetTimeScaleFuturePolicy(policy TimeScaleFuturePolicy) {
|
||||
basic.SetTimeScaleFuturePolicy(policy)
|
||||
}
|
||||
|
||||
// GetTimeScaleFuturePolicy 返回当前民用时标换算政策 / current civil-time conversion policy.
|
||||
func GetTimeScaleFuturePolicy() TimeScaleFuturePolicy {
|
||||
return basic.GetTimeScaleFuturePolicy()
|
||||
}
|
||||
|
||||
// DeltaTModel 标识可选的已发布 ΔT 模型 / a selectable published ΔT model.
|
||||
type DeltaTModel = basic.DeltaTModel
|
||||
|
||||
const (
|
||||
// DeltaTModelDefault 内置默认:SMH2016 + Morrison 2021 / the built-in SMH2016 + Morrison 2021 model.
|
||||
DeltaTModelDefault = basic.DeltaTModelDefault
|
||||
// DeltaTModelSMH2016 显式选择内置模型 / selects the built-in model explicitly.
|
||||
DeltaTModelSMH2016 = basic.DeltaTModelSMH2016
|
||||
// DeltaTModelMS2004 Morrison & Stephenson 2004 长期抛物线 / the M&S2004 long-term parabola.
|
||||
DeltaTModelMS2004 = basic.DeltaTModelMS2004
|
||||
// DeltaTModelEspenakMeeus2006 Espenak & Meeus 2006 分段多项式 / the Espenak & Meeus 2006 piecewise polynomial.
|
||||
DeltaTModelEspenakMeeus2006 = basic.DeltaTModelEspenakMeeus2006
|
||||
// DeltaTModelNASACanon2006 上式再加配对修正 c,即 NASA 五千年目录的印刷口径 / plus the pairing term c, the NASA canon convention.
|
||||
DeltaTModelNASACanon2006 = basic.DeltaTModelNASACanon2006
|
||||
// DeltaTModelManual 表示当前生效的是 SetDeltaT 注入的任意函数 / an arbitrary function injected with SetDeltaT.
|
||||
DeltaTModelManual = basic.DeltaTModelManual
|
||||
)
|
||||
|
||||
// SetDeltaTModel 安装命名 ΔT 模型;keepObserved 为真时逐月实测段优先(推荐),未知模型返回 false 且不改动现状。
|
||||
// SetDeltaTModel installs a named ΔT model; with keepObserved the monthly observed span wins wherever it covers the instant.
|
||||
func SetDeltaTModel(model DeltaTModel, keepObserved bool) bool {
|
||||
return basic.SetDeltaTModel(model, keepObserved)
|
||||
}
|
||||
|
||||
// GetDeltaTModel 返回当前生效的模型与是否保留实测段 / the active model and whether the observed span is kept.
|
||||
func GetDeltaTModel() (DeltaTModel, bool) {
|
||||
return basic.GetDeltaTModel()
|
||||
}
|
||||
|
||||
// DeltaTModelSeconds 按命名模型求 ΔT(秒,入参为 UT 儒略日),不改变进程状态,便于对照 / evaluates a named model without touching process state.
|
||||
func DeltaTModelSeconds(model DeltaTModel, jd float64, keepObserved bool) float64 {
|
||||
return basic.DeltaTModelSeconds(model, jd, keepObserved)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package astro
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/basic"
|
||||
)
|
||||
|
||||
// time.Time 往返经 JD 舍入,容差为 0.2 ms。
|
||||
func TestBarycentricTimeFacade(t *testing.T) {
|
||||
tt := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
if got := TCGMinusTT(tt); math.Abs(got-1.0777) > 5e-3 {
|
||||
t.Errorf("TCG−TT=%v 秒, want ≈1.078", got)
|
||||
}
|
||||
if got := TCBMinusTT(tt); math.Abs(got-23.9757) > 5e-3 {
|
||||
t.Errorf("TCB−TT=%v 秒, want ≈23.976", got)
|
||||
}
|
||||
if got := TDBMinusTT(tt); math.Abs(got) > 2e-3 {
|
||||
t.Errorf("TDB−TT=%v 秒, want |·| ≤ 1.7 ms", got)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
fwd func(time.Time) time.Time
|
||||
back func(time.Time) time.Time
|
||||
}{
|
||||
{"TCG", TCGFromTT, TTFromTCG},
|
||||
{"TCB", TCBFromTT, TTFromTCB},
|
||||
{"TDB", TDBFromTT, TTFromTDB},
|
||||
} {
|
||||
round := tc.back(tc.fwd(tt))
|
||||
if delta := math.Abs(round.Sub(tt).Seconds()); delta > 2e-4 {
|
||||
t.Errorf("%s 往返差 %.6f 秒", tc.name, delta)
|
||||
}
|
||||
if forward := tc.fwd(tt); !forward.After(tt.Add(-time.Second)) || !forward.Before(tt.Add(time.Minute)) {
|
||||
t.Errorf("%s 结果 %v 离开合理区间", tc.name, forward)
|
||||
}
|
||||
}
|
||||
tcb := TCBFromTT(tt)
|
||||
if diff := tcb.Sub(TDBFromTCB(tcb)).Seconds(); math.Abs(diff-23.9757) > 0.1 {
|
||||
t.Errorf("TCB−TDB=%v 秒, want ≈23.976", diff)
|
||||
}
|
||||
if delta := math.Abs(TTFromTCB(tcb).Sub(tt).Seconds()); delta > 2e-4 {
|
||||
t.Errorf("TT→TCB 往返差 %.6f 秒", delta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBarycentricTimeFacadePreservesScaleAndInstant(t *testing.T) {
|
||||
tt := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for _, date := range []time.Time{tt, tt.In(time.FixedZone("UTC+08", 8*3600))} {
|
||||
jd := basic.Date2JD(date.UTC())
|
||||
for name, pair := range map[string][2]float64{
|
||||
"TCG": {TCGMinusTT(date), basic.TCGMinusTTSeconds(jd)},
|
||||
"TCB": {TCBMinusTT(date), basic.TCBMinusTTSeconds(jd)},
|
||||
"TDB": {TDBMinusTT(date), basic.TDBMinusTTSeconds(jd)},
|
||||
} {
|
||||
if pair[0] != pair[1] {
|
||||
t.Errorf("%s facade=%v, basic=%v", name, pair[0], pair[1])
|
||||
}
|
||||
}
|
||||
if direct, composed := TDBFromTT(date), TDBFromTCB(TCBFromTT(date)); math.Abs(direct.Sub(composed).Seconds()) > 1e-4 {
|
||||
t.Errorf("direct TDB=%v, composed=%v", direct, composed)
|
||||
}
|
||||
if got := TCGFromTT(date); got.Location() != time.UTC || !got.Equal(TCGFromTT(tt)) {
|
||||
t.Errorf("TCG depends on input Location: %v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
-33
@@ -1,7 +1,9 @@
|
||||
// 根包回归:testdata 的位级基线与 n 截断契约(n<0 用全项,n>=0 保留约 n 个主项)。
|
||||
package astro_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
@@ -52,6 +54,122 @@ func loadBaselineSamples(t *testing.T) []baselineSample {
|
||||
return samples
|
||||
}
|
||||
|
||||
// truncationTermCounts 是截断检验抽取的 n 值。
|
||||
var truncationTermCounts = []int{2, 4, 8, 16}
|
||||
|
||||
// angularDifference 两个角度量的最小夹角,单位度。
|
||||
func angularDifference(a, b float64) float64 {
|
||||
d := math.Mod(a-b, 360)
|
||||
if d > 180 {
|
||||
d -= 360
|
||||
}
|
||||
if d < -180 {
|
||||
d += 360
|
||||
}
|
||||
return math.Abs(d)
|
||||
}
|
||||
|
||||
// truncationCase 是一个截断探针:err 给出第 sample 个采样点在截断项数 n 下相对全项 n<0 的偏差。
|
||||
type truncationCase struct {
|
||||
name string
|
||||
tol []float64
|
||||
err func(sample, n int) float64
|
||||
}
|
||||
|
||||
// scalarTruncation 构造标量探针;jd 按采样索引取值,angular 为真时偏差取角度最小夹角。
|
||||
func scalarTruncation(name string, jd func(index int) float64, f func(float64, int) float64, angular bool, tol []float64) truncationCase {
|
||||
return truncationCase{
|
||||
name: name,
|
||||
tol: tol,
|
||||
err: func(sample, n int) float64 {
|
||||
got := f(jd(sample), n)
|
||||
full := f(jd(sample), -1)
|
||||
if angular {
|
||||
return angularDifference(got, full)
|
||||
}
|
||||
return math.Abs(got - full)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// pairTruncation 构造经纬成对返回的探针,取两个分量偏差的较大者。
|
||||
func pairTruncation(name string, jd func(index int) float64, f func(float64, int) (float64, float64), tol []float64) truncationCase {
|
||||
return truncationCase{
|
||||
name: name,
|
||||
tol: tol,
|
||||
err: func(sample, n int) float64 {
|
||||
gotA, gotB := f(jd(sample), n)
|
||||
fullA, fullB := f(jd(sample), -1)
|
||||
return math.Max(angularDifference(gotA, fullA), angularDifference(gotB, fullB))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// dateTruncation 构造以 time.Time 为入口的探针,偏差按角度最小夹角计。
|
||||
func dateTruncation(name string, date func(index int) time.Time, f func(time.Time, int) float64, tol []float64) truncationCase {
|
||||
return truncationCase{
|
||||
name: name,
|
||||
tol: tol,
|
||||
err: func(sample, n int) float64 {
|
||||
return angularDifference(f(date(sample), n), f(date(sample), -1))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// baselineDates 解析基线样本的 UTC 时刻。
|
||||
func baselineDates(t *testing.T, samples []baselineSample) []time.Time {
|
||||
t.Helper()
|
||||
|
||||
dates := make([]time.Time, len(samples))
|
||||
for i, sample := range samples {
|
||||
parsed, err := time.Parse(time.RFC3339Nano, sample.UTC)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dates[i] = parsed
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
// assertTruncationConverges 逐采样点检验误差上限,并要求最坏误差随 n 不增、多数采样点严格下降。
|
||||
// 单点截断误差不是嵌套部分和,允许同量级抖动,因此“不增”约束的是采样集上的误差包络。
|
||||
func assertTruncationConverges(t *testing.T, cases []truncationCase, samples int) {
|
||||
t.Helper()
|
||||
|
||||
for _, tc := range cases {
|
||||
envelope := make([]float64, len(truncationTermCounts))
|
||||
strict := 0
|
||||
for sample := 0; sample < samples; sample++ {
|
||||
first, last := 0.0, 0.0
|
||||
for i, n := range truncationTermCounts {
|
||||
e := tc.err(sample, n)
|
||||
if e > tc.tol[i] {
|
||||
t.Fatalf("%s: sample %d truncation error %.6g at n=%d exceeds %.6g", tc.name, sample, e, n, tc.tol[i])
|
||||
}
|
||||
if e > envelope[i] {
|
||||
envelope[i] = e
|
||||
}
|
||||
if i == 0 {
|
||||
first = e
|
||||
}
|
||||
last = e
|
||||
}
|
||||
if last < first {
|
||||
strict++
|
||||
}
|
||||
}
|
||||
for i := 1; i < len(envelope); i++ {
|
||||
if envelope[i] > envelope[i-1] {
|
||||
t.Fatalf("%s: worst truncation error grows from %.6g at n=%d to %.6g at n=%d",
|
||||
tc.name, envelope[i-1], truncationTermCounts[i-1], envelope[i], truncationTermCounts[i])
|
||||
}
|
||||
}
|
||||
if 2*strict < samples {
|
||||
t.Fatalf("%s: truncation error shrank with n at only %d of %d samples", tc.name, strict, samples)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetMoonBaselineRegression(t *testing.T) {
|
||||
samples := loadBaselineSamples(t)
|
||||
for _, sample := range samples {
|
||||
@@ -60,64 +178,85 @@ func TestPlanetMoonBaselineRegression(t *testing.T) {
|
||||
if math.Float64bits(gotLon) != body.LonBits {
|
||||
t.Fatalf("%s lon regression at %s", body.Name, sample.UTC)
|
||||
}
|
||||
gotLonN := planet.WherePlanetN(body.XT, 0, sample.TTJD, -1)
|
||||
if math.Float64bits(gotLonN) != body.LonBits {
|
||||
t.Fatalf("%s lon full-n regression at %s", body.Name, sample.UTC)
|
||||
}
|
||||
|
||||
gotLat := planet.WherePlanet(body.XT, 1, sample.TTJD)
|
||||
if math.Float64bits(gotLat) != body.LatBits {
|
||||
t.Fatalf("%s lat regression at %s", body.Name, sample.UTC)
|
||||
}
|
||||
gotLatN := planet.WherePlanetN(body.XT, 1, sample.TTJD, -1)
|
||||
if math.Float64bits(gotLatN) != body.LatBits {
|
||||
t.Fatalf("%s lat full-n regression at %s", body.Name, sample.UTC)
|
||||
}
|
||||
|
||||
gotRad := planet.WherePlanet(body.XT, 2, sample.TTJD)
|
||||
if math.Float64bits(gotRad) != body.RadBits {
|
||||
t.Fatalf("%s rad regression at %s", body.Name, sample.UTC)
|
||||
}
|
||||
gotRadN := planet.WherePlanetN(body.XT, 2, sample.TTJD, -1)
|
||||
if math.Float64bits(gotRadN) != body.RadBits {
|
||||
t.Fatalf("%s rad full-n regression at %s", body.Name, sample.UTC)
|
||||
}
|
||||
}
|
||||
|
||||
if math.Float64bits(basic.HMoonTrueLo(sample.TTJD)) != sample.Moon.LonBits {
|
||||
t.Fatalf("moon lon regression at %s", sample.UTC)
|
||||
}
|
||||
if math.Float64bits(basic.HMoonTrueLoN(sample.TTJD, -1)) != sample.Moon.LonBits {
|
||||
t.Fatalf("moon lon full-n regression at %s", sample.UTC)
|
||||
}
|
||||
if math.Float64bits(basic.HMoonTrueBo(sample.TTJD)) != sample.Moon.LatBits {
|
||||
t.Fatalf("moon lat regression at %s", sample.UTC)
|
||||
}
|
||||
if math.Float64bits(basic.HMoonTrueBoN(sample.TTJD, -1)) != sample.Moon.LatBits {
|
||||
t.Fatalf("moon lat full-n regression at %s", sample.UTC)
|
||||
}
|
||||
if math.Float64bits(basic.HMoonAway(sample.TTJD)) != sample.Moon.DisBits {
|
||||
t.Fatalf("moon distance regression at %s", sample.UTC)
|
||||
}
|
||||
if math.Float64bits(basic.HMoonAwayN(sample.TTJD, -1)) != sample.Moon.DisBits {
|
||||
t.Fatalf("moon distance full-n regression at %s", sample.UTC)
|
||||
}
|
||||
}
|
||||
|
||||
// planetTruncationName 与 planetTruncationTolerance 的下标同 WherePlanetN 的 xt。
|
||||
var planetTruncationName = []string{"earth", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune"}
|
||||
|
||||
// planetTruncationTolerance 是 zn=黄经/黄纬(度)、日心距(AU)两种量纲的上限,取实测最坏值的 5 倍以上。
|
||||
var planetTruncationTolerance = [][]float64{
|
||||
{20, 2, 0.5, 0.1},
|
||||
{5, 0.5, 0.05, 0.01},
|
||||
{0.3, 0.1, 0.05, 0.02},
|
||||
}
|
||||
|
||||
func planetMoonTruncationCases(samples []baselineSample) []truncationCase {
|
||||
jdOf := func(index int) float64 { return samples[index].TTJD }
|
||||
|
||||
wherePlanet := func(xt, zn int) truncationCase {
|
||||
return scalarTruncation(
|
||||
fmt.Sprintf("planet.WherePlanetN(%s, zn=%d)", planetTruncationName[xt], zn),
|
||||
jdOf,
|
||||
func(jd float64, n int) float64 { return planet.WherePlanetN(xt, zn, jd, n) },
|
||||
zn != 2,
|
||||
planetTruncationTolerance[zn],
|
||||
)
|
||||
}
|
||||
|
||||
cases := make([]truncationCase, 0, 27)
|
||||
for xt := 0; xt <= 7; xt++ {
|
||||
for zn := 0; zn <= 2; zn++ {
|
||||
cases = append(cases, wherePlanet(xt, zn))
|
||||
}
|
||||
}
|
||||
return append(cases,
|
||||
scalarTruncation("basic.HMoonTrueLoN", jdOf, basic.HMoonTrueLoN, true, []float64{5, 2.5, 1, 0.25}),
|
||||
scalarTruncation("basic.HMoonTrueBoN", jdOf, basic.HMoonTrueBoN, true, []float64{3, 0.75, 0.15, 0.06}),
|
||||
scalarTruncation("basic.HMoonAwayN", jdOf, basic.HMoonAwayN, false, []float64{40000, 7000, 2500, 400}),
|
||||
)
|
||||
}
|
||||
|
||||
func TestPublicTruncationFullMatchesDefault(t *testing.T) {
|
||||
date := time.Date(2026, 1, 2, 3, 4, 5, 123456789, time.UTC)
|
||||
func TestPlanetMoonTruncationConvergesToFullSeries(t *testing.T) {
|
||||
samples := loadBaselineSamples(t)
|
||||
assertTruncationConverges(t, planetMoonTruncationCases(samples), len(samples))
|
||||
}
|
||||
|
||||
if math.Float64bits(sun.TrueLo(date)) != math.Float64bits(sun.TrueLoN(date, -1)) {
|
||||
t.Fatal("sun.TrueLoN(-1) should match default")
|
||||
}
|
||||
if math.Float64bits(sun.TrueBo(date)) != math.Float64bits(sun.TrueBoN(date, -1)) {
|
||||
t.Fatal("sun.TrueBoN(-1) should match default")
|
||||
}
|
||||
if math.Float64bits(moon.TrueLo(date)) != math.Float64bits(moon.TrueLoN(date, -1)) {
|
||||
t.Fatal("moon.TrueLoN(-1) should match default")
|
||||
}
|
||||
if math.Float64bits(moon.TrueBo(date)) != math.Float64bits(moon.TrueBoN(date, -1)) {
|
||||
t.Fatal("moon.TrueBoN(-1) should match default")
|
||||
func publicSunMoonTruncationCases(t *testing.T, samples []baselineSample) []truncationCase {
|
||||
t.Helper()
|
||||
|
||||
dates := baselineDates(t, samples)
|
||||
dateOf := func(index int) time.Time { return dates[index] }
|
||||
return []truncationCase{
|
||||
dateTruncation("sun.TrueLoN", dateOf, sun.TrueLoN, []float64{0.15, 0.05, 0.02, 0.006}),
|
||||
dateTruncation("sun.TrueBoN", dateOf, sun.TrueBoN, []float64{1e-3, 5e-4, 3e-4, 1.5e-4}),
|
||||
dateTruncation("moon.TrueLoN", dateOf, moon.TrueLoN, []float64{5, 2.5, 1, 0.25}),
|
||||
dateTruncation("moon.TrueBoN", dateOf, moon.TrueBoN, []float64{3, 0.75, 0.15, 0.06}),
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicSunMoonTruncationConvergesToFullSeries(t *testing.T) {
|
||||
samples := loadBaselineSamples(t)
|
||||
assertTruncationConverges(t, publicSunMoonTruncationCases(t, samples), len(samples))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ancientStationTolerance = 10.0 / 1440.0
|
||||
|
||||
func ancientStationTT(year int, month time.Month, day int) float64 {
|
||||
return UTC2TT(Date2JD(time.Date(year, month, day, 0, 0, 0, 0, time.UTC)))
|
||||
}
|
||||
|
||||
func ancientStationUT(year int, month time.Month, day, hour, minute int) float64 {
|
||||
return Date2JD(time.Date(year, month, day, hour, minute, 0, 0, time.UTC))
|
||||
}
|
||||
|
||||
func assertAncientNextStation(t *testing.T, queryTT, gotUT, wantUT float64) {
|
||||
t.Helper()
|
||||
if !eventUTQueryAfterOrEqual(gotUT, queryTT) {
|
||||
t.Fatalf("station is before query: query TT %.9f, got UT %.9f", queryTT, gotUT)
|
||||
}
|
||||
if diff := math.Abs(gotUT - wantUT); diff > ancientStationTolerance {
|
||||
t.Fatalf("station differs by %.3f minutes: got %s, want %s", diff*1440,
|
||||
JD2DateByZone(gotUT, time.UTC, false), JD2DateByZone(wantUT, time.UTC, false))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarsAncientNextStationsStayOnTheirOppositionSides(t *testing.T) {
|
||||
queryTT := ancientStationTT(-210, time.April, 1)
|
||||
p2r := NextMarsProgradeToRetrograde(queryTT)
|
||||
r2p := NextMarsRetrogradeToPrograde(queryTT)
|
||||
|
||||
assertAncientNextStation(t, queryTT, p2r, ancientStationUT(-209, time.March, 25, 20, 20))
|
||||
assertAncientNextStation(t, queryTT, r2p, ancientStationUT(-209, time.June, 6, 11, 4))
|
||||
if sameEventJD(p2r, r2p) {
|
||||
t.Fatalf("typed Mars stations collapsed to one event: %.9f", p2r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMercuryAncientNextStationsDoNotSkipOrLoop(t *testing.T) {
|
||||
janQueryTT := ancientStationTT(-210, time.January, 1)
|
||||
assertAncientNextStation(t, janQueryTT, NextMercuryProgradeToRetrograde(janQueryTT), ancientStationUT(-210, time.February, 6, 21, 29))
|
||||
assertAncientNextStation(t, janQueryTT, NextMercuryRetrogradeToPrograde(janQueryTT), ancientStationUT(-210, time.March, 1, 14, 20))
|
||||
|
||||
marQueryTT := ancientStationTT(-210, time.March, 1)
|
||||
assertAncientNextStation(t, marQueryTT, NextMercuryProgradeToRetrograde(marQueryTT), ancientStationUT(-210, time.June, 11, 18, 50))
|
||||
assertAncientNextStation(t, marQueryTT, NextMercuryRetrogradeToPrograde(marQueryTT), ancientStationUT(-210, time.March, 1, 14, 20))
|
||||
}
|
||||
+22
-10
@@ -32,8 +32,8 @@ const (
|
||||
|
||||
// ApsisEvent 轨道极值事件 / orbital distance extremum event.
|
||||
type ApsisEvent struct {
|
||||
// JDE 是事件发生时刻对应的世界时儒略日 / event time as UTC-based Julian day.
|
||||
JDE float64
|
||||
// JD 是事件发生时刻对应的世界时儒略日 / event time as UTC-based Julian day.
|
||||
JD float64
|
||||
// Distance 是极值距离;地球相关事件单位 AU,月球相关事件单位 km / extremum distance.
|
||||
Distance float64
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func earthApsis(year int, aphelion bool) ApsisEvent {
|
||||
}
|
||||
eventTT, distanceAU := refineDistanceExtremum(seedTT, cfg, EarthAway)
|
||||
return ApsisEvent{
|
||||
JDE: TD2UT(eventTT, false),
|
||||
JD: TT2UTC(eventTT),
|
||||
Distance: distanceAU,
|
||||
}
|
||||
}
|
||||
@@ -87,8 +87,8 @@ func earthApsis(year int, aphelion bool) ApsisEvent {
|
||||
func moonApsisInMonth(year int, month time.Month, apogee bool) []ApsisEvent {
|
||||
startUTC := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
|
||||
endUTC := startUTC.AddDate(0, 1, 0)
|
||||
startTT := TD2UT(Date2JDE(startUTC), true)
|
||||
endTT := TD2UT(Date2JDE(endUTC), true)
|
||||
startTT := UTC2TT(Date2JD(startUTC))
|
||||
endTT := UTC2TT(Date2JD(endUTC))
|
||||
|
||||
kStart := int(math.Floor((startTT-moonApsisBaseTTJDE)/moonApsisMeanMonthDays)) - 1
|
||||
kEnd := int(math.Ceil((endTT-moonApsisBaseTTJDE)/moonApsisMeanMonthDays)) + 1
|
||||
@@ -110,19 +110,19 @@ func moonApsisInMonth(year int, month time.Month, apogee bool) []ApsisEvent {
|
||||
for k := kStart; k <= kEnd; k++ {
|
||||
seedTT := moonApsisSeedTT(float64(k) + phase)
|
||||
eventTT, distanceKM := refineDistanceExtremum(seedTT, cfg, HMoonAway)
|
||||
eventUT := TD2UT(eventTT, false)
|
||||
eventTimeUTC := JDE2DateByZone(eventUT, time.UTC, false)
|
||||
eventUT := TT2UTC(eventTT)
|
||||
eventTimeUTC := JD2DateByZone(eventUT, time.UTC, false)
|
||||
if eventTimeUTC.Before(startUTC) || !eventTimeUTC.Before(endUTC) {
|
||||
continue
|
||||
}
|
||||
events = append(events, ApsisEvent{
|
||||
JDE: eventUT,
|
||||
JD: eventUT,
|
||||
Distance: distanceKM,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(events, func(i, j int) bool {
|
||||
return events[i].JDE < events[j].JDE
|
||||
return events[i].JD < events[j].JD
|
||||
})
|
||||
return events
|
||||
}
|
||||
@@ -145,14 +145,26 @@ func moonApsisSeedTT(k float64) float64 {
|
||||
}
|
||||
|
||||
func refineDistanceExtremum(seed float64, cfg apsisSearchConfig, distanceFn func(float64) float64) (float64, float64) {
|
||||
if !finite(seed) || !finite(cfg.bracketHalfWidth) || !finite(cfg.sampleStep) || cfg.sampleStep <= 0 {
|
||||
return seed, math.NaN()
|
||||
}
|
||||
best := seed
|
||||
bestDistance := distanceFn(seed)
|
||||
for sample := seed - cfg.bracketHalfWidth; sample <= seed+cfg.bracketHalfWidth+1e-12; sample += cfg.sampleStep {
|
||||
// seed 幅度很大时 sample += sampleStep 可能小于一个 ULP(浮点间隔)而永不推进,
|
||||
// 因此显式检测步长是否真的改变了采样点,避免无界循环。
|
||||
// At a large |seed| the step can fall below one ULP, so `sample += step` would never
|
||||
// advance; detect a step that does not move the sample and stop the sweep instead.
|
||||
for sample, end := seed-cfg.bracketHalfWidth, seed+cfg.bracketHalfWidth+1e-12; sample <= end; {
|
||||
dist := distanceFn(sample)
|
||||
if distanceBetter(dist, bestDistance, cfg.maximize) {
|
||||
best = sample
|
||||
bestDistance = dist
|
||||
}
|
||||
next := sample + cfg.sampleStep
|
||||
if next == sample {
|
||||
break
|
||||
}
|
||||
sample = next
|
||||
}
|
||||
|
||||
left, right, ok := apsisDerivativeBracket(best, seed, cfg, distanceFn)
|
||||
|
||||
+2
-2
@@ -62,7 +62,7 @@ func TestEarthApsisMatchesHorizonsBaseline(t *testing.T) {
|
||||
t.Fatalf("unknown earth apsis kind %q", sample.Kind)
|
||||
}
|
||||
|
||||
gotTime := JDE2DateByZone(got.JDE, time.UTC, false)
|
||||
gotTime := JD2DateByZone(got.JD, time.UTC, false)
|
||||
timeDiff := gotTime.Sub(wantTime)
|
||||
if timeDiff < 0 {
|
||||
timeDiff = -timeDiff
|
||||
@@ -139,7 +139,7 @@ func TestMoonApsisMatchesHorizonsBaseline(t *testing.T) {
|
||||
t.Fatalf("unknown moon apsis kind %q", sample.Kind)
|
||||
}
|
||||
|
||||
gotTime := JDE2DateByZone(got.JDE, time.UTC, false)
|
||||
gotTime := JD2DateByZone(got.JD, time.UTC, false)
|
||||
timeDiff := gotTime.Sub(wantTime)
|
||||
if timeDiff < 0 {
|
||||
timeDiff = -timeDiff
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package basic
|
||||
|
||||
import "math"
|
||||
|
||||
const (
|
||||
// 线性时标关系的参考历元,不表示各时标读数在此相等。
|
||||
barycentricT0JDE = 2443144.5003725
|
||||
lgRate = 6.969290134e-10
|
||||
lbRate = 1.550519768e-8
|
||||
tdb0Seconds = -65.5e-6
|
||||
)
|
||||
|
||||
// tdbPeriodicGroups 按 t 的幂次分组的 TDB−TT 周期项(振幅秒、频率 rad/儒略千年、相位 rad)。
|
||||
var tdbPeriodicGroups = [5][][3]float64{
|
||||
{
|
||||
{0.001656674564, 6283.075849991, 6.240054195},
|
||||
{2.2417471e-05, 5753.384884897, 4.296977442},
|
||||
{1.3839792e-05, 12566.151699983, 6.19690441},
|
||||
{4.770086e-06, 529.690965095, 0.444401603},
|
||||
{4.67674e-06, 6069.776754553, 4.021195093},
|
||||
{2.256707e-06, 213.299095438, 5.543113262},
|
||||
{1.694205e-06, -3.523118349, 5.025132748},
|
||||
{1.554905e-06, 77713.77146792, 5.19846709},
|
||||
{1.276839e-06, 7860.419392439, 5.988822341},
|
||||
{1.193379e-06, 5223.693919802, 3.64982373},
|
||||
{1.115322e-06, 3930.20969622, 1.422745069},
|
||||
{7.94185e-07, 11506.769769794, 2.322313077},
|
||||
{4.47061e-07, 26.2983198, 3.615796498},
|
||||
{4.35206e-07, -398.149003408, 4.349338347},
|
||||
{6.00309e-07, 1577.343542448, 2.678271909},
|
||||
{4.96817e-07, 6208.294251424, 5.696701824},
|
||||
{4.86306e-07, 5884.926846583, 0.520007179},
|
||||
{4.32392e-07, 74.781598567, 2.435898309},
|
||||
{4.68597e-07, 6244.942814354, 5.866398759},
|
||||
{3.7551e-07, 5507.553238667, 4.103476804},
|
||||
{2.43085e-07, -775.522611324, 3.651837925},
|
||||
{1.73435e-07, 18849.227549974, 6.153743485},
|
||||
{2.30685e-07, 5856.477659115, 4.773852582},
|
||||
{2.03747e-07, 12036.460734888, 4.333987818},
|
||||
},
|
||||
{
|
||||
{0.000102156724, 6283.075849991, 4.249032005},
|
||||
{1.706807e-06, 12566.151699983, 4.205904248},
|
||||
{2.69668e-07, 213.299095438, 3.400290479},
|
||||
{2.65919e-07, 529.690965095, 5.836047367},
|
||||
{2.10568e-07, -3.523118349, 6.262738348},
|
||||
{7.7996e-08, 5223.693919802, 4.670344204},
|
||||
{5.4764e-08, 1577.343542448, 4.53480017},
|
||||
{5.9146e-08, 26.2983198, 1.083044735},
|
||||
},
|
||||
{
|
||||
{4.32299e-06, 6283.075849991, 2.642893748},
|
||||
{4.06495e-07, 0.0, 4.71238898},
|
||||
{1.22605e-07, 12566.151699983, 2.438140634},
|
||||
{1.9476e-08, 213.299095438, 1.642186981},
|
||||
},
|
||||
{
|
||||
{1.43388e-07, 6283.075849991, 1.131453581},
|
||||
{6.671e-09, 12566.151699983, 0.775148887},
|
||||
},
|
||||
{
|
||||
{3.826e-09, 6283.075849991, 5.705257275},
|
||||
{3.03e-10, 12566.151699983, 5.407132842},
|
||||
},
|
||||
}
|
||||
|
||||
// 地心近似,不包含观测者位置引起的日周项。
|
||||
func tdbPeriodicSeconds(jd float64) float64 {
|
||||
t := (jd - 2451545.0) / 365250.0
|
||||
sum := 0.0
|
||||
for i := len(tdbPeriodicGroups) - 1; i >= 0; i-- {
|
||||
group := 0.0
|
||||
for _, term := range tdbPeriodicGroups[i] {
|
||||
group += term[0] * math.Sin(term[1]*t+term[2])
|
||||
}
|
||||
sum = sum*t + group
|
||||
}
|
||||
return sum + 0.00065e-6*math.Sin(6069.776754*t+4.021194) +
|
||||
0.00033e-6*math.Sin(213.299095*t+5.543132) -
|
||||
0.00196e-6*math.Sin(6208.294251*t+5.696701) -
|
||||
0.00173e-6*math.Sin(74.781599*t+2.435900) +
|
||||
0.03638e-6*t*t
|
||||
}
|
||||
|
||||
// TCGMinusTTSeconds 返回 TT 时刻的 TCG−TT(秒)/ TCG−TT in seconds at a TT instant.
|
||||
func TCGMinusTTSeconds(jd float64) float64 {
|
||||
return lgRate / (1 - lgRate) * (jd - barycentricT0JDE) * 86400
|
||||
}
|
||||
|
||||
// TCBMinusTTSeconds 返回 TT 时刻的地心 TCB−TT 近似值(秒)/ geocentric TCB−TT approximation in seconds at a TT instant.
|
||||
func TCBMinusTTSeconds(jd float64) float64 {
|
||||
return (lbRate*(jd-barycentricT0JDE)*86400 + tdbPeriodicSeconds(jd) - tdb0Seconds) / (1 - lbRate)
|
||||
}
|
||||
|
||||
// TDBMinusTTSeconds 返回 TT 时刻的地心 TDB−TT 近似值(秒)/ geocentric TDB−TT approximation in seconds at a TT instant.
|
||||
func TDBMinusTTSeconds(jd float64) float64 { return tdbPeriodicSeconds(jd) }
|
||||
|
||||
// TT2TCG 地球时转地心坐标时 / converts TT to TCG.
|
||||
func TT2TCG(ttJDE float64) float64 { return ttJDE + TCGMinusTTSeconds(ttJDE)/86400 }
|
||||
|
||||
// TCG2TT 地心坐标时转地球时 / converts TCG to TT.
|
||||
func TCG2TT(tcgJDE float64) float64 { return tcgJDE - lgRate*(tcgJDE-barycentricT0JDE) }
|
||||
|
||||
// TT2TCB 地球时转太阳系质心坐标时,采用地心近似 / converts TT to TCB using a geocentric approximation.
|
||||
func TT2TCB(ttJDE float64) float64 { return ttJDE + TCBMinusTTSeconds(ttJDE)/86400 }
|
||||
|
||||
// TCB2TT 是 TT2TCB 的逆 / inverts TT2TCB.
|
||||
func TCB2TT(tcbJDE float64) float64 {
|
||||
// TDB 与 TT 相差不超过 2 ms,拿 TDB 读数当初值可少迭代两轮。
|
||||
tt := TCB2TDB(tcbJDE)
|
||||
for i := 0; i < 3; i++ {
|
||||
tt = tcbJDE - TCBMinusTTSeconds(tt)/86400
|
||||
}
|
||||
return tt
|
||||
}
|
||||
|
||||
// TT2TDB 地球时转太阳系质心力学时,采用地心近似 / converts TT to TDB using a geocentric approximation.
|
||||
func TT2TDB(ttJDE float64) float64 { return ttJDE + TDBMinusTTSeconds(ttJDE)/86400 }
|
||||
|
||||
// TDB2TT 是 TT2TDB 的逆 / inverts TT2TDB.
|
||||
func TDB2TT(tdbJDE float64) float64 {
|
||||
tt := tdbJDE - TDBMinusTTSeconds(tdbJDE)/86400
|
||||
for i := 0; i < 3; i++ {
|
||||
tt = tdbJDE - TDBMinusTTSeconds(tt)/86400
|
||||
}
|
||||
return tt
|
||||
}
|
||||
|
||||
// TCB2TDB 太阳系质心坐标时转质心力学时 / converts TCB to TDB.
|
||||
func TCB2TDB(tcbJDE float64) float64 {
|
||||
return tcbJDE + (tdb0Seconds/86400 - lbRate*(tcbJDE-barycentricT0JDE))
|
||||
}
|
||||
|
||||
// TDB2TCB 太阳系质心力学时转质心坐标时 / converts TDB to TCB.
|
||||
func TDB2TCB(tdbJDE float64) float64 {
|
||||
return tdbJDE + (lbRate*(tdbJDE-barycentricT0JDE)-tdb0Seconds/86400)/(1-lbRate)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBarycentricScaleDefinitions(t *testing.T) {
|
||||
for _, jd := range []float64{JDCalc(-3000, 1, 1), barycentricT0JDE, JDCalc(2026, 1, 1), JDCalc(6000, 1, 1)} {
|
||||
tcgOffset := TCGMinusTTSeconds(jd)
|
||||
if diff := tcgOffset*(1-6.969290134e-10) - 6.969290134e-10*(jd-2443144.5003725)*86400; math.Abs(diff) > 1e-12 {
|
||||
t.Errorf("jd=%v TCG defining relation residual=%g s", jd, diff)
|
||||
}
|
||||
tcbOffset := TCBMinusTTSeconds(jd)
|
||||
if diff := tcbOffset*(1-1.550519768e-8) - 1.550519768e-8*(jd-2443144.5003725)*86400 - TDBMinusTTSeconds(jd) - 65.5e-6; math.Abs(diff) > 1e-12 {
|
||||
t.Errorf("jd=%v TCB defining relation residual=%g s", jd, diff)
|
||||
}
|
||||
}
|
||||
if got := TCGMinusTTSeconds(barycentricT0JDE); got != 0 {
|
||||
t.Errorf("TCG-TT at T0=%v, want 0", got)
|
||||
}
|
||||
if got, want := TCB2TDB(barycentricT0JDE), barycentricT0JDE-65.5e-6/86400; got != want {
|
||||
t.Errorf("TDB at TCB T0=%.12f, want %.12f", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBarycentricLinearConversionAnchors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
fn func(float64) float64
|
||||
jd float64
|
||||
want float64
|
||||
}{
|
||||
{"TCB2TDB", TCB2TDB, 2453750.5 + 0.893019599, 2453750.5 + 0.8928551362746343397},
|
||||
{"TDB2TCB", TDB2TCB, 2453750.5 + 0.892855137, 2453750.5 + 0.8930195997253656716},
|
||||
{"TCG2TT", TCG2TT, 2453750.5 + 0.892862531, 2453750.5 + 0.8928551387488816828},
|
||||
{"TT2TCG", TT2TCG, 2453750.5 + 0.892482639, 2453750.5 + 0.8924900312508587113},
|
||||
} {
|
||||
ulp := math.Nextafter(tc.want, math.Inf(1)) - tc.want
|
||||
if got := tc.fn(tc.jd); math.Abs(got-tc.want) > ulp {
|
||||
t.Errorf("%s=%.12f, want %.12f", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBarycentricPeriodicTermsMatchFullSeries(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
jd float64
|
||||
want float64
|
||||
}{
|
||||
{625307.5, 0.001558488628712037},
|
||||
{766574.5, -0.000924800103328196},
|
||||
{1721057.5, 0.0008624527814561311},
|
||||
{2433282.5, -7.069829559472634e-05},
|
||||
{2443144.5, -6.551401857064118e-05},
|
||||
{2451544.5, -0.0001137630988927298},
|
||||
{2461041.5, -8.20152430051247e-05},
|
||||
{2461222.5, 0.0001186656922342686},
|
||||
{2469807.5, -8.01882947792431e-05},
|
||||
{3182029.5, -0.0009618304620565844},
|
||||
{3912514.5, -0.00138271667055309},
|
||||
} {
|
||||
if got := TDBMinusTTSeconds(tc.jd); math.Abs(got-tc.want) > 2.1e-6 {
|
||||
t.Errorf("jd=%v TDB-TT=%.9f us, want %.9f us", tc.jd, got*1e6, tc.want*1e6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBarycentricScaleRoundTrips(t *testing.T) {
|
||||
for _, jd := range []float64{
|
||||
JDCalc(-3000, 1, 1), JDCalc(0, 1, 1), JDCalc(1900, 1, 1), JDCalc(1950, 6, 1),
|
||||
barycentricT0JDE, JDCalc(2000, 1, 1), JDCalc(2026, 4, 1), JDCalc(2050, 7, 1),
|
||||
JDCalc(2100, 1, 1), JDCalc(6000, 1, 1),
|
||||
} {
|
||||
ulp := math.Nextafter(jd, math.Inf(1)) - jd
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
fwd func(float64) float64
|
||||
back func(float64) float64
|
||||
}{
|
||||
{"TT-TCG", TT2TCG, TCG2TT},
|
||||
{"TT-TCB", TT2TCB, TCB2TT},
|
||||
{"TT-TDB", TT2TDB, TDB2TT},
|
||||
{"TCB-TDB", TCB2TDB, TDB2TCB},
|
||||
} {
|
||||
if got := tc.back(tc.fwd(jd)); math.Abs(got-jd) > ulp {
|
||||
t.Errorf("jd=%v %s round-trip error=%g s", jd, tc.name, (got-jd)*86400)
|
||||
}
|
||||
if got := tc.fwd(tc.back(jd)); math.Abs(got-jd) > ulp {
|
||||
t.Errorf("jd=%v %s inverse round-trip error=%g s", jd, tc.name, (got-jd)*86400)
|
||||
}
|
||||
}
|
||||
// 组合路径分别舍入,允许两次 JD 舍入误差。
|
||||
if diff := TCB2TDB(TT2TCB(jd)) - TT2TDB(jd); math.Abs(diff) > 2*ulp {
|
||||
t.Errorf("jd=%v TT-TCB-TDB differs from TT-TDB by %g s", jd, diff*86400)
|
||||
}
|
||||
if diff := (TT2TDB(jd)-jd)*86400 - TDBMinusTTSeconds(jd); math.Abs(diff) > ulp*86400 {
|
||||
t.Errorf("jd=%v TDB seconds and JD conversions differ by %g s", jd, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBarycentricScaleMagnitudes(t *testing.T) {
|
||||
jd := JDCalc(2026, 1, 1)
|
||||
if got := TCGMinusTTSeconds(jd); math.Abs(got-1.0777) > 5e-3 {
|
||||
t.Errorf("TCG-TT=%v s, want about 1.078", got)
|
||||
}
|
||||
if got := TCBMinusTTSeconds(jd); math.Abs(got-23.9757) > 5e-3 {
|
||||
t.Errorf("TCB-TT=%v s, want about 23.976", got)
|
||||
}
|
||||
if got := TCGMinusTTSeconds(barycentricT0JDE+365.25) - TCGMinusTTSeconds(barycentricT0JDE); math.Abs(got-0.02204) > 5e-5 {
|
||||
t.Errorf("TCG-TT annual increase=%v s, want about 0.02204", got)
|
||||
}
|
||||
if got := TCBMinusTTSeconds(barycentricT0JDE+365.25) - TCBMinusTTSeconds(barycentricT0JDE); math.Abs(got-0.48934) > 5e-4 {
|
||||
t.Errorf("TCB-TT annual increase=%v s, want about 0.48934", got)
|
||||
}
|
||||
if got := TDBMinusTTSeconds(JDCalc(2100, 1, 1)) - TDBMinusTTSeconds(JDCalc(2000, 1, 1)); math.Abs(got) > 2e-4 {
|
||||
t.Errorf("TDB-TT century difference=%v s, want within 0.2 ms", got)
|
||||
}
|
||||
min, max := math.Inf(1), math.Inf(-1)
|
||||
for month := 1; month <= 12; month++ {
|
||||
value := TDBMinusTTSeconds(JDCalc(2026, month, 1))
|
||||
min, max = math.Min(min, value), math.Max(max, value)
|
||||
}
|
||||
if amplitude := max - min; amplitude < 3.0e-3 || amplitude > 3.6e-3 {
|
||||
t.Errorf("TDB-TT annual range=%v s, want 3.0-3.6 ms", amplitude)
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func TestGetJQTime(t *testing.T) {
|
||||
month -= 12
|
||||
}
|
||||
|
||||
jd := JDECalc(year, int(month), float64(day))
|
||||
jd := JDCalc(year, int(month), float64(day))
|
||||
if angle == 0 {
|
||||
angle = 360
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func TestGetJQTime(t *testing.T) {
|
||||
jd -= 0.001
|
||||
}
|
||||
jd += 0.001
|
||||
return TD2UT(jd, false)
|
||||
return TT2UTC(jd)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestConstellationNameLookups(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConstellationNameEN(t *testing.T) {
|
||||
jde := Date2JDE(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
jde := Date2JD(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
if en := ConstellationNameEN(88.792939, 7.407064, jde); en != "Orion" {
|
||||
t.Fatalf("ConstellationNameEN() = %q, want %q", en, "Orion")
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
)
|
||||
|
||||
func TestConstellationNameZH(t *testing.T) {
|
||||
now := GetNowJDE()
|
||||
now := GetNowJD()
|
||||
//finish on 30s
|
||||
for i := 0.00; i <= 360.00; i += 0.5 {
|
||||
for j := -90.00; j <= 90.00; j += 0.5 {
|
||||
@@ -15,9 +15,9 @@ func TestConstellationNameZH(t *testing.T) {
|
||||
}
|
||||
|
||||
func BenchmarkConstellationNameZH(b *testing.B) {
|
||||
jde := GetNowJDE()
|
||||
jde := GetNowJD()
|
||||
for i := 0; i < b.N; i++ {
|
||||
//GetNowJDE()
|
||||
//GetNowJD()
|
||||
ConstellationNameZH(11.11, 12.12, jde)
|
||||
}
|
||||
|
||||
|
||||
+41
-44
@@ -10,7 +10,8 @@ import (
|
||||
* 坐标变换,黄道转赤道
|
||||
*/
|
||||
func LoToRa(jde, lo, bo float64) float64 {
|
||||
ra := math.Atan2(Sin(lo)*Cos(TrueObliquity(jde))-Tan(bo)*Sin(TrueObliquity(jde)), Cos(lo))
|
||||
eps := TrueObliquity(jde)
|
||||
ra := math.Atan2(Sin(lo)*Cos(eps)-Tan(bo)*Sin(eps), Cos(lo))
|
||||
ra = ra * 180 / math.Pi
|
||||
if ra < 0 {
|
||||
ra += 360
|
||||
@@ -19,13 +20,17 @@ func LoToRa(jde, lo, bo float64) float64 {
|
||||
}
|
||||
|
||||
func BoToDec(jde, lo, bo float64) float64 {
|
||||
dec := ArcSin(Sin(bo)*Cos(TrueObliquity(jde)) + Cos(bo)*Sin(TrueObliquity(jde))*Sin(lo))
|
||||
eps := TrueObliquity(jde)
|
||||
dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo))
|
||||
return dec
|
||||
}
|
||||
|
||||
func LoBoToRaDec(jde, lo, bo float64) (float64, float64) {
|
||||
dec := ArcSin(Sin(bo)*Cos(TrueObliquity(jde)) + Cos(bo)*Sin(TrueObliquity(jde))*Sin(lo))
|
||||
ra := math.Atan2(Sin(lo)*Cos(TrueObliquity(jde))-Tan(bo)*Sin(TrueObliquity(jde)), Cos(lo))
|
||||
eps := TrueObliquity(jde)
|
||||
sinEps := Sin(eps)
|
||||
cosEps := Cos(eps)
|
||||
dec := ArcSin(Sin(bo)*cosEps + Cos(bo)*sinEps*Sin(lo))
|
||||
ra := math.Atan2(Sin(lo)*cosEps-Tan(bo)*sinEps, Cos(lo))
|
||||
ra = ra * 180 / math.Pi
|
||||
if ra < 0 {
|
||||
ra += 360
|
||||
@@ -81,10 +86,14 @@ func psini(lat, h float64) float64 {
|
||||
}
|
||||
|
||||
func TopocentricRaDec(ra, dec, lat, lon, jd, au, h float64) (float64, float64) {
|
||||
return topocentricRaDecWithSidereal(ra, dec, lat, lon, ApparentSiderealTime(UTC2UT1(jd))*15, au, h)
|
||||
}
|
||||
|
||||
func topocentricRaDecWithSidereal(ra, dec, lat, lon, siderealDegrees, au, h float64) (float64, float64) {
|
||||
sinpi := Sin(0.0024427777777) / au
|
||||
pcosi := pcosi(lat, h)
|
||||
psini := psini(lat, h)
|
||||
tH := Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + lon - ra)
|
||||
tH := Limit360(siderealDegrees + lon - ra)
|
||||
nra := math.Atan2(-pcosi*sinpi*Sin(tH), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
|
||||
|
||||
ndec := math.Atan2((Sin(dec)-psini*sinpi)*Cos(nra), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
|
||||
@@ -92,61 +101,49 @@ func TopocentricRaDec(ra, dec, lat, lon, jd, au, h float64) (float64, float64) {
|
||||
}
|
||||
|
||||
func TopocentricRa(ra, dec, lat, lon, jd, au, h float64) float64 { //jd为格林尼治标准时
|
||||
sinpi := Sin(0.0024427777777) / au
|
||||
pcosi := pcosi(lat, h)
|
||||
tH := Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + lon - ra)
|
||||
nra := math.Atan2(-pcosi*sinpi*Sin(tH), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
|
||||
return ra + nra
|
||||
topocentricRA, _ := TopocentricRaDec(ra, dec, lat, lon, jd, au, h)
|
||||
return topocentricRA
|
||||
}
|
||||
func TopocentricDec(ra, dec, lat, lon, jd, au, h float64) float64 { //jd为格林尼治标准时
|
||||
|
||||
sinpi := Sin(0.0024427777777) / au
|
||||
pcosi := pcosi(lat, h)
|
||||
psini := psini(lat, h)
|
||||
tH := Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + lon - ra)
|
||||
nra := math.Atan2(-pcosi*sinpi*Sin(tH), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
|
||||
|
||||
ndec := math.Atan2((Sin(dec)-psini*sinpi)*Cos(nra), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
|
||||
return ndec
|
||||
_, topocentricDec := TopocentricRaDec(ra, dec, lat, lon, jd, au, h)
|
||||
return topocentricDec
|
||||
}
|
||||
|
||||
func TopocentricLo(lo, bo, lat, lon, jd, au, h float64) float64 { //jd为格林尼治标准时
|
||||
c := pcosi(lat, h)
|
||||
s := psini(lat, h)
|
||||
sinpi := Sin(0.0024427777777) / au
|
||||
ra := LoToRa(jd, lo, bo)
|
||||
tH := Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + lon - ra)
|
||||
n := Cos(lo)*Cos(bo) - c*sinpi*Cos(tH)
|
||||
nlo := math.Atan2(Sin(lo)*Cos(bo)-sinpi*(s*Sin(TrueObliquity(jd))+c*Cos(TrueObliquity(jd))*Sin(tH)), n) * 180 / math.Pi
|
||||
// TopocentricLoBo 一次求值给出站心黄经与黄纬 / topocentric ecliptic longitude and latitude in one solve.
|
||||
//
|
||||
// 先解站心赤道坐标再转黄道:黄道版公式的分母在黄经 90°–270° 时变号,直接 atan2 会切到对顶象限。
|
||||
func TopocentricLoBo(lo, bo, lat, lon, jde, au, h float64) (float64, float64) {
|
||||
ra, dec := LoBoToRaDec(jde, lo, bo)
|
||||
topRA, topDec := TopocentricRaDec(ra, dec, lat, lon, jde, au, h)
|
||||
return RaDecToLoBo(jde, topRA, topDec)
|
||||
}
|
||||
|
||||
// TopocentricLo 站心黄经 / topocentric ecliptic longitude.
|
||||
func TopocentricLo(lo, bo, lat, lon, jde, au, h float64) float64 {
|
||||
nlo, _ := TopocentricLoBo(lo, bo, lat, lon, jde, au, h)
|
||||
return nlo
|
||||
}
|
||||
|
||||
func TopocentricBo(lo, bo, lat, lon, jd, au, h float64) float64 { //jd为格林尼治标准时
|
||||
c := pcosi(lat, h)
|
||||
s := psini(lat, h)
|
||||
sinpi := Sin(0.0024427777777) / au
|
||||
ra := LoToRa(jd, lo, bo)
|
||||
tH := Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + lon - ra)
|
||||
n := Cos(lo)*Cos(bo) - c*sinpi*Cos(tH)
|
||||
nlo := math.Atan2(Sin(lo)*Cos(bo)-sinpi*(s*Sin(TrueObliquity(jd))+c*Cos(TrueObliquity(jd))*Sin(tH)), n) * 180 / math.Pi
|
||||
nbo := math.Atan2(Cos(nlo)*(Sin(bo)-sinpi*(s*Cos(TrueObliquity(jd))-c*Sin(TrueObliquity(jd))*Sin(tH))), n) * 180 / math.Pi
|
||||
// TopocentricBo 站心黄纬 / topocentric ecliptic latitude.
|
||||
func TopocentricBo(lo, bo, lat, lon, jde, au, h float64) float64 {
|
||||
_, nbo := TopocentricLoBo(lo, bo, lat, lon, jde, au, h)
|
||||
return nbo
|
||||
}
|
||||
|
||||
func GXCLo(lo, bo, jd float64) float64 { //光行差修正
|
||||
func GXCLo(lo, bo, jde float64) float64 { //光行差修正
|
||||
k := 20.49552
|
||||
sunlo := SunTrueLo(jd)
|
||||
e := Earthe(jd)
|
||||
epi := EarthPI(jd)
|
||||
sunlo := SunTrueLo(jde)
|
||||
e := Earthe(jde)
|
||||
epi := EarthPI(jde)
|
||||
tmp := (-k*Cos(sunlo-lo) + e*k*Cos(epi-lo)) / Cos(bo)
|
||||
return tmp
|
||||
}
|
||||
|
||||
func GXCBo(lo, bo, jd float64) float64 {
|
||||
func GXCBo(lo, bo, jde float64) float64 {
|
||||
k := 20.49552
|
||||
sunlo := SunTrueLo(jd)
|
||||
e := Earthe(jd)
|
||||
epi := EarthPI(jd)
|
||||
sunlo := SunTrueLo(jde)
|
||||
e := Earthe(jde)
|
||||
epi := EarthPI(jde)
|
||||
tmp := -k * Sin(bo) * (Sin(sunlo-lo) - e*Sin(epi-lo))
|
||||
return tmp
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
. "b612.me/astro/tools"
|
||||
)
|
||||
|
||||
func TestEclipticToEquatorialCachedObliquityMatchesRepeatedEvaluation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
jde float64
|
||||
lo float64
|
||||
bo float64
|
||||
}{
|
||||
{jde: 2451545.0, lo: 0, bo: 0},
|
||||
{jde: 2460832.0, lo: 193.25, bo: -5.75},
|
||||
{jde: 2378496.5, lo: 359.999, bo: 87.5},
|
||||
} {
|
||||
legacyDec := ArcSin(
|
||||
Sin(test.bo)*Cos(TrueObliquity(test.jde)) +
|
||||
Cos(test.bo)*Sin(TrueObliquity(test.jde))*Sin(test.lo),
|
||||
)
|
||||
legacyRA := math.Atan2(
|
||||
Sin(test.lo)*Cos(TrueObliquity(test.jde))-Tan(test.bo)*Sin(TrueObliquity(test.jde)),
|
||||
Cos(test.lo),
|
||||
) * 180 / math.Pi
|
||||
if legacyRA < 0 {
|
||||
legacyRA += 360
|
||||
}
|
||||
|
||||
ra, dec := LoBoToRaDec(test.jde, test.lo, test.bo)
|
||||
if ra != legacyRA || dec != legacyDec {
|
||||
t.Fatalf("LoBoToRaDec(%v, %v, %v) = %.15g %.15g, want %.15g %.15g",
|
||||
test.jde, test.lo, test.bo, ra, dec, legacyRA, legacyDec)
|
||||
}
|
||||
if got := LoToRa(test.jde, test.lo, test.bo); got != legacyRA {
|
||||
t.Fatalf("LoToRa(%v, %v, %v) = %.15g, want %.15g", test.jde, test.lo, test.bo, got, legacyRA)
|
||||
}
|
||||
if got := BoToDec(test.jde, test.lo, test.bo); got != legacyDec {
|
||||
t.Fatalf("BoToDec(%v, %v, %v) = %.15g, want %.15g", test.jde, test.lo, test.bo, got, legacyDec)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "b612.me/astro/tools"
|
||||
)
|
||||
|
||||
func TestTopocentricRaDecUsesUTJulianDateForSiderealTime(t *testing.T) {
|
||||
ut := Date2JD(time.Date(2025, 6, 5, 12, 2, 7, 700000000, time.UTC))
|
||||
ra := 189.527817246
|
||||
dec := -5.973400893
|
||||
lat := 6.79657
|
||||
lon := 121.55381
|
||||
distanceAU := HMoonAwayN(UTC2TT(ut), -1) / 149597870.7
|
||||
|
||||
gotRA, gotDec := TopocentricRaDec(ra, dec, lat, lon, ut, distanceAU, 0)
|
||||
wantRA, wantDec := independentTopocentricRaDec(ra, dec, lat, lon, ut, distanceAU, 0)
|
||||
// 逐分量比较:零距离处 acos 度规的病态下限会把 1 ulp 放大成毫角秒。
|
||||
if deltaRA, deltaDec := math.Abs(gotRA-wantRA), math.Abs(gotDec-wantDec); deltaRA > 1e-12 || deltaDec > 1e-12 {
|
||||
t.Fatalf("TopocentricRaDec differs from independent formula: dRA=%.3g dDec=%.3g deg", deltaRA, deltaDec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopocentricRaAndDecMatchCombinedResult(t *testing.T) {
|
||||
ut := Date2JD(time.Date(2025, 6, 5, 12, 2, 7, 700000000, time.UTC))
|
||||
ra := 189.527817246
|
||||
dec := -5.973400893
|
||||
lat := 6.79657
|
||||
lon := 121.55381
|
||||
distanceAU := HMoonAwayN(UTC2TT(ut), -1) / 149597870.7
|
||||
|
||||
wantRA, wantDec := TopocentricRaDec(ra, dec, lat, lon, ut, distanceAU, 0)
|
||||
if got := TopocentricRa(ra, dec, lat, lon, ut, distanceAU, 0); got != wantRA {
|
||||
t.Fatalf("TopocentricRa = %.12f, want %.12f", got, wantRA)
|
||||
}
|
||||
if got := TopocentricDec(ra, dec, lat, lon, ut, distanceAU, 0); got != wantDec {
|
||||
t.Fatalf("TopocentricDec = %.12f, want %.12f", got, wantDec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHMoonHeightUsesUTForTopocentricCorrection(t *testing.T) {
|
||||
ut := Date2JD(time.Date(2026, 4, 28, 16, 1, 30, 0, time.UTC))
|
||||
longitude := 0.0
|
||||
latitude := 51.4779
|
||||
ra, dec := HMoonApparentRaDecN(ut, longitude, latitude, 0, -1)
|
||||
hourAngle := Limit360(ApparentSiderealTime(UTC2UT1(ut))*15 + longitude - ra)
|
||||
want := ArcSin(Sin(latitude)*Sin(dec) + Cos(dec)*Cos(latitude)*Cos(hourAngle))
|
||||
got := HMoonHeightN(ut, longitude, latitude, 0, -1)
|
||||
if difference := math.Abs(got - want); difference > 1e-10 {
|
||||
t.Fatalf("HMoonHeightN differs from the UT topocentric position by %.12f degrees", difference)
|
||||
}
|
||||
}
|
||||
|
||||
func independentTopocentricRaDec(ra, dec, lat, lon, ut, distanceAU, height float64) (float64, float64) {
|
||||
const (
|
||||
equatorialRadiusKM = 6378.14
|
||||
polarRadiusKM = 6356.755
|
||||
)
|
||||
u := math.Atan(polarRadiusKM / equatorialRadiusKM * Tan(lat))
|
||||
rhoCos := math.Cos(u) + height/6378140.0*Cos(lat)
|
||||
rhoSin := polarRadiusKM/equatorialRadiusKM*math.Sin(u) + height/6378140.0*Sin(lat)
|
||||
sinParallax := Sin(0.0024427777777) / distanceAU
|
||||
hourAngle := Limit360(ApparentSiderealTime(UTC2UT1(ut))*15 + lon - ra)
|
||||
deltaRA := math.Atan2(
|
||||
-rhoCos*sinParallax*Sin(hourAngle),
|
||||
Cos(dec)-rhoCos*sinParallax*Cos(hourAngle),
|
||||
)
|
||||
topRA := ra + deltaRA*180/math.Pi
|
||||
topDec := math.Atan2(
|
||||
(Sin(dec)-rhoSin*sinParallax)*math.Cos(deltaRA),
|
||||
Cos(dec)-rhoCos*sinParallax*Cos(hourAngle),
|
||||
) * 180 / math.Pi
|
||||
return topRA, topDec
|
||||
}
|
||||
|
||||
func angularDistanceArcsec(ra1, dec1, ra2, dec2 float64) float64 {
|
||||
cosDistance := Sin(dec1)*Sin(dec2) + Cos(dec1)*Cos(dec2)*Cos(ra1-ra2)
|
||||
return math.Acos(math.Max(-1, math.Min(1, cosDistance))) * 180 / math.Pi * 3600
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件核实太阳中天(floor 正午锚点)与月亮中天(floor+0.5 午夜锚点)两套锚点:
|
||||
// 调用方按各自约定补偿后,两者都必须落在同一个本地民用日,并接近当日本地日的真实中天。
|
||||
//
|
||||
// 框架约定(由各自调用方固定下来):
|
||||
// - SunHeight/HMoonHeight 的 jd 是本地民用时框架,本地民用日从 Date2JD(本地 0 时) 起算;
|
||||
// - basic.CulminationTime 的返回值是本地民用时框架,sun/sun.go 减 tz/24 得到 UT;
|
||||
// - basic.MoonCulminationTime 的返回值就是本地民用时框架,moon/moon.go 按 byZone=true 使用。
|
||||
|
||||
type culminationAnchorSite struct {
|
||||
name string
|
||||
lon float64
|
||||
lat float64
|
||||
tz float64
|
||||
}
|
||||
|
||||
var culminationAnchorSites = []culminationAnchorSite{
|
||||
{"beijing", 116.4074, 39.9042, 8},
|
||||
{"utc", 0, 0, 0},
|
||||
{"newyork", -74, 40.7, -5},
|
||||
{"adelaide", 138.6, -34.9, 9.5},
|
||||
{"chatham", -176.5, -43.9, 12.75},
|
||||
{"kiritimati", -157.4, 1.9, 14},
|
||||
}
|
||||
|
||||
func culminationAnchorLocalMidnight(site culminationAnchorSite, timestamp time.Time) (time.Time, float64) {
|
||||
location := time.FixedZone("anchor", int(site.tz*3600))
|
||||
local := time.Date(timestamp.Year(), timestamp.Month(), timestamp.Day(), 0, 0, 0, 0, location)
|
||||
return local, Date2JD(local)
|
||||
}
|
||||
|
||||
func TestSunCulminationAnchorKeepsLocalDay(t *testing.T) {
|
||||
timestamp := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for _, site := range culminationAnchorSites {
|
||||
local, midnightJD := culminationAnchorLocalMidnight(site, timestamp)
|
||||
location := local.Location()
|
||||
// sun/sun.go 的补偿:本地 0 时 JD 再加半天,使 floor 落在同一本地日;随后减 tz/24 得 UT。
|
||||
got := JD2DateByZone(CulminationTime(midnightJD+0.5, site.lon, site.tz)-site.tz/24, location, false)
|
||||
truthJD, truthAltitude := 0.0, -999.0
|
||||
for minute := 0; minute <= 24*60; minute++ {
|
||||
jd := midnightJD + float64(minute)/1440.0
|
||||
if altitude := SunHeight(jd, site.lon, site.lat, site.tz); altitude > truthAltitude {
|
||||
truthAltitude, truthJD = altitude, jd
|
||||
}
|
||||
}
|
||||
truth := JD2DateByZone(truthJD-site.tz/24, location, false)
|
||||
if got.Day() != local.Day() || got.Month() != local.Month() {
|
||||
t.Fatalf("%s: sun culmination = %s, want local day %s", site.name, got.Format("2006-01-02 15:04"), local.Format("2006-01-02"))
|
||||
}
|
||||
if deviation := math.Abs(got.Sub(truth).Minutes()); deviation > 1.5 {
|
||||
t.Fatalf("%s: sun culmination deviates %.2f min from the daily altitude maximum", site.name, deviation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoonCulminationAnchorKeepsLocalDay(t *testing.T) {
|
||||
timestamp := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for _, site := range culminationAnchorSites {
|
||||
local, midnightJD := culminationAnchorLocalMidnight(site, timestamp)
|
||||
location := local.Location()
|
||||
got := JD2DateByZone(MoonCulminationTime(midnightJD, site.lon, site.lat, site.tz), location, true)
|
||||
truthJD, truthAltitude := 0.0, -999.0
|
||||
for minute := 0; minute <= 24*60; minute++ {
|
||||
jd := midnightJD + float64(minute)/1440.0
|
||||
if altitude := HMoonHeight(jd, site.lon, site.lat, site.tz); altitude > truthAltitude {
|
||||
truthAltitude, truthJD = altitude, jd
|
||||
}
|
||||
}
|
||||
truth := JD2DateByZone(truthJD, location, true)
|
||||
if got.Day() != local.Day() || got.Month() != local.Month() {
|
||||
t.Fatalf("%s: moon culmination = %s, want local day %s", site.name, got.Format("2006-01-02 15:04"), local.Format("2006-01-02"))
|
||||
}
|
||||
if deviation := math.Abs(got.Sub(truth).Minutes()); deviation > 6.0 {
|
||||
t.Fatalf("%s: moon culmination deviates %.2f min from the daily altitude maximum", site.name, deviation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSunCulminationAnchorDiffersFromMidnightAnchor(t *testing.T) {
|
||||
site := culminationAnchorSites[0]
|
||||
_, midnightJD := culminationAnchorLocalMidnight(site, time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
// 不补半天时 floor 落在相邻的 UT 日:这正是调用方必须补偿 +0.5 的原因。
|
||||
uncompensated := CulminationTime(midnightJD, site.lon, site.tz) - site.tz/24
|
||||
compensated := CulminationTime(midnightJD+0.5, site.lon, site.tz) - site.tz/24
|
||||
if math.Abs(uncompensated-compensated) < 0.4 {
|
||||
t.Fatalf("expected the uncompensated anchor to land on an adjacent day: %.6f vs %.6f", uncompensated, compensated)
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package basic
|
||||
|
||||
import "math"
|
||||
|
||||
var defDeltaTFn = DefaultDeltaTv2
|
||||
|
||||
func DeltaT(date float64, isJDE bool) float64 {
|
||||
return defDeltaTFn(date, isJDE)
|
||||
}
|
||||
|
||||
func SetDeltaTFn(fn func(float64, bool) float64) {
|
||||
if fn != nil {
|
||||
defDeltaTFn = fn
|
||||
}
|
||||
}
|
||||
|
||||
func GetDeltaTFn() func(float64, bool) float64 {
|
||||
return defDeltaTFn
|
||||
}
|
||||
|
||||
func DefaultDeltaTv2(date float64, isJd bool) float64 { //传入年或儒略日,传出为秒
|
||||
if !isJd {
|
||||
date = JDECalc(int(date), int((date-math.Floor(date))*12)+1, (date-math.Floor(date))*365.25+1)
|
||||
}
|
||||
return DeltaTv2(date)
|
||||
}
|
||||
|
||||
// 使用Stephenson等人(2016)和Morrison等人(2021)的拟合和外推公式计算Delta T
|
||||
// http://astro.ukho.gov.uk/nao/lvm/
|
||||
// 2010年后的系数已修改以包含2019年后的数据
|
||||
// 返回Delta T,单位为秒
|
||||
func DeltaTSplineY(y float64) float64 {
|
||||
// 积分lod(平均太阳日偏离86400秒的偏差)方程:
|
||||
// 来自 http://astro.ukho.gov.uk/nao/lvm/:
|
||||
// lod = 1.72 t − 3.5 sin(2*pi*(t+0.75)/14) 单位ms/day,其中 t = (y - 1825)/100
|
||||
// 是从1825年开始的世纪数
|
||||
// 使用 1ms = 1e-3s 和 1儒略年 = 365.25天,
|
||||
// lod = 6.2823e-3 * Delta y - 1.278375*sin(2*pi/14*(Delta y /100 + 0.75) 单位s/year
|
||||
// 其中 Delta y = y - 1825。积分该方程得到
|
||||
// Integrate[lod, y] = 3.14115e-3*(Delta y)^2 + 894.8625/pi*cos(2*pi/14*(Delta y /100 + 0.75)
|
||||
// 单位为秒。积分常数设为0。
|
||||
integratedLod := func(x float64) float64 {
|
||||
u := x - 1825
|
||||
return 3.14115e-3*u*u + 284.8435805251424*math.Cos(0.4487989505128276*(0.01*u+0.75))
|
||||
}
|
||||
|
||||
if y < -720 {
|
||||
// 使用积分lod + 常数
|
||||
const c = 1.007739546148514
|
||||
return integratedLod(y) + c
|
||||
}
|
||||
if y > 2025 {
|
||||
// 使用积分lod + 常数
|
||||
const c = -150.56787057979514
|
||||
return integratedLod(y) + c
|
||||
}
|
||||
|
||||
// 使用三次样条拟合
|
||||
y0 := []float64{-720, -100, 400, 1000, 1150, 1300, 1500, 1600, 1650, 1720, 1800, 1810, 1820, 1830, 1840, 1850, 1855, 1860, 1865, 1870, 1875, 1880, 1885, 1890, 1895, 1900, 1905, 1910, 1915, 1920, 1925, 1930, 1935, 1940, 1945, 1950, 1953, 1956, 1959, 1962, 1965, 1968, 1971, 1974, 1977, 1980, 1983, 1986, 1989, 1992, 1995, 1998, 2001, 2004, 2007, 2010, 2013, 2016, 2019, 2022}
|
||||
y1 := []float64{-100, 400, 1000, 1150, 1300, 1500, 1600, 1650, 1720, 1800, 1810, 1820, 1830, 1840, 1850, 1855, 1860, 1865, 1870, 1875, 1880, 1885, 1890, 1895, 1900, 1905, 1910, 1915, 1920, 1925, 1930, 1935, 1940, 1945, 1950, 1953, 1956, 1959, 1962, 1965, 1968, 1971, 1974, 1977, 1980, 1983, 1986, 1989, 1992, 1995, 1998, 2001, 2004, 2007, 2010, 2013, 2016, 2019, 2022, 2025}
|
||||
a0 := []float64{20371.848, 11557.668, 6535.116, 1650.393, 1056.647, 681.149, 292.343, 109.127, 43.952, 12.068, 18.367, 15.678, 16.516, 10.804, 7.634, 9.338, 10.357, 9.04, 8.255, 2.371, -1.126, -3.21, -4.388, -3.884, -5.017, -1.977, 4.923, 11.142, 17.479, 21.617, 23.789, 24.418, 24.164, 24.426, 27.05, 28.932, 30.002, 30.76, 32.652, 33.621, 35.093, 37.956, 40.951, 44.244, 47.291, 50.361, 52.936, 54.984, 56.373, 58.453, 60.678, 62.898, 64.083, 64.553, 65.197, 66.061, 66.919, 68.130, 69.250, 69.296}
|
||||
a1 := []float64{-9999.586, -5822.27, -5671.519, -753.21, -459.628, -421.345, -192.841, -78.697, -68.089, 2.507, -3.481, 0.021, -2.157, -6.018, -0.416, 1.642, -0.486, -0.591, -3.456, -5.593, -2.314, -1.893, 0.101, -0.531, 0.134, 5.715, 6.828, 6.33, 5.518, 3.02, 1.333, 0.052, -0.419, 1.645, 2.499, 1.127, 0.737, 1.409, 1.577, 0.868, 2.275, 3.035, 3.157, 3.199, 3.069, 2.878, 2.354, 1.577, 1.648, 2.235, 2.324, 1.804, 0.674, 0.466, 0.804, 0.839, 1.005, 1.348, 0.594, -0.227}
|
||||
a2 := []float64{776.247, 1303.151, -298.291, 184.811, 108.771, 61.953, -6.572, 10.505, 38.333, 41.731, -1.126, 4.629, -6.806, 2.944, 2.658, 0.261, -2.389, 2.284, -5.148, 3.011, 0.269, 0.152, 1.842, -2.474, 3.138, 2.443, -1.329, 0.831, -1.643, -0.856, -0.831, -0.449, -0.022, 2.086, -1.232, 0.22, -0.61, 1.282, -1.115, 0.406, 1.002, -0.242, 0.364, -0.323, 0.193, -0.384, -0.14, -0.637, 0.708, -0.121, 0.21, -0.729, -0.402, 0.194, 0.144, -0.109, 0.275, 0.068, -0.822, 0.001}
|
||||
a3 := []float64{409.16, -503.433, 1085.087, -25.346, -24.641, -29.414, 16.197, 3.018, -2.127, -37.939, 1.918, -3.812, 3.25, -0.096, -0.539, -0.883, 1.558, -2.477, 2.72, -0.914, -0.039, 0.563, -1.438, 1.871, -0.232, -1.257, 0.72, -0.825, 0.262, 0.008, 0.127, 0.142, 0.702, -1.106, 0.614, -0.277, 0.631, -0.799, 0.507, 0.199, -0.414, 0.202, -0.229, 0.172, -0.192, 0.081, -0.165, 0.448, -0.276, 0.11, -0.313, 0.109, 0.199, -0.017, -0.084, 0.128, -0.069, -0.297, 0.274, 0.086}
|
||||
|
||||
n := len(y0)
|
||||
var i int
|
||||
for i = n - 1; i >= 0; i-- {
|
||||
if y >= y0[i] {
|
||||
break
|
||||
}
|
||||
}
|
||||
t := (y - y0[i]) / (y1[i] - y0[i])
|
||||
dT := a0[i] + t*(a1[i]+t*(a2[i]+t*a3[i]))
|
||||
return dT
|
||||
}
|
||||
|
||||
func DeltaTv2(jd float64) float64 {
|
||||
if jd > 2461041.5 || jd < 2441317.5 {
|
||||
var y float64
|
||||
if jd >= 2299160.5 {
|
||||
y = (jd-2451544.5)/365.2425 + 2000
|
||||
} else {
|
||||
y = (jd+0.5)/365.25 - 4712
|
||||
}
|
||||
return DeltaTSplineY(y)
|
||||
}
|
||||
|
||||
// 闰秒JD值
|
||||
jdLeaps := []float64{2457754.5, 2457204.5, 2456109.5, 2454832.5,
|
||||
2453736.5, 2451179.5, 2450630.5, 2450083.5,
|
||||
2449534.5, 2449169.5, 2448804.5, 2448257.5,
|
||||
2447892.5, 2447161.5, 2446247.5, 2445516.5,
|
||||
2445151.5, 2444786.5, 2444239.5, 2443874.5,
|
||||
2443509.5, 2443144.5, 2442778.5, 2442413.5,
|
||||
2442048.5, 2441683.5, 2441499.5, 2441133.5}
|
||||
n := len(jdLeaps)
|
||||
deltaTSeconds := 42.184
|
||||
for i := 0; i < n; i++ {
|
||||
if jd > jdLeaps[i] {
|
||||
deltaTSeconds += float64(n - i - 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
return deltaTSeconds
|
||||
}
|
||||
|
||||
func TD2UT(jde float64, utToTD bool) float64 { // true 世界时转力学时CC,false 力学时转世界时VV
|
||||
deltaTSeconds := DeltaT(jde, true)
|
||||
if utToTD {
|
||||
return jde + deltaTSeconds/3600/24
|
||||
} else {
|
||||
return jde - deltaTSeconds/3600/24
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package basic
|
||||
|
||||
import "math"
|
||||
|
||||
// DeltaTModel 标识一个可选的已发布 ΔT 模型 / a selectable published ΔT model.
|
||||
type DeltaTModel string
|
||||
|
||||
const (
|
||||
// DeltaTModelDefault 内置默认模型:Stephenson–Morrison–Hohenkerk 2016 + Morrison 2021 的样条与积分 lod。
|
||||
// DeltaTModelDefault is the built-in model: the SMH2016 + Morrison 2021 spline and integrated lod.
|
||||
DeltaTModelDefault DeltaTModel = ""
|
||||
// DeltaTModelSMH2016 显式选择内置的 SMH2016 + Morrison 2021 模型。
|
||||
// DeltaTModelSMH2016 selects the built-in SMH2016 + Morrison 2021 model explicitly.
|
||||
DeltaTModelSMH2016 DeltaTModel = "smh2016"
|
||||
// DeltaTModelMS2004 Morrison & Stephenson 2004 的长期抛物线 ΔT = −20 + 32u²(u = (y−1820)/100),单位秒。
|
||||
// DeltaTModelMS2004 is the Morrison & Stephenson 2004 long-term parabola in seconds.
|
||||
DeltaTModelMS2004 DeltaTModel = "ms2004"
|
||||
// DeltaTModelEspenakMeeus2006 Espenak & Meeus 2006 的分段多项式,基于 M&S2004,未加配对修正 c。
|
||||
// DeltaTModelEspenakMeeus2006 is the Espenak & Meeus 2006 piecewise polynomial without the pairing term c.
|
||||
DeltaTModelEspenakMeeus2006 DeltaTModel = "espenak_meeus_2006"
|
||||
// DeltaTModelNASACanon2006 是 Espenak–Meeus 再加配对修正 c = −0.000012932(y−1955)²(y < 1955),
|
||||
// 即 NASA 五千年目录实际印刷的 ΔT 口径,配对的是 ELP2000/82 的 −25.858″/cy²。
|
||||
// DeltaTModelNASACanon2006 adds the pairing term c for y < 1955, the convention printed by NASA's canon.
|
||||
DeltaTModelNASACanon2006 DeltaTModel = "nasa_canon_2006"
|
||||
// DeltaTModelManual 表示当前生效的是 SetDeltaTFn 注入的任意函数,不是命名模型。
|
||||
// DeltaTModelManual reports that an arbitrary function injected with SetDeltaTFn is active.
|
||||
DeltaTModelManual DeltaTModel = "manual"
|
||||
)
|
||||
|
||||
// deltaTModelFunction 把命名模型包成 ΔT 钩子的签名;isJulianDay 为假时入参是十进制年。
|
||||
func deltaTModelFunction(model DeltaTModel, keepObserved bool) func(float64, bool) float64 {
|
||||
return func(date float64, isJulianDay bool) float64 {
|
||||
if math.IsNaN(date) || math.IsInf(date, 0) {
|
||||
return math.NaN()
|
||||
}
|
||||
if isJulianDay {
|
||||
return DeltaTModelSeconds(model, date, keepObserved)
|
||||
}
|
||||
if keepObserved {
|
||||
year := math.Floor(date)
|
||||
start := JDCalc(int(year), 1, 1)
|
||||
end := JDCalc(int(year)+1, 1, 1)
|
||||
jd := start + (date-year)*(end-start)
|
||||
if seconds, ok := deltaTMonthlyAt(jd); ok {
|
||||
return seconds
|
||||
}
|
||||
}
|
||||
return deltaTModelSecondsAtYear(model, date)
|
||||
}
|
||||
}
|
||||
|
||||
// DeltaTModelSeconds 按命名模型求 ΔT(秒),入参为 UT 儒略日。
|
||||
//
|
||||
// keepObserved 为真时,落在逐月实测表覆盖段内的时刻一律返回实测值,模型只接管表外;
|
||||
// 这是"实测优先"的推荐用法,也是内置默认模型的结构。为假时全程使用模型,仅用于口径对照。
|
||||
// DeltaTModelSeconds evaluates a named model. With keepObserved the monthly observed table wins
|
||||
// wherever it covers the instant and the model only takes over outside it.
|
||||
func DeltaTModelSeconds(model DeltaTModel, jd float64, keepObserved bool) float64 {
|
||||
if math.IsNaN(jd) || math.IsInf(jd, 0) {
|
||||
return math.NaN()
|
||||
}
|
||||
switch model {
|
||||
case DeltaTModelDefault, DeltaTModelSMH2016:
|
||||
if keepObserved {
|
||||
// 与内置默认逐位一致(含实测段与样条表尾的常值锚定)。
|
||||
return deltaTModelSecondsAtUT(jd)
|
||||
}
|
||||
return deltaTSplineAtJDE(jd)
|
||||
}
|
||||
if keepObserved {
|
||||
if seconds, ok := deltaTMonthlyAt(jd); ok {
|
||||
return seconds
|
||||
}
|
||||
}
|
||||
return deltaTModelSecondsAtYear(model, deltaTYearAtJDE(jd))
|
||||
}
|
||||
|
||||
// SetDeltaTModel 安装命名模型;keepObserved 为真时保留实测表覆盖段(推荐),未知模型返回 false 且不改动现状。
|
||||
// SetDeltaTModel installs a named model, keeping the observed span when keepObserved is true;
|
||||
// an unknown model returns false and leaves the current model untouched.
|
||||
func SetDeltaTModel(model DeltaTModel, keepObserved bool) bool {
|
||||
if !knownDeltaTModel(model) {
|
||||
return false
|
||||
}
|
||||
if model == DeltaTModelDefault || model == DeltaTModelSMH2016 {
|
||||
if keepObserved {
|
||||
// 内置模型本身就是"实测优先",直接用它可以避免表尾锚定出现差异。
|
||||
installDeltaTFn(DefaultDeltaTv2, model, true)
|
||||
return true
|
||||
}
|
||||
installDeltaTFn(deltaTModelFunction(model, false), model, false)
|
||||
return true
|
||||
}
|
||||
installDeltaTFn(deltaTModelFunction(model, keepObserved), model, keepObserved)
|
||||
return true
|
||||
}
|
||||
|
||||
// GetDeltaTModel 返回当前生效的模型与是否保留实测段;SetDeltaTFn 注入的任意函数报 DeltaTModelManual。
|
||||
// GetDeltaTModel returns the active model and whether the observed span is kept; an arbitrary
|
||||
// function injected with SetDeltaTFn reports DeltaTModelManual.
|
||||
func GetDeltaTModel() (DeltaTModel, bool) {
|
||||
deltaTFnMu.RLock()
|
||||
defer deltaTFnMu.RUnlock()
|
||||
return activeDeltaTModel, activeDeltaTKeepObserved
|
||||
}
|
||||
|
||||
func knownDeltaTModel(model DeltaTModel) bool {
|
||||
switch model {
|
||||
case DeltaTModelDefault, DeltaTModelSMH2016, DeltaTModelMS2004,
|
||||
DeltaTModelEspenakMeeus2006, DeltaTModelNASACanon2006:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// deltaTModelSecondsAtYear 按十进制年求模型值;实测段是否介入由调用方决定。
|
||||
func deltaTModelSecondsAtYear(model DeltaTModel, year float64) float64 {
|
||||
switch model {
|
||||
case DeltaTModelMS2004:
|
||||
u := (year - 1820) / 100
|
||||
return -20 + 32*u*u
|
||||
case DeltaTModelEspenakMeeus2006:
|
||||
return espenakMeeus2006Seconds(year)
|
||||
case DeltaTModelNASACanon2006:
|
||||
value := espenakMeeus2006Seconds(year)
|
||||
if year < 1955 {
|
||||
// 配对修正:把 −26.0″/cy² 口径的 ΔT 换到 −25.858″/cy²(1955 起原子时使其与月球历表无关)。
|
||||
d := year - 1955
|
||||
value += -0.000012932 * d * d
|
||||
}
|
||||
return value
|
||||
}
|
||||
return DeltaTSplineY(year)
|
||||
}
|
||||
|
||||
// espenakMeeus2006Seconds 是 Espenak & Meeus 2006 对 −1999..+3000 的分段多项式(秒),
|
||||
// 自变量 y = year + (month−0.5)/12;分段边界处两段取值一致(−500 处由 17203.7 强制连续)。
|
||||
func espenakMeeus2006Seconds(y float64) float64 {
|
||||
switch {
|
||||
case y < -500:
|
||||
u := (y - 1820) / 100
|
||||
return -20 + 32*u*u
|
||||
case y < 500:
|
||||
u := y / 100
|
||||
return 10583.6 + u*(-1014.41+u*(33.78311+u*(-5.952053+u*(-0.1798452+u*(0.022174192+u*0.0090316521)))))
|
||||
case y < 1600:
|
||||
u := (y - 1000) / 100
|
||||
return 1574.2 + u*(-556.01+u*(71.23472+u*(0.319781+u*(-0.8503463+u*(-0.005050998+u*0.0083572073)))))
|
||||
case y < 1700:
|
||||
t := y - 1600
|
||||
return 120 - 0.9808*t - 0.01532*t*t + t*t*t/7129
|
||||
case y < 1800:
|
||||
t := y - 1700
|
||||
return 8.83 + t*(0.1603+t*(-0.0059285+t*(0.00013336-t/1174000)))
|
||||
case y < 1860:
|
||||
t := y - 1800
|
||||
return 13.72 + t*(-0.332447+t*(0.0068612+t*(0.0041116+t*(-0.00037436+
|
||||
t*(0.0000121272+t*(-0.0000001699+t*0.000000000875))))))
|
||||
case y < 1900:
|
||||
t := y - 1860
|
||||
return 7.62 + t*(0.5737+t*(-0.251754+t*(0.01680668+t*(-0.0004473624+t/233174))))
|
||||
case y < 1920:
|
||||
t := y - 1900
|
||||
return -2.79 + t*(1.494119+t*(-0.0598939+t*(0.0061966-t*0.000197)))
|
||||
case y < 1941:
|
||||
t := y - 1920
|
||||
return 21.20 + t*(0.84493+t*(-0.076100+t*0.0020936))
|
||||
case y < 1961:
|
||||
t := y - 1950
|
||||
return 29.07 + t*(0.407-t/233+t*t/2547)
|
||||
case y < 1986:
|
||||
t := y - 1975
|
||||
return 45.45 + t*(1.067-t/260-t*t/718)
|
||||
case y < 2005:
|
||||
t := y - 2000
|
||||
return 63.86 + t*(0.3345+t*(-0.060374+t*(0.0017275+t*(0.000651814+t*0.00002373599))))
|
||||
case y < 2050:
|
||||
t := y - 2000
|
||||
return 62.92 + t*(0.32217+t*0.005589)
|
||||
case y < 2150:
|
||||
u := (y - 1820) / 100
|
||||
return -20 + 32*u*u - 0.5628*(2150-y)
|
||||
}
|
||||
u := (y - 1820) / 100
|
||||
return -20 + 32*u*u
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ΔT 命名模型的契约:已发表锚点、分段连续性、实测段优先的混合语义与模型标记。
|
||||
|
||||
func TestDeltaTModelPublishedAnchors(t *testing.T) {
|
||||
resetTimeScaleState(t)
|
||||
check := func(name string, year, want, tolerance float64) {
|
||||
t.Helper()
|
||||
if got := DeltaT(year, false); math.Abs(got-want) > tolerance {
|
||||
t.Fatalf("%s ΔT(%.5f)=%.6f, want %.6f±%g", name, year, got, want, tolerance)
|
||||
}
|
||||
}
|
||||
|
||||
if !SetDeltaTModel(DeltaTModelEspenakMeeus2006, false) {
|
||||
t.Fatal("Espenak–Meeus 模型应可安装")
|
||||
}
|
||||
check("E-M", 2017.64, 70.34, 0.01)
|
||||
check("E-M", 2024.27, 74.03, 0.01)
|
||||
check("E-M", 2100, 202.7, 0.05)
|
||||
|
||||
if !SetDeltaTModel(DeltaTModelMS2004, false) {
|
||||
t.Fatal("M&S2004 模型应可安装")
|
||||
}
|
||||
check("M&S2004", -500, 17203.7, 0.05)
|
||||
check("M&S2004", 2100, 230.9, 0.05)
|
||||
|
||||
if !SetDeltaTModel(DeltaTModelEspenakMeeus2006, false) {
|
||||
t.Fatal("Espenak–Meeus 模型应可安装")
|
||||
}
|
||||
// 分段边界:原表各段是独立拟合的,边界处本身留有台阶(实测最大 ≤0.3 s,1600 处约 0.25 s),
|
||||
// 这里只守住这个量级,用来抓 Horner 展开之类的结构性错误。
|
||||
for _, knot := range []float64{-500, 500, 1600, 1700, 1800, 1860, 1900, 1920, 1941, 1961, 1986, 2005, 2050, 2150} {
|
||||
before, after := DeltaT(knot-1e-6, false), DeltaT(knot+1e-6, false)
|
||||
if diff := math.Abs(after - before); diff > 0.3 {
|
||||
t.Errorf("分段边界 %.0f 台阶过大:%.6f vs %.6f(差 %g)", knot, before, after, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeltaTModelKeepsObservedSpan(t *testing.T) {
|
||||
resetTimeScaleState(t)
|
||||
jd := JDCalc(2026, 3, 1)
|
||||
observed := DeltaT(jd, true)
|
||||
|
||||
if !SetDeltaTModel(DeltaTModelEspenakMeeus2006, true) {
|
||||
t.Fatal("Espenak–Meeus 模型应可安装")
|
||||
}
|
||||
if got := DeltaT(jd, true); math.Abs(got-observed) > 1e-12 {
|
||||
t.Fatalf("实测段应优先:got %.9f, want %.9f", got, observed)
|
||||
}
|
||||
pure := DeltaTModelSeconds(DeltaTModelEspenakMeeus2006, jd, false)
|
||||
if math.Abs(pure-observed) < 5 {
|
||||
t.Fatalf("纯模型值 %.3f 应与实测 %.3f 明显不同(否则测不到混合语义)", pure, observed)
|
||||
}
|
||||
if model, keep := GetDeltaTModel(); model != DeltaTModelEspenakMeeus2006 || !keep {
|
||||
t.Fatalf("模型标记 = (%q, %v)", model, keep)
|
||||
}
|
||||
future := JDCalc(2100, 1, 1)
|
||||
if got, want := DeltaT(future, true), DeltaTModelSeconds(DeltaTModelEspenakMeeus2006, future, false); math.Abs(got-want) > 1e-9 {
|
||||
t.Fatalf("表外应走模型:got %.9f, want %.9f", got, want)
|
||||
}
|
||||
|
||||
// SMH2016 混合档必须与内置默认逐位一致。
|
||||
if !SetDeltaTModel(DeltaTModelSMH2016, true) {
|
||||
t.Fatal("SMH2016 模型应可安装")
|
||||
}
|
||||
if got := DeltaT(jd, true); got != observed {
|
||||
t.Fatalf("SMH2016 混合档 %.9f 与内置默认 %.9f 不一致", got, observed)
|
||||
}
|
||||
if got := DeltaT(future, true); got != DefaultDeltaTv2(future, true) {
|
||||
t.Fatalf("SMH2016 混合档表外 %.9f 与内置默认 %.9f 不一致", got, DefaultDeltaTv2(future, true))
|
||||
}
|
||||
|
||||
// 手工注入的任意函数标记为 manual;恢复 nil 回到内置默认。
|
||||
SetDeltaTFn(func(float64, bool) float64 { return 0 })
|
||||
if model, keep := GetDeltaTModel(); model != DeltaTModelManual || keep {
|
||||
t.Fatalf("手工注入标记 = (%q, %v)", model, keep)
|
||||
}
|
||||
SetDeltaTFn(nil)
|
||||
if model, keep := GetDeltaTModel(); model != DeltaTModelDefault || !keep {
|
||||
t.Fatalf("恢复默认标记 = (%q, %v)", model, keep)
|
||||
}
|
||||
if SetDeltaTModel(DeltaTModel("nope"), true) {
|
||||
t.Fatal("未知模型应被拒绝")
|
||||
}
|
||||
if model, _ := GetDeltaTModel(); model != DeltaTModelDefault {
|
||||
t.Fatalf("被拒绝后模型不应改动,got %q", model)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultDeltaTFractionalYear(t *testing.T) {
|
||||
for _, year := range []float64{-700.5, -0.5, 0, 1000.5, 1582.75, 1800.5, 2000.5, 2026.5} {
|
||||
whole := math.Floor(year)
|
||||
start := JDCalc(int(whole), 1, 1)
|
||||
end := JDCalc(int(whole)+1, 1, 1)
|
||||
want := DefaultDeltaTv2(start+(year-whole)*(end-start), true)
|
||||
got := DefaultDeltaTv2(year, false)
|
||||
if math.IsNaN(got) || math.Abs(got-want) > 1e-10 {
|
||||
t.Errorf("year=%v DeltaT=%v, want %v", year, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultDeltaTInvalidInput(t *testing.T) {
|
||||
for _, value := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} {
|
||||
for _, isJD := range []bool{false, true} {
|
||||
if got := DefaultDeltaTv2(value, isJD); !math.IsNaN(got) {
|
||||
t.Errorf("DeltaT(%v, %v)=%v, want NaN", value, isJD, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 闰秒只改 TT−UTC,不改 TT−UT1:ΔT 在闰秒两侧连续,TT−UTC 才跳 1 秒。
|
||||
func TestDeltaTStaysContinuousAcrossLeapSecond(t *testing.T) {
|
||||
boundary := 2457754.5
|
||||
before := DeltaTv2(boundary - 1e-6)
|
||||
after := DeltaTv2(boundary + 1e-6)
|
||||
if math.Abs(after-before) > 1e-3 {
|
||||
t.Fatalf("ΔT should stay continuous at a leap second: %v → %v", before, after)
|
||||
}
|
||||
if step := TTMinusUTCSeconds(boundary+1e-6) - TTMinusUTCSeconds(boundary-1e-6); math.Abs(step-1) > 1e-9 {
|
||||
t.Fatalf("TT−UTC should step by one second: %v", step)
|
||||
}
|
||||
if got := DeltaTSplineY(math.NaN()); !math.IsNaN(got) {
|
||||
t.Fatalf("DeltaTSplineY(NaN)=%v, want NaN", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUTC2TTRoundTrip(t *testing.T) {
|
||||
for _, year := range []int{-2000, -720, 0, 26, 1426, 2025, 2027, 3627, 4026, 5000} {
|
||||
utc := JDCalc(year, 9, 20.123456)
|
||||
tt := UTC2TT(utc)
|
||||
if got := TT2UTC(tt); math.Abs(got-utc) > math.Nextafter(utc, math.Inf(1))-utc {
|
||||
t.Errorf("year=%d UTC round trip differs by %.9f seconds", year, (got-utc)*86400)
|
||||
}
|
||||
if got := UTC2TT(TT2UTC(tt)); got != tt {
|
||||
t.Errorf("year=%d TT round trip differs by %.9f seconds", year, (got-tt)*86400)
|
||||
}
|
||||
}
|
||||
for _, seconds := range []float64{-70, -1, -0.1, 0, 0.1, 1, 70} {
|
||||
utc := 2457754.5 + seconds/86400
|
||||
if got := TT2UTC(UTC2TT(utc)); got != utc {
|
||||
t.Errorf("leap second offset=%g round trip differs by %.9f seconds", seconds, (got-utc)*86400)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUTC2TTRoundTripUnderCustomDeltaT(t *testing.T) {
|
||||
original := GetDeltaTFn()
|
||||
t.Cleanup(func() { SetDeltaTFn(original) })
|
||||
for _, slope := range []float64{0, 0.01} {
|
||||
SetDeltaTFn(func(jd float64, isJD bool) float64 {
|
||||
if !isJD {
|
||||
t.Fatal("conversion must request Delta T at a Julian day")
|
||||
}
|
||||
return 10000 + slope*(jd-2451545)
|
||||
})
|
||||
utc := 3000000.123456
|
||||
if got := TT2UTC(UTC2TT(utc)); got != utc {
|
||||
t.Errorf("custom slope=%g round trip differs by %.9f seconds", slope, (got-utc)*86400)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeltaTSecondsAt:有限值(含 0)是显式覆盖,NaN/±Inf 才回退进程级模型;模型按 UT 求值。
|
||||
func TestDeltaTSecondsAtOverrideContract(t *testing.T) {
|
||||
jd := 2460310.5
|
||||
if got := DeltaTSecondsAt(jd, 0); got != 0 {
|
||||
t.Fatalf("explicit zero override = %v, want 0", got)
|
||||
}
|
||||
if got := DeltaTSecondsAt(jd, 12.5); got != 12.5 {
|
||||
t.Fatalf("explicit override = %v, want 12.5", got)
|
||||
}
|
||||
model := DeltaTSecondsAt(jd, math.NaN())
|
||||
if !finite(model) || model <= 0 {
|
||||
t.Fatalf("model fallback = %v, want a positive finite value", model)
|
||||
}
|
||||
if got := DeltaTSecondsAt(jd, math.Inf(1)); got != model {
|
||||
t.Fatalf("+Inf override = %v, want the model %v", got, model)
|
||||
}
|
||||
if got := deltaTModelSecondsAtTT(jd); got != model {
|
||||
t.Fatalf("deltaTModelSecondsAtTT = %v, want %v", got, model)
|
||||
}
|
||||
// 模型按 UT 键控:与"把 TT 直接当 UT"的朴素求值有微小差别。
|
||||
naive := DeltaT(jd, true)
|
||||
if diff := math.Abs(naive - model); diff > 1e-3 {
|
||||
t.Fatalf("UT-keyed model differs from naive TT evaluation by %v s", diff)
|
||||
}
|
||||
}
|
||||
+72
-72
@@ -26,181 +26,181 @@ func angularSemidiameterFromAU(radiusKM, distanceAU float64) float64 {
|
||||
}
|
||||
|
||||
// SunSemidiameter 太阳视半径,单位角秒 / apparent solar semidiameter in arcseconds.
|
||||
func SunSemidiameter(jd float64) float64 {
|
||||
return SunSemidiameterN(jd, -1)
|
||||
func SunSemidiameter(jde float64) float64 {
|
||||
return SunSemidiameterN(jde, -1)
|
||||
}
|
||||
|
||||
// SunSemidiameterN 太阳视半径(截断版),单位角秒 / truncated apparent solar semidiameter in arcseconds.
|
||||
func SunSemidiameterN(jd float64, n int) float64 {
|
||||
return angularSemidiameterFromAU(sunEquatorialRadiusKM, EarthAwayN(jd, n))
|
||||
func SunSemidiameterN(jde float64, n int) float64 {
|
||||
return angularSemidiameterFromAU(sunEquatorialRadiusKM, EarthAwayN(jde, n))
|
||||
}
|
||||
|
||||
// SunDiameter 太阳视直径,单位角秒 / apparent solar diameter in arcseconds.
|
||||
func SunDiameter(jd float64) float64 {
|
||||
return SunDiameterN(jd, -1)
|
||||
func SunDiameter(jde float64) float64 {
|
||||
return SunDiameterN(jde, -1)
|
||||
}
|
||||
|
||||
// SunDiameterN 太阳视直径(截断版),单位角秒 / truncated apparent solar diameter in arcseconds.
|
||||
func SunDiameterN(jd float64, n int) float64 {
|
||||
return 2 * SunSemidiameterN(jd, n)
|
||||
func SunDiameterN(jde float64, n int) float64 {
|
||||
return 2 * SunSemidiameterN(jde, n)
|
||||
}
|
||||
|
||||
// MoonSemidiameter 月亮视半径,单位角秒 / apparent lunar semidiameter in arcseconds.
|
||||
func MoonSemidiameter(jd float64) float64 {
|
||||
return MoonSemidiameterN(jd, -1)
|
||||
func MoonSemidiameter(jde float64) float64 {
|
||||
return MoonSemidiameterN(jde, -1)
|
||||
}
|
||||
|
||||
// MoonSemidiameterN 月亮视半径(截断版),单位角秒 / truncated apparent lunar semidiameter in arcseconds.
|
||||
func MoonSemidiameterN(jd float64, n int) float64 {
|
||||
return angularSemidiameterArcsec(moonEquatorialRadiusKM, HMoonAwayN(jd, n))
|
||||
func MoonSemidiameterN(jde float64, n int) float64 {
|
||||
return angularSemidiameterArcsec(moonEquatorialRadiusKM, HMoonAwayN(jde, n))
|
||||
}
|
||||
|
||||
// MoonDiameter 月亮视直径,单位角秒 / apparent lunar diameter in arcseconds.
|
||||
func MoonDiameter(jd float64) float64 {
|
||||
return MoonDiameterN(jd, -1)
|
||||
func MoonDiameter(jde float64) float64 {
|
||||
return MoonDiameterN(jde, -1)
|
||||
}
|
||||
|
||||
// MoonDiameterN 月亮视直径(截断版),单位角秒 / truncated apparent lunar diameter in arcseconds.
|
||||
func MoonDiameterN(jd float64, n int) float64 {
|
||||
return 2 * MoonSemidiameterN(jd, n)
|
||||
func MoonDiameterN(jde float64, n int) float64 {
|
||||
return 2 * MoonSemidiameterN(jde, n)
|
||||
}
|
||||
|
||||
// MercurySemidiameter 水星视半径,单位角秒 / apparent Mercury semidiameter in arcseconds.
|
||||
func MercurySemidiameter(jd float64) float64 {
|
||||
return MercurySemidiameterN(jd, -1)
|
||||
func MercurySemidiameter(jde float64) float64 {
|
||||
return MercurySemidiameterN(jde, -1)
|
||||
}
|
||||
|
||||
// MercurySemidiameterN 水星视半径(截断版),单位角秒 / truncated apparent Mercury semidiameter in arcseconds.
|
||||
func MercurySemidiameterN(jd float64, n int) float64 {
|
||||
return angularSemidiameterFromAU(mercuryEquatorialRadiusKM, EarthMercuryAwayN(jd, n))
|
||||
func MercurySemidiameterN(jde float64, n int) float64 {
|
||||
return angularSemidiameterFromAU(mercuryEquatorialRadiusKM, EarthMercuryAwayN(jde, n))
|
||||
}
|
||||
|
||||
// MercuryDiameter 水星视直径,单位角秒 / apparent Mercury diameter in arcseconds.
|
||||
func MercuryDiameter(jd float64) float64 {
|
||||
return MercuryDiameterN(jd, -1)
|
||||
func MercuryDiameter(jde float64) float64 {
|
||||
return MercuryDiameterN(jde, -1)
|
||||
}
|
||||
|
||||
// MercuryDiameterN 水星视直径(截断版),单位角秒 / truncated apparent Mercury diameter in arcseconds.
|
||||
func MercuryDiameterN(jd float64, n int) float64 {
|
||||
return 2 * MercurySemidiameterN(jd, n)
|
||||
func MercuryDiameterN(jde float64, n int) float64 {
|
||||
return 2 * MercurySemidiameterN(jde, n)
|
||||
}
|
||||
|
||||
// VenusSemidiameter 金星视半径,单位角秒 / apparent Venus semidiameter in arcseconds.
|
||||
func VenusSemidiameter(jd float64) float64 {
|
||||
return VenusSemidiameterN(jd, -1)
|
||||
func VenusSemidiameter(jde float64) float64 {
|
||||
return VenusSemidiameterN(jde, -1)
|
||||
}
|
||||
|
||||
// VenusSemidiameterN 金星视半径(截断版),单位角秒 / truncated apparent Venus semidiameter in arcseconds.
|
||||
func VenusSemidiameterN(jd float64, n int) float64 {
|
||||
return angularSemidiameterFromAU(venusEquatorialRadiusKM, EarthVenusAwayN(jd, n))
|
||||
func VenusSemidiameterN(jde float64, n int) float64 {
|
||||
return angularSemidiameterFromAU(venusEquatorialRadiusKM, EarthVenusAwayN(jde, n))
|
||||
}
|
||||
|
||||
// VenusDiameter 金星视直径,单位角秒 / apparent Venus diameter in arcseconds.
|
||||
func VenusDiameter(jd float64) float64 {
|
||||
return VenusDiameterN(jd, -1)
|
||||
func VenusDiameter(jde float64) float64 {
|
||||
return VenusDiameterN(jde, -1)
|
||||
}
|
||||
|
||||
// VenusDiameterN 金星视直径(截断版),单位角秒 / truncated apparent Venus diameter in arcseconds.
|
||||
func VenusDiameterN(jd float64, n int) float64 {
|
||||
return 2 * VenusSemidiameterN(jd, n)
|
||||
func VenusDiameterN(jde float64, n int) float64 {
|
||||
return 2 * VenusSemidiameterN(jde, n)
|
||||
}
|
||||
|
||||
// MarsSemidiameter 火星视半径,单位角秒 / apparent Mars semidiameter in arcseconds.
|
||||
func MarsSemidiameter(jd float64) float64 {
|
||||
return MarsSemidiameterN(jd, -1)
|
||||
func MarsSemidiameter(jde float64) float64 {
|
||||
return MarsSemidiameterN(jde, -1)
|
||||
}
|
||||
|
||||
// MarsSemidiameterN 火星视半径(截断版),单位角秒 / truncated apparent Mars semidiameter in arcseconds.
|
||||
func MarsSemidiameterN(jd float64, n int) float64 {
|
||||
return angularSemidiameterFromAU(marsEquatorialRadiusKM, EarthMarsAwayN(jd, n))
|
||||
func MarsSemidiameterN(jde float64, n int) float64 {
|
||||
return angularSemidiameterFromAU(marsEquatorialRadiusKM, EarthMarsAwayN(jde, n))
|
||||
}
|
||||
|
||||
// MarsDiameter 火星视直径,单位角秒 / apparent Mars diameter in arcseconds.
|
||||
func MarsDiameter(jd float64) float64 {
|
||||
return MarsDiameterN(jd, -1)
|
||||
func MarsDiameter(jde float64) float64 {
|
||||
return MarsDiameterN(jde, -1)
|
||||
}
|
||||
|
||||
// MarsDiameterN 火星视直径(截断版),单位角秒 / truncated apparent Mars diameter in arcseconds.
|
||||
func MarsDiameterN(jd float64, n int) float64 {
|
||||
return 2 * MarsSemidiameterN(jd, n)
|
||||
func MarsDiameterN(jde float64, n int) float64 {
|
||||
return 2 * MarsSemidiameterN(jde, n)
|
||||
}
|
||||
|
||||
// JupiterSemidiameter 木星视半径,单位角秒 / apparent Jupiter semidiameter in arcseconds.
|
||||
func JupiterSemidiameter(jd float64) float64 {
|
||||
return JupiterSemidiameterN(jd, -1)
|
||||
func JupiterSemidiameter(jde float64) float64 {
|
||||
return JupiterSemidiameterN(jde, -1)
|
||||
}
|
||||
|
||||
// JupiterSemidiameterN 木星视半径(截断版),单位角秒 / truncated apparent Jupiter semidiameter in arcseconds.
|
||||
func JupiterSemidiameterN(jd float64, n int) float64 {
|
||||
return angularSemidiameterFromAU(jupiterEquatorialRadiusKM, EarthJupiterAwayN(jd, n))
|
||||
func JupiterSemidiameterN(jde float64, n int) float64 {
|
||||
return angularSemidiameterFromAU(jupiterEquatorialRadiusKM, EarthJupiterAwayN(jde, n))
|
||||
}
|
||||
|
||||
// JupiterDiameter 木星视直径,单位角秒 / apparent Jupiter diameter in arcseconds.
|
||||
func JupiterDiameter(jd float64) float64 {
|
||||
return JupiterDiameterN(jd, -1)
|
||||
func JupiterDiameter(jde float64) float64 {
|
||||
return JupiterDiameterN(jde, -1)
|
||||
}
|
||||
|
||||
// JupiterDiameterN 木星视直径(截断版),单位角秒 / truncated apparent Jupiter diameter in arcseconds.
|
||||
func JupiterDiameterN(jd float64, n int) float64 {
|
||||
return 2 * JupiterSemidiameterN(jd, n)
|
||||
func JupiterDiameterN(jde float64, n int) float64 {
|
||||
return 2 * JupiterSemidiameterN(jde, n)
|
||||
}
|
||||
|
||||
// SaturnSemidiameter 土星视半径,单位角秒 / apparent Saturn semidiameter in arcseconds.
|
||||
func SaturnSemidiameter(jd float64) float64 {
|
||||
return SaturnSemidiameterN(jd, -1)
|
||||
func SaturnSemidiameter(jde float64) float64 {
|
||||
return SaturnSemidiameterN(jde, -1)
|
||||
}
|
||||
|
||||
// SaturnSemidiameterN 土星视半径(截断版),单位角秒 / truncated apparent Saturn semidiameter in arcseconds.
|
||||
func SaturnSemidiameterN(jd float64, n int) float64 {
|
||||
return angularSemidiameterFromAU(saturnEquatorialRadiusKM, EarthSaturnAwayN(jd, n))
|
||||
func SaturnSemidiameterN(jde float64, n int) float64 {
|
||||
return angularSemidiameterFromAU(saturnEquatorialRadiusKM, EarthSaturnAwayN(jde, n))
|
||||
}
|
||||
|
||||
// SaturnDiameter 土星视直径,单位角秒 / apparent Saturn diameter in arcseconds.
|
||||
func SaturnDiameter(jd float64) float64 {
|
||||
return SaturnDiameterN(jd, -1)
|
||||
func SaturnDiameter(jde float64) float64 {
|
||||
return SaturnDiameterN(jde, -1)
|
||||
}
|
||||
|
||||
// SaturnDiameterN 土星视直径(截断版),单位角秒 / truncated apparent Saturn diameter in arcseconds.
|
||||
func SaturnDiameterN(jd float64, n int) float64 {
|
||||
return 2 * SaturnSemidiameterN(jd, n)
|
||||
func SaturnDiameterN(jde float64, n int) float64 {
|
||||
return 2 * SaturnSemidiameterN(jde, n)
|
||||
}
|
||||
|
||||
// UranusSemidiameter 天王星视半径,单位角秒 / apparent Uranus semidiameter in arcseconds.
|
||||
func UranusSemidiameter(jd float64) float64 {
|
||||
return UranusSemidiameterN(jd, -1)
|
||||
func UranusSemidiameter(jde float64) float64 {
|
||||
return UranusSemidiameterN(jde, -1)
|
||||
}
|
||||
|
||||
// UranusSemidiameterN 天王星视半径(截断版),单位角秒 / truncated apparent Uranus semidiameter in arcseconds.
|
||||
func UranusSemidiameterN(jd float64, n int) float64 {
|
||||
return angularSemidiameterFromAU(uranusEquatorialRadiusKM, EarthUranusAwayN(jd, n))
|
||||
func UranusSemidiameterN(jde float64, n int) float64 {
|
||||
return angularSemidiameterFromAU(uranusEquatorialRadiusKM, EarthUranusAwayN(jde, n))
|
||||
}
|
||||
|
||||
// UranusDiameter 天王星视直径,单位角秒 / apparent Uranus diameter in arcseconds.
|
||||
func UranusDiameter(jd float64) float64 {
|
||||
return UranusDiameterN(jd, -1)
|
||||
func UranusDiameter(jde float64) float64 {
|
||||
return UranusDiameterN(jde, -1)
|
||||
}
|
||||
|
||||
// UranusDiameterN 天王星视直径(截断版),单位角秒 / truncated apparent Uranus diameter in arcseconds.
|
||||
func UranusDiameterN(jd float64, n int) float64 {
|
||||
return 2 * UranusSemidiameterN(jd, n)
|
||||
func UranusDiameterN(jde float64, n int) float64 {
|
||||
return 2 * UranusSemidiameterN(jde, n)
|
||||
}
|
||||
|
||||
// NeptuneSemidiameter 海王星视半径,单位角秒 / apparent Neptune semidiameter in arcseconds.
|
||||
func NeptuneSemidiameter(jd float64) float64 {
|
||||
return NeptuneSemidiameterN(jd, -1)
|
||||
func NeptuneSemidiameter(jde float64) float64 {
|
||||
return NeptuneSemidiameterN(jde, -1)
|
||||
}
|
||||
|
||||
// NeptuneSemidiameterN 海王星视半径(截断版),单位角秒 / truncated apparent Neptune semidiameter in arcseconds.
|
||||
func NeptuneSemidiameterN(jd float64, n int) float64 {
|
||||
return angularSemidiameterFromAU(neptuneEquatorialRadiusKM, EarthNeptuneAwayN(jd, n))
|
||||
func NeptuneSemidiameterN(jde float64, n int) float64 {
|
||||
return angularSemidiameterFromAU(neptuneEquatorialRadiusKM, EarthNeptuneAwayN(jde, n))
|
||||
}
|
||||
|
||||
// NeptuneDiameter 海王星视直径,单位角秒 / apparent Neptune diameter in arcseconds.
|
||||
func NeptuneDiameter(jd float64) float64 {
|
||||
return NeptuneDiameterN(jd, -1)
|
||||
func NeptuneDiameter(jde float64) float64 {
|
||||
return NeptuneDiameterN(jde, -1)
|
||||
}
|
||||
|
||||
// NeptuneDiameterN 海王星视直径(截断版),单位角秒 / truncated apparent Neptune diameter in arcseconds.
|
||||
func NeptuneDiameterN(jd float64, n int) float64 {
|
||||
return 2 * NeptuneSemidiameterN(jd, n)
|
||||
func NeptuneDiameterN(jde float64, n int) float64 {
|
||||
return 2 * NeptuneSemidiameterN(jde, n)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestAngularDiametersMatchHorizonsBaseline(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("parse sample time %q: %v", sample.InputUTC, err)
|
||||
}
|
||||
jd := TD2UT(Date2JDE(date.UTC()), true)
|
||||
jd := UTC2TT(Date2JD(date.UTC()))
|
||||
for _, tc := range cases {
|
||||
want := sample.Values[tc.baselineKey]
|
||||
got := tc.diameter(jd)
|
||||
@@ -75,7 +75,7 @@ func TestAngularDiametersMatchHorizonsBaseline(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAngularDiameterNFullMatchesDefault(t *testing.T) {
|
||||
jd := TD2UT(Date2JDE(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC)), true)
|
||||
jd := UTC2TT(Date2JD(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC)))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ func HeightDegreeByLat(height, lat float64) float64 {
|
||||
return math.Acos((radius)/(radius+height)) * 180 / math.Pi
|
||||
}
|
||||
|
||||
// GeocentricRadius 地心直径与纬度的关系
|
||||
// GeocentricRadius 给定纬度处的地心半径,单位米 / geocentric radius at the given latitude, in metres.
|
||||
func GeocentricRadius(lat float64) float64 {
|
||||
a := (EARTH_EQUATORIAL_RADIUS * EARTH_EQUATORIAL_RADIUS * Cos(lat))
|
||||
a *= a
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestLunarEclipseDiagramIncludesContacts(t *testing.T) {
|
||||
diagram := LunarEclipseDiagram(JDECalc(2026, 3, 3), LunarEclipseDiagramOptions{StepDays: 10.0 / 1440.0})
|
||||
diagram := LunarEclipseDiagram(JDCalc(2026, 3, 3), LunarEclipseDiagramOptions{StepDays: 10.0 / 1440.0})
|
||||
if diagram.Eclipse.Type != LunarEclipseTotal {
|
||||
t.Fatalf("unexpected eclipse type: got %s want %s", diagram.Eclipse.Type, LunarEclipseTotal)
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func TestLunarEclipseDiagramIncludesContacts(t *testing.T) {
|
||||
|
||||
func TestLocalSolarEclipseDiagramIncludesContacts(t *testing.T) {
|
||||
diagram := LocalSolarEclipseDiagram(
|
||||
TD2UT(Date2JDE(time.Date(2024, 4, 8, 12, 0, 0, 0, time.UTC)), true),
|
||||
UTC2TT(Date2JD(time.Date(2024, 4, 8, 12, 0, 0, 0, time.UTC))),
|
||||
-96.7970,
|
||||
32.7767,
|
||||
0,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package basic
|
||||
|
||||
import "math"
|
||||
|
||||
const (
|
||||
exactEventTolerance = 2.0 / 86400.0
|
||||
exactQueryTTToleranceUT = 0.1 / 86400.0
|
||||
// stationQueryToleranceUT 站(留)事件的「同刻」容差 / same-instant tolerance for station events.
|
||||
//
|
||||
// 站的求解本身有数值不确定度(火星 ~0.2 s、金星 ~2 s、外行星改精修步长后 <0.05 s)。
|
||||
// 若与普通事件一样取 0.1 s,查询落在站前几十毫秒时会被判成「站仍在未来」,
|
||||
// 于是跳到上一个会合周期。取 0.5 s 可覆盖除金星外的全部不确定度。
|
||||
// 上限约束:必须明显小于 1 s,否则会破坏
|
||||
// TestInnerPlanetNextEventAdvancesPastReturnedEvent 的「查询=事件+1 秒必须前进」语义。
|
||||
stationQueryToleranceUT = 0.5 / 86400.0
|
||||
)
|
||||
|
||||
func sameEventJD(a, b float64) bool {
|
||||
return math.Abs(a-b) <= exactEventTolerance
|
||||
}
|
||||
|
||||
// stationUTQueryBeforeOrEqual / stationUTQueryAfterOrEqual 站事件专用侧向判定,
|
||||
// 容差比普通事件宽(见 stationQueryToleranceUT)。
|
||||
func stationUTQueryBeforeOrEqual(eventUT, queryTT float64) bool {
|
||||
return eventUTQueryTTDelta(eventUT, queryTT) <= stationQueryToleranceUT
|
||||
}
|
||||
|
||||
func stationUTQueryAfterOrEqual(eventUT, queryTT float64) bool {
|
||||
return eventUTQueryTTDelta(eventUT, queryTT) >= -stationQueryToleranceUT
|
||||
}
|
||||
|
||||
func closestEventUTToQueryTT(queryTT, best float64, candidates ...float64) float64 {
|
||||
bestAbs := math.Abs(eventUTQueryTTDelta(best, queryTT))
|
||||
for _, candidate := range candidates {
|
||||
candidateAbs := math.Abs(eventUTQueryTTDelta(candidate, queryTT))
|
||||
if candidateAbs < bestAbs {
|
||||
best = candidate
|
||||
bestAbs = candidateAbs
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
type phaseEventSearchFunc func(jde, degree float64, next uint8) float64
|
||||
type simpleEventSearchFunc func(jde float64) float64
|
||||
|
||||
// inclusive* 的唯一作用是把「查询几乎正好落在事件上」判成包含:
|
||||
// 反向搜索给出的邻接事件落在同刻容差内时优先返回它,否则返回本方向的事件(可能是 NaN)。
|
||||
func inclusiveLastPhaseEvent(jde, degree float64, fn phaseEventSearchFunc) float64 {
|
||||
last := fn(jde, degree, 0)
|
||||
if next := fn(jde, degree, 1); eventUTQueryBeforeOrEqual(next, jde) && eventUTQueryAfterOrEqual(next, jde) {
|
||||
return next
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func inclusiveNextPhaseEvent(jde, degree float64, fn phaseEventSearchFunc) float64 {
|
||||
if last := fn(jde, degree, 0); eventUTQueryBeforeOrEqual(last, jde) && eventUTQueryAfterOrEqual(last, jde) {
|
||||
return last
|
||||
}
|
||||
return fn(jde, degree, 1)
|
||||
}
|
||||
|
||||
func inclusiveLastSimpleEvent(jde float64, lastFn, nextFn simpleEventSearchFunc) float64 {
|
||||
last := lastFn(jde)
|
||||
if next := nextFn(jde); eventUTQueryBeforeOrEqual(next, jde) && eventUTQueryAfterOrEqual(next, jde) {
|
||||
return next
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func inclusiveNextSimpleEvent(jde float64, lastFn, nextFn simpleEventSearchFunc) float64 {
|
||||
if last := lastFn(jde); eventUTQueryBeforeOrEqual(last, jde) && eventUTQueryAfterOrEqual(last, jde) {
|
||||
return last
|
||||
}
|
||||
return nextFn(jde)
|
||||
}
|
||||
+174
-3
@@ -2,6 +2,179 @@ package basic
|
||||
|
||||
import "math"
|
||||
|
||||
const (
|
||||
eventNewtonMaxIterations = 24
|
||||
eventDirectionalSearchIterations = 128
|
||||
eventRiseSetScanStep = 1.0 / 1440
|
||||
// stationDerivativeStepDay 「留」精修用的中心差分步长(天)。
|
||||
// 0.5 秒级的步长会被浮点相消噪声支配(实测外行星留误差可达 42 s);
|
||||
// 0.01 天(14.4 分钟)实测误差 <0.05 s,且求值次数不变。
|
||||
stationDerivativeStepDay = 0.01
|
||||
)
|
||||
|
||||
func isFiniteFloat(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
}
|
||||
|
||||
// eventNewtonRefine 执行有界牛顿迭代;修正函数返回 f(x)/f'(x),调用者保留现有导数计算 / eventNewtonRefine performs a bounded Newton iteration. The correction
|
||||
// 对格式错误输入和不收敛迭代快速失败 / function returns f(x)/f'(x), so callers retain their existing derivative
|
||||
// 计算 / calculation while malformed input and non-convergent iterations fail fast.
|
||||
func eventNewtonRefine(seed, tolerance float64, correction func(float64) float64) (float64, bool) {
|
||||
if !isFiniteFloat(seed) || !isFiniteFloat(tolerance) || tolerance <= 0 {
|
||||
return math.NaN(), false
|
||||
}
|
||||
current := seed
|
||||
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||
step := correction(current)
|
||||
if !isFiniteFloat(step) {
|
||||
return math.NaN(), false
|
||||
}
|
||||
next := current - step
|
||||
if !isFiniteFloat(next) {
|
||||
return math.NaN(), false
|
||||
}
|
||||
if math.Abs(next-current) <= tolerance {
|
||||
return next, true
|
||||
}
|
||||
current = next
|
||||
}
|
||||
return math.NaN(), false
|
||||
}
|
||||
|
||||
func eventRiseSetCandidateValid(candidate, civilDayStart, slope float64, isRise bool) bool {
|
||||
if !isFiniteFloat(candidate) || !isFiniteFloat(civilDayStart) || !isFiniteFloat(slope) ||
|
||||
candidate < civilDayStart || candidate >= civilDayStart+1 {
|
||||
return false
|
||||
}
|
||||
if isRise {
|
||||
return slope > 0
|
||||
}
|
||||
return slope < 0
|
||||
}
|
||||
|
||||
func eventDirectionalRiseSetSearch(civilDayStart float64, isRise bool, fallbackErr error,
|
||||
residual func(float64) float64) (float64, error) {
|
||||
if !isFiniteFloat(civilDayStart) {
|
||||
return 0, ErrInvalidObservationInput
|
||||
}
|
||||
|
||||
previousJD := civilDayStart
|
||||
previousValue := residual(previousJD)
|
||||
if !isFiniteFloat(previousValue) {
|
||||
return 0, ErrInvalidObservationInput
|
||||
}
|
||||
minimum, maximum := previousValue, previousValue
|
||||
steps := int(math.Round(1 / eventRiseSetScanStep))
|
||||
for i := 1; i <= steps; i++ {
|
||||
currentJD := civilDayStart + float64(i)*eventRiseSetScanStep
|
||||
currentValue := residual(currentJD)
|
||||
if !isFiniteFloat(currentValue) {
|
||||
return 0, ErrInvalidObservationInput
|
||||
}
|
||||
minimum = math.Min(minimum, currentValue)
|
||||
maximum = math.Max(maximum, currentValue)
|
||||
if eventCrossesDirection(previousValue, currentValue, isRise) {
|
||||
eventJD := eventDirectionalBracketRefine(previousJD, currentJD, previousValue, currentValue, residual)
|
||||
if eventJD < civilDayStart+1 {
|
||||
return eventJD, nil
|
||||
}
|
||||
}
|
||||
previousJD = currentJD
|
||||
previousValue = currentValue
|
||||
}
|
||||
|
||||
switch {
|
||||
case fallbackErr != nil:
|
||||
return 0, fallbackErr
|
||||
case maximum < 0:
|
||||
return 0, ErrNeverRise
|
||||
case minimum > 0:
|
||||
return 0, ErrNeverSet
|
||||
default:
|
||||
return 0, ErrNotOnThisDate
|
||||
}
|
||||
}
|
||||
|
||||
func eventCrossesDirection(leftValue, rightValue float64, isRise bool) bool {
|
||||
if isRise {
|
||||
return leftValue <= 0 && rightValue >= 0 && leftValue != rightValue
|
||||
}
|
||||
return leftValue >= 0 && rightValue <= 0 && leftValue != rightValue
|
||||
}
|
||||
|
||||
func eventDirectionalBracketRefine(leftJD, rightJD, leftValue, rightValue float64, residual func(float64) float64) float64 {
|
||||
if leftValue == 0 {
|
||||
return leftJD
|
||||
}
|
||||
if rightValue == 0 {
|
||||
return rightJD
|
||||
}
|
||||
for i := 0; i < 48; i++ {
|
||||
middleJD := (leftJD + rightJD) / 2
|
||||
middleValue := residual(middleJD)
|
||||
if middleValue == 0 {
|
||||
return middleJD
|
||||
}
|
||||
if (leftValue < 0) == (middleValue < 0) {
|
||||
leftJD = middleJD
|
||||
leftValue = middleValue
|
||||
} else {
|
||||
rightJD = middleJD
|
||||
}
|
||||
}
|
||||
return (leftJD + rightJD) / 2
|
||||
}
|
||||
|
||||
// eventBracketSecantRoot 在已知异号的括号内用割线法(带中点兜底)求根。
|
||||
//
|
||||
// 与 eventDirectionalBracketRefine(固定 48 次二分)相比,割线法通常 5~8 次求值即可达到
|
||||
// 亚秒精度,适合「括号由廉价截断级数给出、抛光必须用全项级数」的两段式搜索。
|
||||
// 括号每一步都收缩,因此不会跑到括号外;括号端点同号或出现非有限值时返回 false。
|
||||
func eventBracketSecantRoot(leftJD, rightJD, leftValue, rightValue, tolerance float64,
|
||||
fn func(float64) float64) (float64, bool) {
|
||||
if leftValue == 0 {
|
||||
return leftJD, true
|
||||
}
|
||||
if rightValue == 0 {
|
||||
return rightJD, true
|
||||
}
|
||||
if !isFiniteFloat(leftValue) || !isFiniteFloat(rightValue) || leftValue*rightValue > 0 {
|
||||
return math.NaN(), false
|
||||
}
|
||||
if !isFiniteFloat(tolerance) || tolerance <= 0 {
|
||||
tolerance = 0.5 / 86400.0
|
||||
}
|
||||
bestJD := (leftJD + rightJD) / 2
|
||||
for i := 0; i < 48; i++ {
|
||||
candidateJD := (leftJD + rightJD) / 2
|
||||
if rightValue != leftValue {
|
||||
secantJD := rightJD - rightValue*(rightJD-leftJD)/(rightValue-leftValue)
|
||||
if secantJD > leftJD && secantJD < rightJD {
|
||||
candidateJD = secantJD
|
||||
}
|
||||
}
|
||||
candidateValue := fn(candidateJD)
|
||||
if !isFiniteFloat(candidateValue) {
|
||||
return math.NaN(), false
|
||||
}
|
||||
bestJD = candidateJD
|
||||
if candidateValue == 0 || math.Abs(rightJD-leftJD) <= tolerance {
|
||||
return candidateJD, true
|
||||
}
|
||||
if (leftValue < 0) == (candidateValue < 0) {
|
||||
leftJD, leftValue = candidateJD, candidateValue
|
||||
continue
|
||||
}
|
||||
rightJD, rightValue = candidateJD, candidateValue
|
||||
}
|
||||
if math.Abs(rightJD-leftJD) <= tolerance {
|
||||
return bestJD, true
|
||||
}
|
||||
// 48 次迭代后括号仍宽于容差(例如容差低于该儒略日的 ULP):调用方把 ok 当作
|
||||
// “已抛光到容差”,这里必须报 false,不能返回一个精度未达标的时刻。
|
||||
return bestJD, false
|
||||
}
|
||||
|
||||
func eventFixedScanRefine(seed, halfWindow, step float64, fn func(float64) float64) float64 {
|
||||
start := seed - halfWindow
|
||||
bestJD := start
|
||||
@@ -40,9 +213,7 @@ func eventZeroBracket(leftJD, leftVal, centerJD, centerVal, rightJD, rightVal fl
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
|
||||
// eventZeroRefine 细化 seed 附近的零点;若找不到可用括号区间,则退回旧的固定步长扫描。
|
||||
// eventZeroRefine refines a nearby zero crossing and falls back to the legacy
|
||||
// fixed-step scan when no usable bracket is found.
|
||||
// eventZeroRefine 细化 seed 附近的零点;无可用括号区间时退回固定步长扫描。
|
||||
func eventZeroRefine(seed, halfWindow, step float64, fn func(float64) float64) float64 {
|
||||
leftJD := seed - halfWindow
|
||||
centerJD := seed
|
||||
|
||||
@@ -23,3 +23,19 @@ func TestEventZeroRefineFallsBackToFixedScan(t *testing.T) {
|
||||
t.Fatalf("got %.15f want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 48 次迭代用尽后括号仍宽于容差时不能谎报"已抛光":调用方把 ok 当作精度保证。
|
||||
func TestEventBracketSecantRootRejectsUnpolishedRoot(t *testing.T) {
|
||||
linear := func(x float64) float64 { return x - 0.5 }
|
||||
if _, ok := eventBracketSecantRoot(0, 1, -0.5, 0.5, 1e-9, linear); !ok {
|
||||
t.Fatalf("normal tolerance should return ok=true")
|
||||
}
|
||||
// 无理根(sqrt(2))永远取不到精确零,容差又低于该量级的 ULP:必须报 false。
|
||||
irrational := func(x float64) float64 { return x*x - 2 }
|
||||
if _, ok := eventBracketSecantRoot(0, 2, -2, 2, 1e-25, irrational); ok {
|
||||
t.Fatalf("unreachable tolerance must not report a polished root")
|
||||
}
|
||||
if _, ok := eventBracketSecantRoot(0, 2, -2, 2, 1e-12, irrational); !ok {
|
||||
t.Fatalf("reachable tolerance should still report ok=true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 等时线(食甚/掩甚时刻等值线)的共享常量与工具。
|
||||
// 两个等时线实现(日食、月掩)用同一套容差与时刻取值生成规则,避免口径漂移。
|
||||
|
||||
const (
|
||||
// greatestTimeContourCoverToleranceKM 是"该点是否已被已绘等时线覆盖"的距离门限。
|
||||
// 判据必须用点到折线的距离:同一条曲线被两个种子各画一次时该距离约为 0,而折线弦高在
|
||||
// 1.5° 步长下最大几 km,所以 10 km 既不会漏判重复,也远小于不同支路的间距。
|
||||
greatestTimeContourCoverToleranceKM = 10.0
|
||||
// greatestTimeContourCorrectionBacktracking 是牛顿投影跳出定义域时按二分回退的次数。
|
||||
greatestTimeContourCorrectionBacktracking = 6
|
||||
// greatestTimeContourKMPerDegree 是地面大圆每度的近似长度,用于局部平面投影。
|
||||
greatestTimeContourKMPerDegree = 111.195
|
||||
// greatestTimeContourMaxLevels 是单个事件允许的等时线条数上限;超出部分按时间截断,
|
||||
// 避免极长事件请求出上万条曲线。
|
||||
greatestTimeContourMaxLevels = 64
|
||||
// greatestTimeContourResidualTolerance 是残差零点判定的收敛容差。
|
||||
greatestTimeContourResidualTolerance = 1e-9
|
||||
// greatestTimeContourBisectionIterations 是二分求根的迭代上限。
|
||||
greatestTimeContourBisectionIterations = 64
|
||||
)
|
||||
|
||||
// greatestTimeContourDifference 优先用中心差分,一侧越界时退化为单侧差分。
|
||||
func greatestTimeContourDifference(center, positive, negative, step float64, positiveOK, negativeOK bool) (float64, bool) {
|
||||
switch {
|
||||
case positiveOK && negativeOK:
|
||||
return (positive - negative) / (2 * step), true
|
||||
case positiveOK:
|
||||
return (positive - center) / step, true
|
||||
case negativeOK:
|
||||
return (center - negative) / step, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// greatestTimeContourBisect 在 [left,right] 上二分求残差零点;residual 在定义域外返回 NaN。
|
||||
func greatestTimeContourBisect(
|
||||
residual func(longitude, latitude float64) float64,
|
||||
left, right, latitude, leftValue float64,
|
||||
) (float64, bool) {
|
||||
for iteration := 0; iteration < greatestTimeContourBisectionIterations; iteration++ {
|
||||
middle := (left + right) / 2
|
||||
middleValue := residual(middle, latitude)
|
||||
if !finite(middleValue) {
|
||||
return 0, false
|
||||
}
|
||||
if math.Abs(middleValue) <= greatestTimeContourResidualTolerance || right-left <= 1e-9 {
|
||||
return middle, true
|
||||
}
|
||||
if leftValue*middleValue <= 0 {
|
||||
right = middle
|
||||
} else {
|
||||
left, leftValue = middle, middleValue
|
||||
}
|
||||
}
|
||||
return (left + right) / 2, true
|
||||
}
|
||||
|
||||
// greatestTimeContourTickTolerance 是对齐网格在窗口边界上的容差,远小于任何合法步长。
|
||||
const greatestTimeContourTickTolerance = time.Millisecond
|
||||
|
||||
// greatestTimeContourLevel 是一条等时线对应的时刻取值:TT 儒略日与它对应的时刻必须成对传递,
|
||||
// 前者用于求根,后者用于标注(按步长生成时它是原始对齐时刻,避免 JDE 往返把整分截断成前一分钟)。
|
||||
type greatestTimeContourLevel struct {
|
||||
tt float64
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// greatestTimeContourAlignedLevels 生成覆盖 [startTT,endTT] 且对齐到 step 整刻度的时刻取值,
|
||||
// 最多 maxLevels 条;起点或终点恰好落在整刻度上时该刻度只生成一次。
|
||||
func greatestTimeContourAlignedLevels(startTT, endTT float64, step time.Duration, maxLevels int) []greatestTimeContourLevel {
|
||||
if step <= 0 || maxLevels <= 0 || endTT <= startTT {
|
||||
return nil
|
||||
}
|
||||
start := greatestTimeContourTTToUTC(startTT)
|
||||
first := start.Truncate(step)
|
||||
// TT 儒略日往返有约 40 µs(1 ULP)误差,恰好落在整刻度上的窗口边界会算到刻度外一点点,容差按 1 ms 给。
|
||||
if first.Add(greatestTimeContourTickTolerance).Before(start) {
|
||||
first = first.Add(step)
|
||||
}
|
||||
end := greatestTimeContourTTToUTC(endTT)
|
||||
levels := make([]greatestTimeContourLevel, 0, maxLevels)
|
||||
for current := first; !current.Add(-greatestTimeContourTickTolerance).After(end) && len(levels) < maxLevels; current = current.Add(step) {
|
||||
levels = append(levels, greatestTimeContourLevel{tt: greatestTimeContourUTCToTT(current), at: current})
|
||||
}
|
||||
return levels
|
||||
}
|
||||
|
||||
// greatestTimeContourTTToUTC 把 TT 儒略日换成对应的 UTC 时刻。
|
||||
func greatestTimeContourTTToUTC(tt float64) time.Time {
|
||||
return JD2DateByZone(TT2UTC(tt), time.UTC, false)
|
||||
}
|
||||
|
||||
// greatestTimeContourUTCToTT 把 UTC 时刻换成对应的 TT 儒略日。
|
||||
func greatestTimeContourUTCToTT(value time.Time) float64 {
|
||||
return UTC2TT(Date2JD(value.UTC()))
|
||||
}
|
||||
|
||||
// greatestTimeContourPointSegmentKM 返回点到折线段的距离,单位 km。
|
||||
// 段长只有 1–2 度,用局部等距圆柱近似即可,误差远小于覆盖容差。
|
||||
func greatestTimeContourPointSegmentKM(longitude, latitude, startLongitude, startLatitude, endLongitude, endLatitude float64) float64 {
|
||||
cosine := math.Cos(latitude * rad)
|
||||
toX := func(value float64) float64 {
|
||||
return normalizeLongitude(value-startLongitude) * rad * cosine * greatestTimeContourKMPerDegree
|
||||
}
|
||||
toY := func(value float64) float64 { return (value - startLatitude) * greatestTimeContourKMPerDegree }
|
||||
pointX, pointY := toX(longitude), toY(latitude)
|
||||
endX, endY := toX(endLongitude), toY(endLatitude)
|
||||
lengthSquared := endX*endX + endY*endY
|
||||
projection := 0.0
|
||||
if lengthSquared > 0 {
|
||||
projection = (pointX*endX + pointY*endY) / lengthSquared
|
||||
if projection < 0 {
|
||||
projection = 0
|
||||
} else if projection > 1 {
|
||||
projection = 1
|
||||
}
|
||||
}
|
||||
return math.Hypot(pointX-projection*endX, pointY-projection*endY)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 对齐网格是导出行为(等时线按整刻度取值)的契约,取值本身写成字面量以免与实际生成函数互相自证。
|
||||
func TestGreatestTimeContourAlignedLevelsGridContracts(t *testing.T) {
|
||||
tt := func(hour, minute int) float64 {
|
||||
return UTC2TT(Date2JD(time.Date(2024, 1, 1, hour, minute, 0, 0, time.UTC)))
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
start, end float64
|
||||
step time.Duration
|
||||
maxLevels int
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "起点在刻度之间时从下一个整刻度开始", start: tt(0, 7), end: tt(2, 7),
|
||||
step: 30 * time.Minute, maxLevels: 64,
|
||||
want: []string{"2024-01-01T00:30:00Z", "2024-01-01T01:00:00Z", "2024-01-01T01:30:00Z", "2024-01-01T02:00:00Z"},
|
||||
},
|
||||
{
|
||||
name: "起点恰在刻度上时不重复生成该刻度", start: tt(0, 30), end: tt(2, 0),
|
||||
step: 30 * time.Minute, maxLevels: 64,
|
||||
want: []string{"2024-01-01T00:30:00Z", "2024-01-01T01:00:00Z", "2024-01-01T01:30:00Z", "2024-01-01T02:00:00Z"},
|
||||
},
|
||||
{
|
||||
name: "终点恰在刻度上时包含终点", start: tt(0, 7), end: tt(1, 30),
|
||||
step: 30 * time.Minute, maxLevels: 64,
|
||||
want: []string{"2024-01-01T00:30:00Z", "2024-01-01T01:00:00Z", "2024-01-01T01:30:00Z"},
|
||||
},
|
||||
{
|
||||
name: "上限截断", start: tt(0, 7), end: tt(5, 7),
|
||||
step: 30 * time.Minute, maxLevels: 3,
|
||||
want: []string{"2024-01-01T00:30:00Z", "2024-01-01T01:00:00Z", "2024-01-01T01:30:00Z"},
|
||||
},
|
||||
{
|
||||
name: "窗口内没有整刻度时为空", start: tt(0, 1), end: tt(0, 20),
|
||||
step: time.Hour, maxLevels: 64,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "非正步长或倒置窗口为空", start: tt(0, 7), end: tt(2, 7),
|
||||
step: 0, maxLevels: 64, want: nil,
|
||||
},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
levels := greatestTimeContourAlignedLevels(testCase.start, testCase.end, testCase.step, testCase.maxLevels)
|
||||
if len(levels) != len(testCase.want) {
|
||||
t.Fatalf("%s: %d levels, want %d", testCase.name, len(levels), len(testCase.want))
|
||||
}
|
||||
for index, level := range levels {
|
||||
got := level.at.UTC().Format(time.RFC3339)
|
||||
if got != testCase.want[index] {
|
||||
t.Fatalf("%s: level %d at %s, want %s", testCase.name, index, got, testCase.want[index])
|
||||
}
|
||||
// 标注时刻与求根时刻必须成对:TT 往返 1 ULP 内一致。
|
||||
if roundTrip := greatestTimeContourUTCToTT(level.at); roundTrip != level.tt {
|
||||
t.Fatalf("%s: level %d tt %.9f != %.9f", testCase.name, index, level.tt, roundTrip)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package basic
|
||||
|
||||
import "math"
|
||||
|
||||
const (
|
||||
innerEventEpsilon = 0.1 / 86400.0
|
||||
innerEventWindowPadding = 4.0 / 86400.0
|
||||
innerEventMaximizeEpsilon = 4.0 / 86400.0
|
||||
)
|
||||
|
||||
func eventQueryTTAsUT(queryTT float64) float64 {
|
||||
return TT2UTC(queryTT)
|
||||
}
|
||||
|
||||
func eventUTQueryTTDelta(eventUT, queryTT float64) float64 {
|
||||
return eventUT - eventQueryTTAsUT(queryTT)
|
||||
}
|
||||
|
||||
func eventUTQueryBeforeOrEqual(eventUT, queryTT float64) bool {
|
||||
return eventUTQueryTTDelta(eventUT, queryTT) <= innerEventEpsilon
|
||||
}
|
||||
|
||||
func eventUTQueryAfterOrEqual(eventUT, queryTT float64) bool {
|
||||
return eventUTQueryTTDelta(eventUT, queryTT) >= -innerEventEpsilon
|
||||
}
|
||||
|
||||
func eventUTNextQueryTT(eventUT float64) float64 {
|
||||
return UTC2TT(eventUT) + 1.0
|
||||
}
|
||||
|
||||
func eventUTLastQueryTT(eventUT float64) float64 {
|
||||
return UTC2TT(eventUT) - 1.0
|
||||
}
|
||||
|
||||
func innerNextCycleOffset(delta, period float64) float64 {
|
||||
if delta <= 0 {
|
||||
return -delta * period / 360.0
|
||||
}
|
||||
return (360.0 - delta) * period / 360.0
|
||||
}
|
||||
|
||||
func innerLastCycleOffset(delta, period float64) float64 {
|
||||
if delta >= 0 {
|
||||
return delta * period / 360.0
|
||||
}
|
||||
return (360.0 + delta) * period / 360.0
|
||||
}
|
||||
|
||||
func clampFloat64(v, min, max float64) float64 {
|
||||
if v < min {
|
||||
return min
|
||||
}
|
||||
if v > max {
|
||||
return max
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func scanWindowForMinAbs(start, end, step float64, fn func(float64) float64) float64 {
|
||||
if end < start {
|
||||
start, end = end, start
|
||||
}
|
||||
if step <= 0 || end == start {
|
||||
return start
|
||||
}
|
||||
bestJD := start
|
||||
bestAbs := math.Abs(fn(start))
|
||||
for jd := start + step; jd < end; jd += step {
|
||||
candidateAbs := math.Abs(fn(jd))
|
||||
if candidateAbs < bestAbs {
|
||||
bestAbs = candidateAbs
|
||||
bestJD = jd
|
||||
}
|
||||
}
|
||||
endAbs := math.Abs(fn(end))
|
||||
if endAbs < bestAbs {
|
||||
return end
|
||||
}
|
||||
return bestJD
|
||||
}
|
||||
|
||||
func scanWindowForMax(start, end, step float64, fn func(float64) float64) float64 {
|
||||
if end < start {
|
||||
start, end = end, start
|
||||
}
|
||||
if step <= 0 || end == start {
|
||||
return start
|
||||
}
|
||||
bestJD := start
|
||||
bestVal := fn(start)
|
||||
for jd := start + step; jd < end; jd += step {
|
||||
candidateVal := fn(jd)
|
||||
if candidateVal > bestVal {
|
||||
bestVal = candidateVal
|
||||
bestJD = jd
|
||||
}
|
||||
}
|
||||
endVal := fn(end)
|
||||
if endVal > bestVal {
|
||||
return end
|
||||
}
|
||||
return bestJD
|
||||
}
|
||||
|
||||
func boundedEventZeroRefine(seed, start, end, halfWindow, step float64, fn func(float64) float64) float64 {
|
||||
if end < start {
|
||||
start, end = end, start
|
||||
}
|
||||
if end <= start {
|
||||
return start
|
||||
}
|
||||
maxHalfWindow := (end - start) / 2
|
||||
if halfWindow > maxHalfWindow {
|
||||
halfWindow = maxHalfWindow
|
||||
}
|
||||
if halfWindow <= 0 {
|
||||
return clampFloat64(seed, start, end)
|
||||
}
|
||||
seed = clampFloat64(seed, start+halfWindow, end-halfWindow)
|
||||
return eventZeroRefine(seed, halfWindow, step, fn)
|
||||
}
|
||||
|
||||
func zeroEventInWindow(start, end, coarseStep, halfWindow, refineStep float64, coarseFn, exactFn func(float64) float64) float64 {
|
||||
if end < start {
|
||||
start, end = end, start
|
||||
}
|
||||
if end <= start {
|
||||
return start
|
||||
}
|
||||
rangeDays := end - start
|
||||
if coarseStep <= 0 || coarseStep > rangeDays {
|
||||
coarseStep = rangeDays / 6.0
|
||||
}
|
||||
if coarseStep < 0.5 {
|
||||
coarseStep = 0.5
|
||||
}
|
||||
if refineStep <= 0 {
|
||||
refineStep = 0.5 / 86400.0
|
||||
}
|
||||
if halfWindow <= 0 {
|
||||
halfWindow = coarseStep
|
||||
}
|
||||
guess := scanWindowForMinAbs(start, end, coarseStep, coarseFn)
|
||||
return boundedEventZeroRefine(guess, start, end, halfWindow, refineStep, exactFn)
|
||||
}
|
||||
|
||||
func maximizeInWindow(start, end, coarseStep float64, coarseFn, exactFn func(float64) float64) float64 {
|
||||
if end < start {
|
||||
start, end = end, start
|
||||
}
|
||||
if end <= start {
|
||||
return start
|
||||
}
|
||||
rangeDays := end - start
|
||||
if coarseStep <= 0 || coarseStep > rangeDays {
|
||||
coarseStep = rangeDays / 6.0
|
||||
}
|
||||
if coarseStep < 0.5 {
|
||||
coarseStep = 0.5
|
||||
}
|
||||
guess := scanWindowForMax(start, end, coarseStep, coarseFn)
|
||||
left := clampFloat64(guess-coarseStep, start, end)
|
||||
right := clampFloat64(guess+coarseStep, start, end)
|
||||
if right-left <= innerEventMaximizeEpsilon {
|
||||
return guess
|
||||
}
|
||||
// 注意:这里必须全部用全项函数迭代。粗阶段(截断函数)虽然更便宜,但截断峰位与全项峰位
|
||||
// 相差分钟量级,粗段省下的求值次数会被精段为达到同等精度而多花掉,因此不做两段式拆分。
|
||||
for i := 0; i < 20; i++ {
|
||||
third := (right - left) / 3.0
|
||||
leftThird := left + third
|
||||
rightThird := right - third
|
||||
if exactFn(leftThird) <= exactFn(rightThird) {
|
||||
left = leftThird
|
||||
continue
|
||||
}
|
||||
right = rightThird
|
||||
}
|
||||
bestJD := guess
|
||||
bestVal := exactFn(bestJD)
|
||||
for _, jd := range []float64{left, (left + right) / 2.0, right} {
|
||||
candidateVal := exactFn(jd)
|
||||
if candidateVal > bestVal {
|
||||
bestVal = candidateVal
|
||||
bestJD = jd
|
||||
}
|
||||
}
|
||||
return bestJD
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInnerPlanetExactEventBoundaryIncludesCurrent(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
seed float64
|
||||
lastFn func(float64) float64
|
||||
nextFn func(float64) float64
|
||||
}{
|
||||
{name: "MercuryConjunction", seed: NextMercuryConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryConjunction, nextFn: NextMercuryConjunction},
|
||||
{name: "MercuryInferior", seed: NextMercuryInferiorConjunctionInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryInferiorConjunctionInclusive, nextFn: NextMercuryInferiorConjunctionInclusive},
|
||||
{name: "MercurySuperior", seed: NextMercurySuperiorConjunctionInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercurySuperiorConjunctionInclusive, nextFn: NextMercurySuperiorConjunctionInclusive},
|
||||
{name: "MercuryRetrograde", seed: NextMercuryRetrogradeInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryRetrogradeInclusive, nextFn: NextMercuryRetrogradeInclusive},
|
||||
{name: "MercuryP2R", seed: NextMercuryProgradeToRetrogradeInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryProgradeToRetrogradeInclusive, nextFn: NextMercuryProgradeToRetrogradeInclusive},
|
||||
{name: "MercuryR2P", seed: NextMercuryRetrogradeToProgradeInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryRetrogradeToProgradeInclusive, nextFn: NextMercuryRetrogradeToProgradeInclusive},
|
||||
{name: "MercuryGreatestElongation", seed: NextMercuryGreatestElongationInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryGreatestElongationInclusive, nextFn: NextMercuryGreatestElongationInclusive},
|
||||
{name: "MercuryEastElongation", seed: NextMercuryGreatestElongationEastInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryGreatestElongationEastInclusive, nextFn: NextMercuryGreatestElongationEastInclusive},
|
||||
{name: "MercuryWestElongation", seed: NextMercuryGreatestElongationWestInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryGreatestElongationWestInclusive, nextFn: NextMercuryGreatestElongationWestInclusive},
|
||||
{name: "VenusConjunction", seed: NextVenusConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusConjunction, nextFn: NextVenusConjunction},
|
||||
{name: "VenusInferior", seed: NextVenusInferiorConjunctionInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusInferiorConjunctionInclusive, nextFn: NextVenusInferiorConjunctionInclusive},
|
||||
{name: "VenusSuperior", seed: NextVenusSuperiorConjunctionInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusSuperiorConjunctionInclusive, nextFn: NextVenusSuperiorConjunctionInclusive},
|
||||
{name: "VenusRetrograde", seed: NextVenusRetrogradeInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusRetrogradeInclusive, nextFn: NextVenusRetrogradeInclusive},
|
||||
{name: "VenusP2R", seed: NextVenusProgradeToRetrogradeInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusProgradeToRetrogradeInclusive, nextFn: NextVenusProgradeToRetrogradeInclusive},
|
||||
{name: "VenusR2P", seed: NextVenusRetrogradeToProgradeInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusRetrogradeToProgradeInclusive, nextFn: NextVenusRetrogradeToProgradeInclusive},
|
||||
{name: "VenusGreatestElongation", seed: NextVenusGreatestElongationInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusGreatestElongationInclusive, nextFn: NextVenusGreatestElongationInclusive},
|
||||
{name: "VenusEastElongation", seed: NextVenusGreatestElongationEastInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusGreatestElongationEastInclusive, nextFn: NextVenusGreatestElongationEastInclusive},
|
||||
{name: "VenusWestElongation", seed: NextVenusGreatestElongationWestInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusGreatestElongationWestInclusive, nextFn: NextVenusGreatestElongationWestInclusive},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
queryTT := UTC2TT(tc.seed)
|
||||
last := tc.lastFn(queryTT)
|
||||
next := tc.nextFn(queryTT)
|
||||
if !sameEventJD(last, tc.seed) {
|
||||
t.Fatalf("last exact boundary mismatch: got %.12f want %.12f", last, tc.seed)
|
||||
}
|
||||
if !sameEventJD(next, tc.seed) {
|
||||
t.Fatalf("next exact boundary mismatch: got %.12f want %.12f", next, tc.seed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInnerPlanetNextEventAdvancesPastReturnedEvent(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
seed float64
|
||||
next func(float64) float64
|
||||
}{
|
||||
{name: "MercuryConjunction", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextMercuryConjunction},
|
||||
{name: "MercuryInferior", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextMercuryInferiorConjunction},
|
||||
{name: "MercuryP2R", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextMercuryProgradeToRetrograde},
|
||||
{name: "MercuryEastElongation", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextMercuryGreatestElongationEast},
|
||||
{name: "VenusConjunction", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextVenusConjunction},
|
||||
{name: "VenusWestElongation", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextVenusGreatestElongationWest},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
first := tc.next(tc.seed)
|
||||
query := UTC2TT(Date2JD(JD2DateByZone(first, time.UTC, false).Add(time.Second)))
|
||||
next := tc.next(query)
|
||||
if !eventUTQueryAfterOrEqual(next, query) {
|
||||
t.Fatalf("next should be after query: first=%.12f query=%.12f next=%.12f", first, query, next)
|
||||
}
|
||||
if sameEventJD(next, first) {
|
||||
t.Fatalf("next should advance past first event: first=%.12f next=%.12f", first, next)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInnerPlanetTypedConjunctionExactBoundaryIncludesCurrent(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
seed float64
|
||||
next func(float64) float64
|
||||
last func(float64) float64
|
||||
}{
|
||||
{name: "MercuryInferior", seed: NextMercuryInferiorConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), next: NextMercuryInferiorConjunction, last: LastMercuryInferiorConjunction},
|
||||
{name: "MercurySuperior", seed: NextMercurySuperiorConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), next: NextMercurySuperiorConjunction, last: LastMercurySuperiorConjunction},
|
||||
{name: "VenusInferior", seed: NextVenusInferiorConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), next: NextVenusInferiorConjunction, last: LastVenusInferiorConjunction},
|
||||
{name: "VenusSuperior", seed: NextVenusSuperiorConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), next: NextVenusSuperiorConjunction, last: LastVenusSuperiorConjunction},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
queryTT := UTC2TT(tc.seed)
|
||||
last := tc.last(queryTT)
|
||||
next := tc.next(queryTT)
|
||||
if !sameEventJD(last, tc.seed) {
|
||||
t.Fatalf("last exact boundary mismatch: got %.12f want %.12f", last, tc.seed)
|
||||
}
|
||||
if !sameEventJD(next, tc.seed) {
|
||||
t.Fatalf("next exact boundary mismatch: got %.12f want %.12f", next, tc.seed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type innerBaselineFile struct {
|
||||
Events []innerBaselineEvent `json:"events"`
|
||||
}
|
||||
|
||||
type innerBaselineEvent struct {
|
||||
Planet string `json:"planet"`
|
||||
Kind string `json:"kind"`
|
||||
NAOJHintJST string `json:"naoj_hint_jst"`
|
||||
Precision string `json:"precision"`
|
||||
CandidateJST string `json:"candidate_jst"`
|
||||
VerifiedJST string `json:"verified_jst"`
|
||||
CandidateSource string `json:"candidate_source"`
|
||||
}
|
||||
|
||||
func loadInnerBaseline(t *testing.T) innerBaselineFile {
|
||||
t.Helper()
|
||||
|
||||
paths := [][]string{
|
||||
{
|
||||
"testdata/jpl_inner_event_baseline.json",
|
||||
"basic/testdata/jpl_inner_event_baseline.json",
|
||||
},
|
||||
{
|
||||
"testdata/jpl_inner_event_baseline_21c_sample.json",
|
||||
"basic/testdata/jpl_inner_event_baseline_21c_sample.json",
|
||||
},
|
||||
{
|
||||
"testdata/jpl_inner_event_baseline_20c_sample.json",
|
||||
"basic/testdata/jpl_inner_event_baseline_20c_sample.json",
|
||||
},
|
||||
{
|
||||
"testdata/jpl_inner_event_baseline_22c_sample.json",
|
||||
"basic/testdata/jpl_inner_event_baseline_22c_sample.json",
|
||||
},
|
||||
}
|
||||
var merged innerBaselineFile
|
||||
for index, candidates := range paths {
|
||||
var (
|
||||
data []byte
|
||||
err error
|
||||
)
|
||||
for _, path := range candidates {
|
||||
data, err = os.ReadFile(path)
|
||||
if err == nil {
|
||||
var baseline innerBaselineFile
|
||||
if err := json.Unmarshal(data, &baseline); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
merged.Events = append(merged.Events, baseline.Events...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil && index == 0 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(merged.Events) == 0 {
|
||||
t.Fatal("empty inner baseline file")
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func parseInnerBaselineTime(t *testing.T, value string) time.Time {
|
||||
t.Helper()
|
||||
loc := time.FixedZone("JST", 9*3600)
|
||||
layouts := []string{
|
||||
"2006-01-02 15:04:05 MST",
|
||||
"2006-01-02 15:04 MST",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02 15:04",
|
||||
}
|
||||
var err error
|
||||
for _, layout := range layouts {
|
||||
when, parseErr := time.ParseInLocation(layout, value, loc)
|
||||
if parseErr == nil {
|
||||
return when
|
||||
}
|
||||
err = parseErr
|
||||
}
|
||||
t.Fatalf("parse baseline time %q: %v", value, err)
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func innerBaselineTolerance(event innerBaselineEvent) time.Duration {
|
||||
switch event.Kind {
|
||||
case "IC", "SC":
|
||||
// 2197 年附近双方 ΔT 外推已差约 2 分钟(校正了 EarthPI 的 0.00046·T² 项之后,
|
||||
// 三个 2197 样本从 −119.5 s 移到 −121…−126 s),因此与留点同样留 3 分钟余量;
|
||||
// 真实的搜索错误是小时/天级,靠这个容差仍能抓住。
|
||||
// Around 2197 the two ΔT extrapolations already differ by about two minutes (after
|
||||
// correcting the EarthPI 0.00046*T^2 term, three 2197 samples moved from -119.5 s to
|
||||
// -121..-126 s), so conjunctions get the same 3-minute margin as the stations.
|
||||
// Real search errors are hours or days, so this still catches them.
|
||||
return 3 * time.Minute
|
||||
case "P2R", "R2P":
|
||||
// 留是 RA 的极值点:星历模型中 ~1e-6 度/天的变化率差异就会被放大成分钟级时刻差,
|
||||
// 且 JPL 给出的 UT 时刻还叠加了各自的 ΔT 外推(2197 年附近两者已差 ~2 分钟)。
|
||||
// 这里留 3 分钟余量,仍远小于任何真实的搜索错误(小时/天级)。
|
||||
return 3 * time.Minute
|
||||
case "GEE", "GEW":
|
||||
return 90 * time.Minute
|
||||
default:
|
||||
return 2 * time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
func innerEventFuncs(t *testing.T, event innerBaselineEvent) (func(float64) float64, func(float64) float64) {
|
||||
t.Helper()
|
||||
switch event.Planet + ":" + event.Kind {
|
||||
case "Mercury:IC":
|
||||
return LastMercuryInferiorConjunctionInclusive, NextMercuryInferiorConjunctionInclusive
|
||||
case "Mercury:SC":
|
||||
return LastMercurySuperiorConjunctionInclusive, NextMercurySuperiorConjunctionInclusive
|
||||
case "Mercury:P2R":
|
||||
return LastMercuryProgradeToRetrogradeInclusive, NextMercuryProgradeToRetrogradeInclusive
|
||||
case "Mercury:R2P":
|
||||
return LastMercuryRetrogradeToProgradeInclusive, NextMercuryRetrogradeToProgradeInclusive
|
||||
case "Mercury:GEE":
|
||||
return LastMercuryGreatestElongationEastInclusive, NextMercuryGreatestElongationEastInclusive
|
||||
case "Mercury:GEW":
|
||||
return LastMercuryGreatestElongationWestInclusive, NextMercuryGreatestElongationWestInclusive
|
||||
case "Venus:IC":
|
||||
return LastVenusInferiorConjunctionInclusive, NextVenusInferiorConjunctionInclusive
|
||||
case "Venus:SC":
|
||||
return LastVenusSuperiorConjunctionInclusive, NextVenusSuperiorConjunctionInclusive
|
||||
case "Venus:P2R":
|
||||
return LastVenusProgradeToRetrogradeInclusive, NextVenusProgradeToRetrogradeInclusive
|
||||
case "Venus:R2P":
|
||||
return LastVenusRetrogradeToProgradeInclusive, NextVenusRetrogradeToProgradeInclusive
|
||||
case "Venus:GEE":
|
||||
return LastVenusGreatestElongationEastInclusive, NextVenusGreatestElongationEastInclusive
|
||||
case "Venus:GEW":
|
||||
return LastVenusGreatestElongationWestInclusive, NextVenusGreatestElongationWestInclusive
|
||||
default:
|
||||
t.Fatalf("unsupported event %s:%s", event.Planet, event.Kind)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func assertInnerBaselineEvent(t *testing.T, event innerBaselineEvent, lastFn, nextFn func(float64) float64) {
|
||||
t.Helper()
|
||||
when := parseInnerBaselineTime(t, event.VerifiedJST)
|
||||
before := when.Add(-24 * time.Hour)
|
||||
after := when.Add(24 * time.Hour)
|
||||
next := JD2DateByZone(nextFn(toUTJD(before)), when.Location(), false)
|
||||
last := JD2DateByZone(lastFn(toUTJD(after)), when.Location(), false)
|
||||
tolerance := innerBaselineTolerance(event)
|
||||
|
||||
if diff := next.Sub(when); diff < -tolerance || diff > tolerance {
|
||||
t.Fatalf("%s %s next mismatch: got %s want %s tol=%s hint=%s candidate=%s via=%s", event.Planet, event.Kind, next, when, tolerance, event.NAOJHintJST, event.CandidateJST, event.CandidateSource)
|
||||
}
|
||||
if diff := last.Sub(when); diff < -tolerance || diff > tolerance {
|
||||
t.Fatalf("%s %s last mismatch: got %s want %s tol=%s hint=%s candidate=%s via=%s", event.Planet, event.Kind, last, when, tolerance, event.NAOJHintJST, event.CandidateJST, event.CandidateSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInnerPlanetTruthAgainstJPL(t *testing.T) {
|
||||
baseline := loadInnerBaseline(t)
|
||||
for _, event := range baseline.Events {
|
||||
event := event
|
||||
name := strings.Join([]string{event.Planet, event.Kind, event.VerifiedJST}, "_")
|
||||
t.Run(name, func(t *testing.T) {
|
||||
lastFn, nextFn := innerEventFuncs(t, event)
|
||||
assertInnerBaselineEvent(t, event, lastFn, nextFn)
|
||||
})
|
||||
}
|
||||
}
|
||||
+25
-21
@@ -9,10 +9,10 @@ import (
|
||||
var ErrInvalidCivilDate = errors.New("invalid civil date")
|
||||
var timeNow = time.Now
|
||||
|
||||
// Date2JDE 日期转儒略日
|
||||
func Date2JDE(date time.Time) float64 {
|
||||
// Date2JD 日期转儒略日
|
||||
func Date2JD(date time.Time) float64 {
|
||||
day := float64(date.Day()) + float64(date.Hour())/24.0 + float64(date.Minute())/24.0/60.0 + float64(date.Second())/24.0/3600.0 + float64(date.Nanosecond())/1000000000.0/3600.0/24.0
|
||||
return JDECalc(date.Year(), int(date.Month()), day)
|
||||
return JDCalc(date.Year(), int(date.Month()), day)
|
||||
}
|
||||
|
||||
func ValidateCivilDate(year, month int, day float64) error {
|
||||
@@ -68,12 +68,10 @@ func isCivilLeapYear(year, month int, day float64) bool {
|
||||
return year%4 == 0
|
||||
}
|
||||
|
||||
/*
|
||||
@name: 儒略日计算
|
||||
@dec: 计算给定时间的儒略日,1582年改力后为格里高利历,之前为儒略历
|
||||
@ 请注意,传入的时间在天文计算中一般为力学时,应当注意和世界时的转化
|
||||
*/
|
||||
func JDECalc(year, month int, day float64) float64 {
|
||||
// JDCalc 由公历年月日(day 可含小数)计算儒略日 / Julian day from a civil year, month and fractional day.
|
||||
// 1582 年 10 月 15 日起按格里高利历,之前按儒略历;日期非法时返回 NaN。
|
||||
// Gregorian from 1582-10-15 onward and Julian before it; an invalid civil date yields NaN.
|
||||
func JDCalc(year, month int, day float64) float64 {
|
||||
if err := ValidateCivilDate(year, month, day); err != nil {
|
||||
return math.NaN()
|
||||
}
|
||||
@@ -92,17 +90,20 @@ func JDECalc(year, month int, day float64) float64 {
|
||||
return (math.Floor(365.25*(float64(year)+4716.0)) + math.Floor(30.6001*float64(month+1)) + day + float64(gregorianCorrection) - 1524.5)
|
||||
}
|
||||
|
||||
/*
|
||||
@name: 获得当前儒略日时间:当地世界时,非格林尼治时间
|
||||
*/
|
||||
func GetNowJDE() (nowJDE float64) {
|
||||
// GetNowJD 按当前时区的日历字段取儒略日 / Julian day from the current clock's calendar fields.
|
||||
// 读的是当前时区的年月日时分秒,不做时区归算。
|
||||
// It reads the calendar fields in the current location without any zone conversion.
|
||||
func GetNowJD() (nowJD float64) {
|
||||
now := timeNow()
|
||||
dayFraction := float64(now.Second())/3600.0/24.0 + float64(now.Minute())/60.0/24.0 + float64(now.Hour())/24.0
|
||||
nowJDE = JDECalc(now.Year(), int(now.Month()), float64(now.Day())+dayFraction)
|
||||
nowJD = JDCalc(now.Year(), int(now.Month()), float64(now.Day())+dayFraction)
|
||||
return
|
||||
}
|
||||
|
||||
func JDE2Date(jd float64) time.Time {
|
||||
// JD2Date 儒略日转 Local 时区的时刻 / Local-zone instant from a Julian day.
|
||||
// 儒略日的日历字段按 Local 时区解释,1582 年 10 月 15 日之前按儒略历。
|
||||
// The calendar fields are read in the Local zone, and dates before 1582-10-15 are read in the Julian calendar.
|
||||
func JD2Date(jd float64) time.Time {
|
||||
jd = jd + 0.5
|
||||
z := float64(int(jd))
|
||||
f := jd - z
|
||||
@@ -137,12 +138,12 @@ func JDE2Date(jd float64) time.Time {
|
||||
return time.Unix(dates.Unix()+int64(tms), int64((tms-math.Floor(tms))*1000000000))
|
||||
}
|
||||
|
||||
// JDE2DateByZone JDE(儒略日)转日期
|
||||
// JD2DateByZone 儒略日转日期
|
||||
// jd: 儒略日
|
||||
// tz: 目标时区
|
||||
// byZone: (true: 传入的儒略日视为目标时区当地时间的儒略日,false: 传入的儒略日视为UTC时间的儒略日)
|
||||
// 回参:转换后的日期,时区始终为目标时区
|
||||
func JDE2DateByZone(jd float64, tz *time.Location, byZone bool) time.Time {
|
||||
func JD2DateByZone(jd float64, tz *time.Location, byZone bool) time.Time {
|
||||
jd = jd + 0.5
|
||||
z := float64(int(jd))
|
||||
f := jd - z
|
||||
@@ -172,10 +173,13 @@ func JDE2DateByZone(jd float64, tz *time.Location, byZone bool) time.Time {
|
||||
}
|
||||
tms := (days - math.Floor(days)) * 24 * 3600
|
||||
days = math.Floor(days)
|
||||
var transTz = tz
|
||||
date := time.Date(int(years), time.Month(int(months)), int(days), 0, 0, 0, 0, time.UTC).
|
||||
Add(time.Duration(int64(1000000000 * tms)))
|
||||
if !byZone {
|
||||
transTz = time.UTC
|
||||
return date.In(tz)
|
||||
}
|
||||
return time.Date(int(years), time.Month(int(months)), int(days), 0, 0, 0, 0, transTz).
|
||||
Add(time.Duration(int64(1000000000 * tms))).In(tz)
|
||||
// 当地 JD 的小数部分是钟表读数,不能按夏令时午夜后的实际时长累加。
|
||||
year, month, day := date.Date()
|
||||
hour, minute, second := date.Clock()
|
||||
return time.Date(year, month, day, hour, minute, second, date.Nanosecond(), tz)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGetNowJDEUsesSingleTimestamp(t *testing.T) {
|
||||
func TestGetNowJDUsesSingleTimestamp(t *testing.T) {
|
||||
oldTimeNow := timeNow
|
||||
defer func() {
|
||||
timeNow = oldTimeNow
|
||||
@@ -23,12 +23,12 @@ func TestGetNowJDEUsesSingleTimestamp(t *testing.T) {
|
||||
return second
|
||||
}
|
||||
|
||||
got := GetNowJDE()
|
||||
want := Date2JDE(first)
|
||||
got := GetNowJD()
|
||||
want := Date2JD(first)
|
||||
if calls != 1 {
|
||||
t.Fatalf("GetNowJDE should read current time once, got %d calls", calls)
|
||||
t.Fatalf("GetNowJD should read current time once, got %d calls", calls)
|
||||
}
|
||||
if math.Float64bits(got) != math.Float64bits(want) {
|
||||
t.Fatalf("GetNowJDE mismatch: got %.15f want %.15f", got, want)
|
||||
t.Fatalf("GetNowJD mismatch: got %.15f want %.15f", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLocalJulianDayAcrossDST(t *testing.T) {
|
||||
for _, name := range []string{"America/New_York", "Australia/Lord_Howe"} {
|
||||
loc, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, md := range [][2]int{{3, 8}, {11, 1}, {4, 5}, {10, 4}} {
|
||||
for _, hour := range []int{0, 3, 12, 23} {
|
||||
want := time.Date(2026, time.Month(md[0]), md[1], hour, 17, 23, 125000000, loc)
|
||||
got := JD2DateByZone(Date2JD(want), loc, true)
|
||||
if delta := got.Sub(want); delta < -100*time.Microsecond || delta > 100*time.Microsecond {
|
||||
t.Errorf("local %s: got %s, delta=%s", want, got, delta)
|
||||
}
|
||||
got = JD2DateByZone(Date2JD(want.UTC()), loc, false)
|
||||
if delta := got.Sub(want); delta < -100*time.Microsecond || delta > 100*time.Microsecond {
|
||||
t.Errorf("UTC %s: got %s, delta=%s", want, got, delta)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
-107
@@ -7,79 +7,79 @@ import (
|
||||
. "b612.me/astro/tools"
|
||||
)
|
||||
|
||||
func JupiterL(jd float64) float64 {
|
||||
return planet.WherePlanet(4, 0, jd)
|
||||
func JupiterL(jde float64) float64 {
|
||||
return planet.WherePlanet(4, 0, jde)
|
||||
}
|
||||
|
||||
func JupiterB(jd float64) float64 {
|
||||
return planet.WherePlanet(4, 1, jd)
|
||||
func JupiterB(jde float64) float64 {
|
||||
return planet.WherePlanet(4, 1, jde)
|
||||
}
|
||||
func JupiterR(jd float64) float64 {
|
||||
return planet.WherePlanet(4, 2, jd)
|
||||
func JupiterR(jde float64) float64 {
|
||||
return planet.WherePlanet(4, 2, jde)
|
||||
}
|
||||
func AJupiterX(jd float64) float64 {
|
||||
l := JupiterL(jd)
|
||||
b := JupiterB(jd)
|
||||
r := JupiterR(jd)
|
||||
el := planet.WherePlanet(-1, 0, jd)
|
||||
eb := planet.WherePlanet(-1, 1, jd)
|
||||
er := planet.WherePlanet(-1, 2, jd)
|
||||
func AJupiterX(jde float64) float64 {
|
||||
l := JupiterL(jde)
|
||||
b := JupiterB(jde)
|
||||
r := JupiterR(jde)
|
||||
el := planet.WherePlanet(-1, 0, jde)
|
||||
eb := planet.WherePlanet(-1, 1, jde)
|
||||
er := planet.WherePlanet(-1, 2, jde)
|
||||
x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el)
|
||||
return x
|
||||
}
|
||||
|
||||
func AJupiterY(jd float64) float64 {
|
||||
func AJupiterY(jde float64) float64 {
|
||||
|
||||
l := JupiterL(jd)
|
||||
b := JupiterB(jd)
|
||||
r := JupiterR(jd)
|
||||
el := planet.WherePlanet(-1, 0, jd)
|
||||
eb := planet.WherePlanet(-1, 1, jd)
|
||||
er := planet.WherePlanet(-1, 2, jd)
|
||||
l := JupiterL(jde)
|
||||
b := JupiterB(jde)
|
||||
r := JupiterR(jde)
|
||||
el := planet.WherePlanet(-1, 0, jde)
|
||||
eb := planet.WherePlanet(-1, 1, jde)
|
||||
er := planet.WherePlanet(-1, 2, jde)
|
||||
y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el)
|
||||
return y
|
||||
}
|
||||
func AJupiterZ(jd float64) float64 {
|
||||
//l := JupiterL(jd)
|
||||
b := JupiterB(jd)
|
||||
r := JupiterR(jd)
|
||||
// el := planet.WherePlanet(-1, 0, jd)
|
||||
eb := planet.WherePlanet(-1, 1, jd)
|
||||
er := planet.WherePlanet(-1, 2, jd)
|
||||
func AJupiterZ(jde float64) float64 {
|
||||
//l := JupiterL(jde)
|
||||
b := JupiterB(jde)
|
||||
r := JupiterR(jde)
|
||||
// el := planet.WherePlanet(-1, 0, jde)
|
||||
eb := planet.WherePlanet(-1, 1, jde)
|
||||
er := planet.WherePlanet(-1, 2, jde)
|
||||
z := r*Sin(b) - er*Sin(eb)
|
||||
return z
|
||||
}
|
||||
|
||||
func AJupiterXYZ(jd float64) (float64, float64, float64) {
|
||||
l := JupiterL(jd)
|
||||
b := JupiterB(jd)
|
||||
r := JupiterR(jd)
|
||||
el := planet.WherePlanet(-1, 0, jd)
|
||||
eb := planet.WherePlanet(-1, 1, jd)
|
||||
er := planet.WherePlanet(-1, 2, jd)
|
||||
func AJupiterXYZ(jde float64) (float64, float64, float64) {
|
||||
l := JupiterL(jde)
|
||||
b := JupiterB(jde)
|
||||
r := JupiterR(jde)
|
||||
el := planet.WherePlanet(-1, 0, jde)
|
||||
eb := planet.WherePlanet(-1, 1, jde)
|
||||
er := planet.WherePlanet(-1, 2, jde)
|
||||
x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el)
|
||||
y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el)
|
||||
z := r*Sin(b) - er*Sin(eb)
|
||||
return x, y, z
|
||||
}
|
||||
|
||||
func JupiterApparentRa(jd float64) float64 {
|
||||
lo, bo := JupiterApparentLoBo(jd)
|
||||
eps := TrueObliquity(jd)
|
||||
func JupiterApparentRa(jde float64) float64 {
|
||||
lo, bo := JupiterApparentLoBo(jde)
|
||||
eps := TrueObliquity(jde)
|
||||
ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo))
|
||||
ra = ra * 180 / math.Pi
|
||||
return Limit360(ra)
|
||||
}
|
||||
func JupiterApparentDec(jd float64) float64 {
|
||||
lo, bo := JupiterApparentLoBo(jd)
|
||||
eps := TrueObliquity(jd)
|
||||
func JupiterApparentDec(jde float64) float64 {
|
||||
lo, bo := JupiterApparentLoBo(jde)
|
||||
eps := TrueObliquity(jde)
|
||||
dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo))
|
||||
return dec
|
||||
}
|
||||
|
||||
func JupiterApparentRaDec(jd float64) (float64, float64) {
|
||||
lo, bo := JupiterApparentLoBo(jd)
|
||||
eps := TrueObliquity(jd)
|
||||
func JupiterApparentRaDec(jde float64) (float64, float64) {
|
||||
lo, bo := JupiterApparentLoBo(jde)
|
||||
eps := TrueObliquity(jde)
|
||||
ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo))
|
||||
ra = ra * 180 / math.Pi
|
||||
dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo))
|
||||
@@ -87,71 +87,40 @@ func JupiterApparentRaDec(jd float64) (float64, float64) {
|
||||
}
|
||||
|
||||
func EarthJupiterAway(jd float64) float64 {
|
||||
x, y, z := AJupiterXYZ(jd)
|
||||
to := math.Sqrt(x*x + y*y + z*z)
|
||||
return to
|
||||
return planetEarthAwayExplicitN(4, jd, -1)
|
||||
}
|
||||
|
||||
func JupiterApparentLo(jd float64) float64 {
|
||||
x, y, z := AJupiterXYZ(jd)
|
||||
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
|
||||
x, y, z = AJupiterXYZ(jd - to)
|
||||
lo := math.Atan2(y, x)
|
||||
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
|
||||
lo = lo * 180 / math.Pi
|
||||
bo = bo * 180 / math.Pi
|
||||
lo = Limit360(lo)
|
||||
//lo-=GXCLo(lo,bo,jd)/3600;
|
||||
//bo+=GXCBo(lo,bo,jd);
|
||||
lo += Nutation2000Bi(jd)
|
||||
return lo
|
||||
geo, _ := planetApparentGeocentricPositionN(4, jd, -1)
|
||||
return geo.lo
|
||||
}
|
||||
|
||||
func JupiterApparentBo(jd float64) float64 {
|
||||
x, y, z := AJupiterXYZ(jd)
|
||||
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
|
||||
x, y, z = AJupiterXYZ(jd - to)
|
||||
//lo := math.Atan2(y, x)
|
||||
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
|
||||
//lo = lo * 180 / math.Pi
|
||||
bo = bo * 180 / math.Pi
|
||||
//lo+=GXCLo(lo,bo,jd);
|
||||
//bo+=GXCBo(lo,bo,jd)/3600;
|
||||
//lo+=Nutation2000Bi(jd);
|
||||
return bo
|
||||
geo, _ := planetApparentGeocentricPositionN(4, jd, -1)
|
||||
return geo.bo
|
||||
}
|
||||
|
||||
func JupiterApparentLoBo(jd float64) (float64, float64) {
|
||||
x, y, z := AJupiterXYZ(jd)
|
||||
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
|
||||
x, y, z = AJupiterXYZ(jd - to)
|
||||
lo := math.Atan2(y, x)
|
||||
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
|
||||
lo = lo * 180 / math.Pi
|
||||
bo = bo * 180 / math.Pi
|
||||
lo = Limit360(lo)
|
||||
//lo-=GXCLo(lo,bo,jd)/3600;
|
||||
//bo+=GXCBo(lo,bo,jd);
|
||||
lo += Nutation2000Bi(jd)
|
||||
return lo, bo
|
||||
geo, _ := planetApparentGeocentricPositionN(4, jd, -1)
|
||||
return geo.lo, geo.bo
|
||||
}
|
||||
|
||||
func JupiterMag(jd float64) float64 {
|
||||
sunDistance := JupiterR(jd)
|
||||
earthDistance := EarthJupiterAway(jd)
|
||||
earthSunDistance := planet.WherePlanet(-1, 2, jd)
|
||||
func JupiterMag(jde float64) float64 {
|
||||
sunDistance := JupiterR(jde)
|
||||
earthDistance := EarthJupiterAway(jde)
|
||||
earthSunDistance := planet.WherePlanet(-1, 2, jde)
|
||||
i := (sunDistance*sunDistance + earthDistance*earthDistance - earthSunDistance*earthSunDistance) / (2 * sunDistance * earthDistance)
|
||||
i = ArcCos(i)
|
||||
mag := -9.40 + 5*math.Log10(sunDistance*earthDistance) + 0.0005*i
|
||||
return FloatRound(mag, 2)
|
||||
}
|
||||
|
||||
func JupiterHeight(jde, lon, lat, timezone float64) float64 {
|
||||
func JupiterHeight(localJD, lon, lat, timezone float64) float64 {
|
||||
// 转换为世界时
|
||||
utcJde := jde - timezone/24.0
|
||||
utcJD := localJD - timezone/24.0
|
||||
// 计算视恒星时
|
||||
ra, dec := JupiterApparentRaDec(TD2UT(utcJde, true))
|
||||
st := Limit360(ApparentSiderealTime(utcJde)*15 + lon)
|
||||
ra, dec := JupiterApparentRaDec(UTC2TT(utcJD))
|
||||
st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon)
|
||||
// 计算时角
|
||||
hourAngle := Limit360(st - ra)
|
||||
// 高度角、时角与天球座标三角转换公式
|
||||
@@ -160,12 +129,12 @@ func JupiterHeight(jde, lon, lat, timezone float64) float64 {
|
||||
return ArcSin(sinHeight)
|
||||
}
|
||||
|
||||
func JupiterAzimuth(jde, lon, lat, timezone float64) float64 {
|
||||
func JupiterAzimuth(localJD, lon, lat, timezone float64) float64 {
|
||||
// 转换为世界时
|
||||
utcJde := jde - timezone/24.0
|
||||
utcJD := localJD - timezone/24.0
|
||||
// 计算视恒星时
|
||||
ra, dec := JupiterApparentRaDec(TD2UT(utcJde, true))
|
||||
st := Limit360(ApparentSiderealTime(utcJde)*15 + lon)
|
||||
ra, dec := JupiterApparentRaDec(UTC2TT(utcJD))
|
||||
st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon)
|
||||
// 计算时角
|
||||
hourAngle := Limit360(st - ra)
|
||||
// 三角转换公式
|
||||
@@ -184,34 +153,34 @@ func JupiterAzimuth(jde, lon, lat, timezone float64) float64 {
|
||||
}
|
||||
|
||||
func JupiterHourAngle(jd, lon, timezone float64) float64 {
|
||||
siderealLongitude := Limit360(ApparentSiderealTime(jd-timezone/24)*15 + lon)
|
||||
hourAngle := siderealLongitude - JupiterApparentRa(TD2UT(jd-timezone/24.0, true))
|
||||
siderealLongitude := Limit360(ApparentSiderealTime(UTC2UT1(jd-timezone/24))*15 + lon)
|
||||
hourAngle := siderealLongitude - JupiterApparentRa(UTC2TT(jd-timezone/24.0))
|
||||
if hourAngle < 0 {
|
||||
hourAngle += 360
|
||||
}
|
||||
return hourAngle
|
||||
}
|
||||
|
||||
func JupiterCulminationTime(jde, lon, timezone float64) float64 {
|
||||
//jde 世界时,非力学时,当地时区 0时,无需转换力学时
|
||||
func JupiterCulminationTime(localJD, lon, timezone float64) float64 {
|
||||
// localJD 是本地民用日锚点(当地 0 时),不是力学时。
|
||||
//ra,dec 瞬时天球座标,非J2000等时间天球坐标
|
||||
jde = math.Floor(jde) + 0.5
|
||||
estimateJD := jde + Limit360(360-JupiterHourAngle(jde, lon, timezone))/15.0/24.0*0.99726851851851851851
|
||||
normalizedHourAngle := func(jde, lon, timezone float64) float64 {
|
||||
currentHourAngle := JupiterHourAngle(jde, lon, timezone)
|
||||
localJD = math.Floor(localJD) + 0.5
|
||||
estimateJD := localJD + Limit360(360-JupiterHourAngle(localJD, lon, timezone))/15.0/24.0*0.99726851851851851851
|
||||
normalizedHourAngle := func(localJD, lon, timezone float64) float64 {
|
||||
currentHourAngle := JupiterHourAngle(localJD, lon, timezone)
|
||||
if currentHourAngle < 180 {
|
||||
currentHourAngle += 360
|
||||
}
|
||||
return currentHourAngle
|
||||
}
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
var ok bool
|
||||
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
|
||||
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
|
||||
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
|
||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
||||
break
|
||||
}
|
||||
return hourAngleDelta / hourAngleSlope
|
||||
})
|
||||
if !ok {
|
||||
return math.NaN()
|
||||
}
|
||||
return estimateJD
|
||||
}
|
||||
|
||||
+160
-83
@@ -40,8 +40,33 @@ func jupiterSunLongitudeDeltaN(jde, degree float64, filter bool, n int) float64
|
||||
return sub
|
||||
}
|
||||
|
||||
func jupiterRADerivative(jde, delta float64) float64 {
|
||||
sub := JupiterApparentRa(jde+delta) - JupiterApparentRa(jde-delta)
|
||||
if sub > 180 {
|
||||
sub -= 360
|
||||
}
|
||||
if sub < -180 {
|
||||
sub += 360
|
||||
}
|
||||
return sub / (2 * delta)
|
||||
}
|
||||
|
||||
func jupiterRADerivativeN(jde, delta float64, n int) float64 {
|
||||
sub := JupiterApparentRaN(jde+delta, n) - JupiterApparentRaN(jde-delta, n)
|
||||
if sub > 180 {
|
||||
sub -= 360
|
||||
}
|
||||
if sub < -180 {
|
||||
sub += 360
|
||||
}
|
||||
return sub / (2 * delta)
|
||||
}
|
||||
|
||||
func jupiterConjunctionFull(jde, degree float64, next uint8) float64 {
|
||||
//0=last 1=next
|
||||
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
|
||||
return math.NaN()
|
||||
}
|
||||
daysPerDegree := JUPITER_S_PERIOD / 360
|
||||
currentDelta := jupiterSunLongitudeDelta(jde, degree, false)
|
||||
if next == 0 {
|
||||
@@ -49,21 +74,30 @@ func jupiterConjunctionFull(jde, degree float64, next uint8) float64 {
|
||||
} else {
|
||||
jde += daysPerDegree * currentDelta
|
||||
}
|
||||
estimateJD := jde
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
longitudeDelta := jupiterSunLongitudeDelta(prevJD, degree, true)
|
||||
longitudeSlope := (jupiterSunLongitudeDelta(prevJD+0.000005, degree, true) - jupiterSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
||||
estimateJDE := jde
|
||||
converged := false
|
||||
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||
prevJDE := estimateJDE
|
||||
longitudeDelta := jupiterSunLongitudeDelta(prevJDE, degree, true)
|
||||
longitudeSlope := (jupiterSunLongitudeDelta(prevJDE+0.000005, degree, true) - jupiterSunLongitudeDelta(prevJDE-0.000005, degree, true)) / 0.00001
|
||||
nextJD := prevJDE - longitudeDelta/longitudeSlope
|
||||
estimateJDE = nextJD
|
||||
if math.Abs(nextJD-prevJDE) <= 0.00001 {
|
||||
converged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return TD2UT(estimateJD, false)
|
||||
if !converged {
|
||||
return math.NaN()
|
||||
}
|
||||
return TT2UTC(estimateJDE)
|
||||
}
|
||||
|
||||
func jupiterConjunction(jde, degree float64, next uint8) float64 {
|
||||
//0=last 1=next
|
||||
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
|
||||
return math.NaN()
|
||||
}
|
||||
daysPerDegree := JUPITER_S_PERIOD / 360
|
||||
currentDelta := jupiterSunLongitudeDelta(jde, degree, false)
|
||||
if next == 0 {
|
||||
@@ -71,136 +105,179 @@ func jupiterConjunction(jde, degree float64, next uint8) float64 {
|
||||
} else {
|
||||
jde += daysPerDegree * currentDelta
|
||||
}
|
||||
estimateJD := jde
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
longitudeDelta := jupiterSunLongitudeDeltaN(prevJD, degree, true, jupiterEventSearchN)
|
||||
longitudeSlope := (jupiterSunLongitudeDeltaN(prevJD+0.000005, degree, true, jupiterEventSearchN) - jupiterSunLongitudeDeltaN(prevJD-0.000005, degree, true, jupiterEventSearchN)) / 0.00001
|
||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
||||
if math.Abs(estimateJD-prevJD) <= jupiterPhaseCoarseTolerance {
|
||||
estimateJDE := jde
|
||||
converged := false
|
||||
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||
prevJDE := estimateJDE
|
||||
longitudeDelta := jupiterSunLongitudeDeltaN(prevJDE, degree, true, jupiterEventSearchN)
|
||||
longitudeSlope := (jupiterSunLongitudeDeltaN(prevJDE+0.000005, degree, true, jupiterEventSearchN) - jupiterSunLongitudeDeltaN(prevJDE-0.000005, degree, true, jupiterEventSearchN)) / 0.00001
|
||||
nextJD := prevJDE - longitudeDelta/longitudeSlope
|
||||
estimateJDE = nextJD
|
||||
if math.Abs(nextJD-prevJDE) <= jupiterPhaseCoarseTolerance {
|
||||
converged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
longitudeDelta := jupiterSunLongitudeDelta(prevJD, degree, true)
|
||||
longitudeSlope := (jupiterSunLongitudeDelta(prevJD+0.000005, degree, true) - jupiterSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
||||
if !converged {
|
||||
return math.NaN()
|
||||
}
|
||||
converged = false
|
||||
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||
prevJDE := estimateJDE
|
||||
longitudeDelta := jupiterSunLongitudeDelta(prevJDE, degree, true)
|
||||
longitudeSlope := (jupiterSunLongitudeDelta(prevJDE+0.000005, degree, true) - jupiterSunLongitudeDelta(prevJDE-0.000005, degree, true)) / 0.00001
|
||||
nextJD := prevJDE - longitudeDelta/longitudeSlope
|
||||
estimateJDE = nextJD
|
||||
if math.Abs(nextJD-prevJDE) <= 0.00001 {
|
||||
converged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return TD2UT(estimateJD, false)
|
||||
if !converged {
|
||||
return math.NaN()
|
||||
}
|
||||
return TT2UTC(estimateJDE)
|
||||
}
|
||||
|
||||
func LastJupiterConjunction(jde float64) float64 {
|
||||
return jupiterConjunction(jde, 0, 0)
|
||||
return inclusiveLastPhaseEvent(jde, 0, jupiterConjunction)
|
||||
}
|
||||
|
||||
func NextJupiterConjunction(jde float64) float64 {
|
||||
return jupiterConjunction(jde, 0, 1)
|
||||
return inclusiveNextPhaseEvent(jde, 0, jupiterConjunction)
|
||||
}
|
||||
|
||||
func LastJupiterOpposition(jde float64) float64 {
|
||||
return jupiterConjunction(jde, 180, 0)
|
||||
return inclusiveLastPhaseEvent(jde, 180, jupiterConjunction)
|
||||
}
|
||||
|
||||
func NextJupiterOpposition(jde float64) float64 {
|
||||
return jupiterConjunction(jde, 180, 1)
|
||||
return inclusiveNextPhaseEvent(jde, 180, jupiterConjunction)
|
||||
}
|
||||
|
||||
func NextJupiterEasternQuadrature(jde float64) float64 {
|
||||
return jupiterConjunction(jde, 90, 1)
|
||||
return inclusiveNextPhaseEvent(jde, 90, jupiterConjunction)
|
||||
}
|
||||
|
||||
func LastJupiterEasternQuadrature(jde float64) float64 {
|
||||
return jupiterConjunction(jde, 90, 0)
|
||||
return inclusiveLastPhaseEvent(jde, 90, jupiterConjunction)
|
||||
}
|
||||
|
||||
func NextJupiterWesternQuadrature(jde float64) float64 {
|
||||
return jupiterConjunction(jde, 270, 1)
|
||||
return inclusiveNextPhaseEvent(jde, 270, jupiterConjunction)
|
||||
}
|
||||
|
||||
func LastJupiterWesternQuadrature(jde float64) float64 {
|
||||
return jupiterConjunction(jde, 270, 0)
|
||||
return inclusiveLastPhaseEvent(jde, 270, jupiterConjunction)
|
||||
}
|
||||
|
||||
func jupiterRetrograde(jde float64, searchBeforeOpposition bool) float64 {
|
||||
//0=last 1=next
|
||||
raRate := func(jde float64, delta float64) float64 {
|
||||
sub := JupiterApparentRa(jde+delta) - JupiterApparentRa(jde-delta)
|
||||
if sub > 180 {
|
||||
sub -= 360
|
||||
}
|
||||
if sub < -180 {
|
||||
sub += 360
|
||||
}
|
||||
return sub / (2 * delta)
|
||||
func jupiterRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool) float64 {
|
||||
if !isFiniteFloat(oppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
jde = jupiterConjunctionFull(jde, 180, 1)
|
||||
oppositionTT := UTC2TT(oppositionJD)
|
||||
startTT := oppositionTT
|
||||
endTT := oppositionTT
|
||||
if searchBeforeOpposition {
|
||||
jde -= 60
|
||||
easternQuadratureUT := jupiterConjunction(oppositionTT, 90, 0)
|
||||
startTT = UTC2TT(easternQuadratureUT)
|
||||
} else {
|
||||
jde += 60
|
||||
westernQuadratureUT := jupiterConjunction(oppositionTT, 270, 1)
|
||||
endTT = UTC2TT(westernQuadratureUT)
|
||||
}
|
||||
for {
|
||||
currentRate := raRate(jde, 1.0/86400.0)
|
||||
if math.Abs(currentRate) > 0.55 {
|
||||
jde += 2
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
estimateJD := jde
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
rateValue := raRate(prevJD, 2.0/86400.0)
|
||||
rateSlope := (raRate(prevJD+15.0/86400.0, 2.0/86400.0) - raRate(prevJD-15.0/86400.0, 2.0/86400.0)) / (30.0 / 86400.0)
|
||||
estimateJD = prevJD - rateValue/rateSlope
|
||||
if math.Abs(estimateJD-prevJD) <= 30.0/86400.0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
bestJD := eventZeroRefine(estimateJD, 15.0/86400.0, 0.5/86400.0, func(jd float64) float64 {
|
||||
return raRate(jd, 0.5/86400.0)
|
||||
bestJDE := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 {
|
||||
return jupiterRADerivativeN(jd, stationDerivativeStepDay, jupiterEventSearchN)
|
||||
}, func(jd float64) float64 {
|
||||
return jupiterRADerivative(jd, stationDerivativeStepDay)
|
||||
})
|
||||
return TD2UT(bestJD, false)
|
||||
return TT2UTC(bestJDE)
|
||||
}
|
||||
|
||||
func NextJupiterRetrogradeToPrograde(jde float64) float64 {
|
||||
date := jupiterRetrograde(jde, false)
|
||||
if date < jde {
|
||||
oppositionJD := jupiterConjunctionFull(jde, 180, 1)
|
||||
return jupiterRetrograde(oppositionJD+10, false)
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := jupiterConjunctionFull(jde, 180, 0)
|
||||
date := jupiterRetrogradeAroundOpposition(lastOppositionJD, false)
|
||||
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
nextOppositionJD := jupiterConjunctionFull(jde, 180, 1)
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = jupiterRetrogradeAroundOpposition(nextOppositionJD, false)
|
||||
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func LastJupiterRetrogradeToPrograde(jde float64) float64 {
|
||||
jde = jupiterConjunctionFull(jde, 180, 0) - 10
|
||||
date := jupiterRetrograde(jde, false)
|
||||
if date > jde {
|
||||
oppositionJD := jupiterConjunctionFull(jde, 180, 0)
|
||||
return jupiterRetrograde(oppositionJD-10, false)
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := jupiterConjunctionFull(jde, 180, 0)
|
||||
date := jupiterRetrogradeAroundOpposition(lastOppositionJD, false)
|
||||
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(lastOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
previousOppositionJD := jupiterConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0)
|
||||
if !isFiniteFloat(previousOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = jupiterRetrogradeAroundOpposition(previousOppositionJD, false)
|
||||
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func NextJupiterProgradeToRetrograde(jde float64) float64 {
|
||||
date := jupiterRetrograde(jde, true)
|
||||
if date < jde {
|
||||
oppositionJD := jupiterConjunctionFull(jde, 180, 1)
|
||||
return jupiterRetrograde(oppositionJD+10, true)
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
nextOppositionJD := jupiterConjunctionFull(jde, 180, 1)
|
||||
date := jupiterRetrogradeAroundOpposition(nextOppositionJD, true)
|
||||
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
followingOppositionJD := jupiterConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1)
|
||||
if !isFiniteFloat(followingOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = jupiterRetrogradeAroundOpposition(followingOppositionJD, true)
|
||||
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func LastJupiterProgradeToRetrograde(jde float64) float64 {
|
||||
jde = jupiterConjunctionFull(jde, 180, 0) - 10
|
||||
date := jupiterRetrograde(jde, true)
|
||||
if date > jde {
|
||||
oppositionJD := jupiterConjunctionFull(jde, 180, 0)
|
||||
return jupiterRetrograde(oppositionJD-10, true)
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
nextOppositionJD := jupiterConjunctionFull(jde, 180, 1)
|
||||
date := jupiterRetrogradeAroundOpposition(nextOppositionJD, true)
|
||||
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := jupiterConjunctionFull(jde, 180, 0)
|
||||
if !isFiniteFloat(lastOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = jupiterRetrogradeAroundOpposition(lastOppositionJD, true)
|
||||
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
+18
-18
@@ -25,14 +25,14 @@ type JupiterCentralMeridianInfo struct {
|
||||
}
|
||||
|
||||
// JupiterCentralMeridians 木星 System I/II/III 中央经线 / Jupiter System I/II/III central meridians.
|
||||
func JupiterCentralMeridians(jd float64) JupiterCentralMeridianInfo {
|
||||
return JupiterCentralMeridiansN(jd, -1)
|
||||
func JupiterCentralMeridians(jde float64) JupiterCentralMeridianInfo {
|
||||
return JupiterCentralMeridiansN(jde, -1)
|
||||
}
|
||||
|
||||
// JupiterCentralMeridiansN 木星 System I/II/III 中央经线(截断版) / truncated Jupiter System I/II/III central meridians.
|
||||
func JupiterCentralMeridiansN(jd float64, n int) JupiterCentralMeridianInfo {
|
||||
observations := jupiterPhysicalObservationsN(jd, n)
|
||||
physical := JupiterPhysicalN(jd, n)
|
||||
func JupiterCentralMeridiansN(jde float64, n int) JupiterCentralMeridianInfo {
|
||||
observations := jupiterPhysicalObservationsN(jde, n)
|
||||
physical := JupiterPhysicalN(jde, n)
|
||||
return JupiterCentralMeridianInfo{
|
||||
SystemI: observations.SystemI,
|
||||
SystemII: observations.SystemII,
|
||||
@@ -41,18 +41,18 @@ func JupiterCentralMeridiansN(jd float64, n int) JupiterCentralMeridianInfo {
|
||||
}
|
||||
|
||||
// JupiterDSDE 木星 DS/DE 行星中心赤纬 / Jupiter planetocentric declinations of Sun and Earth.
|
||||
func JupiterDSDE(jd float64) (ds, de float64) {
|
||||
return JupiterDSDEN(jd, -1)
|
||||
func JupiterDSDE(jde float64) (ds, de float64) {
|
||||
return JupiterDSDEN(jde, -1)
|
||||
}
|
||||
|
||||
// JupiterDSDEN 木星 DS/DE 行星中心赤纬(截断版) / truncated Jupiter planetocentric declinations of Sun and Earth.
|
||||
func JupiterDSDEN(jd float64, n int) (ds, de float64) {
|
||||
observations := jupiterPhysicalObservationsN(jd, n)
|
||||
func JupiterDSDEN(jde float64, n int) (ds, de float64) {
|
||||
observations := jupiterPhysicalObservationsN(jde, n)
|
||||
return observations.DS, observations.DE
|
||||
}
|
||||
|
||||
func jupiterPhysicalObservationsN(jd float64, n int) jupiterPhysicalObservationInfo {
|
||||
days := jd - 2433282.5
|
||||
func jupiterPhysicalObservationsN(jde float64, n int) jupiterPhysicalObservationInfo {
|
||||
days := jde - 2433282.5
|
||||
julianCentury := days / 36525.0
|
||||
|
||||
poleRA := (268.0 + 0.1061*julianCentury) * rad
|
||||
@@ -60,9 +60,9 @@ func jupiterPhysicalObservationsN(jd float64, n int) jupiterPhysicalObservationI
|
||||
w1 := (17.71 + 877.90003539*days) * rad
|
||||
w2 := (16.838 + 870.27003539*days) * rad
|
||||
|
||||
earthLon := planet.WherePlanetN(-1, 0, jd, n)
|
||||
earthLat := planet.WherePlanetN(-1, 1, jd, n)
|
||||
earthRadius := planet.WherePlanetN(-1, 2, jd, n)
|
||||
earthLon := planet.WherePlanetN(-1, 0, jde, n)
|
||||
earthLat := planet.WherePlanetN(-1, 1, jde, n)
|
||||
earthRadius := planet.WherePlanetN(-1, 2, jde, n)
|
||||
|
||||
delta := 4.0
|
||||
var jupiterLon float64
|
||||
@@ -73,16 +73,16 @@ func jupiterPhysicalObservationsN(jd float64, n int) jupiterPhysicalObservationI
|
||||
var z float64
|
||||
for i := 0; i < 2; i++ {
|
||||
lightTimeDays := astronomicalUnitLightTimeDays * delta
|
||||
jupiterLon = planet.WherePlanetN(4, 0, jd-lightTimeDays, n)
|
||||
jupiterLat = planet.WherePlanetN(4, 1, jd-lightTimeDays, n)
|
||||
jupiterRadius = planet.WherePlanetN(4, 2, jd-lightTimeDays, n)
|
||||
jupiterLon = planet.WherePlanetN(4, 0, jde-lightTimeDays, n)
|
||||
jupiterLat = planet.WherePlanetN(4, 1, jde-lightTimeDays, n)
|
||||
jupiterRadius = planet.WherePlanetN(4, 2, jde-lightTimeDays, n)
|
||||
x = jupiterRadius*Cos(jupiterLat)*Cos(jupiterLon) - earthRadius*Cos(earthLat)*Cos(earthLon)
|
||||
y = jupiterRadius*Cos(jupiterLat)*Sin(jupiterLon) - earthRadius*Cos(earthLat)*Sin(earthLon)
|
||||
z = jupiterRadius*Sin(jupiterLat) - earthRadius*Sin(earthLat)
|
||||
delta = math.Sqrt(x*x + y*y + z*z)
|
||||
}
|
||||
|
||||
meanObliquity := EclipticObliquity(jd, false)
|
||||
meanObliquity := EclipticObliquity(jde, false)
|
||||
sinMeanObliquity, cosMeanObliquity := math.Sincos(meanObliquity)
|
||||
sinJupiterLat, cosJupiterLat := math.Sincos(jupiterLat * rad)
|
||||
sinJupiterLon, cosJupiterLon := math.Sincos(jupiterLon * rad)
|
||||
|
||||
@@ -37,14 +37,14 @@ func TestJupiterCentralMeridianSystemIIIMatchesHorizonsBaseline(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("parse sample time %q: %v", sample.InputUTC, err)
|
||||
}
|
||||
jd := TD2UT(Date2JDE(date.UTC()), true)
|
||||
jd := UTC2TT(Date2JD(date.UTC()))
|
||||
got := JupiterCentralMeridians(jd)
|
||||
assertPlanetPhaseClose(t, "Jupiter."+sample.InputUTC+".CMIII", got.SystemIII, sample.SubEarthLongitude, 0.02)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJupiterCentralMeridiansNFullMatchesDefault(t *testing.T) {
|
||||
jd := TD2UT(Date2JDE(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC)), true)
|
||||
jd := UTC2TT(Date2JD(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC)))
|
||||
got := JupiterCentralMeridians(jd)
|
||||
gotN := JupiterCentralMeridiansN(jd, -1)
|
||||
ds, de := JupiterDSDE(jd)
|
||||
@@ -76,7 +76,7 @@ func TestJupiterCentralMeridianAndDSDESampleSweepFiniteAndInRange(t *testing.T)
|
||||
}
|
||||
|
||||
for _, date := range dates {
|
||||
jd := TD2UT(Date2JDE(date.UTC()), true)
|
||||
jd := UTC2TT(Date2JD(date.UTC()))
|
||||
meridians := JupiterCentralMeridians(jd)
|
||||
ds, de := JupiterDSDE(jd)
|
||||
physical := JupiterPhysical(jd)
|
||||
|
||||
@@ -336,25 +336,6 @@ func refineJupiterGalileanShadowContactPair(
|
||||
}, true
|
||||
}
|
||||
|
||||
func refineJupiterGalileanNegativeMetricWindow(
|
||||
seedJD float64,
|
||||
metric func(jd float64) float64,
|
||||
) (float64, float64, bool) {
|
||||
activeJD, activeValue, ok := findJupiterGalileanNegativeMetricSeed(seedJD, metric)
|
||||
if !ok {
|
||||
return math.NaN(), math.NaN(), false
|
||||
}
|
||||
start, ok := refineJupiterGalileanNegativeWindowRoot(activeJD, activeValue, -1, metric)
|
||||
if !ok {
|
||||
return math.NaN(), math.NaN(), false
|
||||
}
|
||||
end, ok := refineJupiterGalileanNegativeWindowRoot(activeJD, activeValue, 1, metric)
|
||||
if !ok {
|
||||
return math.NaN(), math.NaN(), false
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
func findJupiterGalileanNegativeMetricSeed(
|
||||
seedJD float64,
|
||||
metric func(jd float64) float64,
|
||||
@@ -483,8 +464,8 @@ func jupiterGalileanContactGeometryAt(
|
||||
if !isFinite(jd) || satellite < 1 || satellite > 4 || !isValidJupiterGalileanPhenomenonType(phenomenonType) {
|
||||
return jupiterGalileanContactGeometry{}, false
|
||||
}
|
||||
evaluationJD := TD2UT(jd, true)
|
||||
context := newJupiterGalileanObservationContext(evaluationJD)
|
||||
evaluationJDE := UTC2TT(jd)
|
||||
context := newJupiterGalileanObservationContext(evaluationJDE)
|
||||
if context.jupiterDistance == 0 {
|
||||
return jupiterGalileanContactGeometry{}, false
|
||||
}
|
||||
@@ -546,8 +527,8 @@ func jupiterGalileanEclipseSignedDistanceAt(jd float64, satellite int, penumbra
|
||||
if !isFinite(jd) || satellite < 1 || satellite > 4 {
|
||||
return math.NaN(), false
|
||||
}
|
||||
evaluationJD := TD2UT(jd, true)
|
||||
context := newJupiterGalileanObservationContext(evaluationJD)
|
||||
evaluationJDE := UTC2TT(jd)
|
||||
context := newJupiterGalileanObservationContext(evaluationJDE)
|
||||
if context.jupiterDistance == 0 {
|
||||
return math.NaN(), false
|
||||
}
|
||||
@@ -593,18 +574,6 @@ func jupiterPenumbraScale(distanceBehindAU, sunDistanceAU float64) float64 {
|
||||
return 1 + distanceBehindAU*(solarRadiusAU+jupiterRadiusAU)/(sunDistanceAU*jupiterRadiusAU)
|
||||
}
|
||||
|
||||
func jupiterGalileanUmbraRadiusJupiterRadii(satellite int, pathLengthAU, sunDistanceAU float64) float64 {
|
||||
if pathLengthAU <= 0 || sunDistanceAU <= 0 {
|
||||
return math.NaN()
|
||||
}
|
||||
satelliteRadiusAU := jupiterGalileanSatelliteRadiusJupiterRadii(satellite) * jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM
|
||||
umbraRadiusAU := satelliteRadiusAU - pathLengthAU*(solarRadiusAU-satelliteRadiusAU)/sunDistanceAU
|
||||
if umbraRadiusAU <= 0 {
|
||||
return math.NaN()
|
||||
}
|
||||
return umbraRadiusAU / (jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM)
|
||||
}
|
||||
|
||||
func jupiterGalileanPenumbraRadiusJupiterRadii(satellite int, pathLengthAU, sunDistanceAU float64) float64 {
|
||||
if pathLengthAU <= 0 || sunDistanceAU <= 0 {
|
||||
return math.NaN()
|
||||
@@ -621,8 +590,8 @@ func jupiterGalileanShadowLimbMetricAt(jd float64, satellite int, penumbra bool)
|
||||
if !isFinite(jd) || satellite < 1 || satellite > 4 {
|
||||
return math.NaN(), false
|
||||
}
|
||||
evaluationJD := TD2UT(jd, true)
|
||||
context := newJupiterGalileanObservationContext(evaluationJD)
|
||||
evaluationJDE := UTC2TT(jd)
|
||||
context := newJupiterGalileanObservationContext(evaluationJDE)
|
||||
if context.jupiterDistance == 0 {
|
||||
return math.NaN(), false
|
||||
}
|
||||
|
||||
@@ -18,13 +18,13 @@ func TestJupiterGalileanPhenomenonContactEventsAgainstIMCCEBaseline(t *testing.T
|
||||
queryMid := startUTC.Add(endUTC.Sub(startUTC) / 2)
|
||||
phenomenonType := parseBasicGalileanPhenomenonType(t, record.Type)
|
||||
|
||||
event := ClosestJupiterGalileanPhenomenonContactEvent(Date2JDE(queryMid.UTC()), record.Satellite, phenomenonType)
|
||||
event := ClosestJupiterGalileanPhenomenonContactEvent(Date2JD(queryMid.UTC()), record.Satellite, phenomenonType)
|
||||
if !event.Valid {
|
||||
t.Fatalf("%s invalid contact event", record.Label)
|
||||
}
|
||||
|
||||
gotStart := JDE2DateByZone(event.Disappearance.Start, time.UTC, false)
|
||||
gotEnd := JDE2DateByZone(event.Reappearance.Start, time.UTC, false)
|
||||
gotStart := JD2DateByZone(event.Disappearance.Start, time.UTC, false)
|
||||
gotEnd := JD2DateByZone(event.Reappearance.Start, time.UTC, false)
|
||||
startDiff := math.Abs(gotStart.Sub(startUTC).Seconds())
|
||||
endDiff := math.Abs(gotEnd.Sub(endUTC).Seconds())
|
||||
startDurationDiff := math.Abs((event.Disappearance.End-event.Disappearance.Start)*86400 - record.StartDurationMinutes*60)
|
||||
@@ -61,7 +61,7 @@ func TestJupiterGalileanPhenomenonContactEventsAgainstIMCCEBaseline(t *testing.T
|
||||
if !(event.Reappearance.Start <= event.Reappearance.ModelCrossing && event.Reappearance.ModelCrossing <= event.Reappearance.End) {
|
||||
t.Fatalf("%s reappearance ordering invalid", record.Label)
|
||||
}
|
||||
fullEvent := ClosestJupiterGalileanPhenomenonEvent(Date2JDE(queryMid.UTC()), record.Satellite, phenomenonType)
|
||||
fullEvent := ClosestJupiterGalileanPhenomenonEvent(Date2JD(queryMid.UTC()), record.Satellite, phenomenonType)
|
||||
if phenomenonType != JupiterGalileanShadowTransit {
|
||||
if math.Abs(event.Disappearance.ModelCrossing-fullEvent.Start)*86400 > 2 {
|
||||
t.Fatalf("%s disappearance model crossing mismatch", record.Label)
|
||||
|
||||
@@ -39,8 +39,10 @@ type JupiterGalileanPhenomenonEvent struct {
|
||||
}
|
||||
|
||||
type jupiterGalileanMetricSample struct {
|
||||
active bool
|
||||
metric float64
|
||||
active bool
|
||||
metric float64
|
||||
// signed 是该现象相对木星视面中心线的有符号偏移(木星半径),每次过零对应一次现象;未定义时为 NaN。
|
||||
signed float64
|
||||
phenomenon JupiterGalileanPhenomenon
|
||||
}
|
||||
|
||||
@@ -55,18 +57,24 @@ type jupiterGalileanShadowPoint struct {
|
||||
}
|
||||
|
||||
// LastJupiterGalileanPhenomenonEvent 上一次伽利略卫星现象 / previous Galilean-satellite event.
|
||||
//
|
||||
// jd 与返回时刻都是民用儒略日;需要瞬时状态时先用 UTC2TT(jd) 换成 TT。
|
||||
func LastJupiterGalileanPhenomenonEvent(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonEvent {
|
||||
event, _ := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, -1, true)
|
||||
return event
|
||||
}
|
||||
|
||||
// NextJupiterGalileanPhenomenonEvent 下一次伽利略卫星现象 / next Galilean-satellite event.
|
||||
//
|
||||
// jd 与返回时刻都是民用儒略日;需要瞬时状态时先用 UTC2TT(jd) 换成 TT。
|
||||
func NextJupiterGalileanPhenomenonEvent(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonEvent {
|
||||
event, _ := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, 1, false)
|
||||
return event
|
||||
}
|
||||
|
||||
// ClosestJupiterGalileanPhenomenonEvent 最近一次伽利略卫星现象 / closest Galilean-satellite event.
|
||||
//
|
||||
// jd 与返回时刻都是民用儒略日;需要瞬时状态时先用 UTC2TT(jd) 换成 TT。
|
||||
func ClosestJupiterGalileanPhenomenonEvent(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonEvent {
|
||||
last, hasLast := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, -1, true)
|
||||
next, hasNext := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, 1, false)
|
||||
@@ -121,14 +129,11 @@ func searchJupiterGalileanPhenomenonEvent(
|
||||
for i := 2; i <= maxSteps; i++ {
|
||||
nextTime := jd + sign*float64(i)*stepDays
|
||||
nextSample := jupiterGalileanPhenomenonMetricAt(nextTime, satellite, phenomenonType)
|
||||
if isFinite(midSample.metric) &&
|
||||
midSample.metric <= prevSample.metric &&
|
||||
midSample.metric <= nextSample.metric {
|
||||
candidate := refineJupiterGalileanMetricMinimum(prevTime, nextTime, satellite, phenomenonType)
|
||||
event := findJupiterGalileanPhenomenonEventAround(candidate, satellite, phenomenonType)
|
||||
if event.Valid && jupiterGalileanEventMatchesDirection(event.Greatest, jd, direction, includeCurrent) {
|
||||
return event, true
|
||||
}
|
||||
if event, ok := jupiterGalileanIntervalCandidate(
|
||||
prevTime, nextTime, prevSample, midSample, nextSample,
|
||||
jd, satellite, phenomenonType, direction, includeCurrent,
|
||||
); ok {
|
||||
return event, true
|
||||
}
|
||||
prevTime, prevSample = midTime, midSample
|
||||
midTime, midSample = nextTime, nextSample
|
||||
@@ -137,6 +142,132 @@ func searchJupiterGalileanPhenomenonEvent(
|
||||
return invalidJupiterGalileanPhenomenonEvent(), false
|
||||
}
|
||||
|
||||
// jupiterGalileanIntervalCandidate 在粗扫区间内确认现象:度量局部极小,或有符号偏移过零。
|
||||
func jupiterGalileanIntervalCandidate(
|
||||
prevTime, nextTime float64,
|
||||
prevSample, midSample, nextSample jupiterGalileanMetricSample,
|
||||
jd float64,
|
||||
satellite int,
|
||||
phenomenonType JupiterGalileanPhenomenonType,
|
||||
direction int,
|
||||
includeCurrent bool,
|
||||
) (JupiterGalileanPhenomenonEvent, bool) {
|
||||
var best JupiterGalileanPhenomenonEvent
|
||||
found := false
|
||||
consider := func(event JupiterGalileanPhenomenonEvent) {
|
||||
if !event.Valid ||
|
||||
!jupiterGalileanEventMatchesDirection(event.Greatest, jd, direction, includeCurrent) {
|
||||
return
|
||||
}
|
||||
if !found ||
|
||||
(direction > 0 && event.Greatest < best.Greatest) ||
|
||||
(direction < 0 && event.Greatest > best.Greatest) {
|
||||
best, found = event, true
|
||||
}
|
||||
}
|
||||
if isFinite(midSample.metric) &&
|
||||
midSample.metric <= prevSample.metric &&
|
||||
midSample.metric <= nextSample.metric {
|
||||
consider(findJupiterGalileanPhenomenonEventAround(
|
||||
refineJupiterGalileanMetricMinimum(prevTime, nextTime, satellite, phenomenonType),
|
||||
satellite, phenomenonType,
|
||||
))
|
||||
}
|
||||
if jupiterGalileanSignedCrosses(prevSample.signed, nextSample.signed) &&
|
||||
jupiterGalileanNearDisk(prevSample, midSample, nextSample) {
|
||||
crossing := refineJupiterGalileanSignedCrossing(prevTime, nextTime, satellite, phenomenonType)
|
||||
// 过零点只说明"靠近木星",只在它已贴近视面时按事件尺度细扫一次。
|
||||
if sample := jupiterGalileanPhenomenonMetricAt(crossing, satellite, phenomenonType); isFinite(sample.metric) &&
|
||||
sample.metric <= jupiterGalileanCrossingProbeMetric {
|
||||
if event, ok := jupiterGalileanFineScanAround(crossing, satellite, phenomenonType); ok {
|
||||
consider(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
return best, found
|
||||
}
|
||||
|
||||
const (
|
||||
// jupiterGalileanCrossingProbeMetric 是细扫门限(椭球度量,1 为视面边缘)。
|
||||
jupiterGalileanCrossingProbeMetric = 2.5
|
||||
// jupiterGalileanCrossingProbeDays 是过零点两侧的细扫半宽。
|
||||
jupiterGalileanCrossingProbeDays = 15.0 / 1440.0
|
||||
// jupiterGalileanCrossingProbeStepDays 是细扫步长(30 秒),短于最短的擦边事件。
|
||||
jupiterGalileanCrossingProbeStepDays = 0.5 / 1440.0
|
||||
)
|
||||
|
||||
// jupiterGalileanNearDisk 报告区间内是否已有贴近木星视面的采样点。
|
||||
func jupiterGalileanNearDisk(prevSample, midSample, nextSample jupiterGalileanMetricSample) bool {
|
||||
for _, metric := range [3]float64{prevSample.metric, midSample.metric, nextSample.metric} {
|
||||
if isFinite(metric) && metric <= jupiterGalileanCrossingProbeMetric {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// jupiterGalileanFineScanAround 在过零点邻域按事件尺度取样,命中后交给事件求解。
|
||||
func jupiterGalileanFineScanAround(
|
||||
jd float64,
|
||||
satellite int,
|
||||
phenomenonType JupiterGalileanPhenomenonType,
|
||||
) (JupiterGalileanPhenomenonEvent, bool) {
|
||||
// 从过零点向两侧交替外扩,通常几步内就能落进事件窗口。
|
||||
steps := int(math.Round(jupiterGalileanCrossingProbeDays / jupiterGalileanCrossingProbeStepDays))
|
||||
for step := 0; step <= steps; step++ {
|
||||
for _, sign := range [2]float64{1, -1} {
|
||||
if step == 0 && sign < 0 {
|
||||
continue
|
||||
}
|
||||
candidate := jd + sign*float64(step)*jupiterGalileanCrossingProbeStepDays
|
||||
if !jupiterGalileanPhenomenonMetricAt(candidate, satellite, phenomenonType).active {
|
||||
continue
|
||||
}
|
||||
if event := findJupiterGalileanPhenomenonEventAround(candidate, satellite, phenomenonType); event.Valid {
|
||||
return event, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return JupiterGalileanPhenomenonEvent{}, false
|
||||
}
|
||||
|
||||
// jupiterGalileanSignedCrosses 判断两次采样的有符号偏移是否变号(端点为 0 也算)。
|
||||
func jupiterGalileanSignedCrosses(first, second float64) bool {
|
||||
if !isFinite(first) || !isFinite(second) {
|
||||
return false
|
||||
}
|
||||
if first == 0 || second == 0 {
|
||||
return true
|
||||
}
|
||||
return (first < 0) != (second < 0)
|
||||
}
|
||||
|
||||
// refineJupiterGalileanSignedCrossing 用二分把过零时刻收敛到事件容差内。
|
||||
func refineJupiterGalileanSignedCrossing(
|
||||
left, right float64,
|
||||
satellite int,
|
||||
phenomenonType JupiterGalileanPhenomenonType,
|
||||
) float64 {
|
||||
leftValue := jupiterGalileanPhenomenonMetricAt(left, satellite, phenomenonType).signed
|
||||
rightValue := jupiterGalileanPhenomenonMetricAt(right, satellite, phenomenonType).signed
|
||||
if !isFinite(leftValue) || !isFinite(rightValue) || (leftValue < 0) == (rightValue < 0) {
|
||||
return (left + right) / 2
|
||||
}
|
||||
for i := 0; i < 80 && right-left > jupiterGalileanEventEpsilonDays; i++ {
|
||||
middle := (left + right) / 2
|
||||
value := jupiterGalileanPhenomenonMetricAt(middle, satellite, phenomenonType).signed
|
||||
if !isFinite(value) {
|
||||
return middle
|
||||
}
|
||||
if (value < 0) == (leftValue < 0) {
|
||||
left, leftValue = middle, value
|
||||
} else {
|
||||
right, rightValue = middle, value
|
||||
}
|
||||
}
|
||||
return (left + right) / 2
|
||||
}
|
||||
|
||||
func findJupiterGalileanPhenomenonEventAround(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonEvent {
|
||||
sample := jupiterGalileanPhenomenonMetricAt(jd, satellite, phenomenonType)
|
||||
if !sample.active {
|
||||
@@ -292,15 +423,17 @@ func jupiterGalileanPhenomenonMetricAt(
|
||||
if !isFinite(jd) || satellite < 1 || satellite > 4 || !isValidJupiterGalileanPhenomenonType(phenomenonType) {
|
||||
return jupiterGalileanMetricSample{
|
||||
metric: math.Inf(1),
|
||||
signed: math.NaN(),
|
||||
phenomenon: invalidJupiterGalileanPhenomenon(),
|
||||
}
|
||||
}
|
||||
|
||||
evaluationJD := TD2UT(jd, true)
|
||||
context := newJupiterGalileanObservationContext(evaluationJD)
|
||||
evaluationJDE := UTC2TT(jd)
|
||||
context := newJupiterGalileanObservationContext(evaluationJDE)
|
||||
if context.jupiterDistance == 0 {
|
||||
return jupiterGalileanMetricSample{
|
||||
metric: math.Inf(1),
|
||||
signed: math.NaN(),
|
||||
phenomenon: invalidJupiterGalileanPhenomenon(),
|
||||
}
|
||||
}
|
||||
@@ -360,27 +493,41 @@ func jupiterGalileanPhenomenonMetricAt(
|
||||
if !observation.InFrontOfJupiter {
|
||||
metric += 4
|
||||
}
|
||||
return jupiterGalileanMetricSample{active: phenomenon.Transit, metric: metric, phenomenon: phenomenon}
|
||||
return jupiterGalileanMetricSample{
|
||||
active: phenomenon.Transit, metric: metric, signed: xEarth, phenomenon: phenomenon,
|
||||
}
|
||||
case JupiterGalileanOccultation:
|
||||
metric := earthMetric
|
||||
if observation.InFrontOfJupiter {
|
||||
metric += 4
|
||||
}
|
||||
return jupiterGalileanMetricSample{active: phenomenon.Occultation, metric: metric, phenomenon: phenomenon}
|
||||
return jupiterGalileanMetricSample{
|
||||
active: phenomenon.Occultation, metric: metric, signed: xEarth, phenomenon: phenomenon,
|
||||
}
|
||||
case JupiterGalileanEclipse:
|
||||
metric := sunMetric
|
||||
if zSunAU <= 0 {
|
||||
metric += 4
|
||||
}
|
||||
return jupiterGalileanMetricSample{active: phenomenon.Eclipse, metric: metric, phenomenon: phenomenon}
|
||||
return jupiterGalileanMetricSample{
|
||||
active: phenomenon.Eclipse, metric: metric, signed: xSun, phenomenon: phenomenon,
|
||||
}
|
||||
case JupiterGalileanShadowTransit:
|
||||
metric := shadowMetric
|
||||
if shadowPoint.hasIntersection && !shadowPoint.visible {
|
||||
metric += 4
|
||||
}
|
||||
return jupiterGalileanMetricSample{active: phenomenon.ShadowTransit, metric: metric, phenomenon: phenomenon}
|
||||
signed := math.NaN()
|
||||
if shadowPoint.hasIntersection {
|
||||
signed = shadowPoint.xJupiterRadii
|
||||
}
|
||||
return jupiterGalileanMetricSample{
|
||||
active: phenomenon.ShadowTransit, metric: metric, signed: signed, phenomenon: phenomenon,
|
||||
}
|
||||
default:
|
||||
return jupiterGalileanMetricSample{metric: math.Inf(1), phenomenon: invalidJupiterGalileanPhenomenon()}
|
||||
return jupiterGalileanMetricSample{
|
||||
metric: math.Inf(1), signed: math.NaN(), phenomenon: invalidJupiterGalileanPhenomenon(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@ func TestJupiterGalileanPhenomenonEventsAgainstIMCCEBaseline(t *testing.T) {
|
||||
queryMid := startUTC.Add(endUTC.Sub(startUTC) / 2)
|
||||
phenomenonType := parseBasicGalileanPhenomenonType(t, record.Type)
|
||||
|
||||
next := NextJupiterGalileanPhenomenonEvent(Date2JDE(queryBefore.UTC()), record.Satellite, phenomenonType)
|
||||
last := LastJupiterGalileanPhenomenonEvent(Date2JDE(queryAfter.UTC()), record.Satellite, phenomenonType)
|
||||
closest := ClosestJupiterGalileanPhenomenonEvent(Date2JDE(queryMid.UTC()), record.Satellite, phenomenonType)
|
||||
next := NextJupiterGalileanPhenomenonEvent(Date2JD(queryBefore.UTC()), record.Satellite, phenomenonType)
|
||||
last := LastJupiterGalileanPhenomenonEvent(Date2JD(queryAfter.UTC()), record.Satellite, phenomenonType)
|
||||
closest := ClosestJupiterGalileanPhenomenonEvent(Date2JD(queryMid.UTC()), record.Satellite, phenomenonType)
|
||||
|
||||
assertGalileanEventMatchesBaseline(t, record.Label+" next", next, record, startUTC, endUTC, &maxStartDiff, &maxEndDiff)
|
||||
assertGalileanEventMatchesBaseline(t, record.Label+" last", last, record, startUTC, endUTC, &maxStartDiff, &maxEndDiff)
|
||||
@@ -62,8 +62,8 @@ func assertGalileanEventMatchesBaseline(
|
||||
if string(event.Type) != record.Type {
|
||||
t.Fatalf("%s type mismatch: got %q want %q", name, event.Type, record.Type)
|
||||
}
|
||||
gotStart := JDE2DateByZone(event.Start, time.UTC, false)
|
||||
gotEnd := JDE2DateByZone(event.End, time.UTC, false)
|
||||
gotStart := JD2DateByZone(event.Start, time.UTC, false)
|
||||
gotEnd := JD2DateByZone(event.End, time.UTC, false)
|
||||
startDiff := math.Abs(gotStart.Sub(startUTC).Seconds())
|
||||
endDiff := math.Abs(gotEnd.Sub(endUTC).Seconds())
|
||||
if startDiff > *maxStartDiff {
|
||||
@@ -143,3 +143,39 @@ func mustParseRFC3339Nano(t *testing.T, value string) time.Time {
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func TestJupiterGalileanSearchFindsShortGrazingTransit(t *testing.T) {
|
||||
// 2463144 附近 Callisto 有一次擦边凌日:Start 2463143.9993 / Greatest 2463144.0015 /
|
||||
// End 2463144.0038,全场只有 6.4 分钟,整段落在 2 小时粗采样之间。旧实现看不到度量
|
||||
// 凹陷,会把 16.85 天后的下一次凌日当成 "next"(Valid 仍为 true,属静默跳事件)。
|
||||
// Callisto has a grazing transit near 2463144: Start 2463143.9993, Greatest
|
||||
// 2463144.0015, End 2463144.0038 — only 6.4 minutes, entirely between two two-hour
|
||||
// coarse samples. The old search saw no metric dip and reported the next transit 16.85
|
||||
// days later as "next", still with Valid=true (a silent skip).
|
||||
closest := ClosestJupiterGalileanPhenomenonEvent(2463144.0, 4, JupiterGalileanTransit)
|
||||
if !closest.Valid {
|
||||
t.Fatal("no Callisto transit found at 2463144")
|
||||
}
|
||||
if duration := closest.End - closest.Start; duration > 0.01 {
|
||||
t.Fatalf("fixture changed: transit duration %.4f d, want the 6.4-minute grazing event", duration)
|
||||
}
|
||||
|
||||
next := NextJupiterGalileanPhenomenonEvent(2463143.9646, 4, JupiterGalileanTransit)
|
||||
if !next.Valid {
|
||||
t.Fatal("NextJupiterGalileanPhenomenonEvent returned nothing")
|
||||
}
|
||||
if offset := next.Greatest - 2463143.9646; offset > 0.1 {
|
||||
t.Fatalf("next transit is %.3f d away, want the 53-minute one (the search skipped it)", offset)
|
||||
}
|
||||
if math.Abs(next.Greatest-closest.Greatest) > 1e-5 {
|
||||
t.Fatalf("next greatest %.9f, want the same event as closest %.9f", next.Greatest, closest.Greatest)
|
||||
}
|
||||
|
||||
last := LastJupiterGalileanPhenomenonEvent(2463144.05, 4, JupiterGalileanTransit)
|
||||
if !last.Valid {
|
||||
t.Fatal("LastJupiterGalileanPhenomenonEvent returned nothing")
|
||||
}
|
||||
if math.Abs(last.Greatest-closest.Greatest) > 1e-5 {
|
||||
t.Fatalf("last greatest %.9f, want the same event as closest %.9f", last.Greatest, closest.Greatest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,18 +22,18 @@ type JupiterGalileanPhenomenon struct {
|
||||
}
|
||||
|
||||
// JupiterGalileanSatellitePhenomenon 单颗伽利略卫星瞬时现象 / instantaneous phenomena of one Galilean satellite.
|
||||
func JupiterGalileanSatellitePhenomenon(jd float64, satellite int) JupiterGalileanPhenomenon {
|
||||
if satellite < 1 || satellite > 4 || !isFinite(jd) {
|
||||
func JupiterGalileanSatellitePhenomenon(jde float64, satellite int) JupiterGalileanPhenomenon {
|
||||
if satellite < 1 || satellite > 4 || !isFinite(jde) {
|
||||
return invalidJupiterGalileanPhenomenon()
|
||||
}
|
||||
context := newJupiterGalileanObservationContext(jd)
|
||||
context := newJupiterGalileanObservationContext(jde)
|
||||
return context.phenomenonForSatellite(satellite - 1)
|
||||
}
|
||||
|
||||
// JupiterGalileanSatellitePhenomena 四颗伽利略卫星瞬时现象 / instantaneous phenomena of the four Galilean satellites.
|
||||
func JupiterGalileanSatellitePhenomena(jd float64) [4]JupiterGalileanPhenomenon {
|
||||
func JupiterGalileanSatellitePhenomena(jde float64) [4]JupiterGalileanPhenomenon {
|
||||
var phenomena [4]JupiterGalileanPhenomenon
|
||||
context := newJupiterGalileanObservationContext(jd)
|
||||
context := newJupiterGalileanObservationContext(jde)
|
||||
for i := range phenomena {
|
||||
phenomena[i] = context.phenomenonForSatellite(i)
|
||||
}
|
||||
|
||||
+36
-21
@@ -35,7 +35,7 @@ type jupiterGalileanL1Term struct {
|
||||
// JupiterGalileanState 木星伽利略卫星原始状态 / raw Galilean-satellite state.
|
||||
//
|
||||
// 输入 jd 使用 TT/TDB 对应的儒略日;返回值为 IMCCE L1 理论的木心 J2000 平赤道直角坐标与速度,单位 AU / AU/day。
|
||||
// The input jd is a TT/TDB Julian day. Returned coordinates are Jovicentric J2000 mean-equatorial position and velocity from the IMCCE L1 theory, in AU and AU/day.
|
||||
// The input jd is a TT/TDB Julian day (convert a civil query with UTC2TT(jd)). Returned coordinates are Jovicentric J2000 mean-equatorial position and velocity from the IMCCE L1 theory, in AU and AU/day.
|
||||
type JupiterGalileanState struct {
|
||||
X float64
|
||||
Y float64
|
||||
@@ -97,11 +97,11 @@ func JupiterGalileanSatelliteStates(jd float64) [4]JupiterGalileanState {
|
||||
//
|
||||
// jd 为 TT/TDB 对应儒略日;返回卫星的天球视赤道坐标,以及相对木星中心的东/北平面偏移。
|
||||
// jd is a TT/TDB Julian day. The result contains the satellite's astrometric equatorial coordinates and its east/north sky-plane offsets relative to Jupiter's center.
|
||||
func JupiterGalileanSatelliteObservation(jd float64, satellite int) JupiterGalileanObservation {
|
||||
if satellite < 1 || satellite > 4 || !isFinite(jd) {
|
||||
func JupiterGalileanSatelliteObservation(jde float64, satellite int) JupiterGalileanObservation {
|
||||
if satellite < 1 || satellite > 4 || !isFinite(jde) {
|
||||
return invalidJupiterGalileanObservation()
|
||||
}
|
||||
context := newJupiterGalileanObservationContext(jd)
|
||||
context := newJupiterGalileanObservationContext(jde)
|
||||
return context.observationForSatellite(satellite - 1)
|
||||
}
|
||||
|
||||
@@ -109,9 +109,9 @@ func JupiterGalileanSatelliteObservation(jd float64, satellite int) JupiterGalil
|
||||
//
|
||||
// 返回次序固定为 Io、Europa、Ganymede、Callisto。
|
||||
// The returned order is Io, Europa, Ganymede, Callisto.
|
||||
func JupiterGalileanSatelliteObservations(jd float64) [4]JupiterGalileanObservation {
|
||||
func JupiterGalileanSatelliteObservations(jde float64) [4]JupiterGalileanObservation {
|
||||
var observations [4]JupiterGalileanObservation
|
||||
context := newJupiterGalileanObservationContext(jd)
|
||||
context := newJupiterGalileanObservationContext(jde)
|
||||
for i := range observations {
|
||||
observations[i] = context.observationForSatellite(i)
|
||||
}
|
||||
@@ -141,14 +141,14 @@ type jupiterGalileanObservationContext struct {
|
||||
bodyZ Vector3
|
||||
}
|
||||
|
||||
func newJupiterGalileanObservationContext(jd float64) jupiterGalileanObservationContext {
|
||||
context := jupiterGalileanObservationContext{jd: jd}
|
||||
if !isFinite(jd) {
|
||||
func newJupiterGalileanObservationContext(jde float64) jupiterGalileanObservationContext {
|
||||
context := jupiterGalileanObservationContext{jd: jde}
|
||||
if !isFinite(jde) {
|
||||
return context
|
||||
}
|
||||
context.earthHelioJ2000 = rotateEclipticToEquatorial(earthHeliocentricVectorJ2000(jd), orbitJ2000Obliquity)
|
||||
context.jupiterGeoJ2000, context.jupiterLightTime = jupiterAstrometricGeocentricVectorJ2000(jd, context.earthHelioJ2000)
|
||||
context.targetJD = jd - context.jupiterLightTime
|
||||
context.earthHelioJ2000 = rotateEclipticToEquatorial(earthHeliocentricVectorJ2000(jde), orbitJ2000Obliquity)
|
||||
context.jupiterGeoJ2000, context.jupiterLightTime = jupiterAstrometricGeocentricVectorJ2000(jde, context.earthHelioJ2000)
|
||||
context.targetJD = jde - context.jupiterLightTime
|
||||
context.jupiterDistance = vectorMagnitude(context.jupiterGeoJ2000)
|
||||
if context.jupiterDistance == 0 {
|
||||
return context
|
||||
@@ -220,9 +220,9 @@ func jupiterGalileanSatelliteAstrometricGeocentric(index int, jd, initialLightTi
|
||||
result := Vector3{}
|
||||
includeSolarLongPeriod := jupiterGalileanUseSolarLongPeriod(jd)
|
||||
for i := 0; i < 8; i++ {
|
||||
targetJD := jd - lightTime
|
||||
jupiterHelio := rotateEclipticToEquatorial(jupiterHeliocentricVectorJ2000(targetJD), orbitJ2000Obliquity)
|
||||
state = jupiterGalileanSatelliteStateAtET(targetJD-jupiterGalileanReferenceJD, index, includeSolarLongPeriod)
|
||||
targetJDE := jd - lightTime
|
||||
jupiterHelio := rotateEclipticToEquatorial(jupiterHeliocentricVectorJ2000(targetJDE), orbitJ2000Obliquity)
|
||||
state = jupiterGalileanSatelliteStateAtET(targetJDE-jupiterGalileanReferenceJD, index, includeSolarLongPeriod)
|
||||
result = Vector3{
|
||||
jupiterHelio[0] + state.X - earthHelioJ2000[0],
|
||||
jupiterHelio[1] + state.Y - earthHelioJ2000[1],
|
||||
@@ -256,14 +256,14 @@ func jupiterAstrometricGeocentricVectorJ2000(jd float64, earthHelioJ2000 Vector3
|
||||
return result, lightTime
|
||||
}
|
||||
|
||||
func jupiterHeliocentricVectorJ2000(jd float64) Vector3 {
|
||||
func jupiterHeliocentricVectorJ2000(jde float64) Vector3 {
|
||||
return eclipticVectorAtReferenceEpoch(
|
||||
eclipticCartesian(
|
||||
planet.WherePlanet(4, 0, jd),
|
||||
planet.WherePlanet(4, 1, jd),
|
||||
planet.WherePlanet(4, 2, jd),
|
||||
planet.WherePlanet(4, 0, jde),
|
||||
planet.WherePlanet(4, 1, jde),
|
||||
planet.WherePlanet(4, 2, jde),
|
||||
),
|
||||
jd,
|
||||
jde,
|
||||
orbitReferenceJD,
|
||||
)
|
||||
}
|
||||
@@ -361,17 +361,32 @@ func jupiterGalileanElementsToPV(mu float64, elements jupiterGalileanElements) [
|
||||
p := elements.P
|
||||
a := elements.A
|
||||
al := elements.L
|
||||
invalid := func() [6]float64 {
|
||||
nan := math.NaN()
|
||||
return [6]float64{nan, nan, nan, nan, nan, nan}
|
||||
}
|
||||
if !isFiniteFloat(mu) || !isFiniteFloat(k) || !isFiniteFloat(h) || !isFiniteFloat(q) || !isFiniteFloat(p) || !isFiniteFloat(a) || !isFiniteFloat(al) || a == 0 {
|
||||
return invalid()
|
||||
}
|
||||
an := math.Sqrt(mu / math.Pow(a, 3))
|
||||
ee := al + k*math.Sin(al) - h*math.Cos(al)
|
||||
for {
|
||||
converged := false
|
||||
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||
ce := math.Cos(ee)
|
||||
se := math.Sin(ee)
|
||||
de := (al - ee + k*se - h*ce) / (1 - k*ce - h*se)
|
||||
if !isFiniteFloat(de) {
|
||||
return invalid()
|
||||
}
|
||||
ee += de
|
||||
if math.Abs(de) < 1e-12 {
|
||||
converged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !converged {
|
||||
return invalid()
|
||||
}
|
||||
ce := math.Cos(ee)
|
||||
se := math.Sin(ee)
|
||||
dle := h*ce - k*se
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package basic
|
||||
|
||||
import "math"
|
||||
|
||||
// localEphemerisVectorNode is a pair of Cartesian ephemeris samples at one
|
||||
// TT. Cartesian interpolation avoids right-ascension wraparound at 0/360.
|
||||
type localEphemerisVectorNode struct {
|
||||
tt float64
|
||||
first, next [3]float64
|
||||
}
|
||||
|
||||
const (
|
||||
solarLocalEphemerisNodeCount = 13
|
||||
solarLocalEphemerisStepDays = 1.0 / 24.0
|
||||
occultationLocalEphemerisNodeCount = 49
|
||||
occultationLocalEphemerisStepDays = 2.0 / 24.0
|
||||
)
|
||||
|
||||
func interpolateLocalEphemerisVectors(
|
||||
nodes []localEphemerisVectorNode,
|
||||
tt float64,
|
||||
) ([3]float64, [3]float64, bool) {
|
||||
const interpolationPoints = 6
|
||||
if len(nodes) < interpolationPoints || !finite(tt) {
|
||||
return [3]float64{}, [3]float64{}, false
|
||||
}
|
||||
step := nodes[1].tt - nodes[0].tt
|
||||
if !finite(step) || step <= 0 {
|
||||
return [3]float64{}, [3]float64{}, false
|
||||
}
|
||||
u := (tt - nodes[0].tt) / step
|
||||
if u < 0 || u > float64(len(nodes)-1) {
|
||||
return [3]float64{}, [3]float64{}, false
|
||||
}
|
||||
start := int(math.Floor(u)) - 2
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if start > len(nodes)-interpolationPoints {
|
||||
start = len(nodes) - interpolationPoints
|
||||
}
|
||||
var weights [interpolationPoints]float64
|
||||
for point := 0; point < interpolationPoints; point++ {
|
||||
x := float64(start + point)
|
||||
weight := 1.0
|
||||
for other := 0; other < interpolationPoints; other++ {
|
||||
if other == point {
|
||||
continue
|
||||
}
|
||||
xOther := float64(start + other)
|
||||
weight *= (u - xOther) / (x - xOther)
|
||||
}
|
||||
weights[point] = weight
|
||||
}
|
||||
var first, next [3]float64
|
||||
for coordinate := 0; coordinate < 3; coordinate++ {
|
||||
for point := 0; point < interpolationPoints; point++ {
|
||||
first[coordinate] += weights[point] * nodes[start+point].first[coordinate]
|
||||
next[coordinate] += weights[point] * nodes[start+point].next[coordinate]
|
||||
}
|
||||
}
|
||||
return first, next, finiteVector3(first) && finiteVector3(next)
|
||||
}
|
||||
|
||||
func finiteVector3(value [3]float64) bool {
|
||||
return finite(value[0]) && finite(value[1]) && finite(value[2])
|
||||
}
|
||||
|
||||
func occultationPathVectorRaDec(vector occultationPathVector) (float64, float64, float64, bool) {
|
||||
distance := occultationPathNorm(vector)
|
||||
if !finite(distance) || distance <= 0 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
// atan2 给 (−180,180],统一到 [0,360):与精确分支(LoBoToRaDec、starMeanToApparentRaDec)
|
||||
// 和 occultationRiseSetBodyFromVector 的 normalizeRA 一致;下游只按周期量使用,数值不变。
|
||||
ra := normalizeRA(math.Atan2(vector.y, vector.x) / rad)
|
||||
dec := math.Asin(math.Max(-1, math.Min(1, vector.z/distance))) / rad
|
||||
return ra, dec, distance, finite(ra) && finite(dec)
|
||||
}
|
||||
|
||||
type solarEclipseLocalEphemeris struct {
|
||||
nodes []localEphemerisVectorNode
|
||||
}
|
||||
|
||||
func newSolarEclipseLocalEphemeris(center float64) *solarEclipseLocalEphemeris {
|
||||
half := solarLocalEphemerisNodeCount / 2
|
||||
nodes := make([]localEphemerisVectorNode, solarLocalEphemerisNodeCount)
|
||||
for index := range nodes {
|
||||
tt := center + float64(index-half)*solarLocalEphemerisStepDays
|
||||
sun, moon := solarEclipseSunMoonEquatorial(tt)
|
||||
nodes[index] = localEphemerisVectorNode{
|
||||
tt: tt,
|
||||
first: solarEclipseLLRToXYZ(sun[0], sun[1], sun[2]),
|
||||
next: solarEclipseLLRToXYZ(moon[0], moon[1], moon[2]),
|
||||
}
|
||||
}
|
||||
return &solarEclipseLocalEphemeris{nodes: nodes}
|
||||
}
|
||||
|
||||
func (ephemeris *solarEclipseLocalEphemeris) equatorialAt(tt float64) ([3]float64, [3]float64, bool) {
|
||||
var empty [3]float64
|
||||
if ephemeris == nil {
|
||||
return empty, empty, false
|
||||
}
|
||||
sunXYZ, moonXYZ, ok := interpolateLocalEphemerisVectors(ephemeris.nodes, tt)
|
||||
if !ok {
|
||||
return empty, empty, false
|
||||
}
|
||||
return solarEclipseXYZToLLR(sunXYZ[0], sunXYZ[1], sunXYZ[2]),
|
||||
solarEclipseXYZToLLR(moonXYZ[0], moonXYZ[1], moonXYZ[2]), true
|
||||
}
|
||||
|
||||
type starOccultationLocalEphemeris struct {
|
||||
star StarCoordinate
|
||||
nodes []localEphemerisVectorNode
|
||||
dense bool
|
||||
// distanceKM 是中心时刻的当日距离;hasDistance 为假表示恒星距离未知,几何按无穷远处理。
|
||||
distanceKM float64
|
||||
hasDistance bool
|
||||
}
|
||||
|
||||
func newStarOccultationLocalEphemeris(center float64, star StarCoordinate) *starOccultationLocalEphemeris {
|
||||
half := occultationLocalEphemerisNodeCount / 2
|
||||
nodes := make([]localEphemerisVectorNode, occultationLocalEphemerisNodeCount)
|
||||
for index := range nodes {
|
||||
tt := center + float64(index-half)*occultationLocalEphemerisStepDays
|
||||
state := starOccultationEphemerisStateAt(tt, star)
|
||||
moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM)
|
||||
targetDistance := state.starDistanceKM
|
||||
if targetDistance <= 0 {
|
||||
// 恒星距离未知时只需方向:按单位球方向装配,几何只用归一化后的矢量。
|
||||
targetDistance = 1
|
||||
}
|
||||
target := occultationPathRaDecVector(state.starRA, state.starDec, targetDistance)
|
||||
nodes[index] = localEphemerisVectorNode{
|
||||
tt: tt,
|
||||
first: [3]float64{moon.x, moon.y, moon.z},
|
||||
next: [3]float64{target.x, target.y, target.z},
|
||||
}
|
||||
}
|
||||
return newStarOccultationLocalEphemerisFromNodes(center, star, nodes, false)
|
||||
}
|
||||
|
||||
// newStarOccultationLocalEphemerisFromNodes 装配局部星历:距离必须与节点同源,密集分支同样要带上。
|
||||
func newStarOccultationLocalEphemerisFromNodes(center float64, star StarCoordinate, nodes []localEphemerisVectorNode, dense bool) *starOccultationLocalEphemeris {
|
||||
_, _, distanceAU := starApparentRaDecDistanceGeocentric(center, star)
|
||||
return &starOccultationLocalEphemeris{
|
||||
star: star, nodes: nodes, dense: dense,
|
||||
distanceKM: distanceAU * occultationPathAstronomicalUnitKM, hasDistance: distanceAU > 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (ephemeris *starOccultationLocalEphemeris) stateAt(tt float64) (starOccultationEphemerisState, bool) {
|
||||
moonXYZ, targetXYZ, ok := ephemeris.vectorsAt(tt)
|
||||
if !ok {
|
||||
return starOccultationEphemerisState{}, false
|
||||
}
|
||||
moonRA, moonDec, moonDistance, moonOK := occultationPathVectorRaDec(occultationPathVector{
|
||||
x: moonXYZ[0], y: moonXYZ[1], z: moonXYZ[2],
|
||||
})
|
||||
targetRA, targetDec, targetDistance, targetOK := occultationPathVectorRaDec(occultationPathVector{
|
||||
x: targetXYZ[0], y: targetXYZ[1], z: targetXYZ[2],
|
||||
})
|
||||
if !moonOK || !targetOK {
|
||||
return starOccultationEphemerisState{}, false
|
||||
}
|
||||
// 距离取插值矢量自身的模长,才与同一次插值给出的方向同源;距离未知时按 0 上报。
|
||||
starDistanceKM := 0.0
|
||||
if ephemeris.hasDistance {
|
||||
starDistanceKM = targetDistance
|
||||
}
|
||||
return starOccultationEphemerisState{
|
||||
moonRA: moonRA, moonDec: moonDec, moonDistanceKM: moonDistance,
|
||||
starRA: targetRA, starDec: targetDec, starDistanceKM: starDistanceKM,
|
||||
valid: true,
|
||||
}, true
|
||||
}
|
||||
|
||||
func (ephemeris *starOccultationLocalEphemeris) vectorsAt(tt float64) ([3]float64, [3]float64, bool) {
|
||||
if ephemeris == nil {
|
||||
return [3]float64{}, [3]float64{}, false
|
||||
}
|
||||
if ephemeris.dense {
|
||||
return interpolateDenseOccultationVectors(ephemeris.nodes, tt)
|
||||
}
|
||||
return interpolateLocalEphemerisVectors(ephemeris.nodes, tt)
|
||||
}
|
||||
|
||||
func (ephemeris *starOccultationLocalEphemeris) starDistanceKM() float64 {
|
||||
return ephemeris.distanceKM
|
||||
}
|
||||
|
||||
type planetOccultationLocalEphemeris struct {
|
||||
nodes []localEphemerisVectorNode
|
||||
dense bool
|
||||
}
|
||||
|
||||
func newPlanetOccultationLocalEphemeris(center float64, config planetOccultationConfig) *planetOccultationLocalEphemeris {
|
||||
half := occultationLocalEphemerisNodeCount / 2
|
||||
nodes := make([]localEphemerisVectorNode, occultationLocalEphemerisNodeCount)
|
||||
for index := range nodes {
|
||||
tt := center + float64(index-half)*occultationLocalEphemerisStepDays
|
||||
state := planetOccultationEphemerisStateAt(tt, config)
|
||||
moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM)
|
||||
target := occultationPathRaDecVector(state.planetRA, state.planetDec, state.planetDistanceKM)
|
||||
nodes[index] = localEphemerisVectorNode{
|
||||
tt: tt,
|
||||
first: [3]float64{moon.x, moon.y, moon.z},
|
||||
next: [3]float64{target.x, target.y, target.z},
|
||||
}
|
||||
}
|
||||
return &planetOccultationLocalEphemeris{nodes: nodes}
|
||||
}
|
||||
|
||||
func (ephemeris *planetOccultationLocalEphemeris) stateAt(tt float64) (planetOccultationEphemerisState, bool) {
|
||||
moonXYZ, targetXYZ, ok := ephemeris.vectorsAt(tt)
|
||||
if !ok {
|
||||
return planetOccultationEphemerisState{}, false
|
||||
}
|
||||
moonRA, moonDec, moonDistance, moonOK := occultationPathVectorRaDec(occultationPathVector{
|
||||
x: moonXYZ[0], y: moonXYZ[1], z: moonXYZ[2],
|
||||
})
|
||||
targetRA, targetDec, planetDistance, targetOK := occultationPathVectorRaDec(occultationPathVector{
|
||||
x: targetXYZ[0], y: targetXYZ[1], z: targetXYZ[2],
|
||||
})
|
||||
if !moonOK || !targetOK {
|
||||
return planetOccultationEphemerisState{}, false
|
||||
}
|
||||
return planetOccultationEphemerisState{
|
||||
moonRA: moonRA, moonDec: moonDec, moonDistanceKM: moonDistance,
|
||||
planetRA: targetRA, planetDec: targetDec, planetDistanceKM: planetDistance,
|
||||
valid: true,
|
||||
}, true
|
||||
}
|
||||
|
||||
func (ephemeris *planetOccultationLocalEphemeris) vectorsAt(tt float64) ([3]float64, [3]float64, bool) {
|
||||
if ephemeris == nil {
|
||||
return [3]float64{}, [3]float64{}, false
|
||||
}
|
||||
if ephemeris.dense {
|
||||
return interpolateDenseOccultationVectors(ephemeris.nodes, tt)
|
||||
}
|
||||
return interpolateLocalEphemerisVectors(ephemeris.nodes, tt)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSolarEclipseLocalEphemerisBoundedError(t *testing.T) {
|
||||
events := []struct {
|
||||
name string
|
||||
seed float64
|
||||
}{
|
||||
{"2010-01-15", JDCalc(2010, 1, 15)},
|
||||
{"2014-04-29", JDCalc(2014, 4, 29)},
|
||||
{"2023-04-20", JDCalc(2023, 4, 20)},
|
||||
{"2031-05-21", JDCalc(2031, 5, 21)},
|
||||
{"2309-06-09", JDCalc(2309, 6, 9)},
|
||||
}
|
||||
for _, event := range events {
|
||||
t.Run(event.name, func(t *testing.T) {
|
||||
center := CalcMoonSHByJDE(event.seed, 0)
|
||||
ephemeris := newSolarEclipseLocalEphemeris(center)
|
||||
for offset := -4.5; offset <= 4.5; offset += 0.25 {
|
||||
jd := center + offset/24
|
||||
approxSun, approxMoon, ok := ephemeris.equatorialAt(jd)
|
||||
if !ok {
|
||||
t.Fatalf("interpolator rejected in-window time %.3fh", offset)
|
||||
}
|
||||
exactSun, exactMoon := solarEclipseSunMoonEquatorial(jd)
|
||||
for name, pair := range map[string][2][3]float64{
|
||||
"sun": {solarEclipseLLRToXYZ(approxSun[0], approxSun[1], approxSun[2]), solarEclipseLLRToXYZ(exactSun[0], exactSun[1], exactSun[2])},
|
||||
"moon": {solarEclipseLLRToXYZ(approxMoon[0], approxMoon[1], approxMoon[2]), solarEclipseLLRToXYZ(exactMoon[0], exactMoon[1], exactMoon[2])},
|
||||
} {
|
||||
if errorKM := vectorDifferenceKM(pair[0], pair[1]); errorKM > 1 {
|
||||
t.Fatalf("%s interpolation error at %.3fh = %.6f km", name, offset, errorKM)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, _, ok := ephemeris.equatorialAt(center + 6.1/24); ok {
|
||||
t.Fatal("interpolator should reject times outside its bounded window")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationLocalEphemerisBoundedError(t *testing.T) {
|
||||
star := hr4799OccultationCoordinateForTest()
|
||||
starTT := occultationTimeToTT(time.Date(2025, 6, 5, 20, 0, 0, 0, time.UTC))
|
||||
starEphemeris := newStarOccultationLocalEphemeris(starTT, star)
|
||||
for offset := -4.5; offset <= 4.5; offset += 0.25 {
|
||||
jd := starTT + offset/24
|
||||
got, ok := starEphemeris.stateAt(jd)
|
||||
if !ok {
|
||||
t.Fatalf("star interpolator rejected in-window time %.3fh", offset)
|
||||
}
|
||||
want := starOccultationEphemerisStateAt(jd, star)
|
||||
if difference := angularSeparationDegrees(got.moonRA, got.moonDec, want.moonRA, want.moonDec); difference > 1e-5 {
|
||||
t.Fatalf("moon direction interpolation error at %.3fh = %.9f deg", offset, difference)
|
||||
}
|
||||
if difference := angularSeparationDegrees(got.starRA, got.starDec, want.starRA, want.starDec); difference > 1e-5 {
|
||||
t.Fatalf("star direction interpolation error at %.3fh = %.9f deg", offset, difference)
|
||||
}
|
||||
}
|
||||
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn occultation config is unavailable")
|
||||
}
|
||||
planetTT := occultationTimeToTT(time.Date(2024, 8, 21, 2, 40, 0, 0, time.UTC))
|
||||
planetEphemeris := newPlanetOccultationLocalEphemeris(planetTT, config)
|
||||
for offset := -4.5; offset <= 4.5; offset += 0.25 {
|
||||
jd := planetTT + offset/24
|
||||
got, ok := planetEphemeris.stateAt(jd)
|
||||
if !ok {
|
||||
t.Fatalf("planet interpolator rejected in-window time %.3fh", offset)
|
||||
}
|
||||
want := planetOccultationEphemerisStateAt(jd, config)
|
||||
if difference := angularSeparationDegrees(got.moonRA, got.moonDec, want.moonRA, want.moonDec); difference > 1e-5 {
|
||||
t.Fatalf("moon direction interpolation error at %.3fh = %.9f deg", offset, difference)
|
||||
}
|
||||
if difference := angularSeparationDegrees(got.planetRA, got.planetDec, want.planetRA, want.planetDec); difference > 1e-5 {
|
||||
t.Fatalf("planet direction interpolation error at %.3fh = %.9f deg", offset, difference)
|
||||
}
|
||||
if math.Abs(got.planetDistanceKM-want.planetDistanceKM) > 100 {
|
||||
t.Fatalf("planet distance interpolation error at %.3fh = %.6f km", offset, math.Abs(got.planetDistanceKM-want.planetDistanceKM))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationLocalEphemerisFullWindowBoundedError(t *testing.T) {
|
||||
star := hr4799OccultationCoordinateForTest()
|
||||
starTT := occultationTimeToTT(time.Date(2025, 6, 5, 20, 0, 0, 0, time.UTC))
|
||||
starEphemeris := newStarOccultationLocalEphemeris(starTT, star)
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn occultation config is unavailable")
|
||||
}
|
||||
planetTT := occultationTimeToTT(time.Date(2024, 8, 21, 2, 40, 0, 0, time.UTC))
|
||||
planetEphemeris := newPlanetOccultationLocalEphemeris(planetTT, config)
|
||||
for _, offset := range []float64{-47.5, -40, -24, 24, 40, 47.5} {
|
||||
starJD := starTT + offset/24
|
||||
gotStar, starOK := starEphemeris.stateAt(starJD)
|
||||
wantStar := starOccultationEphemerisStateAt(starJD, star)
|
||||
if !starOK {
|
||||
t.Fatalf("star interpolator rejected in-window time %.3fh", offset)
|
||||
}
|
||||
if difference := angularSeparationDegrees(gotStar.moonRA, gotStar.moonDec, wantStar.moonRA, wantStar.moonDec); difference > 1e-5 {
|
||||
t.Fatalf("star-event moon direction error at %.3fh = %.9f deg", offset, difference)
|
||||
}
|
||||
if difference := angularSeparationDegrees(gotStar.starRA, gotStar.starDec, wantStar.starRA, wantStar.starDec); difference > 1e-5 {
|
||||
t.Fatalf("star direction error at %.3fh = %.9f deg", offset, difference)
|
||||
}
|
||||
|
||||
planetJD := planetTT + offset/24
|
||||
gotPlanet, planetOK := planetEphemeris.stateAt(planetJD)
|
||||
wantPlanet := planetOccultationEphemerisStateAt(planetJD, config)
|
||||
if !planetOK {
|
||||
t.Fatalf("planet interpolator rejected in-window time %.3fh", offset)
|
||||
}
|
||||
if difference := angularSeparationDegrees(gotPlanet.moonRA, gotPlanet.moonDec, wantPlanet.moonRA, wantPlanet.moonDec); difference > 1e-5 {
|
||||
t.Fatalf("planet-event moon direction error at %.3fh = %.9f deg", offset, difference)
|
||||
}
|
||||
if difference := angularSeparationDegrees(gotPlanet.planetRA, gotPlanet.planetDec, wantPlanet.planetRA, wantPlanet.planetDec); difference > 1e-5 {
|
||||
t.Fatalf("planet direction error at %.3fh = %.9f deg", offset, difference)
|
||||
}
|
||||
if difference := math.Abs(gotPlanet.planetDistanceKM - wantPlanet.planetDistanceKM); difference > 100 {
|
||||
t.Fatalf("planet distance error at %.3fh = %.6f km", offset, difference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func vectorDifferenceKM(a, b [3]float64) float64 {
|
||||
return math.Sqrt((a[0]-b[0])*(a[0]-b[0]) + (a[1]-b[1])*(a[1]-b[1]) + (a[2]-b[2])*(a[2]-b[2]))
|
||||
}
|
||||
|
||||
// 矢量回读的赤经必须落在 [0,360):atan2 给 (−180,180],而精确分支
|
||||
// (LoBoToRaDec、starMeanToApparentRaDec)与 rise/set 的矢量回读都在 [0,360),
|
||||
// 不归一会让同一时刻的密集/精确状态相差 360 度,误导比较与日志。
|
||||
func TestOccultationPathVectorRaDecKeepsNormalizedRA(t *testing.T) {
|
||||
for _, want := range []float64{0.5, 90, 189.1975, 270, 359.9} {
|
||||
vector := occultationPathRaDecVector(want, -5.8, 2.5)
|
||||
got, dec, distance, ok := occultationPathVectorRaDec(vector)
|
||||
if !ok {
|
||||
t.Fatalf("RA %.4f 的回读失败", want)
|
||||
}
|
||||
if math.Abs(got-want) > 1e-9 {
|
||||
t.Fatalf("RA %.4f 回读为 %.4f,want [0,360) 内的同一读数", want, got)
|
||||
}
|
||||
if math.Abs(dec+5.8) > 1e-9 || math.Abs(distance-2.5) > 1e-12 {
|
||||
t.Fatalf("RA %.4f 的赤纬/距离 = %.6f / %.6f,want -5.8 / 2.5", want, dec, distance)
|
||||
}
|
||||
}
|
||||
if _, _, _, ok := occultationPathVectorRaDec(occultationPathVector{}); ok {
|
||||
t.Fatal("零矢量应判为无效")
|
||||
}
|
||||
}
|
||||
+58
-12
@@ -22,6 +22,9 @@ const (
|
||||
// 输入 seedJDE 只需要落在目标望月附近,允许相差数天。
|
||||
type LunarEclipseResult struct {
|
||||
Type LunarEclipseType
|
||||
// ShadowModel 是本次使用的影半径模型,决定影半径与食分的具体数值。
|
||||
// ShadowModel is the shadow-radius model used, which fixes the shadow radii and magnitudes.
|
||||
ShadowModel LunarEclipseShadowModel
|
||||
|
||||
// Maximum 是食甚时刻;即使最终没有月食,也会返回该次望月附近
|
||||
// “月面中心最接近地影中心”的几何极值时刻。
|
||||
@@ -39,6 +42,10 @@ type LunarEclipseResult struct {
|
||||
// PenumbralStart / PenumbralEnd: 半影食始 / 半影食终
|
||||
// PartialStart / PartialEnd: 初亏 / 复圆
|
||||
// TotalStart / TotalEnd: 食既 / 生光
|
||||
//
|
||||
// 该阶段不发生时为 NaN(0 是 −4713-11-24 的真实时刻);判断阶段是否存在一律用 Has*。
|
||||
// A contact is NaN when that phase does not occur (JD 0 is the real instant −4713-11-24);
|
||||
// decide by the Has* flags, never by comparing a contact against zero.
|
||||
PenumbralStart float64
|
||||
PenumbralEnd float64
|
||||
PartialStart float64
|
||||
@@ -60,6 +67,17 @@ type lunarShadowState struct {
|
||||
penumbraRadiusRad float64
|
||||
}
|
||||
|
||||
// LunarEclipseShadowModel 标识月食用的是哪套影半径模型。
|
||||
// LunarEclipseShadowModel identifies which shadow-radius model produced a result.
|
||||
type LunarEclipseShadowModel int
|
||||
|
||||
const (
|
||||
// LunarEclipseShadowModelDanjon 是 Danjon 影半径模型。
|
||||
LunarEclipseShadowModelDanjon LunarEclipseShadowModel = iota
|
||||
// LunarEclipseShadowModelChauvenet 是 Chauvenet 影半径模型。
|
||||
LunarEclipseShadowModelChauvenet
|
||||
)
|
||||
|
||||
type lunarEclipseShadowModel int
|
||||
|
||||
const (
|
||||
@@ -73,10 +91,9 @@ const (
|
||||
|
||||
// 沿用月食常量:
|
||||
// - 0.2725076 用于月亮视半径和半影几何
|
||||
// - 959.63 / 8.794 分别为太阳视半径与太阳视差的常用角秒常量
|
||||
// - 太阳视半径与日食共用标准档常量,太阳视差用常用角秒常量 8.794
|
||||
lunarMoonRadiusRatio = 0.2725076
|
||||
lunarMoonRadiusScale = lunarMoonRadiusRatio * lunarEarthEquatorialRadiusKM * 1.0000036
|
||||
lunarSolarRadiusArcsec = 959.63
|
||||
lunarSolarParallaxArcsec = 8.794
|
||||
lunarLongitudeAberration = -3.4e-6
|
||||
lunarFiniteDifferenceStep = 60.0 / 86400.0
|
||||
@@ -117,11 +134,23 @@ func lunarEclipse(seedJDE float64, shadowModel lunarEclipseShadowModel) LunarEcl
|
||||
fullMoonJDE := CalcMoonSHByJDE(seedJDE, 1)
|
||||
maximumJDE, state, dxdt, dydt, minimumDistance := refineLunarEclipseMaximum(fullMoonJDE, shadowModel)
|
||||
|
||||
// 未发生的阶段保持 NaN:Has* 是权威判据,时刻字段不能拿 0 当哨兵。
|
||||
model := LunarEclipseShadowModelDanjon
|
||||
if shadowModel == lunarEclipseShadowChauvenet {
|
||||
model = LunarEclipseShadowModelChauvenet
|
||||
}
|
||||
result := LunarEclipseResult{
|
||||
Type: LunarEclipseNone,
|
||||
ShadowModel: model,
|
||||
Maximum: maximumJDE,
|
||||
MinimumDistance: minimumDistance,
|
||||
PenumbralMagnitude: (state.moonRadiusRad + state.penumbraRadiusRad - minimumDistance) / (2 * state.moonRadiusRad),
|
||||
PenumbralStart: math.NaN(),
|
||||
PenumbralEnd: math.NaN(),
|
||||
PartialStart: math.NaN(),
|
||||
PartialEnd: math.NaN(),
|
||||
TotalStart: math.NaN(),
|
||||
TotalEnd: math.NaN(),
|
||||
}
|
||||
rawUmbralMagnitude := (state.moonRadiusRad + state.umbraRadiusRad - minimumDistance) / (2 * state.moonRadiusRad)
|
||||
|
||||
@@ -223,7 +252,7 @@ func refineLunarEclipseContact(
|
||||
) float64 {
|
||||
firstGuess, ok := solveLineCircleContact(maximumState, dxdt, dydt, boundaryRadius, afterMaximum)
|
||||
if !ok {
|
||||
return 0
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
contactState := computeLunarShadowState(firstGuess, shadowModel)
|
||||
@@ -272,11 +301,14 @@ func solveLineCircleContact(
|
||||
return state.jde + delta, true
|
||||
}
|
||||
|
||||
// computeLunarShadowState 计算某一力学时刻下,月心相对地影中心的二维几何状态。
|
||||
//
|
||||
// 所有内部角量统一使用弧度。影半径模型允许在 Danjon 与 Chauvenet 之间切换,
|
||||
// 其余月心轨迹与几何求交框架保持一致。
|
||||
func computeLunarShadowState(jde float64, shadowModel lunarEclipseShadowModel) lunarShadowState {
|
||||
// lunarEclipsePlaneState 只需要月心相对地影中心的二维坐标(穿影图采样点用,跳过半径所需的距离项)。
|
||||
type lunarEclipsePlaneState struct {
|
||||
jde float64
|
||||
x float64
|
||||
y float64
|
||||
}
|
||||
|
||||
func lunarEclipsePlaneStateAt(jde float64) lunarEclipsePlaneState {
|
||||
julianCentury := (jde - 2451545.0) / 36525.0
|
||||
|
||||
sunLongitude := HSunTrueLo(jde)*rad + sunLongitudeAberrationRad(julianCentury)
|
||||
@@ -284,12 +316,26 @@ func computeLunarShadowState(jde float64, shadowModel lunarEclipseShadowModel) l
|
||||
moonLongitude := HMoonTrueLo(jde)*rad + lunarLongitudeAberration
|
||||
moonLatitude := HMoonTrueBo(jde)*rad + moonLatitudeAberrationRad(julianCentury)
|
||||
|
||||
return lunarEclipsePlaneState{
|
||||
jde: jde,
|
||||
x: normalizeRadians(moonLongitude+math.Pi-sunLongitude) * math.Cos((moonLatitude-sunLatitude)/2),
|
||||
y: moonLatitude + sunLatitude,
|
||||
}
|
||||
}
|
||||
|
||||
// computeLunarShadowState 计算某一力学时刻下,月心相对地影中心的二维几何状态。
|
||||
//
|
||||
// 所有内部角量统一使用弧度。影半径模型允许在 Danjon 与 Chauvenet 之间切换,
|
||||
// 其余月心轨迹与几何求交框架保持一致。
|
||||
func computeLunarShadowState(jde float64, shadowModel lunarEclipseShadowModel) lunarShadowState {
|
||||
plane := lunarEclipsePlaneStateAt(jde)
|
||||
|
||||
moonDistanceKM := HMoonAway(jde)
|
||||
sunDistanceAU := EarthAway(jde)
|
||||
|
||||
moonRadiusArcsec := lunarMoonRadiusScale * lunarArcsecPerRadian / moonDistanceKM
|
||||
earthParallaxArcsec := lunarEarthEquatorialRadiusKM / moonDistanceKM * lunarArcsecPerRadian
|
||||
solarRadiusArcsec := lunarSolarRadiusArcsec / sunDistanceAU
|
||||
solarRadiusArcsec := eclipseSunRadiusStandardArcsec / sunDistanceAU
|
||||
solarParallaxArcsec := lunarSolarParallaxArcsec / sunDistanceAU
|
||||
umbraRadiusArcsec, penumbraRadiusArcsec := lunarEclipseShadowRadiiArcsec(
|
||||
earthParallaxArcsec,
|
||||
@@ -299,9 +345,9 @@ func computeLunarShadowState(jde float64, shadowModel lunarEclipseShadowModel) l
|
||||
)
|
||||
|
||||
return lunarShadowState{
|
||||
jde: jde,
|
||||
x: normalizeRadians(moonLongitude+math.Pi-sunLongitude) * math.Cos((moonLatitude-sunLatitude)/2),
|
||||
y: moonLatitude + sunLatitude,
|
||||
jde: plane.jde,
|
||||
x: plane.x,
|
||||
y: plane.y,
|
||||
moonRadiusRad: moonRadiusArcsec / lunarArcsecPerRadian,
|
||||
umbraRadiusRad: umbraRadiusArcsec / lunarArcsecPerRadian,
|
||||
penumbraRadiusRad: penumbraRadiusArcsec / lunarArcsecPerRadian,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 未发生阶段的接触时刻必须是 NaN:JD 0 是 −4713-11-24 的真实时刻,不能当“无此阶段”的哨兵。
|
||||
|
||||
func TestLunarEclipseAbsentPhasesAreNaN(t *testing.T) {
|
||||
eclipse := LunarEclipse(2458860.0)
|
||||
if eclipse.Type != LunarEclipsePenumbral {
|
||||
t.Fatalf("type = %s, want %s", eclipse.Type, LunarEclipsePenumbral)
|
||||
}
|
||||
if !eclipse.HasPenumbral || eclipse.HasPartial || eclipse.HasTotal {
|
||||
t.Fatalf("unexpected flags: %+v", eclipse)
|
||||
}
|
||||
for name, value := range map[string]float64{
|
||||
"PartialStart": eclipse.PartialStart,
|
||||
"PartialEnd": eclipse.PartialEnd,
|
||||
"TotalStart": eclipse.TotalStart,
|
||||
"TotalEnd": eclipse.TotalEnd,
|
||||
} {
|
||||
if !math.IsNaN(value) {
|
||||
t.Fatalf("%s = %.12f, want NaN", name, value)
|
||||
}
|
||||
}
|
||||
for name, value := range map[string]float64{
|
||||
"PenumbralStart": eclipse.PenumbralStart,
|
||||
"PenumbralEnd": eclipse.PenumbralEnd,
|
||||
"Maximum": eclipse.Maximum,
|
||||
} {
|
||||
if !isFiniteFloat(value) {
|
||||
t.Fatalf("%s = %v, want a finite contact", name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLunarEclipseWithoutEclipseHasNoContacts(t *testing.T) {
|
||||
// 2025-01-13 的望月没有月食(半影食分 < 0)。
|
||||
eclipse := LunarEclipse(JDCalc(2025, 1, 13))
|
||||
if eclipse.Type != LunarEclipseNone {
|
||||
t.Fatalf("type = %s, want %s", eclipse.Type, LunarEclipseNone)
|
||||
}
|
||||
if eclipse.HasPenumbral || eclipse.HasPartial || eclipse.HasTotal {
|
||||
t.Fatalf("unexpected flags: %+v", eclipse)
|
||||
}
|
||||
for name, value := range map[string]float64{
|
||||
"PenumbralStart": eclipse.PenumbralStart,
|
||||
"PenumbralEnd": eclipse.PenumbralEnd,
|
||||
"PartialStart": eclipse.PartialStart,
|
||||
"PartialEnd": eclipse.PartialEnd,
|
||||
"TotalStart": eclipse.TotalStart,
|
||||
"TotalEnd": eclipse.TotalEnd,
|
||||
} {
|
||||
if !math.IsNaN(value) {
|
||||
t.Fatalf("%s = %.12f, want NaN", name, value)
|
||||
}
|
||||
}
|
||||
if !isFiniteFloat(eclipse.Maximum) {
|
||||
t.Fatalf("Maximum = %v, want the geometric extremum", eclipse.Maximum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLunarEclipseTotalKeepsAllContactsFinite(t *testing.T) {
|
||||
eclipse := LunarEclipse(JDCalc(2025, 3, 14))
|
||||
if eclipse.Type != LunarEclipseTotal || !eclipse.HasPenumbral || !eclipse.HasPartial || !eclipse.HasTotal {
|
||||
t.Fatalf("unexpected result: %+v", eclipse)
|
||||
}
|
||||
contacts := []float64{
|
||||
eclipse.PenumbralStart, eclipse.PartialStart, eclipse.TotalStart,
|
||||
eclipse.Maximum,
|
||||
eclipse.TotalEnd, eclipse.PartialEnd, eclipse.PenumbralEnd,
|
||||
}
|
||||
for i, value := range contacts {
|
||||
if !isFiniteFloat(value) {
|
||||
t.Fatalf("contact %d = %v, want finite", i, value)
|
||||
}
|
||||
if i > 0 && value <= contacts[i-1] {
|
||||
t.Fatalf("contacts not increasing at %d: %v", i, contacts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLunarEclipseDiagramSkipsAbsentPhases(t *testing.T) {
|
||||
diagram := LunarEclipseDiagram(2458860.0, LunarEclipseDiagramOptions{})
|
||||
if len(diagram.Points) == 0 {
|
||||
t.Fatalf("penumbral eclipse diagram should still have path points")
|
||||
}
|
||||
for _, point := range diagram.Points {
|
||||
if !isFiniteFloat(point.JDE) || !isFiniteFloat(point.X) || !isFiniteFloat(point.Y) {
|
||||
t.Fatalf("non-finite diagram point: %+v", point)
|
||||
}
|
||||
}
|
||||
if empty := LunarEclipseDiagram(JDCalc(2025, 1, 13), LunarEclipseDiagramOptions{}); len(empty.Points) != 0 {
|
||||
t.Fatalf("eclipse-free diagram should have no points, got %d", len(empty.Points))
|
||||
}
|
||||
}
|
||||
@@ -111,11 +111,11 @@ func lunarEclipseDiagram(
|
||||
result.StepDays = stepDays
|
||||
result.Points = make([]LunarEclipseDiagramPoint, 0, len(times))
|
||||
for _, item := range times {
|
||||
state := computeLunarShadowState(item.jde, shadowModel)
|
||||
plane := lunarEclipsePlaneStateAt(item.jde)
|
||||
result.Points = append(result.Points, LunarEclipseDiagramPoint{
|
||||
JDE: item.jde,
|
||||
X: state.x / maximumState.moonRadiusRad,
|
||||
Y: state.y / maximumState.moonRadiusRad,
|
||||
X: plane.x / maximumState.moonRadiusRad,
|
||||
Y: plane.y / maximumState.moonRadiusRad,
|
||||
Label: lunarEclipseDiagramPrimaryLabel(item.labels),
|
||||
Labels: append([]string(nil), item.labels...),
|
||||
})
|
||||
@@ -136,7 +136,7 @@ func normalizeLunarEclipseDiagramOptions(options LunarEclipseDiagramOptions) Lun
|
||||
func lunarEclipseDiagramTimes(eclipse LunarEclipseResult, stepDays float64) ([]lunarEclipseDiagramTime, float64) {
|
||||
startJDE := eclipse.PenumbralStart
|
||||
endJDE := eclipse.PenumbralEnd
|
||||
if startJDE == 0 || endJDE == 0 || endJDE <= startJDE {
|
||||
if !isFiniteFloat(startJDE) || !isFiniteFloat(endJDE) || endJDE <= startJDE {
|
||||
return nil, stepDays
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ func uniqueLunarEclipseDiagramTimes(times []lunarEclipseDiagramTime) []lunarEcli
|
||||
|
||||
unique := times[:0]
|
||||
for _, item := range times {
|
||||
if item.jde == 0 {
|
||||
if !isFiniteFloat(item.jde) {
|
||||
continue
|
||||
}
|
||||
if len(unique) == 0 || math.Abs(item.jde-unique[len(unique)-1].jde) > lunarEclipseDiagramDuplicateDays {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package basic
|
||||
|
||||
import "math"
|
||||
|
||||
// LunarEclipseShadowGeometry 是食甚时刻的地影几何。
|
||||
// 注意单位口径:Gamma 用地球赤道半径,而两个影半径用度——后者是 NASA 月食图上 P./U. Radius 的口径,
|
||||
// 换成地球赤道半径要乘以月球处的地球视差(弧度)。
|
||||
// LunarEclipseShadowGeometry is the terrestrial-shadow geometry at maximum eclipse. Gamma is in Earth
|
||||
// equatorial radii while the two shadow radii are in degrees, matching the P./U. Radius convention of
|
||||
// NASA lunar-eclipse charts; multiply a radius by the Earth's parallax at the Moon to get Earth radii.
|
||||
type LunarEclipseShadowGeometry struct {
|
||||
// Gamma 是月心到地影轴的最小距离,单位地球赤道半径。
|
||||
// Gamma is the least distance from the Moon's centre to the shadow axis, in Earth equatorial radii.
|
||||
Gamma float64
|
||||
// PenumbralRadiusDegrees 与 UmbralRadiusDegrees 是半影、本影在地影轴垂直面上的角半径,单位度。
|
||||
// PenumbralRadiusDegrees and UmbralRadiusDegrees are the penumbral and umbral angular radii on the
|
||||
// plane perpendicular to the shadow axis, in degrees.
|
||||
PenumbralRadiusDegrees float64
|
||||
UmbralRadiusDegrees float64
|
||||
// MoonDistanceEarthRadii 是食甚时的地心月距,单位地球赤道半径。
|
||||
// MoonDistanceEarthRadii is the geocentric lunar distance at greatest eclipse, in Earth equatorial radii.
|
||||
MoonDistanceEarthRadii float64
|
||||
// AxisDegrees 是食甚时月心到地影轴的角距,即 NASA 月食图上那列 Axis。
|
||||
// 它与 Gamma 是同一个量的两种刻度:Gamma 除以月球处的地球视差就是它。
|
||||
// AxisDegrees is the angular distance from the Moon's centre to the shadow axis at greatest eclipse,
|
||||
// the column NASA lunar-eclipse charts print as Axis. It is the same quantity as Gamma on a different
|
||||
// scale: divide Gamma by the Earth's parallax at the Moon.
|
||||
AxisDegrees float64
|
||||
}
|
||||
|
||||
// LunarEclipseShadowGeometryAt 用 Danjon 影半径模型计算食甚时刻的地影几何。
|
||||
// LunarEclipseShadowGeometryAt computes the shadow geometry at maximum eclipse with the Danjon shadow model.
|
||||
func LunarEclipseShadowGeometryAt(maximumJDE float64) LunarEclipseShadowGeometry {
|
||||
return lunarEclipseShadowGeometryAt(maximumJDE, lunarEclipseShadowDanjon)
|
||||
}
|
||||
|
||||
// LunarEclipseShadowGeometryChauvenetAt 用 Chauvenet 影半径模型计算食甚时刻的地影几何。
|
||||
// LunarEclipseShadowGeometryChauvenetAt computes the shadow geometry with the Chauvenet shadow model.
|
||||
func LunarEclipseShadowGeometryChauvenetAt(maximumJDE float64) LunarEclipseShadowGeometry {
|
||||
return lunarEclipseShadowGeometryAt(maximumJDE, lunarEclipseShadowChauvenet)
|
||||
}
|
||||
|
||||
// LunarEclipseShadowGeometryAtModel 按结果里记录的影半径模型取地影几何。
|
||||
// LunarEclipseShadowGeometryAtModel picks the shadow geometry by the model recorded in a result.
|
||||
func LunarEclipseShadowGeometryAtModel(maximumJDE float64, model LunarEclipseShadowModel) LunarEclipseShadowGeometry {
|
||||
if model == LunarEclipseShadowModelChauvenet {
|
||||
return LunarEclipseShadowGeometryChauvenetAt(maximumJDE)
|
||||
}
|
||||
return LunarEclipseShadowGeometryAt(maximumJDE)
|
||||
}
|
||||
|
||||
func lunarEclipseShadowGeometryAt(maximumJDE float64, shadowModel lunarEclipseShadowModel) LunarEclipseShadowGeometry {
|
||||
state := computeLunarShadowState(maximumJDE, shadowModel)
|
||||
moonDistanceKM := HMoonAway(maximumJDE)
|
||||
// 影半径是以地心为顶点的角量,除以月球处的地球视差就换成地球赤道半径。
|
||||
earthParallax := lunarEarthEquatorialRadiusKM / moonDistanceKM
|
||||
_, _, _, _, minimumDistance := refineLunarEclipseMaximum(maximumJDE, shadowModel)
|
||||
if earthParallax <= 0 {
|
||||
return LunarEclipseShadowGeometry{}
|
||||
}
|
||||
return LunarEclipseShadowGeometry{
|
||||
Gamma: minimumDistance / earthParallax,
|
||||
PenumbralRadiusDegrees: state.penumbraRadiusRad * 180 / math.Pi,
|
||||
UmbralRadiusDegrees: state.umbraRadiusRad * 180 / math.Pi,
|
||||
MoonDistanceEarthRadii: moonDistanceKM / lunarEarthEquatorialRadiusKM,
|
||||
// 平面 x 向东(黄经差)、y 向北(黄纬和),方位角自北向东量。
|
||||
AxisDegrees: minimumDistance * 180 / math.Pi,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Axis 与 Gamma 是同一个量的两种刻度:Gamma × 月球处的地球视差 = Axis。
|
||||
func TestLunarEclipseShadowGeometryAxisMatchesGamma(t *testing.T) {
|
||||
for _, date := range []time.Time{
|
||||
time.Date(2025, time.March, 14, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2028, time.July, 6, 0, 0, 0, 0, time.UTC),
|
||||
} {
|
||||
result := LunarEclipse(UTC2TT(Date2JD(date)))
|
||||
geometry := LunarEclipseShadowGeometryAt(result.Maximum)
|
||||
// 影几何内部用的是小角近似的地球视差(R⊕/月距),这里必须用同一形式,否则二级差会露出来。
|
||||
earthParallaxDegrees := (1 / geometry.MoonDistanceEarthRadii) * 180 / math.Pi
|
||||
if delta := geometry.Gamma*earthParallaxDegrees - geometry.AxisDegrees; math.Abs(delta) > 1e-9 {
|
||||
t.Fatalf("%s: Gamma×parallax - Axis = %g", date.Format("2006-01-02"), delta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 食分公式反解出的月心到影轴距离必须等于 Axis:这条把影几何与食分求解器对起来。
|
||||
// 食分 = (影半径 + 月视半径 − Axis) / (2 × 月视半径)。
|
||||
func TestLunarEclipseMagnitudeInvertsToAxis(t *testing.T) {
|
||||
for _, date := range []time.Time{
|
||||
time.Date(2025, time.March, 14, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2028, time.July, 6, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2020, time.November, 30, 0, 0, 0, 0, time.UTC),
|
||||
} {
|
||||
result := LunarEclipse(UTC2TT(Date2JD(date)))
|
||||
geometry := LunarEclipseShadowGeometryAt(result.Maximum)
|
||||
moonSemidiameter := MoonSemidiameter(result.Maximum) / 3600
|
||||
fromUmbral := geometry.UmbralRadiusDegrees + moonSemidiameter -
|
||||
2*moonSemidiameter*result.Magnitude
|
||||
fromPenumbral := geometry.PenumbralRadiusDegrees + moonSemidiameter -
|
||||
2*moonSemidiameter*result.PenumbralMagnitude
|
||||
t.Logf("%s %s: Axis=%.4f 本影反推=%.4f 半影反推=%.4f",
|
||||
date.Format("2006-01-02"), result.Type, geometry.AxisDegrees, fromUmbral, fromPenumbral)
|
||||
if math.Abs(fromUmbral-geometry.AxisDegrees) > 0.002 {
|
||||
t.Fatalf("%s: umbral magnitude implies axis %.4f, geometry gives %.4f",
|
||||
date.Format("2006-01-02"), fromUmbral, geometry.AxisDegrees)
|
||||
}
|
||||
if math.Abs(fromPenumbral-geometry.AxisDegrees) > 0.002 {
|
||||
t.Fatalf("%s: penumbral magnitude implies axis %.4f, geometry gives %.4f",
|
||||
date.Format("2006-01-02"), fromPenumbral, geometry.AxisDegrees)
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
-25
@@ -27,7 +27,7 @@ func TestLunarEclipseChauvenetAgainstLegacyBaseline(t *testing.T) {
|
||||
testCases := []lunarEclipseBaseline{
|
||||
{
|
||||
name: "2022-11-08 total",
|
||||
jde: JDECalc(2022, 11, 8),
|
||||
jde: JDCalc(2022, 11, 8),
|
||||
expectedType: LunarEclipseTotal,
|
||||
expectedMax: 2459891.9585873615,
|
||||
expectedMag: 1.3635170051692678,
|
||||
@@ -40,14 +40,14 @@ func TestLunarEclipseChauvenetAgainstLegacyBaseline(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "2023-05-05 penumbral",
|
||||
jde: JDECalc(2023, 5, 5),
|
||||
jde: JDCalc(2023, 5, 5),
|
||||
expectedType: LunarEclipsePenumbral,
|
||||
expectedPenumbralStart: 2460070.1342392800,
|
||||
expectedPenumbralEnd: 2460070.3159191823,
|
||||
},
|
||||
{
|
||||
name: "2023-10-28 partial",
|
||||
jde: JDECalc(2023, 10, 28),
|
||||
jde: JDCalc(2023, 10, 28),
|
||||
expectedType: LunarEclipsePartial,
|
||||
expectedMax: 2460246.3439460830,
|
||||
expectedMag: 0.12723850274626405,
|
||||
@@ -58,14 +58,14 @@ func TestLunarEclipseChauvenetAgainstLegacyBaseline(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "2024-03-25 penumbral",
|
||||
jde: JDECalc(2024, 3, 25),
|
||||
jde: JDCalc(2024, 3, 25),
|
||||
expectedType: LunarEclipsePenumbral,
|
||||
expectedPenumbralStart: 2460394.7028870000,
|
||||
expectedPenumbralEnd: 2460394.8999071894,
|
||||
},
|
||||
{
|
||||
name: "2024-09-18 partial",
|
||||
jde: JDECalc(2024, 9, 18),
|
||||
jde: JDCalc(2024, 9, 18),
|
||||
expectedType: LunarEclipsePartial,
|
||||
expectedMax: 2460571.6148748010,
|
||||
expectedMag: 0.09042791952817894,
|
||||
@@ -76,7 +76,7 @@ func TestLunarEclipseChauvenetAgainstLegacyBaseline(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "2025-03-14 total",
|
||||
jde: JDECalc(2025, 3, 14),
|
||||
jde: JDCalc(2025, 3, 14),
|
||||
expectedType: LunarEclipseTotal,
|
||||
expectedMax: 2460748.7916214615,
|
||||
expectedMag: 1.1828107517800281,
|
||||
@@ -89,7 +89,7 @@ func TestLunarEclipseChauvenetAgainstLegacyBaseline(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "2025-09-07 total",
|
||||
jde: JDECalc(2025, 9, 7),
|
||||
jde: JDCalc(2025, 9, 7),
|
||||
expectedType: LunarEclipseTotal,
|
||||
expectedMax: 2460926.2590034613,
|
||||
expectedMag: 1.3672329695760280,
|
||||
@@ -102,7 +102,7 @@ func TestLunarEclipseChauvenetAgainstLegacyBaseline(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "2026-03-03 total",
|
||||
jde: JDECalc(2026, 3, 3),
|
||||
jde: JDCalc(2026, 3, 3),
|
||||
expectedType: LunarEclipseTotal,
|
||||
expectedMax: 2461102.9825476190,
|
||||
expectedMag: 1.1556387222746651,
|
||||
@@ -133,12 +133,12 @@ func TestLunarEclipseChauvenetAgainstLegacyBaseline(t *testing.T) {
|
||||
t.Fatalf("Magnitude mismatch: got %.12f want %.12f", result.Magnitude, tc.expectedMag)
|
||||
}
|
||||
|
||||
assertCloseJD(t, "PenumbralStart", result.PenumbralStart, tc.expectedPenumbralStart, timeTolerance)
|
||||
assertCloseJD(t, "PenumbralEnd", result.PenumbralEnd, tc.expectedPenumbralEnd, timeTolerance)
|
||||
assertCloseJD(t, "PartialStart", result.PartialStart, tc.expectedPartialStart, timeTolerance)
|
||||
assertCloseJD(t, "PartialEnd", result.PartialEnd, tc.expectedPartialEnd, timeTolerance)
|
||||
assertCloseJD(t, "TotalStart", result.TotalStart, tc.expectedTotalStart, timeTolerance)
|
||||
assertCloseJD(t, "TotalEnd", result.TotalEnd, tc.expectedTotalEnd, timeTolerance)
|
||||
assertContactJD(t, "PenumbralStart", result.PenumbralStart, tc.expectedPenumbralStart, timeTolerance)
|
||||
assertContactJD(t, "PenumbralEnd", result.PenumbralEnd, tc.expectedPenumbralEnd, timeTolerance)
|
||||
assertContactJD(t, "PartialStart", result.PartialStart, tc.expectedPartialStart, timeTolerance)
|
||||
assertContactJD(t, "PartialEnd", result.PartialEnd, tc.expectedPartialEnd, timeTolerance)
|
||||
assertContactJD(t, "TotalStart", result.TotalStart, tc.expectedTotalStart, timeTolerance)
|
||||
assertContactJD(t, "TotalEnd", result.TotalEnd, tc.expectedTotalEnd, timeTolerance)
|
||||
|
||||
if result.HasTotal && !(result.TotalStart < result.Maximum && result.Maximum < result.TotalEnd) {
|
||||
t.Fatalf("total contact order invalid: start=%.12f max=%.12f end=%.12f", result.TotalStart, result.Maximum, result.TotalEnd)
|
||||
@@ -154,7 +154,7 @@ func TestLunarEclipseChauvenetAgainstLegacyBaseline(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLunarEclipseDefaultUsesDanjon(t *testing.T) {
|
||||
jde := JDECalc(2025, 3, 14)
|
||||
jde := JDCalc(2025, 3, 14)
|
||||
defaultResult := LunarEclipse(jde)
|
||||
danjonResult := LunarEclipseDanjon(jde)
|
||||
chauvenetResult := LunarEclipseChauvenet(jde)
|
||||
@@ -176,9 +176,9 @@ func TestPenumbralLunarEclipseKeepsNegativeUmbralMagnitude(t *testing.T) {
|
||||
jde float64
|
||||
calc func(float64) LunarEclipseResult
|
||||
}{
|
||||
{name: "default 2024-03-25", jde: JDECalc(2024, 3, 25), calc: LunarEclipse},
|
||||
{name: "danjon 2024-03-25", jde: JDECalc(2024, 3, 25), calc: LunarEclipseDanjon},
|
||||
{name: "chauvenet 2023-05-05", jde: JDECalc(2023, 5, 5), calc: LunarEclipseChauvenet},
|
||||
{name: "default 2024-03-25", jde: JDCalc(2024, 3, 25), calc: LunarEclipse},
|
||||
{name: "danjon 2024-03-25", jde: JDCalc(2024, 3, 25), calc: LunarEclipseDanjon},
|
||||
{name: "chauvenet 2023-05-05", jde: JDCalc(2023, 5, 5), calc: LunarEclipseChauvenet},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -210,28 +210,28 @@ func TestLunarEclipseDanjonMagnitudesCloserToNASA(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "2023-10-28 partial",
|
||||
jde: JDECalc(2023, 10, 28),
|
||||
jde: JDCalc(2023, 10, 28),
|
||||
expectedType: LunarEclipsePartial,
|
||||
nasaPenumbralMagnitude: 1.1181,
|
||||
nasaUmbralMagnitude: 0.1220,
|
||||
},
|
||||
{
|
||||
name: "2025-03-14 total",
|
||||
jde: JDECalc(2025, 3, 14),
|
||||
jde: JDCalc(2025, 3, 14),
|
||||
expectedType: LunarEclipseTotal,
|
||||
nasaPenumbralMagnitude: 2.2595,
|
||||
nasaUmbralMagnitude: 1.1784,
|
||||
},
|
||||
{
|
||||
name: "2026-03-03 total",
|
||||
jde: JDECalc(2026, 3, 3),
|
||||
jde: JDCalc(2026, 3, 3),
|
||||
expectedType: LunarEclipseTotal,
|
||||
nasaPenumbralMagnitude: 2.1838,
|
||||
nasaUmbralMagnitude: 1.1507,
|
||||
},
|
||||
{
|
||||
name: "2026-08-28 partial",
|
||||
jde: JDECalc(2026, 8, 28),
|
||||
jde: JDCalc(2026, 8, 28),
|
||||
expectedType: LunarEclipsePartial,
|
||||
nasaPenumbralMagnitude: 1.9645,
|
||||
nasaUmbralMagnitude: 0.9299,
|
||||
@@ -277,15 +277,24 @@ func TestLunarEclipseNoEvent(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := tc.calc(JDECalc(2023, 6, 4))
|
||||
result := tc.calc(JDCalc(2023, 6, 4))
|
||||
if result.Type != LunarEclipseNone {
|
||||
t.Fatalf("Type mismatch: got %s want %s", result.Type, LunarEclipseNone)
|
||||
}
|
||||
if result.HasPenumbral || result.HasPartial || result.HasTotal {
|
||||
t.Fatalf("unexpected contacts: %+v", result)
|
||||
}
|
||||
if result.PenumbralStart != 0 || result.PenumbralEnd != 0 || result.PartialStart != 0 || result.PartialEnd != 0 || result.TotalStart != 0 || result.TotalEnd != 0 {
|
||||
t.Fatalf("expected no contact times, got %+v", result)
|
||||
for name, value := range map[string]float64{
|
||||
"PenumbralStart": result.PenumbralStart,
|
||||
"PenumbralEnd": result.PenumbralEnd,
|
||||
"PartialStart": result.PartialStart,
|
||||
"PartialEnd": result.PartialEnd,
|
||||
"TotalStart": result.TotalStart,
|
||||
"TotalEnd": result.TotalEnd,
|
||||
} {
|
||||
if !math.IsNaN(value) {
|
||||
t.Fatalf("%s = %.12f, want NaN for a non-eclipse: %+v", name, value, result)
|
||||
}
|
||||
}
|
||||
if result.Magnitude != 0 || result.PenumbralMagnitude != 0 {
|
||||
t.Fatalf("expected zero magnitudes for non-eclipse, got %+v", result)
|
||||
@@ -306,3 +315,15 @@ func assertCloseJD(t *testing.T, name string, got, want, tolerance float64) {
|
||||
t.Fatalf("%s mismatch: got %.12f want %.12f", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// assertContactJD 未发生阶段的期望值是 0(基准表写法),此时实际值必须是 NaN 而不是 JD 0。
|
||||
func assertContactJD(t *testing.T, name string, got, want, tolerance float64) {
|
||||
t.Helper()
|
||||
if want == 0 {
|
||||
if !math.IsNaN(got) {
|
||||
t.Fatalf("%s = %.12f, want NaN for an absent phase", name, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
assertCloseJD(t, name, got, want, tolerance)
|
||||
}
|
||||
|
||||
+3
-5
@@ -3,16 +3,14 @@ package basic
|
||||
import "math"
|
||||
|
||||
func GetLunar(year, month, day int, tz float64) (lyear, lmonth, lday int, leap bool, result string) {
|
||||
julianDayEpoch := JDECalc(year, month, float64(day))
|
||||
julianDayEpoch := JDCalc(year, month, float64(day))
|
||||
// 确定农历年份
|
||||
lyear = year
|
||||
adjustedYear := year
|
||||
if month == 11 || month == 12 {
|
||||
winterSolsticeDay := GetJQTime(year, 270) + tz
|
||||
//firstNewMoonDay := TD2UT(CalcMoonS(float64(year)+11.0/12.0+5.0/30.0/12.0, 0), true) + tz
|
||||
//nextNewMoonDay := TD2UT(CalcMoonS(float64(year)+1.0, 0), true) + tz
|
||||
firstNewMoonDay := TD2UT(CalcMoonSHByJDE(winterSolsticeDay-16, 0), false) + tz
|
||||
nextNewMoonDay := TD2UT(CalcMoonSHByJDE(firstNewMoonDay+28, 0), false) + tz
|
||||
firstNewMoonDay := TT2UTC(CalcMoonSHByJDE(winterSolsticeDay-16, 0)) + tz
|
||||
nextNewMoonDay := TT2UTC(CalcMoonSHByJDE(firstNewMoonDay+28, 0)) + tz
|
||||
|
||||
firstNewMoonDay = normalizeTimePoint(firstNewMoonDay)
|
||||
nextNewMoonDay = normalizeTimePoint(nextNewMoonDay)
|
||||
|
||||
+80
-120
@@ -7,79 +7,79 @@ import (
|
||||
. "b612.me/astro/tools"
|
||||
)
|
||||
|
||||
func MarsL(jd float64) float64 {
|
||||
return planet.WherePlanet(3, 0, jd)
|
||||
func MarsL(jde float64) float64 {
|
||||
return planet.WherePlanet(3, 0, jde)
|
||||
}
|
||||
|
||||
func MarsB(jd float64) float64 {
|
||||
return planet.WherePlanet(3, 1, jd)
|
||||
func MarsB(jde float64) float64 {
|
||||
return planet.WherePlanet(3, 1, jde)
|
||||
}
|
||||
func MarsR(jd float64) float64 {
|
||||
return planet.WherePlanet(3, 2, jd)
|
||||
func MarsR(jde float64) float64 {
|
||||
return planet.WherePlanet(3, 2, jde)
|
||||
}
|
||||
func AMarsX(jd float64) float64 {
|
||||
l := MarsL(jd)
|
||||
b := MarsB(jd)
|
||||
r := MarsR(jd)
|
||||
el := planet.WherePlanet(-1, 0, jd)
|
||||
eb := planet.WherePlanet(-1, 1, jd)
|
||||
er := planet.WherePlanet(-1, 2, jd)
|
||||
func AMarsX(jde float64) float64 {
|
||||
l := MarsL(jde)
|
||||
b := MarsB(jde)
|
||||
r := MarsR(jde)
|
||||
el := planet.WherePlanet(-1, 0, jde)
|
||||
eb := planet.WherePlanet(-1, 1, jde)
|
||||
er := planet.WherePlanet(-1, 2, jde)
|
||||
x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el)
|
||||
return x
|
||||
}
|
||||
|
||||
func AMarsY(jd float64) float64 {
|
||||
func AMarsY(jde float64) float64 {
|
||||
|
||||
l := MarsL(jd)
|
||||
b := MarsB(jd)
|
||||
r := MarsR(jd)
|
||||
el := planet.WherePlanet(-1, 0, jd)
|
||||
eb := planet.WherePlanet(-1, 1, jd)
|
||||
er := planet.WherePlanet(-1, 2, jd)
|
||||
l := MarsL(jde)
|
||||
b := MarsB(jde)
|
||||
r := MarsR(jde)
|
||||
el := planet.WherePlanet(-1, 0, jde)
|
||||
eb := planet.WherePlanet(-1, 1, jde)
|
||||
er := planet.WherePlanet(-1, 2, jde)
|
||||
y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el)
|
||||
return y
|
||||
}
|
||||
func AMarsZ(jd float64) float64 {
|
||||
//l := MarsL(jd)
|
||||
b := MarsB(jd)
|
||||
r := MarsR(jd)
|
||||
// el := planet.WherePlanet(-1, 0, jd)
|
||||
eb := planet.WherePlanet(-1, 1, jd)
|
||||
er := planet.WherePlanet(-1, 2, jd)
|
||||
func AMarsZ(jde float64) float64 {
|
||||
//l := MarsL(jde)
|
||||
b := MarsB(jde)
|
||||
r := MarsR(jde)
|
||||
// el := planet.WherePlanet(-1, 0, jde)
|
||||
eb := planet.WherePlanet(-1, 1, jde)
|
||||
er := planet.WherePlanet(-1, 2, jde)
|
||||
z := r*Sin(b) - er*Sin(eb)
|
||||
return z
|
||||
}
|
||||
|
||||
func AMarsXYZ(jd float64) (float64, float64, float64) {
|
||||
l := MarsL(jd)
|
||||
b := MarsB(jd)
|
||||
r := MarsR(jd)
|
||||
el := planet.WherePlanet(-1, 0, jd)
|
||||
eb := planet.WherePlanet(-1, 1, jd)
|
||||
er := planet.WherePlanet(-1, 2, jd)
|
||||
func AMarsXYZ(jde float64) (float64, float64, float64) {
|
||||
l := MarsL(jde)
|
||||
b := MarsB(jde)
|
||||
r := MarsR(jde)
|
||||
el := planet.WherePlanet(-1, 0, jde)
|
||||
eb := planet.WherePlanet(-1, 1, jde)
|
||||
er := planet.WherePlanet(-1, 2, jde)
|
||||
x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el)
|
||||
y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el)
|
||||
z := r*Sin(b) - er*Sin(eb)
|
||||
return x, y, z
|
||||
}
|
||||
|
||||
func MarsApparentRa(jd float64) float64 {
|
||||
lo, bo := MarsApparentLoBo(jd)
|
||||
eps := TrueObliquity(jd)
|
||||
func MarsApparentRa(jde float64) float64 {
|
||||
lo, bo := MarsApparentLoBo(jde)
|
||||
eps := TrueObliquity(jde)
|
||||
ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo))
|
||||
ra = ra * 180 / math.Pi
|
||||
return Limit360(ra)
|
||||
}
|
||||
func MarsApparentDec(jd float64) float64 {
|
||||
lo, bo := MarsApparentLoBo(jd)
|
||||
eps := TrueObliquity(jd)
|
||||
func MarsApparentDec(jde float64) float64 {
|
||||
lo, bo := MarsApparentLoBo(jde)
|
||||
eps := TrueObliquity(jde)
|
||||
dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo))
|
||||
return dec
|
||||
}
|
||||
|
||||
func MarsApparentRaDec(jd float64) (float64, float64) {
|
||||
lo, bo := MarsApparentLoBo(jd)
|
||||
eps := TrueObliquity(jd)
|
||||
func MarsApparentRaDec(jde float64) (float64, float64) {
|
||||
lo, bo := MarsApparentLoBo(jde)
|
||||
eps := TrueObliquity(jde)
|
||||
ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo))
|
||||
ra = ra * 180 / math.Pi
|
||||
dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo))
|
||||
@@ -87,90 +87,50 @@ func MarsApparentRaDec(jd float64) (float64, float64) {
|
||||
}
|
||||
|
||||
func EarthMarsAway(jd float64) float64 {
|
||||
x, y, z := AMarsXYZ(jd)
|
||||
to := math.Sqrt(x*x + y*y + z*z)
|
||||
return to
|
||||
return planetEarthAwayExplicitN(3, jd, -1)
|
||||
}
|
||||
|
||||
func MarsApparentLo(jd float64) float64 {
|
||||
x, y, z := AMarsXYZ(jd)
|
||||
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
|
||||
x, y, z = AMarsXYZ(jd - to)
|
||||
lo := math.Atan2(y, x)
|
||||
//bo := math.Atan2(z, math.Sqrt(x*x+y*y))
|
||||
lo = lo * 180.0 / math.Pi
|
||||
//bo = bo * 180 / math.Pi
|
||||
lo = Limit360(lo) + Nutation2000Bi(jd)
|
||||
//lo-=GXCLo(lo,bo,jd)/3600;
|
||||
//bo+=GXCBo(lo,bo,jd);
|
||||
return lo
|
||||
geo, _ := planetApparentGeocentricPositionN(3, jd, -1)
|
||||
return geo.lo
|
||||
}
|
||||
|
||||
func MarsApparentBo(jd float64) float64 {
|
||||
x, y, z := AMarsXYZ(jd)
|
||||
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
|
||||
x, y, z = AMarsXYZ(jd - to)
|
||||
//lo := math.Atan2(y, x)
|
||||
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
|
||||
//lo = lo * 180 / math.Pi
|
||||
bo = bo * 180 / math.Pi
|
||||
//lo+=GXCLo(lo,bo,jd);
|
||||
//bo+=GXCBo(lo,bo,jd)/3600;
|
||||
//lo+=Nutation2000Bi(jd);
|
||||
return bo
|
||||
geo, _ := planetApparentGeocentricPositionN(3, jd, -1)
|
||||
return geo.bo
|
||||
}
|
||||
|
||||
func MarsApparentLoBo(jd float64) (float64, float64) {
|
||||
x, y, z := AMarsXYZ(jd)
|
||||
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
|
||||
x, y, z = AMarsXYZ(jd - to)
|
||||
lo := math.Atan2(y, x)
|
||||
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
|
||||
lo = lo * 180 / math.Pi
|
||||
bo = bo * 180 / math.Pi
|
||||
lo = Limit360(lo)
|
||||
//lo -= GXCLo(lo, bo, jd) / 3600
|
||||
//bo += GXCBo(lo, bo, jd)
|
||||
lo += Nutation2000Bi(jd)
|
||||
return lo, bo
|
||||
geo, _ := planetApparentGeocentricPositionN(3, jd, -1)
|
||||
return geo.lo, geo.bo
|
||||
}
|
||||
|
||||
func MarsTrueLoBo(jd float64) (float64, float64) {
|
||||
x, y, z := AMarsXYZ(jd)
|
||||
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
|
||||
x, y, z = AMarsXYZ(jd - to)
|
||||
lo := math.Atan2(y, x)
|
||||
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
|
||||
lo = lo * 180 / math.Pi
|
||||
bo = bo * 180 / math.Pi
|
||||
lo = Limit360(lo)
|
||||
return lo, bo
|
||||
geo, _ := planetTrueGeocentricPositionN(3, jd, -1)
|
||||
return geo.lo, geo.bo
|
||||
}
|
||||
|
||||
func MarsTrueLo(jd float64) float64 {
|
||||
x, y, _ := AMarsXYZ(jd)
|
||||
lo := math.Atan2(y, x)
|
||||
lo = lo * 180 / math.Pi
|
||||
lo = Limit360(lo)
|
||||
return lo
|
||||
geo, _ := planetTrueGeocentricPositionN(3, jd, -1)
|
||||
return geo.lo
|
||||
}
|
||||
|
||||
func MarsMag(jd float64) float64 {
|
||||
sunDistance := MarsR(jd)
|
||||
earthDistance := EarthMarsAway(jd)
|
||||
earthSunDistance := planet.WherePlanet(-1, 2, jd)
|
||||
func MarsMag(jde float64) float64 {
|
||||
sunDistance := MarsR(jde)
|
||||
earthDistance := EarthMarsAway(jde)
|
||||
earthSunDistance := planet.WherePlanet(-1, 2, jde)
|
||||
i := (sunDistance*sunDistance + earthDistance*earthDistance - earthSunDistance*earthSunDistance) / (2 * sunDistance * earthDistance)
|
||||
i = ArcCos(i)
|
||||
mag := -1.52 + 5*math.Log10(sunDistance*earthDistance) + 0.016*i
|
||||
return FloatRound(mag, 2)
|
||||
}
|
||||
|
||||
func MarsHeight(jde, lon, lat, timezone float64) float64 {
|
||||
func MarsHeight(localJD, lon, lat, timezone float64) float64 {
|
||||
// 转换为世界时
|
||||
utcJde := jde - timezone/24.0
|
||||
utcJD := localJD - timezone/24.0
|
||||
// 计算视恒星时
|
||||
ra, dec := MarsApparentRaDec(TD2UT(utcJde, true))
|
||||
st := Limit360(ApparentSiderealTime(utcJde)*15 + lon)
|
||||
ra, dec := MarsApparentRaDec(UTC2TT(utcJD))
|
||||
st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon)
|
||||
// 计算时角
|
||||
hourAngle := Limit360(st - ra)
|
||||
// 高度角、时角与天球座标三角转换公式
|
||||
@@ -179,12 +139,12 @@ func MarsHeight(jde, lon, lat, timezone float64) float64 {
|
||||
return ArcSin(sinHeight)
|
||||
}
|
||||
|
||||
func MarsAzimuth(jde, lon, lat, timezone float64) float64 {
|
||||
func MarsAzimuth(localJD, lon, lat, timezone float64) float64 {
|
||||
// 转换为世界时
|
||||
utcJde := jde - timezone/24.0
|
||||
utcJD := localJD - timezone/24.0
|
||||
// 计算视恒星时
|
||||
ra, dec := MarsApparentRaDec(TD2UT(utcJde, true))
|
||||
st := Limit360(ApparentSiderealTime(utcJde)*15 + lon)
|
||||
ra, dec := MarsApparentRaDec(UTC2TT(utcJD))
|
||||
st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon)
|
||||
// 计算时角
|
||||
hourAngle := Limit360(st - ra)
|
||||
// 三角转换公式
|
||||
@@ -203,34 +163,34 @@ func MarsAzimuth(jde, lon, lat, timezone float64) float64 {
|
||||
}
|
||||
|
||||
func MarsHourAngle(jd, lon, timezone float64) float64 {
|
||||
siderealLongitude := Limit360(ApparentSiderealTime(jd-timezone/24)*15 + lon)
|
||||
hourAngle := siderealLongitude - MarsApparentRa(TD2UT(jd-timezone/24.0, true))
|
||||
siderealLongitude := Limit360(ApparentSiderealTime(UTC2UT1(jd-timezone/24))*15 + lon)
|
||||
hourAngle := siderealLongitude - MarsApparentRa(UTC2TT(jd-timezone/24.0))
|
||||
if hourAngle < 0 {
|
||||
hourAngle += 360
|
||||
}
|
||||
return hourAngle
|
||||
}
|
||||
|
||||
func MarsCulminationTime(jde, lon, timezone float64) float64 {
|
||||
//jde 世界时,非力学时,当地时区 0时,无需转换力学时
|
||||
func MarsCulminationTime(localJD, lon, timezone float64) float64 {
|
||||
// localJD 是本地民用日锚点(当地 0 时),不是力学时。
|
||||
//ra,dec 瞬时天球座标,非J2000等时间天球坐标
|
||||
jde = math.Floor(jde) + 0.5
|
||||
estimateJD := jde + Limit360(360-MarsHourAngle(jde, lon, timezone))/15.0/24.0*0.99726851851851851851
|
||||
normalizedHourAngle := func(jde, lon, timezone float64) float64 {
|
||||
currentHourAngle := MarsHourAngle(jde, lon, timezone)
|
||||
localJD = math.Floor(localJD) + 0.5
|
||||
estimateJD := localJD + Limit360(360-MarsHourAngle(localJD, lon, timezone))/15.0/24.0*0.99726851851851851851
|
||||
normalizedHourAngle := func(localJD, lon, timezone float64) float64 {
|
||||
currentHourAngle := MarsHourAngle(localJD, lon, timezone)
|
||||
if currentHourAngle < 180 {
|
||||
currentHourAngle += 360
|
||||
}
|
||||
return currentHourAngle
|
||||
}
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
var ok bool
|
||||
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
|
||||
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
|
||||
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
|
||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
||||
break
|
||||
}
|
||||
return hourAngleDelta / hourAngleSlope
|
||||
})
|
||||
if !ok {
|
||||
return math.NaN()
|
||||
}
|
||||
return estimateJD
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func marsEventSamples() []time.Time {
|
||||
}
|
||||
|
||||
func marsEventSampleTTJD(date time.Time) float64 {
|
||||
return TD2UT(Date2JDE(date.UTC()), true)
|
||||
return UTC2TT(Date2JD(date.UTC()))
|
||||
}
|
||||
|
||||
func marsEventCases() []marsEventCase {
|
||||
|
||||
+145
-77
@@ -9,9 +9,12 @@ import (
|
||||
// Pos
|
||||
|
||||
const (
|
||||
MARS_S_PERIOD = 1 / ((1 / 365.256363004) - (1 / 686.98))
|
||||
marsEventSearchN = 16
|
||||
marsPhaseCoarseTolerance = 30.0 / 86400.0
|
||||
MARS_S_PERIOD = 1 / ((1 / 365.256363004) - (1 / 686.98))
|
||||
marsEventSearchN = 16
|
||||
marsPhaseCoarseTolerance = 30.0 / 86400.0
|
||||
marsStationDerivativeStepDay = 0.01
|
||||
marsStationCoarseStepDay = 6.0
|
||||
marsStationHalfWindowDay = 6.0
|
||||
)
|
||||
|
||||
func marsSunLongitudeDelta(jde, degree float64, filter bool) float64 {
|
||||
@@ -64,6 +67,9 @@ func marsRADerivativeN(jde, val float64, n int) float64 {
|
||||
|
||||
func marsConjunctionFull(jde, degree float64, next uint8) float64 {
|
||||
//0=last 1=next
|
||||
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
|
||||
return math.NaN()
|
||||
}
|
||||
daysPerDegree := MARS_S_PERIOD / 360
|
||||
currentDelta := marsSunLongitudeDelta(jde, degree, false)
|
||||
if next == 0 {
|
||||
@@ -71,21 +77,30 @@ func marsConjunctionFull(jde, degree float64, next uint8) float64 {
|
||||
} else {
|
||||
jde += daysPerDegree * currentDelta
|
||||
}
|
||||
estimateJD := jde
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
longitudeDelta := marsSunLongitudeDelta(prevJD, degree, true)
|
||||
longitudeSlope := (marsSunLongitudeDelta(prevJD+0.000005, degree, true) - marsSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
||||
estimateJDE := jde
|
||||
converged := false
|
||||
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||
prevJDE := estimateJDE
|
||||
longitudeDelta := marsSunLongitudeDelta(prevJDE, degree, true)
|
||||
longitudeSlope := (marsSunLongitudeDelta(prevJDE+0.000005, degree, true) - marsSunLongitudeDelta(prevJDE-0.000005, degree, true)) / 0.00001
|
||||
nextJD := prevJDE - longitudeDelta/longitudeSlope
|
||||
estimateJDE = nextJD
|
||||
if math.Abs(nextJD-prevJDE) <= 0.00001 {
|
||||
converged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return TD2UT(estimateJD, false)
|
||||
if !converged {
|
||||
return math.NaN()
|
||||
}
|
||||
return TT2UTC(estimateJDE)
|
||||
}
|
||||
|
||||
func marsConjunction(jde, degree float64, next uint8) float64 {
|
||||
//0=last 1=next
|
||||
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
|
||||
return math.NaN()
|
||||
}
|
||||
daysPerDegree := MARS_S_PERIOD / 360
|
||||
currentDelta := marsSunLongitudeDelta(jde, degree, false)
|
||||
if next == 0 {
|
||||
@@ -93,126 +108,179 @@ func marsConjunction(jde, degree float64, next uint8) float64 {
|
||||
} else {
|
||||
jde += daysPerDegree * currentDelta
|
||||
}
|
||||
estimateJD := jde
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
longitudeDelta := marsSunLongitudeDeltaN(prevJD, degree, true, marsEventSearchN)
|
||||
longitudeSlope := (marsSunLongitudeDeltaN(prevJD+0.000005, degree, true, marsEventSearchN) - marsSunLongitudeDeltaN(prevJD-0.000005, degree, true, marsEventSearchN)) / 0.00001
|
||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
||||
if math.Abs(estimateJD-prevJD) <= marsPhaseCoarseTolerance {
|
||||
estimateJDE := jde
|
||||
converged := false
|
||||
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||
prevJDE := estimateJDE
|
||||
longitudeDelta := marsSunLongitudeDeltaN(prevJDE, degree, true, marsEventSearchN)
|
||||
longitudeSlope := (marsSunLongitudeDeltaN(prevJDE+0.000005, degree, true, marsEventSearchN) - marsSunLongitudeDeltaN(prevJDE-0.000005, degree, true, marsEventSearchN)) / 0.00001
|
||||
nextJD := prevJDE - longitudeDelta/longitudeSlope
|
||||
estimateJDE = nextJD
|
||||
if math.Abs(nextJD-prevJDE) <= marsPhaseCoarseTolerance {
|
||||
converged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
longitudeDelta := marsSunLongitudeDelta(prevJD, degree, true)
|
||||
longitudeSlope := (marsSunLongitudeDelta(prevJD+0.000005, degree, true) - marsSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
||||
if !converged {
|
||||
return math.NaN()
|
||||
}
|
||||
converged = false
|
||||
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||
prevJDE := estimateJDE
|
||||
longitudeDelta := marsSunLongitudeDelta(prevJDE, degree, true)
|
||||
longitudeSlope := (marsSunLongitudeDelta(prevJDE+0.000005, degree, true) - marsSunLongitudeDelta(prevJDE-0.000005, degree, true)) / 0.00001
|
||||
nextJD := prevJDE - longitudeDelta/longitudeSlope
|
||||
estimateJDE = nextJD
|
||||
if math.Abs(nextJD-prevJDE) <= 0.00001 {
|
||||
converged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return TD2UT(estimateJD, false)
|
||||
if !converged {
|
||||
return math.NaN()
|
||||
}
|
||||
return TT2UTC(estimateJDE)
|
||||
}
|
||||
|
||||
func LastMarsConjunction(jde float64) float64 {
|
||||
return marsConjunction(jde, 0, 0)
|
||||
return inclusiveLastPhaseEvent(jde, 0, marsConjunction)
|
||||
}
|
||||
|
||||
func NextMarsConjunction(jde float64) float64 {
|
||||
return marsConjunction(jde, 0, 1)
|
||||
return inclusiveNextPhaseEvent(jde, 0, marsConjunction)
|
||||
}
|
||||
|
||||
func LastMarsOpposition(jde float64) float64 {
|
||||
return marsConjunction(jde, 180, 0)
|
||||
return inclusiveLastPhaseEvent(jde, 180, marsConjunction)
|
||||
}
|
||||
|
||||
func NextMarsOpposition(jde float64) float64 {
|
||||
return marsConjunction(jde, 180, 1)
|
||||
return inclusiveNextPhaseEvent(jde, 180, marsConjunction)
|
||||
}
|
||||
|
||||
func NextMarsEasternQuadrature(jde float64) float64 {
|
||||
return marsConjunction(jde, 90, 1)
|
||||
return inclusiveNextPhaseEvent(jde, 90, marsConjunction)
|
||||
}
|
||||
|
||||
func LastMarsEasternQuadrature(jde float64) float64 {
|
||||
return marsConjunction(jde, 90, 0)
|
||||
return inclusiveLastPhaseEvent(jde, 90, marsConjunction)
|
||||
}
|
||||
|
||||
func NextMarsWesternQuadrature(jde float64) float64 {
|
||||
return marsConjunction(jde, 270, 1)
|
||||
return inclusiveNextPhaseEvent(jde, 270, marsConjunction)
|
||||
}
|
||||
|
||||
func LastMarsWesternQuadrature(jde float64) float64 {
|
||||
return marsConjunction(jde, 270, 0)
|
||||
return inclusiveLastPhaseEvent(jde, 270, marsConjunction)
|
||||
}
|
||||
|
||||
func marsRetrograde(jde float64, searchBeforeOpposition bool) float64 {
|
||||
//0=last 1=next
|
||||
jde = marsConjunctionFull(jde, 180, 1)
|
||||
func marsRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool) float64 {
|
||||
if !isFiniteFloat(oppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
oppositionTT := UTC2TT(oppositionJD)
|
||||
startTT := oppositionTT
|
||||
endTT := oppositionTT
|
||||
if searchBeforeOpposition {
|
||||
jde -= 60
|
||||
easternQuadratureUT := marsConjunction(oppositionTT, 90, 0)
|
||||
startTT = UTC2TT(easternQuadratureUT)
|
||||
} else {
|
||||
jde += 60
|
||||
westernQuadratureUT := marsConjunction(oppositionTT, 270, 1)
|
||||
endTT = UTC2TT(westernQuadratureUT)
|
||||
}
|
||||
for {
|
||||
currentRate := marsRADerivative(jde, 1.0/86400.0)
|
||||
if math.Abs(currentRate) > 0.55 {
|
||||
jde += 2
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
estimateJD := jde
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
rateValue := marsRADerivative(prevJD, 2.0/86400.0)
|
||||
rateSlope := (marsRADerivative(prevJD+15.0/86400.0, 2.0/86400.0) - marsRADerivative(prevJD-15.0/86400.0, 2.0/86400.0)) / (30.0 / 86400.0)
|
||||
estimateJD = prevJD - rateValue/rateSlope
|
||||
if math.Abs(estimateJD-prevJD) <= 30.0/86400.0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
bestJD := eventZeroRefine(estimateJD, 15.0/86400.0, 0.5/86400.0, func(jd float64) float64 {
|
||||
return marsRADerivative(jd, 0.5/86400.0)
|
||||
bestJDE := zeroEventInWindow(startTT, endTT, marsStationCoarseStepDay, marsStationHalfWindowDay, 30.0/86400.0, func(jd float64) float64 {
|
||||
return marsRADerivativeN(jd, marsStationDerivativeStepDay, marsEventSearchN)
|
||||
}, func(jd float64) float64 {
|
||||
return marsRADerivative(jd, marsStationDerivativeStepDay)
|
||||
})
|
||||
return TD2UT(bestJD, false)
|
||||
return TT2UTC(bestJDE)
|
||||
}
|
||||
|
||||
func NextMarsRetrogradeToPrograde(jde float64) float64 {
|
||||
date := marsRetrograde(jde, false)
|
||||
if date < jde {
|
||||
oppositionJD := marsConjunctionFull(jde, 180, 1)
|
||||
return marsRetrograde(oppositionJD+10, false)
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
|
||||
date := marsRetrogradeAroundOpposition(lastOppositionJD, false)
|
||||
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = marsRetrogradeAroundOpposition(nextOppositionJD, false)
|
||||
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func LastMarsRetrogradeToPrograde(jde float64) float64 {
|
||||
jde = marsConjunctionFull(jde, 180, 0) - 10
|
||||
date := marsRetrograde(jde, false)
|
||||
if date > jde {
|
||||
oppositionJD := marsConjunctionFull(jde, 180, 0)
|
||||
return marsRetrograde(oppositionJD-10, false)
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
|
||||
date := marsRetrogradeAroundOpposition(lastOppositionJD, false)
|
||||
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(lastOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
previousOppositionJD := marsConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0)
|
||||
if !isFiniteFloat(previousOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = marsRetrogradeAroundOpposition(previousOppositionJD, false)
|
||||
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func NextMarsProgradeToRetrograde(jde float64) float64 {
|
||||
date := marsRetrograde(jde, true)
|
||||
if date < jde {
|
||||
oppositionJD := marsConjunctionFull(jde, 180, 1)
|
||||
return marsRetrograde(oppositionJD+10, true)
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
|
||||
date := marsRetrogradeAroundOpposition(nextOppositionJD, true)
|
||||
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
followingOppositionJD := marsConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1)
|
||||
if !isFiniteFloat(followingOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = marsRetrogradeAroundOpposition(followingOppositionJD, true)
|
||||
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func LastMarsProgradeToRetrograde(jde float64) float64 {
|
||||
jde = marsConjunctionFull(jde, 180, 0) - 10
|
||||
date := marsRetrograde(jde, true)
|
||||
if date > jde {
|
||||
oppositionJD := marsConjunctionFull(jde, 180, 0)
|
||||
return marsRetrograde(oppositionJD-10, true)
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
|
||||
date := marsRetrogradeAroundOpposition(nextOppositionJD, true)
|
||||
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
|
||||
if !isFiniteFloat(lastOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = marsRetrogradeAroundOpposition(lastOppositionJD, true)
|
||||
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 这些基准是"性能不回退"的度量口径:Nutation2000B 有界记忆表、升落上下文 stateAt 的分配、
|
||||
// 以及两条真实调用链(月球升落、掩星升落回归)。改动这些热路径后请对比 benchmark 数字。
|
||||
// These benchmarks are the regression yardstick for the hot paths changed here: the bounded
|
||||
// Nutation2000B memo, the allocation behaviour of the rise/set context stateAt, and two real call
|
||||
// chains (moon rise/set and the occultation rise/set regression case). Compare before/after numbers
|
||||
// whenever those paths change.
|
||||
func BenchmarkNutation2000B(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = Nutation2000B(2460310.5 + float64(i%97)*0.37)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkTrueObliquity(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = TrueObliquity(2460310.5 + float64(i%97)*0.37)
|
||||
}
|
||||
}
|
||||
|
||||
var benchMoonRise, benchMoonSet float64
|
||||
|
||||
func BenchmarkMoonRiseSetChain(b *testing.B) {
|
||||
jd := JDCalc(2023, 6, 21)
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
benchMoonRise, _ = GetMoonRiseTime(jd, 116.4074, 39.9042, 8, 1, 0)
|
||||
benchMoonSet, _ = GetMoonSetTime(jd, 116.4074, 39.9042, 8, 1, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Nutation2000B 有界记忆表:重复瞬时必须命中且数值逐位不变。
|
||||
func TestNutationMemoHitsRepeatedInstants(t *testing.T) {
|
||||
resetNutationMemo()
|
||||
const jd = 2460310.5
|
||||
firstPsi, firstEps := Nutation2000B(jd)
|
||||
secondPsi, secondEps := Nutation2000B(jd)
|
||||
if firstPsi != secondPsi || firstEps != secondEps {
|
||||
t.Fatalf("memo changed the value: (%v,%v) vs (%v,%v)", firstPsi, firstEps, secondPsi, secondEps)
|
||||
}
|
||||
hits, misses := nutationMemoStats()
|
||||
if hits == 0 || misses == 0 {
|
||||
t.Fatalf("expected one miss and at least one hit, got hits=%d misses=%d", hits, misses)
|
||||
}
|
||||
// 与未走记忆表的实现逐位一致。
|
||||
directPsi, directEps := nutation2000BCompute(jd)
|
||||
if firstPsi != directPsi || firstEps != directEps {
|
||||
t.Fatalf("memoized value differs from direct computation")
|
||||
}
|
||||
}
|
||||
+76
-106
@@ -7,143 +7,113 @@ import (
|
||||
. "b612.me/astro/tools"
|
||||
)
|
||||
|
||||
func MercuryL(jd float64) float64 {
|
||||
return planet.WherePlanet(1, 0, jd)
|
||||
func MercuryL(jde float64) float64 {
|
||||
return planet.WherePlanet(1, 0, jde)
|
||||
}
|
||||
|
||||
func MercuryB(jd float64) float64 {
|
||||
return planet.WherePlanet(1, 1, jd)
|
||||
func MercuryB(jde float64) float64 {
|
||||
return planet.WherePlanet(1, 1, jde)
|
||||
}
|
||||
func MercuryR(jd float64) float64 {
|
||||
return planet.WherePlanet(1, 2, jd)
|
||||
func MercuryR(jde float64) float64 {
|
||||
return planet.WherePlanet(1, 2, jde)
|
||||
}
|
||||
func AMercuryX(jd float64) float64 {
|
||||
l := MercuryL(jd)
|
||||
b := MercuryB(jd)
|
||||
r := MercuryR(jd)
|
||||
el := planet.WherePlanet(-1, 0, jd)
|
||||
eb := planet.WherePlanet(-1, 1, jd)
|
||||
er := planet.WherePlanet(-1, 2, jd)
|
||||
func AMercuryX(jde float64) float64 {
|
||||
l := MercuryL(jde)
|
||||
b := MercuryB(jde)
|
||||
r := MercuryR(jde)
|
||||
el := planet.WherePlanet(-1, 0, jde)
|
||||
eb := planet.WherePlanet(-1, 1, jde)
|
||||
er := planet.WherePlanet(-1, 2, jde)
|
||||
x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el)
|
||||
return x
|
||||
}
|
||||
|
||||
func AMercuryY(jd float64) float64 {
|
||||
func AMercuryY(jde float64) float64 {
|
||||
|
||||
l := MercuryL(jd)
|
||||
b := MercuryB(jd)
|
||||
r := MercuryR(jd)
|
||||
el := planet.WherePlanet(-1, 0, jd)
|
||||
eb := planet.WherePlanet(-1, 1, jd)
|
||||
er := planet.WherePlanet(-1, 2, jd)
|
||||
l := MercuryL(jde)
|
||||
b := MercuryB(jde)
|
||||
r := MercuryR(jde)
|
||||
el := planet.WherePlanet(-1, 0, jde)
|
||||
eb := planet.WherePlanet(-1, 1, jde)
|
||||
er := planet.WherePlanet(-1, 2, jde)
|
||||
y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el)
|
||||
return y
|
||||
}
|
||||
func AMercuryZ(jd float64) float64 {
|
||||
//l := MercuryL(jd)
|
||||
b := MercuryB(jd)
|
||||
r := MercuryR(jd)
|
||||
// el := planet.WherePlanet(-1, 0, jd)
|
||||
eb := planet.WherePlanet(-1, 1, jd)
|
||||
er := planet.WherePlanet(-1, 2, jd)
|
||||
func AMercuryZ(jde float64) float64 {
|
||||
//l := MercuryL(jde)
|
||||
b := MercuryB(jde)
|
||||
r := MercuryR(jde)
|
||||
// el := planet.WherePlanet(-1, 0, jde)
|
||||
eb := planet.WherePlanet(-1, 1, jde)
|
||||
er := planet.WherePlanet(-1, 2, jde)
|
||||
z := r*Sin(b) - er*Sin(eb)
|
||||
return z
|
||||
}
|
||||
|
||||
func AMercuryXYZ(jd float64) (float64, float64, float64) {
|
||||
l := MercuryL(jd)
|
||||
b := MercuryB(jd)
|
||||
r := MercuryR(jd)
|
||||
el := planet.WherePlanet(-1, 0, jd)
|
||||
eb := planet.WherePlanet(-1, 1, jd)
|
||||
er := planet.WherePlanet(-1, 2, jd)
|
||||
func AMercuryXYZ(jde float64) (float64, float64, float64) {
|
||||
l := MercuryL(jde)
|
||||
b := MercuryB(jde)
|
||||
r := MercuryR(jde)
|
||||
el := planet.WherePlanet(-1, 0, jde)
|
||||
eb := planet.WherePlanet(-1, 1, jde)
|
||||
er := planet.WherePlanet(-1, 2, jde)
|
||||
x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el)
|
||||
y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el)
|
||||
z := r*Sin(b) - er*Sin(eb)
|
||||
return x, y, z
|
||||
}
|
||||
|
||||
func MercuryApparentRa(jd float64) float64 {
|
||||
lo, bo := MercuryApparentLoBo(jd)
|
||||
return LoToRa(jd, lo, bo)
|
||||
func MercuryApparentRa(jde float64) float64 {
|
||||
lo, bo := MercuryApparentLoBo(jde)
|
||||
return LoToRa(jde, lo, bo)
|
||||
}
|
||||
func MercuryApparentDec(jd float64) float64 {
|
||||
lo, bo := MercuryApparentLoBo(jd)
|
||||
eps := TrueObliquity(jd)
|
||||
func MercuryApparentDec(jde float64) float64 {
|
||||
lo, bo := MercuryApparentLoBo(jde)
|
||||
eps := TrueObliquity(jde)
|
||||
dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo))
|
||||
return dec
|
||||
}
|
||||
|
||||
func MercuryApparentRaDec(jd float64) (float64, float64) {
|
||||
lo, bo := MercuryApparentLoBo(jd)
|
||||
return LoBoToRaDec(jd, lo, bo)
|
||||
func MercuryApparentRaDec(jde float64) (float64, float64) {
|
||||
lo, bo := MercuryApparentLoBo(jde)
|
||||
return LoBoToRaDec(jde, lo, bo)
|
||||
}
|
||||
|
||||
func EarthMercuryAway(jd float64) float64 {
|
||||
x, y, z := AMercuryXYZ(jd)
|
||||
to := math.Sqrt(x*x + y*y + z*z)
|
||||
return to
|
||||
return planetEarthAwayExplicitN(1, jd, -1)
|
||||
}
|
||||
|
||||
func MercuryApparentLo(jd float64) float64 {
|
||||
x, y, z := AMercuryXYZ(jd)
|
||||
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
|
||||
x, y, z = AMercuryXYZ(jd - to)
|
||||
lo := math.Atan2(y, x)
|
||||
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
|
||||
lo = lo * 180 / math.Pi
|
||||
bo = bo * 180 / math.Pi
|
||||
lo = Limit360(lo)
|
||||
//lo-=GXCLo(lo,bo,jd)/3600;
|
||||
//bo+=GXCBo(lo,bo,jd);
|
||||
lo += Nutation2000Bi(jd)
|
||||
return lo
|
||||
geo, _ := planetApparentGeocentricPositionN(1, jd, -1)
|
||||
return geo.lo
|
||||
}
|
||||
|
||||
func MercuryApparentBo(jd float64) float64 {
|
||||
x, y, z := AMercuryXYZ(jd)
|
||||
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
|
||||
x, y, z = AMercuryXYZ(jd - to)
|
||||
//lo := math.Atan2(y, x)
|
||||
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
|
||||
//lo = lo * 180 / math.Pi
|
||||
bo = bo * 180 / math.Pi
|
||||
//lo+=GXCLo(lo,bo,jd);
|
||||
//bo+=GXCBo(lo,bo,jd)/3600;
|
||||
//lo+=Nutation2000Bi(jd);
|
||||
return bo
|
||||
geo, _ := planetApparentGeocentricPositionN(1, jd, -1)
|
||||
return geo.bo
|
||||
}
|
||||
|
||||
func MercuryApparentLoBo(jd float64) (float64, float64) {
|
||||
x, y, z := AMercuryXYZ(jd)
|
||||
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
|
||||
x, y, z = AMercuryXYZ(jd - to)
|
||||
lo := math.Atan2(y, x)
|
||||
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
|
||||
lo = lo * 180 / math.Pi
|
||||
bo = bo * 180 / math.Pi
|
||||
lo = Limit360(lo) + Nutation2000Bi(jd)
|
||||
//lo-=GXCLo(lo,bo,jd)/3600;
|
||||
//bo+=GXCBo(lo,bo,jd);
|
||||
return lo, bo
|
||||
geo, _ := planetApparentGeocentricPositionN(1, jd, -1)
|
||||
return geo.lo, geo.bo
|
||||
}
|
||||
|
||||
func MercuryMag(jd float64) float64 {
|
||||
sunDistance := MercuryR(jd)
|
||||
earthDistance := EarthMercuryAway(jd)
|
||||
earthSunDistance := planet.WherePlanet(-1, 2, jd)
|
||||
func MercuryMag(jde float64) float64 {
|
||||
sunDistance := MercuryR(jde)
|
||||
earthDistance := EarthMercuryAway(jde)
|
||||
earthSunDistance := planet.WherePlanet(-1, 2, jde)
|
||||
i := (sunDistance*sunDistance + earthDistance*earthDistance - earthSunDistance*earthSunDistance) / (2 * sunDistance * earthDistance)
|
||||
i = ArcCos(i)
|
||||
mag := -0.42 + 5*math.Log10(sunDistance*earthDistance) + 0.0380*i - 0.000273*i*i + 0.000002*i*i*i
|
||||
return FloatRound(mag, 2)
|
||||
}
|
||||
|
||||
func MercuryHeight(jde, lon, lat, timezone float64) float64 {
|
||||
func MercuryHeight(localJD, lon, lat, timezone float64) float64 {
|
||||
// 转换为世界时
|
||||
utcJde := jde - timezone/24.0
|
||||
utcJD := localJD - timezone/24.0
|
||||
// 计算视恒星时
|
||||
ra, dec := MercuryApparentRaDec(TD2UT(utcJde, true))
|
||||
st := Limit360(ApparentSiderealTime(utcJde)*15 + lon)
|
||||
ra, dec := MercuryApparentRaDec(UTC2TT(utcJD))
|
||||
st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon)
|
||||
// 计算时角
|
||||
hourAngle := Limit360(st - ra)
|
||||
// 高度角、时角与天球座标三角转换公式
|
||||
@@ -152,12 +122,12 @@ func MercuryHeight(jde, lon, lat, timezone float64) float64 {
|
||||
return ArcSin(sinHeight)
|
||||
}
|
||||
|
||||
func MercuryAzimuth(jde, lon, lat, timezone float64) float64 {
|
||||
func MercuryAzimuth(localJD, lon, lat, timezone float64) float64 {
|
||||
// 转换为世界时
|
||||
utcJde := jde - timezone/24.0
|
||||
utcJD := localJD - timezone/24.0
|
||||
// 计算视恒星时
|
||||
ra, dec := MercuryApparentRaDec(TD2UT(utcJde, true))
|
||||
st := Limit360(ApparentSiderealTime(utcJde)*15 + lon)
|
||||
ra, dec := MercuryApparentRaDec(UTC2TT(utcJD))
|
||||
st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon)
|
||||
// 计算时角
|
||||
hourAngle := Limit360(st - ra)
|
||||
// 三角转换公式
|
||||
@@ -176,34 +146,34 @@ func MercuryAzimuth(jde, lon, lat, timezone float64) float64 {
|
||||
}
|
||||
|
||||
func MercuryHourAngle(jd, lon, timezone float64) float64 {
|
||||
siderealLongitude := Limit360(ApparentSiderealTime(jd-timezone/24)*15 + lon)
|
||||
hourAngle := siderealLongitude - MercuryApparentRa(TD2UT(jd-timezone/24.0, true))
|
||||
siderealLongitude := Limit360(ApparentSiderealTime(UTC2UT1(jd-timezone/24))*15 + lon)
|
||||
hourAngle := siderealLongitude - MercuryApparentRa(UTC2TT(jd-timezone/24.0))
|
||||
if hourAngle < 0 {
|
||||
hourAngle += 360
|
||||
}
|
||||
return hourAngle
|
||||
}
|
||||
|
||||
func MercuryCulminationTime(jde, lon, timezone float64) float64 {
|
||||
//jde 世界时,非力学时,当地时区 0时,无需转换力学时
|
||||
func MercuryCulminationTime(localJD, lon, timezone float64) float64 {
|
||||
// localJD 是本地民用日锚点(当地 0 时),不是力学时。
|
||||
//ra,dec 瞬时天球座标,非J2000等时间天球坐标
|
||||
jde = math.Floor(jde) + 0.5
|
||||
estimateJD := jde + Limit360(360-MercuryHourAngle(jde, lon, timezone))/15.0/24.0*0.99726851851851851851
|
||||
normalizedHourAngle := func(jde, lon, timezone float64) float64 {
|
||||
currentHourAngle := MercuryHourAngle(jde, lon, timezone)
|
||||
localJD = math.Floor(localJD) + 0.5
|
||||
estimateJD := localJD + Limit360(360-MercuryHourAngle(localJD, lon, timezone))/15.0/24.0*0.99726851851851851851
|
||||
normalizedHourAngle := func(localJD, lon, timezone float64) float64 {
|
||||
currentHourAngle := MercuryHourAngle(localJD, lon, timezone)
|
||||
if currentHourAngle < 180 {
|
||||
currentHourAngle += 360
|
||||
}
|
||||
return currentHourAngle
|
||||
}
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
var ok bool
|
||||
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
|
||||
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
|
||||
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
|
||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
||||
break
|
||||
}
|
||||
return hourAngleDelta / hourAngleSlope
|
||||
})
|
||||
if !ok {
|
||||
return math.NaN()
|
||||
}
|
||||
return estimateJD
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func mercuryEventSamples() []time.Time {
|
||||
}
|
||||
|
||||
func mercuryEventSampleTTJD(date time.Time) float64 {
|
||||
return TD2UT(Date2JDE(date.UTC()), true)
|
||||
return UTC2TT(Date2JD(date.UTC()))
|
||||
}
|
||||
|
||||
func mercuryEventCases() []mercuryEventCase {
|
||||
|
||||
+625
-287
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMercuryRetrogradeFastPathKeepsTypedCandidateOrder(t *testing.T) {
|
||||
// 这些查询覆盖一个普通古代周期和两个长间隔边界 / These queries cover a normal ancient cycle and two long-gap boundaries
|
||||
//,类型化驻留搜索必须作为最终权威 / where the typed station search must remain the final authority.
|
||||
for _, queryUT := range []float64{
|
||||
1026548.810500779,
|
||||
1644733.927538287,
|
||||
1645082.416782375,
|
||||
} {
|
||||
queryTT := UTC2TT(queryUT)
|
||||
nextP2R := NextMercuryProgradeToRetrograde(queryTT)
|
||||
nextR2P := NextMercuryRetrogradeToPrograde(queryTT)
|
||||
wantNext := math.Min(nextP2R, nextR2P)
|
||||
if got := NextMercuryRetrograde(queryTT); math.Abs(got-wantNext) > 1e-7 {
|
||||
t.Fatalf("next aggregate mismatch at %.9f: got %.12f want %.12f", queryUT, got, wantNext)
|
||||
}
|
||||
|
||||
lastP2R := LastMercuryProgradeToRetrograde(queryTT)
|
||||
lastR2P := LastMercuryRetrogradeToPrograde(queryTT)
|
||||
wantLast := math.Max(lastP2R, lastR2P)
|
||||
if got := LastMercuryRetrograde(queryTT); math.Abs(got-wantLast) > 1e-7 {
|
||||
t.Fatalf("last aggregate mismatch at %.9f: got %.12f want %.12f", queryUT, got, wantLast)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func mercuryTTJDJST(year int, month time.Month, day, hour, minute, second int) float64 {
|
||||
loc := time.FixedZone("JST", 9*3600)
|
||||
return UTC2TT(Date2JD(time.Date(year, month, day, hour, minute, second, 0, loc).UTC()))
|
||||
}
|
||||
|
||||
func TestMercuryTypedStationRegression1929(t *testing.T) {
|
||||
loc := time.FixedZone("JST", 9*3600)
|
||||
const tolerance = 30.0 / 86400.0
|
||||
|
||||
query := mercuryTTJDJST(1929, time.September, 20, 0, 0, 0)
|
||||
wantP2R := mercuryTTJDJST(1929, time.September, 26, 1, 58, 0)
|
||||
wantR2P := mercuryTTJDJST(1929, time.October, 16, 23, 32, 33)
|
||||
|
||||
nextP2R := NextMercuryProgradeToRetrograde(query)
|
||||
nextR2P := NextMercuryRetrogradeToPrograde(query)
|
||||
if math.Abs(nextP2R-wantP2R) > tolerance {
|
||||
t.Fatalf("next P2R mismatch: got %s want %s", JD2DateByZone(nextP2R, loc, false), JD2DateByZone(wantP2R, loc, false))
|
||||
}
|
||||
if math.Abs(nextR2P-wantR2P) > tolerance {
|
||||
t.Fatalf("next R2P mismatch: got %s want %s", JD2DateByZone(nextR2P, loc, false), JD2DateByZone(wantR2P, loc, false))
|
||||
}
|
||||
|
||||
query = mercuryTTJDJST(1929, time.October, 20, 0, 0, 0)
|
||||
lastP2R := LastMercuryProgradeToRetrograde(query)
|
||||
lastR2P := LastMercuryRetrogradeToPrograde(query)
|
||||
if math.Abs(lastP2R-wantP2R) > tolerance {
|
||||
t.Fatalf("last P2R mismatch: got %s want %s", JD2DateByZone(lastP2R, loc, false), JD2DateByZone(wantP2R, loc, false))
|
||||
}
|
||||
if math.Abs(lastR2P-wantR2P) > tolerance {
|
||||
t.Fatalf("last R2P mismatch: got %s want %s", JD2DateByZone(lastR2P, loc, false), JD2DateByZone(wantR2P, loc, false))
|
||||
}
|
||||
}
|
||||
+41
-46
@@ -5,58 +5,58 @@ import (
|
||||
. "b612.me/astro/tools"
|
||||
)
|
||||
|
||||
func MoonLo(jd float64) float64 { //'月球平黄经
|
||||
return planet.MoonLo(jd)
|
||||
func MoonLo(jde float64) float64 { //'月球平黄经
|
||||
return planet.MoonLo(jde)
|
||||
}
|
||||
|
||||
func SunMoonAngle(jd float64) float64 { // '月日距角
|
||||
return planet.SunMoonAngle(jd)
|
||||
func SunMoonAngle(jde float64) float64 { // '月日距角
|
||||
return planet.SunMoonAngle(jde)
|
||||
}
|
||||
|
||||
func MoonM(jd float64) float64 { // '月平近点角
|
||||
return planet.MoonM(jd)
|
||||
func MoonM(jde float64) float64 { // '月平近点角
|
||||
return planet.MoonM(jde)
|
||||
}
|
||||
|
||||
func MoonLonX(jd float64) float64 { // As Double '月球经度参数(到升交点的平角距离)
|
||||
return planet.MoonLonX(jd)
|
||||
func MoonLonX(jde float64) float64 { // As Double '月球经度参数(到升交点的平角距离)
|
||||
return planet.MoonLonX(jde)
|
||||
}
|
||||
|
||||
func MoonI(jd float64) float64 {
|
||||
return planet.MoonI(jd)
|
||||
func MoonI(jde float64) float64 {
|
||||
return planet.MoonI(jde)
|
||||
}
|
||||
|
||||
func MoonR(jd float64) float64 {
|
||||
return planet.MoonR(jd)
|
||||
func MoonR(jde float64) float64 {
|
||||
return planet.MoonR(jde)
|
||||
}
|
||||
|
||||
func MoonB(jd float64) float64 {
|
||||
return planet.MoonB(jd)
|
||||
func MoonB(jde float64) float64 {
|
||||
return planet.MoonB(jde)
|
||||
}
|
||||
|
||||
func MoonTrueLo(jd float64) float64 {
|
||||
return planet.MoonTrueLo(jd)
|
||||
func MoonTrueLo(jde float64) float64 {
|
||||
return planet.MoonTrueLo(jde)
|
||||
}
|
||||
func MoonTrueBo(jd float64) float64 {
|
||||
return planet.MoonTrueBo(jd)
|
||||
func MoonTrueBo(jde float64) float64 {
|
||||
return planet.MoonTrueBo(jde)
|
||||
}
|
||||
func MoonAway(jd float64) float64 { //'月地距离
|
||||
return planet.MoonAway(jd)
|
||||
func MoonAway(jde float64) float64 { //'月地距离
|
||||
return planet.MoonAway(jde)
|
||||
}
|
||||
|
||||
/*
|
||||
* @name 月球视黄经
|
||||
*/
|
||||
func MoonApparentLo(jd float64) float64 {
|
||||
return MoonTrueLo(jd) + Nutation2000Bi(jd)
|
||||
func MoonApparentLo(jde float64) float64 {
|
||||
return MoonTrueLo(jde) + Nutation2000Bi(jde)
|
||||
}
|
||||
|
||||
/*
|
||||
* 月球真赤纬
|
||||
*/
|
||||
func MoonTrueDec(jd float64) float64 {
|
||||
moonLo := MoonApparentLo(jd)
|
||||
moonBo := MoonTrueBo(jd)
|
||||
tmp := Sin(moonBo)*Cos(TrueObliquity(jd)) + Cos(moonBo)*Sin(TrueObliquity(jd))*Sin(moonLo)
|
||||
func MoonTrueDec(jde float64) float64 {
|
||||
moonLo := MoonApparentLo(jde)
|
||||
moonBo := MoonTrueBo(jde)
|
||||
tmp := Sin(moonBo)*Cos(TrueObliquity(jde)) + Cos(moonBo)*Sin(TrueObliquity(jde))*Sin(moonLo)
|
||||
res := ArcSin(tmp)
|
||||
return res
|
||||
}
|
||||
@@ -64,36 +64,31 @@ func MoonTrueDec(jd float64) float64 {
|
||||
/*
|
||||
* 月球真赤经
|
||||
*/
|
||||
func MoonTrueRa(jd float64) float64 {
|
||||
return LoToRa(jd, MoonApparentLo(jd), MoonTrueBo(jd))
|
||||
func MoonTrueRa(jde float64) float64 {
|
||||
return LoToRa(jde, MoonApparentLo(jde), MoonTrueBo(jde))
|
||||
}
|
||||
|
||||
func MoonTrueRaDec(jd float64) (float64, float64) {
|
||||
return LoBoToRaDec(jd, MoonApparentLo(jd), MoonTrueBo(jd))
|
||||
func MoonTrueRaDec(jde float64) (float64, float64) {
|
||||
return LoBoToRaDec(jde, MoonApparentLo(jde), MoonTrueBo(jde))
|
||||
}
|
||||
|
||||
/*
|
||||
*
|
||||
|
||||
*
|
||||
传入世界时
|
||||
*/
|
||||
// MoonApparentRa 站心视赤经;jd 为当地时儒略日,tz 为时区小时数 / topocentric apparent right ascension; jd is local civil time and tz is the zone offset in hours.
|
||||
func MoonApparentRa(jd, lon, lat float64, tz int) float64 {
|
||||
jde := TD2UT(jd, true)
|
||||
utcJD := jde - float64(tz)/24.000
|
||||
ra := MoonTrueRa(utcJD)
|
||||
dec := MoonTrueDec(utcJD)
|
||||
away := MoonAway(utcJD) / 149597870.7
|
||||
// 本地时刻加 ΔT 再减时区偏移即该地时刻的 TT(加法可交换),故 jde 就是 TT。
|
||||
jde := UTC2TT(jd) - float64(tz)/24.000
|
||||
ra := MoonTrueRa(jde)
|
||||
dec := MoonTrueDec(jde)
|
||||
away := MoonAway(jde) / 149597870.7
|
||||
topoRA := TopocentricRa(ra, dec, lat, lon, jd-float64(tz)/24.000, away, 0)
|
||||
return topoRA
|
||||
}
|
||||
|
||||
func MoonApparentDec(jd, lon, lat, tz float64) float64 {
|
||||
jde := TD2UT(jd, true)
|
||||
utcJD := jde - tz/24
|
||||
ra := MoonTrueRa(utcJD)
|
||||
dec := MoonTrueDec(utcJD)
|
||||
away := MoonAway(utcJD) / 149597870.7
|
||||
// 同上:jde 是当地时刻的 TT。
|
||||
jde := UTC2TT(jd) - tz/24
|
||||
ra := MoonTrueRa(jde)
|
||||
dec := MoonTrueDec(jde)
|
||||
away := MoonAway(jde) / 149597870.7
|
||||
topoDec := TopocentricDec(ra, dec, lat, lon, jd-tz/24, away, 0)
|
||||
return topoDec
|
||||
}
|
||||
|
||||
+18
-18
@@ -3,39 +3,39 @@ package basic
|
||||
import . "b612.me/astro/tools"
|
||||
|
||||
// MoonBrightLimbPositionAngle 月亮明亮边缘位置角 / position angle of the Moon's bright limb.
|
||||
func MoonBrightLimbPositionAngle(jd float64) float64 {
|
||||
return MoonBrightLimbPositionAngleN(jd, -1)
|
||||
func MoonBrightLimbPositionAngle(jde float64) float64 {
|
||||
return MoonBrightLimbPositionAngleN(jde, -1)
|
||||
}
|
||||
|
||||
// MoonBrightLimbPositionAngleN 月亮明亮边缘位置角(截断版) / truncated position angle of the Moon's bright limb.
|
||||
func MoonBrightLimbPositionAngleN(jd float64, n int) float64 {
|
||||
sunRA, sunDec := HSunApparentRaDecN(jd, n)
|
||||
moonRA, moonDec := HMoonTrueRaDecN(jd, n)
|
||||
func MoonBrightLimbPositionAngleN(jde float64, n int) float64 {
|
||||
sunRA, sunDec := HSunApparentRaDecN(jde, n)
|
||||
moonRA, moonDec := HMoonTrueRaDecN(jde, n)
|
||||
return brightLimbPositionAngleFromRaDec(sunRA, sunDec, moonRA, moonDec)
|
||||
}
|
||||
|
||||
// MoonTopocentricBrightLimbPositionAngle 月亮站心明亮边缘位置角 / topocentric position angle of the Moon's bright limb.
|
||||
func MoonTopocentricBrightLimbPositionAngle(jd, observerLon, observerLat, height float64) float64 {
|
||||
return MoonTopocentricBrightLimbPositionAngleN(jd, observerLon, observerLat, height, -1)
|
||||
func MoonTopocentricBrightLimbPositionAngle(jde, observerLon, observerLat, height float64) float64 {
|
||||
return MoonTopocentricBrightLimbPositionAngleN(jde, observerLon, observerLat, height, -1)
|
||||
}
|
||||
|
||||
// MoonTopocentricBrightLimbPositionAngleN 月亮站心明亮边缘位置角(截断版) / truncated topocentric position angle of the Moon's bright limb.
|
||||
func MoonTopocentricBrightLimbPositionAngleN(jd, observerLon, observerLat, height float64, n int) float64 {
|
||||
sunRA, sunDec := sunTopocentricApparentRaDecN(jd, observerLon, observerLat, height, n)
|
||||
moonRA, moonDec := moonTopocentricApparentRaDecN(jd, observerLon, observerLat, height, n)
|
||||
func MoonTopocentricBrightLimbPositionAngleN(jde, observerLon, observerLat, height float64, n int) float64 {
|
||||
sunRA, sunDec := sunTopocentricApparentRaDecN(jde, observerLon, observerLat, height, n)
|
||||
moonRA, moonDec := moonTopocentricApparentRaDecN(jde, observerLon, observerLat, height, n)
|
||||
return brightLimbPositionAngleFromRaDec(sunRA, sunDec, moonRA, moonDec)
|
||||
}
|
||||
|
||||
func moonTopocentricApparentRaDecN(jd, observerLon, observerLat, height float64, n int) (float64, float64) {
|
||||
geocentricRA := HMoonTrueRaN(jd, n)
|
||||
geocentricDec := HMoonTrueDecN(jd, n)
|
||||
distanceAU := HMoonAwayN(jd, n) / moonPhysicalAstronomicalUnitKM
|
||||
return TopocentricRaDec(geocentricRA, geocentricDec, observerLat, observerLon, TD2UT(jd, false), distanceAU, height)
|
||||
func moonTopocentricApparentRaDecN(jde, observerLon, observerLat, height float64, n int) (float64, float64) {
|
||||
geocentricRA := HMoonTrueRaN(jde, n)
|
||||
geocentricDec := HMoonTrueDecN(jde, n)
|
||||
distanceAU := HMoonAwayN(jde, n) / moonPhysicalAstronomicalUnitKM
|
||||
return TopocentricRaDec(geocentricRA, geocentricDec, observerLat, observerLon, TT2UTC(jde), distanceAU, height)
|
||||
}
|
||||
|
||||
func sunTopocentricApparentRaDecN(jd, observerLon, observerLat, height float64, n int) (float64, float64) {
|
||||
geocentricRA, geocentricDec := HSunApparentRaDecN(jd, n)
|
||||
return TopocentricRaDec(geocentricRA, geocentricDec, observerLat, observerLon, TD2UT(jd, false), EarthAwayN(jd, n), height)
|
||||
func sunTopocentricApparentRaDecN(jde, observerLon, observerLat, height float64, n int) (float64, float64) {
|
||||
geocentricRA, geocentricDec := HSunApparentRaDecN(jde, n)
|
||||
return TopocentricRaDec(geocentricRA, geocentricDec, observerLat, observerLon, TT2UTC(jde), EarthAwayN(jde, n), height)
|
||||
}
|
||||
|
||||
func brightLimbPositionAngleFromRaDec(sunRA, sunDec, bodyRA, bodyDec float64) float64 {
|
||||
|
||||
@@ -11,7 +11,7 @@ func TestMoonBrightLimbPositionAngleMeeusExample(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMoonBrightLimbPositionAngleNFullMatchesDefault(t *testing.T) {
|
||||
jd := TD2UT(Date2JDE(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC)), true)
|
||||
jd := UTC2TT(Date2JD(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC)))
|
||||
|
||||
got := MoonBrightLimbPositionAngle(jd)
|
||||
gotN := MoonBrightLimbPositionAngleN(jd, -1)
|
||||
@@ -21,7 +21,7 @@ func TestMoonBrightLimbPositionAngleNFullMatchesDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMoonTopocentricBrightLimbPositionAngleSampleFiniteAndInRange(t *testing.T) {
|
||||
jd := TD2UT(Date2JDE(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC)), true)
|
||||
jd := UTC2TT(Date2JD(time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC)))
|
||||
got := MoonTopocentricBrightLimbPositionAngle(jd, 121.4737, 31.2304, 4)
|
||||
|
||||
assertFiniteRange(t, "MoonTopocentricBrightLimbPositionAngle", got, 0, 360, true)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type moonGeocentricApparentSample struct {
|
||||
InputUTC string `json:"input_utc"`
|
||||
RightAscension float64 `json:"right_ascension"`
|
||||
Declination float64 `json:"declination"`
|
||||
EclipticLongitude float64 `json:"ecliptic_longitude"`
|
||||
EclipticLatitude float64 `json:"ecliptic_latitude"`
|
||||
}
|
||||
|
||||
func TestMoonGeocentricApparentCoordinatesMatchHorizonsBaseline(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/moon_geocentric_apparent_baseline.json")
|
||||
if err != nil {
|
||||
t.Fatalf("read baseline: %v", err)
|
||||
}
|
||||
|
||||
var samples []moonGeocentricApparentSample
|
||||
if err := json.Unmarshal(data, &samples); err != nil {
|
||||
t.Fatalf("decode baseline: %v", err)
|
||||
}
|
||||
if len(samples) == 0 {
|
||||
t.Fatal("empty moon apparent baseline")
|
||||
}
|
||||
|
||||
for _, sample := range samples {
|
||||
date, err := time.Parse(time.RFC3339, sample.InputUTC)
|
||||
if err != nil {
|
||||
t.Fatalf("parse sample time %q: %v", sample.InputUTC, err)
|
||||
}
|
||||
jd := UTC2TT(Date2JD(date.UTC()))
|
||||
prefix := "moon." + sample.InputUTC
|
||||
|
||||
assertPlanetApparentAngleClose(t, prefix+".RightAscension", HMoonGeocentricApparentRa(jd), sample.RightAscension, 0.001)
|
||||
assertPlanetPhaseClose(t, prefix+".Declination", HMoonGeocentricApparentDec(jd), sample.Declination, 0.001)
|
||||
assertPlanetApparentAngleClose(t, prefix+".EclipticLongitude", HMoonApparentLo(jd), sample.EclipticLongitude, 0.001)
|
||||
assertPlanetPhaseClose(t, prefix+".EclipticLatitude", HMoonTrueBo(jd), sample.EclipticLatitude, 0.001)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoonGeocentricTrueCoordinatesFollowDefinition(t *testing.T) {
|
||||
samples := []time.Time{
|
||||
time.Date(1900, 1, 14, 12, 0, 0, 0, time.UTC),
|
||||
time.Date(1950, 6, 3, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2000, 2, 29, 18, 0, 0, 0, time.UTC),
|
||||
time.Date(2026, 1, 1, 6, 0, 0, 0, time.UTC),
|
||||
time.Date(2100, 8, 17, 9, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
for _, sample := range samples {
|
||||
jd := UTC2TT(Date2JD(sample.UTC()))
|
||||
wantRA, wantDec := LoBoToRaDec(jd, HMoonTrueLo(jd), HMoonTrueBo(jd))
|
||||
gotRA, gotDec := HMoonGeocentricTrueRaDec(jd)
|
||||
|
||||
assertPlanetApparentAngleClose(t, sample.Format(time.RFC3339)+".TrueRightAscension", gotRA, wantRA, 1e-12)
|
||||
assertPlanetPhaseClose(t, sample.Format(time.RFC3339)+".TrueDeclination", gotDec, wantDec, 1e-12)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHMoonGeocentricApparentRaDecComponentsMatch(t *testing.T) {
|
||||
jd := UTC2TT(JDCalc(2026, 1, 1.25))
|
||||
|
||||
ra, dec := HMoonGeocentricApparentRaDec(jd)
|
||||
if diff := math.Abs(ra - HMoonGeocentricApparentRa(jd)); diff > 1e-12 {
|
||||
t.Fatalf("RA pair mismatch: got %.15f want %.15f", ra, HMoonGeocentricApparentRa(jd))
|
||||
}
|
||||
if diff := math.Abs(dec - HMoonGeocentricApparentDec(jd)); diff > 1e-12 {
|
||||
t.Fatalf("Dec pair mismatch: got %.15f want %.15f", dec, HMoonGeocentricApparentDec(jd))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHMoonGeocentricTrueRaDecComponentsMatch(t *testing.T) {
|
||||
jd := UTC2TT(JDCalc(2026, 1, 1.25))
|
||||
|
||||
ra, dec := HMoonGeocentricTrueRaDec(jd)
|
||||
if diff := math.Abs(ra - HMoonGeocentricTrueRa(jd)); diff > 1e-12 {
|
||||
t.Fatalf("RA pair mismatch: got %.15f want %.15f", ra, HMoonGeocentricTrueRa(jd))
|
||||
}
|
||||
if diff := math.Abs(dec - HMoonGeocentricTrueDec(jd)); diff > 1e-12 {
|
||||
t.Fatalf("Dec pair mismatch: got %.15f want %.15f", dec, HMoonGeocentricTrueDec(jd))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package basic
|
||||
|
||||
// MoonHorizon 返回 UTC 儒略日下海平面几何月球中心地平圈(月球恰好在地平线上的观测者轨迹,
|
||||
// 即月下点周围的地平圈)的 [经度, 纬度] 顶点,单位为度,不重复首点。视差与椭球口径同
|
||||
// HMoonHeight,不含折射;与 HMoonHeight(经, 纬, ..., 0) 配合时该圈上的点高度角为 0。
|
||||
// samples<=0 取 360,其余夹到 [12, 1440]。
|
||||
// MoonHorizon returns sea-level geometric Moon-centre horizon vertices in degrees for a UTC Julian
|
||||
// day: the locus of observers that see the Moon exactly on the horizon. Parallax and the observer
|
||||
// ellipsoid match HMoonHeight; refraction is excluded.
|
||||
func MoonHorizon(jdUTC float64, samples int) [][2]float64 {
|
||||
if !finite(jdUTC) {
|
||||
return nil
|
||||
}
|
||||
return MoonStateAt(jdUTC).MoonHorizon(samples)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMoonHorizonMatchesTopocentricAltitude(t *testing.T) {
|
||||
for _, jd := range []float64{JDCalc(2026, 3, 3), JDCalc(2025, 9, 7), JDCalc(2024, 12, 15)} {
|
||||
points := MoonHorizon(jd, 360)
|
||||
if len(points) != 360 {
|
||||
t.Fatalf("horizon points=%d", len(points))
|
||||
}
|
||||
for _, point := range points {
|
||||
if altitude := HMoonHeight(jd, point[0], point[1], 0); math.Abs(altitude) > 1e-9 {
|
||||
t.Fatalf("JD=%v point=%v altitude=%g, want zero", jd, point, altitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
if MoonHorizon(math.NaN(), 360) != nil {
|
||||
t.Fatal("invalid JD accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMoonHorizonUsesUTCInput 固定地平圈的时标口径:入参是 UTC 儒略日,站心恒星时按 UTC→UT1 换算。
|
||||
// 取 DUT1 明显的两个时刻,确认圈上点只在 UTC 口径下高度角为零,且两种口径在本地可区分。
|
||||
func TestMoonHorizonUsesUTCInput(t *testing.T) {
|
||||
for _, at := range []time.Time{
|
||||
time.Date(1980, 3, 15, 18, 0, 0, 0, time.UTC),
|
||||
time.Date(2035, 3, 15, 18, 0, 0, 0, time.UTC),
|
||||
} {
|
||||
jdUTC := Date2JD(at)
|
||||
dut1Seconds := (UTC2UT1(jdUTC) - jdUTC) * 86400
|
||||
if math.Abs(dut1Seconds) < 0.3 {
|
||||
t.Fatalf("%s DUT1=%.3f s 太小,区分不出两种口径", at.Format("2006-01-02"), dut1Seconds)
|
||||
}
|
||||
jdAsUT1 := jdUTC + dut1Seconds/86400
|
||||
distinguishable := false
|
||||
for _, point := range MoonHorizon(jdUTC, 360) {
|
||||
if altitude := HMoonHeight(jdUTC, point[0], point[1], 0); math.Abs(altitude) > 1e-9 {
|
||||
t.Fatalf("%s UTC 口径下圈上点高度角=%g,应为零", at.Format("2006-01-02"), altitude)
|
||||
}
|
||||
if altitude := HMoonHeight(jdAsUT1, point[0], point[1], 0); math.Abs(altitude) > 1e-4 {
|
||||
distinguishable = true
|
||||
}
|
||||
}
|
||||
if !distinguishable {
|
||||
t.Fatalf("%s 两种口径不可区分,用例失去意义", at.Format("2006-01-02"))
|
||||
}
|
||||
if MoonHorizon(jdAsUT1, 360)[0] == MoonHorizon(jdUTC, 360)[0] {
|
||||
t.Fatalf("%s UTC 与 UT1 两种读法给出了同一条圈", at.Format("2006-01-02"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMoonStateHorizonMatchesPackageHorizon 固定 MoonState 派生量与包级函数逐位一致,以及采样数与非法入参的兜底。
|
||||
func TestMoonStateHorizonMatchesPackageHorizon(t *testing.T) {
|
||||
jd := JDCalc(2026, 3, 3)
|
||||
state := MoonStateAt(jd)
|
||||
for _, samples := range []int{0, 1, 12, 360, 5000} {
|
||||
got := state.MoonHorizon(samples)
|
||||
want := MoonHorizon(jd, samples)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("samples=%d 点数 %d,包级 %d", samples, len(got), len(want))
|
||||
}
|
||||
for index := range got {
|
||||
if got[index] != want[index] {
|
||||
t.Fatalf("samples=%d 第 %d 点 %v,包级 %v", samples, index, got[index], want[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, invalid := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} {
|
||||
if points := MoonStateAt(invalid).MoonHorizon(360); points != nil {
|
||||
t.Fatalf("非有限 UTC 儒略日 %v 仍给出地平圈", invalid)
|
||||
}
|
||||
if points := MoonHorizon(invalid, 360); points != nil {
|
||||
t.Fatalf("非有限 UTC 儒略日 %v 仍给出地平圈", invalid)
|
||||
}
|
||||
}
|
||||
if altitude := MoonStateAt(math.NaN()).HMoonHeight(0, 0); !math.IsNaN(altitude) {
|
||||
t.Fatalf("非有限状态的高度角=%v,期望 NaN", altitude)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHMoonHeightUsesLocalCivilFrame 固定 HMoonHeight 的时标框架:jd 是当地民用时(墙上时刻),
|
||||
// tz 是时区偏移小时数。同一物理时刻写成「当地民用时 + tz」与「UTC 数值 + tz=0」必须一致;
|
||||
// 把 UTC 数值再配非零 tz 会多减一次时区,必须能区分。
|
||||
func TestHMoonHeightUsesLocalCivilFrame(t *testing.T) {
|
||||
utc := time.Date(2029, 1, 1, 16, 0, 0, 0, time.UTC)
|
||||
zone := time.FixedZone("CST", 8*3600)
|
||||
utcJD := Date2JD(utc)
|
||||
localJD := Date2JD(utc.In(zone))
|
||||
// JD 约 2.5e6 天,双精度下 8 小时之差本身带 ~1e-8 h 的表示误差。
|
||||
if offset := (localJD - utcJD) * 24; math.Abs(offset-8) > 1e-6 {
|
||||
t.Fatalf("当地民用时框架与 UTC 相差 %.9f h,期望 8 h", offset)
|
||||
}
|
||||
for _, site := range []struct{ lon, lat float64 }{{108.729, -59.937}, {0, 0}, {-70, 45}} {
|
||||
local := HMoonHeight(localJD, site.lon, site.lat, 8)
|
||||
reference := HMoonHeight(utcJD, site.lon, site.lat, 0)
|
||||
if math.Abs(local-reference) > 1e-5 {
|
||||
t.Fatalf("(%.3f,%.3f) 当地民用时 %.9f,UTC 数值配 tz=0 %.9f", site.lon, site.lat, local, reference)
|
||||
}
|
||||
}
|
||||
state := MoonStateAt(utcJD)
|
||||
for _, site := range []struct{ lon, lat float64 }{{108.729, -59.937}, {0, 0}, {-70, 45}} {
|
||||
if state.HMoonHeight(site.lon, site.lat) != HMoonHeight(utcJD, site.lon, site.lat, 0) {
|
||||
t.Fatalf("(%.3f,%.3f) MoonState 与 HMoonHeight 不一致", site.lon, site.lat)
|
||||
}
|
||||
}
|
||||
if mixed := HMoonHeight(utcJD, 0, 0, 8) - HMoonHeight(utcJD, 0, 0, 0); math.Abs(mixed) < 0.1 {
|
||||
t.Fatalf("UTC 数值配非零 tz 的差异只有 %.6f°,用例失去区分度", mixed)
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,8 @@ const (
|
||||
|
||||
// DeclinationEvent 赤纬极值事件 / declination extremum event.
|
||||
type DeclinationEvent struct {
|
||||
// JDE 是事件发生时刻对应的世界时儒略日 / event time as UTC-based Julian day.
|
||||
JDE float64
|
||||
// JD 是事件发生时刻对应的世界时儒略日 / event time as UTC-based Julian day.
|
||||
JD float64
|
||||
// Declination 是该时刻月心地心赤纬,单位度 / geocentric lunar declination at the event, in degrees.
|
||||
Declination float64
|
||||
}
|
||||
@@ -124,8 +124,8 @@ func ClosestMoonMaximumSouthDeclination(jd float64) DeclinationEvent {
|
||||
func moonMaximumDeclinationsInMonth(year int, month time.Month, coeffs moonMaxDeclinationCoefficients) []DeclinationEvent {
|
||||
startUTC := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
|
||||
endUTC := startUTC.AddDate(0, 1, 0)
|
||||
startTT := TD2UT(Date2JDE(startUTC), true)
|
||||
endTT := TD2UT(Date2JDE(endUTC), true)
|
||||
startTT := UTC2TT(Date2JD(startUTC))
|
||||
endTT := UTC2TT(Date2JD(endUTC))
|
||||
|
||||
kStart := int(math.Floor((startTT-coeffs.JDE0)/moonMaxDeclinationMeanMonthDays)) - 1
|
||||
kEnd := int(math.Ceil((endTT-coeffs.JDE0)/moonMaxDeclinationMeanMonthDays)) + 1
|
||||
@@ -142,7 +142,7 @@ func moonMaximumDeclinationsInMonth(year int, month time.Month, coeffs moonMaxDe
|
||||
events := make([]DeclinationEvent, 0, 2)
|
||||
for k := kStart; k <= kEnd; k++ {
|
||||
event := moonMaximumDeclinationEvent(k, coeffs, cfg)
|
||||
eventTimeUTC := JDE2DateByZone(event.JDE, time.UTC, false)
|
||||
eventTimeUTC := JD2DateByZone(event.JD, time.UTC, false)
|
||||
if eventTimeUTC.Before(startUTC) || !eventTimeUTC.Before(endUTC) {
|
||||
continue
|
||||
}
|
||||
@@ -150,7 +150,7 @@ func moonMaximumDeclinationsInMonth(year int, month time.Month, coeffs moonMaxDe
|
||||
}
|
||||
|
||||
sort.Slice(events, func(i, j int) bool {
|
||||
return events[i].JDE < events[j].JDE
|
||||
return events[i].JD < events[j].JD
|
||||
})
|
||||
return events
|
||||
}
|
||||
@@ -161,13 +161,85 @@ func moonMaximumDeclinationEvent(k int, coeffs moonMaxDeclinationCoefficients, c
|
||||
return HMoonTrueDecN(sampleTT, -1)
|
||||
})
|
||||
return DeclinationEvent{
|
||||
JDE: TD2UT(eventTT, false),
|
||||
JD: TT2UTC(eventTT),
|
||||
Declination: declination,
|
||||
}
|
||||
}
|
||||
|
||||
func moonMaximumDeclinationSearch(jd float64, coeffs moonMaxDeclinationCoefficients, direction int, includeCurrent bool) DeclinationEvent {
|
||||
cfg := apsisSearchConfig{
|
||||
if !finite(jd) {
|
||||
// 非有限查询算不出周期序号(centerK 会把 NaN/±Inf 压成 MinInt64,种子外推到 1e40 后步长小于一个 ULP)。
|
||||
return DeclinationEvent{}
|
||||
}
|
||||
cfg := moonMaximumDeclinationSearchConfig(coeffs)
|
||||
centerK := moonMaximumDeclinationOffset(jd, coeffs)
|
||||
step := 1
|
||||
if direction < 0 {
|
||||
step = -1
|
||||
}
|
||||
event := moonMaximumDeclinationEvent(centerK, coeffs, cfg)
|
||||
// 事件时刻随周期序号单调递增:该方向上最近的候选就是 centerK 沿该方向第一个满足方向不变量
|
||||
// 的周期;命中后只需再回退检查更早的周期是否同样满足,可达范围仍旧是 ±3 个周期。
|
||||
if !moonMaximumDeclinationMatchesDirection(event.JD-jd, direction, includeCurrent) {
|
||||
for offset := 1; offset <= moonMaxDeclinationSearchSpan; offset++ {
|
||||
event = moonMaximumDeclinationEvent(centerK+step*offset, coeffs, cfg)
|
||||
if moonMaximumDeclinationMatchesDirection(event.JD-jd, direction, includeCurrent) {
|
||||
return event
|
||||
}
|
||||
}
|
||||
return DeclinationEvent{}
|
||||
}
|
||||
for offset := 1; offset <= moonMaxDeclinationSearchSpan; offset++ {
|
||||
previous := moonMaximumDeclinationEvent(centerK-step*offset, coeffs, cfg)
|
||||
if !moonMaximumDeclinationMatchesDirection(previous.JD-jd, direction, includeCurrent) {
|
||||
break
|
||||
}
|
||||
event = previous
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
func moonClosestMaximumDeclination(jd float64, coeffs moonMaxDeclinationCoefficients) DeclinationEvent {
|
||||
if !finite(jd) {
|
||||
return DeclinationEvent{}
|
||||
}
|
||||
cfg := moonMaximumDeclinationSearchConfig(coeffs)
|
||||
centerK := moonMaximumDeclinationOffset(jd, coeffs)
|
||||
center := moonMaximumDeclinationEvent(centerK, coeffs, cfg)
|
||||
var last, next DeclinationEvent
|
||||
if center.JD <= jd {
|
||||
last = center
|
||||
next = moonMaximumDeclinationAround(jd, coeffs, cfg, centerK, 1, false)
|
||||
} else {
|
||||
next = center
|
||||
last = moonMaximumDeclinationAround(jd, coeffs, cfg, centerK, -1, true)
|
||||
}
|
||||
lastDistance := math.Abs(jd - last.JD)
|
||||
nextDistance := math.Abs(next.JD - jd)
|
||||
if lastDistance <= nextDistance {
|
||||
return last
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// moonMaximumDeclinationAround 从 centerK 沿 step 方向找第一个满足方向不变量的周期;±3 个周期内没有就返回零事件。
|
||||
func moonMaximumDeclinationAround(jd float64, coeffs moonMaxDeclinationCoefficients, cfg apsisSearchConfig,
|
||||
centerK, step int, includeCurrent bool) DeclinationEvent {
|
||||
direction := 1
|
||||
if step < 0 {
|
||||
direction = -1
|
||||
}
|
||||
for offset := 1; offset <= moonMaxDeclinationSearchSpan; offset++ {
|
||||
event := moonMaximumDeclinationEvent(centerK+step*offset, coeffs, cfg)
|
||||
if moonMaximumDeclinationMatchesDirection(event.JD-jd, direction, includeCurrent) {
|
||||
return event
|
||||
}
|
||||
}
|
||||
return DeclinationEvent{}
|
||||
}
|
||||
|
||||
func moonMaximumDeclinationSearchConfig(coeffs moonMaxDeclinationCoefficients) apsisSearchConfig {
|
||||
return apsisSearchConfig{
|
||||
bracketHalfWidth: moonApsisBracketHalfWidth,
|
||||
sampleStep: moonApsisSampleStep,
|
||||
derivativeStep: moonApsisDerivativeStep,
|
||||
@@ -175,37 +247,11 @@ func moonMaximumDeclinationSearch(jd float64, coeffs moonMaxDeclinationCoefficie
|
||||
maxIterations: moonApsisMaxIterations,
|
||||
maximize: coeffs.sign > 0,
|
||||
}
|
||||
targetTT := TD2UT(jd, true)
|
||||
centerK := int(math.Round((targetTT - coeffs.JDE0) / moonMaxDeclinationMeanMonthDays))
|
||||
|
||||
found := false
|
||||
bestDistance := math.Inf(1)
|
||||
var best DeclinationEvent
|
||||
for offset := -moonMaxDeclinationSearchSpan; offset <= moonMaxDeclinationSearchSpan; offset++ {
|
||||
event := moonMaximumDeclinationEvent(centerK+offset, coeffs, cfg)
|
||||
delta := event.JDE - jd
|
||||
if !moonMaximumDeclinationMatchesDirection(delta, direction, includeCurrent) {
|
||||
continue
|
||||
}
|
||||
distance := math.Abs(delta)
|
||||
if !found || distance < bestDistance || (distance == bestDistance && moonMaximumDeclinationEarlier(event, best)) {
|
||||
best = event
|
||||
bestDistance = distance
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func moonClosestMaximumDeclination(jd float64, coeffs moonMaxDeclinationCoefficients) DeclinationEvent {
|
||||
last := moonMaximumDeclinationSearch(jd, coeffs, -1, true)
|
||||
next := moonMaximumDeclinationSearch(jd, coeffs, 1, false)
|
||||
lastDistance := math.Abs(jd - last.JDE)
|
||||
nextDistance := math.Abs(next.JDE - jd)
|
||||
if lastDistance <= nextDistance {
|
||||
return last
|
||||
}
|
||||
return next
|
||||
// moonMaximumDeclinationOffset 查询时刻落在哪个平均周期(种子多项式的中心序号)。
|
||||
func moonMaximumDeclinationOffset(jd float64, coeffs moonMaxDeclinationCoefficients) int {
|
||||
return int(math.Round((UTC2TT(jd) - coeffs.JDE0) / moonMaxDeclinationMeanMonthDays))
|
||||
}
|
||||
|
||||
func moonMaximumDeclinationMatchesDirection(delta float64, direction int, includeCurrent bool) bool {
|
||||
@@ -226,7 +272,7 @@ func moonMaximumDeclinationMatchesDirection(delta float64, direction int, includ
|
||||
}
|
||||
|
||||
func moonMaximumDeclinationEarlier(a, b DeclinationEvent) bool {
|
||||
return a.JDE < b.JDE
|
||||
return a.JD < b.JD
|
||||
}
|
||||
|
||||
func moonMaximumDeclinationSeedTT(k int, coeffs moonMaxDeclinationCoefficients) float64 {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件用改动前的“七个候选全精修”实现做差分对照与 A/B 基准。
|
||||
|
||||
func moonMaximumDeclinationReferenceSearch(jd float64, coeffs moonMaxDeclinationCoefficients, direction int, includeCurrent bool) DeclinationEvent {
|
||||
if !finite(jd) {
|
||||
return DeclinationEvent{}
|
||||
}
|
||||
cfg := moonMaximumDeclinationSearchConfig(coeffs)
|
||||
centerK := moonMaximumDeclinationOffset(jd, coeffs)
|
||||
found := false
|
||||
bestDistance := math.Inf(1)
|
||||
var best DeclinationEvent
|
||||
for offset := -moonMaxDeclinationSearchSpan; offset <= moonMaxDeclinationSearchSpan; offset++ {
|
||||
event := moonMaximumDeclinationEvent(centerK+offset, coeffs, cfg)
|
||||
delta := event.JD - jd
|
||||
if !moonMaximumDeclinationMatchesDirection(delta, direction, includeCurrent) {
|
||||
continue
|
||||
}
|
||||
distance := math.Abs(delta)
|
||||
if !found || distance < bestDistance || (distance == bestDistance && moonMaximumDeclinationEarlier(event, best)) {
|
||||
best = event
|
||||
bestDistance = distance
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func moonMaximumDeclinationReferenceClosest(jd float64, coeffs moonMaxDeclinationCoefficients) DeclinationEvent {
|
||||
if !finite(jd) {
|
||||
return DeclinationEvent{}
|
||||
}
|
||||
last := moonMaximumDeclinationReferenceSearch(jd, coeffs, -1, true)
|
||||
next := moonMaximumDeclinationReferenceSearch(jd, coeffs, 1, false)
|
||||
lastDistance := math.Abs(jd - last.JD)
|
||||
nextDistance := math.Abs(next.JD - jd)
|
||||
if lastDistance <= nextDistance {
|
||||
return last
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func TestMoonMaximumDeclinationPruningMatchesFullSearch(t *testing.T) {
|
||||
for _, coeffs := range []moonMaxDeclinationCoefficients{moonMaxDeclinationNorthCoefficients, moonMaxDeclinationSouthCoefficients} {
|
||||
for jd := JDCalc(2024, 1, 1); jd <= JDCalc(2025, 6, 1); jd += 2.5 {
|
||||
for _, direction := range []struct {
|
||||
name string
|
||||
dir int
|
||||
includeCurrent bool
|
||||
fn func(float64) DeclinationEvent
|
||||
}{
|
||||
{"Last", -1, true, func(v float64) DeclinationEvent {
|
||||
return moonMaximumDeclinationSearch(v, coeffs, -1, true)
|
||||
}},
|
||||
{"Next", 1, false, func(v float64) DeclinationEvent {
|
||||
return moonMaximumDeclinationSearch(v, coeffs, 1, false)
|
||||
}},
|
||||
} {
|
||||
want := moonMaximumDeclinationReferenceSearch(jd, coeffs, direction.dir, direction.includeCurrent)
|
||||
got := direction.fn(jd)
|
||||
if got != want {
|
||||
t.Fatalf("k=%v %s(%v) = %+v, want %+v", coeffs.sign, direction.name, jd, got, want)
|
||||
}
|
||||
}
|
||||
wantClosest := moonMaximumDeclinationReferenceClosest(jd, coeffs)
|
||||
if got := moonClosestMaximumDeclination(jd, coeffs); got != wantClosest {
|
||||
t.Fatalf("k=%v Closest(%v) = %+v, want %+v", coeffs.sign, jd, got, wantClosest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMoonMaximumDeclinationFullSearchReference(b *testing.B) {
|
||||
jd := JDCalc(2025, 3, 1)
|
||||
b.Run("Next", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = moonMaximumDeclinationReferenceSearch(jd, moonMaxDeclinationNorthCoefficients, 1, false)
|
||||
}
|
||||
})
|
||||
b.Run("Closest", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = moonMaximumDeclinationReferenceClosest(jd, moonMaxDeclinationNorthCoefficients)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func TestMoonMaximumDeclinationsMatchHorizonsBaseline(t *testing.T) {
|
||||
t.Fatalf("unknown declination kind %q", sample.Kind)
|
||||
}
|
||||
|
||||
gotTime := JDE2DateByZone(got.JDE, time.UTC, false)
|
||||
gotTime := JD2DateByZone(got.JD, time.UTC, false)
|
||||
timeDiff := gotTime.Sub(wantTime)
|
||||
if timeDiff < 0 {
|
||||
timeDiff = -timeDiff
|
||||
@@ -124,23 +124,23 @@ func TestMoonMaximumDeclinationSignsAndOrder(t *testing.T) {
|
||||
if event.Declination <= 0 {
|
||||
t.Fatalf("north event #%d should be positive, got %.8f", i+1, event.Declination)
|
||||
}
|
||||
if i > 0 && !(north[i-1].JDE < event.JDE) {
|
||||
t.Fatalf("north events not strictly increasing: %.12f then %.12f", north[i-1].JDE, event.JDE)
|
||||
if i > 0 && !(north[i-1].JD < event.JD) {
|
||||
t.Fatalf("north events not strictly increasing: %.12f then %.12f", north[i-1].JD, event.JD)
|
||||
}
|
||||
}
|
||||
for i, event := range south {
|
||||
if event.Declination >= 0 {
|
||||
t.Fatalf("south event #%d should be negative, got %.8f", i+1, event.Declination)
|
||||
}
|
||||
if i > 0 && !(south[i-1].JDE < event.JDE) {
|
||||
t.Fatalf("south events not strictly increasing: %.12f then %.12f", south[i-1].JDE, event.JDE)
|
||||
if i > 0 && !(south[i-1].JD < event.JD) {
|
||||
t.Fatalf("south events not strictly increasing: %.12f then %.12f", south[i-1].JD, event.JD)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoonMaximumDeclinationSearchMatchesMonthlyEvents(t *testing.T) {
|
||||
query := time.Date(2026, time.January, 10, 0, 0, 0, 0, time.UTC)
|
||||
queryJDE := Date2JDE(query)
|
||||
queryJD := Date2JD(query)
|
||||
|
||||
northEvents := append([]DeclinationEvent{}, MoonMaximumNorthDeclinations(2025, time.December)...)
|
||||
northEvents = append(northEvents, MoonMaximumNorthDeclinations(2026, time.January)...)
|
||||
@@ -150,13 +150,13 @@ func TestMoonMaximumDeclinationSearchMatchesMonthlyEvents(t *testing.T) {
|
||||
southEvents = append(southEvents, MoonMaximumSouthDeclinations(2026, time.January)...)
|
||||
southEvents = append(southEvents, MoonMaximumSouthDeclinations(2026, time.February)...)
|
||||
|
||||
assertSameDeclinationEvent(t, "last north", LastMoonMaximumNorthDeclination(queryJDE), expectedDirectionalDeclinationEvent(northEvents, queryJDE, -1, true))
|
||||
assertSameDeclinationEvent(t, "next north", NextMoonMaximumNorthDeclination(queryJDE), expectedDirectionalDeclinationEvent(northEvents, queryJDE, 1, false))
|
||||
assertSameDeclinationEvent(t, "closest north", ClosestMoonMaximumNorthDeclination(queryJDE), expectedClosestDeclinationEvent(northEvents, queryJDE))
|
||||
assertSameDeclinationEvent(t, "last north", LastMoonMaximumNorthDeclination(queryJD), expectedDirectionalDeclinationEvent(northEvents, queryJD, -1, true))
|
||||
assertSameDeclinationEvent(t, "next north", NextMoonMaximumNorthDeclination(queryJD), expectedDirectionalDeclinationEvent(northEvents, queryJD, 1, false))
|
||||
assertSameDeclinationEvent(t, "closest north", ClosestMoonMaximumNorthDeclination(queryJD), expectedClosestDeclinationEvent(northEvents, queryJD))
|
||||
|
||||
assertSameDeclinationEvent(t, "last south", LastMoonMaximumSouthDeclination(queryJDE), expectedDirectionalDeclinationEvent(southEvents, queryJDE, -1, true))
|
||||
assertSameDeclinationEvent(t, "next south", NextMoonMaximumSouthDeclination(queryJDE), expectedDirectionalDeclinationEvent(southEvents, queryJDE, 1, false))
|
||||
assertSameDeclinationEvent(t, "closest south", ClosestMoonMaximumSouthDeclination(queryJDE), expectedClosestDeclinationEvent(southEvents, queryJDE))
|
||||
assertSameDeclinationEvent(t, "last south", LastMoonMaximumSouthDeclination(queryJD), expectedDirectionalDeclinationEvent(southEvents, queryJD, -1, true))
|
||||
assertSameDeclinationEvent(t, "next south", NextMoonMaximumSouthDeclination(queryJD), expectedDirectionalDeclinationEvent(southEvents, queryJD, 1, false))
|
||||
assertSameDeclinationEvent(t, "closest south", ClosestMoonMaximumSouthDeclination(queryJD), expectedClosestDeclinationEvent(southEvents, queryJD))
|
||||
}
|
||||
|
||||
func TestMoonMaximumDeclinationSearchAtExactEventTime(t *testing.T) {
|
||||
@@ -165,29 +165,29 @@ func TestMoonMaximumDeclinationSearchAtExactEventTime(t *testing.T) {
|
||||
t.Fatalf("expected at least two north events spanning Jan 2026 search window, got %d", len(north))
|
||||
}
|
||||
|
||||
exactJDE := north[0].JDE
|
||||
assertSameDeclinationEvent(t, "exact last north", LastMoonMaximumNorthDeclination(exactJDE), north[0])
|
||||
assertSameDeclinationEvent(t, "exact closest north", ClosestMoonMaximumNorthDeclination(exactJDE), north[0])
|
||||
assertSameDeclinationEvent(t, "exact next north", NextMoonMaximumNorthDeclination(exactJDE), north[1])
|
||||
exactJD := north[0].JD
|
||||
assertSameDeclinationEvent(t, "exact last north", LastMoonMaximumNorthDeclination(exactJD), north[0])
|
||||
assertSameDeclinationEvent(t, "exact closest north", ClosestMoonMaximumNorthDeclination(exactJD), north[0])
|
||||
assertSameDeclinationEvent(t, "exact next north", NextMoonMaximumNorthDeclination(exactJD), north[1])
|
||||
}
|
||||
|
||||
func assertSameDeclinationEvent(t *testing.T, name string, got, want DeclinationEvent) {
|
||||
t.Helper()
|
||||
if math.Abs(got.JDE-want.JDE) > 1e-12 {
|
||||
t.Fatalf("%s JDE mismatch: got %.12f want %.12f", name, got.JDE, want.JDE)
|
||||
if math.Abs(got.JD-want.JD) > 1e-12 {
|
||||
t.Fatalf("%s JD mismatch: got %.12f want %.12f", name, got.JD, want.JD)
|
||||
}
|
||||
if math.Float64bits(got.Declination) != math.Float64bits(want.Declination) {
|
||||
t.Fatalf("%s declination mismatch: got %.12f want %.12f", name, got.Declination, want.Declination)
|
||||
}
|
||||
}
|
||||
|
||||
func expectedDirectionalDeclinationEvent(events []DeclinationEvent, queryJDE float64, direction int, includeCurrent bool) DeclinationEvent {
|
||||
func expectedDirectionalDeclinationEvent(events []DeclinationEvent, queryJD float64, direction int, includeCurrent bool) DeclinationEvent {
|
||||
var (
|
||||
found bool
|
||||
best DeclinationEvent
|
||||
)
|
||||
for _, event := range events {
|
||||
delta := event.JDE - queryJDE
|
||||
delta := event.JD - queryJD
|
||||
if !moonMaximumDeclinationMatchesDirection(delta, direction, includeCurrent) {
|
||||
continue
|
||||
}
|
||||
@@ -196,17 +196,17 @@ func expectedDirectionalDeclinationEvent(events []DeclinationEvent, queryJDE flo
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
if math.Abs(delta) < math.Abs(best.JDE-queryJDE) || (math.Abs(delta) == math.Abs(best.JDE-queryJDE) && event.JDE < best.JDE) {
|
||||
if math.Abs(delta) < math.Abs(best.JD-queryJD) || (math.Abs(delta) == math.Abs(best.JD-queryJD) && event.JD < best.JD) {
|
||||
best = event
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func expectedClosestDeclinationEvent(events []DeclinationEvent, queryJDE float64) DeclinationEvent {
|
||||
last := expectedDirectionalDeclinationEvent(events, queryJDE, -1, true)
|
||||
next := expectedDirectionalDeclinationEvent(events, queryJDE, 1, false)
|
||||
if math.Abs(queryJDE-last.JDE) <= math.Abs(next.JDE-queryJDE) {
|
||||
func expectedClosestDeclinationEvent(events []DeclinationEvent, queryJD float64) DeclinationEvent {
|
||||
last := expectedDirectionalDeclinationEvent(events, queryJD, -1, true)
|
||||
next := expectedDirectionalDeclinationEvent(events, queryJD, 1, false)
|
||||
if math.Abs(queryJD-last.JD) <= math.Abs(next.JD-queryJD) {
|
||||
return last
|
||||
}
|
||||
return next
|
||||
|
||||
+241
-103
@@ -12,14 +12,14 @@ import (
|
||||
func MoonAzimuth(jd, lon, lat, tz float64) float64 {
|
||||
|
||||
//tmp := (tz*15 - lon) * 4 / 60
|
||||
calcjd := TD2UT(jd-tz/24, true)
|
||||
ra := MoonTrueRa(calcjd)
|
||||
dec := MoonTrueDec(calcjd)
|
||||
away := MoonAway(calcjd) / 149597870.7
|
||||
jde := UTC2TT(jd - tz/24)
|
||||
ra := MoonTrueRa(jde)
|
||||
dec := MoonTrueDec(jde)
|
||||
away := MoonAway(jde) / 149597870.7
|
||||
ndec := TopocentricDec(ra, dec, lat, lon, jd-tz/24, away, 0)
|
||||
nra := TopocentricRa(ra, dec, lat, lon, jd-tz/24, away, 0)
|
||||
calcjd = jd - tz/24
|
||||
st := Limit360(ApparentSiderealTime(calcjd)*15 + lon)
|
||||
jdUT := jd - tz/24
|
||||
st := Limit360(ApparentSiderealTime(UTC2UT1(jdUT))*15 + lon)
|
||||
hourAngle := Limit360(st - nra)
|
||||
tmp2 := Sin(hourAngle) / (Cos(hourAngle)*Sin(lat) - Tan(ndec)*Cos(lat))
|
||||
azimuth := ArcTan(tmp2)
|
||||
@@ -42,14 +42,14 @@ func MoonAzimuth(jd, lon, lat, tz float64) float64 {
|
||||
func MoonHeight(jd, lon, lat, tz float64) float64 {
|
||||
// tmp := (tz*15 - lon) * 4 / 60
|
||||
//truejd=jd-tmp/24;
|
||||
calcjd := TD2UT(jd-tz/24, true)
|
||||
ra := MoonTrueRa(calcjd)
|
||||
dec := MoonTrueDec(calcjd)
|
||||
away := MoonAway(calcjd) / 149597870.7
|
||||
jde := UTC2TT(jd - tz/24)
|
||||
ra := MoonTrueRa(jde)
|
||||
dec := MoonTrueDec(jde)
|
||||
away := MoonAway(jde) / 149597870.7
|
||||
ndec := TopocentricDec(ra, dec, lat, lon, jd-tz/24, away, 0)
|
||||
nra := TopocentricRa(ra, dec, lat, lon, jd-tz/24, away, 0)
|
||||
calcjd = jd - tz/24
|
||||
st := Limit360(ApparentSiderealTime(calcjd)*15 + lon)
|
||||
jdUT := jd - tz/24
|
||||
st := Limit360(ApparentSiderealTime(UTC2UT1(jdUT))*15 + lon)
|
||||
hourAngle := Limit360(st - nra)
|
||||
tmp2 := Sin(lat)*Sin(ndec) + Cos(ndec)*Cos(lat)*Cos(hourAngle)
|
||||
return ArcSin(tmp2)
|
||||
@@ -60,14 +60,14 @@ func HMoonAzimuth(jd, lon, lat, tz float64) float64 {
|
||||
}
|
||||
|
||||
func HMoonAzimuthN(jd, lon, lat, tz float64, n int) float64 {
|
||||
calcjd := TD2UT(jd-tz/24, true)
|
||||
ra := HMoonTrueRaN(calcjd, n)
|
||||
dec := HMoonTrueDecN(calcjd, n)
|
||||
away := HMoonAwayN(calcjd, n) / 149597870.7
|
||||
jde := UTC2TT(jd - tz/24)
|
||||
ra := HMoonTrueRaN(jde, n)
|
||||
dec := HMoonTrueDecN(jde, n)
|
||||
away := HMoonAwayN(jde, n) / 149597870.7
|
||||
ndec := TopocentricDec(ra, dec, lat, lon, jd-tz/24, away, 0)
|
||||
nra := TopocentricRa(ra, dec, lat, lon, jd-tz/24, away, 0)
|
||||
calcjd = jd - tz/24
|
||||
st := Limit360(ApparentSiderealTime(calcjd)*15 + lon)
|
||||
jdUT := jd - tz/24
|
||||
st := Limit360(ApparentSiderealTime(UTC2UT1(jdUT))*15 + lon)
|
||||
hourAngle := Limit360(st - nra)
|
||||
tmp2 := Sin(hourAngle) / (Cos(hourAngle)*Sin(lat) - Tan(ndec)*Cos(lat))
|
||||
azimuth := ArcTan(tmp2)
|
||||
@@ -85,20 +85,147 @@ func HMoonAzimuthN(jd, lon, lat, tz float64, n int) float64 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HMoonHeight 当地民用时儒略日下的月心几何高度角(度,不含折射)/ geometric Moon-centre altitude in degrees for a local civil Julian day.
|
||||
//
|
||||
// jd 是该时区的当地民用时(墙上时刻)儒略日,tz 是时区偏移小时数,库内按 jd−tz/24 换成 UTC。
|
||||
// 只有 tz 给 0 时 jd 才是 UTC 儒略日;不要拿 UTC 数值再配非零 tz,那会多减一次时区。
|
||||
// jd is that zone's local civil (wall-clock) Julian day and tz is the zone offset in hours,
|
||||
// converted internally as jd-tz/24. Only tz 0 makes jd a UTC Julian day: pairing a UTC value with a
|
||||
// non-zero tz subtracts the offset twice.
|
||||
func HMoonHeight(jd, lon, lat, tz float64) float64 {
|
||||
return HMoonHeightN(jd, lon, lat, tz, -1)
|
||||
}
|
||||
|
||||
type moonObservationState struct {
|
||||
altitude float64
|
||||
distanceKM float64
|
||||
}
|
||||
|
||||
func hMoonObservationStateN(jd, lon, lat, tz, height float64, n int) moonObservationState {
|
||||
calculationJDE := UTC2TT(jd - tz/24)
|
||||
ra, dec := HMoonTrueRaDecN(calculationJDE, n)
|
||||
distanceKM := HMoonAwayN(calculationJDE, n)
|
||||
distanceAU := distanceKM / angularDiameterAstronomicalUnitKM
|
||||
topocentricRA, topocentricDec := TopocentricRaDec(ra, dec, lat, lon, jd-tz/24, distanceAU, height)
|
||||
siderealTime := Limit360(ApparentSiderealTime(UTC2UT1(jd-tz/24))*15 + lon)
|
||||
hourAngle := Limit360(siderealTime - topocentricRA)
|
||||
altitudeSine := Sin(lat)*Sin(topocentricDec) + Cos(topocentricDec)*Cos(lat)*Cos(hourAngle)
|
||||
return moonObservationState{
|
||||
altitude: ArcSin(altitudeSine),
|
||||
distanceKM: distanceKM,
|
||||
}
|
||||
}
|
||||
|
||||
func HMoonHeightN(jd, lon, lat, tz float64, n int) float64 {
|
||||
calcjd := TD2UT(jd-tz/24, true)
|
||||
ra, dec := HMoonTrueRaDecN(calcjd, n)
|
||||
away := HMoonAwayN(calcjd, n) / 149597870.7
|
||||
nra, ndec := TopocentricRaDec(ra, dec, lat, lon, calcjd, away, 0)
|
||||
calcjd = jd - tz/24
|
||||
st := Limit360(ApparentSiderealTime(calcjd)*15 + lon)
|
||||
hourAngle := Limit360(st - nra)
|
||||
tmp2 := Sin(lat)*Sin(ndec) + Cos(ndec)*Cos(lat)*Cos(hourAngle)
|
||||
return ArcSin(tmp2)
|
||||
return hMoonObservationStateN(jd, lon, lat, tz, 0, n).altitude
|
||||
}
|
||||
|
||||
// MoonState 同一瞬间可对任意观测点复用的月球位置与恒星时 / one instant's lunar position and sidereal time, reusable across observers.
|
||||
type MoonState struct {
|
||||
rightAscension float64
|
||||
declination float64
|
||||
distanceAU float64
|
||||
siderealTime float64
|
||||
}
|
||||
|
||||
// MoonStateAt 由 UTC 儒略日构造该瞬间的可复用月球状态 / builds the reusable state for one UTC Julian day.
|
||||
func MoonStateAt(utcJD float64) MoonState {
|
||||
jde := UTC2TT(utcJD)
|
||||
rightAscension, declination := HMoonTrueRaDec(jde)
|
||||
return MoonState{
|
||||
rightAscension: rightAscension,
|
||||
declination: declination,
|
||||
distanceAU: HMoonAway(jde) / angularDiameterAstronomicalUnitKM,
|
||||
siderealTime: ApparentSiderealTime(UTC2UT1(utcJD)) * 15,
|
||||
}
|
||||
}
|
||||
|
||||
func (state MoonState) finite() bool {
|
||||
return finite(state.rightAscension) && finite(state.declination) &&
|
||||
finite(state.distanceAU) && finite(state.siderealTime)
|
||||
}
|
||||
|
||||
// HMoonHeight 给定观测者经度、纬度(度,椭球高 0)的月心几何高度角,等于 HMoonHeight(构造本状态时的 UTC 儒略日, 经, 纬, 0)。
|
||||
// HMoonHeight returns the geometric Moon-centre altitude for one observer, equal to HMoonHeight(the UTC Julian day given to MoonStateAt, lon, lat, 0).
|
||||
func (state MoonState) HMoonHeight(longitude, latitude float64) float64 {
|
||||
// 本状态固定是 UTC 瞬间、椭球高 0,因此只对应包级 tz=0、height=0 的用法。
|
||||
// 恒星时已在状态里算好,这里不再走会重算恒星时与时标换算的 TopocentricRaDec。
|
||||
topocentricRA, topocentricDec := topocentricRaDecWithSidereal(
|
||||
state.rightAscension, state.declination, latitude, longitude, state.siderealTime, state.distanceAU, 0,
|
||||
)
|
||||
hourAngle := Limit360(Limit360(state.siderealTime+longitude) - topocentricRA)
|
||||
return ArcSin(Sin(latitude)*Sin(topocentricDec) + Cos(topocentricDec)*Cos(latitude)*Cos(hourAngle))
|
||||
}
|
||||
|
||||
// MoonHorizon 用本状态生成海平面几何月心地平圈,口径同包级 MoonHorizon / sea-level geometric Moon-centre horizon ring from this state.
|
||||
func (state MoonState) MoonHorizon(samples int) [][2]float64 {
|
||||
if !state.finite() {
|
||||
return nil
|
||||
}
|
||||
if samples <= 0 {
|
||||
samples = 360
|
||||
}
|
||||
if samples < 12 {
|
||||
samples = 12
|
||||
} else if samples > 1440 {
|
||||
samples = 1440
|
||||
}
|
||||
parallax := math.Sin(0.0024427777777*rad) / state.distanceAU
|
||||
longitude := (state.rightAscension - state.siderealTime) * rad
|
||||
latitude := state.declination * rad
|
||||
if !finite(parallax) || parallax <= 0 || parallax >= 1 || !finite(longitude) || !finite(latitude) {
|
||||
return nil
|
||||
}
|
||||
center := [3]float64{math.Cos(latitude) * math.Cos(longitude), math.Cos(latitude) * math.Sin(longitude), math.Sin(latitude)}
|
||||
north := [3]float64{-math.Sin(latitude) * math.Cos(longitude), -math.Sin(latitude) * math.Sin(longitude), math.Cos(latitude)}
|
||||
east := [3]float64{-math.Sin(longitude), math.Cos(longitude), 0}
|
||||
points := make([][2]float64, samples)
|
||||
for index := range points {
|
||||
bearing := 2 * math.Pi * float64(index) / float64(samples)
|
||||
radius := math.Acos(parallax)
|
||||
var point [3]float64
|
||||
for iteration := 0; iteration < 8; iteration++ {
|
||||
for axis := range point {
|
||||
point[axis] = center[axis]*math.Cos(radius) +
|
||||
(north[axis]*math.Cos(bearing)+east[axis]*math.Sin(bearing))*math.Sin(radius)
|
||||
}
|
||||
lat := math.Asin(math.Max(-1, math.Min(1, point[2]))) / rad
|
||||
// The topocentric direction is horizontal when its dot product
|
||||
// with the geodetic zenith vanishes: cos(radius)=observer/range.
|
||||
next := math.Acos(parallax * (pcosi(lat, 0)*math.Cos(lat*rad) + psini(lat, 0)*math.Sin(lat*rad)))
|
||||
if math.Abs(next-radius) < 1e-14 {
|
||||
break
|
||||
}
|
||||
radius = next
|
||||
}
|
||||
points[index] = [2]float64{math.Atan2(point[1], point[0]) / rad, math.Asin(math.Max(-1, math.Min(1, point[2]))) / rad}
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
func moonRiseSetResidual(jd, longitude, latitude, timeZone, zenithShift, height float64, n int) float64 {
|
||||
state := hMoonObservationStateN(jd, longitude, latitude, timeZone, height, n)
|
||||
// 相对观测者下沉地平线的视上缘高度角 / Apparent upper-limb altitude relative to the observer's depressed horizon.
|
||||
residual := state.altitude + HeightDegreeByLat(height, latitude)
|
||||
if zenithShift != 0 {
|
||||
residual += RefractionFromTrueAltitude(state.altitude, refractionStandardPressureHPa, refractionStandardTemperatureC)
|
||||
residual += angularSemidiameterArcsec(moonEquatorialRadiusKM, state.distanceKM) / 3600
|
||||
}
|
||||
return residual
|
||||
}
|
||||
|
||||
// moonRiseSetOnCivilDay 在民用日内求升/落时刻;找不到过零时的错误口径与 rise_set.go 的 ErrNeverRise/ErrNeverSet 一致,
|
||||
// fallbackErr 是调用方用中天/下中天残差预判的同一几何结论,命中时优先于扫描结果。
|
||||
func moonRiseSetOnCivilDay(candidate, slope, civilDayStart, longitude, latitude, originalTimeZone,
|
||||
localTimeZone, zenithShift, height float64, isRise bool, fallbackErr error) (float64, error) {
|
||||
if eventRiseSetCandidateValid(candidate, civilDayStart, slope, isRise) {
|
||||
return candidate, nil
|
||||
}
|
||||
return eventDirectionalRiseSetSearch(civilDayStart, isRise, fallbackErr, func(outputJD float64) float64 {
|
||||
localJD := outputJD + localTimeZone/24 - originalTimeZone/24
|
||||
return moonRiseSetResidual(localJD, longitude, latitude, localTimeZone, zenithShift, height, -1)
|
||||
})
|
||||
}
|
||||
|
||||
// 废弃
|
||||
@@ -109,44 +236,43 @@ func GetMoonTZTime(jd, lon, lat, tz float64) float64 { //实际中天时间{
|
||||
jd += 0.5
|
||||
}
|
||||
estimateJD := jd
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
var ok bool
|
||||
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||
stDegree := MoonTimeAngle(prevJD, lon, lat, tz) - 359.599
|
||||
stDegreep := (MoonTimeAngle(prevJD+0.000005, lon, lat, tz) - MoonTimeAngle(prevJD-0.000005, lon, lat, tz)) / 0.00001
|
||||
estimateJD = prevJD - stDegree/stDegreep
|
||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
||||
break
|
||||
}
|
||||
return stDegree / stDegreep
|
||||
})
|
||||
if !ok {
|
||||
return math.NaN()
|
||||
}
|
||||
return estimateJD
|
||||
}
|
||||
|
||||
func MoonCulminationTime(jde, lon, lat, timezone float64) float64 {
|
||||
//jde 世界时,非力学时,当地时区 0时,无需转换力学时
|
||||
//ra,dec 瞬时天球座标,非J2000等时间天球坐标
|
||||
jde = math.Floor(jde) + 0.5
|
||||
estimateJD := jde + Limit360(360-MoonTimeAngle(jde, lon, lat, timezone))/15.0/24.0/0.9
|
||||
limitHA := func(jde, lon, timezone float64) float64 {
|
||||
ha := MoonTimeAngle(jde, lon, lat, timezone)
|
||||
func MoonCulminationTime(localJD, lon, lat, timezone float64) float64 {
|
||||
// localJD 是本地民用日锚点(当地 0 时),不是力学时;ra/dec 为瞬时天球坐标,非 J2000 等固定历元。
|
||||
localJD = math.Floor(localJD) + 0.5
|
||||
estimateJD := localJD + Limit360(360-MoonTimeAngle(localJD, lon, lat, timezone))/15.0/24.0/0.9
|
||||
limitHA := func(localJD, lon, timezone float64) float64 {
|
||||
ha := MoonTimeAngle(localJD, lon, lat, timezone)
|
||||
if ha < 180 {
|
||||
ha += 360
|
||||
}
|
||||
return ha
|
||||
}
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
var ok bool
|
||||
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||
stDegree := limitHA(prevJD, lon, timezone) - 360
|
||||
stDegreep := (limitHA(prevJD+0.000005, lon, timezone) - limitHA(prevJD-0.000005, lon, timezone)) / 0.00001
|
||||
estimateJD = prevJD - stDegree/stDegreep
|
||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
||||
break
|
||||
}
|
||||
return stDegree / stDegreep
|
||||
})
|
||||
if !ok {
|
||||
return math.NaN()
|
||||
}
|
||||
return estimateJD
|
||||
}
|
||||
|
||||
func MoonTimeAngle(jd, lon, lat, tz float64) float64 {
|
||||
startime := Limit360(ApparentSiderealTime(jd-tz/24)*15 + lon)
|
||||
startime := Limit360(ApparentSiderealTime(UTC2UT1(jd-tz/24))*15 + lon)
|
||||
timeangle := startime - HMoonApparentRa(jd, lon, lat, tz)
|
||||
if timeangle < 0 {
|
||||
timeangle += 360
|
||||
@@ -155,21 +281,23 @@ func MoonTimeAngle(jd, lon, lat, tz float64) float64 {
|
||||
}
|
||||
|
||||
func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, height float64) (float64, error) {
|
||||
if !isFiniteFloat(julianDay) || !isFiniteFloat(longitude) || !isFiniteFloat(latitude) || !isFiniteFloat(timeZone) || !isFiniteFloat(zenithShift) || !isFiniteFloat(height) {
|
||||
return 0, ErrInvalidObservationInput
|
||||
}
|
||||
originalTimeZone := timeZone
|
||||
timeZone = longitude / 15
|
||||
var timeToMeridian float64
|
||||
julianDayZero := math.Floor(julianDay) + 0.5
|
||||
//julianDay = math.Floor(julianDay) + 0.5 - originalTimeZone/24 + timeZone/24 // 求0时JDE
|
||||
//fix:这里时间分界线应当以传入的时区为准,不应当使用当地时区,否则在0时的判断会出错
|
||||
civilDayStart := math.Floor(julianDay) + 0.5
|
||||
// 时间分界线以传入的时区为准,不用当地时区,否则 0 时的判断会出错。
|
||||
julianDay = math.Floor(julianDay) + 0.5
|
||||
estimatedTime := julianDay
|
||||
moonHeight := MoonHeight(julianDay, longitude, latitude, originalTimeZone) // 求此时月亮高度
|
||||
moonResidual := moonRiseSetResidual(julianDay, longitude, latitude, originalTimeZone, zenithShift, height, -1)
|
||||
|
||||
moonAngle := StandardAltitudeMoon(zenithShift, height, latitude)
|
||||
|
||||
moonAngleTime := MoonTimeAngle(julianDay, longitude, latitude, originalTimeZone)
|
||||
|
||||
if moonHeight-moonAngle > 0 { // 月亮在地平线上或在落下与下中天之间
|
||||
if moonResidual > 0 { // 月亮在地平线上或在落下与下中天之间
|
||||
if moonAngleTime > 180 {
|
||||
timeToMeridian = (180 + 360 - moonAngleTime) / 15
|
||||
} else {
|
||||
@@ -178,10 +306,10 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig
|
||||
estimatedTime += (timeToMeridian/24 + (timeToMeridian/24*12.0)/15.0/24.0)
|
||||
}
|
||||
|
||||
if moonHeight-moonAngle < 0 && moonAngleTime > 180 {
|
||||
if moonResidual < 0 && moonAngleTime > 180 {
|
||||
timeToMeridian = (180 - moonAngleTime) / 15
|
||||
estimatedTime += (timeToMeridian/24 + (timeToMeridian/24*12.0)/15.0/24.0)
|
||||
} else if moonHeight-moonAngle < 0 && moonAngleTime < 180 {
|
||||
} else if moonResidual < 0 && moonAngleTime < 180 {
|
||||
timeToMeridian = (180 - moonAngleTime) / 15
|
||||
estimatedTime += (timeToMeridian/24 + (timeToMeridian/24*12.0)/15.0/24.0)
|
||||
}
|
||||
@@ -191,10 +319,12 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig
|
||||
estimatedTime += (180 - currentAngle) * 4.0 / 60.0 / 24.0
|
||||
}
|
||||
|
||||
currentHeight := HMoonHeight(estimatedTime, longitude, latitude, timeZone)
|
||||
if !(currentHeight < -10 && math.Abs(latitude) < 60) {
|
||||
if currentHeight > moonAngle {
|
||||
return 0, ErrNeverSet
|
||||
currentResidual := moonRiseSetResidual(estimatedTime, longitude, latitude, timeZone, zenithShift, height, -1)
|
||||
if !(currentResidual < -10 && math.Abs(latitude) < 60) {
|
||||
if currentResidual > 0 {
|
||||
// 下中天仍在地平线上:当日无落下(也无可升起),口径见 moonRiseSetOnCivilDay。
|
||||
return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude,
|
||||
originalTimeZone, timeZone, zenithShift, height, true, ErrNeverSet)
|
||||
}
|
||||
checkTime := estimatedTime + 12.0/24.0 + 6.0/15.0/24.0
|
||||
checkAngle := MoonTimeAngle(checkTime, longitude, latitude, timeZone)
|
||||
@@ -202,8 +332,10 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig
|
||||
checkAngle += 360
|
||||
}
|
||||
checkTime += (360 - checkAngle) * 4.0 / 60.0 / 24.0
|
||||
if HMoonHeight(checkTime, longitude, latitude, timeZone) < moonAngle {
|
||||
return 0, ErrNeverRise
|
||||
if moonRiseSetResidual(checkTime, longitude, latitude, timeZone, zenithShift, height, -1) < 0 {
|
||||
// 上中天仍在地平线下:当日无升起。
|
||||
return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude,
|
||||
originalTimeZone, timeZone, zenithShift, height, true, ErrNeverRise)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +347,7 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig
|
||||
estimatedTime += hourAngle/24.00 + hourAngle/33.00/15.00
|
||||
} else {
|
||||
i := 0
|
||||
for MoonHeight(estimatedTime, longitude, latitude, timeZone) < moonAngle {
|
||||
for moonRiseSetResidual(estimatedTime, longitude, latitude, timeZone, zenithShift, height, -1) < 0 {
|
||||
i++
|
||||
estimatedTime += 15.0 / 60.0 / 24.0
|
||||
if i > 48 {
|
||||
@@ -225,41 +357,39 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig
|
||||
}
|
||||
|
||||
// 使用牛顿迭代法求精确解
|
||||
estimatedTime = moonRiseSetNewtonRaphsonIteration(estimatedTime, longitude, latitude, timeZone, moonAngle, HMoonHeight, 0.00002)
|
||||
|
||||
estimatedTime, slope := moonRiseSetResidualIteration(estimatedTime, longitude, latitude, timeZone, zenithShift, height, 0.00002)
|
||||
estimatedTime = estimatedTime - timeZone/24 + originalTimeZone/24
|
||||
|
||||
if estimatedTime > julianDayZero+1 || estimatedTime < julianDayZero {
|
||||
return 0, ErrNotOnThisDate
|
||||
}
|
||||
return estimatedTime, nil
|
||||
return moonRiseSetOnCivilDay(estimatedTime, slope, civilDayStart, longitude, latitude,
|
||||
originalTimeZone, timeZone, zenithShift, height, true, nil)
|
||||
}
|
||||
|
||||
func GetMoonSetTime(julianDay, longitude, latitude, timeZone, zenithShift, height float64) (float64, error) {
|
||||
if !isFiniteFloat(julianDay) || !isFiniteFloat(longitude) || !isFiniteFloat(latitude) || !isFiniteFloat(timeZone) || !isFiniteFloat(zenithShift) || !isFiniteFloat(height) {
|
||||
return 0, ErrInvalidObservationInput
|
||||
}
|
||||
originalTimeZone := timeZone
|
||||
timeZone = longitude / 15
|
||||
var timeToMeridian float64
|
||||
julianDayZero := math.Floor(julianDay) + 0.5
|
||||
//julianDay = math.Floor(julianDay) + 0.5 - originalTimeZone/24 + timeZone/24 // 求0时JDE
|
||||
//fix:这里时间分界线应当以传入的时区为准,不应当使用当地时区,否则在0时的判断会出错
|
||||
civilDayStart := math.Floor(julianDay) + 0.5
|
||||
// 时间分界线以传入的时区为准,不用当地时区,否则 0 时的判断会出错。
|
||||
julianDay = math.Floor(julianDay) + 0.5
|
||||
estimatedTime := julianDay
|
||||
moonHeight := MoonHeight(julianDay, longitude, latitude, originalTimeZone) // 求此时月亮高度
|
||||
moonResidual := moonRiseSetResidual(julianDay, longitude, latitude, originalTimeZone, zenithShift, height, -1)
|
||||
|
||||
moonAngle := StandardAltitudeMoon(zenithShift, height, latitude)
|
||||
|
||||
moonAngleTime := MoonTimeAngle(julianDay, longitude, latitude, originalTimeZone)
|
||||
|
||||
if moonHeight-moonAngle < 0 {
|
||||
if moonResidual < 0 {
|
||||
timeToMeridian = (360 - moonAngleTime) / 15
|
||||
estimatedTime += (timeToMeridian/24 + (timeToMeridian/24.0*12.0)/15.0/24.0)
|
||||
}
|
||||
|
||||
// 月亮在地平线上或在落下与下中天之间
|
||||
if moonHeight-moonAngle > 0 && moonAngleTime < 180 {
|
||||
if moonResidual > 0 && moonAngleTime < 180 {
|
||||
timeToMeridian = (-moonAngleTime) / 15
|
||||
estimatedTime += (timeToMeridian/24.0 + (timeToMeridian/24.0*12.0)/15.0/24.0)
|
||||
} else if moonHeight-moonAngle > 0 {
|
||||
} else if moonResidual > 0 {
|
||||
timeToMeridian = (360 - moonAngleTime) / 15
|
||||
estimatedTime += (timeToMeridian/24.0 + (timeToMeridian/24.0*12.0)/15.0/24.0)
|
||||
}
|
||||
@@ -273,16 +403,20 @@ func GetMoonSetTime(julianDay, longitude, latitude, timeZone, zenithShift, heigh
|
||||
}
|
||||
|
||||
// estimatedTime = 月球中天时间
|
||||
currentHeight := HMoonHeight(estimatedTime, longitude, latitude, timeZone)
|
||||
if !(currentHeight > 10 && math.Abs(latitude) < 60) {
|
||||
if currentHeight < moonAngle {
|
||||
return 0, ErrNeverRise
|
||||
currentResidual := moonRiseSetResidual(estimatedTime, longitude, latitude, timeZone, zenithShift, height, -1)
|
||||
if !(currentResidual > 10 && math.Abs(latitude) < 60) {
|
||||
if currentResidual < 0 {
|
||||
// 上中天仍在地平线下:当日无升起,也就无落下。
|
||||
return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude,
|
||||
originalTimeZone, timeZone, zenithShift, height, false, ErrNeverRise)
|
||||
}
|
||||
checkTime := estimatedTime + 12.0/24.0 + 6.0/15.0/24.0
|
||||
angleSubtraction := 180 - MoonTimeAngle(checkTime, longitude, latitude, timeZone)
|
||||
checkTime += angleSubtraction * 4.0 / 60.0 / 24.0
|
||||
if HMoonHeight(checkTime, longitude, latitude, timeZone) > moonAngle {
|
||||
return 0, ErrNeverSet
|
||||
if moonRiseSetResidual(checkTime, longitude, latitude, timeZone, zenithShift, height, -1) > 0 {
|
||||
// 下中天仍在地平线上:当日无落下。
|
||||
return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude,
|
||||
originalTimeZone, timeZone, zenithShift, height, false, ErrNeverSet)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +428,7 @@ func GetMoonSetTime(julianDay, longitude, latitude, timeZone, zenithShift, heigh
|
||||
estimatedTime += hourAngle/24 + hourAngle/33.0/15.0
|
||||
} else {
|
||||
i := 0
|
||||
for MoonHeight(estimatedTime, longitude, latitude, timeZone) > moonAngle {
|
||||
for moonRiseSetResidual(estimatedTime, longitude, latitude, timeZone, zenithShift, height, -1) > 0 {
|
||||
i++
|
||||
estimatedTime += 15.0 / 60.0 / 24.0
|
||||
if i > 48 {
|
||||
@@ -304,14 +438,10 @@ func GetMoonSetTime(julianDay, longitude, latitude, timeZone, zenithShift, heigh
|
||||
}
|
||||
|
||||
// 使用牛顿迭代法求精确解
|
||||
estimatedTime = moonRiseSetNewtonRaphsonIteration(estimatedTime, longitude, latitude, timeZone, moonAngle, HMoonHeight, 0.00002)
|
||||
|
||||
estimatedTime, slope := moonRiseSetResidualIteration(estimatedTime, longitude, latitude, timeZone, zenithShift, height, 0.00002)
|
||||
estimatedTime = estimatedTime - timeZone/24 + originalTimeZone/24
|
||||
|
||||
if estimatedTime > julianDayZero+1 || estimatedTime < julianDayZero {
|
||||
return 0, ErrNotOnThisDate
|
||||
}
|
||||
return estimatedTime, nil
|
||||
return moonRiseSetOnCivilDay(estimatedTime, slope, civilDayStart, longitude, latitude,
|
||||
originalTimeZone, timeZone, zenithShift, height, false, nil)
|
||||
}
|
||||
|
||||
// heightFunction 高度函数类型定义,用于牛顿迭代法
|
||||
@@ -324,24 +454,32 @@ func moonRiseSetNewtonRaphsonIteration(initialTime, longitude, latitude, timeZon
|
||||
|
||||
currentTime := initialTime
|
||||
|
||||
for {
|
||||
previousTime := currentTime
|
||||
|
||||
// 计算函数值:f(t) = height(t) - targetAngle
|
||||
var ok bool
|
||||
currentTime, ok = eventNewtonRefine(currentTime, tolerance, func(previousTime float64) float64 {
|
||||
functionValue := heightFunc(previousTime, longitude, latitude, timeZone) - targetAngle
|
||||
|
||||
// 计算导数:f'(t) ≈ (f(t+h) - f(t-h)) / (2h)
|
||||
derivative := (heightFunc(previousTime+derivativeStep, longitude, latitude, timeZone) -
|
||||
heightFunc(previousTime-derivativeStep, longitude, latitude, timeZone)) / (2 * derivativeStep)
|
||||
|
||||
// 牛顿-拉夫逊公式:t_new = t_old - f(t) / f'(t)
|
||||
currentTime = previousTime - functionValue/derivative
|
||||
|
||||
// 检查收敛
|
||||
if math.Abs(currentTime-previousTime) <= tolerance {
|
||||
break
|
||||
}
|
||||
return functionValue / derivative
|
||||
})
|
||||
if !ok {
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
return currentTime
|
||||
}
|
||||
|
||||
func moonRiseSetResidualIteration(initialTime, longitude, latitude, timeZone, zenithShift, height, tolerance float64) (float64, float64) {
|
||||
const derivativeStep = 0.000005
|
||||
|
||||
slope := math.NaN()
|
||||
currentTime, ok := eventNewtonRefine(initialTime, tolerance, func(previousTime float64) float64 {
|
||||
functionValue := moonRiseSetResidual(previousTime, longitude, latitude, timeZone, zenithShift, height, -1)
|
||||
slope = (moonRiseSetResidual(previousTime+derivativeStep, longitude, latitude, timeZone, zenithShift, height, -1) -
|
||||
moonRiseSetResidual(previousTime-derivativeStep, longitude, latitude, timeZone, zenithShift, height, -1)) / (2 * derivativeStep)
|
||||
return functionValue / slope
|
||||
})
|
||||
if !ok {
|
||||
return math.NaN(), math.NaN()
|
||||
}
|
||||
return currentTime, slope
|
||||
}
|
||||
|
||||
+30
-32
@@ -6,13 +6,13 @@ import (
|
||||
. "b612.me/astro/tools"
|
||||
)
|
||||
|
||||
func MoonPhase(jd float64) float64 {
|
||||
moonBo := HMoonTrueBo(jd)
|
||||
sunLo := HSunApparentLo(jd)
|
||||
moonLo := HMoonApparentLo(jd)
|
||||
func MoonPhase(jde float64) float64 {
|
||||
moonBo := HMoonTrueBo(jde)
|
||||
sunLo := HSunApparentLo(jde)
|
||||
moonLo := HMoonApparentLo(jde)
|
||||
tmp := Cos(moonBo) * Cos(sunLo-moonLo)
|
||||
earthSunDistance := Distance(jd) * 149597870.691
|
||||
i := earthSunDistance * Sin(ArcCos(tmp)) / (HMoonAway(jd) - earthSunDistance*tmp)
|
||||
earthSunDistance := Distance(jde) * 149597870.691
|
||||
i := earthSunDistance * Sin(ArcCos(tmp)) / (HMoonAway(jde) - earthSunDistance*tmp)
|
||||
i = ArcTan(i)
|
||||
if i < 0 {
|
||||
i += 180
|
||||
@@ -38,14 +38,14 @@ func SunMoonSeek(jde float64, degree float64) float64 {
|
||||
func CalcMoonSHByJDE(jde float64, phaseType int) float64 {
|
||||
phaseType = phaseType * 180
|
||||
estimateJD := jde
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
var ok bool
|
||||
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||
stDegree := SunMoonSeek(prevJD, float64(phaseType))
|
||||
stDegreep := (SunMoonSeek(prevJD+0.000005, float64(phaseType)) - SunMoonSeek(prevJD-0.000005, float64(phaseType))) / 0.00001
|
||||
estimateJD = prevJD - stDegree/stDegreep
|
||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
||||
break
|
||||
}
|
||||
return stDegree / stDegreep
|
||||
})
|
||||
if !ok {
|
||||
return math.NaN()
|
||||
}
|
||||
return estimateJD
|
||||
}
|
||||
@@ -54,14 +54,14 @@ func CalcMoonSH(year float64, phaseType int) float64 {
|
||||
jde := CalcMoonS(year, phaseType)
|
||||
phaseType = phaseType * 180
|
||||
estimateJD := jde
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
var ok bool
|
||||
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||
stDegree := SunMoonSeek(prevJD, float64(phaseType))
|
||||
stDegreep := (SunMoonSeek(prevJD+0.000005, float64(phaseType)) - SunMoonSeek(prevJD-0.000005, float64(phaseType))) / 0.00001
|
||||
estimateJD = prevJD - stDegree/stDegreep
|
||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
||||
break
|
||||
}
|
||||
return stDegree / stDegreep
|
||||
})
|
||||
if !ok {
|
||||
return math.NaN()
|
||||
}
|
||||
return estimateJD
|
||||
}
|
||||
@@ -125,14 +125,14 @@ func CalcMoonXHByJDE(jde float64, quarterType int) float64 {
|
||||
quarterType = -90
|
||||
}
|
||||
estimateJD := jde
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
var ok bool
|
||||
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||
stDegree := SunMoonSeek(prevJD, float64(quarterType))
|
||||
stDegreep := (SunMoonSeek(prevJD+0.000005, float64(quarterType)) - SunMoonSeek(prevJD-0.000005, float64(quarterType))) / 0.00001
|
||||
estimateJD = prevJD - stDegree/stDegreep
|
||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
||||
break
|
||||
}
|
||||
return stDegree / stDegreep
|
||||
})
|
||||
if !ok {
|
||||
return math.NaN()
|
||||
}
|
||||
return estimateJD
|
||||
}
|
||||
@@ -145,14 +145,14 @@ func CalcMoonXH(year float64, quarterType int) float64 {
|
||||
quarterType = -90
|
||||
}
|
||||
estimateJD := jde
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
var ok bool
|
||||
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||
stDegree := SunMoonSeek(prevJD, float64(quarterType))
|
||||
stDegreep := (SunMoonSeek(prevJD+0.000005, float64(quarterType)) - SunMoonSeek(prevJD-0.000005, float64(quarterType))) / 0.00001
|
||||
estimateJD = prevJD - stDegree/stDegreep
|
||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
||||
break
|
||||
}
|
||||
return stDegree / stDegreep
|
||||
})
|
||||
if !ok {
|
||||
return math.NaN()
|
||||
}
|
||||
return estimateJD
|
||||
}
|
||||
@@ -197,8 +197,6 @@ func CalcMoonX(year float64, quarterType int) float64 {
|
||||
A14 := 331.55 + 3.592518*k
|
||||
planetaryCorrection := 325*Sin(A1) + 165*Sin(A2) + 164*Sin(A3) + 126*Sin(A4) + 110*Sin(A5) + 62*Sin(A6) + 60*Sin(A7) + 56*Sin(A8) + 47*Sin(A9) + 42*Sin(A10) + 40*Sin(A11) + 37*Sin(A12) + 35*Sin(A13) + 23*Sin(A14)
|
||||
planetaryCorrection /= 1000000
|
||||
//die(tmp2);
|
||||
//die(JDE." ".tmp." ".tmp2." ".W);
|
||||
jde = jde + planetaryCorrection + correction
|
||||
if quarterType == 0 {
|
||||
jde += W
|
||||
|
||||
+24
-24
@@ -28,35 +28,35 @@ type MoonPhysicalInfo struct {
|
||||
}
|
||||
|
||||
// MoonPhysical 月球物理观测参数 / physical observing parameters of the Moon.
|
||||
func MoonPhysical(jd float64) MoonPhysicalInfo {
|
||||
return MoonPhysicalN(jd, -1)
|
||||
func MoonPhysical(jde float64) MoonPhysicalInfo {
|
||||
return MoonPhysicalN(jde, -1)
|
||||
}
|
||||
|
||||
// MoonPhysicalN 月球物理观测参数(截断版) / truncated physical observing parameters of the Moon.
|
||||
func MoonPhysicalN(jd float64, n int) MoonPhysicalInfo {
|
||||
return moonPhysicalNFromCoordinates(jd, n, HMoonApparentLoN(jd, n), HMoonTrueBoN(jd, n), HMoonTrueRaN(jd, n))
|
||||
func MoonPhysicalN(jde float64, n int) MoonPhysicalInfo {
|
||||
return moonPhysicalNFromCoordinates(jde, n, HMoonApparentLoN(jde, n), HMoonTrueBoN(jde, n), HMoonTrueRaN(jde, n))
|
||||
}
|
||||
|
||||
// MoonTopocentricPhysical 月球站心物理观测参数 / topocentric physical observing parameters of the Moon.
|
||||
func MoonTopocentricPhysical(jd, observerLon, observerLat, height float64) MoonPhysicalInfo {
|
||||
return MoonTopocentricPhysicalN(jd, observerLon, observerLat, height, -1)
|
||||
func MoonTopocentricPhysical(jde, observerLon, observerLat, height float64) MoonPhysicalInfo {
|
||||
return MoonTopocentricPhysicalN(jde, observerLon, observerLat, height, -1)
|
||||
}
|
||||
|
||||
// MoonTopocentricPhysicalN 月球站心物理观测参数(截断版) / truncated topocentric physical observing parameters of the Moon.
|
||||
func MoonTopocentricPhysicalN(jd, observerLon, observerLat, height float64, n int) MoonPhysicalInfo {
|
||||
lambda, beta, alpha := moonTopocentricPhysicalCoordinatesN(jd, observerLon, observerLat, height, n)
|
||||
return moonPhysicalNFromCoordinates(jd, n, lambda, beta, alpha)
|
||||
func MoonTopocentricPhysicalN(jde, observerLon, observerLat, height float64, n int) MoonPhysicalInfo {
|
||||
lambda, beta, alpha := moonTopocentricPhysicalCoordinatesN(jde, observerLon, observerLat, height, n)
|
||||
return moonPhysicalNFromCoordinates(jde, n, lambda, beta, alpha)
|
||||
}
|
||||
|
||||
func moonPhysicalNFromCoordinates(jd float64, n int, lambda, beta, alpha float64) MoonPhysicalInfo {
|
||||
t := (jd - 2451545.0) / 36525.0
|
||||
epsilon := TrueObliquity(jd)
|
||||
deltaPsi := Nutation2000Bi(jd)
|
||||
func moonPhysicalNFromCoordinates(jde float64, n int, lambda, beta, alpha float64) MoonPhysicalInfo {
|
||||
t := (jde - 2451545.0) / 36525.0
|
||||
epsilon := TrueObliquity(jde)
|
||||
deltaPsi := Nutation2000Bi(jde)
|
||||
|
||||
D := Limit360(SunMoonAngle(jd))
|
||||
sunMeanAnomaly := Limit360(SunM(jd))
|
||||
moonMeanAnomaly := Limit360(MoonM(jd))
|
||||
F := Limit360(MoonLonX(jd))
|
||||
D := Limit360(SunMoonAngle(jde))
|
||||
sunMeanAnomaly := Limit360(SunM(jde))
|
||||
moonMeanAnomaly := Limit360(MoonM(jde))
|
||||
F := Limit360(MoonLonX(jde))
|
||||
omega := moonPhysicalMeanAscendingNode(t)
|
||||
E := 1 - 0.002516*t - 0.0000074*t*t
|
||||
K1 := 119.75 + 131.849*t
|
||||
@@ -91,15 +91,15 @@ func moonPhysicalNFromCoordinates(jd float64, n int, lambda, beta, alpha float64
|
||||
}
|
||||
}
|
||||
|
||||
func moonTopocentricPhysicalCoordinatesN(jd, observerLon, observerLat, height float64, n int) (lambda, beta, alpha float64) {
|
||||
geocentricRA := HMoonTrueRaN(jd, n)
|
||||
geocentricDec := HMoonTrueDecN(jd, n)
|
||||
distanceAU := HMoonAwayN(jd, n) / moonPhysicalAstronomicalUnitKM
|
||||
utJD := TD2UT(jd, false)
|
||||
func moonTopocentricPhysicalCoordinatesN(jde, observerLon, observerLat, height float64, n int) (lambda, beta, alpha float64) {
|
||||
geocentricRA := HMoonTrueRaN(jde, n)
|
||||
geocentricDec := HMoonTrueDecN(jde, n)
|
||||
distanceAU := HMoonAwayN(jde, n) / moonPhysicalAstronomicalUnitKM
|
||||
utcJD := TT2UTC(jde)
|
||||
|
||||
var topocentricDec float64
|
||||
alpha, topocentricDec = TopocentricRaDec(geocentricRA, geocentricDec, observerLat, observerLon, utJD, distanceAU, height)
|
||||
lambda, beta = RaDecToLoBo(jd, alpha, topocentricDec)
|
||||
alpha, topocentricDec = TopocentricRaDec(geocentricRA, geocentricDec, observerLat, observerLon, utcJD, distanceAU, height)
|
||||
lambda, beta = RaDecToLoBo(jde, alpha, topocentricDec)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestMoonPhysicalSampleSweepFiniteAndInRange(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, date := range dates {
|
||||
jd := TD2UT(Date2JDE(date.UTC()), true)
|
||||
jd := UTC2TT(Date2JD(date.UTC()))
|
||||
info := MoonPhysical(jd)
|
||||
prefix := date.Format(time.RFC3339)
|
||||
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
package basic
|
||||
|
||||
import "math"
|
||||
|
||||
const (
|
||||
moonPlanetConjunctionEstimateN = 8
|
||||
moonPlanetConjunctionNearQueryDeltaDeg = 3.0
|
||||
moonPlanetConjunctionDirectionEpsilon = 0.1 / 86400.0
|
||||
moonPlanetConjunctionBracketStepDays = 0.5
|
||||
moonPlanetConjunctionNearQueryStepDays = 0.25
|
||||
moonPlanetConjunctionNearQueryHalfSpan = 1.5
|
||||
moonPlanetConjunctionBracketHalfSpan = 2.0
|
||||
moonPlanetConjunctionBracketGrowth = 2.0
|
||||
moonPlanetConjunctionBracketAttempts = 3
|
||||
moonPlanetConjunctionRefineStepDays = 0.5 / 86400.0
|
||||
moonPlanetConjunctionEventTolerance = 0.01
|
||||
moonPlanetConjunctionFallbackSpanScale = 1.5
|
||||
)
|
||||
|
||||
type moonPlanetConjunctionLocalResult struct {
|
||||
lastUT float64
|
||||
nextUT float64
|
||||
}
|
||||
|
||||
func emptyMoonPlanetConjunctionLocalResult() moonPlanetConjunctionLocalResult {
|
||||
return moonPlanetConjunctionLocalResult{
|
||||
lastUT: math.NaN(),
|
||||
nextUT: math.NaN(),
|
||||
}
|
||||
}
|
||||
|
||||
// MoonPlanetConjunctionPlanet 月球合月目标行星 / target planet for Moon-planet conjunction events.
|
||||
type MoonPlanetConjunctionPlanet int
|
||||
|
||||
const (
|
||||
MoonPlanetConjunctionMercury MoonPlanetConjunctionPlanet = iota + 1
|
||||
MoonPlanetConjunctionVenus
|
||||
MoonPlanetConjunctionMars
|
||||
MoonPlanetConjunctionJupiter
|
||||
MoonPlanetConjunctionSaturn
|
||||
MoonPlanetConjunctionUranus
|
||||
MoonPlanetConjunctionNeptune
|
||||
)
|
||||
|
||||
func moonPlanetConjunctionWrappedDelta(diff float64) float64 {
|
||||
diff = math.Mod(diff+180, 360)
|
||||
if diff < 0 {
|
||||
diff += 360
|
||||
}
|
||||
return diff - 180
|
||||
}
|
||||
|
||||
func moonPlanetConjunctionDeltaAt(jdTT float64, planet MoonPlanetConjunctionPlanet, n int) float64 {
|
||||
moonRA := HMoonGeocentricApparentRaN(jdTT, n)
|
||||
var planetRA float64
|
||||
switch planet {
|
||||
case MoonPlanetConjunctionMercury:
|
||||
planetRA = MercuryApparentRaN(jdTT, n)
|
||||
case MoonPlanetConjunctionVenus:
|
||||
planetRA = VenusApparentRaN(jdTT, n)
|
||||
case MoonPlanetConjunctionMars:
|
||||
planetRA = MarsApparentRaN(jdTT, n)
|
||||
case MoonPlanetConjunctionJupiter:
|
||||
planetRA = JupiterApparentRaN(jdTT, n)
|
||||
case MoonPlanetConjunctionSaturn:
|
||||
planetRA = SaturnApparentRaN(jdTT, n)
|
||||
case MoonPlanetConjunctionUranus:
|
||||
planetRA = UranusApparentRaN(jdTT, n)
|
||||
case MoonPlanetConjunctionNeptune:
|
||||
planetRA = NeptuneApparentRaN(jdTT, n)
|
||||
default:
|
||||
return math.NaN()
|
||||
}
|
||||
return moonPlanetConjunctionWrappedDelta(moonRA - planetRA)
|
||||
}
|
||||
|
||||
func moonPlanetConjunctionPeriodDays(planet MoonPlanetConjunctionPlanet) float64 {
|
||||
switch planet {
|
||||
case MoonPlanetConjunctionMercury:
|
||||
return 28.1
|
||||
case MoonPlanetConjunctionVenus:
|
||||
return 28.4
|
||||
case MoonPlanetConjunctionMars:
|
||||
return 29.2
|
||||
case MoonPlanetConjunctionJupiter:
|
||||
return 28.0
|
||||
case MoonPlanetConjunctionSaturn:
|
||||
return 27.4
|
||||
case MoonPlanetConjunctionUranus:
|
||||
return 27.3
|
||||
case MoonPlanetConjunctionNeptune:
|
||||
return 27.3
|
||||
default:
|
||||
return math.NaN()
|
||||
}
|
||||
}
|
||||
|
||||
func moonPlanetConjunctionBeforeOrEqual(eventUT, queryTT float64) bool {
|
||||
return eventUTQueryTTDelta(eventUT, queryTT) <= moonPlanetConjunctionDirectionEpsilon
|
||||
}
|
||||
|
||||
func moonPlanetConjunctionAfterOrEqual(eventUT, queryTT float64) bool {
|
||||
return eventUTQueryTTDelta(eventUT, queryTT) >= -moonPlanetConjunctionDirectionEpsilon
|
||||
}
|
||||
|
||||
func moonPlanetConjunctionInDirection(eventUT, queryTT float64, direction int) bool {
|
||||
switch direction {
|
||||
case -1:
|
||||
return moonPlanetConjunctionBeforeOrEqual(eventUT, queryTT)
|
||||
case 1:
|
||||
return moonPlanetConjunctionAfterOrEqual(eventUT, queryTT)
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func moonPlanetConjunctionFindBracket(centerTT, halfSpan, step float64, planet MoonPlanetConjunctionPlanet) (float64, float64, bool) {
|
||||
if math.IsNaN(centerTT) || math.IsNaN(halfSpan) || math.IsNaN(step) || halfSpan <= 0 || step <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
start := centerTT - halfSpan
|
||||
end := centerTT + halfSpan
|
||||
samples := int(math.Ceil((end-start)/step)) + 1
|
||||
prevTT := start
|
||||
prevVal := moonPlanetConjunctionDeltaAt(prevTT, planet, -1)
|
||||
if math.IsNaN(prevVal) {
|
||||
return 0, 0, false
|
||||
}
|
||||
if prevVal == 0 {
|
||||
return prevTT, prevTT, true
|
||||
}
|
||||
bestLeft := 0.0
|
||||
bestRight := 0.0
|
||||
bestDistance := math.Inf(1)
|
||||
for i := 1; i <= samples; i++ {
|
||||
tt := start + float64(i)*step
|
||||
if tt > end {
|
||||
tt = end
|
||||
}
|
||||
val := moonPlanetConjunctionDeltaAt(tt, planet, -1)
|
||||
if math.IsNaN(val) {
|
||||
return 0, 0, false
|
||||
}
|
||||
if val == 0 {
|
||||
return tt, tt, true
|
||||
}
|
||||
if prevVal*val < 0 {
|
||||
mid := (prevTT + tt) / 2.0
|
||||
distance := math.Abs(mid - centerTT)
|
||||
if distance < bestDistance {
|
||||
bestLeft = prevTT
|
||||
bestRight = tt
|
||||
bestDistance = distance
|
||||
}
|
||||
}
|
||||
if tt == end {
|
||||
break
|
||||
}
|
||||
prevTT = tt
|
||||
prevVal = val
|
||||
}
|
||||
if math.IsInf(bestDistance, 1) {
|
||||
return 0, 0, false
|
||||
}
|
||||
return bestLeft, bestRight, true
|
||||
}
|
||||
|
||||
func moonPlanetConjunctionRefineBracket(leftTT, rightTT float64, planet MoonPlanetConjunctionPlanet) float64 {
|
||||
if leftTT > rightTT {
|
||||
leftTT, rightTT = rightTT, leftTT
|
||||
}
|
||||
if leftTT == rightTT {
|
||||
return leftTT
|
||||
}
|
||||
center := (leftTT + rightTT) / 2.0
|
||||
halfWindow := (rightTT - leftTT) / 2.0
|
||||
return eventZeroRefine(center, halfWindow, moonPlanetConjunctionRefineStepDays, func(sampleTT float64) float64 {
|
||||
return moonPlanetConjunctionDeltaAt(sampleTT, planet, -1)
|
||||
})
|
||||
}
|
||||
|
||||
func moonPlanetConjunctionEventUT(leftTT, rightTT float64, planet MoonPlanetConjunctionPlanet) float64 {
|
||||
eventTT := moonPlanetConjunctionRefineBracket(leftTT, rightTT, planet)
|
||||
if math.Abs(moonPlanetConjunctionDeltaAt(eventTT, planet, -1)) > moonPlanetConjunctionEventTolerance {
|
||||
return math.NaN()
|
||||
}
|
||||
return TT2UTC(eventTT)
|
||||
}
|
||||
|
||||
func moonPlanetConjunctionCollectLocalEvent(result *moonPlanetConjunctionLocalResult, queryTT, eventUT float64) {
|
||||
if math.IsNaN(eventUT) {
|
||||
return
|
||||
}
|
||||
if moonPlanetConjunctionBeforeOrEqual(eventUT, queryTT) {
|
||||
if math.IsNaN(result.lastUT) || math.Abs(eventUTQueryTTDelta(eventUT, queryTT)) < math.Abs(eventUTQueryTTDelta(result.lastUT, queryTT)) {
|
||||
result.lastUT = eventUT
|
||||
}
|
||||
}
|
||||
if moonPlanetConjunctionAfterOrEqual(eventUT, queryTT) {
|
||||
if math.IsNaN(result.nextUT) || math.Abs(eventUTQueryTTDelta(eventUT, queryTT)) < math.Abs(eventUTQueryTTDelta(result.nextUT, queryTT)) {
|
||||
result.nextUT = eventUT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func moonPlanetConjunctionShouldCheckLocal(queryTT float64, planet MoonPlanetConjunctionPlanet) bool {
|
||||
delta := moonPlanetConjunctionDeltaAt(queryTT, planet, moonPlanetConjunctionEstimateN)
|
||||
if math.IsNaN(delta) {
|
||||
return false
|
||||
}
|
||||
return math.Abs(delta) <= moonPlanetConjunctionNearQueryDeltaDeg
|
||||
}
|
||||
|
||||
func moonPlanetConjunctionLocalEvents(queryTT float64, planet MoonPlanetConjunctionPlanet) moonPlanetConjunctionLocalResult {
|
||||
result := emptyMoonPlanetConjunctionLocalResult()
|
||||
start := queryTT - moonPlanetConjunctionNearQueryHalfSpan
|
||||
end := queryTT + moonPlanetConjunctionNearQueryHalfSpan
|
||||
step := moonPlanetConjunctionNearQueryStepDays
|
||||
prevTT := start
|
||||
prevVal := moonPlanetConjunctionDeltaAt(prevTT, planet, -1)
|
||||
if math.IsNaN(prevVal) {
|
||||
return result
|
||||
}
|
||||
samples := int(math.Ceil((end-start)/step)) + 1
|
||||
for i := 1; i <= samples; i++ {
|
||||
tt := start + float64(i)*step
|
||||
if tt > end {
|
||||
tt = end
|
||||
}
|
||||
val := moonPlanetConjunctionDeltaAt(tt, planet, -1)
|
||||
if math.IsNaN(val) {
|
||||
return emptyMoonPlanetConjunctionLocalResult()
|
||||
}
|
||||
if prevVal == 0 || val == 0 || prevVal*val < 0 {
|
||||
moonPlanetConjunctionCollectLocalEvent(&result, queryTT, moonPlanetConjunctionEventUT(prevTT, tt, planet))
|
||||
}
|
||||
if tt == end {
|
||||
break
|
||||
}
|
||||
prevTT = tt
|
||||
prevVal = val
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func moonPlanetConjunctionMaybeLocalEvents(queryTT float64, planet MoonPlanetConjunctionPlanet) moonPlanetConjunctionLocalResult {
|
||||
if !moonPlanetConjunctionShouldCheckLocal(queryTT, planet) {
|
||||
return emptyMoonPlanetConjunctionLocalResult()
|
||||
}
|
||||
return moonPlanetConjunctionLocalEvents(queryTT, planet)
|
||||
}
|
||||
|
||||
func moonPlanetConjunctionGuessTT(queryTT float64, planet MoonPlanetConjunctionPlanet, direction int) float64 {
|
||||
delta := moonPlanetConjunctionDeltaAt(queryTT, planet, moonPlanetConjunctionEstimateN)
|
||||
if math.IsNaN(delta) {
|
||||
return math.NaN()
|
||||
}
|
||||
period := moonPlanetConjunctionPeriodDays(planet)
|
||||
if math.IsNaN(period) {
|
||||
return math.NaN()
|
||||
}
|
||||
switch direction {
|
||||
case -1:
|
||||
return queryTT - innerLastCycleOffset(delta, period)
|
||||
case 1:
|
||||
return queryTT + innerNextCycleOffset(delta, period)
|
||||
default:
|
||||
return math.NaN()
|
||||
}
|
||||
}
|
||||
|
||||
func moonPlanetConjunctionDirectionalFallback(queryTT float64, planet MoonPlanetConjunctionPlanet, direction int) float64 {
|
||||
period := moonPlanetConjunctionPeriodDays(planet)
|
||||
if math.IsNaN(period) {
|
||||
return math.NaN()
|
||||
}
|
||||
span := period * moonPlanetConjunctionFallbackSpanScale
|
||||
if span <= 0 {
|
||||
return math.NaN()
|
||||
}
|
||||
step := moonPlanetConjunctionNearQueryStepDays
|
||||
start := queryTT
|
||||
end := queryTT
|
||||
switch direction {
|
||||
case -1:
|
||||
start -= span
|
||||
case 1:
|
||||
end += span
|
||||
default:
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
prevTT := start
|
||||
prevVal := moonPlanetConjunctionDeltaAt(prevTT, planet, -1)
|
||||
if math.IsNaN(prevVal) {
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
bestEventUT := math.NaN()
|
||||
for tt := start + step; ; tt += step {
|
||||
if tt > end {
|
||||
tt = end
|
||||
}
|
||||
val := moonPlanetConjunctionDeltaAt(tt, planet, -1)
|
||||
if math.IsNaN(val) {
|
||||
return math.NaN()
|
||||
}
|
||||
if prevVal == 0 || val == 0 || prevVal*val < 0 {
|
||||
eventUT := moonPlanetConjunctionEventUT(prevTT, tt, planet)
|
||||
if !math.IsNaN(eventUT) && moonPlanetConjunctionInDirection(eventUT, queryTT, direction) {
|
||||
if direction == 1 {
|
||||
return eventUT
|
||||
}
|
||||
bestEventUT = eventUT
|
||||
}
|
||||
}
|
||||
if tt == end {
|
||||
break
|
||||
}
|
||||
prevTT = tt
|
||||
prevVal = val
|
||||
}
|
||||
return bestEventUT
|
||||
}
|
||||
|
||||
func moonPlanetConjunctionDirectionalEventWithLocal(queryTT float64, planet MoonPlanetConjunctionPlanet, direction int, local moonPlanetConjunctionLocalResult) float64 {
|
||||
switch direction {
|
||||
case -1:
|
||||
if !math.IsNaN(local.lastUT) {
|
||||
return local.lastUT
|
||||
}
|
||||
case 1:
|
||||
if !math.IsNaN(local.nextUT) {
|
||||
return local.nextUT
|
||||
}
|
||||
}
|
||||
guessTT := moonPlanetConjunctionGuessTT(queryTT, planet, direction)
|
||||
if math.IsNaN(guessTT) {
|
||||
return math.NaN()
|
||||
}
|
||||
halfSpan := moonPlanetConjunctionBracketHalfSpan
|
||||
for attempt := 0; attempt < moonPlanetConjunctionBracketAttempts; attempt++ {
|
||||
left, right, ok := moonPlanetConjunctionFindBracket(guessTT, halfSpan, moonPlanetConjunctionBracketStepDays, planet)
|
||||
if ok {
|
||||
eventUT := moonPlanetConjunctionEventUT(left, right, planet)
|
||||
if math.IsNaN(eventUT) {
|
||||
halfSpan *= moonPlanetConjunctionBracketGrowth
|
||||
continue
|
||||
}
|
||||
if moonPlanetConjunctionInDirection(eventUT, queryTT, direction) {
|
||||
return eventUT
|
||||
}
|
||||
}
|
||||
halfSpan *= moonPlanetConjunctionBracketGrowth
|
||||
}
|
||||
return moonPlanetConjunctionDirectionalFallback(queryTT, planet, direction)
|
||||
}
|
||||
|
||||
func moonPlanetConjunctionDirectionalEvent(queryTT float64, planet MoonPlanetConjunctionPlanet, direction int) float64 {
|
||||
return moonPlanetConjunctionDirectionalEventWithLocal(queryTT, planet, direction, moonPlanetConjunctionMaybeLocalEvents(queryTT, planet))
|
||||
}
|
||||
|
||||
// LastMoonPlanetConjunction 指定时刻之前最近一次行星合月(赤经合) / previous Moon-planet conjunction at or before jde.
|
||||
func LastMoonPlanetConjunction(jde float64, planet MoonPlanetConjunctionPlanet) float64 {
|
||||
return moonPlanetConjunctionDirectionalEvent(jde, planet, -1)
|
||||
}
|
||||
|
||||
// NextMoonPlanetConjunction 指定时刻之后最近一次行星合月(赤经合) / next Moon-planet conjunction at or after jde.
|
||||
func NextMoonPlanetConjunction(jde float64, planet MoonPlanetConjunctionPlanet) float64 {
|
||||
return moonPlanetConjunctionDirectionalEvent(jde, planet, 1)
|
||||
}
|
||||
|
||||
// ClosestMoonPlanetConjunction 离指定时刻最近一次行星合月(赤经合) / closest Moon-planet conjunction to jde.
|
||||
func ClosestMoonPlanetConjunction(jde float64, planet MoonPlanetConjunctionPlanet) float64 {
|
||||
local := moonPlanetConjunctionMaybeLocalEvents(jde, planet)
|
||||
if !math.IsNaN(local.lastUT) && !math.IsNaN(local.nextUT) {
|
||||
if sameEventJD(local.lastUT, local.nextUT) {
|
||||
return local.lastUT
|
||||
}
|
||||
return closestEventUTToQueryTT(jde, local.lastUT, local.nextUT)
|
||||
}
|
||||
if !math.IsNaN(local.lastUT) {
|
||||
return local.lastUT
|
||||
}
|
||||
if !math.IsNaN(local.nextUT) {
|
||||
return local.nextUT
|
||||
}
|
||||
last := moonPlanetConjunctionDirectionalEventWithLocal(jde, planet, -1, local)
|
||||
next := moonPlanetConjunctionDirectionalEventWithLocal(jde, planet, 1, local)
|
||||
if math.IsNaN(last) {
|
||||
return next
|
||||
}
|
||||
if math.IsNaN(next) {
|
||||
return last
|
||||
}
|
||||
return closestEventUTToQueryTT(jde, last, next)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type moonPlanetConjunctionBaselineSample struct {
|
||||
Planet string `json:"planet"`
|
||||
Year int `json:"year"`
|
||||
Month int `json:"month"`
|
||||
TimeUTC string `json:"time_utc"`
|
||||
}
|
||||
|
||||
type moonPlanetConjunctionBaseline struct {
|
||||
Samples []moonPlanetConjunctionBaselineSample `json:"samples"`
|
||||
}
|
||||
|
||||
func loadMoonPlanetConjunctionBaseline(t *testing.T) moonPlanetConjunctionBaseline {
|
||||
t.Helper()
|
||||
|
||||
paths := [][]string{
|
||||
{
|
||||
"testdata/moon_planet_conjunction_baseline.json",
|
||||
"basic/testdata/moon_planet_conjunction_baseline.json",
|
||||
},
|
||||
{
|
||||
"testdata/moon_planet_conjunction_baseline_samples.json",
|
||||
"basic/testdata/moon_planet_conjunction_baseline_samples.json",
|
||||
},
|
||||
}
|
||||
|
||||
var merged moonPlanetConjunctionBaseline
|
||||
for index, candidates := range paths {
|
||||
var (
|
||||
data []byte
|
||||
err error
|
||||
)
|
||||
for _, path := range candidates {
|
||||
data, err = os.ReadFile(path)
|
||||
if err == nil {
|
||||
var baseline moonPlanetConjunctionBaseline
|
||||
if err := json.Unmarshal(data, &baseline); err != nil {
|
||||
t.Fatalf("decode baseline %s: %v", path, err)
|
||||
}
|
||||
merged.Samples = append(merged.Samples, baseline.Samples...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil && index == 0 {
|
||||
t.Fatalf("read baseline: %v", err)
|
||||
}
|
||||
}
|
||||
if len(merged.Samples) == 0 {
|
||||
t.Fatal("empty moon-planet conjunction baseline")
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func TestMoonPlanetConjunctionsMatchHorizonsBaseline(t *testing.T) {
|
||||
baseline := loadMoonPlanetConjunctionBaseline(t)
|
||||
|
||||
type conjunctionCase struct {
|
||||
planet MoonPlanetConjunctionPlanet
|
||||
next func(float64, MoonPlanetConjunctionPlanet) float64
|
||||
}
|
||||
|
||||
cases := map[string]conjunctionCase{
|
||||
"mercury": {planet: MoonPlanetConjunctionMercury, next: NextMoonPlanetConjunction},
|
||||
"venus": {planet: MoonPlanetConjunctionVenus, next: NextMoonPlanetConjunction},
|
||||
"mars": {planet: MoonPlanetConjunctionMars, next: NextMoonPlanetConjunction},
|
||||
"jupiter": {planet: MoonPlanetConjunctionJupiter, next: NextMoonPlanetConjunction},
|
||||
"saturn": {planet: MoonPlanetConjunctionSaturn, next: NextMoonPlanetConjunction},
|
||||
"uranus": {planet: MoonPlanetConjunctionUranus, next: NextMoonPlanetConjunction},
|
||||
"neptune": {planet: MoonPlanetConjunctionNeptune, next: NextMoonPlanetConjunction},
|
||||
}
|
||||
|
||||
const tolerance = 20 * time.Second
|
||||
var maxDiff time.Duration
|
||||
|
||||
seen := make(map[string]int, len(cases))
|
||||
for _, sample := range baseline.Samples {
|
||||
tc, ok := cases[sample.Planet]
|
||||
if !ok {
|
||||
t.Fatalf("unknown planet %q", sample.Planet)
|
||||
}
|
||||
|
||||
wantTime, err := time.Parse(time.RFC3339Nano, sample.TimeUTC)
|
||||
if err != nil {
|
||||
t.Fatalf("parse sample time %q: %v", sample.TimeUTC, err)
|
||||
}
|
||||
queryTT := UTC2TT(Date2JD(wantTime.Add(-12 * time.Hour).UTC()))
|
||||
gotUT := tc.next(queryTT, tc.planet)
|
||||
gotTime := JD2DateByZone(gotUT, time.UTC, false)
|
||||
diff := gotTime.Sub(wantTime)
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > maxDiff {
|
||||
maxDiff = diff
|
||||
}
|
||||
if diff > tolerance {
|
||||
t.Fatalf("%s %04d-%02d time mismatch: got %s want %s tolerance %v", sample.Planet, sample.Year, sample.Month, gotTime.Format(time.RFC3339Nano), sample.TimeUTC, tolerance)
|
||||
}
|
||||
|
||||
delta := math.Abs(moonPlanetConjunctionDeltaAt(UTC2TT(gotUT), tc.planet, -1))
|
||||
if delta > 0.01 {
|
||||
t.Fatalf("%s %04d-%02d event not near conjunction: delta=%.8f deg", sample.Planet, sample.Year, sample.Month, delta)
|
||||
}
|
||||
seen[sample.Planet]++
|
||||
}
|
||||
|
||||
for planet := range cases {
|
||||
if seen[planet] == 0 {
|
||||
t.Fatalf("missing baseline samples for %s", planet)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("moon-planet conjunction max diff: time=%v", maxDiff)
|
||||
}
|
||||
|
||||
func TestMoonPlanetConjunctionDirectionalConsistencyAtComputedEvent(t *testing.T) {
|
||||
baseline := loadMoonPlanetConjunctionBaseline(t)
|
||||
|
||||
planets := map[string]MoonPlanetConjunctionPlanet{
|
||||
"mercury": MoonPlanetConjunctionMercury,
|
||||
"venus": MoonPlanetConjunctionVenus,
|
||||
"mars": MoonPlanetConjunctionMars,
|
||||
"jupiter": MoonPlanetConjunctionJupiter,
|
||||
"saturn": MoonPlanetConjunctionSaturn,
|
||||
"uranus": MoonPlanetConjunctionUranus,
|
||||
"neptune": MoonPlanetConjunctionNeptune,
|
||||
}
|
||||
|
||||
for _, sample := range baseline.Samples {
|
||||
planet, ok := planets[sample.Planet]
|
||||
if !ok {
|
||||
t.Fatalf("unknown planet %q", sample.Planet)
|
||||
}
|
||||
wantTime, err := time.Parse(time.RFC3339Nano, sample.TimeUTC)
|
||||
if err != nil {
|
||||
t.Fatalf("parse sample time %q: %v", sample.TimeUTC, err)
|
||||
}
|
||||
seedTT := UTC2TT(Date2JD(wantTime.Add(-12 * time.Hour).UTC()))
|
||||
eventUT := NextMoonPlanetConjunction(seedTT, planet)
|
||||
eventTime := JD2DateByZone(eventUT, time.UTC, false)
|
||||
queryAtTT := UTC2TT(Date2JD(eventTime.UTC()))
|
||||
queryAfterTT := UTC2TT(Date2JD(eventTime.Add(time.Hour).UTC()))
|
||||
|
||||
exactNext := NextMoonPlanetConjunction(queryAtTT, planet)
|
||||
exactClosest := ClosestMoonPlanetConjunction(queryAtTT, planet)
|
||||
exactLastAfter := LastMoonPlanetConjunction(queryAfterTT, planet)
|
||||
|
||||
for name, gotUT := range map[string]float64{
|
||||
"exactNext": exactNext,
|
||||
"exactClosest": exactClosest,
|
||||
"lastAfterEvent": exactLastAfter,
|
||||
} {
|
||||
gotTime := JD2DateByZone(gotUT, time.UTC, false)
|
||||
if diff := math.Abs(gotUT - eventUT); diff > 1e-9 {
|
||||
t.Fatalf("%s %s mismatch: got %s want %s diff=%v", sample.Planet, name, gotTime.Format(time.RFC3339Nano), eventTime.Format(time.RFC3339Nano), diff*86400)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoonPlanetConjunctionRejectsOppositionBranchJump(t *testing.T) {
|
||||
query := time.Date(1900, 11, 10, 12, 0, 0, 0, time.UTC)
|
||||
queryTT := UTC2TT(Date2JD(query))
|
||||
|
||||
lastUT := LastMoonPlanetConjunction(queryTT, MoonPlanetConjunctionSaturn)
|
||||
nextUT := NextMoonPlanetConjunction(queryTT, MoonPlanetConjunctionSaturn)
|
||||
|
||||
if math.Abs(lastUT-Date2JD(query)) <= 5.0/86400.0 {
|
||||
t.Fatalf("last returned query time on branch jump: got %s", JD2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano))
|
||||
}
|
||||
if math.Abs(nextUT-Date2JD(query)) <= 5.0/86400.0 {
|
||||
t.Fatalf("next returned query time on branch jump: got %s", JD2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano))
|
||||
}
|
||||
|
||||
for name, gotUT := range map[string]float64{
|
||||
"last": lastUT,
|
||||
"next": nextUT,
|
||||
} {
|
||||
delta := math.Abs(moonPlanetConjunctionDeltaAt(UTC2TT(gotUT), MoonPlanetConjunctionSaturn, -1))
|
||||
if delta > moonPlanetConjunctionEventTolerance {
|
||||
t.Fatalf("%s returned non-event candidate: delta=%.8f event=%s", name, delta, JD2DateByZone(gotUT, time.UTC, false).Format(time.RFC3339Nano))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoonPlanetConjunctionDirectionalOrderingOnSampleQueries(t *testing.T) {
|
||||
samples := []struct {
|
||||
planet MoonPlanetConjunctionPlanet
|
||||
query time.Time
|
||||
}{
|
||||
{planet: MoonPlanetConjunctionSaturn, query: time.Date(1700, 4, 15, 12, 0, 0, 0, time.UTC)},
|
||||
{planet: MoonPlanetConjunctionMercury, query: time.Date(1900, 1, 14, 12, 0, 0, 0, time.UTC)},
|
||||
{planet: MoonPlanetConjunctionVenus, query: time.Date(1950, 6, 3, 12, 0, 0, 0, time.UTC)},
|
||||
{planet: MoonPlanetConjunctionMars, query: time.Date(2000, 2, 29, 18, 0, 0, 0, time.UTC)},
|
||||
{planet: MoonPlanetConjunctionJupiter, query: time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC)},
|
||||
{planet: MoonPlanetConjunctionSaturn, query: time.Date(2100, 8, 17, 6, 0, 0, 0, time.UTC)},
|
||||
{planet: MoonPlanetConjunctionUranus, query: time.Date(2200, 11, 2, 9, 0, 0, 0, time.UTC)},
|
||||
{planet: MoonPlanetConjunctionNeptune, query: time.Date(2300, 4, 24, 3, 0, 0, 0, time.UTC)},
|
||||
}
|
||||
|
||||
for _, sample := range samples {
|
||||
queryTT := UTC2TT(Date2JD(sample.query.UTC()))
|
||||
lastUT := LastMoonPlanetConjunction(queryTT, sample.planet)
|
||||
nextUT := NextMoonPlanetConjunction(queryTT, sample.planet)
|
||||
closestUT := ClosestMoonPlanetConjunction(queryTT, sample.planet)
|
||||
|
||||
if math.IsNaN(lastUT) || math.IsNaN(nextUT) || math.IsNaN(closestUT) {
|
||||
t.Fatalf("planet=%v query=%s returned NaN event(s): last=%v next=%v closest=%v", sample.planet, sample.query.Format(time.RFC3339), lastUT, nextUT, closestUT)
|
||||
}
|
||||
if !eventUTQueryBeforeOrEqual(lastUT, queryTT) {
|
||||
t.Fatalf("planet=%v last after query: last=%s query=%s", sample.planet, JD2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano), sample.query.Format(time.RFC3339Nano))
|
||||
}
|
||||
if !eventUTQueryAfterOrEqual(nextUT, queryTT) {
|
||||
t.Fatalf("planet=%v next before query: next=%s query=%s", sample.planet, JD2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano), sample.query.Format(time.RFC3339Nano))
|
||||
}
|
||||
if closestUT != closestEventUTToQueryTT(queryTT, lastUT, nextUT) {
|
||||
t.Fatalf("planet=%v closest mismatch: got=%s want=%s", sample.planet, JD2DateByZone(closestUT, time.UTC, false).Format(time.RFC3339Nano), JD2DateByZone(closestEventUTToQueryTT(queryTT, lastUT, nextUT), time.UTC, false).Format(time.RFC3339Nano))
|
||||
}
|
||||
for name, gotUT := range map[string]float64{
|
||||
"last": lastUT,
|
||||
"next": nextUT,
|
||||
"closest": closestUT,
|
||||
} {
|
||||
delta := math.Abs(moonPlanetConjunctionDeltaAt(UTC2TT(gotUT), sample.planet, -1))
|
||||
if delta > moonPlanetConjunctionEventTolerance {
|
||||
t.Fatalf("planet=%v %s returned non-event candidate: delta=%.8f event=%s", sample.planet, name, delta, JD2DateByZone(gotUT, time.UTC, false).Format(time.RFC3339Nano))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoonPlanetConjunctionKeepsImmediateNeighborEvents(t *testing.T) {
|
||||
query := time.Date(1700, 4, 15, 12, 0, 0, 0, time.UTC)
|
||||
queryTT := UTC2TT(Date2JD(query.UTC()))
|
||||
|
||||
lastUT := LastMoonPlanetConjunction(queryTT, MoonPlanetConjunctionSaturn)
|
||||
nextUT := NextMoonPlanetConjunction(queryTT, MoonPlanetConjunctionSaturn)
|
||||
closestUT := ClosestMoonPlanetConjunction(queryTT, MoonPlanetConjunctionSaturn)
|
||||
|
||||
wantLast := time.Date(1700, 4, 15, 11, 55, 59, 115569293, time.UTC)
|
||||
wantNext := time.Date(1700, 5, 13, 0, 35, 5, 981616675, time.UTC)
|
||||
const tolerance = 5.0 / 86400.0
|
||||
|
||||
if diff := math.Abs(lastUT - Date2JD(wantLast)); diff > tolerance {
|
||||
t.Fatalf("last mismatch: got=%s want=%s diff=%.3fs", JD2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano), wantLast.Format(time.RFC3339Nano), diff*86400)
|
||||
}
|
||||
if diff := math.Abs(nextUT - Date2JD(wantNext)); diff > tolerance {
|
||||
t.Fatalf("next mismatch: got=%s want=%s diff=%.3fs", JD2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano), wantNext.Format(time.RFC3339Nano), diff*86400)
|
||||
}
|
||||
if !sameEventJD(closestUT, lastUT) {
|
||||
t.Fatalf("closest should keep immediate previous event: closest=%s last=%s", JD2DateByZone(closestUT, time.UTC, false).Format(time.RFC3339Nano), JD2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoonPlanetConjunctionNextAdvancesPastReturnedEvent(t *testing.T) {
|
||||
seed := UTC2TT(Date2JD(time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)))
|
||||
eventUT := NextMoonPlanetConjunction(seed, MoonPlanetConjunctionMercury)
|
||||
query := JD2DateByZone(eventUT, time.UTC, false).Add(time.Second)
|
||||
queryTT := UTC2TT(Date2JD(query.UTC()))
|
||||
|
||||
nextUT := NextMoonPlanetConjunction(queryTT, MoonPlanetConjunctionMercury)
|
||||
if eventUTQueryTTDelta(nextUT, queryTT) <= 0 {
|
||||
t.Fatalf("expected next conjunction after query: query=%s next=%s delta=%.6fs",
|
||||
query.Format(time.RFC3339Nano),
|
||||
JD2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano),
|
||||
eventUTQueryTTDelta(nextUT, queryTT)*86400,
|
||||
)
|
||||
}
|
||||
if sameEventJD(nextUT, eventUT) {
|
||||
t.Fatalf("next conjunction should advance to a later event: event=%s next=%s",
|
||||
JD2DateByZone(eventUT, time.UTC, false).Format(time.RFC3339Nano),
|
||||
JD2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano),
|
||||
)
|
||||
}
|
||||
}
|
||||
+179
-74
File diff suppressed because one or more lines are too long
@@ -0,0 +1,81 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMoonSeriesNumericalPrecision(t *testing.T) {
|
||||
// The existing binary64 coefficients evaluated at 70 decimal digits.
|
||||
// These check arithmetic precision, not the lunar theory's physical error.
|
||||
for _, sample := range []struct{ jd, longitude, latitude, distance, velocity float64 }{
|
||||
{990647.125, 48.68531043735905108, -3.208940888099671943, 365328.8143785083007, 14.53542662716284001},
|
||||
{1730647.9333686847, 346.3227699608293767, -2.151500042672265917, 368450.7410464923284, 14.30553896629878483},
|
||||
{2451545.0, 223.3189003731153751, 5.170885995272171723, 402448.7431750887777, 12.02142669184144213},
|
||||
{2460770.123456789, 99.20516643712787636, 5.156920731462777075, 378303.2056716878878, 13.57356557811076973},
|
||||
{2817000.99999, 256.0031142427467021, 3.438015761341575242, 402219.0739759512533, 11.99285384469081927},
|
||||
{3191874.3956256355, 178.1121054545695063, 1.37109140818496243, 379219.9689425243825, 13.41568168416747773},
|
||||
{3547272.75, 167.6849402044916958, 5.088592207858965847, 374590.3971822900352, 13.85120063917007945},
|
||||
} {
|
||||
longitude := HMoonTrueLo(sample.jd)
|
||||
latitude := HMoonTrueBo(sample.jd)
|
||||
distance := HMoonAway(sample.jd)
|
||||
if error := math.Abs(math.Remainder(longitude-sample.longitude, 360)); error > 2e-11 {
|
||||
t.Errorf("jd=%.12f longitude error=%g degrees", sample.jd, error)
|
||||
}
|
||||
if error := math.Abs(latitude - sample.latitude); error > 2e-11 {
|
||||
t.Errorf("jd=%.12f latitude error=%g degrees", sample.jd, error)
|
||||
}
|
||||
if error := math.Abs(distance - sample.distance); error > 1e-5 {
|
||||
t.Errorf("jd=%.12f distance error=%g km", sample.jd, error)
|
||||
}
|
||||
const step = 5.0 / 86400
|
||||
before, after := sample.jd-step, sample.jd+step
|
||||
velocity := math.Remainder(HMoonTrueLo(after)-HMoonTrueLo(before), 360) / (after - before)
|
||||
if error := math.Abs(velocity - sample.velocity); error > 2e-7 {
|
||||
t.Errorf("jd=%.12f velocity error=%g degrees/day", sample.jd, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationContactDerivativeNumericalStability(t *testing.T) {
|
||||
config, _ := planetOccultationConfigFor(OccultationMercury)
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
const tt = 1730647.933368684724
|
||||
const longitude = 90.88806942770216
|
||||
const latitude = 29.603106445560446
|
||||
evaluations := newOccultationRiseSetEvaluationCache(cache.riseSetContextAt)
|
||||
derivative := func(jd float64) float64 {
|
||||
return evaluations.evaluation(jd).contactDerivative(longitude, latitude)
|
||||
}
|
||||
center := derivative(tt)
|
||||
const trendStep = 0.5 / 86400
|
||||
trend := (derivative(tt+trendStep) - derivative(tt-trendStep)) / (2 * trendStep)
|
||||
ulp := math.Nextafter(tt, math.Inf(1)) - tt
|
||||
for i := -16; i <= 16; i++ {
|
||||
offset := float64(i) * ulp
|
||||
jitter := derivative(tt+offset) - center - trend*offset
|
||||
if math.Abs(jitter) > occultationRiseSetJunctionDerivativeTolerance {
|
||||
t.Fatalf("offset=%g seconds: derivative jitter=%g degrees/day", offset*86400, jitter)
|
||||
}
|
||||
}
|
||||
cache.preparePathEphemeris(tt, OccultationPathAlgorithmOptimized)
|
||||
if cache.local == nil || !cache.local.dense {
|
||||
t.Fatal("smooth exact states must support the checked dense ephemeris")
|
||||
}
|
||||
}
|
||||
|
||||
// MoonCalcNew 与 HMoonTrueLo 是同一个物理量的两个入口,必须给出同一个值(此前只有一个入口
|
||||
// 走线性相位补偿,两者差约 1e-9 度)。
|
||||
func TestMoonCalcNewMatchesHMoonTrueLo(t *testing.T) {
|
||||
for _, jd := range []float64{2451545.0, 2460310.5, 2415020.5, 2299160.5, 2500000.5} {
|
||||
direct := math.Mod(MoonCalcNew(0, jd)*180/math.Pi, 360)
|
||||
if direct < 0 {
|
||||
direct += 360
|
||||
}
|
||||
high := HMoonTrueLo(jd)
|
||||
if diff := math.Abs(direct - high); diff > 1e-12 {
|
||||
t.Errorf("jd %.1f: MoonCalcNew=%0.15f HMoonTrueLo=%0.15f diff=%.3g deg", jd, direct, high, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件钉住月出/月落「缺失事件」的口径:错误名描述缺失的那个现象,不描述被问的事件。
|
||||
|
||||
func moonRiseSetErrorMatches(got, want error) bool {
|
||||
if want == nil {
|
||||
return got == nil
|
||||
}
|
||||
return errors.Is(got, want)
|
||||
}
|
||||
|
||||
func moonRiseSetDailyGeometry(jd, lon, lat, tz float64) (aboveAll, belowAll bool) {
|
||||
dayStart := math.Floor(jd) + 0.5
|
||||
aboveAll, belowAll = true, true
|
||||
previous := moonRiseSetResidual(dayStart, lon, lat, tz, 1, 0, -1)
|
||||
for i := 1; i <= 288; i++ {
|
||||
current := moonRiseSetResidual(dayStart+float64(i)/288.0, lon, lat, tz, 1, 0, -1)
|
||||
if previous <= 0 || current <= 0 {
|
||||
aboveAll = false
|
||||
}
|
||||
if previous >= 0 || current >= 0 {
|
||||
belowAll = false
|
||||
}
|
||||
previous = current
|
||||
}
|
||||
return aboveAll, belowAll
|
||||
}
|
||||
|
||||
func TestMoonRiseSetMissingEventConvention(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
jd float64
|
||||
lon, lat, tz float64
|
||||
riseErr, setErr error
|
||||
}{
|
||||
{"极昼:全天在地平线上", JDCalc(2023, 6, 21), 0, 85, 0, ErrNeverSet, ErrNeverSet},
|
||||
{"极夜:全天在地平线下", JDCalc(2023, 12, 22), 0, -85, 0, ErrNeverRise, ErrNeverRise},
|
||||
{"当日无升起但别日有", JDCalc(2024, 2, 29), 0, 60, 0, ErrNotOnThisDate, nil},
|
||||
{"正常日两侧都有", JDCalc(2025, 6, 5), 116.4074, 39.9042, 8, nil, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := GetMoonRiseTime(tc.jd, tc.lon, tc.lat, tc.tz, 1, 0); !moonRiseSetErrorMatches(err, tc.riseErr) {
|
||||
t.Fatalf("GetMoonRiseTime error = %v, want %v", err, tc.riseErr)
|
||||
}
|
||||
if _, err := GetMoonSetTime(tc.jd, tc.lon, tc.lat, tc.tz, 1, 0); !moonRiseSetErrorMatches(err, tc.setErr) {
|
||||
t.Fatalf("GetMoonSetTime error = %v, want %v", err, tc.setErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoonRiseSetMissingEventMatchesDailyGeometry(t *testing.T) {
|
||||
dates := []float64{
|
||||
JDCalc(2023, 6, 21), JDCalc(2023, 12, 22), JDCalc(2024, 2, 29),
|
||||
JDCalc(2025, 6, 21), JDCalc(2025, 12, 22), JDCalc(2026, 3, 3),
|
||||
}
|
||||
latitudes := []float64{-89, -85, -75, -66, -60, 60, 66, 75, 85, 89}
|
||||
for _, jd := range dates {
|
||||
for _, lat := range latitudes {
|
||||
aboveAll, belowAll := moonRiseSetDailyGeometry(jd, 0, lat, 0)
|
||||
if !aboveAll && !belowAll {
|
||||
continue
|
||||
}
|
||||
_, riseErr := GetMoonRiseTime(jd, 0, lat, 0, 1, 0)
|
||||
_, setErr := GetMoonSetTime(jd, 0, lat, 0, 1, 0)
|
||||
want := ErrNeverSet
|
||||
if belowAll {
|
||||
want = ErrNeverRise
|
||||
}
|
||||
if !moonRiseSetErrorMatches(riseErr, want) {
|
||||
t.Fatalf("lat %v jd %.1f aboveAll=%v belowAll=%v: moonrise error = %v, want %v",
|
||||
lat, jd, aboveAll, belowAll, riseErr, want)
|
||||
}
|
||||
if !moonRiseSetErrorMatches(setErr, want) {
|
||||
t.Fatalf("lat %v jd %.1f aboveAll=%v belowAll=%v: moonset error = %v, want %v",
|
||||
lat, jd, aboveAll, belowAll, setErr, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/tools"
|
||||
)
|
||||
|
||||
type moonRiseSetExternalEvents struct {
|
||||
RiseUTC string `json:"rise_utc"`
|
||||
SetUTC string `json:"set_utc"`
|
||||
}
|
||||
|
||||
type moonRiseSetExternalSample struct {
|
||||
Site string `json:"site"`
|
||||
DateUTC string `json:"date_utc"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
ObserverHeight float64 `json:"observer_height_m"`
|
||||
Horizons moonRiseSetExternalEvents `json:"jpl_horizons"`
|
||||
METNorway moonRiseSetExternalEvents `json:"met_norway"`
|
||||
IMCCEMiriade moonRiseSetExternalEvents `json:"imcce_miriade"`
|
||||
}
|
||||
|
||||
type moonRiseSetExternalBaseline struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Sources map[string]struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
} `json:"sources"`
|
||||
Samples []moonRiseSetExternalSample `json:"samples"`
|
||||
}
|
||||
|
||||
type moonRiseSetErrorStats struct {
|
||||
Total time.Duration
|
||||
Max time.Duration
|
||||
Count int
|
||||
}
|
||||
|
||||
func (stats *moonRiseSetErrorStats) Add(value time.Duration) {
|
||||
stats.Total += value
|
||||
stats.Count++
|
||||
if value > stats.Max {
|
||||
stats.Max = value
|
||||
}
|
||||
}
|
||||
|
||||
func (stats moonRiseSetErrorStats) Mean() time.Duration {
|
||||
if stats.Count == 0 {
|
||||
return 0
|
||||
}
|
||||
return stats.Total / time.Duration(stats.Count)
|
||||
}
|
||||
|
||||
type moonRiseSetExternalTolerances struct {
|
||||
Horizons time.Duration
|
||||
METNorway time.Duration
|
||||
IMCCEMiriade time.Duration
|
||||
HorizonsVsMET time.Duration
|
||||
}
|
||||
|
||||
type moonRiseSetComparisonStats struct {
|
||||
CurrentHorizons moonRiseSetErrorStats
|
||||
LegacyHorizons moonRiseSetErrorStats
|
||||
CurrentMET moonRiseSetErrorStats
|
||||
LegacyMET moonRiseSetErrorStats
|
||||
CurrentIMCCE moonRiseSetErrorStats
|
||||
LegacyIMCCE moonRiseSetErrorStats
|
||||
HorizonsVsMET moonRiseSetErrorStats
|
||||
HorizonsVsIMCCE moonRiseSetErrorStats
|
||||
CurrentCloserJPL int
|
||||
LegacyCloserJPL int
|
||||
TiesJPL int
|
||||
CurrentCloserMET int
|
||||
LegacyCloserMET int
|
||||
TiesMET int
|
||||
CurrentCloserIMCCE int
|
||||
LegacyCloserIMCCE int
|
||||
TiesIMCCE int
|
||||
}
|
||||
|
||||
func TestMoonRiseSetMatchesExternalBaselines(t *testing.T) {
|
||||
previousDeltaT := defDeltaTFn
|
||||
SetDeltaTFn(DefaultDeltaTv2)
|
||||
defer SetDeltaTFn(previousDeltaT)
|
||||
|
||||
baseline := loadMoonRiseSetExternalBaseline(t)
|
||||
if baseline.SchemaVersion != 1 {
|
||||
t.Fatalf("unsupported baseline schema version %d", baseline.SchemaVersion)
|
||||
}
|
||||
if baseline.Sources["jpl_horizons"].Model != "DE441" {
|
||||
t.Fatalf("unexpected Horizons model %q", baseline.Sources["jpl_horizons"].Model)
|
||||
}
|
||||
if len(baseline.Samples) < 7 {
|
||||
t.Fatalf("external baseline has only %d samples", len(baseline.Samples))
|
||||
}
|
||||
|
||||
tolerances := moonRiseSetExternalTolerances{
|
||||
Horizons: 2 * time.Second,
|
||||
METNorway: 90 * time.Second,
|
||||
IMCCEMiriade: 8 * time.Minute,
|
||||
HorizonsVsMET: 90 * time.Second,
|
||||
}
|
||||
var stats moonRiseSetComparisonStats
|
||||
|
||||
for _, sample := range baseline.Samples {
|
||||
day, err := time.Parse("2006-01-02", sample.DateUTC)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s date %q: %v", sample.Site, sample.DateUTC, err)
|
||||
}
|
||||
jd := Date2JD(day)
|
||||
currentRiseJD, err := GetMoonRiseTime(jd, sample.Longitude, sample.Latitude, 0, 1, sample.ObserverHeight)
|
||||
if err != nil {
|
||||
t.Fatalf("%s current moonrise: %v", sample.Site, err)
|
||||
}
|
||||
currentSetJD, err := GetMoonSetTime(jd, sample.Longitude, sample.Latitude, 0, 1, sample.ObserverHeight)
|
||||
if err != nil {
|
||||
t.Fatalf("%s current moonset: %v", sample.Site, err)
|
||||
}
|
||||
legacyRiseJD, err := legacyMoonRiseSetFromCurrent(currentRiseJD, sample.Longitude, sample.Latitude, 0, 1, sample.ObserverHeight)
|
||||
if err != nil {
|
||||
t.Fatalf("%s legacy moonrise: %v", sample.Site, err)
|
||||
}
|
||||
legacySetJD, err := legacyMoonRiseSetFromCurrent(currentSetJD, sample.Longitude, sample.Latitude, 0, 1, sample.ObserverHeight)
|
||||
if err != nil {
|
||||
t.Fatalf("%s legacy moonset: %v", sample.Site, err)
|
||||
}
|
||||
|
||||
compareMoonRiseSetEvent(t, sample.Site+".rise", currentRiseJD, legacyRiseJD,
|
||||
sample.Horizons.RiseUTC, sample.METNorway.RiseUTC, sample.IMCCEMiriade.RiseUTC,
|
||||
tolerances, &stats)
|
||||
compareMoonRiseSetEvent(t, sample.Site+".set", currentSetJD, legacySetJD,
|
||||
sample.Horizons.SetUTC, sample.METNorway.SetUTC, sample.IMCCEMiriade.SetUTC,
|
||||
tolerances, &stats)
|
||||
}
|
||||
|
||||
t.Logf("moon rise/set external baseline: current vs JPL mean=%v max=%v; legacy vs JPL mean=%v max=%v",
|
||||
stats.CurrentHorizons.Mean(), stats.CurrentHorizons.Max, stats.LegacyHorizons.Mean(), stats.LegacyHorizons.Max)
|
||||
t.Logf("moon rise/set external baseline: current vs MET mean=%v max=%v; legacy vs MET mean=%v max=%v",
|
||||
stats.CurrentMET.Mean(), stats.CurrentMET.Max, stats.LegacyMET.Mean(), stats.LegacyMET.Max)
|
||||
t.Logf("moon rise/set external baseline: current vs IMCCE mean=%v max=%v; legacy vs IMCCE mean=%v max=%v",
|
||||
stats.CurrentIMCCE.Mean(), stats.CurrentIMCCE.Max, stats.LegacyIMCCE.Mean(), stats.LegacyIMCCE.Max)
|
||||
t.Logf("moon rise/set external baseline: JPL vs MET mean=%v max=%v; JPL vs IMCCE mean=%v max=%v",
|
||||
stats.HorizonsVsMET.Mean(), stats.HorizonsVsMET.Max, stats.HorizonsVsIMCCE.Mean(), stats.HorizonsVsIMCCE.Max)
|
||||
t.Logf("moon rise/set external baseline: JPL current closer=%d legacy closer=%d ties=%d",
|
||||
stats.CurrentCloserJPL, stats.LegacyCloserJPL, stats.TiesJPL)
|
||||
t.Logf("moon rise/set external baseline: MET current closer=%d legacy closer=%d ties=%d",
|
||||
stats.CurrentCloserMET, stats.LegacyCloserMET, stats.TiesMET)
|
||||
t.Logf("moon rise/set external baseline: IMCCE current closer=%d legacy closer=%d ties=%d",
|
||||
stats.CurrentCloserIMCCE, stats.LegacyCloserIMCCE, stats.TiesIMCCE)
|
||||
}
|
||||
|
||||
func TestMoonRiseSetLegacyComparatorMatchesPreFixSnapshot(t *testing.T) {
|
||||
previousDeltaT := defDeltaTFn
|
||||
SetDeltaTFn(DefaultDeltaTv2)
|
||||
defer SetDeltaTFn(previousDeltaT)
|
||||
|
||||
jd := JDCalc(2023, 1, 15)
|
||||
currentRise, err := GetMoonRiseTime(jd, 116.4074, 39.9042, 8, 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("current moonrise: %v", err)
|
||||
}
|
||||
currentSet, err := GetMoonSetTime(jd, 116.4074, 39.9042, 8, 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("current moonset: %v", err)
|
||||
}
|
||||
legacyRise, err := legacyMoonRiseSetFromCurrent(currentRise, 116.4074, 39.9042, 8, 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy moonrise: %v", err)
|
||||
}
|
||||
legacySet, err := legacyMoonRiseSetFromCurrent(currentSet, 116.4074, 39.9042, 8, 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy moonset: %v", err)
|
||||
}
|
||||
|
||||
const snapshotTolerance = 2.0 / 86400
|
||||
if difference := math.Abs(legacyRise - 2459959.509182); difference > snapshotTolerance {
|
||||
t.Errorf("legacy moonrise snapshot mismatch: got %.9f want %.9f difference=%.3fs",
|
||||
legacyRise, 2459959.509182, difference*86400)
|
||||
}
|
||||
if difference := math.Abs(legacySet - 2459959.988676); difference > snapshotTolerance {
|
||||
t.Errorf("legacy moonset snapshot mismatch: got %.9f want %.9f difference=%.3fs",
|
||||
legacySet, 2459959.988676, difference*86400)
|
||||
}
|
||||
}
|
||||
|
||||
func loadMoonRiseSetExternalBaseline(t *testing.T) moonRiseSetExternalBaseline {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile("testdata/moon_rise_set_baseline.json")
|
||||
if err != nil {
|
||||
t.Fatalf("read moon rise/set baseline: %v", err)
|
||||
}
|
||||
var baseline moonRiseSetExternalBaseline
|
||||
if err := json.Unmarshal(data, &baseline); err != nil {
|
||||
t.Fatalf("decode moon rise/set baseline: %v", err)
|
||||
}
|
||||
return baseline
|
||||
}
|
||||
|
||||
func compareMoonRiseSetEvent(t *testing.T, name string, currentJD, legacyJD float64,
|
||||
horizonsUTC, metUTC, imcceUTC string, tolerances moonRiseSetExternalTolerances,
|
||||
stats *moonRiseSetComparisonStats) {
|
||||
t.Helper()
|
||||
current := JD2DateByZone(currentJD, time.UTC, false)
|
||||
legacy := JD2DateByZone(legacyJD, time.UTC, false)
|
||||
horizons := parseMoonRiseSetExternalTime(t, name+".jpl", horizonsUTC)
|
||||
met := parseMoonRiseSetExternalTime(t, name+".met", metUTC)
|
||||
imcce := parseMoonRiseSetExternalTime(t, name+".imcce", imcceUTC)
|
||||
currentHorizonsError := absoluteTimeDifference(current, horizons)
|
||||
legacyHorizonsError := absoluteTimeDifference(legacy, horizons)
|
||||
currentMETError := absoluteTimeDifference(current, met)
|
||||
legacyMETError := absoluteTimeDifference(legacy, met)
|
||||
currentIMCCEError := absoluteTimeDifference(current, imcce)
|
||||
legacyIMCCEError := absoluteTimeDifference(legacy, imcce)
|
||||
stats.CurrentHorizons.Add(currentHorizonsError)
|
||||
stats.LegacyHorizons.Add(legacyHorizonsError)
|
||||
stats.CurrentMET.Add(currentMETError)
|
||||
stats.LegacyMET.Add(legacyMETError)
|
||||
stats.CurrentIMCCE.Add(currentIMCCEError)
|
||||
stats.LegacyIMCCE.Add(legacyIMCCEError)
|
||||
horizonsVsMET := absoluteTimeDifference(horizons, met)
|
||||
stats.HorizonsVsMET.Add(horizonsVsMET)
|
||||
stats.HorizonsVsIMCCE.Add(absoluteTimeDifference(horizons, imcce))
|
||||
|
||||
if currentHorizonsError > tolerances.Horizons {
|
||||
t.Errorf("%s current mismatch against JPL: got %s want %s difference=%v tolerance=%v",
|
||||
name, current.Format(time.RFC3339Nano), horizonsUTC, currentHorizonsError, tolerances.Horizons)
|
||||
}
|
||||
if currentMETError > tolerances.METNorway {
|
||||
t.Errorf("%s current mismatch against MET Norway: got %s want %s difference=%v tolerance=%v",
|
||||
name, current.Format(time.RFC3339Nano), metUTC, currentMETError, tolerances.METNorway)
|
||||
}
|
||||
if currentIMCCEError > tolerances.IMCCEMiriade {
|
||||
t.Errorf("%s current mismatch against IMCCE Miriade: got %s want %s difference=%v tolerance=%v",
|
||||
name, current.Format(time.RFC3339Nano), imcceUTC, currentIMCCEError, tolerances.IMCCEMiriade)
|
||||
}
|
||||
if horizonsVsMET > tolerances.HorizonsVsMET {
|
||||
t.Errorf("%s external sources disagree: JPL=%s MET=%s difference=%v tolerance=%v",
|
||||
name, horizonsUTC, metUTC, horizonsVsMET, tolerances.HorizonsVsMET)
|
||||
}
|
||||
switch {
|
||||
case currentHorizonsError < legacyHorizonsError:
|
||||
stats.CurrentCloserJPL++
|
||||
case legacyHorizonsError < currentHorizonsError:
|
||||
stats.LegacyCloserJPL++
|
||||
default:
|
||||
stats.TiesJPL++
|
||||
}
|
||||
switch {
|
||||
case currentMETError < legacyMETError:
|
||||
stats.CurrentCloserMET++
|
||||
case legacyMETError < currentMETError:
|
||||
stats.LegacyCloserMET++
|
||||
default:
|
||||
stats.TiesMET++
|
||||
}
|
||||
switch {
|
||||
case currentIMCCEError < legacyIMCCEError:
|
||||
stats.CurrentCloserIMCCE++
|
||||
case legacyIMCCEError < currentIMCCEError:
|
||||
stats.LegacyCloserIMCCE++
|
||||
default:
|
||||
stats.TiesIMCCE++
|
||||
}
|
||||
t.Logf("%s current_jpl=%v legacy_jpl=%v current_met=%v legacy_met=%v current_imcce=%v legacy_imcce=%v", name,
|
||||
currentHorizonsError, legacyHorizonsError, currentMETError, legacyMETError, currentIMCCEError, legacyIMCCEError)
|
||||
}
|
||||
|
||||
func parseMoonRiseSetExternalTime(t *testing.T, name, value string) time.Time {
|
||||
t.Helper()
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s time %q: %v", name, value, err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func absoluteTimeDifference(left, right time.Time) time.Duration {
|
||||
difference := left.Sub(right)
|
||||
if difference < 0 {
|
||||
return -difference
|
||||
}
|
||||
return difference
|
||||
}
|
||||
|
||||
func legacyMoonRiseSetFromCurrent(currentJD, longitude, latitude, timeZone, zenithShift, height float64) (float64, error) {
|
||||
localTimeZone := longitude / 15
|
||||
localJD := currentJD + localTimeZone/24 - timeZone/24
|
||||
targetAltitude := StandardAltitudeMoon(zenithShift, height, latitude)
|
||||
legacyJD := moonRiseSetNewtonRaphsonIteration(localJD, longitude, latitude, localTimeZone,
|
||||
targetAltitude, legacyHMoonHeight, 0.00002)
|
||||
if math.IsNaN(legacyJD) || math.IsInf(legacyJD, 0) {
|
||||
return 0, fmt.Errorf("legacy height iteration did not converge")
|
||||
}
|
||||
return legacyJD - localTimeZone/24 + timeZone/24, nil
|
||||
}
|
||||
|
||||
func legacyHMoonHeight(jd, longitude, latitude, timeZone float64) float64 {
|
||||
calculationJD := UTC2TT(jd - timeZone/24)
|
||||
ra, dec := HMoonTrueRaDecN(calculationJD, -1)
|
||||
distanceAU := HMoonAwayN(calculationJD, -1) / 149597870.7
|
||||
topocentricRA, topocentricDec := legacyTopocentricRaDec(ra, dec, latitude, longitude, calculationJD, distanceAU, 0)
|
||||
siderealTime := tools.Limit360(ApparentSiderealTime(jd-timeZone/24)*15 + longitude)
|
||||
hourAngle := tools.Limit360(siderealTime - topocentricRA)
|
||||
altitudeSine := tools.Sin(latitude)*tools.Sin(topocentricDec) +
|
||||
tools.Cos(topocentricDec)*tools.Cos(latitude)*tools.Cos(hourAngle)
|
||||
return tools.ArcSin(altitudeSine)
|
||||
}
|
||||
|
||||
func legacyTopocentricRaDec(ra, dec, latitude, longitude, jd, distanceAU, height float64) (float64, float64) {
|
||||
horizontalParallaxSine := tools.Sin(0.0024427777777) / distanceAU
|
||||
observerCosine := pcosi(latitude, height)
|
||||
observerSine := psini(latitude, height)
|
||||
hourAngle := tools.Limit360(TT2UTC(ApparentSiderealTime(jd))*15 + longitude - ra)
|
||||
raCorrection := math.Atan2(-observerCosine*horizontalParallaxSine*tools.Sin(hourAngle),
|
||||
tools.Cos(dec)-observerCosine*horizontalParallaxSine*tools.Cos(hourAngle)) * 180 / math.Pi
|
||||
correctedDec := math.Atan2((tools.Sin(dec)-observerSine*horizontalParallaxSine)*tools.Cos(raCorrection),
|
||||
tools.Cos(dec)-observerCosine*horizontalParallaxSine*tools.Cos(hourAngle)) * 180 / math.Pi
|
||||
return ra + raCorrection, correctedDec
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package basic
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestMoonStateMatchesHMoonHeight 固定 MoonState 与 HMoonHeight 逐位同口径。
|
||||
func TestMoonStateMatchesHMoonHeight(t *testing.T) {
|
||||
for _, jd := range []float64{2462502.5, 2416745.5, 2469807.75, 2378496.25} {
|
||||
state := MoonStateAt(jd)
|
||||
for lon := -180.0; lon < 180; lon += 23.5 {
|
||||
for lat := -89.5; lat <= 89.5; lat += 7.5 {
|
||||
if got, want := state.HMoonHeight(lon, lat), HMoonHeight(jd, lon, lat, 0); got != want {
|
||||
t.Fatalf("jd=%v lon=%v lat=%v got %v want %v", jd, lon, lat, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+591
-546
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestMoonTopocentricPhysicalMatchesCorrectionMethod(t *testing.T) {
|
||||
jd := TD2UT(Date2JDE(testTime(2026, 4, 28, 9, 30, 45)), true)
|
||||
jd := UTC2TT(Date2JD(testTime(2026, 4, 28, 9, 30, 45)))
|
||||
observerLon := 121.4737
|
||||
observerLat := 31.2304
|
||||
|
||||
@@ -28,8 +28,8 @@ func TestMoonTopocentricPhysicalSampleSweepFiniteAndInRange(t *testing.T) {
|
||||
observerLat float64
|
||||
height float64
|
||||
}{
|
||||
{"shanghai", TD2UT(Date2JDE(testTime(2026, 4, 28, 9, 30, 45)), true), 121.4737, 31.2304, 4},
|
||||
{"chicago", TD2UT(Date2JDE(testTime(2024, 3, 25, 7, 0, 0)), true), -87.65, 41.85, 180},
|
||||
{"shanghai", UTC2TT(Date2JD(testTime(2026, 4, 28, 9, 30, 45))), 121.4737, 31.2304, 4},
|
||||
{"chicago", UTC2TT(Date2JD(testTime(2024, 3, 25, 7, 0, 0))), -87.65, 41.85, 180},
|
||||
}
|
||||
|
||||
for _, sample := range samples {
|
||||
@@ -50,7 +50,7 @@ func moonTopocentricPhysicalByCorrection(jd, observerLon, observerLat float64) M
|
||||
geocentric := MoonPhysical(jd)
|
||||
moonRA := HMoonTrueRa(jd)
|
||||
moonDec := HMoonTrueDec(jd)
|
||||
hourAngle := StarHourAngle(TD2UT(jd, false), moonRA, observerLon, 0)
|
||||
hourAngle := StarHourAngle(TT2UTC(jd), moonRA, observerLon, 0)
|
||||
horizontalParallax := ArcSin(6378.1366 / HMoonAway(jd))
|
||||
|
||||
Q := ArcTan2(
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// movingDiskEventEngine contains the time-domain part shared by solar
|
||||
// eclipses and lunar occultations. Geometry remains in the caller: a solar
|
||||
// adapter evaluates the Bessel projection, while an occultation adapter
|
||||
// evaluates the Earth-vector frame. Keeping that boundary explicit is what
|
||||
// lets the solar implementation remain the numerical baseline.
|
||||
type movingDiskEventEngine struct {
|
||||
maxSampleCount int
|
||||
searchSpanDays float64
|
||||
rangeStepDays float64
|
||||
rootToleranceDays float64
|
||||
greatestSpanDays float64
|
||||
greatestToleranceDays float64
|
||||
reserveAnchorSlot bool
|
||||
uniformOverflow bool
|
||||
indexedSampleTimes bool
|
||||
}
|
||||
|
||||
// movingDiskContactState is the dimensionless circular-disk model shared by
|
||||
// solar eclipse and lunar occultation adapters. The radii and separation may
|
||||
// be radians, degrees, or arcseconds, but all four values must use the same
|
||||
// unit. Solar eclipses provide distinct outer and inner occulting radii;
|
||||
// finite-planet occultations normally use the same lunar radius for both.
|
||||
type movingDiskContactState struct {
|
||||
separation float64
|
||||
occultingOuterRadius float64
|
||||
occultingInnerRadius float64
|
||||
targetRadius float64
|
||||
valid bool
|
||||
}
|
||||
|
||||
// movingDiskEventCacheMaximumEntries is shared by event-local caches so a
|
||||
// dense path cannot grow without bound in native or TinyGo/WASM execution.
|
||||
const movingDiskEventCacheMaximumEntries = 2048
|
||||
const movingDiskContactCacheMaximumEntries = movingDiskEventCacheMaximumEntries
|
||||
|
||||
// movingDiskContactCache stores the disk state for one event evaluation. The
|
||||
// valid bit is part of the entry so failed ephemeris evaluations are cached as
|
||||
// well; otherwise a pair of external/internal roots can repeat the same
|
||||
// invalid star or planet calculation indefinitely. Entries are keyed by the
|
||||
// exact TT bits because root refinement intentionally revisits exact endpoints.
|
||||
type movingDiskContactCache struct {
|
||||
entries map[uint64]movingDiskContactCacheEntry
|
||||
maxEntries int
|
||||
}
|
||||
|
||||
type movingDiskContactCacheEntry struct {
|
||||
state movingDiskContactState
|
||||
ok bool
|
||||
}
|
||||
|
||||
func newMovingDiskContactCache() *movingDiskContactCache {
|
||||
return &movingDiskContactCache{
|
||||
entries: make(map[uint64]movingDiskContactCacheEntry),
|
||||
maxEntries: movingDiskContactCacheMaximumEntries,
|
||||
}
|
||||
}
|
||||
|
||||
func (cache *movingDiskContactCache) lookup(tt float64) (movingDiskContactState, bool, bool) {
|
||||
if cache == nil || cache.entries == nil {
|
||||
return movingDiskContactState{}, false, false
|
||||
}
|
||||
entry, ok := cache.entries[math.Float64bits(tt)]
|
||||
if !ok {
|
||||
return movingDiskContactState{}, false, false
|
||||
}
|
||||
return entry.state, entry.ok, true
|
||||
}
|
||||
|
||||
func (cache *movingDiskContactCache) store(tt float64, state movingDiskContactState, ok bool) {
|
||||
if cache == nil {
|
||||
return
|
||||
}
|
||||
if cache.entries == nil {
|
||||
cache.entries = make(map[uint64]movingDiskContactCacheEntry)
|
||||
}
|
||||
limit := cache.maxEntries
|
||||
if limit <= 0 {
|
||||
limit = movingDiskContactCacheMaximumEntries
|
||||
}
|
||||
key := math.Float64bits(tt)
|
||||
if _, exists := cache.entries[key]; !exists && len(cache.entries) >= limit {
|
||||
// Contact roots are local to one event and are naturally clustered in
|
||||
// time. Clearing the bounded table is cheaper and more predictable
|
||||
// than maintaining an eviction list in TinyGo/WASM.
|
||||
for key := range cache.entries {
|
||||
delete(cache.entries, key)
|
||||
}
|
||||
}
|
||||
cache.entries[key] = movingDiskContactCacheEntry{state: state, ok: ok}
|
||||
}
|
||||
|
||||
// movingDiskContactEvaluator adapts an event-specific geometry calculation to
|
||||
// the shared contact root engine. Its callback must be pure for a given TT;
|
||||
// all observer/configuration values belong to the evaluator closure.
|
||||
type movingDiskContactEvaluator struct {
|
||||
evaluate func(float64) (movingDiskContactState, bool)
|
||||
cache *movingDiskContactCache
|
||||
}
|
||||
|
||||
func newMovingDiskContactEvaluator(
|
||||
evaluate func(float64) (movingDiskContactState, bool),
|
||||
) *movingDiskContactEvaluator {
|
||||
return &movingDiskContactEvaluator{
|
||||
evaluate: evaluate,
|
||||
cache: newMovingDiskContactCache(),
|
||||
}
|
||||
}
|
||||
|
||||
func (evaluator *movingDiskContactEvaluator) stateAt(tt float64) (movingDiskContactState, bool) {
|
||||
if evaluator == nil || evaluator.evaluate == nil || !finiteMovingDiskValue(tt) {
|
||||
return movingDiskContactState{}, false
|
||||
}
|
||||
if state, ok, hit := evaluator.cache.lookup(tt); hit {
|
||||
return state, ok
|
||||
}
|
||||
state, ok := evaluator.evaluate(tt)
|
||||
if !ok {
|
||||
state = movingDiskContactState{}
|
||||
}
|
||||
evaluator.cache.store(tt, state, ok)
|
||||
return state, ok
|
||||
}
|
||||
|
||||
// prime inserts a state already computed by the caller. Greatest-point
|
||||
// evaluation often precedes both external and internal contact roots; priming
|
||||
// avoids evaluating that same TT a second time while preserving the callback
|
||||
// as the source of truth for all other samples.
|
||||
func (evaluator *movingDiskContactEvaluator) prime(
|
||||
tt float64,
|
||||
state movingDiskContactState,
|
||||
ok bool,
|
||||
) {
|
||||
if evaluator == nil || !finiteMovingDiskValue(tt) {
|
||||
return
|
||||
}
|
||||
evaluator.cache.store(tt, state, ok)
|
||||
}
|
||||
|
||||
func (evaluator *movingDiskContactEvaluator) gap(tt float64, internal bool) (float64, bool) {
|
||||
state, ok := evaluator.stateAt(tt)
|
||||
if !ok || !state.valid {
|
||||
return 0, false
|
||||
}
|
||||
if internal {
|
||||
return state.internalContactGap(), true
|
||||
}
|
||||
return state.externalContactGap(), true
|
||||
}
|
||||
|
||||
func (state movingDiskContactState) externalContactGap() float64 {
|
||||
if !state.valid {
|
||||
return math.NaN()
|
||||
}
|
||||
return state.separation - state.occultingOuterRadius - state.targetRadius
|
||||
}
|
||||
|
||||
func (state movingDiskContactState) internalContactGap() float64 {
|
||||
if !state.valid {
|
||||
return math.NaN()
|
||||
}
|
||||
return state.separation - math.Abs(state.occultingInnerRadius-state.targetRadius)
|
||||
}
|
||||
|
||||
func movingDiskContactStateValid(separation, outerRadius, innerRadius, targetRadius float64) bool {
|
||||
return finiteMovingDiskValue(separation) && separation >= 0 &&
|
||||
finiteMovingDiskValue(outerRadius) && outerRadius > 0 &&
|
||||
finiteMovingDiskValue(innerRadius) && innerRadius > 0 &&
|
||||
finiteMovingDiskValue(targetRadius) && targetRadius >= 0
|
||||
}
|
||||
|
||||
func finiteMovingDiskValue(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
}
|
||||
|
||||
func (engine movingDiskEventEngine) sampleTimes(
|
||||
start, end, greatest, requestedStep float64,
|
||||
) ([]float64, float64) {
|
||||
if end < start {
|
||||
start, end = end, start
|
||||
}
|
||||
maximum := engine.maxSampleCount
|
||||
if maximum < 3 {
|
||||
maximum = 3
|
||||
}
|
||||
duration := end - start
|
||||
if duration <= 0 {
|
||||
return []float64{start}, requestedStep
|
||||
}
|
||||
step := requestedStep
|
||||
if step <= 0 || math.IsNaN(step) || math.IsInf(step, 0) {
|
||||
step = duration
|
||||
}
|
||||
baseSampleCount := int(math.Ceil(duration/step)) + 1
|
||||
if engine.reserveAnchorSlot {
|
||||
baseSampleCount++
|
||||
}
|
||||
if baseSampleCount > maximum && engine.uniformOverflow {
|
||||
interiorCount := maximum - 3
|
||||
if interiorCount < 0 {
|
||||
interiorCount = 0
|
||||
}
|
||||
bounded := make([]float64, 0, maximum)
|
||||
bounded = append(bounded, start, greatest, end)
|
||||
for index := 1; index <= interiorCount; index++ {
|
||||
bounded = append(bounded, start+duration*float64(index)/float64(interiorCount+1))
|
||||
}
|
||||
sort.Float64s(bounded)
|
||||
return movingDiskUniqueTimes(bounded), step
|
||||
}
|
||||
if baseSampleCount > maximum {
|
||||
step = duration / float64(maximum-1)
|
||||
}
|
||||
|
||||
times := []float64{start, greatest, end}
|
||||
if engine.indexedSampleTimes {
|
||||
for index := 1; ; index++ {
|
||||
current := start + float64(index)*step
|
||||
if current >= end {
|
||||
break
|
||||
}
|
||||
times = append(times, current)
|
||||
}
|
||||
} else {
|
||||
for current := start + step; current < end; current += step {
|
||||
times = append(times, current)
|
||||
}
|
||||
}
|
||||
sort.Float64s(times)
|
||||
return movingDiskUniqueTimes(times), step
|
||||
}
|
||||
|
||||
func (engine movingDiskEventEngine) window(
|
||||
seed, start, end float64,
|
||||
candidateAt, exactAt func(float64) bool,
|
||||
) (float64, float64, bool) {
|
||||
if exactAt == nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
left := start
|
||||
right := end
|
||||
if engine.searchSpanDays > 0 {
|
||||
left = math.Max(left, seed-engine.searchSpanDays)
|
||||
right = math.Min(right, seed+engine.searchSpanDays)
|
||||
}
|
||||
if right <= left {
|
||||
return 0, 0, false
|
||||
}
|
||||
if candidateAt == nil {
|
||||
candidateAt = exactAt
|
||||
}
|
||||
step := engine.rangeStepDays
|
||||
if step <= 0 || math.IsNaN(step) || math.IsInf(step, 0) {
|
||||
step = right - left
|
||||
}
|
||||
|
||||
first := math.NaN()
|
||||
previous := left
|
||||
if candidateAt(previous) && exactAt(previous) {
|
||||
first = previous
|
||||
}
|
||||
if math.IsNaN(first) {
|
||||
for current := left + step; current <= right; current += step {
|
||||
current = math.Min(current, right)
|
||||
if candidateAt(current) {
|
||||
first = engine.refineTransition(previous, current, exactAt, false)
|
||||
break
|
||||
}
|
||||
previous = current
|
||||
}
|
||||
}
|
||||
if math.IsNaN(first) {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
last := first
|
||||
previous = first
|
||||
for current := first + step; current <= right; current += step {
|
||||
current = math.Min(current, right)
|
||||
if !candidateAt(current) {
|
||||
last = engine.refineTransition(previous, current, exactAt, true)
|
||||
return first, last, true
|
||||
}
|
||||
last = current
|
||||
previous = current
|
||||
}
|
||||
return first, right, true
|
||||
}
|
||||
|
||||
func (engine movingDiskEventEngine) refineTransition(
|
||||
left, right float64,
|
||||
predicate func(float64) bool,
|
||||
trueToFalse bool,
|
||||
) float64 {
|
||||
leftOK := predicate(left)
|
||||
tolerance := engine.rootToleranceDays
|
||||
if tolerance <= 0 || math.IsNaN(tolerance) || math.IsInf(tolerance, 0) {
|
||||
tolerance = 1e-10
|
||||
}
|
||||
for iteration := 0; iteration < 48 && math.Abs(right-left) > tolerance; iteration++ {
|
||||
middle := (left + right) / 2
|
||||
middleOK := predicate(middle)
|
||||
if trueToFalse {
|
||||
if middleOK {
|
||||
left = middle
|
||||
} else {
|
||||
right = middle
|
||||
}
|
||||
continue
|
||||
}
|
||||
if middleOK {
|
||||
right = middle
|
||||
} else {
|
||||
left = middle
|
||||
}
|
||||
}
|
||||
if trueToFalse {
|
||||
return left
|
||||
}
|
||||
if leftOK {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
// greatest returns the minimum impact value in the event-local interval.
|
||||
// The callback returns (impact, valid); invalid states are treated as +Inf.
|
||||
func (engine movingDiskEventEngine) greatest(
|
||||
seed, start, end float64,
|
||||
impactAt func(float64) (float64, bool),
|
||||
iterations int,
|
||||
) float64 {
|
||||
if impactAt == nil {
|
||||
return seed
|
||||
}
|
||||
left := start
|
||||
right := end
|
||||
span := engine.greatestSpanDays
|
||||
if span > 0 {
|
||||
left = math.Max(left, seed-span)
|
||||
right = math.Min(right, seed+span)
|
||||
}
|
||||
if right <= left {
|
||||
return seed
|
||||
}
|
||||
if iterations <= 0 {
|
||||
iterations = 56
|
||||
}
|
||||
const goldenRatio = 0.6180339887498949
|
||||
x1 := right - goldenRatio*(right-left)
|
||||
x2 := left + goldenRatio*(right-left)
|
||||
f1 := movingDiskImpact(impactAt, x1)
|
||||
f2 := movingDiskImpact(impactAt, x2)
|
||||
for iteration := 0; iteration < iterations &&
|
||||
(engine.greatestToleranceDays <= 0 || right-left > engine.greatestToleranceDays); iteration++ {
|
||||
if f1 > f2 {
|
||||
left = x1
|
||||
x1, f1 = x2, f2
|
||||
x2 = left + goldenRatio*(right-left)
|
||||
f2 = movingDiskImpact(impactAt, x2)
|
||||
continue
|
||||
}
|
||||
right = x2
|
||||
x2, f2 = x1, f1
|
||||
x1 = right - goldenRatio*(right-left)
|
||||
f1 = movingDiskImpact(impactAt, x1)
|
||||
}
|
||||
return (left + right) / 2
|
||||
}
|
||||
|
||||
// contactRoot finds one external or internal disk-contact root by walking
|
||||
// away from greatest and then bisecting the first valid sign change. The
|
||||
// callback may reject an ephemeris sample; rejected samples are skipped while
|
||||
// searching, but an invalid value inside a confirmed bisection bracket aborts
|
||||
// that root rather than inventing a crossing.
|
||||
func (engine movingDiskEventEngine) contactRoot(
|
||||
greatest, direction, stepDays, spanDays, tolerance float64,
|
||||
metric func(float64) (float64, bool),
|
||||
iterations int,
|
||||
) (float64, bool) {
|
||||
if metric == nil || (direction != -1 && direction != 1) ||
|
||||
stepDays <= 0 || spanDays <= 0 || tolerance <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
nearTT := greatest
|
||||
nearValue, nearOK := metric(nearTT)
|
||||
if !nearOK || !finiteMovingDiskValue(nearValue) || nearValue > 0 {
|
||||
return 0, false
|
||||
}
|
||||
maxSteps := int(math.Ceil(spanDays / stepDays))
|
||||
if maxSteps < 1 {
|
||||
maxSteps = 1
|
||||
}
|
||||
for index := 1; index <= maxSteps; index++ {
|
||||
farTT := greatest + direction*float64(index)*stepDays
|
||||
farValue, farOK := metric(farTT)
|
||||
if !farOK || !finiteMovingDiskValue(farValue) {
|
||||
continue
|
||||
}
|
||||
if farValue < 0 {
|
||||
nearTT, nearValue = farTT, farValue
|
||||
continue
|
||||
}
|
||||
return movingDiskContactBracketRoot(
|
||||
nearTT, farTT, nearValue, farValue, metric, tolerance, iterations,
|
||||
)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func movingDiskContactBracketRoot(
|
||||
left, right, leftValue, rightValue float64,
|
||||
metric func(float64) (float64, bool),
|
||||
tolerance float64,
|
||||
iterations int,
|
||||
) (float64, bool) {
|
||||
if left > right {
|
||||
left, right = right, left
|
||||
leftValue, rightValue = rightValue, leftValue
|
||||
}
|
||||
if !finiteMovingDiskValue(leftValue) || !finiteMovingDiskValue(rightValue) ||
|
||||
leftValue*rightValue > 0 {
|
||||
return 0, false
|
||||
}
|
||||
if leftValue == 0 {
|
||||
return left, true
|
||||
}
|
||||
if rightValue == 0 {
|
||||
return right, true
|
||||
}
|
||||
if iterations <= 0 {
|
||||
iterations = 64
|
||||
}
|
||||
for index := 0; index < iterations && right-left > tolerance; index++ {
|
||||
middle := (left + right) / 2
|
||||
middleValue, ok := metric(middle)
|
||||
if !ok || !finiteMovingDiskValue(middleValue) {
|
||||
return 0, false
|
||||
}
|
||||
if leftValue*middleValue <= 0 {
|
||||
right, rightValue = middle, middleValue
|
||||
} else {
|
||||
left, leftValue = middle, middleValue
|
||||
}
|
||||
}
|
||||
return (left + right) / 2, true
|
||||
}
|
||||
|
||||
func movingDiskImpact(impactAt func(float64) (float64, bool), tt float64) float64 {
|
||||
value, ok := impactAt(tt)
|
||||
if !ok || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return math.Inf(1)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func movingDiskUniqueTimes(times []float64) []float64 {
|
||||
if len(times) < 2 {
|
||||
return times
|
||||
}
|
||||
unique := times[:1]
|
||||
for _, current := range times[1:] {
|
||||
if math.Abs(current-unique[len(unique)-1]) <= 1e-10 {
|
||||
continue
|
||||
}
|
||||
unique = append(unique, current)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
func solarEclipseMovingDiskEngine() movingDiskEventEngine {
|
||||
return movingDiskEventEngine{
|
||||
maxSampleCount: solarEclipsePathMaxSampleCount,
|
||||
rootToleranceDays: solarEclipseShadowContactToleranceDays,
|
||||
greatestToleranceDays: localSolarEclipseGreatestTolerance,
|
||||
}
|
||||
}
|
||||
|
||||
func occultationMovingDiskEngine() movingDiskEventEngine {
|
||||
return movingDiskEventEngine{
|
||||
maxSampleCount: occultationPathMaxSampleCount,
|
||||
searchSpanDays: occultationPathSearchSpanDays,
|
||||
rangeStepDays: occultationPathRangeStepDays,
|
||||
rootToleranceDays: occultationPathRootToleranceDays,
|
||||
greatestSpanDays: 0.75,
|
||||
reserveAnchorSlot: true,
|
||||
uniformOverflow: true,
|
||||
indexedSampleTimes: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMovingDiskEventEngineSampleTimesKeepAnchorsAndBudget(t *testing.T) {
|
||||
engine := movingDiskEventEngine{maxSampleCount: 7, reserveAnchorSlot: true, uniformOverflow: true}
|
||||
times, step := engine.sampleTimes(0, 1, 0.37, 0.01)
|
||||
if len(times) != 7 {
|
||||
t.Fatalf("sample count = %d, want 7", len(times))
|
||||
}
|
||||
if times[0] != 0 || times[len(times)-1] != 1 {
|
||||
t.Fatalf("sample endpoints = %.6f, %.6f, want 0 and 1", times[0], times[len(times)-1])
|
||||
}
|
||||
foundGreatest := false
|
||||
for index, value := range times {
|
||||
if index > 0 && value <= times[index-1] {
|
||||
t.Fatalf("sample times are not strictly increasing: %v", times)
|
||||
}
|
||||
if math.Abs(value-0.37) <= 1e-12 {
|
||||
foundGreatest = true
|
||||
}
|
||||
}
|
||||
if !foundGreatest {
|
||||
t.Fatalf("sample times omitted greatest: %v", times)
|
||||
}
|
||||
if step <= 0 {
|
||||
t.Fatalf("effective step = %v, want positive", step)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingDiskEventEngineWindowUsesCandidateAndExactPredicates(t *testing.T) {
|
||||
engine := movingDiskEventEngine{
|
||||
searchSpanDays: 1,
|
||||
rangeStepDays: 0.1,
|
||||
rootToleranceDays: 1e-9,
|
||||
}
|
||||
candidateCalls, exactCalls := 0, 0
|
||||
candidate := func(tt float64) bool {
|
||||
candidateCalls++
|
||||
return tt >= -0.35 && tt <= 0.42
|
||||
}
|
||||
exact := func(tt float64) bool {
|
||||
exactCalls++
|
||||
return tt >= -0.3 && tt <= 0.4
|
||||
}
|
||||
start, end, ok := engine.window(0, -1, 1, candidate, exact)
|
||||
if !ok {
|
||||
t.Fatal("window returned not found")
|
||||
}
|
||||
if math.Abs(start+0.3) > 2e-8 || math.Abs(end-0.4) > 2e-8 {
|
||||
t.Fatalf("window = %.12f..%.12f, want -0.3..0.4", start, end)
|
||||
}
|
||||
if candidateCalls == 0 || exactCalls == 0 {
|
||||
t.Fatalf("candidate/exact calls = %d/%d, want both", candidateCalls, exactCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingDiskEventEngineGreatestFindsMinimumImpact(t *testing.T) {
|
||||
engine := movingDiskEventEngine{greatestSpanDays: 0.75}
|
||||
got := engine.greatest(0.1, -1, 1, func(tt float64) (float64, bool) {
|
||||
return (tt - 0.23) * (tt - 0.23), true
|
||||
}, 56)
|
||||
if math.Abs(got-0.23) > 1e-8 {
|
||||
t.Fatalf("greatest = %.12f, want 0.23", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingDiskContactStateUsesSolarOuterAndInnerRadii(t *testing.T) {
|
||||
state := movingDiskContactState{
|
||||
separation: 1.0,
|
||||
occultingOuterRadius: 0.6,
|
||||
occultingInnerRadius: 0.4,
|
||||
targetRadius: 0.5,
|
||||
valid: true,
|
||||
}
|
||||
if got := state.externalContactGap(); math.Abs(got+0.1) > 1e-12 {
|
||||
t.Fatalf("external gap = %.12f, want -0.1", got)
|
||||
}
|
||||
if got := state.internalContactGap(); math.Abs(got-0.9) > 1e-12 {
|
||||
t.Fatalf("internal gap = %.12f, want 0.9", got)
|
||||
}
|
||||
if !movingDiskContactStateValid(1, 0.6, 0.4, 0.5) {
|
||||
t.Fatal("finite disk state was rejected")
|
||||
}
|
||||
if movingDiskContactStateValid(math.NaN(), 0.6, 0.4, 0.5) {
|
||||
t.Fatal("NaN disk state was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingDiskEventEngineContactRootWalksBothDirections(t *testing.T) {
|
||||
engine := movingDiskEventEngine{}
|
||||
metric := func(tt float64) (float64, bool) {
|
||||
return (tt + 0.3) * (tt - 0.4), true
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
direction float64
|
||||
want float64
|
||||
}{
|
||||
{name: "forward", direction: 1, want: 0.4},
|
||||
{name: "backward", direction: -1, want: -0.3},
|
||||
} {
|
||||
got, ok := engine.contactRoot(0, test.direction, 0.05, 1, 1e-9, metric, 64)
|
||||
if !ok || math.Abs(got-test.want) > 2e-8 {
|
||||
t.Errorf("%s contact root = %.12f, ok=%v, want %.3f", test.name, got, ok, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingDiskContactEvaluatorCachesValidAndInvalidStates(t *testing.T) {
|
||||
calls := 0
|
||||
evaluator := newMovingDiskContactEvaluator(func(tt float64) (movingDiskContactState, bool) {
|
||||
calls++
|
||||
if tt < 0 {
|
||||
return movingDiskContactState{}, false
|
||||
}
|
||||
state := movingDiskContactState{
|
||||
separation: 1,
|
||||
occultingOuterRadius: 0.6,
|
||||
occultingInnerRadius: 0.6,
|
||||
targetRadius: 0.2,
|
||||
valid: true,
|
||||
}
|
||||
return state, true
|
||||
})
|
||||
if _, ok := evaluator.gap(0.25, false); !ok {
|
||||
t.Fatal("first valid evaluation failed")
|
||||
}
|
||||
if _, ok := evaluator.gap(0.25, true); !ok {
|
||||
t.Fatal("cached valid evaluation failed")
|
||||
}
|
||||
if _, ok := evaluator.gap(-0.25, false); ok {
|
||||
t.Fatal("invalid evaluation was accepted")
|
||||
}
|
||||
if _, ok := evaluator.gap(-0.25, true); ok {
|
||||
t.Fatal("cached invalid evaluation was accepted")
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("evaluation calls = %d, want 2 after valid/invalid cache hits", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingDiskContactEvaluatorPrimeAvoidsCallback(t *testing.T) {
|
||||
calls := 0
|
||||
evaluator := newMovingDiskContactEvaluator(func(tt float64) (movingDiskContactState, bool) {
|
||||
calls++
|
||||
return movingDiskContactState{}, false
|
||||
})
|
||||
state := movingDiskContactState{
|
||||
separation: 1,
|
||||
occultingOuterRadius: 0.6,
|
||||
occultingInnerRadius: 0.6,
|
||||
targetRadius: 0.2,
|
||||
valid: true,
|
||||
}
|
||||
evaluator.prime(0.5, state, true)
|
||||
value, ok := evaluator.gap(0.5, false)
|
||||
if !ok || math.Abs(value-0.2) > 1e-12 {
|
||||
t.Fatalf("primed external gap = %.12f, ok=%v", value, ok)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("callback calls = %d, want 0 for primed TT", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingDiskContactCacheClearsAtBoundedCapacity(t *testing.T) {
|
||||
cache := &movingDiskContactCache{maxEntries: 2}
|
||||
state := movingDiskContactState{valid: true}
|
||||
cache.store(1, state, true)
|
||||
cache.store(2, state, true)
|
||||
cache.store(3, state, true)
|
||||
if len(cache.entries) != 1 {
|
||||
t.Fatalf("cache size = %d, want 1 after bounded clear", len(cache.entries))
|
||||
}
|
||||
if _, _, hit := cache.lookup(1); hit {
|
||||
t.Fatal("old entry survived bounded cache clear")
|
||||
}
|
||||
if _, _, hit := cache.lookup(3); !hit {
|
||||
t.Fatal("new entry missing after bounded cache clear")
|
||||
}
|
||||
}
|
||||
+76
-107
@@ -7,79 +7,79 @@ import (
|
||||
. "b612.me/astro/tools"
|
||||
)
|
||||
|
||||
func NeptuneL(jd float64) float64 {
|
||||
return planet.WherePlanet(7, 0, jd)
|
||||
func NeptuneL(jde float64) float64 {
|
||||
return planet.WherePlanet(7, 0, jde)
|
||||
}
|
||||
|
||||
func NeptuneB(jd float64) float64 {
|
||||
return planet.WherePlanet(7, 1, jd)
|
||||
func NeptuneB(jde float64) float64 {
|
||||
return planet.WherePlanet(7, 1, jde)
|
||||
}
|
||||
func NeptuneR(jd float64) float64 {
|
||||
return planet.WherePlanet(7, 2, jd)
|
||||
func NeptuneR(jde float64) float64 {
|
||||
return planet.WherePlanet(7, 2, jde)
|
||||
}
|
||||
func ANeptuneX(jd float64) float64 {
|
||||
l := NeptuneL(jd)
|
||||
b := NeptuneB(jd)
|
||||
r := NeptuneR(jd)
|
||||
el := planet.WherePlanet(-1, 0, jd)
|
||||
eb := planet.WherePlanet(-1, 1, jd)
|
||||
er := planet.WherePlanet(-1, 2, jd)
|
||||
func ANeptuneX(jde float64) float64 {
|
||||
l := NeptuneL(jde)
|
||||
b := NeptuneB(jde)
|
||||
r := NeptuneR(jde)
|
||||
el := planet.WherePlanet(-1, 0, jde)
|
||||
eb := planet.WherePlanet(-1, 1, jde)
|
||||
er := planet.WherePlanet(-1, 2, jde)
|
||||
x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el)
|
||||
return x
|
||||
}
|
||||
|
||||
func ANeptuneY(jd float64) float64 {
|
||||
func ANeptuneY(jde float64) float64 {
|
||||
|
||||
l := NeptuneL(jd)
|
||||
b := NeptuneB(jd)
|
||||
r := NeptuneR(jd)
|
||||
el := planet.WherePlanet(-1, 0, jd)
|
||||
eb := planet.WherePlanet(-1, 1, jd)
|
||||
er := planet.WherePlanet(-1, 2, jd)
|
||||
l := NeptuneL(jde)
|
||||
b := NeptuneB(jde)
|
||||
r := NeptuneR(jde)
|
||||
el := planet.WherePlanet(-1, 0, jde)
|
||||
eb := planet.WherePlanet(-1, 1, jde)
|
||||
er := planet.WherePlanet(-1, 2, jde)
|
||||
y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el)
|
||||
return y
|
||||
}
|
||||
func ANeptuneZ(jd float64) float64 {
|
||||
//l := NeptuneL(jd)
|
||||
b := NeptuneB(jd)
|
||||
r := NeptuneR(jd)
|
||||
// el := planet.WherePlanet(-1, 0, jd)
|
||||
eb := planet.WherePlanet(-1, 1, jd)
|
||||
er := planet.WherePlanet(-1, 2, jd)
|
||||
func ANeptuneZ(jde float64) float64 {
|
||||
//l := NeptuneL(jde)
|
||||
b := NeptuneB(jde)
|
||||
r := NeptuneR(jde)
|
||||
// el := planet.WherePlanet(-1, 0, jde)
|
||||
eb := planet.WherePlanet(-1, 1, jde)
|
||||
er := planet.WherePlanet(-1, 2, jde)
|
||||
z := r*Sin(b) - er*Sin(eb)
|
||||
return z
|
||||
}
|
||||
|
||||
func ANeptuneXYZ(jd float64) (float64, float64, float64) {
|
||||
l := NeptuneL(jd)
|
||||
b := NeptuneB(jd)
|
||||
r := NeptuneR(jd)
|
||||
el := planet.WherePlanet(-1, 0, jd)
|
||||
eb := planet.WherePlanet(-1, 1, jd)
|
||||
er := planet.WherePlanet(-1, 2, jd)
|
||||
func ANeptuneXYZ(jde float64) (float64, float64, float64) {
|
||||
l := NeptuneL(jde)
|
||||
b := NeptuneB(jde)
|
||||
r := NeptuneR(jde)
|
||||
el := planet.WherePlanet(-1, 0, jde)
|
||||
eb := planet.WherePlanet(-1, 1, jde)
|
||||
er := planet.WherePlanet(-1, 2, jde)
|
||||
x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el)
|
||||
y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el)
|
||||
z := r*Sin(b) - er*Sin(eb)
|
||||
return x, y, z
|
||||
}
|
||||
|
||||
func NeptuneApparentRa(jd float64) float64 {
|
||||
lo, bo := NeptuneApparentLoBo(jd)
|
||||
eps := TrueObliquity(jd)
|
||||
func NeptuneApparentRa(jde float64) float64 {
|
||||
lo, bo := NeptuneApparentLoBo(jde)
|
||||
eps := TrueObliquity(jde)
|
||||
ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo))
|
||||
ra = ra * 180 / math.Pi
|
||||
return Limit360(ra)
|
||||
}
|
||||
func NeptuneApparentDec(jd float64) float64 {
|
||||
lo, bo := NeptuneApparentLoBo(jd)
|
||||
eps := TrueObliquity(jd)
|
||||
func NeptuneApparentDec(jde float64) float64 {
|
||||
lo, bo := NeptuneApparentLoBo(jde)
|
||||
eps := TrueObliquity(jde)
|
||||
dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo))
|
||||
return dec
|
||||
}
|
||||
|
||||
func NeptuneApparentRaDec(jd float64) (float64, float64) {
|
||||
lo, bo := NeptuneApparentLoBo(jd)
|
||||
eps := TrueObliquity(jd)
|
||||
func NeptuneApparentRaDec(jde float64) (float64, float64) {
|
||||
lo, bo := NeptuneApparentLoBo(jde)
|
||||
eps := TrueObliquity(jde)
|
||||
ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo))
|
||||
ra = ra * 180 / math.Pi
|
||||
dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo))
|
||||
@@ -87,71 +87,40 @@ func NeptuneApparentRaDec(jd float64) (float64, float64) {
|
||||
}
|
||||
|
||||
func EarthNeptuneAway(jd float64) float64 {
|
||||
x, y, z := ANeptuneXYZ(jd)
|
||||
to := math.Sqrt(x*x + y*y + z*z)
|
||||
return to
|
||||
return planetEarthAwayExplicitN(7, jd, -1)
|
||||
}
|
||||
|
||||
func NeptuneApparentLo(jd float64) float64 {
|
||||
x, y, z := ANeptuneXYZ(jd)
|
||||
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
|
||||
x, y, z = ANeptuneXYZ(jd - to)
|
||||
lo := math.Atan2(y, x)
|
||||
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
|
||||
lo = lo * 180 / math.Pi
|
||||
bo = bo * 180 / math.Pi
|
||||
lo = Limit360(lo)
|
||||
//lo-=GXCLo(lo,bo,jd)/3600;
|
||||
//bo+=GXCBo(lo,bo,jd);
|
||||
lo += Nutation2000Bi(jd)
|
||||
return lo
|
||||
geo, _ := planetApparentGeocentricPositionN(7, jd, -1)
|
||||
return geo.lo
|
||||
}
|
||||
|
||||
func NeptuneApparentBo(jd float64) float64 {
|
||||
x, y, z := ANeptuneXYZ(jd)
|
||||
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
|
||||
x, y, z = ANeptuneXYZ(jd - to)
|
||||
//lo := math.Atan2(y, x)
|
||||
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
|
||||
//lo = lo * 180 / math.Pi
|
||||
bo = bo * 180 / math.Pi
|
||||
//lo+=GXCLo(lo,bo,jd);
|
||||
//bo+=GXCBo(lo,bo,jd)/3600;
|
||||
//lo+=Nutation2000Bi(jd);
|
||||
return bo
|
||||
geo, _ := planetApparentGeocentricPositionN(7, jd, -1)
|
||||
return geo.bo
|
||||
}
|
||||
|
||||
func NeptuneApparentLoBo(jd float64) (float64, float64) {
|
||||
x, y, z := ANeptuneXYZ(jd)
|
||||
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
|
||||
x, y, z = ANeptuneXYZ(jd - to)
|
||||
lo := math.Atan2(y, x)
|
||||
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
|
||||
lo = lo * 180 / math.Pi
|
||||
bo = bo * 180 / math.Pi
|
||||
lo = Limit360(lo)
|
||||
//lo-=GXCLo(lo,bo,jd)/3600;
|
||||
//bo+=GXCBo(lo,bo,jd);
|
||||
lo += Nutation2000Bi(jd)
|
||||
return lo, bo
|
||||
geo, _ := planetApparentGeocentricPositionN(7, jd, -1)
|
||||
return geo.lo, geo.bo
|
||||
}
|
||||
|
||||
func NeptuneMag(jd float64) float64 {
|
||||
sunDistance := NeptuneR(jd)
|
||||
earthDistance := EarthNeptuneAway(jd)
|
||||
earthSunDistance := planet.WherePlanet(-1, 2, jd)
|
||||
func NeptuneMag(jde float64) float64 {
|
||||
sunDistance := NeptuneR(jde)
|
||||
earthDistance := EarthNeptuneAway(jde)
|
||||
earthSunDistance := planet.WherePlanet(-1, 2, jde)
|
||||
i := (sunDistance*sunDistance + earthDistance*earthDistance - earthSunDistance*earthSunDistance) / (2 * sunDistance * earthDistance)
|
||||
i = ArcCos(i)
|
||||
mag := -6.87 + 5*math.Log10(sunDistance*earthDistance)
|
||||
return FloatRound(mag, 2)
|
||||
}
|
||||
|
||||
func NeptuneHeight(jde, lon, lat, timezone float64) float64 {
|
||||
func NeptuneHeight(localJD, lon, lat, timezone float64) float64 {
|
||||
// 转换为世界时
|
||||
utcJde := jde - timezone/24.0
|
||||
utcJD := localJD - timezone/24.0
|
||||
// 计算视恒星时
|
||||
ra, dec := NeptuneApparentRaDec(TD2UT(utcJde, true))
|
||||
st := Limit360(ApparentSiderealTime(utcJde)*15 + lon)
|
||||
ra, dec := NeptuneApparentRaDec(UTC2TT(utcJD))
|
||||
st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon)
|
||||
// 计算时角
|
||||
hourAngle := Limit360(st - ra)
|
||||
// 高度角、时角与天球座标三角转换公式
|
||||
@@ -160,12 +129,12 @@ func NeptuneHeight(jde, lon, lat, timezone float64) float64 {
|
||||
return ArcSin(sinHeight)
|
||||
}
|
||||
|
||||
func NeptuneAzimuth(jde, lon, lat, timezone float64) float64 {
|
||||
func NeptuneAzimuth(localJD, lon, lat, timezone float64) float64 {
|
||||
// 转换为世界时
|
||||
utcJde := jde - timezone/24.0
|
||||
utcJD := localJD - timezone/24.0
|
||||
// 计算视恒星时
|
||||
ra, dec := NeptuneApparentRaDec(TD2UT(utcJde, true))
|
||||
st := Limit360(ApparentSiderealTime(utcJde)*15 + lon)
|
||||
ra, dec := NeptuneApparentRaDec(UTC2TT(utcJD))
|
||||
st := Limit360(ApparentSiderealTime(UTC2UT1(utcJD))*15 + lon)
|
||||
// 计算时角
|
||||
hourAngle := Limit360(st - ra)
|
||||
// 三角转换公式
|
||||
@@ -184,34 +153,34 @@ func NeptuneAzimuth(jde, lon, lat, timezone float64) float64 {
|
||||
}
|
||||
|
||||
func NeptuneHourAngle(jd, lon, timezone float64) float64 {
|
||||
siderealLongitude := Limit360(ApparentSiderealTime(jd-timezone/24)*15 + lon)
|
||||
hourAngle := siderealLongitude - NeptuneApparentRa(TD2UT(jd-timezone/24.0, true))
|
||||
siderealLongitude := Limit360(ApparentSiderealTime(UTC2UT1(jd-timezone/24))*15 + lon)
|
||||
hourAngle := siderealLongitude - NeptuneApparentRa(UTC2TT(jd-timezone/24.0))
|
||||
if hourAngle < 0 {
|
||||
hourAngle += 360
|
||||
}
|
||||
return hourAngle
|
||||
}
|
||||
|
||||
func NeptuneCulminationTime(jde, lon, timezone float64) float64 {
|
||||
//jde 世界时,非力学时,当地时区 0时,无需转换力学时
|
||||
func NeptuneCulminationTime(localJD, lon, timezone float64) float64 {
|
||||
// localJD 是本地民用日锚点(当地 0 时),不是力学时。
|
||||
//ra,dec 瞬时天球座标,非J2000等时间天球坐标
|
||||
jde = math.Floor(jde) + 0.5
|
||||
estimateJD := jde + Limit360(360-NeptuneHourAngle(jde, lon, timezone))/15.0/24.0*0.99726851851851851851
|
||||
normalizedHourAngle := func(jde, lon, timezone float64) float64 {
|
||||
currentHourAngle := NeptuneHourAngle(jde, lon, timezone)
|
||||
localJD = math.Floor(localJD) + 0.5
|
||||
estimateJD := localJD + Limit360(360-NeptuneHourAngle(localJD, lon, timezone))/15.0/24.0*0.99726851851851851851
|
||||
normalizedHourAngle := func(localJD, lon, timezone float64) float64 {
|
||||
currentHourAngle := NeptuneHourAngle(localJD, lon, timezone)
|
||||
if currentHourAngle < 180 {
|
||||
currentHourAngle += 360
|
||||
}
|
||||
return currentHourAngle
|
||||
}
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
var ok bool
|
||||
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
|
||||
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
|
||||
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
|
||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
||||
break
|
||||
}
|
||||
return hourAngleDelta / hourAngleSlope
|
||||
})
|
||||
if !ok {
|
||||
return math.NaN()
|
||||
}
|
||||
return estimateJD
|
||||
}
|
||||
|
||||
+161
-84
@@ -9,7 +9,7 @@ import (
|
||||
// Pos
|
||||
|
||||
const (
|
||||
NEPTUNE_S_PERIOD = 1 / ((1 / 365.256363004) - (1 / 4332.59))
|
||||
NEPTUNE_S_PERIOD = 1 / ((1 / 365.256363004) - (1 / 60190.03))
|
||||
neptuneEventSearchN = 16
|
||||
neptunePhaseCoarseTolerance = 30.0 / 86400.0
|
||||
)
|
||||
@@ -40,8 +40,33 @@ func neptuneSunLongitudeDeltaN(jde, degree float64, filter bool, n int) float64
|
||||
return sub
|
||||
}
|
||||
|
||||
func neptuneRADerivative(jde, delta float64) float64 {
|
||||
sub := NeptuneApparentRa(jde+delta) - NeptuneApparentRa(jde-delta)
|
||||
if sub > 180 {
|
||||
sub -= 360
|
||||
}
|
||||
if sub < -180 {
|
||||
sub += 360
|
||||
}
|
||||
return sub / (2 * delta)
|
||||
}
|
||||
|
||||
func neptuneRADerivativeN(jde, delta float64, n int) float64 {
|
||||
sub := NeptuneApparentRaN(jde+delta, n) - NeptuneApparentRaN(jde-delta, n)
|
||||
if sub > 180 {
|
||||
sub -= 360
|
||||
}
|
||||
if sub < -180 {
|
||||
sub += 360
|
||||
}
|
||||
return sub / (2 * delta)
|
||||
}
|
||||
|
||||
func neptuneConjunctionFull(jde, degree float64, next uint8) float64 {
|
||||
//0=last 1=next
|
||||
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
|
||||
return math.NaN()
|
||||
}
|
||||
daysPerDegree := NEPTUNE_S_PERIOD / 360
|
||||
currentDelta := neptuneSunLongitudeDelta(jde, degree, false)
|
||||
if next == 0 {
|
||||
@@ -49,21 +74,30 @@ func neptuneConjunctionFull(jde, degree float64, next uint8) float64 {
|
||||
} else {
|
||||
jde += daysPerDegree * currentDelta
|
||||
}
|
||||
estimateJD := jde
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
longitudeDelta := neptuneSunLongitudeDelta(prevJD, degree, true)
|
||||
longitudeSlope := (neptuneSunLongitudeDelta(prevJD+0.000005, degree, true) - neptuneSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
||||
estimateJDE := jde
|
||||
converged := false
|
||||
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||
prevJDE := estimateJDE
|
||||
longitudeDelta := neptuneSunLongitudeDelta(prevJDE, degree, true)
|
||||
longitudeSlope := (neptuneSunLongitudeDelta(prevJDE+0.000005, degree, true) - neptuneSunLongitudeDelta(prevJDE-0.000005, degree, true)) / 0.00001
|
||||
nextJD := prevJDE - longitudeDelta/longitudeSlope
|
||||
estimateJDE = nextJD
|
||||
if math.Abs(nextJD-prevJDE) <= 0.00001 {
|
||||
converged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return TD2UT(estimateJD, false)
|
||||
if !converged {
|
||||
return math.NaN()
|
||||
}
|
||||
return TT2UTC(estimateJDE)
|
||||
}
|
||||
|
||||
func neptuneConjunction(jde, degree float64, next uint8) float64 {
|
||||
//0=last 1=next
|
||||
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
|
||||
return math.NaN()
|
||||
}
|
||||
daysPerDegree := NEPTUNE_S_PERIOD / 360
|
||||
currentDelta := neptuneSunLongitudeDelta(jde, degree, false)
|
||||
if next == 0 {
|
||||
@@ -71,136 +105,179 @@ func neptuneConjunction(jde, degree float64, next uint8) float64 {
|
||||
} else {
|
||||
jde += daysPerDegree * currentDelta
|
||||
}
|
||||
estimateJD := jde
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
longitudeDelta := neptuneSunLongitudeDeltaN(prevJD, degree, true, neptuneEventSearchN)
|
||||
longitudeSlope := (neptuneSunLongitudeDeltaN(prevJD+0.000005, degree, true, neptuneEventSearchN) - neptuneSunLongitudeDeltaN(prevJD-0.000005, degree, true, neptuneEventSearchN)) / 0.00001
|
||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
||||
if math.Abs(estimateJD-prevJD) <= neptunePhaseCoarseTolerance {
|
||||
estimateJDE := jde
|
||||
converged := false
|
||||
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||
prevJDE := estimateJDE
|
||||
longitudeDelta := neptuneSunLongitudeDeltaN(prevJDE, degree, true, neptuneEventSearchN)
|
||||
longitudeSlope := (neptuneSunLongitudeDeltaN(prevJDE+0.000005, degree, true, neptuneEventSearchN) - neptuneSunLongitudeDeltaN(prevJDE-0.000005, degree, true, neptuneEventSearchN)) / 0.00001
|
||||
nextJD := prevJDE - longitudeDelta/longitudeSlope
|
||||
estimateJDE = nextJD
|
||||
if math.Abs(nextJD-prevJDE) <= neptunePhaseCoarseTolerance {
|
||||
converged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
longitudeDelta := neptuneSunLongitudeDelta(prevJD, degree, true)
|
||||
longitudeSlope := (neptuneSunLongitudeDelta(prevJD+0.000005, degree, true) - neptuneSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
||||
if !converged {
|
||||
return math.NaN()
|
||||
}
|
||||
converged = false
|
||||
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||
prevJDE := estimateJDE
|
||||
longitudeDelta := neptuneSunLongitudeDelta(prevJDE, degree, true)
|
||||
longitudeSlope := (neptuneSunLongitudeDelta(prevJDE+0.000005, degree, true) - neptuneSunLongitudeDelta(prevJDE-0.000005, degree, true)) / 0.00001
|
||||
nextJD := prevJDE - longitudeDelta/longitudeSlope
|
||||
estimateJDE = nextJD
|
||||
if math.Abs(nextJD-prevJDE) <= 0.00001 {
|
||||
converged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return TD2UT(estimateJD, false)
|
||||
if !converged {
|
||||
return math.NaN()
|
||||
}
|
||||
return TT2UTC(estimateJDE)
|
||||
}
|
||||
|
||||
func LastNeptuneConjunction(jde float64) float64 {
|
||||
return neptuneConjunction(jde, 0, 0)
|
||||
return inclusiveLastPhaseEvent(jde, 0, neptuneConjunction)
|
||||
}
|
||||
|
||||
func NextNeptuneConjunction(jde float64) float64 {
|
||||
return neptuneConjunction(jde, 0, 1)
|
||||
return inclusiveNextPhaseEvent(jde, 0, neptuneConjunction)
|
||||
}
|
||||
|
||||
func LastNeptuneOpposition(jde float64) float64 {
|
||||
return neptuneConjunction(jde, 180, 0)
|
||||
return inclusiveLastPhaseEvent(jde, 180, neptuneConjunction)
|
||||
}
|
||||
|
||||
func NextNeptuneOpposition(jde float64) float64 {
|
||||
return neptuneConjunction(jde, 180, 1)
|
||||
return inclusiveNextPhaseEvent(jde, 180, neptuneConjunction)
|
||||
}
|
||||
|
||||
func NextNeptuneEasternQuadrature(jde float64) float64 {
|
||||
return neptuneConjunction(jde, 90, 1)
|
||||
return inclusiveNextPhaseEvent(jde, 90, neptuneConjunction)
|
||||
}
|
||||
|
||||
func LastNeptuneEasternQuadrature(jde float64) float64 {
|
||||
return neptuneConjunction(jde, 90, 0)
|
||||
return inclusiveLastPhaseEvent(jde, 90, neptuneConjunction)
|
||||
}
|
||||
|
||||
func NextNeptuneWesternQuadrature(jde float64) float64 {
|
||||
return neptuneConjunction(jde, 270, 1)
|
||||
return inclusiveNextPhaseEvent(jde, 270, neptuneConjunction)
|
||||
}
|
||||
|
||||
func LastNeptuneWesternQuadrature(jde float64) float64 {
|
||||
return neptuneConjunction(jde, 270, 0)
|
||||
return inclusiveLastPhaseEvent(jde, 270, neptuneConjunction)
|
||||
}
|
||||
|
||||
func neptuneRetrograde(jde float64, searchBeforeOpposition bool) float64 {
|
||||
//0=last 1=next
|
||||
raRate := func(jde float64, delta float64) float64 {
|
||||
sub := NeptuneApparentRa(jde+delta) - NeptuneApparentRa(jde-delta)
|
||||
if sub > 180 {
|
||||
sub -= 360
|
||||
}
|
||||
if sub < -180 {
|
||||
sub += 360
|
||||
}
|
||||
return sub / (2 * delta)
|
||||
func neptuneRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool) float64 {
|
||||
if !isFiniteFloat(oppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
jde = neptuneConjunctionFull(jde, 180, 1)
|
||||
oppositionTT := UTC2TT(oppositionJD)
|
||||
startTT := oppositionTT
|
||||
endTT := oppositionTT
|
||||
if searchBeforeOpposition {
|
||||
jde -= 60
|
||||
easternQuadratureUT := neptuneConjunction(oppositionTT, 90, 0)
|
||||
startTT = UTC2TT(easternQuadratureUT)
|
||||
} else {
|
||||
jde += 60
|
||||
westernQuadratureUT := neptuneConjunction(oppositionTT, 270, 1)
|
||||
endTT = UTC2TT(westernQuadratureUT)
|
||||
}
|
||||
for {
|
||||
currentRate := raRate(jde, 1.0/86400.0)
|
||||
if math.Abs(currentRate) > 0.55 {
|
||||
jde += 2
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
estimateJD := jde
|
||||
for {
|
||||
prevJD := estimateJD
|
||||
rateValue := raRate(prevJD, 2.0/86400.0)
|
||||
rateSlope := (raRate(prevJD+15.0/86400.0, 2.0/86400.0) - raRate(prevJD-15.0/86400.0, 2.0/86400.0)) / (30.0 / 86400.0)
|
||||
estimateJD = prevJD - rateValue/rateSlope
|
||||
if math.Abs(estimateJD-prevJD) <= 30.0/86400.0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
bestJD := eventZeroRefine(estimateJD, 15.0/86400.0, 0.5/86400.0, func(jd float64) float64 {
|
||||
return raRate(jd, 0.5/86400.0)
|
||||
bestJDE := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 {
|
||||
return neptuneRADerivativeN(jd, stationDerivativeStepDay, neptuneEventSearchN)
|
||||
}, func(jd float64) float64 {
|
||||
return neptuneRADerivative(jd, stationDerivativeStepDay)
|
||||
})
|
||||
return TD2UT(bestJD, false)
|
||||
return TT2UTC(bestJDE)
|
||||
}
|
||||
|
||||
func NextNeptuneRetrogradeToPrograde(jde float64) float64 {
|
||||
date := neptuneRetrograde(jde, false)
|
||||
if date < jde {
|
||||
oppositionJD := neptuneConjunctionFull(jde, 180, 1)
|
||||
return neptuneRetrograde(oppositionJD+10, false)
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := neptuneConjunctionFull(jde, 180, 0)
|
||||
date := neptuneRetrogradeAroundOpposition(lastOppositionJD, false)
|
||||
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
nextOppositionJD := neptuneConjunctionFull(jde, 180, 1)
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = neptuneRetrogradeAroundOpposition(nextOppositionJD, false)
|
||||
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func LastNeptuneRetrogradeToPrograde(jde float64) float64 {
|
||||
jde = neptuneConjunctionFull(jde, 180, 0) - 10
|
||||
date := neptuneRetrograde(jde, false)
|
||||
if date > jde {
|
||||
oppositionJD := neptuneConjunctionFull(jde, 180, 0)
|
||||
return neptuneRetrograde(oppositionJD-10, false)
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := neptuneConjunctionFull(jde, 180, 0)
|
||||
date := neptuneRetrogradeAroundOpposition(lastOppositionJD, false)
|
||||
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(lastOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
previousOppositionJD := neptuneConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0)
|
||||
if !isFiniteFloat(previousOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = neptuneRetrogradeAroundOpposition(previousOppositionJD, false)
|
||||
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func NextNeptuneProgradeToRetrograde(jde float64) float64 {
|
||||
date := neptuneRetrograde(jde, true)
|
||||
if date < jde {
|
||||
oppositionJD := neptuneConjunctionFull(jde, 180, 1)
|
||||
return neptuneRetrograde(oppositionJD+10, true)
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
nextOppositionJD := neptuneConjunctionFull(jde, 180, 1)
|
||||
date := neptuneRetrogradeAroundOpposition(nextOppositionJD, true)
|
||||
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
followingOppositionJD := neptuneConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1)
|
||||
if !isFiniteFloat(followingOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = neptuneRetrogradeAroundOpposition(followingOppositionJD, true)
|
||||
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func LastNeptuneProgradeToRetrograde(jde float64) float64 {
|
||||
jde = neptuneConjunctionFull(jde, 180, 0) - 10
|
||||
date := neptuneRetrograde(jde, true)
|
||||
if date > jde {
|
||||
oppositionJD := neptuneConjunctionFull(jde, 180, 0)
|
||||
return neptuneRetrograde(oppositionJD-10, true)
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
nextOppositionJD := neptuneConjunctionFull(jde, 180, 1)
|
||||
date := neptuneRetrogradeAroundOpposition(nextOppositionJD, true)
|
||||
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := neptuneConjunctionFull(jde, 180, 0)
|
||||
if !isFiniteFloat(lastOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = neptuneRetrogradeAroundOpposition(lastOppositionJD, true)
|
||||
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
+12
-4
@@ -15,8 +15,8 @@ func EclipticObliquity(jde float64, nutation bool) float64 {
|
||||
return eps
|
||||
}
|
||||
|
||||
func TrueObliquity(JD float64) float64 {
|
||||
return EclipticObliquity(JD, true)
|
||||
func TrueObliquity(JDE float64) float64 {
|
||||
return EclipticObliquity(JDE, true)
|
||||
}
|
||||
|
||||
// 黄经章动 1980
|
||||
@@ -209,9 +209,17 @@ func Nutation1980(jd float64) (float64, float64) {
|
||||
return dpsi * deg, deps * deg
|
||||
}
|
||||
|
||||
// Nutation2000B 计算 IAU 2000B 章动模型
|
||||
// 返回交角章动 (de) 和黄经章动 (dp),单位为度
|
||||
// Nutation2000B IAU 2000B 章动,返回 (黄经章动, 交角章动),单位度 / nutation in longitude and obliquity in degrees.
|
||||
func Nutation2000B(jd float64) (float64, float64) {
|
||||
if dpsi, deps, ok := nutationMemoLoad(jd); ok {
|
||||
return dpsi, deps
|
||||
}
|
||||
dpsi, deps := nutation2000BCompute(jd)
|
||||
nutationMemoStore(jd, dpsi, deps)
|
||||
return dpsi, deps
|
||||
}
|
||||
|
||||
func nutation2000BCompute(jd float64) (float64, float64) {
|
||||
// 常量定义
|
||||
as2r := 4.848136811095359935899141e-6 // 角秒到弧度的转换因子
|
||||
twopi := 6.283185307179586476925287 // 2π
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Nutation2000B 是 jd 的纯函数,但升落、星历与掩星路径会对同一批瞬时反复求值:一条计算链里
|
||||
// 地球自转、状态上下文与几何装配各自求一次同一个瞬时。这里用有界直接映射表记住结果:
|
||||
// 槽位固定、无分配、RWMutex 保证 c-shared 宿主多线程安全。章动只依赖传入的 jd,与 ΔT 世代无关。
|
||||
// Nutation2000B is a pure function of jd, yet the rise/set, ephemeris and occultation chains evaluate
|
||||
// it repeatedly for the same instants from independent code paths. This bounded direct-mapped memo
|
||||
// removes that redundancy with fixed slots, no allocation and an RWMutex for the c-shared host.
|
||||
// Nutation depends only on its argument, so no ΔT generation stamp is needed.
|
||||
const nutationMemoBits = 13
|
||||
|
||||
const nutationMemoSize = 1 << nutationMemoBits
|
||||
|
||||
type nutationMemoEntry struct {
|
||||
// key 是 math.Float64bits(jd)+1,0 表示空槽(避免 jd=0 与空槽同码)。
|
||||
key uint64
|
||||
dpsi, deps float64
|
||||
}
|
||||
|
||||
var (
|
||||
nutationMemoMu sync.RWMutex
|
||||
nutationMemoTable [nutationMemoSize]nutationMemoEntry
|
||||
nutationMemoHits uint64
|
||||
nutationMemoMiss uint64
|
||||
)
|
||||
|
||||
func nutationMemoIndex(jd float64) uint64 {
|
||||
bits := math.Float64bits(jd)
|
||||
return (bits ^ (bits >> 31)) & (nutationMemoSize - 1)
|
||||
}
|
||||
|
||||
func nutationMemoLoad(jd float64) (float64, float64, bool) {
|
||||
key := math.Float64bits(jd) + 1
|
||||
entry := &nutationMemoTable[nutationMemoIndex(jd)]
|
||||
nutationMemoMu.RLock()
|
||||
entryKey, dpsi, deps := entry.key, entry.dpsi, entry.deps
|
||||
nutationMemoMu.RUnlock()
|
||||
if key != 0 && entryKey == key {
|
||||
atomic.AddUint64(&nutationMemoHits, 1)
|
||||
return dpsi, deps, true
|
||||
}
|
||||
atomic.AddUint64(&nutationMemoMiss, 1)
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func nutationMemoStore(jd, dpsi, deps float64) {
|
||||
key := math.Float64bits(jd) + 1
|
||||
if key == 0 {
|
||||
return
|
||||
}
|
||||
nutationMemoMu.Lock()
|
||||
nutationMemoTable[nutationMemoIndex(jd)] = nutationMemoEntry{key: key, dpsi: dpsi, deps: deps}
|
||||
nutationMemoMu.Unlock()
|
||||
}
|
||||
|
||||
// nutationMemoStats 返回命中/未命中计数,供测试守护命中率。
|
||||
func nutationMemoStats() (uint64, uint64) {
|
||||
return atomic.LoadUint64(&nutationMemoHits), atomic.LoadUint64(&nutationMemoMiss)
|
||||
}
|
||||
|
||||
func resetNutationMemo() {
|
||||
nutationMemoMu.Lock()
|
||||
for i := range nutationMemoTable {
|
||||
nutationMemoTable[i] = nutationMemoEntry{}
|
||||
}
|
||||
nutationMemoMu.Unlock()
|
||||
atomic.StoreUint64(&nutationMemoHits, 0)
|
||||
atomic.StoreUint64(&nutationMemoMiss, 0)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 记忆表必须逐位精确:命中返回值与重新展开 77 项级数完全一致,且相邻 1 ULP 的输入互不干扰。
|
||||
// The memo must be bit-exact: a hit returns exactly what the 77-term series would recompute, and
|
||||
// inputs one ULP apart keep independent entries.
|
||||
func TestNutationMemoIsBitExact(t *testing.T) {
|
||||
values := []float64{
|
||||
2451545.0, 2460310.5, 0, -1000000.5, -4713.5, 5373484.5,
|
||||
math.NaN(), math.Inf(1), math.Inf(-1), math.SmallestNonzeroFloat64,
|
||||
}
|
||||
for index := 0; index < 5000; index++ {
|
||||
values = append(values, 1000000.0+float64(index)*0.37)
|
||||
}
|
||||
for _, jd := range values {
|
||||
memoPsi, memoEps := Nutation2000B(jd)
|
||||
directPsi, directEps := nutation2000BCompute(jd)
|
||||
if math.Float64bits(memoPsi) != math.Float64bits(directPsi) ||
|
||||
math.Float64bits(memoEps) != math.Float64bits(directEps) {
|
||||
t.Fatalf("jd=%v: memo (%v,%v) != direct (%v,%v)", jd, memoPsi, memoEps, directPsi, directEps)
|
||||
}
|
||||
againPsi, againEps := Nutation2000B(jd)
|
||||
if math.Float64bits(againPsi) != math.Float64bits(memoPsi) ||
|
||||
math.Float64bits(againEps) != math.Float64bits(memoEps) {
|
||||
t.Fatalf("jd=%v: repeated call changed the value", jd)
|
||||
}
|
||||
// 相邻 1 ULP 的输入必须各自独立(不能被同一槽位合并)。
|
||||
neighbour := math.Nextafter(jd, math.Inf(1))
|
||||
if math.IsNaN(jd) || math.IsInf(jd, 0) {
|
||||
continue
|
||||
}
|
||||
nearPsi, _ := Nutation2000B(neighbour)
|
||||
nearDirect, _ := nutation2000BCompute(neighbour)
|
||||
if math.Float64bits(nearPsi) != math.Float64bits(nearDirect) {
|
||||
t.Fatalf("jd=%v neighbour: memo (%v) != direct (%v)", jd, nearPsi, nearDirect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 章动只依赖传入的 jd:ΔT 覆盖不改变它,因此记忆表不需要世代戳(与视恒星时不同)。
|
||||
func TestNutationMemoIgnoresDeltaTGeneration(t *testing.T) {
|
||||
original := GetDeltaTFn()
|
||||
defer SetDeltaTFn(original)
|
||||
jd := 2460310.5
|
||||
beforePsi, beforeEps := Nutation2000B(jd)
|
||||
SetDeltaTFn(func(date float64, isJd bool) float64 { return 6000 })
|
||||
afterPsi, afterEps := Nutation2000B(jd)
|
||||
if math.Float64bits(beforePsi) != math.Float64bits(afterPsi) ||
|
||||
math.Float64bits(beforeEps) != math.Float64bits(afterEps) {
|
||||
t.Fatalf("nutation changed with the ΔT generation: (%v,%v) vs (%v,%v)", beforePsi, beforeEps, afterPsi, afterEps)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/tools"
|
||||
)
|
||||
|
||||
// ErrInvalidOccultationInput 表示月掩输入契约无效。
|
||||
// ErrInvalidOccultationInput reports invalid lunar-occultation contract input.
|
||||
var ErrInvalidOccultationInput = errors.New("invalid lunar occultation input")
|
||||
|
||||
// ErrOccultationPathSamplingLimit 表示请求的时间步长、中心线间距或有限盘面采样超过确定性的工作量或输出预算。
|
||||
// ErrOccultationPathSamplingLimit reports that the requested time step, center-line spacing, or aggregate finite-disk sampling would exceed the implementation's deterministic work or output budget.
|
||||
var ErrOccultationPathSamplingLimit = errors.New("lunar occultation path sampling limit exceeded")
|
||||
|
||||
const (
|
||||
occultationSearchMinimumStep = 250 * time.Millisecond
|
||||
occultationPathMinimumStep = time.Second
|
||||
occultationPathMinimumTargetSpacingKM = 1.0
|
||||
occultationEventSelectionTolerance = 10 * time.Millisecond
|
||||
occultationEventSelectionToleranceDays = float64(occultationEventSelectionTolerance) / float64(24*time.Hour)
|
||||
// 银河系内恒星的径向速度上限,仅用于挡掉明显填错的输入。
|
||||
starRadialVelocityLimitKmPerSecond = 1000.0
|
||||
)
|
||||
|
||||
// CoordinateFrame 标识恒星输入坐标使用的赤道坐标系。
|
||||
// CoordinateFrame identifies the equatorial coordinate frame used by an input stellar coordinate.
|
||||
type CoordinateFrame string
|
||||
|
||||
const (
|
||||
// CoordinateFrameICRS 表示 ICRS 星表坐标系。
|
||||
// CoordinateFrameICRS is the ICRS catalog frame.
|
||||
CoordinateFrameICRS CoordinateFrame = "icrs"
|
||||
// CoordinateFrameJ2000 表示 J2000 平均赤道坐标系。
|
||||
// CoordinateFrameJ2000 is the mean equatorial J2000 frame.
|
||||
CoordinateFrameJ2000 CoordinateFrame = "j2000"
|
||||
// CoordinateFrameApparentOfDate 表示历元时刻的视赤道坐标系。
|
||||
// CoordinateFrameApparentOfDate is the apparent equatorial frame of date.
|
||||
CoordinateFrameApparentOfDate CoordinateFrame = "apparent_of_date"
|
||||
)
|
||||
|
||||
// OccultationType 标识月掩结果的几何类型。
|
||||
// OccultationType identifies the result geometry.
|
||||
type OccultationType string
|
||||
|
||||
const (
|
||||
// OccultationTotal 表示掩甚时目标盘面被完全覆盖。
|
||||
// OccultationTotal means the target disk is fully covered at greatest occultation.
|
||||
OccultationTotal OccultationType = "total"
|
||||
// OccultationPartial 表示掩甚时有限目标盘面只有部分被覆盖。
|
||||
// OccultationPartial means only part of a finite target disk is covered at greatest occultation.
|
||||
OccultationPartial OccultationType = "partial"
|
||||
// OccultationGrazing 表示两边缘相切,且没有正持续时间的重叠。
|
||||
// OccultationGrazing means the limbs are tangent without a positive-duration overlap.
|
||||
OccultationGrazing OccultationType = "grazing"
|
||||
)
|
||||
|
||||
// OccultationPlanet 标识有限盘面的行星目标。
|
||||
// OccultationPlanet identifies a finite-disk planetary target.
|
||||
type OccultationPlanet string
|
||||
|
||||
const (
|
||||
// OccultationMercury 表示水星有限盘面目标。
|
||||
// OccultationMercury identifies Mercury as the finite-disk target.
|
||||
OccultationMercury OccultationPlanet = "mercury"
|
||||
// OccultationVenus 表示金星有限盘面目标。
|
||||
// OccultationVenus identifies Venus as the finite-disk target.
|
||||
OccultationVenus OccultationPlanet = "venus"
|
||||
// OccultationMars 表示火星有限盘面目标。
|
||||
// OccultationMars identifies Mars as the finite-disk target.
|
||||
OccultationMars OccultationPlanet = "mars"
|
||||
// OccultationJupiter 表示木星有限盘面目标。
|
||||
// OccultationJupiter identifies Jupiter as the finite-disk target.
|
||||
OccultationJupiter OccultationPlanet = "jupiter"
|
||||
// OccultationSaturn 表示土星有限盘面目标。
|
||||
// OccultationSaturn identifies Saturn as the finite-disk target.
|
||||
OccultationSaturn OccultationPlanet = "saturn"
|
||||
// OccultationUranus 表示天王星有限盘面目标。
|
||||
// OccultationUranus identifies Uranus as the finite-disk target.
|
||||
OccultationUranus OccultationPlanet = "uranus"
|
||||
// OccultationNeptune 表示海王星有限盘面目标。
|
||||
// OccultationNeptune identifies Neptune as the finite-disk target.
|
||||
OccultationNeptune OccultationPlanet = "neptune"
|
||||
)
|
||||
|
||||
// String 返回结果标识中使用的英文目标名称。
|
||||
// String returns the English target name used in result identifiers.
|
||||
func (p OccultationPlanet) String() string {
|
||||
switch p {
|
||||
case OccultationMercury:
|
||||
return "Mercury"
|
||||
case OccultationVenus:
|
||||
return "Venus"
|
||||
case OccultationMars:
|
||||
return "Mars"
|
||||
case OccultationJupiter:
|
||||
return "Jupiter"
|
||||
case OccultationSaturn:
|
||||
return "Saturn"
|
||||
case OccultationUranus:
|
||||
return "Uranus"
|
||||
case OccultationNeptune:
|
||||
return "Neptune"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Validate 检查行星目标是否受支持。
|
||||
// Validate checks whether the planetary target is supported.
|
||||
func (p OccultationPlanet) Validate() error {
|
||||
if p.String() == "" {
|
||||
return fmt.Errorf("%w: unsupported occultation planet %q", ErrInvalidOccultationInput, p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOccultationTimeRange(start, end time.Time) error {
|
||||
if start.IsZero() || end.IsZero() {
|
||||
return fmt.Errorf("%w: start and end are required", ErrInvalidOccultationInput)
|
||||
}
|
||||
if !end.After(start) {
|
||||
return fmt.Errorf("%w: end must be after start", ErrInvalidOccultationInput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func occultationTimeInSelectionWindow(value, start, end time.Time) bool {
|
||||
return value.Sub(start) >= -occultationEventSelectionTolerance &&
|
||||
value.Sub(end) <= occultationEventSelectionTolerance
|
||||
}
|
||||
|
||||
// Observer 描述站心观测地点。
|
||||
// Observer describes the topocentric observing site.
|
||||
type Observer struct {
|
||||
// Longitude 是经度,东经为正,单位为度。
|
||||
// Longitude is east-positive, in degrees.
|
||||
Longitude float64
|
||||
// Latitude 是纬度,北纬为正,单位为度。
|
||||
// Latitude is north-positive, in degrees.
|
||||
Latitude float64
|
||||
// Height 是观测者相对平均海平面的高度,单位为米。
|
||||
// Height is the observer elevation above mean sea level, in meters.
|
||||
Height float64
|
||||
}
|
||||
|
||||
// moonTopocentricSemidiameterN 返回指定地点看到的月球角半径。
|
||||
// 通用 MoonSemidiameterN 使用地心距离;月掩接触使用同一站心视差修正后的观测者到月球距离。
|
||||
// moonTopocentricSemidiameterN returns the lunar angular radius as seen from the supplied site.
|
||||
// The usual MoonSemidiameterN uses geocentric distance; occultation contacts use the observer-to-Moon distance after the same topocentric parallax correction as the direction.
|
||||
func moonTopocentricSemidiameterN(tt float64, observer Observer, n int) float64 {
|
||||
moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, n)
|
||||
moonDistanceKM := HMoonAwayN(tt, n)
|
||||
if !finite(moonRA) || !finite(moonDec) || !finite(moonDistanceKM) || moonDistanceKM <= 0 {
|
||||
return math.NaN()
|
||||
}
|
||||
distanceKM := topocentricDistanceKM(moonRA, moonDec, moonDistanceKM, observer, TT2UTC(tt))
|
||||
if !finite(distanceKM) || distanceKM <= 0 {
|
||||
return math.NaN()
|
||||
}
|
||||
return angularSemidiameterArcsec(moonEquatorialRadiusKM, distanceKM)
|
||||
}
|
||||
|
||||
// topocentricDistanceKM 使用与 TopocentricRaDec 相同的 WGS-84 风格站点因子计算观测者到目标的距离。
|
||||
// 目标采用视赤道坐标,恒星时与 TopocentricRaDec 一样基于 UTC/UT。
|
||||
// topocentricDistanceKM uses the same WGS-84-style site factors as TopocentricRaDec.
|
||||
// The target uses apparent equatorial coordinates, and the sidereal angle is based on UTC/UT.
|
||||
func topocentricDistanceKM(ra, dec, distanceKM float64, observer Observer, ut float64) float64 {
|
||||
return topocentricDistanceKMWithSidereal(ra, dec, distanceKM, observer, ApparentSiderealTime(UTC2UT1(ut))*15)
|
||||
}
|
||||
|
||||
func topocentricDistanceKMWithSidereal(
|
||||
ra, dec, distanceKM float64,
|
||||
observer Observer,
|
||||
siderealDegrees float64,
|
||||
) float64 {
|
||||
const earthEquatorialRadius = 6378.14
|
||||
const astronomicalUnitKM = angularDiameterAstronomicalUnitKM
|
||||
|
||||
distanceAU := distanceKM / astronomicalUnitKM
|
||||
if distanceAU <= 0 {
|
||||
return math.NaN()
|
||||
}
|
||||
raRad := ra * math.Pi / 180
|
||||
decRad := dec * math.Pi / 180
|
||||
moon := [3]float64{
|
||||
distanceAU * math.Cos(decRad) * math.Cos(raRad),
|
||||
distanceAU * math.Cos(decRad) * math.Sin(raRad),
|
||||
distanceAU * math.Sin(decRad),
|
||||
}
|
||||
theta := (siderealDegrees + observer.Longitude) * math.Pi / 180
|
||||
observerAU := earthEquatorialRadius / astronomicalUnitKM
|
||||
observerVector := [3]float64{
|
||||
observerAU * pcosi(observer.Latitude, observer.Height) * math.Cos(theta),
|
||||
observerAU * pcosi(observer.Latitude, observer.Height) * math.Sin(theta),
|
||||
observerAU * psini(observer.Latitude, observer.Height),
|
||||
}
|
||||
dx := moon[0] - observerVector[0]
|
||||
dy := moon[1] - observerVector[1]
|
||||
dz := moon[2] - observerVector[2]
|
||||
return math.Sqrt(dx*dx+dy*dy+dz*dz) * astronomicalUnitKM
|
||||
}
|
||||
|
||||
// Validate 检查站心计算所需的地理范围。
|
||||
// Validate checks the geographic bounds needed by topocentric calculations.
|
||||
func (o Observer) Validate() error {
|
||||
if !finite(o.Longitude) || !finite(o.Latitude) || !finite(o.Height) {
|
||||
return fmt.Errorf("%w: observer values must be finite", ErrInvalidOccultationInput)
|
||||
}
|
||||
if o.Longitude < -180 || o.Longitude > 180 {
|
||||
return fmt.Errorf("%w: observer longitude must be in [-180, 180]", ErrInvalidOccultationInput)
|
||||
}
|
||||
if o.Latitude < -90 || o.Latitude > 90 {
|
||||
return fmt.Errorf("%w: observer latitude must be in [-90, 90]", ErrInvalidOccultationInput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StarCoordinate 是调用者为恒星提供的星表坐标或视位置坐标。
|
||||
// RA 和 Dec 的单位为度;ProperMotionRACosDecMasPerYear 使用星表常见的 dRA*cos(Dec) 约定,单位为毫角秒/年。
|
||||
// StarCoordinate is a catalog or apparent coordinate supplied for a star.
|
||||
// RA and Dec are degrees; ProperMotionRACosDecMasPerYear uses the usual catalog convention of dRA*cos(Dec), in milliarcseconds per year.
|
||||
type StarCoordinate struct {
|
||||
ID string
|
||||
|
||||
RA float64
|
||||
Dec float64
|
||||
Epoch time.Time
|
||||
Frame CoordinateFrame
|
||||
|
||||
ProperMotionRACosDecMasPerYear float64
|
||||
ProperMotionDecMasPerYear float64
|
||||
// ParallaxMas 为周年视差,单位毫角秒;0 表示未提供距离,此时退回二维自行传播。
|
||||
// ParallaxMas is the annual parallax in milliarcseconds; 0 means no distance is given and the
|
||||
// two-dimensional proper-motion path is used.
|
||||
ParallaxMas float64
|
||||
// DistanceLightYear 是 ParallaxMas 的替代输入,单位光年;仅当 ParallaxMas 为 0 时生效,0 表示未提供。
|
||||
// DistanceLightYear is an alternative to ParallaxMas in light-years; it applies only when
|
||||
// ParallaxMas is 0, and 0 means it was not given.
|
||||
DistanceLightYear float64
|
||||
// RadialVelocityKmPerSecond 单位千米/秒,0 合法,只在距离已知时参与三维空间运动。
|
||||
// RadialVelocityKmPerSecond is in kilometres per second; 0 is valid and it enters the
|
||||
// three-dimensional motion only when a distance is known.
|
||||
RadialVelocityKmPerSecond float64
|
||||
}
|
||||
|
||||
// Validate 在构造目标前检查恒星坐标契约。
|
||||
// Validate checks the coordinate contract before a target is constructed.
|
||||
func (s StarCoordinate) Validate() error {
|
||||
if !finite(s.RA) || s.RA < 0 || s.RA >= 360 {
|
||||
return fmt.Errorf("%w: star RA must be in [0, 360)", ErrInvalidOccultationInput)
|
||||
}
|
||||
if !finite(s.Dec) || s.Dec < -90 || s.Dec > 90 {
|
||||
return fmt.Errorf("%w: star Dec must be in [-90, 90]", ErrInvalidOccultationInput)
|
||||
}
|
||||
if s.Epoch.IsZero() {
|
||||
return fmt.Errorf("%w: star epoch is required", ErrInvalidOccultationInput)
|
||||
}
|
||||
if !validCoordinateFrame(s.Frame) {
|
||||
return fmt.Errorf("%w: unsupported star coordinate frame %q", ErrInvalidOccultationInput, s.Frame)
|
||||
}
|
||||
if !finite(s.ProperMotionRACosDecMasPerYear) || !finite(s.ProperMotionDecMasPerYear) {
|
||||
return fmt.Errorf("%w: star proper motion must be finite", ErrInvalidOccultationInput)
|
||||
}
|
||||
if !finite(s.ParallaxMas) || s.ParallaxMas < 0 {
|
||||
return fmt.Errorf("%w: star parallax must be finite and non-negative", ErrInvalidOccultationInput)
|
||||
}
|
||||
if !finite(s.DistanceLightYear) || s.DistanceLightYear < 0 {
|
||||
return fmt.Errorf("%w: star distance in light-years must be finite and non-negative", ErrInvalidOccultationInput)
|
||||
}
|
||||
if !finite(s.RadialVelocityKmPerSecond) || math.Abs(s.RadialVelocityKmPerSecond) > starRadialVelocityLimitKmPerSecond {
|
||||
return fmt.Errorf("%w: star radial velocity must be finite and within +/-%.0f km/s", ErrInvalidOccultationInput, starRadialVelocityLimitKmPerSecond)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parallaxMas 返回生效的视差:显式视差优先,否则由光年距离折算。
|
||||
func (s StarCoordinate) parallaxMas() float64 {
|
||||
if s.ParallaxMas > 0 {
|
||||
return s.ParallaxMas
|
||||
}
|
||||
if s.DistanceLightYear <= 0 {
|
||||
return 0
|
||||
}
|
||||
return 1000 / tools.DistanceToParsecs(s.DistanceLightYear, tools.DistanceLightYear)
|
||||
}
|
||||
|
||||
// StarCoordinateFromStarData 将一条内嵌星表记录转换为月掩搜索使用的 J2000 坐标契约。
|
||||
// 星表自行从角秒/年转换为毫角秒/年;正的秒差距距离转换为毫角秒年视差。本函数只转换传入值,不会加载星表。
|
||||
// StarCoordinateFromStarData converts one embedded-catalog entry into the J2000 coordinate contract used by lunar-occultation searches.
|
||||
// The catalog's proper motions are converted from arcseconds/year to milliarcseconds/year; a positive parsec distance is converted to annual parallax in milliarcseconds.
|
||||
// This function only converts the supplied value and never loads the catalog.
|
||||
func StarCoordinateFromStarData(star StarData) (StarCoordinate, error) {
|
||||
if star.HR == 0 {
|
||||
return StarCoordinate{}, fmt.Errorf("%w: star catalog HR number is required", ErrInvalidOccultationInput)
|
||||
}
|
||||
if !finite(star.Pc) || star.Pc < 0 {
|
||||
return StarCoordinate{}, fmt.Errorf("%w: star distance must be finite and non-negative", ErrInvalidOccultationInput)
|
||||
}
|
||||
|
||||
parallaxMas := 0.0
|
||||
if star.Pc > 0 {
|
||||
parallaxMas = 1000 / star.Pc
|
||||
}
|
||||
coordinate := StarCoordinate{
|
||||
ID: starCoordinateIDFromStarData(star),
|
||||
RA: star.Ra,
|
||||
Dec: star.Dec,
|
||||
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
|
||||
Frame: CoordinateFrameJ2000,
|
||||
ProperMotionRACosDecMasPerYear: star.PmRA * 1000,
|
||||
ProperMotionDecMasPerYear: star.PmDec * 1000,
|
||||
ParallaxMas: parallaxMas,
|
||||
RadialVelocityKmPerSecond: star.RadVel,
|
||||
}
|
||||
if err := coordinate.Validate(); err != nil {
|
||||
return StarCoordinate{}, fmt.Errorf("convert star catalog coordinate: %w", err)
|
||||
}
|
||||
return coordinate, nil
|
||||
}
|
||||
|
||||
func starCoordinateIDFromStarData(star StarData) string {
|
||||
for _, name := range []string{star.ChineseName, star.ChineseAlias, star.CommonName, star.Name} {
|
||||
if name = strings.TrimSpace(name); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
if star.HR > 0 {
|
||||
return fmt.Sprintf("HR %d", star.HR)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// OccultationSearchOptions 控制固定目标和行星月掩搜索。
|
||||
// 零值使用实现默认值;MaxEvents == 0 表示不限制数量。
|
||||
// OccultationSearchOptions controls fixed-target and planetary occultation searches.
|
||||
// Zero values select implementation defaults; MaxEvents == 0 means unlimited.
|
||||
type OccultationSearchOptions struct {
|
||||
// MaxStep 是粗略搜索的最大步长;小于 250ms 的正值会被拒绝,因为在支持的时间范围内无法可靠地用儒略日浮点数推进。
|
||||
// MaxStep is the maximum coarse-search step. Positive values below 250 ms are rejected because they cannot be advanced reliably in Julian-day floating-point arithmetic over the supported time span.
|
||||
MaxStep time.Duration
|
||||
// SafetyMarginArcsec 是加入粗略候选和黄纬预筛的安全余量,单位为角秒。
|
||||
// SafetyMarginArcsec is added to coarse candidate and latitude prefilters.
|
||||
SafetyMarginArcsec float64
|
||||
// MaxEvents 为正时限制返回事件数量。
|
||||
// MaxEvents limits the number of returned events when positive.
|
||||
MaxEvents int
|
||||
}
|
||||
|
||||
// OccultationPathAlgorithm 选择全球月掩路径的星历求解分支。
|
||||
// OccultationPathAlgorithm selects the ephemeris branch for global occultation paths.
|
||||
type OccultationPathAlgorithm string
|
||||
|
||||
const (
|
||||
// OccultationPathAlgorithmOptimized 使用经抽检的密集星历插值,保留站心方程与连续包络。
|
||||
// OccultationPathAlgorithmOptimized uses checked dense ephemeris interpolation with the same station equations and continuous envelopes.
|
||||
OccultationPathAlgorithmOptimized OccultationPathAlgorithm = "optimized"
|
||||
// OccultationPathAlgorithmExact 保留原分支:插值预测候选,最终求解使用全项星历。
|
||||
// OccultationPathAlgorithmExact retains the original branch: interpolated candidates and full-term ephemerides for final solving.
|
||||
OccultationPathAlgorithmExact OccultationPathAlgorithm = "exact"
|
||||
)
|
||||
|
||||
// OccultationPathOptions 控制全球月掩路径采样。
|
||||
//
|
||||
// Step 为路径采样的基础时间步长,正值至少为 1 秒。TargetSpacingKM 要求相邻中心线点超过目标地面距离时进行自适应加密。
|
||||
// 正的 TargetSpacingKM 至少为 1 km;超过中心线或有限盘面路径工作量预算时返回 ErrOccultationPathSamplingLimit,不会静默降低请求分辨率。恒星和行星瞬时足迹使用 1 分钟目标步长和独立样本上限。
|
||||
// OccultationPathOptions controls global occultation-path sampling.
|
||||
// Step is the base time step used for path samples; positive values must be at least one second. TargetSpacingKM requests adaptive refinement when adjacent center-line points exceed the requested ground distance.
|
||||
// Positive TargetSpacingKM values must be at least 1 km. Requests that exceed the center-line or aggregate finite-disk work budgets return ErrOccultationPathSamplingLimit instead of silently reducing resolution. Stellar and planetary instantaneous footprints use a one-minute target step and a separate sample cap.
|
||||
type OccultationPathOptions struct {
|
||||
// Algorithm 的零值等同 optimized;exact 可选择原有精确分支。此选项不影响独立的单时刻月影与事件查询接口。
|
||||
// Algorithm defaults to optimized; exact selects the original branch. Independent instant-footprint and event-search APIs are unaffected.
|
||||
Algorithm OccultationPathAlgorithm
|
||||
Step time.Duration
|
||||
TargetSpacingKM float64
|
||||
// RiseSetStep 独立控制六类升落阶段线的采样步长;零值使用 5 分钟。
|
||||
// RiseSetStep controls horizon-curve sampling independently from the path step; zero uses five minutes.
|
||||
RiseSetStep time.Duration
|
||||
// DisableRiseSet 在不需要时跳过六类初掩、掩甚、终掩月升/月落线。
|
||||
// DisableRiseSet skips the six local phase/horizon curves when they are not needed.
|
||||
DisableRiseSet bool
|
||||
// DisableFootprints 跳过密集的独立瞬时可见区,改用稀疏支撑样本构造紧凑掩带;中心线、边界和升落阶段线仍保留。
|
||||
// DisableFootprints skips dense standalone instantaneous visible regions and uses sparse support samples to construct compact bands; the center line, limits, and rise/set curves remain available.
|
||||
DisableFootprints bool
|
||||
// IncludeFootprintTimeline 在保留紧凑静态掩带的同时,增加独立采样的瞬时足迹时间线。
|
||||
// IncludeFootprintTimeline adds independently sampled instantaneous footprints while retaining the compact static band.
|
||||
IncludeFootprintTimeline bool
|
||||
// FootprintTimelineStep 控制瞬时足迹时间线的采样间隔;启用时零值使用 5 分钟。
|
||||
// FootprintTimelineStep controls the instantaneous timeline interval; zero uses five minutes when the timeline is enabled.
|
||||
FootprintTimelineStep time.Duration
|
||||
// GreatestTimeValues 是要计算的地方掩甚时刻等值线的时刻取值(力学时儒略日,最多 64 条,超出按时间截断);空值时改用 GreatestTimeStep。
|
||||
// 只有确实存在该时刻掩甚轨迹的取值才会出现在结果里,所以返回条数可能少于请求条数。
|
||||
// 每条等时线用固定时刻的残差零集延拓,成本正比于曲线长度而不是可见域面积。
|
||||
// GreatestTimeValues requests local greatest-occultation time isolines as TT Julian ephemeris days;
|
||||
// when empty, GreatestTimeStep is used instead. Each isochrone is continued along the zero set of a
|
||||
// fixed-instant residual so the cost scales with curve length rather than with the visible area.
|
||||
GreatestTimeValues []float64
|
||||
// GreatestTimeStep 是等时线间隔;仅在 GreatestTimeValues 为空时生效,非正值不计算等时线。
|
||||
// GreatestTimeStep is the isochrone interval; it applies only when GreatestTimeValues is empty,
|
||||
// and non-positive values disable the isolines.
|
||||
GreatestTimeStep time.Duration
|
||||
}
|
||||
|
||||
// Validate 检查全球路径采样选项。
|
||||
// Validate checks global path sampling options.
|
||||
func (o OccultationPathOptions) Validate() error {
|
||||
switch o.Algorithm {
|
||||
case "", OccultationPathAlgorithmOptimized, OccultationPathAlgorithmExact:
|
||||
default:
|
||||
return fmt.Errorf("%w: unsupported path algorithm %q", ErrInvalidOccultationInput, o.Algorithm)
|
||||
}
|
||||
if o.Step < 0 {
|
||||
return fmt.Errorf("%w: path step cannot be negative", ErrInvalidOccultationInput)
|
||||
}
|
||||
if o.Step > 0 && o.Step < occultationPathMinimumStep {
|
||||
return fmt.Errorf("%w: path step must be zero or at least %s", ErrInvalidOccultationInput, occultationPathMinimumStep)
|
||||
}
|
||||
if !finite(o.TargetSpacingKM) || o.TargetSpacingKM < 0 {
|
||||
return fmt.Errorf("%w: path target spacing must be finite and non-negative", ErrInvalidOccultationInput)
|
||||
}
|
||||
if o.TargetSpacingKM > 0 && o.TargetSpacingKM < occultationPathMinimumTargetSpacingKM {
|
||||
return fmt.Errorf("%w: path target spacing must be zero or at least %.0f km", ErrInvalidOccultationInput, occultationPathMinimumTargetSpacingKM)
|
||||
}
|
||||
if o.RiseSetStep < 0 {
|
||||
return fmt.Errorf("%w: rise/set step cannot be negative", ErrInvalidOccultationInput)
|
||||
}
|
||||
if o.RiseSetStep > 0 && o.RiseSetStep < occultationPathMinimumStep {
|
||||
return fmt.Errorf("%w: rise/set step must be zero or at least %s", ErrInvalidOccultationInput, occultationPathMinimumStep)
|
||||
}
|
||||
if o.FootprintTimelineStep < 0 {
|
||||
return fmt.Errorf("%w: footprint step cannot be negative", ErrInvalidOccultationInput)
|
||||
}
|
||||
if o.FootprintTimelineStep > 0 && o.FootprintTimelineStep < occultationPathMinimumStep {
|
||||
return fmt.Errorf("%w: footprint step must be zero or at least %s", ErrInvalidOccultationInput, occultationPathMinimumStep)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate 检查选项值,但不选择算法专用默认值。
|
||||
// Validate checks option values without selecting algorithm-specific defaults.
|
||||
func (o OccultationSearchOptions) Validate() error {
|
||||
if o.MaxStep < 0 {
|
||||
return fmt.Errorf("%w: search max step cannot be negative", ErrInvalidOccultationInput)
|
||||
}
|
||||
if o.MaxStep > 0 && o.MaxStep < occultationSearchMinimumStep {
|
||||
return fmt.Errorf("%w: search max step must be zero or at least %s", ErrInvalidOccultationInput, occultationSearchMinimumStep)
|
||||
}
|
||||
if !finite(o.SafetyMarginArcsec) || o.SafetyMarginArcsec < 0 {
|
||||
return fmt.Errorf("%w: search safety margin must be finite and non-negative", ErrInvalidOccultationInput)
|
||||
}
|
||||
if o.MaxEvents < 0 {
|
||||
return fmt.Errorf("%w: search max events cannot be negative", ErrInvalidOccultationInput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StarOccultationInfo 描述点光源恒星月掩;掩始和掩终是月缘交点。
|
||||
// StarOccultationInfo describes a point-source stellar occultation. The immersion and emersion times are the Moon-limb crossings.
|
||||
type StarOccultationInfo struct {
|
||||
TargetID string
|
||||
Observer Observer
|
||||
Type OccultationType
|
||||
|
||||
Immersion time.Time
|
||||
Greatest time.Time
|
||||
Emersion time.Time
|
||||
// ContactsComplete 表示两个月缘接触时刻均已求解。
|
||||
// ContactsComplete is true when both lunar-limb contacts were solved.
|
||||
ContactsComplete bool
|
||||
|
||||
MinimumSeparationArcsec float64
|
||||
PositionAngleDeg float64
|
||||
MoonSemidiameterArcsec float64
|
||||
|
||||
MoonAltitudeAtGreatest float64
|
||||
MoonAzimuthAtGreatest float64
|
||||
VisibleAtGreatest bool
|
||||
}
|
||||
|
||||
// PlanetOccultationInfo 描述有限盘面行星月掩。
|
||||
// ExternalImmersion 和 ExternalEmersion 分别是 C1 和 C4;全掩事件的 InternalImmersion 和 InternalEmersion 分别是 C2 和 C3,偏掩和掠掩时为零。
|
||||
// ContactsComplete 表示报告几何适用的所有接触均已求解;目标按赤道半径建模为圆盘,环、大气延伸和扁率不在模型内。
|
||||
// PlanetOccultationInfo describes a finite-disk planetary occultation.
|
||||
// ExternalImmersion and ExternalEmersion are C1 and C4. For a total event, InternalImmersion and InternalEmersion are C2 and C3; they are zero for partial and grazing events.
|
||||
// ContactsComplete means every contact applicable to the reported geometry was solved. The target is modeled as a circular disk using its equatorial body radius; rings, atmospheric extensions, and oblateness are outside this contact model.
|
||||
type PlanetOccultationInfo struct {
|
||||
Planet OccultationPlanet
|
||||
TargetID string
|
||||
Observer Observer
|
||||
Type OccultationType
|
||||
|
||||
ExternalImmersion time.Time
|
||||
InternalImmersion time.Time
|
||||
Greatest time.Time
|
||||
InternalEmersion time.Time
|
||||
ExternalEmersion time.Time
|
||||
|
||||
HasInternalContacts bool
|
||||
ContactsComplete bool
|
||||
|
||||
MinimumSeparationArcsec float64
|
||||
PositionAngleDeg float64
|
||||
MoonSemidiameterArcsec float64
|
||||
PlanetSemidiameterArcsec float64
|
||||
|
||||
MoonAltitudeAtGreatest float64
|
||||
MoonAzimuthAtGreatest float64
|
||||
VisibleAtGreatest bool
|
||||
}
|
||||
|
||||
// OccultationPathPoint 是全球月掩路径上的一个地理采样点。
|
||||
// Start 和 End 描述月缘外接触掩带。
|
||||
// WidthKM 是中心线采样处的地面横向宽度:接触锥可见弧上地面横向偏移的极差;投影折叠的退化事件改用同刻两条横切母线与椭球交点间的弦长。
|
||||
// LimitSeparationKM 只对南北限采样点(含全掩限)非零,是同一时刻对侧限线的地面间距;它与 WidthKM 构造不同,不要互相换算。
|
||||
// 基础采样直接求解,自适应插入点使用宽度插值并进行五米采样误差检查。
|
||||
// OccultationPathPoint is a geographic sample of a global lunar-occultation path.
|
||||
// Start and End describe the outer lunar-limb footprint.
|
||||
// WidthKM is the ground cross-track width at a center-line sample: the spread of ground cross-track offsets over the visible contact-cone arc, falling back to the same-instant chord between the two cross-track generators when the projection folds.
|
||||
// LimitSeparationKM is non-zero only on northern/southern limit samples (total limits included) and is the same-instant ground distance to the opposite limit; it is constructed differently from WidthKM and must not be converted into it.
|
||||
// Base samples are solved directly; adaptive samples use width interpolation and five-meter error checks.
|
||||
type OccultationPathPoint struct {
|
||||
// Time 是该点的时刻。
|
||||
// Time is the instant the point describes.
|
||||
Time time.Time
|
||||
// Longitude 与 Latitude 是地面坐标,东经、北纬为正。
|
||||
// Longitude and Latitude are ground coordinates, east and north positive.
|
||||
Longitude float64
|
||||
Latitude float64
|
||||
// MoonAltitude 是月球几何高度角,单位度,不做蒙气差修正。
|
||||
// MoonAltitude is the geometric lunar altitude in degrees, without refraction.
|
||||
MoonAltitude float64
|
||||
// WidthKM 是可见掩带在该点的地面宽度。掩星可见带可以宽达数千公里,与日食中心带宽度不是同一口径,不要互相比较。
|
||||
// 擦边事件的路径可以没有中心线,此时它仍表示可见带宽度,与 LimitSeparationKM 口径不同。
|
||||
// WidthKM is the ground width of the visible occultation band at the point. An occultation band
|
||||
// can span thousands of kilometres and is not comparable to a solar central-path width. A grazing
|
||||
// path can have no center line while this field still describes the visible band, which is a
|
||||
// different construction from LimitSeparationKM.
|
||||
WidthKM float64
|
||||
// LimitSeparationKM 是该点南北限的地面间距,无论中心线是否存在都有定义。
|
||||
// LimitSeparationKM is the ground separation of the two limits at the point, defined whether or not a center line exists.
|
||||
LimitSeparationKM float64
|
||||
}
|
||||
|
||||
// OccultationGreatestTimeContour 是一个固定地方掩甚时刻的等值线支路集合。
|
||||
// OccultationGreatestTimeContour contains the continuous branches of one fixed local greatest-occultation time.
|
||||
type OccultationGreatestTimeContour struct {
|
||||
// JDE 是该等值线表示的力学时儒略日,也就是各支路上地方掩甚发生的时刻。
|
||||
// JDE is the TT Julian ephemeris day represented by this contour, the local greatest-occultation instant along every branch.
|
||||
JDE float64
|
||||
// Time 是 JDE 对应的时刻;按步长请求时它是原始对齐时刻,避免 JDE 往返把整分取值截断成前一分钟。
|
||||
// Time is the instant matching JDE; for step-derived levels it is the original aligned instant,
|
||||
// so a JDE round trip cannot truncate a whole-minute level into the previous minute.
|
||||
Time time.Time
|
||||
// Segments 是该时刻的连续等时线支路;一条支路两端止于月平线(几何地平,无蒙气差修正)或掩可见域边界,
|
||||
// 纬度 ±88° 以上不再延拓,同一时刻可能有多条不相连的支路。
|
||||
// Segments are continuous isochrone branches; each branch ends at the lunar horizon or the occultation-visibility boundary.
|
||||
Segments [][]OccultationPathPoint
|
||||
}
|
||||
|
||||
// StarOccultationPath 包含点光源恒星月掩的全球掩带。
|
||||
// 中心线是月心与恒星对齐的轨迹;NorthernLimit 和 SouthernLimit 是月缘外接触锥的切点轨迹(掩星在该线上恰好退化为擦边),不是中心线的等距横向平移。
|
||||
// StarOccultationPath contains the global footprint of a point-source stellar occultation.
|
||||
// The center line is the locus where the lunar center aligns with the star; NorthernLimit and SouthernLimit are the tangency tracks of the outer-contact cone, where the occultation degenerates to a graze, so they are not a constant offset of the center line.
|
||||
type StarOccultationPath struct {
|
||||
TargetID string
|
||||
|
||||
Start OccultationPathPoint
|
||||
Greatest OccultationPathPoint
|
||||
End OccultationPathPoint
|
||||
// Complete 表示 Start 和 End 是全球月缘外接触点,而不是查询窗口裁剪点。
|
||||
// Complete is true when Start and End are the global outer-limb contacts rather than query-window clipping points.
|
||||
Complete bool
|
||||
|
||||
// CenterLine 是掩星中心线;掠掩事件可以整条没有中心线(只有南北限),此时该切片为空而 Complete 仍可为真。
|
||||
// CenterLine is the occultation center line; a grazing event can have no center line at all (limits only), leaving this slice empty while Complete can still be true.
|
||||
CenterLine []OccultationPathPoint
|
||||
NorthernLimit []OccultationPathPoint
|
||||
SouthernLimit []OccultationPathPoint
|
||||
// GreatestLimitSeparationKM 是掩甚处南北限的地面间距(取与掩甚时刻最近的限线采样对),与 Greatest.WidthKM 口径不同,不要互相换算。
|
||||
// GreatestLimitSeparationKM is the ground separation of the two limits at greatest, taken from the limit sample pair nearest to the greatest instant; it uses a different construction from Greatest.WidthKM and must not be converted into it.
|
||||
GreatestLimitSeparationKM float64
|
||||
// BandContours 是构造静态可见掩带的连续接触包络;点光源恒星等价于月缘外接触边界。
|
||||
// BandContours are the continuous contact envelopes used to construct the static visible band; for point-source stars they are the lunar-limb outer-contact boundaries.
|
||||
BandContours [][]OccultationPathPoint
|
||||
// VisibilityContours 是掩星有效期间月球可见性的连续时间外包络。
|
||||
// VisibilityContours are the continuous temporal envelopes of lunar visibility while the occultation is active.
|
||||
VisibilityContours [][]OccultationPathPoint
|
||||
// Footprints 是时刻采样的可见点源掩区,其扫掠构成全球掩带;采样口径与行星外接触足迹一致。
|
||||
// Footprints are sampled visible point-source regions whose sweep forms the global band, using the same sampling contract as planetary outer-contact footprints.
|
||||
Footprints []OccultationFootprint
|
||||
// BandFootprints 是关闭密集瞬时足迹时用于构造紧凑掩带的稀疏可见区样本;展示层应合并它们,不应逐个输出。
|
||||
// BandFootprints are sparse visible-region samples used to construct a compact band when dense instantaneous footprints are disabled. Renderers should merge rather than emit them individually.
|
||||
BandFootprints []OccultationFootprint
|
||||
// RiseSetCurves 是初掩、掩甚和终掩分别发生在月升或月落时的六类边界。
|
||||
// RiseSetCurves are the six boundaries where local start, greatest, or end occurs at moonrise or moonset.
|
||||
RiseSetCurves []OccultationRiseSetCurve
|
||||
// GreatestTimeContours 是按掩甚时刻采样的等时线。
|
||||
// GreatestTimeContours are sampled local greatest-occultation time isolines.
|
||||
GreatestTimeContours []OccultationGreatestTimeContour
|
||||
|
||||
Step time.Duration
|
||||
TargetSpacingKM float64
|
||||
}
|
||||
|
||||
// OccultationFootprint 是一个时刻的可见接触足迹。
|
||||
// Polygons 包含供独立时刻绘制的闭合可见区;Boundaries 保留未经地平线封口的接触锥弧,供稀疏静态样本连续扫掠。
|
||||
// Closed 表示 Boundaries 本身是否为闭合交线。
|
||||
// OccultationFootprint is one instantaneous visible contact footprint.
|
||||
// Polygons contain closed visible regions for rendering one instant. Boundaries retain contact-cone arcs before horizon closure for continuously sweeping sparse static samples.
|
||||
// Closed reports whether Boundaries themselves form a closed intersection.
|
||||
type OccultationFootprint struct {
|
||||
// Time 是该瞬时足迹对应的事件时刻。
|
||||
// Time is the event time represented by this instantaneous footprint.
|
||||
Time time.Time
|
||||
// Polygons 是供独立时刻绘制的闭合可见区域。
|
||||
// Polygons are closed visible regions for rendering one instant.
|
||||
Polygons [][]OccultationPathPoint
|
||||
// InteriorPolygons 是为单时刻绘制保留的闭合可见修复面。静态掩带构建器会将其与连续扫掠的接触边界弧分开,避免数值中心修复扩大长时间地理掩带。
|
||||
// InteriorPolygons are closed visible repair faces retained for one-instant
|
||||
// rendering. Static band builders keep them separate from the continuously
|
||||
// swept contact-boundary arcs so a numerical center repair cannot enlarge a
|
||||
// long-lived geographic band.
|
||||
InteriorPolygons [][]OccultationPathPoint
|
||||
// Boundaries 是尚未经过地平线封口的接触边界弧。
|
||||
// Boundaries are contact-boundary arcs before horizon closure.
|
||||
Boundaries [][]OccultationPathPoint
|
||||
// Closed 表示 Boundaries 是否构成闭合交线。
|
||||
// Closed reports whether Boundaries form a closed intersection.
|
||||
Closed bool
|
||||
}
|
||||
|
||||
// OccultationRiseSetCurve 是一种局部月掩阶段与月升/月落同时发生的边界。
|
||||
// OccultationRiseSetCurve is one boundary where a local occultation phase coincides with moonrise or moonset.
|
||||
type OccultationRiseSetCurve struct {
|
||||
// Phase 是与月升或月落同时发生的局部月掩阶段。
|
||||
// Phase is the local occultation phase coinciding with moonrise or moonset.
|
||||
Phase RiseSetPhase
|
||||
// Direction 标识月球正在升起还是落下。
|
||||
// Direction identifies whether the Moon is rising or setting.
|
||||
Direction RiseSetDirection
|
||||
// Segments 是反经线和支路跳变安全分段后的边界采样。
|
||||
// Segments are boundary samples split safely at the antimeridian and branch changes.
|
||||
Segments [][]OccultationPathPoint
|
||||
}
|
||||
|
||||
// PlanetOccultationFootprint 保留有限盘面行星月掩足迹的兼容名称。
|
||||
// PlanetOccultationFootprint retains the compatibility name for finite-disk planetary footprints.
|
||||
type PlanetOccultationFootprint = OccultationFootprint
|
||||
|
||||
// PlanetOccultationPath 包含有限盘面行星月掩的全球掩带。
|
||||
// NorthernLimit 和 SouthernLimit 是行星盘面任意部分被覆盖的外接触边界。
|
||||
// HasTotalBand 为 true 时,NorthernTotalLimit 和 SouthernTotalLimit 是行星圆盘完全被月球覆盖的内接触边界;
|
||||
// 环、大气延伸和扁率不在两种接触模型内。
|
||||
// PlanetOccultationPath contains the global footprint of a finite-disk planetary occultation.
|
||||
// NorthernLimit and SouthernLimit are the outer-contact boundaries where any part of the planet disk is covered.
|
||||
// When HasTotalBand is true, NorthernTotalLimit and SouthernTotalLimit are the inner-contact boundaries where the complete circular planet disk is covered by the Moon. Rings, atmospheric extensions, and oblateness are outside both contact models.
|
||||
type PlanetOccultationPath struct {
|
||||
Planet OccultationPlanet
|
||||
TargetID string
|
||||
|
||||
Start OccultationPathPoint
|
||||
Greatest OccultationPathPoint
|
||||
End OccultationPathPoint
|
||||
// Complete 表示 Start 和 End 是全球外接触点。
|
||||
// Complete is true when Start and End are the global outer contacts.
|
||||
Complete bool
|
||||
|
||||
// CenterLine 是影轴与椭球交点的轨迹;非中心事件退化为最接近影轴的椭球点。
|
||||
// 掠掩事件的路径可以整条没有中心线(只有南北限),此时该切片为空而 Complete 仍可为真。
|
||||
// CenterLine is the track of the shadow-axis/ellipsoid intersection, degenerating to the ellipsoid point nearest the axis for non-central events.
|
||||
// A grazing event can have no center line at all (limits only); the slice is then empty while Complete can still be true.
|
||||
CenterLine []OccultationPathPoint
|
||||
// NorthernLimit 和 SouthernLimit 是外接触锥的切点轨迹:掩星在该线上恰好退化为擦边,不是中心线的等距横向平移。
|
||||
// NorthernLimit and SouthernLimit are the tangency tracks of the outer-contact cone, where the occultation degenerates to a graze, so they are not a constant-width offset of the center line.
|
||||
NorthernLimit []OccultationPathPoint
|
||||
SouthernLimit []OccultationPathPoint
|
||||
// GreatestLimitSeparationKM 是掩甚处南北限的地面间距(取与掩甚时刻最近的限线采样对),与 Greatest.WidthKM 口径不同,不要互相换算。
|
||||
// GreatestLimitSeparationKM is the ground separation of the two limits at greatest, taken from the limit sample pair nearest to the greatest instant; it uses a different construction from Greatest.WidthKM and must not be converted into it.
|
||||
GreatestLimitSeparationKM float64
|
||||
// PartialBandContours 是偏掩静态带的连续外接触包络;任意目标盘面重叠时取零。
|
||||
// PartialBandContours are the continuous outer-contact envelopes for the static partial band where any target-disk overlap begins.
|
||||
PartialBandContours [][]OccultationPathPoint
|
||||
// PartialVisibilityContours 是月球可见性在偏掩阶段内的连续时间外包络。
|
||||
// PartialVisibilityContours are the continuous temporal envelopes of lunar visibility while partial occultation is active.
|
||||
PartialVisibilityContours [][]OccultationPathPoint
|
||||
// PartialFootprints 是时刻采样的可见外接触区域,其扫掠构成全球偏掩区域;足迹以 1 分钟为目标步长,超出独立样本上限时会变粗,
|
||||
// 每个足迹携带实际采样时刻。
|
||||
// PartialFootprints are instantaneous visible outer-contact regions whose sweep forms the global partial-occultation area. Footprints target one-minute intervals and become coarser when their independent sample cap is reached; each footprint carries its actual sample time.
|
||||
PartialFootprints []PlanetOccultationFootprint
|
||||
// PartialBandFootprints 是关闭密集瞬时足迹时用于构造紧凑偏掩带的稀疏可见区样本。
|
||||
// PartialBandFootprints are sparse visible-region samples used to construct the compact partial band when dense instantaneous footprints are disabled.
|
||||
PartialBandFootprints []PlanetOccultationFootprint
|
||||
// RiseSetCurves 是初掩、掩甚和终掩分别发生在月升或月落时的六类边界。
|
||||
// RiseSetCurves are the six boundaries where local start, greatest, or end occurs at moonrise or moonset.
|
||||
RiseSetCurves []OccultationRiseSetCurve
|
||||
|
||||
HasTotalBand bool
|
||||
// TotalStart 和 TotalEnd 是全球内接触的起止点。
|
||||
// TotalStart and TotalEnd are the first and last global inner contacts.
|
||||
TotalStart OccultationPathPoint
|
||||
TotalEnd OccultationPathPoint
|
||||
// TotalComplete 表示 TotalStart 和 TotalEnd 未被内部搜索范围截断。
|
||||
// TotalComplete is true when TotalStart and TotalEnd are not clipped by the internal search span.
|
||||
TotalComplete bool
|
||||
|
||||
// NorthernTotalLimit 和 SouthernTotalLimit 是内接触(全掩)锥的切点轨迹,同刻间距同样记录在 LimitSeparationKM 上。
|
||||
// NorthernTotalLimit and SouthernTotalLimit are the tangency tracks of the inner-contact cone; their same-instant separation is recorded in LimitSeparationKM as well.
|
||||
NorthernTotalLimit []OccultationPathPoint
|
||||
SouthernTotalLimit []OccultationPathPoint
|
||||
// TotalBandContours 是全掩静态带的连续内接触包络;仅在目标圆盘完全被月球覆盖时取零。
|
||||
// TotalBandContours are the continuous inner-contact envelopes for the static total band where the complete target disk is covered by the Moon.
|
||||
TotalBandContours [][]OccultationPathPoint
|
||||
// TotalVisibilityContours 是全掩阶段内月球可见性的连续时间外包络。
|
||||
// TotalVisibilityContours are the temporal envelopes of lunar visibility while the total-occultation contact condition is active.
|
||||
TotalVisibilityContours [][]OccultationPathPoint
|
||||
// TotalFootprints 是时刻采样的可见内接触区域,其扫掠构成全球全掩区域;采样使用与 PartialFootprints 相同的 1 分钟目标步长和样本上限。
|
||||
// TotalFootprints are instantaneous visible inner-contact regions whose sweep forms the global full-coverage area. Their sampling uses the same one-minute target step and sample cap as PartialFootprints.
|
||||
TotalFootprints []PlanetOccultationFootprint
|
||||
// TotalBandFootprints 是关闭密集瞬时足迹时用于构造紧凑全掩带的稀疏可见区样本。
|
||||
// TotalBandFootprints are sparse visible-region samples used to construct the compact total band when dense instantaneous footprints are disabled.
|
||||
TotalBandFootprints []PlanetOccultationFootprint
|
||||
// TotalRiseSetCurves 是全掩带使用的内接触月升/月落边界;其相位与 RiseSetCurves 相同,但 contact metric 采用内接触而非外接触。
|
||||
// TotalRiseSetCurves are the inner-contact moonrise/moonset boundaries used by the total band; they share the same phase labels as RiseSetCurves but evaluate the inner-contact metric instead of the outer-contact metric.
|
||||
TotalRiseSetCurves []OccultationRiseSetCurve
|
||||
// GreatestTotalWidthKM 是全球掩甚时的全掩带宽度,与 Greatest.WidthKM 同口径而作用于内接触锥,且恒小于它。
|
||||
// GreatestTotalWidthKM is the full-coverage band width at global greatest, using the same construction as Greatest.WidthKM on the inner-contact cone and always below it.
|
||||
GreatestTotalWidthKM float64
|
||||
// GreatestTimeContours 是按掩甚时刻采样的等时线,判据用外接触锥,与偏掩可见域一致。
|
||||
// GreatestTimeContours are sampled local greatest-occultation time isolines, gated by the outer-contact cone so they match the partial-occultation visibility area.
|
||||
GreatestTimeContours []OccultationGreatestTimeContour
|
||||
|
||||
Step time.Duration
|
||||
TargetSpacingKM float64
|
||||
}
|
||||
|
||||
func finite(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
}
|
||||
|
||||
func validCoordinateFrame(frame CoordinateFrame) bool {
|
||||
switch frame {
|
||||
case CoordinateFrameICRS, CoordinateFrameJ2000, CoordinateFrameApparentOfDate:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOccultationBoundarySamplingNearTemporalFolds(t *testing.T) {
|
||||
for _, algorithm := range []OccultationPathAlgorithm{OccultationPathAlgorithmExact, OccultationPathAlgorithmOptimized} {
|
||||
t.Run(string(algorithm), func(t *testing.T) {
|
||||
day := time.Date(3627, 9, 20, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := FindPlanetOccultationPaths(day.Add(-12*time.Hour), day.Add(36*time.Hour), OccultationJupiter,
|
||||
OccultationPathOptions{Algorithm: algorithm, Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, RiseSetStep: time.Minute})
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("paths=%d err=%v", len(paths), err)
|
||||
}
|
||||
for _, contours := range [][][]OccultationPathPoint{paths[0].PartialBandContours, paths[0].TotalBandContours} {
|
||||
for ci, contour := range contours {
|
||||
for i := 1; i+1 < len(contour); i++ {
|
||||
a, b, c := contour[i-1], contour[i], contour[i+1]
|
||||
first, second := occultationPathDistanceKM(a, b), occultationPathDistanceKM(b, c)
|
||||
if first > 0.05 && second > 0.05 && first+second > 2 && occultationRiseSetTurnAngleDegrees(a, b, c) < 30 {
|
||||
t.Errorf("contour %d point %d reverses between %.3f/%.3f km edges at %.9f,%.9f", ci, i, first, second, b.Longitude, b.Latitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationPhaseJunctionSamplingResolvesCurvature(t *testing.T) {
|
||||
day := time.Date(1227, 5, 19, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := FindPlanetOccultationPaths(day.Add(-12*time.Hour), day.Add(36*time.Hour), OccultationVenus,
|
||||
OccultationPathOptions{Algorithm: OccultationPathAlgorithmExact, Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, RiseSetStep: time.Minute})
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("paths=%d err=%v", len(paths), err)
|
||||
}
|
||||
config, _ := planetOccultationConfigFor(OccultationVenus)
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
checked := 0
|
||||
for ci, curve := range paths[0].RiseSetCurves {
|
||||
if curve.Phase == RiseSetPhaseGreatest {
|
||||
continue
|
||||
}
|
||||
for _, segment := range curve.Segments {
|
||||
if len(segment) < 2 {
|
||||
continue
|
||||
}
|
||||
for _, atStart := range []bool{true, false} {
|
||||
endpoint := occultationRiseSetSegmentEndpoint(segment, atStart)
|
||||
if !occultationRiseSetEndpointSharesPhaseJunction(paths[0].RiseSetCurves, ci, endpoint) {
|
||||
continue
|
||||
}
|
||||
index := len(segment) - 2
|
||||
if atStart {
|
||||
index = 0
|
||||
}
|
||||
a, b := segment[index], segment[index+1]
|
||||
middle, ok := occultationRiseSetPhaseMidpoint(a, b, curve.Phase, curve.Direction, time.UTC, cache.riseSetCache)
|
||||
if !ok || occultationPathDistanceKM(a, b) < 0.05 {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
deviation := planetOccultationPointSegmentDistanceKM(middle, a, b)
|
||||
if math.IsNaN(deviation) || deviation > 0.05 {
|
||||
t.Errorf("%s/%s endpoint chord misses the physical arc by %.6f km", curve.Phase, curve.Direction, deviation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked < 2 {
|
||||
t.Fatalf("checked only %d junction chords", checked)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 解析速率与中心差分必须给出同一个接触度量导数:点源目标曾把零位置当站心位置,
|
||||
// 减掉观测者速度后得到日尺度的假速率,掩带边界因此无法加密。
|
||||
// The analytic contact rate must agree with the central difference for every target
|
||||
// kind: the point-source branch once treated a zero position as a topocentric vector
|
||||
// and produced a spurious diurnal rate that stopped the band boundary from refining.
|
||||
func TestOccultationContactRateMatchesCentralDifference(t *testing.T) {
|
||||
const tolerance = 0.01 // 度/日
|
||||
star := hr4799OccultationCoordinateForTest()
|
||||
infinite := star
|
||||
infinite.ParallaxMas = 0
|
||||
parallaxStar := star
|
||||
parallaxStar.ParallaxMas = 10
|
||||
planetConfig, _ := planetOccultationConfigFor(OccultationMercury)
|
||||
planetCache := newPlanetOccultationEventCache(planetConfig)
|
||||
|
||||
starEvaluation := func(coordinate StarCoordinate) func(float64) occultationRiseSetEvaluation {
|
||||
cache := newStarOccultationEventCache(coordinate)
|
||||
return newOccultationRiseSetEvaluationCache(cache.riseSetContextAt).evaluation
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
sampleTime time.Time
|
||||
evaluation func(float64) occultationRiseSetEvaluation
|
||||
}{
|
||||
{"point-source star", time.Date(2025, 6, 5, 10, 0, 0, 0, time.UTC), starEvaluation(infinite)},
|
||||
{"finite star", time.Date(2025, 6, 5, 10, 0, 0, 0, time.UTC), starEvaluation(parallaxStar)},
|
||||
{"planet", time.Date(2024, 8, 21, 10, 0, 0, 0, time.UTC), newOccultationRiseSetEvaluationCache(planetCache.riseSetContextAt).evaluation},
|
||||
}
|
||||
sites := [][2]float64{{0, 0}, {115, 33}, {-74, 40}, {179, 80}, {-123, -64}}
|
||||
for _, testCase := range cases {
|
||||
tt := occultationTimeToTT(testCase.sampleTime)
|
||||
evaluation := testCase.evaluation(tt)
|
||||
for _, site := range sites {
|
||||
analytic := evaluation.contactRateAt(site[0], site[1])
|
||||
reference := evaluation.contactDerivative(site[0], site[1])
|
||||
if !finite(analytic) || !finite(reference) {
|
||||
t.Fatalf("%s lon=%g lat=%g: analytic=%g reference=%g", testCase.name, site[0], site[1], analytic, reference)
|
||||
}
|
||||
if math.Abs(analytic-reference) > tolerance {
|
||||
t.Errorf("%s lon=%g lat=%g: analytic=%.6f reference=%.6f degrees/day",
|
||||
testCase.name, site[0], site[1], analytic, reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 无视差恒星的掩带边界必须加密到请求的地面间距:修复前相邻点可达 150 km。
|
||||
func TestStarOccultationBandContourSpacing(t *testing.T) {
|
||||
star := hr4799OccultationCoordinateForTest()
|
||||
start := time.Date(2025, 6, 5, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := FindStarOccultationPaths(start, start.Add(24*time.Hour), star,
|
||||
OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 30, DisableFootprints: true, DisableRiseSet: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const limitKM = 45.0
|
||||
sampled := 0
|
||||
for _, path := range paths {
|
||||
for branch, contour := range path.BandContours {
|
||||
for index := 1; index < len(contour); index++ {
|
||||
spacing := occultationPathDistanceKM(contour[index-1], contour[index])
|
||||
sampled++
|
||||
if spacing > limitKM {
|
||||
t.Fatalf("branch %d sample %d: spacing %.3f km exceeds %.0f km", branch, index, spacing, limitKM)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if sampled == 0 {
|
||||
t.Fatal("no band contour samples")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestOccultationPathFrameBoundaryCacheMatchesUncachedSearch 固定 frame 级边界记忆化的语义:
|
||||
// 记忆化只是复用同一 frame 上纯函数的计算结果,因此切点、可见 θ 区间与按中心角的 θ 区间
|
||||
// 都必须与直接调用未缓存实现逐位一致,重复调用也必须稳定。
|
||||
// TestOccultationPathFrameBoundaryCacheMatchesUncachedSearch pins the frame-level boundary memo
|
||||
// semantics: memoization only reuses pure results for the same frame, so the tangency, the
|
||||
// visible theta intervals and the per-center-angle interval must stay bit-identical to the
|
||||
// uncached implementations, and repeated calls must be stable.
|
||||
func TestOccultationPathFrameBoundaryCacheMatchesUncachedSearch(t *testing.T) {
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn occultation config is unavailable")
|
||||
}
|
||||
tt := occultationTimeToTT(time.Date(2024, time.August, 21, 2, 41, 36, 0, time.UTC))
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
|
||||
cachedFrame, cachedOK := cache.outerFrameAt(tt)
|
||||
directFrame, directOK := planetOccultationPathFrameAt(tt, config)
|
||||
if cachedOK != directOK || !cachedOK {
|
||||
t.Fatalf("frame availability cached=%v direct=%v, want both true", cachedOK, directOK)
|
||||
}
|
||||
if !occultationPathFrameGeometryEqual(cachedFrame, directFrame) {
|
||||
t.Fatal("cached frame geometry differs from a direct frame")
|
||||
}
|
||||
if cachedFrame.boundary == nil {
|
||||
t.Fatal("cached frame is missing its boundary memo")
|
||||
}
|
||||
|
||||
wantPoint, wantTheta, wantOK := occultationPathBoundaryTangentUncached(directFrame)
|
||||
gotPoint, gotTheta, gotOK := occultationPathBoundaryTangent(cachedFrame)
|
||||
if gotOK != wantOK || gotPoint != wantPoint || gotTheta != wantTheta {
|
||||
t.Fatalf("memoized tangency = (%.15g, %.15g, %v), want (%.15g, %.15g, %v)",
|
||||
gotPoint, gotTheta, gotOK, wantPoint, wantTheta, wantOK)
|
||||
}
|
||||
againPoint, againTheta, againOK := occultationPathBoundaryTangent(cachedFrame)
|
||||
if againOK != gotOK || againPoint != gotPoint || againTheta != gotTheta {
|
||||
t.Fatal("repeated tangency query is not stable")
|
||||
}
|
||||
|
||||
wantIntervals := occultationPathBoundaryThetaIntervalsUncached(directFrame)
|
||||
gotIntervals := occultationPathBoundaryThetaIntervals(cachedFrame)
|
||||
if len(gotIntervals) != len(wantIntervals) {
|
||||
t.Fatalf("memoized theta intervals = %v, want %v", gotIntervals, wantIntervals)
|
||||
}
|
||||
for index := range gotIntervals {
|
||||
if gotIntervals[index] != wantIntervals[index] {
|
||||
t.Fatalf("memoized theta interval %d = %v, want %v", index, gotIntervals[index], wantIntervals[index])
|
||||
}
|
||||
}
|
||||
if againIntervals := occultationPathBoundaryThetaIntervals(cachedFrame); len(againIntervals) != len(gotIntervals) {
|
||||
t.Fatal("repeated theta-interval query is not stable")
|
||||
}
|
||||
|
||||
cachedLeft, cachedRight, cachedIntervalOK := occultationPathBoundaryThetaInterval(cachedFrame, wantTheta)
|
||||
directLeft, directRight, directIntervalOK := occultationPathBoundaryThetaInterval(directFrame, wantTheta)
|
||||
if cachedIntervalOK != directIntervalOK || cachedLeft != directLeft || cachedRight != directRight {
|
||||
t.Fatalf("memoized interval = (%.15g, %.15g, %v), want (%.15g, %.15g, %v)",
|
||||
cachedLeft, cachedRight, cachedIntervalOK, directLeft, directRight, directIntervalOK)
|
||||
}
|
||||
repeatLeft, repeatRight, repeatOK := occultationPathBoundaryThetaInterval(cachedFrame, wantTheta)
|
||||
if repeatOK != cachedIntervalOK || repeatLeft != cachedLeft || repeatRight != cachedRight {
|
||||
t.Fatal("repeated per-angle interval query is not stable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOccultationPathZeroFrameComputesBoundaryWithoutCache 保证未挂记忆化的零值 frame 仍然
|
||||
// 直接计算,不会被 nil 缓存跳过。
|
||||
// TestOccultationPathZeroFrameComputesBoundaryWithoutCache keeps a frame without a memo working:
|
||||
// a nil boundary cache must fall back to direct computation, not skip it.
|
||||
func TestOccultationPathZeroFrameComputesBoundaryWithoutCache(t *testing.T) {
|
||||
frame := occultationPathFrame{
|
||||
moon: occultationPathVector{x: 384000},
|
||||
axis: occultationPathVector{x: -1},
|
||||
first: occultationPathVector{y: 1},
|
||||
second: occultationPathVector{z: 1},
|
||||
moonRadius: 0.0045,
|
||||
targetRadius: 1e-5,
|
||||
}
|
||||
if frame.boundary != nil {
|
||||
t.Fatal("fixture frame unexpectedly carries a boundary memo")
|
||||
}
|
||||
point, theta, ok := occultationPathBoundaryTangent(frame)
|
||||
wantPoint, wantTheta, wantOK := occultationPathBoundaryTangentUncached(frame)
|
||||
if ok != wantOK || point != wantPoint || theta != wantTheta {
|
||||
t.Fatalf("uncached tangency = (%.15g, %.15g, %v), want (%.15g, %.15g, %v)",
|
||||
point, theta, ok, wantPoint, wantTheta, wantOK)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StarOccultationInstant 包含指定时刻的点源恒星月掩可见足迹;若接触锥在该时刻未到达可见地球,Footprint 为 nil。
|
||||
// StarOccultationInstant contains the visible point-source footprint at one requested instant; Footprint is nil when no part of the contact cone reaches the visible Earth.
|
||||
type StarOccultationInstant struct {
|
||||
// Time 是本次查询的时刻。
|
||||
// Time is the instant this result describes.
|
||||
Time time.Time
|
||||
// TargetID 是查询恒星的标识,与 StarCoordinate.ID 同值。
|
||||
// TargetID identifies the queried star and equals StarCoordinate.ID.
|
||||
TargetID string
|
||||
// DeltaTSeconds 本次实际使用的 ΔT / ΔT actually used.
|
||||
DeltaTSeconds float64
|
||||
// SublunarLongitude 与 SublunarLatitude 是该时刻月下点,用于声明地平闭合弧 / sublunar point for the horizon closure.
|
||||
SublunarLongitude float64
|
||||
SublunarLatitude float64
|
||||
Footprint *OccultationFootprint
|
||||
}
|
||||
|
||||
// PlanetOccultationInstant 包含指定时刻的行星外接触和内接触可见足迹;相应接触锥未到达可见地球时,Partial 或 Total 为 nil。
|
||||
// PlanetOccultationInstant contains the visible outer- and inner-contact footprints at one requested instant; Partial or Total is nil when that contact cone does not reach the visible Earth.
|
||||
type PlanetOccultationInstant struct {
|
||||
// Time 是本次查询的时刻。
|
||||
// Time is the instant this result describes.
|
||||
Time time.Time
|
||||
// Planet 是本次查询的行星。
|
||||
// Planet is the queried planet.
|
||||
Planet OccultationPlanet
|
||||
// TargetID 是行星标识,与 Planet.String() 同值。
|
||||
// TargetID identifies the planet and equals Planet.String().
|
||||
TargetID string
|
||||
// DeltaTSeconds 本次实际使用的 ΔT / ΔT actually used.
|
||||
DeltaTSeconds float64
|
||||
// SublunarLongitude 与 SublunarLatitude 是该时刻月下点,用于声明地平闭合弧 / sublunar point for the horizon closure.
|
||||
SublunarLongitude float64
|
||||
SublunarLatitude float64
|
||||
Partial *PlanetOccultationFootprint
|
||||
Total *PlanetOccultationFootprint
|
||||
}
|
||||
|
||||
// StarOccultationFootprintAt 返回指定时刻的精确点源恒星月掩可见足迹;它采用与路径时间线相同的分辨率,并对完整接触弧执行站心校正。
|
||||
// StarOccultationFootprintAt returns the exact visible lunar-occultation footprint of a point-source star at one instant, using timeline resolution and station-centred correction of the complete contact arc.
|
||||
func StarOccultationFootprintAt(at time.Time, star StarCoordinate) (StarOccultationInstant, error) {
|
||||
result := StarOccultationInstant{Time: at, TargetID: star.ID}
|
||||
if at.IsZero() {
|
||||
return result, fmt.Errorf("%w: time is required", ErrInvalidOccultationInput)
|
||||
}
|
||||
if err := star.Validate(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
cache := newStarOccultationEventCache(star)
|
||||
tt := occultationTimeToTT(at)
|
||||
result.DeltaTSeconds = DeltaT(tt, true)
|
||||
result.SublunarLongitude, result.SublunarLatitude = occultationSublunarPoint(tt, cache.frameAt)
|
||||
result.Footprint = occultationInstantFootprint(
|
||||
at, cache.frameAt, cache.riseSetContextAt, false,
|
||||
)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlanetOccultationFootprintsAt 返回指定时刻有限行星盘面的精确外接触和内接触月掩可见足迹;它采用路径时间线分辨率,并对每条完整接触弧执行站心校正。
|
||||
// PlanetOccultationFootprintsAt returns the exact visible outer- and inner-contact lunar-occultation footprints of a finite planetary disk at one instant, using timeline resolution and station-centred correction of each complete contact arc.
|
||||
func PlanetOccultationFootprintsAt(at time.Time, planet OccultationPlanet) (PlanetOccultationInstant, error) {
|
||||
result := PlanetOccultationInstant{Time: at, Planet: planet, TargetID: planet.String()}
|
||||
if at.IsZero() {
|
||||
return result, fmt.Errorf("%w: time is required", ErrInvalidOccultationInput)
|
||||
}
|
||||
if err := planet.Validate(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
config, _ := planetOccultationConfigFor(planet)
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
tt := occultationTimeToTT(at)
|
||||
result.DeltaTSeconds = DeltaT(tt, true)
|
||||
result.SublunarLongitude, result.SublunarLatitude = occultationSublunarPoint(tt, cache.outerFrameAt)
|
||||
result.Partial = occultationInstantFootprint(
|
||||
at, cache.outerFrameAt, cache.riseSetContextAt, false,
|
||||
)
|
||||
result.Total = occultationInstantFootprint(
|
||||
at, cache.totalFrameAt, cache.riseSetContextAt, true,
|
||||
)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func occultationSublunarPoint(tt float64, frameAt occultationPathFrameFunc) (float64, float64) {
|
||||
frame, ok := frameAt(tt)
|
||||
if !ok {
|
||||
return math.NaN(), math.NaN()
|
||||
}
|
||||
distance := math.Sqrt(frame.moon.x*frame.moon.x + frame.moon.y*frame.moon.y + frame.moon.z*frame.moon.z)
|
||||
if distance == 0 {
|
||||
return math.NaN(), math.NaN()
|
||||
}
|
||||
rightAscension := math.Atan2(frame.moon.y, frame.moon.x) * 180 / math.Pi
|
||||
declination := math.Asin(math.Max(-1, math.Min(1, frame.moon.z/distance))) * 180 / math.Pi
|
||||
longitude := rightAscension - ApparentSiderealTime(TT2UT1(tt))*15
|
||||
for longitude > 180 {
|
||||
longitude -= 360
|
||||
}
|
||||
for longitude < -180 {
|
||||
longitude += 360
|
||||
}
|
||||
return longitude, declination
|
||||
}
|
||||
|
||||
func occultationInstantFootprint(
|
||||
at time.Time,
|
||||
frameAt occultationPathFrameFunc,
|
||||
contextAt occultationRiseSetContextFunc,
|
||||
total bool,
|
||||
) *OccultationFootprint {
|
||||
tt := occultationTimeToTT(at)
|
||||
footprint, ok := planetOccultationFootprintAtWithResolution(
|
||||
tt, frameAt, at.Location(),
|
||||
planetOccultationTimelineBoundaryPoints,
|
||||
planetOccultationTimelineHorizonPoints,
|
||||
planetOccultationTimelineTargetSpacingKM,
|
||||
)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// A one-element correction deliberately treats this instant as both ends of
|
||||
// the sequence, so every contact-arc sample receives the exact station solve.
|
||||
corrected := occultationStationCorrectFootprintEdges(
|
||||
[]PlanetOccultationFootprint{footprint}, frameAt, contextAt, total, at.Location(),
|
||||
)
|
||||
if len(corrected) != 1 {
|
||||
return nil
|
||||
}
|
||||
footprint = corrected[0]
|
||||
normalizeOccultationInstantTime(&footprint, at)
|
||||
return &footprint
|
||||
}
|
||||
|
||||
func normalizeOccultationInstantTime(footprint *OccultationFootprint, at time.Time) {
|
||||
if footprint == nil {
|
||||
return
|
||||
}
|
||||
footprint.Time = at
|
||||
for _, polygons := range [][][]OccultationPathPoint{
|
||||
footprint.Polygons,
|
||||
footprint.InteriorPolygons,
|
||||
footprint.Boundaries,
|
||||
} {
|
||||
for polygonIndex := range polygons {
|
||||
for pointIndex := range polygons[polygonIndex] {
|
||||
polygons[polygonIndex][pointIndex].Time = at
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPlanetOccultationFootprintsAtReturnsExactRequestedInstant(t *testing.T) {
|
||||
start := time.Date(2025, time.January, 5, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
|
||||
events, err := FindBestPlanetOccultations(
|
||||
start, start.Add(24*time.Hour), OccultationSaturn, OccultationSearchOptions{MaxEvents: 1},
|
||||
)
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("FindBestPlanetOccultations events=%d err=%v, want one", len(events), err)
|
||||
}
|
||||
at := events[0].Greatest.Add(123 * time.Nanosecond)
|
||||
instant, err := PlanetOccultationFootprintsAt(at, OccultationSaturn)
|
||||
if err != nil {
|
||||
t.Fatalf("PlanetOccultationFootprintsAt: %v", err)
|
||||
}
|
||||
if instant.Partial == nil || instant.Total == nil {
|
||||
t.Fatalf("instant footprints partial=%v total=%v, want both", instant.Partial != nil, instant.Total != nil)
|
||||
}
|
||||
for name, footprint := range map[string]*OccultationFootprint{
|
||||
"partial": instant.Partial,
|
||||
"total": instant.Total,
|
||||
} {
|
||||
if !footprint.Time.Equal(at) {
|
||||
t.Fatalf("%s footprint time=%v, want %v", name, footprint.Time, at)
|
||||
}
|
||||
if len(footprint.Polygons) == 0 || len(footprint.Boundaries) == 0 {
|
||||
t.Fatalf("%s footprint has no visible geometry", name)
|
||||
}
|
||||
for _, boundary := range footprint.Boundaries {
|
||||
for _, point := range boundary {
|
||||
if !point.Time.Equal(at) {
|
||||
t.Fatalf("%s boundary time=%v, want %v", name, point.Time, at)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationFootprintAtReturnsEmptyOutsideEvent(t *testing.T) {
|
||||
star := StarCoordinate{
|
||||
ID: "Antares", RA: 247.35166667, Dec: -26.43194444,
|
||||
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
|
||||
Frame: CoordinateFrameJ2000,
|
||||
}
|
||||
instant, err := StarOccultationFootprintAt(
|
||||
time.Date(2024, time.March, 1, 17, 0, 0, 0, time.UTC), star,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("StarOccultationFootprintAt: %v", err)
|
||||
}
|
||||
if instant.Footprint != nil {
|
||||
t.Fatal("outside-event stellar footprint is not nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationFootprintAtValidatesInputs(t *testing.T) {
|
||||
if _, err := PlanetOccultationFootprintsAt(time.Time{}, OccultationSaturn); !errors.Is(err, ErrInvalidOccultationInput) {
|
||||
t.Fatalf("zero time error=%v, want ErrInvalidOccultationInput", err)
|
||||
}
|
||||
if _, err := PlanetOccultationFootprintsAt(time.Now(), OccultationPlanet("earth")); !errors.Is(err, ErrInvalidOccultationInput) {
|
||||
t.Fatalf("invalid planet error=%v, want ErrInvalidOccultationInput", err)
|
||||
}
|
||||
if _, err := StarOccultationFootprintAt(time.Now(), StarCoordinate{}); !errors.Is(err, ErrInvalidOccultationInput) {
|
||||
t.Fatalf("invalid star error=%v, want ErrInvalidOccultationInput", err)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user