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