feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -11,10 +11,15 @@ import (
|
||||
// 返回轨道目标在观测者所在地的视差角,单位度;`observerLon` 东经为正,`observerLat` 北纬为正,`observerHeight` 单位米。
|
||||
// Returns the parallactic angle of the orbital target for the observing site, in degrees. `observerLon` is east-positive, `observerLat` is north-positive, and `observerHeight` is in meters.
|
||||
func ParallacticAngle(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) float64 {
|
||||
position := ApparentTopocentricEquatorial(date, elements, observerLon, observerLat, observerHeight)
|
||||
return basic.ParallacticAngleByHourAngle(
|
||||
HourAngle(date, elements, observerLon, observerLat, observerHeight),
|
||||
position.Dec,
|
||||
// 时角与赤纬须取自同一次站心求解:分属两条儒略日路径(相差 1 ULP ≈ 40 µs)会引入 ≤2e-9 度漂移,
|
||||
// 因此统一到同一时刻后的亚纳度量级输出变化是有意为之,不是纯性能改动。
|
||||
_, dec, hourAngle := basic.OrbitHourAngleWithTopocentric(
|
||||
basic.Date2JDE(date),
|
||||
observerLon,
|
||||
observerLat,
|
||||
observationTimezone(date),
|
||||
observerHeight,
|
||||
toBasicElements(elements),
|
||||
)
|
||||
return basic.ParallacticAngleByHourAngle(hourAngle, dec, observerLat)
|
||||
}
|
||||
|
||||
+158
-4
@@ -1,7 +1,9 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -12,14 +14,166 @@ func TestParallacticAngleMatchesHourAngleForm(t *testing.T) {
|
||||
elements := sampleObservationElements()
|
||||
date := time.Date(2025, 11, 21, 20, 0, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
|
||||
_, dec, _ := basic.OrbitHourAngleWithTopocentric(
|
||||
basic.Date2JDE(date),
|
||||
shanghaiLon, shanghaiLat, observationTimezone(date), shanghaiHeightMeters,
|
||||
toBasicElements(elements),
|
||||
)
|
||||
got := ParallacticAngle(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
|
||||
position := ApparentTopocentricEquatorial(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
|
||||
want := basic.ParallacticAngleByHourAngle(
|
||||
HourAngle(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters),
|
||||
position.Dec,
|
||||
dec,
|
||||
shanghaiLat,
|
||||
)
|
||||
if math.Abs(got-want) > 1e-12 {
|
||||
t.Fatalf("parallactic angle mismatch: got %.15f want %.15f", got, want)
|
||||
if got != want {
|
||||
t.Fatalf("parallactic angle mismatch: got %.17g want %.17g", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 旧口径把时角与赤纬放在相差 1 ULP 儒略日的两条路径上求值;差分对照用它作参考实现。
|
||||
func duplicatedSolveParallacticAngle(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) float64 {
|
||||
position := ApparentTopocentricEquatorial(date, elements, observerLon, observerLat, observerHeight)
|
||||
return basic.ParallacticAngleByHourAngle(
|
||||
HourAngle(date, elements, observerLon, observerLat, observerHeight),
|
||||
position.Dec,
|
||||
observerLat,
|
||||
)
|
||||
}
|
||||
|
||||
func parallacticJDPathsAgree(date time.Time) bool {
|
||||
utcPath := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
|
||||
hourAnglePath := basic.TD2UT(basic.Date2JDE(date)-observationTimezone(date)/24.0, true)
|
||||
return utcPath == hourAnglePath
|
||||
}
|
||||
|
||||
type parallacticCase struct {
|
||||
label string
|
||||
date time.Time
|
||||
elements Elements
|
||||
lon, lat float64
|
||||
}
|
||||
|
||||
func parallacticElementSets() []struct {
|
||||
name string
|
||||
elements Elements
|
||||
} {
|
||||
return []struct {
|
||||
name string
|
||||
elements Elements
|
||||
}{
|
||||
{"mainbelt", sampleObservationElements()},
|
||||
{"neo", Elements{EpochJD: 2461000.5, A: 1.2, E: 0.35, I: 8, Omega: 200.1, W: 120.5, M0: 10}},
|
||||
{"perihelion", Elements{EpochJD: 2461000.5, Q: 0.6, E: 0.7, I: 15, Omega: 120, W: 33, TpJD: 2461005.5}},
|
||||
{"highinclination", Elements{EpochJD: 2461000.5, A: 3.2, E: 0.3, I: 160, Omega: 20, W: 260, M0: 100}},
|
||||
}
|
||||
}
|
||||
|
||||
func parallacticFixedCases() []parallacticCase {
|
||||
zones := []*time.Location{
|
||||
time.UTC,
|
||||
time.FixedZone("CST", 8*3600),
|
||||
time.FixedZone("EST", -5*3600),
|
||||
time.FixedZone("NPT", 5*3600+45*60),
|
||||
time.FixedZone("LMT", -7*3600-52*60-58),
|
||||
}
|
||||
sites := []struct {
|
||||
name string
|
||||
lon, lat float64
|
||||
}{
|
||||
{"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},
|
||||
{"reykjavik", -21.8174, 64.1265},
|
||||
{"equator", 0, 0},
|
||||
}
|
||||
years := []int{-500, 1000, 1582, 1900, 2025, 2100, 3000, 4000}
|
||||
hours := []int{0, 3, 7, 12, 17, 20, 23}
|
||||
cases := make([]parallacticCase, 0, len(parallacticElementSets())*len(sites)*len(years)*len(zones)*len(hours))
|
||||
for _, set := range parallacticElementSets() {
|
||||
for _, site := range sites {
|
||||
for _, year := range years {
|
||||
for _, zone := range zones {
|
||||
for _, hour := range hours {
|
||||
cases = append(cases, parallacticCase{
|
||||
label: fmt.Sprintf("%s/%s/%d/%s/%d", set.name, site.name, year, zone, hour),
|
||||
date: time.Date(year, 5, 17, hour, 43, 21, 123456789, zone),
|
||||
elements: set.elements,
|
||||
lon: site.lon,
|
||||
lat: site.lat,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cases
|
||||
}
|
||||
|
||||
func parallacticRandomCases(count int) []parallacticCase {
|
||||
rng := rand.New(rand.NewSource(20260915))
|
||||
sets := parallacticElementSets()
|
||||
cases := make([]parallacticCase, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
set := sets[rng.Intn(len(sets))]
|
||||
zone := time.FixedZone("random", (rng.Intn(97)-48)*1800)
|
||||
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,
|
||||
)
|
||||
cases = append(cases, parallacticCase{
|
||||
label: fmt.Sprintf("random/%d/%s", i, set.name),
|
||||
date: date,
|
||||
elements: set.elements,
|
||||
lon: rng.Float64()*360 - 180,
|
||||
lat: rng.Float64()*179.8 - 89.9,
|
||||
})
|
||||
}
|
||||
return cases
|
||||
}
|
||||
|
||||
func TestParallacticAngleDifferentialAgainstDuplicatedSolve(t *testing.T) {
|
||||
const (
|
||||
maxAbsoluteTolerance = 2e-9
|
||||
maxRelativeTolerance = 5e-11
|
||||
)
|
||||
cases := append(parallacticFixedCases(), parallacticRandomCases(240)...)
|
||||
bitwise, drifted := 0, 0
|
||||
maxAbsolute, maxRelative := 0.0, 0.0
|
||||
maxAbsoluteCase, maxRelativeCase := "", ""
|
||||
for _, tc := range cases {
|
||||
got := ParallacticAngle(tc.date, tc.elements, tc.lon, tc.lat, shanghaiHeightMeters)
|
||||
want := duplicatedSolveParallacticAngle(tc.date, tc.elements, tc.lon, tc.lat, shanghaiHeightMeters)
|
||||
if got == want {
|
||||
bitwise++
|
||||
continue
|
||||
}
|
||||
if parallacticJDPathsAgree(tc.date) {
|
||||
t.Fatalf("%s: 两条儒略日路径逐位相等,输出必须逐位相同,got %.17g want %.17g", tc.label, got, want)
|
||||
}
|
||||
drifted++
|
||||
diff := math.Abs(got - want)
|
||||
if diff > maxAbsolute {
|
||||
maxAbsolute, maxAbsoluteCase = diff, tc.label
|
||||
}
|
||||
if math.Abs(want) > 1 {
|
||||
if relative := diff / math.Abs(want); relative > maxRelative {
|
||||
maxRelative, maxRelativeCase = relative, tc.label
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("cases=%d bitwise=%d (%.1f%%) drifted=%d maxAbs=%.4g deg (%s) maxRel=%.4g (%s)",
|
||||
len(cases), bitwise, 100*float64(bitwise)/float64(len(cases)), drifted,
|
||||
maxAbsolute, maxAbsoluteCase, maxRelative, maxRelativeCase)
|
||||
if maxAbsolute > maxAbsoluteTolerance {
|
||||
t.Fatalf("max absolute drift %.4g deg exceeds %.4g (%s)", maxAbsolute, maxAbsoluteTolerance, maxAbsoluteCase)
|
||||
}
|
||||
if maxRelative > maxRelativeTolerance {
|
||||
t.Fatalf("max relative drift %.4g exceeds %.4g (%s)", maxRelative, maxRelativeTolerance, maxRelativeCase)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
benchmarkParallacticSink float64
|
||||
benchmarkPositionSink EquatorialPosition
|
||||
)
|
||||
|
||||
func benchmarkObservationInputs() (time.Time, Elements) {
|
||||
return time.Date(2025, 11, 21, 20, 0, 0, 0, time.FixedZone("CST", 8*3600)), sampleObservationElements()
|
||||
}
|
||||
|
||||
func BenchmarkParallacticAngle(b *testing.B) {
|
||||
date, elements := benchmarkObservationInputs()
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
sink := 0.0
|
||||
for i := 0; i < b.N; i++ {
|
||||
sink = ParallacticAngle(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
|
||||
}
|
||||
benchmarkParallacticSink = sink
|
||||
}
|
||||
|
||||
func BenchmarkParallacticAngleLegacy(b *testing.B) {
|
||||
date, elements := benchmarkObservationInputs()
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
sink := 0.0
|
||||
for i := 0; i < b.N; i++ {
|
||||
sink = duplicatedSolveParallacticAngle(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
|
||||
}
|
||||
benchmarkParallacticSink = sink
|
||||
}
|
||||
|
||||
func BenchmarkApparentTopocentricEquatorial(b *testing.B) {
|
||||
date, elements := benchmarkObservationInputs()
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
sink := EquatorialPosition{}
|
||||
for i := 0; i < b.N; i++ {
|
||||
sink = ApparentTopocentricEquatorial(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
|
||||
}
|
||||
benchmarkPositionSink = sink
|
||||
}
|
||||
|
||||
func BenchmarkHourAngle(b *testing.B) {
|
||||
date, elements := benchmarkObservationInputs()
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
sink := 0.0
|
||||
for i := 0; i < b.N; i++ {
|
||||
sink = HourAngle(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
|
||||
}
|
||||
benchmarkParallacticSink = sink
|
||||
}
|
||||
Reference in New Issue
Block a user