astro/eclipse/saros.go
starainrt bec7b8a0d8
feat: 增强日月食搜索、沙罗周期与内行星凌日
- 使用压缩表加速查找日月食沙罗周期信息
- 优化日月食搜索跳步,减少非食季朔望月扫描
- 新增本地日全食、日环食、月全食搜索接口,返回 ok 区分未找到结果
- 新增水星、金星地心凌日查询及测试
2026-05-03 19:00:08 +08:00

275 lines
7.9 KiB
Go

package eclipse
import (
"math"
"time"
"b612.me/astro/basic"
)
const (
sarosCycleLunations = 223
sarosCycleDays = float64(sarosCycleLunations) * solarEclipseSynodicMonthDays
sarosWalkLimit = 100
sarosMagicYearOffset = 3000
sarosMagicCountMask = 0x7f
sarosMagicDayMask = 0x1f
sarosMagicMonthMask = 0x0f
sarosMagicYearMask = 0x1fff
sarosMagicCountShift = 0
sarosMagicDayShift = 7
sarosMagicMonthShift = 12
sarosMagicYearShift = 16
sarosMagicMatchLimitDay = 12.0
sarosMagicTieEpsilonDay = 1e-9
)
// SarosInfo 沙罗序列信息, Saros series metadata.
type SarosInfo struct {
// Series 是 NASA 沙罗系列编号;太阳食可能出现 0 号系列。
// Series is the NASA Saros series number; solar eclipses may use series 0.
Series int
// Member 是本次食在该系列中的序号,从 1 开始计数。
// Member is the 1-based index of this eclipse within the series.
Member int
// Count 是该系列的总成员数。
// Count is the total number of eclipses in the series.
Count int
}
type sarosMagic uint32
type sarosAnchor struct {
Series int16
Count uint8
Year int16
Month uint8
Day uint8
}
type sarosHeadOverride struct {
Series int16
Count uint8
HeadYear int16
HeadMonth uint8
HeadDay uint8
MemberOffset int8
}
var solarSarosHeadOverrides = [...]sarosHeadOverride{
{Series: 22, Count: 71, HeadYear: -2192, HeadMonth: 5, HeadDay: 17, MemberOffset: -1},
}
var lunarSarosHeadOverrides = [...]sarosHeadOverride{
{Series: 4, Count: 78, HeadYear: -2483, HeadMonth: 1, HeadDay: 12, MemberOffset: 2},
{Series: 8, Count: 86, HeadYear: -2494, HeadMonth: 8, HeadDay: 7, MemberOffset: 0},
{Series: 61, Count: 78, HeadYear: -762, HeadMonth: 12, HeadDay: 24, MemberOffset: 1},
}
func solarSarosInfo(ttJDE float64) (SarosInfo, bool) {
if info, ok := matchSarosMagic(solarSarosAnchors[:], 0, solarSarosHeadOverrides[:], ttJDE); ok {
return info, true
}
return solarSarosInfoByWalk(ttJDE)
}
func lunarSarosInfo(ttJDE float64) (SarosInfo, bool) {
if info, ok := matchSarosMagic(lunarSarosAnchors[:], 1, lunarSarosHeadOverrides[:], ttJDE); ok {
return info, true
}
return lunarSarosInfoByWalk(ttJDE)
}
func solarSarosInfoByWalk(ttJDE float64) (SarosInfo, bool) {
headTT, member, ok := solarSarosHead(ttJDE)
if !ok {
return SarosInfo{}, false
}
if info, ok := matchSarosHeadOverride(solarSarosHeadOverrides[:], headTT, member); ok {
return info, true
}
anchor, ok := matchSarosAnchor(solarSarosAnchors[:], 0, headTT)
if !ok || member > int(anchor.Count) {
return SarosInfo{}, false
}
return SarosInfo{
Series: int(anchor.Series),
Member: member,
Count: int(anchor.Count),
}, true
}
func lunarSarosInfoByWalk(ttJDE float64) (SarosInfo, bool) {
headTT, member, ok := lunarSarosHead(ttJDE)
if !ok {
return SarosInfo{}, false
}
if info, ok := matchSarosHeadOverride(lunarSarosHeadOverrides[:], headTT, member); ok {
return info, true
}
anchor, ok := matchSarosAnchor(lunarSarosAnchors[:], 1, headTT)
if !ok || member > int(anchor.Count) {
return SarosInfo{}, false
}
return SarosInfo{
Series: int(anchor.Series),
Member: member,
Count: int(anchor.Count),
}, true
}
func solarSarosHead(ttJDE float64) (float64, int, bool) {
currentTT := ttJDE
member := 1
for step := 0; step < sarosWalkLimit; step++ {
previousSeed := basic.CalcMoonSHByJDE(currentTT-sarosCycleDays, 0)
previous := basic.SolarEclipseNASABulletinSplitK(previousSeed)
if previous.Type == basic.SolarEclipseNone {
return currentTT, member, true
}
currentTT = previous.GreatestEclipse
member++
}
return 0, 0, false
}
func lunarSarosHead(ttJDE float64) (float64, int, bool) {
currentTT := ttJDE
member := 1
for step := 0; step < sarosWalkLimit; step++ {
previousSeed := basic.CalcMoonSHByJDE(currentTT-sarosCycleDays, 1)
previous := basic.LunarEclipseDanjon(previousSeed)
if previous.Type == basic.LunarEclipseNone {
return currentTT, member, true
}
currentTT = previous.Maximum
member++
}
return 0, 0, false
}
func matchSarosMagic(anchors []sarosMagic, seriesBase int, overrides []sarosHeadOverride, ttJDE float64) (SarosInfo, bool) {
if info, ok := matchSarosMagicOverrides(overrides, ttJDE); ok {
return info, true
}
bestDistance := math.Inf(1)
best := SarosInfo{}
for index, magic := range anchors {
anchor := decodeSarosMagic(magic, seriesBase+index)
info, distance, ok := matchSarosMagicCandidate(ttJDE, anchor, 0)
if !ok {
continue
}
if betterSarosMagicMatch(info, distance, best, bestDistance) {
bestDistance = distance
best = info
}
}
if bestDistance <= sarosMagicMatchLimitDay {
return best, true
}
return SarosInfo{}, false
}
func matchSarosMagicOverrides(overrides []sarosHeadOverride, ttJDE float64) (SarosInfo, bool) {
bestDistance := math.Inf(1)
best := SarosInfo{}
for _, override := range overrides {
anchor := sarosAnchor{
Series: override.Series,
Count: override.Count,
Year: override.HeadYear,
Month: override.HeadMonth,
Day: override.HeadDay,
}
info, distance, ok := matchSarosMagicCandidate(ttJDE, anchor, int(override.MemberOffset))
if !ok {
continue
}
if betterSarosMagicMatch(info, distance, best, bestDistance) {
bestDistance = distance
best = info
}
}
if bestDistance <= sarosMagicMatchLimitDay {
return best, true
}
return SarosInfo{}, false
}
func matchSarosMagicCandidate(ttJDE float64, anchor sarosAnchor, memberOffset int) (SarosInfo, float64, bool) {
headTT := basic.JDECalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day))
if math.IsNaN(headTT) {
return SarosInfo{}, 0, false
}
member := int(math.Round((ttJDE-headTT)/sarosCycleDays)) + 1 + memberOffset
if member < 1 || member > int(anchor.Count) {
return SarosInfo{}, 0, false
}
expectedTT := headTT + float64(member-1-memberOffset)*sarosCycleDays
return SarosInfo{
Series: int(anchor.Series),
Member: member,
Count: int(anchor.Count),
}, math.Abs(ttJDE - expectedTT), true
}
func betterSarosMagicMatch(info SarosInfo, distance float64, best SarosInfo, bestDistance float64) bool {
if distance < bestDistance-sarosMagicTieEpsilonDay {
return true
}
if math.Abs(distance-bestDistance) > sarosMagicTieEpsilonDay {
return false
}
if info.Series != best.Series {
return info.Series < best.Series
}
return info.Member < best.Member
}
func decodeSarosMagic(magic sarosMagic, series int) sarosAnchor {
value := uint32(magic)
return sarosAnchor{
Series: int16(series),
Count: uint8((value >> sarosMagicCountShift) & sarosMagicCountMask),
Year: int16(int((value>>sarosMagicYearShift)&sarosMagicYearMask) - sarosMagicYearOffset),
Month: uint8((value >> sarosMagicMonthShift) & sarosMagicMonthMask),
Day: uint8((value >> sarosMagicDayShift) & sarosMagicDayMask),
}
}
func matchSarosAnchor(anchors []sarosMagic, seriesBase int, headTT float64) (sarosAnchor, bool) {
headDate := basic.JDE2DateByZone(headTT, time.UTC, true)
year, month, day := headDate.Date()
monthNumber := int(month)
for index, magic := range anchors {
anchor := decodeSarosMagic(magic, seriesBase+index)
if int(anchor.Year) == year && int(anchor.Month) == monthNumber && int(anchor.Day) == day {
return anchor, true
}
}
return sarosAnchor{}, false
}
func matchSarosHeadOverride(overrides []sarosHeadOverride, headTT float64, member int) (SarosInfo, bool) {
headDate := basic.JDE2DateByZone(headTT, time.UTC, true)
year, month, day := headDate.Date()
monthNumber := int(month)
for _, override := range overrides {
if int(override.HeadYear) != year || int(override.HeadMonth) != monthNumber || int(override.HeadDay) != day {
continue
}
adjustedMember := member + int(override.MemberOffset)
if adjustedMember < 1 || adjustedMember > int(override.Count) {
return SarosInfo{}, false
}
return SarosInfo{
Series: int(override.Series),
Member: adjustedMember,
Count: int(override.Count),
}, true
}
return SarosInfo{}, false
}