feat: 完善日月食与月掩几何链路并扩展历法接口

- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
2026-09-17 12:27:40 +08:00
parent 9ee2163cc7
commit 2bf8478639
428 changed files with 85981 additions and 7998 deletions
+71 -73
View File
@@ -19,6 +19,7 @@ const (
type planetOccultationConfig struct {
planet OccultationPlanet
planetIndex int
equatorialRadiusKM float64
apparentRaDecN func(float64, int) (float64, float64)
earthDistanceN func(float64, int) float64
@@ -41,6 +42,16 @@ type planetOccultationState struct {
valid bool
}
func (state planetOccultationState) movingDiskContactState() movingDiskContactState {
return movingDiskContactState{
separation: state.separationArcsec,
occultingOuterRadius: state.moonSemidiameter,
occultingInnerRadius: state.moonSemidiameter,
targetRadius: state.planetSemidiameter,
valid: state.valid,
}
}
// FindPlanetOccultations 搜索固定观测点的有限盘面行星月掩。
// 经度东为正、纬度北为正,单位为度;高度为平均海平面以上米数。目标位置、视差和视半径会在每次候选、掩甚和接触计算时重新计算。
// FindPlanetOccultations searches one finite-disk planet at a fixed observing site.
@@ -137,36 +148,43 @@ func planetOccultationConfigFor(planet OccultationPlanet) (planetOccultationConf
config := planetOccultationConfig{planet: planet}
switch planet {
case OccultationMercury:
config.planetIndex = 1
config.equatorialRadiusKM = mercuryEquatorialRadiusKM
config.apparentRaDecN = MercuryApparentRaDecN
config.earthDistanceN = EarthMercuryAwayN
config.semidiameterN = MercurySemidiameterN
case OccultationVenus:
config.planetIndex = 2
config.equatorialRadiusKM = venusEquatorialRadiusKM
config.apparentRaDecN = VenusApparentRaDecN
config.earthDistanceN = EarthVenusAwayN
config.semidiameterN = VenusSemidiameterN
case OccultationMars:
config.planetIndex = 3
config.equatorialRadiusKM = marsEquatorialRadiusKM
config.apparentRaDecN = MarsApparentRaDecN
config.earthDistanceN = EarthMarsAwayN
config.semidiameterN = MarsSemidiameterN
case OccultationJupiter:
config.planetIndex = 4
config.equatorialRadiusKM = jupiterEquatorialRadiusKM
config.apparentRaDecN = JupiterApparentRaDecN
config.earthDistanceN = EarthJupiterAwayN
config.semidiameterN = JupiterSemidiameterN
case OccultationSaturn:
config.planetIndex = 5
config.equatorialRadiusKM = saturnEquatorialRadiusKM
config.apparentRaDecN = SaturnApparentRaDecN
config.earthDistanceN = EarthSaturnAwayN
config.semidiameterN = SaturnSemidiameterN
case OccultationUranus:
config.planetIndex = 6
config.equatorialRadiusKM = uranusEquatorialRadiusKM
config.apparentRaDecN = UranusApparentRaDecN
config.earthDistanceN = EarthUranusAwayN
config.semidiameterN = UranusSemidiameterN
case OccultationNeptune:
config.planetIndex = 7
config.equatorialRadiusKM = neptuneEquatorialRadiusKM
config.apparentRaDecN = NeptuneApparentRaDecN
config.earthDistanceN = EarthNeptuneAwayN
@@ -262,7 +280,16 @@ func planetOccultationBestObserver(seedTT, startTT, endTT float64, config planet
}
point, pointOK := occultationPathCenterPointForFrame(greatestTT, frameAt, time.UTC)
if !pointOK {
point, pointOK = occultationPathBoundaryPointForFrame(greatestTT, frameAt, time.UTC)
// 非中心事件的月影轴不与地球椭球相交,最佳站心应取离影轴最近的椭球点;
// 外接触切点位于掩带边缘,会把掩甚站心推出掩食之外,只作最后回退。
// For a non-central event the shadow axis misses the ellipsoid, so the
// best station is the ellipsoid point nearest to that axis. The outer
// contact tangent sits on the band edge and moves the greatest station
// outside the occultation, so it remains the last resort only.
point, pointOK = occultationPathTrackPointForFrame(greatestTT, frameAt, time.UTC)
if !pointOK {
point, pointOK = occultationPathBoundaryPointForFrame(greatestTT, frameAt, time.UTC)
}
}
if !pointOK {
return 0, Observer{}, 0, false
@@ -312,8 +339,10 @@ func planetOccultationInfoAtGreatest(
return info, true
}
externalImmersionTT, externalImmersionOK := planetOccultationContact(greatestTT, -1, false, config, observer)
externalEmersionTT, externalEmersionOK := planetOccultationContact(greatestTT, 1, false, config, observer)
contactEvaluator := newPlanetOccultationContactEvaluator(config, observer)
contactEvaluator.prime(greatestTT, state.movingDiskContactState(), state.valid)
externalImmersionTT, externalImmersionOK := planetOccultationContactWithEvaluator(greatestTT, -1, false, contactEvaluator)
externalEmersionTT, externalEmersionOK := planetOccultationContactWithEvaluator(greatestTT, 1, false, contactEvaluator)
if !externalImmersionOK || !externalEmersionOK || externalEmersionTT <= externalImmersionTT {
return PlanetOccultationInfo{}, false
}
@@ -321,8 +350,8 @@ func planetOccultationInfoAtGreatest(
info.ExternalEmersion = occultationTTToLocation(externalEmersionTT, location)
if state.internalContactMetric < -planetOccultationGrazingToleranceArcsec {
internalImmersionTT, internalImmersionOK := planetOccultationContact(greatestTT, -1, true, config, observer)
internalEmersionTT, internalEmersionOK := planetOccultationContact(greatestTT, 1, true, config, observer)
internalImmersionTT, internalImmersionOK := planetOccultationContactWithEvaluator(greatestTT, -1, true, contactEvaluator)
internalEmersionTT, internalEmersionOK := planetOccultationContactWithEvaluator(greatestTT, 1, true, contactEvaluator)
if !internalImmersionOK || !internalEmersionOK ||
internalImmersionTT <= externalImmersionTT || internalEmersionTT >= externalEmersionTT ||
internalImmersionTT >= greatestTT || internalEmersionTT <= greatestTT {
@@ -370,14 +399,23 @@ func planetOccultationStateAt(tt float64, config planetOccultationConfig, observ
return planetOccultationState{}
}
separation := angularSeparationDegrees(position.moonRA, position.moonDec, position.planetRA, position.planetDec) * 3600
contactState := movingDiskContactState{
separation: separation,
occultingOuterRadius: moonRadius,
occultingInnerRadius: moonRadius,
targetRadius: planetRadius,
valid: movingDiskContactStateValid(
separation, moonRadius, moonRadius, planetRadius,
),
}
return planetOccultationState{
position: position,
separationArcsec: separation,
moonSemidiameter: moonRadius,
planetSemidiameter: planetRadius,
externalContactMetric: separation - (moonRadius + planetRadius),
internalContactMetric: separation - (moonRadius - planetRadius),
valid: finite(separation),
externalContactMetric: contactState.externalContactGap(),
internalContactMetric: contactState.internalContactGap(),
valid: contactState.valid,
}
}
@@ -402,14 +440,6 @@ func planetOccultationExternalContactMetric(tt float64, config planetOccultation
return state.externalContactMetric
}
func planetMoonSeparationArcsec(tt float64, config planetOccultationConfig, observer *Observer, n int) float64 {
position := planetMoonPositionAt(tt, config, observer, n)
if !position.valid {
return math.Inf(1)
}
return angularSeparationDegrees(position.moonRA, position.moonDec, position.planetRA, position.planetDec) * 3600
}
func planetOccultationLatitudePass(tt float64, config planetOccultationConfig, observer *Observer, safetyMarginArcsec float64) bool {
state := planetOccultationStateAt(tt, config, observer, -1)
if !state.valid {
@@ -421,73 +451,41 @@ func planetOccultationLatitudePass(tt float64, config planetOccultationConfig, o
return math.Abs(moonLatitude-planetLatitude)*3600 <= limit
}
func planetOccultationContact(
func newPlanetOccultationContactEvaluator(
config planetOccultationConfig,
observer Observer,
) *movingDiskContactEvaluator {
return newMovingDiskContactEvaluator(func(tt float64) (movingDiskContactState, bool) {
state := planetOccultationStateAt(tt, config, &observer, -1)
return state.movingDiskContactState(), state.valid
})
}
func planetOccultationContactWithEvaluator(
greatestTT float64,
direction int,
internal bool,
config planetOccultationConfig,
observer Observer,
evaluator *movingDiskContactEvaluator,
) (float64, bool) {
if direction != -1 && direction != 1 {
return math.NaN(), false
}
metric := func(tt float64) float64 {
state := planetOccultationStateAt(tt, config, &observer, -1)
if !state.valid {
return math.NaN()
}
if internal {
return state.internalContactMetric
}
return state.externalContactMetric
metric := func(tt float64) (float64, bool) {
return evaluator.gap(tt, internal)
}
nearTT := greatestTT
nearValue := metric(nearTT)
if !finite(nearValue) || nearValue > 0 {
root, ok := occultationMovingDiskEngine().contactRoot(
greatestTT,
float64(direction),
planetOccultationContactStepDays,
planetOccultationContactSpanDays,
planetOccultationRootToleranceDays,
metric,
64,
)
if !ok {
return math.NaN(), false
}
maxSteps := int(math.Ceil(planetOccultationContactSpanDays / planetOccultationContactStepDays))
if maxSteps > planetOccultationMaxContactSteps {
maxSteps = planetOccultationMaxContactSteps
}
for i := 1; i <= maxSteps; i++ {
farTT := greatestTT + float64(direction*i)*planetOccultationContactStepDays
farValue := metric(farTT)
if !finite(farValue) {
continue
}
if farValue >= 0 {
return planetOccultationRoot(nearTT, farTT, nearValue, farValue, metric)
}
nearTT, nearValue = farTT, farValue
}
return math.NaN(), false
}
func planetOccultationRoot(
leftTT, rightTT, leftValue, rightValue float64,
metric func(float64) float64,
) (float64, bool) {
if leftTT > rightTT {
leftTT, rightTT = rightTT, leftTT
leftValue, rightValue = rightValue, leftValue
}
if !finite(leftValue) || !finite(rightValue) || leftValue*rightValue > 0 {
return math.NaN(), false
}
for i := 0; i < 64 && math.Abs(rightTT-leftTT) > planetOccultationRootToleranceDays; i++ {
midTT := (leftTT + rightTT) / 2
midValue := metric(midTT)
if !finite(midValue) {
return math.NaN(), false
}
if leftValue*midValue <= 0 {
rightTT, rightValue = midTT, midValue
} else {
leftTT, leftValue = midTT, midValue
}
}
return (leftTT + rightTT) / 2, true
return root, true
}
func planetOccultationCoarseStepDays(options OccultationSearchOptions) float64 {