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

405 lines
17 KiB
Go

package basic
import "math"
// Absolute Julian dates quantize a moving sky position at about 1e-10 radians.
const solarEclipseCentralVectorTolerance = 2e-10
// solarCentralBandSkyOffset uses a signed internal-contact radius and stable
// sky-plane coordinates. Unlike acos(dot) - abs(radius), these remain smooth
// when a hybrid shadow shrinks to zero and changes from annular to total.
func solarCentralBandSkyOffset(context localSolarEclipseStateContext, longitude, latitude float64) [3]float64 {
observer := localSolarEclipseObserverXYZ(context.gst, longitude*rad, latitude*rad, 0)
sun, moon := subtractSolarEclipse3(context.sunXYZ, observer), subtractSolarEclipse3(context.moonXYZ, observer)
sunDistance := math.Sqrt(dotSolarEclipse3(sun, sun))
moonDistance := math.Sqrt(dotSolarEclipse3(moon, moon))
for i := range sun {
sun[i] /= sunDistance
moon[i] /= moonDistance
}
equatorial := math.Hypot(sun[0], sun[1])
east := [3]float64{-sun[1] / equatorial, sun[0] / equatorial, 0}
north := [3]float64{-sun[2] * east[1], sun[2] * east[0], equatorial}
radius := math.Asin(solarEclipseEarthEquatorialRadiusKM*context.params.umbralK*localSolarMoonRadiusScale/moonDistance) -
math.Asin(solarEclipseEarthEquatorialRadiusKM*solarEclipseSolarRadiusRatio/sunDistance)
return [3]float64{dotSolarEclipse3(moon, east), dotSolarEclipse3(moon, north), math.Sin(radius)}
}
func solarCentralBandVectorResidual(evaluation solarEclipseRiseSetEvaluation, longitude, latitude, side float64) ([2]float64, bool) {
center := solarCentralBandSkyOffset(evaluation.center, longitude, latitude)
before := solarCentralBandSkyOffset(evaluation.before, longitude, latitude)
after := solarCentralBandSkyOffset(evaluation.after, longitude, latitude)
velocity := subtractSolarEclipse3(after, before)
v2 := velocity[0]*velocity[0] + velocity[1]*velocity[1]
discriminant := v2 - velocity[2]*velocity[2]
if v2 <= 0 || discriminant <= 0 {
return [2]float64{}, false
}
// At contact, offset = signedRadius * normal. The envelope condition is
// normal dot velocity = radiusVelocity, giving two regular signed branches.
cross := side * math.Sqrt(discriminant)
nx := (velocity[2]*velocity[0] - cross*velocity[1]) / v2
ny := (velocity[2]*velocity[1] + cross*velocity[0]) / v2
residual := [2]float64{center[0] - center[2]*nx, center[1] - center[2]*ny}
return residual, finite(residual[0]) && finite(residual[1])
}
func (solver solarEclipseSolver) centralBandVectorJacobian(coordinates [3]float64, referenceJDE, side float64, exact bool) ([2]float64, [2][3]float64, bool) {
evaluate := solver.magnitudeCandidateEvaluationAt
if exact {
evaluate = solver.magnitudeEvaluationAt
}
jd := referenceJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale
evaluation := evaluate(jd)
residual, ok := solarCentralBandVectorResidual(evaluation, coordinates[0], coordinates[1], side)
if !ok {
return residual, [2][3]float64{}, false
}
steps := [3]float64{1e-4, 1e-4, 5 * solarEclipseNonCentralBandTimeScale / 86400}
var jacobian [2][3]float64
for column := 0; column < 3; column++ {
shifted := coordinates
shifted[column] += steps[column]
shiftedEvaluation := evaluation
if column == 2 {
shiftedEvaluation = evaluate(jd + steps[column]/solarEclipseNonCentralBandTimeScale)
}
value, valid := solarCentralBandVectorResidual(shiftedEvaluation, shifted[0], shifted[1], side)
if !valid {
return residual, jacobian, false
}
for row := range residual {
jacobian[row][column] = (value[row] - residual[row]) / steps[column]
}
}
return residual, jacobian, true
}
func (solver solarEclipseSolver) correctCentralBandVectorBoundary(predictor, tangent [3]float64, referenceJDE, side float64) (solarEclipseNonCentralBandState, bool) {
coordinates := predictor
exact := false
for iteration := 0; iteration < 16; iteration++ {
residual, jacobian, ok := solver.centralBandVectorJacobian(coordinates, referenceJDE, side, exact)
if !ok {
return solarEclipseNonCentralBandState{}, false
}
plane := dotSolarEclipse3(subtractSolarEclipse3(coordinates, predictor), tangent)
if math.Hypot(residual[0], residual[1]) <= solarEclipseCentralVectorTolerance && math.Abs(plane) <= 1e-9 {
jd := referenceJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale
evaluation := solver.magnitudeEvaluationAt(jd)
check, valid := solarCentralBandVectorResidual(evaluation, coordinates[0], coordinates[1], side)
if !valid || math.Hypot(check[0], check[1]) > solarEclipseCentralVectorTolerance {
exact = true
continue
}
nextTangent, valid := solarEclipseMagnitudeArcTangent(jacobian)
state := evaluation.center.stateAt(coordinates[0]*rad, coordinates[1]*rad, 0)
return solarEclipseNonCentralBandState{
coordinates: coordinates, tangent: nextTangent,
point: SolarEclipsePathPoint{JDE: jd, Longitude: normalizeLongitude(coordinates[0]), Latitude: coordinates[1], SunAltitude: state.sunAltitudeRad / rad},
}, valid
}
delta, valid := solveSolarEclipse3x3([3][3]float64{jacobian[0], jacobian[1], tangent}, [3]float64{-residual[0], -residual[1], -plane})
if !valid {
return solarEclipseNonCentralBandState{}, false
}
scale := math.Max(1, math.Sqrt(dotSolarEclipse3(delta, delta))/2)
for i := range coordinates {
coordinates[i] += delta[i] / scale
}
if math.Abs(coordinates[1]) >= 89.999999 {
return solarEclipseNonCentralBandState{}, false
}
}
return solarEclipseNonCentralBandState{}, false
}
func (solver solarEclipseSolver) traceCentralBandVectorEnvelope(root SolarEclipsePathPoint, endRoots []SolarEclipsePathPoint, transitions *[]SolarEclipsePathPoint, referenceJDE float64) ([]SolarEclipsePathPoint, int, bool) {
coordinates := [3]float64{root.Longitude, root.Latitude, (root.JDE - referenceJDE) * solarEclipseNonCentralBandTimeScale}
evaluation := solver.magnitudeEvaluationAt(root.JDE)
side := 1.0
positive, _ := solarCentralBandVectorResidual(evaluation, root.Longitude, root.Latitude, 1)
negative, _ := solarCentralBandVectorResidual(evaluation, root.Longitude, root.Latitude, -1)
if math.Hypot(negative[0], negative[1]) < math.Hypot(positive[0], positive[1]) {
side = -1
}
_, jacobian, ok := solver.centralBandVectorJacobian(coordinates, referenceJDE, side, false)
if !ok {
return nil, -1, false
}
tangent, ok := solarEclipseMagnitudeArcTangent(jacobian)
if !ok {
return nil, -1, false
}
step := solarEclipseCentralEnvelopeArcStepDegrees / 4
state := solarEclipseNonCentralBandState{coordinates: coordinates, tangent: tangent, point: root}
var next solarEclipseNonCentralBandState
found := false
for _, direction := range []float64{1, -1} {
predictor, oriented := coordinates, tangent
for i := range predictor {
oriented[i] *= direction
predictor[i] += step * oriented[i]
}
candidate, valid := solver.correctCentralBandVectorBoundary(predictor, oriented, referenceJDE, side)
if valid && candidate.point.SunAltitude > 0 && (!found || candidate.point.SunAltitude > next.point.SunAltitude) {
if dotSolarEclipse3(candidate.tangent, oriented) < 0 {
for i := range candidate.tangent {
candidate.tangent[i] = -candidate.tangent[i]
}
}
next, found = candidate, true
}
}
if !found {
return nil, -1, false
}
points := []SolarEclipsePathPoint{root}
transitionIndex := 0
// A hybrid transition can be less than a second from axis contact, where
// time also folds along a limit. Locate it by signed radius while tracing.
appendPoint := func(point SolarEclipsePathPoint) bool {
first := points[len(points)-1]
before := solarCentralBandSkyOffset(solver.localStateContextAt(first.JDE), first.Longitude, first.Latitude)
after := solarCentralBandSkyOffset(solver.localStateContextAt(point.JDE), point.Longitude, point.Latitude)
if before[2]*after[2] < 0 {
if transitionIndex == len(*transitions) {
fraction := before[2] / (before[2] - after[2])
seed := SolarEclipsePathPoint{JDE: first.JDE + fraction*(point.JDE-first.JDE),
Longitude: first.Longitude + fraction*math.Remainder(point.Longitude-first.Longitude, 360),
Latitude: first.Latitude + fraction*(point.Latitude-first.Latitude)}
transition, ok := solver.hybridCentralBandTransition(seed, referenceJDE)
if !ok {
return false
}
*transitions = append(*transitions, transition)
}
points = append(points, (*transitions)[transitionIndex])
transitionIndex++
}
points = append(points, point)
return true
}
if !appendPoint(next.point) {
return nil, -1, false
}
state = next
for count := 0; count < solarEclipseCentralEnvelopeMaxArcSteps; count++ {
predictor := state.coordinates
for i := range predictor {
predictor[i] += step * state.tangent[i]
}
candidate, valid := solver.correctCentralBandVectorBoundary(predictor, state.tangent, referenceJDE, side)
if valid && dotSolarEclipse3(candidate.tangent, state.tangent) < 0 {
for i := range candidate.tangent {
candidate.tangent[i] = -candidate.tangent[i]
}
}
distance := solarEclipsePathDistanceKM(state.point, candidate.point)
chordTolerance := 0.02
if valid {
context := solver.localStateContextAt(candidate.point.JDE)
offset := solarCentralBandSkyOffset(context, candidate.point.Longitude, candidate.point.Latitude)
shadowRadiusKM := math.Abs(offset[2]) * math.Sqrt(dotSolarEclipse3(context.moonXYZ, context.moonXYZ))
chordTolerance = math.Min(chordTolerance, math.Max(0.001, shadowRadiusKM/8))
}
if !valid || distance > solarEclipseCentralEnvelopeMaxSpacingKM ||
centralBandVectorChordErrorKM(state, candidate, distance) > chordTolerance {
step /= 2
if step < solarEclipseCentralEnvelopeMinArcStepDegrees {
return points, -1, false
}
continue
}
if candidate.point.SunAltitude < 0 {
endIndex, bestResidual := -1, math.Inf(1)
for i, end := range endRoots {
residual, ok := solarCentralBandVectorResidual(solver.magnitudeEvaluationAt(end.JDE), end.Longitude, end.Latitude, side)
if norm := math.Hypot(residual[0], residual[1]); ok && norm < bestResidual {
endIndex, bestResidual = i, norm
}
}
if endIndex < 0 || solarEclipsePathDistanceKM(state.point, endRoots[endIndex]) > solarEclipseCentralEnvelopeEndDistanceKM {
return points, -1, false
}
ok := appendPoint(endRoots[endIndex])
return points, endIndex, ok && transitionIndex == len(*transitions)
}
if !appendPoint(candidate.point) {
return points, -1, false
}
state = candidate
if distance < solarEclipseCentralEnvelopeMaxSpacingKM/2 {
step = math.Min(solarEclipseCentralEnvelopeArcStepDegrees, step*1.5)
}
}
return points, -1, false
}
func centralBandVectorChordErrorKM(first, second solarEclipseNonCentralBandState, distance float64) float64 {
latitude := (first.point.Latitude + second.point.Latitude) * rad / 2
ax, ay := first.tangent[0]*math.Cos(latitude), first.tangent[1]
bx, by := second.tangent[0]*math.Cos(latitude), second.tangent[1]
norm := math.Hypot(ax, ay) * math.Hypot(bx, by)
if norm == 0 {
return math.Inf(1)
}
cosine := math.Max(-1, math.Min(1, (ax*bx+ay*by)/norm))
return distance * math.Sqrt(2*(1-cosine)) / 8
}
func (solver solarEclipseSolver) hybridCentralBandTransition(seed SolarEclipsePathPoint, referenceJDE float64) (SolarEclipsePathPoint, bool) {
coordinates := [3]float64{seed.Longitude, seed.Latitude, (seed.JDE - referenceJDE) * solarEclipseNonCentralBandTimeScale}
steps := [3]float64{1e-4, 1e-4, solarEclipseNonCentralBandTimeScale / 86400}
for iteration := 0; iteration < 12; iteration++ {
jd := referenceJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale
context := solver.localStateContextAt(jd)
residual := solarCentralBandSkyOffset(context, coordinates[0], coordinates[1])
if math.Hypot(residual[0], residual[1]) < solarEclipseCentralVectorTolerance/2 && math.Abs(residual[2]) < 1e-12 {
state := context.stateAt(coordinates[0]*rad, coordinates[1]*rad, 0)
return SolarEclipsePathPoint{JDE: jd, Longitude: normalizeLongitude(coordinates[0]), Latitude: coordinates[1], SunAltitude: state.sunAltitudeRad / rad}, true
}
var jacobian [3][3]float64
for column := range coordinates {
shifted, shiftedContext := coordinates, context
shifted[column] += steps[column]
if column == 2 {
shiftedContext = solver.localStateContextAt(jd + steps[column]/solarEclipseNonCentralBandTimeScale)
}
value := solarCentralBandSkyOffset(shiftedContext, shifted[0], shifted[1])
for row := range residual {
jacobian[row][column] = (value[row] - residual[row]) / steps[column]
}
}
delta, valid := solveSolarEclipse3x3(jacobian, [3]float64{-residual[0], -residual[1], -residual[2]})
if !valid {
return SolarEclipsePathPoint{}, false
}
for i := range coordinates {
coordinates[i] += delta[i]
}
}
return SolarEclipsePathPoint{}, false
}
func (solver solarEclipseSolver) centralBandVectorHorizonRoots(axisContactJDE, direction, firstContactJDE, lastContactJDE float64) (SolarEclipsePathPoint, SolarEclipsePathPoint, bool) {
startJDE, endJDE := math.Min(firstContactJDE, lastContactJDE), math.Max(firstContactJDE, lastContactJDE)
if !finite(startJDE) || !finite(endJDE) || startJDE <= 0 || endJDE <= startJDE {
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
}
seed, ok := solver.centralPathPointAt(axisContactJDE + direction/86400)
if !ok {
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
}
var roots [2]SolarEclipsePathPoint
for i, side := range []float64{1, -1} {
coordinates := [3]float64{seed.Longitude, seed.Latitude, (seed.JDE - axisContactJDE) * solarEclipseNonCentralBandTimeScale}
steps := [3]float64{1e-4, 1e-4, 5 * solarEclipseNonCentralBandTimeScale / 86400}
found := false
for iteration := 0; iteration < 12; iteration++ {
residual, jacobian, valid := solver.centralBandVectorJacobian(coordinates, axisContactJDE, side, true)
if !valid {
break
}
jd := axisContactJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale
context := solver.localStateContextAt(jd)
state := context.stateAt(coordinates[0]*rad, coordinates[1]*rad, 0)
if math.Hypot(residual[0], residual[1]) <= solarEclipseCentralVectorTolerance && math.Abs(state.sunAltitudeRad) < 1e-8 {
roots[i] = SolarEclipsePathPoint{JDE: jd, Longitude: normalizeLongitude(coordinates[0]), Latitude: coordinates[1], SunAltitude: state.sunAltitudeRad / rad}
// Grazing horizon roots can be many minutes from axis contact.
// Bound them by the shadow's limb-crossing interval, not a fixed
// window around the seed.
found = jd >= startJDE-solarEclipseCentralLimitHorizonContactMarginDays &&
jd <= endJDE+solarEclipseCentralLimitHorizonContactMarginDays && math.Abs(coordinates[1]) <= 90
break
}
matrix := [3][3]float64{jacobian[0], jacobian[1], {}}
for column := range coordinates {
shifted, shiftedContext := coordinates, context
shifted[column] += steps[column]
if column == 2 {
shiftedContext = solver.localStateContextAt(jd + steps[column]/solarEclipseNonCentralBandTimeScale)
}
value := shiftedContext.stateAt(shifted[0]*rad, shifted[1]*rad, 0)
matrix[2][column] = (value.sunAltitudeRad - state.sunAltitudeRad) / steps[column]
}
delta, valid := solveSolarEclipse3x3(matrix, [3]float64{-residual[0], -residual[1], -state.sunAltitudeRad})
if !valid {
break
}
for j := range coordinates {
coordinates[j] += delta[j]
}
}
if !found {
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
}
}
if roots[1].JDE < roots[0].JDE {
roots[0], roots[1] = roots[1], roots[0]
}
return roots[0], roots[1], solarEclipsePathDistanceKM(roots[0], roots[1]) > 0.001
}
func (solver solarEclipseSolver) hybridCentralBandEnvelope(closures [][]SolarEclipsePathPoint, result SolarEclipseResult) [][]SolarEclipsePathPoint {
if len(closures) != 2 || len(closures[0]) < 2 || len(closures[1]) < 2 {
return nil
}
var transitions []SolarEclipsePathPoint
startRoots := []SolarEclipsePathPoint{closures[0][0], closures[0][len(closures[0])-1]}
endRoots := []SolarEclipsePathPoint{closures[1][0], closures[1][len(closures[1])-1]}
var branches [2][]SolarEclipsePathPoint
var ends [2]int
for i, root := range startRoots {
branch, end, ok := solver.traceCentralBandVectorEnvelope(root, endRoots, &transitions, result.GreatestEclipse)
if !ok {
return nil
}
branches[i], ends[i] = branch, end
}
if ends[0] == ends[1] {
return nil
}
indices := [2]int{}
polygons := make([][]SolarEclipsePathPoint, 0, len(transitions)+1)
for segment := 0; segment <= len(transitions); segment++ {
var parts [2][]SolarEclipsePathPoint
for side, branch := range branches {
last := len(branch) - 1
if segment < len(transitions) {
last = indices[side]
for last < len(branch) && branch[last] != transitions[segment] {
last++
}
if last == len(branch) {
return nil
}
}
parts[side] = branch[indices[side] : last+1]
indices[side] = last
}
ring := append([]SolarEclipsePathPoint(nil), parts[0]...)
if segment == len(transitions) {
closure, ok := orientSolarEclipsePath(closures[1], parts[0][len(parts[0])-1], parts[1][len(parts[1])-1])
if !ok {
return nil
}
ring = append(ring, closure[1:]...)
}
for i := len(parts[1]) - 2; i >= 0; i-- {
ring = append(ring, parts[1][i])
}
if segment == 0 {
closure, ok := orientSolarEclipsePath(closures[0], parts[1][0], parts[0][0])
if !ok {
return nil
}
ring = append(ring, closure[1:]...)
} else {
ring = append(ring, ring[0])
}
polygons = append(polygons, deduplicateSolarEclipsePathPoints(ring))
}
return polygons
}