Files
astro/basic/solar_eclipse.go
T
b612 16c62a97d5 feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持
- 改进日月食、月掩、行星事件及路径边界计算
- 完善恒星三维自行与动态距离传播
- 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具
- 整理中英文手册、示例资源及回归测试
2026-09-23 18:55:12 +08:00

1051 lines
39 KiB
Go
Raw Permalink 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"
// SolarEclipseRadiusModel 表示日食计算中月亮平均半径 k 的取法。
type SolarEclipseRadiusModel string
const (
// SolarEclipseModelIAUSingleK 使用 IAU 单一月亮平均半径 k。
SolarEclipseModelIAUSingleK SolarEclipseRadiusModel = "iau_single_k"
// SolarEclipseModelNASABulletinSplitK 使用 NASA bulletin 的 Split-K 口径。
SolarEclipseModelNASABulletinSplitK SolarEclipseRadiusModel = "nasa_bulletin_split_k"
)
// SolarEclipseSunRadiusModel 日食几何的太阳半径口径 / solar radius convention for eclipse geometry.
type SolarEclipseSunRadiusModel string
const (
// SolarEclipseSunRadiusStandard 标准档,1 AU 处 959.639″,复现已发布星历表与目录 / standard.
SolarEclipseSunRadiusStandard SolarEclipseSunRadiusModel = "standard"
// SolarEclipseSunRadiusMeasured 边缘档,1 AU 处 959.95″:全食带每侧约窄 0.6 千米、中心食时长约短 1.5 秒 / measured.
SolarEclipseSunRadiusMeasured SolarEclipseSunRadiusModel = "measured"
)
// SolarEclipseOptions 日食计算的半径口径 / radius conventions for a solar eclipse computation.
type SolarEclipseOptions struct {
// RadiusModel 月亮平均半径 k 的口径,零值为 NASA bulletin Split-K / lunar radius model.
RadiusModel SolarEclipseRadiusModel
// SunRadiusModel 太阳半径口径,零值为标准档 / solar radius convention.
SunRadiusModel SolarEclipseSunRadiusModel
}
// SolarEclipseType 整场日食的全局食型。
type SolarEclipseType string
const (
// SolarEclipseNone 表示该次朔月没有发生日食。
SolarEclipseNone SolarEclipseType = "none"
// SolarEclipsePartial 表示日偏食。
SolarEclipsePartial SolarEclipseType = "partial"
// SolarEclipseAnnular 表示日环食。
SolarEclipseAnnular SolarEclipseType = "annular"
// SolarEclipseTotal 表示日全食。
SolarEclipseTotal SolarEclipseType = "total"
// SolarEclipseHybrid 表示全环食/混合食。
SolarEclipseHybrid SolarEclipseType = "hybrid"
)
// SolarEclipseCentrality 表示中心线进入地球的方式。
type SolarEclipseCentrality string
const (
// SolarEclipseNonCentral 表示无中心线进入地球。
SolarEclipseNonCentral SolarEclipseCentrality = "non_central"
// SolarEclipseCentralOneLimit 表示中心线只形成一侧极限条件。
SolarEclipseCentralOneLimit SolarEclipseCentrality = "central_one_limit"
// SolarEclipseCentralTwoLimits 表示中心线完整进入地球,两侧都有界线。
SolarEclipseCentralTwoLimits SolarEclipseCentrality = "central_two_limits"
)
// SolarEclipseResult 表示一次朔月附近的全局日食几何结果。
//
// 所有时刻字段都使用力学时儒略日(JDE, TT)。
// 输入 seedJDE 只需要落在目标朔月附近,允许相差数天。
type SolarEclipseResult struct {
// 下列字段是决定上述数值的口径,随结果一起保留。
// The fields below are the conventions that fix the numbers above.
Model SolarEclipseRadiusModel
SunRadiusModel SolarEclipseSunRadiusModel
Type SolarEclipseType
Centrality SolarEclipseCentrality
// GreatestEclipse 是全局“影轴最接近地心”的时刻。
GreatestEclipse float64
// PartialBeginOnEarth / PartialEndOnEarth 是地球范围的偏食开始 / 结束时刻。
PartialBeginOnEarth float64
PartialEndOnEarth float64
// CentralBeginOnEarth / CentralEndOnEarth 是中心线进入 / 离开地球的时刻。
CentralBeginOnEarth float64
CentralEndOnEarth float64
// Magnitude 是全局食分。
Magnitude float64
// Gamma 是月影轴到地心的有符号最小距离,单位为地球赤道半径。
Gamma float64
// CentralDurationDays 是食甚点的中心食持续时间,单位为日;没有中心食时为 0。
// 这是日食目录(如 NASA「Central Dur.」)采用的口径:食甚点的中心食时长。
// CentralDurationDays is the central-phase duration at the greatest eclipse,
// in days, and 0 when the event has no central phase.
CentralDurationDays float64
// PathWidthKM 是食甚处中心食带宽度;非中心食为 0;单侧极限(中心带仅触及地球边缘)时该解析式
// 失效并一并置 0,此时 PathWidthDefined 为 false,NASA 目录该栏印 '-'。
// PathWidthKM is the central path width at greatest eclipse, 0 for a non-central
// event, and 0 when the analytic formula fails at a single-sided limit where the
// band only grazes the Earth's limb; PathWidthDefined is false there and
// catalogues print '-' for this column.
PathWidthKM float64
// PathWidthDefined 表示上面的带宽是否有定义:只有南北两限都存在(central_two_limits)时才为 true。
// PathWidthDefined reports whether the width above is defined: it is true only
// when both band limits exist, that is for central_two_limits.
PathWidthDefined bool
// GreatestLongitude / GreatestLatitude 是日食食甚点地理坐标,东经为正,西经为负。
GreatestLongitude float64
GreatestLatitude float64
HasPartial bool
HasCentral bool
HasAnnular bool
HasTotal bool
HasHybrid bool
}
type solarEclipseModelParameters struct {
penumbralK float64
umbralK float64
sunRadiusRatio float64
}
type solarEclipseShadowRadii struct {
penumbraRadius float64
umbraRadius float64
absUmbraRadius float64
magnitude float64
}
type solarEclipseAxis struct {
rightAscension float64
tilt float64
gst float64
}
type solarEclipseSolver struct {
newMoonJDE float64
model SolarEclipseRadiusModel
sunRadiusModel SolarEclipseSunRadiusModel
params solarEclipseModelParameters
localStateContextCache map[uint64]localSolarEclipseStateContext
localEphemeris *solarEclipseLocalEphemeris
// deltaTSeconds 是调用方显式给出的 ΔT(秒);NaN 表示未覆盖,用进程级模型。
// 只影响地球自转相位(轴的 gst),不改变任何 TT 时刻。
deltaTSeconds float64
besselGeometryCache map[uint64]solarEclipseBesselGeometryCacheEntry
besselCandidateCache map[uint64]solarEclipseBesselGeometryCacheEntry
exactCentralContact bool
meanSunMoonDistance float64
penumbraConeTangent float64
umbraConeTangent float64
}
const solarEclipseBesselGeometryCacheMaximumEntries = movingDiskEventCacheMaximumEntries
// solarEclipseBesselGeometryCacheEntry keeps exact and candidate geometry in
// separate maps. Candidate geometry is interpolated and is only suitable for
// coarse scans; mixing it with exact geometry would silently reduce contact
// and topology accuracy.
type solarEclipseBesselGeometryCacheEntry struct {
// generation 记录写入时的 ΔT 世代:轴里的 gst 由 ΔT 决定,ΔT 覆盖后条目必须失效。
// generation is the ΔT generation at write time: the axis carries a ΔT-dependent
// gst, so overriding ΔT has to invalidate the entry.
generation uint64
moon [3]float64
axis solarEclipseAxis
sun [3]float64
valid bool
}
type solarEclipseFeature struct {
greatestEclipseJDE float64
greatestLongitude float64
greatestLatitude float64
magnitude float64
gamma float64
pathWidthKM float64
partialBeginJDE float64
partialEndJDE float64
centralBeginJDE float64
centralEndJDE float64
typeCode string
}
type solarEclipseLineIntersection struct {
valid bool
x float64
y float64
z float64
r1 float64
r2 float64
}
const (
solarEclipseEarthEquatorialRadiusKM = 6378.1366
// 赤道自转线速度,用于把 ΔT 误差换算成地面横移(见 DeltaTGroundShiftKM)。
// Equatorial rotation speed, used to convert a ΔT error into ground displacement.
solarEclipseEarthEquatorialRotationKMPerSecond = 0.4651
solarEclipseEarthPolarRatio = 0.99664719
solarEclipseEarthPolarRatioSquared = solarEclipseEarthPolarRatio * solarEclipseEarthPolarRatio
solarEclipseAstronomicalUnitKM = 1.49597870691e8
// 标准档与边缘档在 1 AU 处的太阳视半径(角秒),日食与月食几何共用这一组常量。
eclipseSunRadiusStandardArcsec = 959.639
eclipseSunRadiusMeasuredArcsec = 959.95
// 标准档太阳半径是地球赤道半径的 109.1222 倍,与上面的标准档视半径等价;边缘档按视半径比例放大。
solarEclipseSunRadiusRatioStandard = 109.1222
solarEclipseSunRadiusRatioMeasured = solarEclipseSunRadiusRatioStandard * eclipseSunRadiusMeasuredArcsec / eclipseSunRadiusStandardArcsec
// Split-K:半影(偏食)0.2724880、本影与反本影 0.2722810;IAU Single-K 全部使用 0.2725076。
solarEclipsePenumbralK = 0.2724880
solarEclipseUmbralK = 0.2722810
solarEclipseIAUSingleRadiusK = 0.2725076
// SolarEclipsePenumbralK / SolarEclipseUmbralK 是 Split-K 的半影与本影月地半径比 k1/k2,
// SolarEclipseIAUSingleRadiusK 是 IAU Single-K 的单一值。
// SolarEclipsePenumbralK / SolarEclipseUmbralK are the split-k lunar-to-terrestrial radius ratios,
// SolarEclipseIAUSingleRadiusK the IAU single value.
SolarEclipsePenumbralK = solarEclipsePenumbralK
SolarEclipseUmbralK = solarEclipseUmbralK
SolarEclipseIAUSingleRadiusK = solarEclipseIAUSingleRadiusK
solarEclipseNodeCount = 7
solarEclipseNodeStepDays = 0.04
solarEclipseMoonLonAberrRad = -3.4e-6
solarEclipseAxisContactInitialStepDays = 1.0 / 86400.0
solarEclipseAxisContactMaximumStepDays = 30.0 / 1440.0
solarEclipseAxisContactToleranceDays = 1e-9
// 这两个系数沿用经典贝塞尔近似中的极区有效半径经验值。
solarEclipseNonCentralLimit = 0.9972
solarEclipseCentralLimit = 0.9966
)
var solarEclipseArcsecPerRadian = 180.0 * 3600.0 / math.Pi
func normalizeSolarEclipseRadiusModel(model SolarEclipseRadiusModel) SolarEclipseRadiusModel {
if model == SolarEclipseModelIAUSingleK {
return SolarEclipseModelIAUSingleK
}
return SolarEclipseModelNASABulletinSplitK
}
func normalizeSolarEclipseSunRadiusModel(model SolarEclipseSunRadiusModel) SolarEclipseSunRadiusModel {
if model == SolarEclipseSunRadiusMeasured {
return SolarEclipseSunRadiusMeasured
}
return SolarEclipseSunRadiusStandard
}
func solarEclipseSunRadiusRatio(model SolarEclipseSunRadiusModel) float64 {
if normalizeSolarEclipseSunRadiusModel(model) == SolarEclipseSunRadiusMeasured {
return solarEclipseSunRadiusRatioMeasured
}
return solarEclipseSunRadiusRatioStandard
}
// SolarEclipseSunSemidiameter 指定太阳半径口径下的视半径,单位角秒 / apparent solar semidiameter in arcseconds under a given eclipse sun radius convention.
func SolarEclipseSunSemidiameter(jde float64, model SolarEclipseSunRadiusModel) float64 {
return angularSemidiameterFromAU(solarEclipseSunRadiusRatio(model)*solarEclipseEarthEquatorialRadiusKM, EarthAwayN(jde, -1))
}
// SolarEclipse 计算给定近朔时刻附近的一次全局日食,默认使用 NASABulletin Split-K 模型与标准太阳半径。
func SolarEclipse(seedJDE float64) SolarEclipseResult {
return SolarEclipseNASABulletinSplitK(seedJDE)
}
// SolarEclipseWithOptions 计算给定近朔时刻附近的一次全局日食,半径口径由 options 指定 / computes one global solar eclipse with the given radius conventions.
func SolarEclipseWithOptions(seedJDE float64, options SolarEclipseOptions) SolarEclipseResult {
return solarEclipseWithDeltaT(seedJDE, options, 0)
}
// SolarEclipseIAUSingleK 计算给定近朔时刻附近的一次全局日食,使用 IAU Single-K 模型。
func SolarEclipseIAUSingleK(seedJDE float64) SolarEclipseResult {
return solarEclipse(seedJDE, SolarEclipseModelIAUSingleK)
}
// SolarEclipseNASABulletinSplitK 计算给定近朔时刻附近的一次全局日食,使用 NASA bulletin Split-K 模型。
func SolarEclipseNASABulletinSplitK(seedJDE float64) SolarEclipseResult {
return solarEclipse(seedJDE, SolarEclipseModelNASABulletinSplitK)
}
func solarEclipse(seedJDE float64, model SolarEclipseRadiusModel) SolarEclipseResult {
return solarEclipseWithDeltaT(seedJDE, SolarEclipseOptions{RadiusModel: model}, 0)
}
func solarEclipseWithDeltaT(
seedJDE float64,
options SolarEclipseOptions,
deltaTSeconds float64,
) SolarEclipseResult {
newMoonJDE := CalcMoonSHByJDE(seedJDE, 0)
solver := newSolarEclipseSolverWithOptions(newMoonJDE, options).withDeltaTSeconds(deltaTSeconds)
return solver.eclipseResult()
}
func (solver solarEclipseSolver) eclipseResult() SolarEclipseResult {
model := solver.model
feature := solver.feature()
result := SolarEclipseResult{
Model: model,
SunRadiusModel: solver.sunRadiusModel,
Type: SolarEclipseNone,
Centrality: SolarEclipseNonCentral,
GreatestEclipse: feature.greatestEclipseJDE,
Magnitude: feature.magnitude,
Gamma: feature.gamma,
PathWidthKM: feature.pathWidthKM,
GreatestLongitude: feature.greatestLongitude,
GreatestLatitude: feature.greatestLatitude,
}
switch feature.typeCode {
case "P":
result.Type = SolarEclipsePartial
case "A0", "A1", "A":
result.Type = SolarEclipseAnnular
case "T0", "T1", "T":
result.Type = SolarEclipseTotal
case "H", "H2", "H3":
result.Type = SolarEclipseHybrid
}
if solarEclipseTwoLimitsTypeCode(feature.typeCode) {
result.Centrality = SolarEclipseCentralTwoLimits
} else if feature.typeCode == "A1" || feature.typeCode == "T1" {
result.Centrality = SolarEclipseCentralOneLimit
}
result.PathWidthDefined = result.Centrality == SolarEclipseCentralTwoLimits
if result.Type != SolarEclipseNone {
result.HasPartial = true
result.PartialBeginOnEarth = feature.partialBeginJDE
result.PartialEndOnEarth = feature.partialEndJDE
}
if result.Centrality != SolarEclipseNonCentral {
result.HasCentral = true
result.CentralBeginOnEarth = feature.centralBeginJDE
result.CentralEndOnEarth = feature.centralEndJDE
result.CentralDurationDays = solver.greatestCentralDuration(result)
}
switch result.Type {
case SolarEclipseAnnular:
result.HasAnnular = true
case SolarEclipseTotal:
result.HasTotal = true
case SolarEclipseHybrid:
result.HasAnnular = true
result.HasTotal = true
result.HasHybrid = true
}
return result
}
// greatestCentralDuration 在食甚点解一次站心中心食并返回中心相时长(日);没有中心相时为 0。
// greatestCentralDuration solves the local eclipse at the greatest eclipse point and
// returns its central-phase duration in days.
func (solver solarEclipseSolver) greatestCentralDuration(result SolarEclipseResult) float64 {
if !result.HasCentral || result.GreatestEclipse <= 0 {
return 0
}
return solver.centralPhaseDurationDaysAt(
result.GreatestEclipse, result.GreatestLongitude, result.GreatestLatitude,
)
}
func newSolarEclipseSolver(newMoonJDE float64, model SolarEclipseRadiusModel) solarEclipseSolver {
return newSolarEclipseSolverWithOptions(newMoonJDE, SolarEclipseOptions{RadiusModel: model})
}
func newSolarEclipseSolverWithOptions(newMoonJDE float64, options SolarEclipseOptions) solarEclipseSolver {
options.RadiusModel = normalizeSolarEclipseRadiusModel(options.RadiusModel)
options.SunRadiusModel = normalizeSolarEclipseSunRadiusModel(options.SunRadiusModel)
params := solarEclipseModelParams(options.RadiusModel, options.SunRadiusModel)
firstNodeJDE := newMoonJDE + (0-float64(solarEclipseNodeCount)/2+0.5)*solarEclipseNodeStepDays
lastNodeJDE := newMoonJDE + (float64(solarEclipseNodeCount-1)-float64(solarEclipseNodeCount)/2+0.5)*solarEclipseNodeStepDays
firstSun, firstMoon := solarEclipseSunMoonEquatorial(firstNodeJDE)
lastSun, lastMoon := solarEclipseSunMoonEquatorial(lastNodeJDE)
meanSunMoonDistance := ((firstSun[2] + lastSun[2]) - (firstMoon[2] + lastMoon[2])) / 2 / solarEclipseEarthEquatorialRadiusKM
return solarEclipseSolver{
newMoonJDE: newMoonJDE,
model: options.RadiusModel,
sunRadiusModel: options.SunRadiusModel,
params: params,
deltaTSeconds: math.NaN(),
localStateContextCache: make(map[uint64]localSolarEclipseStateContext),
besselGeometryCache: make(map[uint64]solarEclipseBesselGeometryCacheEntry),
besselCandidateCache: make(map[uint64]solarEclipseBesselGeometryCacheEntry),
meanSunMoonDistance: meanSunMoonDistance,
penumbraConeTangent: (params.sunRadiusRatio + params.penumbralK) / meanSunMoonDistance,
umbraConeTangent: (params.sunRadiusRatio - params.umbralK) / meanSunMoonDistance,
}
}
// withDeltaTSeconds 固定本求解器使用的 ΔT(秒),非正值表示回到进程级模型。覆盖会改变
// 轴里的 gst,因此所有按精确 float 位键控的几何缓存必须同时作废。
func (solver solarEclipseSolver) withDeltaTSeconds(deltaTSeconds float64) solarEclipseSolver {
if deltaTSeconds <= 0 || math.IsNaN(deltaTSeconds) || math.IsInf(deltaTSeconds, 0) {
deltaTSeconds = math.NaN()
}
solver.deltaTSeconds = deltaTSeconds
solver.besselGeometryCache = make(map[uint64]solarEclipseBesselGeometryCacheEntry)
solver.besselCandidateCache = make(map[uint64]solarEclipseBesselGeometryCacheEntry)
solver.localStateContextCache = make(map[uint64]localSolarEclipseStateContext)
return solver
}
// effectiveDeltaTSeconds 返回本求解器在某 TT 时刻实际使用的 ΔT(秒)。
// 未覆盖时用真 TT−UT1(观测表/外推),不能回退到混入 UTC 的进程级 DeltaT,
// 否则恒星时相位会少掉 DUT1,站心与影轴两条路径就不一致。
func (solver solarEclipseSolver) effectiveDeltaTSeconds(jde float64) float64 {
if math.IsNaN(solver.deltaTSeconds) {
return ut1ToTTOffsetSeconds(ttToUT1JDE(jde))
}
return solver.deltaTSeconds
}
// siderealTimeAt 返回某 TT 时刻的视恒星时(弧度),ΔT 覆盖时同样生效。
func (solver solarEclipseSolver) siderealTimeAt(jde float64) float64 {
ut1JDE := TT2UT1(jde)
if !math.IsNaN(solver.deltaTSeconds) {
ut1JDE = jde - solver.deltaTSeconds/86400
}
return ApparentSiderealTime(ut1JDE) * 15 * rad
}
// withLocalEphemeris prepares the immutable event-local interpolator used by
// coarse candidate scans. The exact ephemeris remains the fallback outside its
// bounded window and is used by all contact and topology refinements.
func (solver solarEclipseSolver) withLocalEphemeris() solarEclipseSolver {
if solver.localEphemeris == nil {
solver.localEphemeris = newSolarEclipseLocalEphemeris(solver.newMoonJDE)
}
return solver
}
// solarEclipseTwoLimitsTypeCode 报告该类型码的南北两限是否都存在,带宽解析式只在这一类中心食上有定义。
func solarEclipseTwoLimitsTypeCode(typeCode string) bool {
switch typeCode {
case "A", "T", "H", "H2", "H3":
return true
}
return false
}
func (solver solarEclipseSolver) feature() solarEclipseFeature {
const finiteDifferenceStep = 0.04
candidateSolver := solver.withLocalEphemeris()
jde := solver.newMoonJDE
before := candidateSolver.besselMoonCandidateAt(jde - finiteDifferenceStep)
center := candidateSolver.besselMoonCandidateAt(jde)
after := candidateSolver.besselMoonCandidateAt(jde + finiteDifferenceStep)
vx := (after[0] - before[0]) / (2 * finiteDifferenceStep)
vy := (after[1] - before[1]) / (2 * finiteDifferenceStep)
vz := (after[2] - before[2]) / (2 * finiteDifferenceStep)
speed := math.Hypot(vx, vy)
speedSquared := speed * speed
t0 := -(center[0]*vx + center[1]*vy) / speedSquared
greatestEclipseJDE := jde + t0
// The three-node velocity fit locates greatest eclipse accurately, but its
// linearly extrapolated coordinates can miss the true Bessel position by
// tens of kilometres in a grazing non-central event. Re-evaluate the
// ephemeris at the solved time before deriving surface coordinates and
// shadow radii so markers and path geometry use the same state.
greatestMoon := solver.besselMoonAt(greatestEclipseJDE)
xc := greatestMoon[0]
yc := greatestMoon[1]
zc := greatestMoon[2]
gamma := (vx*center[1] - vy*center[0]) / speed
minimumDistance := math.Abs(gamma)
axis := solver.besselAxisAt(greatestEclipseJDE)
axisIntersection := solarEclipseLineEar2(xc, yc, 2, xc, yc, 0, solarEclipseEarthPolarRatio, 1, axis)
midRadii := solver.shadowRadiiAt(zc)
greatestRadii := midRadii
if axisIntersection.valid {
greatestRadii = solver.shadowRadiiAt(zc - axisIntersection.r2)
}
var centralStartParam, centralEndParam float64
if minimumDistance < 1 {
paramSpan := math.Sqrt(1-minimumDistance*minimumDistance) / speed
centralStartParam = t0 - paramSpan
centralEndParam = t0 + paramSpan
}
partialLimit := 1 + midRadii.penumbraRadius
partialSpan := 0.0
if minimumDistance < partialLimit {
partialSpan = math.Sqrt(partialLimit*partialLimit-minimumDistance*minimumDistance) / speed
}
partialStartParam := t0 - partialSpan
partialEndParam := t0 + partialSpan
typeCode := "N"
greatestLongitude, greatestLatitude := 0.0, 0.0
magnitude := 0.0
pathWidthKM := 0.0
if !axisIntersection.valid {
greatestLongitude, greatestLatitude = solarEclipseBesselPointToGeodetic(xc, yc, 0, axis, false)
magnitude = (midRadii.penumbraRadius - (minimumDistance - solarEclipseNonCentralLimit)) / (midRadii.penumbraRadius - midRadii.umbraRadius)
switch {
case minimumDistance > solarEclipseNonCentralLimit+midRadii.penumbraRadius:
typeCode = "N"
case minimumDistance > solarEclipseNonCentralLimit+midRadii.absUmbraRadius:
typeCode = "P"
default:
if midRadii.magnitude < 1 {
typeCode = "A0"
} else {
typeCode = "T0"
}
}
} else {
greatestLongitude = axisIntersectionLongitude(axisIntersection, axis)
greatestLatitude = axisIntersectionLatitude(axisIntersection, axis)
magnitude = greatestRadii.magnitude
switch {
case minimumDistance > solarEclipseCentralLimit-greatestRadii.absUmbraRadius:
if greatestRadii.magnitude < 1 {
typeCode = "A1"
} else {
typeCode = "T1"
}
default:
if greatestRadii.magnitude >= 1 {
startRadii := greatestRadii
endRadii := greatestRadii
if minimumDistance < 1 {
startRadii = solver.shadowRadiiAt(centralStartParam*vz + center[2] - 1.37*centralStartParam*centralStartParam)
endRadii = solver.shadowRadiiAt(centralEndParam*vz + center[2] - 1.37*centralEndParam*centralEndParam)
}
typeCode = "H"
if startRadii.magnitude > 1 {
typeCode = "H2"
}
if endRadii.magnitude > 1 {
typeCode = "H3"
}
if startRadii.magnitude > 1 && endRadii.magnitude > 1 {
typeCode = "T"
}
} else {
typeCode = "A"
}
}
// 单侧极限(A1/T1)只有一侧限界,非中心中心食(A0/T0)连限界都没有:解析式 2r/|sin h|
// 在 h→0 时发散,两类事件该栏都无定义,与中心线逐点宽度一起置 0。
if solarEclipseTwoLimitsTypeCode(typeCode) {
sunAltitude := solarEclipseSunAltitudeAtGreatest(greatestEclipseJDE, greatestLongitude, greatestLatitude, axis.gst)
if math.Abs(math.Sin(sunAltitude)) > 1e-12 {
pathWidthKM = math.Abs(2*greatestRadii.umbraRadius*solarEclipseEarthEquatorialRadiusKM) / math.Abs(math.Sin(sunAltitude))
}
}
}
feature := solarEclipseFeature{
greatestEclipseJDE: greatestEclipseJDE,
greatestLongitude: greatestLongitude,
greatestLatitude: greatestLatitude,
magnitude: magnitude,
gamma: gamma,
pathWidthKM: pathWidthKM,
typeCode: typeCode,
}
if typeCode != "N" {
_, _, feature.partialBeginJDE, _ = solver.quickContactAt(partialStartParam+jde, vx, vy, true)
_, _, feature.partialEndJDE, _ = solver.quickContactAt(partialEndParam+jde, vx, vy, true)
}
if axisIntersection.valid && typeCode != "N" && typeCode != "P" {
_, _, feature.centralBeginJDE, _ = solver.quickContactAt(centralStartParam+jde, vx, vy, false)
_, _, feature.centralEndJDE, _ = solver.quickContactAt(centralEndParam+jde, vx, vy, false)
if refined, ok := solver.centralAxisContactJDE(feature.centralBeginJDE, greatestEclipseJDE, -1); ok {
feature.centralBeginJDE = refined
}
if refined, ok := solver.centralAxisContactJDE(feature.centralEndJDE, greatestEclipseJDE, 1); ok {
feature.centralEndJDE = refined
}
}
return feature
}
func (solver solarEclipseSolver) centralAxisContactJDE(
approximateJDE, greatestJDE, direction float64,
) (float64, bool) {
if !finite(approximateJDE) || !finite(greatestJDE) || direction == 0 {
return 0, false
}
insideJDE, insideResidual := approximateJDE, solver.centralAxisEarthDiscriminant(approximateJDE)
if !finite(insideResidual) || insideResidual < 0 {
insideJDE = greatestJDE
insideResidual = solver.centralAxisEarthDiscriminant(insideJDE)
if !finite(insideResidual) || insideResidual < 0 {
return 0, false
}
}
outsideJDE, outsideResidual := 0.0, 0.0
foundOutside := false
for step := solarEclipseAxisContactInitialStepDays; step <= solarEclipseAxisContactMaximumStepDays; step *= 2 {
candidateJDE := approximateJDE + direction*step
candidateResidual := solver.centralAxisEarthDiscriminant(candidateJDE)
if !finite(candidateResidual) {
continue
}
if candidateResidual <= 0 {
outsideJDE, outsideResidual = candidateJDE, candidateResidual
foundOutside = true
break
}
insideJDE, insideResidual = candidateJDE, candidateResidual
}
if !foundOutside {
return 0, false
}
for iteration := 0; iteration < 24 && math.Abs(outsideJDE-insideJDE) > solarEclipseAxisContactToleranceDays; iteration++ {
candidateJDE := (insideJDE + outsideJDE) / 2
denominator := insideResidual - outsideResidual
if denominator != 0 {
fraction := insideResidual / denominator
if fraction > 0.1 && fraction < 0.9 {
candidateJDE = insideJDE + fraction*(outsideJDE-insideJDE)
}
}
candidateResidual := solver.centralAxisEarthDiscriminant(candidateJDE)
if !finite(candidateResidual) {
return 0, false
}
if candidateResidual >= 0 {
insideJDE, insideResidual = candidateJDE, candidateResidual
} else {
outsideJDE, outsideResidual = candidateJDE, candidateResidual
}
}
return (insideJDE + outsideJDE) / 2, true
}
func (solver solarEclipseSolver) centralAxisEarthDiscriminant(jde float64) float64 {
moon, axis, _ := solver.besselGeometryAt(jde)
return solarEclipseLineEllipsoidDiscriminant(
moon[0], moon[1], 2,
moon[0], moon[1], 0,
solarEclipseEarthPolarRatio, 1, axis,
)
}
func (solver solarEclipseSolver) centralAxisContactPointAt(jde float64) (SolarEclipsePathPoint, bool) {
moon, axis, _ := solver.besselGeometryAt(jde)
cosTilt, sinTilt := math.Cos(axis.tilt), math.Sin(axis.tilt)
x1 := moon[0]
y1 := cosTilt*moon[1] - 2*sinTilt
z1 := sinTilt*moon[1] + 2*cosTilt
x2 := moon[0]
y2 := cosTilt * moon[1]
z2 := sinTilt * moon[1]
dx, dy, dz := x2-x1, y2-y1, z2-z1
polarRatioSquared := solarEclipseEarthPolarRatioSquared
a := dx*dx + dy*dy + dz*dz/polarRatioSquared
if !finite(a) || a <= 0 {
return SolarEclipsePathPoint{}, false
}
b := x1*dx + y1*dy + z1*dz/polarRatioSquared
t := -b / a
intersection := solarEclipseLineIntersection{
valid: true,
x: x1 + dx*t,
y: y1 + dy*t,
z: z1 + dz*t,
}
longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis)
if !finite(longitude) || !finite(latitude) {
return SolarEclipsePathPoint{}, false
}
return SolarEclipsePathPoint{
JDE: jde,
Longitude: longitude,
Latitude: latitude,
SunAltitude: solarEclipseSunAltitudeAtGreatest(jde, longitude, latitude, axis.gst) / rad,
}, true
}
func (solver solarEclipseSolver) quickContactAt(jde, dx, dy float64, penumbral bool) (float64, float64, float64, bool) {
moon := solver.besselMoonAt(jde)
radii := solver.shadowRadiiAt(moon[2])
radius := 0.0
if penumbral {
radius = radii.penumbraRadius
}
denominator := moon[0]*moon[0] + moon[1]*moon[1]
if denominator == 0 {
return 0, 0, 0, false
}
effectiveRadius := 1 - (1/solarEclipseEarthPolarRatioSquared-1)*moon[1]*moon[1]/denominator/2 + radius
velocityProjection := dx*moon[0] + dy*moon[1]
if velocityProjection == 0 {
return 0, 0, 0, false
}
correction := (effectiveRadius*effectiveRadius - moon[0]*moon[0] - moon[1]*moon[1]) / (2 * velocityProjection)
x := moon[0] + correction*dx
y := moon[1] + correction*dy
jde += correction
curvature := (1 - solarEclipseEarthPolarRatioSquared) * radius * x * y / math.Pow(effectiveRadius, 3)
x += curvature * y
y -= curvature * x
axis := solver.besselAxisAt(jde)
longitude, latitude, ok := solarEclipseBesselXYToGeodetic(x/effectiveRadius, y/effectiveRadius, axis, true)
return longitude, latitude, jde, ok
}
func (solver solarEclipseSolver) shadowRadiiAt(moonBesselZ float64) solarEclipseShadowRadii {
return solarEclipseShadowRadii{
penumbraRadius: solver.params.penumbralK + solver.penumbraConeTangent*moonBesselZ,
umbraRadius: solver.params.umbralK - solver.umbraConeTangent*moonBesselZ,
absUmbraRadius: math.Abs(solver.params.umbralK - solver.umbraConeTangent*moonBesselZ),
magnitude: solver.params.umbralK / moonBesselZ / solver.params.sunRadiusRatio * (solver.meanSunMoonDistance + moonBesselZ),
}
}
func (solver solarEclipseSolver) besselAxisAt(jde float64) solarEclipseAxis {
sun, moon := solarEclipseSunMoonEquatorial(jde)
return solarEclipseBesselAxisFromEquatorialWithDeltaT(
jde, sun, moon, solver.effectiveDeltaTSeconds(jde),
)
}
func solarEclipseBesselAxisFromEquatorial(jd float64, sun, moon [3]float64) solarEclipseAxis {
return solarEclipseBesselAxisFromEquatorialWithDeltaT(jd, sun, moon, DeltaT(jd, true))
}
// solarEclipseBesselAxisFromEquatorialWithDeltaT 用显式 ΔT 构造贝塞尔轴:TT 时刻保持
// 不变,ΔT 只决定地球自转相位(恒星时),因此同一 TT 在不同 ΔT 下得到的地面足迹会
// 沿经度平移,这正是"ΔT 只影响自转、不影响几何时刻"的实现点。
// solarEclipseBesselAxisFromEquatorialWithDeltaT builds the Besselian axis with an
// explicit ΔT: the TT instant is untouched and ΔT only sets Earth rotation.
func solarEclipseBesselAxisFromEquatorialWithDeltaT(
jd float64, sun, moon [3]float64, deltaTSeconds float64,
) solarEclipseAxis {
sunXYZ := solarEclipseLLRToXYZ(sun[0], sun[1], sun[2])
moonXYZ := solarEclipseLLRToXYZ(moon[0], moon[1], moon[2])
axis := solarEclipseXYZToLLR(sunXYZ[0]-moonXYZ[0], sunXYZ[1]-moonXYZ[1], sunXYZ[2]-moonXYZ[2])
utJDE := jd - deltaTSeconds/86400
return solarEclipseAxis{
rightAscension: solarEclipseNormalizeRadians(math.Pi/2 + axis[0]),
tilt: math.Pi/2 - axis[1],
gst: solarEclipseNormalizeSignedRadians(ApparentSiderealTime(utJDE) * 15 * rad),
}
}
func (solver solarEclipseSolver) besselMoonAt(jde float64) [3]float64 {
moon, _, _ := solver.besselGeometryAt(jde)
return moon
}
func (solver solarEclipseSolver) besselMoonCandidateAt(jde float64) [3]float64 {
moon, _, _, ok := solver.besselGeometryCandidateAt(jde)
if !ok {
return solver.besselMoonAt(jde)
}
return moon
}
func (solver solarEclipseSolver) besselGeometryAt(jde float64) ([3]float64, solarEclipseAxis, [3]float64) {
key := math.Float64bits(jde)
// 命中要求 ΔT 世代一致:轴里的 gst 依赖 ΔT,SetDeltaTFn 之后旧条目必须视为未命中。
// A hit requires the same ΔT generation: the cached axis carries a ΔT-dependent gst,
// so entries written before a SetDeltaTFn override must count as misses.
if entry, ok := solver.besselGeometryCache[key]; ok && entry.generation == deltaTGenerationValue() {
return entry.moon, entry.axis, entry.sun
}
sun, moon := solarEclipseSunMoonEquatorial(jde)
axis := solarEclipseBesselAxisFromEquatorialWithDeltaT(
jde, sun, moon, solver.effectiveDeltaTSeconds(jde),
)
geometry := solarEclipseBesselGeometryCacheEntry{
moon: solarEclipseBesselMoonFromEquatorial(moon, axis),
axis: axis,
sun: sun,
valid: true,
}
storeSolarEclipseBesselGeometry(solver.besselGeometryCache, key, geometry)
return geometry.moon, geometry.axis, geometry.sun
}
func (solver solarEclipseSolver) besselGeometryCandidateAt(jde float64) ([3]float64, solarEclipseAxis, [3]float64, bool) {
key := math.Float64bits(jde)
if entry, ok := solver.besselCandidateCache[key]; ok && entry.generation == deltaTGenerationValue() {
return entry.moon, entry.axis, entry.sun, entry.valid
}
if solver.localEphemeris == nil {
return [3]float64{}, solarEclipseAxis{}, [3]float64{}, false
}
sun, moon, ok := solver.localEphemeris.equatorialAt(jde)
if !ok {
return [3]float64{}, solarEclipseAxis{}, [3]float64{}, false
}
axis := solarEclipseBesselAxisFromEquatorialWithDeltaT(
jde, sun, moon, solver.effectiveDeltaTSeconds(jde),
)
geometry := solarEclipseBesselGeometryCacheEntry{
moon: solarEclipseBesselMoonFromEquatorial(moon, axis),
axis: axis,
sun: sun,
valid: true,
}
storeSolarEclipseBesselGeometry(solver.besselCandidateCache, key, geometry)
return geometry.moon, geometry.axis, geometry.sun, geometry.valid
}
func storeSolarEclipseBesselGeometry(
cache map[uint64]solarEclipseBesselGeometryCacheEntry,
key uint64,
entry solarEclipseBesselGeometryCacheEntry,
) {
if cache == nil {
return
}
if _, exists := cache[key]; !exists && len(cache) >= solarEclipseBesselGeometryCacheMaximumEntries {
for cachedKey := range cache {
delete(cache, cachedKey)
}
}
entry.generation = deltaTGenerationValue()
cache[key] = entry
}
func solarEclipseBesselMoonFromEquatorial(moon [3]float64, axis solarEclipseAxis) [3]float64 {
rotated := solarEclipseRotateLLR(
solarEclipseNormalizeSignedRadians(moon[0]-axis.rightAscension),
moon[1],
moon[2],
-axis.tilt,
)
rectangular := solarEclipseLLRToXYZ(rotated[0], rotated[1], rotated[2])
return [3]float64{
rectangular[0] / solarEclipseEarthEquatorialRadiusKM,
rectangular[1] / solarEclipseEarthEquatorialRadiusKM,
rectangular[2] / solarEclipseEarthEquatorialRadiusKM,
}
}
func solarEclipseSunMoonEquatorial(jde float64) ([3]float64, [3]float64) {
julianCentury := (jde - 2451545.0) / 36525.0
nutationLongitude, nutationObliquity := Nutation2000B(jde)
obliquity := (Obliquity1980(jde) + nutationObliquity) * rad
// Share the full-series distance and nutation for this single TT.
sunDistanceAU := EarthAway(jde)
sunLongitude := (HSunTrueLoN(jde, -1) + nutationLongitude - 20.49552/sunDistanceAU/3600) * rad
sunLatitude := HSunTrueBo(jde) * rad
sunDistance := sunDistanceAU * solarEclipseAstronomicalUnitKM
moonLongitude := solarEclipseNormalizeRadians((HMoonTrueLoN(jde, -1)+nutationLongitude)*rad + solarEclipseMoonLonAberrRad)
moonLatitude := HMoonTrueBo(jde)*rad + moonLatitudeAberrationRad(julianCentury)
moonDistance := HMoonAway(jde)
sunEquatorial := solarEclipseRotateLLR(sunLongitude, sunLatitude, sunDistance, obliquity)
moonEquatorial := solarEclipseRotateLLR(moonLongitude, moonLatitude, moonDistance, obliquity)
return [3]float64{sunEquatorial[0], sunEquatorial[1], sunEquatorial[2]},
[3]float64{moonEquatorial[0], moonEquatorial[1], moonEquatorial[2]}
}
func solarEclipseSunAltitudeAtGreatest(jde, lonDeg, latDeg, gst float64) float64 {
sun, _ := solarEclipseSunMoonEquatorial(jde)
return solarEclipseSunAltitudeFromEquatorial(sun, lonDeg, latDeg, gst)
}
func solarEclipseSunAltitudeFromEquatorial(sun [3]float64, lonDeg, latDeg, gst float64) float64 {
horizon := solarEclipseEquatorialToHorizontal(sun[0], sun[1], sun[2], lonDeg*rad, latDeg*rad, gst)
return horizon[1]
}
func solarEclipseEquatorialToHorizontal(ra, dec, distance, lon, lat, gst float64) [3]float64 {
rotated := solarEclipseRotateLLR(
solarEclipseNormalizeRadians(ra+math.Pi/2-gst-lon),
dec,
distance,
math.Pi/2-lat,
)
return [3]float64{
solarEclipseNormalizeRadians(math.Pi/2 - rotated[0]),
rotated[1],
rotated[2],
}
}
func solarEclipseLLRToXYZ(longitude, latitude, distance float64) [3]float64 {
return [3]float64{
distance * math.Cos(latitude) * math.Cos(longitude),
distance * math.Cos(latitude) * math.Sin(longitude),
distance * math.Sin(latitude),
}
}
func solarEclipseXYZToLLR(x, y, z float64) [3]float64 {
distance := math.Sqrt(x*x + y*y + z*z)
return [3]float64{
solarEclipseNormalizeRadians(math.Atan2(y, x)),
math.Asin(z / distance),
distance,
}
}
func solarEclipseRotateLLR(longitude, latitude, distance, obliquity float64) [3]float64 {
rotatedLongitude := math.Atan2(
math.Sin(longitude)*math.Cos(obliquity)-math.Tan(latitude)*math.Sin(obliquity),
math.Cos(longitude),
)
return [3]float64{
solarEclipseNormalizeRadians(rotatedLongitude),
math.Asin(math.Cos(obliquity)*math.Sin(latitude) + math.Sin(obliquity)*math.Cos(latitude)*math.Sin(longitude)),
distance,
}
}
func solarEclipseLineEar2(x1, y1, z1, x2, y2, z2, polarRatio, radius float64, axis solarEclipseAxis) solarEclipseLineIntersection {
cosTilt := math.Cos(axis.tilt)
sinTilt := math.Sin(axis.tilt)
x1Rot := x1
y1Rot := cosTilt*y1 - sinTilt*z1
z1Rot := sinTilt*y1 + cosTilt*z1
x2Rot := x2
y2Rot := cosTilt*y2 - sinTilt*z2
z2Rot := sinTilt*y2 + cosTilt*z2
intersection := solarEclipseLineEllipsoid(x1Rot, y1Rot, z1Rot, x2Rot, y2Rot, z2Rot, polarRatio, radius)
if !intersection.valid {
return intersection
}
return intersection
}
func solarEclipseLineEllipsoid(x1, y1, z1, x2, y2, z2, polarRatio, radius float64) solarEclipseLineIntersection {
dx := x2 - x1
dy := y2 - y1
dz := z2 - z1
polarRatioSquared := polarRatio * polarRatio
a := dx*dx + dy*dy + dz*dz/polarRatioSquared
b := x1*dx + y1*dy + z1*dz/polarRatioSquared
c := x1*x1 + y1*y1 + z1*z1/polarRatioSquared - radius*radius
discriminant := b*b - a*c
if discriminant < 0 {
return solarEclipseLineIntersection{}
}
root := math.Sqrt(discriminant)
if b < 0 {
root = -root
}
t := (-b + root) / a
x := x1 + dx*t
y := y1 + dy*t
z := z1 + dz*t
distance := math.Sqrt(dx*dx + dy*dy + dz*dz)
return solarEclipseLineIntersection{
valid: true,
x: x,
y: y,
z: z,
r1: distance * math.Abs(t),
r2: distance * math.Abs(t-1),
}
}
func axisIntersectionLongitude(intersection solarEclipseLineIntersection, axis solarEclipseAxis) float64 {
longitude, _ := solarEclipseIntersectionGeodetic(intersection, axis)
return longitude
}
func axisIntersectionLatitude(intersection solarEclipseLineIntersection, axis solarEclipseAxis) float64 {
_, latitude := solarEclipseIntersectionGeodetic(intersection, axis)
return latitude
}
func solarEclipseIntersectionGeodetic(intersection solarEclipseLineIntersection, axis solarEclipseAxis) (float64, float64) {
longitude := solarEclipseNormalizeSignedRadians(math.Atan2(intersection.y, intersection.x) + axis.rightAscension - axis.gst)
latitude := math.Atan(intersection.z / solarEclipseEarthPolarRatioSquared / math.Sqrt(intersection.x*intersection.x+intersection.y*intersection.y))
return longitude * deg, latitude * deg
}
func solarEclipseBesselPointToGeodetic(x, y, z float64, axis solarEclipseAxis, ellipsoidal bool) (float64, float64) {
point := solarEclipseXYZToLLR(x, y, z)
rotated := solarEclipseRotateLLR(point[0], point[1], point[2], axis.tilt)
longitude := solarEclipseNormalizeSignedRadians(rotated[0] + axis.rightAscension - axis.gst)
latitude := rotated[1]
if ellipsoidal {
latitude = math.Atan(math.Tan(latitude) / solarEclipseEarthPolarRatioSquared)
}
return longitude * deg, latitude * deg
}
func solarEclipseBesselXYToGeodetic(x, y float64, axis solarEclipseAxis, ellipsoidal bool) (float64, float64, bool) {
polarRatio := 1.0
if ellipsoidal {
polarRatio = solarEclipseEarthPolarRatio
}
intersection := solarEclipseLineEar2(x, y, 2, x, y, 0, polarRatio, 1, axis)
if !intersection.valid {
return 0, 0, false
}
longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis)
return longitude, latitude, true
}
func solarEclipseNormalizeRadians(angle float64) float64 {
angle = math.Mod(angle, 2*math.Pi)
if angle < 0 {
angle += 2 * math.Pi
}
return angle
}
func solarEclipseNormalizeSignedRadians(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
}