2026-09-17 12:27:40 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 18:55:12 +08:00
|
|
|
func (solver solarEclipseSolver) centralPathPointAt(jde float64) (SolarEclipsePathPoint, bool) {
|
|
|
|
|
moon := solver.besselMoonAt(jde)
|
|
|
|
|
axis := solver.besselAxisAt(jde)
|
2026-09-17 12:27:40 +08:00
|
|
|
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)
|
2026-09-23 18:55:12 +08:00
|
|
|
sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jde, longitude, latitude, axis.gst)
|
2026-09-17 12:27:40 +08:00
|
|
|
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{
|
2026-09-23 18:55:12 +08:00
|
|
|
JDE: jde,
|
2026-09-17 12:27:40 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 18:55:12 +08:00
|
|
|
func solarEclipsePathPointFromBesselXY(jde, x, y float64, axis solarEclipseAxis) (SolarEclipsePathPoint, bool) {
|
2026-09-17 12:27:40 +08:00
|
|
|
longitude, latitude, ok := solarEclipseBesselXYToGeodetic(x, y, axis, true)
|
|
|
|
|
if !ok {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
2026-09-23 18:55:12 +08:00
|
|
|
sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jde, longitude, latitude, axis.gst)
|
2026-09-17 12:27:40 +08:00
|
|
|
return SolarEclipsePathPoint{
|
2026-09-23 18:55:12 +08:00
|
|
|
JDE: jde,
|
2026-09-17 12:27:40 +08:00
|
|
|
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(
|
2026-09-23 18:55:12 +08:00
|
|
|
jde float64,
|
2026-09-17 12:27:40 +08:00
|
|
|
kind solarEclipseShadowKind,
|
|
|
|
|
internal bool,
|
|
|
|
|
) (float64, bool) {
|
|
|
|
|
if kind == solarEclipseCentralShadow && solver.exactCentralContact && !internal {
|
2026-09-23 18:55:12 +08:00
|
|
|
return solver.shadowContactResidualExact(jde)
|
2026-09-17 12:27:40 +08:00
|
|
|
}
|
2026-09-23 18:55:12 +08:00
|
|
|
moon := solver.besselMoonAt(jde)
|
2026-09-17 12:27:40 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 18:55:12 +08:00
|
|
|
func (solver solarEclipseSolver) shadowContactResidualExact(jde float64) (float64, bool) {
|
|
|
|
|
_, value, ok := solver.shadowContactMaximum(jde)
|
2026-09-17 12:27:40 +08:00
|
|
|
return -value, ok
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 18:55:12 +08:00
|
|
|
func (solver solarEclipseSolver) shadowContactMaximum(jde float64) (float64, float64, bool) {
|
2026-09-17 12:27:40 +08:00
|
|
|
const samples = 32
|
|
|
|
|
step := 2 * math.Pi / samples
|
|
|
|
|
bestIndex := -1
|
|
|
|
|
bestValue := math.Inf(-1)
|
|
|
|
|
for index := 0; index < samples; index++ {
|
2026-09-23 18:55:12 +08:00
|
|
|
value, ok := solver.shadowBoundaryDiscriminant(jde, float64(index)*step)
|
2026-09-17 12:27:40 +08:00
|
|
|
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 {
|
2026-09-23 18:55:12 +08:00
|
|
|
value, ok := solver.shadowBoundaryDiscriminant(jde, angle)
|
2026-09-17 12:27:40 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 18:55:12 +08:00
|
|
|
func (solver solarEclipseSolver) shadowBoundaryDiscriminant(jde, angle float64) (float64, bool) {
|
|
|
|
|
moon, axis, _ := solver.besselGeometryAt(jde)
|
2026-09-17 12:27:40 +08:00
|
|
|
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(
|
2026-09-23 18:55:12 +08:00
|
|
|
jde float64,
|
2026-09-17 12:27:40 +08:00
|
|
|
kind solarEclipseShadowKind,
|
|
|
|
|
internal bool,
|
|
|
|
|
) (SolarEclipsePathPoint, bool) {
|
|
|
|
|
if kind == solarEclipseCentralShadow && solver.exactCentralContact && !internal {
|
2026-09-23 18:55:12 +08:00
|
|
|
angle, _, ok := solver.shadowContactMaximum(jde)
|
2026-09-17 12:27:40 +08:00
|
|
|
if !ok {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
2026-09-23 18:55:12 +08:00
|
|
|
if point, pointOK := solver.centralShadowPointAt(jde, angle); pointOK {
|
2026-09-17 12:27:40 +08:00
|
|
|
return point, true
|
|
|
|
|
}
|
|
|
|
|
for _, offset := range []float64{-1e-8, 1e-8, -1e-7, 1e-7} {
|
2026-09-23 18:55:12 +08:00
|
|
|
offsetJDE := jde + offset
|
2026-09-17 12:27:40 +08:00
|
|
|
offsetAngle, _, maximumOK := solver.shadowContactMaximum(offsetJDE)
|
|
|
|
|
if !maximumOK {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if point, pointOK := solver.centralShadowPointAt(offsetJDE, offsetAngle); pointOK {
|
2026-09-23 18:55:12 +08:00
|
|
|
point.JDE = jde
|
2026-09-17 12:27:40 +08:00
|
|
|
return point, true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
2026-09-23 18:55:12 +08:00
|
|
|
moon := solver.besselMoonAt(jde)
|
2026-09-17 12:27:40 +08:00
|
|
|
distance := math.Hypot(moon[0], moon[1])
|
|
|
|
|
if distance <= 0 || solver.shadowRadiusAt(moon[2], kind) <= 0 {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
2026-09-23 18:55:12 +08:00
|
|
|
axis := solver.besselAxisAt(jde)
|
2026-09-17 12:27:40 +08:00
|
|
|
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)
|
2026-09-23 18:55:12 +08:00
|
|
|
sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jde, longitude, latitude, axis.gst)
|
2026-09-17 12:27:40 +08:00
|
|
|
return SolarEclipsePathPoint{
|
2026-09-23 18:55:12 +08:00
|
|
|
JDE: jde,
|
2026-09-17 12:27:40 +08:00
|
|
|
Longitude: longitude,
|
|
|
|
|
Latitude: latitude,
|
|
|
|
|
SunAltitude: sunAltitudeRad / rad,
|
|
|
|
|
}, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (solver solarEclipseSolver) shadowRadiusAt(moonBesselZ float64, kind solarEclipseShadowKind) float64 {
|
|
|
|
|
radii := solver.shadowRadiiAt(moonBesselZ)
|
|
|
|
|
if kind == solarEclipseCentralShadow {
|
|
|
|
|
return radii.absUmbraRadius
|
|
|
|
|
}
|
|
|
|
|
return radii.penumbraRadius
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 18:55:12 +08:00
|
|
|
func (solver solarEclipseSolver) partialFootprintAt(jde float64, boundaryPoints int) SolarEclipsePartialFootprint {
|
|
|
|
|
return solver.shadowFootprintAt(jde, boundaryPoints, solarEclipsePenumbralShadow)
|
2026-09-17 12:27:40 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (solver solarEclipseSolver) shadowFootprintAt(
|
2026-09-23 18:55:12 +08:00
|
|
|
jde float64,
|
2026-09-17 12:27:40 +08:00
|
|
|
boundaryPoints int,
|
|
|
|
|
kind solarEclipseShadowKind,
|
|
|
|
|
) SolarEclipsePartialFootprint {
|
2026-09-23 18:55:12 +08:00
|
|
|
return solver.shadowFootprintAtWithSpacing(jde, boundaryPoints, kind, 0)
|
2026-09-17 12:27:40 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (solver solarEclipseSolver) shadowFootprintAtWithSpacing(
|
2026-09-23 18:55:12 +08:00
|
|
|
jde float64,
|
2026-09-17 12:27:40 +08:00
|
|
|
boundaryPoints int,
|
|
|
|
|
kind solarEclipseShadowKind,
|
|
|
|
|
targetSpacingKM float64,
|
|
|
|
|
) SolarEclipsePartialFootprint {
|
2026-09-23 18:55:12 +08:00
|
|
|
moon, axis, sun := solver.besselGeometryAt(jde)
|
2026-09-17 12:27:40 +08:00
|
|
|
return solver.shadowFootprintAtWithGeometry(
|
2026-09-23 18:55:12 +08:00
|
|
|
jde, moon, axis, sun, boundaryPoints, kind, targetSpacingKM,
|
2026-09-17 12:27:40 +08:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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))
|
|
|
|
|
}
|