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") }