16c62a97d5
- 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试
382 lines
16 KiB
Go
382 lines
16 KiB
Go
package basic
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
)
|
|
|
|
func TestSolarEclipseShadowInstantMatchesPackagedSamples(t *testing.T) {
|
|
result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{
|
|
StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0,
|
|
DisableRiseSetCurves: true,
|
|
})
|
|
if len(result.CentralShadowFootprints) < 3 {
|
|
t.Fatalf("central shadow samples=%d, want a full series", len(result.CentralShadowFootprints))
|
|
}
|
|
solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{})
|
|
checked := map[string]bool{}
|
|
for _, index := range []int{0, len(result.CentralShadowFootprints) / 2, len(result.CentralShadowFootprints) - 1} {
|
|
sampled := result.CentralShadowFootprints[index]
|
|
instant, ok := solver.ShadowAtJDE(sampled.JDE)
|
|
if !ok {
|
|
t.Fatalf("instant call reported no umbra at %v, packaged sample has %d segments",
|
|
sampled.JDE, len(sampled.Boundaries))
|
|
}
|
|
if instant.Closed != sampled.Closed {
|
|
t.Fatalf("closed mismatch at %v: instant=%v packaged=%v", sampled.JDE, instant.Closed, sampled.Closed)
|
|
}
|
|
if len(instant.Boundaries) != len(sampled.Boundaries) {
|
|
t.Fatalf("segments mismatch at %v: instant=%d packaged=%d",
|
|
sampled.JDE, len(instant.Boundaries), len(sampled.Boundaries))
|
|
}
|
|
for segmentIndex := range sampled.Boundaries {
|
|
if len(instant.Boundaries[segmentIndex]) != len(sampled.Boundaries[segmentIndex]) {
|
|
t.Fatalf("segment %d length mismatch at %v: instant=%d packaged=%d", segmentIndex, sampled.JDE,
|
|
len(instant.Boundaries[segmentIndex]), len(sampled.Boundaries[segmentIndex]))
|
|
}
|
|
for pointIndex := range sampled.Boundaries[segmentIndex] {
|
|
got := instant.Boundaries[segmentIndex][pointIndex]
|
|
want := sampled.Boundaries[segmentIndex][pointIndex]
|
|
if got.Longitude != want.Longitude || got.Latitude != want.Latitude {
|
|
t.Fatalf("point mismatch at %v segment %d point %d: got %.12f,%.12f want %.12f,%.12f",
|
|
sampled.JDE, segmentIndex, pointIndex,
|
|
got.Longitude, got.Latitude, want.Longitude, want.Latitude)
|
|
}
|
|
}
|
|
}
|
|
checked[instant.Topology.Signature()] = true
|
|
}
|
|
if len(checked) < 2 {
|
|
t.Fatalf("expected distinct topology signatures across the series, got %v", checked)
|
|
}
|
|
}
|
|
|
|
func TestSolarEclipseShadowInstantEmptyOffPath(t *testing.T) {
|
|
solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{})
|
|
// 2009-07-22 食甚前后 12 小时已经远离地球上的本影。
|
|
instant, ok := solver.ShadowAtJDE(JDCalc(2009, 7, 22) + 0.6)
|
|
if ok || !instant.Empty() {
|
|
t.Fatalf("off-path instant reported ok=%v empty=%v", ok, instant.Empty())
|
|
}
|
|
if instant.Topology.Signature() != "empty" {
|
|
t.Fatalf("empty topology signature=%q, want empty", instant.Topology.Signature())
|
|
}
|
|
if instant.DeltaTSeconds <= 0 {
|
|
t.Fatalf("off-path instant must still report the ΔT used, got %v", instant.DeltaTSeconds)
|
|
}
|
|
}
|
|
|
|
func TestSolarEclipseShadowTopologySignatureChangesAtHorizonCut(t *testing.T) {
|
|
result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{
|
|
StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0,
|
|
DisableRiseSetCurves: true,
|
|
})
|
|
solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{})
|
|
signatures := map[string]int{}
|
|
for _, sampled := range result.CentralShadowFootprints {
|
|
instant, ok := solver.ShadowAtJDE(sampled.JDE)
|
|
if !ok {
|
|
continue
|
|
}
|
|
signatures[instant.Topology.Signature()]++
|
|
}
|
|
closedSeen, horizonSeen := false, false
|
|
for signature := range signatures {
|
|
if len(signature) >= 7 && signature[:7] == "umbra-c" {
|
|
closedSeen = true
|
|
}
|
|
if len(signature) >= 7 && signature[:7] == "umbra-h" {
|
|
horizonSeen = true
|
|
}
|
|
}
|
|
if !closedSeen || !horizonSeen {
|
|
t.Fatalf("expected both self-closed and horizon-cut signatures, got %v", signatures)
|
|
}
|
|
}
|
|
|
|
func TestSolarEclipseStationStateMatchesLocalEclipse(t *testing.T) {
|
|
seed := JDCalc(2024, 4, 8)
|
|
const lon, lat = -96.8, 32.8
|
|
local := LocalSolarEclipse(seed, lon, lat, 0)
|
|
if !local.HasTotal {
|
|
t.Fatalf("expected a total eclipse at the test station, got type=%v magnitude=%.3f", local.Type, local.Magnitude)
|
|
}
|
|
solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{})
|
|
state := solver.StationStateAtJDE(local.GreatestEclipse, lon, lat, 0)
|
|
if math.Abs(state.SeparationDeg-local.Separation) > 1e-9 {
|
|
t.Fatalf("separation mismatch: station=%.12f local=%.12f", state.SeparationDeg, local.Separation)
|
|
}
|
|
if math.Abs(state.SunAltitudeDeg-local.SunAltitude) > 1e-9 || math.Abs(state.SunAzimuthDeg-local.SunAzimuth) > 1e-9 {
|
|
t.Fatalf("sun position mismatch: station=(%.9f,%.9f) local=(%.9f,%.9f)",
|
|
state.SunAltitudeDeg, state.SunAzimuthDeg, local.SunAltitude, local.SunAzimuth)
|
|
}
|
|
if math.Abs(state.Obscuration-local.Obscuration) > 1e-12 {
|
|
t.Fatalf("obscuration mismatch: station=%.12f local=%.12f", state.Obscuration, local.Obscuration)
|
|
}
|
|
if !state.InCentralPhase || !state.HasTotalPhase || state.HasAnnularPhase {
|
|
t.Fatalf("central phase flags wrong: %+v", state)
|
|
}
|
|
if !state.Visible {
|
|
t.Fatal("expected the Sun above the horizon at greatest eclipse")
|
|
}
|
|
}
|
|
|
|
func TestSolarEclipseShadowDeltaTMovesOnlyEarthRotation(t *testing.T) {
|
|
result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{
|
|
StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0,
|
|
DisableRiseSetCurves: true,
|
|
})
|
|
if len(result.CentralShadowFootprints) == 0 {
|
|
t.Fatal("no central shadow samples")
|
|
}
|
|
jde := result.CentralShadowFootprints[len(result.CentralShadowFootprints)/2].JDE
|
|
base := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{})
|
|
shifted := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{DeltaTSeconds: 100})
|
|
first, okFirst := base.ShadowAtJDE(jde)
|
|
second, okSecond := shifted.ShadowAtJDE(jde)
|
|
if !okFirst || !okSecond {
|
|
t.Fatalf("expected a footprint at %v (ok=%v/%v)", jde, okFirst, okSecond)
|
|
}
|
|
if first.JDE != second.JDE {
|
|
t.Fatalf("geometry instant moved with ΔT: %v vs %v", first.JDE, second.JDE)
|
|
}
|
|
if math.Abs(second.DeltaTSeconds-100) > 1e-12 || first.DeltaTSeconds == second.DeltaTSeconds {
|
|
t.Fatalf("ΔT not reported per solver: %.6f vs %.6f", first.DeltaTSeconds, second.DeltaTSeconds)
|
|
}
|
|
centroid := func(instant SolarEclipseShadowInstant) (float64, float64) {
|
|
var sumX, sumY, sumZ, count float64
|
|
for _, segment := range instant.Boundaries {
|
|
for _, point := range segment {
|
|
longitude, latitude := point.Longitude*rad, point.Latitude*rad
|
|
sumX += math.Cos(latitude) * math.Cos(longitude)
|
|
sumY += math.Cos(latitude) * math.Sin(longitude)
|
|
sumZ += math.Sin(latitude)
|
|
count++
|
|
}
|
|
}
|
|
length := math.Sqrt(sumX*sumX + sumY*sumY + sumZ*sumZ)
|
|
return math.Atan2(sumY/length, sumX/length) / rad, math.Asin(sumZ/length) / rad
|
|
}
|
|
firstLon, firstLat := centroid(first)
|
|
secondLon, secondLat := centroid(second)
|
|
shift := DeltaTGroundShiftKM(100-first.DeltaTSeconds, firstLat)
|
|
measured := solarEclipsePathDistanceKM(
|
|
SolarEclipsePathPoint{Longitude: firstLon, Latitude: firstLat},
|
|
SolarEclipsePathPoint{Longitude: secondLon, Latitude: secondLat},
|
|
)
|
|
if math.Abs(measured-shift) > 0.05*shift {
|
|
t.Fatalf("ΔT ground shift=%.1f km, want about %.1f km", measured, shift)
|
|
}
|
|
}
|
|
|
|
func BenchmarkSolarEclipseShadowAtJDE(b *testing.B) {
|
|
solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{})
|
|
result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{
|
|
StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0,
|
|
DisableRiseSetCurves: true,
|
|
})
|
|
jde := result.CentralShadowFootprints[len(result.CentralShadowFootprints)/2].JDE
|
|
b.ResetTimer()
|
|
for index := 0; index < b.N; index++ {
|
|
if _, ok := solver.ShadowAtJDE(jde + float64(index)*1e-9); !ok {
|
|
b.Fatal("no footprint")
|
|
}
|
|
}
|
|
}
|
|
|
|
func BenchmarkSolarEclipseStationStateAtJDE(b *testing.B) {
|
|
solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{})
|
|
jde := JDCalc(2024, 4, 8) + 0.78
|
|
b.ResetTimer()
|
|
for index := 0; index < b.N; index++ {
|
|
_ = solver.StationStateAtJDE(jde+float64(index)*1e-9, -96.8, 32.8, 0)
|
|
}
|
|
}
|
|
|
|
func TestSolarEclipseShadowClampsBoundaryPoints(t *testing.T) {
|
|
result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{
|
|
StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0,
|
|
DisableRiseSetCurves: true,
|
|
})
|
|
if len(result.CentralShadowFootprints) == 0 {
|
|
t.Fatal("no central shadow samples")
|
|
}
|
|
jde := result.CentralShadowFootprints[len(result.CentralShadowFootprints)/2].JDE
|
|
// 个位数的边界点数会产出无意义的"足迹"(真实案例:1 个点也能 ok=true),必须被夹到下限。
|
|
solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{BoundaryPoints: 1})
|
|
instant, ok := solver.ShadowAtJDE(jde)
|
|
if !ok {
|
|
t.Fatal("expected a footprint")
|
|
}
|
|
points := 0
|
|
for _, segment := range instant.Boundaries {
|
|
points += len(segment)
|
|
}
|
|
if points < solarEclipsePartialFootprintMinBoundaryPoints {
|
|
t.Fatalf("BoundaryPoints=1 produced %d vertices, want at least %d",
|
|
points, solarEclipsePartialFootprintMinBoundaryPoints)
|
|
}
|
|
upper := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{BoundaryPoints: 100000})
|
|
clamped, okClamped := upper.ShadowAtJDE(jde)
|
|
if !okClamped {
|
|
t.Fatal("expected a footprint with a clamped point count")
|
|
}
|
|
clampedPoints := 0
|
|
for _, segment := range clamped.Boundaries {
|
|
clampedPoints += len(segment)
|
|
}
|
|
if clampedPoints > solarEclipseShadowMaximumBoundaryPoints+2 {
|
|
t.Fatalf("BoundaryPoints=100000 produced %d vertices, want at most %d",
|
|
clampedPoints, solarEclipseShadowMaximumBoundaryPoints)
|
|
}
|
|
}
|
|
|
|
func TestSolarEclipseShadowHandleSeesDeltaTChange(t *testing.T) {
|
|
// 复用同一个句柄、中途覆盖进程级 ΔT:缓存里的贝塞尔轴带着由 ΔT 决定的 gst,而键
|
|
// 只有 jd,旧实现会返回"新 ΔT + 旧几何"(实测边界漂移 0 km,新建句柄却差 58 km)。
|
|
// Reusing one handle across a process-wide ΔT override used to replay the cached
|
|
// Besselian axis, whose gst depends on ΔT, and report the new ΔT with the old
|
|
// geometry (0 km drift where a fresh handle moved 58 km).
|
|
original := GetDeltaTFn()
|
|
defer SetDeltaTFn(original)
|
|
|
|
SetDeltaTFn(DefaultDeltaTv2)
|
|
// 取 2009-07-22 本影阶段内的一个真实时刻(与相邻测试同一取法)。
|
|
// Use a real instant inside the 2009-07-22 umbral phase, derived like the neighbour test.
|
|
samples := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{
|
|
StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0,
|
|
DisableRiseSetCurves: true,
|
|
})
|
|
if len(samples.CentralShadowFootprints) == 0 {
|
|
t.Fatal("no central shadow samples")
|
|
}
|
|
jde := samples.CentralShadowFootprints[len(samples.CentralShadowFootprints)/2].JDE
|
|
reused := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{})
|
|
before, okBefore := reused.ShadowAtJDE(jde)
|
|
if !okBefore {
|
|
t.Fatalf("no footprint at %v with the process ΔT", jde)
|
|
}
|
|
|
|
SetDeltaTFn(func(float64, bool) float64 { return 200 })
|
|
after, okAfter := reused.ShadowAtJDE(jde)
|
|
if !okAfter {
|
|
t.Fatalf("no footprint at %v after the ΔT override", jde)
|
|
}
|
|
fresh := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{})
|
|
want, okWant := fresh.ShadowAtJDE(jde)
|
|
if !okWant {
|
|
t.Fatalf("no footprint at %v from a fresh handle", jde)
|
|
}
|
|
if math.Abs(after.DeltaTSeconds-200) > 1e-12 || math.Abs(want.DeltaTSeconds-200) > 1e-12 {
|
|
t.Fatalf("ΔT not reported after the override: reused=%.6f fresh=%.6f",
|
|
after.DeltaTSeconds, want.DeltaTSeconds)
|
|
}
|
|
|
|
maxDelta := func(first, second SolarEclipseShadowInstant) float64 {
|
|
worst := 0.0
|
|
for index, segment := range first.Boundaries {
|
|
if index >= len(second.Boundaries) {
|
|
return math.Inf(1)
|
|
}
|
|
if len(segment) != len(second.Boundaries[index]) {
|
|
return math.Inf(1)
|
|
}
|
|
for pointIndex, point := range segment {
|
|
other := second.Boundaries[index][pointIndex]
|
|
worst = math.Max(worst, math.Abs(point.Longitude-other.Longitude))
|
|
worst = math.Max(worst, math.Abs(point.Latitude-other.Latitude))
|
|
}
|
|
}
|
|
return worst
|
|
}
|
|
if moved := maxDelta(before, after); moved < 1e-4 {
|
|
t.Fatalf("reused handle did not move with ΔT (max %.9f deg)", moved)
|
|
}
|
|
if drift := maxDelta(after, want); drift > 1e-9 {
|
|
t.Fatalf("reused handle differs from a fresh one by %.9f deg after the ΔT override", drift)
|
|
}
|
|
}
|
|
|
|
func TestSolarEclipseShadowPenumbraMatchesPackagedSamples(t *testing.T) {
|
|
result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{
|
|
StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0,
|
|
DisableRiseSetCurves: true,
|
|
})
|
|
if len(result.Footprints) == 0 {
|
|
t.Fatal("no partial footprints")
|
|
}
|
|
solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{
|
|
Kind: SolarEclipseShadowPenumbra, BoundaryPoints: 96,
|
|
})
|
|
sampled := result.Footprints[len(result.Footprints)/2]
|
|
instant, ok := solver.ShadowAtJDE(sampled.JDE)
|
|
if !ok {
|
|
t.Fatal("penumbra instant reported no footprint")
|
|
}
|
|
if instant.Kind != SolarEclipseShadowPenumbra {
|
|
t.Fatalf("kind=%v, want penumbra", instant.Kind)
|
|
}
|
|
if instant.Closed != sampled.Closed || len(instant.Boundaries) != len(sampled.Boundaries) {
|
|
t.Fatalf("shape mismatch: closed=%v/%v segments=%d/%d",
|
|
instant.Closed, sampled.Closed, len(instant.Boundaries), len(sampled.Boundaries))
|
|
}
|
|
for index := range sampled.Boundaries {
|
|
if len(instant.Boundaries[index]) != len(sampled.Boundaries[index]) {
|
|
t.Fatalf("segment %d length %d, want %d", index, len(instant.Boundaries[index]), len(sampled.Boundaries[index]))
|
|
}
|
|
for pointIndex := range sampled.Boundaries[index] {
|
|
got, want := instant.Boundaries[index][pointIndex], sampled.Boundaries[index][pointIndex]
|
|
// 空间加密的递归顺序会让同一时刻相差约 1e-12 度,取 1e-9 度(亚毫米)即可。
|
|
if math.Abs(got.Longitude-want.Longitude) > 1e-9 || math.Abs(got.Latitude-want.Latitude) > 1e-9 {
|
|
t.Fatalf("point %d/%d = %.12f,%.12f want %.12f,%.12f",
|
|
index, pointIndex, got.Longitude, got.Latitude, want.Longitude, want.Latitude)
|
|
}
|
|
}
|
|
}
|
|
if signature := instant.Topology.Signature(); len(signature) < 8 || signature[:8] != "penumbra" {
|
|
t.Fatalf("signature=%q, want a penumbra prefix", signature)
|
|
}
|
|
}
|
|
|
|
func TestSolarEclipseShadowKindChangesFootprint(t *testing.T) {
|
|
result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{
|
|
StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0,
|
|
DisableRiseSetCurves: true,
|
|
})
|
|
if len(result.CentralShadowFootprints) == 0 || len(result.Footprints) == 0 {
|
|
t.Fatal("missing samples")
|
|
}
|
|
jde := result.CentralShadowFootprints[len(result.CentralShadowFootprints)/2].JDE
|
|
umbra, okUmbra := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}).ShadowAtJDE(jde)
|
|
penumbra, okPenumbra := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{
|
|
Kind: SolarEclipseShadowPenumbra,
|
|
}).ShadowAtJDE(jde)
|
|
if !okUmbra || !okPenumbra {
|
|
t.Fatalf("expected both footprints (umbra=%v penumbra=%v)", okUmbra, okPenumbra)
|
|
}
|
|
if umbra.Topology.Vertices == 0 || penumbra.Topology.Vertices == 0 {
|
|
t.Fatal("empty topology")
|
|
}
|
|
umbraMin, umbraMax := longitudeRange(umbra)
|
|
penumbraMin, penumbraMax := longitudeRange(penumbra)
|
|
if penumbraMax-penumbraMin <= umbraMax-umbraMin {
|
|
t.Fatalf("penumbra span %.1f should exceed the umbra span %.1f",
|
|
penumbraMax-penumbraMin, umbraMax-umbraMin)
|
|
}
|
|
}
|
|
|
|
func longitudeRange(instant SolarEclipseShadowInstant) (float64, float64) {
|
|
minimum, maximum := 361.0, -361.0
|
|
for _, segment := range instant.Boundaries {
|
|
for _, point := range segment {
|
|
if point.Longitude < minimum {
|
|
minimum = point.Longitude
|
|
}
|
|
if point.Longitude > maximum {
|
|
maximum = point.Longitude
|
|
}
|
|
}
|
|
}
|
|
return minimum, maximum
|
|
}
|