astro/basic/solar_eclipse_local.go
starainrt 3ffdbe0034
feat: 扩展天文计算能力
- 新增日食、月食、本地可见性、中心线、半影区域、SVG 图示与沙罗周期信息
- 新增行星冲合、留、方照、物理星历、视直径、相位、亮肢角、轨道节点等计算
- 新增木星伽利略卫星位置、现象与接触事件计算
- 新增恒星星表、星座判定、自行修正与观测辅助能力
- 新增 coord、formula、orbit、sundial、lite/sun、lite/moon 等扩展包
- 完善农历年号、月相英文别名、视差角、大气质量、折射、日晷与双星计算
- 增加 NASA、JPL Horizons、IMCCE 等回归测试数据与基线测试
- 重构基础算法文件组织,补充大量公开 API 注释和语义回归测试
- 更新中文和英文 README,补充示例、精度说明、SVG 配图
2026-05-01 22:38:44 +08:00

402 lines
13 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"
// LocalSolarEclipseResult 表示某个站点的一次站心日食几何结果。
//
// 所有时刻字段都使用力学时儒略日JDE, TT
// 输入 seedJDE 只需要落在目标朔月附近,允许相差数天。
type LocalSolarEclipseResult struct {
Model SolarEclipseRadiusModel
Type SolarEclipseType
// GreatestEclipse 是站心盘面中心角距最小的时刻。
GreatestEclipse float64
// PartialStart / PartialEnd 是站心偏食始 / 偏食终。
PartialStart float64
PartialEnd float64
// CentralStart / CentralEnd 是站心中心食始 / 中心食终。
//
// 对全食对应食既 / 生光;
// 对环食对应环食始 / 环食终。
CentralStart float64
CentralEnd float64
// Magnitude 是站心食分。
Magnitude float64
// Obscuration 是食甚时太阳视圆面被月面遮蔽的面积比例,范围 [0, 1]。
Obscuration float64
// Separation 是食甚时日月中心的站心角距,单位为度。
Separation float64
// SunAltitude / SunAzimuth 是食甚时太阳的站心高度角 / 方位角,单位为度。
SunAltitude float64
SunAzimuth float64
// VisibleAtGreatest 表示食甚时太阳中心在地平线上方。
VisibleAtGreatest bool
HasPartial bool
HasCentral bool
HasAnnular bool
HasTotal bool
}
type localSolarEclipseState struct {
separationRad float64
separationSquared float64
sunRadiusRad float64
moonOuterRadiusRad float64
moonInnerRadiusRad float64
sunAltitudeRad float64
sunAzimuthRad float64
}
const (
localSolarEclipseGreatestWindowDays = 0.5
localSolarEclipseGreatestTolerance = 1e-8
localSolarEclipseContactStepDays = 10.0 / 1440.0
localSolarEclipseContactSearchSteps = 72
localSolarEclipseContactTolerance = 1e-8
localSolarMoonRadiusScale = 1.0000036
)
// LocalSolarEclipse 计算给定近朔时刻附近的一次站心日食,默认使用 NASA bulletin Split-K 模型。
//
// seedJDE 为力学时儒略日TT只需落在目标朔月附近允许相差数天。
// lon 为经度东正西负lat 为纬度北正南负height 为海拔高度,单位米。
func LocalSolarEclipse(seedJDE, lon, lat, height float64) LocalSolarEclipseResult {
return LocalSolarEclipseNASABulletinSplitK(seedJDE, lon, lat, height)
}
// LocalSolarEclipseIAUSingleK 计算给定近朔时刻附近的一次站心日食,使用 IAU Single-K 模型。
func LocalSolarEclipseIAUSingleK(seedJDE, lon, lat, height float64) LocalSolarEclipseResult {
return localSolarEclipse(seedJDE, lon, lat, height, SolarEclipseModelIAUSingleK)
}
// LocalSolarEclipseNASABulletinSplitK 计算给定近朔时刻附近的一次站心日食,使用 NASA bulletin Split-K 模型。
func LocalSolarEclipseNASABulletinSplitK(seedJDE, lon, lat, height float64) LocalSolarEclipseResult {
return localSolarEclipse(seedJDE, lon, lat, height, SolarEclipseModelNASABulletinSplitK)
}
func localSolarEclipse(seedJDE, lonDeg, latDeg, heightMeters float64, model SolarEclipseRadiusModel) LocalSolarEclipseResult {
newMoonJDE := CalcMoonSHByJDE(seedJDE, 0)
lonRad := lonDeg * rad
latRad := latDeg * rad
heightKM := heightMeters / 1000.0
params := solarEclipseModelParams(model)
greatestEclipseJDE := localSolarEclipseGreatest(newMoonJDE, lonRad, latRad, heightKM, params)
state := localSolarEclipseStateAt(greatestEclipseJDE, lonRad, latRad, heightKM, params)
visibleThresholdRad := 0.0
if heightMeters > 0 {
visibleThresholdRad = -HeightDegreeByLat(heightMeters, latDeg) * rad
}
result := LocalSolarEclipseResult{
Model: model,
Type: SolarEclipseNone,
GreatestEclipse: greatestEclipseJDE,
Separation: state.separationRad / rad,
SunAltitude: state.sunAltitudeRad / rad,
SunAzimuth: state.sunAzimuthRad / rad,
VisibleAtGreatest: state.sunAltitudeRad > visibleThresholdRad,
}
partialBoundary := state.sunRadiusRad + state.moonOuterRadiusRad
partialGap := state.separationRad - partialBoundary
if partialGap > 0 {
return result
}
result.Type = SolarEclipsePartial
result.HasPartial = true
result.Magnitude = (state.moonOuterRadiusRad + state.sunRadiusRad - state.separationRad) / (2 * state.sunRadiusRad)
if result.Magnitude < 0 {
result.Magnitude = 0
}
result.Obscuration = localSolarEclipseObscuration(
state.sunRadiusRad,
state.moonOuterRadiusRad,
state.separationRad,
)
if partialStart, ok := localSolarEclipseContact(greatestEclipseJDE, lonRad, latRad, heightKM, params, false, true); ok {
result.PartialStart = partialStart
}
if partialEnd, ok := localSolarEclipseContact(greatestEclipseJDE, lonRad, latRad, heightKM, params, false, false); ok {
result.PartialEnd = partialEnd
}
centralBoundary := math.Abs(state.sunRadiusRad - state.moonInnerRadiusRad)
if state.separationRad > centralBoundary {
return result
}
result.HasCentral = true
if state.moonInnerRadiusRad >= state.sunRadiusRad {
result.Type = SolarEclipseTotal
result.HasTotal = true
} else {
result.Type = SolarEclipseAnnular
result.HasAnnular = true
}
result.Magnitude = state.moonInnerRadiusRad / state.sunRadiusRad
if centralStart, ok := localSolarEclipseContact(greatestEclipseJDE, lonRad, latRad, heightKM, params, true, true); ok {
result.CentralStart = centralStart
}
if centralEnd, ok := localSolarEclipseContact(greatestEclipseJDE, lonRad, latRad, heightKM, params, true, false); ok {
result.CentralEnd = centralEnd
}
return result
}
func solarEclipseModelParams(model SolarEclipseRadiusModel) solarEclipseModelParameters {
params := solarEclipseModelParameters{
penumbralK: solarEclipsePenumbralK,
umbralK: solarEclipsePenumbralK,
}
if model == SolarEclipseModelNASABulletinSplitK {
params.umbralK = solarEclipseUmbralK
}
return params
}
func localSolarEclipseGreatest(
newMoonJDE, lonRad, latRad, heightKM float64,
params solarEclipseModelParameters,
) float64 {
left := newMoonJDE - localSolarEclipseGreatestWindowDays
right := newMoonJDE + localSolarEclipseGreatestWindowDays
goldenRatio := (math.Sqrt(5) - 1) / 2
x1 := right - goldenRatio*(right-left)
x2 := left + goldenRatio*(right-left)
f1 := localSolarEclipseStateAt(x1, lonRad, latRad, heightKM, params).separationSquared
f2 := localSolarEclipseStateAt(x2, lonRad, latRad, heightKM, params).separationSquared
for i := 0; i < 80 && right-left > localSolarEclipseGreatestTolerance; i++ {
if f1 <= f2 {
right = x2
x2 = x1
f2 = f1
x1 = right - goldenRatio*(right-left)
f1 = localSolarEclipseStateAt(x1, lonRad, latRad, heightKM, params).separationSquared
continue
}
left = x1
x1 = x2
f1 = f2
x2 = left + goldenRatio*(right-left)
f2 = localSolarEclipseStateAt(x2, lonRad, latRad, heightKM, params).separationSquared
}
return (left + right) / 2
}
func localSolarEclipseContact(
greatestEclipseJDE, lonRad, latRad, heightKM float64,
params solarEclipseModelParameters,
central bool,
beforeGreatest bool,
) (float64, bool) {
centerGap := localSolarEclipseGap(greatestEclipseJDE, lonRad, latRad, heightKM, params, central)
if centerGap > 0 {
return 0, false
}
if math.Abs(centerGap) <= 1e-14 {
return greatestEclipseJDE, true
}
direction := 1.0
if beforeGreatest {
direction = -1.0
}
previousJDE := greatestEclipseJDE
for i := 1; i <= localSolarEclipseContactSearchSteps; i++ {
currentJDE := greatestEclipseJDE + direction*localSolarEclipseContactStepDays*float64(i)
currentGap := localSolarEclipseGap(currentJDE, lonRad, latRad, heightKM, params, central)
if currentGap >= 0 {
left := previousJDE
right := currentJDE
if beforeGreatest {
left = currentJDE
right = previousJDE
}
return localSolarEclipseContactBisection(left, right, lonRad, latRad, heightKM, params, central)
}
previousJDE = currentJDE
}
return 0, false
}
func localSolarEclipseContactBisection(
leftJDE, rightJDE, lonRad, latRad, heightKM float64,
params solarEclipseModelParameters,
central bool,
) (float64, bool) {
leftGap := localSolarEclipseGap(leftJDE, lonRad, latRad, heightKM, params, central)
rightGap := localSolarEclipseGap(rightJDE, lonRad, latRad, heightKM, params, central)
if leftGap == 0 {
return leftJDE, true
}
if rightGap == 0 {
return rightJDE, true
}
if leftGap*rightGap > 0 {
return 0, false
}
for i := 0; i < 80 && rightJDE-leftJDE > localSolarEclipseContactTolerance; i++ {
midJDE := (leftJDE + rightJDE) / 2
midGap := localSolarEclipseGap(midJDE, lonRad, latRad, heightKM, params, central)
if leftGap*midGap > 0 {
leftJDE = midJDE
leftGap = midGap
continue
}
rightJDE = midJDE
rightGap = midGap
}
return (leftJDE + rightJDE) / 2, true
}
func localSolarEclipseGap(
jdTT, lonRad, latRad, heightKM float64,
params solarEclipseModelParameters,
central bool,
) float64 {
state := localSolarEclipseStateAt(jdTT, lonRad, latRad, heightKM, params)
boundary := state.sunRadiusRad + state.moonOuterRadiusRad
if central {
boundary = math.Abs(state.sunRadiusRad - state.moonInnerRadiusRad)
}
return state.separationRad - boundary
}
func localSolarEclipseStateAt(
jdTT, lonRad, latRad, heightKM float64,
params solarEclipseModelParameters,
) localSolarEclipseState {
sunEquatorial, moonEquatorial := solarEclipseSunMoonEquatorial(jdTT)
sunXYZ := solarEclipseLLRToXYZ(sunEquatorial[0], sunEquatorial[1], sunEquatorial[2])
moonXYZ := solarEclipseLLRToXYZ(moonEquatorial[0], moonEquatorial[1], moonEquatorial[2])
utJDE := TD2UT(jdTT, false)
gst := ApparentSiderealTime(utJDE) * 15 * rad
observerXYZ := localSolarEclipseObserverXYZ(gst, lonRad, latRad, heightKM)
sunTopocentric := solarEclipseXYZToLLR(
sunXYZ[0]-observerXYZ[0],
sunXYZ[1]-observerXYZ[1],
sunXYZ[2]-observerXYZ[2],
)
moonTopocentric := solarEclipseXYZToLLR(
moonXYZ[0]-observerXYZ[0],
moonXYZ[1]-observerXYZ[1],
moonXYZ[2]-observerXYZ[2],
)
sunUnit := solarEclipseLLRToXYZ(sunTopocentric[0], sunTopocentric[1], 1)
moonUnit := solarEclipseLLRToXYZ(moonTopocentric[0], moonTopocentric[1], 1)
dot := sunUnit[0]*moonUnit[0] + sunUnit[1]*moonUnit[1] + sunUnit[2]*moonUnit[2]
if dot > 1 {
dot = 1
}
if dot < -1 {
dot = -1
}
sunRadiusRad := math.Asin(localSolarEclipseClampUnit(
solarEclipseEarthEquatorialRadiusKM * solarEclipseSolarRadiusRatio / sunTopocentric[2],
))
moonOuterRadiusRad := math.Asin(localSolarEclipseClampUnit(
solarEclipseEarthEquatorialRadiusKM * solarEclipsePenumbralK * localSolarMoonRadiusScale / moonTopocentric[2],
))
moonInnerRadiusRad := math.Asin(localSolarEclipseClampUnit(
solarEclipseEarthEquatorialRadiusKM * params.umbralK * localSolarMoonRadiusScale / moonTopocentric[2],
))
sunHorizontal := solarEclipseEquatorialToHorizontal(
sunTopocentric[0],
sunTopocentric[1],
sunTopocentric[2],
lonRad,
latRad,
gst,
)
return localSolarEclipseState{
separationRad: math.Acos(dot),
separationSquared: 2 - 2*dot,
sunRadiusRad: sunRadiusRad,
moonOuterRadiusRad: moonOuterRadiusRad,
moonInnerRadiusRad: moonInnerRadiusRad,
sunAltitudeRad: sunHorizontal[1],
sunAzimuthRad: solarEclipseNormalizeRadians(sunHorizontal[0] + math.Pi),
}
}
func localSolarEclipseObserverXYZ(gst, lonRad, latRad, heightKM float64) [3]float64 {
equatorialRadius := solarEclipseEarthEquatorialRadiusKM
polarRadius := solarEclipseEarthEquatorialRadiusKM * solarEclipseEarthPolarRatio
u := math.Atan((polarRadius / equatorialRadius) * math.Tan(latRad))
radiusXY := equatorialRadius*math.Cos(u) + heightKM*math.Cos(latRad)
radiusZ := polarRadius*math.Sin(u) + heightKM*math.Sin(latRad)
angle := gst + lonRad
return [3]float64{
radiusXY * math.Cos(angle),
radiusXY * math.Sin(angle),
radiusZ,
}
}
func localSolarEclipseObscuration(sunRadius, moonRadius, separation float64) float64 {
if separation >= sunRadius+moonRadius {
return 0
}
sunArea := math.Pi * sunRadius * sunRadius
if separation <= math.Abs(sunRadius-moonRadius) {
if moonRadius >= sunRadius {
return 1
}
return moonRadius * moonRadius / (sunRadius * sunRadius)
}
partSun := localSolarEclipseClampUnit((separation*separation + sunRadius*sunRadius - moonRadius*moonRadius) / (2 * separation * sunRadius))
partMoon := localSolarEclipseClampUnit((separation*separation + moonRadius*moonRadius - sunRadius*sunRadius) / (2 * separation * moonRadius))
term := (-separation + sunRadius + moonRadius) *
(separation + sunRadius - moonRadius) *
(separation - sunRadius + moonRadius) *
(separation + sunRadius + moonRadius)
if term < 0 {
term = 0
}
overlapArea := sunRadius*sunRadius*math.Acos(partSun) +
moonRadius*moonRadius*math.Acos(partMoon) -
0.5*math.Sqrt(term)
return overlapArea / sunArea
}
func localSolarEclipseClampUnit(value float64) float64 {
if value > 1 {
return 1
}
if value < -1 {
return -1
}
return value
}