Files
astro/basic/occultation_planet_diagram.go
T
b612 9ee2163cc7 feat: 新增月掩与日月食地理绘图并提升观测计算精度
- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算
- 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出
- 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口
- 扩展日食中心线、南北界及偏食足迹采样,支持极区投影
- 修正站心时角、月出月落、月球视半径、折射和恒星自行计算
- 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
2026-08-06 12:00:56 +08:00

322 lines
14 KiB
Go

package basic
import (
"math"
"sort"
)
const (
planetOccultationDiagramDefaultStepDays = 2.0 / 1440.0
planetOccultationDiagramMinStepDays = 1.0 / 86400.0
planetOccultationDiagramMaxSamples = 2000
planetOccultationDiagramDuplicateDays = 1e-10
planetOccultationDiagramGeometryArcsec = 0.05
planetOccultationDiagramPositionDeg = 0.01
planetOccultationDiagramContactTimeDays = 1e-6
)
// PlanetOccultationDiagramOptions 控制本地行星月掩轨迹采样。
// PlanetOccultationDiagramOptions controls local planetary-occultation track sampling.
type PlanetOccultationDiagramOptions 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
}
// PlanetOccultationDiagramFrame 描述一个时刻的站心月球与行星几何。
// PlanetOccultationDiagramFrame describes topocentric Moon-planet geometry at one instant.
type PlanetOccultationDiagramFrame struct {
// JDE 是 TT 儒略历书日。
// JDE is the TT Julian ephemeris day.
JDE float64
// PlanetXArcsec 和 PlanetYArcsec 是相对月心的切平面偏移,单位为角秒。X 向东为正,Y 向北为正。
// PlanetXArcsec and PlanetYArcsec are tangent-plane offsets from the lunar center. X is positive east and Y is positive north.
PlanetXArcsec float64
PlanetYArcsec float64
// MoonRadiusArcsec 和 PlanetRadiusArcsec 是站心视半径,单位为角秒。
// MoonRadiusArcsec and PlanetRadiusArcsec are topocentric apparent semidiameters.
MoonRadiusArcsec float64
PlanetRadiusArcsec float64
// SeparationArcsec 和 PositionAngleDeg 描述行星中心相对月心的位置。
// SeparationArcsec and PositionAngleDeg describe the planet center relative to the lunar center.
SeparationArcsec float64
PositionAngleDeg float64
// MoonAltitudeDeg 和 MoonAzimuthDeg 是站心地平坐标。
// MoonAltitudeDeg and MoonAzimuthDeg are topocentric horizontal coordinates.
MoonAltitudeDeg float64
MoonAzimuthDeg float64
// DisksOverlap 表示两个视盘面存在正面积交集。
// DisksOverlap is true while the two apparent disks have a positive-area intersection.
DisksOverlap bool
// FullyOcculted 表示行星盘面完全位于月缘内侧。
// FullyOcculted is true while the planet disk lies strictly inside the lunar limb.
FullyOcculted bool
// Label 是主阶段标识;Labels 在掠掩事件中保留重合阶段。
// Label is the primary key phase; Labels retains coincident phases for grazing events.
Label string
Labels []string
}
// PlanetOccultationDiagramResult 包含固定地点行星月掩的几何数据。
// PlanetOccultationDiagramResult contains geometry for a fixed-site planetary occultation.
type PlanetOccultationDiagramResult struct {
Occultation PlanetOccultationInfo
Frames []PlanetOccultationDiagramFrame
// StepDays 是实际采用的基础轨迹采样步长,单位为日。
// StepDays is the effective base-track sampling step in days.
StepDays float64
}
type planetOccultationDiagramTime struct {
jde float64
labels []string
}
// PlanetOccultationDiagram 为已求解的固定地点行星月掩计算以月心为原点的切平面轨迹。事件数据无效或不完整时,结果不含帧。
// PlanetOccultationDiagram computes a Moon-centered tangent-plane track for an already solved fixed-site planetary occultation. Invalid or incomplete event data produces a result without frames.
func PlanetOccultationDiagram(
info PlanetOccultationInfo,
options PlanetOccultationDiagramOptions,
) PlanetOccultationDiagramResult {
options = normalizePlanetOccultationDiagramOptions(options)
result := PlanetOccultationDiagramResult{Occultation: info, StepDays: options.StepDays}
if !planetOccultationDiagramInputValid(info) {
return result
}
config, ok := planetOccultationConfigFor(info.Planet)
if !ok {
return result
}
startTT := occultationTimeToTT(info.ExternalImmersion)
greatestTT := occultationTimeToTT(info.Greatest)
endTT := occultationTimeToTT(info.ExternalEmersion)
externalImmersionFrame, externalImmersionOK := planetOccultationDiagramFrameAt(startTT, config, info.Observer)
greatestFrame, greatestOK := planetOccultationDiagramFrameAt(greatestTT, config, info.Observer)
externalEmersionFrame, externalEmersionOK := planetOccultationDiagramFrameAt(endTT, config, info.Observer)
if !externalImmersionOK || !greatestOK || !externalEmersionOK {
return result
}
var (
internalImmersionFrame, internalEmersionFrame PlanetOccultationDiagramFrame
internalImmersionOK, internalEmersionOK bool
)
if info.HasInternalContacts {
internalImmersionFrame, internalImmersionOK = planetOccultationDiagramFrameAt(
occultationTimeToTT(info.InternalImmersion), config, info.Observer,
)
internalEmersionFrame, internalEmersionOK = planetOccultationDiagramFrameAt(
occultationTimeToTT(info.InternalEmersion), config, info.Observer,
)
}
if (info.HasInternalContacts && (!internalImmersionOK || !internalEmersionOK)) ||
!planetOccultationDiagramMatchesInfo(
info, startTT, greatestTT, endTT,
externalImmersionFrame, internalImmersionFrame, greatestFrame, internalEmersionFrame, externalEmersionFrame,
) {
return result
}
times, stepDays := planetOccultationDiagramTimes(info, startTT, greatestTT, endTT, options.StepDays)
result.StepDays = stepDays
result.Frames = make([]PlanetOccultationDiagramFrame, 0, len(times))
for _, item := range times {
frame, frameOK := planetOccultationDiagramFrameAt(item.jde, config, info.Observer)
if !frameOK {
return PlanetOccultationDiagramResult{Occultation: info, StepDays: stepDays}
}
frame.Labels = append([]string(nil), item.labels...)
frame.Label = planetOccultationDiagramPrimaryLabel(item.labels)
result.Frames = append(result.Frames, frame)
}
return result
}
func planetOccultationDiagramMatchesInfo(
info PlanetOccultationInfo,
startTT, greatestTT, endTT float64,
externalImmersion, internalImmersion, greatest, internalEmersion, externalEmersion PlanetOccultationDiagramFrame,
) bool {
if !finite(info.MinimumSeparationArcsec) || info.MinimumSeparationArcsec < 0 ||
!finite(info.PositionAngleDeg) ||
!finite(info.MoonSemidiameterArcsec) || info.MoonSemidiameterArcsec <= 0 ||
!finite(info.PlanetSemidiameterArcsec) || info.PlanetSemidiameterArcsec <= 0 {
return false
}
if math.Abs(externalImmersion.SeparationArcsec-externalImmersion.MoonRadiusArcsec-externalImmersion.PlanetRadiusArcsec) > planetOccultationDiagramGeometryArcsec ||
math.Abs(externalEmersion.SeparationArcsec-externalEmersion.MoonRadiusArcsec-externalEmersion.PlanetRadiusArcsec) > planetOccultationDiagramGeometryArcsec {
return false
}
if math.Abs(greatest.SeparationArcsec-info.MinimumSeparationArcsec) > planetOccultationDiagramGeometryArcsec ||
math.Abs(greatest.MoonRadiusArcsec-info.MoonSemidiameterArcsec) > planetOccultationDiagramGeometryArcsec ||
math.Abs(greatest.PlanetRadiusArcsec-info.PlanetSemidiameterArcsec) > planetOccultationDiagramGeometryArcsec ||
math.Abs(signedAngleDifference(greatest.PositionAngleDeg, info.PositionAngleDeg)) > planetOccultationDiagramPositionDeg {
return false
}
externalMetric := greatest.SeparationArcsec - greatest.MoonRadiusArcsec - greatest.PlanetRadiusArcsec
internalMetric := greatest.SeparationArcsec - greatest.MoonRadiusArcsec + greatest.PlanetRadiusArcsec
switch info.Type {
case OccultationPartial:
return externalMetric < -planetOccultationGrazingToleranceArcsec &&
internalMetric >= -planetOccultationGrazingToleranceArcsec
case OccultationGrazing:
return math.Abs(externalMetric) <= planetOccultationGrazingToleranceArcsec &&
math.Abs(startTT-greatestTT) <= planetOccultationDiagramContactTimeDays &&
math.Abs(endTT-greatestTT) <= planetOccultationDiagramContactTimeDays
case OccultationTotal:
if math.Abs(internalImmersion.SeparationArcsec-internalImmersion.MoonRadiusArcsec+internalImmersion.PlanetRadiusArcsec) > planetOccultationDiagramGeometryArcsec ||
math.Abs(internalEmersion.SeparationArcsec-internalEmersion.MoonRadiusArcsec+internalEmersion.PlanetRadiusArcsec) > planetOccultationDiagramGeometryArcsec {
return false
}
return externalMetric < -planetOccultationGrazingToleranceArcsec &&
internalMetric < -planetOccultationGrazingToleranceArcsec
default:
return false
}
}
func normalizePlanetOccultationDiagramOptions(options PlanetOccultationDiagramOptions) PlanetOccultationDiagramOptions {
if options.StepDays <= 0 || !finite(options.StepDays) {
options.StepDays = planetOccultationDiagramDefaultStepDays
}
if options.StepDays < planetOccultationDiagramMinStepDays {
options.StepDays = planetOccultationDiagramMinStepDays
}
return options
}
func planetOccultationDiagramInputValid(info PlanetOccultationInfo) bool {
if info.Planet.Validate() != nil || info.Observer.Validate() != nil || !info.ContactsComplete ||
info.ExternalImmersion.IsZero() || info.Greatest.IsZero() || info.ExternalEmersion.IsZero() ||
info.Greatest.Before(info.ExternalImmersion) || info.ExternalEmersion.Before(info.Greatest) {
return false
}
switch info.Type {
case OccultationTotal:
return info.HasInternalContacts && !info.InternalImmersion.IsZero() && !info.InternalEmersion.IsZero() &&
info.InternalImmersion.After(info.ExternalImmersion) && info.InternalImmersion.Before(info.Greatest) &&
info.InternalEmersion.After(info.Greatest) && info.InternalEmersion.Before(info.ExternalEmersion)
case OccultationPartial:
return !info.HasInternalContacts && info.InternalImmersion.IsZero() && info.InternalEmersion.IsZero()
case OccultationGrazing:
return !info.HasInternalContacts && info.InternalImmersion.IsZero() && info.InternalEmersion.IsZero()
default:
return false
}
}
func planetOccultationDiagramTimes(
info PlanetOccultationInfo,
startTT, greatestTT, endTT, stepDays float64,
) ([]planetOccultationDiagramTime, 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 > planetOccultationDiagramMaxSamples {
stepDays = (endTT - startTT) / float64(planetOccultationDiagramMaxSamples-1)
}
}
times := []planetOccultationDiagramTime{
{jde: startTT, labels: []string{"C1"}},
{jde: greatestTT, labels: []string{"Greatest"}},
{jde: endTT, labels: []string{"C4"}},
}
if info.HasInternalContacts {
times = append(times,
planetOccultationDiagramTime{jde: occultationTimeToTT(info.InternalImmersion), labels: []string{"C2"}},
planetOccultationDiagramTime{jde: occultationTimeToTT(info.InternalEmersion), labels: []string{"C3"}},
)
}
for jde := startTT + stepDays; jde < endTT; jde += stepDays {
times = append(times, planetOccultationDiagramTime{jde: jde})
}
sort.SliceStable(times, func(i, j int) bool {
if times[i].jde == times[j].jde {
return planetOccultationDiagramLabelPriority(times[i].labels) < planetOccultationDiagramLabelPriority(times[j].labels)
}
return times[i].jde < times[j].jde
})
return uniquePlanetOccultationDiagramTimes(times), stepDays
}
func uniquePlanetOccultationDiagramTimes(times []planetOccultationDiagramTime) []planetOccultationDiagramTime {
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) > planetOccultationDiagramDuplicateDays {
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 planetOccultationDiagramPrimaryLabel(labels []string) string {
for _, label := range labels {
if label == "Greatest" {
return label
}
}
if len(labels) == 0 {
return ""
}
return labels[0]
}
func planetOccultationDiagramLabelPriority(labels []string) int {
if len(labels) == 0 {
return 99
}
switch labels[0] {
case "C1":
return 0
case "C2":
return 1
case "Greatest":
return 2
case "C3":
return 3
case "C4":
return 4
default:
return 99
}
}
func planetOccultationDiagramFrameAt(
tt float64,
config planetOccultationConfig,
observer Observer,
) (PlanetOccultationDiagramFrame, bool) {
state := planetOccultationStateAt(tt, config, &observer, -1)
if !state.valid {
return PlanetOccultationDiagramFrame{}, false
}
positionAngle := occultationPositionAngle(
state.position.moonRA, state.position.moonDec,
state.position.planetRA, state.position.planetDec,
)
if !finite(positionAngle) {
return PlanetOccultationDiagramFrame{}, false
}
angle := positionAngle * math.Pi / 180
return PlanetOccultationDiagramFrame{
JDE: tt,
PlanetXArcsec: state.separationArcsec * math.Sin(angle),
PlanetYArcsec: state.separationArcsec * math.Cos(angle),
MoonRadiusArcsec: state.moonSemidiameter,
PlanetRadiusArcsec: state.planetSemidiameter,
SeparationArcsec: state.separationArcsec,
PositionAngleDeg: positionAngle,
MoonAltitudeDeg: occultationAltitude(tt, observer, state.position.moonRA, state.position.moonDec),
MoonAzimuthDeg: occultationAzimuth(tt, observer, state.position.moonRA, state.position.moonDec),
DisksOverlap: state.externalContactMetric < -planetOccultationGrazingToleranceArcsec,
FullyOcculted: state.internalContactMetric < -planetOccultationGrazingToleranceArcsec,
}, true
}