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

316 lines
12 KiB
Go

package basic
import (
"math"
"testing"
"time"
)
func solarEclipseIsochroneTestSeed(t *testing.T, year int, month time.Month, day int) float64 {
t.Helper()
return TD2UT(Date2JDE(time.Date(year, month, day, 0, 0, 0, 0, time.UTC)), true)
}
func solarEclipseIsochroneTestSeedB(b *testing.B, year int, month time.Month, day int) float64 {
b.Helper()
return TD2UT(Date2JDE(time.Date(year, month, day, 0, 0, 0, 0, time.UTC)), true)
}
// 等时线必须与站心食甚定义自洽:支路上任意点由站心算法独立求出的食甚时刻等于该支路电平。
func TestSolarEclipseGreatestTimeContoursMatchLocalCircumstances(t *testing.T) {
for _, tc := range []struct {
name string
seed float64
first time.Time
count int
}{
{name: "2009Total", seed: solarEclipseIsochroneTestSeed(t, 2009, time.July, 22),
first: time.Date(2009, time.July, 22, 1, 30, 0, 0, time.UTC), count: 6},
{name: "2010Annular", seed: solarEclipseIsochroneTestSeed(t, 2010, time.January, 15),
first: time.Date(2010, time.January, 15, 5, 0, 0, 0, time.UTC), count: 6},
} {
t.Run(tc.name, func(t *testing.T) {
levels := make([]float64, 0, tc.count)
for index := 0; index < tc.count; index++ {
levels = append(levels, TD2UT(Date2JDE(tc.first.Add(time.Duration(index)*30*time.Minute)), true))
}
result := SolarEclipsePartialFootprintsNASABulletinSplitK(tc.seed, SolarEclipsePartialFootprintOptions{
StepDays: 10.0 / 1440.0,
BoundaryPoints: 24,
DisableRiseSetCurves: true,
GreatestTimeValues: levels,
})
if len(result.GreatestTimeContours) == 0 {
t.Fatal("no greatest-time contours")
}
verified := 0
worst := 0.0
for _, contour := range result.GreatestTimeContours {
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)
}
for _, segment := range contour.Segments {
// 独立回验很贵(每次是完整站心求解),按点数预算抽样而不是全量遍历;
// 每个时刻取值都要抽到,不能只看第一条等时线。
for index := 0; index < len(segment) && verified < 240; index += 1 + len(segment)/12 {
point := segment[index]
local := LocalSolarEclipseNASABulletinSplitK(
contour.JDE, point.Longitude, point.Latitude, 0,
)
if local.GreatestEclipse == 0 {
continue
}
delta := math.Abs(local.GreatestEclipse-contour.JDE) * 86400
if delta > worst {
worst = delta
}
verified++
}
}
}
if verified < 10 {
t.Fatalf("verified only %d points", verified)
}
t.Logf("%s: %d contours, %d points verified, worst %.2f s",
tc.name, len(result.GreatestTimeContours), verified, worst)
if worst > 3 {
t.Fatalf("isochrone disagrees with local greatest eclipse by %.2f s", worst)
}
})
}
}
func TestSolarEclipseGreatestTimeContoursDisabledByDefault(t *testing.T) {
seed := solarEclipseIsochroneTestSeed(t, 2009, time.July, 22)
result := SolarEclipsePartialFootprintsNASABulletinSplitK(seed, SolarEclipsePartialFootprintOptions{
StepDays: 10.0 / 1440.0, DisableRiseSetCurves: true,
})
if len(result.GreatestTimeContours) != 0 {
t.Fatalf("contours computed without a request: %d", len(result.GreatestTimeContours))
}
}
func TestSolarEclipseGreatestTimeContoursRejectOutOfWindowLevels(t *testing.T) {
seed := solarEclipseIsochroneTestSeed(t, 2009, time.July, 22)
result := SolarEclipsePartialFootprintsNASABulletinSplitK(seed, SolarEclipsePartialFootprintOptions{
StepDays: 10.0 / 1440.0,
DisableRiseSetCurves: true,
GreatestTimeValues: []float64{seed - 5, seed + 5, math.NaN()},
})
if len(result.GreatestTimeContours) != 0 {
t.Fatalf("accepted out-of-window levels: %d", len(result.GreatestTimeContours))
}
}
// 同一时刻取值的等时线必须是一条连通曲线:曾经因为"覆盖判据用点到顶点距离"而把同一条线
// 用多个种子各画一遍(四条重复支路、点数翻三倍)。
func TestSolarEclipseGreatestTimeContoursHaveSingleBranchPerLevel(t *testing.T) {
for _, tc := range []struct {
name string
seed float64
hour int
}{
{"2009Total", solarEclipseIsochroneTestSeed(t, 2009, time.July, 22), 2},
{"2024Total", solarEclipseIsochroneTestSeed(t, 2024, time.April, 8), 18},
{"2014NonCentral", solarEclipseIsochroneTestSeed(t, 2014, time.April, 29), 6},
} {
t.Run(tc.name, func(t *testing.T) {
// 用事件当天同一个世界时小时作为时刻取值。
seedTime := JDE2DateByZone(TD2UT(tc.seed, false), time.UTC, false)
eventLevel := TD2UT(Date2JDE(time.Date(seedTime.Year(), seedTime.Month(), seedTime.Day(), tc.hour, 0, 0, 0, time.UTC)), true)
result := SolarEclipsePartialFootprintsNASABulletinSplitK(tc.seed, SolarEclipsePartialFootprintOptions{
StepDays: 10.0 / 1440.0, BoundaryPoints: 24, DisableRiseSetCurves: true,
GreatestTimeValues: []float64{eventLevel},
})
if len(result.GreatestTimeContours) != 1 {
t.Fatalf("expected one contour, got %d", len(result.GreatestTimeContours))
}
contour := result.GreatestTimeContours[0]
if len(contour.Segments) != 1 {
t.Fatalf("contour %.6f has %d branches, want a single connected curve", contour.JDE, len(contour.Segments))
}
if points := len(contour.Segments[0]); points < 20 {
t.Fatalf("contour has only %d points", points)
}
})
}
}
// 按步长请求多个时刻取值时,每条等时线同样只能有一条支路。
func TestSolarEclipseGreatestTimeStepHasSingleBranchPerLevel(t *testing.T) {
for _, tc := range []struct {
name string
seed float64
}{
{"2009Total", solarEclipseIsochroneTestSeed(t, 2009, time.July, 22)},
{"2024Total", solarEclipseIsochroneTestSeed(t, 2024, time.April, 8)},
{"2014NonCentral", solarEclipseIsochroneTestSeed(t, 2014, time.April, 29)},
{"2010Annular", solarEclipseIsochroneTestSeed(t, 2010, time.January, 15)},
} {
t.Run(tc.name, func(t *testing.T) {
result := SolarEclipsePartialFootprintsNASABulletinSplitK(tc.seed, SolarEclipsePartialFootprintOptions{
StepDays: 10.0 / 1440.0, BoundaryPoints: 24, DisableRiseSetCurves: true,
GreatestTimeStep: 30 * time.Minute,
})
if len(result.GreatestTimeContours) < 2 {
t.Fatalf("step request produced %d contours", len(result.GreatestTimeContours))
}
branches := 0
for _, contour := range result.GreatestTimeContours {
branches += len(contour.Segments)
if len(contour.Segments) != 1 {
t.Fatalf("contour %.6f has %d branches, want a single connected curve", contour.JDE, len(contour.Segments))
}
}
if branches != len(result.GreatestTimeContours) {
t.Fatalf("%d branches for %d contours", branches, len(result.GreatestTimeContours))
}
})
}
}
// 延拓必须覆盖整个零集:0.5°×2° 细扫找到的每个根都要落在已绘折线一个覆盖容差内。
func TestSolarEclipseGreatestTimeContoursCoverZeroSet(t *testing.T) {
for _, tc := range []struct {
name string
seed float64
hour int
}{
{"2009Total", solarEclipseIsochroneTestSeed(t, 2009, time.July, 22), 2},
{"2014NonCentral", solarEclipseIsochroneTestSeed(t, 2014, time.April, 29), 6},
} {
t.Run(tc.name, func(t *testing.T) {
seedTime := JDE2DateByZone(TD2UT(tc.seed, false), time.UTC, false)
level := TD2UT(Date2JDE(time.Date(seedTime.Year(), seedTime.Month(), seedTime.Day(), tc.hour, 0, 0, 0, time.UTC)), true)
solver := newSolarEclipseSolver(CalcMoonSHByJDE(tc.seed, 0), SolarEclipseModelNASABulletinSplitK)
segments := solver.greatestTimeContourSegments(level)
if len(segments) == 0 {
t.Fatal("no isochrone for the requested level")
}
arc := solarEclipseGreatestTimeArc{evaluation: solver.magnitudeEvaluationAt(level)}
roots, missed := 0, 0
for latitude := -86.0; latitude <= 86.0; latitude += 2.0 {
previous := arc.residual(-180, latitude)
for longitude := -179.5; longitude <= 180; longitude += 0.5 {
current := arc.residual(longitude, latitude)
if finite(previous) && finite(current) && previous*current <= 0 {
left, right, leftValue := longitude-0.5, longitude, previous
for iteration := 0; iteration < 60; iteration++ {
middle := (left + right) / 2
value := arc.residual(middle, latitude)
if !finite(value) {
break
}
if math.Abs(value) <= greatestTimeContourResidualTolerance || right-left <= 1e-9 {
left, right = middle, middle
break
}
if leftValue*value <= 0 {
right = middle
} else {
left, leftValue = middle, value
}
}
root := (left + right) / 2
roots++
best := math.Inf(1)
for _, segment := range segments {
for index := 1; index < len(segment); index++ {
distance := greatestTimeContourPointSegmentKM(
root, latitude,
segment[index-1].Longitude, segment[index-1].Latitude,
segment[index].Longitude, segment[index].Latitude,
)
if distance < best {
best = distance
}
}
}
if best > greatestTimeContourCoverToleranceKM {
missed++
}
}
previous = current
}
}
if roots < 10 {
t.Fatalf("fine scan found only %d roots", roots)
}
if missed > 0 {
t.Fatalf("%d of %d zero-set roots are farther than %.0f km from the traced isochrone", missed, roots, greatestTimeContourCoverToleranceKM)
}
})
}
}
// 按步长请求时:取值对齐到 UTC 整刻度、落在偏食窗口内、数量受上限保护。
func TestSolarEclipseGreatestTimeStepAlignsAndCapsLevels(t *testing.T) {
seed := solarEclipseIsochroneTestSeed(t, 2009, time.July, 22)
result := SolarEclipsePartialFootprintsNASABulletinSplitK(seed, SolarEclipsePartialFootprintOptions{
StepDays: 10.0 / 1440.0, BoundaryPoints: 24, DisableRiseSetCurves: true,
GreatestTimeStep: 30 * time.Minute,
})
// 对齐网格的取值是固定契约,写成字面量以免与被测的取值生成函数互相自证。
want := []float64{
2455034.542432685, 2455034.563266018, 2455034.584099352, 2455034.604932685,
2455034.625766018, 2455034.646599352, 2455034.667432685,
}
if len(result.GreatestTimeContours) != len(want) {
t.Fatalf("step request produced %d contours, want %d", len(result.GreatestTimeContours), len(want))
}
// 独立重算网格:只用导出的 TT/UTC 换算与整刻度截断,不经过被测的取值生成函数。
begin := JDE2DateByZone(TD2UT(result.Eclipse.PartialBeginOnEarth, false), time.UTC, false)
first := begin.Truncate(30 * time.Minute)
if first.Before(begin) {
first = first.Add(30 * time.Minute)
}
end := JDE2DateByZone(TD2UT(result.Eclipse.PartialEndOnEarth, false), time.UTC, false)
ticked := 0
for current := first; !current.After(end); current = current.Add(30 * time.Minute) {
ticked++
}
// 没有支路的取值会被丢掉,所以返回的是独立重算网格的子集,但必须是子集。
if ticked < len(want) {
t.Fatalf("独立重算的对齐网格只有 %d 个刻度,容不下 %d 条等时线", ticked, len(want))
}
for index, contour := range result.GreatestTimeContours {
if math.Abs(contour.JDE-want[index]) > 1e-9 {
t.Fatalf("contour %d JDE = %.9f, want %.9f", index, contour.JDE, want[index])
}
if len(contour.Segments) == 0 {
t.Fatalf("contour %d has no branches", index)
}
}
// 上限:给一个极细的步长,返回的条数不得超过共享上限。
tiny := SolarEclipsePartialFootprintsNASABulletinSplitK(seed, SolarEclipsePartialFootprintOptions{
StepDays: 10.0 / 1440.0, BoundaryPoints: 24, DisableRiseSetCurves: true,
GreatestTimeStep: time.Minute,
})
if len(tiny.GreatestTimeContours) > greatestTimeContourMaxLevels {
t.Fatalf("level cap not applied: %d contours", len(tiny.GreatestTimeContours))
}
}
func BenchmarkSolarEclipseGreatestTimeContours(b *testing.B) {
seed := solarEclipseIsochroneTestSeedB(b, 2009, time.July, 22)
levels := make([]float64, 0, 6)
for index := 0; index < 6; index++ {
levels = append(levels, TD2UT(Date2JDE(time.Date(2009, time.July, 22, 1, 30, 0, 0, time.UTC).Add(time.Duration(index)*30*time.Minute)), true))
}
options := SolarEclipsePartialFootprintOptions{
StepDays: 10.0 / 1440.0, BoundaryPoints: 24, DisableRiseSetCurves: true,
GreatestTimeValues: levels,
}
for index := 0; index < b.N; index++ {
SolarEclipsePartialFootprintsNASABulletinSplitK(seed, options)
}
}