Files
astro/basic/planet_transit_global_test.go
T
b612 ccf5880269 feat: 完善水星与金星全球凌日搜索及制图导出
- 基于平均下合周期与节点相位预筛候选,减少无效星历计算;跳过无法求解的单个会合周期,避免有限覆盖星历导致搜索提前终止
- 引入影轴 γ、半影半径及地球尺度几何判定,结合站心可见性识别地心无接触、仅部分地区可见的掠凌
- 添加金星 243 年、水星 46 年周期族编号,并明确编号适用范围及未分配时的零值语义
- 为 SVG、GeoJSON 和 KML 增加站心部分凌日可见区,适配接触等时线、几何元数据、图层样式及取景;为地心无接触凌日事件增加正射图,按可见区球面面积加权居中
2026-10-20 22:35:39 +08:00

171 lines
6.7 KiB
Go

package basic
import (
"math"
"testing"
"time"
"b612.me/astro/internal/ephem"
)
func TestPlanetTransitGlobalGrazes(tester *testing.T) {
for _, model := range []ephem.Model{ephem.Semianalytic, ephem.Legacy} {
inst := ephem.NewModelInstance(model)
for _, year := range []int{-540, 2854} {
transit := NextVenusTransitWithInstance(JDCalc(year, 1, 1), inst)
if !transit.Valid || JD2DateByZone(transit.Greatest, time.UTC, false).Year() != year {
tester.Fatalf("model=%d year=%d: missed global event: %+v", model, year, transit)
}
if transit.HasExternal || transit.HasInternal || transit.ExternalIngress != 0 || transit.ExternalEgress != 0 {
tester.Fatalf("global graze fabricated geocentric contacts: %+v", transit)
}
if math.Abs(transit.Gamma) <= transit.PenumbralRadius || math.Abs(transit.Gamma) >= transit.PenumbralRadius+1.01 {
tester.Fatalf("unexpected shadow geometry: %+v", transit)
}
observer := Observer{Longitude: -135, Latitude: -60}
local := PlanetTransitLocalWithInstance(transit, observer, inst)
if !local.Valid || !local.Visible || local.HasInternal || !local.ExternalIngress.Valid || !local.ExternalEgress.Valid {
tester.Fatalf("model=%d year=%d: missing visible local graze: %+v", model, year, local)
}
if year == 2854 {
if other := PlanetTransitLocalWithInstance(transit, Observer{Latitude: 45}, inst); other.Valid {
tester.Fatal("northern site must miss the 2854 event")
}
}
}
}
}
func TestPlanetTransitSeriesNumbers(tester *testing.T) {
for _, sample := range []struct{ planet, year, number, period int }{
{2, 1631, 6, 243}, {2, 1639, 4, 243}, {2, 2004, 3, 243}, {2, 2012, 5, 243}, {2, 2611, 4, 243}, {2, 2854, 4, 243},
{1, 1957, 9, 46}, {1, 2003, 9, 46}, {1, 2006, 8, 46}, {1, 2016, 7, 46}, {1, 2019, 6, 46}, {1, 2174, 11, 46},
} {
query := JDCalc(sample.year, 1, 1)
result := NextVenusTransit(query)
if sample.planet == 1 {
result = NextMercuryTransit(query)
}
if result.Series.Number != sample.number || result.Series.PeriodYears != sample.period {
tester.Fatalf("planet=%d year=%d: series=%+v, want %d/%d", sample.planet, sample.year, result.Series, sample.number, sample.period)
}
}
}
func TestPlanetTransitSeriesUnassigned(tester *testing.T) {
for _, sample := range []struct {
cfg planetTransitConfig
year int
}{
{mercuryTransitConfig(), 1500}, {mercuryTransitConfig(), 2400},
{venusTransitConfig(), -2100}, {venusTransitConfig(), 4100},
} {
series := planetTransitSeriesAt(JDCalc(sample.year, 1, 1), sample.cfg)
if series.Number != 0 || series.PeriodYears == 0 {
tester.Fatalf("unassigned series: %+v", series)
}
}
}
func TestPlanetTransitNearInternalEdge(tester *testing.T) {
for _, year := range []int{1631, 2611} {
transit := NextVenusTransit(JDCalc(year, 1, 1))
if !transit.Valid || !transit.HasExternal || !transit.HasInternal {
tester.Fatalf("year=%d: missing geocentric contacts", year)
}
full, partial := false, false
for latitude := -75; latitude <= 75; latitude += 30 {
for longitude := -180; longitude < 180; longitude += 45 {
local := PlanetTransitLocal(transit, Observer{Longitude: float64(longitude), Latitude: float64(latitude)})
if !local.Valid || !local.Visible {
continue
}
if local.HasInternal {
full = true
} else {
partial = true
}
}
}
if !full || !partial {
tester.Fatalf("year=%d: full=%v partial=%v", year, full, partial)
}
}
}
func TestPlanetTransitInputBounds(tester *testing.T) {
for _, query := range []float64{math.NaN(), math.Inf(1), math.Inf(-1), math.MaxFloat64} {
if result := NextVenusTransit(query); result.Valid {
tester.Fatal("non-finite query produced a transit")
}
}
}
func TestPlanetTransitResultRebuild(tester *testing.T) {
greatest := time.Date(2854, 12, 14, 13, 0, 0, 0, time.UTC)
if PlanetTransitResultFromTimes(2, time.Time{}, time.Time{}, greatest, time.Time{}, time.Time{}).Valid {
tester.Fatal("contact rebuild accepted missing C1/C4")
}
for _, window := range [][2]time.Time{
{{}, greatest.Add(time.Hour)}, {greatest, greatest.Add(time.Hour)}, {greatest.Add(-time.Hour), greatest},
} {
if PlanetTransitResultFromWindow(2, window[0], greatest, window[1]).Valid {
tester.Fatalf("window rebuild accepted %v", window)
}
}
transit := NextVenusTransit(JDCalc(2854, 1, 1))
rebuilt := PlanetTransitResultFromWindow(2, JD2DateByZone(transit.WindowStart, time.UTC, false),
JD2DateByZone(transit.Greatest, time.UTC, false), JD2DateByZone(transit.WindowEnd, time.UTC, false))
observer := Observer{Longitude: -135, Latitude: -60}
want, got := PlanetTransitLocal(transit, observer), PlanetTransitLocal(rebuilt, observer)
if !got.Valid || math.Abs(got.ExternalIngress.JD-want.ExternalIngress.JD)*86400 > 0.01 || math.Abs(got.ExternalEgress.JD-want.ExternalEgress.JD)*86400 > 0.01 {
tester.Fatalf("window rebuild changed local contacts: got %+v want %+v", got, want)
}
}
// boundedCoverageEngine 把内置星历限制在一个时间窗内,窗外的请求按越界报错。
type boundedCoverageEngine struct {
ephem.Engine
start, end float64
}
func (bounded boundedCoverageEngine) within(jd float64) bool {
return jd >= bounded.start && jd <= bounded.end
}
func (bounded boundedCoverageEngine) State(target, center ephem.Body, jdTDB float64) (pos, vel [3]float64, err error) {
if !bounded.within(jdTDB) {
return [3]float64{}, [3]float64{}, ephem.ErrOutOfRange
}
return bounded.Engine.State(target, center, jdTDB)
}
func (bounded boundedCoverageEngine) Apparent(target ephem.Body, jdTDB float64) (pos [3]float64, lightTime, distance float64, err error) {
if !bounded.within(jdTDB) {
return [3]float64{}, 0, 0, ephem.ErrOutOfRange
}
return bounded.Engine.(ephem.ApparentEngine).Apparent(target, jdTDB)
}
func (bounded boundedCoverageEngine) Coverage(ephem.Body) (start, end float64, ok bool) {
return bounded.start, bounded.end, true
}
// 有限覆盖星历:搜索起点前的候选落在覆盖之外时,不得因此丢掉覆盖内的凌日。
func TestPlanetTransitBoundedEphemerisCoverage(tester *testing.T) {
base := ephem.NewModelInstance(ephem.Semianalytic).Engine()
start := JDCalc(2019, 1, 1)
bounded := ephem.NewInstance(boundedCoverageEngine{Engine: base, start: start, end: JDCalc(2021, 1, 1)})
query := start + 60
transit := NextMercuryTransitWithInstance(query, bounded)
if !transit.Valid {
tester.Fatal("bounded ephemeris lost the 2019-11-11 transit inside its coverage")
}
if year := JD2DateByZone(transit.Greatest, time.UTC, false).Year(); year != 2019 {
tester.Fatalf("bounded ephemeris returned year %d, want 2019", year)
}
if transit.HasExternal != true || transit.HasInternal != true {
tester.Fatalf("bounded ephemeris returned an incomplete transit: %+v", transit)
}
}