Files
astro/basic/solar_eclipse_path.go
T
b612 2bf8478639 feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
2026-09-17 12:27:40 +08:00

1477 lines
67 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"
"sort"
"time"
)
const (
solarEclipsePathDefaultStepDays = 1.0 / 1440.0
solarEclipsePathMinStepDays = 1.0 / 86400.0
solarEclipsePathMaxSampleCount = 30000
solarEclipsePathMaxAdaptiveDepth = 20
solarEclipsePathVelocityStepDays = 1.0 / 1440.0
// A sample may sit exactly on the horizon; only clearly below it is the
// central phase unobservable and its duration meaningless.
solarEclipsePathDurationHorizonToleranceDegrees = 0.1
// 地球表面上的影锥足迹不可能比地球本身更宽。
// No shadow footprint on the Earth can be wider than the Earth itself.
solarEclipsePathMaxPossibleWidthKM = 2 * 6371.0088
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
// geometry without forcing every path to the finest possible spacing.
solarEclipsePathAdaptiveCurvatureFraction = 0.02
solarEclipsePathMinimumCurvatureKM = 2.0
solarEclipsePartialFootprintDefaultStepDays = 5.0 / 1440.0
solarEclipseRiseSetDefaultStepDays = 2.0 / 1440.0
solarEclipsePartialFootprintDefaultBoundaryPoints = 180
solarEclipsePartialFootprintMinBoundaryPoints = 12
solarEclipsePartialFootprintMaxBoundaryPoints = 1440
solarEclipsePartialFootprintPointTolerance = 1e-12
solarEclipsePartialFootprintIterationLimit = 10
solarEclipsePartialFootprintMaxPointCount = 2000000
solarEclipsePartialFootprintTargetSpacingKM = 200.0
solarEclipseMagnitudeContourMaxValues = 16
solarEclipsePartialFootprintTransitionIterations = 48
// An open footprint ends where the axis-parallel line through the shadow
// radius stops meeting the ellipsoid. The bracket is searched around the
// converged sampled radius, so a few doublings cover both the outside cut of
// an on-disc shadow and the inside cut of a shadow whose axis misses the
// Earth; the bisection below then lands on the tangency point, which sits on
// the horizon and makes the closing arc exact.
solarEclipseHorizonEndSearchStep = 1e-5
solarEclipseHorizonEndSearchLimit = 0.25
solarEclipseHorizonEndBisectionRounds = 40
// The band footprints only have to resolve the swept envelope, not the
// instantaneous rim: consecutive samples may advance far as long as their
// union still follows the boundary. A grazing event spends most of its
// contact interval creeping across the terminator, so a 10 s stride there
// costs hundreds of solves for a few kilometres of travel.
solarEclipseCentralBandStepDays = 10.0 / 86400.0
solarEclipseNonCentralBandFallbackStepDays = 60.0 / 86400.0
solarEclipseNonCentralBandPreciseMaxDurationDays = 20.0 / 1440.0
solarEclipseCentralBandContactFineStepDays = 1.0 / 86400.0
solarEclipseCentralBandContactFineWindowDays = 45.0 / 86400.0
solarEclipseCentralBandBoundaryPoints = 90
// The union decimates every footprint ring to the union spacing before it
// merges them, and the result is relaxed afterwards, so solving the band
// footprints finer than this only buys trigonometry.
solarEclipseCentralBandTargetSpacingKM = 10.0
solarEclipseNonCentralTotalBandTargetSpacingKM = 25.0
solarEclipseCentralLimitTargetSpacingKM = 200.0
solarEclipseShadowFootprintAdaptiveMaxDepth = 12
solarEclipseShadowContactSearchStepDays = 10.0 / 1440.0
solarEclipseShadowContactSearchSpanDays = 0.75
solarEclipseShadowContactToleranceDays = 1e-9
solarEclipseMagnitudeContourBoundaryPoints = 180
solarEclipseMagnitudeContourTargetSpacingKM = 500.0
solarEclipseMagnitudeContourArcStepDegrees = 4.0
solarEclipseMagnitudeContourMinArcStepDegrees = 0.01
solarEclipseMagnitudeContourMaxArcSteps = 2000
solarEclipseMagnitudeContourTimeScale = 360.0
solarEclipseMagnitudeContourFallbackBearings = 72
solarEclipseMagnitudeContourFallbackDistances = 12
solarEclipseRiseSetBoundaryPoints = 180
solarEclipseRiseSetCriticalStepDays = 1.0 / 1440.0
solarEclipseRiseSetDerivativeStepDays = 5.0 / 86400.0
solarEclipseRiseSetTargetSpacingKM = 500.0
solarEclipseRiseSetAttachmentDistanceToleranceKM = 5.0
solarEclipseRiseSetAttachmentTimeToleranceDays = 2.0 / 86400.0
solarEclipseRiseSetPhaseConnectionLimitKM = 3000.0
solarEclipseRiseSetTimeEpsilonDays = 1e-8
)
type solarEclipseShadowKind uint8
const (
solarEclipsePenumbralShadow solarEclipseShadowKind = iota
solarEclipseCentralShadow
)
// 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
// DeltaTSeconds 显式 ΔT(秒),<=0 用进程级模型;与单时刻阴影层同口径,避免同一张图上
// 路径几何与瞬时足迹各自使用不同的 ΔT(两者会沿经度错开 0.4651·|ΔΔT|·cosφ 千米)。
// DeltaTSeconds is an explicit ΔT in seconds; values <= 0 use the process-wide
// model. The path and the instantaneous footprints of one figure must share it.
DeltaTSeconds float64
// SkipCentralBand 表示调用方已经持有同一场日食的完整足迹结果(其中包含
// CentralBandSegments),本次只求解中心线、南北限界与地平线端点,不重复重建中心食带。
// 单场日食的中心带足迹是整条链路里最贵的一段,同时取足迹与路径时重复计算会翻倍。
// SkipCentralBand reports that the caller already holds the full-footprint
// result for the same eclipse, whose CentralBandSegments are authoritative,
// so this call only solves the center line, the limits and the contact
// points instead of rebuilding the central band. The band footprints are the
// most expensive stage of the pipeline and would otherwise be computed twice
// by callers that ask for both products.
SkipCentralBand bool
}
// 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 中心食带宽度,单位千米;仅中心线点有意义;退化地平线切点无法形成稳定成对横截面时为 0。
// WidthKM is the central path width in kilometers; it is meaningful for centerline points and is 0 at a degenerate horizon contact without a stable paired cross-section.
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
// MaxCentralDurationDays 是中心线上最长的中心食时长(单位为日),并给出其发生位置。
// 与目录(NASA)口径不同:目录值取食甚点,这里是整条中心线上的最大值。
// MaxCentralDurationDays is the longest central phase on the center line, in
// days, with the location where it occurs. The catalogued "central duration"
// is the value at greatest eclipse; this is the maximum along the track.
MaxCentralDurationDays float64
MaxCentralDurationLongitude float64
MaxCentralDurationLatitude float64
// 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
// CentralBandSegments is the authoritative continuous central-band envelope.
CentralBandSegments [][]SolarEclipsePathPoint
// CentralBandSampled 表示上面的包络是用采样瞬时足迹重建的(解析包络不适用)。
// CentralBandSampled reports that the envelope above was reconstructed from
// the sampled instantaneous footprints because no analytic envelope
// described this event.
CentralBandSampled bool
// 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
// RiseSetStepDays 独立采样地平阶段曲线;<=0 时使用 2 分钟。
// RiseSetStepDays samples horizon curves independently; values <=0 use two minutes.
RiseSetStepDays float64
// DisableRiseSetCurves 禁用六类日升日落阶段边界。
// DisableRiseSetCurves disables the six sunrise/sunset boundaries.
DisableRiseSetCurves bool
// MagnitudeValues 是要计算的地方最大食分等值线;空值不计算,线条使用独立的自适应空间采样。
// MagnitudeValues requests local maximum-magnitude contours; empty disables them, and contours use independent adaptive spatial sampling.
MagnitudeValues []float64
// GreatestTimeValues 是要计算的地方食甚时刻等值线取值(TT 儒略日,最多 64 条,超出按时间截断);
// 只有确实存在该时刻食甚轨迹的取值才会出现在结果里,所以返回条数可能少于请求条数。
// 空值时改用 GreatestTimeStep。每条等时线用固定时刻的残差零集延拓,成本正比于曲线长度而不是可见域面积。
// GreatestTimeValues requests local greatest-eclipse time isolines as TT Julian ephemeris days
// (at most 64, truncated in time order); when empty, GreatestTimeStep is used instead. Each
// isochrone is continued along the zero set of a fixed-instant residual, so the cost scales
// with curve length rather than with the visible area.
GreatestTimeValues []float64
// GreatestTimeStep 是等时线间隔;仅在 GreatestTimeValues 为空时生效,非正值不计算等时线。
// 取值对齐到 UTC 整刻度并覆盖地球范围的偏食窗口,最多 64 条;显示层若需要按展示时区对齐,
// 应自行生成时刻后改用 GreatestTimeValues。
// GreatestTimeStep is the isochrone interval; it applies only when GreatestTimeValues is empty,
// and non-positive values disable the isolines. Levels align to UTC ticks across the global
// partial-eclipse window, at most 64; display layers that need the viewing timezone grid should
// generate the instants themselves and pass GreatestTimeValues instead.
GreatestTimeStep time.Duration
// DeltaTSeconds 显式 ΔT(秒),<=0 用进程级模型;与单时刻阴影层同口径。
// DeltaTSeconds is an explicit ΔT in seconds; values <= 0 use the process-wide model.
DeltaTSeconds float64
}
// 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
// HorizonEnds 是未闭合边界两端延伸到地平圈的擦地点,顺序与 Boundaries 的走向一致
// (HorizonEnds[0] 贴近边界起点,HorizonEnds[1] 贴近边界终点);边界自身闭合时为空。
// 被地平线切断的瞬时阴影区域由「物理边界 + 两个擦地点之间的地平弧」闭合;擦地点是
// 阴影锥面与地表的切点,其太阳高度为 0,所以该闭合弧是精确结果而不是启发式。
// HorizonEnds are the two limb-grazing points where an open boundary reaches the
// horizon, ordered like Boundaries (HorizonEnds[0] near the boundary start,
// HorizonEnds[1] near its end); empty when the boundary closes on itself. A region
// cut by the horizon is closed by the physical boundary plus the horizon arc
// between these grazing points, which are exact cone-surface tangency points with
// zero solar altitude rather than a heuristic.
HorizonEnds []SolarEclipsePathPoint
}
// 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
// CentralBandFootprints 是始终计算的低成本本影/反本影端部样本,用于闭合中心食带。
// CentralBandFootprints are always-computed lightweight umbral/antumbral end samples used to close the central band.
CentralBandFootprints []SolarEclipsePartialFootprint
// CentralBandSegments 是地方中心食条件的连续闭合包络。
// CentralBandSegments are continuous closed envelopes of the local central-eclipse condition.
CentralBandSegments [][]SolarEclipsePathPoint
// CentralBandSampled 表示上面的包络是用采样瞬时足迹重建的(解析包络不适用)。
// CentralBandSampled reports that CentralBandSegments were reconstructed from
// the sampled instantaneous footprints because no analytic envelope applied.
CentralBandSampled bool
// CentralBandHorizonClosures 是两限界掠地事件中分别连接首尾两侧限界的食甚地平线弧。
// CentralBandHorizonClosures are the greatest-at-horizon arcs joining both ends of a grazing two-limit event.
CentralBandHorizonClosures [][]SolarEclipsePathPoint
// PartialBandContours 是地方最大食分等于零的连续可见包络,用于与日出日落边界闭合偏食可见域。
// PartialBandContours are the continuous zero local-maximum-magnitude envelopes used with horizon boundaries to close the partial-eclipse visibility region.
PartialBandContours [][]SolarEclipsePathPoint
// MagnitudeContours 是按食分值采样的两侧等值线。
// MagnitudeContours are sampled two-sided local maximum-magnitude contours.
MagnitudeContours []SolarEclipseMagnitudeContour
// GreatestTimeContours 是按食甚时刻采样的等时线。
// GreatestTimeContours are sampled local greatest-eclipse time isolines.
GreatestTimeContours []SolarEclipseGreatestTimeContour
// RiseSetCurves 是初亏、食甚和复圆分别发生在日出或日落时的六类边界。
// RiseSetCurves are the six boundaries where local start, greatest, or end occurs at sunrise or sunset.
RiseSetCurves []SolarEclipseRiseSetCurve
// RiseSetTopologyDegraded 表示请求了升落曲线但六类边界未通过拓扑校验(每曲线 16 段、
// 总计 32 段的段数上限,端点共享与时间单调),RiseSetCurves 是被截断的降级结果。
// RiseSetTopologyDegraded reports that the requested rise/set curves failed the
// 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
// 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
// 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
// CentralBandStepDays 是中心食带足迹的最细实际采样步长。
// CentralBandStepDays is the finest effective sampling step for central-band footprints.
CentralBandStepDays float64
}
// SolarEclipseGreatestTimeContour 是一个固定地方食甚时刻的等值线支路集合。
// SolarEclipseGreatestTimeContour contains the continuous branches of one fixed local greatest-eclipse time.
type SolarEclipseGreatestTimeContour struct {
// JDE 是该等值线表示的力学时儒略日,也就是各支路上地方食甚发生的时刻。
// JDE is the TT Julian ephemeris day represented by this contour, the local greatest-eclipse instant along every branch.
JDE float64
// Segments 是该时刻的连续等时线支路;一条支路两端止于地平线或偏食可见域边界。
// Segments are continuous isochrone branches; each branch ends at the horizon or the partial-visibility boundary.
Segments [][]SolarEclipsePathPoint
}
// SolarEclipseMagnitudeContour 是一条地方最大食分等值线的连续支路集合。
// SolarEclipseMagnitudeContour contains the continuous branches of one local maximum-magnitude contour.
type SolarEclipseMagnitudeContour struct {
// Magnitude 是该等值线表示的地方最大食分。
// Magnitude is the local maximum eclipse magnitude represented by this contour.
Magnitude float64
// Segments 是地方最大食分等值线的连续支路;临近地平线时局部食甚时刻可以沿空间支路折返。
// Segments are continuous local maximum-magnitude branches; local greatest times may fold along a spatial branch near the horizon.
Segments [][]SolarEclipsePathPoint
// NorthernLimit 和 SouthernLimit 保留两侧中心食等值线的兼容视图。
// NorthernLimit and SouthernLimit retain the compatibility view for two-sided central-eclipse contours.
NorthernLimit []SolarEclipsePathPoint
SouthernLimit []SolarEclipsePathPoint
}
// SolarEclipseRiseSetCurve 是一种局部阶段与日出/日落同时发生的边界。
// SolarEclipseRiseSetCurve is one boundary where a local phase coincides with sunrise or sunset.
type SolarEclipseRiseSetCurve struct {
// Phase 是与日出或日落同时发生的局部日食阶段。
// Phase is the local eclipse phase coinciding with sunrise or sunset.
Phase RiseSetPhase
// Direction 标识太阳正在升起还是落下。
// Direction identifies whether the Sun is rising or setting.
Direction RiseSetDirection
// Segments 是反经线和支路跳变安全分段后的边界采样。
// Segments are boundary samples split safely at the antimeridian and branch changes.
Segments [][]SolarEclipsePathPoint
}
// 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)
newMoonJDE := CalcMoonSHByJDE(seedJDE, 0)
solver := newSolarEclipseSolver(newMoonJDE, model).withDeltaTSeconds(options.DeltaTSeconds)
result := solver.eclipseResult()
path := SolarEclipsePathResult{
Eclipse: result,
StepDays: options.StepDays,
TargetSpacingKM: options.TargetSpacingKM,
}
if !result.HasCentral {
return path
}
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
samplingOptions := options
samplingOptions.TargetSpacingKM = 0
centerLine, stepDays := solver.centralPathPoints(
result.CentralBeginOnEarth,
result.CentralEndOnEarth,
result.GreatestEclipse,
samplingOptions,
)
centerLine = solver.attachCentralAxisContactPoints(
centerLine, result.CentralBeginOnEarth, result.CentralEndOnEarth,
)
if result.Type == SolarEclipseHybrid {
centerLine = solver.attachCentralMagnitudeOneTransitions(centerLine)
}
if options.TargetSpacingKM > 0 {
centerLine = solver.refineCentralPathSpacing(centerLine, options.TargetSpacingKM)
}
path.StepDays = stepDays
// 每对横截面只解一次:限界线与宽度都取自同一份配对结果,重复求解是这条链上最大的
// 单点分配与耗时来源。
northernLimit, southernLimit, pairedLimits := solver.centralPathLimitPairs(centerLine)
path.NorthernLimit, path.SouthernLimit = solarEclipseFilterCentralPathLimits(
northernLimit, southernLimit, pairedLimits,
)
// The paired-limit width runs first, then the contact pass restores the exact
// U1/U4 samples, whose cross-section is solved from the contact pair rather
// than from the interior samples.
solver.setCenterLinePairWidths(
centerLine, result.Centrality, northernLimit, southernLimit, pairedLimits,
)
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
path.MaxCentralDurationDays, path.MaxCentralDurationLongitude, path.MaxCentralDurationLatitude =
solver.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
// axis reaches the surface. Add those exact contact points so consumers do
// not close the band across the first/last coarse center-line samples.
if result.Type != SolarEclipseHybrid &&
result.Centrality == SolarEclipseCentralTwoLimits && len(path.NorthernLimit) > 0 {
first, last, contactsOK := solver.shadowContactPair(
result.GreatestEclipse, solarEclipseCentralShadow, false,
)
if contactsOK {
// The open footprint endpoints before the axis reaches the Earth are
// horizon branches, not the two side limits. Appending them here
// makes one side turn back and self-intersect near U1/U4. The exact
// contact points and the first/last cross-sections form the required
// short end caps without introducing that false branch.
path.NorthernLimit = append([]SolarEclipsePathPoint{first}, path.NorthernLimit...)
path.SouthernLimit = append([]SolarEclipsePathPoint{first}, path.SouthernLimit...)
path.NorthernLimit = append(path.NorthernLimit, last)
path.SouthernLimit = append(path.SouthernLimit, last)
}
}
path.NorthernLimit, path.SouthernLimit = solver.refineCentralPathLimitSpacing(
path.NorthernLimit,
path.SouthernLimit,
result.CentralBeginOnEarth,
result.CentralEndOnEarth,
solarEclipseCentralLimitTargetSpacingKM,
)
if options.SkipCentralBand {
return path
}
// Generate the same continuous envelope exposed by the full-footprint API
// so path-only callers cannot reconstruct a different polar ribbon.
band, _ := solver.centralBandWithResult(result, normalizeSolarEclipsePartialFootprintOptions(SolarEclipsePartialFootprintOptions{
StepDays: options.StepDays, BoundaryPoints: 96, DisableRiseSetCurves: true,
}))
path.CentralBandSegments = band.CentralBandSegments
path.CentralBandSampled = band.CentralBandSampled
return path
}
func (solver solarEclipseSolver) attachCentralMagnitudeOneTransitions(
points []SolarEclipsePathPoint,
) []SolarEclipsePathPoint {
for _, transition := range solver.centralMagnitudeOneTransitions(points) {
duplicate := false
for _, point := range points {
if math.Abs(point.JDE-transition.JDE) <= solarEclipsePathDuplicateTimeDays {
duplicate = true
break
}
}
if !duplicate {
points = append(points, transition)
}
}
sort.Slice(points, func(first, second int) bool { return points[first].JDE < points[second].JDE })
return normalizeSolarEclipsePathPointSeries(points)
}
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 {
return solarEclipsePartialFootprintsWithResult(
seedJDE, model, options, solarEclipseWithDeltaT(seedJDE, model, options.DeltaTSeconds),
)
}
func solarEclipsePartialFootprintsWithResult(
seedJDE float64,
model SolarEclipseRadiusModel,
options SolarEclipsePartialFootprintOptions,
result SolarEclipseResult,
) SolarEclipsePartialFootprintsResult {
options = normalizeSolarEclipsePartialFootprintOptions(options)
footprintsResult := SolarEclipsePartialFootprintsResult{
Eclipse: result,
StepDays: options.StepDays,
BoundaryPoints: options.BoundaryPoints,
CentralShadowStepDays: options.CentralShadowStepDays,
}
if !result.HasPartial {
return footprintsResult
}
newMoonJDE := CalcMoonSHByJDE(seedJDE, 0)
solver := newSolarEclipseSolver(newMoonJDE, model).withDeltaTSeconds(options.DeltaTSeconds)
footprintsResult, totalMagnitudeOneSegments := solver.centralBandWithResult(result, options)
// 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,
result.GreatestEclipse,
options.StepDays,
)
var shadowTimes []float64
if options.CentralShadowStepDays > 0 &&
footprintsResult.U1.JDE != 0 && footprintsResult.U4.JDE != 0 {
shadowTimes, _ = solarEclipseMovingDiskEngine().sampleTimes(
footprintsResult.U1.JDE,
footprintsResult.U4.JDE,
result.GreatestEclipse,
options.CentralShadowStepDays,
)
}
options.BoundaryPoints = solarEclipseSharedBoundaryPoints(
options.BoundaryPoints,
len(partialTimes),
len(shadowTimes),
solarEclipseFootprintPointCount(footprintsResult.CentralBandFootprints),
)
footprintsResult.BoundaryPoints = options.BoundaryPoints
if !options.DisableRiseSetCurves {
footprintsResult.PartialBandContours = solver.magnitudeContourSegments(
result.PartialBeginOnEarth,
result.PartialEndOnEarth,
result.CentralBeginOnEarth,
result.CentralEndOnEarth,
result.GreatestEclipse,
0,
options.StepDays,
false,
)
}
footprints, stepDays, effectiveBoundaryPoints := solver.partialFootprints(
result.PartialBeginOnEarth,
result.PartialEndOnEarth,
result.GreatestEclipse,
options,
)
footprintsResult.StepDays = stepDays
footprintsResult.Footprints = footprints
if effectiveBoundaryPoints > footprintsResult.BoundaryPoints {
footprintsResult.BoundaryPoints = effectiveBoundaryPoints
}
if options.CentralShadowStepDays > 0 &&
footprintsResult.U1.JDE != 0 && footprintsResult.U4.JDE != 0 {
var effectiveShadowBoundaryPoints int
footprintsResult.CentralShadowFootprints, footprintsResult.CentralShadowStepDays, effectiveShadowBoundaryPoints = solver.shadowFootprints(
footprintsResult.U1.JDE,
footprintsResult.U4.JDE,
result.GreatestEclipse,
options.CentralShadowStepDays,
options.BoundaryPoints,
solarEclipseCentralShadow,
)
if effectiveShadowBoundaryPoints > footprintsResult.BoundaryPoints {
footprintsResult.BoundaryPoints = effectiveShadowBoundaryPoints
}
}
footprintsResult.MagnitudeContours = solver.magnitudeContours(
result.PartialBeginOnEarth,
result.PartialEndOnEarth,
result.CentralBeginOnEarth,
result.CentralEndOnEarth,
result.GreatestEclipse,
options,
result.Magnitude,
result.Type == SolarEclipseHybrid,
totalMagnitudeOneSegments,
)
greatestTimeOptions := options
if len(greatestTimeOptions.GreatestTimeValues) == 0 && greatestTimeOptions.GreatestTimeStep > 0 {
levels := greatestTimeContourAlignedLevels(
result.PartialBeginOnEarth, result.PartialEndOnEarth,
greatestTimeOptions.GreatestTimeStep, greatestTimeContourMaxLevels,
)
greatestTimeOptions.GreatestTimeValues = make([]float64, 0, len(levels))
for _, level := range levels {
greatestTimeOptions.GreatestTimeValues = append(greatestTimeOptions.GreatestTimeValues, level.tt)
}
}
footprintsResult.GreatestTimeContours = solver.greatestTimeContours(
result.PartialBeginOnEarth,
result.PartialEndOnEarth,
greatestTimeOptions,
)
return footprintsResult
}
func (solver solarEclipseSolver) centralBandWithResult(
result SolarEclipseResult,
options SolarEclipsePartialFootprintOptions,
) (SolarEclipsePartialFootprintsResult, [][]SolarEclipsePathPoint) {
footprintsResult := SolarEclipsePartialFootprintsResult{
Eclipse: result,
StepDays: options.StepDays,
BoundaryPoints: options.BoundaryPoints,
CentralShadowStepDays: options.CentralShadowStepDays,
}
if !result.HasPartial {
return footprintsResult, nil
}
var totalMagnitudeOneSegments [][]SolarEclipsePathPoint
var nonCentralRiseSetCurves []SolarEclipseRiseSetCurve
nonCentralRiseSetComplete := true
if solver.attachCentralBandContacts(&footprintsResult, result) {
solver.attachCentralBandFootprints(&footprintsResult, result)
if result.Centrality == SolarEclipseNonCentral {
totalMagnitudeOneSegments, nonCentralRiseSetCurves, nonCentralRiseSetComplete =
solver.nonCentralCentralBandEnvelope(result, options, &footprintsResult)
}
}
if result.Type == SolarEclipseTotal && result.Centrality == SolarEclipseCentralTwoLimits {
totalMagnitudeOneSegments = solver.magnitudeContourSegmentsWithSpacing(
result.PartialBeginOnEarth, result.PartialEndOnEarth,
result.CentralBeginOnEarth, result.CentralEndOnEarth,
result.GreatestEclipse, 1, options.StepDays, false,
solarEclipseTotalEnvelopeTargetSpacingKM,
)
}
// The rise/set curves are a presentation option, but the precise central
// envelope still needs their roots to close grazing annular/total paths.
// Compute the small internal set unconditionally and only suppress it from
// the returned result when requested.
riseSetCurves, riseSetComplete := solver.centralBandRiseSetCurves(
result, options, nonCentralRiseSetCurves, nonCentralRiseSetComplete,
)
if !options.DisableRiseSetCurves {
footprintsResult.RiseSetCurves = riseSetCurves
// 请求了升落曲线但拓扑校验未通过:返回的是被截断的降级结果,必须显式标记。
footprintsResult.RiseSetTopologyDegraded = len(riseSetCurves) > 0 && !riseSetComplete
}
solver.attachCentralBandClosureEnvelope(
result, &footprintsResult, riseSetCurves, totalMagnitudeOneSegments,
)
solver.attachCentralBandFallbackSweep(result, &footprintsResult, riseSetCurves)
return footprintsResult, totalMagnitudeOneSegments
}
// attachCentralBandContacts 求半影与本影/反本影的外切/内切接触点,并报告本影接触对能否
// 支撑中心带采样:本影不存在或退化的抛物接触会让整个中心带支路没有意义。
func (solver solarEclipseSolver) attachCentralBandContacts(
footprintsResult *SolarEclipsePartialFootprintsResult,
result SolarEclipseResult,
) bool {
contactSolver := solver
contactSolver.exactCentralContact = result.Centrality == SolarEclipseNonCentral
footprintsResult.P1, footprintsResult.P4, _ = contactSolver.shadowContactPair(
result.GreatestEclipse, solarEclipsePenumbralShadow, false,
)
footprintsResult.P2, footprintsResult.P3, _ = contactSolver.shadowContactPair(
result.GreatestEclipse, solarEclipsePenumbralShadow, true,
)
if result.Type != SolarEclipsePartial {
footprintsResult.U1, footprintsResult.U4, _ = contactSolver.shadowContactPair(
result.GreatestEclipse, solarEclipseCentralShadow, false,
)
footprintsResult.U2, footprintsResult.U3, _ = contactSolver.shadowContactPair(
result.GreatestEclipse, solarEclipseCentralShadow, true,
)
return footprintsResult.U1.JDE != 0 && footprintsResult.U4.JDE != 0
}
return false
}
// attachCentralBandFootprints 采样本影/反本影端帽足迹,非中心食的时长决定用哪一档步长。
func (solver solarEclipseSolver) attachCentralBandFootprints(
footprintsResult *SolarEclipsePartialFootprintsResult,
result SolarEclipseResult,
) {
bandStepDays := solarEclipseCentralBandStepDays
if result.Centrality == SolarEclipseNonCentral {
centralDuration := footprintsResult.U4.JDE - footprintsResult.U1.JDE
if centralDuration > solarEclipseNonCentralBandPreciseMaxDurationDays {
bandStepDays = solarEclipseNonCentralBandFallbackStepDays
}
}
footprintsResult.CentralBandFootprints, footprintsResult.CentralBandStepDays = solver.centralBandFootprintsWithStep(
footprintsResult.U1.JDE,
footprintsResult.U2.JDE,
footprintsResult.U3.JDE,
footprintsResult.U4.JDE,
result.GreatestEclipse,
bandStepDays,
)
}
// nonCentralCentralBandEnvelope 求解轴不入地的中心食带:先试解析包络,覆盖不足时退回采样
// 瞬时足迹并集;同时返回该支路使用的升落曲线与 magnitude-one 等值线。
func (solver solarEclipseSolver) nonCentralCentralBandEnvelope(
result SolarEclipseResult,
options SolarEclipsePartialFootprintOptions,
footprintsResult *SolarEclipsePartialFootprintsResult,
) ([][]SolarEclipsePathPoint, []SolarEclipseRiseSetCurve, bool) {
var totalMagnitudeOneSegments [][]SolarEclipsePathPoint
var nonCentralRiseSetCurves []SolarEclipseRiseSetCurve
nonCentralRiseSetComplete := true
if result.Centrality == SolarEclipseNonCentral {
riseSetStepDays := options.RiseSetStepDays
if riseSetStepDays <= 0 || math.IsNaN(riseSetStepDays) || math.IsInf(riseSetStepDays, 0) {
riseSetStepDays = solarEclipseRiseSetDefaultStepDays
}
nonCentralRiseSetCurves, nonCentralRiseSetComplete = solver.riseSetCurvesWithStatus(
result.PartialBeginOnEarth,
result.PartialEndOnEarth,
result.GreatestEclipse,
riseSetStepDays,
)
var bandHorizon []SolarEclipsePathPoint
if result.Type == SolarEclipseTotal {
totalMagnitudeOneSegments = solver.magnitudeContourSegmentsWithSpacing(
result.PartialBeginOnEarth,
result.PartialEndOnEarth,
result.CentralBeginOnEarth,
result.CentralEndOnEarth,
result.GreatestEclipse,
1,
options.StepDays,
false,
solarEclipseNonCentralTotalBandTargetSpacingKM,
)
footprintsResult.CentralBandSegments, bandHorizon = solver.nonCentralTotalBandPolygons(
totalMagnitudeOneSegments,
footprintsResult.U1,
footprintsResult.U4,
result.GreatestEclipse,
nonCentralRiseSetCurves,
)
}
if !solarEclipseNonCentralBandContainsFootprints(
footprintsResult.CentralBandSegments, footprintsResult.CentralBandFootprints,
) {
footprintsResult.CentralBandSegments = nil
bandHorizon = nil
}
centralDuration := footprintsResult.U4.JDE - footprintsResult.U1.JDE
if len(footprintsResult.CentralBandSegments) == 0 &&
centralDuration <= solarEclipseNonCentralBandPreciseMaxDurationDays {
sweep, sweepHorizon, sampled := solver.centralBandSweepPolygons(
footprintsResult.U1.JDE,
footprintsResult.U4.JDE,
result.GreatestEclipse,
nonCentralRiseSetCurves,
)
if centralBandSweepCoversFootprints(
sweep, footprintsResult.CentralBandFootprints, sampled,
) {
footprintsResult.CentralBandSegments, bandHorizon = sweep, sweepHorizon
}
}
if !centralBandSweepCoversFootprints(
footprintsResult.CentralBandSegments, footprintsResult.CentralBandFootprints, false,
) {
footprintsResult.CentralBandSegments = nil
bandHorizon = nil
}
alignNonCentralBandHorizon(nonCentralRiseSetCurves, bandHorizon)
}
return totalMagnitudeOneSegments, nonCentralRiseSetCurves, nonCentralRiseSetComplete
}
// centralBandRiseSetCurves 求解中心带闭合需要的升落曲线;DisableRiseSetCurves 只影响是否
// 对外返回,两限中心食与非中心全食的端帽闭合始终需要它们。
func (solver solarEclipseSolver) centralBandRiseSetCurves(
result SolarEclipseResult,
options SolarEclipsePartialFootprintOptions,
nonCentralRiseSetCurves []SolarEclipseRiseSetCurve,
nonCentralRiseSetComplete bool,
) ([]SolarEclipseRiseSetCurve, bool) {
needRiseSetInternals := !options.DisableRiseSetCurves
if result.Centrality == SolarEclipseCentralTwoLimits && result.Type != SolarEclipsePartial {
needRiseSetInternals = true
}
if result.Centrality == SolarEclipseNonCentral && result.Type == SolarEclipseTotal {
needRiseSetInternals = true
}
if !needRiseSetInternals {
return nil, true
}
var riseSetCurves []SolarEclipseRiseSetCurve
riseSetComplete := true
if len(nonCentralRiseSetCurves) > 0 {
riseSetCurves, riseSetComplete = nonCentralRiseSetCurves, nonCentralRiseSetComplete
} else {
riseSetStepDays := options.RiseSetStepDays
if riseSetStepDays <= 0 || math.IsNaN(riseSetStepDays) || math.IsInf(riseSetStepDays, 0) {
riseSetStepDays = solarEclipseRiseSetDefaultStepDays
}
riseSetCurves, riseSetComplete = solver.riseSetCurvesWithStatus(
result.PartialBeginOnEarth,
result.PartialEndOnEarth,
result.GreatestEclipse,
riseSetStepDays,
)
}
if result.HasCentral {
solver.attachCentralAxisContactsToRiseSetCurves(
riseSetCurves,
result.CentralBeginOnEarth,
result.CentralEndOnEarth,
)
}
return riseSetCurves, riseSetComplete
}
// attachCentralBandClosureEnvelope 用两端的地平闭合弧构造解析中心带;没有得到完整的两条
// 闭合弧时不改动结果,交给采样兜底。
func (solver solarEclipseSolver) attachCentralBandClosureEnvelope(
result SolarEclipseResult,
footprintsResult *SolarEclipsePartialFootprintsResult,
riseSetCurves []SolarEclipseRiseSetCurve,
totalMagnitudeOneSegments [][]SolarEclipsePathPoint,
) {
var totalEnvelopeSegments [][]SolarEclipsePathPoint
if (result.Type == SolarEclipseAnnular || result.Type == SolarEclipseTotal || result.Type == SolarEclipseHybrid) &&
result.Centrality == SolarEclipseCentralTwoLimits &&
(len(riseSetCurves) > 0 || result.Type == SolarEclipseTotal) {
closures := make([][]SolarEclipsePathPoint, 0, 2)
for _, side := range solarEclipseCentralBandClosureSides(*footprintsResult, result) {
closure, ok := solver.centralBandHorizonClosureForSide(
result, *footprintsResult, riseSetCurves, totalMagnitudeOneSegments, side,
)
if !ok {
return
}
closures = append(closures, closure)
}
if len(closures) == 2 {
footprintsResult.CentralBandHorizonClosures = closures
solver.alignSolarEclipseCentralBandHorizonClosures(
riseSetCurves,
footprintsResult.CentralBandHorizonClosures,
)
if result.Type == SolarEclipseAnnular {
// The continuous critical envelope is the authoritative static
// central band. Instantaneous low-altitude footprint edges are
// open slices and must not veto this closed envelope: requiring
// every sampled slice to lie inside it recreates the comb-shaped
// union that this path replaces.
footprintsResult.CentralBandSegments = solver.centralTwoLimitBandEnvelope(
closures, result.GreatestEclipse,
)
if len(footprintsResult.CentralBandSegments) == 0 {
// At an extremely shallow polar annular event the analytic
// continuation can start exactly on the horizon and have no
// numerically visible first step. The sampled central sweep
// still carries the same contact and horizon topology.
solver.attachCentralBandSweep(
footprintsResult,
footprintsResult.U1.JDE, footprintsResult.U4.JDE,
result.GreatestEclipse, riseSetCurves,
)
}
} else if result.Type == SolarEclipseHybrid {
footprintsResult.CentralBandSegments = solver.hybridCentralBandEnvelope(closures, result)
} else {
// Share the signed physical branches with magnitude-one output;
// independently traced scalar contours may stop before the horizon.
// Keep the exact magnitude-one contours when they form a valid
// closed band. Only replace a scalar branch when its own sampled
// shadow slices prove that it terminates early or misses the band.
totalEnvelopeSegments = totalMagnitudeOneSegments
if len(solarEclipseTotalBandEnvelope(totalEnvelopeSegments, closures)) == 0 {
// Rebuild the closures from the same signed roots as the
// vector branches. Scalar magnitude contours may have valid
// samples but terminate on different horizon branches.
vectorClosures := make([][]SolarEclipsePathPoint, 0, 2)
for _, side := range []struct {
axis, first, last, direction float64
}{
{result.CentralBeginOnEarth, footprintsResult.U1.JDE, footprintsResult.U2.JDE, 1},
{result.CentralEndOnEarth, footprintsResult.U4.JDE, footprintsResult.U3.JDE, -1},
} {
first, last, rootsOK := solver.centralBandVectorHorizonRoots(side.axis, side.direction, side.first, side.last)
if !rootsOK {
vectorClosures = nil
break
}
_, key, classified := solver.magnitudeEvaluationAt(first.JDE).classify(first.Longitude, first.Latitude, true)
if !classified {
vectorClosures = nil
break
}
vectorClosures = append(vectorClosures, solver.centralBandHorizonClosure(first, last, key.direction, riseSetCurves))
}
if len(vectorClosures) == 2 {
closures = vectorClosures
footprintsResult.CentralBandHorizonClosures = closures
totalEnvelopeSegments = solver.centralBandVectorBranches(closures, result.GreatestEclipse)
}
}
footprintsResult.CentralBandSegments = solarEclipseTotalBandEnvelope(totalEnvelopeSegments, closures)
if len(footprintsResult.CentralBandSegments) == 0 && len(riseSetCurves) > 0 {
// Very shallow polar totalities may expose only one horizon
// endpoint on each magnitude-one branch. The analytic join then
// has no distinct start/end caps; use the sampled central sweep
// as a topology-preserving fallback.
solver.attachCentralBandSweep(
footprintsResult,
footprintsResult.U1.JDE, footprintsResult.U4.JDE,
result.GreatestEclipse, riseSetCurves,
)
}
}
}
}
}
// attachCentralBandSweep 用扫掠兜底填充分段:解析扫掠与采样并集都可能返回,采样标记
// 必须跟着返回值走,否则导出层会把采样并集当成精确解析包络处理。
func (solver solarEclipseSolver) attachCentralBandSweep(
footprintsResult *SolarEclipsePartialFootprintsResult,
startJDE, endJDE, greatestJDE float64,
riseSetCurves []SolarEclipseRiseSetCurve,
) {
segments, _, sampled := solver.centralBandSweepPolygons(startJDE, endJDE, greatestJDE, riseSetCurves)
footprintsResult.CentralBandSegments = segments
footprintsResult.CentralBandSampled = sampled
}
// attachCentralBandFallbackSweep 是解析包络完全缺席时的最后兜底:只要采样重建覆盖了瞬时足迹
// 就采用它,并如实标记这是采样包络。
func (solver solarEclipseSolver) attachCentralBandFallbackSweep(
result SolarEclipseResult,
footprintsResult *SolarEclipsePartialFootprintsResult,
riseSetCurves []SolarEclipseRiseSetCurve,
) {
centrality := footprintsResult.Eclipse.Centrality
if len(footprintsResult.CentralBandSegments) == 0 && len(riseSetCurves) > 0 &&
footprintsResult.U1.JDE != 0 && footprintsResult.U4.JDE != 0 &&
(centrality == SolarEclipseCentralTwoLimits ||
centrality == SolarEclipseCentralOneLimit && result.Type == SolarEclipseAnnular) {
sweep, _, sweepSampled, covers := solver.centralBandBestSweep(
footprintsResult.U1.JDE, footprintsResult.U4.JDE,
result.GreatestEclipse, riseSetCurves,
footprintsResult.CentralBandFootprints,
)
if covers {
footprintsResult.CentralBandSegments = sweep
footprintsResult.CentralBandSampled = sweepSampled
}
}
}
type solarEclipseCentralBandClosureSide struct {
axisContactJDE float64
shadowContactJDE float64
innerContactJDE float64
direction float64
}
func solarEclipseCentralBandClosureSides(
footprints SolarEclipsePartialFootprintsResult,
result SolarEclipseResult,
) []solarEclipseCentralBandClosureSide {
innerStartJDE := result.CentralBeginOnEarth
if footprints.U2.JDE != 0 {
innerStartJDE = footprints.U2.JDE
}
innerEndJDE := result.CentralEndOnEarth
if footprints.U3.JDE != 0 {
innerEndJDE = footprints.U3.JDE
}
return []solarEclipseCentralBandClosureSide{
{result.CentralBeginOnEarth, footprints.U1.JDE, innerStartJDE, 1},
{result.CentralEndOnEarth, footprints.U4.JDE, innerEndJDE, -1},
}
}
// centralBandHorizonClosureForSide 解出中心带一端的地平闭合弧;三种食型各有一条取根链,
// 取不到唯一一对根就放弃该端(整条解析包络随之放弃)。
func (solver solarEclipseSolver) centralBandHorizonClosureForSide(
result SolarEclipseResult,
footprintsResult SolarEclipsePartialFootprintsResult,
riseSetCurves []SolarEclipseRiseSetCurve,
totalMagnitudeOneSegments [][]SolarEclipsePathPoint,
side solarEclipseCentralBandClosureSide,
) ([]SolarEclipsePathPoint, bool) {
var firstRoot, lastRoot SolarEclipsePathPoint
var ok bool
seededDirection := RiseSetDirection("")
if result.Type == SolarEclipseTotal {
firstRoot, lastRoot, ok = magnitudeOneHorizonRoots(totalMagnitudeOneSegments, side.direction)
if !ok {
firstRoot, lastRoot, ok = solver.centralBandVectorHorizonRoots(
side.axisContactJDE, side.direction, side.shadowContactJDE, side.innerContactJDE,
)
}
} else if result.Type == SolarEclipseHybrid {
firstRoot, lastRoot, ok = solver.centralBandVectorHorizonRoots(
side.axisContactJDE, side.direction, side.shadowContactJDE, side.innerContactJDE,
)
} else {
// A grazing closure arc routinely splits its two endpoints
// between the seeding paths: one sits outside the sampled
// horizon branches, the other outside the axis-adjacent Newton
// basin. Collect what every solver can offer and accept the pair
// only when exactly two distinct roots come out.
candidates := make([]SolarEclipsePathPoint, 0, 4)
directions := make([]RiseSetDirection, 0, 4)
curveDirection := RiseSetDirection("")
unionDirection := RiseSetDirection("")
appendRoots := func(
first, last SolarEclipsePathPoint,
direction RiseSetDirection,
found bool,
) {
if !found {
return
}
for _, root := range []SolarEclipsePathPoint{first, last} {
if root.JDE == 0 || solarEclipseRiseSetPointExists(candidates, root) {
continue
}
candidates = append(candidates, root)
directions = append(directions, direction)
}
}
firstRoot, lastRoot, ok = solver.centralLimitHorizonRootsNearAxisContact(
side.axisContactJDE, side.shadowContactJDE, side.innerContactJDE, side.direction,
)
appendRoots(firstRoot, lastRoot, "", ok)
curveRoots, curveDirection := solver.centralLimitHorizonRootsFromCurves(
riseSetCurves, side.shadowContactJDE, side.innerContactJDE,
)
for _, root := range curveRoots {
appendRoots(root, SolarEclipsePathPoint{}, curveDirection, true)
}
if len(candidates) < 2 {
// The sampled sweep is the most expensive seeding, so it
// only runs while the pair is still incomplete. Some grazing
// events have no two-root closure at all, and then the
// sampled band is the right representation rather than a
// fallback: 4862-09-28 ends 40 km inside the horizon (the
// cap is bounded by the umbral rim), and 1552-07-21 has its
// boundary running along the horizon (measured +0.004 then
// -0.000 degrees), so its closure arc is degenerate.
unionRoots, unionDirection := solver.centralLimitHorizonRootsFromSampledBoundary(
side.shadowContactJDE, side.innerContactJDE, result.GreatestEclipse,
footprintsResult.CentralBandFootprints, riseSetCurves,
)
for _, root := range unionRoots {
appendRoots(root, SolarEclipsePathPoint{}, unionDirection, true)
}
}
if seededDirection == "" {
seededDirection = curveDirection
}
if seededDirection == "" {
seededDirection = unionDirection
}
ok = len(candidates) == 2
if ok {
sort.Slice(candidates, func(first, second int) bool {
return candidates[first].JDE < candidates[second].JDE
})
firstRoot, lastRoot = candidates[0], candidates[1]
for _, direction := range directions {
if direction != "" {
seededDirection = direction
break
}
}
}
}
if !ok {
return nil, false
}
// Near a pole, the first and last contacts can both be sunrise or
// both sunset. Classify the solved root instead of the temporal end.
_, key, classified := solver.magnitudeEvaluationAt(firstRoot.JDE).classify(
firstRoot.Longitude, firstRoot.Latitude, true,
)
if !classified && seededDirection == "" {
seededDirection = solarEclipseNearestGreatestDirection(riseSetCurves, firstRoot)
}
if !classified && seededDirection != "" {
// The local classification needs the eclipse to be exactly at
// its greatest there, which a shallow polar closure root does
// not always satisfy. The horizon curve that seeded the root
// already knows whether the Sun is rising or setting.
key = solarEclipseRiseSetCurveKey{
phase: RiseSetPhaseGreatest, direction: seededDirection,
}
}
if !classified {
return nil, false
}
return solver.centralBandHorizonClosure(
firstRoot, lastRoot, key.direction, riseSetCurves,
), true
}
// centralBandBestSweep returns the sweep candidate that contains the sampled
// umbra. The analytic sweep is smooth and stays the first choice whenever it
// really contains it; a grazing path whose envelope terminates early is rebuilt
// from the sampled footprints instead.
// The returned flag reports whether the candidate already passed the coverage
// audit, so the caller does not repeat the probe.
func (solver solarEclipseSolver) centralBandBestSweep(
startJDE, endJDE, greatestJDE float64,
riseSetCurves []SolarEclipseRiseSetCurve,
footprints []SolarEclipsePartialFootprint,
) ([][]SolarEclipsePathPoint, []SolarEclipsePathPoint, bool, bool) {
candidate, horizon, sampled := solver.centralBandSweepPolygonsReusing(
startJDE, endJDE, greatestJDE, riseSetCurves, footprints,
)
if solarEclipseBandContainsSampledFootprintsWithinKM(
candidate, footprints, centralBandSweepToleranceKM(sampled),
) {
return candidate, horizon, sampled, true
}
// The sampled reconstruction is only worth building when the sweep had an
// analytic region to reject; when the sweep itself already fell back to the
// sampled union, the audit above has judged that exact reconstruction.
if !sampled {
union := solver.centralBandSampledFootprintUnionOverRange(startJDE, endJDE, greatestJDE, footprints)
if len(union) > 0 && solarEclipseBandContainsSampledFootprintsWithinKM(
union, footprints, centralBandSweepToleranceKM(true),
) {
return union, nil, true, true
}
}
return candidate, horizon, sampled, false
}
// centralBandSweepToleranceKM returns the coverage bound of a sweep candidate.
func centralBandSweepToleranceKM(sampled bool) float64 {
if sampled {
return solarEclipseCentralBandUnionContainmentToleranceKM
}
return solarEclipseNonCentralBandContainmentToleranceKM
}
// centralBandSweepCoversFootprints validates a sweep candidate. The analytic
// sweep must contain every sampled footprint within the usual footprint
// tolerance; the sampled footprint union is decimated for tractability and is
// validated against the wider union bound instead.
func centralBandSweepCoversFootprints(
segments [][]SolarEclipsePathPoint,
footprints []SolarEclipsePartialFootprint,
sampled bool,
) bool {
tolerance := solarEclipseNonCentralBandContainmentToleranceKM
if sampled {
tolerance = solarEclipseCentralBandUnionContainmentToleranceKM
}
return solarEclipseBandContainsFootprintsWithinKM(segments, footprints, tolerance)
}
// setCenterLinePairWidths replaces the analytic 2r/sin(altitude) width with the
// distance between the paired limits, which is the width the tables publish.
// The analytic form is a small-angle approximation that diverges towards the
// horizon (2017-08-21 reports 10,062 km near its ends instead of ~90 km), so it
// is only kept as the greatest-eclipse figure that the catalogues quote. A
// sample whose cross-section does not close has no width at all, and a one-limit
// event has none anywhere - which is why catalogues print no width for it (NASA
// lists 1874-10-10 with "-").
func (solver solarEclipseSolver) setCenterLinePairWidths(
centerLine []SolarEclipsePathPoint,
centrality SolarEclipseCentrality,
northern, southern []SolarEclipsePathPoint,
paired []bool,
) {
if len(centerLine) == 0 {
return
}
if centrality != SolarEclipseCentralTwoLimits {
for index := range centerLine {
centerLine[index].WidthKM = 0
}
return
}
if len(northern) != len(centerLine) || len(southern) != len(centerLine) || len(paired) != len(centerLine) {
return
}
for index := range centerLine {
if !paired[index] {
centerLine[index].WidthKM = 0
continue
}
width := solarEclipsePathDistanceKM(northern[index], southern[index])
if width <= 0 || width > solarEclipsePathMaxPossibleWidthKM {
// A cross-section wider than the Earth cannot be a footprint edge.
width = 0
}
centerLine[index].WidthKM = width
}
}
// centralPathMaxCentralDuration returns the longest central phase on the center
// line and where it happens. The local solution reuses the event-local
// interpolated ephemeris, which is what keeps a per-sample scan affordable.
// Catalogues publish the duration at greatest eclipse,
// which for a shallow event is not the longest one: 2020-06-21 lasts 38 s there
// but 1m22s near the sunrise end of its track. Samples below the horizon are
// skipped because the central phase is not observable from them; the width is
// deliberately not used as a filter, because a one-limit event has no width at
// all yet still has a published duration.
func (solver solarEclipseSolver) centralPathMaxCentralDuration(
centerLine []SolarEclipsePathPoint,
) (float64, float64, float64) {
bestDays, bestLongitude, bestLatitude := 0.0, 0.0, 0.0
for _, point := range centerLine {
if point.SunAltitude < -solarEclipsePathDurationHorizonToleranceDegrees {
continue
}
span := solver.centralPhaseDurationDaysAt(point.JDE, point.Longitude, point.Latitude)
if span <= 0 {
continue
}
if span > bestDays {
bestDays, bestLongitude, bestLatitude = span, point.Longitude, point.Latitude
}
}
return bestDays, bestLongitude, bestLatitude
}
func (solver solarEclipseSolver) attachCentralAxisContactPoints(
points []SolarEclipsePathPoint,
startJDE, endJDE float64,
) []SolarEclipsePathPoint {
if len(points) == 0 {
return points
}
const supportOffsetDays = 0.1 / 86400.0
contacts := make([]SolarEclipsePathPoint, 0, 2)
for _, jd := range []float64{startJDE, endJDE} {
if point, ok := solver.centralAxisContactPointAt(jd); ok {
contacts = append(contacts, point)
}
}
if len(contacts) != 2 {
return points
}
supports := make([]SolarEclipsePathPoint, 0, 2)
if endJDE-startJDE > 2*supportOffsetDays {
if point, ok := solver.centralPathPointAt(startJDE + supportOffsetDays); ok {
supports = append(supports, point)
}
if point, ok := solver.centralPathPointAt(endJDE - supportOffsetDays); ok {
supports = append(supports, point)
}
}
filtered := points[:0]
for _, point := range points {
if math.Abs(point.JDE-startJDE) <= solarEclipsePathDuplicateTimeDays ||
math.Abs(point.JDE-endJDE) <= solarEclipsePathDuplicateTimeDays {
continue
}
filtered = append(filtered, point)
}
points = append(filtered, supports...)
points = append(points, contacts...)
sort.Slice(points, func(first, second int) bool { return points[first].JDE < points[second].JDE })
return normalizeSolarEclipsePathPointSeries(points)
}
func setSolarEclipseCentralContactWidths(
centerLine, northern, southern []SolarEclipsePathPoint,
) {
if len(centerLine) < 2 || len(northern) != len(southern) {
return
}
for _, centerIndex := range []int{0, len(centerLine) - 1} {
bestIndex := -1
bestDelta := math.Inf(1)
for index := range northern {
delta := math.Abs(northern[index].JDE - centerLine[centerIndex].JDE)
if delta < bestDelta {
bestIndex, bestDelta = index, delta
}
}
if bestIndex >= 0 && bestDelta <= 0.2/86400.0 {
centerLine[centerIndex].WidthKM = solarEclipsePathDistanceKM(
northern[bestIndex], southern[bestIndex],
)
}
}
}
func (solver solarEclipseSolver) attachCentralAxisContactsToRiseSetCurves(
curves []SolarEclipseRiseSetCurve,
startJDE, endJDE float64,
) {
for _, endpoint := range []struct {
jde float64
direction RiseSetDirection
}{
{startJDE, RiseSetDirectionRise},
{endJDE, RiseSetDirectionSet},
} {
contact, ok := solver.centralAxisContactPointAt(endpoint.jde)
if !ok {
continue
}
insertSolarEclipseRiseSetContact(curves, contact, endpoint.direction)
}
}
func insertSolarEclipseRiseSetContact(
curves []SolarEclipseRiseSetCurve,
contact SolarEclipsePathPoint,
direction RiseSetDirection,
) {
bestCurve, bestSegment, bestPoint := -1, -1, -1
bestDistance := math.Inf(1)
for curveIndex := range curves {
curve := &curves[curveIndex]
if curve.Phase != RiseSetPhaseGreatest || curve.Direction != direction {
continue
}
for segmentIndex, segment := range curve.Segments {
if len(segment) < 2 ||
contact.JDE < segment[0].JDE-solarEclipseRiseSetTimeEpsilonDays ||
contact.JDE > segment[len(segment)-1].JDE+solarEclipseRiseSetTimeEpsilonDays {
continue
}
pointIndex := sort.Search(len(segment), func(index int) bool {
return segment[index].JDE >= contact.JDE
})
if pointIndex == 0 {
pointIndex = 1
} else if pointIndex >= len(segment) {
pointIndex = len(segment) - 1
}
first, second := segment[pointIndex-1], segment[pointIndex]
fraction := 0.0
if second.JDE > first.JDE {
fraction = (contact.JDE - first.JDE) / (second.JDE - first.JDE)
}
fraction = math.Max(0, math.Min(1, fraction))
candidate := solarEclipsePathSphericalInterpolate(first, second, fraction)
distance := solarEclipsePathDistanceKM(candidate, contact)
if distance < bestDistance {
bestCurve, bestSegment, bestPoint = curveIndex, segmentIndex, pointIndex
bestDistance = distance
}
}
}
if bestCurve < 0 || bestDistance > solarEclipseRiseSetTargetSpacingKM {
return
}
segment := curves[bestCurve].Segments[bestSegment]
if bestPoint > 0 && math.Abs(segment[bestPoint-1].JDE-contact.JDE) <= solarEclipseRiseSetTimeEpsilonDays {
segment[bestPoint-1] = contact
} else if bestPoint < len(segment) && math.Abs(segment[bestPoint].JDE-contact.JDE) <= solarEclipseRiseSetTimeEpsilonDays {
segment[bestPoint] = contact
} else {
segment = append(segment, SolarEclipsePathPoint{})
copy(segment[bestPoint+1:], segment[bestPoint:])
segment[bestPoint] = contact
}
curves[bestCurve].Segments[bestSegment] = segment
}
func (solver solarEclipseSolver) centralBandFootprintsWithStep(
outerStartJDE, innerStartJDE, innerEndJDE, outerEndJDE, greatestJDE, stepDays float64,
) ([]SolarEclipsePartialFootprint, float64) {
if stepDays <= 0 || math.IsNaN(stepDays) || math.IsInf(stepDays, 0) {
stepDays = solarEclipseCentralBandStepDays
}
if innerStartJDE != 0 && innerEndJDE != 0 &&
outerStartJDE < innerStartJDE && innerStartJDE < innerEndJDE && innerEndJDE < outerEndJDE {
start, actualStartStep, _ := solver.shadowFootprintsWithSpacing(
outerStartJDE, innerStartJDE, innerStartJDE, stepDays,
solarEclipseCentralBandBoundaryPoints, solarEclipseCentralShadow,
solarEclipseCentralBandTargetSpacingKM,
)
end, actualEndStep, _ := solver.shadowFootprintsWithSpacing(
innerEndJDE, outerEndJDE, innerEndJDE, stepDays,
solarEclipseCentralBandBoundaryPoints, solarEclipseCentralShadow,
solarEclipseCentralBandTargetSpacingKM,
)
return append(start, end...), math.Max(actualStartStep, actualEndStep)
}
if greatestJDE <= outerStartJDE || greatestJDE >= outerEndJDE {
greatestJDE = (outerStartJDE + outerEndJDE) / 2
}
footprints, actualStep, _ := solver.shadowFootprintsWithSpacing(
outerStartJDE, outerEndJDE, greatestJDE, stepDays,
solarEclipseCentralBandBoundaryPoints, solarEclipseCentralShadow,
solarEclipseCentralBandTargetSpacingKM,
)
return footprints, actualStep
}