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

727 lines
26 KiB
Go

package basic
import (
"math"
"b612.me/astro/internal/geodata"
)
const (
solarEclipseNonCentralBandTimeScale = 360.0
solarEclipseNonCentralBandContainmentToleranceKM = solarEclipseCentralBandTargetSpacingKM / 2
solarEclipseNonCentralBandDerivativeTolerance = 5e-7
)
type solarEclipseNonCentralBandState struct {
coordinates [3]float64
tangent [3]float64
point SolarEclipsePathPoint
}
func solarEclipseNonCentralBandContainsFootprints(
segments [][]SolarEclipsePathPoint,
footprints []SolarEclipsePartialFootprint,
) bool {
return solarEclipseBandContainsFootprintsWithinKM(
segments, footprints, solarEclipseNonCentralBandContainmentToleranceKM,
)
}
// solarEclipseBandContainsFootprintsWithinKM is the tolerance-aware form used
// by the sampled central-band reconstruction, whose decimated rings are not an
// analytic envelope and may cut inside the sharpest grazing tips.
func solarEclipseBandContainsFootprintsWithinKM(
segments [][]SolarEclipsePathPoint,
footprints []SolarEclipsePartialFootprint,
toleranceKM float64,
) bool {
polygons, paths := solarEclipseBandFootprintGeometry(segments, footprints)
if len(polygons) == 0 || len(paths) == 0 {
return false
}
return geodata.SphericalPolygonsContainPathsWithinKM(
polygons, paths, false, toleranceKM,
)
}
// The sweep audit samples the footprint series instead of probing every vertex
// of every instantaneous footprint: the reconstruction residual is a systematic
// gap, not one stray vertex, and a full probe costs an order of magnitude more
// than building the band (1136-06-01: 109k probes, 1.3 s in Go).
const (
solarEclipseCentralBandSweepProbePaths = 64
solarEclipseCentralBandSweepProbePoints = 32
)
// solarEclipseBandContainsSampledFootprintsWithinKM validates a candidate
// against a bounded, evenly spread probe set of the reference footprints.
func solarEclipseBandContainsSampledFootprintsWithinKM(
segments [][]SolarEclipsePathPoint,
footprints []SolarEclipsePartialFootprint,
toleranceKM float64,
) bool {
polygons, _ := solarEclipseBandFootprintGeometry(segments, footprints)
if len(polygons) == 0 || len(footprints) == 0 {
return false
}
paths := make([][]geodata.GeoPoint, 0, solarEclipseCentralBandSweepProbePaths)
for _, index := range solarEclipseBandProbeIndices(len(footprints), solarEclipseCentralBandSweepProbePaths) {
for _, boundary := range footprints[index].Boundaries {
if len(boundary) < 3 {
continue
}
path := make([]geodata.GeoPoint, 0, solarEclipseCentralBandSweepProbePoints)
for _, pointIndex := range solarEclipseBandProbeIndices(len(boundary), solarEclipseCentralBandSweepProbePoints) {
point := boundary[pointIndex]
path = append(path, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
}
if len(path) >= 3 {
paths = append(paths, path)
}
}
}
if len(paths) == 0 {
return false
}
return geodata.SphericalPolygonsContainPathsWithinKM(polygons, paths, false, toleranceKM)
}
// solarEclipseBandProbeIndices returns at most limit evenly spread indices over
// count items, always including the first and the last one.
func solarEclipseBandProbeIndices(count, limit int) []int {
if count <= 0 {
return nil
}
if limit < 2 {
limit = 2
}
if count <= limit {
indices := make([]int, count)
for index := range indices {
indices[index] = index
}
return indices
}
indices := make([]int, 0, limit)
for index := 0; index < limit; index++ {
indices = append(indices, index*(count-1)/(limit-1))
}
return indices
}
// solarEclipseBandFootprintGeometry converts one band candidate and its
// reference footprints into the spherical form the audits share.
func solarEclipseBandFootprintGeometry(
segments [][]SolarEclipsePathPoint,
footprints []SolarEclipsePartialFootprint,
) ([][]geodata.GeoPoint, [][]geodata.GeoPoint) {
if len(segments) == 0 || len(footprints) == 0 {
return nil, nil
}
polygons := make([][]geodata.GeoPoint, 0, len(segments))
for _, segment := range segments {
polygon := make([]geodata.GeoPoint, len(segment))
for index, point := range segment {
polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
polygons = append(polygons, polygon)
}
paths := make([][]geodata.GeoPoint, 0, len(footprints))
for _, footprint := range footprints {
for _, boundary := range footprint.Boundaries {
path := make([]geodata.GeoPoint, len(boundary))
for index, point := range boundary {
path[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
paths = append(paths, path)
}
}
return polygons, paths
}
// nonCentralBandRegion returns one closed annular region for a non-central
// eclipse. The sampled critical envelope supplies the visible outer arc; the
// horizon arc supplies the degenerate side where the band reaches sunset or
// sunrise.
func (solver solarEclipseSolver) nonCentralBandRegion(
samples []solarEclipseCentralBandSweepSample,
riseSetCurves []SolarEclipseRiseSetCurve,
referenceJDE float64,
) ([]SolarEclipsePathPoint, []SolarEclipsePathPoint) {
boundary := solver.correctNonCentralBandEnvelopeSamples(
samples, referenceJDE,
)
return solver.closeNonCentralBandBoundary(boundary, riseSetCurves)
}
func (solver solarEclipseSolver) closeNonCentralBandBoundary(
boundary []SolarEclipsePathPoint,
riseSetCurves []SolarEclipseRiseSetCurve,
) ([]SolarEclipsePathPoint, []SolarEclipsePathPoint) {
horizon := solver.nonCentralBandHorizonSegment(riseSetCurves)
if len(horizon) < 3 {
return nil, nil
}
if len(boundary) < 2 {
return nil, nil
}
keep := solarEclipsePathDistanceKM(boundary[0], horizon[0]) +
solarEclipsePathDistanceKM(boundary[len(boundary)-1], horizon[len(horizon)-1])
reverse := solarEclipsePathDistanceKM(boundary[0], horizon[len(horizon)-1]) +
solarEclipsePathDistanceKM(boundary[len(boundary)-1], horizon[0])
if reverse < keep {
for left, right := 0, len(horizon)-1; left < right; left, right = left+1, right-1 {
horizon[left], horizon[right] = horizon[right], horizon[left]
}
}
polygon := append([]SolarEclipsePathPoint(nil), boundary...)
polygon = appendNonCentralBandInterpolatedSegment(polygon, boundary[len(boundary)-1], horizon[len(horizon)-1])
for index := len(horizon) - 2; index >= 0; index-- {
polygon = append(polygon, horizon[index])
}
polygon = appendNonCentralBandInterpolatedSegment(polygon, horizon[0], boundary[0])
polygon = deduplicateSolarEclipsePathPoints(polygon)
if len(polygon) < 4 || solarEclipsePathDistanceKM(polygon[0], polygon[len(polygon)-1]) > 0.01 {
return nil, nil
}
polygon[len(polygon)-1] = polygon[0]
return polygon, horizon
}
// alignNonCentralBandHorizon replaces the coarse public greatest-at-horizon
// samples with the exact horizon side used to close the non-central band. The
// shared vertices keep GeoJSON and SVG renderers from drawing a chord through
// the narrow band between otherwise identical roots.
func alignNonCentralBandHorizon(
curves []SolarEclipseRiseSetCurve,
horizon []SolarEclipsePathPoint,
) {
if len(horizon) < 2 {
return
}
start, end := horizon[0], horizon[len(horizon)-1]
for curveIndex := range curves {
curve := &curves[curveIndex]
if curve.Phase != RiseSetPhaseGreatest {
continue
}
for segmentIndex, segment := range curve.Segments {
if len(segment) < 2 || segment[0].JDE >= end.JDE || segment[len(segment)-1].JDE <= start.JDE {
continue
}
if !nonCentralBandHorizonMatchesSegment(segment, start, end) {
continue
}
joined := make([]SolarEclipsePathPoint, 0, len(segment)+len(horizon))
for _, point := range segment {
if point.JDE < start.JDE-solarEclipseRiseSetTimeEpsilonDays {
joined = append(joined, point)
}
}
joined = append(joined, horizon...)
for _, point := range segment {
if point.JDE > end.JDE+solarEclipseRiseSetTimeEpsilonDays {
joined = append(joined, point)
}
}
curve.Segments[segmentIndex] = joined
// The exact horizon arc may fold in time at high latitude. It is
// attached after the normal rise/set topology pass, so normalize
// here as well to split that newly introduced fold into branches.
normalizeSolarEclipseRiseSetCurveSegments(curve)
return
}
}
}
func nonCentralBandHorizonMatchesSegment(
segment []SolarEclipsePathPoint,
start, end SolarEclipsePathPoint,
) bool {
const maximumAttachmentDistanceKM = 100.0
nearestDistance := func(target SolarEclipsePathPoint) float64 {
best := math.Inf(1)
for _, point := range segment {
if math.Abs(point.JDE-target.JDE) > 10.0/1440.0 {
continue
}
best = math.Min(best, solarEclipsePathDistanceKM(point, target))
}
return best
}
return nearestDistance(start) <= maximumAttachmentDistanceKM &&
nearestDistance(end) <= maximumAttachmentDistanceKM
}
func (solver solarEclipseSolver) correctNonCentralBandEnvelopeSamples(
samples []solarEclipseCentralBandSweepSample,
referenceJDE float64,
) []SolarEclipsePathPoint {
points := make([]SolarEclipsePathPoint, 0, len(samples))
for _, sample := range samples {
state, stateOK := solver.nonCentralBandStateAt(sample.envelope, referenceJDE)
if !stateOK {
continue
}
corrected, _, correctedOK := solver.correctNonCentralBandBoundary(
state.coordinates, state.tangent, referenceJDE,
)
// A near-grazing Newton solve can converge to another critical branch.
// Keep the correction only when it remains close to this sample;
// otherwise validate and use the local predictor below.
if correctedOK && solarEclipsePathDistanceKM(corrected.point, sample.envelope) <=
2*solarEclipseCentralBandTargetSpacingKM {
state = corrected
} else {
evaluation := solver.magnitudeEvaluationAt(sample.envelope.JDE)
approximate := evaluation.center.stateAt(sample.envelope.Longitude*rad, sample.envelope.Latitude*rad, 0)
if math.Abs(solarEclipseCentralContactGap(approximate)) > 1e-6 ||
evaluation.centralContactSecondDerivative(sample.envelope.Longitude, sample.envelope.Latitude) <= 0 {
continue
}
state.point = SolarEclipsePathPoint{
JDE: sample.envelope.JDE, Longitude: sample.envelope.Longitude,
Latitude: sample.envelope.Latitude, SunAltitude: approximate.sunAltitudeRad / rad,
}
}
if len(points) > 0 && solarEclipsePathDistanceKM(points[len(points)-1], state.point) >
2*solarEclipseCentralBandTargetSpacingKM {
// Prefer the continuous predictor if a corrected branch jumped.
evaluation := solver.magnitudeEvaluationAt(sample.envelope.JDE)
approximate := evaluation.center.stateAt(sample.envelope.Longitude*rad, sample.envelope.Latitude*rad, 0)
if math.Abs(solarEclipseCentralContactGap(approximate)) > 1e-6 ||
evaluation.centralContactSecondDerivative(sample.envelope.Longitude, sample.envelope.Latitude) <= 0 ||
solarEclipsePathDistanceKM(points[len(points)-1], sample.envelope) >
2*solarEclipseCentralBandTargetSpacingKM {
return nil
}
state.point = sample.envelope
}
points = append(points, state.point)
}
return deduplicateSolarEclipsePathPoints(points)
}
func appendNonCentralBandInterpolatedSegment(
points []SolarEclipsePathPoint,
start, end SolarEclipsePathPoint,
) []SolarEclipsePathPoint {
distance := solarEclipsePathDistanceKM(start, end)
steps := int(math.Ceil(distance / solarEclipseCentralBandTargetSpacingKM))
if steps < 1 {
steps = 1
}
deltaLongitude := math.Remainder(end.Longitude-start.Longitude, 360)
for step := 1; step <= steps; step++ {
fraction := float64(step) / float64(steps)
points = append(points, SolarEclipsePathPoint{
JDE: start.JDE + fraction*(end.JDE-start.JDE),
Longitude: normalizeLongitude(start.Longitude + fraction*deltaLongitude),
Latitude: start.Latitude + fraction*(end.Latitude-start.Latitude),
SunAltitude: start.SunAltitude + fraction*(end.SunAltitude-start.SunAltitude),
})
}
return points
}
func (solver solarEclipseSolver) nonCentralBandHorizonSegment(
curves []SolarEclipseRiseSetCurve,
) []SolarEclipsePathPoint {
var best []SolarEclipsePathPoint
for _, curve := range curves {
if curve.Phase != RiseSetPhaseGreatest {
continue
}
for _, segment := range curve.Segments {
segment = solver.correctNonCentralBandHorizonSamples(segment)
if len(segment) < 2 {
continue
}
var current []SolarEclipsePathPoint
for index := 1; index < len(segment); index++ {
first, second := segment[index-1], segment[index]
firstGap, firstOK := solver.nonCentralBandGapAt(first)
secondGap, secondOK := solver.nonCentralBandGapAt(second)
if !firstOK || !secondOK {
current = nil
continue
}
if len(current) == 0 {
switch {
case firstGap > 0 && secondGap <= 0:
junction, ok := solver.refineNonCentralBandHorizonGapCrossing(first, second)
if !ok {
continue
}
current = append(current, junction)
case firstGap <= 0:
current = append(current, first)
default:
continue
}
}
if secondGap <= 0 {
if solarEclipsePathDistanceKM(current[len(current)-1], second) > 0.001 {
current = append(current, second)
}
continue
}
if firstGap <= 0 {
junction, ok := solver.refineNonCentralBandHorizonGapCrossing(first, second)
if ok {
current = append(current, junction)
}
}
if solver.nonCentralBandHorizonCandidateValid(current) && len(current) > len(best) {
best = append([]SolarEclipsePathPoint(nil), current...)
}
current = nil
}
if solver.nonCentralBandHorizonCandidateValid(current) && len(current) > len(best) {
best = append([]SolarEclipsePathPoint(nil), current...)
}
}
}
if len(best) < 2 {
return nil
}
refined := make([]SolarEclipsePathPoint, 1, len(best))
refined[0] = best[0]
for index := 1; index < len(best); index++ {
refined = solver.appendRefinedSolarEclipseCentralHorizonSegment(
refined, best[index-1], best[index], 0,
)
}
return deduplicateSolarEclipsePathPoints(refined)
}
func (solver solarEclipseSolver) correctNonCentralBandHorizonSamples(
segment []SolarEclipsePathPoint,
) []SolarEclipsePathPoint {
corrected := make([]SolarEclipsePathPoint, 0, len(segment))
for _, point := range segment {
evaluation := solver.magnitudeEvaluationAt(point.JDE)
longitude, latitude, ok := riseSetRefineGeographicRoot(
point.Longitude,
point.Latitude,
func(lon, lat float64) (float64, float64, bool) {
state := evaluation.center.stateAt(lon*rad, lat*rad, 0)
phase := evaluation.separationDerivative(lon, lat)
return phase, state.sunAltitudeRad, finite(phase) && finite(state.sunAltitudeRad)
},
)
if !ok || evaluation.separationSecondDerivative(longitude, latitude) <= 0 {
continue
}
state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0)
corrected = append(corrected, SolarEclipsePathPoint{
JDE: point.JDE, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad,
})
}
return corrected
}
func (solver solarEclipseSolver) nonCentralBandHorizonCandidateValid(
points []SolarEclipsePathPoint,
) bool {
if len(points) < 2 {
return false
}
firstGap, firstOK := solver.nonCentralBandGapAt(points[0])
lastGap, lastOK := solver.nonCentralBandGapAt(points[len(points)-1])
return firstOK && lastOK && math.Abs(firstGap) <= 1e-7 && math.Abs(lastGap) <= 1e-7
}
func (solver solarEclipseSolver) appendRefinedSolarEclipseCentralHorizonSegment(
points []SolarEclipsePathPoint,
start, end SolarEclipsePathPoint,
depth int,
) []SolarEclipsePathPoint {
if solarEclipsePathDistanceKM(start, end) <= solarEclipseCentralBandTargetSpacingKM ||
depth >= 16 || end.JDE-start.JDE <= solarEclipsePathMinStepDays {
return append(points, end)
}
jd := (start.JDE + end.JDE) / 2
longitude := normalizeLongitude(
start.Longitude + math.Remainder(end.Longitude-start.Longitude, 360)/2,
)
latitude := (start.Latitude + end.Latitude) / 2
evaluation := solver.magnitudeEvaluationAt(jd)
longitude, latitude, ok := riseSetRefineGeographicRoot(
longitude,
latitude,
func(lon, lat float64) (float64, float64, bool) {
state := evaluation.center.stateAt(lon*rad, lat*rad, 0)
phase := evaluation.separationDerivative(lon, lat)
return phase, state.sunAltitudeRad, finite(phase) && finite(state.sunAltitudeRad)
},
)
if !ok {
return append(points, end)
}
state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0)
if solarEclipseCentralContactGap(state) > 1e-7 ||
evaluation.separationSecondDerivative(longitude, latitude) <= 0 {
return append(points, end)
}
middle := SolarEclipsePathPoint{
JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad,
}
points = solver.appendRefinedSolarEclipseCentralHorizonSegment(points, start, middle, depth+1)
return solver.appendRefinedSolarEclipseCentralHorizonSegment(points, middle, end, depth+1)
}
func (solver solarEclipseSolver) nonCentralBandStateAt(
point SolarEclipsePathPoint,
referenceJDE float64,
) (solarEclipseNonCentralBandState, bool) {
coordinates := [3]float64{
point.Longitude,
point.Latitude,
(point.JDE - referenceJDE) * solarEclipseNonCentralBandTimeScale,
}
_, jacobian, ok := solver.nonCentralBandBoundaryJacobian(coordinates, referenceJDE)
if !ok {
return solarEclipseNonCentralBandState{}, false
}
tangent, ok := solarEclipseMagnitudeArcTangent(jacobian)
return solarEclipseNonCentralBandState{coordinates: coordinates, tangent: tangent, point: point}, ok
}
func (solver solarEclipseSolver) correctNonCentralBandBoundary(
predictor, tangent [3]float64,
referenceJDE float64,
) (solarEclipseNonCentralBandState, int, bool) {
coordinates := predictor
for iteration := 0; iteration < 16; iteration++ {
residual, jacobian, ok := solver.nonCentralBandBoundaryJacobian(coordinates, referenceJDE)
if !ok {
return solarEclipseNonCentralBandState{}, iteration, false
}
planeResidual := dotSolarEclipse3(subtractSolarEclipse3(coordinates, predictor), tangent)
if math.Abs(residual[0]) <= 1e-10 &&
math.Abs(residual[1]) <= solarEclipseNonCentralBandDerivativeTolerance &&
math.Abs(planeResidual) <= 1e-9 {
return solver.validNonCentralBandState(coordinates, jacobian, referenceJDE, iteration+1)
}
matrix := [3][3]float64{jacobian[0], jacobian[1], tangent}
delta, ok := solveSolarEclipse3x3(matrix, [3]float64{-residual[0], -residual[1], -planeResidual})
if !ok {
return solarEclipseNonCentralBandState{}, iteration, false
}
norm := math.Sqrt(dotSolarEclipse3(delta, delta))
if norm > 2 {
for index := range delta {
delta[index] *= 2 / norm
}
}
for index := range coordinates {
coordinates[index] += delta[index]
}
coordinates[0], coordinates[1] = normalizeSolarEclipseSphericalCoordinates(coordinates[0], coordinates[1])
}
residual, jacobian, ok := solver.nonCentralBandBoundaryJacobian(coordinates, referenceJDE)
planeResidual := dotSolarEclipse3(subtractSolarEclipse3(coordinates, predictor), tangent)
if !ok || math.Abs(residual[0]) > 1e-7 ||
math.Abs(residual[1]) > solarEclipseNonCentralBandDerivativeTolerance ||
math.Abs(planeResidual) > 1e-7 {
return solarEclipseNonCentralBandState{}, 16, false
}
return solver.validNonCentralBandState(coordinates, jacobian, referenceJDE, 16)
}
// normalizeSolarEclipseSphericalCoordinates keeps continuation coordinates on
// the sphere when a polar branch crosses a geographic pole. Reflecting the
// latitude and shifting longitude by 180 degrees preserves the same point and
// avoids the artificial singularity at +/-90 degrees.
func normalizeSolarEclipseSphericalCoordinates(longitude, latitude float64) (float64, float64) {
crossedPole := false
for latitude > 90 || latitude < -90 {
crossedPole = true
if latitude > 90 {
latitude = 180 - latitude
longitude += 180
continue
}
latitude = -180 - latitude
longitude += 180
}
if crossedPole {
longitude = normalizeLongitude(longitude)
}
return longitude, latitude
}
func (solver solarEclipseSolver) validNonCentralBandState(
coordinates [3]float64,
jacobian [2][3]float64,
referenceJDE float64,
iterations int,
) (solarEclipseNonCentralBandState, int, bool) {
jd := referenceJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale
longitude, latitude := normalizeLongitude(coordinates[0]), coordinates[1]
evaluation := solver.magnitudeEvaluationAt(jd)
state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0)
if math.Abs(solarEclipseCentralContactGap(state)) > 1e-6 ||
math.Abs(evaluation.centralContactDerivative(longitude, latitude)) >
solarEclipseNonCentralBandDerivativeTolerance ||
evaluation.centralContactSecondDerivative(longitude, latitude) <= 0 {
return solarEclipseNonCentralBandState{}, iterations, false
}
tangent, ok := solarEclipseMagnitudeArcTangent(jacobian)
if !ok {
return solarEclipseNonCentralBandState{}, iterations, false
}
return solarEclipseNonCentralBandState{
coordinates: coordinates,
tangent: tangent,
point: SolarEclipsePathPoint{
JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad,
},
}, iterations, true
}
func (solver solarEclipseSolver) nonCentralBandBoundaryJacobian(
coordinates [3]float64,
referenceJDE float64,
) ([2]float64, [2][3]float64, bool) {
jd := referenceJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale
longitude, latitude := normalizeLongitude(coordinates[0]), coordinates[1]
evaluation := solver.magnitudeEvaluationAt(jd)
residual, ok := solarEclipseNonCentralBandBoundaryResidualAt(evaluation, longitude, latitude)
if !ok {
return [2]float64{}, [2][3]float64{}, false
}
steps := [3]float64{1e-4, 1e-4, 5.0 * solarEclipseNonCentralBandTimeScale / 86400.0}
jacobian := [2][3]float64{}
for column, shifted := range [][2]float64{{longitude + steps[0], latitude}, {longitude, latitude + steps[1]}} {
shiftedResidual, shiftedOK := solarEclipseNonCentralBandBoundaryResidualAt(
evaluation, shifted[0], shifted[1],
)
if !shiftedOK {
return [2]float64{}, [2][3]float64{}, false
}
for row := 0; row < 2; row++ {
jacobian[row][column] = (shiftedResidual[row] - residual[row]) / steps[column]
}
}
timeEvaluation := solver.magnitudeEvaluationAt(jd + steps[2]/solarEclipseNonCentralBandTimeScale)
timeResidual, timeOK := solarEclipseNonCentralBandBoundaryResidualAt(
timeEvaluation, longitude, latitude,
)
if !timeOK {
return [2]float64{}, [2][3]float64{}, false
}
for row := 0; row < 2; row++ {
jacobian[row][2] = (timeResidual[row] - residual[row]) / steps[2]
}
return residual, jacobian, true
}
func solarEclipseNonCentralBandBoundaryResidualAt(
evaluation solarEclipseRiseSetEvaluation,
longitude, latitude float64,
) ([2]float64, bool) {
state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0)
residual := [2]float64{
solarEclipseCentralContactGap(state),
evaluation.centralContactDerivative(longitude, latitude),
}
return residual, finite(residual[0]) && finite(residual[1])
}
func solarEclipseCentralContactGap(state localSolarEclipseState) float64 {
return state.movingDiskContactState().internalContactGap()
}
func (evaluation solarEclipseRiseSetEvaluation) centralContactDerivative(longitude, latitude float64) float64 {
before := evaluation.before.stateAt(longitude*rad, latitude*rad, 0)
after := evaluation.after.stateAt(longitude*rad, latitude*rad, 0)
return (solarEclipseCentralContactGap(after) - solarEclipseCentralContactGap(before)) /
(2 * solarEclipseRiseSetDerivativeStepDays)
}
func (evaluation solarEclipseRiseSetEvaluation) centralContactSecondDerivative(longitude, latitude float64) float64 {
before := evaluation.before.stateAt(longitude*rad, latitude*rad, 0)
center := evaluation.center.stateAt(longitude*rad, latitude*rad, 0)
after := evaluation.after.stateAt(longitude*rad, latitude*rad, 0)
stepSquared := solarEclipseRiseSetDerivativeStepDays * solarEclipseRiseSetDerivativeStepDays
return (solarEclipseCentralContactGap(after) - 2*solarEclipseCentralContactGap(center) +
solarEclipseCentralContactGap(before)) / stepSquared
}
func (solver solarEclipseSolver) refineNonCentralBandHorizonGapCrossing(
first, second SolarEclipsePathPoint,
) (SolarEclipsePathPoint, bool) {
firstGap, firstOK := solver.nonCentralBandGapAt(first)
secondGap, secondOK := solver.nonCentralBandGapAt(second)
if !firstOK || !secondOK || firstGap*secondGap > 0 {
return SolarEclipsePathPoint{}, false
}
if first.JDE > second.JDE {
first, second = second, first
firstGap, secondGap = secondGap, firstGap
}
best := first
bestGap := math.Abs(firstGap)
if math.Abs(secondGap) < bestGap {
best, bestGap = second, math.Abs(secondGap)
}
for iteration := 0; iteration < 64; iteration++ {
jd := (first.JDE + second.JDE) / 2
fraction := (jd - first.JDE) / (second.JDE - first.JDE)
longitude := normalizeLongitude(
first.Longitude + fraction*math.Remainder(second.Longitude-first.Longitude, 360),
)
latitude := first.Latitude + fraction*(second.Latitude-first.Latitude)
evaluation := solver.magnitudeEvaluationAt(jd)
longitude, latitude, ok := riseSetRefineGeographicRoot(
longitude,
latitude,
func(lon, lat float64) (float64, float64, bool) {
state := evaluation.center.stateAt(lon*rad, lat*rad, 0)
phase := evaluation.separationDerivative(lon, lat)
return phase, state.sunAltitudeRad, finite(phase) && finite(state.sunAltitudeRad)
},
)
if !ok {
return SolarEclipsePathPoint{}, false
}
state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0)
middleGap := solarEclipseCentralContactGap(state)
middle := SolarEclipsePathPoint{
JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad,
}
if math.Abs(middleGap) < bestGap {
best, bestGap = middle, math.Abs(middleGap)
}
if bestGap <= 1e-10 || second.JDE-first.JDE <= 1e-10 {
break
}
if firstGap*middleGap <= 0 {
second, secondGap = middle, middleGap
} else {
first, firstGap = middle, middleGap
}
}
if bestGap > 1e-7 {
return SolarEclipsePathPoint{}, false
}
evaluation := solver.magnitudeEvaluationAt(best.JDE)
if math.Abs(evaluation.separationDerivative(best.Longitude, best.Latitude)) > 1e-8 ||
math.Abs(best.SunAltitude) > 1e-5 ||
evaluation.separationSecondDerivative(best.Longitude, best.Latitude) <= 0 {
return SolarEclipsePathPoint{}, false
}
return best, true
}
func (solver solarEclipseSolver) nonCentralBandGapAt(
point SolarEclipsePathPoint,
) (float64, bool) {
evaluation := solver.magnitudeEvaluationAt(point.JDE)
state := evaluation.center.stateAt(point.Longitude*rad, point.Latitude*rad, 0)
gap := solarEclipseCentralContactGap(state)
return gap, finite(gap)
}