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 moon 轻量月球链路:少量摄动项的月球近似、轻量站心修正、月相月龄和升落搜索,角度单位为度。
// Package moon is the lightweight lunar chain: a few-perturbation-term Moon approximation, light topocentric correction, phase, age and rise/set search; angles are degrees.
package moon
+11 -4
View File
@@ -60,27 +60,34 @@ func ApparentRaDec(date time.Time, lon, lat float64) (float64, float64) {
return state.RightAscension, state.Declination
}
func topocentricHorizontal(date time.Time, lon, lat float64) (altitude, azimuth, hourAngle float64) {
jd := basic.Date2JDE(date.UTC())
state := lite.MoonTopocentric(jd, lon, lat, 0)
return lite.HorizontalCoordinates(state.RightAscension, state.Declination, jd, lon, lat)
}
// HourAngle 轻量时角 / lightweight hour angle.
func HourAngle(date time.Time, lon, lat float64) float64 {
_, _, hourAngle := lite.HorizontalCoordinates(ApparentRa(date, lon, lat), ApparentDec(date, lon, lat), basic.Date2JDE(date.UTC()), lon, lat)
_, _, hourAngle := topocentricHorizontal(date, lon, lat)
return hourAngle
}
// Azimuth 轻量方位角 / lightweight azimuth.
func Azimuth(date time.Time, lon, lat float64) float64 {
_, azimuth, _ := lite.HorizontalCoordinates(ApparentRa(date, lon, lat), ApparentDec(date, lon, lat), basic.Date2JDE(date.UTC()), lon, lat)
_, azimuth, _ := topocentricHorizontal(date, lon, lat)
return azimuth
}
// Altitude 轻量高度角 / lightweight altitude.
func Altitude(date time.Time, lon, lat float64) float64 {
altitude, _, _ := lite.HorizontalCoordinates(ApparentRa(date, lon, lat), ApparentDec(date, lon, lat), basic.Date2JDE(date.UTC()), lon, lat)
altitude, _, _ := topocentricHorizontal(date, lon, lat)
return altitude
}
// Zenith 轻量天顶距 / lightweight zenith distance.
func Zenith(date time.Time, lon, lat float64) float64 {
return 90 - Altitude(date, lon, lat)
altitude, _, _ := topocentricHorizontal(date, lon, lat)
return 90 - altitude
}
// SunMoonLoDiff 轻量日月黄经差 / lightweight Moon-Sun ecliptic-longitude difference.
+83
View File
@@ -2,9 +2,12 @@ package moon
import (
"math"
"math/rand"
"testing"
"time"
"b612.me/astro/basic"
lite "b612.me/astro/lite/internal"
fullmoon "b612.me/astro/moon"
)
@@ -92,3 +95,83 @@ func assertTimeWithinMinutes(t *testing.T, name string, got, want time.Time, lim
t.Fatalf("%s = %s, want %s", name, got, want)
}
}
// 四个地平入口须只求一次站心位置:参考实现按旧口径对同一时刻重复求值,逐位对照。
func TestHorizontalEntriesMatchDuplicatedEvaluation(t *testing.T) {
type site struct {
name string
lon, lat float64
}
sites := []site{
{"shanghai", 121.4737, 31.2304},
{"sydney", 151.2093, -33.8688},
{"north-pole", 0, 89.9999},
{"south-pole", 0, -89.9999},
{"dateline-west", -179.99, 12},
{"dateline-east", 179.99, -12},
{"equator", 0, 0},
}
zones := []*time.Location{
time.UTC,
time.FixedZone("CST", 8*3600),
time.FixedZone("EST", -5*3600),
time.FixedZone("LMT", -7*3600-52*60-58),
}
years := []int{-500, 1000, 1582, 2025, 2100, 3000, 4000}
hours := []int{0, 5, 12, 20, 23}
type sample struct {
label string
date time.Time
lon float64
lat float64
}
samples := make([]sample, 0, len(sites)*len(zones)*len(years)*len(hours)+200)
for _, st := range sites {
for _, zone := range zones {
for _, year := range years {
for _, hour := range hours {
samples = append(samples, sample{
label: st.name + "/" + zone.String(),
date: time.Date(year, 3, 17, hour, 43, 21, 123456789, zone),
lon: st.lon,
lat: st.lat,
})
}
}
}
}
rng := rand.New(rand.NewSource(20260915))
for i := 0; i < 200; i++ {
zone := time.FixedZone("random", (rng.Intn(97)-48)*1800)
samples = append(samples, sample{
label: "random",
date: time.Date(
rng.Intn(8000)-2000, time.Month(1+rng.Intn(12)), 1+rng.Intn(28),
rng.Intn(24), rng.Intn(60), rng.Intn(60), rng.Intn(1000000000), zone,
),
lon: rng.Float64()*360 - 180,
lat: rng.Float64()*179.8 - 89.9,
})
}
for _, s := range samples {
jd := basic.Date2JDE(s.date.UTC())
altitude, azimuth, hourAngle := lite.HorizontalCoordinates(ApparentRa(s.date, s.lon, s.lat), ApparentDec(s.date, s.lon, s.lat), jd, s.lon, s.lat)
checks := []struct {
name string
got float64
want float64
}{
{"HourAngle", HourAngle(s.date, s.lon, s.lat), hourAngle},
{"Azimuth", Azimuth(s.date, s.lon, s.lat), azimuth},
{"Altitude", Altitude(s.date, s.lon, s.lat), altitude},
{"Zenith", Zenith(s.date, s.lon, s.lat), 90 - altitude},
}
for _, check := range checks {
if check.got != check.want {
t.Fatalf("%s %s %s: got %.17g want %.17g", check.name, s.label, s.date.Format(time.RFC3339Nano), check.got, check.want)
}
}
}
}
+84
View File
@@ -0,0 +1,84 @@
package moon
import (
"testing"
"time"
"b612.me/astro/basic"
lite "b612.me/astro/lite/internal"
)
var benchmarkHorizontalSink float64
func benchmarkHorizontalInputs() (time.Time, float64, float64) {
return time.Date(2026, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)), 121.4737, 31.2304
}
func BenchmarkMoonHourAngle(b *testing.B) {
date, lon, lat := benchmarkHorizontalInputs()
b.ReportAllocs()
b.ResetTimer()
sink := 0.0
for i := 0; i < b.N; i++ {
sink = HourAngle(date, lon, lat)
}
benchmarkHorizontalSink = sink
}
func BenchmarkMoonAzimuth(b *testing.B) {
date, lon, lat := benchmarkHorizontalInputs()
b.ReportAllocs()
b.ResetTimer()
sink := 0.0
for i := 0; i < b.N; i++ {
sink = Azimuth(date, lon, lat)
}
benchmarkHorizontalSink = sink
}
func BenchmarkMoonAltitude(b *testing.B) {
date, lon, lat := benchmarkHorizontalInputs()
b.ReportAllocs()
b.ResetTimer()
sink := 0.0
for i := 0; i < b.N; i++ {
sink = Altitude(date, lon, lat)
}
benchmarkHorizontalSink = sink
}
func BenchmarkMoonZenith(b *testing.B) {
date, lon, lat := benchmarkHorizontalInputs()
b.ReportAllocs()
b.ResetTimer()
sink := 0.0
for i := 0; i < b.N; i++ {
sink = Zenith(date, lon, lat)
}
benchmarkHorizontalSink = sink
}
func BenchmarkMoonHorizontalLegacy(b *testing.B) {
date, lon, lat := benchmarkHorizontalInputs()
jd := basic.Date2JDE(date.UTC())
entries := []struct {
name string
pick func(altitude, azimuth, hourAngle float64) float64
}{
{"HourAngle", func(_, _, hourAngle float64) float64 { return hourAngle }},
{"Azimuth", func(_, azimuth, _ float64) float64 { return azimuth }},
{"Altitude", func(altitude, _, _ float64) float64 { return altitude }},
{"Zenith", func(altitude, _, _ float64) float64 { return 90 - altitude }},
}
for _, entry := range entries {
b.Run(entry.name, func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
sink := 0.0
for i := 0; i < b.N; i++ {
sink = entry.pick(lite.HorizontalCoordinates(ApparentRa(date, lon, lat), ApparentDec(date, lon, lat), jd, lon, lat))
}
benchmarkHorizontalSink = sink
})
}
}