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
+67 -54
View File
@@ -4,6 +4,8 @@ import (
"math"
"sort"
"time"
"b612.me/astro/internal/geodata"
)
const (
@@ -30,6 +32,8 @@ type planetOccultationFrameCacheEntry struct {
}
type planetOccultationEventCache struct {
inst *Instance
config planetOccultationConfig
states map[uint64]planetOccultationEphemerisState
outerFrames map[uint64]planetOccultationFrameCacheEntry
@@ -39,8 +43,10 @@ type planetOccultationEventCache struct {
local *planetOccultationLocalEphemeris
}
func newPlanetOccultationEventCache(config planetOccultationConfig) *planetOccultationEventCache {
cache := &planetOccultationEventCache{
func newPlanetOccultationEventCache(config planetOccultationConfig, instances ...*Instance) *planetOccultationEventCache {
inst := instanceValue(instances)
cache := &planetOccultationEventCache{inst: inst,
config: config,
states: make(map[uint64]planetOccultationEphemerisState),
outerFrames: make(map[uint64]planetOccultationFrameCacheEntry),
@@ -57,12 +63,9 @@ func newPlanetOccultationEventCache(config planetOccultationConfig) *planetOccul
return cache
}
// FindPlanetOccultationPaths 搜索有限盘面行星月掩的全球外接触和内接触掩带。
// 查询窗口按全球几何掩甚点选择事件;端点容差 10 ms 与数值根精度一致。求解成功时,每条路径扩展到完整全球起止点。
// FindPlanetOccultationPaths searches the global outer- and inner-contact footprints of one finite-disk planet.
// The query window selects events by global geometric greatest, with a 10 ms endpoint tolerance matching the numerical root precision. Each returned path expands to its complete global start and end when solved.
func FindPlanetOccultationPaths(start, end time.Time, planet OccultationPlanet,
options OccultationPathOptions) ([]PlanetOccultationPath, error) {
// FindPlanetOccultationPathsWithInstance 用指定星历实例求值;inst 为 nil 时用进程当前模型 / evaluation with an explicit instance; nil uses the process model.
func FindPlanetOccultationPathsWithInstance(start, end time.Time, planet OccultationPlanet,
options OccultationPathOptions, inst *Instance) ([]PlanetOccultationPath, error) {
if err := validateOccultationTimeRange(start, end); err != nil {
return nil, err
}
@@ -73,18 +76,18 @@ func FindPlanetOccultationPaths(start, end time.Time, planet OccultationPlanet,
return nil, err
}
config, _ := planetOccultationConfigFor(planet)
config, _ := planetOccultationConfigFor(planet, inst)
options = normalizeOccultationPathOptions(options)
startTT := occultationTimeToTT(start)
endTT := occultationTimeToTT(end)
candidateStartTT := startTT - occultationPathSearchSpanDays
candidateEndTT := endTT + occultationPathSearchSpanDays
candidates := planetOccultationCandidateGreatestTimes(
candidateStartTT, candidateEndTT, planetOccultationDefaultStepDays, config, nil, 0,
candidateStartTT, candidateEndTT, planetOccultationDefaultStepDays, config, nil, 0, inst,
)
paths := make([]PlanetOccultationPath, 0, len(candidates))
for _, seedTT := range candidates {
path, ok, err := planetOccultationPathAtSeed(seedTT, config, options, start, end)
path, ok, err := planetOccultationPathAtSeed(seedTT, config, options, start, end, inst)
if err != nil {
return nil, err
}
@@ -106,10 +109,11 @@ func planetOccultationPathAtSeed(
seedTT float64,
config planetOccultationConfig,
options OccultationPathOptions,
selectionStart, selectionEnd time.Time,
) (PlanetOccultationPath, bool, error) {
selectionStart, selectionEnd time.Time, instances ...*Instance) (PlanetOccultationPath, bool, error) {
inst := instanceValue(instances)
location := selectionStart.Location()
cache := newPlanetOccultationEventCache(config)
cache := newPlanetOccultationEventCache(config, inst)
cache.prepareLocalEphemeris(seedTT)
frameAt := cache.outerFrameAt
totalFrameAt := cache.totalFrameAt
@@ -182,14 +186,14 @@ func planetOccultationPathAtSeed(
return PlanetOccultationPath{}, false, ErrOccultationPathSamplingLimit
}
start := occultationPathBoundaryEndpointForFrame(outerStart, frameAt, location, 1)
end := occultationPathBoundaryEndpointForFrame(outerEnd, frameAt, location, -1)
start := occultationPathBoundaryEndpointForFrame(outerStart, frameAt, location, 1, inst)
end := occultationPathBoundaryEndpointForFrame(outerEnd, frameAt, location, -1, inst)
if !start.valid || !end.valid {
return PlanetOccultationPath{}, false, nil
}
exactFrameAt, exactTotalFrameAt := frameAt, totalFrameAt
if options.Algorithm != OccultationPathAlgorithmExact {
optimized := newPlanetOccultationEventCache(config)
optimized := newPlanetOccultationEventCache(config, inst)
optimized.preparePathEphemeris(seedTT, options.Algorithm)
if optimized.local.dense {
cache = optimized
@@ -197,7 +201,7 @@ func planetOccultationPathAtSeed(
}
}
centerLine, northern, southern, err := planetOccultationPathSamples(
outerStart, outerEnd, centerStart, centerEnd, hasCenter, greatestTT, frameAt, options, location,
outerStart, outerEnd, centerStart, centerEnd, hasCenter, greatestTT, frameAt, exactFrameAt, options, location,
)
if err != nil {
return PlanetOccultationPath{}, false, err
@@ -228,6 +232,10 @@ func planetOccultationPathAtSeed(
path.SouthernLimit = occultationStationCorrectLimitSeries(
path.SouthernLimit, frameAt, cache.riseSetContextAt, false, location,
)
var northApplied, southApplied bool
path.NorthernLimit, northApplied = occultationLimbCorrectLimitSeries(path.NorthernLimit, cache.riseSetContextAt, options.LunarLimb, location, inst)
path.SouthernLimit, southApplied = occultationLimbCorrectLimitSeries(path.SouthernLimit, cache.riseSetContextAt, options.LunarLimb, location, inst)
path.LunarLimbApplied = northApplied || southApplied
path.GreatestLimitSeparationKM, _ = occultationPathLimitSeparations(
path.NorthernLimit, path.SouthernLimit, greatestTT,
)
@@ -282,8 +290,8 @@ func planetOccultationPathAtSeed(
if !hasTotal {
return path, true, nil
}
totalStart := occultationPathBoundaryEndpointForFrame(totalStartTT, exactTotalFrameAt, location, 1)
totalEnd := occultationPathBoundaryEndpointForFrame(totalEndTT, exactTotalFrameAt, location, -1)
totalStart := occultationPathBoundaryEndpointForFrame(totalStartTT, exactTotalFrameAt, location, 1, inst)
totalEnd := occultationPathBoundaryEndpointForFrame(totalEndTT, exactTotalFrameAt, location, -1, inst)
_, _, totalWidth, totalWidthOK := occultationPathLimitsAndWidthForFrame(greatestTT, exactTotalFrameAt)
if !totalStart.valid || !totalEnd.valid || !totalWidthOK || totalWidth <= 0 {
return path, true, nil
@@ -464,14 +472,14 @@ func planetOccultationPathSamples(
centerStartTT, centerEndTT float64,
hasCenter bool,
greatestTT float64,
frameAt occultationPathFrameFunc,
frameAt, exactFrameAt occultationPathFrameFunc,
options OccultationPathOptions,
location *time.Location,
) ([]OccultationPathPoint, []OccultationPathPoint, []OccultationPathPoint, error) {
var centerLine []OccultationPathPoint
if hasCenter {
var err error
centerLine, err = occultationPathCenterSamplesForFrame(centerStartTT, centerEndTT, greatestTT, frameAt, options, location)
centerLine, err = occultationPathCenterSamplesForFrame(centerStartTT, centerEndTT, greatestTT, frameAt, exactFrameAt, options, location)
if err != nil {
return nil, nil, nil, err
}
@@ -613,11 +621,6 @@ func appendOccultationPathBoundaryPairSegment(
)
}
const (
occultationPathWebMercatorRadiusKM = 6378.1366
occultationPathWebMercatorMaxLat = 85.05112878
)
func occultationPathBoundaryPairWebMercatorSpacing(
first, second occultationPathBoundaryPairSample,
) float64 {
@@ -656,13 +659,10 @@ func occultationPathWebMercatorPointSpacing(
) float64 {
firstLongitude, firstLatitude := occultationPathGeodetic(firstTT, firstVector)
secondLongitude, secondLatitude := occultationPathGeodetic(secondTT, secondVector)
firstLatitude = math.Max(-occultationPathWebMercatorMaxLat, math.Min(occultationPathWebMercatorMaxLat, firstLatitude))
secondLatitude = math.Max(-occultationPathWebMercatorMaxLat, math.Min(occultationPathWebMercatorMaxLat, secondLatitude))
longitudeDelta := math.Remainder(secondLongitude-firstLongitude, 360) * math.Pi / 180
firstY := math.Log(math.Tan(math.Pi/4 + firstLatitude*math.Pi/360))
secondY := math.Log(math.Tan(math.Pi/4 + secondLatitude*math.Pi/360))
latitudeDelta := secondY - firstY
return occultationPathWebMercatorRadiusKM * math.Hypot(longitudeDelta, latitudeDelta)
return geodata.WebMercatorDistanceKM(
geodata.GeoPoint{Longitude: firstLongitude, Latitude: firstLatitude},
geodata.GeoPoint{Longitude: secondLongitude, Latitude: secondLatitude},
)
}
func occultationPathContourBoundaryPairAt(
@@ -1074,7 +1074,7 @@ func occultationPathOrientBoundarySamples(
func occultationPathCenterSamplesForFrame(
startTT, endTT, greatestTT float64,
frameAt occultationPathFrameFunc,
frameAt, exactFrameAt occultationPathFrameFunc,
options OccultationPathOptions,
location *time.Location,
) ([]OccultationPathPoint, error) {
@@ -1083,6 +1083,10 @@ func occultationPathCenterSamplesForFrame(
points := make([]OccultationPathPoint, 0, len(times))
for _, tt := range times {
point, ok := occultationPathCenterPointForFrame(tt, frameAt, location)
// 中心窗口端点由精确帧求得,插值帧在端点附近可能判无交点,此时回退精确帧。
if !ok && exactFrameAt != nil {
point, ok = occultationPathCenterPointForFrame(tt, exactFrameAt, location)
}
if ok && (len(points) == 0 || point.Time.After(points[len(points)-1].Time)) {
points = append(points, point)
}
@@ -1156,23 +1160,29 @@ func appendOccultationPathSegmentForFrame(
return appendOccultationPathSegmentForFrame(points, mid, end, frameAt, targetSpacingKM, location, depth+1)
}
func planetOccultationPathFrameAt(tt float64, config planetOccultationConfig) (occultationPathFrame, bool) {
return planetOccultationContactPathFrameAt(tt, config, false)
func planetOccultationPathFrameAt(tt float64, config planetOccultationConfig, instances ...*Instance) (occultationPathFrame, bool) {
inst := instanceValue(instances)
return planetOccultationContactPathFrameAt(tt, config, false, inst)
}
func planetOccultationTotalPathFrameAt(tt float64, config planetOccultationConfig) (occultationPathFrame, bool) {
return planetOccultationContactPathFrameAt(tt, config, true)
}
func planetOccultationContactPathFrameAt(tt float64, config planetOccultationConfig, total bool) (occultationPathFrame, bool) {
state := planetOccultationEphemerisStateAt(tt, config)
func planetOccultationContactPathFrameAt(tt float64, config planetOccultationConfig, total bool, instances ...*Instance) (occultationPathFrame, bool) {
inst := instanceValue(instances)
state := planetOccultationEphemerisStateAt(tt, config, inst)
return planetOccultationContactPathFrameFromState(state, config, total)
}
func planetOccultationEphemerisStateAt(tt float64, config planetOccultationConfig) planetOccultationEphemerisState {
moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, -1)
moonDistance := HMoonAwayN(tt, -1)
planetRA, planetDec, planetDistanceAU := planetOccultationApparentPositionAndDistanceN(tt, config, -1)
func planetOccultationEphemerisStateAt(tt float64, config planetOccultationConfig, instances ...*Instance) planetOccultationEphemerisState {
inst := instanceValue(instances)
moonRA, moonDec := HMoonGeocentricApparentRaDecNWithInstance(tt, -1, inst)
moonDistance := HMoonAwayNWithInstance(tt, -1, inst)
planetRA, planetDec, planetDistanceAU := planetOccultationApparentPositionAndDistanceN(tt, config, -1, inst)
planetDistance := planetDistanceAU * occultationPathAstronomicalUnitKM
return planetOccultationEphemerisState{
moonRA: moonRA, moonDec: moonDec, moonDistanceKM: moonDistance,
@@ -1185,9 +1195,10 @@ func planetOccultationEphemerisStateAt(tt float64, config planetOccultationConfi
func planetOccultationApparentPositionAndDistanceN(
tt float64,
config planetOccultationConfig,
n int,
) (float64, float64, float64) {
position, _, distanceAU := planetApparentGeocentricPositionAndDistanceN(config.planetIndex, tt, n)
n int, instances ...*Instance) (float64, float64, float64) {
inst := instanceValue(instances)
position, _, distanceAU := planetApparentGeocentricPositionAndDistanceN(config.planetIndex, tt, n, inst)
ra, dec := planetApparentRaDecFromLoBo(tt, position.lo, position.bo)
return ra, dec, distanceAU
}
@@ -1253,7 +1264,7 @@ func (cache *planetOccultationEventCache) stateAt(tt float64) planetOccultationE
state, interpolated = cache.local.stateAt(tt)
}
if !interpolated {
state = planetOccultationEphemerisStateAt(tt, cache.config)
state = planetOccultationEphemerisStateAt(tt, cache.config, cache.inst)
}
cache.states[key] = state
return state
@@ -1261,7 +1272,7 @@ func (cache *planetOccultationEventCache) stateAt(tt float64) planetOccultationE
func (cache *planetOccultationEventCache) prepareLocalEphemeris(center float64) {
if cache.local == nil {
cache.local = newPlanetOccultationLocalEphemeris(center, cache.config)
cache.local = newPlanetOccultationLocalEphemeris(center, cache.config, cache.inst)
}
}
@@ -1353,8 +1364,9 @@ func occultationPathBoundaryEndpointForFrame(
tt float64,
frameAt occultationPathFrameFunc,
location *time.Location,
direction int,
) occultationPathEndpoint {
direction int, instances ...*Instance) occultationPathEndpoint {
inst := instanceValue(instances)
if _, ok := frameAt(tt); !ok {
return occultationPathEndpoint{}
}
@@ -1366,7 +1378,7 @@ func occultationPathBoundaryEndpointForFrame(
}
vector, _, valid := occultationPathBoundaryTangent(frame)
if valid {
return occultationPathEndpoint{point: occultationPathPointFromVector(candidateTT, vector, 0, location), valid: true}
return occultationPathEndpoint{point: occultationPathPointFromVector(candidateTT, vector, 0, location, inst), valid: true}
}
}
return occultationPathEndpoint{}
@@ -1411,8 +1423,9 @@ func occultationPathCenterPointForFrameWithoutWidth(
func occultationPathBoundaryPointForFrame(
tt float64,
frameAt occultationPathFrameFunc,
location *time.Location,
) (OccultationPathPoint, bool) {
location *time.Location, instances ...*Instance) (OccultationPathPoint, bool) {
inst := instanceValue(instances)
frame, ok := frameAt(tt)
if !ok {
return OccultationPathPoint{}, false
@@ -1422,7 +1435,7 @@ func occultationPathBoundaryPointForFrame(
return OccultationPathPoint{}, false
}
north, south, limitsOK := occultationPathScannedLimitsAtFrame(tt, frame)
return occultationPathPointFromVector(tt, point, occultationPathBoundaryWidth(north, south, limitsOK), location), true
return occultationPathPointFromVector(tt, point, occultationPathBoundaryWidth(north, south, limitsOK), location, inst), true
}
func occultationPathTrackPointForFrame(