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

1342 lines
44 KiB
Go

package basic
import "math"
func uniqueSolarEclipsePathTimes(times []float64) []float64 {
return movingDiskUniqueTimes(times)
}
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) refineCentralPathLimitSpacing(
northern, southern []SolarEclipsePathPoint,
centralBeginJDE, centralEndJDE, targetSpacingKM float64,
) ([]SolarEclipsePathPoint, []SolarEclipsePathPoint) {
if len(northern) < 2 || len(northern) != len(southern) || targetSpacingKM <= 0 {
return northern, southern
}
refinedNorth := make([]SolarEclipsePathPoint, 0, len(northern))
refinedSouth := make([]SolarEclipsePathPoint, 0, len(southern))
refinedNorth = append(refinedNorth, northern[0])
refinedSouth = append(refinedSouth, southern[0])
for index := 1; index < len(northern); index++ {
refinedNorth, refinedSouth = solver.appendRefinedCentralPathLimitSegment(
refinedNorth, refinedSouth,
northern[index-1], southern[index-1],
northern[index], southern[index],
centralBeginJDE, centralEndJDE, targetSpacingKM, 0,
)
}
return normalizeSolarEclipseCentralLimitPairs(refinedNorth, refinedSouth)
}
// normalizeSolarEclipsePathPointSeries removes numerical duplicate samples
// introduced by midpoint refinement. A public path is a time-ordered series;
// retaining a rounded duplicate makes GeoJSON/SVG consumers either reject the
// line or render a zero-length kink.
func normalizeSolarEclipsePathPointSeries(points []SolarEclipsePathPoint) []SolarEclipsePathPoint {
if len(points) < 2 {
return points
}
result := make([]SolarEclipsePathPoint, 0, len(points))
for _, point := range points {
if !finite(point.JDE) || !finite(point.Longitude) || !finite(point.Latitude) {
continue
}
if len(result) == 0 {
result = append(result, point)
continue
}
last := result[len(result)-1]
if point.JDE <= last.JDE+solarEclipsePathDuplicateTimeDays {
if solarEclipsePathDistanceKM(last, point) <= 0.01 {
// Keep the later evaluation so its derived altitude/width is the
// one exposed at the surviving timestamp.
result[len(result)-1] = point
continue
}
// A branch change cannot be represented by a single public series;
// discard the numerically ambiguous sample rather than emitting a
// non-monotone line.
continue
}
result = append(result, point)
}
return result
}
func normalizeSolarEclipseCentralLimitPairs(
northern, southern []SolarEclipsePathPoint,
) ([]SolarEclipsePathPoint, []SolarEclipsePathPoint) {
if len(northern) != len(southern) || len(northern) < 2 {
return nil, nil
}
resultNorth := make([]SolarEclipsePathPoint, 0, len(northern))
resultSouth := make([]SolarEclipsePathPoint, 0, len(southern))
for index := range northern {
north, south := northern[index], southern[index]
if !finite(north.JDE) || !finite(south.JDE) ||
!finite(north.Longitude) || !finite(north.Latitude) ||
!finite(south.Longitude) || !finite(south.Latitude) {
continue
}
if len(resultNorth) == 0 {
resultNorth = append(resultNorth, north)
resultSouth = append(resultSouth, south)
continue
}
lastNorth, lastSouth := resultNorth[len(resultNorth)-1], resultSouth[len(resultSouth)-1]
// Near a grazing polar contact, the limit solver can refine one logical
// instant through several numerically distinct branch solutions. Their
// JDEs differ by microseconds or milliseconds, while the longitude/latitude
// may jump to the opposite polar chart branch. Treat that interval as one
// sample; retaining both points creates a false edge in map geometry.
const nearDuplicateTimeDays = 100.0 / 86400000.0
if north.JDE <= lastNorth.JDE+nearDuplicateTimeDays ||
south.JDE <= lastSouth.JDE+nearDuplicateTimeDays {
if solarEclipsePathDistanceKM(lastNorth, north) <= 0.01 &&
solarEclipsePathDistanceKM(lastSouth, south) <= 0.01 {
resultNorth[len(resultNorth)-1] = north
resultSouth[len(resultSouth)-1] = south
}
continue
}
resultNorth = append(resultNorth, north)
resultSouth = append(resultSouth, south)
}
if len(resultNorth) < 2 || len(resultNorth) != len(resultSouth) {
return nil, nil
}
return resultNorth, resultSouth
}
func (solver solarEclipseSolver) appendRefinedCentralPathLimitSegment(
northern, southern []SolarEclipsePathPoint,
startNorth, startSouth, endNorth, endSouth SolarEclipsePathPoint,
centralBeginJDE, centralEndJDE, targetSpacingKM float64,
depth int,
) ([]SolarEclipsePathPoint, []SolarEclipsePathPoint) {
maximumDistance := math.Max(
solarEclipsePathDistanceKM(startNorth, endNorth),
solarEclipsePathDistanceKM(startSouth, endSouth),
)
middleJDE := (startNorth.JDE + endNorth.JDE) / 2
if depth >= solarEclipsePathMaxAdaptiveDepth ||
maximumDistance <= targetSpacingKM ||
middleJDE <= centralBeginJDE || middleJDE >= centralEndJDE {
return append(northern, endNorth), append(southern, endSouth)
}
middleCenter, centerOK := solver.centralPathPointAt(middleJDE)
if !centerOK {
return append(northern, endNorth), append(southern, endSouth)
}
before, beforeOK := solver.centralPathPointAt(middleJDE - solarEclipsePathVelocityStepDays)
after, afterOK := solver.centralPathPointAt(middleJDE + solarEclipsePathVelocityStepDays)
if !beforeOK {
before = middleCenter
}
if !afterOK {
after = middleCenter
}
first, second, limitsOK := solver.centralPathLimitsAtAlong(middleCenter, before, after)
if !limitsOK {
first, second, limitsOK = solver.centralPathLimitsAt(middleCenter)
}
if !limitsOK {
return append(northern, endNorth), append(southern, endSouth)
}
keepDistance := solarEclipsePathDistanceKM(startNorth, first) +
solarEclipsePathDistanceKM(startSouth, second) +
solarEclipsePathDistanceKM(first, endNorth) +
solarEclipsePathDistanceKM(second, endSouth)
swapDistance := solarEclipsePathDistanceKM(startNorth, second) +
solarEclipsePathDistanceKM(startSouth, first) +
solarEclipsePathDistanceKM(second, endNorth) +
solarEclipsePathDistanceKM(first, endSouth)
if swapDistance < keepDistance {
first, second = second, first
}
northern, southern = solver.appendRefinedCentralPathLimitSegment(
northern, southern, startNorth, startSouth, first, second,
centralBeginJDE, centralEndJDE, targetSpacingKM, depth+1,
)
return solver.appendRefinedCentralPathLimitSegment(
northern, southern, first, second, endNorth, endSouth,
centralBeginJDE, centralEndJDE, targetSpacingKM, depth+1,
)
}
func (solver solarEclipseSolver) appendRefinedCentralPathSegment(
points []SolarEclipsePathPoint,
start, end SolarEclipsePathPoint,
targetSpacingKM float64,
depth int,
) []SolarEclipsePathPoint {
if depth >= solarEclipsePathMaxAdaptiveDepth {
return append(points, end)
}
segmentDistanceKM := solarEclipsePathDistanceKM(start, end)
midJDE := (start.JDE + end.JDE) / 2
mid, ok := solver.centralPathPointAt(midJDE)
if !ok {
return append(points, end)
}
// The renderer joins samples with the shorter great-circle arc. Compare
// the physical midpoint with that arc's midpoint so a short but sharply
// turning segment is refined even when its endpoints are close together.
geodesicMiddle := solarEclipsePathSphericalInterpolate(start, end, 0.5)
curvatureErrorKM := solarEclipsePathDistanceKM(mid, geodesicMiddle)
curvatureToleranceKM := math.Max(
solarEclipsePathMinimumCurvatureKM,
targetSpacingKM*solarEclipsePathAdaptiveCurvatureFraction,
)
if segmentDistanceKM <= targetSpacingKM && curvatureErrorKM <= curvatureToleranceKM {
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(jde float64) (SolarEclipsePathPoint, bool) {
moon := solver.besselMoonAt(jde)
axis := solver.besselAxisAt(jde)
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(jde, 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: jde,
Longitude: longitude,
Latitude: latitude,
SunAltitude: sunAltitudeRad / rad,
WidthKM: widthKM,
}, true
}
func (solver solarEclipseSolver) centralPathLimits(centerLine []SolarEclipsePathPoint) ([]SolarEclipsePathPoint, []SolarEclipsePathPoint) {
northern, southern, paired := solver.centralPathLimitPairs(centerLine)
return solarEclipseFilterCentralPathLimits(northern, southern, paired)
}
func solarEclipseFilterCentralPathLimits(
northern, southern []SolarEclipsePathPoint,
paired []bool,
) ([]SolarEclipsePathPoint, []SolarEclipsePathPoint) {
filteredNorth := make([]SolarEclipsePathPoint, 0, len(northern))
filteredSouth := make([]SolarEclipsePathPoint, 0, len(southern))
for index := range northern {
if !paired[index] {
continue
}
filteredNorth = append(filteredNorth, northern[index])
filteredSouth = append(filteredSouth, southern[index])
}
// Limits are optional derived lines. A grazing or very narrow path may
// yield one numerically valid cross-section even when its center line is
// usable; exposing that singleton would make GeoJSON consumers reject the
// otherwise valid event as a line geometry.
if len(filteredNorth) < 2 || len(filteredNorth) != len(filteredSouth) {
return nil, nil
}
return filteredNorth, filteredSouth
}
// centralPathLimitPairs solves the paired cross-section at every centerline
// sample and keeps the slice aligned with the center line: paired[index] is
// false where no stable pair exists, which is exactly where the analytic
// 2r/sin(altitude) width diverges and the width contract asks for 0.
func (solver solarEclipseSolver) centralPathLimitPairs(
centerLine []SolarEclipsePathPoint,
) ([]SolarEclipsePathPoint, []SolarEclipsePathPoint, []bool) {
northern := make([]SolarEclipsePathPoint, len(centerLine))
southern := make([]SolarEclipsePathPoint, len(centerLine))
paired := make([]bool, len(centerLine))
var previousNorth, previousSouth SolarEclipsePathPoint
havePrevious := false
for index, center := range centerLine {
beforeIndex, afterIndex := index-1, index+1
if beforeIndex < 0 {
beforeIndex = 0
}
if afterIndex >= len(centerLine) {
afterIndex = len(centerLine) - 1
}
first, second, ok := solver.centralPathLimitsAtAlong(
center,
centerLine[beforeIndex],
centerLine[afterIndex],
)
if !ok {
first, second, ok = solver.centralPathLimitsAt(center)
}
if !ok {
continue
}
north, south := first, second
if !havePrevious {
if second.Latitude > first.Latitude {
north, south = second, first
}
} else {
keepDistance := solarEclipsePathDistanceKM(first, previousNorth) + solarEclipsePathDistanceKM(second, previousSouth)
swapDistance := solarEclipsePathDistanceKM(second, previousNorth) + solarEclipsePathDistanceKM(first, previousSouth)
if swapDistance < keepDistance {
north, south = second, first
}
}
northern[index] = north
southern[index] = south
paired[index] = true
previousNorth, previousSouth = north, south
havePrevious = true
}
return northern, southern, paired
}
func (solver solarEclipseSolver) centralPathLimitsAtAlong(
center, before, after SolarEclipsePathPoint,
) (SolarEclipsePathPoint, SolarEclipsePathPoint, bool) {
footprint := solver.shadowFootprintAtWithSpacing(
center.JDE,
solarEclipseCentralBandBoundaryPoints,
solarEclipseCentralShadow,
0,
)
if len(footprint.Boundaries) == 0 {
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
}
latitude := center.Latitude * rad
tangentX := math.Remainder(after.Longitude-before.Longitude, 360) * math.Cos(latitude)
tangentY := after.Latitude - before.Latitude
tangentLength := math.Hypot(tangentX, tangentY)
if tangentLength <= 1e-12 {
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
}
normalX, normalY := -tangentY/tangentLength, tangentX/tangentLength
axis := solver.besselAxisAt(center.JDE)
var candidates []SolarEclipsePathPoint
appendCrossing := func(first, second SolarEclipsePathPoint) {
firstResidual := solarEclipseAlongTrackResidual(first, center, tangentX, tangentY)
secondResidual := solarEclipseAlongTrackResidual(second, center, tangentX, tangentY)
if !finite(firstResidual) || !finite(secondResidual) {
return
}
if math.Abs(firstResidual) <= 1e-10 {
candidates = append(candidates, first)
}
if firstResidual*secondResidual > 0 || math.Abs(secondResidual-firstResidual) <= 1e-12 {
return
}
fraction := -firstResidual / (secondResidual - firstResidual)
if fraction <= 0 || fraction >= 1 {
return
}
longitude := normalizeLongitude(first.Longitude + math.Remainder(second.Longitude-first.Longitude, 360)*fraction)
latitudeValue := first.Latitude + (second.Latitude-first.Latitude)*fraction
candidates = append(candidates, SolarEclipsePathPoint{
JDE: center.JDE,
Longitude: longitude,
Latitude: latitudeValue,
SunAltitude: solarEclipseSunAltitudeAtGreatest(center.JDE, longitude, latitudeValue, axis.gst) / rad,
WidthKM: center.WidthKM,
})
}
for _, segment := range footprint.Boundaries {
for index := 1; index < len(segment); index++ {
appendCrossing(segment[index-1], segment[index])
}
}
if footprint.Closed && len(footprint.Boundaries) == 1 && len(footprint.Boundaries[0]) > 2 {
segment := footprint.Boundaries[0]
appendCrossing(segment[len(segment)-1], segment[0])
}
if len(candidates) < 2 {
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
}
minimum, maximum := math.Inf(1), math.Inf(-1)
var minimumPoint, maximumPoint SolarEclipsePathPoint
for _, point := range candidates {
value := solarEclipseCrossTrackResidual(point, center, normalX, normalY)
if value < minimum {
minimum, minimumPoint = value, point
}
if value > maximum {
maximum, maximumPoint = value, point
}
}
if !finite(minimum) || !finite(maximum) || maximum-minimum <= 1e-8 {
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
}
return maximumPoint, minimumPoint, true
}
func solarEclipseAlongTrackResidual(
point, center SolarEclipsePathPoint, tangentX, tangentY float64,
) float64 {
latitude := center.Latitude * rad
deltaX := math.Remainder(point.Longitude-center.Longitude, 360) * math.Cos(latitude)
deltaY := point.Latitude - center.Latitude
return deltaX*tangentX + deltaY*tangentY
}
func solarEclipseCrossTrackResidual(
point, center SolarEclipsePathPoint, normalX, normalY float64,
) float64 {
latitude := center.Latitude * rad
deltaX := math.Remainder(point.Longitude-center.Longitude, 360) * math.Cos(latitude)
deltaY := point.Latitude - center.Latitude
return deltaX*normalX + deltaY*normalY
}
func (solver solarEclipseSolver) centralPathLimitsAt(center SolarEclipsePathPoint) (SolarEclipsePathPoint, SolarEclipsePathPoint, bool) {
moon, axis, sun := solver.besselGeometryAt(center.JDE)
vx, vy, speed := solver.besselVelocityXYAt(center.JDE)
if speed <= 0 {
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
}
perpX := -vy / speed
perpY := vx / speed
angle := math.Atan2(perpY, perpX)
first, okFirst := solver.shadowFootprintPointAt(
center.JDE, moon, axis, sun, angle, solarEclipseCentralShadow,
)
second, okSecond := solver.shadowFootprintPointAt(
center.JDE, moon, axis, sun, angle+math.Pi, solarEclipseCentralShadow,
)
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(jde, x, y float64, axis solarEclipseAxis) (SolarEclipsePathPoint, bool) {
longitude, latitude, ok := solarEclipseBesselXYToGeodetic(x, y, axis, true)
if !ok {
return SolarEclipsePathPoint{}, false
}
sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jde, longitude, latitude, axis.gst)
return SolarEclipsePathPoint{
JDE: jde,
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, internal)
last, lastOK := solver.shadowContactPointAt(lastJDE, kind, internal)
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(
jde float64,
kind solarEclipseShadowKind,
internal bool,
) (float64, bool) {
if kind == solarEclipseCentralShadow && solver.exactCentralContact && !internal {
return solver.shadowContactResidualExact(jde)
}
moon := solver.besselMoonAt(jde)
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) shadowContactResidualExact(jde float64) (float64, bool) {
_, value, ok := solver.shadowContactMaximum(jde)
return -value, ok
}
func (solver solarEclipseSolver) shadowContactMaximum(jde float64) (float64, float64, bool) {
const samples = 32
step := 2 * math.Pi / samples
bestIndex := -1
bestValue := math.Inf(-1)
for index := 0; index < samples; index++ {
value, ok := solver.shadowBoundaryDiscriminant(jde, float64(index)*step)
if !ok {
continue
}
if value > bestValue {
bestIndex, bestValue = index, value
}
}
if bestIndex < 0 {
return 0, 0, false
}
left := float64(bestIndex)*step - step
right := float64(bestIndex)*step + step
valueAt := func(angle float64) float64 {
value, ok := solver.shadowBoundaryDiscriminant(jde, angle)
if !ok {
return math.Inf(-1)
}
return value
}
// Golden-section maximization removes the polar effective-radius
// approximation from contact times while retaining a bounded cost.
golden := (math.Sqrt(5) - 1) / 2
x1 := right - golden*(right-left)
x2 := left + golden*(right-left)
f1, f2 := valueAt(x1), valueAt(x2)
for iteration := 0; iteration < 32; iteration++ {
if f1 < f2 {
left, x1, f1 = x1, x2, f2
x2 = left + golden*(right-left)
f2 = valueAt(x2)
} else {
right, x2, f2 = x2, x1, f1
x1 = right - golden*(right-left)
f1 = valueAt(x1)
}
}
bestAngle := float64(bestIndex) * step
if f1 > bestValue {
bestAngle, bestValue = x1, f1
}
if f2 > bestValue {
bestAngle, bestValue = x2, f2
}
return bestAngle, bestValue, true
}
func (solver solarEclipseSolver) shadowBoundaryDiscriminant(jde, angle float64) (float64, bool) {
moon, axis, _ := solver.besselGeometryAt(jde)
radius := solver.shadowRadiusAt(moon[2], solarEclipseCentralShadow)
if radius <= 0 {
return 0, false
}
cosAngle, sinAngle := math.Cos(angle), math.Sin(angle)
best := math.Inf(-1)
for iteration := 0; iteration < solarEclipsePartialFootprintIterationLimit; iteration++ {
x, y := moon[0]+radius*cosAngle, moon[1]+radius*sinAngle
discriminant := solarEclipseLineEllipsoidDiscriminant(
x, y, 2, x, y, 0, solarEclipseEarthPolarRatio, 1, axis,
)
best = discriminant
if discriminant < 0 {
return discriminant, true
}
intersection := solarEclipseLineEar2(
x, y, 2, x, y, 0, solarEclipseEarthPolarRatio, 1, axis,
)
if !intersection.valid {
return discriminant, true
}
nextRadius := solver.shadowRadiusAt(moon[2]-intersection.r2, solarEclipseCentralShadow)
if nextRadius <= 0 {
return discriminant, true
}
if math.Abs(nextRadius-radius) <= solarEclipsePartialFootprintPointTolerance {
break
}
radius = nextRadius
}
return best, true
}
func solarEclipseLineEllipsoidDiscriminant(
x1, y1, z1, x2, y2, z2, polarRatio, radius float64,
axis solarEclipseAxis,
) float64 {
cosTilt, sinTilt := math.Cos(axis.tilt), math.Sin(axis.tilt)
x1Rot := x1
y1Rot := cosTilt*y1 - sinTilt*z1
z1Rot := sinTilt*y1 + cosTilt*z1
x2Rot := x2
y2Rot := cosTilt*y2 - sinTilt*z2
z2Rot := sinTilt*y2 + cosTilt*z2
dx, dy, dz := x2Rot-x1Rot, y2Rot-y1Rot, z2Rot-z1Rot
polarRatioSquared := polarRatio * polarRatio
a := dx*dx + dy*dy + dz*dz/polarRatioSquared
b := x1Rot*dx + y1Rot*dy + z1Rot*dz/polarRatioSquared
c := x1Rot*x1Rot + y1Rot*y1Rot + z1Rot*z1Rot/polarRatioSquared - radius*radius
return b*b - a*c
}
func (solver solarEclipseSolver) shadowContactPointAt(
jde float64,
kind solarEclipseShadowKind,
internal bool,
) (SolarEclipsePathPoint, bool) {
if kind == solarEclipseCentralShadow && solver.exactCentralContact && !internal {
angle, _, ok := solver.shadowContactMaximum(jde)
if !ok {
return SolarEclipsePathPoint{}, false
}
if point, pointOK := solver.centralShadowPointAt(jde, angle); pointOK {
return point, true
}
for _, offset := range []float64{-1e-8, 1e-8, -1e-7, 1e-7} {
offsetJDE := jde + offset
offsetAngle, _, maximumOK := solver.shadowContactMaximum(offsetJDE)
if !maximumOK {
continue
}
if point, pointOK := solver.centralShadowPointAt(offsetJDE, offsetAngle); pointOK {
point.JDE = jde
return point, true
}
}
return SolarEclipsePathPoint{}, false
}
moon := solver.besselMoonAt(jde)
distance := math.Hypot(moon[0], moon[1])
if distance <= 0 || solver.shadowRadiusAt(moon[2], kind) <= 0 {
return SolarEclipsePathPoint{}, false
}
axis := solver.besselAxisAt(jde)
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(jde, longitude, latitude, axis.gst)
return SolarEclipsePathPoint{
JDE: jde,
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
}
func (solver solarEclipseSolver) partialFootprintAt(jde float64, boundaryPoints int) SolarEclipsePartialFootprint {
return solver.shadowFootprintAt(jde, boundaryPoints, solarEclipsePenumbralShadow)
}
func (solver solarEclipseSolver) shadowFootprintAt(
jde float64,
boundaryPoints int,
kind solarEclipseShadowKind,
) SolarEclipsePartialFootprint {
return solver.shadowFootprintAtWithSpacing(jde, boundaryPoints, kind, 0)
}
func (solver solarEclipseSolver) shadowFootprintAtWithSpacing(
jde float64,
boundaryPoints int,
kind solarEclipseShadowKind,
targetSpacingKM float64,
) SolarEclipsePartialFootprint {
moon, axis, sun := solver.besselGeometryAt(jde)
return solver.shadowFootprintAtWithGeometry(
jde, moon, axis, sun, boundaryPoints, kind, targetSpacingKM,
)
}
// shadowFootprintAtWithGeometry 用调用方给定的贝塞尔几何求瞬时足迹。显式 ΔT 的单时刻
// 入口走这条路径:几何由调用方按自己的 ΔT 构造,避免落到进程级 ΔT 上。
// shadowFootprintAtWithGeometry solves one instantaneous footprint from precomputed
// Besselian geometry, so a caller with an explicit ΔT never falls back to the global one.
func (solver solarEclipseSolver) shadowFootprintAtWithGeometry(
jd float64,
moon [3]float64,
axis solarEclipseAxis,
sun [3]float64,
boundaryPoints int,
kind solarEclipseShadowKind,
targetSpacingKM float64,
) SolarEclipsePartialFootprint {
samples := make([]solarEclipsePartialBoundarySample, boundaryPoints)
for i := range samples {
angle := 2 * math.Pi * float64(i) / float64(boundaryPoints)
point, ok := solver.shadowFootprintPointAt(jd, moon, axis, sun, angle, kind)
samples[i] = solarEclipsePartialBoundarySample{
point: point,
ok: ok,
angle: angle,
}
}
samples = solver.refineShadowFootprintTransitions(jd, moon, axis, sun, samples, kind)
if targetSpacingKM > 0 {
samples = solver.refineShadowFootprintSpacing(
jd, moon, axis, sun, samples, kind, targetSpacingKM,
)
}
boundaries, closed := solarEclipsePartialBoundarySegments(samples)
footprint := SolarEclipsePartialFootprint{
JDE: jd,
Boundaries: boundaries,
Closed: closed,
}
if !closed {
// The sampled rim stops where the shooting gives up, which can be tens of
// kilometres inside the horizon. Recover the exact tangency points so the
// caller can close the region with a horizon arc that really meets the
// physical boundary; Boundaries itself stays untouched so every existing
// consumer of the open rim keeps its geometry.
footprint.HorizonEnds = solver.shadowFootprintHorizonEnds(
jd, moon, axis, sun, samples, boundaries, kind,
)
}
return footprint
}
// shadowFootprintHorizonEnds 求未闭合足迹边界两端在地平圈上的擦地点。
// shadowFootprintHorizonEnds returns the horizon tangency point at each end of an
// open footprint boundary.
func (solver solarEclipseSolver) shadowFootprintHorizonEnds(
jd float64,
moon [3]float64,
axis solarEclipseAxis,
sun [3]float64,
samples []solarEclipsePartialBoundarySample,
boundaries [][]SolarEclipsePathPoint,
kind solarEclipseShadowKind,
) []SolarEclipsePathPoint {
if len(samples) < 2 || len(boundaries) == 0 || len(boundaries[0]) == 0 {
return nil
}
lastSegment := boundaries[len(boundaries)-1]
if len(lastSegment) == 0 {
return nil
}
// A footprint can be cut by the horizon along more than one arc (a wide
// penumbra near the terminator has two), so the two ends of the joined
// boundary are the first and the last ok/!ok transition rather than an
// arbitrary pair of them.
firstIndex, lastIndex := -1, -1
for index := range samples {
if samples[index].ok == samples[(index+1)%len(samples)].ok {
continue
}
if firstIndex < 0 {
firstIndex = index
}
lastIndex = index
}
if firstIndex < 0 || lastIndex == firstIndex {
return nil
}
ends := make([]SolarEclipsePathPoint, 0, 2)
for _, index := range []int{firstIndex, lastIndex} {
sample, next := samples[index], samples[(index+1)%len(samples)]
// The transition samples are already bisected in azimuth by
// refineShadowFootprintTransitions, so the valid side of the pair is the
// azimuth that carries the tangency.
angle := sample.angle
if !sample.ok {
angle = next.angle
}
point, ok := solver.shadowFootprintHorizonEndAt(jd, moon, axis, sun, angle, kind)
if !ok {
return nil
}
ends = append(ends, point)
}
start := boundaries[0][0]
if solarEclipsePathDistanceKM(ends[0], start) > solarEclipsePathDistanceKM(ends[1], start) {
ends[0], ends[1] = ends[1], ends[0]
}
return ends
}
// shadowFootprintHorizonEndAt 在给定方位上求阴影锥面与地表的切点。该方位由采样
// 的 ok/!ok 转移给出,切点就是本影(或半影)边界真正终止的位置:轴线平行线与椭球
// 相切,因此当地太阳高度为 0,也就是落在该时刻的地平圈上。
// shadowFootprintHorizonEndAt solves the cone/ground tangency point on one azimuth.
// The azimuth comes from the sampled ok/!ok transition, and the tangency point is
// where the boundary really ends: the axis-parallel line grazes the ellipsoid, so
// the local solar altitude is zero and the point lies on the horizon.
func (solver solarEclipseSolver) shadowFootprintHorizonEndAt(
jd float64,
moon [3]float64,
axis solarEclipseAxis,
sun [3]float64,
angle float64,
kind solarEclipseShadowKind,
) (SolarEclipsePathPoint, bool) {
radius := solver.shadowRadiusAt(moon[2], kind)
if radius <= 0 {
return SolarEclipsePathPoint{}, false
}
cosAngle, sinAngle := math.Cos(angle), math.Sin(angle)
discriminantAt := func(value float64) float64 {
x := moon[0] + value*cosAngle
y := moon[1] + value*sinAngle
return solarEclipseLineEllipsoidDiscriminant(
x, y, 2, x, y, 0, solarEclipseEarthPolarRatio, 1, axis,
)
}
if discriminantAt(radius) < 0 {
return SolarEclipsePathPoint{}, false
}
// The transition azimuth is where the sampled radius meets an edge of the
// silhouette, and that edge can be either the inner or the outer one: a shadow
// whose axis still crosses the Earth is cut outwards, one whose axis misses the
// Earth is cut inwards, and a large penumbral circle can meet either. Search
// both directions and keep the nearer sign change, which is the edge the
// sampled radius actually ran into.
valid, invalid := radius, 0.0
found := false
nearest := math.Inf(1)
for offset := solarEclipseHorizonEndSearchStep; offset <= solarEclipseHorizonEndSearchLimit; offset *= 2 {
if discriminantAt(radius+offset) < 0 {
nearest, invalid, found = offset, radius+offset, true
break
}
}
for offset := solarEclipseHorizonEndSearchStep; offset <= radius; offset *= 2 {
candidate := radius - offset
if candidate <= 0 {
break
}
if discriminantAt(candidate) < 0 {
if offset < nearest {
invalid, found = candidate, true
}
break
}
}
if !found {
return SolarEclipsePathPoint{}, false
}
// valid stays on the disc >= 0 side and invalid on the other one; the bracket
// order differs between the outward and the inward cut, so the two ends are
// never sorted against each other.
inside, outside := valid, invalid
for round := 0; round < solarEclipseHorizonEndBisectionRounds; round++ {
middle := (inside + outside) / 2
if discriminantAt(middle) >= 0 {
inside = middle
continue
}
outside = middle
}
tangent := inside
intersection := solarEclipseLineEar2(
moon[0]+tangent*cosAngle, moon[1]+tangent*sinAngle, 2,
moon[0]+tangent*cosAngle, moon[1]+tangent*sinAngle, 0,
solarEclipseEarthPolarRatio, 1, axis,
)
if !intersection.valid {
return SolarEclipsePathPoint{}, false
}
longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis)
sunAltitudeRad := solarEclipseSunAltitudeFromEquatorial(sun, longitude, latitude, axis.gst)
return SolarEclipsePathPoint{
JDE: jd,
Longitude: longitude,
Latitude: latitude,
SunAltitude: sunAltitudeRad / rad,
}, true
}
func (solver solarEclipseSolver) refineShadowFootprintSpacing(
jd float64,
moon [3]float64,
axis solarEclipseAxis,
sun [3]float64,
samples []solarEclipsePartialBoundarySample,
kind solarEclipseShadowKind,
targetSpacingKM float64,
) []solarEclipsePartialBoundarySample {
if len(samples) < 2 || targetSpacingKM <= 0 {
return samples
}
result := make([]solarEclipsePartialBoundarySample, 0, len(samples))
for index, left := range samples {
right := samples[(index+1)%len(samples)]
if index == len(samples)-1 {
right.angle += 2 * math.Pi
} else if right.angle < left.angle {
right.angle += 2 * math.Pi
}
result = append(result, left)
result = solver.appendRefinedShadowFootprintInterval(
result, jd, moon, axis, sun, left, right, kind, targetSpacingKM, 0,
)
}
return result
}
func (solver solarEclipseSolver) appendRefinedShadowFootprintInterval(
result []solarEclipsePartialBoundarySample,
jd float64,
moon [3]float64,
axis solarEclipseAxis,
sun [3]float64,
left, right solarEclipsePartialBoundarySample,
kind solarEclipseShadowKind,
targetSpacingKM float64,
depth int,
) []solarEclipsePartialBoundarySample {
if depth >= solarEclipseShadowFootprintAdaptiveMaxDepth || !left.ok || !right.ok ||
solarEclipsePathDistanceKM(left.point, right.point) <= targetSpacingKM {
return result
}
angle := (left.angle + right.angle) / 2
point, ok := solver.shadowFootprintPointAt(
jd, moon, axis, sun, math.Mod(angle, 2*math.Pi), kind,
)
middle := solarEclipsePartialBoundarySample{point: point, ok: ok, angle: angle}
if !middle.ok {
return result
}
result = solver.appendRefinedShadowFootprintInterval(
result, jd, moon, axis, sun, left, middle, kind, targetSpacingKM, depth+1,
)
result = append(result, middle)
return solver.appendRefinedShadowFootprintInterval(
result, jd, moon, axis, sun, middle, right, kind, targetSpacingKM, depth+1,
)
}
type solarEclipsePartialBoundarySample struct {
point SolarEclipsePathPoint
ok bool
angle float64
}
func (solver solarEclipseSolver) refineShadowFootprintTransitions(
jd float64,
moon [3]float64,
axis solarEclipseAxis,
sun [3]float64,
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, sun, sample, next, nextAngle, kind)
result = append(result, refined)
}
return result
}
func (solver solarEclipseSolver) refineShadowFootprintTransition(
jd float64,
moon [3]float64,
axis solarEclipseAxis,
sun [3]float64,
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, sun, 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(
jd float64,
moon [3]float64,
axis solarEclipseAxis,
sun [3]float64,
angle float64,
kind solarEclipseShadowKind,
) (SolarEclipsePathPoint, bool) {
cosAngle := math.Cos(angle)
sinAngle := math.Sin(angle)
radius := solver.shadowRadiusAt(moon[2], kind)
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)
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 := solarEclipseSunAltitudeFromEquatorial(sun, 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
for _, sample := range samples {
if !sample.ok {
allSamplesValid = false
segments = appendSolarEclipsePartialSegment(segments, current)
current = nil
continue
}
if len(current) > 0 && solarEclipsePathCrossesAntimeridian(current[len(current)-1], sample.point) {
firstCrossing, secondCrossing := solarEclipsePathAntimeridianCrossings(
current[len(current)-1], sample.point,
)
current = append(current, firstCrossing)
segments = appendSolarEclipsePartialSegment(segments, current)
current = []SolarEclipsePathPoint{secondCrossing}
}
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]) {
segments[0] = append(segments[0], segments[0][0])
}
return segments, true
}
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]) {
firstCrossing, secondCrossing := solarEclipsePathAntimeridianCrossings(
last[len(last)-1], first[0],
)
segments[len(segments)-1] = append(last, firstCrossing)
segments[0] = append([]SolarEclipsePathPoint{secondCrossing}, first...)
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 solarEclipsePathAntimeridianCrossings(
first, second SolarEclipsePathPoint,
) (SolarEclipsePathPoint, SolarEclipsePathPoint) {
boundary := 180.0
secondLongitude := second.Longitude
if first.Longitude < 0 {
boundary = -180
secondLongitude -= 360
} else {
secondLongitude += 360
}
left, right := 0.0, 1.0
for iteration := 0; iteration < 64; iteration++ {
middle := (left + right) / 2
point := solarEclipsePathSphericalInterpolate(first, second, middle)
middleLongitude := point.Longitude
for middleLongitude-first.Longitude > 180 {
middleLongitude -= 360
}
for middleLongitude-first.Longitude < -180 {
middleLongitude += 360
}
if (first.Longitude-boundary)*(middleLongitude-boundary) <= 0 {
right = middle
} else {
left = middle
}
if math.Abs(middleLongitude-boundary) <= 1e-12 || right-left <= 1e-13 {
break
}
}
fraction := (left + right) / 2
if secondLongitude != first.Longitude {
linearFraction := (boundary - first.Longitude) / (secondLongitude - first.Longitude)
if linearFraction >= 0 && linearFraction <= 1 && math.Abs(right-left) > 1e-6 {
fraction = linearFraction
}
}
crossing := solarEclipsePathSphericalInterpolate(first, second, fraction)
crossing.Longitude = boundary
opposite := crossing
opposite.Longitude = -boundary
return crossing, opposite
}
func solarEclipsePathSphericalInterpolate(
first, second SolarEclipsePathPoint,
fraction float64,
) SolarEclipsePathPoint {
firstVector := solarEclipseLLRToXYZ(first.Longitude*rad, first.Latitude*rad, 1)
secondVector := solarEclipseLLRToXYZ(second.Longitude*rad, second.Latitude*rad, 1)
dot := firstVector[0]*secondVector[0] + firstVector[1]*secondVector[1] + firstVector[2]*secondVector[2]
dot = math.Max(-1, math.Min(1, dot))
firstWeight, secondWeight := 1-fraction, fraction
if dot < 1-1e-14 && dot > -1+1e-14 {
angle := math.Acos(dot)
sine := math.Sin(angle)
firstWeight = math.Sin((1-fraction)*angle) / sine
secondWeight = math.Sin(fraction*angle) / sine
}
vector := [3]float64{
firstWeight*firstVector[0] + secondWeight*secondVector[0],
firstWeight*firstVector[1] + secondWeight*secondVector[1],
firstWeight*firstVector[2] + secondWeight*secondVector[2],
}
coordinates := solarEclipseXYZToLLR(vector[0], vector[1], vector[2])
longitude := solarEclipseNormalizeSignedRadians(coordinates[0]) / rad
return SolarEclipsePathPoint{
JDE: first.JDE + fraction*(second.JDE-first.JDE),
Longitude: longitude,
Latitude: coordinates[1] / rad,
SunAltitude: first.SunAltitude + fraction*(second.SunAltitude-first.SunAltitude),
WidthKM: first.WidthKM + fraction*(second.WidthKM-first.WidthKM),
}
}
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))
}