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

273 lines
12 KiB
Go

package eclipse
import (
"math"
"testing"
"time"
"b612.me/astro/moon/limb"
)
type lunarLimbCase struct {
name string
date time.Time
lon, lat float64
total bool
}
var lunarLimbCases = []lunarLimbCase{
{"2024-04-08 Dallas total", time.Date(2024, 4, 8, 18, 0, 0, 0, time.UTC), -96.8, 32.8, true},
{"2024-04-08 Torreon total", time.Date(2024, 4, 8, 18, 0, 0, 0, time.UTC), -103.43, 25.54, true},
{"2023-10-14 Albuquerque annular", time.Date(2023, 10, 14, 16, 0, 0, 0, time.UTC), -106.65, 35.08, false},
}
func lunarLimbContacts(info LocalSolarEclipseInfo) [4]time.Time {
return [4]time.Time{info.PartialStart, info.CentralStart, info.CentralEnd, info.PartialEnd}
}
// 月缘只应让接触时刻移动几秒:既要确实移动,又不能超过 15 s;食型与默认结果都不变。
func TestLocalSolarEclipseLunarLimbShiftsContacts(t *testing.T) {
limbed := NewWithInstance(nil).WithLunarLimb(limb.Default())
for _, c := range lunarLimbCases {
circular, ok := defaultModule.LocalSolarEclipseOnDate(c.date, c.lon, c.lat, 0)
if !ok || !circular.HasCentral || circular.HasTotal != c.total {
t.Fatalf("%s: circular type %v", c.name, circular.Type)
}
corrected, ok := limbed.LocalSolarEclipseOnDate(c.date, c.lon, c.lat, 0)
if !ok || corrected.Type != circular.Type {
t.Fatalf("%s: limb changed type %v -> %v", c.name, circular.Type, corrected.Type)
}
moved := false
a, b := lunarLimbContacts(circular), lunarLimbContacts(corrected)
for _, point := range corrected.ContactPoints {
want := b[map[string]int{"C1": 0, "C2": 1, "C3": 2, "C4": 3}[point.Label]]
if gap := point.Time.Sub(want).Seconds(); math.Abs(gap) > 0.05 {
t.Errorf("%s %s contact point off phase time by %.3f s", c.name, point.Label, gap)
}
}
if len(corrected.ContactPoints) != 4 {
t.Errorf("%s: %d contact points", c.name, len(corrected.ContactPoints))
}
for index := range a {
shift := b[index].Sub(a[index]).Seconds()
t.Logf("%s contact %d shift %+.3f s", c.name, index, shift)
if math.Abs(shift) > 15 {
t.Errorf("%s contact %d moved %.3f s", c.name, index, shift)
}
if math.Abs(shift) > 0.01 {
moved = true
}
}
if !moved {
t.Errorf("%s: lunar limb did not move any contact", c.name)
}
t.Logf("%s central duration circular %.3f s, limb %.3f s", c.name,
circular.CentralEnd.Sub(circular.CentralStart).Seconds(),
corrected.CentralEnd.Sub(corrected.CentralStart).Seconds())
// 选项里的月缘与模块设置等价;模块设置不影响原模块。
viaOptions, _ := defaultModule.LocalSolarEclipseOnDateWithOptions(c.date, c.lon, c.lat, 0,
SolarEclipseOptions{LunarLimb: limb.Default()})
if lunarLimbContacts(viaOptions) != b {
t.Errorf("%s: options limb differs from module limb", c.name)
}
again, _ := defaultModule.LocalSolarEclipseOnDate(c.date, c.lon, c.lat, 0)
if lunarLimbContacts(again) != a {
t.Errorf("%s: WithLunarLimb changed the original module", c.name)
}
}
}
// 2025-09-07 月全食紫金山天文台发布时刻(北京时间列换算 UT,0.1 分钟精度),只用于观察月缘修正的方向与量级。
func TestLunarEclipseLunarLimbAgainstPMO(t *testing.T) {
pmo := []struct {
label string
ut time.Time
}{
{"P1", time.Date(2025, 9, 7, 15, 26, 54, 0, time.UTC)},
{"U1", time.Date(2025, 9, 7, 16, 26, 48, 0, time.UTC)},
{"U2", time.Date(2025, 9, 7, 17, 30, 24, 0, time.UTC)},
{"U3", time.Date(2025, 9, 7, 18, 53, 12, 0, time.UTC)},
{"U4", time.Date(2025, 9, 7, 19, 56, 54, 0, time.UTC)},
{"P4", time.Date(2025, 9, 7, 20, 56, 36, 0, time.UTC)},
}
date := time.Date(2025, 9, 7, 18, 0, 0, 0, time.UTC)
plain := NewWithInstance(nil)
limbed := plain.WithLunarLimb(limb.Default())
for _, chauvenet := range []bool{false, true} {
var circular, corrected LunarEclipseInfo
if chauvenet {
circular, corrected = plain.ClosestLunarEclipseChauvenet(date), limbed.ClosestLunarEclipseChauvenet(date)
} else {
circular, corrected = plain.ClosestLunarEclipseDanjon(date), limbed.ClosestLunarEclipseDanjon(date)
}
if !corrected.Maximum.Equal(circular.Maximum) || corrected.Type != circular.Type {
t.Fatalf("chauvenet=%v: maximum or type changed", chauvenet)
}
c := []time.Time{circular.PenumbralStart, circular.PartialStart, circular.TotalStart, circular.TotalEnd, circular.PartialEnd, circular.PenumbralEnd}
l := []time.Time{corrected.PenumbralStart, corrected.PartialStart, corrected.TotalStart, corrected.TotalEnd, corrected.PartialEnd, corrected.PenumbralEnd}
var circErr, limbErr float64
for i, ref := range pmo {
ce := LunarEclipseInfoInUT1(circular)
le := LunarEclipseInfoInUT1(corrected)
cu := []time.Time{ce.PenumbralStart, ce.PartialStart, ce.TotalStart, ce.TotalEnd, ce.PartialEnd, ce.PenumbralEnd}[i]
lu := []time.Time{le.PenumbralStart, le.PartialStart, le.TotalStart, le.TotalEnd, le.PartialEnd, le.PenumbralEnd}[i]
shift := l[i].Sub(c[i]).Seconds()
de, dl := cu.Sub(ref.ut).Seconds(), lu.Sub(ref.ut).Seconds()
circErr += math.Abs(de)
limbErr += math.Abs(dl)
t.Logf("chauvenet=%v %s shift %+6.2f s PMO err circular %+6.1f s limb %+6.1f s", chauvenet, ref.label, shift, de, dl)
if math.Abs(shift) > 20 {
t.Errorf("chauvenet=%v %s: limb shift %.2f s too large", chauvenet, ref.label, shift)
}
}
t.Logf("chauvenet=%v PMO MAE circular %.2f s, limb %.2f s", chauvenet, circErr/6, limbErr/6)
// 紫金山按 Chauvenet 口径发布,只有该口径可比精度;Danjon 与之差约 90 s 属口径差。
if chauvenet && (limbErr > circErr || limbErr/6 > 2) {
t.Errorf("PMO MAE: limb %.2f s, circular %.2f s; want the limb no worse and within 2 s", limbErr/6, circErr/6)
}
}
}
// 2024-04-08 全球日食紫金山天文台发布 P1/P4(UT,整秒),只用于观察月缘修正的方向与量级。
func TestGlobalSolarEclipseLunarLimbAgainstPMO(t *testing.T) {
date := time.Date(2024, 4, 8, 18, 0, 0, 0, time.UTC)
plain := NewWithInstance(nil)
limbed := plain.WithLunarLimb(limb.Default())
circular, corrected := plain.ClosestSolarEclipse(date), limbed.ClosestSolarEclipse(date)
if !corrected.GreatestEclipse.Equal(circular.GreatestEclipse) || corrected.Type != circular.Type {
t.Fatal("greatest or type changed")
}
cu, lu := SolarEclipseInfoInUT1(circular), SolarEclipseInfoInUT1(corrected)
for _, c := range []struct {
label string
ref time.Time
circ, lb time.Time
}{
{"P1", time.Date(2024, 4, 8, 15, 42, 13, 0, time.UTC), cu.PartialBeginOnEarth, lu.PartialBeginOnEarth},
{"P4", time.Date(2024, 4, 8, 20, 52, 21, 0, time.UTC), cu.PartialEndOnEarth, lu.PartialEndOnEarth},
} {
shift := c.lb.Sub(c.circ).Seconds()
t.Logf("%s shift %+6.2f s PMO err circular %+6.2f s limb %+6.2f s", c.label, shift,
c.circ.Sub(c.ref).Seconds(), c.lb.Sub(c.ref).Seconds())
if math.Abs(shift) > 20 {
t.Errorf("%s: limb shift %.2f s too large", c.label, shift)
}
// 发布值取整秒,±0.5 s 的舍入淹没圆月面与月缘之差,只约束月缘结果贴近发布值。
if d := c.lb.Sub(c.ref).Seconds(); math.Abs(d) > 1.5 {
t.Errorf("%s: limb result %.2f s from PMO", c.label, d)
}
}
t.Logf("central duration circular %.2f s limb %.2f s", circular.CentralDuration.Seconds(), corrected.CentralDuration.Seconds())
}
// 偏食足迹里的全球结果与 P1/P4 必须与全球日食接口同源,不能只有可见域边界按月缘修正。
func TestPartialFootprintsLunarLimbMatchesGlobal(t *testing.T) {
date := time.Date(2024, 4, 8, 18, 0, 0, 0, time.UTC)
limbed := NewWithInstance(nil).WithLunarLimb(limb.Default())
global, ok := limbed.SolarEclipseOnDate(date)
if !ok || !global.LunarLimbApplied {
t.Fatal("global limb correction not applied")
}
footprints, ok := limbed.SolarEclipsePartialFootprints(date, SolarEclipsePartialFootprintOptions{})
if !ok || !footprints.PartialBoundaryLimbApplied {
t.Fatal("partial boundary limb correction not applied")
}
embedded := footprints.Eclipse
if !embedded.LunarLimbApplied ||
!embedded.PartialBeginOnEarth.Equal(global.PartialBeginOnEarth) ||
!embedded.PartialEndOnEarth.Equal(global.PartialEndOnEarth) ||
embedded.CentralDuration != global.CentralDuration {
t.Fatalf("embedded eclipse differs from global: applied=%v P1 %v/%v P4 %v/%v",
embedded.LunarLimbApplied, embedded.PartialBeginOnEarth, global.PartialBeginOnEarth,
embedded.PartialEndOnEarth, global.PartialEndOnEarth)
}
for _, c := range []struct {
label string
point SolarEclipsePathPoint
when time.Time
contact SolarEclipseLimbContact
}{
{"P1", footprints.P1, global.PartialBeginOnEarth, global.PartialBeginContact},
{"P4", footprints.P4, global.PartialEndOnEarth, global.PartialEndContact},
} {
if !c.contact.Defined {
continue
}
if !c.point.Time.Equal(c.when) || c.point.Longitude != c.contact.Longitude || c.point.Latitude != c.contact.Latitude {
t.Fatalf("%s: footprint contact %v (%.4f, %.4f) differs from global %v (%.4f, %.4f)",
c.label, c.point.Time, c.point.Longitude, c.point.Latitude, c.when, c.contact.Longitude, c.contact.Latitude)
}
}
}
// 月缘只平移全食带南北界,量级应为数百米到数公里。
func TestCentralPathLunarLimbShiftsLimits(t *testing.T) {
date := time.Date(2024, 4, 8, 18, 0, 0, 0, time.UTC)
plain := NewWithInstance(nil)
circular, ok := plain.SolarEclipseCentralPath(date, SolarEclipsePathOptions{})
if !ok {
t.Fatal("no central path")
}
corrected, ok := plain.WithLunarLimb(limb.Default()).SolarEclipseCentralPath(date, SolarEclipsePathOptions{})
if !ok || len(corrected.NorthernLimit) != len(circular.NorthernLimit) || len(corrected.SouthernLimit) != len(circular.SouthernLimit) {
t.Fatal("limit lengths changed")
}
for side, pair := range [][2][]SolarEclipsePathPoint{{circular.NorthernLimit, corrected.NorthernLimit}, {circular.SouthernLimit, corrected.SouthernLimit}} {
worst, sum, moved := 0.0, 0.0, 0
for i := range pair[0] {
a, b := pair[0][i], pair[1][i]
d := math.Hypot((a.Longitude-b.Longitude)*math.Cos(a.Latitude*math.Pi/180), a.Latitude-b.Latitude) * 111.2
if d > 0 {
moved++
}
sum += d
worst = math.Max(worst, d)
}
t.Logf("side %d: %d/%d points moved, mean %.3f km, max %.3f km", side, moved, len(pair[0]), sum/float64(len(pair[0])), worst)
if worst > 10 || moved == 0 {
t.Errorf("side %d: implausible limb shift (max %.3f km, moved %d)", side, worst, moved)
}
}
}
// 月缘让全食带变窄、时长变化在数秒以内;带宽与足迹必须与修正后的南北界同向。
func TestCentralPathLunarLimbWidthsAndDuration(t *testing.T) {
date := time.Date(2024, 4, 8, 18, 0, 0, 0, time.UTC)
plain := NewWithInstance(nil)
circular, ok := plain.SolarEclipseCentralPath(date, SolarEclipsePathOptions{})
if !ok {
t.Fatal("no central path")
}
corrected, ok := plain.WithLunarLimb(limb.Default()).SolarEclipseCentralPath(date, SolarEclipsePathOptions{})
if !ok || len(corrected.CenterLine) != len(circular.CenterLine) {
t.Fatal("center line changed")
}
narrower, total := 0, 0
worst := 0.0
for i := range circular.CenterLine {
a, b := circular.CenterLine[i].WidthKM, corrected.CenterLine[i].WidthKM
if a <= 0 || b <= 0 {
continue
}
total++
if b < a {
narrower++
}
worst = math.Max(worst, math.Abs(b-a))
}
t.Logf("width: %d/%d samples narrower, max change %.3f km; greatest %.3f -> %.3f km",
narrower, total, worst, circular.Greatest.WidthKM, corrected.Greatest.WidthKM)
t.Logf("max central duration %.2f s -> %.2f s",
circular.MaxCentralDuration.Seconds(), corrected.MaxCentralDuration.Seconds())
if worst > 10 || total == 0 {
t.Errorf("implausible width change %.3f km over %d samples", worst, total)
}
if d := math.Abs((corrected.MaxCentralDuration - circular.MaxCentralDuration).Seconds()); d > 10 {
t.Errorf("max duration moved by %.2f s", d)
}
if len(corrected.CentralBandSegments) != len(circular.CentralBandSegments) {
t.Fatal("band segment count changed")
}
}