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

273 lines
11 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 (
"math"
"sort"
)
const (
schaeferNightSkyNanolambert = 180.0
schaeferNanolambertToErg = 1.02e-15
schaeferSunMagnitude = -26.74
schaeferZeroMagnitudeRef = -11.05
schaeferMinSeparationDeg = 0.1
schaeferTwilightLowSunDeg = -3.0
schaeferDaylightHighSunDeg = 4.0
schaeferScotopicLimitNL = 1479.1
)
// HeliacalOptions 偕日可见计算参数;零值使用消光系数 0.25 星等/大气质量、目标最低高度 0.5° / heliacal visibility options.
type HeliacalOptions struct {
// ExtinctionCoefficient 大气消光系数(星等/大气质量)/ extinction in magnitudes per airmass.
ExtinctionCoefficient float64
// MinTargetAltitude 目标最低几何高度(度)/ minimum geometric target altitude in degrees.
MinTargetAltitude float64
// Moonlight 为 true 时计入月光散射(Krisciunas–Schaefer);默认不计 / include scattered moonlight when true.
Moonlight bool
}
func (options HeliacalOptions) normalized() HeliacalOptions {
if !(options.ExtinctionCoefficient > 0) {
options.ExtinctionCoefficient = 0.25
}
if !(options.MinTargetAltitude > 0) {
options.MinTargetAltitude = 0.5
}
return options
}
func schaeferAirmass(altitudeDeg float64) float64 {
z := math.Min(90, math.Max(0, 90-altitudeDeg)) * rad
c := math.Cos(z)
return 1 / (c + 0.025*math.Exp(-11*c))
}
func schaeferTransmission(k, airmass float64) float64 { return math.Pow(10, -0.4*k*airmass) }
func schaeferPhase(separationDeg float64) float64 {
s := math.Max(schaeferMinSeparationDeg, separationDeg)
c := math.Cos(s * rad)
return 6.2e7/(s*s) + math.Pow(10, 6.15-s/40) + math.Pow(10, 5.36)*(1.06+c*c)
}
func validExtinction(extinction float64) bool {
return extinction > 0 && !math.IsInf(extinction, 0)
}
// SkyBrightnessNanolambert 无月夜晨昏天光亮度(nL,Schaefer 模型),消光系数须为正,否则返回 NaN / twilight sky brightness without moonlight, Schaefer model.
func SkyBrightnessNanolambert(targetAltitude, sunAltitude, separation, extinction float64) float64 {
if !validExtinction(extinction) {
return math.NaN()
}
k := extinction
targetT := schaeferTransmission(k, schaeferAirmass(targetAltitude))
sunT := schaeferTransmission(k, schaeferAirmass(sunAltitude))
targetZ := math.Min(90, math.Max(0, 90-targetAltitude))
sunZ := math.Min(180, math.Max(0, 90-sunAltitude))
sep := math.Max(schaeferMinSeparationDeg, separation)
scale := math.Pow(10, -0.4*(schaeferSunMagnitude-schaeferZeroMagnitudeRef+43.27))
day := scale * (1 - targetT) * (schaeferPhase(sep)*sunT + 440000*(1-sunT))
twilight := math.Pow(10, -0.4*(schaeferSunMagnitude-schaeferZeroMagnitudeRef-57.5+sunZ-targetZ/(360*k))) *
(1 - targetT) * 100 / sep
sky := math.Min(day, twilight)
switch {
case sunAltitude < schaeferTwilightLowSunDeg:
sky = twilight
case sunAltitude > schaeferDaylightHighSunDeg:
sky = day
}
sz := math.Sin(targetZ * rad)
night := schaeferNightSkyNanolambert * (0.4 + 0.6/math.Sqrt(1-0.96*sz*sz)) * targetT
return sky/schaeferNanolambertToErg + night
}
// LimitingMagnitude 肉眼极限星等(已扣除目标方向的大气消光),消光系数须为正,否则返回 NaN / naked-eye limiting magnitude after extinction at the target altitude.
func LimitingMagnitude(targetAltitude, sunAltitude, separation, extinction float64) float64 {
return limitingMagnitudeForSky(SkyBrightnessNanolambert(targetAltitude, sunAltitude, separation, extinction), targetAltitude, extinction)
}
// MoonlightNanolambert 月光在目标方向造成的天光亮度(nL,Krisciunas–Schaefer);月在地平下为 0,消光系数非正为 NaN /
// sky brightness from scattered moonlight; zero when the Moon is below the horizon.
func MoonlightNanolambert(targetAltitude, moonAltitude, moonSeparation, moonPhaseAngle, extinction float64) float64 {
if !validExtinction(extinction) {
return math.NaN()
}
if moonAltitude <= 0 || targetAltitude <= 0 {
return 0
}
alpha := math.Abs(moonPhaseAngle)
mag := -12.73 + 0.026*alpha + 4e-9*math.Pow(alpha, 4)
illuminance := math.Pow(10, -0.4*(mag+16.57))
rho := math.Max(schaeferMinSeparationDeg, moonSeparation)
c := math.Cos(rho * rad)
scatter := math.Pow(10, 5.36)*(1.06+c*c) + math.Pow(10, 6.15-rho/40)
airmass := func(alt float64) float64 {
s := math.Sin((90 - alt) * rad)
return 1 / math.Sqrt(1-0.96*s*s)
}
return scatter * illuminance * math.Pow(10, -0.4*extinction*airmass(moonAltitude)) *
(1 - math.Pow(10, -0.4*extinction*airmass(targetAltitude)))
}
func limitingMagnitudeForSky(b, targetAltitude, extinction float64) float64 {
c, k := 4.4668359215096e-9, 1.2589254117942e-6
if b < schaeferScotopicLimitNL {
c, k = 1.5848931924611e-10, 0.012589254117942
}
threshold := c * math.Pow(1+math.Sqrt(k*b), 2)
return -16.57 - 2.5*math.Log10(threshold) - extinction*schaeferAirmass(targetAltitude)
}
// HeliacalEventKind 偕日事件类型 / heliacal event kind.
type HeliacalEventKind int
const (
// HeliacalMorningFirst 晨始见(偕日升)/ morning first visibility.
HeliacalMorningFirst HeliacalEventKind = iota
// HeliacalMorningLast 晨末见 / morning last visibility.
HeliacalMorningLast
// HeliacalEveningFirst 昏始见 / evening first visibility.
HeliacalEveningFirst
// HeliacalEveningLast 昏末见(偕日落)/ evening last visibility.
HeliacalEveningLast
)
// HeliacalEvent 偕日事件:JD 为当天最佳可见时刻(UT 儒略日),Margin 为极限星等减目标星等 / heliacal event at the best moment of that twilight.
type HeliacalEvent struct {
Kind HeliacalEventKind
JD float64
Margin float64
TargetAltitude float64
SunAltitude float64
}
// heliacalTarget 返回 TT 时刻目标的视赤经、赤纬(度)与视星等。
type heliacalTarget func(jde float64) (ra, dec, magnitude float64)
type heliacalBest struct {
jd, margin, targetAlt, sunAlt float64
visible bool
}
func heliacalAltitude(ra, dec, lst, lat float64) float64 {
h := (lst - ra) * rad
d, p := dec*rad, lat*rad
return math.Asin(math.Sin(p)*math.Sin(d)+math.Cos(p)*math.Cos(d)*math.Cos(h)) / rad
}
// heliacalTwilight 扫描一次晨(morning)或昏的太阳高度 −18°..0° 时段,取可见余量最大的样本。
// 日月位置在窗口中点取一次,窗口不足两小时,引入的高度误差远小于模型本身。
func heliacalTwilight(dayJD, lon, lat float64, morning bool, target heliacalTarget, options HeliacalOptions, inst *Instance) heliacalBest {
center := dayJD - lon/360 + 0.5
if morning {
center -= 0.25
} else {
center += 0.25
}
jde := UTC2TT(center)
sunRA, sunDec := HSunApparentRaDecWithInstance(jde, inst)
ra, dec, mag := target(jde)
sep := skyAngularDistance(ra, dec, sunRA, sunDec)
best := heliacalBest{margin: math.Inf(-1)}
if math.IsNaN(ra) || math.IsNaN(mag) {
return best
}
var moonRA, moonDec, moonSep, moonPhase float64
if options.Moonlight {
lo, bo := SkyBodyApparentLoBoWithInstance(SkyMoon, jde, inst)
moonRA, moonDec = LoBoToRaDec(jde, lo, bo)
moonSep = skyAngularDistance(ra, dec, moonRA, moonDec)
moonPhase = 180 - skyAngularDistance(moonRA, moonDec, sunRA, sunDec)
}
lst0 := ApparentSiderealTime(UTC2UT1(center))*15 + lon
const step = 2.0 / 1440
for dt := -0.25; dt <= 0.25; dt += step {
lst := lst0 + dt*360.98564736629
sunAlt := heliacalAltitude(sunRA, sunDec, lst, lat)
if sunAlt > 0 || sunAlt < -18 {
continue
}
alt := heliacalAltitude(ra, dec, lst, lat)
if alt < options.MinTargetAltitude {
continue
}
sky := SkyBrightnessNanolambert(alt, sunAlt, sep, options.ExtinctionCoefficient)
if options.Moonlight {
sky += MoonlightNanolambert(alt, heliacalAltitude(moonRA, moonDec, lst, lat), moonSep, moonPhase, options.ExtinctionCoefficient)
}
margin := limitingMagnitudeForSky(sky, alt, options.ExtinctionCoefficient) - mag
if margin > best.margin {
best = heliacalBest{center + dt, margin, alt, sunAlt, margin >= 0}
}
}
return best
}
// heliacalMaxDays 逐日扫描的天数上限(约 2.3 万年),挡住非法跨度造成的近乎无界扫描。
const heliacalMaxDays = 1 << 23
// findHeliacalEvents 逐日扫描晨、昏可见性,从不可见变为可见记“始见”,反之记“末见”(取末个可见日)。
func findHeliacalEvents(startJD, endJD, lon, lat float64, target heliacalTarget, options HeliacalOptions, inst *Instance) []HeliacalEvent {
options = options.normalized()
if !finite(startJD) || !finite(endJD) || !(endJD > startJD) || math.Abs(lat) >= 90 {
return nil
}
day0 := math.Floor(startJD-0.5) + 0.5
span := math.Floor(endJD - day0)
if !(span < heliacalMaxDays) {
return nil
}
days := int(span)
var out []HeliacalEvent
for _, morning := range []bool{true, false} {
first, last := HeliacalEveningFirst, HeliacalEveningLast
if morning {
first, last = HeliacalMorningFirst, HeliacalMorningLast
}
prev := heliacalTwilight(day0-1, lon, lat, morning, target, options, inst)
for i := 0; i <= days; i++ {
cur := heliacalTwilight(day0+float64(i), lon, lat, morning, target, options, inst)
// 事件时刻是采样日内的细化值,可能越过 endJD,所以上界按事件时刻判断,不能按采样日判断。
switch {
case cur.visible && !prev.visible && cur.jd >= startJD && cur.jd <= endJD:
out = append(out, HeliacalEvent{first, cur.jd, cur.margin, cur.targetAlt, cur.sunAlt})
case !cur.visible && prev.visible && prev.jd >= startJD && prev.jd <= endJD:
out = append(out, HeliacalEvent{last, prev.jd, prev.margin, prev.targetAlt, prev.sunAlt})
}
prev = cur
}
}
sortHeliacalEvents(out)
return out
}
func sortHeliacalEvents(events []HeliacalEvent) {
sort.SliceStable(events, func(i, j int) bool { return events[i].JD < events[j].JD })
}
// FindPlanetHeliacalEventsWithInstance 行星偕日事件(UT 儒略日范围,经度东正)/ planet heliacal events in a UT JD range.
func FindPlanetHeliacalEventsWithInstance(body SkyBody, startJD, endJD, lon, lat float64, options HeliacalOptions, inst *Instance) []HeliacalEvent {
if body < SkyMercury || body > SkyNeptune {
return nil
}
index := int(body-SkyMercury) + 1
target := func(jde float64) (float64, float64, float64) {
lo, bo := SkyBodyApparentLoBoWithInstance(body, jde, inst)
ra, dec := LoBoToRaDec(jde, lo, bo)
return ra, dec, planetMagnitude2018(index, jde, inst)
}
return findHeliacalEvents(startJD, endJD, lon, lat, target, options, inst)
}
// FindStarHeliacalEventsWithInstance 恒星偕日事件;magnitude 为恒星 V 星等 / star heliacal events with the star's V magnitude.
func FindStarHeliacalEventsWithInstance(star StarCoordinate, magnitude, startJD, endJD, lon, lat float64, options HeliacalOptions, inst *Instance) []HeliacalEvent {
if math.IsNaN(magnitude) || math.IsInf(magnitude, 0) {
return nil
}
target := func(jde float64) (float64, float64, float64) {
ra, dec := starApparentRaDecGeocentric(jde, star, inst)
return ra, dec, magnitude
}
return findHeliacalEvents(startJD, endJD, lon, lat, target, options, inst)
}