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