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
+461
View File
@@ -0,0 +1,461 @@
package basic
import (
"fmt"
"math"
"sort"
"time"
)
const (
starOccultationLimbDefaultStep = 200 * time.Millisecond
starOccultationLimbMinimumStep = 10 * time.Millisecond
starOccultationLimbMaxSamples = 20000
starOccultationLimbMinSamples = 64
starOccultationLimbBoundStep = 60.0 / 86400.0
starOccultationLimbBoundSpan = 3.5 / 24.0
starOccultationLimbNodeDays = 10.0 / 1440.0
starOccultationLimbNodeSpan = 4.0 / 24.0
starOccultationLimbRootDays = 1e-3 / 86400.0
starOccultationLimbUnrangedPadKM = 1.0
)
// StarOccultationLimbProfileOptions 控制精密恒星月掩的星迹采样 / sampling of a precise stellar occultation.
type StarOccultationLimbProfileOptions struct {
// Step 是星迹采样步长;零值 200 ms,小于 10 ms 提升到 10 ms;每段至少 64 个样本、总数不超过 20000,越界时自动调整。短于步长的遮挡或复现可能漏检 / track sampling step.
Step time.Duration
}
// StarOccultationLimbSample 星迹上一个时刻的月缘几何,角量均为角秒、度 / limb geometry at one instant on the star track.
type StarOccultationLimbSample struct {
Time time.Time
// SeparationArcsec 恒星到站心月心的角距 / star–Moon-centre separation.
SeparationArcsec float64
// LimbRadiusArcsec 恒星方向上的月缘角半径 / limb radius toward the star.
LimbRadiusArcsec float64
// MeanRadiusArcsec 参考圆月面角半径 / circular reference radius.
MeanRadiusArcsec float64
// PositionAngleDeg 恒星相对月心的位置角,自天北向东 / position angle of the star.
PositionAngleDeg float64
// Hidden 恒星位于月缘以内 / star is behind the limb.
Hidden bool
}
// OccultationInterval 一段连续遮挡 / one continuous hidden interval.
type OccultationInterval struct {
Start time.Time
End time.Time
}
// StarOccultationLimbProfileResult 一次恒星月掩沿星迹的月缘剖面与全部遮挡区间 / limb profile and every hidden interval of a stellar occultation.
type StarOccultationLimbProfileResult struct {
// Occultation 按同一剖面求得的标准结果,掩始、掩终为首次遮挡与最后复现 / standard result on the same profile.
Occultation StarOccultationInfo
// Segments 月缘起伏带内的采样段,按时间排序;深掩时为掩入侧与复现侧两段,掠掩时通常为一段 / samples inside the limb relief band.
Segments [][]StarOccultationLimbSample
// Hidden 全部遮挡区间,按时间排序 / every hidden interval.
Hidden []OccultationInterval
// ReferenceRadiusKM 样本 MeanRadiusArcsec 对应的参考月球半径 / reference lunar radius behind MeanRadiusArcsec.
ReferenceRadiusKM float64
// ReliefMinKM / ReliefMaxKM 判定所用的月缘半径范围 / limb radius range used for gating.
ReliefMinKM float64
ReliefMaxKM float64
// Step 实际采用的采样步长 / effective step.
Step time.Duration
}
type starLimbState struct {
separation, limbRadius, meanRadius, positionAngle float64
distanceKM float64
}
// starLimbEphemeris 掩甚前后 ±4 h 的地心月球、恒星视位置与月球本体坐标架插值表;站心换算逐点精确计算。
type starLimbEphemeris struct {
bodies []localEphemerisVectorNode
axes []localEphemerisVectorNode
base lunarLimbFrame
hasDistance bool
}
func newStarLimbEphemeris(greatestTT float64, star StarCoordinate, limb LunarLimb, inst *Instance) (*starLimbEphemeris, bool) {
half := int(math.Round(starOccultationLimbNodeSpan / starOccultationLimbNodeDays))
ephemeris := &starLimbEphemeris{
bodies: make([]localEphemerisVectorNode, 2*half+1),
axes: make([]localEphemerisVectorNode, 2*half+1),
}
for index := range ephemeris.bodies {
tt := greatestTT + float64(index-half)*starOccultationLimbNodeDays
moonRA, moonDec := HMoonGeocentricApparentRaDecNWithInstance(tt, -1, inst)
moon := lunarEclipseLimbVector(moonRA, moonDec, HMoonAwayNWithInstance(tt, -1, inst))
starRA, starDec, distanceAU := starApparentRaDecDistanceGeocentric(tt, star, inst)
if distanceAU <= 0 {
distanceAU = 1
} else {
ephemeris.hasDistance = true
}
frame := newLunarLimbFrame(limb, tt, moon, inst)
if !frame.valid || !finiteVector3(moon) {
return nil, false
}
if index == half {
ephemeris.base = frame
}
ephemeris.bodies[index] = localEphemerisVectorNode{tt: tt, first: moon, next: lunarEclipseLimbVector(starRA, starDec, distanceAU)}
ephemeris.axes[index] = localEphemerisVectorNode{tt: tt, first: frame.axisX, next: frame.axisZ}
}
return ephemeris, true
}
func (ephemeris *starLimbEphemeris) stateAt(tt float64, observer Observer) (starLimbState, bool) {
moon, starVector, ok := interpolateLocalEphemerisVectors(ephemeris.bodies, tt)
if !ok {
return starLimbState{}, false
}
axisX, axisZ, ok := interpolateLocalEphemerisVectors(ephemeris.axes, tt)
if !ok {
return starLimbState{}, false
}
geoDistance := math.Sqrt(lunarLimbDot(moon, moon))
geoRA := normalizeRA(math.Atan2(moon[1], moon[0]) / rad)
geoDec := math.Asin(clampUnit(moon[2]/geoDistance)) / rad
ut := TT2UTC(tt)
topoRA, topoDec := TopocentricRaDec(geoRA, geoDec, observer.Latitude, observer.Longitude, ut, geoDistance/149597870.7, observer.Height)
topoRA = normalizeRA(topoRA)
topoDistance := topocentricDistanceKM(geoRA, geoDec, geoDistance, observer, ut)
starDistance := math.Sqrt(lunarLimbDot(starVector, starVector))
starRA := normalizeRA(math.Atan2(starVector[1], starVector[0]) / rad)
starDec := math.Asin(clampUnit(starVector[2]/starDistance)) / rad
if ephemeris.hasDistance {
starRA, starDec = TopocentricRaDec(starRA, starDec, observer.Latitude, observer.Longitude, ut, starDistance, observer.Height)
starRA = normalizeRA(starRA)
}
if !finite(topoRA) || !finite(topoDec) || !finite(topoDistance) || topoDistance <= 0 || !finite(starRA) || !finite(starDec) {
return starLimbState{}, false
}
frame := ephemeris.base
if frame.axisX, ok = lunarLimbNormalize(axisX); !ok {
return starLimbState{}, false
}
if frame.axisZ, ok = lunarLimbNormalize(axisZ); !ok {
return starLimbState{}, false
}
// 两轴分别插值后不再严格正交,按 z 轴重新正交化 x 轴。
if frame.axisX, ok = lunarLimbNormalize(lunarLimbReject(frame.axisX, frame.axisZ)); !ok {
return starLimbState{}, false
}
frame.axisY = lunarLimbCross(frame.axisZ, frame.axisX)
radiusKM, ok := frame.radiusKM(lunarEclipseLimbVector(topoRA, topoDec, topoDistance), lunarEclipseLimbVector(starRA, starDec, 1), false)
if !ok {
return starLimbState{}, false
}
return starLimbState{
separation: angularSeparationDegrees(topoRA, topoDec, starRA, starDec) * 3600,
limbRadius: angularSemidiameterArcsec(radiusKM, topoDistance),
meanRadius: angularSemidiameterArcsec(moonEquatorialRadiusKM, topoDistance),
positionAngle: occultationPositionAngle(topoRA, topoDec, starRA, starDec),
distanceKM: topoDistance,
}, true
}
// starOccultationLimbRange 返回剖面半径范围;剖面不报告范围时在掩甚扫一圈并外扩 1 km。
func starOccultationLimbRange(tt float64, star StarCoordinate, observer Observer, limb LunarLimb, inst *Instance) (float64, float64, bool) {
geoRA, geoDec := HMoonGeocentricApparentRaDecNWithInstance(tt, -1, inst)
geoDistance := HMoonAwayNWithInstance(tt, -1, inst)
frame := newLunarLimbFrame(limb, tt, lunarEclipseLimbVector(geoRA, geoDec, geoDistance), inst)
if !frame.valid {
return 0, 0, false
}
if frame.ranged {
return frame.minKM, frame.maxKM, true
}
topoRA, topoDec := moonTopocentricApparentRaDec(tt, observer, -1, inst)
starRA, starDec := starApparentRaDec(tt, star, observer, inst)
view, ok := frame.viewToward(lunarEclipseLimbVector(topoRA, topoDec, 1), lunarEclipseLimbVector(starRA, starDec, 1))
if !ok {
return 0, 0, false
}
low, high := math.Inf(1), math.Inf(-1)
for offset := 0.0; offset < 360; offset += frame.stepDeg {
radius, ok := frame.radiusAt(view, offset)
if !ok {
return 0, 0, false
}
low, high = math.Min(low, radius), math.Max(high, radius)
}
return low - starOccultationLimbUnrangedPadKM, high + starOccultationLimbUnrangedPadKM, true
}
// starOccultationSeparationCrossing 自掩甚向 direction 一侧求角距升到 radiusKM 对应角半径的时刻;角距自掩甚单调增大。
func starOccultationSeparationCrossing(greatestTT float64, direction float64, radiusKM float64, ephemeris *starLimbEphemeris, observer Observer) (float64, bool) {
value := func(tt float64) float64 {
state, ok := ephemeris.stateAt(tt, observer)
if !ok {
return math.NaN()
}
return state.separation - angularSemidiameterArcsec(radiusKM, state.distanceKM)
}
near, nearValue := greatestTT, value(greatestTT)
if !finite(nearValue) {
return 0, false
}
if nearValue >= 0 {
return greatestTT, true
}
for step := 1; float64(step)*starOccultationLimbBoundStep <= starOccultationLimbBoundSpan; step++ {
far := greatestTT + direction*float64(step)*starOccultationLimbBoundStep
farValue := value(far)
if !finite(farValue) {
return 0, false
}
if farValue < 0 {
near, nearValue = far, farValue
continue
}
for i := 0; i < 64 && math.Abs(far-near) > starOccultationLimbRootDays; i++ {
mid := (near + far) / 2
if v := value(mid); v < 0 {
near = mid
} else {
far = mid
}
}
return (near + far) / 2, true
}
return 0, false
}
type starLimbRoot struct {
tt float64
disappear bool
}
type starLimbScan struct {
segments [][]StarOccultationLimbSample
hidden [][2]float64
lowKM float64
highKM float64
stepDays float64
// grazing 为真表示星迹始终在月缘起伏带内(单段采样)。
grazing bool
}
// starOccultationLimbScanAt 按剖面起伏带把星迹分必见、必掩与带内三段,带外两端遮挡状态由构造保证;ok 为假表示剖面无值或星历失败。
func starOccultationLimbScanAt(greatestTT float64, star StarCoordinate, observer Observer, limb LunarLimb, stepDays float64, location *time.Location, inst *Instance) (starLimbScan, bool) {
lowKM, highKM, ok := starOccultationLimbRange(greatestTT, star, observer, limb, inst)
if !ok {
return starLimbScan{}, false
}
scan := starLimbScan{lowKM: lowKM, highKM: highKM, stepDays: stepDays}
ephemeris, ok := newStarLimbEphemeris(greatestTT, star, limb, inst)
if !ok {
return starLimbScan{}, false
}
outerLeft, okLeft := starOccultationSeparationCrossing(greatestTT, -1, highKM, ephemeris, observer)
outerRight, okRight := starOccultationSeparationCrossing(greatestTT, 1, highKM, ephemeris, observer)
if !okLeft || !okRight {
return starLimbScan{}, false
}
if outerLeft == greatestTT && outerRight == greatestTT {
return scan, true
}
innerLeft, okLeft := starOccultationSeparationCrossing(greatestTT, -1, lowKM, ephemeris, observer)
innerRight, okRight := starOccultationSeparationCrossing(greatestTT, 1, lowKM, ephemeris, observer)
if !okLeft || !okRight {
return starLimbScan{}, false
}
type span struct {
start, end float64
hiddenAtStart bool
hiddenAtEnd bool
forceStart, forceEnd bool
}
spans := []span{{outerLeft, innerLeft, false, true, true, true}, {innerRight, outerRight, true, false, true, true}}
if innerLeft == greatestTT && innerRight == greatestTT {
spans = []span{{outerLeft, outerRight, false, false, true, true}}
}
total, shortest := 0.0, math.Inf(1)
for _, item := range spans {
total += item.end - item.start
shortest = math.Min(shortest, item.end-item.start)
}
// 步长过大时掠掩两端都可见、中间的遮挡会整段漏掉,所以每段至少保留 64 个样本。
if limit := shortest / starOccultationLimbMinSamples; shortest > 0 && stepDays > limit {
stepDays = math.Max(limit, starOccultationLimbMinimumStep.Hours()/24)
}
if total/stepDays > starOccultationLimbMaxSamples {
stepDays = total / starOccultationLimbMaxSamples
}
scan.stepDays = stepDays
scan.grazing = len(spans) == 1
gapAt := func(tt float64) (starLimbState, float64, bool) {
state, ok := ephemeris.stateAt(tt, observer)
return state, state.separation - state.limbRadius, ok
}
var roots []starLimbRoot
for _, item := range spans {
// 剖面范围零宽时带也零长,遮挡状态在该点翻转;不产样本,避免同刻样本。
if item.end-item.start < starOccultationLimbRootDays {
if item.hiddenAtStart != item.hiddenAtEnd {
roots = append(roots, starLimbRoot{tt: item.start, disappear: item.hiddenAtEnd})
}
continue
}
count := int(math.Ceil((item.end - item.start) / stepDays))
if count < 1 {
count = 1
}
samples := make([]StarOccultationLimbSample, 0, count+1)
previousTT, previousHidden := 0.0, false
var previousGap float64
for index := 0; index <= count; index++ {
tt := item.start + (item.end-item.start)*float64(index)/float64(count)
state, gap, ok := gapAt(tt)
if !ok {
return starLimbScan{}, false
}
hidden := gap < 0
// 带两端的遮挡状态由角距保证,消除端点上的舍入抖动。
if index == 0 && item.forceStart {
hidden = item.hiddenAtStart
}
if index == count && item.forceEnd {
hidden = item.hiddenAtEnd
}
samples = append(samples, StarOccultationLimbSample{
Time: occultationTTToLocation(tt, location),
SeparationArcsec: state.separation,
LimbRadiusArcsec: state.limbRadius,
MeanRadiusArcsec: state.meanRadius,
PositionAngleDeg: state.positionAngle,
Hidden: hidden,
})
if index > 0 && hidden != previousHidden {
root := starOccultationLimbBisect(previousTT, tt, previousGap, gap, gapAt)
roots = append(roots, starLimbRoot{tt: root, disappear: hidden})
}
previousTT, previousHidden, previousGap = tt, hidden, gap
}
scan.segments = append(scan.segments, samples)
}
sort.SliceStable(roots, func(i, j int) bool { return roots[i].tt < roots[j].tt })
start := math.NaN()
for _, root := range roots {
if root.disappear {
start = root.tt
} else if !math.IsNaN(start) {
scan.hidden = append(scan.hidden, [2]float64{start, root.tt})
start = math.NaN()
}
}
return scan, true
}
// starOccultationLimbBisect 在相邻样本间求月缘间隙零点;端点被强制定号时零点可能落在端点上。
func starOccultationLimbBisect(left, right, leftGap, rightGap float64, gapAt func(float64) (starLimbState, float64, bool)) float64 {
if leftGap*rightGap > 0 {
if math.Abs(leftGap) < math.Abs(rightGap) {
return left
}
return right
}
for i := 0; i < 64 && right-left > starOccultationLimbRootDays; i++ {
mid := (left + right) / 2
_, gap, ok := gapAt(mid)
if !ok {
break
}
if (gap < 0) == (leftGap < 0) {
left, leftGap = mid, gap
} else {
right = mid
}
}
return (left + right) / 2
}
// starOccultationInfoWithLimb 有月缘时按剖面判定是否成掩,掩始、掩终取首次遮挡与最后复现;剖面无值时退回圆月面。
func starOccultationInfoWithLimb(greatestTT float64, star StarCoordinate, observer Observer, safetyMarginArcsec float64, location *time.Location, limb LunarLimb, inst *Instance) (StarOccultationInfo, bool) {
if limb == nil {
return starOccultationInfoAtGreatest(greatestTT, star, observer, safetyMarginArcsec, location, inst)
}
info, ok := starOccultationGreatestInfo(greatestTT, star, observer, safetyMarginArcsec, location, inst)
if !ok {
return StarOccultationInfo{}, false
}
scan, ok := starOccultationLimbScanAt(greatestTT, star, observer, limb, starOccultationLimbDefaultStep.Hours()/24, location, inst)
if !ok {
return starOccultationInfoAtGreatest(greatestTT, star, observer, safetyMarginArcsec, location, inst)
}
return starOccultationInfoFromScan(info, scan, location), len(scan.hidden) > 0
}
func starOccultationInfoFromScan(info StarOccultationInfo, scan starLimbScan, location *time.Location) StarOccultationInfo {
if len(scan.hidden) == 0 {
info.Type = OccultationNone
info.Immersion, info.Emersion = time.Time{}, time.Time{}
info.ContactsComplete = false
info.LunarLimbApplied = true
return info
}
info.Type = OccultationTotal
if scan.grazing {
info.Type = OccultationGrazing
}
info.Immersion = occultationTTToLocation(scan.hidden[0][0], location)
info.Emersion = occultationTTToLocation(scan.hidden[len(scan.hidden)-1][1], location)
info.ContactsComplete = true
info.LunarLimbApplied = true
return info
}
// StarOccultationLimbProfileWithInstance 沿星迹按月缘剖面精密求解一次恒星月掩,返回全部遮挡区间与带内采样;info 只取掩甚时刻与观测者,须为 UTC 口径结果(不能传 UT1 标注副本),恒星未被遮挡时 Occultation.Type 为 OccultationNone / precise stellar occultation along the track with a lunar limb profile.
func StarOccultationLimbProfileWithInstance(info StarOccultationInfo, star StarCoordinate, limb LunarLimb, options StarOccultationLimbProfileOptions, inst *Instance) (StarOccultationLimbProfileResult, error) {
if limb == nil {
return StarOccultationLimbProfileResult{}, fmt.Errorf("%w: lunar limb profile is required", ErrInvalidOccultationInput)
}
if err := star.Validate(); err != nil {
return StarOccultationLimbProfileResult{}, err
}
if err := info.Observer.Validate(); err != nil {
return StarOccultationLimbProfileResult{}, err
}
if info.Greatest.IsZero() {
return StarOccultationLimbProfileResult{}, fmt.Errorf("%w: greatest occultation time is required", ErrInvalidOccultationInput)
}
if options.Step < 0 {
return StarOccultationLimbProfileResult{}, fmt.Errorf("%w: profile step cannot be negative", ErrInvalidOccultationInput)
}
step := options.Step
if step == 0 {
step = starOccultationLimbDefaultStep
}
if step < starOccultationLimbMinimumStep {
step = starOccultationLimbMinimumStep
}
location := info.Greatest.Location()
greatestTT := occultationTimeToTT(info.Greatest)
base, ok := starOccultationGreatestInfo(greatestTT, star, info.Observer, math.Inf(1), location, inst)
if !ok {
return StarOccultationLimbProfileResult{}, fmt.Errorf("%w: geometry is unavailable at greatest occultation", ErrInvalidOccultationInput)
}
base.TargetID = star.ID
scan, ok := starOccultationLimbScanAt(greatestTT, star, info.Observer, limb, step.Hours()/24, location, inst)
if !ok {
return StarOccultationLimbProfileResult{}, fmt.Errorf("%w: lunar limb profile has no value along the track", ErrInvalidOccultationInput)
}
profile := StarOccultationLimbProfileResult{
Occultation: starOccultationInfoFromScan(base, scan, location),
Segments: scan.segments,
ReferenceRadiusKM: moonEquatorialRadiusKM,
ReliefMinKM: scan.lowKM,
ReliefMaxKM: scan.highKM,
Step: time.Duration(math.Round(scan.stepDays * 86400 * float64(time.Second))),
}
for _, interval := range scan.hidden {
profile.Hidden = append(profile.Hidden, OccultationInterval{
Start: occultationTTToLocation(interval[0], location),
End: occultationTTToLocation(interval[1], location),
})
}
return profile, nil
}