5 Commits

Author SHA1 Message Date
b612 16c62a97d5 feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持
- 改进日月食、月掩、行星事件及路径边界计算
- 完善恒星三维自行与动态距离传播
- 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具
- 整理中英文手册、示例资源及回归测试
2026-09-23 18:55:12 +08:00
b612 1f31a9b5b5 docs: 修正README中月球公式计算口径表述笔误 2026-09-17 21:33:28 +08:00
b612 2bf8478639 feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
2026-09-17 12:27:40 +08:00
b612 9ee2163cc7 feat: 新增月掩与日月食地理绘图并提升观测计算精度
- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算
- 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出
- 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口
- 扩展日食中心线、南北界及偏食足迹采样,支持极区投影
- 修正站心时角、月出月落、月球视半径、折射和恒星自行计算
- 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
2026-08-06 12:00:56 +08:00
b612 25dc7ac0bc calendar: 补齐前104古历纪年解析与回归测试
- 为先秦与秦汉古历结果填充周、鲁、秦、西汉早期纪年信息
- 支持默认与显式古历下的年号日期解析
2026-06-11 09:33:46 +08:00
724 changed files with 135421 additions and 13229 deletions
+79 -1833
View File
File diff suppressed because it is too large Load Diff
+71 -1915
View File
File diff suppressed because it is too large Load Diff
+79 -1
View File
@@ -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)
}
+69
View File
@@ -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)
}
}
}
+171 -32
View File
@@ -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)
}
}
}
func TestPublicTruncationFullMatchesDefault(t *testing.T) {
date := time.Date(2026, 1, 2, 3, 4, 5, 123456789, time.UTC)
// planetTruncationName 与 planetTruncationTolerance 的下标同 WherePlanetN 的 xt。
var planetTruncationName = []string{"earth", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune"}
if math.Float64bits(sun.TrueLo(date)) != math.Float64bits(sun.TrueLoN(date, -1)) {
t.Fatal("sun.TrueLoN(-1) should match default")
// 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},
}
if math.Float64bits(sun.TrueBo(date)) != math.Float64bits(sun.TrueBoN(date, -1)) {
t.Fatal("sun.TrueBoN(-1) should match default")
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],
)
}
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")
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 TestPlanetMoonTruncationConvergesToFullSeries(t *testing.T) {
samples := loadBaselineSamples(t)
assertTruncationConverges(t, planetMoonTruncationCases(samples), len(samples))
}
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))
}
+50
View File
@@ -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
View File
@@ -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
View File
@@ -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
+137
View File
@@ -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)
}
+128
View File
@@ -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)
}
}
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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")
}
+3 -3
View File
@@ -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
View File
@@ -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
}
+44
View File
@@ -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)
}
}
}
+82
View File
@@ -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
}
+95
View File
@@ -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)
}
}
-115
View File
@@ -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
}
}
+186
View File
@@ -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
}
+94
View File
@@ -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)
}
}
+107
View File
@@ -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
View File
@@ -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)
}
+2 -2
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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,
+24 -26
View File
@@ -5,14 +5,28 @@ 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
}
func sameEventUTQueryTT(eventUT, queryTT float64) bool {
return math.Abs(eventUTQueryTTDelta(eventUT, queryTT)) <= exactQueryTTToleranceUT
// 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 {
@@ -30,50 +44,34 @@ func closestEventUTToQueryTT(queryTT, best float64, candidates ...float64) float
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)
next := fn(jde, degree, 1)
if eventUTQueryBeforeOrEqual(next, jde) && eventUTQueryAfterOrEqual(next, jde) {
if next := fn(jde, degree, 1); eventUTQueryBeforeOrEqual(next, jde) && eventUTQueryAfterOrEqual(next, jde) {
return next
}
if eventUTQueryBeforeOrEqual(last, jde) {
return last
}
return last
}
func inclusiveNextPhaseEvent(jde, degree float64, fn phaseEventSearchFunc) float64 {
last := fn(jde, degree, 0)
if eventUTQueryBeforeOrEqual(last, jde) && eventUTQueryAfterOrEqual(last, jde) {
if last := fn(jde, degree, 0); eventUTQueryBeforeOrEqual(last, jde) && eventUTQueryAfterOrEqual(last, jde) {
return last
}
next := fn(jde, degree, 1)
if eventUTQueryAfterOrEqual(next, jde) {
return next
}
return next
return fn(jde, degree, 1)
}
func inclusiveLastSimpleEvent(jde float64, lastFn, nextFn simpleEventSearchFunc) float64 {
last := lastFn(jde)
next := nextFn(jde)
if eventUTQueryBeforeOrEqual(next, jde) && eventUTQueryAfterOrEqual(next, jde) {
if next := nextFn(jde); eventUTQueryBeforeOrEqual(next, jde) && eventUTQueryAfterOrEqual(next, jde) {
return next
}
if eventUTQueryBeforeOrEqual(last, jde) {
return last
}
return last
}
func inclusiveNextSimpleEvent(jde float64, lastFn, nextFn simpleEventSearchFunc) float64 {
last := lastFn(jde)
if eventUTQueryBeforeOrEqual(last, jde) && eventUTQueryAfterOrEqual(last, jde) {
if last := lastFn(jde); eventUTQueryBeforeOrEqual(last, jde) && eventUTQueryAfterOrEqual(last, jde) {
return last
}
next := nextFn(jde)
if eventUTQueryAfterOrEqual(next, jde) {
return next
}
return next
return nextFn(jde)
}
+173
View File
@@ -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
+16
View File
@@ -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")
}
}
+126
View File
@@ -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)
}
+66
View File
@@ -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)
}
}
}
}
+5 -3
View File
@@ -9,7 +9,7 @@ const (
)
func eventQueryTTAsUT(queryTT float64) float64 {
return TD2UT(queryTT, false)
return TT2UTC(queryTT)
}
func eventUTQueryTTDelta(eventUT, queryTT float64) float64 {
@@ -25,11 +25,11 @@ func eventUTQueryAfterOrEqual(eventUT, queryTT float64) bool {
}
func eventUTNextQueryTT(eventUT float64) float64 {
return TD2UT(eventUT, true) + 1.0
return UTC2TT(eventUT) + 1.0
}
func eventUTLastQueryTT(eventUT float64) float64 {
return TD2UT(eventUT, true) - 1.0
return UTC2TT(eventUT) - 1.0
}
func innerNextCycleOffset(delta, period float64) float64 {
@@ -164,6 +164,8 @@ func maximizeInWindow(start, end, coarseStep float64, coarseFn, exactFn func(flo
if right-left <= innerEventMaximizeEpsilon {
return guess
}
// 注意:这里必须全部用全项函数迭代。粗阶段(截断函数)虽然更便宜,但截断峰位与全项峰位
// 相差分钟量级,粗段省下的求值次数会被精段为达到同等精度而多花掉,因此不做两段式拆分。
for i := 0; i < 20; i++ {
third := (right - left) / 3.0
leftThird := left + third
+3 -3
View File
@@ -34,7 +34,7 @@ func TestInnerPlanetExactEventBoundaryIncludesCurrent(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
queryTT := TD2UT(tc.seed, true)
queryTT := UTC2TT(tc.seed)
last := tc.lastFn(queryTT)
next := tc.nextFn(queryTT)
if !sameEventJD(last, tc.seed) {
@@ -64,7 +64,7 @@ func TestInnerPlanetNextEventAdvancesPastReturnedEvent(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
first := tc.next(tc.seed)
query := TD2UT(Date2JDE(JDE2DateByZone(first, time.UTC, false).Add(time.Second)), true)
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)
@@ -91,7 +91,7 @@ func TestInnerPlanetTypedConjunctionExactBoundaryIncludesCurrent(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
queryTT := TD2UT(tc.seed, true)
queryTT := UTC2TT(tc.seed)
last := tc.last(queryTT)
next := tc.next(queryTT)
if !sameEventJD(last, tc.seed) {
+16 -4
View File
@@ -93,8 +93,20 @@ func parseInnerBaselineTime(t *testing.T, value string) time.Time {
func innerBaselineTolerance(event innerBaselineEvent) time.Duration {
switch event.Kind {
case "IC", "SC", "P2R", "R2P":
return 2 * time.Minute
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:
@@ -140,8 +152,8 @@ func assertInnerBaselineEvent(t *testing.T, event innerBaselineEvent, lastFn, ne
when := parseInnerBaselineTime(t, event.VerifiedJST)
before := when.Add(-24 * time.Hour)
after := when.Add(24 * time.Hour)
next := JDE2DateByZone(nextFn(toUTJD(before)), when.Location(), false)
last := JDE2DateByZone(lastFn(toUTJD(after)), when.Location(), false)
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 {
+25 -21
View File
@@ -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)
}
+5 -5
View File
@@ -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)
}
}
+28
View File
@@ -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)
}
}
}
}
}
+69 -69
View File
@@ -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))
@@ -105,22 +105,22 @@ func JupiterApparentLoBo(jd float64) (float64, float64) {
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)
// 高度角、时角与天球座标三角转换公式
@@ -129,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)
// 三角转换公式
@@ -153,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
}
+114 -38
View File
@@ -64,6 +64,9 @@ func jupiterRADerivativeN(jde, delta float64, n int) float64 {
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 {
@@ -71,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 {
@@ -93,26 +105,38 @@ 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 {
@@ -148,60 +172,112 @@ func LastJupiterWesternQuadrature(jde float64) float64 {
}
func jupiterRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool) float64 {
oppositionTT := TD2UT(oppositionJD, true)
if !isFiniteFloat(oppositionJD) {
return math.NaN()
}
oppositionTT := UTC2TT(oppositionJD)
startTT := oppositionTT
endTT := oppositionTT
if searchBeforeOpposition {
easternQuadratureUT := jupiterConjunction(oppositionTT, 90, 0)
startTT = TD2UT(easternQuadratureUT, true)
startTT = UTC2TT(easternQuadratureUT)
} else {
westernQuadratureUT := jupiterConjunction(oppositionTT, 270, 1)
endTT = TD2UT(westernQuadratureUT, true)
endTT = UTC2TT(westernQuadratureUT)
}
bestJD := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 {
return jupiterRADerivativeN(jd, 1.0/86400.0, jupiterEventSearchN)
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, 0.5/86400.0)
return jupiterRADerivative(jd, stationDerivativeStepDay)
})
return TD2UT(bestJD, false)
return TT2UTC(bestJDE)
}
func NextJupiterRetrogradeToPrograde(jde float64) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
lastOppositionJD := jupiterConjunctionFull(jde, 180, 0)
date := jupiterRetrogradeAroundOpposition(lastOppositionJD, false)
if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) {
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
return date
}
nextOppositionJD := jupiterConjunctionFull(jde, 180, 1)
return jupiterRetrogradeAroundOpposition(nextOppositionJD, false)
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 {
if !isFiniteFloat(jde) {
return math.NaN()
}
lastOppositionJD := jupiterConjunctionFull(jde, 180, 0)
date := jupiterRetrogradeAroundOpposition(lastOppositionJD, false)
if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) {
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
return date
}
if !isFiniteFloat(lastOppositionJD) {
return math.NaN()
}
previousOppositionJD := jupiterConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0)
return jupiterRetrogradeAroundOpposition(previousOppositionJD, false)
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 {
if !isFiniteFloat(jde) {
return math.NaN()
}
nextOppositionJD := jupiterConjunctionFull(jde, 180, 1)
date := jupiterRetrogradeAroundOpposition(nextOppositionJD, true)
if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) {
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
return date
}
if !isFiniteFloat(nextOppositionJD) {
return math.NaN()
}
followingOppositionJD := jupiterConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1)
return jupiterRetrogradeAroundOpposition(followingOppositionJD, true)
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 {
if !isFiniteFloat(jde) {
return math.NaN()
}
nextOppositionJD := jupiterConjunctionFull(jde, 180, 1)
date := jupiterRetrogradeAroundOpposition(nextOppositionJD, true)
if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) {
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
return date
}
lastOppositionJD := jupiterConjunctionFull(jde, 180, 0)
return jupiterRetrogradeAroundOpposition(lastOppositionJD, true)
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
View File
@@ -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)
+3 -3
View File
@@ -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)
+6 -37
View File
@@ -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)
+161 -14
View File
@@ -41,6 +41,8 @@ type JupiterGalileanPhenomenonEvent struct {
type jupiterGalileanMetricSample struct {
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,15 +129,12 @@ 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) {
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(),
}
}
}
+41 -5
View File
@@ -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)
}
}
+5 -5
View File
@@ -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
View File
@@ -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
+244
View File
@@ -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)
}
+157
View File
@@ -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
View File
@@ -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,
+98
View File
@@ -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))
}
}
+5 -5
View File
@@ -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 {
+69
View File
@@ -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,
}
}
+53
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+69 -69
View File
@@ -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))
@@ -115,22 +115,22 @@ func MarsTrueLo(jd float64) float64 {
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)
// 高度角、时角与天球座标三角转换公式
@@ -139,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)
// 三角转换公式
@@ -163,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
}
+1 -1
View File
@@ -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 {
+117 -100
View File
@@ -12,6 +12,9 @@ const (
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,26 +108,38 @@ 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 {
@@ -148,122 +175,112 @@ func LastMarsWesternQuadrature(jde float64) float64 {
}
func marsRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool) float64 {
jde := oppositionJD
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)
}
func marsOppositionFromBefore(oppositionJD float64) float64 {
return marsConjunctionFull(eventUTLastQueryTT(oppositionJD), 180, 1)
}
func marsOppositionFromAfter(oppositionJD float64) float64 {
return marsConjunctionFull(eventUTNextQueryTT(oppositionJD), 180, 0)
}
func stabilizeMarsStationNearQuery(jde, date float64, searchBeforeOpposition bool) float64 {
if math.Abs(eventUTQueryTTDelta(date, jde)) > exactEventTolerance {
return date
}
if searchBeforeOpposition {
stableOppositionJD := NextMarsOpposition(jde)
sameOppositionJD := marsOppositionFromAfter(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, marsRetrogradeAroundOpposition(stableOppositionJD, true), marsRetrogradeAroundOpposition(sameOppositionJD, true))
}
stableOppositionJD := LastMarsOpposition(jde)
sameOppositionJD := marsOppositionFromBefore(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, marsRetrogradeAroundOpposition(stableOppositionJD, false), marsRetrogradeAroundOpposition(sameOppositionJD, false))
return TT2UTC(bestJDE)
}
func NextMarsRetrogradeToPrograde(jde float64) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
date := marsRetrogradeAroundOpposition(lastOppositionJD, false)
date = stabilizeMarsStationNearQuery(jde, date, false)
if sameEventUTQueryTT(date, jde) {
stableOppositionJD := LastMarsOpposition(jde)
stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, false)
sameOppositionJD := marsOppositionFromBefore(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, false))
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
return date
}
if !eventUTQueryAfterOrEqual(date, jde) {
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
return marsRetrogradeAroundOpposition(nextOppositionJD, false)
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 {
if !isFiniteFloat(jde) {
return math.NaN()
}
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
date := marsRetrogradeAroundOpposition(lastOppositionJD, false)
date = stabilizeMarsStationNearQuery(jde, date, false)
if sameEventUTQueryTT(date, jde) {
stableOppositionJD := LastMarsOpposition(jde)
stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, false)
sameOppositionJD := marsOppositionFromBefore(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, false))
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
return date
}
if !isFiniteFloat(lastOppositionJD) {
return math.NaN()
}
if !eventUTQueryBeforeOrEqual(date, jde) {
previousOppositionJD := marsConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0)
return marsRetrogradeAroundOpposition(previousOppositionJD, false)
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 {
if !isFiniteFloat(jde) {
return math.NaN()
}
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
date := marsRetrogradeAroundOpposition(nextOppositionJD, true)
date = stabilizeMarsStationNearQuery(jde, date, true)
if sameEventUTQueryTT(date, jde) {
stableOppositionJD := NextMarsOpposition(jde)
stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, true)
sameOppositionJD := marsOppositionFromAfter(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, true))
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
return date
}
if !isFiniteFloat(nextOppositionJD) {
return math.NaN()
}
if !eventUTQueryAfterOrEqual(date, jde) {
followingOppositionJD := marsConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1)
return marsRetrogradeAroundOpposition(followingOppositionJD, true)
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 {
if !isFiniteFloat(jde) {
return math.NaN()
}
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
date := marsRetrogradeAroundOpposition(nextOppositionJD, true)
date = stabilizeMarsStationNearQuery(jde, date, true)
if sameEventUTQueryTT(date, jde) {
stableOppositionJD := NextMarsOpposition(jde)
stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, true)
sameOppositionJD := marsOppositionFromAfter(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, true))
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
return date
}
if !isFiniteFloat(nextOppositionJD) {
return math.NaN()
}
if !eventUTQueryBeforeOrEqual(date, jde) {
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
return marsRetrogradeAroundOpposition(lastOppositionJD, true)
if !isFiniteFloat(lastOppositionJD) {
return math.NaN()
}
date = marsRetrogradeAroundOpposition(lastOppositionJD, true)
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
return math.NaN()
}
return date
}
+56
View File
@@ -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")
}
}
+69 -69
View File
@@ -7,76 +7,76 @@ 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 {
@@ -98,22 +98,22 @@ func MercuryApparentLoBo(jd float64) (float64, float64) {
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)
// 高度角、时角与天球座标三角转换公式
@@ -122,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)
// 三角转换公式
@@ -146,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
}
+1 -1
View File
@@ -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 {
+411 -271
View File
@@ -12,6 +12,25 @@ const (
mercuryConjunctionDerivativeStepDay = 2e-5 * 36525.0
mercuryLightTimeDaysPerAU = 0.0057755183
mercuryEventSearchN = 16
mercuryStationWindowDays = 30.0
mercuryStationDerivativeStepDay = 0.01
mercuryStationCoarseStepDay = 2.0
mercuryStationHalfWindowDay = 2.0
mercuryStationMotionTolerance = 1e-3
// mercuryStationAnchorAttempts 类型化「留」在锚点不满足侧向不变量时,最多推进/回退几个会合周期。
mercuryStationAnchorAttempts = 4
// mercuryConjunctionSameInstantDegrees 合搜索「同刻」快速路径的视黄经差触发阈值(度)。
// 0.05 度约合 30 分钟的时间跨度,远大于截断级数在根附近的误差,
// 从而保证「查询落在合附近」时一定走精确解 + 侧向判定,而不是被方向扫描跨过去。
mercuryConjunctionSameInstantDegrees = 5.0e-2
// mercuryConjunctionScanStepDay / mercuryConjunctionScanMaxSteps 方向性括号扫描参数:
// 8 天一步 × 80 步 = 640 天,覆盖一个水星会合周期(115.88 天)且有大量余量。
mercuryConjunctionScanStepDay = 8.0
mercuryConjunctionScanMaxSteps = 80
// mercuryConjunctionPolishToleranceDay 全项抛光的时间容差(0.01 秒):
// 割线法从 8 天括号收敛到该量级只需多 1~2 次求值,但把合时刻精度保持在毫秒级
// (与旧实现牛顿法的 1e-5 天口径一致)。
mercuryConjunctionPolishToleranceDay = 0.01 / 86400.0
)
type mercuryConjunctionLBR struct {
@@ -32,11 +51,11 @@ type mercuryConjunctionResult struct {
geoLightDays float64
}
func mercuryHelioN(planetIndex int, jd float64, n int) mercuryConjunctionLBR {
func mercuryHelioN(planetIndex int, jde float64, n int) mercuryConjunctionLBR {
return mercuryConjunctionLBR{
lo: planet.WherePlanetN(planetIndex, 0, jd, n),
bo: planet.WherePlanetN(planetIndex, 1, jd, n),
r: planet.WherePlanetN(planetIndex, 2, jd, n),
lo: planet.WherePlanetN(planetIndex, 0, jde, n),
bo: planet.WherePlanetN(planetIndex, 1, jde, n),
r: planet.WherePlanetN(planetIndex, 2, jde, n),
}
}
@@ -63,9 +82,9 @@ func mercuryConjunctionAngleDelta(diff float64) float64 {
return diff
}
func mercuryConjunctionHeliocentricDelta(jd, targetDeg float64, n int) float64 {
planetLo := planet.WherePlanetN(1, 0, jd, n)
earthLo := planet.WherePlanetN(-1, 0, jd, n)
func mercuryConjunctionHeliocentricDelta(jde, targetDeg float64, n int) float64 {
planetLo := planet.WherePlanetN(1, 0, jde, n)
earthLo := planet.WherePlanetN(-1, 0, jde, n)
return mercuryConjunctionAngleDelta(planetLo - earthLo - targetDeg)
}
@@ -82,8 +101,8 @@ func mercuryConjunctionDifference(jd float64, n int, targetDeg, sunLightDays, ge
}
}
func mercuryConjunctionExactDelta(jd float64) float64 {
return mercuryConjunctionAngleDelta(MercuryApparentLo(jd) - HSunApparentLo(jd))
func mercuryConjunctionExactDelta(jde float64) float64 {
return mercuryConjunctionAngleDelta(MercuryApparentLo(jde) - HSunApparentLo(jde))
}
func mercuryConjunctionApproxTT(seed float64, inferior bool) float64 {
@@ -91,87 +110,61 @@ func mercuryConjunctionApproxTT(seed float64, inferior bool) float64 {
if inferior {
heliocentricTarget = 0
}
jd := seed
jde := seed
for i := 0; i < 6; i++ {
jd -= mercuryConjunctionHeliocentricDelta(jd, heliocentricTarget, 8) / (360.0 / MERCURY_S_PERIOD)
jde -= mercuryConjunctionHeliocentricDelta(jde, heliocentricTarget, 8) / (360.0 / MERCURY_S_PERIOD)
}
startSample := mercuryConjunctionDifference(jd, 8, 0, 0, 0)
nextSample := mercuryConjunctionDifference(jd+mercuryConjunctionDerivativeStepDay, 8, 0, 0, 0)
startSample := mercuryConjunctionDifference(jde, 8, 0, 0, 0)
nextSample := mercuryConjunctionDifference(jde+mercuryConjunctionDerivativeStepDay, 8, 0, 0, 0)
diffSlope := mercuryConjunctionAngleDelta(nextSample.diff-startSample.diff) / mercuryConjunctionDerivativeStepDay
refined := mercuryConjunctionDifference(jd, 40, 0, startSample.sunLightDays, startSample.geoLightDays)
jd -= refined.diff / diffSlope
final := mercuryConjunctionDifference(jd, -1, 0, refined.sunLightDays, refined.geoLightDays)
jd -= final.diff / diffSlope
return jd
refined := mercuryConjunctionDifference(jde, 40, 0, startSample.sunLightDays, startSample.geoLightDays)
jde -= refined.diff / diffSlope
final := mercuryConjunctionDifference(jde, -1, 0, refined.sunLightDays, refined.geoLightDays)
jde -= final.diff / diffSlope
return jde
}
func mercuryConjunctionExactTT(seed float64, inferior bool) float64 {
estimateJD := mercuryConjunctionApproxTT(seed, inferior)
for {
prevJD := estimateJD
longitudeDelta := mercuryConjunctionExactDelta(prevJD)
longitudeSlope := (mercuryConjunctionExactDelta(prevJD+0.000005) - mercuryConjunctionExactDelta(prevJD-0.000005)) / 0.00001
estimateJD = prevJD - longitudeDelta/longitudeSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
converged := false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJDE := estimateJD
longitudeDelta := mercuryConjunctionExactDelta(prevJDE)
longitudeSlope := (mercuryConjunctionExactDelta(prevJDE+0.000005) - mercuryConjunctionExactDelta(prevJDE-0.000005)) / 0.00001
nextJD := prevJDE - longitudeDelta/longitudeSlope
estimateJD = nextJD
if math.Abs(nextJD-prevJDE) <= 0.00001 {
converged = true
break
}
}
if !converged {
return math.NaN()
}
return estimateJD
}
func mercuryConjunctionLegacy(jde float64, next uint8) float64 {
//0=last 1=next
longitudeDeltaAt := func(jde float64) float64 {
return mercuryConjunctionExactDelta(jde)
}
currentDelta := longitudeDeltaAt(jde)
distanceTrend := math.Abs(longitudeDeltaAt(jde+1/86400.0)) - math.Abs(currentDelta)
if distanceTrend >= 0 && next == 1 && currentDelta > 0 {
jde += MERCURY_S_PERIOD/8.0 + 2
}
if distanceTrend >= 0 && next == 1 && currentDelta < 0 {
jde += MERCURY_S_PERIOD/6.0 + 2
}
if distanceTrend <= 0 && next == 0 && currentDelta < 0 {
jde -= MERCURY_S_PERIOD/8.0 + 2
}
if distanceTrend <= 0 && next == 0 && currentDelta > 0 {
jde -= MERCURY_S_PERIOD/6.0 + 2
}
for {
currentDelta := longitudeDeltaAt(jde)
distanceTrend := math.Abs(longitudeDeltaAt(jde+1/86400.0)) - math.Abs(currentDelta)
if math.Abs(currentDelta) > 12 || (distanceTrend > 0 && next == 1) || (distanceTrend < 0 && next == 0) {
if next == 1 {
jde += 2
} else {
jde -= 2
}
continue
}
break
}
estimateJD := jde
for {
prevJD := estimateJD
longitudeDelta := longitudeDeltaAt(prevJD)
longitudeSlope := (longitudeDeltaAt(prevJD+0.000005) - longitudeDeltaAt(prevJD-0.000005)) / 0.00001
estimateJD = prevJD - longitudeDelta/longitudeSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
}
return TD2UT(estimateJD, false)
}
// mercuryConjunction 在 jde 的指定方向上求最近一次水星合(内合/外合)。
//
// 旧实现用「启发式跳 + 2 天一步走」:跳过头落到合之后时,|Δ| 会持续变大,
// 于是走满一个会合周期、跳过一次合(例如 2008-01-22 查到 2008-06-07 而不是 2008-02-06),
// 并且下游的类型化「留」会因此返回错事件、甚至让 Last 返回未来。
// 新实现改成方向性括号扫描:截断级数逐段找异号区间(顺序扫描 ⇒ 一定取最近的一个合),
// 再用全项级数在括号内抛光;扫满 640 天仍无括号时有界返回 NaN。
func mercuryConjunction(jde float64, next uint8) float64 {
//0=last 1=next
if math.Abs(mercuryConjunctionExactDelta(jde)) <= 30.0/86400.0 {
if !isFiniteFloat(jde) {
return math.NaN()
}
if math.Abs(mercuryConjunctionExactDelta(jde)) <= mercuryConjunctionSameInstantDegrees {
best := math.NaN()
consider := func(inferior bool) {
eventUT := TD2UT(mercuryConjunctionExactTT(jde, inferior), false)
eventUT := TT2UTC(mercuryConjunctionExactTT(jde, inferior))
if !isFiniteFloat(eventUT) {
return
}
if next == 0 && !eventUTQueryBeforeOrEqual(eventUT, jde) {
return
}
@@ -188,42 +181,87 @@ func mercuryConjunction(jde float64, next uint8) float64 {
return best
}
}
currentDelta := mercuryConjunctionExactDelta(jde)
// pos 大于0:远离太阳 小于0:靠近太阳
distanceTrend := math.Abs(mercuryConjunctionExactDelta(jde+1/86400.0)) - math.Abs(currentDelta)
if distanceTrend >= 0 && next == 1 && currentDelta > 0 {
jde += MERCURY_S_PERIOD/8.0 + 2
direction := 1.0
if next == 0 {
direction = -1
}
if distanceTrend >= 0 && next == 1 && currentDelta < 0 {
jde += MERCURY_S_PERIOD/6.0 + 2
leftJDE := jde
leftValue := mercuryConjunctionDeltaN(leftJDE, mercuryEventSearchN)
if !isFiniteFloat(leftValue) {
return math.NaN()
}
if distanceTrend <= 0 && next == 0 && currentDelta < 0 {
jde -= MERCURY_S_PERIOD/8.0 + 2
for i := 0; i < mercuryConjunctionScanMaxSteps; i++ {
rightJDE := jde + direction*mercuryConjunctionScanStepDay*float64(i+1)
rightValue := mercuryConjunctionDeltaN(rightJDE, mercuryEventSearchN)
if !isFiniteFloat(rightValue) {
return math.NaN()
}
if distanceTrend <= 0 && next == 0 && currentDelta > 0 {
jde -= MERCURY_S_PERIOD/6.0 + 2
if leftValue == 0 || rightValue == 0 || leftValue*rightValue < 0 {
return mercuryConjunctionPolish(jde, leftJDE, rightJDE, direction)
}
for {
currentDelta := mercuryConjunctionExactDelta(jde)
distanceTrend := math.Abs(mercuryConjunctionExactDelta(jde+1/86400.0)) - math.Abs(currentDelta)
if math.Abs(currentDelta) > 12 || (distanceTrend > 0 && next == 1) || (distanceTrend < 0 && next == 0) {
if next == 1 {
jde += 2
} else {
jde -= 2
leftJDE, leftValue = rightJDE, rightValue
}
continue
}
break
return math.NaN()
}
inferior := mercuryConjunctionExactTT(jde, true)
superior := mercuryConjunctionExactTT(jde, false)
best := inferior
if math.Abs(superior-jde) < math.Abs(inferior-jde) {
best = superior
// mercuryConjunctionDeltaN 截断级数下的水星-太阳视黄经差(度,[-180,180]),用于方向性括号扫描。
func mercuryConjunctionDeltaN(jde float64, n int) float64 {
return mercuryConjunctionAngleDelta(MercuryApparentLoN(jde, n) - HSunApparentLoN(jde, n))
}
return TD2UT(best, false)
// mercuryConjunctionPolish 用全项级数在截断级数给出的括号内抛光。
// 截断误差可能让括号两端在全项函数上同号(罕见),此时沿扫描方向再扩一两个扫描步;
// 若仍未被确认(典型情形:查询几乎正好落在合上,截断级数在根两侧的符号与全项不一致),
// 退回全项级数的方向扫描,保证有界且不返回 NaN。
func mercuryConjunctionPolish(jde, leftJDE, rightJDE, direction float64) float64 {
for attempt := 0; attempt < 3; attempt++ {
leftValue := mercuryConjunctionExactDelta(leftJDE)
rightValue := mercuryConjunctionExactDelta(rightJDE)
if !isFiniteFloat(leftValue) || !isFiniteFloat(rightValue) {
return math.NaN()
}
if leftValue == 0 || rightValue == 0 || leftValue*rightValue < 0 {
root, ok := eventBracketSecantRoot(leftJDE, rightJDE, leftValue, rightValue,
mercuryConjunctionPolishToleranceDay, mercuryConjunctionExactDelta)
if !ok {
return math.NaN()
}
return TT2UTC(root)
}
if direction > 0 {
rightJDE += mercuryConjunctionScanStepDay
continue
}
leftJDE -= mercuryConjunctionScanStepDay
}
return mercuryConjunctionFullDirectionalScan(jde, direction)
}
// mercuryConjunctionFullDirectionalScan 全项级数的方向扫描(截断括号未被确认时的兜底)。
func mercuryConjunctionFullDirectionalScan(jde, direction float64) float64 {
leftJDE := jde
leftValue := mercuryConjunctionExactDelta(leftJDE)
if !isFiniteFloat(leftValue) {
return math.NaN()
}
for i := 0; i < mercuryConjunctionScanMaxSteps; i++ {
rightJDE := jde + direction*mercuryConjunctionScanStepDay*float64(i+1)
rightValue := mercuryConjunctionExactDelta(rightJDE)
if !isFiniteFloat(rightValue) {
return math.NaN()
}
if leftValue == 0 || rightValue == 0 || leftValue*rightValue < 0 {
root, ok := eventBracketSecantRoot(leftJDE, rightJDE, leftValue, rightValue,
mercuryConjunctionPolishToleranceDay, mercuryConjunctionExactDelta)
if !ok {
return math.NaN()
}
return TT2UTC(root)
}
leftJDE, leftValue = rightJDE, rightValue
}
return math.NaN()
}
func LastMercuryConjunction(jde float64) float64 {
@@ -274,51 +312,6 @@ func LastMercurySuperiorConjunction(jde float64) float64 {
return date
}
func mercuryRetrograde(jde float64) float64 {
//0=last 1=next
solarRADelta := func(jde float64) float64 {
sub := Limit360(MercuryApparentRa(jde) - SunApparentRa(jde))
if sub > 180 {
sub -= 360
}
if sub < -180 {
sub += 360
}
return sub
}
lastConjunction := mercuryConjunctionLegacy(jde, 0)
nextConjunction := mercuryConjunctionLegacy(jde, 1)
currentRADelta := solarRADelta(jde)
if currentRADelta > 0 {
jde = lastConjunction + ((nextConjunction - lastConjunction) / 5.0 * 3.5)
} else {
jde = lastConjunction + ((nextConjunction - lastConjunction) / 5.5)
}
for {
currentRate := mercuryRADerivative(jde, 1.0/86400.0)
if math.Abs(currentRate) > 0.55 {
jde += 2
continue
}
break
}
estimateJD := jde
for {
prevJD := estimateJD
rateValue := mercuryRADerivative(prevJD, 2.0/86400.0)
rateSlope := (mercuryRADerivative(prevJD+15.0/86400.0, 2.0/86400.0) - mercuryRADerivative(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 mercuryRADerivative(jd, 0.5/86400.0)
})
//fmt.Println((bestJD - lastConjunction) / (nextConjunction - lastConjunction))
return TD2UT(bestJD, false)
}
func mercuryRADerivative(jde, delta float64) float64 {
sub := MercuryApparentRa(jde+delta) - MercuryApparentRa(jde-delta)
if sub > 180 {
@@ -330,55 +323,226 @@ func mercuryRADerivative(jde, delta float64) float64 {
return sub / (2 * delta)
}
func mercuryStationIsProgradeToRetrograde(eventUT float64) bool {
for _, offset := range []float64{0.25, 0.5, 1.0} {
before := mercuryRADerivative(eventUT-offset, 0.5/86400.0)
after := mercuryRADerivative(eventUT+offset, 0.5/86400.0)
if before > 0 && after < 0 {
return true
func mercuryRADerivativeN(jde, delta float64, n int) float64 {
sub := MercuryApparentRaN(jde+delta, n) - MercuryApparentRaN(jde-delta, n)
if sub > 180 {
sub -= 360
}
if before < 0 && after > 0 {
if sub < -180 {
sub += 360
}
return sub / (2 * delta)
}
func mercuryStationInWindow(startTT, endTT float64) float64 {
bestJDE := zeroEventInWindow(startTT, endTT, mercuryStationCoarseStepDay, mercuryStationHalfWindowDay, 30.0/86400.0, func(jd float64) float64 {
return mercuryRADerivativeN(jd, mercuryStationDerivativeStepDay, mercuryEventSearchN)
}, func(jd float64) float64 {
return mercuryRADerivative(jd, mercuryStationDerivativeStepDay)
})
return TT2UTC(bestJDE)
}
func mercuryStationBetween(startTT, endTT float64) bool {
if endTT < startTT {
startTT, endTT = endTT, startTT
}
if endTT-startTT <= 0 {
return false
}
// 跨度超过一个回留窗口时截断扫描已不可靠:返回 false 不再阻断快速路径。
// 此时候选事件由 mercuryConjunction 的完整方向扫描保证,快速路径无需再复核。
// A span beyond the station window makes the truncated scan unreliable; report false so the
// fast path is not blocked. The typed candidate is already guaranteed by mercuryConjunction's
// exhaustive directional scan.
if endTT-startTT > mercuryStationWindowDays {
return false
}
before := mercuryRADerivative(eventUT-0.25, 0.5/86400.0)
after := mercuryRADerivative(eventUT+0.25, 0.5/86400.0)
return before > after
// 截断扫描足以判断单候选快速路径是否安全 / A truncated scan is enough to decide whether the one-candidate fast path is safe.
left := startTT
leftValue := mercuryRADerivativeN(left, mercuryStationDerivativeStepDay, mercuryEventSearchN)
for left < endTT {
right := left + mercuryStationCoarseStepDay
if right > endTT {
right = endTT
}
rightValue := mercuryRADerivativeN(right, mercuryStationDerivativeStepDay, mercuryEventSearchN)
if leftValue == 0 || leftValue*rightValue < 0 || rightValue == 0 {
return true
}
left = right
leftValue = rightValue
}
return false
}
func nextMercuryTypedStation(jde float64, progradeToRetrograde bool) float64 {
date := NextMercuryRetrogradeStrict(jde)
for mercuryStationIsProgradeToRetrograde(date) != progradeToRetrograde {
date = NextMercuryRetrogradeStrict(eventUTNextQueryTT(date))
}
return date
func mercuryProgradeToRetrogradeAroundInferior(inferiorUT float64) float64 {
inferiorTT := UTC2TT(inferiorUT)
return mercuryStationInWindow(inferiorTT-mercuryStationWindowDays, inferiorTT)
}
func lastMercuryTypedStation(jde float64, progradeToRetrograde bool) float64 {
date := LastMercuryRetrogradeStrict(jde)
for mercuryStationIsProgradeToRetrograde(date) != progradeToRetrograde {
date = LastMercuryRetrogradeStrict(eventUTLastQueryTT(date))
func mercuryRetrogradeToProgradeAroundInferior(inferiorUT float64) float64 {
inferiorTT := UTC2TT(inferiorUT)
return mercuryStationInWindow(inferiorTT, inferiorTT+mercuryStationWindowDays)
}
// 类型化「留」统一结构:以下合为锚点求候选,再用侧向不变量(Next 不得早于查询、
// Last 不得晚于查询)校验;不满足则把锚点推进/回退一个会合周期重试,最多
// mercuryStationAnchorAttempts 次,最终仍有界失败为 NaN。
// 这保证 Last* 永远不会返回未来、Next* 永远不会返回过去的事件。
func NextMercuryProgradeToRetrograde(jde float64) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
inferior := NextMercuryInferiorConjunction(jde)
for i := 0; i < mercuryStationAnchorAttempts && isFiniteFloat(inferior); i++ {
date := mercuryProgradeToRetrogradeAroundInferior(inferior)
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
return date
}
inferior = NextMercuryInferiorConjunction(eventUTNextQueryTT(inferior))
}
return math.NaN()
}
func NextMercuryRetrogradeToPrograde(jde float64) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
inferior := LastMercuryInferiorConjunction(jde)
for i := 0; i < mercuryStationAnchorAttempts && isFiniteFloat(inferior); i++ {
date := mercuryRetrogradeToProgradeAroundInferior(inferior)
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
return date
}
inferior = NextMercuryInferiorConjunction(eventUTNextQueryTT(inferior))
}
return math.NaN()
}
func LastMercuryProgradeToRetrograde(jde float64) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
inferior := NextMercuryInferiorConjunction(jde)
for i := 0; i < mercuryStationAnchorAttempts && isFiniteFloat(inferior); i++ {
date := mercuryProgradeToRetrogradeAroundInferior(inferior)
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
return date
}
inferior = LastMercuryInferiorConjunction(eventUTLastQueryTT(inferior))
}
return math.NaN()
}
func LastMercuryRetrogradeToPrograde(jde float64) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
inferior := LastMercuryInferiorConjunction(jde)
for i := 0; i < mercuryStationAnchorAttempts && isFiniteFloat(inferior); i++ {
date := mercuryRetrogradeToProgradeAroundInferior(inferior)
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
return date
}
inferior = LastMercuryInferiorConjunction(eventUTLastQueryTT(inferior))
}
return math.NaN()
}
// earliestFiniteEventUT / latestFiniteEventUT 在候选中取最早/最晚的有限值(全部非有限时返回 NaN)。
func earliestFiniteEventUT(candidates ...float64) float64 {
best := math.NaN()
for _, candidate := range candidates {
if !isFiniteFloat(candidate) {
continue
}
if math.IsNaN(best) || candidate < best {
best = candidate
}
}
return best
}
func latestFiniteEventUT(candidates ...float64) float64 {
best := math.NaN()
for _, candidate := range candidates {
if !isFiniteFloat(candidate) {
continue
}
if math.IsNaN(best) || candidate > best {
best = candidate
}
}
return best
}
func nextMercuryRetrogradeFromTyped(jde float64) float64 {
return earliestFiniteEventUT(NextMercuryProgradeToRetrograde(jde), NextMercuryRetrogradeToPrograde(jde))
}
func NextMercuryRetrograde(jde float64) float64 {
date := mercuryRetrograde(jde)
if !eventUTQueryAfterOrEqual(date, jde) {
nextConjunction := NextMercuryConjunctionStrict(jde)
return mercuryRetrograde(nextConjunction + 2)
if !isFiniteFloat(jde) {
return math.NaN()
}
return date
motion := mercuryRADerivative(jde, mercuryStationDerivativeStepDay)
if motion > mercuryStationMotionTolerance {
p2r := NextMercuryProgradeToRetrograde(jde)
if isFiniteFloat(p2r) && !mercuryStationBetween(jde, UTC2TT(p2r)) {
return p2r
}
best := earliestFiniteEventUT(p2r, NextMercuryRetrogradeToPrograde(jde))
if !stationUTQueryAfterOrEqual(best, jde) {
return math.NaN()
}
return best
}
if motion < -mercuryStationMotionTolerance {
r2p := NextMercuryRetrogradeToPrograde(jde)
if isFiniteFloat(r2p) && !mercuryStationBetween(jde, UTC2TT(r2p)) {
return r2p
}
best := earliestFiniteEventUT(NextMercuryProgradeToRetrograde(jde), r2p)
if !stationUTQueryAfterOrEqual(best, jde) {
return math.NaN()
}
return best
}
return nextMercuryRetrogradeFromTyped(jde)
}
func lastMercuryRetrogradeFromTyped(jde float64) float64 {
return latestFiniteEventUT(LastMercuryProgradeToRetrograde(jde), LastMercuryRetrogradeToPrograde(jde))
}
func LastMercuryRetrograde(jde float64) float64 {
lastConjunction := LastMercuryConjunctionStrict(jde)
date := mercuryRetrograde(lastConjunction + 2)
if !eventUTQueryBeforeOrEqual(date, jde) {
previousConjunction := LastMercuryConjunctionStrict(eventUTLastQueryTT(lastConjunction))
return mercuryRetrograde(previousConjunction + 2)
if !isFiniteFloat(jde) {
return math.NaN()
}
return date
motion := mercuryRADerivative(jde, mercuryStationDerivativeStepDay)
if motion > mercuryStationMotionTolerance {
r2p := LastMercuryRetrogradeToPrograde(jde)
if isFiniteFloat(r2p) && !mercuryStationBetween(UTC2TT(r2p), jde) {
return r2p
}
best := latestFiniteEventUT(LastMercuryProgradeToRetrograde(jde), r2p)
if !stationUTQueryBeforeOrEqual(best, jde) {
return math.NaN()
}
return best
}
if motion < -mercuryStationMotionTolerance {
p2r := LastMercuryProgradeToRetrograde(jde)
if isFiniteFloat(p2r) && !mercuryStationBetween(UTC2TT(p2r), jde) {
return p2r
}
best := latestFiniteEventUT(p2r, LastMercuryRetrogradeToPrograde(jde))
if !stationUTQueryBeforeOrEqual(best, jde) {
return math.NaN()
}
return best
}
return lastMercuryRetrogradeFromTyped(jde)
}
func LastMercuryRetrogradeStrict(jde float64) float64 {
@@ -389,22 +553,6 @@ func NextMercuryRetrogradeStrict(jde float64) float64 {
return NextMercuryRetrograde(jde)
}
func NextMercuryProgradeToRetrograde(jde float64) float64 {
return nextMercuryTypedStation(jde, true)
}
func NextMercuryRetrogradeToPrograde(jde float64) float64 {
return nextMercuryTypedStation(jde, false)
}
func LastMercuryProgradeToRetrograde(jde float64) float64 {
return lastMercuryTypedStation(jde, true)
}
func LastMercuryRetrogradeToPrograde(jde float64) float64 {
return lastMercuryTypedStation(jde, false)
}
func MercurySunElongation(jde float64) float64 {
lo1, bo1 := MercuryApparentLoBo(jde)
lo2 := HSunApparentLo(jde)
@@ -419,20 +567,15 @@ func mercurySunElongationN(jde float64, n int) float64 {
return StarAngularSeparation(lo1, bo1, lo2, bo2)
}
func mercuryTrueElongationN(jde float64, n int) float64 {
earth := mercuryHelioN(-1, jde, n)
planetPos := mercuryHelioN(1, jde, n)
geo := mercuryGeocentric(planetPos, earth)
return StarAngularSeparation(geo.lo, geo.bo, HSunTrueLoN(jde, n), HSunTrueBoN(jde, n))
}
// mercuryGreatestElongationInWindow 求窗口内大距:目标是公开 MercurySunElongation 的极大(视距角),
// 而不是忽略光行差/视位置修正的真距角,否则返回的时刻不是调用方能量到的那个极值。
// 窗口两端是世界时,目标函数收力学时,因此逐次换算。
func mercuryGreatestElongationInWindow(start, end float64) float64 {
best := maximizeInWindow(start, end, 2.0, func(jd float64) float64 {
return mercuryTrueElongationN(jd, mercuryEventSearchN)
}, func(jd float64) float64 {
return mercuryTrueElongationN(jd, -1)
return maximizeInWindow(start, end, 2.0, func(utJD float64) float64 {
return mercurySunElongationN(UTC2TT(utJD), mercuryEventSearchN)
}, func(utJD float64) float64 {
return MercurySunElongation(UTC2TT(utJD))
})
return TD2UT(best, false)
}
func mercuryEastElongationWindowEndingAt(inferior float64) (float64, float64) {
@@ -466,127 +609,124 @@ func mercuryWestElongationWindowContaining(jde float64) (float64, float64) {
}
func nextMercuryGreatestElongationTyped(jde float64, east bool) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
if east {
start, windowEnd := mercuryEastElongationWindowContaining(jde)
for {
for i := 0; i < eventDirectionalSearchIterations; i++ {
date := mercuryGreatestElongationInWindow(start, windowEnd)
if !isFiniteFloat(date) {
return math.NaN()
}
if eventUTQueryAfterOrEqual(date, jde) {
return date
}
nextInferior := NextMercuryInferiorConjunction(eventUTNextQueryTT(windowEnd))
start, windowEnd = mercuryEastElongationWindowEndingAt(nextInferior)
}
return math.NaN()
}
start, windowEnd := mercuryWestElongationWindowContaining(jde)
for {
for i := 0; i < eventDirectionalSearchIterations; i++ {
date := mercuryGreatestElongationInWindow(start, windowEnd)
if !isFiniteFloat(date) {
return math.NaN()
}
if eventUTQueryAfterOrEqual(date, jde) {
return date
}
nextSuperior := NextMercurySuperiorConjunction(eventUTNextQueryTT(windowEnd))
start, windowEnd = mercuryWestElongationWindowEndingAt(nextSuperior)
}
return math.NaN()
}
func lastMercuryGreatestElongationTyped(jde float64, east bool) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
if east {
start, windowEnd := mercuryEastElongationWindowContaining(jde)
for {
for i := 0; i < eventDirectionalSearchIterations; i++ {
date := mercuryGreatestElongationInWindow(start, windowEnd)
if !isFiniteFloat(date) {
return math.NaN()
}
if eventUTQueryBeforeOrEqual(date, jde) {
return date
}
prevInferior := LastMercuryInferiorConjunction(eventUTLastQueryTT(start))
start, windowEnd = mercuryEastElongationWindowEndingAt(prevInferior)
}
return math.NaN()
}
start, windowEnd := mercuryWestElongationWindowContaining(jde)
for {
for i := 0; i < eventDirectionalSearchIterations; i++ {
date := mercuryGreatestElongationInWindow(start, windowEnd)
if !isFiniteFloat(date) {
return math.NaN()
}
if eventUTQueryBeforeOrEqual(date, jde) {
return date
}
prevSuperior := LastMercurySuperiorConjunction(eventUTLastQueryTT(start))
start, windowEnd = mercuryWestElongationWindowEndingAt(prevSuperior)
}
return math.NaN()
}
func mercuryGreatestElongation(jde float64) float64 {
solarRADelta := func(jde float64) float64 {
sub := Limit360(MercuryApparentRa(jde) - SunApparentRa(jde))
if sub > 180 {
sub -= 360
// mercuryElongationWindowAt 返回包含 jde 的该侧大距窗口;查询落在合的 4 秒内边距里时两侧都不包含。
func mercuryElongationWindowAt(jde float64, east bool) (float64, float64, bool) {
if east {
start, end := mercuryEastElongationWindowEndingAt(NextMercuryInferiorConjunction(jde))
return start, end, eventUTQueryBeforeOrEqual(start, jde) && eventUTQueryAfterOrEqual(end, jde)
}
if sub < -180 {
sub += 360
start, end := mercuryWestElongationWindowEndingAt(NextMercurySuperiorConjunction(jde))
return start, end, eventUTQueryBeforeOrEqual(start, jde) && eventUTQueryAfterOrEqual(end, jde)
}
return sub
// 无东西侧参数的 Next/Last 先只看查询所在窗口那一侧:该侧大距未过就是答案,已过则另一侧紧接着的
// 下一个才是答案,因此只有在查询贴住合(两侧窗口都不含)时才退化为两侧都算。赤经差在合附近会提前
// 变号,不能用它定窗口。
func NextMercuryGreatestElongation(jde float64) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
elongationRate := func(jde float64, delta float64) float64 {
sub := MercurySunElongation(jde+delta) - MercurySunElongation(jde-delta)
if sub > 180 {
sub -= 360
}
if sub < -180 {
sub += 360
}
return sub / (2 * delta)
}
lastConjunction := LastMercuryConjunctionStrict(jde)
nextConjunction := NextMercuryConjunctionStrict(jde)
currentRADelta := solarRADelta(jde)
if currentRADelta > 0 {
jde = lastConjunction + ((nextConjunction - lastConjunction) / 5.0 * 2.0)
} else {
jde = lastConjunction + ((nextConjunction - lastConjunction) / 6.0)
}
for {
currentRate := elongationRate(jde, 1.0/86400.0)
if math.Abs(currentRate) > 0.4 {
jde += 2
for _, east := range [2]bool{true, false} {
start, end, ok := mercuryElongationWindowAt(jde, east)
if !ok {
continue
}
if date := mercuryGreatestElongationInWindow(start, end); isFiniteFloat(date) && eventUTQueryAfterOrEqual(date, jde) {
return date
}
if date := nextMercuryGreatestElongationTyped(jde, !east); isFiniteFloat(date) && eventUTQueryAfterOrEqual(date, jde) {
return date
}
break
}
estimateJD := jde
for {
prevJD := estimateJD
rateValue := elongationRate(prevJD, 2.0/86400.0)
rateSlope := (elongationRate(prevJD+15.0/86400.0, 2.0/86400.0) - elongationRate(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 elongationRate(jd, 0.5/86400.0)
})
//fmt.Println((bestJD - lastConjunction) / (nextConjunction - lastConjunction))
return TD2UT(bestJD, false)
}
func NextMercuryGreatestElongation(jde float64) float64 {
east := NextMercuryGreatestElongationEast(jde)
west := NextMercuryGreatestElongationWest(jde)
if sameEventJD(east, west) {
return east
}
if east < west {
return east
}
return west
return earliestFiniteEventUT(nextMercuryGreatestElongationTyped(jde, true), nextMercuryGreatestElongationTyped(jde, false))
}
func LastMercuryGreatestElongation(jde float64) float64 {
east := LastMercuryGreatestElongationEast(jde)
west := LastMercuryGreatestElongationWest(jde)
if sameEventJD(east, west) {
return east
if !isFiniteFloat(jde) {
return math.NaN()
}
if east > west {
return east
for _, east := range [2]bool{true, false} {
start, end, ok := mercuryElongationWindowAt(jde, east)
if !ok {
continue
}
return west
if date := mercuryGreatestElongationInWindow(start, end); isFiniteFloat(date) && eventUTQueryBeforeOrEqual(date, jde) {
return date
}
if date := lastMercuryGreatestElongationTyped(jde, !east); isFiniteFloat(date) && eventUTQueryBeforeOrEqual(date, jde) {
return date
}
break
}
return latestFiniteEventUT(lastMercuryGreatestElongationTyped(jde, true), lastMercuryGreatestElongationTyped(jde, false))
}
func LastMercuryInferiorConjunctionInclusive(jde float64) float64 {
@@ -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)
}
}
}
+5 -5
View File
@@ -8,7 +8,7 @@ import (
func mercuryTTJDJST(year int, month time.Month, day, hour, minute, second int) float64 {
loc := time.FixedZone("JST", 9*3600)
return TD2UT(Date2JDE(time.Date(year, month, day, hour, minute, second, 0, loc).UTC()), true)
return UTC2TT(Date2JD(time.Date(year, month, day, hour, minute, second, 0, loc).UTC()))
}
func TestMercuryTypedStationRegression1929(t *testing.T) {
@@ -22,19 +22,19 @@ func TestMercuryTypedStationRegression1929(t *testing.T) {
nextP2R := NextMercuryProgradeToRetrograde(query)
nextR2P := NextMercuryRetrogradeToPrograde(query)
if math.Abs(nextP2R-wantP2R) > tolerance {
t.Fatalf("next P2R mismatch: got %s want %s", JDE2DateByZone(nextP2R, loc, false), JDE2DateByZone(wantP2R, loc, false))
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", JDE2DateByZone(nextR2P, loc, false), JDE2DateByZone(wantR2P, loc, false))
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", JDE2DateByZone(lastP2R, loc, false), JDE2DateByZone(wantP2R, loc, false))
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", JDE2DateByZone(lastR2P, loc, false), JDE2DateByZone(wantR2P, loc, false))
t.Fatalf("last R2P mismatch: got %s want %s", JD2DateByZone(lastR2P, loc, false), JD2DateByZone(wantR2P, loc, false))
}
}
+41 -46
View File
@@ -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
View File
@@ -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 {
+2 -2
View File
@@ -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)
@@ -34,7 +34,7 @@ func TestMoonGeocentricApparentCoordinatesMatchHorizonsBaseline(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()))
prefix := "moon." + sample.InputUTC
assertPlanetApparentAngleClose(t, prefix+".RightAscension", HMoonGeocentricApparentRa(jd), sample.RightAscension, 0.001)
@@ -54,7 +54,7 @@ func TestMoonGeocentricTrueCoordinatesFollowDefinition(t *testing.T) {
}
for _, sample := range samples {
jd := TD2UT(Date2JDE(sample.UTC()), true)
jd := UTC2TT(Date2JD(sample.UTC()))
wantRA, wantDec := LoBoToRaDec(jd, HMoonTrueLo(jd), HMoonTrueBo(jd))
gotRA, gotDec := HMoonGeocentricTrueRaDec(jd)
+2 -2
View File
@@ -6,7 +6,7 @@ import (
)
func TestHMoonGeocentricApparentRaDecComponentsMatch(t *testing.T) {
jd := TD2UT(JDECalc(2026, 1, 1.25), true)
jd := UTC2TT(JDCalc(2026, 1, 1.25))
ra, dec := HMoonGeocentricApparentRaDec(jd)
if diff := math.Abs(ra - HMoonGeocentricApparentRa(jd)); diff > 1e-12 {
@@ -18,7 +18,7 @@ func TestHMoonGeocentricApparentRaDecComponentsMatch(t *testing.T) {
}
func TestHMoonGeocentricTrueRaDecComponentsMatch(t *testing.T) {
jd := TD2UT(JDECalc(2026, 1, 1.25), true)
jd := UTC2TT(JDCalc(2026, 1, 1.25))
ra, dec := HMoonGeocentricTrueRaDec(jd)
if diff := math.Abs(ra - HMoonGeocentricTrueRa(jd)); diff > 1e-12 {
+15
View File
@@ -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)
}
+114
View File
@@ -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)
}
}
+84 -38
View File
@@ -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 {
+91
View File
@@ -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)
}
})
}
+25 -25
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -184,7 +184,7 @@ func moonPlanetConjunctionEventUT(leftTT, rightTT float64, planet MoonPlanetConj
if math.Abs(moonPlanetConjunctionDeltaAt(eventTT, planet, -1)) > moonPlanetConjunctionEventTolerance {
return math.NaN()
}
return TD2UT(eventTT, false)
return TT2UTC(eventTT)
}
func moonPlanetConjunctionCollectLocalEvent(result *moonPlanetConjunctionLocalResult, queryTT, eventUT float64) {
+33 -33
View File
@@ -92,9 +92,9 @@ func TestMoonPlanetConjunctionsMatchHorizonsBaseline(t *testing.T) {
if err != nil {
t.Fatalf("parse sample time %q: %v", sample.TimeUTC, err)
}
queryTT := TD2UT(Date2JDE(wantTime.Add(-12*time.Hour).UTC()), true)
queryTT := UTC2TT(Date2JD(wantTime.Add(-12 * time.Hour).UTC()))
gotUT := tc.next(queryTT, tc.planet)
gotTime := JDE2DateByZone(gotUT, time.UTC, false)
gotTime := JD2DateByZone(gotUT, time.UTC, false)
diff := gotTime.Sub(wantTime)
if diff < 0 {
diff = -diff
@@ -106,7 +106,7 @@ func TestMoonPlanetConjunctionsMatchHorizonsBaseline(t *testing.T) {
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(TD2UT(gotUT, true), tc.planet, -1))
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)
}
@@ -144,11 +144,11 @@ func TestMoonPlanetConjunctionDirectionalConsistencyAtComputedEvent(t *testing.T
if err != nil {
t.Fatalf("parse sample time %q: %v", sample.TimeUTC, err)
}
seedTT := TD2UT(Date2JDE(wantTime.Add(-12*time.Hour).UTC()), true)
seedTT := UTC2TT(Date2JD(wantTime.Add(-12 * time.Hour).UTC()))
eventUT := NextMoonPlanetConjunction(seedTT, planet)
eventTime := JDE2DateByZone(eventUT, time.UTC, false)
queryAtTT := TD2UT(Date2JDE(eventTime.UTC()), true)
queryAfterTT := TD2UT(Date2JDE(eventTime.Add(time.Hour).UTC()), true)
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)
@@ -159,7 +159,7 @@ func TestMoonPlanetConjunctionDirectionalConsistencyAtComputedEvent(t *testing.T
"exactClosest": exactClosest,
"lastAfterEvent": exactLastAfter,
} {
gotTime := JDE2DateByZone(gotUT, time.UTC, false)
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)
}
@@ -169,25 +169,25 @@ func TestMoonPlanetConjunctionDirectionalConsistencyAtComputedEvent(t *testing.T
func TestMoonPlanetConjunctionRejectsOppositionBranchJump(t *testing.T) {
query := time.Date(1900, 11, 10, 12, 0, 0, 0, time.UTC)
queryTT := TD2UT(Date2JDE(query), true)
queryTT := UTC2TT(Date2JD(query))
lastUT := LastMoonPlanetConjunction(queryTT, MoonPlanetConjunctionSaturn)
nextUT := NextMoonPlanetConjunction(queryTT, MoonPlanetConjunctionSaturn)
if math.Abs(lastUT-Date2JDE(query)) <= 5.0/86400.0 {
t.Fatalf("last returned query time on branch jump: got %s", JDE2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano))
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-Date2JDE(query)) <= 5.0/86400.0 {
t.Fatalf("next returned query time on branch jump: got %s", JDE2DateByZone(nextUT, 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(TD2UT(gotUT, true), MoonPlanetConjunctionSaturn, -1))
delta := math.Abs(moonPlanetConjunctionDeltaAt(UTC2TT(gotUT), MoonPlanetConjunctionSaturn, -1))
if delta > moonPlanetConjunctionEventTolerance {
t.Fatalf("%s returned non-event candidate: delta=%.8f event=%s", name, delta, JDE2DateByZone(gotUT, time.UTC, false).Format(time.RFC3339Nano))
t.Fatalf("%s returned non-event candidate: delta=%.8f event=%s", name, delta, JD2DateByZone(gotUT, time.UTC, false).Format(time.RFC3339Nano))
}
}
}
@@ -208,7 +208,7 @@ func TestMoonPlanetConjunctionDirectionalOrderingOnSampleQueries(t *testing.T) {
}
for _, sample := range samples {
queryTT := TD2UT(Date2JDE(sample.query.UTC()), true)
queryTT := UTC2TT(Date2JD(sample.query.UTC()))
lastUT := LastMoonPlanetConjunction(queryTT, sample.planet)
nextUT := NextMoonPlanetConjunction(queryTT, sample.planet)
closestUT := ClosestMoonPlanetConjunction(queryTT, sample.planet)
@@ -217,22 +217,22 @@ func TestMoonPlanetConjunctionDirectionalOrderingOnSampleQueries(t *testing.T) {
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, JDE2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano), sample.query.Format(time.RFC3339Nano))
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, JDE2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano), sample.query.Format(time.RFC3339Nano))
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, JDE2DateByZone(closestUT, time.UTC, false).Format(time.RFC3339Nano), JDE2DateByZone(closestEventUTToQueryTT(queryTT, lastUT, nextUT), time.UTC, false).Format(time.RFC3339Nano))
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(TD2UT(gotUT, true), sample.planet, -1))
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, JDE2DateByZone(gotUT, time.UTC, false).Format(time.RFC3339Nano))
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))
}
}
}
@@ -240,7 +240,7 @@ func TestMoonPlanetConjunctionDirectionalOrderingOnSampleQueries(t *testing.T) {
func TestMoonPlanetConjunctionKeepsImmediateNeighborEvents(t *testing.T) {
query := time.Date(1700, 4, 15, 12, 0, 0, 0, time.UTC)
queryTT := TD2UT(Date2JDE(query.UTC()), true)
queryTT := UTC2TT(Date2JD(query.UTC()))
lastUT := LastMoonPlanetConjunction(queryTT, MoonPlanetConjunctionSaturn)
nextUT := NextMoonPlanetConjunction(queryTT, MoonPlanetConjunctionSaturn)
@@ -250,35 +250,35 @@ func TestMoonPlanetConjunctionKeepsImmediateNeighborEvents(t *testing.T) {
wantNext := time.Date(1700, 5, 13, 0, 35, 5, 981616675, time.UTC)
const tolerance = 5.0 / 86400.0
if diff := math.Abs(lastUT - Date2JDE(wantLast)); diff > tolerance {
t.Fatalf("last mismatch: got=%s want=%s diff=%.3fs", JDE2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano), wantLast.Format(time.RFC3339Nano), diff*86400)
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 - Date2JDE(wantNext)); diff > tolerance {
t.Fatalf("next mismatch: got=%s want=%s diff=%.3fs", JDE2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano), wantNext.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", JDE2DateByZone(closestUT, time.UTC, false).Format(time.RFC3339Nano), JDE2DateByZone(lastUT, time.UTC, false).Format(time.RFC3339Nano))
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 := TD2UT(Date2JDE(time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)), true)
seed := UTC2TT(Date2JD(time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)))
eventUT := NextMoonPlanetConjunction(seed, MoonPlanetConjunctionMercury)
query := JDE2DateByZone(eventUT, time.UTC, false).Add(time.Second)
queryTT := TD2UT(Date2JDE(query.UTC()), true)
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),
JDE2DateByZone(nextUT, time.UTC, false).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",
JDE2DateByZone(eventUT, time.UTC, false).Format(time.RFC3339Nano),
JDE2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano),
JD2DateByZone(eventUT, time.UTC, false).Format(time.RFC3339Nano),
JD2DateByZone(nextUT, time.UTC, false).Format(time.RFC3339Nano),
)
}
}
+116 -73
View File
File diff suppressed because one or more lines are too long
+81
View File
@@ -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)
}
}
}
+87
View File
@@ -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)
}
}
}
}
+325
View File
@@ -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
}
+17
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -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(
+496
View File
@@ -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,
}
}
+184
View File
@@ -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")
}
}
+69 -69
View File
@@ -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))
@@ -105,22 +105,22 @@ func NeptuneApparentLoBo(jd float64) (float64, float64) {
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)
// 高度角、时角与天球座标三角转换公式
@@ -129,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)
// 三角转换公式
@@ -153,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
}
+114 -38
View File
@@ -64,6 +64,9 @@ func neptuneRADerivativeN(jde, delta float64, n int) float64 {
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 {
@@ -71,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 {
@@ -93,26 +105,38 @@ 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 {
@@ -148,60 +172,112 @@ func LastNeptuneWesternQuadrature(jde float64) float64 {
}
func neptuneRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool) float64 {
oppositionTT := TD2UT(oppositionJD, true)
if !isFiniteFloat(oppositionJD) {
return math.NaN()
}
oppositionTT := UTC2TT(oppositionJD)
startTT := oppositionTT
endTT := oppositionTT
if searchBeforeOpposition {
easternQuadratureUT := neptuneConjunction(oppositionTT, 90, 0)
startTT = TD2UT(easternQuadratureUT, true)
startTT = UTC2TT(easternQuadratureUT)
} else {
westernQuadratureUT := neptuneConjunction(oppositionTT, 270, 1)
endTT = TD2UT(westernQuadratureUT, true)
endTT = UTC2TT(westernQuadratureUT)
}
bestJD := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 {
return neptuneRADerivativeN(jd, 1.0/86400.0, neptuneEventSearchN)
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, 0.5/86400.0)
return neptuneRADerivative(jd, stationDerivativeStepDay)
})
return TD2UT(bestJD, false)
return TT2UTC(bestJDE)
}
func NextNeptuneRetrogradeToPrograde(jde float64) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
lastOppositionJD := neptuneConjunctionFull(jde, 180, 0)
date := neptuneRetrogradeAroundOpposition(lastOppositionJD, false)
if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) {
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
return date
}
nextOppositionJD := neptuneConjunctionFull(jde, 180, 1)
return neptuneRetrogradeAroundOpposition(nextOppositionJD, false)
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 {
if !isFiniteFloat(jde) {
return math.NaN()
}
lastOppositionJD := neptuneConjunctionFull(jde, 180, 0)
date := neptuneRetrogradeAroundOpposition(lastOppositionJD, false)
if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) {
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
return date
}
if !isFiniteFloat(lastOppositionJD) {
return math.NaN()
}
previousOppositionJD := neptuneConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0)
return neptuneRetrogradeAroundOpposition(previousOppositionJD, false)
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 {
if !isFiniteFloat(jde) {
return math.NaN()
}
nextOppositionJD := neptuneConjunctionFull(jde, 180, 1)
date := neptuneRetrogradeAroundOpposition(nextOppositionJD, true)
if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) {
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
return date
}
if !isFiniteFloat(nextOppositionJD) {
return math.NaN()
}
followingOppositionJD := neptuneConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1)
return neptuneRetrogradeAroundOpposition(followingOppositionJD, true)
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 {
if !isFiniteFloat(jde) {
return math.NaN()
}
nextOppositionJD := neptuneConjunctionFull(jde, 180, 1)
date := neptuneRetrogradeAroundOpposition(nextOppositionJD, true)
if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) {
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
return date
}
lastOppositionJD := neptuneConjunctionFull(jde, 180, 0)
return neptuneRetrogradeAroundOpposition(lastOppositionJD, true)
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
View File
@@ -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π
+75
View File
@@ -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)
}
+56
View File
@@ -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)
}
}
+752
View File
@@ -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)
}
}
+80
View File
@@ -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")
}
}
+94
View File
@@ -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)
}
}
+160
View File
@@ -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
}
}
}
}
+72
View File
@@ -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