16c62a97d5
- 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试
357 lines
12 KiB
Go
357 lines
12 KiB
Go
package basic
|
||
|
||
import (
|
||
"math"
|
||
"testing"
|
||
"time"
|
||
|
||
. "b612.me/astro/tools"
|
||
)
|
||
|
||
// 本文件是「行星相位事件(留/合/冲/方照)」的顺序不变量回归。
|
||
//
|
||
// 背景:类型化「留」的结果必须满足
|
||
// Next*(q) >= q - tol、Last*(q) <= q + tol、返回的是真值事件、且不得跳过更近的同类型事件。
|
||
// 2026-09 的 review 发现水星类型化「留」会跳过一个下合,导致 Last* 返回未来
|
||
// (例如查询 TT 2008-01-22 时 LastMercuryProgradeToRetrograde 返回 2008-01-28)。
|
||
// 真值来自对导出星历的独立二分扫描,不使用被测的 Next*/Last*,因此能独立复现该类缺陷。
|
||
|
||
const phaseInvariantToleranceDay = 0.6 / 86400.0 // 站事件同刻容差 0.5 s + 余量
|
||
|
||
type phaseTruthStation struct {
|
||
jd float64
|
||
p2r bool
|
||
}
|
||
|
||
func phaseRate(ra func(float64) float64, jd float64) float64 {
|
||
sub := ra(jd+0.01) - ra(jd-0.01)
|
||
if sub > 180 {
|
||
sub -= 360
|
||
}
|
||
if sub < -180 {
|
||
sub += 360
|
||
}
|
||
return sub / 0.02
|
||
}
|
||
|
||
func phaseTruthStations(ra func(float64) float64, jd0, jd1, step float64) []phaseTruthStation {
|
||
rate := func(jd float64) float64 { return phaseRate(ra, jd) }
|
||
var out []phaseTruthStation
|
||
prevJD, prev := jd0, rate(jd0)
|
||
for jd := jd0 + step; jd <= jd1; jd += step {
|
||
cur := rate(jd)
|
||
if prev*cur < 0 {
|
||
left, right := prevJD, jd
|
||
for i := 0; i < 100; i++ {
|
||
middle := (left + right) / 2
|
||
if middle == left || middle == right {
|
||
break
|
||
}
|
||
if (prev < 0) == (rate(middle) < 0) {
|
||
left = middle
|
||
continue
|
||
}
|
||
right = middle
|
||
}
|
||
out = append(out, phaseTruthStation{jd: (left + right) / 2, p2r: prev > 0})
|
||
}
|
||
prevJD, prev = jd, cur
|
||
}
|
||
return out
|
||
}
|
||
|
||
type phaseStationCase struct {
|
||
name string
|
||
ra func(float64) float64
|
||
nextP2R func(float64) float64
|
||
lastP2R func(float64) float64
|
||
nextR2P func(float64) float64
|
||
lastR2P func(float64) float64
|
||
step float64
|
||
// radius 真值扫描半径(天):必须覆盖「查询在站后几秒 ⇒ 答案是下一个同名站」的距离。
|
||
radius float64
|
||
// queryRadius 参与构造查询的站距中心的最大距离(天)。
|
||
queryRadius float64
|
||
}
|
||
|
||
func phaseStationCases() []phaseStationCase {
|
||
return []phaseStationCase{
|
||
{"Mercury", MercuryApparentRa, NextMercuryProgradeToRetrograde, LastMercuryProgradeToRetrograde,
|
||
NextMercuryRetrogradeToPrograde, LastMercuryRetrogradeToPrograde, 0.25, 700, 450},
|
||
{"Mars", MarsApparentRa, NextMarsProgradeToRetrograde, LastMarsProgradeToRetrograde,
|
||
NextMarsRetrogradeToPrograde, LastMarsRetrogradeToPrograde, 0.5, 1100, 250},
|
||
{"Venus", VenusApparentRa, NextVenusProgradeToRetrograde, LastVenusProgradeToRetrograde,
|
||
NextVenusRetrogradeToPrograde, LastVenusRetrogradeToPrograde, 0.5, 900, 250},
|
||
{"Jupiter", JupiterApparentRa, NextJupiterProgradeToRetrograde, LastJupiterProgradeToRetrograde,
|
||
NextJupiterRetrogradeToPrograde, LastJupiterRetrogradeToPrograde, 1, 700, 250},
|
||
}
|
||
}
|
||
|
||
func phaseEpochTT(year int, month time.Month, day int) float64 {
|
||
return UTC2TT(Date2JD(time.Date(year, month, day, 0, 0, 0, 0, time.UTC)))
|
||
}
|
||
|
||
// TestPlanetStationOrderInvariant 在固定的历史失败时点附近逐点检查顺序不变量。
|
||
func TestPlanetStationOrderInvariant(t *testing.T) {
|
||
epochs := map[string][]time.Time{
|
||
"Mercury": {
|
||
time.Date(2008, 1, 22, 0, 0, 0, 0, time.UTC),
|
||
time.Date(2006, 2, 23, 0, 0, 0, 0, time.UTC),
|
||
time.Date(2007, 2, 6, 0, 0, 0, 0, time.UTC),
|
||
time.Date(2007, 11, 8, 0, 0, 0, 0, time.UTC),
|
||
time.Date(2003, 9, 27, 0, 0, 0, 0, time.UTC),
|
||
time.Date(-209, 1, 14, 0, 0, 0, 0, time.UTC),
|
||
time.Date(-208, 12, 11, 0, 0, 0, 0, time.UTC),
|
||
},
|
||
"Mars": {time.Date(2003, 7, 30, 0, 0, 0, 0, time.UTC)},
|
||
"Venus": {time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC)},
|
||
"Jupiter": {time.Date(2003, 7, 30, 0, 0, 0, 0, time.UTC)},
|
||
}
|
||
offsets := []float64{0, 1, -1, 30, -30, 300, -300, 3600, -3600, 86400, -86400, 10 * 86400, -10 * 86400, 30 * 86400, -30 * 86400}
|
||
|
||
for _, tc := range phaseStationCases() {
|
||
for _, epoch := range epochs[tc.name] {
|
||
center := phaseEpochTT(epoch.Year(), epoch.Month(), epoch.Day())
|
||
stations := phaseTruthStations(tc.ra, center-tc.radius, center+tc.radius, tc.step)
|
||
if len(stations) == 0 {
|
||
t.Fatalf("%s: no truth stations near %s", tc.name, epoch.Format("2006-01-02"))
|
||
}
|
||
funcs := []struct {
|
||
label string
|
||
fn func(float64) float64
|
||
next bool
|
||
p2r bool
|
||
}{
|
||
{"NextP2R", tc.nextP2R, true, true},
|
||
{"LastP2R", tc.lastP2R, false, true},
|
||
{"NextR2P", tc.nextR2P, true, false},
|
||
{"LastR2P", tc.lastR2P, false, false},
|
||
}
|
||
for _, f := range funcs {
|
||
var queries []float64
|
||
for _, st := range stations {
|
||
if st.p2r != f.p2r || math.Abs(st.jd-center) > tc.queryRadius {
|
||
continue
|
||
}
|
||
for _, off := range offsets {
|
||
queries = append(queries, st.jd+off/86400.0)
|
||
}
|
||
}
|
||
for _, q := range queries {
|
||
gotUT := f.fn(q)
|
||
if math.IsNaN(gotUT) {
|
||
t.Fatalf("%s %s at %s returned NaN", tc.name, f.label,
|
||
JD2DateByZone(TT2UTC(q), time.UTC, false).Format("2006-01-02 15:04:05"))
|
||
}
|
||
got := UTC2TT(gotUT)
|
||
// 1) 顺序不变量
|
||
if f.next && got < q-phaseInvariantToleranceDay {
|
||
t.Fatalf("%s %s at %s returned past event %s", tc.name, f.label,
|
||
JD2DateByZone(TT2UTC(q), time.UTC, false).Format("2006-01-02 15:04:05"),
|
||
JD2DateByZone(gotUT, time.UTC, false).Format("2006-01-02 15:04:05"))
|
||
}
|
||
if !f.next && got > q+phaseInvariantToleranceDay {
|
||
t.Fatalf("%s %s at %s returned future event %s", tc.name, f.label,
|
||
JD2DateByZone(TT2UTC(q), time.UTC, false).Format("2006-01-02 15:04:05"),
|
||
JD2DateByZone(gotUT, time.UTC, false).Format("2006-01-02 15:04:05"))
|
||
}
|
||
// 2) 必须是真值事件
|
||
nearest, nearestDev := math.NaN(), math.Inf(1)
|
||
for _, st := range stations {
|
||
if st.p2r != f.p2r {
|
||
continue
|
||
}
|
||
if dev := math.Abs(st.jd - got); dev < nearestDev {
|
||
nearest, nearestDev = st.jd, dev
|
||
}
|
||
}
|
||
if nearestDev > 60.0/86400.0 {
|
||
t.Fatalf("%s %s at %s returned non-event %.6f (nearest truth %.3f d away)", tc.name, f.label,
|
||
JD2DateByZone(TT2UTC(q), time.UTC, false).Format("2006-01-02 15:04:05"), got, nearestDev)
|
||
}
|
||
_ = nearest
|
||
// 3) 不得跳过更近的同类型事件
|
||
for _, st := range stations {
|
||
if st.p2r != f.p2r {
|
||
continue
|
||
}
|
||
if f.next && st.jd > q+phaseInvariantToleranceDay && st.jd < got-60.0/86400.0 {
|
||
t.Fatalf("%s %s at %s skipped %s", tc.name, f.label,
|
||
JD2DateByZone(TT2UTC(q), time.UTC, false).Format("2006-01-02 15:04:05"),
|
||
JD2DateByZone(TT2UTC(st.jd), time.UTC, false).Format("2006-01-02 15:04:05"))
|
||
}
|
||
if !f.next && st.jd < q-phaseInvariantToleranceDay && st.jd > got+60.0/86400.0 {
|
||
t.Fatalf("%s %s at %s skipped %s", tc.name, f.label,
|
||
JD2DateByZone(TT2UTC(q), time.UTC, false).Format("2006-01-02 15:04:05"),
|
||
JD2DateByZone(TT2UTC(st.jd), time.UTC, false).Format("2006-01-02 15:04:05"))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestMercuryConjunctionNeverSkips 检查水星「合」搜索不会跨过更近的合
|
||
// (历史缺陷:启发式跳 + 2 天走法会走满一个会合周期,跳过一次下合)。
|
||
func TestMercuryConjunctionNeverSkips(t *testing.T) {
|
||
delta := func(jd float64) float64 {
|
||
sub := Limit360(MercuryApparentLo(jd) - HSunApparentLo(jd))
|
||
if sub > 180 {
|
||
sub -= 360
|
||
}
|
||
if sub < -180 {
|
||
sub += 360
|
||
}
|
||
return sub
|
||
}
|
||
// 真值:局部细扫
|
||
truth := func(jd0, jd1, step float64) []float64 {
|
||
var out []float64
|
||
prevJD, prev := jd0, delta(jd0)
|
||
for jd := jd0 + step; jd <= jd1; jd += step {
|
||
cur := delta(jd)
|
||
if prev*cur < 0 {
|
||
left, right := prevJD, jd
|
||
for i := 0; i < 100; i++ {
|
||
middle := (left + right) / 2
|
||
if middle == left || middle == right {
|
||
break
|
||
}
|
||
if (prev < 0) == (delta(middle) < 0) {
|
||
left = middle
|
||
continue
|
||
}
|
||
right = middle
|
||
}
|
||
out = append(out, (left+right)/2)
|
||
}
|
||
prevJD, prev = jd, cur
|
||
}
|
||
return out
|
||
}
|
||
for _, epoch := range []time.Time{
|
||
time.Date(2008, 1, 22, 0, 0, 0, 0, time.UTC),
|
||
time.Date(2006, 2, 23, 0, 0, 0, 0, time.UTC),
|
||
time.Date(2007, 2, 6, 0, 0, 0, 0, time.UTC),
|
||
time.Date(-209, 1, 14, 0, 0, 0, 0, time.UTC),
|
||
} {
|
||
center := phaseEpochTT(epoch.Year(), epoch.Month(), epoch.Day())
|
||
truthEvents := truth(center-400, center+400, 0.25)
|
||
if len(truthEvents) < 5 {
|
||
t.Fatalf("truth scan too sparse near %s: %d", epoch.Format("2006-01-02"), len(truthEvents))
|
||
}
|
||
for _, q := range []float64{center, center + 0.5, center + 12, center - 12, center + 60, center - 60} {
|
||
for _, next := range []uint8{0, 1} {
|
||
got := UTC2TT(mercuryConjunction(q, next))
|
||
if math.IsNaN(got) {
|
||
t.Fatalf("mercuryConjunction(%.6f, %d) = NaN", q, next)
|
||
}
|
||
want := math.NaN()
|
||
if next == 1 {
|
||
for _, e := range truthEvents {
|
||
if e >= q-0.1/86400.0 {
|
||
want = e
|
||
break
|
||
}
|
||
}
|
||
} else {
|
||
for i := len(truthEvents) - 1; i >= 0; i-- {
|
||
if truthEvents[i] <= q+0.1/86400.0 {
|
||
want = truthEvents[i]
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if math.IsNaN(want) {
|
||
continue
|
||
}
|
||
if math.Abs(got-want) > 60.0/86400.0 {
|
||
t.Fatalf("mercuryConjunction(%.6f, %d) = %.6f want %.6f (%.3f d off) near %s",
|
||
q, next, got, want, got-want, epoch.Format("2006-01-02"))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestGreatestElongationNoSkip 检查大距事件不会跳过相邻的极大。
|
||
//
|
||
// 口径说明:库内 Next*GreatestElongation 用的是「真距角」(mercuryTrueElongationN,
|
||
// 不含光行差/视位置),公开的 MercurySunElongation 是「视距角」,两者极大时刻相差约 10 分钟。
|
||
// 因此这里用视距角的极值作为参照,只做「不跳事件 / 顺序 / 是极值附近」的判定,
|
||
// 匹配容差取 30 分钟,足以覆盖口径差又远小于任何真实跳事件(数十天)。
|
||
func TestGreatestElongationNoSkip(t *testing.T) {
|
||
type elongCase struct {
|
||
name string
|
||
elongate func(float64) float64
|
||
next func(float64) float64
|
||
last func(float64) float64
|
||
}
|
||
cases := []elongCase{
|
||
{"Mercury", MercurySunElongation, NextMercuryGreatestElongation, LastMercuryGreatestElongation},
|
||
{"Venus", VenusSunElongation, NextVenusGreatestElongation, LastVenusGreatestElongation},
|
||
}
|
||
jd0 := phaseEpochTT(2024, 1, 1)
|
||
jd1 := phaseEpochTT(2029, 1, 1)
|
||
for _, tc := range cases {
|
||
// 真值:视距角的局部极大
|
||
var maxima []float64
|
||
prev, cur := tc.elongate(jd0), tc.elongate(jd0+0.5)
|
||
for jd := jd0 + 1.0; jd <= jd1; jd += 0.5 {
|
||
next := tc.elongate(jd)
|
||
if cur >= prev && cur >= next && (cur > prev || cur > next) {
|
||
left, right := jd-1.0, jd
|
||
for i := 0; i < 60; i++ {
|
||
third := (right - left) / 3
|
||
if tc.elongate(left+third) <= tc.elongate(right-third) {
|
||
left += third
|
||
continue
|
||
}
|
||
right -= third
|
||
}
|
||
maxima = append(maxima, (left+right)/2)
|
||
}
|
||
prev, cur = cur, next
|
||
}
|
||
if len(maxima) < 5 {
|
||
t.Fatalf("%s: too few elongation maxima (%d)", tc.name, len(maxima))
|
||
}
|
||
const matchTolerance = 30.0 / 1440.0
|
||
for i, maximum := range maxima {
|
||
// 查询落在极大前一天:Next 必须命中该极大(不得跳过)
|
||
next := UTC2TT(tc.next(maximum - 1))
|
||
if math.IsNaN(next) {
|
||
t.Fatalf("%s: Next at %s returned NaN", tc.name, tmpPhaseDate(maximum-1))
|
||
}
|
||
if dev := math.Abs(next - maximum); dev > matchTolerance {
|
||
t.Fatalf("%s: Next at %s = %s, expected the maximum %s (%.2f min off)",
|
||
tc.name, tmpPhaseDate(maximum-1), tmpPhaseDate(next), tmpPhaseDate(maximum), dev*1440)
|
||
}
|
||
if next < maximum-1-phaseInvariantToleranceDay {
|
||
t.Fatalf("%s: Next returned an event before the query", tc.name)
|
||
}
|
||
// 查询落在极大后一天:Last 必须命中该极大,Next 必须命中下一个极大
|
||
last := UTC2TT(tc.last(maximum + 1))
|
||
if dev := math.Abs(last - maximum); dev > matchTolerance {
|
||
t.Fatalf("%s: Last at %s = %s, expected the previous maximum %s (%.2f min off)",
|
||
tc.name, tmpPhaseDate(maximum+1), tmpPhaseDate(last), tmpPhaseDate(maximum), dev*1440)
|
||
}
|
||
if last > maximum+1+phaseInvariantToleranceDay {
|
||
t.Fatalf("%s: Last returned an event after the query", tc.name)
|
||
}
|
||
if i+1 < len(maxima) {
|
||
following := UTC2TT(tc.next(maximum + 1))
|
||
if dev := math.Abs(following - maxima[i+1]); dev > matchTolerance {
|
||
t.Fatalf("%s: Next at %s = %s, expected the following maximum %s (%.2f min off)",
|
||
tc.name, tmpPhaseDate(maximum+1), tmpPhaseDate(following), tmpPhaseDate(maxima[i+1]), dev*1440)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func tmpPhaseDate(jd float64) string {
|
||
return JD2DateByZone(TT2UTC(jd), time.UTC, false).Format("2006-01-02 15:04")
|
||
}
|