Files
astro/basic/planet_transit.go
T
b612 044f2f4381 feat: 引入可切换星历模型并扩展天象观测链路
- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算
- 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询
- 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出
- 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解
- 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤
- 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持
- 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理
- 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
2026-10-15 23:54:15 +08:00

562 lines
20 KiB
Go

package basic
import (
"math"
. "b612.me/astro/tools"
)
const (
planetTransitMeanSolarMotionDegPerDay = 360.0 / 365.2422
planetTransitTropicalYearDays = 365.2422
planetTransitSeasonProbeStepDays = 20.0
planetTransitSearchLimit = 2400
planetTransitSearchEpsilonDays = 1.0 / 86400.0
planetTransitGreatestWindowDays = 1.2
planetTransitGreatestToleranceDays = 0.25 / 86400.0
planetTransitContactStepDays = 0.02
planetTransitContactSpanDays = 1.0
planetTransitContactToleranceDays = 0.25 / 86400.0
planetTransitCoarseN = 16
)
// PlanetTransitResult 表示一次地心行星凌日结果。
//
// Valid 为 false 时表示没有找到有效凌日。所有时刻字段均为 UT 儒略日。
// MinimumSeparationArcsec、SunSemidiameterArcsec、PlanetSemidiameterArcsec 的单位均为角秒。
type PlanetTransitResult struct {
Valid bool
// PlanetIndex 为行星序号,1 表示水星,2 表示金星。
PlanetIndex int
// ExternalIngress / ExternalEgress 为一触 / 四触。
ExternalIngress float64
ExternalEgress float64
// InternalIngress / InternalEgress 为二触 / 三触。掠凌没有内切接触时为 0。
InternalIngress float64
InternalEgress float64
// Greatest 为凌甚,即行星中心最接近太阳中心的时刻。
Greatest float64
MinimumSeparationArcsec float64
SunSemidiameterArcsec float64
PlanetSemidiameterArcsec float64
HasExternal bool
HasInternal bool
}
type planetTransitConfig struct {
planetIndex int
synodicPeriodDays float64
anchorInferiorTT float64
seasonWindowDays float64
latitudePrefilter float64
conjunctionStepDay float64
apparentLoN func(float64, int) float64
apparentBoN func(float64, int) float64
apparentRaDecN func(float64, int) (float64, float64)
semidiameterN func(float64, int) float64
earthDistanceN func(float64, int) float64
nodeN func(float64, int) float64
}
type planetTransitState struct {
jdTT float64
separationArcsec float64
separationSquared float64
sunSemidiameter float64
planetSemidiameter float64
externalContactMetric float64
internalContactMetric float64
}
func mercuryTransitConfig(instances ...*Instance) planetTransitConfig {
inst := instanceValue(instances)
return planetTransitConfig{
planetIndex: 1,
synodicPeriodDays: MERCURY_S_PERIOD,
anchorInferiorTT: UTC2TT(JDCalc(2019, 11, 11+(15+21.0/60+40.0/3600)/24)),
seasonWindowDays: 12,
latitudePrefilter: 1.0,
conjunctionStepDay: 0.00001,
apparentLoN: func(jd float64, n int) float64 { return MercuryApparentLoNWithInstance(jd, n, inst) },
apparentBoN: func(jd float64, n int) float64 { return MercuryApparentBoNWithInstance(jd, n, inst) },
apparentRaDecN: func(jde float64, n int) (float64, float64) { return MercuryApparentRaDecNWithInstance(jde, n, inst) },
semidiameterN: func(jde float64, n int) float64 { return MercurySemidiameterNWithInstance(jde, n, inst) },
earthDistanceN: func(jd float64, n int) float64 { return EarthMercuryAwayNWithInstance(jd, n, inst) },
nodeN: func(jd float64, n int) float64 { return MercuryAscendingNodeNWithInstance(jd, n, inst) },
}
}
func venusTransitConfig(instances ...*Instance) planetTransitConfig {
inst := instanceValue(instances)
return planetTransitConfig{
planetIndex: 2,
synodicPeriodDays: VENUS_S_PERIOD,
anchorInferiorTT: UTC2TT(JDCalc(2012, 6, 6+(1+29.0/60)/24)),
seasonWindowDays: 8,
latitudePrefilter: 0.8,
conjunctionStepDay: 0.00001,
apparentLoN: func(jd float64, n int) float64 { return VenusApparentLoNWithInstance(jd, n, inst) },
apparentBoN: func(jd float64, n int) float64 { return VenusApparentBoNWithInstance(jd, n, inst) },
apparentRaDecN: func(jd float64, n int) (float64, float64) { return VenusApparentRaDecNWithInstance(jd, n, inst) },
semidiameterN: func(jde float64, n int) float64 { return VenusSemidiameterNWithInstance(jde, n, inst) },
earthDistanceN: func(jd float64, n int) float64 { return EarthVenusAwayNWithInstance(jd, n, inst) },
nodeN: func(jd float64, n int) float64 { return VenusAscendingNodeNWithInstance(jd, n, inst) },
}
}
// NextMercuryTransitWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextMercuryTransitWithInstance(jde float64, inst *Instance) PlanetTransitResult {
result, _ := searchPlanetTransit(jde, mercuryTransitConfig(inst), 1, false, inst)
return result
}
// LastMercuryTransitWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastMercuryTransitWithInstance(jde float64, inst *Instance) PlanetTransitResult {
result, _ := searchPlanetTransit(jde, mercuryTransitConfig(inst), -1, true, inst)
return result
}
// ClosestMercuryTransitWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func ClosestMercuryTransitWithInstance(jde float64, inst *Instance) PlanetTransitResult {
return closestPlanetTransit(jde, mercuryTransitConfig(inst), inst)
}
// NextVenusTransitWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func NextVenusTransitWithInstance(jde float64, inst *Instance) PlanetTransitResult {
result, _ := searchPlanetTransit(jde, venusTransitConfig(inst), 1, false, inst)
return result
}
// LastVenusTransitWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func LastVenusTransitWithInstance(jde float64, inst *Instance) PlanetTransitResult {
result, _ := searchPlanetTransit(jde, venusTransitConfig(inst), -1, true, inst)
return result
}
// ClosestVenusTransitWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func ClosestVenusTransitWithInstance(jde float64, inst *Instance) PlanetTransitResult {
return closestPlanetTransit(jde, venusTransitConfig(inst), inst)
}
func closestPlanetTransit(jde float64, cfg planetTransitConfig, instances ...*Instance) PlanetTransitResult {
inst := instanceValue(instances)
last, hasLast := searchPlanetTransit(jde, cfg, -1, true, inst)
next, hasNext := searchPlanetTransit(jde, cfg, 1, false, inst)
switch {
case hasLast && !hasNext:
return last
case !hasLast && hasNext:
return next
case !hasLast && !hasNext:
return PlanetTransitResult{}
}
if math.Abs(last.Greatest-jde) <= math.Abs(next.Greatest-jde) {
return last
}
return next
}
func searchPlanetTransit(jd float64, cfg planetTransitConfig, direction int, includeCurrent bool, instances ...*Instance) (PlanetTransitResult, bool) {
inst := instanceValue(instances)
if !isFiniteFloat(jd) || direction == 0 {
return PlanetTransitResult{}, false
}
targetTT := UTC2TT(jd)
// 节点季可能略早于查询时刻而凌甚仍在其后,从一个季窗之前起探,方向由凌甚时刻再判。
probeTT := targetTT - float64(direction)*cfg.seasonWindowDays
for i := 0; i < planetTransitSearchLimit; i++ {
seasonTT, ok := nextPlanetTransitSeasonTT(probeTT, cfg, direction, inst)
if !ok {
return PlanetTransitResult{}, false
}
seedTT := nearestPlanetTransitInferiorSeedTT(seasonTT, cfg)
if math.Abs(seedTT-seasonTT) <= cfg.seasonWindowDays {
conjunctionTT := refinePlanetTransitInferiorConjunctionTT(seedTT, cfg, inst)
if math.Abs(conjunctionTT-seasonTT) <= cfg.seasonWindowDays+1 && isPotentialPlanetTransit(conjunctionTT, cfg, inst) {
resultTT, ok := planetTransitAtInferiorConjunctionTT(conjunctionTT, cfg, inst)
if ok && planetTransitMatchesDirection(resultTT.Greatest, targetTT, direction, includeCurrent) {
return planetTransitResultTTToUT(resultTT), true
}
}
}
probeTT = seasonTT + float64(direction)*planetTransitSeasonProbeStepDays
}
return PlanetTransitResult{}, false
}
func nextPlanetTransitSeasonTT(jdTT float64, cfg planetTransitConfig, direction int, instances ...*Instance) (float64, bool) {
inst := instanceValue(instances)
best := math.NaN()
for nodeOffset := 0; nodeOffset <= 1; nodeOffset++ {
candidate := estimatePlanetTransitSeasonTT(jdTT, cfg, nodeOffset, direction, inst)
candidate = refinePlanetTransitSeasonTT(candidate, cfg, nodeOffset, inst)
if !isFiniteFloat(candidate) {
continue
}
matchedDirection := false
for i := 0; i < eventDirectionalSearchIterations; i++ {
if planetTransitMatchesDirection(candidate, jdTT, direction, false) {
matchedDirection = true
break
}
candidate += float64(direction) * planetTransitTropicalYearDays
candidate = refinePlanetTransitSeasonTT(candidate, cfg, nodeOffset, inst)
if !isFiniteFloat(candidate) {
break
}
}
if !matchedDirection {
continue
}
if !isFiniteFloat(best) || math.Abs(candidate-jdTT) < math.Abs(best-jdTT) {
best = candidate
}
}
if !isFiniteFloat(best) {
return 0, false
}
return best, true
}
func estimatePlanetTransitSeasonTT(jdTT float64, cfg planetTransitConfig, nodeOffset int, direction int, instances ...*Instance) float64 {
inst := instanceValue(instances)
sunLongitude := HSunApparentLoNWithInstance(jdTT, planetTransitCoarseN, inst)
nodeLongitude := planetTransitNodeLongitude(jdTT, cfg, nodeOffset, planetTransitCoarseN)
if direction > 0 {
delta := Limit360(nodeLongitude - sunLongitude)
if delta <= planetTransitSearchEpsilonDays {
delta += 360
}
return jdTT + delta/planetTransitMeanSolarMotionDegPerDay
}
delta := Limit360(sunLongitude - nodeLongitude)
if delta <= planetTransitSearchEpsilonDays {
delta += 360
}
return jdTT - delta/planetTransitMeanSolarMotionDegPerDay
}
func refinePlanetTransitSeasonTT(seedTT float64, cfg planetTransitConfig, nodeOffset int, instances ...*Instance) float64 {
inst := instanceValue(instances)
current := seedTT
for i := 0; i < 8; i++ {
prev := current
value := planetTransitSunNodeLongitudeDelta(prev, cfg, nodeOffset, inst)
slope := (planetTransitSunNodeLongitudeDelta(prev+0.5, cfg, nodeOffset, inst) -
planetTransitSunNodeLongitudeDelta(prev-0.5, cfg, nodeOffset, inst)) / 1.0
if slope == 0 || !isFiniteFloat(slope) {
break
}
current = prev - value/slope
if math.Abs(current-prev) <= 0.00001 {
break
}
}
return current
}
func planetTransitSunNodeLongitudeDelta(jdTT float64, cfg planetTransitConfig, nodeOffset int, instances ...*Instance) float64 {
inst := instanceValue(instances)
return planetTransitAngleDelta(HSunApparentLoNWithInstance(jdTT, planetTransitCoarseN, inst) -
planetTransitNodeLongitude(jdTT, cfg, nodeOffset, planetTransitCoarseN))
}
func planetTransitNodeLongitude(jdTT float64, cfg planetTransitConfig, nodeOffset int, n int) float64 {
return Limit360(cfg.nodeN(jdTT, n) + float64(nodeOffset)*180)
}
func nearestPlanetTransitInferiorSeedTT(seasonTT float64, cfg planetTransitConfig) float64 {
k := math.Round((seasonTT - cfg.anchorInferiorTT) / cfg.synodicPeriodDays)
return cfg.anchorInferiorTT + k*cfg.synodicPeriodDays
}
func refinePlanetTransitInferiorConjunctionTT(seedTT float64, cfg planetTransitConfig, instances ...*Instance) float64 {
inst := instanceValue(instances)
current := seedTT
for i := 0; i < 4; i++ {
prev := current
value := planetTransitLongitudeDeltaN(prev, cfg, planetTransitCoarseN, inst)
slope := (planetTransitLongitudeDeltaN(prev+cfg.conjunctionStepDay, cfg, planetTransitCoarseN, inst) -
planetTransitLongitudeDeltaN(prev-cfg.conjunctionStepDay, cfg, planetTransitCoarseN, inst)) / (2 * cfg.conjunctionStepDay)
if slope == 0 || !isFiniteFloat(slope) {
break
}
current = prev - value/slope
if math.Abs(current-prev) <= 30.0/86400.0 {
break
}
}
for i := 0; i < 8; i++ {
prev := current
value := planetTransitLongitudeDeltaN(prev, cfg, -1, inst)
slope := (planetTransitLongitudeDeltaN(prev+cfg.conjunctionStepDay, cfg, -1, inst) -
planetTransitLongitudeDeltaN(prev-cfg.conjunctionStepDay, cfg, -1, inst)) / (2 * cfg.conjunctionStepDay)
if slope == 0 || !isFiniteFloat(slope) {
break
}
current = prev - value/slope
if math.Abs(current-prev) <= cfg.conjunctionStepDay {
break
}
}
return current
}
func planetTransitLongitudeDeltaN(jdTT float64, cfg planetTransitConfig, n int, instances ...*Instance) float64 {
inst := instanceValue(instances)
return planetTransitAngleDelta(cfg.apparentLoN(jdTT, n) - HSunApparentLoNWithInstance(jdTT, n, inst))
}
func isPotentialPlanetTransit(conjunctionTT float64, cfg planetTransitConfig, instances ...*Instance) bool {
inst := instanceValue(instances)
if cfg.earthDistanceN(conjunctionTT, planetTransitCoarseN) > EarthAwayNWithInstance(conjunctionTT, planetTransitCoarseN, inst) {
return false
}
return math.Abs(cfg.apparentBoN(conjunctionTT, planetTransitCoarseN)) <= cfg.latitudePrefilter
}
func planetTransitAtInferiorConjunctionTT(conjunctionTT float64, cfg planetTransitConfig, instances ...*Instance) (PlanetTransitResult, bool) {
inst := instanceValue(instances)
greatestTT := greatestPlanetTransitTT(conjunctionTT, cfg, inst)
greatestState := planetTransitStateAt(greatestTT, cfg, -1, inst)
if !isFiniteFloat(greatestState.externalContactMetric) || greatestState.externalContactMetric > 0 {
return PlanetTransitResult{}, false
}
result := PlanetTransitResult{
Valid: true,
PlanetIndex: cfg.planetIndex,
Greatest: greatestTT,
MinimumSeparationArcsec: greatestState.separationArcsec,
SunSemidiameterArcsec: greatestState.sunSemidiameter,
PlanetSemidiameterArcsec: greatestState.planetSemidiameter,
HasExternal: true,
HasInternal: greatestState.internalContactMetric <= 0,
}
externalIngress, ok := refinePlanetTransitContactTT(greatestTT, cfg, -1, false, inst)
if !ok {
return PlanetTransitResult{}, false
}
externalEgress, ok := refinePlanetTransitContactTT(greatestTT, cfg, 1, false, inst)
if !ok || externalEgress <= externalIngress {
return PlanetTransitResult{}, false
}
result.ExternalIngress = externalIngress
result.ExternalEgress = externalEgress
if result.HasInternal {
internalIngress, ok := refinePlanetTransitContactTT(greatestTT, cfg, -1, true, inst)
if ok {
result.InternalIngress = internalIngress
}
internalEgress, ok := refinePlanetTransitContactTT(greatestTT, cfg, 1, true, inst)
if ok && internalEgress > internalIngress {
result.InternalEgress = internalEgress
}
result.HasInternal = result.InternalIngress != 0 && result.InternalEgress != 0
}
return result, true
}
func greatestPlanetTransitTT(seedTT float64, cfg planetTransitConfig, instances ...*Instance) float64 {
inst := instanceValue(instances)
left := seedTT - planetTransitGreatestWindowDays
right := seedTT + planetTransitGreatestWindowDays
goldenRatio := (math.Sqrt(5) - 1) / 2
x1 := right - goldenRatio*(right-left)
x2 := left + goldenRatio*(right-left)
f1 := planetTransitStateAt(x1, cfg, planetTransitCoarseN, inst).separationSquared
f2 := planetTransitStateAt(x2, cfg, planetTransitCoarseN, inst).separationSquared
for i := 0; i < 80 && right-left > planetTransitGreatestToleranceDays; i++ {
if f1 <= f2 {
right = x2
x2 = x1
f2 = f1
x1 = right - goldenRatio*(right-left)
f1 = planetTransitStateAt(x1, cfg, planetTransitCoarseN, inst).separationSquared
continue
}
left = x1
x1 = x2
f1 = f2
x2 = left + goldenRatio*(right-left)
f2 = planetTransitStateAt(x2, cfg, planetTransitCoarseN, inst).separationSquared
}
center := (left + right) / 2
left = center - 2.0/24.0
right = center + 2.0/24.0
x1 = right - goldenRatio*(right-left)
x2 = left + goldenRatio*(right-left)
f1 = planetTransitStateAt(x1, cfg, -1, inst).separationSquared
f2 = planetTransitStateAt(x2, cfg, -1, inst).separationSquared
for i := 0; i < 80 && right-left > planetTransitGreatestToleranceDays; i++ {
if f1 <= f2 {
right = x2
x2 = x1
f2 = f1
x1 = right - goldenRatio*(right-left)
f1 = planetTransitStateAt(x1, cfg, -1, inst).separationSquared
continue
}
left = x1
x1 = x2
f1 = f2
x2 = left + goldenRatio*(right-left)
f2 = planetTransitStateAt(x2, cfg, -1, inst).separationSquared
}
return (left + right) / 2
}
func planetTransitStateAt(jdTT float64, cfg planetTransitConfig, n int, instances ...*Instance) planetTransitState {
inst := instanceValue(instances)
planetRA, planetDec := cfg.apparentRaDecN(jdTT, n)
sunRA, sunDec := HSunApparentRaDecNWithInstance(jdTT, n, inst)
separationArcsec := StarAngularSeparation(planetRA, planetDec, sunRA, sunDec) * 3600
sunSemidiameter := SunSemidiameterNWithInstance(jdTT, n, inst)
planetSemidiameter := cfg.semidiameterN(jdTT, n)
return planetTransitState{
jdTT: jdTT,
separationArcsec: separationArcsec,
separationSquared: separationArcsec * separationArcsec,
sunSemidiameter: sunSemidiameter,
planetSemidiameter: planetSemidiameter,
externalContactMetric: separationArcsec - (sunSemidiameter + planetSemidiameter),
internalContactMetric: separationArcsec - (sunSemidiameter - planetSemidiameter),
}
}
func refinePlanetTransitContactTT(greatestTT float64, cfg planetTransitConfig, direction int, internal bool, instances ...*Instance) (float64, bool) {
inst := instanceValue(instances)
if direction != -1 && direction != 1 {
return 0, false
}
metric := func(jdTT float64) float64 {
state := planetTransitStateAt(jdTT, cfg, -1, inst)
if internal {
return state.internalContactMetric
}
return state.externalContactMetric
}
nearJD := greatestTT
nearValue := metric(nearJD)
if !isFiniteFloat(nearValue) || nearValue > 0 {
return 0, false
}
maxSteps := int(math.Ceil(planetTransitContactSpanDays / planetTransitContactStepDays))
for i := 1; i <= maxSteps; i++ {
farJD := greatestTT + float64(direction)*planetTransitContactStepDays*float64(i)
farValue := metric(farJD)
if !isFiniteFloat(farValue) {
continue
}
if farValue >= 0 {
return bisectPlanetTransitContactTT(nearJD, nearValue, farJD, farValue, metric)
}
nearJD = farJD
nearValue = farValue
}
return 0, false
}
func bisectPlanetTransitContactTT(leftJD, leftValue, rightJD, rightValue float64, metric func(float64) float64) (float64, bool) {
if leftJD > rightJD {
leftJD, rightJD = rightJD, leftJD
leftValue, rightValue = rightValue, leftValue
}
if leftValue == 0 {
return leftJD, true
}
if rightValue == 0 {
return rightJD, true
}
if leftValue*rightValue > 0 {
return 0, false
}
for i := 0; i < 80 && rightJD-leftJD > planetTransitContactToleranceDays; i++ {
midJD := (leftJD + rightJD) / 2
midValue := metric(midJD)
if !isFiniteFloat(midValue) {
return 0, false
}
if midValue == 0 {
return midJD, true
}
if leftValue*midValue <= 0 {
rightJD = midJD
rightValue = midValue
continue
}
leftJD = midJD
leftValue = midValue
}
return (leftJD + rightJD) / 2, true
}
func planetTransitResultTTToUT(result PlanetTransitResult) PlanetTransitResult {
result.Greatest = TT2UTC(result.Greatest)
result.ExternalIngress = TT2UTC(result.ExternalIngress)
result.ExternalEgress = TT2UTC(result.ExternalEgress)
if result.InternalIngress != 0 {
result.InternalIngress = TT2UTC(result.InternalIngress)
}
if result.InternalEgress != 0 {
result.InternalEgress = TT2UTC(result.InternalEgress)
}
return result
}
func planetTransitMatchesDirection(eventJDE, targetJDE float64, direction int, includeCurrent bool) bool {
delta := eventJDE - targetJDE
if math.Abs(delta) <= planetTransitSearchEpsilonDays {
return includeCurrent
}
if direction > 0 {
return delta > 0
}
return delta < 0
}
func planetTransitAngleDelta(diff float64) float64 {
diff = Limit360(diff)
if diff > 180 {
diff -= 360
}
if diff < -180 {
diff += 360
}
return diff
}