feat: 新增月掩与日月食地理绘图并提升观测计算精度

- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算
- 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出
- 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口
- 扩展日食中心线、南北界及偏食足迹采样,支持极区投影
- 修正站心时角、月出月落、月球视半径、折射和恒星自行计算
- 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
This commit is contained in:
2026-08-06 12:00:56 +08:00
parent 25dc7ac0bc
commit 9ee2163cc7
137 changed files with 21770 additions and 1746 deletions
+247
View File
@@ -0,0 +1,247 @@
package basic
import (
"math"
"sort"
)
const (
starOccultationDiagramDefaultStepDays = 2.0 / 1440.0
starOccultationDiagramMinStepDays = 1.0 / 86400.0
starOccultationDiagramMaxSamples = 2000
starOccultationDiagramDuplicateDays = 1e-10
starOccultationDiagramGeometryArcsec = 0.05
starOccultationDiagramPositionDeg = 0.01
)
// StarOccultationDiagramOptions 控制本地恒星月掩图的轨迹采样。
// StarOccultationDiagramOptions controls local stellar-occultation diagram sampling.
type StarOccultationDiagramOptions struct {
// StepDays 是请求的轨迹采样步长,单位为日;非正值或非有限值使用两分钟,正值小于一秒时使用一秒。长事件可能增大实际步长,使基础轨迹不超过 2000 个采样点;必要阶段帧仍会额外保留。结果会报告实际采用的值。
// StepDays is the requested track sampling step in days. Non-positive or non-finite values use two minutes, and positive values below one second use one second. Long events may increase the effective step to keep the base track within 2000 samples; required phase frames are retained in addition. The result reports the effective value.
StepDays float64
}
// StarOccultationDiagramFrame 描述一个时刻的站心月球与恒星几何。
// StarOccultationDiagramFrame describes topocentric Moon-star geometry at one instant.
type StarOccultationDiagramFrame struct {
// JDE 是 TT 儒略历书日。
// JDE is the TT Julian ephemeris day.
JDE float64
// StarXArcsec 和 StarYArcsec 是相对月心的切平面偏移,单位为角秒。X 向东为正,Y 向北为正。
// StarXArcsec and StarYArcsec are tangent-plane offsets from the lunar center. X is positive east and Y is positive north.
StarXArcsec float64
StarYArcsec float64
// MoonRadiusArcsec 是站心月球视半径,单位为角秒。
// MoonRadiusArcsec is the topocentric apparent lunar semidiameter.
MoonRadiusArcsec float64
// SeparationArcsec 和 PositionAngleDeg 描述恒星相对月心的位置。
// SeparationArcsec and PositionAngleDeg describe the star relative to the lunar center.
SeparationArcsec float64
PositionAngleDeg float64
// MoonAltitudeDeg 和 MoonAzimuthDeg 是站心地平坐标。
// MoonAltitudeDeg and MoonAzimuthDeg are topocentric horizontal coordinates.
MoonAltitudeDeg float64
MoonAzimuthDeg float64
// BehindMoon 表示点光源恒星位于月缘内侧。
// BehindMoon is true while the point-source star lies strictly inside the lunar limb.
BehindMoon bool
// Label 是主阶段标识;Labels 在掠掩事件中保留重合阶段。
// Label is the primary key phase; Labels retains coincident phases for grazing events.
Label string
Labels []string
}
// StarOccultationDiagramResult 包含固定地点恒星月掩的几何数据。
// StarOccultationDiagramResult contains geometry for a fixed-site stellar occultation.
type StarOccultationDiagramResult struct {
Occultation StarOccultationInfo
Frames []StarOccultationDiagramFrame
// StepDays 是实际采用的基础轨迹采样步长,单位为日。
// StepDays is the effective base-track sampling step in days.
StepDays float64
}
type starOccultationDiagramTime struct {
jde float64
labels []string
}
// StarOccultationDiagram 为已求解的固定地点恒星月掩计算以月心为原点的切平面轨迹。事件数据无效或不完整时,结果不含帧。
// StarOccultationDiagram computes a Moon-centered tangent-plane track for an already solved fixed-site stellar occultation. Invalid or incomplete event data produces a result without frames.
func StarOccultationDiagram(
info StarOccultationInfo,
star StarCoordinate,
options StarOccultationDiagramOptions,
) StarOccultationDiagramResult {
options = normalizeStarOccultationDiagramOptions(options)
result := StarOccultationDiagramResult{Occultation: info, StepDays: options.StepDays}
if star.Validate() != nil || info.Observer.Validate() != nil ||
!info.ContactsComplete || info.Immersion.IsZero() || info.Greatest.IsZero() || info.Emersion.IsZero() ||
info.Greatest.Before(info.Immersion) || info.Emersion.Before(info.Greatest) ||
(info.Type != OccultationTotal && info.Type != OccultationGrazing) {
return result
}
startTT := occultationTimeToTT(info.Immersion)
greatestTT := occultationTimeToTT(info.Greatest)
endTT := occultationTimeToTT(info.Emersion)
immersionFrame, immersionOK := starOccultationDiagramFrameAt(startTT, star, info.Observer)
greatestFrame, greatestOK := starOccultationDiagramFrameAt(greatestTT, star, info.Observer)
emersionFrame, emersionOK := starOccultationDiagramFrameAt(endTT, star, info.Observer)
if !immersionOK || !greatestOK || !emersionOK ||
!starOccultationDiagramMatchesInfo(info, immersionFrame, greatestFrame, emersionFrame) {
return result
}
times, stepDays := starOccultationDiagramTimes(startTT, greatestTT, endTT, options.StepDays)
result.StepDays = stepDays
result.Frames = make([]StarOccultationDiagramFrame, 0, len(times))
for _, item := range times {
frame, ok := starOccultationDiagramFrameAt(item.jde, star, info.Observer)
if !ok {
return StarOccultationDiagramResult{Occultation: info, StepDays: stepDays}
}
frame.Labels = append([]string(nil), item.labels...)
frame.Label = starOccultationDiagramPrimaryLabel(item.labels)
result.Frames = append(result.Frames, frame)
}
return result
}
func starOccultationDiagramMatchesInfo(
info StarOccultationInfo,
immersion, greatest, emersion StarOccultationDiagramFrame,
) bool {
if !finite(info.MinimumSeparationArcsec) || !finite(info.MoonSemidiameterArcsec) ||
!finite(info.PositionAngleDeg) {
return false
}
if math.Abs(immersion.SeparationArcsec-immersion.MoonRadiusArcsec) > starOccultationDiagramGeometryArcsec ||
math.Abs(emersion.SeparationArcsec-emersion.MoonRadiusArcsec) > starOccultationDiagramGeometryArcsec {
return false
}
return math.Abs(greatest.SeparationArcsec-info.MinimumSeparationArcsec) <= starOccultationDiagramGeometryArcsec &&
math.Abs(greatest.MoonRadiusArcsec-info.MoonSemidiameterArcsec) <= starOccultationDiagramGeometryArcsec &&
math.Abs(signedAngleDifference(greatest.PositionAngleDeg, info.PositionAngleDeg)) <= starOccultationDiagramPositionDeg
}
func normalizeStarOccultationDiagramOptions(options StarOccultationDiagramOptions) StarOccultationDiagramOptions {
if options.StepDays <= 0 || !finite(options.StepDays) {
options.StepDays = starOccultationDiagramDefaultStepDays
}
if options.StepDays < starOccultationDiagramMinStepDays {
options.StepDays = starOccultationDiagramMinStepDays
}
return options
}
func starOccultationDiagramTimes(startTT, greatestTT, endTT, stepDays float64) ([]starOccultationDiagramTime, float64) {
if !finite(startTT) || !finite(greatestTT) || !finite(endTT) || greatestTT < startTT || endTT < greatestTT {
return nil, stepDays
}
if endTT > startTT {
if sampleCount := int(math.Ceil((endTT-startTT)/stepDays)) + 1; sampleCount > starOccultationDiagramMaxSamples {
stepDays = (endTT - startTT) / float64(starOccultationDiagramMaxSamples-1)
}
}
times := []starOccultationDiagramTime{
{jde: startTT, labels: []string{"Immersion"}},
{jde: greatestTT, labels: []string{"Greatest"}},
{jde: endTT, labels: []string{"Emersion"}},
}
for jde := startTT + stepDays; jde < endTT; jde += stepDays {
times = append(times, starOccultationDiagramTime{jde: jde})
}
sort.SliceStable(times, func(i, j int) bool {
if times[i].jde == times[j].jde {
return starOccultationDiagramLabelPriority(times[i].labels) < starOccultationDiagramLabelPriority(times[j].labels)
}
return times[i].jde < times[j].jde
})
return uniqueStarOccultationDiagramTimes(times), stepDays
}
func uniqueStarOccultationDiagramTimes(times []starOccultationDiagramTime) []starOccultationDiagramTime {
unique := times[:0]
for _, item := range times {
if !finite(item.jde) {
continue
}
if len(unique) == 0 || math.Abs(item.jde-unique[len(unique)-1].jde) > starOccultationDiagramDuplicateDays {
item.labels = append([]string(nil), item.labels...)
unique = append(unique, item)
continue
}
unique[len(unique)-1].labels = mergeStarOccultationDiagramLabels(unique[len(unique)-1].labels, item.labels)
}
return unique
}
func mergeStarOccultationDiagramLabels(existing, incoming []string) []string {
for _, label := range incoming {
found := false
for _, current := range existing {
if current == label {
found = true
break
}
}
if !found {
existing = append(existing, label)
}
}
return existing
}
func starOccultationDiagramPrimaryLabel(labels []string) string {
for _, label := range labels {
if label == "Greatest" {
return label
}
}
if len(labels) == 0 {
return ""
}
return labels[0]
}
func starOccultationDiagramLabelPriority(labels []string) int {
if len(labels) == 0 {
return 99
}
switch labels[0] {
case "Immersion":
return 0
case "Greatest":
return 1
case "Emersion":
return 2
default:
return 99
}
}
func starOccultationDiagramFrameAt(tt float64, star StarCoordinate, observer Observer) (StarOccultationDiagramFrame, bool) {
position := starMoonPositionAt(tt, star, observer)
moonRadius := moonTopocentricSemidiameterN(tt, observer, -1)
if !position.valid || !finite(moonRadius) || moonRadius <= 0 {
return StarOccultationDiagramFrame{}, false
}
separation := angularSeparationDegrees(position.moonRA, position.moonDec, position.starRA, position.starDec) * 3600
positionAngle := occultationPositionAngle(position.moonRA, position.moonDec, position.starRA, position.starDec)
if !finite(separation) || !finite(positionAngle) {
return StarOccultationDiagramFrame{}, false
}
angle := positionAngle * math.Pi / 180
return StarOccultationDiagramFrame{
JDE: tt,
StarXArcsec: separation * math.Sin(angle),
StarYArcsec: separation * math.Cos(angle),
MoonRadiusArcsec: moonRadius,
SeparationArcsec: separation,
PositionAngleDeg: positionAngle,
MoonAltitudeDeg: occultationAltitude(tt, observer, position.moonRA, position.moonDec),
MoonAzimuthDeg: occultationAzimuth(tt, observer, position.moonRA, position.moonDec),
BehindMoon: separation < moonRadius-starOccultationGrazingTolerance,
}, true
}