feat: 完善日月食与月掩几何链路并扩展历法接口

- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
2026-09-17 12:27:40 +08:00
parent 9ee2163cc7
commit 2bf8478639
428 changed files with 85981 additions and 7998 deletions
+3
View File
@@ -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
+66
View File
@@ -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
}
+3 -3
View File
@@ -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]),
+66
View File
@@ -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)
}
+22 -1
View File
@@ -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])
}
}
}
+3 -3
View File
@@ -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]),
+23 -1
View File
@@ -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
View File
@@ -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,