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
+50
View File
@@ -0,0 +1,50 @@
package basic
import (
"math"
"testing"
"time"
)
const ancientStationTolerance = 10.0 / 1440.0
func ancientStationTT(year int, month time.Month, day int) float64 {
return TD2UT(Date2JDE(time.Date(year, month, day, 0, 0, 0, 0, time.UTC)), true)
}
func ancientStationUT(year int, month time.Month, day, hour, minute int) float64 {
return Date2JDE(time.Date(year, month, day, hour, minute, 0, 0, time.UTC))
}
func assertAncientNextStation(t *testing.T, queryTT, gotUT, wantUT float64) {
t.Helper()
if !eventUTQueryAfterOrEqual(gotUT, queryTT) {
t.Fatalf("station is before query: query TT %.9f, got UT %.9f", queryTT, gotUT)
}
if diff := math.Abs(gotUT - wantUT); diff > ancientStationTolerance {
t.Fatalf("station differs by %.3f minutes: got %s, want %s", diff*1440,
JDE2DateByZone(gotUT, time.UTC, false), JDE2DateByZone(wantUT, time.UTC, false))
}
}
func TestMarsAncientNextStationsStayOnTheirOppositionSides(t *testing.T) {
queryTT := ancientStationTT(-210, time.April, 1)
p2r := NextMarsProgradeToRetrograde(queryTT)
r2p := NextMarsRetrogradeToPrograde(queryTT)
assertAncientNextStation(t, queryTT, p2r, ancientStationUT(-209, time.March, 25, 20, 20))
assertAncientNextStation(t, queryTT, r2p, ancientStationUT(-209, time.June, 6, 11, 4))
if sameEventJD(p2r, r2p) {
t.Fatalf("typed Mars stations collapsed to one event: %.9f", p2r)
}
}
func TestMercuryAncientNextStationsDoNotSkipOrLoop(t *testing.T) {
janQueryTT := ancientStationTT(-210, time.January, 1)
assertAncientNextStation(t, janQueryTT, NextMercuryProgradeToRetrograde(janQueryTT), ancientStationUT(-210, time.February, 6, 21, 29))
assertAncientNextStation(t, janQueryTT, NextMercuryRetrogradeToPrograde(janQueryTT), ancientStationUT(-210, time.March, 1, 14, 20))
marQueryTT := ancientStationTT(-210, time.March, 1)
assertAncientNextStation(t, marQueryTT, NextMercuryProgradeToRetrograde(marQueryTT), ancientStationUT(-210, time.June, 11, 18, 50))
assertAncientNextStation(t, marQueryTT, NextMercuryRetrogradeToPrograde(marQueryTT), ancientStationUT(-210, time.March, 1, 14, 20))
}
+7 -17
View File
@@ -84,7 +84,7 @@ func TopocentricRaDec(ra, dec, lat, lon, jd, au, h float64) (float64, float64) {
sinpi := Sin(0.0024427777777) / au
pcosi := pcosi(lat, h)
psini := psini(lat, h)
tH := Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + lon - ra)
tH := Limit360(ApparentSiderealTime(jd)*15 + lon - ra)
nra := math.Atan2(-pcosi*sinpi*Sin(tH), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
ndec := math.Atan2((Sin(dec)-psini*sinpi)*Cos(nra), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
@@ -92,22 +92,12 @@ func TopocentricRaDec(ra, dec, lat, lon, jd, au, h float64) (float64, float64) {
}
func TopocentricRa(ra, dec, lat, lon, jd, au, h float64) float64 { //jd为格林尼治标准时
sinpi := Sin(0.0024427777777) / au
pcosi := pcosi(lat, h)
tH := Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + lon - ra)
nra := math.Atan2(-pcosi*sinpi*Sin(tH), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
return ra + nra
topocentricRA, _ := TopocentricRaDec(ra, dec, lat, lon, jd, au, h)
return topocentricRA
}
func TopocentricDec(ra, dec, lat, lon, jd, au, h float64) float64 { //jd为格林尼治标准时
sinpi := Sin(0.0024427777777) / au
pcosi := pcosi(lat, h)
psini := psini(lat, h)
tH := Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + lon - ra)
nra := math.Atan2(-pcosi*sinpi*Sin(tH), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
ndec := math.Atan2((Sin(dec)-psini*sinpi)*Cos(nra), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
return ndec
_, topocentricDec := TopocentricRaDec(ra, dec, lat, lon, jd, au, h)
return topocentricDec
}
func TopocentricLo(lo, bo, lat, lon, jd, au, h float64) float64 { //jd为格林尼治标准时
@@ -115,7 +105,7 @@ func TopocentricLo(lo, bo, lat, lon, jd, au, h float64) float64 { //jd为格林
s := psini(lat, h)
sinpi := Sin(0.0024427777777) / au
ra := LoToRa(jd, lo, bo)
tH := Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + lon - ra)
tH := Limit360(ApparentSiderealTime(jd)*15 + lon - ra)
n := Cos(lo)*Cos(bo) - c*sinpi*Cos(tH)
nlo := math.Atan2(Sin(lo)*Cos(bo)-sinpi*(s*Sin(TrueObliquity(jd))+c*Cos(TrueObliquity(jd))*Sin(tH)), n) * 180 / math.Pi
return nlo
@@ -126,7 +116,7 @@ func TopocentricBo(lo, bo, lat, lon, jd, au, h float64) float64 { //jd为格林
s := psini(lat, h)
sinpi := Sin(0.0024427777777) / au
ra := LoToRa(jd, lo, bo)
tH := Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + lon - ra)
tH := Limit360(ApparentSiderealTime(jd)*15 + lon - ra)
n := Cos(lo)*Cos(bo) - c*sinpi*Cos(tH)
nlo := math.Atan2(Sin(lo)*Cos(bo)-sinpi*(s*Sin(TrueObliquity(jd))+c*Cos(TrueObliquity(jd))*Sin(tH)), n) * 180 / math.Pi
nbo := math.Atan2(Cos(nlo)*(Sin(bo)-sinpi*(s*Cos(TrueObliquity(jd))-c*Sin(TrueObliquity(jd))*Sin(tH))), n) * 180 / math.Pi
+81
View File
@@ -0,0 +1,81 @@
package basic
import (
"math"
"testing"
"time"
. "b612.me/astro/tools"
)
func TestTopocentricRaDecUsesUTJulianDateForSiderealTime(t *testing.T) {
ut := Date2JDE(time.Date(2025, 6, 5, 12, 2, 7, 700000000, time.UTC))
ra := 189.527817246
dec := -5.973400893
lat := 6.79657
lon := 121.55381
distanceAU := HMoonAwayN(TD2UT(ut, true), -1) / 149597870.7
gotRA, gotDec := TopocentricRaDec(ra, dec, lat, lon, ut, distanceAU, 0)
wantRA, wantDec := independentTopocentricRaDec(ra, dec, lat, lon, ut, distanceAU, 0)
if delta := angularDistanceArcsec(gotRA, gotDec, wantRA, wantDec); delta > 1e-6 {
t.Fatalf("TopocentricRaDec differs from independent formula by %.9f arcsec", delta)
}
}
func TestTopocentricRaAndDecMatchCombinedResult(t *testing.T) {
ut := Date2JDE(time.Date(2025, 6, 5, 12, 2, 7, 700000000, time.UTC))
ra := 189.527817246
dec := -5.973400893
lat := 6.79657
lon := 121.55381
distanceAU := HMoonAwayN(TD2UT(ut, true), -1) / 149597870.7
wantRA, wantDec := TopocentricRaDec(ra, dec, lat, lon, ut, distanceAU, 0)
if got := TopocentricRa(ra, dec, lat, lon, ut, distanceAU, 0); got != wantRA {
t.Fatalf("TopocentricRa = %.12f, want %.12f", got, wantRA)
}
if got := TopocentricDec(ra, dec, lat, lon, ut, distanceAU, 0); got != wantDec {
t.Fatalf("TopocentricDec = %.12f, want %.12f", got, wantDec)
}
}
func TestHMoonHeightUsesUTForTopocentricCorrection(t *testing.T) {
ut := Date2JDE(time.Date(2026, 4, 28, 16, 1, 30, 0, time.UTC))
longitude := 0.0
latitude := 51.4779
ra, dec := HMoonApparentRaDecN(ut, longitude, latitude, 0, -1)
hourAngle := Limit360(ApparentSiderealTime(ut)*15 + longitude - ra)
want := ArcSin(Sin(latitude)*Sin(dec) + Cos(dec)*Cos(latitude)*Cos(hourAngle))
got := HMoonHeightN(ut, longitude, latitude, 0, -1)
if difference := math.Abs(got - want); difference > 1e-10 {
t.Fatalf("HMoonHeightN differs from the UT topocentric position by %.12f degrees", difference)
}
}
func independentTopocentricRaDec(ra, dec, lat, lon, ut, distanceAU, height float64) (float64, float64) {
const (
equatorialRadiusKM = 6378.14
polarRadiusKM = 6356.755
)
u := math.Atan(polarRadiusKM / equatorialRadiusKM * Tan(lat))
rhoCos := math.Cos(u) + height/6378140.0*Cos(lat)
rhoSin := polarRadiusKM/equatorialRadiusKM*math.Sin(u) + height/6378140.0*Sin(lat)
sinParallax := Sin(0.0024427777777) / distanceAU
hourAngle := Limit360(ApparentSiderealTime(ut)*15 + lon - ra)
deltaRA := math.Atan2(
-rhoCos*sinParallax*Sin(hourAngle),
Cos(dec)-rhoCos*sinParallax*Cos(hourAngle),
)
topRA := ra + deltaRA*180/math.Pi
topDec := math.Atan2(
(Sin(dec)-rhoSin*sinParallax)*math.Cos(deltaRA),
Cos(dec)-rhoCos*sinParallax*Cos(hourAngle),
) * 180 / math.Pi
return topRA, topDec
}
func angularDistanceArcsec(ra1, dec1, ra2, dec2 float64) float64 {
cosDistance := Sin(dec1)*Sin(dec2) + Cos(dec1)*Cos(dec2)*Cos(ra1-ra2)
return math.Acos(math.Max(-1, math.Min(1, cosDistance))) * 180 / math.Pi * 3600
}
+119
View File
@@ -2,6 +2,125 @@ package basic
import "math"
const (
eventNewtonMaxIterations = 24
eventDirectionalSearchIterations = 128
eventRiseSetScanStep = 1.0 / 1440
)
func isFiniteFloat(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
// eventNewtonRefine 执行有界牛顿迭代;修正函数返回 f(x)/f'(x),调用者保留现有导数计算 / eventNewtonRefine performs a bounded Newton iteration. The correction
// 对格式错误输入和不收敛迭代快速失败 / function returns f(x)/f'(x), so callers retain their existing derivative
// 计算 / calculation while malformed input and non-convergent iterations fail fast.
func eventNewtonRefine(seed, tolerance float64, correction func(float64) float64) (float64, bool) {
if !isFiniteFloat(seed) || !isFiniteFloat(tolerance) || tolerance <= 0 {
return math.NaN(), false
}
current := seed
for i := 0; i < eventNewtonMaxIterations; i++ {
step := correction(current)
if !isFiniteFloat(step) {
return math.NaN(), false
}
next := current - step
if !isFiniteFloat(next) {
return math.NaN(), false
}
if math.Abs(next-current) <= tolerance {
return next, true
}
current = next
}
return math.NaN(), false
}
func eventRiseSetCandidateValid(candidate, civilDayStart, slope float64, isRise bool) bool {
if !isFiniteFloat(candidate) || !isFiniteFloat(civilDayStart) || !isFiniteFloat(slope) ||
candidate < civilDayStart || candidate >= civilDayStart+1 {
return false
}
if isRise {
return slope > 0
}
return slope < 0
}
func eventDirectionalRiseSetSearch(civilDayStart float64, isRise bool, fallbackErr error,
residual func(float64) float64) (float64, error) {
if !isFiniteFloat(civilDayStart) {
return 0, ErrInvalidObservationInput
}
previousJD := civilDayStart
previousValue := residual(previousJD)
if !isFiniteFloat(previousValue) {
return 0, ErrInvalidObservationInput
}
minimum, maximum := previousValue, previousValue
steps := int(math.Round(1 / eventRiseSetScanStep))
for i := 1; i <= steps; i++ {
currentJD := civilDayStart + float64(i)*eventRiseSetScanStep
currentValue := residual(currentJD)
if !isFiniteFloat(currentValue) {
return 0, ErrInvalidObservationInput
}
minimum = math.Min(minimum, currentValue)
maximum = math.Max(maximum, currentValue)
if eventCrossesDirection(previousValue, currentValue, isRise) {
eventJD := eventDirectionalBracketRefine(previousJD, currentJD, previousValue, currentValue, residual)
if eventJD < civilDayStart+1 {
return eventJD, nil
}
}
previousJD = currentJD
previousValue = currentValue
}
switch {
case fallbackErr != nil:
return 0, fallbackErr
case maximum < 0:
return 0, ErrNeverRise
case minimum > 0:
return 0, ErrNeverSet
default:
return 0, ErrNotOnThisDate
}
}
func eventCrossesDirection(leftValue, rightValue float64, isRise bool) bool {
if isRise {
return leftValue <= 0 && rightValue >= 0 && leftValue != rightValue
}
return leftValue >= 0 && rightValue <= 0 && leftValue != rightValue
}
func eventDirectionalBracketRefine(leftJD, rightJD, leftValue, rightValue float64, residual func(float64) float64) float64 {
if leftValue == 0 {
return leftJD
}
if rightValue == 0 {
return rightJD
}
for i := 0; i < 48; i++ {
middleJD := (leftJD + rightJD) / 2
middleValue := residual(middleJD)
if middleValue == 0 {
return middleJD
}
if (leftValue < 0) == (middleValue < 0) {
leftJD = middleJD
leftValue = middleValue
} else {
rightJD = middleJD
}
}
return (leftJD + rightJD) / 2
}
func eventFixedScanRefine(seed, halfWindow, step float64, fn func(float64) float64) float64 {
start := seed - halfWindow
bestJD := start
+6 -6
View File
@@ -173,14 +173,14 @@ func JupiterCulminationTime(jde, lon, timezone float64) float64 {
}
return currentHourAngle
}
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return hourAngleDelta / hourAngleSlope
})
if !ok {
return math.NaN()
}
return estimateJD
}
+33 -9
View File
@@ -64,6 +64,9 @@ func jupiterRADerivativeN(jde, delta float64, n int) float64 {
func jupiterConjunctionFull(jde, degree float64, next uint8) float64 {
//0=last 1=next
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
return math.NaN()
}
daysPerDegree := JUPITER_S_PERIOD / 360
currentDelta := jupiterSunLongitudeDelta(jde, degree, false)
if next == 0 {
@@ -72,20 +75,29 @@ func jupiterConjunctionFull(jde, degree float64, next uint8) float64 {
jde += daysPerDegree * currentDelta
}
estimateJD := jde
for {
converged := false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJD := estimateJD
longitudeDelta := jupiterSunLongitudeDelta(prevJD, degree, true)
longitudeSlope := (jupiterSunLongitudeDelta(prevJD+0.000005, degree, true) - jupiterSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
estimateJD = prevJD - longitudeDelta/longitudeSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
nextJD := prevJD - longitudeDelta/longitudeSlope
estimateJD = nextJD
if math.Abs(nextJD-prevJD) <= 0.00001 {
converged = true
break
}
}
if !converged {
return math.NaN()
}
return TD2UT(estimateJD, false)
}
func jupiterConjunction(jde, degree float64, next uint8) float64 {
//0=last 1=next
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
return math.NaN()
}
daysPerDegree := JUPITER_S_PERIOD / 360
currentDelta := jupiterSunLongitudeDelta(jde, degree, false)
if next == 0 {
@@ -94,24 +106,36 @@ func jupiterConjunction(jde, degree float64, next uint8) float64 {
jde += daysPerDegree * currentDelta
}
estimateJD := jde
for {
converged := false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJD := estimateJD
longitudeDelta := jupiterSunLongitudeDeltaN(prevJD, degree, true, jupiterEventSearchN)
longitudeSlope := (jupiterSunLongitudeDeltaN(prevJD+0.000005, degree, true, jupiterEventSearchN) - jupiterSunLongitudeDeltaN(prevJD-0.000005, degree, true, jupiterEventSearchN)) / 0.00001
estimateJD = prevJD - longitudeDelta/longitudeSlope
if math.Abs(estimateJD-prevJD) <= jupiterPhaseCoarseTolerance {
nextJD := prevJD - longitudeDelta/longitudeSlope
estimateJD = nextJD
if math.Abs(nextJD-prevJD) <= jupiterPhaseCoarseTolerance {
converged = true
break
}
}
for {
if !converged {
return math.NaN()
}
converged = false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJD := estimateJD
longitudeDelta := jupiterSunLongitudeDelta(prevJD, degree, true)
longitudeSlope := (jupiterSunLongitudeDelta(prevJD+0.000005, degree, true) - jupiterSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
estimateJD = prevJD - longitudeDelta/longitudeSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
nextJD := prevJD - longitudeDelta/longitudeSlope
estimateJD = nextJD
if math.Abs(nextJD-prevJD) <= 0.00001 {
converged = true
break
}
}
if !converged {
return math.NaN()
}
return TD2UT(estimateJD, false)
}
+16 -1
View File
@@ -361,17 +361,32 @@ func jupiterGalileanElementsToPV(mu float64, elements jupiterGalileanElements) [
p := elements.P
a := elements.A
al := elements.L
invalid := func() [6]float64 {
nan := math.NaN()
return [6]float64{nan, nan, nan, nan, nan, nan}
}
if !isFiniteFloat(mu) || !isFiniteFloat(k) || !isFiniteFloat(h) || !isFiniteFloat(q) || !isFiniteFloat(p) || !isFiniteFloat(a) || !isFiniteFloat(al) || a == 0 {
return invalid()
}
an := math.Sqrt(mu / math.Pow(a, 3))
ee := al + k*math.Sin(al) - h*math.Cos(al)
for {
converged := false
for i := 0; i < eventNewtonMaxIterations; i++ {
ce := math.Cos(ee)
se := math.Sin(ee)
de := (al - ee + k*se - h*ce) / (1 - k*ce - h*se)
if !isFiniteFloat(de) {
return invalid()
}
ee += de
if math.Abs(de) < 1e-12 {
converged = true
break
}
}
if !converged {
return invalid()
}
ce := math.Cos(ee)
se := math.Sin(ee)
dle := h*ce - k*se
+6 -6
View File
@@ -183,14 +183,14 @@ func MarsCulminationTime(jde, lon, timezone float64) float64 {
}
return currentHourAngle
}
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return hourAngleDelta / hourAngleSlope
})
if !ok {
return math.NaN()
}
return estimateJD
}
+66 -101
View File
@@ -9,9 +9,12 @@ import (
// Pos
const (
MARS_S_PERIOD = 1 / ((1 / 365.256363004) - (1 / 686.98))
marsEventSearchN = 16
marsPhaseCoarseTolerance = 30.0 / 86400.0
MARS_S_PERIOD = 1 / ((1 / 365.256363004) - (1 / 686.98))
marsEventSearchN = 16
marsPhaseCoarseTolerance = 30.0 / 86400.0
marsStationDerivativeStepDay = 0.01
marsStationCoarseStepDay = 6.0
marsStationHalfWindowDay = 6.0
)
func marsSunLongitudeDelta(jde, degree float64, filter bool) float64 {
@@ -64,6 +67,9 @@ func marsRADerivativeN(jde, val float64, n int) float64 {
func marsConjunctionFull(jde, degree float64, next uint8) float64 {
//0=last 1=next
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
return math.NaN()
}
daysPerDegree := MARS_S_PERIOD / 360
currentDelta := marsSunLongitudeDelta(jde, degree, false)
if next == 0 {
@@ -72,20 +78,29 @@ func marsConjunctionFull(jde, degree float64, next uint8) float64 {
jde += daysPerDegree * currentDelta
}
estimateJD := jde
for {
converged := false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJD := estimateJD
longitudeDelta := marsSunLongitudeDelta(prevJD, degree, true)
longitudeSlope := (marsSunLongitudeDelta(prevJD+0.000005, degree, true) - marsSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
estimateJD = prevJD - longitudeDelta/longitudeSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
nextJD := prevJD - longitudeDelta/longitudeSlope
estimateJD = nextJD
if math.Abs(nextJD-prevJD) <= 0.00001 {
converged = true
break
}
}
if !converged {
return math.NaN()
}
return TD2UT(estimateJD, false)
}
func marsConjunction(jde, degree float64, next uint8) float64 {
//0=last 1=next
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
return math.NaN()
}
daysPerDegree := MARS_S_PERIOD / 360
currentDelta := marsSunLongitudeDelta(jde, degree, false)
if next == 0 {
@@ -94,24 +109,36 @@ func marsConjunction(jde, degree float64, next uint8) float64 {
jde += daysPerDegree * currentDelta
}
estimateJD := jde
for {
converged := false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJD := estimateJD
longitudeDelta := marsSunLongitudeDeltaN(prevJD, degree, true, marsEventSearchN)
longitudeSlope := (marsSunLongitudeDeltaN(prevJD+0.000005, degree, true, marsEventSearchN) - marsSunLongitudeDeltaN(prevJD-0.000005, degree, true, marsEventSearchN)) / 0.00001
estimateJD = prevJD - longitudeDelta/longitudeSlope
if math.Abs(estimateJD-prevJD) <= marsPhaseCoarseTolerance {
nextJD := prevJD - longitudeDelta/longitudeSlope
estimateJD = nextJD
if math.Abs(nextJD-prevJD) <= marsPhaseCoarseTolerance {
converged = true
break
}
}
for {
if !converged {
return math.NaN()
}
converged = false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJD := estimateJD
longitudeDelta := marsSunLongitudeDelta(prevJD, degree, true)
longitudeSlope := (marsSunLongitudeDelta(prevJD+0.000005, degree, true) - marsSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
estimateJD = prevJD - longitudeDelta/longitudeSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
nextJD := prevJD - longitudeDelta/longitudeSlope
estimateJD = nextJD
if math.Abs(nextJD-prevJD) <= 0.00001 {
converged = true
break
}
}
if !converged {
return math.NaN()
}
return TD2UT(estimateJD, false)
}
@@ -148,122 +175,60 @@ func LastMarsWesternQuadrature(jde float64) float64 {
}
func marsRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool) float64 {
jde := oppositionJD
oppositionTT := TD2UT(oppositionJD, true)
startTT := oppositionTT
endTT := oppositionTT
if searchBeforeOpposition {
jde -= 60
easternQuadratureUT := marsConjunction(oppositionTT, 90, 0)
startTT = TD2UT(easternQuadratureUT, true)
} else {
jde += 60
westernQuadratureUT := marsConjunction(oppositionTT, 270, 1)
endTT = TD2UT(westernQuadratureUT, true)
}
for {
currentRate := marsRADerivative(jde, 1.0/86400.0)
if math.Abs(currentRate) > 0.55 {
jde += 2
continue
}
break
}
estimateJD := jde
for {
prevJD := estimateJD
rateValue := marsRADerivative(prevJD, 2.0/86400.0)
rateSlope := (marsRADerivative(prevJD+15.0/86400.0, 2.0/86400.0) - marsRADerivative(prevJD-15.0/86400.0, 2.0/86400.0)) / (30.0 / 86400.0)
estimateJD = prevJD - rateValue/rateSlope
if math.Abs(estimateJD-prevJD) <= 30.0/86400.0 {
break
}
}
bestJD := eventZeroRefine(estimateJD, 15.0/86400.0, 0.5/86400.0, func(jd float64) float64 {
return marsRADerivative(jd, 0.5/86400.0)
bestJD := zeroEventInWindow(startTT, endTT, marsStationCoarseStepDay, marsStationHalfWindowDay, 30.0/86400.0, func(jd float64) float64 {
return marsRADerivativeN(jd, marsStationDerivativeStepDay, marsEventSearchN)
}, func(jd float64) float64 {
return marsRADerivative(jd, marsStationDerivativeStepDay)
})
return TD2UT(bestJD, false)
}
func marsOppositionFromBefore(oppositionJD float64) float64 {
return marsConjunctionFull(eventUTLastQueryTT(oppositionJD), 180, 1)
}
func marsOppositionFromAfter(oppositionJD float64) float64 {
return marsConjunctionFull(eventUTNextQueryTT(oppositionJD), 180, 0)
}
func stabilizeMarsStationNearQuery(jde, date float64, searchBeforeOpposition bool) float64 {
if math.Abs(eventUTQueryTTDelta(date, jde)) > exactEventTolerance {
return date
}
if searchBeforeOpposition {
stableOppositionJD := NextMarsOpposition(jde)
sameOppositionJD := marsOppositionFromAfter(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, marsRetrogradeAroundOpposition(stableOppositionJD, true), marsRetrogradeAroundOpposition(sameOppositionJD, true))
}
stableOppositionJD := LastMarsOpposition(jde)
sameOppositionJD := marsOppositionFromBefore(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, marsRetrogradeAroundOpposition(stableOppositionJD, false), marsRetrogradeAroundOpposition(sameOppositionJD, false))
}
func NextMarsRetrogradeToPrograde(jde float64) float64 {
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
date := marsRetrogradeAroundOpposition(lastOppositionJD, false)
date = stabilizeMarsStationNearQuery(jde, date, false)
if sameEventUTQueryTT(date, jde) {
stableOppositionJD := LastMarsOpposition(jde)
stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, false)
sameOppositionJD := marsOppositionFromBefore(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, false))
if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) {
return date
}
if !eventUTQueryAfterOrEqual(date, jde) {
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
return marsRetrogradeAroundOpposition(nextOppositionJD, false)
}
return date
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
return marsRetrogradeAroundOpposition(nextOppositionJD, false)
}
func LastMarsRetrogradeToPrograde(jde float64) float64 {
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
date := marsRetrogradeAroundOpposition(lastOppositionJD, false)
date = stabilizeMarsStationNearQuery(jde, date, false)
if sameEventUTQueryTT(date, jde) {
stableOppositionJD := LastMarsOpposition(jde)
stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, false)
sameOppositionJD := marsOppositionFromBefore(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, false))
if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) {
return date
}
if !eventUTQueryBeforeOrEqual(date, jde) {
previousOppositionJD := marsConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0)
return marsRetrogradeAroundOpposition(previousOppositionJD, false)
}
return date
previousOppositionJD := marsConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0)
return marsRetrogradeAroundOpposition(previousOppositionJD, false)
}
func NextMarsProgradeToRetrograde(jde float64) float64 {
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
date := marsRetrogradeAroundOpposition(nextOppositionJD, true)
date = stabilizeMarsStationNearQuery(jde, date, true)
if sameEventUTQueryTT(date, jde) {
stableOppositionJD := NextMarsOpposition(jde)
stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, true)
sameOppositionJD := marsOppositionFromAfter(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, true))
if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) {
return date
}
if !eventUTQueryAfterOrEqual(date, jde) {
followingOppositionJD := marsConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1)
return marsRetrogradeAroundOpposition(followingOppositionJD, true)
}
return date
followingOppositionJD := marsConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1)
return marsRetrogradeAroundOpposition(followingOppositionJD, true)
}
func LastMarsProgradeToRetrograde(jde float64) float64 {
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
date := marsRetrogradeAroundOpposition(nextOppositionJD, true)
date = stabilizeMarsStationNearQuery(jde, date, true)
if sameEventUTQueryTT(date, jde) {
stableOppositionJD := NextMarsOpposition(jde)
stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, true)
sameOppositionJD := marsOppositionFromAfter(stableOppositionJD)
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, true))
if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) {
return date
}
if !eventUTQueryBeforeOrEqual(date, jde) {
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
return marsRetrogradeAroundOpposition(lastOppositionJD, true)
}
return date
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
return marsRetrogradeAroundOpposition(lastOppositionJD, true)
}
+6 -6
View File
@@ -166,14 +166,14 @@ func MercuryCulminationTime(jde, lon, timezone float64) float64 {
}
return currentHourAngle
}
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return hourAngleDelta / hourAngleSlope
})
if !ok {
return math.NaN()
}
return estimateJD
}
+227 -155
View File
@@ -12,6 +12,11 @@ const (
mercuryConjunctionDerivativeStepDay = 2e-5 * 36525.0
mercuryLightTimeDaysPerAU = 0.0057755183
mercuryEventSearchN = 16
mercuryStationWindowDays = 30.0
mercuryStationDerivativeStepDay = 0.01
mercuryStationCoarseStepDay = 2.0
mercuryStationHalfWindowDay = 2.0
mercuryStationMotionTolerance = 1e-3
)
type mercuryConjunctionLBR struct {
@@ -109,65 +114,29 @@ func mercuryConjunctionApproxTT(seed float64, inferior bool) float64 {
func mercuryConjunctionExactTT(seed float64, inferior bool) float64 {
estimateJD := mercuryConjunctionApproxTT(seed, inferior)
for {
converged := false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJD := estimateJD
longitudeDelta := mercuryConjunctionExactDelta(prevJD)
longitudeSlope := (mercuryConjunctionExactDelta(prevJD+0.000005) - mercuryConjunctionExactDelta(prevJD-0.000005)) / 0.00001
estimateJD = prevJD - longitudeDelta/longitudeSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
nextJD := prevJD - longitudeDelta/longitudeSlope
estimateJD = nextJD
if math.Abs(nextJD-prevJD) <= 0.00001 {
converged = true
break
}
}
if !converged {
return math.NaN()
}
return estimateJD
}
func mercuryConjunctionLegacy(jde float64, next uint8) float64 {
//0=last 1=next
longitudeDeltaAt := func(jde float64) float64 {
return mercuryConjunctionExactDelta(jde)
}
currentDelta := longitudeDeltaAt(jde)
distanceTrend := math.Abs(longitudeDeltaAt(jde+1/86400.0)) - math.Abs(currentDelta)
if distanceTrend >= 0 && next == 1 && currentDelta > 0 {
jde += MERCURY_S_PERIOD/8.0 + 2
}
if distanceTrend >= 0 && next == 1 && currentDelta < 0 {
jde += MERCURY_S_PERIOD/6.0 + 2
}
if distanceTrend <= 0 && next == 0 && currentDelta < 0 {
jde -= MERCURY_S_PERIOD/8.0 + 2
}
if distanceTrend <= 0 && next == 0 && currentDelta > 0 {
jde -= MERCURY_S_PERIOD/6.0 + 2
}
for {
currentDelta := longitudeDeltaAt(jde)
distanceTrend := math.Abs(longitudeDeltaAt(jde+1/86400.0)) - math.Abs(currentDelta)
if math.Abs(currentDelta) > 12 || (distanceTrend > 0 && next == 1) || (distanceTrend < 0 && next == 0) {
if next == 1 {
jde += 2
} else {
jde -= 2
}
continue
}
break
}
estimateJD := jde
for {
prevJD := estimateJD
longitudeDelta := longitudeDeltaAt(prevJD)
longitudeSlope := (longitudeDeltaAt(prevJD+0.000005) - longitudeDeltaAt(prevJD-0.000005)) / 0.00001
estimateJD = prevJD - longitudeDelta/longitudeSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
}
return TD2UT(estimateJD, false)
}
func mercuryConjunction(jde float64, next uint8) float64 {
//0=last 1=next
if !isFiniteFloat(jde) {
return math.NaN()
}
if math.Abs(mercuryConjunctionExactDelta(jde)) <= 30.0/86400.0 {
best := math.NaN()
consider := func(inferior bool) {
@@ -203,9 +172,14 @@ func mercuryConjunction(jde float64, next uint8) float64 {
if distanceTrend <= 0 && next == 0 && currentDelta > 0 {
jde -= MERCURY_S_PERIOD/6.0 + 2
}
for {
found := false
for i := 0; i < eventDirectionalSearchIterations; i++ {
currentDelta := mercuryConjunctionExactDelta(jde)
distanceTrend := math.Abs(mercuryConjunctionExactDelta(jde+1/86400.0)) - math.Abs(currentDelta)
nextDelta := mercuryConjunctionExactDelta(jde + 1/86400.0)
if !isFiniteFloat(currentDelta) || !isFiniteFloat(nextDelta) {
return math.NaN()
}
distanceTrend := math.Abs(nextDelta) - math.Abs(currentDelta)
if math.Abs(currentDelta) > 12 || (distanceTrend > 0 && next == 1) || (distanceTrend < 0 && next == 0) {
if next == 1 {
jde += 2
@@ -214,11 +188,18 @@ func mercuryConjunction(jde float64, next uint8) float64 {
}
continue
}
found = true
break
}
if !found {
return math.NaN()
}
inferior := mercuryConjunctionExactTT(jde, true)
superior := mercuryConjunctionExactTT(jde, false)
if !isFiniteFloat(inferior) || !isFiniteFloat(superior) {
return math.NaN()
}
best := inferior
if math.Abs(superior-jde) < math.Abs(inferior-jde) {
best = superior
@@ -274,51 +255,6 @@ func LastMercurySuperiorConjunction(jde float64) float64 {
return date
}
func mercuryRetrograde(jde float64) float64 {
//0=last 1=next
solarRADelta := func(jde float64) float64 {
sub := Limit360(MercuryApparentRa(jde) - SunApparentRa(jde))
if sub > 180 {
sub -= 360
}
if sub < -180 {
sub += 360
}
return sub
}
lastConjunction := mercuryConjunctionLegacy(jde, 0)
nextConjunction := mercuryConjunctionLegacy(jde, 1)
currentRADelta := solarRADelta(jde)
if currentRADelta > 0 {
jde = lastConjunction + ((nextConjunction - lastConjunction) / 5.0 * 3.5)
} else {
jde = lastConjunction + ((nextConjunction - lastConjunction) / 5.5)
}
for {
currentRate := mercuryRADerivative(jde, 1.0/86400.0)
if math.Abs(currentRate) > 0.55 {
jde += 2
continue
}
break
}
estimateJD := jde
for {
prevJD := estimateJD
rateValue := mercuryRADerivative(prevJD, 2.0/86400.0)
rateSlope := (mercuryRADerivative(prevJD+15.0/86400.0, 2.0/86400.0) - mercuryRADerivative(prevJD-15.0/86400.0, 2.0/86400.0)) / (30.0 / 86400.0)
estimateJD = prevJD - rateValue/rateSlope
if math.Abs(estimateJD-prevJD) <= 30.0/86400.0 {
break
}
}
bestJD := eventZeroRefine(estimateJD, 15.0/86400.0, 0.5/86400.0, func(jd float64) float64 {
return mercuryRADerivative(jd, 0.5/86400.0)
})
//fmt.Println((bestJD - lastConjunction) / (nextConjunction - lastConjunction))
return TD2UT(bestJD, false)
}
func mercuryRADerivative(jde, delta float64) float64 {
sub := MercuryApparentRa(jde+delta) - MercuryApparentRa(jde-delta)
if sub > 180 {
@@ -330,55 +266,174 @@ func mercuryRADerivative(jde, delta float64) float64 {
return sub / (2 * delta)
}
func mercuryStationIsProgradeToRetrograde(eventUT float64) bool {
for _, offset := range []float64{0.25, 0.5, 1.0} {
before := mercuryRADerivative(eventUT-offset, 0.5/86400.0)
after := mercuryRADerivative(eventUT+offset, 0.5/86400.0)
if before > 0 && after < 0 {
func mercuryRADerivativeN(jde, delta float64, n int) float64 {
sub := MercuryApparentRaN(jde+delta, n) - MercuryApparentRaN(jde-delta, n)
if sub > 180 {
sub -= 360
}
if sub < -180 {
sub += 360
}
return sub / (2 * delta)
}
func mercuryStationInWindow(startTT, endTT float64) float64 {
bestJD := zeroEventInWindow(startTT, endTT, mercuryStationCoarseStepDay, mercuryStationHalfWindowDay, 30.0/86400.0, func(jd float64) float64 {
return mercuryRADerivativeN(jd, mercuryStationDerivativeStepDay, mercuryEventSearchN)
}, func(jd float64) float64 {
return mercuryRADerivative(jd, mercuryStationDerivativeStepDay)
})
return TD2UT(bestJD, false)
}
func mercuryStationBetween(startTT, endTT float64) bool {
if endTT < startTT {
startTT, endTT = endTT, startTT
}
if endTT-startTT <= 0 {
return false
}
if endTT-startTT > mercuryStationWindowDays {
return true
}
// 截断扫描足以判断单候选快速路径是否安全 / A truncated scan is enough to decide whether the one-candidate fast path is safe.
left := startTT
leftValue := mercuryRADerivativeN(left, mercuryStationDerivativeStepDay, mercuryEventSearchN)
for left < endTT {
right := left + mercuryStationCoarseStepDay
if right > endTT {
right = endTT
}
rightValue := mercuryRADerivativeN(right, mercuryStationDerivativeStepDay, mercuryEventSearchN)
if leftValue == 0 || leftValue*rightValue < 0 || rightValue == 0 {
return true
}
if before < 0 && after > 0 {
return false
}
left = right
leftValue = rightValue
}
before := mercuryRADerivative(eventUT-0.25, 0.5/86400.0)
after := mercuryRADerivative(eventUT+0.25, 0.5/86400.0)
return before > after
return false
}
func nextMercuryTypedStation(jde float64, progradeToRetrograde bool) float64 {
date := NextMercuryRetrogradeStrict(jde)
for mercuryStationIsProgradeToRetrograde(date) != progradeToRetrograde {
date = NextMercuryRetrogradeStrict(eventUTNextQueryTT(date))
}
return date
func mercuryProgradeToRetrogradeAroundInferior(inferiorUT float64) float64 {
inferiorTT := TD2UT(inferiorUT, true)
return mercuryStationInWindow(inferiorTT-mercuryStationWindowDays, inferiorTT)
}
func lastMercuryTypedStation(jde float64, progradeToRetrograde bool) float64 {
date := LastMercuryRetrogradeStrict(jde)
for mercuryStationIsProgradeToRetrograde(date) != progradeToRetrograde {
date = LastMercuryRetrogradeStrict(eventUTLastQueryTT(date))
func mercuryRetrogradeToProgradeAroundInferior(inferiorUT float64) float64 {
inferiorTT := TD2UT(inferiorUT, true)
return mercuryStationInWindow(inferiorTT, inferiorTT+mercuryStationWindowDays)
}
func NextMercuryProgradeToRetrograde(jde float64) float64 {
inferior := NextMercuryInferiorConjunction(jde)
date := mercuryProgradeToRetrogradeAroundInferior(inferior)
if eventUTQueryAfterOrEqual(date, jde) {
return date
}
return date
followingInferior := NextMercuryInferiorConjunction(eventUTNextQueryTT(inferior))
return mercuryProgradeToRetrogradeAroundInferior(followingInferior)
}
func NextMercuryRetrogradeToPrograde(jde float64) float64 {
inferior := LastMercuryInferiorConjunction(jde)
date := mercuryRetrogradeToProgradeAroundInferior(inferior)
if eventUTQueryAfterOrEqual(date, jde) {
return date
}
nextInferior := NextMercuryInferiorConjunction(eventUTNextQueryTT(inferior))
return mercuryRetrogradeToProgradeAroundInferior(nextInferior)
}
func LastMercuryProgradeToRetrograde(jde float64) float64 {
inferior := NextMercuryInferiorConjunction(jde)
date := mercuryProgradeToRetrogradeAroundInferior(inferior)
if eventUTQueryBeforeOrEqual(date, jde) {
return date
}
previousInferior := LastMercuryInferiorConjunction(eventUTLastQueryTT(inferior))
return mercuryProgradeToRetrogradeAroundInferior(previousInferior)
}
func LastMercuryRetrogradeToPrograde(jde float64) float64 {
inferior := LastMercuryInferiorConjunction(jde)
date := mercuryRetrogradeToProgradeAroundInferior(inferior)
if eventUTQueryBeforeOrEqual(date, jde) {
return date
}
previousInferior := LastMercuryInferiorConjunction(eventUTLastQueryTT(inferior))
return mercuryRetrogradeToProgradeAroundInferior(previousInferior)
}
func nextMercuryRetrogradeFromTyped(jde float64) float64 {
p2r := NextMercuryProgradeToRetrograde(jde)
r2p := NextMercuryRetrogradeToPrograde(jde)
if p2r < r2p {
return p2r
}
return r2p
}
func NextMercuryRetrograde(jde float64) float64 {
date := mercuryRetrograde(jde)
if !eventUTQueryAfterOrEqual(date, jde) {
nextConjunction := NextMercuryConjunctionStrict(jde)
return mercuryRetrograde(nextConjunction + 2)
motion := mercuryRADerivative(jde, mercuryStationDerivativeStepDay)
if motion > mercuryStationMotionTolerance {
p2r := NextMercuryProgradeToRetrograde(jde)
if !mercuryStationBetween(jde, TD2UT(p2r, true)) {
return p2r
}
r2p := NextMercuryRetrogradeToPrograde(jde)
if p2r < r2p {
return p2r
}
return r2p
}
return date
if motion < -mercuryStationMotionTolerance {
r2p := NextMercuryRetrogradeToPrograde(jde)
if !mercuryStationBetween(jde, TD2UT(r2p, true)) {
return r2p
}
p2r := NextMercuryProgradeToRetrograde(jde)
if p2r < r2p {
return p2r
}
return r2p
}
return nextMercuryRetrogradeFromTyped(jde)
}
func lastMercuryRetrogradeFromTyped(jde float64) float64 {
p2r := LastMercuryProgradeToRetrograde(jde)
r2p := LastMercuryRetrogradeToPrograde(jde)
if p2r > r2p {
return p2r
}
return r2p
}
func LastMercuryRetrograde(jde float64) float64 {
lastConjunction := LastMercuryConjunctionStrict(jde)
date := mercuryRetrograde(lastConjunction + 2)
if !eventUTQueryBeforeOrEqual(date, jde) {
previousConjunction := LastMercuryConjunctionStrict(eventUTLastQueryTT(lastConjunction))
return mercuryRetrograde(previousConjunction + 2)
motion := mercuryRADerivative(jde, mercuryStationDerivativeStepDay)
if motion > mercuryStationMotionTolerance {
r2p := LastMercuryRetrogradeToPrograde(jde)
if !mercuryStationBetween(TD2UT(r2p, true), jde) {
return r2p
}
p2r := LastMercuryProgradeToRetrograde(jde)
if p2r > r2p {
return p2r
}
return r2p
}
return date
if motion < -mercuryStationMotionTolerance {
p2r := LastMercuryProgradeToRetrograde(jde)
if !mercuryStationBetween(TD2UT(p2r, true), jde) {
return p2r
}
r2p := LastMercuryRetrogradeToPrograde(jde)
if p2r > r2p {
return p2r
}
return r2p
}
return lastMercuryRetrogradeFromTyped(jde)
}
func LastMercuryRetrogradeStrict(jde float64) float64 {
@@ -389,22 +444,6 @@ func NextMercuryRetrogradeStrict(jde float64) float64 {
return NextMercuryRetrograde(jde)
}
func NextMercuryProgradeToRetrograde(jde float64) float64 {
return nextMercuryTypedStation(jde, true)
}
func NextMercuryRetrogradeToPrograde(jde float64) float64 {
return nextMercuryTypedStation(jde, false)
}
func LastMercuryProgradeToRetrograde(jde float64) float64 {
return lastMercuryTypedStation(jde, true)
}
func LastMercuryRetrogradeToPrograde(jde float64) float64 {
return lastMercuryTypedStation(jde, false)
}
func MercurySunElongation(jde float64) float64 {
lo1, bo1 := MercuryApparentLoBo(jde)
lo2 := HSunApparentLo(jde)
@@ -466,52 +505,77 @@ func mercuryWestElongationWindowContaining(jde float64) (float64, float64) {
}
func nextMercuryGreatestElongationTyped(jde float64, east bool) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
if east {
start, windowEnd := mercuryEastElongationWindowContaining(jde)
for {
for i := 0; i < eventDirectionalSearchIterations; i++ {
date := mercuryGreatestElongationInWindow(start, windowEnd)
if !isFiniteFloat(date) {
return math.NaN()
}
if eventUTQueryAfterOrEqual(date, jde) {
return date
}
nextInferior := NextMercuryInferiorConjunction(eventUTNextQueryTT(windowEnd))
start, windowEnd = mercuryEastElongationWindowEndingAt(nextInferior)
}
return math.NaN()
}
start, windowEnd := mercuryWestElongationWindowContaining(jde)
for {
for i := 0; i < eventDirectionalSearchIterations; i++ {
date := mercuryGreatestElongationInWindow(start, windowEnd)
if !isFiniteFloat(date) {
return math.NaN()
}
if eventUTQueryAfterOrEqual(date, jde) {
return date
}
nextSuperior := NextMercurySuperiorConjunction(eventUTNextQueryTT(windowEnd))
start, windowEnd = mercuryWestElongationWindowEndingAt(nextSuperior)
}
return math.NaN()
}
func lastMercuryGreatestElongationTyped(jde float64, east bool) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
if east {
start, windowEnd := mercuryEastElongationWindowContaining(jde)
for {
for i := 0; i < eventDirectionalSearchIterations; i++ {
date := mercuryGreatestElongationInWindow(start, windowEnd)
if !isFiniteFloat(date) {
return math.NaN()
}
if eventUTQueryBeforeOrEqual(date, jde) {
return date
}
prevInferior := LastMercuryInferiorConjunction(eventUTLastQueryTT(start))
start, windowEnd = mercuryEastElongationWindowEndingAt(prevInferior)
}
return math.NaN()
}
start, windowEnd := mercuryWestElongationWindowContaining(jde)
for {
for i := 0; i < eventDirectionalSearchIterations; i++ {
date := mercuryGreatestElongationInWindow(start, windowEnd)
if !isFiniteFloat(date) {
return math.NaN()
}
if eventUTQueryBeforeOrEqual(date, jde) {
return date
}
prevSuperior := LastMercurySuperiorConjunction(eventUTLastQueryTT(start))
start, windowEnd = mercuryWestElongationWindowEndingAt(prevSuperior)
}
return math.NaN()
}
func mercuryGreatestElongation(jde float64) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
solarRADelta := func(jde float64) float64 {
sub := Limit360(MercuryApparentRa(jde) - SunApparentRa(jde))
if sub > 180 {
@@ -540,23 +604,31 @@ func mercuryGreatestElongation(jde float64) float64 {
} else {
jde = lastConjunction + ((nextConjunction - lastConjunction) / 6.0)
}
for {
found := false
for i := 0; i < eventDirectionalSearchIterations; i++ {
currentRate := elongationRate(jde, 1.0/86400.0)
if !isFiniteFloat(currentRate) {
return math.NaN()
}
if math.Abs(currentRate) > 0.4 {
jde += 2
continue
}
found = true
break
}
if !found {
return math.NaN()
}
estimateJD := jde
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 30.0/86400.0, func(prevJD float64) float64 {
rateValue := elongationRate(prevJD, 2.0/86400.0)
rateSlope := (elongationRate(prevJD+15.0/86400.0, 2.0/86400.0) - elongationRate(prevJD-15.0/86400.0, 2.0/86400.0)) / (30.0 / 86400.0)
estimateJD = prevJD - rateValue/rateSlope
if math.Abs(estimateJD-prevJD) <= 30.0/86400.0 {
break
}
return rateValue / rateSlope
})
if !ok {
return math.NaN()
}
bestJD := eventZeroRefine(estimateJD, 15.0/86400.0, 0.5/86400.0, func(jd float64) float64 {
return elongationRate(jd, 0.5/86400.0)
@@ -0,0 +1,31 @@
package basic
import (
"math"
"testing"
)
func TestMercuryRetrogradeFastPathKeepsTypedCandidateOrder(t *testing.T) {
// 这些查询覆盖一个普通古代周期和两个长间隔边界 / These queries cover a normal ancient cycle and two long-gap boundaries
//,类型化驻留搜索必须作为最终权威 / where the typed station search must remain the final authority.
for _, queryUT := range []float64{
1026548.810500779,
1644733.927538287,
1645082.416782375,
} {
queryTT := TD2UT(queryUT, true)
nextP2R := NextMercuryProgradeToRetrograde(queryTT)
nextR2P := NextMercuryRetrogradeToPrograde(queryTT)
wantNext := math.Min(nextP2R, nextR2P)
if got := NextMercuryRetrograde(queryTT); math.Abs(got-wantNext) > 1e-7 {
t.Fatalf("next aggregate mismatch at %.9f: got %.12f want %.12f", queryUT, got, wantNext)
}
lastP2R := LastMercuryProgradeToRetrograde(queryTT)
lastR2P := LastMercuryRetrogradeToPrograde(queryTT)
wantLast := math.Max(lastP2R, lastR2P)
if got := LastMercuryRetrograde(queryTT); math.Abs(got-wantLast) > 1e-7 {
t.Fatalf("last aggregate mismatch at %.9f: got %.12f want %.12f", queryUT, got, wantLast)
}
}
}
+117 -73
View File
@@ -89,16 +89,50 @@ func HMoonHeight(jd, lon, lat, tz float64) float64 {
return HMoonHeightN(jd, lon, lat, tz, -1)
}
type moonObservationState struct {
altitude float64
distanceKM float64
}
func hMoonObservationStateN(jd, lon, lat, tz, height float64, n int) moonObservationState {
calculationJD := TD2UT(jd-tz/24, true)
ra, dec := HMoonTrueRaDecN(calculationJD, n)
distanceKM := HMoonAwayN(calculationJD, n)
distanceAU := distanceKM / angularDiameterAstronomicalUnitKM
topocentricRA, topocentricDec := TopocentricRaDec(ra, dec, lat, lon, jd-tz/24, distanceAU, height)
siderealTime := Limit360(ApparentSiderealTime(jd-tz/24)*15 + lon)
hourAngle := Limit360(siderealTime - topocentricRA)
altitudeSine := Sin(lat)*Sin(topocentricDec) + Cos(topocentricDec)*Cos(lat)*Cos(hourAngle)
return moonObservationState{
altitude: ArcSin(altitudeSine),
distanceKM: distanceKM,
}
}
func HMoonHeightN(jd, lon, lat, tz float64, n int) float64 {
calcjd := TD2UT(jd-tz/24, true)
ra, dec := HMoonTrueRaDecN(calcjd, n)
away := HMoonAwayN(calcjd, n) / 149597870.7
nra, ndec := TopocentricRaDec(ra, dec, lat, lon, calcjd, away, 0)
calcjd = jd - tz/24
st := Limit360(ApparentSiderealTime(calcjd)*15 + lon)
hourAngle := Limit360(st - nra)
tmp2 := Sin(lat)*Sin(ndec) + Cos(ndec)*Cos(lat)*Cos(hourAngle)
return ArcSin(tmp2)
return hMoonObservationStateN(jd, lon, lat, tz, 0, n).altitude
}
func moonRiseSetResidual(jd, longitude, latitude, timeZone, zenithShift, height float64, n int) float64 {
state := hMoonObservationStateN(jd, longitude, latitude, timeZone, height, n)
// 相对观测者下沉地平线的视上缘高度角 / Apparent upper-limb altitude relative to the observer's depressed horizon.
residual := state.altitude + HeightDegreeByLat(height, latitude)
if zenithShift != 0 {
residual += RefractionFromTrueAltitude(state.altitude, refractionStandardPressureHPa, refractionStandardTemperatureC)
residual += angularSemidiameterArcsec(moonEquatorialRadiusKM, state.distanceKM) / 3600
}
return residual
}
func moonRiseSetOnCivilDay(candidate, slope, civilDayStart, longitude, latitude, originalTimeZone,
localTimeZone, zenithShift, height float64, isRise bool, fallbackErr error) (float64, error) {
if eventRiseSetCandidateValid(candidate, civilDayStart, slope, isRise) {
return candidate, nil
}
return eventDirectionalRiseSetSearch(civilDayStart, isRise, fallbackErr, func(outputJD float64) float64 {
localJD := outputJD + localTimeZone/24 - originalTimeZone/24
return moonRiseSetResidual(localJD, longitude, latitude, localTimeZone, zenithShift, height, -1)
})
}
// 废弃
@@ -109,14 +143,14 @@ func GetMoonTZTime(jd, lon, lat, tz float64) float64 { //实际中天时间{
jd += 0.5
}
estimateJD := jd
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
stDegree := MoonTimeAngle(prevJD, lon, lat, tz) - 359.599
stDegreep := (MoonTimeAngle(prevJD+0.000005, lon, lat, tz) - MoonTimeAngle(prevJD-0.000005, lon, lat, tz)) / 0.00001
estimateJD = prevJD - stDegree/stDegreep
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return stDegree / stDegreep
})
if !ok {
return math.NaN()
}
return estimateJD
}
@@ -133,14 +167,14 @@ func MoonCulminationTime(jde, lon, lat, timezone float64) float64 {
}
return ha
}
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
stDegree := limitHA(prevJD, lon, timezone) - 360
stDegreep := (limitHA(prevJD+0.000005, lon, timezone) - limitHA(prevJD-0.000005, lon, timezone)) / 0.00001
estimateJD = prevJD - stDegree/stDegreep
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return stDegree / stDegreep
})
if !ok {
return math.NaN()
}
return estimateJD
}
@@ -155,21 +189,24 @@ func MoonTimeAngle(jd, lon, lat, tz float64) float64 {
}
func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, height float64) (float64, error) {
if !isFiniteFloat(julianDay) || !isFiniteFloat(longitude) || !isFiniteFloat(latitude) || !isFiniteFloat(timeZone) || !isFiniteFloat(zenithShift) || !isFiniteFloat(height) {
return 0, ErrInvalidObservationInput
}
originalTimeZone := timeZone
timeZone = longitude / 15
var timeToMeridian float64
julianDayZero := math.Floor(julianDay) + 0.5
civilDayStart := math.Floor(julianDay) + 0.5
//julianDay = math.Floor(julianDay) + 0.5 - originalTimeZone/24 + timeZone/24 // 求0时JDE
//fix:这里时间分界线应当以传入的时区为准,不应当使用当地时区,否则在0时的判断会出错
julianDay = math.Floor(julianDay) + 0.5
estimatedTime := julianDay
moonHeight := MoonHeight(julianDay, longitude, latitude, originalTimeZone) // 求此时月亮高度
moonResidual := moonRiseSetResidual(julianDay, longitude, latitude, originalTimeZone, zenithShift, height, -1)
moonAngle := StandardAltitudeMoon(zenithShift, height, latitude)
moonAngleTime := MoonTimeAngle(julianDay, longitude, latitude, originalTimeZone)
if moonHeight-moonAngle > 0 { // 月亮在地平线上或在落下与下中天之间
if moonResidual > 0 { // 月亮在地平线上或在落下与下中天之间
if moonAngleTime > 180 {
timeToMeridian = (180 + 360 - moonAngleTime) / 15
} else {
@@ -178,10 +215,10 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig
estimatedTime += (timeToMeridian/24 + (timeToMeridian/24*12.0)/15.0/24.0)
}
if moonHeight-moonAngle < 0 && moonAngleTime > 180 {
if moonResidual < 0 && moonAngleTime > 180 {
timeToMeridian = (180 - moonAngleTime) / 15
estimatedTime += (timeToMeridian/24 + (timeToMeridian/24*12.0)/15.0/24.0)
} else if moonHeight-moonAngle < 0 && moonAngleTime < 180 {
} else if moonResidual < 0 && moonAngleTime < 180 {
timeToMeridian = (180 - moonAngleTime) / 15
estimatedTime += (timeToMeridian/24 + (timeToMeridian/24*12.0)/15.0/24.0)
}
@@ -191,10 +228,11 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig
estimatedTime += (180 - currentAngle) * 4.0 / 60.0 / 24.0
}
currentHeight := HMoonHeight(estimatedTime, longitude, latitude, timeZone)
if !(currentHeight < -10 && math.Abs(latitude) < 60) {
if currentHeight > moonAngle {
return 0, ErrNeverSet
currentResidual := moonRiseSetResidual(estimatedTime, longitude, latitude, timeZone, zenithShift, height, -1)
if !(currentResidual < -10 && math.Abs(latitude) < 60) {
if currentResidual > 0 {
return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude,
originalTimeZone, timeZone, zenithShift, height, true, ErrNeverSet)
}
checkTime := estimatedTime + 12.0/24.0 + 6.0/15.0/24.0
checkAngle := MoonTimeAngle(checkTime, longitude, latitude, timeZone)
@@ -202,8 +240,9 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig
checkAngle += 360
}
checkTime += (360 - checkAngle) * 4.0 / 60.0 / 24.0
if HMoonHeight(checkTime, longitude, latitude, timeZone) < moonAngle {
return 0, ErrNeverRise
if moonRiseSetResidual(checkTime, longitude, latitude, timeZone, zenithShift, height, -1) < 0 {
return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude,
originalTimeZone, timeZone, zenithShift, height, true, ErrNeverRise)
}
}
@@ -215,7 +254,7 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig
estimatedTime += hourAngle/24.00 + hourAngle/33.00/15.00
} else {
i := 0
for MoonHeight(estimatedTime, longitude, latitude, timeZone) < moonAngle {
for moonRiseSetResidual(estimatedTime, longitude, latitude, timeZone, zenithShift, height, -1) < 0 {
i++
estimatedTime += 15.0 / 60.0 / 24.0
if i > 48 {
@@ -225,41 +264,40 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig
}
// 使用牛顿迭代法求精确解
estimatedTime = moonRiseSetNewtonRaphsonIteration(estimatedTime, longitude, latitude, timeZone, moonAngle, HMoonHeight, 0.00002)
estimatedTime, slope := moonRiseSetResidualIteration(estimatedTime, longitude, latitude, timeZone, zenithShift, height, 0.00002)
estimatedTime = estimatedTime - timeZone/24 + originalTimeZone/24
if estimatedTime > julianDayZero+1 || estimatedTime < julianDayZero {
return 0, ErrNotOnThisDate
}
return estimatedTime, nil
return moonRiseSetOnCivilDay(estimatedTime, slope, civilDayStart, longitude, latitude,
originalTimeZone, timeZone, zenithShift, height, true, nil)
}
func GetMoonSetTime(julianDay, longitude, latitude, timeZone, zenithShift, height float64) (float64, error) {
if !isFiniteFloat(julianDay) || !isFiniteFloat(longitude) || !isFiniteFloat(latitude) || !isFiniteFloat(timeZone) || !isFiniteFloat(zenithShift) || !isFiniteFloat(height) {
return 0, ErrInvalidObservationInput
}
originalTimeZone := timeZone
timeZone = longitude / 15
var timeToMeridian float64
julianDayZero := math.Floor(julianDay) + 0.5
civilDayStart := math.Floor(julianDay) + 0.5
//julianDay = math.Floor(julianDay) + 0.5 - originalTimeZone/24 + timeZone/24 // 求0时JDE
//fix:这里时间分界线应当以传入的时区为准,不应当使用当地时区,否则在0时的判断会出错
julianDay = math.Floor(julianDay) + 0.5
estimatedTime := julianDay
moonHeight := MoonHeight(julianDay, longitude, latitude, originalTimeZone) // 求此时月亮高度
moonResidual := moonRiseSetResidual(julianDay, longitude, latitude, originalTimeZone, zenithShift, height, -1)
moonAngle := StandardAltitudeMoon(zenithShift, height, latitude)
moonAngleTime := MoonTimeAngle(julianDay, longitude, latitude, originalTimeZone)
if moonHeight-moonAngle < 0 {
if moonResidual < 0 {
timeToMeridian = (360 - moonAngleTime) / 15
estimatedTime += (timeToMeridian/24 + (timeToMeridian/24.0*12.0)/15.0/24.0)
}
// 月亮在地平线上或在落下与下中天之间
if moonHeight-moonAngle > 0 && moonAngleTime < 180 {
if moonResidual > 0 && moonAngleTime < 180 {
timeToMeridian = (-moonAngleTime) / 15
estimatedTime += (timeToMeridian/24.0 + (timeToMeridian/24.0*12.0)/15.0/24.0)
} else if moonHeight-moonAngle > 0 {
} else if moonResidual > 0 {
timeToMeridian = (360 - moonAngleTime) / 15
estimatedTime += (timeToMeridian/24.0 + (timeToMeridian/24.0*12.0)/15.0/24.0)
}
@@ -273,16 +311,18 @@ func GetMoonSetTime(julianDay, longitude, latitude, timeZone, zenithShift, heigh
}
// estimatedTime = 月球中天时间
currentHeight := HMoonHeight(estimatedTime, longitude, latitude, timeZone)
if !(currentHeight > 10 && math.Abs(latitude) < 60) {
if currentHeight < moonAngle {
return 0, ErrNeverRise
currentResidual := moonRiseSetResidual(estimatedTime, longitude, latitude, timeZone, zenithShift, height, -1)
if !(currentResidual > 10 && math.Abs(latitude) < 60) {
if currentResidual < 0 {
return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude,
originalTimeZone, timeZone, zenithShift, height, false, ErrNeverRise)
}
checkTime := estimatedTime + 12.0/24.0 + 6.0/15.0/24.0
angleSubtraction := 180 - MoonTimeAngle(checkTime, longitude, latitude, timeZone)
checkTime += angleSubtraction * 4.0 / 60.0 / 24.0
if HMoonHeight(checkTime, longitude, latitude, timeZone) > moonAngle {
return 0, ErrNeverSet
if moonRiseSetResidual(checkTime, longitude, latitude, timeZone, zenithShift, height, -1) > 0 {
return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude,
originalTimeZone, timeZone, zenithShift, height, false, ErrNeverSet)
}
}
@@ -294,7 +334,7 @@ func GetMoonSetTime(julianDay, longitude, latitude, timeZone, zenithShift, heigh
estimatedTime += hourAngle/24 + hourAngle/33.0/15.0
} else {
i := 0
for MoonHeight(estimatedTime, longitude, latitude, timeZone) > moonAngle {
for moonRiseSetResidual(estimatedTime, longitude, latitude, timeZone, zenithShift, height, -1) > 0 {
i++
estimatedTime += 15.0 / 60.0 / 24.0
if i > 48 {
@@ -304,14 +344,10 @@ func GetMoonSetTime(julianDay, longitude, latitude, timeZone, zenithShift, heigh
}
// 使用牛顿迭代法求精确解
estimatedTime = moonRiseSetNewtonRaphsonIteration(estimatedTime, longitude, latitude, timeZone, moonAngle, HMoonHeight, 0.00002)
estimatedTime, slope := moonRiseSetResidualIteration(estimatedTime, longitude, latitude, timeZone, zenithShift, height, 0.00002)
estimatedTime = estimatedTime - timeZone/24 + originalTimeZone/24
if estimatedTime > julianDayZero+1 || estimatedTime < julianDayZero {
return 0, ErrNotOnThisDate
}
return estimatedTime, nil
return moonRiseSetOnCivilDay(estimatedTime, slope, civilDayStart, longitude, latitude,
originalTimeZone, timeZone, zenithShift, height, false, nil)
}
// heightFunction 高度函数类型定义,用于牛顿迭代法
@@ -324,24 +360,32 @@ func moonRiseSetNewtonRaphsonIteration(initialTime, longitude, latitude, timeZon
currentTime := initialTime
for {
previousTime := currentTime
// 计算函数值:f(t) = height(t) - targetAngle
var ok bool
currentTime, ok = eventNewtonRefine(currentTime, tolerance, func(previousTime float64) float64 {
functionValue := heightFunc(previousTime, longitude, latitude, timeZone) - targetAngle
// 计算导数:f'(t) ≈ (f(t+h) - f(t-h)) / (2h)
derivative := (heightFunc(previousTime+derivativeStep, longitude, latitude, timeZone) -
heightFunc(previousTime-derivativeStep, longitude, latitude, timeZone)) / (2 * derivativeStep)
// 牛顿-拉夫逊公式:t_new = t_old - f(t) / f'(t)
currentTime = previousTime - functionValue/derivative
// 检查收敛
if math.Abs(currentTime-previousTime) <= tolerance {
break
}
return functionValue / derivative
})
if !ok {
return math.NaN()
}
return currentTime
}
func moonRiseSetResidualIteration(initialTime, longitude, latitude, timeZone, zenithShift, height, tolerance float64) (float64, float64) {
const derivativeStep = 0.000005
slope := math.NaN()
currentTime, ok := eventNewtonRefine(initialTime, tolerance, func(previousTime float64) float64 {
functionValue := moonRiseSetResidual(previousTime, longitude, latitude, timeZone, zenithShift, height, -1)
slope = (moonRiseSetResidual(previousTime+derivativeStep, longitude, latitude, timeZone, zenithShift, height, -1) -
moonRiseSetResidual(previousTime-derivativeStep, longitude, latitude, timeZone, zenithShift, height, -1)) / (2 * derivativeStep)
return functionValue / slope
})
if !ok {
return math.NaN(), math.NaN()
}
return currentTime, slope
}
+24 -24
View File
@@ -38,14 +38,14 @@ func SunMoonSeek(jde float64, degree float64) float64 {
func CalcMoonSHByJDE(jde float64, phaseType int) float64 {
phaseType = phaseType * 180
estimateJD := jde
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
stDegree := SunMoonSeek(prevJD, float64(phaseType))
stDegreep := (SunMoonSeek(prevJD+0.000005, float64(phaseType)) - SunMoonSeek(prevJD-0.000005, float64(phaseType))) / 0.00001
estimateJD = prevJD - stDegree/stDegreep
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return stDegree / stDegreep
})
if !ok {
return math.NaN()
}
return estimateJD
}
@@ -54,14 +54,14 @@ func CalcMoonSH(year float64, phaseType int) float64 {
jde := CalcMoonS(year, phaseType)
phaseType = phaseType * 180
estimateJD := jde
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
stDegree := SunMoonSeek(prevJD, float64(phaseType))
stDegreep := (SunMoonSeek(prevJD+0.000005, float64(phaseType)) - SunMoonSeek(prevJD-0.000005, float64(phaseType))) / 0.00001
estimateJD = prevJD - stDegree/stDegreep
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return stDegree / stDegreep
})
if !ok {
return math.NaN()
}
return estimateJD
}
@@ -125,14 +125,14 @@ func CalcMoonXHByJDE(jde float64, quarterType int) float64 {
quarterType = -90
}
estimateJD := jde
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
stDegree := SunMoonSeek(prevJD, float64(quarterType))
stDegreep := (SunMoonSeek(prevJD+0.000005, float64(quarterType)) - SunMoonSeek(prevJD-0.000005, float64(quarterType))) / 0.00001
estimateJD = prevJD - stDegree/stDegreep
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return stDegree / stDegreep
})
if !ok {
return math.NaN()
}
return estimateJD
}
@@ -145,14 +145,14 @@ func CalcMoonXH(year float64, quarterType int) float64 {
quarterType = -90
}
estimateJD := jde
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
stDegree := SunMoonSeek(prevJD, float64(quarterType))
stDegreep := (SunMoonSeek(prevJD+0.000005, float64(quarterType)) - SunMoonSeek(prevJD-0.000005, float64(quarterType))) / 0.00001
estimateJD = prevJD - stDegree/stDegreep
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return stDegree / stDegreep
})
if !ok {
return math.NaN()
}
return estimateJD
}
+41 -30
View File
File diff suppressed because one or more lines are too long
+325
View File
@@ -0,0 +1,325 @@
package basic
import (
"encoding/json"
"fmt"
"math"
"os"
"testing"
"time"
"b612.me/astro/tools"
)
type moonRiseSetExternalEvents struct {
RiseUTC string `json:"rise_utc"`
SetUTC string `json:"set_utc"`
}
type moonRiseSetExternalSample struct {
Site string `json:"site"`
DateUTC string `json:"date_utc"`
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
ObserverHeight float64 `json:"observer_height_m"`
Horizons moonRiseSetExternalEvents `json:"jpl_horizons"`
METNorway moonRiseSetExternalEvents `json:"met_norway"`
IMCCEMiriade moonRiseSetExternalEvents `json:"imcce_miriade"`
}
type moonRiseSetExternalBaseline struct {
SchemaVersion int `json:"schema_version"`
Sources map[string]struct {
Provider string `json:"provider"`
Model string `json:"model"`
} `json:"sources"`
Samples []moonRiseSetExternalSample `json:"samples"`
}
type moonRiseSetErrorStats struct {
Total time.Duration
Max time.Duration
Count int
}
func (stats *moonRiseSetErrorStats) Add(value time.Duration) {
stats.Total += value
stats.Count++
if value > stats.Max {
stats.Max = value
}
}
func (stats moonRiseSetErrorStats) Mean() time.Duration {
if stats.Count == 0 {
return 0
}
return stats.Total / time.Duration(stats.Count)
}
type moonRiseSetExternalTolerances struct {
Horizons time.Duration
METNorway time.Duration
IMCCEMiriade time.Duration
HorizonsVsMET time.Duration
}
type moonRiseSetComparisonStats struct {
CurrentHorizons moonRiseSetErrorStats
LegacyHorizons moonRiseSetErrorStats
CurrentMET moonRiseSetErrorStats
LegacyMET moonRiseSetErrorStats
CurrentIMCCE moonRiseSetErrorStats
LegacyIMCCE moonRiseSetErrorStats
HorizonsVsMET moonRiseSetErrorStats
HorizonsVsIMCCE moonRiseSetErrorStats
CurrentCloserJPL int
LegacyCloserJPL int
TiesJPL int
CurrentCloserMET int
LegacyCloserMET int
TiesMET int
CurrentCloserIMCCE int
LegacyCloserIMCCE int
TiesIMCCE int
}
func TestMoonRiseSetMatchesExternalBaselines(t *testing.T) {
previousDeltaT := defDeltaTFn
SetDeltaTFn(DefaultDeltaTv2)
defer SetDeltaTFn(previousDeltaT)
baseline := loadMoonRiseSetExternalBaseline(t)
if baseline.SchemaVersion != 1 {
t.Fatalf("unsupported baseline schema version %d", baseline.SchemaVersion)
}
if baseline.Sources["jpl_horizons"].Model != "DE441" {
t.Fatalf("unexpected Horizons model %q", baseline.Sources["jpl_horizons"].Model)
}
if len(baseline.Samples) < 7 {
t.Fatalf("external baseline has only %d samples", len(baseline.Samples))
}
tolerances := moonRiseSetExternalTolerances{
Horizons: 2 * time.Second,
METNorway: 90 * time.Second,
IMCCEMiriade: 8 * time.Minute,
HorizonsVsMET: 90 * time.Second,
}
var stats moonRiseSetComparisonStats
for _, sample := range baseline.Samples {
day, err := time.Parse("2006-01-02", sample.DateUTC)
if err != nil {
t.Fatalf("parse %s date %q: %v", sample.Site, sample.DateUTC, err)
}
jd := Date2JDE(day)
currentRiseJD, err := GetMoonRiseTime(jd, sample.Longitude, sample.Latitude, 0, 1, sample.ObserverHeight)
if err != nil {
t.Fatalf("%s current moonrise: %v", sample.Site, err)
}
currentSetJD, err := GetMoonSetTime(jd, sample.Longitude, sample.Latitude, 0, 1, sample.ObserverHeight)
if err != nil {
t.Fatalf("%s current moonset: %v", sample.Site, err)
}
legacyRiseJD, err := legacyMoonRiseSetFromCurrent(currentRiseJD, sample.Longitude, sample.Latitude, 0, 1, sample.ObserverHeight)
if err != nil {
t.Fatalf("%s legacy moonrise: %v", sample.Site, err)
}
legacySetJD, err := legacyMoonRiseSetFromCurrent(currentSetJD, sample.Longitude, sample.Latitude, 0, 1, sample.ObserverHeight)
if err != nil {
t.Fatalf("%s legacy moonset: %v", sample.Site, err)
}
compareMoonRiseSetEvent(t, sample.Site+".rise", currentRiseJD, legacyRiseJD,
sample.Horizons.RiseUTC, sample.METNorway.RiseUTC, sample.IMCCEMiriade.RiseUTC,
tolerances, &stats)
compareMoonRiseSetEvent(t, sample.Site+".set", currentSetJD, legacySetJD,
sample.Horizons.SetUTC, sample.METNorway.SetUTC, sample.IMCCEMiriade.SetUTC,
tolerances, &stats)
}
t.Logf("moon rise/set external baseline: current vs JPL mean=%v max=%v; legacy vs JPL mean=%v max=%v",
stats.CurrentHorizons.Mean(), stats.CurrentHorizons.Max, stats.LegacyHorizons.Mean(), stats.LegacyHorizons.Max)
t.Logf("moon rise/set external baseline: current vs MET mean=%v max=%v; legacy vs MET mean=%v max=%v",
stats.CurrentMET.Mean(), stats.CurrentMET.Max, stats.LegacyMET.Mean(), stats.LegacyMET.Max)
t.Logf("moon rise/set external baseline: current vs IMCCE mean=%v max=%v; legacy vs IMCCE mean=%v max=%v",
stats.CurrentIMCCE.Mean(), stats.CurrentIMCCE.Max, stats.LegacyIMCCE.Mean(), stats.LegacyIMCCE.Max)
t.Logf("moon rise/set external baseline: JPL vs MET mean=%v max=%v; JPL vs IMCCE mean=%v max=%v",
stats.HorizonsVsMET.Mean(), stats.HorizonsVsMET.Max, stats.HorizonsVsIMCCE.Mean(), stats.HorizonsVsIMCCE.Max)
t.Logf("moon rise/set external baseline: JPL current closer=%d legacy closer=%d ties=%d",
stats.CurrentCloserJPL, stats.LegacyCloserJPL, stats.TiesJPL)
t.Logf("moon rise/set external baseline: MET current closer=%d legacy closer=%d ties=%d",
stats.CurrentCloserMET, stats.LegacyCloserMET, stats.TiesMET)
t.Logf("moon rise/set external baseline: IMCCE current closer=%d legacy closer=%d ties=%d",
stats.CurrentCloserIMCCE, stats.LegacyCloserIMCCE, stats.TiesIMCCE)
}
func TestMoonRiseSetLegacyComparatorMatchesPreFixSnapshot(t *testing.T) {
previousDeltaT := defDeltaTFn
SetDeltaTFn(DefaultDeltaTv2)
defer SetDeltaTFn(previousDeltaT)
jd := JDECalc(2023, 1, 15)
currentRise, err := GetMoonRiseTime(jd, 116.4074, 39.9042, 8, 1, 0)
if err != nil {
t.Fatalf("current moonrise: %v", err)
}
currentSet, err := GetMoonSetTime(jd, 116.4074, 39.9042, 8, 1, 0)
if err != nil {
t.Fatalf("current moonset: %v", err)
}
legacyRise, err := legacyMoonRiseSetFromCurrent(currentRise, 116.4074, 39.9042, 8, 1, 0)
if err != nil {
t.Fatalf("legacy moonrise: %v", err)
}
legacySet, err := legacyMoonRiseSetFromCurrent(currentSet, 116.4074, 39.9042, 8, 1, 0)
if err != nil {
t.Fatalf("legacy moonset: %v", err)
}
const snapshotTolerance = 2.0 / 86400
if difference := math.Abs(legacyRise - 2459959.509182); difference > snapshotTolerance {
t.Errorf("legacy moonrise snapshot mismatch: got %.9f want %.9f difference=%.3fs",
legacyRise, 2459959.509182, difference*86400)
}
if difference := math.Abs(legacySet - 2459959.988676); difference > snapshotTolerance {
t.Errorf("legacy moonset snapshot mismatch: got %.9f want %.9f difference=%.3fs",
legacySet, 2459959.988676, difference*86400)
}
}
func loadMoonRiseSetExternalBaseline(t *testing.T) moonRiseSetExternalBaseline {
t.Helper()
data, err := os.ReadFile("testdata/moon_rise_set_baseline.json")
if err != nil {
t.Fatalf("read moon rise/set baseline: %v", err)
}
var baseline moonRiseSetExternalBaseline
if err := json.Unmarshal(data, &baseline); err != nil {
t.Fatalf("decode moon rise/set baseline: %v", err)
}
return baseline
}
func compareMoonRiseSetEvent(t *testing.T, name string, currentJD, legacyJD float64,
horizonsUTC, metUTC, imcceUTC string, tolerances moonRiseSetExternalTolerances,
stats *moonRiseSetComparisonStats) {
t.Helper()
current := JDE2DateByZone(currentJD, time.UTC, false)
legacy := JDE2DateByZone(legacyJD, time.UTC, false)
horizons := parseMoonRiseSetExternalTime(t, name+".jpl", horizonsUTC)
met := parseMoonRiseSetExternalTime(t, name+".met", metUTC)
imcce := parseMoonRiseSetExternalTime(t, name+".imcce", imcceUTC)
currentHorizonsError := absoluteTimeDifference(current, horizons)
legacyHorizonsError := absoluteTimeDifference(legacy, horizons)
currentMETError := absoluteTimeDifference(current, met)
legacyMETError := absoluteTimeDifference(legacy, met)
currentIMCCEError := absoluteTimeDifference(current, imcce)
legacyIMCCEError := absoluteTimeDifference(legacy, imcce)
stats.CurrentHorizons.Add(currentHorizonsError)
stats.LegacyHorizons.Add(legacyHorizonsError)
stats.CurrentMET.Add(currentMETError)
stats.LegacyMET.Add(legacyMETError)
stats.CurrentIMCCE.Add(currentIMCCEError)
stats.LegacyIMCCE.Add(legacyIMCCEError)
horizonsVsMET := absoluteTimeDifference(horizons, met)
stats.HorizonsVsMET.Add(horizonsVsMET)
stats.HorizonsVsIMCCE.Add(absoluteTimeDifference(horizons, imcce))
if currentHorizonsError > tolerances.Horizons {
t.Errorf("%s current mismatch against JPL: got %s want %s difference=%v tolerance=%v",
name, current.Format(time.RFC3339Nano), horizonsUTC, currentHorizonsError, tolerances.Horizons)
}
if currentMETError > tolerances.METNorway {
t.Errorf("%s current mismatch against MET Norway: got %s want %s difference=%v tolerance=%v",
name, current.Format(time.RFC3339Nano), metUTC, currentMETError, tolerances.METNorway)
}
if currentIMCCEError > tolerances.IMCCEMiriade {
t.Errorf("%s current mismatch against IMCCE Miriade: got %s want %s difference=%v tolerance=%v",
name, current.Format(time.RFC3339Nano), imcceUTC, currentIMCCEError, tolerances.IMCCEMiriade)
}
if horizonsVsMET > tolerances.HorizonsVsMET {
t.Errorf("%s external sources disagree: JPL=%s MET=%s difference=%v tolerance=%v",
name, horizonsUTC, metUTC, horizonsVsMET, tolerances.HorizonsVsMET)
}
switch {
case currentHorizonsError < legacyHorizonsError:
stats.CurrentCloserJPL++
case legacyHorizonsError < currentHorizonsError:
stats.LegacyCloserJPL++
default:
stats.TiesJPL++
}
switch {
case currentMETError < legacyMETError:
stats.CurrentCloserMET++
case legacyMETError < currentMETError:
stats.LegacyCloserMET++
default:
stats.TiesMET++
}
switch {
case currentIMCCEError < legacyIMCCEError:
stats.CurrentCloserIMCCE++
case legacyIMCCEError < currentIMCCEError:
stats.LegacyCloserIMCCE++
default:
stats.TiesIMCCE++
}
t.Logf("%s current_jpl=%v legacy_jpl=%v current_met=%v legacy_met=%v current_imcce=%v legacy_imcce=%v", name,
currentHorizonsError, legacyHorizonsError, currentMETError, legacyMETError, currentIMCCEError, legacyIMCCEError)
}
func parseMoonRiseSetExternalTime(t *testing.T, name, value string) time.Time {
t.Helper()
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
t.Fatalf("parse %s time %q: %v", name, value, err)
}
return parsed
}
func absoluteTimeDifference(left, right time.Time) time.Duration {
difference := left.Sub(right)
if difference < 0 {
return -difference
}
return difference
}
func legacyMoonRiseSetFromCurrent(currentJD, longitude, latitude, timeZone, zenithShift, height float64) (float64, error) {
localTimeZone := longitude / 15
localJD := currentJD + localTimeZone/24 - timeZone/24
targetAltitude := StandardAltitudeMoon(zenithShift, height, latitude)
legacyJD := moonRiseSetNewtonRaphsonIteration(localJD, longitude, latitude, localTimeZone,
targetAltitude, legacyHMoonHeight, 0.00002)
if math.IsNaN(legacyJD) || math.IsInf(legacyJD, 0) {
return 0, fmt.Errorf("legacy height iteration did not converge")
}
return legacyJD - localTimeZone/24 + timeZone/24, nil
}
func legacyHMoonHeight(jd, longitude, latitude, timeZone float64) float64 {
calculationJD := TD2UT(jd-timeZone/24, true)
ra, dec := HMoonTrueRaDecN(calculationJD, -1)
distanceAU := HMoonAwayN(calculationJD, -1) / 149597870.7
topocentricRA, topocentricDec := legacyTopocentricRaDec(ra, dec, latitude, longitude, calculationJD, distanceAU, 0)
siderealTime := tools.Limit360(ApparentSiderealTime(jd-timeZone/24)*15 + longitude)
hourAngle := tools.Limit360(siderealTime - topocentricRA)
altitudeSine := tools.Sin(latitude)*tools.Sin(topocentricDec) +
tools.Cos(topocentricDec)*tools.Cos(latitude)*tools.Cos(hourAngle)
return tools.ArcSin(altitudeSine)
}
func legacyTopocentricRaDec(ra, dec, latitude, longitude, jd, distanceAU, height float64) (float64, float64) {
horizontalParallaxSine := tools.Sin(0.0024427777777) / distanceAU
observerCosine := pcosi(latitude, height)
observerSine := psini(latitude, height)
hourAngle := tools.Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + longitude - ra)
raCorrection := math.Atan2(-observerCosine*horizontalParallaxSine*tools.Sin(hourAngle),
tools.Cos(dec)-observerCosine*horizontalParallaxSine*tools.Cos(hourAngle)) * 180 / math.Pi
correctedDec := math.Atan2((tools.Sin(dec)-observerSine*horizontalParallaxSine)*tools.Cos(raCorrection),
tools.Cos(dec)-observerCosine*horizontalParallaxSine*tools.Cos(hourAngle)) * 180 / math.Pi
return ra + raCorrection, correctedDec
}
+583 -538
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -173,14 +173,14 @@ func NeptuneCulminationTime(jde, lon, timezone float64) float64 {
}
return currentHourAngle
}
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return hourAngleDelta / hourAngleSlope
})
if !ok {
return math.NaN()
}
return estimateJD
}
+33 -9
View File
@@ -64,6 +64,9 @@ func neptuneRADerivativeN(jde, delta float64, n int) float64 {
func neptuneConjunctionFull(jde, degree float64, next uint8) float64 {
//0=last 1=next
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
return math.NaN()
}
daysPerDegree := NEPTUNE_S_PERIOD / 360
currentDelta := neptuneSunLongitudeDelta(jde, degree, false)
if next == 0 {
@@ -72,20 +75,29 @@ func neptuneConjunctionFull(jde, degree float64, next uint8) float64 {
jde += daysPerDegree * currentDelta
}
estimateJD := jde
for {
converged := false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJD := estimateJD
longitudeDelta := neptuneSunLongitudeDelta(prevJD, degree, true)
longitudeSlope := (neptuneSunLongitudeDelta(prevJD+0.000005, degree, true) - neptuneSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
estimateJD = prevJD - longitudeDelta/longitudeSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
nextJD := prevJD - longitudeDelta/longitudeSlope
estimateJD = nextJD
if math.Abs(nextJD-prevJD) <= 0.00001 {
converged = true
break
}
}
if !converged {
return math.NaN()
}
return TD2UT(estimateJD, false)
}
func neptuneConjunction(jde, degree float64, next uint8) float64 {
//0=last 1=next
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
return math.NaN()
}
daysPerDegree := NEPTUNE_S_PERIOD / 360
currentDelta := neptuneSunLongitudeDelta(jde, degree, false)
if next == 0 {
@@ -94,24 +106,36 @@ func neptuneConjunction(jde, degree float64, next uint8) float64 {
jde += daysPerDegree * currentDelta
}
estimateJD := jde
for {
converged := false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJD := estimateJD
longitudeDelta := neptuneSunLongitudeDeltaN(prevJD, degree, true, neptuneEventSearchN)
longitudeSlope := (neptuneSunLongitudeDeltaN(prevJD+0.000005, degree, true, neptuneEventSearchN) - neptuneSunLongitudeDeltaN(prevJD-0.000005, degree, true, neptuneEventSearchN)) / 0.00001
estimateJD = prevJD - longitudeDelta/longitudeSlope
if math.Abs(estimateJD-prevJD) <= neptunePhaseCoarseTolerance {
nextJD := prevJD - longitudeDelta/longitudeSlope
estimateJD = nextJD
if math.Abs(nextJD-prevJD) <= neptunePhaseCoarseTolerance {
converged = true
break
}
}
for {
if !converged {
return math.NaN()
}
converged = false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJD := estimateJD
longitudeDelta := neptuneSunLongitudeDelta(prevJD, degree, true)
longitudeSlope := (neptuneSunLongitudeDelta(prevJD+0.000005, degree, true) - neptuneSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
estimateJD = prevJD - longitudeDelta/longitudeSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
nextJD := prevJD - longitudeDelta/longitudeSlope
estimateJD = nextJD
if math.Abs(nextJD-prevJD) <= 0.00001 {
converged = true
break
}
}
if !converged {
return math.NaN()
}
return TD2UT(estimateJD, false)
}
+518
View File
@@ -0,0 +1,518 @@
package basic
import (
"errors"
"fmt"
"math"
"strings"
"time"
)
// ErrInvalidOccultationInput 表示月掩输入契约无效。
// ErrInvalidOccultationInput reports invalid lunar-occultation contract input.
var ErrInvalidOccultationInput = errors.New("invalid lunar occultation input")
// ErrOccultationPathSamplingLimit 表示请求的时间步长、中心线间距或有限盘面采样超过确定性的工作量或输出预算。
// ErrOccultationPathSamplingLimit reports that the requested time step, center-line spacing, or aggregate finite-disk sampling would exceed the implementation's deterministic work or output budget.
var ErrOccultationPathSamplingLimit = errors.New("lunar occultation path sampling limit exceeded")
const (
occultationSearchMinimumStep = 250 * time.Millisecond
occultationPathMinimumStep = time.Second
occultationPathMinimumTargetSpacingKM = 1.0
occultationEventSelectionTolerance = 10 * time.Millisecond
occultationEventSelectionToleranceDays = float64(occultationEventSelectionTolerance) / float64(24*time.Hour)
)
// CoordinateFrame 标识恒星输入坐标使用的赤道坐标系。
// CoordinateFrame identifies the equatorial coordinate frame used by an input stellar coordinate.
type CoordinateFrame string
const (
// CoordinateFrameICRS 表示 ICRS 星表坐标系。
// CoordinateFrameICRS is the ICRS catalog frame.
CoordinateFrameICRS CoordinateFrame = "icrs"
// CoordinateFrameJ2000 表示 J2000 平均赤道坐标系。
// CoordinateFrameJ2000 is the mean equatorial J2000 frame.
CoordinateFrameJ2000 CoordinateFrame = "j2000"
// CoordinateFrameApparentOfDate 表示历元时刻的视赤道坐标系。
// CoordinateFrameApparentOfDate is the apparent equatorial frame of date.
CoordinateFrameApparentOfDate CoordinateFrame = "apparent_of_date"
)
// OccultationType 标识月掩结果的几何类型。
// OccultationType identifies the result geometry.
type OccultationType string
const (
// OccultationTotal 表示掩甚时目标盘面被完全覆盖。
// OccultationTotal means the target disk is fully covered at greatest occultation.
OccultationTotal OccultationType = "total"
// OccultationPartial 表示掩甚时有限目标盘面只有部分被覆盖。
// OccultationPartial means only part of a finite target disk is covered at greatest occultation.
OccultationPartial OccultationType = "partial"
// OccultationGrazing 表示两边缘相切,且没有正持续时间的重叠。
// OccultationGrazing means the limbs are tangent without a positive-duration overlap.
OccultationGrazing OccultationType = "grazing"
)
// OccultationPlanet 标识有限盘面的行星目标。
// OccultationPlanet identifies a finite-disk planetary target.
type OccultationPlanet string
const (
// OccultationMercury 表示水星有限盘面目标。
// OccultationMercury identifies Mercury as the finite-disk target.
OccultationMercury OccultationPlanet = "mercury"
// OccultationVenus 表示金星有限盘面目标。
// OccultationVenus identifies Venus as the finite-disk target.
OccultationVenus OccultationPlanet = "venus"
// OccultationMars 表示火星有限盘面目标。
// OccultationMars identifies Mars as the finite-disk target.
OccultationMars OccultationPlanet = "mars"
// OccultationJupiter 表示木星有限盘面目标。
// OccultationJupiter identifies Jupiter as the finite-disk target.
OccultationJupiter OccultationPlanet = "jupiter"
// OccultationSaturn 表示土星有限盘面目标。
// OccultationSaturn identifies Saturn as the finite-disk target.
OccultationSaturn OccultationPlanet = "saturn"
// OccultationUranus 表示天王星有限盘面目标。
// OccultationUranus identifies Uranus as the finite-disk target.
OccultationUranus OccultationPlanet = "uranus"
// OccultationNeptune 表示海王星有限盘面目标。
// OccultationNeptune identifies Neptune as the finite-disk target.
OccultationNeptune OccultationPlanet = "neptune"
)
// String 返回结果标识中使用的英文目标名称。
// String returns the English target name used in result identifiers.
func (p OccultationPlanet) String() string {
switch p {
case OccultationMercury:
return "Mercury"
case OccultationVenus:
return "Venus"
case OccultationMars:
return "Mars"
case OccultationJupiter:
return "Jupiter"
case OccultationSaturn:
return "Saturn"
case OccultationUranus:
return "Uranus"
case OccultationNeptune:
return "Neptune"
default:
return ""
}
}
// Validate 检查行星目标是否受支持。
// Validate checks whether the planetary target is supported.
func (p OccultationPlanet) Validate() error {
if p.String() == "" {
return fmt.Errorf("%w: unsupported occultation planet %q", ErrInvalidOccultationInput, p)
}
return nil
}
func validateOccultationTimeRange(start, end time.Time) error {
if start.IsZero() || end.IsZero() {
return fmt.Errorf("%w: start and end are required", ErrInvalidOccultationInput)
}
if !end.After(start) {
return fmt.Errorf("%w: end must be after start", ErrInvalidOccultationInput)
}
return nil
}
func occultationTimeInSelectionWindow(value, start, end time.Time) bool {
return value.Sub(start) >= -occultationEventSelectionTolerance &&
value.Sub(end) <= occultationEventSelectionTolerance
}
// Observer 描述站心观测地点。
// Observer describes the topocentric observing site.
type Observer struct {
// Longitude 是经度,东经为正,单位为度。
// Longitude is east-positive, in degrees.
Longitude float64
// Latitude 是纬度,北纬为正,单位为度。
// Latitude is north-positive, in degrees.
Latitude float64
// Height 是观测者相对平均海平面的高度,单位为米。
// Height is the observer elevation above mean sea level, in meters.
Height float64
}
// moonTopocentricSemidiameterN 返回指定地点看到的月球角半径。
// 通用 MoonSemidiameterN 使用地心距离;月掩接触使用同一站心视差修正后的观测者到月球距离。
// moonTopocentricSemidiameterN returns the lunar angular radius as seen from the supplied site.
// The usual MoonSemidiameterN uses geocentric distance; occultation contacts use the observer-to-Moon distance after the same topocentric parallax correction as the direction.
func moonTopocentricSemidiameterN(tt float64, observer Observer, n int) float64 {
moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, n)
moonDistanceKM := HMoonAwayN(tt, n)
if !finite(moonRA) || !finite(moonDec) || !finite(moonDistanceKM) || moonDistanceKM <= 0 {
return math.NaN()
}
distanceKM := topocentricDistanceKM(moonRA, moonDec, moonDistanceKM, observer, TD2UT(tt, false))
if !finite(distanceKM) || distanceKM <= 0 {
return math.NaN()
}
return angularSemidiameterArcsec(moonEquatorialRadiusKM, distanceKM)
}
// topocentricDistanceKM 使用与 TopocentricRaDec 相同的 WGS-84 风格站点因子计算观测者到目标的距离 /
// The target is supplied in apparent equatorial coordinates, and the sidereal angle uses UTC/UT like TopocentricRaDec.
func topocentricDistanceKM(ra, dec, distanceKM float64, observer Observer, ut float64) float64 {
const earthEquatorialRadius = 6378.14
const astronomicalUnitKM = angularDiameterAstronomicalUnitKM
distanceAU := distanceKM / astronomicalUnitKM
if distanceAU <= 0 {
return math.NaN()
}
raRad := ra * math.Pi / 180
decRad := dec * math.Pi / 180
moon := [3]float64{
distanceAU * math.Cos(decRad) * math.Cos(raRad),
distanceAU * math.Cos(decRad) * math.Sin(raRad),
distanceAU * math.Sin(decRad),
}
theta := (ApparentSiderealTime(ut)*15 + observer.Longitude) * math.Pi / 180
observerAU := earthEquatorialRadius / astronomicalUnitKM
observerVector := [3]float64{
observerAU * pcosi(observer.Latitude, observer.Height) * math.Cos(theta),
observerAU * pcosi(observer.Latitude, observer.Height) * math.Sin(theta),
observerAU * psini(observer.Latitude, observer.Height),
}
dx := moon[0] - observerVector[0]
dy := moon[1] - observerVector[1]
dz := moon[2] - observerVector[2]
return math.Sqrt(dx*dx+dy*dy+dz*dz) * astronomicalUnitKM
}
// Validate 检查站心计算所需的地理范围。
// Validate checks the geographic bounds needed by topocentric calculations.
func (o Observer) Validate() error {
if !finite(o.Longitude) || !finite(o.Latitude) || !finite(o.Height) {
return fmt.Errorf("%w: observer values must be finite", ErrInvalidOccultationInput)
}
if o.Longitude < -180 || o.Longitude > 180 {
return fmt.Errorf("%w: observer longitude must be in [-180, 180]", ErrInvalidOccultationInput)
}
if o.Latitude < -90 || o.Latitude > 90 {
return fmt.Errorf("%w: observer latitude must be in [-90, 90]", ErrInvalidOccultationInput)
}
return nil
}
// StarCoordinate 是调用者为恒星提供的星表坐标或视位置坐标。
// RA 和 Dec 的单位为度;ProperMotionRACosDecMasPerYear 使用星表常见的 dRA*cos(Dec) 约定,单位为毫角秒/年。
// StarCoordinate is a catalog or apparent coordinate supplied for a star.
// RA and Dec are degrees; ProperMotionRACosDecMasPerYear uses the usual catalog convention of dRA*cos(Dec), in milliarcseconds per year.
type StarCoordinate struct {
ID string
RA float64
Dec float64
Epoch time.Time
Frame CoordinateFrame
ProperMotionRACosDecMasPerYear float64
ProperMotionDecMasPerYear float64
ParallaxMas float64
}
// Validate 在构造目标前检查恒星坐标契约。
// Validate checks the coordinate contract before a target is constructed.
func (s StarCoordinate) Validate() error {
if !finite(s.RA) || s.RA < 0 || s.RA >= 360 {
return fmt.Errorf("%w: star RA must be in [0, 360)", ErrInvalidOccultationInput)
}
if !finite(s.Dec) || s.Dec < -90 || s.Dec > 90 {
return fmt.Errorf("%w: star Dec must be in [-90, 90]", ErrInvalidOccultationInput)
}
if s.Epoch.IsZero() {
return fmt.Errorf("%w: star epoch is required", ErrInvalidOccultationInput)
}
if !validCoordinateFrame(s.Frame) {
return fmt.Errorf("%w: unsupported star coordinate frame %q", ErrInvalidOccultationInput, s.Frame)
}
if !finite(s.ProperMotionRACosDecMasPerYear) || !finite(s.ProperMotionDecMasPerYear) {
return fmt.Errorf("%w: star proper motion must be finite", ErrInvalidOccultationInput)
}
if !finite(s.ParallaxMas) || s.ParallaxMas < 0 {
return fmt.Errorf("%w: star parallax must be finite and non-negative", ErrInvalidOccultationInput)
}
return nil
}
// StarCoordinateFromStarData 将一条内嵌星表记录转换为月掩搜索使用的 J2000 坐标契约。
// 星表自行从角秒/年转换为毫角秒/年;正的秒差距距离转换为毫角秒年视差。本函数只转换传入值,不会加载星表。
// StarCoordinateFromStarData converts one embedded-catalog entry into the J2000 coordinate contract used by lunar-occultation searches.
// The catalog's proper motions are converted from arcseconds/year to milliarcseconds/year; a positive parsec distance is converted to annual parallax in milliarcseconds.
// This function only converts the supplied value and never loads the catalog.
func StarCoordinateFromStarData(star StarData) (StarCoordinate, error) {
if star.HR == 0 {
return StarCoordinate{}, fmt.Errorf("%w: star catalog HR number is required", ErrInvalidOccultationInput)
}
if !finite(star.Pc) || star.Pc < 0 {
return StarCoordinate{}, fmt.Errorf("%w: star distance must be finite and non-negative", ErrInvalidOccultationInput)
}
parallaxMas := 0.0
if star.Pc > 0 {
parallaxMas = 1000 / star.Pc
}
coordinate := StarCoordinate{
ID: starCoordinateIDFromStarData(star),
RA: star.Ra,
Dec: star.Dec,
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
Frame: CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: star.PmRA * 1000,
ProperMotionDecMasPerYear: star.PmDec * 1000,
ParallaxMas: parallaxMas,
}
if err := coordinate.Validate(); err != nil {
return StarCoordinate{}, fmt.Errorf("convert star catalog coordinate: %w", err)
}
return coordinate, nil
}
func starCoordinateIDFromStarData(star StarData) string {
for _, name := range []string{star.ChineseName, star.ChineseAlias, star.CommonName, star.Name} {
if name = strings.TrimSpace(name); name != "" {
return name
}
}
if star.HR > 0 {
return fmt.Sprintf("HR %d", star.HR)
}
return ""
}
// OccultationSearchOptions 控制固定目标和行星月掩搜索。
// 零值使用实现默认值;MaxEvents == 0 表示不限制数量。
// OccultationSearchOptions controls fixed-target and planetary occultation searches.
// Zero values select implementation defaults; MaxEvents == 0 means unlimited.
type OccultationSearchOptions struct {
// MaxStep 是粗略搜索的最大步长;小于 250ms 的正值会被拒绝,因为在支持的时间范围内无法可靠地用儒略日浮点数推进。
// MaxStep is the maximum coarse-search step. Positive values below 250 ms are rejected because they cannot be advanced reliably in Julian-day floating-point arithmetic over the supported time span.
MaxStep time.Duration
// SafetyMarginArcsec 是加入粗略候选和黄纬预筛的安全余量,单位为角秒。
// SafetyMarginArcsec is added to coarse candidate and latitude prefilters.
SafetyMarginArcsec float64
// MaxEvents 为正时限制返回事件数量。
// MaxEvents limits the number of returned events when positive.
MaxEvents int
}
// OccultationPathOptions 控制全球月掩路径采样。
//
// Step 为路径采样的基础时间步长,正值至少为 1 秒。TargetSpacingKM 要求相邻中心线点超过目标地面距离时进行自适应加密。
// 正的 TargetSpacingKM 至少为 1 km;超过中心线或有限盘面路径工作量预算时返回 ErrOccultationPathSamplingLimit,不会静默降低请求分辨率。行星瞬时足迹使用结果中说明的独立有界采样策略。
// OccultationPathOptions controls global occultation-path sampling.
// Step is the base time step used for path samples; positive values must be at least one second. TargetSpacingKM requests adaptive refinement when adjacent center-line points exceed the requested ground distance.
// Positive TargetSpacingKM values must be at least 1 km. Requests that exceed the center-line or aggregate finite-disk work budgets return ErrOccultationPathSamplingLimit instead of silently reducing resolution. Planetary instantaneous footprints have a separate bounded sampling policy documented on the result.
type OccultationPathOptions struct {
Step time.Duration
TargetSpacingKM float64
}
// Validate 检查全球路径采样选项。
// Validate checks global path sampling options.
func (o OccultationPathOptions) Validate() error {
if o.Step < 0 {
return fmt.Errorf("%w: path step cannot be negative", ErrInvalidOccultationInput)
}
if o.Step > 0 && o.Step < occultationPathMinimumStep {
return fmt.Errorf("%w: path step must be zero or at least %s", ErrInvalidOccultationInput, occultationPathMinimumStep)
}
if !finite(o.TargetSpacingKM) || o.TargetSpacingKM < 0 {
return fmt.Errorf("%w: path target spacing must be finite and non-negative", ErrInvalidOccultationInput)
}
if o.TargetSpacingKM > 0 && o.TargetSpacingKM < occultationPathMinimumTargetSpacingKM {
return fmt.Errorf("%w: path target spacing must be zero or at least %.0f km", ErrInvalidOccultationInput, occultationPathMinimumTargetSpacingKM)
}
return nil
}
// Validate 检查选项值,但不选择算法专用默认值。
// Validate checks option values without selecting algorithm-specific defaults.
func (o OccultationSearchOptions) Validate() error {
if o.MaxStep < 0 {
return fmt.Errorf("%w: search max step cannot be negative", ErrInvalidOccultationInput)
}
if o.MaxStep > 0 && o.MaxStep < occultationSearchMinimumStep {
return fmt.Errorf("%w: search max step must be zero or at least %s", ErrInvalidOccultationInput, occultationSearchMinimumStep)
}
if !finite(o.SafetyMarginArcsec) || o.SafetyMarginArcsec < 0 {
return fmt.Errorf("%w: search safety margin must be finite and non-negative", ErrInvalidOccultationInput)
}
if o.MaxEvents < 0 {
return fmt.Errorf("%w: search max events cannot be negative", ErrInvalidOccultationInput)
}
return nil
}
// StarOccultationInfo 描述点光源恒星月掩;掩始和掩终是月缘交点。
// StarOccultationInfo describes a point-source stellar occultation. The immersion and emersion times are the Moon-limb crossings.
type StarOccultationInfo struct {
TargetID string
Observer Observer
Type OccultationType
Immersion time.Time
Greatest time.Time
Emersion time.Time
// ContactsComplete 表示两个月缘接触时刻均已求解。
// ContactsComplete is true when both lunar-limb contacts were solved.
ContactsComplete bool
MinimumSeparationArcsec float64
PositionAngleDeg float64
MoonSemidiameterArcsec float64
MoonAltitudeAtGreatest float64
MoonAzimuthAtGreatest float64
VisibleAtGreatest bool
}
// PlanetOccultationInfo 描述有限盘面行星月掩。
// ExternalImmersion 和 ExternalEmersion 分别是 C1 和 C4;全掩事件的 InternalImmersion 和 InternalEmersion 分别是 C2 和 C3,偏掩和掠掩时为零。
// ContactsComplete 表示报告几何适用的所有接触均已求解;目标按赤道半径建模为圆盘,环、大气延伸和扁率不在模型内。
// PlanetOccultationInfo describes a finite-disk planetary occultation.
// ExternalImmersion and ExternalEmersion are C1 and C4. For a total event, InternalImmersion and InternalEmersion are C2 and C3; they are zero for partial and grazing events.
// ContactsComplete means every contact applicable to the reported geometry was solved. The target is modeled as a circular disk using its equatorial body radius; rings, atmospheric extensions, and oblateness are outside this contact model.
type PlanetOccultationInfo struct {
Planet OccultationPlanet
TargetID string
Observer Observer
Type OccultationType
ExternalImmersion time.Time
InternalImmersion time.Time
Greatest time.Time
InternalEmersion time.Time
ExternalEmersion time.Time
HasInternalContacts bool
ContactsComplete bool
MinimumSeparationArcsec float64
PositionAngleDeg float64
MoonSemidiameterArcsec float64
PlanetSemidiameterArcsec float64
MoonAltitudeAtGreatest float64
MoonAzimuthAtGreatest float64
VisibleAtGreatest bool
}
// OccultationPathPoint 是全球月掩路径上的一个地理采样点。
// Start 和 End 描述月缘外接触掩带;WidthKM 是垂直地面轨迹方向的切平面宽度,仅对中心线采样点有意义。
// 基础采样直接求解,自适应插入点使用宽度插值并进行五米采样误差检查。
// OccultationPathPoint is a geographic sample of a global lunar-occultation path.
// Start and End describe the outer lunar-limb footprint. WidthKM is the local tangent-plane width perpendicular to the ground track and is meaningful only on center-line samples.
// Base samples are solved directly; adaptive samples use width interpolation and five-meter error checks.
type OccultationPathPoint struct {
Time time.Time
Longitude float64
Latitude float64
MoonAltitude float64
WidthKM float64
}
// StarOccultationPath 包含点光源恒星月掩的全球掩带。
// 中心线是月心与恒星对齐的轨迹;NorthernLimit 和 SouthernLimit 是中心线两侧采样的月缘外边界。
// StarOccultationPath contains the global footprint of a point-source stellar occultation.
// The center line is the locus where the lunar center aligns with the star; NorthernLimit and SouthernLimit are the two outer lunar-limb boundaries sampled beside that line.
type StarOccultationPath struct {
TargetID string
Start OccultationPathPoint
Greatest OccultationPathPoint
End OccultationPathPoint
// Complete 表示 Start 和 End 是全球月缘外接触点,而不是查询窗口裁剪点。
// Complete is true when Start and End are the global outer-limb contacts rather than query-window clipping points.
Complete bool
CenterLine []OccultationPathPoint
NorthernLimit []OccultationPathPoint
SouthernLimit []OccultationPathPoint
Step time.Duration
TargetSpacingKM float64
}
// PlanetOccultationFootprint 是一个时刻的可见接触足迹。
// Polygons 包含接触锥圆弧;当锥面与椭球的交线在朝月半球开放时,沿月球地平线闭合。
// PlanetOccultationFootprint is one instantaneous visible contact footprint.
// Polygons contain contact-cone arcs closed along the lunar horizon when the cone/ellipsoid intersection is open on the Moon-facing hemisphere.
type PlanetOccultationFootprint struct {
Time time.Time
Polygons [][]OccultationPathPoint
}
// PlanetOccultationPath 包含有限盘面行星月掩的全球掩带。
// NorthernLimit 和 SouthernLimit 是行星盘面任意部分被覆盖的外接触边界。
// HasTotalBand 为 true 时,NorthernTotalLimit 和 SouthernTotalLimit 是行星圆盘完全被月球覆盖的内接触边界;
// 环、大气延伸和扁率不在两种接触模型内。
// PlanetOccultationPath contains the global footprint of a finite-disk planetary occultation.
// NorthernLimit and SouthernLimit are the outer-contact boundaries where any part of the planet disk is covered.
// When HasTotalBand is true, NorthernTotalLimit and SouthernTotalLimit are the inner-contact boundaries where the complete circular planet disk is covered by the Moon. Rings, atmospheric extensions, and oblateness are outside both contact models.
type PlanetOccultationPath struct {
Planet OccultationPlanet
TargetID string
Start OccultationPathPoint
Greatest OccultationPathPoint
End OccultationPathPoint
// Complete 表示 Start 和 End 是全球外接触点。
// Complete is true when Start and End are the global outer contacts.
Complete bool
CenterLine []OccultationPathPoint
NorthernLimit []OccultationPathPoint
SouthernLimit []OccultationPathPoint
// PartialFootprints 是时刻采样的可见外接触区域,其扫掠构成全球偏掩区域;为限制输出和运行时间,采样可能比 Step 更粗,
// 每个足迹携带实际采样时刻。
// PartialFootprints are instantaneous visible outer-contact regions whose sweep forms the global partial-occultation area. To bound output and runtime, sampling may be coarser than Step; each footprint carries its actual sample time.
PartialFootprints []PlanetOccultationFootprint
HasTotalBand bool
// TotalStart 和 TotalEnd 是全球内接触的起止点。
// TotalStart and TotalEnd are the first and last global inner contacts.
TotalStart OccultationPathPoint
TotalEnd OccultationPathPoint
// TotalComplete 表示 TotalStart 和 TotalEnd 未被内部搜索范围截断。
// TotalComplete is true when TotalStart and TotalEnd are not clipped by the internal search span.
TotalComplete bool
NorthernTotalLimit []OccultationPathPoint
SouthernTotalLimit []OccultationPathPoint
// TotalFootprints 是时刻采样的可见内接触区域,其扫掠构成全球全掩区域;采样使用与 PartialFootprints 相同的有界策略。
// TotalFootprints are instantaneous visible inner-contact regions whose sweep forms the global full-coverage area. Their sampling uses the same bounded policy as PartialFootprints.
TotalFootprints []PlanetOccultationFootprint
// GreatestTotalWidthKM 是全球掩甚时的全掩带宽度。
// GreatestTotalWidthKM is the full-coverage band width at global greatest.
GreatestTotalWidthKM float64
Step time.Duration
TargetSpacingKM float64
}
func finite(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
func validCoordinateFrame(frame CoordinateFrame) bool {
switch frame {
case CoordinateFrameICRS, CoordinateFrameJ2000, CoordinateFrameApparentOfDate:
return true
default:
return false
}
}
File diff suppressed because it is too large Load Diff
+502
View File
@@ -0,0 +1,502 @@
package basic
import (
"math"
"sort"
"time"
)
const (
planetOccultationDefaultStepDays = 0.25
planetOccultationCandidateLimitArcsec = 10 * 3600.0
planetOccultationLatitudeMarginArcsec = 3600.0
planetOccultationContactStepDays = 10.0 / 1440.0
planetOccultationContactSpanDays = 2.0
planetOccultationGrazingToleranceArcsec = 0.01
planetOccultationRootToleranceDays = occultationEventSelectionToleranceDays
planetOccultationMaxContactSteps = 10000
)
type planetOccultationConfig struct {
planet OccultationPlanet
equatorialRadiusKM float64
apparentRaDecN func(float64, int) (float64, float64)
earthDistanceN func(float64, int) float64
semidiameterN func(float64, int) float64
}
type planetMoonPosition struct {
moonRA, moonDec float64
planetRA, planetDec float64
valid bool
}
type planetOccultationState struct {
position planetMoonPosition
separationArcsec float64
moonSemidiameter float64
planetSemidiameter float64
externalContactMetric float64
internalContactMetric float64
valid bool
}
// FindPlanetOccultations 搜索固定观测点的有限盘面行星月掩。
// 经度东为正、纬度北为正,单位为度;高度为平均海平面以上米数。目标位置、视差和视半径会在每次候选、掩甚和接触计算时重新计算。
// FindPlanetOccultations searches one finite-disk planet at a fixed observing site.
// Longitude is east-positive in degrees, latitude is north-positive in degrees, and height is the observer elevation above mean sea level in meters. The target position, parallax, and semidiameter are recomputed at every candidate, greatest, and contact evaluation.
func FindPlanetOccultations(start, end time.Time, planet OccultationPlanet, longitude, latitude, height float64,
options OccultationSearchOptions) ([]PlanetOccultationInfo, error) {
if err := validateOccultationTimeRange(start, end); err != nil {
return nil, err
}
if err := planet.Validate(); err != nil {
return nil, err
}
observer := Observer{Longitude: longitude, Latitude: latitude, Height: height}
if err := observer.Validate(); err != nil {
return nil, err
}
if err := options.Validate(); err != nil {
return nil, err
}
config, _ := planetOccultationConfigFor(planet)
startTT := occultationTimeToTT(start)
endTT := occultationTimeToTT(end)
resultLocation := start.Location()
candidates := planetOccultationCandidateGreatestTimes(
startTT, endTT, planetOccultationCoarseStepDays(options), config, &observer, options.SafetyMarginArcsec,
)
results := make([]PlanetOccultationInfo, 0, len(candidates))
for _, greatestTT := range candidates {
info, ok := planetOccultationInfoAtGreatest(greatestTT, config, observer, options.SafetyMarginArcsec, resultLocation)
if !ok {
continue
}
if len(results) == 0 || math.Abs(results[len(results)-1].Greatest.Sub(info.Greatest).Seconds()) > 60 {
results = append(results, info)
if options.MaxEvents > 0 && len(results) >= options.MaxEvents {
break
}
}
}
sort.SliceStable(results, func(i, j int) bool { return results[i].Greatest.Before(results[j].Greatest) })
return results, nil
}
// FindBestPlanetOccultations 返回窗口内每次有限盘面行星月掩的全球海平面几何掩甚点。
// 地心数据只用于搜索初值;返回位置与 FindPlanetOccultationPaths 一致,地平线可见性只报告、不参与点选择。距离查询端点 10 ms 内的掩甚时刻也会包含,与数值根精度一致。
// FindBestPlanetOccultations returns the global geometric greatest point at sea level for every finite-disk planetary occultation in the window.
// Geocentric data only seeds the search. The returned location matches FindPlanetOccultationPaths; horizon visibility is reported but does not select the point. A greatest instant within 10 ms of either query endpoint is included, matching the numerical root precision.
func FindBestPlanetOccultations(start, end time.Time, planet OccultationPlanet,
options OccultationSearchOptions) ([]PlanetOccultationInfo, error) {
if err := validateOccultationTimeRange(start, end); err != nil {
return nil, err
}
if err := planet.Validate(); err != nil {
return nil, err
}
if err := options.Validate(); err != nil {
return nil, err
}
config, _ := planetOccultationConfigFor(planet)
startTT := occultationTimeToTT(start)
endTT := occultationTimeToTT(end)
selectionStartTT := startTT - occultationEventSelectionToleranceDays
selectionEndTT := endTT + occultationEventSelectionToleranceDays
candidateStartTT := startTT - occultationPathSearchSpanDays
candidateEndTT := endTT + occultationPathSearchSpanDays
resultLocation := start.Location()
candidates := planetOccultationCandidateGreatestTimes(
candidateStartTT, candidateEndTT, planetOccultationCoarseStepDays(options), config, nil, options.SafetyMarginArcsec,
)
results := make([]PlanetOccultationInfo, 0, len(candidates))
for _, seedTT := range candidates {
greatestTT, observer, _, observerOK := planetOccultationBestObserver(seedTT, selectionStartTT, selectionEndTT, config)
if !observerOK {
continue
}
info, ok := planetOccultationInfoAtGreatest(greatestTT, config, observer, options.SafetyMarginArcsec, resultLocation)
if !ok {
continue
}
if len(results) == 0 || math.Abs(results[len(results)-1].Greatest.Sub(info.Greatest).Seconds()) > 60 {
results = append(results, info)
if options.MaxEvents > 0 && len(results) >= options.MaxEvents {
break
}
}
}
sort.SliceStable(results, func(i, j int) bool { return results[i].Greatest.Before(results[j].Greatest) })
return results, nil
}
func planetOccultationConfigFor(planet OccultationPlanet) (planetOccultationConfig, bool) {
config := planetOccultationConfig{planet: planet}
switch planet {
case OccultationMercury:
config.equatorialRadiusKM = mercuryEquatorialRadiusKM
config.apparentRaDecN = MercuryApparentRaDecN
config.earthDistanceN = EarthMercuryAwayN
config.semidiameterN = MercurySemidiameterN
case OccultationVenus:
config.equatorialRadiusKM = venusEquatorialRadiusKM
config.apparentRaDecN = VenusApparentRaDecN
config.earthDistanceN = EarthVenusAwayN
config.semidiameterN = VenusSemidiameterN
case OccultationMars:
config.equatorialRadiusKM = marsEquatorialRadiusKM
config.apparentRaDecN = MarsApparentRaDecN
config.earthDistanceN = EarthMarsAwayN
config.semidiameterN = MarsSemidiameterN
case OccultationJupiter:
config.equatorialRadiusKM = jupiterEquatorialRadiusKM
config.apparentRaDecN = JupiterApparentRaDecN
config.earthDistanceN = EarthJupiterAwayN
config.semidiameterN = JupiterSemidiameterN
case OccultationSaturn:
config.equatorialRadiusKM = saturnEquatorialRadiusKM
config.apparentRaDecN = SaturnApparentRaDecN
config.earthDistanceN = EarthSaturnAwayN
config.semidiameterN = SaturnSemidiameterN
case OccultationUranus:
config.equatorialRadiusKM = uranusEquatorialRadiusKM
config.apparentRaDecN = UranusApparentRaDecN
config.earthDistanceN = EarthUranusAwayN
config.semidiameterN = UranusSemidiameterN
case OccultationNeptune:
config.equatorialRadiusKM = neptuneEquatorialRadiusKM
config.apparentRaDecN = NeptuneApparentRaDecN
config.earthDistanceN = EarthNeptuneAwayN
config.semidiameterN = NeptuneSemidiameterN
default:
return planetOccultationConfig{}, false
}
return config, true
}
func planetOccultationCandidateGreatestTimes(
startTT, endTT, step float64,
config planetOccultationConfig,
observer *Observer,
safetyMarginArcsec float64,
) []float64 {
if endTT <= startTT {
return nil
}
duration := endTT - startTT
if step > duration/4 {
step = math.Max(duration/4, 0.25/86400.0)
}
step = math.Max(step, 0.25/86400.0)
scanStart := startTT - step
scanEnd := endTT + step
leftTT := scanStart
centerTT := math.Min(leftTT+step, scanEnd)
leftValue := planetOccultationExternalContactMetric(leftTT, config, observer, 8)
centerValue := planetOccultationExternalContactMetric(centerTT, config, observer, 8)
results := make([]float64, 0)
for centerTT < scanEnd {
rightTT := math.Min(centerTT+step, scanEnd)
rightValue := planetOccultationExternalContactMetric(rightTT, config, observer, 8)
candidateLimit := planetOccultationCandidateLimitArcsec + safetyMarginArcsec
if finite(leftValue) && finite(centerValue) && finite(rightValue) &&
centerValue <= leftValue && centerValue <= rightValue && centerValue <= candidateLimit {
// 最小外接触度量同时包含动态月面和行星盘面,因此定义事件是否存在以及报告的掩甚时刻。
// The minimum outer-contact metric includes both dynamic disks and therefore defines event existence and the reported greatest instant.
greatestTT := planetOccultationMinimizeExternalMetric(leftTT, rightTT, config, observer)
if greatestTT >= startTT && greatestTT <= endTT &&
planetOccultationLatitudePass(greatestTT, config, observer, safetyMarginArcsec) {
if len(results) == 0 || math.Abs(greatestTT-results[len(results)-1]) > 60.0/86400.0 {
results = append(results, greatestTT)
}
}
}
leftTT, leftValue = centerTT, centerValue
centerTT, centerValue = rightTT, rightValue
}
return results
}
func planetOccultationMinimizeExternalMetric(left, right float64, config planetOccultationConfig, observer *Observer) float64 {
if right <= left {
return left
}
const goldenRatio = 0.6180339887498949
x1 := right - goldenRatio*(right-left)
x2 := left + goldenRatio*(right-left)
f1 := planetOccultationExternalContactMetric(x1, config, observer, -1)
f2 := planetOccultationExternalContactMetric(x2, config, observer, -1)
for i := 0; i < 64 && right-left > planetOccultationRootToleranceDays; i++ {
if f1 > f2 {
left = x1
x1, f1 = x2, f2
x2 = left + goldenRatio*(right-left)
f2 = planetOccultationExternalContactMetric(x2, config, observer, -1)
} else {
right = x2
x2, f2 = x1, f1
x1 = right - goldenRatio*(right-left)
f1 = planetOccultationExternalContactMetric(x1, config, observer, -1)
}
}
return (left + right) / 2
}
func planetOccultationBestObserver(seedTT, startTT, endTT float64, config planetOccultationConfig) (float64, Observer, float64, bool) {
frameAt := func(tt float64) (occultationPathFrame, bool) {
return planetOccultationPathFrameAt(tt, config)
}
searchStart := seedTT - occultationPathSearchSpanDays
searchEnd := seedTT + occultationPathSearchSpanDays
outerStart, outerEnd, ok := occultationPathWindowForFrame(seedTT, searchStart, searchEnd, frameAt, false)
if !ok {
return 0, Observer{}, 0, false
}
greatestTT := occultationPathGreatestForFrame(seedTT, outerStart, outerEnd, frameAt)
if greatestTT < startTT || greatestTT > endTT {
return 0, Observer{}, 0, false
}
point, pointOK := occultationPathCenterPointForFrame(greatestTT, frameAt, time.UTC)
if !pointOK {
point, pointOK = occultationPathBoundaryPointForFrame(greatestTT, frameAt, time.UTC)
}
if !pointOK {
return 0, Observer{}, 0, false
}
observer := Observer{Longitude: point.Longitude, Latitude: point.Latitude}
state := planetOccultationStateAt(greatestTT, config, &observer, -1)
if !state.valid {
return 0, Observer{}, 0, false
}
return greatestTT, observer, state.externalContactMetric, true
}
func planetOccultationInfoAtGreatest(
greatestTT float64,
config planetOccultationConfig,
observer Observer,
safetyMarginArcsec float64,
location *time.Location,
) (PlanetOccultationInfo, bool) {
if !planetOccultationLatitudePass(greatestTT, config, &observer, safetyMarginArcsec) {
return PlanetOccultationInfo{}, false
}
state := planetOccultationStateAt(greatestTT, config, &observer, -1)
if !state.valid || state.externalContactMetric > 0 {
return PlanetOccultationInfo{}, false
}
info := PlanetOccultationInfo{
Planet: config.planet,
TargetID: config.planet.String(),
Observer: observer,
Type: OccultationPartial,
Greatest: occultationTTToLocation(greatestTT, location),
MinimumSeparationArcsec: state.separationArcsec,
PositionAngleDeg: occultationPositionAngle(state.position.moonRA, state.position.moonDec, state.position.planetRA, state.position.planetDec),
MoonSemidiameterArcsec: state.moonSemidiameter,
PlanetSemidiameterArcsec: state.planetSemidiameter,
MoonAltitudeAtGreatest: occultationAltitude(greatestTT, observer, state.position.moonRA, state.position.moonDec),
MoonAzimuthAtGreatest: occultationAzimuth(greatestTT, observer, state.position.moonRA, state.position.moonDec),
}
info.VisibleAtGreatest = info.MoonAltitudeAtGreatest >= 0
if math.Abs(state.externalContactMetric) <= planetOccultationGrazingToleranceArcsec {
info.Type = OccultationGrazing
info.ExternalImmersion = info.Greatest
info.ExternalEmersion = info.Greatest
info.ContactsComplete = true
return info, true
}
externalImmersionTT, externalImmersionOK := planetOccultationContact(greatestTT, -1, false, config, observer)
externalEmersionTT, externalEmersionOK := planetOccultationContact(greatestTT, 1, false, config, observer)
if !externalImmersionOK || !externalEmersionOK || externalEmersionTT <= externalImmersionTT {
return PlanetOccultationInfo{}, false
}
info.ExternalImmersion = occultationTTToLocation(externalImmersionTT, location)
info.ExternalEmersion = occultationTTToLocation(externalEmersionTT, location)
if state.internalContactMetric < -planetOccultationGrazingToleranceArcsec {
internalImmersionTT, internalImmersionOK := planetOccultationContact(greatestTT, -1, true, config, observer)
internalEmersionTT, internalEmersionOK := planetOccultationContact(greatestTT, 1, true, config, observer)
if !internalImmersionOK || !internalEmersionOK ||
internalImmersionTT <= externalImmersionTT || internalEmersionTT >= externalEmersionTT ||
internalImmersionTT >= greatestTT || internalEmersionTT <= greatestTT {
return PlanetOccultationInfo{}, false
}
info.Type = OccultationTotal
info.InternalImmersion = occultationTTToLocation(internalImmersionTT, location)
info.InternalEmersion = occultationTTToLocation(internalEmersionTT, location)
info.HasInternalContacts = true
}
info.ContactsComplete = true
return info, true
}
func planetMoonPositionAt(tt float64, config planetOccultationConfig, observer *Observer, n int) planetMoonPosition {
moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, n)
planetRA, planetDec := config.apparentRaDecN(tt, n)
if observer != nil {
ut := TD2UT(tt, false)
moonDistanceAU := HMoonAwayN(tt, n) / angularDiameterAstronomicalUnitKM
planetDistanceAU := config.earthDistanceN(tt, n)
moonRA, moonDec = TopocentricRaDec(moonRA, moonDec, observer.Latitude, observer.Longitude, ut, moonDistanceAU, observer.Height)
planetRA, planetDec = TopocentricRaDec(planetRA, planetDec, observer.Latitude, observer.Longitude, ut, planetDistanceAU, observer.Height)
moonRA = normalizeRA(moonRA)
planetRA = normalizeRA(planetRA)
}
return planetMoonPosition{
moonRA: moonRA,
moonDec: moonDec,
planetRA: planetRA,
planetDec: planetDec,
valid: finite(moonRA) && finite(moonDec) && finite(planetRA) && finite(planetDec),
}
}
func planetOccultationStateAt(tt float64, config planetOccultationConfig, observer *Observer, n int) planetOccultationState {
position := planetMoonPositionAt(tt, config, observer, n)
moonRadius := MoonSemidiameterN(tt, n)
planetRadius := config.semidiameterN(tt, n)
if observer != nil {
moonRadius = moonTopocentricSemidiameterN(tt, *observer, n)
planetRadius = planetTopocentricSemidiameterN(tt, config, *observer, n)
}
if !position.valid || !finite(moonRadius) || !finite(planetRadius) || moonRadius <= planetRadius || planetRadius <= 0 {
return planetOccultationState{}
}
separation := angularSeparationDegrees(position.moonRA, position.moonDec, position.planetRA, position.planetDec) * 3600
return planetOccultationState{
position: position,
separationArcsec: separation,
moonSemidiameter: moonRadius,
planetSemidiameter: planetRadius,
externalContactMetric: separation - (moonRadius + planetRadius),
internalContactMetric: separation - (moonRadius - planetRadius),
valid: finite(separation),
}
}
func planetTopocentricSemidiameterN(tt float64, config planetOccultationConfig, observer Observer, n int) float64 {
ra, dec := config.apparentRaDecN(tt, n)
distanceKM := config.earthDistanceN(tt, n) * angularDiameterAstronomicalUnitKM
if !finite(ra) || !finite(dec) || !finite(distanceKM) || distanceKM <= 0 {
return math.NaN()
}
distanceKM = topocentricDistanceKM(ra, dec, distanceKM, observer, TD2UT(tt, false))
if !finite(distanceKM) || distanceKM <= config.equatorialRadiusKM {
return math.NaN()
}
return angularSemidiameterArcsec(config.equatorialRadiusKM, distanceKM)
}
func planetOccultationExternalContactMetric(tt float64, config planetOccultationConfig, observer *Observer, n int) float64 {
state := planetOccultationStateAt(tt, config, observer, n)
if !state.valid {
return math.Inf(1)
}
return state.externalContactMetric
}
func planetMoonSeparationArcsec(tt float64, config planetOccultationConfig, observer *Observer, n int) float64 {
position := planetMoonPositionAt(tt, config, observer, n)
if !position.valid {
return math.Inf(1)
}
return angularSeparationDegrees(position.moonRA, position.moonDec, position.planetRA, position.planetDec) * 3600
}
func planetOccultationLatitudePass(tt float64, config planetOccultationConfig, observer *Observer, safetyMarginArcsec float64) bool {
state := planetOccultationStateAt(tt, config, observer, -1)
if !state.valid {
return false
}
_, moonLatitude := RaDecToLoBo(tt, state.position.moonRA, state.position.moonDec)
_, planetLatitude := RaDecToLoBo(tt, state.position.planetRA, state.position.planetDec)
limit := state.moonSemidiameter + state.planetSemidiameter + planetOccultationLatitudeMarginArcsec + safetyMarginArcsec
return math.Abs(moonLatitude-planetLatitude)*3600 <= limit
}
func planetOccultationContact(
greatestTT float64,
direction int,
internal bool,
config planetOccultationConfig,
observer Observer,
) (float64, bool) {
if direction != -1 && direction != 1 {
return math.NaN(), false
}
metric := func(tt float64) float64 {
state := planetOccultationStateAt(tt, config, &observer, -1)
if !state.valid {
return math.NaN()
}
if internal {
return state.internalContactMetric
}
return state.externalContactMetric
}
nearTT := greatestTT
nearValue := metric(nearTT)
if !finite(nearValue) || nearValue > 0 {
return math.NaN(), false
}
maxSteps := int(math.Ceil(planetOccultationContactSpanDays / planetOccultationContactStepDays))
if maxSteps > planetOccultationMaxContactSteps {
maxSteps = planetOccultationMaxContactSteps
}
for i := 1; i <= maxSteps; i++ {
farTT := greatestTT + float64(direction*i)*planetOccultationContactStepDays
farValue := metric(farTT)
if !finite(farValue) {
continue
}
if farValue >= 0 {
return planetOccultationRoot(nearTT, farTT, nearValue, farValue, metric)
}
nearTT, nearValue = farTT, farValue
}
return math.NaN(), false
}
func planetOccultationRoot(
leftTT, rightTT, leftValue, rightValue float64,
metric func(float64) float64,
) (float64, bool) {
if leftTT > rightTT {
leftTT, rightTT = rightTT, leftTT
leftValue, rightValue = rightValue, leftValue
}
if !finite(leftValue) || !finite(rightValue) || leftValue*rightValue > 0 {
return math.NaN(), false
}
for i := 0; i < 64 && math.Abs(rightTT-leftTT) > planetOccultationRootToleranceDays; i++ {
midTT := (leftTT + rightTT) / 2
midValue := metric(midTT)
if !finite(midValue) {
return math.NaN(), false
}
if leftValue*midValue <= 0 {
rightTT, rightValue = midTT, midValue
} else {
leftTT, leftValue = midTT, midValue
}
}
return (leftTT + rightTT) / 2, true
}
func planetOccultationCoarseStepDays(options OccultationSearchOptions) float64 {
step := planetOccultationDefaultStepDays
if options.MaxStep > 0 {
requested := options.MaxStep.Hours() / 24
if requested > 0 && requested < step {
step = requested
}
}
return math.Max(step, occultationSearchMinimumStep.Hours()/24)
}
+321
View File
@@ -0,0 +1,321 @@
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
}
+112
View File
@@ -0,0 +1,112 @@
package basic
import (
"math"
"testing"
"time"
)
func TestPlanetOccultationDiagramSaturnIncludesFiveDynamicDiskStages(t *testing.T) {
location := time.FixedZone("UTC+8", 8*3600)
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, location)
end := start.Add(24 * time.Hour)
events, err := FindPlanetOccultations(
start, end, OccultationSaturn, 104.52219613, 55.25401991, 0, OccultationSearchOptions{},
)
if err != nil {
t.Fatalf("FindPlanetOccultations() error = %v", err)
}
if len(events) != 1 {
t.Fatalf("FindPlanetOccultations() returned %d events, want 1", len(events))
}
diagram := PlanetOccultationDiagram(events[0], PlanetOccultationDiagramOptions{})
if len(diagram.Frames) < 5 {
t.Fatalf("PlanetOccultationDiagram() returned %d frames, want at least 5", len(diagram.Frames))
}
frames := make(map[string]PlanetOccultationDiagramFrame)
for _, frame := range diagram.Frames {
for _, label := range frame.Labels {
frames[label] = frame
}
}
for _, label := range []string{"C1", "C2", "Greatest", "C3", "C4"} {
if _, ok := frames[label]; !ok {
t.Fatalf("diagram is missing %s", label)
}
}
for _, label := range []string{"C1", "C4"} {
frame := frames[label]
residual := frame.SeparationArcsec - frame.MoonRadiusArcsec - frame.PlanetRadiusArcsec
if math.Abs(residual) > 0.1 {
t.Errorf("%s outer-contact residual = %.6f arcsec", label, residual)
}
}
for _, label := range []string{"C2", "C3"} {
frame := frames[label]
residual := frame.SeparationArcsec - frame.MoonRadiusArcsec + frame.PlanetRadiusArcsec
if math.Abs(residual) > 0.1 {
t.Errorf("%s inner-contact residual = %.6f arcsec", label, residual)
}
}
if !frames["Greatest"].FullyOcculted {
t.Fatal("greatest frame does not report a fully occulted Saturn disk")
}
}
func TestPlanetOccultationDiagramRejectsIncompleteEvent(t *testing.T) {
result := PlanetOccultationDiagram(PlanetOccultationInfo{Planet: OccultationSaturn}, PlanetOccultationDiagramOptions{})
if len(result.Frames) != 0 {
t.Fatalf("invalid event returned %d frames, want none", len(result.Frames))
}
}
func TestPlanetOccultationDiagramRejectsTargetGeometryMismatch(t *testing.T) {
location := time.FixedZone("UTC+8", 8*3600)
events, err := FindPlanetOccultations(
time.Date(2025, time.February, 1, 0, 0, 0, 0, location),
time.Date(2025, time.February, 2, 0, 0, 0, 0, location),
OccultationSaturn, 104.52219613, 55.25401991, 0, OccultationSearchOptions{},
)
if err != nil || len(events) != 1 {
t.Fatalf("FindPlanetOccultations() = %d events, %v; want one", len(events), err)
}
// TargetID 只是显示元数据,但改变建模行星必须 / TargetID is display metadata, but changing the modeled planet must
// 使事件几何失效,而不是静默绘制另一个天体 / invalidate the event geometry rather than silently drawing another body.
event := events[0]
event.Planet = OccultationVenus
event.TargetID = "Venus"
result := PlanetOccultationDiagram(event, PlanetOccultationDiagramOptions{})
if len(result.Frames) != 0 {
t.Fatalf("diagram accepted mismatched planet and returned %d frames", len(result.Frames))
}
}
func TestPlanetOccultationDiagramPartialEventHasOnlyExternalStages(t *testing.T) {
start := time.Date(2024, time.August, 21, 1, 30, 0, 0, time.UTC)
end := time.Date(2024, time.August, 21, 4, 0, 0, 0, time.UTC)
events, err := FindPlanetOccultations(
start, end, OccultationSaturn, -30.072, -6.5, 0, OccultationSearchOptions{},
)
if err != nil || len(events) != 1 {
t.Fatalf("FindPlanetOccultations() = %d events, %v; want one", len(events), err)
}
if events[0].Type != OccultationPartial {
t.Fatalf("event type = %q, want partial", events[0].Type)
}
diagram := PlanetOccultationDiagram(events[0], PlanetOccultationDiagramOptions{})
labels := make(map[string]bool)
for _, frame := range diagram.Frames {
for _, label := range frame.Labels {
labels[label] = true
}
}
for _, label := range []string{"C1", "Greatest", "C4"} {
if !labels[label] {
t.Fatalf("partial diagram is missing %s", label)
}
}
if labels["C2"] || labels["C3"] {
t.Fatalf("partial diagram contains internal contacts: %+v", labels)
}
}
+188
View File
@@ -0,0 +1,188 @@
package basic
import (
"math"
"time"
)
const (
planetOccultationFootprintBoundaryPoints = 180
planetOccultationHorizonPoints = 360
planetOccultationFootprintMaxSamples = 360
)
type planetOccultationFootprintSample struct {
point OccultationPathPoint
ok bool
}
func planetOccultationFootprints(
startTT, endTT, greatestTT float64,
frameAt occultationPathFrameFunc,
options OccultationPathOptions,
location *time.Location,
) []PlanetOccultationFootprint {
stepDays := float64(options.Step) / float64(24*time.Hour)
times := occultationPathSampleTimesWithLimit(
startTT, endTT, greatestTT, stepDays, planetOccultationFootprintMaxSamples,
)
footprints := make([]PlanetOccultationFootprint, 0, len(times))
for _, tt := range times {
footprint, ok := planetOccultationFootprintAt(tt, frameAt, location)
if ok {
footprints = append(footprints, footprint)
}
}
return footprints
}
func planetOccultationFootprintAt(
tt float64,
frameAt occultationPathFrameFunc,
location *time.Location,
) (PlanetOccultationFootprint, bool) {
frame, ok := frameAt(tt)
if !ok {
return PlanetOccultationFootprint{}, false
}
samples := make([]planetOccultationFootprintSample, planetOccultationFootprintBoundaryPoints)
for index := range samples {
theta := 2 * math.Pi * float64(index) / float64(len(samples))
vector, _, valid := occultationPathBoundaryVector(frame, theta)
if valid {
samples[index] = planetOccultationFootprintSample{
point: occultationPathPointFromVector(tt, vector, 0, location),
ok: true,
}
}
}
segments, closed := planetOccultationFootprintSegments(samples)
polygons := make([][]OccultationPathPoint, 0, len(segments))
for _, segment := range segments {
if len(segment) < 2 {
continue
}
polygon := append([]OccultationPathPoint(nil), segment...)
if closed {
polygon = append(polygon, polygon[0])
} else {
polygon = append(polygon, planetOccultationHorizonArc(tt, frame, segment[len(segment)-1], segment[0], location)...)
}
if len(polygon) >= 4 {
polygons = append(polygons, polygon)
}
}
if len(polygons) == 0 {
return PlanetOccultationFootprint{}, false
}
return PlanetOccultationFootprint{
Time: occultationTTToLocation(tt, location),
Polygons: polygons,
}, true
}
func planetOccultationFootprintSegments(
samples []planetOccultationFootprintSample,
) ([][]OccultationPathPoint, bool) {
segments := make([][]OccultationPathPoint, 0, 2)
current := make([]OccultationPathPoint, 0, len(samples))
allValid := len(samples) > 0
for _, sample := range samples {
if !sample.ok {
allValid = false
if len(current) > 0 {
segments = append(segments, current)
current = nil
}
continue
}
current = append(current, sample.point)
}
if len(current) > 0 {
segments = append(segments, current)
}
if len(segments) > 1 && samples[0].ok && samples[len(samples)-1].ok {
first := segments[0]
last := segments[len(segments)-1]
merged := make([]OccultationPathPoint, 0, len(last)+len(first))
merged = append(merged, last...)
merged = append(merged, first...)
segments[0] = merged
segments = segments[:len(segments)-1]
}
return segments, allValid && len(segments) == 1
}
func planetOccultationHorizonArc(
tt float64,
frame occultationPathFrame,
from, to OccultationPathPoint,
location *time.Location,
) []OccultationPathPoint {
sublunarLongitude, sublunarLatitude := occultationPathGeodetic(tt, frame.moon)
circle := planetOccultationSphericalCircle(
occultationTTToLocation(tt, location), sublunarLongitude, sublunarLatitude,
90, planetOccultationHorizonPoints,
)
fromIndex := planetOccultationNearestPointIndex(circle, from)
toIndex := planetOccultationNearestPointIndex(circle, to)
forwardSteps := (toIndex - fromIndex + len(circle)) % len(circle)
backwardSteps := (fromIndex - toIndex + len(circle)) % len(circle)
direction := 1
steps := forwardSteps
if backwardSteps < forwardSteps {
direction = -1
steps = backwardSteps
}
arc := make([]OccultationPathPoint, 0, steps+1)
for step := 1; step < steps; step++ {
index := (fromIndex + direction*step) % len(circle)
if index < 0 {
index += len(circle)
}
arc = append(arc, circle[index])
}
return append(arc, to)
}
func planetOccultationSphericalCircle(
value time.Time,
centerLongitude, centerLatitude, radius float64,
count int,
) []OccultationPathPoint {
centerLongitude *= math.Pi / 180
centerLatitude *= math.Pi / 180
radius *= math.Pi / 180
points := make([]OccultationPathPoint, count)
for index := range points {
bearing := 2 * math.Pi * float64(index) / float64(count)
latitude := math.Asin(
math.Sin(centerLatitude)*math.Cos(radius) +
math.Cos(centerLatitude)*math.Sin(radius)*math.Cos(bearing),
)
longitude := centerLongitude + math.Atan2(
math.Sin(bearing)*math.Sin(radius)*math.Cos(centerLatitude),
math.Cos(radius)-math.Sin(centerLatitude)*math.Sin(latitude),
)
points[index] = OccultationPathPoint{
Time: value,
Longitude: normalizeLongitude(longitude * 180 / math.Pi),
Latitude: latitude * 180 / math.Pi,
}
}
return points
}
func planetOccultationNearestPointIndex(points []OccultationPathPoint, target OccultationPathPoint) int {
nearest := 0
distance := math.Inf(1)
for index, point := range points {
candidate := occultationPathDistanceKM(point, target)
if candidate < distance {
nearest = index
distance = candidate
}
}
return nearest
}
+116
View File
@@ -0,0 +1,116 @@
package basic
import (
"errors"
"math"
"testing"
"time"
)
func TestPlanetOccultationSaturnFootprintsContainCenterLine(t *testing.T) {
config, ok := planetOccultationConfigFor(OccultationSaturn)
if !ok {
t.Fatal("Saturn occultation config is unavailable")
}
seedTT := occultationTimeToTT(time.Date(2025, time.February, 1, 4, 0, 48, 0, time.UTC))
for _, contact := range []struct {
name string
frameAt occultationPathFrameFunc
}{
{name: "partial", frameAt: func(tt float64) (occultationPathFrame, bool) {
return planetOccultationPathFrameAt(tt, config)
}},
{name: "total", frameAt: func(tt float64) (occultationPathFrame, bool) {
return planetOccultationTotalPathFrameAt(tt, config)
}},
} {
startTT, endTT, found := occultationPathWindowForFrame(
seedTT, seedTT-occultationPathSearchSpanDays, seedTT+occultationPathSearchSpanDays,
contact.frameAt, true,
)
if !found {
t.Fatalf("%s center interval is unavailable", contact.name)
}
checked := 0
for tt := startTT + 2.0/1440.0; tt < endTT-2.0/1440.0; tt += 5.0 / 1440.0 {
frame, frameOK := contact.frameAt(tt)
center, _, centerOK := occultationEarthLineIntersection(frame.moon, frame.axis)
footprint, footprintOK := planetOccultationFootprintAt(tt, contact.frameAt, time.UTC)
if !frameOK || !centerOK || !footprintOK {
t.Fatalf("%s center or footprint is unavailable at TT %.9f", contact.name, tt)
}
longitude, latitude := occultationPathGeodetic(tt, center)
if !planetOccultationFootprintContains(footprint, longitude, latitude) {
t.Fatalf("%s footprint does not contain center %.6f, %.6f at TT %.9f",
contact.name, longitude, latitude, tt)
}
checked++
}
if checked < 10 {
t.Fatalf("%s checked only %d center samples", contact.name, checked)
}
}
}
func TestPlanetOccultationPathRejectsExcessiveAggregateSampling(t *testing.T) {
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
_, err := FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), OccultationSaturn,
OccultationPathOptions{Step: time.Second},
)
if !errors.Is(err, ErrOccultationPathSamplingLimit) {
t.Fatalf("FindPlanetOccultationPaths() error = %v, want ErrOccultationPathSamplingLimit", err)
}
}
func TestPlanetOccultationFootprintsHaveIndependentSampleBudget(t *testing.T) {
times := occultationPathSampleTimesWithLimit(
0, 1, 0.500001, 1.0/86400.0, planetOccultationFootprintMaxSamples,
)
if len(times) > planetOccultationFootprintMaxSamples {
t.Fatalf("footprint sample count = %d, maximum %d", len(times), planetOccultationFootprintMaxSamples)
}
foundGreatest := false
for _, sample := range times {
if sample == 0.500001 {
foundGreatest = true
break
}
}
if !foundGreatest {
t.Fatal("bounded footprint samples omitted greatest")
}
}
func planetOccultationFootprintContains(
footprint PlanetOccultationFootprint,
longitude, latitude float64,
) bool {
for _, polygon := range footprint.Polygons {
inside := false
for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 {
currentX := math.Remainder(polygon[current].Longitude-longitude, 360)
previousX := math.Remainder(polygon[previous].Longitude-longitude, 360)
currentY := polygon[current].Latitude
previousY := polygon[previous].Latitude
if math.Abs(currentX-previousX) > 180 {
if currentX < previousX {
currentX += 360
} else {
previousX += 360
}
}
if (currentY > latitude) == (previousY > latitude) {
continue
}
intersectionX := previousX + (latitude-previousY)*(currentX-previousX)/(currentY-previousY)
if intersectionX > 0 {
inside = !inside
}
}
if inside {
return true
}
}
return false
}
+895
View File
@@ -0,0 +1,895 @@
package basic
import (
"math"
"sort"
"time"
)
const planetOccultationPathMaxTemporalSamples = 5000
type occultationPathFrameFunc func(float64) (occultationPathFrame, bool)
// 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) {
if err := validateOccultationTimeRange(start, end); err != nil {
return nil, err
}
if err := planet.Validate(); err != nil {
return nil, err
}
if err := options.Validate(); err != nil {
return nil, err
}
config, _ := planetOccultationConfigFor(planet)
options = normalizeOccultationPathOptions(options)
startTT := occultationTimeToTT(start)
endTT := occultationTimeToTT(end)
candidateStartTT := startTT - occultationPathSearchSpanDays
candidateEndTT := endTT + occultationPathSearchSpanDays
candidates := planetOccultationCandidateGreatestTimes(
candidateStartTT, candidateEndTT, planetOccultationDefaultStepDays, config, nil, 0,
)
paths := make([]PlanetOccultationPath, 0, len(candidates))
for _, seedTT := range candidates {
path, ok, err := planetOccultationPathAtSeed(seedTT, config, options, start.Location())
if err != nil {
return nil, err
}
if !ok {
continue
}
if !occultationTimeInSelectionWindow(path.Greatest.Time, start, end) {
continue
}
if len(paths) > 0 && math.Abs(paths[len(paths)-1].Greatest.Time.Sub(path.Greatest.Time).Seconds()) <= 60 {
continue
}
paths = append(paths, path)
}
sort.SliceStable(paths, func(i, j int) bool {
return paths[i].Greatest.Time.Before(paths[j].Greatest.Time)
})
return paths, nil
}
func planetOccultationPathAtSeed(
seedTT float64,
config planetOccultationConfig,
options OccultationPathOptions,
location *time.Location,
) (PlanetOccultationPath, bool, error) {
frameAt := func(tt float64) (occultationPathFrame, bool) {
return planetOccultationPathFrameAt(tt, config)
}
totalFrameAt := func(tt float64) (occultationPathFrame, bool) {
return planetOccultationTotalPathFrameAt(tt, config)
}
searchStart := seedTT - occultationPathSearchSpanDays
searchEnd := seedTT + occultationPathSearchSpanDays
outerStart, outerEnd, ok := occultationPathWindowForFrame(seedTT, searchStart, searchEnd, frameAt, false)
if !ok {
return PlanetOccultationPath{}, false, nil
}
centerStart, centerEnd, hasCenter := occultationPathWindowForFrame(seedTT, searchStart, searchEnd, frameAt, true)
greatestTT := occultationPathGreatestForFrame(seedTT, outerStart, outerEnd, frameAt)
greatest, greatestOK := occultationPathCenterPointForFrame(greatestTT, frameAt, location)
if !greatestOK && hasCenter {
greatestTT = math.Max(centerStart, math.Min(centerEnd, greatestTT))
greatest, greatestOK = occultationPathCenterPointForFrame(greatestTT, frameAt, location)
}
if !greatestOK {
greatest, greatestOK = occultationPathBoundaryPointForFrame(greatestTT, frameAt, location)
}
if !greatestOK {
return PlanetOccultationPath{}, false, nil
}
_, _, greatestWidth, greatestWidthOK := occultationPathLimitsAndWidthForFrame(greatestTT, frameAt)
if !greatestWidthOK || greatestWidth <= 0 {
return PlanetOccultationPath{}, false, nil
}
// 仅有边界的事件没有影轴与椭球交点;原回退点使用纬度极值弦宽,全掩带使用下方的地面横向宽度。统一两种接触带宽度定义,使有限盘面内外接触宽度可比较。
// Boundary-only events do not have an axis/ellipsoid intersection. Their fallback point used to carry a latitude-extrema chord width, while total bands used the ground cross-track width below. Keep both contact bands on the same width definition so finite-disk inner/outer widths are comparable.
greatest.WidthKM = greatestWidth
totalStartTT, totalEndTT, hasTotal := occultationPathWindowForFrame(
seedTT, searchStart, searchEnd, totalFrameAt, false,
)
hasTotal = hasTotal && greatestTT >= totalStartTT && greatestTT <= totalEndTT
if planetOccultationPathTemporalSampleCount(
outerStart, outerEnd, centerStart, centerEnd, hasCenter,
totalStartTT, totalEndTT, hasTotal, greatestTT, options,
) > planetOccultationPathMaxTemporalSamples {
return PlanetOccultationPath{}, false, ErrOccultationPathSamplingLimit
}
start := occultationPathBoundaryEndpointForFrame(outerStart, frameAt, location, 1)
end := occultationPathBoundaryEndpointForFrame(outerEnd, frameAt, location, -1)
if !start.valid || !end.valid {
return PlanetOccultationPath{}, false, nil
}
centerLine, northern, southern, err := planetOccultationPathSamples(
outerStart, outerEnd, centerStart, centerEnd, hasCenter, greatestTT, frameAt, options, location,
)
if err != nil {
return PlanetOccultationPath{}, false, err
}
path := PlanetOccultationPath{
Planet: config.planet,
TargetID: config.planet.String(),
Start: start.point,
Greatest: greatest,
End: end.point,
Complete: outerStart > searchStart && outerEnd < searchEnd,
CenterLine: centerLine,
NorthernLimit: occultationPathWithEndpoints(start.point, end.point, northern),
SouthernLimit: occultationPathWithEndpoints(start.point, end.point, southern),
Step: options.Step,
TargetSpacingKM: options.TargetSpacingKM,
}
path.PartialFootprints = planetOccultationFootprints(
outerStart, outerEnd, greatestTT, frameAt, options, location,
)
if !hasTotal {
return path, true, nil
}
totalStart := occultationPathBoundaryEndpointForFrame(totalStartTT, totalFrameAt, location, 1)
totalEnd := occultationPathBoundaryEndpointForFrame(totalEndTT, totalFrameAt, location, -1)
_, _, totalWidth, totalWidthOK := occultationPathLimitsAndWidthForFrame(greatestTT, totalFrameAt)
if !totalStart.valid || !totalEnd.valid || !totalWidthOK || totalWidth <= 0 {
return path, true, nil
}
totalNorthern, totalSouthern := occultationPathBoundarySamplesForFrame(
totalStartTT, totalEndTT, greatestTT, totalFrameAt, options, location,
)
if len(totalNorthern) == 0 || len(totalSouthern) == 0 {
return path, true, nil
}
path.HasTotalBand = true
path.TotalStart = totalStart.point
path.TotalEnd = totalEnd.point
path.TotalComplete = totalStartTT > searchStart && totalEndTT < searchEnd
path.NorthernTotalLimit = occultationPathWithEndpoints(totalStart.point, totalEnd.point, totalNorthern)
path.SouthernTotalLimit = occultationPathWithEndpoints(totalStart.point, totalEnd.point, totalSouthern)
path.TotalFootprints = planetOccultationFootprints(
totalStartTT, totalEndTT, greatestTT, totalFrameAt, options, location,
)
path.GreatestTotalWidthKM = totalWidth
return path, true, nil
}
func planetOccultationPathTemporalSampleCount(
outerStartTT, outerEndTT float64,
centerStartTT, centerEndTT float64,
hasCenter bool,
totalStartTT, totalEndTT float64,
hasTotal bool,
greatestTT float64,
options OccultationPathOptions,
) int {
stepDays := float64(options.Step) / float64(24*time.Hour)
count := len(occultationPathSampleTimes(outerStartTT, outerEndTT, greatestTT, stepDays))
count += len(occultationPathSampleTimesWithLimit(
outerStartTT, outerEndTT, greatestTT, stepDays, planetOccultationFootprintMaxSamples,
))
if hasCenter {
count += len(occultationPathSampleTimes(centerStartTT, centerEndTT, greatestTT, stepDays))
}
if hasTotal {
count += len(occultationPathSampleTimes(totalStartTT, totalEndTT, greatestTT, stepDays))
count += len(occultationPathSampleTimesWithLimit(
totalStartTT, totalEndTT, greatestTT, stepDays, planetOccultationFootprintMaxSamples,
))
}
return count
}
func occultationPathWindowForFrame(
seedTT, startTT, endTT float64,
frameAt occultationPathFrameFunc,
center bool,
) (float64, float64, bool) {
left := math.Max(startTT, seedTT-occultationPathSearchSpanDays)
right := math.Min(endTT, seedTT+occultationPathSearchSpanDays)
if right <= left {
return 0, 0, false
}
predicate := func(tt float64) bool {
frame, ok := frameAt(tt)
if !ok {
return false
}
if center {
_, _, ok = occultationEarthLineIntersection(frame.moon, frame.axis)
return ok
}
return occultationPathFrameHasBoundary(frame)
}
step := occultationPathRangeStepDays
first := math.NaN()
previous := left
previousOK := predicate(previous)
if previousOK {
first = previous
} else {
for tt := left + step; tt <= right; tt += step {
current := math.Min(tt, right)
currentOK := predicate(current)
if currentOK {
first = occultationPathRefineTransition(previous, current, predicate, false)
break
}
previous = current
previousOK = currentOK
}
}
if math.IsNaN(first) {
return 0, 0, false
}
last := first
previous = first
previousOK = true
for tt := first + step; tt <= right; tt += step {
current := math.Min(tt, right)
currentOK := predicate(current)
if !currentOK {
last = occultationPathRefineTransition(previous, current, predicate, true)
return first, last, true
}
last = current
previous = current
previousOK = currentOK
}
if previousOK {
last = right
}
return first, last, true
}
func occultationPathGreatestForFrame(seedTT, startTT, endTT float64, frameAt occultationPathFrameFunc) float64 {
left := math.Max(startTT, seedTT-0.75)
right := math.Min(endTT, seedTT+0.75)
if right <= left {
return seedTT
}
impact := func(tt float64) float64 {
frame, ok := frameAt(tt)
if !ok {
return math.Inf(1)
}
return math.Hypot(frame.moonProjectionX(), frame.moonProjectionY())
}
const goldenRatio = 0.6180339887498949
x1 := right - goldenRatio*(right-left)
x2 := left + goldenRatio*(right-left)
f1 := impact(x1)
f2 := impact(x2)
for i := 0; i < 56; i++ {
if f1 > f2 {
left = x1
x1, f1 = x2, f2
x2 = left + goldenRatio*(right-left)
f2 = impact(x2)
} else {
right = x2
x2, f2 = x1, f1
x1 = right - goldenRatio*(right-left)
f1 = impact(x1)
}
}
return (left + right) / 2
}
func planetOccultationPathSamples(
outerStartTT, outerEndTT float64,
centerStartTT, centerEndTT float64,
hasCenter bool,
greatestTT float64,
frameAt 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)
if err != nil {
return nil, nil, nil, err
}
}
northern, southern := occultationPathBoundarySamplesForFrame(
outerStartTT, outerEndTT, greatestTT, frameAt, options, location,
)
return centerLine, northern, southern, nil
}
func occultationPathBoundarySamplesForFrame(
startTT, endTT, greatestTT float64,
frameAt occultationPathFrameFunc,
options OccultationPathOptions,
location *time.Location,
) ([]OccultationPathPoint, []OccultationPathPoint) {
stepDays := float64(options.Step) / float64(24*time.Hour)
times := occultationPathSampleTimes(startTT, endTT, greatestTT, stepDays)
samples := make([]occultationPathBoundaryPairSample, 0, len(times))
for _, tt := range times {
firstVector, secondVector, ok := occultationPathCrossTrackLimitsForFrame(tt, frameAt)
if !ok {
continue
}
sample := occultationPathBoundaryPairSample{tt: tt, first: firstVector, second: secondVector}
if len(samples) == 0 {
samples = append(samples, sample)
continue
}
samples = appendOccultationPathBoundaryPairSegment(samples, samples[len(samples)-1], sample, frameAt, 0)
}
first := make([]OccultationPathPoint, len(samples))
second := make([]OccultationPathPoint, len(samples))
for index, sample := range samples {
first[index] = occultationPathPointFromVector(sample.tt, sample.first, 0, location)
second[index] = occultationPathPointFromVector(sample.tt, sample.second, 0, location)
}
return occultationPathOrientBoundarySamples(first, second, greatestTT)
}
type occultationPathBoundaryPairSample struct {
tt float64
first, second occultationPathVector
}
func appendOccultationPathBoundaryPairSegment(
samples []occultationPathBoundaryPairSample,
start, end occultationPathBoundaryPairSample,
frameAt occultationPathFrameFunc,
depth int,
) []occultationPathBoundaryPairSample {
if depth >= occultationPathMaxAdaptiveDepth ||
occultationPathBoundaryPairSpacing(start, end) <= occultationPathBoundarySpacingKM {
return append(samples, end)
}
midTT := (start.tt + end.tt) / 2
first, second, ok := occultationPathCrossTrackLimitsForFrame(midTT, frameAt)
if !ok {
return append(samples, end)
}
mid := occultationPathBoundaryPairSample{tt: midTT, first: first, second: second}
samples = appendOccultationPathBoundaryPairSegment(samples, start, mid, frameAt, depth+1)
return appendOccultationPathBoundaryPairSegment(samples, mid, end, frameAt, depth+1)
}
func occultationPathBoundaryPairSpacing(first, second occultationPathBoundaryPairSample) float64 {
firstA := occultationPathEarthFixedVector(first.tt, first.first)
firstB := occultationPathEarthFixedVector(first.tt, first.second)
secondA := occultationPathEarthFixedVector(second.tt, second.first)
secondB := occultationPathEarthFixedVector(second.tt, second.second)
direct := math.Max(
occultationPathNorm(occultationPathSub(secondA, firstA)),
occultationPathNorm(occultationPathSub(secondB, firstB)),
)
swapped := math.Max(
occultationPathNorm(occultationPathSub(secondB, firstA)),
occultationPathNorm(occultationPathSub(secondA, firstB)),
)
return math.Min(direct, swapped)
}
func occultationPathCrossTrackLimitsForFrame(
tt float64,
frameAt occultationPathFrameFunc,
) (occultationPathVector, occultationPathVector, bool) {
frame, ok := frameAt(tt)
before, beforeOK := frameAt(tt - occultationPathVelocityStepDays)
after, afterOK := frameAt(tt + occultationPathVelocityStepDays)
if !ok || !beforeOK || !afterOK {
return occultationPathVector{}, occultationPathVector{}, false
}
vx := after.moonProjectionX() - before.moonProjectionX()
vy := after.moonProjectionY() - before.moonProjectionY()
if math.Hypot(vx, vy) <= 1e-12 {
return occultationPathVector{}, occultationPathVector{}, false
}
// 复用日食中心线构造:取基准面中垂直于运动方向的两条影锥母线。点源掠过阶段将缺失母线限制到可见角度区间;有限目标使用最近可见区间两端,直到两条横向母线分别与地球相交。
// Match the solar-eclipse central-path construction: take the two shadow generators perpendicular to motion in the fundamental plane. During a grazing point-source phase, clamp a missing generator to the visible-angle interval. For a finite target, use both ends of the nearest visible interval until the two cross-track generators intersect Earth independently.
theta := math.Atan2(vx, -vy)
first, _, firstOK := occultationPathBoundaryVector(frame, theta)
second, _, secondOK := occultationPathBoundaryVector(frame, theta+math.Pi)
if frame.targetRadius == 0 {
if !firstOK {
first, firstOK = occultationPathBoundaryAtNearestPointSourceTheta(frame, theta)
}
if !secondOK {
second, secondOK = occultationPathBoundaryAtNearestPointSourceTheta(frame, theta+math.Pi)
}
if !firstOK || !secondOK {
return occultationPathVector{}, occultationPathVector{}, false
}
return first, second, true
}
intervals := occultationPathBoundaryThetaIntervals(frame)
interval, intervalOK := occultationPathNearestThetaInterval(intervals, theta)
if firstOK && secondOK {
if intervalOK && occultationPathAngleDistance(interval.left, theta) > occultationPathAngleDistance(interval.left, theta+math.Pi) {
first, second = second, first
}
return first, second, true
}
if !intervalOK {
return occultationPathVector{}, occultationPathVector{}, false
}
first, _, firstOK = occultationPathBoundaryVector(frame, interval.left)
second, _, secondOK = occultationPathBoundaryVector(frame, interval.right)
if !firstOK || !secondOK {
return occultationPathVector{}, occultationPathVector{}, false
}
return first, second, true
}
func occultationPathAngleDistance(first, second float64) float64 {
return math.Abs(math.Remainder(first-second, 2*math.Pi))
}
func occultationPathBoundaryAtNearestPointSourceTheta(
frame occultationPathFrame,
theta float64,
) (occultationPathVector, bool) {
_, tangentTheta, tangentOK := occultationPathBoundaryTangent(frame)
if !tangentOK {
return occultationPathVector{}, false
}
left, right, intervalOK := occultationPathBoundaryThetaInterval(frame, tangentTheta)
if !intervalOK {
return occultationPathVector{}, false
}
middle := (left + right) / 2
theta += 2 * math.Pi * math.Round((middle-theta)/(2*math.Pi))
if theta < left {
theta = left
} else if theta > right {
theta = right
}
point, _, ok := occultationPathBoundaryVector(frame, theta)
return point, ok
}
type occultationPathThetaInterval struct {
left, right float64
}
func occultationPathNearestThetaInterval(
intervals []occultationPathThetaInterval,
theta float64,
) (occultationPathThetaInterval, bool) {
var closest occultationPathThetaInterval
closestDistance := math.Inf(1)
for _, interval := range intervals {
middle := (interval.left + interval.right) / 2
firstDelta := math.Abs(math.Remainder(theta-middle, 2*math.Pi))
secondDelta := math.Abs(math.Remainder(theta+math.Pi-middle, 2*math.Pi))
distance := math.Min(firstDelta, secondDelta)
if distance < closestDistance {
closest = interval
closestDistance = distance
}
}
if !finite(closestDistance) {
return occultationPathThetaInterval{}, false
}
return closest, true
}
func occultationPathBoundaryThetaIntervals(frame occultationPathFrame) []occultationPathThetaInterval {
step := 2 * math.Pi / float64(occultationPathBoundaryScanPoints)
discriminants := make([]float64, occultationPathBoundaryScanPoints)
for index := range discriminants {
discriminant, _, _, ok := occultationPathBoundaryLine(frame, step*float64(index))
if !ok {
discriminant = math.Inf(-1)
}
discriminants[index] = discriminant
}
intervals := make([]occultationPathThetaInterval, 0, 2)
for index, value := range discriminants {
previous := discriminants[(index+len(discriminants)-1)%len(discriminants)]
next := discriminants[(index+1)%len(discriminants)]
if value < previous || value < next {
continue
}
theta := occultationPathRefineBoundaryMaximum(frame, step*float64(index), step)
discriminant, b, scale, ok := occultationPathBoundaryLine(frame, theta)
tolerance := 1e-12 * math.Max(scale, 1)
if !ok || b >= 0 || discriminant < -tolerance {
continue
}
left, right, intervalOK := occultationPathBoundaryThetaInterval(frame, theta)
if intervalOK {
intervals = append(intervals, occultationPathThetaInterval{left: left, right: right})
}
}
return intervals
}
func occultationPathRefineBoundaryMaximum(frame occultationPathFrame, center, step float64) float64 {
left := center - step
right := center + step
const goldenRatio = 0.6180339887498949
x1 := right - goldenRatio*(right-left)
x2 := left + goldenRatio*(right-left)
f1, _, _, _ := occultationPathBoundaryLine(frame, x1)
f2, _, _, _ := occultationPathBoundaryLine(frame, x2)
for iteration := 0; iteration < 40; iteration++ {
if f1 < f2 {
left = x1
x1, f1 = x2, f2
x2 = left + goldenRatio*(right-left)
f2, _, _, _ = occultationPathBoundaryLine(frame, x2)
} else {
right = x2
x2, f2 = x1, f1
x1 = right - goldenRatio*(right-left)
f1, _, _, _ = occultationPathBoundaryLine(frame, x1)
}
}
return (left + right) / 2
}
func occultationPathOrientBoundarySamples(
first, second []OccultationPathPoint,
greatestTT float64,
) ([]OccultationPathPoint, []OccultationPathPoint) {
if len(first) == 0 || len(first) != len(second) {
return first, second
}
nearest := 0
nearestDelta := math.Inf(1)
for index := range first {
delta := math.Abs(centerTimeTT(first[index].Time) - greatestTT)
if delta < nearestDelta {
nearest = index
nearestDelta = delta
}
}
if first[nearest].Latitude >= second[nearest].Latitude {
return first, second
}
return second, first
}
func occultationPathCenterSamplesForFrame(
startTT, endTT, greatestTT float64,
frameAt occultationPathFrameFunc,
options OccultationPathOptions,
location *time.Location,
) ([]OccultationPathPoint, error) {
stepDays := float64(options.Step) / float64(24*time.Hour)
times := occultationPathSampleTimes(startTT, endTT, greatestTT, stepDays)
points := make([]OccultationPathPoint, 0, len(times))
for _, tt := range times {
point, ok := occultationPathCenterPointForFrame(tt, frameAt, location)
if ok {
points = append(points, point)
}
}
if options.TargetSpacingKM > 0 {
return refineOccultationPathSpacingForFrame(points, frameAt, options.TargetSpacingKM, location)
}
return points, nil
}
func refineOccultationPathSpacingForFrame(
points []OccultationPathPoint,
frameAt occultationPathFrameFunc,
targetSpacingKM float64,
location *time.Location,
) ([]OccultationPathPoint, error) {
if len(points) < 2 || targetSpacingKM <= 0 {
return points, nil
}
refined := make([]OccultationPathPoint, 0, len(points))
refined = append(refined, points[0])
widthAt := func(tt float64) (float64, bool) {
_, _, width, ok := occultationPathLimitsAndWidthForFrame(tt, frameAt)
return width, ok
}
for i := 1; i < len(points); i++ {
segmentStart := len(refined) - 1
var err error
refined, err = appendOccultationPathSegmentForFrame(refined, points[i-1], points[i], frameAt, targetSpacingKM, location, 0)
if err != nil {
return nil, err
}
refineOccultationPathWidths(refined[segmentStart:], widthAt)
}
return refined, nil
}
func appendOccultationPathSegmentForFrame(
points []OccultationPathPoint,
start, end OccultationPathPoint,
frameAt occultationPathFrameFunc,
targetSpacingKM float64,
location *time.Location,
depth int,
) ([]OccultationPathPoint, error) {
distance := occultationPathDistanceKM(start, end)
if distance <= targetSpacingKM {
if len(points) >= occultationPathMaxSampleCount {
return nil, ErrOccultationPathSamplingLimit
}
return append(points, end), nil
}
if depth >= occultationPathMaxAdaptiveDepth || len(points) >= occultationPathMaxSampleCount {
return nil, ErrOccultationPathSamplingLimit
}
midTT := (centerTimeTT(start.Time) + centerTimeTT(end.Time)) / 2
mid, ok := occultationPathCenterPointForFrameWithoutWidth(midTT, frameAt, location)
if !ok {
return append(points, end), nil
}
mid.WidthKM = (start.WidthKM + end.WidthKM) / 2
var err error
points, err = appendOccultationPathSegmentForFrame(points, start, mid, frameAt, targetSpacingKM, location, depth+1)
if err != nil {
return nil, err
}
return appendOccultationPathSegmentForFrame(points, mid, end, frameAt, targetSpacingKM, location, depth+1)
}
func planetOccultationPathFrameAt(tt float64, config planetOccultationConfig) (occultationPathFrame, bool) {
return planetOccultationContactPathFrameAt(tt, config, false)
}
func planetOccultationTotalPathFrameAt(tt float64, config planetOccultationConfig) (occultationPathFrame, bool) {
return planetOccultationContactPathFrameAt(tt, config, true)
}
func planetOccultationContactPathFrameAt(tt float64, config planetOccultationConfig, total bool) (occultationPathFrame, bool) {
moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, -1)
moonDistance := HMoonAwayN(tt, -1)
planetRA, planetDec := config.apparentRaDecN(tt, -1)
planetDistance := config.earthDistanceN(tt, -1) * occultationPathAstronomicalUnitKM
if !finite(moonRA) || !finite(moonDec) || !finite(moonDistance) || moonDistance <= 0 ||
!finite(planetRA) || !finite(planetDec) || !finite(planetDistance) || planetDistance <= moonDistance {
return occultationPathFrame{}, false
}
moon := occultationPathRaDecVector(moonRA, moonDec, moonDistance)
target := occultationPathRaDecVector(planetRA, planetDec, planetDistance)
moonToTarget := occultationPathSub(target, moon)
moonToTargetDistance := occultationPathNorm(moonToTarget)
moonRadius := MoonSemidiameter(tt) * math.Pi / (180 * 3600)
moonRadiusKM := occultationPathNorm(moon) * math.Sin(moonRadius)
contactRadiusKM := moonRadiusKM + config.equatorialRadiusKM
if total {
contactRadiusKM = moonRadiusKM - config.equatorialRadiusKM
}
if moonToTargetDistance <= math.Abs(contactRadiusKM) {
return occultationPathFrame{}, false
}
axis := occultationPathUnit(occultationPathScale(moonToTarget, -1))
north := occultationPathVector{z: 1}
first := occultationPathCross(north, axis)
if occultationPathNorm(first) < 1e-12 {
first = occultationPathCross(occultationPathVector{x: 1}, axis)
}
first = occultationPathUnit(first)
second := occultationPathUnit(occultationPathCross(axis, first))
return occultationPathFrame{
moon: moon,
axis: axis,
first: first,
second: second,
moonRadius: moonRadius,
targetRadius: math.Asin(contactRadiusKM / moonToTargetDistance),
}, true
}
func occultationPathFrameHasBoundary(frame occultationPathFrame) bool {
_, _, ok := occultationPathBoundaryTangent(frame)
return ok
}
func occultationPathBoundaryEndpointForFrame(
tt float64,
frameAt occultationPathFrameFunc,
location *time.Location,
direction int,
) occultationPathEndpoint {
if _, ok := frameAt(tt); !ok {
return occultationPathEndpoint{}
}
for offset := 0; offset <= 3; offset++ {
candidateTT := tt + float64(direction*offset)*0.5/86400.0
frame, ok := frameAt(candidateTT)
if !ok {
continue
}
vector, _, valid := occultationPathBoundaryTangent(frame)
if valid {
return occultationPathEndpoint{point: occultationPathPointFromVector(candidateTT, vector, 0, location), valid: true}
}
}
return occultationPathEndpoint{}
}
func occultationPathCenterPointForFrame(
tt float64,
frameAt occultationPathFrameFunc,
location *time.Location,
) (OccultationPathPoint, bool) {
frame, ok := frameAt(tt)
if !ok {
return OccultationPathPoint{}, false
}
point, _, ok := occultationEarthLineIntersection(frame.moon, frame.axis)
if !ok {
return OccultationPathPoint{}, false
}
width := 0.0
if _, _, tangentWidth, limitsOK := occultationPathLimitsAndWidthForFrame(tt, frameAt); limitsOK {
width = tangentWidth
}
return occultationPathPointFromVectorWithMoon(tt, point, width, frame.moon, location), true
}
func occultationPathCenterPointForFrameWithoutWidth(
tt float64,
frameAt occultationPathFrameFunc,
location *time.Location,
) (OccultationPathPoint, bool) {
frame, ok := frameAt(tt)
if !ok {
return OccultationPathPoint{}, false
}
point, _, ok := occultationEarthLineIntersection(frame.moon, frame.axis)
if !ok {
return OccultationPathPoint{}, false
}
return occultationPathPointFromVectorWithMoon(tt, point, 0, frame.moon, location), true
}
func occultationPathBoundaryPointForFrame(
tt float64,
frameAt occultationPathFrameFunc,
location *time.Location,
) (OccultationPathPoint, bool) {
frame, ok := frameAt(tt)
if !ok {
return OccultationPathPoint{}, false
}
point, _, ok := occultationPathBoundaryTangent(frame)
if !ok {
return OccultationPathPoint{}, false
}
north, south, limitsOK := occultationPathScannedLimitsAtFrame(tt, frame)
return occultationPathPointFromVector(tt, point, occultationPathBoundaryWidth(north, south, limitsOK), location), true
}
func occultationPathLimitsAndWidthForFrame(
tt float64,
frameAt occultationPathFrameFunc,
) (occultationPathVector, occultationPathVector, float64, bool) {
frame, ok := frameAt(tt)
if !ok {
return occultationPathVector{}, occultationPathVector{}, 0, false
}
beforeFrame, beforeOK := frameAt(tt - occultationPathVelocityStepDays)
afterFrame, afterOK := frameAt(tt + occultationPathVelocityStepDays)
if !beforeOK || !afterOK {
north, south, scannedOK := occultationPathScannedLimitsForFrame(tt, frame)
return north, south, occultationPathBoundaryWidth(north, south, scannedOK), scannedOK
}
vx := afterFrame.moonProjectionX() - beforeFrame.moonProjectionX()
vy := afterFrame.moonProjectionY() - beforeFrame.moonProjectionY()
speed := math.Hypot(vx, vy)
if speed <= 1e-12 {
north, south, scannedOK := occultationPathScannedLimitsForFrame(tt, frame)
return north, south, occultationPathBoundaryWidth(north, south, scannedOK), scannedOK
}
planeCrossTrack := occultationPathUnit(occultationPathAdd(
occultationPathScale(frame.first, -vy/speed),
occultationPathScale(frame.second, vx/speed),
))
var centerFixed, groundCrossTrack occultationPathVector
groundWidthOK := false
center, centerOK := occultationPathTrackReference(frame)
before, beforeCenterOK := occultationPathTrackReference(beforeFrame)
after, afterCenterOK := occultationPathTrackReference(afterFrame)
if centerOK && beforeCenterOK && afterCenterOK {
centerFixed = occultationPathEarthFixedVector(tt, center)
beforeFixed := occultationPathEarthFixedVector(tt-occultationPathVelocityStepDays, before)
afterFixed := occultationPathEarthFixedVector(tt+occultationPathVelocityStepDays, after)
polarRatioSquared := occultationPathEarthPolarRatio * occultationPathEarthPolarRatio
normal := occultationPathUnit(occultationPathVector{x: centerFixed.x, y: centerFixed.y, z: centerFixed.z / polarRatioSquared})
track := occultationPathSub(afterFixed, beforeFixed)
track = occultationPathSub(track, occultationPathScale(normal, occultationPathDot(track, normal)))
if occultationPathNorm(track) > 1e-12 {
groundCrossTrack = occultationPathUnit(occultationPathCross(normal, occultationPathUnit(track)))
groundWidthOK = true
}
}
minimumOffset := math.Inf(1)
maximumOffset := math.Inf(-1)
minimumGroundOffset := math.Inf(1)
maximumGroundOffset := math.Inf(-1)
var minimumPoint, maximumPoint occultationPathVector
var minimumGroundPoint, maximumGroundPoint occultationPathVector
consider := func(point occultationPathVector) {
offset := occultationPathDot(point, planeCrossTrack)
if offset < minimumOffset {
minimumOffset = offset
minimumPoint = point
}
if offset > maximumOffset {
maximumOffset = offset
maximumPoint = point
}
if groundWidthOK {
fixed := occultationPathEarthFixedVector(tt, point)
groundOffset := occultationPathDot(occultationPathSub(fixed, centerFixed), groundCrossTrack)
if groundOffset < minimumGroundOffset {
minimumGroundOffset = groundOffset
minimumGroundPoint = point
}
if groundOffset > maximumGroundOffset {
maximumGroundOffset = groundOffset
maximumGroundPoint = point
}
}
}
if tangentPoint, tangentTheta, tangentOK := occultationPathBoundaryTangent(frame); tangentOK {
consider(tangentPoint)
if leftTheta, rightTheta, intervalOK := occultationPathBoundaryThetaInterval(frame, tangentTheta); intervalOK {
const intervalSamples = 128
for i := 0; i <= intervalSamples; i++ {
theta := leftTheta + (rightTheta-leftTheta)*float64(i)/intervalSamples
if point, _, pointOK := occultationPathBoundaryVector(frame, theta); pointOK {
consider(point)
}
}
}
}
for i := 0; i < occultationPathBoundaryScanPoints; i++ {
point, _, pointOK := occultationPathBoundaryVector(frame, 2*math.Pi*float64(i)/float64(occultationPathBoundaryScanPoints))
if !pointOK {
continue
}
consider(point)
}
if !finite(minimumOffset) || !finite(maximumOffset) {
return occultationPathVector{}, occultationPathVector{}, 0, false
}
width := maximumOffset - minimumOffset
if groundWidthOK && finite(minimumGroundOffset) && finite(maximumGroundOffset) {
width = maximumGroundOffset - minimumGroundOffset
// 有限目标会把月影打开或收束成圆锥;接近地平线时其影面投影可能折叠,使全球投影极值在远处地平线交点间跳变。地面轨迹极值仍位于掩带的同一物理侧。
// A finite target opens or closes the lunar shadow into a cone. Near the horizon its shadow-plane projection can fold, causing the global projected extremum to jump between distant horizon intersections. Ground-track extrema remain on the same physical sides of the band.
if frame.targetRadius != 0 {
return maximumGroundPoint, minimumGroundPoint, width, true
}
}
_, minimumLatitude := occultationPathGeodetic(tt, minimumPoint)
_, maximumLatitude := occultationPathGeodetic(tt, maximumPoint)
if maximumLatitude >= minimumLatitude {
return maximumPoint, minimumPoint, width, true
}
return minimumPoint, maximumPoint, width, true
}
func occultationPathScannedLimitsForFrame(
tt float64,
frame occultationPathFrame,
) (occultationPathVector, occultationPathVector, bool) {
return occultationPathScannedLimitsAtFrame(tt, frame)
}
+342
View File
@@ -0,0 +1,342 @@
package basic
import (
"math"
"strings"
"testing"
"time"
)
func TestPlanetOccultationFiniteDiskExpandsOuterAndContractsTotalPath(t *testing.T) {
config, ok := planetOccultationConfigFor(OccultationSaturn)
if !ok {
t.Fatal("Saturn occultation config is unavailable")
}
tt := occultationTimeToTT(time.Date(2024, time.August, 21, 2, 41, 36, 0, time.UTC))
frameAt := func(tt float64) (occultationPathFrame, bool) {
return planetOccultationPathFrameAt(tt, config)
}
_, _, finiteWidth, finiteOK := occultationPathLimitsAndWidthForFrame(tt, frameAt)
if !finiteOK {
t.Fatal("finite-disk path limits are unavailable")
}
pointFrameAt := func(tt float64) (occultationPathFrame, bool) {
frame, valid := planetOccultationPathFrameAt(tt, config)
frame.targetRadius = 0
return frame, valid
}
_, _, pointWidth, pointOK := occultationPathLimitsAndWidthForFrame(tt, pointFrameAt)
if !pointOK {
t.Fatal("point-source comparison limits are unavailable")
}
if finiteWidth <= pointWidth {
t.Fatalf("finite-disk outer width = %.6f km, want greater than point-source width %.6f km", finiteWidth, pointWidth)
}
if finiteWidth-pointWidth < 1 {
t.Fatalf("finite-disk expansion = %.6f km, want a measurable planetary-radius contribution", finiteWidth-pointWidth)
}
innerFrameAt := func(tt float64) (occultationPathFrame, bool) {
return planetOccultationTotalPathFrameAt(tt, config)
}
_, _, totalWidth, totalOK := occultationPathLimitsAndWidthForFrame(tt, innerFrameAt)
if !totalOK {
t.Fatal("finite-disk total-occultation limits are unavailable")
}
if totalWidth >= pointWidth {
t.Fatalf("finite-disk total width = %.6f km, want less than point-source width %.6f km", totalWidth, pointWidth)
}
if pointWidth-totalWidth < 1 {
t.Fatalf("finite-disk contraction = %.6f km, want a measurable planetary-radius contribution", pointWidth-totalWidth)
}
}
func TestPlanetOccultationConesUseTwoSphereCommonTangents(t *testing.T) {
config, ok := planetOccultationConfigFor(OccultationSaturn)
if !ok {
t.Fatal("Saturn occultation config is unavailable")
}
tt := occultationTimeToTT(time.Date(2025, time.February, 1, 4, 0, 48, 0, time.UTC))
outer, ok := planetOccultationPathFrameAt(tt, config)
if !ok {
t.Fatal("Saturn outer-contact cone is unavailable")
}
inner, ok := planetOccultationTotalPathFrameAt(tt, config)
if !ok {
t.Fatal("Saturn inner-contact cone is unavailable")
}
planetRA, planetDec := config.apparentRaDecN(tt, -1)
planetDistance := config.earthDistanceN(tt, -1) * occultationPathAstronomicalUnitKM
target := occultationPathRaDecVector(planetRA, planetDec, planetDistance)
moonToTargetDistance := occultationPathNorm(occultationPathSub(target, outer.moon))
moonRadiusKM := occultationPathNorm(outer.moon) * math.Sin(outer.moonRadius)
wantOuter := math.Asin((moonRadiusKM + config.equatorialRadiusKM) / moonToTargetDistance)
wantInner := math.Asin((moonRadiusKM - config.equatorialRadiusKM) / moonToTargetDistance)
if difference := math.Abs(outer.targetRadius - wantOuter); difference > 1e-15 {
t.Fatalf("outer-contact cone angle = %.15g rad, want %.15g (difference %.3g)", outer.targetRadius, wantOuter, difference)
}
if difference := math.Abs(inner.targetRadius - wantInner); difference > 1e-15 {
t.Fatalf("inner-contact cone angle = %.15g rad, want %.15g (difference %.3g)", inner.targetRadius, wantInner, difference)
}
for _, contact := range []struct {
name string
frame occultationPathFrame
}{
{name: "outer", frame: outer},
{name: "inner", frame: inner},
} {
origin, direction, rayOK := occultationPathBoundaryRay(contact.frame, 0.73)
if !rayOK {
t.Fatalf("%s-contact boundary ray is unavailable", contact.name)
}
moonNormal := occultationPathSub(origin, contact.frame.moon)
if difference := math.Abs(occultationPathNorm(moonNormal) - moonRadiusKM); difference > 1e-6 {
t.Fatalf("%s-contact lunar tangency radius differs by %.9f km", contact.name, difference)
}
if residual := math.Abs(occultationPathDot(moonNormal, direction)); residual > 1e-6 {
t.Fatalf("%s-contact ray/lunar-radius dot product = %.9f km", contact.name, residual)
}
targetParameter := occultationPathDot(occultationPathSub(target, origin), direction)
targetTangent := occultationPathAdd(origin, occultationPathScale(direction, targetParameter))
targetNormal := occultationPathSub(targetTangent, target)
if difference := math.Abs(occultationPathNorm(targetNormal) - config.equatorialRadiusKM); difference > 1e-5 {
t.Fatalf("%s-contact planetary tangency radius differs by %.9f km", contact.name, difference)
}
if residual := math.Abs(occultationPathDot(targetNormal, direction)); residual > 1e-5 {
t.Fatalf("%s-contact ray/planet-radius dot product = %.9f km", contact.name, residual)
}
}
}
func TestPlanetOccultationInnerConeUsesSignedTargetRadius(t *testing.T) {
config, ok := planetOccultationConfigFor(OccultationSaturn)
if !ok {
t.Fatal("Saturn occultation config is unavailable")
}
tt := occultationTimeToTT(time.Date(2025, time.February, 1, 4, 0, 48, 0, time.UTC))
frame, ok := planetOccultationTotalPathFrameAt(tt, config)
if !ok {
t.Fatal("Saturn inner-contact cone is unavailable")
}
for index := 0; index < occultationPathBoundaryScanPoints; index++ {
theta := 2 * math.Pi * float64(index) / float64(occultationPathBoundaryScanPoints)
want, _, wantOK := occultationPathBoundaryVector(frame, theta)
if !wantOK {
continue
}
discriminant, _, scale, lineOK := occultationPathBoundaryLine(frame, theta)
if !lineOK || discriminant < 0 {
continue
}
got, _, gotOK := occultationPathBoundaryIntersection(frame, theta, 1e-12*math.Max(scale, 1))
if !gotOK {
t.Fatalf("signed inner-cone intersection is unavailable at theta %.9f", theta)
}
if difference := occultationPathNorm(occultationPathSub(got, want)); difference > 1e-6 {
t.Fatalf("inner-cone intersection differs by %.6f km at theta %.9f", difference, theta)
}
return
}
t.Fatal("no comparable Saturn inner-cone boundary point found")
}
func TestOccultationPathBoundaryTangentFindsBetweenSamples(t *testing.T) {
const boundaryRadiusKM = 1737.4
theta := math.Pi / float64(occultationPathBoundaryScanPoints)
offset := occultationPathEarthEquatorialRadiusKM + boundaryRadiusKM - 0.01
moon := occultationPathVector{
x: 384000,
y: -offset * math.Cos(theta),
z: -offset * math.Sin(theta),
}
frame := occultationPathFrame{
moon: moon,
axis: occultationPathVector{x: -1},
first: occultationPathVector{y: 1},
second: occultationPathVector{z: 1},
moonRadius: math.Asin(boundaryRadiusKM / occultationPathNorm(moon)),
}
for _, sampledTheta := range []float64{0, 2 * math.Pi / float64(occultationPathBoundaryScanPoints)} {
if _, _, ok := occultationPathBoundaryVector(frame, sampledTheta); ok {
t.Fatalf("fixture is not narrower than the old sample spacing at theta %.9f", sampledTheta)
}
}
point, tangentTheta, ok := occultationPathBoundaryTangent(frame)
if !ok {
t.Fatal("continuous boundary tangency was not found between scan points")
}
if math.Abs(tangentTheta-theta) > 5e-5 {
t.Fatalf("tangent theta = %.9f, want %.9f", tangentTheta, theta)
}
polarRatioSquared := occultationPathEarthPolarRatio * occultationPathEarthPolarRatio
ellipsoidResidual := point.x*point.x + point.y*point.y + point.z*point.z/polarRatioSquared -
occultationPathEarthEquatorialRadiusKM*occultationPathEarthEquatorialRadiusKM
if math.Abs(ellipsoidResidual) > 1e-3 {
t.Fatalf("tangent point ellipsoid residual = %.9f", ellipsoidResidual)
}
frameAt := func(float64) (occultationPathFrame, bool) { return frame, true }
if _, _, centerOK := occultationEarthLineIntersection(frame.moon, frame.axis); centerOK {
t.Fatal("synthetic center line unexpectedly intersects Earth")
}
north, south, width, limitsOK := occultationPathLimitsAndWidthForFrame(2451545, frameAt)
if !limitsOK {
t.Fatal("boundary-only event did not produce path limits")
}
if separation := occultationPathNorm(occultationPathSub(north, south)); separation <= 1e-6 {
t.Fatalf("boundary-only path limits collapsed to one point: separation=%.12f km", separation)
}
if width <= 0 {
t.Fatalf("boundary-only path width = %.12f km, want positive", width)
}
greatest, greatestOK := occultationPathBoundaryPointForFrame(2451545, frameAt, time.UTC)
if !greatestOK {
t.Fatal("boundary-only event did not produce a greatest surface point")
}
if greatest.WidthKM <= 0 {
t.Fatalf("boundary-only greatest width = %.12f km, want positive", greatest.WidthKM)
}
}
func TestPlanetOccultationSaturnLimitsRemainContinuous(t *testing.T) {
location := time.FixedZone("UTC+8", 8*3600)
paths, err := FindPlanetOccultationPaths(
time.Date(2025, time.February, 1, 0, 0, 0, 0, location),
time.Date(2025, time.February, 2, 0, 0, 0, 0, location),
OccultationSaturn,
OccultationPathOptions{Step: 2 * time.Minute},
)
if err != nil {
t.Fatalf("FindPlanetOccultationPaths() error = %v", err)
}
if len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() returned %d paths, want 1", len(paths))
}
for _, limit := range []struct {
name string
points []OccultationPathPoint
}{
{name: "outer northern", points: paths[0].NorthernLimit},
{name: "outer southern", points: paths[0].SouthernLimit},
{name: "total northern", points: paths[0].NorthernTotalLimit},
{name: "total southern", points: paths[0].SouthernTotalLimit},
} {
for index := 1; index < len(limit.points); index++ {
distance := occultationPathDistanceKM(limit.points[index-1], limit.points[index])
if distance > 1000 {
t.Fatalf("%s limit jumps %.1f km between %v and %v", limit.name, distance,
limit.points[index-1].Time, limit.points[index].Time)
}
}
}
}
func TestRefinedPlanetOccultationCenterLineRespectsWidthTolerance(t *testing.T) {
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
paths, err := FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), OccultationSaturn,
OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 50},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
config, ok := planetOccultationConfigFor(OccultationSaturn)
if !ok {
t.Fatal("Saturn occultation config is unavailable")
}
frameAt := func(tt float64) (occultationPathFrame, bool) {
return planetOccultationPathFrameAt(tt, config)
}
for index, point := range paths[0].CenterLine {
exact, pointOK := occultationPathCenterPointForFrame(centerTimeTT(point.Time), frameAt, time.UTC)
if !pointOK {
t.Fatalf("exact center point %d is unavailable", index)
}
if difference := math.Abs(point.WidthKM - exact.WidthKM); difference > occultationPathWidthToleranceKM {
t.Fatalf("center point %d width differs from exact value by %.9f km: got %.9f want %.9f",
index, difference, point.WidthKM, exact.WidthKM)
}
}
}
func TestPlanetOccultationSaturnLimitsDoNotDependOnStep(t *testing.T) {
location := time.FixedZone("UTC+8", 8*3600)
start := time.Date(2024, time.August, 21, 0, 0, 0, 0, location)
end := time.Date(2024, time.August, 22, 0, 0, 0, 0, location)
fine := findSinglePlanetOccultationPath(t, start, end, 30*time.Second)
coarse := findSinglePlanetOccultationPath(t, start, end, 2*time.Minute)
for _, limits := range []struct {
name string
fine, coarse []OccultationPathPoint
}{
{name: "outer northern", fine: fine.NorthernLimit, coarse: coarse.NorthernLimit},
{name: "outer southern", fine: fine.SouthernLimit, coarse: coarse.SouthernLimit},
{name: "total northern", fine: fine.NorthernTotalLimit, coarse: coarse.NorthernTotalLimit},
{name: "total southern", fine: fine.SouthernTotalLimit, coarse: coarse.SouthernTotalLimit},
} {
assertOccultationPathCommonSamplesEqual(t, limits.name, limits.fine, limits.coarse)
for index := 1; index+1 < len(limits.coarse); index++ {
paired := coarse.SouthernLimit
if strings.HasPrefix(limits.name, "total") {
paired = coarse.SouthernTotalLimit
}
if strings.HasSuffix(limits.name, "southern") {
continue
}
if distance := occultationPathDistanceKM(limits.coarse[index], paired[index]); distance < 0.001 {
t.Fatalf("%s and southern limit collapse at %v", limits.name, limits.coarse[index].Time)
}
}
}
}
func findSinglePlanetOccultationPath(t *testing.T, start, end time.Time, step time.Duration) PlanetOccultationPath {
t.Helper()
paths, err := FindPlanetOccultationPaths(start, end, OccultationSaturn, OccultationPathOptions{Step: step})
if err != nil {
t.Fatalf("FindPlanetOccultationPaths(step=%v) error = %v", step, err)
}
if len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths(step=%v) returned %d paths, want 1", step, len(paths))
}
if !paths[0].HasTotalBand {
t.Fatalf("FindPlanetOccultationPaths(step=%v) has no total band", step)
}
return paths[0]
}
func assertOccultationPathCommonSamplesEqual(t *testing.T, name string, fine, coarse []OccultationPathPoint) {
t.Helper()
matched := 0
fineIndex := 0
for _, coarsePoint := range coarse[1 : len(coarse)-1] {
for fineIndex+1 < len(fine) && fine[fineIndex].Time.Before(coarsePoint.Time.Add(-20*time.Millisecond)) {
fineIndex++
}
nearest := -1
nearestDelta := math.Inf(1)
for candidateIndex := fineIndex - 2; candidateIndex <= fineIndex+2; candidateIndex++ {
if candidateIndex < 0 || candidateIndex >= len(fine) {
continue
}
delta := math.Abs(fine[candidateIndex].Time.Sub(coarsePoint.Time).Seconds())
if delta < nearestDelta {
nearest = candidateIndex
nearestDelta = delta
}
}
if nearest < 0 || nearestDelta > 0.00001 {
continue
}
matched++
if distance := occultationPathDistanceKM(fine[nearest], coarsePoint); distance > 5 {
t.Fatalf("%s differs by %.1f km at common time %v (sample delta %.6f s)",
name, distance, coarsePoint.Time, nearestDelta)
}
}
if matched < 10 {
t.Fatalf("%s compared only %d common samples, want at least 10", name, matched)
}
}
+284
View File
@@ -0,0 +1,284 @@
package basic
import (
"math"
"testing"
"time"
)
func TestPlanetOccultationSupportsAllPlanetTargets(t *testing.T) {
tt := TD2UT(Date2JDE(time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)), true)
tests := []struct {
planet OccultationPlanet
name string
}{
{OccultationMercury, "Mercury"},
{OccultationVenus, "Venus"},
{OccultationMars, "Mars"},
{OccultationJupiter, "Jupiter"},
{OccultationSaturn, "Saturn"},
{OccultationUranus, "Uranus"},
{OccultationNeptune, "Neptune"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if err := test.planet.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
if test.planet.String() != test.name {
t.Fatalf("String() = %q, want %q", test.planet.String(), test.name)
}
config, ok := planetOccultationConfigFor(test.planet)
if !ok {
t.Fatal("planet occultation config is unavailable")
}
state := planetOccultationStateAt(tt, config, nil, -1)
if !state.valid || state.planetSemidiameter <= 0 || state.moonSemidiameter <= state.planetSemidiameter {
t.Fatalf("invalid planet state: %+v", state)
}
})
}
}
func TestPlanetOccultationSaturnContactsSolveDynamicDiskMetrics(t *testing.T) {
observer := Observer{Longitude: -30.072, Latitude: 16.21}
start := time.Date(2024, time.August, 21, 1, 30, 0, 0, time.UTC)
end := time.Date(2024, time.August, 21, 4, 0, 0, 0, time.UTC)
results, err := FindPlanetOccultations(
start, end, OccultationSaturn,
observer.Longitude, observer.Latitude, observer.Height,
OccultationSearchOptions{},
)
if err != nil {
t.Fatalf("FindPlanetOccultations() error = %v", err)
}
if len(results) != 1 {
t.Fatalf("FindPlanetOccultations() returned %d events, want 1", len(results))
}
result := results[0]
if result.Type != OccultationTotal || !result.HasInternalContacts || !result.ContactsComplete {
t.Fatalf("unexpected event geometry: type=%q internal=%v complete=%v", result.Type, result.HasInternalContacts, result.ContactsComplete)
}
config, ok := planetOccultationConfigFor(OccultationSaturn)
if !ok {
t.Fatal("Saturn occultation config is unavailable")
}
contacts := []struct {
name string
value time.Time
internal bool
}{
{"C1", result.ExternalImmersion, false},
{"C2", result.InternalImmersion, true},
{"C3", result.InternalEmersion, true},
{"C4", result.ExternalEmersion, false},
}
for _, contact := range contacts {
state := planetOccultationStateAt(occultationTimeToTT(contact.value), config, &observer, -1)
metric := state.externalContactMetric
if contact.internal {
metric = state.internalContactMetric
}
if math.Abs(metric) > 0.1 {
t.Errorf("%s contact residual = %.6f arcsec, want <= 0.1", contact.name, metric)
}
}
if !(result.ExternalImmersion.Before(result.InternalImmersion) &&
result.InternalImmersion.Before(result.Greatest) &&
result.Greatest.Before(result.InternalEmersion) &&
result.InternalEmersion.Before(result.ExternalEmersion)) {
t.Fatalf("contact order is invalid: %+v", result)
}
if result.PlanetSemidiameterArcsec <= 0 || result.MoonSemidiameterArcsec <= result.PlanetSemidiameterArcsec {
t.Fatalf("invalid dynamic semidiameters: Moon=%.6f planet=%.6f", result.MoonSemidiameterArcsec, result.PlanetSemidiameterArcsec)
}
}
func TestPlanetOccultationUsesStationMoonDistanceForRadius(t *testing.T) {
tt := occultationTimeToTT(time.Date(2024, time.August, 21, 2, 49, 10, 0, time.UTC))
config, ok := planetOccultationConfigFor(OccultationSaturn)
if !ok {
t.Fatal("Saturn occultation config is unavailable")
}
moonRA, _ := HMoonGeocentricApparentRaDecN(tt, -1)
subMoonLongitude := normalizeLongitude180(moonRA - ApparentSiderealTime(TD2UT(tt, false))*15)
near := Observer{Longitude: subMoonLongitude, Latitude: 0}
far := Observer{Longitude: normalizeLongitude180(subMoonLongitude + 180), Latitude: 0}
nearState := planetOccultationStateAt(tt, config, &near, -1)
farState := planetOccultationStateAt(tt, config, &far, -1)
if !nearState.valid || !farState.valid {
t.Fatalf("invalid station states: near=%+v far=%+v", nearState, farState)
}
if nearState.moonSemidiameter <= farState.moonSemidiameter {
t.Fatalf("station Moon radius did not follow station distance: near=%.6f far=%.6f", nearState.moonSemidiameter, farState.moonSemidiameter)
}
if nearState.moonSemidiameter-MoonSemidiameterN(tt, -1) <= 0 ||
farState.moonSemidiameter-MoonSemidiameterN(tt, -1) >= 0 {
t.Fatalf("station radius does not straddle geocentric radius: near=%.6f geo=%.6f far=%.6f",
nearState.moonSemidiameter, MoonSemidiameterN(tt, -1), farState.moonSemidiameter)
}
}
func TestPlanetOccultationUsesStationPlanetDistanceForRadius(t *testing.T) {
tt := occultationTimeToTT(time.Date(2024, time.March, 11, 1, 0, 0, 0, time.UTC))
config, ok := planetOccultationConfigFor(OccultationMercury)
if !ok {
t.Fatal("Mercury occultation config is unavailable")
}
planetRA, _ := config.apparentRaDecN(tt, -1)
subPlanetLongitude := normalizeLongitude180(planetRA - ApparentSiderealTime(TD2UT(tt, false))*15)
near := Observer{Longitude: subPlanetLongitude, Latitude: 0}
far := Observer{Longitude: normalizeLongitude180(subPlanetLongitude + 180), Latitude: 0}
nearState := planetOccultationStateAt(tt, config, &near, -1)
farState := planetOccultationStateAt(tt, config, &far, -1)
if !nearState.valid || !farState.valid {
t.Fatalf("invalid station states: near=%+v far=%+v", nearState, farState)
}
if nearState.planetSemidiameter <= farState.planetSemidiameter {
t.Fatalf("station planet radius did not follow station distance: near=%.12f far=%.12f",
nearState.planetSemidiameter, farState.planetSemidiameter)
}
geocentric := config.semidiameterN(tt, -1)
if nearState.planetSemidiameter <= geocentric || farState.planetSemidiameter >= geocentric {
t.Fatalf("station planet radius does not straddle geocentric radius: near=%.12f geo=%.12f far=%.12f",
nearState.planetSemidiameter, geocentric, farState.planetSemidiameter)
}
}
func TestPlanetOccultationBestObserverRefinesDynamicMetric(t *testing.T) {
config, ok := planetOccultationConfigFor(OccultationSaturn)
if !ok {
t.Fatal("Saturn occultation config is unavailable")
}
startTT := occultationTimeToTT(time.Date(2024, time.August, 21, 1, 30, 0, 0, time.UTC))
endTT := occultationTimeToTT(time.Date(2024, time.August, 21, 4, 0, 0, 0, time.UTC))
seedTT := occultationTimeToTT(time.Date(2024, time.August, 21, 2, 49, 10, 0, time.UTC))
bestTT, observer, _, bestOK := planetOccultationBestObserver(seedTT, startTT, endTT, config)
if !bestOK {
t.Fatal("best-observer search failed")
}
metric := planetOccultationExternalContactMetric(bestTT, config, &observer, -1)
for _, deltaSeconds := range []float64{-0.1, 0.1} {
neighbor := planetOccultationExternalContactMetric(bestTT+deltaSeconds/86400, config, &observer, -1)
if metric > neighbor+1e-6 {
t.Fatalf("best time does not minimize the dynamic metric: center=%.12f neighbor(%+.1fs)=%.12f",
metric, deltaSeconds, neighbor)
}
}
}
func TestPlanetOccultationInnerPlanetContactsUseDynamicTargets(t *testing.T) {
tests := []struct {
name string
planet OccultationPlanet
start time.Time
end time.Time
}{
{"Mercury", OccultationMercury, time.Date(2024, time.March, 10, 0, 0, 0, 0, time.UTC), time.Date(2024, time.March, 12, 0, 0, 0, 0, time.UTC)},
{"Venus", OccultationVenus, time.Date(2024, time.April, 6, 0, 0, 0, 0, time.UTC), time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
results, err := FindBestPlanetOccultations(test.start, test.end, test.planet, OccultationSearchOptions{MaxEvents: 1})
if err != nil {
t.Fatalf("FindBestPlanetOccultations() error = %v", err)
}
if len(results) != 1 {
t.Fatalf("FindBestPlanetOccultations() returned %d events, want 1", len(results))
}
result := results[0]
if result.Planet != test.planet || !result.HasInternalContacts || !result.ContactsComplete {
t.Fatalf("unexpected event geometry: %+v", result)
}
if !(result.ExternalImmersion.Before(result.InternalImmersion) &&
result.InternalImmersion.Before(result.Greatest) &&
result.Greatest.Before(result.InternalEmersion) &&
result.InternalEmersion.Before(result.ExternalEmersion)) {
t.Fatalf("contact order is invalid: %+v", result)
}
config, ok := planetOccultationConfigFor(test.planet)
if !ok {
t.Fatalf("%s occultation config is unavailable", test.name)
}
contacts := []struct {
value time.Time
internal bool
}{
{result.ExternalImmersion, false},
{result.InternalImmersion, true},
{result.InternalEmersion, true},
{result.ExternalEmersion, false},
}
for index, contact := range contacts {
state := planetOccultationStateAt(occultationTimeToTT(contact.value), config, &result.Observer, -1)
metric := state.externalContactMetric
if contact.internal {
metric = state.internalContactMetric
}
if math.Abs(metric) > 0.1 {
t.Errorf("contact %d residual = %.6f arcsec, want <= 0.1", index+1, metric)
}
}
})
}
}
func TestPlanetOccultationOuterPlanetContactsUseDynamicTargets(t *testing.T) {
tests := []struct {
name string
planet OccultationPlanet
start time.Time
end time.Time
observer Observer
}{
{"Mars", OccultationMars, time.Date(2020, 2, 18, 11, 0, 0, 0, time.UTC), time.Date(2020, 2, 18, 16, 0, 0, 0, time.UTC), Observer{Longitude: -76.01, Latitude: 29.918}},
{"Jupiter", OccultationJupiter, time.Date(2020, 1, 23, 0, 0, 0, 0, time.UTC), time.Date(2020, 1, 23, 5, 0, 0, 0, time.UTC), Observer{Longitude: 120.15, Latitude: -45.552}},
{"Uranus", OccultationUranus, time.Date(2022, 2, 7, 19, 0, 0, 0, time.UTC), time.Date(2022, 2, 7, 22, 0, 0, 0, time.UTC), Observer{Longitude: 11.186, Latitude: -62.948}},
{"Neptune", OccultationNeptune, time.Date(2023, 9, 1, 7, 0, 0, 0, time.UTC), time.Date(2023, 9, 1, 10, 0, 0, 0, time.UTC), Observer{Longitude: -13.896, Latitude: -62.038}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
results, err := FindPlanetOccultations(
test.start, test.end, test.planet,
test.observer.Longitude, test.observer.Latitude, test.observer.Height,
OccultationSearchOptions{},
)
if err != nil {
t.Fatalf("FindPlanetOccultations() error = %v", err)
}
if len(results) != 1 {
t.Fatalf("FindPlanetOccultations() returned %d events, want 1", len(results))
}
result := results[0]
if result.Type != OccultationTotal || !result.HasInternalContacts || !result.ContactsComplete {
t.Fatalf("unexpected event geometry: %+v", result)
}
config, ok := planetOccultationConfigFor(test.planet)
if !ok {
t.Fatalf("%s occultation config is unavailable", test.name)
}
contacts := []struct {
value time.Time
internal bool
}{
{result.ExternalImmersion, false},
{result.InternalImmersion, true},
{result.InternalEmersion, true},
{result.ExternalEmersion, false},
}
for index, contact := range contacts {
state := planetOccultationStateAt(occultationTimeToTT(contact.value), config, &test.observer, -1)
metric := state.externalContactMetric
if contact.internal {
metric = state.internalContactMetric
}
if math.Abs(metric) > 0.1 {
t.Errorf("contact %d residual = %.6f arcsec, want <= 0.1", index+1, metric)
}
}
})
}
}
+719
View File
@@ -0,0 +1,719 @@
package basic
import (
"math"
"sort"
"time"
)
const (
starOccultationSiderealMonthDays = 27.321661
starOccultationDefaultStepDays = 0.25
starOccultationContactStepDays = 10.0 / 1440.0
starOccultationContactSpanDays = 2.0
starOccultationLatitudeMarginAS = 3600.0
starOccultationMoonLatitudeDeg = 6.0
starOccultationGrazingTolerance = 0.01
starOccultationRootToleranceDays = occultationEventSelectionToleranceDays
starOccultationMaxContactSteps = 10000
)
// FindStarOccultations 搜索单颗点源恒星的月掩星。
//
// 输入坐标会从历元传播并转换到当日视坐标系;若提供视差,还会修正观测者的恒星视差。
// 搜索不会加载内嵌 9100 星表;需要全星表搜索时,调用者必须显式加载并选择恒星。经度东为正、纬度北为正,单位为度;高度为平均海平面以上米数。
// FindStarOccultations searches for lunar occultations of one point-source star.
// The input coordinate is propagated from its epoch, converted to the apparent frame of date, and corrected for the observer's stellar parallax when one is supplied.
// The search does not load the embedded 9100-star catalog; callers that need a catalog-wide search must load and select stars explicitly. Longitude is east-positive in degrees, latitude is north-positive in degrees, and height is the observer elevation above mean sea level in meters.
func FindStarOccultations(start, end time.Time, star StarCoordinate, longitude, latitude, height float64,
options OccultationSearchOptions) ([]StarOccultationInfo, error) {
if err := validateOccultationTimeRange(start, end); err != nil {
return nil, err
}
if err := star.Validate(); err != nil {
return nil, err
}
observer := Observer{Longitude: longitude, Latitude: latitude, Height: height}
if err := observer.Validate(); err != nil {
return nil, err
}
if err := options.Validate(); err != nil {
return nil, err
}
startTT := occultationTimeToTT(start)
endTT := occultationTimeToTT(end)
resultLocation := start.Location()
results := make([]StarOccultationInfo, 0)
for _, greatestTT := range starOccultationCandidateGreatestTimes(startTT, endTT, starOccultationCoarseStepDays(options), star, observer, options.SafetyMarginArcsec) {
info, ok := starOccultationInfoAtGreatest(greatestTT, star, observer, options.SafetyMarginArcsec, resultLocation)
if !ok {
continue
}
if len(results) == 0 || math.Abs(results[len(results)-1].Greatest.Sub(info.Greatest).Seconds()) > 60 {
results = append(results, info)
if options.MaxEvents > 0 && len(results) >= options.MaxEvents {
break
}
}
}
sort.SliceStable(results, func(i, j int) bool { return results[i].Greatest.Before(results[j].Greatest) })
return results, nil
}
// FindBestStarOccultations 返回窗口内每次恒星月掩在地球上的全球几何掩甚点。
// 返回的 StarOccultationInfo.Observer 是海平面大地测量位置,由月掩几何选择,不使用地平线或可见性评分。
//
// 地心数据只用于月周期搜索初值;最终点是与 FindStarOccultationPaths 一致的标准全球掩甚路径点,然后在该处重新进行站心接触和可见性计算。查询端点 10 ms 内的掩甚时刻也会包含,与数值根精度一致。
// FindBestStarOccultations returns the global geometric greatest point on Earth for each stellar occultation in the window.
// The returned StarOccultationInfo.Observer is the geodetic location at sea level; it is selected from the occultation geometry, without a horizon or visibility score.
// Geocentric data only seeds each lunar-month search. The final point is the canonical global greatest-path point, matching FindStarOccultationPaths; event contacts and visibility are then recomputed topocentrically there. A greatest instant within 10 ms of either query endpoint is included, matching the numerical root precision.
func FindBestStarOccultations(start, end time.Time, star StarCoordinate, options OccultationSearchOptions) ([]StarOccultationInfo, error) {
if err := validateOccultationTimeRange(start, end); err != nil {
return nil, err
}
if err := star.Validate(); err != nil {
return nil, err
}
if err := options.Validate(); err != nil {
return nil, err
}
startTT := occultationTimeToTT(start)
endTT := occultationTimeToTT(end)
selectionStartTT := startTT - occultationEventSelectionToleranceDays
selectionEndTT := endTT + occultationEventSelectionToleranceDays
candidateStartTT := startTT - occultationPathSearchSpanDays
candidateEndTT := endTT + occultationPathSearchSpanDays
resultLocation := start.Location()
results := make([]StarOccultationInfo, 0)
for _, seedTT := range starOccultationGeocentricCandidateGreatestTimes(candidateStartTT, candidateEndTT, starOccultationCoarseStepDays(options), star, options.SafetyMarginArcsec) {
greatestTT, observer, _, observerOK := starOccultationBestObserver(seedTT, selectionStartTT, selectionEndTT, star)
if !observerOK {
continue
}
info, ok := starOccultationInfoAtGreatest(greatestTT, star, observer, options.SafetyMarginArcsec, resultLocation)
if !ok {
continue
}
if len(results) == 0 || math.Abs(results[len(results)-1].Greatest.Sub(info.Greatest).Seconds()) > 60 {
results = append(results, info)
if options.MaxEvents > 0 && len(results) >= options.MaxEvents {
break
}
}
}
sort.SliceStable(results, func(i, j int) bool { return results[i].Greatest.Before(results[j].Greatest) })
return results, nil
}
func starOccultationCandidateGreatestTimes(startTT, endTT, step float64, star StarCoordinate, observer Observer, safetyMarginArcsec float64) []float64 {
results := make([]float64, 0)
for cycleStart := startTT; cycleStart < endTT; cycleStart += starOccultationSiderealMonthDays {
cycleEnd := math.Min(cycleStart+starOccultationSiderealMonthDays, endTT)
scanStart := math.Max(startTT-step, cycleStart-step)
scanEnd := math.Min(endTT+step, cycleEnd+step)
if !starOccultationLatitudeEnvelopePass(scanStart, scanEnd, star, &observer, safetyMarginArcsec) {
continue
}
results = append(results, starOccultationScanCandidates(
scanStart, scanEnd, step,
func(tt float64) float64 { return starMoonSeparationArcsec(tt, star, observer) },
func(tt float64) bool {
return starOccultationLatitudePass(tt, star, observer, safetyMarginArcsec)
},
)...)
}
return uniqueOccultationCandidateTimes(results, startTT, endTT)
}
func starOccultationGeocentricCandidateGreatestTimes(startTT, endTT, step float64, star StarCoordinate, safetyMarginArcsec float64) []float64 {
results := make([]float64, 0)
for cycleStart := startTT; cycleStart < endTT; cycleStart += starOccultationSiderealMonthDays {
cycleEnd := math.Min(cycleStart+starOccultationSiderealMonthDays, endTT)
scanStart := math.Max(startTT-step, cycleStart-step)
scanEnd := math.Min(endTT+step, cycleEnd+step)
if !starOccultationLatitudeEnvelopePass(scanStart, scanEnd, star, nil, safetyMarginArcsec) {
continue
}
results = append(results, starOccultationScanCandidates(
scanStart, scanEnd, step,
func(tt float64) float64 { return starOccultationGeocentricSeparationArcsec(tt, star) },
func(tt float64) bool {
return starOccultationLatitudePassGeocentric(tt, star, safetyMarginArcsec)
},
)...)
}
return uniqueOccultationCandidateTimes(results, startTT, endTT)
}
// starOccultationScanCandidates 找出粗扫描中的所有局部最小值。
// 恒星月仍适合作为黄纬预筛桶,但不能假定每个桶恰好只有一个最近接近。
// starOccultationScanCandidates finds every local minimum in a coarse scan.
// A lunar month remains a useful latitude-prefilter bucket, but it must not be treated as a promise that exactly one closest approach exists in that bucket.
func starOccultationScanCandidates(
startTT, endTT, step float64,
value func(float64) float64,
accept func(float64) bool,
) []float64 {
if endTT < startTT {
return nil
}
if endTT == startTT {
if accept(startTT) && finite(value(startTT)) {
return []float64{startTT}
}
return nil
}
if step <= 0 || !finite(step) {
step = starOccultationDefaultStepDays
}
if step > (endTT-startTT)/2 {
step = (endTT - startTT) / 2
}
appendCandidate := func(results *[]float64, tt float64) {
if tt < startTT || tt > endTT || !finite(tt) || !finite(value(tt)) || !accept(tt) {
return
}
if len(*results) == 0 || math.Abs(tt-(*results)[len(*results)-1]) > 60.0/86400.0 {
*results = append(*results, tt)
}
}
results := make([]float64, 0, 2)
leftTT, leftValue := startTT, value(startTT)
centerTT := math.Min(startTT+step, endTT)
centerValue := value(centerTT)
for centerTT < endTT {
rightTT := math.Min(centerTT+step, endTT)
rightValue := value(rightTT)
if finite(leftValue) && finite(centerValue) && finite(rightValue) &&
centerValue <= leftValue && centerValue <= rightValue {
candidate := starOccultationMinimizeValue(leftTT, rightTT, value)
appendCandidate(&results, candidate)
}
leftTT, leftValue = centerTT, centerValue
centerTT, centerValue = rightTT, rightValue
}
return results
}
func uniqueOccultationCandidateTimes(times []float64, startTT, endTT float64) []float64 {
if len(times) == 0 {
return nil
}
sort.Float64s(times)
unique := times[:0]
for _, tt := range times {
if tt < startTT || tt > endTT {
continue
}
if len(unique) == 0 || math.Abs(tt-unique[len(unique)-1]) > 60.0/86400.0 {
unique = append(unique, tt)
}
}
return unique
}
func starOccultationLatitudeEnvelopePass(startTT, endTT float64, star StarCoordinate, observer *Observer, safetyMarginArcsec float64) bool {
minimumLatitude := math.Inf(1)
maximumLatitude := math.Inf(-1)
maximumMoonRadiusDeg := 0.0
for _, tt := range []float64{startTT, (startTT + endTT) / 2, endTT} {
var ra, dec float64
if observer == nil {
ra, dec = starApparentRaDecGeocentric(tt, star)
} else {
ra, dec = starApparentRaDec(tt, star, *observer)
}
_, latitude := RaDecToLoBo(tt, ra, dec)
minimumLatitude = math.Min(minimumLatitude, latitude)
maximumLatitude = math.Max(maximumLatitude, latitude)
moonRadius := MoonSemidiameter(tt)
if observer != nil {
moonRadius = moonTopocentricSemidiameterN(tt, *observer, -1)
}
maximumMoonRadiusDeg = math.Max(maximumMoonRadiusDeg, moonRadius/3600)
}
limit := starOccultationMoonLatitudeDeg + starOccultationLatitudeMarginAS/3600 + safetyMarginArcsec/3600 + maximumMoonRadiusDeg
return minimumLatitude <= limit && maximumLatitude >= -limit
}
func starOccultationGeocentricLongitudeCandidate(startTT, endTT, step float64, star StarCoordinate) float64 {
bestTT := math.NaN()
bestDelta := math.Inf(1)
for tt := startTT; tt <= endTT; tt += step {
delta := math.Abs(signedAngleDifference(HMoonTrueLoN(tt, 8), starOccultationGeocentricStarLongitude(tt, star)))
if delta < bestDelta {
bestDelta = delta
bestTT = tt
}
}
if endTT > startTT {
delta := math.Abs(signedAngleDifference(HMoonTrueLoN(endTT, 8), starOccultationGeocentricStarLongitude(endTT, star)))
if delta < bestDelta {
bestTT = endTT
}
}
return bestTT
}
func starOccultationGeocentricStarLongitude(tt float64, star StarCoordinate) float64 {
ra, dec := starApparentRaDecGeocentric(tt, star)
longitude, _ := RaDecToLoBo(tt, ra, dec)
return longitude
}
func starOccultationMinimizeGeocentricSeparation(seed, startTT, endTT float64, star StarCoordinate) float64 {
halfWindow := 0.75
left := math.Max(startTT, seed-halfWindow)
right := math.Min(endTT, seed+halfWindow)
return starOccultationMinimizeValue(left, right, func(tt float64) float64 {
return starOccultationGeocentricSeparationArcsec(tt, star)
})
}
func starOccultationMinimizeValue(left, right float64, value func(float64) float64) float64 {
if right <= left {
return left
}
const goldenRatio = 0.6180339887498949
x1 := right - goldenRatio*(right-left)
x2 := left + goldenRatio*(right-left)
f1 := value(x1)
f2 := value(x2)
for i := 0; i < 64 && right-left > starOccultationRootToleranceDays; i++ {
if f1 > f2 {
left = x1
x1, f1 = x2, f2
x2 = left + goldenRatio*(right-left)
f2 = value(x2)
} else {
right = x2
x2, f2 = x1, f1
x1 = right - goldenRatio*(right-left)
f1 = value(x1)
}
}
return (left + right) / 2
}
func starOccultationGeocentricSeparationArcsec(tt float64, star StarCoordinate) float64 {
moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, -1)
starRA, starDec := starApparentRaDecGeocentric(tt, star)
return angularSeparationDegrees(moonRA, moonDec, starRA, starDec) * 3600
}
func starOccultationBestObserver(seedTT, startTT, endTT float64, star StarCoordinate) (float64, Observer, float64, bool) {
searchStart := seedTT - occultationPathSearchSpanDays
searchEnd := seedTT + occultationPathSearchSpanDays
outerStart, outerEnd, ok := starOccultationPathWindow(seedTT, searchStart, searchEnd, star, false)
if !ok {
return 0, Observer{}, 0, false
}
greatestTT := starOccultationPathGreatest(seedTT, outerStart, outerEnd, star)
if greatestTT < startTT || greatestTT > endTT {
return 0, Observer{}, 0, false
}
point, pointOK := starOccultationPathCenterPoint(greatestTT, star, time.UTC)
if !pointOK {
frameAt := func(tt float64) (occultationPathFrame, bool) {
return starOccultationPathFrameAt(tt, star)
}
point, pointOK = occultationPathBoundaryPointForFrame(greatestTT, frameAt, time.UTC)
}
if !pointOK {
return 0, Observer{}, 0, false
}
observer := Observer{Longitude: point.Longitude, Latitude: point.Latitude}
position := starMoonPositionAt(greatestTT, star, observer)
moonRadius := moonTopocentricSemidiameterN(greatestTT, observer, -1)
if !position.valid || !finite(moonRadius) {
return 0, Observer{}, 0, false
}
metric := angularSeparationDegrees(position.moonRA, position.moonDec, position.starRA, position.starDec)*3600 - moonRadius
return greatestTT, observer, metric, finite(metric)
}
func normalizeLongitude180(longitude float64) float64 {
longitude = math.Mod(longitude+180, 360)
if longitude < 0 {
longitude += 360
}
return longitude - 180
}
func starOccultationInfoAtGreatest(greatestTT float64, star StarCoordinate, observer Observer, safetyMarginArcsec float64, location *time.Location) (StarOccultationInfo, bool) {
if !starOccultationLatitudePass(greatestTT, star, observer, safetyMarginArcsec) {
return StarOccultationInfo{}, false
}
minimumSeparation := starMoonSeparationArcsec(greatestTT, star, observer)
moonRadius := moonTopocentricSemidiameterN(greatestTT, observer, -1)
if !finite(minimumSeparation) || !finite(moonRadius) || minimumSeparation > moonRadius {
return StarOccultationInfo{}, false
}
greatestPosition := starMoonPositionAt(greatestTT, star, observer)
if !greatestPosition.valid {
return StarOccultationInfo{}, false
}
info := StarOccultationInfo{
TargetID: star.ID,
Observer: observer,
Type: OccultationTotal,
Greatest: occultationTTToLocation(greatestTT, location),
MinimumSeparationArcsec: minimumSeparation,
PositionAngleDeg: occultationPositionAngle(greatestPosition.moonRA, greatestPosition.moonDec, greatestPosition.starRA, greatestPosition.starDec),
MoonSemidiameterArcsec: moonRadius,
MoonAltitudeAtGreatest: occultationAltitude(greatestTT, observer, greatestPosition.moonRA, greatestPosition.moonDec),
MoonAzimuthAtGreatest: occultationAzimuth(greatestTT, observer, greatestPosition.moonRA, greatestPosition.moonDec),
}
info.VisibleAtGreatest = info.MoonAltitudeAtGreatest >= 0
minimumResidual := minimumSeparation - moonRadius
if math.Abs(minimumResidual) <= starOccultationGrazingTolerance {
info.Type = OccultationGrazing
info.Immersion = info.Greatest
info.Emersion = info.Greatest
info.ContactsComplete = true
return info, true
}
immersionTT, immersionOK := starOccultationContact(greatestTT, greatestTT-starOccultationContactSpanDays, -1, star, observer)
emersionTT, emersionOK := starOccultationContact(greatestTT, greatestTT+starOccultationContactSpanDays, 1, star, observer)
if !immersionOK || !emersionOK {
return StarOccultationInfo{}, false
}
info.Immersion = occultationTTToLocation(immersionTT, location)
info.Emersion = occultationTTToLocation(emersionTT, location)
info.ContactsComplete = true
return info, true
}
type starMoonPosition struct {
moonRA, moonDec float64
starRA, starDec float64
valid bool
}
func starMoonPositionAt(tt float64, star StarCoordinate, observer Observer) starMoonPosition {
moonRA, moonDec := moonTopocentricApparentRaDec(tt, observer, -1)
starRA, starDec := starApparentRaDec(tt, star, observer)
return starMoonPosition{
moonRA: moonRA,
moonDec: moonDec,
starRA: starRA,
starDec: starDec,
valid: finite(moonRA) && finite(moonDec) && finite(starRA) && finite(starDec),
}
}
func moonTopocentricApparentRaDec(tt float64, observer Observer, n int) (float64, float64) {
ra, dec := HMoonGeocentricApparentRaDecN(tt, n)
ut := TD2UT(tt, false)
distanceAU := HMoonAwayN(tt, n) / 149597870.7
ra, dec = TopocentricRaDec(ra, dec, observer.Latitude, observer.Longitude, ut, distanceAU, observer.Height)
return normalizeRA(ra), dec
}
func starApparentRaDec(tt float64, star StarCoordinate, observer Observer) (float64, float64) {
ra, dec := starApparentRaDecGeocentric(tt, star)
if star.ParallaxMas > 0 {
// 1 秒差距处 1 角秒对应 206264.806 AU。
// One arcsecond at 1 pc corresponds to 206264.806 AU.
distanceAU := 206264806.247 / star.ParallaxMas
ra, dec = TopocentricRaDec(ra, dec, observer.Latitude, observer.Longitude, TD2UT(tt, false), distanceAU, observer.Height)
ra = normalizeRA(ra)
}
return ra, dec
}
func starApparentRaDecGeocentric(tt float64, star StarCoordinate) (float64, float64) {
epochJD := occultationTimeToTT(star.Epoch)
years := (tt - epochJD) / 365.25
ra := star.RA
dec := star.Dec
precessionEpoch := 2451545.0
if star.Frame == CoordinateFrameICRS {
ra, dec = starICRSToMeanJ2000RaDec(ra, dec)
} else if star.Frame == CoordinateFrameApparentOfDate {
ra, dec = starApparentToMeanRaDec(epochJD, ra, dec, star.ParallaxMas)
precessionEpoch = epochJD
}
cosDec := math.Cos(dec * math.Pi / 180)
if math.Abs(cosDec) > 1e-12 {
ra += years * star.ProperMotionRACosDecMasPerYear / (3600000.0 * cosDec)
}
dec += years * star.ProperMotionDecMasPerYear / 3600000.0
dec = math.Max(-90, math.Min(90, dec))
ra, dec = Precess(ra, dec, precessionEpoch, tt)
return starMeanToApparentRaDec(tt, ra, dec, star.ParallaxMas)
}
func starICRSToMeanJ2000RaDec(ra, dec float64) (float64, float64) {
// IAU SOFA 框架偏差矩阵,将 GCRS/ICRS 向量转换为 J2000.0 平均赤道和春分点。
// IAU SOFA frame-bias matrix, transforming a GCRS/ICRS vector to the mean equator and equinox of J2000.0.
const (
b00 = 0.9999999999999942
b01 = -0.7078279744199197e-7
b02 = 0.8056217146976134e-7
b10 = 0.7078279477857337e-7
b11 = 0.9999999999999969
b12 = 0.3306041454222148e-7
b20 = -0.8056217380986972e-7
b21 = -0.3306040883980553e-7
b22 = 0.9999999999999962
)
raRad := ra * math.Pi / 180
decRad := dec * math.Pi / 180
x := math.Cos(decRad) * math.Cos(raRad)
y := math.Cos(decRad) * math.Sin(raRad)
z := math.Sin(decRad)
biasedX := b00*x + b01*y + b02*z
biasedY := b10*x + b11*y + b12*z
biasedZ := b20*x + b21*y + b22*z
return normalizeRA(math.Atan2(biasedY, biasedX) * 180 / math.Pi),
math.Atan2(biasedZ, math.Hypot(biasedX, biasedY)) * 180 / math.Pi
}
func starMeanToApparentRaDec(tt, ra, dec, parallaxMas float64) (float64, float64) {
longitude, latitude := starMeanEquatorialToEcliptic(tt, ra, dec)
if parallaxMas > 0 {
longitude, latitude = starAnnualParallaxEcliptic(tt, longitude, latitude, parallaxMas)
}
meanLongitude, meanLatitude := longitude, latitude
longitude = normalizeRA(meanLongitude + GXCLo(meanLongitude, meanLatitude, tt)/3600 + Nutation2000Bi(tt))
latitude = meanLatitude + GXCBo(meanLongitude, meanLatitude, tt)/3600
ra, dec = LoBoToRaDec(tt, longitude, latitude)
return normalizeRA(ra), dec
}
func starApparentToMeanRaDec(tt, apparentRA, apparentDec, parallaxMas float64) (float64, float64) {
meanRA, meanDec := apparentRA, apparentDec
for i := 0; i < 8; i++ {
computedRA, computedDec := starMeanToApparentRaDec(tt, meanRA, meanDec, parallaxMas)
meanRA = normalizeRA(meanRA - signedAngleDifference(computedRA, apparentRA))
meanDec -= computedDec - apparentDec
}
return meanRA, math.Max(-90, math.Min(90, meanDec))
}
func starMeanEquatorialToEcliptic(tt, ra, dec float64) (float64, float64) {
obliquity := EclipticObliquity(tt, false) * math.Pi / 180
ra *= math.Pi / 180
dec *= math.Pi / 180
longitude := math.Atan2(
math.Sin(ra)*math.Cos(obliquity)+math.Tan(dec)*math.Sin(obliquity),
math.Cos(ra),
) * 180 / math.Pi
latitude := math.Asin(
math.Sin(dec)*math.Cos(obliquity)-math.Cos(dec)*math.Sin(obliquity)*math.Sin(ra),
) * 180 / math.Pi
return normalizeRA(longitude), latitude
}
func starAnnualParallaxEcliptic(tt, longitude, latitude, parallaxMas float64) (float64, float64) {
distanceAU := 206264806.247 / parallaxMas
longitudeRad := longitude * math.Pi / 180
latitudeRad := latitude * math.Pi / 180
cosLatitude := math.Cos(latitudeRad)
starX := distanceAU * cosLatitude * math.Cos(longitudeRad)
starY := distanceAU * cosLatitude * math.Sin(longitudeRad)
starZ := distanceAU * math.Sin(latitudeRad)
earthLongitude := normalizeRA(HSunTrueLoN(tt, -1)+180) * math.Pi / 180
earthDistance := EarthAwayN(tt, -1)
starX -= earthDistance * math.Cos(earthLongitude)
starY -= earthDistance * math.Sin(earthLongitude)
longitude = math.Atan2(starY, starX) * 180 / math.Pi
latitude = math.Atan2(starZ, math.Hypot(starX, starY)) * 180 / math.Pi
return normalizeRA(longitude), latitude
}
func starOccultationLongitudeCandidate(startTT, endTT, step float64, star StarCoordinate, observer Observer) float64 {
bestTT := math.NaN()
bestDelta := math.Inf(1)
for tt := startTT; tt <= endTT; tt += step {
delta := starOccultationLongitudeDistance(tt, star, observer)
if delta < bestDelta {
bestDelta = delta
bestTT = tt
}
}
if endTT > startTT {
delta := starOccultationLongitudeDistance(endTT, star, observer)
if delta < bestDelta {
bestTT = endTT
}
}
return bestTT
}
func starOccultationLongitudeDistance(tt float64, star StarCoordinate, observer Observer) float64 {
moonLongitude := HMoonTrueLoN(tt, 8)
starRA, starDec := starApparentRaDec(tt, star, observer)
starLongitude, _ := RaDecToLoBo(tt, starRA, starDec)
return math.Abs(signedAngleDifference(moonLongitude, starLongitude))
}
func starOccultationMinimizeSeparation(seed, startTT, endTT float64, star StarCoordinate, observer Observer) float64 {
halfWindow := 0.75
left := math.Max(startTT, seed-halfWindow)
right := math.Min(endTT, seed+halfWindow)
return starOccultationMinimizeValue(left, right, func(tt float64) float64 {
return starMoonSeparationArcsec(tt, star, observer)
})
}
func starMoonSeparationArcsec(tt float64, star StarCoordinate, observer Observer) float64 {
position := starMoonPositionAt(tt, star, observer)
if !position.valid {
return math.Inf(1)
}
return angularSeparationDegrees(position.moonRA, position.moonDec, position.starRA, position.starDec) * 3600
}
func starOccultationLatitudePass(tt float64, star StarCoordinate, observer Observer, safetyMarginArcsec float64) bool {
starRA, starDec := starApparentRaDec(tt, star, observer)
_, starLatitude := RaDecToLoBo(tt, starRA, starDec)
moonLatitude := HMoonTrueBoN(tt, 8)
moonRadius := moonTopocentricSemidiameterN(tt, observer, -1)
limit := moonRadius + starOccultationLatitudeMarginAS + safetyMarginArcsec
return math.Abs(starLatitude-moonLatitude)*3600 <= limit
}
func starOccultationLatitudePassGeocentric(tt float64, star StarCoordinate, safetyMarginArcsec float64) bool {
starRA, starDec := starApparentRaDecGeocentric(tt, star)
_, starLatitude := RaDecToLoBo(tt, starRA, starDec)
moonLatitude := HMoonTrueBoN(tt, 8)
limit := MoonSemidiameter(tt) + starOccultationLatitudeMarginAS + safetyMarginArcsec
return math.Abs(starLatitude-moonLatitude)*3600 <= limit
}
func starOccultationContact(greatestTT, boundaryTT float64, direction int, star StarCoordinate, observer Observer) (float64, bool) {
valueAtGreatest := starMoonSeparationArcsec(greatestTT, star, observer) - moonTopocentricSemidiameterN(greatestTT, observer, -1)
if !finite(valueAtGreatest) || valueAtGreatest > 0 {
return math.NaN(), false
}
currentTT := greatestTT
currentValue := valueAtGreatest
step := starOccultationContactStepDays
for i := 0; i < starOccultationMaxContactSteps; i++ {
nextTT := currentTT + float64(direction)*step
if direction < 0 && nextTT < boundaryTT {
nextTT = boundaryTT
}
if direction > 0 && nextTT > boundaryTT {
nextTT = boundaryTT
}
nextValue := starMoonSeparationArcsec(nextTT, star, observer) - moonTopocentricSemidiameterN(nextTT, observer, -1)
if finite(nextValue) && nextValue >= 0 {
return starOccultationRoot(currentTT, nextTT, currentValue, nextValue, star, observer)
}
if nextTT == boundaryTT {
return math.NaN(), false
}
currentTT = nextTT
currentValue = nextValue
}
return math.NaN(), false
}
func starOccultationRoot(leftTT, rightTT, leftValue, rightValue float64, star StarCoordinate, observer Observer) (float64, bool) {
if !finite(leftValue) || !finite(rightValue) || leftValue*rightValue > 0 {
return math.NaN(), false
}
if leftValue == 0 {
return leftTT, true
}
if rightValue == 0 {
return rightTT, true
}
for i := 0; i < 64 && math.Abs(rightTT-leftTT) > starOccultationRootToleranceDays; i++ {
midTT := (leftTT + rightTT) / 2
midValue := starMoonSeparationArcsec(midTT, star, observer) - moonTopocentricSemidiameterN(midTT, observer, -1)
if !finite(midValue) {
return math.NaN(), false
}
if leftValue*midValue <= 0 {
rightTT, rightValue = midTT, midValue
} else {
leftTT, leftValue = midTT, midValue
}
}
return (leftTT + rightTT) / 2, true
}
func starOccultationCoarseStepDays(options OccultationSearchOptions) float64 {
step := starOccultationDefaultStepDays
if options.MaxStep > 0 {
requested := options.MaxStep.Hours() / 24
if requested > 0 && requested < step {
step = requested
}
}
return math.Max(step, occultationSearchMinimumStep.Hours()/24)
}
func angularSeparationDegrees(ra1, dec1, ra2, dec2 float64) float64 {
ra1 *= math.Pi / 180
ra2 *= math.Pi / 180
dec1 *= math.Pi / 180
dec2 *= math.Pi / 180
cosSeparation := math.Sin(dec1)*math.Sin(dec2) + math.Cos(dec1)*math.Cos(dec2)*math.Cos(ra1-ra2)
return math.Acos(math.Max(-1, math.Min(1, cosSeparation))) * 180 / math.Pi
}
func occultationPositionAngle(moonRA, moonDec, starRA, starDec float64) float64 {
deltaRA := (starRA - moonRA) * math.Pi / 180
moonDecRad := moonDec * math.Pi / 180
starDecRad := starDec * math.Pi / 180
y := math.Sin(deltaRA) * math.Cos(starDecRad)
x := math.Cos(moonDecRad)*math.Sin(starDecRad) - math.Sin(moonDecRad)*math.Cos(starDecRad)*math.Cos(deltaRA)
return normalizeRA(math.Atan2(y, x) * 180 / math.Pi)
}
func occultationAltitude(tt float64, observer Observer, ra, dec float64) float64 {
hourAngle := signedAngleDifference(ApparentSiderealTime(TD2UT(tt, false))*15+observer.Longitude, ra) * math.Pi / 180
lat := observer.Latitude * math.Pi / 180
declination := dec * math.Pi / 180
sinAltitude := math.Sin(lat)*math.Sin(declination) + math.Cos(lat)*math.Cos(declination)*math.Cos(hourAngle)
return math.Asin(math.Max(-1, math.Min(1, sinAltitude))) * 180 / math.Pi
}
func occultationAzimuth(tt float64, observer Observer, ra, dec float64) float64 {
hourAngle := signedAngleDifference(ApparentSiderealTime(TD2UT(tt, false))*15+observer.Longitude, ra) * math.Pi / 180
lat := observer.Latitude * math.Pi / 180
declination := dec * math.Pi / 180
y := math.Sin(hourAngle)
x := math.Cos(hourAngle)*math.Sin(lat) - math.Tan(declination)*math.Cos(lat)
return normalizeRA(math.Atan2(y, x)*180/math.Pi + 180)
}
func signedAngleDifference(a, b float64) float64 {
difference := math.Mod(a-b+180, 360)
if difference < 0 {
difference += 360
}
return difference - 180
}
func normalizeRA(ra float64) float64 {
ra = math.Mod(ra, 360)
if ra < 0 {
ra += 360
}
return ra
}
func occultationTimeToTT(value time.Time) float64 {
return TD2UT(Date2JDE(value.UTC()), true)
}
func occultationTTToLocation(tt float64, location *time.Location) time.Time {
if location == nil {
location = time.UTC
}
return JDE2DateByZone(TD2UT(tt, false), location, false)
}
+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
}
+86
View File
@@ -0,0 +1,86 @@
package basic
import (
"math"
"testing"
"time"
)
func TestStarOccultationDiagramUsesSolvedLocalContacts(t *testing.T) {
location := time.FixedZone("CST", 8*3600)
star := StarCoordinate{
ID: "HR 4799",
RA: 189.1975,
Dec: -5.831944444444,
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
Frame: CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -28,
ProperMotionDecMasPerYear: -18,
}
events, err := FindStarOccultations(
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
star, 121.56601, 6.80706, 0, OccultationSearchOptions{},
)
if err != nil {
t.Fatalf("FindStarOccultations() error = %v", err)
}
if len(events) != 1 {
t.Fatalf("FindStarOccultations() returned %d events, want 1", len(events))
}
diagram := StarOccultationDiagram(events[0], star, StarOccultationDiagramOptions{StepDays: 2.0 / 1440})
if len(diagram.Frames) < 3 {
t.Fatalf("StarOccultationDiagram() frame count = %d, want at least 3", len(diagram.Frames))
}
if !starOccultationDiagramFrameHasLabel(diagram.Frames[0], "Immersion") {
t.Fatalf("first frame labels = %v, want Immersion", diagram.Frames[0].Labels)
}
last := diagram.Frames[len(diagram.Frames)-1]
if !starOccultationDiagramFrameHasLabel(last, "Emersion") {
t.Fatalf("last frame labels = %v, want Emersion", last.Labels)
}
if residual := math.Abs(diagram.Frames[0].SeparationArcsec - diagram.Frames[0].MoonRadiusArcsec); residual > 0.1 {
t.Fatalf("immersion limb residual = %.6f arcsec, want <= 0.1", residual)
}
if residual := math.Abs(last.SeparationArcsec - last.MoonRadiusArcsec); residual > 0.1 {
t.Fatalf("emersion limb residual = %.6f arcsec, want <= 0.1", residual)
}
greatest, ok := starOccultationDiagramFrameByLabel(diagram.Frames, "Greatest")
if !ok {
t.Fatalf("diagram does not contain Greatest frame")
}
if !greatest.BehindMoon || greatest.SeparationArcsec >= greatest.MoonRadiusArcsec {
t.Fatalf("greatest frame is not behind Moon: separation=%.6f radius=%.6f", greatest.SeparationArcsec, greatest.MoonRadiusArcsec)
}
wrongStar := star
wrongStar.RA += 30
if frames := StarOccultationDiagram(events[0], wrongStar, StarOccultationDiagramOptions{}).Frames; len(frames) != 0 {
t.Fatalf("same-ID wrong coordinate produced %d frames, want none", len(frames))
}
infoWithoutID := events[0]
infoWithoutID.TargetID = ""
wrongStar.ID = ""
if frames := StarOccultationDiagram(infoWithoutID, wrongStar, StarOccultationDiagramOptions{}).Frames; len(frames) != 0 {
t.Fatalf("empty-ID wrong coordinate produced %d frames, want none", len(frames))
}
}
func starOccultationDiagramFrameHasLabel(frame StarOccultationDiagramFrame, label string) bool {
for _, current := range frame.Labels {
if current == label {
return true
}
}
return false
}
func starOccultationDiagramFrameByLabel(frames []StarOccultationDiagramFrame, label string) (StarOccultationDiagramFrame, bool) {
for _, frame := range frames {
if starOccultationDiagramFrameHasLabel(frame, label) {
return frame, true
}
}
return StarOccultationDiagramFrame{}, false
}
+196
View File
@@ -0,0 +1,196 @@
package basic
import (
"math"
"testing"
"time"
)
func TestStarOccultationLatitudeEnvelopePrefilter(t *testing.T) {
start := occultationTimeToTT(time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC))
end := start + starOccultationSiderealMonthDays
polar := StarCoordinate{RA: 0, Dec: 89, Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameICRS}
if starOccultationLatitudeEnvelopePass(start, end, polar, nil, 0) {
t.Fatal("polar star should be rejected by global ecliptic-latitude envelope")
}
if !starOccultationLatitudeEnvelopePass(start, end, hr4799OccultationCoordinateForTest(), nil, 0) {
t.Fatal("HR 4799 should pass the global ecliptic-latitude envelope")
}
}
func TestStarOccultationScanCandidatesFindsMultipleLocalMinima(t *testing.T) {
value := func(tt float64) float64 {
return (tt-1)*(tt-1)*(tt-3)*(tt-3) + 0.001
}
got := starOccultationScanCandidates(0, 4, 0.5, value, func(float64) bool { return true })
if len(got) != 2 {
t.Fatalf("candidate count = %d, want 2: %v", len(got), got)
}
if math.Abs(got[0]-1) > 1e-6 || math.Abs(got[1]-3) > 1e-6 {
t.Fatalf("candidate minima = %v, want [1 3]", got)
}
}
func TestStarOccultationScanCandidatesFindsNarrowWindowMidpoint(t *testing.T) {
const (
start = 10.0
end = 10.1
want = (start + end) / 2
)
value := func(tt float64) float64 { return (tt - want) * (tt - want) }
got := starOccultationScanCandidates(start, end, starOccultationDefaultStepDays, value, func(float64) bool { return true })
if len(got) != 1 {
t.Fatalf("candidate count = %d, want 1: %v", len(got), got)
}
if math.Abs(got[0]-want) > 1e-7 {
t.Fatalf("candidate midpoint = %.12f, want %.12f", got[0], want)
}
}
func TestUniqueOccultationCandidateTimesFiltersSingleOutsideCandidate(t *testing.T) {
if got := uniqueOccultationCandidateTimes([]float64{9.9}, 10, 11); len(got) != 0 {
t.Fatalf("outside candidate was not filtered: %v", got)
}
got := uniqueOccultationCandidateTimes([]float64{10.5}, 10, 11)
if len(got) != 1 || got[0] != 10.5 {
t.Fatalf("inside candidate = %v, want [10.5]", got)
}
}
func TestStarOccultationPropagatesApparentCoordinateProperMotion(t *testing.T) {
epoch := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
target := epoch.Add(365*24*time.Hour + 6*time.Hour)
star := StarCoordinate{
RA: 10,
Dec: 20,
Epoch: epoch,
Frame: CoordinateFrameApparentOfDate,
ProperMotionRACosDecMasPerYear: 360000,
ProperMotionDecMasPerYear: -720000,
}
baseline := star
baseline.ProperMotionRACosDecMasPerYear = 0
baseline.ProperMotionDecMasPerYear = 0
baselineRA, baselineDec := starApparentRaDec(occultationTimeToTT(target), baseline, Observer{})
ra, dec := starApparentRaDec(occultationTimeToTT(target), star, Observer{})
raMotion := signedAngleDifference(ra, baselineRA) * math.Cos(baselineDec*math.Pi/180)
decMotion := dec - baselineDec
if math.Abs(raMotion-0.1) > 0.001 {
t.Fatalf("propagated RA*cos(Dec) motion = %.10f deg, want 0.1", raMotion)
}
if math.Abs(decMotion-(-0.2)) > 0.001 {
t.Fatalf("propagated Dec motion = %.10f deg, want -0.2", decMotion)
}
}
func TestStarOccultationApparentOfDateRoundTripsAtEpoch(t *testing.T) {
epoch := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
star := StarCoordinate{RA: 189.5, Dec: -6.2, Epoch: epoch, Frame: CoordinateFrameApparentOfDate, ParallaxMas: 100}
ra, dec := starApparentRaDecGeocentric(occultationTimeToTT(epoch), star)
if math.Abs(signedAngleDifference(ra, star.RA))*3600 > 1e-5 || math.Abs(dec-star.Dec)*3600 > 1e-5 {
t.Fatalf("apparent coordinate did not round-trip at epoch: got %.12f %.12f", ra, dec)
}
}
func TestStarOccultationApparentPlaceCorrections(t *testing.T) {
location := time.FixedZone("CST", 8*3600)
tt := occultationTimeToTT(time.Date(2025, 6, 5, 20, 2, 7, 700000000, location))
star := hr4799OccultationCoordinateForTest()
gotRA, gotDec := starApparentRaDecGeocentric(tt, star)
if math.Abs(signedAngleDifference(gotRA, 189.527817)) > 0.0002 || math.Abs(gotDec-(-5.973401)) > 0.0002 {
t.Fatalf("apparent place = %.9f %.9f, want near 189.527817 -5.973401", gotRA, gotDec)
}
years := (tt - Date2JDE(star.Epoch.UTC())) / 365.25
meanRA := star.RA + years*star.ProperMotionRACosDecMasPerYear/(3600000*math.Cos(star.Dec*math.Pi/180))
meanDec := star.Dec + years*star.ProperMotionDecMasPerYear/3600000
meanRA, meanDec = Precess(meanRA, meanDec, 2451545, tt)
correction := angularSeparationDegrees(meanRA, meanDec, gotRA, gotDec) * 3600
if correction < 5 || correction > 30 {
t.Fatalf("apparent-place correction = %.6f arcsec, want a plausible annual correction", correction)
}
}
func TestStarOccultationApparentPlaceAppliesAnnualParallax(t *testing.T) {
star := hr4799OccultationCoordinateForTest()
tt := occultationTimeToTT(time.Date(2025, 6, 5, 12, 0, 0, 0, time.UTC))
withoutRA, withoutDec := starApparentRaDecGeocentric(tt, star)
star.ParallaxMas = 1000
withRA, withDec := starApparentRaDecGeocentric(tt, star)
shift := angularSeparationDegrees(withoutRA, withoutDec, withRA, withDec) * 3600
if shift < 0.05 || shift > 1.1 {
t.Fatalf("annual parallax shift = %.6f arcsec, want (0.05, 1.1]", shift)
}
}
func TestStarOccultationICRSAppliesJ2000FrameBias(t *testing.T) {
epoch := time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC)
icrs := StarCoordinate{RA: 0, Dec: 0, Epoch: epoch, Frame: CoordinateFrameICRS}
j2000 := icrs
j2000.Frame = CoordinateFrameJ2000
tt := occultationTimeToTT(epoch)
icrsRA, icrsDec := starApparentRaDecGeocentric(tt, icrs)
j2000RA, j2000Dec := starApparentRaDecGeocentric(tt, j2000)
raBiasMas := signedAngleDifference(icrsRA, j2000RA) * 3600000
decBiasMas := (icrsDec - j2000Dec) * 3600000
if math.Abs(raBiasMas-14.6) > 0.1 || math.Abs(decBiasMas-(-16.617)) > 0.1 {
t.Fatalf("ICRS frame bias = %.6f %.6f mas, want about 14.6 -16.617", raBiasMas, decBiasMas)
}
}
func TestRefinedStarOccultationCenterLineRespectsWidthTolerance(t *testing.T) {
star := hr4799OccultationCoordinateForTest()
start := time.Date(2025, time.June, 5, 0, 0, 0, 0, time.UTC)
paths, err := FindStarOccultationPaths(
start, start.Add(24*time.Hour), star,
OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 50},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
for index, point := range paths[0].CenterLine {
exact, ok := starOccultationPathCenterPoint(centerTimeTT(point.Time), star, time.UTC)
if !ok {
t.Fatalf("exact center point %d is unavailable", index)
}
if difference := math.Abs(point.WidthKM - exact.WidthKM); difference > occultationPathWidthToleranceKM {
t.Fatalf("center point %d width differs from exact value by %.9f km: got %.9f want %.9f",
index, difference, point.WidthKM, exact.WidthKM)
}
}
}
func TestRefineOccultationPathWidthsBoundsSmoothInterpolationError(t *testing.T) {
start := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC)
startTT := occultationTimeToTT(start)
widthAt := func(tt float64) (float64, bool) {
seconds := (tt - startTT) * 86400
return 3500 + 0.0002*(seconds-50)*(seconds-50), true
}
points := make([]OccultationPathPoint, 101)
for index := range points {
points[index].Time = start.Add(time.Duration(index) * time.Second)
}
points[0].WidthKM, _ = widthAt(centerTimeTT(points[0].Time))
points[len(points)-1].WidthKM, _ = widthAt(centerTimeTT(points[len(points)-1].Time))
refineOccultationPathWidths(points, widthAt)
for index, point := range points {
exact, _ := widthAt(centerTimeTT(point.Time))
if difference := math.Abs(point.WidthKM - exact); difference > occultationPathWidthToleranceKM {
t.Fatalf("interpolated width %d differs by %.9f km, tolerance %.9f", index, difference, occultationPathWidthToleranceKM)
}
}
}
func hr4799OccultationCoordinateForTest() StarCoordinate {
return StarCoordinate{
ID: "HR 4799",
RA: 189.1975,
Dec: -5.831944444444,
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
Frame: CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -28,
ProperMotionDecMasPerYear: -18,
}
}
+30 -15
View File
@@ -52,6 +52,9 @@ func OrbitHourAngle(jde, observerLon, observerLat, timezone, observerHeight floa
// OrbitCulminationTime 返回轨道目标的中天时刻,输入输出均沿用本仓库现有观测函数的 JD 语义。
func OrbitCulminationTime(jde, observerLon, observerLat, timezone, observerHeight float64, elements OrbitElements) float64 {
if !isFiniteFloat(jde) || !isFiniteFloat(observerLon) || !isFiniteFloat(observerLat) || !isFiniteFloat(timezone) || !isFiniteFloat(observerHeight) {
return math.NaN()
}
jde = math.Floor(jde) + 0.5
estimateJD := jde + Limit360(360-OrbitHourAngle(jde, observerLon, observerLat, timezone, observerHeight, elements))/15.0/24.0*0.99726851851851851851
normalizedHourAngle := func(jde float64) float64 {
@@ -61,14 +64,14 @@ func OrbitCulminationTime(jde, observerLon, observerLat, timezone, observerHeigh
}
return currentHourAngle
}
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
hourAngleDelta := normalizedHourAngle(prevJD) - 360
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005) - normalizedHourAngle(prevJD-0.000005)) / 0.00001
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return hourAngleDelta / hourAngleSlope
})
if !ok {
return math.NaN()
}
return estimateJD
}
@@ -84,19 +87,33 @@ func OrbitSetTime(jde, observerLon, observerLat, timezone, aeroCorrection, obser
}
func orbitRiseDown(jde, observerLon, observerLat, timezone, aeroCorrection, observerHeight float64, elements OrbitElements, isRise bool) (float64, error) {
if !isFiniteFloat(jde) || !isFiniteFloat(observerLon) || !isFiniteFloat(observerLat) || !isFiniteFloat(timezone) || !isFiniteFloat(aeroCorrection) || !isFiniteFloat(observerHeight) {
return 0, ErrInvalidObservationInput
}
localTimezone := math.Round(observerLon / 15)
targetAltitude := StandardAltitudePlanet(aeroCorrection, observerHeight, observerLat)
culminationJD := OrbitCulminationTime(jde, observerLon, observerLat, localTimezone, observerHeight, elements)
if OrbitHeight(culminationJD, observerLon, observerLat, localTimezone, observerHeight, elements) < targetAltitude {
if !isFiniteFloat(culminationJD) {
return 0, ErrInvalidObservationInput
}
culminationHeight := OrbitHeight(culminationJD, observerLon, observerLat, localTimezone, observerHeight, elements)
previousHeight := OrbitHeight(culminationJD-0.5, observerLon, observerLat, localTimezone, observerHeight, elements)
if !isFiniteFloat(culminationHeight) || !isFiniteFloat(previousHeight) {
return 0, ErrInvalidObservationInput
}
if culminationHeight < targetAltitude {
return 0, ErrNeverRise
}
if OrbitHeight(culminationJD-0.5, observerLon, observerLat, localTimezone, observerHeight, elements) > targetAltitude {
if previousHeight > targetAltitude {
return 0, ErrNeverSet
}
_, dec, _ := orbitTopocentricObservation(culminationJD, observerLon, observerLat, observerHeight, localTimezone, elements)
cosHourAngle := (Sin(targetAltitude) - Sin(dec)*Sin(observerLat)) / (Cos(dec) * Cos(observerLat))
if !isFiniteFloat(dec) || !isFiniteFloat(cosHourAngle) {
return 0, ErrInvalidObservationInput
}
var eventJD float64
if math.Abs(cosHourAngle) <= 1 {
@@ -122,15 +139,13 @@ func orbitRiseDown(jde, observerLon, observerLat, timezone, aeroCorrection, obse
}
}
estimateJD := eventJD
for {
prevJD := estimateJD
estimateJD, ok := eventNewtonRefine(eventJD, 0.00001, func(prevJD float64) float64 {
altitudeDelta := OrbitHeight(prevJD, observerLon, observerLat, localTimezone, observerHeight, elements) - targetAltitude
altitudeSlope := (OrbitHeight(prevJD+0.000005, observerLon, observerLat, localTimezone, observerHeight, elements) - OrbitHeight(prevJD-0.000005, observerLon, observerLat, localTimezone, observerHeight, elements)) / 0.00001
estimateJD = prevJD - altitudeDelta/altitudeSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return altitudeDelta / altitudeSlope
})
if !ok {
return 0, ErrInvalidObservationInput
}
return estimateJD - localTimezone/24 + timezone/24, nil
}
+93
View File
@@ -0,0 +1,93 @@
package basic
import (
"math"
"testing"
)
func TestPlanetEventsRejectNonFiniteQuery(t *testing.T) {
nan := math.NaN()
events := []struct {
name string
fn func(float64) float64
}{
{"Mars conjunction", NextMarsConjunction},
{"Jupiter conjunction", NextJupiterConjunction},
{"Saturn conjunction", NextSaturnConjunction},
{"Uranus conjunction", NextUranusConjunction},
{"Neptune conjunction", NextNeptuneConjunction},
{"Mercury conjunction", NextMercuryConjunction},
{"Mercury greatest elongation", NextMercuryGreatestElongation},
{"Venus greatest elongation", NextVenusGreatestElongation},
{"Venus station", NextVenusProgradeToRetrograde},
}
for _, tc := range events {
t.Run(tc.name, func(t *testing.T) {
if got := tc.fn(nan); !math.IsNaN(got) {
t.Fatalf("got %v, want NaN", got)
}
})
}
}
func TestPlanetObservationRejectsNonFiniteQuery(t *testing.T) {
nan := math.NaN()
culminations := []struct {
name string
fn func(float64, float64, float64) float64
}{
{"Mercury", MercuryCulminationTime},
{"Venus", VenusCulminationTime},
{"Mars", MarsCulminationTime},
{"Jupiter", JupiterCulminationTime},
{"Saturn", SaturnCulminationTime},
{"Uranus", UranusCulminationTime},
{"Neptune", NeptuneCulminationTime},
}
for _, tc := range culminations {
t.Run(tc.name, func(t *testing.T) {
if got := tc.fn(nan, 0, 0); !math.IsNaN(got) {
t.Fatalf("got %v, want NaN", got)
}
})
}
if got := OrbitCulminationTime(nan, 0, 0, 0, 0, OrbitElements{}); !math.IsNaN(got) {
t.Fatalf("OrbitCulminationTime got %v, want NaN", got)
}
if got := MercuryCulminationTimeN(nan, 0, 0, 16); !math.IsNaN(got) {
t.Fatalf("MercuryCulminationTimeN got %v, want NaN", got)
}
if _, err := MarsRiseTime(nan, 0, 0, 0, 0, 0); err != ErrInvalidObservationInput {
t.Fatalf("MarsRiseTime error = %v, want ErrInvalidObservationInput", err)
}
if _, err := MercuryRiseTimeN(nan, 0, 0, 0, 0, 0, 16); err != ErrInvalidObservationInput {
t.Fatalf("MercuryRiseTimeN error = %v, want ErrInvalidObservationInput", err)
}
if got := JupiterGalileanSatelliteState(nan, 1); !math.IsNaN(got.X) {
t.Fatalf("JupiterGalileanSatelliteState X = %v, want NaN", got.X)
}
}
func TestSharedObservationRejectsNonFiniteQuery(t *testing.T) {
nan := math.NaN()
if got := MoonCulminationTime(nan, 0, 0, 0); !math.IsNaN(got) {
t.Fatalf("MoonCulminationTime got %v, want NaN", got)
}
if got := StarCulminationTime(nan, 0, 0, 0); !math.IsNaN(got) {
t.Fatalf("StarCulminationTime got %v, want NaN", got)
}
if got := CalcMoonSHByJDE(nan, 0); !math.IsNaN(got) {
t.Fatalf("CalcMoonSHByJDE got %v, want NaN", got)
}
if _, err := GetSunRiseTime(nan, 0, 0, 0, 0, 0); err != ErrInvalidObservationInput {
t.Fatalf("GetSunRiseTime error = %v, want ErrInvalidObservationInput", err)
}
if _, err := GetMoonRiseTime(nan, 0, 0, 0, 0, 0); err != ErrInvalidObservationInput {
t.Fatalf("GetMoonRiseTime error = %v, want ErrInvalidObservationInput", err)
}
if _, err := StarRiseTime(nan, 0, 0, 0, 0, 0, 0, false); err != ErrInvalidObservationInput {
t.Fatalf("StarRiseTime error = %v, want ErrInvalidObservationInput", err)
}
}
+15 -5
View File
@@ -195,9 +195,23 @@ func nextPlanetTransitSeasonTT(jdTT float64, cfg planetTransitConfig, direction
for nodeOffset := 0; nodeOffset <= 1; nodeOffset++ {
candidate := estimatePlanetTransitSeasonTT(jdTT, cfg, nodeOffset, direction)
candidate = refinePlanetTransitSeasonTT(candidate, cfg, nodeOffset)
for !planetTransitMatchesDirection(candidate, jdTT, direction, false) {
if !isFiniteFloat(candidate) {
continue
}
matchedDirection := false
for i := 0; i < eventDirectionalSearchIterations; i++ {
if planetTransitMatchesDirection(candidate, jdTT, direction, false) {
matchedDirection = true
break
}
candidate += float64(direction) * planetTransitTropicalYearDays
candidate = refinePlanetTransitSeasonTT(candidate, cfg, nodeOffset)
if !isFiniteFloat(candidate) {
break
}
}
if !matchedDirection {
continue
}
if !isFiniteFloat(best) || math.Abs(candidate-jdTT) < math.Abs(best-jdTT) {
best = candidate
@@ -514,7 +528,3 @@ func planetTransitAngleDelta(diff float64) float64 {
}
return diff
}
func isFiniteFloat(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
+30 -15
View File
@@ -92,6 +92,9 @@ func planetHourAngleN(jd, lon, timezone float64, n int, apparentRa func(float64,
}
func planetCulminationTimeN(jde, lon, timezone float64, n int, hourAngle func(float64, float64, float64, int) float64) float64 {
if !isFiniteFloat(jde) || !isFiniteFloat(lon) || !isFiniteFloat(timezone) {
return math.NaN()
}
jde = math.Floor(jde) + 0.5
estimateJD := jde + Limit360(360-hourAngle(jde, lon, timezone, n))/15.0/24.0*0.99726851851851851851
normalizedHourAngle := func(jde, lon, timezone float64) float64 {
@@ -101,31 +104,45 @@ func planetCulminationTimeN(jde, lon, timezone float64, n int, hourAngle func(fl
}
return currentHourAngle
}
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return hourAngleDelta / hourAngleSlope
})
if !ok {
return math.NaN()
}
return estimateJD
}
func planetRiseDownN(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, isRise bool, n int, culmination func(float64, float64, float64, int) float64, height func(float64, float64, float64, float64, int) float64, declination planetDeclinationFuncN) (float64, error) {
if !isFiniteFloat(jd) || !isFiniteFloat(lon) || !isFiniteFloat(lat) || !isFiniteFloat(timezone) || !isFiniteFloat(aeroCorrection) || !isFiniteFloat(observerHeight) {
return 0, ErrInvalidObservationInput
}
jd = math.Floor(jd) + 0.5
localTimezone := math.Round(lon / 15)
targetAltitude := StandardAltitudePlanet(aeroCorrection, observerHeight, lat)
culminationJD := culmination(jd, lon, localTimezone, n)
if height(culminationJD, lon, lat, localTimezone, n) < targetAltitude {
if !isFiniteFloat(culminationJD) {
return 0, ErrInvalidObservationInput
}
culminationHeight := height(culminationJD, lon, lat, localTimezone, n)
previousHeight := height(culminationJD-0.5, lon, lat, localTimezone, n)
if !isFiniteFloat(culminationHeight) || !isFiniteFloat(previousHeight) {
return 0, ErrInvalidObservationInput
}
if culminationHeight < targetAltitude {
return 0, ErrNeverRise
}
if height(culminationJD-0.5, lon, lat, localTimezone, n) > targetAltitude {
if previousHeight > targetAltitude {
return 0, ErrNeverSet
}
dec := declination(TD2UT(culminationJD-localTimezone/24, true), n)
cosHourAngle := (Sin(targetAltitude) - Sin(dec)*Sin(lat)) / (Cos(dec) * Cos(lat))
if !isFiniteFloat(dec) || !isFiniteFloat(cosHourAngle) {
return 0, ErrInvalidObservationInput
}
var eventJD float64
if math.Abs(cosHourAngle) <= 1 {
hourOffset := ArcCos(cosHourAngle) / 15
@@ -149,15 +166,13 @@ func planetRiseDownN(jd, lon, lat, timezone, aeroCorrection, observerHeight floa
}
}
}
estimateJD := eventJD
for {
prevJD := estimateJD
estimateJD, ok := eventNewtonRefine(eventJD, 0.00001, func(prevJD float64) float64 {
altitudeDelta := height(prevJD, lon, lat, localTimezone, n) - targetAltitude
altitudeSlope := (height(prevJD+0.000005, lon, lat, localTimezone, n) - height(prevJD-0.000005, lon, lat, localTimezone, n)) / 0.00001
estimateJD = prevJD - altitudeDelta/altitudeSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return altitudeDelta / altitudeSlope
})
if !ok {
return 0, ErrInvalidObservationInput
}
return estimateJD - localTimezone/24 + timezone/24, nil
}
+120 -42
View File
@@ -4,32 +4,37 @@ import "math"
const (
refractionStandardPressureHPa = 1010.0
refractionStandardTemperatureK = 283.0
refractionStandardTemperatureC = 10.0
refractionStandardTemperatureK = 283.15
refractionAbsoluteZeroC = -273.15
refractionLowerLimitAltitudeDeg = -5.0
refractionUpperLimitAltitudeDeg = 90.0
)
// RefractionFromApparentAltitude 大气折射修正量,单位度;输入为视高度角。
// 返回值应从高度角加上后得到高度角。
// 返回值应从高度角减去后得到高度角。
// 若模型在支持的真高度范围内没有逆解,则返回 NaN。
func RefractionFromApparentAltitude(apparentAltitude, pressureHPa, temperatureC float64) float64 {
if !validRefractionInputs(apparentAltitude, pressureHPa, temperatureC) {
return math.NaN()
}
if apparentAltitude < refractionLowerLimitAltitudeDeg || apparentAltitude > refractionUpperLimitAltitudeDeg {
if apparentAltitude <= refractionLowerLimitAltitudeDeg || apparentAltitude >= refractionUpperLimitAltitudeDeg {
return 0
}
angle := (apparentAltitude + 10.3/(apparentAltitude+5.11)) * math.Pi / 180
return refractionScale(pressureHPa, temperatureC) * (1.02 / math.Tan(angle)) / 60
trueAltitude := trueAltitudeFromApparent(apparentAltitude, pressureHPa, temperatureC)
return apparentAltitude - trueAltitude
}
// TrueAltitude 真高度角,单位度;输入为视高度角。
// TrueAltitude 真高度角,单位度;输入为视高度角。若折射模型在支持的
// 真高度范围内没有逆解,则返回 NaN。
func TrueAltitude(apparentAltitude, pressureHPa, temperatureC float64) float64 {
refraction := RefractionFromApparentAltitude(apparentAltitude, pressureHPa, temperatureC)
if math.IsNaN(refraction) {
if !validRefractionInputs(apparentAltitude, pressureHPa, temperatureC) {
return math.NaN()
}
return apparentAltitude - refraction
if apparentAltitude <= refractionLowerLimitAltitudeDeg || apparentAltitude >= refractionUpperLimitAltitudeDeg {
return apparentAltitude
}
return trueAltitudeFromApparent(apparentAltitude, pressureHPa, temperatureC)
}
// ApparentAltitude 视高度角,单位度;输入为真高度角。
@@ -37,45 +42,118 @@ func ApparentAltitude(trueAltitude, pressureHPa, temperatureC float64) float64 {
if !validRefractionInputs(trueAltitude, pressureHPa, temperatureC) {
return math.NaN()
}
if trueAltitude < refractionLowerLimitAltitudeDeg || trueAltitude > refractionUpperLimitAltitudeDeg {
return trueAltitude
}
estimate := trueAltitude + RefractionFromApparentAltitude(trueAltitude, pressureHPa, temperatureC)
for i := 0; i < 8; i++ {
refraction := RefractionFromApparentAltitude(estimate, pressureHPa, temperatureC)
if math.IsNaN(refraction) {
return math.NaN()
}
value := estimate - refraction - trueAltitude
if math.Abs(value) < 1e-12 {
break
}
const delta = 1e-6
refractionPlus := RefractionFromApparentAltitude(estimate+delta, pressureHPa, temperatureC)
refractionMinus := RefractionFromApparentAltitude(estimate-delta, pressureHPa, temperatureC)
if math.IsNaN(refractionPlus) || math.IsNaN(refractionMinus) {
return math.NaN()
}
derivative := 1 - (refractionPlus-refractionMinus)/(2*delta)
if derivative == 0 {
break
}
estimate -= value / derivative
}
return estimate
return trueAltitude + refractionFromTrueAltitude(trueAltitude, pressureHPa, temperatureC)
}
// RefractionFromTrueAltitude 大气折射修正量,单位度;输入为真高度角。
// 返回值应从真高度角加上后得到视高度角。
func RefractionFromTrueAltitude(trueAltitude, pressureHPa, temperatureC float64) float64 {
apparentAltitude := ApparentAltitude(trueAltitude, pressureHPa, temperatureC)
if math.IsNaN(apparentAltitude) {
if !validRefractionInputs(trueAltitude, pressureHPa, temperatureC) {
return math.NaN()
}
return apparentAltitude - trueAltitude
return refractionFromTrueAltitude(trueAltitude, pressureHPa, temperatureC)
}
// Saemundsson 公式以真高度角为输入;逆 API 对同一模型做数值求解,保持公开真/视高度语义一致。
// Saemundsson's formula takes true altitude. The inverse APIs solve the same model numerically so the public true/apparent semantics remain consistent.
func refractionFromTrueAltitude(trueAltitude, pressureHPa, temperatureC float64) float64 {
if trueAltitude <= refractionLowerLimitAltitudeDeg || trueAltitude >= refractionUpperLimitAltitudeDeg {
return 0
}
angle := (trueAltitude + 10.3/(trueAltitude+5.11)) * math.Pi / 180
return refractionScale(pressureHPa, temperatureC) * (1.02 / math.Tan(angle)) / 60
}
func trueAltitudeFromApparent(apparentAltitude, pressureHPa, temperatureC float64) float64 {
lower := math.Nextafter(refractionLowerLimitAltitudeDeg, math.Inf(1))
upper := apparentAltitude
// Saemundsson 近似在 90 度以下极窄范围会略为负值;此时真高度角高于视高度角。若用视高度角作为根区间上界会漏掉有效解,因此保留模型明确的 90 度边界,允许逆解越过输入的视高度角。
// Saemundsson's approximation becomes slightly negative just below 90 degrees. In that narrow range the true altitude is above the apparent altitude, so using the apparent altitude as the root bracket would omit the valid solution. Keep the model's explicit 90-degree boundary while allowing the inverse to cross above the apparent input.
if refractionFromTrueAltitude(apparentAltitude, pressureHPa, temperatureC) < 0 {
upper = math.Nextafter(refractionUpperLimitAltitudeDeg, math.Inf(-1))
}
if estimate, ok := trueAltitudeFromApparentNewton(apparentAltitude, pressureHPa, temperatureC, lower, upper); ok {
return estimate
}
return trueAltitudeFromApparentBisection(apparentAltitude, pressureHPa, temperatureC, lower, upper)
}
func trueAltitudeFromApparentNewton(apparentAltitude, pressureHPa, temperatureC, lower, upper float64) (float64, bool) {
estimate := apparentAltitude - refractionFromTrueAltitude(apparentAltitude, pressureHPa, temperatureC)
const delta = 1e-6
for i := 0; i < 12; i++ {
if estimate < lower || estimate > upper || !finiteRefractionValue(estimate) {
return 0, false
}
value := refractionInverseResidual(estimate, apparentAltitude, pressureHPa, temperatureC)
if math.Abs(value) < 1e-12 {
return estimate, true
}
refractionPlus := refractionFromTrueAltitude(estimate+delta, pressureHPa, temperatureC)
refractionMinus := refractionFromTrueAltitude(estimate-delta, pressureHPa, temperatureC)
derivative := 1 + (refractionPlus-refractionMinus)/(2*delta)
if math.Abs(derivative) < 1e-12 || !finiteRefractionValue(derivative) {
return 0, false
}
next := estimate - value/derivative
if next < lower || next > upper || !finiteRefractionValue(next) {
return 0, false
}
estimate = next
}
return 0, false
}
func trueAltitudeFromApparentBisection(apparentAltitude, pressureHPa, temperatureC, lower, upper float64) float64 {
lowerValue := refractionInverseResidual(lower, apparentAltitude, pressureHPa, temperatureC)
upperValue := refractionInverseResidual(upper, apparentAltitude, pressureHPa, temperatureC)
if !finiteRefractionValue(lowerValue) || !finiteRefractionValue(upperValue) || lowerValue > 0 || upperValue < 0 {
return math.NaN()
}
if math.Abs(lowerValue) < 1e-12 {
return lower
}
if math.Abs(upperValue) < 1e-12 {
return upper
}
best, bestResidual := lower, math.Abs(lowerValue)
if math.Abs(upperValue) < bestResidual {
best, bestResidual = upper, math.Abs(upperValue)
}
for i := 0; i < 96; i++ {
midpoint := lower + (upper-lower)/2
if midpoint == lower || midpoint == upper {
break
}
value := refractionInverseResidual(midpoint, apparentAltitude, pressureHPa, temperatureC)
if !finiteRefractionValue(value) {
return math.NaN()
}
if residual := math.Abs(value); residual < bestResidual {
best, bestResidual = midpoint, residual
}
if math.Abs(value) < 1e-12 {
return midpoint
}
if value > 0 {
upper = midpoint
} else {
lower = midpoint
}
}
if bestResidual <= 1e-9 {
return best
}
return math.NaN()
}
func refractionInverseResidual(trueAltitude, apparentAltitude, pressureHPa, temperatureC float64) float64 {
return trueAltitude + refractionFromTrueAltitude(trueAltitude, pressureHPa, temperatureC) - apparentAltitude
}
func finiteRefractionValue(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
func validRefractionInputs(altitude, pressureHPa, temperatureC float64) bool {
@@ -85,5 +163,5 @@ func validRefractionInputs(altitude, pressureHPa, temperatureC float64) bool {
}
func refractionScale(pressureHPa, temperatureC float64) float64 {
return pressureHPa / refractionStandardPressureHPa * refractionStandardTemperatureK / (273 + temperatureC)
return pressureHPa / refractionStandardPressureHPa * refractionStandardTemperatureK / (temperatureC - refractionAbsoluteZeroC)
}
+67 -11
View File
@@ -5,9 +5,17 @@ import (
"testing"
)
func TestRefractionFromApparentAltitudeStandardAtmosphere(t *testing.T) {
assertClose(t, "Refraction@0deg", RefractionFromApparentAltitude(0, 1010, 10), 0.483032, 0.0001)
assertClose(t, "Refraction@45deg", RefractionFromApparentAltitude(45, 1010, 10), 0.016878, 0.0001)
func TestRefractionFromTrueAltitudeStandardAtmosphere(t *testing.T) {
assertClose(t, "RefractionFromTrue@0deg", RefractionFromTrueAltitude(0, 1010, 10), 0.483032, 0.0001)
assertClose(t, "RefractionFromTrue@45deg", RefractionFromTrueAltitude(45, 1010, 10), 0.016878, 0.0001)
assertClose(t, "ApparentAltitude@0deg", ApparentAltitude(0, 1010, 10), 0.483032, 0.0001)
}
func TestRefractionFromApparentAltitudeUsesApparentInput(t *testing.T) {
// 视地平线处修正约为 34.5 角分,而不是真高度角公式在 0 度返回的 29 角分 / At the apparent horizon the correction is about 34.5 arcminutes, not
// 29 角分 / the 29 arcminutes returned by the true-altitude formula at 0 degrees.
assertClose(t, "RefractionFromApparent@0deg", RefractionFromApparentAltitude(0, 1010, 10), 0.574, 0.001)
assertClose(t, "TrueAltitude@0deg", TrueAltitude(0, 1010, 10), -0.574, 0.001)
}
func TestRefractionRoundTrip(t *testing.T) {
@@ -35,29 +43,77 @@ func TestRefractionRoundTrip(t *testing.T) {
}
}
func TestRefractionRoundTripNearZenith(t *testing.T) {
for _, trueAltitude := range []float64{89.90, 89.95, 89.99} {
apparentAltitude := ApparentAltitude(trueAltitude, 1010, 10)
if math.IsNaN(apparentAltitude) || math.IsInf(apparentAltitude, 0) {
t.Fatalf("true altitude %.2f produced invalid apparent altitude %v", trueAltitude, apparentAltitude)
}
got := TrueAltitude(apparentAltitude, 1010, 10)
if math.IsNaN(got) || math.IsInf(got, 0) {
t.Fatalf("apparent altitude %.12f produced invalid true altitude %v", apparentAltitude, got)
}
assertClose(t, "NearZenithRoundTrip", got, trueAltitude, 1e-9)
if refraction := RefractionFromApparentAltitude(apparentAltitude, 1010, 10); math.IsNaN(refraction) || math.IsInf(refraction, 0) {
t.Fatalf("apparent altitude %.12f produced invalid refraction %v", apparentAltitude, refraction)
}
}
}
func TestRefractionExtremeTemperatureInverse(t *testing.T) {
const (
apparentAltitude = 0.0
pressureHPa = 1010.0
)
trueAltitude := TrueAltitude(apparentAltitude, pressureHPa, -273)
if math.IsNaN(trueAltitude) || math.IsInf(trueAltitude, 0) {
t.Fatalf("-273 C inverse returned invalid true altitude %v", trueAltitude)
}
assertClose(t, "ExtremeTemperatureRoundTrip",
ApparentAltitude(trueAltitude, pressureHPa, -273), apparentAltitude, 1e-9)
for _, temperatureC := range []float64{-273.14, math.Nextafter(refractionAbsoluteZeroC, math.Inf(1))} {
if got := TrueAltitude(apparentAltitude, pressureHPa, temperatureC); !math.IsNaN(got) {
t.Errorf("temperature %.17g C inverse = %v, want NaN when the model has no root", temperatureC, got)
}
if got := RefractionFromApparentAltitude(apparentAltitude, pressureHPa, temperatureC); !math.IsNaN(got) {
t.Errorf("temperature %.17g C apparent refraction = %v, want NaN when the model has no root", temperatureC, got)
}
}
}
func TestRefractionScalingAndBounds(t *testing.T) {
lowPressure := RefractionFromApparentAltitude(0, 980, 10)
highPressure := RefractionFromApparentAltitude(0, 1030, 10)
lowPressure := RefractionFromTrueAltitude(0, 980, 10)
highPressure := RefractionFromTrueAltitude(0, 1030, 10)
if !(lowPressure < highPressure) {
t.Fatalf("pressure scaling mismatch: low %.12f high %.12f", lowPressure, highPressure)
}
cold := RefractionFromApparentAltitude(0, 1010, 0)
hot := RefractionFromApparentAltitude(0, 1010, 30)
cold := RefractionFromTrueAltitude(0, 1010, 0)
hot := RefractionFromTrueAltitude(0, 1010, 30)
if !(cold > hot) {
t.Fatalf("temperature scaling mismatch: cold %.12f hot %.12f", cold, hot)
}
if RefractionFromApparentAltitude(-6, 1010, 10) != 0 {
if RefractionFromTrueAltitude(-6, 1010, 10) != 0 {
t.Fatalf("refraction below lower limit should be 0")
}
if RefractionFromApparentAltitude(95, 1010, 10) != 0 {
if RefractionFromTrueAltitude(95, 1010, 10) != 0 {
t.Fatalf("refraction above upper limit should be 0")
}
if !math.IsNaN(RefractionFromApparentAltitude(0, 0, 10)) {
if !math.IsNaN(RefractionFromTrueAltitude(0, 0, 10)) {
t.Fatalf("invalid pressure should produce NaN")
}
if !math.IsNaN(RefractionFromApparentAltitude(0, 1010, -274)) {
if !math.IsNaN(RefractionFromTrueAltitude(0, 1010, -274)) {
t.Fatalf("invalid temperature should produce NaN")
}
if !math.IsNaN(RefractionFromTrueAltitude(0, 1010, -273.15)) {
t.Fatalf("absolute zero should produce NaN")
}
for _, temperatureC := range []float64{-273, -273.14} {
refraction := RefractionFromTrueAltitude(0, 1010, temperatureC)
if math.IsNaN(refraction) || math.IsInf(refraction, 0) || refraction <= 0 {
t.Fatalf("temperature %.2f C produced invalid refraction %v", temperatureC, refraction)
}
}
}
+25 -12
View File
@@ -8,9 +8,10 @@ import (
)
var (
ErrNeverRise = errors.New("rise event does not occur on this date")
ErrNeverSet = errors.New("set event does not occur on this date")
ErrNotOnThisDate = errors.New("rise/set event occurs on adjacent date")
ErrNeverRise = errors.New("rise event does not occur on this date")
ErrNeverSet = errors.New("set event does not occur on this date")
ErrNotOnThisDate = errors.New("rise/set event occurs on adjacent date")
ErrInvalidObservationInput = errors.New("invalid observation input")
)
func StandardAltitudeStar(aero bool, observerHeight, lat float64) float64 {
@@ -50,20 +51,34 @@ type planetHeightFunc func(float64, float64, float64, float64) float64
type planetDeclinationFunc func(float64) float64
func planetRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, isRise bool, culmination planetCulminationFunc, height planetHeightFunc, declination planetDeclinationFunc) (float64, error) {
if !isFiniteFloat(jd) || !isFiniteFloat(lon) || !isFiniteFloat(lat) || !isFiniteFloat(timezone) || !isFiniteFloat(aeroCorrection) || !isFiniteFloat(observerHeight) {
return 0, ErrInvalidObservationInput
}
jd = math.Floor(jd) + 0.5
localTimezone := math.Round(lon / 15)
targetAltitude := StandardAltitudePlanet(aeroCorrection, observerHeight, lat)
culminationJD := culmination(jd, lon, localTimezone)
if height(culminationJD, lon, lat, localTimezone) < targetAltitude {
if !isFiniteFloat(culminationJD) {
return 0, ErrInvalidObservationInput
}
culminationHeight := height(culminationJD, lon, lat, localTimezone)
previousHeight := height(culminationJD-0.5, lon, lat, localTimezone)
if !isFiniteFloat(culminationHeight) || !isFiniteFloat(previousHeight) {
return 0, ErrInvalidObservationInput
}
if culminationHeight < targetAltitude {
return 0, ErrNeverRise
}
if height(culminationJD-0.5, lon, lat, localTimezone) > targetAltitude {
if previousHeight > targetAltitude {
return 0, ErrNeverSet
}
dec := declination(TD2UT(culminationJD-localTimezone/24, true))
cosHourAngle := (Sin(targetAltitude) - Sin(dec)*Sin(lat)) / (Cos(dec) * Cos(lat))
if !isFiniteFloat(dec) || !isFiniteFloat(cosHourAngle) {
return 0, ErrInvalidObservationInput
}
var eventJD float64
if math.Abs(cosHourAngle) <= 1 {
@@ -89,15 +104,13 @@ func planetRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight float
}
}
estimateJD := eventJD
for {
prevJD := estimateJD
estimateJD, ok := eventNewtonRefine(eventJD, 0.00001, func(prevJD float64) float64 {
altitudeDelta := height(prevJD, lon, lat, localTimezone) - targetAltitude
altitudeSlope := (height(prevJD+0.000005, lon, lat, localTimezone) - height(prevJD-0.000005, lon, lat, localTimezone)) / 0.00001
estimateJD = prevJD - altitudeDelta/altitudeSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return altitudeDelta / altitudeSlope
})
if !ok {
return 0, ErrInvalidObservationInput
}
return estimateJD - localTimezone/24 + timezone/24, nil
}
+150
View File
@@ -0,0 +1,150 @@
package basic
import (
"errors"
"math"
"testing"
"time"
. "b612.me/astro/tools"
)
func TestSunRiseSetDynamicResidual(t *testing.T) {
const (
longitude = 116.4074
latitude = 39.9042
timeZone = 8.0
height = 0.0
)
jd := JDECalc(2025, 6, 5)
for _, event := range []struct {
name string
get func(float64, float64, float64, float64, float64, float64) (float64, error)
}{
{name: "rise", get: GetSunRiseTime},
{name: "set", get: GetSunSetTime},
} {
eventJD, err := event.get(jd, longitude, latitude, timeZone, 1, height)
if err != nil {
t.Fatalf("%s: %v", event.name, err)
}
naturalTimeZone := math.Round(longitude / 15)
localJD := eventJD + naturalTimeZone/24 - timeZone/24
residual := sunRiseSetResidual(localJD, longitude, latitude, naturalTimeZone, 1, height, -1)
if math.Abs(residual) > 0.001 {
t.Fatalf("%s dynamic horizon residual = %.9f degrees", event.name, residual)
}
fixedResidual := SunHeight(localJD, longitude, latitude, naturalTimeZone) - StandardAltitudeSun(1, height, latitude)
if math.Abs(fixedResidual) < 0.01 {
t.Fatalf("%s still matches the legacy fixed-altitude event: residual %.9f degrees", event.name, fixedResidual)
}
}
}
func TestSunApparentStateReusesDistanceWithoutChangingCoordinates(t *testing.T) {
const jd = 2460827.5
ra, dec, distanceAU := hSunApparentRaDecDistanceN(jd, -1)
wantRA, wantDec := LoBoToRaDec(jd, HSunApparentLoN(jd, -1), HSunTrueBoN(jd, -1))
assertClose(t, "sun state RA", ra, wantRA, 1e-12)
assertClose(t, "sun state Dec", dec, wantDec, 1e-12)
assertClose(t, "sun state distance", distanceAU, EarthAwayN(jd, -1), 1e-15)
}
func TestSunRiseSetDynamicGrazingKeepsDateAndDirection(t *testing.T) {
date := time.Date(2025, 6, 10, 0, 0, 0, 0, time.UTC)
jd := Date2JDE(date)
dayStart := math.Floor(jd) + 0.5
rise, err := GetSunRiseTime(jd, 0, 66, 0, 1, 0)
if err != nil {
t.Fatalf("sunrise: %v", err)
}
set, err := GetSunSetTime(jd, 0, 66, 0, 1, 0)
if err != nil {
t.Fatalf("sunset: %v", err)
}
assertRiseSetEvent(t, "sunrise", rise, dayStart, true, func(eventJD float64) float64 {
return sunRiseSetResidual(eventJD, 0, 66, 0, 1, 0, -1)
})
assertRiseSetEvent(t, "sunset", set, dayStart, false, func(eventJD float64) float64 {
return sunRiseSetResidual(eventJD, 0, 66, 0, 1, 0, -1)
})
}
func TestMoonSetDynamicGrazingKeepsDirection(t *testing.T) {
date := time.Date(2025, 1, 31, 0, 0, 0, 0, time.UTC)
jd := Date2JDE(date)
dayStart := math.Floor(jd) + 0.5
set, err := GetMoonSetTime(jd, 0, 80, 0, 1, 0)
if err != nil {
t.Fatalf("moonset: %v", err)
}
assertRiseSetEvent(t, "moonset", set, dayStart, false, func(eventJD float64) float64 {
return moonRiseSetResidual(eventJD, 0, 80, 0, 1, 0, -1)
})
}
func TestMoonRiseSetDirectionalFallbackPreservesMissingEventError(t *testing.T) {
date := time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC)
jd := Date2JDE(date)
_, err := GetMoonRiseTime(jd, -42.6043, 71.7069, -3, 1, 0)
if !errors.Is(err, ErrNeverRise) {
t.Fatalf("moonrise error = %v, want %v", err, ErrNeverRise)
}
}
func TestMoonRiseSetDynamicUsesObserverHeightForParallax(t *testing.T) {
date := time.Date(2025, 6, 5, 0, 0, 0, 0, time.UTC)
jd := Date2JDE(date)
const (
longitude = 116.4074
latitude = 39.9042
height = 10000.0
)
rise, err := GetMoonRiseTime(jd, longitude, latitude, 0, 1, height)
if err != nil {
t.Fatalf("moonrise: %v", err)
}
residual := moonRiseSetResidualAtObserverHeight(rise, longitude, latitude, 0, 1, height)
if math.Abs(residual) > 1e-5 {
t.Fatalf("moonrise observer-height residual = %.12f degrees", residual)
}
}
func assertRiseSetEvent(t *testing.T, name string, eventJD, dayStart float64, isRise bool, residual func(float64) float64) {
t.Helper()
if eventJD < dayStart || eventJD >= dayStart+1 {
t.Fatalf("%s %.12f is outside civil day [%.12f, %.12f)", name, eventJD, dayStart, dayStart+1)
}
const step = 1.0 / 1440
slope := (residual(eventJD+step) - residual(eventJD-step)) / (2 * step)
if isRise && slope <= 0 {
t.Fatalf("%s slope = %.9f degrees/day, want positive", name, slope)
}
if !isRise && slope >= 0 {
t.Fatalf("%s slope = %.9f degrees/day, want negative", name, slope)
}
if value := residual(eventJD); math.Abs(value) > 1e-4 {
t.Fatalf("%s residual = %.12f degrees", name, value)
}
}
func moonRiseSetResidualAtObserverHeight(jd, longitude, latitude, timeZone, zenithShift, height float64) float64 {
calculationJD := TD2UT(jd-timeZone/24, true)
ra, dec := HMoonTrueRaDecN(calculationJD, -1)
distanceKM := HMoonAwayN(calculationJD, -1)
topocentricRA, topocentricDec := TopocentricRaDec(ra, dec, latitude, longitude,
jd-timeZone/24, distanceKM/angularDiameterAstronomicalUnitKM, height)
siderealTime := Limit360(ApparentSiderealTime(jd-timeZone/24)*15 + longitude)
hourAngle := Limit360(siderealTime - topocentricRA)
altitude := ArcSin(Sin(latitude)*Sin(topocentricDec) + Cos(topocentricDec)*Cos(latitude)*Cos(hourAngle))
residual := altitude + HeightDegreeByLat(height, latitude)
if zenithShift != 0 {
residual += RefractionFromTrueAltitude(altitude, refractionStandardPressureHPa, refractionStandardTemperatureC)
residual += angularSemidiameterArcsec(moonEquatorialRadiusKM, distanceKM) / 3600
}
return residual
}
+6 -6
View File
@@ -167,14 +167,14 @@ func SaturnCulminationTime(jde, lon, timezone float64) float64 {
}
return currentHourAngle
}
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return hourAngleDelta / hourAngleSlope
})
if !ok {
return math.NaN()
}
return estimateJD
}
+33 -9
View File
@@ -64,6 +64,9 @@ func saturnRADerivativeN(jde, delta float64, n int) float64 {
func saturnConjunctionFull(jde, degree float64, next uint8) float64 {
//0=last 1=next
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
return math.NaN()
}
daysPerDegree := SATURN_S_PERIOD / 360
currentDelta := saturnSunLongitudeDelta(jde, degree, false)
if next == 0 {
@@ -72,20 +75,29 @@ func saturnConjunctionFull(jde, degree float64, next uint8) float64 {
jde += daysPerDegree * currentDelta
}
estimateJD := jde
for {
converged := false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJD := estimateJD
longitudeDelta := saturnSunLongitudeDelta(prevJD, degree, true)
longitudeSlope := (saturnSunLongitudeDelta(prevJD+0.000005, degree, true) - saturnSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
estimateJD = prevJD - longitudeDelta/longitudeSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
nextJD := prevJD - longitudeDelta/longitudeSlope
estimateJD = nextJD
if math.Abs(nextJD-prevJD) <= 0.00001 {
converged = true
break
}
}
if !converged {
return math.NaN()
}
return TD2UT(estimateJD, false)
}
func saturnConjunction(jde, degree float64, next uint8) float64 {
//0=last 1=next
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
return math.NaN()
}
daysPerDegree := SATURN_S_PERIOD / 360
currentDelta := saturnSunLongitudeDelta(jde, degree, false)
if next == 0 {
@@ -94,24 +106,36 @@ func saturnConjunction(jde, degree float64, next uint8) float64 {
jde += daysPerDegree * currentDelta
}
estimateJD := jde
for {
converged := false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJD := estimateJD
longitudeDelta := saturnSunLongitudeDeltaN(prevJD, degree, true, saturnEventSearchN)
longitudeSlope := (saturnSunLongitudeDeltaN(prevJD+0.000005, degree, true, saturnEventSearchN) - saturnSunLongitudeDeltaN(prevJD-0.000005, degree, true, saturnEventSearchN)) / 0.00001
estimateJD = prevJD - longitudeDelta/longitudeSlope
if math.Abs(estimateJD-prevJD) <= saturnPhaseCoarseTolerance {
nextJD := prevJD - longitudeDelta/longitudeSlope
estimateJD = nextJD
if math.Abs(nextJD-prevJD) <= saturnPhaseCoarseTolerance {
converged = true
break
}
}
for {
if !converged {
return math.NaN()
}
converged = false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJD := estimateJD
longitudeDelta := saturnSunLongitudeDelta(prevJD, degree, true)
longitudeSlope := (saturnSunLongitudeDelta(prevJD+0.000005, degree, true) - saturnSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
estimateJD = prevJD - longitudeDelta/longitudeSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
nextJD := prevJD - longitudeDelta/longitudeSlope
estimateJD = nextJD
if math.Abs(nextJD-prevJD) <= 0.00001 {
converged = true
break
}
}
if !converged {
return math.NaN()
}
return TD2UT(estimateJD, false)
}
+315 -14
View File
@@ -19,6 +19,17 @@ const (
solarEclipsePartialFootprintMaxBoundaryPoints = 1440
solarEclipsePartialFootprintPointTolerance = 1e-12
solarEclipsePartialFootprintIterationLimit = 10
solarEclipsePartialFootprintTransitionIterations = 48
solarEclipseShadowContactSearchStepDays = 10.0 / 1440.0
solarEclipseShadowContactSearchSpanDays = 0.75
solarEclipseShadowContactToleranceDays = 1e-9
)
type solarEclipseShadowKind uint8
const (
solarEclipsePenumbralShadow solarEclipseShadowKind = iota
solarEclipseCentralShadow
)
// SolarEclipsePathOptions 控制日食中心路径采样。
@@ -78,6 +89,9 @@ type SolarEclipsePartialFootprintOptions struct {
// BoundaryPoints 是每个瞬时半影边界的角向采样点数;<=0 时使用 180。
// BoundaryPoints is the angular sample count for each instantaneous penumbral boundary; values <= 0 use 180.
BoundaryPoints int
// CentralShadowStepDays 是本影/反本影瞬时足迹的时间步长,单位为日;<=0 时不计算。
// CentralShadowStepDays is the umbral/antumbral footprint step in days; values <= 0 disable it.
CentralShadowStepDays float64
}
// SolarEclipsePartialAreaOptions 是 SolarEclipsePartialFootprintOptions 的兼容别名。
@@ -104,12 +118,30 @@ type SolarEclipsePartialFootprintsResult struct {
Eclipse SolarEclipseResult
// Footprints 是按时间采样的瞬时半影足迹, sampled instantaneous penumbral footprints.
Footprints []SolarEclipsePartialFootprint
// CentralShadowFootprints 是按时间采样的本影/反本影足迹。
// CentralShadowFootprints are sampled umbral/antumbral footprints.
CentralShadowFootprints []SolarEclipsePartialFootprint
// P1-P4 是半影与地球的外切/内切接触点;不存在的内切点保持零值。
// P1-P4 are external/internal penumbral contacts; absent internal contacts remain zero.
P1 SolarEclipsePathPoint
P2 SolarEclipsePathPoint
P3 SolarEclipsePathPoint
P4 SolarEclipsePathPoint
// U1-U4 是本影/反本影与地球的外切/内切接触点;不存在时保持零值。
// U1-U4 are external/internal umbral/antumbral contacts; absent contacts remain zero.
U1 SolarEclipsePathPoint
U2 SolarEclipsePathPoint
U3 SolarEclipsePathPoint
U4 SolarEclipsePathPoint
// StepDays 是实际采用的基础时间采样步长,单位为日。
// StepDays is the effective base time step in days.
StepDays float64
// BoundaryPoints 是实际采用的边界角向采样点数。
// BoundaryPoints is the effective angular sample count for each boundary.
BoundaryPoints int
// CentralShadowStepDays 是本影/反本影足迹的实际采样步长;0 表示未计算。
// CentralShadowStepDays is the effective umbral/antumbral footprint step; zero means disabled.
CentralShadowStepDays float64
}
// SolarEclipsePartialAreaResult 是 SolarEclipsePartialFootprintsResult 的兼容别名。
@@ -237,9 +269,10 @@ func solarEclipsePartialFootprints(
options = normalizeSolarEclipsePartialFootprintOptions(options)
result := solarEclipse(seedJDE, model)
footprintsResult := SolarEclipsePartialFootprintsResult{
Eclipse: result,
StepDays: options.StepDays,
BoundaryPoints: options.BoundaryPoints,
Eclipse: result,
StepDays: options.StepDays,
BoundaryPoints: options.BoundaryPoints,
CentralShadowStepDays: options.CentralShadowStepDays,
}
if !result.HasPartial {
return footprintsResult
@@ -247,6 +280,20 @@ func solarEclipsePartialFootprints(
newMoonJDE := CalcMoonSHByJDE(seedJDE, 0)
solver := newSolarEclipseSolver(newMoonJDE, model)
footprintsResult.P1, footprintsResult.P4, _ = solver.shadowContactPair(
result.GreatestEclipse, solarEclipsePenumbralShadow, false,
)
footprintsResult.P2, footprintsResult.P3, _ = solver.shadowContactPair(
result.GreatestEclipse, solarEclipsePenumbralShadow, true,
)
if result.Type != SolarEclipsePartial {
footprintsResult.U1, footprintsResult.U4, _ = solver.shadowContactPair(
result.GreatestEclipse, solarEclipseCentralShadow, false,
)
footprintsResult.U2, footprintsResult.U3, _ = solver.shadowContactPair(
result.GreatestEclipse, solarEclipseCentralShadow, true,
)
}
footprints, stepDays := solver.partialFootprints(
result.PartialBeginOnEarth,
result.PartialEndOnEarth,
@@ -255,6 +302,17 @@ func solarEclipsePartialFootprints(
)
footprintsResult.StepDays = stepDays
footprintsResult.Footprints = footprints
if options.CentralShadowStepDays > 0 &&
footprintsResult.U1.JDE != 0 && footprintsResult.U4.JDE != 0 {
footprintsResult.CentralShadowFootprints, footprintsResult.CentralShadowStepDays = solver.shadowFootprints(
footprintsResult.U1.JDE,
footprintsResult.U4.JDE,
result.GreatestEclipse,
options.CentralShadowStepDays,
options.BoundaryPoints,
solarEclipseCentralShadow,
)
}
return footprintsResult
}
@@ -274,6 +332,11 @@ func normalizeSolarEclipsePartialFootprintOptions(options SolarEclipsePartialFoo
if options.BoundaryPoints > solarEclipsePartialFootprintMaxBoundaryPoints {
options.BoundaryPoints = solarEclipsePartialFootprintMaxBoundaryPoints
}
if options.CentralShadowStepDays <= 0 || math.IsNaN(options.CentralShadowStepDays) || math.IsInf(options.CentralShadowStepDays, 0) {
options.CentralShadowStepDays = 0
} else if options.CentralShadowStepDays < solarEclipsePathMinStepDays {
options.CentralShadowStepDays = solarEclipsePathMinStepDays
}
return options
}
@@ -316,15 +379,30 @@ func (solver solarEclipseSolver) centralPathPoints(
func (solver solarEclipseSolver) partialFootprints(
startJDE, endJDE, greatestJDE float64,
options SolarEclipsePartialFootprintOptions,
) ([]SolarEclipsePartialFootprint, float64) {
return solver.shadowFootprints(
startJDE,
endJDE,
greatestJDE,
options.StepDays,
options.BoundaryPoints,
solarEclipsePenumbralShadow,
)
}
func (solver solarEclipseSolver) shadowFootprints(
startJDE, endJDE, greatestJDE, requestedStepDays float64,
boundaryPoints int,
kind solarEclipseShadowKind,
) ([]SolarEclipsePartialFootprint, float64) {
if endJDE < startJDE {
startJDE, endJDE = endJDE, startJDE
}
if startJDE == 0 || endJDE == 0 || endJDE <= startJDE {
return nil, options.StepDays
return nil, requestedStepDays
}
stepDays := options.StepDays
stepDays := requestedStepDays
if sampleCount := int(math.Ceil((endJDE-startJDE)/stepDays)) + 1; sampleCount > solarEclipsePathMaxSampleCount {
stepDays = (endJDE - startJDE) / float64(solarEclipsePathMaxSampleCount-1)
}
@@ -338,7 +416,7 @@ func (solver solarEclipseSolver) partialFootprints(
footprints := make([]SolarEclipsePartialFootprint, 0, len(times))
for _, jd := range times {
footprint := solver.partialFootprintAt(jd, options.BoundaryPoints)
footprint := solver.shadowFootprintAt(jd, boundaryPoints, kind)
if len(footprint.Boundaries) > 0 {
footprints = append(footprints, footprint)
}
@@ -509,18 +587,164 @@ func solarEclipsePathPointFromBesselXY(jd, x, y float64, axis solarEclipseAxis)
}, true
}
func (solver solarEclipseSolver) shadowContactPair(
greatestJDE float64,
kind solarEclipseShadowKind,
internal bool,
) (SolarEclipsePathPoint, SolarEclipsePathPoint, bool) {
middleResidual, ok := solver.shadowContactResidual(greatestJDE, kind, internal)
if !ok || middleResidual > 0 {
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
}
firstJDE, firstOK := solver.shadowContactRoot(greatestJDE, -1, middleResidual, kind, internal)
lastJDE, lastOK := solver.shadowContactRoot(greatestJDE, 1, middleResidual, kind, internal)
if !firstOK || !lastOK {
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
}
first, firstOK := solver.shadowContactPointAt(firstJDE, kind)
last, lastOK := solver.shadowContactPointAt(lastJDE, kind)
if !firstOK || !lastOK {
return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false
}
return first, last, true
}
func (solver solarEclipseSolver) shadowContactRoot(
greatestJDE float64,
direction float64,
middleResidual float64,
kind solarEclipseShadowKind,
internal bool,
) (float64, bool) {
insideJDE := greatestJDE
insideResidual := middleResidual
for span := solarEclipseShadowContactSearchStepDays; span <= solarEclipseShadowContactSearchSpanDays; span += solarEclipseShadowContactSearchStepDays {
outsideJDE := greatestJDE + direction*span
outsideResidual, ok := solver.shadowContactResidual(outsideJDE, kind, internal)
if !ok {
continue
}
if outsideResidual >= 0 {
leftJDE, rightJDE := outsideJDE, insideJDE
leftResidual, rightResidual := outsideResidual, insideResidual
if leftJDE > rightJDE {
leftJDE, rightJDE = rightJDE, leftJDE
leftResidual, rightResidual = rightResidual, leftResidual
}
for rightJDE-leftJDE > solarEclipseShadowContactToleranceDays {
middleJDE := (leftJDE + rightJDE) / 2
residual, valid := solver.shadowContactResidual(middleJDE, kind, internal)
if !valid {
return 0, false
}
if (residual >= 0) == (leftResidual >= 0) {
leftJDE, leftResidual = middleJDE, residual
} else {
rightJDE, rightResidual = middleJDE, residual
}
}
return (leftJDE + rightJDE) / 2, true
}
insideJDE, insideResidual = outsideJDE, outsideResidual
}
return 0, false
}
func (solver solarEclipseSolver) shadowContactResidual(
jd float64,
kind solarEclipseShadowKind,
internal bool,
) (float64, bool) {
moon := solver.besselMoonAt(jd)
distanceSquared := moon[0]*moon[0] + moon[1]*moon[1]
if distanceSquared <= 0 {
return 0, false
}
radius := solver.shadowRadiusAt(moon[2], kind)
if radius <= 0 {
return 0, false
}
earthRadius := 1 - (1/solarEclipseEarthPolarRatioSquared-1)*moon[1]*moon[1]/distanceSquared/2
limit := earthRadius + radius
if internal {
limit = earthRadius - radius
}
if limit <= 0 {
return 0, false
}
return math.Sqrt(distanceSquared) - limit, true
}
func (solver solarEclipseSolver) shadowContactPointAt(
jd float64,
kind solarEclipseShadowKind,
) (SolarEclipsePathPoint, bool) {
moon := solver.besselMoonAt(jd)
distance := math.Hypot(moon[0], moon[1])
if distance <= 0 || solver.shadowRadiusAt(moon[2], kind) <= 0 {
return SolarEclipsePathPoint{}, false
}
axis := solver.besselAxisAt(jd)
unitX, unitY := moon[0]/distance, moon[1]/distance
insideScale, outsideScale := 0.0, 1.1
var intersection solarEclipseLineIntersection
for iteration := 0; iteration < 48; iteration++ {
scale := (insideScale + outsideScale) / 2
candidate := solarEclipseLineEar2(
scale*unitX, scale*unitY, 2,
scale*unitX, scale*unitY, 0,
solarEclipseEarthPolarRatio, 1, axis,
)
if candidate.valid {
insideScale = scale
intersection = candidate
} else {
outsideScale = scale
}
}
if !intersection.valid {
return SolarEclipsePathPoint{}, false
}
longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis)
sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst)
return SolarEclipsePathPoint{
JDE: jd,
Longitude: longitude,
Latitude: latitude,
SunAltitude: sunAltitudeRad / rad,
}, true
}
func (solver solarEclipseSolver) shadowRadiusAt(moonBesselZ float64, kind solarEclipseShadowKind) float64 {
radii := solver.shadowRadiiAt(moonBesselZ)
if kind == solarEclipseCentralShadow {
return radii.absUmbraRadius
}
return radii.penumbraRadius
}
func (solver solarEclipseSolver) partialFootprintAt(jd float64, boundaryPoints int) SolarEclipsePartialFootprint {
return solver.shadowFootprintAt(jd, boundaryPoints, solarEclipsePenumbralShadow)
}
func (solver solarEclipseSolver) shadowFootprintAt(
jd float64,
boundaryPoints int,
kind solarEclipseShadowKind,
) SolarEclipsePartialFootprint {
moon := solver.besselMoonAt(jd)
axis := solver.besselAxisAt(jd)
samples := make([]solarEclipsePartialBoundarySample, boundaryPoints)
for i := range samples {
angle := 2 * math.Pi * float64(i) / float64(boundaryPoints)
point, ok := solver.partialFootprintPointAt(jd, moon, axis, angle)
point, ok := solver.shadowFootprintPointAt(jd, moon, axis, angle, kind)
samples[i] = solarEclipsePartialBoundarySample{
point: point,
ok: ok,
angle: angle,
}
}
samples = solver.refineShadowFootprintTransitions(jd, moon, axis, samples, kind)
boundaries, closed := solarEclipsePartialBoundarySegments(samples)
return SolarEclipsePartialFootprint{
@@ -533,17 +757,83 @@ func (solver solarEclipseSolver) partialFootprintAt(jd float64, boundaryPoints i
type solarEclipsePartialBoundarySample struct {
point SolarEclipsePathPoint
ok bool
angle float64
}
func (solver solarEclipseSolver) partialFootprintPointAt(
func (solver solarEclipseSolver) refineShadowFootprintTransitions(
jd float64,
moon [3]float64,
axis solarEclipseAxis,
samples []solarEclipsePartialBoundarySample,
kind solarEclipseShadowKind,
) []solarEclipsePartialBoundarySample {
if len(samples) < 2 {
return samples
}
result := make([]solarEclipsePartialBoundarySample, 0, len(samples)+4)
for index, sample := range samples {
result = append(result, sample)
next := samples[(index+1)%len(samples)]
if sample.ok == next.ok {
continue
}
nextAngle := next.angle
if index == len(samples)-1 {
nextAngle += 2 * math.Pi
}
refined := solver.refineShadowFootprintTransition(jd, moon, axis, sample, next, nextAngle, kind)
result = append(result, refined)
}
return result
}
func (solver solarEclipseSolver) refineShadowFootprintTransition(
jd float64,
moon [3]float64,
axis solarEclipseAxis,
first, second solarEclipsePartialBoundarySample,
secondAngle float64,
kind solarEclipseShadowKind,
) solarEclipsePartialBoundarySample {
leftAngle := first.angle
rightAngle := secondAngle
leftOK := first.ok
best := first
if second.ok {
best = second
best.angle = secondAngle
}
for iteration := 0; iteration < solarEclipsePartialFootprintTransitionIterations; iteration++ {
middleAngle := (leftAngle + rightAngle) / 2
evaluationAngle := math.Mod(middleAngle, 2*math.Pi)
point, ok := solver.shadowFootprintPointAt(jd, moon, axis, evaluationAngle, kind)
middle := solarEclipsePartialBoundarySample{point: point, ok: ok, angle: middleAngle}
if ok {
best = middle
}
if ok == leftOK {
leftAngle = middleAngle
} else {
rightAngle = middleAngle
}
if rightAngle-leftAngle <= solarEclipsePartialFootprintPointTolerance {
break
}
}
best.angle = math.Mod(best.angle, 2*math.Pi)
return best
}
func (solver solarEclipseSolver) shadowFootprintPointAt(
jd float64,
moon [3]float64,
axis solarEclipseAxis,
angle float64,
kind solarEclipseShadowKind,
) (SolarEclipsePathPoint, bool) {
cosAngle := math.Cos(angle)
sinAngle := math.Sin(angle)
radius := solver.shadowRadiiAt(moon[2]).penumbraRadius
radius := solver.shadowRadiusAt(moon[2], kind)
if radius <= 0 {
return SolarEclipsePathPoint{}, false
}
@@ -567,7 +857,7 @@ func (solver solarEclipseSolver) partialFootprintPointAt(
return SolarEclipsePathPoint{}, false
}
nextRadius := solver.shadowRadiiAt(moon[2] - intersection.r2).penumbraRadius
nextRadius := solver.shadowRadiusAt(moon[2]-intersection.r2, kind)
if nextRadius <= 0 {
return SolarEclipsePathPoint{}, false
}
@@ -608,8 +898,10 @@ func (solver solarEclipseSolver) partialFootprintPointAt(
func solarEclipsePartialBoundarySegments(samples []solarEclipsePartialBoundarySample) ([][]SolarEclipsePathPoint, bool) {
segments := make([][]SolarEclipsePathPoint, 0, 2)
var current []SolarEclipsePathPoint
allSamplesValid := len(samples) > 0
for _, sample := range samples {
if !sample.ok {
allSamplesValid = false
segments = appendSolarEclipsePartialSegment(segments, current)
current = nil
continue
@@ -623,11 +915,20 @@ func solarEclipsePartialBoundarySegments(samples []solarEclipsePartialBoundarySa
segments = appendSolarEclipsePartialSegment(segments, current)
segments = mergeSolarEclipsePartialWrapSegment(segments, samples)
if len(segments) == 1 && len(segments[0]) > 2 && !solarEclipsePathCrossesAntimeridian(segments[0][len(segments[0])-1], segments[0][0]) {
segments[0] = append(segments[0], segments[0][0])
return segments, true
if !allSamplesValid {
return segments, false
}
return segments, false
totalPoints := 0
for _, segment := range segments {
totalPoints += len(segment)
}
if totalPoints < 3 {
return segments, false
}
if len(segments) == 1 && !solarEclipsePathCrossesAntimeridian(segments[0][len(segments[0])-1], segments[0][0]) {
segments[0] = append(segments[0], segments[0][0])
}
return segments, true
}
func appendSolarEclipsePartialSegment(
+104 -2
View File
@@ -3,6 +3,7 @@ package basic
import (
"math"
"testing"
"time"
)
func TestSolarEclipseCentralPathMatchesGlobalGreatest(t *testing.T) {
@@ -128,6 +129,9 @@ func TestSolarEclipsePartialFootprintsIncludeGreatest(t *testing.T) {
if !foundGreatest {
t.Fatalf("partial footprints should include greatest eclipse JDE %.12f", global.GreatestEclipse)
}
if footprints.Footprints[0].Closed {
t.Fatal("grazing first footprint must remain open for horizon closure")
}
}
func TestSolarEclipsePartialFootprintsWorkForPartialOnlyEclipse(t *testing.T) {
@@ -147,6 +151,101 @@ func TestSolarEclipsePartialFootprintsWorkForPartialOnlyEclipse(t *testing.T) {
}
}
func TestSolarEclipseShadowContactsAgainstNASA2012Baseline(t *testing.T) {
result := SolarEclipsePartialFootprints(
solarEclipseUTToTTJDE(time.Date(2012, 5, 20, 0, 0, 0, 0, time.UTC)),
SolarEclipsePartialFootprintOptions{
StepDays: 30.0 / 1440.0,
BoundaryPoints: 72,
CentralShadowStepDays: 10.0 / 1440.0,
},
)
baseline := []struct {
name string
point SolarEclipsePathPoint
want time.Time
}{
{"P1", result.P1, time.Date(2012, 5, 20, 20, 56, 7, 0, time.UTC)},
{"P4", result.P4, time.Date(2012, 5, 21, 2, 49, 21, 500000000, time.UTC)},
{"U1", result.U1, time.Date(2012, 5, 20, 22, 6, 16, 600000000, time.UTC)},
{"U2", result.U2, time.Date(2012, 5, 20, 22, 11, 46, 400000000, time.UTC)},
{"U3", result.U3, time.Date(2012, 5, 21, 1, 33, 42, 800000000, time.UTC)},
{"U4", result.U4, time.Date(2012, 5, 21, 1, 39, 11, 200000000, time.UTC)},
}
for _, contact := range baseline {
if contact.point.JDE == 0 {
t.Fatalf("%s contact is absent", contact.name)
}
assertLocalSolarEclipseJDEClose(
t,
contact.name,
contact.point.JDE,
solarEclipseUTToTTJDE(contact.want),
3*time.Second,
)
if math.Abs(contact.point.SunAltitude) > 0.01 {
t.Fatalf("%s Sun altitude = %.9f degrees, want horizon contact", contact.name, contact.point.SunAltitude)
}
}
if result.P2.JDE != 0 || result.P3.JDE != 0 {
t.Fatalf("2012 eclipse unexpectedly has P2/P3 contacts: P2=%+v P3=%+v", result.P2, result.P3)
}
if len(result.CentralShadowFootprints) == 0 {
t.Fatal("expected sampled central-shadow footprints")
}
if math.Abs(result.CentralShadowStepDays-10.0/1440.0) > 1e-12 {
t.Fatalf("central shadow step = %.12f days, want ten minutes", result.CentralShadowStepDays)
}
for _, footprint := range result.CentralShadowFootprints {
if len(footprint.Boundaries) == 0 {
t.Fatalf("central-shadow footprint at %.12f has no boundary", footprint.JDE)
}
}
}
func TestSolarEclipseShadowContactsIncludeP2P3WhenPenumbraEntersEarthDisk(t *testing.T) {
result := SolarEclipsePartialFootprints(
solarEclipseUTToTTJDE(time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC)),
SolarEclipsePartialFootprintOptions{StepDays: 30.0 / 1440.0, BoundaryPoints: 36},
)
for name, contacts := range map[string][]SolarEclipsePathPoint{
"penumbral": {result.P1, result.P2, result.P3, result.P4},
"central": {result.U1, result.U2, result.U3, result.U4},
} {
for index, contact := range contacts {
if contact.JDE == 0 {
t.Fatalf("%s contact %d is absent", name, index)
}
if index > 0 && !(contacts[index-1].JDE < contact.JDE) {
t.Fatalf("%s contacts out of order at %d: %.12f >= %.12f", name, index, contacts[index-1].JDE, contact.JDE)
}
}
}
if !(result.P1.JDE < result.U1.JDE && result.U4.JDE < result.P4.JDE) {
t.Fatalf("central shadow contacts must lie inside partial phase: P1=%v U1=%v U4=%v P4=%v",
result.P1.JDE, result.U1.JDE, result.U4.JDE, result.P4.JDE)
}
if result.CentralShadowFootprints != nil || result.CentralShadowStepDays != 0 {
t.Fatal("central-shadow footprints must remain disabled by default")
}
}
func TestSolarEclipsePartialBoundarySegmentsRemainClosedAcrossAntimeridian(t *testing.T) {
samples := []solarEclipsePartialBoundarySample{
{point: SolarEclipsePathPoint{Longitude: 170, Latitude: 20}, ok: true},
{point: SolarEclipsePathPoint{Longitude: -170, Latitude: 25}, ok: true},
{point: SolarEclipsePathPoint{Longitude: -160, Latitude: 10}, ok: true},
{point: SolarEclipsePathPoint{Longitude: 160, Latitude: 5}, ok: true},
}
boundaries, closed := solarEclipsePartialBoundarySegments(samples)
if !closed {
t.Fatal("complete spherical boundary must remain closed after antimeridian splitting")
}
if len(boundaries) < 2 {
t.Fatalf("expected antimeridian split, got %d boundary segment(s)", len(boundaries))
}
}
func TestSolarEclipsePartialFootprintsNoEvent(t *testing.T) {
footprints := SolarEclipsePartialFootprints(JDECalc(2023, 5, 15), SolarEclipsePartialFootprintOptions{})
@@ -180,8 +279,11 @@ func assertSolarEclipseFootprintClosedFlag(t *testing.T, footprint SolarEclipseP
if !footprint.Closed {
return
}
if len(footprint.Boundaries) != 1 {
t.Fatalf("closed footprint should have one boundary: got %d", len(footprint.Boundaries))
if len(footprint.Boundaries) == 0 {
t.Fatal("closed footprint has no boundaries")
}
if len(footprint.Boundaries) > 1 {
return
}
boundary := footprint.Boundaries[0]
if len(boundary) < 2 {
+6 -8
View File
@@ -79,16 +79,14 @@ func GetJQTime(year, angle int) float64 {
// Newton-Raphson iteration to find precise Julian date
currentJD := initialJD
for {
previousJD := currentJD
var ok bool
currentJD, ok = eventNewtonRefine(currentJD, 0.00001, func(previousJD float64) float64 {
errorValue := JQLospec(previousJD, targetAngle) - targetAngle
derivative := (JQLospec(previousJD+0.000005, targetAngle) - JQLospec(previousJD-0.000005, targetAngle)) / 0.00001
currentJD = previousJD - errorValue/derivative
// Check for convergence
if math.Abs(currentJD-previousJD) <= 0.00001 {
break
}
return errorValue / derivative
})
if !ok {
return math.NaN()
}
// Convert to UT and return
+18 -12
View File
@@ -130,6 +130,9 @@ func StarSetTime(jde, ra, dec, lon, lat, height, timezone float64, aero bool) (f
}
func StarRiseSetTime(jde, ra, dec, lon, lat, height, timezone float64, aero, isRise bool) (float64, error) {
if !isFiniteFloat(jde) || !isFiniteFloat(ra) || !isFiniteFloat(dec) || !isFiniteFloat(lon) || !isFiniteFloat(lat) || !isFiniteFloat(height) || !isFiniteFloat(timezone) {
return 0, ErrInvalidObservationInput
}
//jde 世界时,非力学时,当地时区 0时,无需转换力学时
//ra,dec 瞬时天球座标,非J2000等时间天球坐标
jde = math.Floor(jde) + 0.5
@@ -148,19 +151,22 @@ func StarRiseSetTime(jde, ra, dec, lon, lat, height, timezone float64, aero, isR
} else {
estimateJD = sct + ArcCos(tmp)/15.0/24.0
}
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
stDegree := StarHeight(prevJD, ra, dec, lon, lat, timezone) - targetAltitude
stDegreep := (StarHeight(prevJD+0.000005, ra, dec, lon, lat, timezone) - StarHeight(prevJD-0.000005, ra, dec, lon, lat, timezone)) / 0.00001
estimateJD = prevJD - stDegree/stDegreep
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return stDegree / stDegreep
})
if !ok {
return 0, ErrInvalidObservationInput
}
return estimateJD, nil
}
func StarCulminationTime(jde, ra, lon, timezone float64) float64 {
if !isFiniteFloat(jde) || !isFiniteFloat(ra) || !isFiniteFloat(lon) || !isFiniteFloat(timezone) {
return math.NaN()
}
//jde 世界时,非力学时,当地时区 0时,无需转换力学时
//ra,dec 瞬时天球座标,非J2000等时间天球坐标
jde = math.Floor(jde) + 0.5
@@ -172,14 +178,14 @@ func StarCulminationTime(jde, ra, lon, timezone float64) float64 {
}
return ha
}
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
stDegree := limitStarHA(prevJD, ra, lon, timezone) - 360
stDegreep := (limitStarHA(prevJD+0.000005, ra, lon, timezone) - limitStarHA(prevJD-0.000005, ra, lon, timezone)) / 0.00001
estimateJD = prevJD - stDegree/stDegreep
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return stDegree / stDegreep
})
if !ok {
return math.NaN()
}
return estimateJD
}
+12 -5
View File
@@ -4,13 +4,14 @@ import (
"bytes"
"errors"
"fmt"
"math"
"strconv"
"strings"
"sync"
"time"
)
// this file contains bright 9100 stars
// 本文件包含约 9100 颗亮星 / this file contains bright 9100 stars
// 9100颗亮星列表
type InnerStarData struct {
@@ -20,8 +21,8 @@ type InnerStarData struct {
Ra float64 //Ra J2000;J2000历元赤经
Dec float64 //De J2000;J2000历元赤纬
Mag float64 //视星等
PmRA float64 //赤经年自行
PmDec float64 //赤纬年自行
PmRA float64 //赤经投影年自行 cos(赤纬)*d赤经/dt,单位角秒/年
PmDec float64 //赤纬年自行,单位角秒/年
RadVel float64 //径向速度 km/s
RotVel float64 //自行速度 km/s
Pc float64 //秒差距
@@ -252,9 +253,15 @@ func StarDataByHR(hr int) (StarData, error) {
}
func (s InnerStarData) RaDecByJde(jde float64) (float64, float64) {
//计算自行
// BSC 的 pmRA 是投影自行 cos(Dec)*dRA/dt,而不是 dRA/dt / BSC pmRA is the projected motion cos(Dec)*dRA/dt, not dRA/dt.
year := ((jde - 2451545.0) / 365.2422)
return Precess(s.Ra+(year*s.PmRA/3600), s.Dec+(year*s.PmDec/3600), 2451545.0, jde)
dec := s.Dec + year*s.PmDec/3600
cosDec := math.Cos(s.Dec * math.Pi / 180)
ra := s.Ra
if math.Abs(cosDec) > 1e-12 {
ra += year * s.PmRA / (3600 * cosDec)
}
return Precess(ra, dec, 2451545.0, jde)
}
func (s StarData) RaDecByDate(date time.Time) (float64, float64) {
+2
View File
@@ -53,6 +53,7 @@ func TestStarDataRegressionSamples(t *testing.T) {
{15, 677, "21Alp And", "壁宿二", "", "仙女座α", "Alpheratz", "Andromeda", "仙女座", 2.097083333333, 29.090555555556, 2.06, 0.136, -0.163},
{424, 11767, "1Alp UMi", "勾陈一", "北极星", "小熊座α", "Polaris", "UrsaMinor", "小熊座", 37.952916666667, 89.264166666667, 2.02, 0.038, -0.015},
{2491, 32349, "9Alp CMa", "天狼", "", "大犬座α", "Sirius", "CanisMajor", "大犬座", 101.287083333333, -16.716111111111, -1.46, -0.553, -1.205},
{4799, 61558, "25 Vir", "进贤增九", "", "室女座25", "", "Virgo", "室女座", 189.1975, -5.831944444444, 5.87, -0.028, -0.018},
{7001, 91262, "3Alp Lyr", "织女一", "织女", "天琴座α", "Vega", "Lyra", "天琴座", 279.234583333333, 38.783611111111, 0.03, 0.202, 0.286},
{9100, 330, "9 Cas", "", "", "", "", "", "", 1.056666666667, 62.287777777778, 5.88, -0.004, 0.006},
}
@@ -90,6 +91,7 @@ func TestStarDataByChineseAlias(t *testing.T) {
{"北极", 424, 11767},
{"北极星", 424, 11767},
{"织女", 7001, 91262},
{"进贤增九", 4799, 61558},
}
for _, tc := range tests {
got, err := StarDataByChinese(tc.name)
+15 -4
View File
@@ -9,15 +9,23 @@ import (
"io"
)
// star_catalog.dat layout after gzip decompression:
// magic[8] | version[1] | rawDataLen[4] | rawData | stringCount[2] |
// repeated(stringLen[uvarint] + stringBytes) |
// maxHR[2] | repeated(maxHR * 6 * stringIndex[2]) | repeated(maxHR * hip[4]).
// star_catalog.dat gzip 解压后的布局 / star_catalog.dat layout after gzip decompression:
// magic[8] | version[1] | rawDataLen[4] | rawData | stringCount[2](字符串计数) |
// repeated(stringLen[uvarint] + stringBytes)(字符串表) |
// maxHR[2] | repeated(maxHR * 6 * stringIndex[2]) | repeated(maxHR * hip[4])(星表记录)。
const starCatalogMagic = "STRCAT01"
//go:embed star_catalog.dat
var starCatalogCompressed []byte
// supplementalStarDetails 保存嵌入载荷没有对应命名条目的星表元数据 / supplementalStarDetails contains catalog metadata that is maintained in
// 源码形式 / source form when the embedded payload has no corresponding named entry.
// 六个字段按编码顺序排列:中文名、别名、拜耳命名、通用名、中文星座和 IAU 星座 / The six fields follow the encoded detail order: Chinese name, alias, Bayer
// 命名、通用名、中文星座和 IAU 星座 / designation, common name, Chinese constellation, and IAU constellation.
var supplementalStarDetails = map[uint16][]string{
4799: {"进贤增九", "", "室女座25", "", "室女座", "Virgo"},
}
func initStarCatalogData() []byte {
reader, err := gzip.NewReader(bytes.NewReader(starCatalogCompressed))
if err != nil {
@@ -32,6 +40,9 @@ func initStarCatalogData() []byte {
if err != nil {
panic(err)
}
for hr, record := range supplementalStarDetails {
detail[hr] = record
}
hr2detail = detail
hr2hip = hip
return data
+11
View File
@@ -60,3 +60,14 @@ func TestGetRaDecByDate(t *testing.T) {
t.Fatal("unexpected empty formatted catalog coordinates")
}
}
func TestRaDecByJdeUsesProjectedRightAscensionMotion(t *testing.T) {
star := InnerStarData{Ra: 10, Dec: 60, PmRA: 3600, PmDec: 0}
jde := 2451545.0 + 365.2422
ra, dec := star.RaDecByJde(jde)
// 投影坐标每年 1 度在 Dec=60 度时对应赤经每年 2 度 / 1 deg/year in the projected coordinate is 2 deg/year in RA at Dec=60.
wantRA, wantDec := Precess(12, 60, 2451545.0, jde)
if math.Abs(signedAngleDifference(ra, wantRA)) > 1e-8 || math.Abs(dec-wantDec) > 1e-8 {
t.Fatalf("position = %.12f %.12f, want %.12f %.12f", ra, dec, wantRA, wantDec)
}
}
+11 -1
View File
@@ -148,7 +148,17 @@ func HSunApparentRaDec(jd float64) (float64, float64) {
}
func HSunApparentRaDecN(jd float64, n int) (float64, float64) {
return LoBoToRaDec(jd, HSunApparentLoN(jd, n), HSunTrueBoN(jd, n))
ra, dec, _ := hSunApparentRaDecDistanceN(jd, n)
return ra, dec
}
func hSunApparentRaDecDistanceN(jd float64, n int) (ra, dec, distanceAU float64) {
trueLongitude := HSunTrueLoN(jd, n)
trueLatitude := HSunTrueBoN(jd, n)
distanceAU = EarthAwayN(jd, n)
apparentLongitude := trueLongitude + Nutation2000Bi(jd) - 20.49552/distanceAU/3600
ra, dec = LoBoToRaDec(jd, apparentLongitude, trueLatitude)
return ra, dec, distanceAU
}
func HSunApparentRa(jd float64) float64 { // '太阳视赤经
+126 -96
View File
@@ -49,14 +49,14 @@ func EveningTwilight(jd, lon, lat, tz, targetAltitude float64) (float64, error)
}
}
estimateJD := sundown - 5.00/24.00/60.00
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
stDegree := SunHeight(prevJD, lon, lat, localTimeZone) - targetAltitude
stDegreep := (SunHeight(prevJD+0.000005, lon, lat, localTimeZone) - SunHeight(prevJD-0.000005, lon, lat, localTimeZone)) / 0.00001
estimateJD = prevJD - stDegree/stDegreep
if math.Abs(estimateJD-prevJD) < 0.00001 {
break
}
return stDegree / stDegreep
})
if !ok {
return 0, ErrInvalidObservationInput
}
return estimateJD - localTimeZone/24 + tz/24, nil
}
@@ -88,14 +88,14 @@ func EveningTwilightN(jd, lon, lat, tz, targetAltitude float64, n int) (float64,
}
}
estimateJD := sundown - 5.00/24.00/60.00
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
stDegree := SunHeightN(prevJD, lon, lat, localTimeZone, n) - targetAltitude
stDegreep := (SunHeightN(prevJD+0.000005, lon, lat, localTimeZone, n) - SunHeightN(prevJD-0.000005, lon, lat, localTimeZone, n)) / 0.00001
estimateJD = prevJD - stDegree/stDegreep
if math.Abs(estimateJD-prevJD) < 0.00001 {
break
}
return stDegree / stDegreep
})
if !ok {
return 0, ErrInvalidObservationInput
}
return estimateJD - localTimeZone/24 + tz/24, nil
}
@@ -134,15 +134,14 @@ func MorningTwilight(jd, lon, lat, tz, targetAltitude float64) (float64, error)
}
estimateJD := sunrise - 5.0/(24.0*60.0)
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
heightDiff := SunHeight(prevJD, lon, lat, localTimeZone) - targetAltitude
heightDerivative := (SunHeight(prevJD+0.000005, lon, lat, localTimeZone) - SunHeight(prevJD-0.000005, lon, lat, localTimeZone)) / 0.00001
estimateJD = prevJD - heightDiff/heightDerivative
if math.Abs(estimateJD-prevJD) < 0.00001 {
break
}
return heightDiff / heightDerivative
})
if !ok {
return 0, ErrInvalidObservationInput
}
return estimateJD - localTimeZone/24 + tz/24, nil
@@ -174,15 +173,14 @@ func MorningTwilightN(jd, lon, lat, tz, targetAltitude float64, n int) (float64,
}
estimateJD := sunrise - 5.0/(24.0*60.0)
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
heightDiff := SunHeightN(prevJD, lon, lat, localTimeZone, n) - targetAltitude
heightDerivative := (SunHeightN(prevJD+0.000005, lon, lat, localTimeZone, n) - SunHeightN(prevJD-0.000005, lon, lat, localTimeZone, n)) / 0.00001
estimateJD = prevJD - heightDiff/heightDerivative
if math.Abs(estimateJD-prevJD) < 0.00001 {
break
}
return heightDiff / heightDerivative
})
if !ok {
return 0, ErrInvalidObservationInput
}
return estimateJD - localTimeZone/24 + tz/24, nil
@@ -209,6 +207,46 @@ func SunTimeAngleN(jd, lon, lat, tz float64, n int) float64 {
return timeangle
}
type sunObservationState struct {
altitude float64
distanceAU float64
}
func sunObservationStateN(jd, lon, lat, tz float64, n int) sunObservationState {
calculationJD := jd - tz/24.0
tt := TD2UT(calculationJD, true)
siderealTime := Limit360(ApparentSiderealTime(calculationJD)*15 + lon)
ra, dec, distanceAU := hSunApparentRaDecDistanceN(tt, n)
hourAngle := Limit360(siderealTime - ra)
altitudeSine := Sin(lat)*Sin(dec) + Cos(dec)*Cos(lat)*Cos(hourAngle)
return sunObservationState{
altitude: ArcSin(altitudeSine),
distanceAU: distanceAU,
}
}
func sunRiseSetResidual(jd, longitude, latitude, timeZone, zenithShift, height float64, n int) float64 {
state := sunObservationStateN(jd, longitude, latitude, timeZone, n)
// 相对观测者下沉地平线的视上缘高度角 / Apparent upper-limb altitude relative to the observer's depressed horizon.
residual := state.altitude + HeightDegreeByLat(height, latitude)
if zenithShift != 0 {
residual += RefractionFromTrueAltitude(state.altitude, refractionStandardPressureHPa, refractionStandardTemperatureC)
residual += angularSemidiameterFromAU(sunEquatorialRadiusKM, state.distanceAU) / 3600
}
return residual
}
func sunRiseSetOnCivilDay(candidate, slope, civilDayStart, longitude, latitude, requestedTimeZone,
localTimeZone, zenithShift, height float64, isSunrise bool, n int, fallbackErr error) (float64, error) {
if eventRiseSetCandidateValid(candidate, civilDayStart, slope, isSunrise) {
return candidate, nil
}
return eventDirectionalRiseSetSearch(civilDayStart, isSunrise, fallbackErr, func(outputJD float64) float64 {
localJD := outputJD + localTimeZone/24 - requestedTimeZone/24
return sunRiseSetResidual(localJD, longitude, latitude, localTimeZone, zenithShift, height, n)
})
}
// GetSunRiseTime 精确计算日出时间,传入当日0时JDE
func GetSunRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, height float64) (float64, error) {
return calculateSunRiseSetTime(julianDay, longitude, latitude, timeZone, zenithShift, height, true)
@@ -229,6 +267,10 @@ func GetSunSetTimeN(julianDay, longitude, latitude, timeZone, zenithShift, heigh
// calculateSunRiseSetTime 统一的日出日落计算函数
func calculateSunRiseSetTime(julianDay, longitude, latitude, timeZone, zenithShift, height float64, isSunrise bool) (float64, error) {
if !isFiniteFloat(julianDay) || !isFiniteFloat(longitude) || !isFiniteFloat(latitude) || !isFiniteFloat(timeZone) || !isFiniteFloat(zenithShift) || !isFiniteFloat(height) {
return 0, ErrInvalidObservationInput
}
civilDayStart := math.Floor(julianDay) + 0.5
julianDay = math.Floor(julianDay) + 1.5
naturalTimeZone := math.Round(longitude / 15)
sunAngle := StandardAltitudeSun(zenithShift, height, latitude)
@@ -237,34 +279,44 @@ func calculateSunRiseSetTime(julianDay, longitude, latitude, timeZone, zenithShi
solarNoonTime := CulminationTime(julianDay, longitude, naturalTimeZone)
// 检查极夜极昼条件
if err := checkPolarConditions(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, isSunrise); err != nil {
return 0, err
if err := checkPolarConditions(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height, isSunrise); err != nil {
return sunRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude, timeZone,
naturalTimeZone, zenithShift, height, isSunrise, -1, err)
}
// 计算初始估算时间
initialTime := calculateInitialSunTime(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, isSunrise)
initialTime := calculateInitialSunTime(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, zenithShift, height, isSunrise)
// 牛顿-拉夫逊迭代求精确解
return sunRiseSetNewtonRaphsonIteration(initialTime, longitude, latitude, naturalTimeZone, sunAngle, timeZone), nil
result, slope := sunRiseSetNewtonRaphsonIteration(initialTime, longitude, latitude, naturalTimeZone, zenithShift, height, timeZone)
return sunRiseSetOnCivilDay(result, slope, civilDayStart, longitude, latitude, timeZone,
naturalTimeZone, zenithShift, height, isSunrise, -1, nil)
}
func calculateSunRiseSetTimeN(julianDay, longitude, latitude, timeZone, zenithShift, height float64, isSunrise bool, n int) (float64, error) {
if !isFiniteFloat(julianDay) || !isFiniteFloat(longitude) || !isFiniteFloat(latitude) || !isFiniteFloat(timeZone) || !isFiniteFloat(zenithShift) || !isFiniteFloat(height) {
return 0, ErrInvalidObservationInput
}
civilDayStart := math.Floor(julianDay) + 0.5
julianDay = math.Floor(julianDay) + 1.5
naturalTimeZone := math.Round(longitude / 15)
sunAngle := StandardAltitudeSun(zenithShift, height, latitude)
solarNoonTime := CulminationTimeN(julianDay, longitude, naturalTimeZone, n)
if err := checkPolarConditionsN(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, isSunrise, n); err != nil {
return 0, err
if err := checkPolarConditionsN(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height, isSunrise, n); err != nil {
return sunRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude, timeZone,
naturalTimeZone, zenithShift, height, isSunrise, n, err)
}
initialTime := calculateInitialSunTimeN(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, isSunrise, n)
return sunRiseSetNewtonRaphsonIterationN(initialTime, longitude, latitude, naturalTimeZone, sunAngle, timeZone, n), nil
initialTime := calculateInitialSunTimeN(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, zenithShift, height, isSunrise, n)
result, slope := sunRiseSetNewtonRaphsonIterationN(initialTime, longitude, latitude, naturalTimeZone, zenithShift, height, timeZone, n)
return sunRiseSetOnCivilDay(result, slope, civilDayStart, longitude, latitude, timeZone,
naturalTimeZone, zenithShift, height, isSunrise, n, nil)
}
// checkPolarConditions 检查极夜极昼条件
func checkPolarConditions(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle float64, isSunrise bool) error {
if SunHeight(solarNoonTime, longitude, latitude, naturalTimeZone) < sunAngle {
func checkPolarConditions(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height float64, isSunrise bool) error {
if sunRiseSetResidual(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height, -1) < 0 {
return ErrNeverRise
}
@@ -273,15 +325,15 @@ func checkPolarConditions(solarNoonTime, longitude, latitude, naturalTimeZone, s
checkTime = solarNoonTime - 0.5
}
if SunHeight(checkTime, longitude, latitude, naturalTimeZone) > sunAngle {
if sunRiseSetResidual(checkTime, longitude, latitude, naturalTimeZone, zenithShift, height, -1) > 0 {
return ErrNeverSet
}
return nil
}
func checkPolarConditionsN(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle float64, isSunrise bool, n int) error {
if SunHeightN(solarNoonTime, longitude, latitude, naturalTimeZone, n) < sunAngle {
func checkPolarConditionsN(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height float64, isSunrise bool, n int) error {
if sunRiseSetResidual(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height, n) < 0 {
return ErrNeverRise
}
@@ -290,7 +342,7 @@ func checkPolarConditionsN(solarNoonTime, longitude, latitude, naturalTimeZone,
checkTime = solarNoonTime - 0.5
}
if SunHeightN(checkTime, longitude, latitude, naturalTimeZone, n) > sunAngle {
if sunRiseSetResidual(checkTime, longitude, latitude, naturalTimeZone, zenithShift, height, n) > 0 {
return ErrNeverSet
}
@@ -298,7 +350,7 @@ func checkPolarConditionsN(solarNoonTime, longitude, latitude, naturalTimeZone,
}
// calculateInitialSunTime 计算日出日落的初始估算时间
func calculateInitialSunTime(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle float64, isSunrise bool) float64 {
func calculateInitialSunTime(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, zenithShift, height float64, isSunrise bool) float64 {
// 使用球面三角法计算: (sin(ho)-sin(φ)*sin(δ))/(cos(φ)*cos(δ))
apparentDeclination := HSunApparentDec(solarNoonTime)
cosHourAngle := (Sin(sunAngle) - Sin(apparentDeclination)*Sin(latitude)) / (Cos(apparentDeclination) * Cos(latitude))
@@ -318,11 +370,11 @@ func calculateInitialSunTime(solarNoonTime, longitude, latitude, naturalTimeZone
}
} else {
// 使用迭代逼近法(极地条件)
return iterativeApproach(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, isSunrise)
return iterativeApproach(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height, isSunrise)
}
}
func calculateInitialSunTimeN(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle float64, isSunrise bool, n int) float64 {
func calculateInitialSunTimeN(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, zenithShift, height float64, isSunrise bool, n int) float64 {
apparentDeclination := HSunApparentDecN(solarNoonTime, n)
cosHourAngle := (Sin(sunAngle) - Sin(apparentDeclination)*Sin(latitude)) / (Cos(apparentDeclination) * Cos(latitude))
@@ -339,11 +391,11 @@ func calculateInitialSunTimeN(solarNoonTime, longitude, latitude, naturalTimeZon
return solarNoonTime + hourAngle/24 + timeOffset
}
return iterativeApproachN(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, isSunrise, n)
return iterativeApproachN(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height, isSunrise, n)
}
// iterativeApproach 迭代逼近法计算(用于极地等特殊条件)
func iterativeApproach(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle float64, isSunrise bool) float64 {
func iterativeApproach(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height float64, isSunrise bool) float64 {
estimatedTime := solarNoonTime
stepSize := 15.0 / 60.0 / 24.0 // 15分钟步长
if isSunrise {
@@ -351,14 +403,14 @@ func iterativeApproach(solarNoonTime, longitude, latitude, naturalTimeZone, sunA
}
const maxIterations = 48
for i := 0; i < maxIterations && LowSunHeight(estimatedTime, longitude, latitude, naturalTimeZone) > sunAngle; i++ {
for i := 0; i < maxIterations && sunRiseSetResidual(estimatedTime, longitude, latitude, naturalTimeZone, zenithShift, height, -1) > 0; i++ {
estimatedTime += stepSize
}
return estimatedTime
}
func iterativeApproachN(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle float64, isSunrise bool, n int) float64 {
func iterativeApproachN(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height float64, isSunrise bool, n int) float64 {
estimatedTime := solarNoonTime
stepSize := 15.0 / 60.0 / 24.0
if isSunrise {
@@ -366,7 +418,7 @@ func iterativeApproachN(solarNoonTime, longitude, latitude, naturalTimeZone, sun
}
const maxIterations = 48
for i := 0; i < maxIterations && lowSunHeightForN(estimatedTime, longitude, latitude, naturalTimeZone, n) > sunAngle; i++ {
for i := 0; i < maxIterations && sunRiseSetResidual(estimatedTime, longitude, latitude, naturalTimeZone, zenithShift, height, n) > 0; i++ {
estimatedTime += stepSize
}
@@ -374,82 +426,60 @@ func iterativeApproachN(solarNoonTime, longitude, latitude, naturalTimeZone, sun
}
// sunRiseSetNewtonRaphsonIteration 牛顿-拉夫逊迭代法求精确解
func sunRiseSetNewtonRaphsonIteration(initialTime, longitude, latitude, naturalTimeZone, sunAngle, timeZone float64) float64 {
func sunRiseSetNewtonRaphsonIteration(initialTime, longitude, latitude, naturalTimeZone, zenithShift, height, timeZone float64) (float64, float64) {
const (
convergenceThreshold = 0.00001
derivativeStep = 0.000005
)
currentTime := initialTime
for {
previousTime := currentTime
// 计算函数值:f(t) = SunHeight(t) - targetAngle
functionValue := SunHeight(previousTime, longitude, latitude, naturalTimeZone) - sunAngle
// 计算导数:f'(t) ≈ (f(t+h) - f(t-h)) / (2h)
derivative := (SunHeight(previousTime+derivativeStep, longitude, latitude, naturalTimeZone) -
SunHeight(previousTime-derivativeStep, longitude, latitude, naturalTimeZone)) / (2 * derivativeStep)
// 牛顿-拉夫逊公式:t_new = t_old - f(t) / f'(t)
currentTime = previousTime - functionValue/derivative
// 检查收敛
if math.Abs(currentTime-previousTime) <= convergenceThreshold {
break
}
slope := math.NaN()
var ok bool
currentTime, ok = eventNewtonRefine(currentTime, convergenceThreshold, func(previousTime float64) float64 {
functionValue := sunRiseSetResidual(previousTime, longitude, latitude, naturalTimeZone, zenithShift, height, -1)
slope = (sunRiseSetResidual(previousTime+derivativeStep, longitude, latitude, naturalTimeZone, zenithShift, height, -1) -
sunRiseSetResidual(previousTime-derivativeStep, longitude, latitude, naturalTimeZone, zenithShift, height, -1)) / (2 * derivativeStep)
return functionValue / slope
})
if !ok {
return math.NaN(), math.NaN()
}
// 转换为指定时区
return currentTime - naturalTimeZone/24 + timeZone/24
return currentTime - naturalTimeZone/24 + timeZone/24, slope
}
func sunRiseSetNewtonRaphsonIterationN(initialTime, longitude, latitude, naturalTimeZone, sunAngle, timeZone float64, n int) float64 {
func sunRiseSetNewtonRaphsonIterationN(initialTime, longitude, latitude, naturalTimeZone, zenithShift, height, timeZone float64, n int) (float64, float64) {
const (
convergenceThreshold = 0.00001
derivativeStep = 0.000005
)
currentTime := initialTime
for {
previousTime := currentTime
functionValue := SunHeightN(previousTime, longitude, latitude, naturalTimeZone, n) - sunAngle
derivative := (SunHeightN(previousTime+derivativeStep, longitude, latitude, naturalTimeZone, n) -
SunHeightN(previousTime-derivativeStep, longitude, latitude, naturalTimeZone, n)) / (2 * derivativeStep)
currentTime = previousTime - functionValue/derivative
if math.Abs(currentTime-previousTime) <= convergenceThreshold {
break
}
slope := math.NaN()
var ok bool
currentTime, ok = eventNewtonRefine(currentTime, convergenceThreshold, func(previousTime float64) float64 {
functionValue := sunRiseSetResidual(previousTime, longitude, latitude, naturalTimeZone, zenithShift, height, n)
slope = (sunRiseSetResidual(previousTime+derivativeStep, longitude, latitude, naturalTimeZone, zenithShift, height, n) -
sunRiseSetResidual(previousTime-derivativeStep, longitude, latitude, naturalTimeZone, zenithShift, height, n)) / (2 * derivativeStep)
return functionValue / slope
})
if !ok {
return math.NaN(), math.NaN()
}
return currentTime - naturalTimeZone/24 + timeZone/24
return currentTime - naturalTimeZone/24 + timeZone/24, slope
}
/*
* 太阳高度角 世界时
*/
func SunHeight(jd, lon, lat, tz float64) float64 {
//tmp := (tz*15 - lon) * 4 / 60
//truejd := jd - tmp/24
calcjd := jd - tz/24.0
tjde := TD2UT(calcjd, true)
st := Limit360(ApparentSiderealTime(calcjd)*15 + lon)
ra, dec := HSunApparentRaDec(tjde)
hourAngle := Limit360(st - ra)
tmp2 := Sin(lat)*Sin(dec) + Cos(dec)*Cos(lat)*Cos(hourAngle)
return ArcSin(tmp2)
return SunHeightN(jd, lon, lat, tz, -1)
}
func SunHeightN(jd, lon, lat, tz float64, n int) float64 {
calcjd := jd - tz/24.0
tjde := TD2UT(calcjd, true)
st := Limit360(ApparentSiderealTime(calcjd)*15 + lon)
ra, dec := HSunApparentRaDecN(tjde, n)
hourAngle := Limit360(st - ra)
tmp2 := Sin(lat)*Sin(dec) + Cos(dec)*Cos(lat)*Cos(hourAngle)
return ArcSin(tmp2)
return sunObservationStateN(jd, lon, lat, tz, n).altitude
}
func LowSunHeight(jd, lon, lat, tz float64) float64 {
+384 -384
View File
File diff suppressed because it is too large Load Diff
+162
View File
@@ -0,0 +1,162 @@
{
"schema_version": 1,
"generated_utc": "2026-08-02T15:47:59Z",
"sources": {
"imcce_miriade": {
"provider": "IMCCE/LTE Miriade RTS",
"url": "https://ssp.imcce.fr/webservices/miriade/api/rts.php",
"definition": "direct rise and set service; the RTS API does not expose its horizon, refraction, or ephemeris-theory settings",
"resolution": "0.1-second display precision; not an uncertainty estimate"
},
"jpl_horizons": {
"provider": "NASA/JPL Horizons",
"url": "https://ssd.jpl.nasa.gov/api/horizons.api",
"model": "DE441",
"definition": "refracted apparent upper limb crossing the reference-ellipsoid visual horizon at sea level",
"resolution": "linear zero interpolation from 1-minute refracted center elevation and angular diameter"
},
"met_norway": {
"provider": "MET Norway Sunrise API",
"url": "https://api.met.no/weatherapi/sunrise/3.0/moon",
"model": "Skyfield 1.53 with JPL DE440s",
"definition": "fixed 0.5666-degree refraction plus 0.2667-degree lunar radius; queried by local solar date and returned in UTC",
"resolution": "1 minute",
"license_url": "https://api.met.no/license_data.html"
}
},
"samples": [
{
"site": "greenwich",
"date_utc": "2026-04-28",
"longitude": 0,
"latitude": 51.4779,
"observer_height_m": 0,
"jpl_horizons": {
"rise_utc": "2026-04-28T16:01:21Z",
"set_utc": "2026-04-28T03:19:32Z"
},
"met_norway": {
"rise_utc": "2026-04-28T16:01:00Z",
"set_utc": "2026-04-28T03:19:00Z"
},
"imcce_miriade": {
"rise_utc": "2026-04-28T16:03:10.4Z",
"set_utc": "2026-04-28T03:17:47.1Z"
}
},
{
"site": "beijing",
"date_utc": "2025-06-05",
"longitude": 116.4074,
"latitude": 39.9042,
"observer_height_m": 0,
"jpl_horizons": {
"rise_utc": "2025-06-05T06:01:29Z",
"set_utc": "2025-06-05T17:41:15Z"
},
"met_norway": {
"rise_utc": "2025-06-05T06:01:00Z",
"set_utc": "2025-06-05T17:41:00Z"
},
"imcce_miriade": {
"rise_utc": "2025-06-05T06:02:54.6Z",
"set_utc": "2025-06-05T17:39:51.3Z"
}
},
{
"site": "singapore",
"date_utc": "2025-01-15",
"longitude": 103.8198,
"latitude": 1.3521,
"observer_height_m": 0,
"jpl_horizons": {
"rise_utc": "2025-01-15T12:36:30Z",
"set_utc": "2025-01-15T00:15:05Z"
},
"met_norway": {
"rise_utc": "2025-01-15T12:36:00Z",
"set_utc": "2025-01-15T00:14:00Z"
},
"imcce_miriade": {
"rise_utc": "2025-01-15T12:37:41.8Z",
"set_utc": "2025-01-15T00:13:51.3Z"
}
},
{
"site": "sydney",
"date_utc": "2025-07-16",
"longitude": 151.2093,
"latitude": -33.8688,
"observer_height_m": 0,
"jpl_horizons": {
"rise_utc": "2025-07-16T12:40:33Z",
"set_utc": "2025-07-16T00:09:02Z"
},
"met_norway": {
"rise_utc": "2025-07-16T12:40:00Z",
"set_utc": "2025-07-16T00:08:00Z"
},
"imcce_miriade": {
"rise_utc": "2025-07-16T12:41:59.3Z",
"set_utc": "2025-07-16T00:07:38.7Z"
}
},
{
"site": "new_york",
"date_utc": "2025-10-15",
"longitude": -74.006,
"latitude": 40.7128,
"observer_height_m": 0,
"jpl_horizons": {
"rise_utc": "2025-10-15T04:57:44Z",
"set_utc": "2025-10-15T19:47:14Z"
},
"met_norway": {
"rise_utc": "2025-10-15T04:58:00Z",
"set_utc": "2025-10-15T19:46:00Z"
},
"imcce_miriade": {
"rise_utc": "2025-10-15T04:59:27.6Z",
"set_utc": "2025-10-15T19:45:38.0Z"
}
},
{
"site": "anchorage",
"date_utc": "2025-03-20",
"longitude": -149.9003,
"latitude": 61.2181,
"observer_height_m": 0,
"jpl_horizons": {
"rise_utc": "2025-03-20T12:53:14Z",
"set_utc": "2025-03-20T16:04:16Z"
},
"met_norway": {
"rise_utc": "2025-03-20T12:54:00Z",
"set_utc": "2025-03-20T16:03:00Z"
},
"imcce_miriade": {
"rise_utc": "2025-03-20T12:59:56.0Z",
"set_utc": "2025-03-20T15:57:34.4Z"
}
},
{
"site": "rio_de_janeiro",
"date_utc": "2026-01-15",
"longitude": -43.1729,
"latitude": -22.9068,
"observer_height_m": 0,
"jpl_horizons": {
"rise_utc": "2026-01-15T05:13:59Z",
"set_utc": "2026-01-15T19:26:42Z"
},
"met_norway": {
"rise_utc": "2026-01-15T05:14:00Z",
"set_utc": "2026-01-15T19:26:00Z"
},
"imcce_miriade": {
"rise_utc": "2026-01-15T05:15:20.6Z",
"set_utc": "2026-01-15T19:25:19.4Z"
}
}
]
}
+12 -3
View File
@@ -59,15 +59,24 @@ func GetWuHouTime(Year, Angle int) float64 {
if Angle <= 5 {
Angle = 360 + Angle
}
for {
converged := false
for i := 0; i < eventNewtonMaxIterations; i++ {
JD0 := JD1
stDegree := JQLospec(JD0, float64(Angle)) - float64(Angle)
stDegreep := (JQLospec(JD0+0.000005, float64(Angle)) - JQLospec(JD0-0.000005, float64(Angle))) / 0.00001
JD1 = JD0 - stDegree/stDegreep
if math.Abs(JD1-JD0) <= 0.00001 {
nextJD := JD0 - stDegree/stDegreep
if !isFiniteFloat(nextJD) {
return math.NaN()
}
JD1 = nextJD
if math.Abs(nextJD-JD0) <= 0.00001 {
converged = true
break
}
}
if !converged {
return math.NaN()
}
return TD2UT(JD1, false)
}
+6 -6
View File
@@ -173,14 +173,14 @@ func UranusCulminationTime(jde, lon, timezone float64) float64 {
}
return currentHourAngle
}
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return hourAngleDelta / hourAngleSlope
})
if !ok {
return math.NaN()
}
return estimateJD
}
+33 -9
View File
@@ -64,6 +64,9 @@ func uranusRADerivativeN(jde, delta float64, n int) float64 {
func uranusConjunctionFull(jde, degree float64, next uint8) float64 {
//0=last 1=next
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
return math.NaN()
}
daysPerDegree := URANUS_S_PERIOD / 360
currentDelta := uranusSunLongitudeDelta(jde, degree, false)
if next == 0 {
@@ -72,20 +75,29 @@ func uranusConjunctionFull(jde, degree float64, next uint8) float64 {
jde += daysPerDegree * currentDelta
}
estimateJD := jde
for {
converged := false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJD := estimateJD
longitudeDelta := uranusSunLongitudeDelta(prevJD, degree, true)
longitudeSlope := (uranusSunLongitudeDelta(prevJD+0.000005, degree, true) - uranusSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
estimateJD = prevJD - longitudeDelta/longitudeSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
nextJD := prevJD - longitudeDelta/longitudeSlope
estimateJD = nextJD
if math.Abs(nextJD-prevJD) <= 0.00001 {
converged = true
break
}
}
if !converged {
return math.NaN()
}
return TD2UT(estimateJD, false)
}
func uranusConjunction(jde, degree float64, next uint8) float64 {
//0=last 1=next
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
return math.NaN()
}
daysPerDegree := URANUS_S_PERIOD / 360
currentDelta := uranusSunLongitudeDelta(jde, degree, false)
if next == 0 {
@@ -94,24 +106,36 @@ func uranusConjunction(jde, degree float64, next uint8) float64 {
jde += daysPerDegree * currentDelta
}
estimateJD := jde
for {
converged := false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJD := estimateJD
longitudeDelta := uranusSunLongitudeDeltaN(prevJD, degree, true, uranusEventSearchN)
longitudeSlope := (uranusSunLongitudeDeltaN(prevJD+0.000005, degree, true, uranusEventSearchN) - uranusSunLongitudeDeltaN(prevJD-0.000005, degree, true, uranusEventSearchN)) / 0.00001
estimateJD = prevJD - longitudeDelta/longitudeSlope
if math.Abs(estimateJD-prevJD) <= uranusPhaseCoarseTolerance {
nextJD := prevJD - longitudeDelta/longitudeSlope
estimateJD = nextJD
if math.Abs(nextJD-prevJD) <= uranusPhaseCoarseTolerance {
converged = true
break
}
}
for {
if !converged {
return math.NaN()
}
converged = false
for i := 0; i < eventNewtonMaxIterations; i++ {
prevJD := estimateJD
longitudeDelta := uranusSunLongitudeDelta(prevJD, degree, true)
longitudeSlope := (uranusSunLongitudeDelta(prevJD+0.000005, degree, true) - uranusSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
estimateJD = prevJD - longitudeDelta/longitudeSlope
if math.Abs(estimateJD-prevJD) <= 0.00001 {
nextJD := prevJD - longitudeDelta/longitudeSlope
estimateJD = nextJD
if math.Abs(nextJD-prevJD) <= 0.00001 {
converged = true
break
}
}
if !converged {
return math.NaN()
}
return TD2UT(estimateJD, false)
}
+6 -6
View File
@@ -173,14 +173,14 @@ func VenusCulminationTime(jde, lon, timezone float64) float64 {
}
return ha
}
for {
prevJD := estimateJD
var ok bool
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
stDegree := limitHA(prevJD, lon, timezone) - 360
stDegreep := (limitHA(prevJD+0.000005, lon, timezone) - limitHA(prevJD-0.000005, lon, timezone)) / 0.00001
estimateJD = prevJD - stDegree/stDegreep
if math.Abs(estimateJD-prevJD) <= 0.00001 {
break
}
return stDegree / stDegreep
})
if !ok {
return math.NaN()
}
return estimateJD
}
+20
View File
@@ -5,6 +5,7 @@ import (
"math"
"os"
"testing"
"time"
)
type venusEventBaselineSample struct {
@@ -47,7 +48,13 @@ func TestVenusEventBaselineRegression(t *testing.T) {
t.Fatalf("%s missing baseline event %s", sample.InputUTC, event.name)
}
want := math.Float64frombits(wantBits)
if math.IsNaN(want) || math.IsInf(want, 0) {
t.Fatalf("%s %s baseline is non-finite %v", sample.InputUTC, event.name, want)
}
got := event.fn(jd)
if math.IsNaN(got) || math.IsInf(got, 0) {
t.Fatalf("%s %s returned non-finite result %v", sample.InputUTC, event.name, got)
}
diff := math.Abs(got - want)
if diff > event.tolerance {
t.Fatalf("%s %s diff %.12f > tolerance %.12f", sample.InputUTC, event.name, diff, event.tolerance)
@@ -55,3 +62,16 @@ func TestVenusEventBaselineRegression(t *testing.T) {
}
}
}
func TestVenusConjunctionExtremeInputIsBounded(t *testing.T) {
started := time.Now()
for _, next := range []uint8{0, 1} {
got := venusConjunction(1e8, next)
if math.IsInf(got, 0) {
t.Fatalf("venusConjunction(1e8, %d) returned infinite result %v", next, got)
}
}
if elapsed := time.Since(started); elapsed > 2*time.Second {
t.Fatalf("venusConjunction extreme input took %s, want <= 2s", elapsed)
}
}
+104 -24
View File
@@ -195,6 +195,9 @@ func venusElongationDerivativeN(jde, val float64, n int) float64 {
}
func venusConjunction(jde float64, next uint8) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
queryTT := jde
direction := -1.0
if next == 1 {
@@ -203,13 +206,14 @@ func venusConjunction(jde float64, next uint8) float64 {
left := queryTT
leftVal := venusSunLongitudeDeltaN(left, venusEventSearchN)
if math.Abs(venusSunLongitudeDelta(queryTT)) <= 30.0/86400.0 {
exact := eventZeroRefine(left, 1.0, 0.000005, venusSunLongitudeDelta)
eventUT := TD2UT(exact, false)
if next == 0 && eventUTQueryBeforeOrEqual(eventUT, queryTT) {
return eventUT
}
if next == 1 && eventUTQueryAfterOrEqual(eventUT, queryTT) {
return eventUT
if exact, ok := venusConjunctionRefine(left, 1.0); ok {
eventUT := TD2UT(exact, false)
if next == 0 && eventUTQueryBeforeOrEqual(eventUT, queryTT) {
return eventUT
}
if next == 1 && eventUTQueryAfterOrEqual(eventUT, queryTT) {
return eventUT
}
}
}
const step = 8.0
@@ -219,12 +223,36 @@ func venusConjunction(jde float64, next uint8) float64 {
if leftVal == 0 || rightVal == 0 || leftVal*rightVal <= 0 {
center := (left + right) / 2.0
halfWindow := math.Abs(right-left) / 2.0
return TD2UT(eventZeroRefine(center, halfWindow, 0.000005, venusSunLongitudeDelta), false)
if exact, ok := venusConjunctionRefine(center, halfWindow); ok {
return TD2UT(exact, false)
}
return math.NaN()
}
left = right
leftVal = rightVal
}
return TD2UT(eventZeroRefine(queryTT, VENUS_S_PERIOD, 0.000005, venusSunLongitudeDelta), false)
// 640 天已经覆盖一个金星会合周期;仍无括号通常表示输入超出解析项的可靠范围。
// 继续按 5 微日扫描整个周期会产生数亿次星历计算,因此在这里有界失败。
// The 640-day directional scan already exceeds one Venus synodic period. If it
// still finds no bracket, fail in a bounded way instead of scanning hundreds
// of millions of five-microday samples across the full fallback window.
return math.NaN()
}
func venusConjunctionRefine(seed, halfWindow float64) (float64, bool) {
leftJD := seed - halfWindow
centerJD := seed
rightJD := seed + halfWindow
leftVal := venusSunLongitudeDelta(leftJD)
centerVal := venusSunLongitudeDelta(centerJD)
rightVal := venusSunLongitudeDelta(rightJD)
if !isFiniteFloat(leftVal) || !isFiniteFloat(centerVal) || !isFiniteFloat(rightVal) {
return math.NaN(), false
}
if _, _, _, _, ok := eventZeroBracket(leftJD, leftVal, centerJD, centerVal, rightJD, rightVal); !ok {
return math.NaN(), false
}
return eventZeroRefine(seed, halfWindow, 0.000005, venusSunLongitudeDelta), true
}
func venusConjunctionTypeAt(eventUT float64) bool {
@@ -289,6 +317,9 @@ func LastVenusSuperiorConjunction(jde float64) float64 {
func venusRetrograde(jde float64) float64 {
//0=last 1=next
if !isFiniteFloat(jde) {
return math.NaN()
}
lastHe := LastVenusConjunctionStrict(jde)
nextHe := NextVenusConjunctionStrict(jde)
nowSub := venusSunRADelta(jde)
@@ -297,23 +328,31 @@ func venusRetrograde(jde float64) float64 {
} else {
jde = lastHe + 10
}
for {
found := false
for i := 0; i < eventDirectionalSearchIterations; i++ {
nowSub := venusRADerivativeN(jde, 1.0/86400.0, venusEventSearchN)
if !isFiniteFloat(nowSub) {
return math.NaN()
}
if math.Abs(nowSub) > 0.5 {
jde += 5
continue
}
found = true
break
}
if !found {
return math.NaN()
}
JD1 := jde
for {
JD0 := JD1
var ok bool
JD1, ok = eventNewtonRefine(JD1, 20.0/86400.0, func(JD0 float64) float64 {
stDegree := venusRADerivative(JD0, 0.5/86400.0)
stDegreep := (venusRADerivative(JD0+10.0/86400.0, 0.5/86400.0) - venusRADerivative(JD0-10.0/86400.0, 0.5/86400.0)) / (20.0 / 86400.0)
JD1 = JD0 - stDegree/stDegreep
if math.Abs(JD1-JD0) <= 20.0/86400.0 {
break
}
return stDegree / stDegreep
})
if !ok {
return math.NaN()
}
min := eventZeroRefine(JD1, 10.0/86400.0, 0.5/86400.0, func(jd float64) float64 {
return venusRADerivative(jd, 0.5/86400.0)
@@ -376,46 +415,62 @@ func venusRetrogradeToProgradeAroundInferior(inferior float64) float64 {
func NextVenusProgradeToRetrograde(jde float64) float64 {
inferior := NextVenusInferiorConjunction(jde)
for {
for i := 0; i < eventDirectionalSearchIterations; i++ {
date := venusProgradeToRetrogradeAroundInferior(inferior)
if !isFiniteFloat(date) {
return math.NaN()
}
if eventUTQueryAfterOrEqual(date, jde) {
return date
}
inferior = NextVenusInferiorConjunction(eventUTNextQueryTT(inferior))
}
return math.NaN()
}
func NextVenusRetrogradeToPrograde(jde float64) float64 {
inferior := LastVenusInferiorConjunction(jde)
for {
for i := 0; i < eventDirectionalSearchIterations; i++ {
date := venusRetrogradeToProgradeAroundInferior(inferior)
if !isFiniteFloat(date) {
return math.NaN()
}
if eventUTQueryAfterOrEqual(date, jde) {
return date
}
inferior = NextVenusInferiorConjunction(eventUTNextQueryTT(inferior))
}
return math.NaN()
}
func LastVenusProgradeToRetrograde(jde float64) float64 {
inferior := NextVenusInferiorConjunction(jde)
for {
for i := 0; i < eventDirectionalSearchIterations; i++ {
date := venusProgradeToRetrogradeAroundInferior(inferior)
if !isFiniteFloat(date) {
return math.NaN()
}
if eventUTQueryBeforeOrEqual(date, jde) {
return date
}
inferior = LastVenusInferiorConjunction(eventUTLastQueryTT(inferior))
}
return math.NaN()
}
func LastVenusRetrogradeToPrograde(jde float64) float64 {
inferior := LastVenusInferiorConjunction(jde)
for {
for i := 0; i < eventDirectionalSearchIterations; i++ {
date := venusRetrogradeToProgradeAroundInferior(inferior)
if !isFiniteFloat(date) {
return math.NaN()
}
if eventUTQueryBeforeOrEqual(date, jde) {
return date
}
inferior = LastVenusInferiorConjunction(eventUTLastQueryTT(inferior))
}
return math.NaN()
}
func VenusSunElongation(jde float64) float64 {
@@ -465,52 +520,77 @@ func venusWestElongationWindowContaining(jde float64) (float64, float64) {
}
func nextVenusGreatestElongationTyped(jde float64, east bool) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
if east {
start, windowEnd := venusEastElongationWindowContaining(jde)
for {
for i := 0; i < eventDirectionalSearchIterations; i++ {
date := venusGreatestElongationInWindow(start, windowEnd)
if !isFiniteFloat(date) {
return math.NaN()
}
if eventUTQueryAfterOrEqual(date, jde) {
return date
}
nextInferior := NextVenusInferiorConjunction(eventUTNextQueryTT(windowEnd))
start, windowEnd = venusEastElongationWindowEndingAt(nextInferior)
}
return math.NaN()
}
start, windowEnd := venusWestElongationWindowContaining(jde)
for {
for i := 0; i < eventDirectionalSearchIterations; i++ {
date := venusGreatestElongationInWindow(start, windowEnd)
if !isFiniteFloat(date) {
return math.NaN()
}
if eventUTQueryAfterOrEqual(date, jde) {
return date
}
nextSuperior := NextVenusSuperiorConjunction(eventUTNextQueryTT(windowEnd))
start, windowEnd = venusWestElongationWindowEndingAt(nextSuperior)
}
return math.NaN()
}
func lastVenusGreatestElongationTyped(jde float64, east bool) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
if east {
start, windowEnd := venusEastElongationWindowContaining(jde)
for {
for i := 0; i < eventDirectionalSearchIterations; i++ {
date := venusGreatestElongationInWindow(start, windowEnd)
if !isFiniteFloat(date) {
return math.NaN()
}
if eventUTQueryBeforeOrEqual(date, jde) {
return date
}
prevInferior := LastVenusInferiorConjunction(eventUTLastQueryTT(start))
start, windowEnd = venusEastElongationWindowEndingAt(prevInferior)
}
return math.NaN()
}
start, windowEnd := venusWestElongationWindowContaining(jde)
for {
for i := 0; i < eventDirectionalSearchIterations; i++ {
date := venusGreatestElongationInWindow(start, windowEnd)
if !isFiniteFloat(date) {
return math.NaN()
}
if eventUTQueryBeforeOrEqual(date, jde) {
return date
}
prevSuperior := LastVenusSuperiorConjunction(eventUTLastQueryTT(start))
start, windowEnd = venusWestElongationWindowEndingAt(prevSuperior)
}
return math.NaN()
}
func venusGreatestElongation(jde float64) float64 {
if !isFiniteFloat(jde) {
return math.NaN()
}
east := venusSunRADelta(jde) > 0
if east {
return nextVenusGreatestElongationTyped(jde, true)