2026-09-17 12:27:40 +08:00
|
|
|
package basic
|
|
|
|
|
|
|
|
|
|
import "math"
|
|
|
|
|
|
|
|
|
|
func (solver solarEclipseSolver) refineRiseSetPhaseJunction(
|
2026-09-23 18:55:12 +08:00
|
|
|
jde, longitude, latitude float64,
|
2026-09-17 12:27:40 +08:00
|
|
|
) (SolarEclipsePathPoint, bool) {
|
|
|
|
|
const (
|
|
|
|
|
geographicStep = 1e-4
|
|
|
|
|
timeStep = 1.0 / 86400.0
|
|
|
|
|
)
|
|
|
|
|
for iteration := 0; iteration < 24; iteration++ {
|
2026-09-23 18:55:12 +08:00
|
|
|
evaluation := solver.magnitudeEvaluationAt(jde)
|
2026-09-17 12:27:40 +08:00
|
|
|
residual, ok := solarEclipseRiseSetPhaseJunctionResidualAt(evaluation, longitude, latitude)
|
|
|
|
|
if !ok {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
if math.Abs(residual[0]) <= 1e-10 && math.Abs(residual[1]) <= 1e-10 && math.Abs(residual[2]) <= 1e-10 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
longitudeResidual, longitudeOK := solarEclipseRiseSetPhaseJunctionResidualAt(
|
|
|
|
|
evaluation, longitude+geographicStep, latitude,
|
|
|
|
|
)
|
|
|
|
|
latitudeResidual, latitudeOK := solarEclipseRiseSetPhaseJunctionResidualAt(
|
|
|
|
|
evaluation, longitude, latitude+geographicStep,
|
|
|
|
|
)
|
2026-09-23 18:55:12 +08:00
|
|
|
timeResidual, timeOK := solver.riseSetPhaseJunctionResidual(jde+timeStep, longitude, latitude)
|
2026-09-17 12:27:40 +08:00
|
|
|
if !longitudeOK || !latitudeOK || !timeOK {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
matrix := [3][3]float64{}
|
|
|
|
|
for row := 0; row < 3; row++ {
|
|
|
|
|
matrix[row][0] = (longitudeResidual[row] - residual[row]) / geographicStep
|
|
|
|
|
matrix[row][1] = (latitudeResidual[row] - residual[row]) / geographicStep
|
|
|
|
|
matrix[row][2] = (timeResidual[row] - residual[row]) / timeStep
|
|
|
|
|
}
|
|
|
|
|
delta, ok := solveSolarEclipse3x3(matrix, [3]float64{-residual[0], -residual[1], -residual[2]})
|
|
|
|
|
if !ok {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
geographicScale := math.Max(math.Abs(delta[0]), math.Abs(delta[1]))
|
|
|
|
|
if geographicScale > 2 {
|
|
|
|
|
delta[0] *= 2 / geographicScale
|
|
|
|
|
delta[1] *= 2 / geographicScale
|
|
|
|
|
}
|
|
|
|
|
if math.Abs(delta[2]) > 5.0/1440.0 {
|
|
|
|
|
delta[2] = math.Copysign(5.0/1440.0, delta[2])
|
|
|
|
|
}
|
|
|
|
|
longitude = normalizeLongitude(longitude + delta[0])
|
|
|
|
|
latitude += delta[1]
|
2026-09-23 18:55:12 +08:00
|
|
|
jde += delta[2]
|
2026-09-17 12:27:40 +08:00
|
|
|
if latitude <= -89.999999 || latitude >= 89.999999 {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-23 18:55:12 +08:00
|
|
|
residual, ok := solver.riseSetPhaseJunctionResidual(jde, longitude, latitude)
|
2026-09-17 12:27:40 +08:00
|
|
|
if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-7 || math.Abs(residual[2]) > 1e-8 {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
2026-09-23 18:55:12 +08:00
|
|
|
evaluation := solver.magnitudeEvaluationAt(jde)
|
2026-09-17 12:27:40 +08:00
|
|
|
if evaluation.partialContactSecondDerivative(longitude, latitude) <= 0 {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
return SolarEclipsePathPoint{
|
2026-09-23 18:55:12 +08:00
|
|
|
JDE: jde, Longitude: longitude, Latitude: latitude, SunAltitude: residual[2] / rad,
|
2026-09-17 12:27:40 +08:00
|
|
|
}, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (solver solarEclipseSolver) refineRiseSetPhaseJunctionOnHorizon(
|
|
|
|
|
seed SolarEclipsePathPoint,
|
|
|
|
|
) (SolarEclipsePathPoint, bool) {
|
|
|
|
|
coordinates := [2]float64{
|
|
|
|
|
solver.riseSetHorizonAngle(seed.JDE, seed.Longitude, seed.Latitude),
|
|
|
|
|
0,
|
|
|
|
|
}
|
|
|
|
|
residualAt := func(value [2]float64) ([2]float64, float64, float64, float64, bool) {
|
2026-09-23 18:55:12 +08:00
|
|
|
jde := seed.JDE + value[1]/1440
|
|
|
|
|
longitude, latitude := solver.riseSetHorizonPointAt(jde, value[0])
|
|
|
|
|
evaluation := solver.magnitudeEvaluationAt(jde)
|
2026-09-17 12:27:40 +08:00
|
|
|
state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0)
|
|
|
|
|
residual := [2]float64{
|
|
|
|
|
solarEclipsePartialContactGap(state),
|
|
|
|
|
evaluation.partialContactDerivative(longitude, latitude),
|
|
|
|
|
}
|
2026-09-23 18:55:12 +08:00
|
|
|
return residual, jde, longitude, latitude, finite(residual[0]) && finite(residual[1])
|
2026-09-17 12:27:40 +08:00
|
|
|
}
|
|
|
|
|
const (
|
|
|
|
|
angleStep = 1e-4
|
|
|
|
|
timeStepMinute = 1.0 / 60.0
|
|
|
|
|
)
|
|
|
|
|
for iteration := 0; iteration < 32; iteration++ {
|
|
|
|
|
residual, _, _, _, ok := residualAt(coordinates)
|
|
|
|
|
if !ok {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
if math.Abs(residual[0]) <= 1e-10 && math.Abs(residual[1]) <= 1e-9 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
anglePlus, _, _, _, anglePlusOK := residualAt([2]float64{coordinates[0] + angleStep, coordinates[1]})
|
|
|
|
|
angleMinus, _, _, _, angleMinusOK := residualAt([2]float64{coordinates[0] - angleStep, coordinates[1]})
|
|
|
|
|
timePlus, _, _, _, timePlusOK := residualAt([2]float64{coordinates[0], coordinates[1] + timeStepMinute})
|
|
|
|
|
timeMinus, _, _, _, timeMinusOK := residualAt([2]float64{coordinates[0], coordinates[1] - timeStepMinute})
|
|
|
|
|
if !anglePlusOK || !angleMinusOK || !timePlusOK || !timeMinusOK {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
matrix := [2][2]float64{
|
|
|
|
|
{(anglePlus[0] - angleMinus[0]) / (2 * angleStep), (timePlus[0] - timeMinus[0]) / (2 * timeStepMinute)},
|
|
|
|
|
{(anglePlus[1] - angleMinus[1]) / (2 * angleStep), (timePlus[1] - timeMinus[1]) / (2 * timeStepMinute)},
|
|
|
|
|
}
|
|
|
|
|
determinant := matrix[0][0]*matrix[1][1] - matrix[0][1]*matrix[1][0]
|
|
|
|
|
if !finite(determinant) || math.Abs(determinant) < 1e-18 {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
delta := [2]float64{
|
|
|
|
|
(-residual[0]*matrix[1][1] + matrix[0][1]*residual[1]) / determinant,
|
|
|
|
|
(-matrix[0][0]*residual[1] + residual[0]*matrix[1][0]) / determinant,
|
|
|
|
|
}
|
|
|
|
|
if math.Abs(delta[0]) > 0.25 {
|
|
|
|
|
delta[0] = math.Copysign(0.25, delta[0])
|
|
|
|
|
}
|
|
|
|
|
if math.Abs(delta[1]) > 5 {
|
|
|
|
|
delta[1] = math.Copysign(5, delta[1])
|
|
|
|
|
}
|
|
|
|
|
coordinates[0] = riseSetNormalizeRadians(coordinates[0] + delta[0])
|
|
|
|
|
coordinates[1] += delta[1]
|
|
|
|
|
}
|
2026-09-23 18:55:12 +08:00
|
|
|
residual, jde, longitude, latitude, ok := residualAt(coordinates)
|
2026-09-17 12:27:40 +08:00
|
|
|
if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-7 {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
2026-09-23 18:55:12 +08:00
|
|
|
return solver.refineRiseSetPhaseJunction(jde, longitude, latitude)
|
2026-09-17 12:27:40 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (solver solarEclipseSolver) riseSetPhaseSegmentIsContinuous(
|
|
|
|
|
start, end SolarEclipsePathPoint,
|
|
|
|
|
phase RiseSetPhase,
|
|
|
|
|
direction RiseSetDirection,
|
|
|
|
|
) bool {
|
|
|
|
|
if math.Abs(end.JDE-start.JDE)*86400 < 0.1 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
totalDistance := solarEclipsePathDistanceKM(start, end)
|
|
|
|
|
continuityToleranceKM := math.Max(50, 0.05*totalDistance)
|
|
|
|
|
candidate := end
|
|
|
|
|
for divisor := 2.0; divisor <= 1024; divisor *= 2 {
|
2026-09-23 18:55:12 +08:00
|
|
|
jde := start.JDE + (end.JDE-start.JDE)/divisor
|
|
|
|
|
seedAngle := solver.riseSetHorizonAngle(jde, candidate.Longitude, candidate.Latitude)
|
|
|
|
|
next, ok := solver.riseSetPhasePointOnHorizon(jde, seedAngle, phase, direction)
|
2026-09-17 12:27:40 +08:00
|
|
|
if !ok {
|
|
|
|
|
return solarEclipsePathDistanceKM(start, candidate) <= continuityToleranceKM
|
|
|
|
|
}
|
|
|
|
|
candidate = next
|
|
|
|
|
}
|
|
|
|
|
return solarEclipsePathDistanceKM(start, candidate) <= continuityToleranceKM
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (solver solarEclipseSolver) riseSetPhasePointOnHorizon(
|
2026-09-23 18:55:12 +08:00
|
|
|
jde, angle float64,
|
2026-09-17 12:27:40 +08:00
|
|
|
phase RiseSetPhase,
|
|
|
|
|
direction RiseSetDirection,
|
|
|
|
|
) (SolarEclipsePathPoint, bool) {
|
2026-09-23 18:55:12 +08:00
|
|
|
evaluation := solver.magnitudeEvaluationAt(jde)
|
2026-09-17 12:27:40 +08:00
|
|
|
greatest := phase == RiseSetPhaseGreatest
|
|
|
|
|
valueAt := func(candidateAngle float64) (float64, float64, float64, bool) {
|
2026-09-23 18:55:12 +08:00
|
|
|
longitude, latitude := solver.riseSetHorizonPointAt(jde, candidateAngle)
|
2026-09-17 12:27:40 +08:00
|
|
|
value, ok := solarEclipseRiseSetPhaseResidual(evaluation, longitude, latitude, greatest)
|
|
|
|
|
return value, longitude, latitude, ok && finite(value)
|
|
|
|
|
}
|
|
|
|
|
const angleStep = 1e-4
|
|
|
|
|
angle = riseSetNormalizeRadians(angle)
|
|
|
|
|
for iteration := 0; iteration < 24; iteration++ {
|
|
|
|
|
value, _, _, ok := valueAt(angle)
|
|
|
|
|
if !ok {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
if math.Abs(value) <= 1e-10 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
before, _, _, beforeOK := valueAt(angle - angleStep)
|
|
|
|
|
after, _, _, afterOK := valueAt(angle + angleStep)
|
|
|
|
|
if !beforeOK || !afterOK {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
derivative := (after - before) / (2 * angleStep)
|
|
|
|
|
if !finite(derivative) || math.Abs(derivative) < 1e-16 {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
delta := -value / derivative
|
|
|
|
|
if math.Abs(delta) > 0.25 {
|
|
|
|
|
delta = math.Copysign(0.25, delta)
|
|
|
|
|
}
|
|
|
|
|
angle = riseSetNormalizeRadians(angle + delta)
|
|
|
|
|
}
|
|
|
|
|
value, longitude, latitude, ok := valueAt(angle)
|
|
|
|
|
if !ok || math.Abs(value) > 1e-7 {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
longitude, latitude, ok = riseSetRefineGeographicRoot(
|
|
|
|
|
longitude,
|
|
|
|
|
latitude,
|
|
|
|
|
func(lon, lat float64) (float64, float64, bool) {
|
|
|
|
|
state := evaluation.center.stateAt(lon*rad, lat*rad, 0)
|
|
|
|
|
first, valid := solarEclipseRiseSetPhaseResidual(evaluation, lon, lat, greatest)
|
|
|
|
|
return first, state.sunAltitudeRad, valid && finite(state.sunAltitudeRad)
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if !ok {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
point, key, valid := evaluation.classify(longitude, latitude, greatest)
|
|
|
|
|
if !valid || key.phase != phase || key.direction != direction {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
return point, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (solver solarEclipseSolver) riseSetFoldBridgesPhaseJunction(
|
|
|
|
|
junction SolarEclipsePathPoint,
|
|
|
|
|
endpoint solarEclipseRiseSetEndpointRef,
|
|
|
|
|
fold SolarEclipsePathPoint,
|
|
|
|
|
phase RiseSetPhase,
|
|
|
|
|
direction RiseSetDirection,
|
|
|
|
|
) bool {
|
|
|
|
|
const timeToleranceDays = 1e-8
|
|
|
|
|
if endpoint.atStart {
|
|
|
|
|
if fold.JDE > math.Min(junction.JDE, endpoint.point.JDE)+timeToleranceDays {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
} else if fold.JDE < math.Max(junction.JDE, endpoint.point.JDE)-timeToleranceDays {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if solarEclipsePathDistanceKM(fold, junction) > 6000 ||
|
|
|
|
|
solarEclipsePathDistanceKM(fold, endpoint.point) > 6000 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
evaluation := solver.magnitudeEvaluationAt(fold.JDE)
|
|
|
|
|
_, key, valid := evaluation.classify(
|
|
|
|
|
fold.Longitude, fold.Latitude, phase == RiseSetPhaseGreatest,
|
|
|
|
|
)
|
|
|
|
|
return valid && key.phase == phase && key.direction == direction
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (solver solarEclipseSolver) refineRiseSetFoldNearPhaseJunction(
|
|
|
|
|
junction SolarEclipsePathPoint,
|
|
|
|
|
endpoint solarEclipseRiseSetEndpointRef,
|
|
|
|
|
phase RiseSetPhase,
|
|
|
|
|
direction RiseSetDirection,
|
|
|
|
|
stepDays float64,
|
|
|
|
|
) (SolarEclipsePathPoint, bool) {
|
|
|
|
|
key := solarEclipseRiseSetCurveKey{phase: phase, direction: direction}
|
|
|
|
|
greatest := phase == RiseSetPhaseGreatest
|
|
|
|
|
for _, sign := range []float64{-1, 1} {
|
|
|
|
|
for divisor := 1024.0; divisor >= 1; divisor /= 2 {
|
|
|
|
|
fraction := 1 / divisor
|
2026-09-23 18:55:12 +08:00
|
|
|
jde := junction.JDE + sign*fraction*stepDays
|
|
|
|
|
roots := solver.riseSetPointsAt(jde, solarEclipseRiseSetBoundaryPoints)[key]
|
2026-09-17 12:27:40 +08:00
|
|
|
if len(roots) < 2 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for first := 0; first < len(roots); first++ {
|
|
|
|
|
for second := first + 1; second < len(roots); second++ {
|
|
|
|
|
fold, ok := solver.refineRiseSetFold(roots[first], roots[second], greatest)
|
|
|
|
|
if ok && solver.riseSetFoldBridgesPhaseJunction(
|
|
|
|
|
junction, endpoint, fold, phase, direction,
|
|
|
|
|
) {
|
|
|
|
|
return fold, true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (solver solarEclipseSolver) riseSetPhaseJunctionResidual(
|
2026-09-23 18:55:12 +08:00
|
|
|
jde, longitude, latitude float64,
|
2026-09-17 12:27:40 +08:00
|
|
|
) ([3]float64, bool) {
|
2026-09-23 18:55:12 +08:00
|
|
|
evaluation := solver.magnitudeEvaluationAt(jde)
|
2026-09-17 12:27:40 +08:00
|
|
|
return solarEclipseRiseSetPhaseJunctionResidualAt(evaluation, longitude, latitude)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func solarEclipseRiseSetPhaseJunctionResidualAt(
|
|
|
|
|
evaluation solarEclipseRiseSetEvaluation,
|
|
|
|
|
longitude, latitude float64,
|
|
|
|
|
) ([3]float64, bool) {
|
|
|
|
|
state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0)
|
|
|
|
|
residual := [3]float64{
|
|
|
|
|
solarEclipsePartialContactGap(state),
|
|
|
|
|
evaluation.partialContactDerivative(longitude, latitude),
|
|
|
|
|
state.sunAltitudeRad,
|
|
|
|
|
}
|
|
|
|
|
return residual, finite(residual[0]) && finite(residual[1]) && finite(residual[2])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (solver solarEclipseSolver) refineRiseSetFold(
|
|
|
|
|
first, second SolarEclipsePathPoint,
|
|
|
|
|
greatest bool,
|
|
|
|
|
) (SolarEclipsePathPoint, bool) {
|
|
|
|
|
seedJDE := (first.JDE + second.JDE) / 2
|
|
|
|
|
firstAngle := solver.riseSetHorizonAngle(seedJDE, first.Longitude, first.Latitude)
|
|
|
|
|
secondAngle := solver.riseSetHorizonAngle(seedJDE, second.Longitude, second.Latitude)
|
|
|
|
|
deltaAngle := math.Remainder(secondAngle-firstAngle, 2*math.Pi)
|
|
|
|
|
coordinates := [2]float64{riseSetNormalizeRadians(firstAngle + deltaAngle/2), 0}
|
|
|
|
|
const (
|
|
|
|
|
angleStep = 1e-4
|
|
|
|
|
timeStepMinute = 1.0 / 60.0
|
|
|
|
|
)
|
|
|
|
|
for iteration := 0; iteration < 32; iteration++ {
|
|
|
|
|
residual, ok := solver.riseSetFoldHorizonResidual(seedJDE, coordinates, greatest)
|
|
|
|
|
if !ok {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
if math.Abs(residual[0]) <= 1e-10 && math.Abs(residual[1]) <= 1e-9 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
anglePlus, anglePlusOK := solver.riseSetFoldHorizonResidual(
|
|
|
|
|
seedJDE, [2]float64{coordinates[0] + angleStep, coordinates[1]}, greatest,
|
|
|
|
|
)
|
|
|
|
|
angleMinus, angleMinusOK := solver.riseSetFoldHorizonResidual(
|
|
|
|
|
seedJDE, [2]float64{coordinates[0] - angleStep, coordinates[1]}, greatest,
|
|
|
|
|
)
|
|
|
|
|
timePlus, timePlusOK := solver.riseSetFoldHorizonResidual(
|
|
|
|
|
seedJDE, [2]float64{coordinates[0], coordinates[1] + timeStepMinute}, greatest,
|
|
|
|
|
)
|
|
|
|
|
timeMinus, timeMinusOK := solver.riseSetFoldHorizonResidual(
|
|
|
|
|
seedJDE, [2]float64{coordinates[0], coordinates[1] - timeStepMinute}, greatest,
|
|
|
|
|
)
|
|
|
|
|
if !anglePlusOK || !angleMinusOK || !timePlusOK || !timeMinusOK {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
matrix := [2][2]float64{}
|
|
|
|
|
for row := 0; row < 2; row++ {
|
|
|
|
|
matrix[row][0] = (anglePlus[row] - angleMinus[row]) / (2 * angleStep)
|
|
|
|
|
matrix[row][1] = (timePlus[row] - timeMinus[row]) / (2 * timeStepMinute)
|
|
|
|
|
}
|
|
|
|
|
determinant := matrix[0][0]*matrix[1][1] - matrix[0][1]*matrix[1][0]
|
|
|
|
|
if !finite(determinant) || math.Abs(determinant) < 1e-18 {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
delta := [2]float64{
|
|
|
|
|
(-residual[0]*matrix[1][1] + matrix[0][1]*residual[1]) / determinant,
|
|
|
|
|
(-matrix[0][0]*residual[1] + residual[0]*matrix[1][0]) / determinant,
|
|
|
|
|
}
|
|
|
|
|
if math.Abs(delta[0]) > 0.25 {
|
|
|
|
|
delta[0] = math.Copysign(0.25, delta[0])
|
|
|
|
|
}
|
|
|
|
|
if math.Abs(delta[1]) > 5 {
|
|
|
|
|
delta[1] = math.Copysign(5, delta[1])
|
|
|
|
|
}
|
|
|
|
|
coordinates[0] = riseSetNormalizeRadians(coordinates[0] + delta[0])
|
|
|
|
|
coordinates[1] += delta[1]
|
|
|
|
|
}
|
2026-09-23 18:55:12 +08:00
|
|
|
jde := seedJDE + coordinates[1]/1440.0
|
|
|
|
|
longitude, latitude := solver.riseSetHorizonPointAt(jde, coordinates[0])
|
|
|
|
|
return solver.refineRiseSetFoldPoint(jde, longitude, latitude, greatest)
|
2026-09-17 12:27:40 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (solver solarEclipseSolver) riseSetFoldHorizonResidual(
|
|
|
|
|
seedJDE float64,
|
|
|
|
|
coordinates [2]float64,
|
|
|
|
|
greatest bool,
|
|
|
|
|
) ([2]float64, bool) {
|
|
|
|
|
const derivativeStep = 1e-4
|
2026-09-23 18:55:12 +08:00
|
|
|
jde := seedJDE + coordinates[1]/1440.0
|
|
|
|
|
evaluation := solver.magnitudeEvaluationAt(jde)
|
2026-09-17 12:27:40 +08:00
|
|
|
centerLongitude, centerLatitude := solarEclipseRiseSetHorizonCenter(evaluation)
|
|
|
|
|
valueAt := func(angle float64) (float64, bool) {
|
|
|
|
|
longitude, latitude := riseSetHorizonPoint(centerLongitude, centerLatitude, angle)
|
|
|
|
|
return solarEclipseRiseSetPhaseResidual(evaluation, longitude, latitude, greatest)
|
|
|
|
|
}
|
|
|
|
|
center, centerOK := valueAt(coordinates[0])
|
|
|
|
|
before, beforeOK := valueAt(coordinates[0] - derivativeStep)
|
|
|
|
|
after, afterOK := valueAt(coordinates[0] + derivativeStep)
|
|
|
|
|
residual := [2]float64{center, (after - before) / (2 * derivativeStep)}
|
|
|
|
|
return residual, centerOK && beforeOK && afterOK && finite(residual[1])
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 18:55:12 +08:00
|
|
|
func (solver solarEclipseSolver) riseSetHorizonPointAt(jde, angle float64) (float64, float64) {
|
|
|
|
|
evaluation := solver.magnitudeEvaluationAt(jde)
|
2026-09-17 12:27:40 +08:00
|
|
|
centerLongitude, centerLatitude := solarEclipseRiseSetHorizonCenter(evaluation)
|
|
|
|
|
return riseSetHorizonPoint(centerLongitude, centerLatitude, angle)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (solver solarEclipseSolver) riseSetHorizonAngle(
|
2026-09-23 18:55:12 +08:00
|
|
|
jde, longitude, latitude float64,
|
2026-09-17 12:27:40 +08:00
|
|
|
) float64 {
|
2026-09-23 18:55:12 +08:00
|
|
|
evaluation := solver.magnitudeEvaluationAt(jde)
|
2026-09-17 12:27:40 +08:00
|
|
|
centerLongitude, centerLatitude := solarEclipseRiseSetHorizonCenter(evaluation)
|
|
|
|
|
centerLon, centerLat := centerLongitude*rad, centerLatitude*rad
|
|
|
|
|
center := [3]float64{
|
|
|
|
|
math.Cos(centerLat) * math.Cos(centerLon),
|
|
|
|
|
math.Cos(centerLat) * math.Sin(centerLon),
|
|
|
|
|
math.Sin(centerLat),
|
|
|
|
|
}
|
|
|
|
|
reference := [3]float64{0, 0, 1}
|
|
|
|
|
if math.Abs(center[2]) > 0.9 {
|
|
|
|
|
reference = [3]float64{1, 0, 0}
|
|
|
|
|
}
|
|
|
|
|
first := riseSetUnitVector(riseSetCross(reference, center))
|
|
|
|
|
second := riseSetUnitVector(riseSetCross(center, first))
|
|
|
|
|
lon, lat := longitude*rad, latitude*rad
|
|
|
|
|
point := [3]float64{math.Cos(lat) * math.Cos(lon), math.Cos(lat) * math.Sin(lon), math.Sin(lat)}
|
|
|
|
|
return riseSetNormalizeRadians(math.Atan2(dotSolarEclipse3(point, second), dotSolarEclipse3(point, first)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func solarEclipseRiseSetHorizonCenter(evaluation solarEclipseRiseSetEvaluation) (float64, float64) {
|
|
|
|
|
sun := solarEclipseXYZToLLR(
|
|
|
|
|
evaluation.center.sunXYZ[0], evaluation.center.sunXYZ[1], evaluation.center.sunXYZ[2],
|
|
|
|
|
)
|
|
|
|
|
return normalizeLongitude((sun[0] - evaluation.center.gst) / rad), sun[1] / rad
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (solver solarEclipseSolver) refineRiseSetFoldPoint(
|
|
|
|
|
jd, longitude, latitude float64,
|
|
|
|
|
greatest bool,
|
|
|
|
|
) (SolarEclipsePathPoint, bool) {
|
|
|
|
|
const (
|
|
|
|
|
geographicStep = 1e-3
|
|
|
|
|
timeStep = 1.0 / 86400.0
|
|
|
|
|
)
|
|
|
|
|
coordinates := [3]float64{longitude, latitude, jd}
|
|
|
|
|
for iteration := 0; iteration < 32; iteration++ {
|
|
|
|
|
evaluation := solver.magnitudeEvaluationAt(coordinates[2])
|
|
|
|
|
residual, ok := solarEclipseRiseSetFoldResidualAt(evaluation, coordinates[0], coordinates[1], greatest)
|
|
|
|
|
if !ok {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
if math.Abs(residual[0]) <= 1e-10 && math.Abs(residual[1]) <= 1e-10 && math.Abs(residual[2]) <= 1e-10 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
steps := [3]float64{geographicStep, geographicStep, timeStep}
|
|
|
|
|
matrix := [3][3]float64{}
|
|
|
|
|
for column := 0; column < 3; column++ {
|
|
|
|
|
plus, minus := coordinates, coordinates
|
|
|
|
|
plus[column] += steps[column]
|
|
|
|
|
minus[column] -= steps[column]
|
|
|
|
|
var plusResidual, minusResidual [3]float64
|
|
|
|
|
var plusOK, minusOK bool
|
|
|
|
|
if column < 2 {
|
|
|
|
|
plusResidual, plusOK = solarEclipseRiseSetFoldResidualAt(evaluation, plus[0], plus[1], greatest)
|
|
|
|
|
minusResidual, minusOK = solarEclipseRiseSetFoldResidualAt(evaluation, minus[0], minus[1], greatest)
|
|
|
|
|
} else {
|
|
|
|
|
plusResidual, plusOK = solver.riseSetFoldResidual(plus[2], plus[0], plus[1], greatest)
|
|
|
|
|
minusResidual, minusOK = solver.riseSetFoldResidual(minus[2], minus[0], minus[1], greatest)
|
|
|
|
|
}
|
|
|
|
|
if !plusOK || !minusOK {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
for row := 0; row < 3; row++ {
|
|
|
|
|
matrix[row][column] = (plusResidual[row] - minusResidual[row]) / (2 * steps[column])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
delta, ok := solveSolarEclipse3x3(matrix, [3]float64{-residual[0], -residual[1], -residual[2]})
|
|
|
|
|
if !ok {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
geographicScale := math.Max(math.Abs(delta[0]), math.Abs(delta[1]))
|
|
|
|
|
if geographicScale > 2 {
|
|
|
|
|
delta[0] *= 2 / geographicScale
|
|
|
|
|
delta[1] *= 2 / geographicScale
|
|
|
|
|
}
|
|
|
|
|
if math.Abs(delta[2]) > 2.0/1440.0 {
|
|
|
|
|
delta[2] = math.Copysign(2.0/1440.0, delta[2])
|
|
|
|
|
}
|
|
|
|
|
for index := range coordinates {
|
|
|
|
|
coordinates[index] += delta[index]
|
|
|
|
|
}
|
|
|
|
|
coordinates[0] = normalizeLongitude(coordinates[0])
|
|
|
|
|
if coordinates[1] <= -89.999999 || coordinates[1] >= 89.999999 {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
residual, ok := solver.riseSetFoldResidual(coordinates[2], coordinates[0], coordinates[1], greatest)
|
|
|
|
|
if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-8 || math.Abs(residual[2]) > 1e-7 {
|
|
|
|
|
return SolarEclipsePathPoint{}, false
|
|
|
|
|
}
|
|
|
|
|
evaluation := solver.magnitudeEvaluationAt(coordinates[2])
|
|
|
|
|
state := evaluation.center.stateAt(coordinates[0]*rad, coordinates[1]*rad, 0)
|
|
|
|
|
return SolarEclipsePathPoint{
|
|
|
|
|
JDE: coordinates[2], Longitude: coordinates[0], Latitude: coordinates[1], SunAltitude: state.sunAltitudeRad / rad,
|
|
|
|
|
}, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (solver solarEclipseSolver) riseSetFoldResidual(
|
2026-09-23 18:55:12 +08:00
|
|
|
jde, longitude, latitude float64,
|
2026-09-17 12:27:40 +08:00
|
|
|
greatest bool,
|
|
|
|
|
) ([3]float64, bool) {
|
2026-09-23 18:55:12 +08:00
|
|
|
evaluation := solver.magnitudeEvaluationAt(jde)
|
2026-09-17 12:27:40 +08:00
|
|
|
return solarEclipseRiseSetFoldResidualAt(evaluation, longitude, latitude, greatest)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func solarEclipseRiseSetFoldResidualAt(
|
|
|
|
|
evaluation solarEclipseRiseSetEvaluation,
|
|
|
|
|
longitude, latitude float64,
|
|
|
|
|
greatest bool,
|
|
|
|
|
) ([3]float64, bool) {
|
|
|
|
|
const step = 1e-3
|
|
|
|
|
valueAt := func(lon, lat float64) ([2]float64, bool) {
|
|
|
|
|
first, ok := solarEclipseRiseSetPhaseResidual(evaluation, lon, lat, greatest)
|
|
|
|
|
state := evaluation.center.stateAt(lon*rad, lat*rad, 0)
|
|
|
|
|
return [2]float64{first, state.sunAltitudeRad}, ok && finite(state.sunAltitudeRad)
|
|
|
|
|
}
|
|
|
|
|
center, centerOK := valueAt(longitude, latitude)
|
|
|
|
|
lonPlus, lonPlusOK := valueAt(longitude+step, latitude)
|
|
|
|
|
lonMinus, lonMinusOK := valueAt(longitude-step, latitude)
|
|
|
|
|
latPlus, latPlusOK := valueAt(longitude, latitude+step)
|
|
|
|
|
latMinus, latMinusOK := valueAt(longitude, latitude-step)
|
|
|
|
|
if !centerOK || !lonPlusOK || !lonMinusOK || !latPlusOK || !latMinusOK {
|
|
|
|
|
return [3]float64{}, false
|
|
|
|
|
}
|
|
|
|
|
dFirstLon := (lonPlus[0] - lonMinus[0]) / (2 * step)
|
|
|
|
|
dFirstLat := (latPlus[0] - latMinus[0]) / (2 * step)
|
|
|
|
|
dAltitudeLon := (lonPlus[1] - lonMinus[1]) / (2 * step)
|
|
|
|
|
dAltitudeLat := (latPlus[1] - latMinus[1]) / (2 * step)
|
|
|
|
|
residual := [3]float64{
|
|
|
|
|
center[0],
|
|
|
|
|
center[1],
|
|
|
|
|
dFirstLon*dAltitudeLat - dFirstLat*dAltitudeLon,
|
|
|
|
|
}
|
|
|
|
|
return residual, finite(residual[2])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func solarEclipseRiseSetPhaseResidual(
|
|
|
|
|
evaluation solarEclipseRiseSetEvaluation,
|
|
|
|
|
longitude, latitude float64,
|
|
|
|
|
greatest bool,
|
|
|
|
|
) (float64, bool) {
|
|
|
|
|
if greatest {
|
|
|
|
|
value := evaluation.separationDerivative(longitude, latitude)
|
|
|
|
|
return value, finite(value)
|
|
|
|
|
}
|
|
|
|
|
state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0)
|
|
|
|
|
value := solarEclipsePartialContactGap(state)
|
|
|
|
|
return value, finite(value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (solver solarEclipseSolver) refineRiseSetCurveSpacing(curve *SolarEclipseRiseSetCurve) {
|
|
|
|
|
if curve == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
solver = solver.withLocalEphemeris()
|
|
|
|
|
for segmentIndex, segment := range curve.Segments {
|
|
|
|
|
if len(segment) < 2 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
refined := make([]SolarEclipsePathPoint, 1, len(segment))
|
|
|
|
|
refined[0] = segment[0]
|
|
|
|
|
for pointIndex := 1; pointIndex < len(segment); pointIndex++ {
|
|
|
|
|
refined = solver.appendRefinedRiseSetSegment(
|
|
|
|
|
refined, segment[pointIndex-1], segment[pointIndex], curve.Phase, curve.Direction, 0,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
curve.Segments[segmentIndex] = refined
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (solver solarEclipseSolver) appendRefinedRiseSetSegment(
|
|
|
|
|
points []SolarEclipsePathPoint,
|
|
|
|
|
start, end SolarEclipsePathPoint,
|
|
|
|
|
phase RiseSetPhase,
|
|
|
|
|
direction RiseSetDirection,
|
|
|
|
|
depth int,
|
|
|
|
|
) []SolarEclipsePathPoint {
|
|
|
|
|
if depth >= 12 || end.JDE-start.JDE <= solarEclipsePathMinStepDays {
|
|
|
|
|
return append(points, end)
|
|
|
|
|
}
|
2026-09-23 18:55:12 +08:00
|
|
|
jde := (start.JDE + end.JDE) / 2
|
2026-09-17 12:27:40 +08:00
|
|
|
longitude := normalizeLongitude(start.Longitude + math.Remainder(end.Longitude-start.Longitude, 360)/2)
|
|
|
|
|
latitude := (start.Latitude + end.Latitude) / 2
|
|
|
|
|
// A comfortably straight candidate needs no new output vertex. Reserve
|
|
|
|
|
// half the chord budget for prediction error; every inserted vertex still
|
|
|
|
|
// uses the exact ephemeris and the original phase residual checks.
|
|
|
|
|
short := solarEclipsePathDistanceKM(start, end) <= solarEclipseRiseSetTargetSpacingKM
|
|
|
|
|
if short && solver.localEphemeris != nil {
|
2026-09-23 18:55:12 +08:00
|
|
|
candidate, key, valid := solarEclipseRefineRiseSetMiddle(solver.magnitudeCandidateEvaluationAt(jde), longitude, latitude, phase)
|
2026-09-17 12:27:40 +08:00
|
|
|
if valid && key.phase == phase && key.direction == direction &&
|
|
|
|
|
solarEclipseRiseSetChordDeviationKM(candidate, start, end) <= solarEclipseRiseSetChordToleranceKM/2 {
|
|
|
|
|
return append(points, end)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-23 18:55:12 +08:00
|
|
|
middle, key, valid := solarEclipseRefineRiseSetMiddle(solver.magnitudeEvaluationAt(jde), longitude, latitude, phase)
|
2026-09-17 12:27:40 +08:00
|
|
|
if !valid || key.phase != phase || key.direction != direction {
|
|
|
|
|
return append(points, end)
|
|
|
|
|
}
|
|
|
|
|
if short &&
|
|
|
|
|
solarEclipseRiseSetChordDeviationKM(middle, start, end) <= solarEclipseRiseSetChordToleranceKM {
|
|
|
|
|
return append(points, end)
|
|
|
|
|
}
|
|
|
|
|
points = solver.appendRefinedRiseSetSegment(points, start, middle, phase, direction, depth+1)
|
|
|
|
|
return solver.appendRefinedRiseSetSegment(points, middle, end, phase, direction, depth+1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func solarEclipseRefineRiseSetMiddle(
|
|
|
|
|
evaluation solarEclipseRiseSetEvaluation,
|
|
|
|
|
longitude, latitude float64,
|
|
|
|
|
phase RiseSetPhase,
|
|
|
|
|
) (SolarEclipsePathPoint, solarEclipseRiseSetCurveKey, bool) {
|
|
|
|
|
greatest := phase == RiseSetPhaseGreatest
|
|
|
|
|
longitude, latitude, ok := riseSetRefineGeographicRoot(longitude, latitude,
|
|
|
|
|
func(lon, lat float64) (float64, float64, bool) {
|
|
|
|
|
first, valid := solarEclipseRiseSetPhaseResidual(evaluation, lon, lat, greatest)
|
|
|
|
|
state := evaluation.center.stateAt(lon*rad, lat*rad, 0)
|
|
|
|
|
return first, state.sunAltitudeRad, valid && finite(state.sunAltitudeRad)
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
return SolarEclipsePathPoint{}, solarEclipseRiseSetCurveKey{}, false
|
|
|
|
|
}
|
|
|
|
|
return evaluation.classify(longitude, latitude, greatest)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const solarEclipseRiseSetChordToleranceKM = 0.25
|
|
|
|
|
|
|
|
|
|
func solarEclipseRiseSetChordDeviationKM(point, start, end SolarEclipsePathPoint) float64 {
|
|
|
|
|
first := solarEclipseLLRToXYZ(start.Longitude*rad, start.Latitude*rad, 1)
|
|
|
|
|
last := solarEclipseLLRToXYZ(end.Longitude*rad, end.Latitude*rad, 1)
|
|
|
|
|
middle := solarEclipseLLRToXYZ(point.Longitude*rad, point.Latitude*rad, 1)
|
|
|
|
|
normal := solarEclipseRiseSetCross(first, last)
|
|
|
|
|
norm := math.Sqrt(dotSolarEclipse3(normal, normal))
|
|
|
|
|
if norm < 1e-12 || dotSolarEclipse3(solarEclipseRiseSetCross(first, middle), normal) < 0 ||
|
|
|
|
|
dotSolarEclipse3(solarEclipseRiseSetCross(middle, last), normal) < 0 {
|
|
|
|
|
return math.Min(solarEclipsePathDistanceKM(point, start), solarEclipsePathDistanceKM(point, end))
|
|
|
|
|
}
|
|
|
|
|
return solarEclipseEarthEquatorialRadiusKM * math.Asin(math.Min(1, math.Abs(dotSolarEclipse3(middle, normal))/norm))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func solarEclipseRiseSetCross(first, second [3]float64) [3]float64 {
|
|
|
|
|
return [3]float64{
|
|
|
|
|
first[1]*second[2] - first[2]*second[1],
|
|
|
|
|
first[2]*second[0] - first[0]*second[2],
|
|
|
|
|
first[0]*second[1] - first[1]*second[0],
|
|
|
|
|
}
|
|
|
|
|
}
|