12 Commits

Author SHA1 Message Date
b612 0e3825d5ba test: 修正日食中心阴影边界测试的时标换算
- 将日下点计算由 TT2UTC 改为 TT2UT1,使测试与地球自转几何口径一致
2026-10-16 07:52:20 +08:00
b612 044f2f4381 feat: 引入可切换星历模型并扩展天象观测链路
- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算
- 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询
- 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出
- 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解
- 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤
- 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持
- 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理
- 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
2026-10-15 23:54:15 +08:00
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
b612 a8e7513683 • feat(calendar): 扩展先秦至秦汉古历支持
- 新增显式古历 API,支持先秦古历与秦汉颛顼历选择
- 将默认公农历转换范围扩展至 -721..3000
- 支持后九月解析、负年份干支日和古历法相符节气
- 补充秦汉、先秦、交接边界和节气回归测试
2026-06-09 19:35:18 +08:00
b612 c8dd777a7b docs: 统一公开 API 的中英双语注释
- 补齐公开接口说明段的英文描述,保持签名注释和详细说明均为中英双语结构
- 规范农历、坐标、公式、轨道、日晷、太阳、恒星及行星事件等 API 的注释口径
2026-05-27 16:08:11 +08:00
b612 46b555cd49 fix: 修复天象事件 API 在事件边界附近的重复返回问题
- 修正合月、合日、留等Last/Next/Closest接口在精确命中和事件后秒级查询时仍返回当前事件的问题,避免应用侧枚举卡死
- 收紧事件边界判定,并补强水星、金星、火星近事件路径的稳定化,保证公开API的单调前进语义
- 补充公开wrapper、跨世纪样本和外部基线回归,覆盖合月邻近事件与边界场景
2026-05-23 23:08:05 +08:00
b612 be3af3884c feat(moon): 新增行星合月查询并修正月球地心赤经赤纬接口
- 修正月球地心真/视赤经赤纬接口口径
- 新增月球与七大行星合月时刻查询
2026-05-23 19:00:53 +08:00
b612 34ff6a36ae fix: 修正行星事件边界与留点计算
- 统一 UT 事件时刻与 TT 查询时刻的边界判断
- 将外行星留点搜索锚定到对应冲日周期
- 修正水星、金星合日、留、大距事件选择
- 统一七大行星视位置计算辅助逻辑
- 增加公开 Last/Next 边界和 JPL/NAOJ 基线回归测试
2026-05-22 12:24:41 +08:00
1045 changed files with 227417 additions and 24390 deletions
+160
View File
@@ -0,0 +1,160 @@
本文件是第三方数据与条款声明。代码与文档采用 Apache-2.0;下列数据文件**不适用** Apache-2.0,各自遵循其来源条款。分发内嵌了这些数据的二进制(含 c-shared DLL/SO)时,须一并附上对应署名。
This file is the per-component licence and attribution notice. The code and documentation are Apache-2.0; the data files listed below are not, and each follows the terms of its own source. Distributing a binary that embeds them — including the c-shared DLL/SO build — requires carrying the corresponding attribution.
================================================================================
1. SELENE (KAGUYA) lunar limb profile — moon/limb/kaguya_lalt.bin
================================================================================
Source data
SELENE Global Grid Topographic Data of the Moon (LALT_GGT_NUM), dataset
sln-l-lalt-5-topo-ggt-num-v2.0, version 2.0, published 2017.
Origin of the data: ISAS/JAXA.
Provided by the SELENE (KAGUYA) Data Archive (DARTS), ISAS/JAXA:
https://darts.isas.jaxa.jp/datasets/darts:sln-l-lalt-5-topo-ggt-num-v2.0
https://data.darts.isas.jaxa.jp/pub/pds3/sln-l-lalt-5-topo-ggt-num-v2.0/
Acknowledgement, as requested by the SELENE Data Archive
"We thank the SELENE(KAGUYA) LALT team and the SELENE Data Archive for
providing the SELENE(KAGUYA) data. SELENE is a Japanese mission developed
and operated by JAXA."
Modifications
The original PDS3 fixed-width table (1/16 degree global grid, 2880 x 5760
samples) was converted to a float32 elevation grid and then resampled into a
limb-radius profile table: the limb height is tabulated on a 1 degree grid of
selenographic (libration) longitude and latitude and on a 0.1 degree grid of
position angle, taking max (R + h) cos(alpha) over a +/-9 degree arc for each
direction. Values are quantized to 4 m, delta-encoded along longitude and
latitude, and then stored in this project's own zlib-compressed LLB1
container. No other changes were made.
Licence
The source data are provided under the ISAS Data Policy,
https://www.isas.jaxa.jp/en/researchers/data-policy/
which states that its rules are compatible with Creative Commons Attribution
4.0 International, https://creativecommons.org/licenses/by/4.0/
This file is redistributed under the ISAS Data Policy. It is provided "as
is", without warranties of any kind. ISAS/JAXA is not responsible for the
actions of data users and does not endorse this project.
Embedding
Compiled into the moon/limb package with //go:embed, and linked only when
that package is imported. A copy of this section ships as moon/limb/NOTICE
so that vendored copies of the package keep the attribution.
================================================================================
2. Planetary and lunar theories — planet/internal/semianalytic/**/*.bin
================================================================================
Upstream theories and authors
VSOP2013 — planetary theory, Simon, Francou, Fienga & Manche (2013),
distributed by IMCCE at https://ftp.imcce.fr/pub/ephem/planets/vsop2013/
TOP2013 — planetary theory for the outer planets, IMCCE, distributed at
https://ftp.imcce.fr/pub/ephem/planets/top2013/
ELP/MPP02 — lunar theory, J. Chapront, M. Chapront & G. Francou, 2003,
distributed by SYRTE, Observatoire de Paris (not through IMCCE's ftp server)
at ftp://cyrano-se.obspm.fr/pub/2_lunar_solutions/2_elpmpp02/
Its distribution README.TXT (December 2002) is purely scientific — authors,
description, file list and references — and states no licence and no terms
of use.
Used by the legacy model as well:
ELP2000-82B — earlier lunar solution, Chapront-Touzé & Chapront, distributed
by IMCCE at https://ftp.imcce.fr/pub/ephem/moon/elp82b/
VSOP87 — earlier planetary series, Bretagnon & Francou (1988).
What this project distributes
Its own numerical refits of those theories' argument structure to the JPL
DE440/DE441 ephemerides. The amplitudes in the *.bin tables are produced by
this project's fitting pipeline; the upstream coefficient files are not
redistributed here. Relative to the upstream theories the tables are
modified in two ways: (1) the argument structure is truncated to the terms
the fit retains, and (2) every amplitude is re-fitted to DE440/DE441 instead
of being taken from the published series.
Published constants
The argument phases and frequencies (the VSOP2013 "ll(1..17)" set) and the
GM constants used by the generator are published in the VSOP2013 README.pdf
distributed by IMCCE and in Simon, Francou, Fienga & Manche, A&A 557, A49
(2013); for the Moon, in Chapront & Francou, A&A 404, 735 (2003).
Rights in the underlying theories
This project can neither allow nor forbid the use of the VSOP2013, TOP2013,
ELP/MPP02, ELP2000-82B or VSOP87 theories. Apache-2.0 covers only this
project's own work — its code, and its compilation and rearrangement of the
published series — and not the theories themselves. Downstream users who
need to rely on the terms of the underlying theories should consult the IMCCE
legal notice,
https://www.imcce.fr/mentions-legales
which restricts reproduction and makes commercial or professional use
subject to IMCCE authorisation.
ELP/MPP02 terms
The official ELP/MPP02 distribution ships README.TXT (December 2002) and the
explanatory note elpmpp02.pdf; neither states a licence or terms of use, and
no separate terms file is included. This project therefore treats ELP/MPP02
as carrying no stated terms and relies on the disclaimer under "Rights in
the underlying theories" above. Note also that IMCCE's ftp server carries
only ELP2000-82B, not ELP/MPP02. Downstream users who need a formal position
should consult SYRTE, https://syrte.obspm.fr/, and the IMCCE legal notice.
================================================================================
3. JPL ephemerides — DE440 / DE441 and Horizons kernels
================================================================================
Files
basic/testdata/semianalytic/planet_apparent_de441.csv
planet/internal/semianalytic/testdata/de441-states.csv
planet/spk/testdata/de441_ref.csv
planet/spk/testdata/ceres.bsp
Source
JPL DE440/DE441 planetary ephemerides via NAIF,
https://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/planets/
and JPL Horizons, https://ssd.jpl.nasa.gov/horizons/
The DE440/DE441 data are also the fitting target of the tables in section 2.
Terms
Produced by the United States Government (NASA/JPL); not subject to
copyright protection in the United States. NAIF and PDS ask that the source
be credited, as done here.
================================================================================
4. Natural Earth land polygons — internal/svgmap/land_*.path
================================================================================
Source
Natural Earth 1:50m physical land, without administrative boundaries,
https://www.naturalearthdata.com/
Terms
Public domain. Natural Earth states that the data may be used for any
purpose without permission. Credit is given here as good practice, and the
rendered charts carry the "Natural Earth 1:50m physical land" label.
================================================================================
5. Star catalogue data — basic/star_catalog.dat
================================================================================
Astrometry
The built-in catalogue carries Bright Star Catalogue / Harvard (HR) numbers
1..9110 with J2000 positions, proper motions and magnitudes. Its astrometric
content derives from the Yale Bright Star Catalogue (Hoffleit & Warren),
distributed by CDS as V/50,
https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50
Catalogue data from CDS is distributed free of charge subject to the CDS
general terms of use and to citation of the source.
================================================================================
References
================================================================================
ISAS Data Policy ...... https://www.isas.jaxa.jp/en/researchers/data-policy/
CC BY 4.0 ............. https://creativecommons.org/licenses/by/4.0/
SELENE Data Archive ... https://darts.isas.jaxa.jp/
IMCCE legal notice .... https://www.imcce.fr/mentions-legales
Natural Earth ......... https://www.naturalearthdata.com/
NAIF SPK kernels ...... https://naif.jpl.nasa.gov/pub/naif/generic_kernels/
JPL Horizons .......... https://ssd.jpl.nasa.gov/horizons/
CDS VizieR V/50 ....... https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50
SYRTE (Observatoire de Paris) ... https://syrte.obspm.fr/
ELP/MPP02 distribution .......... ftp://cyrano-se.obspm.fr/pub/2_lunar_solutions/2_elpmpp02/
+115 -1811
View File
File diff suppressed because it is too large Load Diff
+115 -1893
View File
File diff suppressed because it is too large Load Diff
+79 -1
View File
@@ -1,16 +1,94 @@
// Package astro
// Package astro 提供高层星历上下文、模型切换与进程级时标配置 / high-level ephemeris contexts, model switching and process-wide time-scale configuration.
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, leap hour as the zero value; separate from output TimeScale.
type TimeScaleFuturePolicy = basic.TimeScaleFuturePolicy
const (
// TimeScaleLeapHour 按 ΔT 越限施加整小时校正(2027 起 CGPM 决议) / applies hour-sized corrections as adopted by CGPM from 2027.
TimeScaleLeapHour = basic.TimeScaleLeapHour
// TimeScaleLeapSecond 按 ΔT 越限施加整数秒校正(1972–2026 确定值) / integer-second corrections (definitive 1972–2026).
TimeScaleLeapSecond = basic.TimeScaleLeapSecond
// TimeScaleAssumeUT1Tracking 窗口外固定末端 DUT1,平滑跟随 UT1 / holds the last observed DUT1 beyond the window.
TimeScaleAssumeUT1Tracking = basic.TimeScaleAssumeUT1Tracking
// TimeScaleFreezeUTCOffset 窗口外固定末端 TT−UTC / holds the last TT−UTC offset beyond the window.
TimeScaleFreezeUTCOffset = basic.TimeScaleFreezeUTCOffset
// 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)
}
}
}
+177 -33
View File
@@ -1,7 +1,9 @@
// 根包回归:testdata 的位级基线与 n 截断契约(n<0 用全项,n>=0 保留约 n 个主项)。
package astro_test
import (
"encoding/json"
"fmt"
"math"
"os"
"testing"
@@ -11,6 +13,8 @@ import (
"b612.me/astro/moon"
"b612.me/astro/planet"
"b612.me/astro/sun"
"b612.me/astro/internal/ephemtest"
)
type baselinePlanetSnapshot struct {
@@ -52,7 +56,124 @@ 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) {
ephemtest.PinLegacy(t)
samples := loadBaselineSamples(t)
for _, sample := range samples {
for _, body := range sample.Planets {
@@ -60,64 +181,87 @@ func TestPlanetMoonBaselineRegression(t *testing.T) {
if math.Float64bits(gotLon) != body.LonBits {
t.Fatalf("%s lon regression at %s", body.Name, sample.UTC)
}
gotLonN := planet.WherePlanetN(body.XT, 0, sample.TTJD, -1)
if math.Float64bits(gotLonN) != body.LonBits {
t.Fatalf("%s lon full-n regression at %s", body.Name, sample.UTC)
}
gotLat := planet.WherePlanet(body.XT, 1, sample.TTJD)
if math.Float64bits(gotLat) != body.LatBits {
t.Fatalf("%s lat regression at %s", body.Name, sample.UTC)
}
gotLatN := planet.WherePlanetN(body.XT, 1, sample.TTJD, -1)
if math.Float64bits(gotLatN) != body.LatBits {
t.Fatalf("%s lat full-n regression at %s", body.Name, sample.UTC)
}
gotRad := planet.WherePlanet(body.XT, 2, sample.TTJD)
if math.Float64bits(gotRad) != body.RadBits {
t.Fatalf("%s rad regression at %s", body.Name, sample.UTC)
}
gotRadN := planet.WherePlanetN(body.XT, 2, sample.TTJD, -1)
if math.Float64bits(gotRadN) != body.RadBits {
t.Fatalf("%s rad full-n regression at %s", body.Name, sample.UTC)
}
}
if math.Float64bits(basic.HMoonTrueLo(sample.TTJD)) != sample.Moon.LonBits {
t.Fatalf("moon lon regression at %s", sample.UTC)
}
if math.Float64bits(basic.HMoonTrueLoN(sample.TTJD, -1)) != sample.Moon.LonBits {
t.Fatalf("moon lon full-n regression at %s", sample.UTC)
}
if math.Float64bits(basic.HMoonTrueBo(sample.TTJD)) != sample.Moon.LatBits {
t.Fatalf("moon lat regression at %s", sample.UTC)
}
if math.Float64bits(basic.HMoonTrueBoN(sample.TTJD, -1)) != sample.Moon.LatBits {
t.Fatalf("moon lat full-n regression at %s", sample.UTC)
}
if math.Float64bits(basic.HMoonAway(sample.TTJD)) != sample.Moon.DisBits {
t.Fatalf("moon distance regression at %s", sample.UTC)
}
if math.Float64bits(basic.HMoonAwayN(sample.TTJD, -1)) != sample.Moon.DisBits {
t.Fatalf("moon distance full-n regression at %s", sample.UTC)
}
}
// planetTruncationName 与 planetTruncationTolerance 的下标同 WherePlanetN 的 xt。
var planetTruncationName = []string{"earth", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune"}
// planetTruncationTolerance 是 zn=黄经/黄纬(度)、日心距(AU)两种量纲的上限,取实测最坏值的 5 倍以上。
var planetTruncationTolerance = [][]float64{
{20, 2, 0.5, 0.1},
{5, 0.5, 0.05, 0.01},
{0.3, 0.1, 0.05, 0.02},
}
func planetMoonTruncationCases(samples []baselineSample) []truncationCase {
jdOf := func(index int) float64 { return samples[index].TTJD }
wherePlanet := func(xt, zn int) truncationCase {
return scalarTruncation(
fmt.Sprintf("planet.WherePlanetN(%s, zn=%d)", planetTruncationName[xt], zn),
jdOf,
func(jd float64, n int) float64 { return planet.WherePlanetN(xt, zn, jd, n) },
zn != 2,
planetTruncationTolerance[zn],
)
}
cases := make([]truncationCase, 0, 27)
for xt := 0; xt <= 7; xt++ {
for zn := 0; zn <= 2; zn++ {
cases = append(cases, wherePlanet(xt, zn))
}
}
return append(cases,
scalarTruncation("basic.HMoonTrueLoN", jdOf, basic.HMoonTrueLoN, true, []float64{5, 2.5, 1, 0.25}),
scalarTruncation("basic.HMoonTrueBoN", jdOf, basic.HMoonTrueBoN, true, []float64{3, 0.75, 0.15, 0.06}),
scalarTruncation("basic.HMoonAwayN", jdOf, basic.HMoonAwayN, false, []float64{40000, 7000, 2500, 400}),
)
}
func TestPublicTruncationFullMatchesDefault(t *testing.T) {
date := time.Date(2026, 1, 2, 3, 4, 5, 123456789, time.UTC)
func TestPlanetMoonTruncationConvergesToFullSeries(t *testing.T) {
ephemtest.PinLegacy(t)
samples := loadBaselineSamples(t)
assertTruncationConverges(t, planetMoonTruncationCases(samples), len(samples))
}
if math.Float64bits(sun.TrueLo(date)) != math.Float64bits(sun.TrueLoN(date, -1)) {
t.Fatal("sun.TrueLoN(-1) should match default")
}
if math.Float64bits(sun.TrueBo(date)) != math.Float64bits(sun.TrueBoN(date, -1)) {
t.Fatal("sun.TrueBoN(-1) should match default")
}
if math.Float64bits(moon.TrueLo(date)) != math.Float64bits(moon.TrueLoN(date, -1)) {
t.Fatal("moon.TrueLoN(-1) should match default")
}
if math.Float64bits(moon.TrueBo(date)) != math.Float64bits(moon.TrueBoN(date, -1)) {
t.Fatal("moon.TrueBoN(-1) should match default")
func publicSunMoonTruncationCases(t *testing.T, samples []baselineSample) []truncationCase {
t.Helper()
dates := baselineDates(t, samples)
dateOf := func(index int) time.Time { return dates[index] }
return []truncationCase{
dateTruncation("sun.TrueLoN", dateOf, sun.TrueLoN, []float64{0.15, 0.05, 0.02, 0.006}),
dateTruncation("sun.TrueBoN", dateOf, sun.TrueBoN, []float64{1e-3, 5e-4, 3e-4, 1.5e-4}),
dateTruncation("moon.TrueLoN", dateOf, moon.TrueLoN, []float64{5, 2.5, 1, 0.25}),
dateTruncation("moon.TrueBoN", dateOf, moon.TrueBoN, []float64{3, 0.75, 0.15, 0.06}),
}
}
func TestPublicSunMoonTruncationConvergesToFullSeries(t *testing.T) {
ephemtest.PinLegacy(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))
}
+1794
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
// 被测契约:日月视赤经纬单值与双值接口一致,且黄经、黄纬取自同一视方向。
package basic
import (
"math"
"testing"
"b612.me/astro/internal/ephem"
"b612.me/astro/internal/ephemtest"
)
func TestSunMoonApparentRaDecUseFullDirection(t *testing.T) {
ephemtest.Pin(t, ephem.Semianalytic)
const jde = 2460409.25
for _, body := range []ephem.Body{ephem.Sun, ephem.Moon} {
lo, bo, _, _, ok := vectorApparent(body, jde, -1)
if !ok {
t.Fatalf("body %d: no vector apparent position", body)
}
wantRA, wantDec := LoBoToRaDec(jde, lo, bo)
var ra, dec float64
if body == ephem.Sun {
ra, dec = HSunApparentRaDec(jde)
if r, d := HSunApparentRa(jde), HSunApparentDec(jde); r != ra || d != dec {
t.Errorf("Sun single (%v, %v) != pair (%v, %v)", r, d, ra, dec)
}
} else {
ra, dec = HMoonGeocentricApparentRaDecN(jde, -1)
}
if math.Abs(ra-wantRA)*3600 > 1e-6 || math.Abs(dec-wantDec)*3600 > 1e-6 {
t.Errorf("body %d: (%v, %v), want (%v, %v)", body, ra, dec, wantRA, wantDec)
}
}
}
+46
View File
@@ -0,0 +1,46 @@
// 被测契约:包级与实例视位置共用同一判据,零方向、非正距离、负光行时与非有限值一律报错。
package basic
import (
"math"
"testing"
"b612.me/astro/internal/ephem"
)
type fixedApparentEngine struct {
dir [3]float64
dist, tau float64
}
func (e fixedApparentEngine) State(target, center ephem.Body, jdTDB float64) (pos, vel [3]float64, err error) {
return pos, vel, ephem.ErrUnsupportedBody
}
func (e fixedApparentEngine) Coverage(ephem.Body) (start, end float64, ok bool) { return 0, 0, false }
func (e fixedApparentEngine) Apparent(ephem.Body, float64) ([3]float64, float64, float64, error) {
return e.dir, e.dist, e.tau, nil
}
func TestApparentStateRejectsInvalid(t *testing.T) {
nan := math.NaN()
for _, c := range []struct {
name string
e fixedApparentEngine
ok bool
}{
{"valid", fixedApparentEngine{[3]float64{1, 0, 0}, 1.5e8, 0.0058}, true},
{"nan direction", fixedApparentEngine{[3]float64{nan, 0, 0}, 1.5e8, 0.0058}, false},
{"zero direction", fixedApparentEngine{[3]float64{}, 1.5e8, 0.0058}, false},
{"zero distance", fixedApparentEngine{[3]float64{1, 0, 0}, 0, 0.0058}, false},
{"inf distance", fixedApparentEngine{[3]float64{1, 0, 0}, math.Inf(1), 0.0058}, false},
{"negative tau", fixedApparentEngine{[3]float64{1, 0, 0}, 1.5e8, -1}, false},
{"nan tau", fixedApparentEngine{[3]float64{1, 0, 0}, 1.5e8, nan}, false},
} {
_, _, _, _, ok, err := apparentState(ephem.Sun, 2451545, ephem.NewInstance(c.e))
if c.ok && (err != nil || !ok) || !c.ok && err == nil {
t.Errorf("%s: ok=%v err=%v", c.name, ok, err)
}
}
}
+77
View File
@@ -0,0 +1,77 @@
package basic
import (
"math"
"b612.me/astro/internal/ephem"
)
// planetBody 旧模型行星序号 1..7(水星..海王星,不含地球)对应的 NAIF 编号。
var planetBody = [8]ephem.Body{0, ephem.Mercury, ephem.Venus, ephem.Mars, ephem.Jupiter, ephem.Saturn, ephem.Uranus, ephem.Neptune}
// vectorApparent 新模型全精度时的矢量视位置:真黄道经纬(度)、光行时(日)、几何地心距(AU);不适用时 ok=false。
// 状态非法时与显式实例路径一致:ok=true 且各值为 NaN,不得退回低精度公式掩盖错误。
func vectorApparent(target ephem.Body, jde float64, n int) (lo, bo, tau, distAU float64, ok bool) {
if n >= 0 || ephem.Active() != ephem.Semianalytic {
return 0, 0, 0, 0, false
}
lo, bo, tau, distAU, ok, err := apparentState(target, jde, ephem.Global())
if err != nil {
return math.NaN(), math.NaN(), math.NaN(), math.NaN(), true
}
return lo, bo, tau, distAU, ok
}
// icrfToTrueEcliptic ICRF 单位矢量 → 真黄道经纬(度);黄赤交角与下游 LoBoToRaDec 同口径,往返得到真赤道。
func icrfToTrueEcliptic(d [3]float64, jde float64) (lo, bo float64) {
m := ltpPBMAT(2000 + (jde-2451545)/365.25)
var q [3]float64
for i := range q {
q[i] = m[i][0]*d[0] + m[i][1]*d[1] + m[i][2]*d[2]
}
s, c := math.Sincos(Obliquity1980(jde) * math.Pi / 180)
x, y, z := q[0], q[1]*c+q[2]*s, -q[1]*s+q[2]*c
lo = math.Atan2(y, x)*180/math.Pi + Nutation2000Bi(jde)
bo = math.Atan2(z, math.Hypot(x, y)) * 180 / math.Pi
return math.Mod(lo+720, 360), bo
}
// sunApparentLoBoN 太阳视黄经与黄纬;有矢量视位置时两者取自同一方向,否则退回视黄经配几何黄纬。
func sunApparentLoBoN(jde float64, n int, inst *Instance) (float64, float64) {
if lo, bo, _, _, ok, err := vectorApparentWithInstance(ephem.Sun, jde, n, inst); err != nil {
return math.NaN(), math.NaN()
} else if ok {
return lo, bo
}
return HSunApparentLoNWithInstance(jde, n, inst), HSunTrueBoNWithInstance(jde, n, inst)
}
// moonApparentLoBoN 月球视黄经与黄纬;有矢量视位置时两者取自同一方向,否则退回视黄经配几何黄纬。
func moonApparentLoBoN(jde float64, n int, inst *Instance) (float64, float64) {
if lo, bo, _, _, ok, err := vectorApparentWithInstance(ephem.Moon, jde, n, inst); err != nil {
return math.NaN(), math.NaN()
} else if ok {
return lo, bo
}
return HMoonApparentLoNWithInstance(jde, n, inst), HMoonTrueBoNWithInstance(jde, n, inst)
}
func sunApparentRaDecN(jde float64, n int, inst *Instance) (float64, float64) {
lo, bo := sunApparentLoBoN(jde, n, inst)
return LoBoToRaDec(jde, lo, bo)
}
func sunApparentRaN(jde float64, n int, inst *Instance) float64 {
ra, _ := sunApparentRaDecN(jde, n, inst)
return ra
}
func moonApparentRaDecN(jde float64, n int, inst *Instance) (float64, float64) {
lo, bo := moonApparentLoBoN(jde, n, inst)
return LoBoToRaDec(jde, lo, bo)
}
func moonApparentRaN(jde float64, n int, inst *Instance) float64 {
ra, _ := moonApparentRaDecN(jde, n, inst)
return ra
}
+32 -18
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
}
@@ -57,14 +57,14 @@ func EarthAphelion(year int) ApsisEvent {
return earthApsis(year, true)
}
// MoonPerigees 指定年月内的所有月球近地点 / all lunar perigees in the given Gregorian month.
func MoonPerigees(year int, month time.Month) []ApsisEvent {
return moonApsisInMonth(year, month, false)
// MoonPerigeesWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MoonPerigeesWithInstance(year int, month time.Month, inst *Instance) []ApsisEvent {
return moonApsisInMonth(year, month, false, inst)
}
// MoonApogees 指定年月内的所有月球远地点 / all lunar apogees in the given Gregorian month.
func MoonApogees(year int, month time.Month) []ApsisEvent {
return moonApsisInMonth(year, month, true)
// MoonApogeesWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MoonApogeesWithInstance(year int, month time.Month, inst *Instance) []ApsisEvent {
return moonApsisInMonth(year, month, true, inst)
}
func earthApsis(year int, aphelion bool) ApsisEvent {
@@ -79,16 +79,18 @@ func earthApsis(year int, aphelion bool) ApsisEvent {
}
eventTT, distanceAU := refineDistanceExtremum(seedTT, cfg, EarthAway)
return ApsisEvent{
JDE: TD2UT(eventTT, false),
JD: TT2UTC(eventTT),
Distance: distanceAU,
}
}
func moonApsisInMonth(year int, month time.Month, apogee bool) []ApsisEvent {
func moonApsisInMonth(year int, month time.Month, apogee bool, instances ...*Instance) []ApsisEvent {
inst := instanceValue(instances)
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
@@ -109,20 +111,20 @@ func moonApsisInMonth(year int, month time.Month, apogee bool) []ApsisEvent {
events := make([]ApsisEvent, 0, 2)
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)
eventTT, distanceKM := refineDistanceExtremum(seedTT, cfg, func(jde float64) float64 { return HMoonAwayWithInstance(jde, inst) })
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 +147,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 -3
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
@@ -87,7 +87,6 @@ func TestEarthApsisMatchesHorizonsBaseline(t *testing.T) {
}
func TestMoonApsisMatchesHorizonsBaseline(t *testing.T) {
// Baseline is generated from JPL Horizons by scripts/generate_moon_apsis_baseline.sh.
data, err := os.ReadFile("testdata/moon_apsis_baseline.json")
if err != nil {
t.Fatalf("read baseline: %v", err)
@@ -139,7 +138,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)
}
}
+4 -3
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 {
@@ -61,8 +61,9 @@ func TestGetJQTime(t *testing.T) {
for _, tc := range testCases {
originalResult := originalFunc(tc.year, tc.angle)
optimizedResult := GetJQTime(tc.year, tc.angle)
// 两种解法各自收敛到 1e-5 日,末步残差可达数十微秒;1e-8 日足以区分选错相邻年份的根。
diff := math.Abs(originalResult - optimizedResult)
if diff > 1e-10 {
if diff > 1e-8 {
t.Fatalf("GetJQTime mismatch: year=%d angle=%d original=%.15f optimized=%.15f diff=%.15f",
tc.year, tc.angle, originalResult, optimizedResult, diff)
}
+53 -14
View File
@@ -1,6 +1,8 @@
package basic
import (
"math"
. "b612.me/astro/tools"
)
@@ -148,29 +150,66 @@ func isCross(a, b, c, d constellationPoint) bool {
}
func resolveConstellationCode(ra, dec, jde float64) string {
var nra, ndec float64
initConstellationData()
nra = ra
if ra >= 360 {
nra -= 360
}
nra, ndec = Precess(nra, dec, jde, 2451545.0)
if ra >= 360 && nra < 270 {
nra += 360
}
if code := matchConstellationCode(nra, ndec); code != "" {
code, nra, ndec := lookupConstellationCode(ra, dec, jde)
if code != "" {
return code
}
if nra <= 270 {
ra = ra + 360
return resolveConstellationCode(ra, dec, jde)
// 只从 [0,360) 补一圈重试一次,匹配 RA 已加 360 的跨零点星座。
if nra <= 270 && ra < 360 {
if code, _, _ = lookupConstellationCode(ra+360, dec, jde); code != "" {
return code
}
}
if ndec > 50 {
return "UMI"
} else if ndec < -50 {
return "OCT"
}
return ""
if ra >= 360 {
return ""
}
// 边界数据相邻多边形之间有细缝,落在缝里的点归最近的边。
return nearestConstellationCode(nra, ndec)
}
func lookupConstellationCode(ra, dec, jde float64) (string, float64, float64) {
nra := ra
if ra >= 360 {
nra -= 360
}
nra, ndec := Precess(nra, dec, jde, 2451545.0)
if ra >= 360 && nra < 270 {
nra += 360
}
return matchConstellationCode(nra, ndec), nra, ndec
}
func nearestConstellationCode(ra, dec float64) string {
cosDec := math.Cos(dec * math.Pi / 180)
best, bestDistance := "", math.Inf(1)
for _, boundary := range constellationBoundaries {
pointCount := len(boundary.points)
for index := 0; index < pointCount; index++ {
p, q := boundary.points[index], boundary.points[(index+1)%pointCount]
px, py := math.Remainder(p.RA-ra, 360)*cosDec, p.DEC-dec
qx, qy := math.Remainder(q.RA-ra, 360)*cosDec, q.DEC-dec
if distance := originSegmentDistance(px, py, qx, qy); distance < bestDistance {
best, bestDistance = boundary.code, distance
}
}
}
return best
}
func originSegmentDistance(px, py, qx, qy float64) float64 {
dx, dy := qx-px, qy-py
length := dx*dx + dy*dy
t := 0.0
if length > 0 {
t = math.Max(0, math.Min(1, -(px*dx+py*dy)/length))
}
return math.Hypot(px+t*dx, py+t*dy)
}
func matchConstellationCode(ra, dec float64) string {
+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")
}
+17 -7
View File
@@ -5,19 +5,29 @@ import (
)
func TestConstellationNameZH(t *testing.T) {
now := GetNowJDE()
//finish on 30s
for i := 0.00; i <= 360.00; i += 0.5 {
for j := -90.00; j <= 90.00; j += 0.5 {
ConstellationNameZH(float64(i), float64(j), now)
for _, jde := range []float64{2451545.0, 2461328.5} {
for i := 0.00; i <= 360.00; i += 0.25 {
for j := -90.00; j <= 90.00; j += 0.25 {
if ConstellationNameZH(i, j, jde) == "" {
t.Fatalf("ConstellationNameZH(%v, %v, %v) is empty", i, j, jde)
}
}
}
}
}
func TestConstellationBoundarySliver(t *testing.T) {
for _, dec := range []float64{3.25, 3.5, 3.75, 4.0} {
if got := ConstellationNameZH(278.25, dec, 2461328.5); got != "巨蛇座" && got != "蛇夫座" {
t.Fatalf("ConstellationNameZH(278.25, %v) = %q, want 巨蛇座 or 蛇夫座", dec, got)
}
}
}
func BenchmarkConstellationNameZH(b *testing.B) {
jde := GetNowJDE()
jde := GetNowJD()
for i := 0; i < b.N; i++ {
//GetNowJDE()
//GetNowJD()
ConstellationNameZH(11.11, 12.12, jde)
}
+57 -52
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
@@ -63,28 +68,38 @@ func DecToBo(jde, ra, dec float64) float64 {
/*
* 地心坐标转站心坐标,参数分别为,地心赤经赤纬 纬度经度,jde,离地心位置au
*/
// 站心视差沿用的旧地球椭球,与 geodata 的赤道半径不同,不可混用。
const (
topocentricEarthRadiusKM = 6378.14
topocentricEarthPolarRadiusKM = 6356.755
)
func pcosi(lat, h float64) float64 {
b := 6356.755
a := 6378.14
b := topocentricEarthPolarRadiusKM
a := topocentricEarthRadiusKM
u := ArcTan(b / a * Tan(lat))
//psin=b/a*Sin(u)+h/6378140*Sin(lat);
pcos := Cos(u) + h/6378140.0*Cos(lat)
//psin=b/a*Sin(u)+h/(topocentricEarthRadiusKM*1000)*Sin(lat);
pcos := Cos(u) + h/(topocentricEarthRadiusKM*1000)*Cos(lat)
return pcos
}
func psini(lat, h float64) float64 {
b := 6356.755
a := 6378.14
b := topocentricEarthPolarRadiusKM
a := topocentricEarthRadiusKM
u := ArcTan(b / a * Tan(lat))
psin := b/a*Sin(u) + h/6378140*Sin(lat)
//pcos=Cos(u)+h/6378140*Cos(lat);
psin := b/a*Sin(u) + h/(topocentricEarthRadiusKM*1000)*Sin(lat)
//pcos=Cos(u)+h/(topocentricEarthRadiusKM*1000)*Cos(lat);
return psin
}
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 +107,51 @@ 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 { //光行差修正
// GXCLoWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func GXCLoWithInstance(lo, bo, jde float64, inst *Instance) float64 { //光行差修正
k := 20.49552
sunlo := SunTrueLo(jd)
e := Earthe(jd)
epi := EarthPI(jd)
sunlo := SunTrueLoWithInstance(jde, inst)
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 {
// GXCBoWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func GXCBoWithInstance(lo, bo, jde float64, inst *Instance) float64 {
k := 20.49552
sunlo := SunTrueLo(jd)
e := Earthe(jd)
epi := EarthPI(jd)
sunlo := SunTrueLoWithInstance(jde, inst)
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)
}
}
+94 -92
View File
@@ -1,6 +1,8 @@
package basic
import "math"
import (
"math"
)
const (
angularDiameterAstronomicalUnitKM = 149597870.7
@@ -26,181 +28,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))
// SunSemidiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func SunSemidiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return angularSemidiameterFromAU(sunEquatorialRadiusKM, EarthAwayNWithInstance(jde, n, inst))
}
// 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)
// SunDiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func SunDiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return 2 * SunSemidiameterNWithInstance(jde, n, inst)
}
// MoonSemidiameter 月亮视半径,单位角秒 / apparent lunar semidiameter in arcseconds.
func MoonSemidiameter(jd float64) float64 {
return MoonSemidiameterN(jd, -1)
// MoonSemidiameterWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MoonSemidiameterWithInstance(jde float64, inst *Instance) float64 {
return MoonSemidiameterNWithInstance(jde, -1, inst)
}
// MoonSemidiameterN 月亮视半径(截断版),单位角秒 / truncated apparent lunar semidiameter in arcseconds.
func MoonSemidiameterN(jd float64, n int) float64 {
return angularSemidiameterArcsec(moonEquatorialRadiusKM, HMoonAwayN(jd, n))
// MoonSemidiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MoonSemidiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return angularSemidiameterArcsec(moonEquatorialRadiusKM, HMoonAwayNWithInstance(jde, n, inst))
}
// 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)
// MoonDiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MoonDiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return 2 * MoonSemidiameterNWithInstance(jde, n, inst)
}
// 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))
// MercurySemidiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MercurySemidiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return angularSemidiameterFromAU(mercuryEquatorialRadiusKM, EarthMercuryAwayNWithInstance(jde, n, inst))
}
// 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)
// MercuryDiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MercuryDiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return 2 * MercurySemidiameterNWithInstance(jde, n, inst)
}
// 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))
// VenusSemidiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func VenusSemidiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return angularSemidiameterFromAU(venusEquatorialRadiusKM, EarthVenusAwayNWithInstance(jde, n, inst))
}
// 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)
// VenusDiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func VenusDiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return 2 * VenusSemidiameterNWithInstance(jde, n, inst)
}
// 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))
// MarsSemidiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MarsSemidiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return angularSemidiameterFromAU(marsEquatorialRadiusKM, EarthMarsAwayNWithInstance(jde, n, inst))
}
// 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)
// MarsDiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MarsDiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return 2 * MarsSemidiameterNWithInstance(jde, n, inst)
}
// 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))
// JupiterSemidiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterSemidiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return angularSemidiameterFromAU(jupiterEquatorialRadiusKM, EarthJupiterAwayNWithInstance(jde, n, inst))
}
// 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)
// JupiterDiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterDiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return 2 * JupiterSemidiameterNWithInstance(jde, n, inst)
}
// 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))
// SaturnSemidiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func SaturnSemidiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return angularSemidiameterFromAU(saturnEquatorialRadiusKM, EarthSaturnAwayNWithInstance(jde, n, inst))
}
// 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)
// SaturnDiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func SaturnDiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return 2 * SaturnSemidiameterNWithInstance(jde, n, inst)
}
// 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))
// UranusSemidiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func UranusSemidiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return angularSemidiameterFromAU(uranusEquatorialRadiusKM, EarthUranusAwayNWithInstance(jde, n, inst))
}
// 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)
// UranusDiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func UranusDiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return 2 * UranusSemidiameterNWithInstance(jde, n, inst)
}
// 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))
// NeptuneSemidiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NeptuneSemidiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return angularSemidiameterFromAU(neptuneEquatorialRadiusKM, EarthNeptuneAwayNWithInstance(jde, n, inst))
}
// 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)
// NeptuneDiameterNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NeptuneDiameterNWithInstance(jde float64, n int, inst *Instance) float64 {
return 2 * NeptuneSemidiameterNWithInstance(jde, n, inst)
}
+2 -3
View File
@@ -14,7 +14,6 @@ type angularDiameterSample struct {
}
func TestAngularDiametersMatchHorizonsBaseline(t *testing.T) {
// Baseline is generated from JPL Horizons by scripts/generate_angular_diameter_baseline.sh.
data, err := os.ReadFile("testdata/angular_diameter_baseline.json")
if err != nil {
t.Fatalf("read baseline: %v", err)
@@ -51,7 +50,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 +74,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
+5
View File
@@ -0,0 +1,5 @@
// Package basic 提供底层天文算法、星历实例入口与内置模型口径。
// Package basic provides the low-level astronomical algorithms, ephemeris-instance entry points and built-in model conventions.
// 求值接口的 N 后缀是 n 变体:n < 0 取完整级数;半解析模型下所有 n >= 0 使用同一张快速表;Legacy 按 n 截断。
// The N suffix marks an n-variant: n < 0 uses the full series, every n >= 0 uses one fast table under the semianalytic model, and Legacy truncates by n.
package basic
+2 -2
View File
@@ -11,7 +11,7 @@ import (
const (
EARTH_EQUATORIAL_RADIUS float64 = 6378137.0
EARTH_POLAR_RADIUS float64 = 6356752.3
EARTH_AVERAGE_RADIUS float64 = 6371393.0
EARTH_AVERAGE_RADIUS float64 = 6371008.8
)
// HeightDistance 高度与地平线距离的关系(单位:米)
@@ -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
+1 -1
View File
@@ -27,7 +27,7 @@ func TestHeightDegreeByLatMatchesSphericalArc(t *testing.T) {
func TestHeightDegreeReferenceValue(t *testing.T) {
got := HeightDegree(10000)
want := 3.20801665537668
want := 3.208113256367627
if math.Abs(got-want) > 1e-12 {
t.Fatalf("HeightDegree(10000) = %.15f want %.15f", got, want)
}
+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,
+35
View File
@@ -0,0 +1,35 @@
package basic
import (
"b612.me/astro/internal/ephem"
)
// Instance 显式只读星历实例 / explicit read-only ephemeris instance.
type Instance = ephem.Instance
// EphemerisModel 几何位置所用的星历模型 / ephemeris model behind geometric positions.
type EphemerisModel int
const (
// EphemerisLegacy VSOP87 + ELP2000 截断级数,astro_legacy 构建下为默认 / truncated VSOP87 and ELP2000 series, default under astro_legacy.
EphemerisLegacy EphemerisModel = iota
// EphemerisSemianalytic VSOP2013/TOP2013 + ELP/MPP02 结构、按 DE441 重拟的半解析表 / semi-analytic tables refitted to DE441.
EphemerisSemianalytic
)
// SetEphemerisModel 切换全局星历模型,未知值返回 false / switches the global ephemeris model; unknown values return false.
func SetEphemerisModel(m EphemerisModel) bool {
if m != EphemerisLegacy && m != EphemerisSemianalytic {
return false
}
// 依赖星历的缓存都按 ΔT 世代校验,切换时一并递增。
deltaTFnMu.Lock()
if ephem.SetActive(ephem.Model(m)) {
deltaTGeneration++
}
deltaTFnMu.Unlock()
return true
}
// GetEphemerisModel 当前星历模型 / the active ephemeris model.
func GetEphemerisModel() EphemerisModel { return EphemerisModel(ephem.Active()) }
+120
View File
@@ -0,0 +1,120 @@
package basic
import (
"errors"
"math"
"b612.me/astro/internal/ephem"
"b612.me/astro/planet"
)
func instanceValue(instances []*Instance) *Instance {
if len(instances) == 0 {
return nil
}
return instances[0]
}
func instanceModel(inst *Instance) ephem.Model {
if inst == nil {
return ephem.Active()
}
return inst.Model()
}
// PlanetCoordinateWithInstance 指定星历实例的行星日心经纬或距离 / heliocentric planetary coordinate or distance with an explicit instance.
func PlanetCoordinateWithInstance(index, coordinate int, jde float64, inst *Instance) float64 {
return PlanetCoordinateNWithInstance(index, coordinate, jde, -1, inst)
}
// PlanetCoordinateNWithInstance 指定星历实例的行星日心经纬或距离(n 变体) / n-parameterised heliocentric planetary coordinate or distance with an explicit instance.
func PlanetCoordinateNWithInstance(index, coordinate int, jde float64, n int, inst *Instance) float64 {
if inst == nil {
return planet.WherePlanetN(index, coordinate, jde, n)
}
if inst.Model() != ephem.Custom {
return planet.WherePlanetModelN(inst.Model(), index, coordinate, jde, n)
}
value, ok := planetHeliocentricCoordinateWithInstance(index, coordinate, jde, inst)
if !ok {
return math.NaN()
}
return value
}
func boundVectorApparent(target ephem.Body, jde float64, n int, inst *Instance) (lo, bo, tau, distAU float64, ok bool) {
lo, bo, tau, distAU, ok, err := vectorApparentWithInstance(target, jde, n, inst)
if err != nil {
return math.NaN(), math.NaN(), math.NaN(), math.NaN(), true
}
return lo, bo, tau, distAU, ok
}
func instanceStateEcliptic(target ephem.Body, jde float64, inst *Instance) (lo, bo, distAU float64, ok bool) {
return instanceStateEclipticRelative(target, ephem.Earth, jde, jde, inst)
}
func instanceStateEclipticRelative(target, center ephem.Body, stateJDE, eclipticJDE float64, inst *Instance) (lo, bo, distAU float64, ok bool) {
// 几何状态捷径只给自定义引擎,内置模型必须走与包级相同的级数与视位置管线。
if inst == nil || inst.Model() != ephem.Custom {
return 0, 0, 0, false
}
state, _, err := inst.State(target, center, stateJDE)
if err != nil {
return 0, 0, 0, false
}
distanceKM := math.Sqrt(state[0]*state[0] + state[1]*state[1] + state[2]*state[2])
if distanceKM == 0 || math.IsNaN(distanceKM) || math.IsInf(distanceKM, 0) {
return 0, 0, 0, false
}
direction := [3]float64{state[0] / distanceKM, state[1] / distanceKM, state[2] / distanceKM}
lo, bo = icrfToMeanEcliptic(direction, eclipticJDE)
return lo, bo, distanceKM / astronomicalUnitKM, true
}
func icrfToMeanEcliptic(direction [3]float64, jde float64) (lo, bo float64) {
matrix := ltpPBMAT(2000 + (jde-2451545)/365.25)
var equatorial [3]float64
for axis := range equatorial {
equatorial[axis] = matrix[axis][0]*direction[0] + matrix[axis][1]*direction[1] + matrix[axis][2]*direction[2]
}
sine, cosine := math.Sincos(Obliquity1980(jde) * math.Pi / 180)
eclipticY := equatorial[1]*cosine + equatorial[2]*sine
eclipticZ := -equatorial[1]*sine + equatorial[2]*cosine
lo = math.Atan2(eclipticY, equatorial[0]) * 180 / math.Pi
bo = math.Atan2(eclipticZ, math.Hypot(equatorial[0], eclipticY)) * 180 / math.Pi
return math.Mod(lo+720, 360), bo
}
func instanceApparent(target ephem.Body, jde float64, n int, inst *Instance) (lo, bo, tau, distAU float64, ok bool, err error) {
if inst == nil || n >= 0 && inst.Model() != ephem.Custom {
return 0, 0, 0, 0, false, nil
}
return apparentState(target, jde, inst)
}
// apparentState 求视位置并校验;引擎不支持时 ok=false 且 err 为 nil,状态非法时返回 err。
func apparentState(target ephem.Body, jde float64, inst *Instance) (lo, bo, tau, distAU float64, ok bool, err error) {
direction, distanceKM, tau, err := inst.Apparent(target, jde)
if errors.Is(err, ephem.ErrUnsupportedApparent) || inst.Model() != ephem.Custom && errors.Is(err, ephem.ErrNoEngine) {
return 0, 0, 0, 0, false, nil
}
if err != nil {
return 0, 0, 0, 0, false, err
}
norm := math.Hypot(math.Hypot(direction[0], direction[1]), direction[2])
if !isFiniteFloat(distanceKM) || distanceKM <= 0 || !isFiniteFloat(norm) || norm <= 0 || !isFiniteFloat(tau) || tau < 0 {
return 0, 0, 0, 0, false, errors.New("ephem: invalid apparent state")
}
lo, bo = icrfToTrueEcliptic(direction, jde)
// 天文单位须与 vectorApparent 一致,否则实例与包级距离不逐位相等。
return lo, bo, tau, distanceKM / 149597870.7, true, nil
}
func vectorApparentWithInstance(target ephem.Body, jde float64, n int, inst *Instance) (lo, bo, tau, distAU float64, ok bool, err error) {
if inst != nil {
return instanceApparent(target, jde, n, inst)
}
lo, bo, tau, distAU, ok = vectorApparent(target, jde, n)
return lo, bo, tau, distAU, ok, nil
}
+121
View File
@@ -0,0 +1,121 @@
package basic
import (
"math"
"testing"
"b612.me/astro/internal/ephem"
"b612.me/astro/planet"
)
type p3StateEngine struct {
position [3]float64
err error
}
func (engine p3StateEngine) State(target, center ephem.Body, jde float64) ([3]float64, [3]float64, error) {
return engine.position, [3]float64{}, engine.err
}
func (engine p3StateEngine) Coverage(target ephem.Body) (float64, float64, bool) {
return 2400000, 2500000, true
}
type p3ApparentEngine struct {
p3StateEngine
direction [3]float64
distance float64
tau float64
err error
}
func (engine p3ApparentEngine) Apparent(target ephem.Body, jde float64) ([3]float64, float64, float64, error) {
return engine.direction, engine.distance, engine.tau, engine.err
}
func TestP3BuiltInGeometryPreservesTruncation(t *testing.T) {
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
inst := ephem.NewModelInstance(model)
for _, jde := range []float64{2451545, 2461319.5} {
for _, terms := range []int{-1, 0, 16} {
for _, sample := range []struct {
name string
got float64
want float64
}{
{"SunLo", HSunTrueLoNWithInstance(jde, terms, inst), planet.WherePlanetModelN(model, 0, 0, jde, terms)},
{"SunBo", HSunTrueBoNWithInstance(jde, terms, inst), planet.WherePlanetModelN(model, 0, 1, jde, terms)},
{"SunDistance", EarthAwayNWithInstance(jde, terms, inst), planet.WherePlanetModelN(model, 0, 2, jde, terms)},
{"MoonLo", MoonCalcNewNWithInstance(0, jde, terms, inst), planet.WhereMoonModelN(model, 0, jde, terms)},
{"MoonBo", MoonCalcNewNWithInstance(1, jde, terms, inst), planet.WhereMoonModelN(model, 1, jde, terms)},
{"MoonDistance", HMoonAwayNWithInstance(jde, terms, inst), planet.WhereMoonModelN(model, 2, jde, terms)},
} {
if sample.got != sample.want {
t.Errorf("%s model=%d jde=%v n=%d: got %.17g, want %.17g", sample.name, model, jde, terms, sample.got, sample.want)
}
}
}
}
}
}
func TestP3CustomGeometryUsesMeanEcliptic(t *testing.T) {
const jde = 2461319.5
direction := [3]float64{1, 0, 0}
inst := ephem.NewInstance(p3StateEngine{position: [3]float64{astronomicalUnitKM, 0, 0}})
trueLo, wantBo := icrfToTrueEcliptic(direction, jde)
wantLo := trueLo - Nutation2000Bi(jde)
for _, got := range []float64{HSunTrueLoWithInstance(jde, inst), HMoonTrueLoWithInstance(jde, inst), MercuryLWithInstance(jde, inst)} {
if math.Abs(angleDifference(got, wantLo)) > 1e-12 {
t.Errorf("geometric longitude includes nutation: got %.17g, want %.17g", got, wantLo)
}
}
if got := HSunTrueBoWithInstance(jde, inst); math.Abs(got-wantBo) > 1e-12 {
t.Errorf("latitude: got %.17g, want %.17g", got, wantBo)
}
if got := EarthAwayNWithInstance(jde, 16, inst); got != 1 {
t.Errorf("distance: got %v, want 1 AU", got)
}
}
func TestP3CustomGeometryDoesNotFallback(t *testing.T) {
for _, engine := range []ephem.Engine{
nil,
p3StateEngine{err: ephem.ErrOutOfRange},
p3StateEngine{err: ephem.ErrUnsupportedBody},
p3StateEngine{},
p3StateEngine{position: [3]float64{math.NaN(), 0, 0}},
} {
inst := ephem.NewInstance(engine)
for _, got := range []float64{
HSunTrueLoWithInstance(2451545, inst), HSunTrueBoWithInstance(2451545, inst), EarthAwayNWithInstance(2451545, -1, inst),
HMoonTrueLoWithInstance(2451545, inst), HMoonTrueBoWithInstance(2451545, inst), HMoonAwayWithInstance(2451545, inst),
} {
if !math.IsNaN(got) {
t.Errorf("failed custom engine %T fell back to a built-in model: %v", engine, got)
}
}
}
}
func TestP3CustomApparentFailureDoesNotFallback(t *testing.T) {
state := p3StateEngine{position: [3]float64{astronomicalUnitKM, 0, 0}}
for _, engine := range []p3ApparentEngine{
{p3StateEngine: state, err: ephem.ErrOutOfRange},
{p3StateEngine: state, direction: [3]float64{1, 0, 0}, distance: -1},
{p3StateEngine: state, direction: [3]float64{math.NaN(), 0, 0}, distance: 1},
{p3StateEngine: state, distance: 1},
{p3StateEngine: state, direction: [3]float64{1, 0, 0}, distance: 1, tau: math.NaN()},
} {
inst := ephem.NewInstance(engine)
for _, got := range []float64{HSunApparentLoWithInstance(2451545, inst), HMoonApparentLoWithInstance(2451545, inst), MercuryApparentLoWithInstance(2451545, inst)} {
if !math.IsNaN(got) {
t.Errorf("failed apparent engine fell back to geometry: %v", got)
}
}
}
inst := ephem.NewInstance(state)
if got := HSunApparentLoWithInstance(2451545, inst); !isFiniteFloat(got) {
t.Errorf("state-only engine must retain geometric fallback: %v", got)
}
}
+384
View File
@@ -0,0 +1,384 @@
package basic
import (
"math"
"testing"
"b612.me/astro/internal/ephem"
)
type p3ProbeEngine struct {
angle float64
distance float64
}
func (engine p3ProbeEngine) State(target, center ephem.Body, jdTDB float64) (pos, vel [3]float64, err error) {
angle := engine.angle + float64(target-center)*0.01
distance := engine.distance
if distance == 0 {
distance = 1
}
pos[0] = math.Cos(angle) * astronomicalUnitKM * distance
pos[1] = math.Sin(angle) * astronomicalUnitKM * distance
return pos, vel, nil
}
func (engine p3ProbeEngine) Coverage(target ephem.Body) (start, end float64, ok bool) {
return 2400000, 2500000, target != ephem.SSB
}
func (engine p3ProbeEngine) Apparent(target ephem.Body, jdTT float64) (dir [3]float64, distKM, tauDays float64, err error) {
angle := engine.angle + float64(target)*0.01
dir[0] = math.Cos(angle)
dir[1] = math.Sin(angle)
distance := engine.distance
if distance == 0 {
distance = 1
}
return dir, astronomicalUnitKM * distance, 0.005, nil
}
func TestP3SunMoonFunctionsUseExplicitInstances(t *testing.T) {
previous := ephem.Active()
ephem.SetActive(ephem.Legacy)
t.Cleanup(func() { ephem.SetActive(previous) })
first := ephem.NewInstance(p3ProbeEngine{angle: 0})
second := ephem.NewInstance(p3ProbeEngine{angle: math.Pi / 2})
jd := 2451545.0
sunFirst := HSunApparentLoNWithInstance(jd, -1, first)
sunSecond := HSunApparentLoNWithInstance(jd, -1, second)
if math.Abs(angleDifference(sunFirst, sunSecond)) < 1 {
t.Fatalf("sun apparent longitude did not follow instance: %.6f vs %.6f", sunFirst, sunSecond)
}
moonFirst := HMoonApparentLoNWithInstance(jd, -1, first)
moonSecond := HMoonApparentLoNWithInstance(jd, -1, second)
if math.Abs(angleDifference(moonFirst, moonSecond)) < 1 {
t.Fatalf("moon apparent longitude did not follow instance: %.6f vs %.6f", moonFirst, moonSecond)
}
if got, want := HSunApparentLoN(jd, -1), HSunApparentLoNWithInstance(jd, -1, nil); math.Abs(angleDifference(got, want)) > 1e-12 {
t.Fatalf("nil instance changed legacy-compatible result: %.15f vs %.15f", got, want)
}
}
func TestP3PlanetFunctionsUseExplicitInstances(t *testing.T) {
first := ephem.NewInstance(p3ProbeEngine{angle: 0})
second := ephem.NewInstance(p3ProbeEngine{angle: math.Pi / 2})
jd := 2451545.0
for _, sample := range []struct {
name string
first float64
second float64
}{
{"MercuryL", MercuryLWithInstance(jd, first), MercuryLWithInstance(jd, second)},
{"VenusL", VenusLWithInstance(jd, first), VenusLWithInstance(jd, second)},
{"MarsL", MarsLWithInstance(jd, first), MarsLWithInstance(jd, second)},
{"JupiterL", JupiterLWithInstance(jd, first), JupiterLWithInstance(jd, second)},
{"SaturnL", SaturnLWithInstance(jd, first), SaturnLWithInstance(jd, second)},
{"UranusL", UranusLWithInstance(jd, first), UranusLWithInstance(jd, second)},
{"NeptuneL", NeptuneLWithInstance(jd, first), NeptuneLWithInstance(jd, second)},
{"MercuryApparentLo", MercuryApparentLoWithInstance(jd, first), MercuryApparentLoWithInstance(jd, second)},
{"NeptuneApparentLo", NeptuneApparentLoWithInstance(jd, first), NeptuneApparentLoWithInstance(jd, second)},
} {
if math.Abs(angleDifference(sample.first, sample.second)) < 1 {
t.Fatalf("%s did not follow explicit instance: %.6f vs %.6f", sample.name, sample.first, sample.second)
}
}
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
inst := ephem.NewModelInstance(model)
for _, sample := range []struct {
name string
value float64
}{
{"MercuryL", MercuryLWithInstance(jd, inst)},
{"MercuryR", MercuryRWithInstance(jd, inst)},
{"MercuryApparentRa", MercuryApparentRaWithInstance(jd, inst)},
{"NeptuneApparentDec", NeptuneApparentDecWithInstance(jd, inst)},
} {
if math.IsNaN(sample.value) || math.IsInf(sample.value, 0) {
t.Fatalf("%s model %d returned non-finite value: %v", sample.name, model, sample.value)
}
}
}
previous := ephem.Active()
ephem.SetActive(ephem.Legacy)
t.Cleanup(func() { ephem.SetActive(previous) })
if got, want := MercuryLWithInstance(jd, nil), MercuryL(jd); got != want {
t.Fatalf("nil instance changed MercuryL: %.15f vs %.15f", got, want)
}
if got, want := NeptuneApparentRaWithInstance(jd, nil), NeptuneApparentRa(jd); got != want {
t.Fatalf("nil instance changed NeptuneApparentRa: %.15f vs %.15f", got, want)
}
}
func TestP3MoonPhaseFunctionsUseExplicitInstances(t *testing.T) {
first := ephem.NewInstance(p3ProbeEngine{angle: 0})
second := ephem.NewInstance(p3ProbeEngine{angle: math.Pi / 2})
jd := 2451545.0
phaseFirst := MoonPhaseWithInstance(jd, first)
phaseSecond := MoonPhaseWithInstance(jd, second)
if math.Abs(phaseFirst-phaseSecond) < 1e-6 {
t.Fatalf("moon phase did not follow explicit instance: %.12f vs %.12f", phaseFirst, phaseSecond)
}
seekFirst := SunMoonSeekWithInstance(jd, 0, first)
seekSecond := SunMoonSeekWithInstance(jd, 0, second)
if math.Abs(angleDifference(seekFirst, seekSecond)) < 1 {
t.Fatalf("sun-moon phase residual did not follow explicit instance: %.6f vs %.6f", seekFirst, seekSecond)
}
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
inst := ephem.NewModelInstance(model)
for _, sample := range []struct {
name string
value float64
}{
{"CalcMoonSHByJDE", CalcMoonSHByJDEWithInstance(jd, 0, inst)},
{"CalcMoonXHByJDE", CalcMoonXHByJDEWithInstance(jd, 0, inst)},
{"MoonPhase", MoonPhaseWithInstance(jd, inst)},
} {
if math.IsNaN(sample.value) || math.IsInf(sample.value, 0) {
t.Fatalf("%s model %d returned non-finite value: %v", sample.name, model, sample.value)
}
}
}
if got, want := MoonPhaseWithInstance(jd, nil), MoonPhase(jd); got != want {
t.Fatalf("nil instance changed MoonPhase: %.15f vs %.15f", got, want)
}
if got, want := CalcMoonSHByJDEWithInstance(jd, 0, nil), CalcMoonSHByJDE(jd, 0); got != want {
t.Fatalf("nil instance changed CalcMoonSHByJDE: %.15f vs %.15f", got, want)
}
}
func TestP3SolarTermFunctionsUseExplicitInstances(t *testing.T) {
first := ephem.NewInstance(p3ProbeEngine{angle: 0})
second := ephem.NewInstance(p3ProbeEngine{angle: math.Pi / 2})
jd := 2451545.0
firstResidual := JQLospecWithInstance(jd, 0, first)
secondResidual := JQLospecWithInstance(jd, 0, second)
if math.Abs(angleDifference(firstResidual, secondResidual)) < 1 {
t.Fatalf("solar-term longitude did not follow explicit instance: %.6f vs %.6f", firstResidual, secondResidual)
}
if GetShiErCiWithInstance(jd, first) == GetShiErCiWithInstance(jd, second) {
t.Fatalf("twelve-division label did not follow explicit instance")
}
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
inst := ephem.NewModelInstance(model)
for _, sample := range []struct {
name string
value float64
}{
{"GetJQTime", GetJQTimeWithInstance(2024, 270, inst)},
{"GetWuHouTime", GetWuHouTimeWithInstance(2024, 0, inst)},
} {
if math.IsNaN(sample.value) || math.IsInf(sample.value, 0) {
t.Fatalf("%s model %d returned non-finite value: %v", sample.name, model, sample.value)
}
}
if got := GetJieqiLoopsWithInstance(2024, 3, inst); len(got) != 3 {
t.Fatalf("GetJieqiLoops model %d returned %d values", model, len(got))
}
}
if got, want := JQLospecWithInstance(jd, 0, nil), JQLospec(jd, 0); got != want {
t.Fatalf("nil instance changed JQLospec: %.15f vs %.15f", got, want)
}
if got, want := GetJQTimeWithInstance(2024, 270, nil), GetJQTime(2024, 270); got != want {
t.Fatalf("nil instance changed GetJQTime: %.15f vs %.15f", got, want)
}
}
func TestP3SunObservationFunctionsUseExplicitInstances(t *testing.T) {
first := ephem.NewInstance(p3ProbeEngine{angle: 0})
second := ephem.NewInstance(p3ProbeEngine{angle: math.Pi / 2})
jd := 2451545.0
firstHeight := SunHeightWithInstance(jd, 121.47, 31.23, 8, first)
secondHeight := SunHeightWithInstance(jd, 121.47, 31.23, 8, second)
if math.Abs(firstHeight-secondHeight) < 1 {
t.Fatalf("sun height did not follow explicit instance: %.6f vs %.6f", firstHeight, secondHeight)
}
firstAzimuth := SunAzimuthWithInstance(jd, 121.47, 31.23, 8, first)
secondAzimuth := SunAzimuthWithInstance(jd, 121.47, 31.23, 8, second)
if math.Abs(angleDifference(firstAzimuth, secondAzimuth)) < 1 {
t.Fatalf("sun azimuth did not follow explicit instance: %.6f vs %.6f", firstAzimuth, secondAzimuth)
}
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
inst := ephem.NewModelInstance(model)
for _, sample := range []struct {
name string
value float64
}{
{"SunTime", SunTimeWithInstance(jd, inst)},
{"CulminationTime", CulminationTimeWithInstance(jd, 121.47, 8, inst)},
{"SunTimeAngle", SunTimeAngleWithInstance(jd, 121.47, 31.23, 8, inst)},
{"SunHeight", SunHeightWithInstance(jd, 121.47, 31.23, 8, inst)},
{"SunAzimuth", SunAzimuthWithInstance(jd, 121.47, 31.23, 8, inst)},
} {
if math.IsNaN(sample.value) || math.IsInf(sample.value, 0) {
t.Fatalf("%s model %d returned non-finite value: %v", sample.name, model, sample.value)
}
}
}
if got, want := SunHeightWithInstance(jd, 121.47, 31.23, 8, nil), SunHeight(jd, 121.47, 31.23, 8); got != want {
t.Fatalf("nil instance changed SunHeight: %.15f vs %.15f", got, want)
}
if got, want := SunAzimuthWithInstance(jd, 121.47, 31.23, 8, nil), SunAzimuth(jd, 121.47, 31.23, 8); got != want {
t.Fatalf("nil instance changed SunAzimuth: %.15f vs %.15f", got, want)
}
}
// 显式 Legacy 实例的均时差与中天时刻必须复现包级 Legacy 结果。
func TestSunTimeWithLegacyInstanceMatchesPackageLevel(t *testing.T) {
previous := ephem.Active()
ephem.SetActive(ephem.Legacy)
t.Cleanup(func() { ephem.SetActive(previous) })
inst := ephem.NewModelInstance(ephem.Legacy)
for _, jd := range []float64{2451545.0, 2460384.5, 2460569.5} {
if got, want := SunTimeWithInstance(jd, inst), SunTime(jd); math.Abs(got-want) > 1e-9 {
t.Fatalf("SunTime jd %.1f: instance %.9f h, package %.9f h", jd, got, want)
}
if got, want := SunTimeNWithInstance(jd, 20, inst), SunTimeN(jd, 20); math.Abs(got-want) > 1e-9 {
t.Fatalf("SunTimeN jd %.1f: instance %.9f h, package %.9f h", jd, got, want)
}
if got, want := CulminationTimeWithInstance(jd, 116.4, 8, inst), CulminationTime(jd, 116.4, 8); math.Abs(got-want)*86400 > 1e-3 {
t.Fatalf("CulminationTime jd %.1f: differs by %.3f s", jd, (got-want)*86400)
}
}
}
func TestP3SunMoonAliasFunctionsUseExplicitInstances(t *testing.T) {
first := ephem.NewInstance(p3ProbeEngine{angle: 0})
second := ephem.NewInstance(p3ProbeEngine{angle: math.Pi / 2})
jd := 2451545.0
if math.Abs(angleDifference(SunApparentRaWithInstance(jd, first), SunApparentRaWithInstance(jd, second))) < 1 {
t.Fatal("SunApparentRa alias did not follow explicit instance")
}
if math.Abs(angleDifference(MoonGeocentricApparentRaWithInstance(jd, first), MoonGeocentricApparentRaWithInstance(jd, second))) < 1 {
t.Fatal("MoonGeocentricApparentRa alias did not follow explicit instance")
}
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
inst := ephem.NewModelInstance(model)
for _, sample := range []struct {
name string
value float64
}{
{"SunApparentRa", SunApparentRaWithInstance(jd, inst)},
{"SunTrueDec", SunTrueDecWithInstance(jd, inst)},
{"MoonGeocentricTrueRa", MoonGeocentricTrueRaWithInstance(jd, inst)},
{"MoonTrueDec", MoonTrueDecWithInstance(jd, inst)},
} {
if math.IsNaN(sample.value) || math.IsInf(sample.value, 0) {
t.Fatalf("%s model %d returned non-finite value: %v", sample.name, model, sample.value)
}
}
}
if got, want := SunApparentRaWithInstance(jd, nil), SunApparentRa(jd); got != want {
t.Fatalf("nil instance changed SunApparentRa: %.15f vs %.15f", got, want)
}
if got, want := MoonGeocentricTrueRaWithInstance(jd, nil), HMoonGeocentricTrueRa(jd); got != want {
t.Fatalf("nil instance changed MoonGeocentricTrueRa: %.15f vs %.15f", got, want)
}
}
func TestP3PlanetMagnitudeFunctionsUseExplicitInstances(t *testing.T) {
first := ephem.NewInstance(p3ProbeEngine{angle: 0, distance: 0.8})
second := ephem.NewInstance(p3ProbeEngine{angle: math.Pi / 2, distance: 1.2})
jd := 2451545.0
for _, sample := range []struct {
name string
first float64
second float64
}{
{"MercuryMag", MercuryMagWithInstance(jd, first), MercuryMagWithInstance(jd, second)},
{"VenusMag", VenusMagWithInstance(jd, first), VenusMagWithInstance(jd, second)},
{"MarsMag", MarsMagWithInstance(jd, first), MarsMagWithInstance(jd, second)},
{"JupiterMag", JupiterMagWithInstance(jd, first), JupiterMagWithInstance(jd, second)},
{"UranusMag", UranusMagWithInstance(jd, first), UranusMagWithInstance(jd, second)},
{"NeptuneMag", NeptuneMagWithInstance(jd, first), NeptuneMagWithInstance(jd, second)},
} {
if math.Abs(sample.first-sample.second) < 1e-6 {
t.Fatalf("%s did not follow explicit instance: %.12f vs %.12f", sample.name, sample.first, sample.second)
}
}
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
inst := ephem.NewModelInstance(model)
for _, sample := range []struct {
name string
value float64
}{
{"MercuryMag", MercuryMagWithInstance(jd, inst)},
{"JupiterMag", JupiterMagWithInstance(jd, inst)},
{"NeptuneMag", NeptuneMagWithInstance(jd, inst)},
} {
if math.IsNaN(sample.value) || math.IsInf(sample.value, 0) {
t.Fatalf("%s model %d returned non-finite value: %v", sample.name, model, sample.value)
}
}
}
if got, want := MercuryMagWithInstance(jd, nil), MercuryMag(jd); got != want {
t.Fatalf("nil instance changed MercuryMag: %.15f vs %.15f", got, want)
}
}
func TestP3SaturnRingFunctionsUseExplicitInstances(t *testing.T) {
first := ephem.NewInstance(p3ProbeEngine{angle: 0, distance: 0.8})
second := ephem.NewInstance(p3ProbeEngine{angle: math.Pi / 2, distance: 1.2})
jd := 2451545.0
firstRing := SaturnRingBWithInstance(jd, first)
secondRing := SaturnRingBWithInstance(jd, second)
if math.Abs(firstRing-secondRing) < 1e-6 {
t.Fatalf("Saturn ring opening angle did not follow explicit instance: %.12f vs %.12f", firstRing, secondRing)
}
firstMagnitude := SaturnMagWithInstance(jd, first)
secondMagnitude := SaturnMagWithInstance(jd, second)
if math.Abs(firstMagnitude-secondMagnitude) < 1e-6 {
t.Fatalf("Saturn magnitude did not follow explicit instance: %.12f vs %.12f", firstMagnitude, secondMagnitude)
}
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
inst := ephem.NewModelInstance(model)
for _, sample := range []struct {
name string
value float64
}{
{"SaturnRingB", SaturnRingBWithInstance(jd, inst)},
{"SaturnRingPositionAngle", SaturnRingPositionAngleWithInstance(jd, inst)},
{"SaturnMag", SaturnMagWithInstance(jd, inst)},
} {
if math.IsNaN(sample.value) || math.IsInf(sample.value, 0) {
t.Fatalf("%s model %d returned non-finite value: %v", sample.name, model, sample.value)
}
}
}
if got, want := SaturnMagWithInstance(jd, nil), SaturnMag(jd); got != want {
t.Fatalf("nil instance changed SaturnMag: %.15f vs %.15f", got, want)
}
}
func angleDifference(a, b float64) float64 {
difference := math.Mod(a-b+180, 360)
if difference < 0 {
difference += 360
}
return difference - 180
}
+41
View File
@@ -0,0 +1,41 @@
// 被测契约:默认构建以旧级数开始;切换模型使缓存世代递增,重复设置同一模型不递增,未知模型被拒绝。
package basic
import (
"testing"
"b612.me/astro/internal/ephem"
)
func TestEphemerisDefaultIsBuildDefault(t *testing.T) {
if ephem.BuildDefault != ephem.Legacy && ephem.BuildDefault != ephem.Semianalytic {
t.Fatalf("build default %d", ephem.BuildDefault)
}
if GetEphemerisModel() != EphemerisModel(ephem.BuildDefault) {
t.Fatalf("default model %d, build default %d", GetEphemerisModel(), ephem.BuildDefault)
}
}
func TestSetEphemerisModelBumpsGeneration(t *testing.T) {
if SetEphemerisModel(EphemerisModel(99)) {
t.Fatal("unknown model accepted")
}
prev := GetEphemerisModel()
t.Cleanup(func() { SetEphemerisModel(prev) })
SetEphemerisModel(EphemerisLegacy)
g := deltaTGenerationValue()
SetEphemerisModel(EphemerisSemianalytic)
if deltaTGenerationValue() == g {
t.Fatal("generation did not change")
}
g = deltaTGenerationValue()
SetEphemerisModel(EphemerisSemianalytic)
if deltaTGenerationValue() != g {
t.Fatal("setting the same model changed the generation")
}
before := HMoonTrueLo(2451545.0)
SetEphemerisModel(EphemerisLegacy)
if HMoonTrueLo(2451545.0) == before {
t.Fatal("moon longitude did not follow the model switch")
}
}
+85
View File
@@ -0,0 +1,85 @@
// 事件回归基线按星历模型分文件:旧模型用 testdata/<name>.json,新模型用 testdata/semianalytic/<name>.json。
// ASTRO_UPDATE_GOLDEN=1 且当前为新模型时,以旧基线的输入时刻重算并重写新模型基线;旧模型基线从不改写。
package basic
import (
"encoding/json"
"math"
"os"
"path/filepath"
"sort"
"testing"
"b612.me/astro/internal/ephem"
"b612.me/astro/internal/ephemtest"
)
type eventBaselineSample struct {
InputUTC string `json:"input_utc"`
TTJDBits uint64 `json:"tt_jd_bits"`
Events map[string]uint64 `json:"events"`
}
type eventBaselineFile struct {
Samples []eventBaselineSample `json:"samples"`
}
type namedEventFunc struct {
name string
fn func(float64) float64
}
func readEventBaseline(t *testing.T, path string) eventBaselineFile {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var b eventBaselineFile
if err := json.Unmarshal(data, &b); err != nil {
t.Fatal(err)
}
if len(b.Samples) == 0 {
t.Fatalf("empty event baseline %s", path)
}
return b
}
// eventBaselineData 当前模型的基线原始字节;需要时先重写新模型基线。
func eventBaselineData(t *testing.T, legacyPath string, cases []namedEventFunc) []byte {
t.Helper()
path := ephemtest.Golden(legacyPath)
if ephemtest.Update() && ephem.Active() == ephem.Semianalytic {
src := readEventBaseline(t, legacyPath)
out := eventBaselineFile{Samples: make([]eventBaselineSample, len(src.Samples))}
for i, s := range src.Samples {
jd := math.Float64frombits(s.TTJDBits)
ev := make(map[string]uint64, len(cases))
for _, c := range cases {
ev[c.name] = math.Float64bits(c.fn(jd))
}
out.Samples[i] = eventBaselineSample{InputUTC: s.InputUTC, TTJDBits: s.TTJDBits, Events: ev}
}
data, err := json.MarshalIndent(out, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
t.Fatal(err)
}
names := make([]string, 0, len(cases))
for _, c := range cases {
names = append(names, c.name)
}
sort.Strings(names)
t.Logf("rewrote %s: %d samples × %d events", path, len(out.Samples), len(names))
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("%v (regenerate with ASTRO_UPDATE_GOLDEN=1 go test)", err)
}
return data
}
+77
View File
@@ -0,0 +1,77 @@
package basic
import "math"
const (
exactEventTolerance = 2.0 / 86400.0
exactQueryTTToleranceUT = 0.1 / 86400.0
// stationQueryToleranceUT 站(留)事件的「同刻」容差 / same-instant tolerance for station events.
//
// 站的求解本身有数值不确定度(火星 ~0.2 s、金星 ~2 s、外行星改精修步长后 <0.05 s)。
// 若与普通事件一样取 0.1 s,查询落在站前几十毫秒时会被判成「站仍在未来」,
// 于是跳到上一个会合周期。取 0.5 s 可覆盖除金星外的全部不确定度。
// 上限约束:必须明显小于 1 s,否则会破坏
// TestInnerPlanetNextEventAdvancesPastReturnedEvent 的「查询=事件+1 秒必须前进」语义。
stationQueryToleranceUT = 0.5 / 86400.0
)
func sameEventJD(a, b float64) bool {
return math.Abs(a-b) <= exactEventTolerance
}
// stationUTQueryBeforeOrEqual / stationUTQueryAfterOrEqual 站事件专用侧向判定,
// 容差比普通事件宽(见 stationQueryToleranceUT)。
func stationUTQueryBeforeOrEqual(eventUT, queryTT float64) bool {
return eventUTQueryTTDelta(eventUT, queryTT) <= stationQueryToleranceUT
}
func stationUTQueryAfterOrEqual(eventUT, queryTT float64) bool {
return eventUTQueryTTDelta(eventUT, queryTT) >= -stationQueryToleranceUT
}
func closestEventUTToQueryTT(queryTT, best float64, candidates ...float64) float64 {
bestAbs := math.Abs(eventUTQueryTTDelta(best, queryTT))
for _, candidate := range candidates {
candidateAbs := math.Abs(eventUTQueryTTDelta(candidate, queryTT))
if candidateAbs < bestAbs {
best = candidate
bestAbs = candidateAbs
}
}
return best
}
type phaseEventSearchFunc func(jde, degree float64, next uint8) float64
type simpleEventSearchFunc func(jde float64) float64
// inclusive* 的唯一作用是把「查询几乎正好落在事件上」判成包含:
// 反向搜索给出的邻接事件落在同刻容差内时优先返回它,否则返回本方向的事件(可能是 NaN)。
func inclusiveLastPhaseEvent(jde, degree float64, fn phaseEventSearchFunc) float64 {
last := fn(jde, degree, 0)
if next := fn(jde, degree, 1); eventUTQueryBeforeOrEqual(next, jde) && eventUTQueryAfterOrEqual(next, jde) {
return next
}
return last
}
func inclusiveNextPhaseEvent(jde, degree float64, fn phaseEventSearchFunc) float64 {
if last := fn(jde, degree, 0); eventUTQueryBeforeOrEqual(last, jde) && eventUTQueryAfterOrEqual(last, jde) {
return last
}
return fn(jde, degree, 1)
}
func inclusiveLastSimpleEvent(jde float64, lastFn, nextFn simpleEventSearchFunc) float64 {
last := lastFn(jde)
if next := nextFn(jde); eventUTQueryBeforeOrEqual(next, jde) && eventUTQueryAfterOrEqual(next, jde) {
return next
}
return last
}
func inclusiveNextSimpleEvent(jde float64, lastFn, nextFn simpleEventSearchFunc) float64 {
if last := lastFn(jde); eventUTQueryBeforeOrEqual(last, jde) && eventUTQueryAfterOrEqual(last, jde) {
return last
}
return nextFn(jde)
}
+172 -3
View File
@@ -2,6 +2,177 @@ package basic
import "math"
const (
eventNewtonMaxIterations = 24
eventDirectionalSearchIterations = 128
eventRiseSetScanStep = 1.0 / 1440
// stationDerivativeStepDay 「留」精修用的中心差分步长(天):更细的步长会被浮点相消噪声支配。
stationDerivativeStepDay = 0.01
)
func isFiniteFloat(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
// eventNewtonRefine 执行有界牛顿迭代;修正函数返回 f(x)/f'(x),调用者保留现有导数计算 / eventNewtonRefine performs a bounded Newton iteration. The correction
// 对格式错误输入和不收敛迭代快速失败 / function returns f(x)/f'(x), so callers retain their existing derivative
// 计算 / calculation while malformed input and non-convergent iterations fail fast.
func eventNewtonRefine(seed, tolerance float64, correction func(float64) float64) (float64, bool) {
if !isFiniteFloat(seed) || !isFiniteFloat(tolerance) || tolerance <= 0 {
return math.NaN(), false
}
current := seed
for i := 0; i < eventNewtonMaxIterations; i++ {
step := correction(current)
if !isFiniteFloat(step) {
return math.NaN(), false
}
next := current - step
if !isFiniteFloat(next) {
return math.NaN(), false
}
if math.Abs(next-current) <= tolerance {
return next, true
}
current = next
}
return math.NaN(), false
}
func eventRiseSetCandidateValid(candidate, civilDayStart, slope float64, isRise bool) bool {
if !isFiniteFloat(candidate) || !isFiniteFloat(civilDayStart) || !isFiniteFloat(slope) ||
candidate < civilDayStart || candidate >= civilDayStart+1 {
return false
}
if isRise {
return slope > 0
}
return slope < 0
}
func eventDirectionalRiseSetSearch(civilDayStart float64, isRise bool, fallbackErr error,
residual func(float64) float64) (float64, error) {
if !isFiniteFloat(civilDayStart) {
return 0, ErrInvalidObservationInput
}
previousJD := civilDayStart
previousValue := residual(previousJD)
if !isFiniteFloat(previousValue) {
return 0, ErrInvalidObservationInput
}
minimum, maximum := previousValue, previousValue
steps := int(math.Round(1 / eventRiseSetScanStep))
for i := 1; i <= steps; i++ {
currentJD := civilDayStart + float64(i)*eventRiseSetScanStep
currentValue := residual(currentJD)
if !isFiniteFloat(currentValue) {
return 0, ErrInvalidObservationInput
}
minimum = math.Min(minimum, currentValue)
maximum = math.Max(maximum, currentValue)
if eventCrossesDirection(previousValue, currentValue, isRise) {
eventJD := eventDirectionalBracketRefine(previousJD, currentJD, previousValue, currentValue, residual)
if eventJD < civilDayStart+1 {
return eventJD, nil
}
}
previousJD = currentJD
previousValue = currentValue
}
switch {
case fallbackErr != nil:
return 0, fallbackErr
case maximum < 0:
return 0, ErrNeverRise
case minimum > 0:
return 0, ErrNeverSet
default:
return 0, ErrNotOnThisDate
}
}
func eventCrossesDirection(leftValue, rightValue float64, isRise bool) bool {
if isRise {
return leftValue <= 0 && rightValue >= 0 && leftValue != rightValue
}
return leftValue >= 0 && rightValue <= 0 && leftValue != rightValue
}
func eventDirectionalBracketRefine(leftJD, rightJD, leftValue, rightValue float64, residual func(float64) float64) float64 {
if leftValue == 0 {
return leftJD
}
if rightValue == 0 {
return rightJD
}
for i := 0; i < 48; i++ {
middleJD := (leftJD + rightJD) / 2
middleValue := residual(middleJD)
if middleValue == 0 {
return middleJD
}
if (leftValue < 0) == (middleValue < 0) {
leftJD = middleJD
leftValue = middleValue
} else {
rightJD = middleJD
}
}
return (leftJD + rightJD) / 2
}
// eventBracketSecantRoot 在已知异号的括号内用割线法(带中点兜底)求根。
//
// 与 eventDirectionalBracketRefine(固定 48 次二分)相比,割线法通常 5~8 次求值即可达到
// 亚秒精度,适合「括号由廉价截断级数给出、抛光必须用全项级数」的两段式搜索。
// 括号每一步都收缩,因此不会跑到括号外;括号端点同号或出现非有限值时返回 false。
func eventBracketSecantRoot(leftJD, rightJD, leftValue, rightValue, tolerance float64,
fn func(float64) float64) (float64, bool) {
if leftValue == 0 {
return leftJD, true
}
if rightValue == 0 {
return rightJD, true
}
if !isFiniteFloat(leftValue) || !isFiniteFloat(rightValue) || leftValue*rightValue > 0 {
return math.NaN(), false
}
if !isFiniteFloat(tolerance) || tolerance <= 0 {
tolerance = 0.5 / 86400.0
}
bestJD := (leftJD + rightJD) / 2
for i := 0; i < 48; i++ {
candidateJD := (leftJD + rightJD) / 2
if rightValue != leftValue {
secantJD := rightJD - rightValue*(rightJD-leftJD)/(rightValue-leftValue)
if secantJD > leftJD && secantJD < rightJD {
candidateJD = secantJD
}
}
candidateValue := fn(candidateJD)
if !isFiniteFloat(candidateValue) {
return math.NaN(), false
}
bestJD = candidateJD
if candidateValue == 0 || math.Abs(rightJD-leftJD) <= tolerance {
return candidateJD, true
}
if (leftValue < 0) == (candidateValue < 0) {
leftJD, leftValue = candidateJD, candidateValue
continue
}
rightJD, rightValue = candidateJD, candidateValue
}
if math.Abs(rightJD-leftJD) <= tolerance {
return bestJD, true
}
// 48 次迭代后括号仍宽于容差(例如容差低于该儒略日的 ULP):调用方把 ok 当作
// “已抛光到容差”,这里必须报 false,不能返回一个精度未达标的时刻。
return bestJD, false
}
func eventFixedScanRefine(seed, halfWindow, step float64, fn func(float64) float64) float64 {
start := seed - halfWindow
bestJD := start
@@ -40,9 +211,7 @@ func eventZeroBracket(leftJD, leftVal, centerJD, centerVal, rightJD, rightVal fl
return 0, 0, 0, 0, false
}
// eventZeroRefine 细化 seed 附近的零点;若找不到可用括号区间,则退回旧的固定步长扫描。
// eventZeroRefine refines a nearby zero crossing and falls back to the legacy
// fixed-step scan when no usable bracket is found.
// eventZeroRefine 细化 seed 附近的零点;无可用括号区间时退回固定步长扫描。
func eventZeroRefine(seed, halfWindow, step float64, fn func(float64) float64) float64 {
leftJD := seed - halfWindow
centerJD := seed
+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"
"b612.me/astro/internal/geodata"
)
// 等时线(食甚/掩甚时刻等值线)的共享常量与工具。
// 两个等时线实现(日食、月掩)用同一套容差与时刻取值生成规则,避免口径漂移。
const (
// greatestTimeContourCoverToleranceKM 是"该点是否已被已绘等时线覆盖"的距离门限。
// 判据必须用点到折线的距离:同一条曲线被两个种子各画一次时该距离约为 0,而折线弦高在
// 1.5° 步长下最大几 km,所以 10 km 既不会漏判重复,也远小于不同支路的间距。
greatestTimeContourCoverToleranceKM = 10.0
// greatestTimeContourCorrectionBacktracking 是牛顿投影跳出定义域时按二分回退的次数。
greatestTimeContourCorrectionBacktracking = 6
// 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 geodata.NormalizeLongitude(value-startLongitude) * rad * cosine * geodata.EarthKMPerDegree
}
toY := func(value float64) float64 { return (value - startLatitude) * geodata.EarthKMPerDegree }
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)
}
}
}
}
+272
View File
@@ -0,0 +1,272 @@
package basic
import (
"math"
"sort"
)
const (
schaeferNightSkyNanolambert = 180.0
schaeferNanolambertToErg = 1.02e-15
schaeferSunMagnitude = -26.74
schaeferZeroMagnitudeRef = -11.05
schaeferMinSeparationDeg = 0.1
schaeferTwilightLowSunDeg = -3.0
schaeferDaylightHighSunDeg = 4.0
schaeferScotopicLimitNL = 1479.1
)
// HeliacalOptions 偕日可见计算参数;零值使用消光系数 0.25 星等/大气质量、目标最低高度 0.5° / heliacal visibility options.
type HeliacalOptions struct {
// ExtinctionCoefficient 大气消光系数(星等/大气质量)/ extinction in magnitudes per airmass.
ExtinctionCoefficient float64
// MinTargetAltitude 目标最低几何高度(度)/ minimum geometric target altitude in degrees.
MinTargetAltitude float64
// Moonlight 为 true 时计入月光散射(Krisciunas–Schaefer);默认不计 / include scattered moonlight when true.
Moonlight bool
}
func (options HeliacalOptions) normalized() HeliacalOptions {
if !(options.ExtinctionCoefficient > 0) {
options.ExtinctionCoefficient = 0.25
}
if !(options.MinTargetAltitude > 0) {
options.MinTargetAltitude = 0.5
}
return options
}
func schaeferAirmass(altitudeDeg float64) float64 {
z := math.Min(90, math.Max(0, 90-altitudeDeg)) * rad
c := math.Cos(z)
return 1 / (c + 0.025*math.Exp(-11*c))
}
func schaeferTransmission(k, airmass float64) float64 { return math.Pow(10, -0.4*k*airmass) }
func schaeferPhase(separationDeg float64) float64 {
s := math.Max(schaeferMinSeparationDeg, separationDeg)
c := math.Cos(s * rad)
return 6.2e7/(s*s) + math.Pow(10, 6.15-s/40) + math.Pow(10, 5.36)*(1.06+c*c)
}
func validExtinction(extinction float64) bool {
return extinction > 0 && !math.IsInf(extinction, 0)
}
// SkyBrightnessNanolambert 无月夜晨昏天光亮度(nL,Schaefer 模型),消光系数须为正,否则返回 NaN / twilight sky brightness without moonlight, Schaefer model.
func SkyBrightnessNanolambert(targetAltitude, sunAltitude, separation, extinction float64) float64 {
if !validExtinction(extinction) {
return math.NaN()
}
k := extinction
targetT := schaeferTransmission(k, schaeferAirmass(targetAltitude))
sunT := schaeferTransmission(k, schaeferAirmass(sunAltitude))
targetZ := math.Min(90, math.Max(0, 90-targetAltitude))
sunZ := math.Min(180, math.Max(0, 90-sunAltitude))
sep := math.Max(schaeferMinSeparationDeg, separation)
scale := math.Pow(10, -0.4*(schaeferSunMagnitude-schaeferZeroMagnitudeRef+43.27))
day := scale * (1 - targetT) * (schaeferPhase(sep)*sunT + 440000*(1-sunT))
twilight := math.Pow(10, -0.4*(schaeferSunMagnitude-schaeferZeroMagnitudeRef-57.5+sunZ-targetZ/(360*k))) *
(1 - targetT) * 100 / sep
sky := math.Min(day, twilight)
switch {
case sunAltitude < schaeferTwilightLowSunDeg:
sky = twilight
case sunAltitude > schaeferDaylightHighSunDeg:
sky = day
}
sz := math.Sin(targetZ * rad)
night := schaeferNightSkyNanolambert * (0.4 + 0.6/math.Sqrt(1-0.96*sz*sz)) * targetT
return sky/schaeferNanolambertToErg + night
}
// LimitingMagnitude 肉眼极限星等(已扣除目标方向的大气消光),消光系数须为正,否则返回 NaN / naked-eye limiting magnitude after extinction at the target altitude.
func LimitingMagnitude(targetAltitude, sunAltitude, separation, extinction float64) float64 {
return limitingMagnitudeForSky(SkyBrightnessNanolambert(targetAltitude, sunAltitude, separation, extinction), targetAltitude, extinction)
}
// MoonlightNanolambert 月光在目标方向造成的天光亮度(nL,Krisciunas–Schaefer);月在地平下为 0,消光系数非正为 NaN /
// sky brightness from scattered moonlight; zero when the Moon is below the horizon.
func MoonlightNanolambert(targetAltitude, moonAltitude, moonSeparation, moonPhaseAngle, extinction float64) float64 {
if !validExtinction(extinction) {
return math.NaN()
}
if moonAltitude <= 0 || targetAltitude <= 0 {
return 0
}
alpha := math.Abs(moonPhaseAngle)
mag := -12.73 + 0.026*alpha + 4e-9*math.Pow(alpha, 4)
illuminance := math.Pow(10, -0.4*(mag+16.57))
rho := math.Max(schaeferMinSeparationDeg, moonSeparation)
c := math.Cos(rho * rad)
scatter := math.Pow(10, 5.36)*(1.06+c*c) + math.Pow(10, 6.15-rho/40)
airmass := func(alt float64) float64 {
s := math.Sin((90 - alt) * rad)
return 1 / math.Sqrt(1-0.96*s*s)
}
return scatter * illuminance * math.Pow(10, -0.4*extinction*airmass(moonAltitude)) *
(1 - math.Pow(10, -0.4*extinction*airmass(targetAltitude)))
}
func limitingMagnitudeForSky(b, targetAltitude, extinction float64) float64 {
c, k := 4.4668359215096e-9, 1.2589254117942e-6
if b < schaeferScotopicLimitNL {
c, k = 1.5848931924611e-10, 0.012589254117942
}
threshold := c * math.Pow(1+math.Sqrt(k*b), 2)
return -16.57 - 2.5*math.Log10(threshold) - extinction*schaeferAirmass(targetAltitude)
}
// HeliacalEventKind 偕日事件类型 / heliacal event kind.
type HeliacalEventKind int
const (
// HeliacalMorningFirst 晨始见(偕日升)/ morning first visibility.
HeliacalMorningFirst HeliacalEventKind = iota
// HeliacalMorningLast 晨末见 / morning last visibility.
HeliacalMorningLast
// HeliacalEveningFirst 昏始见 / evening first visibility.
HeliacalEveningFirst
// HeliacalEveningLast 昏末见(偕日落)/ evening last visibility.
HeliacalEveningLast
)
// HeliacalEvent 偕日事件:JD 为当天最佳可见时刻(UT 儒略日),Margin 为极限星等减目标星等 / heliacal event at the best moment of that twilight.
type HeliacalEvent struct {
Kind HeliacalEventKind
JD float64
Margin float64
TargetAltitude float64
SunAltitude float64
}
// heliacalTarget 返回 TT 时刻目标的视赤经、赤纬(度)与视星等。
type heliacalTarget func(jde float64) (ra, dec, magnitude float64)
type heliacalBest struct {
jd, margin, targetAlt, sunAlt float64
visible bool
}
func heliacalAltitude(ra, dec, lst, lat float64) float64 {
h := (lst - ra) * rad
d, p := dec*rad, lat*rad
return math.Asin(math.Sin(p)*math.Sin(d)+math.Cos(p)*math.Cos(d)*math.Cos(h)) / rad
}
// heliacalTwilight 扫描一次晨(morning)或昏的太阳高度 −18°..0° 时段,取可见余量最大的样本。
// 日月位置在窗口中点取一次,窗口不足两小时,引入的高度误差远小于模型本身。
func heliacalTwilight(dayJD, lon, lat float64, morning bool, target heliacalTarget, options HeliacalOptions, inst *Instance) heliacalBest {
center := dayJD - lon/360 + 0.5
if morning {
center -= 0.25
} else {
center += 0.25
}
jde := UTC2TT(center)
sunRA, sunDec := HSunApparentRaDecWithInstance(jde, inst)
ra, dec, mag := target(jde)
sep := skyAngularDistance(ra, dec, sunRA, sunDec)
best := heliacalBest{margin: math.Inf(-1)}
if math.IsNaN(ra) || math.IsNaN(mag) {
return best
}
var moonRA, moonDec, moonSep, moonPhase float64
if options.Moonlight {
lo, bo := SkyBodyApparentLoBoWithInstance(SkyMoon, jde, inst)
moonRA, moonDec = LoBoToRaDec(jde, lo, bo)
moonSep = skyAngularDistance(ra, dec, moonRA, moonDec)
moonPhase = 180 - skyAngularDistance(moonRA, moonDec, sunRA, sunDec)
}
lst0 := ApparentSiderealTime(UTC2UT1(center))*15 + lon
const step = 2.0 / 1440
for dt := -0.25; dt <= 0.25; dt += step {
lst := lst0 + dt*360.98564736629
sunAlt := heliacalAltitude(sunRA, sunDec, lst, lat)
if sunAlt > 0 || sunAlt < -18 {
continue
}
alt := heliacalAltitude(ra, dec, lst, lat)
if alt < options.MinTargetAltitude {
continue
}
sky := SkyBrightnessNanolambert(alt, sunAlt, sep, options.ExtinctionCoefficient)
if options.Moonlight {
sky += MoonlightNanolambert(alt, heliacalAltitude(moonRA, moonDec, lst, lat), moonSep, moonPhase, options.ExtinctionCoefficient)
}
margin := limitingMagnitudeForSky(sky, alt, options.ExtinctionCoefficient) - mag
if margin > best.margin {
best = heliacalBest{center + dt, margin, alt, sunAlt, margin >= 0}
}
}
return best
}
// heliacalMaxDays 逐日扫描的天数上限(约 2.3 万年),挡住非法跨度造成的近乎无界扫描。
const heliacalMaxDays = 1 << 23
// findHeliacalEvents 逐日扫描晨、昏可见性,从不可见变为可见记“始见”,反之记“末见”(取末个可见日)。
func findHeliacalEvents(startJD, endJD, lon, lat float64, target heliacalTarget, options HeliacalOptions, inst *Instance) []HeliacalEvent {
options = options.normalized()
if !finite(startJD) || !finite(endJD) || !(endJD > startJD) || math.Abs(lat) >= 90 {
return nil
}
day0 := math.Floor(startJD-0.5) + 0.5
span := math.Floor(endJD - day0)
if !(span < heliacalMaxDays) {
return nil
}
days := int(span)
var out []HeliacalEvent
for _, morning := range []bool{true, false} {
first, last := HeliacalEveningFirst, HeliacalEveningLast
if morning {
first, last = HeliacalMorningFirst, HeliacalMorningLast
}
prev := heliacalTwilight(day0-1, lon, lat, morning, target, options, inst)
for i := 0; i <= days; i++ {
cur := heliacalTwilight(day0+float64(i), lon, lat, morning, target, options, inst)
// 事件时刻是采样日内的细化值,可能越过 endJD,所以上界按事件时刻判断,不能按采样日判断。
switch {
case cur.visible && !prev.visible && cur.jd >= startJD && cur.jd <= endJD:
out = append(out, HeliacalEvent{first, cur.jd, cur.margin, cur.targetAlt, cur.sunAlt})
case !cur.visible && prev.visible && prev.jd >= startJD && prev.jd <= endJD:
out = append(out, HeliacalEvent{last, prev.jd, prev.margin, prev.targetAlt, prev.sunAlt})
}
prev = cur
}
}
sortHeliacalEvents(out)
return out
}
func sortHeliacalEvents(events []HeliacalEvent) {
sort.SliceStable(events, func(i, j int) bool { return events[i].JD < events[j].JD })
}
// FindPlanetHeliacalEventsWithInstance 行星偕日事件(UT 儒略日范围,经度东正)/ planet heliacal events in a UT JD range.
func FindPlanetHeliacalEventsWithInstance(body SkyBody, startJD, endJD, lon, lat float64, options HeliacalOptions, inst *Instance) []HeliacalEvent {
if body < SkyMercury || body > SkyNeptune {
return nil
}
index := int(body-SkyMercury) + 1
target := func(jde float64) (float64, float64, float64) {
lo, bo := SkyBodyApparentLoBoWithInstance(body, jde, inst)
ra, dec := LoBoToRaDec(jde, lo, bo)
return ra, dec, planetMagnitude2018(index, jde, inst)
}
return findHeliacalEvents(startJD, endJD, lon, lat, target, options, inst)
}
// FindStarHeliacalEventsWithInstance 恒星偕日事件;magnitude 为恒星 V 星等 / star heliacal events with the star's V magnitude.
func FindStarHeliacalEventsWithInstance(star StarCoordinate, magnitude, startJD, endJD, lon, lat float64, options HeliacalOptions, inst *Instance) []HeliacalEvent {
if math.IsNaN(magnitude) || math.IsInf(magnitude, 0) {
return nil
}
target := func(jde float64) (float64, float64, float64) {
ra, dec := starApparentRaDecGeocentric(jde, star, inst)
return ra, dec, magnitude
}
return findHeliacalEvents(startJD, endJD, lon, lat, target, options, inst)
}
+138
View File
@@ -0,0 +1,138 @@
// 被测契约:天狼星在开罗约 8 月初晨始见;金星下合前后依次出现昏末见与晨始见;极限星等随天光单调变化;偕日事件不得越出请求区间。
package basic
import (
"math"
"testing"
"time"
)
func TestSiriusHeliacalRisingCairo(t *testing.T) {
sirius := StarCoordinate{ID: "Sirius", RA: 101.28715533, Dec: -16.71611586, Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -546.01, ProperMotionDecMasPerYear: -1223.07}
start := Date2JD(time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC))
end := Date2JD(time.Date(2025, 9, 30, 0, 0, 0, 0, time.UTC))
events := FindStarHeliacalEventsWithInstance(sirius, -1.46, start, end, 31.23, 30.04, HeliacalOptions{}, nil)
var rising *HeliacalEvent
for i := range events {
if events[i].Kind == HeliacalMorningFirst {
rising = &events[i]
break
}
}
if rising == nil {
t.Fatalf("no morning first visibility: %+v", events)
}
when := JD2Date(rising.JD)
t.Logf("Sirius morning first %v margin %.2f alt %.2f sun %.2f", when, rising.Margin, rising.TargetAltitude, rising.SunAltitude)
if when.Month() != time.July && when.Month() != time.August {
t.Errorf("Sirius heliacal rising %v, expected late July to mid August", when)
}
}
func TestVenusHeliacalAroundInferiorConjunction(t *testing.T) {
// 2025-03-23 金星下合。
start := Date2JD(time.Date(2025, 2, 15, 0, 0, 0, 0, time.UTC))
end := Date2JD(time.Date(2025, 4, 30, 0, 0, 0, 0, time.UTC))
events := FindPlanetHeliacalEventsWithInstance(SkyVenus, start, end, 116.4, 39.9, HeliacalOptions{}, nil)
var eveningLast, morningFirst float64
for _, e := range events {
t.Logf("kind %d %v margin %.2f", e.Kind, JD2Date(e.JD), e.Margin)
if e.Kind == HeliacalEveningLast && eveningLast == 0 {
eveningLast = e.JD
}
if e.Kind == HeliacalMorningFirst && morningFirst == 0 {
morningFirst = e.JD
}
}
conj := Date2JD(time.Date(2025, 3, 23, 0, 0, 0, 0, time.UTC))
if eveningLast == 0 || morningFirst == 0 || !(eveningLast < conj && morningFirst > conj) {
t.Errorf("evening last %.1f, morning first %.1f around conjunction %.1f", eveningLast, morningFirst, conj)
}
}
func TestLimitingMagnitudeDarkensWithTwilight(t *testing.T) {
prev := LimitingMagnitude(20, -2, 30, 0.25)
for _, sun := range []float64{-4, -6, -9, -12, -15, -18} {
cur := LimitingMagnitude(20, sun, 30, 0.25)
if !(cur > prev) {
t.Errorf("sun %.0f: limiting magnitude %.2f not fainter than %.2f", sun, cur, prev)
}
prev = cur
}
if prev < 5 || prev > 7.5 {
t.Errorf("dark-sky limiting magnitude at 20° altitude %.2f", prev)
}
}
func TestMoonlightBrightensSky(t *testing.T) {
if MoonlightNanolambert(30, -5, 20, 10, 0.25) != 0 {
t.Error("moon below horizon must contribute nothing")
}
full := MoonlightNanolambert(30, 40, 20, 5, 0.25)
crescent := MoonlightNanolambert(30, 40, 20, 120, 0.25)
if !(full > crescent && crescent > 0 && full > 1000) {
t.Errorf("full %.1f crescent %.1f nL", full, crescent)
}
start := 2460676.5
plain := FindPlanetHeliacalEventsWithInstance(SkyVenus, start, start+400, 116.4, 39.9, HeliacalOptions{}, nil)
lit := FindPlanetHeliacalEventsWithInstance(SkyVenus, start, start+400, 116.4, 39.9, HeliacalOptions{Moonlight: true}, nil)
if len(plain) == 0 || len(lit) == 0 {
t.Fatalf("events %d %d", len(plain), len(lit))
}
}
func TestHeliacalRejectsUnscannableRange(t *testing.T) {
sirius := StarCoordinate{RA: 101.287, Dec: -16.716}
for _, r := range [][2]float64{
{2451545, math.Inf(1)}, {math.Inf(-1), 2451545}, {math.NaN(), 2451545},
{2451545, math.NaN()}, {2451545, 2451545 + 1e10}, {1e300, math.MaxFloat64},
} {
if got := FindStarHeliacalEventsWithInstance(sirius, -1.46, r[0], r[1], 31.23, 30.04, HeliacalOptions{}, nil); got != nil {
t.Errorf("star %v: %d events", r, len(got))
}
if got := FindPlanetHeliacalEventsWithInstance(SkyVenus, r[0], r[1], 116.4, 39.9, HeliacalOptions{}, nil); got != nil {
t.Errorf("planet %v: %d events", r, len(got))
}
}
}
func TestSkyModelRejectsNonPositiveExtinction(t *testing.T) {
for _, k := range []float64{0, -1, math.NaN(), math.Inf(1)} {
if v := SkyBrightnessNanolambert(20, -10, 30, k); !math.IsNaN(v) {
t.Errorf("sky k=%g: %g", k, v)
}
if v := LimitingMagnitude(20, -10, 30, k); !math.IsNaN(v) {
t.Errorf("limit k=%g: %g", k, v)
}
if v := MoonlightNanolambert(30, 40, 20, 5, k); !math.IsNaN(v) {
t.Errorf("moonlight k=%g: %g", k, v)
}
}
}
func TestHeliacalEventsStayWithinRequestedRange(t *testing.T) {
lon, lat := 116.4, 39.9
narrow := 0
for _, body := range []SkyBody{SkyMercury, SkyVenus, SkyMars, SkyJupiter, SkySaturn} {
wide := FindPlanetHeliacalEventsWithInstance(body, 2459000.5, 2460000.5, lon, lat, HeliacalOptions{}, nil)
for _, event := range wide {
// 事件时刻是采样日内的细化值;把 endJD 卡在两者之间,正是越界最容易出现的窗口。
day := math.Floor(event.JD-0.5) + 0.5
offset := event.JD - day
if offset <= 0.1 {
continue
}
start, end := day, day+offset-0.05
narrow++
for _, got := range FindPlanetHeliacalEventsWithInstance(body, start, end, lon, lat, HeliacalOptions{}, nil) {
if got.JD < start || got.JD > end {
t.Fatalf("body %v: event JD %.6f outside [%.6f, %.6f]", body, got.JD, start, end)
}
}
}
}
if narrow == 0 {
t.Fatal("no narrow window was exercised")
}
}
+32
View File
@@ -0,0 +1,32 @@
package basic
import "b612.me/astro/internal/iau2006"
// RotationMatrix 3×3 旋转矩阵,按行存放,作用于列向量 / 3×3 rotation matrix, row-major, acting on column vectors.
type RotationMatrix = iau2006.Matrix
// Nutation2000A IAU 2000A 章动,返回 (黄经章动, 交角章动),单位度;不进入默认计算链 / IAU 2000A nutation in degrees, opt-in only.
func Nutation2000A(jde float64) (float64, float64) {
dpsi, deps := iau2006.Nutation(jde)
return dpsi / 3600, deps / 3600
}
// MeanObliquity2006 IAU 2006 平黄赤交角(度)/ IAU 2006 mean obliquity in degrees.
func MeanObliquity2006(jde float64) float64 { return iau2006.MeanObliquity(jde) / 3600 }
// EquationOfEquinoxes2006 春分点方程主项 Δψ·cos εA(度),按 IAU 2000A 章动 / equation of the equinoxes in degrees, IAU 2000A.
func EquationOfEquinoxes2006(jde float64) float64 { return iau2006.EquationOfEquinoxes(jde) / 3600 }
// Precession2006Matrix J2000 平赤道到历元平赤道的 IAU 2006 岁差矩阵 / IAU 2006 precession matrix.
func Precession2006Matrix(jde float64) RotationMatrix { return iau2006.Precession(jde) }
// Nutation2000AMatrix 历元平赤道到真赤道的 IAU 2000A 章动矩阵 / IAU 2000A nutation matrix.
func Nutation2000AMatrix(jde float64) RotationMatrix { return iau2006.NutationMatrix(jde) }
// FrameBiasMatrix ICRS 到 J2000 平赤道的框架偏差矩阵 / frame bias from ICRS to the J2000 mean equator.
func FrameBiasMatrix() RotationMatrix { return iau2006.FrameBias() }
// BiasPrecessionNutation2006 ICRS 到真赤道真春分点的完整旋转 N·P·B / full ICRS to true-of-date rotation.
func BiasPrecessionNutation2006(jde float64) RotationMatrix {
return iau2006.BiasPrecessionNutation(jde)
}
+25
View File
@@ -0,0 +1,25 @@
// 被测契约:2000A 与默认 2000B 章动差在 1 mas 量级,单位换算正确。
package basic
import (
"math"
"testing"
)
func TestNutation2000ANearDefault(t *testing.T) {
for _, jde := range []float64{2451545.0, 2460676.5, 2415020.0, 2488069.5} {
a1, a2 := Nutation2000A(jde)
b1, b2 := Nutation2000B(jde)
if d := math.Max(math.Abs(a1-b1), math.Abs(a2-b2)) * 3600e3; d > 2 {
t.Errorf("jde %.1f: 2000A vs 2000B differ by %.3f mas", jde, d)
}
}
if d := math.Abs(MeanObliquity2006(2451545.0)*3600 - 84381.406); d > 1e-9 {
t.Errorf("J2000 obliquity off by %g arcsec", d)
}
m := BiasPrecessionNutation2006(2460676.5)
v := m.Apply([3]float64{1, 0, 0})
if n := math.Sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2]); math.Abs(n-1) > 1e-14 {
t.Errorf("rotation not orthonormal: %g", n)
}
}
+189
View File
@@ -0,0 +1,189 @@
package basic
import "math"
const (
innerEventEpsilon = 0.1 / 86400.0
innerEventWindowPadding = 4.0 / 86400.0
innerEventMaximizeEpsilon = 4.0 / 86400.0
)
func eventQueryTTAsUT(queryTT float64) float64 {
return TT2UTC(queryTT)
}
func eventUTQueryTTDelta(eventUT, queryTT float64) float64 {
return eventUT - eventQueryTTAsUT(queryTT)
}
func eventUTQueryBeforeOrEqual(eventUT, queryTT float64) bool {
return eventUTQueryTTDelta(eventUT, queryTT) <= innerEventEpsilon
}
func eventUTQueryAfterOrEqual(eventUT, queryTT float64) bool {
return eventUTQueryTTDelta(eventUT, queryTT) >= -innerEventEpsilon
}
func eventUTNextQueryTT(eventUT float64) float64 {
return UTC2TT(eventUT) + 1.0
}
func eventUTLastQueryTT(eventUT float64) float64 {
return UTC2TT(eventUT) - 1.0
}
func innerNextCycleOffset(delta, period float64) float64 {
if delta <= 0 {
return -delta * period / 360.0
}
return (360.0 - delta) * period / 360.0
}
func innerLastCycleOffset(delta, period float64) float64 {
if delta >= 0 {
return delta * period / 360.0
}
return (360.0 + delta) * period / 360.0
}
func clampFloat64(v, min, max float64) float64 {
if v < min {
return min
}
if v > max {
return max
}
return v
}
func scanWindowForMinAbs(start, end, step float64, fn func(float64) float64) float64 {
if end < start {
start, end = end, start
}
if step <= 0 || end == start {
return start
}
bestJD := start
bestAbs := math.Abs(fn(start))
for jd := start + step; jd < end; jd += step {
candidateAbs := math.Abs(fn(jd))
if candidateAbs < bestAbs {
bestAbs = candidateAbs
bestJD = jd
}
}
endAbs := math.Abs(fn(end))
if endAbs < bestAbs {
return end
}
return bestJD
}
func scanWindowForMax(start, end, step float64, fn func(float64) float64) float64 {
if end < start {
start, end = end, start
}
if step <= 0 || end == start {
return start
}
bestJD := start
bestVal := fn(start)
for jd := start + step; jd < end; jd += step {
candidateVal := fn(jd)
if candidateVal > bestVal {
bestVal = candidateVal
bestJD = jd
}
}
endVal := fn(end)
if endVal > bestVal {
return end
}
return bestJD
}
func boundedEventZeroRefine(seed, start, end, halfWindow, step float64, fn func(float64) float64) float64 {
if end < start {
start, end = end, start
}
if end <= start {
return start
}
maxHalfWindow := (end - start) / 2
if halfWindow > maxHalfWindow {
halfWindow = maxHalfWindow
}
if halfWindow <= 0 {
return clampFloat64(seed, start, end)
}
seed = clampFloat64(seed, start+halfWindow, end-halfWindow)
return eventZeroRefine(seed, halfWindow, step, fn)
}
func zeroEventInWindow(start, end, coarseStep, halfWindow, refineStep float64, coarseFn, exactFn func(float64) float64) float64 {
if end < start {
start, end = end, start
}
if end <= start {
return start
}
rangeDays := end - start
if coarseStep <= 0 || coarseStep > rangeDays {
coarseStep = rangeDays / 6.0
}
if coarseStep < 0.5 {
coarseStep = 0.5
}
if refineStep <= 0 {
refineStep = 0.5 / 86400.0
}
if halfWindow <= 0 {
halfWindow = coarseStep
}
guess := scanWindowForMinAbs(start, end, coarseStep, coarseFn)
return boundedEventZeroRefine(guess, start, end, halfWindow, refineStep, exactFn)
}
func maximizeInWindow(start, end, coarseStep float64, coarseFn, exactFn func(float64) float64) float64 {
if end < start {
start, end = end, start
}
if end <= start {
return start
}
rangeDays := end - start
if coarseStep <= 0 || coarseStep > rangeDays {
coarseStep = rangeDays / 6.0
}
if coarseStep < 0.5 {
coarseStep = 0.5
}
guess := scanWindowForMax(start, end, coarseStep, coarseFn)
left := clampFloat64(guess-coarseStep, start, end)
right := clampFloat64(guess+coarseStep, start, end)
if right-left <= innerEventMaximizeEpsilon {
return guess
}
// 注意:这里必须全部用全项函数迭代。粗阶段(截断函数)虽然更便宜,但截断峰位与全项峰位
// 相差分钟量级,粗段省下的求值次数会被精段为达到同等精度而多花掉,因此不做两段式拆分。
for i := 0; i < 20; i++ {
third := (right - left) / 3.0
leftThird := left + third
rightThird := right - third
if exactFn(leftThird) <= exactFn(rightThird) {
left = leftThird
continue
}
right = rightThird
}
bestJD := guess
bestVal := exactFn(bestJD)
for _, jd := range []float64{left, (left + right) / 2.0, right} {
candidateVal := exactFn(jd)
if candidateVal > bestVal {
bestVal = candidateVal
bestJD = jd
}
}
return bestJD
}
+105
View File
@@ -0,0 +1,105 @@
package basic
import (
"testing"
"time"
)
func TestInnerPlanetExactEventBoundaryIncludesCurrent(t *testing.T) {
cases := []struct {
name string
seed float64
lastFn func(float64) float64
nextFn func(float64) float64
}{
{name: "MercuryConjunction", seed: NextMercuryConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryConjunction, nextFn: NextMercuryConjunction},
{name: "MercuryInferior", seed: NextMercuryInferiorConjunctionInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryInferiorConjunctionInclusive, nextFn: NextMercuryInferiorConjunctionInclusive},
{name: "MercurySuperior", seed: NextMercurySuperiorConjunctionInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercurySuperiorConjunctionInclusive, nextFn: NextMercurySuperiorConjunctionInclusive},
{name: "MercuryRetrograde", seed: NextMercuryRetrogradeInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryRetrogradeInclusive, nextFn: NextMercuryRetrogradeInclusive},
{name: "MercuryP2R", seed: NextMercuryProgradeToRetrogradeInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryProgradeToRetrogradeInclusive, nextFn: NextMercuryProgradeToRetrogradeInclusive},
{name: "MercuryR2P", seed: NextMercuryRetrogradeToProgradeInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryRetrogradeToProgradeInclusive, nextFn: NextMercuryRetrogradeToProgradeInclusive},
{name: "MercuryGreatestElongation", seed: NextMercuryGreatestElongationInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryGreatestElongationInclusive, nextFn: NextMercuryGreatestElongationInclusive},
{name: "MercuryEastElongation", seed: NextMercuryGreatestElongationEastInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryGreatestElongationEastInclusive, nextFn: NextMercuryGreatestElongationEastInclusive},
{name: "MercuryWestElongation", seed: NextMercuryGreatestElongationWestInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastMercuryGreatestElongationWestInclusive, nextFn: NextMercuryGreatestElongationWestInclusive},
{name: "VenusConjunction", seed: NextVenusConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusConjunction, nextFn: NextVenusConjunction},
{name: "VenusInferior", seed: NextVenusInferiorConjunctionInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusInferiorConjunctionInclusive, nextFn: NextVenusInferiorConjunctionInclusive},
{name: "VenusSuperior", seed: NextVenusSuperiorConjunctionInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusSuperiorConjunctionInclusive, nextFn: NextVenusSuperiorConjunctionInclusive},
{name: "VenusRetrograde", seed: NextVenusRetrogradeInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusRetrogradeInclusive, nextFn: NextVenusRetrogradeInclusive},
{name: "VenusP2R", seed: NextVenusProgradeToRetrogradeInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusProgradeToRetrogradeInclusive, nextFn: NextVenusProgradeToRetrogradeInclusive},
{name: "VenusR2P", seed: NextVenusRetrogradeToProgradeInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusRetrogradeToProgradeInclusive, nextFn: NextVenusRetrogradeToProgradeInclusive},
{name: "VenusGreatestElongation", seed: NextVenusGreatestElongationInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusGreatestElongationInclusive, nextFn: NextVenusGreatestElongationInclusive},
{name: "VenusEastElongation", seed: NextVenusGreatestElongationEastInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusGreatestElongationEastInclusive, nextFn: NextVenusGreatestElongationEastInclusive},
{name: "VenusWestElongation", seed: NextVenusGreatestElongationWestInclusive(ttjdUTC(2026, 1, 1, 0, 0, 0)), lastFn: LastVenusGreatestElongationWestInclusive, nextFn: NextVenusGreatestElongationWestInclusive},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
queryTT := UTC2TT(tc.seed)
last := tc.lastFn(queryTT)
next := tc.nextFn(queryTT)
if !sameEventJD(last, tc.seed) {
t.Fatalf("last exact boundary mismatch: got %.12f want %.12f", last, tc.seed)
}
if !sameEventJD(next, tc.seed) {
t.Fatalf("next exact boundary mismatch: got %.12f want %.12f", next, tc.seed)
}
})
}
}
func TestInnerPlanetNextEventAdvancesPastReturnedEvent(t *testing.T) {
cases := []struct {
name string
seed float64
next func(float64) float64
}{
{name: "MercuryConjunction", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextMercuryConjunction},
{name: "MercuryInferior", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextMercuryInferiorConjunction},
{name: "MercuryP2R", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextMercuryProgradeToRetrograde},
{name: "MercuryEastElongation", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextMercuryGreatestElongationEast},
{name: "VenusConjunction", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextVenusConjunction},
{name: "VenusWestElongation", seed: ttjdUTC(2026, 5, 1, 0, 0, 0), next: NextVenusGreatestElongationWest},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
first := tc.next(tc.seed)
query := UTC2TT(Date2JD(JD2DateByZone(first, time.UTC, false).Add(time.Second)))
next := tc.next(query)
if !eventUTQueryAfterOrEqual(next, query) {
t.Fatalf("next should be after query: first=%.12f query=%.12f next=%.12f", first, query, next)
}
if sameEventJD(next, first) {
t.Fatalf("next should advance past first event: first=%.12f next=%.12f", first, next)
}
})
}
}
func TestInnerPlanetTypedConjunctionExactBoundaryIncludesCurrent(t *testing.T) {
cases := []struct {
name string
seed float64
next func(float64) float64
last func(float64) float64
}{
{name: "MercuryInferior", seed: NextMercuryInferiorConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), next: NextMercuryInferiorConjunction, last: LastMercuryInferiorConjunction},
{name: "MercurySuperior", seed: NextMercurySuperiorConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), next: NextMercurySuperiorConjunction, last: LastMercurySuperiorConjunction},
{name: "VenusInferior", seed: NextVenusInferiorConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), next: NextVenusInferiorConjunction, last: LastVenusInferiorConjunction},
{name: "VenusSuperior", seed: NextVenusSuperiorConjunction(ttjdUTC(2026, 1, 1, 0, 0, 0)), next: NextVenusSuperiorConjunction, last: LastVenusSuperiorConjunction},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
queryTT := UTC2TT(tc.seed)
last := tc.last(queryTT)
next := tc.next(queryTT)
if !sameEventJD(last, tc.seed) {
t.Fatalf("last exact boundary mismatch: got %.12f want %.12f", last, tc.seed)
}
if !sameEventJD(next, tc.seed) {
t.Fatalf("next exact boundary mismatch: got %.12f want %.12f", next, tc.seed)
}
})
}
}
+177
View File
@@ -0,0 +1,177 @@
package basic
import (
"encoding/json"
"os"
"strings"
"testing"
"time"
)
type innerBaselineFile struct {
Events []innerBaselineEvent `json:"events"`
}
type innerBaselineEvent struct {
Planet string `json:"planet"`
Kind string `json:"kind"`
NAOJHintJST string `json:"naoj_hint_jst"`
Precision string `json:"precision"`
CandidateJST string `json:"candidate_jst"`
VerifiedJST string `json:"verified_jst"`
CandidateSource string `json:"candidate_source"`
}
func loadInnerBaseline(t *testing.T) innerBaselineFile {
t.Helper()
paths := [][]string{
{
"testdata/jpl_inner_event_baseline.json",
"basic/testdata/jpl_inner_event_baseline.json",
},
{
"testdata/jpl_inner_event_baseline_21c_sample.json",
"basic/testdata/jpl_inner_event_baseline_21c_sample.json",
},
{
"testdata/jpl_inner_event_baseline_20c_sample.json",
"basic/testdata/jpl_inner_event_baseline_20c_sample.json",
},
{
"testdata/jpl_inner_event_baseline_22c_sample.json",
"basic/testdata/jpl_inner_event_baseline_22c_sample.json",
},
}
var merged innerBaselineFile
for index, candidates := range paths {
var (
data []byte
err error
)
for _, path := range candidates {
data, err = os.ReadFile(path)
if err == nil {
var baseline innerBaselineFile
if err := json.Unmarshal(data, &baseline); err != nil {
t.Fatal(err)
}
merged.Events = append(merged.Events, baseline.Events...)
break
}
}
if err != nil && index == 0 {
t.Fatal(err)
}
}
if len(merged.Events) == 0 {
t.Fatal("empty inner baseline file")
}
return merged
}
func parseInnerBaselineTime(t *testing.T, value string) time.Time {
t.Helper()
loc := time.FixedZone("JST", 9*3600)
layouts := []string{
"2006-01-02 15:04:05 MST",
"2006-01-02 15:04 MST",
"2006-01-02 15:04:05",
"2006-01-02 15:04",
}
var err error
for _, layout := range layouts {
when, parseErr := time.ParseInLocation(layout, value, loc)
if parseErr == nil {
return when
}
err = parseErr
}
t.Fatalf("parse baseline time %q: %v", value, err)
return time.Time{}
}
func innerBaselineTolerance(event innerBaselineEvent) time.Duration {
switch event.Kind {
case "IC", "SC":
// 2197 年附近双方 ΔT 外推已差约 2 分钟(校正了 EarthPI 的 0.00046·T² 项之后,
// 三个 2197 样本从 −119.5 s 移到 −121…−126 s),因此与留点同样留 3 分钟余量;
// 真实的搜索错误是小时/天级,靠这个容差仍能抓住。
// Around 2197 the two ΔT extrapolations already differ by about two minutes (after
// correcting the EarthPI 0.00046*T^2 term, three 2197 samples moved from -119.5 s to
// -121..-126 s), so conjunctions get the same 3-minute margin as the stations.
// Real search errors are hours or days, so this still catches them.
return 3 * time.Minute
case "P2R", "R2P":
// 留是 RA 的极值点:星历模型中 ~1e-6 度/天的变化率差异就会被放大成分钟级时刻差,
// 且 JPL 给出的 UT 时刻还叠加了各自的 ΔT 外推(2197 年附近两者已差 ~2 分钟)。
// 这里留 3 分钟余量,仍远小于任何真实的搜索错误(小时/天级)。
return 3 * time.Minute
case "GEE", "GEW":
return 90 * time.Minute
default:
return 2 * time.Minute
}
}
func innerEventFuncs(t *testing.T, event innerBaselineEvent) (func(float64) float64, func(float64) float64) {
t.Helper()
switch event.Planet + ":" + event.Kind {
case "Mercury:IC":
return LastMercuryInferiorConjunctionInclusive, NextMercuryInferiorConjunctionInclusive
case "Mercury:SC":
return LastMercurySuperiorConjunctionInclusive, NextMercurySuperiorConjunctionInclusive
case "Mercury:P2R":
return LastMercuryProgradeToRetrogradeInclusive, NextMercuryProgradeToRetrogradeInclusive
case "Mercury:R2P":
return LastMercuryRetrogradeToProgradeInclusive, NextMercuryRetrogradeToProgradeInclusive
case "Mercury:GEE":
return LastMercuryGreatestElongationEastInclusive, NextMercuryGreatestElongationEastInclusive
case "Mercury:GEW":
return LastMercuryGreatestElongationWestInclusive, NextMercuryGreatestElongationWestInclusive
case "Venus:IC":
return LastVenusInferiorConjunctionInclusive, NextVenusInferiorConjunctionInclusive
case "Venus:SC":
return LastVenusSuperiorConjunctionInclusive, NextVenusSuperiorConjunctionInclusive
case "Venus:P2R":
return LastVenusProgradeToRetrogradeInclusive, NextVenusProgradeToRetrogradeInclusive
case "Venus:R2P":
return LastVenusRetrogradeToProgradeInclusive, NextVenusRetrogradeToProgradeInclusive
case "Venus:GEE":
return LastVenusGreatestElongationEastInclusive, NextVenusGreatestElongationEastInclusive
case "Venus:GEW":
return LastVenusGreatestElongationWestInclusive, NextVenusGreatestElongationWestInclusive
default:
t.Fatalf("unsupported event %s:%s", event.Planet, event.Kind)
return nil, nil
}
}
func assertInnerBaselineEvent(t *testing.T, event innerBaselineEvent, lastFn, nextFn func(float64) float64) {
t.Helper()
when := parseInnerBaselineTime(t, event.VerifiedJST)
before := when.Add(-24 * time.Hour)
after := when.Add(24 * time.Hour)
next := JD2DateByZone(nextFn(toUTJD(before)), when.Location(), false)
last := JD2DateByZone(lastFn(toUTJD(after)), when.Location(), false)
tolerance := innerBaselineTolerance(event)
if diff := next.Sub(when); diff < -tolerance || diff > tolerance {
t.Fatalf("%s %s next mismatch: got %s want %s tol=%s hint=%s candidate=%s via=%s", event.Planet, event.Kind, next, when, tolerance, event.NAOJHintJST, event.CandidateJST, event.CandidateSource)
}
if diff := last.Sub(when); diff < -tolerance || diff > tolerance {
t.Fatalf("%s %s last mismatch: got %s want %s tol=%s hint=%s candidate=%s via=%s", event.Planet, event.Kind, last, when, tolerance, event.NAOJHintJST, event.CandidateJST, event.CandidateSource)
}
}
func TestInnerPlanetTruthAgainstJPL(t *testing.T) {
baseline := loadInnerBaseline(t)
for _, event := range baseline.Events {
event := event
name := strings.Join([]string{event.Planet, event.Kind, event.VerifiedJST}, "_")
t.Run(name, func(t *testing.T) {
lastFn, nextFn := innerEventFuncs(t, event)
assertInnerBaselineEvent(t, event, lastFn, nextFn)
})
}
}
+25 -21
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)
}
}
}
}
}
+91 -139
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))
@@ -87,143 +87,95 @@ func JupiterApparentRaDec(jd float64) (float64, float64) {
}
func EarthJupiterAway(jd float64) float64 {
x, y, z := AJupiterXYZ(jd)
to := math.Sqrt(x*x + y*y + z*z)
return to
return planetEarthAwayExplicitN(4, jd, -1)
}
func JupiterApparentLo(jd float64) float64 {
x, y, z := AJupiterXYZ(jd)
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
x, y, z = AJupiterXYZ(jd - to)
lo := math.Atan2(y, x)
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
lo = lo * 180 / math.Pi
bo = bo * 180 / math.Pi
lo = Limit360(lo)
//lo-=GXCLo(lo,bo,jd)/3600;
//bo+=GXCBo(lo,bo,jd);
lo += Nutation2000Bi(jd)
return lo
geo, _ := planetApparentGeocentricPositionN(4, jd, -1)
return geo.lo
}
func JupiterApparentBo(jd float64) float64 {
x, y, z := AJupiterXYZ(jd)
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
x, y, z = AJupiterXYZ(jd - to)
//lo := math.Atan2(y, x)
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
//lo = lo * 180 / math.Pi
bo = bo * 180 / math.Pi
//lo+=GXCLo(lo,bo,jd);
//bo+=GXCBo(lo,bo,jd)/3600;
//lo+=Nutation2000Bi(jd);
return bo
geo, _ := planetApparentGeocentricPositionN(4, jd, -1)
return geo.bo
}
func JupiterApparentLoBo(jd float64) (float64, float64) {
x, y, z := AJupiterXYZ(jd)
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
x, y, z = AJupiterXYZ(jd - to)
lo := math.Atan2(y, x)
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
lo = lo * 180 / math.Pi
bo = bo * 180 / math.Pi
lo = Limit360(lo)
//lo-=GXCLo(lo,bo,jd)/3600;
//bo+=GXCBo(lo,bo,jd);
lo += Nutation2000Bi(jd)
return lo, bo
geo, _ := planetApparentGeocentricPositionN(4, jd, -1)
return geo.lo, geo.bo
}
func JupiterMag(jd float64) float64 {
sunDistance := JupiterR(jd)
earthDistance := EarthJupiterAway(jd)
earthSunDistance := planet.WherePlanet(-1, 2, jd)
func JupiterMag(jde float64) float64 {
sunDistance := JupiterR(jde)
earthDistance := EarthJupiterAway(jde)
earthSunDistance := planet.WherePlanet(-1, 2, jde)
i := (sunDistance*sunDistance + earthDistance*earthDistance - earthSunDistance*earthSunDistance) / (2 * sunDistance * earthDistance)
i = ArcCos(i)
mag := -9.40 + 5*math.Log10(sunDistance*earthDistance) + 0.0005*i
return FloatRound(mag, 2)
}
func JupiterHeight(jde, lon, lat, timezone float64) float64 {
// 转换为世界时
utcJde := jde - timezone/24.0
// 计算视恒星时
ra, dec := JupiterApparentRaDec(TD2UT(utcJde, true))
st := Limit360(ApparentSiderealTime(utcJde)*15 + lon)
// 计算时角
hourAngle := Limit360(st - ra)
// 高度角、时角与天球座标三角转换公式
// sin(h)=sin(lat)*sin(dec)+cos(dec)*cos(lat)*cos(hourAngle)
sinHeight := Sin(lat)*Sin(dec) + Cos(dec)*Cos(lat)*Cos(hourAngle)
return ArcSin(sinHeight)
// JupiterHeightWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterHeightWithInstance(localJD, lon, lat, timezone float64, inst *Instance) float64 {
return planetHeightN(4, localJD, lon, lat, timezone, -1, inst)
}
func JupiterAzimuth(jde, lon, lat, timezone float64) float64 {
// 转换为世界时
utcJde := jde - timezone/24.0
// 计算视恒星时
ra, dec := JupiterApparentRaDec(TD2UT(utcJde, true))
st := Limit360(ApparentSiderealTime(utcJde)*15 + lon)
// 计算时角
hourAngle := Limit360(st - ra)
// 三角转换公式
tanAzimuth := Sin(hourAngle) / (Cos(hourAngle)*Sin(lat) - Tan(dec)*Cos(lat))
azimuth := ArcTan(tanAzimuth)
if azimuth < 0 {
if hourAngle/15 < 12 {
return azimuth + 360
}
return azimuth + 180
}
if hourAngle/15 < 12 {
return azimuth + 180
}
return azimuth
// JupiterAzimuthWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterAzimuthWithInstance(localJD, lon, lat, timezone float64, inst *Instance) float64 {
return planetAzimuthN(4, localJD, lon, lat, timezone, -1, inst)
}
func JupiterHourAngle(jd, lon, timezone float64) float64 {
siderealLongitude := Limit360(ApparentSiderealTime(jd-timezone/24)*15 + lon)
hourAngle := siderealLongitude - JupiterApparentRa(TD2UT(jd-timezone/24.0, true))
// JupiterHourAngleWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterHourAngleWithInstance(jd, lon, timezone float64, inst *Instance) float64 {
siderealLongitude := Limit360(ApparentSiderealTime(UTC2UT1(jd-timezone/24))*15 + lon)
hourAngle := siderealLongitude - JupiterApparentRaWithInstance(UTC2TT(jd-timezone/24.0), inst)
if hourAngle < 0 {
hourAngle += 360
}
return hourAngle
}
func JupiterCulminationTime(jde, lon, timezone float64) float64 {
//jde 世界时,非力学时,当地时区 0时,无需转换力学时
// JupiterCulminationTimeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterCulminationTimeWithInstance(localJD, lon, timezone float64, inst *Instance) 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-JupiterHourAngleWithInstance(localJD, lon, timezone, inst))/15.0/24.0*0.99726851851851851851
normalizedHourAngle := func(localJD, lon, timezone float64) float64 {
currentHourAngle := JupiterHourAngleWithInstance(localJD, lon, timezone, inst)
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
}
func JupiterRiseTime(jd, lon, lat, timezone, aeroCorrection, observerHeight float64) (float64, error) {
return jupiterRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, true)
// JupiterRiseTimeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterRiseTimeWithInstance(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, inst *Instance) (float64, error) {
return jupiterRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, true, inst)
}
func JupiterSetTime(jd, lon, lat, timezone, aeroCorrection, observerHeight float64) (float64, error) {
return jupiterRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, false)
// JupiterSetTimeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterSetTimeWithInstance(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, inst *Instance) (float64, error) {
return jupiterRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, false, inst)
}
func jupiterRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, isRise bool) (float64, error) {
return planetRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, isRise, JupiterCulminationTime, JupiterHeight, JupiterApparentDec)
func jupiterRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, isRise bool, instances ...*Instance) (float64, error) {
inst := instanceValue(instances)
return planetRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, isRise, func(localJD, lon, timezone float64) float64 {
return JupiterCulminationTimeWithInstance(localJD, lon, timezone, inst)
}, func(localJD, lon, lat, timezone float64) float64 {
return JupiterHeightWithInstance(localJD, lon, lat, timezone, inst)
}, func(jde float64) float64 { return JupiterApparentDecWithInstance(jde, inst) })
}
+243 -124
View File
@@ -14,8 +14,10 @@ const (
jupiterPhaseCoarseTolerance = 30.0 / 86400.0
)
func jupiterSunLongitudeDelta(jde, degree float64, filter bool) float64 {
sub := Limit360(Limit360(JupiterApparentLo(jde)-HSunApparentLo(jde)) - degree)
func jupiterSunLongitudeDelta(jde, degree float64, filter bool, instances ...*Instance) float64 {
inst := instanceValue(instances)
sub := Limit360(Limit360(JupiterApparentLoWithInstance(jde, inst)-HSunApparentLoWithInstance(jde, inst)) - degree)
if filter {
if sub > 180 {
sub -= 360
@@ -27,8 +29,10 @@ func jupiterSunLongitudeDelta(jde, degree float64, filter bool) float64 {
return sub
}
func jupiterSunLongitudeDeltaN(jde, degree float64, filter bool, n int) float64 {
sub := Limit360(Limit360(JupiterApparentLoN(jde, n)-HSunApparentLoN(jde, n)) - degree)
func jupiterSunLongitudeDeltaN(jde, degree float64, filter bool, n int, instances ...*Instance) float64 {
inst := instanceValue(instances)
sub := Limit360(Limit360(JupiterApparentLoNWithInstance(jde, n, inst)-HSunApparentLoNWithInstance(jde, n, inst)) - degree)
if filter {
if sub > 180 {
sub -= 360
@@ -40,167 +44,282 @@ func jupiterSunLongitudeDeltaN(jde, degree float64, filter bool, n int) float64
return sub
}
func jupiterConjunctionFull(jde, degree float64, next uint8) float64 {
func jupiterRADerivative(jde, delta float64, instances ...*Instance) float64 {
inst := instanceValue(instances)
sub := JupiterApparentRaWithInstance(jde+delta, inst) - JupiterApparentRaWithInstance(jde-delta, inst)
if sub > 180 {
sub -= 360
}
if sub < -180 {
sub += 360
}
return sub / (2 * delta)
}
func jupiterRADerivativeN(jde, delta float64, n int, instances ...*Instance) float64 {
inst := instanceValue(instances)
sub := JupiterApparentRaNWithInstance(jde+delta, n, inst) - JupiterApparentRaNWithInstance(jde-delta, n, inst)
if sub > 180 {
sub -= 360
}
if sub < -180 {
sub += 360
}
return sub / (2 * delta)
}
func jupiterConjunctionFull(jde, degree float64, next uint8, instances ...*Instance) float64 {
inst := instanceValue(instances)
//0=last 1=next
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
return math.NaN()
}
daysPerDegree := JUPITER_S_PERIOD / 360
currentDelta := jupiterSunLongitudeDelta(jde, degree, false)
currentDelta := jupiterSunLongitudeDelta(jde, degree, false, inst)
if next == 0 {
jde -= (360 - currentDelta) * daysPerDegree
} 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, inst)
longitudeSlope := (jupiterSunLongitudeDelta(prevJDE+0.000005, degree, true, inst) - jupiterSunLongitudeDelta(prevJDE-0.000005, degree, true, inst)) / 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 {
func jupiterConjunction(jde, degree float64, next uint8, instances ...*Instance) float64 {
inst := instanceValue(instances)
//0=last 1=next
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
return math.NaN()
}
daysPerDegree := JUPITER_S_PERIOD / 360
currentDelta := jupiterSunLongitudeDelta(jde, degree, false)
currentDelta := jupiterSunLongitudeDelta(jde, degree, false, inst)
if next == 0 {
jde -= (360 - currentDelta) * daysPerDegree
} 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, inst)
longitudeSlope := (jupiterSunLongitudeDeltaN(prevJDE+0.000005, degree, true, jupiterEventSearchN, inst) - jupiterSunLongitudeDeltaN(prevJDE-0.000005, degree, true, jupiterEventSearchN, inst)) / 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, inst)
longitudeSlope := (jupiterSunLongitudeDelta(prevJDE+0.000005, degree, true, inst) - jupiterSunLongitudeDelta(prevJDE-0.000005, degree, true, inst)) / 0.00001
nextJD := prevJDE - longitudeDelta/longitudeSlope
estimateJDE = nextJD
if math.Abs(nextJD-prevJDE) <= 0.00001 {
converged = true
break
}
}
return TD2UT(estimateJD, false)
}
func LastJupiterConjunction(jde float64) float64 {
return jupiterConjunction(jde, 0, 0)
}
func NextJupiterConjunction(jde float64) float64 {
return jupiterConjunction(jde, 0, 1)
}
func LastJupiterOpposition(jde float64) float64 {
return jupiterConjunction(jde, 180, 0)
}
func NextJupiterOpposition(jde float64) float64 {
return jupiterConjunction(jde, 180, 1)
}
func NextJupiterEasternQuadrature(jde float64) float64 {
return jupiterConjunction(jde, 90, 1)
}
func LastJupiterEasternQuadrature(jde float64) float64 {
return jupiterConjunction(jde, 90, 0)
}
func NextJupiterWesternQuadrature(jde float64) float64 {
return jupiterConjunction(jde, 270, 1)
}
func LastJupiterWesternQuadrature(jde float64) float64 {
return jupiterConjunction(jde, 270, 0)
}
func jupiterRetrograde(jde float64, searchBeforeOpposition bool) float64 {
//0=last 1=next
raRate := func(jde float64, delta float64) float64 {
sub := JupiterApparentRa(jde+delta) - JupiterApparentRa(jde-delta)
if sub > 180 {
sub -= 360
}
if sub < -180 {
sub += 360
}
return sub / (2 * delta)
if !converged {
return math.NaN()
}
jde = jupiterConjunctionFull(jde, 180, 1)
if searchBeforeOpposition {
jde -= 60
} else {
jde += 60
}
for {
currentRate := raRate(jde, 1.0/86400.0)
if math.Abs(currentRate) > 0.55 {
jde += 2
continue
}
break
}
estimateJD := jde
for {
prevJD := estimateJD
rateValue := raRate(prevJD, 2.0/86400.0)
rateSlope := (raRate(prevJD+15.0/86400.0, 2.0/86400.0) - raRate(prevJD-15.0/86400.0, 2.0/86400.0)) / (30.0 / 86400.0)
estimateJD = prevJD - rateValue/rateSlope
if math.Abs(estimateJD-prevJD) <= 30.0/86400.0 {
break
}
}
bestJD := eventZeroRefine(estimateJD, 15.0/86400.0, 0.5/86400.0, func(jd float64) float64 {
return raRate(jd, 0.5/86400.0)
return TT2UTC(estimateJDE)
}
// LastJupiterConjunctionWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastJupiterConjunctionWithInstance(jde float64, inst *Instance) float64 {
return inclusiveLastPhaseEvent(jde, 0, func(jde, degree float64, next uint8) float64 {
return jupiterConjunction(jde, degree, next, inst)
})
return TD2UT(bestJD, false)
}
func NextJupiterRetrogradeToPrograde(jde float64) float64 {
date := jupiterRetrograde(jde, false)
if date < jde {
oppositionJD := jupiterConjunctionFull(jde, 180, 1)
return jupiterRetrograde(oppositionJD+10, false)
// NextJupiterConjunctionWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextJupiterConjunctionWithInstance(jde float64, inst *Instance) float64 {
return inclusiveNextPhaseEvent(jde, 0, func(jde, degree float64, next uint8) float64 {
return jupiterConjunction(jde, degree, next, inst)
})
}
// LastJupiterOppositionWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastJupiterOppositionWithInstance(jde float64, inst *Instance) float64 {
return inclusiveLastPhaseEvent(jde, 180, func(jde, degree float64, next uint8) float64 {
return jupiterConjunction(jde, degree, next, inst)
})
}
// NextJupiterOppositionWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextJupiterOppositionWithInstance(jde float64, inst *Instance) float64 {
return inclusiveNextPhaseEvent(jde, 180, func(jde, degree float64, next uint8) float64 {
return jupiterConjunction(jde, degree, next, inst)
})
}
// NextJupiterEasternQuadratureWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextJupiterEasternQuadratureWithInstance(jde float64, inst *Instance) float64 {
return inclusiveNextPhaseEvent(jde, 90, func(jde, degree float64, next uint8) float64 {
return jupiterConjunction(jde, degree, next, inst)
})
}
// LastJupiterEasternQuadratureWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastJupiterEasternQuadratureWithInstance(jde float64, inst *Instance) float64 {
return inclusiveLastPhaseEvent(jde, 90, func(jde, degree float64, next uint8) float64 {
return jupiterConjunction(jde, degree, next, inst)
})
}
// NextJupiterWesternQuadratureWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextJupiterWesternQuadratureWithInstance(jde float64, inst *Instance) float64 {
return inclusiveNextPhaseEvent(jde, 270, func(jde, degree float64, next uint8) float64 {
return jupiterConjunction(jde, degree, next, inst)
})
}
// LastJupiterWesternQuadratureWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastJupiterWesternQuadratureWithInstance(jde float64, inst *Instance) float64 {
return inclusiveLastPhaseEvent(jde, 270, func(jde, degree float64, next uint8) float64 {
return jupiterConjunction(jde, degree, next, inst)
})
}
func jupiterRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool, instances ...*Instance) float64 {
inst := instanceValue(instances)
if !isFiniteFloat(oppositionJD) {
return math.NaN()
}
oppositionTT := UTC2TT(oppositionJD)
startTT := oppositionTT
endTT := oppositionTT
if searchBeforeOpposition {
easternQuadratureUT := jupiterConjunction(oppositionTT, 90, 0, inst)
startTT = UTC2TT(easternQuadratureUT)
} else {
westernQuadratureUT := jupiterConjunction(oppositionTT, 270, 1, inst)
endTT = UTC2TT(westernQuadratureUT)
}
bestJDE := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 {
return jupiterRADerivativeN(jd, stationDerivativeStepDay, jupiterEventSearchN, inst)
}, func(jd float64) float64 {
return jupiterRADerivative(jd, stationDerivativeStepDay, inst)
})
return TT2UTC(bestJDE)
}
// NextJupiterRetrogradeToProgradeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextJupiterRetrogradeToProgradeWithInstance(jde float64, inst *Instance) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
lastOppositionJD := jupiterConjunctionFull(jde, 180, 0, inst)
date := jupiterRetrogradeAroundOpposition(lastOppositionJD, false, inst)
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
return date
}
nextOppositionJD := jupiterConjunctionFull(jde, 180, 1, inst)
if !isFiniteFloat(nextOppositionJD) {
return math.NaN()
}
date = jupiterRetrogradeAroundOpposition(nextOppositionJD, false, inst)
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
return math.NaN()
}
return date
}
func LastJupiterRetrogradeToPrograde(jde float64) float64 {
jde = jupiterConjunctionFull(jde, 180, 0) - 10
date := jupiterRetrograde(jde, false)
if date > jde {
oppositionJD := jupiterConjunctionFull(jde, 180, 0)
return jupiterRetrograde(oppositionJD-10, false)
// LastJupiterRetrogradeToProgradeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastJupiterRetrogradeToProgradeWithInstance(jde float64, inst *Instance) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
lastOppositionJD := jupiterConjunctionFull(jde, 180, 0, inst)
date := jupiterRetrogradeAroundOpposition(lastOppositionJD, false, inst)
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
return date
}
if !isFiniteFloat(lastOppositionJD) {
return math.NaN()
}
previousOppositionJD := jupiterConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0, inst)
if !isFiniteFloat(previousOppositionJD) {
return math.NaN()
}
date = jupiterRetrogradeAroundOpposition(previousOppositionJD, false, inst)
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
return math.NaN()
}
return date
}
func NextJupiterProgradeToRetrograde(jde float64) float64 {
date := jupiterRetrograde(jde, true)
if date < jde {
oppositionJD := jupiterConjunctionFull(jde, 180, 1)
return jupiterRetrograde(oppositionJD+10, true)
// NextJupiterProgradeToRetrogradeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextJupiterProgradeToRetrogradeWithInstance(jde float64, inst *Instance) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
nextOppositionJD := jupiterConjunctionFull(jde, 180, 1, inst)
date := jupiterRetrogradeAroundOpposition(nextOppositionJD, true, inst)
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
return date
}
if !isFiniteFloat(nextOppositionJD) {
return math.NaN()
}
followingOppositionJD := jupiterConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1, inst)
if !isFiniteFloat(followingOppositionJD) {
return math.NaN()
}
date = jupiterRetrogradeAroundOpposition(followingOppositionJD, true, inst)
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
return math.NaN()
}
return date
}
func LastJupiterProgradeToRetrograde(jde float64) float64 {
jde = jupiterConjunctionFull(jde, 180, 0) - 10
date := jupiterRetrograde(jde, true)
if date > jde {
oppositionJD := jupiterConjunctionFull(jde, 180, 0)
return jupiterRetrograde(oppositionJD-10, true)
// LastJupiterProgradeToRetrogradeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastJupiterProgradeToRetrogradeWithInstance(jde float64, inst *Instance) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
nextOppositionJD := jupiterConjunctionFull(jde, 180, 1, inst)
date := jupiterRetrogradeAroundOpposition(nextOppositionJD, true, inst)
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
return date
}
if !isFiniteFloat(nextOppositionJD) {
return math.NaN()
}
lastOppositionJD := jupiterConjunctionFull(jde, 180, 0, inst)
if !isFiniteFloat(lastOppositionJD) {
return math.NaN()
}
date = jupiterRetrogradeAroundOpposition(lastOppositionJD, true, inst)
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
return math.NaN()
}
return date
}
+22 -21
View File
@@ -3,7 +3,6 @@ package basic
import (
"math"
"b612.me/astro/planet"
. "b612.me/astro/tools"
)
@@ -25,14 +24,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)
// JupiterCentralMeridiansNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterCentralMeridiansNWithInstance(jde float64, n int, inst *Instance) JupiterCentralMeridianInfo {
observations := jupiterPhysicalObservationsN(jde, n, inst)
physical := JupiterPhysicalNWithInstance(jde, n, inst)
return JupiterCentralMeridianInfo{
SystemI: observations.SystemI,
SystemII: observations.SystemII,
@@ -41,18 +40,20 @@ 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)
// JupiterDSDENWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterDSDENWithInstance(jde float64, n int, inst *Instance) (ds, de float64) {
observations := jupiterPhysicalObservationsN(jde, n, inst)
return observations.DS, observations.DE
}
func jupiterPhysicalObservationsN(jd float64, n int) jupiterPhysicalObservationInfo {
days := jd - 2433282.5
func jupiterPhysicalObservationsN(jde float64, n int, instances ...*Instance) jupiterPhysicalObservationInfo {
inst := instanceValue(instances)
days := jde - 2433282.5
julianCentury := days / 36525.0
poleRA := (268.0 + 0.1061*julianCentury) * rad
@@ -60,9 +61,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 := PlanetCoordinateNWithInstance(-1, 0, jde, n, inst)
earthLat := PlanetCoordinateNWithInstance(-1, 1, jde, n, inst)
earthRadius := PlanetCoordinateNWithInstance(-1, 2, jde, n, inst)
delta := 4.0
var jupiterLon float64
@@ -73,16 +74,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 = PlanetCoordinateNWithInstance(4, 0, jde-lightTimeDays, n, inst)
jupiterLat = PlanetCoordinateNWithInstance(4, 1, jde-lightTimeDays, n, inst)
jupiterRadius = PlanetCoordinateNWithInstance(4, 2, jde-lightTimeDays, n, inst)
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)
+6 -3
View File
@@ -6,9 +6,12 @@ import (
"os"
"testing"
"time"
"b612.me/astro/internal/ephemtest"
)
func TestJupiterCentralMeridiansMeeusExample43A(t *testing.T) {
ephemtest.PinLegacy(t)
got := JupiterCentralMeridians(2448972.50068)
ds, de := JupiterDSDE(2448972.50068)
@@ -37,14 +40,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 +79,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)
+51 -70
View File
@@ -1,6 +1,8 @@
package basic
import "math"
import (
"math"
)
const (
jupiterGalileanContactBracketStepDays = 2.0 / 1440.0
@@ -59,28 +61,30 @@ type jupiterGalileanContactGeometry struct {
effectiveRadius float64
}
// LastJupiterGalileanPhenomenonContactEvent 上一次 IMCCE 风格接触事件 / previous IMCCE-style contact event.
func LastJupiterGalileanPhenomenonContactEvent(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonContactEvent {
// LastJupiterGalileanPhenomenonContactEventWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastJupiterGalileanPhenomenonContactEventWithInstance(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType, inst *Instance) JupiterGalileanPhenomenonContactEvent {
return jupiterGalileanPhenomenonContactEventFromEvent(
LastJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType),
LastJupiterGalileanPhenomenonEventWithInstance(jd, satellite, phenomenonType, inst), inst,
)
}
// NextJupiterGalileanPhenomenonContactEvent 下一次 IMCCE 风格接触事件 / next IMCCE-style contact event.
func NextJupiterGalileanPhenomenonContactEvent(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonContactEvent {
// NextJupiterGalileanPhenomenonContactEventWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextJupiterGalileanPhenomenonContactEventWithInstance(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType, inst *Instance) JupiterGalileanPhenomenonContactEvent {
return jupiterGalileanPhenomenonContactEventFromEvent(
NextJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType),
NextJupiterGalileanPhenomenonEventWithInstance(jd, satellite, phenomenonType, inst), inst,
)
}
// ClosestJupiterGalileanPhenomenonContactEvent 最近一次 IMCCE 风格接触事件 / closest IMCCE-style contact event.
func ClosestJupiterGalileanPhenomenonContactEvent(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonContactEvent {
// ClosestJupiterGalileanPhenomenonContactEventWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func ClosestJupiterGalileanPhenomenonContactEventWithInstance(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType, inst *Instance) JupiterGalileanPhenomenonContactEvent {
return jupiterGalileanPhenomenonContactEventFromEvent(
ClosestJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType),
ClosestJupiterGalileanPhenomenonEventWithInstance(jd, satellite, phenomenonType, inst), inst,
)
}
func jupiterGalileanPhenomenonContactEventFromEvent(event JupiterGalileanPhenomenonEvent) JupiterGalileanPhenomenonContactEvent {
func jupiterGalileanPhenomenonContactEventFromEvent(event JupiterGalileanPhenomenonEvent, instances ...*Instance) JupiterGalileanPhenomenonContactEvent {
inst := instanceValue(instances)
if !event.Valid {
return invalidJupiterGalileanPhenomenonContactEvent()
}
@@ -90,11 +94,11 @@ func jupiterGalileanPhenomenonContactEventFromEvent(event JupiterGalileanPhenome
ok bool
)
if event.Type == JupiterGalileanShadowTransit {
disappearance, reappearance, ok = refineJupiterGalileanShadowContactPair(event)
disappearance, reappearance, ok = refineJupiterGalileanShadowContactPair(event, inst)
} else if event.Type == JupiterGalileanEclipse {
disappearance, reappearance, ok = refineJupiterGalileanEclipseContactPair(event)
disappearance, reappearance, ok = refineJupiterGalileanEclipseContactPair(event, inst)
} else {
disappearance, reappearance, ok = refineJupiterGalileanContactPair(event.Greatest, event.Satellite, event.Type)
disappearance, reappearance, ok = refineJupiterGalileanContactPair(event.Greatest, event.Satellite, event.Type, inst)
}
if !ok {
return invalidJupiterGalileanPhenomenonContactEvent()
@@ -113,24 +117,25 @@ func jupiterGalileanPhenomenonContactEventFromEvent(event JupiterGalileanPhenome
func refineJupiterGalileanContactPair(
greatestJD float64,
satellite int,
phenomenonType JupiterGalileanPhenomenonType,
) (JupiterGalileanPhenomenonContact, JupiterGalileanPhenomenonContact, bool) {
phenomenonType JupiterGalileanPhenomenonType, instances ...*Instance) (JupiterGalileanPhenomenonContact, JupiterGalileanPhenomenonContact, bool) {
inst := instanceValue(instances)
signedDistance := func(jd float64) float64 {
geometry, ok := jupiterGalileanContactGeometryAt(jd, satellite, phenomenonType)
geometry, ok := jupiterGalileanContactGeometryAt(jd, satellite, phenomenonType, inst)
if !ok {
return math.NaN()
}
return geometry.signedDistance
}
disappearanceStartTarget := func(jd float64) float64 {
geometry, ok := jupiterGalileanContactGeometryAt(jd, satellite, phenomenonType)
geometry, ok := jupiterGalileanContactGeometryAt(jd, satellite, phenomenonType, inst)
if !ok {
return math.NaN()
}
return geometry.signedDistance - geometry.effectiveRadius
}
insideTarget := func(jd float64) float64 {
geometry, ok := jupiterGalileanContactGeometryAt(jd, satellite, phenomenonType)
geometry, ok := jupiterGalileanContactGeometryAt(jd, satellite, phenomenonType, inst)
if !ok {
return math.NaN()
}
@@ -179,8 +184,9 @@ func refineJupiterGalileanContactPair(
}
func refineJupiterGalileanEclipseContactPair(
event JupiterGalileanPhenomenonEvent,
) (JupiterGalileanPhenomenonContact, JupiterGalileanPhenomenonContact, bool) {
event JupiterGalileanPhenomenonEvent, instances ...*Instance) (JupiterGalileanPhenomenonContact, JupiterGalileanPhenomenonContact, bool) {
inst := instanceValue(instances)
satelliteRadius := jupiterGalileanSatelliteRadiusJupiterRadii(event.Satellite)
if !isFinite(satelliteRadius) || satelliteRadius <= 0 {
return JupiterGalileanPhenomenonContact{}, JupiterGalileanPhenomenonContact{}, false
@@ -188,28 +194,28 @@ func refineJupiterGalileanEclipseContactPair(
// IMCCE 的 EC.D/EC.F 更接近“目视消失/重现”而不是纯几何接触:
// D 相用半影入段近似,F 相用本影/半影出段的中点近似。
penumbraOuterTarget := func(jd float64) float64 {
signedDistance, ok := jupiterGalileanEclipseSignedDistanceAt(jd, event.Satellite, true)
signedDistance, ok := jupiterGalileanEclipseSignedDistanceAt(jd, event.Satellite, true, inst)
if !ok {
return math.NaN()
}
return signedDistance - satelliteRadius
}
penumbraInnerTarget := func(jd float64) float64 {
signedDistance, ok := jupiterGalileanEclipseSignedDistanceAt(jd, event.Satellite, true)
signedDistance, ok := jupiterGalileanEclipseSignedDistanceAt(jd, event.Satellite, true, inst)
if !ok {
return math.NaN()
}
return signedDistance + satelliteRadius
}
umbraInnerTarget := func(jd float64) float64 {
signedDistance, ok := jupiterGalileanEclipseSignedDistanceAt(jd, event.Satellite, false)
signedDistance, ok := jupiterGalileanEclipseSignedDistanceAt(jd, event.Satellite, false, inst)
if !ok {
return math.NaN()
}
return signedDistance + satelliteRadius
}
umbraOuterTarget := func(jd float64) float64 {
signedDistance, ok := jupiterGalileanEclipseSignedDistanceAt(jd, event.Satellite, false)
signedDistance, ok := jupiterGalileanEclipseSignedDistanceAt(jd, event.Satellite, false, inst)
if !ok {
return math.NaN()
}
@@ -262,17 +268,18 @@ func refineJupiterGalileanEclipseContactPair(
}
func refineJupiterGalileanShadowContactPair(
event JupiterGalileanPhenomenonEvent,
) (JupiterGalileanPhenomenonContact, JupiterGalileanPhenomenonContact, bool) {
event JupiterGalileanPhenomenonEvent, instances ...*Instance) (JupiterGalileanPhenomenonContact, JupiterGalileanPhenomenonContact, bool) {
inst := instanceValue(instances)
penumbraMetric := func(jd float64) float64 {
value, ok := jupiterGalileanShadowLimbMetricAt(jd, event.Satellite, true)
value, ok := jupiterGalileanShadowLimbMetricAt(jd, event.Satellite, true, inst)
if !ok {
return math.NaN()
}
return value
}
umbraMetric := func(jd float64) float64 {
value, ok := jupiterGalileanShadowLimbMetricAt(jd, event.Satellite, false)
value, ok := jupiterGalileanShadowLimbMetricAt(jd, event.Satellite, false, inst)
if !ok {
return math.NaN()
}
@@ -336,25 +343,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,
@@ -478,13 +466,14 @@ func bisectJupiterGalileanContactRoot(
func jupiterGalileanContactGeometryAt(
jd float64,
satellite int,
phenomenonType JupiterGalileanPhenomenonType,
) (jupiterGalileanContactGeometry, bool) {
phenomenonType JupiterGalileanPhenomenonType, instances ...*Instance) (jupiterGalileanContactGeometry, bool) {
inst := instanceValue(instances)
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, inst)
if context.jupiterDistance == 0 {
return jupiterGalileanContactGeometry{}, false
}
@@ -542,12 +531,14 @@ func jupiterGalileanContactGeometryAt(
}
}
func jupiterGalileanEclipseSignedDistanceAt(jd float64, satellite int, penumbra bool) (float64, bool) {
func jupiterGalileanEclipseSignedDistanceAt(jd float64, satellite int, penumbra bool, instances ...*Instance) (float64, bool) {
inst := instanceValue(instances)
if !isFinite(jd) || satellite < 1 || satellite > 4 {
return math.NaN(), false
}
evaluationJD := TD2UT(jd, true)
context := newJupiterGalileanObservationContext(evaluationJD)
evaluationJDE := UTC2TT(jd)
context := newJupiterGalileanObservationContext(evaluationJDE, inst)
if context.jupiterDistance == 0 {
return math.NaN(), false
}
@@ -593,18 +584,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()
@@ -617,12 +596,14 @@ func jupiterGalileanPenumbraRadiusJupiterRadii(satellite int, pathLengthAU, sunD
return penumbraRadiusAU / (jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM)
}
func jupiterGalileanShadowLimbMetricAt(jd float64, satellite int, penumbra bool) (float64, bool) {
func jupiterGalileanShadowLimbMetricAt(jd float64, satellite int, penumbra bool, instances ...*Instance) (float64, bool) {
inst := instanceValue(instances)
if !isFinite(jd) || satellite < 1 || satellite > 4 {
return math.NaN(), false
}
evaluationJD := TD2UT(jd, true)
context := newJupiterGalileanObservationContext(evaluationJD)
evaluationJDE := UTC2TT(jd)
context := newJupiterGalileanObservationContext(evaluationJDE, inst)
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)
+214 -61
View File
@@ -1,6 +1,8 @@
package basic
import "math"
import (
"math"
)
const (
jupiterGalileanEventSearchSpanDays = 8 * 365.25
@@ -39,8 +41,10 @@ type JupiterGalileanPhenomenonEvent struct {
}
type jupiterGalileanMetricSample struct {
active bool
metric float64
active bool
metric float64
// signed 是该现象相对木星视面中心线的有符号偏移(木星半径),每次过零对应一次现象;未定义时为 NaN。
signed float64
phenomenon JupiterGalileanPhenomenon
}
@@ -54,22 +58,22 @@ type jupiterGalileanShadowPoint struct {
yJupiterRadii float64
}
// LastJupiterGalileanPhenomenonEvent 上一次伽利略卫星现象 / previous Galilean-satellite event.
func LastJupiterGalileanPhenomenonEvent(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonEvent {
event, _ := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, -1, true)
// LastJupiterGalileanPhenomenonEventWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastJupiterGalileanPhenomenonEventWithInstance(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType, inst *Instance) JupiterGalileanPhenomenonEvent {
event, _ := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, -1, true, inst)
return event
}
// NextJupiterGalileanPhenomenonEvent 下一次伽利略卫星现象 / next Galilean-satellite event.
func NextJupiterGalileanPhenomenonEvent(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonEvent {
event, _ := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, 1, false)
// NextJupiterGalileanPhenomenonEventWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextJupiterGalileanPhenomenonEventWithInstance(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType, inst *Instance) JupiterGalileanPhenomenonEvent {
event, _ := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, 1, false, inst)
return event
}
// ClosestJupiterGalileanPhenomenonEvent 最近一次伽利略卫星现象 / closest Galilean-satellite event.
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)
// ClosestJupiterGalileanPhenomenonEventWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func ClosestJupiterGalileanPhenomenonEventWithInstance(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType, inst *Instance) JupiterGalileanPhenomenonEvent {
last, hasLast := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, -1, true, inst)
next, hasNext := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, 1, false, inst)
switch {
case hasLast && !hasNext:
return last
@@ -89,14 +93,15 @@ func searchJupiterGalileanPhenomenonEvent(
satellite int,
phenomenonType JupiterGalileanPhenomenonType,
direction int,
includeCurrent bool,
) (JupiterGalileanPhenomenonEvent, bool) {
includeCurrent bool, instances ...*Instance) (JupiterGalileanPhenomenonEvent, bool) {
inst := instanceValue(instances)
if !isFinite(jd) || direction == 0 || satellite < 1 || satellite > 4 || !isValidJupiterGalileanPhenomenonType(phenomenonType) {
return invalidJupiterGalileanPhenomenonEvent(), false
}
if sample := jupiterGalileanPhenomenonMetricAt(jd, satellite, phenomenonType); sample.active {
current := findJupiterGalileanPhenomenonEventAround(jd, satellite, phenomenonType)
if sample := jupiterGalileanPhenomenonMetricAt(jd, satellite, phenomenonType, inst); sample.active {
current := findJupiterGalileanPhenomenonEventAround(jd, satellite, phenomenonType, inst)
if current.Valid && includeCurrent {
return current, true
}
@@ -114,21 +119,18 @@ func searchJupiterGalileanPhenomenonEvent(
sign := float64(direction)
prevTime := jd
prevSample := jupiterGalileanPhenomenonMetricAt(prevTime, satellite, phenomenonType)
prevSample := jupiterGalileanPhenomenonMetricAt(prevTime, satellite, phenomenonType, inst)
midTime := jd + sign*stepDays
midSample := jupiterGalileanPhenomenonMetricAt(midTime, satellite, phenomenonType)
midSample := jupiterGalileanPhenomenonMetricAt(midTime, satellite, phenomenonType, inst)
for i := 2; i <= maxSteps; i++ {
nextTime := jd + sign*float64(i)*stepDays
nextSample := jupiterGalileanPhenomenonMetricAt(nextTime, satellite, phenomenonType)
if isFinite(midSample.metric) &&
midSample.metric <= prevSample.metric &&
midSample.metric <= nextSample.metric {
candidate := refineJupiterGalileanMetricMinimum(prevTime, nextTime, satellite, phenomenonType)
event := findJupiterGalileanPhenomenonEventAround(candidate, satellite, phenomenonType)
if event.Valid && jupiterGalileanEventMatchesDirection(event.Greatest, jd, direction, includeCurrent) {
return event, true
}
nextSample := jupiterGalileanPhenomenonMetricAt(nextTime, satellite, phenomenonType, inst)
if event, ok := jupiterGalileanIntervalCandidate(
prevTime, nextTime, prevSample, midSample, nextSample,
jd, satellite, phenomenonType, direction, includeCurrent, inst,
); ok {
return event, true
}
prevTime, prevSample = midTime, midSample
midTime, midSample = nextTime, nextSample
@@ -137,8 +139,139 @@ func searchJupiterGalileanPhenomenonEvent(
return invalidJupiterGalileanPhenomenonEvent(), false
}
func findJupiterGalileanPhenomenonEventAround(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonEvent {
sample := jupiterGalileanPhenomenonMetricAt(jd, satellite, phenomenonType)
// jupiterGalileanIntervalCandidate 在粗扫区间内确认现象:度量局部极小,或有符号偏移过零。
func jupiterGalileanIntervalCandidate(
prevTime, nextTime float64,
prevSample, midSample, nextSample jupiterGalileanMetricSample,
jd float64,
satellite int,
phenomenonType JupiterGalileanPhenomenonType,
direction int,
includeCurrent bool, instances ...*Instance) (JupiterGalileanPhenomenonEvent, bool) {
inst := instanceValue(instances)
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, inst),
satellite, phenomenonType, inst,
))
}
if jupiterGalileanSignedCrosses(prevSample.signed, nextSample.signed) &&
jupiterGalileanNearDisk(prevSample, midSample, nextSample) {
crossing := refineJupiterGalileanSignedCrossing(prevTime, nextTime, satellite, phenomenonType, inst)
// 过零点只说明"靠近木星",只在它已贴近视面时按事件尺度细扫一次。
if sample := jupiterGalileanPhenomenonMetricAt(crossing, satellite, phenomenonType, inst); isFinite(sample.metric) &&
sample.metric <= jupiterGalileanCrossingProbeMetric {
if event, ok := jupiterGalileanFineScanAround(crossing, satellite, phenomenonType, inst); 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, instances ...*Instance) (JupiterGalileanPhenomenonEvent, bool) {
inst := instanceValue(instances)
// 从过零点向两侧交替外扩,通常几步内就能落进事件窗口。
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, inst).active {
continue
}
if event := findJupiterGalileanPhenomenonEventAround(candidate, satellite, phenomenonType, inst); 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, instances ...*Instance) float64 {
inst := instanceValue(instances)
leftValue := jupiterGalileanPhenomenonMetricAt(left, satellite, phenomenonType, inst).signed
rightValue := jupiterGalileanPhenomenonMetricAt(right, satellite, phenomenonType, inst).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, inst).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, instances ...*Instance) JupiterGalileanPhenomenonEvent {
inst := instanceValue(instances)
sample := jupiterGalileanPhenomenonMetricAt(jd, satellite, phenomenonType, inst)
if !sample.active {
return invalidJupiterGalileanPhenomenonEvent()
}
@@ -150,7 +283,7 @@ func findJupiterGalileanPhenomenonEventAround(jd float64, satellite int, phenome
inactiveStart := math.NaN()
for i := 0; i < maxBoundarySteps; i++ {
candidate := activeStart - stepDays
if !jupiterGalileanPhenomenonMetricAt(candidate, satellite, phenomenonType).active {
if !jupiterGalileanPhenomenonMetricAt(candidate, satellite, phenomenonType, inst).active {
inactiveStart = candidate
break
}
@@ -164,7 +297,7 @@ func findJupiterGalileanPhenomenonEventAround(jd float64, satellite int, phenome
inactiveEnd := math.NaN()
for i := 0; i < maxBoundarySteps; i++ {
candidate := activeEnd + stepDays
if !jupiterGalileanPhenomenonMetricAt(candidate, satellite, phenomenonType).active {
if !jupiterGalileanPhenomenonMetricAt(candidate, satellite, phenomenonType, inst).active {
inactiveEnd = candidate
break
}
@@ -174,14 +307,14 @@ func findJupiterGalileanPhenomenonEventAround(jd float64, satellite int, phenome
return invalidJupiterGalileanPhenomenonEvent()
}
start := refineJupiterGalileanEventStart(inactiveStart, activeStart, satellite, phenomenonType)
end := refineJupiterGalileanEventEnd(activeEnd, inactiveEnd, satellite, phenomenonType)
start := refineJupiterGalileanEventStart(inactiveStart, activeStart, satellite, phenomenonType, inst)
end := refineJupiterGalileanEventEnd(activeEnd, inactiveEnd, satellite, phenomenonType, inst)
if !isFinite(start) || !isFinite(end) || end <= start {
return invalidJupiterGalileanPhenomenonEvent()
}
greatest := refineJupiterGalileanMetricMinimum(start, end, satellite, phenomenonType)
greatestSample := jupiterGalileanPhenomenonMetricAt(greatest, satellite, phenomenonType)
greatest := refineJupiterGalileanMetricMinimum(start, end, satellite, phenomenonType, inst)
greatestSample := jupiterGalileanPhenomenonMetricAt(greatest, satellite, phenomenonType, inst)
if !greatestSample.active {
return invalidJupiterGalileanPhenomenonEvent()
}
@@ -200,22 +333,23 @@ func findJupiterGalileanPhenomenonEventAround(jd float64, satellite int, phenome
func refineJupiterGalileanEventStart(
outsideJD, insideJD float64,
satellite int,
phenomenonType JupiterGalileanPhenomenonType,
) float64 {
phenomenonType JupiterGalileanPhenomenonType, instances ...*Instance) float64 {
inst := instanceValue(instances)
if insideJD < outsideJD {
outsideJD, insideJD = insideJD, outsideJD
}
if jupiterGalileanPhenomenonMetricAt(outsideJD, satellite, phenomenonType).active {
if jupiterGalileanPhenomenonMetricAt(outsideJD, satellite, phenomenonType, inst).active {
return math.NaN()
}
if !jupiterGalileanPhenomenonMetricAt(insideJD, satellite, phenomenonType).active {
if !jupiterGalileanPhenomenonMetricAt(insideJD, satellite, phenomenonType, inst).active {
return math.NaN()
}
left := outsideJD
right := insideJD
for i := 0; i < 80 && right-left > jupiterGalileanEventEpsilonDays; i++ {
mid := (left + right) / 2
if jupiterGalileanPhenomenonMetricAt(mid, satellite, phenomenonType).active {
if jupiterGalileanPhenomenonMetricAt(mid, satellite, phenomenonType, inst).active {
right = mid
} else {
left = mid
@@ -227,22 +361,23 @@ func refineJupiterGalileanEventStart(
func refineJupiterGalileanEventEnd(
insideJD, outsideJD float64,
satellite int,
phenomenonType JupiterGalileanPhenomenonType,
) float64 {
phenomenonType JupiterGalileanPhenomenonType, instances ...*Instance) float64 {
inst := instanceValue(instances)
if outsideJD < insideJD {
insideJD, outsideJD = outsideJD, insideJD
}
if !jupiterGalileanPhenomenonMetricAt(insideJD, satellite, phenomenonType).active {
if !jupiterGalileanPhenomenonMetricAt(insideJD, satellite, phenomenonType, inst).active {
return math.NaN()
}
if jupiterGalileanPhenomenonMetricAt(outsideJD, satellite, phenomenonType).active {
if jupiterGalileanPhenomenonMetricAt(outsideJD, satellite, phenomenonType, inst).active {
return math.NaN()
}
left := insideJD
right := outsideJD
for i := 0; i < 80 && right-left > jupiterGalileanEventEpsilonDays; i++ {
mid := (left + right) / 2
if jupiterGalileanPhenomenonMetricAt(mid, satellite, phenomenonType).active {
if jupiterGalileanPhenomenonMetricAt(mid, satellite, phenomenonType, inst).active {
left = mid
} else {
right = mid
@@ -254,8 +389,9 @@ func refineJupiterGalileanEventEnd(
func refineJupiterGalileanMetricMinimum(
jd1, jd2 float64,
satellite int,
phenomenonType JupiterGalileanPhenomenonType,
) float64 {
phenomenonType JupiterGalileanPhenomenonType, instances ...*Instance) float64 {
inst := instanceValue(instances)
left := math.Min(jd1, jd2)
right := math.Max(jd1, jd2)
if right-left <= jupiterGalileanEventEpsilonDays {
@@ -264,21 +400,21 @@ func refineJupiterGalileanMetricMinimum(
const phi = 0.6180339887498948482
x1 := right - phi*(right-left)
x2 := left + phi*(right-left)
f1 := jupiterGalileanPhenomenonMetricAt(x1, satellite, phenomenonType).metric
f2 := jupiterGalileanPhenomenonMetricAt(x2, satellite, phenomenonType).metric
f1 := jupiterGalileanPhenomenonMetricAt(x1, satellite, phenomenonType, inst).metric
f2 := jupiterGalileanPhenomenonMetricAt(x2, satellite, phenomenonType, inst).metric
for i := 0; i < 80 && right-left > jupiterGalileanEventEpsilonDays; i++ {
if f1 <= f2 {
right = x2
x2 = x1
f2 = f1
x1 = right - phi*(right-left)
f1 = jupiterGalileanPhenomenonMetricAt(x1, satellite, phenomenonType).metric
f1 = jupiterGalileanPhenomenonMetricAt(x1, satellite, phenomenonType, inst).metric
} else {
left = x1
x1 = x2
f1 = f2
x2 = left + phi*(right-left)
f2 = jupiterGalileanPhenomenonMetricAt(x2, satellite, phenomenonType).metric
f2 = jupiterGalileanPhenomenonMetricAt(x2, satellite, phenomenonType, inst).metric
}
}
return (left + right) / 2
@@ -287,20 +423,23 @@ func refineJupiterGalileanMetricMinimum(
func jupiterGalileanPhenomenonMetricAt(
jd float64,
satellite int,
phenomenonType JupiterGalileanPhenomenonType,
) jupiterGalileanMetricSample {
phenomenonType JupiterGalileanPhenomenonType, instances ...*Instance) jupiterGalileanMetricSample {
inst := instanceValue(instances)
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, inst)
if context.jupiterDistance == 0 {
return jupiterGalileanMetricSample{
metric: math.Inf(1),
signed: math.NaN(),
phenomenon: invalidJupiterGalileanPhenomenon(),
}
}
@@ -360,27 +499,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)
}
}
+9 -7
View File
@@ -1,6 +1,8 @@
package basic
import "math"
import (
"math"
)
const solarRadiusAU = 695700.0 / astronomicalUnitKM
@@ -22,18 +24,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 {
// JupiterGalileanSatellitePhenomenaWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterGalileanSatellitePhenomenaWithInstance(jde float64, inst *Instance) [4]JupiterGalileanPhenomenon {
var phenomena [4]JupiterGalileanPhenomenon
context := newJupiterGalileanObservationContext(jd)
context := newJupiterGalileanObservationContext(jde, inst)
for i := range phenomena {
phenomena[i] = context.phenomenonForSatellite(i)
}
+52 -31
View File
@@ -3,7 +3,6 @@ package basic
import (
"math"
"b612.me/astro/planet"
. "b612.me/astro/tools"
)
@@ -35,7 +34,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,21 +96,18 @@ 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)
}
// JupiterGalileanSatelliteObservations 四颗伽利略卫星视位置 / apparent geometry of the four Galilean satellites.
//
// 返回次序固定为 Io、Europa、Ganymede、Callisto。
// The returned order is Io, Europa, Ganymede, Callisto.
func JupiterGalileanSatelliteObservations(jd float64) [4]JupiterGalileanObservation {
// JupiterGalileanSatelliteObservationsWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func JupiterGalileanSatelliteObservationsWithInstance(jde float64, inst *Instance) [4]JupiterGalileanObservation {
var observations [4]JupiterGalileanObservation
context := newJupiterGalileanObservationContext(jd)
context := newJupiterGalileanObservationContext(jde, inst)
for i := range observations {
observations[i] = context.observationForSatellite(i)
}
@@ -119,6 +115,8 @@ func JupiterGalileanSatelliteObservations(jd float64) [4]JupiterGalileanObservat
}
type jupiterGalileanObservationContext struct {
inst *Instance
jd float64
targetJD float64
earthHelioJ2000 Vector3
@@ -141,14 +139,16 @@ type jupiterGalileanObservationContext struct {
bodyZ Vector3
}
func newJupiterGalileanObservationContext(jd float64) jupiterGalileanObservationContext {
context := jupiterGalileanObservationContext{jd: jd}
if !isFinite(jd) {
func newJupiterGalileanObservationContext(jde float64, instances ...*Instance) jupiterGalileanObservationContext {
inst := instanceValue(instances)
context := jupiterGalileanObservationContext{inst: inst, 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, inst), orbitJ2000Obliquity)
context.jupiterGeoJ2000, context.jupiterLightTime = jupiterAstrometricGeocentricVectorJ2000(jde, context.earthHelioJ2000, inst)
context.targetJD = jde - context.jupiterLightTime
context.jupiterDistance = vectorMagnitude(context.jupiterGeoJ2000)
if context.jupiterDistance == 0 {
return context
@@ -158,7 +158,7 @@ func newJupiterGalileanObservationContext(jd float64) jupiterGalileanObservation
ra, dec := vectorToRaDec(context.lineOfSight)
context.east = Vector3{-Sin(ra), Cos(ra), 0}
context.north = Vector3{-Cos(ra) * Sin(dec), -Sin(ra) * Sin(dec), Cos(dec)}
jupiterHelio := rotateEclipticToEquatorial(jupiterHeliocentricVectorJ2000(context.targetJD), orbitJ2000Obliquity)
jupiterHelio := rotateEclipticToEquatorial(jupiterHeliocentricVectorJ2000(context.targetJD, inst), orbitJ2000Obliquity)
context.sunDistanceAU = vectorMagnitude(jupiterHelio)
context.sunDirection = normalizeVector(Vector3{-jupiterHelio[0], -jupiterHelio[1], -jupiterHelio[2]})
context.sunLineOfSight = Vector3{-context.sunDirection[0], -context.sunDirection[1], -context.sunDirection[2]}
@@ -178,7 +178,7 @@ func (context jupiterGalileanObservationContext) observationForSatellite(index i
if index < 0 || index >= 4 || context.jupiterDistance == 0 {
return invalidJupiterGalileanObservation()
}
state, geocentric := jupiterGalileanSatelliteAstrometricGeocentric(index, context.jd, context.jupiterLightTime, context.earthHelioJ2000)
state, geocentric := jupiterGalileanSatelliteAstrometricGeocentric(index, context.jd, context.jupiterLightTime, context.earthHelioJ2000, context.inst)
direction := normalizeVector(geocentric)
ra, dec := vectorToRaDec(direction)
distance := vectorMagnitude(geocentric)
@@ -214,15 +214,17 @@ func tangentPlaneOffsetAngles(target, center, east, north Vector3) (float64, flo
return math.Atan2(vectorDot(target, east), denominator), math.Atan2(vectorDot(target, north), denominator)
}
func jupiterGalileanSatelliteAstrometricGeocentric(index int, jd, initialLightTime float64, earthHelioJ2000 Vector3) (JupiterGalileanState, Vector3) {
func jupiterGalileanSatelliteAstrometricGeocentric(index int, jd, initialLightTime float64, earthHelioJ2000 Vector3, instances ...*Instance) (JupiterGalileanState, Vector3) {
inst := instanceValue(instances)
lightTime := initialLightTime
state := JupiterGalileanState{}
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, inst), orbitJ2000Obliquity)
state = jupiterGalileanSatelliteStateAtET(targetJDE-jupiterGalileanReferenceJD, index, includeSolarLongPeriod)
result = Vector3{
jupiterHelio[0] + state.X - earthHelioJ2000[0],
jupiterHelio[1] + state.Y - earthHelioJ2000[1],
@@ -237,11 +239,13 @@ func jupiterGalileanSatelliteAstrometricGeocentric(index int, jd, initialLightTi
return state, result
}
func jupiterAstrometricGeocentricVectorJ2000(jd float64, earthHelioJ2000 Vector3) (Vector3, float64) {
func jupiterAstrometricGeocentricVectorJ2000(jd float64, earthHelioJ2000 Vector3, instances ...*Instance) (Vector3, float64) {
inst := instanceValue(instances)
lightTime := 0.0
result := Vector3{}
for i := 0; i < 8; i++ {
jupiterHelio := rotateEclipticToEquatorial(jupiterHeliocentricVectorJ2000(jd-lightTime), orbitJ2000Obliquity)
jupiterHelio := rotateEclipticToEquatorial(jupiterHeliocentricVectorJ2000(jd-lightTime, inst), orbitJ2000Obliquity)
result = Vector3{
jupiterHelio[0] - earthHelioJ2000[0],
jupiterHelio[1] - earthHelioJ2000[1],
@@ -256,14 +260,16 @@ func jupiterAstrometricGeocentricVectorJ2000(jd float64, earthHelioJ2000 Vector3
return result, lightTime
}
func jupiterHeliocentricVectorJ2000(jd float64) Vector3 {
func jupiterHeliocentricVectorJ2000(jde float64, instances ...*Instance) Vector3 {
inst := instanceValue(instances)
return eclipticVectorAtReferenceEpoch(
eclipticCartesian(
planet.WherePlanet(4, 0, jd),
planet.WherePlanet(4, 1, jd),
planet.WherePlanet(4, 2, jd),
PlanetCoordinateWithInstance(4, 0, jde, inst),
PlanetCoordinateWithInstance(4, 1, jde, inst),
PlanetCoordinateWithInstance(4, 2, jde, inst),
),
jd,
jde,
orbitReferenceJD,
)
}
@@ -361,17 +367,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
+198
View File
@@ -0,0 +1,198 @@
package basic
import (
"math"
"time"
)
const (
// 月掩南北界:星迹相对月心是直线,偏离切点 δ 后月缘需高出 R(1/cosδ − 1),8° 时约 17 km,远超月面起伏。
limbGrazeSpanDeg = 8.0
limbGrazeStepDeg = 0.1
// 界线点月缘平移上限;超出视为不收敛并保留圆月面点。
limbLimitMaxShiftKM = 30.0
limbLimitProbeKM = 1.0
limbLimitIterations = 4
// 太阳 / 月球高度低于此值的界线点不做月缘平移。
limbLimitHorizonDeg = 0.5
)
// limbLimitGap 返回站点处的圆月面接触间隙与月缘要求的间隙(同一单位),二者相等即为月缘界线。
type limbLimitGap func(longitude, latitude float64) (gap, target float64, ok bool)
// limbLimitSolve 从圆月面界线点出发,沿间隙梯度做牛顿迭代,使 gap − gap0 = target;失败时返回原点。
// gap0 是原点处的圆月面间隙:界线点与站心间隙零点之间的模型差由它吸收,只平移月缘带来的部分。
func limbLimitSolve(longitude, latitude float64, evaluate limbLimitGap) (float64, float64, bool) {
kmPerDegLat := math.Pi / 180 * topocentricEarthRadiusKM
gap0, _, ok := evaluate(longitude, latitude)
if !ok {
return longitude, latitude, false
}
lon, lat := longitude, latitude
for iteration := 0; iteration < limbLimitIterations; iteration++ {
gap, target, ok := evaluate(lon, lat)
if !ok {
return longitude, latitude, false
}
residual := gap - gap0 - target
kmPerDegLon := kmPerDegLat * math.Cos(lat*rad)
if kmPerDegLon < 1 {
return longitude, latitude, false
}
dLon := limbLimitProbeKM / kmPerDegLon
dLat := limbLimitProbeKM / kmPerDegLat
east, _, okEast := evaluate(lon+dLon, lat)
north, _, okNorth := evaluate(lon, lat+dLat)
if !okEast || !okNorth {
return longitude, latitude, false
}
gx := (east - gap) / limbLimitProbeKM
gy := (north - gap) / limbLimitProbeKM
norm2 := gx*gx + gy*gy
if !finite(norm2) || norm2 <= 0 {
return longitude, latitude, false
}
stepX, stepY := -residual*gx/norm2, -residual*gy/norm2
lon += stepX / kmPerDegLon
lat += stepY / kmPerDegLat
if math.Abs(stepX) < 1e-4 && math.Abs(stepY) < 1e-4 {
break
}
}
shiftKM := math.Hypot((lon-longitude)*kmPerDegLat*math.Cos(latitude*rad), (lat-latitude)*kmPerDegLat)
if !finite(shiftKM) || shiftKM > limbLimitMaxShiftKM || lat > 90 || lat < -90 {
return longitude, latitude, false
}
if lon > 180 {
lon -= 360
} else if lon < -180 {
lon += 360
}
return lon, lat, true
}
func occultationVectorArray(vector occultationPathVector) [3]float64 {
return [3]float64{vector.x, vector.y, vector.z}
}
// occultationLimbCorrectLimitSeries 把月掩外接触南北界平移到月缘掠掩线:
// 星迹在切点附近按直线处理,有效月缘半径取 max R(θ0+δ)·cosδ。只用于外接触(掠掩)界线。
func occultationLimbCorrectLimitSeries(
points []OccultationPathPoint,
contextAt occultationRiseSetContextFunc,
limb LunarLimb,
location *time.Location,
inst *Instance,
) ([]OccultationPathPoint, bool) {
if limb == nil || len(points) == 0 || contextAt == nil {
return points, false
}
applied := false
corrected := append([]OccultationPathPoint(nil), points...)
for index, point := range points {
if math.Abs(point.MoonAltitude) < limbLimitHorizonDeg {
continue
}
tt := centerTimeTT(point.Time)
context := contextAt(tt)
if !context.valid || context.moon.distanceKM <= 0 {
continue
}
frame := newLunarLimbFrame(limb, tt, occultationVectorArray(context.moon.positionKM), inst)
if !frame.valid {
continue
}
evaluate := func(longitude, latitude float64) (float64, float64, bool) {
state := context.stateAt(longitude, latitude)
if !state.valid || !finite(state.contactMetric) {
return 0, 0, false
}
parallaxKM, distanceKM, _ := occultationRiseSetObserverVectors(context.siderealDegrees, longitude, latitude)
moonKM := occultationPathSub(context.moon.positionKM, parallaxKM)
var targetKM occultationPathVector
if context.target.distanceKM > 0 {
targetKM = occultationPathSub(context.target.positionKM, parallaxKM)
} else {
targetKM = context.target.direction
}
view, ok := frame.viewToward(occultationVectorArray(moonKM), occultationVectorArray(targetKM))
if !ok {
return 0, 0, false
}
effective := 0.0
for offset := -limbGrazeSpanDeg; offset <= limbGrazeSpanDeg+1e-9; offset += limbGrazeStepDeg {
radius, ok := frame.radiusAt(view, offset)
if !ok {
return 0, 0, false
}
if value := radius * math.Cos(offset*rad); value > effective {
effective = value
}
}
moonDistanceKM := occultationRiseSetTopocentricDistance(context.moon, distanceKM)
if !finite(moonDistanceKM) || moonDistanceKM <= effective {
return 0, 0, false
}
// 有效半径更大时掠掩线外移:需要的圆月面间隙为两者角半径之差(度)。
target := (angularSemidiameterArcsec(effective, moonDistanceKM) -
angularSemidiameterArcsec(moonEquatorialRadiusKM, moonDistanceKM)) / 3600
return state.contactMetric, target, true
}
longitude, latitude, ok := limbLimitSolve(point.Longitude, point.Latitude, evaluate)
if !ok {
continue
}
if !limbGroundMoved(point.Longitude, point.Latitude, longitude, latitude) {
continue
}
corrected[index].Longitude, corrected[index].Latitude = longitude, latitude
applied = true
state := context.stateAt(longitude, latitude)
if state.valid {
corrected[index].MoonAltitude = state.moonAltitude
}
}
return corrected, applied
}
// limbCorrectCentralLimits 把全食 / 环食带南北界平移到月缘界线。
func (solver solarEclipseSolver) limbCorrectCentralLimits(points []SolarEclipsePathPoint, limb LunarLimb) ([]SolarEclipsePathPoint, bool) {
if limb == nil || len(points) == 0 {
return points, false
}
limbSolver := solver.withLimbParams(limb)
applied := false
corrected := append([]SolarEclipsePathPoint(nil), points...)
for index, point := range points {
// 地平线上的 U1/U4 端点处地面梯度趋于零,平移不可信,保留圆月面点。
if math.Abs(point.SunAltitude) < limbLimitHorizonDeg || !finite(point.JDE) || point.JDE <= 0 {
continue
}
context := solver.localStateContextAt(point.JDE)
limbContext := limbSolver.localStateContextAt(point.JDE)
if limbContext.limbFrame == nil {
continue
}
// 与站心同一判据:月缘内切间隙取自站心状态的等效内半径,目标是让它在界线上为零。
evaluate := func(longitude, latitude float64) (float64, float64, bool) {
lonRad, latRad := longitude*rad, latitude*rad
state := context.stateAt(lonRad, latRad, 0)
limbState := limbContext.stateAt(lonRad, latRad, 0)
if !limbState.limbApplied {
return 0, 0, false
}
gap := state.movingDiskContactState().internalContactGap()
return gap, gap - limbState.movingDiskContactState().internalContactGap(), true
}
longitude, latitude, ok := limbLimitSolve(point.Longitude, point.Latitude, evaluate)
if !ok {
continue
}
corrected[index].Longitude, corrected[index].Latitude = longitude, latitude
// 牛顿迭代可能收敛回原点(常数月缘即如此),只有位移超过噪声容差才算真正修正。
if limbGroundMoved(point.Longitude, point.Latitude, corrected[index].Longitude, corrected[index].Latitude) {
applied = true
}
}
return corrected, applied
}
+35
View File
@@ -0,0 +1,35 @@
package basic
import (
"math"
"b612.me/astro/internal/geodata"
)
// 月缘修正是否生效的统一判据:每种量纲只有一个容差,所有层都经下面的函数判断,避免口径漂移。
const (
// limbAppliedTimeToleranceDays 接触时刻与时长的容差(0.05 秒)。
limbAppliedTimeToleranceDays = 0.05 / 86400.0
// limbAppliedDistanceToleranceKM 地面界线点的平移容差(0.05 km)。
limbAppliedDistanceToleranceKM = 0.05
// limbAppliedMagnitudeTolerance 食分与遮蔽率的容差。
limbAppliedMagnitudeTolerance = 1e-9
)
// limbTimeChanged 报告两个儒略日(或以日为单位的时长)是否相差超过时刻容差;任一非有限时视为未变。
func limbTimeChanged(corrected, original float64) bool {
return finite(corrected) && finite(original) &&
math.Abs(corrected-original) > limbAppliedTimeToleranceDays
}
// limbMagnitudeChanged 报告食分或遮蔽率是否相差超过容差;任一非有限时视为未变。
func limbMagnitudeChanged(corrected, original float64) bool {
return finite(corrected) && finite(original) &&
math.Abs(corrected-original) > limbAppliedMagnitudeTolerance
}
// limbGroundMoved 报告地面点(度)是否平移超过距离容差。
func limbGroundMoved(originalLon, originalLat, correctedLon, correctedLat float64) bool {
longitude := signedAngleDifference(correctedLon, originalLon) * math.Cos(originalLat*rad)
return math.Hypot(longitude, correctedLat-originalLat)*geodata.EarthKMPerDegree > limbAppliedDistanceToleranceKM
}
+298
View File
@@ -0,0 +1,298 @@
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, instances ...*Instance) *solarEclipseLocalEphemeris {
inst := instanceValue(instances)
half := solarLocalEphemerisNodeCount / 2
nodes := make([]localEphemerisVectorNode, solarLocalEphemerisNodeCount)
for index := range nodes {
tt := center + float64(index-half)*solarLocalEphemerisStepDays
sun, moon := solarEclipseSunMoonEquatorial(tt, inst)
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}
}
// solarEclipseLimbAxes 与日月局部星历同一时间网格的月球本体坐标架插值节点。
type solarEclipseLimbAxes struct {
base lunarLimbFrame
nodes []localEphemerisVectorNode
}
func newSolarEclipseLimbAxes(ephemeris *solarEclipseLocalEphemeris, limb LunarLimb, inst *Instance) *solarEclipseLimbAxes {
if ephemeris == nil || limb == nil {
return nil
}
axes := &solarEclipseLimbAxes{nodes: make([]localEphemerisVectorNode, len(ephemeris.nodes))}
for index, node := range ephemeris.nodes {
frame := newLunarLimbFrame(limb, node.tt, node.next, inst)
if !frame.valid {
return nil
}
if index == len(ephemeris.nodes)/2 {
axes.base = frame
}
axes.nodes[index] = localEphemerisVectorNode{tt: node.tt, first: frame.axisX, next: frame.axisZ}
}
return axes
}
func (axes *solarEclipseLimbAxes) frameAt(tt float64) (lunarLimbFrame, bool) {
if axes == nil {
return lunarLimbFrame{}, false
}
axisX, axisZ, ok := interpolateLocalEphemerisVectors(axes.nodes, tt)
if !ok {
return lunarLimbFrame{}, false
}
frame := axes.base
if frame.axisZ, ok = lunarLimbNormalize(axisZ); !ok {
return lunarLimbFrame{}, false
}
// 两轴分别插值后不再严格正交,按 z 轴重新正交化 x 轴。
if frame.axisX, ok = lunarLimbNormalize(lunarLimbReject(axisX, frame.axisZ)); !ok {
return lunarLimbFrame{}, false
}
frame.axisY = lunarLimbCross(frame.axisZ, frame.axisX)
return frame, true
}
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, instances ...*Instance) *starOccultationLocalEphemeris {
inst := instanceValue(instances)
half := occultationLocalEphemerisNodeCount / 2
nodes := make([]localEphemerisVectorNode, occultationLocalEphemerisNodeCount)
for index := range nodes {
tt := center + float64(index-half)*occultationLocalEphemerisStepDays
state := starOccultationEphemerisStateAt(tt, star, inst)
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, inst)
}
// newStarOccultationLocalEphemerisFromNodes 装配局部星历:距离必须与节点同源,密集分支同样要带上。
func newStarOccultationLocalEphemerisFromNodes(center float64, star StarCoordinate, nodes []localEphemerisVectorNode, dense bool, instances ...*Instance) *starOccultationLocalEphemeris {
inst := instanceValue(instances)
_, _, distanceAU := starApparentRaDecDistanceGeocentric(center, star, inst)
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, instances ...*Instance) *planetOccultationLocalEphemeris {
inst := instanceValue(instances)
half := occultationLocalEphemerisNodeCount / 2
nodes := make([]localEphemerisVectorNode, occultationLocalEphemerisNodeCount)
for index := range nodes {
tt := center + float64(index-half)*occultationLocalEphemerisStepDays
state := planetOccultationEphemerisStateAt(tt, config, inst)
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("零矢量应判为无效")
}
}
+118 -52
View File
@@ -1,6 +1,10 @@
package basic
import "math"
import (
"math"
"b612.me/astro/internal/geodata"
)
// LunarEclipseType 表示月食类型。
type LunarEclipseType string
@@ -22,6 +26,11 @@ const (
// 输入 seedJDE 只需要落在目标望月附近,允许相差数天。
type LunarEclipseResult struct {
Type LunarEclipseType
// LunarLimbApplied 表示接触时刻已按月缘剖面修正 / reports whether lunar-limb refinement changed the contacts.
LunarLimbApplied bool
// ShadowModel 是本次使用的影半径模型,决定影半径与食分的具体数值。
// ShadowModel is the shadow-radius model used, which fixes the shadow radii and magnitudes.
ShadowModel LunarEclipseShadowModel
// Maximum 是食甚时刻;即使最终没有月食,也会返回该次望月附近
// “月面中心最接近地影中心”的几何极值时刻。
@@ -39,6 +48,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 +73,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 (
@@ -68,15 +92,11 @@ const (
)
const (
lunarEarthEquatorialRadiusKM = 6378.1366
lunarAstronomicalUnitKM = 1.49597870691e8
// 沿用月食常量:
// - 0.2725076 用于月亮视半径和半影几何
// - 959.63 / 8.794 分别为太阳视半径与太阳视差的常用角秒常量
// - 太阳视半径与日食共用标准档常量,太阳视差用常用角秒常量 8.794
lunarMoonRadiusRatio = 0.2725076
lunarMoonRadiusScale = lunarMoonRadiusRatio * lunarEarthEquatorialRadiusKM * 1.0000036
lunarSolarRadiusArcsec = 959.63
lunarMoonRadiusScale = lunarMoonRadiusRatio * geodata.EarthEquatorialRadiusKM * 1.0000036
lunarSolarParallaxArcsec = 8.794
lunarLongitudeAberration = -3.4e-6
lunarFiniteDifferenceStep = 60.0 / 86400.0
@@ -115,13 +135,33 @@ func LunarEclipseChauvenet(seedJDE float64) LunarEclipseResult {
func lunarEclipse(seedJDE float64, shadowModel lunarEclipseShadowModel) LunarEclipseResult {
fullMoonJDE := CalcMoonSHByJDE(seedJDE, 1)
maximumJDE, state, dxdt, dydt, minimumDistance := refineLunarEclipseMaximum(fullMoonJDE, shadowModel)
return lunarEclipseFromFullMoon(fullMoonJDE, shadowModel, func(jde float64) lunarShadowState {
return computeLunarShadowState(jde, shadowModel)
})
}
type lunarShadowEvaluator func(float64) lunarShadowState
func lunarEclipseFromFullMoon(fullMoonJDE float64, shadowModel lunarEclipseShadowModel, evaluate lunarShadowEvaluator) LunarEclipseResult {
maximumJDE, state, dxdt, dydt, minimumDistance := refineLunarEclipseMaximumWithEvaluator(fullMoonJDE, evaluate)
// 未发生的阶段保持 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)
@@ -133,11 +173,11 @@ func lunarEclipse(seedJDE float64, shadowModel lunarEclipseShadowModel) LunarEcl
result.Type = LunarEclipsePenumbral
result.HasPenumbral = true
result.Magnitude = rawUmbralMagnitude
result.PenumbralStart = refineLunarEclipseContact(
state, dxdt, dydt, state.moonRadiusRad+state.penumbraRadiusRad, false, shadowModel,
result.PenumbralStart = refineLunarEclipseContactWithEvaluator(
state, dxdt, dydt, state.moonRadiusRad+state.penumbraRadiusRad, false, evaluate,
)
result.PenumbralEnd = refineLunarEclipseContact(
state, dxdt, dydt, state.moonRadiusRad+state.penumbraRadiusRad, true, shadowModel,
result.PenumbralEnd = refineLunarEclipseContactWithEvaluator(
state, dxdt, dydt, state.moonRadiusRad+state.penumbraRadiusRad, true, evaluate,
)
}
@@ -145,22 +185,22 @@ func lunarEclipse(seedJDE float64, shadowModel lunarEclipseShadowModel) LunarEcl
result.Type = LunarEclipsePartial
result.HasPartial = true
result.Magnitude = rawUmbralMagnitude
result.PartialStart = refineLunarEclipseContact(
state, dxdt, dydt, state.moonRadiusRad+state.umbraRadiusRad, false, shadowModel,
result.PartialStart = refineLunarEclipseContactWithEvaluator(
state, dxdt, dydt, state.moonRadiusRad+state.umbraRadiusRad, false, evaluate,
)
result.PartialEnd = refineLunarEclipseContact(
state, dxdt, dydt, state.moonRadiusRad+state.umbraRadiusRad, true, shadowModel,
result.PartialEnd = refineLunarEclipseContactWithEvaluator(
state, dxdt, dydt, state.moonRadiusRad+state.umbraRadiusRad, true, evaluate,
)
}
if minimumDistance <= state.umbraRadiusRad-state.moonRadiusRad {
result.Type = LunarEclipseTotal
result.HasTotal = true
result.TotalStart = refineLunarEclipseContact(
state, dxdt, dydt, state.umbraRadiusRad-state.moonRadiusRad, false, shadowModel,
result.TotalStart = refineLunarEclipseContactWithEvaluator(
state, dxdt, dydt, state.umbraRadiusRad-state.moonRadiusRad, false, evaluate,
)
result.TotalEnd = refineLunarEclipseContact(
state, dxdt, dydt, state.umbraRadiusRad-state.moonRadiusRad, true, shadowModel,
result.TotalEnd = refineLunarEclipseContactWithEvaluator(
state, dxdt, dydt, state.umbraRadiusRad-state.moonRadiusRad, true, evaluate,
)
}
@@ -174,17 +214,23 @@ func refineLunarEclipseMaximum(
seedJDE float64,
shadowModel lunarEclipseShadowModel,
) (float64, lunarShadowState, float64, float64, float64) {
return refineLunarEclipseMaximumWithEvaluator(seedJDE, func(jde float64) lunarShadowState {
return computeLunarShadowState(jde, shadowModel)
})
}
func refineLunarEclipseMaximumWithEvaluator(seedJDE float64, evaluate lunarShadowEvaluator) (float64, lunarShadowState, float64, float64, float64) {
currentJDE := seedJDE
for i := 0; i < 2; i++ {
state := computeLunarShadowState(currentJDE, shadowModel)
nextState := computeLunarShadowState(currentJDE+lunarFiniteDifferenceStep, shadowModel)
state := evaluate(currentJDE)
nextState := evaluate(currentJDE + lunarFiniteDifferenceStep)
dxdt := (nextState.x - state.x) / lunarFiniteDifferenceStep
dydt := (nextState.y - state.y) / lunarFiniteDifferenceStep
denominator := dxdt*dxdt + dydt*dydt
if denominator == 0 {
finalState := computeLunarShadowState(currentJDE, shadowModel)
finalState := evaluate(currentJDE)
return currentJDE, finalState, 0, 0, math.Hypot(finalState.x, finalState.y)
}
@@ -192,8 +238,8 @@ func refineLunarEclipseMaximum(
currentJDE += correction
}
linearState := computeLunarShadowState(currentJDE, shadowModel)
nextLinearState := computeLunarShadowState(currentJDE+lunarFiniteDifferenceStep, shadowModel)
linearState := evaluate(currentJDE)
nextLinearState := evaluate(currentJDE + lunarFiniteDifferenceStep)
dxdt := (nextLinearState.x - linearState.x) / lunarFiniteDifferenceStep
dydt := (nextLinearState.y - linearState.y) / lunarFiniteDifferenceStep
@@ -205,8 +251,8 @@ func refineLunarEclipseMaximum(
correction := -(linearState.x*dxdt + linearState.y*dydt) / denominator
maximumJDE := currentJDE + correction
finalState := computeLunarShadowState(maximumJDE, shadowModel)
nextState := computeLunarShadowState(maximumJDE+lunarFiniteDifferenceStep, shadowModel)
finalState := evaluate(maximumJDE)
nextState := evaluate(maximumJDE + lunarFiniteDifferenceStep)
dxdt = (nextState.x - finalState.x) / lunarFiniteDifferenceStep
dydt = (nextState.y - finalState.y) / lunarFiniteDifferenceStep
@@ -221,12 +267,18 @@ func refineLunarEclipseContact(
afterMaximum bool,
shadowModel lunarEclipseShadowModel,
) float64 {
return refineLunarEclipseContactWithEvaluator(maximumState, dxdt, dydt, boundaryRadius, afterMaximum, func(jde float64) lunarShadowState {
return computeLunarShadowState(jde, shadowModel)
})
}
func refineLunarEclipseContactWithEvaluator(maximumState lunarShadowState, dxdt, dydt, boundaryRadius float64, afterMaximum bool, evaluate lunarShadowEvaluator) float64 {
firstGuess, ok := solveLineCircleContact(maximumState, dxdt, dydt, boundaryRadius, afterMaximum)
if !ok {
return 0
return math.NaN()
}
contactState := computeLunarShadowState(firstGuess, shadowModel)
contactState := evaluate(firstGuess)
refinedRadius := boundaryRadius
switch {
@@ -272,24 +324,49 @@ func solveLineCircleContact(
return state.jde + delta, true
}
// lunarEclipsePlaneState 只需要月心相对地影中心的二维坐标(穿影图采样点用,跳过半径所需的距离项)。
type lunarEclipsePlaneState struct {
jde float64
x float64
y float64
}
func lunarEclipsePlaneStateAt(jde float64) lunarEclipsePlaneState {
return lunarEclipsePlaneStateAtWithInstance(jde, nil)
}
func lunarEclipsePlaneStateAtWithInstance(jde float64, inst *Instance) lunarEclipsePlaneState {
julianCentury := (jde - 2451545.0) / 36525.0
sunLongitude := HSunTrueLoWithInstance(jde, inst)*rad + sunLongitudeAberrationRad(julianCentury)
sunLatitude := HSunTrueBoWithInstance(jde, inst) * rad
moonLongitude := HMoonTrueLoWithInstance(jde, inst)*rad + lunarLongitudeAberration
moonLatitude := HMoonTrueBoWithInstance(jde, inst)*rad + moonLatitudeAberrationRad(julianCentury)
return lunarEclipsePlaneState{
jde: jde,
x: normalizeSignedRadians(moonLongitude+math.Pi-sunLongitude) * math.Cos((moonLatitude-sunLatitude)/2),
y: moonLatitude + sunLatitude,
}
}
// computeLunarShadowState 计算某一力学时刻下,月心相对地影中心的二维几何状态。
//
// 所有内部角量统一使用弧度。影半径模型允许在 Danjon 与 Chauvenet 之间切换,
// 其余月心轨迹与几何求交框架保持一致。
func computeLunarShadowState(jde float64, shadowModel lunarEclipseShadowModel) lunarShadowState {
julianCentury := (jde - 2451545.0) / 36525.0
return computeLunarShadowStateWithInstance(jde, shadowModel, nil)
}
sunLongitude := HSunTrueLo(jde)*rad + sunLongitudeAberrationRad(julianCentury)
sunLatitude := HSunTrueBo(jde) * rad
moonLongitude := HMoonTrueLo(jde)*rad + lunarLongitudeAberration
moonLatitude := HMoonTrueBo(jde)*rad + moonLatitudeAberrationRad(julianCentury)
func computeLunarShadowStateWithInstance(jde float64, shadowModel lunarEclipseShadowModel, inst *Instance) lunarShadowState {
plane := lunarEclipsePlaneStateAtWithInstance(jde, inst)
moonDistanceKM := HMoonAway(jde)
sunDistanceAU := EarthAway(jde)
moonDistanceKM := HMoonAwayWithInstance(jde, inst)
sunDistanceAU := EarthAwayNWithInstance(jde, -1, inst)
moonRadiusArcsec := lunarMoonRadiusScale * lunarArcsecPerRadian / moonDistanceKM
earthParallaxArcsec := lunarEarthEquatorialRadiusKM / moonDistanceKM * lunarArcsecPerRadian
solarRadiusArcsec := lunarSolarRadiusArcsec / sunDistanceAU
earthParallaxArcsec := geodata.EarthEquatorialRadiusKM / moonDistanceKM * lunarArcsecPerRadian
solarRadiusArcsec := eclipseSunRadiusStandardArcsec / sunDistanceAU
solarParallaxArcsec := lunarSolarParallaxArcsec / sunDistanceAU
umbraRadiusArcsec, penumbraRadiusArcsec := lunarEclipseShadowRadiiArcsec(
earthParallaxArcsec,
@@ -299,9 +376,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,
@@ -324,17 +401,6 @@ func lunarEclipseShadowRadiiArcsec(
}
}
func normalizeRadians(angle float64) float64 {
angle = math.Mod(angle, 2*math.Pi)
if angle > math.Pi {
angle -= 2 * math.Pi
}
if angle <= -math.Pi {
angle += 2 * math.Pi
}
return angle
}
func sunLongitudeAberrationRad(julianCentury float64) float64 {
meanAnomaly := -0.043126 + 628.301955*julianCentury - 0.000002732*julianCentury*julianCentury
eccentricity := 0.016708634 - 0.000042037*julianCentury - 0.0000001267*julianCentury*julianCentury
+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))
}
}
+13 -7
View File
@@ -18,6 +18,8 @@ type LunarEclipseDiagramOptions struct {
// StepDays 是路径采样步长,单位为日;<=0 时使用 5 分钟。
// StepDays is the path sampling step in days; values <= 0 use five minutes.
StepDays float64
// LunarLimb 月缘剖面,nil 为圆月面;只精修接触时刻,月盘仍按圆面绘制 / lunar limb profile for contacts.
LunarLimb LunarLimb
}
// LunarEclipseDiagramPoint 表示月食穿影图上的一个月心位置。
@@ -88,8 +90,12 @@ func lunarEclipseDiagram(
shadowModel lunarEclipseShadowModel,
options LunarEclipseDiagramOptions,
) LunarEclipseDiagramResult {
return lunarEclipseDiagramWithInstance(seedJDE, shadowModel, options, nil)
}
func lunarEclipseDiagramWithInstance(seedJDE float64, shadowModel lunarEclipseShadowModel, options LunarEclipseDiagramOptions, inst *Instance) LunarEclipseDiagramResult {
options = normalizeLunarEclipseDiagramOptions(options)
eclipse := lunarEclipse(seedJDE, shadowModel)
eclipse := LunarEclipseWithLunarLimb(lunarEclipseWithInstance(seedJDE, shadowModel, inst), options.LunarLimb, inst)
result := LunarEclipseDiagramResult{
Eclipse: eclipse,
StepDays: options.StepDays,
@@ -98,7 +104,7 @@ func lunarEclipseDiagram(
return result
}
maximumState := computeLunarShadowState(eclipse.Maximum, shadowModel)
maximumState := computeLunarShadowStateWithInstance(eclipse.Maximum, shadowModel, inst)
if maximumState.moonRadiusRad <= 0 {
return result
}
@@ -111,11 +117,11 @@ func lunarEclipseDiagram(
result.StepDays = stepDays
result.Points = make([]LunarEclipseDiagramPoint, 0, len(times))
for _, item := range times {
state := computeLunarShadowState(item.jde, shadowModel)
plane := lunarEclipsePlaneStateAtWithInstance(item.jde, inst)
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 +142,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 +187,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 {
+61
View File
@@ -0,0 +1,61 @@
package basic
import ()
// LunarEclipseWithInstance 指定星历实例的月食(TT,Danjon),计算失败时食甚为 NaN / lunar eclipse with an explicit instance; TT maximum is NaN on failure.
func LunarEclipseWithInstance(seedJDE float64, inst *Instance) LunarEclipseResult {
return LunarEclipseDanjonWithInstance(seedJDE, inst)
}
// LunarEclipseDanjonWithInstance 指定星历实例的 Danjon 月食 / Danjon lunar eclipse with an explicit ephemeris instance.
func LunarEclipseDanjonWithInstance(seedJDE float64, inst *Instance) LunarEclipseResult {
return lunarEclipseWithInstance(seedJDE, lunarEclipseShadowDanjon, inst)
}
// LunarEclipseChauvenetWithInstance 指定星历实例的 Chauvenet 月食 / Chauvenet lunar eclipse with an explicit ephemeris instance.
func LunarEclipseChauvenetWithInstance(seedJDE float64, inst *Instance) LunarEclipseResult {
return lunarEclipseWithInstance(seedJDE, lunarEclipseShadowChauvenet, inst)
}
func lunarEclipseWithInstance(seedJDE float64, shadowModel lunarEclipseShadowModel, inst *Instance) LunarEclipseResult {
if inst == nil {
return lunarEclipse(seedJDE, shadowModel)
}
fullMoonJDE := CalcMoonSHByJDEWithInstance(seedJDE, 1, inst)
return lunarEclipseFromFullMoon(fullMoonJDE, shadowModel, func(jde float64) lunarShadowState {
return computeLunarShadowStateWithInstance(jde, shadowModel, inst)
})
}
// LunarEclipseShadowGeometryAtWithInstance 指定星历实例的 Danjon 地影几何 / Danjon shadow geometry with an explicit ephemeris instance.
func LunarEclipseShadowGeometryAtWithInstance(maximumJDE float64, inst *Instance) LunarEclipseShadowGeometry {
return lunarEclipseShadowGeometryAtWithInstance(maximumJDE, lunarEclipseShadowDanjon, inst)
}
// LunarEclipseShadowGeometryChauvenetAtWithInstance 指定星历实例的 Chauvenet 地影几何 / Chauvenet shadow geometry with an explicit ephemeris instance.
func LunarEclipseShadowGeometryChauvenetAtWithInstance(maximumJDE float64, inst *Instance) LunarEclipseShadowGeometry {
return lunarEclipseShadowGeometryAtWithInstance(maximumJDE, lunarEclipseShadowChauvenet, inst)
}
// LunarEclipseShadowGeometryAtModelWithInstance 指定星历实例及影半径模型的地影几何 / shadow geometry with an explicit instance and shadow-radius model.
func LunarEclipseShadowGeometryAtModelWithInstance(maximumJDE float64, model LunarEclipseShadowModel, inst *Instance) LunarEclipseShadowGeometry {
if model == LunarEclipseShadowModelChauvenet {
return LunarEclipseShadowGeometryChauvenetAtWithInstance(maximumJDE, inst)
}
return LunarEclipseShadowGeometryAtWithInstance(maximumJDE, inst)
}
// LunarEclipseDiagramWithInstance 指定星历实例的 Danjon 月食穿影图 / Danjon lunar eclipse diagram with an explicit ephemeris instance.
func LunarEclipseDiagramWithInstance(seedJDE float64, options LunarEclipseDiagramOptions, inst *Instance) LunarEclipseDiagramResult {
return LunarEclipseDiagramDanjonWithInstance(seedJDE, options, inst)
}
// LunarEclipseDiagramDanjonWithInstance 指定星历实例的 Danjon 月食穿影图 / Danjon lunar eclipse diagram with an explicit ephemeris instance.
func LunarEclipseDiagramDanjonWithInstance(seedJDE float64, options LunarEclipseDiagramOptions, inst *Instance) LunarEclipseDiagramResult {
return lunarEclipseDiagramWithInstance(seedJDE, lunarEclipseShadowDanjon, options, inst)
}
// LunarEclipseDiagramChauvenetWithInstance 指定星历实例的 Chauvenet 月食穿影图 / Chauvenet lunar eclipse diagram with an explicit ephemeris instance.
func LunarEclipseDiagramChauvenetWithInstance(seedJDE float64, options LunarEclipseDiagramOptions, inst *Instance) LunarEclipseDiagramResult {
return lunarEclipseDiagramWithInstance(seedJDE, lunarEclipseShadowChauvenet, options, inst)
}
@@ -0,0 +1,162 @@
package basic
import (
"math"
"reflect"
"testing"
"b612.me/astro/internal/ephem"
"b612.me/astro/internal/geodata"
)
type p3LunarEngine struct {
epoch float64
moonDistance float64
}
func (engine p3LunarEngine) State(target, center ephem.Body, jde float64) ([3]float64, [3]float64, error) {
if center != ephem.Earth || target != ephem.Sun && target != ephem.Moon {
return [3]float64{}, [3]float64{}, ephem.ErrUnsupportedBody
}
longitude, latitude, distance := 0.0, 0.0, astronomicalUnitKM
if target == ephem.Moon {
longitude = math.Pi + 0.23*(jde-engine.epoch)
latitude = 0.002
distance = engine.moonDistance
}
ecliptic := [3]float64{distance * math.Cos(latitude) * math.Cos(longitude), distance * math.Cos(latitude) * math.Sin(longitude), distance * math.Sin(latitude)}
sine, cosine := math.Sincos(Obliquity1980(jde) * math.Pi / 180)
equatorial := [3]float64{ecliptic[0], ecliptic[1]*cosine - ecliptic[2]*sine, ecliptic[1]*sine + ecliptic[2]*cosine}
matrix := ltpPBMAT(2000 + (jde-2451545)/365.25)
var position [3]float64
for axis := range position {
position[axis] = matrix[0][axis]*equatorial[0] + matrix[1][axis]*equatorial[1] + matrix[2][axis]*equatorial[2]
}
return position, [3]float64{}, nil
}
func (engine p3LunarEngine) Coverage(target ephem.Body) (float64, float64, bool) {
return engine.epoch - 20, engine.epoch + 20, target == ephem.Sun || target == ephem.Moon
}
func p3AssertLunarResultEqual(t *testing.T, got, want LunarEclipseResult) {
t.Helper()
gotValue, wantValue := reflect.ValueOf(got), reflect.ValueOf(want)
for field := 0; field < gotValue.NumField(); field++ {
gotField, wantField := gotValue.Field(field), wantValue.Field(field)
if gotField.Kind() == reflect.Float64 && math.IsNaN(gotField.Float()) && math.IsNaN(wantField.Float()) {
continue
}
if !reflect.DeepEqual(gotField.Interface(), wantField.Interface()) {
t.Errorf("%s: got %v, want %v", gotValue.Type().Field(field).Name, gotField.Interface(), wantField.Interface())
}
}
}
func TestP3LunarEclipseBuiltInCompatibility(t *testing.T) {
previous := ephem.Active()
t.Cleanup(func() { ephem.SetActive(previous) })
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
ephem.SetActive(model)
inst := ephem.NewModelInstance(model)
for _, date := range [][3]int{{2022, 11, 8}, {2023, 5, 5}, {2023, 10, 28}, {2024, 4, 23}} {
seed := JDCalc(date[0], date[1], float64(date[2]))
for _, calculate := range []struct {
old func(float64) LunarEclipseResult
new func(float64, *ephem.Instance) LunarEclipseResult
}{
{LunarEclipse, LunarEclipseWithInstance},
{LunarEclipseDanjon, LunarEclipseDanjonWithInstance},
{LunarEclipseChauvenet, LunarEclipseChauvenetWithInstance},
} {
want := calculate.old(seed)
p3AssertLunarResultEqual(t, calculate.new(seed, nil), want)
p3AssertLunarResultEqual(t, calculate.new(seed, inst), want)
}
}
}
}
func TestP3LunarEclipseInstancesStayIsolated(t *testing.T) {
const seed = 2459891.9
first := ephem.NewInstance(p3LunarEngine{epoch: seed, moonDistance: 384400})
second := ephem.NewInstance(p3LunarEngine{epoch: seed + 0.25, moonDistance: 370000})
global, model, generation := ephem.Global(), ephem.Active(), ephem.Generation()
firstResult := LunarEclipseWithInstance(seed, first)
secondResult := LunarEclipseWithInstance(seed, second)
if !firstResult.HasTotal || !secondResult.HasTotal {
t.Fatalf("synthetic eclipses must be total: %+v / %+v", firstResult, secondResult)
}
if math.Abs(secondResult.Maximum-firstResult.Maximum-0.25) > 1e-6 {
t.Errorf("maximum did not follow instance: %.12f / %.12f", firstResult.Maximum, secondResult.Maximum)
}
if firstResult.TotalEnd-firstResult.TotalStart == secondResult.TotalEnd-secondResult.TotalStart {
t.Error("contact durations did not follow instance distances")
}
for _, result := range []LunarEclipseResult{firstResult, secondResult} {
contacts := []float64{result.PenumbralStart, result.PartialStart, result.TotalStart, result.Maximum, result.TotalEnd, result.PartialEnd, result.PenumbralEnd}
for index := 1; index < len(contacts); index++ {
if !isFiniteFloat(contacts[index]) || contacts[index] <= contacts[index-1] {
t.Errorf("invalid contact order: %v", contacts)
}
}
}
p3AssertLunarResultEqual(t, LunarEclipseWithInstance(seed, first), firstResult)
if ephem.Global() != global || ephem.Active() != model || ephem.Generation() != generation {
t.Error("explicit lunar eclipse changed the global ephemeris")
}
}
func TestP3LunarEclipseGeometryAndDiagramUseInstance(t *testing.T) {
const seed = 2459891.9
options := LunarEclipseDiagramOptions{StepDays: 0.02}
for _, shadowModel := range []LunarEclipseShadowModel{LunarEclipseShadowModelDanjon, LunarEclipseShadowModelChauvenet} {
inst := ephem.NewInstance(p3LunarEngine{epoch: seed + 0.15, moonDistance: 380000})
calculate := LunarEclipseDiagramDanjonWithInstance
if shadowModel == LunarEclipseShadowModelChauvenet {
calculate = LunarEclipseDiagramChauvenetWithInstance
}
diagram := calculate(seed, options, inst)
geometry := LunarEclipseShadowGeometryAtModelWithInstance(diagram.Eclipse.Maximum, shadowModel, inst)
if len(diagram.Points) == 0 || math.Abs(geometry.MoonDistanceEarthRadii-380000/geodata.EarthEquatorialRadiusKM) > 1e-10 {
t.Fatalf("diagram/geometry lost instance: %+v", geometry)
}
if math.Abs(diagram.Eclipse.Maximum-seed-0.15) > 0.001 {
t.Errorf("diagram eclipse lost instance: %v", diagram.Eclipse.Maximum)
}
maximumState := computeLunarShadowStateWithInstance(diagram.Eclipse.Maximum, lunarEclipseShadowModel(shadowModel), inst)
for _, point := range diagram.Points {
plane := lunarEclipsePlaneStateAtWithInstance(point.JDE, inst)
if point.X != plane.x/maximumState.moonRadiusRad || point.Y != plane.y/maximumState.moonRadiusRad {
t.Errorf("diagram sample lost instance: %+v", point)
}
}
}
for _, calculate := range []struct {
old func(float64, LunarEclipseDiagramOptions) LunarEclipseDiagramResult
new func(float64, LunarEclipseDiagramOptions, *ephem.Instance) LunarEclipseDiagramResult
}{
{LunarEclipseDiagram, LunarEclipseDiagramWithInstance},
{LunarEclipseDiagramDanjon, LunarEclipseDiagramDanjonWithInstance},
{LunarEclipseDiagramChauvenet, LunarEclipseDiagramChauvenetWithInstance},
} {
got, want := calculate.new(seed, options, nil), calculate.old(seed, options)
p3AssertLunarResultEqual(t, got.Eclipse, want.Eclipse)
got.Eclipse, want.Eclipse = LunarEclipseResult{}, LunarEclipseResult{}
if !reflect.DeepEqual(got, want) {
t.Error("nil changed diagram geometry or samples")
}
}
}
func TestP3LunarEclipseFailureDoesNotFallback(t *testing.T) {
inst := ephem.NewInstance(p3StateEngine{err: ephem.ErrOutOfRange})
result := LunarEclipseWithInstance(2459891.9, inst)
if !math.IsNaN(result.Maximum) || result.HasPenumbral || result.HasPartial || result.HasTotal {
t.Errorf("failed engine produced an eclipse: %+v", result)
}
diagram := LunarEclipseDiagramWithInstance(2459891.9, LunarEclipseDiagramOptions{}, inst)
if !math.IsNaN(diagram.Eclipse.Maximum) || len(diagram.Points) != 0 {
t.Error("failed engine produced diagram samples")
}
}
+80
View File
@@ -0,0 +1,80 @@
package basic
import (
"math"
"b612.me/astro/internal/geodata"
)
// 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 {
return lunarEclipseShadowGeometryAtWithInstance(maximumJDE, shadowModel, nil)
}
func lunarEclipseShadowGeometryAtWithInstance(maximumJDE float64, shadowModel lunarEclipseShadowModel, inst *Instance) LunarEclipseShadowGeometry {
evaluate := func(jde float64) lunarShadowState {
return computeLunarShadowStateWithInstance(jde, shadowModel, inst)
}
state := evaluate(maximumJDE)
moonDistanceKM := HMoonAwayWithInstance(maximumJDE, inst)
// 影半径是以地心为顶点的角量,除以月球处的地球视差就换成地球赤道半径。
earthParallax := geodata.EarthEquatorialRadiusKM / moonDistanceKM
_, _, _, _, minimumDistance := refineLunarEclipseMaximumWithEvaluator(maximumJDE, evaluate)
if earthParallax <= 0 {
return LunarEclipseShadowGeometry{}
}
return LunarEclipseShadowGeometry{
Gamma: minimumDistance / earthParallax,
PenumbralRadiusDegrees: state.penumbraRadiusRad * 180 / math.Pi,
UmbralRadiusDegrees: state.umbraRadiusRad * 180 / math.Pi,
MoonDistanceEarthRadii: moonDistanceKM / geodata.EarthEquatorialRadiusKM,
// 平面 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)
}
}
}
+147
View File
@@ -0,0 +1,147 @@
package basic
import "math"
// lunarEclipseLimbMaxShiftDays 是月缘精修允许偏离圆月面接触的上限(10 分钟),超出视为不收敛并保留原值。
const lunarEclipseLimbMaxShiftDays = 600.0 / 86400.0
// lunarEclipseLimbStepDays 是割线法的初始步长(1 秒)。
const lunarEclipseLimbStepDays = 1.0 / 86400.0
// LunarEclipseWithLunarLimb 用月缘剖面精修月食接触时刻,食甚、食分与食型仍按圆月面 / refine lunar eclipse contacts with a lunar limb profile.
func LunarEclipseWithLunarLimb(result LunarEclipseResult, limb LunarLimb, inst *Instance) LunarEclipseResult {
if limb == nil {
return result
}
original := result
shadowModel := lunarEclipseShadowDanjon
if result.ShadowModel == LunarEclipseShadowModelChauvenet {
shadowModel = lunarEclipseShadowChauvenet
}
evaluate := func(jde float64) lunarShadowState {
return computeLunarShadowStateWithInstance(jde, shadowModel, inst)
}
refine := func(seed float64, kind lunarEclipseLimbContact) float64 {
if !finite(seed) {
return seed
}
return refineLunarEclipseLimbContact(seed, kind, limb, evaluate, inst)
}
if result.HasPenumbral {
result.PenumbralStart = refine(result.PenumbralStart, lunarEclipseLimbPenumbral)
result.PenumbralEnd = refine(result.PenumbralEnd, lunarEclipseLimbPenumbral)
}
if result.HasPartial {
result.PartialStart = refine(result.PartialStart, lunarEclipseLimbUmbral)
result.PartialEnd = refine(result.PartialEnd, lunarEclipseLimbUmbral)
}
if result.HasTotal {
result.TotalStart = refine(result.TotalStart, lunarEclipseLimbTotal)
result.TotalEnd = refine(result.TotalEnd, lunarEclipseLimbTotal)
}
result.LunarLimbApplied = lunarEclipseLimbChanged(result, original)
return result
}
func lunarEclipseLimbChanged(result, original LunarEclipseResult) bool {
for _, pair := range [][2]float64{
{result.PenumbralStart, original.PenumbralStart},
{result.PenumbralEnd, original.PenumbralEnd},
{result.PartialStart, original.PartialStart},
{result.PartialEnd, original.PartialEnd},
{result.TotalStart, original.TotalStart},
{result.TotalEnd, original.TotalEnd},
} {
if limbTimeChanged(pair[0], pair[1]) {
return true
}
}
return false
}
type lunarEclipseLimbContact int
const (
lunarEclipseLimbPenumbral lunarEclipseLimbContact = iota
lunarEclipseLimbUmbral
lunarEclipseLimbTotal
)
// refineLunarEclipseLimbContact 以圆月面接触为初值用割线法求月缘间隙的零点,不收敛时返回初值。
func refineLunarEclipseLimbContact(seed float64, kind lunarEclipseLimbContact, limb LunarLimb, evaluate lunarShadowEvaluator, inst *Instance) float64 {
t0, t1 := seed, seed+lunarEclipseLimbStepDays
g0, ok0 := lunarEclipseLimbGap(t0, kind, limb, evaluate, inst)
g1, ok1 := lunarEclipseLimbGap(t1, kind, limb, evaluate, inst)
if !ok0 || !ok1 {
return seed
}
for i := 0; i < 12; i++ {
if g1 == g0 {
return seed
}
t2 := t1 - g1*(t1-t0)/(g1-g0)
if !finite(t2) || math.Abs(t2-seed) > lunarEclipseLimbMaxShiftDays {
return seed
}
if math.Abs(t2-t1) < 1e-9 {
return t2
}
g2, ok := lunarEclipseLimbGap(t2, kind, limb, evaluate, inst)
if !ok {
return seed
}
t0, g0, t1, g1 = t1, g1, t2, g2
}
return seed
}
// lunarEclipseLimbGap 是月缘版接触间隙(弧度),零点即接触。
func lunarEclipseLimbGap(jde float64, kind lunarEclipseLimbContact, limb LunarLimb, evaluate lunarShadowEvaluator, inst *Instance) (float64, bool) {
state := evaluate(jde)
moonRA, moonDec := HMoonGeocentricApparentRaDecWithInstance(jde, inst)
sunRA, sunDec := HSunApparentRaDecWithInstance(jde, inst)
moon := lunarEclipseLimbVector(moonRA, moonDec, HMoonAwayWithInstance(jde, inst))
shadow := lunarEclipseLimbVector(sunRA+180, -sunDec, 1)
if !finite(state.moonRadiusRad) || state.moonRadiusRad <= 0 {
return 0, false
}
frame := newLunarLimbFrame(limb, jde, moon, inst)
view, ok := frame.viewToward(moon, shadow)
if !ok {
return 0, false
}
separation := math.Hypot(state.x, state.y)
contact := lunarLimbContact{
frame: &frame,
view: view,
separation: separation,
anglePerKM: state.moonRadiusRad / lunarMoonRadiusScale,
}
switch kind {
case lunarEclipseLimbPenumbral:
contact.targetRadius = state.penumbraRadiusRad
default:
contact.targetRadius = state.umbraRadiusRad
}
// 半影、本影外切取全方向最外的月缘;全食内切取全方向最靠外、最后进入本影的月缘。
var limbRad float64
if kind == lunarEclipseLimbTotal {
limbRad, ok = contact.innerRadius(false)
} else {
limbRad, ok = contact.outerRadius()
}
if !ok {
return 0, false
}
if kind == lunarEclipseLimbTotal {
return separation - math.Abs(limbRad-contact.targetRadius), true
}
return separation - limbRad - contact.targetRadius, true
}
func lunarEclipseLimbVector(raDeg, decDeg, distance float64) [3]float64 {
sinRA, cosRA := math.Sincos(raDeg * rad)
sinDec, cosDec := math.Sincos(decDeg * rad)
return [3]float64{distance * cosDec * cosRA, distance * cosDec * sinRA, distance * sinDec}
}
+58 -25
View File
@@ -3,6 +3,8 @@ package basic
import (
"math"
"testing"
"b612.me/astro/internal/ephemtest"
)
type lunarEclipseBaseline struct {
@@ -27,7 +29,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 +42,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 +60,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 +78,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 +91,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 +104,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,
@@ -117,10 +119,20 @@ func TestLunarEclipseChauvenetAgainstLegacyBaseline(t *testing.T) {
const timeTolerance = 1e-6
const magnitudeTolerance = 2e-5
values := ephemtest.NewValues(t, "lunar_eclipse_chauvenet")
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := LunarEclipseChauvenet(tc.jde)
w := func(field string, legacy, got float64) float64 { return values.Want(tc.name+"/"+field, legacy, got) }
tc.expectedMax = w("Maximum", tc.expectedMax, result.Maximum)
tc.expectedMag = w("Magnitude", tc.expectedMag, result.Magnitude)
tc.expectedPenumbralStart = w("PenumbralStart", tc.expectedPenumbralStart, result.PenumbralStart)
tc.expectedPenumbralEnd = w("PenumbralEnd", tc.expectedPenumbralEnd, result.PenumbralEnd)
tc.expectedPartialStart = w("PartialStart", tc.expectedPartialStart, result.PartialStart)
tc.expectedPartialEnd = w("PartialEnd", tc.expectedPartialEnd, result.PartialEnd)
tc.expectedTotalStart = w("TotalStart", tc.expectedTotalStart, result.TotalStart)
tc.expectedTotalEnd = w("TotalEnd", tc.expectedTotalEnd, result.TotalEnd)
if result.Type != tc.expectedType {
t.Fatalf("Type mismatch: got %s want %s", result.Type, tc.expectedType)
@@ -133,12 +145,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 +166,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 +188,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 +222,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 +289,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 +327,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)
}
+424
View File
@@ -0,0 +1,424 @@
package basic
import "math"
// LunarLimb 月缘剖面,nil 为圆月面,内置数据表在 moon/limb / lunar limb profile; nil means a circular limb.
type LunarLimb interface {
// LimbRadiusKM 按观测者总天平动经纬(度,东正北正)与自投影月北极起算的位置角(度)返回月心到月缘距离(km),无值时第二个返回 false / limb radius at a position angle for the given libration.
LimbRadiusKM(librationLongitude, librationLatitude, positionAngle float64) (float64, bool)
}
// lunarLimbFrame 是某一瞬间的月球本体坐标架在真赤道坐标系下的表示,
// 由该时刻的地心天平动与自转轴位置角反解,可对任意站心方向复用。
type lunarLimbFrame struct {
limb LunarLimb
// stepDeg 是剖面位置角网格步长;ranged 为真时 minKM/maxKM 是剖面可能取到的半径范围。
stepDeg float64
minKM, maxKM float64
ranged bool
// axisX / axisY / axisZ 分别是月面经纬 (0°,0°)、(90°,0°) 与北极方向的单位矢量。
axisX [3]float64
axisY [3]float64
axisZ [3]float64
valid bool
}
// newLunarLimbFrame 用地心月球方向与该时刻的物理天平动构造本体坐标架。
// moonGeocentric 是真赤道坐标系下的地心月球矢量(km)。
func newLunarLimbFrame(limb LunarLimb, jdTT float64, moonGeocentric [3]float64, instances ...*Instance) lunarLimbFrame {
if limb == nil {
return lunarLimbFrame{}
}
inst := instanceValue(instances)
toEarth, ok := lunarLimbNormalize([3]float64{-moonGeocentric[0], -moonGeocentric[1], -moonGeocentric[2]})
if !ok {
return lunarLimbFrame{}
}
physical := MoonPhysicalWithInstance(jdTT, inst)
north, east, ok := lunarLimbSkyBasis([3]float64{-toEarth[0], -toEarth[1], -toEarth[2]})
if !ok {
return lunarLimbFrame{}
}
// 月球北极在天空平面上的投影自天北起向东量 P,于是极轴方向 = sinB·(月心→地心) + cosB·N。
sinP, cosP := math.Sincos(physical.PositionAngle * rad)
projected := lunarLimbCombine(cosP, north, sinP, east)
sinB, cosB := math.Sincos(physical.LibrationLatitude * rad)
axisZ, ok := lunarLimbNormalize(lunarLimbCombine(sinB, toEarth, cosB, projected))
if !ok {
return lunarLimbFrame{}
}
// 地心方向在赤道面内的分量对应本体系的 (cosL, sinL, 0),据此旋回 x 轴。
meridian, ok := lunarLimbNormalize(lunarLimbReject(toEarth, axisZ))
if !ok {
return lunarLimbFrame{}
}
sinL, cosL := math.Sincos(physical.LibrationLongitude * rad)
axisX, ok := lunarLimbNormalize(lunarLimbCombine(cosL, meridian, -sinL, lunarLimbCross(axisZ, meridian)))
if !ok {
return lunarLimbFrame{}
}
frame := lunarLimbFrame{
limb: limb,
stepDeg: lunarLimbDefaultStepDeg,
axisX: axisX,
axisY: lunarLimbCross(axisZ, axisX),
axisZ: axisZ,
valid: true,
}
if ranged, ok := limb.(lunarLimbRangeProvider); ok {
low, high := ranged.RadiusRangeKM()
if step := ranged.AngleStepDeg(); finite(step) && step >= lunarLimbMinStepDeg && step <= 1 {
frame.stepDeg = step
}
if finite(low) && finite(high) && low > 0 && high >= low {
frame.minKM, frame.maxKM, frame.ranged = low, high, true
}
}
return frame
}
// radiusKM 返回站心所见月缘在指定方向上的月心距(km);awayFromTarget 为真时取背向目标的一侧。
func (frame lunarLimbFrame) radiusKM(moonTopocentric, targetTopocentric [3]float64, awayFromTarget bool) (float64, bool) {
if !frame.valid {
return 0, false
}
moon, ok := lunarLimbNormalize(moonTopocentric)
if !ok {
return 0, false
}
target, ok := lunarLimbNormalize(targetTopocentric)
if !ok {
return 0, false
}
north, east, ok := lunarLimbSkyBasis(moon)
if !ok {
return 0, false
}
toObserver := [3]float64{-moon[0], -moon[1], -moon[2]}
longitude := math.Atan2(lunarLimbDot(toObserver, frame.axisY), lunarLimbDot(toObserver, frame.axisX)) / rad
latitude := math.Asin(clampUnit(lunarLimbDot(toObserver, frame.axisZ))) / rad
// 自转轴位置角 P 与目标方向位置角 Q 都自天北向东量;剖面表的 θ 自月球北极向月面东经一侧
// (从地球看是天西)量,所以 θ = P − Q。
axisAngle := math.Atan2(lunarLimbDot(frame.axisZ, east), lunarLimbDot(frame.axisZ, north))
targetPlane, ok := lunarLimbNormalize(lunarLimbReject(target, moon))
if !ok {
return 0, false
}
targetAngle := math.Atan2(lunarLimbDot(targetPlane, east), lunarLimbDot(targetPlane, north))
positionAngle := (axisAngle - targetAngle) / rad
if awayFromTarget {
positionAngle += 180
}
positionAngle = math.Mod(positionAngle, 360)
if positionAngle < 0 {
positionAngle += 360
}
radius, ok := frame.limb.LimbRadiusKM(longitude, latitude, positionAngle)
if !ok || !finite(radius) || radius <= 0 {
return 0, false
}
return radius, true
}
// lunarLimbView 是站心所见的月面经纬与指向目标方向的剖面位置角(度)。
type lunarLimbView struct {
longitude, latitude, angle float64
}
// viewToward 求站心所见天平动与目标方向的剖面位置角,约定与 radiusKM 相同。
func (frame lunarLimbFrame) viewToward(moonTopocentric, targetTopocentric [3]float64) (lunarLimbView, bool) {
if !frame.valid {
return lunarLimbView{}, false
}
moon, ok := lunarLimbNormalize(moonTopocentric)
if !ok {
return lunarLimbView{}, false
}
target, ok := lunarLimbNormalize(targetTopocentric)
if !ok {
return lunarLimbView{}, false
}
north, east, ok := lunarLimbSkyBasis(moon)
if !ok {
return lunarLimbView{}, false
}
toObserver := [3]float64{-moon[0], -moon[1], -moon[2]}
axisAngle := math.Atan2(lunarLimbDot(frame.axisZ, east), lunarLimbDot(frame.axisZ, north))
targetPlane, ok := lunarLimbNormalize(lunarLimbReject(target, moon))
if !ok {
return lunarLimbView{}, false
}
targetAngle := math.Atan2(lunarLimbDot(targetPlane, east), lunarLimbDot(targetPlane, north))
return lunarLimbView{
longitude: math.Atan2(lunarLimbDot(toObserver, frame.axisY), lunarLimbDot(toObserver, frame.axisX)) / rad,
latitude: math.Asin(clampUnit(lunarLimbDot(toObserver, frame.axisZ))) / rad,
angle: (axisAngle - targetAngle) / rad,
}, true
}
// radiusAt 返回剖面位置角偏离目标方向 offset 度处的月缘月心距(km)。
func (frame lunarLimbFrame) radiusAt(view lunarLimbView, offset float64) (float64, bool) {
angle := math.Mod(view.angle+offset, 360)
if angle < 0 {
angle += 360
}
radius, ok := frame.limb.LimbRadiusKM(view.longitude, view.latitude, angle)
if !ok || !finite(radius) || radius <= 0 {
return 0, false
}
return radius, true
}
// lunarLimbSkyBasis 返回视方向 direction(观测者指向天体)处的天球北、东单位矢量;
// 东向为赤经增加方向,e = n × direction。
func lunarLimbSkyBasis(direction [3]float64) ([3]float64, [3]float64, bool) {
north, ok := lunarLimbNormalize(lunarLimbReject([3]float64{0, 0, 1}, direction))
if !ok {
return [3]float64{}, [3]float64{}, false
}
return north, lunarLimbCross(north, direction), true
}
func lunarLimbNormalize(vector [3]float64) ([3]float64, bool) {
norm := math.Sqrt(vector[0]*vector[0] + vector[1]*vector[1] + vector[2]*vector[2])
if !finite(norm) || norm <= 0 {
return [3]float64{}, false
}
return [3]float64{vector[0] / norm, vector[1] / norm, vector[2] / norm}, true
}
// lunarLimbReject 返回 vector 中垂直于单位矢量 axis 的分量。
func lunarLimbReject(vector, axis [3]float64) [3]float64 {
projection := lunarLimbDot(vector, axis)
return [3]float64{
vector[0] - projection*axis[0],
vector[1] - projection*axis[1],
vector[2] - projection*axis[2],
}
}
func lunarLimbCombine(scaleA float64, a [3]float64, scaleB float64, b [3]float64) [3]float64 {
return [3]float64{
scaleA*a[0] + scaleB*b[0],
scaleA*a[1] + scaleB*b[1],
scaleA*a[2] + scaleB*b[2],
}
}
func lunarLimbCross(a, b [3]float64) [3]float64 {
return [3]float64{
a[1]*b[2] - a[2]*b[1],
a[2]*b[0] - a[0]*b[2],
a[0]*b[1] - a[1]*b[0],
}
}
func lunarLimbDot(a, b [3]float64) float64 {
return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]
}
// lunarLimbDefaultStepDeg 剖面不报告网格步长时的位置角采样步长(度)。
const lunarLimbDefaultStepDeg = 0.1
// lunarLimbMinStepDeg 剖面报告的步长低于此值时退回默认步长,防止全圈扫描次数失控。
const lunarLimbMinStepDeg = 0.001
// lunarLimbRangeProvider 可选接口:报告剖面半径范围与位置角网格步长,用于门控与剪枝;步长不在 [0.001°, 1°] 内时改用默认 0.1°。
type lunarLimbRangeProvider interface {
RadiusRangeKM() (float64, float64)
AngleStepDeg() float64
}
// lunarLimbContact 是某一站心时刻按全部位置角求日面接触余量的上下文,角量均为弧度,在天空平面上按平直几何计。
type lunarLimbContact struct {
frame *lunarLimbFrame
view lunarLimbView
distanceKM float64
separation float64
targetRadius float64
// anglePerKM 非零时月缘半径按线性比例换算成角量(月食影面坐标);为零时按 asin(r/distanceKM)。
anglePerKM float64
}
// radius 返回该方向月缘角半径;exact 为假时用 r/距离 的线性近似,只供扫描选方向,极值处再按 exact 重算。
func (contact lunarLimbContact) radius(offsetDeg float64, exact bool) (float64, bool) {
radiusKM, ok := contact.frame.radiusAt(contact.view, offsetDeg)
if !ok {
return 0, false
}
if contact.anglePerKM != 0 {
return radiusKM * contact.anglePerKM, true
}
if !exact {
return radiusKM / contact.distanceKM, true
}
return math.Asin(localSolarEclipseClampUnit(radiusKM / contact.distanceKM)), true
}
// bounds 返回剖面半径的角量上下界;剖面不报告范围时 ok 为假,此时不门控、不剪枝。
func (contact lunarLimbContact) bounds() (float64, float64, bool) {
if !contact.frame.ranged {
return 0, 0, false
}
if contact.anglePerKM != 0 {
return contact.frame.minKM * contact.anglePerKM, contact.frame.maxKM * contact.anglePerKM, true
}
return contact.frame.minKM / contact.distanceKM, contact.frame.maxKM / contact.distanceKM, true
}
// rayChord 返回自月心沿偏离日心方向 δ 的射线与日面边界的近、远交点距离;射线不交日面时 ok 为假。
func (contact lunarLimbContact) rayChord(offsetDeg float64) (float64, float64, bool) {
sine, cosine := math.Sincos(offsetDeg * rad)
d, s := contact.separation, contact.targetRadius
discriminant := s*s - d*d*sine*sine
if discriminant < 0 {
return 0, 0, false
}
root := math.Sqrt(discriminant)
return d*cosine - root, d*cosine + root, true
}
// scan 自 baseDeg 向两侧按剖面位置角网格节点求余量极值并在极值方向按精确半径重算;maximize 为真取最大。剖面在节点间按位置角线性插值,
// 只取节点与 baseDeg 即不会漏掉剖面峰谷。bound(δ) 给出该方向余量可能达到的最好值,越过它不可能再刷新极值即停止该侧。
func (contact lunarLimbContact) scan(
baseDeg float64,
maximize bool,
margin func(offsetDeg float64, exact bool) (float64, bool, bool),
bound func(offsetDeg float64) (float64, bool),
) (float64, bool) {
best, bestOffset, found := 0.0, baseDeg, false
visit := func(offset float64) bool {
value, hit, ok := margin(offset, false)
if !ok {
return false
}
if hit && (!found || (maximize && value > best) || (!maximize && value < best)) {
best, bestOffset, found = value, offset, true
}
return true
}
if !visit(baseDeg) {
return 0, false
}
step := contact.frame.stepDeg
absolute := contact.view.angle + baseDeg
first := [2]float64{math.Floor(absolute/step)*step - absolute, math.Ceil(absolute/step)*step - absolute}
steps := int(math.Ceil(180/step)) + 1
for side, sign := range [2]float64{-1, 1} {
for index := 0; index <= steps; index++ {
delta := first[side] + sign*float64(index)*step
if delta == 0 || math.Abs(delta) > 180 {
continue
}
offset := baseDeg + delta
if limit, ok := bound(offset); ok && found &&
((maximize && limit <= best) || (!maximize && limit >= best)) {
break
}
if !visit(offset) {
return 0, false
}
}
}
if !found {
return 0, false
}
exact, _, ok := margin(bestOffset, true)
return exact, ok
}
// outerRadius 返回外切等效月半径(弧度):取全部方向中月缘超出日面近侧边界最多者,使 d − R − s 为零即外切。
// 只有剖面范围保证间隙离零至少一个起伏幅度时才跳过扫描,免得门控两侧的间隙在接触根附近不连续。
func (contact lunarLimbContact) outerRadius() (float64, bool) {
d, s := contact.separation, contact.targetRadius
low, high, ranged := contact.bounds()
if d <= s || (ranged && (high <= d-s-(high-low) || low >= d-s+(high-low))) {
return contact.radius(0, true)
}
best, ok := contact.scan(0, true,
func(offset float64, exact bool) (float64, bool, bool) {
near, _, hit := contact.rayChord(offset)
if !hit {
return 0, false, true
}
radius, ok := contact.radius(offset, exact)
return radius - near, true, ok
},
func(offset float64) (float64, bool) {
near, _, hit := contact.rayChord(offset)
if !hit {
return math.Inf(-1), true
}
return high - near, ranged
})
if !ok {
return 0, false
}
return d - s + best, true
}
// innerRadius 返回内切等效月半径(弧度),使内切间隙 d − |R − s| 等于 min(−余量, d)。total 为真按全食求月缘盖住日面
// 远侧边界的最小余量,否则按环食求日面远侧边界超出月缘的最小余量;门控条件同 outerRadius。
func (contact lunarLimbContact) innerRadius(total bool) (float64, bool) {
d, s := contact.separation, contact.targetRadius
low, high, ranged := contact.bounds()
relief := high - low
if total {
if ranged && (low >= d+s+relief || high <= d+s-relief) {
radius, ok := contact.radius(0, true)
return s + math.Max(radius-s, 0), ok
}
best, ok := contact.scan(0, false,
func(offset float64, exact bool) (float64, bool, bool) {
_, far, hit := contact.rayChord(offset)
if !hit {
return 0, false, true
}
radius, ok := contact.radius(offset, exact)
return radius - far, true, ok
},
func(offset float64) (float64, bool) {
_, far, hit := contact.rayChord(offset)
if !hit {
return math.Inf(1), true
}
return low - far, ranged
})
if !ok {
return 0, false
}
return s + math.Max(d+best, 0), true
}
// 月心在日面外时环食不可能,内切间隙恰为 d。
if d >= s {
return s, true
}
if ranged && (high <= s-d-relief || low >= s-d+relief) {
radius, ok := contact.radius(180, true)
return math.Min(radius, s), ok
}
best, ok := contact.scan(180, false,
func(offset float64, exact bool) (float64, bool, bool) {
_, far, hit := contact.rayChord(offset)
if !hit {
return 0, false, true
}
radius, ok := contact.radius(offset, exact)
return far - radius, true, ok
},
func(offset float64) (float64, bool) {
_, far, hit := contact.rayChord(offset)
if !hit {
return math.Inf(1), true
}
return far - high, ranged
})
if !ok {
return 0, false
}
return s - math.Max(d+best, 0), true
}
+105
View File
@@ -0,0 +1,105 @@
package basic
// 月缘路径基准:与同一调用的圆月面版本成对,改动扫描步长或剪枝时对照两者倍率。
import (
"testing"
"time"
"b612.me/astro/moon/limb"
)
func benchmarkLocalSolarEclipseLimb(b *testing.B, profile LunarLimb) {
seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC))
options := SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK, LunarLimb: profile}
b.ResetTimer()
for index := 0; index < b.N; index++ {
LocalSolarEclipseWithOptionsWithInstance(seed, 116.2310, 39.70, 0, options, nil)
}
}
func BenchmarkLocalSolarEclipseCircular20350902(b *testing.B) { benchmarkLocalSolarEclipseLimb(b, nil) }
func BenchmarkLocalSolarEclipseLunarLimb20350902(b *testing.B) {
benchmarkLocalSolarEclipseLimb(b, limb.Default())
}
func benchmarkStarOccultationLimb(b *testing.B, profile LunarLimb) {
start := time.Date(2026, 2, 10, 12, 0, 0, 0, time.UTC)
options := OccultationSearchOptions{LunarLimb: profile}
b.ResetTimer()
for index := 0; index < b.N; index++ {
if _, err := FindStarOccultationsWithInstance(start, start.Add(36*time.Hour), limbTestAntares, 41.5907, -55.07036, 0, options, nil); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkStarOccultationCircularAntares(b *testing.B) { benchmarkStarOccultationLimb(b, nil) }
func BenchmarkStarOccultationLunarLimbAntares(b *testing.B) {
benchmarkStarOccultationLimb(b, limb.Default())
}
func benchmarkPlanetOccultationLimb(b *testing.B, profile LunarLimb) {
start := time.Date(2024, time.August, 21, 1, 30, 0, 0, time.UTC)
end := time.Date(2024, time.August, 21, 4, 0, 0, 0, time.UTC)
options := OccultationSearchOptions{LunarLimb: profile}
b.ResetTimer()
for index := 0; index < b.N; index++ {
if _, err := FindPlanetOccultationsWithInstance(start, end, OccultationSaturn, -30.072, 43.562592, 0, options, nil); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkPlanetOccultationCircularSaturnGraze(b *testing.B) {
benchmarkPlanetOccultationLimb(b, nil)
}
func BenchmarkPlanetOccultationLunarLimbSaturnGraze(b *testing.B) {
benchmarkPlanetOccultationLimb(b, limb.Default())
}
func BenchmarkStarOccultationLimbProfileAntares(b *testing.B) {
info := StarOccultationInfo{Greatest: time.Date(2026, 2, 11, 3, 47, 15, 0, time.UTC), Observer: Observer{Longitude: 41.5907, Latitude: -55.07036}}
profile := limb.Default()
b.ResetTimer()
for index := 0; index < b.N; index++ {
if _, err := StarOccultationLimbProfileWithInstance(info, limbTestAntares, profile, StarOccultationLimbProfileOptions{}, nil); err != nil {
b.Fatal(err)
}
}
}
func benchmarkSolarEclipseCentralPathLimb(b *testing.B, profile LunarLimb) {
seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC))
b.ResetTimer()
for index := 0; index < b.N; index++ {
SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{LunarLimb: profile}, nil)
}
}
func BenchmarkSolarEclipseCentralPathCircular20350902(b *testing.B) {
benchmarkSolarEclipseCentralPathLimb(b, nil)
}
func BenchmarkSolarEclipseCentralPathLunarLimb20350902(b *testing.B) {
benchmarkSolarEclipseCentralPathLimb(b, limb.Default())
}
func benchmarkSolarEclipsePartialFootprintsLimb(b *testing.B, profile LunarLimb) {
seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC))
b.ResetTimer()
for index := 0; index < b.N; index++ {
SolarEclipsePartialFootprintsNASABulletinSplitKWithInstance(seed, SolarEclipsePartialFootprintOptions{LunarLimb: profile}, nil)
}
}
func BenchmarkSolarEclipsePartialFootprintsCircular20350902(b *testing.B) {
benchmarkSolarEclipsePartialFootprintsLimb(b, nil)
}
func BenchmarkSolarEclipsePartialFootprintsLunarLimb20350902(b *testing.B) {
benchmarkSolarEclipsePartialFootprintsLimb(b, limb.Default())
}
+277
View File
@@ -0,0 +1,277 @@
package basic
// 月缘等效半径必须与全部位置角的暴力判据一致,且站心全食与中心带多边形对同一地点结论一致。
import (
"math"
"testing"
"time"
"b612.me/astro/internal/geodata"
"b612.me/astro/moon/limb"
)
func limbBruteMargins(contact lunarLimbContact) (outer, cover, inside float64) {
outer, cover, inside = math.Inf(-1), math.Inf(1), math.Inf(1)
step := contact.frame.stepDeg / 4
start := math.Ceil(contact.view.angle/step)*step - contact.view.angle
for offset := start - 180; offset < 180; offset += step {
radius, ok := contact.radius(offset, true)
if !ok {
continue
}
// 远交点为负的射线背向日面,不是真实交点;月心在日面外时远交点为正即近交点为正。
near, far, hit := contact.rayChord(offset)
if hit && far > 0 {
outer = math.Max(outer, radius-near)
cover = math.Min(cover, radius-far)
}
if _, far, hit := contact.rayChord(offset + 180); hit && far > 0 {
if radius, ok := contact.radius(offset+180, true); ok {
inside = math.Min(inside, far-radius)
}
}
}
return
}
func TestLunarLimbContactMatchesBruteForce(t *testing.T) {
seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC))
params := solarEclipseModelParams(SolarEclipseModelNASABulletinSplitK, SolarEclipseSunRadiusStandard)
params.lunarLimb = limb.Default()
greatest := localSolarEclipseGreatest(solarEclipseAnchorNewMoon(seed), 116.2310*rad, 39.7215*rad, 0, params)
const tolerance = 1e-4 / 3600 * rad
for _, dtSec := range []float64{-60, -20, -5, 0, 5, 20, 60} {
context := newLocalSolarEclipseStateContext(greatest+dtSec/86400, params)
for _, latDeg := range []float64{39.70, 39.7215, 39.73, 39.74} {
observer := localSolarEclipseObserverXYZ(context.gst, 116.2310*rad, latDeg*rad, 0)
moon := [3]float64{context.moonXYZ[0] - observer[0], context.moonXYZ[1] - observer[1], context.moonXYZ[2] - observer[2]}
sun := [3]float64{context.sunXYZ[0] - observer[0], context.sunXYZ[1] - observer[1], context.sunXYZ[2] - observer[2]}
view, ok := context.limbFrame.viewToward(moon, sun)
if !ok {
t.Fatal("view failed")
}
state := context.stateAt(116.2310*rad, latDeg*rad, 0)
contact := lunarLimbContact{frame: context.limbFrame, view: view, distanceKM: math.Sqrt(lunarLimbDot(moon, moon)),
separation: state.separationRad, targetRadius: state.sunRadiusRad}
outer, cover, _ := limbBruteMargins(contact)
low, high, _ := contact.bounds()
relief := high - low
d, sunRadius := state.separationRad, state.sunRadiusRad
// 门控区只保证间隙符号,门控外须与暴力判据逐值一致。
gotOuter, _ := contact.outerRadius()
wantOuterGap := -outer
if gotGap := d - gotOuter - sunRadius; d > sunRadius && math.Abs(wantOuterGap) < relief {
if math.Abs(gotGap-wantOuterGap) > tolerance {
t.Errorf("dt %.0f lat %.4f: outer gap %.4f\" want %.4f\"", dtSec, latDeg, gotGap/rad*3600, wantOuterGap/rad*3600)
}
} else if (gotGap > 0) != (wantOuterGap > 0) {
t.Errorf("dt %.0f lat %.4f: outer gap sign %.4f\" want %.4f\"", dtSec, latDeg, gotGap/rad*3600, wantOuterGap/rad*3600)
}
gotInner, _ := contact.innerRadius(true)
gotGap := d - math.Abs(gotInner-sunRadius)
wantGap := math.Min(-cover, d)
if math.Abs(cover) < relief {
if math.Abs(gotGap-wantGap) > tolerance {
t.Errorf("dt %.0f lat %.4f: inner gap %.4f\" want %.4f\"", dtSec, latDeg, gotGap/rad*3600, wantGap/rad*3600)
}
} else if (gotGap > 0) != (wantGap > 0) {
t.Errorf("dt %.0f lat %.4f: inner gap sign %.4f\" want %.4f\"", dtSec, latDeg, gotGap/rad*3600, wantGap/rad*3600)
}
}
}
}
func limbPointInBand(segments [][]SolarEclipsePathPoint, lon, lat float64) bool {
inside := false
for _, ring := range segments {
for i, j := 0, len(ring)-1; i < len(ring); j, i = i, i+1 {
a, b := ring[i], ring[j]
if (a.Latitude > lat) != (b.Latitude > lat) &&
lon < (b.Longitude-a.Longitude)*(lat-a.Latitude)/(b.Latitude-a.Latitude)+a.Longitude {
inside = !inside
}
}
}
return inside
}
func TestLimbCentralBandMatchesLocalTotality(t *testing.T) {
seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC))
profile := limb.Default()
path := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{LunarLimb: profile}, nil)
options := SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK, LunarLimb: profile}
const lon, marginDeg = 116.2310, 0.5 / 111.2
for lat := 39.60; lat <= 39.85; lat += 0.005 {
inBand := limbPointInBand(path.CentralBandSegments, lon, lat)
if inBand != limbPointInBand(path.CentralBandSegments, lon, lat-marginDeg) ||
inBand != limbPointInBand(path.CentralBandSegments, lon, lat+marginDeg) {
continue
}
local := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, options, nil)
if local.HasCentral != inBand {
t.Errorf("lat %.4f: local central %v, band %v", lat, local.HasCentral, inBand)
}
}
local := LocalSolarEclipseWithOptionsWithInstance(seed, lon, 39.7215, 0, options, nil)
if local.HasCentral || limbPointInBand(path.CentralBandSegments, lon, 39.7215) {
t.Errorf("39.7215: local central %v must agree with band (outside)", local.HasCentral)
}
}
// limbTestContact 2035-09-02 北京附近某一时刻的月缘接触上下文,日面半径与角距可替换以构造环食。
func limbTestContact(t *testing.T) lunarLimbContact {
t.Helper()
seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC))
params := solarEclipseModelParams(SolarEclipseModelNASABulletinSplitK, SolarEclipseSunRadiusStandard)
params.lunarLimb = limb.Default()
greatest := localSolarEclipseGreatest(solarEclipseAnchorNewMoon(seed), 116.2310*rad, 39.7215*rad, 0, params)
context := newLocalSolarEclipseStateContext(greatest, params)
observer := localSolarEclipseObserverXYZ(context.gst, 116.2310*rad, 39.7215*rad, 0)
moon := [3]float64{context.moonXYZ[0] - observer[0], context.moonXYZ[1] - observer[1], context.moonXYZ[2] - observer[2]}
sun := [3]float64{context.sunXYZ[0] - observer[0], context.sunXYZ[1] - observer[1], context.sunXYZ[2] - observer[2]}
view, ok := context.limbFrame.viewToward(moon, sun)
if !ok {
t.Fatal("view failed")
}
state := context.stateAt(116.2310*rad, 39.7215*rad, 0)
return lunarLimbContact{frame: context.limbFrame, view: view, distanceKM: math.Sqrt(lunarLimbDot(moon, moon)),
separation: state.separationRad, targetRadius: state.sunRadiusRad}
}
func TestLunarLimbAnnularAndUnrangedMatchBruteForce(t *testing.T) {
base := limbTestContact(t)
const tolerance = 1e-4 / 3600 * rad
meanRadius := math.Asin(1737.4 / base.distanceKM)
unranged := *base.frame
unranged.ranged = false
for _, ranged := range []bool{true, false} {
for _, separationArcsec := range []float64{0, 1, 5, 12, 20} {
contact := base
if !ranged {
contact.frame = &unranged
}
contact.targetRadius = meanRadius + 6/3600.0*rad
contact.separation = separationArcsec / 3600 * rad
_, _, inside := limbBruteMargins(contact)
got, ok := contact.innerRadius(false)
if !ok {
t.Fatalf("ranged %v d %.0f\": annular radius failed", ranged, separationArcsec)
}
// 环食等效内半径必须小于日面半径,否则食分会大于 1;只比间隙看不出 R 关于 s 的镜像错误。
if got > contact.targetRadius {
t.Errorf("ranged %v d %.0f\": annular radius %.4f\" exceeds the solar radius %.4f\"", ranged, separationArcsec, got/rad*3600, contact.targetRadius/rad*3600)
}
gotGap := contact.separation - math.Abs(got-contact.targetRadius)
wantGap := math.Min(-inside, contact.separation)
if math.Abs(gotGap-wantGap) > tolerance && !(gotGap > 0 && wantGap > 0) {
t.Errorf("ranged %v d %.0f\": annular gap %.4f\" want %.4f\"", ranged, separationArcsec, gotGap/rad*3600, wantGap/rad*3600)
}
if !ranged {
contact.targetRadius = meanRadius - 6/3600.0*rad
contact.separation = (separationArcsec + 1900) / 3600 * rad
outer, _, _ := limbBruteMargins(contact)
got, ok := contact.outerRadius()
if !ok {
t.Fatal("unranged outer failed")
}
if gap := contact.separation - got - contact.targetRadius; math.Abs(gap+outer) > tolerance {
t.Errorf("unranged d %.0f\": outer gap %.4f\" want %.4f\"", separationArcsec, gap/rad*3600, -outer/rad*3600)
}
}
}
}
}
func TestLimbCentralLimitMatchesLocalLimit(t *testing.T) {
seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC))
profile := limb.Default()
path := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{LunarLimb: profile}, nil)
options := SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK, LunarLimb: profile}
const lon = 116.2310
bisect := func(inside func(float64) bool) float64 {
low, high := 39.5, 39.9
for i := 0; i < 40; i++ {
mid := (low + high) / 2
if inside(mid) {
high = mid
} else {
low = mid
}
}
return (low + high) / 2
}
local := bisect(func(lat float64) bool {
return LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, options, nil).HasCentral
})
band := bisect(func(lat float64) bool { return limbPointInBand(path.CentralBandSegments, lon, lat) })
if d := math.Abs(local-band) * 111.2; d > 0.5 {
t.Errorf("southern limit: local %.5f, band %.5f, %.2f km apart", local, band, d)
}
}
func TestLocalSolarEclipseLimbCanFlipTotalToAnnular(t *testing.T) {
seed := Date2JD(time.Date(2023, 4, 20, 0, 0, 0, 0, time.UTC))
const lon, lat = 170.1481, 4.6940
circular := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK}, nil)
if circular.Type != SolarEclipseTotal {
t.Fatalf("circular type %v, want total near the hybrid transition", circular.Type)
}
// 常数月缘比圆月面内半径小 2 km:圆月面判全食,月缘下日面在各方向都露出,应为环食。
innerKM := geodata.EarthEquatorialRadiusKM * solarEclipseUmbralK * localSolarMoonRadiusScale
smaller := &recordingLunarLimb{radiusKM: innerKM - 2}
limbed := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0,
SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK, LunarLimb: smaller}, nil)
if limbed.Type != SolarEclipseAnnular || !limbed.HasCentral || !limbed.HasAnnular || limbed.HasTotal {
t.Fatalf("smaller constant limb: type %v central %v annular %v total %v, want annular", limbed.Type, limbed.HasCentral, limbed.HasAnnular, limbed.HasTotal)
}
if limbed.CentralEnd <= limbed.CentralStart {
t.Errorf("annular phase has no duration: %.8f–%.8f", limbed.CentralStart, limbed.CentralEnd)
}
}
// sectorLunarLimb 只在以 center 为中心、半宽 halfWidth 的位置角扇区内给出剖面值,并报告整张表的半径范围。
type sectorLunarLimb struct {
center, halfWidth float64
profile *limb.Profile
}
func (sector sectorLunarLimb) LimbRadiusKM(lon, lat, angle float64) (float64, bool) {
if math.Abs(math.Mod(angle-sector.center+540, 360)-180) > sector.halfWidth {
return 0, false
}
return sector.profile.LimbRadiusKM(lon, lat, angle)
}
func (sector sectorLunarLimb) RadiusRangeKM() (float64, float64) {
return sector.profile.RadiusRangeKM()
}
func (sector sectorLunarLimb) AngleStepDeg() float64 { return sector.profile.AngleStepDeg() }
func TestLocalSolarEclipseStateSeparatesOuterAndInnerLimb(t *testing.T) {
params := solarEclipseModelParams(SolarEclipseModelNASABulletinSplitK, SolarEclipseSunRadiusStandard)
seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC))
const lon, lat = 116.2310 * rad, 39.70 * rad
// 食甚附近月盘深入日面:外切间隙远离零点被门控,只查日心方向;内切接近零点必须全方向扫描。
tt := localSolarEclipseGreatest(solarEclipseAnchorNewMoon(seed), lon, lat, 0, params)
circular := newLocalSolarEclipseStateContext(tt, params).stateAt(lon, lat, 0)
params.lunarLimb = limb.Default()
full := newLocalSolarEclipseStateContext(tt, params)
observer := localSolarEclipseObserverXYZ(full.gst, lon, lat, 0)
view, ok := full.limbFrame.viewToward(
[3]float64{full.moonXYZ[0] - observer[0], full.moonXYZ[1] - observer[1], full.moonXYZ[2] - observer[2]},
[3]float64{full.sunXYZ[0] - observer[0], full.sunXYZ[1] - observer[1], full.sunXYZ[2] - observer[2]})
if !ok {
t.Fatal("view failed")
}
params.lunarLimb = sectorLunarLimb{center: view.angle, halfWidth: 2, profile: limb.Default()}
state := newLocalSolarEclipseStateContext(tt, params).stateAt(lon, lat, 0)
if !state.outerLimbApplied || state.limbApplied {
t.Fatalf("outerLimbApplied %v limbApplied %v, want outer only", state.outerLimbApplied, state.limbApplied)
}
if state.moonInnerRadiusRad != circular.moonInnerRadiusRad || state.moonOuterRadiusRad == circular.moonOuterRadiusRad {
t.Errorf("inner radius must stay circular and outer must move: inner %.9g vs %.9g, outer %.9g vs %.9g",
state.moonInnerRadiusRad, circular.moonInnerRadiusRad, state.moonOuterRadiusRad, circular.moonOuterRadiusRad)
}
}
+489
View File
@@ -0,0 +1,489 @@
package basic
import (
"math"
"testing"
"time"
"b612.me/astro/internal/geodata"
"b612.me/astro/moon/limb"
)
// recordingLunarLimb 记录最后一次查询参数,返回固定半径。
type recordingLunarLimb struct {
lon, lat, angle float64
radiusKM float64
}
func (limb *recordingLunarLimb) LimbRadiusKM(lon, lat, angle float64) (float64, bool) {
limb.lon, limb.lat, limb.angle = lon, lat, angle
return limb.radiusKM, true
}
func lunarLimbAngleDiff(a, b float64) float64 {
d := math.Mod(a-b, 360)
if d > 180 {
d -= 360
}
if d < -180 {
d += 360
}
return d
}
// 地心观测者所见的月面经纬必须还原该时刻的总天平动;目标位于月球北极投影方向时位置角为 0,
// 向天东转 90° 时为 90°。
func TestLunarLimbFrameRoundTrip(t *testing.T) {
for _, jde := range []float64{2460409.25, 2451545.0, 2462000.7} {
_, moon := solarEclipseSunMoonEquatorial(jde)
moonXYZ := solarEclipseLLRToXYZ(moon[0], moon[1], moon[2])
limb := &recordingLunarLimb{radiusKM: 1737.4}
frame := newLunarLimbFrame(limb, jde, moonXYZ)
if !frame.valid {
t.Fatalf("jde %v: frame invalid", jde)
}
physical := MoonPhysical(jde)
moonUnit, _ := lunarLimbNormalize(moonXYZ)
north, east, _ := lunarLimbSkyBasis(moonUnit)
for _, offset := range []float64{0, 90, 200} {
pa := (physical.PositionAngle + offset) * rad
// 目标放在月心旁 0.5° 处、位置角为 P + offset。
sky := lunarLimbCombine(math.Cos(pa), north, math.Sin(pa), east)
target := lunarLimbCombine(math.Cos(0.5*rad), moonUnit, math.Sin(0.5*rad), sky)
target = [3]float64{target[0] * 1.5e8, target[1] * 1.5e8, target[2] * 1.5e8}
if _, ok := frame.radiusKM(moonXYZ, target, false); !ok {
t.Fatalf("jde %v: radiusKM failed", jde)
}
if d := lunarLimbAngleDiff(limb.lon, physical.LibrationLongitude); math.Abs(d) > 1e-6 {
t.Errorf("jde %v: libration longitude %.9f, want %.9f", jde, limb.lon, physical.LibrationLongitude)
}
if d := limb.lat - physical.LibrationLatitude; math.Abs(d) > 1e-6 {
t.Errorf("jde %v: libration latitude %.9f, want %.9f", jde, limb.lat, physical.LibrationLatitude)
}
if d := lunarLimbAngleDiff(limb.angle, -offset); math.Abs(d) > 1e-6 {
t.Errorf("jde %v offset %v: position angle %.9f", jde, offset, limb.angle)
}
if _, ok := frame.radiusKM(moonXYZ, target, true); !ok {
t.Fatalf("jde %v: away radiusKM failed", jde)
}
if d := lunarLimbAngleDiff(limb.angle, 180-offset); math.Abs(d) > 1e-6 {
t.Errorf("jde %v offset %v: away position angle %.9f", jde, offset, limb.angle)
}
}
}
}
// 参考球半径等于 k·R⊕ 的常数月缘只能让结果在末位附近变化,用来确认替换路径本身不引入偏差。
func TestLocalSolarEclipseConstantLimbMatchesCircular(t *testing.T) {
const seed, lon, lat = 2460409.25, -96.8, 32.8 // 2024-04-08,达拉斯附近
options := SolarEclipseOptions{RadiusModel: SolarEclipseModelIAUSingleK}
circular := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, options, nil)
if !circular.HasTotal {
t.Fatalf("expected total eclipse, got %v", circular.Type)
}
options.LunarLimb = &recordingLunarLimb{
radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseIAUSingleRadiusK * localSolarMoonRadiusScale,
}
limbed := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, options, nil)
for _, pair := range [][2]float64{
{circular.PartialStart, limbed.PartialStart},
{circular.CentralStart, limbed.CentralStart},
{circular.CentralEnd, limbed.CentralEnd},
{circular.PartialEnd, limbed.PartialEnd},
} {
if d := math.Abs(pair[0]-pair[1]) * 86400; d > 1e-3 {
t.Errorf("contact moved by %.6f s with a constant limb", d)
}
}
}
func TestLunarEclipseConstantLimbMatchesCircular(t *testing.T) {
limb := &recordingLunarLimb{radiusKM: lunarMoonRadiusScale}
for _, chauvenet := range []bool{false, true} {
circular := LunarEclipseDanjonWithInstance(2460926.0, nil)
if chauvenet {
circular = LunarEclipseChauvenetWithInstance(2460926.0, nil)
}
limbed := LunarEclipseWithLunarLimb(circular, limb, nil)
for _, pair := range [][2]float64{
{circular.PenumbralStart, limbed.PenumbralStart},
{circular.PartialStart, limbed.PartialStart},
{circular.TotalStart, limbed.TotalStart},
{circular.TotalEnd, limbed.TotalEnd},
{circular.PartialEnd, limbed.PartialEnd},
{circular.PenumbralEnd, limbed.PenumbralEnd},
} {
if d := math.Abs(pair[0]-pair[1]) * 86400; d > 0.05 {
t.Errorf("chauvenet=%v: contact moved by %.4f s with a constant limb", chauvenet, d)
}
}
if limbed.Maximum != circular.Maximum || limbed.Magnitude != circular.Magnitude {
t.Error("maximum or magnitude changed")
}
}
if got := LunarEclipseWithLunarLimb(LunarEclipseDanjonWithInstance(2460926.0, nil), nil, nil); got != LunarEclipseDanjonWithInstance(2460926.0, nil) {
t.Error("nil limb must return the input unchanged")
}
}
func TestOccultationConstantLimbMatchesCircular(t *testing.T) {
limb := &recordingLunarLimb{radiusKM: moonEquatorialRadiusKM}
start := time.Date(2024, time.August, 21, 1, 30, 0, 0, time.UTC)
end := time.Date(2024, time.August, 21, 4, 0, 0, 0, time.UTC)
observer := Observer{Longitude: -30.072, Latitude: 16.21}
plain, err := FindPlanetOccultationsWithInstance(start, end, OccultationSaturn,
observer.Longitude, observer.Latitude, 0, OccultationSearchOptions{}, nil)
if err != nil || len(plain) != 1 {
t.Fatalf("circular planet search: %v, %d events", err, len(plain))
}
limbed, err := FindPlanetOccultationsWithInstance(start, end, OccultationSaturn,
observer.Longitude, observer.Latitude, 0, OccultationSearchOptions{LunarLimb: limb}, nil)
if err != nil || len(limbed) != 1 {
t.Fatalf("limb planet search: %v, %d events", err, len(limbed))
}
a, b := plain[0], limbed[0]
for i, pair := range [][2]time.Time{
{a.ExternalImmersion, b.ExternalImmersion}, {a.InternalImmersion, b.InternalImmersion},
{a.InternalEmersion, b.InternalEmersion}, {a.ExternalEmersion, b.ExternalEmersion},
} {
if d := math.Abs(pair[0].Sub(pair[1]).Seconds()); d > 0.01 {
t.Errorf("planet contact %d moved by %.4f s with a constant limb", i, d)
}
}
if !a.Greatest.Equal(b.Greatest) || a.Type != b.Type {
t.Error("greatest or type changed")
}
}
func TestGlobalSolarEclipseConstantLimbMatchesCircular(t *testing.T) {
// IAU Single-K 的内外 k 相同,常数月缘可同时复现外切与内切半径。
limb := &recordingLunarLimb{radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseIAUSingleRadiusK * localSolarMoonRadiusScale}
options := SolarEclipseOptions{RadiusModel: SolarEclipseModelIAUSingleK}
for _, seed := range []float64{2460409.0, 2460232.0} {
circular := SolarEclipseWithOptionsWithInstance(seed, options, nil)
withLimb := options
withLimb.LunarLimb = limb
limbed := SolarEclipseWithOptionsWithInstance(seed, withLimb, nil)
for i, pair := range [][2]float64{
{circular.PartialBeginOnEarth, limbed.PartialBeginOnEarth},
{circular.PartialEndOnEarth, limbed.PartialEndOnEarth},
} {
if d := math.Abs(pair[0]-pair[1]) * 86400; d > 0.05 {
t.Errorf("seed %.0f contact %d moved by %.4f s with a constant limb", seed, i, d)
}
}
if limbed.GreatestEclipse != circular.GreatestEclipse || limbed.Type != circular.Type ||
limbed.CentralBeginOnEarth != circular.CentralBeginOnEarth || limbed.PathWidthKM != circular.PathWidthKM {
t.Errorf("seed %.0f: non-contact fields changed", seed)
}
if d := math.Abs(limbed.CentralDurationDays-circular.CentralDurationDays) * 86400; d > 0.05 {
t.Errorf("seed %.0f: central duration moved by %.4f s with a constant limb", seed, d)
}
}
}
func TestCentralPathConstantLimbKeepsLimits(t *testing.T) {
limb := &recordingLunarLimb{radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseUmbralK * localSolarMoonRadiusScale}
for _, seed := range []float64{2460409.0, 2460232.0} {
circular := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{}, nil)
limbed := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{LunarLimb: limb}, nil)
if len(circular.NorthernLimit) == 0 || len(limbed.NorthernLimit) != len(circular.NorthernLimit) ||
len(limbed.SouthernLimit) != len(circular.SouthernLimit) {
t.Fatalf("seed %.0f: limit lengths %d/%d vs %d/%d", seed,
len(limbed.NorthernLimit), len(limbed.SouthernLimit), len(circular.NorthernLimit), len(circular.SouthernLimit))
}
worst := 0.0
for _, pair := range [][2][]SolarEclipsePathPoint{{circular.NorthernLimit, limbed.NorthernLimit}, {circular.SouthernLimit, limbed.SouthernLimit}} {
for i := range pair[0] {
d := math.Hypot((pair[0][i].Longitude-pair[1][i].Longitude)*math.Cos(pair[0][i].Latitude*rad), pair[0][i].Latitude-pair[1][i].Latitude) * 111.2
worst = math.Max(worst, d)
}
}
if worst > 0.05 {
t.Errorf("seed %.0f: constant limb moved a limit by %.3f km", seed, worst)
}
}
}
func TestOccultationPathConstantLimbKeepsLimits(t *testing.T) {
limb := &recordingLunarLimb{radiusKM: moonEquatorialRadiusKM}
star := StarCoordinate{
ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444,
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -28, ProperMotionDecMasPerYear: -18,
}
start := time.Date(2025, 6, 4, 16, 0, 0, 0, time.UTC)
end := time.Date(2025, 6, 5, 16, 0, 0, 0, time.UTC)
options := OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200, DisableFootprints: true, DisableRiseSet: true}
circular, err := FindStarOccultationPathsWithInstance(start, end, star, options, nil)
if err != nil || len(circular) != 1 {
t.Fatalf("circular: %v %d", err, len(circular))
}
options.LunarLimb = limb
limbed, err := FindStarOccultationPathsWithInstance(start, end, star, options, nil)
if err != nil || len(limbed) != 1 {
t.Fatalf("limbed: %v %d", err, len(limbed))
}
worst := 0.0
for _, pair := range [][2][]OccultationPathPoint{{circular[0].NorthernLimit, limbed[0].NorthernLimit}, {circular[0].SouthernLimit, limbed[0].SouthernLimit}} {
if len(pair[0]) == 0 || len(pair[0]) != len(pair[1]) {
t.Fatalf("limit lengths %d vs %d", len(pair[0]), len(pair[1]))
}
for i := range pair[0] {
d := math.Hypot((pair[0][i].Longitude-pair[1][i].Longitude)*math.Cos(pair[0][i].Latitude*rad), pair[0][i].Latitude-pair[1][i].Latitude) * 111.2
worst = math.Max(worst, d)
}
}
if worst > 0.05 {
t.Errorf("constant limb moved a graze limit by %.3f km", worst)
}
if limbed[0].LunarLimbApplied {
t.Fatal("constant circular limb set LunarLimbApplied = true")
}
}
func TestCentralPathConstantLimbKeepsWidthsAndBand(t *testing.T) {
limb := &recordingLunarLimb{radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseUmbralK * localSolarMoonRadiusScale}
for _, seed := range []float64{2460409.0, 2460232.0} {
circular := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{}, nil)
limbed := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{LunarLimb: limb}, nil)
if len(limbed.CenterLine) != len(circular.CenterLine) || len(limbed.CentralBandSegments) != len(circular.CentralBandSegments) {
t.Fatalf("seed %.0f: sample counts changed", seed)
}
worstWidth := 0.0
for i := range circular.CenterLine {
worstWidth = math.Max(worstWidth, math.Abs(limbed.CenterLine[i].WidthKM-circular.CenterLine[i].WidthKM))
}
if worstWidth > 0.1 {
t.Errorf("seed %.0f: constant limb moved a width by %.3f km", seed, worstWidth)
}
if d := math.Abs(limbed.Greatest.WidthKM - circular.Greatest.WidthKM); d > 0.1 {
t.Errorf("seed %.0f: greatest width moved by %.3f km", seed, d)
}
if d := math.Abs(limbed.MaxCentralDurationDays-circular.MaxCentralDurationDays) * 86400; d > 0.05 {
t.Errorf("seed %.0f: max duration moved by %.4f s", seed, d)
}
worstBand := 0.0
for s := range circular.CentralBandSegments {
if len(limbed.CentralBandSegments[s]) != len(circular.CentralBandSegments[s]) {
t.Fatalf("seed %.0f: band segment %d length changed", seed, s)
}
for i, a := range circular.CentralBandSegments[s] {
b := limbed.CentralBandSegments[s][i]
d := math.Hypot((a.Longitude-b.Longitude)*math.Cos(a.Latitude*rad), a.Latitude-b.Latitude) * 111.2
worstBand = math.Max(worstBand, d)
}
}
if worstBand > 0.1 {
t.Errorf("seed %.0f: constant limb moved a band vertex by %.3f km", seed, worstBand)
}
}
}
// failingLunarLimb 处处无值:月缘修正必须整体退回圆月面且不置 LunarLimbApplied。
type failingLunarLimb struct{}
func (failingLunarLimb) LimbRadiusKM(_, _, _ float64) (float64, bool) { return 0, false }
func TestFailingLimbKeepsEclipsesCircular(t *testing.T) {
const seed, lon, lat = 2460409.25, -96.8, 32.8 // 2024-04-08,达拉斯附近
limb := failingLunarLimb{}
for _, model := range []SolarEclipseRadiusModel{SolarEclipseModelNASABulletinSplitK, SolarEclipseModelIAUSingleK} {
options := SolarEclipseOptions{RadiusModel: model}
withLimb := options
withLimb.LunarLimb = limb
circularLocal := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, options, nil)
limbedLocal := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, withLimb, nil)
if limbedLocal != circularLocal {
t.Errorf("model %v: failing limb changed the local result", model)
}
circularGlobal := SolarEclipseWithOptionsWithInstance(seed, options, nil)
limbedGlobal := SolarEclipseWithOptionsWithInstance(seed, withLimb, nil)
if limbedGlobal.LunarLimbApplied ||
limbedGlobal.PartialBeginOnEarth != circularGlobal.PartialBeginOnEarth ||
limbedGlobal.PartialEndOnEarth != circularGlobal.PartialEndOnEarth ||
limbedGlobal.CentralDurationDays != circularGlobal.CentralDurationDays {
t.Errorf("model %v: failing limb changed the global result or set LunarLimbApplied", model)
}
}
circularLunar := LunarEclipseDanjonWithInstance(2460926.0, nil)
if got := LunarEclipseWithLunarLimb(circularLunar, limb, nil); got != circularLunar {
t.Error("failing limb changed the lunar eclipse result")
}
}
func TestPartialBoundaryLimbCorrection(t *testing.T) {
const seed = 2460409.25 // 2024-04-08
circularOptions := SolarEclipsePartialFootprintOptions{}
circular := solarEclipsePartialFootprints(seed, SolarEclipseModelIAUSingleK, circularOptions, nil)
if len(circular.PartialBandContours) == 0 || len(circular.RiseSetCurves) == 0 {
t.Fatal("circular partial boundary missing")
}
samePoints := func(a, b []SolarEclipsePathPoint, toleranceKM float64) float64 {
worst := 0.0
for index := range a {
km := math.Hypot(signedAngleDifference(a[index].Longitude, b[index].Longitude)*111.19*math.Cos(a[index].Latitude*rad),
(a[index].Latitude-b[index].Latitude)*111.19)
worst = math.Max(worst, km)
}
return worst
}
compare := func(name string, limb LunarLimb, wantApplied bool, maxShiftKM float64) {
options := circularOptions
options.LunarLimb = limb
limbed := solarEclipsePartialFootprints(seed, SolarEclipseModelIAUSingleK, options, nil)
if limbed.PartialBoundaryLimbApplied != wantApplied {
t.Errorf("%s: PartialBoundaryLimbApplied = %v, want %v", name, limbed.PartialBoundaryLimbApplied, wantApplied)
}
if limbed.RiseSetTopologyDegraded {
t.Errorf("%s: rise/set topology degraded", name)
}
if len(limbed.PartialBandContours) != len(circular.PartialBandContours) || len(limbed.RiseSetCurves) != len(circular.RiseSetCurves) {
t.Fatalf("%s: boundary structure changed", name)
}
worst := 0.0
for index := range circular.PartialBandContours {
worst = math.Max(worst, samePoints(circular.PartialBandContours[index], limbed.PartialBandContours[index], 0))
}
for index, curve := range circular.RiseSetCurves {
for segment := range curve.Segments {
shift := samePoints(curve.Segments[segment], limbed.RiseSetCurves[index].Segments[segment], 0)
if curve.Phase == RiseSetPhaseGreatest && shift != 0 {
t.Errorf("%s: greatest rise/set curve moved by %.3f km", name, shift)
}
worst = math.Max(worst, shift)
}
}
if worst > maxShiftKM {
t.Errorf("%s: boundary moved by %.3f km, want <= %.3f km", name, worst, maxShiftKM)
}
if wantApplied && worst < 0.5 {
t.Errorf("%s: boundary moved only %.3f km", name, worst)
}
}
compare("failing", failingLunarLimb{}, false, 0)
compare("constant", &recordingLunarLimb{
radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseIAUSingleRadiusK * localSolarMoonRadiusScale,
}, false, 0.05)
compare("profile", sawtoothLunarLimb{}, true, limbLimitMaxShiftKM)
}
// sawtoothLunarLimb 以位置角 10° 为周期在参考半径上叠加 ±3 km 起伏,模拟月缘山谷。
type sawtoothLunarLimb struct{}
func (sawtoothLunarLimb) LimbRadiusKM(_, _, angle float64) (float64, bool) {
phase := math.Mod(angle, 10) / 10
return geodata.EarthEquatorialRadiusKM*solarEclipseIAUSingleRadiusK*localSolarMoonRadiusScale + 6*math.Abs(phase-0.5) - 1.5, true
}
// TestGlobalSolarEclipseLimbContactMatchesTime 检查月缘修正后的偏食始末接触点与修正时刻同源:
// 接触点必须落在该时刻重解的半影外切点上,而不是沿地球边缘扫描网格上的样点。
func TestGlobalSolarEclipseLimbContactMatchesTime(t *testing.T) {
for _, seed := range []float64{2460409.25, 2460232.25, 2461275.25} {
options := SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK, LunarLimb: limb.Default()}
result := SolarEclipseWithOptionsWithInstance(seed, options, nil)
if !result.LunarLimbApplied {
t.Fatalf("seed %.2f: the default limb profile changed nothing", seed)
}
solver := newSolarEclipseSolverWithOptions(CalcMoonSHByJDE(seed, 0), options, nil)
for _, pair := range []struct {
name string
jde float64
contact SolarEclipseLimbContact
}{
{"P1", result.PartialBeginOnEarth, result.PartialBeginContact},
{"P4", result.PartialEndOnEarth, result.PartialEndContact},
} {
if !pair.contact.Defined {
t.Errorf("seed %.2f %s: the corrected contact location is missing", seed, pair.name)
continue
}
point, ok := solver.shadowContactPointAt(pair.jde, solarEclipsePenumbralShadow, false)
if !ok {
t.Errorf("seed %.2f %s: no penumbral tangent point at the corrected time", seed, pair.name)
continue
}
longitude := signedAngleDifference(pair.contact.Longitude, point.Longitude) * math.Cos(point.Latitude*rad)
if d := math.Hypot(longitude, pair.contact.Latitude-point.Latitude) * geodata.EarthKMPerDegree; d > 0.05 {
t.Errorf("seed %.2f %s: contact location is %.3f km from the tangent point at its own time", seed, pair.name, d)
}
}
}
}
// TestLimbFlagsIgnoreNoiseWithConstantLimb 检查常数月缘等价于圆月面时,各层月缘标志都不因离散残差误报。
func TestLimbFlagsIgnoreNoiseWithConstantLimb(t *testing.T) {
iau := &recordingLunarLimb{radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseIAUSingleRadiusK * localSolarMoonRadiusScale}
umbral := &recordingLunarLimb{radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseUmbralK * localSolarMoonRadiusScale}
for _, seed := range []float64{2460409.25, 2460232.25, 2461275.25} {
global := SolarEclipseWithOptionsWithInstance(seed, SolarEclipseOptions{RadiusModel: SolarEclipseModelIAUSingleK, LunarLimb: iau}, nil)
if global.LunarLimbApplied || global.PartialBeginContact.Defined || global.PartialEndContact.Defined {
t.Errorf("seed %.2f: constant limb reported a global correction", seed)
}
central := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{LunarLimb: umbral}, nil)
if central.CentralLimitsLimbApplied {
t.Errorf("seed %.2f: constant limb reported moved central limits", seed)
}
local := LocalSolarEclipseWithOptionsWithInstance(seed, -96.8, 32.8, 0, SolarEclipseOptions{RadiusModel: SolarEclipseModelIAUSingleK, LunarLimb: iau}, nil)
if local.LunarLimbApplied {
t.Errorf("seed %.2f: constant limb reported a local correction", seed)
}
}
real := SolarEclipseCentralPathNASABulletinSplitKWithInstance(2460409.25, SolarEclipsePathOptions{LunarLimb: limb.Default()}, nil)
if !real.CentralLimitsLimbApplied {
t.Error("default limb profile must move the central limits")
}
}
// TestLocalSolarEclipseLimbFlagCoversMagnitude 检查只改变食分、遮蔽率而不改变接触时刻时站心标志仍为真。
func TestLocalSolarEclipseLimbFlagCoversMagnitude(t *testing.T) {
circular := LocalSolarEclipseResult{Type: SolarEclipsePartial, PartialStart: 1, PartialEnd: 2, Magnitude: 0.5, Obscuration: 0.4}
limbed := circular
if localSolarEclipseLimbChanged(limbed, circular) {
t.Fatal("identical results reported a change")
}
limbed.Magnitude += 1e-12
if localSolarEclipseLimbChanged(limbed, circular) {
t.Error("floating-point noise in magnitude reported a change")
}
limbed.Magnitude = circular.Magnitude + 1e-6
if !localSolarEclipseLimbChanged(limbed, circular) {
t.Error("a visible magnitude change was not reported")
}
limbed = circular
limbed.Obscuration += 1e-6
if !localSolarEclipseLimbChanged(limbed, circular) {
t.Error("a visible obscuration change was not reported")
}
limbed = circular
limbed.Type = SolarEclipseAnnular
if !localSolarEclipseLimbChanged(limbed, circular) {
t.Error("a type change was not reported")
}
}
// steppedLunarLimb 报告指定网格步长的常半径剖面。
type steppedLunarLimb struct{ step float64 }
func (limb steppedLunarLimb) LimbRadiusKM(lon, lat, angle float64) (float64, bool) {
return 1737.4, true
}
func (limb steppedLunarLimb) RadiusRangeKM() (float64, float64) { return 1737.4, 1737.4 }
func (limb steppedLunarLimb) AngleStepDeg() float64 { return limb.step }
func TestLunarLimbFrameRejectsTinyStep(t *testing.T) {
_, moon := solarEclipseSunMoonEquatorial(2460409.25)
moonXYZ := solarEclipseLLRToXYZ(moon[0], moon[1], moon[2])
for _, c := range []struct{ step, want float64 }{
{1e-300, lunarLimbDefaultStepDeg}, {1e-4, lunarLimbDefaultStepDeg}, {0.001, 0.001}, {0.5, 0.5}, {2, lunarLimbDefaultStepDeg},
} {
frame := newLunarLimbFrame(steppedLunarLimb{c.step}, 2460409.25, moonXYZ)
if !frame.valid || frame.stepDeg != c.want {
t.Errorf("step %g: got %g valid=%v, want %g", c.step, frame.stepDeg, frame.valid, c.want)
}
}
}
+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)
+95 -152
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))
@@ -87,162 +87,105 @@ func MarsApparentRaDec(jd float64) (float64, float64) {
}
func EarthMarsAway(jd float64) float64 {
x, y, z := AMarsXYZ(jd)
to := math.Sqrt(x*x + y*y + z*z)
return to
return planetEarthAwayExplicitN(3, jd, -1)
}
func MarsApparentLo(jd float64) float64 {
x, y, z := AMarsXYZ(jd)
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
x, y, z = AMarsXYZ(jd - to)
lo := math.Atan2(y, x)
//bo := math.Atan2(z, math.Sqrt(x*x+y*y))
lo = lo * 180.0 / math.Pi
//bo = bo * 180 / math.Pi
lo = Limit360(lo) + Nutation2000Bi(jd)
//lo-=GXCLo(lo,bo,jd)/3600;
//bo+=GXCBo(lo,bo,jd);
return lo
geo, _ := planetApparentGeocentricPositionN(3, jd, -1)
return geo.lo
}
func MarsApparentBo(jd float64) float64 {
x, y, z := AMarsXYZ(jd)
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
x, y, z = AMarsXYZ(jd - to)
//lo := math.Atan2(y, x)
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
//lo = lo * 180 / math.Pi
bo = bo * 180 / math.Pi
//lo+=GXCLo(lo,bo,jd);
//bo+=GXCBo(lo,bo,jd)/3600;
//lo+=Nutation2000Bi(jd);
return bo
geo, _ := planetApparentGeocentricPositionN(3, jd, -1)
return geo.bo
}
func MarsApparentLoBo(jd float64) (float64, float64) {
x, y, z := AMarsXYZ(jd)
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
x, y, z = AMarsXYZ(jd - to)
lo := math.Atan2(y, x)
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
lo = lo * 180 / math.Pi
bo = bo * 180 / math.Pi
lo = Limit360(lo)
//lo -= GXCLo(lo, bo, jd) / 3600
//bo += GXCBo(lo, bo, jd)
lo += Nutation2000Bi(jd)
return lo, bo
geo, _ := planetApparentGeocentricPositionN(3, jd, -1)
return geo.lo, geo.bo
}
func MarsTrueLoBo(jd float64) (float64, float64) {
x, y, z := AMarsXYZ(jd)
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
x, y, z = AMarsXYZ(jd - to)
lo := math.Atan2(y, x)
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
lo = lo * 180 / math.Pi
bo = bo * 180 / math.Pi
lo = Limit360(lo)
return lo, bo
geo, _ := planetTrueGeocentricPositionN(3, jd, -1)
return geo.lo, geo.bo
}
func MarsTrueLo(jd float64) float64 {
x, y, _ := AMarsXYZ(jd)
lo := math.Atan2(y, x)
lo = lo * 180 / math.Pi
lo = Limit360(lo)
return lo
geo, _ := planetTrueGeocentricPositionN(3, jd, -1)
return geo.lo
}
func MarsMag(jd float64) float64 {
sunDistance := MarsR(jd)
earthDistance := EarthMarsAway(jd)
earthSunDistance := planet.WherePlanet(-1, 2, jd)
func MarsMag(jde float64) float64 {
sunDistance := MarsR(jde)
earthDistance := EarthMarsAway(jde)
earthSunDistance := planet.WherePlanet(-1, 2, jde)
i := (sunDistance*sunDistance + earthDistance*earthDistance - earthSunDistance*earthSunDistance) / (2 * sunDistance * earthDistance)
i = ArcCos(i)
mag := -1.52 + 5*math.Log10(sunDistance*earthDistance) + 0.016*i
return FloatRound(mag, 2)
}
func MarsHeight(jde, lon, lat, timezone float64) float64 {
// 转换为世界时
utcJde := jde - timezone/24.0
// 计算视恒星时
ra, dec := MarsApparentRaDec(TD2UT(utcJde, true))
st := Limit360(ApparentSiderealTime(utcJde)*15 + lon)
// 计算时角
hourAngle := Limit360(st - ra)
// 高度角、时角与天球座标三角转换公式
// sin(h)=sin(lat)*sin(dec)+cos(dec)*cos(lat)*cos(hourAngle)
sinHeight := Sin(lat)*Sin(dec) + Cos(dec)*Cos(lat)*Cos(hourAngle)
return ArcSin(sinHeight)
// MarsHeightWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MarsHeightWithInstance(localJD, lon, lat, timezone float64, inst *Instance) float64 {
return planetHeightN(3, localJD, lon, lat, timezone, -1, inst)
}
func MarsAzimuth(jde, lon, lat, timezone float64) float64 {
// 转换为世界时
utcJde := jde - timezone/24.0
// 计算视恒星时
ra, dec := MarsApparentRaDec(TD2UT(utcJde, true))
st := Limit360(ApparentSiderealTime(utcJde)*15 + lon)
// 计算时角
hourAngle := Limit360(st - ra)
// 三角转换公式
tanAzimuth := Sin(hourAngle) / (Cos(hourAngle)*Sin(lat) - Tan(dec)*Cos(lat))
azimuth := ArcTan(tanAzimuth)
if azimuth < 0 {
if hourAngle/15 < 12 {
return azimuth + 360
}
return azimuth + 180
}
if hourAngle/15 < 12 {
return azimuth + 180
}
return azimuth
// MarsAzimuthWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MarsAzimuthWithInstance(localJD, lon, lat, timezone float64, inst *Instance) float64 {
return planetAzimuthN(3, localJD, lon, lat, timezone, -1, inst)
}
func MarsHourAngle(jd, lon, timezone float64) float64 {
siderealLongitude := Limit360(ApparentSiderealTime(jd-timezone/24)*15 + lon)
hourAngle := siderealLongitude - MarsApparentRa(TD2UT(jd-timezone/24.0, true))
// MarsHourAngleWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MarsHourAngleWithInstance(jd, lon, timezone float64, inst *Instance) float64 {
siderealLongitude := Limit360(ApparentSiderealTime(UTC2UT1(jd-timezone/24))*15 + lon)
hourAngle := siderealLongitude - MarsApparentRaWithInstance(UTC2TT(jd-timezone/24.0), inst)
if hourAngle < 0 {
hourAngle += 360
}
return hourAngle
}
func MarsCulminationTime(jde, lon, timezone float64) float64 {
//jde 世界时,非力学时,当地时区 0时,无需转换力学时
// MarsCulminationTimeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MarsCulminationTimeWithInstance(localJD, lon, timezone float64, inst *Instance) 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-MarsHourAngleWithInstance(localJD, lon, timezone, inst))/15.0/24.0*0.99726851851851851851
normalizedHourAngle := func(localJD, lon, timezone float64) float64 {
currentHourAngle := MarsHourAngleWithInstance(localJD, lon, timezone, inst)
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
}
func MarsRiseTime(jd, lon, lat, timezone, aeroCorrection, observerHeight float64) (float64, error) {
return marsRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, true)
// MarsRiseTimeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MarsRiseTimeWithInstance(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, inst *Instance) (float64, error) {
return marsRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, true, inst)
}
func MarsSetTime(jd, lon, lat, timezone, aeroCorrection, observerHeight float64) (float64, error) {
return marsRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, false)
// MarsSetTimeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MarsSetTimeWithInstance(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, inst *Instance) (float64, error) {
return marsRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, false, inst)
}
func marsRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, isRise bool) (float64, error) {
return planetRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, isRise, MarsCulminationTime, MarsHeight, MarsApparentDec)
func marsRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, isRise bool, instances ...*Instance) (float64, error) {
inst := instanceValue(instances)
return planetRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, isRise, func(localJD, lon, timezone float64) float64 {
return MarsCulminationTimeWithInstance(localJD, lon, timezone, inst)
}, func(localJD, lon, lat, timezone float64) float64 {
return MarsHeightWithInstance(localJD, lon, lat, timezone, inst)
}, func(jde float64) float64 { return MarsApparentDecWithInstance(jde, inst) })
}
+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 {
+4 -4
View File
@@ -3,7 +3,6 @@ package basic
import (
"encoding/json"
"math"
"os"
"testing"
)
@@ -20,10 +19,11 @@ type marsEventBaseline struct {
func loadMarsEventBaseline(t *testing.T) marsEventBaseline {
t.Helper()
data, err := os.ReadFile("testdata/mars_event_baseline.json")
if err != nil {
t.Fatal(err)
var named []namedEventFunc
for _, c := range marsEventCases() {
named = append(named, namedEventFunc{c.name, c.fn})
}
data := eventBaselineData(t, "testdata/mars_event_baseline.json", named)
var baseline marsEventBaseline
if err := json.Unmarshal(data, &baseline); err != nil {
+231 -121
View File
@@ -9,13 +9,18 @@ import (
// Pos
const (
MARS_S_PERIOD = 1 / ((1 / 365.256363004) - (1 / 686.98))
marsEventSearchN = 16
marsPhaseCoarseTolerance = 30.0 / 86400.0
MARS_S_PERIOD = 1 / ((1 / 365.256363004) - (1 / 686.98))
marsEventSearchN = 16
marsPhaseCoarseTolerance = 30.0 / 86400.0
marsStationDerivativeStepDay = 0.01
marsStationCoarseStepDay = 6.0
marsStationHalfWindowDay = 6.0
)
func marsSunLongitudeDelta(jde, degree float64, filter bool) float64 {
sub := Limit360(Limit360(MarsApparentLo(jde)-HSunApparentLo(jde)) - degree)
func marsSunLongitudeDelta(jde, degree float64, filter bool, instances ...*Instance) float64 {
inst := instanceValue(instances)
sub := Limit360(Limit360(MarsApparentLoWithInstance(jde, inst)-HSunApparentLoWithInstance(jde, inst)) - degree)
if filter {
if sub > 180 {
sub -= 360
@@ -27,8 +32,10 @@ func marsSunLongitudeDelta(jde, degree float64, filter bool) float64 {
return sub
}
func marsSunLongitudeDeltaN(jde, degree float64, filter bool, n int) float64 {
sub := Limit360(Limit360(MarsApparentLoN(jde, n)-HSunApparentLoN(jde, n)) - degree)
func marsSunLongitudeDeltaN(jde, degree float64, filter bool, n int, instances ...*Instance) float64 {
inst := instanceValue(instances)
sub := Limit360(Limit360(MarsApparentLoNWithInstance(jde, n, inst)-HSunApparentLoNWithInstance(jde, n, inst)) - degree)
if filter {
if sub > 180 {
sub -= 360
@@ -40,8 +47,10 @@ func marsSunLongitudeDeltaN(jde, degree float64, filter bool, n int) float64 {
return sub
}
func marsRADerivative(jde, val float64) float64 {
sub := MarsApparentRa(jde+val) - MarsApparentRa(jde-val)
func marsRADerivative(jde, val float64, instances ...*Instance) float64 {
inst := instanceValue(instances)
sub := MarsApparentRaWithInstance(jde+val, inst) - MarsApparentRaWithInstance(jde-val, inst)
if sub > 180 {
sub -= 360
}
@@ -51,8 +60,10 @@ func marsRADerivative(jde, val float64) float64 {
return sub / (2 * val)
}
func marsRADerivativeN(jde, val float64, n int) float64 {
sub := MarsApparentRaN(jde+val, n) - MarsApparentRaN(jde-val, n)
func marsRADerivativeN(jde, val float64, n int, instances ...*Instance) float64 {
inst := instanceValue(instances)
sub := MarsApparentRaNWithInstance(jde+val, n, inst) - MarsApparentRaNWithInstance(jde-val, n, inst)
if sub > 180 {
sub -= 360
}
@@ -62,157 +73,256 @@ func marsRADerivativeN(jde, val float64, n int) float64 {
return sub / (2 * val)
}
func marsConjunctionFull(jde, degree float64, next uint8) float64 {
func marsConjunctionFull(jde, degree float64, next uint8, instances ...*Instance) float64 {
inst := instanceValue(instances)
//0=last 1=next
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
return math.NaN()
}
daysPerDegree := MARS_S_PERIOD / 360
currentDelta := marsSunLongitudeDelta(jde, degree, false)
currentDelta := marsSunLongitudeDelta(jde, degree, false, inst)
if next == 0 {
jde -= (360 - currentDelta) * daysPerDegree
} 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, inst)
longitudeSlope := (marsSunLongitudeDelta(prevJDE+0.000005, degree, true, inst) - marsSunLongitudeDelta(prevJDE-0.000005, degree, true, inst)) / 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 {
func marsConjunction(jde, degree float64, next uint8, instances ...*Instance) float64 {
inst := instanceValue(instances)
//0=last 1=next
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
return math.NaN()
}
daysPerDegree := MARS_S_PERIOD / 360
currentDelta := marsSunLongitudeDelta(jde, degree, false)
currentDelta := marsSunLongitudeDelta(jde, degree, false, inst)
if next == 0 {
jde -= (360 - currentDelta) * daysPerDegree
} 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, inst)
longitudeSlope := (marsSunLongitudeDeltaN(prevJDE+0.000005, degree, true, marsEventSearchN, inst) - marsSunLongitudeDeltaN(prevJDE-0.000005, degree, true, marsEventSearchN, inst)) / 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, inst)
longitudeSlope := (marsSunLongitudeDelta(prevJDE+0.000005, degree, true, inst) - marsSunLongitudeDelta(prevJDE-0.000005, degree, true, inst)) / 0.00001
nextJD := prevJDE - longitudeDelta/longitudeSlope
estimateJDE = nextJD
if math.Abs(nextJD-prevJDE) <= 0.00001 {
converged = true
break
}
}
return TD2UT(estimateJD, false)
}
func LastMarsConjunction(jde float64) float64 {
return marsConjunction(jde, 0, 0)
}
func NextMarsConjunction(jde float64) float64 {
return marsConjunction(jde, 0, 1)
}
func LastMarsOpposition(jde float64) float64 {
return marsConjunction(jde, 180, 0)
}
func NextMarsOpposition(jde float64) float64 {
return marsConjunction(jde, 180, 1)
}
func NextMarsEasternQuadrature(jde float64) float64 {
return marsConjunction(jde, 90, 1)
}
func LastMarsEasternQuadrature(jde float64) float64 {
return marsConjunction(jde, 90, 0)
}
func NextMarsWesternQuadrature(jde float64) float64 {
return marsConjunction(jde, 270, 1)
}
func LastMarsWesternQuadrature(jde float64) float64 {
return marsConjunction(jde, 270, 0)
}
func marsRetrograde(jde float64, searchBeforeOpposition bool) float64 {
//0=last 1=next
jde = marsConjunctionFull(jde, 180, 1)
if searchBeforeOpposition {
jde -= 60
} else {
jde += 60
if !converged {
return math.NaN()
}
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)
return TT2UTC(estimateJDE)
}
// LastMarsConjunctionWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastMarsConjunctionWithInstance(jde float64, inst *Instance) float64 {
return inclusiveLastPhaseEvent(jde, 0, func(jde, degree float64, next uint8) float64 {
return marsConjunction(jde, degree, next, inst)
})
return TD2UT(bestJD, false)
}
func NextMarsRetrogradeToPrograde(jde float64) float64 {
date := marsRetrograde(jde, false)
if date < jde {
oppositionJD := marsConjunctionFull(jde, 180, 1)
return marsRetrograde(oppositionJD+10, false)
// NextMarsConjunctionWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextMarsConjunctionWithInstance(jde float64, inst *Instance) float64 {
return inclusiveNextPhaseEvent(jde, 0, func(jde, degree float64, next uint8) float64 {
return marsConjunction(jde, degree, next, inst)
})
}
// LastMarsOppositionWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastMarsOppositionWithInstance(jde float64, inst *Instance) float64 {
return inclusiveLastPhaseEvent(jde, 180, func(jde, degree float64, next uint8) float64 {
return marsConjunction(jde, degree, next, inst)
})
}
// NextMarsOppositionWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextMarsOppositionWithInstance(jde float64, inst *Instance) float64 {
return inclusiveNextPhaseEvent(jde, 180, func(jde, degree float64, next uint8) float64 {
return marsConjunction(jde, degree, next, inst)
})
}
// NextMarsEasternQuadratureWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextMarsEasternQuadratureWithInstance(jde float64, inst *Instance) float64 {
return inclusiveNextPhaseEvent(jde, 90, func(jde, degree float64, next uint8) float64 {
return marsConjunction(jde, degree, next, inst)
})
}
// LastMarsEasternQuadratureWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastMarsEasternQuadratureWithInstance(jde float64, inst *Instance) float64 {
return inclusiveLastPhaseEvent(jde, 90, func(jde, degree float64, next uint8) float64 {
return marsConjunction(jde, degree, next, inst)
})
}
// NextMarsWesternQuadratureWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextMarsWesternQuadratureWithInstance(jde float64, inst *Instance) float64 {
return inclusiveNextPhaseEvent(jde, 270, func(jde, degree float64, next uint8) float64 {
return marsConjunction(jde, degree, next, inst)
})
}
// LastMarsWesternQuadratureWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastMarsWesternQuadratureWithInstance(jde float64, inst *Instance) float64 {
return inclusiveLastPhaseEvent(jde, 270, func(jde, degree float64, next uint8) float64 {
return marsConjunction(jde, degree, next, inst)
})
}
func marsRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool, instances ...*Instance) float64 {
inst := instanceValue(instances)
if !isFiniteFloat(oppositionJD) {
return math.NaN()
}
oppositionTT := UTC2TT(oppositionJD)
startTT := oppositionTT
endTT := oppositionTT
if searchBeforeOpposition {
easternQuadratureUT := marsConjunction(oppositionTT, 90, 0, inst)
startTT = UTC2TT(easternQuadratureUT)
} else {
westernQuadratureUT := marsConjunction(oppositionTT, 270, 1, inst)
endTT = UTC2TT(westernQuadratureUT)
}
bestJDE := zeroEventInWindow(startTT, endTT, marsStationCoarseStepDay, marsStationHalfWindowDay, 30.0/86400.0, func(jd float64) float64 {
return marsRADerivativeN(jd, marsStationDerivativeStepDay, marsEventSearchN, inst)
}, func(jd float64) float64 {
return marsRADerivative(jd, marsStationDerivativeStepDay, inst)
})
return TT2UTC(bestJDE)
}
// NextMarsRetrogradeToProgradeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextMarsRetrogradeToProgradeWithInstance(jde float64, inst *Instance) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
lastOppositionJD := marsConjunctionFull(jde, 180, 0, inst)
date := marsRetrogradeAroundOpposition(lastOppositionJD, false, inst)
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
return date
}
nextOppositionJD := marsConjunctionFull(jde, 180, 1, inst)
if !isFiniteFloat(nextOppositionJD) {
return math.NaN()
}
date = marsRetrogradeAroundOpposition(nextOppositionJD, false, inst)
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
return math.NaN()
}
return date
}
func LastMarsRetrogradeToPrograde(jde float64) float64 {
jde = marsConjunctionFull(jde, 180, 0) - 10
date := marsRetrograde(jde, false)
if date > jde {
oppositionJD := marsConjunctionFull(jde, 180, 0)
return marsRetrograde(oppositionJD-10, false)
// LastMarsRetrogradeToProgradeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastMarsRetrogradeToProgradeWithInstance(jde float64, inst *Instance) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
lastOppositionJD := marsConjunctionFull(jde, 180, 0, inst)
date := marsRetrogradeAroundOpposition(lastOppositionJD, false, inst)
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
return date
}
if !isFiniteFloat(lastOppositionJD) {
return math.NaN()
}
previousOppositionJD := marsConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0, inst)
if !isFiniteFloat(previousOppositionJD) {
return math.NaN()
}
date = marsRetrogradeAroundOpposition(previousOppositionJD, false, inst)
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
return math.NaN()
}
return date
}
func NextMarsProgradeToRetrograde(jde float64) float64 {
date := marsRetrograde(jde, true)
if date < jde {
oppositionJD := marsConjunctionFull(jde, 180, 1)
return marsRetrograde(oppositionJD+10, true)
// NextMarsProgradeToRetrogradeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextMarsProgradeToRetrogradeWithInstance(jde float64, inst *Instance) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
nextOppositionJD := marsConjunctionFull(jde, 180, 1, inst)
date := marsRetrogradeAroundOpposition(nextOppositionJD, true, inst)
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
return date
}
if !isFiniteFloat(nextOppositionJD) {
return math.NaN()
}
followingOppositionJD := marsConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1, inst)
if !isFiniteFloat(followingOppositionJD) {
return math.NaN()
}
date = marsRetrogradeAroundOpposition(followingOppositionJD, true, inst)
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
return math.NaN()
}
return date
}
func LastMarsProgradeToRetrograde(jde float64) float64 {
jde = marsConjunctionFull(jde, 180, 0) - 10
date := marsRetrograde(jde, true)
if date > jde {
oppositionJD := marsConjunctionFull(jde, 180, 0)
return marsRetrograde(oppositionJD-10, true)
// LastMarsProgradeToRetrogradeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastMarsProgradeToRetrogradeWithInstance(jde float64, inst *Instance) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
nextOppositionJD := marsConjunctionFull(jde, 180, 1, inst)
date := marsRetrogradeAroundOpposition(nextOppositionJD, true, inst)
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
return date
}
if !isFiniteFloat(nextOppositionJD) {
return math.NaN()
}
lastOppositionJD := marsConjunctionFull(jde, 180, 0, inst)
if !isFiniteFloat(lastOppositionJD) {
return math.NaN()
}
date = marsRetrogradeAroundOpposition(lastOppositionJD, true, inst)
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")
}
}
+91 -138
View File
@@ -7,215 +7,168 @@ import (
. "b612.me/astro/tools"
)
func MercuryL(jd float64) float64 {
return planet.WherePlanet(1, 0, jd)
func MercuryL(jde float64) float64 {
return planet.WherePlanet(1, 0, jde)
}
func MercuryB(jd float64) float64 {
return planet.WherePlanet(1, 1, jd)
func MercuryB(jde float64) float64 {
return planet.WherePlanet(1, 1, jde)
}
func MercuryR(jd float64) float64 {
return planet.WherePlanet(1, 2, jd)
func MercuryR(jde float64) float64 {
return planet.WherePlanet(1, 2, jde)
}
func AMercuryX(jd float64) float64 {
l := MercuryL(jd)
b := MercuryB(jd)
r := MercuryR(jd)
el := planet.WherePlanet(-1, 0, jd)
eb := planet.WherePlanet(-1, 1, jd)
er := planet.WherePlanet(-1, 2, jd)
func AMercuryX(jde float64) float64 {
l := MercuryL(jde)
b := MercuryB(jde)
r := MercuryR(jde)
el := planet.WherePlanet(-1, 0, jde)
eb := planet.WherePlanet(-1, 1, jde)
er := planet.WherePlanet(-1, 2, jde)
x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el)
return x
}
func AMercuryY(jd float64) float64 {
func AMercuryY(jde float64) float64 {
l := MercuryL(jd)
b := MercuryB(jd)
r := MercuryR(jd)
el := planet.WherePlanet(-1, 0, jd)
eb := planet.WherePlanet(-1, 1, jd)
er := planet.WherePlanet(-1, 2, jd)
l := MercuryL(jde)
b := MercuryB(jde)
r := MercuryR(jde)
el := planet.WherePlanet(-1, 0, jde)
eb := planet.WherePlanet(-1, 1, jde)
er := planet.WherePlanet(-1, 2, jde)
y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el)
return y
}
func AMercuryZ(jd float64) float64 {
//l := MercuryL(jd)
b := MercuryB(jd)
r := MercuryR(jd)
// el := planet.WherePlanet(-1, 0, jd)
eb := planet.WherePlanet(-1, 1, jd)
er := planet.WherePlanet(-1, 2, jd)
func AMercuryZ(jde float64) float64 {
//l := MercuryL(jde)
b := MercuryB(jde)
r := MercuryR(jde)
// el := planet.WherePlanet(-1, 0, jde)
eb := planet.WherePlanet(-1, 1, jde)
er := planet.WherePlanet(-1, 2, jde)
z := r*Sin(b) - er*Sin(eb)
return z
}
func AMercuryXYZ(jd float64) (float64, float64, float64) {
l := MercuryL(jd)
b := MercuryB(jd)
r := MercuryR(jd)
el := planet.WherePlanet(-1, 0, jd)
eb := planet.WherePlanet(-1, 1, jd)
er := planet.WherePlanet(-1, 2, jd)
func AMercuryXYZ(jde float64) (float64, float64, float64) {
l := MercuryL(jde)
b := MercuryB(jde)
r := MercuryR(jde)
el := planet.WherePlanet(-1, 0, jde)
eb := planet.WherePlanet(-1, 1, jde)
er := planet.WherePlanet(-1, 2, jde)
x := r*Cos(b)*Cos(l) - er*Cos(eb)*Cos(el)
y := r*Cos(b)*Sin(l) - er*Cos(eb)*Sin(el)
z := r*Sin(b) - er*Sin(eb)
return x, y, z
}
func MercuryApparentRa(jd float64) float64 {
lo, bo := MercuryApparentLoBo(jd)
return LoToRa(jd, lo, bo)
func MercuryApparentRa(jde float64) float64 {
lo, bo := MercuryApparentLoBo(jde)
return LoToRa(jde, lo, bo)
}
func MercuryApparentDec(jd float64) float64 {
lo, bo := MercuryApparentLoBo(jd)
eps := TrueObliquity(jd)
func MercuryApparentDec(jde float64) float64 {
lo, bo := MercuryApparentLoBo(jde)
eps := TrueObliquity(jde)
dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo))
return dec
}
func MercuryApparentRaDec(jd float64) (float64, float64) {
lo, bo := MercuryApparentLoBo(jd)
return LoBoToRaDec(jd, lo, bo)
func MercuryApparentRaDec(jde float64) (float64, float64) {
lo, bo := MercuryApparentLoBo(jde)
return LoBoToRaDec(jde, lo, bo)
}
func EarthMercuryAway(jd float64) float64 {
x, y, z := AMercuryXYZ(jd)
to := math.Sqrt(x*x + y*y + z*z)
return to
return planetEarthAwayExplicitN(1, jd, -1)
}
func MercuryApparentLo(jd float64) float64 {
x, y, z := AMercuryXYZ(jd)
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
x, y, z = AMercuryXYZ(jd - to)
lo := math.Atan2(y, x)
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
lo = lo * 180 / math.Pi
bo = bo * 180 / math.Pi
lo = Limit360(lo)
//lo-=GXCLo(lo,bo,jd)/3600;
//bo+=GXCBo(lo,bo,jd);
lo += Nutation2000Bi(jd)
return lo
geo, _ := planetApparentGeocentricPositionN(1, jd, -1)
return geo.lo
}
func MercuryApparentBo(jd float64) float64 {
x, y, z := AMercuryXYZ(jd)
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
x, y, z = AMercuryXYZ(jd - to)
//lo := math.Atan2(y, x)
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
//lo = lo * 180 / math.Pi
bo = bo * 180 / math.Pi
//lo+=GXCLo(lo,bo,jd);
//bo+=GXCBo(lo,bo,jd)/3600;
//lo+=Nutation2000Bi(jd);
return bo
geo, _ := planetApparentGeocentricPositionN(1, jd, -1)
return geo.bo
}
func MercuryApparentLoBo(jd float64) (float64, float64) {
x, y, z := AMercuryXYZ(jd)
to := 0.0057755183 * math.Sqrt(x*x+y*y+z*z)
x, y, z = AMercuryXYZ(jd - to)
lo := math.Atan2(y, x)
bo := math.Atan2(z, math.Sqrt(x*x+y*y))
lo = lo * 180 / math.Pi
bo = bo * 180 / math.Pi
lo = Limit360(lo) + Nutation2000Bi(jd)
//lo-=GXCLo(lo,bo,jd)/3600;
//bo+=GXCBo(lo,bo,jd);
return lo, bo
geo, _ := planetApparentGeocentricPositionN(1, jd, -1)
return geo.lo, geo.bo
}
func MercuryMag(jd float64) float64 {
sunDistance := MercuryR(jd)
earthDistance := EarthMercuryAway(jd)
earthSunDistance := planet.WherePlanet(-1, 2, jd)
func MercuryMag(jde float64) float64 {
sunDistance := MercuryR(jde)
earthDistance := EarthMercuryAway(jde)
earthSunDistance := planet.WherePlanet(-1, 2, jde)
i := (sunDistance*sunDistance + earthDistance*earthDistance - earthSunDistance*earthSunDistance) / (2 * sunDistance * earthDistance)
i = ArcCos(i)
mag := -0.42 + 5*math.Log10(sunDistance*earthDistance) + 0.0380*i - 0.000273*i*i + 0.000002*i*i*i
return FloatRound(mag, 2)
}
func MercuryHeight(jde, lon, lat, timezone float64) float64 {
// 转换为世界时
utcJde := jde - timezone/24.0
// 计算视恒星时
ra, dec := MercuryApparentRaDec(TD2UT(utcJde, true))
st := Limit360(ApparentSiderealTime(utcJde)*15 + lon)
// 计算时角
hourAngle := Limit360(st - ra)
// 高度角、时角与天球座标三角转换公式
// sin(h)=sin(lat)*sin(dec)+cos(dec)*cos(lat)*cos(hourAngle)
sinHeight := Sin(lat)*Sin(dec) + Cos(dec)*Cos(lat)*Cos(hourAngle)
return ArcSin(sinHeight)
// MercuryHeightWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MercuryHeightWithInstance(localJD, lon, lat, timezone float64, inst *Instance) float64 {
return planetHeightN(1, localJD, lon, lat, timezone, -1, inst)
}
func MercuryAzimuth(jde, lon, lat, timezone float64) float64 {
// 转换为世界时
utcJde := jde - timezone/24.0
// 计算视恒星时
ra, dec := MercuryApparentRaDec(TD2UT(utcJde, true))
st := Limit360(ApparentSiderealTime(utcJde)*15 + lon)
// 计算时角
hourAngle := Limit360(st - ra)
// 三角转换公式
tanAzimuth := Sin(hourAngle) / (Cos(hourAngle)*Sin(lat) - Tan(dec)*Cos(lat))
azimuth := ArcTan(tanAzimuth)
if azimuth < 0 {
if hourAngle/15 < 12 {
return azimuth + 360
}
return azimuth + 180
}
if hourAngle/15 < 12 {
return azimuth + 180
}
return azimuth
// MercuryAzimuthWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MercuryAzimuthWithInstance(localJD, lon, lat, timezone float64, inst *Instance) float64 {
return planetAzimuthN(1, localJD, lon, lat, timezone, -1, inst)
}
func MercuryHourAngle(jd, lon, timezone float64) float64 {
siderealLongitude := Limit360(ApparentSiderealTime(jd-timezone/24)*15 + lon)
hourAngle := siderealLongitude - MercuryApparentRa(TD2UT(jd-timezone/24.0, true))
// MercuryHourAngleWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MercuryHourAngleWithInstance(jd, lon, timezone float64, inst *Instance) float64 {
siderealLongitude := Limit360(ApparentSiderealTime(UTC2UT1(jd-timezone/24))*15 + lon)
hourAngle := siderealLongitude - MercuryApparentRaWithInstance(UTC2TT(jd-timezone/24.0), inst)
if hourAngle < 0 {
hourAngle += 360
}
return hourAngle
}
func MercuryCulminationTime(jde, lon, timezone float64) float64 {
//jde 世界时,非力学时,当地时区 0时,无需转换力学时
// MercuryCulminationTimeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MercuryCulminationTimeWithInstance(localJD, lon, timezone float64, inst *Instance) 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-MercuryHourAngleWithInstance(localJD, lon, timezone, inst))/15.0/24.0*0.99726851851851851851
normalizedHourAngle := func(localJD, lon, timezone float64) float64 {
currentHourAngle := MercuryHourAngleWithInstance(localJD, lon, timezone, inst)
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
}
func MercuryRiseTime(jd, lon, lat, timezone, aeroCorrection, observerHeight float64) (float64, error) {
return mercuryRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, true)
// MercuryRiseTimeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MercuryRiseTimeWithInstance(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, inst *Instance) (float64, error) {
return mercuryRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, true, inst)
}
func MercurySetTime(jd, lon, lat, timezone, aeroCorrection, observerHeight float64) (float64, error) {
return mercuryRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, false)
// MercurySetTimeWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MercurySetTimeWithInstance(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, inst *Instance) (float64, error) {
return mercuryRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, false, inst)
}
func mercuryRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, isRise bool) (float64, error) {
return planetRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, isRise, MercuryCulminationTime, MercuryHeight, MercuryApparentDec)
func mercuryRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, isRise bool, instances ...*Instance) (float64, error) {
inst := instanceValue(instances)
return planetRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight, isRise, func(localJD, lon, timezone float64) float64 {
return MercuryCulminationTimeWithInstance(localJD, lon, timezone, inst)
}, func(localJD, lon, lat, timezone float64) float64 {
return MercuryHeightWithInstance(localJD, lon, lat, timezone, inst)
}, func(jde float64) float64 { return MercuryApparentDecWithInstance(jde, inst) })
}
+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 {
+4 -4
View File
@@ -3,7 +3,6 @@ package basic
import (
"encoding/json"
"math"
"os"
"testing"
)
@@ -20,10 +19,11 @@ type mercuryEventBaseline struct {
func loadMercuryEventBaseline(t *testing.T) mercuryEventBaseline {
t.Helper()
data, err := os.ReadFile("testdata/mercury_event_baseline.json")
if err != nil {
t.Fatal(err)
var named []namedEventFunc
for _, c := range mercuryEventCases() {
named = append(named, namedEventFunc{c.name, c.fn})
}
data := eventBaselineData(t, "testdata/mercury_event_baseline.json", named)
var baseline mercuryEventBaseline
if err := json.Unmarshal(data, &baseline); err != nil {
+733 -309
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
package basic
import (
"math"
"testing"
)
func TestMercuryRetrogradeFastPathKeepsTypedCandidateOrder(t *testing.T) {
// 这些查询覆盖一个普通古代周期和两个长间隔边界 / These queries cover a normal ancient cycle and two long-gap boundaries
//,类型化驻留搜索必须作为最终权威 / where the typed station search must remain the final authority.
for _, queryUT := range []float64{
1026548.810500779,
1644733.927538287,
1645082.416782375,
} {
queryTT := UTC2TT(queryUT)
nextP2R := NextMercuryProgradeToRetrograde(queryTT)
nextR2P := NextMercuryRetrogradeToPrograde(queryTT)
wantNext := math.Min(nextP2R, nextR2P)
if got := NextMercuryRetrograde(queryTT); math.Abs(got-wantNext) > 1e-7 {
t.Fatalf("next aggregate mismatch at %.9f: got %.12f want %.12f", queryUT, got, wantNext)
}
lastP2R := LastMercuryProgradeToRetrograde(queryTT)
lastR2P := LastMercuryRetrogradeToPrograde(queryTT)
wantLast := math.Max(lastP2R, lastR2P)
if got := LastMercuryRetrograde(queryTT); math.Abs(got-wantLast) > 1e-7 {
t.Fatalf("last aggregate mismatch at %.9f: got %.12f want %.12f", queryUT, got, wantLast)
}
}
}
+40
View File
@@ -0,0 +1,40 @@
package basic
import (
"math"
"testing"
"time"
)
func mercuryTTJDJST(year int, month time.Month, day, hour, minute, second int) float64 {
loc := time.FixedZone("JST", 9*3600)
return UTC2TT(Date2JD(time.Date(year, month, day, hour, minute, second, 0, loc).UTC()))
}
func TestMercuryTypedStationRegression1929(t *testing.T) {
loc := time.FixedZone("JST", 9*3600)
const tolerance = 30.0 / 86400.0
query := mercuryTTJDJST(1929, time.September, 20, 0, 0, 0)
wantP2R := mercuryTTJDJST(1929, time.September, 26, 1, 58, 0)
wantR2P := mercuryTTJDJST(1929, time.October, 16, 23, 32, 33)
nextP2R := NextMercuryProgradeToRetrograde(query)
nextR2P := NextMercuryRetrogradeToPrograde(query)
if math.Abs(nextP2R-wantP2R) > tolerance {
t.Fatalf("next P2R mismatch: got %s want %s", JD2DateByZone(nextP2R, loc, false), JD2DateByZone(wantP2R, loc, false))
}
if math.Abs(nextR2P-wantR2P) > tolerance {
t.Fatalf("next R2P mismatch: got %s want %s", JD2DateByZone(nextR2P, loc, false), JD2DateByZone(wantR2P, loc, false))
}
query = mercuryTTJDJST(1929, time.October, 20, 0, 0, 0)
lastP2R := LastMercuryProgradeToRetrograde(query)
lastR2P := LastMercuryRetrogradeToPrograde(query)
if math.Abs(lastP2R-wantP2R) > tolerance {
t.Fatalf("last P2R mismatch: got %s want %s", JD2DateByZone(lastP2R, loc, false), JD2DateByZone(wantP2R, loc, false))
}
if math.Abs(lastR2P-wantR2P) > tolerance {
t.Fatalf("last R2P mismatch: got %s want %s", JD2DateByZone(lastR2P, loc, false), JD2DateByZone(wantR2P, loc, false))
}
}
+41 -46
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
}
+27 -21
View File
@@ -1,41 +1,47 @@
package basic
import . "b612.me/astro/tools"
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)
// MoonBrightLimbPositionAngleNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MoonBrightLimbPositionAngleNWithInstance(jde float64, n int, inst *Instance) float64 {
sunRA, sunDec := HSunApparentRaDecNWithInstance(jde, n, inst)
moonRA, moonDec := HMoonTrueRaDecNWithInstance(jde, n, inst)
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)
// MoonTopocentricBrightLimbPositionAngleNWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MoonTopocentricBrightLimbPositionAngleNWithInstance(jde, observerLon, observerLat, height float64, n int, inst *Instance) float64 {
sunRA, sunDec := sunTopocentricApparentRaDecN(jde, observerLon, observerLat, height, n, inst)
moonRA, moonDec := moonTopocentricApparentRaDecN(jde, observerLon, observerLat, height, n, inst)
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, instances ...*Instance) (float64, float64) {
inst := instanceValue(instances)
geocentricRA := HMoonTrueRaNWithInstance(jde, n, inst)
geocentricDec := HMoonTrueDecNWithInstance(jde, n, inst)
distanceAU := HMoonAwayNWithInstance(jde, n, inst) / 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, instances ...*Instance) (float64, float64) {
inst := instanceValue(instances)
geocentricRA, geocentricDec := HSunApparentRaDecNWithInstance(jde, n, inst)
return TopocentricRaDec(geocentricRA, geocentricDec, observerLat, observerLon, TT2UTC(jde), EarthAwayNWithInstance(jde, n, inst), 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)
+107
View File
@@ -0,0 +1,107 @@
package basic
import (
"math"
"b612.me/astro/internal/ephem"
"b612.me/astro/planet"
. "b612.me/astro/tools"
)
func moonCalcNewNWithInstance(coordIndex int, jd float64, n int, inst *Instance) float64 {
if inst == nil {
return planet.WhereMoonN(coordIndex, jd, n)
}
if inst.Model() != ephem.Custom {
return planet.WhereMoonModelN(inst.Model(), coordIndex, jd, n)
}
if lo, bo, distAU, ok := instanceStateEcliptic(ephem.Moon, jd, inst); ok {
switch coordIndex {
case 0:
return lo * math.Pi / 180
case 1:
return bo * math.Pi / 180
case 2:
return distAU * astronomicalUnitKM
}
}
return math.NaN()
}
// MoonCalcNewWithInstance 指定星历实例的月球几何量 / lunar geometry with an explicit ephemeris instance.
func MoonCalcNewWithInstance(coordIndex int, jd float64, inst *Instance) float64 {
return moonCalcNewNWithInstance(coordIndex, jd, -1, inst)
}
// MoonCalcNewNWithInstance 指定星历实例的月球几何量(n 变体) / n-parameterised lunar geometry with an explicit ephemeris instance.
func MoonCalcNewNWithInstance(coordIndex int, jd float64, n int, inst *Instance) float64 {
return moonCalcNewNWithInstance(coordIndex, jd, n, inst)
}
// HMoonTrueLoWithInstance 指定星历实例的月球真黄经 / true lunar longitude with an explicit ephemeris instance.
func HMoonTrueLoWithInstance(jde float64, inst *Instance) float64 {
return HMoonTrueLoNWithInstance(jde, -1, inst)
}
// HMoonTrueLoNWithInstance 指定星历实例的月球真黄经(n 变体) / n-parameterised true lunar longitude with an explicit ephemeris instance.
func HMoonTrueLoNWithInstance(jde float64, n int, inst *Instance) float64 {
return Limit360(moonCalcNewNWithInstance(0, jde, n, inst) * 180 / math.Pi)
}
// HMoonTrueBoWithInstance 指定星历实例的月球真黄纬 / true lunar latitude with an explicit ephemeris instance.
func HMoonTrueBoWithInstance(jde float64, inst *Instance) float64 {
return HMoonTrueBoNWithInstance(jde, -1, inst)
}
// HMoonTrueBoNWithInstance 指定星历实例的月球真黄纬(n 变体) / n-parameterised true lunar latitude with an explicit ephemeris instance.
func HMoonTrueBoNWithInstance(jde float64, n int, inst *Instance) float64 {
return moonCalcNewNWithInstance(1, jde, n, inst) * 180 / math.Pi
}
// HMoonAwayWithInstance 指定星历实例的月地距离 / lunar distance with an explicit ephemeris instance.
func HMoonAwayWithInstance(jde float64, inst *Instance) float64 {
return HMoonAwayNWithInstance(jde, -1, inst)
}
// HMoonAwayNWithInstance 指定星历实例的月地距离(n 变体) / n-parameterised lunar distance with an explicit ephemeris instance.
func HMoonAwayNWithInstance(jde float64, n int, inst *Instance) float64 {
return moonCalcNewNWithInstance(2, jde, n, inst)
}
// HMoonApparentLoWithInstance 指定星历实例的月球视黄经 / apparent lunar longitude with an explicit ephemeris instance.
func HMoonApparentLoWithInstance(jde float64, inst *Instance) float64 {
return HMoonApparentLoNWithInstance(jde, -1, inst)
}
// HMoonApparentLoNWithInstance 指定星历实例的月球视黄经(n 变体) / n-parameterised apparent lunar longitude with an explicit ephemeris instance.
func HMoonApparentLoNWithInstance(jde float64, n int, inst *Instance) float64 {
if lo, _, _, _, ok, err := vectorApparentWithInstance(ephem.Moon, jde, n, inst); err != nil {
return math.NaN()
} else if ok {
return lo
}
return HMoonTrueLoNWithInstance(jde, n, inst) + Nutation2000Bi(jde)
}
// HMoonGeocentricApparentRaDecWithInstance 指定星历实例的月球地心视赤经纬 / apparent geocentric lunar right ascension and declination with an explicit ephemeris instance.
func HMoonGeocentricApparentRaDecWithInstance(jde float64, inst *Instance) (float64, float64) {
return HMoonGeocentricApparentRaDecNWithInstance(jde, -1, inst)
}
// HMoonGeocentricApparentRaDecNWithInstance 指定星历实例的月球地心视赤经纬(n 变体) / n-parameterised apparent geocentric lunar right ascension and declination with an explicit ephemeris instance.
func HMoonGeocentricApparentRaDecNWithInstance(jde float64, n int, inst *Instance) (float64, float64) {
return moonApparentRaDecN(jde, n, inst)
}
// HMoonTrueRaDecWithInstance 指定星历实例的月球真赤经纬 / true lunar right ascension and declination with an explicit ephemeris instance.
func HMoonTrueRaDecWithInstance(jde float64, inst *Instance) (float64, float64) {
return moonApparentRaDecN(jde, -1, inst)
}
// HMoonApparentRaDecWithInstance 指定星历实例的月球站心视赤经纬 / topocentric apparent lunar right ascension and declination with an explicit ephemeris instance.
func HMoonApparentRaDecWithInstance(jd, lon, lat, tz float64, inst *Instance) (float64, float64) {
jde := UTC2TT(jd) - tz/24
ra, dec := HMoonTrueRaDecWithInstance(jde, inst)
away := HMoonAwayWithInstance(jde, inst) / astronomicalUnitKM
return TopocentricRaDec(ra, dec, lat, lon, jd-tz/24, away, 0)
}
@@ -0,0 +1,64 @@
package basic
import (
"encoding/json"
"os"
"testing"
"time"
)
type moonGeocentricApparentSample struct {
InputUTC string `json:"input_utc"`
RightAscension float64 `json:"right_ascension"`
Declination float64 `json:"declination"`
EclipticLongitude float64 `json:"ecliptic_longitude"`
EclipticLatitude float64 `json:"ecliptic_latitude"`
}
func TestMoonGeocentricApparentCoordinatesMatchHorizonsBaseline(t *testing.T) {
data, err := os.ReadFile("testdata/moon_geocentric_apparent_baseline.json")
if err != nil {
t.Fatalf("read baseline: %v", err)
}
var samples []moonGeocentricApparentSample
if err := json.Unmarshal(data, &samples); err != nil {
t.Fatalf("decode baseline: %v", err)
}
if len(samples) == 0 {
t.Fatal("empty moon apparent baseline")
}
for _, sample := range samples {
date, err := time.Parse(time.RFC3339, sample.InputUTC)
if err != nil {
t.Fatalf("parse sample time %q: %v", sample.InputUTC, err)
}
jd := UTC2TT(Date2JD(date.UTC()))
prefix := "moon." + sample.InputUTC
assertPlanetApparentAngleClose(t, prefix+".RightAscension", HMoonGeocentricApparentRa(jd), sample.RightAscension, 0.001)
assertPlanetPhaseClose(t, prefix+".Declination", HMoonGeocentricApparentDec(jd), sample.Declination, 0.001)
assertPlanetApparentAngleClose(t, prefix+".EclipticLongitude", HMoonApparentLo(jd), sample.EclipticLongitude, 0.001)
assertPlanetPhaseClose(t, prefix+".EclipticLatitude", HMoonTrueBo(jd), sample.EclipticLatitude, 0.001)
}
}
func TestMoonGeocentricTrueCoordinatesFollowDefinition(t *testing.T) {
samples := []time.Time{
time.Date(1900, 1, 14, 12, 0, 0, 0, time.UTC),
time.Date(1950, 6, 3, 0, 0, 0, 0, time.UTC),
time.Date(2000, 2, 29, 18, 0, 0, 0, time.UTC),
time.Date(2026, 1, 1, 6, 0, 0, 0, time.UTC),
time.Date(2100, 8, 17, 9, 0, 0, 0, time.UTC),
}
for _, sample := range samples {
jd := UTC2TT(Date2JD(sample.UTC()))
wantRA, wantDec := LoBoToRaDec(jd, HMoonTrueLo(jd), HMoonTrueBo(jd))
gotRA, gotDec := HMoonGeocentricTrueRaDec(jd)
assertPlanetApparentAngleClose(t, sample.Format(time.RFC3339)+".TrueRightAscension", gotRA, wantRA, 1e-12)
assertPlanetPhaseClose(t, sample.Format(time.RFC3339)+".TrueDeclination", gotDec, wantDec, 1e-12)
}
}
+30
View File
@@ -0,0 +1,30 @@
package basic
import (
"math"
"testing"
)
func TestHMoonGeocentricApparentRaDecComponentsMatch(t *testing.T) {
jd := UTC2TT(JDCalc(2026, 1, 1.25))
ra, dec := HMoonGeocentricApparentRaDec(jd)
if diff := math.Abs(ra - HMoonGeocentricApparentRa(jd)); diff > 1e-12 {
t.Fatalf("RA pair mismatch: got %.15f want %.15f", ra, HMoonGeocentricApparentRa(jd))
}
if diff := math.Abs(dec - HMoonGeocentricApparentDec(jd)); diff > 1e-12 {
t.Fatalf("Dec pair mismatch: got %.15f want %.15f", dec, HMoonGeocentricApparentDec(jd))
}
}
func TestHMoonGeocentricTrueRaDecComponentsMatch(t *testing.T) {
jd := UTC2TT(JDCalc(2026, 1, 1.25))
ra, dec := HMoonGeocentricTrueRaDec(jd)
if diff := math.Abs(ra - HMoonGeocentricTrueRa(jd)); diff > 1e-12 {
t.Fatalf("RA pair mismatch: got %.15f want %.15f", ra, HMoonGeocentricTrueRa(jd))
}
if diff := math.Abs(dec - HMoonGeocentricTrueDec(jd)); diff > 1e-12 {
t.Fatalf("Dec pair mismatch: got %.15f want %.15f", dec, HMoonGeocentricTrueDec(jd))
}
}
+11
View File
@@ -0,0 +1,11 @@
package basic
import ()
// MoonHorizonWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func MoonHorizonWithInstance(jdUTC float64, samples int, inst *Instance) [][2]float64 {
if !finite(jdUTC) {
return nil
}
return MoonStateAtWithInstance(jdUTC, inst).MoonHorizon(samples)
}

Some files were not shown because too many files have changed in this diff Show More