feat: 引入可切换星历模型并扩展天象观测链路

- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算
- 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询
- 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出
- 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解
- 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤
- 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持
- 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理
- 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
This commit is contained in:
2026-10-15 23:54:15 +08:00
parent 16c62a97d5
commit 044f2f4381
674 changed files with 62983 additions and 8396 deletions
+109 -86
View File
@@ -52,12 +52,9 @@ func (state planetOccultationState) movingDiskContactState() movingDiskContactSt
}
}
// FindPlanetOccultations 搜索固定观测点的有限盘面行星月掩。
// 经度东为正、纬度北为正,单位为度;高度为平均海平面以上米数。目标位置、视差和视半径会在每次候选、掩甚和接触计算时重新计算。
// FindPlanetOccultations searches one finite-disk planet at a fixed observing site.
// Longitude is east-positive in degrees, latitude is north-positive in degrees, and height is the observer elevation above mean sea level in meters. The target position, parallax, and semidiameter are recomputed at every candidate, greatest, and contact evaluation.
func FindPlanetOccultations(start, end time.Time, planet OccultationPlanet, longitude, latitude, height float64,
options OccultationSearchOptions) ([]PlanetOccultationInfo, error) {
// FindPlanetOccultationsWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func FindPlanetOccultationsWithInstance(start, end time.Time, planet OccultationPlanet, longitude, latitude, height float64,
options OccultationSearchOptions, inst *Instance) ([]PlanetOccultationInfo, error) {
if err := validateOccultationTimeRange(start, end); err != nil {
return nil, err
}
@@ -72,16 +69,16 @@ func FindPlanetOccultations(start, end time.Time, planet OccultationPlanet, long
return nil, err
}
config, _ := planetOccultationConfigFor(planet)
config, _ := planetOccultationConfigFor(planet, inst)
startTT := occultationTimeToTT(start)
endTT := occultationTimeToTT(end)
resultLocation := start.Location()
candidates := planetOccultationCandidateGreatestTimes(
startTT, endTT, planetOccultationCoarseStepDays(options), config, &observer, options.SafetyMarginArcsec,
startTT, endTT, planetOccultationCoarseStepDays(options), config, &observer, options.SafetyMarginArcsec, inst,
)
results := make([]PlanetOccultationInfo, 0, len(candidates))
for _, greatestTT := range candidates {
info, ok := planetOccultationInfoAtGreatest(greatestTT, config, observer, options.SafetyMarginArcsec, resultLocation)
info, ok := planetOccultationInfoWithLimb(greatestTT, config, observer, options.SafetyMarginArcsec, resultLocation, options.LunarLimb, inst)
if !ok {
continue
}
@@ -96,12 +93,9 @@ func FindPlanetOccultations(start, end time.Time, planet OccultationPlanet, long
return results, nil
}
// FindBestPlanetOccultations 返回窗口内每次有限盘面行星月掩的全球海平面几何掩甚点。
// 地心数据只用于搜索初值;返回位置与 FindPlanetOccultationPaths 一致,地平线可见性只报告、不参与点选择。距离查询端点 10 ms 内的掩甚时刻也会包含,与数值根精度一致。
// FindBestPlanetOccultations returns the global geometric greatest point at sea level for every finite-disk planetary occultation in the window.
// Geocentric data only seeds the search. The returned location matches FindPlanetOccultationPaths; horizon visibility is reported but does not select the point. A greatest instant within 10 ms of either query endpoint is included, matching the numerical root precision.
func FindBestPlanetOccultations(start, end time.Time, planet OccultationPlanet,
options OccultationSearchOptions) ([]PlanetOccultationInfo, error) {
// FindBestPlanetOccultationsWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func FindBestPlanetOccultationsWithInstance(start, end time.Time, planet OccultationPlanet,
options OccultationSearchOptions, inst *Instance) ([]PlanetOccultationInfo, error) {
if err := validateOccultationTimeRange(start, end); err != nil {
return nil, err
}
@@ -112,7 +106,7 @@ func FindBestPlanetOccultations(start, end time.Time, planet OccultationPlanet,
return nil, err
}
config, _ := planetOccultationConfigFor(planet)
config, _ := planetOccultationConfigFor(planet, inst)
startTT := occultationTimeToTT(start)
endTT := occultationTimeToTT(end)
selectionStartTT := startTT - occultationEventSelectionToleranceDays
@@ -121,15 +115,15 @@ func FindBestPlanetOccultations(start, end time.Time, planet OccultationPlanet,
candidateEndTT := endTT + occultationPathSearchSpanDays
resultLocation := start.Location()
candidates := planetOccultationCandidateGreatestTimes(
candidateStartTT, candidateEndTT, planetOccultationCoarseStepDays(options), config, nil, options.SafetyMarginArcsec,
candidateStartTT, candidateEndTT, planetOccultationCoarseStepDays(options), config, nil, options.SafetyMarginArcsec, inst,
)
results := make([]PlanetOccultationInfo, 0, len(candidates))
for _, seedTT := range candidates {
greatestTT, observer, _, observerOK := planetOccultationBestObserver(seedTT, selectionStartTT, selectionEndTT, config)
greatestTT, observer, _, observerOK := planetOccultationBestObserver(seedTT, selectionStartTT, selectionEndTT, config, inst)
if !observerOK {
continue
}
info, ok := planetOccultationInfoAtGreatest(greatestTT, config, observer, options.SafetyMarginArcsec, resultLocation)
info, ok := planetOccultationInfoWithLimb(greatestTT, config, observer, options.SafetyMarginArcsec, resultLocation, options.LunarLimb, inst)
if !ok {
continue
}
@@ -144,51 +138,53 @@ func FindBestPlanetOccultations(start, end time.Time, planet OccultationPlanet,
return results, nil
}
func planetOccultationConfigFor(planet OccultationPlanet) (planetOccultationConfig, bool) {
func planetOccultationConfigFor(planet OccultationPlanet, instances ...*Instance) (planetOccultationConfig, bool) {
inst := instanceValue(instances)
config := planetOccultationConfig{planet: planet}
switch planet {
case OccultationMercury:
config.planetIndex = 1
config.equatorialRadiusKM = mercuryEquatorialRadiusKM
config.apparentRaDecN = MercuryApparentRaDecN
config.earthDistanceN = EarthMercuryAwayN
config.semidiameterN = MercurySemidiameterN
config.apparentRaDecN = func(jde float64, n int) (float64, float64) { return MercuryApparentRaDecNWithInstance(jde, n, inst) }
config.earthDistanceN = func(jd float64, n int) float64 { return EarthMercuryAwayNWithInstance(jd, n, inst) }
config.semidiameterN = func(jde float64, n int) float64 { return MercurySemidiameterNWithInstance(jde, n, inst) }
case OccultationVenus:
config.planetIndex = 2
config.equatorialRadiusKM = venusEquatorialRadiusKM
config.apparentRaDecN = VenusApparentRaDecN
config.earthDistanceN = EarthVenusAwayN
config.semidiameterN = VenusSemidiameterN
config.apparentRaDecN = func(jd float64, n int) (float64, float64) { return VenusApparentRaDecNWithInstance(jd, n, inst) }
config.earthDistanceN = func(jd float64, n int) float64 { return EarthVenusAwayNWithInstance(jd, n, inst) }
config.semidiameterN = func(jde float64, n int) float64 { return VenusSemidiameterNWithInstance(jde, n, inst) }
case OccultationMars:
config.planetIndex = 3
config.equatorialRadiusKM = marsEquatorialRadiusKM
config.apparentRaDecN = MarsApparentRaDecN
config.earthDistanceN = EarthMarsAwayN
config.semidiameterN = MarsSemidiameterN
config.apparentRaDecN = func(jd float64, n int) (float64, float64) { return MarsApparentRaDecNWithInstance(jd, n, inst) }
config.earthDistanceN = func(jd float64, n int) float64 { return EarthMarsAwayNWithInstance(jd, n, inst) }
config.semidiameterN = func(jde float64, n int) float64 { return MarsSemidiameterNWithInstance(jde, n, inst) }
case OccultationJupiter:
config.planetIndex = 4
config.equatorialRadiusKM = jupiterEquatorialRadiusKM
config.apparentRaDecN = JupiterApparentRaDecN
config.earthDistanceN = EarthJupiterAwayN
config.semidiameterN = JupiterSemidiameterN
config.apparentRaDecN = func(jd float64, n int) (float64, float64) { return JupiterApparentRaDecNWithInstance(jd, n, inst) }
config.earthDistanceN = func(jd float64, n int) float64 { return EarthJupiterAwayNWithInstance(jd, n, inst) }
config.semidiameterN = func(jde float64, n int) float64 { return JupiterSemidiameterNWithInstance(jde, n, inst) }
case OccultationSaturn:
config.planetIndex = 5
config.equatorialRadiusKM = saturnEquatorialRadiusKM
config.apparentRaDecN = SaturnApparentRaDecN
config.earthDistanceN = EarthSaturnAwayN
config.semidiameterN = SaturnSemidiameterN
config.apparentRaDecN = func(jd float64, n int) (float64, float64) { return SaturnApparentRaDecNWithInstance(jd, n, inst) }
config.earthDistanceN = func(jd float64, n int) float64 { return EarthSaturnAwayNWithInstance(jd, n, inst) }
config.semidiameterN = func(jde float64, n int) float64 { return SaturnSemidiameterNWithInstance(jde, n, inst) }
case OccultationUranus:
config.planetIndex = 6
config.equatorialRadiusKM = uranusEquatorialRadiusKM
config.apparentRaDecN = UranusApparentRaDecN
config.earthDistanceN = EarthUranusAwayN
config.semidiameterN = UranusSemidiameterN
config.apparentRaDecN = func(jd float64, n int) (float64, float64) { return UranusApparentRaDecNWithInstance(jd, n, inst) }
config.earthDistanceN = func(jd float64, n int) float64 { return EarthUranusAwayNWithInstance(jd, n, inst) }
config.semidiameterN = func(jde float64, n int) float64 { return UranusSemidiameterNWithInstance(jde, n, inst) }
case OccultationNeptune:
config.planetIndex = 7
config.equatorialRadiusKM = neptuneEquatorialRadiusKM
config.apparentRaDecN = NeptuneApparentRaDecN
config.earthDistanceN = EarthNeptuneAwayN
config.semidiameterN = NeptuneSemidiameterN
config.apparentRaDecN = func(jd float64, n int) (float64, float64) { return NeptuneApparentRaDecNWithInstance(jd, n, inst) }
config.earthDistanceN = func(jd float64, n int) float64 { return EarthNeptuneAwayNWithInstance(jd, n, inst) }
config.semidiameterN = func(jde float64, n int) float64 { return NeptuneSemidiameterNWithInstance(jde, n, inst) }
default:
return planetOccultationConfig{}, false
}
@@ -199,8 +195,9 @@ func planetOccultationCandidateGreatestTimes(
startTT, endTT, step float64,
config planetOccultationConfig,
observer *Observer,
safetyMarginArcsec float64,
) []float64 {
safetyMarginArcsec float64, instances ...*Instance) []float64 {
inst := instanceValue(instances)
if endTT <= startTT {
return nil
}
@@ -214,20 +211,20 @@ func planetOccultationCandidateGreatestTimes(
leftTT := scanStart
centerTT := math.Min(leftTT+step, scanEnd)
leftValue := planetOccultationExternalContactMetric(leftTT, config, observer, 8)
centerValue := planetOccultationExternalContactMetric(centerTT, config, observer, 8)
leftValue := planetOccultationExternalContactMetric(leftTT, config, observer, 8, inst)
centerValue := planetOccultationExternalContactMetric(centerTT, config, observer, 8, inst)
results := make([]float64, 0)
for centerTT < scanEnd {
rightTT := math.Min(centerTT+step, scanEnd)
rightValue := planetOccultationExternalContactMetric(rightTT, config, observer, 8)
rightValue := planetOccultationExternalContactMetric(rightTT, config, observer, 8, inst)
candidateLimit := planetOccultationCandidateLimitArcsec + safetyMarginArcsec
if finite(leftValue) && finite(centerValue) && finite(rightValue) &&
centerValue <= leftValue && centerValue <= rightValue && centerValue <= candidateLimit {
// 最小外接触度量同时包含动态月面和行星盘面,因此定义事件是否存在以及报告的掩甚时刻。
// The minimum outer-contact metric includes both dynamic disks and therefore defines event existence and the reported greatest instant.
greatestTT := planetOccultationMinimizeExternalMetric(leftTT, rightTT, config, observer)
greatestTT := planetOccultationMinimizeExternalMetric(leftTT, rightTT, config, observer, inst)
if greatestTT >= startTT && greatestTT <= endTT &&
planetOccultationLatitudePass(greatestTT, config, observer, safetyMarginArcsec) {
planetOccultationLatitudePass(greatestTT, config, observer, safetyMarginArcsec, inst) {
if len(results) == 0 || math.Abs(greatestTT-results[len(results)-1]) > 60.0/86400.0 {
results = append(results, greatestTT)
}
@@ -239,34 +236,38 @@ func planetOccultationCandidateGreatestTimes(
return results
}
func planetOccultationMinimizeExternalMetric(left, right float64, config planetOccultationConfig, observer *Observer) float64 {
func planetOccultationMinimizeExternalMetric(left, right float64, config planetOccultationConfig, observer *Observer, instances ...*Instance) float64 {
inst := instanceValue(instances)
if right <= left {
return left
}
const goldenRatio = 0.6180339887498949
x1 := right - goldenRatio*(right-left)
x2 := left + goldenRatio*(right-left)
f1 := planetOccultationExternalContactMetric(x1, config, observer, -1)
f2 := planetOccultationExternalContactMetric(x2, config, observer, -1)
f1 := planetOccultationExternalContactMetric(x1, config, observer, -1, inst)
f2 := planetOccultationExternalContactMetric(x2, config, observer, -1, inst)
for i := 0; i < 64 && right-left > planetOccultationRootToleranceDays; i++ {
if f1 > f2 {
left = x1
x1, f1 = x2, f2
x2 = left + goldenRatio*(right-left)
f2 = planetOccultationExternalContactMetric(x2, config, observer, -1)
f2 = planetOccultationExternalContactMetric(x2, config, observer, -1, inst)
} else {
right = x2
x2, f2 = x1, f1
x1 = right - goldenRatio*(right-left)
f1 = planetOccultationExternalContactMetric(x1, config, observer, -1)
f1 = planetOccultationExternalContactMetric(x1, config, observer, -1, inst)
}
}
return (left + right) / 2
}
func planetOccultationBestObserver(seedTT, startTT, endTT float64, config planetOccultationConfig) (float64, Observer, float64, bool) {
func planetOccultationBestObserver(seedTT, startTT, endTT float64, config planetOccultationConfig, instances ...*Instance) (float64, Observer, float64, bool) {
inst := instanceValue(instances)
frameAt := func(tt float64) (occultationPathFrame, bool) {
return planetOccultationPathFrameAt(tt, config)
return planetOccultationPathFrameAt(tt, config, inst)
}
searchStart := seedTT - occultationPathSearchSpanDays
searchEnd := seedTT + occultationPathSearchSpanDays
@@ -288,14 +289,14 @@ func planetOccultationBestObserver(seedTT, startTT, endTT float64, config planet
// 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)
point, pointOK = occultationPathBoundaryPointForFrame(greatestTT, frameAt, time.UTC, inst)
}
}
if !pointOK {
return 0, Observer{}, 0, false
}
observer := Observer{Longitude: point.Longitude, Latitude: point.Latitude}
state := planetOccultationStateAt(greatestTT, config, &observer, -1)
state := planetOccultationStateAt(greatestTT, config, &observer, -1, inst)
if !state.valid {
return 0, Observer{}, 0, false
}
@@ -307,14 +308,32 @@ func planetOccultationInfoAtGreatest(
config planetOccultationConfig,
observer Observer,
safetyMarginArcsec float64,
location *time.Location,
) (PlanetOccultationInfo, bool) {
if !planetOccultationLatitudePass(greatestTT, config, &observer, safetyMarginArcsec) {
location *time.Location, instances ...*Instance) (PlanetOccultationInfo, bool) {
inst := instanceValue(instances)
info, state, ok := planetOccultationGreatestInfo(greatestTT, config, observer, safetyMarginArcsec, location, inst)
if !ok || state.externalContactMetric > 0 {
return PlanetOccultationInfo{}, false
}
state := planetOccultationStateAt(greatestTT, config, &observer, -1)
if !state.valid || state.externalContactMetric > 0 {
return PlanetOccultationInfo{}, false
if math.Abs(state.externalContactMetric) <= planetOccultationGrazingToleranceArcsec {
info.Type = OccultationGrazing
info.ExternalImmersion = info.Greatest
info.ExternalEmersion = info.Greatest
info.ContactsComplete = true
return info, true
}
return planetOccultationCircularContacts(info, state, greatestTT, config, observer, location, inst)
}
// planetOccultationGreatestInfo 只填掩甚几何,不判断圆月面是否成掩、不求接触。
func planetOccultationGreatestInfo(greatestTT float64, config planetOccultationConfig, observer Observer, safetyMarginArcsec float64, location *time.Location, inst *Instance) (PlanetOccultationInfo, planetOccultationState, bool) {
if !planetOccultationLatitudePass(greatestTT, config, &observer, safetyMarginArcsec, inst) {
return PlanetOccultationInfo{}, planetOccultationState{}, false
}
state := planetOccultationStateAt(greatestTT, config, &observer, -1, inst)
if !state.valid {
return PlanetOccultationInfo{}, planetOccultationState{}, false
}
info := PlanetOccultationInfo{
Planet: config.planet,
@@ -330,16 +349,11 @@ func planetOccultationInfoAtGreatest(
MoonAzimuthAtGreatest: occultationAzimuth(greatestTT, observer, state.position.moonRA, state.position.moonDec),
}
info.VisibleAtGreatest = info.MoonAltitudeAtGreatest >= 0
return info, state, true
}
if math.Abs(state.externalContactMetric) <= planetOccultationGrazingToleranceArcsec {
info.Type = OccultationGrazing
info.ExternalImmersion = info.Greatest
info.ExternalEmersion = info.Greatest
info.ContactsComplete = true
return info, true
}
contactEvaluator := newPlanetOccultationContactEvaluator(config, observer)
func planetOccultationCircularContacts(info PlanetOccultationInfo, state planetOccultationState, greatestTT float64, config planetOccultationConfig, observer Observer, location *time.Location, inst *Instance) (PlanetOccultationInfo, bool) {
contactEvaluator := newPlanetOccultationContactEvaluator(config, observer, inst)
contactEvaluator.prime(greatestTT, state.movingDiskContactState(), state.valid)
externalImmersionTT, externalImmersionOK := planetOccultationContactWithEvaluator(greatestTT, -1, false, contactEvaluator)
externalEmersionTT, externalEmersionOK := planetOccultationContactWithEvaluator(greatestTT, 1, false, contactEvaluator)
@@ -366,12 +380,14 @@ func planetOccultationInfoAtGreatest(
return info, true
}
func planetMoonPositionAt(tt float64, config planetOccultationConfig, observer *Observer, n int) planetMoonPosition {
moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, n)
func planetMoonPositionAt(tt float64, config planetOccultationConfig, observer *Observer, n int, instances ...*Instance) planetMoonPosition {
inst := instanceValue(instances)
moonRA, moonDec := HMoonGeocentricApparentRaDecNWithInstance(tt, n, inst)
planetRA, planetDec := config.apparentRaDecN(tt, n)
if observer != nil {
ut := TT2UTC(tt)
moonDistanceAU := HMoonAwayN(tt, n) / angularDiameterAstronomicalUnitKM
moonDistanceAU := HMoonAwayNWithInstance(tt, n, inst) / angularDiameterAstronomicalUnitKM
planetDistanceAU := config.earthDistanceN(tt, n)
moonRA, moonDec = TopocentricRaDec(moonRA, moonDec, observer.Latitude, observer.Longitude, ut, moonDistanceAU, observer.Height)
planetRA, planetDec = TopocentricRaDec(planetRA, planetDec, observer.Latitude, observer.Longitude, ut, planetDistanceAU, observer.Height)
@@ -387,12 +403,14 @@ func planetMoonPositionAt(tt float64, config planetOccultationConfig, observer *
}
}
func planetOccultationStateAt(tt float64, config planetOccultationConfig, observer *Observer, n int) planetOccultationState {
position := planetMoonPositionAt(tt, config, observer, n)
moonRadius := MoonSemidiameterN(tt, n)
func planetOccultationStateAt(tt float64, config planetOccultationConfig, observer *Observer, n int, instances ...*Instance) planetOccultationState {
inst := instanceValue(instances)
position := planetMoonPositionAt(tt, config, observer, n, inst)
moonRadius := MoonSemidiameterNWithInstance(tt, n, inst)
planetRadius := config.semidiameterN(tt, n)
if observer != nil {
moonRadius = moonTopocentricSemidiameterN(tt, *observer, n)
moonRadius = moonTopocentricSemidiameterN(tt, *observer, n, inst)
planetRadius = planetTopocentricSemidiameterN(tt, config, *observer, n)
}
if !position.valid || !finite(moonRadius) || !finite(planetRadius) || moonRadius <= planetRadius || planetRadius <= 0 {
@@ -432,16 +450,20 @@ func planetTopocentricSemidiameterN(tt float64, config planetOccultationConfig,
return angularSemidiameterArcsec(config.equatorialRadiusKM, distanceKM)
}
func planetOccultationExternalContactMetric(tt float64, config planetOccultationConfig, observer *Observer, n int) float64 {
state := planetOccultationStateAt(tt, config, observer, n)
func planetOccultationExternalContactMetric(tt float64, config planetOccultationConfig, observer *Observer, n int, instances ...*Instance) float64 {
inst := instanceValue(instances)
state := planetOccultationStateAt(tt, config, observer, n, inst)
if !state.valid {
return math.Inf(1)
}
return state.externalContactMetric
}
func planetOccultationLatitudePass(tt float64, config planetOccultationConfig, observer *Observer, safetyMarginArcsec float64) bool {
state := planetOccultationStateAt(tt, config, observer, -1)
func planetOccultationLatitudePass(tt float64, config planetOccultationConfig, observer *Observer, safetyMarginArcsec float64, instances ...*Instance) bool {
inst := instanceValue(instances)
state := planetOccultationStateAt(tt, config, observer, -1, inst)
if !state.valid {
return false
}
@@ -453,10 +475,11 @@ func planetOccultationLatitudePass(tt float64, config planetOccultationConfig, o
func newPlanetOccultationContactEvaluator(
config planetOccultationConfig,
observer Observer,
) *movingDiskContactEvaluator {
observer Observer, instances ...*Instance) *movingDiskContactEvaluator {
inst := instanceValue(instances)
return newMovingDiskContactEvaluator(func(tt float64) (movingDiskContactState, bool) {
state := planetOccultationStateAt(tt, config, &observer, -1)
state := planetOccultationStateAt(tt, config, &observer, -1, inst)
return state.movingDiskContactState(), state.valid
})
}