351 lines
15 KiB
Go
351 lines
15 KiB
Go
|
|
package astro_test
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"errors"
|
||
|
|
"math"
|
||
|
|
"reflect"
|
||
|
|
"sync"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"b612.me/astro"
|
||
|
|
"b612.me/astro/basic"
|
||
|
|
"b612.me/astro/eclipse"
|
||
|
|
"b612.me/astro/geojson"
|
||
|
|
"b612.me/astro/jupiter"
|
||
|
|
"b612.me/astro/mars"
|
||
|
|
"b612.me/astro/mercury"
|
||
|
|
"b612.me/astro/moon"
|
||
|
|
"b612.me/astro/neptune"
|
||
|
|
"b612.me/astro/saturn"
|
||
|
|
"b612.me/astro/sun"
|
||
|
|
"b612.me/astro/uranus"
|
||
|
|
"b612.me/astro/venus"
|
||
|
|
)
|
||
|
|
|
||
|
|
func mustContext(tb testing.TB, model astro.Model, engines ...astro.Engine) *astro.Context {
|
||
|
|
tb.Helper()
|
||
|
|
context, err := astro.NewContext(model, engines...)
|
||
|
|
if err != nil {
|
||
|
|
tb.Fatalf("NewContext(%v): %v", model, err)
|
||
|
|
}
|
||
|
|
return context
|
||
|
|
}
|
||
|
|
|
||
|
|
func mustInstance(tb testing.TB, model astro.EphemerisModel) *astro.Instance {
|
||
|
|
tb.Helper()
|
||
|
|
inst, err := astro.NewModelInstance(model)
|
||
|
|
if err != nil {
|
||
|
|
tb.Fatalf("NewModelInstance(%v): %v", model, err)
|
||
|
|
}
|
||
|
|
return inst
|
||
|
|
}
|
||
|
|
|
||
|
|
type contextScalarCase struct {
|
||
|
|
name string
|
||
|
|
global func(time.Time) float64
|
||
|
|
bound func(*astro.Context, time.Time) float64
|
||
|
|
}
|
||
|
|
|
||
|
|
func contextScalars() []contextScalarCase {
|
||
|
|
return []contextScalarCase{
|
||
|
|
{"moon", moon.ApparentLo, func(context *astro.Context, date time.Time) float64 { return context.Moon().ApparentLo(date) }},
|
||
|
|
{"sun", sun.ApparentLo, func(context *astro.Context, date time.Time) float64 { return context.Sun().ApparentLo(date) }},
|
||
|
|
{"mercury", mercury.ApparentLo, func(context *astro.Context, date time.Time) float64 { return context.Mercury().ApparentLo(date) }},
|
||
|
|
{"venus", venus.ApparentLo, func(context *astro.Context, date time.Time) float64 { return context.Venus().ApparentLo(date) }},
|
||
|
|
{"mars", mars.ApparentLo, func(context *astro.Context, date time.Time) float64 { return context.Mars().ApparentLo(date) }},
|
||
|
|
{"jupiter", jupiter.ApparentLo, func(context *astro.Context, date time.Time) float64 { return context.Jupiter().ApparentLo(date) }},
|
||
|
|
{"saturn", saturn.ApparentLo, func(context *astro.Context, date time.Time) float64 { return context.Saturn().ApparentLo(date) }},
|
||
|
|
{"uranus", uranus.ApparentLo, func(context *astro.Context, date time.Time) float64 { return context.Uranus().ApparentLo(date) }},
|
||
|
|
{"neptune", neptune.ApparentLo, func(context *astro.Context, date time.Time) float64 { return context.Neptune().ApparentLo(date) }},
|
||
|
|
{"sun-distance", sun.EarthDistance, func(context *astro.Context, date time.Time) float64 { return context.Sun().EarthDistance(date) }},
|
||
|
|
{"moon-distance", moon.EarthDistance, func(context *astro.Context, date time.Time) float64 { return context.Moon().EarthDistance(date) }},
|
||
|
|
{"venus-distance", venus.SunDistance, func(context *astro.Context, date time.Time) float64 { return context.Venus().SunDistance(date) }},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestContextIsolationAndGlobalModelSwitch(t *testing.T) {
|
||
|
|
previous := astro.GetModel()
|
||
|
|
t.Cleanup(func() { astro.SetModel(previous) })
|
||
|
|
date := time.Date(2024, 10, 6, 12, 0, 0, 0, time.UTC)
|
||
|
|
legacy := mustContext(t, astro.ModelLegacy)
|
||
|
|
modern := mustContext(t, astro.ModelSemianalytic)
|
||
|
|
for _, test := range contextScalars() {
|
||
|
|
t.Run(test.name, func(t *testing.T) {
|
||
|
|
astro.SetModel(astro.ModelLegacy)
|
||
|
|
wantLegacy := test.global(date)
|
||
|
|
gotLegacy := test.bound(legacy, date)
|
||
|
|
astro.SetModel(astro.ModelSemianalytic)
|
||
|
|
wantModern := test.global(date)
|
||
|
|
gotModern := test.bound(modern, date)
|
||
|
|
if math.IsNaN(gotLegacy) || math.IsNaN(gotModern) || math.Abs(gotLegacy-wantLegacy) > 1e-9 || math.Abs(gotModern-wantModern) > 1e-9 {
|
||
|
|
t.Fatalf("legacy %.12f/%.12f; modern %.12f/%.12f", gotLegacy, wantLegacy, gotModern, wantModern)
|
||
|
|
}
|
||
|
|
if math.Abs(gotLegacy-gotModern) < 1e-12 {
|
||
|
|
t.Fatalf("model switch did not change %s: %.12f", test.name, gotLegacy)
|
||
|
|
}
|
||
|
|
if test.bound(legacy, date) != gotLegacy || test.bound(modern, date) != gotModern {
|
||
|
|
t.Fatal("explicit context changed with the global model")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
if legacy.Model() != astro.ModelLegacy || modern.Model() != astro.ModelSemianalytic || astro.GetModel() != astro.ModelSemianalytic {
|
||
|
|
t.Fatal("incorrect model identity")
|
||
|
|
}
|
||
|
|
if !basic.SetEphemerisModel(basic.EphemerisLegacy) || astro.GetModel() != astro.ModelLegacy {
|
||
|
|
t.Fatal("existing basic model setter diverged from the root facade")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestContextRisePhaseAndEclipseIsolation(t *testing.T) {
|
||
|
|
previous := astro.GetModel()
|
||
|
|
t.Cleanup(func() { astro.SetModel(previous) })
|
||
|
|
zone := time.FixedZone("UTC+8", 8*3600)
|
||
|
|
date := time.Date(2024, 4, 8, 0, 0, 0, 0, zone)
|
||
|
|
for _, model := range []astro.Model{astro.ModelLegacy, astro.ModelSemianalytic} {
|
||
|
|
context := mustContext(t, model)
|
||
|
|
astro.SetModel(model)
|
||
|
|
wantRise, wantErr := moon.RiseTime(date, 116.4, 39.9, 0, true)
|
||
|
|
wantSunrise, wantSunErr := sun.RiseTime(date, 116.4, 39.9, 0, true)
|
||
|
|
wantPhase := moon.NextNewMoon(date)
|
||
|
|
solarDate := time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC)
|
||
|
|
wantSolar, wantSolarOK := eclipse.SolarEclipseOnDate(solarDate)
|
||
|
|
wantLunar, wantLunarOK := eclipse.LunarEclipseOnDateWithInstance(time.Date(2024, 3, 25, 0, 0, 0, 0, time.UTC), mustInstance(t, model))
|
||
|
|
if !wantSolarOK || !wantLunarOK {
|
||
|
|
t.Fatal("known solar or lunar eclipse was not found")
|
||
|
|
}
|
||
|
|
wantJSON, wantJSONErr := geojson.MarshalLunarEclipse(wantLunar, 24)
|
||
|
|
astro.SetModel(astro.Model(1 - int(model)))
|
||
|
|
gotRise, gotErr := context.Moon().RiseTime(date, 116.4, 39.9, 0, true)
|
||
|
|
gotSunrise, gotSunErr := context.Sun().RiseTime(date, 116.4, 39.9, 0, true)
|
||
|
|
if gotRise != wantRise || gotErr != wantErr || gotSunrise != wantSunrise || gotSunErr != wantSunErr || context.Moon().NextNewMoon(date) != wantPhase {
|
||
|
|
t.Fatalf("model %d: rise or phase escaped context", model)
|
||
|
|
}
|
||
|
|
gotSolar, gotSolarOK := context.Eclipse().SolarEclipseOnDate(solarDate)
|
||
|
|
gotLunar, gotLunarOK := context.Eclipse().LunarEclipseOnDate(time.Date(2024, 3, 25, 0, 0, 0, 0, time.UTC))
|
||
|
|
if gotSolarOK != wantSolarOK || !reflect.DeepEqual(gotSolar, wantSolar) || gotLunarOK != wantLunarOK || !reflect.DeepEqual(gotLunar, wantLunar) {
|
||
|
|
t.Fatalf("model %d: eclipse escaped context\nsolar got=%+v\nsolar want=%+v\nlunar got=%+v\nlunar want=%+v", model, gotSolar, wantSolar, gotLunar, wantLunar)
|
||
|
|
}
|
||
|
|
gotJSON, gotJSONErr := context.GeoJSON().MarshalLunarEclipse(wantLunar, 24)
|
||
|
|
if (gotJSONErr == nil) != (wantJSONErr == nil) || !bytes.Equal(gotJSON, wantJSON) {
|
||
|
|
t.Fatalf("model %d: lunar GeoJSON differs: %v/%v", model, gotJSONErr, wantJSONErr)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestContextConcurrentReadAndLazyModules(t *testing.T) {
|
||
|
|
previous := astro.GetModel()
|
||
|
|
t.Cleanup(func() { astro.SetModel(previous) })
|
||
|
|
context := mustContext(t, astro.ModelSemianalytic)
|
||
|
|
date := time.Date(2024, 10, 6, 12, 0, 0, 0, time.UTC)
|
||
|
|
want := mustContext(t, astro.ModelSemianalytic).Moon().ApparentLo(date)
|
||
|
|
var wait sync.WaitGroup
|
||
|
|
for worker := 0; worker < 12; worker++ {
|
||
|
|
wait.Add(1)
|
||
|
|
go func() {
|
||
|
|
defer wait.Done()
|
||
|
|
for iteration := 0; iteration < 12; iteration++ {
|
||
|
|
if context.Moon() != context.Moon() || context.Sun() != context.Sun() || context.Mercury() != context.Mercury() || context.Venus() != context.Venus() || context.Mars() != context.Mars() || context.Jupiter() != context.Jupiter() || context.Saturn() != context.Saturn() || context.Uranus() != context.Uranus() || context.Neptune() != context.Neptune() || context.Eclipse() != context.Eclipse() || context.GeoJSON() != context.GeoJSON() {
|
||
|
|
t.Error("lazy module identity changed")
|
||
|
|
}
|
||
|
|
if context.Moon().ApparentLo(date) != want {
|
||
|
|
t.Error("concurrent context calculation changed")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
}
|
||
|
|
for iteration := 0; iteration < 12; iteration++ {
|
||
|
|
astro.SetModel(astro.Model(iteration % 2))
|
||
|
|
}
|
||
|
|
wait.Wait()
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestContextSolarShadowCacheAndGeoJSONIsolation(t *testing.T) {
|
||
|
|
previous := astro.GetModel()
|
||
|
|
t.Cleanup(func() { astro.SetModel(previous) })
|
||
|
|
date := time.Date(2024, 4, 8, 18, 17, 0, 0, time.UTC)
|
||
|
|
options := eclipse.SolarEclipseShadowSolverOptions{BoundaryPoints: 24, TargetSpacingKM: -1}
|
||
|
|
for _, model := range []astro.Model{astro.ModelLegacy, astro.ModelSemianalytic} {
|
||
|
|
astro.SetModel(model)
|
||
|
|
wantSolver := eclipse.NewSolarEclipseShadowSolver(options)
|
||
|
|
want, wantOK := wantSolver.ShadowAt(date)
|
||
|
|
if !wantOK || want.Empty() {
|
||
|
|
t.Fatal("known solar eclipse has no shadow")
|
||
|
|
}
|
||
|
|
wantJSON, wantErr := geojson.MarshalSolarEclipseShadowInstant(want)
|
||
|
|
context := mustContext(t, model)
|
||
|
|
solver := context.Eclipse().NewSolarEclipseShadowSolver(options)
|
||
|
|
before, beforeOK := solver.ShadowAt(date)
|
||
|
|
astro.SetModel(astro.Model(1 - int(model)))
|
||
|
|
after, afterOK := solver.ShadowAt(date)
|
||
|
|
if !beforeOK || !afterOK || !reflect.DeepEqual(before, want) || !reflect.DeepEqual(after, want) {
|
||
|
|
t.Fatalf("model %d: shadow solver cache escaped instance", model)
|
||
|
|
}
|
||
|
|
gotJSON, gotErr := context.GeoJSON().MarshalSolarEclipseShadowInstant(after)
|
||
|
|
if wantErr != nil || gotErr != nil || !bytes.Equal(gotJSON, wantJSON) {
|
||
|
|
t.Fatalf("model %d: solar GeoJSON escaped context: %v/%v", model, gotErr, wantErr)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestContextPackageCoordinateAllocations(t *testing.T) {
|
||
|
|
previous := astro.GetModel()
|
||
|
|
t.Cleanup(func() { astro.SetModel(previous) })
|
||
|
|
date := time.Date(2024, 10, 6, 12, 0, 0, 0, time.UTC)
|
||
|
|
for _, model := range []astro.Model{astro.ModelLegacy, astro.ModelSemianalytic} {
|
||
|
|
astro.SetModel(model)
|
||
|
|
context := mustContext(t, model)
|
||
|
|
for _, test := range contextScalars() {
|
||
|
|
if count := testing.AllocsPerRun(10, func() { test.global(date) }); count != 0 {
|
||
|
|
t.Errorf("model %d %s package allocations=%g", model, test.name, count)
|
||
|
|
}
|
||
|
|
if count := testing.AllocsPerRun(10, func() { test.bound(context, date) }); count != 0 {
|
||
|
|
t.Errorf("model %d %s warm context allocations=%g", model, test.name, count)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestContextPackageEventAllocations(t *testing.T) {
|
||
|
|
previous := astro.GetModel()
|
||
|
|
t.Cleanup(func() { astro.SetModel(previous) })
|
||
|
|
astro.SetModel(astro.ModelLegacy)
|
||
|
|
date := time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC)
|
||
|
|
for _, test := range []struct {
|
||
|
|
name string
|
||
|
|
compute func()
|
||
|
|
maximum float64
|
||
|
|
}{
|
||
|
|
{"moon-rise", func() { moon.RiseTime(date, 116.4, 39.9, 0, true) }, 0},
|
||
|
|
{"sun-rise", func() { sun.RiseTime(date, 116.4, 39.9, 0, true) }, 0},
|
||
|
|
{"moon-phase", func() { moon.NextNewMoon(date) }, 0},
|
||
|
|
{"venus-event", func() { venus.NextConjunction(date) }, 0},
|
||
|
|
{"solar-eclipse", func() { eclipse.SolarEclipseOnDate(date) }, 27},
|
||
|
|
} {
|
||
|
|
if allocations := testing.AllocsPerRun(5, test.compute); allocations > test.maximum {
|
||
|
|
t.Errorf("%s allocations=%g, maximum=%g", test.name, allocations, test.maximum)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type contextRejectingEngine struct{}
|
||
|
|
|
||
|
|
type contextRotatedEngine struct {
|
||
|
|
astro.Engine
|
||
|
|
apparent astro.ApparentEngine
|
||
|
|
}
|
||
|
|
|
||
|
|
func (engine contextRotatedEngine) Apparent(target astro.Body, jdTT float64) ([3]float64, float64, float64, error) {
|
||
|
|
direction, distance, delay, err := engine.apparent.Apparent(target, jdTT)
|
||
|
|
if err != nil {
|
||
|
|
return direction, distance, delay, err
|
||
|
|
}
|
||
|
|
sine, cosine := math.Sincos(0.01)
|
||
|
|
direction = [3]float64{cosine*direction[0] - sine*direction[1], sine*direction[0] + cosine*direction[1], direction[2]}
|
||
|
|
return direction, distance, delay, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestContextCustomApparentEngineIsolation(t *testing.T) {
|
||
|
|
previous := astro.GetModel()
|
||
|
|
t.Cleanup(func() { astro.SetModel(previous) })
|
||
|
|
engine := mustInstance(t, astro.ModelSemianalytic).Engine()
|
||
|
|
apparent, ok := engine.(astro.ApparentEngine)
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("built-in semi-analytic engine lacks apparent positions")
|
||
|
|
}
|
||
|
|
customEngine := contextRotatedEngine{Engine: engine, apparent: apparent}
|
||
|
|
context := mustContext(t, astro.ModelCustom, customEngine)
|
||
|
|
instance := astro.NewInstance(customEngine)
|
||
|
|
date := time.Date(2024, 10, 6, 12, 0, 0, 0, time.UTC)
|
||
|
|
jde := basic.UTC2TT(basic.Date2JD(date))
|
||
|
|
want := basic.HMoonApparentLoWithInstance(jde, instance)
|
||
|
|
ordinary := mustContext(t, astro.ModelSemianalytic).Moon().ApparentLo(date)
|
||
|
|
for _, model := range []astro.Model{astro.ModelLegacy, astro.ModelSemianalytic} {
|
||
|
|
astro.SetModel(model)
|
||
|
|
if got := context.Moon().ApparentLo(date); got != want || math.Abs(got-ordinary) < 0.01 {
|
||
|
|
t.Fatalf("custom engine ignored or escaped context: got %.12f, want %.12f, ordinary %.12f", got, want, ordinary)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (*contextRejectingEngine) State(target, center astro.Body, jdTDB float64) ([3]float64, [3]float64, error) {
|
||
|
|
return [3]float64{}, [3]float64{}, astro.ErrEphemerisOutOfRange
|
||
|
|
}
|
||
|
|
|
||
|
|
func (*contextRejectingEngine) Coverage(target astro.Body) (float64, float64, bool) {
|
||
|
|
return 0, 0, false
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestContextCustomEngineAndInvalidConstruction(t *testing.T) {
|
||
|
|
date := time.Date(2024, 10, 6, 12, 0, 0, 0, time.UTC)
|
||
|
|
context := mustContext(t, astro.ModelCustom, &contextRejectingEngine{})
|
||
|
|
if context.Model() != astro.ModelCustom || !math.IsNaN(context.Moon().ApparentLo(date)) || !math.IsNaN(context.Sun().ApparentLo(date)) || !math.IsNaN(context.Venus().SunDistance(date)) {
|
||
|
|
t.Fatal("failed custom engine silently fell back to a built-in model")
|
||
|
|
}
|
||
|
|
var typedNil *contextRejectingEngine
|
||
|
|
contextErr := func(model astro.Model, engines ...astro.Engine) func() error {
|
||
|
|
return func() error {
|
||
|
|
context, err := astro.NewContext(model, engines...)
|
||
|
|
if context != nil {
|
||
|
|
t.Error("failed construction returned a context")
|
||
|
|
}
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
stackErr := func(engines ...astro.Engine) func() error {
|
||
|
|
return func() error {
|
||
|
|
context, err := astro.NewStackContext(engines...)
|
||
|
|
if context != nil {
|
||
|
|
t.Error("failed construction returned a context")
|
||
|
|
}
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for _, test := range []struct {
|
||
|
|
name string
|
||
|
|
construct func() error
|
||
|
|
want error
|
||
|
|
}{
|
||
|
|
{"unknown", contextErr(astro.Model(99)), astro.ErrUnknownModel},
|
||
|
|
{"missing-engine", contextErr(astro.ModelCustom), astro.ErrInvalidEngine},
|
||
|
|
{"nil-engine", contextErr(astro.ModelCustom, nil), astro.ErrInvalidEngine},
|
||
|
|
{"typed-nil-engine", contextErr(astro.ModelCustom, typedNil), astro.ErrInvalidEngine},
|
||
|
|
{"extra-engine", contextErr(astro.ModelLegacy, &contextRejectingEngine{}), astro.ErrInvalidEngine},
|
||
|
|
{"stack-nil-engine", stackErr(&contextRejectingEngine{}, nil), astro.ErrInvalidEngine},
|
||
|
|
{"global-custom", func() error { return astro.SetModel(astro.ModelCustom) }, astro.ErrUnknownModel},
|
||
|
|
} {
|
||
|
|
t.Run(test.name, func(t *testing.T) {
|
||
|
|
if err := test.construct(); !errors.Is(err, test.want) {
|
||
|
|
t.Errorf("err = %v, want %v", err, test.want)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
before := astro.GetModel()
|
||
|
|
if astro.SetModel(astro.ModelCustom) == nil || astro.GetModel() != before {
|
||
|
|
t.Error("rejected SetModel changed the global model")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestZeroContextHasNoEngine(t *testing.T) {
|
||
|
|
var zero astro.Context
|
||
|
|
var nilContext *astro.Context
|
||
|
|
date := time.Date(2024, 10, 6, 12, 0, 0, 0, time.UTC)
|
||
|
|
for name, context := range map[string]*astro.Context{"zero": &zero, "nil": nilContext} {
|
||
|
|
if context.Model() != astro.ModelCustom || !math.IsNaN(context.Moon().ApparentLo(date)) || !math.IsNaN(context.Sun().ApparentLo(date)) ||
|
||
|
|
!math.IsNaN(context.Venus().SunDistance(date)) {
|
||
|
|
t.Fatalf("%s context must have no engine", name)
|
||
|
|
}
|
||
|
|
if context.Mercury() == nil || context.Mars() == nil || context.Jupiter() == nil || context.Saturn() == nil || context.Uranus() == nil ||
|
||
|
|
context.Neptune() == nil || context.Eclipse() == nil || context.GeoJSON() == nil || context.Phenomena() == nil {
|
||
|
|
t.Fatalf("%s context accessor returned nil", name)
|
||
|
|
}
|
||
|
|
if _, err := context.Sun().RiseTime(date, 116.4, 39.9, 0, true); !errors.Is(err, basic.ErrInvalidObservationInput) {
|
||
|
|
t.Fatalf("%s context rise/set err = %v", name, err)
|
||
|
|
}
|
||
|
|
if limited := context.WithLunarLimb(nil); limited == nil || limited.Model() != astro.ModelCustom {
|
||
|
|
t.Fatalf("%s context WithLunarLimb lost the empty instance", name)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|