Files
astro/basic/sky_events.go
T
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

270 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package basic
import (
"math"
"b612.me/astro/internal/geodata"
)
// SkyBody 通用天象搜索支持的天体 / body for generic sky event searches.
type SkyBody int
const (
// SkySun 太阳 / the Sun.
SkySun SkyBody = iota
// SkyMoon 月球 / the Moon.
SkyMoon
// SkyMercury 水星 / Mercury.
SkyMercury
// SkyVenus 金星 / Venus.
SkyVenus
// SkyMars 火星 / Mars.
SkyMars
// SkyJupiter 木星 / Jupiter.
SkyJupiter
// SkySaturn 土星 / Saturn.
SkySaturn
// SkyUranus 天王星 / Uranus.
SkyUranus
// SkyNeptune 海王星 / Neptune.
SkyNeptune
)
// SkyEvent 通用天象事件:TT 儒略日与事件时的角量(度)/ generic sky event at TT JDE with its angle in degrees.
type SkyEvent struct {
JDE float64
Value float64
}
func (body SkyBody) valid() bool { return body >= SkySun && body <= SkyNeptune }
// skyScanStep 保证相邻采样间视黄经变化远小于 180°,逆行回转也不会漏根。
func skyScanStep(bodies ...SkyBody) float64 {
step := 2.0
for _, b := range bodies {
switch b {
case SkyMoon:
step = math.Min(step, 0.25)
case SkyMercury, SkyVenus, SkySun:
step = math.Min(step, 1.0)
}
}
return step
}
// SkyBodyApparentLoBoWithInstance 地心视黄经、视黄纬(度)/ geocentric apparent ecliptic longitude and latitude.
func SkyBodyApparentLoBoWithInstance(body SkyBody, jde float64, inst *Instance) (float64, float64) {
switch body {
case SkySun:
return sunApparentLoBoN(jde, -1, inst)
case SkyMoon:
return moonApparentLoBoN(jde, -1, inst)
case SkyMercury, SkyVenus, SkyMars, SkyJupiter, SkySaturn, SkyUranus, SkyNeptune:
return planetApparentLoBoWithInstance(int(body-SkyMercury)+1, jde, inst)
}
return math.NaN(), math.NaN()
}
// skyGridMaxIntervals 网格段数上限;须能放进 32 位 int,超出时扫描本身也不可能完成。
const skyGridMaxIntervals = 1 << 30
// skyGrid 把 [start, end] 均分为至少 minIntervals 段、每段不超过 step;按下标取点,浮点下必然前进且末点恰为 end。
func skyGrid(start, end, step float64, minIntervals int) (count int, at func(int) float64, ok bool) {
span := end - start
if !finite(start) || !finite(end) || !(span > 0) || !(step > 0) {
return 0, nil, false
}
ratio := math.Ceil(span / step)
if !(ratio < skyGridMaxIntervals) {
return 0, nil, false
}
count = minIntervals
if ratio > float64(count) {
count = int(ratio)
}
return count, func(index int) float64 {
if index == count {
return end
}
return start + span*float64(index)/float64(count)
}, true
}
// skyRoots 在闭区间 [start, end] 内按 step 扫描 f 的零点并二分到约 0.1 秒;f 须连续(已处理 360° 回绕)。
func skyRoots(start, end, step float64, f func(float64) float64) []float64 {
count, at, ok := skyGrid(start, end, step, 1)
if !ok {
return nil
}
var roots []float64
prevT, prev := start, f(start)
if prev == 0 {
roots = append(roots, start)
}
for index := 1; index <= count; index++ {
t := at(index)
cur := f(t)
switch {
case cur == 0:
roots = append(roots, t)
// 回绕跳变时 |差| 接近 360,不是真根;prev 为 0 的根已在上一点记过。
case prev != 0 && !math.IsNaN(prev) && !math.IsNaN(cur) && (prev < 0) != (cur < 0) && math.Abs(cur-prev) < 180:
lo, hi, flo := prevT, t, prev
// 迭代上限防止大 JDE 下浮点间隔大于容差时不收敛。
for i := 0; i < 100 && hi-lo > 1e-6; i++ {
mid := (lo + hi) / 2
fm := f(mid)
if fm == 0 {
lo, hi = mid, mid
break
}
if (fm < 0) == (flo < 0) {
lo, flo = mid, fm
} else {
hi = mid
}
}
roots = append(roots, (lo+hi)/2)
}
prevT, prev = t, cur
}
return roots
}
// SkyLongitudeCrossingsWithInstance body 视黄经经过 longitude 的全部时刻 / times when the apparent longitude equals longitude.
func SkyLongitudeCrossingsWithInstance(body SkyBody, longitude, startJDE, endJDE float64, inst *Instance) []SkyEvent {
if !body.valid() || !(endJDE > startJDE) {
return nil
}
f := func(t float64) float64 {
lo, _ := SkyBodyApparentLoBoWithInstance(body, t, inst)
return geodata.NormalizeLongitude(lo - longitude)
}
var out []SkyEvent
for _, t := range skyRoots(startJDE, endJDE, skyScanStep(body), f) {
out = append(out, SkyEvent{JDE: t, Value: longitude})
}
return out
}
// SkyStationsWithInstance 视黄经留(速度过零)的时刻,Value 为留时视黄经 / longitude stations; Value is the longitude.
func SkyStationsWithInstance(body SkyBody, startJDE, endJDE float64, inst *Instance) []SkyEvent {
if !body.valid() || body == SkySun || body == SkyMoon || !(endJDE > startJDE) {
return nil
}
const h = 0.05
rate := func(t float64) float64 {
a, _ := SkyBodyApparentLoBoWithInstance(body, t-h, inst)
b, _ := SkyBodyApparentLoBoWithInstance(body, t+h, inst)
return geodata.NormalizeLongitude(b - a)
}
var out []SkyEvent
for _, t := range skyRoots(startJDE, endJDE, skyScanStep(body), rate) {
lo, _ := SkyBodyApparentLoBoWithInstance(body, t, inst)
out = append(out, SkyEvent{JDE: t, Value: lo})
}
return out
}
// SkyAspectCrossingsWithInstance a 与 b 视黄经差(a − b)等于 angle 的时刻;angle 0 为黄经合 / times when lon(a) − lon(b) equals angle.
func SkyAspectCrossingsWithInstance(a, b SkyBody, angle, startJDE, endJDE float64, inst *Instance) []SkyEvent {
if !a.valid() || !b.valid() || a == b || !(endJDE > startJDE) {
return nil
}
f := func(t float64) float64 {
la, _ := SkyBodyApparentLoBoWithInstance(a, t, inst)
lb, _ := SkyBodyApparentLoBoWithInstance(b, t, inst)
return geodata.NormalizeLongitude(la - lb - angle)
}
var out []SkyEvent
for _, t := range skyRoots(startJDE, endJDE, skyScanStep(a, b), f) {
out = append(out, SkyEvent{JDE: t, Value: angle})
}
return out
}
func skyAngularDistance(lon1, lat1, lon2, lat2 float64) float64 {
v1, v2 := sphericalUnit(lon1, lat1), sphericalUnit(lon2, lat2)
cross := [3]float64{v1[1]*v2[2] - v1[2]*v2[1], v1[2]*v2[0] - v1[0]*v2[2], v1[0]*v2[1] - v1[1]*v2[0]}
return math.Atan2(math.Sqrt(cross[0]*cross[0]+cross[1]*cross[1]+cross[2]*cross[2]), v1[0]*v2[0]+v1[1]*v2[1]+v1[2]*v2[2]) / rad
}
// skyGoldenMin 黄金分割求 [lo, hi] 内单峰 f 的极小点。
func skyGoldenMin(lo, hi float64, f func(float64) float64) (float64, float64) {
const g = 0.6180339887498949
x1, x2 := hi-g*(hi-lo), lo+g*(hi-lo)
fx1, fx2 := f(x1), f(x2)
for i := 0; i < 200 && hi-lo > 1e-6; i++ {
if fx1 < fx2 {
hi, x2, fx2 = x2, x1, fx1
x1 = hi - g*(hi-lo)
fx1 = f(x1)
} else {
lo, x1, fx1 = x1, x2, fx2
x2 = lo + g*(hi-lo)
fx2 = f(x2)
}
}
t := (lo + hi) / 2
return t, f(t)
}
// skyMinima 扫描 f 在 [start, end] 内的局部极小并用黄金分割细化;只保留 ≤ maxValue 的极小。
func skyMinima(start, end, step, maxValue float64, f func(float64) float64) []SkyEvent {
count, at, ok := skyGrid(start, end, step, 2)
if !ok {
return nil
}
var out []SkyEvent
add := func(lo, hi, boundary float64) {
// 端点段细化值须低于边界采样值,否则极小在窗口外,不是窗口内的局部极小。
if t, v := skyGoldenMin(lo, hi, f); v <= maxValue && v < boundary {
out = append(out, SkyEvent{JDE: t, Value: v})
}
}
t0, t1 := at(0), at(1)
f0, f1 := f(t0), f(t1)
if f0 < f1 {
add(t0, t1, f0)
}
for index := 2; index <= count; index++ {
t2 := at(index)
f2 := f(t2)
if f1 <= f0 && f1 < f2 {
add(t0, t2, math.Inf(1))
}
t0, t1, f0, f1 = t1, t2, f1, f2
}
if f1 < f0 {
add(t0, t1, f1)
}
return out
}
// SkySeparationMinimaWithInstance a、b 地心视角距的局部极小(度),只返回 ≤ maxSeparation 的 / closest approaches within maxSeparation degrees.
func SkySeparationMinimaWithInstance(a, b SkyBody, maxSeparation, startJDE, endJDE float64, inst *Instance) []SkyEvent {
if !a.valid() || !b.valid() || a == b || !(endJDE > startJDE) {
return nil
}
f := func(t float64) float64 {
la, ba := SkyBodyApparentLoBoWithInstance(a, t, inst)
lb, bb := SkyBodyApparentLoBoWithInstance(b, t, inst)
return skyAngularDistance(la, ba, lb, bb)
}
return skyMinima(startJDE, endJDE, skyScanStep(a, b)/2, maxSeparation, f)
}
// SkyStarSeparationMinimaWithInstance 天体与恒星地心视角距的局部极小(度)/ closest approaches of a body to a star within maxSeparation degrees.
func SkyStarSeparationMinimaWithInstance(body SkyBody, star StarCoordinate, maxSeparation, startJDE, endJDE float64, inst *Instance) []SkyEvent {
if !body.valid() || !(endJDE > startJDE) {
return nil
}
f := func(t float64) float64 {
ra, dec := starApparentRaDecGeocentric(t, star, inst)
sl, sb := RaDecToLoBo(t, ra, dec)
lo, bo := SkyBodyApparentLoBoWithInstance(body, t, inst)
return skyAngularDistance(lo, bo, sl, sb)
}
return skyMinima(startJDE, endJDE, skyScanStep(body)/2, maxSeparation, f)
}