1188 lines
48 KiB
Go
1188 lines
48 KiB
Go
|
|
package basic
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"math"
|
||
|
|
"reflect"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestSolarEclipseRiseSetCurvesSatisfyLocalPhaseEquations(t *testing.T) {
|
||
|
|
result := SolarEclipsePartialFootprints(JDECalc(2024, 4, 8), SolarEclipsePartialFootprintOptions{
|
||
|
|
StepDays: 10.0 / 1440.0,
|
||
|
|
})
|
||
|
|
if len(result.RiseSetCurves) != 6 {
|
||
|
|
t.Fatalf("solar rise/set curve count = %d, want 6", len(result.RiseSetCurves))
|
||
|
|
}
|
||
|
|
solver := newSolarEclipseSolver(CalcMoonSHByJDE(JDECalc(2024, 4, 8), 0), SolarEclipseModelNASABulletinSplitK)
|
||
|
|
for _, curve := range result.RiseSetCurves {
|
||
|
|
for _, segment := range curve.Segments {
|
||
|
|
for _, point := range riseSetSolarTestSamples(segment) {
|
||
|
|
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-6 {
|
||
|
|
t.Fatalf("solar %s/%s altitude = %.9f deg", curve.Phase, curve.Direction, state.sunAltitudeRad/rad)
|
||
|
|
}
|
||
|
|
assertSolarRiseSetPhase(t, curve, point, state, evaluation)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSolarEclipseRiseSetCurvesCloseFoldsAndPhaseJunctions(t *testing.T) {
|
||
|
|
result := SolarEclipsePartialFootprints(JDECalc(2031, 5, 21), SolarEclipsePartialFootprintOptions{
|
||
|
|
StepDays: 2.0 / 1440.0,
|
||
|
|
})
|
||
|
|
if len(result.RiseSetCurves) != 6 {
|
||
|
|
t.Fatalf("solar rise/set curve count = %d, want 6", len(result.RiseSetCurves))
|
||
|
|
}
|
||
|
|
|
||
|
|
curves := make(map[solarEclipseRiseSetCurveKey]SolarEclipseRiseSetCurve, len(result.RiseSetCurves))
|
||
|
|
for _, curve := range result.RiseSetCurves {
|
||
|
|
key := solarEclipseRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction}
|
||
|
|
curves[key] = curve
|
||
|
|
if len(curve.Segments) != 2 {
|
||
|
|
t.Fatalf("solar %s/%s segments = %d, want 2", curve.Phase, curve.Direction, len(curve.Segments))
|
||
|
|
}
|
||
|
|
if !solarRiseSetSegmentsShareEndpoint(curve.Segments[0], curve.Segments[1]) {
|
||
|
|
t.Fatalf("solar %s/%s branches do not share their fold endpoint", curve.Phase, curve.Direction)
|
||
|
|
}
|
||
|
|
for segmentIndex, segment := range curve.Segments {
|
||
|
|
for pointIndex := 1; pointIndex < len(segment); pointIndex++ {
|
||
|
|
if distance := solarEclipsePathDistanceKM(segment[pointIndex-1], segment[pointIndex]); distance > 1.1*solarEclipseRiseSetTargetSpacingKM {
|
||
|
|
t.Fatalf("solar %s/%s segment %d interval %d distance = %.3f km",
|
||
|
|
curve.Phase, curve.Direction, segmentIndex, pointIndex, distance)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, direction := range []RiseSetDirection{RiseSetDirectionRise, RiseSetDirectionSet} {
|
||
|
|
start := curves[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseStart, direction: direction}]
|
||
|
|
greatest := curves[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseGreatest, direction: direction}]
|
||
|
|
end := curves[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseEnd, direction: direction}]
|
||
|
|
junctions := 0
|
||
|
|
for _, segment := range start.Segments {
|
||
|
|
if solarRiseSetSegmentSharesEndpointWithBoth(segment, greatest.Segments, end.Segments) {
|
||
|
|
junctions++
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if junctions != 2 {
|
||
|
|
t.Fatalf("solar %s phase junctions = %d, want 2", direction, junctions)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSolarEclipseRiseSetCurvesCloseFoldsWithAdditionalBranches(t *testing.T) {
|
||
|
|
result := SolarEclipsePartialFootprints(JDECalc(2010, 1, 15), SolarEclipsePartialFootprintOptions{
|
||
|
|
StepDays: 10.0 / 1440.0,
|
||
|
|
})
|
||
|
|
for _, phase := range []RiseSetPhase{RiseSetPhaseGreatest, RiseSetPhaseEnd} {
|
||
|
|
var curve SolarEclipseRiseSetCurve
|
||
|
|
for _, candidate := range result.RiseSetCurves {
|
||
|
|
if candidate.Phase == phase && candidate.Direction == RiseSetDirectionRise {
|
||
|
|
curve = candidate
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(curve.Segments) != 3 {
|
||
|
|
t.Fatalf("solar %s/rise segments = %d, want 3", phase, len(curve.Segments))
|
||
|
|
}
|
||
|
|
folds := 0
|
||
|
|
for first := 0; first < len(curve.Segments); first++ {
|
||
|
|
for second := first + 1; second < len(curve.Segments); second++ {
|
||
|
|
if solarRiseSetSegmentsShareEndpoint(curve.Segments[first], curve.Segments[second]) {
|
||
|
|
folds++
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if folds == 0 {
|
||
|
|
t.Fatalf("solar %s/rise additional branches do not share their fold endpoint", phase)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSolarEclipseRiseSetCurvesCloseSunsetPhaseJunctions20100115(t *testing.T) {
|
||
|
|
result := SolarEclipsePartialFootprints(JDECalc(2010, 1, 15), SolarEclipsePartialFootprintOptions{
|
||
|
|
StepDays: 10.0 / 1440.0,
|
||
|
|
})
|
||
|
|
curves := make(map[solarEclipseRiseSetCurveKey]SolarEclipseRiseSetCurve, len(result.RiseSetCurves))
|
||
|
|
for _, curve := range result.RiseSetCurves {
|
||
|
|
curves[solarEclipseRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction}] = curve
|
||
|
|
}
|
||
|
|
start := curves[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseStart, direction: RiseSetDirectionSet}]
|
||
|
|
greatest := curves[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseGreatest, direction: RiseSetDirectionSet}]
|
||
|
|
end := curves[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseEnd, direction: RiseSetDirectionSet}]
|
||
|
|
if got := solarRiseSetSharedPhaseJunctionCount(start.Segments, greatest.Segments, end.Segments); got != 1 {
|
||
|
|
t.Fatalf("sunset phase junctions=%d, want one shared endpoint", got)
|
||
|
|
}
|
||
|
|
if len(greatest.Segments) < 2 {
|
||
|
|
t.Fatalf("sunset greatest branches=%d, want a short branch through the phase junction", len(greatest.Segments))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSolarEclipseRiseSetCurvesCloseSunsetPhaseJunctions23090609(t *testing.T) {
|
||
|
|
result := SolarEclipsePartialFootprints(JDECalc(2309, 6, 9), SolarEclipsePartialFootprintOptions{
|
||
|
|
StepDays: 2.0 / 1440.0,
|
||
|
|
})
|
||
|
|
curves := make(map[solarEclipseRiseSetCurveKey]SolarEclipseRiseSetCurve, len(result.RiseSetCurves))
|
||
|
|
for _, curve := range result.RiseSetCurves {
|
||
|
|
curves[solarEclipseRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction}] = curve
|
||
|
|
}
|
||
|
|
start := curves[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseStart, direction: RiseSetDirectionSet}]
|
||
|
|
greatest := curves[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseGreatest, direction: RiseSetDirectionSet}]
|
||
|
|
end := curves[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseEnd, direction: RiseSetDirectionSet}]
|
||
|
|
if got := solarRiseSetSharedPhaseJunctionCount(start.Segments, greatest.Segments, end.Segments); got != 2 {
|
||
|
|
t.Fatalf("2309-06-09 sunset phase junctions=%d, want two shared endpoints", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSolarEclipseRiseSetCurveEndpointsAcrossEclipseTypes(t *testing.T) {
|
||
|
|
tests := []struct {
|
||
|
|
year, month, day int
|
||
|
|
directionJunctions int
|
||
|
|
}{
|
||
|
|
{2008, 8, 1, 3},
|
||
|
|
{2010, 1, 15, 3},
|
||
|
|
{2014, 4, 29, 3},
|
||
|
|
{2024, 4, 8, 0},
|
||
|
|
{2025, 3, 29, 3},
|
||
|
|
}
|
||
|
|
for _, test := range tests {
|
||
|
|
result := SolarEclipsePartialFootprints(
|
||
|
|
JDECalc(test.year, test.month, float64(test.day)),
|
||
|
|
SolarEclipsePartialFootprintOptions{StepDays: 2.0 / 1440.0},
|
||
|
|
)
|
||
|
|
if len(result.RiseSetCurves) != 6 {
|
||
|
|
t.Fatalf("%04d-%02d-%02d rise/set curve count = %d, want 6",
|
||
|
|
test.year, test.month, test.day, len(result.RiseSetCurves))
|
||
|
|
}
|
||
|
|
solver := newSolarEclipseSolver(
|
||
|
|
CalcMoonSHByJDE(JDECalc(test.year, test.month, float64(test.day)), 0),
|
||
|
|
SolarEclipseModelNASABulletinSplitK,
|
||
|
|
)
|
||
|
|
curves := make(map[solarEclipseRiseSetCurveKey]SolarEclipseRiseSetCurve, len(result.RiseSetCurves))
|
||
|
|
for _, curve := range result.RiseSetCurves {
|
||
|
|
key := solarEclipseRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction}
|
||
|
|
curves[key] = curve
|
||
|
|
if len(curve.Segments) == 2 && !solarRiseSetSegmentsShareEndpoint(curve.Segments[0], curve.Segments[1]) {
|
||
|
|
t.Fatalf("%04d-%02d-%02d %s/%s branches do not share their fold endpoint",
|
||
|
|
test.year, test.month, test.day, curve.Phase, curve.Direction)
|
||
|
|
}
|
||
|
|
for segmentIndex, segment := range curve.Segments {
|
||
|
|
for _, point := range []SolarEclipsePathPoint{segment[0], segment[len(segment)-1]} {
|
||
|
|
evaluation := solver.magnitudeEvaluationAt(point.JDE)
|
||
|
|
state := evaluation.center.stateAt(point.Longitude*rad, point.Latitude*rad, 0)
|
||
|
|
assertSolarRiseSetPhase(t, curve, point, state, evaluation)
|
||
|
|
}
|
||
|
|
for pointIndex := 1; pointIndex < len(segment); pointIndex++ {
|
||
|
|
if segment[pointIndex].JDE <= segment[pointIndex-1].JDE {
|
||
|
|
t.Fatalf("%04d-%02d-%02d %s/%s segment %d time is not increasing at %d",
|
||
|
|
test.year, test.month, test.day, curve.Phase, curve.Direction, segmentIndex, pointIndex)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if count := solarRiseSetDirectionJunctionCount(curves); count != test.directionJunctions {
|
||
|
|
t.Fatalf("%04d-%02d-%02d shared direction junctions = %d, want %d",
|
||
|
|
test.year, test.month, test.day, count, test.directionJunctions)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSolarEclipseNonCentralGreatestHorizonWithoutTimeFold(t *testing.T) {
|
||
|
|
seed := JDECalc(1957, 10, 23)
|
||
|
|
solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK)
|
||
|
|
for _, stepSeconds := range []float64{120, 5} {
|
||
|
|
t.Run(fmt.Sprintf("step_%gs", stepSeconds), func(t *testing.T) {
|
||
|
|
result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{
|
||
|
|
StepDays: 2.0 / 1440, BoundaryPoints: 24, RiseSetStepDays: stepSeconds / 86400,
|
||
|
|
})
|
||
|
|
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)
|
||
|
|
}
|
||
|
|
if !solarEclipseRiseSetCurveTopologyComplete(result.RiseSetCurves) {
|
||
|
|
t.Fatal("horizon branches must be time-ordered and share their endpoints")
|
||
|
|
}
|
||
|
|
curves := make(map[solarEclipseRiseSetCurveKey]SolarEclipseRiseSetCurve, len(result.RiseSetCurves))
|
||
|
|
for _, curve := range result.RiseSetCurves {
|
||
|
|
curves[solarEclipseRiseSetCurveKey{curve.Phase, curve.Direction}] = curve
|
||
|
|
}
|
||
|
|
// Non-central totality need not produce a time fold. Here the
|
||
|
|
// greatest branches meet at a sunrise/sunset transition instead.
|
||
|
|
for _, direction := range []RiseSetDirection{RiseSetDirectionRise, RiseSetDirectionSet} {
|
||
|
|
start := curves[solarEclipseRiseSetCurveKey{RiseSetPhaseStart, direction}]
|
||
|
|
greatest := curves[solarEclipseRiseSetCurveKey{RiseSetPhaseGreatest, direction}]
|
||
|
|
end := curves[solarEclipseRiseSetCurveKey{RiseSetPhaseEnd, direction}]
|
||
|
|
if len(greatest.Segments) != 1 {
|
||
|
|
t.Fatalf("greatest/%s branches=%d, want one", direction, len(greatest.Segments))
|
||
|
|
}
|
||
|
|
if count := solarRiseSetSharedPhaseJunctionCount(start.Segments, greatest.Segments, end.Segments); count != 1 {
|
||
|
|
t.Fatalf("%s phase junctions=%d, want one", direction, count)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
rise := curves[solarEclipseRiseSetCurveKey{RiseSetPhaseGreatest, RiseSetDirectionRise}].Segments[0]
|
||
|
|
set := curves[solarEclipseRiseSetCurveKey{RiseSetPhaseGreatest, RiseSetDirectionSet}].Segments[0]
|
||
|
|
if !solarRiseSetSameEndpoint(rise[len(rise)-1], set[0]) {
|
||
|
|
t.Fatal("greatest sunrise and sunset branches do not share their transition")
|
||
|
|
}
|
||
|
|
// Solve the horizon roots independently of path sampling and
|
||
|
|
// endpoint completion, so a missing branch cannot pass by count.
|
||
|
|
checked := 0
|
||
|
|
for jd := result.Eclipse.PartialBeginOnEarth; jd <= result.Eclipse.PartialEndOnEarth; jd += 60.0 / 86400 {
|
||
|
|
for key, roots := range solver.riseSetPointsAt(jd, 720) {
|
||
|
|
if key.phase != RiseSetPhaseGreatest {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
for _, root := range roots {
|
||
|
|
distance := math.Inf(1)
|
||
|
|
for _, segment := range curves[key].Segments {
|
||
|
|
for index := 1; index < len(segment); index++ {
|
||
|
|
distance = math.Min(distance, solarEclipseRiseSetChordDeviationKM(root, segment[index-1], segment[index]))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if distance > solarEclipseRiseSetChordToleranceKM {
|
||
|
|
t.Fatalf("greatest/%s root at %.9f is %.6f km from the exported curve", key.direction, jd, distance)
|
||
|
|
}
|
||
|
|
checked++
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if checked < 100 {
|
||
|
|
t.Fatalf("checked %d exact roots, want coverage across the event", checked)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func solarRiseSetDirectionJunctionCount(
|
||
|
|
curves map[solarEclipseRiseSetCurveKey]SolarEclipseRiseSetCurve,
|
||
|
|
) int {
|
||
|
|
count := 0
|
||
|
|
for _, phase := range []RiseSetPhase{RiseSetPhaseStart, RiseSetPhaseGreatest, RiseSetPhaseEnd} {
|
||
|
|
rise := curves[solarEclipseRiseSetCurveKey{phase: phase, direction: RiseSetDirectionRise}]
|
||
|
|
set := curves[solarEclipseRiseSetCurveKey{phase: phase, direction: RiseSetDirectionSet}]
|
||
|
|
for _, segment := range rise.Segments {
|
||
|
|
for _, point := range []SolarEclipsePathPoint{segment[0], segment[len(segment)-1]} {
|
||
|
|
if solarRiseSetEndpointInSegments(point, set.Segments) {
|
||
|
|
count++
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return count
|
||
|
|
}
|
||
|
|
|
||
|
|
func solarRiseSetSharedPhaseJunctionCount(
|
||
|
|
start, greatest, end [][]SolarEclipsePathPoint,
|
||
|
|
) int {
|
||
|
|
var junctions []SolarEclipsePathPoint
|
||
|
|
for _, segment := range start {
|
||
|
|
for _, point := range []SolarEclipsePathPoint{segment[0], segment[len(segment)-1]} {
|
||
|
|
if !solarRiseSetEndpointInSegments(point, greatest) || !solarRiseSetEndpointInSegments(point, end) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
duplicate := false
|
||
|
|
for _, junction := range junctions {
|
||
|
|
if solarRiseSetSameEndpoint(point, junction) {
|
||
|
|
duplicate = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !duplicate {
|
||
|
|
junctions = append(junctions, point)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return len(junctions)
|
||
|
|
}
|
||
|
|
|
||
|
|
func solarRiseSetSegmentsShareEndpoint(first, second []SolarEclipsePathPoint) bool {
|
||
|
|
for _, a := range []SolarEclipsePathPoint{first[0], first[len(first)-1]} {
|
||
|
|
for _, b := range []SolarEclipsePathPoint{second[0], second[len(second)-1]} {
|
||
|
|
if solarRiseSetSameEndpoint(a, b) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func solarRiseSetSegmentSharesEndpointWithBoth(
|
||
|
|
segment []SolarEclipsePathPoint,
|
||
|
|
firstCandidates, secondCandidates [][]SolarEclipsePathPoint,
|
||
|
|
) bool {
|
||
|
|
for _, point := range []SolarEclipsePathPoint{segment[0], segment[len(segment)-1]} {
|
||
|
|
if solarRiseSetEndpointInSegments(point, firstCandidates) && solarRiseSetEndpointInSegments(point, secondCandidates) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func solarRiseSetEndpointInSegments(point SolarEclipsePathPoint, segments [][]SolarEclipsePathPoint) bool {
|
||
|
|
for _, segment := range segments {
|
||
|
|
if solarRiseSetSameEndpoint(point, segment[0]) || solarRiseSetSameEndpoint(point, segment[len(segment)-1]) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func solarRiseSetSameEndpoint(first, second SolarEclipsePathPoint) bool {
|
||
|
|
return math.Abs(first.JDE-second.JDE) <= 1e-8 && solarEclipsePathDistanceKM(first, second) <= 0.01
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestOccultationRiseSetCurvesSatisfyStarAndPlanetEquations(t *testing.T) {
|
||
|
|
location := time.FixedZone("CST", 8*3600)
|
||
|
|
star := hr4799OccultationCoordinateForTest()
|
||
|
|
starPaths, err := FindStarOccultationPaths(
|
||
|
|
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
|
||
|
|
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
|
||
|
|
star, OccultationPathOptions{Step: 10 * time.Minute},
|
||
|
|
)
|
||
|
|
if err != nil || len(starPaths) != 1 {
|
||
|
|
t.Fatalf("star paths=%d err=%v, want one", len(starPaths), err)
|
||
|
|
}
|
||
|
|
starContextAt := func(tt float64) occultationRiseSetContext {
|
||
|
|
moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, -1)
|
||
|
|
targetRA, targetDec := starApparentRaDecGeocentric(tt, star)
|
||
|
|
return newOccultationRiseSetContext(
|
||
|
|
tt, moonRA, moonDec, HMoonAwayN(tt, -1),
|
||
|
|
targetRA, targetDec, 0, 0,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
assertOccultationRiseSetCurves(t, starPaths[0].RiseSetCurves, starContextAt)
|
||
|
|
|
||
|
|
config, _ := planetOccultationConfigFor(OccultationSaturn)
|
||
|
|
planetPaths, err := FindPlanetOccultationPaths(
|
||
|
|
time.Date(2024, 8, 21, 0, 0, 0, 0, time.UTC),
|
||
|
|
time.Date(2024, 8, 22, 0, 0, 0, 0, time.UTC),
|
||
|
|
OccultationSaturn, OccultationPathOptions{Step: 10 * time.Minute},
|
||
|
|
)
|
||
|
|
if err != nil || len(planetPaths) != 1 {
|
||
|
|
t.Fatalf("planet paths=%d err=%v, want one", len(planetPaths), err)
|
||
|
|
}
|
||
|
|
planetContextAt := func(tt float64) occultationRiseSetContext {
|
||
|
|
moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, -1)
|
||
|
|
targetRA, targetDec := config.apparentRaDecN(tt, -1)
|
||
|
|
return newOccultationRiseSetContext(
|
||
|
|
tt, moonRA, moonDec, HMoonAwayN(tt, -1), targetRA, targetDec,
|
||
|
|
config.earthDistanceN(tt, -1)*occultationPathAstronomicalUnitKM, config.equatorialRadiusKM,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
assertOccultationRiseSetCurves(t, planetPaths[0].RiseSetCurves, planetContextAt)
|
||
|
|
if !planetPaths[0].HasTotalBand || len(planetPaths[0].TotalRiseSetCurves) != 6 {
|
||
|
|
t.Fatalf("planet total rise/set curves=%d hasTotal=%v, want six inner-contact curves",
|
||
|
|
len(planetPaths[0].TotalRiseSetCurves), planetPaths[0].HasTotalBand)
|
||
|
|
}
|
||
|
|
assertOccultationRiseSetCurves(t, planetPaths[0].TotalRiseSetCurves, func(tt float64) occultationRiseSetContext {
|
||
|
|
return planetContextAt(tt).withInternalContact()
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestOccultationRiseSetSamplingIsIndependentAndOptional(t *testing.T) {
|
||
|
|
location := time.FixedZone("CST", 8*3600)
|
||
|
|
start := time.Date(2025, 6, 5, 0, 0, 0, 0, location)
|
||
|
|
end := start.Add(24 * time.Hour)
|
||
|
|
star := hr4799OccultationCoordinateForTest()
|
||
|
|
find := func(options OccultationPathOptions) StarOccultationPath {
|
||
|
|
t.Helper()
|
||
|
|
paths, err := FindStarOccultationPaths(start, end, star, options)
|
||
|
|
if err != nil || len(paths) != 1 {
|
||
|
|
t.Fatalf("star paths=%d err=%v, want one", len(paths), err)
|
||
|
|
}
|
||
|
|
return paths[0]
|
||
|
|
}
|
||
|
|
|
||
|
|
fine := find(OccultationPathOptions{Step: 10 * time.Second, RiseSetStep: 10 * time.Minute})
|
||
|
|
coarse := find(OccultationPathOptions{Step: 2 * time.Minute, RiseSetStep: 10 * time.Minute})
|
||
|
|
if !reflect.DeepEqual(fine.RiseSetCurves, coarse.RiseSetCurves) {
|
||
|
|
t.Fatal("rise/set curves changed when only the main path step changed")
|
||
|
|
}
|
||
|
|
disabled := find(OccultationPathOptions{Step: 2 * time.Minute, DisableRiseSet: true})
|
||
|
|
if len(disabled.RiseSetCurves) != 0 {
|
||
|
|
t.Fatalf("disabled rise/set curve count = %d, want zero", len(disabled.RiseSetCurves))
|
||
|
|
}
|
||
|
|
withoutFootprints := find(OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true})
|
||
|
|
if len(withoutFootprints.Footprints) != 0 {
|
||
|
|
t.Fatalf("disabled footprint count = %d, want zero", len(withoutFootprints.Footprints))
|
||
|
|
}
|
||
|
|
if len(withoutFootprints.BandFootprints) == 0 {
|
||
|
|
t.Fatal("disabled dense footprints did not retain compact band support")
|
||
|
|
}
|
||
|
|
if len(withoutFootprints.RiseSetCurves) != 6 {
|
||
|
|
t.Fatalf("rise/set curve count with disabled footprints = %d, want six", len(withoutFootprints.RiseSetCurves))
|
||
|
|
}
|
||
|
|
if err := (OccultationPathOptions{RiseSetStep: -time.Second}).Validate(); err == nil {
|
||
|
|
t.Fatal("negative rise/set step was accepted")
|
||
|
|
}
|
||
|
|
fineRiseSet := find(OccultationPathOptions{Step: 2 * time.Minute, RiseSetStep: 5 * time.Minute})
|
||
|
|
coarseRiseSet := find(OccultationPathOptions{Step: 2 * time.Minute, RiseSetStep: 30 * time.Minute})
|
||
|
|
if occultationRiseSetPointCount(coarseRiseSet.RiseSetCurves) >= occultationRiseSetPointCount(fineRiseSet.RiseSetCurves) {
|
||
|
|
t.Fatal("coarser occultation rise/set step did not reduce curve samples")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestOccultationRiseSet20240725CompletesMoonsetGreatestJunction(t *testing.T) {
|
||
|
|
zone := time.FixedZone("UTC+8", 8*60*60)
|
||
|
|
start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone)
|
||
|
|
paths, err := FindPlanetOccultationPaths(
|
||
|
|
start, start.Add(24*time.Hour), OccultationSaturn,
|
||
|
|
OccultationPathOptions{
|
||
|
|
Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
if err != nil || len(paths) != 1 {
|
||
|
|
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||
|
|
}
|
||
|
|
curves := make(map[occultationRiseSetCurveKey]OccultationRiseSetCurve)
|
||
|
|
for _, curve := range paths[0].RiseSetCurves {
|
||
|
|
curves[occultationRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction}] = curve
|
||
|
|
}
|
||
|
|
greatest := curves[occultationRiseSetCurveKey{phase: RiseSetPhaseGreatest, direction: RiseSetDirectionSet}]
|
||
|
|
if len(greatest.Segments) != 1 || len(greatest.Segments[0]) < 2 {
|
||
|
|
t.Fatalf("moonset greatest segments=%d, want one usable segment", len(greatest.Segments))
|
||
|
|
}
|
||
|
|
junction := greatest.Segments[0][len(greatest.Segments[0])-1]
|
||
|
|
if junction.Latitude > 35 {
|
||
|
|
t.Fatalf("moonset greatest stopped at latitude %.3f, want completed southern junction", junction.Latitude)
|
||
|
|
}
|
||
|
|
for _, phase := range []RiseSetPhase{RiseSetPhaseStart, RiseSetPhaseEnd} {
|
||
|
|
curve := curves[occultationRiseSetCurveKey{phase: phase, direction: RiseSetDirectionSet}]
|
||
|
|
shared := false
|
||
|
|
for _, segment := range curve.Segments {
|
||
|
|
for _, endpoint := range []OccultationPathPoint{segment[0], segment[len(segment)-1]} {
|
||
|
|
if math.Abs(endpoint.Time.Sub(junction.Time).Seconds()) <= 1 &&
|
||
|
|
occultationPathDistanceKM(endpoint, junction) <= 5 {
|
||
|
|
shared = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !shared {
|
||
|
|
t.Fatalf("moonset %s curve does not share the greatest-phase junction at %s", phase, junction.Time)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestOccultationRiseSet20250630MarsClosesMoonsetPhaseJunction(t *testing.T) {
|
||
|
|
zone := time.FixedZone("UTC+8", 8*60*60)
|
||
|
|
start := time.Date(2025, time.June, 30, 0, 0, 0, 0, zone)
|
||
|
|
paths, err := FindPlanetOccultationPaths(
|
||
|
|
start, start.Add(24*time.Hour), OccultationMars,
|
||
|
|
OccultationPathOptions{
|
||
|
|
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: 5 * time.Minute,
|
||
|
|
DisableFootprints: true,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
if err != nil || len(paths) != 1 {
|
||
|
|
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||
|
|
}
|
||
|
|
if len(paths[0].RiseSetCurves) != 6 {
|
||
|
|
t.Fatalf("rise/set curve count=%d, want six", len(paths[0].RiseSetCurves))
|
||
|
|
}
|
||
|
|
assertOccultationRiseSetEndpointsClosed(t, paths[0].RiseSetCurves)
|
||
|
|
config, _ := planetOccultationConfigFor(OccultationMars)
|
||
|
|
assertOccultationRiseSetCurves(t, paths[0].RiseSetCurves, func(tt float64) occultationRiseSetContext {
|
||
|
|
state := planetOccultationEphemerisStateAt(tt, config)
|
||
|
|
return newOccultationRiseSetContext(
|
||
|
|
tt, state.moonRA, state.moonDec, state.moonDistanceKM,
|
||
|
|
state.planetRA, state.planetDec, state.planetDistanceKM,
|
||
|
|
config.equatorialRadiusKM,
|
||
|
|
)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestOccultationRiseSet20250105SaturnClosesPolarBranchesWithoutJump(t *testing.T) {
|
||
|
|
zone := time.FixedZone("UTC+8", 8*60*60)
|
||
|
|
start := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone)
|
||
|
|
paths, err := FindPlanetOccultationPaths(
|
||
|
|
start, start.Add(24*time.Hour), OccultationSaturn,
|
||
|
|
OccultationPathOptions{
|
||
|
|
Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
if err != nil || len(paths) != 1 {
|
||
|
|
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||
|
|
}
|
||
|
|
if len(paths[0].RiseSetCurves) != 6 {
|
||
|
|
t.Fatalf("rise/set curve count=%d, want six", len(paths[0].RiseSetCurves))
|
||
|
|
}
|
||
|
|
path := paths[0]
|
||
|
|
assertOccultationRiseSetEndpointNetwork(t, path.RiseSetCurves, path.NorthernLimit, path.SouthernLimit)
|
||
|
|
assertOccultationRiseSetNoInstantaneousBranchJumps(t, path.RiseSetCurves)
|
||
|
|
assertOccultationRiseSetPolarProjectionSpacing(t, path.RiseSetCurves, 230)
|
||
|
|
curves := make(map[occultationRiseSetCurveKey]OccultationRiseSetCurve, len(path.RiseSetCurves))
|
||
|
|
for _, curve := range path.RiseSetCurves {
|
||
|
|
curves[occultationRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction}] = curve
|
||
|
|
}
|
||
|
|
startRise := curves[occultationRiseSetCurveKey{phase: RiseSetPhaseStart, direction: RiseSetDirectionRise}]
|
||
|
|
startSet := curves[occultationRiseSetCurveKey{phase: RiseSetPhaseStart, direction: RiseSetDirectionSet}]
|
||
|
|
greatestRise := curves[occultationRiseSetCurveKey{phase: RiseSetPhaseGreatest, direction: RiseSetDirectionRise}]
|
||
|
|
endRise := curves[occultationRiseSetCurveKey{phase: RiseSetPhaseEnd, direction: RiseSetDirectionRise}]
|
||
|
|
if !occultationRiseSetCurvePairSharesEndpointInRegion(
|
||
|
|
startRise, startSet, -60, 60, 70, 90,
|
||
|
|
) {
|
||
|
|
t.Fatal("start moonrise/moonset curves do not share their polar direction junction")
|
||
|
|
}
|
||
|
|
if !occultationRiseSetSegmentsShareEndpointInRegion(startSet.Segments, -60, 60, 70, 90) {
|
||
|
|
t.Fatal("moonset start-phase branches do not share their polar fold endpoint")
|
||
|
|
}
|
||
|
|
if !occultationRiseSetCurvesShareEndpointInRegion(
|
||
|
|
startRise, greatestRise, endRise, -100, -70, 20, 50,
|
||
|
|
) {
|
||
|
|
t.Fatal("moonrise start/greatest/end curves do not share the North American phase junction")
|
||
|
|
}
|
||
|
|
if !occultationRiseSetSegmentsShareEndpointInRegion(endRise.Segments, -100, -70, 20, 50) {
|
||
|
|
t.Fatal("moonrise end-phase branches do not share their North American fold endpoint")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func assertOccultationRiseSetPolarProjectionSpacing(
|
||
|
|
t *testing.T,
|
||
|
|
curves []OccultationRiseSetCurve,
|
||
|
|
maximumMercatorKM float64,
|
||
|
|
) {
|
||
|
|
t.Helper()
|
||
|
|
for _, curve := range curves {
|
||
|
|
for segmentIndex, segment := range curve.Segments {
|
||
|
|
for pointIndex := 1; pointIndex < len(segment); pointIndex++ {
|
||
|
|
first, second := segment[pointIndex-1], segment[pointIndex]
|
||
|
|
if math.Max(math.Abs(first.Latitude), math.Abs(second.Latitude)) < 75 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
deltaLongitude := math.Remainder((second.Longitude-first.Longitude)*rad, 2*math.Pi)
|
||
|
|
firstY := math.Log(math.Tan(math.Pi/4 + math.Min(85, math.Max(-85, first.Latitude))*rad/2))
|
||
|
|
secondY := math.Log(math.Tan(math.Pi/4 + math.Min(85, math.Max(-85, second.Latitude))*rad/2))
|
||
|
|
distance := occultationTopocentricEarthRadiusKM * math.Hypot(deltaLongitude, secondY-firstY)
|
||
|
|
if distance > maximumMercatorKM {
|
||
|
|
t.Fatalf("%s/%s segment %d edge %d has %.1f km Web Mercator length, want at most %.1f km",
|
||
|
|
curve.Phase, curve.Direction, segmentIndex, pointIndex-1, distance, maximumMercatorKM)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func occultationRiseSetCurvePairSharesEndpointInRegion(
|
||
|
|
first, second OccultationRiseSetCurve,
|
||
|
|
minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude float64,
|
||
|
|
) bool {
|
||
|
|
for _, segment := range first.Segments {
|
||
|
|
for _, point := range []OccultationPathPoint{segment[0], segment[len(segment)-1]} {
|
||
|
|
if point.Longitude < minimumLongitude || point.Longitude > maximumLongitude ||
|
||
|
|
point.Latitude < minimumLatitude || point.Latitude > maximumLatitude {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if occultationRiseSetCurveHasEndpoint(second, point) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func assertOccultationRiseSetNoInstantaneousBranchJumps(t *testing.T, curves []OccultationRiseSetCurve) {
|
||
|
|
t.Helper()
|
||
|
|
for _, curve := range curves {
|
||
|
|
for segmentIndex, segment := range curve.Segments {
|
||
|
|
for pointIndex := 1; pointIndex < len(segment); pointIndex++ {
|
||
|
|
previous, current := segment[pointIndex-1], segment[pointIndex]
|
||
|
|
distance := occultationPathDistanceKM(previous, current)
|
||
|
|
deltaSeconds := math.Abs(current.Time.Sub(previous.Time).Seconds())
|
||
|
|
if distance > 500 && deltaSeconds < 1 {
|
||
|
|
t.Fatalf("%s/%s segment %d contains %.1f km jump in %.3f seconds", curve.Phase, curve.Direction, segmentIndex, distance, deltaSeconds)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func occultationRiseSetCurvesShareEndpointInRegion(
|
||
|
|
first, second, third OccultationRiseSetCurve,
|
||
|
|
minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude float64,
|
||
|
|
) bool {
|
||
|
|
for _, segment := range first.Segments {
|
||
|
|
for _, point := range []OccultationPathPoint{segment[0], segment[len(segment)-1]} {
|
||
|
|
if point.Longitude < minimumLongitude || point.Longitude > maximumLongitude ||
|
||
|
|
point.Latitude < minimumLatitude || point.Latitude > maximumLatitude {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if occultationRiseSetCurveHasEndpoint(second, point) &&
|
||
|
|
occultationRiseSetCurveHasEndpoint(third, point) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func occultationRiseSetSegmentsShareEndpointInRegion(
|
||
|
|
segments [][]OccultationPathPoint,
|
||
|
|
minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude float64,
|
||
|
|
) bool {
|
||
|
|
for firstIndex, first := range segments {
|
||
|
|
for _, point := range []OccultationPathPoint{first[0], first[len(first)-1]} {
|
||
|
|
if point.Longitude < minimumLongitude || point.Longitude > maximumLongitude ||
|
||
|
|
point.Latitude < minimumLatitude || point.Latitude > maximumLatitude {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
for secondIndex, second := range segments {
|
||
|
|
if firstIndex == secondIndex {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if occultationRiseSetSegmentHasEndpoint(second, point) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func occultationRiseSetCurveHasEndpoint(curve OccultationRiseSetCurve, point OccultationPathPoint) bool {
|
||
|
|
for _, segment := range curve.Segments {
|
||
|
|
if occultationRiseSetSegmentHasEndpoint(segment, point) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func occultationRiseSetSegmentHasEndpoint(segment []OccultationPathPoint, point OccultationPathPoint) bool {
|
||
|
|
for _, endpoint := range []OccultationPathPoint{segment[0], segment[len(segment)-1]} {
|
||
|
|
if math.Abs(endpoint.Time.Sub(point.Time).Seconds()) <= 1 &&
|
||
|
|
occultationPathDistanceKM(endpoint, point) <= 5 {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func assertOccultationRiseSetEndpointNetwork(
|
||
|
|
t *testing.T,
|
||
|
|
curves []OccultationRiseSetCurve,
|
||
|
|
northern, southern []OccultationPathPoint,
|
||
|
|
) {
|
||
|
|
t.Helper()
|
||
|
|
type endpoint struct {
|
||
|
|
curve, segment, side int
|
||
|
|
point OccultationPathPoint
|
||
|
|
}
|
||
|
|
var endpoints []endpoint
|
||
|
|
for curveIndex, curve := range curves {
|
||
|
|
for segmentIndex, segment := range curve.Segments {
|
||
|
|
if len(segment) < 2 {
|
||
|
|
t.Fatalf("%s/%s segment %d has fewer than two points", curve.Phase, curve.Direction, segmentIndex)
|
||
|
|
}
|
||
|
|
endpoints = append(endpoints,
|
||
|
|
endpoint{curveIndex, segmentIndex, 0, segment[0]},
|
||
|
|
endpoint{curveIndex, segmentIndex, 1, segment[len(segment)-1]},
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for index, current := range endpoints {
|
||
|
|
closed := false
|
||
|
|
for otherIndex, other := range endpoints {
|
||
|
|
if index == otherIndex {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if math.Abs(current.point.Time.Sub(other.point.Time).Seconds()) <= 1 &&
|
||
|
|
occultationPathDistanceKM(current.point, other.point) <= 5 {
|
||
|
|
closed = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if closed {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
nearestBoundary := math.Inf(1)
|
||
|
|
for _, point := range append(append([]OccultationPathPoint(nil), northern...), southern...) {
|
||
|
|
nearestBoundary = math.Min(nearestBoundary, occultationPathDistanceKM(current.point, point))
|
||
|
|
}
|
||
|
|
if nearestBoundary > 400 {
|
||
|
|
curve := curves[current.curve]
|
||
|
|
t.Errorf("%s/%s segment %d endpoint is %.1f km from the occultation-band boundary",
|
||
|
|
curve.Phase, curve.Direction, current.segment, nearestBoundary)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestOccultationRiseSetEvaluationCacheReusesContexts(t *testing.T) {
|
||
|
|
calls := 0
|
||
|
|
cache := newOccultationRiseSetEvaluationCache(func(tt float64) occultationRiseSetContext {
|
||
|
|
calls++
|
||
|
|
return newOccultationRiseSetContext(tt, 10, 20, 384400, 30, -5, 0, 0)
|
||
|
|
})
|
||
|
|
const tt = 2451545.25
|
||
|
|
cache.evaluation(tt)
|
||
|
|
cache.evaluation(tt)
|
||
|
|
if calls != 3 {
|
||
|
|
t.Fatalf("same evaluation built %d contexts, want center/before/after once", calls)
|
||
|
|
}
|
||
|
|
cache.evaluation(tt + occultationRiseSetDerivativeStepDays)
|
||
|
|
if calls != 4 {
|
||
|
|
t.Fatalf("overlapping evaluation built %d contexts, want one additional context", calls)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestOccultationRiseSetRegressionCurveCounts(t *testing.T) {
|
||
|
|
polar := StarCoordinate{
|
||
|
|
ID: "Antares", RA: 247.3516666666667, Dec: -26.431944444444444,
|
||
|
|
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000,
|
||
|
|
ProperMotionRACosDecMasPerYear: -10, ProperMotionDecMasPerYear: -20, ParallaxMas: 24,
|
||
|
|
}
|
||
|
|
cases := []struct {
|
||
|
|
name string
|
||
|
|
find func() ([]OccultationRiseSetCurve, error)
|
||
|
|
// wantPoints 是当前采样下的实测点数,断言取 ±40% 带宽,抓采样与落段回归。
|
||
|
|
wantPoints int
|
||
|
|
}{
|
||
|
|
{
|
||
|
|
name: "HR4799",
|
||
|
|
wantPoints: 591,
|
||
|
|
find: func() ([]OccultationRiseSetCurve, error) {
|
||
|
|
paths, err := FindStarOccultationPaths(
|
||
|
|
time.Date(2025, 6, 5, 0, 0, 0, 0, time.UTC),
|
||
|
|
time.Date(2025, 6, 6, 0, 0, 0, 0, time.UTC),
|
||
|
|
hr4799OccultationCoordinateForTest(),
|
||
|
|
OccultationPathOptions{Step: 10 * time.Minute},
|
||
|
|
)
|
||
|
|
if err != nil || len(paths) != 1 {
|
||
|
|
return nil, fmt.Errorf("paths=%d err=%v", len(paths), err)
|
||
|
|
}
|
||
|
|
return paths[0].RiseSetCurves, nil
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "Saturn",
|
||
|
|
wantPoints: 707,
|
||
|
|
find: func() ([]OccultationRiseSetCurve, error) {
|
||
|
|
paths, err := FindPlanetOccultationPaths(
|
||
|
|
time.Date(2024, 8, 21, 0, 0, 0, 0, time.UTC),
|
||
|
|
time.Date(2024, 8, 22, 0, 0, 0, 0, time.UTC),
|
||
|
|
OccultationSaturn,
|
||
|
|
OccultationPathOptions{Step: 10 * time.Minute},
|
||
|
|
)
|
||
|
|
if err != nil || len(paths) != 1 {
|
||
|
|
return nil, fmt.Errorf("paths=%d err=%v", len(paths), err)
|
||
|
|
}
|
||
|
|
return paths[0].RiseSetCurves, nil
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "Saturn-2025-02-01",
|
||
|
|
wantPoints: 1490,
|
||
|
|
find: func() ([]OccultationRiseSetCurve, error) {
|
||
|
|
start := time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC)
|
||
|
|
paths, err := FindPlanetOccultationPaths(
|
||
|
|
start, start.Add(24*time.Hour), OccultationSaturn,
|
||
|
|
OccultationPathOptions{
|
||
|
|
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
||
|
|
RiseSetStep: 5 * time.Minute, DisableFootprints: true,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
if err != nil || len(paths) != 1 {
|
||
|
|
return nil, fmt.Errorf("paths=%d err=%v", len(paths), err)
|
||
|
|
}
|
||
|
|
return paths[0].RiseSetCurves, nil
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "high-latitude",
|
||
|
|
wantPoints: 1262,
|
||
|
|
find: func() ([]OccultationRiseSetCurve, error) {
|
||
|
|
paths, err := FindStarOccultationPaths(
|
||
|
|
time.Date(2026, 2, 11, 0, 0, 0, 0, time.UTC),
|
||
|
|
time.Date(2026, 2, 12, 0, 0, 0, 0, time.UTC),
|
||
|
|
polar,
|
||
|
|
OccultationPathOptions{Step: 10 * time.Minute},
|
||
|
|
)
|
||
|
|
if err != nil || len(paths) != 1 {
|
||
|
|
return nil, fmt.Errorf("paths=%d err=%v", len(paths), err)
|
||
|
|
}
|
||
|
|
return paths[0].RiseSetCurves, nil
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
for _, test := range cases {
|
||
|
|
t.Run(test.name, func(t *testing.T) {
|
||
|
|
curves, err := test.find()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if len(curves) != 6 {
|
||
|
|
t.Fatalf("rise/set curve count = %d, want 6", len(curves))
|
||
|
|
}
|
||
|
|
points := occultationRiseSetPointCount(curves)
|
||
|
|
if points < test.wantPoints*3/5 || points > test.wantPoints*8/5 {
|
||
|
|
t.Fatalf("rise/set point count = %d, want %d +/-40%%", points, test.wantPoints)
|
||
|
|
}
|
||
|
|
for _, curve := range curves {
|
||
|
|
if len(curve.Segments) == 0 {
|
||
|
|
t.Fatalf("%s/%s has no segments", curve.Phase, curve.Direction)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func occultationRiseSetPointCount(curves []OccultationRiseSetCurve) int {
|
||
|
|
count := 0
|
||
|
|
for _, curve := range curves {
|
||
|
|
for _, segment := range curve.Segments {
|
||
|
|
count += len(segment)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return count
|
||
|
|
}
|
||
|
|
|
||
|
|
func BenchmarkOccultationRiseSetRegression(b *testing.B) {
|
||
|
|
polar := StarCoordinate{
|
||
|
|
ID: "Antares", RA: 247.3516666666667, Dec: -26.431944444444444,
|
||
|
|
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000,
|
||
|
|
ProperMotionRACosDecMasPerYear: -10, ProperMotionDecMasPerYear: -20, ParallaxMas: 24,
|
||
|
|
}
|
||
|
|
cases := []struct {
|
||
|
|
name string
|
||
|
|
find func() ([]OccultationRiseSetCurve, error)
|
||
|
|
}{
|
||
|
|
{
|
||
|
|
name: "HR4799",
|
||
|
|
find: func() ([]OccultationRiseSetCurve, error) {
|
||
|
|
paths, err := FindStarOccultationPaths(
|
||
|
|
time.Date(2025, 6, 5, 0, 0, 0, 0, time.UTC),
|
||
|
|
time.Date(2025, 6, 6, 0, 0, 0, 0, time.UTC),
|
||
|
|
hr4799OccultationCoordinateForTest(), OccultationPathOptions{Step: 10 * time.Minute},
|
||
|
|
)
|
||
|
|
if err != nil || len(paths) != 1 {
|
||
|
|
return nil, fmt.Errorf("paths=%d err=%v", len(paths), err)
|
||
|
|
}
|
||
|
|
return paths[0].RiseSetCurves, nil
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "Saturn",
|
||
|
|
find: func() ([]OccultationRiseSetCurve, error) {
|
||
|
|
paths, err := FindPlanetOccultationPaths(
|
||
|
|
time.Date(2024, 8, 21, 0, 0, 0, 0, time.UTC),
|
||
|
|
time.Date(2024, 8, 22, 0, 0, 0, 0, time.UTC),
|
||
|
|
OccultationSaturn, OccultationPathOptions{Step: 10 * time.Minute},
|
||
|
|
)
|
||
|
|
if err != nil || len(paths) != 1 {
|
||
|
|
return nil, fmt.Errorf("paths=%d err=%v", len(paths), err)
|
||
|
|
}
|
||
|
|
return paths[0].RiseSetCurves, nil
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "Saturn-2025-02-01",
|
||
|
|
find: func() ([]OccultationRiseSetCurve, error) {
|
||
|
|
start := time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC)
|
||
|
|
paths, err := FindPlanetOccultationPaths(
|
||
|
|
start, start.Add(24*time.Hour), OccultationSaturn,
|
||
|
|
OccultationPathOptions{
|
||
|
|
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
||
|
|
RiseSetStep: 5 * time.Minute, DisableFootprints: true,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
if err != nil || len(paths) != 1 {
|
||
|
|
return nil, fmt.Errorf("paths=%d err=%v", len(paths), err)
|
||
|
|
}
|
||
|
|
return paths[0].RiseSetCurves, nil
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "high-latitude",
|
||
|
|
find: func() ([]OccultationRiseSetCurve, error) {
|
||
|
|
paths, err := FindStarOccultationPaths(
|
||
|
|
time.Date(2026, 2, 11, 0, 0, 0, 0, time.UTC),
|
||
|
|
time.Date(2026, 2, 12, 0, 0, 0, 0, time.UTC),
|
||
|
|
polar, OccultationPathOptions{Step: 10 * time.Minute},
|
||
|
|
)
|
||
|
|
if err != nil || len(paths) != 1 {
|
||
|
|
return nil, fmt.Errorf("paths=%d err=%v", len(paths), err)
|
||
|
|
}
|
||
|
|
return paths[0].RiseSetCurves, nil
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
for _, test := range cases {
|
||
|
|
b.Run(test.name, func(b *testing.B) {
|
||
|
|
b.ReportAllocs()
|
||
|
|
for index := 0; index < b.N; index++ {
|
||
|
|
curves, err := test.find()
|
||
|
|
if err != nil || len(curves) != 6 {
|
||
|
|
b.Fatalf("curves=%d err=%v, want six curves", len(curves), err)
|
||
|
|
}
|
||
|
|
b.ReportMetric(float64(occultationRiseSetPointCount(curves)), "points/op")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSolarEclipseRiseSetStepAllowsCoarseSampling(t *testing.T) {
|
||
|
|
seed := JDECalc(2024, 4, 8)
|
||
|
|
// Five- and thirty-minute inputs can both hit the spatial chord limit.
|
||
|
|
// Use a genuinely dense input so the test measures time decimation.
|
||
|
|
fine := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{
|
||
|
|
StepDays: 10.0 / 1440.0, RiseSetStepDays: 30.0 / 86400.0,
|
||
|
|
})
|
||
|
|
coarse := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{
|
||
|
|
StepDays: 10.0 / 1440.0, RiseSetStepDays: 30.0 / 1440.0,
|
||
|
|
})
|
||
|
|
if !solarEclipseRiseSetCurveTopologyComplete(fine.RiseSetCurves) ||
|
||
|
|
!solarEclipseRiseSetCurveTopologyComplete(coarse.RiseSetCurves) {
|
||
|
|
t.Fatal("time decimation must preserve complete rise/set topology")
|
||
|
|
}
|
||
|
|
if solarRiseSetPointCount(coarse.RiseSetCurves) >= solarRiseSetPointCount(fine.RiseSetCurves) {
|
||
|
|
t.Fatal("coarser solar rise/set step did not reduce curve samples")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func solarRiseSetPointCount(curves []SolarEclipseRiseSetCurve) int {
|
||
|
|
count := 0
|
||
|
|
for _, curve := range curves {
|
||
|
|
for _, segment := range curve.Segments {
|
||
|
|
count += len(segment)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return count
|
||
|
|
}
|
||
|
|
|
||
|
|
func assertOccultationRiseSetCurves(
|
||
|
|
t *testing.T,
|
||
|
|
curves []OccultationRiseSetCurve,
|
||
|
|
contextAt occultationRiseSetContextFunc,
|
||
|
|
) {
|
||
|
|
t.Helper()
|
||
|
|
if len(curves) != 6 {
|
||
|
|
t.Fatalf("occultation rise/set curve count = %d, want 6", len(curves))
|
||
|
|
}
|
||
|
|
for _, curve := range curves {
|
||
|
|
for _, segment := range curve.Segments {
|
||
|
|
for index := 1; index < len(segment); index++ {
|
||
|
|
if !segment[index].Time.After(segment[index-1].Time) {
|
||
|
|
t.Fatalf("occultation %s/%s segment time is not increasing at %d", curve.Phase, curve.Direction, index)
|
||
|
|
}
|
||
|
|
if distance := occultationPathDistanceKM(segment[index-1], segment[index]); distance > occultationPathBoundarySpacingKM+1e-6 {
|
||
|
|
t.Fatalf("occultation %s/%s segment gap = %.1f km from %v (%.4f, %.4f) to %v (%.4f, %.4f), want at most %.0f km",
|
||
|
|
curve.Phase, curve.Direction, distance,
|
||
|
|
segment[index-1].Time, segment[index-1].Longitude, segment[index-1].Latitude,
|
||
|
|
segment[index].Time, segment[index].Longitude, segment[index].Latitude,
|
||
|
|
occultationPathBoundarySpacingKM)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for _, point := range riseSetOccultationTestSamples(segment) {
|
||
|
|
tt := occultationTimeToTT(point.Time)
|
||
|
|
evaluation := occultationRiseSetEvaluation{
|
||
|
|
tt: tt, center: contextAt(tt),
|
||
|
|
before: contextAt(tt - occultationRiseSetDerivativeStepDays),
|
||
|
|
after: contextAt(tt + occultationRiseSetDerivativeStepDays),
|
||
|
|
}
|
||
|
|
state := evaluation.center.stateAt(point.Longitude, point.Latitude)
|
||
|
|
if !state.valid || math.Abs(state.moonAltitude) > 1e-6 {
|
||
|
|
t.Fatalf("occultation %s/%s altitude = %.9f deg", curve.Phase, curve.Direction, state.moonAltitude)
|
||
|
|
}
|
||
|
|
contactDerivative := evaluation.contactDerivative(point.Longitude, point.Latitude)
|
||
|
|
phaseJunction := math.Abs(state.contactMetric) <= 1e-7 &&
|
||
|
|
math.Abs(contactDerivative) <= occultationRiseSetJunctionDerivativeTolerance
|
||
|
|
if phaseJunction {
|
||
|
|
if evaluation.contactSecondDerivative(point.Longitude, point.Latitude) <= 0 {
|
||
|
|
t.Fatalf("occultation phase junction is not a contact-gap minimum at %.6f, %.6f",
|
||
|
|
point.Longitude, point.Latitude)
|
||
|
|
}
|
||
|
|
} else if curve.Phase == RiseSetPhaseGreatest {
|
||
|
|
derivative := evaluation.separationDerivative(point.Longitude, point.Latitude)
|
||
|
|
secondDerivative := evaluation.separationSecondDerivative(point.Longitude, point.Latitude)
|
||
|
|
if math.Abs(derivative) > 1e-8 || secondDerivative <= 0 || state.contactMetric > 1e-7 {
|
||
|
|
t.Fatalf("occultation greatest residual is invalid at %.6f, %.6f: derivative=%.9g second=%.9g contact=%.9g",
|
||
|
|
point.Longitude, point.Latitude, derivative, secondDerivative, state.contactMetric)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
if math.Abs(state.contactMetric) > 1e-7 {
|
||
|
|
t.Fatalf("occultation %s contact residual = %.9g deg", curve.Phase, state.contactMetric)
|
||
|
|
}
|
||
|
|
if math.Abs(contactDerivative) > 1e-7 &&
|
||
|
|
(curve.Phase == RiseSetPhaseStart && contactDerivative >= 0 || curve.Phase == RiseSetPhaseEnd && contactDerivative <= 0) {
|
||
|
|
t.Fatalf("occultation %s contact derivative = %.9g", curve.Phase, contactDerivative)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
altitudeDerivative := evaluation.moonAltitudeDerivative(point.Longitude, point.Latitude)
|
||
|
|
if math.Abs(altitudeDerivative) > 1e-7 &&
|
||
|
|
(curve.Direction == RiseSetDirectionRise && altitudeDerivative <= 0 ||
|
||
|
|
curve.Direction == RiseSetDirectionSet && altitudeDerivative >= 0) {
|
||
|
|
t.Fatalf("occultation %s altitude derivative = %.9g", curve.Direction, altitudeDerivative)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
assertOccultationRiseSetEndpointsClosed(t, curves)
|
||
|
|
}
|
||
|
|
|
||
|
|
func assertOccultationRiseSetEndpointsClosed(t *testing.T, curves []OccultationRiseSetCurve) {
|
||
|
|
t.Helper()
|
||
|
|
type endpoint struct {
|
||
|
|
curve, segment, side int
|
||
|
|
point OccultationPathPoint
|
||
|
|
}
|
||
|
|
var endpoints []endpoint
|
||
|
|
for curveIndex, curve := range curves {
|
||
|
|
for segmentIndex, segment := range curve.Segments {
|
||
|
|
if len(segment) == 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
endpoints = append(endpoints,
|
||
|
|
endpoint{curve: curveIndex, segment: segmentIndex, side: 0, point: segment[0]},
|
||
|
|
endpoint{curve: curveIndex, segment: segmentIndex, side: 1, point: segment[len(segment)-1]},
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for index, current := range endpoints {
|
||
|
|
matched := false
|
||
|
|
nearestIndex := -1
|
||
|
|
nearestMetric := math.Inf(1)
|
||
|
|
for otherIndex, other := range endpoints {
|
||
|
|
if index == otherIndex || current.curve == other.curve && current.segment == other.segment && current.side == other.side {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
timeDifference := math.Abs(occultationTimeToTT(current.point.Time) - occultationTimeToTT(other.point.Time))
|
||
|
|
distance := occultationPathDistanceKM(current.point, other.point)
|
||
|
|
metric := distance + timeDifference*86400
|
||
|
|
if metric < nearestMetric {
|
||
|
|
nearestIndex, nearestMetric = otherIndex, metric
|
||
|
|
}
|
||
|
|
if timeDifference <= 1e-8 && distance <= 0.01 {
|
||
|
|
matched = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !matched {
|
||
|
|
curve := curves[current.curve]
|
||
|
|
nearest := endpoints[nearestIndex]
|
||
|
|
nearestCurve := curves[nearest.curve]
|
||
|
|
t.Errorf("occultation %s/%s segment %d has an unclosed endpoint at %v (%.6f, %.6f); nearest %s/%s segment %d differs by %.3fs and %.1f km",
|
||
|
|
curve.Phase, curve.Direction, current.segment, current.point.Time,
|
||
|
|
current.point.Longitude, current.point.Latitude,
|
||
|
|
nearestCurve.Phase, nearestCurve.Direction, nearest.segment,
|
||
|
|
math.Abs(current.point.Time.Sub(nearest.point.Time).Seconds()),
|
||
|
|
occultationPathDistanceKM(current.point, nearest.point))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func assertSolarRiseSetPhase(
|
||
|
|
t *testing.T,
|
||
|
|
curve SolarEclipseRiseSetCurve,
|
||
|
|
point SolarEclipsePathPoint,
|
||
|
|
state localSolarEclipseState,
|
||
|
|
evaluation solarEclipseRiseSetEvaluation,
|
||
|
|
) {
|
||
|
|
t.Helper()
|
||
|
|
contactGap := solarEclipsePartialContactGap(state)
|
||
|
|
contactDerivative := evaluation.partialContactDerivative(point.Longitude, point.Latitude)
|
||
|
|
phaseJunction := math.Abs(contactGap) <= 1e-7 && math.Abs(contactDerivative) <= 1e-7
|
||
|
|
if phaseJunction {
|
||
|
|
if evaluation.partialContactSecondDerivative(point.Longitude, point.Latitude) <= 0 {
|
||
|
|
t.Fatalf("solar phase junction is not a contact-gap minimum at %.6f, %.6f", point.Longitude, point.Latitude)
|
||
|
|
}
|
||
|
|
} else if curve.Phase == RiseSetPhaseGreatest {
|
||
|
|
if math.Abs(evaluation.separationDerivative(point.Longitude, point.Latitude)) > 1e-8 ||
|
||
|
|
evaluation.separationSecondDerivative(point.Longitude, point.Latitude) <= 0 ||
|
||
|
|
contactGap > 1e-7 {
|
||
|
|
t.Fatalf("solar greatest residual is invalid at %.6f, %.6f", point.Longitude, point.Latitude)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
if math.Abs(contactGap) > 1e-7 {
|
||
|
|
t.Fatalf("solar %s contact residual = %.9g rad", curve.Phase, contactGap)
|
||
|
|
}
|
||
|
|
if curve.Phase == RiseSetPhaseStart && contactDerivative >= 0 || curve.Phase == RiseSetPhaseEnd && contactDerivative <= 0 {
|
||
|
|
t.Fatalf("solar %s contact derivative = %.9g", curve.Phase, contactDerivative)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
altitudeDerivative := evaluation.sunAltitudeDerivative(point.Longitude, point.Latitude)
|
||
|
|
directionJunction := math.Abs(altitudeDerivative) <= 1e-7
|
||
|
|
if directionJunction {
|
||
|
|
if math.Abs(evaluation.sunAltitudeSecondDerivative(point.Longitude, point.Latitude)) <= 1e-7 {
|
||
|
|
t.Fatalf("solar direction junction is not a horizon tangency at %.6f, %.6f", point.Longitude, point.Latitude)
|
||
|
|
}
|
||
|
|
} else if curve.Direction == RiseSetDirectionRise && altitudeDerivative <= 0 ||
|
||
|
|
curve.Direction == RiseSetDirectionSet && altitudeDerivative >= 0 {
|
||
|
|
t.Fatalf("solar %s altitude derivative = %.9g", curve.Direction, altitudeDerivative)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func riseSetSolarTestSamples(segment []SolarEclipsePathPoint) []SolarEclipsePathPoint {
|
||
|
|
return []SolarEclipsePathPoint{segment[0], segment[len(segment)/2], segment[len(segment)-1]}
|
||
|
|
}
|
||
|
|
|
||
|
|
func riseSetOccultationTestSamples(segment []OccultationPathPoint) []OccultationPathPoint {
|
||
|
|
return []OccultationPathPoint{segment[0], segment[len(segment)/2], segment[len(segment)-1]}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestRiseSetCyclicRootsWithFoldToleranceCoversFoldOnlyResiduals 固定折点补根的实际契约:
|
||
|
|
// 折点处残差与零相切而不变号,符号扫描整圈为空,此时补根必须给出折点根(这是极区相位曲线
|
||
|
|
// 得以闭合的路径);而当同一相位上同时存在普通变号根时,历史契约只返回符号根,折点根会被
|
||
|
|
// 丢弃。后者是已知取舍:实测在重建阶段无条件合并两类根会让 2025-01-05 这类极区事件的解析
|
||
|
|
// 掩带失去权威性(偏掩带回退为 footprint-sweep-fallback,可见性边界越出掩带 784 km),且在
|
||
|
|
// 去重容差 1e-4 与 1e-2 下都会退化、仅在 1e-3 下勉强通过,因此不能作为修复落地。
|
||
|
|
// TestRiseSetCyclicRootsWithFoldToleranceCoversFoldOnlyResiduals pins the actual fold-recovery
|
||
|
|
// contract: a fold holds the residual at zero without changing sign and leaves the sign scan
|
||
|
|
// empty, and recovery must then return the fold root, which is how polar phase curves close.
|
||
|
|
// When an ordinary sign-change root shares the phase, the historical contract returns only the
|
||
|
|
// sign roots and the fold root is dropped. That is a known trade-off: always merging both
|
||
|
|
// families in the rebuild pass was measured to strip the analytic band of its authority for
|
||
|
|
// polar events such as 2025-01-05 (the partial band fell back to footprint-sweep-fallback and
|
||
|
|
// the visibility boundary escaped it by 784 km), degrading at both 1e-4 and 1e-2 deduplication
|
||
|
|
// tolerances and only passing at 1e-3, so it cannot ship as a fix.
|
||
|
|
func TestRiseSetCyclicRootsWithFoldToleranceCoversFoldOnlyResiduals(t *testing.T) {
|
||
|
|
const samples = 720
|
||
|
|
// 1.0 处与零相切不变号:符号扫描为空,折点补根必须命中。
|
||
|
|
// The residual tangents zero at 1.0 without changing sign, so the sign scan stays empty
|
||
|
|
// and fold recovery must find it.
|
||
|
|
foldOnly := func(angle float64) (float64, bool) {
|
||
|
|
return 0.5 * riseSetAngularDistance(angle, 1.0), true
|
||
|
|
}
|
||
|
|
if roots := riseSetSignChangeRoots(samples, foldOnly); len(roots) != 0 {
|
||
|
|
t.Fatalf("riseSetSignChangeRoots() = %v, want no crossing root", roots)
|
||
|
|
}
|
||
|
|
roots := riseSetCyclicRootsWithFoldTolerance(samples, riseSetFoldRootResidualToleranceDeg, foldOnly)
|
||
|
|
if len(roots) != 1 || riseSetAngularDistance(roots[0], 1.0) > 1e-3 {
|
||
|
|
t.Fatalf("riseSetCyclicRootsWithFoldTolerance() = %v, want the single fold root near 1", roots)
|
||
|
|
}
|
||
|
|
// 折点与普通变号根共存:只返回符号根,折点根按现状丢弃。
|
||
|
|
// A fold coexisting with ordinary crossing roots: only the sign roots are returned and the
|
||
|
|
// fold root is dropped, matching current behaviour.
|
||
|
|
mixed := func(angle float64) (float64, bool) {
|
||
|
|
if riseSetAngularDistance(angle, 4.0) < 1.2 {
|
||
|
|
return riseSetAngularDistance(angle, 4.0) - 0.2, true
|
||
|
|
}
|
||
|
|
return 0.5 * riseSetAngularDistance(angle, 1.0), true
|
||
|
|
}
|
||
|
|
signRoots := riseSetSignChangeRoots(samples, mixed)
|
||
|
|
if len(signRoots) != 2 {
|
||
|
|
t.Fatalf("riseSetSignChangeRoots() = %v, want the two crossing roots", signRoots)
|
||
|
|
}
|
||
|
|
roots = riseSetCyclicRootsWithFoldTolerance(samples, riseSetFoldRootResidualToleranceDeg, mixed)
|
||
|
|
if len(roots) != len(signRoots) {
|
||
|
|
t.Fatalf("riseSetCyclicRootsWithFoldTolerance() = %v, want only the %d sign roots",
|
||
|
|
roots, len(signRoots))
|
||
|
|
}
|
||
|
|
for index, root := range roots {
|
||
|
|
if riseSetAngularDistance(root, signRoots[index]) > 1e-6 {
|
||
|
|
t.Fatalf("riseSetCyclicRootsWithFoldTolerance() = %v, want the sign roots %v", roots, signRoots)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|