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