diff --git a/python/across_api/base/schema.py b/python/across_api/base/schema.py index cb1d27795..a20ffd18b 100644 --- a/python/across_api/base/schema.py +++ b/python/across_api/base/schema.py @@ -39,6 +39,9 @@ class BaseSchema(BaseModel): model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True) + def __hash__(self): + return hash((type(self),) + tuple(self.__dict__.values())) + class DateRangeSchema(BaseSchema): """ diff --git a/python/across_api/base/tle.py b/python/across_api/base/tle.py index 8c6a6c28d..807393e61 100644 --- a/python/across_api/base/tle.py +++ b/python/across_api/base/tle.py @@ -88,7 +88,7 @@ class TLEBase(ACROSSAPIBase): # Return values error: Optional[str] - def __init__(self, epoch: Time): + def __init__(self, epoch: Time, tle: Optional[TLEEntry] = None): """ Initialize a TLE object with the given epoch. @@ -98,7 +98,10 @@ def __init__(self, epoch: Time): The epoch of the TLE object. """ self.epoch = epoch - self.tles = [] + if tle is not None: + self.tles = [tle] + else: + self.tles = [] if self.validate_get(): self.get() @@ -342,6 +345,10 @@ def get(self) -> bool: elif self.epoch > Time.now().utc: self.epoch = Time.now().utc + # Check if a TLE is loaded manually + if self.tle is not None: + return True + # Fetch TLE from the TLE database if self.read_tle_db() is True: if self.tle is not None: diff --git a/python/across_api/burstcube/ephem.py b/python/across_api/burstcube/ephem.py index d2bc4e802..1178b6f59 100644 --- a/python/across_api/burstcube/ephem.py +++ b/python/across_api/burstcube/ephem.py @@ -2,10 +2,12 @@ # Administrator of the National Aeronautics and Space Administration. # All Rights Reserved. +from typing import Optional import astropy.units as u # type: ignore from astropy.time import Time # type: ignore from cachetools import TTLCache, cached +from ..base.schema import TLEEntry from ..base.common import ACROSSAPIBase from ..base.ephem import EphemBase from ..burstcube.tle import BurstCubeTLE @@ -21,7 +23,16 @@ class BurstCubeEphem(EphemBase, ACROSSAPIBase): # Configuration options earth_radius = 70 * u.deg # Fix 70 degree Earth radius - def __init__(self, begin: Time, end: Time, stepsize: u.Quantity = 60 * u.s): - self.tle = BurstCubeTLE(begin).tle + def __init__( + self, + begin: Time, + end: Time, + stepsize: u.Quantity = 60 * u.s, + tle: Optional[TLEEntry] = None, + ): + # Load TLE data + self.tle = tle + if self.tle is None: + self.tle = BurstCubeTLE(begin).tle if self.tle is not None: super().__init__(begin=begin, end=end, stepsize=stepsize) diff --git a/python/across_api/swift/ephem.py b/python/across_api/swift/ephem.py index 30ecaf903..046767e49 100644 --- a/python/across_api/swift/ephem.py +++ b/python/across_api/swift/ephem.py @@ -2,12 +2,14 @@ # Administrator of the National Aeronautics and Space Administration. # All Rights Reserved. +from typing import Optional import astropy.units as u # type: ignore from astropy.time import Time # type: ignore from cachetools import TTLCache, cached from ..base.common import ACROSSAPIBase from ..base.ephem import EphemBase +from ..base.schema import TLEEntry from .tle import SwiftTLE @@ -18,7 +20,16 @@ class SwiftEphem(EphemBase, ACROSSAPIBase): Satellite from TLE. """ - def __init__(self, begin: Time, end: Time, stepsize: u.Quantity = 60 * u.s): - self.tle = SwiftTLE(begin).tle + def __init__( + self, + begin: Time, + end: Time, + stepsize: u.Quantity = 60 * u.s, + tle: Optional[TLEEntry] = None, + ): + # Load TLE data + self.tle = tle + if self.tle is None: + self.tle = SwiftTLE(begin).tle if self.tle is not None: super().__init__(begin=begin, end=end, stepsize=stepsize) diff --git a/python/tests/across/__init__.py b/python/tests/across/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/tests/across/conftest.py b/python/tests/across/conftest.py new file mode 100644 index 000000000..edb6c7a1d --- /dev/null +++ b/python/tests/across/conftest.py @@ -0,0 +1,28 @@ +# Copyright © 2023 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. + +import pytest + + +class ExpectedResolve: + def __init__(self, name: str, ra: float, dec: float, resolver: str): + self.name = name + self.ra = ra + self.dec = dec + self.resolver = resolver + + +@pytest.fixture +def expected_resolve_crab(): + return ExpectedResolve(name="Crab", ra=83.6287, dec=22.0147, resolver="CDS") + + +@pytest.fixture +def expected_resolve_ztf(): + return ExpectedResolve( + name="ZTF17aabwgbz", + ra=95.85514670221599, + dec=-12.322666705084146, + resolver="ANTARES", + ) diff --git a/python/tests/across/test_across_resolve.py b/python/tests/across/test_across_resolve.py new file mode 100644 index 000000000..42276f9e4 --- /dev/null +++ b/python/tests/across/test_across_resolve.py @@ -0,0 +1,19 @@ +# Copyright © 2023 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. + +from across_api.across.resolve import Resolve + + +def test_resolve_cds(expected_resolve_crab): + resolve = Resolve(expected_resolve_crab.name) + assert abs(resolve.ra - expected_resolve_crab.ra) < 0.1 / 3600 + assert abs(resolve.dec - expected_resolve_crab.dec) < 0.1 / 3600 + assert resolve.resolver == expected_resolve_crab.resolver + + +def test_resolve_antares(expected_resolve_ztf): + resolve = Resolve(expected_resolve_ztf.name) + assert abs(resolve.ra - expected_resolve_ztf.ra) < 0.1 / 3600 + assert abs(resolve.dec - expected_resolve_ztf.dec) < 0.1 / 3600 + assert resolve.resolver == expected_resolve_ztf.resolver diff --git a/python/tests/test_env.py b/python/tests/across/test_env.py similarity index 99% rename from python/tests/test_env.py rename to python/tests/across/test_env.py index c208b6718..23ede934c 100644 --- a/python/tests/test_env.py +++ b/python/tests/across/test_env.py @@ -2,7 +2,6 @@ # Administrator of the National Aeronautics and Space Administration. # All Rights Reserved. - from env import feature, get_features diff --git a/python/tests/ephem/__init__.py b/python/tests/ephem/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/tests/ephem/conftest.py b/python/tests/ephem/conftest.py new file mode 100644 index 000000000..aeb90503e --- /dev/null +++ b/python/tests/ephem/conftest.py @@ -0,0 +1,351 @@ +# Copyright © 2023 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. + +import pytest +import numpy as np +from astropy.time import Time # type: ignore +from across_api.burstcube.ephem import BurstCubeEphem # type: ignore +from across_api.burstcube.tle import BurstCubeTLE # type: ignore +from across_api.swift.ephem import SwiftEphem # type: ignore +from across_api.swift.tle import SwiftTLE # type: ignore +from across_api.base.schema import TLEEntry # type: ignore + + +class ExpectedSkyField: + def __init__(self, posvec, velocity, lat, lon, sunra, sundec, moonra, moondec): + self.posvec = posvec + self.velocity = velocity + self.lat = lat + self.lon = lon + self.sunra = sunra + self.sundec = sundec + self.moonra = moonra + self.moondec = moondec + + +@pytest.fixture +def burstcube_ephem(): + # Define a TLE by hand + satname = "ISS (ZARYA)" + tle1 = "1 25544U 98067A 24003.59801929 .00015877 00000-0 28516-3 0 9995" + tle2 = "2 25544 51.6422 55.8239 0003397 348.6159 108.6885 15.50043818432877" + tleentry = TLEEntry(satname=satname, tle1=tle1, tle2=tle2) + + # Manually load this TLE + tle = BurstCubeTLE(epoch=Time("2024-01-01"), tle=tleentry) + + # Calculate a BurstCube Ephemeris + return BurstCubeEphem( + begin=Time("2024-01-01"), end=Time("2024-01-01 00:05:00"), tle=tle.tle + ) + + +@pytest.fixture +def swift_ephem(): + # Define a TLE by hand + satname = "SWIFT" + tle1 = "1 28485U 04047A 24029.43721350 .00012795 00000-0 63383-3 0 9994" + tle2 = "2 28485 20.5570 98.6682 0008279 273.6948 86.2541 15.15248522 52921" + tleentry = TLEEntry(satname=satname, tle1=tle1, tle2=tle2) + + # Manually load this TLE + tle = SwiftTLE(epoch=Time("2024-01-29"), tle=tleentry) + + # Calculate a Swift Ephemeris + return SwiftEphem( + begin=Time("2024-01-29"), end=Time("2024-01-29 00:05:00"), tle=tle.tle + ) + + +@pytest.fixture +def expected_burstcube_skyfield(): + # Compute GCRS position using Skyfield library + # from skyfield.api import load, wgs84, EarthSatellite, utc + + # satname = "ISS (ZARYA)" + # tle1 = "1 25544U 98067A 24003.59801929 .00015877 00000-0 28516-3 0 9995" + # tle2 = "2 25544 51.6422 55.8239 0003397 348.6159 108.6885 15.50043818432877" + # ts = load.timescale() + # satellite = EarthSatellite(tle1, tle2, satname, ts) + # bodies = load("de421.bsp") + # nowts = ts.from_datetimes([dt.replace(tzinfo=utc) for dt in eph.timestamp.datetime]) + # gcrs = satellite.at(nowts) + # posvec = gcrs.position.km + # posvec + + posvec = np.array( + [ + [ + 3102.62364411, + 2862.16585313, + 2608.59848659, + 2343.0807521, + 2066.82742813, + 1781.10319625, + ], + [ + 5981.53068666, + 6140.36702165, + 6271.07862231, + 6373.06152239, + 6445.84422401, + 6489.08998678, + ], + [ + -870.78766934, + -512.60637227, + -152.07061193, + 209.16367558, + 569.43677198, + 927.09298308, + ], + ] + ) + + # Skyfield calculate velocity + # gcrs.velocity.km_per_s + velocity = np.array( + [ + [ + -3.89219421, + -4.12001516, + -4.32900941, + -4.51820512, + -4.68672262, + -4.83377889, + ], + [2.87775303, 2.41476409, 1.94062072, 1.45750803, 0.96765472, 0.47332214], + [5.94091899, 5.99390616, 6.01936913, 6.01717993, 5.98734059, 5.92998321], + ] + ) + + # Calculate lat/lon using Skyfield + # lat, lon = wgs84.latlon_of(gcrs) + + # lat.degrees + lat = np.array( + [-7.34620853, -4.29603822, -1.23756857, 1.82349873, 4.88150001, 7.93070336] + ) + + # lon.degrees + lon = np.array( + [ + -37.27659609, + -35.09670928, + -32.93574936, + -30.78004142, + -28.61598827, + -26.4298605, + ] + ) + + # Calculate Sun position as seen from the satellite orbiting the Earth + # sunpos = (bodies["Earth"] + satellite).at(nowts).observe(bodies["Sun"]) + # sunra = sunpos.apparent().radec()[0]._degrees + # + # sunra + sunra = np.array( + [ + 280.55657134, + 280.55736204, + 280.55816379, + 280.55897644, + 280.55979979, + 280.56063359, + ] + ) + # sundec = sunpos.apparent().radec()[0]._degrees + # + # sundec + sundec = np.array( + [ + -23.07915326, + -23.07915921, + -23.07917361, + -23.07919616, + -23.07922653, + -23.07926433, + ] + ) + + # Calculate Moon position as seen from the satellite orbiting the Earth + # moonpos = (bodies["Earth"] + satellite).at(nowts).observe(bodies["Moon"]) + # + # moonpos.apparent().radec()[0]._degrees + moonra = np.array( + [ + 159.77137018, + 159.78862186, + 159.80141207, + 159.809707, + 159.81349339, + 159.81277874, + ] + ) + # + # moonpos.apparent().radec()[1]._degrees + moondec = np.array( + [12.84719405, 12.80310308, 12.75868635, 12.71413103, 12.6696258, 12.62535993] + ) + + return ExpectedSkyField( + posvec=posvec, + velocity=velocity, + lat=lat, + lon=lon, + sunra=sunra, + sundec=sundec, + moonra=moonra, + moondec=moondec, + ) + + +@pytest.fixture +def expected_swift_skyfield(): + # Compute GCRS position using Skyfield library + # from skyfield.api import load, wgs84, EarthSatellite, utc + + # satname = "SWIFT" + # tle1 = "1 28485U 04047A 24029.43721350 .00012795 00000-0 63383-3 0 9994" + # tle2 = "2 28485 20.5570 98.6682 0008279 273.6948 86.2541 15.15248522 52921" + # ts = load.timescale() + # satellite = EarthSatellite(tle1, tle2, satname, ts) + # bodies = load("de421.bsp") + # nowts = ts.from_datetimes([dt.replace(tzinfo=utc) for dt in eph.timestamp.datetime]) + # gcrs = satellite.at(nowts) + # posvec = gcrs.position.km + # posvec + + posvec = np.array( + [ + [ + -4021.14168009, + -3677.15476197, + -3317.0818494, + -2942.49650402, + -2555.0360961, + -2156.39468954, + ], + [ + -5279.52579435, + -5559.61309208, + -5815.37918607, + -6045.70235929, + -6249.57188609, + -6426.09251345, + ], + [ + 1881.44369991, + 1774.93957664, + 1660.64930775, + 1539.07344591, + 1410.74460051, + 1276.22512307, + ], + ] + ) + + # Skyfield calculate velocity + # gcrs.velocity.km_per_s + velocity = np.array( + [ + [5.59057289, 5.87140084, 6.12657304, 6.3549683, 6.55558167, 6.72752905], + [ + -4.86407618, + -4.46866974, + -4.05367016, + -3.62088727, + -3.17220958, + -2.70959622, + ], + [ + -1.70752377, + -1.84127551, + -1.96696431, + -2.08403741, + -2.19197943, + -2.29031471, + ], + ] + ) + + # Calculate lat/lon using Skyfield + # lat, lon = wgs84.latlon_of(gcrs) + + # lat.degrees + lat = np.array( + [15.83859755, 14.92370425, 13.94588103, 12.90989575, 11.82064667, 10.68313044] + ) + + # lon.degrees + lon = np.array( + [ + 105.23276066, + 108.79628721, + 112.32691818, + 115.82523204, + 119.29234661, + 122.72987384, + ] + ) + + # Calculate Sun position as seen from the satellite orbiting the Earth + # sunpos = (bodies["Earth"] + satellite).at(nowts).observe(bodies["Sun"]) + # sunra = sunpos.apparent().radec()[0]._degrees + # + # sunra + sunra = np.array( + [ + 310.63900074, + 310.63978046, + 310.64054735, + 310.6413012, + 310.64204183, + 310.64276916, + ] + ) + # sundec = sunpos.apparent().radec()[0]._degrees + # + # sundec + sundec = np.array( + [ + -18.20980286, + -18.20966515, + -18.20952402, + -18.20937928, + -18.20923076, + -18.20907831, + ] + ) + + # Calculate Moon position as seen from the satellite orbiting the Earth + # moonpos = (bodies["Earth"] + satellite).at(nowts).observe(bodies["Moon"]) + # + # moonpos.apparent().radec()[0]._degrees + moonra = np.array( + [ + 165.39248665, + 165.37337541, + 165.35826335, + 165.34723799, + 165.34036809, + 165.33770354, + ] + ) + # + # moonpos.apparent().radec()[1]._degrees + moondec = np.array( + [8.71492202, 8.71744169, 8.72084558, 8.72509989, 8.73016706, 8.73600592] + ) + + return ExpectedSkyField( + posvec=posvec, + velocity=velocity, + lat=lat, + lon=lon, + sunra=sunra, + sundec=sundec, + moonra=moonra, + moondec=moondec, + ) diff --git a/python/tests/ephem/test_burstcube_ephem.py b/python/tests/ephem/test_burstcube_ephem.py new file mode 100644 index 000000000..5b96df77f --- /dev/null +++ b/python/tests/ephem/test_burstcube_ephem.py @@ -0,0 +1,72 @@ +# Copyright © 2023 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. + +from astropy.time import Time # type: ignore +import astropy.units as u # type: ignore +from across_api.burstcube.tle import BurstCubeTLE # type: ignore + + +def test_burstcube_ephem_posvec(burstcube_ephem, expected_burstcube_skyfield): + # Check that the Skyfield posvec and BurstCubeEphem posvec match to < 0.1 cm + assert ( + abs(burstcube_ephem.posvec.xyz.value - expected_burstcube_skyfield.posvec) + < 1e-6 + ).all(), "GCRS position vector values off by more than 0.1 cm" + + +def test_burstcube_ephem_velvec(burstcube_ephem, expected_burstcube_skyfield): + # Check that the Skyfield calculated velocity matches the BurstCubeEphem value to < 0.1 cm/s + assert abs( + burstcube_ephem.velvec.xyz.value - expected_burstcube_skyfield.velocity < 1e-6 + ).all(), "GCRS velocity vector values off by more than 0.1 cm/s" + + +def test_burstcube_ephem_latlon(burstcube_ephem, expected_burstcube_skyfield): + # Check Skyfield latitude matches BurstCubeEphem value by < 0.3 arcseconds + assert ( + abs(burstcube_ephem.latitude.deg - expected_burstcube_skyfield.lat) < 0.3 / 3600 + ).all(), "GCRS latitude values off by more than 3 arcseconds" + + # Astropy ITRS and SkyField longitude disagree by ~3 arcseconds, so we set our tolerance to <3 arcseconds. + assert ( + abs(burstcube_ephem.longitude.deg - expected_burstcube_skyfield.lon) < 3 / 3600 + ).all(), "GCRS longitude values off by more than 3 arcseconds" + + +def test_burstcube_ephem_sun(burstcube_ephem, expected_burstcube_skyfield): + # Assert Skyfield Sun RA/Dec is within 5 arc-seconds of Astropy. These numbers don't match, + # but 5 arc-seconds difference is not a huge offset. + assert ( + abs(burstcube_ephem.sun.ra.deg - expected_burstcube_skyfield.sunra) < 5 / 3600.0 + ).all(), "GCRS sun.ra values off by more than 5 arcseconds" + + assert ( + abs(burstcube_ephem.sun.dec.deg - expected_burstcube_skyfield.sundec) + < 5 / 3600.0 + ).all(), "GCRS sun.dec values off by more than 5 arcseconds" + + +def test_burstcube_ephem_moon(burstcube_ephem, expected_burstcube_skyfield): + # Assert Skyfield Moon RA/Dec is within 5 arc-seconds of Astropy. These numbers don't match, + # but 5 arc-seconds difference is not a huge offset. + assert ( + abs(burstcube_ephem.moon.ra.deg - expected_burstcube_skyfield.moonra) < 5 / 3600 + ).all(), "GCRS moon.ra values off by more than 5 arcseconds" + + assert ( + abs(burstcube_ephem.moon.dec.deg - expected_burstcube_skyfield.moondec) + < 5 / 3600 + ).all(), "GCRS moon.dec values off by more than 5 arcseconds" + + +def test_ephem_epoch(burstcube_ephem): + assert (burstcube_ephem.tle.epoch - Time("2024-01-01")) < BurstCubeTLE.tle_bad + + +def test_ephem_length(burstcube_ephem): + assert len(burstcube_ephem.timestamp) == 6 + + +def test_default_stepsize(burstcube_ephem): + assert burstcube_ephem.stepsize == 60 * u.s diff --git a/python/tests/ephem/test_swift_ephem.py b/python/tests/ephem/test_swift_ephem.py new file mode 100644 index 000000000..956b14948 --- /dev/null +++ b/python/tests/ephem/test_swift_ephem.py @@ -0,0 +1,69 @@ +# Copyright © 2023 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. + +from astropy.time import Time # type: ignore +import astropy.units as u # type: ignore +from across_api.swift.tle import SwiftTLE # type: ignore + + +def test_swift_ephem_posvec(swift_ephem, expected_swift_skyfield): + # Check that the Skyfield posvec and SwiftEphem posvec match to < 0.1 cm + assert ( + abs(swift_ephem.posvec.xyz.value - expected_swift_skyfield.posvec) < 1e-6 + ).all(), "GCRS position vector values off by more than 0.1 cm" + + +def test_swift_ephem_velvec(swift_ephem, expected_swift_skyfield): + # Check that the Skyfield calculated velocity matches the SwiftEphem value to < 0.1 cm/s + assert abs( + swift_ephem.velvec.xyz.value - expected_swift_skyfield.velocity < 1e-6 + ).all(), "GCRS velocity vector values off by more than 0.1 cm/s" + + +def test_swift_ephem_latlon(swift_ephem, expected_swift_skyfield): + # Check Skyfield latitude matches SwiftEphem value by < 0.3 arcseconds + assert ( + abs(swift_ephem.latitude.deg - expected_swift_skyfield.lat) < 0.3 / 3600 + ).all(), "GCRS latitude values off by more than 3 arcseconds" + + # Check Skyfield latitude matches SwiftEphem value by < 0.3 arcseconds + assert ( + abs(swift_ephem.longitude.deg - expected_swift_skyfield.lon) < 3 / 3600 + ).all(), "GCRS longitude values off by more than 3 arcseconds" + + +def test_swift_ephem_sun(swift_ephem, expected_swift_skyfield): + # Assert Skyfield Sun RA/Dec is within 5 arc-seconds of Astropy. These numbers don't match, + # but 5 arc-seconds difference is not a huge offset. + assert ( + abs(swift_ephem.sun.ra.deg - expected_swift_skyfield.sunra) < 5 / 3600.0 + ).all(), "GCRS sun.ra values off by more than 5 arcseconds" + + assert ( + abs(swift_ephem.sun.dec.deg - expected_swift_skyfield.sundec) < 5 / 3600.0 + ).all(), "GCRS sun.dec values off by more than 5 arcseconds" + + +def test_swift_ephem_moon(swift_ephem, expected_swift_skyfield): + # Assert Skyfield Moon RA/Dec is within 5 arc-seconds of Astropy. These numbers don't match, + # but 5 arc-seconds difference is not a huge offset. + assert ( + abs(swift_ephem.moon.ra.deg - expected_swift_skyfield.moonra) < 5 / 3600 + ).all(), "GCRS moon.ra values off by more than 5 arcseconds" + + assert ( + abs(swift_ephem.moon.dec.deg - expected_swift_skyfield.moondec) < 5 / 3600 + ).all(), "GCRS moon.dec values off by more than 5 arcseconds" + + +def test_ephem_epoch(swift_ephem): + assert (swift_ephem.tle.epoch - Time("2024-01-29")) < SwiftTLE.tle_bad + + +def test_ephem_length(swift_ephem): + assert len(swift_ephem.timestamp) == 6 + + +def test_default_stepsize(swift_ephem): + assert swift_ephem.stepsize == 60 * u.s diff --git a/requirements.txt b/requirements.txt index 0971779f4..76bfcfa59 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ boto3 mypy ruff +hypothesis pytest types-requests types-cachetools