feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
// Package jupiter 木星位置、升落、合冲、留、方照、相位、视直径、物理星历,以及四颗伽利略卫星的位置、现象与接触事件;角度单位为度。
|
||||
// Package jupiter covers Jupiter position, rise/set, conjunction, opposition, station, quadrature, phase, diameter and physical ephemeris, plus Galilean-satellite positions, phenomena and contact events; angles are degrees.
|
||||
package jupiter
|
||||
@@ -0,0 +1,66 @@
|
||||
package jupiter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func BenchmarkSatellites(b *testing.B) {
|
||||
date := time.Date(2026, 4, 2, 18, 0, 0, 0, time.UTC)
|
||||
var sink GalileanSatellitesInfo
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
sink = Satellites(date)
|
||||
}
|
||||
_ = sink
|
||||
}
|
||||
|
||||
func BenchmarkSatellitePhenomena(b *testing.B) {
|
||||
date := time.Date(2026, 4, 2, 18, 0, 0, 0, time.UTC)
|
||||
var sink GalileanPhenomenaInfo
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
sink = SatellitePhenomena(date)
|
||||
}
|
||||
_ = sink
|
||||
}
|
||||
|
||||
func BenchmarkGalileanPhenomenonEventLast(b *testing.B) {
|
||||
date := time.Date(2026, 4, 2, 20, 0, 0, 0, time.UTC)
|
||||
var sink GalileanPhenomenonEvent
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
sink = LastGalileanPhenomenonEvent(date, GalileanSatelliteIo, GalileanPhenomenonTransit)
|
||||
}
|
||||
_ = sink
|
||||
}
|
||||
|
||||
func BenchmarkGalileanPhenomenonEventNext(b *testing.B) {
|
||||
date := time.Date(2026, 4, 2, 20, 0, 0, 0, time.UTC)
|
||||
var sink GalileanPhenomenonEvent
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
sink = NextGalileanPhenomenonEvent(date, GalileanSatelliteIo, GalileanPhenomenonTransit)
|
||||
}
|
||||
_ = sink
|
||||
}
|
||||
|
||||
func BenchmarkGalileanPhenomenonEventClosest(b *testing.B) {
|
||||
date := time.Date(2026, 4, 2, 20, 0, 0, 0, time.UTC)
|
||||
var sink GalileanPhenomenonEvent
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
sink = ClosestGalileanPhenomenonEvent(date, GalileanSatelliteIo, GalileanPhenomenonTransit)
|
||||
}
|
||||
_ = sink
|
||||
}
|
||||
|
||||
func BenchmarkGalileanPhenomenonContactEventClosest(b *testing.B) {
|
||||
date := time.Date(2026, 4, 2, 20, 0, 0, 0, time.UTC)
|
||||
var sink GalileanPhenomenonContactEvent
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
sink = ClosestGalileanPhenomenonContactEvent(date, GalileanSatelliteIo, GalileanPhenomenonTransit)
|
||||
}
|
||||
_ = sink
|
||||
}
|
||||
@@ -35,10 +35,10 @@ type GalileanPhenomenaInfo struct {
|
||||
|
||||
// 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.
|
||||
// date 表示观测绝对时刻(UTC),内部换算为该时刻对应的 TT/TDB 历元求值。
|
||||
// date is the observing instant in UTC; it is converted to the corresponding TT/TDB epoch for evaluation.
|
||||
func SatellitePhenomena(date time.Time) GalileanPhenomenaInfo {
|
||||
jde := basic.Date2JDE(date.UTC())
|
||||
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
|
||||
phenomena := basic.JupiterGalileanSatellitePhenomena(jde)
|
||||
return GalileanPhenomenaInfo{
|
||||
Io: galileanPhenomenonFromBasic(phenomena[0]),
|
||||
|
||||
@@ -94,3 +94,69 @@ func assertGalileanContactMatchesBasic(
|
||||
t.Fatalf("%s phase mismatch: got %q want %q", name, got.Phase, want.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
// IMCCE 年表每个事件给 4 个时刻:D1、D2 = D1+时长、F1、F2 = F1+时长。
|
||||
func TestGalileanPhenomenonContactEventMatchesIMCCEBaseline(t *testing.T) {
|
||||
records := loadGalileanPublicEventBaseline(t)
|
||||
const (
|
||||
contactTolerance = 120 * time.Second
|
||||
durationTolerance = 25 * time.Second
|
||||
)
|
||||
var maxContact, maxDuration time.Duration
|
||||
for _, record := range records {
|
||||
startUTC := mustParseGalileanEventTime(t, record.StartUTC)
|
||||
endUTC := mustParseGalileanEventTime(t, record.EndUTC)
|
||||
startDuration := time.Duration(record.StartDurationMinutes * float64(time.Minute))
|
||||
endDuration := time.Duration(record.EndDurationMinutes * float64(time.Minute))
|
||||
endOfEvent := endUTC.Add(endDuration)
|
||||
midpoint := startUTC.Add(endOfEvent.Sub(startUTC) / 2)
|
||||
|
||||
got := ClosestGalileanPhenomenonContactEvent(midpoint, record.Satellite, GalileanPhenomenonType(record.Type))
|
||||
if !got.Valid {
|
||||
t.Fatalf("%s returned no contact event", record.Label)
|
||||
}
|
||||
contacts := []struct {
|
||||
name string
|
||||
got time.Time
|
||||
want time.Time
|
||||
}{
|
||||
{"D1", got.Disappearance.Start, startUTC},
|
||||
{"D2", got.Disappearance.End, startUTC.Add(startDuration)},
|
||||
{"F1", got.Reappearance.Start, endUTC},
|
||||
{"F2", got.Reappearance.End, endOfEvent},
|
||||
}
|
||||
for _, contact := range contacts {
|
||||
delta := contact.got.Sub(contact.want)
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
if delta > maxContact {
|
||||
maxContact = delta
|
||||
}
|
||||
if delta > contactTolerance {
|
||||
t.Fatalf("%s %s offset %v exceeds %v", record.Label, contact.name, contact.got.Sub(contact.want), contactTolerance)
|
||||
}
|
||||
}
|
||||
durations := []struct {
|
||||
name string
|
||||
got time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{"D", got.Disappearance.Duration, startDuration},
|
||||
{"F", got.Reappearance.Duration, endDuration},
|
||||
}
|
||||
for _, duration := range durations {
|
||||
delta := duration.got - duration.want
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
if delta > maxDuration {
|
||||
maxDuration = delta
|
||||
}
|
||||
if delta > durationTolerance {
|
||||
t.Fatalf("%s %s-phase duration offset %v exceeds %v", record.Label, duration.name, duration.got-duration.want, durationTolerance)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("contact offset max %v, duration offset max %v", maxContact, maxDuration)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,18 @@ package jupiter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/basic"
|
||||
)
|
||||
|
||||
const galileanShadowToleranceArcsec = 0.2
|
||||
// 基线 shadow 值按 TDB/TT 逆行历元(UTC 观测时刻换算 ΔT 后再减光行时)标定;
|
||||
// 按 UTC 瞬时求值会让整条曲线平移 ΔT(Io 约 0.46″)并被本容差拒绝。
|
||||
const galileanShadowToleranceArcsec = 0.15
|
||||
|
||||
type galileanPhenomenaSample struct {
|
||||
UTC string `json:"utc"`
|
||||
@@ -103,3 +108,19 @@ func selectGalileanPhenomenon(info GalileanPhenomenaInfo, name string) GalileanS
|
||||
panic("unknown satellite: " + name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGalileanPhenomenaUseTTFrame(t *testing.T) {
|
||||
dates := []time.Time{
|
||||
time.Date(1999, 1, 1, 2, 0, 0, 0, time.UTC),
|
||||
time.Date(2000, 1, 1, 14, 0, 0, 0, time.UTC),
|
||||
time.Date(2026, 4, 2, 20, 0, 0, 0, time.UTC),
|
||||
}
|
||||
for _, date := range dates {
|
||||
want := basic.JupiterGalileanSatellitePhenomena(basic.TD2UT(basic.Date2JDE(date.UTC()), true))
|
||||
got := SatellitePhenomena(date)
|
||||
phenomena := []GalileanSatellitePhenomenon{got.Io, got.Europa, got.Ganymede, got.Callisto}
|
||||
for i, phenomenon := range phenomena {
|
||||
assertSameGalileanPhenomenon(t, fmt.Sprintf("date=%s satellite=%d TT frame", date, i+1), phenomenon, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +43,10 @@ type GalileanSatellitesInfo struct {
|
||||
|
||||
// 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.
|
||||
// date 表示观测绝对时刻(UTC),内部换算为该时刻对应的 TT/TDB 历元做 L1 星历求值。
|
||||
// date is the observing instant in UTC; it is converted to the corresponding TT/TDB epoch for the L1 ephemeris evaluation.
|
||||
func Satellites(date time.Time) GalileanSatellitesInfo {
|
||||
jde := basic.Date2JDE(date.UTC())
|
||||
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
|
||||
observations := basic.JupiterGalileanSatelliteObservations(jde)
|
||||
return GalileanSatellitesInfo{
|
||||
Io: galileanSatellitePositionFromBasic(observations[0]),
|
||||
|
||||
@@ -6,9 +6,12 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/basic"
|
||||
)
|
||||
|
||||
const galileanHorizonsToleranceArcsec = 1.0
|
||||
// 基线是 UTC 瞬时地心赤道量的派生偏移,容差取实测最大残差(0.054″)的约三倍。
|
||||
const galileanHorizonsToleranceArcsec = 0.15
|
||||
|
||||
type galileanHorizonsEquatorialRecord struct {
|
||||
RA float64 `json:"ra_deg"`
|
||||
@@ -95,3 +98,22 @@ func selectGalileanSatellite(info GalileanSatellitesInfo, name string) GalileanS
|
||||
panic("unknown satellite: " + name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGalileanSatellitesUseTTFrame(t *testing.T) {
|
||||
dates := []time.Time{
|
||||
time.Date(1973, 4, 15, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(1999, 1, 1, 2, 0, 0, 0, time.UTC),
|
||||
time.Date(2026, 4, 2, 18, 0, 0, 0, time.UTC),
|
||||
}
|
||||
for _, date := range dates {
|
||||
want := basic.JupiterGalileanSatelliteObservations(basic.TD2UT(basic.Date2JDE(date.UTC()), true))
|
||||
got := Satellites(date)
|
||||
positions := []GalileanSatellitePosition{got.Io, got.Europa, got.Ganymede, got.Callisto}
|
||||
for i, position := range positions {
|
||||
expected := galileanSatellitePositionFromBasic(want[i])
|
||||
if position != expected {
|
||||
t.Fatalf("date=%s satellite=%d TT frame mismatch: got %+v want %+v", date, i+1, position, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -54,8 +54,8 @@
|
||||
"occultation": false,
|
||||
"eclipse": false,
|
||||
"shadow_transit": true,
|
||||
"shadow_x_arcsec": 17.228193145273753,
|
||||
"shadow_y_arcsec": 3.256241312014926
|
||||
"shadow_x_arcsec": 16.76690445965992,
|
||||
"shadow_y_arcsec": 3.0477215683782015
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -108,8 +108,8 @@
|
||||
"occultation": false,
|
||||
"eclipse": false,
|
||||
"shadow_transit": true,
|
||||
"shadow_x_arcsec": 3.158460096080454,
|
||||
"shadow_y_arcsec": -16.417707231374926
|
||||
"shadow_x_arcsec": 2.9731905953542506,
|
||||
"shadow_y_arcsec": -16.49424583711469
|
||||
},
|
||||
"io": {
|
||||
"transit": false,
|
||||
@@ -133,8 +133,8 @@
|
||||
"occultation": false,
|
||||
"eclipse": false,
|
||||
"shadow_transit": true,
|
||||
"shadow_x_arcsec": -5.755686876706326,
|
||||
"shadow_y_arcsec": -12.656123592995895
|
||||
"shadow_x_arcsec": -5.978454744969274,
|
||||
"shadow_y_arcsec": -12.745255093672851
|
||||
},
|
||||
"ganymede": {
|
||||
"transit": false,
|
||||
|
||||
Reference in New Issue
Block a user