1962 lines
70 KiB
Go
1962 lines
70 KiB
Go
|
|
package basic
|
||
|
|
|
||
|
|
import (
|
||
|
|
"math"
|
||
|
|
"sort"
|
||
|
|
|
||
|
|
"b612.me/astro/internal/geodata"
|
||
|
|
)
|
||
|
|
|
||
|
|
type solarEclipseCentralBandSweepSample struct {
|
||
|
|
jde float64
|
||
|
|
envelope SolarEclipsePathPoint
|
||
|
|
first SolarEclipsePathPoint
|
||
|
|
second SolarEclipsePathPoint
|
||
|
|
firstCap []SolarEclipsePathPoint
|
||
|
|
secondCap []SolarEclipsePathPoint
|
||
|
|
}
|
||
|
|
|
||
|
|
type solarEclipseCentralBandGeometry struct {
|
||
|
|
jde float64
|
||
|
|
moon [3]float64
|
||
|
|
axis solarEclipseAxis
|
||
|
|
sun [3]float64
|
||
|
|
}
|
||
|
|
|
||
|
|
// Horizon roots use topocentric local-centrality and sunrise/sunset, while
|
||
|
|
// U1/U4 are geocentric shadow contacts. Near a polar grazing event the two
|
||
|
|
// definitions can differ by a few seconds; keep that physical seam from
|
||
|
|
// rejecting an otherwise converged horizon root.
|
||
|
|
const solarEclipseCentralLimitHorizonContactMarginDays = 30.0 / 86400.0
|
||
|
|
|
||
|
|
// centralLimitHorizonRootsNearAxisContact finds the two physical intersections
|
||
|
|
// between the central limits and the local-greatest horizon curve. Seeds are
|
||
|
|
// taken from the on-Earth side of the central-axis contact, independently of
|
||
|
|
// the caller's global path sampling step.
|
||
|
|
func (solver solarEclipseSolver) centralLimitHorizonRootsNearAxisContact(
|
||
|
|
axisContactJDE, shadowContactJDE, innerContactJDE, direction float64,
|
||
|
|
) (SolarEclipsePathPoint, SolarEclipsePathPoint, bool) {
|
||
|
|
const (
|
||
|
|
seedWindowDays = 300.0 / 86400.0
|
||
|
|
seedStepDays = 15.0 / 86400.0
|
||
|
|
rootTimeEpsilon = 0.25 / 86400.0
|
||
|
|
rootDistanceKM = 0.1
|
||
|
|
)
|
||
|
|
// At a grazing contact, the two horizon roots can be many minutes apart.
|
||
|
|
// Both belong to the interval while the shadow intersects the Earth limb.
|
||
|
|
rootWindowDays := direction * (innerContactJDE - shadowContactJDE)
|
||
|
|
if rootWindowDays <= 0 {
|
||
|
|
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
|
||
|
|
}
|
||
|
|
centers := make([]SolarEclipsePathPoint, 0, int(seedWindowDays/seedStepDays))
|
||
|
|
for offset := seedStepDays; offset <= seedWindowDays+seedStepDays/2; offset += seedStepDays {
|
||
|
|
if center, ok := solver.centralPathPointAt(axisContactJDE + direction*offset); ok {
|
||
|
|
centers = append(centers, center)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
sort.Slice(centers, func(first, second int) bool { return centers[first].JDE < centers[second].JDE })
|
||
|
|
northern, southern := solver.centralPathLimits(centers)
|
||
|
|
roots := make([]SolarEclipsePathPoint, 0, 2)
|
||
|
|
for index := range northern {
|
||
|
|
for _, seed := range []SolarEclipsePathPoint{northern[index], southern[index]} {
|
||
|
|
root, ok := solveSolarEclipseCentralLimitHorizonRoot(
|
||
|
|
solver,
|
||
|
|
[3]float64{seed.Longitude, seed.Latitude, seed.JDE},
|
||
|
|
)
|
||
|
|
insideOffset := direction * (root.JDE - shadowContactJDE)
|
||
|
|
if !ok ||
|
||
|
|
insideOffset < -solarEclipseCentralLimitHorizonContactMarginDays ||
|
||
|
|
insideOffset > rootWindowDays+solarEclipseCentralLimitHorizonContactMarginDays {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
duplicate := false
|
||
|
|
for _, existing := range roots {
|
||
|
|
if math.Abs(root.JDE-existing.JDE) <= rootTimeEpsilon &&
|
||
|
|
solarEclipsePathDistanceKM(root, existing) <= rootDistanceKM {
|
||
|
|
duplicate = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !duplicate {
|
||
|
|
roots = append(roots, root)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(roots) != 2 || solarEclipsePathDistanceKM(roots[0], roots[1]) <= 0.01 {
|
||
|
|
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
|
||
|
|
}
|
||
|
|
sort.Slice(roots, func(first, second int) bool { return roots[first].JDE < roots[second].JDE })
|
||
|
|
return roots[0], roots[1], true
|
||
|
|
}
|
||
|
|
|
||
|
|
// centralLimitHorizonRootsFromCurves brackets missing grazing roots on the
|
||
|
|
// already sampled greatest-at-horizon branches, then corrects C and dC/dt.
|
||
|
|
// Axis-adjacent Newton seeds alone can converge to the opposite end of a
|
||
|
|
// polar event even when the two limits are well separated.
|
||
|
|
func (solver solarEclipseSolver) centralLimitHorizonRootsFromCurves(
|
||
|
|
curves []SolarEclipseRiseSetCurve,
|
||
|
|
firstContactJDE, lastContactJDE float64,
|
||
|
|
) ([]SolarEclipsePathPoint, RiseSetDirection) {
|
||
|
|
startJDE, endJDE := math.Min(firstContactJDE, lastContactJDE), math.Max(firstContactJDE, lastContactJDE)
|
||
|
|
if startJDE <= 0 || endJDE <= startJDE {
|
||
|
|
return nil, ""
|
||
|
|
}
|
||
|
|
roots := make([]SolarEclipsePathPoint, 0, 2)
|
||
|
|
seededDirection := RiseSetDirection("")
|
||
|
|
for _, curve := range curves {
|
||
|
|
if curve.Phase != RiseSetPhaseGreatest {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
for _, segment := range curve.Segments {
|
||
|
|
for index := 1; index < len(segment); index++ {
|
||
|
|
first, second := segment[index-1], segment[index]
|
||
|
|
if second.JDE < startJDE || first.JDE > endJDE {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
seed, ok := solver.refineNonCentralBandHorizonGapCrossing(first, second)
|
||
|
|
if !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
root, ok := solveSolarEclipseCentralLimitHorizonRoot(
|
||
|
|
solver, [3]float64{seed.Longitude, seed.Latitude, seed.JDE},
|
||
|
|
)
|
||
|
|
if !ok || root.JDE < startJDE-solarEclipseCentralLimitHorizonContactMarginDays ||
|
||
|
|
root.JDE > endJDE+solarEclipseCentralLimitHorizonContactMarginDays || solarEclipseRiseSetPointExists(roots, root) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if seededDirection == "" {
|
||
|
|
seededDirection = curve.Direction
|
||
|
|
}
|
||
|
|
roots = append(roots, root)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
sort.Slice(roots, func(first, second int) bool { return roots[first].JDE < roots[second].JDE })
|
||
|
|
return roots, seededDirection
|
||
|
|
}
|
||
|
|
|
||
|
|
// centralLimitHorizonRootsFromSampledBoundary recovers a horizon root the
|
||
|
|
// analytic seeds miss, by intersecting the sampled umbral sweep with the
|
||
|
|
// greatest-at-horizon curves. A grazing closure arc ends where the swept region
|
||
|
|
// crosses that curve, and the sweep is already available, so the crossing is a
|
||
|
|
// far better seed than the axis-adjacent Newton start that fails on these
|
||
|
|
// events.
|
||
|
|
func (solver solarEclipseSolver) centralLimitHorizonRootsFromSampledBoundary(
|
||
|
|
startJDE, endJDE, greatestJDE float64,
|
||
|
|
footprints []SolarEclipsePartialFootprint,
|
||
|
|
curves []SolarEclipseRiseSetCurve,
|
||
|
|
) ([]SolarEclipsePathPoint, RiseSetDirection) {
|
||
|
|
low, high := math.Min(startJDE, endJDE), math.Max(startJDE, endJDE)
|
||
|
|
if low <= 0 || high <= low || len(footprints) == 0 {
|
||
|
|
return nil, ""
|
||
|
|
}
|
||
|
|
// Sample slightly beyond the contact window: a closure arc can put one of
|
||
|
|
// its roots just outside it, and the sweep is cheapest here (the caller's
|
||
|
|
// footprints are reused).
|
||
|
|
margin := solarEclipseCentralLimitHorizonContactMarginDays
|
||
|
|
if span := high - low; span > 0 {
|
||
|
|
margin = math.Max(margin, span*0.15)
|
||
|
|
}
|
||
|
|
rings := solver.centralBandSampledFootprintUnionOverRange(
|
||
|
|
low-margin, high+margin, greatestJDE, footprints,
|
||
|
|
)
|
||
|
|
if len(rings) == 0 {
|
||
|
|
return nil, ""
|
||
|
|
}
|
||
|
|
// A grazing closure arc meets the sweep almost tangentially, so an exact
|
||
|
|
// segment crossing is not reliable; every seed below is refined by the same
|
||
|
|
// Newton correction and then judged by the contact window.
|
||
|
|
seeds := solarEclipseHorizonCurveSeeds(curves, low, high)
|
||
|
|
seeds = append(seeds, solarEclipseHorizonBoundarySeeds(rings, curves, greatestJDE)...)
|
||
|
|
roots := make([]SolarEclipsePathPoint, 0, 2)
|
||
|
|
seededDirection := RiseSetDirection("")
|
||
|
|
for _, seed := range seeds {
|
||
|
|
root, ok := solveSolarEclipseCentralLimitHorizonRoot(
|
||
|
|
solver, [3]float64{seed.point.Longitude, seed.point.Latitude, seed.point.JDE},
|
||
|
|
)
|
||
|
|
if !ok ||
|
||
|
|
root.JDE < low-solarEclipseCentralLimitHorizonContactMarginDays ||
|
||
|
|
root.JDE > high+solarEclipseCentralLimitHorizonContactMarginDays ||
|
||
|
|
solarEclipseRiseSetPointExists(roots, root) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if seededDirection == "" {
|
||
|
|
seededDirection = seed.direction
|
||
|
|
}
|
||
|
|
roots = append(roots, root)
|
||
|
|
}
|
||
|
|
sort.Slice(roots, func(first, second int) bool { return roots[first].JDE < roots[second].JDE })
|
||
|
|
return roots, seededDirection
|
||
|
|
}
|
||
|
|
|
||
|
|
// solarEclipseHorizonSeed is a starting point for the closure-root correction,
|
||
|
|
// carrying the horizon direction of the curve it came from.
|
||
|
|
type solarEclipseHorizonSeed struct {
|
||
|
|
point SolarEclipsePathPoint
|
||
|
|
direction RiseSetDirection
|
||
|
|
}
|
||
|
|
|
||
|
|
// solarEclipseHorizonCurveSeeds seeds from the sampled greatest-at-horizon
|
||
|
|
// branches that lie inside the window, including their endpoints: a sampled
|
||
|
|
// branch can stop right at the closure root (1136-06-01 loses its second root
|
||
|
|
// that way).
|
||
|
|
func solarEclipseHorizonCurveSeeds(
|
||
|
|
curves []SolarEclipseRiseSetCurve,
|
||
|
|
low, high float64,
|
||
|
|
) []solarEclipseHorizonSeed {
|
||
|
|
var seeds []solarEclipseHorizonSeed
|
||
|
|
for _, curve := range curves {
|
||
|
|
if curve.Phase != RiseSetPhaseGreatest {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
for _, segment := range curve.Segments {
|
||
|
|
if len(segment) == 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
for _, endpoint := range []SolarEclipsePathPoint{segment[0], segment[len(segment)-1]} {
|
||
|
|
if endpoint.JDE >= low && endpoint.JDE <= high {
|
||
|
|
seeds = append(seeds, solarEclipseHorizonSeed{point: endpoint, direction: curve.Direction})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return seeds
|
||
|
|
}
|
||
|
|
|
||
|
|
// solarEclipseHorizonBoundarySeeds seeds from the sampled sweep boundary: the
|
||
|
|
// closest approach between a boundary segment and a horizon segment, and the
|
||
|
|
// ends of every stretch of boundary that hugs the curve. A shallow polar band
|
||
|
|
// can run along the curve for hundreds of kilometres, and its closure endpoints
|
||
|
|
// are where it enters and leaves that stretch rather than a closest approach
|
||
|
|
// along it.
|
||
|
|
func solarEclipseHorizonBoundarySeeds(
|
||
|
|
rings [][]SolarEclipsePathPoint,
|
||
|
|
curves []SolarEclipseRiseSetCurve,
|
||
|
|
greatestJDE float64,
|
||
|
|
) []solarEclipseHorizonSeed {
|
||
|
|
var seeds []solarEclipseHorizonSeed
|
||
|
|
for _, ring := range rings {
|
||
|
|
if len(ring) < 3 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
near, directions := solarEclipseBoundaryNearRuns(ring, curves, greatestJDE)
|
||
|
|
for index := range ring {
|
||
|
|
previous := (index + len(ring) - 1) % len(ring)
|
||
|
|
if near[index] != near[previous] {
|
||
|
|
entry := index
|
||
|
|
if !near[index] {
|
||
|
|
entry = previous
|
||
|
|
}
|
||
|
|
seeds = append(seeds, solarEclipseHorizonSeed{
|
||
|
|
point: SolarEclipsePathPoint{
|
||
|
|
JDE: greatestJDE,
|
||
|
|
Longitude: ring[entry].Longitude,
|
||
|
|
Latitude: ring[entry].Latitude,
|
||
|
|
},
|
||
|
|
direction: directions[entry],
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for index := 0; index+1 < len(ring); index++ {
|
||
|
|
first, second := ring[index], ring[index+1]
|
||
|
|
for _, curve := range curves {
|
||
|
|
if curve.Phase != RiseSetPhaseGreatest {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
for _, segment := range curve.Segments {
|
||
|
|
for position := 0; position+1 < len(segment); position++ {
|
||
|
|
seed, ok := closestSolarEclipseBandApproach(
|
||
|
|
first, second, segment[position], segment[position+1],
|
||
|
|
solarEclipseCentralLimitHorizonContactSeedKM,
|
||
|
|
)
|
||
|
|
if ok {
|
||
|
|
seeds = append(seeds, solarEclipseHorizonSeed{point: seed, direction: curve.Direction})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return seeds
|
||
|
|
}
|
||
|
|
|
||
|
|
// solarEclipseBoundaryNearRuns marks the boundary vertices that sit within the
|
||
|
|
// seed tolerance of a greatest-at-horizon curve, with that curve's direction.
|
||
|
|
func solarEclipseBoundaryNearRuns(
|
||
|
|
ring []SolarEclipsePathPoint,
|
||
|
|
curves []SolarEclipseRiseSetCurve,
|
||
|
|
greatestJDE float64,
|
||
|
|
) ([]bool, []RiseSetDirection) {
|
||
|
|
near := make([]bool, len(ring))
|
||
|
|
directions := make([]RiseSetDirection, len(ring))
|
||
|
|
for index, vertex := range ring {
|
||
|
|
direction, ok := solarEclipseGreatestCurveWithin(curves, SolarEclipsePathPoint{
|
||
|
|
JDE: greatestJDE, Longitude: vertex.Longitude, Latitude: vertex.Latitude,
|
||
|
|
}, solarEclipseCentralLimitHorizonContactSeedKM)
|
||
|
|
if !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
near[index] = true
|
||
|
|
directions[index] = direction
|
||
|
|
}
|
||
|
|
return near, directions
|
||
|
|
}
|
||
|
|
|
||
|
|
// solarEclipseGreatestCurveWithin reports the horizon direction of the sampled
|
||
|
|
// greatest-at-horizon point closest to one location, when it lies within the
|
||
|
|
// tolerance. A grazing closure root lies on one of those curves by
|
||
|
|
// construction, so the nearest sample already knows whether the Sun is rising
|
||
|
|
// or setting there, which the local classification cannot always reproduce.
|
||
|
|
func solarEclipseGreatestCurveWithin(
|
||
|
|
curves []SolarEclipseRiseSetCurve,
|
||
|
|
point SolarEclipsePathPoint,
|
||
|
|
toleranceKM float64,
|
||
|
|
) (RiseSetDirection, bool) {
|
||
|
|
bestDistance := toleranceKM
|
||
|
|
direction := RiseSetDirection("")
|
||
|
|
for _, curve := range curves {
|
||
|
|
if curve.Phase != RiseSetPhaseGreatest || curve.Direction == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
for _, segment := range curve.Segments {
|
||
|
|
for _, sample := range segment {
|
||
|
|
distance := solarEclipsePathDistanceKM(point, sample)
|
||
|
|
if distance <= bestDistance {
|
||
|
|
bestDistance = distance
|
||
|
|
direction = curve.Direction
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return direction, direction != ""
|
||
|
|
}
|
||
|
|
|
||
|
|
// solarEclipseNearestGreatestDirection reports the horizon direction of the
|
||
|
|
// sampled greatest-at-horizon curve closest to one closure root, at any
|
||
|
|
// distance.
|
||
|
|
func solarEclipseNearestGreatestDirection(
|
||
|
|
curves []SolarEclipseRiseSetCurve,
|
||
|
|
point SolarEclipsePathPoint,
|
||
|
|
) RiseSetDirection {
|
||
|
|
direction, _ := solarEclipseGreatestCurveWithin(curves, point, math.Inf(1))
|
||
|
|
return direction
|
||
|
|
}
|
||
|
|
|
||
|
|
// solarEclipseCentralLimitHorizonContactSeedKM bounds how far a sampled sweep
|
||
|
|
// boundary may sit from a horizon curve and still seed a closure root.
|
||
|
|
const solarEclipseCentralLimitHorizonContactSeedKM = 40.0
|
||
|
|
|
||
|
|
// closestSolarEclipseBandApproach returns the point of the horizon segment that
|
||
|
|
// comes closest to one band segment, when the two nearly touch. A grazing
|
||
|
|
// closure arc leaves the swept region almost tangentially, so requiring an
|
||
|
|
// exact crossing would miss it.
|
||
|
|
func closestSolarEclipseBandApproach(
|
||
|
|
first, second, horizonFirst, horizonSecond SolarEclipsePathPoint,
|
||
|
|
toleranceKM float64,
|
||
|
|
) (SolarEclipsePathPoint, bool) {
|
||
|
|
scale := math.Cos(horizonFirst.Latitude * math.Pi / 180)
|
||
|
|
ax := math.Remainder(first.Longitude-horizonFirst.Longitude, 360) * scale
|
||
|
|
ay := first.Latitude - horizonFirst.Latitude
|
||
|
|
bx := math.Remainder(second.Longitude-horizonFirst.Longitude, 360) * scale
|
||
|
|
by := second.Latitude - horizonFirst.Latitude
|
||
|
|
dx := math.Remainder(horizonSecond.Longitude-horizonFirst.Longitude, 360) * scale
|
||
|
|
dy := horizonSecond.Latitude - horizonFirst.Latitude
|
||
|
|
length := dx*dx + dy*dy
|
||
|
|
if length <= 0 {
|
||
|
|
return SolarEclipsePathPoint{}, false
|
||
|
|
}
|
||
|
|
bestDistance := math.Inf(1)
|
||
|
|
bestFraction := 0.0
|
||
|
|
for step := 0; step <= 8; step++ {
|
||
|
|
bandFraction := float64(step) / 8
|
||
|
|
pointX := ax + bandFraction*(bx-ax)
|
||
|
|
pointY := ay + bandFraction*(by-ay)
|
||
|
|
fraction := math.Max(0, math.Min(1, (pointX*dx+pointY*dy)/length))
|
||
|
|
distance := math.Hypot(pointX-fraction*dx, pointY-fraction*dy)
|
||
|
|
if distance < bestDistance {
|
||
|
|
bestDistance = distance
|
||
|
|
bestFraction = fraction
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if bestDistance*111.32 > toleranceKM {
|
||
|
|
return SolarEclipsePathPoint{}, false
|
||
|
|
}
|
||
|
|
return SolarEclipsePathPoint{
|
||
|
|
JDE: horizonFirst.JDE + bestFraction*(horizonSecond.JDE-horizonFirst.JDE),
|
||
|
|
Longitude: normalizeLongitude(horizonFirst.Longitude + bestFraction*math.Remainder(horizonSecond.Longitude-horizonFirst.Longitude, 360)),
|
||
|
|
Latitude: horizonFirst.Latitude + bestFraction*(horizonSecond.Latitude-horizonFirst.Latitude),
|
||
|
|
}, true
|
||
|
|
}
|
||
|
|
|
||
|
|
// magnitudeOneHorizonRoots pairs the early or late endpoints of the two
|
||
|
|
// complete totality branches. Approximate shadow-contact times must not clip
|
||
|
|
// these independently solved topocentric horizon roots.
|
||
|
|
func magnitudeOneHorizonRoots(
|
||
|
|
segments [][]SolarEclipsePathPoint,
|
||
|
|
direction float64,
|
||
|
|
) (SolarEclipsePathPoint, SolarEclipsePathPoint, bool) {
|
||
|
|
if len(segments) != 2 || (direction != 1 && direction != -1) {
|
||
|
|
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
|
||
|
|
}
|
||
|
|
var roots [2]SolarEclipsePathPoint
|
||
|
|
horizon := [2]SolarEclipsePathPoint{}
|
||
|
|
horizonAvailable := [2]bool{}
|
||
|
|
for index, segment := range segments {
|
||
|
|
if len(segment) < 2 {
|
||
|
|
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
|
||
|
|
}
|
||
|
|
first, last := segment[0], segment[len(segment)-1]
|
||
|
|
if !finite(first.JDE) || !finite(first.Longitude) || !finite(first.Latitude) || !finite(first.SunAltitude) ||
|
||
|
|
!finite(last.JDE) || !finite(last.Longitude) || !finite(last.Latitude) || !finite(last.SunAltitude) {
|
||
|
|
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
|
||
|
|
}
|
||
|
|
if first.JDE > last.JDE {
|
||
|
|
first, last = last, first
|
||
|
|
}
|
||
|
|
if last.JDE-first.JDE <= solarEclipsePathDuplicateTimeDays {
|
||
|
|
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
|
||
|
|
}
|
||
|
|
if math.Abs(first.SunAltitude) <= 1e-5 {
|
||
|
|
horizon[index], horizonAvailable[index] = first, true
|
||
|
|
} else if math.Abs(last.SunAltitude) <= 1e-5 {
|
||
|
|
horizon[index], horizonAvailable[index] = last, true
|
||
|
|
}
|
||
|
|
candidate := first
|
||
|
|
if direction < 0 {
|
||
|
|
candidate = last
|
||
|
|
}
|
||
|
|
// A very shallow polar branch can terminate at the contour's
|
||
|
|
// interior sampling limit instead of the horizon. In that case the
|
||
|
|
// opposite endpoint is the physical horizon root.
|
||
|
|
if math.Abs(candidate.SunAltitude) > 1e-5 {
|
||
|
|
alternate := last
|
||
|
|
if candidate.JDE == last.JDE {
|
||
|
|
alternate = first
|
||
|
|
}
|
||
|
|
if math.Abs(alternate.SunAltitude) > 1e-5 {
|
||
|
|
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
|
||
|
|
}
|
||
|
|
candidate = alternate
|
||
|
|
}
|
||
|
|
roots[index] = candidate
|
||
|
|
}
|
||
|
|
// Extremely shallow two-limit events can expose only one horizon endpoint
|
||
|
|
// on each complete magnitude-one branch. Those two endpoints are the
|
||
|
|
// physical pair for both temporal closures.
|
||
|
|
if horizonAvailable[0] && horizonAvailable[1] {
|
||
|
|
if math.Abs(roots[0].SunAltitude) > 1e-5 || math.Abs(roots[1].SunAltitude) > 1e-5 {
|
||
|
|
roots = horizon
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if solarEclipsePathDistanceKM(roots[0], roots[1]) <= 0.01 {
|
||
|
|
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
|
||
|
|
}
|
||
|
|
if roots[0].JDE > roots[1].JDE {
|
||
|
|
roots[0], roots[1] = roots[1], roots[0]
|
||
|
|
}
|
||
|
|
return roots[0], roots[1], true
|
||
|
|
}
|
||
|
|
|
||
|
|
func (solver solarEclipseSolver) centralBandHorizonClosure(
|
||
|
|
firstRoot, lastRoot SolarEclipsePathPoint,
|
||
|
|
direction RiseSetDirection,
|
||
|
|
curves []SolarEclipseRiseSetCurve,
|
||
|
|
) []SolarEclipsePathPoint {
|
||
|
|
closure := []SolarEclipsePathPoint{firstRoot}
|
||
|
|
var selected []SolarEclipsePathPoint
|
||
|
|
bestScore := math.Inf(1)
|
||
|
|
for _, curve := range curves {
|
||
|
|
if curve.Phase != RiseSetPhaseGreatest || curve.Direction != direction {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
for _, segment := range curve.Segments {
|
||
|
|
if len(segment) < 2 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
first, firstOK := solarEclipseHorizonSegmentPointAt(segment, firstRoot.JDE)
|
||
|
|
last, lastOK := solarEclipseHorizonSegmentPointAt(segment, lastRoot.JDE)
|
||
|
|
if !firstOK || !lastOK {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
// A phase can have several disconnected horizon branches with the
|
||
|
|
// same time range. Select the branch that actually joins both solved
|
||
|
|
// central-limit roots; concatenating every time-overlapping branch
|
||
|
|
// creates the large 2010/2056 endpoint folds seen on the map.
|
||
|
|
score := solarEclipsePathDistanceKM(first, firstRoot) +
|
||
|
|
solarEclipsePathDistanceKM(last, lastRoot)
|
||
|
|
if score >= bestScore {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
bestScore = score
|
||
|
|
selected = segment
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(selected) > 0 {
|
||
|
|
for _, point := range selected {
|
||
|
|
if point.JDE > firstRoot.JDE && point.JDE < lastRoot.JDE {
|
||
|
|
closure = append(closure, point)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
closure = append(closure, lastRoot)
|
||
|
|
closure = deduplicateSolarEclipsePathPoints(closure)
|
||
|
|
refined := make([]SolarEclipsePathPoint, 1, len(closure))
|
||
|
|
refined[0] = closure[0]
|
||
|
|
for index := 1; index < len(closure); index++ {
|
||
|
|
refined = solver.appendRefinedSolarEclipseCentralHorizonSegment(
|
||
|
|
refined, closure[index-1], closure[index], 0,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
return deduplicateSolarEclipsePathPoints(refined)
|
||
|
|
}
|
||
|
|
|
||
|
|
// alignSolarEclipseCentralBandHorizonClosures makes the public greatest-at-
|
||
|
|
// horizon curves share the exact arcs used to close a two-limit central band.
|
||
|
|
// Without this replacement, the band uses refined roots while the rendered
|
||
|
|
// greatest curve keeps its coarser samples, leaving a visible seam at both
|
||
|
|
// endpoints even though the two calculations describe the same boundary.
|
||
|
|
func (solver solarEclipseSolver) alignSolarEclipseCentralBandHorizonClosures(
|
||
|
|
curves []SolarEclipseRiseSetCurve,
|
||
|
|
closures [][]SolarEclipsePathPoint,
|
||
|
|
) {
|
||
|
|
for _, closure := range closures {
|
||
|
|
if len(closure) < 2 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
root := closure[0]
|
||
|
|
_, key, ok := solver.magnitudeEvaluationAt(root.JDE).classify(root.Longitude, root.Latitude, true)
|
||
|
|
if ok {
|
||
|
|
alignSolarEclipseCentralBandHorizonClosure(curves, closure, key.direction)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func alignSolarEclipseCentralBandHorizonClosure(
|
||
|
|
curves []SolarEclipseRiseSetCurve,
|
||
|
|
closure []SolarEclipsePathPoint,
|
||
|
|
direction RiseSetDirection,
|
||
|
|
) {
|
||
|
|
if len(closure) < 2 {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
ordered := append([]SolarEclipsePathPoint(nil), closure...)
|
||
|
|
if ordered[0].JDE > ordered[len(ordered)-1].JDE {
|
||
|
|
for left, right := 0, len(ordered)-1; left < right; left, right = left+1, right-1 {
|
||
|
|
ordered[left], ordered[right] = ordered[right], ordered[left]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
start, end := ordered[0], ordered[len(ordered)-1]
|
||
|
|
bestCurve, bestSegment := -1, -1
|
||
|
|
bestScore := math.Inf(1)
|
||
|
|
for curveIndex := range curves {
|
||
|
|
curve := &curves[curveIndex]
|
||
|
|
if curve.Phase != RiseSetPhaseGreatest || curve.Direction != direction {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
for segmentIndex, segment := range curve.Segments {
|
||
|
|
if len(segment) < 2 ||
|
||
|
|
start.JDE < segment[0].JDE-solarEclipseRiseSetTimeEpsilonDays ||
|
||
|
|
end.JDE > segment[len(segment)-1].JDE+solarEclipseRiseSetTimeEpsilonDays {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
segmentStart, startOK := solarEclipseHorizonSegmentPointAt(segment, start.JDE)
|
||
|
|
segmentEnd, endOK := solarEclipseHorizonSegmentPointAt(segment, end.JDE)
|
||
|
|
if !startOK || !endOK {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
score := solarEclipsePathDistanceKM(segmentStart, start) +
|
||
|
|
solarEclipsePathDistanceKM(segmentEnd, end)
|
||
|
|
if score < bestScore {
|
||
|
|
bestCurve, bestSegment, bestScore = curveIndex, segmentIndex, score
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if bestCurve < 0 || bestScore > 2*solarEclipseRiseSetTargetSpacingKM {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
segment := curves[bestCurve].Segments[bestSegment]
|
||
|
|
joined := make([]SolarEclipsePathPoint, 0, len(segment)+len(ordered))
|
||
|
|
for _, point := range segment {
|
||
|
|
if point.JDE < start.JDE-solarEclipseRiseSetTimeEpsilonDays {
|
||
|
|
joined = append(joined, point)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
joined = append(joined, ordered...)
|
||
|
|
for _, point := range segment {
|
||
|
|
if point.JDE > end.JDE+solarEclipseRiseSetTimeEpsilonDays {
|
||
|
|
joined = append(joined, point)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
curves[bestCurve].Segments[bestSegment] = deduplicateSolarEclipsePathPoints(joined)
|
||
|
|
}
|
||
|
|
|
||
|
|
func solarEclipseHorizonSegmentPointAt(
|
||
|
|
segment []SolarEclipsePathPoint, jde float64,
|
||
|
|
) (SolarEclipsePathPoint, bool) {
|
||
|
|
if len(segment) < 2 {
|
||
|
|
return SolarEclipsePathPoint{}, false
|
||
|
|
}
|
||
|
|
best := SolarEclipsePathPoint{}
|
||
|
|
bestDistance := math.Inf(1)
|
||
|
|
for index := 1; index < len(segment); index++ {
|
||
|
|
first, second := segment[index-1], segment[index]
|
||
|
|
if (jde < first.JDE && jde < second.JDE) || (jde > first.JDE && jde > second.JDE) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
fraction := 0.0
|
||
|
|
if second.JDE != first.JDE {
|
||
|
|
fraction = (jde - first.JDE) / (second.JDE - first.JDE)
|
||
|
|
}
|
||
|
|
fraction = math.Max(0, math.Min(1, fraction))
|
||
|
|
candidate := solarEclipsePathSphericalInterpolate(first, second, fraction)
|
||
|
|
candidate.JDE = jde
|
||
|
|
return candidate, true
|
||
|
|
}
|
||
|
|
for _, point := range segment {
|
||
|
|
if distance := math.Abs(point.JDE - jde); distance < bestDistance {
|
||
|
|
best, bestDistance = point, distance
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if bestDistance > 10.0/1440.0 {
|
||
|
|
return SolarEclipsePathPoint{}, false
|
||
|
|
}
|
||
|
|
best.JDE = jde
|
||
|
|
return best, true
|
||
|
|
}
|
||
|
|
|
||
|
|
func solveSolarEclipseCentralLimitHorizonRoot(
|
||
|
|
solver solarEclipseSolver, coordinates [3]float64,
|
||
|
|
) (SolarEclipsePathPoint, bool) {
|
||
|
|
for iteration := 0; iteration < 24; iteration++ {
|
||
|
|
residual, jacobian, ok := solarEclipseCentralLimitHorizonJacobian(
|
||
|
|
solver, coordinates, solver.magnitudeEvaluationAt,
|
||
|
|
)
|
||
|
|
if !ok {
|
||
|
|
return SolarEclipsePathPoint{}, false
|
||
|
|
}
|
||
|
|
if solarEclipseCentralLimitHorizonConverged(residual) {
|
||
|
|
if !finite(coordinates[0]) || !finite(coordinates[1]) || !finite(coordinates[2]) ||
|
||
|
|
coordinates[1] < -90 || coordinates[1] > 90 {
|
||
|
|
return SolarEclipsePathPoint{}, false
|
||
|
|
}
|
||
|
|
evaluation := solver.magnitudeEvaluationAt(coordinates[2])
|
||
|
|
state := evaluation.center.stateAt(coordinates[0]*rad, coordinates[1]*rad, 0)
|
||
|
|
if evaluation.centralContactSecondDerivative(coordinates[0], coordinates[1]) <= 0 {
|
||
|
|
return SolarEclipsePathPoint{}, false
|
||
|
|
}
|
||
|
|
return SolarEclipsePathPoint{JDE: coordinates[2], Longitude: normalizeLongitude(coordinates[0]), Latitude: coordinates[1], SunAltitude: state.sunAltitudeRad / rad}, true
|
||
|
|
}
|
||
|
|
delta, ok := solveSolarEclipse3x3(jacobian, [3]float64{-residual[0], -residual[1], -residual[2]})
|
||
|
|
if !ok {
|
||
|
|
return SolarEclipsePathPoint{}, false
|
||
|
|
}
|
||
|
|
for index := range coordinates {
|
||
|
|
coordinates[index] += delta[index]
|
||
|
|
}
|
||
|
|
coordinates[0] = normalizeLongitude(coordinates[0])
|
||
|
|
}
|
||
|
|
return SolarEclipsePathPoint{}, false
|
||
|
|
}
|
||
|
|
|
||
|
|
func solarEclipseCentralLimitHorizonConverged(residual [3]float64) bool {
|
||
|
|
return math.Abs(residual[0]) < 1e-9 && math.Abs(residual[1]) < 1e-7 && math.Abs(residual[2]) < 1e-9
|
||
|
|
}
|
||
|
|
|
||
|
|
func solarEclipseCentralLimitHorizonJacobian(
|
||
|
|
solver solarEclipseSolver, coordinates [3]float64,
|
||
|
|
evaluate func(float64) solarEclipseRiseSetEvaluation,
|
||
|
|
) ([3]float64, [3][3]float64, bool) {
|
||
|
|
steps := [3]float64{1e-5, 1e-5, 1.0 / 86400.0}
|
||
|
|
// 经度、纬度两列的差分不改变时刻,三处残差共用同一时刻的星历态;只有时间列要换时刻。
|
||
|
|
centerEvaluation := evaluate(coordinates[2])
|
||
|
|
evaluations := [4]solarEclipseRiseSetEvaluation{
|
||
|
|
centerEvaluation, centerEvaluation, centerEvaluation, evaluate(coordinates[2] + steps[2]),
|
||
|
|
}
|
||
|
|
valueAt := func(evaluation solarEclipseRiseSetEvaluation, longitude, latitude float64) ([3]float64, bool) {
|
||
|
|
state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0)
|
||
|
|
return [3]float64{solarEclipseCentralContactGap(state), evaluation.centralContactDerivative(longitude, latitude), state.sunAltitudeRad}, finite(state.sunAltitudeRad)
|
||
|
|
}
|
||
|
|
residual, ok := valueAt(evaluations[0], coordinates[0], coordinates[1])
|
||
|
|
if !ok {
|
||
|
|
return [3]float64{}, [3][3]float64{}, false
|
||
|
|
}
|
||
|
|
jacobian := [3][3]float64{}
|
||
|
|
for column, step := range steps {
|
||
|
|
shifted := coordinates
|
||
|
|
shifted[column] += step
|
||
|
|
value, ok := valueAt(evaluations[column+1], shifted[0], shifted[1])
|
||
|
|
if !ok {
|
||
|
|
return [3]float64{}, [3][3]float64{}, false
|
||
|
|
}
|
||
|
|
for row := range residual {
|
||
|
|
jacobian[row][column] = (value[row] - residual[row]) / step
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return residual, jacobian, true
|
||
|
|
}
|
||
|
|
|
||
|
|
// centralBandSweepPolygons combines the critical envelope with the part of the
|
||
|
|
// local-greatest horizon arc that lies inside the central-contact condition.
|
||
|
|
func (solver solarEclipseSolver) centralBandSweepPolygons(
|
||
|
|
startJDE, endJDE, greatestJDE float64,
|
||
|
|
riseSetCurves []SolarEclipseRiseSetCurve,
|
||
|
|
) ([][]SolarEclipsePathPoint, []SolarEclipsePathPoint, bool) {
|
||
|
|
return solver.centralBandSweepPolygonsReusing(startJDE, endJDE, greatestJDE, riseSetCurves, nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
// centralBandSweepPolygonsReusing is centralBandSweepPolygons with the caller's
|
||
|
|
// already solved instantaneous footprints, so the sampled reconstruction does
|
||
|
|
// not solve them a second time when the analytic region fails.
|
||
|
|
func (solver solarEclipseSolver) centralBandSweepPolygonsReusing(
|
||
|
|
startJDE, endJDE, greatestJDE float64,
|
||
|
|
riseSetCurves []SolarEclipseRiseSetCurve,
|
||
|
|
existing []SolarEclipsePartialFootprint,
|
||
|
|
) ([][]SolarEclipsePathPoint, []SolarEclipsePathPoint, bool) {
|
||
|
|
times, _ := solarEclipsePathSampleTimes(
|
||
|
|
startJDE, endJDE, greatestJDE, solarEclipseCentralBandStepDays,
|
||
|
|
)
|
||
|
|
times = solarEclipseCentralBandContactSampleTimes(times, startJDE, endJDE)
|
||
|
|
samples := make([]solarEclipseCentralBandSweepSample, 0, len(times))
|
||
|
|
for _, jd := range times {
|
||
|
|
sample, ok := solver.centralBandSweepSampleAt(jd)
|
||
|
|
if ok {
|
||
|
|
samples = append(samples, sample)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(samples) >= 2 {
|
||
|
|
refined := solver.refineCentralBandSweepSamples(samples)
|
||
|
|
polygon, horizon := solver.nonCentralBandRegion(refined, riseSetCurves, greatestJDE)
|
||
|
|
if len(polygon) >= 4 {
|
||
|
|
return [][]SolarEclipsePathPoint{polygon}, horizon, false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return solver.centralBandSampledFootprintUnion(times, existing, greatestJDE), nil, true
|
||
|
|
}
|
||
|
|
|
||
|
|
// centralBandSampledFootprintUnionOverRange rebuilds the sampled-footprint
|
||
|
|
// union for the complete central-contact interval of one event.
|
||
|
|
func (solver solarEclipseSolver) centralBandSampledFootprintUnionOverRange(
|
||
|
|
startJDE, endJDE, greatestJDE float64,
|
||
|
|
existing []SolarEclipsePartialFootprint,
|
||
|
|
) [][]SolarEclipsePathPoint {
|
||
|
|
times, _ := solarEclipsePathSampleTimes(
|
||
|
|
startJDE, endJDE, greatestJDE, solarEclipseCentralBandStepDays,
|
||
|
|
)
|
||
|
|
times = solarEclipseCentralBandContactSampleTimes(times, startJDE, endJDE)
|
||
|
|
return solver.centralBandSampledFootprintUnion(times, existing, greatestJDE)
|
||
|
|
}
|
||
|
|
|
||
|
|
// A grazing central event can keep part of the umbral/antumbral rim off the
|
||
|
|
// Earth over the whole path: every instantaneous footprint then has an open
|
||
|
|
// arc over the outer samples and a fully closed rim over the middle. The
|
||
|
|
// analytic envelope and the open-arc sweep both describe only part of that
|
||
|
|
// shape (and the closed middle samples carry no open arc at all), so the static
|
||
|
|
// band is rebuilt from the instantaneous footprints themselves. The union of
|
||
|
|
// every sampled footprint is the central band by definition; a vertex spacing
|
||
|
|
// keeps the spherical union affordable.
|
||
|
|
const (
|
||
|
|
// The union is a coverage reconstruction, not a display-resolution trace:
|
||
|
|
// its rings are only ever unioned and exported, so they are sampled well
|
||
|
|
// below the footprint resolution. Neighbouring grazing footprints each
|
||
|
|
// extend for a thousand kilometres, so the temporal stride only has to be
|
||
|
|
// shorter than the along-track overlap; the contact ends keep every sample.
|
||
|
|
solarEclipseCentralBandUnionBoundaryPoints = 90
|
||
|
|
solarEclipseCentralBandUnionMinStepSeconds = 10.0
|
||
|
|
// The union needs a new footprint only once the previous one has moved far
|
||
|
|
// enough for the envelope between them to stay resolved, so the stride
|
||
|
|
// follows the shadow's ground speed instead of the clock: a grazing event
|
||
|
|
// spends most of its contact interval creeping across the terminator, and a
|
||
|
|
// fixed stride over-samples exactly there. The contact anchors below keep
|
||
|
|
// the flared ends at full resolution.
|
||
|
|
// The advance target matches the union spacing: the caller already samples
|
||
|
|
// the contact interval at roughly this step, and a coarser stride measurably
|
||
|
|
// opens the sweep (1042-06-20 leaves its umbral sweep by 86 km at 20 km).
|
||
|
|
// The rule therefore only bites when a caller passes a sparse grid.
|
||
|
|
solarEclipseCentralBandUnionAdvanceKM = 4.0
|
||
|
|
solarEclipseCentralBandUnionMaxStepSeconds = 60.0
|
||
|
|
solarEclipseCentralBandUnionAnchorSamples = 3
|
||
|
|
solarEclipseCentralBandUnionSpacingKM = 10.0
|
||
|
|
// The caller already solved the contact-interval footprints; a sample within
|
||
|
|
// this tolerance of one of them is reused instead of solved again, so only
|
||
|
|
// the interval the caller skipped is actually computed here.
|
||
|
|
solarEclipseCentralBandUnionReuseDays = 6.0 / 86400.0
|
||
|
|
// A decimated union is not an analytic envelope, so the reconstructed band
|
||
|
|
// may cut inside the sharpest tip by the union spacing instead of the
|
||
|
|
// footprint tolerance. The caller validates it with this bound.
|
||
|
|
solarEclipseCentralBandUnionContainmentToleranceKM = 40.0
|
||
|
|
// Each pair of neighbouring instantaneous rims meets in a shallow cusp, and
|
||
|
|
// where the rims run nearly parallel the union alternates between them and
|
||
|
|
// reads as a staircase even though the physical envelope is smooth. Many
|
||
|
|
// lambda|mu passes flatten that sampling sweep without the systematic inward
|
||
|
|
// shrink a plain Laplacian or a wide quadratic fit would introduce.
|
||
|
|
solarEclipseCentralBandUnionSmoothPasses = 30
|
||
|
|
solarEclipseCentralBandUnionSmoothLambda = 0.5
|
||
|
|
solarEclipseCentralBandUnionSmoothMu = -0.53
|
||
|
|
// A spherical union leaves nodes where two rims nearly touch: vertices a few
|
||
|
|
// hundred metres apart that carry no geometry but read as 150-degree spikes.
|
||
|
|
solarEclipseCentralBandUnionMinVertexSpacingKM = 1.0
|
||
|
|
// The relaxation can pull two nearby vertices onto opposite sides of the
|
||
|
|
// underlying curve, which turns a harmless pair into a spike. A second,
|
||
|
|
// coarser cleanup after the filter removes those pairs.
|
||
|
|
solarEclipseCentralBandUnionPostSmoothSpacingKM = 3.0
|
||
|
|
)
|
||
|
|
|
||
|
|
// solarEclipseRingCenter returns the mean position of one closed ring.
|
||
|
|
func solarEclipseRingCenter(ring []geodata.GeoPoint) geodata.GeoPoint {
|
||
|
|
if len(ring) == 0 {
|
||
|
|
return geodata.GeoPoint{}
|
||
|
|
}
|
||
|
|
longitude, latitude := 0.0, 0.0
|
||
|
|
reference := ring[0].Longitude
|
||
|
|
for _, point := range ring {
|
||
|
|
longitude += reference + math.Remainder(point.Longitude-reference, 360)
|
||
|
|
latitude += point.Latitude
|
||
|
|
}
|
||
|
|
total := float64(len(ring))
|
||
|
|
return geodata.GeoPoint{
|
||
|
|
Longitude: normalizeLongitude(longitude / total),
|
||
|
|
Latitude: latitude / total,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// centralBandSampledFootprintUnion samples the instantaneous central-shadow
|
||
|
|
// footprint over the complete central-contact interval and returns their
|
||
|
|
// spherical union as closed rings.
|
||
|
|
func (solver solarEclipseSolver) centralBandSampledFootprintUnion(
|
||
|
|
times []float64,
|
||
|
|
existing []SolarEclipsePartialFootprint,
|
||
|
|
greatestJDE float64,
|
||
|
|
) [][]SolarEclipsePathPoint {
|
||
|
|
rings := make([][]geodata.GeoPoint, 0, len(times))
|
||
|
|
ringJDE := make(map[geodata.GeoPoint]float64, len(times)*8)
|
||
|
|
solved := append([]SolarEclipsePartialFootprint(nil), existing...)
|
||
|
|
sort.Slice(solved, func(first, second int) bool { return solved[first].JDE < solved[second].JDE })
|
||
|
|
existingIndex := 0
|
||
|
|
lastKept := 0.0
|
||
|
|
var lastCenter geodata.GeoPoint
|
||
|
|
speedKMperSecond := 0.0
|
||
|
|
for index, jd := range times {
|
||
|
|
anchored := index < solarEclipseCentralBandUnionAnchorSamples ||
|
||
|
|
index >= len(times)-solarEclipseCentralBandUnionAnchorSamples
|
||
|
|
if !anchored && lastKept > 0 {
|
||
|
|
elapsed := (jd - lastKept) * 86400
|
||
|
|
if elapsed < solarEclipseCentralBandUnionMinStepSeconds {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
// Skip ahead while the previous footprint has barely moved: the
|
||
|
|
// swept envelope between two samples this close is already covered.
|
||
|
|
if speedKMperSecond > 0 &&
|
||
|
|
elapsed*speedKMperSecond < solarEclipseCentralBandUnionAdvanceKM {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if elapsed > solarEclipseCentralBandUnionMaxStepSeconds {
|
||
|
|
// Never let the stride stretch past the cap, so a stalled centre
|
||
|
|
// cannot leave a gap in the sweep.
|
||
|
|
if lastKept+solarEclipseCentralBandUnionMaxStepSeconds/86400 < jd {
|
||
|
|
jd = lastKept + solarEclipseCentralBandUnionMaxStepSeconds/86400
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for existingIndex < len(solved) &&
|
||
|
|
solved[existingIndex].JDE < jd-solarEclipseCentralBandUnionReuseDays {
|
||
|
|
existingIndex++
|
||
|
|
}
|
||
|
|
var ring []SolarEclipsePathPoint
|
||
|
|
if existingIndex < len(solved) &&
|
||
|
|
math.Abs(solved[existingIndex].JDE-jd) <= solarEclipseCentralBandUnionReuseDays {
|
||
|
|
ring = solarEclipseFootprintBoundaryRing(solved[existingIndex])
|
||
|
|
} else {
|
||
|
|
ring = solver.centralBandSampledFootprintRingAt(jd)
|
||
|
|
}
|
||
|
|
if len(ring) < 4 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
ring = decimateSolarEclipseClosedRing(ring, solarEclipseCentralBandUnionSpacingKM)
|
||
|
|
if len(ring) < 4 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
polygon := make([]geodata.GeoPoint, len(ring))
|
||
|
|
for position, point := range ring {
|
||
|
|
polygon[position] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||
|
|
// 同一坐标可能同时落在相邻两个足迹的环上,取它最早出现的那次,
|
||
|
|
// 也就是本影边缘真正扫过它的时刻。
|
||
|
|
if _, exists := ringJDE[polygon[position]]; !exists {
|
||
|
|
ringJDE[polygon[position]] = point.JDE
|
||
|
|
}
|
||
|
|
}
|
||
|
|
rings = append(rings, polygon)
|
||
|
|
if lastKept > 0 && jd > lastKept {
|
||
|
|
center := solarEclipseRingCenter(polygon)
|
||
|
|
if speedKMperSecond <= 0 {
|
||
|
|
speedKMperSecond = solarEclipsePathDistanceKM(
|
||
|
|
SolarEclipsePathPoint{Longitude: lastCenter.Longitude, Latitude: lastCenter.Latitude},
|
||
|
|
SolarEclipsePathPoint{Longitude: center.Longitude, Latitude: center.Latitude},
|
||
|
|
) / ((jd - lastKept) * 86400)
|
||
|
|
}
|
||
|
|
lastCenter = center
|
||
|
|
} else {
|
||
|
|
lastCenter = solarEclipseRingCenter(polygon)
|
||
|
|
}
|
||
|
|
lastKept = jd
|
||
|
|
}
|
||
|
|
if len(rings) == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
merged := unionSolarEclipseCentralBandRings(rings)
|
||
|
|
if len(merged) == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
segments := make([][]SolarEclipsePathPoint, 0, len(merged))
|
||
|
|
for _, polygon := range merged {
|
||
|
|
if len(polygon) < 4 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
times := solarEclipseRingVertexTimes(polygon, ringJDE, greatestJDE)
|
||
|
|
polygon, times = dedupeSolarEclipseClosedRing(polygon, times, solarEclipseCentralBandUnionMinVertexSpacingKM)
|
||
|
|
smoothed, smoothedTimes := smoothSolarEclipseClosedRing(
|
||
|
|
polygon, times, solarEclipseCentralBandUnionSmoothPasses,
|
||
|
|
)
|
||
|
|
if len(smoothed) >= 4 {
|
||
|
|
polygon, times = dedupeSolarEclipseClosedRing(
|
||
|
|
smoothed, smoothedTimes, solarEclipseCentralBandUnionPostSmoothSpacingKM,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
segment := make([]SolarEclipsePathPoint, 0, len(polygon)+1)
|
||
|
|
for index, point := range polygon {
|
||
|
|
segment = append(segment, SolarEclipsePathPoint{
|
||
|
|
JDE: times[index], Longitude: point.Longitude, Latitude: point.Latitude,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
if solarEclipsePathDistanceKM(segment[0], segment[len(segment)-1]) > 0.01 {
|
||
|
|
segment = append(segment, segment[0])
|
||
|
|
}
|
||
|
|
segments = append(segments, segment)
|
||
|
|
}
|
||
|
|
if len(segments) == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return segments
|
||
|
|
}
|
||
|
|
|
||
|
|
// unionSolarEclipseCentralBandRings merges the sampled footprints one at a
|
||
|
|
// time. A single boolean join of a few hundred grazing footprints leaves a
|
||
|
|
// handful of nanodegree seams between temporally adjacent rims, and the
|
||
|
|
// all-at-once join then reports the outer ring as open; folding the rings into
|
||
|
|
// a growing accumulator keeps every join local, so the few rings that still
|
||
|
|
// fail are skipped instead of discarding the whole band.
|
||
|
|
func unionSolarEclipseCentralBandRings(rings [][]geodata.GeoPoint) [][]geodata.GeoPoint {
|
||
|
|
if len(rings) == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
accumulator, err := geodata.UnionPolygons(rings[:1])
|
||
|
|
if err != nil || len(accumulator) == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
for _, ring := range rings[1:] {
|
||
|
|
merged, mergeErr := geodata.UnionPolygons(append(accumulator, ring))
|
||
|
|
if mergeErr != nil || len(merged) == 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
accumulator = merged
|
||
|
|
}
|
||
|
|
return accumulator
|
||
|
|
}
|
||
|
|
|
||
|
|
// centralBandSampledFootprintRingAt returns one instantaneous central-shadow
|
||
|
|
// footprint as a closed ring. The traced boundary is used as-is, so every
|
||
|
|
// sampled footprint vertex stays on the reconstructed band and the coverage
|
||
|
|
// audit keeps its meaning; an open (horizon-cut) footprint is closed by the
|
||
|
|
// chord between its two rim ends, and the neighbouring samples cover the
|
||
|
|
// horizon side.
|
||
|
|
func (solver solarEclipseSolver) centralBandSampledFootprintRingAt(
|
||
|
|
jd float64,
|
||
|
|
) []SolarEclipsePathPoint {
|
||
|
|
return solarEclipseFootprintBoundaryRing(solver.shadowFootprintAtWithSpacing(
|
||
|
|
jd, solarEclipseCentralBandUnionBoundaryPoints, solarEclipseCentralShadow,
|
||
|
|
solarEclipseCentralBandUnionSpacingKM,
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
// solarEclipseFootprintBoundaryRing joins one instantaneous footprint into a
|
||
|
|
// closed ring. An antimeridian-crossing rim is split into two segments; they are
|
||
|
|
// joined the same way the renderers do instead of adding a false chord across
|
||
|
|
// 180, and an open (horizon-cut) rim is closed by the chord between its two
|
||
|
|
// ends while the neighbouring samples cover the horizon side.
|
||
|
|
func solarEclipseFootprintBoundaryRing(
|
||
|
|
footprint SolarEclipsePartialFootprint,
|
||
|
|
) []SolarEclipsePathPoint {
|
||
|
|
if len(footprint.Boundaries) == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
segments := make([][]geodata.GeoPoint, 0, len(footprint.Boundaries))
|
||
|
|
for _, boundary := range footprint.Boundaries {
|
||
|
|
segment := make([]geodata.GeoPoint, len(boundary))
|
||
|
|
for index, point := range boundary {
|
||
|
|
segment[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||
|
|
}
|
||
|
|
segments = append(segments, segment)
|
||
|
|
}
|
||
|
|
joined := geodata.JoinPolylineSegments(segments)
|
||
|
|
if len(joined) < 3 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
ring := make([]SolarEclipsePathPoint, 0, len(joined)+1)
|
||
|
|
for _, point := range joined {
|
||
|
|
ring = append(ring, SolarEclipsePathPoint{
|
||
|
|
JDE: footprint.JDE, Longitude: point.Longitude, Latitude: point.Latitude,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
if solarEclipsePathDistanceKM(ring[0], ring[len(ring)-1]) > 0.01 {
|
||
|
|
ring = append(ring, ring[0])
|
||
|
|
}
|
||
|
|
return ring
|
||
|
|
}
|
||
|
|
|
||
|
|
// dedupeSolarEclipseClosedRing drops vertices closer together than the given
|
||
|
|
// spacing, returns a ring that closes on its first vertex, and keeps the
|
||
|
|
// parallel time slice aligned so every retained vertex keeps its own time.
|
||
|
|
func dedupeSolarEclipseClosedRing(
|
||
|
|
points []geodata.GeoPoint,
|
||
|
|
times []float64,
|
||
|
|
minimumKM float64,
|
||
|
|
) ([]geodata.GeoPoint, []float64) {
|
||
|
|
if len(points) < 4 || minimumKM <= 0 || len(times) != len(points) {
|
||
|
|
return points, times
|
||
|
|
}
|
||
|
|
open, openTimes := points, times
|
||
|
|
if pointDistanceKM(points[len(points)-1], points[0]) < minimumKM {
|
||
|
|
open, openTimes = points[:len(points)-1], times[:len(times)-1]
|
||
|
|
}
|
||
|
|
if len(open) < 3 {
|
||
|
|
return points, times
|
||
|
|
}
|
||
|
|
result := make([]geodata.GeoPoint, 0, len(open)+1)
|
||
|
|
resultTimes := make([]float64, 0, len(open)+1)
|
||
|
|
result = append(result, open[0])
|
||
|
|
resultTimes = append(resultTimes, openTimes[0])
|
||
|
|
for index, point := range open[1:] {
|
||
|
|
if pointDistanceKM(result[len(result)-1], point) >= minimumKM {
|
||
|
|
result = append(result, point)
|
||
|
|
resultTimes = append(resultTimes, openTimes[index+1])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(result) < 3 {
|
||
|
|
return points, times
|
||
|
|
}
|
||
|
|
result = append(result, result[0])
|
||
|
|
resultTimes = append(resultTimes, resultTimes[0])
|
||
|
|
return result, resultTimes
|
||
|
|
}
|
||
|
|
|
||
|
|
// solarEclipseRingVertexTimes 给并集环的每个顶点配回它自己的时间:并集输出保留了
|
||
|
|
// 原足迹顶点,交点顶点没有直接时间,用环上前后两个已知时间按沿环距离插值。
|
||
|
|
func solarEclipseRingVertexTimes(
|
||
|
|
ring []geodata.GeoPoint,
|
||
|
|
ringJDE map[geodata.GeoPoint]float64,
|
||
|
|
fallbackJDE float64,
|
||
|
|
) []float64 {
|
||
|
|
times := make([]float64, len(ring))
|
||
|
|
known := make([]bool, len(ring))
|
||
|
|
found := false
|
||
|
|
for index, point := range ring {
|
||
|
|
if jde, ok := ringJDE[point]; ok {
|
||
|
|
times[index], known[index], found = jde, true, true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !found {
|
||
|
|
for index := range times {
|
||
|
|
times[index] = fallbackJDE
|
||
|
|
}
|
||
|
|
return times
|
||
|
|
}
|
||
|
|
if len(ring) > 1 && ring[0] == ring[len(ring)-1] {
|
||
|
|
if known[0] {
|
||
|
|
known[len(ring)-1], times[len(ring)-1] = true, times[0]
|
||
|
|
}
|
||
|
|
if known[len(ring)-1] {
|
||
|
|
known[0], times[0] = true, times[len(ring)-1]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
count := len(ring)
|
||
|
|
for offset := 0; offset < count; offset++ {
|
||
|
|
if known[offset] {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
backIndex, backDistance := offset, 0.0
|
||
|
|
for steps := 0; steps < count; steps++ {
|
||
|
|
previous := (backIndex - 1 + count) % count
|
||
|
|
backDistance += pointDistanceKM(ring[previous], ring[backIndex])
|
||
|
|
backIndex = previous
|
||
|
|
if known[backIndex] {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
forwardIndex, forwardDistance := offset, 0.0
|
||
|
|
for steps := 0; steps < count; steps++ {
|
||
|
|
next := (forwardIndex + 1) % count
|
||
|
|
forwardDistance += pointDistanceKM(ring[forwardIndex], ring[next])
|
||
|
|
forwardIndex = next
|
||
|
|
if known[forwardIndex] {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
fraction := 0.5
|
||
|
|
if total := backDistance + forwardDistance; total > 0 {
|
||
|
|
fraction = backDistance / total
|
||
|
|
}
|
||
|
|
times[offset] = times[backIndex] + fraction*(times[forwardIndex]-times[backIndex])
|
||
|
|
}
|
||
|
|
return times
|
||
|
|
}
|
||
|
|
|
||
|
|
// smoothSolarEclipseClosedRing filters one closed ring with Taubin smoothing.
|
||
|
|
// A bare Laplacian pass removes short-wavelength ripple but also shrinks a
|
||
|
|
// convex boundary — which a grazing band cannot afford, because its coverage
|
||
|
|
// check allows only tens of kilometres — and a wide quadratic fit can fold a
|
||
|
|
// tight corner. The lambda|mu pair alternates a shrinking with a slightly
|
||
|
|
// larger inflating step, so a long run of iterations flattens the sampling
|
||
|
|
// sweep of the footprint union without pulling the band inside the umbra.
|
||
|
|
// Longitudes are unwrapped first so the filter never averages across the
|
||
|
|
// antimeridian. The same weights are applied to the parallel time slice, so a
|
||
|
|
// moved vertex carries the time of the neighbourhood it was moved into instead
|
||
|
|
// of falling back to the event's greatest eclipse.
|
||
|
|
func smoothSolarEclipseClosedRing(
|
||
|
|
points []geodata.GeoPoint,
|
||
|
|
times []float64,
|
||
|
|
passes int,
|
||
|
|
) ([]geodata.GeoPoint, []float64) {
|
||
|
|
count := len(points) - 1
|
||
|
|
if count < 8 || passes <= 0 || len(times) != len(points) {
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
longitudes := make([]float64, count)
|
||
|
|
latitudes := make([]float64, count)
|
||
|
|
vertexTimes := make([]float64, count)
|
||
|
|
longitudes[0] = points[0].Longitude
|
||
|
|
latitudes[0] = points[0].Latitude
|
||
|
|
vertexTimes[0] = times[0]
|
||
|
|
for index := 1; index < count; index++ {
|
||
|
|
delta := math.Remainder((points[index].Longitude-points[index-1].Longitude)*rad, 2*math.Pi) / rad
|
||
|
|
longitudes[index] = longitudes[index-1] + delta
|
||
|
|
latitudes[index] = points[index].Latitude
|
||
|
|
vertexTimes[index] = times[index]
|
||
|
|
}
|
||
|
|
nextLongitudes := make([]float64, count)
|
||
|
|
nextLatitudes := make([]float64, count)
|
||
|
|
nextTimes := make([]float64, count)
|
||
|
|
for pass := 0; pass < passes; pass++ {
|
||
|
|
weight := solarEclipseCentralBandUnionSmoothLambda
|
||
|
|
if pass%2 == 1 {
|
||
|
|
weight = solarEclipseCentralBandUnionSmoothMu
|
||
|
|
}
|
||
|
|
for index := 0; index < count; index++ {
|
||
|
|
previous := (index + count - 1) % count
|
||
|
|
following := (index + 1) % count
|
||
|
|
nextLongitudes[index] = longitudes[index] +
|
||
|
|
weight*(0.5*(longitudes[previous]+longitudes[following])-longitudes[index])
|
||
|
|
nextLatitudes[index] = latitudes[index] +
|
||
|
|
weight*(0.5*(latitudes[previous]+latitudes[following])-latitudes[index])
|
||
|
|
nextTimes[index] = vertexTimes[index] +
|
||
|
|
weight*(0.5*(vertexTimes[previous]+vertexTimes[following])-vertexTimes[index])
|
||
|
|
}
|
||
|
|
copy(longitudes, nextLongitudes)
|
||
|
|
copy(latitudes, nextLatitudes)
|
||
|
|
copy(vertexTimes, nextTimes)
|
||
|
|
}
|
||
|
|
ring := make([]geodata.GeoPoint, 0, count+1)
|
||
|
|
ringTimes := make([]float64, 0, count+1)
|
||
|
|
for index := 0; index < count; index++ {
|
||
|
|
ring = append(ring, geodata.GeoPoint{
|
||
|
|
Longitude: normalizeLongitude(longitudes[index]),
|
||
|
|
Latitude: latitudes[index],
|
||
|
|
})
|
||
|
|
ringTimes = append(ringTimes, vertexTimes[index])
|
||
|
|
}
|
||
|
|
ring = append(ring, ring[0])
|
||
|
|
ringTimes = append(ringTimes, ringTimes[0])
|
||
|
|
return ring, ringTimes
|
||
|
|
}
|
||
|
|
|
||
|
|
// pointDistanceKM is the great-circle distance between two geographic points.
|
||
|
|
func pointDistanceKM(first, second geodata.GeoPoint) float64 {
|
||
|
|
firstLatitude := first.Latitude * rad
|
||
|
|
secondLatitude := second.Latitude * rad
|
||
|
|
deltaLatitude := secondLatitude - firstLatitude
|
||
|
|
deltaLongitude := math.Remainder((second.Longitude-first.Longitude)*rad, 2*math.Pi)
|
||
|
|
haversine := math.Sin(deltaLatitude/2)*math.Sin(deltaLatitude/2) +
|
||
|
|
math.Cos(firstLatitude)*math.Cos(secondLatitude)*math.Sin(deltaLongitude/2)*math.Sin(deltaLongitude/2)
|
||
|
|
return 2 * 6378.1366 * math.Asin(math.Sqrt(math.Min(1, haversine)))
|
||
|
|
}
|
||
|
|
|
||
|
|
// decimateSolarEclipseClosedRing thins one closed ring to the requested
|
||
|
|
// spacing while keeping its first vertex and its closure.
|
||
|
|
func decimateSolarEclipseClosedRing(
|
||
|
|
points []SolarEclipsePathPoint,
|
||
|
|
spacingKM float64,
|
||
|
|
) []SolarEclipsePathPoint {
|
||
|
|
if len(points) < 4 || spacingKM <= 0 {
|
||
|
|
return points
|
||
|
|
}
|
||
|
|
result := make([]SolarEclipsePathPoint, 0, len(points))
|
||
|
|
result = append(result, points[0])
|
||
|
|
for _, point := range points[1:] {
|
||
|
|
if solarEclipsePathDistanceKM(result[len(result)-1], point) >= spacingKM {
|
||
|
|
result = append(result, point)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(result) < 3 {
|
||
|
|
return points
|
||
|
|
}
|
||
|
|
first := result[0]
|
||
|
|
if solarEclipsePathDistanceKM(result[len(result)-1], first) > 0.01 {
|
||
|
|
result = append(result, first)
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
func (solver solarEclipseSolver) nonCentralTotalBandPolygons(
|
||
|
|
segments [][]SolarEclipsePathPoint,
|
||
|
|
firstContact, lastContact SolarEclipsePathPoint,
|
||
|
|
referenceJDE float64,
|
||
|
|
riseSetCurves []SolarEclipseRiseSetCurve,
|
||
|
|
) ([][]SolarEclipsePathPoint, []SolarEclipsePathPoint) {
|
||
|
|
contacts := []SolarEclipsePathPoint{firstContact, lastContact}
|
||
|
|
for _, segment := range segments {
|
||
|
|
boundary, ok := solver.completeNonCentralTotalBandBoundary(
|
||
|
|
segment, contacts, referenceJDE,
|
||
|
|
)
|
||
|
|
if !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
polygon, horizon := solver.closeNonCentralBandBoundary(boundary, riseSetCurves)
|
||
|
|
if len(polygon) >= 4 {
|
||
|
|
return [][]SolarEclipsePathPoint{polygon}, horizon
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (solver solarEclipseSolver) completeNonCentralTotalBandBoundary(
|
||
|
|
segment []SolarEclipsePathPoint,
|
||
|
|
contacts []SolarEclipsePathPoint,
|
||
|
|
referenceJDE float64,
|
||
|
|
) ([]SolarEclipsePathPoint, bool) {
|
||
|
|
if len(segment) < 2 {
|
||
|
|
return nil, false
|
||
|
|
}
|
||
|
|
boundary := append([]SolarEclipsePathPoint(nil), segment...)
|
||
|
|
for _, atStart := range []bool{true, false} {
|
||
|
|
endpointIndex := len(boundary) - 1
|
||
|
|
if atStart {
|
||
|
|
endpointIndex = 0
|
||
|
|
}
|
||
|
|
endpoint := boundary[endpointIndex]
|
||
|
|
if math.Abs(endpoint.SunAltitude) <= 1e-5 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
contact, ok := nearestNonCentralBandContact(endpoint, contacts)
|
||
|
|
if !ok {
|
||
|
|
return nil, false
|
||
|
|
}
|
||
|
|
extension, ok := solver.nonCentralBandContactExtension(
|
||
|
|
contact, endpoint, referenceJDE,
|
||
|
|
)
|
||
|
|
if !ok {
|
||
|
|
return nil, false
|
||
|
|
}
|
||
|
|
if atStart {
|
||
|
|
if solarEclipsePathDistanceKM(extension[len(extension)-1], endpoint) > 0.01 {
|
||
|
|
for left, right := 0, len(extension)-1; left < right; left, right = left+1, right-1 {
|
||
|
|
extension[left], extension[right] = extension[right], extension[left]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
boundary = append(extension[:len(extension)-1], boundary...)
|
||
|
|
} else {
|
||
|
|
if solarEclipsePathDistanceKM(extension[0], endpoint) > 0.01 {
|
||
|
|
for left, right := 0, len(extension)-1; left < right; left, right = left+1, right-1 {
|
||
|
|
extension[left], extension[right] = extension[right], extension[left]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
boundary = append(boundary, extension[1:]...)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return deduplicateSolarEclipsePathPoints(boundary), true
|
||
|
|
}
|
||
|
|
|
||
|
|
func nearestNonCentralBandContact(
|
||
|
|
endpoint SolarEclipsePathPoint,
|
||
|
|
contacts []SolarEclipsePathPoint,
|
||
|
|
) (SolarEclipsePathPoint, bool) {
|
||
|
|
const maximumTimeGapDays = 5.0 / 1440.0
|
||
|
|
best := SolarEclipsePathPoint{}
|
||
|
|
bestTimeGap := math.Inf(1)
|
||
|
|
for _, contact := range contacts {
|
||
|
|
if contact.JDE == 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
timeGap := math.Abs(contact.JDE - endpoint.JDE)
|
||
|
|
if timeGap < bestTimeGap {
|
||
|
|
best, bestTimeGap = contact, timeGap
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if bestTimeGap > maximumTimeGapDays ||
|
||
|
|
solarEclipsePathDistanceKM(best, endpoint) > 3000 {
|
||
|
|
return SolarEclipsePathPoint{}, false
|
||
|
|
}
|
||
|
|
return best, true
|
||
|
|
}
|
||
|
|
|
||
|
|
func (solver solarEclipseSolver) nonCentralBandContactExtension(
|
||
|
|
contact, endpoint SolarEclipsePathPoint,
|
||
|
|
referenceJDE float64,
|
||
|
|
) ([]SolarEclipsePathPoint, bool) {
|
||
|
|
startJDE, endJDE := contact.JDE, endpoint.JDE
|
||
|
|
if startJDE > endJDE {
|
||
|
|
startJDE, endJDE = endJDE, startJDE
|
||
|
|
}
|
||
|
|
if endJDE-startJDE <= solarEclipsePathDuplicateTimeDays {
|
||
|
|
return []SolarEclipsePathPoint{endpoint}, true
|
||
|
|
}
|
||
|
|
const stepDays = 5.0 / 86400.0
|
||
|
|
times := make([]float64, 0, int((endJDE-startJDE)/stepDays)+20)
|
||
|
|
for jd := startJDE + stepDays; jd < endJDE-solarEclipsePathDuplicateTimeDays; jd += stepDays {
|
||
|
|
times = append(times, jd)
|
||
|
|
}
|
||
|
|
for second := 1.0; second <= 10; second++ {
|
||
|
|
offset := second / 86400.0
|
||
|
|
if startJDE+offset < endJDE-solarEclipsePathDuplicateTimeDays {
|
||
|
|
times = append(times, startJDE+offset)
|
||
|
|
}
|
||
|
|
if endJDE-offset > startJDE+solarEclipsePathDuplicateTimeDays {
|
||
|
|
times = append(times, endJDE-offset)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
sort.Float64s(times)
|
||
|
|
times = uniqueSolarEclipsePathTimes(times)
|
||
|
|
samples := make([]solarEclipseCentralBandSweepSample, 0, len(times))
|
||
|
|
for _, jd := range times {
|
||
|
|
if sample, ok := solver.centralBandSweepSampleAt(jd); ok {
|
||
|
|
samples = append(samples, sample)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
samples = solver.refineCentralBandSweepSamples(samples)
|
||
|
|
corrected := solver.correctNonCentralBandEnvelopeSamples(samples, referenceJDE)
|
||
|
|
if len(corrected) == 0 {
|
||
|
|
return nil, false
|
||
|
|
}
|
||
|
|
result := make([]SolarEclipsePathPoint, 0, len(corrected)+1)
|
||
|
|
if contact.JDE <= endpoint.JDE {
|
||
|
|
result = append(result, corrected...)
|
||
|
|
result = append(result, endpoint)
|
||
|
|
} else {
|
||
|
|
result = append(result, endpoint)
|
||
|
|
result = append(result, corrected...)
|
||
|
|
}
|
||
|
|
result = deduplicateSolarEclipsePathPoints(result)
|
||
|
|
if len(result) < 2 {
|
||
|
|
return nil, false
|
||
|
|
}
|
||
|
|
for index := 1; index < len(result); index++ {
|
||
|
|
if solarEclipsePathDistanceKM(result[index-1], result[index]) >
|
||
|
|
2*solarEclipseCentralBandTargetSpacingKM {
|
||
|
|
return nil, false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result, true
|
||
|
|
}
|
||
|
|
|
||
|
|
func solarEclipseCentralBandContactSampleTimes(times []float64, startJDE, endJDE float64) []float64 {
|
||
|
|
window := math.Min(solarEclipseCentralBandContactFineWindowDays, (endJDE-startJDE)/4)
|
||
|
|
if window <= solarEclipseCentralBandContactFineStepDays {
|
||
|
|
return times
|
||
|
|
}
|
||
|
|
for jd := startJDE + solarEclipseCentralBandContactFineStepDays; jd < startJDE+window; jd += solarEclipseCentralBandContactFineStepDays {
|
||
|
|
times = append(times, jd)
|
||
|
|
}
|
||
|
|
for jd := endJDE - window + solarEclipseCentralBandContactFineStepDays; jd < endJDE; jd += solarEclipseCentralBandContactFineStepDays {
|
||
|
|
times = append(times, jd)
|
||
|
|
}
|
||
|
|
sort.Float64s(times)
|
||
|
|
return uniqueSolarEclipsePathTimes(times)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (solver solarEclipseSolver) centralBandSweepSampleAt(
|
||
|
|
jd float64,
|
||
|
|
) (solarEclipseCentralBandSweepSample, bool) {
|
||
|
|
moon, axis, sun := solver.besselGeometryAt(jd)
|
||
|
|
samples := make([]solarEclipsePartialBoundarySample, solarEclipseCentralBandBoundaryPoints)
|
||
|
|
for index := range samples {
|
||
|
|
angle := 2 * math.Pi * float64(index) / float64(len(samples))
|
||
|
|
point, ok := solver.shadowFootprintPointAt(
|
||
|
|
jd, moon, axis, sun, angle, solarEclipseCentralShadow,
|
||
|
|
)
|
||
|
|
samples[index] = solarEclipsePartialBoundarySample{point: point, ok: ok, angle: angle}
|
||
|
|
}
|
||
|
|
samples = solver.refineShadowFootprintTransitions(
|
||
|
|
jd, moon, axis, sun, samples, solarEclipseCentralShadow,
|
||
|
|
)
|
||
|
|
samples = solver.refineShadowFootprintSpacing(
|
||
|
|
jd, moon, axis, sun, samples, solarEclipseCentralShadow,
|
||
|
|
solarEclipseCentralBandTargetSpacingKM,
|
||
|
|
)
|
||
|
|
arc := solarEclipseLongestOpenShadowArc(samples)
|
||
|
|
if len(arc) < 2 {
|
||
|
|
return solarEclipseCentralBandSweepSample{}, false
|
||
|
|
}
|
||
|
|
geometry := solarEclipseCentralBandGeometry{jde: jd, moon: moon, axis: axis, sun: sun}
|
||
|
|
angle, ok := solver.centralBandEnvelopeAngle(geometry, arc)
|
||
|
|
if !ok {
|
||
|
|
return solarEclipseCentralBandSweepSample{}, false
|
||
|
|
}
|
||
|
|
envelope, ok := solver.centralShadowPointAt(jd, angle)
|
||
|
|
if !ok {
|
||
|
|
return solarEclipseCentralBandSweepSample{}, false
|
||
|
|
}
|
||
|
|
// 弧角沿环单向展开,可以超过 2π;包络角取 [0,2π) 支,比较前必须映射到同一支,
|
||
|
|
// 否则展开段的样本会被两端同时丢弃。
|
||
|
|
envelopeAngle := angle
|
||
|
|
if len(arc) > 0 {
|
||
|
|
envelopeAngle = solarEclipseArcBranchAngle(angle, arc[0].angle)
|
||
|
|
}
|
||
|
|
firstCap := []SolarEclipsePathPoint{envelope}
|
||
|
|
secondCap := []SolarEclipsePathPoint{envelope}
|
||
|
|
for index := len(arc) - 1; index >= 0; index-- {
|
||
|
|
if arc[index].angle >= envelopeAngle {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
firstCap = append(firstCap, arc[index].point)
|
||
|
|
}
|
||
|
|
for _, sample := range arc {
|
||
|
|
if sample.angle <= envelopeAngle {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
secondCap = append(secondCap, sample.point)
|
||
|
|
}
|
||
|
|
firstCap = deduplicateSolarEclipsePathPoints(firstCap)
|
||
|
|
secondCap = deduplicateSolarEclipsePathPoints(secondCap)
|
||
|
|
if len(firstCap) < 2 || len(secondCap) < 2 {
|
||
|
|
return solarEclipseCentralBandSweepSample{}, false
|
||
|
|
}
|
||
|
|
return solarEclipseCentralBandSweepSample{
|
||
|
|
jde: jd, envelope: envelope,
|
||
|
|
first: firstCap[len(firstCap)-1], second: secondCap[len(secondCap)-1],
|
||
|
|
firstCap: firstCap, secondCap: secondCap,
|
||
|
|
}, true
|
||
|
|
}
|
||
|
|
|
||
|
|
// solarEclipseArcBranchAngle 把 [0,2π) 的角度映射到以 reference 为起点的那条展开支上。
|
||
|
|
func solarEclipseArcBranchAngle(angle, reference float64) float64 {
|
||
|
|
return reference + math.Remainder(angle-reference, 2*math.Pi)
|
||
|
|
}
|
||
|
|
|
||
|
|
func solarEclipseLongestOpenShadowArc(
|
||
|
|
samples []solarEclipsePartialBoundarySample,
|
||
|
|
) []solarEclipsePartialBoundarySample {
|
||
|
|
if len(samples) == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
invalid := -1
|
||
|
|
for index, sample := range samples {
|
||
|
|
if !sample.ok {
|
||
|
|
invalid = index
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if invalid < 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
var longest, current []solarEclipsePartialBoundarySample
|
||
|
|
for offset := 1; offset <= len(samples); offset++ {
|
||
|
|
sample := samples[(invalid+offset)%len(samples)]
|
||
|
|
if !sample.ok {
|
||
|
|
if len(current) > len(longest) {
|
||
|
|
longest = append([]solarEclipsePartialBoundarySample(nil), current...)
|
||
|
|
}
|
||
|
|
current = nil
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if len(current) > 0 {
|
||
|
|
for sample.angle <= current[len(current)-1].angle {
|
||
|
|
sample.angle += 2 * math.Pi
|
||
|
|
}
|
||
|
|
}
|
||
|
|
current = append(current, sample)
|
||
|
|
}
|
||
|
|
if len(current) > len(longest) {
|
||
|
|
longest = current
|
||
|
|
}
|
||
|
|
return longest
|
||
|
|
}
|
||
|
|
|
||
|
|
func (solver solarEclipseSolver) centralBandEnvelopeAngle(
|
||
|
|
geometry solarEclipseCentralBandGeometry,
|
||
|
|
arc []solarEclipsePartialBoundarySample,
|
||
|
|
) (float64, bool) {
|
||
|
|
const timeStep = 0.5 / 86400.0
|
||
|
|
beforeMoon, beforeAxis, beforeSun := solver.besselGeometryAt(geometry.jde - timeStep)
|
||
|
|
afterMoon, afterAxis, afterSun := solver.besselGeometryAt(geometry.jde + timeStep)
|
||
|
|
before := solarEclipseCentralBandGeometry{
|
||
|
|
jde: geometry.jde - timeStep, moon: beforeMoon, axis: beforeAxis, sun: beforeSun,
|
||
|
|
}
|
||
|
|
after := solarEclipseCentralBandGeometry{
|
||
|
|
jde: geometry.jde + timeStep, moon: afterMoon, axis: afterAxis, sun: afterSun,
|
||
|
|
}
|
||
|
|
previousAngle := arc[0].angle
|
||
|
|
previous, previousOK := solver.centralBandSweepJacobian(geometry, before, after, previousAngle)
|
||
|
|
bestAngle, bestValue := previousAngle, math.Abs(previous)
|
||
|
|
for index := 1; index < len(arc); index++ {
|
||
|
|
angle := arc[index].angle
|
||
|
|
value, ok := solver.centralBandSweepJacobian(geometry, before, after, angle)
|
||
|
|
if ok && math.Abs(value) < bestValue {
|
||
|
|
bestAngle, bestValue = angle, math.Abs(value)
|
||
|
|
}
|
||
|
|
if previousOK && ok && previous*value <= 0 {
|
||
|
|
left, right := previousAngle, angle
|
||
|
|
leftValue := previous
|
||
|
|
for iteration := 0; iteration < 48 && right-left > 1e-11; iteration++ {
|
||
|
|
middle := (left + right) / 2
|
||
|
|
middleValue, middleOK := solver.centralBandSweepJacobian(geometry, before, after, middle)
|
||
|
|
if !middleOK {
|
||
|
|
return 0, false
|
||
|
|
}
|
||
|
|
if leftValue*middleValue <= 0 {
|
||
|
|
right = middle
|
||
|
|
} else {
|
||
|
|
left, leftValue = middle, middleValue
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return math.Mod((left+right)/2, 2*math.Pi), true
|
||
|
|
}
|
||
|
|
previousAngle, previous, previousOK = angle, value, ok
|
||
|
|
}
|
||
|
|
if bestValue <= 1e-5 {
|
||
|
|
return math.Mod(bestAngle, 2*math.Pi), true
|
||
|
|
}
|
||
|
|
return 0, false
|
||
|
|
}
|
||
|
|
|
||
|
|
func (solver solarEclipseSolver) centralBandSweepJacobian(
|
||
|
|
geometry, before, after solarEclipseCentralBandGeometry,
|
||
|
|
angle float64,
|
||
|
|
) (float64, bool) {
|
||
|
|
const angleStep = 1e-5
|
||
|
|
center, centerOK := solver.centralShadowPointAtGeometry(geometry, angle)
|
||
|
|
angleBefore, angleBeforeOK := solver.centralShadowPointAtGeometry(geometry, angle-angleStep)
|
||
|
|
angleAfter, angleAfterOK := solver.centralShadowPointAtGeometry(geometry, angle+angleStep)
|
||
|
|
timeBefore, timeBeforeOK := solver.centralShadowPointAtGeometry(before, angle)
|
||
|
|
timeAfter, timeAfterOK := solver.centralShadowPointAtGeometry(after, angle)
|
||
|
|
if !centerOK || !angleBeforeOK || !angleAfterOK || !timeBeforeOK || !timeAfterOK {
|
||
|
|
return 0, false
|
||
|
|
}
|
||
|
|
latitudeScale := math.Cos(center.Latitude * rad)
|
||
|
|
angleX := math.Remainder(angleAfter.Longitude-angleBefore.Longitude, 360) * latitudeScale
|
||
|
|
angleY := angleAfter.Latitude - angleBefore.Latitude
|
||
|
|
timeX := math.Remainder(timeAfter.Longitude-timeBefore.Longitude, 360) * latitudeScale
|
||
|
|
timeY := timeAfter.Latitude - timeBefore.Latitude
|
||
|
|
value := angleX*timeY - angleY*timeX
|
||
|
|
return value, finite(value)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (solver solarEclipseSolver) centralShadowPointAtGeometry(
|
||
|
|
geometry solarEclipseCentralBandGeometry,
|
||
|
|
angle float64,
|
||
|
|
) (SolarEclipsePathPoint, bool) {
|
||
|
|
return solver.shadowFootprintPointAt(
|
||
|
|
geometry.jde, geometry.moon, geometry.axis, geometry.sun,
|
||
|
|
math.Mod(angle+2*math.Pi, 2*math.Pi), solarEclipseCentralShadow,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (solver solarEclipseSolver) centralShadowPointAt(jd, angle float64) (SolarEclipsePathPoint, bool) {
|
||
|
|
moon, axis, sun := solver.besselGeometryAt(jd)
|
||
|
|
return solver.shadowFootprintPointAt(
|
||
|
|
jd, moon, axis, sun, math.Mod(angle+2*math.Pi, 2*math.Pi), solarEclipseCentralShadow,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (solver solarEclipseSolver) refineCentralBandSweepSamples(
|
||
|
|
samples []solarEclipseCentralBandSweepSample,
|
||
|
|
) []solarEclipseCentralBandSweepSample {
|
||
|
|
if len(samples) < 2 {
|
||
|
|
return samples
|
||
|
|
}
|
||
|
|
result := make([]solarEclipseCentralBandSweepSample, 0, len(samples))
|
||
|
|
result = append(result, samples[0])
|
||
|
|
for index := 1; index < len(samples); index++ {
|
||
|
|
result = solver.appendRefinedCentralBandSweepSample(result, samples[index-1], samples[index], 0)
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
func (solver solarEclipseSolver) appendRefinedCentralBandSweepSample(
|
||
|
|
result []solarEclipseCentralBandSweepSample,
|
||
|
|
start, end solarEclipseCentralBandSweepSample,
|
||
|
|
depth int,
|
||
|
|
) []solarEclipseCentralBandSweepSample {
|
||
|
|
maximumDistance := math.Max(
|
||
|
|
solarEclipsePathDistanceKM(start.envelope, end.envelope),
|
||
|
|
math.Max(
|
||
|
|
solarEclipsePathDistanceKM(start.first, end.first),
|
||
|
|
solarEclipsePathDistanceKM(start.second, end.second),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
if depth >= solarEclipseShadowFootprintAdaptiveMaxDepth ||
|
||
|
|
maximumDistance <= solarEclipseCentralBandTargetSpacingKM {
|
||
|
|
return append(result, end)
|
||
|
|
}
|
||
|
|
middle, ok := solver.centralBandSweepSampleAt((start.jde + end.jde) / 2)
|
||
|
|
if !ok {
|
||
|
|
return append(result, end)
|
||
|
|
}
|
||
|
|
keep := solarEclipsePathDistanceKM(start.first, middle.first) +
|
||
|
|
solarEclipsePathDistanceKM(start.second, middle.second)
|
||
|
|
reverse := solarEclipsePathDistanceKM(start.first, middle.second) +
|
||
|
|
solarEclipsePathDistanceKM(start.second, middle.first)
|
||
|
|
if reverse < keep {
|
||
|
|
middle.first, middle.second = middle.second, middle.first
|
||
|
|
middle.firstCap, middle.secondCap = middle.secondCap, middle.firstCap
|
||
|
|
}
|
||
|
|
result = solver.appendRefinedCentralBandSweepSample(result, start, middle, depth+1)
|
||
|
|
return solver.appendRefinedCentralBandSweepSample(result, middle, end, depth+1)
|
||
|
|
}
|
||
|
|
|
||
|
|
func deduplicateSolarEclipsePathPoints(points []SolarEclipsePathPoint) []SolarEclipsePathPoint {
|
||
|
|
if len(points) < 2 {
|
||
|
|
return points
|
||
|
|
}
|
||
|
|
result := make([]SolarEclipsePathPoint, 0, len(points))
|
||
|
|
for _, point := range points {
|
||
|
|
if len(result) == 0 || solarEclipsePathDistanceKM(result[len(result)-1], point) > 0.001 {
|
||
|
|
result = append(result, point)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
func normalizeSolarEclipsePartialFootprintOptions(options SolarEclipsePartialFootprintOptions) SolarEclipsePartialFootprintOptions {
|
||
|
|
if options.StepDays <= 0 || math.IsNaN(options.StepDays) || math.IsInf(options.StepDays, 0) {
|
||
|
|
options.StepDays = solarEclipsePartialFootprintDefaultStepDays
|
||
|
|
}
|
||
|
|
if options.StepDays < solarEclipsePathMinStepDays {
|
||
|
|
options.StepDays = solarEclipsePathMinStepDays
|
||
|
|
}
|
||
|
|
if options.BoundaryPoints <= 0 {
|
||
|
|
options.BoundaryPoints = solarEclipsePartialFootprintDefaultBoundaryPoints
|
||
|
|
}
|
||
|
|
if options.BoundaryPoints < solarEclipsePartialFootprintMinBoundaryPoints {
|
||
|
|
options.BoundaryPoints = solarEclipsePartialFootprintMinBoundaryPoints
|
||
|
|
}
|
||
|
|
if options.BoundaryPoints > solarEclipsePartialFootprintMaxBoundaryPoints {
|
||
|
|
options.BoundaryPoints = solarEclipsePartialFootprintMaxBoundaryPoints
|
||
|
|
}
|
||
|
|
if options.CentralShadowStepDays <= 0 || math.IsNaN(options.CentralShadowStepDays) || math.IsInf(options.CentralShadowStepDays, 0) {
|
||
|
|
options.CentralShadowStepDays = 0
|
||
|
|
} else if options.CentralShadowStepDays < solarEclipsePathMinStepDays {
|
||
|
|
options.CentralShadowStepDays = solarEclipsePathMinStepDays
|
||
|
|
}
|
||
|
|
if options.RiseSetStepDays > 0 && options.RiseSetStepDays < solarEclipsePathMinStepDays {
|
||
|
|
options.RiseSetStepDays = solarEclipsePathMinStepDays
|
||
|
|
}
|
||
|
|
if len(options.MagnitudeValues) > 0 {
|
||
|
|
values := make([]float64, 0, len(options.MagnitudeValues))
|
||
|
|
for _, value := range options.MagnitudeValues {
|
||
|
|
if !isFinite(value) || value <= 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
duplicate := false
|
||
|
|
for _, existing := range values {
|
||
|
|
if math.Abs(existing-value) <= 1e-12 {
|
||
|
|
duplicate = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !duplicate {
|
||
|
|
values = append(values, value)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
sort.Float64s(values)
|
||
|
|
if len(values) > solarEclipseMagnitudeContourMaxValues {
|
||
|
|
values = values[:solarEclipseMagnitudeContourMaxValues]
|
||
|
|
}
|
||
|
|
options.MagnitudeValues = values
|
||
|
|
}
|
||
|
|
if len(options.GreatestTimeValues) > 0 {
|
||
|
|
values := make([]float64, 0, len(options.GreatestTimeValues))
|
||
|
|
for _, value := range options.GreatestTimeValues {
|
||
|
|
if !isFinite(value) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
duplicate := false
|
||
|
|
for _, existing := range values {
|
||
|
|
if math.Abs(existing-value) <= 1e-9 {
|
||
|
|
duplicate = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !duplicate {
|
||
|
|
values = append(values, value)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
sort.Float64s(values)
|
||
|
|
if len(values) > greatestTimeContourMaxLevels {
|
||
|
|
values = values[:greatestTimeContourMaxLevels]
|
||
|
|
}
|
||
|
|
options.GreatestTimeValues = values
|
||
|
|
}
|
||
|
|
return options
|
||
|
|
}
|
||
|
|
|
||
|
|
func (solver solarEclipseSolver) centralPathPoints(
|
||
|
|
startJDE, endJDE, greatestJDE float64,
|
||
|
|
options SolarEclipsePathOptions,
|
||
|
|
) ([]SolarEclipsePathPoint, float64) {
|
||
|
|
if endJDE < startJDE {
|
||
|
|
startJDE, endJDE = endJDE, startJDE
|
||
|
|
}
|
||
|
|
if startJDE == 0 || endJDE == 0 || endJDE <= startJDE {
|
||
|
|
return nil, options.StepDays
|
||
|
|
}
|
||
|
|
|
||
|
|
times, stepDays := solarEclipseMovingDiskEngine().sampleTimes(
|
||
|
|
startJDE, endJDE, greatestJDE, options.StepDays,
|
||
|
|
)
|
||
|
|
|
||
|
|
points := make([]SolarEclipsePathPoint, 0, len(times))
|
||
|
|
for _, jd := range times {
|
||
|
|
point, ok := solver.centralPathPointAt(jd)
|
||
|
|
if ok {
|
||
|
|
points = append(points, point)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// A coarse caller step can place both contact endpoints exactly on the
|
||
|
|
// numerical horizon where the Earth intersection is rejected. Keep the
|
||
|
|
// public center line usable by making one bounded fallback pass through the
|
||
|
|
// event interval instead of returning a single greatest-point sample.
|
||
|
|
if len(points) < 2 {
|
||
|
|
fallbackCount := 32
|
||
|
|
for index := 0; index <= fallbackCount; index++ {
|
||
|
|
jd := startJDE + (endJDE-startJDE)*float64(index)/float64(fallbackCount)
|
||
|
|
point, ok := solver.centralPathPointAt(jd)
|
||
|
|
if !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
duplicate := false
|
||
|
|
for _, existing := range points {
|
||
|
|
if math.Abs(existing.JDE-jd) <= solarEclipsePathDuplicateTimeDays {
|
||
|
|
duplicate = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !duplicate {
|
||
|
|
points = append(points, point)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
sort.Slice(points, func(i, j int) bool { return points[i].JDE < points[j].JDE })
|
||
|
|
if fallbackStep := (endJDE - startJDE) / float64(fallbackCount); fallbackStep > 0 && fallbackStep < stepDays {
|
||
|
|
stepDays = fallbackStep
|
||
|
|
}
|
||
|
|
}
|
||
|
|
sort.Slice(points, func(i, j int) bool { return points[i].JDE < points[j].JDE })
|
||
|
|
if options.TargetSpacingKM > 0 {
|
||
|
|
points = solver.refineCentralPathSpacing(points, options.TargetSpacingKM)
|
||
|
|
}
|
||
|
|
points = normalizeSolarEclipsePathPointSeries(points)
|
||
|
|
if len(points) < 2 {
|
||
|
|
return nil, stepDays
|
||
|
|
}
|
||
|
|
return points, stepDays
|
||
|
|
}
|
||
|
|
|
||
|
|
func (solver solarEclipseSolver) partialFootprints(
|
||
|
|
startJDE, endJDE, greatestJDE float64,
|
||
|
|
options SolarEclipsePartialFootprintOptions,
|
||
|
|
) ([]SolarEclipsePartialFootprint, float64, int) {
|
||
|
|
footprints, stepDays, boundaryPoints := solver.shadowFootprintsWithSpacing(
|
||
|
|
startJDE,
|
||
|
|
endJDE,
|
||
|
|
greatestJDE,
|
||
|
|
options.StepDays,
|
||
|
|
options.BoundaryPoints,
|
||
|
|
solarEclipsePenumbralShadow,
|
||
|
|
solarEclipsePartialFootprintTargetSpacingKM,
|
||
|
|
)
|
||
|
|
return footprints, stepDays, boundaryPoints
|
||
|
|
}
|
||
|
|
|
||
|
|
func (solver solarEclipseSolver) shadowFootprints(
|
||
|
|
startJDE, endJDE, greatestJDE, requestedStepDays float64,
|
||
|
|
boundaryPoints int,
|
||
|
|
kind solarEclipseShadowKind,
|
||
|
|
) ([]SolarEclipsePartialFootprint, float64, int) {
|
||
|
|
return solver.shadowFootprintsWithSpacing(
|
||
|
|
startJDE, endJDE, greatestJDE, requestedStepDays,
|
||
|
|
boundaryPoints, kind, 0,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (solver solarEclipseSolver) shadowFootprintsWithSpacing(
|
||
|
|
startJDE, endJDE, greatestJDE, requestedStepDays float64,
|
||
|
|
boundaryPoints int,
|
||
|
|
kind solarEclipseShadowKind,
|
||
|
|
targetSpacingKM float64,
|
||
|
|
) ([]SolarEclipsePartialFootprint, float64, int) {
|
||
|
|
if endJDE < startJDE {
|
||
|
|
startJDE, endJDE = endJDE, startJDE
|
||
|
|
}
|
||
|
|
if startJDE == 0 || endJDE == 0 || endJDE <= startJDE {
|
||
|
|
return nil, requestedStepDays, boundaryPoints
|
||
|
|
}
|
||
|
|
|
||
|
|
times, stepDays := solarEclipseMovingDiskEngine().sampleTimes(
|
||
|
|
startJDE, endJDE, greatestJDE, requestedStepDays,
|
||
|
|
)
|
||
|
|
|
||
|
|
// Very small, near-grazing footprints can occupy less than one angular
|
||
|
|
// sample at a caller-requested low boundary resolution. Retry the whole
|
||
|
|
// sequence at a bounded finer resolution only when the first pass found no
|
||
|
|
// usable footprint; ordinary events keep the requested cost unchanged.
|
||
|
|
maximumBoundaryPoints := solarEclipseMaximumBoundaryPoints(len(times))
|
||
|
|
retryBoundaryPoints := maximumBoundaryPoints
|
||
|
|
if retryBoundaryPoints > 360 {
|
||
|
|
retryBoundaryPoints = 360
|
||
|
|
}
|
||
|
|
effectiveBoundaryPoints := solarEclipseEffectiveBoundaryPoints(len(times), boundaryPoints)
|
||
|
|
// With very dense temporal sampling, the fixed spatial refinement target
|
||
|
|
// can add many more vertices than the requested angular resolution. The
|
||
|
|
// shared aggregate budget above already limits the two main sweeps; avoid
|
||
|
|
// defeating that limit by repeating spatial refinement at every second.
|
||
|
|
if targetSpacingKM > 0 && len(times)*effectiveBoundaryPoints > solarEclipsePartialFootprintMaxPointCount/3 {
|
||
|
|
targetSpacingKM = 0
|
||
|
|
}
|
||
|
|
var footprints []SolarEclipsePartialFootprint
|
||
|
|
for {
|
||
|
|
footprints = make([]SolarEclipsePartialFootprint, 0, len(times))
|
||
|
|
for _, jd := range times {
|
||
|
|
footprint := solver.shadowFootprintAtWithSpacing(
|
||
|
|
jd, effectiveBoundaryPoints, kind, targetSpacingKM,
|
||
|
|
)
|
||
|
|
if len(footprint.Boundaries) > 0 {
|
||
|
|
footprints = append(footprints, footprint)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(footprints) > 0 || effectiveBoundaryPoints >= retryBoundaryPoints {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
effectiveBoundaryPoints *= 4
|
||
|
|
if effectiveBoundaryPoints < 96 {
|
||
|
|
effectiveBoundaryPoints = 96
|
||
|
|
}
|
||
|
|
if effectiveBoundaryPoints > retryBoundaryPoints {
|
||
|
|
effectiveBoundaryPoints = retryBoundaryPoints
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return footprints, stepDays, effectiveBoundaryPoints
|
||
|
|
}
|
||
|
|
|
||
|
|
func solarEclipseEffectiveBoundaryPoints(sampleCount, requested int) int {
|
||
|
|
if requested < solarEclipsePartialFootprintMinBoundaryPoints {
|
||
|
|
requested = solarEclipsePartialFootprintMinBoundaryPoints
|
||
|
|
}
|
||
|
|
if sampleCount < 1 {
|
||
|
|
return requested
|
||
|
|
}
|
||
|
|
maximum := solarEclipseMaximumBoundaryPoints(sampleCount)
|
||
|
|
if requested > maximum {
|
||
|
|
return maximum
|
||
|
|
}
|
||
|
|
return requested
|
||
|
|
}
|
||
|
|
|
||
|
|
func solarEclipseMaximumBoundaryPoints(sampleCount int) int {
|
||
|
|
if sampleCount < 1 {
|
||
|
|
return solarEclipsePartialFootprintMaxBoundaryPoints
|
||
|
|
}
|
||
|
|
maximum := solarEclipsePartialFootprintMaxPointCount / sampleCount
|
||
|
|
if maximum < solarEclipsePartialFootprintMinBoundaryPoints {
|
||
|
|
maximum = solarEclipsePartialFootprintMinBoundaryPoints
|
||
|
|
}
|
||
|
|
if maximum > solarEclipsePartialFootprintMaxBoundaryPoints {
|
||
|
|
maximum = solarEclipsePartialFootprintMaxBoundaryPoints
|
||
|
|
}
|
||
|
|
return maximum
|
||
|
|
}
|
||
|
|
|
||
|
|
func solarEclipseSharedBoundaryPoints(requested, partialSamples, shadowSamples, reserved int) int {
|
||
|
|
if requested < solarEclipsePartialFootprintMinBoundaryPoints {
|
||
|
|
requested = solarEclipsePartialFootprintMinBoundaryPoints
|
||
|
|
}
|
||
|
|
if partialSamples < 0 {
|
||
|
|
partialSamples = 0
|
||
|
|
}
|
||
|
|
if shadowSamples < 0 {
|
||
|
|
shadowSamples = 0
|
||
|
|
}
|
||
|
|
if reserved < 0 {
|
||
|
|
reserved = 0
|
||
|
|
}
|
||
|
|
remaining := solarEclipsePartialFootprintMaxPointCount - reserved
|
||
|
|
if remaining <= 0 || partialSamples+shadowSamples <= 0 {
|
||
|
|
return requested
|
||
|
|
}
|
||
|
|
shared := remaining / (partialSamples + shadowSamples)
|
||
|
|
if shared < solarEclipsePartialFootprintMinBoundaryPoints {
|
||
|
|
shared = solarEclipsePartialFootprintMinBoundaryPoints
|
||
|
|
}
|
||
|
|
if shared < requested {
|
||
|
|
return shared
|
||
|
|
}
|
||
|
|
return requested
|
||
|
|
}
|
||
|
|
|
||
|
|
func solarEclipseFootprintPointCount(footprints []SolarEclipsePartialFootprint) int {
|
||
|
|
total := 0
|
||
|
|
for _, footprint := range footprints {
|
||
|
|
for _, boundary := range footprint.Boundaries {
|
||
|
|
if len(boundary) > solarEclipsePartialFootprintMaxPointCount-total {
|
||
|
|
return solarEclipsePartialFootprintMaxPointCount
|
||
|
|
}
|
||
|
|
total += len(boundary)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return total
|
||
|
|
}
|
||
|
|
|
||
|
|
func (solver solarEclipseSolver) magnitudeContours(
|
||
|
|
startJDE, endJDE, centralStartJDE, centralEndJDE, greatestJDE float64,
|
||
|
|
options SolarEclipsePartialFootprintOptions,
|
||
|
|
maximumMagnitude float64,
|
||
|
|
hybrid bool,
|
||
|
|
precomputedMagnitudeOne ...[][]SolarEclipsePathPoint,
|
||
|
|
) []SolarEclipseMagnitudeContour {
|
||
|
|
if len(options.MagnitudeValues) == 0 || startJDE == 0 || endJDE == 0 || endJDE <= startJDE {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
contours := make([]SolarEclipseMagnitudeContour, 0, len(options.MagnitudeValues))
|
||
|
|
for _, magnitude := range options.MagnitudeValues {
|
||
|
|
if magnitude > maximumMagnitude+1e-9 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
var segments [][]SolarEclipsePathPoint
|
||
|
|
if math.Abs(magnitude-1) <= 1e-12 && len(precomputedMagnitudeOne) > 0 &&
|
||
|
|
len(precomputedMagnitudeOne[0]) > 0 {
|
||
|
|
segments = precomputedMagnitudeOne[0]
|
||
|
|
} else {
|
||
|
|
segments = solver.magnitudeContourSegments(
|
||
|
|
startJDE, endJDE, centralStartJDE, centralEndJDE,
|
||
|
|
greatestJDE, magnitude, options.StepDays, hybrid,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
if len(segments) == 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
northern, southern := solarEclipseMagnitudeContourCompatibilitySides(segments)
|
||
|
|
contours = append(contours, SolarEclipseMagnitudeContour{
|
||
|
|
Magnitude: magnitude,
|
||
|
|
Segments: segments,
|
||
|
|
NorthernLimit: northern,
|
||
|
|
SouthernLimit: southern,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return contours
|
||
|
|
}
|