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