package basic import ( "math" "testing" "time" ) func TestPlanetOccultationSupportsAllPlanetTargets(t *testing.T) { tt := TD2UT(Date2JDE(time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)), true) tests := []struct { planet OccultationPlanet name string }{ {OccultationMercury, "Mercury"}, {OccultationVenus, "Venus"}, {OccultationMars, "Mars"}, {OccultationJupiter, "Jupiter"}, {OccultationSaturn, "Saturn"}, {OccultationUranus, "Uranus"}, {OccultationNeptune, "Neptune"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if err := test.planet.Validate(); err != nil { t.Fatalf("Validate() error = %v", err) } if test.planet.String() != test.name { t.Fatalf("String() = %q, want %q", test.planet.String(), test.name) } config, ok := planetOccultationConfigFor(test.planet) if !ok { t.Fatal("planet occultation config is unavailable") } state := planetOccultationStateAt(tt, config, nil, -1) if !state.valid || state.planetSemidiameter <= 0 || state.moonSemidiameter <= state.planetSemidiameter { t.Fatalf("invalid planet state: %+v", state) } }) } } func TestPlanetOccultationSaturnContactsSolveDynamicDiskMetrics(t *testing.T) { observer := Observer{Longitude: -30.072, Latitude: 16.21} start := time.Date(2024, time.August, 21, 1, 30, 0, 0, time.UTC) end := time.Date(2024, time.August, 21, 4, 0, 0, 0, time.UTC) results, err := FindPlanetOccultations( start, end, OccultationSaturn, observer.Longitude, observer.Latitude, observer.Height, OccultationSearchOptions{}, ) if err != nil { t.Fatalf("FindPlanetOccultations() error = %v", err) } if len(results) != 1 { t.Fatalf("FindPlanetOccultations() returned %d events, want 1", len(results)) } result := results[0] if result.Type != OccultationTotal || !result.HasInternalContacts || !result.ContactsComplete { t.Fatalf("unexpected event geometry: type=%q internal=%v complete=%v", result.Type, result.HasInternalContacts, result.ContactsComplete) } config, ok := planetOccultationConfigFor(OccultationSaturn) if !ok { t.Fatal("Saturn occultation config is unavailable") } contacts := []struct { name string value time.Time internal bool }{ {"C1", result.ExternalImmersion, false}, {"C2", result.InternalImmersion, true}, {"C3", result.InternalEmersion, true}, {"C4", result.ExternalEmersion, false}, } for _, contact := range contacts { state := planetOccultationStateAt(occultationTimeToTT(contact.value), config, &observer, -1) metric := state.externalContactMetric if contact.internal { metric = state.internalContactMetric } if math.Abs(metric) > 0.1 { t.Errorf("%s contact residual = %.6f arcsec, want <= 0.1", contact.name, metric) } } if !(result.ExternalImmersion.Before(result.InternalImmersion) && result.InternalImmersion.Before(result.Greatest) && result.Greatest.Before(result.InternalEmersion) && result.InternalEmersion.Before(result.ExternalEmersion)) { t.Fatalf("contact order is invalid: %+v", result) } if result.PlanetSemidiameterArcsec <= 0 || result.MoonSemidiameterArcsec <= result.PlanetSemidiameterArcsec { t.Fatalf("invalid dynamic semidiameters: Moon=%.6f planet=%.6f", result.MoonSemidiameterArcsec, result.PlanetSemidiameterArcsec) } } func TestPlanetOccultationUsesStationMoonDistanceForRadius(t *testing.T) { tt := occultationTimeToTT(time.Date(2024, time.August, 21, 2, 49, 10, 0, time.UTC)) config, ok := planetOccultationConfigFor(OccultationSaturn) if !ok { t.Fatal("Saturn occultation config is unavailable") } moonRA, _ := HMoonGeocentricApparentRaDecN(tt, -1) subMoonLongitude := normalizeLongitude180(moonRA - ApparentSiderealTime(TD2UT(tt, false))*15) near := Observer{Longitude: subMoonLongitude, Latitude: 0} far := Observer{Longitude: normalizeLongitude180(subMoonLongitude + 180), Latitude: 0} nearState := planetOccultationStateAt(tt, config, &near, -1) farState := planetOccultationStateAt(tt, config, &far, -1) if !nearState.valid || !farState.valid { t.Fatalf("invalid station states: near=%+v far=%+v", nearState, farState) } if nearState.moonSemidiameter <= farState.moonSemidiameter { t.Fatalf("station Moon radius did not follow station distance: near=%.6f far=%.6f", nearState.moonSemidiameter, farState.moonSemidiameter) } if nearState.moonSemidiameter-MoonSemidiameterN(tt, -1) <= 0 || farState.moonSemidiameter-MoonSemidiameterN(tt, -1) >= 0 { t.Fatalf("station radius does not straddle geocentric radius: near=%.6f geo=%.6f far=%.6f", nearState.moonSemidiameter, MoonSemidiameterN(tt, -1), farState.moonSemidiameter) } } func TestPlanetOccultationUsesStationPlanetDistanceForRadius(t *testing.T) { tt := occultationTimeToTT(time.Date(2024, time.March, 11, 1, 0, 0, 0, time.UTC)) config, ok := planetOccultationConfigFor(OccultationMercury) if !ok { t.Fatal("Mercury occultation config is unavailable") } planetRA, _ := config.apparentRaDecN(tt, -1) subPlanetLongitude := normalizeLongitude180(planetRA - ApparentSiderealTime(TD2UT(tt, false))*15) near := Observer{Longitude: subPlanetLongitude, Latitude: 0} far := Observer{Longitude: normalizeLongitude180(subPlanetLongitude + 180), Latitude: 0} nearState := planetOccultationStateAt(tt, config, &near, -1) farState := planetOccultationStateAt(tt, config, &far, -1) if !nearState.valid || !farState.valid { t.Fatalf("invalid station states: near=%+v far=%+v", nearState, farState) } if nearState.planetSemidiameter <= farState.planetSemidiameter { t.Fatalf("station planet radius did not follow station distance: near=%.12f far=%.12f", nearState.planetSemidiameter, farState.planetSemidiameter) } geocentric := config.semidiameterN(tt, -1) if nearState.planetSemidiameter <= geocentric || farState.planetSemidiameter >= geocentric { t.Fatalf("station planet radius does not straddle geocentric radius: near=%.12f geo=%.12f far=%.12f", nearState.planetSemidiameter, geocentric, farState.planetSemidiameter) } } func TestPlanetOccultationBestObserverRefinesDynamicMetric(t *testing.T) { config, ok := planetOccultationConfigFor(OccultationSaturn) if !ok { t.Fatal("Saturn occultation config is unavailable") } startTT := occultationTimeToTT(time.Date(2024, time.August, 21, 1, 30, 0, 0, time.UTC)) endTT := occultationTimeToTT(time.Date(2024, time.August, 21, 4, 0, 0, 0, time.UTC)) seedTT := occultationTimeToTT(time.Date(2024, time.August, 21, 2, 49, 10, 0, time.UTC)) bestTT, observer, _, bestOK := planetOccultationBestObserver(seedTT, startTT, endTT, config) if !bestOK { t.Fatal("best-observer search failed") } metric := planetOccultationExternalContactMetric(bestTT, config, &observer, -1) for _, deltaSeconds := range []float64{-0.1, 0.1} { neighbor := planetOccultationExternalContactMetric(bestTT+deltaSeconds/86400, config, &observer, -1) if metric > neighbor+1e-6 { t.Fatalf("best time does not minimize the dynamic metric: center=%.12f neighbor(%+.1fs)=%.12f", metric, deltaSeconds, neighbor) } } } func TestPlanetOccultationInnerPlanetContactsUseDynamicTargets(t *testing.T) { tests := []struct { name string planet OccultationPlanet start time.Time end time.Time }{ {"Mercury", OccultationMercury, time.Date(2024, time.March, 10, 0, 0, 0, 0, time.UTC), time.Date(2024, time.March, 12, 0, 0, 0, 0, time.UTC)}, {"Venus", OccultationVenus, time.Date(2024, time.April, 6, 0, 0, 0, 0, time.UTC), time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { results, err := FindBestPlanetOccultations(test.start, test.end, test.planet, OccultationSearchOptions{MaxEvents: 1}) if err != nil { t.Fatalf("FindBestPlanetOccultations() error = %v", err) } if len(results) != 1 { t.Fatalf("FindBestPlanetOccultations() returned %d events, want 1", len(results)) } result := results[0] if result.Planet != test.planet || !result.HasInternalContacts || !result.ContactsComplete { t.Fatalf("unexpected event geometry: %+v", result) } if !(result.ExternalImmersion.Before(result.InternalImmersion) && result.InternalImmersion.Before(result.Greatest) && result.Greatest.Before(result.InternalEmersion) && result.InternalEmersion.Before(result.ExternalEmersion)) { t.Fatalf("contact order is invalid: %+v", result) } config, ok := planetOccultationConfigFor(test.planet) if !ok { t.Fatalf("%s occultation config is unavailable", test.name) } contacts := []struct { value time.Time internal bool }{ {result.ExternalImmersion, false}, {result.InternalImmersion, true}, {result.InternalEmersion, true}, {result.ExternalEmersion, false}, } for index, contact := range contacts { state := planetOccultationStateAt(occultationTimeToTT(contact.value), config, &result.Observer, -1) metric := state.externalContactMetric if contact.internal { metric = state.internalContactMetric } if math.Abs(metric) > 0.1 { t.Errorf("contact %d residual = %.6f arcsec, want <= 0.1", index+1, metric) } } }) } } func TestPlanetOccultationOuterPlanetContactsUseDynamicTargets(t *testing.T) { tests := []struct { name string planet OccultationPlanet start time.Time end time.Time observer Observer }{ {"Mars", OccultationMars, time.Date(2020, 2, 18, 11, 0, 0, 0, time.UTC), time.Date(2020, 2, 18, 16, 0, 0, 0, time.UTC), Observer{Longitude: -76.01, Latitude: 29.918}}, {"Jupiter", OccultationJupiter, time.Date(2020, 1, 23, 0, 0, 0, 0, time.UTC), time.Date(2020, 1, 23, 5, 0, 0, 0, time.UTC), Observer{Longitude: 120.15, Latitude: -45.552}}, {"Uranus", OccultationUranus, time.Date(2022, 2, 7, 19, 0, 0, 0, time.UTC), time.Date(2022, 2, 7, 22, 0, 0, 0, time.UTC), Observer{Longitude: 11.186, Latitude: -62.948}}, {"Neptune", OccultationNeptune, time.Date(2023, 9, 1, 7, 0, 0, 0, time.UTC), time.Date(2023, 9, 1, 10, 0, 0, 0, time.UTC), Observer{Longitude: -13.896, Latitude: -62.038}}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { results, err := FindPlanetOccultations( test.start, test.end, test.planet, test.observer.Longitude, test.observer.Latitude, test.observer.Height, OccultationSearchOptions{}, ) if err != nil { t.Fatalf("FindPlanetOccultations() error = %v", err) } if len(results) != 1 { t.Fatalf("FindPlanetOccultations() returned %d events, want 1", len(results)) } result := results[0] if result.Type != OccultationTotal || !result.HasInternalContacts || !result.ContactsComplete { t.Fatalf("unexpected event geometry: %+v", result) } config, ok := planetOccultationConfigFor(test.planet) if !ok { t.Fatalf("%s occultation config is unavailable", test.name) } contacts := []struct { value time.Time internal bool }{ {result.ExternalImmersion, false}, {result.InternalImmersion, true}, {result.InternalEmersion, true}, {result.ExternalEmersion, false}, } for index, contact := range contacts { state := planetOccultationStateAt(occultationTimeToTT(contact.value), config, &test.observer, -1) metric := state.externalContactMetric if contact.internal { metric = state.internalContactMetric } if math.Abs(metric) > 0.1 { t.Errorf("contact %d residual = %.6f arcsec, want <= 0.1", index+1, metric) } } }) } }