Files
astro/basic/occultation_isochrone_test.go
T
b612 2bf8478639 feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
2026-09-17 12:27:40 +08:00

252 lines
9.5 KiB
Go

package basic
import (
"math"
"testing"
"time"
)
func occultationIsochroneTestStar() StarCoordinate {
return StarCoordinate{
ID: "Regulus",
RA: 152.09292,
Dec: 11.96719,
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
Frame: CoordinateFrameJ2000,
}
}
// 等时线必须与站心掩甚定义自洽:支路上任意点由站心事件搜索独立求出的掩甚时刻等于该支路电平。
func TestOccultationGreatestTimeContoursMatchLocalGreatest(t *testing.T) {
star := occultationIsochroneTestStar()
start := time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC)
end := time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC)
options := OccultationPathOptions{
Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 15 * time.Minute,
}
paths, err := FindStarOccultationPaths(start, end, star, options)
if err != nil || len(paths) == 0 {
t.Fatalf("no path with isochrones: %v", err)
}
contours := paths[0].GreatestTimeContours
if len(contours) < 4 {
t.Fatalf("expected a full isochrone fan, got %d contours", len(contours))
}
for _, contour := range contours {
if contour.Time.Second() != 0 || contour.Time.Nanosecond() != 0 || contour.Time.Minute()%15 != 0 {
t.Fatalf("contour time %s is not aligned to the requested step",
contour.Time.Format("15:04:05.000"))
}
}
verified := 0
worst := 0.0
for _, contour := range contours {
branchPoints := 0
for _, segment := range contour.Segments {
if len(segment) < 2 {
t.Fatalf("contour %.6f has a degenerate branch", contour.JDE)
}
branchPoints += len(segment)
}
// 弧长步长上限决定了单条等时线的点数上界;点数爆表意味着延拓没有真正前进。
if branchPoints > 4000 {
t.Fatalf("contour %.6f has %d points, continuation is not advancing", contour.JDE, branchPoints)
}
level := occultationTTToLocation(contour.JDE, time.UTC)
for _, segment := range contour.Segments {
for index := 0; index < len(segment) && verified < 80; index += 1 + len(segment)/8 {
point := segment[index]
infos, searchErr := FindStarOccultations(
level.Add(-2*time.Hour), level.Add(2*time.Hour), star,
point.Longitude, point.Latitude, 0, OccultationSearchOptions{},
)
if searchErr != nil || len(infos) == 0 {
continue
}
delta := math.Abs(infos[0].Greatest.Sub(point.Time).Seconds())
if delta > worst {
worst = delta
}
verified++
}
}
}
if verified < 10 {
t.Fatalf("verified only %d points", verified)
}
t.Logf("%d contours, %d points verified, worst %.2f s", len(contours), verified, worst)
if worst > 1 {
t.Fatalf("isochrone disagrees with local greatest occultation by %.2f s", worst)
}
}
func TestOccultationGreatestTimeContoursDisabledByDefault(t *testing.T) {
star := occultationIsochroneTestStar()
start := time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC)
end := time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC)
paths, err := FindStarOccultationPaths(start, end, star, OccultationPathOptions{
Step: 2 * time.Minute, DisableFootprints: true,
})
if err != nil || len(paths) == 0 {
t.Fatalf("no occultation path: %v", err)
}
if len(paths[0].GreatestTimeContours) != 0 {
t.Fatalf("contours computed without a request: %d", len(paths[0].GreatestTimeContours))
}
}
// 行星是有限盘面,等时线按外接触锥门控,必须与站心行星掩甚一致。
func TestPlanetOccultationGreatestTimeContoursMatchLocalGreatest(t *testing.T) {
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2025, time.February, 2, 0, 0, 0, 0, time.UTC)
options := OccultationPathOptions{
Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 30 * time.Minute,
}
paths, err := FindPlanetOccultationPaths(start, end, OccultationSaturn, options)
if err != nil || len(paths) == 0 {
t.Fatalf("no planetary path with isochrones: %v", err)
}
contours := paths[0].GreatestTimeContours
if len(contours) == 0 {
t.Fatal("no greatest-time contours")
}
verified := 0
worst := 0.0
for _, contour := range contours {
level := occultationTTToLocation(contour.JDE, time.UTC)
for _, segment := range contour.Segments {
if len(segment) < 2 {
t.Fatalf("contour %.6f has a degenerate branch", contour.JDE)
}
for index := 0; index < len(segment) && verified < 60; index += 1 + len(segment)/8 {
point := segment[index]
infos, searchErr := FindPlanetOccultations(
level.Add(-3*time.Hour), level.Add(3*time.Hour), OccultationSaturn,
point.Longitude, point.Latitude, 0, OccultationSearchOptions{},
)
if searchErr != nil || len(infos) == 0 {
continue
}
delta := math.Abs(infos[0].Greatest.Sub(point.Time).Seconds())
if delta > worst {
worst = delta
}
verified++
}
}
}
if verified < 10 {
t.Fatalf("verified only %d points", verified)
}
t.Logf("%d contours, %d points verified, worst %.2f s", len(contours), verified, worst)
if worst > 1 {
t.Fatalf("isochrone disagrees with local greatest occultation by %.2f s", worst)
}
}
// 同一时刻取值的等时线必须是单条连通曲线:曾因"覆盖判据用点到顶点距离"而被重复延拓
// (行星掩星每个取值 3 条重叠支路)。
func TestOccultationGreatestTimeContoursHaveSingleBranchPerLevel(t *testing.T) {
star := occultationIsochroneTestStar()
starPaths, err := FindStarOccultationPaths(
time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC),
time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC), star,
OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 15 * time.Minute},
)
if err != nil || len(starPaths) == 0 {
t.Fatalf("star: %v", err)
}
planetPaths, err := FindPlanetOccultationPaths(
time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC),
time.Date(2025, time.February, 2, 0, 0, 0, 0, time.UTC), OccultationSaturn,
OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 30 * time.Minute},
)
if err != nil || len(planetPaths) == 0 {
t.Fatalf("planet: %v", err)
}
for _, tc := range []struct {
name string
contours []OccultationGreatestTimeContour
}{
{"star", starPaths[0].GreatestTimeContours},
{"planet", planetPaths[0].GreatestTimeContours},
} {
if len(tc.contours) == 0 {
t.Fatalf("%s: no contours", tc.name)
}
for _, contour := range tc.contours {
if len(contour.Segments) != 1 {
t.Fatalf("%s contour %s has %d branches, want a single connected curve",
tc.name, contour.Time.Format("15:04"), len(contour.Segments))
}
}
}
}
// 显式时刻取值:按 TT 儒略日给出,回显时刻取整到毫秒,窗口外取值被丢弃,且同样受上限保护。
func TestOccultationGreatestTimeContoursAcceptExplicitValues(t *testing.T) {
star := occultationIsochroneTestStar()
start := time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC)
end := time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC)
base := OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 15 * time.Minute}
stepPaths, err := FindStarOccultationPaths(start, end, star, base)
if err != nil || len(stepPaths) == 0 || len(stepPaths[0].GreatestTimeContours) == 0 {
t.Fatalf("step request failed: %v", err)
}
stepContours := stepPaths[0].GreatestTimeContours
// 用步长路径得到的取值当显式取值,外加 8 个窗口外取值,检验回显与过滤。
values := make([]float64, 0, len(stepContours)+8)
for _, contour := range stepContours {
values = append(values, contour.JDE)
}
for index := 0; index < 8; index++ {
values = append(values, occultationTimeToTT(start.Add(-time.Duration(index+1)*time.Hour)))
}
explicitPaths, err := FindStarOccultationPaths(start, end, star, OccultationPathOptions{
Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeValues: values,
})
if err != nil || len(explicitPaths) == 0 {
t.Fatalf("explicit request failed: %v", err)
}
contours := explicitPaths[0].GreatestTimeContours
if len(contours) != len(stepContours) {
t.Fatalf("explicit values produced %d contours, step request produced %d", len(contours), len(stepContours))
}
for index, contour := range contours {
if contour.JDE != stepContours[index].JDE {
t.Fatalf("contour %d JDE %.9f does not match the requested value %.9f", index, contour.JDE, stepContours[index].JDE)
}
if contour.Time.Nanosecond() != 0 {
t.Fatalf("contour time %s is not rounded to the millisecond", contour.Time.Format("15:04:05.000000000"))
}
}
// 上限:72 个互不相同、都落在窗口内的取值,返回条数不得超过共享上限。
dense := make([]float64, 0, greatestTimeContourMaxLevels+8)
for index := 0; index < greatestTimeContourMaxLevels+8; index++ {
dense = append(dense, stepContours[0].JDE+float64(index)*1e-6)
}
cappedPaths, err := FindStarOccultationPaths(start, end, star, OccultationPathOptions{
Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeValues: dense,
})
if err != nil || len(cappedPaths) == 0 {
t.Fatalf("capped request failed: %v", err)
}
if count := len(cappedPaths[0].GreatestTimeContours); count > greatestTimeContourMaxLevels {
t.Fatalf("level cap not applied: %d contours", count)
}
}
func BenchmarkOccultationGreatestTimeContours(b *testing.B) {
star := occultationIsochroneTestStar()
start := time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC)
end := time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC)
options := OccultationPathOptions{
Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 15 * time.Minute,
}
for index := 0; index < b.N; index++ {
FindStarOccultationPaths(start, end, star, options)
}
}