Files
astro/basic/solar_eclipse_path.go
T

982 lines
35 KiB
Go
Raw Normal View History

2026-05-01 22:38:44 +08:00
package basic
import (
"math"
"sort"
)
const (
solarEclipsePathDefaultStepDays = 1.0 / 1440.0
solarEclipsePathMinStepDays = 1.0 / 86400.0
solarEclipsePathMaxSampleCount = 30000
solarEclipsePathMaxAdaptiveDepth = 20
solarEclipsePathVelocityStepDays = 1.0 / 1440.0
solarEclipsePathDuplicateTimeDays = 1e-10
solarEclipsePartialFootprintDefaultStepDays = 5.0 / 1440.0
solarEclipsePartialFootprintDefaultBoundaryPoints = 180
solarEclipsePartialFootprintMinBoundaryPoints = 12
solarEclipsePartialFootprintMaxBoundaryPoints = 1440
solarEclipsePartialFootprintPointTolerance = 1e-12
solarEclipsePartialFootprintIterationLimit = 10
solarEclipsePartialFootprintTransitionIterations = 48
solarEclipseShadowContactSearchStepDays = 10.0 / 1440.0
solarEclipseShadowContactSearchSpanDays = 0.75
solarEclipseShadowContactToleranceDays = 1e-9
)
type solarEclipseShadowKind uint8
const (
solarEclipsePenumbralShadow solarEclipseShadowKind = iota
solarEclipseCentralShadow
2026-05-01 22:38:44 +08:00
)
// SolarEclipsePathOptions 控制日食中心路径采样。
// SolarEclipsePathOptions controls central solar eclipse path sampling.
type SolarEclipsePathOptions struct {
// StepDays 是基础时间采样步长,单位为日;<=0 时使用 1 分钟。
// StepDays is the base time step in days; values <= 0 use one minute.
StepDays float64
// TargetSpacingKM 是相邻中心线点的最大目标地表距离;<=0 时不按距离加密。
// TargetSpacingKM is the target maximum ground spacing between centerline points; values <= 0 disable spacing refinement.
TargetSpacingKM float64
}
// SolarEclipsePathPoint 表示日食路径上的一个地理点。
// SolarEclipsePathPoint is one geographic point on a solar eclipse path.
type SolarEclipsePathPoint struct {
// JDE 是力学时儒略日, TT Julian ephemeris day.
JDE float64
// Longitude 经度,东正西负, longitude in degrees, east positive.
Longitude float64
// Latitude 纬度,北正南负, latitude in degrees, north positive.
Latitude float64
// SunAltitude 太阳高度角,单位度, Sun altitude in degrees.
SunAltitude float64
// WidthKM 中心食带宽度,单位千米;仅中心线点有意义。
// WidthKM is the central path width in kilometers; meaningful for centerline points.
WidthKM float64
}
// SolarEclipsePathResult 表示一次中心日食的路径数据。
// SolarEclipsePathResult contains central solar eclipse path data.
type SolarEclipsePathResult struct {
// Eclipse 是对应的全局日食结果, related global solar eclipse result.
Eclipse SolarEclipseResult
// Greatest 是食甚点/最佳观测点, greatest eclipse point.
Greatest SolarEclipsePathPoint
// CenterLine 是中心线, central line.
CenterLine []SolarEclipsePathPoint
// NorthernLimit 是中心食带北界近似线, approximate northern limit of the central path.
NorthernLimit []SolarEclipsePathPoint
// SouthernLimit 是中心食带南界近似线, approximate southern limit of the central path.
SouthernLimit []SolarEclipsePathPoint
// StepDays 是实际采用的基础时间采样步长,单位为日。
// StepDays is the effective base time step in days.
StepDays float64
// TargetSpacingKM 是实际采用的目标空间采样距离,单位千米。
// TargetSpacingKM is the effective target spacing in kilometers.
TargetSpacingKM float64
}
// SolarEclipsePartialFootprintOptions 控制日食偏食半影足迹采样。
// SolarEclipsePartialFootprintOptions controls solar eclipse penumbral footprint sampling.
type SolarEclipsePartialFootprintOptions struct {
// StepDays 是基础时间采样步长,单位为日;<=0 时使用 5 分钟。
// StepDays is the base time step in days; values <= 0 use five minutes.
StepDays float64
// BoundaryPoints 是每个瞬时半影边界的角向采样点数;<=0 时使用 180。
// BoundaryPoints is the angular sample count for each instantaneous penumbral boundary; values <= 0 use 180.
BoundaryPoints int
// CentralShadowStepDays 是本影/反本影瞬时足迹的时间步长,单位为日;<=0 时不计算。
// CentralShadowStepDays is the umbral/antumbral footprint step in days; values <= 0 disable it.
CentralShadowStepDays float64
2026-05-01 22:38:44 +08:00
}
// SolarEclipsePartialAreaOptions 是 SolarEclipsePartialFootprintOptions 的兼容别名。
// SolarEclipsePartialAreaOptions is a compatibility alias for SolarEclipsePartialFootprintOptions.
type SolarEclipsePartialAreaOptions = SolarEclipsePartialFootprintOptions
// SolarEclipsePartialFootprint 表示某一时刻的半影足迹边界。
// SolarEclipsePartialFootprint is the penumbral footprint boundary at one instant.
type SolarEclipsePartialFootprint struct {
// JDE 是力学时儒略日, TT Julian ephemeris day.
JDE float64
// Boundaries 是半影边界分段;反经线或无效投影会拆成多段。
// Boundaries are segmented penumbral boundary polylines, split at invalid projections or the antimeridian.
Boundaries [][]SolarEclipsePathPoint
// Closed 表示 Boundaries 是否构成一个闭合边界。
// Closed indicates whether Boundaries form one closed boundary.
Closed bool
}
// SolarEclipsePartialFootprintsResult 表示一次日食的偏食半影足迹序列。
// SolarEclipsePartialFootprintsResult contains penumbral footprint samples for a solar eclipse.
type SolarEclipsePartialFootprintsResult struct {
// Eclipse 是对应的全局日食结果, related global solar eclipse result.
Eclipse SolarEclipseResult
// Footprints 是按时间采样的瞬时半影足迹, sampled instantaneous penumbral footprints.
Footprints []SolarEclipsePartialFootprint
// CentralShadowFootprints 是按时间采样的本影/反本影足迹。
// CentralShadowFootprints are sampled umbral/antumbral footprints.
CentralShadowFootprints []SolarEclipsePartialFootprint
// P1-P4 是半影与地球的外切/内切接触点;不存在的内切点保持零值。
// P1-P4 are external/internal penumbral contacts; absent internal contacts remain zero.
P1 SolarEclipsePathPoint
P2 SolarEclipsePathPoint
P3 SolarEclipsePathPoint
P4 SolarEclipsePathPoint
// U1-U4 是本影/反本影与地球的外切/内切接触点;不存在时保持零值。
// U1-U4 are external/internal umbral/antumbral contacts; absent contacts remain zero.
U1 SolarEclipsePathPoint
U2 SolarEclipsePathPoint
U3 SolarEclipsePathPoint
U4 SolarEclipsePathPoint
2026-05-01 22:38:44 +08:00
// StepDays 是实际采用的基础时间采样步长,单位为日。
// StepDays is the effective base time step in days.
StepDays float64
// BoundaryPoints 是实际采用的边界角向采样点数。
// BoundaryPoints is the effective angular sample count for each boundary.
BoundaryPoints int
// CentralShadowStepDays 是本影/反本影足迹的实际采样步长;0 表示未计算。
// CentralShadowStepDays is the effective umbral/antumbral footprint step; zero means disabled.
CentralShadowStepDays float64
2026-05-01 22:38:44 +08:00
}
// SolarEclipsePartialAreaResult 是 SolarEclipsePartialFootprintsResult 的兼容别名。
// SolarEclipsePartialAreaResult is a compatibility alias for SolarEclipsePartialFootprintsResult.
type SolarEclipsePartialAreaResult = SolarEclipsePartialFootprintsResult
// SolarEclipseCentralPath 计算给定近朔时刻附近的日食中心路径,默认使用 NASA bulletin Split-K 模型。
// SolarEclipseCentralPath computes the central path near the given new-moon seed, using NASA bulletin Split-K by default.
func SolarEclipseCentralPath(seedJDE float64, options SolarEclipsePathOptions) SolarEclipsePathResult {
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)
}
// 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)
}
// SolarEclipsePartialFootprints 计算给定近朔时刻附近的日食偏食半影足迹序列,默认使用 NASA bulletin Split-K 模型。
// SolarEclipsePartialFootprints computes penumbral footprint samples near the given new-moon seed, using NASA bulletin Split-K by default.
func SolarEclipsePartialFootprints(seedJDE float64, options SolarEclipsePartialFootprintOptions) SolarEclipsePartialFootprintsResult {
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)
}
// 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)
}
// SolarEclipsePartialArea 计算日食偏食半影足迹序列,是 SolarEclipsePartialFootprints 的兼容包装。
// SolarEclipsePartialArea computes penumbral footprint samples and is a compatibility wrapper for SolarEclipsePartialFootprints.
func SolarEclipsePartialArea(seedJDE float64, options SolarEclipsePartialAreaOptions) SolarEclipsePartialAreaResult {
return SolarEclipsePartialFootprints(seedJDE, options)
}
// SolarEclipsePartialAreaIAUSingleK 计算日食偏食半影足迹序列,是 SolarEclipsePartialFootprintsIAUSingleK 的兼容包装。
// SolarEclipsePartialAreaIAUSingleK is a compatibility wrapper for SolarEclipsePartialFootprintsIAUSingleK.
func SolarEclipsePartialAreaIAUSingleK(seedJDE float64, options SolarEclipsePartialAreaOptions) SolarEclipsePartialAreaResult {
return SolarEclipsePartialFootprintsIAUSingleK(seedJDE, options)
}
// SolarEclipsePartialAreaNASABulletinSplitK 计算日食偏食半影足迹序列,是 SolarEclipsePartialFootprintsNASABulletinSplitK 的兼容包装。
// SolarEclipsePartialAreaNASABulletinSplitK is a compatibility wrapper for SolarEclipsePartialFootprintsNASABulletinSplitK.
func SolarEclipsePartialAreaNASABulletinSplitK(seedJDE float64, options SolarEclipsePartialAreaOptions) SolarEclipsePartialAreaResult {
return SolarEclipsePartialFootprintsNASABulletinSplitK(seedJDE, options)
}
func solarEclipseCentralPath(seedJDE float64, model SolarEclipseRadiusModel, options SolarEclipsePathOptions) SolarEclipsePathResult {
options = normalizeSolarEclipsePathOptions(options)
result := solarEclipse(seedJDE, model)
path := SolarEclipsePathResult{
Eclipse: result,
StepDays: options.StepDays,
TargetSpacingKM: options.TargetSpacingKM,
}
if !result.HasCentral {
return path
}
newMoonJDE := CalcMoonSHByJDE(seedJDE, 0)
solver := newSolarEclipseSolver(newMoonJDE, model)
greatest, ok := solver.centralPathPointAt(result.GreatestEclipse)
if !ok {
greatest = SolarEclipsePathPoint{
JDE: result.GreatestEclipse,
Longitude: result.GreatestLongitude,
Latitude: result.GreatestLatitude,
WidthKM: result.PathWidthKM,
SunAltitude: solarEclipseSunAltitudeAtGreatest(
result.GreatestEclipse,
result.GreatestLongitude,
result.GreatestLatitude,
solver.besselAxisAt(result.GreatestEclipse).gst,
) / rad,
}
}
greatest.Longitude = result.GreatestLongitude
greatest.Latitude = result.GreatestLatitude
greatest.WidthKM = result.PathWidthKM
path.Greatest = greatest
centerLine, stepDays := solver.centralPathPoints(
result.CentralBeginOnEarth,
result.CentralEndOnEarth,
result.GreatestEclipse,
options,
)
path.StepDays = stepDays
path.CenterLine = centerLine
path.NorthernLimit, path.SouthernLimit = solver.centralPathLimits(centerLine)
return path
}
func normalizeSolarEclipsePathOptions(options SolarEclipsePathOptions) SolarEclipsePathOptions {
if options.StepDays <= 0 || math.IsNaN(options.StepDays) || math.IsInf(options.StepDays, 0) {
options.StepDays = solarEclipsePathDefaultStepDays
}
if options.StepDays < solarEclipsePathMinStepDays {
options.StepDays = solarEclipsePathMinStepDays
}
if options.TargetSpacingKM <= 0 || math.IsNaN(options.TargetSpacingKM) || math.IsInf(options.TargetSpacingKM, 0) {
options.TargetSpacingKM = 0
}
return options
}
func solarEclipsePartialFootprints(
seedJDE float64,
model SolarEclipseRadiusModel,
options SolarEclipsePartialFootprintOptions,
) SolarEclipsePartialFootprintsResult {
options = normalizeSolarEclipsePartialFootprintOptions(options)
result := solarEclipse(seedJDE, model)
footprintsResult := SolarEclipsePartialFootprintsResult{
Eclipse: result,
StepDays: options.StepDays,
BoundaryPoints: options.BoundaryPoints,
CentralShadowStepDays: options.CentralShadowStepDays,
2026-05-01 22:38:44 +08:00
}
if !result.HasPartial {
return footprintsResult
}
newMoonJDE := CalcMoonSHByJDE(seedJDE, 0)
solver := newSolarEclipseSolver(newMoonJDE, model)
footprintsResult.P1, footprintsResult.P4, _ = solver.shadowContactPair(
result.GreatestEclipse, solarEclipsePenumbralShadow, false,
)
footprintsResult.P2, footprintsResult.P3, _ = solver.shadowContactPair(
result.GreatestEclipse, solarEclipsePenumbralShadow, true,
)
if result.Type != SolarEclipsePartial {
footprintsResult.U1, footprintsResult.U4, _ = solver.shadowContactPair(
result.GreatestEclipse, solarEclipseCentralShadow, false,
)
footprintsResult.U2, footprintsResult.U3, _ = solver.shadowContactPair(
result.GreatestEclipse, solarEclipseCentralShadow, true,
)
}
2026-05-01 22:38:44 +08:00
footprints, stepDays := solver.partialFootprints(
result.PartialBeginOnEarth,
result.PartialEndOnEarth,
result.GreatestEclipse,
options,
)
footprintsResult.StepDays = stepDays
footprintsResult.Footprints = footprints
if options.CentralShadowStepDays > 0 &&
footprintsResult.U1.JDE != 0 && footprintsResult.U4.JDE != 0 {
footprintsResult.CentralShadowFootprints, footprintsResult.CentralShadowStepDays = solver.shadowFootprints(
footprintsResult.U1.JDE,
footprintsResult.U4.JDE,
result.GreatestEclipse,
options.CentralShadowStepDays,
options.BoundaryPoints,
solarEclipseCentralShadow,
)
}
2026-05-01 22:38:44 +08:00
return footprintsResult
}
func normalizeSolarEclipsePartialFootprintOptions(options SolarEclipsePartialFootprintOptions) SolarEclipsePartialFootprintOptions {
if options.StepDays <= 0 || math.IsNaN(options.StepDays) || math.IsInf(options.StepDays, 0) {
options.StepDays = solarEclipsePartialFootprintDefaultStepDays
}
if options.StepDays < solarEclipsePathMinStepDays {
options.StepDays = solarEclipsePathMinStepDays
}
if options.BoundaryPoints <= 0 {
options.BoundaryPoints = solarEclipsePartialFootprintDefaultBoundaryPoints
}
if options.BoundaryPoints < solarEclipsePartialFootprintMinBoundaryPoints {
options.BoundaryPoints = solarEclipsePartialFootprintMinBoundaryPoints
}
if options.BoundaryPoints > solarEclipsePartialFootprintMaxBoundaryPoints {
options.BoundaryPoints = solarEclipsePartialFootprintMaxBoundaryPoints
}
if options.CentralShadowStepDays <= 0 || math.IsNaN(options.CentralShadowStepDays) || math.IsInf(options.CentralShadowStepDays, 0) {
options.CentralShadowStepDays = 0
} else if options.CentralShadowStepDays < solarEclipsePathMinStepDays {
options.CentralShadowStepDays = solarEclipsePathMinStepDays
}
2026-05-01 22:38:44 +08:00
return options
}
func (solver solarEclipseSolver) centralPathPoints(
startJDE, endJDE, greatestJDE float64,
options SolarEclipsePathOptions,
) ([]SolarEclipsePathPoint, float64) {
if endJDE < startJDE {
startJDE, endJDE = endJDE, startJDE
}
if startJDE == 0 || endJDE == 0 || endJDE <= startJDE {
return nil, options.StepDays
}
stepDays := options.StepDays
if sampleCount := int(math.Ceil((endJDE-startJDE)/stepDays)) + 1; sampleCount > solarEclipsePathMaxSampleCount {
stepDays = (endJDE - startJDE) / float64(solarEclipsePathMaxSampleCount-1)
}
times := []float64{startJDE, greatestJDE, endJDE}
for jd := startJDE + stepDays; jd < endJDE; jd += stepDays {
times = append(times, jd)
}
sort.Float64s(times)
times = uniqueSolarEclipsePathTimes(times)
points := make([]SolarEclipsePathPoint, 0, len(times))
for _, jd := range times {
point, ok := solver.centralPathPointAt(jd)
if ok {
points = append(points, point)
}
}
if options.TargetSpacingKM > 0 {
points = solver.refineCentralPathSpacing(points, options.TargetSpacingKM)
}
return points, stepDays
}
func (solver solarEclipseSolver) partialFootprints(
startJDE, endJDE, greatestJDE float64,
options SolarEclipsePartialFootprintOptions,
) ([]SolarEclipsePartialFootprint, float64) {
return solver.shadowFootprints(
startJDE,
endJDE,
greatestJDE,
options.StepDays,
options.BoundaryPoints,
solarEclipsePenumbralShadow,
)
}
func (solver solarEclipseSolver) shadowFootprints(
startJDE, endJDE, greatestJDE, requestedStepDays float64,
boundaryPoints int,
kind solarEclipseShadowKind,
2026-05-01 22:38:44 +08:00
) ([]SolarEclipsePartialFootprint, float64) {
if endJDE < startJDE {
startJDE, endJDE = endJDE, startJDE
}
if startJDE == 0 || endJDE == 0 || endJDE <= startJDE {
return nil, requestedStepDays
2026-05-01 22:38:44 +08:00
}
stepDays := requestedStepDays
2026-05-01 22:38:44 +08:00
if sampleCount := int(math.Ceil((endJDE-startJDE)/stepDays)) + 1; sampleCount > solarEclipsePathMaxSampleCount {
stepDays = (endJDE - startJDE) / float64(solarEclipsePathMaxSampleCount-1)
}
times := []float64{startJDE, greatestJDE, endJDE}
for jd := startJDE + stepDays; jd < endJDE; jd += stepDays {
times = append(times, jd)
}
sort.Float64s(times)
times = uniqueSolarEclipsePathTimes(times)
footprints := make([]SolarEclipsePartialFootprint, 0, len(times))
for _, jd := range times {
footprint := solver.shadowFootprintAt(jd, boundaryPoints, kind)
2026-05-01 22:38:44 +08:00
if len(footprint.Boundaries) > 0 {
footprints = append(footprints, footprint)
}
}
return footprints, stepDays
}
func uniqueSolarEclipsePathTimes(times []float64) []float64 {
if len(times) < 2 {
return times
}
unique := times[:1]
for _, jd := range times[1:] {
if math.Abs(jd-unique[len(unique)-1]) <= solarEclipsePathDuplicateTimeDays {
continue
}
unique = append(unique, jd)
}
return unique
}
func (solver solarEclipseSolver) refineCentralPathSpacing(points []SolarEclipsePathPoint, targetSpacingKM float64) []SolarEclipsePathPoint {
if len(points) < 2 || targetSpacingKM <= 0 {
return points
}
refined := make([]SolarEclipsePathPoint, 0, len(points))
refined = append(refined, points[0])
for i := 1; i < len(points); i++ {
refined = solver.appendRefinedCentralPathSegment(refined, points[i-1], points[i], targetSpacingKM, 0)
}
return refined
}
func (solver solarEclipseSolver) appendRefinedCentralPathSegment(
points []SolarEclipsePathPoint,
start, end SolarEclipsePathPoint,
targetSpacingKM float64,
depth int,
) []SolarEclipsePathPoint {
if depth >= solarEclipsePathMaxAdaptiveDepth || solarEclipsePathDistanceKM(start, end) <= targetSpacingKM {
return append(points, end)
}
midJDE := (start.JDE + end.JDE) / 2
mid, ok := solver.centralPathPointAt(midJDE)
if !ok {
return append(points, end)
}
points = solver.appendRefinedCentralPathSegment(points, start, mid, targetSpacingKM, depth+1)
return solver.appendRefinedCentralPathSegment(points, mid, end, targetSpacingKM, depth+1)
}
func (solver solarEclipseSolver) centralPathPointAt(jd float64) (SolarEclipsePathPoint, bool) {
moon := solver.besselMoonAt(jd)
axis := solver.besselAxisAt(jd)
intersection := solarEclipseLineEar2(
moon[0],
moon[1],
2,
moon[0],
moon[1],
0,
solarEclipseEarthPolarRatio,
1,
axis,
)
if !intersection.valid {
return SolarEclipsePathPoint{}, false
}
longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis)
sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst)
radii := solver.shadowRadiiAt(moon[2] - intersection.r2)
widthKM := 0.0
if math.Abs(math.Sin(sunAltitudeRad)) > 1e-12 {
widthKM = math.Abs(2*radii.umbraRadius*solarEclipseEarthEquatorialRadiusKM) / math.Abs(math.Sin(sunAltitudeRad))
}
return SolarEclipsePathPoint{
JDE: jd,
Longitude: longitude,
Latitude: latitude,
SunAltitude: sunAltitudeRad / rad,
WidthKM: widthKM,
}, true
}
func (solver solarEclipseSolver) centralPathLimits(centerLine []SolarEclipsePathPoint) ([]SolarEclipsePathPoint, []SolarEclipsePathPoint) {
northern := make([]SolarEclipsePathPoint, 0, len(centerLine))
southern := make([]SolarEclipsePathPoint, 0, len(centerLine))
for _, center := range centerLine {
north, south, ok := solver.centralPathLimitsAt(center)
if !ok {
continue
}
northern = append(northern, north)
southern = append(southern, south)
}
return northern, southern
}
func (solver solarEclipseSolver) centralPathLimitsAt(center SolarEclipsePathPoint) (SolarEclipsePathPoint, SolarEclipsePathPoint, bool) {
moon := solver.besselMoonAt(center.JDE)
axis := solver.besselAxisAt(center.JDE)
intersection := solarEclipseLineEar2(
moon[0],
moon[1],
2,
moon[0],
moon[1],
0,
solarEclipseEarthPolarRatio,
1,
axis,
)
if !intersection.valid {
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
}
radii := solver.shadowRadiiAt(moon[2] - intersection.r2)
radius := radii.absUmbraRadius
if radius <= 0 {
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
}
vx, vy, speed := solver.besselVelocityXYAt(center.JDE)
if speed <= 0 {
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
}
perpX := -vy / speed
perpY := vx / speed
first, okFirst := solarEclipsePathPointFromBesselXY(center.JDE, moon[0]+radius*perpX, moon[1]+radius*perpY, axis)
second, okSecond := solarEclipsePathPointFromBesselXY(center.JDE, moon[0]-radius*perpX, moon[1]-radius*perpY, axis)
if !okFirst || !okSecond {
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
}
first.WidthKM = center.WidthKM
second.WidthKM = center.WidthKM
if first.Latitude >= second.Latitude {
return first, second, true
}
return second, first, true
}
func (solver solarEclipseSolver) besselVelocityXYAt(jd float64) (float64, float64, float64) {
before := solver.besselMoonAt(jd - solarEclipsePathVelocityStepDays)
after := solver.besselMoonAt(jd + solarEclipsePathVelocityStepDays)
vx := (after[0] - before[0]) / (2 * solarEclipsePathVelocityStepDays)
vy := (after[1] - before[1]) / (2 * solarEclipsePathVelocityStepDays)
return vx, vy, math.Hypot(vx, vy)
}
func solarEclipsePathPointFromBesselXY(jd, x, y float64, axis solarEclipseAxis) (SolarEclipsePathPoint, bool) {
longitude, latitude, ok := solarEclipseBesselXYToGeodetic(x, y, axis, true)
if !ok {
return SolarEclipsePathPoint{}, false
}
sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst)
return SolarEclipsePathPoint{
JDE: jd,
Longitude: longitude,
Latitude: latitude,
SunAltitude: sunAltitudeRad / rad,
}, true
}
func (solver solarEclipseSolver) shadowContactPair(
greatestJDE float64,
kind solarEclipseShadowKind,
internal bool,
) (SolarEclipsePathPoint, SolarEclipsePathPoint, bool) {
middleResidual, ok := solver.shadowContactResidual(greatestJDE, kind, internal)
if !ok || middleResidual > 0 {
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
}
firstJDE, firstOK := solver.shadowContactRoot(greatestJDE, -1, middleResidual, kind, internal)
lastJDE, lastOK := solver.shadowContactRoot(greatestJDE, 1, middleResidual, kind, internal)
if !firstOK || !lastOK {
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
}
first, firstOK := solver.shadowContactPointAt(firstJDE, kind)
last, lastOK := solver.shadowContactPointAt(lastJDE, kind)
if !firstOK || !lastOK {
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
}
return first, last, true
}
func (solver solarEclipseSolver) shadowContactRoot(
greatestJDE float64,
direction float64,
middleResidual float64,
kind solarEclipseShadowKind,
internal bool,
) (float64, bool) {
insideJDE := greatestJDE
insideResidual := middleResidual
for span := solarEclipseShadowContactSearchStepDays; span <= solarEclipseShadowContactSearchSpanDays; span += solarEclipseShadowContactSearchStepDays {
outsideJDE := greatestJDE + direction*span
outsideResidual, ok := solver.shadowContactResidual(outsideJDE, kind, internal)
if !ok {
continue
}
if outsideResidual >= 0 {
leftJDE, rightJDE := outsideJDE, insideJDE
leftResidual, rightResidual := outsideResidual, insideResidual
if leftJDE > rightJDE {
leftJDE, rightJDE = rightJDE, leftJDE
leftResidual, rightResidual = rightResidual, leftResidual
}
for rightJDE-leftJDE > solarEclipseShadowContactToleranceDays {
middleJDE := (leftJDE + rightJDE) / 2
residual, valid := solver.shadowContactResidual(middleJDE, kind, internal)
if !valid {
return 0, false
}
if (residual >= 0) == (leftResidual >= 0) {
leftJDE, leftResidual = middleJDE, residual
} else {
rightJDE, rightResidual = middleJDE, residual
}
}
return (leftJDE + rightJDE) / 2, true
}
insideJDE, insideResidual = outsideJDE, outsideResidual
}
return 0, false
}
func (solver solarEclipseSolver) shadowContactResidual(
jd float64,
kind solarEclipseShadowKind,
internal bool,
) (float64, bool) {
moon := solver.besselMoonAt(jd)
distanceSquared := moon[0]*moon[0] + moon[1]*moon[1]
if distanceSquared <= 0 {
return 0, false
}
radius := solver.shadowRadiusAt(moon[2], kind)
if radius <= 0 {
return 0, false
}
earthRadius := 1 - (1/solarEclipseEarthPolarRatioSquared-1)*moon[1]*moon[1]/distanceSquared/2
limit := earthRadius + radius
if internal {
limit = earthRadius - radius
}
if limit <= 0 {
return 0, false
}
return math.Sqrt(distanceSquared) - limit, true
}
func (solver solarEclipseSolver) shadowContactPointAt(
jd float64,
kind solarEclipseShadowKind,
) (SolarEclipsePathPoint, bool) {
moon := solver.besselMoonAt(jd)
distance := math.Hypot(moon[0], moon[1])
if distance <= 0 || solver.shadowRadiusAt(moon[2], kind) <= 0 {
return SolarEclipsePathPoint{}, false
}
axis := solver.besselAxisAt(jd)
unitX, unitY := moon[0]/distance, moon[1]/distance
insideScale, outsideScale := 0.0, 1.1
var intersection solarEclipseLineIntersection
for iteration := 0; iteration < 48; iteration++ {
scale := (insideScale + outsideScale) / 2
candidate := solarEclipseLineEar2(
scale*unitX, scale*unitY, 2,
scale*unitX, scale*unitY, 0,
solarEclipseEarthPolarRatio, 1, axis,
)
if candidate.valid {
insideScale = scale
intersection = candidate
} else {
outsideScale = scale
}
}
if !intersection.valid {
return SolarEclipsePathPoint{}, false
}
longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis)
sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst)
return SolarEclipsePathPoint{
JDE: jd,
Longitude: longitude,
Latitude: latitude,
SunAltitude: sunAltitudeRad / rad,
}, true
}
func (solver solarEclipseSolver) shadowRadiusAt(moonBesselZ float64, kind solarEclipseShadowKind) float64 {
radii := solver.shadowRadiiAt(moonBesselZ)
if kind == solarEclipseCentralShadow {
return radii.absUmbraRadius
}
return radii.penumbraRadius
}
2026-05-01 22:38:44 +08:00
func (solver solarEclipseSolver) partialFootprintAt(jd float64, boundaryPoints int) SolarEclipsePartialFootprint {
return solver.shadowFootprintAt(jd, boundaryPoints, solarEclipsePenumbralShadow)
}
func (solver solarEclipseSolver) shadowFootprintAt(
jd float64,
boundaryPoints int,
kind solarEclipseShadowKind,
) SolarEclipsePartialFootprint {
2026-05-01 22:38:44 +08:00
moon := solver.besselMoonAt(jd)
axis := solver.besselAxisAt(jd)
samples := make([]solarEclipsePartialBoundarySample, boundaryPoints)
for i := range samples {
angle := 2 * math.Pi * float64(i) / float64(boundaryPoints)
point, ok := solver.shadowFootprintPointAt(jd, moon, axis, angle, kind)
2026-05-01 22:38:44 +08:00
samples[i] = solarEclipsePartialBoundarySample{
point: point,
ok: ok,
angle: angle,
2026-05-01 22:38:44 +08:00
}
}
samples = solver.refineShadowFootprintTransitions(jd, moon, axis, samples, kind)
2026-05-01 22:38:44 +08:00
boundaries, closed := solarEclipsePartialBoundarySegments(samples)
return SolarEclipsePartialFootprint{
JDE: jd,
Boundaries: boundaries,
Closed: closed,
}
}
type solarEclipsePartialBoundarySample struct {
point SolarEclipsePathPoint
ok bool
angle float64
}
func (solver solarEclipseSolver) refineShadowFootprintTransitions(
jd float64,
moon [3]float64,
axis solarEclipseAxis,
samples []solarEclipsePartialBoundarySample,
kind solarEclipseShadowKind,
) []solarEclipsePartialBoundarySample {
if len(samples) < 2 {
return samples
}
result := make([]solarEclipsePartialBoundarySample, 0, len(samples)+4)
for index, sample := range samples {
result = append(result, sample)
next := samples[(index+1)%len(samples)]
if sample.ok == next.ok {
continue
}
nextAngle := next.angle
if index == len(samples)-1 {
nextAngle += 2 * math.Pi
}
refined := solver.refineShadowFootprintTransition(jd, moon, axis, sample, next, nextAngle, kind)
result = append(result, refined)
}
return result
2026-05-01 22:38:44 +08:00
}
func (solver solarEclipseSolver) refineShadowFootprintTransition(
jd float64,
moon [3]float64,
axis solarEclipseAxis,
first, second solarEclipsePartialBoundarySample,
secondAngle float64,
kind solarEclipseShadowKind,
) solarEclipsePartialBoundarySample {
leftAngle := first.angle
rightAngle := secondAngle
leftOK := first.ok
best := first
if second.ok {
best = second
best.angle = secondAngle
}
for iteration := 0; iteration < solarEclipsePartialFootprintTransitionIterations; iteration++ {
middleAngle := (leftAngle + rightAngle) / 2
evaluationAngle := math.Mod(middleAngle, 2*math.Pi)
point, ok := solver.shadowFootprintPointAt(jd, moon, axis, evaluationAngle, kind)
middle := solarEclipsePartialBoundarySample{point: point, ok: ok, angle: middleAngle}
if ok {
best = middle
}
if ok == leftOK {
leftAngle = middleAngle
} else {
rightAngle = middleAngle
}
if rightAngle-leftAngle <= solarEclipsePartialFootprintPointTolerance {
break
}
}
best.angle = math.Mod(best.angle, 2*math.Pi)
return best
}
func (solver solarEclipseSolver) shadowFootprintPointAt(
2026-05-01 22:38:44 +08:00
jd float64,
moon [3]float64,
axis solarEclipseAxis,
angle float64,
kind solarEclipseShadowKind,
2026-05-01 22:38:44 +08:00
) (SolarEclipsePathPoint, bool) {
cosAngle := math.Cos(angle)
sinAngle := math.Sin(angle)
radius := solver.shadowRadiusAt(moon[2], kind)
2026-05-01 22:38:44 +08:00
if radius <= 0 {
return SolarEclipsePathPoint{}, false
}
var intersection solarEclipseLineIntersection
for i := 0; i < solarEclipsePartialFootprintIterationLimit; i++ {
x := moon[0] + radius*cosAngle
y := moon[1] + radius*sinAngle
intersection = solarEclipseLineEar2(
x,
y,
2,
x,
y,
0,
solarEclipseEarthPolarRatio,
1,
axis,
)
if !intersection.valid {
return SolarEclipsePathPoint{}, false
}
nextRadius := solver.shadowRadiusAt(moon[2]-intersection.r2, kind)
2026-05-01 22:38:44 +08:00
if nextRadius <= 0 {
return SolarEclipsePathPoint{}, false
}
if math.Abs(nextRadius-radius) <= solarEclipsePartialFootprintPointTolerance {
radius = nextRadius
break
}
radius = nextRadius
}
x := moon[0] + radius*cosAngle
y := moon[1] + radius*sinAngle
intersection = solarEclipseLineEar2(
x,
y,
2,
x,
y,
0,
solarEclipseEarthPolarRatio,
1,
axis,
)
if !intersection.valid {
return SolarEclipsePathPoint{}, false
}
longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis)
sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst)
return SolarEclipsePathPoint{
JDE: jd,
Longitude: longitude,
Latitude: latitude,
SunAltitude: sunAltitudeRad / rad,
}, true
}
func solarEclipsePartialBoundarySegments(samples []solarEclipsePartialBoundarySample) ([][]SolarEclipsePathPoint, bool) {
segments := make([][]SolarEclipsePathPoint, 0, 2)
var current []SolarEclipsePathPoint
allSamplesValid := len(samples) > 0
2026-05-01 22:38:44 +08:00
for _, sample := range samples {
if !sample.ok {
allSamplesValid = false
2026-05-01 22:38:44 +08:00
segments = appendSolarEclipsePartialSegment(segments, current)
current = nil
continue
}
if len(current) > 0 && solarEclipsePathCrossesAntimeridian(current[len(current)-1], sample.point) {
segments = appendSolarEclipsePartialSegment(segments, current)
current = nil
}
current = append(current, sample.point)
}
segments = appendSolarEclipsePartialSegment(segments, current)
segments = mergeSolarEclipsePartialWrapSegment(segments, samples)
if !allSamplesValid {
return segments, false
}
totalPoints := 0
for _, segment := range segments {
totalPoints += len(segment)
}
if totalPoints < 3 {
return segments, false
}
if len(segments) == 1 && !solarEclipsePathCrossesAntimeridian(segments[0][len(segments[0])-1], segments[0][0]) {
2026-05-01 22:38:44 +08:00
segments[0] = append(segments[0], segments[0][0])
}
return segments, true
2026-05-01 22:38:44 +08:00
}
func appendSolarEclipsePartialSegment(
segments [][]SolarEclipsePathPoint,
segment []SolarEclipsePathPoint,
) [][]SolarEclipsePathPoint {
if len(segment) == 0 {
return segments
}
return append(segments, segment)
}
func mergeSolarEclipsePartialWrapSegment(
segments [][]SolarEclipsePathPoint,
samples []solarEclipsePartialBoundarySample,
) [][]SolarEclipsePathPoint {
if len(segments) < 2 || len(samples) == 0 || !samples[0].ok || !samples[len(samples)-1].ok {
return segments
}
first := segments[0]
last := segments[len(segments)-1]
if solarEclipsePathCrossesAntimeridian(last[len(last)-1], first[0]) {
return segments
}
merged := make([]SolarEclipsePathPoint, 0, len(last)+len(first))
merged = append(merged, last...)
merged = append(merged, first...)
result := make([][]SolarEclipsePathPoint, 0, len(segments)-1)
result = append(result, merged)
result = append(result, segments[1:len(segments)-1]...)
return result
}
func solarEclipsePathCrossesAntimeridian(a, b SolarEclipsePathPoint) bool {
return math.Abs(a.Longitude-b.Longitude) > 180
}
func solarEclipsePathDistanceKM(a, b SolarEclipsePathPoint) float64 {
lat1 := a.Latitude * rad
lat2 := b.Latitude * rad
dlat := lat2 - lat1
dlon := solarEclipseNormalizeSignedRadians((b.Longitude - a.Longitude) * rad)
h := math.Sin(dlat/2)*math.Sin(dlat/2) +
math.Cos(lat1)*math.Cos(lat2)*math.Sin(dlon/2)*math.Sin(dlon/2)
if h > 1 {
h = 1
}
return 2 * solarEclipseEarthEquatorialRadiusKM * math.Asin(math.Sqrt(h))
}