Files
astro/basic/occultation_star_limb.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

462 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}