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