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

1243 lines
50 KiB
Go

package basic
import (
"fmt"
"math"
"testing"
"time"
)
func TestSolarEclipseCentralPathMatchesGlobalGreatest(t *testing.T) {
seedJDE := JDECalc(2024, 4, 8)
global := SolarEclipse(seedJDE)
path := SolarEclipseCentralPath(seedJDE, SolarEclipsePathOptions{StepDays: 5.0 / 1440.0})
if path.Eclipse.Type != global.Type {
t.Fatalf("type mismatch: got %s want %s", path.Eclipse.Type, global.Type)
}
if !path.Eclipse.HasCentral {
t.Fatalf("expected central eclipse path")
}
if len(path.CenterLine) == 0 {
t.Fatalf("expected center line points")
}
if len(path.NorthernLimit) == 0 || len(path.SouthernLimit) == 0 {
t.Fatalf("expected central path limits: north=%d south=%d", len(path.NorthernLimit), len(path.SouthernLimit))
}
assertSolarEclipseJDEClose(t, "Greatest.JDE", path.Greatest.JDE, global.GreatestEclipse, 1e-8)
assertSolarEclipseFloatClose(t, "Greatest.Longitude", path.Greatest.Longitude, global.GreatestLongitude, 1e-9)
assertSolarEclipseFloatClose(t, "Greatest.Latitude", path.Greatest.Latitude, global.GreatestLatitude, 1e-9)
assertSolarEclipseFloatClose(t, "Greatest.WidthKM", path.Greatest.WidthKM, global.PathWidthKM, 1e-9)
foundGreatest := false
for _, point := range path.CenterLine {
if math.Abs(point.JDE-global.GreatestEclipse) <= solarEclipsePathDuplicateTimeDays {
foundGreatest = true
break
}
}
if !foundGreatest {
t.Fatalf("center line should include greatest eclipse JDE %.12f", global.GreatestEclipse)
}
}
func TestSolarEclipseCentralPathTargetSpacingRefinesSamples(t *testing.T) {
seedJDE := JDECalc(2024, 4, 8)
coarse := SolarEclipseCentralPath(seedJDE, SolarEclipsePathOptions{StepDays: 20.0 / 1440.0})
refined := SolarEclipseCentralPath(seedJDE, SolarEclipsePathOptions{
StepDays: 20.0 / 1440.0,
TargetSpacingKM: 120,
})
if len(coarse.CenterLine) == 0 || len(refined.CenterLine) == 0 {
t.Fatalf("expected path points: coarse=%d refined=%d", len(coarse.CenterLine), len(refined.CenterLine))
}
if len(refined.CenterLine) <= len(coarse.CenterLine) {
t.Fatalf("target spacing should refine samples: coarse=%d refined=%d", len(coarse.CenterLine), len(refined.CenterLine))
}
for i := 1; i < len(refined.CenterLine); i++ {
distanceKM := solarEclipsePathDistanceKM(refined.CenterLine[i-1], refined.CenterLine[i])
if distanceKM > 120.1 {
t.Fatalf("segment %d too long: got %.6f km want <= 120.1 km", i, distanceKM)
}
}
}
func TestSolarEclipseCentralPathAdaptiveCurvature2543(t *testing.T) {
seed := JDECalc(2543, 10, 29)
path := SolarEclipseCentralPath(seed, SolarEclipsePathOptions{
StepDays: 20.0 / 1440.0,
TargetSpacingKM: 700,
})
if len(path.CenterLine) < 2 {
t.Fatalf("2543-10-29 center line has %d points, want at least two", len(path.CenterLine))
}
coarse := SolarEclipseCentralPath(seed, SolarEclipsePathOptions{StepDays: 20.0 / 1440.0})
if len(path.CenterLine) <= len(coarse.CenterLine) {
t.Fatalf("adaptive curvature refinement did not add samples: coarse=%d refined=%d",
len(coarse.CenterLine), len(path.CenterLine))
}
solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK)
tolerance := math.Max(solarEclipsePathMinimumCurvatureKM,
path.TargetSpacingKM*solarEclipsePathAdaptiveCurvatureFraction)
for index := 1; index < len(path.CenterLine); index++ {
start, end := path.CenterLine[index-1], path.CenterLine[index]
middle, ok := solver.centralPathPointAt((start.JDE + end.JDE) / 2)
if !ok {
continue
}
geodesicMiddle := solarEclipsePathSphericalInterpolate(start, end, 0.5)
if errorKM := solarEclipsePathDistanceKM(middle, geodesicMiddle); errorKM > tolerance+1e-6 {
t.Fatalf("segment %d midpoint error=%.6f km, want <= %.6f km", index, errorKM, tolerance)
}
}
}
func TestSolarEclipseCentralPathLimitsKeepValidBoundarySamples(t *testing.T) {
path := SolarEclipseCentralPath(JDECalc(2008, 8, 1), SolarEclipsePathOptions{
StepDays: 2.0 / 1440.0,
})
if len(path.CenterLine) < 3 || len(path.NorthernLimit) < 3 || len(path.NorthernLimit) != len(path.SouthernLimit) {
t.Fatalf("unexpected 2008 path samples: center=%d north=%d south=%d", len(path.CenterLine), len(path.NorthernLimit), len(path.SouthernLimit))
}
if path.NorthernLimit[0].JDE >= path.CenterLine[0].JDE ||
path.NorthernLimit[len(path.NorthernLimit)-1].JDE <= path.CenterLine[len(path.CenterLine)-1].JDE {
t.Fatal("central limit samples should include the external contact caps")
}
if solarEclipsePathDistanceKM(path.NorthernLimit[0], path.SouthernLimit[0]) > 1 ||
solarEclipsePathDistanceKM(path.NorthernLimit[len(path.NorthernLimit)-1], path.SouthernLimit[len(path.SouthernLimit)-1]) > 1 {
t.Fatal("external contact caps should collapse to one contact point")
}
for index := 1; index < len(path.NorthernLimit); index++ {
if solarEclipsePathDistanceKM(path.NorthernLimit[index-1], path.NorthernLimit[index]) > 500 {
t.Fatalf("northern limit branch jumps at %d", index)
}
if solarEclipsePathDistanceKM(path.SouthernLimit[index-1], path.SouthernLimit[index]) > 500 {
t.Fatalf("southern limit branch jumps at %d", index)
}
}
}
func TestSolarEclipseCentralPathLimitsRemainStrictlyOrdered19851101(t *testing.T) {
path := SolarEclipseCentralPath(JDECalc(1985, 11, 1), SolarEclipsePathOptions{
StepDays: 10.0 / 1440.0,
})
if len(path.CenterLine) < 2 || len(path.NorthernLimit) < 2 ||
len(path.NorthernLimit) != len(path.SouthernLimit) {
t.Fatalf("unexpected 1985-11-01 path sizes: center=%d north=%d south=%d",
len(path.CenterLine), len(path.NorthernLimit), len(path.SouthernLimit))
}
for index := 1; index < len(path.CenterLine); index++ {
if path.CenterLine[index].JDE <= path.CenterLine[index-1].JDE {
t.Fatalf("center-line times are not strictly increasing at %d", index)
}
}
for index := 1; index < len(path.NorthernLimit); index++ {
if path.NorthernLimit[index].JDE <= path.NorthernLimit[index-1].JDE ||
path.SouthernLimit[index].JDE <= path.SouthernLimit[index-1].JDE {
t.Fatalf("central-limit times are not strictly increasing at %d: north %.12f -> %.12f, south %.12f -> %.12f",
index, path.NorthernLimit[index-1].JDE, path.NorthernLimit[index].JDE,
path.SouthernLimit[index-1].JDE, path.SouthernLimit[index].JDE)
}
if math.Abs(path.NorthernLimit[index].JDE-path.SouthernLimit[index].JDE) > solarEclipsePathDuplicateTimeDays {
t.Fatalf("central-limit sample %d times do not match", index)
}
}
}
func TestSolarEclipseRiseSetRawTopologyAvoidsPolarTraceExplosion(t *testing.T) {
seed := JDECalc(2309, 6, 9)
result := SolarEclipse(seed)
solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK)
curves, junctions, actualStep := solver.sampleRiseSetCurves(
result.PartialBeginOnEarth, result.PartialEndOnEarth, result.GreatestEclipse,
2.0/1440.0,
)
if !solarEclipseRiseSetRawTopologyUsable(curves) {
t.Fatalf("2309-06-09 sampled topology is not bounded: curves=%d", len(curves))
}
solver.finalizeRiseSetCurveTopology(curves, actualStep, junctions)
if !solarEclipseRiseSetCurveTopologyComplete(curves) {
t.Fatal("2309-06-09 sampled topology should close without continuation tracing")
}
}
func TestSolarEclipseNonCentralGreatestHorizonFoldsRemainTimedSegments(t *testing.T) {
for _, date := range [][3]int{
{1656, 7, 21},
{1928, 5, 19},
{1967, 11, 2},
} {
date := date
t.Run(fmt.Sprintf("%04d-%02d-%02d", date[0], date[1], date[2]), func(t *testing.T) {
result := SolarEclipsePartialFootprints(JDECalc(date[0], date[1], float64(date[2])), SolarEclipsePartialFootprintOptions{
StepDays: 2.0 / 1440.0, BoundaryPoints: 24, RiseSetStepDays: 2.0 / 1440.0,
})
if result.Eclipse.Type != SolarEclipseTotal || result.Eclipse.Centrality != SolarEclipseNonCentral {
t.Fatalf("type=%s centrality=%s, want non-central total", result.Eclipse.Type, result.Eclipse.Centrality)
}
greatestSegments := 0
for index := range result.RiseSetCurves {
curve := &result.RiseSetCurves[index]
if curve.Phase == RiseSetPhaseGreatest && curve.Direction == RiseSetDirectionRise {
if len(curve.Segments) > greatestSegments {
greatestSegments = len(curve.Segments)
}
}
if curve.Phase == RiseSetPhaseGreatest && curve.Direction == RiseSetDirectionSet {
if len(curve.Segments) > greatestSegments {
greatestSegments = len(curve.Segments)
}
}
for segmentIndex, segment := range curve.Segments {
if len(segment) < 2 {
t.Fatalf("curve %s/%s segment %d has %d points", curve.Phase, curve.Direction, segmentIndex, len(segment))
}
for pointIndex := 1; pointIndex < len(segment); pointIndex++ {
if segment[pointIndex].JDE <= segment[pointIndex-1].JDE+solarEclipseRiseSetTimeEpsilonDays {
t.Fatalf("curve %s/%s segment %d folds at %d: %.12f -> %.12f",
curve.Phase, curve.Direction, segmentIndex, pointIndex,
segment[pointIndex-1].JDE, segment[pointIndex].JDE)
}
}
}
}
if greatestSegments < 2 {
t.Fatalf("greatest horizon fold was not split into independent timed branches")
}
})
}
}
func TestSolarEclipseCentralPathPartialHasNoCenterLine(t *testing.T) {
path := SolarEclipseCentralPath(JDECalc(2025, 3, 29), SolarEclipsePathOptions{})
if path.Eclipse.Type != SolarEclipsePartial {
t.Fatalf("unexpected eclipse type: got %s want %s", path.Eclipse.Type, SolarEclipsePartial)
}
if path.Eclipse.HasCentral {
t.Fatalf("partial eclipse should not have central path")
}
if len(path.CenterLine) != 0 || len(path.NorthernLimit) != 0 || len(path.SouthernLimit) != 0 {
t.Fatalf(
"partial eclipse should not return central path points: center=%d north=%d south=%d",
len(path.CenterLine),
len(path.NorthernLimit),
len(path.SouthernLimit),
)
}
}
func TestSolarEclipsePartialFootprintsIncludeGreatest(t *testing.T) {
seedJDE := JDECalc(2024, 4, 8)
global := SolarEclipse(seedJDE)
footprints := SolarEclipsePartialFootprints(seedJDE, SolarEclipsePartialFootprintOptions{
StepDays: 30.0 / 1440.0,
BoundaryPoints: 72,
})
if footprints.Eclipse.Type != SolarEclipseTotal {
t.Fatalf("unexpected eclipse type: got %s want %s", footprints.Eclipse.Type, SolarEclipseTotal)
}
if footprints.BoundaryPoints != 72 {
t.Fatalf("boundary points mismatch: got %d want 72", footprints.BoundaryPoints)
}
if len(footprints.Footprints) == 0 {
t.Fatalf("expected partial footprints")
}
foundGreatest := false
for _, footprint := range footprints.Footprints {
if math.Abs(footprint.JDE-global.GreatestEclipse) <= solarEclipsePathDuplicateTimeDays {
foundGreatest = true
}
if len(footprint.Boundaries) == 0 {
t.Fatalf("footprint at %.12f has no boundaries", footprint.JDE)
}
for _, boundary := range footprint.Boundaries {
if len(boundary) == 0 {
t.Fatalf("footprint at %.12f has an empty boundary segment", footprint.JDE)
}
for _, point := range boundary {
if math.Abs(point.JDE-footprint.JDE) > 1e-12 {
t.Fatalf("point JDE mismatch: got %.12f want %.12f", point.JDE, footprint.JDE)
}
if point.Longitude < -180 || point.Longitude > 180 {
t.Fatalf("longitude out of range: %.9f", point.Longitude)
}
if point.Latitude < -90 || point.Latitude > 90 {
t.Fatalf("latitude out of range: %.9f", point.Latitude)
}
}
}
assertSolarEclipseFootprintClosedFlag(t, footprint)
}
if !foundGreatest {
t.Fatalf("partial footprints should include greatest eclipse JDE %.12f", global.GreatestEclipse)
}
if footprints.Footprints[0].Closed {
t.Fatal("grazing first footprint must remain open for horizon closure")
}
}
func TestSolarEclipsePartialFootprintBoundarySpacing20431003(t *testing.T) {
seedJDE := JDECalc(2043, 10, 3)
global := SolarEclipse(seedJDE)
solver := newSolarEclipseSolver(
CalcMoonSHByJDE(seedJDE, 0),
SolarEclipseModelNASABulletinSplitK,
)
footprints, _, _ := solver.partialFootprints(
global.PartialBeginOnEarth,
global.PartialEndOnEarth,
global.GreatestEclipse,
SolarEclipsePartialFootprintOptions{
StepDays: 2.0 / 1440.0,
BoundaryPoints: 96,
},
)
if len(footprints) == 0 {
t.Fatal("expected 2043-10-03 penumbral footprints")
}
maximumDistance := 0.0
var maximumTime float64
for _, footprint := range footprints {
for _, boundary := range footprint.Boundaries {
for index := 1; index < len(boundary); index++ {
distance := solarEclipsePathDistanceKM(boundary[index-1], boundary[index])
if distance > maximumDistance {
maximumDistance = distance
maximumTime = footprint.JDE
}
}
}
}
if maximumDistance > 250 {
t.Fatalf("2043-10-03 penumbral footprint has a %.1f km boundary chord at JDE %.12f, want <=250 km",
maximumDistance, maximumTime)
}
}
func TestSolarEclipseFootprintBoundaryBudgetIsDeterministic(t *testing.T) {
if got := solarEclipseEffectiveBoundaryPoints(1000, 1440); got != 1440 {
t.Fatalf("ordinary event boundary points=%d, want requested 1440", got)
}
if got := solarEclipseEffectiveBoundaryPoints(30000, 1440); got != 66 {
t.Fatalf("dense event boundary points=%d, want point-budget cap 66", got)
}
if got := solarEclipseMaximumBoundaryPoints(30000); got != 66 {
t.Fatalf("dense event maximum boundary points=%d, want 66", got)
}
if got := solarEclipseEffectiveBoundaryPoints(1, 4); got != solarEclipsePartialFootprintMinBoundaryPoints {
t.Fatalf("minimum boundary points=%d, want %d", got, solarEclipsePartialFootprintMinBoundaryPoints)
}
}
func TestSolarEclipseMagnitudeContourInputHasBoundedCardinality(t *testing.T) {
values := make([]float64, solarEclipseMagnitudeContourMaxValues+8)
for index := range values {
values[index] = float64(index+1) / 100
}
options := normalizeSolarEclipsePartialFootprintOptions(
SolarEclipsePartialFootprintOptions{MagnitudeValues: values},
)
if len(options.MagnitudeValues) != solarEclipseMagnitudeContourMaxValues {
t.Fatalf("magnitude values=%d, want %d", len(options.MagnitudeValues), solarEclipseMagnitudeContourMaxValues)
}
}
func TestSolarEclipsePartialBandContoursMeetHorizonFootprintTracks(t *testing.T) {
seed := JDECalc(2009, 7, 22)
result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{
StepDays: 2.0 / 1440.0, BoundaryPoints: 96,
})
if len(result.PartialBandContours) != 2 {
t.Fatalf("partial-band contours=%d, want two zero-magnitude envelopes", len(result.PartialBandContours))
}
solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK)
for contourIndex, contour := range result.PartialBandContours {
if len(contour) < 2 {
t.Fatalf("partial-band contour %d has %d points", contourIndex, len(contour))
}
for endpointIndex, endpoint := range []SolarEclipsePathPoint{contour[0], contour[len(contour)-1]} {
if math.Abs(endpoint.SunAltitude) > 1e-4 {
t.Fatalf("contour %d endpoint %d altitude=%.9f, want horizon", contourIndex, endpointIndex, endpoint.SunAltitude)
}
state := solver.localStateContextAt(endpoint.JDE).stateAt(endpoint.Longitude*rad, endpoint.Latitude*rad, 0)
if magnitude := solarEclipseLocalMagnitude(state); math.Abs(magnitude) > 2e-6 {
t.Fatalf("contour %d endpoint %d magnitude=%.9f, want zero", contourIndex, endpointIndex, magnitude)
}
minimum := math.Inf(1)
for _, curve := range result.RiseSetCurves {
if curve.Phase == RiseSetPhaseGreatest {
continue
}
for _, segment := range curve.Segments {
for _, point := range segment {
minimum = math.Min(minimum, solarEclipsePathDistanceKM(endpoint, point))
}
}
}
if minimum > 10 {
t.Fatalf("contour %d endpoint %d misses the start/end horizon network by %.3f km", contourIndex, endpointIndex, minimum)
}
}
}
}
func TestSolarEclipseDisableRiseSetAlsoSkipsPartialBandTopology(t *testing.T) {
result := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{
StepDays: 10.0 / 1440.0, BoundaryPoints: 24, DisableRiseSetCurves: true,
})
if len(result.RiseSetCurves) != 0 || len(result.PartialBandContours) != 0 {
t.Fatalf("disabled rise/set returned curves=%d partial-band contours=%d",
len(result.RiseSetCurves), len(result.PartialBandContours))
}
}
func TestSolarEclipseMagnitudeContoursIncludeNonCentralAnnularBand(t *testing.T) {
result := SolarEclipsePartialFootprints(JDECalc(2014, 4, 29), SolarEclipsePartialFootprintOptions{
StepDays: 10.0 / 1440.0,
BoundaryPoints: 24,
CentralShadowStepDays: 2.0 / 1440.0,
MagnitudeValues: []float64{0.4, 0.8, 1.0},
})
if result.Eclipse.Type != SolarEclipseAnnular || result.Eclipse.Centrality != SolarEclipseNonCentral {
t.Fatalf("unexpected eclipse classification: type=%s centrality=%s", result.Eclipse.Type, result.Eclipse.Centrality)
}
if len(result.CentralShadowFootprints) < 3 {
t.Fatalf("expected non-central antumbral footprints, got %d", len(result.CentralShadowFootprints))
}
if len(result.MagnitudeContours) != 2 {
t.Fatalf("expected two magnitude contours below the annular maximum, got %d", len(result.MagnitudeContours))
}
for _, contour := range result.MagnitudeContours {
if len(contour.Segments) == 0 {
t.Fatalf("magnitude %.2f has no continuous envelope segments", contour.Magnitude)
}
for segmentIndex, segment := range contour.Segments {
if len(segment) < 2 {
t.Fatalf("magnitude %.2f segment %d has %d points", contour.Magnitude, segmentIndex, len(segment))
}
for index := 1; index < len(segment); index++ {
if distance := solarEclipsePathDistanceKM(segment[index-1], segment[index]); distance > 1.1*solarEclipseMagnitudeContourTargetSpacingKM {
t.Fatalf("magnitude %.2f segment %d interval %d distance %.3f km",
contour.Magnitude, segmentIndex, index, distance)
}
}
}
}
}
func TestSolarEclipseMagnitudeContoursAllowTotalityValuesAboveOne(t *testing.T) {
result := SolarEclipsePartialFootprints(JDECalc(2024, 4, 8), SolarEclipsePartialFootprintOptions{
StepDays: 10.0 / 1440.0, MagnitudeValues: []float64{1.01},
})
if len(result.MagnitudeContours) != 1 || result.MagnitudeContours[0].Magnitude != 1.01 {
t.Fatalf("magnitude contours = %#v, want one contour at 1.01", result.MagnitudeContours)
}
}
func TestSolarEclipseMagnitudeContoursCoverHybridAndDeepTotalValues(t *testing.T) {
for _, test := range []struct {
name string
seed float64
values []float64
minimumSegmentLen int
}{
{name: "2023 hybrid", seed: JDECalc(2023, 4, 20), values: []float64{1.005, 1.01, 1.012}, minimumSegmentLen: 2},
{name: "2035 total", seed: JDECalc(2035, 9, 2), values: []float64{1.001, 1.01, 1.02}, minimumSegmentLen: 2},
} {
result := SolarEclipsePartialFootprints(test.seed, SolarEclipsePartialFootprintOptions{
StepDays: 10.0 / 1440.0, MagnitudeValues: test.values, DisableRiseSetCurves: true,
})
solver := newSolarEclipseSolver(CalcMoonSHByJDE(test.seed, 0), SolarEclipseModelNASABulletinSplitK)
if len(result.MagnitudeContours) != len(test.values) {
t.Fatalf("%s contours=%d, want %d", test.name, len(result.MagnitudeContours), len(test.values))
}
for _, contour := range result.MagnitudeContours {
if len(contour.Segments) == 0 {
t.Fatalf("%s magnitude %.3f has no segments", test.name, contour.Magnitude)
}
for segmentIndex, segment := range contour.Segments {
if len(segment) < test.minimumSegmentLen {
t.Fatalf("%s magnitude %.3f segment %d has %d points", test.name, contour.Magnitude, segmentIndex, len(segment))
}
for _, point := range segment {
evaluation := solarEclipseRiseSetEvaluation{jd: point.JDE, center: newLocalSolarEclipseStateContext(point.JDE, solver.params)}
state := evaluation.center.stateAt(point.Longitude*rad, point.Latitude*rad, 0)
if math.Abs(solarEclipseMagnitudeAtTarget(state, contour.Magnitude)-contour.Magnitude) > 2e-6 {
t.Fatalf("%s magnitude %.3f segment %d point magnitude=%.9f", test.name, contour.Magnitude, segmentIndex, solarEclipseMagnitudeAtTarget(state, contour.Magnitude))
}
}
}
}
}
}
func TestSolarEclipseHybridMagnitudeOneContoursMeetCenterLineTransitions(t *testing.T) {
for _, test := range []struct {
year, month, day int
transitions int
}{
{1827, 10, 20, 2},
{1845, 10, 30, 2},
{1854, 11, 20, 1},
{1909, 6, 17, 2},
{1986, 10, 3, 2},
{2013, 11, 3, 1},
{2023, 4, 20, 2},
{2172, 10, 17, 1},
} {
name := fmt.Sprintf("%04d-%02d-%02d", test.year, test.month, test.day)
t.Run(name, func(t *testing.T) {
seed := JDECalc(test.year, test.month, float64(test.day))
partial := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{
StepDays: 10.0 / 1440.0, MagnitudeValues: []float64{1}, DisableRiseSetCurves: true,
})
central := SolarEclipseCentralPath(seed, SolarEclipsePathOptions{StepDays: 2.0 / 1440.0})
if partial.Eclipse.Type != SolarEclipseHybrid || len(partial.MagnitudeContours) != 1 {
t.Fatalf("unexpected hybrid result: type=%s contours=%d", partial.Eclipse.Type, len(partial.MagnitudeContours))
}
segments := partial.MagnitudeContours[0].Segments
if len(segments) != 2 {
t.Fatalf("magnitude 1 segments=%d, want two", len(segments))
}
solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK)
transitions := solver.centralMagnitudeOneTransitions(central.CenterLine)
if len(transitions) != test.transitions {
t.Fatalf("center-line transitions=%d, want %d", len(transitions), test.transitions)
}
for transitionIndex, transition := range transitions {
centerMatches := 0
for _, point := range central.CenterLine {
if math.Abs(point.JDE-transition.JDE) <= solarEclipseRiseSetTimeEpsilonDays &&
solarEclipsePathDistanceKM(point, transition) <= 0.01 {
centerMatches++
}
}
if centerMatches != 1 {
t.Fatalf("transition %d center-line matches=%d, want one shared vertex", transitionIndex, centerMatches)
}
matches := 0
for _, segment := range segments {
for _, endpoint := range []SolarEclipsePathPoint{segment[0], segment[len(segment)-1]} {
if solarEclipsePathDistanceKM(endpoint, transition) <= 0.1 {
matches++
}
}
}
if matches != 2 {
t.Fatalf("transition %d endpoint matches=%d, want two", transitionIndex, matches)
}
}
})
}
}
func TestSolarEclipseHybridMagnitudeOneContoursKeepTheirCentralPathSide11440703(t *testing.T) {
seed := JDECalc(1144, 7, 3)
partial := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{
StepDays: 2.0 / 1440.0, MagnitudeValues: []float64{1}, DisableRiseSetCurves: true,
})
if partial.Eclipse.Type != SolarEclipseHybrid || len(partial.MagnitudeContours) != 1 {
t.Fatalf("unexpected hybrid result: type=%s contours=%d", partial.Eclipse.Type, len(partial.MagnitudeContours))
}
solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK)
for segmentIndex, segment := range partial.MagnitudeContours[0].Segments {
var branchSign float64
for pointIndex, point := range segment {
sign, ok := solver.magnitudeContourBranchSign(point)
if !ok {
continue
}
if branchSign != 0 && branchSign*sign < 0 {
t.Fatalf("segment %d switches central-path side at point %d", segmentIndex, pointIndex)
}
branchSign = sign
}
if branchSign == 0 {
t.Fatalf("segment %d has no resolved central-path side", segmentIndex)
}
}
}
func TestSolarEclipseMagnitudeContoursMatchLocalMaximumMagnitude(t *testing.T) {
tests := []struct {
year, month, day int
magnitude float64
}{
{2008, 8, 1, 0.2},
{2014, 4, 29, 0.4},
{2024, 4, 8, 0.2},
}
for _, test := range tests {
seed := JDECalc(test.year, test.month, float64(test.day))
result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{
StepDays: 10.0 / 1440.0, MagnitudeValues: []float64{test.magnitude},
})
if len(result.MagnitudeContours) != 1 || len(result.MagnitudeContours[0].Segments) == 0 {
t.Fatalf("%04d-%02d-%02d magnitude %.1f contour is absent", test.year, test.month, test.day, test.magnitude)
}
for _, segment := range result.MagnitudeContours[0].Segments {
stride := len(segment) / 5
if stride < 1 {
stride = 1
}
for index := 0; index < len(segment); index += stride {
point := segment[index]
local := LocalSolarEclipse(seed, point.Longitude, point.Latitude, 0)
if !local.HasPartial || math.Abs(local.Magnitude-test.magnitude) > 2e-4 {
t.Fatalf("%04d-%02d-%02d contour %.1f at %.6f, %.6f has local maximum %.9f",
test.year, test.month, test.day, test.magnitude, point.Longitude, point.Latitude, local.Magnitude)
}
}
}
}
}
func TestSolarEclipseMagnitudeContoursReachGreatestRiseSetBoundary(t *testing.T) {
seed := JDECalc(2031, 5, 21)
result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{
StepDays: 10.0 / 1440.0, MagnitudeValues: []float64{0.8},
})
if len(result.MagnitudeContours) != 1 {
t.Fatalf("magnitude contours=%d, want one", len(result.MagnitudeContours))
}
if len(result.MagnitudeContours[0].Segments) != 2 {
t.Fatalf("magnitude contour segments=%d, want two", len(result.MagnitudeContours[0].Segments))
}
solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK)
for segmentIndex, segment := range result.MagnitudeContours[0].Segments {
for pointIndex := 1; pointIndex < len(segment); pointIndex++ {
if distance := solarEclipsePathDistanceKM(segment[pointIndex-1], segment[pointIndex]); distance > 550 {
t.Fatalf("segment %d interval %d distance=%.3f km, want at most 550 km",
segmentIndex, pointIndex, distance)
}
}
for _, pointIndex := range []int{0, len(segment) - 1} {
point := segment[pointIndex]
evaluation := solarEclipseRiseSetEvaluation{
jd: point.JDE,
center: newLocalSolarEclipseStateContext(point.JDE, solver.params),
before: newLocalSolarEclipseStateContext(point.JDE-solarEclipseRiseSetDerivativeStepDays, solver.params),
after: newLocalSolarEclipseStateContext(point.JDE+solarEclipseRiseSetDerivativeStepDays, solver.params),
}
state := evaluation.center.stateAt(point.Longitude*rad, point.Latitude*rad, 0)
if math.Abs(state.sunAltitudeRad/rad) > 1e-5 {
t.Fatalf("segment %d endpoint %d Sun altitude=%.6f deg, want horizon",
segmentIndex, pointIndex, state.sunAltitudeRad/rad)
}
if math.Abs(solarEclipseLocalMagnitude(state)-0.8) > 2e-7 {
t.Fatalf("segment %d endpoint %d magnitude=%.9f, want 0.8",
segmentIndex, pointIndex, solarEclipseLocalMagnitude(state))
}
if math.Abs(evaluation.separationDerivative(point.Longitude, point.Latitude)) > 1e-8 {
t.Fatalf("segment %d endpoint %d greatest derivative=%.9g",
segmentIndex, pointIndex, evaluation.separationDerivative(point.Longitude, point.Latitude))
}
}
}
}
func TestSolarEclipseMagnitudeContourEndpointsAcrossEclipseTypes(t *testing.T) {
tests := []struct {
year, month, day int
magnitude float64
}{
{2008, 8, 1, 0.2},
{2014, 4, 29, 0.4},
{2024, 4, 8, 0.2},
{2025, 3, 29, 0.2},
}
for _, test := range tests {
seed := JDECalc(test.year, test.month, float64(test.day))
result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{
StepDays: 10.0 / 1440.0, MagnitudeValues: []float64{test.magnitude},
})
if len(result.MagnitudeContours) != 1 || len(result.MagnitudeContours[0].Segments) == 0 {
t.Fatalf("%04d-%02d-%02d contour count=%d", test.year, test.month, test.day, len(result.MagnitudeContours))
}
for segmentIndex, segment := range result.MagnitudeContours[0].Segments {
for _, pointIndex := range []int{0, len(segment) - 1} {
if altitude := math.Abs(segment[pointIndex].SunAltitude); altitude > 1e-5 {
t.Fatalf("%04d-%02d-%02d segment %d endpoint %d altitude=%.6f deg",
test.year, test.month, test.day, segmentIndex, pointIndex, altitude)
}
}
}
}
}
func TestSolarEclipseTotalMagnitudeOneContoursMeetHorizonClosures20260812(t *testing.T) {
result := SolarEclipsePartialFootprints(JDECalc(2026, 8, 12), SolarEclipsePartialFootprintOptions{
StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0,
MagnitudeValues: []float64{1},
})
if len(result.MagnitudeContours) != 1 || len(result.MagnitudeContours[0].Segments) != 2 {
t.Fatalf("magnitude-one contours=%d, want two segments", len(result.MagnitudeContours))
}
if len(result.CentralBandHorizonClosures) != 2 {
t.Fatalf("horizon closures=%d, want rise and set arcs", len(result.CentralBandHorizonClosures))
}
var endpoints []SolarEclipsePathPoint
for segmentIndex, segment := range result.MagnitudeContours[0].Segments {
if len(segment) < 2 {
t.Fatalf("segment %d has %d points", segmentIndex, len(segment))
}
for _, point := range []SolarEclipsePathPoint{segment[0], segment[len(segment)-1]} {
if math.Abs(point.SunAltitude) > 1e-5 {
t.Fatalf("segment %d endpoint altitude=%.9f degrees, want horizon", segmentIndex, point.SunAltitude)
}
endpoints = append(endpoints, point)
}
}
for closureIndex, closure := range result.CentralBandHorizonClosures {
for rootIndex, root := range []SolarEclipsePathPoint{closure[0], closure[len(closure)-1]} {
minimumDistance := math.Inf(1)
for _, endpoint := range endpoints {
minimumDistance = math.Min(minimumDistance, solarEclipsePathDistanceKM(root, endpoint))
}
if minimumDistance > 0.1 {
t.Fatalf("closure %d root %d is %.3f km from every magnitude-one endpoint", closureIndex, rootIndex, minimumDistance)
}
}
}
}
func TestSolarEclipseRiseSetJunctionsRemainConnected20100115(t *testing.T) {
result := SolarEclipsePartialFootprints(JDECalc(2010, 1, 15), SolarEclipsePartialFootprintOptions{
StepDays: 10.0 / 1440.0, BoundaryPoints: 180,
})
if len(result.RiseSetCurves) != 6 {
t.Fatalf("rise-set curve count=%d, want six", len(result.RiseSetCurves))
}
curveIndex := make(map[solarEclipseRiseSetCurveKey]int, len(result.RiseSetCurves))
for index, curve := range result.RiseSetCurves {
curveIndex[solarEclipseRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction}] = index
}
stepDays := 10.0 / 1440.0
direction := RiseSetDirectionRise
startIndex := curveIndex[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseStart, direction: direction}]
greatestIndex := curveIndex[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseGreatest, direction: direction}]
endIndex := curveIndex[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseEnd, direction: direction}]
for _, seed := range solarEclipseRiseSetUnsharedEndpoints(startIndex, result.RiseSetCurves[startIndex].Segments) {
for _, phaseIndex := range []int{greatestIndex, endIndex} {
if _, ok := solarEclipseClosestRiseSetEndpoint(
seed.point, phaseIndex, result.RiseSetCurves[phaseIndex].Segments, nil, stepDays,
); !ok {
t.Fatalf("%s seed at %.9f (%.4f, %.4f) has no phase junction in curve %d",
direction, seed.point.JDE, seed.point.Longitude, seed.point.Latitude, phaseIndex)
}
}
}
}
func TestSolarEclipseCentralPathLimitsIncludeExternalContacts20100115(t *testing.T) {
path := SolarEclipseCentralPath(
JDECalc(2010, 1, 15),
SolarEclipsePathOptions{StepDays: 10.0 / 1440.0, TargetSpacingKM: 100},
)
if len(path.NorthernLimit) < 2 || len(path.NorthernLimit) != len(path.SouthernLimit) {
t.Fatalf("central limits north=%d south=%d, want paired samples", len(path.NorthernLimit), len(path.SouthernLimit))
}
firstNorth := path.NorthernLimit[0]
firstSouth := path.SouthernLimit[0]
lastNorth := path.NorthernLimit[len(path.NorthernLimit)-1]
lastSouth := path.SouthernLimit[len(path.SouthernLimit)-1]
if solarEclipsePathDistanceKM(firstNorth, firstSouth) > 1 || solarEclipsePathDistanceKM(lastNorth, lastSouth) > 1 {
t.Fatalf("external limits should collapse at contacts: first=%.3f km last=%.3f km",
solarEclipsePathDistanceKM(firstNorth, firstSouth), solarEclipsePathDistanceKM(lastNorth, lastSouth))
}
if !(firstNorth.JDE < path.CenterLine[0].JDE && lastNorth.JDE > path.CenterLine[len(path.CenterLine)-1].JDE) {
t.Fatalf("contact caps must bracket center-line endpoints: first=%.12f center-first=%.12f center-last=%.12f last=%.12f",
firstNorth.JDE, path.CenterLine[0].JDE, path.CenterLine[len(path.CenterLine)-1].JDE, lastNorth.JDE)
}
}
func TestSolarEclipseCentralPathMeetsGreatestSetCurveAtExactLimit20100115(t *testing.T) {
seed := JDECalc(2010, 1, 15)
path := SolarEclipseCentralPath(seed, SolarEclipsePathOptions{
StepDays: 2.0 / 1440.0, TargetSpacingKM: 100,
})
if len(path.CenterLine) < 2 {
t.Fatalf("center line has %d points, want at least two", len(path.CenterLine))
}
last := path.CenterLine[len(path.CenterLine)-1]
// NASA's path-table Limits row is 36°49.6'N, 121°40.9'E with Sun altitude 0°.
if math.Abs(last.Longitude-121.6817) > 0.12 || math.Abs(last.Latitude-36.8267) > 0.12 {
t.Fatalf("center-line limit = (%.6f, %.6f), want NASA limit near (121.6817, 36.8267)",
last.Longitude, last.Latitude)
}
if math.Abs(last.SunAltitude) > 0.01 {
t.Fatalf("center-line limit Sun altitude = %.9f degrees, want horizon contact", last.SunAltitude)
}
footprints := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{
StepDays: 10.0 / 1440.0, BoundaryPoints: 24, RiseSetStepDays: 2.0 / 1440.0,
})
found := false
for _, curve := range footprints.RiseSetCurves {
if curve.Phase != RiseSetPhaseGreatest || curve.Direction != RiseSetDirectionSet {
continue
}
for _, segment := range curve.Segments {
for _, point := range segment {
if math.Abs(point.JDE-last.JDE) <= solarEclipseRiseSetTimeEpsilonDays &&
solarEclipsePathDistanceKM(point, last) <= 0.01 {
found = true
}
}
}
}
if !found {
t.Fatal("exact center-line limit is not a shared vertex of the greatest/set curve")
}
}
func TestSolarEclipseCentralPathContactsShareGreatestRiseSetVerticesAcrossTypes(t *testing.T) {
for _, date := range [][3]int{
{2008, 8, 1}, // total
{2010, 1, 15}, // annular
{2012, 5, 20}, // polar annular
{2023, 4, 20}, // hybrid and antimeridian
{2035, 9, 2}, // total
{2309, 6, 9}, // far-future sunset regression
} {
date := date
t.Run(fmt.Sprintf("%04d-%02d-%02d", date[0], date[1], date[2]), func(t *testing.T) {
seed := JDECalc(date[0], date[1], float64(date[2]))
path := SolarEclipseCentralPath(seed, SolarEclipsePathOptions{
StepDays: 2.0 / 1440.0, TargetSpacingKM: 200,
})
if len(path.CenterLine) < 2 {
t.Fatalf("center line has %d points, want at least two", len(path.CenterLine))
}
footprints := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{
StepDays: 30.0 / 1440.0, BoundaryPoints: 24, RiseSetStepDays: 5.0 / 1440.0,
})
contacts := []struct {
name string
point SolarEclipsePathPoint
direction RiseSetDirection
}{
{"begin", path.CenterLine[0], RiseSetDirectionRise},
{"end", path.CenterLine[len(path.CenterLine)-1], RiseSetDirectionSet},
}
for _, contact := range contacts {
if math.Abs(contact.point.SunAltitude) > 0.02 {
t.Fatalf("%s Sun altitude = %.9f degrees, want horizon limit", contact.name, contact.point.SunAltitude)
}
if !finite(contact.point.WidthKM) || contact.point.WidthKM <= 0 || contact.point.WidthKM > 5000 {
t.Fatalf("%s width = %.6f km, want a finite path width", contact.name, contact.point.WidthKM)
}
if !solarEclipseRiseSetCurvesContainPoint(
footprints.RiseSetCurves, contact.point, RiseSetPhaseGreatest, contact.direction,
) {
t.Fatalf("%s is not a shared greatest/%s vertex", contact.name, contact.direction)
}
}
})
}
}
func solarEclipseRiseSetCurvesContainPoint(
curves []SolarEclipseRiseSetCurve,
want SolarEclipsePathPoint,
phase RiseSetPhase,
direction RiseSetDirection,
) bool {
for _, curve := range curves {
if curve.Phase != phase || curve.Direction != direction {
continue
}
for _, segment := range curve.Segments {
for _, point := range segment {
if math.Abs(point.JDE-want.JDE) <= solarEclipseRiseSetTimeEpsilonDays &&
solarEclipsePathDistanceKM(point, want) <= 0.01 {
return true
}
}
}
}
return false
}
func TestSolarEclipseExactNonCentralContactsRecoverAtPolarLimb21410108(t *testing.T) {
seed := JDECalc(2141, 1, 8)
result := solarEclipse(seed, SolarEclipseModelNASABulletinSplitK)
if result.Type != SolarEclipseAnnular || result.Centrality != SolarEclipseNonCentral {
t.Fatalf("unexpected eclipse classification: type=%s centrality=%s", result.Type, result.Centrality)
}
solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK)
solver.exactCentralContact = true
first, last, ok := solver.shadowContactPair(result.GreatestEclipse, solarEclipseCentralShadow, false)
if !ok || first.JDE == 0 || last.JDE <= first.JDE {
t.Fatalf("non-central contact pair is unavailable: first=%.12f last=%.12f ok=%v", first.JDE, last.JDE, ok)
}
for name, point := range map[string]SolarEclipsePathPoint{"U1": first, "U4": last} {
if !finite(point.Longitude) || !finite(point.Latitude) ||
point.Longitude < -180 || point.Longitude > 180 || point.Latitude < -90 || point.Latitude > 90 {
t.Fatalf("%s polar-limb contact is invalid: %+v", name, point)
}
}
}
func BenchmarkSolarEclipseMagnitudeContours(b *testing.B) {
seed := JDECalc(2031, 5, 21)
global := solarEclipse(seed, SolarEclipseModelNASABulletinSplitK)
options := SolarEclipsePartialFootprintOptions{
StepDays: 2.0 / 1440.0, MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1.0},
}
b.ReportAllocs()
b.ResetTimer()
for iteration := 0; iteration < b.N; iteration++ {
solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK)
contours := solver.magnitudeContours(
global.PartialBeginOnEarth,
global.PartialEndOnEarth,
global.CentralBeginOnEarth,
global.CentralEndOnEarth,
global.GreatestEclipse,
options,
global.Magnitude,
global.Type == SolarEclipseHybrid,
)
if len(contours) != 4 {
b.Fatalf("magnitude contours=%d, want four", len(contours))
}
}
}
func BenchmarkSolarEclipsePartialFootprintsFull(b *testing.B) {
seed := JDECalc(2031, 5, 21)
options := SolarEclipsePartialFootprintOptions{
StepDays: 2.0 / 1440.0,
BoundaryPoints: 180,
CentralShadowStepDays: 10.0 / 1440.0,
MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1.0},
}
b.ReportAllocs()
for iteration := 0; iteration < b.N; iteration++ {
result := SolarEclipsePartialFootprints(seed, options)
if len(result.Footprints) == 0 || len(result.MagnitudeContours) != 4 || len(result.RiseSetCurves) == 0 {
b.Fatalf("incomplete full footprint result: footprints=%d contours=%d rise-set=%d",
len(result.Footprints), len(result.MagnitudeContours), len(result.RiseSetCurves))
}
}
}
func BenchmarkSolarEclipseNonCentralPartialFootprints20431003(b *testing.B) {
seed := JDECalc(2043, 10, 3)
options := SolarEclipsePartialFootprintOptions{
StepDays: 2.0 / 1440.0,
BoundaryPoints: 96,
CentralShadowStepDays: 2.0 / 1440.0,
MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1.0},
RiseSetStepDays: 2.0 / 1440.0,
}
b.ReportAllocs()
for iteration := 0; iteration < b.N; iteration++ {
result := SolarEclipsePartialFootprints(seed, options)
if result.Eclipse.Centrality != SolarEclipseNonCentral ||
len(result.CentralBandSegments) != 1 || len(result.Footprints) == 0 {
b.Fatalf("incomplete 2043 non-central result: centrality=%s bands=%d footprints=%d",
result.Eclipse.Centrality, len(result.CentralBandSegments), len(result.Footprints))
}
}
}
func TestSolarEclipsePartialFootprintsWorkForPartialOnlyEclipse(t *testing.T) {
footprints := SolarEclipsePartialFootprints(JDECalc(2025, 3, 29), SolarEclipsePartialFootprintOptions{
StepDays: 30.0 / 1440.0,
BoundaryPoints: 72,
})
if footprints.Eclipse.Type != SolarEclipsePartial {
t.Fatalf("unexpected eclipse type: got %s want %s", footprints.Eclipse.Type, SolarEclipsePartial)
}
if footprints.Eclipse.HasCentral {
t.Fatalf("partial-only eclipse should not have central path")
}
if len(footprints.Footprints) == 0 {
t.Fatalf("expected partial footprints for partial-only eclipse")
}
}
func TestSolarEclipseShadowContactsAgainstNASA2012Baseline(t *testing.T) {
result := SolarEclipsePartialFootprints(
solarEclipseUTToTTJDE(time.Date(2012, 5, 20, 0, 0, 0, 0, time.UTC)),
SolarEclipsePartialFootprintOptions{
StepDays: 30.0 / 1440.0,
BoundaryPoints: 72,
CentralShadowStepDays: 10.0 / 1440.0,
},
)
baseline := []struct {
name string
point SolarEclipsePathPoint
want time.Time
}{
{"P1", result.P1, time.Date(2012, 5, 20, 20, 56, 7, 0, time.UTC)},
{"P4", result.P4, time.Date(2012, 5, 21, 2, 49, 21, 500000000, time.UTC)},
{"U1", result.U1, time.Date(2012, 5, 20, 22, 6, 16, 600000000, time.UTC)},
{"U2", result.U2, time.Date(2012, 5, 20, 22, 11, 46, 400000000, time.UTC)},
{"U3", result.U3, time.Date(2012, 5, 21, 1, 33, 42, 800000000, time.UTC)},
{"U4", result.U4, time.Date(2012, 5, 21, 1, 39, 11, 200000000, time.UTC)},
}
for _, contact := range baseline {
if contact.point.JDE == 0 {
t.Fatalf("%s contact is absent", contact.name)
}
assertLocalSolarEclipseJDEClose(
t,
contact.name,
contact.point.JDE,
solarEclipseUTToTTJDE(contact.want),
3*time.Second,
)
if math.Abs(contact.point.SunAltitude) > 0.01 {
t.Fatalf("%s Sun altitude = %.9f degrees, want horizon contact", contact.name, contact.point.SunAltitude)
}
}
if result.P2.JDE != 0 || result.P3.JDE != 0 {
t.Fatalf("2012 eclipse unexpectedly has P2/P3 contacts: P2=%+v P3=%+v", result.P2, result.P3)
}
if len(result.CentralShadowFootprints) == 0 {
t.Fatal("expected sampled central-shadow footprints")
}
if math.Abs(result.CentralShadowStepDays-10.0/1440.0) > 1e-12 {
t.Fatalf("central shadow step = %.12f days, want ten minutes", result.CentralShadowStepDays)
}
for _, footprint := range result.CentralShadowFootprints {
if len(footprint.Boundaries) == 0 {
t.Fatalf("central-shadow footprint at %.12f has no boundary", footprint.JDE)
}
}
closedBandFootprints := 0
for _, footprint := range result.CentralBandFootprints {
if !footprint.Closed {
continue
}
closedBandFootprints++
if footprint.JDE < result.U2.JDE-solarEclipsePathDuplicateTimeDays ||
footprint.JDE > result.U3.JDE+solarEclipsePathDuplicateTimeDays {
t.Fatalf("closed central-band footprint %.12f is outside U2-U3", footprint.JDE)
}
}
if closedBandFootprints == 0 || closedBandFootprints > 17 {
t.Fatalf("closed central-band footprints=%d, want a bounded non-empty edge sample", closedBandFootprints)
}
}
func TestSolarEclipseShadowContactsIncludeP2P3WhenPenumbraEntersEarthDisk(t *testing.T) {
result := SolarEclipsePartialFootprints(
solarEclipseUTToTTJDE(time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC)),
SolarEclipsePartialFootprintOptions{StepDays: 30.0 / 1440.0, BoundaryPoints: 36},
)
for name, contacts := range map[string][]SolarEclipsePathPoint{
"penumbral": {result.P1, result.P2, result.P3, result.P4},
"central": {result.U1, result.U2, result.U3, result.U4},
} {
for index, contact := range contacts {
if contact.JDE == 0 {
t.Fatalf("%s contact %d is absent", name, index)
}
if index > 0 && !(contacts[index-1].JDE < contact.JDE) {
t.Fatalf("%s contacts out of order at %d: %.12f >= %.12f", name, index, contacts[index-1].JDE, contact.JDE)
}
}
}
if !(result.P1.JDE < result.U1.JDE && result.U4.JDE < result.P4.JDE) {
t.Fatalf("central shadow contacts must lie inside partial phase: P1=%v U1=%v U4=%v P4=%v",
result.P1.JDE, result.U1.JDE, result.U4.JDE, result.P4.JDE)
}
if result.CentralShadowFootprints != nil || result.CentralShadowStepDays != 0 {
t.Fatal("central-shadow footprints must remain disabled by default")
}
if len(result.CentralBandFootprints) == 0 || result.CentralBandStepDays <= 0 {
t.Fatal("default result must retain lightweight central-band end footprints")
}
}
func TestSolarEclipseCentralBandFootprintsCoverNonCentralAndOneLimitEvents(t *testing.T) {
for _, fixture := range []struct {
year, month, day int
centrality SolarEclipseCentrality
}{
{2003, 5, 31, SolarEclipseCentralOneLimit},
{2014, 4, 29, SolarEclipseNonCentral},
{2043, 4, 9, SolarEclipseNonCentral},
{2043, 10, 3, SolarEclipseNonCentral},
} {
result := SolarEclipsePartialFootprints(
JDECalc(fixture.year, fixture.month, float64(fixture.day)),
SolarEclipsePartialFootprintOptions{StepDays: 20.0 / 1440.0, BoundaryPoints: 24},
)
if result.Eclipse.Centrality != fixture.centrality {
t.Fatalf("%04d-%02d-%02d centrality=%s, want %s",
fixture.year, fixture.month, fixture.day, result.Eclipse.Centrality, fixture.centrality)
}
if len(result.CentralBandFootprints) < 2 {
t.Fatalf("%04d-%02d-%02d central-band footprints=%d, want at least two",
fixture.year, fixture.month, fixture.day, len(result.CentralBandFootprints))
}
if fixture.centrality == SolarEclipseNonCentral {
if len(result.CentralBandSegments) != 1 {
t.Fatalf("%04d-%02d-%02d central-band regions=%d, want one horizon-closed region",
fixture.year, fixture.month, fixture.day, len(result.CentralBandSegments))
}
solver := newSolarEclipseSolver(
CalcMoonSHByJDE(JDECalc(fixture.year, fixture.month, float64(fixture.day)), 0),
SolarEclipseModelNASABulletinSplitK,
)
horizonPoints := 0
junctions := 0
for segmentIndex, segment := range result.CentralBandSegments {
if len(segment) < 4 || solarEclipsePathDistanceKM(segment[0], segment[len(segment)-1]) > 0.01 {
t.Fatalf("%04d-%02d-%02d central-band sweep cell %d is not closed",
fixture.year, fixture.month, fixture.day, segmentIndex)
}
for pointIndex, point := range segment {
evaluation := solver.magnitudeEvaluationAt(point.JDE)
state := evaluation.center.stateAt(point.Longitude*rad, point.Latitude*rad, 0)
centralGap := state.separationRad - math.Abs(state.sunRadiusRad-state.moonInnerRadiusRad)
if math.Abs(state.sunAltitudeRad/rad) <= 1e-5 {
horizonPoints++
if centralGap > 1e-7 {
t.Fatalf("%04d-%02d-%02d central-band horizon point %d gap=%g, want <= 0",
fixture.year, fixture.month, fixture.day, pointIndex, centralGap)
}
if math.Abs(centralGap) <= 1e-7 &&
math.Abs(evaluation.separationDerivative(point.Longitude, point.Latitude)) <= 1e-8 {
junctions++
}
}
if pointIndex == 0 {
continue
}
maximumEdgeKM := 1.6 * solarEclipseCentralBandTargetSpacingKM
if result.Eclipse.Type == SolarEclipseTotal {
maximumEdgeKM = 1.1 * solarEclipseNonCentralTotalBandTargetSpacingKM
}
if distance := solarEclipsePathDistanceKM(segment[pointIndex-1], segment[pointIndex]); distance > maximumEdgeKM {
t.Fatalf("%04d-%02d-%02d central-band sweep cell %d edge %d = %.3f km",
fixture.year, fixture.month, fixture.day, segmentIndex, pointIndex-1, distance)
}
}
}
if horizonPoints < 4 {
t.Fatalf("%04d-%02d-%02d central-band horizon points=%d, want a sampled closing arc",
fixture.year, fixture.month, fixture.day, horizonPoints)
}
if junctions < 2 {
t.Fatalf("%04d-%02d-%02d central-band horizon/contact junctions=%d, want two",
fixture.year, fixture.month, fixture.day, junctions)
}
if err := auditSolarEclipseBandContainsFootprints(
result.CentralBandSegments, result.CentralBandFootprints,
); err != nil {
t.Fatalf("%04d-%02d-%02d central-band containment: %v",
fixture.year, fixture.month, fixture.day, err)
}
}
}
}
func TestSolarEclipseNonCentralBandFallsBackWhenCriticalEnvelopeLeaks(t *testing.T) {
result := SolarEclipsePartialFootprints(
JDECalc(1950, 3, 18),
SolarEclipsePartialFootprintOptions{
StepDays: 60.0 / 1440.0, BoundaryPoints: 24, DisableRiseSetCurves: true,
},
)
if result.Eclipse.Centrality != SolarEclipseNonCentral {
t.Fatalf("1950-03-18 centrality=%s, want non-central", result.Eclipse.Centrality)
}
if len(result.CentralBandSegments) != 0 {
t.Fatalf("leaking critical envelope was retained with %d segments", len(result.CentralBandSegments))
}
if err := auditSolarEclipseOpenBandSweep(result.CentralBandFootprints); err != nil {
t.Fatalf("open-footprint fallback is unusable: %v", err)
}
}
func TestSolarEclipsePartialBoundarySegmentsRemainClosedAcrossAntimeridian(t *testing.T) {
samples := []solarEclipsePartialBoundarySample{
{point: SolarEclipsePathPoint{Longitude: 170, Latitude: 20}, ok: true},
{point: SolarEclipsePathPoint{Longitude: -170, Latitude: 25}, ok: true},
{point: SolarEclipsePathPoint{Longitude: -160, Latitude: 10}, ok: true},
{point: SolarEclipsePathPoint{Longitude: 160, Latitude: 5}, ok: true},
}
boundaries, closed := solarEclipsePartialBoundarySegments(samples)
if !closed {
t.Fatal("complete spherical boundary must remain closed after antimeridian splitting")
}
if len(boundaries) < 2 {
t.Fatalf("expected antimeridian split, got %d boundary segment(s)", len(boundaries))
}
for index, boundary := range boundaries {
if len(boundary) < 2 {
t.Fatalf("antimeridian boundary %d has only %d point(s)", index, len(boundary))
}
first := boundary[0].Longitude
last := boundary[len(boundary)-1].Longitude
if math.Abs(first) != 180 && math.Abs(last) != 180 {
t.Fatalf("antimeridian boundary %d has no exact map-edge endpoint", index)
}
}
}
func TestSolarEclipsePartialFootprintsNoEvent(t *testing.T) {
footprints := SolarEclipsePartialFootprints(JDECalc(2023, 5, 15), SolarEclipsePartialFootprintOptions{})
if footprints.Eclipse.Type != SolarEclipseNone {
t.Fatalf("unexpected eclipse type: got %s want %s", footprints.Eclipse.Type, SolarEclipseNone)
}
if len(footprints.Footprints) != 0 {
t.Fatalf("no eclipse should not return footprints: got %d", len(footprints.Footprints))
}
}
func TestSolarEclipsePartialAreaCompatibilityWrapper(t *testing.T) {
seedJDE := JDECalc(2024, 4, 8)
options := SolarEclipsePartialAreaOptions{
StepDays: 30.0 / 1440.0,
BoundaryPoints: 72,
}
compat := SolarEclipsePartialArea(seedJDE, options)
primary := SolarEclipsePartialFootprints(seedJDE, options)
if compat.Eclipse.Type != primary.Eclipse.Type {
t.Fatalf("compat type mismatch: got %s want %s", compat.Eclipse.Type, primary.Eclipse.Type)
}
if len(compat.Footprints) != len(primary.Footprints) {
t.Fatalf("compat footprint count mismatch: got %d want %d", len(compat.Footprints), len(primary.Footprints))
}
}
func assertSolarEclipseFootprintClosedFlag(t *testing.T, footprint SolarEclipsePartialFootprint) {
t.Helper()
if !footprint.Closed {
return
}
if len(footprint.Boundaries) == 0 {
t.Fatal("closed footprint has no boundaries")
}
if len(footprint.Boundaries) > 1 {
return
}
boundary := footprint.Boundaries[0]
if len(boundary) < 2 {
t.Fatalf("closed footprint boundary too short: got %d", len(boundary))
}
first := boundary[0]
last := boundary[len(boundary)-1]
if first.Longitude != last.Longitude || first.Latitude != last.Latitude {
t.Fatalf("closed footprint boundary should repeat first point at end")
}
}