feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
package eclipse
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
|
||||
"b612.me/astro/basic"
|
||||
)
|
||||
|
||||
const (
|
||||
sarosInexLunations = 358
|
||||
sarosLunationEpoch = 2451550.09765
|
||||
// Keep the acceptance interval below half a synodic month while allowing
|
||||
// the mean-phase seed to drift in remote eras.
|
||||
sarosEclipseSeedToleranceDays = 7.0
|
||||
// 表外外推按 ±90 个沙罗周期求值同一批朔望月,直映缓存让相邻序列成员复用结果。
|
||||
sarosEclipseMemoSlots = 4096
|
||||
)
|
||||
|
||||
// 键是(朔望月序号,相位);判定与模型无关,只有月序号和相位决定结果。
|
||||
type sarosEclipseMemoEntry struct {
|
||||
key int64
|
||||
maximum float64
|
||||
exists bool
|
||||
valid bool
|
||||
set bool
|
||||
}
|
||||
|
||||
var (
|
||||
sarosEclipseMemoMu sync.RWMutex
|
||||
sarosEclipseMemoEntries [sarosEclipseMemoSlots]sarosEclipseMemoEntry
|
||||
)
|
||||
|
||||
// 直接取模即可:外推窗口的步长 223 与槽数互质,±90 个回次落在互不相同的槽位上。
|
||||
func sarosEclipseMemoSlot(key int64) int {
|
||||
return int(uint64(key) % sarosEclipseMemoSlots)
|
||||
}
|
||||
|
||||
func sarosEclipseLookup(k, phase int) (float64, bool, bool, bool) {
|
||||
key := int64(k)*2 + int64(phase)
|
||||
slot := sarosEclipseMemoSlot(key)
|
||||
sarosEclipseMemoMu.RLock()
|
||||
entry := sarosEclipseMemoEntries[slot]
|
||||
sarosEclipseMemoMu.RUnlock()
|
||||
if !entry.set || entry.key != key {
|
||||
return 0, false, false, false
|
||||
}
|
||||
return entry.maximum, entry.exists, entry.valid, true
|
||||
}
|
||||
|
||||
func sarosEclipseStore(k, phase int, maximum float64, exists, valid bool) {
|
||||
key := int64(k)*2 + int64(phase)
|
||||
slot := sarosEclipseMemoSlot(key)
|
||||
sarosEclipseMemoMu.Lock()
|
||||
sarosEclipseMemoEntries[slot] = sarosEclipseMemoEntry{
|
||||
key: key, maximum: maximum, exists: exists, valid: valid, set: true,
|
||||
}
|
||||
sarosEclipseMemoMu.Unlock()
|
||||
}
|
||||
|
||||
// A span contains consecutive 223-month returns. Separate spans preserve gaps
|
||||
// in shallow series without counting the missing returns as eclipses.
|
||||
type sarosSpan struct {
|
||||
Series int
|
||||
First int
|
||||
Last int
|
||||
Member int
|
||||
Count int
|
||||
}
|
||||
|
||||
func sarosLunation(ttJDE float64, phase int) (int, bool) {
|
||||
k := (ttJDE-sarosLunationEpoch)/solarEclipseSynodicMonthDays - float64(phase)/2
|
||||
if math.IsNaN(k) || math.IsInf(k, 0) || math.Abs(k) > 1e7 {
|
||||
return 0, false
|
||||
}
|
||||
return int(math.Round(k)), true
|
||||
}
|
||||
|
||||
// 358*38 = 1 (mod 223). Select the Inex column whose Saros row is nearest
|
||||
// the catalog reference; neighboring solutions are 358 Saros rows apart.
|
||||
// See NASA SEperiodicity.html, sections 1.7 and 1.9 (also valid for lunar series).
|
||||
func sarosNumber(k, phase int) int {
|
||||
anchors, base := solarSarosAnchors[:], 0
|
||||
if phase == 1 {
|
||||
anchors, base = lunarSarosAnchors[:], 1
|
||||
}
|
||||
anchor := decodeSarosMagic(anchors[len(anchors)/2], base+len(anchors)/2)
|
||||
refTT := basic.JDECalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day))
|
||||
refK, _ := sarosLunation(refTT, phase)
|
||||
delta := k - refK
|
||||
column := (38 * delta) % sarosCycleLunations
|
||||
column += sarosCycleLunations * int(math.Round((float64(delta)/sarosInexLunations-float64(column))/sarosCycleLunations))
|
||||
return int(anchor.Series) + column
|
||||
}
|
||||
|
||||
func matchSarosSpans(spans []sarosSpan, k int) (SarosInfo, bool) {
|
||||
for _, span := range spans {
|
||||
if k < span.First || k > span.Last || (k-span.First)%sarosCycleLunations != 0 {
|
||||
continue
|
||||
}
|
||||
return SarosInfo{
|
||||
Series: span.Series,
|
||||
Member: span.Member + (k-span.First)/sarosCycleLunations,
|
||||
Count: span.Count,
|
||||
}, true
|
||||
}
|
||||
return SarosInfo{}, false
|
||||
}
|
||||
|
||||
func extendedSarosInfo(ttJDE float64, phase int) (SarosInfo, bool) {
|
||||
k, ok := sarosLunation(ttJDE, phase)
|
||||
if !ok {
|
||||
return SarosInfo{}, false
|
||||
}
|
||||
if ttJDE >= sarosExtendedStartTT && ttJDE < sarosExtendedEndTT {
|
||||
spans := solarSarosExtended[:]
|
||||
if phase == 1 {
|
||||
spans = lunarSarosExtended[:]
|
||||
}
|
||||
info, ok := matchSarosSpans(spans, k)
|
||||
if !ok {
|
||||
return SarosInfo{}, false
|
||||
}
|
||||
if anchor, known := sarosAnchorRangeFor(phase, info.Series); known {
|
||||
info.Count = anchor.count
|
||||
}
|
||||
return info, true
|
||||
}
|
||||
return extrapolateSaros(k, phase)
|
||||
}
|
||||
|
||||
func sarosEclipse(k, phase int) (float64, bool, bool) {
|
||||
if maximum, exists, valid, ok := sarosEclipseLookup(k, phase); ok {
|
||||
return maximum, exists, valid
|
||||
}
|
||||
maximum, exists, valid := sarosEclipseUncached(k, phase)
|
||||
sarosEclipseStore(k, phase, maximum, exists, valid)
|
||||
return maximum, exists, valid
|
||||
}
|
||||
|
||||
// Saros metadata uses the Split-K solar model and the union of Danjon and
|
||||
// Chauvenet lunar detections, independent of the observer or display model.
|
||||
func sarosEclipseUncached(k, phase int) (float64, bool, bool) {
|
||||
seed := sarosLunationEpoch + (float64(k)+float64(phase)/2)*solarEclipseSynodicMonthDays
|
||||
var maximum float64
|
||||
var exists bool
|
||||
if phase == 0 {
|
||||
result := basic.SolarEclipseNASABulletinSplitK(seed)
|
||||
maximum, exists = result.GreatestEclipse, result.Type != basic.SolarEclipseNone
|
||||
} else {
|
||||
result := basic.LunarEclipseDanjon(seed)
|
||||
if result.Type == basic.LunarEclipseNone {
|
||||
result = basic.LunarEclipseChauvenet(seed)
|
||||
}
|
||||
maximum, exists = result.Maximum, result.Type != basic.LunarEclipseNone
|
||||
}
|
||||
valid := !math.IsNaN(maximum) && !math.IsInf(maximum, 0) && math.Abs(maximum-seed) < sarosEclipseSeedToleranceDays
|
||||
return maximum, exists, valid
|
||||
}
|
||||
|
||||
func extrapolateSaros(k, phase int) (SarosInfo, bool) {
|
||||
info := SarosInfo{Series: sarosNumber(k, phase)}
|
||||
for offset := -sarosExtrapolationWindow; offset <= sarosExtrapolationWindow; offset++ {
|
||||
_, exists, valid := sarosEclipse(k+offset*sarosCycleLunations, phase)
|
||||
if !valid || (offset == 0 && !exists) {
|
||||
return SarosInfo{}, false
|
||||
}
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if offset == -sarosExtrapolationWindow || offset == sarosExtrapolationWindow {
|
||||
return SarosInfo{}, false
|
||||
}
|
||||
info.Count++
|
||||
if offset <= 0 {
|
||||
info.Member++
|
||||
}
|
||||
}
|
||||
return info, true
|
||||
}
|
||||
Reference in New Issue
Block a user