245 lines
10 KiB
Go
245 lines
10 KiB
Go
|
|
package basic
|
||
|
|
|
||
|
|
import (
|
||
|
|
"math"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestOccultationRiseSetVectorStateMatchesLegacyTopocentricState(t *testing.T) {
|
||
|
|
planetConfig, ok := planetOccultationConfigFor(OccultationSaturn)
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("Saturn occultation config is unavailable")
|
||
|
|
}
|
||
|
|
planetTT := occultationTimeToTT(time.Date(2024, time.August, 21, 2, 41, 36, 0, time.UTC))
|
||
|
|
planetState := planetOccultationEphemerisStateAt(planetTT, planetConfig)
|
||
|
|
|
||
|
|
star := StarCoordinate{
|
||
|
|
ID: "parallax-test", 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,
|
||
|
|
}
|
||
|
|
starTT := occultationTimeToTT(time.Date(2026, time.February, 11, 12, 0, 0, 0, time.UTC))
|
||
|
|
starState := starOccultationEphemerisStateAt(starTT, star)
|
||
|
|
|
||
|
|
contexts := []struct {
|
||
|
|
name string
|
||
|
|
tt float64
|
||
|
|
moonRA, moonDec, moonDistanceKM float64
|
||
|
|
targetRA, targetDec, targetDistanceKM float64
|
||
|
|
targetRadiusKM float64
|
||
|
|
}{
|
||
|
|
{
|
||
|
|
name: "planet", tt: planetTT,
|
||
|
|
moonRA: planetState.moonRA, moonDec: planetState.moonDec, moonDistanceKM: planetState.moonDistanceKM,
|
||
|
|
targetRA: planetState.planetRA, targetDec: planetState.planetDec, targetDistanceKM: planetState.planetDistanceKM,
|
||
|
|
targetRadiusKM: planetConfig.equatorialRadiusKM,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "finite-distance-star", tt: starTT,
|
||
|
|
moonRA: starState.moonRA, moonDec: starState.moonDec, moonDistanceKM: starState.moonDistanceKM,
|
||
|
|
targetRA: starState.starRA, targetDec: starState.starDec, targetDistanceKM: starState.starDistanceKM,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "infinite-distance-star", tt: starTT,
|
||
|
|
moonRA: starState.moonRA, moonDec: starState.moonDec, moonDistanceKM: starState.moonDistanceKM,
|
||
|
|
targetRA: starState.starRA, targetDec: starState.starDec,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
locations := []Observer{
|
||
|
|
{Longitude: 0, Latitude: 0},
|
||
|
|
{Longitude: 115.4, Latitude: 32.9},
|
||
|
|
{Longitude: -73.9857, Latitude: 40.7484},
|
||
|
|
{Longitude: 179.9, Latitude: 80},
|
||
|
|
{Longitude: -120, Latitude: -70},
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, test := range contexts {
|
||
|
|
t.Run(test.name, func(t *testing.T) {
|
||
|
|
context := newOccultationRiseSetContext(
|
||
|
|
test.tt, test.moonRA, test.moonDec, test.moonDistanceKM,
|
||
|
|
test.targetRA, test.targetDec, test.targetDistanceKM, test.targetRadiusKM,
|
||
|
|
)
|
||
|
|
for _, observer := range locations {
|
||
|
|
got := context.stateAt(observer.Longitude, observer.Latitude)
|
||
|
|
want := legacyOccultationRiseSetStateAt(
|
||
|
|
test.tt, test.moonRA, test.moonDec, test.moonDistanceKM,
|
||
|
|
test.targetRA, test.targetDec, test.targetDistanceKM, test.targetRadiusKM,
|
||
|
|
observer.Longitude, observer.Latitude,
|
||
|
|
)
|
||
|
|
if got.valid != want.valid {
|
||
|
|
t.Fatalf("observer %.4f %.4f valid=%t, want %t", observer.Longitude, observer.Latitude, got.valid, want.valid)
|
||
|
|
}
|
||
|
|
if !got.valid {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
assertOccultationRiseSetStateClose(t, "contact metric", got.contactMetric, want.contactMetric, 2e-10)
|
||
|
|
assertOccultationRiseSetStateClose(t, "separation squared", got.separationSquared, want.separationSquared, 2e-13)
|
||
|
|
assertOccultationRiseSetStateClose(t, "moon altitude", got.moonAltitude, want.moonAltitude, 2e-10)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestOccultationRiseSetContextFromVectorsMatchesRADecContext(t *testing.T) {
|
||
|
|
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("Saturn occultation config is unavailable")
|
||
|
|
}
|
||
|
|
tt := occultationTimeToTT(time.Date(2025, time.January, 5, 17, 0, 0, 0, time.UTC))
|
||
|
|
state := planetOccultationEphemerisStateAt(tt, config)
|
||
|
|
moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM)
|
||
|
|
target := occultationPathRaDecVector(state.planetRA, state.planetDec, state.planetDistanceKM)
|
||
|
|
vectorContext := newOccultationRiseSetContextFromVectors(
|
||
|
|
tt,
|
||
|
|
[3]float64{moon.x, moon.y, moon.z},
|
||
|
|
[3]float64{target.x, target.y, target.z},
|
||
|
|
true,
|
||
|
|
config.equatorialRadiusKM,
|
||
|
|
)
|
||
|
|
raDecContext := newOccultationRiseSetContext(
|
||
|
|
tt, state.moonRA, state.moonDec, state.moonDistanceKM,
|
||
|
|
state.planetRA, state.planetDec, state.planetDistanceKM, config.equatorialRadiusKM,
|
||
|
|
)
|
||
|
|
for _, observer := range []Observer{
|
||
|
|
{Longitude: 0, Latitude: 0},
|
||
|
|
{Longitude: 115.4, Latitude: 32.9},
|
||
|
|
{Longitude: -45, Latitude: 82},
|
||
|
|
{Longitude: 170, Latitude: -70},
|
||
|
|
} {
|
||
|
|
got := vectorContext.stateAt(observer.Longitude, observer.Latitude)
|
||
|
|
want := raDecContext.stateAt(observer.Longitude, observer.Latitude)
|
||
|
|
if got.valid != want.valid {
|
||
|
|
t.Fatalf("observer %.3f %.3f valid=%t, want %t", observer.Longitude, observer.Latitude, got.valid, want.valid)
|
||
|
|
}
|
||
|
|
if !got.valid {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
assertOccultationRiseSetStateClose(t, "contact metric", got.contactMetric, want.contactMetric, 1e-12)
|
||
|
|
assertOccultationRiseSetStateClose(t, "separation squared", got.separationSquared, want.separationSquared, 1e-15)
|
||
|
|
assertOccultationRiseSetStateClose(t, "moon altitude", got.moonAltitude, want.moonAltitude, 1e-12)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestOccultationRiseSetMoonHorizonResidualMatchesVectorAltitude(t *testing.T) {
|
||
|
|
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("Saturn occultation config is unavailable")
|
||
|
|
}
|
||
|
|
tt := occultationTimeToTT(time.Date(2025, time.January, 5, 17, 0, 0, 0, time.UTC))
|
||
|
|
state := planetOccultationEphemerisStateAt(tt, config)
|
||
|
|
context := newOccultationRiseSetContext(
|
||
|
|
tt, state.moonRA, state.moonDec, state.moonDistanceKM,
|
||
|
|
state.planetRA, state.planetDec, state.planetDistanceKM, config.equatorialRadiusKM,
|
||
|
|
)
|
||
|
|
for _, observer := range []Observer{
|
||
|
|
{Longitude: 0, Latitude: 0},
|
||
|
|
{Longitude: 115.4, Latitude: 32.9},
|
||
|
|
{Longitude: -45, Latitude: 82},
|
||
|
|
{Longitude: 170, Latitude: -70},
|
||
|
|
} {
|
||
|
|
got, valid := context.moonHorizonResidual(observer.Longitude, observer.Latitude)
|
||
|
|
observerParallax, _, zenith := occultationRiseSetObserverVectors(
|
||
|
|
context.siderealDegrees, observer.Longitude, observer.Latitude,
|
||
|
|
)
|
||
|
|
topocentricMoon := occultationPathSub(context.moon.positionKM, observerParallax)
|
||
|
|
want := occultationPathDot(topocentricMoon, zenith)
|
||
|
|
if !valid || math.Abs(got-want) > 1e-8 {
|
||
|
|
t.Fatalf("observer %.3f %.3f horizon residual=%.15g valid=%t, want %.15g",
|
||
|
|
observer.Longitude, observer.Latitude, got, valid, want)
|
||
|
|
}
|
||
|
|
altitude := context.stateAt(observer.Longitude, observer.Latitude).moonAltitude * rad
|
||
|
|
if difference := math.Abs(got/occultationPathNorm(topocentricMoon) - math.Sin(altitude)); difference > 1e-14 {
|
||
|
|
t.Fatalf("observer %.3f %.3f normalized horizon residual differs by %.3g",
|
||
|
|
observer.Longitude, observer.Latitude, difference)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestOccultationRiseSetEvaluationCacheSeparatesCandidateAndExactContexts(t *testing.T) {
|
||
|
|
var exactCalls, candidateCalls int
|
||
|
|
contextAt := func(tt float64) occultationRiseSetContext {
|
||
|
|
return newOccultationRiseSetContext(tt, 10, 5, 384000, 10.5, 5.25, 1e9, 0)
|
||
|
|
}
|
||
|
|
cache := newOccultationRiseSetEvaluationCacheWithCandidate(
|
||
|
|
func(tt float64) occultationRiseSetContext {
|
||
|
|
exactCalls++
|
||
|
|
return contextAt(tt)
|
||
|
|
},
|
||
|
|
func(tt float64) occultationRiseSetContext {
|
||
|
|
candidateCalls++
|
||
|
|
return contextAt(tt)
|
||
|
|
},
|
||
|
|
)
|
||
|
|
tt := occultationTimeToTT(time.Date(2025, time.January, 5, 17, 0, 0, 0, time.UTC))
|
||
|
|
cache.candidateEvaluation(tt)
|
||
|
|
cache.candidateEvaluation(tt)
|
||
|
|
if exactCalls != 0 || candidateCalls != 3 {
|
||
|
|
t.Fatalf("candidate evaluation calls exact=%d candidate=%d, want 0/3", exactCalls, candidateCalls)
|
||
|
|
}
|
||
|
|
cache.evaluation(tt)
|
||
|
|
cache.evaluation(tt)
|
||
|
|
if exactCalls != 3 || candidateCalls != 3 {
|
||
|
|
t.Fatalf("exact evaluation calls exact=%d candidate=%d, want 3/3", exactCalls, candidateCalls)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func legacyOccultationRiseSetStateAt(
|
||
|
|
tt, moonRA, moonDec, moonDistanceKM,
|
||
|
|
targetRA, targetDec, targetDistanceKM, targetRadiusKM,
|
||
|
|
longitude, latitude float64,
|
||
|
|
) occultationRiseSetState {
|
||
|
|
siderealDegrees := ApparentSiderealTime(TD2UT(tt, false)) * 15
|
||
|
|
observer := Observer{Longitude: longitude, Latitude: latitude}
|
||
|
|
moonTopocentricRA, moonTopocentricDec := topocentricRaDecWithSidereal(
|
||
|
|
moonRA, moonDec, latitude, longitude, siderealDegrees,
|
||
|
|
moonDistanceKM/occultationPathAstronomicalUnitKM, 0,
|
||
|
|
)
|
||
|
|
moonTopocentricRA = normalizeRA(moonTopocentricRA)
|
||
|
|
targetTopocentricRA, targetTopocentricDec := targetRA, targetDec
|
||
|
|
if targetDistanceKM > 0 {
|
||
|
|
targetTopocentricRA, targetTopocentricDec = topocentricRaDecWithSidereal(
|
||
|
|
targetRA, targetDec, latitude, longitude, siderealDegrees,
|
||
|
|
targetDistanceKM/occultationPathAstronomicalUnitKM, 0,
|
||
|
|
)
|
||
|
|
targetTopocentricRA = normalizeRA(targetTopocentricRA)
|
||
|
|
}
|
||
|
|
moonTopocentricDistanceKM := topocentricDistanceKMWithSidereal(
|
||
|
|
moonRA, moonDec, moonDistanceKM, observer, siderealDegrees,
|
||
|
|
)
|
||
|
|
if !finite(moonTopocentricDistanceKM) || moonTopocentricDistanceKM <= moonEquatorialRadiusKM {
|
||
|
|
return occultationRiseSetState{}
|
||
|
|
}
|
||
|
|
moonRadius := angularSemidiameterArcsec(moonEquatorialRadiusKM, moonTopocentricDistanceKM) / 3600
|
||
|
|
targetRadius := 0.0
|
||
|
|
if targetRadiusKM > 0 {
|
||
|
|
targetTopocentricDistanceKM := topocentricDistanceKMWithSidereal(
|
||
|
|
targetRA, targetDec, targetDistanceKM, observer, siderealDegrees,
|
||
|
|
)
|
||
|
|
if !finite(targetTopocentricDistanceKM) || targetTopocentricDistanceKM <= targetRadiusKM {
|
||
|
|
return occultationRiseSetState{}
|
||
|
|
}
|
||
|
|
targetRadius = angularSemidiameterArcsec(targetRadiusKM, targetTopocentricDistanceKM) / 3600
|
||
|
|
}
|
||
|
|
separation := angularSeparationDegrees(
|
||
|
|
moonTopocentricRA, moonTopocentricDec, targetTopocentricRA, targetTopocentricDec,
|
||
|
|
)
|
||
|
|
separationRad := separation * rad
|
||
|
|
moonAltitude := occultationAltitudeWithSidereal(
|
||
|
|
siderealDegrees, observer, moonTopocentricRA, moonTopocentricDec,
|
||
|
|
)
|
||
|
|
return occultationRiseSetState{
|
||
|
|
contactMetric: separation - moonRadius - targetRadius,
|
||
|
|
separationSquared: 2 - 2*math.Cos(separationRad),
|
||
|
|
moonAltitude: moonAltitude,
|
||
|
|
valid: finite(separation) && finite(moonRadius) && finite(targetRadius) && finite(moonAltitude),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func assertOccultationRiseSetStateClose(t *testing.T, name string, got, want, tolerance float64) {
|
||
|
|
t.Helper()
|
||
|
|
if difference := math.Abs(got - want); difference > tolerance {
|
||
|
|
t.Fatalf("%s=%.15g, want %.15g (difference %.3g, tolerance %.3g)", name, got, want, difference, tolerance)
|
||
|
|
}
|
||
|
|
}
|