feat: 扩展天文计算能力

- 新增日食、月食、本地可见性、中心线、半影区域、SVG 图示与沙罗周期信息
- 新增行星冲合、留、方照、物理星历、视直径、相位、亮肢角、轨道节点等计算
- 新增木星伽利略卫星位置、现象与接触事件计算
- 新增恒星星表、星座判定、自行修正与观测辅助能力
- 新增 coord、formula、orbit、sundial、lite/sun、lite/moon 等扩展包
- 完善农历年号、月相英文别名、视差角、大气质量、折射、日晷与双星计算
- 增加 NASA、JPL Horizons、IMCCE 等回归测试数据与基线测试
- 重构基础算法文件组织,补充大量公开 API 注释和语义回归测试
- 更新中文和英文 README,补充示例、精度说明、SVG 配图
This commit is contained in:
2026-05-01 22:38:44 +08:00
parent 98ff574495
commit 3ffdbe0034
365 changed files with 63589 additions and 17508 deletions
+30
View File
@@ -0,0 +1,30 @@
package jupiter
import (
"time"
"b612.me/astro/basic"
"b612.me/astro/calendar"
)
// Semidiameter 木星视半径,单位角秒 / apparent Jupiter semidiameter in arcseconds.
func Semidiameter(date time.Time) float64 {
return SemidiameterN(date, -1)
}
// SemidiameterN 木星视半径(截断版),单位角秒 / truncated apparent Jupiter semidiameter in arcseconds.
func SemidiameterN(date time.Time, n int) float64 {
jde := calendar.Date2JDE(date.UTC())
return basic.JupiterSemidiameterN(basic.TD2UT(jde, true), n)
}
// Diameter 木星视直径,单位角秒 / apparent Jupiter diameter in arcseconds.
func Diameter(date time.Time) float64 {
return DiameterN(date, -1)
}
// DiameterN 木星视直径(截断版),单位角秒 / truncated apparent Jupiter diameter in arcseconds.
func DiameterN(date time.Time, n int) float64 {
jde := calendar.Date2JDE(date.UTC())
return basic.JupiterDiameterN(basic.TD2UT(jde, true), n)
}
+145 -84
View File
@@ -15,63 +15,115 @@ var (
ERR_JUPITER_NEVER_DOWN = ERR_JUPITER_NEVER_SET
)
// ApparentLo 视黄经
func riseSetResult(date time.Time, jde float64, err error) (time.Time, error) {
if err != nil {
switch {
case errors.Is(err, basic.ErrNeverRise):
return time.Time{}, ERR_JUPITER_NEVER_RISE
case errors.Is(err, basic.ErrNeverSet):
return time.Time{}, ERR_JUPITER_NEVER_SET
default:
return time.Time{}, err
}
}
return basic.JDE2DateByZone(jde, date.Location(), true), nil
}
// ApparentLo 视黄经 / apparent ecliptic longitude.
//
// 返回木星在 date 对应绝对时刻的瞬时视黄经,单位度。
// Returns the apparent ecliptic longitude of Jupiter at the instant represented by date, in degrees.
func ApparentLo(date time.Time) float64 {
jde := calendar.Date2JDE(date)
jde := calendar.Date2JDE(date.UTC())
return basic.JupiterApparentLo(basic.TD2UT(jde, true))
}
// ApparentBo 视黄纬
// ApparentBo 视黄纬 / apparent ecliptic latitude.
//
// 返回木星在 date 对应绝对时刻的瞬时视黄纬,单位度。
// Returns the apparent ecliptic latitude of Jupiter at the instant represented by date, in degrees.
func ApparentBo(date time.Time) float64 {
jde := calendar.Date2JDE(date)
jde := calendar.Date2JDE(date.UTC())
return basic.JupiterApparentBo(basic.TD2UT(jde, true))
}
// ApparentRa 视赤经
// ApparentRa 视赤经 / apparent right ascension.
//
// 返回木星在 date 对应绝对时刻的瞬时视赤经,单位度。
// Returns the apparent right ascension of Jupiter at the instant represented by date, in degrees.
func ApparentRa(date time.Time) float64 {
jde := calendar.Date2JDE(date)
jde := calendar.Date2JDE(date.UTC())
return basic.JupiterApparentRa(basic.TD2UT(jde, true))
}
// ApparentDec 视赤纬
// ApparentDec 视赤纬 / apparent declination.
//
// 返回木星在 date 对应绝对时刻的瞬时视赤纬,单位度。
// Returns the apparent declination of Jupiter at the instant represented by date, in degrees.
func ApparentDec(date time.Time) float64 {
jde := calendar.Date2JDE(date)
jde := calendar.Date2JDE(date.UTC())
return basic.JupiterApparentDec(basic.TD2UT(jde, true))
}
// ApparentRaDec 视赤经赤纬
// ApparentRaDec 视赤经、视赤纬 / apparent right ascension and declination.
//
// 返回木星在 date 对应绝对时刻的瞬时视赤经与视赤纬,单位度。
// Returns the apparent right ascension and declination of Jupiter at the instant represented by date, in degrees.
func ApparentRaDec(date time.Time) (float64, float64) {
jde := calendar.Date2JDE(date)
jde := calendar.Date2JDE(date.UTC())
return basic.JupiterApparentRaDec(basic.TD2UT(jde, true))
}
// ApparentMagnitude 视星等
// ApparentMagnitude 视星等 / apparent magnitude.
//
// 返回木星在 date 对应绝对时刻的视星等。
// Returns the apparent visual magnitude of Jupiter at the instant represented by date.
func ApparentMagnitude(date time.Time) float64 {
jde := calendar.Date2JDE(date)
jde := calendar.Date2JDE(date.UTC())
return basic.JupiterMag(basic.TD2UT(jde, true))
}
// EarthDistance 与地球距离(天文单位)
// EarthDistance 地心距离 / Earth distance.
//
// 返回木星在 date 对应绝对时刻到地球的距离,单位 AU。
// Returns the distance from Jupiter to Earth at the instant represented by date, in astronomical units.
func EarthDistance(date time.Time) float64 {
jde := calendar.Date2JDE(date)
jde := calendar.Date2JDE(date.UTC())
return basic.EarthJupiterAway(basic.TD2UT(jde, true))
}
// EarthDistance 与太阳距离(天文单位)
// SunDistance 日心距离 / Sun distance.
//
// 返回木星在 date 对应绝对时刻到太阳的距离,单位 AU。
// Returns the distance from Jupiter to the Sun at the instant represented by date, in astronomical units.
func SunDistance(date time.Time) float64 {
jde := calendar.Date2JDE(date)
jde := calendar.Date2JDE(date.UTC())
return planet.WherePlanet(4, 2, basic.TD2UT(jde, true))
}
// Zenith 高度角
func Zenith(date time.Time, lon, lat float64) float64 {
// Altitude 高度角 / altitude.
//
// date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负;lat 为观测者纬度,北正南负。返回值单位度。
// date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude, lat is north-positive latitude, and the result is in degrees.
func Altitude(date time.Time, lon, lat float64) float64 {
jde := basic.Date2JDE(date)
_, loc := date.Zone()
timezone := float64(loc) / 3600.0
return basic.JupiterHeight(jde, lon, lat, timezone)
}
// Azimuth 方位角
// Zenith 天顶距 / zenith distance.
//
// 参数与 Altitude 相同,返回值为对应时刻的天顶距,单位度。
// Uses the same inputs as Altitude and returns the zenith distance in degrees.
func Zenith(date time.Time, lon, lat float64) float64 {
return 90 - Altitude(date, lon, lat)
}
// Azimuth 方位角 / azimuth.
//
// date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负;lat 为观测者纬度,北正南负。返回值按正北为 0°、向东增加。
// date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude, lat is north-positive latitude, and azimuth is measured from north toward east.
func Azimuth(date time.Time, lon, lat float64) float64 {
jde := basic.Date2JDE(date)
_, loc := date.Zone()
@@ -79,8 +131,10 @@ func Azimuth(date time.Time, lon, lat float64) float64 {
return basic.JupiterAzimuth(jde, lon, lat, timezone)
}
// HourAngle 时角
// 返回给定经纬度、对应date时区date时刻的时角(
// HourAngle 时角 / hour angle.
//
// date 表示观测时刻,会读取其时区参与地方时计算;lon 为观测者经度,东正西负。返回值单位度。
// date is the observing instant and its zone offset participates in local-time calculations. lon is east-positive longitude and the returned hour angle is in degrees.
func HourAngle(date time.Time, lon float64) float64 {
jde := basic.Date2JDE(date)
_, loc := date.Zone()
@@ -88,8 +142,10 @@ func HourAngle(date time.Time, lon float64) float64 {
return basic.JupiterHourAngle(jde, lon, timezone)
}
// CulminationTime 中天时
// 返回给定经纬度、对应date时区date时刻的中天日期
// CulminationTime 中天时刻 / culmination time.
//
// date 取其所在时区的当地日期,返回值保持相同时区;lon 为观测者经度,东正西负。
// date is interpreted on its local civil day and the result keeps the same time zone. lon is east-positive longitude.
func CulminationTime(date time.Time, lon float64) time.Time {
if date.Hour() > 12 {
date = date.Add(time.Hour * -12)
@@ -101,14 +157,11 @@ func CulminationTime(date time.Time, lon float64) time.Time {
return basic.JDE2DateByZone(calcJde, date.Location(), false)
}
// RiseTime 升起时间
// date,取日期,时区忽略
// lon,经度,东正西负
// lat,纬度,北正南负
// height,高度
// aerotrue时进行大气修正
// RiseTime 升起时间 / rise time.
//
// date 取其所在时区的当地日期,返回值保持相同时区;lon 为东正西负经度,lat 为北正南负纬度;height 为观测点海拔高度(米);aero 为 true 时加入标准大气折射。
// date is interpreted on its local civil day and the result keeps the same time zone. lon is east-positive longitude, lat is north-positive latitude, height is observer elevation in meters, and aero enables standard atmospheric refraction.
func RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) {
var err error
var aeroFloat float64
if aero {
aeroFloat = 1
@@ -119,35 +172,25 @@ func RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, e
jde := basic.Date2JDE(date)
_, loc := date.Zone()
timezone := float64(loc) / 3600.0
riseJde := basic.JupiterRiseTime(jde, lon, lat, timezone, aeroFloat, height)
if riseJde == -2 {
err = ERR_JUPITER_NEVER_RISE
}
if riseJde == -1 {
err = ERR_JUPITER_NEVER_SET
}
return basic.JDE2DateByZone(riseJde, date.Location(), true), err
riseJde, err := basic.JupiterRiseTime(jde, lon, lat, timezone, aeroFloat, height)
return riseSetResult(date, riseJde, err)
}
// deprecated: -- use SetTime instead
// DownTime 落下时间
// date,取日期,时区忽略
// lon,经度,东正西负
// lat,纬度,北正南负
// height,高度
// aerotrue时进行大气修正
// DownTime 落下时间别名 / deprecated set-time alias.
//
// Deprecated: use SetTime instead.
//
// 参数与 SetTime 相同,仅为兼容旧接口保留。
// Same as SetTime and kept only for backward compatibility.
func DownTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) {
return SetTime(date, lon, lat, height, aero)
}
// SetTime 落下时间
// date,取日期,时区忽略
// lon,经度,东正西负
// lat,纬度,北正南负
// height,高度
// aerotrue时进行大气修正
// SetTime 落下时间 / set time.
//
// 参数与 RiseTime 相同,返回给定当地日期内的落下时刻。
// Uses the same inputs as RiseTime and returns the set time on the corresponding local civil day.
func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) {
var err error
var aeroFloat float64
if aero {
aeroFloat = 1
@@ -158,95 +201,113 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er
jde := basic.Date2JDE(date)
_, loc := date.Zone()
timezone := float64(loc) / 3600.0
riseJde := basic.JupiterDownTime(jde, lon, lat, timezone, aeroFloat, height)
if riseJde == -2 {
err = ERR_JUPITER_NEVER_RISE
}
if riseJde == -1 {
err = ERR_JUPITER_NEVER_SET
}
return basic.JDE2DateByZone(riseJde, date.Location(), true), err
riseJde, err := basic.JupiterSetTime(jde, lon, lat, timezone, aeroFloat, height)
return riseSetResult(date, riseJde, err)
}
// LastConjunction 上次合日时间
// 返回上次合日时间
// LastConjunction 上次合日 / previous conjunction with the Sun.
//
// 返回 date 之前最近一次与太阳的合日时刻,结果保持 date 的时区。
// Returns the most recent conjunction with the Sun relative to date, keeping date's time zone.
func LastConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastJupiterConjunction(jde), date.Location(), false)
}
// NextConjunction 下次合日时间
// 返回下次合日时间
// NextConjunction 下次合日 / next conjunction with the Sun.
//
// 返回 date 之后最近一次与太阳的合日时刻,结果保持 date 的时区。
// Returns the next conjunction with the Sun relative to date, keeping date's time zone.
func NextConjunction(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextJupiterConjunction(jde), date.Location(), false)
}
// LastOpposition 上次冲日时间
// 返回上次冲日时间
// LastOpposition 上次冲日 / previous opposition.
//
// 返回 date 之前最近一次冲日时刻,结果保持 date 的时区。
// Returns the most recent opposition relative to date, keeping date's time zone.
func LastOpposition(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastJupiterOpposition(jde), date.Location(), false)
}
// NextOpposition 下次冲日时间
// 返回下次冲日时间
// NextOpposition 下次冲日 / next opposition.
//
// 返回 date 之后最近一次冲日时刻,结果保持 date 的时区。
// Returns the next opposition relative to date, keeping date's time zone.
func NextOpposition(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextJupiterOpposition(jde), date.Location(), false)
}
// LastProgradeToRetrograde 上次留(顺转逆)
// 返回上次顺转逆留的时间
// LastProgradeToRetrograde 上一次顺行转逆行留 / previous station from prograde to retrograde.
//
// 返回 date 之前最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。
// Returns the most recent stationary point where motion changes from prograde to retrograde relative to date, keeping date's time zone.
func LastProgradeToRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastJupiterProgradeToRetrograde(jde), date.Location(), false)
}
// NextProgradeToRetrograde 下次留(顺转逆)
// 返回下次顺转逆留的时间
// NextProgradeToRetrograde 下一次顺行转逆行留 / next station from prograde to retrograde.
//
// 返回 date 之后最近一次由顺行转为逆行的留时刻,结果保持 date 的时区。
// Returns the next stationary point where motion changes from prograde to retrograde relative to date, keeping date's time zone.
func NextProgradeToRetrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextJupiterProgradeToRetrograde(jde), date.Location(), false)
}
// LastRetrogradeToPrograde 上次留(逆转瞬)
// 返回上次逆转瞬留的时间
// LastRetrogradeToPrograde 上一次逆行转顺行留 / previous station from retrograde to prograde.
//
// 返回 date 之前最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。
// Returns the most recent stationary point where motion changes from retrograde to prograde relative to date, keeping date's time zone.
func LastRetrogradeToPrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastJupiterRetrogradeToPrograde(jde), date.Location(), false)
}
// NextRetrogradeToPrograde 上次留(逆转瞬)
// // 返回上次逆转瞬留的时间
// NextRetrogradeToPrograde 下一次逆行转顺行留 / next station from retrograde to prograde.
//
// 返回 date 之后最近一次由逆行转为顺行的留时刻,结果保持 date 的时区。
// Returns the next stationary point where motion changes from retrograde to prograde relative to date, keeping date's time zone.
func NextRetrogradeToPrograde(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextJupiterRetrogradeToPrograde(jde), date.Location(), false)
}
// LastEasternQuadrature 上次东方照时间
// 返回上次东方照时间
// LastEasternQuadrature 上次东方照 / previous eastern quadrature.
//
// 返回 date 之前最近一次东方照时刻,结果保持 date 的时区。
// Returns the most recent eastern quadrature relative to date, keeping date's time zone.
func LastEasternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastJupiterEasternQuadrature(jde), date.Location(), false)
}
// NextEasternQuadrature 下次东方照时间
// 返回下次东方照时间
// NextEasternQuadrature 下次东方照 / next eastern quadrature.
//
// 返回 date 之后最近一次东方照时刻,结果保持 date 的时区。
// Returns the next eastern quadrature relative to date, keeping date's time zone.
func NextEasternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextJupiterEasternQuadrature(jde), date.Location(), false)
}
// LastWesternQuadrature 上次西方照时间
// 返回上次西方照时间
// LastWesternQuadrature 上次西方照 / previous western quadrature.
//
// 返回 date 之前最近一次西方照时刻,结果保持 date 的时区。
// Returns the most recent western quadrature relative to date, keeping date's time zone.
func LastWesternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.LastJupiterWesternQuadrature(jde), date.Location(), false)
}
// NextWesternQuadrature 下次西方照时间
// 返回下次西方照时间
// NextWesternQuadrature 下次西方照 / next western quadrature.
//
// 返回 date 之后最近一次西方照时刻,结果保持 date 的时区。
// Returns the next western quadrature relative to date, keeping date's time zone.
func NextWesternQuadrature(date time.Time) time.Time {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
return basic.JDE2DateByZone(basic.NextJupiterWesternQuadrature(jde), date.Location(), false)
+30
View File
@@ -0,0 +1,30 @@
package jupiter
import (
"time"
"b612.me/astro/basic"
"b612.me/astro/calendar"
)
// AscendingNode 木星升交点黄经 / ascending node longitude of Jupiter.
func AscendingNode(date time.Time) float64 {
return AscendingNodeN(date, -1)
}
// AscendingNodeN 木星升交点黄经(截断版) / truncated ascending node longitude of Jupiter.
func AscendingNodeN(date time.Time, n int) float64 {
jde := calendar.Date2JDE(date.UTC())
return basic.JupiterAscendingNodeN(basic.TD2UT(jde, true), n)
}
// DescendingNode 木星降交点黄经 / descending node longitude of Jupiter.
func DescendingNode(date time.Time) float64 {
return DescendingNodeN(date, -1)
}
// DescendingNodeN 木星降交点黄经(截断版) / truncated descending node longitude of Jupiter.
func DescendingNodeN(date time.Time, n int) float64 {
jde := calendar.Date2JDE(date.UTC())
return basic.JupiterDescendingNodeN(basic.TD2UT(jde, true), n)
}
+94
View File
@@ -0,0 +1,94 @@
package jupiter
import (
"math"
"testing"
"time"
)
func TestObservationNFullMatchesDefault(t *testing.T) {
date := time.Date(2026, 4, 26, 9, 30, 45, 123456789, time.FixedZone("CST", 8*3600))
lon := 116.391
lat := 39.907
height := 45.0
assertSame := func(name string, got, want float64) {
t.Helper()
if math.Float64bits(got) != math.Float64bits(want) {
t.Fatalf("%s full-n mismatch", name)
}
}
assertSamePair := func(name string, got1, got2, want1, want2 float64) {
t.Helper()
assertSame(name+".1", got1, want1)
assertSame(name+".2", got2, want2)
}
assertTimeSame := func(name string, got, want time.Time) {
t.Helper()
if got.UnixNano() != want.UnixNano() || got.Location().String() != want.Location().String() {
t.Fatalf("%s full-n mismatch", name)
}
}
assertErrSame := func(name string, got, want error) {
t.Helper()
switch {
case got == nil && want == nil:
return
case got == nil || want == nil:
t.Fatalf("%s full-n mismatch", name)
case got.Error() != want.Error():
t.Fatalf("%s full-n mismatch", name)
}
}
floatChecks := []struct {
name string
got func() float64
want func() float64
}{
{"ApparentLo", func() float64 { return ApparentLo(date) }, func() float64 { return ApparentLoN(date, -1) }},
{"ApparentBo", func() float64 { return ApparentBo(date) }, func() float64 { return ApparentBoN(date, -1) }},
{"ApparentRa", func() float64 { return ApparentRa(date) }, func() float64 { return ApparentRaN(date, -1) }},
{"ApparentDec", func() float64 { return ApparentDec(date) }, func() float64 { return ApparentDecN(date, -1) }},
{"ApparentMagnitude", func() float64 { return ApparentMagnitude(date) }, func() float64 { return ApparentMagnitudeN(date, -1) }},
{"PhaseAngle", func() float64 { return PhaseAngle(date) }, func() float64 { return PhaseAngleN(date, -1) }},
{"IlluminatedFraction", func() float64 { return IlluminatedFraction(date) }, func() float64 { return IlluminatedFractionN(date, -1) }},
{"Phase", func() float64 { return Phase(date) }, func() float64 { return PhaseN(date, -1) }},
{"BrightLimbPositionAngle", func() float64 { return BrightLimbPositionAngle(date) }, func() float64 { return BrightLimbPositionAngleN(date, -1) }},
{"EarthDistance", func() float64 { return EarthDistance(date) }, func() float64 { return EarthDistanceN(date, -1) }},
{"SunDistance", func() float64 { return SunDistance(date) }, func() float64 { return SunDistanceN(date, -1) }},
{"Altitude", func() float64 { return Altitude(date, lon, lat) }, func() float64 { return AltitudeN(date, lon, lat, -1) }},
{"Zenith", func() float64 { return Zenith(date, lon, lat) }, func() float64 { return ZenithN(date, lon, lat, -1) }},
{"Azimuth", func() float64 { return Azimuth(date, lon, lat) }, func() float64 { return AzimuthN(date, lon, lat, -1) }},
{"HourAngle", func() float64 { return HourAngle(date, lon) }, func() float64 { return HourAngleN(date, lon, -1) }},
{"ParallacticAngle", func() float64 { return ParallacticAngle(date, lon, lat) }, func() float64 { return ParallacticAngleN(date, lon, lat, -1) }},
}
for _, tc := range floatChecks {
assertSame(tc.name, tc.got(), tc.want())
}
if math.Abs((Altitude(date, lon, lat)+Zenith(date, lon, lat))-90) > 1e-12 {
t.Fatal("altitude + zenith should equal 90 degrees")
}
gotRa, gotDec := ApparentRaDec(date)
wantRa, wantDec := ApparentRaDecN(date, -1)
assertSamePair("ApparentRaDec", gotRa, gotDec, wantRa, wantDec)
assertTimeSame("CulminationTime", CulminationTime(date, lon), CulminationTimeN(date, lon, -1))
rise1, err1 := RiseTime(date, lon, lat, height, true)
rise2, err2 := RiseTimeN(date, lon, lat, height, true, -1)
assertTimeSame("RiseTime", rise1, rise2)
assertErrSame("RiseTime.err", err1, err2)
set1, err1 := SetTime(date, lon, lat, height, true)
set2, err2 := SetTimeN(date, lon, lat, height, true, -1)
assertTimeSame("SetTime", set1, set2)
assertErrSame("SetTime.err", err1, err2)
down1, err1 := DownTime(date, lon, lat, height, true)
down2, err2 := DownTimeN(date, lon, lat, height, true, -1)
assertTimeSame("DownTime", down1, down2)
assertErrSame("DownTime.err", err1, err2)
}
+17
View File
@@ -0,0 +1,17 @@
package jupiter
import (
"time"
"b612.me/astro/basic"
)
// ParallacticAngle 木星视差角(天顶方向角) / Jupiter parallactic angle.
func ParallacticAngle(date time.Time, lon, lat float64) float64 {
return basic.ParallacticAngleByHourAngle(HourAngle(date, lon), ApparentDec(date), lat)
}
// ParallacticAngleN 截断项木星视差角(天顶方向角) / truncated Jupiter parallactic angle.
func ParallacticAngleN(date time.Time, lon, lat float64, n int) float64 {
return basic.ParallacticAngleByHourAngle(HourAngleN(date, lon, n), ApparentDecN(date, n), lat)
}
+52
View File
@@ -0,0 +1,52 @@
package jupiter
import (
"time"
"b612.me/astro/basic"
"b612.me/astro/calendar"
)
// PhaseAngle 相位角,单位度 / phase angle in degrees.
func PhaseAngle(date time.Time) float64 {
return PhaseAngleN(date, -1)
}
// PhaseAngleN 相位角(截断版),单位度 / truncated phase angle in degrees.
func PhaseAngleN(date time.Time, n int) float64 {
return basic.JupiterPhaseAngleN(phaseJD(date), n)
}
// IlluminatedFraction 被照亮比例 / illuminated fraction.
func IlluminatedFraction(date time.Time) float64 {
return IlluminatedFractionN(date, -1)
}
// IlluminatedFractionN 被照亮比例(截断版) / truncated illuminated fraction.
func IlluminatedFractionN(date time.Time, n int) float64 {
return basic.JupiterIlluminatedFractionN(phaseJD(date), n)
}
// Phase 相位,被照亮比例 / phase, illuminated fraction.
func Phase(date time.Time) float64 {
return IlluminatedFraction(date)
}
// PhaseN 相位(截断版),被照亮比例 / truncated phase, illuminated fraction.
func PhaseN(date time.Time, n int) float64 {
return IlluminatedFractionN(date, n)
}
// BrightLimbPositionAngle 亮面中心位置角,单位度 / bright limb position angle in degrees.
func BrightLimbPositionAngle(date time.Time) float64 {
return BrightLimbPositionAngleN(date, -1)
}
// BrightLimbPositionAngleN 亮面中心位置角(截断版),单位度 / truncated bright limb position angle in degrees.
func BrightLimbPositionAngleN(date time.Time, n int) float64 {
return basic.JupiterBrightLimbPositionAngleN(phaseJD(date), n)
}
func phaseJD(date time.Time) float64 {
return basic.TD2UT(calendar.Date2JDE(date.UTC()), true)
}
+62
View File
@@ -0,0 +1,62 @@
package jupiter
import (
"time"
"b612.me/astro/basic"
)
// GalileanSatellitePhenomenon 木星伽利略卫星瞬时现象 / instantaneous Galilean-satellite phenomena.
//
// Transit 为凌日,Occultation 为掩蔽,Eclipse 为进入木星本影,ShadowTransit 为卫星影子落在可见木星盘面上。
// ShadowOffset* 仅在 ShadowTransit 为 true 时有意义。
// Transit means a transit across Jupiter's disk, Occultation means hidden behind Jupiter, Eclipse means inside Jupiter's umbra, and ShadowTransit means the shadow falls on the visible Jovian disk.
// ShadowOffset* are meaningful only when ShadowTransit is true.
type GalileanSatellitePhenomenon struct {
Transit bool
Occultation bool
Eclipse bool
ShadowTransit bool
ShadowOffsetXArcsec float64
ShadowOffsetYArcsec float64
ShadowOffsetXJupiterR float64
ShadowOffsetYJupiterR float64
}
// GalileanPhenomenaInfo 四颗伽利略卫星瞬时现象 / instantaneous phenomena of the four Galilean satellites.
type GalileanPhenomenaInfo struct {
Io GalileanSatellitePhenomenon
Europa GalileanSatellitePhenomenon
Ganymede GalileanSatellitePhenomenon
Callisto GalileanSatellitePhenomenon
}
// SatellitePhenomena 木星四颗伽利略卫星瞬时现象 / instantaneous phenomena of Jupiter's four Galilean satellites.
//
// date 表示观测绝对时刻;内部使用该时刻对应的 TT/TDB 历元求值。
// date is the observing instant; internally the corresponding TT/TDB epoch is used.
func SatellitePhenomena(date time.Time) GalileanPhenomenaInfo {
jde := basic.Date2JDE(date.UTC())
phenomena := basic.JupiterGalileanSatellitePhenomena(jde)
return GalileanPhenomenaInfo{
Io: galileanPhenomenonFromBasic(phenomena[0]),
Europa: galileanPhenomenonFromBasic(phenomena[1]),
Ganymede: galileanPhenomenonFromBasic(phenomena[2]),
Callisto: galileanPhenomenonFromBasic(phenomena[3]),
}
}
func galileanPhenomenonFromBasic(phenomenon basic.JupiterGalileanPhenomenon) GalileanSatellitePhenomenon {
return GalileanSatellitePhenomenon{
Transit: phenomenon.Transit,
Occultation: phenomenon.Occultation,
Eclipse: phenomenon.Eclipse,
ShadowTransit: phenomenon.ShadowTransit,
ShadowOffsetXArcsec: phenomenon.ShadowOffsetXArcsec,
ShadowOffsetYArcsec: phenomenon.ShadowOffsetYArcsec,
ShadowOffsetXJupiterR: phenomenon.ShadowOffsetXJupiterRadii,
ShadowOffsetYJupiterR: phenomenon.ShadowOffsetYJupiterRadii,
}
}
+125
View File
@@ -0,0 +1,125 @@
package jupiter
import (
"time"
"b612.me/astro/basic"
)
// GalileanPhenomenonContactPhase 接触阶段 / contact phase.
type GalileanPhenomenonContactPhase string
const (
// GalileanPhenomenonContactDisappearance 初亏/初入接触阶段 / disappearance ingress contact.
GalileanPhenomenonContactDisappearance GalileanPhenomenonContactPhase = "disappearance"
// GalileanPhenomenonContactReappearance 复圆/复出接触阶段 / reappearance egress contact.
GalileanPhenomenonContactReappearance GalileanPhenomenonContactPhase = "reappearance"
)
// GalileanPhenomenonContact 伽利略卫星接触窗口 / Galilean-satellite contact window.
//
// Start/End 是有限圆盘或有限影斑开始/结束接触的时刻;ModelCrossing 是这套连续接触模型下,
// 零半径参考点穿越边界的时刻。
// Start/End mark the beginning/end of the finite-disk or finite-shadow contact interval.
// ModelCrossing is the zero-radius boundary crossing in this continuous contact model.
type GalileanPhenomenonContact struct {
Valid bool
Phase GalileanPhenomenonContactPhase
Start time.Time
ModelCrossing time.Time
End time.Time
Duration time.Duration
}
// GalileanPhenomenonContactEvent IMCCE 风格的 D/F 接触事件 / IMCCE-style D/F contact event.
//
// 这个 API 返回有限圆盘/有限影斑的接触窗口,适合和 IMCCE 的 `TR.D/TR.F/OC.D/OC.F/EC.D/EC.F/SH.D/SH.F` 对齐;
// 现有 `GalileanPhenomenonEvent` 返回的是零半径几何模型保持 active 的整段区间,两者语义不同。
// 其中 `shadow_transit` 先用半影/本影边界求部分相持续时间,再把这段持续时间中心放在旧影轴过盘时刻上。
// This API returns finite-disk / finite-shadow contact windows and is intended to align with IMCCE
// `TR.D/TR.F/OC.D/OC.F/EC.D/EC.F/SH.D/SH.F` rows. The existing `GalileanPhenomenonEvent` returns the
// whole active interval of the zero-radius geometric model, so the semantics are different.
// For `shadow_transit`, the partial-phase duration comes from penumbra/umbra boundaries,
// while the reported D/F time is centered on the shadow-axis limb crossing from the existing full-event model.
type GalileanPhenomenonContactEvent struct {
Valid bool
Satellite int
Type GalileanPhenomenonType
Disappearance GalileanPhenomenonContact
Greatest time.Time
Reappearance GalileanPhenomenonContact
GreatestState GalileanSatellitePhenomenon
}
// LastGalileanPhenomenonContactEvent 上一次 IMCCE 风格接触事件 / previous IMCCE-style contact event.
func LastGalileanPhenomenonContactEvent(date time.Time, satellite int, phenomenonType GalileanPhenomenonType) GalileanPhenomenonContactEvent {
return galileanPhenomenonContactEventFromBasic(
basic.LastJupiterGalileanPhenomenonContactEvent(
basic.Date2JDE(date.UTC()),
satellite,
basic.JupiterGalileanPhenomenonType(phenomenonType),
),
date.Location(),
)
}
// NextGalileanPhenomenonContactEvent 下一次 IMCCE 风格接触事件 / next IMCCE-style contact event.
func NextGalileanPhenomenonContactEvent(date time.Time, satellite int, phenomenonType GalileanPhenomenonType) GalileanPhenomenonContactEvent {
return galileanPhenomenonContactEventFromBasic(
basic.NextJupiterGalileanPhenomenonContactEvent(
basic.Date2JDE(date.UTC()),
satellite,
basic.JupiterGalileanPhenomenonType(phenomenonType),
),
date.Location(),
)
}
// ClosestGalileanPhenomenonContactEvent 最近一次 IMCCE 风格接触事件 / closest IMCCE-style contact event.
func ClosestGalileanPhenomenonContactEvent(date time.Time, satellite int, phenomenonType GalileanPhenomenonType) GalileanPhenomenonContactEvent {
return galileanPhenomenonContactEventFromBasic(
basic.ClosestJupiterGalileanPhenomenonContactEvent(
basic.Date2JDE(date.UTC()),
satellite,
basic.JupiterGalileanPhenomenonType(phenomenonType),
),
date.Location(),
)
}
func galileanPhenomenonContactEventFromBasic(event basic.JupiterGalileanPhenomenonContactEvent, loc *time.Location) GalileanPhenomenonContactEvent {
if !event.Valid {
return GalileanPhenomenonContactEvent{}
}
greatest := basic.JDE2DateByZone(event.Greatest, loc, false)
return GalileanPhenomenonContactEvent{
Valid: true,
Satellite: event.Satellite,
Type: GalileanPhenomenonType(event.Type),
Disappearance: galileanPhenomenonContactFromBasic(event.Disappearance, loc),
Greatest: greatest,
Reappearance: galileanPhenomenonContactFromBasic(event.Reappearance, loc),
GreatestState: galileanPhenomenonFromBasic(event.GreatestPhenomenon),
}
}
func galileanPhenomenonContactFromBasic(contact basic.JupiterGalileanPhenomenonContact, loc *time.Location) GalileanPhenomenonContact {
if !contact.Valid {
return GalileanPhenomenonContact{}
}
start := basic.JDE2DateByZone(contact.Start, loc, false)
modelCrossing := basic.JDE2DateByZone(contact.ModelCrossing, loc, false)
end := basic.JDE2DateByZone(contact.End, loc, false)
return GalileanPhenomenonContact{
Valid: true,
Phase: GalileanPhenomenonContactPhase(contact.Phase),
Start: start,
ModelCrossing: modelCrossing,
End: end,
Duration: end.Sub(start),
}
}
+96
View File
@@ -0,0 +1,96 @@
package jupiter
import (
"testing"
"time"
"b612.me/astro/basic"
)
func TestGalileanPhenomenonContactEventWrappersMatchBasic(t *testing.T) {
records := loadGalileanPublicEventBaseline(t)
loc := time.FixedZone("UTC+8", 8*3600)
for _, record := range records {
startUTC := mustParseGalileanEventTime(t, record.StartUTC)
endUTC := mustParseGalileanEventTime(t, record.EndUTC)
queryMid := startUTC.Add(endUTC.Sub(startUTC) / 2).In(loc)
phenomenonType := GalileanPhenomenonType(record.Type)
got := ClosestGalileanPhenomenonContactEvent(queryMid, record.Satellite, phenomenonType)
want := basic.ClosestJupiterGalileanPhenomenonContactEvent(
basic.Date2JDE(queryMid.UTC()),
record.Satellite,
basic.JupiterGalileanPhenomenonType(phenomenonType),
)
assertGalileanContactWrapperMatchesBasic(t, record.Label, got, want, loc)
}
}
func assertGalileanContactWrapperMatchesBasic(
t *testing.T,
name string,
got GalileanPhenomenonContactEvent,
want basic.JupiterGalileanPhenomenonContactEvent,
loc *time.Location,
) {
t.Helper()
if got.Valid != want.Valid {
t.Fatalf("%s valid mismatch: got %v want %v", name, got.Valid, want.Valid)
}
if !got.Valid {
return
}
if got.Greatest.Location() != loc {
t.Fatalf("%s greatest timezone mismatch", name)
}
wantGreatest := basic.JDE2DateByZone(want.Greatest, loc, false)
if !got.Greatest.Equal(wantGreatest) {
t.Fatalf("%s greatest mismatch: got %s want %s", name, got.Greatest.Format(time.RFC3339Nano), wantGreatest.Format(time.RFC3339Nano))
}
if got.Satellite != want.Satellite || string(got.Type) != string(want.Type) {
t.Fatalf("%s id/type mismatch", name)
}
assertGalileanContactMatchesBasic(t, name+" disappearance", got.Disappearance, want.Disappearance, loc)
assertGalileanContactMatchesBasic(t, name+" reappearance", got.Reappearance, want.Reappearance, loc)
assertSameGalileanPhenomenon(t, name+" greatest", got.GreatestState, want.GreatestPhenomenon)
}
func assertGalileanContactMatchesBasic(
t *testing.T,
name string,
got GalileanPhenomenonContact,
want basic.JupiterGalileanPhenomenonContact,
loc *time.Location,
) {
t.Helper()
if got.Valid != want.Valid {
t.Fatalf("%s valid mismatch", name)
}
if !got.Valid {
return
}
wantStart := basic.JDE2DateByZone(want.Start, loc, false)
wantModel := basic.JDE2DateByZone(want.ModelCrossing, loc, false)
wantEnd := basic.JDE2DateByZone(want.End, loc, false)
if got.Start.Location() != loc || got.ModelCrossing.Location() != loc || got.End.Location() != loc {
t.Fatalf("%s timezone mismatch", name)
}
if !got.Start.Equal(wantStart) || !got.ModelCrossing.Equal(wantModel) || !got.End.Equal(wantEnd) {
t.Fatalf(
"%s time mismatch: got [%s %s %s] want [%s %s %s]",
name,
got.Start.Format(time.RFC3339Nano),
got.ModelCrossing.Format(time.RFC3339Nano),
got.End.Format(time.RFC3339Nano),
wantStart.Format(time.RFC3339Nano),
wantModel.Format(time.RFC3339Nano),
wantEnd.Format(time.RFC3339Nano),
)
}
if got.Duration != got.End.Sub(got.Start) {
t.Fatalf("%s duration mismatch", name)
}
if string(got.Phase) != string(want.Phase) {
t.Fatalf("%s phase mismatch: got %q want %q", name, got.Phase, want.Phase)
}
}
+115
View File
@@ -0,0 +1,115 @@
package jupiter
import (
"time"
"b612.me/astro/basic"
)
// GalileanPhenomenonType 伽利略卫星现象类型 / Galilean-satellite phenomenon type.
type GalileanPhenomenonType string
const (
// GalileanPhenomenonTransit 凌日 / satellite transit across Jupiter.
GalileanPhenomenonTransit GalileanPhenomenonType = "transit"
// GalileanPhenomenonOccultation 掩蔽 / occultation behind Jupiter.
GalileanPhenomenonOccultation GalileanPhenomenonType = "occultation"
// GalileanPhenomenonEclipse 食 / eclipse in Jupiter's shadow.
GalileanPhenomenonEclipse GalileanPhenomenonType = "eclipse"
// GalileanPhenomenonShadowTransit 影凌 / shadow transit across Jupiter.
GalileanPhenomenonShadowTransit GalileanPhenomenonType = "shadow_transit"
)
const (
// GalileanSatelliteIo 木卫一 / Io.
GalileanSatelliteIo = 1
// GalileanSatelliteEuropa 木卫二 / Europa.
GalileanSatelliteEuropa = 2
// GalileanSatelliteGanymede 木卫三 / Ganymede.
GalileanSatelliteGanymede = 3
// GalileanSatelliteCallisto 木卫四 / Callisto.
GalileanSatelliteCallisto = 4
)
// GalileanPhenomenonEvent 伽利略卫星整场现象 / full Galilean-satellite event.
//
// Start、Greatest、End 都保持调用者输入的时区。
// GreatestState 是食甚/现象最深时刻对应的瞬时现象标志与影心偏移。
// 这里的 Start/End 基于零半径几何模型是否 active,不是 IMCCE 年表中的 D/F 接触时刻;
// 如果需要有限圆盘/有限影斑的接触窗口,请改用 `GalileanPhenomenonContactEvent`。
// Start, Greatest, and End preserve the caller's timezone.
// GreatestState contains the instantaneous phenomenon flags and shadow offsets at greatest event depth.
// Start/End here are based on whether the zero-radius geometric model is active, not the IMCCE D/F contact times.
// Use `GalileanPhenomenonContactEvent` when you need finite-disk or finite-shadow contact windows.
type GalileanPhenomenonEvent struct {
Valid bool
Satellite int
Type GalileanPhenomenonType
Start time.Time
Greatest time.Time
End time.Time
Duration time.Duration
GreatestState GalileanSatellitePhenomenon
}
// LastGalileanPhenomenonEvent 上一次伽利略卫星现象 / previous Galilean-satellite event.
//
// date 表示查询绝对时刻;satellite 取 `1=Io, 2=Europa, 3=Ganymede, 4=Callisto`
// phenomenonType 取 `transit/occultation/eclipse/shadow_transit` 中之一。
// date is the query instant; satellite is `1=Io, 2=Europa, 3=Ganymede, 4=Callisto`;
// phenomenonType is one of `transit/occultation/eclipse/shadow_transit`.
func LastGalileanPhenomenonEvent(date time.Time, satellite int, phenomenonType GalileanPhenomenonType) GalileanPhenomenonEvent {
return galileanPhenomenonEventFromBasic(
basic.LastJupiterGalileanPhenomenonEvent(
basic.Date2JDE(date.UTC()),
satellite,
basic.JupiterGalileanPhenomenonType(phenomenonType),
),
date.Location(),
)
}
// NextGalileanPhenomenonEvent 下一次伽利略卫星现象 / next Galilean-satellite event.
func NextGalileanPhenomenonEvent(date time.Time, satellite int, phenomenonType GalileanPhenomenonType) GalileanPhenomenonEvent {
return galileanPhenomenonEventFromBasic(
basic.NextJupiterGalileanPhenomenonEvent(
basic.Date2JDE(date.UTC()),
satellite,
basic.JupiterGalileanPhenomenonType(phenomenonType),
),
date.Location(),
)
}
// ClosestGalileanPhenomenonEvent 最近一次伽利略卫星现象 / closest Galilean-satellite event.
func ClosestGalileanPhenomenonEvent(date time.Time, satellite int, phenomenonType GalileanPhenomenonType) GalileanPhenomenonEvent {
return galileanPhenomenonEventFromBasic(
basic.ClosestJupiterGalileanPhenomenonEvent(
basic.Date2JDE(date.UTC()),
satellite,
basic.JupiterGalileanPhenomenonType(phenomenonType),
),
date.Location(),
)
}
func galileanPhenomenonEventFromBasic(event basic.JupiterGalileanPhenomenonEvent, loc *time.Location) GalileanPhenomenonEvent {
if !event.Valid {
return GalileanPhenomenonEvent{}
}
start := basic.JDE2DateByZone(event.Start, loc, false)
greatest := basic.JDE2DateByZone(event.Greatest, loc, false)
end := basic.JDE2DateByZone(event.End, loc, false)
return GalileanPhenomenonEvent{
Valid: true,
Satellite: event.Satellite,
Type: GalileanPhenomenonType(event.Type),
Start: start,
Greatest: greatest,
End: end,
Duration: end.Sub(start),
GreatestState: galileanPhenomenonFromBasic(event.GreatestPhenomenon),
}
}
+136
View File
@@ -0,0 +1,136 @@
package jupiter
import (
"encoding/json"
"math"
"os"
"testing"
"time"
"b612.me/astro/basic"
)
type galileanPublicEventBaselineRecord struct {
Label string `json:"label"`
Satellite int `json:"satellite"`
Type string `json:"type"`
StartUTC string `json:"start_utc"`
StartDurationMinutes float64 `json:"start_duration_minutes"`
EndUTC string `json:"end_utc"`
EndDurationMinutes float64 `json:"end_duration_minutes"`
}
func TestGalileanPhenomenonEventWrappersMatchBasic(t *testing.T) {
records := loadGalileanPublicEventBaseline(t)
loc := time.FixedZone("UTC+8", 8*3600)
for _, record := range records {
startUTC := mustParseGalileanEventTime(t, record.StartUTC)
endUTC := mustParseGalileanEventTime(t, record.EndUTC)
queryBefore := startUTC.Add(-12 * time.Hour).In(loc)
queryAfter := endUTC.Add(12 * time.Hour).In(loc)
queryMid := startUTC.Add(endUTC.Sub(startUTC) / 2).In(loc)
phenomenonType := GalileanPhenomenonType(record.Type)
assertGalileanWrapperMatchesBasic(
t,
record.Label+" next",
NextGalileanPhenomenonEvent(queryBefore, record.Satellite, phenomenonType),
basic.NextJupiterGalileanPhenomenonEvent(basic.Date2JDE(queryBefore.UTC()), record.Satellite, basic.JupiterGalileanPhenomenonType(phenomenonType)),
loc,
)
assertGalileanWrapperMatchesBasic(
t,
record.Label+" last",
LastGalileanPhenomenonEvent(queryAfter, record.Satellite, phenomenonType),
basic.LastJupiterGalileanPhenomenonEvent(basic.Date2JDE(queryAfter.UTC()), record.Satellite, basic.JupiterGalileanPhenomenonType(phenomenonType)),
loc,
)
assertGalileanWrapperMatchesBasic(
t,
record.Label+" closest",
ClosestGalileanPhenomenonEvent(queryMid, record.Satellite, phenomenonType),
basic.ClosestJupiterGalileanPhenomenonEvent(basic.Date2JDE(queryMid.UTC()), record.Satellite, basic.JupiterGalileanPhenomenonType(phenomenonType)),
loc,
)
}
}
func assertGalileanWrapperMatchesBasic(
t *testing.T,
name string,
got GalileanPhenomenonEvent,
want basic.JupiterGalileanPhenomenonEvent,
loc *time.Location,
) {
t.Helper()
if got.Valid != want.Valid {
t.Fatalf("%s valid mismatch: got %v want %v", name, got.Valid, want.Valid)
}
if !got.Valid {
return
}
if got.Start.Location() != loc || got.Greatest.Location() != loc || got.End.Location() != loc {
t.Fatalf("%s timezone mismatch", name)
}
wantStart := basic.JDE2DateByZone(want.Start, loc, false)
wantGreatest := basic.JDE2DateByZone(want.Greatest, loc, false)
wantEnd := basic.JDE2DateByZone(want.End, loc, false)
if !got.Start.Equal(wantStart) || !got.Greatest.Equal(wantGreatest) || !got.End.Equal(wantEnd) {
t.Fatalf(
"%s time mismatch: got [%s %s %s] want [%s %s %s]",
name,
got.Start.Format(time.RFC3339Nano),
got.Greatest.Format(time.RFC3339Nano),
got.End.Format(time.RFC3339Nano),
wantStart.Format(time.RFC3339Nano),
wantGreatest.Format(time.RFC3339Nano),
wantEnd.Format(time.RFC3339Nano),
)
}
if got.Duration != got.End.Sub(got.Start) {
t.Fatalf("%s duration mismatch: got %s want %s", name, got.Duration, got.End.Sub(got.Start))
}
if got.Satellite != want.Satellite || string(got.Type) != string(want.Type) {
t.Fatalf("%s id/type mismatch", name)
}
assertSameGalileanPhenomenon(t, name+" greatest", got.GreatestState, want.GreatestPhenomenon)
}
func assertSameGalileanPhenomenon(t *testing.T, name string, got GalileanSatellitePhenomenon, want basic.JupiterGalileanPhenomenon) {
t.Helper()
if got.Transit != want.Transit || got.Occultation != want.Occultation || got.Eclipse != want.Eclipse || got.ShadowTransit != want.ShadowTransit {
t.Fatalf("%s flag mismatch", name)
}
gotFloats := []float64{got.ShadowOffsetXArcsec, got.ShadowOffsetYArcsec, got.ShadowOffsetXJupiterR, got.ShadowOffsetYJupiterR}
wantFloats := []float64{want.ShadowOffsetXArcsec, want.ShadowOffsetYArcsec, want.ShadowOffsetXJupiterRadii, want.ShadowOffsetYJupiterRadii}
for i := range gotFloats {
if math.Float64bits(gotFloats[i]) != math.Float64bits(wantFloats[i]) {
t.Fatalf("%s shadow field %d mismatch: got %.18f want %.18f", name, i, gotFloats[i], wantFloats[i])
}
}
}
func loadGalileanPublicEventBaseline(t *testing.T) []galileanPublicEventBaselineRecord {
t.Helper()
data, err := os.ReadFile("testdata/galilean_events_imcce_2026.json")
if err != nil {
t.Fatal(err)
}
var records []galileanPublicEventBaselineRecord
if err := json.Unmarshal(data, &records); err != nil {
t.Fatal(err)
}
if len(records) == 0 {
t.Fatal("empty galilean public event baseline")
}
return records
}
func mustParseGalileanEventTime(t *testing.T, value string) time.Time {
t.Helper()
date, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
t.Fatalf("parse %q: %v", value, err)
}
return date
}
+105
View File
@@ -0,0 +1,105 @@
package jupiter
import (
"encoding/json"
"math"
"os"
"testing"
"time"
)
const galileanShadowToleranceArcsec = 0.2
type galileanPhenomenaSample struct {
UTC string `json:"utc"`
Phenomena map[string]galileanPhenomenonExpectation `json:"phenomena"`
}
type galileanPhenomenonExpectation struct {
Transit bool `json:"transit"`
Occultation bool `json:"occultation"`
Eclipse bool `json:"eclipse"`
ShadowTransit bool `json:"shadow_transit"`
ShadowXArcsec *float64 `json:"shadow_x_arcsec,omitempty"`
ShadowYArcsec *float64 `json:"shadow_y_arcsec,omitempty"`
}
func TestGalileanPhenomenaAgainstHorizonsBaseline(t *testing.T) {
samples := loadGalileanPhenomenaBaseline(t)
maxShadowX := 0.0
maxShadowY := 0.0
for _, sample := range samples {
date, err := time.Parse(time.RFC3339, sample.UTC)
if err != nil {
t.Fatalf("parse %s: %v", sample.UTC, err)
}
got := SatellitePhenomena(date)
for name, want := range sample.Phenomena {
phenomenon := selectGalileanPhenomenon(got, name)
if phenomenon.Transit != want.Transit {
t.Fatalf("%s transit mismatch at %s: got %v want %v", name, sample.UTC, phenomenon.Transit, want.Transit)
}
if phenomenon.Occultation != want.Occultation {
t.Fatalf("%s occultation mismatch at %s: got %v want %v", name, sample.UTC, phenomenon.Occultation, want.Occultation)
}
if phenomenon.Eclipse != want.Eclipse {
t.Fatalf("%s eclipse mismatch at %s: got %v want %v", name, sample.UTC, phenomenon.Eclipse, want.Eclipse)
}
if phenomenon.ShadowTransit != want.ShadowTransit {
t.Fatalf("%s shadow-transit mismatch at %s: got %v want %v", name, sample.UTC, phenomenon.ShadowTransit, want.ShadowTransit)
}
if !want.ShadowTransit {
continue
}
if want.ShadowXArcsec == nil || want.ShadowYArcsec == nil {
t.Fatalf("%s shadow baseline incomplete at %s", name, sample.UTC)
}
xDiff := math.Abs(phenomenon.ShadowOffsetXArcsec - *want.ShadowXArcsec)
yDiff := math.Abs(phenomenon.ShadowOffsetYArcsec - *want.ShadowYArcsec)
if xDiff > maxShadowX {
maxShadowX = xDiff
}
if yDiff > maxShadowY {
maxShadowY = yDiff
}
if xDiff > galileanShadowToleranceArcsec {
t.Fatalf("%s shadow X mismatch at %s: got %.6f want %.6f", name, sample.UTC, phenomenon.ShadowOffsetXArcsec, *want.ShadowXArcsec)
}
if yDiff > galileanShadowToleranceArcsec {
t.Fatalf("%s shadow Y mismatch at %s: got %.6f want %.6f", name, sample.UTC, phenomenon.ShadowOffsetYArcsec, *want.ShadowYArcsec)
}
}
}
t.Logf("galilean phenomena shadow max diff: X=%.3f arcsec Y=%.3f arcsec", maxShadowX, maxShadowY)
}
func loadGalileanPhenomenaBaseline(t *testing.T) []galileanPhenomenaSample {
t.Helper()
data, err := os.ReadFile("testdata/galilean_phenomena_horizons.json")
if err != nil {
t.Fatal(err)
}
var samples []galileanPhenomenaSample
if err := json.Unmarshal(data, &samples); err != nil {
t.Fatal(err)
}
if len(samples) == 0 {
t.Fatal("empty phenomena baseline")
}
return samples
}
func selectGalileanPhenomenon(info GalileanPhenomenaInfo, name string) GalileanSatellitePhenomenon {
switch name {
case "io":
return info.Io
case "europa":
return info.Europa
case "ganymede":
return info.Ganymede
case "callisto":
return info.Callisto
default:
panic("unknown satellite: " + name)
}
}
+83
View File
@@ -0,0 +1,83 @@
package jupiter
import (
"time"
"b612.me/astro/basic"
)
// PhysicalInfo 木星物理观测参数 / physical observing parameters of Jupiter.
type PhysicalInfo struct {
// SubEarthLongitude 子地经度,单位度;采用 Jupiter 当前 IAU/Horizons 西经为正约定。
SubEarthLongitude float64
// SubEarthLatitude 子地纬度,单位度。
SubEarthLatitude float64
// SubSolarLongitude 子日经度,单位度;采用 Jupiter 当前 IAU/Horizons 西经为正约定。
SubSolarLongitude float64
// SubSolarLatitude 子日纬度,单位度。
SubSolarLatitude float64
// NorthPolePositionAngle 木星北极位置角,单位度。
NorthPolePositionAngle float64
// DS 太阳相对木星赤道的行星中心赤纬,单位度。
DS float64
// DE 地球相对木星赤道的行星中心赤纬,单位度。
DE float64
// CentralMeridianSystemI 木星 System I 照亮盘中央经线,单位度,西经为正。
CentralMeridianSystemI float64
// CentralMeridianSystemII 木星 System II 照亮盘中央经线,单位度,西经为正。
CentralMeridianSystemII float64
// CentralMeridianSystemIII 木星 System III 盘面中央经线,单位度,西经为正。
CentralMeridianSystemIII float64
}
// CentralMeridianInfo 木星 System I/II/III 中央经线 / Jupiter System I/II/III central meridians.
type CentralMeridianInfo struct {
// SystemI 木星 System I 照亮盘中央经线,单位度,西经为正。
SystemI float64
// SystemII 木星 System II 照亮盘中央经线,单位度,西经为正。
SystemII float64
// SystemIII 木星 System III 盘面中央经线,单位度,西经为正。
SystemIII float64
}
// Physical 木星物理观测参数 / physical observing parameters of Jupiter.
func Physical(date time.Time) PhysicalInfo {
return PhysicalN(date, -1)
}
// PhysicalN 木星物理观测参数(截断版) / truncated physical observing parameters of Jupiter.
func PhysicalN(date time.Time, n int) PhysicalInfo {
jde := basic.Date2JDE(date.UTC())
jd := basic.TD2UT(jde, true)
info := basic.JupiterPhysicalN(jd, n)
meridians := basic.JupiterCentralMeridiansN(jd, n)
ds, de := basic.JupiterDSDEN(jd, n)
return PhysicalInfo{
SubEarthLongitude: info.SubEarthLongitude,
SubEarthLatitude: info.SubEarthLatitude,
SubSolarLongitude: info.SubSolarLongitude,
SubSolarLatitude: info.SubSolarLatitude,
NorthPolePositionAngle: info.NorthPolePositionAngle,
DS: ds,
DE: de,
CentralMeridianSystemI: meridians.SystemI,
CentralMeridianSystemII: meridians.SystemII,
CentralMeridianSystemIII: meridians.SystemIII,
}
}
// CentralMeridians 木星 System I/II/III 中央经线 / Jupiter System I/II/III central meridians.
func CentralMeridians(date time.Time) CentralMeridianInfo {
return CentralMeridiansN(date, -1)
}
// CentralMeridiansN 木星 System I/II/III 中央经线(截断版) / truncated Jupiter System I/II/III central meridians.
func CentralMeridiansN(date time.Time, n int) CentralMeridianInfo {
jde := basic.Date2JDE(date.UTC())
info := basic.JupiterCentralMeridiansN(basic.TD2UT(jde, true), n)
return CentralMeridianInfo{
SystemI: info.SystemI,
SystemII: info.SystemII,
SystemIII: info.SystemIII,
}
}
+57
View File
@@ -0,0 +1,57 @@
package jupiter
import (
"math"
"testing"
"time"
"b612.me/astro/basic"
)
func TestPhysicalWrapperMatchesBasic(t *testing.T) {
date := time.Date(2026, 4, 28, 9, 30, 45, 0, time.UTC)
jde := basic.Date2JDE(date.UTC())
got := Physical(date)
gotN := PhysicalN(date, -1)
want := basic.JupiterPhysicalN(basic.TD2UT(jde, true), -1)
wantMeridians := basic.JupiterCentralMeridiansN(basic.TD2UT(jde, true), -1)
gotMeridians := CentralMeridians(date)
gotMeridiansN := CentralMeridiansN(date, -1)
assertSamePhysicalFloat(t, "SubEarthLongitude", got.SubEarthLongitude, want.SubEarthLongitude)
assertSamePhysicalFloat(t, "SubEarthLatitude", got.SubEarthLatitude, want.SubEarthLatitude)
assertSamePhysicalFloat(t, "SubSolarLongitude", got.SubSolarLongitude, want.SubSolarLongitude)
assertSamePhysicalFloat(t, "SubSolarLatitude", got.SubSolarLatitude, want.SubSolarLatitude)
assertSamePhysicalFloat(t, "NorthPolePositionAngle", got.NorthPolePositionAngle, want.NorthPolePositionAngle)
wantDS, wantDE := basic.JupiterDSDEN(basic.TD2UT(jde, true), -1)
assertSamePhysicalFloat(t, "DS", got.DS, wantDS)
assertSamePhysicalFloat(t, "DE", got.DE, wantDE)
assertSamePhysicalFloat(t, "CentralMeridianSystemI", got.CentralMeridianSystemI, wantMeridians.SystemI)
assertSamePhysicalFloat(t, "CentralMeridianSystemII", got.CentralMeridianSystemII, wantMeridians.SystemII)
assertSamePhysicalFloat(t, "CentralMeridianSystemIII", got.CentralMeridianSystemIII, wantMeridians.SystemIII)
assertSamePhysicalFloat(t, "PhysicalN.SubEarthLongitude", got.SubEarthLongitude, gotN.SubEarthLongitude)
assertSamePhysicalFloat(t, "PhysicalN.SubEarthLatitude", got.SubEarthLatitude, gotN.SubEarthLatitude)
assertSamePhysicalFloat(t, "PhysicalN.SubSolarLongitude", got.SubSolarLongitude, gotN.SubSolarLongitude)
assertSamePhysicalFloat(t, "PhysicalN.SubSolarLatitude", got.SubSolarLatitude, gotN.SubSolarLatitude)
assertSamePhysicalFloat(t, "PhysicalN.NorthPolePositionAngle", got.NorthPolePositionAngle, gotN.NorthPolePositionAngle)
assertSamePhysicalFloat(t, "PhysicalN.DS", got.DS, gotN.DS)
assertSamePhysicalFloat(t, "PhysicalN.DE", got.DE, gotN.DE)
assertSamePhysicalFloat(t, "PhysicalN.CentralMeridianSystemI", got.CentralMeridianSystemI, gotN.CentralMeridianSystemI)
assertSamePhysicalFloat(t, "PhysicalN.CentralMeridianSystemII", got.CentralMeridianSystemII, gotN.CentralMeridianSystemII)
assertSamePhysicalFloat(t, "PhysicalN.CentralMeridianSystemIII", got.CentralMeridianSystemIII, gotN.CentralMeridianSystemIII)
assertSamePhysicalFloat(t, "CentralMeridians.SystemI", gotMeridians.SystemI, wantMeridians.SystemI)
assertSamePhysicalFloat(t, "CentralMeridians.SystemII", gotMeridians.SystemII, wantMeridians.SystemII)
assertSamePhysicalFloat(t, "CentralMeridians.SystemIII", gotMeridians.SystemIII, wantMeridians.SystemIII)
assertSamePhysicalFloat(t, "CentralMeridiansN.SystemI", gotMeridians.SystemI, gotMeridiansN.SystemI)
assertSamePhysicalFloat(t, "CentralMeridiansN.SystemII", gotMeridians.SystemII, gotMeridiansN.SystemII)
assertSamePhysicalFloat(t, "CentralMeridiansN.SystemIII", gotMeridians.SystemIII, gotMeridiansN.SystemIII)
}
func assertSamePhysicalFloat(t *testing.T, name string, got, want float64) {
t.Helper()
if math.Float64bits(got) != math.Float64bits(want) {
t.Fatalf("%s mismatch: got %.18f want %.18f", name, got, want)
}
}
+21
View File
@@ -0,0 +1,21 @@
package jupiter
import (
"math"
"testing"
"time"
)
func TestPhysicalPreservesInstantAcrossTimezones(t *testing.T) {
utc := time.Date(2026, 4, 28, 9, 30, 45, 123000000, time.UTC)
shanghai := utc.In(time.FixedZone("UTC+8", 8*3600))
got := Physical(shanghai)
want := Physical(utc)
valuesGot := []float64{got.SubEarthLongitude, got.SubEarthLatitude, got.SubSolarLongitude, got.SubSolarLatitude, got.NorthPolePositionAngle, got.DS, got.DE, got.CentralMeridianSystemI, got.CentralMeridianSystemII, got.CentralMeridianSystemIII}
valuesWant := []float64{want.SubEarthLongitude, want.SubEarthLatitude, want.SubSolarLongitude, want.SubSolarLatitude, want.NorthPolePositionAngle, want.DS, want.DE, want.CentralMeridianSystemI, want.CentralMeridianSystemII, want.CentralMeridianSystemIII}
for i := range valuesGot {
if math.Float64bits(valuesGot[i]) != math.Float64bits(valuesWant[i]) {
t.Fatalf("timezone instant mismatch at index %d: got %.18f want %.18f", i, valuesGot[i], valuesWant[i])
}
}
}
+78
View File
@@ -0,0 +1,78 @@
package jupiter
import (
"time"
"b612.me/astro/basic"
)
// GalileanSatellitePosition 木星伽利略卫星视位置 / apparent position of a Galilean satellite.
//
// Jovicentric* 为木心 J2000 平赤道直角坐标与速度,单位 AU / AU-day。
// ApparentRA/Dec 为该卫星的地心天体测量赤经赤纬,单位度。
// OffsetX/OffsetY 以木星中心为原点,分别沿天球东、北方向为正。
// Jovicentric* are Jovicentric J2000 mean-equatorial coordinates and velocities in AU / AU-day.
// ApparentRA/Dec are geocentric astrometric right ascension and declination in degrees.
// OffsetX/OffsetY are measured from Jupiter's center, positive to celestial east and north.
type GalileanSatellitePosition struct {
JovicentricX float64
JovicentricY float64
JovicentricZ float64
JovicentricVX float64
JovicentricVY float64
JovicentricVZ float64
ApparentRA float64
ApparentDec float64
EarthDistance float64
OffsetXArcsec float64
OffsetYArcsec float64
OffsetXJupiterR float64
OffsetYJupiterR float64
OffsetZJupiterR float64
InFrontOfJupiter bool
}
// GalileanSatellitesInfo 四颗伽利略卫星视位置 / apparent positions of the four Galilean satellites.
type GalileanSatellitesInfo struct {
Io GalileanSatellitePosition
Europa GalileanSatellitePosition
Ganymede GalileanSatellitePosition
Callisto GalileanSatellitePosition
}
// Satellites 木星四颗伽利略卫星视位置 / apparent positions of Jupiter's four Galilean satellites.
//
// date 表示观测绝对时刻;内部使用该时刻对应的 TT/TDB 历元做 L1 星历求值。
// date is the observing instant; internally the corresponding TT/TDB epoch is used for the L1 ephemeris evaluation.
func Satellites(date time.Time) GalileanSatellitesInfo {
jde := basic.Date2JDE(date.UTC())
observations := basic.JupiterGalileanSatelliteObservations(jde)
return GalileanSatellitesInfo{
Io: galileanSatellitePositionFromBasic(observations[0]),
Europa: galileanSatellitePositionFromBasic(observations[1]),
Ganymede: galileanSatellitePositionFromBasic(observations[2]),
Callisto: galileanSatellitePositionFromBasic(observations[3]),
}
}
func galileanSatellitePositionFromBasic(observation basic.JupiterGalileanObservation) GalileanSatellitePosition {
return GalileanSatellitePosition{
JovicentricX: observation.State.X,
JovicentricY: observation.State.Y,
JovicentricZ: observation.State.Z,
JovicentricVX: observation.State.VX,
JovicentricVY: observation.State.VY,
JovicentricVZ: observation.State.VZ,
ApparentRA: observation.RA,
ApparentDec: observation.Dec,
EarthDistance: observation.Distance,
OffsetXArcsec: observation.OffsetXArcsec,
OffsetYArcsec: observation.OffsetYArcsec,
OffsetXJupiterR: observation.OffsetXJupiterRadii,
OffsetYJupiterR: observation.OffsetYJupiterRadii,
OffsetZJupiterR: observation.OffsetZJupiterRadii,
InFrontOfJupiter: observation.InFrontOfJupiter,
}
}
+97
View File
@@ -0,0 +1,97 @@
package jupiter
import (
"encoding/json"
"math"
"os"
"testing"
"time"
)
const galileanHorizonsToleranceArcsec = 1.0
type galileanHorizonsEquatorialRecord struct {
RA float64 `json:"ra_deg"`
Dec float64 `json:"dec_deg"`
Delta float64 `json:"delta_au"`
}
type galileanHorizonsOffsetRecord struct {
RA float64 `json:"ra_deg"`
Dec float64 `json:"dec_deg"`
Delta float64 `json:"delta_au"`
OffsetXArcsec float64 `json:"offset_x_arcsec"`
OffsetYArcsec float64 `json:"offset_y_arcsec"`
}
type galileanHorizonsSample struct {
UTC string `json:"utc"`
Jupiter galileanHorizonsEquatorialRecord `json:"jupiter"`
Satellites map[string]galileanHorizonsOffsetRecord `json:"satellites"`
}
func TestGalileanSatellitesAgainstHorizonsRelativeOffsets(t *testing.T) {
samples := loadGalileanHorizonsBaseline(t)
maxXDiff := 0.0
maxYDiff := 0.0
for _, sample := range samples {
date, err := time.Parse(time.RFC3339, sample.UTC)
if err != nil {
t.Fatalf("parse %s: %v", sample.UTC, err)
}
got := Satellites(date)
for name, want := range sample.Satellites {
position := selectGalileanSatellite(got, name)
xDiff := math.Abs(position.OffsetXArcsec - want.OffsetXArcsec)
yDiff := math.Abs(position.OffsetYArcsec - want.OffsetYArcsec)
if xDiff > maxXDiff {
maxXDiff = xDiff
}
if yDiff > maxYDiff {
maxYDiff = yDiff
}
if xDiff > galileanHorizonsToleranceArcsec {
t.Fatalf("%s X mismatch at %s: got %.6f want %.6f", name, sample.UTC, position.OffsetXArcsec, want.OffsetXArcsec)
}
if yDiff > galileanHorizonsToleranceArcsec {
t.Fatalf("%s Y mismatch at %s: got %.6f want %.6f", name, sample.UTC, position.OffsetYArcsec, want.OffsetYArcsec)
}
wantFront := want.Delta < sample.Jupiter.Delta
if position.InFrontOfJupiter != wantFront {
t.Fatalf("%s front/back mismatch at %s: got %v want %v", name, sample.UTC, position.InFrontOfJupiter, wantFront)
}
}
}
t.Logf("galilean Horizons max diff: X=%.3f arcsec Y=%.3f arcsec", maxXDiff, maxYDiff)
}
func loadGalileanHorizonsBaseline(t *testing.T) []galileanHorizonsSample {
t.Helper()
data, err := os.ReadFile("testdata/galilean_satellites_horizons.json")
if err != nil {
t.Fatal(err)
}
var samples []galileanHorizonsSample
if err := json.Unmarshal(data, &samples); err != nil {
t.Fatal(err)
}
if len(samples) == 0 {
t.Fatal("empty Galilean baseline")
}
return samples
}
func selectGalileanSatellite(info GalileanSatellitesInfo, name string) GalileanSatellitePosition {
switch name {
case "io":
return info.Io
case "europa":
return info.Europa
case "ganymede":
return info.Ganymede
case "callisto":
return info.Callisto
default:
panic("unknown satellite: " + name)
}
}
+90
View File
@@ -0,0 +1,90 @@
[
{
"label": "io_transit_20260402",
"satellite": 1,
"type": "transit",
"start_utc": "2026-04-02T18:00:36.716Z",
"start_duration_minutes": 3.62,
"end_utc": "2026-04-02T20:16:11.316Z",
"end_duration_minutes": 3.62,
"source": "IMCCE phenomenes des satellites de Jupiter 2026 (English PDF, TT times)",
"source_lines": "PDF lines 660,662"
},
{
"label": "io_shadow_20260402",
"satellite": 1,
"type": "shadow_transit",
"start_utc": "2026-04-02T19:17:10.316Z",
"start_duration_minutes": 3.61,
"end_utc": "2026-04-02T21:33:23.716Z",
"end_duration_minutes": 3.61,
"source": "IMCCE phenomenes des satellites de Jupiter 2026 (English PDF, TT times)",
"source_lines": "PDF lines 661,664"
},
{
"label": "ganymede_occultation_20260406",
"satellite": 3,
"type": "occultation",
"start_utc": "2026-04-06T10:35:18.616Z",
"start_duration_minutes": 8.93,
"end_utc": "2026-04-06T13:54:58.216Z",
"end_duration_minutes": 8.93,
"source": "IMCCE phenomenes des satellites de Jupiter 2026 (English PDF, TT times)",
"source_lines": "PDF lines 690,691"
},
{
"label": "ganymede_eclipse_20260406",
"satellite": 3,
"type": "eclipse",
"start_utc": "2026-04-06T15:47:24.216Z",
"start_duration_minutes": 8.61,
"end_utc": "2026-04-06T19:14:32.116Z",
"end_duration_minutes": 8.61,
"source": "IMCCE phenomenes des satellites de Jupiter 2026 (English PDF, TT times)",
"source_lines": "PDF lines 692,693"
},
{
"label": "europa_transit_20260406",
"satellite": 2,
"type": "transit",
"start_utc": "2026-04-06T22:32:36.216Z",
"start_duration_minutes": 3.85,
"end_utc": "2026-04-07T01:21:05.516Z",
"end_duration_minutes": 3.85,
"source": "IMCCE phenomenes des satellites de Jupiter 2026 (English PDF, TT times)",
"source_lines": "PDF lines 694,696"
},
{
"label": "europa_shadow_20260407",
"satellite": 2,
"type": "shadow_transit",
"start_utc": "2026-04-07T01:04:31.716Z",
"start_duration_minutes": 3.81,
"end_utc": "2026-04-07T03:54:13.416Z",
"end_duration_minutes": 3.81,
"source": "IMCCE phenomenes des satellites de Jupiter 2026 (English PDF, TT times)",
"source_lines": "PDF lines 695,697"
},
{
"label": "callisto_transit_20260403",
"satellite": 4,
"type": "transit",
"start_utc": "2026-04-03T13:07:09.016Z",
"start_duration_minutes": 11.99,
"end_utc": "2026-04-03T17:04:35.916Z",
"end_duration_minutes": 11.92,
"source": "IMCCE phenomenes des satellites de Jupiter 2026 (English PDF, TT times)",
"source_lines": "PDF lines 671,674"
},
{
"label": "callisto_shadow_20260404",
"satellite": 4,
"type": "shadow_transit",
"start_utc": "2026-04-04T01:08:40.516Z",
"start_duration_minutes": 10.94,
"end_utc": "2026-04-04T05:26:50.116Z",
"end_duration_minutes": 10.98,
"source": "IMCCE phenomenes des satellites de Jupiter 2026 (English PDF, TT times)",
"source_lines": "PDF lines 676,677"
}
]
+327
View File
@@ -0,0 +1,327 @@
[
{
"utc": "1998-01-08T12:00:00Z",
"phenomena": {
"callisto": {
"transit": false,
"occultation": true,
"eclipse": false,
"shadow_transit": false
},
"europa": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"ganymede": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"io": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
}
}
},
{
"utc": "1999-01-01T02:00:00Z",
"phenomena": {
"callisto": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"europa": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"ganymede": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"io": {
"transit": true,
"occultation": false,
"eclipse": false,
"shadow_transit": true,
"shadow_x_arcsec": 17.228193145273753,
"shadow_y_arcsec": 3.256241312014926
}
}
},
{
"utc": "2000-01-01T06:00:00Z",
"phenomena": {
"callisto": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"europa": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"ganymede": {
"transit": true,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"io": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
}
}
},
{
"utc": "2000-01-01T11:00:00Z",
"phenomena": {
"callisto": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"europa": {
"transit": true,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"ganymede": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": true,
"shadow_x_arcsec": 3.158460096080454,
"shadow_y_arcsec": -16.417707231374926
},
"io": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
}
}
},
{
"utc": "2000-01-01T14:00:00Z",
"phenomena": {
"callisto": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"europa": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": true,
"shadow_x_arcsec": -5.755686876706326,
"shadow_y_arcsec": -12.656123592995895
},
"ganymede": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"io": {
"transit": false,
"occultation": true,
"eclipse": true,
"shadow_transit": false
}
}
},
{
"utc": "2000-01-01T15:00:00Z",
"phenomena": {
"callisto": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"europa": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"ganymede": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"io": {
"transit": false,
"occultation": false,
"eclipse": true,
"shadow_transit": false
}
}
},
{
"utc": "2000-01-02T00:00:00Z",
"phenomena": {
"callisto": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"europa": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"ganymede": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"io": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
}
}
},
{
"utc": "2000-01-03T05:00:00Z",
"phenomena": {
"callisto": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"europa": {
"transit": false,
"occultation": true,
"eclipse": false,
"shadow_transit": false
},
"ganymede": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"io": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
}
}
},
{
"utc": "2000-01-03T08:00:00Z",
"phenomena": {
"callisto": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"europa": {
"transit": false,
"occultation": false,
"eclipse": true,
"shadow_transit": false
},
"ganymede": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"io": {
"transit": false,
"occultation": true,
"eclipse": false,
"shadow_transit": false
}
}
},
{
"utc": "2000-01-04T20:00:00Z",
"phenomena": {
"callisto": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"europa": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"ganymede": {
"transit": false,
"occultation": true,
"eclipse": false,
"shadow_transit": false
},
"io": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
}
}
},
{
"utc": "2000-01-05T01:00:00Z",
"phenomena": {
"callisto": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"europa": {
"transit": true,
"occultation": false,
"eclipse": false,
"shadow_transit": false
},
"ganymede": {
"transit": false,
"occultation": false,
"eclipse": true,
"shadow_transit": false
},
"io": {
"transit": false,
"occultation": false,
"eclipse": false,
"shadow_transit": false
}
}
}
]
+344
View File
@@ -0,0 +1,344 @@
[
{
"utc": "1973-04-15T00:00:00Z",
"jupiter": {
"ra_deg": 311.88806,
"dec_deg": -18.29401,
"delta_au": 5.28137291912345
},
"satellites": {
"callisto": {
"ra_deg": 311.7589,
"dec_deg": -18.33129,
"delta_au": 5.2768299268895,
"offset_x_arcsec": -441.3803197314987,
"offset_y_arcsec": -134.36446774862958
},
"europa": {
"ra_deg": 311.90297,
"dec_deg": -18.2902,
"delta_au": 5.28564623493191,
"offset_x_arcsec": 50.96424397059699,
"offset_y_arcsec": 13.713918937389815
},
"ganymede": {
"ra_deg": 311.85552,
"dec_deg": -18.30439,
"delta_au": 5.2878698628188,
"offset_x_arcsec": -111.21668080202586,
"offset_y_arcsec": -37.37791869906149
},
"io": {
"ra_deg": 311.85761,
"dec_deg": -18.30307,
"delta_au": 5.2818325588453,
"offset_x_arcsec": -104.07417725728882,
"offset_y_arcsec": -32.62468494888245
}
}
},
{
"utc": "1979-07-09T12:00:00Z",
"jupiter": {
"ra_deg": 135.47291,
"dec_deg": 17.61495,
"delta_au": 6.22692079240897
},
"satellites": {
"callisto": {
"ra_deg": 135.41111,
"dec_deg": 17.634,
"delta_au": 6.21639039566952,
"offset_x_arcsec": -212.02591082772028,
"offset_y_arcsec": 68.61463977683415
},
"europa": {
"ra_deg": 135.45128,
"dec_deg": 17.62131,
"delta_au": 6.22314306640108,
"offset_x_arcsec": -74.21428902094401,
"offset_y_arcsec": 22.900240712738924
},
"ganymede": {
"ra_deg": 135.431,
"dec_deg": 17.62783,
"delta_au": 6.22139657545851,
"offset_x_arcsec": -143.79142173449782,
"offset_y_arcsec": 46.383925792891034
},
"io": {
"ra_deg": 135.48048,
"dec_deg": 17.61266,
"delta_au": 6.22960675673866,
"offset_x_arcsec": 25.974530718161613,
"offset_y_arcsec": -8.243480803327909
}
}
},
{
"utc": "1986-02-28T06:00:00Z",
"jupiter": {
"ra_deg": 334.31927,
"dec_deg": -11.53761,
"delta_au": 6.00043827450847
},
"satellites": {
"callisto": {
"ra_deg": 334.39264,
"dec_deg": -11.50804,
"delta_au": 5.99075804173071,
"offset_x_arcsec": 258.8221117548082,
"offset_y_arcsec": 106.4189385239666
},
"europa": {
"ra_deg": 334.34619,
"dec_deg": -11.5258,
"delta_au": 6.00369206259064,
"offset_x_arcsec": 94.95775500954227,
"offset_y_arcsec": 42.51154273649485
},
"ganymede": {
"ra_deg": 334.35893,
"dec_deg": -11.52063,
"delta_au": 6.00605898085651,
"offset_x_arcsec": 139.89947712091362,
"offset_y_arcsec": 61.11832971139567
},
"io": {
"ra_deg": 334.29956,
"dec_deg": -11.54609,
"delta_au": 5.9987041475836,
"offset_x_arcsec": -69.52013591273698,
"offset_y_arcsec": -30.530393390408392
}
}
},
{
"utc": "1990-01-01T00:00:00Z",
"jupiter": {
"ra_deg": 95.81931,
"dec_deg": 23.21442,
"delta_au": 4.17057854607155
},
"satellites": {
"callisto": {
"ra_deg": 95.92891,
"dec_deg": 23.21348,
"delta_au": 4.18089325952361,
"offset_x_arcsec": 362.6174261571469,
"offset_y_arcsec": -3.247297114635362
},
"europa": {
"ra_deg": 95.75478,
"dec_deg": 23.21883,
"delta_au": 4.17160377436699,
"offset_x_arcsec": -213.4923982076987,
"offset_y_arcsec": 15.923397637886637
},
"ganymede": {
"ra_deg": 95.72549,
"dec_deg": 23.22076,
"delta_au": 4.17400474524911,
"offset_x_arcsec": -310.3915493369612,
"offset_y_arcsec": 22.924196432221798
},
"io": {
"ra_deg": 95.85972,
"dec_deg": 23.21264,
"delta_au": 4.17134483600364,
"offset_x_arcsec": 133.69948476482548,
"offset_y_arcsec": -6.3894167489867995
}
}
},
{
"utc": "1994-07-16T20:00:00Z",
"jupiter": {
"ra_deg": 213.25869,
"dec_deg": -12.18243,
"delta_au": 5.13006301194003
},
"satellites": {
"callisto": {
"ra_deg": 213.33486,
"dec_deg": -12.21633,
"delta_au": 5.14030952670628,
"offset_x_arcsec": 268.002683889942,
"offset_y_arcsec": -122.07769582646469
},
"europa": {
"ra_deg": 213.3048,
"dec_deg": -12.19819,
"delta_au": 5.1287760793537,
"offset_x_arcsec": 162.24824222991055,
"offset_y_arcsec": -56.74979461519546
},
"ganymede": {
"ra_deg": 213.33267,
"dec_deg": -12.21081,
"delta_au": 5.13171837461076,
"offset_x_arcsec": 260.3026257035611,
"offset_y_arcsec": -102.20354428249364
},
"io": {
"ra_deg": 213.25028,
"dec_deg": -12.18102,
"delta_au": 5.13278719330082,
"offset_x_arcsec": -29.594361442763407,
"offset_y_arcsec": 5.075541713701257
}
}
},
{
"utc": "2000-01-01T00:00:00Z",
"jupiter": {
"ra_deg": 23.85196,
"dec_deg": 8.58622,
"delta_au": 4.61342273127327
},
"satellites": {
"callisto": {
"ra_deg": 23.94979,
"dec_deg": 8.63255,
"delta_au": 4.62253449896296,
"offset_x_arcsec": 348.19826724085266,
"offset_y_arcsec": 166.83261886559407
},
"europa": {
"ra_deg": 23.89053,
"dec_deg": 8.60003,
"delta_au": 4.61036909784164,
"offset_x_arcsec": 137.29079390672234,
"offset_y_arcsec": 49.72291009234657
},
"ganymede": {
"ra_deg": 23.87051,
"dec_deg": 8.58946,
"delta_au": 4.60642203313293,
"offset_x_arcsec": 66.030987085105,
"offset_y_arcsec": 11.665596446458599
},
"io": {
"ra_deg": 23.82242,
"dec_deg": 8.57313,
"delta_au": 4.61225879634782,
"offset_x_arcsec": -105.1557573112971,
"offset_y_arcsec": -47.119959026939206
}
}
},
{
"utc": "2003-11-01T03:00:00Z",
"jupiter": {
"ra_deg": 164.75393,
"dec_deg": 7.54667,
"delta_au": 5.89519011181137
},
"satellites": {
"callisto": {
"ra_deg": 164.67248,
"dec_deg": 7.58541,
"delta_au": 5.88672225624688,
"offset_x_arcsec": -290.6541511800618,
"offset_y_arcsec": 139.49127107610735
},
"europa": {
"ra_deg": 164.79359,
"dec_deg": 7.5289,
"delta_au": 5.89499084824017,
"offset_x_arcsec": 141.5451201277793,
"offset_y_arcsec": -63.9655812066536
},
"ganymede": {
"ra_deg": 164.69132,
"dec_deg": 7.57511,
"delta_au": 5.89658460351966,
"offset_x_arcsec": -223.42897345174333,
"offset_y_arcsec": 102.40009278019474
},
"io": {
"ra_deg": 164.72967,
"dec_deg": 7.5579,
"delta_au": 5.89452838126592,
"offset_x_arcsec": -86.5772659690193,
"offset_y_arcsec": 40.43041079542706
}
}
},
{
"utc": "2007-05-10T18:30:00Z",
"jupiter": {
"ra_deg": 256.8904,
"dec_deg": -22.15607,
"delta_au": 4.41668013865791
},
"satellites": {
"callisto": {
"ra_deg": 256.92962,
"dec_deg": -22.15161,
"delta_au": 4.40445022555321,
"offset_x_arcsec": 130.77052785171492,
"offset_y_arcsec": 16.03912383080068
},
"europa": {
"ra_deg": 256.94812,
"dec_deg": -22.15993,
"delta_au": 4.41502519173859,
"offset_x_arcsec": 192.4433571554188,
"offset_y_arcsec": -13.932562898451753
},
"ganymede": {
"ra_deg": 256.96968,
"dec_deg": -22.15942,
"delta_au": 4.41230056589285,
"offset_x_arcsec": 264.32717135812686,
"offset_y_arcsec": -12.128977503550622
},
"io": {
"ra_deg": 256.8516,
"dec_deg": -22.15279,
"delta_au": 4.41625480314586,
"offset_x_arcsec": -129.36904468312147,
"offset_y_arcsec": 11.791482646233307
}
}
},
{
"utc": "2010-09-21T00:00:00Z",
"jupiter": {
"ra_deg": 359.06914,
"dec_deg": -2.14436,
"delta_au": 3.95393079286274
},
"satellites": {
"callisto": {
"ra_deg": 358.92727,
"dec_deg": -2.20719,
"delta_au": 3.96045524535587,
"offset_x_arcsec": -510.35338962334276,
"offset_y_arcsec": -226.2123343336146
},
"europa": {
"ra_deg": 359.12197,
"dec_deg": -2.11777,
"delta_au": 3.95588150012068,
"offset_x_arcsec": 190.05811838293806,
"offset_y_arcsec": 95.72076203774765
},
"ganymede": {
"ra_deg": 359.00696,
"dec_deg": -2.17074,
"delta_au": 3.95934036114568,
"offset_x_arcsec": -223.687388112253,
"offset_y_arcsec": -94.97259749250195
},
"io": {
"ra_deg": 359.10579,
"dec_deg": -2.12709,
"delta_au": 3.95359305390024,
"offset_x_arcsec": 131.84909358793013,
"offset_y_arcsec": 62.17043482817281
}
}
}
]
+137
View File
@@ -0,0 +1,137 @@
package jupiter
import (
"time"
"b612.me/astro/basic"
"b612.me/astro/calendar"
"b612.me/astro/planet"
)
// N variants keep the same semantics as the non-N APIs; n < 0 means full series.
// ApparentLoN 视黄经(截断版) / truncated apparent ecliptic longitude.
func ApparentLoN(date time.Time, n int) float64 {
jde := calendar.Date2JDE(date.UTC())
return basic.JupiterApparentLoN(basic.TD2UT(jde, true), n)
}
// ApparentBoN 视黄纬(截断版) / truncated apparent ecliptic latitude.
func ApparentBoN(date time.Time, n int) float64 {
jde := calendar.Date2JDE(date.UTC())
return basic.JupiterApparentBoN(basic.TD2UT(jde, true), n)
}
// ApparentRaN 视赤经(截断版) / truncated apparent right ascension.
func ApparentRaN(date time.Time, n int) float64 {
jde := calendar.Date2JDE(date.UTC())
return basic.JupiterApparentRaN(basic.TD2UT(jde, true), n)
}
// ApparentDecN 视赤纬(截断版) / truncated apparent declination.
func ApparentDecN(date time.Time, n int) float64 {
jde := calendar.Date2JDE(date.UTC())
return basic.JupiterApparentDecN(basic.TD2UT(jde, true), n)
}
// ApparentRaDecN 视赤经赤纬(截断版) / truncated apparent right ascension and declination.
func ApparentRaDecN(date time.Time, n int) (float64, float64) {
jde := calendar.Date2JDE(date.UTC())
return basic.JupiterApparentRaDecN(basic.TD2UT(jde, true), n)
}
// ApparentMagnitudeN 视星等(截断版) / truncated apparent magnitude.
func ApparentMagnitudeN(date time.Time, n int) float64 {
jde := calendar.Date2JDE(date.UTC())
return basic.JupiterMagN(basic.TD2UT(jde, true), n)
}
// EarthDistanceN 地球距离(截断版) / truncated Earth distance.
func EarthDistanceN(date time.Time, n int) float64 {
jde := calendar.Date2JDE(date.UTC())
return basic.EarthJupiterAwayN(basic.TD2UT(jde, true), n)
}
// SunDistanceN 太阳距离(截断版) / truncated Sun distance.
func SunDistanceN(date time.Time, n int) float64 {
jde := calendar.Date2JDE(date.UTC())
return planet.WherePlanetN(4, 2, basic.TD2UT(jde, true), n)
}
// AltitudeN 高度角(截断版) / truncated altitude angle.
func AltitudeN(date time.Time, lon, lat float64, n int) float64 {
jde := basic.Date2JDE(date)
_, loc := date.Zone()
timezone := float64(loc) / 3600.0
return basic.JupiterHeightN(jde, lon, lat, timezone, n)
}
// ZenithN 天顶距(截断版) / truncated zenith distance.
func ZenithN(date time.Time, lon, lat float64, n int) float64 {
return 90 - AltitudeN(date, lon, lat, n)
}
// AzimuthN 方位角(截断版) / truncated azimuth angle.
func AzimuthN(date time.Time, lon, lat float64, n int) float64 {
jde := basic.Date2JDE(date)
_, loc := date.Zone()
timezone := float64(loc) / 3600.0
return basic.JupiterAzimuthN(jde, lon, lat, timezone, n)
}
// HourAngleN 时角(截断版) / truncated hour angle.
func HourAngleN(date time.Time, lon float64, n int) float64 {
jde := basic.Date2JDE(date)
_, loc := date.Zone()
timezone := float64(loc) / 3600.0
return basic.JupiterHourAngleN(jde, lon, timezone, n)
}
// CulminationTimeN 中天时间(截断版) / truncated culmination time.
func CulminationTimeN(date time.Time, lon float64, n int) time.Time {
if date.Hour() > 12 {
date = date.Add(time.Hour * -12)
}
jde := basic.Date2JDE(date)
_, loc := date.Zone()
timezone := float64(loc) / 3600.0
calcJde := basic.JupiterCulminationTimeN(jde, lon, timezone, n) - timezone/24.0
return basic.JDE2DateByZone(calcJde, date.Location(), false)
}
// RiseTimeN 升起时间(截断版) / truncated rise time.
func RiseTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time.Time, error) {
var aeroFloat float64
if aero {
aeroFloat = 1
}
if date.Hour() > 12 {
date = date.Add(time.Hour * -12)
}
jde := basic.Date2JDE(date)
_, loc := date.Zone()
timezone := float64(loc) / 3600.0
riseJde, err := basic.JupiterRiseTimeN(jde, lon, lat, timezone, aeroFloat, height, n)
return riseSetResult(date, riseJde, err)
}
// DownTimeN 落下时间别名(截断版) / truncated down-time alias.
func DownTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time.Time, error) {
return SetTimeN(date, lon, lat, height, aero, n)
}
// SetTimeN 落下时间(截断版) / truncated set time.
func SetTimeN(date time.Time, lon, lat, height float64, aero bool, n int) (time.Time, error) {
var aeroFloat float64
if aero {
aeroFloat = 1
}
if date.Hour() > 12 {
date = date.Add(time.Hour * -12)
}
jde := basic.Date2JDE(date)
_, loc := date.Zone()
timezone := float64(loc) / 3600.0
riseJde, err := basic.JupiterSetTimeN(jde, lon, lat, timezone, aeroFloat, height, n)
return riseSetResult(date, riseJde, err)
}