feat: 新增月掩与日月食地理绘图并提升观测计算精度
- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算 - 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出 - 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口 - 扩展日食中心线、南北界及偏食足迹采样,支持极区投影 - 修正站心时角、月出月落、月球视半径、折射和恒星自行计算 - 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+162
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user