astro/basic/apsis_test.go
starainrt 3ffdbe0034
feat: 扩展天文计算能力
- 新增日食、月食、本地可见性、中心线、半影区域、SVG 图示与沙罗周期信息
- 新增行星冲合、留、方照、物理星历、视直径、相位、亮肢角、轨道节点等计算
- 新增木星伽利略卫星位置、现象与接触事件计算
- 新增恒星星表、星座判定、自行修正与观测辅助能力
- 新增 coord、formula、orbit、sundial、lite/sun、lite/moon 等扩展包
- 完善农历年号、月相英文别名、视差角、大气质量、折射、日晷与双星计算
- 增加 NASA、JPL Horizons、IMCCE 等回归测试数据与基线测试
- 重构基础算法文件组织,补充大量公开 API 注释和语义回归测试
- 更新中文和英文 README,补充示例、精度说明、SVG 配图
2026-05-01 22:38:44 +08:00

176 lines
5.0 KiB
Go

package basic
import (
"encoding/json"
"math"
"os"
"testing"
"time"
)
type earthApsisSample struct {
Kind string `json:"kind"`
Year int `json:"year"`
TimeUTC string `json:"time_utc"`
DistanceAU float64 `json:"distance_au"`
}
type moonApsisSample struct {
Kind string `json:"kind"`
Year int `json:"year"`
Month int `json:"month"`
TimeUTC string `json:"time_utc"`
DistanceKM float64 `json:"distance_km"`
}
type moonApsisMonthState struct {
perigees []ApsisEvent
apogees []ApsisEvent
perigeeI int
apogeeI int
}
func TestEarthApsisMatchesHorizonsBaseline(t *testing.T) {
data, err := os.ReadFile("testdata/earth_apsis_baseline.json")
if err != nil {
t.Fatalf("read baseline: %v", err)
}
var samples []earthApsisSample
if err := json.Unmarshal(data, &samples); err != nil {
t.Fatalf("decode baseline: %v", err)
}
const timeTolerance = 2 * time.Minute
const distanceToleranceAU = 5e-8
var maxTimeDiff time.Duration
var maxDistanceDiff float64
for _, sample := range samples {
wantTime, err := time.Parse(time.RFC3339Nano, sample.TimeUTC)
if err != nil {
t.Fatalf("parse sample time %q: %v", sample.TimeUTC, err)
}
var got ApsisEvent
switch sample.Kind {
case "perihelion":
got = EarthPerihelion(sample.Year)
case "aphelion":
got = EarthAphelion(sample.Year)
default:
t.Fatalf("unknown earth apsis kind %q", sample.Kind)
}
gotTime := JDE2DateByZone(got.JDE, time.UTC, false)
timeDiff := gotTime.Sub(wantTime)
if timeDiff < 0 {
timeDiff = -timeDiff
}
if timeDiff > maxTimeDiff {
maxTimeDiff = timeDiff
}
if timeDiff > timeTolerance {
t.Fatalf("%s %d time mismatch: got %s want %s tolerance %v", sample.Kind, sample.Year, gotTime.Format(time.RFC3339Nano), sample.TimeUTC, timeTolerance)
}
distanceDiff := math.Abs(got.Distance - sample.DistanceAU)
if distanceDiff > maxDistanceDiff {
maxDistanceDiff = distanceDiff
}
if distanceDiff > distanceToleranceAU {
t.Fatalf("%s %d distance mismatch: got %.12f want %.12f tolerance %.12f", sample.Kind, sample.Year, got.Distance, sample.DistanceAU, distanceToleranceAU)
}
}
t.Logf("earth apsis max diff: time=%v distance=%.12f AU", maxTimeDiff, maxDistanceDiff)
}
func TestMoonApsisMatchesHorizonsBaseline(t *testing.T) {
// Baseline is generated from JPL Horizons by scripts/generate_moon_apsis_baseline.sh.
data, err := os.ReadFile("testdata/moon_apsis_baseline.json")
if err != nil {
t.Fatalf("read baseline: %v", err)
}
var samples []moonApsisSample
if err := json.Unmarshal(data, &samples); err != nil {
t.Fatalf("decode baseline: %v", err)
}
const timeTolerance = 20 * time.Minute
const distanceToleranceKM = 50.0
states := make(map[int]*moonApsisMonthState)
var maxTimeDiff time.Duration
var maxDistanceDiff float64
for _, sample := range samples {
wantTime, err := time.Parse(time.RFC3339Nano, sample.TimeUTC)
if err != nil {
t.Fatalf("parse sample time %q: %v", sample.TimeUTC, err)
}
key := sample.Year*100 + sample.Month
state := states[key]
if state == nil {
state = &moonApsisMonthState{
perigees: MoonPerigees(sample.Year, time.Month(sample.Month)),
apogees: MoonApogees(sample.Year, time.Month(sample.Month)),
}
states[key] = state
}
var got ApsisEvent
switch sample.Kind {
case "perigee":
if state.perigeeI >= len(state.perigees) {
t.Fatalf("%04d-%02d missing perigee #%d", sample.Year, sample.Month, state.perigeeI+1)
}
got = state.perigees[state.perigeeI]
state.perigeeI++
case "apogee":
if state.apogeeI >= len(state.apogees) {
t.Fatalf("%04d-%02d missing apogee #%d", sample.Year, sample.Month, state.apogeeI+1)
}
got = state.apogees[state.apogeeI]
state.apogeeI++
default:
t.Fatalf("unknown moon apsis kind %q", sample.Kind)
}
gotTime := JDE2DateByZone(got.JDE, time.UTC, false)
timeDiff := gotTime.Sub(wantTime)
if timeDiff < 0 {
timeDiff = -timeDiff
}
if timeDiff > maxTimeDiff {
maxTimeDiff = timeDiff
}
if timeDiff > timeTolerance {
t.Fatalf("%s %04d-%02d time mismatch: got %s want %s tolerance %v", sample.Kind, sample.Year, sample.Month, gotTime.Format(time.RFC3339Nano), sample.TimeUTC, timeTolerance)
}
distanceDiff := math.Abs(got.Distance - sample.DistanceKM)
if distanceDiff > maxDistanceDiff {
maxDistanceDiff = distanceDiff
}
if distanceDiff > distanceToleranceKM {
t.Fatalf("%s %04d-%02d distance mismatch: got %.6f want %.6f tolerance %.6f", sample.Kind, sample.Year, sample.Month, got.Distance, sample.DistanceKM, distanceToleranceKM)
}
}
for key, state := range states {
year := key / 100
month := key % 100
if state.perigeeI != len(state.perigees) {
t.Fatalf("%04d-%02d unconsumed perigees: got %d of %d", year, month, state.perigeeI, len(state.perigees))
}
if state.apogeeI != len(state.apogees) {
t.Fatalf("%04d-%02d unconsumed apogees: got %d of %d", year, month, state.apogeeI, len(state.apogees))
}
}
t.Logf("moon apsis max diff: time=%v distance=%.6f km", maxTimeDiff, maxDistanceDiff)
}