044f2f4381
- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算 - 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询 - 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出 - 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解 - 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤 - 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持 - 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理 - 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
175 lines
4.9 KiB
Go
175 lines
4.9 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 := JD2DateByZone(got.JD, 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) {
|
|
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 := JD2DateByZone(got.JD, 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)
|
|
}
|