feat: 引入可切换星历模型并扩展天象观测链路

- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算
- 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询
- 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出
- 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解
- 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤
- 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持
- 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理
- 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
This commit is contained in:
2026-10-15 23:54:15 +08:00
parent 16c62a97d5
commit 044f2f4381
674 changed files with 62983 additions and 8396 deletions
+125 -36
View File
@@ -4,6 +4,8 @@ import (
"math"
"sort"
"time"
"b612.me/astro/internal/geodata"
)
//哦~我是一颗小地球~~
@@ -19,7 +21,7 @@ const (
solarEclipsePathDurationHorizonToleranceDegrees = 0.1
// 地球表面上的影锥足迹不可能比地球本身更宽。
// No shadow footprint on the Earth can be wider than the Earth itself.
solarEclipsePathMaxPossibleWidthKM = 2 * 6371.0088
solarEclipsePathMaxPossibleWidthKM = 2 * geodata.EarthMeanRadiusKM
solarEclipsePathDuplicateTimeDays = 1e-10
// A short segment can still hide a large directional change at high
// latitude. Use a small relative sagitta tolerance so refinement follows
@@ -120,6 +122,8 @@ type SolarEclipsePathOptions struct {
// most expensive stage of the pipeline and would otherwise be computed twice
// by callers that ask for both products.
SkipCentralBand bool
// LunarLimb 月缘剖面,nil 为圆月面;修正南北界、带宽、中心带足迹、最长中心食时长与全球始末,中心线不变 / lunar limb profile for limits, widths, band, longest duration and P1/P4.
LunarLimb LunarLimb
}
// SolarEclipsePathPoint 表示日食路径上的一个地理点。
@@ -173,6 +177,8 @@ type SolarEclipsePathResult struct {
// the sampled instantaneous footprints because no analytic envelope
// described this event.
CentralBandSampled bool
// CentralLimitsLimbApplied 南北界至少一点按月缘剖面平移,带宽随之修正 / the central limits were moved by the lunar limb.
CentralLimitsLimbApplied bool
// StepDays 是实际采用的基础时间采样步长,单位为日。
// StepDays is the effective base time step in days.
StepDays float64
@@ -224,6 +230,9 @@ type SolarEclipsePartialFootprintOptions struct {
// SunRadiusModel 太阳半径口径,零值为标准档。
// SunRadiusModel is the solar radius convention; the zero value is the standard one.
SunRadiusModel SolarEclipseSunRadiusModel
// LunarLimb 月缘剖面,nil 为圆月面;修正偏食可见域外边界(零食分包络与初亏/复圆升落曲线),
// 内嵌全球结果与 P1/P4 也与全球日食接口同源地修正;食分线与等时线仍按圆月面 / lunar limb profile for the outer partial boundary, embedded global result and P1/P4.
LunarLimb LunarLimb
}
// SolarEclipsePartialAreaOptions 是 SolarEclipsePartialFootprintOptions 的兼容别名。
@@ -295,6 +304,8 @@ type SolarEclipsePartialFootprintsResult struct {
// topology audit (the 16-segment per-curve and 32-segment total caps, shared
// endpoints and monotonic times), so RiseSetCurves is a truncated result.
RiseSetTopologyDegraded bool
// PartialBoundaryLimbApplied 表示偏食可见域外边界至少有一点按月缘剖面平移 / the outer partial boundary was moved by the lunar limb.
PartialBoundaryLimbApplied bool
// P1-P4 是半影与地球的外切/内切接触点;不存在的内切点保持零值。
// P1-P4 are external/internal penumbral contacts; absent internal contacts remain zero.
P1 SolarEclipsePathPoint
@@ -371,16 +382,14 @@ func SolarEclipseCentralPath(seedJDE float64, options SolarEclipsePathOptions) S
return SolarEclipseCentralPathNASABulletinSplitK(seedJDE, options)
}
// SolarEclipseCentralPathIAUSingleK 计算日食中心路径,使用 IAU Single-K 模型。
// SolarEclipseCentralPathIAUSingleK computes the central path with the IAU Single-K model.
func SolarEclipseCentralPathIAUSingleK(seedJDE float64, options SolarEclipsePathOptions) SolarEclipsePathResult {
return solarEclipseCentralPath(seedJDE, SolarEclipseModelIAUSingleK, options)
// SolarEclipseCentralPathIAUSingleKWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func SolarEclipseCentralPathIAUSingleKWithInstance(seedJDE float64, options SolarEclipsePathOptions, inst *Instance) SolarEclipsePathResult {
return solarEclipseCentralPath(seedJDE, SolarEclipseModelIAUSingleK, options, inst)
}
// SolarEclipseCentralPathNASABulletinSplitK 计算日食中心路径,使用 NASA bulletin Split-K 模型。
// SolarEclipseCentralPathNASABulletinSplitK computes the central path with the NASA bulletin Split-K model.
func SolarEclipseCentralPathNASABulletinSplitK(seedJDE float64, options SolarEclipsePathOptions) SolarEclipsePathResult {
return solarEclipseCentralPath(seedJDE, SolarEclipseModelNASABulletinSplitK, options)
// SolarEclipseCentralPathNASABulletinSplitKWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func SolarEclipseCentralPathNASABulletinSplitKWithInstance(seedJDE float64, options SolarEclipsePathOptions, inst *Instance) SolarEclipsePathResult {
return solarEclipseCentralPath(seedJDE, SolarEclipseModelNASABulletinSplitK, options, inst)
}
// SolarEclipsePartialFootprints 计算给定近朔时刻附近的日食偏食半影足迹序列,默认使用 NASA bulletin Split-K 模型。
@@ -389,16 +398,14 @@ func SolarEclipsePartialFootprints(seedJDE float64, options SolarEclipsePartialF
return SolarEclipsePartialFootprintsNASABulletinSplitK(seedJDE, options)
}
// SolarEclipsePartialFootprintsIAUSingleK 计算日食偏食半影足迹序列,使用 IAU Single-K 模型。
// SolarEclipsePartialFootprintsIAUSingleK computes penumbral footprint samples with the IAU Single-K model.
func SolarEclipsePartialFootprintsIAUSingleK(seedJDE float64, options SolarEclipsePartialFootprintOptions) SolarEclipsePartialFootprintsResult {
return solarEclipsePartialFootprints(seedJDE, SolarEclipseModelIAUSingleK, options)
// SolarEclipsePartialFootprintsIAUSingleKWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func SolarEclipsePartialFootprintsIAUSingleKWithInstance(seedJDE float64, options SolarEclipsePartialFootprintOptions, inst *Instance) SolarEclipsePartialFootprintsResult {
return solarEclipsePartialFootprints(seedJDE, SolarEclipseModelIAUSingleK, options, inst)
}
// SolarEclipsePartialFootprintsNASABulletinSplitK 计算日食偏食半影足迹序列,使用 NASA bulletin Split-K 模型。
// SolarEclipsePartialFootprintsNASABulletinSplitK computes penumbral footprint samples with the NASA bulletin Split-K model.
func SolarEclipsePartialFootprintsNASABulletinSplitK(seedJDE float64, options SolarEclipsePartialFootprintOptions) SolarEclipsePartialFootprintsResult {
return solarEclipsePartialFootprints(seedJDE, SolarEclipseModelNASABulletinSplitK, options)
// SolarEclipsePartialFootprintsNASABulletinSplitKWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func SolarEclipsePartialFootprintsNASABulletinSplitKWithInstance(seedJDE float64, options SolarEclipsePartialFootprintOptions, inst *Instance) SolarEclipsePartialFootprintsResult {
return solarEclipsePartialFootprints(seedJDE, SolarEclipseModelNASABulletinSplitK, options, inst)
}
// SolarEclipsePartialArea 计算日食偏食半影足迹序列,是 SolarEclipsePartialFootprints 的兼容包装。
@@ -419,13 +426,15 @@ func SolarEclipsePartialAreaNASABulletinSplitK(seedJDE float64, options SolarEcl
return SolarEclipsePartialFootprintsNASABulletinSplitK(seedJDE, options)
}
func solarEclipseCentralPath(seedJDE float64, model SolarEclipseRadiusModel, options SolarEclipsePathOptions) SolarEclipsePathResult {
func solarEclipseCentralPath(seedJDE float64, model SolarEclipseRadiusModel, options SolarEclipsePathOptions, instances ...*Instance) SolarEclipsePathResult {
inst := instanceValue(instances)
options = normalizeSolarEclipsePathOptions(options)
newMoonJDE := CalcMoonSHByJDE(seedJDE, 0)
newMoonJDE := solarEclipseAnchorNewMoon(seedJDE, inst)
solver := newSolarEclipseSolverWithOptions(newMoonJDE, SolarEclipseOptions{
RadiusModel: model,
SunRadiusModel: options.SunRadiusModel,
}).withDeltaTSeconds(options.DeltaTSeconds)
}, inst).withDeltaTSeconds(options.DeltaTSeconds)
result := solver.eclipseResult()
path := SolarEclipsePathResult{
Eclipse: result,
@@ -447,7 +456,7 @@ func solarEclipseCentralPath(seedJDE float64, model SolarEclipseRadiusModel, opt
result.GreatestEclipse,
result.GreatestLongitude,
result.GreatestLatitude,
solver.besselAxisAt(result.GreatestEclipse).gst,
solver.besselAxisAt(result.GreatestEclipse).gst, inst,
) / rad,
}
}
@@ -486,14 +495,33 @@ func solarEclipseCentralPath(seedJDE float64, model SolarEclipseRadiusModel, opt
solver.setCenterLinePairWidths(
centerLine, result.Centrality, northernLimit, southernLimit, pairedLimits,
)
limb := options.LunarLimb
if limb != nil {
path.Eclipse = solver.withLunarLimb(path.Eclipse, limb)
greatestIndex, circularWidth := solarEclipseNearestCenterWidth(centerLine, result.GreatestEclipse)
northernLimb, northWidthApplied := solver.limbCorrectCentralLimits(northernLimit, limb)
southernLimb, southWidthApplied := solver.limbCorrectCentralLimits(southernLimit, limb)
path.CentralLimitsLimbApplied = northWidthApplied || southWidthApplied
solver.setCenterLinePairWidths(centerLine, result.Centrality, northernLimb, southernLimb, pairedLimits)
// 目录带宽沿用解析口径,只叠加食甚附近成对横截面上的月缘增量。
if greatestIndex >= 0 && circularWidth > 0 && centerLine[greatestIndex].WidthKM > 0 {
delta := centerLine[greatestIndex].WidthKM - circularWidth
path.Eclipse.PathWidthKM += delta
path.Greatest.WidthKM += delta
}
}
if result.Centrality == SolarEclipseCentralTwoLimits {
// A one-limit event has no paired cross-section at all, so every sample
// keeps the 0 that the catalogues also print for it.
setSolarEclipseCentralContactWidths(centerLine, path.NorthernLimit, path.SouthernLimit)
}
path.CenterLine = centerLine
durationSolver := solver
if limb != nil {
durationSolver = solver.withLimbParams(limb)
}
path.MaxCentralDurationDays, path.MaxCentralDurationLongitude, path.MaxCentralDurationLatitude =
solver.centralPathMaxCentralDuration(centerLine)
durationSolver.centralPathMaxCentralDuration(centerLine)
// A two-limit central eclipse starts and ends at the external umbral
// contacts, where the northern and southern limits meet. The center line
// begins later because its Earth-axis intersection is undefined before the
@@ -523,6 +551,10 @@ func solarEclipseCentralPath(seedJDE float64, model SolarEclipseRadiusModel, opt
result.CentralEndOnEarth,
solarEclipseCentralLimitTargetSpacingKM,
)
var northApplied, southApplied bool
path.NorthernLimit, northApplied = solver.limbCorrectCentralLimits(path.NorthernLimit, limb)
path.SouthernLimit, southApplied = solver.limbCorrectCentralLimits(path.SouthernLimit, limb)
path.CentralLimitsLimbApplied = path.CentralLimitsLimbApplied || northApplied || southApplied
path.PathWidthDefined = result.Centrality == SolarEclipseCentralTwoLimits &&
len(path.NorthernLimit) > 0 && len(path.SouthernLimit) > 0
if !path.PathWidthDefined {
@@ -542,10 +574,29 @@ func solarEclipseCentralPath(seedJDE float64, model SolarEclipseRadiusModel, opt
StepDays: options.StepDays, BoundaryPoints: 96, DisableRiseSetCurves: true,
}))
path.CentralBandSegments = band.CentralBandSegments
if limb != nil {
for index, segment := range path.CentralBandSegments {
path.CentralBandSegments[index], _ = solver.limbCorrectCentralLimits(segment, limb)
}
}
path.CentralBandSampled = band.CentralBandSampled
return path
}
// solarEclipseNearestCenterWidth 返回最接近 jde 的中心线样本序号与其带宽,没有样本时序号为 −1。
func solarEclipseNearestCenterWidth(centerLine []SolarEclipsePathPoint, jde float64) (int, float64) {
best, bestDelta := -1, math.Inf(1)
for index, point := range centerLine {
if delta := math.Abs(point.JDE - jde); delta < bestDelta {
best, bestDelta = index, delta
}
}
if best < 0 {
return -1, 0
}
return best, centerLine[best].WidthKM
}
func (solver solarEclipseSolver) attachCentralMagnitudeOneTransitions(
points []SolarEclipsePathPoint,
) []SolarEclipsePathPoint {
@@ -581,13 +632,14 @@ func normalizeSolarEclipsePathOptions(options SolarEclipsePathOptions) SolarEcli
func solarEclipsePartialFootprints(
seedJDE float64,
model SolarEclipseRadiusModel,
options SolarEclipsePartialFootprintOptions,
) SolarEclipsePartialFootprintsResult {
options SolarEclipsePartialFootprintOptions, instances ...*Instance) SolarEclipsePartialFootprintsResult {
inst := instanceValue(instances)
return solarEclipsePartialFootprintsWithResult(
seedJDE, model, options, solarEclipseWithDeltaT(seedJDE, SolarEclipseOptions{
RadiusModel: model,
SunRadiusModel: options.SunRadiusModel,
}, options.DeltaTSeconds),
}, options.DeltaTSeconds, inst), inst,
)
}
@@ -595,8 +647,9 @@ func solarEclipsePartialFootprintsWithResult(
seedJDE float64,
model SolarEclipseRadiusModel,
options SolarEclipsePartialFootprintOptions,
result SolarEclipseResult,
) SolarEclipsePartialFootprintsResult {
result SolarEclipseResult, instances ...*Instance) SolarEclipsePartialFootprintsResult {
inst := instanceValue(instances)
options = normalizeSolarEclipsePartialFootprintOptions(options)
footprintsResult := SolarEclipsePartialFootprintsResult{
Eclipse: result,
@@ -608,20 +661,28 @@ func solarEclipsePartialFootprintsWithResult(
return footprintsResult
}
newMoonJDE := CalcMoonSHByJDE(seedJDE, 0)
newMoonJDE := solarEclipseAnchorNewMoon(seedJDE, inst)
solver := newSolarEclipseSolverWithOptions(newMoonJDE, SolarEclipseOptions{
RadiusModel: model,
SunRadiusModel: options.SunRadiusModel,
}).withDeltaTSeconds(options.DeltaTSeconds)
}, inst).withDeltaTSeconds(options.DeltaTSeconds)
footprintsResult, totalMagnitudeOneSegments := solver.centralBandWithResult(result, options)
// 有月缘时先求全球月缘结果:偏食足迹与食甚等时线必须落在最终报告的 P1–P4 之内。
global, window := result, result
if options.LunarLimb != nil {
global = solver.withLunarLimb(result, options.LunarLimb)
if global.PartialBeginOnEarth < result.GreatestEclipse && result.GreatestEclipse < global.PartialEndOnEarth {
window = global
}
}
// Partial and central-shadow sweeps used to enforce the point budget
// independently. A high-resolution request could therefore allocate
// nearly two budgets for each sweep before the caller saw the result.
// Reserve the already-built central-band samples and share the remaining
// budget between the main time series before either sweep is generated.
partialTimes, _ := solarEclipseMovingDiskEngine().sampleTimes(
result.PartialBeginOnEarth,
result.PartialEndOnEarth,
window.PartialBeginOnEarth,
window.PartialEndOnEarth,
result.GreatestEclipse,
options.StepDays,
)
@@ -654,9 +715,12 @@ func solarEclipsePartialFootprintsWithResult(
false,
)
}
if options.LunarLimb != nil && !options.DisableRiseSetCurves {
footprintsResult.PartialBoundaryLimbApplied = solver.limbCorrectPartialBoundary(&footprintsResult, options.LunarLimb)
}
footprints, stepDays, effectiveBoundaryPoints := solver.partialFootprints(
result.PartialBeginOnEarth,
result.PartialEndOnEarth,
window.PartialBeginOnEarth,
window.PartialEndOnEarth,
result.GreatestEclipse,
options,
)
@@ -694,7 +758,7 @@ func solarEclipsePartialFootprintsWithResult(
greatestTimeOptions := options
if len(greatestTimeOptions.GreatestTimeValues) == 0 && greatestTimeOptions.GreatestTimeStep > 0 {
levels := greatestTimeContourAlignedLevels(
result.PartialBeginOnEarth, result.PartialEndOnEarth,
window.PartialBeginOnEarth, window.PartialEndOnEarth,
greatestTimeOptions.GreatestTimeStep, greatestTimeContourMaxLevels,
)
greatestTimeOptions.GreatestTimeValues = make([]float64, 0, len(levels))
@@ -703,13 +767,36 @@ func solarEclipsePartialFootprintsWithResult(
}
}
footprintsResult.GreatestTimeContours = solver.greatestTimeContours(
result.PartialBeginOnEarth,
result.PartialEndOnEarth,
window.PartialBeginOnEarth,
window.PartialEndOnEarth,
greatestTimeOptions,
)
if options.LunarLimb != nil {
applyFootprintsGlobalLimb(&footprintsResult, global)
}
return footprintsResult
}
// applyFootprintsGlobalLimb 让足迹里的全球结果与 P1/P4 和全球日食接口同源:换上月缘修正后的偏食始末、
// 食甚点中心食时长与接触点位置;偏食足迹与食甚等时线须已按同一时间窗采样。
func applyFootprintsGlobalLimb(footprintsResult *SolarEclipsePartialFootprintsResult, global SolarEclipseResult) {
footprintsResult.Eclipse = global
if !global.LunarLimbApplied {
return
}
// 时刻与位置必须同源:月缘解给出接触点时一并替换,否则保留整套圆月面接触点。
if footprintsResult.P1.JDE != 0 && global.PartialBeginContact.Defined {
footprintsResult.P1.JDE = global.PartialBeginOnEarth
footprintsResult.P1.Longitude = global.PartialBeginContact.Longitude
footprintsResult.P1.Latitude = global.PartialBeginContact.Latitude
}
if footprintsResult.P4.JDE != 0 && global.PartialEndContact.Defined {
footprintsResult.P4.JDE = global.PartialEndOnEarth
footprintsResult.P4.Longitude = global.PartialEndContact.Longitude
footprintsResult.P4.Latitude = global.PartialEndContact.Latitude
}
}
func (solver solarEclipseSolver) centralBandWithResult(
result SolarEclipseResult,
options SolarEclipsePartialFootprintOptions,
@@ -1337,6 +1424,8 @@ func (solver solarEclipseSolver) centralPathMaxCentralDuration(
centerLine []SolarEclipsePathPoint,
) (float64, float64, float64) {
bestDays, bestLongitude, bestLatitude := 0.0, 0.0, 0.0
// 局部星历只建一次;逐点各建一次会让整段扫描的星历开销翻上百倍。
solver = solver.withLocalEphemeris()
for _, point := range centerLine {
if point.SunAltitude < -solarEclipsePathDurationHorizonToleranceDegrees {
continue